Level 3 — ProductionHashing

Hashing

45 minutes60 marksprintable — key stays hidden on paper

Subject: Coding · Chapter: Hashing Time limit: 45 minutes · Total marks: 60

Instructions: Write code from memory where asked. Explain reasoning explicitly. Use ...... for math.


Question 1 — Hash Table with Chaining (from scratch) [12 marks]

Implement a hash table using separate chaining (Python, no dict/set). Your class HashMap must support put(key, value), get(key), and delete(key), and must resize (double the number of buckets and rehash) when the load factor exceeds 0.75.

(a) Write the complete class. [9] (b) State the load factor α\alpha formula and explain why resizing keeps operations amortized O(1)O(1). [3]


Question 2 — Open Addressing & Tombstones [11 marks]

An open-addressing table of size m=7m = 7 uses linear probing with h(k)=kmod7h(k) = k \bmod 7. Keys are inserted in order: 10,17,24,310, 17, 24, 3.

(a) Show the final array contents (index → key). [4] (b) Now delete(17). Explain why you cannot simply blank the slot, and show the array using a tombstone marker. [3] (c) After the deletion, trace a search(24). Show every probe and explain why the tombstone must not stop the search. [4]


Question 3 — Probing strategies & clustering [9 marks]

(a) Define linear probing, quadratic probing, and double hashing with their probe-sequence formulas. [4] (b) Explain primary clustering and which strategy suffers from it. [2] (c) For double hashing on table size m=13m=13 with h1(k)=kmod13h_1(k)=k\bmod 13 and h2(k)=1+(kmod11)h_2(k)=1+(k\bmod 11), list the first three probe indices for key k=27k=27. [3]


Question 4 — Load factor, resizing cost, amortization [10 marks]

(a) A chaining table resizes by doubling. Starting from capacity 1, we insert nn keys. Show that the total cost of all rehashes is O(n)O(n), hence amortized O(1)O(1) per insert. [5] (b) Under uniform hashing with load factor α\alpha, give the expected number of probes for an unsuccessful search in (i) chaining and (ii) open addressing. [4] (c) State one reason to keep α\alpha well below 1 in open addressing. [1]


Question 5 — Universal hashing [8 marks]

Let H\mathcal{H} be a family of hash functions mapping a universe UU to {0,,m1}\{0,\dots,m-1\}.

(a) Give the formal definition of a universal family. [3] (b) For a randomly chosen hHh\in\mathcal H and two distinct keys x,yx,y, what is the bound on collision probability, and why does this give a probabilistic O(1)O(1) guarantee independent of adversarial input? [3] (c) With nn keys in a table of size mm using a universal family, give the expected number of keys colliding with a fixed key xx. [2]


Question 6 — Application: Two-Sum with a hash set [10 marks]

(a) Write a function two_sum(nums, target) returning indices (i, j) with nums[i] + nums[j] == target, using a hash map in a single pass. [5] (b) State its time and space complexity and justify. [2] (c) Explain why a Python dict gives average O(1)O(1) lookups here, referencing its internal open-addressing implementation and what happens on a collision. [3]


Answer keyMark scheme & solutions

Question 1 [12]

(a) [9] — 3 for structure/hash+resize trigger, 3 for put/get, 3 for delete + rehash correctness.

class HashMap:
    def __init__(self, cap=8):
        self.cap = cap
        self.size = 0
        self.buckets = [[] for _ in range(cap)]
 
    def _idx(self, key):
        return hash(key) % self.cap        # deterministic, fast
 
    def put(self, key, value):
        b = self.buckets[self._idx(key)]
        for i, (k, v) in enumerate(b):
            if k == key:
                b[i] = (key, value)         # update existing
                return
        b.append((key, value))
        self.size += 1
        if self.size / self.cap > 0.75:     # load factor check
            self._resize()
 
    def get(self, key):
        for k, v in self.buckets[self._idx(key)]:
            if k == key:
                return v
        raise KeyError(key)
 
    def delete(self, key):
        b = self.buckets[self._idx(key)]
        for i, (k, v) in enumerate(b):
            if k == key:
                b.pop(i)
                self.size -= 1
                return
        raise KeyError(key)
 
    def _resize(self):
        old = self.buckets
        self.cap *= 2
        self.buckets = [[] for _ in range(self.cap)]
        self.size = 0
        for b in old:
            for k, v in b:
                self.put(k, v)              # rehash into larger table

(b) [3]

  • Load factor α=n/m\alpha = n/m (entries / buckets). [1]
  • With chaining, expected chain length is α\alpha; keeping α0.75\alpha \le 0.75 keeps chains short so each op is O(1+α)=O(1)O(1+\alpha)=O(1) expected. [1]
  • Resizing is O(n)O(n) but happens rarely (only after nn doublings), so its cost amortizes to O(1)O(1) per insert. [1]

Question 2 [11]

(a) [4] h(k)=kmod7h(k)=k\bmod 7:

  • 10mod7=310\bmod7=3 → slot 3
  • 17mod7=317\bmod7=3 → occupied, probe 4 → slot 4
  • 24mod7=324\bmod7=3 → 3,4 occupied, probe 5 → slot 5
  • 3mod7=33\bmod7=3 → 3,4,5 occupied, probe 6 → slot 6
idx 0 1 2 3 4 5 6
key 10 17 24 3

(1 mark per correctly placed key.)

