সরাসরি পর্বে যান
পর্ব ৩১ · ৭২-এর মধ্যে · মডিউল ৪

Trie — শব্দ আর prefix খোঁজার tree

Tries (Prefix Trees) — Insert/Search/StartsWith in O(L), Prefix Counts, Autocomplete, Unicode Keys, Radix Trees & Patricia, Longest Prefix Match & Binary Tries for Maximum XOR

Taught in Bangla, this lesson builds the trie: each edge a character, insert, search and startsWith in O(L), children as arrays, hash maps or sorted vectors, prefix counts, word frequency and deletion, autocomplete by DFS in lexicographic order, tries for Bangla and Unicode text, radix trees, Patricia and related structures, and binary tries for the maximum XOR pair, with C++ and Python code.

এই পর্বে যা শিখবেন

  • কেন hash table বা সাজানো অ্যারে prefix-এর প্রশ্নে দুর্বল, আর trie কীভাবে প্রশ্নটিকেই tree-র পথ বানিয়ে ফেলে
  • Insert, search, startsWith, prefix গোনা, delete আর autocomplete হাতে trace করা, আর প্রতিটির time ও space নিখুঁতভাবে বলা
  • সন্তান রাখার তিন উপায়ের মেমরি-হিসাব, আর বাংলা বা Unicode লেখায় trie বানানোর সিদ্ধান্ত
  • Radix tree, Patricia, ternary search tree আর suffix trie চেনা — কোথায় কোনটি বাস্তবে চলে
  • Binary trie দিয়ে maximum XOR pair সমাধান করা, আর brute force-এর সাথে মিলিয়ে কোড যাচাই করা

১ · সমস্যা — ফোন কীভাবে আপনার শব্দ আগেই আন্দাজ করে

ফোনে মেসেজ লিখছেন। “ca” টাইপ করতেই কিবোর্ডের ওপরে ভেসে উঠল: car, card, cat. ফোনবুকে “Ra” লিখলেই চলে এল রহিম, রাকিব, রাশেদ। Google বা দারাজের সার্চ বক্সে দুই অক্ষর লিখতেই দশটি পরামর্শ। প্রতিটি ক্ষেত্রে প্রশ্নটি একই: “এই শুরু দিয়ে কোন কোন শব্দ আছে?” আর উত্তর দিতে হবে প্রতিটি অক্ষর চাপার সাথে সাথে, চোখের পলকে।

এই শুরুর অংশকে বলে prefix — যেমন “car”, “ca” আর “c” তিনটিই “card”-এর prefix. আর ব্যবহারকারী পুরো শব্দ লেখার আগেই সম্ভাব্য শব্দ দেখানোর সুবিধার নাম autocomplete. এর সাথে আরও দুটি কাছাকাছি প্রশ্ন আসে: বানান-যাচাইয়ে “এই শব্দটি কি অভিধানে আছে?”, আর পরিসংখ্যানে “কতগুলো শব্দ ‘pre’ দিয়ে শুরু?”

আগের পর্বগুলোর অস্ত্র দিয়ে চেষ্টা করি। ধরুন অভিধানে n টি শব্দ, প্রশ্নের prefix-এর দৈর্ঘ্য L.

যা দিয়েশব্দটি আছে কি?এই prefix দিয়ে কয়টি?সমস্যা
অসাজানো listO(n·L)O(n·L)প্রতিবার সব শব্দ দেখা
Hash table (পর্ব ৩২)গড়ে O(L)O(n·L) — সব key দেখতে হয়hash পুরো শব্দের; “ca” আর “car”-এর hash-এর কোনো সম্পর্ক নেই
সাজানো অ্যারে + binary searchO(L log n)O(L log n) — দুটি সীমা খুঁজে বিয়োগকাজ চলে, কিন্তু নতুন শব্দ যোগ O(n)
BST (পর্ব ২৭–২৯)O(L log n)বাড়তি তথ্য রাখলে O(L log n)প্রতিটি node-এ পুরো string তুলনা
TrieO(L)O(L)মেমরি বেশি লাগতে পারে (অংশ ৪)

সাজানো অ্যারে কিন্তু খারাপ নয় — পর্ব ১৮-এর lower bound দিয়ে “ca”-র প্রথম শব্দ আর “cb”-র প্রথম শব্দের অবস্থান খুঁজে বিয়োগ করলেই “ca” দিয়ে কয়টি শব্দ, তা বের হয়। প্রতিটি তুলনা দুটি string-এর, তাই খরচ O(L log n). কিন্তু প্রতিটি নতুন শব্দ ঢোকাতে বাকিদের সরাতে হয়। Hash table-এর অবস্থা আরও খারাপ: hash function ইচ্ছা করেই কাছাকাছি key-গুলোকে দূরে দূরে ছড়িয়ে দেয়, তাই prefix-এর তথ্য পুরো হারিয়ে যায়।

পুরোনো দিনের বড় একটি ছাপা অভিধানের কথা ভাবুন। “card” খুঁজতে আপনি প্রথমে পাশের বুড়ো আঙুলের খাঁজ দেখে “C” অংশে যান, তারপর পাতার মাথার লেখা দেখে “ca”, তারপর “car”. আপনি কখনো “card”-কে অভিধানের প্রতিটি শব্দের সাথে তুলনা করেন না — প্রতিটি অক্ষর আপনাকে এক ধাপ ছোট একটি অংশে নিয়ে যায়। Trie ঠিক এই কাজটিকে ডেটা স্ট্রাকচার বানায়: প্রতিটি অক্ষর একটি মোড়, আর পুরো শব্দ মানে root থেকে একটি পথ।

২ · Trie-এর গঠন — প্রতিটি edge একটি অক্ষর

Trie একটি rooted tree. এর নিয়ম মাত্র তিনটি:

  • প্রতিটি edge-এ একটি অক্ষর. কোনো node থেকে একই অক্ষরের দুটি edge বের হয় না।
  • প্রতিটি node একটি prefix. Root থেকে কোনো node পর্যন্ত পথের অক্ষরগুলো পরপর পড়লে যে string হয়, সেই node সেটিকেই বোঝায়। Root নিজে খালি string “”. লক্ষ করুন, অক্ষর আসলে node-এর ভেতরে লেখা থাকে না — থাকে parent থেকে আসা edge-এ; ছবিতে সুবিধার জন্য অক্ষরটি node-এর গায়ে লেখা হয়।
  • শব্দের শেষে একটি চিহ্ন. যে node-এ কোনো রাখা শব্দ শেষ হয়েছে, সেখানে একটি end-of-word marker (একটি bool বা counter) থাকে। এটি ছাড়া “car” রাখলে “ca”-ও আছে বলে মনে হতো — কারণ “ca”-র node-ও তো tree-তে আছে।

এখান থেকেই trie-র আরেক নাম prefix tree, আর পুরোনো বইয়ে digital tree বা digital search tree. প্রতিটি node-এর সন্তান সর্বোচ্চ σ (সিগমা) টি, যেখানে σ হলো alphabet size — ছোট হাতের ইংরেজি অক্ষরে ২৬, বাইটে ২৫৬, DNA-তে ৪ (A, C, G, T), আর bit-এ ২। নিচে খালি trie-তে পরপর “car”, “cat”, “card” আর “do” ঢোকানো দেখুন। * চিহ্ন মানে এখানে একটি শব্দ শেষ।

Trie (* = শব্দ শেষ)সবুজ = এই ধাপে ছোঁয়া node

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
নতুন node
মোট node (root-সহ)
এ পর্যন্ত ঢোকানো অক্ষর
ফল
  1. শুরু. খালি trie-তে শুধু root — খালি string-এর node. এখনো কোনো শব্দ নেই।
  2. “car”. root-এর কোনো c-সন্তান নেই, তাই নতুন node; তারপর a, তারপর r — ৩টি নতুন node. শেষ node r-এ * বসল: “car” একটি শব্দ।
  3. “cat”. c আর a আগে থেকেই আছে — শুধু হেঁটে যাই, নতুন কিছু বানাই না। a-র t-সন্তান নেই, তাই মাত্র ১টি নতুন node, তাতে *. ৬টি অক্ষর ঢুকেছে, কিন্তু node মাত্র ৫টি — ভাগাভাগির শুরু।
  4. “card”. c → a → r পুরোটাই আছে; r-এর নিচে নতুন d, তাতে *. লক্ষ করুন, r-এর * মুছে যায়নি — একটি node শব্দের শেষ হয়েও অন্য শব্দের মাঝখানে থাকতে পারে। তাই end-চিহ্ন আর “leaf কি না” আলাদা জিনিস।
  5. “do”. root-এর d-সন্তান নেই — নতুন শাখা শুরু: d, o, ২টি নতুন node. মোট ১২টি অক্ষর ঢুকেছে, node মাত্র ৮টি (root-সহ)।
  6. search(“ca”). root → c → a — পথটি আছে, কিন্তু a-তে * নেই, তাই “ca” রাখা শব্দ নয়: search = no. অথচ startsWith(“ca”) = yes, কারণ পথটির অস্তিত্বই প্রমাণ যে অন্তত একটি শব্দ “ca” দিয়ে শুরু। দুই প্রশ্নের পার্থক্য শুধু শেষের চিহ্নটি দেখা।

দুটি তথ্য মনে রাখুন। প্রথমত, trie-র আকার রাখা শব্দগুলোর ওপর নির্ভর করে, ঢোকানোর ক্রমের ওপর নয় — যে ক্রমেই car, cat, card, do ঢোকান, ঠিক এই tree-ই হবে। BST-র (পর্ব ২৭) মতো কোনো ক্রমে tree একপাশে ঝুলে পড়ার ভয় নেই, তাই ভারসাম্য রাখার কোনো ঝামেলাও নেই। দ্বিতীয়ত, node-এর সংখ্যা সর্বোচ্চ N + 1, যেখানে N = সব শব্দের দৈর্ঘ্যের যোগফল — প্রতিটি অক্ষর বড়জোর একটি নতুন node বানায়। শব্দগুলো যত বেশি prefix ভাগ করে, node তত কম।

১৯৫৯ সালে René de la Briandais একটি সম্মেলনে (Western Joint Computer Conference) “File searching using variable length keys” প্রবন্ধে প্রথম এমন একটি tree বর্ণনা করেন, যেখানে key-এর অক্ষর ধরে ধরে নামা হয় — প্রতিটি node-এর সন্তানরা ছিল একটি linked list-এ। পরের বছর, ১৯৬০ সালে, Edward Fredkin Communications of the ACM-এ “Trie Memory” প্রকাশ করেন আর নামটি দেন — retrieval (তথ্য উদ্ধার) শব্দের মাঝখান থেকে। Fredkin নিজে শব্দটি “tree”-র মতো উচ্চারণ করতেন; পরে অনেকে “try” বলতে শুরু করেন, যাতে সাধারণ tree-র সাথে গুলিয়ে না যায়। দুটি উচ্চারণই আজ চলে।

৩ · Insert, search আর startsWith — O(L), n যত বড়ই হোক

তিনটি কাজের কঙ্কাল একই: root থেকে শুরু করে শব্দের প্রতিটি অক্ষরের edge ধরে এক ধাপ নামা। পার্থক্য শুধু পথ না পেলে কী করবেন, আর শেষে কী দেখবেন।

Pseudocode — trie over a..z (0-based letters: c = ch - 'a')

node: child[0..25]  (NIL = no child),  isEnd = false

insert(w):
    v = root
    for each ch in w:
        if v.child[ch] == NIL: v.child[ch] = new node     // only here does the trie grow
        v = v.child[ch]
    v.isEnd = true

walk(p):                                                // the node for prefix p, or NIL
    v = root
    for each ch in p:
        v = v.child[ch]
        if v == NIL: return NIL                         // some word would need this edge
    return v

search(w):      v = walk(w);  return v != NIL and v.isEnd
startsWith(p):  return walk(p) != NIL

