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.
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.
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.
int[]?” Usually the answer is the answer. Just remember strings are immutable in JS — mutate an array of chars and .join('') at the end.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.
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.
2n − 1 centers or you silently miss every even-length palindrome like "abba".Faced with a fresh string problem, climb these rungs in order — the right tool falls out as soon as one matches:
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.Each tool has a one-sentence invariant. Say it out loud and the loop conditions write themselves:
[lo, hi]already matched its mirror” — keep going while lo < hi.s[l..r] is a palindrome right now” — that invariant holds only while s[l] === s[r] and both indices stay in bounds.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 outThe 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.
A string is a sequence of characters — treat it like an array and every array technique becomes available. The most-used primitives are:
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.lo starts left, hi starts right — converge while skipping non-alphanumerics and comparing case-folded chars.O(n²) time and O(1) space.5#hello3#foo. The decoder reads the number, then extracts exactly that many characters — no ambiguity.word.split('').sort().join('') (sort the characters) or build a 26-count vector and stringify it. Both are correct; the costs differ.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.
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.
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.
A large fraction of string problems are not fundamentally about strings — they reduce to a pattern you already know:
Identifying the reduction early is what separates a clean solution from a tangled one.
babad. A palindrome mirrors around a center — so try every center and grow outward. Grow while the mirror holds.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 reverse | two 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 delimiter | length-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 |
When → Two strings of the same length: check whether they use identical characters. Tally one string up, drain the other down, verify all zeros.
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
}charCode - 97) when input is guaranteed lowercase ASCII. Use a full Map<string, number> for Unicode or mixed-case without normalization.When → Determine whether a string reads the same forwards and backwards, optionally ignoring non-alphanumeric characters and case (LC 125 style).
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);
}lo < hi → otherwise the inner while can walk past the opposite pointer on an all-punctuation string.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.
// 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;
}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.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 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;
}, 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.charCode - 97 assumes lowercase ASCII letters a–z. 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.
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.
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.
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.