Skip to content

String manipulation

coreCommon atGoogleMetaAmazonStripeOpenAIRead firstArraysPractise in the Sandbox →

A string is an array of characters you can’t modify: every “change” makes a new string, so building one with += in a loop can cost O(n²). Collect pieces and join once (or use a builder) for O(n). Characters are numbers underneath, so ch - 'a' style arithmetic gives you O(1) letter indexes, as long as you know which “character” your language counts.

Strings in Python, JavaScript and C# are immutable: once created, their contents never change. That’s what makes them safe to share, use as hash keys, and pass around without defensive copies.

The consequence: s = s + "x" doesn’t extend s. It allocates a new string of length len(s) + 1, copies all of s into it, then adds "x". Do that n times and you copy 1 + 2 + … + n ≈ n²/2 characters.

The fix in every language is the same idea as a dynamic array: keep a growable buffer of pieces and turn it into one string at the end.

  • Python: parts.append(piece) then "".join(parts) (or io.StringIO).
  • TypeScript: parts.push(piece) then parts.join("").
  • C#: StringBuilder.Append(piece) then .ToString(), or string.Join / string.Concat.

Append words both ways and compare how many characters get copied. The += lane recopies the whole string every time; the builder lane only writes the new piece, plus an occasional doubling.

Building a string: += vs a builder
sandbox
s = s + piece (immutable): every append builds a brand-new string. Yellow = characters copied by the last append.
builder / list + join: append writes into spare capacity (dashed). Green = characters written by the last append. capacity = 8
+= : chars copied
0
builder: chars copied
0
After 0 appends the string has 0 chars. Repeated += copies about n²/(2·piece) characters; the builder copies about 2n(amortized doubling) plus one final ToString() / join (included in the green bar). Press ×50 a few times and watch the gap grow quadratically.
The problem says… The tool
“build an output string”, “encode”, “format” List of parts + join, or a StringBuilder.
“letters a-z”, “shift by k”, “index of a letter” Char codes: ord(ch) - ord('a'), charCodeAt, ch - 'a'.
“words separated by spaces”, “parse fields” split (know its edge cases) or a hand-rolled scanner.
“modify characters in place” Convert to a list / char[], edit, convert back.
“substring that…”, “contiguous run of chars” Indices plus two pointers or a sliding window, not repeated slicing.
“find a pattern in text” Built-in in / includes / Contains, or string algorithms (KMP, rolling hash).
  1. Split into tokens. Know whether empty fields are kept.
  2. Transform the tokens as a list (an ordinary mutable array).
  3. Join once at the end.
reverse_words.py
def reverse_words(s: str) -> str:
"""' the sky is blue ' -> 'blue is sky the' (collapse runs of spaces)."""
words = s.split() # 1. no-arg split: splits on whitespace runs, drops empty strings
words.reverse() # 2. reverse the list in place, O(n)
return " ".join(words) # 3. one allocation for the result, not one per word
def split_keep_empty(s: str, sep: str) -> list[str]:
return s.split(sep) # with an explicit separator, empty fields are kept
if __name__ == "__main__":
assert reverse_words(" the sky is blue ") == "blue is sky the"
assert reverse_words("") == ""
assert split_keep_empty("a,,b", ",") == ["a", "", "b"]
assert split_keep_empty("", ",") == [""] # not []!
assert "".split() == []
print("ok")

split is the most edge-case-dense call in this whole topic:

