Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ১১/২৪কঠিন৪৫ মিনিট

Trie ও Radix Tree — prefix দিয়ে খোঁজা

Tries and Radix Trees

Trie-তে key বসে edge-এ edge-এ — character ধরে নামতে হয়, খোঁজা O(word length), dictionary দশগুণ বড় হলেও; radix compression এক-child শৃঙ্খল গুটিয়ে memory-bill অর্ধেকে নামায়।

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

  • character-indexed edge দিয়ে trie-র গঠন ব্যাখ্যা করে insert, search, startswith হাতে trace করতে পারবেন
  • lookup-খরচ কেন word-length-এর ফাংশন — dictionary-size-এর না — তা যুক্তি দিয়ে প্রমাণ করতে পারবেন
  • node-per-character space explosion-এর মাপ বের করে radix compression কীভাবে single-child chain merge করে সেটা কমায় সংখ্যায় দেখাতে পারবেন
  • trie বনাম hash table-এর সীমানা টানতে পারবেন — prefix query, longest-prefix match, ordered iteration কোথায় hash অসহায়
  • IP routing-এর LPM আর autocomplete engine-এর পেছনে কোন variant চলে তা চিনতে পারবেন

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

আগে এটা বুঝি

ফোনের সার্চবারে pro টাইপ করুন — সঙ্গে সঙ্গে “programming”, “project”, “promise” ঝুলে আসে। Dictionary-তে লাখ word থাকতে পারে; তিন letter-এ candidate-তালিকা কীভাবে?

Hash table-এর ভাষায় প্রশ্নটা অসম্ভব। Hash function পুরো key ছাড়া উত্তরই দেয় না — "pro"-র hash "programming"-এর সাথে কোনো সম্পর্কই রাখে না। Prefix = key-র শুরু — আর hash শুরু-মাঝ-শেষ মিশিয়ে fingerprint বানায়, শুরু দিয়ে খোঁজার পথ থাকেই না। বাধ্য হয়ে linear scan O(n) প্রতি keystroke-এ।

আজকের hero Trie (reTRIEval; উচ্চারণ “try”) — tree যার edge-এ character। খোঁজা = ওই অক্ষরগুলো ধরে নামা: "pro" = তিন ধাপ — dictionary এক লাখের হোক কোটির, খরচ word-এর length-driven, size-independent। আর prefix-path- এর subtree-ই সব সম্ভাব্য continuation — autocomplete এক subtree-walk।

দাম আছে: memory। কাটার হাতিয়ার radix compression (Patricia-ভাবনা): one-child chain গুটিয়ে label-string। দুটোই আজ হাতে-হাতে।

মূল ধারণা

গঠন — key বাসা না, রাস্তা

BST-তে key node-এ। Trie-তে node-এ শুধু flag (“এই prefix complete word?”), আর character-রা edge-এ: root→node path-এর characters-ই node-এর prefix।

class TrieNode:
    __slots__ = ('children', 'is_word')
    def __init__(self):
        self.children = {}        # character → TrieNode
        self.is_word = False      # এই prefix complete key?

class Trie:
    def __init__(self):
        self.root = TrieNode()    # root-এর prefix = empty

    def _walk(self, s):           # prefix ধরে নামা — একটাই রাস্তা
        n = self.root
        for ch in s:
            n = n.children.get(ch)
            if n is None:
                return None
        return n

    def insert(self, word):
        n = self.root
        for ch in word:
            n = n.children.setdefault(ch, TrieNode())
        n.is_word = True

    def search(self, word):
        n = self._walk(word)
        return n is not None and n.is_word

    def startswith(self, prefix):
        return self._walk(prefix) is not None

সব operation এক কাঠামো: length-of-key বার loop — কোথাও dictionary- size নেই, কোথাও তুলনা-তুলনা নেই।

খরচের হিসাব — কেন size-নিরপেক্ষ

lookup(s)=O(∣s∣)— n থেকে সম্পূর্ণ স্বাধীন\text{lookup}(s) = O(|s|) \quad \text{— } n \text{ থেকে সম্পূর্ণ স্বাধীন}

