01Name three edge cases to try on every array problem.
An empty or one-item array, all items the same, and negative values or zero.
02The largest n is 5,000. What is the slowest Big O that fits?
O(n²). That is about 2.5 × 10⁷ steps, well inside 10⁸ per second.
Python code for DSA is short. That is its trap: one short line can copy a whole list or walk it from end to end, and some lines give a wrong answer with no error at all.
This lesson lists what each tool costs, the six traps that break correct ideas, and which module to use for a queue and a heap.
Lists: cheap at the end, costly at the front
A list keeps its items in numbered slots. Changing the end touches one slot. Changing the front moves every other item.
┌──────────────────────────────────────────────┐ │ │ │ append(5): add at the end, nothing moves │ │ ┌───┬───┬───┬───┬───┐ │ │ │ 4 │ 9 │ 2 │ 7 │ 5 │ O(1) │ │ └───┴───┴───┴───┴───┘ │ │ ▲ │ │ new │ │ │ │ pop(0): take 4, the rest move left │ │ ┌───┬───┬───┬───┐ │ │ │ 9 │ 2 │ 7 │ 5 │ O(n) │ │ └───┴───┴───┴───┘ │ │ ◀ ◀ ◀ ◀ each moved one slot │ │ │ └──────────────────────────────────────────────┘
The difference is real. In a test on this laptop with Python 3.14:
- Emptying 100,000 items with
pop(0)took about half a second. - Twice the items took about four times as long: the mark of O(n²).
- A
dequeemptied the same items withpopleft()in about 2 milliseconds.
What each operation costs
| Operation | Cost | Why |
|---|---|---|
a[i], a[i] = x, len(a) | O(1) | one slot |
a.append(x), a.pop() | O(1) on average | the end only |
a.pop(0), a.insert(0, x) | O(n) | every item moves |
a[i:j] (k items), a.copy(), a + b | O(k), O(n) | makes a copy |
x in a, a.index(x), min(a), sum(a) | O(n) | scans the list |
a.sort(), sorted(a) | O(n log n) | a real sort |
dict, set: d[k], k in d, s.add(x) | O(1) on average | hashing |
deque: append, appendleft, pop, popleft | O(1) | either end |
heapq.heappush, heapq.heappop | O(log n) | a heap |
heapq.heapify(a) | O(n) | builds the heap once |
s[i], len(s) | O(1) | one character |
s[i:j], s + t, "".join(parts) | O(n) | builds a new string |
A line is not one step. if x in a inside a loop over a list is a hidden loop inside a loop: O(n²). Make a a set and it is O(1).
sort() and sorted()
words = ["pear", "fig", "apple"]
print(sorted(words, key=len)) # ['fig', 'pear', 'apple']
print(sorted(words, key=lambda w: (-len(w), w))) # ['apple', 'pear', 'fig']
nums = [9, 10, 1]
nums.sort(reverse=True)
print(nums) # [10, 9, 1]sortedreturns a new list.a.sort()sorts in place and returnsNone, soa = a.sort()leaves you withNone.keysays what to compare. A tuple key sorts by its first item, then breaks ties with the next: here, longest first, then A to Z.- Numbers sort as numbers, and the sort is stable: equal items keep their order.
dict, set and Counter
Two tools solve a large share of array problems:
setanswers "have I seen this?" in one step.dictanswers "how many?" or "where?" in one step.
The counting pattern you will write most often, by hand and with Counter:
from collections import Counter
count: dict[str, int] = {}
for ch in "banana":
count[ch] = count.get(ch, 0) + 1
print(count) # {'b': 1, 'a': 3, 'n': 2}
c = Counter("banana")
print(c.most_common(1)) # [('a', 3)]
print(c["z"]) # 0: a missing key counts as 0count.get(ch, 0)returns 0 the first time.count[ch]on a missing key raises aKeyError.Counterdoes the same count in one call.most_common(k)returns the k largest counts, largest first.
Three rules keep a dict or set correct:
- Keys must be hashable. A list cannot be a key:
{[0, 1]}raises aTypeError. For a grid cell, use the tuple(row, col). - A dict keeps insertion order. The printout above lists
bfirst because it came first. A set keeps no order. defaultdict(list)builds lists for you.graph[u].append(v)works even the first time you seeu.
Six traps
1. Slicing copies
a = [4, 9, 2, 7]
b = a[1:3] # a new list with 2 items
b[0] = 99
print(a, b) # [4, 9, 2, 7] [99, 2]
print("hello"[::-1]) # olleh- A slice of k items costs O(k) time and memory.
- The costly case is recursion:
solve(a[1:])copies the list at every call, O(n²) in total. Pass an index instead, as insolve(a, i + 1).
2. Strings cannot change
s[0] = "j" raises a TypeError. Every string method returns a new string.
To build a string piece by piece, collect the pieces in a list and join once:
s = "hello"
parts: list[str] = []
for i in range(len(s) - 1, -1, -1):
parts.append(s[i])
print("".join(parts)) # ollehThe Python docs warn that building a sequence with repeated + costs O(n²) in total. The list and join are O(n).
3. // and % round down
print(7 // 2, -7 // 2) # 3 -4: rounds down, not toward zero
print(int(-7 / 2)) # -3: rounds toward zero
print(-7 % 3) # 2: the sign follows the right side
print(6 / 2) # 3.0: / always returns a float
print(2 ** 63) # 9223372036854775808: no overflow- Java and JavaScript give -3 for -7 / 2 and -1 for -7 % 3. Python gives -4 and 2. Check negative values when you port a solution.
- Use
//for whole-number division. A float loses digits past about 9 × 10¹⁵, soint(a / b)can be wrong for large values. - A Python
intnever overflows. Large numbers are slower, though, so "modulo 10⁹ + 7" still means%after every step.
You use % 10 and // 10 in the next lesson to take a number apart, digit by digit.
4. The recursion limit
Python stops a recursion at about 1,000 calls deep.
import sys
def depth(n: int) -> int:
return 0 if n == 0 else 1 + depth(n - 1)
print(sys.getrecursionlimit()) # 1000
# depth(5000) raises RecursionError
sys.setrecursionlimit(10_000)
print(depth(5000)) # 5000- A depth-first search on a path of 10⁵ nodes goes 10⁵ calls deep and fails.
sys.setrecursionlimitraises the limit, but the docs warn that a limit set too high can crash Python. On this laptop, Python 3.9 at a depth of 10⁶ died with a segmentation fault and no Python error.- For very deep inputs, turn the recursion into a loop with your own stack.
5. Mutable default arguments
Python builds a default value once, when it defines the function. A list default is then shared by every call.
def add_item(x: int, items: list[int] = []) -> list[int]:
items.append(x)
return items
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2]: the same list as the first callThe fix: default to None, and make a new list inside the function.
def add_item(x: int, items: list[int] | None = None) -> list[int]:
if items is None:
items = []
items.append(x)
return items
print(add_item(1), add_item(2)) # [1] [2]Watch for this in def dfs(node, path=[]) and def solve(n, memo={}). Results from one test case leak into the next.
6. The shared-row grid
* 3 on a list of lists repeats the same row object. Change one row, and all rows change.
┌────────────────────────────────────────────┐ │ │ │ [[0] * 3] * 3: one shared row │ │ grid[0] ──┐ │ │ grid[1] ──┼──▶ [ 1, 0, 0 ] │ │ grid[2] ──┘ │ │ │ │ a comprehension: three rows │ │ grid[0] ─────▶ [ 1, 0, 0 ] │ │ grid[1] ─────▶ [ 0, 0, 0 ] │ │ grid[2] ─────▶ [ 0, 0, 0 ] │ │ │ └────────────────────────────────────────────┘
bad = [[0] * 3] * 3
bad[0][0] = 1
print(bad) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]The comprehension runs [0] * 3 once per row, so each row is a new list. The inner [0] * 3 is safe: a number cannot change, so sharing it does no harm.
Queues and heaps
Python has no queue or heap class you call by name. Use these:
| You need | Use | Avoid |
|---|---|---|
| stack | list: append, pop, a[-1] | nothing: a list is right |
| queue | collections.deque: append, popleft | list.pop(0) |
| min-heap | heapq functions on a plain list | sorting after each push |
| max-heap | heapq with negated values | sorting after each push |
| sorted map | none built in: bisect on a sorted list | sorting after each insert |
from collections import deque
import heapq
queue = deque([4, 9])
queue.append(2)
print(queue.popleft()) # 4: first in, first out
heap = [5, 1, 8]
heapq.heapify(heap) # reorders the list in place, O(n)
heapq.heappush(heap, 3)
print(heapq.heappop(heap)) # 1: smallest first
max_heap: list[int] = []
for x in [5, 1, 8]:
heapq.heappush(max_heap, -x)
print(-heapq.heappop(max_heap)) # 8: largest firstheapqis a min-heap. For a max-heap, push-xand negate what you pop. Python ints never overflow, so this is safe.- Push tuples to order by a key:
heapq.heappush(heap, (dist, node))pops the smallestdistfirst. - Python 3.14 adds
heappush_maxandheappop_max. Older versions do not have them, so negation is the habit that works everywhere. bisectfinds a place in a sorted list in O(log n), but inserting there is still O(n).
- 01
Work at the end of a list. pop(0) and insert(0, x) are O(n). Use collections.deque for a queue.
- 02
A slice is a copy. Pass an index into a recursion, not a slice, and build strings with "".join.
- 03
// and % round down, so negatives differ from Java and JavaScript. Recursion stops near 1,000 calls deep.
- 04
heapq is a min-heap: negate values for a max-heap. Never use a list or dict as a default argument.
Read more
- TimeComplexity on the Python wiki, the cost of every list, deque, dict and set operation
- collections in the Python docs, for deque, Counter and defaultdict
- heapq in the Python docs, with examples of a priority queue
- Default argument values in the Python tutorial, on why a default is built once
Next: working with digits. Skip the other toolbox lessons unless you code in those languages too. You will take a number apart with % and //.