Introduction
String building and collection operations are among the most frequently written Python code, yet small choices in how you concatenate strings or check membership can create order-of-magnitude performance differences. This lesson covers the key patterns that separate O(n) from O(n^2) in everyday code.
Key Concepts
str.join(): Builds a string from an iterable in a single pass by pre-calculating the total size, achieving O(n) time versus O(n^2) for repeated+=.- Generator expression: A lazy iterator that produces values one at a time, avoiding the memory cost of materializing an entire list.
- Set membership testing: Checking
item in setis O(1) average case via hash lookup, versus O(n) foritem in list. collections.deque: A double-ended queue with O(1) append and pop from both ends, unlikelist.pop(0)which is O(n).
Real World Context
Building HTML or CSV output from thousands of rows, filtering large datasets for duplicate detection, and implementing task queues or BFS algorithms all benefit directly from choosing the right string and collection operations.
Deep Dive
String Concatenation: join() vs +=
Repeated += on strings creates a new string object on each iteration because strings are immutable. For n concatenations, this copies approximately 1 + 2 + 3 + ... + n characters, totaling O(n^2):
python# O(n^2) — each += allocates a new, larger string result = "" for word in words: result += word # Copies all previous characters every time
str.join() pre-calculates the total length, allocates once, and copies each string exactly once:
python# O(n) — single allocation, one copy per string result = "".join(words)
For building strings incrementally (e.g., in a loop with formatting), collect parts in a list, then join:
pythonparts = [] for item in items: parts.append(f"{item.name}: {item.value}") result = "\n".join(parts)
Alternatively, io.StringIO provides a file-like buffer:
pythonfrom io import StringIO buffer = StringIO() for item in items: buffer.write(f"{item.name}: {item.value}\n") result = buffer.getvalue()
Generators vs Lists
When you only need to iterate once, a generator avoids allocating the entire result in memory:
python# Materializes a list of 10 million items in memory total = sum([x ** 2 for x in range(10_000_000)]) # ~80 MB # Generates values one at a time, constant memory total = sum(x ** 2 for x in range(10_000_000)) # ~0 MB extra
Set for Membership Testing
Checking item in list scans every element. Converting to a set gives O(1) lookups:
python# O(n) per lookup, O(n*m) total for m lookups blacklist = ["spam", "phishing", "malware", ...] # 100,000 entries for email in emails: if email.sender in blacklist: # Linear scan each time flag(email) # O(1) per lookup, O(m) total after O(n) set construction blacklist_set = set(blacklist) for email in emails: if email.sender in blacklist_set: # Hash lookup flag(email)
deque for Queue Operations
list.pop(0) shifts every element, making it O(n). collections.deque uses a doubly-linked block structure:
pythonfrom collections import deque # O(n) per pop from front — shifts all elements queue = [1, 2, 3, 4, 5] queue.pop(0) # Slow for large lists # O(1) per pop from either end queue = deque([1, 2, 3, 4, 5]) queue.popleft() # Fast regardless of size
Common Pitfalls
- Using
+=in a tight loop for string building: This is the single most common Python performance mistake. Always usejoin()orStringIOfor building strings from many pieces. - Converting to a set for a single membership check: If you only check membership once, the O(n) cost of building the set exceeds the O(n) cost of a single linear scan. Sets pay off when you perform multiple lookups.
- Using a list comprehension inside
sum(),min(),max(): These functions accept generators directly. Wrapping in[...]wastes memory for no benefit.
Best Practices
- Default to
str.join()for combining multiple strings. Reserve+=for 2-3 concatenations where readability wins. - Use generator expressions instead of list comprehensions when the result is consumed once (passed to
sum(),any(),all(), etc.). - Convert lookup collections to
setorfrozensetupfront when performing repeated membership tests.
Summary
str.join()builds strings in O(n) time; repeated+=is O(n^2) because each concatenation copies all previous characters.- Generator expressions produce values lazily, avoiding the memory cost of materializing full lists.
setprovides O(1) average membership testing versus O(n) for lists — convert once, query many times.collections.dequegives O(1) popleft/appendleft, making it the correct choice for FIFO queues.
Code Examples
import timeit
words = ["hello"] * 50_000
def concat_plus():
result = ""
for w in words:
result += w
return result
def concat_join():
return "".join(words)
def concat_stringio():
from io import StringIO
buf = StringIO()
for w in words:
buf.write(w)
return buf.getvalue()
print(f"+= : {timeit.timeit(concat_plus, number=10):.4f}s")
print(f"join() : {timeit.timeit(concat_join, number=10):.4f}s")
print(f"StringIO : {timeit.timeit(concat_stringio, number=10):.4f}s")import timeit
from collections import deque
def list_queue(n=10_000):
q = list(range(n))
while q:
q.pop(0) # O(n) shift each time
def deque_queue(n=10_000):
q = deque(range(n))
while q:
q.popleft() # O(1) each time
print(f"list.pop(0) : {timeit.timeit(list_queue, number=10):.4f}s")
print(f"deque.popleft(): {timeit.timeit(deque_queue, number=10):.4f}s")