Study Notes
Notesfoundation

Strings — 11 Problems, Every Trick

Eleven string problems, every core pattern — reverse words, balanced brackets, palindromic substrings, edit distance, expression eval, remove duplicates, compression, subsequence, LCS, longest unique window, and anagram. Full notebook summaries + commented Java.

30 min read·verified Content review 2026·reviewed 2026-08-30

After this you should be able to

  • Explain the core idea in Strings — 11 Problems, Every Trick
  • Apply Strings — 11 Problems, Every Trick in a representative exercise
Stack / Two-ptrDPSliding Window

I. String Fundamentals (front-loaded in Session 1)#

The mental models the whole unit rests on#

A string is just an array whose elements are characters — so every array technique carries over. This one sentence is the spine of both sessions: two pointers, in-place index rewriting, sliding window, and frequency counting all apply directly.

★ TRICK Bounded charset ⇒ O(1) space. When a problem says the string is only A–Z, a–z, 0–9, that is 26+26+10 = 62 possible characters. An auxiliary array of fixed size 62 (or 26 for lowercase-only) is O(1) space regardless of input length — not O(n). The read: when the statement highlights the charset, the interviewer is hinting at this space optimization.
★ TRICK Palindromes: expand from the center, don't reverse. Fix a center and grow two pointers outward while s[l]==s[r]. Odd-length centers on one char (l=i, r=i); even-length centers between two (l=i, r=i+1). One helper handles both by passing different starting indices.
⚠ PITFALL Don't confuse the two palindrome techniques. Checking if one whole word is a palindrome → fix first/last pointers, shrink inward. Finding all palindromic substrings → you cannot fix first/last; you must treat each character as a center and expand out. A tempting alternative is the shrink-inward approach for the substring problem; it fails because it only tests the full string, not every embedded substring.
💡 TIP Substring counting math. A string of length n has n(n+1)/2 substrings = O(n²). So brute-forcing "check every substring" is already O(n²); layering an O(n) palindrome check on top makes it O(n³). Knowing this counting fact is how you justify reaching for a smarter method in an interview.

Substring vs. subsequence. A substring is contiguous ("cr" in "cric"). A subsequence is any set of characters kept in increasing index order, not necessarily contiguous ("cic", "rc"). A single character counts as a subsequence.

💡 TIP Stacks & brackets. Stack is LIFO (last in, first out) — pushing then popping a word reverses it. Rule of thumb: the moment a problem involves brackets, think stack first — 90–95% of bracket problems are solved with one. Same LIFO idea powers the "pop when precedence is violated" logic in expression evaluation.

II. Session 1 — Notebook Summary (hand-copy / flashcards)#

1. Reverse Words in a String#

Two-index parse

Insight: skip spaces, grab each word with [i..j), prepend it to the result (result = word + " " + result).

T/S: O(n) / O(n).

★ TRICK — prepend each word instead of reversing at the end; order falls out for free.

⚠ PITFALL — strip leading/trailing spaces and collapse multiple spaces to one.

i=0
while i<n:
  while i<n && s[i]==' ': i++      // skip spaces
  if i>=n: break
  j=i+1; while j<n && s[j]!=' ': j++ // grab word [i..j)
  word=s[i..j)
  result = (result=="" ) ? word : word+" "+result
  i=j+1
return result

doodle: [_ _ h e l l o _ w o r l d _] → prepend words → "world hello"

Practice: LeetCode 151 — Reverse Words in a String

2. Minimum Swaps to Make String Balanced#

Stack / counter

