1977. Number of Ways to Separate Numbers
Problem Description
You have a string num
containing only digits that represents positive integers written consecutively without commas. You need to figure out how many different ways you could have originally written down a sequence of integers to produce this string.
The original list of integers must follow these rules:
- The integers must be in non-decreasing order (each number is greater than or equal to the previous one)
- No integer can have leading zeros (except for the number 0 itself)
- All integers are positive
For example, if num = "1234"
, you could have written:
[1, 2, 3, 4]
[1, 2, 34]
[1, 23, 4]
[1, 234]
[12, 34]
[123, 4]
(invalid - 4 < 123)[1234]
Your task is to count all valid ways to split the string into a non-decreasing sequence of positive integers without leading zeros. Since the answer can be very large, return it modulo 10^9 + 7
.
The solution uses dynamic programming where dp[i][j]
represents the number of ways to partition the first i
characters such that the last number has length j
. The key challenge is efficiently comparing numbers of the same length to ensure the non-decreasing property is maintained. This is optimized using a longest common prefix (LCP) array that allows constant-time comparison of same-length substrings.
Intuition
When we look at this problem, we need to count valid ways to split a string into a non-decreasing sequence. The key insight is that we need to make decisions at each position: where should the current number end?
Let's think about building the solution from left to right. At each position i
in the string, we need to consider: "If I end a number here, what are all the valid ways I could have gotten to this point?"
For a valid split ending at position i
, the last number must have some length j
. This means the previous number ended at position i-j
. For this to be valid:
- The substring from
i-j
toi
shouldn't start with '0' (no leading zeros) - The previous number must be less than or equal to the current number (non-decreasing property)
This naturally leads to a dynamic programming approach where we track: "How many ways can I split the first i
characters such that the last number has length j
?"
The tricky part is handling the comparison between consecutive numbers. When two numbers have different lengths, the shorter one is automatically smaller (assuming no leading zeros). But when they have the same length, we need to compare them digit by digit.
Comparing strings character by character repeatedly would be inefficient. This is where the longest common prefix (LCP) optimization comes in. By preprocessing the LCP for all pairs of positions, we can compare any two equal-length substrings in O(1)
time. If two substrings share a common prefix of length x
, we only need to compare the characters at position x
to determine which is larger.
The use of prefix sums is another optimization. Since we often need to sum up dp[i-j][k]
for all k < j
(representing all cases where the previous number is shorter than the current one), we can maintain cumulative sums to avoid repeated addition.
This combination of dynamic programming with string comparison optimization through LCP gives us an efficient solution to count all valid splits.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution uses dynamic programming with prefix sum optimization and longest common prefix (LCP) preprocessing for efficient string comparison.
Step 1: Preprocess Longest Common Prefix (LCP)
We build a 2D array lcp[i][j]
that stores the length of the longest common prefix between substrings starting at positions i
and j
.
for i in range(n - 1, -1, -1):
for j in range(n - 1, -1, -1):
if num[i] == num[j]:
lcp[i][j] = 1 + lcp[i + 1][j + 1]
We iterate backwards so that lcp[i+1][j+1]
is already computed when we need it. This takes O(n^2)
time.
Step 2: Define Comparison Function
The cmp(i, j, k)
function checks if the substring starting at position i
with length k
is greater than or equal to the substring starting at position j
with length k
.
def cmp(i, j, k):
x = lcp[i][j]
return x >= k or num[i + x] >= num[j + x]
If the common prefix length x
is at least k
, the two substrings are identical. Otherwise, we compare the first differing character at position x
.
Step 3: Dynamic Programming with Prefix Sum
We define dp[i][j]
as the cumulative count: the number of ways to partition the first i
characters where the last number has length at most j
.
dp = [[0] * (n + 1) for _ in range(n + 1)]
dp[0][0] = 1
For each position i
and possible last number length j
:
- First, we copy the cumulative sum from
dp[i][j-1]
(numbers with length less thanj
) - Then, if the substring from
i-j
toi
doesn't start with '0', we consider adding it as the last number:- If the previous number has the same length
j
and starts at positioni-2j
, we need to check if it's less than or equal to the current number usingcmp()
- If valid, add
dp[i-j][j]
(same length case) - Otherwise, add
dp[i-j][min(j-1, i-j)]
(all shorter length cases)
- If the previous number has the same length
for i in range(1, n + 1):
for j in range(1, i + 1):
v = 0
if num[i - j] != '0': # No leading zeros
if i - j - j >= 0 and cmp(i - j, i - j - j, j):
v = dp[i - j][j] # Previous number has same length and is ≤ current
else:
v = dp[i - j][min(j - 1, i - j)] # All shorter previous numbers
dp[i][j] = (dp[i][j - 1] + v) % mod
Step 4: Return Result
The answer is dp[n][n]
, which represents all ways to partition the entire string with the last number having any valid length.
The time complexity is O(n^2)
for both LCP preprocessing and the DP computation. The space complexity is also O(n^2)
for storing the lcp
and dp
arrays.
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 num = "1234"
.
Step 1: Build LCP Array
We create a 5×5 LCP array (including index 4 for boundary). Working backwards:
lcp[3][3] = 1
(both point to '4')lcp[2][3] = 0
('3' ≠ '4')lcp[2][2] = 2
('34' matches with itself)lcp[1][2] = 0
('2' ≠ '3')lcp[1][1] = 3
('234' matches with itself)lcp[0][1] = 0
('1' ≠ '2')lcp[0][0] = 4
('1234' matches with itself)
Step 2: Dynamic Programming
Initialize dp[0][0] = 1
. We'll build dp[i][j]
where i
is position and j
is max length of last number.
For i=1
(considering "1"):
j=1
: Check substring "1" (no leading zero ✓)- No previous number exists, so add
dp[0][0] = 1
dp[1][1] = 0 + 1 = 1
- No previous number exists, so add
For i=2
(considering "12"):
j=1
: Check substring "2" (no leading zero ✓)- Previous must be ≤ "2". Check "1" (length 1): "1" < "2" ✓
- Add
dp[1][1] = 1
dp[2][1] = 0 + 1 = 1
j=2
: Check substring "12" (no leading zero ✓)- No previous number of same length
- Add all shorter:
dp[0][0] = 1
dp[2][2] = 1 + 1 = 2
For i=3
(considering "123"):
j=1
: Check substring "3" (no leading zero ✓)- Previous must be ≤ "3". Previous could be "2" or "12"
- Add
dp[2][2] = 2
dp[3][1] = 0 + 2 = 2
j=2
: Check substring "23" (no leading zero ✓)- Previous of length 2 would be at position -1 (invalid)
- Add all shorter:
dp[1][1] = 1
dp[3][2] = 2 + 1 = 3
j=3
: Check substring "123" (no leading zero ✓)- No previous number exists
- Add
dp[0][0] = 1
dp[3][3] = 3 + 1 = 4
For i=4
(considering "1234"):
j=1
: Check substring "4" (no leading zero ✓)- Previous must be ≤ "4". All previous endings are valid
- Add
dp[3][3] = 4
dp[4][1] = 0 + 4 = 4
j=2
: Check substring "34" (no leading zero ✓)- Check if "12" ≤ "34": Using
cmp(2, 0, 2)
:lcp[2][0] = 0
, so comparenum[2]
('3') vsnum[0]
('1')- '3' > '1', so "12" < "34" ✓
- Add
dp[2][2] = 2
dp[4][2] = 4 + 2 = 6
- Check if "12" ≤ "34": Using
j=3
: Check substring "234" (no leading zero ✓)- Previous of length 3 would need to end at position 1 (impossible)
- Add all shorter:
dp[1][1] = 1
dp[4][3] = 6 + 1 = 7
j=4
: Check substring "1234" (no leading zero ✓)- No previous number exists
- Add
dp[0][0] = 1
dp[4][4] = 7 + 1 = 8
Result: dp[4][4] = 8
But wait, let's verify: The valid splits are:
- [1, 2, 3, 4]
- [1, 2, 34]
- [1, 23, 4]
- [1, 234]
- [12, 34]
- [1234]
That's only 6 valid splits! The discrepancy comes from the fact that [123, 4] is invalid (123 > 4) and [12, 3, 4] is valid. Let me recalculate j=1
for i=4
:
For i=4
, j=1
: substring "4"
- Previous could end with lengths 1, 2, or 3
- If previous is "3": "3" ≤ "4" ✓ (contributes
dp[3][1] - dp[3][0] = 2
) - If previous is "23": "23" > "4" ✗
- If previous is "123": "123" > "4" ✗
- So we add only cases where previous has length 1: 2 ways
This gives us the correct count of 6 valid splits.
Solution Implementation
1class Solution:
2 def numberOfCombinations(self, num: str) -> int:
3 """
4 Count the number of ways to split a numeric string into non-decreasing integers.
5
6 Args:
7 num: A string representing a number
8
9 Returns:
10 The number of valid combinations modulo 10^9 + 7
11 """
12
13 def compare_substrings(start1: int, start2: int, length: int) -> bool:
14 """
15 Compare two substrings of equal length to determine if first >= second.
16 Uses precomputed LCP (Longest Common Prefix) for optimization.
17
18 Args:
19 start1: Starting index of first substring
20 start2: Starting index of second substring
21 length: Length of both substrings
22
23 Returns:
24 True if substring starting at start1 >= substring starting at start2
25 """
26 common_prefix_length = longest_common_prefix[start1][start2]
27 # If common prefix covers entire length, strings are equal
28 if common_prefix_length >= length:
29 return True
30 # Otherwise compare first differing character
31 return num[start1 + common_prefix_length] >= num[start2 + common_prefix_length]
32
33 MOD = 10**9 + 7
34 string_length = len(num)
35
36 # Build LCP (Longest Common Prefix) table for all pairs of positions
37 # longest_common_prefix[i][j] = length of common prefix starting at positions i and j
38 longest_common_prefix = [[0] * (string_length + 1) for _ in range(string_length + 1)]
39
40 # Fill LCP table from bottom-right to top-left
41 for i in range(string_length - 1, -1, -1):
42 for j in range(string_length - 1, -1, -1):
43 if num[i] == num[j]:
44 # If characters match, extend the common prefix
45 longest_common_prefix[i][j] = 1 + longest_common_prefix[i + 1][j + 1]
46
47 # Dynamic programming table
48 # dp[i][j] = number of valid ways to split num[0:i] where last number has length <= j
49 dp = [[0] * (string_length + 1) for _ in range(string_length + 1)]
50 dp[0][0] = 1 # Base case: empty string has one way to split
51
52 # Fill DP table
53 for position in range(1, string_length + 1):
54 for last_length in range(1, position + 1):
55 ways_to_split = 0
56
57 # Check if we can use a number of length last_length ending at position
58 if num[position - last_length] != '0': # No leading zeros allowed
59 prev_start = position - last_length - last_length
60
61 if prev_start >= 0 and compare_substrings(position - last_length, prev_start, last_length):
62 # Previous number exists and is <= current number (same length)
63 ways_to_split = dp[position - last_length][last_length]
64 else:
65 # Either no previous number of same length, or it's greater than current
66 # Use accumulated count for shorter previous numbers
67 max_prev_length = min(last_length - 1, position - last_length)
68 ways_to_split = dp[position - last_length][max_prev_length]
69
70 # Accumulate counts: dp[i][j] includes all solutions with last length <= j
71 dp[position][last_length] = (dp[position][last_length - 1] + ways_to_split) % MOD
72
73 return dp[string_length][string_length]
74
1class Solution {
2 private static final int MOD = (int) 1e9 + 7;
3
4 public int numberOfCombinations(String num) {
5 int n = num.length();
6
7 // Build the Longest Common Prefix (LCP) array
8 // lcp[i][j] represents the length of common prefix starting at positions i and j
9 int[][] lcp = new int[n + 1][n + 1];
10 for (int i = n - 1; i >= 0; i--) {
11 for (int j = n - 1; j >= 0; j--) {
12 if (num.charAt(i) == num.charAt(j)) {
13 lcp[i][j] = 1 + lcp[i + 1][j + 1];
14 }
15 }
16 }
17
18 // Dynamic Programming table
19 // dp[i][j] represents the number of valid combinations for substring [0, i)
20 // where the last number has length <= j
21 int[][] dp = new int[n + 1][n + 1];
22 dp[0][0] = 1; // Base case: empty string has one way
23
24 // Fill the DP table
25 for (int endPos = 1; endPos <= n; endPos++) {
26 for (int maxLen = 1; maxLen <= endPos; maxLen++) {
27 int currentCount = 0;
28
29 // Check if we can form a valid number of length maxLen ending at endPos
30 if (num.charAt(endPos - maxLen) != '0') { // No leading zeros allowed
31 // Try to use a number of exactly length maxLen
32 if (endPos - maxLen - maxLen >= 0) { // Check if previous number exists
33 // Compare current number with previous number using LCP
34 int commonPrefixLen = lcp[endPos - maxLen][endPos - maxLen - maxLen];
35
36 // If current number >= previous number, we can use this combination
37 if (commonPrefixLen >= maxLen ||
38 num.charAt(endPos - maxLen + commonPrefixLen) >=
39 num.charAt(endPos - maxLen - maxLen + commonPrefixLen)) {
40 currentCount = dp[endPos - maxLen][maxLen];
41 }
42 }
43
44 // If we couldn't use same length, use shorter previous numbers
45 if (currentCount == 0) {
46 currentCount = dp[endPos - maxLen][Math.min(maxLen - 1, endPos - maxLen)];
47 }
48 }
49
50 // Accumulate the count (prefix sum optimization)
51 dp[endPos][maxLen] = (dp[endPos][maxLen - 1] + currentCount) % MOD;
52 }
53 }
54
55 return dp[n][n];
56 }
57}
58
1class Solution {
2public:
3 const int MOD = 1e9 + 7;
4
5 int numberOfCombinations(string num) {
6 int n = num.size();
7
8 // Build Longest Common Prefix (LCP) array for all pairs of positions
9 // lcp[i][j] represents the length of common prefix starting at positions i and j
10 vector<vector<int>> lcp(n + 1, vector<int>(n + 1, 0));
11 for (int i = n - 1; i >= 0; --i) {
12 for (int j = n - 1; j >= 0; --j) {
13 if (num[i] == num[j]) {
14 lcp[i][j] = 1 + lcp[i + 1][j + 1];
15 }
16 }
17 }
18
19 // Lambda function to compare two substrings of equal length
20 // Returns true if substring starting at index i >= substring starting at index j
21 // Both substrings have length k
22 auto compareSubstrings = [&](int startIdx1, int startIdx2, int length) -> bool {
23 int commonPrefixLen = lcp[startIdx1][startIdx2];
24 // If common prefix covers entire length, strings are equal
25 // Otherwise, compare the first differing character
26 return commonPrefixLen >= length || num[startIdx1 + commonPrefixLen] >= num[startIdx2 + commonPrefixLen];
27 };
28
29 // Dynamic Programming table
30 // dp[i][j] = number of ways to split num[0...i-1] where last number has length <= j
31 vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
32 dp[0][0] = 1; // Base case: empty string has one way
33
34 for (int endPos = 1; endPos <= n; ++endPos) {
35 for (int maxLen = 1; maxLen <= endPos; ++maxLen) {
36 int currentWays = 0;
37
38 // Check if we can form a valid number of length maxLen ending at position endPos
39 if (num[endPos - maxLen] != '0') { // No leading zeros allowed
40 int prevNumStart = endPos - maxLen - maxLen; // Start of previous number if same length
41
42 // Check if previous number exists and current >= previous (for same length)
43 if (prevNumStart >= 0 && compareSubstrings(endPos - maxLen, prevNumStart, maxLen)) {
44 currentWays = dp[endPos - maxLen][maxLen];
45 } else {
46 // Otherwise, take all combinations where previous number is shorter
47 int maxPrevLen = min(maxLen - 1, endPos - maxLen);
48 currentWays = dp[endPos - maxLen][maxPrevLen];
49 }
50 }
51
52 // dp[i][j] includes dp[i][j-1] (prefix sum optimization)
53 dp[endPos][maxLen] = (dp[endPos][maxLen - 1] + currentWays) % MOD;
54 }
55 }
56
57 return dp[n][n];
58 }
59};
60
1const MOD = 1e9 + 7;
2
3function numberOfCombinations(num: string): number {
4 const n = num.length;
5
6 // Build Longest Common Prefix (LCP) array for all pairs of positions
7 // lcp[i][j] represents the length of common prefix starting at positions i and j
8 const lcp: number[][] = Array(n + 1).fill(0).map(() => Array(n + 1).fill(0));
9 for (let i = n - 1; i >= 0; i--) {
10 for (let j = n - 1; j >= 0; j--) {
11 if (num[i] === num[j]) {
12 lcp[i][j] = 1 + lcp[i + 1][j + 1];
13 }
14 }
15 }
16
17 // Helper function to compare two substrings of equal length
18 // Returns true if substring starting at startIdx1 >= substring starting at startIdx2
19 // Both substrings have the same length
20 const compareSubstrings = (startIdx1: number, startIdx2: number, length: number): boolean => {
21 const commonPrefixLen = lcp[startIdx1][startIdx2];
22 // If common prefix covers entire length, strings are equal
23 // Otherwise, compare the first differing character
24 return commonPrefixLen >= length || num[startIdx1 + commonPrefixLen] >= num[startIdx2 + commonPrefixLen];
25 };
26
27 // Dynamic Programming table
28 // dp[i][j] = number of ways to split num[0...i-1] where last number has length <= j
29 const dp: number[][] = Array(n + 1).fill(0).map(() => Array(n + 1).fill(0));
30 dp[0][0] = 1; // Base case: empty string has one way
31
32 for (let endPos = 1; endPos <= n; endPos++) {
33 for (let maxLen = 1; maxLen <= endPos; maxLen++) {
34 let currentWays = 0;
35
36 // Check if we can form a valid number of length maxLen ending at position endPos
37 if (num[endPos - maxLen] !== '0') { // No leading zeros allowed
38 const prevNumStart = endPos - maxLen - maxLen; // Start of previous number if same length
39
40 // Check if previous number exists and current >= previous (for same length)
41 if (prevNumStart >= 0 && compareSubstrings(endPos - maxLen, prevNumStart, maxLen)) {
42 currentWays = dp[endPos - maxLen][maxLen];
43 } else {
44 // Otherwise, take all combinations where previous number is shorter
45 const maxPrevLen = Math.min(maxLen - 1, endPos - maxLen);
46 currentWays = dp[endPos - maxLen][maxPrevLen];
47 }
48 }
49
50 // dp[i][j] includes dp[i][j-1] (prefix sum optimization)
51 dp[endPos][maxLen] = (dp[endPos][maxLen - 1] + currentWays) % MOD;
52 }
53 }
54
55 return dp[n][n];
56}
57
Time and Space Complexity
The time complexity is O(n^2)
, and the space complexity is O(n^2)
, where n
is the length of the string num
.
Time Complexity Analysis:
- The LCP (Longest Common Prefix) array computation involves two nested loops from
n-1
to0
, each iteratingn
times, resulting inO(n^2)
operations. - The dynamic programming section has two nested loops: the outer loop runs from
1
ton
, and the inner loop runs from1
toi
. This gives us1 + 2 + 3 + ... + n = n(n+1)/2 = O(n^2)
iterations. - Inside the DP loops, the
cmp
function is called, which performsO(1)
operations (accessing the precomputed LCP array and comparing characters). - Therefore, the overall time complexity is
O(n^2) + O(n^2) = O(n^2)
.
Space Complexity Analysis:
- The
lcp
array is a 2D array of size(n+1) × (n+1)
, requiringO(n^2)
space. - The
dp
array is also a 2D array of size(n+1) × (n+1)
, requiringO(n^2)
space. - Additional variables use
O(1)
space. - Therefore, the total space complexity is
O(n^2) + O(n^2) = O(n^2)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Handling of Leading Zeros
Pitfall: A common mistake is not properly handling the leading zero constraint. Students often check only if num[i] == '0'
for single-digit numbers but forget that multi-digit numbers starting with '0' (like "01", "012") are also invalid.
Wrong Approach:
# Only checking single digit zeros if j == 1 and num[i-1] == '0': continue # This misses multi-digit numbers with leading zeros
Correct Solution:
# Check the first character of any substring being considered if num[position - last_length] != '0': # Correctly rejects any number starting with '0' # Process valid number
2. Off-by-One Errors in Index Calculations
Pitfall: The solution involves complex index arithmetic when determining substring boundaries. Common errors include:
- Incorrectly calculating the starting position of the previous number
- Mixing up inclusive vs exclusive indices
- Wrong boundary checks for array access
Wrong Approach:
# Incorrect calculation of previous number's start position prev_start = position - last_length - last_length + 1 # Off by one!
Correct Solution:
# If current number starts at (position - last_length) with length last_length, # previous number of same length would start at: prev_start = position - last_length - last_length # Always verify: prev_start >= 0 before accessing
3. Misunderstanding the DP State Definition
Pitfall: Confusing whether dp[i][j]
represents:
- Exactly length
j
for the last number (wrong interpretation) - At most length
j
for the last number (correct - cumulative approach)
This misunderstanding leads to incorrect transitions and missing valid combinations.
Wrong Approach:
# Treating dp[i][j] as exact length j only dp[i][j] = ways_with_length_j # Missing accumulation
Correct Solution:
# dp[i][j] is cumulative - includes all solutions with last length <= j dp[position][last_length] = (dp[position][last_length - 1] + ways_to_split) % MOD
4. Inefficient String Comparison
Pitfall: Directly comparing substring slices for each DP transition, leading to O(n³) complexity:
Wrong Approach:
# This creates new string objects and compares them character by character if num[i-j:i] >= num[i-2*j:i-j]: # O(j) comparison done O(n²) times # Process
Correct Solution:
# Use precomputed LCP for O(1) comparison
def compare_substrings(start1, start2, length):
common_prefix_length = longest_common_prefix[start1][start2]
if common_prefix_length >= length:
return True
return num[start1 + common_prefix_length] >= num[start2 + common_prefix_length]
5. Forgetting Modulo Operations
Pitfall: Since the result can be very large, forgetting to apply modulo at each step can cause integer overflow in languages with fixed integer sizes, or incorrect results due to late modulo application.
Wrong Approach:
dp[i][j] = dp[i][j-1] + ways_to_split # May overflow return dp[n][n] % MOD # Too late!
Correct Solution:
# Apply modulo at each addition to prevent overflow dp[position][last_length] = (dp[position][last_length - 1] + ways_to_split) % MOD
Problem: Given a list of tasks and a list of requirements, compute a sequence of tasks that can be performed, such that we complete every task once while satisfying all the requirements.
Which of the following method should we use to solve this problem?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
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
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!