Foundationপ্রথম নীতি থেকে
LEVEL 6কঠিন~২৫ ঘণ্টাPython

DS Library from Scratch

DS Library from Scratch

Python-এর list আর dict একপাশে রেখে dynamic array, open-addressing hash map, AVL tree আর binary heap নিজের হাতে বানানো — তারপর stdlib-এর সঙ্গে benchmark দিয়ে প্রমাণ করা। বইয়ের big-O আর wall-clock-এর মাঝখানে যে amortized cost, cache miss, resize spike-এর গল্প লুকিয়ে থাকে, সেটাই এই প্রজেক্টের মূল শিক্ষা।

মাইলস্টোন

আগে যা পড়া দরকার

কেন এই প্রজেক্ট

Python programmer হিসেবে আপনি প্রতিদিন list আর dict ব্যবহার করছেন, কিন্তু ভেতরে কী চলে সেটা কোনোদিন দেখতে হয় না। Arrays-and-dynamic-arrays লেসনে দেখেছি capacity শেষ হলে কী হয়, Hash-tables লেসনে দেখেছি collision কীভাবে probe করে সামলায় — সবই ছিল অন্য কারো লেখা কোডের উপর গল্প। এই প্রজেক্টে নিয়ম কঠোর: list আর dict storage হিসেবে নিষিদ্ধ — শুধু fixed-size array (array module বা bytearray) দিয়ে চারটা ডেটা স্ট্রাকচার বানাতে হবে, আর প্রতিটার জন্য stdlib-এর বিপরীতে benchmark দিতে হবে।

আর সবচেয়ে জরুরি যে শিক্ষা: big-O notation asymptotic — n=১০০-তে constant factor আর cache behavior-ই খেলা জেতে। আপনার AVL tree সম্ভবত lookup-এ stdlib dict-কে হারবে, ঠিক তেমন আপনার Vector নির্দিষ্ট workload-এ list-কে ছুঁয়ে ফেলবে। কেন হারলেন, কোথায় জিতলেন — এই প্রশ্নের উত্তর বের করাই এই প্রজেক্টের আসল কাজ, কোড লেখা তার অর্ধেক।

ধাপে ধাপে

১. Vector — capacity আর growth

import array

class Vector:
    def __init__(self):
        self._cap = 4
        self._n = 0
        self._buf = array.array('q', [0]) * self._cap   # fixed-size buffer

    def _grow(self):
        new = array.array('q', [0]) * (self._cap * 2)
        for i in range(self._n):
            new[i] = self._buf[i]
        self._buf = new
        self._cap *= 2

    def append(self, x):
        if self._n == self._cap:
            self._grow()
        self._buf[self._n] = x
        self._n += 1

Amortized analysis লেসনের হিসাব এখানে কাগজে-কলমে মেলে: n টা element ঢোকাতে মোট copy = n + 2 + 4 + … + n/2 ≈ 2n, তাই per-append average constant। যাচাই করুন — _grow()-এ একটা counter বাড়ান, ১০ লাখ append-এ counter কত দেখায়?

Checkpoint: initial cap 4 হলে 1,000,000 append-এ resize হবে ঠিক 18 বার (log₂(250k))। আপনার counter এর সঙ্গে মিললো তো growth বোঝা গেছে।

২. HashMap — probing আর tombstone

Open addressing-এ delete-ই আসল মাথাব্যথা। Cell খালি করে দিলে তার পরের chain-এ বসা key আর কোনোদিন খুঁজে পাওয়া যাবে না — তাই tombstone:

EMPTY, TOMBSTONE = object(), object()

class HashMap:
    def get(self, key):
        i = hash(key) % self._cap
        while True:
            slot = self._slots[i]
            if slot is EMPTY:
                return None              # chain শেষ — নেই
            if slot is not TOMBSTONE and slot[0] == key:
                return slot[1]
            i = (i + 1) % self._cap      # linear probe

    def delete(self, key):
        i = self._find_slot(key)
        if i is not None:
            self._slots[i] = TOMBSTONE   # মুছবেন না — ঢেকে রাখুন

দুটো classic bug নিজেই ঘটিয়ে দেখুন:

  1. Tombstone-কে EMPTY করে delete করলে কিছু key হঠাৎ হারিয়ে যায়।
  2. Resize-এ load factor হিসাবে শুধু live entry ধরলে tombstone জমতে জমতে probe length আকাশছোঁয়া — resize-এর threshold-এ tombstone-ও গুনুন।

Resize-এ সব live key rehash হয়, tombstone মরে যায় — এই পার্থক্যটা টেস্টে ধরুন।

৩. AVL — insert + rotation

Recursive insert প্রতিটা subtree-র root return করে, ফেরার পথে height আর balance update:

def insert(node, key):
    if node is None:
        return Node(key)
    if key < node.key:
        node.left = insert(node.left, key)
    else:
        node.right = insert(node.right, key)
    update_height(node)
    bf = balance_factor(node)          # h(right) - h(left)
    if bf > 1:
        if key > node.right.key:       # RR → single left
            return rotate_left(node)
        node.right = rotate_right(node.right)   # RL → double
        return rotate_left(node)
    if bf < -1:                        # LL / LR mirror image
        ...
    return node

rotate_left/rotate_right-এ child pointer swap-ই সব — কাগজে আঁকুন, কোডে সেটার অনুবাদ।

Checkpoint: property-based test — প্রতিটা insert-এর পরে তিনটা invariant যাচাই করুন: (a) BST order ঠিক (inorder sorted), (b) প্রতিটা node-এ |balance factor| ≤ 1, (c) stored height = computed height। Random ৫,০০০ insert-এ একবারও ভাঙলে rotation logic-এ bug।

৪. MinHeap + heapsort

def sift_down(a, i, n):
    while True:
        smallest, l, r = i, 2*i+1, 2*i+2
        if l < n and a[l] < a[smallest]: smallest = l
        if r < n and a[r] < a[smallest]: smallest = r
        if smallest == i: return
        a[i], a[smallest] = a[smallest], a[i]
        i = smallest

Heapsort দুই phase-এ: পুরো array দিয়ে heap build (নিচের অর্ধেক থেকে sift-down, O(n)), তারপর max/min ঘুরিয়ে swap করে shrink — in-place, extra memory zero। Heap-priority-queues লেসনের sift দুটোই এখানে কাজে লাগবে। MinHeap দিয়ে descending order-এ sort হয় — ভাবুন কেন।

Checkpoint: heapsort output sorted, আর প্রতিটা ধাপে parent ≤ child scan করে heap invariant ভাঙেনি — দুটো assertion আলাদা লিখুন।

৫. Benchmark harness — op-count + wall-clock

দুই ধরনের মাপ দুই টেবিলে:

class CountingVector(Vector):        # op-count instrumentation
    def __init__(self):
        super().__init__()
        self.copies = 0

    def _grow(self):
        self.copies += self._n       # grow-এ কয়টা element copy হলো
        super()._grow()

Wall-clock-এর জন্য: warmup run, তারপর একই কাজ k বার, median নিন — mean-এ outlier (GC pause, OS scheduler) টেবিল নষ্ট করে। Workload তিনটা আলাদা করুন: sequential insert, random lookup-heavy, mixed insert/delete। তারপর নিজের implementation আর stdlib পাশাপাশি:

operation (100k)my VectorPython listmy HashMapdict
append/insert median? ms? ms? ms? ms
successful lookup median? ms? ms? ms? ms
p99 lookup? ms? ms? ms? ms
total copies/rehashes????

p99 কলামটাই গল্প বলবে — median ভালো দেখলেও resize-এর মুহূর্তে latency spike ধরা পড়ে।

৬. Serialization

প্রতিটা স্ট্রাকচারের to_bytes() আর from_bytes():

import struct

def vector_to_bytes(v):
    out = struct.pack('<QQ', v._n, v._cap)     # header: n, cap
    return out + v._buf.tobytes()[:v._n * 8]

Little-endian, length prefix, versioned magic number — তিনটাই convention অভ্যাসের জন্য। Round-trip test: random data ঢেলে serialize → deserialize → প্রতিটা operation (get/probe/sort) আগের মতো এক ফল দেয় কি না। HashMap-এ tombstone সিরিয়ালাইজ হবে না — rebuild করে compact form-এ ফেলুন।

Checkpoint: from_bytes(vector_to_bytes(v)).to_bytes() == vector_to_bytes(v) — canonical encoding মানে এটা সবসময় true।

৭. Fuzz vs oracle

Random operation sequence দুই জগতে একসঙ্গে চালান — আপনার structure আর oracle (stdlib dict/set/sorted list):

rng = random.Random(42)
mine, oracle = HashMap(), {}
for step in range(100_000):
    key = rng.randrange(1000)          # ছোট keyspace → collision বেশি
    op  = rng.random()
    if op < 0.5:
        val = rng.randrange(10**6)
        assert mine.get(key) == oracle.get(key)
        mine.put(key, val); oracle[key] = val
    else:
        assert mine.get(key) == oracle.get(key)
        mine.delete(key); oracle.pop(key, None)

Mismatch ধরা পড়লে seed fixed থাকায় reproduce সহজ — ছোট failing case shrink করে রাখুন। Keyspace ইচ্ছে করে ছোট রাখুন, নইলে collision পাবেই না আর probe code path অপরীক্ষিত থেকে যাবে।

নিজেকে চ্যালেঞ্জ করুন

  1. Growth factor 1.5 বনাম 2 বনাম 3 — wall-clock benchmark দিয়ে কোনটা কেন জেতে ব্যাখ্যা করুন (memory reuse বনাম copy count trade-off)
  2. Linear probing বনাম robin-hood hashing — clustered workload-এ probe length distribution compare
  3. Deletion-এ tombstone-এর বদলে backward-shift deletion — কোন edge case ভুল করলে chain ভাঙে?
  4. AVL-এ range query keys(lo, hi) — inorder traversal prune করে
  5. Iterator invalidation semantics — Vector resize হলে পুরনো index-based iterator-এর কী হবে, নিজের নিয়ম লিখুন আর enforce করুন (version counter)
  6. mmap-backed Vector — file-কেই buffer বানিয়ে persistence free পাওয়া

যা এখানে শিখলেন, পরে যেখানে লাগবে

এখানেপরে
Amortized growth আর p99 spikeLatency-sensitive system design — tail latency এখান থেকেই শুরু
Open addressing + tombstoneStorage engine-এর memtable, Bloom filter-জাতীয় structure
Rotation + invariant testRed-black tree, B-tree page split — একই “local repair” চিন্তা
Benchmark methodologyযেকোনো optimization claim — measurement ছাড়া argument নয়
Serialization round-tripWire protocol আর on-disk format design
Fuzz vs oracleDifferential testing — parser, VM, সবখানে একই কৌশল