Skip to content
SparkTime, home

Lesson 4 · Foundations (Think Like a Programmer)

Time and Space Complexity Without the Confusion

Big O explained by counting steps: constant, logarithmic, linear, n log n, quadratic and exponential growth, best, average and worst cases, auxiliary and recursion-stack space, with dry runs you can check yourself.

On this page

Key takeaways

  • Count steps as a function of the input size n. Big O keeps only the fastest-growing term and drops constants.
  • Know the ladder: O(1), O(log n), O(n), O(n log n), O(n^2), O(2^n). Each rung is far worse than the last once n gets large.
  • Complexity is stated for a case. Say worst case unless you mean otherwise, and mention average or amortized when it differs.
  • Space complexity counts the extra memory you use (auxiliary space), including the recursion stack, not the input itself.
  • Library calls hide costs. list.remove(0), String concatenation in a loop and list.contains inside a loop are common surprises.

"How fast is your solution?" is the question interviewers ask after every answer. If your reply is "it ran in 0.3 seconds on my laptop", you have told them almost nothing. A different machine, a smaller test, or a busy computer would give a different number.

What does stay the same across machines is how the amount of work grows when the input grows. Double the input: does the work double, quadruple, or barely change? That growth is what complexity describes, and it is simpler than it sounds. You are going to count steps.

What you will learn

#
  • How to count steps as a function of the input size n.
  • What Big O means, and the three rules for simplifying an expression.
  • The common growth rates, and what each looks like in code.
  • Best, average and worst case, with a concrete example.
  • How to count space, including the recursion stack.
  • How to read a piece of code and state its complexity.

Count steps, not seconds

#

Let n be the size of the input, for example the length of an array. A step is one small unit of work: comparing two numbers, reading an array element, adding two values.

Look at three loops:

Code
loop A:  for i in 0..n-1:  do one step              → n stepsloop B:  for i in 0..n-1, for j in 0..n-1:  one step → n × n stepsloop C:  one step, regardless of n                  → 1 step

If n is 1,000, loop A does 1,000 steps, loop B does 1,000,000 and loop C does 1. If n becomes 2,000, loop A does 2,000 steps (double), loop B does 4,000,000 (four times as much) and loop C still does 1. The way each count changes as n changes is its growth rate, and it is what we care about.

Here is a program that counts the steps of several loop shapes for n = 10, 100 and 1000. The only thing it does is count.

Java
// File: Main.javapublic class Main {    static long constantWork(int n) {        return 1;                                   // ignores n completely    }    static long logarithmicWork(int n) {        long steps = 0;        for (int i = 1; i < n; i *= 2) {            // i doubles each time            steps++;        }        return steps;    }    static long linearWork(int n) {        long steps = 0;        for (int i = 0; i < n; i++) {            steps++;        }        return steps;    }    static long linearithmicWork(int n) {        long steps = 0;        for (int i = 0; i < n; i++) {               // n times ...            for (int j = 1; j < n; j *= 2) {        // ... a doubling loop                steps++;            }        }        return steps;    }    static long quadraticWork(int n) {        long steps = 0;        for (int i = 0; i < n; i++) {            for (int j = 0; j < n; j++) {                steps++;            }        }        return steps;    }    static long triangularWork(int n) {        long steps = 0;        for (int i = 0; i < n; i++) {            for (int j = i + 1; j < n; j++) {       // inner loop starts after i                steps++;            }        }        return steps;    }    static long fibCalls(int n) {                   // naive recursion: counts the calls made        if (n <= 1) {            return 1;        }        return 1 + fibCalls(n - 1) + fibCalls(n - 2);    }    public static void main(String[] args) {        System.out.println("n        1   log n       n   n log n         n^2   n(n-1)/2");        int[] sizes = {10, 100, 1000};        for (int n : sizes) {            System.out.printf("%-6d %3d %7d %7d %9d %11d %10d%n", n, constantWork(n), logarithmicWork(n),                    linearWork(n), linearithmicWork(n), quadraticWork(n), triangularWork(n));        }        for (int n : new int[] {10, 20, 30}) {            System.out.println("fib(" + n + ") makes " + fibCalls(n) + " calls");        }    }}
Code
n        1   log n       n   n log n         n^2   n(n-1)/210       1       4      10        40         100         45100      1       7     100       700       10000       49501000     1      10    1000     10000     1000000     499500fib(10) makes 177 callsfib(20) makes 21891 callsfib(30) makes 2692537 calls

Read this table slowly. When n grows from 10 to 1,000 (a hundred times bigger):

  • The constant column stays at 1.
  • The logarithmic column grows from 4 to 10. It barely moves.
  • The linear column grows a hundred times.
  • The n log n column grows 250 times.
  • The quadratic column grows ten thousand times.
  • The recursion-based column explodes. Going from n = 20 to n = 30 multiplies the work by more than a hundred.

This is the whole story of complexity, in one table.

Dry run: counting a loop by hand

#

You should be able to count simple loops on paper. Take triangularWork(5):

i

j runs over

Steps this round

Running total

0

1, 2, 3, 4

4

4

1

2, 3, 4

3

7

2

3, 4

2

9

3

4

1

10

4

none

0

10

The total is 4 + 3 + 2 + 1 + 0 = 10, which equals 5 × 4 / 2. In general this sum is n × (n - 1) / 2. That is about half of n × n, so it grows like n^2.

And the doubling loop for (int i = 1; i < 20; i *= 2):

Iteration

i at the start

Runs?

1

1

yes

2

2

yes

3

4

yes

4

8

yes

5

16

yes

6

32

no, 32 < 20 is false

It runs 5 times. If you keep doubling until you pass n, the number of iterations is about the number of times you can double 1 before reaching n. That count is what mathematicians call log2(n). We just call it "log n" and treat it as "the number of times you can halve n before reaching 1". Binary search, which we see in Searching and Sorting, works this way.

Big O: three simplification rules

#

Big O notation describes how the step count grows, ignoring details that do not matter when n is large. Formally, saying a step count f(n) is O(g(n)) means that for large enough n, f(n) stays below some constant multiple of g(n). In practice, use these rules.

  1. Drop constant factors. 3n steps and 100n steps are both O(n). Doubling the speed of a machine changes a constant factor, not the shape of growth.
  2. Drop lower-order terms. n^2 + 5n + 40 is O(n^2). When n is a million, n^2 is a trillion and 5n is five million, which is noise.
  3. Keep different inputs separate. If a method takes two arrays of sizes a and b and does a × b work, it is O(a × b), not O(n^2). If it processes one after the other, it is O(a + b).

Two more combining rules:

  • Steps in sequence add. A loop of n steps followed by a loop of n^2 steps is O(n + n^2) = O(n^2).
  • Steps nested multiply. A loop of n iterations containing a loop of m iterations is O(n × m).

Worked example. For this code:

