DataData/06 · Hash Table
CHAPTER 06 · Hash Table

The Hash Table

A hash function turns a key into an array index, and the table jumps straight to it. A lookup therefore does not scan the stored data. The price is that two different keys can produce the same index, and handling that is most of what the rest of this chapter is about.

§01

Intuition: compute the position instead of searching for it

A hash table is an array plus a rule that turns any key into an index.

To find a word in a paper dictionary you can start at page one and turn pages until you reach it. That is a linear search, O(n), and it gets slower as the dictionary gets thicker. Or you can use the index at the front: the spelling of the word tells you which page to open, and you go there directly. The size of the dictionary barely matters. The second method works because the word itself, put through a fixed rule, produces a position.

An array already has this ability. Give it an index and it reaches the element in O(1), because the address is computed from the index rather than searched for. But the index has to be a small non-negative integer. A hash table extends the same ability to any kind of key: a hash function turns the key — a string, an object, a pair of coordinates — into an integer, and that integer is used as the index into an array. Nothing is scanned. That is the whole idea. A hash table is not a new way to store data; it is an array, a rule for computing indexes, and a plan for when two keys compute the same one.

PART 01
Hash function

It turns any key into an integer inside a fixed range. The same key must always produce the same integer. If it does not, a value you stored can never be found again.

PART 02
Bucket array

The place where the data actually lives: an ordinary array whose slots are called buckets. The output of the hash function is the bucket index, so the O(1) jump comes from the array underneath.

PART 03
Collision handling

There are more possible keys than buckets, so two different keys landing in the same bucket is certain, not a bug. Separate chaining and open addressing are the two standard answers.

Where you already rely on hash tables

Python resolves a variable name in a namespace, and a namespace is a dict. A browser cache finds a file by its URL. A database index answers "which rows hold this value". Redis is close to a hash table with a network interface. Wherever a name leads directly to content, there is usually a hash table behind it. One case is related but different: Git names each commit by a cryptographic hash of its content. That is a different kind of hash function with a different goal — making it infeasible to find two contents with the same name — not making lookups fast.

§02

Hash functions: turning a key into an index

One fixed mapping: any key goes in, an integer in a fixed range comes out.

A hash function takes a key and returns an integer. It could compute almost anything, but three requirements decide whether the table works at all. Each one exists because of what goes wrong without it.

RequirementWhat it meansWhat happens without it
DeterministicThe same key always produces the same valueYou store an entry in bucket 3 and later compute bucket 5 for the same key. The entry is still there, but nothing can reach it.
Well spreadDifferent keys are spread over all the bucketsMost keys pile into a few buckets. Those buckets hold long lists, and lookup degrades from O(1) toward O(n).
FastComputing the hash is itself close to O(1)The time saved on the search is spent computing the hash instead.

How does a string become a number? The classic method is a polynomial hash: multiply each character code by a power of 31 and add the results. For "cat" (c = 99, a = 97, t = 116):

hash("cat") = 99 × 31² + 97 × 31 + 116 = 95139 + 3007 + 116 = 98262

Why not simply add the character codes? Because then "cat" and "act" would produce the same value: same letters, different order. Multiplying by powers of 31 makes the position take part in the result — a character further to the left is multiplied by 31 more times. Written as a loop it is one line: h = h × 31 + code(c). The last step takes this large number modulo the bucket count, which pushes it into the range of legal array indexes. Try the machine yourself:

Hash lab — any word becomes an index in three steps
waiting for a word…
h starts at 0. For each character: h = h × 31 + character code
[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]
Type a word of at most 6 characters and press Hash it. Try cat and dog, then try Aa and BB — those two are chosen to land in the same bucket.

Why 31? The choice made in Java's String.hashCode

31 is an odd prime. An odd multiplier keeps the low bits of the result meaningful, which matters because the bucket count is usually a power of two and the index is taken from the low bits. It is also cheap: 31 × i equals (i << 5) − i, one shift and one subtraction. The collision between Aa and BB that you can produce in the lab above is not a bug — both have hashCode 2112 in Java. There are infinitely many strings and only 2³² int values, so by the pigeonhole principle some pairs of keys must share a hash. Collisions cannot be designed away. They can only be handled.

§03

Collisions: two keys, one bucket

Collisions cannot be avoided. What can be avoided is letting them ruin the performance.

Option one: separate chaining. A bucket holds a list instead of a single entry, and a colliding entry is appended to that list. A lookup hashes to the bucket and then compares the keys in the list one by one. Java's HashMap works this way, and so does the implementation in §04.

Option two: open addressing. A bucket holds at most one entry. On a collision the table follows a fixed rule to find another free bucket. The simplest rule is to try the next bucket to the right, which is called linear probing. CPython's dict and Rust's standard HashMap use open addressing. It is friendlier to the CPU cache, because the next bucket probed is usually in the same cache line. Here are the same six keys under both strategies:

Collision lab — the same six words, two strategies
[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]
Six words are inserted into eight buckets, one at a time. With separate chaining, a bucket does not hold a single entry. It holds a list, and a colliding entry is appended to that list.
1 / 8
Chaining: how Java 8 limits the damage

A single list can become very long if the hash is poor or if the input was chosen by an attacker. Since Java 8, once a bucket holds about 8 entries and the table has at least 64 buckets, that bucket is converted from a linked list to a red-black tree, so a lookup in it costs O(log n) instead of O(n). Why 8? With a hash that spreads well, the number of entries per bucket follows a Poisson distribution, and reaching 8 has a probability of roughly six in one hundred million. The conversion is not meant for ordinary data. It is a safety limit for deliberately crafted input, a HashDoS attack.

Open addressing: deletion needs tombstones

In a probing table you cannot simply set a slot back to empty. A search stops at the first empty slot, so any entry that was pushed further along the probe path would become unreachable. The usual fix is to write a tombstone: a marker meaning "something used to be here". A search continues past it, and an insert may reuse it. So deletion under open addressing does not really free the slot, and once there are many tombstones the whole table has to be rebuilt.

The fuller the buckets, the more often keys collide. The measure of "full" is the load factor: the number of stored entries divided by the number of buckets. A Java HashMap grows once the load factor passes 0.75 by default. Growing means allocating a bigger bucket array — the capacity doubles — and then placing every existing key again. That second part is called rehashing, and it is not optional. The index comes from hash % bucketCount, so changing the bucket count changes where a key belongs. For example, 98262 % 8 = 6 and 98262 % 16 = 6, so that key happens to stay; but 98270 % 8 = 6 while 98270 % 16 = 14, so that key moves. With a power-of-two capacity an entry in bucket i either stays at i or moves to i + oldCapacity, and every entry still has to be visited to find out which.

Rehashing means a single insert can cost O(n). Because the capacity doubles, that cost is spread over the insertions that follow, so insertion is O(1) amortized rather than O(1) every time. Why 0.75? Higher, and each bucket holds more entries on average, so the lists grow and the average lookup stops being constant. Lower, and many buckets stay empty and waste memory.

OperationAverageWorst caseWhy
putO(1) amortizedO(n)Average: compute the index, then compare a constant number of keys. The amortized part covers the rehash after the table grows. Worst: every key lands in one bucket.
get / containsO(1)O(n)Same as above. The average holds only while the hash spreads the keys and the load factor stays bounded — both conditions are maintained on purpose.
removeO(1)O(n)Finding the entry costs the same as a lookup. Unlinking it from a list, or writing a tombstone, is O(1).
IterateO(n + buckets)Every bucket has to be visited, including the empty ones. A hash table keeps no order of its own.

State the complexity together with its conditions

Insert, lookup, and delete in a hash table are O(1) on average, under two conditions: the hash function spreads the keys well, and the load factor stays bounded, which is what growing the table maintains. The worst case is O(n), and it happens when a large number of keys land in the same bucket. Saying only "a hash table is O(1)" leaves out both the average and the conditions, and that is the most common mistake in an interview answer.

§04

Build one: separate chaining from scratch

Locate the bucket, compare along the list, and double the table when it gets too full.

A bucket array with one list per bucket, and five methods: hash (locate), put, get, remove, and resize. Watch the line inside resize that computes the index again. That single line is the rehash described in §03.

my_hashmap.py
1class MyHashMap:
2 def __init__(self):
3 # 8 buckets; each bucket is a list of [key, val] pairs
4 self.buckets = [[] for _ in range(8)]
5 self.size = 0
6
7 def _index(self, key, cap):
8 return hash(key) % cap # built-in hash(): any hashable object
9
10 def put(self, key, val):
11 b = self.buckets[self._index(key, len(self.buckets))]
12 for pair in b:
13 if pair[0] == key: # key exists: overwrite
14 pair[1] = val
15 return
16 b.append([key, val]) # new pair goes into the bucket
17 self.size += 1
18 if self.size > len(self.buckets) * 0.75:
19 self._resize() # load factor > 0.75: grow
20
21 def get(self, key):
22 b = self.buckets[self._index(key, len(self.buckets))]
23 for k, v in b: # compare the keys in this bucket
24 if k == key:
25 return v
26 return None # not found
27
28 def remove(self, key):
29 b = self.buckets[self._index(key, len(self.buckets))]
30 for i, (k, _) in enumerate(b):
31 if k == key:
32 self.size -= 1
33 return b.pop(i)[1] # take it out, return the old value
34 return None
35
36 def _resize(self):
37 old = self.buckets
38 self.buckets = [[] for _ in range(len(old) * 2)] # twice as many buckets
39 for bucket in old:
40 for k, v in bucket:
41 # the bucket count changed, so every key needs a new index
42 self.buckets[self._index(k, len(self.buckets))].append([k, v])
This is a teaching version. CPython's real dict uses open addressing rather than chaining, and it keeps the entries in a separate compact array in insertion order. Also, hash() for str is randomized with a per-process seed by default (a defence against HashDoS), so the numeric value of hash("abc") differs between runs and must never be relied on.

What these five methods already answer

"What happens during put?" "What happens when the table grows?" "Why is get O(1) on average?" Each answer is a line above, and you can point at it. What is left out — treeified buckets, thread safety, ConcurrentHashMap — is added on top of this same skeleton.

§05

Three languages: Map and Set

One abstraction in two shapes: a dictionary from key to value, and a set that stores keys only.

Every language ships an industrial-strength hash table, and each ships it in two shapes. A Map (dictionary) stores key-value pairs. A Set stores keys only — it is the same hash table with no value attached, which is why a membership test is as fast as a map lookup. The APIs are similar. The mistakes are language-specific:

hash_basics.py
1# dict - built into the language, with its own literal syntax
2cnt = {"apple": 1}
3cnt["pear"] = 2 # insert or overwrite
4cnt["apple"] # read - raises KeyError if the key is absent
5cnt.get("kiwi", 0) # safe read: a default instead of an error
6"apple" in cnt # is this key present, O(1) on average
7del cnt["apple"]
8len(cnt)
9
10for k, v in cnt.items(): # 3.7+ guarantees insertion order
11 print(k, v)
12
13# set
14seen = {1, 2, 3}
15seen.add(7)
167 in seen # True
17
18# two helpers worth knowing
19from collections import Counter, defaultdict
20Counter("aabbc") # Counter({'a':2, 'b':2, 'c':1})
21d = defaultdict(list) # a missing key builds its default value
22d["group"].append("x") # no need to check whether the key exists
23
24# a key must be hashable, which in practice means immutable:
25ok = {(1, 2): "a tuple can be a key"}
26# bad = {[1, 2]: "..."} # TypeError: unhashable type: 'list'
Common mistakes: a mutable object such as list, dict, or set has no __hash__, so it cannot be a key. If its contents changed, its hash would change and the entry would be lost. A tuple is immutable and can be a key, provided everything inside it is hashable too. And the difference between d[k] and d.get(k) — raising KeyError versus returning None — catches most beginners once.
OperationJava (HashMap)Python (dict)JavaScript (Map)Complexity
Createnew HashMap<>(){}new Map()O(1)
Insert or updatem.put(k, v)m[k] = vm.set(k, v)O(1) amortized
Readm.get(k)m[k] (raises if absent)m.get(k)O(1) avg
Read with a defaultm.getOrDefault(k, d)m.get(k, d)m.get(k) ?? dO(1) avg
Contains keym.containsKey(k)k in mm.has(k)O(1) avg
Deletem.remove(k)del m[k] / m.pop(k, None)m.delete(k)O(1) avg
Sizem.size()len(m)m.sizeO(1)
Iteratefor (var e : m.entrySet())for k, v in m.items():for (const [k, v] of m)O(n)
Iteration orderNo guarantee (LinkedHashMap keeps insertion order)Insertion order (guaranteed since 3.7)Insertion order (guaranteed)

