How do you work out the time complexity of a method, and why does O(n²) matter when the code looks fine?
Count how the work grows with the input, not how long it takes. Read the loops: sequential loops add, nested loops over the same input multiply, and halving the range each step is logarithmic. The costs that catch people are hidden inside library calls — contains on a List is a scan, and += on a String copies everything it has so far, so a loop around either is quietly quadratic.
The Answer
- Big-O describes how the work grows as the input grows, not how fast the code is. It drops constants and lower-order terms on purpose — they depend on the machine, and the growth does not.
- Read it off the structure:
- one loop over
n→ O(n); - two loops one after the other → still O(n), because you add;
- a loop inside a loop over the same input → O(n²), because you multiply;
- the range halving each step → O(log n).
- one loop over
- The cost you miss is inside the method calls.
list.contains(x)is a scan.string += xcopies the whole string. Put either in a loop and you have written O(n²) that reads like O(n). - "Fine on my machine" usually means the test input was small. O(n²) and O(n) are indistinguishable at n = 100 and four hours apart at n = 1,000,000.
- Say the worst case unless you state otherwise, and say what
nis.
Understand It
Count the work, do not time it
Timing a JVM tells you about JIT warm-up, garbage collection and what else the laptop was doing. Counting steps tells you about the algorithm. Everything on this page counts.
for each item in list: # runs n times
doSomething(item) # cost of the body, call it c
# total: c · n → O(n)
for i in 0..n: # n times
for j in 0..n: # n times, for EACH i
doSomething(i, j) # total: n · n → O(n²)
while lo <= hi: # range halves each pass
mid ← middle of lo..hi # n → n/2 → n/4 → ... → 1
# that is log₂(n) passes → O(log n)
The rule that does the most work: nested loops over the same input multiply; sequential loops add. Two passes over a million rows is two million steps. One pass containing another pass is a trillion.
The scan hiding inside contains
List.contains reads like a question. It is a loop:
for (int n : new int[] {1000, 4000, 16000}) {
List<Integer> list = listOf(n);
long steps = 0;
for (int i = 0; i < 200; i++) steps += scanSteps(list, n - 1);
System.out.printf("n=%-6d 200 lookups cost %d steps%n", n, steps);
}n=1000 200 lookups cost 200000 steps
n=4000 200 lookups cost 800000 steps
n=16000 200 lookups cost 3200000 stepsFour times the data, four times the work, for the same number of lookups. That
is O(n) per lookup, and it is exactly what you do not want inside a loop —
because then the number of lookups grows with n too, and the total is O(n²).
This is the single most common accidental quadratic in Java:
// O(n * m) — a scan of `seen` for every candidate
for (String candidate : candidates)
if (seen.contains(candidate)) ... // seen is a List
// O(n) — a hash lookup for every candidate
Set<String> index = new HashSet<>(seen);
for (String candidate : candidates)
if (index.contains(candidate)) ...
One line — building the HashSet — changes the growth rate. Nothing about the
loop changed.
The copy hiding inside +=
String is immutable, so s = s + "x" cannot extend anything. It allocates a
new string and copies every character already there:
for (int n : new int[] {1000, 2000, 4000}) {
System.out.printf("n=%-6d chars copied by += : %d%n", n, concatCopies(n));
}n=1000 chars copied by += : 499500
n=2000 chars copied by += : 1999000
n=4000 chars copied by += : 7998000Double the loop count and the copying quadruples — 0.5M, 2M, 8M. That is
the signature of O(n²) in a loop whose body is one short line. StringBuilder
appends into a buffer it owns, making the same loop O(n).
The compiler rewrites a + b into an invokedynamic bootstrap that can be
optimised at runtime, which makes single concatenations faster. It does not
change the loop: each iteration still produces a whole new string.
Amortised is not the same as average
ArrayList.add is documented as amortised O(1), and the word matters. Most
appends are a single array write. Occasionally the array is full and every
element is copied to a bigger one:
for (int n : new int[] {1000, 2000, 4000}) {
long[] g = growth(n);
System.out.printf("n=%-6d regrowths: %2d, elements copied: %d%n", n, g[0], g[1]);
}n=1000 regrowths: 12, elements copied: 2456
n=2000 regrowths: 14, elements copied: 5541
n=4000 regrowths: 15, elements copied: 8317Doubling n adds about two regrowths, not twice as many — growth is
multiplicative, so the count is logarithmic. And the total copying stays
proportional to n: around 2× the elements appended, so each append costs
O(1) on average across the sequence, which is what amortised means.
Amortised says nothing about a single call. One unlucky add copies four
million elements. That is invisible in a throughput benchmark and very visible
in a latency percentile.
Reference
The costs worth knowing by heart, because they are the ones that appear inside loops:
| Operation | Cost | The trap |
|---|---|---|
ArrayList.get(i) | O(1) | — |
ArrayList.add(x) | O(1) amortised | one call in log n copies everything |
ArrayList.add(0, x) | O(n) | shifts every element |
ArrayList.remove(x) | O(n) | scan, then shift |
ArrayList.contains(x) | O(n) | looks like a lookup, is a scan |
LinkedList.get(i) | O(n) | walks the chain; indexed loops are O(n²) |
HashMap.get/put | O(1) average | O(log n) worst case after treeify |
TreeMap.get/put | O(log n) | — |
Collections.sort | O(n log n) | sorting inside a loop is O(n² log n) |
String += | O(n) per call | O(n²) in a loop |
StringBuilder.append | O(1) amortised | — |
Arrays.binarySearch | O(log n) | requires sorted input |
Reading a method top to bottom:
static int countPairs(List<Integer> values, int target) { // n = values.size()
int count = 0;
for (int i = 0; i < values.size(); i++) { // n iterations
for (int j = i + 1; j < values.size(); j++) { // ~n/2 on average
if (values.get(i) + values.get(j) == target) // O(1) each
count++;
}
}
return count; // n · n/2 → O(n²)
}
n/2 is dropped because constants are dropped. The same problem in one pass,
trading memory for time:
static int countPairs(List<Integer> values, int target) {
Map<Integer, Integer> seen = new HashMap<>();
int count = 0;
for (int v : values) { // n iterations
count += seen.getOrDefault(target - v, 0); // O(1) average
seen.merge(v, 1, Integer::sum); // O(1) average
}
return count; // O(n) time, O(n) space
}
Space complexity is counted the same way, and is the thing the first version was quietly winning on: O(1) extra memory against O(n).
Scenarios
A report that got slow after a good quarter. The code joins two lists with a nested loop. At 200 customers it is 40,000 comparisons and nobody notices; at 20,000 customers it is 400 million. Nothing changed but the data. This is the normal way quadratic code is discovered — by growth, in production.
The profiler blames String.concat. It is not the concatenation that is
slow, it is that there are n of them each copying n/2 characters. Replacing
the loop body changes nothing; replacing the accumulator with a
StringBuilder changes the growth rate.
A "cache" that is a List. Someone adds if (!cache.contains(key)) in
front of an expensive call. Each check is a scan, so the cache costs more as it
gets more useful. A HashSet has the same API shape and O(1) lookups.
When O(n²) is the right answer. For n under a few hundred, the simple
quadratic loop is often faster than the clever linear one — no hashing, no
allocation, cache-friendly. Big-O is about growth, and if n cannot grow, it
is not the thing to optimise. Say so explicitly rather than pretending not to
know.
Interviewer's Next Move
1. "Two sequential loops over the same list — O(n) or O(n²)?"
O(n). Sequential work adds: n + n = 2n, and the constant drops. Only nesting
multiplies. The question is really testing whether you read structure or count
for keywords.
2. "Why is HashMap.get O(1) if it has to handle collisions?"
It is O(1) on average, assuming a decent hash spreading keys across bins. In
the worst case every key lands in one bin — then it is O(n) for a list-shaped
bin, or O(log n) since Java 8 treeifies long bins. A deliberately bad
hashCode makes this the worst case on purpose, which is the HashDoS attack.
3. "What does amortised O(1) mean for a latency SLA?"
That the average over many calls is constant, not that any single call is.
One add in log n copies the whole array, so it shows up in p99.9 rather
than the mean. If the tail matters, size the list up front or pick a structure
that does not reallocate.
4. "Your solution is O(n) time and O(n) space; the other is O(n²) and O(1).
Which do you ship?"
Depends on n and on where it runs. A million rows makes the quadratic one
impossible. A fixed twenty rows in a memory-constrained device makes the extra
map wasteful. The answer an interviewer wants is that you know it is a trade,
and which quantity you traded.
5. "How do you find this in existing code?"
Look for a method call inside a loop whose cost depends on collection size —
contains, indexOf, remove(Object), get(i) on a LinkedList, string
concatenation. Then confirm with a measurement at two input sizes: if
quadrupling the input roughly sixteen-times the work, it is quadratic.
Check Yourself
A loop appends to a String 10,000 times and it is slow. Where is the
quadratic, given the loop body is one line?
In +=. Each concatenation allocates a new String and copies everything
accumulated so far, so the total copying is 0 + 1 + ... + n-1, which grows
with n². StringBuilder makes it O(n).
ArrayList.add is amortised O(1). What is the worst single call?
O(n) — the one that finds the array full and copies every element into a larger
one. It happens about log₁.₅(n) times across n appends, which is why the
average stays constant.
How do you tell O(n) from O(n²) with a stopwatch and no profiler?
Run it at n and at 4n. Linear work takes about four times as long;
quadratic takes about sixteen. Two points and a ratio are enough to
distinguish them, which is why counting at two sizes beats timing at one.
Where this question goes next
Questions that lead here
When can you replace a nested loop with two pointers, and why is it correct?
When each comparison lets you rule out a whole candidate rather than one pair. On a sorted array, a sum that is too small proves no pair using the current left element can work — so the pointer advances past it for good. That turns n²/2 comparisons into n. Measured on 4,000 elements: 7,998,000 comparisons against 3,999.
Asked constantlyjunior0–8 yrs10 min readArrays and stringsWhy doesn't printing a PriorityQueue show the elements in order?
Because a PriorityQueue is a binary heap in an array, and a heap is not sorted — it only guarantees that every parent beats its children. That is enough to make the smallest element index 0, which is all peek and poll need. toString and iteration walk the array, so they show heap order. Only the head is ordered, and ties are not stable.
Asked oftenjunior1–8 yrs9 min readLinear structuresIf Java 8 treeifies long bins, is a bad hashCode still O(n)?
Yes, unless your keys are Comparable. The red-black tree a bin turns into needs an ordering to search, and equal hash codes give it none — so for non-Comparable keys it falls back to searching both subtrees and stays linear. Measured: 8,005,999 comparisons for 4,000 lookups with a constant hash, against 49,585 when the same key implements Comparable.
Asked oftenintermediate2–10 yrs9 min readHashing structuresWhy is a binary search tree O(log n) in theory and O(n) on real data?
Because O(log n) is the cost of the tree's height, and nothing in a plain BST keeps the height low. Insert already-sorted keys and every node becomes a right child — the tree is a linked list with extra pointers, height n. Measured: inserting 4,000 ascending keys gives height 4,000; the same keys shuffled give 29. TreeMap stays at 11 comparisons per lookup because a red-black tree rebalances.
Asked oftenintermediate1–10 yrs10 min readTrees and graphs
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-09-11.