Hashing
Chapter: 3.3 Hashing Level: 5 — Mastery (cross-domain: probability + algorithm analysis + coding) Time limit: 75 minutes Total marks: 60
Instructions: Answer all three questions. Show reasoning, derivations, and complexity arguments. Code may be written in Python-like pseudocode or valid Python. Use for inline math.
Question 1 — Probabilistic Analysis of Chaining & Universal Hashing (20 marks)
A hash table with chaining has buckets and stores keys. Assume a family of hash functions that is universal, i.e. for any distinct keys ,
(a) Define load factor and state, with a one-line justification, the expected number of keys per bucket. (3)
(b) Fix a key already in the table. Let be the number of other keys that collide with (land in the same bucket). Prove that Use indicator random variables and linearity of expectation. (6)
(c) Hence show the expected time for an unsuccessful search is and explain why the "" term cannot be dropped. (4)
(d) Consider the concrete universal family over a prime (universe size ): For , , , , compute for keys and . Do they collide? (4)
(e) State in one sentence why a deterministic fixed hash function cannot give the same worst-case guarantee that universal hashing gives. (3)
Question 2 — Open Addressing, Tombstones & Amortized Resizing (22 marks)
A table uses open addressing with table size and the hash function .
(a) Insert the keys in this order using linear probing (). Show the final array, listing index → key. (5)
(b) Now insert the same keys using double hashing with , . Show the final array. (5)
(c) Explain precisely why, when deleting from an open-addressing table, we must place a tombstone rather than emptying the slot. Give a small concrete failure scenario (using part (a)'s table) that shows what breaks if we simply empty a slot. (4)
(d) Amortized cost proof. A dynamic table starts empty and doubles its capacity whenever load factor reaches (each resize rehashes all current elements at cost equal to the number of elements moved). Using the aggregate method, prove that the total cost of inserting items is , hence amortized per insertion. Include the geometric-series bound explicitly. (6)
(e) For linear probing with load factor , the expected number of probes for an unsuccessful search is approximately . Evaluate this for and comment on what it implies about resize thresholds. (2)
Question 3 — Build & Apply: LRU Cache and Two-Sum (18 marks)
(a) The two-sum problem: given an array and target , return indices with . Write a single-pass algorithm using a hash map and state its time and space complexity, justifying the time via amortized hash operations. (6)
(b) Trace your algorithm on , . Show the map contents step by step and the returned indices. (4)
(c) Build an LRU cache supporting get(key) and put(key, value) in average time with capacity . Describe the data structures used (hash map + doubly linked list), give pseudocode for both operations, and explain how each achieves . (6)
(d) Explain why a Python dict (which preserves insertion order) alone is not sufficient to get true LRU behaviour without extra care, and what operation would be if done naively. (2)
Answer keyMark scheme & solutions
Question 1
(a) [3]
- Load factor . (1) — definition.
- Under simple uniform hashing each key is equally likely in any bucket, so expected keys per bucket . (2) — justification by symmetry/linearity.
(b) [6] For each other key define indicator if , else . (2) By universality, . (2) , so by linearity of expectation:
(c) [4]
- Unsuccessful search hashes (cost ) then scans the whole chain of its bucket. (1)
- Expected chain length (or for a probe key), so scan cost ; total . (2)
- The "" accounts for computing the hash and accessing the bucket even when the chain is empty (); dropping it would wrongly imply zero cost when . (1)
(d) [4]
- : ; ; . (1.5)
- : ; ; . (1.5)
- Both map to bucket → they collide. (1)
(e) [3]
- For any fixed deterministic hash function, an adversary who knows the function can choose keys that all hash to the same bucket, forcing worst-case per operation; randomizing the choice of from a universal family means no fixed input is bad in expectation, giving the probabilistic guarantee. (3)
Question 2
(a) Linear probing, , . [5]
| key | h | probes | slot |
|---|---|---|---|
| 22 | 0 | — | 0 |
| 1 | 1 | — | 1 |
| 13 | 2 | — | 2 |
| 24 | 2 | 2 taken, 3 free | 3 |
| 12 | 1 | 1,2,3 taken → 4 free | 4 |
Final: index0→22, 1→1, 2→13, 3→24, 4→12; others empty. (5) (deduct 1 per misplaced key)
(b) Double hashing, . [5]
- 22: → slot 0.
- 1: → slot 1.
- 13: → slot 2.
- 24: occupied. . probe : → slot 6.
- 12: occupied. . probe : → slot 3.
Final: 0→22, 1→1, 2→13, 3→12, 6→24. (5)
(c) [4]
- In open addressing, a search stops when it hits an empty slot (that signals "key not present along this probe chain"). (1)
- If we simply empty a deleted slot, keys that were placed after it during probing become unreachable — the search terminates early at the false empty slot. (2)
- A tombstone marks "deleted but keep probing"; it is treated as occupied for search, free for insertion. (1)
- Concrete failure (from (a)): delete key 13 (slot 2) by emptying. Searching for 24: finds slot 2 empty → wrongly reports 24 absent, though 24 sits at slot 3. (counts toward the 3 marks above)
(d) Aggregate/amortized proof. [6]
- Between resizes, each ordinary insertion costs ; over inserts these contribute . (1)
- Resizes occur when size hits ; the -th resize copies elements. (2)
- Total copy cost (geometric series). (2)
- Total cost ; dividing by gives amortized per insertion. (1)
(e) [2] About 50 probes on average — catastrophic; hence linear-probing tables should resize at a much lower threshold (e.g. ). (2)
Question 3
(a) [6]
def two_sum(A, T):
seen = {} # value -> index
for j, x in enumerate(A):
if (T - x) in seen: # O(1) avg lookup
return (seen[T - x], j)
seen[x] = j # O(1) avg insert
return None
- Single pass, each element does one lookup + one insert, both amortized (hashing) → total time. (4)
- Space for the map. (2)
(b) Trace on , . [4]
| j | x | T-x | in seen? | seen after |
|---|---|---|---|---|
| 0 | 3 | 7 | no | {3:0} |
| 1 | 8 | 2 | no | {3:0, 8:1} |
| 2 | 2 | 8 | yes (8→1) | return (1,2) |
Returned indices (1, 2) since . (4)
(c) [6]
Structures: a hash map key → node and a doubly linked list ordered by recency (head = most recent, tail = least recent). (2)
get(key):
if key not in map: return -1
node = map[key]
move_to_head(node) # unlink + insert at head, O(1)
return node.value
put(key, value):
if key in map:
node = map[key]; node.value = value; move_to_head(node)
else:
node = new Node(key, value)
map[key] = node; add_to_head(node)
if len(map) > C:
lru = tail.prev
remove(lru); del map[lru.key] # evict LRU
- Map gives average lookup; DLL gives unlink/insert/eviction because node pointers are known directly (no scan). (2 for pseudocode, 2 for the justification)
(d) [2]
A plain dict preserves order, but to mark a key most-recently-used you must move it to the end. Doing that naively (delete then reinsert) is in CPython, but finding/removing the least-recently-used front element, or reordering an arbitrary middle element by rebuilding, would be ; the explicit DLL (or OrderedDict.move_to_end) is needed to guarantee eviction/repositioning. (2)
[
{"claim":"h(4)=0 and h(21)=0 for a=3,b=5,p=17,m=6, so they collide",
"code":"a,b,p,m=3,5,17,6\nh4=((a*4+b)%p)%m\nh21=((a*21+b)%p)%m\nresult=(h4==0 and h21==0 and h4==h21)"},
{"claim":"Linear probing final placement: 22->0,1->1,13->2,24->3,12->4",
"code":"m=11\ntab=[None]*m\nfor k in [22,1,13,24,12]:\n i=0\n while tab[(k%m+i)%m] is not None: i+=1\n tab[(k%m+i)%m]=k\nresult=(tab[0]==22 and tab[1]==1 and tab[2]==13 and tab[3]==24 and tab[4]==12)"},
{"claim":"Double hashing places 24 at slot 6 and 12 at slot 3",
"code":"m=11\ntab=[None]*m\ndef h2(k): return 7-(k%7)\nfor k in [22,1,13,24,12]:\n i=0\n while tab[(k%m+i*h2(k))%m] is not None: i+=1\n tab[(k%m+i*h2(k))%m]=k\nresult=(tab[6]==24 and tab[3]==12)"},
{"claim":"Linear probing unsuccessful-search estimate at alpha=0.9 equals 50.5",
"code":"al=Rational(9,10)\nval=Rational(1,2)*(1+1/(1-al)**2)\nresult=(val==Rational(101,2))"},
{"claim":"Geometric sum of resize copies up to n=8 is < 2n",
"code":"n=8\ntotal=sum(2**j for j in range(0,4))\nresult=(total==15 and total<2*n+1)"}
]