GUIDE · Patterns

Strings

character-level scanning, frequency tables, and parsing.

lesson13 sectionsruns in browser39 asserts183 lines

At a glance

WHAT
character-level scanning, frequency tables, and parsing.
TELL
"palindrome", "anagram", "lowercase letters only" (-> array[26]), "substring", "prefix", "parse", "compress".
SAY
two pointersfrequency array / countingexpand around centercanonical key (sorted string)KMP failure (LPS) tablechar code arithmetic
BIG-O
O(n) scans; O(n^2) for expand-around-center; O(n k log k) for sorted keys.

The idea#

Picture a row of letter beads on a string. You can pinch the two ends and walk your fingers inward, comparing beads as you go. If they match all the way to the middle, the string reads the same forward and backward. That is a palindrome, and the two fingers are the whole check.

You can also dump the beads into twenty-six cups, one cup per letter, and count. An anagram is two strings that fill the cups the same way. Add one for the first string, subtract one for the second, and every cup should end at zero. When the problem promises lowercase English letters, the cups are a length-26 array, indexed by code - 97, and 'a'.charCodeAt(0) is 97. String.fromCharCode(97 + i) turns the index back into a letter.

The rest of the work is still beads and cups. Expand around a center to grow the longest mirrored stretch. Shrink a shared prefix until every word still starts with it. Count a run of the same bead and write the letter plus the count. Parse a bead at a time when the string is really a number or a Roman numeral hiding in costume.

Why it matters#

Strings invite you to chop them into arrays and sort every slice. That is sometimes the right key, and it is often a quadratic habit wearing a library call. You reach for this sheet when the problem says palindrome, anagram, lowercase letters only, substring, prefix, parse, or compress. The tell is "the characters themselves are the data." The tool is a scan, a cup count, or two fingers.

A plain scan is O(n). Expanding around every center to find the longest palindromic substring is O(n²). Grouping anagrams by sorting each word of length k is O(n · k log k).

A small example#

JS
function isPalindrome(word) {  let lo = 0;  let hi = word.length - 1;  while (lo < hi) {    if (word[lo] !== word[hi]) return false;    lo += 1;    hi -= 1;  }  return true;} const ok = isPalindrome('kayak');

The fingers start on the two ks, meet in the middle on y, and ok is true. One mismatch returns false and the walk stops. No second string is built.

What the code below is doing#

The sheet starts with the char-code arithmetic, so the cups have an address. Palindrome check is the two-finger walk, linear time and constant space. Valid anagram is the length-26 cup array, plus one, minus one, then a zero check. Reverse words splits on whitespace, drops the empty pieces, reverses the list, and joins. Longest palindromic substring tries 2n - 1 centers, the odd-length ones on a bead and the even-length ones between beads, and expands while the sides match.

Longest common prefix shrinks a candidate until every word startsWith it, linear in the total number of characters. Run-length compression counts a run and emits the character plus the count when the run is longer than one. First unique character counts, then walks again for the first index whose count is 1. Isomorphic strings keep two maps, s to t and t to s, and both directions have to agree, so the mapping is a bijection. Group anagrams sorts each word and uses that sorted string as the map key.

The parse sections are a single left-to-right scan. The atoi-style parser skips whitespace, reads a sign, reads digits, and clamps into the 32-bit integer range. Roman to integer adds the value, and subtracts when a smaller value sits immediately before a larger one, the way IV is four. The last section builds the KMP failure table, the longest proper prefix that is also a suffix, and then searches by jumping the pattern index through that table on a mismatch, so the text and the pattern are each scanned a linear number of times.

When you talk about it, say this: beads in a row, cups for letters, and a scan that decides one character at a time.

Everything below is the demonstration. Same idea, written as code you can run.

JSalgorithms/strings.js · setup
'use strict';const assert = require('node:assert/strict');
00

Char-code arithmetic#

'a'.charCodeAt(0) === 97; index = code - 97; back: String.fromCharCode(97 + i)

