Big-O Through Code Examples: Understanding Algorithm Complexity

1. Overview

Big-O is a way to describe how fast the runtime (or memory) grows as input size increases. This article doesn’t use dry math formulas; instead, it goes through each common complexity level with real Java code and analyzes which lines cause the cost. After reading, you’ll be able to look at a code snippet and instantly guess whether it’s O(1), O(n), O(n log n), or O(n²) without manual counting.

2. What is Big-O and Why Should You Care

Big-O answers the question: “If input doubles, how much does runtime increase?” It ignores constants and minor factors, keeping only the growth trend as n (input size) approaches infinity.

n = 1.000      -> O(n) runs ~1.000 steps
n = 1.000.000  -> O(n) runs ~1.000.000 steps (linear growth)
n = 1.000.000  -> O(n²) runs ~10^12 steps (exponential growth)

Two pieces of code can “look the same at first glance” but have very different complexity if one has nested loops hidden inside a function call. Understanding Big-O helps you avoid writing code that runs fine with 100 test records but “freezes” in production with 10 million records.

Note: Big-O describes the worst case by the most common convention, unless explicitly stated as best-case or average-case.

3. O(1) – Constant Time

Runtime stays the same regardless of input size.

public class InventoryCache {

    private final Map<String, Integer> stockBySku = new HashMap<>();

    public Integer getStock(String sku) {
        return stockBySku.get(sku); // HashMap.get is O(1) on average
    }
}

get() on HashMap computes the hash of the key and jumps directly to the corresponding bucket, independent of the number of elements in the map. Similarly, accessing array[i] or list.get(i) with ArrayList is also O(1) because it only needs to calculate the offset address.

4. O(n) – Linear Time

Runtime increases proportionally with input size.

public class OrderValidator {

    public boolean hasExpiredItem(List<Order> orders) {
        for (Order order : orders) {
            if (order.isExpired()) {
                return true; // stops early but worst-case still scans all
            }
        }
        return false;
    }
}

The for loop goes through at most n elements of orders. Worst case happens when no order is expired, forcing you to check the entire list. This is the classic O(n) pattern: a single loop, not nested.

4.1. Common Trap: contains() in a Loop

public class DuplicateChecker {

    // O(n²) because contains() on List is O(n), repeated n times
    public boolean hasDuplicateSlow(List<String> emails) {
        List<String> seen = new ArrayList<>();
        for (String email : emails) {
            if (seen.contains(email)) {
                return true;
            }
            seen.add(email);
        }
        return false;
    }
}

seen.contains(email) on ArrayList is O(n) because it has to scan linearly to find it. Calling it inside the for loop turns the entire function into O(n²) — an easy mistake to make when you don’t pay attention to the underlying data structure.

public class DuplicateChecker {

    // O(n) because HashSet.add/contains is O(1)
    public boolean hasDuplicateFast(List<String> emails) {
        Set<String> seen = new HashSet<>();
        for (String email : emails) {
            if (!seen.add(email)) { // add() returns false if it already exists
                return true;
            }
        }
        return false;
    }
}

Just by changing List to HashSet, complexity drops from O(n²) to O(n). This is the most practical example showing that choosing the right data structure matters more than “micro-optimizing”.

5. O(log n) – Logarithmic Time

Each step eliminates half of the search space.

public class PriceLookup {

    // sortedPrices must be sorted in ascending order first
    public int findIndex(int[] sortedPrices, int target) {
        int low = 0;
        int high = sortedPrices.length - 1;

        while (low <= high) {
            int mid = low + (high - low) / 2;
            if (sortedPrices[mid] == target) {
                return mid;
            } else if (sortedPrices[mid] < target) {
                low = mid + 1;
            } else {
                high = mid - 1;
            }
        }
        return -1;
    }
}

Binary search halves the array at each while loop, so with n = 1.000.000 it takes only about 20 steps (log₂ 1.000.000 ≈ 20) to find the result, instead of scanning a million elements linearly.

class PriceLookupTest {

    @Test
    void shouldFindIndexInSortedArray() {
        int[] prices = {10, 20, 30, 40, 50};
        PriceLookup lookup = new PriceLookup();

        int index = lookup.findIndex(prices, 40);

        assertThat(index).isEqualTo(3);
    }

    @Test
    void shouldReturnMinusOneWhenNotFound() {
        PriceLookup lookup = new PriceLookup();

        int index = lookup.findIndex(new int[] {1, 2, 3}, 99);

        assertThat(index).isEqualTo(-1);
    }
}

This test verifies correct behavior both when found (returns index) and when not found (returns -1), the two most critical branches of binary search.

6. O(n log n) – Combining Linear and Logarithmic

This is the complexity of efficient sorting algorithms like merge sort, quick sort (average case), or Collections.sort().

public class RevenueReport {

    public List<Order> sortByAmountDescending(List<Order> orders) {
        List<Order> copy = new ArrayList<>(orders);
        copy.sort(Comparator.comparing(Order::amount).reversed()); // O(n log n)
        return copy;
    }
}

