Facebook Pixel

2963. Count the Number of Good Partitions

Problem Description

You have an array nums containing positive integers (0-indexed). Your task is to find how many ways you can partition this array into contiguous subarrays such that no two subarrays contain the same number.

A partition means dividing the array into one or more contiguous subarrays where:

  • Each element belongs to exactly one subarray
  • The subarrays cover the entire array
  • The subarrays maintain the original order

A partition is considered good if no number appears in more than one subarray. In other words, if a number appears multiple times in the array, all its occurrences must be grouped together in the same subarray.

For example, if you have [1, 2, 1, 3], you cannot split it as [1, 2] | [1, 3] because the number 1 would appear in two different subarrays. Instead, [1, 2, 1] | [3] would be valid since all occurrences of 1 are in the same subarray.

Your goal is to count the total number of different ways to create good partitions of the array. Since this count can be very large, return the result modulo 10^9 + 7.

The key insight is that certain elements act as "anchors" - if a number appears at positions i and j, then all elements between positions i and j must be in the same subarray. This creates natural groupings in the array, and you need to count how many ways these groupings can be combined or separated to form valid partitions.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

Let's think about what makes a partition "good". If a number appears multiple times in the array, all its occurrences must stay together in the same subarray. This is the key constraint.

For each number, we need to know where it last appears in the array. Why? Because if a number appears at index i and its last occurrence is at index j, then the segment from i to j must be kept together - we cannot split anywhere between them.

Consider the array [1, 2, 3, 2, 1, 4]. The number 1 appears at indices 0 and 4, so indices 0 through 4 must be in the same subarray. The number 2 appears at indices 1 and 3, which are already within the range [0, 4]. So the minimum segment that must stay together is [0, 4].

As we traverse the array from left to right, we can track the rightmost boundary that we must reach before we can make a cut. When we reach this boundary (when current index equals the rightmost boundary), we've found a position where we can potentially make a partition.

Each such position represents a choice: we can either make a cut here or continue without cutting. If we identify k such positions where cuts are possible, we have k segments that could be independent subarrays.

Between k segments, there are k-1 positions where we can choose to cut or not cut. Each position gives us 2 choices (cut or don't cut), so the total number of ways to partition is 2^(k-1).

The algorithm tracks:

  • last[x]: the last occurrence index of each number x
  • j: the furthest index we must reach before considering a cut
  • k: the count of positions where we can potentially make a cut

When we encounter a number at index i, we update j to be the maximum of current j and the last occurrence of this number. If i == j, we've reached a boundary where all constraints are satisfied, and we can potentially partition here.

Learn more about Math and Combinatorics patterns.

Solution Approach

The solution uses a hash table combined with a greedy approach to identify partition points, followed by fast power calculation to count the total number of valid partitions.

Step 1: Build the Last Occurrence Map

First, we create a hash table last that maps each number to its last occurrence index in the array:

last = {x: i for i, x in enumerate(nums)}

This preprocessing step allows us to quickly look up where each number last appears, which is crucial for determining partition boundaries.

Step 2: Identify Partition Points

We traverse the array from left to right, maintaining two key variables:

  • j: The maximum index we must reach before we can make a partition (initialized to -1)
  • k: The count of positions where we can potentially partition

For each element at index i:

  1. Update j = max(j, last[nums[i]]) - this extends our current segment to include all occurrences of the current number
  2. Check if i == j - if true, we've reached a point where all numbers seen so far have their last occurrences within this segment
  3. When i == j, increment k by 1 as this is a valid partition point
j, k = -1, 0
for i, x in enumerate(nums):
    j = max(j, last[x])
    k += i == j

Step 3: Calculate Total Partitions

After identifying k partition points, we have k segments. Between these k segments, there are k-1 positions where we can choose to either make a cut or not. Each position gives us 2 choices, resulting in 2^(k-1) total ways to partition the array.

We use Python's built-in pow function with three arguments for modular exponentiation:

return pow(2, k - 1, mod)

This efficiently computes 2^(k-1) mod (10^9 + 7) using fast power algorithm, avoiding overflow for large values of k.

Time Complexity: O(n) where n is the length of the array - we traverse the array twice (once to build the hash table, once to find partition points).

Space Complexity: O(n) for storing the hash table of last occurrences.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through the solution with the array nums = [1, 2, 3, 2, 1, 4].

Step 1: Build Last Occurrence Map

First, we create a hash table mapping each number to its last occurrence index:

  • 1 appears at indices 0 and 4, so last[1] = 4
  • 2 appears at indices 1 and 3, so last[2] = 3
  • 3 appears at index 2, so last[3] = 2
  • 4 appears at index 5, so last[4] = 5

Result: last = {1: 4, 2: 3, 3: 2, 4: 5}

Step 2: Identify Partition Points

Now we traverse the array to find valid partition points. We track:

  • j: the furthest index we must reach (initially -1)
  • k: count of partition points (initially 0)
Index inums[i]last[nums[i]]j = max(j, last[nums[i]])i == j?k
014max(-1, 4) = 4No0
123max(4, 3) = 4No0
232max(4, 2) = 4No0
323max(4, 3) = 4No0
414max(4, 4) = 4Yes1
545max(4, 5) = 5Yes2

At index 4, we have i == j, meaning we've included all occurrences of numbers 1, 2, and 3. This is our first partition point.

At index 5, we again have i == j, giving us a second partition point.

Step 3: Calculate Total Partitions

We found k = 2 partition points, which means we have 2 segments:

  • Segment 1: indices [0, 4] containing [1, 2, 3, 2, 1]
  • Segment 2: index [5] containing [4]

Between 2 segments, there is 1 position where we can choose to cut or not cut.

Number of ways = 2^(k-1) = 2^(2-1) = 2^1 = 2

The two valid partitions are:

  1. Keep everything together: [1, 2, 3, 2, 1, 4]
  2. Split after index 4: [1, 2, 3, 2, 1] | [4]

Both partitions are valid because in each case, all occurrences of each number remain in the same subarray. The answer is 2.

Solution Implementation

1class Solution:
2    def numberOfGoodPartitions(self, nums: List[int]) -> int:
3        # Create a dictionary mapping each element to its last occurrence index
4        last_occurrence = {element: index for index, element in enumerate(nums)}
5      
6        # Define the modulo value for the result
7        MOD = 10**9 + 7
8      
9        # Initialize variables:
10        # max_reach: tracks the furthest index we need to include in current partition
11        # partition_count: counts the number of partition boundaries
12        max_reach = -1
13        partition_count = 0
14      
15        # Iterate through the array
16        for current_index, element in enumerate(nums):
17            # Update max_reach to ensure all occurrences of current element are in same partition
18            max_reach = max(max_reach, last_occurrence[element])
19          
20            # If current index equals max_reach, we've completed a mandatory partition
21            if current_index == max_reach:
22                partition_count += 1
23      
24        # Between k mandatory partitions, there are k-1 gaps where we can choose to split or not
25        # Each gap has 2 choices (split or not split), giving us 2^(k-1) total combinations
26        return pow(2, partition_count - 1, MOD)
27
1class Solution {
2    public int numberOfGoodPartitions(int[] nums) {
3        // Map to store the last occurrence index of each element
4        Map<Integer, Integer> lastOccurrenceMap = new HashMap<>();
5        int arrayLength = nums.length;
6      
7        // Build the map of last occurrences for each element
8        for (int i = 0; i < arrayLength; i++) {
9            lastOccurrenceMap.put(nums[i], i);
10        }
11      
12        final int MOD = (int) 1e9 + 7;
13      
14        // Track the rightmost boundary of current partition
15        int currentPartitionEnd = -1;
16        // Count the number of valid partition points
17        int partitionCount = 0;
18      
19        // Iterate through the array to find valid partition points
20        for (int i = 0; i < arrayLength; i++) {
21            // Extend the current partition to include all occurrences of nums[i]
22            currentPartitionEnd = Math.max(currentPartitionEnd, lastOccurrenceMap.get(nums[i]));
23          
24            // If we've reached the end of current partition, increment partition count
25            if (i == currentPartitionEnd) {
26                partitionCount++;
27            }
28        }
29      
30        // Calculate 2^(partitionCount - 1) mod MOD
31        // This represents the number of ways to choose partition boundaries
32        return modularPower(2, partitionCount - 1, MOD);
33    }
34
35    /**
36     * Calculates (base^exponent) % modulo using fast exponentiation
37     * @param base the base number
38     * @param exponent the power to raise the base to
39     * @param modulo the modulo value
40     * @return (base^exponent) % modulo
41     */
42    private int modularPower(long base, int exponent, int modulo) {
43        long result = 1;
44      
45        // Fast exponentiation algorithm
46        while (exponent > 0) {
47            // If current bit is 1, multiply result by base
48            if ((exponent & 1) == 1) {
49                result = (result * base) % modulo;
50            }
51            // Square the base for next bit
52            base = (base * base) % modulo;
53            // Right shift to process next bit
54            exponent >>= 1;
55        }
56      
57        return (int) result;
58    }
59}
60
1class Solution {
2public:
3    int numberOfGoodPartitions(vector<int>& nums) {
4        // Map to store the last occurrence index of each element
5        unordered_map<int, int> lastOccurrence;
6        int n = nums.size();
7      
8        // Find the last occurrence index for each element in the array
9        for (int i = 0; i < n; ++i) {
10            lastOccurrence[nums[i]] = i;
11        }
12      
13        const int MOD = 1e9 + 7;
14      
15        // maxRight: tracks the rightmost boundary of current partition
16        // partitionCount: counts the number of required partition points
17        int maxRight = -1;
18        int partitionCount = 0;
19      
20        // Iterate through the array to find partition points
21        for (int i = 0; i < n; ++i) {
22            // Extend the current partition to include all occurrences of nums[i]
23            maxRight = max(maxRight, lastOccurrence[nums[i]]);
24          
25            // If we've reached the end of current partition, increment count
26            if (i == maxRight) {
27                partitionCount++;
28            }
29        }
30      
31        // Fast exponentiation function to calculate (base^exponent) % mod
32        auto fastPower = [&](long long base, int exponent, int mod) -> int {
33            long long result = 1;
34          
35            while (exponent > 0) {
36                // If exponent is odd, multiply result by base
37                if (exponent & 1) {
38                    result = (result * base) % mod;
39                }
40                // Square the base and halve the exponent
41                base = (base * base) % mod;
42                exponent >>= 1;
43            }
44          
45            return static_cast<int>(result);
46        };
47      
48        // The number of good partitions is 2^(partitionCount - 1)
49        // This is because we have (partitionCount - 1) positions where we can choose
50        // to either split or not split, giving us 2^(partitionCount - 1) possibilities
51        return fastPower(2, partitionCount - 1, MOD);
52    }
53};
54
1/**
2 * Counts the number of good partitions in an array
3 * A good partition is one where no element appears in multiple parts
4 * @param nums - Input array of numbers
5 * @returns Number of good partitions modulo 10^9 + 7
6 */
7function numberOfGoodPartitions(nums: number[]): number {
8    /**
9     * Computes (base^exponent) % modulo using fast exponentiation
10     * @param base - Base number
11     * @param exponent - Power to raise the base to
12     * @param modulo - Modulo value
13     * @returns Result of (base^exponent) % modulo
14     */
15    const quickPower = (base: number, exponent: number, modulo: number): number => {
16        let result = 1;
17      
18        // Process each bit of the exponent
19        while (exponent > 0) {
20            // If current bit is 1, multiply result by base
21            if (exponent & 1) {
22                result = Number((BigInt(result) * BigInt(base)) % BigInt(modulo));
23            }
24            // Square the base for next bit position
25            base = Number((BigInt(base) * BigInt(base)) % BigInt(modulo));
26            // Shift exponent right by 1 bit
27            exponent >>= 1;
28        }
29      
30        return result;
31    };
32  
33    // Map to store the last occurrence index of each number
34    const lastOccurrenceMap: Map<number, number> = new Map();
35    const arrayLength = nums.length;
36  
37    // Record the last occurrence index for each unique number
38    for (let i = 0; i < arrayLength; i++) {
39        lastOccurrenceMap.set(nums[i], i);
40    }
41  
42    const MODULO = 1e9 + 7;
43  
44    // Track the rightmost boundary of current segment and count of segments
45    let rightBoundary = -1;
46    let segmentCount = 0;
47  
48    // Iterate through array to find independent segments
49    for (let i = 0; i < arrayLength; i++) {
50        // Extend right boundary to include all occurrences of current element
51        rightBoundary = Math.max(rightBoundary, lastOccurrenceMap.get(nums[i])!);
52      
53        // If we've reached the end of current segment, increment counter
54        if (i === rightBoundary) {
55            segmentCount++;
56        }
57    }
58  
59    // Number of ways to partition k segments is 2^(k-1)
60    return quickPower(2, segmentCount - 1, MODULO);
61}
62

Time and Space Complexity

Time Complexity: O(n)

The algorithm consists of two main passes through the array:

  1. Building the last dictionary using dictionary comprehension iterates through all n elements once, taking O(n) time
  2. The main loop iterates through the array once more with enumerate(nums), performing constant-time operations (max(), dictionary lookup, comparison, and increment) at each step, taking O(n) time
  3. The final pow(2, k-1, mod) operation uses modular exponentiation which takes O(log k) time, and since k ≤ n, this is O(log n)

Overall time complexity: O(n) + O(n) + O(log n) = O(n)

Space Complexity: O(n)

The space usage comes from:

  1. The last dictionary stores at most n key-value pairs (one for each unique element in nums), requiring O(n) space
  2. Variables j, k, and mod use constant O(1) space

Overall space complexity: O(n) + O(1) = O(n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Handling of Edge Cases with Single Element Arrays

Pitfall: Not properly handling arrays with only one element or arrays where all elements are the same. The formula 2^(k-1) can produce incorrect results when k = 0.

Example: For nums = [1], the code correctly identifies k = 1 partition point, resulting in 2^(1-1) = 2^0 = 1, which is correct. However, if the logic for counting partition points is slightly modified incorrectly, you might get k = 0, leading to 2^(-1) which would cause an error or incorrect result.

Solution: Always ensure that at least one partition point is counted (the end of the array). The current implementation handles this correctly by checking i == j at each position.

2. Off-by-One Error in Counting Partition Points

Pitfall: Confusing the number of partition points with the number of partitions. If you have k partition points, you actually have k segments, but only k-1 positions where you can choose to make or not make a cut.

Incorrect approach:

# Wrong: Using k instead of k-1
return pow(2, partition_count, MOD)  # This would give 2^k instead of 2^(k-1)

Solution: Remember that between k mandatory segments, there are exactly k-1 decision points. The formula should always be 2^(k-1).

3. Integer Overflow When Computing Powers

Pitfall: Using regular exponentiation without modulo can cause integer overflow for large values of k:

# Wrong: Can cause overflow for large k
result = (2 ** (partition_count - 1)) % MOD

Solution: Always use modular exponentiation with three-argument pow():

# Correct: Efficiently computes (2^(k-1)) mod MOD
return pow(2, partition_count - 1, MOD)

4. Misunderstanding the Partition Boundary Logic

Pitfall: Incorrectly updating the max_reach variable or checking partition boundaries. A common mistake is resetting max_reach after finding a partition point:

# Wrong: Resetting max_reach after each partition
for current_index, element in enumerate(nums):
    max_reach = max(max_reach, last_occurrence[element])
    if current_index == max_reach:
        partition_count += 1
        max_reach = -1  # WRONG: This would break the logic

Solution: Never reset max_reach. It should continuously track the furthest point we need to reach based on all elements seen so far. The variable naturally "resets" conceptually when we pass a partition boundary because the next element's last occurrence becomes the new constraint.

5. Using Sets Instead of Last Occurrence Tracking

Pitfall: Trying to track which elements have been "completed" using a set, which adds unnecessary complexity and can lead to incorrect results:

# Wrong approach: Overly complex and error-prone
seen = set()
completed = set()
for i, x in enumerate(nums):
    seen.add(x)
    # Complex logic to track when all occurrences are seen...

Solution: Stick to the simple approach of tracking the last occurrence index. This single piece of information per element is sufficient to determine all partition boundaries.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

How would you design a stack which has a function min that returns the minimum element in the stack, in addition to push and pop? All push, pop, min should have running time O(1).


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More