Trie ও Radix Tree — prefix দিয়ে খোঁজা
Tries and Radix Trees
Trie-তে key বসে edge-এ edge-এ — character ধরে নামতে হয়, খোঁজা O(word length), dictionary দশগুণ বড় হলেও; radix compression এক-child শৃঙ্খল গুটিয়ে memory-bill অর্ধেকে নামায়।
আগে এটা বুঝি
ফোনের সার্চবারে 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-নিরপেক্ষ
গঠনগত যুক্তি: 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 R | R×pointer প্রতি node | lookup এক 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 — সিদ্ধান্ত-টেবিল
| প্রশ্ন | Hash | Trie / Radix |
|---|---|---|
| Exact match | O(1) amortized | O(|s|) |
| Prefix query | অসম্ভব — linear scan | O(|p| + matches) |
| Order / range | অসম্ভব | in-order walk-ই sorted |
| Longest-prefix match | অসম্ভব | native |
| Memory | compact | node-heavy; radix-এ ভালো |
| Worst-case guarantee | adversarial 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 কমায়।নিজে চালিয়ে দেখুন
Dictionary দশগুণ বড় করুন — trie-র ঘড়ি নড়ে না
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 গিলে ফেলে।
নিজে বানান
Trie → RadixTree — queries + compression + savings-report
- Trie.insert — character-chain
- _walk(prefix) helper — search/startswith/autocomplete এক নামা ভাগ করে
- count_nodes(): census — compression-আগে-পরের ভিত্তি
- build_radix(tnode): one-child non-word chain merge → edge-label string
- 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 nodesexample-এর 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 লেখা হয়।
বুঝেছেন কি না দেখুন
1Words {an, ant, and, any}: trie আঁকুন — মোট node, কোথায় is_word,
compression-এ কে টিকবে?
স্মরণ
{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-এর
ভূমিকা?
প্রয়োগ
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-তে মানে কী?
যুক্তি
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 একবারই।
4Core-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।
5Synthetic 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