Strings

Strings are arrays of characterswith a compact toolkit: frequency counting for anagram/signature problems, two-pointer or expand-around-center for palindromes, a stack for bracket matching, and length-prefix encoding for serialization. Many "string" problems are secretly sliding window, hash map, or 2-D DP problems in disguise — recognize the underlying structure first.

Topic guide8 problems
The unlock

A string is just an array of characters — so the entire array toolkit (two pointers, sliding window, hashing) already applies. The only new moves are two reframes: represent a word by its character countswhen order doesn't matter, and grow outward from a center when you care about symmetry.

MENTAL MODEL A string is an array wearing a costume

Don't treat “string problems” as a separate species. A string is a fixed sequence of characters at integer indices — exactly an array. The instant you see that, every array reflex transfers: converge two pointers from the ends, slide a window across a contiguous run, hash a character into a count.

  "racecar"  is just  ['r','a','c','e','c','a','r']

   index :   0    1    2    3    4    5    6
   char  : [ r ][ a ][ c ][ e ][ c ][ a ][ r ]

   Two pointers, sliding window, hashing — every array move applies.
   The only string-specific catch: in JS a string is IMMUTABLE.
   No s[2] = 'x'. Build with an array, then .join('') at the end.

On top of the array toolkit there are just two string-flavored moves worth memorizing: the frequency fingerprint (counts, not order) and expand around a center (symmetry). Almost every classic string question is one of those two, or a sliding-window / 2-D-DP problem in disguise.

The reframe →Before inventing a string-specific trick, ask “what would I do if this were an int[]?” Usually the answer is the answer. Just remember strings are immutable in JS — mutate an array of chars and .join('') at the end.

FREQUENCY FINGERPRINT When order is irrelevant, keep only the counts

For anagrams, permutations, and “same letters?” questions the arrangement of characters is noise — only how many of each matters. So throw the order away: collapse the word into a length-26 count array. That array is the word's fingerprint. Two words are anagrams exactly when their fingerprints are equal.

   s = "anagram"          t = "nagaram"

         a b c ... g ... m n r          a b c ... g ... m n r
   s : [ 3 0 0 ... 1 ... 1 1 1 ]  t : [ 3 0 0 ... 1 ... 1 1 1 ]
       └──────── identical ────────────────────────────┘  → ANAGRAM

   One-array trick: tally s up, drain t down. All zeros ⇒ match.

         a:+3-3   g:+1-1   m:+1-1   n:+1-1   r:+1-1   →  all 0
   Order is thrown away on purpose — only the COUNTS survive.

The same idea powers group anagrams: a fingerprint (or the sorted word, which is just a canonical ordering of the same counts) is a group key. Hash every word to its key and words with matching fingerprints land in the same bucket.

Key insight → a fingerprint is a lossyhash that deliberately forgets order. That loss is the feature: it makes “is this a rearrangement of that?” a single equality check instead of a search over permutations.

SEE IT Watch the arms grow outward from a center

The other reframe is for symmetry. A palindrome mirrors around a center, so instead of testing every substring, plant yourself at a center and push two arms outward — left index down, right index up — while the characters keep matching:

            "b a b a d"
   center →     ^                odd center at index 1 ('a')

   step 0:     l=1 r=1     a            (a single char is a palindrome)
   step 1:   l=0   r=2   b a b          b == b  ✔  grow
   step 2: l=-1     r=3  .babad         l out of bounds  ✘  STOP

   best so far = "bab"  (length 3)

   Even center sits in the GAP between i and i+1:
            "a b b a"
                ^^               l=1 r=2 → "bb" → "abba"
   So scan 2n-1 centers: n single chars + (n-1) gaps.

When the arms break (chars differ or you fall off an edge), the last matching span was your palindrome. Index arithmetic — careful l-- / r++ and bounds checks — replaces the extra space a brute-force scan would need.

The catch → a center is either on a character (odd length) or in the gap between two characters (even length). Scan all 2n − 1 centers or you silently miss every even-length palindrome like "abba".

HOW TO THINK The cold-start ladder — run this on any string problem

Faced with a fresh string problem, climb these rungs in order — the right tool falls out as soon as one matches:

  1. Is order irrelevant? If the question is about which characters appear and how many (anagram, permutation, “same letters”, grouping) → build a counts fingerprint and compare or hash it.
  2. A contiguous run with a condition?(“longest substring with no repeats / at most k distinct”, “minimum window containing t”) → sliding window with a frequency map. This is an array move, not a string-specific one.
  3. Symmetry / palindrome?expand around every center (odd and even), or converge two pointers from the ends to test one whole string.
  4. Two whole strings aligned?(edit distance, longest common subsequence, interleaving) → that's 2-D DP on the grid of the two strings — no pointer trick covers it.
  5. Parsing / serializing? (encode-decode a list, string-to-int) → walk an index pointer and read fields deliberately; for serialization a length prefix beats any separator.
Two ever-present hazards → off-by-one on the indices (an inner skip loop must also guard lo < hi), and the immutability of strings — never build a result with += in a loop (that's O(n²) copying); push to an array and join once.

SAY IT Name the invariant before you code

Each tool has a one-sentence invariant. Say it out loud and the loop conditions write themselves:

  • Fingerprint: “two strings are anagrams iff every character count is equal” — so tally one up, drain the other down, and assert all counts return to zero.
  • Two-pointer palindrome: “everything outside [lo, hi]already matched its mirror” — keep going while lo < hi.
  • Expand around center:s[l..r] is a palindrome right now” — that invariant holds only while s[l] === s[r] and both indices stay in bounds.
The test →if you can't state what stays true at every step, you don't yet know when to stop the loop or what to return. State the invariant first; the code is just its enforcement.

EXPAND SHAPE Every palindrome scan is this skeleton

Longest palindromic substring and count-all-palindromes are the same two loops — one outer pass over centers, one inner pass that grows the arms. Read it as a sentence:

expandFromEachCenter(s):

    for i in 0 .. n-1:
        grow(i, i)         # odd-length palindrome centered ON s[i]
        grow(i, i + 1)     # even-length, center in GAP after s[i]

grow(l, r):
    while l >= 0 and r < n and s[l] == s[r]:   # mirror still holds?
        record (l, r)                          # this is a palindrome
        l -= 1                                 # push left arm out
        r += 1                                 # push right arm out

The only thing that differs between problems is what you do inside grow: track the longest (l, r) seen (LC 5) or just count++ on every successful expansion (LC 647). Same shape, different bookkeeping — exactly like the frequency fingerprint reused for anagram-check vs. group-anagrams.

MNEMONIC Grow while the mirror holds.

Grow while the mirror holds. Palindromes mirror around a center, so try every center (and every gap between letters) and push two arms outward while the characters match. The Visualize tab animates the arms expanding and breaking.

PATTERN Strings as character arrays

A string is a sequence of characters — treat it like an array and every array technique becomes available. The most-used primitives are:

  • Frequency signature. A length-26 array (or a Map) counting each character. Two strings with identical signatures are anagrams. Increment for one string, decrement for the other — if everything zeros out, they match.
  • Two pointers (palindrome). lo starts left, hi starts right — converge while skipping non-alphanumerics and comparing case-folded chars.
  • Expand around center. Every palindrome has a center (one char for odd length, the gap between two chars for even). Expand outward as long as characters match. Covers both the longest-substring and count-all-palindromes problems in O(n²) time and O(1) space.
  • Stack for bracket matching. Push opening brackets; on a closing bracket check whether the stack top is the matching opener and pop it. An empty stack at the end means valid.
  • Length-prefix encoding. To serialize a list of arbitrary strings (which may contain any delimiter), prepend each string with its length and a separator: 5#hello3#foo. The decoder reads the number, then extracts exactly that many characters — no ambiguity.

KEY IDEA Grouping by signature

Sort-key vs count-key → to group anagrams, you need a canonical key per word. Two options: word.split('').sort().join('') (sort the characters) or build a 26-count vector and stringify it. Both are correct; the costs differ.
Sort key
O(n · k log k)
Sort each word of length k.
Count key
O(n · k)
One pass per word, fixed-size array.

For the typical interview input the difference is negligible, but the count approach is asymptotically better and also handles Unicode naturally if you use a full Map instead of a 26-slot array.

COST Palindrome: naive vs expand-around-center

The brute-force approach checks every substring for palindromicity: pick every (i, j) pair and verify. That's O(n²) substrings × O(n) verification each.

All pairs + verify
O(n³)
Every (i,j) × O(n) check.
Expand around center
O(n²)
2n centers × O(n) expand, O(1) space.

There is an O(n)algorithm (Manacher's), but it is almost never required in interviews — expand-around-center is the expected answer at every major company.

REDUCTION When "string" is really another pattern

A large fraction of string problems are not fundamentally about strings — they reduce to a pattern you already know:

  • Longest substring with at most k distinct chars / no repeats → sliding window with a frequency map.
  • Longest common subsequence / edit distance → 2-D dynamic programming on the two-string grid.
  • Prefix search / autocomplete → trie.
  • Pattern matching (find all occurrences) → KMP or Rabin-Karp (rarely needed in interviews, but know the idea).

Identifying the reduction early is what separates a clean solution from a tangled one.

RUN IT Grow while the mirror holds

step 0 / 18
STARTLongest palindrome in babad. A palindrome mirrors around a center — so try every center and grow outward. Grow while the mirror holds.
sb0a1b2a3d4
State
l
r
"b"
best
arms (l, r)inside palindromebest so far
slowfast

TRIGGERS When you see ___ → reach for ___

Reach for the string toolkit when the problem is about character identity, order, or symmetrywithin one or two words — not about subarray sums or prefix counts. The first question to ask is: "which tool fits the structure?"

"anagram" / "same letters" / "rearrangement"frequency signature: 26-array or Map, tally one string and drain the other
"is it a palindrome?" / compare a string to its reversetwo pointers converging from both ends, skip non-alphanumeric, case-fold
"longest palindromic substring" / "count palindromic substrings"expand around every center (2n centers: odd + even), track best or accumulate count
"valid parentheses" / "matching brackets" / "balanced"stack: push openers, pop on a matching closer, invalid if mismatch or non-empty at end
"encode / decode list of strings" / serialize without a safe delimiterlength-prefix: "<len>#<payload>", decoder reads length then slices exactly that many chars
"group anagrams" / "group by character content"hash map keyed by sorted string or count-vector string; collect words per key
"longest substring without repeat" / "at most k distinct" / "minimum window"sliding window — this is NOT a pure string problem, apply the window pattern

RED FLAGSWhen it's NOT this pattern

  • Contiguous substring + sum/condition → that's a sliding window, not a static frequency count. The window expands and contracts; a plain character map won't tell you when to shrink.
  • Two-string alignment / edit cost → reach for 2-D DP. Problems like longest common subsequence or edit distance build a grid over the two strings; no pointer trick handles the full state space.
  • Prefix / autocomplete / word search → a trie(prefix tree) encodes all words in shared prefixes and supports O(k) lookup. A hash map can't answer "all words with prefix X" efficiently.
  • Substring "contains all characters of t" → minimum window substring is a sliding window + frequency map hybrid. The window guarantees contiguity; the map tracks the coverage deficit.

TEMPLATE Frequency-count comparison (anagram)

When → Two strings of the same length: check whether they use identical characters. Tally one string up, drain the other down, verify all zeros.

frequency-count-comparison-anagram-.ts
function isAnagram(s: string, t: string): boolean {
  if (s.length !== t.length) return false;
  const count = new Array(26).fill(0);          // index by charCode - 97
  for (let i = 0; i < s.length; i++) {
    count[s.charCodeAt(i) - 97]++;              // tally up for s
    count[t.charCodeAt(i) - 97]--;              // drain down for t
  }
  return count.every(c => c === 0);             // all balanced → anagram
}
26-array vs Map → use a 26-slot array (charCode - 97) when input is guaranteed lowercase ASCII. Use a full Map<string, number> for Unicode or mixed-case without normalization.

TEMPLATE Two-pointer palindrome check

When → Determine whether a string reads the same forwards and backwards, optionally ignoring non-alphanumeric characters and case (LC 125 style).

two-pointer-palindrome-check.ts
function isPalindrome(s: string): boolean {
  let lo = 0, hi = s.length - 1;
  while (lo < hi) {
    while (lo < hi && !isAlphanumeric(s[lo])) lo++;   // skip junk left
    while (lo < hi && !isAlphanumeric(s[hi])) hi--;   // skip junk right
    if (s[lo].toLowerCase() !== s[hi].toLowerCase()) return false;
    lo++; hi--;
  }
  return true;
}

function isAlphanumeric(c: string): boolean {
  return /[a-zA-Z0-9]/.test(c);
}
Skip loops must also guard lo < hi otherwise the inner while can walk past the opposite pointer on an all-punctuation string.

TEMPLATE Expand around center

When → Find the longest palindromic substring or count all palindromic substrings. Expanding from every one of the 2n − 1 centers covers both odd and even lengths.

expand-around-center.ts
// Returns [start, end] of the longest palindromic substring
function longestPalindrome(s: string): string {
  let best = [0, 0];

  function expand(l: number, r: number): void {
    while (l >= 0 && r < s.length && s[l] === s[r]) {
      if (r - l > best[1] - best[0]) best = [l, r];
      l--; r++;
    }
  }

  for (let i = 0; i < s.length; i++) {
    expand(i, i);     // odd-length center
    expand(i, i + 1); // even-length center (gap between i and i+1)
  }
  return s.slice(best[0], best[1] + 1);
}

// Count all palindromic substrings (LC 647)
function countSubstrings(s: string): number {
  let count = 0;
  function expand(l: number, r: number): void {
    while (l >= 0 && r < s.length && s[l] === s[r]) {
      count++;
      l--; r++;
    }
  }
  for (let i = 0; i < s.length; i++) {
    expand(i, i);
    expand(i, i + 1);
  }
  return count;
}
Two expand calls per index → expand(i, i) handles odd-length palindromes centered at i; expand(i, i+1) handles even-length ones centered in the gap. Miss either and you skip half the candidates.

TEMPLATE Length-prefix encode / decode

When → Serialize a list of strings into a single string that can be unambiguously decoded even when the strings contain any character (including the delimiter).

length-prefix-encode-decode.ts
// Length-prefix encoding: "<len>#<str><len>#<str>..."
function encode(strs: string[]): string {
  return strs.map(s => `${s.length}#${s}`).join('');
}

function decode(s: string): string[] {
  const result: string[] = [];
  let i = 0;
  while (i < s.length) {
    const hash = s.indexOf('#', i);          // find the delimiter
    const len = parseInt(s.slice(i, hash));  // read the length prefix
    result.push(s.slice(hash + 1, hash + 1 + len));
    i = hash + 1 + len;                      // jump past the payload
  }
  return result;
}
Why not a plain separator? → if the strings themselves can contain , or |, splitting on that delimiter breaks. The length prefix is always safe because the decoder jumps by an exact byte count, not by searching for a character.

PITFALL 26-array breaks on Unicode / uppercase input

charCode - 97 assumes lowercase ASCII letters az. Uppercase letters, digits, or any non-ASCII character produce negative indices or out-of-range slots. Normalize with .toLowerCase() first, or switch to a Map<string, number> which works for any character set.

PITFALL Missing the even-length palindrome center

A single expand call expand(i, i) only finds palindromes of odd length. Every even-length palindrome (e.g. "abba") has its center in the gap between two adjacent characters. You must also call expand(i, i + 1) for every index, or you will silently miss all even-length palindromes.

PITFALL String concatenation in a loop

In JavaScript/TypeScript, result += charinside a loop creates a new string object on every iteration — that's O(n²) total copying. Always collect characters into an array and call .join('') once at the end for O(n) construction.

PITFALL Forgetting case and whitespace normalization

LC 125 (Valid Palindrome) explicitly strips non-alphanumerics and ignores case. Forgetting either step produces wrong answers on inputs like "A man, a plan, a canal: Panama". Apply .toLowerCase() during comparison (not once upfront, to avoid allocating a whole new string) and skip non-alphanumeric characters with an inner while loop inside the two-pointer template.