গঠনগত যুক্তি: root→node path-length = prefix-দৈর্ঘ্য; ভাণ্ডার বড় হলে paths লম্বা হতে পারে, কিন্তু আপনার query-word-এর length তো বদলাল না। Hash-এর O(1)-এর সাথে সৎ তুলনা: hash-ও আসলে O(|key|) (পুরো key hash করতে হয়) — exact-match-এ দুজন asymptotic-প্রতিবেশী। ফারাক query-type-এ: prefix, range (“a–d”), lexicographic iteration — এসবে hash-এর উত্তরই নেই।

Alphabet size আর node-প্রস্থ

Node-এর max child = alphabet R (lowercase English ২৬; Unicode লক্ষ!)। Children-representation:

রূপখরচমন্তব্য
Array of RR×pointer প্রতি nodelookup এক index-arithmetic; sparse-এ waste
dict / sorted listব্যবহৃত child মাত্রেPython-এ dict natural; C-তে sorted array + binary search

C-style array-trie (R=26): ২৬×৮ = ২০৮ byte/node, গড় ব্যবহৃত child ~২-৩ — utilization ১০%-এর নিচে: space explosion-এর প্রথম দরজা।

ভেতরে কী ঘটছে

Space explosion — সৌন্দর্যের বিল

English গড় word ~৫-৯; ১ লাখ word-এ trie-node ২-৩ লাখ, প্রতিটায় dict-overhead

  • pointer — আর সবাই heap-এ ছড়ানো। Character-প্রতি ধাপ = pointer-deref, বড় trie-তে cache miss; dict-bucket walk তো আরো। তাত্ত্বিক O(L) vs hardware constant-factor — production trie-র পুরো engineering-গল্প এই ফারাক।

Radix/Patricia compression — chain গুটানো

Observation: trie-র বেশিরভাগ node single-child + non-word। dog-পথ d→o→g — মাঝের node-রা শুধু “আরেক character আছে” বলে। Radix tree গুটিয়ে edge-label string বানায়: root —'dog'→ leaf। নিয়ম: node টিকবে যদি word-শেষ হয়, বা branch-point হয় (≥২ child)।

ফল: node-count ব্যাপক নামে; depth কমে (কম pointer-hop); lookup তবু O(|s|) — প্রতি edge-এ label-compare, total compared chars ≈ |s|। Morrison- এর PATRICIA (1968) binary-রূপ — bit-level skip-count; IP-routing-এর কিংবদন্তি algorithms সেখান থেকে।

Router-এর প্রশ্ন: longest prefix, exact না

Packet destination 10.0.42.7; table-এ 10.0.0.0/8, /16, /24 — সবগুলো match! Router চায় সবচেয়ে লম্বা matching prefix (LPM)। Hash structurally অন্ধ: exact-key-ই নেই। Bit-bit Patricia-descent + পথে deepest-match মনে রাখা = উত্তর। Linux kernel FIB, DPDK LPM (LC-trie), hardware TCAM-fallback — router-family compressed trie ছাড়া চলে না; আপনার Wi-Fi এই মুহূর্তে trie- এর ওপর দিয়ে packet পাঠাচ্ছে।

Trie বনাম Hash — সিদ্ধান্ত-টেবিল

প্রশ্নHashTrie / Radix
Exact matchO(1) amortizedO(|s|)
Prefix queryঅসম্ভব — linear scanO(|p| + matches)
Order / rangeঅসম্ভবin-order walk-ই sorted
Longest-prefix matchঅসম্ভবnative
Memorycompactnode-heavy; radix-এ ভালো
Worst-case guaranteeadversarial O(n)deterministic O(|s|)

মোদ্দা: query-র আকৃতিই structure বেছে নেয় — exact → hash; prefix/order/ LPM → trie family।

উদাহরণ

ছোট্ট trie-র সম্পূর্ণ জীবন

Words: car, card, care, cart, cat, dog

search("card"):                    autocomplete("ca"):

root ──c── a ── r(is_word)         ca পর্যন্ত নামো → subtree DFS
                 ├─d(is_word)      ফল: car, card, care, cart, cat
                 ├─e(is_word)             (cat-ও — c-a-t ca-র নিচেই)
                 └─t(is_word)
       └─a── t(is_word)            startswith("cab") → ৩ ধাপে None
       └─d── o ── g(is_word)         — scan কিছুই নেই