Insight: cancel matched pairs; leftover unmatched ['s of size k → ceil(k/2) swaps.

T/S: O(n) / O(n) (O(1) with a counter).

★ TRICK — swaps = ceil(unmatched/2): even → k/2, odd → (k+1)/2.

💡 TIP — brackets ⇒ stack; but a single balance counter replaces it for O(1) space.

for c in s:
  if c=='[': push
  else if stack nonempty && top=='[': pop  // cancel
k = stack.size()                            // unmatched '['
return (k%2==0) ? k/2 : (k+1)/2

doodle: ] [ ] [ → cancel middle []; ends ]..[ → 1 swap

Practice: LeetCode 1963 — Minimum Number of Swaps to Make the String Balanced

3. Palindromic Substrings (count)#

Expand from center

Insight: every char is a center; expand out counting each match. Do odd (i,i) and even (i,i+1).

T/S: O(n²) / O(1) (count only).

★ TRICK — center-expansion beats brute force O(n³) → O(n²); no substring extraction.

⚠ PITFALL — independent of string parity; run BOTH odd and even centers at every i.

💡 TIP [!] — PDF collects substrings in a List (O(n²) space); just increment a counter for O(1).

count=0
for i in 0..n-1:
  count += expand(i, i)     // odd
  count += expand(i, i+1)   // even
expand(l,h): while l>=0 && h<n && s[l]==s[h]: count++; l--; h++

doodle: a b a → center b: ←a a→ match; two arrows out from each char

Practice: LeetCode 647 — Palindromic Substrings

4. Edit Distance#

DP (memo)

Insight: match → recurse (n-1,m-1); else 1 + min(insert, delete, replace).

T/S: O(m·n) / O(m·n).

★ TRICK — index bookkeeping: insert → (n,m-1), delete → (n-1,m), replace → (n-1,m-1).

⚠ PITFALL — base case: empty string → return the OTHER string's remaining length, not 0.

💡 TIP — DP = recursion + memo; 2 changing params (n,m) ⇒ 2D table init to -1.

ed(n,m):
  if n==0: return m
  if m==0: return n
  if dp[n][m]!=-1: return it
  if s1[n-1]==s2[m-1]: return dp[n][m]=ed(n-1,m-1)
  return dp[n][m] = 1 + min(ed(n,m-1), ed(n-1,m), ed(n-1,m-1))

doodle: grid, diagonal on match; ins← del↑ rep↖ on miss

Practice: LeetCode 72 — Edit Distance

5. Evaluate Expression (Basic Calculator)#

Two stacks

Insight: operand stack + operator stack; a low-precedence op cannot sit on a higher one → evaluate first.

T/S: O(n) / O(n).

★ TRICK — before pushing an op, pop & apply while top has higher precedence (×/ over +−).

⚠ PITFALL — multi-digit numbers: build with num=num*10+(c-'0'), then i-- to undo overshoot.

💡 TIP — stack gives O(1) top access; that's why it fits precedence problems.

for each token:
  if digit: parse full number -> push to operands
  else (op): while ops nonempty && higherPrec(top, op):
                apply(pop op, pop 2 operands) -> push result
             push op
while ops nonempty: apply top
return operands.pop()

doodle: operands[..] | ops[..]; / beats + so / evaluates first

Practice: LeetCode 227 — Basic Calculator II (+ − × / no parens)

II½. Session 1 — Detailed Study Notes#

1. Reverse Words in a String#

Two-index parse

Reverse the order of words; a word is a run of non-space chars. Output has no leading/trailing spaces and exactly one space between words. " hello world " → "world hello".

Pattern: manual two-index string parse + prepend. Family: string tokenizing / index manipulation.

Practice: LeetCode 151 — Reverse Words in a String.

Brute force / library. split on whitespace, reverse the array, join with a space. Fine, but the manual version (no split) is worth doing to drill index manipulation, since a string is just a char array.

The approach. Walk with index i: skip any run of spaces, then use a second index j to capture the word [i..j) up to the next space. Instead of collecting words and reversing at the end, prepend each new word: result = word + " " + result. Reversal order comes for free.

★ TRICK Prepending each extracted word (word + " " + result) builds the reversed order as you scan left to right — no separate reverse pass, no array of words.
⚠ PITFALL Three space cases must all be handled: leading spaces (skip before the first word), trailing spaces (the i>=n break after skipping), and multiple internal spaces (the skip-spaces loop collapses them). The single-space separator is enforced by only ever inserting one " ".
💡 TIP A student worried that string concatenation inside the loop adds complexity. In this parse it isn't nested, so it stays O(n) overall; in Java you'd prefer a StringBuilder in production, but the logic is what the interviewer is testing.
public String reverseWords(String s) {
    String result = new String();
    int i = 0, n = s.length();
    while (i < n) {
        while (i < n && s.charAt(i) == ' ') i++;   // skip leading/extra spaces
        if (i >= n) break;                          // trailing spaces -> done
        int j = i + 1;
        while (j < n && s.charAt(j) != ' ') j++;    // capture word [i .. j-1]
        String sub = s.substring(i, j);
        if (result.length() == 0) result = sub;     // first word
        else result = sub + " " + result;           // PREPEND -> reversed order
        i = j + 1;
    }
    return result;
}

Edge cases. All spaces → empty string. Single word → returned unchanged. Multiple internal spaces → collapsed to one.

Why this matters. Manual tokenizing with two indices (skip-delimiter, then capture-token) is the reusable core for parsing CSV-like input, sentences, and version strings without library splits.

2. Minimum Swaps to Make String Balanced#

Stack / counter

String of n/2 [ and n/2 ]; you may swap any two indices any number of times. Return the minimum swaps to make it balanced. "]["-style input → often 1.

Pattern: stack cancellation (or running balance). Family: bracket matching.

Practice: LeetCode 1963 — Minimum Number of Swaps to Make the String Balanced.

The approach. Sweep left to right. Push every [. On a ], if the stack top is a matching [, pop — that pair is already balanced and irrelevant. After the sweep, whatever ['s remain (count k) represent the unmatched openers; each swap fixes two of them, so the answer is ceil(k/2).

★ TRICK The answer is a clean ceiling: if leftover count k is even → k/2; if odd → (k+1)/2. One swap repairs two misplaced brackets, which is why you divide by two.
💡 TIP Brackets ⇒ reach for a stack first (the 90–95% rule). But you don't even need the stack: a single balance counter (+1 for [, cancel on match) gives the same leftover count in O(1) space. Both are correct; the stack is the instinct to build. [+]
public int minSwaps(String s) {
    Stack<Character> st = new Stack<>();
    for (int i = 0; i < s.length(); i++) {
        if (s.charAt(i) == '[') {
            st.push('[');
        }
        // matched pair -> cancel it (guard: stack not empty)
        if (s.charAt(i) == ']' && st.size() != 0 && st.peek() == '[') {
            st.pop();
        }
    }
    int k = st.size();                 // unmatched '[' count
    return (k % 2 == 0) ? k / 2 : (k + 1) / 2;
}

Edge cases & bugs. Already balanced → empty stack → 0 swaps. [!] The empty-stack guard (st.size() != 0) matters: a leading ] must not pop an empty stack. This is exactly the edge case to watch.

Why this matters. "Cancel matched pairs, measure the leftover" is the template for valid-parentheses, min-add-to-make-valid, and remove-invalid-parentheses.

3. Palindromic Substrings (count)#

Expand from center

Return the number of palindromic substrings (contiguous) in s. "abc" → 3 (each single char).

Pattern: expand-around-center. Family: palindrome problems (siblings: Longest Palindromic Substring).

Practice: LeetCode 647 — Palindromic Substrings.

Brute force. Enumerate all O(n²) substrings, check each in O(n) → O(n³). The counting fact from the fundamentals section (n(n+1)/2 substrings) is exactly why the naive path is O(n³) and why you look for better.

The leap. Treat every index as the center of a palindrome and expand two pointers outward while the characters match, counting each successful expansion. Because palindromes are symmetric, you never need to re-examine a substring you already grew past. Run it twice per index: odd-length center (i, i) and even-length center (i, i+1). Total O(n²).

★ TRICK Expand-from-center turns O(n³) into O(n²) and never builds substring objects — each match is one increment. This is the single most reused palindrome technique across the unit.
⚠ PITFALL Whether you use odd or even centers has nothing to do with the string's length — it's easy to guess it depends on n being odd/even; it doesn't. You always run both center types at every index so you catch both palindrome shapes.
⚠ PITFALL You cannot solve the substring version by fixing first/last pointers and shrinking inward — that only tests the entire string. The whole point of center-expansion is to reach every embedded substring.
💡 TIP [!] The PDF collects substrings into a List and returns its size — O(n²) extra space, and it uses substring(l, h) which is a half-open range bug (misses the last char). For a count, just increment an int on each match: O(1) space and no off-by-one. Corrected version below. [+]

Recommended (counting, O(1) space) [+]:

public int countSubstrings(String s) {
    int n = s.length(), count = 0;
    for (int i = 0; i < n; i++) {
        count += expand(s, i, i);       // odd-length palindromes
        count += expand(s, i, i + 1);   // even-length palindromes
    }
    return count;
}
private int expand(String s, int l, int h) {
    int c = 0;
    while (l >= 0 && h < s.length() && s.charAt(l) == s.charAt(h)) {
        c++;      // s[l..h] is a palindrome
        l--; h++;
    }
    return c;
}

Edge cases. Empty string → 0. All-same ("aaa") → 6. Every single char always counts.

Interview follow-up. Same center-expansion, but track the widest span instead of a count → Longest Palindromic Substring. [+]

Why this matters. Expand-around-center is the go-to for any "palindrome inside a string" question and sidesteps the O(n³) trap.

4. Edit Distance#

DP (memoized recursion)

Minimum insert / delete / replace operations to convert word1 into word2. "zwcmqpow" → "zwtcmqpow" → 1 (insert 't').

Pattern: two-string DP. Family: sequence-alignment DP (sibling of LCS).

Practice: LeetCode 72 — Edit Distance.

Brute force → recursion. Compare from the ends. If last chars match, no operation — recurse on (n-1, m-1). If they differ, you must do one of three operations and pay 1, then take the cheapest branch. Plain recursion recomputes the same (n, m) subproblems exponentially.

The three operations and how each moves the indices (the part the session drilled hardest):

  • Replace word1's char to match word2 → both consumed → recurse (n-1, m-1).
  • Delete word1's extra char → word1 shrinks, word2 unchanged → recurse (n-1, m).
  • Insert into word1 to match word2's char → word2 shrinks, word1 unchanged → recurse (n, m-1).

Cost on a mismatch: 1 + min(insert, delete, replace).

★ TRICK The whole problem is knowing which index each operation decrements. Get the three recursive calls right and "recursion takes care of the rest" — you don't manually track surrounding characters.
⚠ PITFALL Base case is not return 0. If one string is empty you still need m (or n) operations to build the remaining characters — return the other string's leftover length.
💡 TIP DP = recursion + memoization. Two changing parameters (n, m) ⇒ a 2D dp table. Initialize to -1; if dp[n][m] != -1 the subproblem is already solved — reuse it instead of recomputing (the Fibonacci "compute f(4) once, reuse it" intuition).
public int minDis(String s1, String s2, int n, int m, int[][] dp) {
    if (n == 0) return m;              // build remaining m chars
    if (m == 0) return n;              // remove remaining n chars
    if (dp[n][m] != -1) return dp[n][m];   // memo hit

    if (s1.charAt(n - 1) == s2.charAt(m - 1)) {   // match -> no op
        return dp[n][m] = minDis(s1, s2, n - 1, m - 1, dp);
    }
    int insert  = minDis(s1, s2, n,     m - 1, dp);   // word2 shrinks
    int del     = minDis(s1, s2, n - 1, m,     dp);   // word1 shrinks
    int replace = minDis(s1, s2, n - 1, m - 1, dp);   // both shrink
    return dp[n][m] = 1 + Math.min(insert, Math.min(del, replace));
}
// driver: dp = new int[n+1][m+1] filled with -1; call minDis(w1, w2, n, m, dp)

Edge cases. Either string empty → length of the other. Identical strings → 0.

Interview follow-ups. Bottom-up int[n+1][m+1] table; space-optimize to two rows. [+] The recurrence without the replace branch (and counting deletions/insertions) reduces to LCS-based distance.

Why this matters. This is the canonical two-string DP: state = the two prefix lengths, branch on match vs. the three edits, memoize. Recognizing "two changing indices ⇒ 2D DP" transfers everywhere.

5. Evaluate Expression (Basic Calculator)#

Two stacks + precedence

Evaluate a math expression string and return its integer value, honoring operator precedence. " 3+5 / 2 " → 5 (integer division: 5/2=2, 3+2=5).

Pattern: two stacks (operands + operators) with precedence rule. Family: expression parsing / stack evaluation.

Practice: LeetCode 227 — Basic Calculator II (handles + − × / without parentheses).

The approach. Keep two stacks: one for operands (numbers), one for operators. Scan the string. On a number, parse the full multi-digit value and push it. On an operator, before pushing it, repeatedly pop and apply any operator on top that has higher-or-equal precedence — because a lower-precedence operator must never sit above a higher one. After the scan, drain the operator stack. The last operand left is the answer.

★ TRICK The invariant is "a low-priority operator cannot rest on top of a high-priority operator." When adding + while *// sits on top, evaluate the top first. The stack's O(1) top access is precisely why it fits this rule — no simpler structure works.
⚠ PITFALL Multi-digit numbers (12, 200, 2026) must be parsed as a whole: num = num*10 + (c - '0') in an inner loop, then i-- to undo the loop's final over-increment. And c - '0' is the char→int conversion — forgetting it corrupts the value.
💡 TIP Guard division by zero (throw / handle). Interview aside: if the input is a lone number with no operator, just return it; a standalone -10 or single value isn't really an "expression" — clarify the input contract.
public static int calculate(String s) {
    Stack<Integer> operands = new Stack<>();
    Stack<Character> operators = new Stack<>();
    for (int i = 0; i < s.length(); i++) {
        char c = s.charAt(i);
        if (Character.isDigit(c)) {
            int num = 0;
            while (i < s.length() && Character.isDigit(s.charAt(i))) {
                num = num * 10 + (s.charAt(i) - '0');   // build multi-digit
                i++;
            }
            i--;                       // undo the extra ++ from the inner loop
            operands.push(num);
        } else if (c == '+' || c == '-' || c == '*' || c == '/') {
            // apply higher-precedence ops sitting on top before pushing c
            while (!operators.isEmpty() && hasHigherPrecedence(operators.peek(), c)) {
                char op = operators.pop();
                int right = operands.pop(), left = operands.pop();
                operands.push(performOperation(left, right, op));
            }
            operators.push(c);
        }
    }
    while (!operators.isEmpty()) {     // drain remaining operators
        char op = operators.pop();
        int right = operands.pop(), left = operands.pop();
        operands.push(performOperation(left, right, op));
    }
    return operands.pop();
}
private static boolean hasHigherPrecedence(char op1, char op2) {
    // op1 (on stack) outranks op2 (incoming) when op1 is * or / and op2 is + or -
    return (op1 == '*' || op1 == '/') && (op2 == '+' || op2 == '-');
}
private static int performOperation(int left, int right, char op) {
    switch (op) {
        case '+': return left + right;
        case '-': return left - right;
        case '*': return left * right;
        case '/':
        if (right == 0) throw new ArithmeticException("Division by zero");
        return left / right;
    }
    return 0;
}

Edge cases & bugs. Leading/trailing/internal spaces are skipped naturally (non-digit, non-operator). Division by zero throws. Same-precedence operators evaluate left-to-right because the incoming equal-precedence op triggers the pop.

Note [!]. The precedence check as written only pops when the stack op is ×// and the incoming is +/−; for correct left-to-right handling of same-precedence chains (e.g. 6/2*3), also pop when precedence is equal. Verify against the platform's test cases.

Why this matters. The two-stack shunting approach generalizes to full calculators (with parentheses), reverse-Polish evaluation, and any precedence-driven parsing.

III. Session 2 — Notebook Summary (hand-copy / flashcards) #

1. Remove Duplicate Letters#

Monotonic stack

Insight: greedily keep the smallest char on a stack; pop a bigger top only if it reappears later.

T/S: O(n) / O(n).

★ TRICK — same precedence logic as expression-eval: a smaller char can't sit on top of a larger one if the larger recurs later.

⚠ PITFALL — only pop if i < lastIndex[top]; else you delete a char that never comes back.

💡 TIP — alpha[26] = "in-stack?" visited flag; avoids scanning the stack.

last[c] = last index of c        // map
for c at i:
  if inStack[c]: continue
  while stk && c<top && i<last[top]: inStack[pop]=0
  push c; inStack[c]=1
answer = reverse(stack)

doodle: [a][c][d] ←stack, arrow "pop d if d recurs & c<d"

Practice: LeetCode 316 — Remove Duplicate Letters

2. String Compression#

In-place index

Insight: two indices — read pointer i counts a run, write pointer ans overwrites char + digits in place.

T/S: O(n) / O(1).

★ TRICK — index manipulation instead of a HashMap; a string IS an array, so array tricks apply.

⚠ PITFALL — count 1 writes nothing; multi-digit counts (e.g. 12, 200) write each digit char.

💡 TIP — return the new length, not the string; interviewer's "improve the space" cue = drop the map.

i=0, ans=0
while i<n:
  curr=chars[i]; count=0
  while i<n && chars[i]==curr: i++; count++
  chars[ans++]=curr
  if count!=1: for d in str(count): chars[ans++]=d
return ans

doodle: [a a b b c c c] → write↓ [a 2 b 2 c 3 . .]

Practice: LeetCode 443 — String Compression

3. Is Subsequence#

Two pointers

Insight: advance j over t every step; advance i over s only on a match. s is a subsequence iff i reaches s.length.

T/S: O(max(m,n)) / O(1).

★ TRICK — the terminating check is i == s.length(), not "loop ended".

⚠ PITFALL — only s's pointer is conditional; t's pointer is unconditional.

i=0, j=0
while i<m && j<n:
  if s[i]==t[j]: i++
  j++
return i == m

doodle: s: a→b→c ; t: a h b g d c (i jumps only on hit)

Practice: LeetCode 392 — Is Subsequence

4. Longest Common Subsequence#

DP (memo)

Insight: match → 1 + LCS(n-1,m-1); mismatch → max(LCS(n-1,m), LCS(n,m-1)).

T/S: O(m·n) / O(m·n).

★ TRICK — 2 changing params (n,m) ⇒ 2D memo; key "n,m" in a map works too.

⚠ PITFALL — base case is n==0 || m==0 → 0 (empty string shares nothing).

💡 TIP — DP = recursion + memoization; derive base case by plugging length 0 into the statement.

LCS(n,m):
  if n==0 or m==0: return 0
  if memo[n,m]: return it
  if s1[n-1]==s2[m-1]: r = 1+LCS(n-1,m-1)
  else: r = max(LCS(n-1,m), LCS(n,m-1))
  memo[n,m]=r; return r

doodle: grid n×m, diagonal on match, max(←,↑) on miss

Practice: LeetCode 1143 — Longest Common Subsequence

5. Longest Substring w/o Repeating Chars#

Sliding window

Insight: window [i..j] valid while mapSize == j-i+1 (all unique). Expand j; on duplicate shrink i.

T/S: O(n) / O(n) (or O(min(n,charset))).

★ TRICK — "window size == distinct count" is the unique-chars invariant; no per-char rescan.

⚠ PITFALL [!] — PDF counts+decrements freq but its guard only handles one duplicate; use "remove until char unique" or a lastIndex jump. See detailed notes.

💡 TIP — a Set works; a Map also gives you the index to jump i directly.

i=0, best=0, seen={}
for j in 0..n-1:
  while s[j] in seen: remove s[i]; i++
  add s[j]; best=max(best, j-i+1)
return best

doodle: array with moving [L....R] window bracket

Practice: LeetCode 3 — Longest Substring Without Repeating Characters

6. Valid Anagram#

Frequency count

Insight: length mismatch → false. Else ++ for s, -- for t on a [26] array; all zero ⇒ anagram.

T/S: O(max(m,n)) / O(1).

★ TRICK — single 26-array, +/- in one pass; no sorting, no HashMap.

⚠ PITFALL — must compute lengths first → TC is O(max(m,n)), not O(n) of one string.

💡 TIP — lowercase-only ⇒ array beats HashMap (O(1) vs O(n) space); confirm charset with interviewer.

if m!=n: return false
for c in s: cnt[c-'a']++
for c in t: cnt[c-'a']--
for x in cnt: if x!=0: return false
return true

doodle: 26 boxes, s: +1s, t: -1s → all 0?

Practice: LeetCode 242 — Valid Anagram

IV. Session 2 — Detailed Study Notes#

1. Remove Duplicate Letters#

Monotonic stack

Keep each letter once so the result is the lexicographically smallest of all such strings, preserving relative order. Input "cbacdcbc" → "acdb".

Pattern: monotonic stack + greedy. Family: "smallest/largest subsequence via stack" (same shape as Remove K Digits, Smallest Subsequence).

Practice: LeetCode 316 — Remove Duplicate Letters (identical: LeetCode 1081 — Smallest Subsequence of Distinct Characters).

Brute force. Enumerate subsequences that contain each distinct char exactly once, keep the lexicographically smallest — exponential, useless. The realization: you can build the answer greedily left to right.

The leap. Walk the string keeping a stack of the answer so far. When the current char curr is smaller than the stack top and that top appears again later, pop the top — you'll re-add it later in a better position. This is exactly the precedence idea from the expression-evaluation problem: a lower-priority (smaller) char can't sit on top of a higher one that still has a future occurrence.

★ TRICK The whole problem reduces to one monotonic-stack sweep. The key connection: it is the same "can't leave a bigger element beneath a smaller one" logic as operator precedence in expression evaluation. Recognizing that is what turns a "hard" problem into a known pattern.
⚠ PITFALL You may only pop the top if it recurs later: i < lastIndex[top]. Drop that guard and you delete a character that never returns, producing a string missing a required letter. This is the sole reason the last-index map exists.
💡 TIP The alpha[26] array is an "is this char currently on the stack?" flag — the same idea as a visited array in DFS. It lets you skip already-placed chars and update membership in O(1) instead of scanning the stack.
public String removeDuplicateLetters(String s) {
    int[] inStack = new int[26];            // 1 = char currently on stack
    HashMap<Character,Integer> last = new HashMap<>();
    Stack<Character> stack = new Stack<>();

    // last occurrence index of every char
    for (int i = 0; i < s.length(); i++) last.put(s.charAt(i), i);

    for (int i = 0; i < s.length(); i++) {
        char curr = s.charAt(i);
        if (inStack[curr - 'a'] == 1) continue;   // already placed -> skip

        // pop bigger tops that still appear later (so we can re-add them)
        while (!stack.isEmpty() && curr < stack.peek() && i < last.get(stack.peek())) {
            inStack[stack.pop() - 'a'] = 0;
        }
        stack.push(curr);
        inStack[curr - 'a'] = 1;
    }

    StringBuilder sb = new StringBuilder();
    while (!stack.isEmpty()) sb.append(stack.pop());
    return sb.reverse().toString();   // stack is top-first, so reverse
}

Edge cases & bugs. Empty string → empty result. All-unique input → string unchanged. [!] One version's final line is return sb.reverse(); that returns a StringBuilder, not a String — add .toString(). Uppercase input isn't handled by the size-26 array; you'd assume lowercase and clarify with the interviewer.

Why this matters. The stack-as-greedy-builder + "pop when a better future exists" pattern transfers directly to Remove K Digits, Create Maximum Number, and any "smallest/largest subsequence keeping order" problem.

2. String Compression#

In-place index

Compress runs of equal chars into char + run-length, in place, and return the new length. ["a","a","b","b","c","c","c"] → first 6 slots become ["a","2","b","2","c","3"], return 6.

Pattern: in-place two-index rewrite. Family: array index-manipulation (like Remove Element, Remove Duplicates from Sorted Array).

Practice: LeetCode 443 — String Compression.

Brute force. Count frequencies into a HashMap, then rebuild — correct but O(n) extra space. In an interview the map answer is the expected "first" answer; the follow-up is always "reduce the space."

The leap. A string is just a character array, so use two indices in the same array: a read pointer i that scans a run and counts it, and a write pointer ans that overwrites position by position with the char followed by its count's digits. No auxiliary structure → O(1) space.

★ TRICK Index manipulation replaces the HashMap. The recurring lesson: whatever technique you learned on arrays applies to strings, because a string is an array with characters in each slot.
⚠ PITFALL A run of length 1 writes only the char, no "1" (if (count != 1)). Counts > 9 are multi-character: 200 must be written as three separate slots '2','0','0'. Iterating Integer.toString(count).toCharArray() handles both, and you must advance the write index for every digit.
💡 TIP You return the new length, not the string — a common misread ("A occurs 200 times" → return 4, because "a200" is length 4). The map-then-improve exchange is itself the point: interviewers use this problem to see if you know the in-place trick.
public int compress(char[] chars) {
    int ans = 0, i = 0;              // ans = write ptr, i = read ptr
    while (i < chars.length) {
        char curr = chars[i];
        int count = 0;
        while (i < chars.length && chars[i] == curr) { // count the run
            i++;
            count++;
        }
        chars[ans++] = curr;                 // write the char
        if (count != 1) {                    // write digits only if > 1
            for (char c : Integer.toString(count).toCharArray()) chars[ans++] = c;
        }
    }
    return ans;                              // new length
}

Edge cases & bugs. Single char → length 1, no count. All-same input of length k → char + digits of k. The write pointer never overtakes the read pointer because compressed output is never longer than the run it replaces.

Why this matters. The two-pointer in-place rewrite (read scans, write commits) is the backbone of most O(1)-space array-editing problems.

3. Is Subsequence#

Two pointers

Return true if s is a subsequence of t (delete some chars of t without reordering to get s). s="abc", t="ahbgdc" → true.

Pattern: two pointers, one per string. Family: sequence matching / merge-style scans.

Practice: LeetCode 392 — Is Subsequence.

The approach. Pointer i on s, j on t. Always advance j. Advance i only when s[i]==t[j]. Because a subsequence preserves order, matching greedily as early as possible is always safe. s is a subsequence exactly when i reaches the end of s. Single pass, O(max(m,n)) time, O(1) space.

★ TRICK The final answer is a clean pointer check — i == s.length() — not "did the loop finish." Worth drilling: the loop can exit because t ran out, so you must test whether s was fully consumed.
⚠ PITFALL Only s's pointer is conditional. A common slip is guarding j++ too, which stalls on mismatches. j always moves; i moves only on a hit.
public boolean isSubsequence(String s, String t) {
    int i = 0, j = 0;
    while (i < s.length() && j < t.length()) {
        if (s.charAt(i) == t.charAt(j)) i++;   // advance s only on match
        j++;                                   // advance t every step
    }
    return i == s.length();                    // consumed all of s?
}

Edge cases. Empty s → true (0 reaches length 0). s longer than t → loop ends with i < m → false. Equal strings → true.

Interview follow-up. If you must answer many queries s₁, s₂, ... against the same t, preprocess t into per-letter sorted index lists and binary-search each char — O(|t|) prep, O(|s| log |t|) per query. (Raised as the natural scaling variant.) [+]

Why this matters. The "advance both, gate one" two-pointer scan generalizes to merging sorted lists, matching, and interval sweeps.

4. Longest Common Subsequence (LCS)#

DP (memoized recursion)

Return the length of the longest subsequence common to text1 and text2. "abcde", "ace" → 3.

Pattern: two-sequence DP. Family: edit-distance / sequence-alignment DP (siblings: Edit Distance, Shortest Common Supersequence).

Practice: LeetCode 1143 — Longest Common Subsequence.

Brute force. Compare from the ends. If last chars match, that char is in the LCS: 1 + LCS(n-1, m-1). If not, drop the last char of one string or the other and take the better: max(LCS(n-1,m), LCS(n,m-1)). Plain recursion recomputes the same (n,m) subproblems exponentially.

The leap. Only two parameters change across calls: the two prefix lengths n and m. That means at most m×n distinct subproblems → memoize on (n,m). Recursion + memoization = dynamic programming. Result: O(m·n) time and space.

★ TRICK Count the varying parameters to size the memo. Two changing values (n, m) ⇒ a 2D table — "why not 3D?" — only two indices vary, so two dimensions suffice. A HashMap keyed by n + "," + m is an equivalent memo.
⚠ PITFALL Base case: if either length is 0, LCS is 0 (an empty string shares nothing). A shortcut for finding any base case: plug length 0 into the problem statement and read off the answer.
💡 TIP The map key must be unique per subproblem — concatenating n + "," + m guarantees that. Starting indices at 0 and comparing i==m, j==n also works but needs two extra parameters, so counting down from the lengths is cleaner.
int lcsDP(String s1, String s2, int n, int m, Map<String,Integer> memo) {
    if (n == 0 || m == 0) return 0;             // empty prefix -> 0

    String key = n + "," + m;                   // unique per subproblem
    if (memo.containsKey(key)) return memo.get(key);

    int lcs;
    if (s1.charAt(n - 1) == s2.charAt(m - 1)) { // last chars match
        lcs = 1 + lcsDP(s1, s2, n - 1, m - 1, memo);
    } else {                                    // drop one char from either
        lcs = Math.max(lcsDP(s1, s2, n - 1, m, memo), lcsDP(s1, s2, n, m - 1, memo));
    }
    memo.put(key, lcs);
    return lcs;
}

public int longestCommonSubsequence(String text1, String text2) {
    Map<String,Integer> memo = new HashMap<>();
    return lcsDP(text1, text2, text1.length(), text2.length(), memo);
}

Edge cases. No common chars → 0. One string empty → 0. Identical strings → the shorter length.

Interview follow-ups. Convert to a bottom-up int[n+1][m+1] table, then space-optimize to two rows (O(min(m,n)) space). [+] To recover the actual subsequence (not just length), backtrack through the table. The identical recurrence with a cost twist solves Edit Distance.

Why this matters. This is the template for all two-string DP: define state as the two prefix lengths, branch on match vs. mismatch, memoize.

5. Longest Substring Without Repeating Characters#

Sliding window

Find the length of the longest substring (contiguous) with all distinct characters. "pwwkew" → 3 ("wke").

Pattern: sliding window with two pointers. Family: variable-size window on a "constraint holds" invariant.

Practice: LeetCode 3 — Longest Substring Without Repeating Characters.

Brute force. Check every substring for uniqueness — O(n²) substrings × O(n) check = O(n³). The insight: a window that's already valid only becomes invalid when a duplicate enters, so you never need to restart from scratch.

The leap. Maintain window [i..j]. Expand the right edge j while the window stays all-unique; when a duplicate appears, advance the left edge i until it's unique again. Each pointer only moves forward, so total work is O(n). The uniqueness test: track chars in a map and check mapSize == j - i + 1 — if distinct count equals window width, every char in the window is unique.

★ TRICK The invariant "number of distinct chars == window size" is a clean, O(1) way to know the whole window is duplicate-free — no rescanning the window each step.
⚠ PITFALL [!] The PDF solution increments a frequency count and shrinks with while (map.size() < j-i+1), removing chars only when their count hits 0. This is fragile: it leans on the window being off by exactly one duplicate each step and can leave stale non-zero counts. The robust, standard forms are either (a) shrink i while the incoming char is already in a Set, or (b) store each char's last index in a map and jump i = max(i, lastIndex[c] + 1). Prefer one of these in an interview.
💡 TIP A HashSet is enough for membership. A HashMap<char,index> is stronger — it lets you jump the left pointer straight past the previous occurrence instead of stepping one at a time.

Recommended (last-index jump) — the cleanest correct version [+]:

public int lengthOfLongestSubstring(String s) {
    Map<Character,Integer> lastIndex = new HashMap<>();
    int i = 0, max = 0;                       // i = left edge
    for (int j = 0; j < s.length(); j++) {    // j = right edge
        char c = s.charAt(j);
        if (lastIndex.containsKey(c)) i = Math.max(i, lastIndex.get(c) + 1);  // jump past duplicate
        lastIndex.put(c, j);
        max = Math.max(max, j - i + 1);
    }
    return max;
}

Map-size invariant version — keep the [!] caveat above in mind:

public int lengthOfLongestSubstring(String s) {
    Map<Character,Integer> map = new HashMap<>();
    int i = 0, j = 0, max = 0;
    while (j < s.length()) {
        map.put(s.charAt(j), map.getOrDefault(s.charAt(j), 0) + 1);
        if (map.size() == j - i + 1) {          // all unique -> record + grow
            max = Math.max(max, j - i + 1);
            j++;
        } else if (map.size() < j - i + 1) {    // duplicate -> shrink left
            while (map.size() < j - i + 1) {
                map.put(s.charAt(i), map.get(s.charAt(i)) - 1);
                if (map.get(s.charAt(i)) == 0) map.remove(s.charAt(i));
                i++;
            }
            j++;
        }
    }
    return max;
}

Edge cases. Empty string → 0. All-same ("aaaa") → 1. All-unique → full length.

Why this matters. Variable-size sliding window — grow right while a constraint holds, shrink left when it breaks — solves a huge class: min window substring, longest substring with k distinct, max consecutive ones after flips.

6. Valid Anagram#

Frequency count

Return true if t is an anagram of s (same multiset of letters). Lowercase only. "anagram", "nagaram" → true.

Pattern: fixed-alphabet frequency array. Family: counting / hashing on a bounded charset.

Practice: LeetCode 242 — Valid Anagram.

Brute force. Sort both strings and compare — O(n log n). Fine, but you can do linear with counting.

The leap. If lengths differ, immediately false. Otherwise use one int[26]: increment for each char of s, decrement for each char of t. If the strings are anagrams every bucket returns to 0. Single alphabet-bounded array, one increment pass and one decrement pass.

★ TRICK One 26-slot array with ++ on s and -- on t — anagrams cancel to all zeros. No sort, no second structure. Because the alphabet is fixed at 26, space is O(1).
⚠ PITFALL You must read both lengths to short-circuit unequal-length inputs, so the honest time complexity is O(max(m,n)), not "O(n) of one string." Worth stating this precisely.
💡 TIP Lowercase-only is the cue to use a size-26 array instead of a HashMap: the array is O(1) space, the map is O(n). For full Unicode you'd fall back to a HashMap — confirm the charset with the interviewer.
public boolean isAnagram(String s, String t) {
    int m = s.length(), n = t.length();
    if (m != n) return false;                 // different lengths -> not anagram
    int[] count = new int[26];                // lowercase a..z
    for (int i = 0; i < m; i++) count[s.charAt(i) - 'a']++;
    for (int i = 0; i < n; i++) count[t.charAt(i) - 'a']--;
    for (int x : count) if (x != 0) return false;
    return true;
}

Edge cases. Both empty → true. Unequal lengths → false immediately. Same letters, different order → true.

Interview follow-ups. Unicode input → switch to HashMap<Character,Integer>. "Group anagrams" → use the sorted string or the frequency signature as a map key. [+]

Why this matters. The bounded-alphabet counting array (and its +/- cancellation trick) recurs across anagram, permutation-in-string, and character-frequency problems.

V. Patterns Recap (all 11 problems)#

Every problem → technique, insight, cost#

#ProblemTechniqueKey insightTimeSpace
S1·1Reverse Words in a StringTwo-index parse + prependSkip spaces, grab word, prepend to resultO(n)O(n)
S1·2Min Swaps to Balance BracketsStack cancellationCancel pairs; ceil(unmatched/2) swapsO(n)O(n)
S1·3Palindromic SubstringsExpand from centerEach char a center; both odd & evenO(n²)O(1)
S1·4Edit DistanceMemoized DPMatch → diag; else 1+min(ins,del,rep)O(m·n)O(m·n)
S1·5Evaluate ExpressionTwo stacks + precedenceLow-priority op can't sit on higher oneO(n)O(n)
S2·1Remove Duplicate LettersMonotonic stack + greedyPop bigger top only if it recurs laterO(n)O(n)
S2·2String CompressionIn-place two-index rewriteRead ptr counts run, write ptr overwritesO(n)O(1)
S2·3Is SubsequenceTwo pointersAdvance t always, s only on matchO(max(m,n))O(1)
S2·4Longest Common SubsequenceMemoized DPMatch → 1+diag; else max of two dropsO(m·n)O(m·n)
S2·5Longest Substring w/o RepeatsSliding windowdistinct count == window widthO(n)O(n)
S2·6Valid AnagramFrequency array [26]++ for s, -- for t; all zero ⇒ anagramO(max(m,n))O(1)

The through-line worth returning to: a string is an array of characters, so every array technique — two pointers, in-place index rewriting, sliding window, frequency counting — carries over. When a bounded charset is stated, use a fixed-size array for O(1) space. When brackets or precedence appear, reach for a stack. When two indices vary in a recurrence, it's 2D DP. And when order + "smallest/largest" appear together, reach for a monotonic stack.