Skip to content

Design HashMap and Skiplist

Both of these are structures you normally get from the standard library and never think about. Being asked to build one is a request to explain why it is fast, and in both cases the honest answer involves a caveat: a hash map is O(1)O(1) average and not worst case, and a skiplist is O(logn)O(\log n) expected with no guarantee at all.

When it is the wrong tool. In real Python, use dict and set — they are C implementations with an open-addressing design better tuned than anything you will write here. For a sorted container, sortedcontainers.SortedList beats a hand-rolled skiplist and bisect is fine when insertions are rare. These pages exist because the interview asks for the mechanism, not because you should ship one.

A hash map is an array of buckets plus a rule for collisions. The modulo is what makes the lookup fast; the chain is what makes the worst case slow:

arrayAn unbounded key space collapsing onto five bucketsLC 706 · O(1) average
b0: -0b1: -1b2: -2b3: -3b4: -4
buckets5size0load0.0worst chain0
buckets5size0
emptyA hash map is an **array of buckets** plus a rule for collisions. Each cell below is one bucket and shows the chain inside it. With 5 buckets, key k lands in bucket k % 5 — which means an unbounded key space collapses onto 5 slots, so collisions are not an edge case, they are the normal operating condition.
1/14

Keys 1, 6 and 11 all satisfy k % 5 == 1, so they share a bucket and form a chain. Watch the 'worst chain' chip rather than 'size': a lookup pays for the length of its own bucket, not for the number of keys in the map. That is the entire difference between O(1) average and O(1) worst case.

my_hash_map.py
class MyHashMap:
    """LC 706, with separate chaining."""
 
    def __init__(self, n_buckets=1009):        # a prime reduces clustering
        self.n = n_buckets
        self.buckets = [[] for _ in range(n_buckets)]
 
    def _index(self, key):
        return key % self.n                    # the modulo collapse
 
    def put(self, key, value):
        chain = self.buckets[self._index(key)]
        for i, (k, _) in enumerate(chain):
            if k == key:
                chain[i] = (key, value)        # UPDATE, do not append
                return
        chain.append((key, value))
 
    def get(self, key):
        for k, v in self.buckets[self._index(key)]:
            if k == key:
                return v
        return -1                              # LC 706's "absent" sentinel
 
    def remove(self, key):
        chain = self.buckets[self._index(key)]
        for i, (k, _) in enumerate(chain):
            if k == key:
                chain.pop(i)
                return
 
 
m = MyHashMap(n_buckets=5)
for k in [1, 6, 11]:
    m.put(k, k * 10)
print([m.get(1), m.get(11), m.get(4)])   # expect [10, 110, -1]

Three details carry the whole design:

  • The modulo is the lookup. key % n says where to look with no search at all. That is the source of the speed; everything else is damage control for collisions.
  • put must scan the chain for an existing key. Appending unconditionally leaves two entries with the same key, and get then returns whichever the scan reaches first — a stale-value bug, not a crash.
  • A prime bucket count reduces clustering. With n = 1000 and keys that are all multiples of 10, only 100 buckets are ever used. A prime has no small factors to conspire with the key distribution.

Resizing is the part candidates forget. As the load factor — keys divided by buckets — grows, so do the chains, and average lookup is O(1+load)O(1 + \text{load}). Real maps allocate a larger array once the load passes about 0.75 and rehash every key into it, because a key’s bucket depends on the bucket count. That rehash is O(n)O(n), which makes insertion O(1)O(1) amortised — the same accounting as list.append.

A sorted linked list has O(1)O(1) insert given the position and O(n)O(n) search, because you must walk it. A skiplist adds express lanes: each node appears at level 0, and with probability ½ at each level above, so the top lanes skip most of the list.

skiplist.py
import random
 
MAX_LEVEL = 16
 
 
class Node:
    __slots__ = ("val", "next")
 
    def __init__(self, val, level):
        self.val = val
        self.next = [None] * level             # one forward pointer per level
 
 
