- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — The Anxiety Big-O Provokes
- What O() Actually Means
- The Common Complexity Classes
- When Constants Win — Small n and the Cache
- The O(n squared) That Hides — Nested Loops and N+1 Queries
- Don't Forget Space Complexity
- Amortized — The Secret of the Dynamic Array
- When to Stop Optimizing
- Wrapping Up
- References
Introduction — The Anxiety Big-O Provokes
For a lot of people, hearing "Big-O" summons memories of college limits and proofs, and they flinch. But in practice Big-O is not an exam question; it's a tool for intuition. It exists to answer a very practical question: "if the input grows tenfold, how much slower does this code get?"
This post sets mathematical rigor aside for a moment and covers just as much Big-O as a working engineer needs. No limits, no proofs, just building the sense to judge "will this code hold up on a large input?" If you want to watch how sorting algorithms with different complexities behave differently, it helps to keep this site's Sorting Visualizer open alongside.
What O() Actually Means
Big-O expresses how the running time (or memory) grows as the input size grows. The key word is "growth." It describes the shape of the growth, not the absolute speed.
There are a few important properties.
- Drop the constants:
2nand100nare both O(n). Big-O only looks at the trend as input grows, so it ignores the constant multiplier out front. - Drop the lower-order terms:
n squared + n + 5is O(n squared). When n gets very large, then squaredterm dwarfs the rest, so you keep only the dominant term. - Measure the worst case: Big-O usually refers to the worst case. It's not "fast if you're lucky" but "no worse than this, guaranteed."
There's an easy misconception here. Big-O does not tell you "this code takes X seconds." It only tells you "as input grows, it slows down along this curve." So Big-O alone is sometimes not enough to compare the real speed of two algorithms, a point I'll come back to.
The Common Complexity Classes
The complexities you actually meet in practice boil down to a handful. Let's see each with a real example.
O(1) — constant time. Always takes the same time regardless of input size. Array index access and hash map (dictionary) lookup are the classics.
# one access no matter how big the input
value = my_dict[key] # O(1)
first = my_list[0] # O(1)
O(log n) — logarithmic time. Each step halves the size of the problem. Binary search in a sorted array is the archetype. Even with a million items, about 20 steps finishes it.
# discard half every time in a sorted array -> O(log n)
import bisect
idx = bisect.bisect_left(sorted_list, target)
O(n) — linear time. Scans the input once. Finding the max in a list, counting elements that meet a condition, and most tasks that "look at every element once."
# look at every element exactly once -> O(n)
total = 0
for x in numbers:
total += x
O(n log n) — quasilinear time. The complexity of efficient sorting algorithms (merge sort, quicksort, Timsort). A language's standard-library sort() usually lives here. It's effectively the lower bound for the task of "sort n items."
O(n squared) — quadratic time. Comparing every pair. It arises when two nested loops each run over the whole input. Fine for small inputs, but it degrades sharply as they grow.
# compare every pair -> O(n squared)
for i in range(len(items)):
for j in range(len(items)):
compare(items[i], items[j])
A table of how much these classes actually differ makes it click.
| n | O(log n) | O(n) | O(n log n) | O(n squared) |
|---|---|---|---|---|
| 10 | about 3 | 10 | about 33 | 100 |
| 1,000 | about 10 | 1,000 | about 10,000 | 1,000,000 |
| 1,000,000 | about 20 | 1,000,000 | about 20 million | 1 trillion |
At a million, O(n squared) is a trillion operations. Even a modern computer takes a while. O(n log n), by contrast, is 20 million, done in a blink. This one table compresses the whole answer to "why does algorithm choice matter."
When Constants Win — Small n and the Cache
Here is where the practical subtlety enters. Big-O is a story about "when n is large enough." But real-world n is often small. And when it's small, the constants you threw away start to matter again.
Take insertion sort, which is O(n squared), against merge sort, which is O(n log n). In theory merge sort should win, but when n is small, like 10 to 20, insertion sort is often faster. Merge sort carries heavy overhead (constants): recursive calls, splitting arrays, allocating a temporary array for merging. In fact, the standard sorts in Python and Java switch to insertion sort on small runs.
The cache also has a large effect on constants. Modern CPUs read contiguous memory much faster (cache locality). So even when the theoretical complexity is equal or slightly worse, an array accessed contiguously often beats a linked list that chases pointers around.
by theoretical complexity alone:
linked-list middle insert O(1) vs array middle insert O(n) -> list wins?
in reality (because of cache locality):
for small-to-medium sizes, arrays often beat linked lists
-> the cache miss of pointer chasing costs more than scanning an array
The lesson is this. Big-O tells you the "big picture," but actual performance depends on constants and hardware. When two candidates have equal or similar Big-O, the answer lies not in theory but in measurement.
The O(n squared) That Hides — Nested Loops and N+1 Queries
The reason O(n squared) is scary is that it's often hard to spot. An explicit double for-loop is easy to notice, but the traps are usually hidden.
The first trap is a linear operation hidden inside a loop.
# looks like O(n) since there's only one loop, but...
result = []
for item in items: # n iterations
if item in result: # a list membership check is O(n)!
continue
result.append(item)
# actually O(n squared) - each iteration scans all of result
In item in result, result is a list, so the in check is O(n) each time. Nested inside an O(n) loop, the whole thing becomes O(n squared). The fix is to make the in check O(1), that is, use a set.
# a set membership check is O(1) -> the whole thing is O(n)
seen = set()
result = []
for item in items:
if item in seen:
continue
seen.add(item)
result.append(item)
The second trap is the notorious N+1 query problem in backend development.
# fetch 100 posts (1 query)...
posts = db.query("SELECT * FROM posts LIMIT 100")
for post in posts:
# fetch each post's author with a separate query -> 100 queries!
author = db.query("SELECT * FROM users WHERE id = ?", post.author_id)
print(author.name)
# 1 + 100 = 101 queries total
As the number of posts grows, the query count grows linearly, and the response degrades sharply on larger data. It feels worse still because each query carries a network round trip. The fix is to fetch the needed data together in a single query (a join, an IN clause, or the ORM's eager loading).
# fetch the needed authors in one go -> 2 queries
posts = db.query("SELECT * FROM posts LIMIT 100")
author_ids = [p.author_id for p in posts]
authors = db.query("SELECT * FROM users WHERE id IN (...)", author_ids)
# solved with 2 queries total
N+1 is hard to notice because the code just looks like an ordinary loop. It hides further because the ORM fires the queries for you behind the scenes. The habit of turning on query logging and checking "how many queries does opening this page once fire?" guards against this trap.
Don't Forget Space Complexity
Big-O isn't only for time. Space complexity expresses, in the same way, how much extra memory an algorithm uses.
# time O(n), space O(1) - the only extra memory is a few variables
def sum_list(numbers):
total = 0
for x in numbers:
total += x
return total
# time O(n), space O(n) - builds a new list proportional to the input
def double_all(numbers):
return [x * 2 for x in numbers]
Time and space are often a trade-off. Using a set for deduplication above is a good example. In exchange for O(n) extra memory in the seen set, we dropped the time complexity from O(n squared) to O(n). We spent a bit more memory to save a lot of time.
In practice, space complexity matters especially with large data. If a file is bigger than memory, the O(n)-space approach of "read it all into a list" dies outright. In that case you switch to streaming, processing one line at a time (space O(1)). Fast in time doesn't help if the memory blows up and the program won't run.
Amortized — The Secret of the Dynamic Array
A subtle but important concept is amortized complexity. It's the view that "there's an occasional expensive operation, but averaged over many calls it's cheap."
The append that adds an element to a Python list or a dynamic array in another language is the classic case. It's usually O(1), but when the array fills up it has to allocate a bigger array and copy all existing elements over. That copy is O(n).
keep appending to an array of capacity 4:
[a][b][c][d] <- full
next append -> grow to capacity 8, copy 4 (this one time is O(n))
[a][b][c][d][e][_][_][_]
the next 5 appends are each O(1) again
But when growing the array, the size is typically doubled. Thanks to that, the expensive copy happens more and more rarely. Add up the total cost of appending n elements and divide by n, and the average cost per append converges to O(1). That is amortized O(1).
The key is not to panic over the individual worst case (O(n)). You have to also see how rarely that expensive operation happens to understand the real performance properly. Hash map insertion is amortized O(1) by the same principle.
When to Stop Optimizing
Finally, perhaps the most important practical instinct. Learning Big-O makes you want to optimize every piece of code, but most code doesn't need optimizing.
Donald Knuth's famous line: "premature optimization is the root of all evil." Optimizing code into complexity before you even know it's a bottleneck only hurts readability with no payoff.
To lay out the criteria:
- If n is small and will stay small: O(n squared) is fine. Running a double loop over a 100-item list is done in the blink of an eye. Clear code beats optimized code.
- Is it a hot path?: Is this code on a path called thousands of times per second, or a batch that runs once a day? Optimize the frequently-executed places first.
- Measure first: Don't guess; find the actual bottleneck with a profiler. A developer's intuition is often wrong about where the bottleneck is. It's efficient to find the 10% that eats 90% of the time and fix only that.
- Algorithm first, then constants: If optimization is needed, an algorithmic improvement from O(n squared) to O(n) has far more effect than micro-tuning constants.
In other words, Big-O is not an order to "always optimize"; it's a radar that "spots in advance the places at risk of growing." The core practical skill is the balance of choosing clear code for small data and good complexity for data that will grow.
Wrapping Up
Big-O is not a math exam but an engineer's tool for intuition. It exists to answer "what happens to this code as the input grows?" Get just a handful of classes into your bones, like O(1), O(log n), O(n), O(n log n), and O(n squared), and you can judge most situations.
At the same time, don't forget that Big-O isn't everything. At small n, constants and the cache win; the real bottleneck has to be found with a profiler; and for most code, clarity matters more than optimization. Knowing complexity is knowing when to optimize, and more importantly, knowing when you don't have to.
If you want to watch how sorting algorithms behave differently on the same data, feel the difference between O(n squared) and O(n log n) firsthand in the Sorting Visualizer. It'll land far more vividly than the numbers in a table.
References
- Cormen et al., "Introduction to Algorithms" (growth of functions and asymptotic notation)
- Donald Knuth, "Structured Programming with go to Statements" (source of the premature-optimization quote)
- Big-O Cheat Sheet: https://www.bigocheatsheet.com/
- "What is the N+1 query problem": https://www.geeksforgeeks.org/what-is-the-n1-query-problem/