card খুঁজতে ৪ ধাপ; dictionary ৬ হোক ৬০ লাখ — সংখ্যা একই।

Compression — কে টিকবে?

uncompressed: ১১ node              radix: ৮ node

root                                root
├─ c                               ├─ 'ca' ──┬─ 't' (cat✓)
│  └─ a                            │         └─ 'r' (car✓)
│     ├─ t (cat✓)                  │            ├─ 'd' ✓
│     └─ r (car✓)                  │            ├─ 'e' ✓
│        ├─ d ✓                    │            └─ 't' ✓
│        ├─ e ✓                    └─ 'dog' ✓
│        └─ t ✓
└─ d                          গুটানো: root-c-a chain → 'ca'
   └─ o                       (a branch-point, সেখানেই থামল);
      └─ g ✓                  car-এর d/e/t tail, dog tail — এক label করে।

                              node: 11 → 8   (২৭% saved!)

ca(অর্থাৎ original a) টিকেছে branch-point হিসেবে; car-এর d,e,t — প্রত্যেকে word-শেষ। Compression তথ্য মোছে না, ফাঁকা station ভাঙে।

ছবিতে

uncompressed trie (•=is_word):        radix:

        (root)                             root
       /       \\                         /      \\
      c          d                      'ca'       'dog'•
      |          |                     /    \\
      a          o                  't'•     'r'•
     / \\         |                          / | \\
   t•   r•       g•                      'd'• 'e'• 't'•

node: 11 → 8;  branch-point ('ca','r') আর word-শেষরাই টিকে;
chain-গুলো label-এ গলে গেছে। Edge-crossing-এ total compared chars
≈ query length — compression খরচ বাড়ায় না, node-count কমায়।
Word-marked trie বনাম radix — একই key-set, গুটানো রাস্তা।

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

EXPERIMENT

Dictionary দশগুণ বড় করুন — trie-র ঘড়ি নড়ে না

Python 3.8+ (macOS/Linux)· ১৫ মিনিট
import time, random, os


# --- build অংশের Trie/TrieNode/build_radix এখানে পেস্ট করুন ---

def load_words(limit):
    path = '/usr/share/dict/words'
    if os.path.exists(path):
        with open(path, encoding='utf-8', errors='ignore') as f:
            ws = [w.strip() for w in f
                  if w.strip().isalpha() and w.strip().islower()]
        return ws[:limit]
    return [''.join(random.choices('abcdefgh', k=6))
            for _ in range(limit)]


random.seed(3)
for size in (10_000, 100_000):
    words = load_words(size)

    t0 = time.perf_counter()
    t = Trie()
    for w in words:
        t.insert(w)
    build = time.perf_counter() - t0

    queries = [random.choice(words)[:4] for _ in range(200)]

    t0 = time.perf_counter()
    hits_trie = sum(len(t.autocomplete(q)) for q in queries)
    t_trie = time.perf_counter() - t0

    t0 = time.perf_counter()
    hits_lin = sum(len([w for w in words if w.startswith(q)])
                   for q in queries)
    t_lin = time.perf_counter() - t0

    assert hits_trie == hits_lin
    print(f'dict={size:7d}: build {build*1000:6.0f}ms | '
          f'trie {t_trie*1000:6.1f}ms | linear {t_lin*1000:7.1f}ms | '
          f'ratio {t_lin/t_trie:5.0f}x')

# --- radix savings ---
words = load_words(100_000)
t = Trie()
for w in words:
    t.insert(w)
tn = t.count_nodes()
radix_root = build_radix(t.root)

def count_radix(rn):
    return 1 + sum(count_radix(kid) for _, kid in rn.edges.values())
rn = count_radix(radix_root)
print(f'nodes: trie {tn:,} -> radix {rn:,} ({100*(tn-rn)/tn:.0f}% saved)')

macOS-machine নমুনা output (আপনারটা ভিন্ন, প্যাটার্ন না):

dict=  10000: build     58ms | trie   54.5ms | linear   288.6ms | ratio     5x
dict= 100000: build    847ms | trie   33.8ms | linear  3504.4ms | ratio   104x
nodes: trie 327,081 -> radix 134,196  (59% saved)

তিন observation: (১) trie-query-time ১০× dictionary-তেও flat (54.5→33.8ms, variance-মধ্যে); (২) linear 289→3504ms — exactly proportional; (৩) compression ৩.২ লাখ → ১.৩ লাখ node — ৫৯% bill-cut, semantics একবিন্দুও না।

নিজে বাড়ান: query-length 3→5→8 — trie length-driven, linear length- blind; synthetic random-letter list-এ compression-% মাপুন — common-prefix না থাকলে radix-লাভ ভাঙে (structure-এর খাদ্য-chain)।

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

Dictionary 10k→100k হলে linear-scan autocomplete ~১২× ধীর, trie-র query-time কার্যত flat — size-independent; আর বাস্তব wordlist-এ radix compression ~৫৯% node গিলে ফেলে।

নিজে বানান

BUILD IT

Trie → RadixTree — queries + compression + savings-report

Python · ●●○○○
  1. Trie.insert — character-chain
  2. _walk(prefix) helper — search/startswith/autocomplete এক নামা ভাগ করে
  3. count_nodes(): census — compression-আগে-পরের ভিত্তি
  4. build_radix(tnode): one-child non-word chain merge → edge-label string
  5. report: node-before/after + দুই tree-র query-result assert
class TrieNode:
    __slots__ = ('children', 'is_word')

    def __init__(self):
        self.children = {}
        self.is_word = False


class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        n = self.root
        for ch in word:
            n = n.children.setdefault(ch, TrieNode())
        n.is_word = True

    def _walk(self, prefix):
        n = self.root
        for ch in prefix:
            n = n.children.get(ch)
            if n is None:
                return None
        return n

    def search(self, word):
        n = self._walk(word)
        return n is not None and n.is_word

    def startswith(self, prefix):
        return self._walk(prefix) is not None

    def autocomplete(self, prefix):
        n = self._walk(prefix)
        if n is None:
            return []
        out = []
        def dfs(node, s):
            if node.is_word:
                out.append(s)
            for ch, kid in node.children.items():
                dfs(kid, s + ch)
        dfs(n, prefix)
        return out

    def count_nodes(self):
        def rec(n):
            return 1 + sum(rec(k) for k in n.children.values())
        return rec(self.root)


class RadixNode:
    __slots__ = ('edges', 'is_word')     # first-char -> (label, node)

    def __init__(self):
        self.edges = {}
        self.is_word = False


def build_radix(tnode):
    """trie-subtree → radix-subtree; single-child non-word chain merge"""
    rn = RadixNode()
    rn.is_word = tnode.is_word
    for ch, kid in tnode.children.items():
        label = ch
        while len(kid.children) == 1 and not kid.is_word:
            ((c2, kid),) = kid.children.items()     # একমাত্র child গোটাও
            label += c2
        rn.edges[label[0]] = (label, build_radix(kid))
    return rn


def count_radix(rn):
    return 1 + sum(count_radix(kid) for _, kid in rn.edges.values())


def radix_search(root, word):
    n, i = root, 0
    while i \< len(word):
        found = False
        for label, kid in n.edges.values():
            if word.startswith(label, i):
                i += len(label); n = kid; found = True; break
        if not found:
            return False
    return n.is_word


if __name__ == '__main__':
    words = ['car', 'card', 'care', 'cart', 'cat', 'dog']
    t = Trie()
    for w in words:
        t.insert(w)

    print('trie nodes :', t.count_nodes())
    assert sorted(t.autocomplete('ca')) == ['car', 'card', 'care', 'cart', 'cat']

    r = build_radix(t.root)
    print('radix nodes:', count_radix(r))
    for w in words:
        assert radix_search(r, w), f'{w} হারাল!'
    assert not radix_search(r, 'cars')
    assert not radix_search(r, 'do')
    print('radix query-ও একই উত্তর — semantics intact')
    print(f'savings: {t.count_nodes()} → {count_radix(r)} nodes')

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

trie nodes : 11
radix nodes: 8
radix query-ও একই উত্তর — semantics intact
savings: 11 → 8 nodes

example-এর hand-count-এর সাথে মিলল (১১→৮)? নিয়ম আয়ত্তে। (ছোট set-এ modest savings — prefix-rich বড় dictionary-তে ৫০-৬০%, experiment-এ দেখেছেন।)

নিজে বাড়ান: (১) delete(word) + নতুন chain re-merge (বা lazy flag); (২) count_prefix(p) — node-এ word-count field রেখে O(|p|); (৩) Patricia-step: label-এর বদলে bit-skip-count কেন router-এ সুবিধা — ৫ লাইনে; (৪) বড় wordlist-এ depth-distribution মাপুন — max-depth নামে কি না।

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

Trie-র বংশধররা

  • Autocomplete/suggest: IDE symbol completion, address-bar suggestions, Elasticsearch completion suggester (ভেতরে FST — trie-র DAG-করা আত্মীয়, shared suffixes-ও share করে)।
  • IP routing: kernel FIB, DPDK LPM (LC-trie) — প্রতিটা forwarding-decision compressed-trie descent; internet control-plane = বিশাল prefix database।
  • Spell-checkers: wordlist-trie + edit-distance pruning — ভুল spelling- এর কাছাকাছি valid words সস্তায়।
  • T9/predictive keypad: digit-sequence → letter-set trie-walk — Nokia- typing magic এক prefix-tree।
  • Bioinformatics: suffix tree/array — genome substring search O(m) (string-algorithms module-এ পূর্ণ দর্শন)।
  • Storage engines: LSM key-দের shared-prefix page-compression — radix- ভাবনা page-এর ভেতরে।

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

“Trie-র lookup O(1) — hash-table-এর মতো।”

না — O(|key|): প্রতি character এক edge। Hash-ও সৎ হলে O(|key|) (hashing পুরো key পড়ে); exact-match-এ দুজন প্রতিবেশী — ফারাক constants-এ (trie-র pointer-hop দামি) আর query-type-এ। URL/file-path-জাতীয় লম্বা key-তে |key| factor টের পাওয়া যায় — তাই production tries path-compress করে।

“Trie মানেই memory-খেকো — বড় dictionary-তে ব্যবহারই না।”

Naive node-per-character ভয়ংকর — সেটা naive-এর অপরাধ। Radix ৫৯% কেটেছে (experiment); double-array pointer-ই মুছে দেয়; DAG/FST suffixes-ও share করে; HAT/burst locality ঠিক করে। প্রতিটা generation মেমরি-সমস্যারই উত্তরে জন্মেছে — Fredkin-1960 থেকে FST।

“Prefix query hash-এও হয় — সব possible prefix store করলেই তো।”

গাণিতিকভাবে সম্ভব, ব্যবহারিক আত্মহত্যা: n·L entries (১ লাখ × ৮-char = ৮ লাখ), প্রতি insertion/deletion L hash-update; range/LPM/lexicographic তবুও অসম্ভব — hash ordering মুছে দেয়। Shared-prefix-তথ্য destroy করে সেই তথ্য store-এর চেষ্টা — ভুলের মূল; trie শৃঙ্খলাটাকেই স্থাপত্য বানায়।

“Radix compression-এ query-ফল বদলে যেতে পারে।”

Semantics অপরিবর্তিত — representation-ই বদলায়: root-to-node character- sequence হুবহু এক, শুধু এক/একাধিক edge-এ ভাগ। Build-assertions প্রমাণ (cars, do correctly rejected — partial-label match)। Bug-ঝুঁকি implementation- এ (label-boundary), abstraction-এ না — তাই boundary-test লেখা হয়।

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

1

Words {an, ant, and, any}: trie আঁকুন — মোট node, কোথায় is_word, compression-এ কে টিকবে?

স্মরণ

Root→a→n→{t,d,y}: ৬ node; is_word: n, t, d, y। Compression: n word+branch — টিকবে; a single-child non-word — label-এ গুটাবে (‘an’); t/d/y word-শেষ — টিকবে। ফল ৬ → ৫। ছোট set, modest লাভ — prefix-rich বড় list-এ আসল খেলা।

