Strings
Topic 3 of 8, with 4 concept checks. Immutability, formatting, and common string operations
Reason about immutable text transformations
Text operations
Treat every string operation as producing a value rather than changing the original text, then connect indexing, slicing, formatting, and normalization to that immutable model.
Core lesson 01
my_string[::-1] — slicing with a step of -1, implemented in C, is the idiomatic and fastest way.
Python slice syntax is `seq[start:stop:step]`. Omitting start/stop and using step -1 tells Python to walk the whole sequence backward. Because slicing is implemented in CPython's C layer, this is faster than a manual loop for reversing any sequence, not just strings.
s = "hello"
print(s[::-1]) # 'olleh'
print(s[::2]) # 'hlo' -- every 2nd char
print(list(reversed(s))) # alternative, returns an iteratorWhat to remember
How do you reverse a string idiomatically in Python?
Common footguns
- Using `reversed(s)` and then trying to print it directly — it returns an iterator, not a string; wrap with `''.join(...)`.
Core lesson 02
Prefer f-strings for readability and speed. Use .format() for dynamically-built templates. % is legacy.
All three embed values into strings, but differ in mechanism. `%` is C-style printf formatting — oldest, least flexible. `.format()` uses `{}` placeholders resolved by position/name, useful when the template string itself is data (e.g. loaded from a file). f-strings (3.6+) let you write expressions directly inside `{}` and are evaluated at parse time, making them both the fastest and most readable for code you're writing directly.
name, score = "Ada", 95
print("%s scored %d" % (name, score)) # legacy
print("{} scored {}".format(name, score)) # flexible templates
print(f"{name} scored {score}") # preferred
print(f"{score/100:.1%}") # inline formatting specWhat to remember
What's the difference between f-strings, .format(), and % formatting?
Common footguns
- Using an f-string for a template string that isn't known until runtime (e.g. loaded from a config file) — f-strings evaluate immediately, so `.format()` is actually the right tool there.
Core lesson 03
Strings are immutable, so each += copies everything so far into a new string — O(n²) total for n iterations. Use ''.join(...) instead.
Because str is immutable, `s = s + "a"` (what += desugars to for strings) allocates a brand new string containing the old contents plus the new character, then rebinds `s` to it. Doing this n times means copying 1 + 2 + ... + n characters — quadratic total work. `''.join(parts)` instead computes the final size once and writes each piece exactly once.
# Slow: O(n^2)
s = ""
for i in range(5):
s += str(i)
print(s)
# Fast: O(n)
parts = [str(i) for i in range(5)]
print("".join(parts))What to remember
Why is repeatedly doing s += 'a' in a loop inefficient?
Common footguns
- Not noticing this until the input gets large — for small n the slow version looks perfectly fine.
s = ""
for i in range(n):
s += "x" # each += copies ALL previous chars into a NEW string
# 1+2+3+...+n ~= O(n^2) total copies
"".join(parts) # O(n) -- builds the result onceCore lesson 04
strip() trims both ends, lstrip() only the left, rstrip() only the right — all default to whitespace but accept a custom character set.
All three remove characters from the ends of a string, not the middle. By default they strip whitespace, but you can pass a string of characters to strip instead — and it's treated as a *set* of characters, not a literal prefix/suffix.
print(' hi '.strip()) # 'hi'
print(' hi '.lstrip()) # 'hi '
print(' hi '.rstrip()) # ' hi'
print('xxhixx'.strip('x')) # 'hi'
print('xyhixy'.strip('xy')) # 'hi' -- strips ANY of x or y, not the literal 'xy'What to remember
What's the difference between .strip(), .lstrip(), and .rstrip()?
Common footguns
- Expecting `.strip('xy')` to remove the literal substring 'xy' from the ends — it actually strips any combination of the characters 'x' and 'y'. For literal prefix/suffix removal, use `.removeprefix()`/`.removesuffix()` (3.9+).
Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Use f-strings for formatting in Python 3.6+.
- Use ''.join(...) instead of += when building strings inside a loop.
- Use .startswith()/.endswith() instead of slicing for prefix/suffix checks.
- Use .casefold() rather than .lower() for robust case-insensitive comparisons across languages.
Apply the concept in Interview practice
Valid PalindromeeasyLeetCode #125 · O(n) time, O(1) space
Filter to alphanumeric lowercase characters, then compare with two pointers from both ends.
Open problemValid AnagrameasyLeetCode #242 · O(n) time
Compare Counter(s1) == Counter(s2), or sort both strings and compare.
Open problemLongest Common PrefixeasyLeetCode #14 · O(n log n) time
Sort the list of strings; the common prefix can only be the shared prefix between the first and last strings after sorting.
Open problemString CompressionmediumLeetCode #443 · O(n) time, O(1) extra space
Use two pointers to walk the character array in place, counting consecutive repeats and writing char+count back into the same array; return the new length.
Open problemReverse Words in a StringmediumLeetCode #151 · O(n) time
Split on whitespace (which also collapses repeated spaces), reverse the list of words, and join with a single space.
Open problemConcept checks
How do you reverse a string idiomatically in Python?
Hint
Slicing supports a step argument.
A negative step walks backward through the sequence.
Answer
my_string[::-1] — slicing with a step of -1, implemented in C, is the idiomatic and fastest way.
Python slice syntax is `seq[start:stop:step]`. Omitting start/stop and using step -1 tells Python to walk the whole sequence backward. Because slicing is implemented in CPython's C layer, this is faster than a manual loop for reversing any sequence, not just strings.
s = "hello"
print(s[::-1]) # 'olleh'
print(s[::2]) # 'hlo' -- every 2nd char
print(list(reversed(s))) # alternative, returns an iteratorWatch out
- Using `reversed(s)` and then trying to print it directly — it returns an iterator, not a string; wrap with `''.join(...)`.
What's the difference between f-strings, .format(), and % formatting?
Hint
One was introduced in 3.6 and evaluates expressions directly inline.
Think readability and performance, not just syntax.
Answer
Prefer f-strings for readability and speed. Use .format() for dynamically-built templates. % is legacy.
All three embed values into strings, but differ in mechanism. `%` is C-style printf formatting — oldest, least flexible. `.format()` uses `{}` placeholders resolved by position/name, useful when the template string itself is data (e.g. loaded from a file). f-strings (3.6+) let you write expressions directly inside `{}` and are evaluated at parse time, making them both the fastest and most readable for code you're writing directly.
name, score = "Ada", 95
print("%s scored %d" % (name, score)) # legacy
print("{} scored {}".format(name, score)) # flexible templates
print(f"{name} scored {score}") # preferred
print(f"{score/100:.1%}") # inline formatting specWatch out
- Using an f-string for a template string that isn't known until runtime (e.g. loaded from a config file) — f-strings evaluate immediately, so `.format()` is actually the right tool there.
Why is repeatedly doing s += 'a' in a loop inefficient?
Hint
Consider what happens to an immutable object when you 'change' it.
Every += actually builds something new.
Answer
Strings are immutable, so each += copies everything so far into a new string — O(n²) total for n iterations. Use ''.join(...) instead.
Because str is immutable, `s = s + "a"` (what += desugars to for strings) allocates a brand new string containing the old contents plus the new character, then rebinds `s` to it. Doing this n times means copying 1 + 2 + ... + n characters — quadratic total work. `''.join(parts)` instead computes the final size once and writes each piece exactly once.
# Slow: O(n^2)
s = ""
for i in range(5):
s += str(i)
print(s)
# Fast: O(n)
parts = [str(i) for i in range(5)]
print("".join(parts))s = ""
for i in range(n):
s += "x" # each += copies ALL previous chars into a NEW string
# 1+2+3+...+n ~= O(n^2) total copies
"".join(parts) # O(n) -- builds the result onceWatch out
- Not noticing this until the input gets large — for small n the slow version looks perfectly fine.
What's the difference between .strip(), .lstrip(), and .rstrip()?
Hint
Think about which side(s) get trimmed.
l = left, r = right.
Answer
strip() trims both ends, lstrip() only the left, rstrip() only the right — all default to whitespace but accept a custom character set.
All three remove characters from the ends of a string, not the middle. By default they strip whitespace, but you can pass a string of characters to strip instead — and it's treated as a *set* of characters, not a literal prefix/suffix.
print(' hi '.strip()) # 'hi'
print(' hi '.lstrip()) # 'hi '
print(' hi '.rstrip()) # ' hi'
print('xxhixx'.strip('x')) # 'hi'
print('xyhixy'.strip('xy')) # 'hi' -- strips ANY of x or y, not the literal 'xy'Watch out
- Expecting `.strip('xy')` to remove the literal substring 'xy' from the ends — it actually strips any combination of the characters 'x' and 'y'. For literal prefix/suffix removal, use `.removeprefix()`/`.removesuffix()` (3.9+).