Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ৬/২৪অ্যাডভান্সড১ ঘণ্টা

Hash Table — O(1)-এর জাদু আর তার দাম

Hash Tables: Collisions, Open Addressing, Chaining

Hash table array-র O(1) indexing-কে key-তে সাধারণীকরণ করে — কিন্তু pigeonhole বলে collision থামানো যায় না, তাই আসল engineering হলো collision-কে সস্তা করা: chaining, probing, load factor, resize, আর adversarial keys-এর বিরুদ্ধে randomization।

এই লেসন শেষে আপনি পারবেন

  • Hash function-এর তিনটা দাবি — determinism, uniformity, avalanche — আলাদা করে ব্যাখ্যা করে বলতে পারবেন কেন খারাপ hash পুরো table-কে linear বানিয়ে দেয়
  • Pigeonhole principle দিয়ে প্রমাণ করতে পারবেন collision অনিবার্য, আর chaining বনাম open addressing-এ collision resolution-এর দুই দর্শনের trade-off বিশ্লেষণ করতে পারবেন
  • Linear probing-এর primary clustering, quadratic probing আর double hashing-এর পার্থক্য দেখাতে পারবেন — কেন tombstone ছাড়া deletion probe-chain ভাঙে
  • Load factor আর resizing-এর সম্পর্ক amortized analysis দিয়ে justify করতে পারবেন — rehash-এর দামি মুহূর্ত কীভাবে গড়ে ছড়ায়
  • PYTHONHASHSEED randomization আর crafted colliding keys দিয়ে দেখাতে পারবেন adversarial input কীভাবে average-case O(1)-কে O(n²)-এ ভাঙে — hash DoS-এর প্রক্রিয়া

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

Array দিয়ে আমরা একটা জাদু পেয়েছিলাম: arr[7] লিখলেই সপ্তম slot-এ সরাসরি পৌঁছে যাওয়া — কোনো খোঁজা নেই, শুধু arithmetic। কিন্তু index-টা তো integer, শূন্য থেকে শুরু। বাস্তব প্রোগ্রামে আমরা যা চাই তা অন্যটা: user["rafiq"], cache["session-9f2"] — string, tuple, যা-খুশি key দিয়ে সরাসরি পৌঁছানো।

Hash table ঠিক এই লাফটা: hash function key-কে integer-এ বদলায়, integer-কে slot-এ ম্যাপ করা যায়, আর slot- access আমরা জানি — O(1)। মনে হয় সমস্যা শেষ: সব lookup constant time! কিন্তু pigeonhole principle দরজায় দাঁড়িয়ে: সম্ভাব্য key অসীম, slot সসীম — দুইটা ভিন্ন key কখনো না কখনো একই slot চাইবেই। Collision থামানো যায় না; প্রশ্ন শুধু কীভাবে সস্তা করা যায়।

আজকের প্রশ্ন তিনটা: (১) কোন hash function “ভালো” — ঠিক কোন মানদণ্ডে? (২) Collision-এর দুই দর্শন — chaining আর open addressing — কোথায় কে জেতে, আর deletion কেন দুজনেরই সবচেয়ে কঠিন অপারেশন? (৩) Average-case O(1)-এর ওই “average” কার হাতে — আর adversary assumption-টা ভাঙলে কী হয়?

মূল ধারণা

ভালো hash function-এর তিনটা দাবি

  • Determinism — একই key সবসময় একই hash। এটা না মানলে lookup অসম্ভব: ঢোকার সময় এক slot, খোঁজার সময় আরেক slot!
  • Uniformity — key-space-এর বাস্তব distribution জুড়ে output slot-গুলোতে সমভাবে ছড়ানো। Clustering মানেই কিছু chain লম্বা, কিছু খালি — গড় O(1) কিন্তু বাস্তবে দুষ্ট bucket-এ বসে থাকা।
  • Avalanche — input-এর এক bit বদলালে output-এর প্রায় অর্ধেক bit বদলানোর সম্ভাবনা। এটা uniformity-র সূক্ষ্ম রূপ: similar keys (user1, user2, user3…) দূরে দূরে পড়ার নিশ্চয়তা।