Java
for (int i = 0; i < n; i++) {          // n times    total += values[i];                // 1 step}for (int i = 0; i < n; i++) {          // n times    for (int j = 0; j < n; j++) {      // n times each        count++;                       // 1 step    }}

The first loop costs n and the second costs n^2. The total is n + n^2, and we keep the dominant term, so the code is O(n^2).

The common growth rates

#

Notation

Name

In code it usually looks like

Everyday analogy

O(1)

Constant

Array index, HashMap lookup (on average), arithmetic

Opening a book to a bookmarked page

O(log n)

Logarithmic

Halving the problem each step (binary search)

Finding a word in a dictionary by repeatedly splitting it

O(n)

Linear

One loop over the input

Reading every page once

O(n log n)

Linearithmic

Efficient sorting, divide and conquer

Sorting a deck by repeatedly splitting and merging

O(n^2)

Quadratic

Nested loops over the input

Everyone in a class shaking hands with everyone else

O(2^n)

Exponential

Trying every subset, naive recursive Fibonacci

Every light switch in a room being on or off, in all combinations

O(n!)

Factorial

Trying every ordering (permutations)

Every seating order at a table

How large can n be before each one is too slow? Use the rough guide from Problem Solving Fundamentals: O(n^2) is usually fine for n up to a few thousand, O(n log n) and O(n) for n up to hundreds of thousands or millions, O(2^n) only up to about 20 or 25, and O(n!) only up to about 10.

The exponential row of our program shows why. Naive Fibonacci with n = 30 makes 2,692,537 calls. At n = 50 it would make about 40 billion. We fix this with dynamic programming in Dynamic Programming: Learn to Recognize Overlapping Subproblems.

Best, average and worst case

#

The same algorithm can do different amounts of work for different inputs of the same size. That is why complexity is attached to a case.

Take linear search: look through an array until you find the target.

Java
// File: Main.javapublic class Main {    static int comparisons(int[] a, int target) {        int count = 0;        for (int value : a) {            count++;            if (value == target) {                return count;            }        }        return count;    }    public static void main(String[] args) {        int n = 1000;        int[] a = new int[n];        for (int i = 0; i < n; i++) {            a[i] = i;        }        System.out.println("best (first element): " + comparisons(a, 0));        System.out.println("worst (last element): " + comparisons(a, n - 1));        System.out.println("worst (missing):      " + comparisons(a, -1));        long total = 0;        for (int target = 0; target < n; target++) {            total += comparisons(a, target);        }        System.out.println("average over all present targets: " + (double) total / n);    }}
Code
best (first element): 1worst (last element): 1000worst (missing):      1000average over all present targets: 500.5
  • Best case: the target is the first element, so one comparison, O(1).
  • Worst case: the target is last or missing, so n comparisons, O(n).
  • Average case: if the target is equally likely to be anywhere, about n / 2 comparisons, which is still O(n).

By default, when someone asks "what is the complexity?", give the worst case, because it is a guarantee. Mention the others when they differ meaningfully. For example, quicksort is O(n log n) on average but O(n^2) in its worst case, and a hash table lookup is O(1) on average but can degrade in the worst case.

Amortized cost

#

Some operations are usually cheap and occasionally expensive. An ArrayList is backed by an array. When the array is full, add allocates a bigger one and copies everything over. A single add can cost O(n), yet a long series of adds is cheap per element. We call that amortized O(1). This small program counts the copies for a list that doubles its capacity each time it is full:

Java
// File: Main.javapublic class Main {    public static void main(String[] args) {        int capacity = 1;        int size = 0;        long copies = 0;        int n = 1000;        for (int i = 0; i < n; i++) {            if (size == capacity) {                copies += size;      // every existing element is copied to the bigger array                capacity *= 2;            }            size++;        }        System.out.println("added " + n + " elements, copied " + copies + " in total, capacity " + capacity);    }}
Code
added 1000 elements, copied 1023 in total, capacity 1024

Adding 1,000 elements caused 1,023 copies in total. The copies cost 1 + 2 + 4 + ... + 512, which is always less than twice the final size, so the average extra cost per add is a constant. (Java's ArrayList grows by a smaller factor than 2, but the reasoning is the same.)

Space complexity

#

Space complexity measures how much extra memory your algorithm uses as n grows. We usually count the auxiliary space: the additional memory beyond the input itself.

Code

Extra space

Why

Reverse an array by swapping from both ends

O(1)

A couple of index variables and one temporary

Reverse by copying into a new array

O(n)

A second array of the same size

Sum an array with a loop

O(1)

One running total

Build a HashSet of all values

O(n)

The set stores up to n items

Build a 2D table of size n × n

O(n^2)

n^2 cells

Recursive sum of an array of length n

O(n)

The call stack holds n pending calls

The recursion stack counts

#

Every recursive call that has not yet returned takes a little memory on the call stack. A recursive method that goes n levels deep before returning uses O(n) space even if it creates no arrays. A method that halves the problem at each call, like binary search, goes only O(log n) deep. Java also has a limit on how deep the stack can grow, so very deep recursion fails with a StackOverflowError. We investigate this in Recursion and Backtracking.

Time and space often trade against each other

#

In the very first article, hasDuplicateSet was much faster than the brute force, but it used O(n) extra space for the set while the brute force used O(1). This kind of trade, spending memory to save time, shows up in hashing, prefix sums and dynamic programming. Always ask yourself which resource is scarcer for the problem at hand.

Reading code for complexity

#

Use this procedure on any piece of code:

  1. Identify what n is. If there are several independent inputs, give them separate names.
  2. Find the loops and recursive calls.
  3. For each loop, ask how many times it runs, in terms of n.
  4. Add the costs of code in sequence. Multiply the costs of nested code.
  5. Look for hidden costs inside library calls.
  6. Drop constants and lower-order terms.

Let us practise on a few shapes.

A. Two sequential loops

Java
for (int i = 0; i < n; i++) { ... }for (int j = 0; j < n; j++) { ... }

n + n = 2n, so O(n).

B. A loop inside a loop where the inner one shrinks

Java
for (int i = 0; i < n; i++) {    for (int j = i + 1; j < n; j++) { ... }}

This is the triangular sum, n × (n - 1) / 2, so O(n^2).

C. A loop whose counter doubles, inside a loop that runs n times

Java
for (int i = 0; i < n; i++) {    for (int j = 1; j < n; j *= 2) { ... }}

n × log n, so O(n log n). It matches the "n log n" column in the table.

D. Hidden costs

Java
for (int i = 0; i < n; i++) {    if (list.contains(values[i])) { ... }   // contains walks the list: O(n)}

The loop runs n times and list.contains is itself O(n), so the code is O(n^2) even though there is only one loop in sight. Using a HashSet instead makes each check O(1) on average, and the whole thing O(n).

Complexity of recursion, informally

#

For recursion, think of the recursion tree: each call is a node, and its recursive calls are its children. The total work is the sum of work over all nodes.

Recursion shape

Calls

Typical time

One call on n - 1, constant work each

n levels, 1 call each

O(n)

One call on n / 2, constant work each

log n levels, 1 call each

O(log n)

Two calls on n - 1

calls double at each level

O(2^n)

Two calls on n / 2, O(n) work to combine

log n levels, n work per level

O(n log n)

The last row is merge sort. The second-to-last row is naive Fibonacci, which is why 30 gave 2.7 million calls. We use this tree thinking again in the recursion, sorting and dynamic programming articles.

Edge cases and common mistakes

#
  • Confusing the input size. For a method that takes a string of length m and an array of length n, say so. Do not call both n without saying so.
  • Forgetting hidden costs. String concatenation in a loop, list.remove(0), list.contains, substring and equals on long strings are all not O(1).
  • Calling a nested loop O(n^2) when the bounds differ. If the inner loop runs a fixed 26 times (the alphabet), the total is O(26 n) = O(n).
  • Ignoring space. An answer that is fast but needs O(n^2) memory may crash for large inputs.
  • Believing O(n) is always faster than O(n log n). For small n, or with a large constant factor, it may not be. Big O describes growth. It does not tell you the winner for every n.
  • Treating the number itself as the size. For a function like isPrime(n), the loop to sqrt(n) is fast in terms of the value of n, but the size of the input in bits is only about log n. This subtlety matters for some theory topics, and it explains why dynamic programming solutions that loop up to a target value are called pseudo-polynomial. You do not need it yet.
  • Mixing up Big O with best case. Big O is not "best case". It is an upper bound that can describe any case.

Exercises

#

Exercise 1

Count exactly

#

For n = 5, how many times does count++ run in this code? Give the general formula and the Big O.

Java
for (int i = 0; i < n; i++) {    for (int j = i; j < n; j++) {        count++;    }}

Exercise 2: Name the complexity

#

Give the time complexity of each fragment in terms of the variables shown.

Java
// (a)for (int i = 0; i < n; i += 2) { work(); }// (b)for (int i = n; i > 0; i /= 2) { work(); }// (c)for (int i = 0; i < n; i++) {    for (int j = 0; j < n; j++) {        for (int k = 0; k < 10; k++) { work(); }    }}// (d)for (int i = 0; i < a; i++) {    for (int j = 0; j < b; j++) { work(); }}// (e)void f(int n) {    if (n == 0) return;    f(n - 1);    f(n - 1);}

Exercise 3: Space

#

State the auxiliary space of: (a) reverseCopy, which builds a new array and copies the elements in reverse order; (b) reverseInPlace, which swaps elements from both ends; (c) sumRecursive(int[] a, int i), which returns a[i] + sumRecursive(a, i + 1) until i reaches the end.

Exercise 4: Find the hidden cost

#

Each method below looks like one loop, but is slower than it appears. Give the true time complexity, then rewrite each one to run in O(n).

Java
static String joinWithCommas(List<Integer> values) {    String result = "";    for (int v : values) {        result += v + ",";    }    return result;}static List<Integer> dedupe(List<Integer> values) {    List<Integer> result = new ArrayList<>();    for (int v : values) {        if (!result.contains(v)) {            result.add(v);        }    }    return result;}static long drain(List<Integer> values) {    long sum = 0;    while (!values.isEmpty()) {        sum += values.remove(0);    }    return sum;}

Exercise 5: Interview-style

#

An interviewer says: "The input array can have up to 200,000 elements. Find whether any two numbers add up to a given target." Your first idea compares every pair. What do you say about its speed, and what would you propose instead? Support your answer with numbers.

Show solutionHide solution

For n = 5, the inner loop starts at i, so it runs 5, 4, 3, 2, 1 times for i = 0, 1, 2, 3, 4. The total is 5 + 4 + 3 + 2 + 1 = 15.

In general it is n + (n - 1) + ... + 1 = n × (n + 1) / 2. For n = 5 that gives 5 × 6 / 2 = 15. The leading term is n^2 / 2, so the time is O(n^2).

Compare with triangularWork from the article, where the inner loop started at i + 1 and the total was n × (n - 1) / 2. A tiny change in the starting point shifts the exact count by n, but the growth rate is the same.

Exercise 2

Show solutionHide solution
  • (a) i goes 0, 2, 4, ... up to n. That is about n / 2 iterations. Drop the constant: O(n).
  • (b) i halves each time: n, n/2, n/4, ..., 1. That is about log n iterations: O(log n).
  • (c) n × n × 10 = 10 n^2. Drop the constant: O(n^2). The innermost loop has a fixed bound, so it is a constant factor.
  • (d) a × b: O(a × b). Do not collapse it to n^2, because a and b are different inputs. If you are told a = b = n, then it is O(n^2).
  • (e) Each call makes two calls on n - 1, so the number of calls doubles at every level: 1 + 2 + 4 + ... + 2^n, which is O(2^n).

Exercise 3

Show solutionHide solution
  • (a) reverseCopy allocates a second array of size n, so auxiliary space is O(n). (The returned array counts as output. Many people still mention it. If the problem says "do not count the output", say that you are not counting it.)
  • (b) reverseInPlace uses two indexes and a temporary variable, so O(1).
  • (c) sumRecursive makes n nested calls before the first one returns, so the stack holds n frames: O(n). A loop would do the same job in O(1) space. For a large array, the recursive version could also hit StackOverflowError.

Exercise 4

Show solutionHide solution
  1. joinWithCommas is O(n^2) in characters copied. Each += builds a brand-new string by copying the old one plus the new text, so the work is 1 + 2 + ... + n times the piece length. Use a StringBuilder, which appends in amortized O(1) per character.
  2. dedupe is O(n^2), because ArrayList.contains scans the list. Keep a HashSet of values already seen: contains and add are O(1) on average. The order of the result list stays the same.
  3. drain is O(n^2), because remove(0) shifts every remaining element one place left. Loop by index, or use an ArrayDeque and poll(), which is O(1). (We only need the sum, so a simple loop is enough.)

All three fixed versions below are checked against the slow versions on a sample.

Java
// File: Main.javaimport java.util.ArrayList;import java.util.HashSet;import java.util.List;import java.util.Set;public class Main {    static String joinSlow(List<Integer> values) {        String result = "";        for (int v : values) {            result += v + ",";        }        return result;    }    static String joinFast(List<Integer> values) {        StringBuilder result = new StringBuilder();        for (int v : values) {            result.append(v).append(',');        }        return result.toString();    }    static List<Integer> dedupeSlow(List<Integer> values) {        List<Integer> result = new ArrayList<>();        for (int v : values) {            if (!result.contains(v)) {                result.add(v);            }        }        return result;    }    static List<Integer> dedupeFast(List<Integer> values) {        Set<Integer> seen = new HashSet<>();        List<Integer> result = new ArrayList<>();        for (int v : values) {            if (seen.add(v)) {          // add returns false if the value was already present                result.add(v);            }        }        return result;    }    static long drainSlow(List<Integer> values) {        List<Integer> copy = new ArrayList<>(values);        long sum = 0;        while (!copy.isEmpty()) {            sum += copy.remove(0);        }        return sum;    }    static long drainFast(List<Integer> values) {        long sum = 0;        for (int v : values) {            sum += v;        }        return sum;    }    public static void main(String[] args) {        List<Integer> sample = List.of(4, 8, 4, 15, 8, 16, 23, 42);        System.out.println(joinSlow(sample).equals(joinFast(sample)) + " " + joinFast(sample));        System.out.println(dedupeSlow(sample).equals(dedupeFast(sample)) + " " + dedupeFast(sample));        System.out.println((drainSlow(sample) == drainFast(sample)) + " " + drainFast(sample));    }}
Code
true 4,8,4,15,8,16,23,42,true [4, 8, 15, 16, 23, 42]true 120

Note that drainFast no longer empties the list, which is a different side effect. If you must consume the list, use an ArrayDeque and poll().

Exercise 5

Show solutionHide solution

Estimate the brute force. Comparing every pair for n = 200,000 is about n × (n - 1) / 2, which is about 2 × 10^10, twenty billion comparisons. At very roughly a hundred million simple operations per second, that is a few minutes, far beyond a typical time limit of a second or two. So the pair-checking idea is too slow for these constraints. I would say that out loud, and then propose something faster.

Better ideas.

  1. Hashing. Walk through the array once. For each value x, ask whether target - x has been seen. Remember each x in a HashSet. That is O(n) time on average and O(n) space. (Details in Hashing.)
  2. Sort and use two pointers. Sort the array in O(n log n), then move two indexes inwards from both ends. That is O(n log n) time and O(1) extra space, if sorting in place is allowed. (Details in Two Pointers.)

For n = 200,000, n log2 n is about 3.5 million steps, and n is 200,000. Both are tiny next to twenty billion.

A good answer to the interviewer also states the trade-off: hashing is faster but uses extra memory, while sorting uses little extra space but costs a bit more time. Mention which you would pick and why. For example: "I would pick hashing unless memory is tight."

Where to go next

#
  • Next: Arrays and Array-Based Problem Solving starts the data-structures module, and puts complexity to work at once.
  • Revisit later: the table in this article is useful at every step. Come back to it when you meet sorting, hashing and dynamic programming.
  • Practice habit: from now on, state the time and space complexity of every solution you write, in a comment above the method.
  • Guide · Technology

    How to Learn Data Structures and Algorithms Without Burning Out

  • Guide · Career

    The OA Landscape: Platforms, Formats, Proctoring and Rules

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages