Complexity & Arrays
A complete, computer-science-grade Data Structures & Algorithms track — from the ground up, in Python, grounded in the agent/RAG code this course builds. This first part gives you the one tool every later chapter depends on: how to reason about the cost of code — Big-O time and space — and the structure everything is built on, the array (Python's list).
Learning objectives
- Read and write Big-O, and know how Θ and Ω differ from it.
- Count operations in a loop and derive the complexity class.
- Reason about space as well as time.
- Understand how Python's
listis a dynamic array — and whyappendis O(1) amortized butinsert(0, x)is O(n). - Apply the two-pointer, sliding-window, and prefix-sum patterns — the array techniques that turn O(n²) into O(n).
Why a DSA track in an AI course? motivation
Because agents are made of data structures. RAG retrieval is a heap (top-k) over cosine similarities; the agent loop is a stack/queue of messages; tool dispatch is a hash map; a dependency-aware DevOps plan is a graph with a topological sort; re-ranking is sorting; chunk lookup is binary search. Knowing the structure — and its cost — is the difference between an agent that scales to a million documents and one that times out on a thousand. This track makes those choices deliberate.
Read it top-to-bottom: the one app at the top fans out into its parts, and each part rests on a named data structure below it.
- The top box is the whole thing you ship — an AI agent or RAG app. The blue arrows fan out to the features it needs.
- The middle blue row is what the app does: keep a message loop, dispatch tools, retrieve the top-k chunks, order a dependency plan.
- The bottom green row is what each feature actually is under the hood — a stack/queue, a hash map, a heap, a graph with a sort. These are exactly the structures this track builds from scratch (D1–D6).
In short: Pick any agent feature and there's a data structure under it whose Big-O decides whether the feature scales — which is why a DSA track belongs in an AI course.
| Part | Structures | Where it shows up |
|---|---|---|
| D1 | Arrays, complexity | Embedding vectors, message buffers, the cost of every loop |
| D2 | Stack, queue, deque, linked list | Agent step stack, task queue, sliding chat window |
| D3 | Hash table, set, recursion | Tool dispatch, dedup, memoized retrieval, backtracking |
| D4 | Trees, BST, heaps, tries | Top-k retrieval (heap), routing trees, prefix autocomplete |
| D5 | Graphs | DevOps dependency plans, knowledge graphs, tool call chains |
| D6 | Sorting & searching | Re-ranking results, binary search over sorted scores |
1 · Big-O, Θ and Ω — the language of cost basic
Start with a story you've probably lived. You write a function. On your laptop, with 50 test rows, it returns before your finger leaves the Enter key. You ship it. Three months later, with 200,000 real rows, the same function takes 40 seconds and users are filing tickets. The code never changed — the input did. Big-O is the tool that would have warned you on day one.
Here's the whole idea in one question: when the input doubles, what happens to the work? That's it. You're not measuring seconds (those change with your CPU, your other tabs, the weather) — you're describing the shape of the growth.
- Work stays flat when the input grows → O(1) ("constant")
- Work doubles when the input doubles → O(n) ("linear")
- Work quadruples when the input doubles → O(n²) ("quadratic")
Now the formal version, which will make sense because you have the intuition: Big-O describes how the number of operations grows as the input size n grows. We drop constants and lower-order terms because we care about the shape of the growth, not the exact count. 3n + 7 and 100n are both O(n) — double n and each roughly doubles, so they scale linearly. (Yes, 100n is slower in practice — but Big-O is about how it scales, and both scale as a straight line.)
There are only about six growth shapes you meet in practice. Read this table not as a list to memorize but as a ladder of pain — each row is dramatically worse than the one above it once n gets large. The middle column ("n=1000 does ~") is the one to feel: it's roughly how many operations you'd do on a thousand-item input.
Before the numbers, see the shapes. The picture below plots work (up) against input size (right) for the common classes — the whole point of Big-O is which curve you are riding.
Scan left-to-right along the bottom axis as the input n grows; the higher a curve climbs, the more work that class does at that size.
- The horizontal axis is input size
n; the vertical axis is the amount of work. A curve that stays low is cheap; one that races upward is expensive. - The flat green line (O(1)) and the nearly-flat blue curve (O(log n)) hardly rise at all — they are the ones you want. The straight grey line (O(n)) climbs steadily and predictably.
- The red curves (O(n²) and the dashed O(2ⁿ)) bend sharply upward and hit the ceiling while the others are still near the floor — that early, violent steepness is exactly why they "die in production".
In short: Same axes, six very different shapes — the flatter your curve, the more input you can throw at it before the work explodes.
| Class | Name | n=1000 does ~ | Example |
|---|---|---|---|
| O(1) | constant | 1 | list[i], dict[k], len(x) |
| O(log n) | logarithmic | ~10 | binary search, balanced-tree lookup |
| O(n) | linear | 1000 | a single loop, x in list |
| O(n log n) | linearithmic | ~10000 | good sorts (Timsort, merge, heap) |
| O(n²) | quadratic | 1000000 | nested loops, bubble sort |
| O(2ⁿ) | exponential | astronomically large | naive recursive subsets, brute-force |
Let's make each row concrete, because the names mean nothing until you attach them to a feeling:
- O(1) — constant. The work doesn't care how big the input is. Grabbing
list[500]takes the same time whether the list has 1,000 items or 10 million (you'll see why in §4 — it's pure address arithmetic). This is the gold standard: free at any scale. - O(log n) — logarithmic. Every step throws away half the remaining work. That's why it barely grows: going from 1,000 to 1,000,000 items only takes ~10 more steps. Think "find a name in a phone book by always opening to the middle." Anywhere you see halving, you're in log-land — and that's almost as good as constant.
- O(n) — linear. You touch each item a fixed number of times. Double the input, double the work — a straight, predictable line. Most honest "look at everything once" code lives here, and for most jobs it's perfectly fine.
- O(n log n) — linearithmic. A linear pass, but each item pays a small
log ntax. This is the speed limit for comparison sorting — no general sort can beat it — so when you callsorted(), this is what you're paying. Only a hair worse than linear; totally acceptable. - O(n²) — quadratic. Here's where it hurts. For each item you do work proportional to every other item — the classic "loop inside a loop." At
n=1,000that's a million operations; atn=10,000it's a hundred million. This is the single most common cause of "it worked in testing, it died in production." Section 2 is all about spotting it. - O(2ⁿ) — exponential. Adding one element doubles the total work. This falls off a cliff almost immediately —
n=60is already more operations than there are seconds since the Big Bang. If your solution is exponential, you don't optimize it, you redesign it (that's what dynamic programming in D10–D11 is for).
Why we're allowed to throw away the details. You'll notice we said 3n + 7 is "just" O(n) — we dropped the × 3 and the + 7. That feels like cheating, so here's the justification: as n grows huge, the fastest-growing term drowns out everything else. Compare n² against 100n: at n=10 the 100n looks bigger (1,000 vs 100), but by n=1,000 the n² has pulled far ahead (1,000,000 vs 100,000) and it never looks back. Big-O deliberately ignores the small stuff because it's answering one question only: which term wins as the input explodes? Constants matter for tuning; the growth class matters for whether your program survives at all.
One more turn of that screw, because it trips people up. Take searching for a value by scanning a list. If you're lucky, it's the very first item — one check, done. If you're unlucky, it's last (or absent) — n checks. So the same algorithm is Ω(1) in the best case and O(n) in the worst. That's not a contradiction — they're describing different scenarios. When an interviewer or a design doc asks "what's the complexity?", they almost always mean the worst case (the O), because that's the one that decides whether your system falls over under load. Best-case numbers make for nice slides and terrible capacity plans.
2 · Counting operations — deriving the class basic → intermediate
In §1 we learned the vocabulary of cost. Now the practical skill: looking at a piece of code and naming its Big-O. The good news is you don't guess and you don't need a maths degree — you count, using two rules that cover almost everything you'll write.
Rule 1 — sequential steps add, and the biggest wins. If your function does one loop over n items and then, separately, another loop over n items, that's n + n = 2n operations. Two things happen: we add them (because they run one after the other), and then we drop the constant, so 2n is just O(n). More usefully: if a function does an O(n) scan and an O(n²) nested loop, the total is n + n² → O(n²). The slowest part dominates, so find the worst thing your code does and that's usually your answer.
Rule 2 — nested loops multiply. This is the one that bites. A loop inside a loop doesn't add, it multiplies: for each of the n outer passes you pay for a full n inner passes, so n × n = O(n²). Three levels deep is O(n³). The word that decides it is "inside": loops side by side add (cheap), loops nested multiply (expensive). When you review code for performance, your eye should go straight to the indentation — a loop nested in a loop is the first thing to check.
The diagram below makes the multiply-vs-add rule visual: one loop is a row of work, a nested loop is the whole grid.
This picture is the whole idea of Big-O in one image: how much work grows as the input n grows. The left side is one loop; the right side is a loop inside a loop.
- The left row of blue boxes is a single loop touching each of the
nitems once — a straight line of work, labelledn steps. That is O(n): double the input, double the work. - The right grid of amber cells is a loop nested inside another loop. For each of the
nouter steps you doninner steps, so you touch every pair — the wholen × nsquare. That is O(n²). - Read the size of the shaded area as the cost: a thin row (n) versus a full grid (n²). The caption's rule — nesting multiplies — is why one extra loop is so expensive.
In short: One loop = a line of work (O(n)); a loop-in-a-loop = a filled square (O(n²)). At n=1000 that's 1,000 vs 1,000,000 operations — the gap only widens as n grows.
python# O(n): one loop over n items
def total(nums):
s = 0 # O(1)
for x in nums: # runs n times ...
s += x # ... each O(1) -> n * O(1) = O(n)
return s # O(1) total: O(n)
# O(n^2): a loop inside a loop
def has_duplicate(nums):
for i in range(len(nums)): # n times
for j in range(i+1, len(nums)): # ~n times -> n*n = O(n^2)
if nums[i] == nums[j]:
return True
return False
# O(n): same job, using a set (we'll build sets from scratch in D3)
def has_duplicate_fast(nums):
seen = set()
for x in nums: # n times
if x in seen: # set membership is O(1) avg
return True
seen.add(x)
return False
These three functions show how you count operations to find the complexity class, and how picking a better data structure changes it. All three solve small, real problems; read the # comments on the right — they tally the cost line by line.
total(nums)is O(n). Oneforloop runs once per item (ntimes), and each pass does a constant-cost add (s += x).npasses × O(1) each = O(n). The setup and thereturnare one-time O(1) steps you drop.has_duplicate(nums)is O(n²). The outer loop runsntimes; for eachithe inner loop compares against every later item. That's every pair — aboutn × ncomparisons — the nested-loop grid from the diagram above.has_duplicate_fast(nums)is O(n). Same job, but it remembers what it hasseenin aset. Checkingif x in seenon a set is O(1) on average (you'll build sets from scratch in D3), so one pass overnitems stays linear.
What the output means: total returns the sum; both duplicate-checkers return True/False. The point isn't the answer — it's that the fast version does the same job with far fewer operations (~n instead of ~n²).
Try this: On a list of 10,000 items the nested version does ~50 million comparisons and the set version ~10,000 — the same answer, ~5000× less work. Swapping a list scan for a set is the single most common speed-up in this whole track.
The two duplicate-finders return the same answer, but on 10,000 items the first does ~50 million comparisons and the second does ~10,000. Same output, different data structure, 5000× fewer operations. That is the entire point of this track.
3 · Space complexity intermediate
So far we've counted time — operations. But there's a second bill, and beginners almost always forget it until it crashes something: memory. A function can be beautifully fast and still blow up your process because it quietly built a copy of a huge input. Space complexity is the same Big-O idea pointed at a different resource: how much extra memory does this code allocate as the input n grows?
The word "extra" is the key, and it catches people out. We do not count the input itself — you were handed that. We count only what the algorithm allocates on top of it. Two examples to anchor it:
- A loop that scans a million-item list keeping a single running
maxuses O(1) extra space — one variable, no matter the input size. The million items were already there; you added nothing. - A list comprehension that builds a new million-item list uses O(n) extra space — your memory footprint grows one-for-one with the input.
Why care, concretely? An agent holding a long conversation, or a RAG system loading an embedding matrix, is often memory-bound before it's time-bound — it runs out of RAM long before it runs out of patience. Knowing whether your approach is O(1) or O(n) in space is what decides whether a job fits on the machine you have. The diagram contrasts the two directly.
This diagram is about memory instead of time: how much extra space an algorithm needs as the input n grows. Same Big-O idea, different resource.
- The left green box is
max_val: it keeps just a couple of variables no matter how big the list is, so its extra memory is O(1) — flat, constant, 'same memory for any n'. - The right row of amber boxes is
doubled: it builds a brand-new list the same length as the input, so its extra memory grows one-for-one withn— O(n). - The small print 'a generator would be O(1)' is the escape hatch: a generator hands back items one at a time instead of storing them all, keeping extra space constant.
In short: A fixed handful of variables = O(1) space; a new collection as big as the input = O(n) space. For agents holding long chats or big embedding matrices, that difference decides whether it fits in memory.
python# O(1) extra space: reuses a few variables regardless of input size
def max_val(nums):
best = nums[0]
for x in nums:
if x > best: best = x
return best # space: O(1)
# O(n) extra space: builds a new list as big as the input
def doubled(nums):
return [x*2 for x in nums] # space: O(n)
# The generator version is O(1) space — it yields, storing nothing (see P6)
def doubled_lazy(nums):
for x in nums:
yield x*2 # space: O(1) — one item at a time
These three functions do almost the same thing (look at the numbers), but they cost different amounts of memory. The comments call out the space complexity of each.
max_val— O(1) space. It scans the list keeping a single runningbestvalue. No matter how longnumsis, it holds just that one extra variable, so its extra memory is constant.doubled— O(n) space. The list comprehension[x*2 for x in nums]creates a whole new list the same size as the input. If the input has a million items, so does the output — extra memory grows withn.doubled_lazy— O(1) space. Usingyieldmakes a generator: it produces one doubled value each time it's asked and stores nothing in between (you'll meet generators again in P6). Same results, but memory stays flat.
What the output means: max_val returns the biggest number; doubled returns a new list; doubled_lazy returns a generator you loop over. The lesson is the memory each one uses, not the values.
Try this: Run list(doubled_lazy([1,2,3])) — you get [2,4,6], same as doubled, but the generator never held all three at once. That's how you stream a huge file without loading it into RAM.
4 · Arrays and Python's list basic → intermediate
We've talked about cost in the abstract. Now we meet the structure that everything in this track is built on — the array — and you'll finally see why some operations are free and others are secretly expensive. Every complexity claim from here on comes back to how arrays physically sit in memory.
Picture computer memory as one enormous street of numbered houses. An array is a run of houses right next to each other, all the same size. That "same size, side by side" detail is the whole magic: to find element i, the computer doesn't walk down the street counting — it does one sum, base + i × slotsize, and jumps straight to the door. One multiply, one add, done. That is why list[i] is O(1) — instant whether the list holds ten items or ten million.
Python's list is a special kind of array called a dynamic array: an array that quietly grows itself when it runs out of room (we unpack exactly how in §5). That flexibility is why Python lists feel effortless — but it also creates a trap. The same contiguous layout that makes indexing free makes inserting at the front costly: every element behind the new one has to physically shuffle down a slot. Keep that tension in mind as you read the operation table below — fast at the end, slow at the front is the theme.
i's address is pure arithmetic — no scanning.
This shows why reading lst[i] is instant — the O(1) that makes arrays the foundation of everything else.
- The row of boxes is the array in memory: the slots sit side by side ('contiguous') and are all the same size. The values
10, 20, 30…are what's stored; thei=0, 1, 2…below are the positions (indices). - The bottom labels
baseandbase+2·ware memory addresses:baseis where the array starts, andwis the width of one slot. - The blue formula
lst[2] → base + 2×wis the key: to find any element the computer does one multiplication and one addition — it never walks through the earlier items. That single arithmetic step is what O(1) means here.
In short: Because every slot is the same size, the address of item i is pure arithmetic — no scanning. That's why lst[i] costs the same whether the list has 10 items or 10 million.
| Operation | Complexity | Why |
|---|---|---|
lst[i] | O(1) | direct address arithmetic |
lst[i] = x | O(1) | write to a known slot |
lst.append(x) | O(1) amortized | usually a free slot; occasionally regrow (§5) |
lst.pop() (end) | O(1) | shrink from the tail |
lst.pop(0) / insert(0,x) | O(n) | every later element must shift |
x in lst | O(n) | linear scan — no index |
lst[a:b] (slice) | O(b-a) | copies the sub-range |
len(lst) | O(1) | the length is stored, not counted |
lst.pop(0) and lst.insert(0, x) are O(n) because every remaining element slides down one slot. Doing that in a loop is a hidden O(n²). If you need fast operations at both ends, use a collections.deque — that's exactly what D2 covers.5 · Why append is O(1) "amortized" intermediate → advanced
Here's a puzzle that exposes a beautiful idea. An array is a fixed block of memory — so how can Python's list keep accepting append after append forever? And why do we call append "O(1) amortized" instead of just "O(1)"? That weasel-word "amortized" is worth understanding, because the trick behind it shows up everywhere from hash tables to database storage.
The naïve approach, and why it's a disaster. Imagine the array grew by exactly one slot each time it filled up. To add a slot you must allocate a new, slightly bigger block and copy every existing element across. Do that on every append and building an n-item list costs 1 + 2 + 3 + … + n copies — that's O(n²). Building a modest list would grind to a halt.
The fix: grow in big jumps, not tiny steps. When the array fills, Python doesn't grab one more slot — it grabs a much bigger block (roughly doubling for small lists, ~1.125× for large ones) and copies once. Now most appends land in a slot that's already sitting empty and waiting — those are true O(1). Only the occasional "we're full, time to move house" append pays the O(n) copy cost. And because each resize doubles the headroom, those expensive moves get rarer and rarer as the list grows.
That's what "amortized" means: average the rare expensive operations over the many cheap ones, and the cost per append works out to a constant. It's not that any single append is guaranteed fast — it's that across a whole sequence of appends, the total work is only about n, so the average is O(1). The diagram traces exactly this doubling; run the lab under it to watch the capacity jump with your own eyes.
This explains the word 'amortized': why append is called O(1) even though it occasionally has to do expensive work. Read it top-to-bottom as the list grows.
- Each row is the list's capacity (how many slots it has reserved) at a moment in time: first
cap 2, thencap 4, thencap 8. Capacity is usually bigger than the number of items actually stored. - When the slots fill up, Python doesn't add one slot — it allocates a bigger block (roughly double) and copies the old items over once. Those copy steps are the O(n) moments noted on the right ('copy 2 items', 'copy 4 items').
- The extra blue free slots in the
cap 8row mean the next few appends just drop into a waiting slot — each of those is O(1). - The green bottom line is the punchline: a few rare O(n) copies spread over many cheap O(1) appends average out to O(1) amortized per append.
In short: 'Amortized O(1)' = averaged over many appends, each one is effectively constant, because the expensive resize happens rarely and the doubling means the total copy-work is only about n across the whole build.
pythonimport sys
lst = []
prev = -1
for i in range(17):
cap = sys.getsizeof(lst) # bytes the list object occupies
if cap != prev:
print(f"len={len(lst):2d} bytes={cap} <-- grew")
prev = cap
lst.append(i)
# You'll see the byte-size jump only at a few sizes (0, 4, 8, 16, ...),
# not on every append — that's the over-allocation making append O(1) amortized.
This tiny program lets you see the over-allocation happen. It appends items one at a time and prints the list's size in bytes only when that size actually changes.
sys.getsizeof(lst)asks Python how many bytes the list object is currently using. As you append, that number jumps up in steps, not smoothly.- The
if cap != prev:check prints a line only when the byte-size changed — i.e. only when Python grabbed a bigger block.prevremembers the last size so unchanged appends stay silent. - Because Python reserves spare capacity, most of the 17 appends reuse an existing slot and print nothing; only a handful trigger a growth line.
What the output means: A few lines like len=0 … grew, len=4 … grew, len=8 … grew — the size jumps at a few sizes (0, 4, 8, 16…), not on every append. Those gaps are the free slots that make append O(1) amortized.
Try this: Change range(17) to range(100) and watch how the jumps get further and further apart — each resize buys more headroom, so resizes get rarer as the list grows.
n old items, the jar already holds enough tokens to pay for all the copying. Since every append pays a constant 3 tokens, the amortized cost is O(1) — even though individual appends occasionally do O(n) work.6 · Two pointers — O(n²) → O(n) intermediate → advanced
The last three sections (§6–§8) are your first real algorithmic techniques — named tricks that take a problem you'd naïvely solve with a slow nested loop and collapse it to a single fast pass. If §1–§5 were about measuring cost, these are about beating it. The first one is the two-pointer technique, and it's the one interviewers reach for most.
The setup. Suppose you're asked: "do two numbers in this array add up to a target?" The obvious answer is to try every pair — a loop inside a loop, O(n²). On a sorted array, there's a far smarter move. Put one finger on the smallest value (left end) and one on the largest (right end), and look at their sum:
- Sum is too small? The only way to grow it is to give up the smallest number — move the left finger right, to a bigger value.
- Sum is too big? The only way to shrink it is to give up the largest number — move the right finger left, to a smaller value.
- Sum is exactly the target? Found it.
Why this is correct, not just clever. Each comparison lets you permanently discard one number — and because the array is sorted, you can prove you never skip a valid pair (the reasoning is spelled out right after the lab). Each finger only ever moves inward, so together they take at most n steps: one linear pass, O(n) time and O(1) space, replacing the quadratic double loop. The catch — and it's essential — is that the array must be sorted first; the whole trick rides on "too small → go right, too big → go left" being reliable.
python# Brute force: check every pair -> O(n^2)
def two_sum_slow(nums, target):
for i in range(len(nums)):
for j in range(i+1, len(nums)):
if nums[i] + nums[j] == target:
return (i, j)
return None
# Two pointers on a SORTED array -> O(n) time, O(1) space
def two_sum_fast(nums, target): # nums must be sorted ascending
lo, hi = 0, len(nums) - 1
while lo < hi:
s = nums[lo] + nums[hi]
if s == target:
return (lo, hi)
elif s < target:
lo += 1 # need a bigger sum -> move left pointer up
else:
hi -= 1 # need a smaller sum -> move right pointer down
return None
print(two_sum_fast([1, 3, 4, 7, 11], 10)) # (1, 3) -> 3 + 7
The two-pointer technique turns an O(n²) brute force into an O(n) single pass on a sorted array. Both functions find two numbers that add up to target.
two_sum_slowis O(n²). Nested loops try every possible pair (iwith every laterj) until one sums totarget— the quadratic grid again.two_sum_fastis O(n). It puts one pointer at the start (lo) and one at the end (hi) of the sorted array and walks them toward each other.- The
while lo < hiloop compares the current sum to the target. If the sum is too small it movesloup (to a bigger value); if too big it moveshidown (to a smaller value). Because the array is sorted, each move is the only way to fix the sum, so no valid pair is ever skipped. - Each pointer moves at most
ntimes before they meet, so the whole scan is one linear pass — O(n) time and O(1) space.
What the output means: two_sum_fast([1,3,4,7,11], 10) prints (1, 3) — the values at indices 1 and 3 are 3 and 7, and 3+7=10.
Try this: This only works because the array is sorted. Try an unsorted array and you'll get wrong answers — the whole trick relies on 'too small → move left up, too big → move right down'.
This traces the two-pointer scan from the code above, step by step, on the array [1, 3, 4, 7, 11] with target = 10.
- The row of boxes is the sorted array. The blue
lo ▲marker starts under the leftmost value and the red▲ himarker starts under the rightmost. - Each line below is one step. It adds the two pointed-at values and compares to 10:
1+11=12 > 10is too big, sohimoves left (hi--). - Next
1+7=8 < 10is too small, solomoves right (lo++). The pointers keep converging — never crossing without checking. - The green line
3+7=10 ✓is the hit: the pointers landed on a pair that sums to the target, giving indices(1, 3).
In short: Follow the arrows: each comparison forces exactly one pointer to move inward, so the two pointers together take at most n steps — one O(n) pass instead of the O(n²) double loop.
Why it's correct: at each step, if the sum is too small the only way to increase it is to raise the low value; if too big, lower the high value. No pair is ever wrongly skipped, and each pointer moves at most n times — so the whole scan is O(n).
7 · Sliding window advanced
The second technique attacks a whole family of questions: "what's the best/longest/largest run of consecutive items?" — the maximum sum of any 5 in a row, the longest stretch with no repeats, the shortest span that hits a threshold. The naïve approach recomputes each window from scratch, which is O(n·k) and does a mountain of duplicate work.
The insight is embarrassingly simple once you see it. When you slide a fixed-size window one step to the right, almost everything inside it is identical to a moment ago — only two things changed: one new item entered on the right, and one old item fell off the left. So don't rebuild the total. Add the entering item, subtract the leaving one, and you've updated the window in two operations flat — O(1) per slide — no matter how wide the window is.
Slide across the whole array that way and the entire scan is O(n) instead of O(n·k). The mental image is a physical window frame gliding along the array: it never lifts off and re-counts everything, it just gains one pane and loses one pane per step. That "reuse what you already computed" instinct is the heart of the technique — and, later, of dynamic programming. The diagram animates one slide; the lab computes a real max-window-sum.
This shows the sliding window idea: instead of re-adding a group of items every time, you slide a fixed-size box across the array and only adjust for what enters and leaves.
- The row of boxes is the array
[2, 1, 5, 1, 3, 2]. The blue outlined rectangle is the window — here it covers the firstk = 3items, whosesum = 8. - To move the window one step right you don't re-add all three items. You add the new item entering on the right and subtract the item that just left on the left — shown as
+nums[i] − nums[i−k]. - That adjustment is two arithmetic operations — O(1) per slide — no matter how big
kis. Sliding across the whole array is therefore O(n), versus O(n·k) if you rebuilt each window from scratch.
In short: The window slides, it doesn't rebuild: one add + one subtract per step. Recomputing each window fresh would redo work you already did — the running total avoids that.
pythondef max_window_sum(nums, k):
if k > len(nums):
raise ValueError("window bigger than array")
window = sum(nums[:k]) # first window: O(k) once
best = window
for i in range(k, len(nums)): # slide across the rest: O(n)
window += nums[i] - nums[i-k] # add new item, drop the one leaving
best = max(best, window)
return best
print(max_window_sum([2, 1, 5, 1, 3, 2], 3)) # 9 (5+1+3)
max_window_sum finds the largest sum of any k consecutive items using the sliding-window trick from the diagram — one pass, not one pass per window.
- The guard
if k > len(nums)raises a clear error if you ask for a window bigger than the array — a small habit that prevents confusing bugs later. window = sum(nums[:k])computes the first window's total once — this costs O(k) but happens only a single time.beststarts as that sum.- The loop
for i in range(k, len(nums))slides one step at a time.window += nums[i] - nums[i-k]adds the item entering on the right and subtracts the one leaving on the left — the O(1) update. best = max(best, window)keeps the biggest window sum seen so far. After the single pass,bestis the answer.
What the output means: max_window_sum([2,1,5,1,3,2], 3) prints 9 — the best 3-in-a-row is 5+1+3.
Try this: Trace it: first window 2+1+5=8; slide → 8 + 1 − 2 = 7; slide → 7 + 3 − 1 = 9; slide → 9 + 2 − 5 = 6. The max seen is 9 — and each slide was just one add and one subtract.
8 · Prefix sums — precompute once, answer in O(1) advanced
The last technique in this chapter answers a question you'll hit constantly in analytics and data work: "what's the sum of everything between position i and position j?" — asked over and over on the same array. Answer each one by re-adding the range and you pay O(n) per question; a thousand questions is O(n × 1000) of repeated adding.
The move is to do the hard work once, up front. Build a companion array of running totals — prefix[k] holds "the sum of everything before position k." Building it is a single O(n) pass. But now watch what any range query becomes: the sum from i to j is just prefix[j+1] − prefix[i]. One subtraction. Two big running totals, subtract the earlier from the later, and the middle chunk falls out — O(1), every time, forever.
The everyday analogy: it's a bank statement. If you know your balance on March 1 and your balance on March 31, you know March's net flow by subtracting — you don't re-add every transaction. You've paid a little memory (the extra prefix array) to make thousands of future queries almost free. That "precompute-then-query" trade of space for time is a recurring pattern — it powers image processing, range queries, and the Fenwick tree you'll meet in D4. The diagram shows the two running totals being subtracted; the lab builds it in nine lines.
prefix[k] holds the sum of the first k elements, so any range is a single subtraction — O(1) instead of re-summing.
This shows the prefix-sum trick: do a little work once so that every 'sum between i and j' question afterwards is answered instantly.
- The top row (
nums) is the original data[3, 1, 4, 1, 5, 9]. The bottom row (prefix) holds running totals: each cell is the sum of everything before it, starting from0. - So
prefixreads0, 3, 4, 8, 9, 14, 23— e.g. the8is3+1+4and the14is3+1+4+1+5. Building this whole row is one O(n) pass, done once. - The blue formula answers a range query with one subtraction: the sum of a slice is
prefix[end] − prefix[start]. Hereprefix[4] − prefix[1] = 14 − 3 = 11, the sum of the middle chunk. - The two green cells are the two prefix values being subtracted — that's all the work per query, so each answer is O(1).
In short: Precompute the running totals once (O(n)), then any range sum is a single subtraction (O(1)). You trade a bit of memory for the prefix array to make thousands of queries almost free.
pythondef build_prefix(nums):
prefix = [0] * (len(nums) + 1) # prefix[0] = 0
for i, x in enumerate(nums):
prefix[i+1] = prefix[i] + x # running total
return prefix
def range_sum(prefix, i, j): # sum of nums[i..j] inclusive
return prefix[j+1] - prefix[i] # O(1) per query
nums = [3, 1, 4, 1, 5, 9]
p = build_prefix(nums)
print(range_sum(p, 1, 3)) # 6 (1+4+1)
print(range_sum(p, 0, 5)) # 23 (whole array)
These two functions implement the prefix-sum diagram: build_prefix does the one-time O(n) precompute, and range_sum answers any range in O(1).
prefix = [0] * (len(nums) + 1)makes the totals array one longer than the input, with a leading0. That extra slot at the front is what makes the subtraction formula clean (no special case for ranges starting at index 0).- The loop
prefix[i+1] = prefix[i] + xfills each cell with the previous total plus the current value — the running total you saw in the diagram.enumerategives both the indexiand valuex. range_sum(prefix, i, j)returnsprefix[j+1] - prefix[i]— one subtraction, O(1), for the sum ofnums[i..j]inclusive.
What the output means: range_sum(p, 1, 3) prints 6 (that's 1+4+1) and range_sum(p, 0, 5) prints 23 (the whole array).
Try this: Call range_sum(p, 2, 2) — it returns 4, the single element at index 2. The +1 offsets and the leading zero are what make even a one-element range work without special-casing.
Exercises advanced
- State the time and space complexity of
sorted(set(nums))and explain each factor. - Rewrite
has_duplicateto instead return the first duplicated value, keeping it O(n). - Given a sorted array and a target, use two pointers to count how many pairs sum to the target (watch out for duplicates).
- Use a sliding window to find the length of the shortest subarray whose sum is ≥ a target (all positive numbers).
- Extend
range_sumto return the average of a range in O(1).
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see D8 · Big Tech DSA patterns.
"Largest sum of a contiguous subarray." The O(n) DP: at each element, extend the running sum or restart.
pythondef max_subarray(nums):
best = cur = nums[0]
for x in nums[1:]:
cur = max(x, cur + x) # extend or restart
best = max(best, cur)
return best
Kadane's algorithm finds the largest sum of any contiguous subarray in a single O(n) pass. It's a classic interview question (classic) and a first taste of dynamic programming.
- It walks the array once, tracking two numbers:
cur= the best sum ending at the current element, andbest= the best sum seen anywhere so far. Both start at the first element. - For each next value
x,cur = max(x, cur + x)makes the key choice: either extend the current run (cur + x) or restart a fresh run atx. You restart whenever the running sum has gone negative — dragging it along would only hurt. best = max(best, cur)remembers the largestcurever reached. After one pass,bestis the answer.
What the output means: For [-2,1,-3,4,-1,2,1,-5,4] it returns 6 — the subarray [4,-1,2,1]. One O(n) pass, O(1) space.
Try this: Trace the 'extend or restart' choice on [-2, 1, -3, 4]: after the -3, cur is negative, so at 4 the max picks x (restart) over cur+x. Spotting that restart is what interviewers want to see.
Compact non-zeros to the front with a write pointer, then fill the rest with zeros.
pythondef move_zeroes(nums):
w = 0
for x in nums:
if x != 0:
nums[w] = x; w += 1
for i in range(w, len(nums)):
nums[i] = 0
'Move zeroes' (classic) shifts every 0 to the end while keeping the other numbers in order — done in place with a write pointer, so O(n) time and O(1) extra space.
wis a write pointer: the next slot where a non-zero value should go. It starts at0.- The first loop scans every value; whenever it finds a non-zero, it copies it to position
wand advancesw. This packs all the non-zeros to the front in their original order, without allocating a new list. - After that loop,
wmarks where the non-zeros end. The second loop fills every remaining slot fromwonward with0— putting all the zeros at the back.
What the output means: [0,1,0,3,12] becomes [1,3,12,0,0] — non-zeros keep their order, zeros pushed to the end, and the original list is modified directly (no copy returned).
Try this: The two-pointer 'read everything, write only what you keep' pattern (here read = the loop variable, write = w) is a workhorse for in-place array edits — remember it for dedup and filter problems too.
Checkpoint — you can move on when you can… advanced
- Give the Big-O of a loop by counting nested iterations.
- Explain why
appendis O(1) amortized butinsert(0,x)is O(n). - Recognize when two-pointer / sliding-window / prefix-sum turns O(n²) into O(n).
- Reason about space, not just time, and name the memory-vs-time trade-off.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Before you can compare algorithms you must be able to read their growth from the loop structure alone. Big-O is the vocabulary every interview and design review is conducted in.
Your task: For three snippets — one flat loop over n, one nested loop, one halving loop — state the Big-O of each and confirm it by counting the iterations that actually run.
Requirements:
- Classify the flat loop as
O(n) - Classify the nested loop as
O(n²) - Classify the halving loop as
O(log n) - Back each claim with an iteration count, not just the label
💡 Hint: A loop that multiplies or divides its index each step touches log₂ n values, not n of them.
Show solution
def ops(n):
a = b = c = 0
for _ in range(n): # (a) O(n)
a += 1
for i in range(n): # (b) O(n^2)
for j in range(n):
b += 1
k = n
while k > 1: # (c) O(log n) -- halves each step
k //= 2
c += 1
return a, b, c
print(ops(8)) # (8, 64, 3) -> n, n^2, log2(n)
Constant factors and lower-order terms drop: n + n^2 + log n is O(n^2), dominated by the nested loop.
Context: Two-sum is the canonical "trade space for time" lesson: the obvious double loop is quadratic, but a single pass with a lookup table collapses it to linear.
Your task: Given an array and a target, return the indices of two numbers that add to the target — first the brute-force O(n²) version, then an O(n) version using a hash map.
Requirements:
- Brute force checks every pair and is
O(n²) - The improved version is a single pass,
O(n)time - Use a hash map from value → index to find the complement
- Return the two indices (not the values)
- Both versions agree on the same input
💡 Hint: As you scan, ask the map whether target − current has already been seen; store each value's index as you go.
Show solution
def two_sum_slow(nums, target): # O(n^2) time, O(1) space
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
def two_sum(nums, target): # O(n) time, O(n) space
seen = {} # value -> index
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
print(two_sum_slow([3, 2, 4], 6)) # [1, 2]
The hash map trades O(n) space to remove the inner loop — the classic time/space tradeoff.
Context: When the input is already sorted you can often drop the hash map entirely and solve in constant extra space — the two-pointer pattern that shows up all over array interviews.
Your task: Given a sorted array, find a pair that sums to the target in O(n) time and O(1) space — no hash map.
Requirements:
- Use one pointer at each end of the array
- Move the pointers inward based on whether the current sum is too big or too small
O(n)time,O(1)extra space- Return the pair (or a not-found signal) — assert on a known example
💡 Hint: If the current sum is too small advance the left pointer; if too big retreat the right one — the sortedness is what makes that decision correct.
Show solution
def pair_sum_sorted(nums, target): # O(n) time, O(1) space
lo, hi = 0, len(nums) - 1
while lo < hi:
s = nums[lo] + nums[hi]
if s == target:
return [lo, hi]
if s < target:
lo += 1 # need a bigger sum
else:
hi -= 1 # need a smaller sum
return None
print(pair_sum_sorted([1, 2, 4, 7, 11, 15], 15)) # [3, 4] -> 4+11
Sortedness lets each comparison discard one candidate, so we sweep once instead of nesting loops.
Context: The sliding-window technique turns many "longest/shortest substring" problems from quadratic into linear. This is the textbook one (classic).
Your task: Return the length of the longest substring whose characters are all distinct, in O(n).
Requirements:
- Maintain a window of unique characters over a single pass
- Track the last seen position of each character
- Shrink the window's left edge past a repeat instead of restarting
O(n)time- Return the maximum window length seen
💡 Hint: Keep the window's start just to the right of the previous occurrence of the current character; the answer is the largest end−start you ever reach.
Show solution
def length_of_longest(s): # O(n) time, O(min(n,alphabet)) space
last = {} # char -> last index seen
start = best = 0
for i, ch in enumerate(s):
if ch in last and last[ch] >= start:
start = last[ch] + 1 # shrink window past the repeat
last[ch] = i
best = max(best, i - start + 1)
return best
print(length_of_longest("abcabcbb")) # 3 ("abc")
print(length_of_longest("bbbbb")) # 1
print(length_of_longest("pwwkew")) # 3 ("wke")
The window [start, i] only ever grows or jumps forward, so each index is processed once — O(n).
Context: When you must answer thousands of range-sum queries on a fixed array, recomputing each sum is wasteful. A one-time prefix-sum precomputation makes every query constant-time.
Your task: Precompute so that any "sum of elements from i to j" query answers in O(1), and show it beats recomputing the sum per query.
Requirements:
- Build a prefix-sum array once in
O(n) - Each range query is answered by a single subtraction,
O(1) - Handle the inclusive/exclusive index convention consistently
- Demonstrate the speed win versus the naive per-query loop
💡 Hint: sum(i..j) is prefix[j+1] − prefix[i]; get the offset convention right and every query is one subtraction.
Show solution
class RangeSum: # build O(n), query O(1)
def __init__(self, nums):
self.pre = [0]
for x in nums:
self.pre.append(self.pre[-1] + x)
def query(self, i, j): # inclusive sum nums[i..j]
return self.pre[j + 1] - self.pre[i]
rs = RangeSum([3, 1, 4, 1, 5, 9, 2, 6])
print(rs.query(2, 5)) # 4+1+5+9 = 19
print(rs.query(0, 7)) # 31
Amortized: 1000 queries cost O(n + q) instead of O(n·q). Precompute-once is the pattern behind range queries, image integrals, and Fenwick trees.
Context: Counting subarrays with a given sum in a stream of possibly-negative values is a top-frequency interview problem (classic) that fuses prefix sums with a hash map.
Your task: Given a stream of daily P&L values (which may be negative), count how many contiguous subarrays sum to exactly K, in O(n).
Requirements:
- Use running prefix sums, not nested loops
- Keep a hash map of how many times each prefix sum has occurred
- For each position, look up how many earlier prefixes equal
current − K O(n)time; correct even with negative values- Return the total count of qualifying subarrays
💡 Hint: A subarray sums to K exactly when prefix_now − prefix_earlier == K; the map counts the matching earlier prefixes as you scan.
Show solution
from collections import defaultdict
def subarray_sum(nums, k): # O(n) time, O(n) space
count = 0
running = 0
seen = defaultdict(int)
seen[0] = 1 # empty prefix sums to 0
for x in nums:
running += x
count += seen[running - k] # prefixes that make (running - prefix)=k
seen[running] += 1
return count
print(subarray_sum([1, 1, 1], 2)) # 2
print(subarray_sum([3, 4, -7, 1, 3, 3, 1, -4], 7)) # 4
Negatives break the sliding window, so we count prefix sums instead: if running - k was seen before, every earlier occurrence closes a valid subarray. This is the industrial form of the two-sum trick.
Knowledge check check yourself
Why is Python's list.append called O(1) amortized rather than simply O(1)?
Show answer
How does the sliding-window technique reduce an O(n²) scan to O(n)?