কেন এটি ঠিক — invariant. insert আর walk-এর লুপে i টি অক্ষর পার হওয়ার পর একটি কথা সব সময় সত্য: “v হলো সেই node, root থেকে যার পথের অক্ষরগুলো ঠিক w-এর প্রথম i টি অক্ষর।” শুরুতে i = 0, v = root, পথ খালি — সত্য। প্রতিটি ধাপে আমরা অক্ষর w[i]-এর edge ধরে নামি, তাই পথে ঠিক w[i] যোগ হয় — সত্য থাকে। কোনো node থেকে একই অক্ষরের দুটি edge নেই, তাই প্রতিটি prefix-এর node একটিই. এখন search-এর শুদ্ধতা: w আগে ঢোকানো হয়ে থাকলে তার পথ তৈরি হয়েছিল আর শেষ node-এ isEnd বসেছিল — তাই search হ্যাঁ বলবে। উল্টো দিকে, isEnd শুধু insert-এর শেষ লাইনই বসায়, আর সেটি বসে ঠিক ঢোকানো শব্দের node-এ — তাই কোনো না-ঢোকানো শব্দে search হ্যাঁ বলতে পারে না। startsWith(p) হ্যাঁ ⇔ p-এর node আছে ⇔ কোনো ঢোকানো শব্দের পথ এই node দিয়ে গেছে ⇔ কোনো শব্দ p দিয়ে শুরু। এটি পর্ব ০৩-এর loop invariant পদ্ধতির সরল প্রয়োগ।

বিশ্লেষণ. প্রতিটি অক্ষরে একটি সন্তান খোঁজা আর বড়জোর একটি node বানানো। সন্তান যদি array-তে থাকে, খোঁজা O(1) — তাই insert, search আর startsWith তিনটিই Θ(L) সময় (শব্দ না থাকলে আরও আগে থামতে পারে, তাই search-এর worst case O(L)). লক্ষ করুন n কোথাও নেই! দশ শব্দের অভিধান আর দশ লাখ শব্দের অভিধানে “card” খুঁজতে একই ৪টি ধাপ। বাড়তি জায়গা: search আর startsWith O(1), insert বড়জোর L টি নতুন node — প্রতিটি σ আকারের array নিলে O(L·σ). পুরো trie-র জায়গা O(N·σ), N = মোট অক্ষর।

মূল ধারণা

BST-তে প্রতিটি তুলনা একটি পুরো string-এর সাথে, আর তুলনা চলে log n বার — খরচ O(L log n). Trie-তে প্রতিটি অক্ষর একবারই দেখা হয়, আর সেই অক্ষর নিজেই বলে দেয় কোন দিকে যেতে হবে। Key-এর ভেতরের গঠন (অক্ষরের ক্রম) কাজে লাগানোতেই লাভ — ঠিক যেমন radix sort (পর্ব ২৪) সংখ্যার অঙ্ক ধরে কাজ করে তুলনার সীমা এড়ায়।

৪ · সন্তান কোথায় রাখবেন — array[26], hash map, না sorted vector

Pseudocode-এ লিখেছি child[0..25]. কিন্তু এটিই একমাত্র উপায় নয়, আর বড় trie-তে এই সিদ্ধান্তই ঠিক করে দেয় প্রোগ্রাম মেমরিতে ধরবে কি না। তিনটি প্রচলিত উপায় পাশাপাশি দেখুন (σ = alphabet size, d = node-টির আসল সন্তানের সংখ্যা):

Fixed array int next[26]

অক্ষর c-এর সন্তান সরাসরি next[c - 'a']-এ — O(1) খোঁজা, সবচেয়ে সহজ কোড, আর ক্রমে ঘুরলে আপনাআপনি a থেকে z. কিন্তু প্রতিটি node-এ σ টি ঘর, সন্তান থাকুক বা না থাকুক।

দ্রুততম, জায়গা Θ(σ) প্রতি node

Hash map unordered_map<char,int> / dict

শুধু আসল সন্তানগুলো থাকে, তাই বড় alphabet-এ (Unicode) জায়গা বাঁচে। খোঁজা গড়ে O(1), কিন্তু hashing-এর ধ্রুবক বড়, প্রতিটি map-এর নিজস্ব খরচ আছে, আর ক্রম নেই — autocomplete-এ key সাজাতে হয়।

বড় alphabet-এ নমনীয়

Sorted vector vector<pair<char,int>>

সন্তানগুলো অক্ষর ধরে সাজানো ছোট একটি অ্যারেতে। খোঁজা O(log d) (binary search) বা ছোট d-তে সরল linear scan; জায়গা ঠিক O(d); ক্রমও থাকে। de la Briandais-এর linked list ধারণার আধুনিক, cache-বান্ধব রূপ।

কম জায়গা, ক্রমসহ

সংখ্যা দিয়ে ভাবি। ধরুন একটি ইংরেজি অভিধানের trie-তে ২০ লাখ node. int next[26] (প্রতি ঘর ৪ byte) মানে প্রতি node-এ ১০৪ byte — মোট প্রায় ২০৮ MB. Pointer রাখলে (৬৪-bit মেশিনে ৮ byte) দ্বিগুণ, প্রায় ৪১৬ MB. অথচ বাস্তব অভিধানের trie-তে গভীরের বেশিরভাগ node-এর সন্তান মাত্র একটি বা শূন্য — অর্থাৎ ২৬টি ঘরের প্রায় ২৫টিই খালি পড়ে থাকে। Sorted vector-এ তখন প্রতি সন্তানে মাত্র কয়েক byte, সাথে vector-এর নিজের সামান্য খরচ।

উপায়এক ধাপে খোঁজাinsert/search মোটমোট জায়গাক্রমে ঘোরা
array[σ]O(1)O(L)Θ(nodes · σ)আপনাআপনি
hash mapগড়ে O(1)গড়ে O(L)O(nodes) কিন্তু বড় ধ্রুবকপ্রতি node-এ সাজাতে হয়: O(d log d)
sorted vectorO(log d) ≤ O(log σ)O(L log σ)O(nodes) — প্রায় ঠিক যতটুকু দরকারআপনাআপনি
linked list (de la Briandais)O(d) ≤ O(σ)O(L·σ)O(nodes)সাজিয়ে রাখলে আপনাআপনি

লক্ষ করুন, “মোট জায়গা” কলামে nodes ≤ N + 1. অর্থাৎ array-র সংস্করণে জায়গা O(N·σ), বাকিগুলোতে O(N). আরেকটি বাস্তব কৌশল: প্রতিটি node-এর জন্য আলাদা new না করে সব node একটি বড় অ্যারেতে রাখুন আর pointer-এর বদলে int index ব্যবহার করুন (এই পর্বের কোড ঠিক তাই করে)। এতে মেমরি অর্ধেক হয়, allocation-এর খরচ যায়, আর node-গুলো পাশাপাশি থাকায় cache locality ভালো হয়। কনটেস্টে প্রায়ই আগে থেকে হিসাব করে নেওয়া হয়: মোট অক্ষর সর্বোচ্চ ১০৬ হলে node-এর অ্যারে ১০৬ + ১ আকারের।

সবচেয়ে প্রচলিত crash. next[ch - 'a'] ধরে নেয় সব অক্ষর ছোট হাতের a থেকে z. ইনপুটে একটি বড় হাতের অক্ষর, অঙ্ক বা space এলে index ঋণাত্মক বা ২৫-এর বেশি — অ্যারের বাইরে লেখা, যা C++-এ undefined behavior. হয় ইনপুট আগে ছোট হাতের করুন ও যাচাই করুন, নয়তো σ বড় নিন (যেমন ১২৮টি ASCII অক্ষর), নয়তো map ব্যবহার করুন।

৫ · Prefix গোনা, শব্দের frequency আর delete

শুধু একটি bool-এর বদলে প্রতিটি node-এ দুটি counter রাখুন: pass[v] — কতগুলো ঢোকানো শব্দের পথ v দিয়ে গেছে, আর end[v] — কতগুলো শব্দ ঠিক v-তে শেষ হয়েছে। Insert-এর সময় পথের প্রতিটি node-এ (root-সহ) pass এক বাড়ান, আর শেষ node-এ end এক বাড়ান। এই ছোট পরিবর্তনে তিনটি নতুন প্রশ্নের উত্তর মেলে, প্রত্যেকটি O(L)-এ:

  • countPrefix(p) = pass[walk(p)] — কতগুলো শব্দ p দিয়ে শুরু। কোনো DFS লাগে না, উত্তর node-এই লেখা আছে। এটিই prefix count.
  • frequency(w) = end[walk(w)] — একই শব্দ বারবার ঢোকালে কতবার এসেছে। এক কোটি সার্চের log থেকে প্রতিটি সার্চ-বাক্য কতবার খোঁজা হয়েছে, তা গোনার একটি স্বাভাবিক উপায়।
  • erase(w) — আগে search করে নিশ্চিত হোন w আছে, তারপর পথ ধরে প্রতিটি pass এক কমান আর শেষে end এক কমান। যে node-এর pass শূন্য হলো, তার নিচে আর কোনো শব্দ নেই — সেটি কেটে ফেলা যায়।

নিচে car, card, care আর cat রাখা trie থেকে “card” মুছে ফেলা দেখুন। প্রতিটি node-এর পাশে লেখা আছে [pass, end].

Trie: প্রতিটি node-এ [pass, end]সবুজ = এই ধাপে বদলানো

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
countPrefix("car") = pass[r]
search("card")
search("car")
  1. শুরু. চারটি শব্দ, তাই root-এর pass 4. r-এর pass 3 (car, card, care), end 1 (car নিজে). countPrefix(“car”) = 3, কোনো DFS ছাড়াই।
  2. আগে যাচাই. root → c → a → r → d — পথ আছে, আর d-তে end = 1, তাই “card” সত্যিই আছে। যাচাই না করে কমালে ভুল শব্দ মুছতে গিয়ে অন্য শব্দের counter নষ্ট হতো।
  3. ওপর থেকে কমানো. root, c আর a-এর pass 4 থেকে 3 — এখন তিনটি শব্দ এদের মধ্য দিয়ে যায়।
  4. শেষ পর্যন্ত. r-এর pass 3 থেকে 2, d-এর pass 1 থেকে 0 আর end 1 থেকে 0. এখনই countPrefix(“car”) = 2 আর search(“card”) = no. কিন্তু r-এর end এখনো 1 — “car” অক্ষত, কারণ আমরা শুধু card-এর নিজের অংশটুকুই কমিয়েছি।
  5. ছাঁটাই. d-এর pass 0, মানে এর নিচে আর কোনো শব্দ নেই — node-টি কেটে ফেলা যায় (বা শুধু উপেক্ষা করা যায়: autocomplete-এ pass 0 সন্তানে না নামলেই হলো)। মোট কাজ L = 4টি node, তাই erase Θ(L).

Counter ছাড়া, শুধু bool দিয়েও delete করা যায় — recursion দিয়ে: শব্দের শেষ node-এ isEnd = false করুন, তারপর ফেরার পথে প্রতিটি node দেখুন — তার কোনো সন্তান নেই আর সে নিজে কোনো শব্দের শেষ নয়? তাহলে তাকে মুছে parent-এর ঘর NIL করুন। প্রথম যে node-এ শর্তটি মেলে না (যেমন উপরের r, যেটি “car”-এর শেষ), সেখানেই থামুন। প্রচলিত ভুল হলো শর্ত না দেখেই পুরো পথ মুছে ফেলা — তাতে “card” মুছতে গিয়ে “car” আর “care”-ও হারিয়ে যায়।

৬ · Autocomplete — DFS দিয়ে অভিধানের ক্রমে সব শব্দ

“ca” টাইপ করা হয়েছে। আগে walk("ca") দিয়ে prefix-এর node খুঁজুন — O(L). তারপর সেই node-এর subtree-তে যত শব্দ আছে, সবই “ca” দিয়ে শুরু, আর বাইরে একটিও নেই। তাই উত্তর = subtree-র সব end-চিহ্নিত node. এদের বের করতে DFS চালান, সাথে বর্তমান string-টি নিয়ে: নামার সময় অক্ষর যোগ, ফেরার সময় বাদ। প্রতিটি node-এ আগে নিজের শব্দটি (যদি থাকে) লিখুন, তারপর সন্তানদের a থেকে z ক্রমে ঘুরুন — এটিই preorder traversal (পর্ব ২৬).

“ca”-র subtree (* = শব্দ শেষ)সবুজ = বর্তমান node-এর পথ

            
প্রতিটি ধাপের পর
বর্তমান string (cur)
recursion-এর পথ
আউটপুট এ পর্যন্ত
  1. prefix-এ হাঁটা. root → c → a: “ca”-র node পাওয়া গেল। a-তে end-চিহ্ন নেই, তাই “ca” নিজে আউটপুটে যায় না। এবার a-র সন্তানদের ক্রমে দেখা: r, তারপর t.
  2. r. cur = “car”. r-এ * আছে — আগে নিজের শব্দ: আউটপুটে “car”. তারপর r-এর সন্তান d, e, t — এই ক্রমে।
  3. d. cur = “card”, * আছে — “card”. d-র কোনো সন্তান নেই, ফিরে গেলাম r-এ, cur থেকে d বাদ।
  4. e. cur = “care” — আউটপুটে যোগ, তারপর ফেরা।
  5. t (r-এর নিচে). cur = “cart” — যোগ। r-এর সব সন্তান শেষ, ফিরে গেলাম a-তে।
  6. t (a-র নিচে). cur = “cat” — যোগ। আর কিছু বাকি নেই। আউটপুট car card care cart cat — ঠিক অভিধানের ক্রমে, অথচ কোথাও sort করা হয়নি।

