Facebook Pixel

659. Split Array into Consecutive Subsequences

Problem Description

You have an integer array nums that is sorted in non-decreasing order (like [1, 2, 2, 3, 3, 4, 4, 5]).

Your task is to determine if you can split all elements of this array into groups (subsequences) where:

  1. Each group forms a consecutive increasing sequence: The numbers in each group must be consecutive integers that increase by exactly 1. For example, [3, 4, 5] is valid, but [3, 5, 6] is not (missing 4).

  2. Each group must have at least 3 elements: Groups like [1, 2] are too short and not allowed.

The key point is that you're selecting elements from the original array to form these groups, and you can skip elements as long as you maintain the relative order. Every element in nums must be used exactly once in exactly one group.

For example:

  • nums = [1, 2, 3, 3, 4, 5] can be split into [1, 2, 3] and [3, 4, 5], so return true
  • nums = [1, 2, 3, 3, 4, 4, 5, 5] can be split into [1, 2, 3, 4, 5] and [3, 4, 5], so return true
  • nums = [1, 2, 3, 4, 4, 5] cannot be split according to the rules, so return false

The solution uses a greedy approach with heaps. For each number, it either extends an existing sequence that ends at v-1, or starts a new sequence. It tracks the length of sequences ending at each value using min-heaps. At the end, it checks if all sequences have length greater than 2.

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

Intuition

When we encounter each number in the sorted array, we face a critical decision: should this number extend an existing sequence or start a new one?

The key insight is that we should always prefer to extend an existing sequence rather than starting a new one. Why? Because starting a new sequence creates an additional constraint - we'll need to find at least two more consecutive numbers to complete it. By extending an existing sequence, we're making progress toward satisfying the length requirement.

Consider what happens when we see a number v:

  • If there's a sequence ending at v-1, we can extend it by adding v
  • If no sequence ends at v-1, we have no choice but to start a new sequence beginning with v

But what if multiple sequences end at v-1? Which one should we extend? Here's the crucial observation: we should extend the shortest sequence first. This is because shorter sequences are at greater risk of not meeting the minimum length requirement of 3. By prioritizing them, we maximize our chances of making all sequences valid.

This naturally leads to using a min-heap (priority queue) data structure. For each value, we maintain a min-heap of sequence lengths ending at that value. When we process a number v:

  1. Check if any sequences end at v-1
  2. If yes, pop the shortest one (using heappop), increment its length, and push it to the heap for value v
  3. If no, start a new sequence of length 1 at value v

After processing all numbers, we simply verify that every sequence has reached a length of at least 3. The beauty of this approach is that it greedily makes the locally optimal choice at each step, which turns out to give us the globally optimal solution.

Learn more about Greedy and Heap (Priority Queue) patterns.

Solution Approach

The implementation uses a greedy algorithm with min-heaps to track and extend sequences optimally.

Data Structure:

  • d = defaultdict(list): A dictionary where each key is a number and the value is a min-heap containing lengths of all sequences ending at that number.

Algorithm Steps:

  1. Iterate through each number v in the sorted array:

    for v in nums:
  2. Check if we can extend an existing sequence:

    if h := d[v - 1]:
        heappush(d[v], heappop(h) + 1)
    • Use the walrus operator := to check if there are sequences ending at v-1
    • If yes, pop the shortest sequence from d[v-1] using heappop(h)
    • Extend this sequence by incrementing its length by 1
    • Push the extended sequence to d[v] using heappush
  3. Start a new sequence if no extension is possible:

    else:
        heappush(d[v], 1)
    • If no sequences end at v-1, start a new sequence of length 1 at value v
  4. Validate all sequences:

    return all(not v or v and v[0] > 2 for v in d.values())
    • Check each heap in the dictionary
    • not v: Empty heap is valid (no sequences ending at this value)
    • v and v[0] > 2: If heap exists, the minimum length (at index 0) must be greater than 2
    • The all() function ensures every sequence meets the length requirement

Why This Works:

  • By always extending the shortest sequence first (using min-heap), we maximize the chance that all sequences reach the minimum required length
  • The greedy choice at each step (extend shortest or start new) leads to the optimal global solution
  • Since the array is sorted, we process numbers in order, ensuring we can only extend sequences in a valid consecutive manner

Time Complexity: O(n log n) where n is the length of nums, due to heap operations Space Complexity: O(n) for storing the dictionary and heaps

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's walk through the algorithm with nums = [1, 2, 3, 3, 4, 5] to see how it determines this can be split into valid groups.

Initial state: d = {} (empty dictionary)

Step 1: Process v = 1

  • Check if sequences end at v-1 = 0: No sequences at d[0]
  • Start new sequence: d[1] = [1] (sequence of length 1 ending at 1)

Step 2: Process v = 2

  • Check if sequences end at v-1 = 1: Yes! d[1] = [1]
  • Extend shortest sequence: Pop length 1 from d[1], add 1, push to d[2]
  • Result: d[1] = [], d[2] = [2] (sequence of length 2 ending at 2)

Step 3: Process v = 3

  • Check if sequences end at v-1 = 2: Yes! d[2] = [2]
  • Extend shortest sequence: Pop length 2 from d[2], add 1, push to d[3]
  • Result: d[2] = [], d[3] = [3] (sequence of length 3 ending at 3)

Step 4: Process v = 3 (second occurrence)

  • Check if sequences end at v-1 = 2: No sequences at d[2] (empty now)
  • Start new sequence: d[3] = [3, 1] → After heappush: d[3] = [1, 3]
  • Now we have two sequences ending at 3: one of length 3, one of length 1

Step 5: Process v = 4

  • Check if sequences end at v-1 = 3: Yes! d[3] = [1, 3]
  • Extend shortest sequence: Pop length 1 (the minimum), add 1, push to d[4]
  • Result: d[3] = [3], d[4] = [2] (extended the newer sequence)

Step 6: Process v = 5

  • Check if sequences end at v-1 = 4: Yes! d[4] = [2]
  • Extend shortest sequence: Pop length 2, add 1, push to d[5]
  • Result: d[4] = [], d[5] = [3]

Final validation:

  • d[1] = [] (empty, valid)
  • d[2] = [] (empty, valid)
  • d[3] = [3] (minimum length is 3, valid ✓)
  • d[4] = [] (empty, valid)
  • d[5] = [3] (minimum length is 3, valid ✓)

All sequences have length ≥ 3, so return true.

The algorithm successfully identified two sequences: [1, 2, 3] (the first three elements) and [3, 4, 5] (elements at indices 3, 4, 5).

Solution Implementation

1from collections import defaultdict
2from heapq import heappush, heappop
3from typing import List
4
5class Solution:
6    def isPossible(self, nums: List[int]) -> bool:
7        # Dictionary to store min-heaps of subsequence lengths ending at each number
8        # Key: ending number of subsequence
9        # Value: min-heap of lengths of subsequences ending at this number
10        subsequence_lengths = defaultdict(list)
11      
12        # Process each number in the input array
13        for current_num in nums:
14            # Check if there's any subsequence ending at (current_num - 1)
15            # that we can extend with current_num
16            if subsequence_lengths[current_num - 1]:
17                # Get the shortest subsequence ending at (current_num - 1)
18                shortest_length = heappop(subsequence_lengths[current_num - 1])
19                # Extend it by 1 and add to subsequences ending at current_num
20                heappush(subsequence_lengths[current_num], shortest_length + 1)
21            else:
22                # No subsequence to extend, start a new subsequence of length 1
23                heappush(subsequence_lengths[current_num], 1)
24      
25        # Check if all subsequences have length > 2 (at least 3 elements)
26        for heap in subsequence_lengths.values():
27            # If heap is empty, skip it
28            if not heap:
29                continue
30            # If the shortest subsequence (heap[0]) has length <= 2, return False
31            if heap[0] <= 2:
32                return False
33      
34        return True
35
1class Solution {
2    public boolean isPossible(int[] nums) {
3        // Map to store ending number -> priority queue of subsequence lengths ending at that number
4        // Priority queue keeps shortest subsequence length at the top (min heap)
5        Map<Integer, PriorityQueue<Integer>> endingNumberToLengths = new HashMap<>();
6      
7        // Process each number in the input array
8        for (int currentNum : nums) {
9            // Check if there's a subsequence ending at (currentNum - 1) that we can extend
10            if (endingNumberToLengths.containsKey(currentNum - 1)) {
11                // Get the priority queue of subsequences ending at (currentNum - 1)
12                PriorityQueue<Integer> previousQueue = endingNumberToLengths.get(currentNum - 1);
13              
14                // Remove the shortest subsequence and extend it by 1
15                int shortestLength = previousQueue.poll();
16              
17                // Add the extended subsequence to subsequences ending at currentNum
18                endingNumberToLengths.computeIfAbsent(currentNum, k -> new PriorityQueue<>())
19                    .offer(shortestLength + 1);
20              
21                // Clean up: remove entry if no more subsequences end at (currentNum - 1)
22                if (previousQueue.isEmpty()) {
23                    endingNumberToLengths.remove(currentNum - 1);
24                }
25            } else {
26                // No subsequence to extend, start a new subsequence of length 1
27                endingNumberToLengths.computeIfAbsent(currentNum, k -> new PriorityQueue<>())
28                    .offer(1);
29            }
30        }
31      
32        // Check if all subsequences have length at least 3
33        for (PriorityQueue<Integer> lengths : endingNumberToLengths.values()) {
34            // If the shortest subsequence is less than 3, return false
35            if (lengths.peek() < 3) {
36                return false;
37            }
38        }
39      
40        // All subsequences have valid length (>= 3)
41        return true;
42    }
43}
44
1class Solution {
2public:
3    bool isPossible(vector<int>& nums) {
4        // Map to store ending values and their corresponding subsequence lengths
5        // Key: ending value of subsequences
6        // Value: min-heap of lengths of subsequences ending at this value
7        unordered_map<int, priority_queue<int, vector<int>, greater<int>>> endingValueToLengths;
8      
9        // Process each number in the input array
10        for (int currentNum : nums) {
11            // Check if there's a subsequence ending at (currentNum - 1)
12            if (endingValueToLengths.count(currentNum - 1)) {
13                // Get the min-heap of subsequences ending at (currentNum - 1)
14                auto& previousSubsequences = endingValueToLengths[currentNum - 1];
15              
16                // Extend the shortest subsequence by adding current number
17                int shortestLength = previousSubsequences.top();
18                endingValueToLengths[currentNum].push(shortestLength + 1);
19              
20                // Remove the extended subsequence from previous ending value
21                previousSubsequences.pop();
22              
23                // Clean up empty entries
24                if (previousSubsequences.empty()) {
25                    endingValueToLengths.erase(currentNum - 1);
26                }
27            } else {
28                // Start a new subsequence with current number
29                endingValueToLengths[currentNum].push(1);
30            }
31        }
32      
33        // Check if all subsequences have length >= 3
34        for (const auto& [endingValue, lengthsHeap] : endingValueToLengths) {
35            // If any subsequence has length < 3, return false
36            if (lengthsHeap.top() < 3) {
37                return false;
38            }
39        }
40      
41        return true;
42    }
43};
44
1function isPossible(nums: number[]): boolean {
2    // Map to store ending values and their corresponding subsequence lengths
3    // Key: ending value of subsequences
4    // Value: array of lengths of subsequences ending at this value (simulating min-heap)
5    const endingValueToLengths: Map<number, number[]> = new Map();
6  
7    // Helper function to get the minimum value from array (simulating heap.top())
8    const getMinLength = (lengths: number[]): number => {
9        return Math.min(...lengths);
10    };
11  
12    // Helper function to remove the minimum value from array (simulating heap.pop())
13    const removeMinLength = (lengths: number[]): void => {
14        const minValue = Math.min(...lengths);
15        const index = lengths.indexOf(minValue);
16        lengths.splice(index, 1);
17    };
18  
19    // Process each number in the input array
20    for (const currentNum of nums) {
21        // Check if there's a subsequence ending at (currentNum - 1)
22        if (endingValueToLengths.has(currentNum - 1)) {
23            // Get the array of subsequences ending at (currentNum - 1)
24            const previousSubsequences = endingValueToLengths.get(currentNum - 1)!;
25          
26            // Extend the shortest subsequence by adding current number
27            const shortestLength = getMinLength(previousSubsequences);
28          
29            // Initialize array for current number if it doesn't exist
30            if (!endingValueToLengths.has(currentNum)) {
31                endingValueToLengths.set(currentNum, []);
32            }
33            endingValueToLengths.get(currentNum)!.push(shortestLength + 1);
34          
35            // Remove the extended subsequence from previous ending value
36            removeMinLength(previousSubsequences);
37          
38            // Clean up empty entries
39            if (previousSubsequences.length === 0) {
40                endingValueToLengths.delete(currentNum - 1);
41            }
42        } else {
43            // Start a new subsequence with current number
44            if (!endingValueToLengths.has(currentNum)) {
45                endingValueToLengths.set(currentNum, []);
46            }
47            endingValueToLengths.get(currentNum)!.push(1);
48        }
49    }
50  
51    // Check if all subsequences have length >= 3
52    for (const [endingValue, lengthsArray] of endingValueToLengths) {
53        // If any subsequence has length < 3, return false
54        if (getMinLength(lengthsArray) < 3) {
55            return false;
56        }
57    }
58  
59    return true;
60}
61

Time and Space Complexity

Time Complexity: O(n log n)

The algorithm iterates through the input array nums once, performing the following operations for each element:

  • Checking if d[v - 1] exists: O(1) average case for dictionary lookup
  • heappop operation: O(log k) where k is the size of the heap at key v - 1
  • heappush operation: O(log m) where m is the size of the heap at key v

In the worst case, all elements could end up in the same heap, making the heap operations O(log n). Since we perform these operations for each of the n elements, the overall time complexity is O(n log n).

The final validation step iterates through all dictionary values, checking the minimum element of each heap. Since accessing the minimum of a heap is O(1) and we have at most n distinct keys, this step is O(n).

Therefore, the total time complexity is O(n log n) + O(n) = O(n log n).

Space Complexity: O(n)

The space is used by:

  • The dictionary d which stores heaps: In the worst case, we have n distinct values as keys, and the total number of elements across all heaps equals n (since each input element is pushed exactly once)
  • The heaps themselves store integers representing sequence lengths

The total space required is O(n) for storing all the elements across the dictionary and its heaps.

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

Common Pitfalls

1. Not Understanding Why Greedy Extension Works

Many developers struggle to understand why always extending the shortest sequence is optimal. The key insight is that by extending the shortest sequences first, we give them the best chance to reach the minimum required length of 3. If we extended longer sequences instead, shorter ones might get "stranded" without enough consecutive numbers to reach length 3.

Example of Wrong Approach:

# WRONG: Extending any available sequence randomly
if subsequence_lengths[current_num - 1]:
    # Just pop any sequence, not necessarily the shortest
    some_length = subsequence_lengths[current_num - 1].pop()  # Wrong!
    subsequence_lengths[current_num].append(some_length + 1)

2. Forgetting to Use a Min-Heap

A critical pitfall is using a regular list instead of a heap, which breaks the algorithm's correctness.

Incorrect Implementation:

# WRONG: Using regular lists instead of heaps
subsequence_lengths = defaultdict(list)
for current_num in nums:
    if subsequence_lengths[current_num - 1]:
        # This doesn't get the minimum!
        length = subsequence_lengths[current_num - 1].pop(0)  # Wrong!
        subsequence_lengths[current_num].append(length + 1)

Correct Fix: Always use heappush and heappop to maintain the min-heap property.

3. Incorrect Final Validation

A subtle but common mistake is checking the wrong condition at the end.

Wrong Validation Examples:

# WRONG: Checking if ANY sequence is valid
return any(not v or min(v) > 2 for v in subsequence_lengths.values())

# WRONG: Checking maximum instead of minimum
return all(not v or max(v) > 2 for v in subsequence_lengths.values())

# WRONG: Off-by-one error in length check
return all(not v or v[0] >= 2 for v in subsequence_lengths.values())  # Should be > 2

Correct Validation:

# Check that ALL sequences have length > 2 (at least 3 elements)
return all(not heap or heap[0] > 2 for heap in subsequence_lengths.values())

4. Misunderstanding Empty Heaps in Final Check

Some developers get confused about why we need to check not heap in the final validation. Empty heaps are valid because they mean no sequences end at that particular number - all sequences that passed through that number have been extended further.

Overly Complicated Check:

# Unnecessary: Filtering out empty heaps
non_empty_heaps = [heap for heap in subsequence_lengths.values() if heap]
return all(heap[0] > 2 for heap in non_empty_heaps)

While this works, the simpler not heap or heap[0] > 2 pattern is cleaner and more idiomatic.

5. Not Handling Edge Cases Properly

Failing to consider arrays where all numbers are the same or have large gaps.

Example Edge Cases to Consider:

  • nums = [1, 1, 1, 2, 2, 2, 3, 3, 3] - All numbers appear exactly 3 times
  • nums = [1, 2, 3, 6, 7, 8] - Gap between consecutive sequences
  • nums = [1] - Single element (should return False)

The algorithm handles these correctly, but understanding why helps avoid modifications that break these cases.

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

Which of the following shows the order of node visit in a Breadth-first Search?


Recommended Readings

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

Load More