খারাপ hash-এর canonical উদাহরণ: keys যেগুলো power-of-two-র গুণিতক দূরত্বে থাকে, আর slot = hash mod 2ᵏ — তাহলে নিচের কয়েকটা bit-ই সব সিদ্ধান্ত নেয়, আর ওই bit-গুলো সব key-তে identical। সব key এক bucket-এ — hash table হয়ে যায় একটা লম্বা linked list যার উপরে অপ্রয়োজনীয় গণিত বসানো। আজকের experiment-এ এই attack-টা নিজে করবেন।

Collision — pigeonhole-এর অনিবার্যতা

m টা slot, সম্ভাব্য key অসীম (বা শুধু m+১ টা হলেও যথেষ্ট): একাদশ key-কে দশ slot-এ রাখতে হলে দুজন এক slot শেয়ার করবেই — এটাই pigeonhole principle, কোনো clever function-ই এড়াতে পারবে না। তাহলে design প্রশ্ন: collision কোথায় রাখা হবে? দুইটা মৌলিক উত্তর:

দর্শন ১ — Chaining: এক slot, এক bucket-list

প্রতিটা slot-এ একটা linked list; colliding keys সব তাতে জমে:

slots:      [0]→(k7)→(k23)→None
            [1]→None
            [2]→(k42)→None
            [3]→(k11)→(k88)→(k5)→None     ← cluster

Load factor α = n/m (entries ÷ slots)। Uniform hash-এ গড় chain length ঠিক α — lookup খরচ O(1 + α)। α ছোট রাখা মানে মেমরি বেশি; α বাড়লে গড় ধীরে বাড়ে — graceful degradation, এটা chaining-এর মার্জিত দিক। Deletion সহজ (list থেকে unlink)। দাম: প্রতিটা node allocation + pointer chase — গত লেসনের locality lesson এখানেও খাটে।

দর্শন ২ — Open addressing: সবকিছু table-এর ভেতরেই

কোনো pointer নেই — collision হলে table-এর পরের slot-ই দেখো। Probe sequence তিন রকম বিখ্যাত:

  • Linear probing — i, i+1, i+2, … (mod m)। সরল, cache-friendly (sequential slots!), কিন্তু primary clustering: একটা collision জমা হলে পরের collision তার পাশেই জমার সম্ভাবনা বাড়ে — cluster বড় হওয়ার সঙ্গে সঙ্গে আরও বড় হওয়ার প্রবণতা। গড় probe length α-র linear-এর চেয়ে দ্রুত ফুলে ওঠে।
  • Quadratic probing — i, i+1, i+4, i+9, … — cluster-কে ছড়ায়, কিন্তু একই hash-ভ্যালুর keys একই sequence-এ হাঁটে (secondary clustering)।
  • Double hashing — দ্বিতীয় hash function থেকে step size — দুটো clustering-ই ভাঙে, দাম হলো দ্বিতীয় hash-এর খরচ।

Open addressing-এ deletion বিপজ্জনক: মাঝখানের একটা slot খালি করলে তার পরে বসা key-গুলোর probe-chain ভাঙে — lookup খালি slot-এ থেমে যায়, key আর পাওয়া যায় না! সমাধান: slot খালি না করে tombstone বসাও — “এখানে কিছু ছিল, এখানে নেই, কিন্তু থামো না।” Build-এ পুরো mechanism-টা বানাব।

ভেতরে কী ঘটছে

CPython dict — compact design

CPython 3.6+ dict দুইটা array রাখে — split table:

dk_indices (sparse):   [ _ ][ 1 ][ _ ][ _ ][ 0 ][ _ ]...   ← ছোট int index
dk_entries (dense):    [(k1,v1)] [(k2,v2)] ...             ← insertion order-এ গুচ্ছত