2

১০ লাখ word-এ search("kite") — trie কত compare, hash কী করে? Size-এর ভূমিকা?

প্রয়োগ

Trie: ৪ edge-follow + ৪ char-compare + flag-check — dictionary-size শূন্য প্রভাব। Hash: পুরো "kite" hash (৪ char-process) + bucket-probe (avg O(1), collision-এ strcmp)। Asymptotic-এ দুজনেই Θ(|s|) exact-match-এ; practical ফারাক constants — pure exact workload-এ hash সাধারণত আগে, prefix/order- এ trie ডাকে।

3

প্রমাণ করুন: trie-তে (compressed হোক না) query s-এর lookup-এ compared chars ≤ |s|। Complexity-তে মানে কী?

যুক্তি

Uncompressed: প্রতি ধাপ ১ char consume — ধাপ ≤ |s|। Radix: প্রতি matched-edge label-এর chars disjoint query-position কাটে — total ≤ |s|; mismatch-এ তারও আগে থামে। ফল O(|s|) — input-sensitive, universe-independent। Comparison- based BST হতো O(|s|·log n) (প্রতি level full string-compare) — trie comparison-সংখ্যা key-সংখ্যা থেকে কেটে দেয়, char-processing একবারই।

4

Core-router: ৫ লাখ prefix, প্রতি packet-এ LPM; routing-change ঘন ঘন। All-prefixes-hash নাকি compressed trie?

ডিজাইন

Compressed trie (Patricia/LC-trie): (১) LPM exact-match না — hash-এ structural উত্তর নেই; all-prefixes-store = IPv4-তে ৩২× expansion + ৩২ probe; Patricia-তে ৩২-bit descent, deepest-match track — deterministic O(32)। (২) Update: prefix add/withdraw = local splice vs ৩২-entry cascade। (৩) Memory: LC-trie কয়েক MB line-rate cache-resident; hash-expansion budget ভাঙে। Trade-off: implementation-জটিলতা — router-এ correctness+latency king।

5

Synthetic random-letter wordlist-এ radix-savings % কেমন হবে English-এর তুলনায়? ভবিষ্যদ্বাণী + যুক্তি।

যুক্তি

Synthetic ≈ শূন্যের কাছে; English ৫০-৬০%-ঘেঁষা। Compression-খাদ্য = shared prefix → single-child chain। Random uniform letters-এ প্রতি position- এ multiple child — bushy, chain নেই, merge-কিছুই নেই। English morphology (pre, und, ing) — গভীর chain স্বাভাবিক। বড় শিক্ষা: data-র আকৃতি structure-এর performance ঠিক করে — B-tree fill-factor, hash load-factor, trie prefix-sharing — benchmark-এর আগে data-প্রশ্নটা আগে।

এরপর কী

এরপর কী

Trie: prefix-খোঁজার স্থাপত্য; radix: same-semantics, কম node। কিন্তু এরা static-ish — key-দের মধ্যে সম্পর্ক রাখে না।

পরের প্রশ্ন ভিন্ন: “এই দুই element এক group?” আর “merge করো” — dynamic connectivity। Union-Find দুটো op নেয় প্রায়-constant α(n)-এ — inverse Ackermann, এমন ধীর function যে observable universe-এর n-এও ৪ ছাড়ায় না। Union by rank + path compression-এর জাদু, naive chain, Kruskal থেকে image-labeling application — পরের লেসনে।

আরও পড়ুন

  • "Trie memory" (Communications of the ACM, 1960) — Edward Fredkin · 'Trie' নামের উৎস — reTRIEval; retrieval-এর জন্য প্রথম digital tree
  • "PATRICIA—Practical Algorithm to Retrieve Information Coded in Alphanumeric" (JACM, 1968) — Donald R. Morrison · Path-compressed binary trie — one-child chain গুটানোর ক্লাসিক; radix tree-র পূর্বপুরুষ
  • The Art of Computer Programming, Vol. 3, Section 6.3 — Digital Searching — Donald E. Knuth · Digital tree-র গণিত — alphabet size, node utilization, trie-vs-comparison search