How a hash map works
Think of a coat check. You hand over your coat and get a ticket with a number. When you come back, the attendant doesn’t search every coat in the room: the number tells them exactly which hook to go to.
A hash mapHash mapA key-value collection that finds a value by its key in constant time on average.Open in glossary → does the same with data. You store a value under a key (a word, a user ID, a number). The map turns the key into a number, called its hash, and uses that number to jump straight to the right spot. That’s why finding a value takes the same short time whether the map holds ten items or ten million. We call that O(1), read “constant time” (see Big-O notationBig-O notationA way to describe how an algorithm's time or memory grows as the input gets bigger.Open in glossary →).
Each key appears at most once. Writing to a key that is already there replaces its value instead of adding a second entry.
An array of buckets, a hash function that maps keys to bucket indexes, an equality check to resolve collisionsHash collisionTwo different keys that end up in the same bucket of a hash map.Open in glossary →, and a resize when the load factorLoad factorHow full a hash map is allowed to get (entries divided by buckets) before it grows.Open in glossary → gets too high. That gives average O(1) insert, lookup and delete, amortized over resizes. Worst case is O(n) when many keys collide (O(log n) for Java’s HashMap, which turns long chains into trees). The details, and how they differ between Swift, Kotlin and Java, are in the Detailed version.
let words = ["swift", "kotlin", "swift", "java", "swift"]
var counts: [String: Int] = [:]
for word in words {
counts[word, default: 0] += 1
}
// ["swift": 3, "kotlin": 1, "java": 1]val words = listOf("swift", "kotlin", "swift", "java", "swift")
val counts = mutableMapOf<String, Int>()
for (word in words) {
counts[word] = (counts[word] ?: 0) + 1
}
// {swift=3, kotlin=1, java=1}
// Shorter: words.groupingBy { it }.eachCount()var words = List.of("swift", "kotlin", "swift", "java", "swift");
Map<String, Integer> counts = new HashMap<>();
for (String word : words) {
counts.merge(word, 1, Integer::sum);
}
// {swift=3, kotlin=1, java=1} (in some order)const words = ["swift", "kotlin", "swift", "java", "swift"];
const counts = new Map<string, number>();
for (const word of words) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
// Map(3) { 'swift' => 3, 'kotlin' => 1, 'java' => 1 }| Operation | Average | Worst case |
|---|---|---|
| Insert, lookup, delete | O(1) | O(n) |
| Iterate all entries | O(n) | O(n) |
| Memory | O(n) | O(n) |
Practice: counting, step by step
Counting is the hash map move you’ll use most, so let’s slow down and watch it happen. Here’s the map after each letter of "banana":
| Letter | What the code does | Map afterwards |
|---|---|---|
b | b isn’t there yet: start at 0, add 1 | b: 1 |
a | a isn’t there yet: start at 0, add 1 | b: 1, a: 1 |
n | n isn’t there yet: start at 0, add 1 | b: 1, a: 1, n: 1 |
a | a is there: 1 + 1 | b: 1, a: 2, n: 1 |
n | n is there: 1 + 1 | b: 1, a: 2, n: 2 |
a | a is there: 2 + 1 | b: 1, a: 3, n: 2 |
Every step is one lookup and one write, and both take about the same time whether the map holds 3 keys or 3 million. That’s why counting a whole book is still O(n).
The first row is where beginners trip: the key isn’t in the map yet. Which line crashes?
Tap the line with the bug.
func letterCounts(_ text: String) -> [Character: Int] {
var counts: [Character: Int] = [:]
for letter in text {
counts[letter] = counts[letter]! + 1
}
return counts
}fun letterCounts(text: String): Map<Char, Int> {
val counts = mutableMapOf<Char, Int>()
for (letter in text) {
counts[letter] = counts[letter]!! + 1
}
return counts
}static Map<Character, Integer> letterCounts(String text) {
Map<Character, Integer> counts = new HashMap<>();
for (char letter : text.toCharArray()) {
counts.put(letter, counts.get(letter) + 1);
}
return counts;
}function letterCounts(text: string): Map<string, number> {
const counts = new Map<string, number>();
for (const letter of text) {
counts.set(letter, counts.get(letter)! + 1);
}
return counts;
}Hint: Follow the first letter, b. What does the map return for a key it has never seen?
On the very first letter, the map has no entry for it, so the lookup returns nil/null. Force-unwrapping it crashes (Java crashes too, when it unboxes null to add 1). The function fails on every input except the empty string.
TypeScript note
TypeScript doesn’t crash, which is worse: ! checks nothing, undefined + 1 is NaN, and every count in the map ends up NaN.
Each language has a way to say “start at 0 if it’s missing”:
counts[letter, default: 0] += 1counts[letter] = (counts[letter] ?: 0) + 1
// or: counts.merge(letter, 1, Int::plus)counts.merge(letter, 1, Integer::sum);
// or: counts.put(letter, counts.getOrDefault(letter, 0) + 1);counts.set(letter, (counts.get(letter) ?? 0) + 1);Predict the output
What gets printed?
var ages = ["Ana": 30]
ages["Ana"] = 31
ages["Marko"] = 25
print(ages.count)val ages = mutableMapOf("Ana" to 30)
ages["Ana"] = 31
ages["Marko"] = 25
println(ages.size)Map<String, Integer> ages = new HashMap<>(Map.of("Ana", 30));
ages.put("Ana", 31);
ages.put("Marko", 25);
System.out.println(ages.size());const ages = new Map([["Ana", 30]]);
ages.set("Ana", 31);
ages.set("Marko", 25);
console.log(ages.size);A key can appear only once in a map. Writing to a key that already exists replaces its value, so after the second line there's still one "Ana" (now 31), and "Marko" makes two entries. This is exactly what makes maps good for "have I seen this?": a key is either there or it isn't, no matter how many times you add it.
First Unique Character
Given a string, return the index of the first character that appears exactly once. If there isn’t one, return -1.
"leetcode" → 0 ('l' appears once)
"loveleetcode" → 2 ('v')
"aabb" → -1›
You can’t know whether a character is unique until you’ve seen the whole string. So walk it twice.
›
First pass: count every character, exactly like "banana" above. Second pass: go through the string again in order and return the first index whose count is 1. Don’t loop over the map instead: you need the string’s order, not the map’s.
SolutionTry it yourself firstTime O(n)Space O(1)
func firstUniqueChar(_ s: String) -> Int {
var counts: [Character: Int] = [:]
for c in s {
counts[c, default: 0] += 1
}
for (index, c) in s.enumerated() where counts[c] == 1 {
return index
}
return -1
}fun firstUniqueChar(s: String): Int {
val counts = mutableMapOf<Char, Int>()
for (c in s) {
counts[c] = (counts[c] ?: 0) + 1
}
return s.indexOfFirst { counts[it] == 1 }
}static int firstUniqueChar(String s) {
Map<Character, Integer> counts = new HashMap<>();
for (char c : s.toCharArray()) {
counts.merge(c, 1, Integer::sum);
}
for (int i = 0; i < s.length(); i++) {
if (counts.get(s.charAt(i)) == 1) return i;
}
return -1;
}function firstUniqueChar(s: string): number {
const counts = new Map<string, number>();
for (const c of s) {
counts.set(c, (counts.get(c) ?? 0) + 1);
}
return [...s].findIndex((c) => counts.get(c) === 1);
}Two passes of n steps is still O(n). The space is O(1) when the input is only lowercase letters, because the map can never hold more than 26 keys; for any characters at all, say O(k), where k is the number of different characters.
In an interview, mention the follow-up yourself: if the input is only a to z, an array of 26 counts (counts[c - 'a']) does the same job without hashing.
What happens inside
Every hash map turns a key into an array index: compute hashCode() / hash(into:), mix the bits, and keep the low bits (hash & (capacity - 1), because the capacity is a power of two). The designs differ in what they do when two keys want the same bucket:
- Separate chaining (Java’s
HashMap, so also Kotlin on the JVM, and V8’sMapin JavaScript and TypeScript): each bucket points to a small linked list of entries. - Open addressing (Swift’s
Dictionary): every entry lives in the array itself; a taken bucket means “try the next one” (linear probing).
Insert the keys below and watch both layouts. "Aa" and "BB" have the same String.hashCode() (2112), and ana, eve and max land in the same bucket of the small table:
Inside a hash table
- 0·
- 1·
- 2·
- 3·
- 4·
- 5·
- 6·
- 7·
Insert keys one at a time. Each key's hash picks a bucket: index = hash & (capacity − 1).
Tap a key to look it up. Both layouts use Java's String.hashCode so you can compare them; Swift's real hashes are seeded randomly per process.
Chaining keeps a collision local to its bucket, at the cost of an object per entry. Open addressing keeps everything in contiguous arrays (cache-friendly, little overhead), but collisions spill into the neighbors and form clusters, which is why it must stay well below full.
Java
java.util.HashMap
- Table: an array of
Node<K,V>(hash, key, value, next), allocated lazily on the firstputwith 16 buckets. - Index:
(n - 1) & (h ^ (h >>> 16)). The XOR spreads the high bits into the low bits that pick the bucket. - Lookup: compare the stored hash first, then
==, thenequals. Equal keys must have equal hashes, or the lookup never reachesequalsin the right bucket. - Tree bins: a bucket that reaches 8 entries becomes a red-black tree (O(log n)), but only once the table has at least 64 buckets; smaller tables resize instead. It turns back into a list at 6.
- Resize: when
size > capacity × 0.75, the table doubles. Each bucket splits into a “low” and a “high” list by one bit of the hash (hash & oldCap), so nothing is rehashed and relative order is kept. - Variants:
LinkedHashMapthreads a doubly linked list through the entries (insertion or access order, the basis of LRU caches).HashSetis aHashMapwith a dummy value.ConcurrentHashMaplocks per bucket and allows nonullkeys or values.
Kotlin
Kotlin collections
- On the JVM there’s no separate implementation:
HashMapis a type alias forjava.util.HashMap, andmutableMapOf(),linkedMapOf()andmapOf(a to b, …)create ajava.util.LinkedHashMap. Everything about Java’s buckets, tree bins and resizing applies. - Index:
(n - 1) & (h ^ (h >>> 16)), chaining with linked nodes, a red-black tree for a bucket of 8+ entries once the table has 64+ buckets, and doubling at load factor 0.75. data classkeys getequalsandhashCodegenerated from the constructor properties, which is why they make good keys as long as those properties arevals.- Boxing: a
Map<Int, Int>storesjava.lang.Integerobjects. On Android,SparseArray/SparseIntArray(sorted primitive arrays, binary search) andArrayMap(two arrays, binary search on the hash) use far less memory for maps up to a few hundred entries, at O(log n) per lookup. - Kotlin/Native (Kotlin Multiplatform on iOS) ships its own open-addressing
HashMap, so don’t rely on JVM details like tree bins or iteration order in shared code.
TypeScript
Map and Set (V8, the engine in Chrome and Node.js)
- Two arrays: an entries array that keeps every key and value in insertion order, and a bucket array whose slots point to the first entry with that hash; entries in the same bucket are chained. This “deterministic hash table” design is why iteration order is guaranteed.
- Hashes: numbers are hashed by value, strings by content (computed once and cached in the string, with a seed chosen randomly per process), and objects by an identity hash: a random number stored on the object the first time it’s used as a key. That’s why object keys match only themselves.
- Equality is
SameValueZero: like===, exceptNaNmatchesNaN.1and"1"are different keys. - Deleting leaves a hole in the entries array; the table is compacted when it’s rebuilt, which also happens when it grows.
- Limits: a single
Mapin V8 holds at most about 16.7 million entries (2²⁴); beyond that,setthrowsRangeError. - Plain objects used as dictionaries are a different structure (hidden classes, switching to a slower “dictionary mode” as keys come and go), with string-only keys and inherited properties.
Mapis the hash map; objects are records.
Swift
Dictionary
- Storage: a value type backed by one copy-on-write buffer: a bitmap of occupied buckets, plus contiguous arrays of keys and values. No object per entry.
- Index:
Hasher(SipHash-1-3) with a random seed per process, mixed with a per-storage seed, then masked to a power-of-two bucket count. - Collisions: linear probing, with a maximum load factor of 3/4; inserting beyond it allocates a bigger buffer and reinserts everything.
- Removal: no tombstones. The entries after the hole in the same cluster are shifted back, so lookups never have to skip deleted slots.
- Copy-on-write:
var b = ashares the buffer, and the first mutation of a shared buffer copies the whole thing, O(n). Mutating a dictionary inside a class property or an array element while another reference holds it pays that copy every time. Setis the same table without the values array.NSDictionarybridging is lazy and uses Objective-Chash/isEqual:.
Kotlin, Java
Deep diveWhy Java XORs the high bits into the low bitsInterview favorite
The bucket index uses only the low bits: with 16 buckets, hash & 15 keeps 4 bits. Keys whose hashes differ only in their high bits would all land in one bucket. That happens more than you’d think: Float keys like 1.0f, 2.0f, 4.0f differ mostly in their exponent bits, and hand-written hashCode()s often multiply by large numbers.
h ^ (h >>> 16) folds the top 16 bits into the bottom 16, so every bit of the hash influences the index. It costs one shift and one XOR, which is why Java does this instead of a full mixing function (tree bins take care of the really bad cases).
Deep diveTree bins need Comparable keysInterview favorite
A tree bin is ordered by hash first. Keys with equal hashes (like "Aa" and "BB") are ordered with compareTo when the key class implements Comparable, such as String. Then a lookup in a bucket of thousands of colliding strings is O(log n).
If the keys aren’t Comparable, the tree can only use a tie-breaker (class name, then identity hash) for insertion, and a lookup may have to search both subtrees, which is O(n) again. Tree bins were added in Java 8 as the answer to hash flooding (below); make your key types Comparable, or better, give them good hash codes.
TypeScript
Deep diveMap or plain object?Interview favorite
Before ES2015, JavaScript developers used plain objects as hash maps, and a lot of code still does. The differences come up in interviews and in production:
- Keys: an object turns every key into a string (
obj[1]andobj["1"]are the same entry, and an object key becomes"[object Object]"). AMapkeeps keys as they are. - Inherited names:
counts["constructor"]on a plain object findsObject.prototype.constructor, so a word counter breaks on the word “constructor”.Object.create(null)avoids it; aMapnever has the problem. - Order: a
Mapiterates in insertion order; an object lists integer-like keys first, in numeric order, then the rest in insertion order. - Size and speed:
map.sizeis O(1),Object.keys(obj).lengthbuilds an array. Engines optimize objects for a fixed shape; adding and deleting many keys pushes them into a slower dictionary mode, whileMapis built for exactly that.
Use objects for records with known fields, Map for lookups with keys you don’t know in advance.
Swift
Deep diveWhy Swift seeds every dictionary differently
With linear probing, the order of insertion matters. Iterating a dictionary returns keys in bucket order. If a second, smaller dictionary used the same hash function, copying the first into it (for (k, v) in big { small[k] = v }) would insert keys in exactly the order that piles them into one growing cluster, and the copy becomes quadratic.
Swift therefore mixes a per-storage seed into every hash, so two dictionaries with the same keys usually iterate in different orders. The same idea explains why you can’t rely on order even between two dictionaries in one run. For reproducible tests, set the environment variable SWIFT_DETERMINISTIC_HASHING=1.
Where it goes wrong
- Hash flooding. If an attacker chooses the keys (JSON field names, form parameters, HTTP headers), they can send thousands that collide and turn every insert into O(n): a few kilobytes of request burn seconds of CPU. In 2011 this hit Java, PHP, Python, Ruby and ASP.NET at once. Strings like
"Aa"and"BB"can be combined into endless colliding keys ("AaAa","AaBB","BBAa","BBBB", …). Defenses: tree bins (Java 8), randomly seeded hashing (Swift, Python, Rust), and limits on the number of parameters a server accepts. - Resizing spikes. Insert is O(1) amortized: the insert that crosses the load factor copies every entry. In a latency-sensitive loop, or when you know the size, presize the map. Note that Java’s
new HashMap<>(1000)still resizes before 1,000 entries, because 1,000 × 0.75 < 1,000; Java 19 addedHashMap.newHashMap(1000), which accounts for the load factor. - Concurrent writes. None of these maps is thread-safe. In Java 7, two threads resizing a
HashMapat once could link a bucket into a cycle, and the nextgetlooped forever at 100% CPU; Java 8 removed the cycle, but lost updates and corrupted state remain. Swift reports a data race on aDictionaryas undefined behavior (and Swift 6 strict concurrency refuses to compile the sharing). - Changing the map while iterating. Java and Kotlin iterators are fail-fast and throw
ConcurrentModificationException(on a best-effort basis); useiterator.remove(),removeIforentries.removeAll { … }. Swift iterates over a value, so mutating inside the loop is safe, but may copy the whole dictionary. - Memory. A
HashMap<Integer, Integer>with a million entries holds three million objects (node, boxed key, boxed value). Primitive maps (Android’sSparseIntArray, fastutil on the server) or arrays indexed by key are often several times smaller. - Mutable keys and broken
equals/hashCodeare covered in Keys that behave.
Java
Pick the right map: ConcurrentHashMap for shared mutable state (and compute / merge for atomic updates), LinkedHashMap for predictable order or an LRU, EnumMap for enum keys (an array, no hashing), HashMap.newHashMap(n) when you know the size.
Kotlin
Pick the right map: ConcurrentHashMap (or a Mutex around a plain map in coroutines) for shared mutable state, HashMap(n) instead of mutableMapOf() when order doesn’t matter and memory does, EnumMap for enum keys, and SparseArray / ArrayMap for small maps on Android.
Swift
Pick the right map: an actor (or Mutex from the Synchronization module) around shared state, reserveCapacity(_:) or Dictionary(minimumCapacity:) when you know the size, and isKnownUniquelyReferenced / keeping one owner to avoid copy-on-write copies in hot paths.
TypeScript
Pick the right map: Map / Set for dynamic keys (not plain objects), string or number keys for compound values, and WeakMap for caching data about objects: its entries disappear when the key object is garbage collected, so the cache can’t leak.
Buckets, a hash function, equality inside a bucket, and a resize at a load factor: average O(1) insert, lookup and delete, O(n) iteration, and O(n) in the worst case when many keys collide.
Swift
Dictionary uses open addressing in one buffer and seeds hashing randomly per process (against hash flooding), so iteration order changes between runs.
Kotlin
HashMap() is Java’s: at a load factor of 0.75 it doubles, and a bucket of 8+ entries (in a table of 64+) becomes a red-black tree, so the worst case is O(log n). mutableMapOf() returns a LinkedHashMap, which keeps insertion order.
Java
At a load factor of 0.75 the table doubles, and a bucket of 8+ entries (in a table of 64+) becomes a red-black tree, so the worst case is O(log n). Iteration order is unspecified; LinkedHashMap keeps insertion order.
TypeScript
V8’s Map and Set are deterministic hash tables: buckets of chained entries, plus an entries array in insertion order, so iteration order is guaranteed. Objects and arrays are keys by identity (SameValueZero), never by contents. Use Map rather than a plain object for dynamic keys: object keys are always strings, and inherited names like "constructor" get in the way.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Create | [String: Int]() | mutableMapOf<String, Int>() (ordered), HashMap() | new HashMap<>() | new Map<string, number>() (ordered) |
| Get with default | m[k, default: 0] | m.getOrDefault(k, 0), m[k] ?: 0 | m.getOrDefault(k, 0) | m.get(k) ?? 0 |
| Increment | m[k, default: 0] += 1 | m.merge(k, 1, Int::plus) | m.merge(k, 1, Integer::sum) | m.set(k, (m.get(k) ?? 0) + 1) |
Never let output or tests depend on the iteration order of an unordered map.
Refresher:Hash, bucket, collision
The hash is a number computed from the key; the map uses it to pick a bucket (an array slot). Two keys in the same bucket are a collision, resolved by comparing the keys for equality. That’s why equal keys must have equal hashes.
Check this part
What is the average time to look up a key in a hash map?
A map holds
"Ana": 30. You write"Ana": 31, then add"Marko": 25. How many entries does it have?You print the same
Dictionaryin two runs of your program. Is the order the same?What doesmutableMapOf()give you, and in what order does it iterate?In what order does aHashMapiterate its entries?In what order does aMapiterate its entries?Many keys land in the same bucket of a
Dictionary. What is the worst case for a lookup?Many keys land in the same bucket of aHashMapon the JVM. What is a lookup in that bucket?In Java 8+, many keys land in the sameHashMapbucket. What is a lookup in that bucket?Many keys land in the same bucket of a V8Map. What is the worst case for a lookup?