87. Scramble String
Problem Description
You are given two strings s1
and s2
of the same length. You need to determine if s2
is a scrambled version of s1
.
A string can be scrambled using the following recursive algorithm:
-
If the string has length 1, stop (base case).
-
If the string has length > 1:
- Split the string into two non-empty parts at any position. For example, if
s = "great"
, you could split it intox = "gr"
andy = "eat"
, makings = x + y
. - Choose to either keep the two parts in the same order (
s = x + y
) or swap them (s = y + x
). - Recursively apply this scrambling process to each part
x
andy
.
- Split the string into two non-empty parts at any position. For example, if
Through this process, a string can be transformed into many different scrambled versions. For example, starting with "great"
:
- Split into
"gr"
and"eat"
- Swap to get
"eat" + "gr" = "eatgr"
- Recursively scramble
"gr"
to get"rg"
(by swapping) - Result:
"eatrg"
is a scrambled version of"great"
The task is to return true
if s2
can be obtained by scrambling s1
using this algorithm, otherwise return false
.
The solution uses dynamic programming with memoization. The function dfs(i, j, k)
checks if a substring of length k
starting at index i
in s1
can be scrambled to match a substring of length k
starting at index j
in s2
. For each possible split position h
, it checks two scenarios:
- No swap: the first
h
characters match and the remainingk-h
characters match - Swap: the first
h
characters ofs1
match the lasth
characters ofs2
, and vice versa
The base case is when k=1
, where we simply check if the single characters are equal.
Intuition
The key insight is that if s2
is a scrambled version of s1
, then there must exist a way to recursively partition both strings such that the corresponding parts match (either directly or after swapping).
Think about the scrambling process in reverse. If s2
came from s1
, then at some point during the scrambling, s1
was split into two parts. These two parts were either kept in order or swapped, and then each part was independently scrambled. This means we can check if s2
matches s1
by trying all possible split points and checking if the resulting subproblems are valid.
For any substring of s1
and corresponding substring of s2
with the same length k
, we need to find if there's a valid split point h
(where 1 ≤ h < k
) such that:
-
No swap case: The first
h
characters of both substrings can be scrambled to match each other, AND the remainingk-h
characters can also be scrambled to match. This corresponds to checkingdfs(i, j, h) && dfs(i+h, j+h, k-h)
. -
Swap case: The first
h
characters ofs1
can be scrambled to match the lasth
characters ofs2
, AND the lastk-h
characters ofs1
can be scrambled to match the firstk-h
characters ofs2
. This corresponds to checkingdfs(i+h, j, k-h) && dfs(i, j+k-h, h)
.
The reason we need to check all possible split points is that we don't know where the original split occurred during scrambling. By trying all possibilities and using memoization to avoid recalculating the same subproblems, we can efficiently determine if the transformation is possible.
The base case is straightforward: when we're comparing single characters (k=1
), they either match or they don't - no scrambling is possible at this level.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution implements a top-down dynamic programming approach with memoization using Python's @cache
decorator.
Core Function Definition:
The function dfs(i, j, k)
determines whether the substring s1[i:i+k]
can be scrambled to match s2[j:j+k]
. The parameters represent:
i
: starting index ins1
j
: starting index ins2
k
: length of the substring to compare
Base Case:
When k = 1
, we're comparing single characters. Simply check if s1[i] == s2[j]
. If they match, return True
; otherwise, return False
.
Recursive Case:
For k > 1
, we try all possible split points h
where 1 ≤ h < k
. For each split point, we check two scenarios:
-
No Swap Scenario:
- Check if
dfs(i, j, h)
returnsTrue
(first part matches) - AND
dfs(i + h, j + h, k - h)
returnsTrue
(second part matches) - This represents keeping the original order after splitting
- Check if
-
Swap Scenario:
- Check if
dfs(i + h, j, k - h)
returnsTrue
(second part ofs1
matches first part ofs2
) - AND
dfs(i, j + k - h, h)
returnsTrue
(first part ofs1
matches second part ofs2
) - This represents swapping the two parts after splitting
- Check if
If either scenario returns True
for any split point h
, we return True
. If all split points fail both scenarios, we return False
.
Memoization:
The @cache
decorator automatically stores the results of dfs(i, j, k)
calls. This prevents redundant calculations when the same subproblem appears multiple times, reducing the time complexity from exponential to polynomial.
Main Function:
The solution starts by calling dfs(0, 0, len(s1))
, which checks if the entire string s1
can be scrambled to match the entire string s2
.
Time Complexity: O(n^4)
where n
is the length of the strings. There are O(n^3)
possible states (i, j, k)
, and for each state, we iterate through up to n
split points.
Space Complexity: O(n^3)
for the memoization cache storing all possible states.
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 trace through how the algorithm determines if s2 = "rgeat"
is a scrambled version of s1 = "great"
.
We start with dfs(0, 0, 5)
, checking if "great"
can be scrambled to match "rgeat"
.
Step 1: Initial call dfs(0, 0, 5)
- We're comparing
s1[0:5] = "great"
withs2[0:5] = "rgeat"
- Since
k = 5 > 1
, we try different split pointsh = 1, 2, 3, 4
Step 2: Try h = 1
(split at position 1)
- No swap: Check
dfs(0, 0, 1)
ANDdfs(1, 1, 4)
dfs(0, 0, 1)
: Compare"g"
with"r"
→ returnsFalse
- Since first part is
False
, no need to check second part
- Swap: Check
dfs(1, 0, 4)
ANDdfs(0, 4, 1)
dfs(1, 0, 4)
: Check if"reat"
can scramble to"rgea"
dfs(0, 4, 1)
: Compare"g"
with"t"
→ returnsFalse
- Since second part is
False
, this fails
Step 3: Try h = 2
(split at position 2)
- No swap: Check
dfs(0, 0, 2)
ANDdfs(2, 2, 3)
dfs(0, 0, 2)
: Check if"gr"
can scramble to"rg"
- Try
h = 1
: Swap case works!"g"
matches"g"
and"r"
matches"r"
- Returns
True
- Try
dfs(2, 2, 3)
: Check if"eat"
can scramble to"eat"
- Direct match, returns
True
- Direct match, returns
- Both parts are
True
, so this split works!
Step 4: Return True
The algorithm found that splitting "great"
into "gr"
and "eat"
, then scrambling "gr"
to "rg"
(by swapping at split point 1), produces "rgeat"
. Therefore, "rgeat"
is indeed a scrambled version of "great"
.
Key observations:
- The algorithm systematically tries all possible ways the original string could have been split
- Memoization ensures that when we encounter
dfs(0, 0, 2)
again in other branches, we don't recalculate it - The recursion naturally handles nested scrambling (like scrambling within
"gr"
to get"rg"
)
Solution Implementation
1class Solution:
2 def isScramble(self, s1: str, s2: str) -> bool:
3 """
4 Determines if s2 is a scrambled string of s1.
5 A scrambled string is formed by recursively dividing the string into two non-empty substrings
6 and potentially swapping them.
7
8 Args:
9 s1: The original string
10 s2: The string to check if it's a scrambled version of s1
11
12 Returns:
13 True if s2 is a scrambled string of s1, False otherwise
14 """
15 from functools import cache
16
17 @cache
18 def is_scramble_substring(start_s1: int, start_s2: int, length: int) -> bool:
19 """
20 Recursively checks if a substring of s2 is a scrambled version of a substring of s1.
21
22 Args:
23 start_s1: Starting index in s1
24 start_s2: Starting index in s2
25 length: Length of the substring to check
26
27 Returns:
28 True if the substrings match as scrambled strings, False otherwise
29 """
30 # Base case: single character comparison
31 if length == 1:
32 return s1[start_s1] == s2[start_s2]
33
34 # Try all possible split points
35 for split_point in range(1, length):
36 left_length = split_point
37 right_length = length - split_point
38
39 # Case 1: No swap occurred at this level
40 # Check if left parts match and right parts match
41 if (is_scramble_substring(start_s1, start_s2, left_length) and
42 is_scramble_substring(start_s1 + left_length, start_s2 + left_length, right_length)):
43 return True
44
45 # Case 2: Swap occurred at this level
46 # Check if left part of s1 matches right part of s2 and vice versa
47 if (is_scramble_substring(start_s1 + left_length, start_s2, right_length) and
48 is_scramble_substring(start_s1, start_s2 + right_length, left_length)):
49 return True
50
51 # No valid split found
52 return False
53
54 # Start the recursive check from the beginning of both strings
55 return is_scramble_substring(0, 0, len(s1))
56
1class Solution {
2 // Memoization cache: dp[i][j][length] represents whether s1[i...i+length-1]
3 // can be scrambled to match s2[j...j+length-1]
4 private Boolean[][][] dp;
5 private String s1;
6 private String s2;
7
8 /**
9 * Determines if s2 is a scrambled string of s1.
10 * A scrambled string is formed by recursively dividing the string into two non-empty
11 * substrings and potentially swapping them.
12 *
13 * @param s1 The original string
14 * @param s2 The string to check if it's a scrambled version of s1
15 * @return true if s2 is a scrambled string of s1, false otherwise
16 */
17 public boolean isScramble(String s1, String s2) {
18 int n = s1.length();
19 this.s1 = s1;
20 this.s2 = s2;
21
22 // Initialize memoization cache
23 // dp[i][j][length]: can substring of s1 starting at i with given length
24 // be scrambled to match substring of s2 starting at j with same length
25 dp = new Boolean[n][n][n + 1];
26
27 // Check if entire strings can be scrambled to match
28 return checkScramble(0, 0, n);
29 }
30
31 /**
32 * Recursively checks if a substring of s1 can be scrambled to match a substring of s2.
33 *
34 * @param s1Start Starting index in s1
35 * @param s2Start Starting index in s2
36 * @param length Length of the substrings to compare
37 * @return true if the substrings can be scrambled to match, false otherwise
38 */
39 private boolean checkScramble(int s1Start, int s2Start, int length) {
40 // Return cached result if already computed
41 if (dp[s1Start][s2Start][length] != null) {
42 return dp[s1Start][s2Start][length];
43 }
44
45 // Base case: single character comparison
46 if (length == 1) {
47 return s1.charAt(s1Start) == s2.charAt(s2Start);
48 }
49
50 // Try all possible split points
51 for (int splitSize = 1; splitSize < length; splitSize++) {
52 // Case 1: No swap - left matches left, right matches right
53 // Check if s1[s1Start...s1Start+splitSize-1] matches s2[s2Start...s2Start+splitSize-1]
54 // AND s1[s1Start+splitSize...s1Start+length-1] matches s2[s2Start+splitSize...s2Start+length-1]
55 if (checkScramble(s1Start, s2Start, splitSize) &&
56 checkScramble(s1Start + splitSize, s2Start + splitSize, length - splitSize)) {
57 return dp[s1Start][s2Start][length] = true;
58 }
59
60 // Case 2: With swap - left matches right, right matches left
61 // Check if s1[s1Start+splitSize...s1Start+length-1] matches s2[s2Start...s2Start+(length-splitSize)-1]
62 // AND s1[s1Start...s1Start+splitSize-1] matches s2[s2Start+length-splitSize...s2Start+length-1]
63 if (checkScramble(s1Start + splitSize, s2Start, length - splitSize) &&
64 checkScramble(s1Start, s2Start + length - splitSize, splitSize)) {
65 return dp[s1Start][s2Start][length] = true;
66 }
67 }
68
69 // No valid split found
70 return dp[s1Start][s2Start][length] = false;
71 }
72}
73
1class Solution {
2public:
3 bool isScramble(string s1, string s2) {
4 int n = s1.size();
5
6 // dp[i][j][len] represents whether s1[i...i+len-1] can be scrambled to s2[j...j+len-1]
7 // -1: not computed, 0: false, 1: true
8 int dp[n][n][n + 1];
9 memset(dp, -1, sizeof(dp));
10
11 // DFS with memoization to check if substrings can be scrambled
12 function<bool(int, int, int)> dfs = [&](int i1, int i2, int length) -> bool {
13 // Return cached result if already computed
14 if (dp[i1][i2][length] != -1) {
15 return dp[i1][i2][length] == 1;
16 }
17
18 // Base case: single character comparison
19 if (length == 1) {
20 return dp[i1][i2][length] = (s1[i1] == s2[i2]);
21 }
22
23 // Try all possible split positions
24 for (int splitPos = 1; splitPos < length; ++splitPos) {
25 // Case 1: No swap - left part matches left, right part matches right
26 // s1[i1...i1+splitPos-1] matches s2[i2...i2+splitPos-1]
27 // s1[i1+splitPos...i1+length-1] matches s2[i2+splitPos...i2+length-1]
28 if (dfs(i1, i2, splitPos) && dfs(i1 + splitPos, i2 + splitPos, length - splitPos)) {
29 return dp[i1][i2][length] = true;
30 }
31
32 // Case 2: With swap - left part matches right, right part matches left
33 // s1[i1+splitPos...i1+length-1] matches s2[i2...i2+length-splitPos-1]
34 // s1[i1...i1+splitPos-1] matches s2[i2+length-splitPos...i2+length-1]
35 if (dfs(i1 + splitPos, i2, length - splitPos) && dfs(i1, i2 + length - splitPos, splitPos)) {
36 return dp[i1][i2][length] = true;
37 }
38 }
39
40 // No valid split found
41 return dp[i1][i2][length] = false;
42 };
43
44 // Check if entire strings can be scrambled
45 return dfs(0, 0, n);
46 }
47};
48
1/**
2 * Determines if s2 is a scrambled string of s1.
3 * A scrambled string is formed by recursively dividing the string into two non-empty substrings
4 * and either keeping them in the same order or swapping them.
5 *
6 * @param s1 - The first string
7 * @param s2 - The second string to check if it's a scramble of s1
8 * @returns true if s2 is a scrambled string of s1, false otherwise
9 */
10function isScramble(s1: string, s2: string): boolean {
11 const length: number = s1.length;
12
13 // Create a 3D memoization array to store intermediate results
14 // memo[i][j][k] represents whether substring of s1 starting at index i with length k
15 // can be scrambled to match substring of s2 starting at index j with length k
16 // -1: not computed, 0: false, 1: true
17 const memo: number[][][] = new Array(length)
18 .fill(0)
19 .map(() => new Array(length)
20 .fill(0)
21 .map(() => new Array(length + 1).fill(-1))
22 );
23
24 /**
25 * Depth-first search with memoization to check if substrings can be scrambled
26 *
27 * @param s1Start - Starting index in s1
28 * @param s2Start - Starting index in s2
29 * @param substringLength - Length of the substring to check
30 * @returns true if the substring of s1 can be scrambled to match substring of s2
31 */
32 const checkScramble = (s1Start: number, s2Start: number, substringLength: number): boolean => {
33 // Check if result is already computed and cached
34 if (memo[s1Start][s2Start][substringLength] !== -1) {
35 return memo[s1Start][s2Start][substringLength] === 1;
36 }
37
38 // Base case: single character comparison
39 if (substringLength === 1) {
40 return s1[s1Start] === s2[s2Start];
41 }
42
43 // Try all possible split points
44 for (let splitPoint = 1; splitPoint < substringLength; splitPoint++) {
45 const remainingLength: number = substringLength - splitPoint;
46
47 // Case 1: No swap - check if both parts match without swapping
48 if (checkScramble(s1Start, s2Start, splitPoint) &&
49 checkScramble(s1Start + splitPoint, s2Start + splitPoint, remainingLength)) {
50 memo[s1Start][s2Start][substringLength] = 1;
51 return true;
52 }
53
54 // Case 2: With swap - check if parts match after swapping
55 if (checkScramble(s1Start + splitPoint, s2Start, remainingLength) &&
56 checkScramble(s1Start, s2Start + remainingLength, splitPoint)) {
57 memo[s1Start][s2Start][substringLength] = 1;
58 return true;
59 }
60 }
61
62 // No valid scramble found
63 memo[s1Start][s2Start][substringLength] = 0;
64 return false;
65 };
66
67 return checkScramble(0, 0, length);
68}
69
Time and Space Complexity
The time complexity is O(n^4)
, and the space complexity is O(n^3)
, where n
is the length of the string.
Time Complexity Analysis:
- The function
dfs(i, j, k)
has three parameters, each ranging from0
ton
, creatingO(n^3)
possible states. - For each state with
k > 1
, we iterate throughh
from1
tok-1
, which takesO(k)
iterations in the worst case, wherek
can be up ton
. - Within each iteration, we make recursive calls that are memoized, so each recursive call takes
O(1)
time to retrieve cached results. - Therefore, the total time complexity is
O(n^3) × O(n) = O(n^4)
.
Space Complexity Analysis:
- The
@cache
decorator stores the results of all unique calls todfs(i, j, k)
. - There are at most
n
possible values for each ofi
,j
, andk
, resulting inO(n^3)
unique states to cache. - The recursion depth is at most
O(n)
(whenk
starts atn
and decreases by 1 each level). - The dominant factor is the cache storage, giving us a space complexity of
O(n^3)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Forgetting Early Optimization: Character Frequency Check
A common pitfall is diving straight into the recursive solution without first checking if the two strings have the same character frequencies. If s1
and s2
don't contain the exact same characters with the same frequencies, s2
cannot possibly be a scrambled version of s1
.
Why this matters: Without this check, the algorithm will perform expensive recursive calls even when the answer is obviously False
, leading to unnecessary computation.
Solution: Add a preliminary check before starting the recursion:
from collections import Counter
class Solution:
def isScramble(self, s1: str, s2: str) -> bool:
# Early termination: check if both strings have same characters
if Counter(s1) != Counter(s2):
return False
# If strings are identical, no need for complex checks
if s1 == s2:
return True
# Proceed with the recursive solution...
from functools import cache
@cache
def is_scramble_substring(start_s1: int, start_s2: int, length: int) -> bool:
# ... rest of the implementation
2. Index Calculation Errors in Swap Case
The most error-prone part is calculating the correct indices when checking the swap scenario. It's easy to mix up:
- Which part of
s1
should match which part ofs2
- The correct starting indices after swapping
- The correct lengths for each recursive call
Common mistakes:
- Using
j + h
instead ofj + k - h
for the swapped second part - Using
i + k - h
instead ofi + h
for the first part's starting position - Swapping the length parameters incorrectly
Solution: Visualize the swap operation clearly:
Original: s1[i:i+k] vs s2[j:j+k] Split s1 at position h: [i:i+h] and [i+h:i+k] Split s2 for swap: [j:j+(k-h)] and [j+(k-h):j+k] After swap matching: - s1[i+h:i+k] (right part, length k-h) matches s2[j:j+(k-h)] (left part, length k-h) - s1[i:i+h] (left part, length h) matches s2[j+(k-h):j+k] (right part, length h)
3. Not Handling Edge Cases Properly
Common oversights:
- Not checking if the strings have the same length initially
- Not handling empty strings (though typically not part of the problem constraints)
- Missing the case where both strings are identical
Solution: Add comprehensive initial checks:
class Solution:
def isScramble(self, s1: str, s2: str) -> bool:
# Edge case: different lengths
if len(s1) != len(s2):
return False
# Edge case: empty strings
if not s1:
return True
# Optimization: identical strings
if s1 == s2:
return True
# Character frequency check
if Counter(s1) != Counter(s2):
return False
# Proceed with main algorithm...
4. Inefficient Base Case Check
Some implementations check character frequency at every recursive level as a pruning technique, but doing this for every substring can actually slow down the algorithm due to the overhead of creating Counter objects repeatedly.
Better approach: Only do the frequency check at the top level, and trust the recursive structure to handle mismatches through the base case and split logic.
Which technique can we use to find the middle of a linked list?
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!