Why Algorithms Matter
An algorithm is just a precise, step-by-step recipe for solving a problem — but which recipe you pick can be the difference between a program that answers in milliseconds and one that never finishes at all. Two pieces of code can produce the exact same correct output while one scales to millions of inputs and the other grinds to a halt. This lesson builds the intuition for why that happens, before you learn any specific data structure or algorithm in the rest of this course.
Overview: Why Algorithm Choice Matters
Imagine you need to find a specific name in a phone book with 1,000,000 entries. If the book is unsorted, your only option is to check every page from the front until you find it — in the worst case, that’s 1,000,000 checks. But because a real phone book is sorted alphabetically, you instead open to the middle, see which half the name falls in, and repeat: about 20 checks total. Same task, same data, wildly different amount of work — and the difference comes entirely from how you search, not from a faster brain or a faster computer.
This is the central idea of algorithm analysis: as the size of the input (call it n) grows, different approaches to the same problem grow in cost at fundamentally different rates. A faster processor gives you a constant-factor speedup — twice as fast, ten times as fast — but it cannot rescue an algorithm whose cost grows quadratically or exponentially with input size. Doubling your input might double the work for one algorithm and quadruple (or worse) the work for another, no matter how powerful the hardware is. That’s why engineers — especially in interviews — are expected to reason about an algorithm’s growth rate independent of any specific machine.
Big-O notation is the language for describing that growth rate. It answers: “if I double the input size, roughly how much more work does this algorithm do?” O(1) means the work doesn’t grow at all (an array index lookup). O(log n) means the work grows very slowly (binary search). O(n) means the work grows in direct proportion to the input (a single loop). O(n log n) is typical of efficient sorting. O(n²) means the work grows with the square of the input (nested loops over the same data) — and it gets painful fast.
Time and Space Complexity
Before writing code, it helps to see how dramatically these growth rates diverge as n gets large. The table below shows the approximate number of operations for a few common complexity classes at increasing input sizes.
| n | O(log n) | O(n) | O(n log n) | O(n²) |
|---|---|---|---|---|
| 10 | ~3 | 10 | ~33 | 100 |
| 1,000 | ~10 | 1,000 | ~10,000 | 1,000,000 |
| 1,000,000 | ~20 | 1,000,000 | ~20,000,000 | 1,000,000,000,000 |
At n = 1,000,000, an O(n²) algorithm performs roughly a trillion operations — even at a billion operations per second, that’s over 15 minutes, versus microseconds for an O(log n) approach. This is why “just use a faster computer” is not a substitute for choosing the right algorithm: hardware improvements are linear, multiplicative gains, while a better algorithm can change which curve you’re even on.
Two specific operations you’ll use constantly — linear search and binary search — illustrate this directly:
| Operation | Best case | Average case | Worst case | Space |
|---|---|---|---|---|
| Linear search (unsorted or sorted list) | O(1) | O(n) | O(n) | O(1) |
| Binary search (sorted list required) | O(1) | O(log n) | O(log n) | O(1) iterative |
Linear search’s best case (the target is the very first element) is O(1), but that’s a lucky accident, not something you can rely on — its average and worst case are both O(n) because, in general, you might have to inspect every element. Binary search’s worst case is O(log n) because each comparison eliminates half of the remaining candidates: after k comparisons, at most n / 2^k elements remain, and the search ends once that shrinks to zero or one — solving n / 2^k = 1 for k gives k = log₂ n. Both use O(1) extra space in their iterative form because they only track a couple of index variables, not a copy of the input.
Examples
Example 1: Counting the cost of linear search
The function below performs an ordinary linear search but also counts how many comparisons it makes, so you can see the cost directly instead of just trusting a Big-O label.
def linear_search_with_count(arr: list[int], target: int) -> tuple[int, int]:
comparisons = 0
for index, value in enumerate(arr):
comparisons += 1
if value == target:
return index, comparisons
return -1, comparisons
numbers = [8, 3, 15, 1, 9, 22, 4, 17, 6, 11]
index, comparisons = linear_search_with_count(numbers, 17)
print(f"Found 17 at index {index} after {comparisons} comparisons")
index, comparisons = linear_search_with_count(numbers, 8)
print(f"Found 8 at index {index} after {comparisons} comparisons")
Output:
Found 17 at index 7 after 8 comparisons
Found 8 at index 0 after 1 comparisons
The list [8, 3, 15, 1, 9, 22, 4, 17, 6, 11] is unsorted, so the function has no choice but to check elements in order starting from index 0. 17 happens to sit at index 7 (the eighth element), so it takes 8 comparisons to find it. 8 happens to be the very first element, so it’s found in a single comparison — that’s the lucky O(1) best case, not something the algorithm can guarantee for an arbitrary target.
Example 2: Counting the cost of binary search
Now sort the same numbers and search with binary search instead, again counting comparisons.
def binary_search_with_count(arr: list[int], target: int) -> tuple[int, int]:
comparisons = 0
left, right = 0, len(arr) - 1
while left <= right:
comparisons += 1
mid = (left + right) // 2
if arr[mid] == target:
return mid, comparisons
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1, comparisons
sorted_numbers = sorted([8, 3, 15, 1, 9, 22, 4, 17, 6, 11])
print(sorted_numbers)
index, comparisons = binary_search_with_count(sorted_numbers, 17)
print(f"Found 17 at index {index} after {comparisons} comparisons")
index, comparisons = binary_search_with_count(sorted_numbers, 8)
print(f"Found 8 at index {index} after {comparisons} comparisons")
Output:
[1, 3, 4, 6, 8, 9, 11, 15, 17, 22]
Found 17 at index 8 after 3 comparisons
Found 8 at index 4 after 1 comparisons
Once sorted, 17 now lives at index 8 and is found in just 3 comparisons instead of 8 — binary search discards half of the remaining candidates at every step. 8 also takes only 1 comparison here, but for a structural reason this time: it lands exactly on the first midpoint the algorithm checks, not because it happens to be first in the list. This is the payoff of sorting once and then reusing that order across many searches.
Example 3: Same output, very different cost
The two functions below compute the same result — how many elements two lists have in common — but one scans a list for every lookup, and the other builds a set first. Their output is identical; their growth rate is not.
def count_common_elements_slow(list_a: list[int], list_b: list[int]) -> int:
count = 0
for item in list_a:
if item in list_b: # O(n) scan of list_b for every item -> O(n * m) overall
count += 1
return count
def count_common_elements_fast(list_a: list[int], list_b: list[int]) -> int:
lookup = set(list_b) # O(m) to build, O(1) average lookup afterward
count = 0
for item in list_a:
if item in lookup:
count += 1
return count
list_a = [1, 2, 3, 4, 5, 6, 7, 8]
list_b = [4, 5, 6, 9, 10]
print(count_common_elements_slow(list_a, list_b))
print(count_common_elements_fast(list_a, list_b))
Output:
3
3
count_common_elements_slow checks membership with item in list_b, and testing membership in a Python list is O(m) because it may have to scan every element — done once per item in list_a, that’s O(n * m) overall. count_common_elements_fast converts list_b into a set first (O(m), done once), and membership testing in a set is O(1) on average because it uses hashing rather than scanning — so the total work drops to O(n + m). Both return 3 (the shared elements 4, 5, and 6), but on large lists the fast version finishes while the slow version is still working.
How It Works Step by Step
Let’s trace binary_search_with_count from Example 2 on sorted_numbers = [1, 3, 4, 6, 8, 9, 11, 15, 17, 22] (indices 0 through 9) searching for 17.
| Step | left | right | mid | arr[mid] | Action |
|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 8 | 8 < 17, so search the right half: left = 5 |
| 2 | 5 | 9 | 7 | 15 | 15 < 17, so search the right half: left = 8 |
| 3 | 8 | 9 | 8 | 17 | 17 == 17, match found at index 8 |
Each step computes mid as the midpoint of the current [left, right] window and throws away the half that cannot contain the target. Starting with 10 candidates, step 1 narrows to 5 (indices 5–9), step 2 narrows to 2 (indices 8–9), and step 3 finds the answer — three comparisons total, matching the count printed in Example 2. This is exactly why binary search requires a sorted input: the decision to discard the left or right half depends entirely on being able to trust that everything left of mid is smaller and everything right of it is larger.
Common Mistakes
Mistake 1: Running binary search on unsorted data
Binary search’s correctness depends entirely on the array being sorted. Run it on unsorted data and it will silently return wrong answers — no exception is raised, so the bug can hide for a long time.
def naive_binary_search(arr: list[int], target: int) -> int:
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
unsorted = [8, 3, 15, 1, 9, 22, 4, 17, 6, 11]
print(naive_binary_search(unsorted, 6))
Output:
-1
The value 6 is actually present in unsorted at index 8, but naive_binary_search reports -1 (not found). Because the array isn’t sorted, comparing arr[mid] to the target and jumping to “the right half” or “the left half” is meaningless — the target can be on either side regardless of that comparison, so the search eliminates the wrong half and walks right past the answer. The fix is to sort first (or maintain the data in sorted order from the start):
def binary_search(arr: list[int], target: int) -> int:
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
unsorted = [8, 3, 15, 1, 9, 22, 4, 17, 6, 11]
sorted_arr = sorted(unsorted)
print(sorted_arr)
print(binary_search(sorted_arr, 6))
Output:
[1, 3, 4, 6, 8, 9, 11, 15, 17, 22]
3
Sorting costs O(n log n) once, but if you’ll search the same collection many times, that upfront cost is easily paid back by every O(log n) search afterward — far cheaper than repeated O(n) linear scans, and correct, unlike binary search on unsorted data.
Mistake 2: Building a string with repeated concatenation in a loop
Strings in Python are immutable, so report += word doesn’t modify the string in place — it creates an entirely new string and copies the old contents into it every single time. Done inside a loop, that turns an operation that looks like O(n) into O(n²).
def build_report_slow(words: list[str]) -> str:
report = ""
for word in words:
report += word + " "
return report
words = ["algorithms", "matter", "because", "they", "determine", "speed"]
print(build_report_slow(words))
Output:
algorithms matter because they determine speed
The code works and produces the expected sentence, so this mistake is easy to miss in a small example — but each += copies everything accumulated so far, so building a string of length n this way does roughly 1 + 2 + 3 + ... + n character copies, which sums to O(n²). On thousands or millions of words, that quadratic blowup becomes very real. The standard fix is to accumulate pieces in a list (or just pass them directly) and join once at the end, which is O(n) overall:
def build_report_fast(words: list[str]) -> str:
return " ".join(words)
words = ["algorithms", "matter", "because", "they", "determine", "speed"]
print(build_report_fast(words))
Output:
algorithms matter because they determine speed
" ".join(words) allocates the final string exactly once instead of rebuilding it word by word, which is why the standard library idiom exists at all — it isn’t just shorter, it’s asymptotically better.
Best Practices
- Always state a complexity in terms of what’s growing — “O(n)” alone is incomplete; say
O(n)wherenis the number of elements in the list, orO(V + E)whereVandEare vertices and edges in a graph. - Before optimizing, check whether the input size actually makes the difference matter — an
O(n²)algorithm on 50 items is instant; the same algorithm on 5,000,000 items is not. Don’t reach for a cleverer algorithm before confirming it’s needed. - Reach for a hash-based structure (
set,dict) whenever the problem repeatedly asks “have I seen this before?” or “is this in the collection?” — it trades a small amount of extra memory for averageO(1)lookups instead ofO(n)scans. - Only use binary search — or any algorithm that assumes sorted order — on data you know is sorted. If you’re not sure, either sort it first or use a linear approach.
- When accumulating strings in a loop, build a list and call
"".join(...)once at the end instead of repeated+=. - Think about both time and space complexity — an algorithm that’s faster but needs more memory than the machine has isn’t actually usable.
- In interviews, say the complexity out loud and justify it (“this is
O(n)because we touch each element once”) — interviewers are evaluating your reasoning, not just your final answer.
Practice Exercises
- Trace it yourself: Using
binary_search_with_countfrom Example 2, trace by hand how many comparisons it takes to search for1and for22insorted_numbers. Then run the code to check yourself. - Redesign for repetition: Suppose you need to check “is this ID in our list of 100,000 banned IDs?” 10,000 times while your program runs. Decide which data structure you’d use and why, and state the total complexity of doing all 10,000 checks with a
listversus with aset. - Interview-style: Explain in your own words why an algorithm that runs “twice as fast” is only a constant-factor improvement (still the same Big-O class), while moving from
O(n)toO(log n)is a fundamentally different kind of improvement. Give the approximate operation counts for both atn= 1,000,000 to make the point concrete.
Summary
- An algorithm’s growth rate — not raw hardware speed — determines whether it scales to large inputs; a faster computer only ever gives a constant-factor speedup.
- Big-O notation describes how the amount of work grows as input size
nincreases, ignoring constants and lower-order terms. - Linear search is
O(n)average/worst case because it may inspect every element; binary search isO(log n)because it halves the remaining candidates each step — but binary search requires sorted input. set/dictmembership tests areO(1)on average via hashing, versusO(n)for alist— a huge win for repeated “have I seen this?” checks.- Two pieces of code can produce identical output while having very different complexity — correctness and efficiency are separate questions, and both matter.
- Common efficiency mistakes include running sort-dependent algorithms on unsorted data, and building strings with repeated
+=in a loop instead of"".join(...). - The rest of this course builds on this foundation: every data structure and algorithm from here on will be judged by exactly this lens — its time and space complexity, and when to reach for it.