class Skiplist:
    """LC 1206."""
 
    def __init__(self):
        self.head = Node(float("-inf"), MAX_LEVEL)
 
    def _random_level(self):
        level = 1
        while random.random() < 0.5 and level < MAX_LEVEL:
            level += 1
        return level                           # P(level >= k) = 2^-(k-1)
 
    def _walk(self, target):
        """Return, per level, the last node strictly before `target`."""
        update = [self.head] * MAX_LEVEL
        cur = self.head
        for level in range(MAX_LEVEL - 1, -1, -1):
            while cur.next[level] and cur.next[level].val < target:
                cur = cur.next[level]          # hop forward on this lane
            update[level] = cur                # then DESCEND from here
        return update
 
    def search(self, target):
        node = self._walk(target)[0].next[0]
        return node is not None and node.val == target
 
    def add(self, num):
        update = self._walk(num)
        level = self._random_level()
        node = Node(num, level)
        for i in range(level):
            node.next[i] = update[i].next[i]   # splice in on every level it owns
            update[i].next[i] = node
 
    def erase(self, num):
        update = self._walk(num)
        node = update[0].next[0]
        if node is None or node.val != num:
            return False
        for i in range(len(node.next)):
            if update[i].next[i] is node:
                update[i].next[i] = node.next[i]
        return True

_walk is the entire structure. It descends from the top lane, hopping forward while the next value is still below the target and dropping a level when it is not — and it records where it left each lane, which is exactly what add and erase need to splice or unsplice. Search, insert and delete are all one _walk plus a constant amount of pointer work.

The hash map, and what a lookup actually pays

Section titled “The hash map, and what a lookup actually pays”

Keys [1, 6, 11, 2, 7, 3] into 5 buckets. 1 % 5, 6 % 5 and 11 % 5 are all 1.

putBucketResultBuckets afterLoadLongest chain
11empty slotb1: 10.21
61collisionb1: 1 -> 60.42
111collisionb1: 1 -> 6 -> 110.63
22empty slotb2: 20.83
72collisionb2: 2 -> 71.03
33empty slotb3: 31.23

Final state: b0: -, b1: 1 -> 6 -> 11, b2: 2 -> 7, b3: 3, b4: -.

Now the probe counts, which is the number that matters:

LookupBucketChainProbes
get(1)1[1, 6, 11]1
get(11)1[1, 6, 11]3
get(4)4[]0

A lookup pays for its own bucket, not for the map. get(11) costs three comparisons while get(4) costs none, in the same map with the same six keys. That is why the useful statistic is the longest chain, and why the trace above shows it as a chip.

And the degenerate case, which nothing in the code prevents:

KeysBucketsOccupancyLongest chain
[1, 6, 11, 2, 7, 3]5[0, 3, 2, 1, 0]3
[0, 5, 10, 15, 20]5[5, 0, 0, 0, 0]5 = n

Every key a multiple of the bucket count means one bucket holds everything and get is a linear scan. Verified. The map still works; it is just no longer a hash map in any useful sense.

Inserting 3, 7, 9, 12, 17, 19 with levels forced to 1, 3, 1, 2, 1, 4 (so the trace is reproducible rather than random):

text
L3:                                    19
L2:              7                     19
L1:              7        12           19
L0:   3          7    9   12   17      19

Verified by building it. Now search, counting a “probe” as either a forward hop or the comparison that makes us descend:

SearchDescent path (level, node)ProbesLevel-0 scan would take
19(2, 7) -> (1, 12) -> (0, 17)76
3none — first node on L041
10 (absent)(2, 7) -> (0, 9)64

At n = 6 the skiplist is slower than just walking the list, on all three queries. That is not a bug in the trace — it is the honest shape of the structure. Four levels of descent cost four comparisons before any useful hopping happens, and with six elements there is almost nothing to skip.

Measured across sizes, with random levels and 300 lookups each:

nSkiplist probesLevel-0 scan probeslog2n\log_2 n
65.33.52.6
5012.025.95.6
50017.8261.29.0
5,00025.42,567.812.3
50,00032.225,411.715.6

The crossover is somewhere below n = 50, and by 50,000 the skiplist is 790x cheaper. Note the skiplist column tracks roughly 2log2n2\log_2 n — one hop plus one descend per level, which is exactly the theoretical expectation.

StructureSearchInsertDeleteOrdered iterationSpace
Hash map (chaining)O(1)O(1) avg, O(n)O(n) worstO(1)O(1) avg amortisedO(1)O(1) avgO(nlogn)O(n \log n) — must sortO(n+b)O(n + b)
Hash map (open addressing)O(1)O(1) avgO(1)O(1) avg amortisedO(1)O(1) avg, needs tombstonesO(b)O(b), no per-entry lists
Sorted array + bisectO(logn)O(\log n)O(n)O(n) — the shiftO(n)O(n)O(n)O(n)O(n)O(n)
SkiplistO(logn)O(\log n) expectedO(logn)O(\log n) expectedO(logn)O(\log n) expectedO(n)O(n) — walk level 0O(n)O(n) expected, 2n2n pointers
Balanced BST (AVL / red-black)O(logn)O(\log n) guaranteedO(logn)O(\log n)O(logn)O(\log n)O(n)O(n)O(n)O(n)
Unsorted linked listO(n)O(n)O(1)O(1)O(n)O(n)O(n)O(n)

Four things worth being precise about:

  • The hash map’s O(1)O(1) is an average over a good hash. A lookup costs the length of its own chain: measured 1, 3 and 0 probes for three keys in the same six-key map, and n when every key collides.
  • Insertion is O(1)O(1) amortised, not worst case, because of the rehash on resize. Same accounting as list.append: the resize is O(n)O(n) and happens rarely enough that the total is linear.
  • The skiplist’s O(logn)O(\log n) is expected, not guaranteed — the only bound here that is probabilistic. A balanced tree guarantees it; the skiplist trades that guarantee for having no balancing code.
  • Space is O(n)O(n) but the constant is about 2: with p=12p = \frac12, the expected number of forward pointers per node is k2(k1)=2\sum_k 2^{-(k-1)} = 2. Lowering pp to ¼ reduces pointers to ~1.33 at the cost of taller searches.
VariantThe changeCanonical problem
Hash set instead of mapStore keys only; no value in the chain705 Design HashSet
Hash map with chainingBucket holds a list706 Design HashMap
Open addressingOn collision, probe the next slot; deletion needs a tombstonehow CPython’s dict works
ResizingDouble the bucket count past a ~0.75 load and rehash every keyany real map
Non-integer keyshash(key) % n, and rely on __hash__ / __eq__ agreeing
Sorted container, O(logn)O(\log n) insertSkiplist, or a balanced tree1206 Design Skiplist
Skiplist with a duplicate policyLC 1206 permits duplicates; erase removes one1206
Range queries on a skiplistWalk level 0 from the search position
Rank / order-statistic queriesStore a span (nodes skipped) on each forward pointerRedis sorted sets
Guaranteed, not expected, O(logn)O(\log n)AVL or red-black tree — more code, no randomness
Time-keyed storeA map from key to a sorted list, then bisect on the timestamps981 Time Based Key-Value Store
  • put appending without scanning the chain. A repeated key then has two entries, and get returns whichever the scan hits first. The value is stale rather than wrong-looking, so it survives casual testing.
  • A non-prime bucket count. With n = 1000 and keys that are multiples of 10, only 100 buckets are ever reachable. A prime has no small factors to conspire with the key distribution.
  • Claiming O(1)O(1) worst case for a hash map. It is O(1)O(1) average. Measured: [0, 5, 10, 15, 20] into 5 buckets gives occupancy [5,0,0,0,0] and a linear lookup.
  • Forgetting resizing entirely. Without it the load factor grows without bound and every chain becomes long. Mentioning the ~0.75 threshold and the O(n)O(n) rehash is most of what the follow-up wants.
  • Forgetting that a rehash must recompute every bucket. A key’s slot depends on the bucket count, so you cannot copy chains across — you re-insert.
  • Open addressing without tombstones. Deleting by clearing a slot breaks every probe sequence that passed through it, so later lookups stop early and report absent keys. Mark deleted rather than empty.
  • _walk restarting from the head on each level. This is the one that makes a skiplist slower than a linked list — measured 50,878 probes against a scan’s 25,412 at n = 50{,}000. Continue from the node the higher lane left you at.
  • Descending before hopping. The loop order is: hop forward while the next value is strictly less than the target, then descend. Reversing it skips nodes.
  • < target versus <= target in the walk. It must be strict, so the walk stops at the last node before the target and update[0].next[0] is the candidate. With <= you land on the target itself and add inserts in the wrong place.
  • erase unsplicing at only level 0. A node appears at every level up to its own height; missing the upper links leaves dangling forward pointers that still route through a removed node.
  • Expecting MAX_LEVEL to be free. Every node allocates a pointer array, and the head allocates MAX_LEVEL of them. 16 is plenty for n65,000n \approx 65{,}000 at p=12p = \frac12.
  • Hand-rolling either of these in production Python. dict is a tuned C open-addressing map; sortedcontainers.SortedList beats a Python skiplist comfortably.
15 problems
7 easy6 medium2 hard

Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.

Drill 2 — count the probes, not the keys

Section titled “Drill 2 — count the probes, not the keys”
They askWhat they’re checkingThe answer
“Why is a hash map O(1)O(1)?”Whether you can justify it or only assert itThe modulo says where to look with no search. But it is O(1)O(1) average: a lookup walks its own bucket’s chain, so the cost is the chain length, and the worst case is O(n)O(n)
“What happens on a collision?”The two standard answersChaining (the bucket holds a list) or open addressing (probe the next slot). CPython’s dict uses open addressing; chaining is easier to write under pressure
“What happens when it fills up?”Resizing, which candidates forgetPast a load factor of ~0.75, allocate a larger array and rehash every key — a key’s bucket depends on the bucket count, so chains cannot be copied across. The O(n)O(n) rehash makes insert O(1)O(1) amortised
“Why a prime number of buckets?”Depth on the hashIt has no small factors to align with structure in the keys. With 1000 buckets and keys that are multiples of 10, only 100 buckets are ever used
“Delete with open addressing?”The tombstoneMark the slot deleted rather than empty. Clearing it truncates every probe sequence that passed through, so later lookups report absent keys that are present
“Give me a sorted container with O(logn)O(\log n) insert.”Whether you know bisect is not itbisect finds the position in O(logn)O(\log n) but inserting shifts the tail, so it is O(n)O(n). A skiplist or a balanced tree is the answer
“Why a skiplist over a red-black tree?”Judgement, not asymptoticsSame expected bound with no balancing code — much less to get wrong. Range queries fall out of walking level 0, and it is far easier to make lock-free. Redis implements sorted sets with one, which is the answer that lands
“Is the skiplist bound guaranteed?”Honesty about randomnessNo — O(logn)O(\log n) expected. Unlucky flips genuinely can make it O(n)O(n), but no adversary can force it, because the randomness is in your coin flips rather than in the data. A balanced tree is the answer when the bound must be a guarantee
“Why probability ½?”Whether the constant is understoodIt makes the expected number of levels log2n\log_2 n and forward pointers per node 2. Lowering pp to ¼ cuts pointers to ~1.33 and makes searches taller — a space-versus-time knob
“Your skiplist is slower than a list. Why?”The one real implementation bug_walk is almost certainly restarting from the head at each level instead of continuing from where the higher lane stopped. Measured, that mistake costs 50,878 probes against a scan’s 25,412 at n = 50{,}000
“Would you write either of these in production?”Practical judgementNo — dict is a tuned C implementation and sortedcontainers.SortedList beats a Python skiplist. These are asked to see whether you can explain the mechanism
pch.quizTag pch.quizDefaultTitle
  1. In the traced map, get(1), get(11) and get(4) cost 1, 3 and 0 probes. What does that show?

    pch.quizShowAnswer

    B — A lookup pays for the length of ITS OWN bucket's chain, not for the number of keys -- which is why the bound is O(1) average rather than worst case — Three lookups in the same six-key map costing 1, 3 and 0 comparisons. The useful statistic is therefore the *longest chain*, not the size -- which is exactly what the trace shows as a chip. Feed the same map [0, 5, 10, 15, 20] with 5 buckets and occupancy becomes [5,0,0,0,0], making get a linear scan.

  2. Your `put` appends unconditionally instead of scanning the chain first. What is the symptom?

    pch.quizShowAnswer

    B — Duplicate entries for the same key; get returns whichever the scan reaches first, so the value is stale rather than obviously wrong — In the drill, put(6, 999) after put(6, 60) still makes get(6) return 999, because the scan finds the newer entry first. Only the chain *length* exposes the bug -- bucket 1 holds 4 entries instead of 3. Correct-looking output plus a hidden duplicate is exactly the combination that survives testing.

  3. Why use a prime number of buckets?

    pch.quizShowAnswer

    B — A prime has no small factors to align with structure in the keys -- with 1000 buckets and keys that are multiples of 10, only 100 buckets are ever reachable — The failure is a clustering one: any common factor between the bucket count and a stride in the key distribution collapses the usable buckets by that factor. Nothing about primes is faster, and nothing prevents collisions -- they only stop a *systematic* pile-up. Real implementations go further and randomise the hash.

  4. What must happen when a hash map's load factor passes about 0.75?

    pch.quizShowAnswer

    B — Allocate a larger bucket array and REHASH every key, because a key's bucket depends on the bucket count. That O(n) rehash makes insertion O(1) amortised — Chaining does keep working, but average lookup is O(1 + load), so chains grow without bound and the constant stops being constant. The detail candidates miss is that chains cannot be copied across -- key % new_n differs from key % old_n, so every key is re-inserted. Same amortisation argument as list.append.

  5. You need a sorted container with O(log n) insertion. Is `bisect.insort` enough?

    pch.quizShowAnswer

    B — No -- bisect finds the position in O(log n) but the insertion shifts the tail, so insort is O(n). A skiplist or balanced tree is needed — Only the locating half is logarithmic. Because the shift is a fast memmove, insort stays competitive well past where the asymptotics suggest -- so it is a fine choice for occasional insertions. For many, the O(n) per insert dominates, which is exactly the gap a skiplist fills.

  6. Is a skiplist's O(log n) guaranteed?

    pch.quizShowAnswer

    B — No -- it is EXPECTED. Unlucky coin flips can degrade it, but no adversary can force that, because the randomness is in your flips rather than the data — This is the trade the structure makes: a balanced tree guarantees the bound by *maintaining* balance through rotations, while a skiplist gets the same expected bound with no balancing code at all. The randomised-not-average distinction matters -- it is the same argument as randomising a quicksort pivot, and it is why an adversarial input cannot hurt you.

  7. Your skiplist is slower than a plain linked-list scan at n = 50,000. What is almost certainly wrong?

    pch.quizShowAnswer

    B — The walk restarts from the head at each level instead of continuing from where the higher lane stopped -- measured, that costs 50,878 probes against a scan's 25,412 — Restarting per level is not a skiplist at all, it is a separate full scan per level -- so it is strictly worse than one scan. The entire mechanism is that work done on a higher lane is never repeated: you descend *from the node you stopped at*. Correctly implemented, probe counts track about 2*log2(n): 32 at n = 50,000.

  8. At n = 6 the traced skiplist needs 7 probes to find 19 while a level-0 walk needs 6. Is the trace wrong?

    pch.quizShowAnswer

    B — No -- four levels of descent cost four comparisons before any useful hopping, and with six elements there is nothing to skip. The crossover is measured below n = 50 — The express lanes have a fixed setup cost -- one comparison per level, whether or not that lane helps. Measured across sizes: at n = 6 a scan wins 3.5 to 5.3 probes; at n = 50 the skiplist wins 12.0 to 25.9; at n = 50,000 it wins 32.2 to 25,411.7. Structures with a per-level overhead often lose on tiny inputs, and saying so is more useful than claiming they always win.

  9. Why does Redis implement sorted sets with a skiplist rather than a balanced tree?

    pch.quizShowAnswer

    B — No rebalancing code (far less to get wrong), range queries fall out of walking level 0, and it is much easier to make lock-free for concurrency — The bounds are the same, so the answer is engineering rather than asymptotics -- which is what makes it a good answer to give. An insert touches only forward pointers instead of rotating subtrees, which is both simpler to reason about and much friendlier to concurrent access. Memory is actually slightly worse: about 2 forward pointers per node at p = 1/2.

  • A hash map is an array of buckets plus a collision rule. key % n says where to look with no search — that is the whole source of the speed.
  • put must scan its chain for the key before appending, or duplicates accumulate and get returns a stale value with no error.
  • A lookup pays for its own chain, not the map size: measured 1, 3 and 0 probes for three keys in one six-key map. So the statistic that matters is the longest chain.
  • O(1)O(1) is the average. [0, 5, 10, 15, 20] into 5 buckets gives [5,0,0,0,0] — a linear lookup, undetected.
  • Use a prime bucket count. 1000 buckets with multiples-of-10 keys reaches only 100 of them.
  • Resize past a ~0.75 load and rehash every key — buckets depend on the count, so chains cannot be copied. The O(n)O(n) rehash makes insert O(1)O(1) amortised.
  • Open addressing needs tombstones — clearing a slot truncates probe sequences through it.
  • A skiplist is a sorted list plus express lanes, each node at level 0 and at each higher level with probability ½. Expected log2n\log_2 n levels, ~2 pointers per node.
  • _walk is the whole structure: hop while next.val < target (strict), then descend from where you stopped — never restart from the head. That mistake makes it slower than a plain scan (50,878 vs 25,412 probes at n = 50{,}000).
  • O(logn)O(\log n) is expected, not guaranteed — but unforceable by an adversary, since the randomness is in your flips. Use a balanced tree when the bound must be a guarantee.
  • Express lanes lose on tiny inputs: a scan wins at n = 6 (3.5 vs 5.3 probes); the skiplist wins by n = 50 and by 790x at n = 50{,}000.
  • bisect.insort is O(n)O(n), not O(logn)O(\log n) — the search is logarithmic, the shift is not.
  • Redis sorted sets are skiplists: no rebalancing code, free range queries, easy to make lock-free. That is the answer to “why not a red-black tree?”.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading