Demo: turning an O(n²) solution into O(n)
On This Lesson 11 topics
Lesson Overview
This demo ties the whole module together on one small problem: does an array contain a duplicate? We will write the obvious solution, state its complexity, time it, make a plausible but wrong fix that is still slow (the hidden loop of list.contains), and then do it properly with a HashSet. At the end you will have a measured speed-up and a clear statement of what it cost.
This is also preparation for lab BEG-039-LAB1, where you will repeat the same process on other problems, such as finding a pair with a given sum.
Why this matters: "replace a nested loop with a hash lookup" is the most common single optimization in backend code, and the version with the hidden loop is the most common failed attempt at it.
This lesson builds on all of BEG-039 (growth, Big O, loops, space, cases) and on timing code with System.nanoTime() and on HashSet (BEG-030).
Learning Objectives
By the end of this lesson you will be able to:
- Analyze a nested-loop solution and state its time and space complexity.
- Time several solutions on three input sizes with a small harness and record a table.
- Explain why
List.containsinside a loop does not fix the problem. - Rewrite a duplicate check with a
HashSetand state its new time and space. - Name the trade: what was gained, what was paid.
- Apply the same pattern to a "pair with a given sum" problem.
Written Explanation
Step 1: the obvious solution
static boolean nested(int[] a) {
for (int i = 0; i < a.length; i++)
for (int j = i + 1; j < a.length; j++)
if (a[i] == a[j]) return true;
return false;
}Compare every pair once (j starts at i + 1, so each pair is checked only once and no element is compared with itself). When there are no duplicates, the worst case, the inner comparison runs n × (n - 1) / 2 times. For 100,000 numbers that is 4,999,950,000. The time is O(n²) and the extra space is O(1). Correct, simple, and slow on large input.
A quick check of correctness: nested(new int[]{3, 1, 4, 1}) printed true.
Step 2: measure it
Timing needs care (BEG-030): one run includes warm-up and noise, so the harness below runs each method three times and keeps the fastest. The input is the worst case: the values 0, 1, ..., n - 1, all different, so the methods must look at everything.
interface Check { boolean run(int[] a); }
static long micros(Check c, int[] a) {
long best = Long.MAX_VALUE;
for (int r = 0; r < 3; r++) {
long t = System.nanoTime();
c.run(a);
best = Math.min(best, System.nanoTime() - t);
}
return best / 1_000; // microseconds
}Passing a method as a value, micros(L06::nested, a), uses a method reference (BEG-035).
Step 3: the planned mistake
A first idea: keep a list of the numbers seen so far and use contains, so there is only one loop:
static boolean withList(int[] a) {
List<Integer> seen = new ArrayList<>();
for (int x : a) {
if (seen.contains(x)) return true; // looks like O(1)
seen.add(x);
}
return false;
}It looks like O(n): one loop. But seen.contains(x) scans the list, which has up to i elements at iteration i, so the total is up to 0 + 1 + ... + (n - 1), which is O(n²) again. L03 called this a hidden loop: the ArrayList documentation puts contains among the operations that run in linear time. The code is shorter and the complexity is the same.
Step 4: the fix
static boolean withSet(int[] a) {
Set<Integer> seen = new HashSet<>();
for (int x : a) {
if (!seen.add(x)) return true; // add returns false if x was already present
}
return false;
}HashSet.add returns false when the element is already in the set, so one call both checks and inserts. The HashSet documentation promises constant-time basic operations, assuming a hash function that disperses the elements well, which Integer does. One loop with a constant-time step: O(n) time on average. The set can hold up to n numbers: O(n) space.
Step 5: time all three
Output of my run on JDK 21 (a Windows laptop; best of three runs; microseconds), all inputs without duplicates:
n | nested | list + contains | HashSet |
|---|---|---|---|
| 1,000 | 931 | 644 | 215 |
| 10,000 | 12,059 | 43,380 | 1,274 |
| 100,000 | 1,315,179 | 4,893,845 | 5,212 |
At 100,000 elements: nested loops about 1.3 seconds, the list version about 4.9 seconds, the HashSet version about 5 milliseconds. The set was roughly 250 times faster than the nested loops and roughly 900 times faster than the list version. The set also ran a million elements in about 78 milliseconds in the same session (77,850 microseconds), something the other two could not do in reasonable time (the nested loops would need about 500 billion comparisons).
Read the table for growth, not for exact values. From 10,000 to 100,000, n grows ten times and the nested loop's time grows about 109 times (roughly the 100 times that n² predicts). The list version grows about 113 times. The set grows about 4 times, a little below the ten that O(n) predicts, which is plausible for warm-up and noise at such small times. At 1,000 elements the three are close together; the difference only becomes decisive as n grows. The list version being slower than the nested loops is plausible given that contains compares boxed Integer objects with equals and the list also does add calls; I did not profile it to confirm.
Step 6: state the trade
| Version | Time | Extra space |
|---|---|---|
nested | O(n²) | O(1) |
withList | O(n²) | O(n) |
withSet | O(n) average | O(n) |
withList is the worst of both worlds: it uses O(n) memory and gets no speed-up. withSet pays O(n) memory for a large time gain. If memory is very tight, or n is tiny, nested loops are still a legitimate choice.
The same pattern for "pair with a given sum"
Problem: does the array have two different positions whose values add to target?
static boolean pairNested(int[] a, int target) { // O(n^2) time, O(1) space
for (int i = 0; i < a.length; i++)
for (int j = i + 1; j < a.length; j++)
if (a[i] + a[j] == target) return true;
return false;
}
static boolean pairSet(int[] a, int target) { // O(n) average time, O(n) space
Set<Integer> seen = new HashSet<>();
for (int x : a) {
if (seen.contains(target - x)) return true; // the partner was seen earlier
seen.add(x);
}
return false;
}For {2, 7, 11, 15} with target 9, both returned true (2 + 7); pairSet with target 100 returned false. The idea: for each x, the number we need is target - x; a set answers "have I seen it?" in constant time. Checking contains before add ensures an element is not paired with itself.
Examples
Example 1: counting the work by hand (simple)
For {5, 8, 5}: nested compares (5, 8), (5, 5) and stops at true after 2 comparisons. withSet adds 5 (new), adds 8 (new), and the third add(5) returns false, so it returns true after 3 set operations. For a tiny array the nested loops can be as cheap as the set. The difference appears with large n.
Example 2: the shape of the growth (practical)
Using the table: when n went from 1,000 to 100,000, a hundred times more data, the nested loop's time went from 931 to 1,315,179 microseconds, about 1,400 times more: far above the 100 times that linear growth would give, though below the 10,000 times that n² predicts (fixed overhead and warm-up inflate the small-size time). The set went from 215 to 5,212, about 24 times, well below the 100 times of linear growth, for the same reason. Doing the ratios is how you check a Big O claim against a measurement.
Example 3: reusing the idea in a service (realistic)
A signup service must reject emails that appear twice in an uploaded batch of 100,000 addresses. The nested approach is the obvious first draft and a 1.3-second pause per upload in our measurements. The HashSet version takes milliseconds and uses extra memory proportional to the batch, a fair price on a server with gigabytes of RAM. If the batch can be 500 million rows, memory becomes the problem and the design changes, as in L04.
Backend Insight
Almost every "slow endpoint" investigation looks like this lesson: find the nested loop (visible or hidden), ask what it is searching for, and give the search a data structure that answers in O(1) on average: a Set for "have I seen this?" and a Map for "what goes with this key?". The cost is memory and the work of building the structure once, so the pattern pays off when the search happens many times.
Always measure after you change. The step count told you the new version should win; the timing confirms it and tells you by how much. And always keep the old simple version (for example in a test) as a reference to check that the fast version returns the same answers.
Common Mistakes
Mistake 1: Hiding the loop instead of removing it
Using list.contains inside a loop moves the scan out of sight; it does not remove it. Check the documentation or the type of every call in a loop.
Mistake 2: Timing once, or timing the best case
A duplicate check on data whose first two elements are equal returns instantly. Time the worst case (no duplicates) several times.
Mistake 3: Forgetting the cost of the new structure
The set made the code faster by using O(n) memory. State both in your answer.
Mistake 4: Pairing an element with itself
In the pair-sum problem, adding x to the set before checking target - x would report true for {5} with target 10. Check first, then add.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. State time and space for nested, withList, and withSet in one line each.
Show solution A1
A1. nested: O(n²) time, O(1) space. withList: O(n²) time (the contains scans), O(n) space. withSet: O(n) average time, O(n) space.
A2. Why is withList not O(n), even though it has a single loop?
Show solution A2
A2. contains on an ArrayList scans the elements, so each iteration does up to i comparisons, and the total is quadratic. The loop count is only part of the cost.
A3. What does HashSet.add return, and how does withSet use that?
Show solution A3
A3. It returns true if the element was not already in the set and false if it was. withSet returns true (duplicate found) when add returns false.
Level B: Application
B1. Write static int firstRepeated(int[] a) that returns the first value whose second occurrence is reached while scanning left to right, or -1 if there is none. For {4, 2, 7, 2, 4} the answer is 2. State time and space.
Show solution B1
B1.
static int firstRepeated(int[] a) {
Set<Integer> seen = new HashSet<>();
for (int x : a) {
if (!seen.add(x)) return x;
}
return -1;
}For {4, 2, 7, 2, 4} it returns 2 (the second 2 is reached before the second 4). O(n) average time, O(n) space.
B2. Use the table: how many times slower is nested than withSet at n = 1,000, 10,000, and 100,000? What does the trend show?
Show solution B2
B2. At 1,000: 931 / 215, about 4 times. At 10,000: 12,059 / 1,274, about 9 times. At 100,000: 1,315,179 / 5,212, about 252 times. The ratio grows with n, which is what a faster-growing algorithm against a slower-growing one should show.
B3. Explain what is wrong with this pair-sum solution and give a test input where it fails:
static boolean pairWrong(int[] a, int target) {
Set<Integer> seen = new HashSet<>();
for (int x : a) {
seen.add(x);
if (seen.contains(target - x)) return true;
}
return false;
}Show solution B3
B3. It adds x before checking, so an element can pair with itself. With {5} and target 10, the loop adds 5, then seen.contains(10 - 5) is true, so it wrongly returns true: there are not two positions. The fix is to check before adding, as pairSet does.
Level C: Challenge
C1. Write static List<String> common(List<String> a, List<String> b) that returns the elements of a that also appear in b, in the order of a, without nested loops. State the complexity in terms of n = a.size() and m = b.size().
Show solution C1
C1.
static List<String> common(List<String> a, List<String> b) {
Set<String> inB = new HashSet<>(b);
List<String> result = new ArrayList<>();
for (String s : a) {
if (inB.contains(s)) result.add(s);
}
return result;
}Building the set costs O(m), the loop O(n) with O(1) average lookups: O(n + m) time on average, O(m) extra space for the set plus the result list. With a.contains-style nested scans it would be O(n × m).
C2. Build the harness from this lesson and measure nested and withSet for n = 2,000, 4,000, 8,000, and 16,000 with all-different values. Record the table. What do you expect the nested column to do each time n doubles? What does it actually do on your machine?
Show solution C2
C2. Expect the nested column to grow about four times each time n doubles (quadratic), and the set column about twice. On a real machine the nested column should be near four times per doubling; the set column is small and noisy, so look at the larger sizes and repeat the runs before concluding. Your own numbers will differ from mine.
Mini Challenge
Choose one problem from the lab list: "find a pair with a given sum" or "does the array contain duplicates".
- Write the O(n²) version and state its time and space.
- Write the
SetorMapversion and state its time and space. - Check that both return the same answer on at least five test inputs, including an empty array and an array with one element.
- Time both for three input sizes of your choice and record a table of best-of-three results.
- Write two sentences: the measured speed-up at the largest size, and the price you paid.
Check your work: the two versions agree on every test input, the table shows the gap growing with n, and you named the extra memory.
Summary & Next Lesson
Key takeaways
- Nested loops over the same data are O(n²); a hash-based
SetorMaplookup turns the inner loop into an O(1) average step, giving O(n). list.containsinside a loop is a hidden loop: it does not fix the problem.- The speed-up costs O(n) extra memory: a time-for-space trade.
- Measure the worst case several times; the gap between versions grows with
n. - Check the fast version against the simple one, and state time and space for both.
Next lesson
This is the last lesson of the module. Apply it in lab BEG-039-LAB1 to analyze eight snippets and rewrite two O(n²) solutions as O(n). The next module, "Linear data structures" (BEG-040), starts with how arrays sit in memory, which explains why some of the costs you counted here are what they are.
Self-check
- What is the complexity of a nested duplicate check, and what makes it slow?
- Why does replacing the inner loop with
list.containsnot help? - What does the
HashSetversion cost in space? - Which input should you time to see the worst case of a duplicate check?
- In pair-sum with a set, why check
containsbeforeadd?
Show answers
- O(n²): it compares each pair of elements.
containson a list scans the elements, so the loop is hidden but still quadratic overall.- O(n) extra space, since the set may hold all the elements.
- An input with no duplicates, so the method must examine everything.
- So an element is not paired with itself.
References
- Oracle, HashSet (Java SE 21 API).
add,remove,contains, andsizeare constant-time assuming a good hash function;addreturnstrueonly if the element was not already present. - Oracle, ArrayList (Java SE 21 API). Operations other than
size,get,set,add(amortized) and the iterators run in linear time (roughly speaking), which coverscontains. - Oracle, System (Java SE 21 API).
nanoTime()is for elapsed time as a difference of two readings. - All timings in this lesson come from one run of the code on JDK 21, on a Windows laptop, using the best of three runs; they will differ on your machine, and the reasons I give for the list version's slowness and the set's sub-linear ratio are plausible explanations I did not verify by profiling.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.