Part 2 · Lesson 12 · 24 min read
Big-O: Counting the Steps
How many times does the loop run? Reading time and space complexity from code, and from the constraints of a problem.
On this page
- Why we count steps, not seconds
- Counting the steps of a loop
- Big-O: keep only what grows
- The growth rates you will meet
- O(log n): the halving loop
- Traced: halving 16 down to 1
- Reading Big-O from code
- Hidden loops in built-in methods
- Space complexity
- Best, average and worst case
- From constraints to approach
- Practice questions (7)
- Make it stick
- What's next
Why we count steps, not seconds
In Lesson 11 we often said that one solution was “faster” than another. How can we know this before we run the code? A stopwatch does not help much. The same code runs at different speeds on a laptop and on a phone. And with a small input, almost every solution looks instant.
So we count steps instead. A step is one simple operation, such as a comparison, an addition or reading arr[i]. Then we ask one question: when the input gets bigger, how fast does the number of steps grow? We write the answer in Big-O notation. Big-O is a short way to describe how the work grows when the input size grows. Every interviewer uses it to talk about efficiency. The letter n always means the size of the input, for example the length of an array.
Counting the steps of a loop
Take one of the simplest loops, adding up an array, and count its steps:
function sum(nums) { let total = 0; // 1 step for (let i = 0; i < nums.length; i++) { // n + 1 checks, n updates total += nums[i]; // n steps } return total; // 1 step}If you add up the numbers in the comments, you get 1 + (n + 1) + n + n + 1 = 3n + 3 steps for an array of length n. (Different people count slightly different steps. For example, we did not count let i = 0. That is fine, as you will see.) Check the formula for a few sizes:
| n (length) | 3n + 3 | Growth |
|---|---|---|
| 10 | 33 | |
| 100 | 303 | ×10 input → about ×10 steps |
| 1,000 | 3,003 | ×10 input → about ×10 steps |
| 1,000,000 | 3,000,003 | ×1000 input → about ×1000 steps |
Double the input and the work doubles. That straight-line growth is what O(n) means.
Big-O: keep only what grows
Exact counts like 3n + 3 depend on small details. For example, does i++ count as one step or as two? Big-O ignores such details. It uses two rules:
- Drop constant factors. A constant factor is a fixed number that you multiply by. 3n and n grow in the same way: if you double n, the work doubles. So 3n becomes n.
- Keep only the fastest-growing term. Look at n² + n + 3. When n is 1,000, the n² part is 1,000,000 and the rest is only about 1,000. The small terms stop mattering. So n² + n + 3 becomes n².
| Exact count | Big-O | Said as |
|---|---|---|
| 3n + 3 | O(n) | linear |
| 5 | O(1) | constant |
| n²/2 + n/2 | O(n²) | quadratic |
| 2n + 100 | O(n) | linear |
| n² + 1000n | O(n²) | quadratic |
So the sum function is O(n). Say it “order n” or “linear time”. This measure of how the number of steps grows with the input is called time complexity.
The growth rates you will meet
Almost every solution in this series is one of these growth rates. They are in order from fastest to slowest:
| Big-O | Name | Typical code | Example |
|---|---|---|---|
| O(1) | constant | No loop over the input | arr[i], Map get, a maths formula |
| O(log n) | logarithmic | Halve the problem each step | Binary search (Lesson 28) |
| O(n) | linear | One loop | Sum, max, linear search |
| O(n log n) | n log n | Good sorting | arr.sort(), merge sort (Lesson 17) |
| O(n²) | quadratic | A loop inside a loop | Checking every pair |
| O(2ⁿ) | exponential | Try every subset (every possible group of items) | Subsets (Lesson 33) |
The difference between these rates is very big. Here are the step counts as n grows:
| n | log n | n | n log n | n² | 2ⁿ |
|---|---|---|---|---|---|
| 10 | ≈ 3 | 10 | ≈ 33 | 100 | 1,024 |
| 100 | ≈ 7 | 100 | ≈ 660 | 10,000 | ≈ 10³⁰ |
| 1,000 | ≈ 10 | 1,000 | ≈ 10,000 | 1,000,000 | far too many |
| 1,000,000 | ≈ 20 | 1,000,000 | ≈ 20,000,000 | 10¹² | far too many |
As a rough rule, a computer does about 10⁸ (100 million) simple steps per second. For n = 1,000,000: O(n) takes about 0.01 seconds, O(n log n) takes about 0.2 seconds, and O(n²) takes about 3 hours.
For large inputs, the growth rate matters much more than the speed of the computer or small changes to the code. Often, the main improvement that an interviewer looks for is to change an O(n²) solution into an O(n) solution.
O(log n): the halving loop
One growth rate needs more explanation. A logarithm (log for short) answers this question: how many times can I halve n before I reach 1? The small 2 in log₂ means “halve”.
- 16 → 8 → 4 → 2 → 1: four halvings, so log₂ 16 = 4.
- 1,024 → … → 1: ten halvings, so log₂ 1,024 = 10.
- 1,000,000 → … → 1: about twenty halvings.
So any loop that throws away half of what is left on each step is O(log n). It grows very slowly. Even for a billion items, it runs only about 30 times.
In Big-O, the base of the logarithm does not matter. (Dividing by 10 instead of 2 changes the work only by a constant factor.) So we just write log n. You have already met one such loop. The digit loop from Lesson 5 divides by 10 each time, so it is O(log n) in the size of the number.
Traced: halving 16 down to 1
n is the size of the problem.
▶let n = 16;2let steps = 0;3while (n > 1) {4 n = Math.floor(n / 2);5 steps++;6}7console.log(steps);| n | 16 |
Reading Big-O from code
You rarely need to count exactly. Look at the shape of the loops instead. Here are four rules:
- One after another → add. Two separate loops over n are n + n = 2n, which is O(n).
- One inside another → multiply. A loop of n inside a loop of n is n × n = O(n²).
- Halving or doubling → log. A loop variable that is multiplied or divided by 2 each time gives O(log n).
- Two inputs → two letters. If there are two arrays of lengths n and m, write O(n + m) or O(n × m). Do not merge them into one n.
// O(1) — the same work for any nconst first = nums[0];// O(n) — one loop over the inputfor (let i = 0; i < n; i++) { /* ... */ }// O(n) — two loops one after the other: n + n = 2n → O(n)for (let i = 0; i < n; i++) { /* ... */ }for (let j = 0; j < n; j++) { /* ... */ }// O(n²) — a loop inside a loop: n × nfor (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { /* ... */ }}// O(n²) — the inner loop starts at i + 1: about n²/2, still O(n²)for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { /* ... */ }}// O(log n) — the loop variable doubles (or n halves) each timefor (let i = 1; i < n; i *= 2) { /* ... */ }// O(n + m) — two different inputs, one after the otherfor (const a of arrA) { /* ... */ }for (const b of arrB) { /* ... */ }// O(n × m) — two different inputs, one inside the otherfor (const a of arrA) { for (const b of arrB) { /* ... */ }}Hidden loops in built-in methods
Some JavaScript methods look like one step, but they run a loop inside. If you put one of these inside your own loop, an O(n) solution becomes O(n²). You will not see a nested loop in your code, but it is there.
| Operation | Cost | Why |
|---|---|---|
arr[i], arr.length, push, pop | O(1) | Works at one known position. (push and pop are O(1) on average) |
Map / Set: get, set, has, add, delete | O(1) on average | Jumps straight to the key (Lesson 15) |
includes, indexOf, find | O(n) | Linear search through the array |
shift, unshift, splice | O(n) | Every later item may have to move one position (some engines can make shift faster, but you should not rely on it) |
slice, [...arr], concat, join | O(n) | Copies every item |
new Set(arr), map, filter, reduce | O(n) | Visits every item |
sort | O(n log n) | Sorting (Lessons 16–18) |
// Looks like one loop, but it is really two.function commonValues(a, b) { const result = []; for (const x of a) { // runs n times if (b.includes(x)) { // includes scans b: up to m steps result.push(x); } } return result; // O(n × m) in total}// Same result, but only one pass over each arrayfunction commonValuesFast(a, b) { const inB = new Set(b); // m steps, once const result = []; for (const x of a) { // n times if (inB.has(x)) { // 1 step result.push(x); } } return result; // O(n + m)}Space complexity
Time is not the only cost. Space complexity measures how much extra memory a solution needs as the input grows. We do not count the input itself. We use the same Big-O notation for it.
| Code | Extra space |
|---|---|
A few number variables (total, i, max) | O(1) |
| A copy of the array, or a new array of results | O(n) |
| A Set or Map holding every value | O(n) |
| An n × n grid | O(n²) |
Many improvements trade space for time. This means you use more memory to save time. In the hidden-loop example above, commonValuesFast uses an extra Set (O(m) space). In return, the time drops from O(n × m) to O(n + m). This is usually a good trade. Be ready to say it out loud, for example: “this is O(n) time and O(n) extra space”. Interviewers expect both numbers.
Best, average and worst case
The same code can do very different amounts of work for different inputs. Take linear search:
function indexOf(nums, target) { for (let i = 0; i < nums.length; i++) { if (nums[i] === target) return i; } return -1;}| Case | When | Steps |
|---|---|---|
| Best | target is the first item | 1 → O(1) |
| Average | target is somewhere in the middle | about n/2 → O(n) |
| Worst | target is last, or not there at all | n → O(n) |
Unless someone says otherwise, we quote the worst case. It is the only one you can promise, because the best case depends on luck. The common exception is Map and Set. For them we quote the average case, O(1), because the worst case almost never happens in practice.
From constraints to approach
This is where Big-O helps you most in interviews. Take the constraints (Lesson 11) and the rule of thumb of about 108 simple steps per second. Then the size of n tells you which growth rate you need before you design the solution:
| If n is at most… | This is fast enough | Typical technique |
|---|---|---|
| about 10 | O(n!) | Try every ordering (n! means n × (n−1) × … × 1, and a “permutation” is one ordering) |
| about 20 | O(2ⁿ) | Try every subset (backtracking: try a choice, and go back if it fails) |
| about 500 | O(n³) | Three nested loops |
| about 5,000 | O(n²) | Check every pair |
| 105 to 106 | O(n log n) or O(n) | Sorting, one pass with a Map or Set, two pointers |
| 109 or more | O(log n) or O(1) | Binary search, a maths formula |
secondLargest allowed up to 105 values. O(n²) would be 1010 steps, which is far too slow. So we needed O(n log n) (sort) or O(n) (one pass). Both of our solutions are fast enough. In “Maximum product of two elements”, n was at most 500, so even the O(n²) pair check was fine.
Practice questions
For every solution below, say its time complexity and space complexity before you read the explanation. Saying both numbers should become automatic for you.
Give the time complexity of each snippet. Assume work() is O(1).
// Afor (let i = 0; i < n; i++) for (let j = 0; j < n; j++) work();// Bfor (let i = 0; i < n; i++) work();for (let j = 0; j < n; j++) work();// Cfor (let i = 1; i < n; i *= 2) work();// Dfor (let i = 0; i < n; i++) for (let j = 0; j < i; j++) work();// Efor (let i = 0; i < n; i++) for (let j = 0; j < 5; j++) work();// Ffor (const x of nums) if (other.includes(x)) work(); // both arrays have length nHint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
Count how many times work() runs for bigger and bigger n. Then see how the count changes when n is multiplied by 10.
function count(n) { const c = { A: 0, B: 0, C: 0, D: 0, E: 0 }; for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) c.A++; for (let i = 0; i < n; i++) c.B++; for (let j = 0; j < n; j++) c.B++; for (let i = 1; i < n; i *= 2) c.C++; for (let i = 0; i < n; i++) for (let j = 0; j < i; j++) c.D++; for (let i = 0; i < n; i++) for (let j = 0; j < 5; j++) c.E++; return c;}console.log(count(10)); // { A: 100, B: 20, C: 4, D: 45, E: 50 }console.log(count(100)); // { A: 10000, B: 200, C: 7, D: 4950, E: 500 }console.log(count(1000)); // { A: 1000000, B: 2000, C: 10, D: 499500, E: 5000 }Each time n becomes 10 times bigger, A and D become about 100 times bigger (quadratic). B and E become 10 times bigger (linear). C grows by only about 3 (logarithmic). Measuring like this is a good way to check your thinking while you learn.
Look at the limit of each loop and how its variable changes. Then use the rules: add for loops one after another, multiply for loops inside loops.
// A n × n → O(n²)// B n + n = 2n → O(n)// C 1, 2, 4 … < n → O(log n)// D 0 + 1 + … + (n-1) → n(n-1)/2 → O(n²)// E n × 5 → O(n)// F n × (includes: n) → O(n²)This is what you do in an interview. You do not run the code. You only read its shape. All six snippets use only a few variables, so every one needs O(1) extra space.
Most people get D and F wrong. D looks like “half as much work” as A, but halving is only a constant factor. F has only one loop that you can see, but includes is a second, hidden loop.
Return 1 + 2 + … + n. Constraints: 1 ≤ n ≤ 108.
Hint 1
Hint 2
Show all 2 solutions with explanations
Add every number from 1 to n to a running total (an accumulator).
function sumTo(n) { let total = 0; for (let i = 1; i <= n; i++) total += i; return total;}console.log(sumTo(5)); // 15Time O(n), space O(1). For n = 108 this is 100 million additions. That takes roughly between a tenth of a second and a second in JavaScript.
There are n/2 pairs, and each pair adds up to n + 1. So the total is n × (n + 1) / 2.
function sumTo(n) { return (n * (n + 1)) / 2;}console.log(sumTo(5)); // 15console.log(sumTo(100000000)); // 5000000050000000Time O(1), space O(1). It is one multiplication and one division, for any n. You used this formula in “Missing number” in Lesson 11.
The constraints decide. If n ≤ 1,000, either way is fine. If n can be 109, only the O(1) formula is acceptable. This is the “109 or more” row of the table.
Return true if there are two different positions i and j with arr[i] === 2 * arr[j].
Hint 1
Hint 2
2 * x and x / 2 would.Hint 3
Show all 2 solutions with explanations
For every pair of different positions, check whether one value is double the other value.
function checkIfExist(arr) { for (let i = 0; i < arr.length; i++) { for (let j = 0; j < arr.length; j++) { if (i !== j && arr[i] === 2 * arr[j]) return true; } } return false;}console.log(checkIfExist([10, 2, 5, 3])); // trueconsole.log(checkIfExist([3, 1, 7, 11])); // falseconsole.log(checkIfExist([0, 0])); // trueconsole.log(checkIfExist([0])); // falseTime O(n²), space O(1). The check i !== j is what makes [0] return false.
For each value, ask the Set whether its double or its half was seen earlier. Then add the value to the Set.
function checkIfExist(arr) { const seen = new Set(); for (const x of arr) { if (seen.has(2 * x) || seen.has(x / 2)) return true; seen.add(x); } return false;}console.log(checkIfExist([10, 2, 5, 3])); // trueconsole.log(checkIfExist([3, 1, 7, 11])); // falseconsole.log(checkIfExist([0, 0])); // trueconsole.log(checkIfExist([0])); // falseTime O(n), space O(n). We check before we add, so a value is never matched with itself. That is why [0] gives false and [0, 0] gives true. (For an odd x, x / 2 is a fraction, and a fraction is never in the Set.)
With up to 500 values (the LeetCode limit), both pass. Still, give the Set version and say the trade: you use O(n) extra space to go from O(n²) to O(n) time. (LeetCode 1346.)
Return an array of the values that appear in both nums1 and nums2. Each value must appear only once in the result, in any order.
Hint 1
Hint 2
includes inside a loop is O(n × m).Hint 3
Show all 3 solutions with explanations
For each value of the first array, check the second array with includes. Do not add a value twice.
function intersection(nums1, nums2) { const result = []; for (const x of nums1) { if (nums2.includes(x) && !result.includes(x)) result.push(x); } return result;}console.log(intersection([1, 2, 2, 1], [2, 2])); // [ 2 ]console.log(intersection([4, 9, 5], [9, 4, 9, 8, 4])); // [ 4, 9 ]Time O(n × m), because of the hidden loop. There is also another hidden loop in result.includes. Space is O(1) beyond the result. The code is short, but it is slow for large inputs.
Turn the first array into a Set. Walk through the second array. Collect the matches in another Set, so that duplicates disappear.
function intersection(nums1, nums2) { const inFirst = new Set(nums1); const result = new Set(); for (const x of nums2) { if (inFirst.has(x)) result.add(x); } return [...result];}console.log(intersection([1, 2, 2, 1], [2, 2])); // [ 2 ]console.log(intersection([4, 9, 5], [9, 4, 9, 8, 4])); // [ 9, 4 ]Time O(n + m): one pass to build the Set and one pass to check. Space O(n) for the Set.
Sort both arrays. Then use one position (a pointer) in each array. Always move the pointer that points to the smaller value.
function intersection(nums1, nums2) { const a = [...nums1].sort((x, y) => x - y); const b = [...nums2].sort((x, y) => x - y); const result = []; let i = 0, j = 0; while (i < a.length && j < b.length) { if (a[i] < b[j]) i++; else if (a[i] > b[j]) j++; else { if (result[result.length - 1] !== a[i]) result.push(a[i]); i++; j++; } } return result;}console.log(intersection([4, 9, 5], [9, 4, 9, 8, 4])); // [ 4, 9 ]Time O(n log n + m log m) for the sorting. The walk itself is only O(n + m). This is a first look at the two-pointer pattern (Lesson 21).
Two Sets is the standard answer. It is the fastest and it is easy to explain. If the interviewer adds “both arrays are already sorted”, Approach 3 needs only O(n + m) time and no Set. (LeetCode 349.)
Given a whole number n (0 ≤ n ≤ 109), return how many digits it has.
Hint 1
Hint 2
Show all 3 solutions with explanations
Remove the last digit with Math.floor(n / 10) (it divides by 10 and rounds down). Repeat until n is 0, and count each removal.
function countDigits(n) { if (n === 0) return 1; let count = 0; while (n > 0) { n = Math.floor(n / 10); count++; } return count;}console.log(countDigits(7)); // 1console.log(countDigits(12345)); // 5console.log(countDigits(0)); // 1| n | count |
|---|---|
| 12345 | 0 |
| 1234 | 1 |
| 123 | 2 |
| 12 | 3 |
| 1 | 4 |
| 0 | 5 |
Time O(log n): the loop runs once per digit, and a number n has about log₁₀ n digits. Space O(1).
Turn the number into a string with String(n). The length of the string is the number of digits.
function countDigits(n) { return String(n).length;}console.log(countDigits(12345)); // 5console.log(countDigits(0)); // 1This is also O(log n), because building the string still visits every digit. The work is just hidden inside String. Space is O(log n) for the string.
A number with d digits is at least 10d−1 and less than 10d. So d = floor(log₁₀ n) + 1. (floor rounds down.)
function countDigits(n) { if (n === 0) return 1; return Math.floor(Math.log10(n)) + 1;}console.log(countDigits(7)); // 1console.log(countDigits(12345)); // 5console.log(countDigits(1000)); // 4This shows exactly where the “log” comes from: the digit count is based on the logarithm. Math.log10(0) is -Infinity, so 0 still needs its own check. (Computers store decimals with tiny rounding errors. For n up to 109 this formula is fine, but Approach 1 never has this risk.)
All three are correct. In an interview, give Approach 1 and say “O(log n) time, because each step removes one digit”. That sentence is the point of the question. (Related: LeetCode 1295.)
Reverse the order of the items in nums. Give the time and the extra space of each solution.
Hint 1
Hint 2
Show all 2 solutions with explanations
Walk from the end to the start. Push each item into a new array.
function reversed(nums) { const out = []; for (let i = nums.length - 1; i >= 0; i--) out.push(nums[i]); return out;}console.log(reversed([1, 2, 3, 4, 5])); // [ 5, 4, 3, 2, 1 ]console.log(reversed([])); // []Time O(n), space O(n) for the new array. The original array is unchanged. Sometimes that is exactly what you want.
Use two positions, one at each end. Swap their items. Move both positions inwards until they meet.
function reverseInPlace(nums) { let left = 0; let right = nums.length - 1; while (left < right) { const tmp = nums[left]; nums[left] = nums[right]; nums[right] = tmp; left++; right--; } return nums;}console.log(reverseInPlace([1, 2, 3, 4, 5])); // [ 5, 4, 3, 2, 1 ]console.log(reverseInPlace([])); // []Time O(n), because there are about n/2 swaps. Space O(1), because it uses only left, right and tmp, however big the array is.
The time is the same, but the space is different. If the statement says “in place” or “O(1) extra space”, only Approach 2 is accepted. If it says “do not modify the input”, only Approach 1 is accepted. This is a clarifying question that is worth asking (Lesson 11).
nums has n values, each between 1 and n. Some values appear twice and some do not appear at all. Return every number from 1 to n that does not appear.
Hint 1
nums.includes(k). What is the Big-O?Hint 2
Hint 3
Show all 3 solutions with explanations
For every number from 1 to n, check with includes whether it is in the array.
function findDisappearedNumbers(nums) { const result = []; for (let k = 1; k <= nums.length; k++) { if (!nums.includes(k)) result.push(k); } return result;}console.log(findDisappearedNumbers([4, 3, 2, 7, 8, 2, 3, 1])); // [ 5, 6 ]Time O(n²), because there are n candidates and each one has a hidden O(n) search. Space O(1). With n up to 105, that is about 1010 steps, which is too slow.
Put all values in a Set. Then check each number from 1 to n.
function findDisappearedNumbers(nums) { const present = new Set(nums); const result = []; for (let k = 1; k <= nums.length; k++) { if (!present.has(k)) result.push(k); } return result;}console.log(findDisappearedNumbers([4, 3, 2, 7, 8, 2, 3, 1])); // [ 5, 6 ]console.log(findDisappearedNumbers([1, 1])); // [ 2 ]Time O(n), space O(n). Most interviewers expect this answer first.
- For each value v, make the number at position
v − 1negative (if it is not already negative). - Use
Math.abs(it removes the minus sign) when you read a value, because earlier steps may have made it negative. - If position i is still positive at the end, the value
i + 1never appeared.
function findDisappearedNumbers(nums) { for (const x of nums) { const i = Math.abs(x) - 1; if (nums[i] > 0) nums[i] = -nums[i]; } const result = []; for (let i = 0; i < nums.length; i++) { if (nums[i] > 0) result.push(i + 1); } return result;}console.log(findDisappearedNumbers([4, 3, 2, 7, 8, 2, 3, 1])); // [ 5, 6 ]console.log(findDisappearedNumbers([1, 1])); // [ 2 ]Time O(n), extra space O(1). The result array is the output, so we do not count it. The cost is that the input array is changed. So ask first whether that is allowed.
This is a good example of trading space for time, and then trading back. The three solutions are O(n²) time and O(1) space, then O(n) time and O(n) space, then O(n) time and O(1) space. Give the Set version first. Mention the in-place version if the interviewer asks for O(1) extra space. (LeetCode 448.)
Make it stick
- Write the six growth rates in order, from fastest to slowest, with one example of each.
- Explain why a loop that halves n is O(log n), using 16 as the example.
- Name three array methods that hide an O(n) loop, and say why putting them inside a loop is a problem.
- From memory, fill in the “if n is at most… then this is fast enough” table.
Revisit schedule: solve two of the questions again tomorrow, again in 3 days, and once more in a week — without looking at the answers. Each time it will feel easier. That is the memory forming.
What's next
You can now read a problem carefully and judge how fast a solution must be. Lesson 13 covers basic maths that appears in many problems: divisors, prime numbers, the greatest common divisor and fast powers. Several of these ideas turn an O(n) loop into O(√n) or O(log n). You now have the words to say exactly why that matters.
Practise on LeetCode
Real interview problems that use exactly what this lesson teaches. Solve them in JavaScript after the practice questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series