lookup: hash(key) → mask → indices[i] → entries-এর index → key compare

কেন দুই স্তর? পুরনো design-এ slot-গুলো সরাসরি (key, value) রাখত — খালি slot-গুলোও পুরো entry-র জায়গা খেত (৮×৩ byte নয়, ২৪ byte করে)। Compact design-এ sparse array শুধু ছোট integer রাখে — memory প্রায় ২০–৩০% বাঁচে — আর dense array insertion-order-এ জমে বলে iteration order = insertion order ফ্রিতে পাওয়া যায়। এটাই Python 3.7-এ language guarantee হওয়ার ভেতরের গল্প — একটা memory optimization থেকে ভাষার contract!

Randomization — PYTHONHASHSEED আর adversary

Python-এর str/bytes hash প্রতি process-এ random seed দিয়ে শুরু হয় (SipHash)। একই string দুই run-এ ভিন্ন hash পায়। কেন? কারণটা defensive: যদি hash predictable হতো, attacker অফলাইনে হাজার হাজার colliding key তৈরি করে পাঠাতে পারত — প্রতিটা POST parameter এক bucket- এ জমে সার্ভারকে quadratic কাজে ফেলে দিত। ২০১১-এর 28c3 talk “Efficient Denial of Service Attacks on Web Application Platforms” এই ঘরের attack সব বড় ভাষায় দেখিয়েছিল — এরপরই PHP, Java, Python, Ruby সবাই randomized hash নেয়। এটাই hash flooding / hash DoS: average-case analysis-এর distribution assumption ভাঙা একটা production-grade weapon। আজকের experiment-এ এর নিরীহ লোকাল version করব — int keys দিয়ে, কারণ int hash randomize হয় না।

d["rafiq"] — একটা lookup-এর পূর্ণ যাত্রা
  1. HashSipHash("rafiq", process seed) → 64-bit সংখ্যা — deterministic এই process-এ, random cross-process
  2. Indexhash & (table_size - 1) → sparse indices-এর slot — power-of-two mask-এর জাদু আর বিপদ দুই-ই এখানে
  3. Probeslot ফাঁকা না হলে perturb sequence-এ পরের candidate — open addressing-এর ভাবনা CPython-এও আছে
  4. Comparecandidate entry-র key-র সঙ্গে equality check — hash মিলা মানে key মিলা না; এই compare-ই collision-এর কর
  5. Valuedense entries থেকে value — সফল হলে O(1); দুষ্ট distribution-এ এই চক্রই n বার

একই problem, চারটা ভিন্ন constraint — চারটা ভিন্ন উত্তর। Hash table design মানে “সবচেয়ে ভালোটা” খোঁজা না, workload-এর constraint-এ মাপা উত্তর।

উদাহরণ

হাতে-হাতে: m=8 table-এ জীবন

hash values দেওয়া (ধরুন): h(A)=2, h(B)=10, h(C)=18, h(D)=3, h(E)=2। mask = 7 (m−1):

insert A (h=2):  slot 2 খালি → বসল        [ _ _ A _ _ _ _ _ ]
insert B (h=10): 10 & 7 = 2 → দখল! probe → slot 3 খালি → বসল
                                          [ _ _ A B _ _ _ _ ]
insert C (h=18): 18 & 7 = 2 → দখল! probe → 3 দখল! → slot 4 → বসল
                                          [ _ _ A B C _ _ _ ]
insert D (h=3):  3 & 7 = 3 → দখল! probe → 4 দখল! → 5 → বসল
                                          [ _ _ A B C D _ _ ]

লক্ষ্য করুন: D-র home slot 3, কিন্তু B আর C-র জঞ্জালের পিছনে ঠেলে গিয়ে 5-এ বসল — এটাই primary clustering: প্রতিটা নতুন collision cluster-কে আরও দীর্ঘ করে, আর cluster যত লম্বা, তার আশেপাশে পড়া যেকোনো key-ও তার শিকার।