Why an IDE always generates equals and hashCode together

IntelliJ and Eclipse generate the two methods in one action, Lombok's annotation is called @EqualsAndHashCode, and a Java record generates both automatically. The whole ecosystem enforces one rule: objects that are equal must have equal hash codes. A HashMap lookup has two steps — hashCode picks the bucket, equals confirms the match inside it. Break the rule and the first step already goes to the wrong bucket.

§06

Three signals: seen before, pairing, grouping

★ Interview core

When a problem sounds like one of these three, reach for a hash table.

Every hash-table solution in this chapter does the same thing: it spends O(n) memory so that a search that would cost O(n) becomes one lookup that costs O(1) on average. Three signals tell you when that applies:

SIGNAL 01
"Seen before?" → Set

Detecting duplicates, detecting a cycle, computing an intersection. You only care whether a value exists, not what is attached to it. → LC 217, 202, 349, 128.

SIGNAL 02
"Find the partner" → Map

Looking for "the other number that sums to k", or for where a value appeared. The key is the value you want to be found by; the value is the index or the count. → LC 1, 454, 560.

SIGNAL 03
"Group and count" → Map<signature, list>

Put items that are the same in some sense under one key. Design a signature function so that items of the same kind always produce the same key. → LC 49, 383, 299.

Deep dive A

LC 1 · Two Sum

EASY

The first problem on LeetCode, and the prototype of the pairing signal. The task: find two numbers in the array that add up to target, and return their indexes. Brute force: two nested loops over every pair, O(n²). Where the improvement comes from: look at what the inner loop is doing. For each i, it searches the part of the array before i for target − nums[i]. That is a linear search. Put the numbers you have already passed into a hash table, and that search becomes one O(1) lookup:

LC 1 · one pass: look up first, record second (target = 9)
110
21
152
73
target = 9. Start with an empty map from value to index. For every number, look up first — has the number it needs already appeared? — and record it afterwards.
1 / 5
lc1_two_sum.py
1class Solution:
2 def twoSum(self, nums: list[int], target: int) -> list[int]:
3 seen = {} # value -> index
4 for i, v in enumerate(nums):
5 need = target - v # the partner this number needs
6 if need in seen: # look up first: has it appeared?
7 return [seen[need], i]
8 seen[v] = i # record second
9 return []

Complexity and follow-up questions

Time O(n), space O(n) — memory traded for time. Follow-up one: why look up before recording? It stops a number from pairing with itself. With target = 8 and nums[i] = 4, recording first would let 4 match its own entry. Follow-up two: what if the array is sorted? Then two pointers moving toward each other solve it in O(n) time and O(1) space (LC 167, covered in the array chapter). Sorted input suggests two pointers; unsorted input suggests a hash table.

Deep dive B

LC 49 · Group Anagrams

MEDIUM

The task: group the words that contain the same letters in a different order. Brute force: compare every pair of words, O(n² · k). Where the improvement comes from: instead of comparing words with each other, give every word a signature — a value that is identical for anagrams and different for everything else. Then a Map from signature to list collects the groups on its own:

LC 49 · grouping by a sorted signature
eat0
tea1
tan2
ate3
nat4
bat5
Anagrams contain the same letters in a different order. Give each word a signature: sort its letters. eat, tea, and ate all become "aet". The signature is the key, and the word joins the group stored under that key.
1 / 7
lc49_group_anagrams.py
1from collections import defaultdict
2
3class Solution:
4 def groupAnagrams(self, strs: list[str]) -> list[list[str]]:
5 groups = defaultdict(list)
6 for s in strs:
7 key = "".join(sorted(s)) # sorted letters = the signature
8 groups[key].append(s) # same signature, same group
9 return list(groups.values())

Two signatures, and when to switch

Sorted signature: O(k log k) per word, and the shortest to write. Counting signature: count the 26 letters and join the counts into a string such as "a1e1t1", which is O(k) per word and better when the words are long. Both follow the same rule: the signature must capture exactly what makes two items belong together. The same idea returns in LC 249 (Group Shifted Strings) and LC 205 (Isomorphic Strings).

