Why Learn Data Structures & Algorithms When AI Can Write Code
In an age where AI drafts code for us in seconds, first-year CS students often ask: “Do I still need to learn Data Structures & Algorithms (DSA)?” The short answer is yes — but the reason has changed. Where we once learned DSA to write code, today we learn it to judge whether the code we receive is actually good. As AI gets better and faster at writing code every year, the human job keeps sliding up a level, from “typing” to “reviewing and deciding.”
Core idea
Section titled “Core idea”AI has made the cost of typing code very low. Mechanical skills — memorizing syntax, churning out loops — are worth less in the market now. But the one thing AI cannot do for you is decide whether the code it produced is correct, efficient, and trustworthy.
AI makes writing code cheaper, but makes deep understanding more valuable.
Your role is shifting from writer to judge. Anyone who lacks a deep grasp of DSA becomes a mere recipient — copy-pasting the AI’s answer without knowing whether it will collapse as the data grows, or whether a bug is hiding inside.
The goal of this course is to build intuition before memorization — so you can feel which approach will survive at scale, rather than just reciting a formula for an exam.
The three skills AI can’t replace
Section titled “The three skills AI can’t replace”1. Complexity Intuition
Section titled “1. Complexity Intuition”Picture a simple comparison: finding a friend in a classroom of 30 people takes a few seconds, but finding one specific person in a city of 10 million by walking up and asking each one individually could take a lifetime and never finish. The difference isn’t “effort” — it’s the growth rate of the work as the problem size grows. That’s exactly what Big-O tries to make visible.
The table below shows the approximate number of operations for each complexity class as n grows:
| n | O(1) | O(log n) | O(n) | O(n log n) | O(n²) | O(2ⁿ) |
|---|---|---|---|---|---|---|
| 10 | 1 | ~3 | 10 | ~33 | 100 | 1,024 |
| 100 | 1 | ~7 | 100 | ~664 | 10,000 | ~10³⁰ |
| 10,000 | 1 | ~13 | 10,000 | ~133,000 | 100,000,000 | truly infeasible |
| 10,000,000 | 1 | ~23 | 10,000,000 | ~233,000,000 | ~10¹⁴ | truly infeasible |
Look at the last row: at n = 10 million, an O(n²) algorithm needs roughly 10¹⁴ operations — even a computer doing a billion operations per second would take days. And O(2ⁿ) at just n = 100 already produces a number larger than the atoms in the observable universe.
Try toggling the curves above to see how quickly each one pulls away from the others as n increases — this is exactly the kind of thing you need to be able to feel, without recalculating it every time.
Example: if someone asks “are there any duplicates in this list of 10 million names?”, both of these return the correct answer:
# Approach A: compare every pair — O(n^2)def has_duplicate_a(items): for i in range(len(items)): for j in range(i + 1, len(items)): if items[i] == items[j]: return True return False
# Approach B: use a set — O(n)def has_duplicate_b(items): seen = set() for x in items: if x in seen: return True seen.add(x) return FalseWith 10 million items, Approach A does roughly 10^14 operations — it would run for days. Approach B does roughly 10^7 — done in a second. Someone with complexity intuition sees instantly that Approach A is unusable, without even running it.
Let’s trace Approach B’s variable state step by step with items = [3, 7, 2, 7, 5] to see exactly how it works:
| Step | x | seen (before check) | x in seen? |
seen (after adding) |
|---|---|---|---|---|
| 1 | 3 | {} |
False | {3} |
| 2 | 7 | {3} |
False | {3, 7} |
| 3 | 2 | {3, 7} |
False | {3, 7, 2} |
| 4 | 7 | {3, 7, 2} |
True → returns True immediately |
— |
Notice the function stops the instant it finds a duplicate (step 4) without scanning the remaining elements — which is why, in practice, Approach B is often even faster than its O(n) bound suggests when a duplicate turns up early.
2. Problem Modeling
Section titled “2. Problem Modeling”The ability to choose the data structure and abstraction that fit the problem. This is where AI still stumbles often, because it doesn’t understand the real context of your system — it will make the immediate request pass, without knowing how many times per second this function will be called, or how much the data will grow in a year.
Example: a system must answer “has this user already liked this post?” millions of times per second.
- Model it as a
listof likes, and each lookup isO(n). - Model it as a
setordict(hash table), and each lookup isO(1)on average.
Choosing the right data structure up front matters more than writing pretty code — it sets the performance ceiling for the entire system. Let’s compare the cost profile of three basic data structures:
| Data structure | Lookup | Insert | Best for |
|---|---|---|---|
list (unsorted) |
O(n) | O(1) at the tail / O(n) at the head | Small data, infrequent lookups |
set / dict (hash table) |
O(1) average | O(1) average | Frequent membership checks or lookups |
| linked list | O(n) | O(1) if you already hold the position (e.g. the head) | Frequent insert/delete in the middle, no need for random access |
Try adding entries to the hash table above and watch what happens when two keys land in the same bucket (a collision) — that’s exactly why lookup is O(1) “on average,” not “always.”
It’s not just lookup whose cost depends on the data structure — insertion does too. Look at the linked list below: inserting at the head (prepend) is always O(1) because nothing needs to shift, but appending at the tail without a tail pointer means walking all the way to the end first, making it O(n).
This is exactly why Python’s collections.deque (designed so both ends are O(1)) is a better fit for a job queue than a plain list — we’ll return to this question in Exercise 3.
3. Verification & Debugging
Section titled “3. Verification & Debugging”The ability to read code critically, test it, and find the edge cases AI tends to overlook.
Example: AI hands you this function to compute a median.
def median(nums): nums.sort() mid = len(nums) // 2 return nums[mid]Don’t take it at face value just because it’s short and looks reasonable. Trace through several cases:
| Input | Correct median | Function’s result | Correct? |
|---|---|---|---|
[3, 1, 2] |
2 | 2 | Yes |
[4, 1, 3, 2] |
2.5 (average of 2 and 3) | 3 | No — doesn’t handle an even count |
[] |
should raise an error or return a clearly defined value | IndexError |
No — doesn’t handle an empty list |
Code that “runs fine” on a simple example (the first row) does not mean it is correct. A good judge finds the cases that break it — before they break a real system. The fixed version looks like this:
def median(nums): if not nums: raise ValueError("median of empty list") s = sorted(nums) n = len(s) mid = n // 2 if n % 2 == 1: return s[mid] return (s[mid - 1] + s[mid]) / 2The AI Code Critique loop
Section titled “The AI Code Critique loop”This is the core activity we repeat throughout the course. The loop has four steps.
Prompt → Read → Judge → Improve
- Prompt — ask the AI to solve a clearly stated problem, e.g. “write a function that finds the most frequent number in a list.”
- Read — understand every line, not just confirm that it runs.
- Judge — ask: what’s the complexity? Are there edge cases that break it? Is the data structure the right one?
- Improve — tell the AI to fix what you found, or rewrite it yourself, then loop back and re-check.
A worked walk-through: you prompt “find the most frequent number.” The AI replies:
def most_frequent(nums): return max(nums, key=nums.count)Read: short and elegant. It runs on [1, 2, 2, 3] and returns 2.
Judge: but nums.count(x) is O(n), and it’s called for every element, making the whole thing O(n^2) — far too slow on a large list.
Improve: use a frequency table instead.
from collections import Counter
def most_frequent(nums): return Counter(nums).most_common(1)[0][0]This version is O(n). You just did the job of a judge.
Real-world problem
Section titled “Real-world problem”Suppose you’re building a “look up a product by ID” feature for an e-commerce system with 5 million products. You ask the AI twice and get two answers that both pass the same small test.
# Answer 1: linear searchdef find_product_v1(products, target_id): for p in products: if p["id"] == target_id: return p return None
# Answer 2: build an index once, then look up with a dictclass ProductCatalog: def __init__(self, products): self.index = {p["id"]: p for p in products}
def find(self, target_id): return self.index.get(target_id)Both are correct when tested against 10 products. But watch how this gap widens as the data grows (the numbers below are illustrative, not a real benchmark):
| Number of products | Answer 1 — O(n) per lookup | Answer 2 — O(1) per lookup (after building the index) |
|---|---|---|
| 10 | Very fast (no perceptible difference) | Very fast |
| 100,000 | Starts to lag under frequent lookups | Just as fast |
| 5,000,000 | Each lookup is thousands of times slower, and multiplied by thousands of requests per second, the system grinds to a halt | Still just as fast, because the index cost is paid only once |
- Answer 1 searches in
O(n)each time — with 5 million products and frequent lookups, the system grinds to a halt. - Answer 2 pays an
O(n)cost once to build the index, then each lookup isO(1)— smooth even as the data grows.
A good judge asks the decisive questions: “How many times is this function called, and how big is the data?” That single question separates code that “runs” from code that “works in production.”
Another true story (a composite of problems commonly seen in notification systems): a team built an “unread message count” feature by counting rows in a messages table where is_read = false, recomputed every single time the app loaded. This worked fine in testing with a handful of users. But with millions of real users, each with tens of thousands of accumulated messages, that count became a repeated full-table scan happening thousands of times per second. The database couldn’t keep up, and the whole system slowed down.
The fix was to switch from “recompute every time” (O(n) per request) to “maintain a counter that’s updated incrementally” — adding an unread_count column that’s incremented when a new message arrives and decremented when the user reads one. That makes reading the value O(1) always, no matter how many tens of thousands of messages a user has piled up. This is re-modeling the problem: from “count every time” to “track the change.”
Exercises
Section titled “Exercises”Exercise 1. The two functions below check whether a list contains duplicates. Which one scales better, and why?
def dup_a(xs): return len(xs) != len(set(xs))
def dup_b(xs): for i in range(len(xs)): for j in range(i + 1, len(xs)): if xs[i] == xs[j]: return True return FalseAnswer
dup_a is better — it builds a set once in O(n) and compares lengths. dup_b is O(n^2) because it compares every pair. For large n, dup_b is dramatically slower. (Caveat: dup_a requires the elements to be hashable.)
Exercise 2. The AI wrote this function to concatenate strings in a loop. What hidden performance problem does it have?
def join_words(words): result = "" for w in words: result += w + " " return resultAnswer
In many languages (and as a general caution), += on strings creates a new string each time, which can make this O(n^2) in the worst case. The better choice is " ".join(words), which is O(n) and easier to read. The lesson: watch for hidden costs in operations that look “harmless.”
Exercise 3. A system must process jobs first-in-first-out (FIFO) and add/remove jobs millions of times. Which should you choose: a list using pop(0), or collections.deque?
Answer
Use deque, because popleft() is O(1) while list.pop(0) is O(n) — it has to shift every element. This is an example of problem modeling: matching the structure to the usage pattern (go back and look at the linked-list widget in section 2 to see why).
Exercise 4. This function finds the maximum in a list, but has an edge-case bug. Find it.
def my_max(xs): m = 0 for x in xs: if x > m: m = x return mAnswer
The bug is the initial value m = 0. If every value in the list is negative, e.g. [-3, -1, -7], the function wrongly returns 0. Initialize with m = xs[0] instead (and handle the empty-list case separately). This is the verification & edge-case skill.
Exercise 5. This Fibonacci function always returns the correct answer, but the AI that wrote it never warned you how slow it gets as n grows. Identify its approximate complexity, and explain why.
def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2)Answer
The complexity is O(2^n) — each call branches into two child calls (fib(n-1) and fib(n-2)), and many of those child calls recompute the exact same values (e.g. fib(n-2) gets computed again both from the fib(n-1) branch and directly). Go back to the growth-rate table in the Complexity Intuition section — at just n ~40, this function is already noticeably slow. The fix is memoization (caching results you’ve already computed), which reduces the complexity to O(n).
Exercise 6. A spam-number blocklist service must check whether a given phone number is in a list of 20 million blocked numbers, thousands of times per second. Which data structure should you use to store those numbers, and why?
Answer
Use a set (hash table), because membership checking (number in blocked_set) is O(1) on average, regardless of whether the list has 20 million numbers or far more. If stored as a plain list, each check would be O(n), and multiplied by thousands of checks per second, the system couldn’t keep up. (Go back and play with the algo-hash widget in section 2 to see why a hash table finds a slot so quickly.)
Exercise 7. This function checks whether every item in items_to_check exists in big_list. If big_list has millions of elements and items_to_check has thousands, what’s the complexity, and how would you improve it?
def contains_all(big_list, items_to_check): return all(item in big_list for item in items_to_check)Answer
item in big_list, when big_list is a list, is a linear search, O(n), and it’s called repeatedly, m times (once per element of items_to_check), for a total of O(n * m). With n in the millions and m in the thousands, that’s billions of operations. The fix is to convert big_list into a set once (O(n)), then check membership one item at a time (O(1) each), for a total of only O(n + m).
def contains_all(big_list, items_to_check): big_set = set(big_list) return all(item in big_set for item in items_to_check)Exercise 8. Predict the output of the last two print statements below, and explain why it’s not ['apple'] and ['banana'] as most people expect.
def add_item(item, basket=[]): basket.append(item) return basket
print(add_item("apple"))print(add_item("banana"))Answer
The output is ['apple'] followed by ['apple', 'banana'] — not a standalone ['banana']. That’s because the mutable default value (basket=[]) is created only once, at function-definition time, and is then shared across every call that doesn’t supply its own basket. The fix is to default to None and create a fresh list inside the function:
def add_item(item, basket=None): if basket is None: basket = [] basket.append(item) return basketThis is a classic trap that AI often doesn’t flag, because the original code “runs with no errors.” Catching it takes actual testing and a real understanding of the language’s semantics.
AI Code Critique
Section titled “AI Code Critique”Try the full loop yourself.
The prompt you send the AI: “Write a Python function that takes a list of emails and returns the unique ones, preserving the original order.”
The code the AI returns:
def unique_emails(emails): result = [] for e in emails: if e not in result: result.append(e) return resultIt runs and preserves order — but as the judge, analyze what’s wrong with it, then improve it.
Answer
Judge: e not in result searches the list in O(n), and it’s called on every iteration, making the whole thing O(n^2). With millions of emails it becomes very slow.
Improve: use a set to remember what you’ve seen so the check is O(1) on average, while still preserving order with the result list:
def unique_emails(emails): seen = set() result = [] for e in emails: if e not in seen: seen.add(e) result.append(e) return resultThis version is O(n) — just as correct, but it actually scales.
🎮 Game Dev: Collision Detection at Bullet-Hell Scale
Section titled “🎮 Game Dev: Collision Detection at Bullet-Hell Scale”Bullet-hell and arena shooter games often have hundreds or thousands of enemies and bullets moving on screen at once. Every single frame, the game has to answer the same question over and over: “which pairs of objects are colliding?” — exactly the same shape of problem as “find duplicates in a list” from the Complexity Intuition section above. The “check every pair” collision code an AI often hands you runs perfectly fine when tested with 20-30 objects, but once the real game has thousands of bullets on screen at once, the frame rate collapses and the game becomes unplayable. This is exactly where a judge has to catch the problem before the code ships to production.
from collections import defaultdict
# Naive approach: compare every pair of objects — O(n^2)def find_collisions_naive(entities): """entities: a list of (id, x, y, radius)""" hits = [] for i in range(len(entities)): for j in range(i + 1, len(entities)): id_a, xa, ya, ra = entities[i] id_b, xb, yb, rb = entities[j] dist_sq = (xa - xb) ** 2 + (ya - yb) ** 2 if dist_sq <= (ra + rb) ** 2: hits.append((id_a, id_b)) return hits
CELL_SIZE = 32 # grid cell size — should be close to the size of a typical object / interaction radius
def cell_of(x, y): return (int(x // CELL_SIZE), int(y // CELL_SIZE))
# Scalable approach: bucket objects into grid cells (spatial hash), compare only neighbors — approaches O(n)def find_collisions_grid(entities): grid = defaultdict(list) for e in entities: _, x, y, _ = e grid[cell_of(x, y)].append(e)
hits = [] checked = set() for id_a, xa, ya, ra in entities: cx, cy = cell_of(xa, ya) for dx in (-1, 0, 1): for dy in (-1, 0, 1): # check just 9 cells: itself + 8 neighbors for id_b, xb, yb, rb in grid[(cx + dx, cy + dy)]: pair = tuple(sorted((id_a, id_b))) if id_a == id_b or pair in checked: continue checked.add(pair) dist_sq = (xa - xb) ** 2 + (ya - yb) ** 2 if dist_sq <= (ra + rb) ** 2: hits.append(pair) return hitsfind_collisions_naive is always O(n²) — with 3,000 objects that’s about 4.5 million pairs, every single frame. At 60 fps that’s nearly 270 million comparisons per second — the game will freeze. find_collisions_grid first buckets objects into “bins” by position (the same idea as the hash table from the Problem Modeling section), then each object only compares against its neighbors in nearby cells, not everything on screen — if the average number of objects per cell stays roughly constant, the total work approaches O(n).
Figure: left — naive all-pairs collision check (O(n²)), a line for every pair of objects. Right — the same objects bucketed into a spatial grid; only neighbors within the same cell are compared (approaching O(n)).
Try adjusting the object count in the widget above and watch how much faster the “all-pairs” comparison counter climbs compared to the “grid” one as the object count grows.
Game Dev Exercise 1. An arena game has 3,000 enemies and bullets combined on screen. Using find_collisions_naive, roughly how many pairs must be compared? If you switch to find_collisions_grid with a well-chosen CELL_SIZE (a handful of objects per cell on average), roughly how far does that number drop?
Answer
find_collisions_naive compares every pair: C(3000, 2) = 3000 × 2999 / 2 ≈ 4.5 million pairs, per frame. find_collisions_grid only compares each object against neighbors in its surrounding 9 cells — if there are only a handful of objects per cell on average, the total work scales roughly linearly with n times a small constant, not n². That drops the total down to the tens or hundreds of thousands of comparisons, not millions — this is the difference between O(n²) and O(n).
Game Dev Exercise 2. In find_collisions_grid, why does the code check 9 cells (itself plus 8 neighbors) instead of just checking its own cell?
Answer
Because an object can sit near the edge of its cell, and another object it actually collides with may have landed in the next cell over. Checking only its own cell would miss collisions that happen right at a cell boundary — an edge case of dividing space into a grid. Checking the surrounding 9 cells guarantees you won’t miss a pair that’s genuinely close together but happened to land in different buckets.
Game Dev Exercise 3. What happens to the performance of find_collisions_grid if CELL_SIZE is set too small (say, 1 pixel) or too large (say, 5000 pixels)?
Answer
If cells are too small, there are a huge number of cells, objects can span several of them, and walking the 9 surrounding cells still costs a lot of dictionary-lookup overhead. If cells are too large, most objects pile up into the same bucket, and comparisons inside that one bucket degrade back to O(n²). The sweet spot is a CELL_SIZE close to the typical object size / interaction radius, so each bucket holds only a handful of objects.
Game Dev Exercise 4. A fast bullet moves 200 pixels per frame, while the player is only 20 pixels wide. If you use find_collisions_grid and rebuild the grid from current positions every frame, what problem occurs, and how would you fix it at a basic level?
Answer
This is the classic tunneling problem: the grid only checks positions at discrete instants in time. If the bullet jumps from in front of the player to behind the player within a single frame, it may never occupy the same cell/radius as the player at any sampled instant, so the collision is missed entirely. A basic fix is swept collision detection — checking the path (a line segment) from the old position to the new one, not just the endpoint — or capping the maximum per-frame speed so it never exceeds the size of the smallest object. This is the verification & edge-case skill, applied to a game system.
Challenge Problem 1. Design a collision system for an arena game with 5,000 enemies, 2,000 player bullets, and several hundred static obstacles (walls). Should you use one shared grid for every object type, or separate grids per type? Why?
Approach
Separating grids (or at least separating “which group needs to check against which”) usually pays off, because player bullets need to check against enemies and walls, but not against each other. Dumping everything into one grid means every query has to filter out pairs that don’t actually need to interact, wasting work. Static obstacles (walls) should be bucketed once at level load — no need to rebuild every frame the way you do for moving objects. Enemies and bullets, by contrast, need their grid rebuilt every frame since their positions change.
Challenge Problem 2. The game adds a single giant boss dozens of times larger than a grid cell. What problem does this cause for a fixed-size grid, and how would you fix it?
Approach
A single object far bigger than a grid cell has to be inserted into every cell it overlaps — potentially dozens of cells — making both bucketing and lookup abnormally expensive for just one object. A common fix is to pull unusually large objects out of the grid entirely and check them directly against everything else with a plain bounding-box test. Since there are typically very few such objects (often just one or two bosses), the cost of an O(n) check for a single boss is still far cheaper than letting one oversized object degrade the whole grid.
Going deeper
Section titled “Going deeper”Recommended world-class resources for further study:
- MIT 6.006 Introduction to Algorithms — open course on MIT OpenCourseWare with full video lectures, notes, and problem sets: https://ocw.mit.edu/courses/6-006-introduction-to-algorithms-spring-2020/
- Harvard CS50 — the famous introduction to computer science, building a foundation in computational thinking: https://cs50.harvard.edu/x/
- Stanford CS161 Design and Analysis of Algorithms — a deeper dive into designing and analyzing algorithms: https://stanford-cs161.github.io/winter2024/
- CLRS — Introduction to Algorithms (Cormen, Leiserson, Rivest, Stein) — the field’s standard reference text, ideal to keep at your side throughout your DSA studies.
- Skiena — The Algorithm Design Manual (3rd ed.) — written in a “War Story” style, recounting real cases where a team picked the wrong algorithm or data structure and paid for it. This is the closest match to the intuition this lesson is trying to build.
- Bhargava — Grokking Algorithms (2nd ed.) — explains concepts through illustrations and plain language, an ideal starting point before tackling a formal text like CLRS.
- Sedgewick & Wayne — Algorithms (4th ed.) — covers implementing data structures like hash tables and linked lists — the ones used in this lesson — in detail, with real code.

