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.
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.
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)
+= 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… | Representation | Cost |
|---|---|---|
| Mutate characters in place | list(s) | O(n) |
| Build output piece by piece | list + ''.join() | O(n) |
| Compare letter multisets | dict of counts | O(n) |
| Bucket rearrangements together | canonical form as a key | O(n log n) or O(n) |
| Walk a symmetric structure | index pair (left, right) | O(1) space |
| Serialize a list of strings | length-prefixed chunks | O(n) |
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.
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.
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]
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.
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
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.
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.
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.
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)
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.
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.
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
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.
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())
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 tuple | 26-length count | |
|---|---|---|
| Time per word (length k) | O(k log k) | O(k + 26) |
| Total for n words | O(n · k log k) | O(n · k) |
| Alphabet | any characters | lowercase a–z only |
| Key size | k | always 26 |
| Lines of code | 1 | 4 |
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.
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.
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.
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
["", ""] 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.
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)
- 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).
Three skeletons to have in your fingers
T1Expand around every center
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
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
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
Complexity reference
| Technique | Time | Space |
|---|---|---|
| Expand around center (LC 5, 647) | O(n²) | O(1) |
| DP table for palindromes | O(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 loop | O(n²) | O(n) |
List build + ''.join() | O(n) | O(n) |
* bounded by the alphabet size, so constant for a–z.
Where string problems bite back
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.
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.
result += char inside a loop+= 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.
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.
# 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.
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.
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.