Pattern 23 · Strings

String Techniques

A Python string is a frozen list of characters. Every technique on this page is really a choice of representation — grow outward from a center, swap two ends of a character list, collapse a word into a canonical key, or prefix a payload with its own length.

centers 2n − 1palindrome scan O(n²) / O(1)anagram key sorted · 26-countpractice 14 problems
01 core concept

A string is a frozen list of characters

Python strings are immutable. s[0] = 'x' is a TypeError, and s += char does not append to s — it allocates a brand new string and copies every character across. Do that inside a loop and you have quietly written an O(n²) algorithm inside what looks like an O(n) one.

building_strings.pypython
def build_slow(characters):
    """O(n^2): each += copies everything accumulated so far."""
    result = ""
    for char in characters:
        result = result + char      # new string allocated every iteration
    return result


def build_fast(characters):
    """O(n): list append is amortized O(1), and join allocates once."""
    pieces = []
    for char in characters:
        pieces.append(char)
    return "".join(pieces)
Why the slow one sometimes looks fast
CPython has a special case that mutates a string in place when nothing else refers to it, so a microbenchmark can make += look linear. It vanishes the moment a second name points at the string, and it does not exist in other implementations. Never rely on it. Build a list, then ''.join() once.

Pick the representation, and the problem solves itself

Almost every string question on this page is won before you write a loop, by turning the string into the shape that makes the question trivial.

You need to…RepresentationCost
Mutate characters in placelist(s)O(n)
Build output piece by piecelist + ''.join()O(n)
Compare letter multisetsdict of countsO(n)
Bucket rearrangements togethercanonical form as a keyO(n log n) or O(n)
Walk a symmetric structureindex pair (left, right)O(1) space
Serialize a list of stringslength-prefixed chunksO(n)
02 expand around center

Palindromes grow outward from a middle

A palindrome has a middle. Peel one character off each end of a palindrome and what remains is still a palindrome — so run that backwards: start at a middle and push outward while the two ends match. You never re-verify the part you already grew through, which is the entire saving.

The trap is that there are two kinds of middle. An odd-length palindrome such as racecar is centred on a character. An even-length one such as abba is centred on the gap between two characters. A string of length n therefore has n + (n − 1) = 2n − 1 centers, and a solution that only walks the n character-centers silently misses every even-length answer.

"abba" -- even center, sits between index 1 and index 2 a b b a 0 1 2 3 ^ left=1, right=2 'b' == 'b' -> grow left=0, right=3 'a' == 'a' -> grow left=-1 out of bounds -> stop, answer is s[0:4] "racecar" -- odd center, sits on index 3 r a c e c a r 0 1 2 3 4 5 6 ^ left=right=3, then 2/4, then 1/5, then 0/6
1 / 12walkthrough — expand around center on "babad"
b
a
b
a
d
best —
centers to try 2·5 − 1 = 9
Goal: the longest palindromic substring of 'babad'. Rather than testing every substring (O(n³)), we pick a center and grow outward while both ends match. Five characters give 5 single-character centers plus 4 gaps between characters — 9 centers in all.

LC 5Longest Palindromic Substring

Keep the expansion in its own helper so the driver only has to compare lengths. The helper returns where the palindrome starts and how long it is, which avoids slicing strings inside the hot loop.

longest_palindrome.pypython
def expand_from_center(s, left, right):
    """Grow outward while both ends match.
    Returns (start_index, length) of the palindrome found."""
    while left >= 0 and right < len(s) and s[left] == s[right]:
        left -= 1
        right += 1

    # The loop always overshoots by one on each side, so the real
    # palindrome is s[left + 1 : right].
    start = left + 1
    length = right - left - 1
    return start, length


def longest_palindrome(s):
    if not s:
        return ""

    best_start = 0
    best_length = 1

    for center in range(len(s)):
        # Odd lengths: the center is a single character.
        odd_start, odd_length = expand_from_center(s, center, center)
        if odd_length > best_length:
            best_start = odd_start
            best_length = odd_length

        # Even lengths: the center is the gap after this character.
        even_start, even_length = expand_from_center(s, center, center + 1)
        if even_length > best_length:
            best_start = even_start
            best_length = even_length

    return s[best_start:best_start + best_length]
Why the even call is safe at the last index
When center is the final index, right = center + 1 is already out of bounds, so the while body never runs. The helper returns length right - left - 1 = 0, which can never beat best_length = 1. No special case needed.

LC 647Palindromic Substrings — count instead of measure

Same machinery, different bookkeeping. Every successful expansion is itself a distinct palindromic substring, so counting iterations of the while loop counts the answer. Nothing else changes.