এবার delete B — naive খালি করলে:

[ _ _ A _ C D _ _ ]     ← B-র জায়গা খালি
lookup C: home 2 → দখল(A) → probe 3 → খালি! থেমে গেল → "C নেই" ❌

C-র probe chain-এর মাঝে ফাঁকা — lookup ভেঙে গেল। Tombstone দিয়ে:

[ _ _ A T C D _ _ ]     ← T = tombstone ("এখানে ছিল, থামো না")
lookup C: home 2 → A না → probe 3 → T, চলতে থাকো → 4: C! ✓
lookup X (নতুন): home 2 → A → T → প্রথম T-তেই নতুন entry বসতে পারবে (reuse)

Tombstone lookup-কে বাঁচায়, insert-কে reuse দেয়, কিন্তু জমে গেলে probe-chain দীর্ঘ করে — তাই resize-এর সময় tombstone সব মুছে টেবিল fresh হয়।

ছবিতে

দুই দর্শন পাশাপাশি

CHAINING:                        OPEN ADDRESSING (linear):

slot0: →(k7)→(k23)→∅             [k3][k7][k23][k11][∅][k5][∅][∅]
slot1: →∅                          ▲            ▲
slot2: →(k42)→∅                    └─ probe walk┘ (সব একই array-তে)
slot3: →(k11)→(k88)→(k5)→∅

খরচ: node allocation,            খরচ: cluster-এর দৈর্ঘ্য পর্যন্ত walk,
pointer chase, cache miss        কিন্তু sequential slots — prefetcher বন্ধু
α বাড়লে ধীরে ধীরে খারাপ          α বাড়লে হঠাৎ মারাত্মক খারাপ

নিজে চালিয়ে দেখুন

EXPERIMENT

Hash-এর দুই মুখ — randomization আর crafted collision (নিরীহ, লোকাল)

Linux/macOS, Python 3.8+· ২০ মিনিট
# hash_lab.py — hash-এর সততা পরীক্ষা (সবকিছু লোকাল, নিরীহ)
# চালান: python3 hash_lab.py
import sys, time

# ---- পর্ব ১: hash randomization ----
print('PYTHONHASHSEED effect:')
print(f'  hash_randomization flag : {sys.flags.hash_randomization}')
print(f'  hash("algo") this run   : {hash("algo")}')
print(f'  hash(42) this run       : {hash(42)}   (int: identity)')
# ---- পর্ব ২: crafted collisions ----
N = 20_000
base = 42
p = 2 ** 61 - 1                         # sys.hash_info.modulus — int hash এখানে wrap করে
# প্রতিটা key = base + i·p → hash(key) = base, হুবহু সবার এক!
evil = [base + i * p for i in range(N)]
good = list(range(N))

def insert_all(keys):
    d = {}
    for k in keys:
        d[k] = True
    return d

t0 = time.perf_counter()
insert_all(good)
t_good = time.perf_counter() - t0

t0 = time.perf_counter()
insert_all(evil)
t_evil = time.perf_counter() - t0

print(f'\ninsert {N:,} int keys:')
print(f'  normal keys  : {t_good*1000:9.1f} ms')
print(f'  crafted keys : {t_evil*1000:9.1f} ms')
print(f'  slowdown     : {t_evil/max(t_good, 1e-9):9.1f}x')

# ---- পর্ব ৩: load factor-এর ছাপ ----
M = 50_000
LOOKUPS = 300_000
keys = list(range(M))

tight = {}
for k in keys:
    tight[k] = True          # α ≈ 0.66 — resize threshold-এর কাছে

loose = dict(tight)
for k in range(M, 2 * M):
    loose[k] = True          # table বড় হয়ে গেল
for k in range(M, 2 * M):
    del loose[k]             # entries মুছল — কিন্তু table কমে না!

def probe(d):
    hits = 0
    for i in range(LOOKUPS):
        if d.get(i % M) is not None:
            hits += 1
    return hits