Input Python TypeScript C#
"a,,b" split on , ['a', '', 'b'] ['a', '', 'b'] ["a", "", "b"]
"" split on , [''] [''] [""]
" a b " on whitespace s.split() → ['a', 'b'] s.split(/\s+/) → ['', 'a', 'b', ''] Split(' ', RemoveEmptyEntries) → ["a", "b"]
limit of 2 on "a,b,c" split(',', 1) → ['a', 'b,c'] split(',', 2) → ['a', 'b'] (truncates) Split(',', 2) → ["a", "b,c"]
trim each field [f.strip() for f in …] .map(f => f.trim()) StringSplitOptions.TrimEntries (.NET 5+)
  1. Get the code: ord(ch) (Python), s.charCodeAt(i) (TS), (int)ch or just ch - 'a' (C#).
  2. Do arithmetic: offset, mod 26, compare ranges.
  3. Turn it back: chr(n), String.fromCharCode(n), (char)n.
to_title / to_number.py
def to_title(n: int) -> str:
"""Spreadsheet column: 1 -> 'A', 26 -> 'Z', 27 -> 'AA', 703 -> 'AAA'."""
parts: list[str] = [] # 1. collect pieces in a list; join once at the end
while n > 0:
n -= 1 # 2. bijective base 26: shift to 0..25 before each digit
parts.append(chr(ord("A") + n % 26))
n //= 26
return "".join(reversed(parts)) # 3. digits came out least-significant first
def to_number(title: str) -> int:
n = 0
for ch in title:
n = n * 26 + (ord(ch) - ord("A") + 1) # 4. char code arithmetic: 'A' -> 1
return n
if __name__ == "__main__":
for n, t in [(1, "A"), (26, "Z"), (27, "AA"), (52, "AZ"), (703, "AAA")]:
assert to_title(n) == t and to_number(t) == n
assert all(to_number(to_title(n)) == n for n in range(1, 2000))
print("ok")

But “one character” means different things. Python strings are sequences of code points. JS and C# strings are sequences of UTF-16 code units, so an emoji outside the Basic Multilingual Plane is two units (a surrogate pair). What a person sees as one character (a grapheme) can be several code points: a base letter plus a combining accent, or an emoji plus a skin-tone modifier. Type something:

Characters, code points, code units, bytes
type anything
you seecode points (Python ord)UTF-16 units (JS / C# char)UTF-8 bytes
CU+0043004343
aU+0061 (ord−97 = 0)006161
fU+0066 (ord−97 = 5)006666
éU+00E900E9C3 A9
␣U+0020002020
👍🏽U+1F44D U+1F3FDD83D DC4D D83C DFFD ← surrogate pairF0 9F 91 8D F0 9F 8F BD
␣U+0020002020
oU+006F (ord−97 = 14)006F6F
kU+006B (ord−97 = 10)006B6B
Python len(s)10
JS s.length / C# s.Length12
UTF-8 bytes17
graphemes (what users see)9
JS s.split('').reverse().join(''): ko �🁍� éfaC
JS [...s].reverse().join('') / Python s[::-1]: ko 🏽👍 éfaC
Highlighted rows take more than one unit. Reversing by UTF-16 unit splits surrogate pairs into garbage (�); reversing by code point keeps emoji whole but still detaches skin-tone modifiers and combining accents. For ASCII-only input (most interview problems) all four counts agree. Ask.
Python TypeScript C#
length len(s) (code points) s.length (UTF-16 units) s.Length (UTF-16 units)
char at i s[i] (a 1-char str) s[i] / s.charAt(i) s[i] (a char)
substring s[i:j] (copy) s.slice(i, j) s[i..j] / Substring(i, len) (copy); AsSpan(i, len) (no copy)
find s.find(t) (-1) / s.index(t) (raises) s.indexOf(t) s.IndexOf(t, StringComparison.Ordinal)
mutable copy list(s) [...s] / s.split('') s.ToCharArray()
back to string "".join(chars) chars.join('') new string(chars)
letter / digit test ch.isalpha(), ch.isdigit() regex /[a-z]/i.test(ch) char.IsLetter(ch), char.IsDigit(ch)
case s.lower(), s.casefold() s.toLowerCase() s.ToLowerInvariant()
equality == (value) === (value, for primitives) == (value, ordinal)
Operation Cost Note
index s[i] O(1) Python stores code points in a fixed width per string, so this is O(1) too.
length O(1)
slice / substring of k chars O(k) A copy, except C# Span / ReadOnlyMemory.
a + b O(len(a) + len(b)) New string.
n appends with += O(n²) worst Total characters copied grows quadratically.
n appends with builder / join O(n) Amortized doubling, one final copy.
t in s / includes / Contains O(len(s) · len(t)) worst Implementations use fast heuristics, but don’t claim O(n) without saying so.
compare / hash O(length) Hashing a long key isn’t free.
split, join, replace, lower O(n) Each allocates a new result.

The interview trap is the hidden O(n) inside a loop: slicing s[1:] in recursion, s.replace per character, or building with +=. Pass indices instead of substrings and build with a builder.

  • Mutable char buffers: list(s) / char[] for in-place reversal or swapping, then convert back once.
  • Fixed alphabet tables: int[26] counts indexed by ch - 'a'. See counting.
  • Scanning parsers: walk an index through the string, reading digits, names and separators. See parsing.
  • Interning: identical string literals may share one object. Use == for value equality in Python and C#; never is (Python) or ReferenceEquals (C#).
  • Byte strings: Python bytes/bytearray, JS Uint8Array + TextEncoder, C# byte[] + Encoding.UTF8. Needed whenever sizes are in bytes (network, files, token budgets).
  • Quadratic building with +=, especially in C#.
  • Off-by-one in slices. Python and JS end indices are exclusive; C# Substring(start, length) takes a length, not an end.
  • Culture-sensitive comparison in C#. string.Compare, StartsWith(string) and ToLower() use the current culture by default; pass StringComparison.Ordinal or use ToLowerInvariant. (The classic bug: Turkish dotless i.)
  • JS default sort compares UTF-16 units: ["b", "a", "B"].sort() gives ["B", "a", "b"]. Same for Python and C# ordinal sorts: uppercase sorts before lowercase.
  • Surrogate pairs. s.split('') and s[i] in JS/C# can split an emoji in half.
  • Python str.isdigit() is true for characters like '²'. For ASCII digits specifically, compare against '0' <= ch <= '9'.
  • Ask about the character set: “ASCII only? Lowercase letters? Unicode?” It decides int[26] vs a map, and whether length means characters or bytes.
  • Ask about whitespace and empty input: leading/trailing spaces, repeated separators, empty string.
  • Say out loud when you choose a builder: “Strings are immutable, so I collect parts and join once: O(n) instead of O(n²).”
  • Pass indices, not substrings, into helpers and recursion, and say why.
  • Follow-ups to expect: “the input is a 10 GB file” (stream it line by line), “now support Unicode” (code points vs graphemes).
Check yourself
pick one
A C# loop runs `result += word + " ";` for 100,000 words of about 5 characters. Roughly how many characters get copied in total?
Check yourself
pick one
In JavaScript, what is "👍".length?
Check yourself
pick one
You recurse with helper(s[1:]) on a string of length n. Ignoring the recursion's own work, what do the slices cost in total?

Classic textbook exercises, not gym questions:

  • Longest common prefix of a list of words.
  • Basic string compression: "aabcccccaaa" → "a2b1c5a3", returning the original if it isn’t shorter.
  • Is t a rotation of s? (one in check, if you see the trick)
  • Caesar cipher: shift letters by k, keeping case and leaving other characters alone.