Hi👋SpeedAlgo • Deliberate Practice & Cognitive Ergonomics for Software Engineers

An interactive algorithm mastery and technical interview preparation platform published by Hi👋WebEnterprise. Built for senior and staff software engineers preparing for rigorous coding screens at top tech companies (FAANG/MAMAA).

13 Core Algorithmic Patterns & 225 Practice Problems

  • 1. Two Pointers (16 Paradigms, 43 Problems): Opposite ends, sorted pair sums, palindrome verification, trapping rain water, 3Sum, container with most water — includes the Sliding Window and Fast & Slow Pointers paradigms (contiguous subarray invariants, longest substrings, Floyd's cycle detection, linked list middle).
  • 2. Binary Search (9 Paradigms, 14 Problems): Monotonic predicate partitioning (feasible(x)), boundary search, rotated sorted arrays, square roots, minimum ship capacity, median of two sorted arrays, matrix median on value range.
  • 3. Bit Manipulation (5 Paradigms, 11 Problems): Counting set bits, XOR cancellation, bit reversal, bitmask pairing, maximum XOR with a bitwise trie.
  • 4. Math & Geometry (8 Paradigms, 14 Problems): Sieve of Eratosthenes, integer/roman conversions, modular arithmetic, geometric simulation.
  • 5. Tree/Graph Depth-First Search (15 Paradigms, 24 Problems): Path sums, lowest common ancestor, tree diameter, validating BSTs, tree DP (House Robber III, Binary Tree Cameras).
  • 6. Tree/Graph Breadth-First Search (5 Paradigms, 12 Problems): Level-order traversals, shortest path in unweighted graphs, zig-zag traversals, rotting oranges, word ladders.
  • 7. Graphs (12 Paradigms, 22 Problems): Topological sort, cycle detection, Dijkstra's algorithm, bipartite graph validation, network delay.
  • 8. Backtracking (6 Paradigms, 14 Problems): Subsets, permutations, combinations, constraint satisfaction, pruning, N-Queens, Sudoku solver.
  • 9. Dynamic Programming (13 Paradigms, 28 Problems): Memoization vs tabulation, 0/1 knapsack, unbounded knapsack, coin change, edit distance, longest common subsequence.
  • 10. Heap / Priority Queue (9 Paradigms, 11 Problems): Running medians, top-k elements, interval scheduling, IPO, k-way merges.
  • 11. Advanced Data Structures (7 Paradigms, 17 Problems): Trie (prefix tree), Union-Find (Disjoint Set Union), LRU Cache, LFU Cache, range-sum queries with updates.
  • 12. Stack (5 Paradigms, 9 Problems): Next greater element, largest rectangle in histogram, remove k digits, decode string, min stack.
  • 13. Intervals & Greedy (2 Paradigms, 6 Problems): Merge intervals, interval scheduling, meeting rooms by sweep line, jump game.

4-Stage Deliberate Practice Framework

  1. Stage 1 (Compare & Learn): Read the pattern's mental model, loop invariant and Big-O proof, compare the pattern's template with the problem's solution side by side, and use the step-by-step debugger. The solution can also be shown in C#, Java, TypeScript, C++, Go or Rust for almost every problem.
  2. Stage 2 (Active Recall): Rebuild the solution from memory in a code editor, with an invariant checklist and optional progressive hints, and check it against a judged test suite.
  3. Stage 3 (AI Interview Coach, coming soon): A mock-interview stage still in development; for now, students go straight from Stage 2 to Stage 4.
  4. Stage 4 (Solve on Your Own): Solve a new problem on your own against a 15-minute timer; your code is graded against a test suite. Python and TypeScript run in your browser and C# runs on our server (design problems grade in Python and TypeScript).

Equipped with a spaced-repetition review hub on a fixed 7-step schedule (1 day up to 90 days), a two-pane Studio workspace, and study notes.

Pricing, Access & Commercial Terms

  • Core Curriculum: 100% Free. No credit card required.
  • Coins: New accounts get 40 free coins once, the daily claim adds +20 coins once a day, and inviting a friend earns you +25 coins when they complete their first practice step. These are the only ways to earn coins.
  • AI Coach: Each AI coach question costs 1-5 coins, priced by the server according to its size.
  • BYOK (Bring Your Own Key): AI coaching is free with your own Google Gemini, OpenAI or OpenRouter API key (BYOK).
  • Extras: Coins also unlock extras for 24 hours: a Recall hint level (1 coin), a Solve solution reveal (2), a LeetCode problem card's solutions (1) and a pattern's extra canonical problems (1).
  • Payments & Refunds: Nothing on the site is sold for money: coins can only be earned, never bought. There are no subscriptions or charges, so there is nothing to refund.
  • Platform Operator: Hi👋WebEnterprise. Support & policies at hispeedalgo.com.
Skip to main content
Hi👋SpeedAlgo

Invariant-First Algorithmic Mastery

Arena
238Items
Theory Context•Backtracking & State Search

Builds on Combinations, which you haven’t finished yet.

P2MediumBuilt on Subsets~6 min speedrun

String Partitioning Backtracking

Built on
CombinationsThe choice is where the next piece ends, and a piece is taken only if it passes the check.
The problem

Palindrome Partitioning (LC 131)(opens leetcode.com)

Given a string s, cut it into consecutive pieces so that every piece reads the same forwards and backwards (a palindrome). Return every distinct way to cut s this way.

Example 1
Input:s = "aab"
Output:[["a","a","b"],["aa","b"]]
"aa" is a palindrome, so it may stay together; "aab" and "ab" are not, so no other cut works.

String Partitioning

string s ➔ valid prefix check ➔ recurse on suffix starting end + 1
Spot it when: A string must be cut into pieces that each pass a check (palindrome, dictionary word, valid IP part), and you list or count the ways.
String Partitioning
Python
1from typing import Callable, List
2 
3 
4def split_string(s: str, is_valid_piece: Callable[[List[str], int, int], bool]) -> List[List[str]]:
5 """String Partitioning: every way to cut s into pieces, where each piece s[start:end + 1] passes is_valid_piece.
6 is_valid_piece(path, start, end) checks the piece from index start to index end, both included.
7 path holds the pieces cut before it, so the check can compare with them (Additive Number, unique pieces).
8 Precondition: whether a piece is valid depends only on path and on the piece itself.
9 Expression Add Operators does not fit, because it also chooses an operator between pieces.
10 N = len(s). B = the calls to backtrack. I = the loop steps over all calls. G = the splits saved.
11 T = the cost of one is_valid_piece call.
12 Time: O(I * T + (B + G) * N) | Space: O(N) path + recursion stack, output not counted
13 """
14 results: List[List[str]] = []
15 path: List[str] = []
16 
17 def backtrack(start: int) -> None:
18 # 1. Save a Complete Split:
19 # start is the first index no piece covers yet, so s is fully cut only when
20 # start reaches len(s); a split saved earlier leaves the end of s unused.
21 if start == len(s):
22 results.append(list(path))
23 return
24 
25 for end in range(start, len(s)):
26 # 2. Choose, Explore, Un-choose, One First Piece at a Time:
27 # is_valid_piece checks the candidate piece s[start:end + 1]; trying every
28 # end from start builds each split exactly once, since a split has exactly
29 # one first piece.
30 if is_valid_piece(path, start, end):
31 path.append(s[start:end + 1])
32 backtrack(end + 1)
33 path.pop()
34 
35 backtrack(0)
36 return results
Invariant: The pieces in path, joined in order, spell exactly s[:start], and each piece passed is_palindrome.

Every split is a first piece followed by a split of the rest, so at start try each end, and when is_palindrome(start, end) holds, append s[start:end + 1] to path and recurse on end + 1. Each split has exactly one first piece, so trying every end in turn builds every split once and never twice. The trap is where to save: only when start == len(s), because a path saved earlier leaves the end of s unused.

See it run

String Partitioning on a string, step by step

start 0 · end - · path [] · saved 0

start 0 · end 0 · path ["a"] · saved 0

start 1 · end 1 · path ["a", "a"] · saved 0

start 2 · end 2 · path ["a", "a", "b"] · saved 0

start 3 · end - · path ["a", "a", "b"] · saved 1

start 2 · end 2 · path ["a", "a"] · saved 1

start 1 · end 1 · path ["a"] · saved 1

start 1 · end 2 · path ["a"] · saved 1

start 0 · end 0 · path [] · saved 1

start 0 · end 1 · path ["aa"] · saved 1

start 2 · end 2 · path ["aa", "b"] · saved 1

start 3 · end - · path ["aa", "b"] · saved 2

start 2 · end 2 · path ["aa"] · saved 2

start 0 · end 1 · path [] · saved 2

start 0 · end 2 · path [] · saved 2

start - · end - · path [] · saved 2 · answer [["a","a","b"],["aa","b"]]

The first row is the letters of s. The second row marks a cut before a letter with |. The third row holds the pieces of path, in order.

  • piece the piece s[start:end + 1] that is being tested, and its slot in path once appended
  • in path a piece that is in path now
  • saved a complete split, copied into results
  • undo a piece that path.pop() has just removed

Start: s = "aab" has 3 letters, path is empty, and backtrack(0) begins.

start = 0, end = 0: s[0:1] is "a", a palindrome. Append it to path and call backtrack(1).

start = 1, end = 1: s[1:2] is "a", a palindrome. Append it to path and call backtrack(2).

start = 2, end = 2: s[2:3] is "b", a palindrome. Append it to path and call backtrack(3).

start = 3 equals len(s), so the pieces cover all of s and ["a", "a", "b"] is saved. A call with start < 3 never saves, because its path leaves the end of s unused.

path.pop() removes "b". The loop at start = 2 goes on with the next end.

path.pop() removes "a". The loop at start = 1 goes on with the next end.

start = 1, end = 2: s[1:3] is "ab", not a palindrome. Skip it, so nothing is appended.

path.pop() removes "a". The loop at start = 0 goes on with the next end.

start = 0, end = 1: s[0:2] is "aa", a palindrome. Append it to path and call backtrack(2).

start = 2, end = 2: s[2:3] is "b", a palindrome. Append it to path and call backtrack(3).

start = 3 equals len(s), so the pieces cover all of s and ["aa", "b"] is saved. A call with start < 3 never saves, because its path leaves the end of s unused.

path.pop() removes "b". The loop at start = 2 goes on with the next end.

path.pop() removes "aa". The loop at start = 0 goes on with the next end.

start = 0, end = 2: s[0:3] is "aab", not a palindrome. Skip it, so nothing is appended.

Done: backtrack(0) has tried every end, and 2 splits were saved.

s
0a
1a
2b
cut before
path
1 / 16
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Saving path before start reaches len(s)

    Mistake
    results.append(list(path))
    if start == len(s):
        return
    Fix
    if start == len(s):
        results.append(list(path))
        return

    What happens: start is the first index that no piece covers yet. While start < len(s), the pieces in path leave the end of s unused. Saving on every call also saves those partial splits, such as ["a"] and ["a", "a"].

    Example: s = "aab"
    Mistake: returns [[], ['a'], ['a', 'a'], ['a', 'a', 'b'], ['aa'], ['aa', 'b']]Fix: returns [['a', 'a', 'b'], ['aa', 'b']]
  2. 2

    Gotcha 2: Taking the piece as s[start:end]

    Mistake
    path.append(s[start:end])
    Fix
    path.append(s[start:end + 1])

    What happens: end is the index of the last letter of the piece, so it is inclusive. A Python slice stops before its end index. So s[start:end] drops the last letter of every piece, and a one-letter piece becomes the empty string.

    Example: s = "aab"
    Mistake: returns [['', '', ''], ['a', '']]Fix: returns [['a', 'a', 'b'], ['aa', 'b']]
  3. 3

    Gotcha 3: Re-checking the same piece in many branches

    What happens: is_palindrome(start, end) walks the piece every time, which costs O(n). Many branches check the same pair (start, end) again. The answers stay the same, but long strings get slow. Fill a table is_pal[i][j] once, in O(n^2) time, and read each check in O(1).

  4. 4

    Gotcha 4: Allowing a part with a leading zero

    Mistake
    if int(part) <= 255:
    Fix
    if (part == "0" or part[0] != "0") and int(part) <= 255:

    What happens: Restore IP Addresses (LC 93) cuts s into 4 parts. Each part has 1 to 3 digits and is at most 255. A part may start with 0 only when it is exactly "0". Without that test, parts such as "00" and "01" are accepted.

    Example: Restore IP Addresses (LC 93): s = "10001"
    Mistake: returns ['1.0.0.01', '1.0.00.1', '1.00.0.1', '10.0.0.1']Fix: returns ['10.0.0.1']
Not this one if…
  • Only the number of ways to cut the string is asked, as in Decode Ways (LC 91), not the cuts themselves: the same start is reached again and again, so one stored count per start replaces listing every split. See Memoization

Deep Dive

A split is one first piece, then a split of the rest

Combinations already shows the start index: the loop only picks from start onward, so each result is built in one order. This card changes one thing. The pieces must cover s from the first letter to the last, with no gap and no overlap. So the next call starts at end + 1, the first letter after the piece.

Every split of s has exactly one first piece. If the first piece is s[start:end + 1], the rest of the split is a split of s[end + 1:]. The loop tries every end from start. Each try fixes the first piece, and the recursive call lists every way to split the rest. So every split is built once, and no split is built twice.

A string of length N has N - 1 places where a cut may go. Each place is cut or not cut, so there are at most 2^(N - 1) splits. For s = "aaaa", every piece is a palindrome, so all 2^3 = 8 splits are valid. For s = "aaaaaaaa", there are 128.

Example: for s = "abba", the answer is [["a","b","b","a"],["a","bb","a"],["abba"]]. For s = "abc", only one split works: [["a","b","c"]].

One trace: when path is saved, and what the undo does

The board replays partition("aab"). The answer is [["a","a","b"],["aa","b"]]. The first row holds the letters of s. The second row shows a cut before a letter. The third row is path.

backtrack(0) tries end = 0. The piece "a" is a palindrome, so it is appended and backtrack(1) runs. In the same way, "a" and then "b" are appended, and backtrack(3) starts with start = 3 = len(s). The pieces cover all of s, so ["a", "a", "b"] is saved. This is the step marked as the trap on the board.

Then path.pop() removes "b", and the call for start = 1 tries end = 2. The piece "ab" is not a palindrome, so nothing is appended. After one more pop, backtrack(0) tries end = 1. The piece "aa" is appended, then "b", and ["aa", "b"] is saved. The last try, "aab", is not a palindrome.

In all, 6 calls of backtrack run, the loops take 7 steps, and 7 palindrome checks are made. Only 2 of the 6 calls have start == len(s), and only those 2 save.

Example: if the code saved on every call, partition("aab") would return [[], ["a"], ["a", "a"], ["a", "a", "b"], ["aa"], ["aa", "b"]]. The extra lists are partial splits that leave the end of s unused.

The piece check can read path, and the check changes with the problem

The template takes is_valid_piece(path, start, end). The loop never changes. Only this check changes from one problem to the next. It receives path so a check can compare the new piece with the pieces before it.

In Palindrome Partitioning (LC 131), the check reads only the piece: is_palindrome(start, end). In Restore IP Addresses (LC 93), the check is that the piece is "0" or has no leading zero, and its value is at most 255. The call also needs exactly 4 pieces. For s = "25525511135", the answer is ["255.255.11.135", "255.255.111.35"].

In Split a String Into the Max Number of Unique Substrings (LC 1593), the check reads path: the piece must not already be in path. The answer is the largest len(path) seen at a save. For s = "ababccc", it is 5. In Additive Number (LC 306), the check compares the piece with the sum of the last two pieces in path.

The template does not fit Expression Add Operators (LC 282). That problem also chooses an operator between the pieces, so one cut has more than one choice.

Example: if the check fails for every end at some start, the loop ends and the call returns without a save. That branch is cut off. For s = "abc", partition makes 4 calls and 6 palindrome checks, and it saves 1 split.

The cost: calls and loop steps times the cost of one check

The card's complexity line is O(I * T + (B + G) * N). B is the number of backtrack calls, I is the number of loop steps over all calls, and G is the number of splits saved. T is the cost of one check, and N is len(s).

For s = "aaaa", B = 16 and I = 15, and each loop step makes one palindrome check. For s = "aaaaaaaa", B = 256, I = 255 and G = 128. The numbers double when the string gets one letter longer, so the cost grows as 2^N.

One call of is_palindrome walks the piece and costs O(N). That gives O(N * 2^N) time for Palindrome Partitioning. The stack and path use O(N) space, and the saved output is not counted.

The same pair (start, end) is checked in many branches. A table is_pal[i][j], built once in O(N^2) time, makes each check cost O(1). Each saved split is still copied with list(path), which costs O(N). So the worst case stays O(N * 2^N), and the table removes the repeated work only.

Example: if only the number of splits is asked, as in Decode Ways (LC 91), do not list them. The same start is reached again and again, so one stored count per start is enough.

60-Second Pitch

"A split has exactly one first piece, so I fix that piece and split the rest. At start, I try every end. If s[start:end + 1] passes the check, I append it to path, call backtrack(end + 1), and then pop it. I save path only when start == len(s), because only then do the pieces cover all of s. I save a copy, not path itself. Each split is built once. The number of splits is at most 2^(N - 1), and each check costs O(N), so the time is O(N * 2^N). With a table of palindrome answers, each check is O(1). If the question asks only for a count, I would use one stored count per start instead of listing the splits."

Common Questions

Why does the recursive call use end + 1 and not start + 1?

The piece s[start:end + 1] is already used. The next piece must begin at the first unused letter, which is end + 1. Using start + 1 would make pieces overlap, and the pieces would no longer spell s.

Why is path saved only when start == len(s)?

start is the first letter that no piece covers. Before it reaches len(s), the pieces in path leave the end of s unused. For "aab", saving on every call returns 6 lists instead of the 2 splits.

How do you speed up the palindrome check?

Build a table is_pal[i][j] once. A piece s[i:j + 1] is a palindrome when s[i] == s[j] and the inside is_pal[i + 1][j - 1] is a palindrome, or the inside is empty or one letter. The table costs O(N^2), and each check then costs O(1). The saved output is still copied, so the worst case stays O(N * 2^N).

What changes for Restore IP Addresses (LC 93)?

Only the check and the stop rule. Each piece has 1 to 3 digits, is "0" or has no leading zero, and is at most 255. The save needs len(path) == 4 and start == len(s). The loop tries at most 3 values of end, so the search is small.

When is a count enough, and which technique do you use then?

If the question asks how many splits exist, as in Decode Ways (LC 91), the same start is reached again and again. Store the count for each start and reuse it. That is memoization, and it takes O(N) states instead of listing 2^(N - 1) splits.

SWE Reasoning

Spot it

  • A string must be cut into pieces, and each piece must pass a test: a palindrome, a valid IP part, a dictionary word, an unused piece.
  • The pieces, joined in order, must give back the whole string with nothing left over.
  • The question asks for every split, or for the best split found by trying all of them, and N is small, about 16 to 20.
  • The pieces are substrings in a fixed order. They are not a choice of items from a set.
  • The test for a piece depends only on the piece and on the pieces before it.

The invariant, step by step

  1. backtrack(start) is called when the pieces in path, joined in order, spell exactly s[:start], and each piece passed is_palindrome.
  2. If start == len(s), the pieces spell all of s. The call saves list(path) and returns.
  3. Otherwise the loop tries each end from start. The candidate piece is s[start:end + 1].
  4. If the piece passes, it is appended to path, so the pieces now spell s[:end + 1]. The call backtrack(end + 1) keeps the invariant.
  5. After the call, path.pop() removes the piece. path is again exactly as it was before the append, and the loop tries the next end.
  6. A split has one first piece, so each split is built once. A piece that fails is never appended, and its branch is never entered.

Traps

  • Saving before start reaches len(s). Saved lists include partial splits. For "aab", the result has 6 lists instead of 2, and one of them is empty.
  • Slicing s[start:end]. The slice stops before end, so each piece loses its last letter. For "aab", the result is [["", "", ""], ["a", ""]].
  • Saving path itself. path keeps changing, so the saved list ends up empty. Save list(path).
  • Skipping path.pop(). The next piece is appended after the old one, and the pieces no longer spell s[:start].
  • A leading zero in Restore IP Addresses (LC 93). For s = "10001", a check that only tests int(part) <= 255 also returns "1.0.0.01". The right answer is ["10.0.0.1"].
  • Checking the same pair again and again. Each check costs O(N). A table is_pal[i][j] makes it O(1).

Practice

Canonical LeetCode:Palindrome Partitioning (LC 131)Restore IP Addresses (LC 93)Additive Number (LC 306)Split a String Into the Max Number of Unique Substrings (LC 1593)Word Pattern II (LC 291)Word Break II (LC 140)
Theory Context•Backtracking & State Search

Builds on Combinations, which you haven’t finished yet.

P2MediumBuilt on Subsets~6 min speedrun

String Partitioning Backtracking

Built on
CombinationsThe choice is where the next piece ends, and a piece is taken only if it passes the check.
The problem

Palindrome Partitioning (LC 131)(opens leetcode.com)

Given a string s, cut it into consecutive pieces so that every piece reads the same forwards and backwards (a palindrome). Return every distinct way to cut s this way.

Example 1
Input:s = "aab"
Output:[["a","a","b"],["aa","b"]]
"aa" is a palindrome, so it may stay together; "aab" and "ab" are not, so no other cut works.

String Partitioning

string s ➔ valid prefix check ➔ recurse on suffix starting end + 1
Spot it when: A string must be cut into pieces that each pass a check (palindrome, dictionary word, valid IP part), and you list or count the ways.
String Partitioning
Python
1from typing import Callable, List
2 
3 
4def split_string(s: str, is_valid_piece: Callable[[List[str], int, int], bool]) -> List[List[str]]:
5 """String Partitioning: every way to cut s into pieces, where each piece s[start:end + 1] passes is_valid_piece.
6 is_valid_piece(path, start, end) checks the piece from index start to index end, both included.
7 path holds the pieces cut before it, so the check can compare with them (Additive Number, unique pieces).
8 Precondition: whether a piece is valid depends only on path and on the piece itself.
9 Expression Add Operators does not fit, because it also chooses an operator between pieces.
10 N = len(s). B = the calls to backtrack. I = the loop steps over all calls. G = the splits saved.
11 T = the cost of one is_valid_piece call.
12 Time: O(I * T + (B + G) * N) | Space: O(N) path + recursion stack, output not counted
13 """
14 results: List[List[str]] = []
15 path: List[str] = []
16 
17 def backtrack(start: int) -> None:
18 # 1. Save a Complete Split:
19 # start is the first index no piece covers yet, so s is fully cut only when
20 # start reaches len(s); a split saved earlier leaves the end of s unused.
21 if start == len(s):
22 results.append(list(path))
23 return
24 
25 for end in range(start, len(s)):
26 # 2. Choose, Explore, Un-choose, One First Piece at a Time:
27 # is_valid_piece checks the candidate piece s[start:end + 1]; trying every
28 # end from start builds each split exactly once, since a split has exactly
29 # one first piece.
30 if is_valid_piece(path, start, end):
31 path.append(s[start:end + 1])
32 backtrack(end + 1)
33 path.pop()
34 
35 backtrack(0)
36 return results
Invariant: The pieces in path, joined in order, spell exactly s[:start], and each piece passed is_palindrome.

Every split is a first piece followed by a split of the rest, so at start try each end, and when is_palindrome(start, end) holds, append s[start:end + 1] to path and recurse on end + 1. Each split has exactly one first piece, so trying every end in turn builds every split once and never twice. The trap is where to save: only when start == len(s), because a path saved earlier leaves the end of s unused.

See it run

String Partitioning on a string, step by step

start 0 · end - · path [] · saved 0

start 0 · end 0 · path ["a"] · saved 0

start 1 · end 1 · path ["a", "a"] · saved 0

start 2 · end 2 · path ["a", "a", "b"] · saved 0

start 3 · end - · path ["a", "a", "b"] · saved 1

start 2 · end 2 · path ["a", "a"] · saved 1

start 1 · end 1 · path ["a"] · saved 1

start 1 · end 2 · path ["a"] · saved 1

start 0 · end 0 · path [] · saved 1

start 0 · end 1 · path ["aa"] · saved 1

start 2 · end 2 · path ["aa", "b"] · saved 1

start 3 · end - · path ["aa", "b"] · saved 2

start 2 · end 2 · path ["aa"] · saved 2

start 0 · end 1 · path [] · saved 2

start 0 · end 2 · path [] · saved 2

start - · end - · path [] · saved 2 · answer [["a","a","b"],["aa","b"]]

The first row is the letters of s. The second row marks a cut before a letter with |. The third row holds the pieces of path, in order.

  • piece the piece s[start:end + 1] that is being tested, and its slot in path once appended
  • in path a piece that is in path now
  • saved a complete split, copied into results
  • undo a piece that path.pop() has just removed

Start: s = "aab" has 3 letters, path is empty, and backtrack(0) begins.

start = 0, end = 0: s[0:1] is "a", a palindrome. Append it to path and call backtrack(1).

start = 1, end = 1: s[1:2] is "a", a palindrome. Append it to path and call backtrack(2).

start = 2, end = 2: s[2:3] is "b", a palindrome. Append it to path and call backtrack(3).

start = 3 equals len(s), so the pieces cover all of s and ["a", "a", "b"] is saved. A call with start < 3 never saves, because its path leaves the end of s unused.

path.pop() removes "b". The loop at start = 2 goes on with the next end.

path.pop() removes "a". The loop at start = 1 goes on with the next end.

start = 1, end = 2: s[1:3] is "ab", not a palindrome. Skip it, so nothing is appended.

path.pop() removes "a". The loop at start = 0 goes on with the next end.

start = 0, end = 1: s[0:2] is "aa", a palindrome. Append it to path and call backtrack(2).

start = 2, end = 2: s[2:3] is "b", a palindrome. Append it to path and call backtrack(3).

start = 3 equals len(s), so the pieces cover all of s and ["aa", "b"] is saved. A call with start < 3 never saves, because its path leaves the end of s unused.

path.pop() removes "b". The loop at start = 2 goes on with the next end.

path.pop() removes "aa". The loop at start = 0 goes on with the next end.

start = 0, end = 2: s[0:3] is "aab", not a palindrome. Skip it, so nothing is appended.

Done: backtrack(0) has tried every end, and 2 splits were saved.

s
0a
1a
2b
cut before
path
1 / 16
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Saving path before start reaches len(s)

    Mistake
    results.append(list(path))
    if start == len(s):
        return
    Fix
    if start == len(s):
        results.append(list(path))
        return

    What happens: start is the first index that no piece covers yet. While start < len(s), the pieces in path leave the end of s unused. Saving on every call also saves those partial splits, such as ["a"] and ["a", "a"].

    Example: s = "aab"
    Mistake: returns [[], ['a'], ['a', 'a'], ['a', 'a', 'b'], ['aa'], ['aa', 'b']]Fix: returns [['a', 'a', 'b'], ['aa', 'b']]
  2. 2

    Gotcha 2: Taking the piece as s[start:end]

    Mistake
    path.append(s[start:end])
    Fix
    path.append(s[start:end + 1])

    What happens: end is the index of the last letter of the piece, so it is inclusive. A Python slice stops before its end index. So s[start:end] drops the last letter of every piece, and a one-letter piece becomes the empty string.

    Example: s = "aab"
    Mistake: returns [['', '', ''], ['a', '']]Fix: returns [['a', 'a', 'b'], ['aa', 'b']]
  3. 3

    Gotcha 3: Re-checking the same piece in many branches

    What happens: is_palindrome(start, end) walks the piece every time, which costs O(n). Many branches check the same pair (start, end) again. The answers stay the same, but long strings get slow. Fill a table is_pal[i][j] once, in O(n^2) time, and read each check in O(1).

  4. 4

    Gotcha 4: Allowing a part with a leading zero

    Mistake
    if int(part) <= 255:
    Fix
    if (part == "0" or part[0] != "0") and int(part) <= 255:

    What happens: Restore IP Addresses (LC 93) cuts s into 4 parts. Each part has 1 to 3 digits and is at most 255. A part may start with 0 only when it is exactly "0". Without that test, parts such as "00" and "01" are accepted.

    Example: Restore IP Addresses (LC 93): s = "10001"
    Mistake: returns ['1.0.0.01', '1.0.00.1', '1.00.0.1', '10.0.0.1']Fix: returns ['10.0.0.1']
Not this one if…
  • Only the number of ways to cut the string is asked, as in Decode Ways (LC 91), not the cuts themselves: the same start is reached again and again, so one stored count per start replaces listing every split. See Memoization

Deep Dive

A split is one first piece, then a split of the rest

Combinations already shows the start index: the loop only picks from start onward, so each result is built in one order. This card changes one thing. The pieces must cover s from the first letter to the last, with no gap and no overlap. So the next call starts at end + 1, the first letter after the piece.

Every split of s has exactly one first piece. If the first piece is s[start:end + 1], the rest of the split is a split of s[end + 1:]. The loop tries every end from start. Each try fixes the first piece, and the recursive call lists every way to split the rest. So every split is built once, and no split is built twice.

A string of length N has N - 1 places where a cut may go. Each place is cut or not cut, so there are at most 2^(N - 1) splits. For s = "aaaa", every piece is a palindrome, so all 2^3 = 8 splits are valid. For s = "aaaaaaaa", there are 128.

Example: for s = "abba", the answer is [["a","b","b","a"],["a","bb","a"],["abba"]]. For s = "abc", only one split works: [["a","b","c"]].

One trace: when path is saved, and what the undo does

The board replays partition("aab"). The answer is [["a","a","b"],["aa","b"]]. The first row holds the letters of s. The second row shows a cut before a letter. The third row is path.

backtrack(0) tries end = 0. The piece "a" is a palindrome, so it is appended and backtrack(1) runs. In the same way, "a" and then "b" are appended, and backtrack(3) starts with start = 3 = len(s). The pieces cover all of s, so ["a", "a", "b"] is saved. This is the step marked as the trap on the board.

Then path.pop() removes "b", and the call for start = 1 tries end = 2. The piece "ab" is not a palindrome, so nothing is appended. After one more pop, backtrack(0) tries end = 1. The piece "aa" is appended, then "b", and ["aa", "b"] is saved. The last try, "aab", is not a palindrome.

In all, 6 calls of backtrack run, the loops take 7 steps, and 7 palindrome checks are made. Only 2 of the 6 calls have start == len(s), and only those 2 save.

Example: if the code saved on every call, partition("aab") would return [[], ["a"], ["a", "a"], ["a", "a", "b"], ["aa"], ["aa", "b"]]. The extra lists are partial splits that leave the end of s unused.

The piece check can read path, and the check changes with the problem

The template takes is_valid_piece(path, start, end). The loop never changes. Only this check changes from one problem to the next. It receives path so a check can compare the new piece with the pieces before it.

In Palindrome Partitioning (LC 131), the check reads only the piece: is_palindrome(start, end). In Restore IP Addresses (LC 93), the check is that the piece is "0" or has no leading zero, and its value is at most 255. The call also needs exactly 4 pieces. For s = "25525511135", the answer is ["255.255.11.135", "255.255.111.35"].

In Split a String Into the Max Number of Unique Substrings (LC 1593), the check reads path: the piece must not already be in path. The answer is the largest len(path) seen at a save. For s = "ababccc", it is 5. In Additive Number (LC 306), the check compares the piece with the sum of the last two pieces in path.

The template does not fit Expression Add Operators (LC 282). That problem also chooses an operator between the pieces, so one cut has more than one choice.

Example: if the check fails for every end at some start, the loop ends and the call returns without a save. That branch is cut off. For s = "abc", partition makes 4 calls and 6 palindrome checks, and it saves 1 split.

The cost: calls and loop steps times the cost of one check

The card's complexity line is O(I * T + (B + G) * N). B is the number of backtrack calls, I is the number of loop steps over all calls, and G is the number of splits saved. T is the cost of one check, and N is len(s).

For s = "aaaa", B = 16 and I = 15, and each loop step makes one palindrome check. For s = "aaaaaaaa", B = 256, I = 255 and G = 128. The numbers double when the string gets one letter longer, so the cost grows as 2^N.

One call of is_palindrome walks the piece and costs O(N). That gives O(N * 2^N) time for Palindrome Partitioning. The stack and path use O(N) space, and the saved output is not counted.

The same pair (start, end) is checked in many branches. A table is_pal[i][j], built once in O(N^2) time, makes each check cost O(1). Each saved split is still copied with list(path), which costs O(N). So the worst case stays O(N * 2^N), and the table removes the repeated work only.

Example: if only the number of splits is asked, as in Decode Ways (LC 91), do not list them. The same start is reached again and again, so one stored count per start is enough.

60-Second Pitch

"A split has exactly one first piece, so I fix that piece and split the rest. At start, I try every end. If s[start:end + 1] passes the check, I append it to path, call backtrack(end + 1), and then pop it. I save path only when start == len(s), because only then do the pieces cover all of s. I save a copy, not path itself. Each split is built once. The number of splits is at most 2^(N - 1), and each check costs O(N), so the time is O(N * 2^N). With a table of palindrome answers, each check is O(1). If the question asks only for a count, I would use one stored count per start instead of listing the splits."

Common Questions

Why does the recursive call use end + 1 and not start + 1?

The piece s[start:end + 1] is already used. The next piece must begin at the first unused letter, which is end + 1. Using start + 1 would make pieces overlap, and the pieces would no longer spell s.

Why is path saved only when start == len(s)?

start is the first letter that no piece covers. Before it reaches len(s), the pieces in path leave the end of s unused. For "aab", saving on every call returns 6 lists instead of the 2 splits.

How do you speed up the palindrome check?

Build a table is_pal[i][j] once. A piece s[i:j + 1] is a palindrome when s[i] == s[j] and the inside is_pal[i + 1][j - 1] is a palindrome, or the inside is empty or one letter. The table costs O(N^2), and each check then costs O(1). The saved output is still copied, so the worst case stays O(N * 2^N).

What changes for Restore IP Addresses (LC 93)?

Only the check and the stop rule. Each piece has 1 to 3 digits, is "0" or has no leading zero, and is at most 255. The save needs len(path) == 4 and start == len(s). The loop tries at most 3 values of end, so the search is small.

When is a count enough, and which technique do you use then?

If the question asks how many splits exist, as in Decode Ways (LC 91), the same start is reached again and again. Store the count for each start and reuse it. That is memoization, and it takes O(N) states instead of listing 2^(N - 1) splits.

SWE Reasoning

Spot it

  • A string must be cut into pieces, and each piece must pass a test: a palindrome, a valid IP part, a dictionary word, an unused piece.
  • The pieces, joined in order, must give back the whole string with nothing left over.
  • The question asks for every split, or for the best split found by trying all of them, and N is small, about 16 to 20.
  • The pieces are substrings in a fixed order. They are not a choice of items from a set.
  • The test for a piece depends only on the piece and on the pieces before it.

The invariant, step by step

  1. backtrack(start) is called when the pieces in path, joined in order, spell exactly s[:start], and each piece passed is_palindrome.
  2. If start == len(s), the pieces spell all of s. The call saves list(path) and returns.
  3. Otherwise the loop tries each end from start. The candidate piece is s[start:end + 1].
  4. If the piece passes, it is appended to path, so the pieces now spell s[:end + 1]. The call backtrack(end + 1) keeps the invariant.
  5. After the call, path.pop() removes the piece. path is again exactly as it was before the append, and the loop tries the next end.
  6. A split has one first piece, so each split is built once. A piece that fails is never appended, and its branch is never entered.

Traps

  • Saving before start reaches len(s). Saved lists include partial splits. For "aab", the result has 6 lists instead of 2, and one of them is empty.
  • Slicing s[start:end]. The slice stops before end, so each piece loses its last letter. For "aab", the result is [["", "", ""], ["a", ""]].
  • Saving path itself. path keeps changing, so the saved list ends up empty. Save list(path).
  • Skipping path.pop(). The next piece is appended after the old one, and the pieces no longer spell s[:start].
  • A leading zero in Restore IP Addresses (LC 93). For s = "10001", a check that only tests int(part) <= 255 also returns "1.0.0.01". The right answer is ["10.0.0.1"].
  • Checking the same pair again and again. Each check costs O(N). A table is_pal[i][j] makes it O(1).

Practice

Canonical LeetCode:Palindrome Partitioning (LC 131)Restore IP Addresses (LC 93)Additive Number (LC 306)Split a String Into the Max Number of Unique Substrings (LC 1593)Word Pattern II (LC 291)Word Break II (LC 140)
© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•