কেন ক্রমটি lexicographic — প্রমাণ. দুটি আলাদা রাখা শব্দ u আর w নিন, যেখানে lexicographic order-এ u < w. দুটি ক্ষেত্র। (ক) u হলো w-এর prefix (যেমন car আর card): তাহলে u-র node w-এর পথের ওপরে, পূর্বপুরুষ; preorder-এ node নিজে তার সব বংশধরের আগে আসে, তাই u আগে লেখা হয়। (খ) নইলে প্রথম যে index-এ দুটি আলাদা, সেখানে u-র অক্ষর ছোট (যেমন card আর care-এ d < e): দুটির পথ সেই পর্যন্ত এক, তারপর একই node থেকে দুটি আলাদা সন্তানে ভাগ হয় — আর আমরা ছোট অক্ষরের সন্তানের পুরো subtree শেষ করে তারপর বড়টিতে যাই। তাই u আগে আসে। দুই ক্ষেত্রেই DFS-এর ক্রম = lexicographic ক্রম। ∎ (এই কারণে n টি শব্দ trie-তে ঢুকিয়ে পুরো DFS করলেই সাজানো list পাওয়া যায় — O(N·σ) সময়ের এক ধরনের radix sort.)

খরচ. prefix-এ হাঁটা O(L). তারপর DFS subtree-র প্রতিটি node একবার দেখে; array[σ] হলে প্রতিটি node-এ σ টি ঘর পরীক্ষা, তাই O(L + σ·S + K), যেখানে S = subtree-র node-সংখ্যা আর K = আউটপুটের মোট দৈর্ঘ্য (প্রতিটি শব্দ লিখতে তার দৈর্ঘ্যের সমান সময় লাগে)। ফোনের কিবোর্ড কিন্তু সব শব্দ দেখায় না, দেখায় সবচেয়ে বেশি ব্যবহৃত তিনটি — এ নিয়ে অংশটির শেষে।

এবার সব একসাথে কোডে: insert, search, startsWith, countPrefix, frequency আর autocomplete. C++ সংস্করণে সন্তান array<int, 26>-এ, সব node একটি vector-এ, আর index 0 একই সাথে root আর “সন্তান নেই”-এর চিহ্ন (root কখনো কারও সন্তান হয় না, তাই গোলমাল হয় না)। Python সংস্করণে সন্তান dict-এ — অংশ ৪-এর hash map উপায়।

#include <array>
#include <initializer_list>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
struct Trie {
    vector<array<int, 26>> nxt = vector<array<int, 26>>(1);  // node 0 = root; 0 also means "no child"
    vector<int> pass{0}, ends{0};          // words passing through v / words ending at v
    void insert(const string& w) {
        int v = 0; ++pass[v];
        for (char ch : w) {
            int c = ch - 'a';
            if (!nxt[v][c]) { nxt[v][c] = (int)nxt.size(); nxt.push_back({}); pass.push_back(0); ends.push_back(0); }
            v = nxt[v][c]; ++pass[v];
        }
        ++ends[v];
    }
    int walk(const string& p) const {      // node of prefix p, or -1
        int v = 0;
        for (char ch : p) if (!(v = nxt[v][ch - 'a'])) return -1;
        return v;
    }
    bool search(const string& w) const { int v = walk(w); return v >= 0 && ends[v] > 0; }
    int countPrefix(const string& p) const { int v = walk(p); return v < 0 ? 0 : pass[v]; }
    void dfs(int v, string& cur, string& out) const {          // preorder, children a..z
        if (ends[v]) out += (out.empty() ? "" : " ") + cur;
        for (int c = 0; c < 26; ++c)
            if (int u = nxt[v][c]) { cur += char('a' + c); dfs(u, cur, out); cur.pop_back(); }
    }
    string complete(string p) const { string out; if (int v = walk(p); v >= 0) dfs(v, p, out); return out; }
};
int main() {
    Trie t;
    for (const char* w : {"car", "card", "care", "cart", "cat", "do", "dog", "dot", "deal", "dog"}) t.insert(w);
    auto yn = [](bool b) { return b ? "yes" : "no"; };
    cout << "nodes: " << t.nxt.size() << "  words: " << t.countPrefix("") << '\n';
    cout << "search car: " << yn(t.search("car")) << "  search ca: " << yn(t.search("ca"))
         << "  startsWith ca: " << yn(t.countPrefix("ca") > 0) << '\n';
    cout << "prefix car: " << t.countPrefix("car") << "  prefix do: " << t.countPrefix("do")
         << "  freq dog: " << t.ends[t.walk("dog")] << '\n';
    cout << "complete ca: " << t.complete("ca") << "\ncomplete d : " << t.complete("d") << '\n';
}

আউটপুট:

nodes: 15  words: 10
search car: yes  search ca: no  startsWith ca: yes
prefix car: 4  prefix do: 4  freq dog: 2
complete ca: car card care cart cat
complete d : deal do dog dot

দশটি শব্দে মোট ৩৩টি অক্ষর ঢুকেছে, অথচ node মাত্র ১৫টি (root-সহ) — car, card, care, cart, cat একই c → a পথ ভাগ করে, আর দ্বিতীয়বারের “dog” কোনো নতুন node বানায়নি, শুধু counter বাড়িয়েছে। তাই prefix do = 4 (do, dog, dog, dot) আর freq dog = 2. search ca = no কিন্তু startsWith = yes — অংশ ২-এর পার্থক্য। complete d-এ deal আগে, কারণ দ্বিতীয় অক্ষর e < o; আর do এসেছে dog-এর আগে, কারণ preorder-এ prefix আগে। walk-এর লুপে v = nxt[v][...] একসাথে নামা আর “সন্তান নেই” যাচাই করে। freq dog-এ walk −1 দিলে অ্যারের বাইরে পড়ত; এখানে শব্দটি আছে জানি বলে সরাসরি লেখা, বাস্তব কোডে আগে যাচাই করুন।

class Trie:
    def __init__(self):
        self.kids = [{}]                     # node 0 = root; kids[v]: dict letter -> child id
        self.passes, self.ends = [0], [0]    # words through v / words ending at v
    def insert(self, w):
        v = 0
        self.passes[0] += 1
        for ch in w:
            if ch not in self.kids[v]:
                self.kids[v][ch] = len(self.kids)
                self.kids.append({}); self.passes.append(0); self.ends.append(0)
            v = self.kids[v][ch]
            self.passes[v] += 1
        self.ends[v] += 1
    def walk(self, p):                       # node of prefix p, or -1
        v = 0
        for ch in p:
            v = self.kids[v].get(ch, -1)
            if v < 0: return -1
        return v
    def search(self, w):
        v = self.walk(w); return v >= 0 and self.ends[v] > 0
    def count_prefix(self, p):
        v = self.walk(p); return 0 if v < 0 else self.passes[v]
    def complete(self, p):                   # stored words starting with p, a..z order
        out = []
        def dfs(v, cur):
            if self.ends[v]: out.append(cur)
            for ch in sorted(self.kids[v]):  # a dict keeps insertion order: sort the keys
                dfs(self.kids[v][ch], cur + ch)
        v = self.walk(p)
        if v >= 0: dfs(v, p)
        return " ".join(out)

t = Trie()
for w in ["car", "card", "care", "cart", "cat", "do", "dog", "dot", "deal", "dog"]: t.insert(w)
yn = lambda b: "yes" if b else "no"
print("nodes:", len(t.kids), " words:", t.count_prefix(""))
print("search car:", yn(t.search("car")), " search ca:", yn(t.search("ca")), " startsWith ca:", yn(t.count_prefix("ca") > 0))
print("prefix car:", t.count_prefix("car"), " prefix do:", t.count_prefix("do"), " freq dog:", t.ends[t.walk("dog")])
print("complete ca:", t.complete("ca"))
print("complete d :", t.complete("d"))

আউটপুট C++-এর সাথে হুবহু এক:

nodes: 15  words: 10
search car: yes  search ca: no  startsWith ca: yes
prefix car: 4  prefix do: 4  freq dog: 2
complete ca: car card care cart cat
complete d : deal do dog dot

এখানে প্রতিটি node-এর সন্তান একটি dict — শুধু আসল সন্তানগুলো থাকে, তাই বাংলা বা যেকোনো Unicode অক্ষরও কোনো পরিবর্তন ছাড়াই চলবে। কিন্তু dict অক্ষরের ক্রম মনে রাখে না, রাখে ঢোকানোর ক্রম; তাই complete-এ sorted(self.kids[v]) — এটি বাদ দিলে “d”-এর উত্তর আসত do dog dot deal, কারণ “do” আগে ঢুকেছিল। এই এক লাইনের জন্য প্রতিটি node-এ O(d log d) বাড়তি খরচ। লক্ষ করুন count_prefix("") = root-এর pass = মোট শব্দ ১০, ডুপ্লিকেটসহ।

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

নিচের টুলে “চালিয়ে দেখুন” চাপুন। বাক্সে একটি শব্দ লিখে যোগ করুন চাপলে সেটি trie-তে ঢুকবে, আর যে node-গুলোর মধ্য দিয়ে গেছে সেগুলো লাল হবে। মোটা দুই-দাগের বর্ডার মানে সেখানে একটি শব্দ শেষ। একটি prefix লিখে পরামর্শ চাপলে prefix-এর পথ জ্বলবে, আর নিচে আসবে সব completion. এই সংস্করণ সন্তান রাখে Map-এ আর অক্ষর ধরে for…of লুপে — তাই “কলম”, “কমল”, “কলা”-র মতো বাংলা শব্দও দিয়ে দেখুন (অংশ ৭).

const kids = [new Map()], endw = [false];     // node 0 = root; kids[v]: letter -> child id
const $ = id => document.getElementById(id);
let hot = new Set();
function addWord(w) {
  let v = 0, made = 0; hot = new Set([0]);
  for (const ch of w) {                        // for..of walks code points, so Bangla works too
    if (!kids[v].has(ch)) { kids[v].set(ch, kids.length); kids.push(new Map()); endw.push(false); made++; }
    v = kids[v].get(ch); hot.add(v);
  }
  endw[v] = true;
  return made;
}
function words(v, cur, out) {                  // preorder DFS, children in sorted order
  if (endw[v]) out.push(cur);
  for (const ch of [...kids[v].keys()].sort()) words(kids[v].get(ch), cur + ch, out);
  return out;
}
function tree(v) {
  const ks = [...kids[v].keys()].sort();
  if (!ks.length) return "";
  return "<ul>" + ks.map(ch => { const c = kids[v].get(ch);
    return `<li><span class="nd${hot.has(c) ? " hl" : ""}${endw[c] ? " end" : ""}">${ch}</span>${tree(c)}</li>`; }).join("") + "</ul>";
}
function draw(msg) {
  $("tr").innerHTML = '<span class="nd hl">root</span>' + tree(0);
  $("msg").textContent = msg; console.log(msg);
}
function insertWord(w) {
  w = w.trim().toLowerCase(); if (!w) return;
  const m = addWord(w); draw(`insert ${w}: +${m} nodes, total ${kids.length}`);
}
function suggest(p) {
  p = p.trim().toLowerCase(); let v = 0; hot = new Set([0]);
  for (const ch of p) { v = kids[v].get(ch); if (v === undefined) { hot = new Set(); return draw(`prefix ${p}: no words`); } hot.add(v); }
  draw(`prefix ${p}: ` + words(v, p, []).join(" "));
}
$("bi").onclick = () => insertWord($("wv").value);
$("bs").onclick = () => suggest($("wv").value);
$("br").onclick = () => { kids.length = 1; kids[0] = new Map(); endw.length = 1; endw[0] = false; hot = new Set(); draw("empty trie"); };
for (const w of ["car", "cat", "card", "do", "dog"]) insertWord(w);
suggest("ca");

শুরুতেই কোড নিজে পাঁচটি শব্দ ঢোকায় আর “ca”-র পরামর্শ দেখায়:

insert car: +3 nodes, total 4
insert cat: +1 nodes, total 5
insert card: +1 nodes, total 6
insert do: +2 nodes, total 8
insert dog: +1 nodes, total 9
prefix ca: car card cat

সংখ্যাগুলো অংশ ২-এর ডায়াগ্রামের সাথে মেলে: car-এ ৩টি, cat আর card-এ ১টি করে নতুন node. “dog”-এ মাত্র ১টি, কারণ d → o আগেই ছিল। ছবিটি একটি nested list: প্রতিটি সন্তান তার parent-এর নিচে একটু ডানে, ড্যাশ-দাগ দিয়ে জোড়া। পরীক্ষা: “cart” যোগ করুন — শুধু ১টি নতুন node আসবে। তারপর “ka” দিয়ে পরামর্শ চান — “no words”. এবার “কলম” আর “কমল” যোগ করে দেখুন: দুটি শব্দ প্রথম অক্ষর “ক” ভাগ করে, তারপর আলাদা শাখায় যায়।

বেশি ব্যবহৃত শব্দ আগে, আর longest common prefix

বাস্তবের কিবোর্ড অভিধানের ক্রমে সব শব্দ দেখায় না, দেখায় সবচেয়ে সম্ভাব্য তিনটি। এখানে end[v] হয় শব্দের frequency — কতবার ব্যবহার হয়েছে। সরল উপায়: subtree-র সব শব্দ বের করে (frequency, শব্দ) ধরে পর্ব ২৩-এর top-k heap চালানো — O(L + σ·S + S log k). কিন্তু “a” টাইপ করলে subtree প্রায় অর্ধেক অভিধান! তাই বড় সিস্টেম আগে থেকে হিসাব করে রাখে: প্রতিটি node-এ তার subtree-র সেরা k টি শব্দের একটি ছোট list. প্রশ্ন তখন O(L + k), কিন্তু প্রতিটি node-এ k টি বাড়তি তথ্য, আর frequency বদলালে পথের সব node-এর list হালনাগাদ করতে হয় — আবারও সময় আর জায়গার বিনিময়।

একটি ক্লাসিক ইন্টারভিউ প্রশ্ন: কয়েকটি শব্দের longest common prefix (LCP) কী? পর্ব ১১-এ দুটি সরল পদ্ধতি দেখেছেন। Trie দিয়ে উত্তরটি চোখে দেখা যায়: সব শব্দ ঢোকান, তারপর root থেকে নামুন যতক্ষণ বর্তমান node-এর ঠিক একটি সন্তান আছে আর বর্তমান node নিজে কোনো শব্দের শেষ নয়। যেমন {flower, flow, flight}: f → l পর্যন্ত একটিই পথ, তারপর l-এর দুটি সন্তান (o আর i) — LCP “fl”. আর {interview, internet, in}-এ “in”-এর n-এ শব্দ শেষ, তাই সেখানেই থামতে হবে: LCP “in”. খরচ ঢোকাতে O(N), নামতে O(LCP-র দৈর্ঘ্য). শুধু একবারের প্রশ্নে সাজিয়ে প্রথম আর শেষ শব্দ মেলানো সহজতর, কিন্তু শব্দ যোগ হতে থাকলে trie প্রতিবার নতুন উত্তর সস্তায় দেয়।

৭ · বাংলা আর Unicode-এর trie — code point না byte?

ইংরেজির ২৬টি অক্ষরের হিসাব সরল। কিন্তু বাংলায় সার্চ-পরামর্শ, বাংলা বানান-যাচাই বা ফোনবুকে বাংলা নাম খুঁজতে কী হবে? পর্ব ১১-এ দেখেছেন, কম্পিউটারে প্রতিটি অক্ষর একটি সংখ্যা: Unicode-এ প্রতিটি অক্ষরের একটি code point (বাংলার জন্য U+0980 থেকে U+09FF — ১২৮টি ঘরের একটি block), আর ফাইলে বা মেমরিতে তা সাধারণত UTF-8 encoding-এ — প্রতিটি বাংলা অক্ষর ৩টি byte. “কলম” আর “কমল” নিয়ে দুই উপায় দেখুন:

শব্দcode pointUTF-8 byte
কলমU+0995 U+09B2 U+09AEE0 A6 95 · E0 A6 B2 · E0 A6 AE
কমলU+0995 U+09AE U+09B2E0 A6 95 · E0 A6 AE · E0 A6 B2

Code point-এর trie

প্রতিটি edge একটি পুরো অক্ষর। গভীরতা = অক্ষরের সংখ্যা (কলম-এ ৩). সন্তান রাখতে map লাগে (σ বিশাল), অথবা শুধু বাংলা হলে next[128] আর index = code point − 0x0980. আগে UTF-8 থেকে code point-এ decode করতে হয়।

কম গভীর, map বা offset

UTF-8 byte-এর trie

প্রতিটি edge একটি byte, তাই σ = ২৫৬ আর কোনো decode লাগে না — যেকোনো ভাষা একই কোডে। কিন্তু গভীরতা ৩ গুণ (কলম-এ ৯), আর প্রায় প্রতিটি বাংলা অক্ষরের প্রথম দুই byte E0 A6 বা E0 A7 — একা-সন্তানের লম্বা শিকল, node-এর অপচয়।

সরল ও সর্বজনীন, কিন্তু গভীর

Byte-trie-এর সেই একা-সন্তানের শিকল ঠিক পরের অংশের radix tree জুড়ে ফেলে, তাই বাস্তবে অনেক লাইব্রেরি byte-এর ওপর compressed trie বানায়। আর byte-এর ক্রম আর code point-এর ক্রম UTF-8-এ একই — UTF-8-এর একটি পরিকল্পিত গুণ — তাই দুই উপায়েই DFS একই lexicographic ক্রম দেয়।

কিন্তু সেই “lexicographic” ক্রম বাংলা বর্ণমালার ক্রম কি না, সেটি আলাদা প্রশ্ন। বেশিরভাগ ক্ষেত্রে কাছাকাছি — ক (U+0995) খ-এর (U+0996) আগে — কিন্তু ং (U+0982) আর ঃ (U+0983) আছে অ-এর (U+0985) আগে, আর য় (U+09DF) বসেছে হ-এর (U+09B9) অনেক পরে। অভিধানের মতো ক্রম চাইলে আলাদা collation নিয়ম লাগে (পর্ব ২৫).

একই চেহারা, আলাদা key. “য়” লেখা যায় একটি code point দিয়ে (U+09DF), আবার য (U+09AF) + নুক্তা (U+09BC) দিয়েও — পর্দায় হুবহু এক, কিন্তু trie-তে দুটি আলাদা পথ! ব্যবহারকারী এক কিবোর্ডে লিখে আরেক কিবোর্ডে লেখা শব্দ খুঁজে পাবেন না। সমাধান: ঢোকানো আর খোঁজা — দুই সময়েই একই Unicode normalization (যেমন NFC) করে নেওয়া; NFC-তে U+09DF ভেঙে য + নুক্তা হয়। আরও একটি সূক্ষ্মতা: “ক্ষমা” আসলে ক + ্ + ষ + ম + া, তাই code point-এর হিসাবে “ক” তার prefix. ব্যবহারকারী যাকে একটি অক্ষর দেখেন (grapheme cluster) আর trie-র একটি edge সবসময় এক নয় — prefix দেখানোর নিয়ম ঠিক করার আগে এটি মাথায় রাখুন।

৮ · Radix tree, Patricia আর trie-র পরিবার

একা-সন্তানের শিকল জুড়ে ফেলা — compressed trie

“internationalization” একা একটি শব্দ trie-তে রাখলে ২০টি node, প্রতিটির একটি মাত্র সন্তান — আর array[26] নিলে প্রতিটিতে ২৫টি খালি ঘর। সমাধান: যে node-এর ঠিক একটি সন্তান আর যে নিজে শব্দের শেষ নয়, তাকে তার সন্তানের সাথে জুড়ে দিন। তখন একটি edge-এ একটি অক্ষর নয়, একটি পুরো string. এর নাম radix tree বা compressed trie.

সাধারণ trie — romane, romanus, romulus, rubens (root ছাড়া ১৭টি node)

r ─┬─ o ─ m ─┬─ a ─ n ─┬─ e*
   │         │         └─ u ─ s*
   │         └─ u ─ l ─ u ─ s*
   └─ u ─ b ─ e ─ n ─ s*

radix tree — একই চারটি শব্দ (root ছাড়া ৭টি node)

r ─┬─ om ─┬─ an ─┬─ e*
   │      │      └─ us*
   │      └─ ulus*
   └─ ubens*
একা-সন্তানের শিকলগুলো এক-একটি edge হয়ে যায়। Edge-এর label আলাদা করে কপি না করে মূল শব্দের (শুরুর index, দৈর্ঘ্য) হিসেবে রাখলে জায়গা আরও কমে।

কেন জায়গা নিশ্চিতভাবে কমে? Radix tree-তে প্রতিটি internal node হয় একটি শব্দের শেষ, নয়তো তার অন্তত দুটি সন্তান। n টি শব্দ মানে বড়জোর n টি leaf, আর অন্তত দুই-সন্তানের internal node সর্বোচ্চ n − 1 টি — তাই মোট node O(n), শব্দগুলো যত লম্বাই হোক। খোঁজা এখনও O(L): edge-এর label-এর অক্ষরগুলো একে একে মেলাতে হয়।

১৯৬৮ সালে Donald R. Morrison Journal of the ACM-এ প্রকাশ করেন PATRICIA — “Practical Algorithm To Retrieve Information Coded In Alphanumeric”. এটি key-কে bit-এর সারি হিসেবে দেখে (σ = ২), আর প্রতিটি node-এ শুধু লেখা থাকে “এবার কোন bit-টি দেখতে হবে” — মাঝের যেসব bit-এ কোনো শাখা নেই, সেগুলো সরাসরি লাফিয়ে পার হয়। শেষে পৌঁছে পুরো key একবার মিলিয়ে নিতে হয়, কারণ লাফানো bit-গুলো দেখা হয়নি। আজ “Patricia trie” আর “radix tree” নাম দুটি প্রায়ই একই অর্থে ব্যবহার হয়।

যেখানে radix tree রোজ চলে

IP routing — longest prefix match. আপনার bKash অ্যাপের প্রতিটি অনুরোধ ইন্টারনেটে অসংখ্য router পার হয়। প্রতিটি router-এর routing table-এ IP address-এর prefix আর “কোন দিকে পাঠাতে হবে” লেখা থাকে, যেমন:

prefixমানেপাঠাও
0.0.0.0/0সব address (default)C
10.0.0.0/8প্রথম ৮ bit = 10A
10.1.0.0/16প্রথম ১৬ bit = 10.1B

গন্তব্য 10.1.2.3 হলে তিনটি নিয়মই মেলে, কিন্তু নিতে হবে সবচেয়ে লম্বা (সবচেয়ে নির্দিষ্ট) মেলা prefix-টি — B. এর নাম longest prefix match. 10.2.3.4 যাবে A-তে, 8.8.8.8 যাবে C-তে। Hash table এখানে সরাসরি কাজ করে না, কারণ জানা নেই কত bit-এর prefix খুঁজতে হবে। Trie-তে কাজটি স্বাভাবিক: address-এর ৩২টি bit ধরে নামুন, আর পথে শেষ যে node-এ একটি নিয়ম পেয়েছেন সেটি মনে রাখুন — O(W), W = address-এর bit (IPv4-এ ৩২, IPv6-এ ১২৮). BSD ঘরানার অপারেটিং সিস্টেমে বহু বছর ধরে radix tree দিয়ে routing table রাখা হয়েছে, আর Linux kernel IPv4-এর জন্য একটি level-compressed trie (LC-trie) ব্যবহার করে। বড় হার্ডওয়্যার router কাজটি আরও দ্রুত করতে বিশেষ মেমরি-চিপ (TCAM) ব্যবহার করে।

Web server-এর URL router. একটি API-তে হাজারো পথ থাকতে পারে: /users, /users/:id, /users/:id/orders, /products… প্রতিটি অনুরোধের URL কোন handler-এ যাবে? Go-র জনপ্রিয় httprouter লাইব্রেরি আর তার ওপর বানানো Gin framework এই কাজে radix tree ব্যবহার করে — খরচ URL-এর দৈর্ঘ্যের সমানুপাতিক, route-এর সংখ্যার নয়। Redis-এর stream-এর ভেতরেও একটি radix tree (rax) আছে, আর Ethereum তার state রাখে “Merkle Patricia Trie”-তে।

Ternary search tree আর suffix trie — দুই আত্মীয়

Ternary search tree (TST; Jon Bentley আর Robert Sedgewick, ১৯৯৭) array-র মেমরি সমস্যার আরেকটি সমাধান: প্রতিটি node-এ একটি অক্ষর আর তিনটি সন্তান — ছোট (<), সমান (=), বড় (>). অক্ষর মিললে “সমান” দিয়ে শব্দের পরের অক্ষরে যান; না মিললে BST-র মতো বাঁয়ে বা ডানে, একই অক্ষর-অবস্থানে। প্রতিটি node-এ মাত্র তিনটি pointer, আর prefix-খোঁজা ও ক্রমে ঘোরা সবই চলে। খরচ মোটামুটি L আর একটি BST-র গভীরতার যোগফল — এলোমেলো ক্রমে ঢোকালে গড়ে O(L + log n).

একটি লেখা T-এর (দৈর্ঘ্য m) সব suffix একটি trie-তে ঢোকালে পাওয়া যায় suffix trie. T-এর যেকোনো substring কোনো না কোনো suffix-এর prefix — তাই “P কি T-এর ভেতরে আছে?” প্রশ্নের উত্তর O(|P|)-এ, T যত বড়ই হোক। সমস্যা আকারে: m টি suffix-এর মোট দৈর্ঘ্য m(m+1)/2, তাই worst case-এ Θ(m²) node. Radix-এর মতো জুড়ে ফেললে আসে suffix tree — O(m) node (Peter Weiner, ১৯৭৩; পরে Esko Ukkonen ১৯৯৫ সালে বাঁ থেকে ডানে এক পাসে বানানোর পদ্ধতি দেন)। আর সব suffix-কে শুধু তাদের শুরুর index দিয়ে সাজিয়ে রাখলে পাওয়া যায় আরও কম জায়গার suffix array (Udi Manber আর Gene Myers, ১৯৯০) — যা আসলে suffix tree-র leaf-গুলোকে lexicographic DFS-এর ক্রমে পড়া। অনেকগুলো pattern একসাথে খোঁজার Aho-Corasick অ্যালগরিদমও একটি trie-র ওপর বানানো। এসব বিস্তারিত পর্ব ৫৯-এ।

৯ · Binary trie আর maximum XOR pair — কনটেস্টের প্রিয় কৌশল

সমস্যা: n টি অঋণাত্মক পূর্ণসংখ্যার মধ্যে এমন দুটি বাছুন, যাদের XOR সবচেয়ে বড়। সব জোড়া দেখলে O(n²) — n = ২ লাখ হলে প্রায় ২০০০ কোটি জোড়া, কনটেস্টের সময়সীমায় অসম্ভব। কৌশল: সংখ্যাগুলোকে B bit-এর string হিসেবে দেখুন (সবচেয়ে বড় bit আগে), আর σ = ২-এর একটি trie-তে রাখুন — binary trie.

মূল পর্যবেক্ষণ greedy, কিন্তু প্রমাণযোগ্য: XOR-এর মান ঠিক করে সবচেয়ে বাঁয়ের যে bit 1. 2b > 2b−1 + … + 2 + 1, তাই bit b-তে 1 পাওয়া নিচের সব bit মিলিয়ে যা পাওয়া যায় তার চেয়ে বেশি দামি। তাই একটি সংখ্যা x-এর সেরা সঙ্গী y খুঁজতে root থেকে নামুন, সবচেয়ে বড় bit আগে: প্রতিটি ধাপে x-এর bit-এর উল্টো bit-এর সন্তান থাকলে সেদিকে যান (ফলের এই bit = 1); না থাকলে বাধ্য হয়ে একই bit-এর দিকে (ফলের bit = 0). পুরো উত্তর: সংখ্যাগুলো একে একে নিন — প্রতিটির জন্য আগে ঢোকানো সংখ্যাদের মধ্যে সেরা সঙ্গী খুঁজুন, তারপর সেটি নিজে ঢোকান। এভাবেই প্রতিটি জোড়া (i < j) একবার বিবেচিত হয় — maximum XOR pair.

প্রমাণ (exchange-এর ধাঁচে). ধরুন greedy পথ প্রথম যে bit b-তে সত্যিকারের সেরা সঙ্গী y* থেকে আলাদা হলো। তার আগের সব bit-এ দুটির পথ এক, তাই ফলের ওপরের bit-গুলোও এক। bit b-তে greedy উল্টো bit নিয়েছে (ফলে 1) — নিশ্চয়ই y*-এর পথে ছিল একই bit (ফলে 0), কারণ greedy শুধু উল্টোটি না থাকলেই একই bit নেয়, আর সেক্ষেত্রে y*-ও সেই পথেই থাকত। তাহলে greedy-র ফল bit b-তে 1, y*-এর 0, ওপরে সমান — তাই greedy-র ফল কঠোরভাবে বড়, যা y* সেরা হওয়ার সাথে বিরোধ। অতএব greedy-ই সেরা। নিচে a = [3, 10, 5, 25, 2, 8]-এ (B = 5 bit) x = 25-এর পালা দেখুন, যখন trie-তে আগের তিনটি সংখ্যা {3, 10, 5} আছে।

5-bit রূপ (bit 4 … bit 0)সবুজ = এখন যে bit দেখা হচ্ছে

            
প্রতিটি ধাপের পর
x-এর bit → চাই (উল্টো)
চাওয়া সন্তান আছে?
ফল r (binary = দশমিক)
বাকি প্রার্থী
  1. শুরু. trie-তে 3, 10, 5 (প্রতিটি ৫ bit-এর পথ). x = 25 = 11001. লক্ষ্য: এমন y খোঁজা, যাতে x XOR y সবচেয়ে বড়। r = 0.
  2. bit 4. x-এর bit 1, তাই চাই 0. তিনটি সংখ্যারই bit 4 = 0 — সন্তান আছে, সেদিকে গেলাম। ফলের bit 4 = 1: r = 16. প্রার্থী তিনজনই রইল।
  3. bit 3. x-এর bit 1, চাই 0. 3 আর 5-এর bit 3 = 0 (10-এর 1) — 0-এর সন্তান আছে। r = 16 + 8 = 24, 10 বাদ পড়ল।
  4. bit 2. x-এর bit 0, চাই 1. 5-এর bit 2 = 1 — আছে। r = 24 + 4 = 28, 3 বাদ। এখন একজনই প্রার্থী, তবু পথ শেষ পর্যন্ত হাঁটতে হবে।
  5. bit 1. x-এর bit 0, চাই 1 — কিন্তু 5-এর bit 1 = 0, সেই সন্তান নেই। বাধ্য হয়ে 0-এর দিকে; ফলের bit 1 = 0, r = 28 থাকল।
  6. bit 0. x-এর bit 1, চাই 0 — নেই (5-এর bit 0 = 1). 1-এর দিকে; r = 28. সেরা সঙ্গী y = 5, আর 25 XOR 5 = 11100 = 28. মোট ধাপ ঠিক B = 5, n-এর ওপর নির্ভর করে না।

বিশ্লেষণ. প্রতিটি insert আর প্রতিটি query ঠিক B ধাপ, তাই মোট সময় Θ(n·B); trie-তে সর্বোচ্চ n·B + 1 টি node, প্রতিটিতে দুটি int — জায়গা O(n·B). মান ১০৯ পর্যন্ত হলে B = ৩০ (কারণ 230 > ১০৯), তাই n = ২ লাখে insert আর query মিলিয়ে প্রায় ১ কোটি ২০ লাখ ধাপ — চোখের পলকে। নিচের কোড এই উদাহরণ চালায়, তারপর ৩০০টি এলোমেলো অ্যারেতে উত্তরটি সরল O(n²) brute force-এর সাথে মিলিয়ে দেখে — এটিই stress testing.

#include <algorithm>
#include <array>
#include <iostream>
#include <random>
#include <vector>
using namespace std;
const int B = 20;                                  // values are in [0, 2^20)
int maxXorTrie(const vector<int>& a) {
    vector<array<int, 2>> ch(1);                   // node 0 = root; ch[v][bit] = child, 0 = none
    auto add = [&](int x) {
        int v = 0;
        for (int b = B - 1; b >= 0; --b) {
            int bit = (x >> b) & 1;
            if (!ch[v][bit]) { ch[v][bit] = (int)ch.size(); ch.push_back({}); }
            v = ch[v][bit];
        }
    };
    auto best = [&](int x) {                       // max of x ^ y over every y already inserted
        int v = 0, r = 0;
        for (int b = B - 1; b >= 0; --b) {
            int want = ((x >> b) & 1) ^ 1;         // the opposite bit puts a 1 here
            if (ch[v][want]) { r |= 1 << b; v = ch[v][want]; } else v = ch[v][want ^ 1];
        }
        return r;
    };
    int ans = 0; add(a[0]);
    for (size_t i = 1; i < a.size(); ++i) { ans = max(ans, best(a[i])); add(a[i]); }
    return ans;
}
int maxXorBrute(const vector<int>& a) {
    int r = 0;
    for (size_t i = 0; i < a.size(); ++i) for (size_t j = i + 1; j < a.size(); ++j) r = max(r, a[i] ^ a[j]);
    return r;
}
int main() {
    vector<int> a{3, 10, 5, 25, 2, 8};
    cout << "trie: " << maxXorTrie(a) << ", brute force: " << maxXorBrute(a) << '\n';
    mt19937 rng(2026); int ok = 0;
    for (int t = 0; t < 300; ++t) {
        vector<int> v(2 + rng() % 60);
        for (int& x : v) x = int(rng() % (1u << B));
        ok += maxXorTrie(v) == maxXorBrute(v);
    }
    cout << "random tests passed: " << ok << " / 300\n";
}

আউটপুট:

trie: 28, brute force: 28
random tests passed: 300 / 300

প্রথম লাইনে trie আর brute force দুটোই 28 — ডায়াগ্রামের 25 XOR 5. দ্বিতীয় লাইন বলে ৩০০টি এলোমেলো অ্যারের (২ থেকে ৬১টি সংখ্যা, প্রতিটি 220-এর কম) সবগুলোতে দুই উত্তর মিলেছে। best()-এর else শাখায় যাচাই ছাড়াই ch[v][want ^ 1]-এ নামা নিরাপদ, কারণ প্রতিটি ঢোকানো সংখ্যা পুরো B bit-এর পথ বানায় — পথের প্রতিটি node-এর অন্তত একটি সন্তান থাকে। তাই best() ডাকার আগে trie-তে অন্তত একটি সংখ্যা থাকা জরুরি; এজন্যই add(a[0]) আগে। mt19937-এর একটি নির্দিষ্ট seed (2026) দেওয়ায় প্রতিবার একই “এলোমেলো” ইনপুট আসে — ভুল পেলে আবার একই ইনপুটে চালানো যায়।

import random
B = 20                                   # values are in [0, 2**20)

def max_xor_trie(a):
    ch = [[0, 0]]                        # node 0 = root; ch[v][bit] = child id, 0 = none
    def add(x):
        v = 0
        for b in range(B - 1, -1, -1):
            bit = (x >> b) & 1
            if not ch[v][bit]:
                ch[v][bit] = len(ch); ch.append([0, 0])
            v = ch[v][bit]
    def best(x):                         # max of x ^ y over every y already in the trie
        v = r = 0
        for b in range(B - 1, -1, -1):
            want = ((x >> b) & 1) ^ 1    # the opposite bit puts a 1 here
            if ch[v][want]: r |= 1 << b; v = ch[v][want]
            else: v = ch[v][want ^ 1]
        return r
    ans = 0; add(a[0])
    for x in a[1:]:
        ans = max(ans, best(x)); add(x)
    return ans

def max_xor_brute(a):
    return max(a[i] ^ a[j] for i in range(len(a)) for j in range(i + 1, len(a)))

a = [3, 10, 5, 25, 2, 8]
print(f"trie: {max_xor_trie(a)}, brute force: {max_xor_brute(a)}")
random.seed(2026); ok = 0
for _ in range(300):
    v = [random.randrange(1 << B) for _ in range(random.randint(2, 61))]
    ok += max_xor_trie(v) == max_xor_brute(v)
print(f"random tests passed: {ok} / 300")

এই সংস্করণটি এখানে চালানোর বোতাম ছাড়া দেওয়া হয়েছে; নিজের কম্পিউটারে চালালে আউটপুট C++-এর মতোই আসে:

trie: 28, brute force: 28
random tests passed: 300 / 300

Python-এর এলোমেলো সংখ্যা C++-এর mt19937-এর থেকে আলাদা ক্রমে আসে, তাই ৩০০টি পরীক্ষা ভিন্ন অ্যারেতে চলে — কিন্তু ফল একই, সবগুলো মেলে। if ch[v][want]: r |= 1 << b; v = ch[v][want] লাইনে সেমিকোলন দিয়ে লেখা দুটি কাজই if-এর ভেতরে। Python-এ প্রতিটি node একটি ছোট list — বড় n-এ মেমরি অনেক লাগে; কনটেস্টে দুটি সমান্তরাল flat list (বাঁ সন্তান, ডান সন্তান) রাখলে অনেক দ্রুত হয়।

একই binary trie আরও অনেক সমস্যার চাবি। Prefix XOR (পর্ব ১৭) p[i] রাখলে যেকোনো subarray-র XOR = p[r] XOR p[l − 1] — তাই “সবচেয়ে বড় XOR-এর subarray” আসলে prefix XOR-গুলোর মধ্যে maximum XOR pair. প্রতিটি node-এ subtree-র সংখ্যা গুনে রাখলে “x-এর সাথে XOR k-এর কম এমন কয়টি সংখ্যা” বা “k-তম ছোট XOR”-এর মতো প্রশ্নও O(B)-এ। আর সংখ্যা মুছতে হলে অংশ ৫-এর pass-counter কৌশল হুবহু কাজ করে: counter শূন্য হলে সেই শাখায় নামবেন না।

আরও গভীরে: Trie-র O(L) সীমাটি আসলে নিখুঁত: শব্দটি আছে কি না নিশ্চিত হতে প্রতিটি অক্ষর অন্তত একবার পড়তেই হয়। কিন্তু সংখ্যার key-তে (W bit-এর word) আরও এগোনো যায় — “x-এর ঠিক আগের বা পরের সংখ্যা কোনটি?” প্রশ্নে binary trie W ধাপ নেয়, অথচ x-fast trie (Dan Willard, ১৯৮৩) trie-র প্রতিটি স্তরের node-গুলো hash table-এ রেখে স্তরগুলোর ওপর binary search চালায়, ফলে O(log W) — এটি পর্ব ২৩-এ উল্লেখ করা van Emde Boas tree-র নিকটাত্মীয়। আর বাস্তবে, বিশাল অভিধানের জন্য trie-কে একটি ছোট automaton-এ (DAWG বা minimal acyclic automaton) পরিণত করা যায়, যেখানে একই suffix-ও ভাগ হয় — Lucene-এর মতো search engine লাইব্রেরি term dictionary রাখতে এ ধরনের finite state transducer ব্যবহার করে।

পরীক্ষা ও ইন্টারভিউতে:

  • n টি শব্দের trie-তে দৈর্ঘ্য L-এর একটি শব্দ খুঁজতে সময় কত? — O(L) (array-সন্তানে), n-এর ওপর নির্ভর করে না।
  • search আর startsWith-এর পার্থক্য কী? — দুটোই prefix-এর পথ খোঁজে; search শেষ node-এ end-চিহ্নও দেখে।
  • একটি prefix দিয়ে কয়টি শব্দ শুরু, O(L)-এ কীভাবে? — প্রতিটি node-এ pass-counter রাখুন, insert-এর সময় পথ ধরে বাড়ান; উত্তর = prefix-এর node-এর counter.
  • trie-র worst-case জায়গা কত, আর কীভাবে কমাবেন? — O(N·σ) (N = মোট অক্ষর); map বা sorted vector-এ সন্তান, অথবা radix tree-তে শিকল জুড়ে O(n) node.
  • n টি সংখ্যার মধ্যে সর্বোচ্চ XOR জোড়া? — B-bit binary trie, বড় bit থেকে উল্টো bit-এর দিকে greedy; O(n·B).

১০ · AI-এর যুগে trie — কোড ছোট, ফাঁদ সূক্ষ্ম

“একটি Trie class লিখে দাও insert, search, startsWith সহ” — যেকোনো AI কয়েক সেকেন্ডে এটি লিখবে, আর প্রায় সবসময় ঠিকও লিখবে, কারণ এটি সবচেয়ে বেশি অনুশীলিত ইন্টারভিউ প্রশ্নগুলোর একটি। ভুল লুকোয় একটু বাইরের অংশে: delete-এ ভাগাভাগি করা node মুছে ফেলা, autocomplete-এ hash map-এর এলোমেলো ক্রম, বড় ইনপুটে মেমরি সীমা পার হওয়া, বা XOR-এর প্রশ্নে bit-এর ভুল সীমা। আর complexity জিজ্ঞেস করলে AI প্রায়ই trie-র search-কে “O(log n)” বা জায়গাকে শুধু “O(N)” বলে দেয়, σ-এর গুণটি বাদ দিয়ে।

Trie-এর কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
search-এ end-চিহ্ন না দেখা — শুধু পথ থাকলেই true“car” ঢুকিয়ে search(“ca”) চালান — উত্তর no হওয়া উচিত
delete-এ পুরো পথ মুছে ফেলা, বা শব্দ না থাকলেও counter কমানোcar, card, care ঢুকিয়ে card মুছুন, তারপর car আর care খুঁজুন; আর একটি না-থাকা শব্দ মুছতে চেষ্টা করুন
ch - 'a' দিয়ে index, অথচ ইনপুটে বড় হাতের অক্ষর বা অঙ্ক“Car” বা “a1” দিয়ে চালান; AddressSanitizer (-fsanitize=address) চালু রাখুন
প্রতিটি node-এ new আর Node* child[26] — ১০৬ অক্ষরে কয়েকশো MBনিজে হিসাব করুন: node-সংখ্যা × σ × ঘরের আকার; সীমার সাথে মেলান
unordered_map বা dict-এর সন্তান, অথচ দাবি “ফল সাজানো ক্রমে”শব্দগুলো উল্টো ক্রমে ঢুকিয়ে autocomplete চালান; sorted()-এর সাথে মেলান
max XOR-এ bit-এর লুপ ভুল সীমায় (যেমন 31 থেকে, বা 30-এর বদলে 29 পর্যন্ত), বা খালি trie-তে queryসবচেয়ে বড় সম্ভাব্য মান আর ২টি সংখ্যার অ্যারে দিয়ে brute force-এর সাথে মেলান

AI-কে trie লিখে দিতে না বলে, আপনার কোড আর trace পরীক্ষা করতে বলুন — আর প্রতিটি complexity-র দাবির কারণ চান:

আমি DSA শিখছি, এখন trie. নিচে আমার লেখা trie (insert, search, countPrefix, erase, autocomplete) আর একটি ছোট ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. car, card, care, cat ঢুকিয়ে card মুছলে প্রতিটি node-এর counter কী হবে দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার erase কি একটি না-থাকা শব্দে বা অন্য শব্দের prefix-এ (যেমন "ca") ভুল কিছু করে? করলে কোন লাইনে? ৩. আমার autocomplete কি সত্যিই অভিধানের ক্রমে ফল দেয়? কেন বা কেন নয়, তা সন্তান রাখার ডেটা স্ট্রাকচার ধরে ব্যাখ্যা করো। ৪. মোট অক্ষর N আর alphabet size σ ধরে আমার trie-র time আর space complexity কত — ধাপে ধাপে যুক্তি দাও, শুধু উত্তর নয়।

Trie-এর কোড যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১১৯৫৯ আর ১৯৬০ — de la Briandais আর Fredkin ঠিক কী সমস্যার সমাধান খুঁজছিলেন, আর “trie” নামটি কেন টিকে গেল?

১৯৫০-এর দশকের শেষে কম্পিউটারের বড় কাজগুলোর একটি ছিল ফাইলে রেকর্ড খোঁজা — কর্মচারীর নাম, যন্ত্রাংশের কোড, লাইব্রেরির ক্যাটালগ। এসব key-এর দৈর্ঘ্য এক নয়: কোনো নাম তিন অক্ষরের, কোনোটি পনেরোর। তখনকার প্রচলিত পদ্ধতি — সাজানো ফাইলে binary search বা নির্দিষ্ট দৈর্ঘ্যের key-তে hashing — পরিবর্তনশীল দৈর্ঘ্যের key-তে অস্বস্তিকর ছিল, আর প্রতিটি তুলনায় পুরো key মেলাতে হতো। René de la Briandais ১৯৫৯ সালের প্রবন্ধে প্রস্তাব করেন: key-কে অক্ষর ধরে ভাঙুন, আর প্রতিটি স্তরে শুধু একটি অক্ষর দেখে শাখা বাছাই করুন। মেমরি তখন অত্যন্ত দামি, তাই তিনি প্রতিটি node-এর সন্তানদের রাখেন একটি linked list-এ — শুধু যতগুলো অক্ষর সত্যিই আছে, ততগুলো। আজও “de la Briandais tree” বললে trie-র এই linked-list রূপটিকেই বোঝায়, যা অংশ ৪-এর তুলনার শেষ সারি।

Edward Fredkin তখন Bolt Beranek and Newman (BBN) প্রতিষ্ঠানে কাজ করতেন। ১৯৬০ সালে Communications of the ACM-এ তাঁর “Trie Memory” প্রবন্ধে তিনি ধারণাটিকে একটি সাধারণ “মেমরি” হিসেবে দেখান — তথ্য রাখা আর উদ্ধার করার একটি পদ্ধতি, যেখানে key-এর প্রতিটি অক্ষর একটি টেবিলের ঘর বেছে দেয় (অর্থাৎ অনেকটা আমাদের array[σ] রূপ)। নামটি এল retrieval থেকে। মজার কথা, নামটির উচ্চারণ নিয়ে বিভ্রান্তি সেই শুরু থেকেই: “tree” উচ্চারণ করলে সাধারণ tree-র সাথে গুলিয়ে যায়, তাই অনেক শিক্ষক “try” বলেন। Fredkin পরে MIT-তে অধ্যাপক হন, আর reversible computing-এর গবেষণায় পরিচিত হন — “Fredkin gate” তাঁর নামেই।

Donald Knuth তাঁর The Art of Computer Programming-এর তৃতীয় খণ্ডে (sorting and searching) “digital searching” অংশে trie আর Patricia বিস্তারিত বিশ্লেষণ করেন — এর ফলে পরের প্রজন্মের পাঠ্যবইয়ে trie একটি মানক ডেটা স্ট্রাকচার হয়ে যায়। নামটি টিকে যাওয়ার একটি কারণ সম্ভবত এটাই যে এটি কাজটির সারকথা বলে: এটি তুলনার tree নয়, উদ্ধারের tree — key-এর অক্ষরই ঠিকানা।

লক্ষ করুন, দুজনেই একই মৌলিক টানাপোড়েনের দুই প্রান্ত বেছেছিলেন — de la Briandais মেমরি বাঁচিয়েছিলেন list দিয়ে (খোঁজা ধীর), Fredkin গতি নিয়েছিলেন টেবিল দিয়ে (মেমরি বেশি)। ষাট বছর পরেও trie-র প্রতিটি বাস্তবায়ন এই একই সিদ্ধান্ত নেয়।

মূল উপলব্ধি: Trie-র জন্ম পরিবর্তনশীল দৈর্ঘ্যের key দ্রুত খোঁজার চাহিদা থেকে, আর তার প্রথম দুটি রূপই দেখায় যে এই ডেটা স্ট্রাকচারের কেন্দ্রীয় প্রশ্ন হলো সন্তান কীভাবে রাখা হবে — মেমরি আর গতির বিনিময়।

প্র ০২ইন্টারনেটের router প্রতি সেকেন্ডে কোটি কোটি packet পাঠায় — longest prefix match-এর জন্য trie কি যথেষ্ট দ্রুত, আর বাস্তবে কী ব্যবহার হয়?

ইন্টারনেটের বড় backbone router-এর routing table-এ আজ লক্ষাধিক IPv4 prefix থাকে, আর IPv6-এর সংখ্যাও দ্রুত বাড়ছে। প্রতিটি packet এলে router-কে ঠিক করতে হয় কোন পথে পাঠাবে — আর দ্রুত লাইনে এই সিদ্ধান্তের জন্য সময় থাকে মাত্র কয়েক ন্যানোসেকেন্ড। সমস্যাটি সাধারণ “এই key আছে কি?” নয়: একই address-এর সাথে অনেক prefix মিলতে পারে (/8, /16, /24…), আর নিতে হবে সবচেয়ে লম্বাটি। ১৯৯৩ সালে CIDR (Classless Inter-Domain Routing) চালু হওয়ার পর prefix-এর দৈর্ঘ্য যেকোনো কিছু হতে পারে, তাই এই প্রশ্ন আরও গুরুত্বপূর্ণ হয়ে ওঠে।