count_palindromes.pypython
def count_from_center(s, left, right):
    count = 0
    while left >= 0 and right < len(s) and s[left] == s[right]:
        count += 1          # this expansion IS a palindromic substring
        left -= 1
        right += 1
    return count


def count_palindromic_substrings(s):
    total = 0
    for center in range(len(s)):
        total += count_from_center(s, center, center)      # odd lengths
        total += count_from_center(s, center, center + 1)  # even lengths
    return total
count_palindromic_substrings("aaa") center 0, odd (0,0): "a" -> 1 center 0, even (0,1): "aa" -> 1 center 1, odd (1,1): "a", then (0,2): "aaa" -> 2 center 1, even (1,2): "aa" -> 1 center 2, odd (2,2): "a" -> 1 center 2, even (2,3): right out of bounds -> 0 total = 6

There is an O(n) algorithm for LC 5 — Manacher's — but it is long, fiddly, and almost never expected. Expand-around-center at O(n²) time and O(1) space is the answer interviewers want, and it beats the classic O(n²)-space DP table on memory.

03 reversal

Reversing characters, and reversing words

LC 344 hands you a list of characters rather than a string, precisely because a string could not be reversed in place. Two pointers walk toward each other, swapping as they go — the mechanics live in pattern 02, Two Pointers; what matters here is that the list, not the string, is what makes it possible.

reverse_string.py — LC 344python
def reverse_string(chars):
    """chars is a list of single characters; reverse it in place."""
    left = 0
    right = len(chars) - 1

    while left < right:
        chars[left], chars[right] = chars[right], chars[left]
        left += 1
        right -= 1

The loop stops at left < right, not <=. With an odd length the two pointers meet on the middle character, which needs no swap; using <= swaps it with itself — harmless here, but the same off-by-one corrupts data in reversal problems that do real work at the meeting point.

LC 151Reverse Words in a String

The whole problem is the whitespace rules: leading and trailing spaces are dropped, and runs of several spaces collapse to one. Bare split() does all three for free. split(" ") does not — it emits empty strings for every extra space.

reverse_words.pypython
def reverse_words(s):
    # split() with no argument splits on runs of whitespace and
    # discards leading/trailing blanks -- exactly the spec here.
    words = s.split()
    words.reverse()
    return " ".join(words)
The O(1)-space follow-up
If the interviewer hands you a mutable character array and forbids extra space, use the reverse twice trick: reverse the entire array, which puts the words in the right order but spells each one backwards, then reverse each word in place to fix them. Two linear passes, no allocation.
"the sky is blue" 1. reverse the whole thing -> "eulb si yks eht" 2. reverse each word -> "blue is sky the"

LC 125Valid Palindrome — reversal you never perform

Comparing cleaned == cleaned[::-1] works and is O(n) space. The two-pointer version answers the same question in O(1) space and, more usefully, exits early on the first mismatch.

valid_palindrome.pypython
def is_palindrome(s):
    left = 0
    right = len(s) - 1

    while left < right:
        # Skip anything that isn't a letter or a digit, one side at a time.
        if not s[left].isalnum():
            left += 1
        elif not s[right].isalnum():
            right -= 1
        elif s[left].lower() != s[right].lower():
            return False
        else:
            left += 1
            right -= 1

    return True

Note the elif chain: only one pointer moves per iteration when skipping. Advancing both inside a single if can step a pointer over the character it was supposed to compare.

04 frequency & canonical forms

Two strings are anagrams if they normalize to the same thing

An anagram question is never really about order — it is about multisets of characters. Two strategies: count the characters, or map each string to a single canonical form that all its rearrangements share.

LC 242Valid Anagram — count up, then count down

valid_anagram.pypython
def is_anagram(s, t):
    # Different lengths can never be anagrams -- cheap early exit.
    if len(s) != len(t):
        return False

    counts = {}
    for char in s:
        counts[char] = counts.get(char, 0) + 1

    for char in t:
        if char not in counts:
            return False        # t has a character s never had
        counts[char] -= 1
        if counts[char] == 0:
            del counts[char]    # keep the dict holding only surpluses

    return len(counts) == 0

Deleting keys that hit zero means the final check is just "is the dict empty". The one-liner Counter(s) == Counter(t) is fine to mention afterwards, but write the loop first — it is what shows you understand the invariant.

LC 49Group Anagrams — the canonical form as a key

Group the words by a value that is identical for every rearrangement. There are two standard keys, and the choice is a genuine tradeoff worth stating out loud in an interview.

group_anagrams_sorted.py — key Apython
def group_anagrams(words):
    """Key = the word's letters in sorted order."""
    groups = {}

    for word in words:
        # sorted() returns a LIST, which is unhashable -- convert it.
        key = tuple(sorted(word))       # "eat" and "tea" both -> ('a','e','t')

        if key not in groups:
            groups[key] = []
        groups[key].append(word)

    return list(groups.values())
group_anagrams_counts.py — key Bpython
def group_anagrams(words):
    """Key = a 26-slot vector of letter counts."""
    groups = {}

    for word in words:
        counts = [0] * 26
        for char in word:
            counts[ord(char) - ord("a")] += 1
        key = tuple(counts)             # tuple so it can be a dict key

        if key not in groups:
            groups[key] = []
        groups[key].append(word)

    return list(groups.values())
Sorted tuple26-length count
Time per word (length k)O(k log k)O(k + 26)
Total for n wordsO(n · k log k)O(n · k)
Alphabetany characterslowercase a–z only
Key sizekalways 26
Lines of code14

The count key is asymptotically better and it is the answer to "can you do it without sorting?". But it is only actually faster for long words: with the typical short inputs, hashing a fixed 26-tuple costs more than sorting five letters, and it hard-codes an alphabet that breaks on uppercase or Unicode. Default to the sorted key for clarity; reach for counts when k is large or sorting is explicitly ruled out.

05 encode / decode

Length prefixes beat delimiters, always

LC 271 asks you to flatten a list of strings into one string and read it back exactly. The instinct is to join with a separator like #. That is wrong for a reason worth internalising: the payload is allowed to contain your separator. There is no safe character, because the input can be arbitrary.

Encode ["neet", "co#de", ""] the naive way: "neet#co#de#" Decode with split("#"): ["neet", "co", "de", ""] four pieces from three strings The decoder cannot distinguish the separator from a '#' that was part of the data. Escaping is possible, but then you must escape the escape character too -- more state, more bugs.

The fix is to tell the decoder how many characters to take before it takes them. Each chunk becomes <length>#<payload>. The # is now only a marker meaning "the digits end here", so it never has to be absent from the payload — the decoder counts rather than searches.

1 / 10walkthrough — length-prefixed encode / decode (LC 271)
n
e
e
t
#
c
o
#
d
e
#
original ["neet", "co#de", ""]
Goal: flatten the list ["neet", "co#de", ""] into a single string, then recover the exact same list from it. The obvious first idea is to glue the pieces together with a separator character — here every string is followed by a '#'.
codec.py — LC 271python
def encode(strings):
    """Each chunk is: length, then '#', then the payload itself."""
    chunks = []
    for text in strings:
        chunks.append(str(len(text)))
        chunks.append("#")
        chunks.append(text)
    return "".join(chunks)


def decode(encoded):
    strings = []
    i = 0

    while i < len(encoded):
        # Scan forward to the '#' that closes this chunk's length.
        # Safe: lengths are pure digits, so this '#' is always ours.
        marker = i
        while encoded[marker] != "#":
            marker += 1

        length = int(encoded[i:marker])
        start = marker + 1
        strings.append(encoded[start:start + length])

        # Jump the whole payload -- we never look inside it.
        i = start + length

    return strings
Why the empty string is the giveaway test
Ask any delimiter-based codec to round-trip ["", ""] and it cannot tell two empty strings from one, or from none. The length prefix stores 0#0# and recovers both exactly. If your codec survives ["", "#", "1#a"], it is correct.
06 when to use it

Recognition signals

  • "Palindrome", "reads the same backwards", "symmetric substring" → expand around center
  • "Anagram", "same letters rearranged", "group these words" → a canonical form used as a dict key
  • "Reverse", "in place", and the input is a character array → two converging pointers
  • "Serialize / deserialize a list of strings", "send over a network" → length-prefixed chunks
  • Output is assembled character by character → a list of pieces plus one ''.join()
  • "Common prefix" across a handful of words → vertical scan, column by column (LC 14)
  • "Compare versions", "parse this number" → split, then pad the shorter side with zeros (LC 165)
  • "Zigzag", "justify this text", "multiply as strings" → plain simulation; write down the bookkeeping variables first (LC 6, 68, 43)
When NOT to reach for this page
  • A contiguous window with a constraint — "longest substring without repeating characters", "minimum window substring" — is Sliding Window (pattern 05), not expand-around-center.
  • Two ends converging on a sorted array, or a target-sum pairing, is Two Pointers (pattern 02). This page borrows the mechanic; it does not own it.
  • Prefix matching across many words, autocomplete, or repeated startswith queries wants a Trie (pattern 16).
  • Expand-around-center only finds symmetry around a point. Longest common substring or subsequence between two strings is a DP grid (pattern 07).
07 templates

Three skeletons to have in your fingers

T1Expand around every center

template_expand.pypython
def scan_all_centers(s):
    for center in range(len(s)):
        handle(s, center, center)       # odd-length palindromes
        handle(s, center, center + 1)   # even-length palindromes


def handle(s, left, right):
    while left >= 0 and right < len(s) and s[left] == s[right]:
        # s[left..right] is a palindrome right now -- record it here.
        left -= 1
        right += 1
    # After the loop: the last valid palindrome was s[left + 1 : right].

T2Canonical form as a bucket key

template_canonical.pypython
def bucket_by_canonical_form(words):
    groups = {}

    for word in words:
        key = tuple(sorted(word))       # must be hashable, so tuple

        if key not in groups:
            groups[key] = []
        groups[key].append(word)

    return groups

T3Build a string without quadratic copying

template_build.pypython
def transform(s):
    pieces = []

    for char in s:
        if char.isalpha():
            pieces.append(char.upper())
        else:
            pieces.append("_")

    return "".join(pieces)              # one allocation, at the end
08 complexity

Complexity reference

TechniqueTimeSpace
Expand around center (LC 5, 647)O(n²)O(1)
DP table for palindromesO(n²)O(n²)
Two-pointer palindrome check (LC 125)O(n)O(1)
In-place reversal (LC 344)O(n)O(1)
Reverse words via split / join (LC 151)O(n)O(n)
Anagram check by counts (LC 242)O(n)O(1)*
Group anagrams, sorted key (LC 49)O(n · k log k)O(n · k)
Group anagrams, count key (LC 49)O(n · k)O(n · k)
Encode + decode (LC 271)O(n)O(n)
String build with += in a loopO(n²)O(n)
List build + ''.join()O(n)O(n)

* bounded by the alphabet size, so constant for a–z.

09 common mistakes

Where string problems bite back

Mistake 1 — only checking odd centers
Looping expand(s, i, i) and stopping there finds no even-length palindrome at all. On "abba" it returns "a". You need both (i, i) and (i, i + 1) — 2n − 1 centers, not n.
Mistake 2 — off-by-one after the expansion loop
The while exits after the failing step, so left and right are each one past the palindrome. The substring is s[left + 1 : right] and its length is right - left - 1. Writing s[left:right + 1] grabs the two characters that just failed to match.
Mistake 3 — result += char inside a loop
Strings are immutable, so every += allocates and copies the whole accumulated result: quadratic time hidden in a single innocent line. Append to a list and ''.join() once at the end. Same rule applies to result = char + result for reversing.
Mistake 4 — split(" ") instead of split()
"a b".split(" ") returns ['a', '', 'b'], and a leading space adds another empty string. Bare split() collapses whitespace runs and trims the ends. LC 151 is built entirely around this difference.
Mistake 5 — a delimiter your data can contain
Joining with # or , in LC 271 passes the sample input and fails the moment a payload contains that character — or is empty. Prefix each chunk with its length instead, then jump rather than search.
Mistake 6 — unhashable keys and the 26-letter assumption
sorted(word) returns a list, so groups[sorted(word)] raises TypeError: unhashable type. Wrap it in tuple(...) or "".join(...). And the 26-slot count key assumes lowercase a–z: ord(char) - ord("a") goes negative on an uppercase letter and out of range on Unicode. If the constraints do not promise lowercase ASCII, use a dict.
10 practice

Practice ladder

In this order — easiest first. Tick each one as you solve it, and re-solve anything that took more than 25 minutes two days later.

14 problems
easyLC 344
Two pointers swapping ends of a character list, in place
easyLC 125
Converging pointers that skip non-alphanumeric characters
easyLC 242
Character-frequency map, decremented to zero
easyLC 14
Vertical scan: compare column i across every word
mediumLC 165
Split on dots, pad the shorter list with implicit zeros
mediumLC 151
split() collapses whitespace, then reverse the word list
mediumLC 271 ★
Length-prefix protocol so the payload can contain anything
LeetCode Premium required
mediumLC 49
Canonical form as a dict key: sorted tuple or 26-count tuple
mediumLC 647
Expand around center and count every successful expansion
mediumLC 5
Expand around all 2n-1 centers, track start and length
mediumLC 6
Simulate the row index bouncing between 0 and numRows-1
mediumLC 8
Ordered parse phases: spaces, sign, digits, clamp to 32 bits
mediumLC 43
Grade-school multiplication into a digit array, carry afterwards
hardLC 68
Greedy line packing, then integer division to spread the spaces