Implement a hash map from scratch (get, set, remove), then extend it: what do you test and how does resizing work?

29.2.2 · L3 · Interview

Reported at: Meta, Uber

Predict first

What does the chained hash table report?

struct HashTable<Key: Hashable, Value> {
    private var buckets: [[(key: Key, value: Value)]]
    private(set) var count = 0
    private(set) var resizes = 0

    init(capacity: Int = 4) { buckets = Array(repeating: [], count: capacity) }

    private func slot(_ key: Key, in n: Int) -> Int { Int(UInt(bitPattern: key.hashValue) % UInt(n)) }

    subscript(key: Key) -> Value? {
        get { buckets[slot(key, in: buckets.count)].first { $0.key == key }?.value }
        set {
            let s = slot(key, in: buckets.count)
            if let i = buckets[s].firstIndex(where: { $0.key == key }) {
                if let v = newValue { buckets[s][i].value = v }
                else { buckets[s].remove(at: i); count -= 1 }
            } else if let v = newValue {
                buckets[s].append((key, v)); count += 1
                if count * 4 > buckets.count * 3 { grow() }            // load factor above 0.75
            }
        }
    }
    private mutating func grow() {
        let bigger = buckets.count * 2
        var next = [[(key: Key, value: Value)]](repeating: [], count: bigger)
        for bucket in buckets { for e in bucket { next[slot(e.key, in: bigger)].append(e) } }
        buckets = next; resizes += 1
    }
}
struct BadKey: Hashable {                          // every key hashes alike: still correct, just slow
    let id: Int
    func hash(into h: inout Hasher) { h.combine(0) }
}
var t = HashTable<Int, String>()
for i in 1...20 { t[i] = "v\(i)" }
print("count", t.count, "resizes", t.resizes)
t[5] = "five"                                       // overwrite: count must not change
print("t[5]", t[5] ?? "nil", "count", t.count)
t[7] = nil
print("t[7]", t[7] ?? "nil", "count", t.count)
print("t[99]", t[99] ?? "nil")
var bad = HashTable<BadKey, Int>()
for i in 0..<10 { bad[BadKey(id: i)] = i * i }
print("bad[9]", bad[BadKey(id: 9)] ?? -1, "count", bad.count)
  • count 20 resizes 3 / t[5] five count 20 / t[7] nil count 19 / t[99] nil / bad[9] 81 count 10
  • count 20 resizes 4 / t[5] five count 21 / t[7] nil count 19 / t[99] nil / bad[9] 81 count 10
  • count 20 resizes 3 / t[5] five count 20 / t[7] 7 count 19 / t[99] nil / bad[9] -1 count 10

Mental model. A wall of pigeonholes labelled by a number you compute from the name. Pigeonholes can hold several letters, and when the wall gets crowded you build a bigger one and refile every letter.

The core is an array of buckets; the key’s hash modulo the bucket count picks a bucket, and each bucket holds the entries that landed there (separate chaining). get finds the bucket and searches it for an equal key; set overwrites an existing key (the count must not change) or appends; remove deletes the entry. Use a generic Key: Hashable, and take the hash with UInt(bitPattern: key.hashValue) because hashValue can be negative. Expected O(1) operations as long as buckets stay short.

That depends on the load factor (entries divided by buckets). Past a threshold (0.75 here) grow: allocate twice the buckets and re-insert every entry with the new modulus, because the old positions are no longer valid; the 20 inserts in the check trigger three doublings (4, 8, 16, 32 buckets). Then extend it as asked: conform to Sequence, add Dictionary-style literal init, or use open addressing. Test insert and overwrite, missing keys, removal, many keys across resizes, and a key type with a terrible hash: the program’s BadKey puts everything in one bucket and is still correct, only slower, which shows that correctness must not depend on hash quality. Reported at Meta and Uber (anonymous, unverified); do not print raw iteration order, because Swift randomises hashing per process.

Twenty inserts into four starting buckets grow it three times; overwriting keeps the count, removal decrements it, and colliding keys still work.

Common traps

  • Not rehashing on resize. Entries moved to a bigger array but kept at old positions are unreachable.
  • Counting an overwrite as a new entry, which triggers early resizes and a wrong count.

They might follow up with

  • Chaining versus open addressing? Chaining is simpler and tolerates high load; open addressing is more cache friendly but needs tombstones for removal.
  • Why is the order of a Swift Dictionary not stable between runs? Per-process random hash seeding protects against collision attacks.

Related questions

  • 29.1.1 Two-sum: given an array and a target, return the indices of two elements that add up to it. How do you get…
  • 29.1.3 Write an Array extension that removes every element that appears in a second array. What is the complexity…
  • 29.2.1 Reverse a singly linked list in Swift, iteratively and recursively. What can go wrong with long lists?

← Reverse a singly linked list in Swift, iteratively and recursively.…   Given a binary search tree and a decimal target, find the value… →