Given a string s, return the longest palindromic substring in s.
1 ≤ s.length ≤ 1000s consists of only digits and English letters.We're going to build the intuition from the ground up β not just memorize the solution. By the end, you'll know how to derive the recurrence for any "optimal substring" problem.
A string s is a palindrome if it reads the same forwards and backwards:
"racecar" β outer chars match, and the inside is also a palindrome.
This is the recurrence in disguise:
s[i..j] is a palindrome iff:
s[i] === s[j] β the outer characters match, ANDs[i+1 .. j-1] is a palindrome β the inner substring is also a palindrome.Base cases: every single character is a palindrome. Two identical adjacent characters are a palindrome.
Break this down:
j - i < 2 covers lengths 1 (i==j) and 2 (j==i+1). No inner substring needed β just check s[i]==s[j].Any problem where the answer for [i..j] depends on [i+1..j-1] follows this exact shape:
This shows up in: Palindrome Partitioning, Longest Palindromic Subsequence, Minimum Insertions to Form Palindrome, and many "interval DP" problems.
Choose an example string and step through how the DP table fills:
You cannot fill row-by-row left-to-right, because dp[i][j] depends on dp[i+1][j-1] (the cell below-left).
Strategy: Fill by substring length.
| T | ||||
| T | ||||
| T | ||||
| T | ||||
| T |
Pass 1: length=1 (diagonal)
Pass 2: length=2 (one off diagonal)
Pass 3: length=3 (two off diagonal)
... and so on until length=n.
dp[i][j] depends on a strictly shorter interval, iterating by length guarantees correct dependency order.
dp[i][j] only depends on row i+1, you can reduce to O(n) space with two rows. But for learning, the full table is clearer.
| Problem | Recurrence | Traversal |
|---|---|---|
| Longest Palindromic Substring | dp[i][j] = s[i]==s[j] && dp[i+1][j-1] |
By length |
| Longest Palindromic Subsequence | dp[i][j] = s[i]==s[j] ? 2+dp[i+1][j-1] : max(dp[i+1][j], dp[i][j-1]) |
By length |
| Palindrome Partitioning II (min cuts) | Use palindrome DP table + min-cut DP | By length |
| Stone Game / Burst Balloons | dp[i][j] = max(dp[i][k] + dp[k][j] + ...) |
By length |
The common thread: interval DP β the answer for [i..j] splits into smaller intervals. Always fill by increasing length.
range(4, len(s)) stops at len(s)-1, so you never check the full string. For "babad" (len=5), you only check str_len=4. The substring "babad" itself is never evaluated. If the longest palindrome is the whole string, your code returns the wrong answer.
range(4, len(s) + 1)
longest_palindrom BLOCKINGInside the lengthβ₯4 loop, you write to longest_palindrom (missing an 'e'). This creates a new local variable β it never updates longest_palindrome. The function always returns whatever was found in the length-2 or length-3 passes. For any string where the longest palindrome has length β₯ 4, the result is wrong.
β οΈ This is the kind of bug that's very hard to spot in an interview because your brain autocorrects the spelling.
self. on hash_array_get BLOCKINGInside the lengthβ₯4 loop you call hash_array_get(dp, i+1, j-1) without self.. This will throw a NameError at runtime β the function is never defined in local or global scope.
j calculation: j = idx + str_len BLOCKINGThis is the only logic-level bug β and it's subtle. Look at lengths 2 and 3:
j = idx + 1 β and s[i:j+1] slices 2 chars. βj = idx + 2 β and s[i:j+1] slices 3 chars. βBut in the generalized loop, j = idx + str_len gives a substring of length str_len + 1, not str_len. For str_len=4, you're checking substrings of length 5. The range(len(s) - str_len) compensates by shrinking the loop range, but the two errors partially mask each other β until they don't.
Counterexample: "abba" β the whole string is a palindrome. But:
j = idx + str_len - 1 (so the substring s[i:j+1] has exactly str_len characters)
if len(s) < 1: return '0' β the character '0' is a valid English letter/digit and could appear in the input. An empty string should return "". Returning '0' is misleading and would fail test cases where the input is empty (though constraints say lenβ₯1, it's sloppy).
You never initialize dp[i][i] = True. The code works around this by special-casing lengths 2 and 3, but this is fragile. If you later refactored to unify the loops, the recurrence dp[i+1][j-1] would fail for substrings where the inner portion is length 1 (because hash_array_get returns False for unset keys).
Missing base cases = missing the foundation of DP. The interviewer wants to see you explicitly set up the base.
You have three separate loops (len=2, len=3, lenβ₯4) when the general recurrence handles all of them. Length 2 (j-i+1=2): the inner substring dp[i+1][j-1] doesn't exist (i+1 > j-1), so the recurrence needs a guard. Length 3: the inner substring is length 1, which is always a palindrome, so s[i]==s[j] suffices. But all this collapses into one loop with the len <= 3 guard β exactly what the canonical solution does.
if s[i] == s[j] and (len <= 3 or dp[i+1][j-1])
try/except assert for dict defaultshash_array_set uses assert dp[i] inside a try/except to detect missing keys. This is non-idiomatic and confusing:
assert can be disabled with python -O, silently breaking the codedp.setdefault(i, {}) or if i not in dp: dp[i] = {}hash_array_get has an unused parameter val β dead codedict[int, int] suggests values are int, but you're storing nested dicts of bools. The correct hint is dict[int, dict[int, bool]]. Wrong type hints signal to the interviewer that you're not thinking about the data structure's shape.
The idea of using a hash-map to "avoid storing all the data" is premature optimization. For nβ€1000, an nΓn list-of-lists uses ~8MB for booleans β negligible. The hash-map approach adds overhead (hashing, collisions), makes traversal harder, and doesn't reduce asymptotic complexity. The real space optimization is the two-row rolling array that drops O(nΒ²)βO(n).
for len in range(2, n+1) replaces three ad-hoc loopsdp[i][i] = True is initialized up front[[False]*n for _ in range(n)] is simpler, faster, and idiomatic"" for empty input, s[0] for single-char inputYou understood the recurrence perfectly β this is the hard part. The intuition that dp[i][j] = s[i]==s[j] and dp[i+1][j-1] and that you fill by increasing length is exactly right. The hash-map idea shows you're thinking about space. The separate handling of base lengths shows correct DP thinking.
However, three of the four bugs come from translating the recurrence into loop indices. Bug #4 is especially instructive β you correctly derived j = idx+1 for length 2 and j = idx+2 for length 3 (both are idx + (length - 1)), but wrote j = idx + str_len in the generalized loop (should be idx + str_len - 1). This is a classic off-by-one that happens when you generalize from concrete cases without verifying the formula.
j = i + length - 1 explicitly β don't guess from the small-case pattern"abba" β it exposes index errors the fastest
What you fixed: β
unified three loops into one, β
added length-1 base case, β
fixed typo, β
fixed return sentinel, β
fixed type hints. What went wrong: the j calculation still has the off-by-one, the recurrence references dp[i][j] instead of dp[i+1][j-1], and the str_len <= 3 guard now fires for the wrong lengths.
j = idx + str_len off-by-one from submission 1 BLOCKINGYou renamed the variable to str_len but it still isn't the substring length β it's j - idx which equals length - 1. When you write for str_len in range(n+1), you iterate over 0, 1, 2, ..., n, but:
str_len=0 β j=idx, actual substring length = 1 (redundant with base case)str_len=1 β j=idx+1, actual substring length = 2str_len=2 β j=idx+2, actual substring length = 3str_len=3 β j=idx+3, actual substring length = 4This alone isn't fatal β but it sets up Bug #3 to be catastrophic.
j = idx + length - 1 and iterate for length in range(2, n+1)
dp[i][j] instead of dp[i+1][j-1] BLOCKINGThis is the most serious error. Your recurrence says:
dp[i][j] is the cell you're currently computing β it hasn't been set yet (or is False from the hash-map default). For any substring where str_len > 3 (actual length β₯ 5), this always evaluates to False, making every palindrome of length β₯ 5 invisible to the algorithm.
Counterexample: "abcba" (length 5). The code checks dp[0][4] which is unset β False. Result: "bcb" instead of "abcba".
str_len <= 3 guard fires for wrong actual lengths BLOCKINGBecause str_len is really length - 1, the guard str_len <= 3 corresponds to actual substring lengths 2, 3, and 4. This means every length-4 substring with matching outer characters is treated as a palindrome without checking the inner pair β even when the inner pair mismatches.
Counterexample: "abca" (n=4, str_len=3): s[0]==s[3] and 3 <= 3 β True. Result: "abca" reported as palindrome β it's not.
The double hit: Bug #2 kills all palindromes of length β₯ 5 (false negatives). Bug #3 creates false palindromes at exactly length 4 (false positives). The bugs partially mask each other when they shouldn't.
longest_palindrome without checking lengthEvery time is_pal is True, you blindly overwrite longest_palindrome regardless of whether the new palindrome is actually longer. This works only because str_len increases monotonically (so later palindromes happen to be longer). If the loop order ever changed, or if a shorter palindrome is checked after a longer one within the same iteration, the result would be wrong. A if len(cand) > len(longest_palindrome) guard costs nothing and documents intent.
range(len(s) + 1) includes str_len=0str_len=0 re-checks every single-character substring that was already handled by the base case. It's redundant and makes the loop harder to reason about. A cleaner range: for length in range(2, n+1).
try/except assert anti-patternUnchanged from submission #1. assert inside try/except to detect missing dict keys remains non-idiomatic and breaks with python -O.
hash_array_get still has unused val parameterAnd this function is no longer even called β you inlined the lookup with dp[i][j] (which is itself the bug). Dead code signals incomplete refactoring.
Same concern as submission #1. A 2D list is simpler, faster, and makes indexing mistakes like dp[i][j] vs dp[i+1][j-1] more visually obvious.
for length in range(2, n+1) and set j = idx + length - 1dp[i+1][j-1] not dp[i][j]length <= 3 (now uses the correct variable)You keep hitting the same wall: translating "by increasing length" into correct loop indices. Both submissions have the recurrence right in your head (s[i]==s[j] and inner is palindrome), but the code writes dp[i][j] when it means dp[i+1][j-1], and the loop variable confuses "length" with "j - idx".
Concrete habit: Before writing the loop, write this invariant on a comment line:
And triple-check that every recurrence reference is to a strictly shorter interval: dp[i+1][j-1] in this problem, dp[i+1][j] or dp[i][j-1] in others.
What you changed from #2: loop now starts at range(2, n+1) (skipping str_len=0,1) and range(n - str_len + 1) (attempting to fix the off-by-one). Neither addresses the root cause, and the +1 introduces an out-of-bounds crash on every non-trivial input.
range(2, n+1) skips all length-2 substrings BLOCKINGYour str_len variable is really j - idx, which equals actual_length - 1:
| str_len=0 | β | actual length 1 (handled by base case) |
| str_len=1 | β | actual length 2 β MISSING! |
| str_len=2 | β | actual length 3 (first loop iteration) |
"cbbd" (whose only palindrome is "bb", length 2) would return "" β if it didn't crash first.
range(n - str_len + 1) β IndexError BLOCKINGWith j = idx + str_len, the last iteration gives:
The +1 was an attempt to fix the off-by-one from submission #1, but the real problem is j = idx + str_len (should be idx + str_len - 1). Adding +1 to the range flips the bug from "never reaches the last substring" to "goes past the last valid index."
j = idx + str_len β same off-by-one. str_len is j - i, so actual substring length = str_len + 1.dp[i][j] β still checks the cell being computed, not dp[i+1][j-1] (the inner substring).str_len <= 3 guard fires for actual lengths 2,3,4 β still wrong for actual length 4.longest_palindromeIf every palindrome is length 1 (e.g., "abc"), longest_palindrome stays "". Even the single test case that doesn't crash ("a") returns "".
Unchanged from #2. Relies on loop ordering rather than an explicit len(candidate) > len(current) guard.
All three submissions share the same root cause: you're treating str_len as substring length when it's actually j - i (length β 1). This creates a cascade: the range bound, the base-case guard, and the recurrence all operate on the wrong quantity.
Tweaking ranges and start values won't fix it. You need to rename the variable to match reality and derive j from the invariant:
Once j is correct, everything collapses into place: the range bound, the guard, and the recurrence all use the same length variable that means exactly what it says.
[i..j] be expressed in terms of [i+1..j], [i..j-1], or [i+1..j-1]?If yes to all three β interval DP, fill by length.