সরল binary trie-তে IPv4-এর জন্য worst case ৩২টি ধাপ, আর প্রতিটি ধাপ সম্ভবত একটি আলাদা মেমরি-পড়া। মূল মেমরি থেকে একটি পড়া কয়েক ডজন ন্যানোসেকেন্ড নিতে পারে, তাই ৩২টি পড়া দ্রুত লাইনের জন্য অনেক ধীর। সফটওয়্যার router-গুলো তাই trie-কে চাপ দিয়ে ছোট করে। Patricia/radix tree একা-সন্তানের শিকল বাদ দেয়। “Multibit trie” প্রতি ধাপে একটি bit-এর বদলে কয়েকটি bit (যেমন ৮টি) দেখে — প্রতিটি node-এ 28 = ২৫৬টি ঘর, কিন্তু ধাপ মাত্র ৪টি; এটি আমাদের array[σ] সিদ্ধান্তেরই আরেক রূপ, σ বাড়িয়ে গভীরতা কমানো। Stefan Nilsson আর Gunnar Karlsson-এর LC-trie (level-compressed trie) ঘন অংশে একাধিক স্তর একসাথে জুড়ে দেয়; Linux kernel-এর IPv4 routing-এর “fib_trie” এই ধারণার ওপর ভিত্তি করে বানানো। BSD পরিবারের অপারেটিং সিস্টেমে Keith Sklower-এর radix tree বহু বছর ধরে routing table রেখেছে।

সবচেয়ে দ্রুত হার্ডওয়্যার router আবার ভিন্ন পথে যায়: TCAM (Ternary Content-Addressable Memory) — এমন একটি মেমরি, যা সব রাখা prefix-এর সাথে address-কে একসাথে, একটি clock cycle-এ মেলায়, আর মেলাগুলোর মধ্যে অগ্রাধিকার দিয়ে সবচেয়ে লম্বাটি বেছে দেয়। এটি অত্যন্ত দ্রুত, কিন্তু দামি আর বিদ্যুৎ-খরুচে — তাই TCAM-এর আকার সীমিত, আর তাকে কম রাখতে আবার trie-ধাঁচের সফটওয়্যার সাহায্য লাগে।

একই ধারণা firewall-এর নিয়ম মেলানো, DNS-এর domain নাম খোঁজা (domain-কে উল্টো ক্রমে পড়লে com → example → www — একটি trie), আর ফোন কোম্পানির নম্বর-prefix ধরে কল route করায়ও দেখা যায়। বাংলাদেশে 017, 018, 019-এর মতো operator prefix দেখে কল কোন নেটওয়ার্কে যাবে, তা ঠিক করার প্রশ্নটিও আকারে একই।

মূল উপলব্ধি: Longest prefix match-এর সমস্যায় trie স্বাভাবিক উত্তর, কিন্তু বাস্তবের গতির সীমা মেমরি-পড়ার সংখ্যা ঠিক করে দেয় — তাই trie-কে চাপা, প্রতি ধাপে বেশি bit দেখা, বা হার্ডওয়্যারে সমান্তরাল মেলানো দরকার হয়।

প্র ০৩অভিধানের বানান-যাচাইয়ের জন্য trie নাকি hash table — কোনটি কখন জেতে?

শুধু “এই শব্দটি আছে কি?” প্রশ্ন হলে hash table (পর্ব ৩২) প্রায়ই সহজ আর দ্রুত: শব্দের hash একবার হিসাব করা O(L), তারপর গড়ে একটি-দুটি তুলনা। Trie-তেও O(L), কিন্তু প্রতিটি অক্ষরে একটি আলাদা node — অর্থাৎ L টি মেমরি-পড়া, যেগুলো মেমরিতে ছড়িয়ে থাকতে পারে। আধুনিক CPU-তে cache miss-ই আসল খরচ, তাই সাধারণ hash table শব্দ-খোঁজায় অনেক সময় trie-কে হারায়, আর মেমরিও কম নেয় (শুধু শব্দগুলো আর একটি টেবিল)।

কিন্তু প্রশ্নটি একটু বদলালেই ছবি উল্টে যায়। (১) Prefix-এর প্রশ্ন: autocomplete, “কয়টি শব্দ ‘pre’ দিয়ে শুরু”, longest prefix match — hash table এগুলো সরাসরি পারে না। (২) ক্রম: trie-র DFS শব্দগুলো সাজানো ক্রমে দেয়; hash table-এ ক্রম নেই। (৩) Worst case: hash table-এর গড় O(1) একটি অনুমানের ওপর দাঁড়িয়ে — শত্রুভাবাপন্ন ইনপুটে সব key একই ঘরে পড়তে পারে (পর্ব ১৫-এর anti-hash test); trie-তে কোনো অনুমান নেই, worst case-ও O(L). (৪) ভুল বানানের পরামর্শ: “recieve” লিখলে কাছাকাছি শব্দ খুঁজতে trie-র ওপর DFS চালিয়ে edit distance-এর DP টেবিল (পর্ব ৫৪) এক সারি এক সারি করে হিসাব করা যায় — একই prefix ভাগ করা শব্দগুলোর জন্য টেবিলের সারিগুলোও ভাগ হয়, আর দূরত্ব সীমা ছাড়ালে পুরো শাখা ছেঁটে ফেলা যায়।

বাস্তবের বানান-যাচাইকারীরা প্রায়ই মাঝামাঝি পথ নেয়। Hunspell-এর মতো জনপ্রিয় spell checker (LibreOffice ও Firefox-এ ব্যবহৃত) শব্দের মূল রূপ আর প্রত্যয়ের নিয়ম আলাদা রাখে, যাতে “play, plays, played, playing” চারটি আলাদা শব্দ না রেখে একটি মূল আর নিয়ম রাখা যায় — বাংলার মতো প্রত্যয়-সমৃদ্ধ ভাষায় এটি আরও জরুরি (করা, করেছি, করছিলাম…)। খুব বড় অভিধানে আবার trie-কে একটি minimal automaton-এ সংকুচিত করা হয়, যেখানে “-ing”, “-tion”-এর মতো একই suffix-ও ভাগ হয়ে যায়।

মূল উপলব্ধি: সঠিক ডেটা স্ট্রাকচার নির্ভর করে প্রশ্নের ধরনের ওপর। শুধু সদস্যতা — hash table; prefix, ক্রম, নিশ্চিত worst case বা কাছাকাছি-মেলানো — trie বা তার কোনো আত্মীয়।

প্র ০৪বাংলায় টাইপ করার সময় শব্দের পরামর্শ দেখানো একটি সফটওয়্যার বানাতে চান — trie-র নকশায় কোন কোন সিদ্ধান্ত নিতে হবে?

প্রথম সিদ্ধান্ত: key কী হবে? বাংলায় অনেকে সরাসরি বাংলা অক্ষরে লেখেন, আবার অনেকে ফোনেটিক পদ্ধতিতে — ইংরেজি অক্ষরে “ami” লিখলে “আমি” আসে। বাংলাদেশে ২০০০-এর দশকের শুরু থেকে Avro Keyboard-এর মতো বিনামূল্যের সফটওয়্যার এই ফোনেটিক টাইপিং জনপ্রিয় করেছে। ফোনেটিক ব্যবস্থায় ব্যবহারকারীর টাইপ করা ইংরেজি অক্ষরের prefix থেকে সম্ভাব্য বাংলা শব্দ বের করতে হয় — এখানে trie-র key হতে পারে ইংরেজি উচ্চারণ-রূপ, আর প্রতিটি end-node-এ রাখা থাকে বাংলা শব্দগুলোর তালিকা। আর সরাসরি বাংলা কিবোর্ডে key হবে বাংলা code point.

দ্বিতীয় সিদ্ধান্ত: code point না byte? অংশ ৭-এ দেখেছেন, UTF-8 byte-এ প্রতিটি বাংলা অক্ষর ৩টি ধাপ, আর প্রথম দুটি প্রায় সবসময় এক — তাই হয় code point নিন (map বা U+0980 থেকে offset), নয়তো compressed trie. ফোনের মতো কম মেমরির যন্ত্রে এটি গুরুত্বপূর্ণ।

তৃতীয়: normalization আর grapheme. একই শব্দ ভিন্ন code point-এর সারিতে আসতে পারে (য় = U+09DF, অথবা য + নুক্তা)। ঢোকানো আর খোঁজার আগে একই নিয়মে normalize না করলে পরামর্শ হারিয়ে যাবে। যুক্তাক্ষরের মাঝখানে ব্যবহারকারী যখন হসন্ত (্) টাইপ করে থেমেছেন, তখনও prefix-টি বৈধ — “ক্” দিয়ে শুরু শব্দগুলো (ক্ষমা, ক্রয়, ক্লান্ত) দেখানো উচিত। কিন্তু grapheme-এর হিসাবে কাটা কোনো prefix কোথায় থামবে, তা ইন্টারফেসের নকশার প্রশ্ন।

চতুর্থ: কোন শব্দ আগে? অভিধানের (code point) ক্রম বাংলা বর্ণমালার সাথে পুরো মেলে না — ং, ঃ অ-এর আগে আসে। কিন্তু পরামর্শে অভিধানের ক্রম আসলে কম দরকারি; দরকার ব্যবহারের frequency. তাই প্রতিটি node-এ subtree-র সেরা কয়েকটি শব্দ আগে থেকে হিসাব করে রাখা (অংশ ৬), আর ব্যবহারকারীর নিজের লেখা থেকে frequency হালনাগাদ। বাংলা প্রত্যয়-সমৃদ্ধ — “কর” থেকে করা, করে, করেছি, করছিলাম, করবেন… — তাই trie দ্রুত বড় হয়; প্রত্যয় আলাদা করে রাখা বা radix compression এখানে মেমরি বাঁচায়।

পঞ্চম: ভুল টাইপ সহ্য করা. ছোট ফোনের কিবোর্ডে পাশের অক্ষর চাপা খুব স্বাভাবিক। Trie-র ওপর সীমিত DFS — প্রতিটি ধাপে কাছাকাছি অক্ষরের শাখাতেও নামা, ভুলের একটি বাজেট রেখে — এক-দুই অক্ষরের ভুল থাকা শব্দও খুঁজে দেয়। পুরোনো মোবাইল ফোনের T9-এর মতো সংখ্যা-কিবোর্ডের পূর্বাভাসও একই আকারের সমস্যা: key হলো চাপা বোতামের সারি (২ = abc, ৩ = def…), আর একটি key-সারির পেছনে অনেক শব্দ।

মূল উপলব্ধি: ভাষা বদলালে ডেটা স্ট্রাকচারের মূল ধারণা একই থাকে, কিন্তু “অক্ষর” কী, “একই শব্দ” কী, আর “ক্রম” কী — এই প্রশ্নগুলোর উত্তর নকশার বড় অংশ ঠিক করে দেয়।

প্র ০৫Suffix trie Θ(m²) জায়গা নেয় — তাহলে মানুষের পুরো genome (প্রায় ৩০০ কোটি অক্ষর)-এ pattern খোঁজার স্ট্রাকচার কীভাবে কম্পিউটারে ধরে?

লেখা T-এর সব suffix একটি trie-তে রাখলে যেকোনো pattern P খোঁজা O(|P|) — দারুণ। কিন্তু m অক্ষরের লেখার suffix trie-তে worst case-এ m(m+1)/2-এর কাছাকাছি node. মানুষের genome-এর মতো ৩০০ কোটি অক্ষরে সেটি কল্পনাতীত। প্রথম বড় অগ্রগতি এল ১৯৭৩ সালে: Peter Weiner দেখান, একা-সন্তানের শিকলগুলো জুড়ে দিলে (ঠিক radix tree-র মতো) node-সংখ্যা O(m), আর edge-এর label মূল লেখার (শুরু, শেষ) index হিসেবে রাখলে পুরো suffix tree O(m) জায়গায় — আর তিনি এটি O(m) সময়ে বানানোর পদ্ধতিও দেন। Edward McCreight ১৯৭৬ সালে কম জায়গার একটি সংস্করণ দেন, আর Esko Ukkonen ১৯৯৫ সালে বাঁ থেকে ডানে অক্ষর পড়তে পড়তে (online) বানানোর সুন্দর পদ্ধতি। Donald Knuth নাকি Weiner-এর কাজকে “algorithm of the year 1973” বলেছিলেন — এ কথা প্রায়ই উদ্ধৃত হয়।

কিন্তু O(m) মানেই ছোট নয়। একটি suffix tree-র প্রতিটি node-এ কয়েকটি pointer আর index থাকে — বাস্তবে অক্ষরপ্রতি কয়েক ডজন byte, ফলে genome-এর জন্য শত শত GB. ১৯৯০ সালে Udi Manber আর Gene Myers প্রস্তাব করেন suffix array: শুধু suffix-গুলোর শুরুর index, lexicographic ক্রমে সাজানো — অক্ষরপ্রতি একটি পূর্ণসংখ্যা। Pattern খোঁজা তখন binary search, O(|P| log m); সাথে LCP অ্যারে রাখলে suffix tree-র প্রায় সব কাজ করা যায়। এটি আসলে suffix tree-র leaf-গুলোকে এই পর্বের lexicographic DFS-এর ক্রমে পড়ার সমান — trie থেকে অ্যারেতে নামা।