(b) [3] Blanking slot 4 would break the probe chain: a later search for 24 (which probed 3→4→5) would hit the empty slot 4 and stop early, wrongly concluding 24 is absent. [2] Instead mark slot 4 as a tombstone (DELETED): [1]

idx 3 4 5 6
key 10 🪦 24 3

(c) [4] search(24): h(24)=3h(24)=3.

  • Probe 3: key 10 ≠ 24, continue. [1]
  • Probe 4: tombstone → do not stop, keep probing (a tombstone means "was occupied, may have kept a later key on the chain"). [1+1]
  • Probe 5: key 24 → found. [1]

Question 3 [9]

(a) [4] Probe sequence for slot i=0,1,2,i=0,1,2,\dots:

  • Linear: h(k,i)=(h(k)+i)modmh(k,i) = (h(k)+i)\bmod m. [1]
  • Quadratic: h(k,i)=(h(k)+c1i+c2i2)modmh(k,i) = (h(k)+c_1 i + c_2 i^2)\bmod m. [1.5]
  • Double hashing: h(k,i)=(h1(k)+ih2(k))modmh(k,i) = (h_1(k)+i\cdot h_2(k))\bmod m. [1.5]

(b) [2] Primary clustering: contiguous runs of occupied slots grow and merge, so long runs get longer, degrading probes. Linear probing suffers from it. [2]

(c) [3] h1(27)=27mod13=1h_1(27)=27\bmod13=1; h2(27)=1+(27mod11)=1+5=6h_2(27)=1+(27\bmod11)=1+5=6.

  • i=0i=0: 11
  • i=1i=1: (1+6)mod13=7(1+6)\bmod13=7
  • i=2i=2: (1+12)mod13=0(1+12)\bmod13=0

Probe indices: 1, 7, 0. [1 each]


Question 4 [10]

(a) [5] Doubling from capacity 1: rehashes occur at sizes 1,2,4,,2kn1,2,4,\dots,2^k \le n. Cost of rehash at size 2j2^j is Θ(2j)\Theta(2^j). Total: j=0log2n2j=2log2n+11=2n1=O(n).\sum_{j=0}^{\log_2 n} 2^j = 2^{\log_2 n +1}-1 = 2n-1 = O(n). [3] Dividing by nn inserts gives amortized O(1)O(1) per insert. [2]

(b) [4]

  • (i) Chaining: expected probes =α= \alpha (must scan the whole chain). [2]
  • (ii) Open addressing (uniform): 11α\dfrac{1}{1-\alpha}. [2]

(c) [1] As α1\alpha\to1 in open addressing, 11α\frac{1}{1-\alpha} blows up (probes explode / table fills); keeping α\alpha low keeps probes bounded. [1]


Question 5 [8]

(a) [3] H\mathcal H is universal if for all distinct xyUx\neq y \in U: PrhH[h(x)=h(y)]1m.\Pr_{h\in\mathcal H}[h(x)=h(y)] \le \frac{1}{m}.

(b) [3] Collision probability 1/m\le 1/m regardless of which keys the adversary picks, because hh is chosen randomly after the keys are fixed. [2] So expected chain length / probe count stays O(1+α)O(1+\alpha) in expectation over the choice of hh — no fixed input can force worst-case behaviour. [1]

(c) [2] Expected colliders with fixed xx among the other n1n-1 keys: yxPr[h(y)=h(x)]n1m=O(α).\sum_{y\neq x}\Pr[h(y)=h(x)] \le \frac{n-1}{m} = O(\alpha).


Question 6 [10]

(a) [5]

def two_sum(nums, target):
    seen = {}                       # value -> index
    for j, x in enumerate(nums):
        need = target - x
        if need in seen:            # O(1) average lookup
            return (seen[need], j)
        seen[x] = j
    return None

(2 for single-pass structure, 2 for complement lookup, 1 for correct return.)

(b) [2] Time O(n)O(n): one pass, each dict op O(1)O(1) average. Space O(n)O(n): up to nn entries stored. [2]

(c) [3] CPython's dict is an open-addressing hash table. The key's hash selects a slot; [1] on a collision it uses a perturbation-based probe sequence to find the next slot; [1] since α\alpha is kept below ~2/3 (it resizes/rehashes otherwise), probe chains stay short, giving average O(1)O(1) lookup. [1]


[
  {"claim":"Q2a: linear probing places 10,17,24,3 at slots 3,4,5,6",
   "code":"m=7; slots=[None]*m\nfor k in [10,17,24,3]:\n    i=k%m\n    while slots[i] is not None: i=(i+1)%m\n    slots[i]=k\nresult = slots==[None,None,None,10,17,24,3]"},
  {"claim":"Q3c: double hashing probes for k=27 are 1,7,0",
   "code":"m=13; h1=27%13; h2=1+(27%11)\nprobes=[(h1+i*h2)%m for i in range(3)]\nresult = probes==[1,7,0]"},
  {"claim":"Q4a: geometric doubling total cost is 2n-1 for n a power of 2 (n=8)",
   "code":"n=8; total=sum(2**j for j in range(0, n.bit_length()))\nresult = total==2*n-1"},
  {"claim":"Q4b: open-addressing unsuccessful search probes 1/(1-alpha) at alpha=0.75 equals 4",
   "code":"from sympy import Rational\nalpha=Rational(3,4)\nresult = (1/(1-alpha))==4"}
]