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.
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 numberx
j
: the furthest index we must reach before considering a cutk
: 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
:
- Update
j = max(j, last[nums[i]])
- this extends our current segment to include all occurrences of the current number - Check if
i == j
- if true, we've reached a point where all numbers seen so far have their last occurrences within this segment - When
i == j
, incrementk
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 EvaluatorExample 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, solast[1] = 4
2
appears at indices 1 and 3, solast[2] = 3
3
appears at index 2, solast[3] = 2
4
appears at index 5, solast[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 i | nums[i] | last[nums[i]] | j = max(j, last[nums[i]]) | i == j? | k |
---|---|---|---|---|---|
0 | 1 | 4 | max(-1, 4) = 4 | No | 0 |
1 | 2 | 3 | max(4, 3) = 4 | No | 0 |
2 | 3 | 2 | max(4, 2) = 4 | No | 0 |
3 | 2 | 3 | max(4, 3) = 4 | No | 0 |
4 | 1 | 4 | max(4, 4) = 4 | Yes | 1 |
5 | 4 | 5 | max(4, 5) = 5 | Yes | 2 |
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:
- Keep everything together:
[1, 2, 3, 2, 1, 4]
- 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:
- Building the
last
dictionary using dictionary comprehension iterates through alln
elements once, takingO(n)
time - 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, takingO(n)
time - The final
pow(2, k-1, mod)
operation uses modular exponentiation which takesO(log k)
time, and sincek ≤ n
, this isO(log n)
Overall time complexity: O(n) + O(n) + O(log n) = O(n)
Space Complexity: O(n)
The space usage comes from:
- The
last
dictionary stores at mostn
key-value pairs (one for each unique element innums
), requiringO(n)
space - Variables
j
,k
, andmod
use constantO(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.
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
Math for Technical Interviews How much math do I need to know for technical interviews The short answer is about high school level math Computer science is often associated with math and some universities even place their computer science department under the math faculty However the reality is that you
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Don’t Miss This!