Deep dive C

LC 128 · Longest Consecutive Sequence

MEDIUM

The task: in an unsorted array, find the length of the longest run of consecutive values. Their positions do not matter, and the solution must be O(n). First idea: sort and scan — but sorting is O(n log n), which the problem rules out. Where the improvement comes from: put every value into a set, and "is x + 1 present" becomes O(1), so a run can be measured by walking right. Doing that from every value is still O(n²): in the run 1..100 you would count from 1, then from 2, then from 3. One rule fixes it: only start counting at the beginning of a run, that is at a value x for which x − 1 is not in the set:

LC 128 · a set, and counting only from the start of a run
1000
41
2002
13
34
25
Step one: put every value into a set = {100, 4, 200, 1, 3, 2}. From now on "is x present" costs O(1) on average. The goal is the longest run of consecutive values; where they sit in the array does not matter.
1 / 9
lc128_longest_consecutive.py
1class Solution:
2 def longestConsecutive(self, nums: list[int]) -> int:
3 s = set(nums) # O(1) membership from now on
4 best = 0
5 for v in s:
6 if v - 1 in s: # not a start: skip it
7 continue
8 length = 1
9 while v + length in s: # count right from the start
10 length += 1
11 best = max(best, length)
12 return best

Why this is O(n)

There is a nested while loop, but it only runs from the start of a run, and each run has exactly one start. Adding up all its iterations, every value is counted through once. n start checks on the outside, plus at most n steps on the inside, gives O(n). This is the same amortized argument as the 2n bound for the sliding window in the array chapter. Follow-up: can you iterate over nums instead of the set? Yes, but duplicates would repeat the start check for no reason. Iterating the set is cleaner.

§07

Problem set: 10 hash table problems

Hot 100 selection

Grouped by signal, easiest first. LC 560, prefix sums plus a map, is the one to understand completely.

§08

Quiz

✎ Quiz

Eight questions. Get them all right to mark this chapter as complete.

QUESTION 01 / 8

Which of these must a usable hash function satisfy? (select all)

QUESTION 02 / 8

A string hash can be a number in the hundreds of thousands. Why take it modulo the bucket count at the end?

QUESTION 03 / 8

The load factor is the number of stored entries divided by the number of buckets. Above which load factor does a Java HashMap grow by default? (write a decimal)

QUESTION 04 / 8

Lookup in a hash table is O(1) on average but O(n) in the worst case. What causes the worst case?

QUESTION 05 / 8

In Java you override equals() but forget hashCode(), then use the object as a HashMap key. What happens?

QUESTION 06 / 8

In Python a list cannot be a dict key but a tuple can. What is the underlying reason?

QUESTION 07 / 8

In JavaScript, what is the most important difference between using a plain object as a dictionary and using a Map?

QUESTION 08 / 8

In Python 3.7 and later, in what order does iterating a dict return the keys?

What to take away from this chapter
  • A hash table is an array + a hash function + a plan for collisions. The hash function turns a key into an index, so the O(1) jump that an array offers to integer indexes becomes available to any kind of key.
  • A hash function must be deterministic, well spread, and fast. There are more possible keys than buckets, so collisions are certain. Separate chaining stores a list per bucket (Java); open addressing probes for another slot (Python), which costs more at a high load factor and makes deletion need tombstones.
  • Past the load factor (0.75 by default in Java) the table allocates a larger array and rehashes every key, because the index depends on the bucket count. One insert can therefore cost O(n), which is why insertion is O(1) amortized. Stated precisely: average O(1) with a good hash and a bounded load factor, worst case O(n) when many keys share a bucket.
  • One trap per language. Java: overriding equals means overriding hashCode, and never modify a key after inserting it. Python: mutable objects cannot be keys (a list cannot, a tuple of hashables can), and dict preserves insertion order since 3.7. JavaScript: use Map, not a plain object — object keys are converted to strings and the prototype contributes keys of its own.
  • Three signals: "seen before" → Set; "find the partner" → Map from value to index or count; "group and count" → Map from signature to list. A Set is a hash table that stores keys only. Prefix sums plus a map (LC 560) is Two Sum applied to prefix sums.