t0 = time.perf_counter()
probe(tight)
t_tight = time.perf_counter() - t0

t0 = time.perf_counter()
probe(loose)
t_loose = time.perf_counter() - t0

print(f'\n{LOOKUPS:,} lookups:')
print(f'  tight table (α≈0.66): {t_tight*1000:9.1f} ms')
print(f'  loose table (α≈0.33): {t_loose*1000:9.1f} ms')
print(f'  ratio               : {t_tight/max(t_loose, 1e-9):9.2f}x')
python3 hash_lab.py
python3 hash_lab.py        # আবার — string hash বদলেছে কি না দেখুন

Typical ফলাফল:

PYTHONHASHSEED effect:
  hash_randomization flag : 1
  hash("algo") this run   : 7187420385517429313
  hash(42) this run       : 42   (int: identity)

insert 20,000 int keys:
  normal keys  :     2.8 ms
  crafted keys :  3028.2 ms
  slowdown     :   1081.5x

50,000 lookups:
  tight table (α≈0.66):    18.9 ms
  loose table (α≈0.33):    17.2 ms
  ratio               :     1.10x

তিনটা reading: (১) string hash cross-process random — এটাই SipHash- ভিত্তিক DoS defense চোখে দেখা; (২) crafted keys-এ ~১০০০× slowdown — কীভাবে কাজ করে শেখার মতো ব্যাপার: int-এর hash হলো value mod (2⁶¹−১), তাই modulus-এর হুবহু গুণিতক যোগ করলে পুরো hash-ই identical থাকে — আর hash identical হলে perturb probe-path-ও identical, প্রতিটা insert আগের সবগুলোর উপর দিয়ে হাঁটে — quadratic total, ঠিক যেভাবে ২০১১-র hash-flood attack সার্ভার ফেলেছিল (বাস্তব attack-এ string hash ছিল predictable; আজ Python-এ seed তাই আটকায় — কিন্তু নিজস্ব hash লেখা বা predictable key-space এখনো ফাঁক); (৩) load factor অর্ধেক করলে lookup ~১০% লাভ — ছোট, কিন্তু hot path-এ repeatable; আর লক্ষ্য করুন del করলেও CPython table কমে না — সেটা আমরা কাজে লাগালাম।

যা প্রমাণিত: O(1) average দাবিটা distribution-এর কাছে ঋণী। Adversary distribution বেছে নিতে পারলে গড়টাই অস্ত্র হয়ে ওঠে।

এটা কী প্রমাণ করে

str hash প্রতি process-এ randomize হয় (PYTHONHASHSEED), int hash identity থাকে — modulus-wrap দিয়ে এমন ভিন্ন keys বানানো যায় যাদের পুরো hash identical; একই সংখ্যক key insert করলে normal keys মিলিসেকেন্ডে শেষ, crafted keys হাজার গুণ ধীর — adversarial distribution average-case O(1)-কে quadratic-এ ভাঙার পুরো প্রক্রিয়া।

নিজে বানান

BUILD IT

OpenHashMap — linear probing + tombstone + resize, dict-এর বিরুদ্ধে পরীক্ষিত

Python · ●●●○○
  1. Slot states তিনটা আলাদা করুন: EMPTY, DELETED (tombstone), (key, value) pair
  2. _find_slot(key) লিখুন — linear probe, প্রথম tombstone মনে রেখে, EMPTY-তে থামা
  3. put: load factor 0.7 ছুঁলে resize (capacity × 2, tombstone মুছে rebuild); hit হলে value update
  4. delete: hit হলে slot-এ DELETED বসান — খালি না! size--, used একই
  5. Stress test: ২ লক্ষ random put/get/delete — dict-এর সঙ্গে প্রতিটা ধাপে ফল মিলিয়ে assert

লক্ষ্য: এই লেসনের সব concept — probe, tombstone, load factor, rehash — একটা চালু, সঠিক hash map-এ একসাথে; আর correctness-এর বিচারক হবে CPython-এর নিজের dict।

# openhash.py — open addressing hash map with honest machinery
# চালান: python3 openhash.py

EMPTY = object()
DELETED = object()          # tombstone


class OpenHashMap:
    def __init__(self, capacity=8):
        self.cap = capacity                 # power of two রাখুন
        self.slots = [EMPTY] * capacity
        self.size = 0                       # live entries
        self.used = 0                       # live + tombstones (probe cost driver)

    def _find_slot(self, key):
        """Probe করে (index, kind) দেয়; kind: 'hit' | 'fresh' | 'reuse'"""
        mask = self.cap - 1
        i = hash(key) & mask
        first_tomb = -1
        while True:
            slot = self.slots[i]
            if slot is EMPTY:
                if first_tomb >= 0:
                    return first_tomb, 'reuse'
                return i, 'fresh'
            if slot is DELETED:
                if first_tomb == -1:
                    first_tomb = i
            elif slot[0] == key:
                return i, 'hit'
            i = (i + 1) & mask

    def put(self, key, value):
        if (self.used + 1) * 10 >= self.cap * 7:      # α 0.7 ছুঁলে
            self._resize(self.cap * 2)
        i, kind = self._find_slot(key)
        if kind == 'hit':
            self.slots[i] = (key, value)
            return
        if kind == 'fresh':
            self.used += 1
        self.slots[i] = (key, value)
        self.size += 1

    def get(self, key, default=None):
        i, kind = self._find_slot(key)
        if kind == 'hit':
            return self.slots[i][1]
        return default

    def delete(self, key):
        i, kind = self._find_slot(key)
        if kind != 'hit':
            return False
        self.slots[i] = DELETED            # খালি না — চেইন বাঁচাতে কবরস্থান
        self.size -= 1
        return True

    def _resize(self, new_cap):
        live = [s for s in self.slots if s is not EMPTY and s is not DELETED]
        self.cap = new_cap
        self.slots = [EMPTY] * new_cap
        self.used = len(live)              # tombstone সব মুছে গেল
        for k, v in live:
            i, kind = self._find_slot(k)
            self.slots[i] = (k, v)

    def stats(self):
        return (f'size={self.size} cap={self.cap} '
                f'load={self.size / self.cap:.3f} '
                f'tombs={self.used - self.size}')


if __name__ == '__main__':
    import random

    m = OpenHashMap()
    ref = {}                                # বিচারক: আসল dict
    random.seed(42)
    OPS = 200_000
    KEYS = 30_000

    for step in range(OPS):
        k = random.randrange(KEYS)
        r = random.random()
        if r >= 0.5:                        # put
            v = random.randrange(10 ** 9)
            m.put(k, v)
            ref[k] = v
        elif r >= 0.25:                     # get
            got, want = m.get(k), ref.get(k)
            assert got == want, f'mismatch at step {step}: {got} vs {want}'
        else:                               # delete
            had = ref.pop(k, None)
            assert m.delete(k) == (had is not None), f'delete mismatch {step}'
        if step % 50_000 == 0:
            print(f'  step {step:>6}: {m.stats()}')

    # শেষ পরীক্ষা: সব live entry সঠিক?
    for k, v in ref.items():
        assert m.get(k) == v
    assert m.size == len(ref)
    print('stress OK:', OPS, 'ops mirrored against dict — zero mismatch')
    print('final:', m.stats())

প্রত্যাশিত output:

  step      0: size=1 cap=8 load=0.125 tombs=0
  step  50000: size=12543 cap=65536 load=0.191 tombs=4187
stress OK: 200000 ops mirrored against dict — zero mismatch
final: size=12578 cap=65536 load=0.192 tombs=16620

Reading: (১) dict-এর বিরুদ্ধে ২ লক্ষ mixed operation-এ একটাও mismatch নেই — semantics ঠিক; (২) tombstone সংখ্যা ক্রমাগত বাড়ছে — deletion- heavy workload-এ এরাই probe-cost বাড়ায়, আর এজন্যই production tables সময়ে সময়ে rebuild করে; (৩) load 0.19-এ থেকে গেছে কারণ KEYS=30k-এর চেয়ে table বড় — resize একবারই ঘটেছিল।

নিজে বাড়ান:

clustering-এর পার্থক্য মাপুন (গড় probe length instrument করে) 2. Iteration (__iter__) যোগ করুন — tombstone skip করে; এরপর for k in m চালিয়ে dict-এর সঙ্গে key-set মিলান

বাস্তব সিস্টেমে

Hash table পৃথিবী যেভাবে চালায়

Python dict/set — ভাষার মেরুদণ্ড। Module namespace, instance attributes (__dict__), keyword arguments, set operations — সব dict। Python program-এর performance profile-এর বড় অংশ আসলে dict lookup-এর performance profile। আজকের build-এর প্রতিটা লাইন CPython-এর Objects/dictobject.c-এর simplified প্রতিচ্ছবি।

Redis। In-memory database-এর মূল structure hash table — সঙ্গে incremental rehash: resize একবারে না করে দুই table ধরে রেখে প্রতি অপারেশনে কয়েক bucket migrate — গত লেসনের amortized-vs-latency tension-এর production সমাধান।

Security incident history। ২০১১-এর hash flooding (28c3), এরপর CVE-সমূহ — Java, PHP, Python, Ruby, V8 সবাই আঘাত খেয়েছিল predictable hash-এ। Response: SipHash, per-process seeds, Java-র treeified bins (α ≥ ৮ হলে chain red-black tree — worst O(log n))। এই module-এর প্রথম লেসনের “average case কার হাতে” প্রশ্নের ইতিহাস এখানে পূর্ণ বৃত্ত।

যে ভুলগুলো সবাই করে

“Hash table-এর O(1) মানে সবসময় constant — এটা fast structure।”

Average-case constant, শর্তসহ: uniform hash, সভ্য load factor, non- adversarial keys। বাস্তবে তিনটা ভাঙার রাস্তাই আছে: দুষ্ট keys (আজকের experiment-এ ৭০০×), rehash pause (লক্ষ entry-র table-এ একটা insert হঠাৎ O(n)), আর α → 1-এ probe explosion। Latency-sensitive system-এ এই তিনটাই পরিকল্পনার অংশ — pre-size, incremental rehash, randomized hash। O(1) হলো গড়ের প্রতিশ্রুতি, প্রতিটা মুহূর্তের না।

“Hash table-এর iteration order sorted — বা অন্তত predictable।”

Sorted নিশ্চিই না — hash order আসলে pseudo-random। CPython dict-এ insertion-order guarantee আছে (3.7+), কিন্তু সেটা sorted না — ঢোকানোর ক্রম। Set-এ কোনো order contract-ই না। আর implementation বদলালে (ভাষা version, hash seed) order বদলায় — order-এ নির্ভর করা code এক machine-এ চলে, production-এ ভাঙে। ক্রম দরকার হলে sort করুন বা ordered structure (আগের লেসনগুলোর) ব্যবহার করুন — hash table-এর contract-এ order নেই।

“Load factor শুধু memory-efficiency-র সংখ্যা — ১-এর কাছে রাখলে বাঁচে মেমরি।”

Open addressing-এ α মানে শুধু জায়গা না — probe length-এর fuel। α = 0.5 → গড় কয়েক probe; α = 0.95 → কয়েক ডজন; α → 1 → গাণিতিকভাবে অন্ধকূপ। Chaining-এ সেই খারাপি ধীর (গড় = α), তাই Java α=0.75-এ comfortable। মেমরি বাঁচাতে গিয়ে α তুলে দেওয়া মানে প্রতিটা lookup- কে জিম্মি করা — trade-off curve-টা linear না, cliff আছে।

বুঝেছেন কি না দেখুন

1

ভালো hash function-এর তিনটা দাবির নাম আর এক-এক বাক্যে মর্ম লিখুন। কোনটা ভাঙলে lookup-ই অসম্ভব হয়ে যায়?

স্মরণ

Determinism — একই key সবসময় একই hash (ভাঙলে ঢোকা-আর-খোঁজা ভিন্ন slot-এ যায়, lookup অসম্ভব — এটাই ভাঙা-যাবে-না শর্ত)। Uniformity — output slots-এ ছড়ানো সমান হওয়া (ভাঙলে cluster, গড় খারাপ)। Avalanche — ছোট input change বড় output change (ভাঙলে similar keys একসাথে জমে)। প্রথমটা correctness-এর, শেষ দুটো performance-এর।

2

m = 16, linear probing। h(k)=5 দিয়ে ৭টা key পরপর insert হলে শেষ key- টা কোন slot-এ বসবে? মোট কত probe লাগবে? এই ঘটনার নাম কী?

প্রয়োগ

সবার home slot 5। Probe: 1ম → 5 (১ probe), ২য় → 6, ৩য় → 7, …, ৭ম → 11। মোট probe = 1+2+…+7 = ২৮। নাম: primary clustering — একই home-ভ্যালুর key-গুলো পরস্পরকে ঠেলে লম্বা chain বানায়, আর chain যত লম্বা, কাছাকাছি home-এর key-ও তার শিকার হয়। Quadratic/double hashing এই জমাট বাঁধা ভাঙে।

3

আপনার hash map-এ tombstone নেই — delete মানে slot-এ EMPTY বসানো। কোন scenario-তে এটা ভুল ফল দেবে? একটা concrete ৩-key উদাহরণ trace করুন।

যুক্তি

যখন deleted slot-এর পরে probe-chain-এ বসা কোনো key থাকে। উদাহরণ (m=8): put A (h=2)→slot2; put B (h=2, collision)→slot3; delete A (slot2=EMPTY); get B → home 2 খালি → probe থামে → “B নেই” — অথচ B slot3-এ বেঁচে আছে! Tombstone হলে slot2=DELETED → lookup চলতে থাকে → slot3-এ B পায়। মূল নিয়ম: EMPTY মানে “এর পরে কিছু নেই” — সেই দাবি মিথ্যা করা যাবে না যতক্ষণ chain-এ কেউ বাস করে।

এরপর কী

এরপর কী

Data Structures & Algorithms module-এর ভিত্তিটা এখানে সম্পূর্ণ — array-র arithmetic থেকে hash table-এর collision economics পর্যন্ত, প্রতিটা structure-কে আমরা theory দিয়ে বিচার করেছি আবার silicon-এ মেপেও দেখেছি। এই module-এর সবচেয়ে বড় পাওয়া কোনো structure না — একটা পদ্ধতি: asymptotic ভাষায় দাবি লেখা, amortized lens-এ খরচ ছড়ানো, locality-র হিসাব রাখা, আর adversary-র চোখে নিজের assumption পরীক্ষা করা। পরবর্তী লেসনগুলো এই ভিত্তির উপর দাঁড়িয়ে tree আর graph-এর জগতে যাবে — যেখানে আজকের hash table-ও বারবার ফিরে আসবে (hash join, memoization, visited-set)।

এই module-এর পরের ধাপ: Trees ও Binary Search — hierarchy-তে খোঁজা।

আরও পড়ুন

  • Introduction to Algorithms (3rd edition), Chapter 11 — Hash Tables — Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein · Chaining, open addressing, universal hashing-এর formal treatment — এই লেসনের কাঠামোর উৎস
  • The Art of Computer Programming, Volume 3, Section 6.4 — Hashing — Donald E. Knuth · Hashing-এর classical analysis — probing sequence আর clustering-এর গভীর গাণিতিক আলোচনা
  • Algorithms (4th edition), Section 3.4 — Hash Tables — Robert Sedgewick, Kevin Wayne · Java-স্টাইল implementation choices — separate hashing, linear probe test-এর practical trade-offs