LeetCode 659 - Split Array into Consecutive Subsequences
The problem gives us a sorted integer array nums, and asks whether we can divide every element into one or more subsequences that satisfy two conditions. First, each subsequence must consist of consecutive increasing integers.
Difficulty: 🟡 Medium
Topics: Array, Hash Table, Greedy, Heap (Priority Queue)
Solution
Problem Understanding
The problem gives us a sorted integer array nums, and asks whether we can divide every element into one or more subsequences that satisfy two conditions.
First, each subsequence must consist of consecutive increasing integers. This means every next value must be exactly 1 larger than the previous value. For example, [3,4,5] is valid, while [3,5,6] is not.
Second, every subsequence must have length at least 3. Short subsequences like [1,2] are invalid, even if they are consecutive.
The important detail is that every number in the array must belong to exactly one subsequence. We are not allowed to discard elements.
Because the input array is already sorted in non-decreasing order, we can process the numbers from left to right greedily. The sorted order is extremely important because consecutive subsequences naturally depend on processing smaller numbers before larger ones.
The constraints are relatively small, nums.length <= 10^4, but large enough that exponential or heavy backtracking solutions will not work efficiently. We need something close to linear time.
Several edge cases are important:
- Duplicate numbers can appear many times, which means we may need to maintain multiple subsequences ending at the same value.
- Some numbers may not be extendable into valid subsequences, especially near the end of the array.
- A naive strategy that always starts new subsequences can fail because short unfinished subsequences may remain.
- Arrays with fewer than three total elements are automatically impossible unless they are empty, which the constraints do not allow.
For example, [1,2,3,4,4,5] fails because one 4 can extend [1,2,3] into [1,2,3,4,5], but the second 4 cannot form a valid subsequence of length at least 3.
Approaches
Brute Force Approach
A brute-force solution would try all possible ways to assign each number to a subsequence. For every element, we could decide whether to append it to an existing subsequence or start a new one.
This approach is correct because it explores every possible partitioning of the array. Eventually, if a valid arrangement exists, the search would discover it.
However, the number of possible assignments grows exponentially. With duplicates and many branching choices, the recursion tree becomes enormous. Even moderate input sizes become infeasible.
For example, if several subsequences could legally accept the current number, brute force explores all of them independently. This leads to exponential time complexity.
Optimal Greedy Approach
The key observation is that extending an existing subsequence is always more important than starting a new one.
Suppose we already have a subsequence ending in x-1. When we encounter x, extending that subsequence is safer than creating a new subsequence starting at x. Why? Because short subsequences are dangerous. A subsequence of length 1 or 2 still needs future numbers to become valid.
If we unnecessarily start new subsequences, we may leave older subsequences stranded before reaching length 3.
This naturally leads to a greedy strategy:
- Always try to extend an existing subsequence ending at
num - 1. - Only start a new subsequence if extension is impossible.
- When starting a new subsequence, immediately verify that
num + 1andnum + 2exist, because every subsequence must eventually reach length at least3.
We maintain two hash maps:
frequency[num]tracks how many copies of each number remain unused.subsequences[num]tracks how many subsequences currently end atnum.
This allows efficient greedy decisions in linear time.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(2^n) or worse | O(n) | Tries all possible subsequence assignments |
| Optimal | O(n) | O(n) | Greedily extends existing subsequences before creating new ones |
Algorithm Walkthrough
- Count the frequency of every number in the array.
We use a hash map called frequency. This tells us how many unused copies of each value are still available.
For example:
nums = [1,2,3,3,4,5]
frequency:
1 -> 1
2 -> 1
3 -> 2
4 -> 1
5 -> 1
- Create another hash map to track active subsequences.
We use subsequences[end] to represent how many subsequences currently end with the value end.
Initially, no subsequences exist. 3. Process the array from left to right.
Since the array is sorted, we always process smaller numbers before larger ones. 4. Skip numbers that have already been used.
If frequency[num] == 0, then all copies of this number have already been assigned to subsequences, so we continue.
5. Try to extend an existing subsequence ending at num - 1.
If subsequences[num - 1] > 0, then there exists a subsequence that can accept num.
We greedily extend it because extending old subsequences prevents them from becoming invalid short sequences.
Steps:
- Decrease
subsequences[num - 1] - Increase
subsequences[num] - Decrease
frequency[num]
- If extension is impossible, try to start a new subsequence.
To create a valid new subsequence, we must immediately reserve:
numnum + 1num + 2
If any of these are unavailable, the answer is immediately false.
7. Create the new subsequence.
If all required numbers exist:
- Decrease frequencies of
num,num+1, andnum+2 - Record a subsequence ending at
num+2
- If neither extension nor creation is possible, return
false.
This means the current number cannot belong to any valid subsequence arrangement.
9. If all numbers are processed successfully, return true.
Why it works
The greedy invariant is that extending an existing subsequence is always safer than starting a new one. Existing subsequences may still be too short and require future numbers to become valid. If we ignore them and create new subsequences instead, we risk leaving unfinished sequences behind.
By always prioritizing extension, we minimize the number of unfinished subsequences and guarantee that every started subsequence can eventually reach length at least 3.
Python Solution
from collections import Counter, defaultdict
from typing import List
class Solution:
def isPossible(self, nums: List[int]) -> bool:
frequency = Counter(nums)
subsequences = defaultdict(int)
for num in nums:
if frequency[num] == 0:
continue
# Use current number to extend an existing subsequence
if subsequences[num - 1] > 0:
subsequences[num - 1] -= 1
subsequences[num] += 1
frequency[num] -= 1
# Otherwise, try to create a new subsequence
elif frequency[num + 1] > 0 and frequency[num + 2] > 0:
frequency[num] -= 1
frequency[num + 1] -= 1
frequency[num + 2] -= 1
subsequences[num + 2] += 1
# Neither extending nor creating is possible
else:
return False
return True
The implementation begins by counting how many copies of each number exist using Counter. This allows constant-time lookup of remaining unused values.
The subsequences map tracks how many subsequences end at each number. If subsequences[4] == 2, it means two subsequences currently end with 4 and could potentially be extended by a 5.
Inside the loop, we first skip numbers already consumed by previous operations.
The most important section is the greedy extension step. If a subsequence ending at num - 1 exists, we extend it immediately. This prevents short subsequences from being stranded.
If extension is impossible, we attempt to start a brand-new subsequence. However, we only do this if num + 1 and num + 2 are available. This guarantees the new subsequence will already satisfy the minimum length requirement.
If neither action is possible, the configuration is invalid, so we return False.
If the loop completes successfully, every number has been assigned to a valid subsequence.
Go Solution
func isPossible(nums []int) bool {
frequency := make(map[int]int)
subsequences := make(map[int]int)
for _, num := range nums {
frequency[num]++
}
for _, num := range nums {
if frequency[num] == 0 {
continue
}
// Extend an existing subsequence
if subsequences[num-1] > 0 {
subsequences[num-1]--
subsequences[num]++
frequency[num]--
// Create a new subsequence
} else if frequency[num+1] > 0 && frequency[num+2] > 0 {
frequency[num]--
frequency[num+1]--
frequency[num+2]--
subsequences[num+2]++
// Impossible to place current number
} else {
return false
}
}
return true
}
The Go implementation closely mirrors the Python solution. Go maps return the zero value for missing keys, which is convenient because nonexistent entries automatically behave like frequency 0.
Unlike Python's defaultdict, Go does not require explicit initialization for missing keys. Accessing an absent key simply returns 0.
There are no integer overflow concerns because the constraints are small. Slices are used directly for iterating through the input array.
Worked Examples
Example 1
nums = [1,2,3,3,4,5]
Initial state:
| Number | Frequency |
|---|---|
| 1 | 1 |
| 2 | 1 |
| 3 | 2 |
| 4 | 1 |
| 5 | 1 |
Initial subsequences map is empty.
Iteration Trace
| Current Num | Action | Subsequences State |
|---|---|---|
| 1 | Start new subsequence using 1,2,3 | ends at 3: 1 |
| 2 | Already used | unchanged |
| 3 | One copy already used, one remains | unchanged |
| 3 | Start new subsequence using 3,4,5 | ends at 3: 1, ends at 5: 1 |
| 4 | Already used | unchanged |
| 5 | Already used | unchanged |
All numbers are assigned successfully, so the answer is true.
Valid subsequences:
[1,2,3]
[3,4,5]
Example 2
nums = [1,2,3,3,4,4,5,5]
Iteration Trace
| Current Num | Action | Subsequences State |
|---|---|---|
| 1 | Start new subsequence 1,2,3 | ends at 3: 1 |
| 2 | Already used | unchanged |
| 3 | Try second copy | unchanged |
| 3 | Start new subsequence 3,4,5 | ends at 3: 1, ends at 5: 1 |
| 4 | Extend subsequence ending at 3 | ends at 4: 1, ends at 5: 1 |
| 4 | Already used | unchanged |
| 5 | Extend subsequence ending at 4 | ends at 5: 2 |
| 5 | Already used | unchanged |
Final subsequences:
[1,2,3,4,5]
[3,4,5]
Answer is true.
Example 3
nums = [1,2,3,4,4,5]
Iteration Trace
| Current Num | Action | Subsequences State |
|---|---|---|
| 1 | Start new subsequence 1,2,3 | ends at 3: 1 |
| 2 | Already used | unchanged |
| 3 | Already used | unchanged |
| 4 | Extend subsequence ending at 3 | ends at 4: 1 |
| 4 | Cannot extend and cannot create 4,5,6 | failure |
The second 4 cannot:
- extend an existing subsequence ending at
3 - create a new valid subsequence because
6does not exist
Therefore the answer is false.
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Each number is processed a constant number of times |
| Space | O(n) | Hash maps store frequencies and subsequence counts |
The algorithm is linear because every operation on the hash maps is constant time on average, and each array element is visited once during processing.
The space complexity is linear because the frequency map and subsequence map may each contain up to n distinct keys in the worst case.
Test Cases
from typing import List
from collections import Counter, defaultdict
class Solution:
def isPossible(self, nums: List[int]) -> bool:
frequency = Counter(nums)
subsequences = defaultdict(int)
for num in nums:
if frequency[num] == 0:
continue
if subsequences[num - 1] > 0:
subsequences[num - 1] -= 1
subsequences[num] += 1
frequency[num] -= 1
elif frequency[num + 1] > 0 and frequency[num + 2] > 0:
frequency[num] -= 1
frequency[num + 1] -= 1
frequency[num + 2] -= 1
subsequences[num + 2] += 1
else:
return False
return True
solution = Solution()
assert solution.isPossible([1,2,3,3,4,5]) == True # basic valid split
assert solution.isPossible([1,2,3,3,4,4,5,5]) == True # overlapping subsequences
assert solution.isPossible([1,2,3,4,4,5]) == False # stranded number
assert solution.isPossible([1,2,3]) == True # single valid subsequence
assert solution.isPossible([1,2]) == False # too short
assert solution.isPossible([1,2,3,4,5,6]) == True # one long subsequence
assert solution.isPossible([1,2,3,4,5,5,6,7]) == True # extension plus new sequence
assert solution.isPossible([1,2,3,4,5,5,6]) == False # cannot complete second sequence
assert solution.isPossible([1,1,2,2,3,3]) == True # duplicate balanced counts
assert solution.isPossible([1,1,1,2,2,2,3,3,3]) == True # multiple equal subsequences
assert solution.isPossible([1,2,3,4,6,7,8]) == True # separate valid groups
assert solution.isPossible([-1,0,1,2,3,4]) == True # negative numbers supported
assert solution.isPossible([1,2,3,4,5,6,7,8,9]) == True # long continuous chain
| Test | Why |
|---|---|
[1,2,3,3,4,5] |
Basic successful split |
[1,2,3,3,4,4,5,5] |
Multiple overlapping subsequences |
[1,2,3,4,4,5] |
Impossible due to stranded element |
[1,2,3] |
Minimum valid subsequence |
[1,2] |
Minimum invalid length |
[1,2,3,4,5,6] |
Single long subsequence |
[1,2,3,4,5,5,6,7] |
Mix of extension and new creation |
[1,2,3,4,5,5,6] |
Incomplete second subsequence |
[1,1,2,2,3,3] |
Balanced duplicates |
[1,1,1,2,2,2,3,3,3] |
Multiple parallel subsequences |
[-1,0,1,2,3,4] |
Negative values |
[1,2,3,4,5,6,7,8,9] |
Long consecutive chain |
Edge Cases
One important edge case is arrays that are too short to form valid subsequences. For example, [1,2] cannot possibly satisfy the minimum length requirement of 3. A naive implementation might incorrectly treat any consecutive sequence as valid. This implementation correctly rejects such cases because it only starts new subsequences when num+1 and num+2 both exist.
Another tricky edge case involves duplicate values. Consider [1,1,2,2,3,3]. A poor greedy strategy might place both 1s into the same subsequence attempt, leaving unmatched numbers later. The frequency map and subsequence tracking ensure that each copy is used independently and correctly distributed across multiple subsequences.
A third important edge case occurs when a number appears that can neither extend an existing subsequence nor start a new valid one. For example, in [1,2,3,4,4,5], the second 4 becomes stranded because no subsequence ends at 3, and 6 does not exist to create [4,5,6]. The implementation detects this immediately and returns False.
Another subtle edge case involves negative numbers. Since the problem allows values as small as -1000, the algorithm must not assume numbers are positive. Because the solution uses hash maps keyed by integers rather than array indexing, negative values are handled naturally without special logic.