Facebook Pixel

1977. Number of Ways to Separate Numbers

HardStringDynamic ProgrammingSuffix Array
Leetcode Link

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.

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

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:

  1. The substring from i-j to i shouldn't start with '0' (no leading zeros)
  2. 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:

  1. First, we copy the cumulative sum from dp[i][j-1] (numbers with length less than j)
  2. Then, if the substring from i-j to i 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 position i-2j, we need to check if it's less than or equal to the current number using cmp()
    • If valid, add dp[i-j][j] (same length case)
    • Otherwise, add dp[i-j][min(j-1, i-j)] (all shorter length cases)
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 Evaluator

Example 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

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 compare num[2] ('3') vs num[0] ('1')
      • '3' > '1', so "12" < "34" ✓
    • Add dp[2][2] = 2
    • dp[4][2] = 4 + 2 = 6
  • 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. [1, 2, 3, 4]
  2. [1, 2, 34]
  3. [1, 23, 4]
  4. [1, 234]
  5. [12, 34]
  6. [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 to 0, each iterating n times, resulting in O(n^2) operations.
  • The dynamic programming section has two nested loops: the outer loop runs from 1 to n, and the inner loop runs from 1 to i. This gives us 1 + 2 + 3 + ... + n = n(n+1)/2 = O(n^2) iterations.
  • Inside the DP loops, the cmp function is called, which performs O(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), requiring O(n^2) space.
  • The dp array is also a 2D array of size (n+1) × (n+1), requiring O(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
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

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

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

Load More