HashMap & HashSet

The last lesson ended with Red-Black trees giving O(log n) lookups no matter the insertion order. Hashing does better still for pure key lookups — O(1) on average — by giving up ordering entirely. This lesson covers how hashing works, what's actually going on inside Java's HashMap, and a classic interview problem (two-sum) that hashing solves far better than brute force.

The Hashing Concept

A hash function takes a key and converts it into an integer (the hash code), which is then reduced to an index within a fixed-size internal array — commonly via index = hash(key) % arraySize. That index is called a bucket. To look up a key, you don't scan anything — you compute its hash, jump straight to that bucket, and (ideally) find your value right there.

key "apple"  -> hashCode() -> 93029210 -> % 16 buckets -> bucket 10
key "banana" -> hashCode() -> 93020956 -> % 16 buckets -> bucket  4
key "cherry" -> hashCode() -> 93006589 -> % 16 buckets -> bucket 13

In Java, every object has a hashCode() method, and HashMap further "spreads" that hash to reduce clustering before taking the modulo. This is why any key type you put in a HashMap must have a correct, consistent hashCode() — and an equals() that agrees with it.

Java's HashMap Internals

A HashMap is backed by an array of buckets (default capacity 16, doubling as it fills up past a load factor of 0.75). When two different keys hash to the same bucket — a collision — Java has to store both in that one slot:

  • Historically, each bucket held a simple linked list of entries; a collision just appended to that list.
  • Since Java 8, if a single bucket's list grows past 8 entries (and the table is large enough), that bucket is converted — "treeified" — into a small red-black tree instead, so worst-case lookup within a badly-collided bucket drops from O(n) to O(log n).
bucket 4:  [ "banana" -> 3 ] -> [ "grape" -> 7 ]   (collision chain, linked list)
bucket 10: [ "apple"  -> 5 ]                        (no collision)
bucket 13: [ "cherry" -> 9 ]                        (no collision)
Map<String, Integer> prices = new HashMap<>();
prices.put("apple", 5);
prices.put("banana", 3);
prices.put("grape", 7);   // might collide into banana's bucket

// get() computes the hash, jumps to the bucket, then scans
// just that bucket's (usually short) chain for a matching key.
System.out.println(prices.get("grape"));  // 7

Time complexity: O(1) average for get/put — a good hash spreads keys evenly so each bucket holds very few entries. Worst case is O(n) if every key collides into the same bucket (or O(log n) once Java 8 treeifies a large bucket) — but with a well-distributed hash function this is rare. Space complexity: O(n) to store n entries plus the bucket array.

HashSet: A HashMap in Disguise

HashSet<E> is literally implemented as a HashMap<E, Object> internally — every element you add becomes a key, mapped to a single shared dummy placeholder value. That's why a HashSet gives you the exact same O(1)-average add/contains/remove behavior as a HashMap's key operations, just without a value to retrieve.

Set<String> seen = new HashSet<>();
seen.add("apple");
seen.add("banana");

System.out.println(seen.contains("apple"));  // true — one hash lookup, no scanning
System.out.println(seen.contains("cherry")); // false

Time complexity: O(1) average for add/contains/remove — same reasoning as HashMap, since a HashSet's contains() is just a HashMap get() under the hood. Space complexity: O(n) for n stored elements.

Worked Problem: Two-Sum

Given an array of integers and a target, find the indices of two numbers that add up to the target. The brute-force approach checks every pair:

// Brute force: check every pair
int[] twoSumBrute(int[] nums, int target) {
    for (int i = 0; i < nums.length; i++) {
        for (int j = i + 1; j < nums.length; j++) {
            if (nums[i] + nums[j] == target) {
                return new int[]{i, j};
            }
        }
    }
    throw new IllegalArgumentException("No solution");
}

Time complexity: O(n²) — every element is compared against every other element. Space complexity: O(1) — no extra data structure beyond the result array.

A HashMap gets this down to a single pass. For each number, first check if its complement (target - num) was already seen; if not, record the current number and its index for future lookups:

// One pass with a HashMap: value -> index
int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> seenAt = new HashMap<>();

    for (int i = 0; i < nums.length; i++) {
        int complement = target - nums[i];
        if (seenAt.containsKey(complement)) {
            return new int[]{seenAt.get(complement), i};
        }
        seenAt.put(nums[i], i);  // remember this value for later lookups
    }
    throw new IllegalArgumentException("No solution");
}

// nums = [2, 7, 11, 15], target = 9
// i=0: complement=7, not seen yet -> remember 2 at index 0
// i=1: complement=2, seen at index 0 -> return [0, 1]

Time complexity: O(n) — a single pass through the array, with each HashMap containsKey/get/put being O(1) average. Space complexity: O(n) — in the worst case (solution found at the last element) the map holds almost every value seen so far.

Why this pattern matters: "have I seen this value/complement before?" is one of the most common interview shapes — duplicates, pairs that sum to a target, anagram grouping, frequency counting. A HashMap or HashSet turns an O(n²) nested-loop check into an O(n) single pass almost every time.
Next up: hashing gave us O(1) lookups by giving up structure entirely. Graph Representation introduces a very different kind of structure — networks of connected nodes — starting with how to even store a graph in memory.