Collections.sort()/List.sort() in Java uses Timsort (a variant of merge sort), guaranteeing O(n log n) in the worst case. If you see code calling sort() inside a loop that runs n times, the total complexity multiplies to O(n² log n) — a sign you need to refactor.

7. O(n²) – Quadratic Time

Two nested loops, each running through all (or nearly all) of the input.

public class PairFinder {

    // Find all pairs of products whose price sum equals target
    public List<int[]> findPairsSlow(int[] prices, int target) {
        List<int[]> result = new ArrayList<>();
        for (int i = 0; i < prices.length; i++) {
            for (int j = i + 1; j < prices.length; j++) {
                if (prices[i] + prices[j] == target) {
                    result.add(new int[] {prices[i], prices[j]});
                }
            }
        }
        return result;
    }
}

Outer loop runs n times, inner loop runs up to n times for each outer iteration — total comparisons are roughly n²/2, so complexity is O(n²). With n = 10.000, that’s 50 million comparisons — it still runs but noticeably slower.

public class PairFinder {

    // O(n) thanks to HashSet storing seen values
    public List<int[]> findPairsFast(int[] prices, int target) {
        List<int[]> result = new ArrayList<>();
        Set<Integer> seen = new HashSet<>();
        for (int price : prices) {
            int complement = target - price;
            if (seen.contains(complement)) {
                result.add(new int[] {complement, price});
            }
            seen.add(price);
        }
        return result;
    }
}

Instead of comparing every pair, we only need a single loop and O(1) lookups in HashSet. This is the common “trade time for space” technique: we use extra memory to store seen, but reduce complexity from O(n²) to O(n).

8. O(2^n) – Exponential Time

Runtime doubles every time input increases by 1 — commonly seen in recursive functions that compute all subsets.

public class SubsetGenerator {

    public List<List<Integer>> generateAllSubsets(List<Integer> items) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(items, 0, new ArrayList<>(), result);
        return result;
    }

    private void backtrack(List<Integer> items, int index,
                            List<Integer> current, List<List<Integer>> result) {
        if (index == items.size()) {
            result.add(new ArrayList<>(current));
            return;
        }
        // Branch 1: don't select item[index]
        backtrack(items, index + 1, current, result);

        // Branch 2: select item[index]
        current.add(items.get(index));
        backtrack(items, index + 1, current, result);
        current.remove(current.size() - 1);
    }
}

Each element creates 2 recursive branches (select or don’t select), so with n elements we get 2^n subsets. With n = 20, that’s already over 1 million subsets; n = 40 won’t finish on any machine in reasonable time. This is a sign to consider dynamic programming if the problem allows it.

9. Quick Comparison with Real Benchmarks

A simple test example to “see” the difference between O(n) and O(n²):

class DuplicateCheckerBenchmarkTest {

    @Test
    void fastVersionShouldHandleLargeInputQuickly() {
        List<String> emails = new ArrayList<>();
        for (int i = 0; i < 100_000; i++) {
            emails.add("user" + i + "@example.com");
        }
        DuplicateChecker checker = new DuplicateChecker();

        long start = System.nanoTime();
        boolean hasDuplicate = checker.hasDuplicateFast(emails);
        long elapsedMs = (System.nanoTime() - start) / 1_000_000;

        assertThat(hasDuplicate).isFalse();
        assertThat(elapsedMs).isLessThan(500); // O(n) so still very fast with 100k elements
    }
}

If you try replacing hasDuplicateFast with hasDuplicateSlow for the same 100.000 emails, runtime will jump well beyond 500ms — visual proof of the difference between O(n) and O(n²) on real data.

10. Summary Table and Quick Recognition Rules

Big-O Signs in Code Example
O(1) No loop, direct access (array index, HashMap.get) map.get(key)
O(log n) Each step eliminates part of search space Binary search
O(n) Single loop through input for loop through List
O(n log n) Loop combined with sort, or divide-and-conquer Collections.sort()
O(n²) Loop nested in loop, both through input Comparing every pair
O(2^n) Recursion with 2 branches per step, no pruning Generate all subsets

Quick counting rule: count nested loops that iterate through input — each level adds one “power of n”. If the loop calls library functions (contains, sort, indexOf), look up their complexity before adding to the total.

Note: Big-O says nothing about real constants. An O(n) algorithm with a large constant (like reading a file each loop) can be slower than O(n log n) with a small constant when n isn’t large enough. Don’t blindly optimize for Big-O without profiling real performance.

11. Conclusion

Big-O is a tool to predict trends, not absolute numbers — but those trends determine whether your code survives or crashes when data grows. Most practical tip: whenever you write a loop, ask yourself “does this loop call any O(n) function inside?” — that’s the source of most unwanted O(n²) bugs in practice. Mastering the 6 complexity levels above is enough to understand and critique most algorithm code you encounter daily.

algorithmsbig-ojavacomplexitydata-structures