JS
const A = 'a'.charCodeAt(0);const idx = (ch) => ch.charCodeAt(0) - A;const chr = (i) => String.fromCharCode(A + i);
Verified by 3 asserts
JS
assert.equal(idx('c'), 2);assert.equal(chr(25), 'z');assert.equal(String.fromCharCode(...[104, 105]), 'hi');
01

Palindrome check (two pointers)#

compare from both ends inward

O(n) / O(1)
JS
function isPalindrome(s) {  let i = 0, j = s.length - 1;  while (i < j) if (s[i++] !== s[j--]) return false;  return true;}
Verified by 3 asserts
JS
assert.equal(isPalindrome('racecar'), true);assert.equal(isPalindrome('ab'), false);assert.equal(isPalindrome(''), true);
02

Valid anagram (frequency array of 26)#

+1 for s, -1 for t, all zero

O(n) / O(1)
JS
function isAnagram(s, t) {  if (s.length !== t.length) return false;  const freq = new Array(26).fill(0);  for (let i = 0; i < s.length; i++) { freq[idx(s[i])]++; freq[idx(t[i])]--; }  return freq.every((c) => c === 0);}
Verified by 3 asserts
JS
assert.equal(isAnagram('anagram', 'nagaram'), true);assert.equal(isAnagram('rat', 'car'), false);assert.equal(isAnagram('', ''), true);
03

Reverse words#

split on whitespace, filter empties, reverse, join

O(n) / O(n)
JS
const reverseWords = (s) => s.split(/\s+/).filter(Boolean).reverse().join(' ');
Verified by 3 asserts
JS
assert.equal(reverseWords('the sky is blue'), 'blue is sky the');assert.equal(reverseWords('  hello   world  '), 'world hello');assert.equal(reverseWords(''), '');
04

Longest palindromic substring (expand around center)#

2n-1 centers (odd + even)

O(n^2) / O(1)
JS
function longestPalindrome(s) {  let best = '';  const expand = (l, r) => {    while (l >= 0 && r < s.length && s[l] === s[r]) { l--; r++; }    if (r - l - 1 > best.length) best = s.slice(l + 1, r);  };  for (let i = 0; i < s.length; i++) { expand(i, i); expand(i, i + 1); }  return best;}
Verified by 3 asserts
JS
assert.equal(longestPalindrome('babad'), 'bab');assert.equal(longestPalindrome('cbbd'), 'bb');assert.equal(longestPalindrome(''), '');
05

Longest common prefix#

shrink prefix until every word startsWith it

O(S) total chars / O(1)
JS
function longestCommonPrefix(strs) {  if (strs.length === 0) return '';  let p = strs[0];  for (const w of strs) while (!w.startsWith(p)) p = p.slice(0, -1);  return p;}
Verified by 3 asserts
JS
assert.equal(longestCommonPrefix(['flower', 'flow', 'flight']), 'fl');assert.equal(longestCommonPrefix(['dog', 'racecar']), '');assert.equal(longestCommonPrefix([]), '');
06

String compression (run-length)#

count runs; emit char + count if > 1

O(n) / O(n)
JS
function compress(s) {  let out = '';  for (let i = 0; i < s.length;) {    let j = i;    while (j < s.length && s[j] === s[i]) j++;    out += s[i] + (j - i > 1 ? j - i : '');    i = j;  }  return out;}
Verified by 3 asserts
JS
assert.equal(compress('aabcccc'), 'a2bc4');assert.equal(compress('abc'), 'abc');assert.equal(compress(''), '');
07

First unique character#

count pass, then first index with count 1

O(n) / O(1)
JS
function firstUniqChar(s) {  const freq = new Map();  for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);  for (let i = 0; i < s.length; i++) if (freq.get(s[i]) === 1) return i;  return -1;}
Verified by 3 asserts
JS
assert.equal(firstUniqChar('leetcode'), 0);assert.equal(firstUniqChar('loveleetcode'), 2);assert.equal(firstUniqChar('aabb'), -1);
08

Isomorphic strings#

bijection: two maps s->t and t->s must agree

O(n) / O(1)
JS
function isIsomorphic(s, t) {  if (s.length !== t.length) return false;  const st = new Map(), ts = new Map();  for (let i = 0; i < s.length; i++) {    const a = s[i], b = t[i];    if ((st.has(a) && st.get(a) !== b) || (ts.has(b) && ts.get(b) !== a)) return false;    st.set(a, b); ts.set(b, a);  }  return true;}
Verified by 3 asserts
JS
assert.equal(isIsomorphic('egg', 'add'), true);assert.equal(isIsomorphic('foo', 'bar'), false);assert.equal(isIsomorphic('badc', 'baba'), false);
09

Group anagrams (sorted key)#

key = chars sorted; Map key -> list

O(n k log k) / O(nk)
JS
function groupAnagrams(strs) {  const groups = new Map();  for (const w of strs) {    const key = [...w].sort().join('');    if (!groups.has(key)) groups.set(key, []);    groups.get(key).push(w);  }  return [...groups.values()];}
Verified by 2 asserts
JS
assert.deepEqual(groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat']), [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]);assert.deepEqual(groupAnagrams(['']), [['']]);
10

atoi-style parse with overflow clamp#

skip ws, sign, digits, clamp to int32

O(n) / O(1)
JS
function myAtoi(s) {  const INT_MAX = 2 ** 31 - 1, INT_MIN = -(2 ** 31);  let i = 0, sign = 1, num = 0;  while (s[i] === ' ') i++;  if (s[i] === '+' || s[i] === '-') sign = s[i++] === '-' ? -1 : 1;  while (i < s.length && s[i] >= '0' && s[i] <= '9') {    num = num * 10 + (s.charCodeAt(i++) - 48);    if (sign * num > INT_MAX) return INT_MAX;    if (sign * num < INT_MIN) return INT_MIN;  }  return sign * num;}
Verified by 3 asserts
JS
assert.equal(myAtoi('   -42abc'), -42);assert.equal(myAtoi('91283472332'), 2147483647);assert.equal(myAtoi('words 987'), 0);
11

Roman to integer#

if value < next value, subtract; else add

O(n) / O(1)
JS
function romanToInt(s) {  const v = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };  let total = 0;  for (let i = 0; i < s.length; i++) total += v[s[i]] < (v[s[i + 1]] ?? 0) ? -v[s[i]] : v[s[i]];  return total;}
Verified by 3 asserts
JS
assert.equal(romanToInt('MCMXCIV'), 1994);assert.equal(romanToInt('LVIII'), 58);assert.equal(romanToInt('I'), 1);
12

KMP failure table (LPS: longest proper prefix that is also a suffix)#

lps[i] for pattern[0..i]

O(m) / O(m)
JS
function buildLPS(p) {  const lps = new Array(p.length).fill(0);  for (let i = 1, len = 0; i < p.length;) {    if (p[i] === p[len]) lps[i++] = ++len;    else if (len > 0) len = lps[len - 1]; // fall back, do not advance i    else lps[i++] = 0;  }  return lps;}// KMP search  —  on mismatch jump j = lps[j-1] instead of restarting  —  O(n + m)function kmpSearch(text, p) {  if (p.length === 0) return 0;  const lps = buildLPS(p);  for (let i = 0, j = 0; i < text.length;) {    if (text[i] === p[j]) { i++; j++; if (j === p.length) return i - j; }    else if (j > 0) j = lps[j - 1];    else i++;  }  return -1;}
Verified by 4 asserts
JS
assert.deepEqual(buildLPS('aabaaab'), [0, 1, 0, 1, 2, 2, 3]);assert.deepEqual(buildLPS('abcd'), [0, 0, 0, 0]);assert.equal(kmpSearch('abxabcabcaby', 'abcaby'), 6);assert.equal(kmpSearch('aaaa', 'b'), -1);

See also