Lesson 1 of 164
Big O and Time Complexity
Time complexity describes how the number of steps an algorithm takes grows as the input size n grows. It doesn't measure seconds, which depend on the machine. It measures growth, so you can compare two algorithms on paper.
Counting operations
Take a function that sums an array. The running total is a long, because an int total wraps around silently once it passes Integer.MAX_VALUE:
public class Main {
static long sum(int[] nums) {
long total = 0; // 1 step
for (int x : nums) { // runs n times
total += x; // 1 step each time
}
return total; // 1 step
}
public static void main(String[] args) {
System.out.println(sum(new int[]{3, 1, 4, 1, 5}));
}
}14The exact count is about n + 2 steps. For large n, the + 2 doesn't matter, and neither does whether a loop iteration costs 1 step or 3. Big O keeps only the part that grows fastest:
- Drop constant factors:
3n + 5becomesO(n). - Keep only the largest term:
n² + 10n + 100becomesO(n²). - Different inputs keep separate variables: two arrays of lengths
nandmgiveO(n + m)orO(n·m), notO(n).
Unless stated otherwise, Big O means the worst case. Linear search is O(n) even though it sometimes finds the target at index 0.
Loops
Most of the time, the loops give you the answer.
| Code shape | Steps | Big O |
|---|---|---|
one loop over n items |
n |
O(n) |
| two loops one after another | n + n |
O(n) |
a loop inside a loop, both over n |
n · n |
O(n²) |
inner loop starts at i + 1 |
n(n-1)/2 |
O(n²) |
| loop where the range halves each time | log₂ n |
O(log n) |
a halving loop inside a loop over n |
n · log n |
O(n log n) |
Loops one after another add. Loops inside each other multiply.
public class Main {
static String examples(int[] nums) {
// One loop over n items: O(n)
long sum = 0;
for (int x : nums) sum += x;
// Nested loops over all pairs i < j: n(n-1)/2 checks, O(n²)
int equalPairs = 0;
for (int i = 0; i < nums.length; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] == nums[j]) equalPairs++;
}
}
// The range halves every step: O(log n)
int halvings = 0;
int size = nums.length;
while (size > 1) {
size /= 2;
halvings++;
}
return "(" + sum + ", " + equalPairs + ", " + halvings + ")";
}
public static void main(String[] args) {
System.out.println(examples(new int[]{1, 2, 2, 3, 3, 3, 4, 5}));
}
}(23, 4, 3)The whole function is O(n) + O(n²) + O(log n), which is O(n²).
The "inner loop starts at i + 1" case is a common trap. It does about half the work of a full nested loop, but half of n² is still O(n²).
Logarithmic time
An algorithm is O(log n) when each step throws away a constant fraction of the remaining input. Binary search halves the range on every comparison, so a sorted array of 1,000,000 elements needs at most about 20 comparisons (2²⁰ ≈ 1,000,000).
import java.util.stream.IntStream;
public class Main {
// Returns the index of target, or -1 when it is absent.
static int binarySearch(int[] nums, int target) {
int lo = 0, hi = nums.length; // search in [lo, hi)
int steps = 0;
while (lo < hi) {
steps++;
int mid = lo + (hi - lo) / 2; // lo + hi could overflow int for huge arrays
if (nums[mid] == target) {
System.out.println("found after " + steps + " steps");
return mid;
} else if (nums[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return -1;
}
public static void main(String[] args) {
int[] nums = IntStream.range(0, 1_000_000).toArray();
System.out.println(binarySearch(nums, 765_432));
}
}found after 20 steps
765432A nested loop is not always O(n²)
Count how many times the inner body runs in total, not how deeply the loops are nested. In this two-pointer loop, each step moves left or right inward, so the total work across both loops is O(n):
public class Main {
static boolean hasPairWithSum(int[] sorted, int target) {
int left = 0, right = sorted.length;
while (left + 1 < right) {
int sum = sorted[left] + sorted[right - 1];
if (sum == target) {
return true;
} else if (sum < target) {
left++;
} else {
right--;
}
}
return false;
}
public static void main(String[] args) {
System.out.println(hasPairWithSum(new int[]{1, 3, 4, 6, 9}, 10));
}
}trueThe same reasoning applies to a monotonic stack: every element is pushed once and popped at most once, so a while loop inside a for loop can still be O(n) in total.
Hidden loops
A single line can hide a loop. These are all O(n) on an ArrayList (a resizable array-backed list):
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Integer> v = new ArrayList<>(List.of(5, 1, 4, 2, 3));
boolean hasFour = v.contains(4); // scans the list
v.add(0, 9); // shifts every element right
v.remove(0); // shifts every element left (remove by index)
List<Integer> copy = new ArrayList<>(v); // copies every element
int max = Collections.max(v); // scans the list
String text = v.toString(); // visits every element
System.out.println(hasFour + " " + copy + " " + max + " " + text);
}
}true [5, 1, 4, 2, 3] 5 [5, 1, 4, 2, 3]Calling v.contains(x) inside a loop over v is O(n²), even though only one loop is visible. The same goes for new ArrayList<>(v) or Arrays.copyOfRange(a, i, a.length) inside a loop, and for s += c on a String in a loop: strings are immutable, so each += copies the whole string, and n appends cost O(n²). A StringBuilder appends in amortized O(1). Sorting (Arrays.sort, List.sort) is O(n log n), so "sort, then loop once" is O(n log n) overall.
Recursion
For recursion, count the number of calls and multiply by the work per call. Each call also uses a stack frame, and the thread stack is limited, so very deep recursion (for example sumTo(1_000_000)) throws StackOverflowError.
public class Main {
// n calls, O(1) each: O(n)
static long sumTo(long n) {
return n == 0 ? 0 : n + sumTo(n - 1);
}
// Each call halves n: O(log n) calls
static int countBits(long n) {
return n == 0 ? 0 : 1 + countBits(n / 2);
}
// Each call makes two more calls: about 2ⁿ calls, O(2ⁿ)
static long fib(int n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
public static void main(String[] args) {
System.out.println(sumTo(100) + " " + countBits(1_000_000) + " " + fib(20));
}
}5050 20 6765fib is slow because it solves the same subproblems again and again: fib(18) is computed twice, fib(17) three times, and so on. Remembering results (memoization) or looping upward brings it down to O(n).
A recursive function that visits every node of a tree once is O(n), no matter what shape the tree has.
Common complexity classes
| Big O | Name | Typical example | n = 1,000,000 |
|---|---|---|---|
O(1) |
constant | index an array, HashMap.get (expected), ArrayDeque.push |
1 |
O(log n) |
logarithmic | binary search, PriorityQueue.add |
about 20 |
O(n) |
linear | one pass over the input | 10⁶ |
O(n log n) |
linearithmic | Arrays.sort, List.sort |
about 2·10⁷ |
O(n²) |
quadratic | checking every pair | 10¹² (too slow) |
O(2ⁿ) |
exponential | trying every subset | only works for small n, about 25 |
O(n!) |
factorial | trying every ordering | only works for n up to about 11 |
Amortized O(1)
ArrayList.add usually writes into spare capacity in O(1). When the internal array is full, ArrayList allocates a larger array and copies every element, which costs O(n). The new capacity is a constant multiple of the old one, so the copies become rarer as the list grows, and n additions copy O(n) elements in total. Averaged over all additions, each one costs O(1). That's called amortized O(1).
When the final size is known, new ArrayList<>(n) allocates the capacity up front and avoids the reallocations.