তারপরও genome-এর জন্য বড়। চূড়ান্ত চাপ এল অন্য দিক থেকে: Michael Burrows আর David Wheeler ১৯৯৪ সালে একটি ডেটা-সংকোচন রূপান্তর (Burrows–Wheeler Transform, BWT) প্রকাশ করেন, যা suffix array-এর সাথে ঘনিষ্ঠভাবে যুক্ত। ২০০০ সালে Paolo Ferragina আর Giovanni Manzini দেখান, BWT-ভিত্তিক “FM-index” লেখাটিকে সংকুচিত রেখেও তার ভেতরে pattern খুঁজতে পারে। আজকের DNA sequencing-এর জনপ্রিয় aligner, যেমন BWA আর Bowtie, এই ধরনের index ব্যবহার করে — মানুষের genome-এর index কয়েক GB-তে ধরে যায়, একটি সাধারণ সার্ভারে।

পুরো গল্পটি এই পর্বের একটি শিক্ষার বড় রূপ: trie ধারণা হিসেবে নিখুঁত, কিন্তু প্রতিটি অক্ষরের জন্য একটি node রাখা মেমরিতে ব্যয়বহুল। প্রথমে শিকল জোড়া (suffix tree), তারপর tree-কে অ্যারেতে চ্যাপ্টা করা (suffix array), তারপর অ্যারেকে সংকোচন (FM-index) — প্রতিটি ধাপ একই প্রশ্নের উত্তর দেয় আরও কম জায়গায়। বিস্তারিত পর্ব ৫৯-এ।

মূল উপলব্ধি: Suffix trie হলো ধারণার উৎস, suffix tree ও suffix array তার বাস্তব রূপ। ভালো অ্যালগরিদম ডিজাইন প্রায়ই এভাবেই এগোয় — আগে সরল, স্পষ্ট স্ট্রাকচার; তারপর একই তথ্য কম জায়গায় রাখার কৌশল।

অনুশীলন

খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন: খালি trie-তে পরপর ঢোকান tea, ten, to, inn, in. trie-টি আঁকুন, প্রতিটি insert-এ কয়টি নতুন node হলো বলুন, কোন কোন node-এ end-চিহ্ন বসল দেখান, আর মোট node (root-সহ) গুনুন। তারপর countPrefix(“te”) আর countPrefix(“i”) কত?

    tea → t, e, a: ৩টি নতুন node, a-তে চিহ্ন। ten → t, e আছে, শুধু n: ১টি। to → t আছে, o: ১টি। inn → i, n, n: ৩টি। in → i, n আছে — নতুন node ০টি, শুধু প্রথম n-এ চিহ্ন বসল। মোট node = ১ (root) + ৩ + ১ + ১ + ৩ = ৯.

    (root)
     ├─ i
     │   └─ n*
     │       └─ n*
     └─ t
         ├─ e
         │   ├─ a*
         │   └─ n*
         └─ o*

    countPrefix(“te”) = 2 (tea, ten), countPrefix(“i”) = 2 (inn, in). লক্ষ করুন “in”-এর জন্য কোনো node বানাতে হয়নি — শব্দটি অন্য শব্দের prefix হলে শুধু চিহ্ন বসে।

  2. Max XOR হাতে চালান: সংখ্যাগুলো 8, 1, 2, 12, 7 (৪ bit). trie-তে প্রথম চারটি আছে ধরে x = 7-এর সেরা সঙ্গী বের করুন — প্রতিটি bit-এ কোন দিকে গেলেন আর ফল কত। তারপর পুরো অ্যারের সর্বোচ্চ XOR কত?

    ৪-bit রূপ: 8 = 1000, 1 = 0001, 2 = 0010, 12 = 1100, 7 = 0111. bit 3: x-এর 0, চাই 1 — আছে (8, 12): r = 8. bit 2: x-এর 1, চাই 0 — 8-এর bit 2 = 0 আছে (12 বাদ): r = 12. bit 1: x-এর 1, চাই 0 — 8-এর bit 1 = 0: r = 14. bit 0: x-এর 1, চাই 0 — 8-এর bit 0 = 0: r = 15. সেরা সঙ্গী 8, আর 7 XOR 8 = 1111 = 15.

    ৪ bit-এ সম্ভাব্য সবচেয়ে বড় মান 15, তাই এটিই পুরো অ্যারের উত্তর — অন্য কোনো জোড়া এর চেয়ে বড় হতে পারে না। চারটি ধাপের প্রতিটিতে “চাওয়া” bit পাওয়া গেছে, তাই ফলের সব bit 1.

  3. ভেবে বলুন: (ক) n টি শব্দ, প্রতিটির দৈর্ঘ্য ঠিক L, কোনো দুটির প্রথম অক্ষরও এক নয় — trie-তে কয়টি node? (খ) সব শব্দ একই, “aaaa…a” (দৈর্ঘ্য L), n বার ঢোকানো — কয়টি node? (গ) array[26]-এর trie-তে (ক)-এর ক্ষেত্রে মোট কতগুলো ঘর খালি?

    (ক) কোনো ভাগাভাগি নেই: প্রতিটি শব্দ নিজের L টি node বানায়, তাই n·L + 1 (root-সহ) — N + 1-এর সীমা ঠিক এখানেই পূর্ণ হয়। (খ) প্রথমবারে L টি node, তারপর আর একটিও না — মোট L + 1; শুধু শেষ node-এর end-counter হবে n.

    (গ) মোট ঘর 26·(n·L + 1). ভরা ঘর = edge-এর সংখ্যা = node − 1 = n·L. তাই খালি ঘর 26·(n·L + 1) − n·L = 25·n·L + 26 — প্রায় ৯৬% অপচয়। এই অবস্থায় sorted vector বা radix tree (যেখানে প্রতিটি শব্দ একটি মাত্র edge হয়ে যেত) অনেক ভালো।

  4. কোড লিখুন: একটি string s-এর (দৈর্ঘ্য m) মোট কতগুলো আলাদা (distinct) খালি-নয় substring আছে, তা suffix trie দিয়ে বের করুন। ইঙ্গিত: প্রতিটি substring কোনো suffix-এর prefix. Time ও space complexity বলুন, আর “abab” হাতে মিলিয়ে দেখান।

    প্রতিটি আলাদা substring suffix trie-র ঠিক একটি node (root ছাড়া) — কারণ প্রতিটি node একটি অনন্য prefix, আর suffix-এর prefix মানেই substring. তাই উত্তর = root ছাড়া node-এর সংখ্যা। সব suffix ঢোকানোর সময় নতুন node গুনলেই হলো:

    long long distinctSubstrings(const string& s) {     // s has only 'a'..'z'
        vector<array<int, 26>> nxt(1);                   // node 0 = root
        long long made = 0;
        for (size_t i = 0; i < s.size(); ++i) {          // insert suffix s[i..]
            int v = 0;
            for (size_t j = i; j < s.size(); ++j) {
                int c = s[j] - 'a';
                if (!nxt[v][c]) { nxt[v][c] = (int)nxt.size(); nxt.push_back({}); ++made; }
                v = nxt[v][c];
            }
        }
        return made;                                     // one new node = one new distinct substring
    }

    “abab”: suffix abab বানায় a, b, a, b — ৪টি node; bab বানায় b, a, b — ৩টি; ab আর b-এর পথ আগেই আছে — ০টি। মোট ৭: a, b, ab, ba, aba, bab, abab — ঠিক সাতটি আলাদা substring. সময় O(m²) (সব suffix-এর মোট দৈর্ঘ্য m(m+1)/2), জায়গা worst case O(m²·σ). বড় m-এ suffix array আর LCP দিয়ে একই উত্তর O(m log m)-এ পাওয়া যায় (পর্ব ৫৯).

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি trie থেকে prefix p দিয়ে শুরু সব শব্দ অভিধানের ক্রমে বের করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. root থেকে শুরু করে p-এর প্রতিটি অক্ষরের edge ধরে নামা
  2. কোনো অক্ষরের সন্তান না পেলে খালি তালিকা ফেরত দেওয়া
  3. p-এর শেষ node থেকে বর্তমান string = p নিয়ে DFS শুরু করা
  4. প্রতিটি node-এ পৌঁছে end-চিহ্ন থাকলে বর্তমান string তালিকায় যোগ করা
  5. তারপর সেই node-এর সন্তানদের ছোট অক্ষর থেকে বড় অক্ষরের ক্রমে একই কাজ করা

a–z অক্ষরের trie-তে সন্তান একটি ২৬ ঘরের array-তে রাখা, আর trie-তে n টি শব্দ আছে। দৈর্ঘ্য L-এর একটি শব্দ আছে কি না খুঁজতে worst case-এ সময় কত?

  • O(log n)
  • O(L log n)
  • O(L)
  • O(n·L)

খালি একটি trie-তে to, tea, ted, ten, i, in, inn — এই ৭টি শব্দ ঢোকানো হলো। root-সহ মোট কয়টি node?

  • ১৭
  • ১০
  • ৯
  • ৭

একটি trie-তে প্রতিটি node-এ pass[v] রাখা হয় — কতগুলো ঢোকানো শব্দের পথ v দিয়ে গেছে। এই তথ্য দিয়ে কোন প্রশ্নটির উত্তর সরাসরি O(L)-এ পাওয়া যায়?

  • trie-র সবচেয়ে লম্বা শব্দ কোনটি
  • prefix p দিয়ে শুরু সব শব্দের তালিকা
  • একটি শব্দ ঠিক কতবার ঢোকানো হয়েছে
  • কতগুলো শব্দ prefix p দিয়ে শুরু

একটি trie-তে প্রতিটি node-এর সন্তানদের অক্ষরের ক্রমে (a থেকে z) ঘোরা হয়। কোন traversal-এ end-চিহ্নিত node-গুলোর শব্দ lexicographic (অভিধানের) ক্রমে আসে?

  • Preorder DFS — আগে node-এর নিজের শব্দ, তারপর সন্তানরা
  • Postorder DFS — আগে সন্তানরা, তারপর node-এর নিজের শব্দ
  • স্তর ধরে BFS
  • Inorder — বাঁয়ের সন্তান, node, তারপর বাকি সন্তান

n টি অঋণাত্মক পূর্ণসংখ্যা, প্রতিটি B bit-এর মধ্যে। Binary trie দিয়ে সবচেয়ে বড় XOR-এর জোড়া খুঁজতে মোট সময় কত?

  • O(n²)
  • O(n log n)
  • O(n·B)
  • O(B)

একটি routing table-এ তিনটি নিয়ম: 0.0.0.0/0 → C, 10.0.0.0/8 → A, 10.20.0.0/16 → B. longest prefix match নিয়মে 10.20.5.1 address-এর packet কোথায় যাবে?

  • A
  • B
  • C
  • কোথাও না — একাধিক নিয়ম মেলায় packet বাতিল

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Trie-তে প্রতিটি edge একটি অক্ষর, প্রতিটি node একটি prefix, আর end-চিহ্ন বলে কোথায় শব্দ শেষ। search আর startsWith-এর পার্থক্য শুধু এই চিহ্ন।
  2. Insert, search, startsWith — O(L), n-এর ওপর নির্ভর করে না। node ≤ N + 1 (N = মোট অক্ষর); array[σ]-এ জায়গা O(N·σ).
  3. প্রতিটি node-এ pass আর end counter: O(L)-এ prefix গোনা, frequency আর delete (pass 0 হলে শাখা ছাঁটা)।
  4. Autocomplete = prefix-এর node থেকে preorder DFS, সন্তান ছোট থেকে বড় ক্রমে — ফল আপনাআপনি lexicographic. Hash map-এর সন্তানে sort লাগে।
  5. Radix tree/Patricia শিকল জুড়ে O(n) node — IP routing-এর longest prefix match, URL router. Binary trie: maximum XOR O(n·B). Suffix trie → suffix tree → suffix array (পর্ব ৫৯).

পরের পর্বে: Trie prefix-এর প্রশ্নে রাজা, কিন্তু শুধু “আছে কি নেই” জানতে আরও সরল আর প্রায়ই দ্রুত একটি উপায় আছে — hash table. Hash function, collision সামলানো, load factor আর কেন গড় O(1) একটি প্রতিশ্রুতি, নিশ্চয়তা নয়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান