এই পর্বে যা শিখবেন
- কেন suffix-গুলো substring-এর সব প্রশ্নের চাবি, আর suffix trie, suffix tree ও suffix array-র সম্পর্ক
- Prefix doubling দিয়ে suffix array বানানো হাতে trace করা, আর naive, O(n log² n), O(n log n) ও O(n) পদ্ধতির পার্থক্য বলা
- Suffix array-তে binary search করে pattern খোঁজা, আর Kasai-এর অ্যালগরিদমে LCP array বানিয়ে তার O(n) প্রমাণ বোঝা
- আলাদা substring গোনা, longest repeated substring আর দুটি string-এর longest common substring বের করা
- Aho-Corasick-এর failure link আর output link বানানো ও লেখা একবার পড়ে সব pattern খুঁজে পাওয়া
১ · একটি লেখা, হাজারো প্রশ্ন — suffix কেন চাবি
পর্ব ৫৮-এ KMP বা Z algorithm দিয়ে একটি লেখা T-এর (দৈর্ঘ্য n) ভেতরে একটি pattern P (দৈর্ঘ্য m) খুঁজেছেন O(n + m) সময়ে। কিন্তু ভাবুন, লেখাটি একটি বড় অভিধান, একটি আইনের বই বা একটি জীবের পুরো DNA — আর একই লেখার ওপর প্রতিদিন লাখো আলাদা প্রশ্ন আসে। প্রতিবার পুরো লেখা নতুন করে পড়া মানে প্রতিটি প্রশ্নে O(n). এখানে বুদ্ধি হলো উল্টো দিকে খাটা: লেখাটিকে একবার গুছিয়ে রাখুন, যাতে পরে প্রতিটি প্রশ্নের খরচ মূলত শুধু pattern-এর দৈর্ঘ্যের ওপর নির্ভর করে।
গোছানোর চাবি হলো suffix: কোনো একটি অবস্থান থেকে লেখার শেষ পর্যন্ত অংশ। “banana”-র suffix ছয়টি — banana, anana, nana, ana, na, a. এখন একটি সরল কিন্তু শক্তিশালী পর্যবেক্ষণ:
প্রতিটি substring কোনো একটি suffix-এর prefix. T[i..j] হলো i থেকে শুরু suffix-এর প্রথম j − i + 1 টি অক্ষর। যেমন “nan” হলো “nana”-র শুরু। তাই “P কি T-তে আছে?” প্রশ্নটি দাঁড়ায় “কোনো suffix কি P দিয়ে শুরু?” — আর prefix-এর প্রশ্নে কী করতে হয়, তা পর্ব ৩১-এর trie থেকেই জানেন।
পর্ব ৩১-এ দেখেছেন, T-এর সব suffix একটি trie-তে ঢোকালে পাওয়া যায় suffix trie: যেকোনো P খোঁজা O(m), কারণ শুধু P-এর অক্ষর ধরে নামতে হয়। সমস্যা জায়গায় — suffix-গুলোর মোট দৈর্ঘ্য n(n+1)/2, তাই worst case-এ Θ(n²) node. দশ লাখ অক্ষরের লেখায় সেটি প্রায় ৫০,০০০ কোটি node — অসম্ভব।
সমাধানের প্রথম ধাপ radix tree-র কৌশল: একা-সন্তানের শিকলগুলো জুড়ে একটি edge বানানো, আর edge-এর label-কে মূল লেখার (শুরু, শেষ) index হিসেবে রাখা। এতে পাওয়া যায় suffix tree — n টি leaf আর বড়জোর n − 1 টি শাখা-node, অর্থাৎ O(n) জায়গা। একে O(n) সময়ে বানানোর পদ্ধতি দিয়েছেন Peter Weiner (১৯৭৩), পরে কম জায়গায় Edward McCreight (১৯৭৬), আর বাঁ থেকে ডানে এক পাসে Esko Ukkonen (১৯৯৫). এই পদ্ধতিগুলো সুন্দর কিন্তু জটিল; এই পর্বে আমরা এদের নাম জেনেই থামব।
পরীক্ষার হলের সিট-প্ল্যান ভাবুন। হাজারখানেক পরীক্ষার্থীর রোল নম্বর যদি এলোমেলোভাবে দেয়ালে টাঙানো থাকে, নিজের নাম খুঁজতে সবাইকে পুরো তালিকা পড়তে হবে। কিন্তু একবার নাম ধরে সাজিয়ে টাঙিয়ে দিলে প্রত্যেকে কয়েক নজরেই নিজেরটা পায়। সাজানোর কাজ একবার, সুবিধা হাজারবার। Suffix array ঠিক এই কাজটি করে লেখার সব suffix নিয়ে।
২ · Suffix array — সব suffix সাজিয়ে শুধু শুরুর index
Suffix tree-র O(n) জায়গাও বাস্তবে বড়: প্রতিটি node-এ কয়েকটি pointer আর index, ফলে অক্ষরপ্রতি কয়েক ডজন byte. ১৯৯০ সালে Udi Manber আর Gene Myers প্রস্তাব করেন অনেক সরল একটি স্ট্রাকচার: suffix array — সব suffix-কে lexicographic order-এ (অভিধানের ক্রমে) সাজিয়ে শুধু তাদের শুরুর index-গুলো একটি অ্যারেতে রাখা। অক্ষরপ্রতি মাত্র একটি পূর্ণসংখ্যা। নিচে “banana” দিয়ে দেখুন (0-based index).
| এই ধাপের কাজ | |
| SA এ পর্যন্ত | |
| রাখতে হলো |
- শুরু. ৬ অক্ষরের লেখায় ৬টি suffix, i = 0 থেকে 5. এগুলো আলাদা করে কপি করলে মোট ২১টি অক্ষর লাগত — n(n+1)/2. আমরা কিছুই কপি করব না, শুধু index সাজাব।
- প্রথম অক্ষর. অভিধানের মতো আগে প্রথম অক্ষর দেখি: a-দলে তিনটি, b-দলে একটি, n-দলে দুটি। a < b < n, তাই দলগুলোর ক্রম এটাই।
- a-দল. “a” বাকি দুটির prefix — অভিধানে ছোট শব্দ আগে, তাই a (5) প্রথম। ana আর anana-র প্রথম তিনটি অক্ষর এক; ana সেখানেই শেষ, তাই ana (3) আগে, anana (1) পরে।
- b আর n. b-দলে একা banana (0). n-দলে na হলো nana-র prefix, তাই na (4), তারপর nana (2).
- ফল. সাজানো ক্রমে শুরুর index-গুলো পড়লে SA = [5, 3, 1, 0, 4, 2]. r-তম সারির suffix হলো T[sa[r]..]. লক্ষ করুন, একই prefix-এর suffix-গুলো (ana, anana) পাশাপাশি এসে বসেছে — পরের সব অংশ এই একটি সত্যের ওপর দাঁড়িয়ে।
এর সাথে প্রায়ই রাখা হয় উল্টো অ্যারে rank array: rank[i] = i থেকে শুরু suffix-টি সাজানো তালিকার কোন সারিতে। banana-য় rank = [3, 2, 5, 1, 4, 0], কারণ sa[3] = 0, sa[2] = 1, ইত্যাদি। দুটি অ্যারে একে অন্যের inverse: sa[rank[i]] = i.
সবচেয়ে সরল নির্মাণ. 0 থেকে n − 1 পর্যন্ত index নিন আর এমন comparator দিয়ে sort করুন, যা দুটি index-এর suffix অক্ষর ধরে তুলনা করে। Sort-এ O(n log n) টি তুলনা (পর্ব ২১), আর প্রতিটি তুলনা worst case-এ O(n) অক্ষর পড়ে — যেমন “aaaa…a”-তে প্রায় সব তুলনা লম্বা। মোট O(n² log n) সময়, বাড়তি জায়গা O(n). কয়েক হাজার অক্ষরের জন্য যথেষ্ট, আর ছোট ইনপুটে উত্তর মেলানোর জন্য দারুণ brute force. কিন্তু n = ১০৫ হলে এটি কনটেস্টের সময়সীমা পার হয়ে যায়।
৩ · Prefix doubling — ১, ২, ৪, ৮ অক্ষর ধরে সাজানো
Naive পদ্ধতির অপচয় হলো, প্রতিটি তুলনা একই অক্ষরগুলো বারবার পড়ে। Prefix doubling (Manber ও Myers-এর মূল প্রবন্ধের ধারণা; Karp, Miller আর Rosenberg-এর ১৯৭২ সালের কৌশলের ওপর দাঁড়ানো) আগের কাজ কাজে লাগায়। ধরুন প্রতিটি suffix i-এর জন্য আমরা জানি rankk[i] — শুধু প্রথম k টি অক্ষর ধরলে সে সাজানো ক্রমে কোথায় (প্রথম k অক্ষর সমান হলে rank-ও সমান)। তাহলে প্রথম 2k অক্ষরের তুলনা আসলে একটি জোড়ার তুলনা:
কারণ প্রথম 2k অক্ষর = প্রথম k অক্ষর, তারপর i + k থেকে শুরু আরও k অক্ষর। i + k লেখার বাইরে গেলে দ্বিতীয় ভাগ খালি, আর খালি সবার চেয়ে ছোট — তাই সেখানে −1 বসান। জোড়াগুলো sort করে পাশাপাশি তুলনা করলেই নতুন rank2k. প্রতি ধাপে দৈর্ঘ্য দ্বিগুণ, আর যখন সব rank আলাদা হয়ে যায়, ক্রম চূড়ান্ত।
| rank যত অক্ষর ধরে | |
| rank (i = 0..5) | |
| আলাদা rank কয়টি |
- ধাপ ০: এক অক্ষর. a < b < n, তাই a-র rank 0, b-র 1, n-এর 2. তিনটি a-suffix (1, 3, 5) এখনো সমান — এক অক্ষরে তাদের আলাদা করা যায় না।
- k = 1: জোড়া বানানো. প্রতিটি i-এর জোড়া = (নিজের rank, এক ঘর পরের suffix-এর rank). যেমন i = 0: “b” আর তার পরে “a” — (1, 0). i = 5-এর পরে কিছু নেই, তাই দ্বিতীয় ঘরে −1. জোড়াটি আসলে প্রথম ২টি অক্ষরের সংক্ষিপ্ত রূপ।
- Sort আর নতুন rank. জোড়া ধরে সাজিয়ে পাশাপাশি তুলনা: আগেরটির চেয়ে বড় হলে rank এক বাড়ে, সমান হলে একই থাকে। “a” (0, −1) সবচেয়ে ছোট। i = 1 আর 3 দুটোই “an” — rank 1 ভাগ করে; i = 2 আর 4 দুটোই “na”. আলাদা rank এখন ৪টি।
- k = 2: আবার জোড়া. এবার দুই ঘর পরের rank: i = 1-এর জোড়া (rank[1], rank[3]) = (1, 1) — “an” আর তার পরের “an”, মানে “anan”. i = 3: (1, rank[5]) = (1, 0) — “an” + “a” = “ana”. i = 2: (rank[2], rank[4]) = (3, 3) — “na” + “na” = “nana”. লক্ষ করুন, কোনো অক্ষর নতুন করে পড়িনি।
- শেষ. সাজানোর পর ছয়টি rank-ই আলাদা — প্রথম ৪ অক্ষরেই সব suffix আলাদা হয়ে গেছে, তাই ক্রম চূড়ান্ত। rank দিয়ে সারি সাজালে SA = [5, 3, 1, 0, 4, 2], আর শেষ rank অ্যারেটিই অংশ ২-এর rank array.
Pseudocode — prefix doubling (0-based; rank[j] = -1 when j >= n)
suffixArray(T[0..n-1]):
sa = [0, 1, ..., n-1]; rank[i] = code of T[i]
k = 1
loop:
key(i) = (rank[i], rank[i + k]) // first 2k characters of suffix i
sort sa by key
new[sa[0]] = 0
for t = 1 .. n-1:
new[sa[t]] = new[sa[t-1]] + (key(sa[t-1]) < key(sa[t]) ? 1 : 0)
rank = new
if rank[sa[n-1]] == n-1: return sa // all ranks distinct
k = 2kকেন এটি ঠিক. Invariant: k-এর ধাপ শেষে rank[i] < rank[j] ⇔ suffix i-এর প্রথম 2k অক্ষর suffix j-এর প্রথম 2k অক্ষরের চেয়ে অভিধানে ছোট (খালি অংশকে সবচেয়ে ছোট ধরে). শুরুতে এক অক্ষরের জন্য এটি সংজ্ঞা থেকেই সত্য। ধাপ থেকে ধাপে: 2k অক্ষরের তুলনা = আগে প্রথম k-এর তুলনা, সমান হলে পরের k-এর — ঠিক জোড়ার lexicographic তুলনা, আর দুই ভাগের তুলনা আগের invariant দেয়। সব rank আলাদা হলে আর কোনো দুটি suffix-এর ক্রম বদলাতে পারে না, কারণ ক্রম আগের অক্ষরেই ঠিক হয়ে গেছে। ২t ≥ n হলে অবশ্যই থামে, কারণ পুরো suffix-ই তখন ধরা পড়ে আর দুটি আলাদা suffix-এর দৈর্ঘ্য আলাদা।
বিশ্লেষণ. ধাপ বড়জোর ⌈log₂ n⌉ + 1 টি। প্রতিটি ধাপে comparison sort O(n log n) (প্রতিটি তুলনা O(1), কারণ জোড়ার তুলনা) — মোট O(n log² n). কিন্তু rank-গুলো 0 থেকে n − 1-এর মধ্যে ছোট পূর্ণসংখ্যা, তাই comparison sort-এর বদলে পর্ব ২৪-এর radix sort চলে: আগে দ্বিতীয় ঘর ধরে, তারপর প্রথম ঘর ধরে দুটি stable counting sort, প্রতিটি O(n). তখন প্রতিটি ধাপ O(n), মোট O(n log n). জায়গা দুই ক্ষেত্রেই O(n). বাস্তবে আরও একটি শর্টকাট আছে: দ্বিতীয় ঘর ধরে সাজানো ক্রম আগের ধাপের sa থেকেই সরাসরি পড়ে নেওয়া যায়, তাই কনটেস্টের কোডে প্রায়ই একটি মাত্র counting sort থাকে।
O(n)-এও সম্ভব. ২০০৩ সালে কয়েকটি দল প্রায় একসাথে linear-time নির্মাণ প্রকাশ করে — তার একটি Juha Kärkkäinen আর Peter Sanders-এর DC3 (skew algorithm), যা দুই-তৃতীয়াংশ suffix recursion-এ সাজিয়ে বাকিগুলো merge করে। ২০০৯ সালে Ge Nong, Sen Zhang আর Wai Hong Chan দেন SA-IS, যা বাস্তবে খুব দ্রুত আর অনেক লাইব্রেরিতে ব্যবহৃত। এদের বিস্তারিত এই কোর্সের বাইরে; কনটেস্ট আর ইন্টারভিউতে prefix doubling-ই যথেষ্ট।
৪ · Suffix array দিয়ে pattern খোঁজা — O(m log n)
অংশ ২-এর শেষ পর্যবেক্ষণটি এখন কাজে লাগবে। যে suffix-গুলো P দিয়ে শুরু, তারা সাজানো তালিকায় একটি টানা অংশ — ঠিক যেমন অভিধানে “ca” দিয়ে শুরু সব শব্দ পাশাপাশি। তাই দুটি binary search (পর্ব ১৮-এর lower bound আর upper bound) যথেষ্ট: প্রথম যে সারিতে suffix-এর প্রথম m অক্ষর ≥ P, আর প্রথম যে সারিতে তা > P. প্রতিটি তুলনায় বড়জোর m টি অক্ষর পড়তে হয়, আর তুলনা হয় O(log n) বার।
banana-য় P = “ana” খুঁজি। সাজানো suffix: a, ana, anana, banana, na, nana. প্রতিটি তুলনায় শুধু suffix-এর প্রথম ৩টি অক্ষর দেখা হয়:
| খোঁজ | lo, hi | mid | sa[mid]-এর প্রথম ৩ অক্ষর | সিদ্ধান্ত |
|---|---|---|---|---|
| lower (≥ ana) | 0, 6 | 3 | ban | ban ≥ ana → hi = 3 |
| 0, 3 | 1 | ana | ana ≥ ana → hi = 1 | |
| 0, 1 | 0 | a | a < ana → lo = 1. L = 1 | |
| upper (> ana) | 0, 6 | 3 | ban | ban > ana → hi = 3 |
| 0, 3 | 1 | ana | ana বড় নয় → lo = 2 | |
| 2, 3 | 2 | ana (anana-র শুরু) | বড় নয় → lo = 3. R = 3 |
সারি [L, R) = [1, 3), অর্থাৎ ২টি ঘটনা, শুরু sa[1] = 3 আর sa[2] = 1 — banana-র index 1 আর 3-এ “ana”, যা একটি আরেকটির ওপর পড়ে। খরচ: গোনা O(m log n); সব z টি অবস্থান লিখতে আরও O(z). লেখাটি একবার সাজানো থাকলে প্রতিটি নতুন প্রশ্নে n টি অক্ষর আর পড়তে হয় না, শুধু log n বার m অক্ষর। Manber ও Myers দেখিয়েছিলেন, পাশের অংশের LCP-র তথ্য আগে থেকে রাখলে এই খরচ O(m + log n)-এ নামে — কারণ binary search-এর সময় আগের তুলনায় মেলা অক্ষরগুলো আর পড়তে হয় না।
প্রচলিত ভুল. তুলনা করার সময় পুরো suffix-কে P-এর সাথে তুলনা করলে “anana” > “ana” হয় — ফলে upper bound ভুল জায়গায় থামে আর ঘটনা বাদ পড়ে। শুধু suffix-এর প্রথম m অক্ষর নিন (C++-এ s.compare(sa[mid], m, p)), আর suffix যদি m-এর চেয়ে ছোট হয়, যতটুকু আছে ততটুকুই।
৫ · LCP array আর Kasai-এর O(n) অ্যালগরিদম
সাজানো suffix-গুলো পাশাপাশি কতটা মেলে, সেটিই বহু প্রশ্নের উত্তর। LCP array: lcp[r] = sa[r − 1] আর sa[r]-এর suffix দুটির longest common prefix-এর দৈর্ঘ্য (lcp[0] = 0 ধরি). banana-য় পাশাপাশি জোড়া: a/ana → 1, ana/anana → 3, anana/banana → 0, banana/na → 0, na/nana → 2. তাই lcp = [0, 1, 3, 0, 0, 2].
সরলভাবে প্রতিটি জোড়া প্রথম থেকে মেলালে worst case O(n²) — “aaaa…a”-তে lcp = 0, 1, 2, …, n − 1, যোগফল প্রায় n²/2. ২০০১ সালে Toru Kasai আর সহলেখকেরা (Lee, Arimura, Arikawa, Park) দেখান কীভাবে O(n)-এ পারা যায়। কৌশল: জোড়াগুলো সাজানো ক্রমে নয়, লেখার ক্রমে দেখুন — i = 0, 1, 2, … — আর আগের উত্তর থেকে শুরু করুন।
suffix i আর সাজানো ক্রমে তার ঠিক আগের suffix-এর LCP যদি h > 0 হয়, তাহলে suffix i + 1 আর তার আগের suffix-এর LCP অন্তত h − 1.
প্রমাণ. ধরুন j হলো সাজানো ক্রমে i-এর ঠিক আগের suffix, আর তারা প্রথম h অক্ষর ভাগ করে (h > 0). দুটির প্রথম অক্ষর এক বাদ দিলে suffix j + 1 আর i + 1 প্রথম h − 1 অক্ষর ভাগ করে। আর j < i (অভিধানে) এবং প্রথম অক্ষর এক, তাই j + 1 < i + 1 — অর্থাৎ সাজানো ক্রমে j + 1 আসে i + 1-এর আগে। এখন i + 1-এর ঠিক আগের suffix k সাজানো তালিকায় j + 1 আর i + 1-এর মাঝখানে (বা j + 1 নিজেই)। সাজানো তালিকায় দুটি suffix যে prefix ভাগ করে, মাঝখানের সবাই সেটি ভাগ করে — তাই k-ও i + 1-এর সাথে অন্তত h − 1 অক্ষর ভাগ করে। ∎
ফলে প্রতিটি i-এ h শূন্য থেকে নয়, আগের h − 1 থেকে মেলানো শুরু হয়। h কখনো n-এর বেশি হয় না আর প্রতিটি ধাপে বড়জোর ১ কমে, তাই সব ধাপ মিলিয়ে h বড়জোর n বার কমে — অতএব বাড়ে বড়জোর 2n বার। প্রতিটি সফল অক্ষর-মিল h এক বাড়ায়, প্রতিটি i-এ বড়জোর একটি ব্যর্থ মিল: মোট O(n) সময়, জায়গা rank আর lcp-এর জন্য O(n). (এই “potential কমে বড়জোর এতটুকু” ধরনের যুক্তি পর্ব ৬০-এর amortized analysis-এর একটি সুন্দর উদাহরণ।)
| i (লেখার ক্রমে) | |
| rank[i] → আগের suffix | |
| h শুরুতে → মিলের পর | |
| পরের ধাপের h | |
| এ পর্যন্ত সফল অক্ষর-মিল |
- i = 0, banana. rank[0] = 3, তাই তুলনা সারি ২-এর anana-র সাথে। b ≠ a — h = 0, lcp[3] = 0. h শূন্য, কমানোর কিছু নেই।
- i = 1, anana. সারি ২; আগের সারির ana-র সাথে শূন্য থেকে মেলাই: a, n, a মিলল, তারপর ana শেষ — h = 3, lcp[2] = 3. লেমা বলে পরের suffix-এ অন্তত 2, তাই h = 2.
- i = 2, nana. সারি ৫, আগে na. প্রথম ২টি অক্ষর (“na”) না দেখেই মিল ধরে নিই — লেমার গ্যারান্টি। তারপর তৃতীয় অক্ষরে na শেষ — h = 2, lcp[5] = 2. একটি অক্ষরও নতুন করে মেলেনি। পরের h = 1.
- i = 3, ana. সারি ১, আগে a. h = 1 থেকে: ana-র দ্বিতীয় অক্ষর n, কিন্তু “a” সেখানেই শেষ — lcp[1] = 1. পরের h = 0.
- i = 4, na. সারি ৪, আগে banana. n ≠ b — lcp[4] = 0.
- i = 5, a. সারি ০, আগে কেউ নেই — lcp[0] = 0, h = 0. পুরো কাজে সফল অক্ষর-মিল মাত্র ৩টি, আর প্রতিটি i-এ বড়জোর একটি ব্যর্থ তুলনা। ফল lcp = [0, 1, 3, 0, 0, 2], যোগফল 6.
এবার দুটি অংশ একসাথে কোডে: prefix doubling-এ suffix array (সরলতার জন্য std::sort, তাই O(n log² n)) আর Kasai-এ LCP. তারপর পরের অংশের সূত্রে আলাদা substring গোনা হয় আর সব substring একটি set-এ রাখা brute force-এর সাথে মেলানো হয় — চারটি ভিন্ন ধাঁচের string-এ।
#include <algorithm>
#include <initializer_list>
#include <iostream>
#include <set>
#include <string>
#include <vector>
using namespace std;
vector<int> suffixArray(const string& s) { // prefix doubling, O(n log^2 n)
int n = s.size();
vector<int> sa(n), rk(s.begin(), s.end()), tmp(n); // round 0: rank = character code
for (int i = 0; i < n; ++i) sa[i] = i;
for (int k = 1; ; k *= 2) {
auto key = [&](int i) { return pair(rk[i], i + k < n ? rk[i + k] : -1); };
sort(sa.begin(), sa.end(), [&](int a, int b) { return key(a) < key(b); });
tmp[sa[0]] = 0;
for (int t = 1; t < n; ++t) tmp[sa[t]] = tmp[sa[t - 1]] + (key(sa[t - 1]) < key(sa[t]));
rk = tmp;
if (rk[sa[n - 1]] == n - 1) return sa; // all ranks distinct: sorted
}
}
vector<int> kasai(const string& s, const vector<int>& sa) { // lcp[r] = LCP(sa[r-1], sa[r]), O(n)
int n = s.size(), h = 0;
vector<int> rk(n), lcp(n, 0); for (int r = 0; r < n; ++r) rk[sa[r]] = r;
for (int i = 0; i < n; ++i) { // suffixes in text order
if (rk[i] == 0) { h = 0; continue; }
int j = sa[rk[i] - 1]; // the suffix just before i in sorted order
while (i + h < n && j + h < n && s[i + h] == s[j + h]) ++h;
lcp[rk[i]] = h;
if (h > 0) --h; // key fact: the next LCP is at least h - 1
}
return lcp;
}
int main() {
for (string w : {"banana", "abab", "aaaa", "mississippi"}) {
auto sa = suffixArray(w); auto lcp = kasai(w, sa);
long long n = w.size(), sum = 0; int b = 0;
for (int r = 0; r < n; ++r) { sum += lcp[r]; if (lcp[r] > lcp[b]) b = r; }
if (w == "banana") for (int r = 0; r < n; ++r) cout << r << ' ' << sa[r] << ' ' << lcp[r] << ' ' << w.substr(sa[r]) << '\n';
set<string> all; // brute force: every substring
for (int i = 0; i < n; ++i) for (int l = 1; i + l <= n; ++l) all.insert(w.substr(i, l));
cout << w << ": distinct " << n * (n + 1) / 2 - sum << ", brute " << all.size()
<< ", longest repeat '" << w.substr(sa[b], lcp[b]) << "'\n";
}
}আউটপুট:
0 5 0 a
1 3 1 ana
2 1 3 anana
3 0 0 banana
4 4 0 na
5 2 2 nana
banana: distinct 15, brute 15, longest repeat 'ana'
abab: distinct 7, brute 7, longest repeat 'ab'
aaaa: distinct 4, brute 4, longest repeat 'aaa'
mississippi: distinct 53, brute 53, longest repeat 'issi'
প্রথম ছয় লাইন হলো r, sa[r], lcp[r] আর suffix — দুটি stepper-এর টেবিল হুবহু। তারপর প্রতিটি string-এ সূত্রের উত্তর আর brute force মিলেছে: banana-য় 21 − 6 = 15, abab-এ 10 − 3 = 7, aaaa-য় 10 − 6 = 4, mississippi-তে 66 − 13 = 53. “aaaa”-তে lcp = [0, 1, 2, 3] — naive LCP-র worst case, অথচ Kasai-এর h সেখানে প্রতি ধাপে মাত্র ১ কমে আর আবার ১ বাড়ে। rk(s.begin(), s.end()) শুরুর rank হিসেবে অক্ষরের code নেয়; সংখ্যাগুলো 0..n−1 না হলেও কিছু যায় আসে না, কারণ শুধু ক্রম দরকার। Brute force-এর set-এ O(n²) টি string, তাই এটি শুধু ছোট পরীক্ষার জন্য।
def suffix_array(s): # prefix doubling, O(n log^2 n)
n = len(s)
sa, rk = list(range(n)), [ord(c) for c in s]
k = 1
while True:
key = lambda i: (rk[i], rk[i + k] if i + k < n else -1)
sa.sort(key=key)
tmp = [0] * n
for t in range(1, n):
tmp[sa[t]] = tmp[sa[t - 1]] + (key(sa[t - 1]) < key(sa[t]))
rk = tmp
if rk[sa[-1]] == n - 1: return sa # all ranks distinct: sorted
k *= 2
def kasai(s, sa): # lcp[r] = LCP(suffix sa[r-1], suffix sa[r]), O(n)
n, h = len(s), 0
rk, lcp = [0] * n, [0] * n
for r, i in enumerate(sa): rk[i] = r
for i in range(n): # suffixes in text order: i = 0, 1, 2, ...
if rk[i] == 0: h = 0; continue
j = sa[rk[i] - 1] # the suffix just before i in sorted order
while i + h < n and j + h < n and s[i + h] == s[j + h]: h += 1
lcp[rk[i]] = h
if h > 0: h -= 1 # key fact: the next LCP is at least h - 1
return lcp
for w in ["banana", "abab", "aaaa", "mississippi"]:
sa = suffix_array(w); lcp = kasai(w, sa); n = len(w)
if w == "banana":
for r in range(n): print(r, sa[r], lcp[r], w[sa[r]:])
brute = len({w[i:j] for i in range(n) for j in range(i + 1, n + 1)}) # every substring
b = max(range(n), key=lambda r: lcp[r]) # first r with the largest LCP
print(f"{w}: distinct {n * (n + 1) // 2 - sum(lcp)}, brute {brute}, longest repeat '{w[sa[b]:sa[b] + lcp[b]]}'")আউটপুট C++-এর সাথে হুবহু এক:
0 5 0 a
1 3 1 ana
2 1 3 anana
3 0 0 banana
4 4 0 na
5 2 2 nana
banana: distinct 15, brute 15, longest repeat 'ana'
abab: distinct 7, brute 7, longest repeat 'ab'
aaaa: distinct 4, brute 4, longest repeat 'aaa'
mississippi: distinct 53, brute 53, longest repeat 'issi'
sa.sort(key=key)-এ key একটি tuple, আর Python-এর tuple তুলনা নিজেই lexicographic — জোড়ার তুলনা আলাদা করে লিখতে হয়নি। Python-এর sort stable হলেও এখানে stability দরকার নেই, কারণ সমান key-এর suffix-গুলো একই rank পায়। max(range(n), key=...) সবচেয়ে বড় lcp-এর প্রথম সারি দেয়, C++-এর > তুলনার মতোই — তাই দুই সংস্করণ একই longest repeat লেখে।
৬ · প্রয়োগ — আলাদা substring, longest repeated আর common substring
কতগুলো আলাদা substring?
দৈর্ঘ্য n-এর লেখায় মোট substring (একই লেখা বারবার গুনলে) n(n+1)/2 টি। কিন্তু “banana”-য় “a” তিনবার, “ana” দুবার — আলাদা substring কয়টি? Suffix array-র সারি ধরে ভাবুন: সারি r-এর suffix-এর দৈর্ঘ্য n − sa[r], তাই তার prefix n − sa[r] টি। এদের মধ্যে প্রথম lcp[r] টি আগের সারির suffix-এরও prefix — আগেই গোনা হয়েছে। বাকিগুলো একেবারে নতুন: কারণ আগের যেকোনো সারির সাথে মিল পাশের সারির চেয়ে বেশি হতে পারে না (সাজানো ক্রম)। তাই
banana: 21 − 6 = 15. Suffix array আর LCP বানাতে O(n log n), যোগফল O(n) — অথচ পর্ব ৩১-এর suffix trie-তে একই উত্তরের জন্য O(n²) node লাগত।
Longest repeated substring
যে substring লেখায় অন্তত দুবার আছে, সেটি দুটি আলাদা suffix-এর common prefix. আর সাজানো তালিকায় সবচেয়ে বেশি মেলে পাশের জোড়া — তাই longest repeated substring-এর দৈর্ঘ্য = max lcp[r], আর লেখাটি হলো T[sa[r]..sa[r] + lcp[r] − 1]. banana-য় সর্বোচ্চ lcp[2] = 3 — “ana” (দুটি ঘটনা একে অন্যের ওপর পড়ে, সেটি অনুমোদিত). mississippi-তে “issi”. খরচ O(n log n), naive পদ্ধতির O(n³)-এর তুলনায় বিশাল পার্থক্য।
দুটি string-এর longest common substring
দুটি লেখা s আর t-এর longest common substring চাই — যেমন দুটি প্রতিবেদনের মধ্যে সবচেয়ে লম্বা হুবহু কপি করা অংশ। কৌশল: মাঝে এমন একটি আলাদা অক্ষর দিয়ে জুড়ে দিন, যা দুটির কোনোটিতে নেই — u = s + “#” + t. এখন u-এর suffix array আর LCP বানান, আর এমন পাশাপাশি জোড়া খুঁজুন যাদের একটি s-এর ভেতরে শুরু (sa < |s|) আর অন্যটি t-এর ভেতরে। এমন জোড়ার সর্বোচ্চ lcp-ই উত্তর। “#” থাকায় কোনো common prefix দুই string-এর সীমা পার হতে পারে না, কারণ “#” একবারই আছে। “banana” আর “ananas”-এ উত্তর “anana” (দৈর্ঘ্য ৫). কেন শুধু পাশাপাশি জোড়া দেখলেই চলে? সেরা জোড়াটি (একটি s-এর, একটি t-এর) সাজানো তালিকায় যেখানেই থাকুক, তাদের মাঝখানে কোথাও না কোথাও s থেকে t-তে (বা উল্টো) একটি পাশাপাশি বদল আছে, আর সেই বদলের জোড়ার lcp অন্তত তাদের common prefix-এর সমান। খরচ O((|s| + |t|) log(|s| + |t|)), পর্ব ৫৪-এর DP-র O(|s|·|t|)-এর চেয়ে বড় ইনপুটে অনেক দ্রুত।
যেকোনো দুটি suffix-এর LCP. উপরের প্রমাণে বারবার একটি সত্য এসেছে: সারি a < b হলে suffix sa[a] আর sa[b]-এর LCP = min(lcp[a + 1..b]). এটি একটি range minimum query, তাই LCP array-র ওপর পর্ব ৩৭-এর sparse table বানালে O(n log n) প্রস্তুতির পর যেকোনো দুটি suffix-এর LCP O(1)-এ — কনটেস্টের অনেক কঠিন string সমস্যার মূল উপাদান।
৭ · অনেক pattern একসাথে — Aho-Corasick-এর trie আর failure link
এবার প্রশ্ন উল্টো। লেখা একটি, কিন্তু pattern অনেক — একটি মেসেজে কয়েক হাজার নিষিদ্ধ শব্দের কোনোটি আছে কি না, বা একটি ফাইলে লাখো পরিচিত ভাইরাসের কোনো চিহ্ন আছে কি না। প্রতিটি pattern-এর জন্য আলাদা KMP চালালে খরচ k টি pattern-এ O(k·n + m), যেখানে m = সব pattern-এর মোট দৈর্ঘ্য। ১৯৭৫ সালে Bell Labs-এর Alfred Aho আর Margaret Corasick দেখান কীভাবে লেখাটি একবার পড়েই সব pattern খোঁজা যায় — Aho-Corasick অ্যালগরিদম।
প্রথম ধাপ সহজ: সব pattern দিয়ে একটি trie বানান (পর্ব ৩১), আর যে node-এ কোনো pattern শেষ, সেখানে চিহ্ন রাখুন। লেখা পড়ার সময় trie-তে হাঁটুন। সমস্যা হলো মিল ভাঙলে কী করবেন। KMP-র উত্তর ছিল: এতক্ষণ যা মিলেছে, তার যে সবচেয়ে লম্বা অংশ শেষে আছে আর আবার একটি শুরু হিসেবে কাজে লাগে, সেখানে সরে যান। Aho-Corasick ঠিক এই ধারণাটিকে trie-তে নিয়ে যায়:
node v-এর string যদি S হয়, তাহলে fail(v) হলো সেই node, যার string S-এর সবচেয়ে লম্বা proper suffix যা trie-তেও আছে (proper মানে S নিজে নয়; খালি string = root). অর্থাৎ “এতক্ষণ পড়া লেখার শেষ অংশের সাথে trie-র সবচেয়ে গভীর কোন node মেলে”।
{he, she, his, hers}-এর trie-তে “she”-র node থেকে failure link যায় “he”-তে — কারণ “she” পড়া হলে শেষের “he”-ও পড়া হয়েছে, আর সেখান থেকে “hers” পর্যন্ত যাওয়া যেতে পারে। Link-গুলো বানানো হয় BFS দিয়ে, স্তর ধরে (পর্ব ৪০), কারণ fail(v) সবসময় v-এর চেয়ে অগভীর — তাই তার link আগেই জানা থাকে। নিয়ম: root-এর সন্তানদের fail = root. তারপর node v-এর c-অক্ষরের সন্তান u-এর জন্য fail(v) থেকে c-এর edge খুঁজুন; না পেলে fail(fail(v)) থেকে, এভাবে; root-এও না পেলে root.
| BFS queue | |
| এই ধাপে বের হলো | |
| নতুন link |
- Trie. চারটি pattern ঢোকানো হলো: he (1, 2), she (3, 4, 5), his (6, 7 — h ভাগ করে), hers (8, 9 — he ভাগ করে). মোট ১০টি node. এখনো কোনো link নেই।
- স্তর ১. এক অক্ষরের string-এর একমাত্র proper suffix খালি string, তাই h (1) আর s (3) দুটোরই f = root. দুটিকে queue-তে রাখি।
- node 1 (h) বের হলো. সন্তান e (2): f(1) = root থেকে e-এর edge আছে? নেই — f(2) = 0. সন্তান i (6): root থেকে i নেই — f(6) = 0. “he” বা “hi”-এর শেষাংশ “e” বা “i” দিয়ে কোনো pattern শুরু হয় না।
- node 3 (s) বের হলো. সন্তান h (4), string “sh”. f(3) = root থেকে h-এর edge আছে — node 1. তাই f(4) = 1: “sh” পড়া মানে শেষে একটি “h” পড়া, যা he/his/hers-এর শুরু।
- node 2 আর 6. he-এর সন্তান r (8): f(2) = 0, root থেকে r নেই — f(8) = 0. hi-এর সন্তান s (7): root থেকে s আছে — f(7) = 3. “his”-এর শেষে “s”, যা “she”-র শুরু হতে পারে।
- শেষ স্তর. sh-এর সন্তান e (5): f(4) = 1 থেকে e-এর edge — node 2. তাই f(5) = 2: “she”-র শেষে “he”. node 2 নিজে একটি pattern, তাই output link out(5) = 2 — “she” পেলে “he”-ও রিপোর্ট করতে হবে। her-এর সন্তান s (9): root থেকে s — f(9) = 3. queue খালি, কাজ শেষ।
কেন নিয়মটি ঠিক. u-এর string যদি Sc হয় (S = v-এর string, c একটি অক্ষর), তাহলে Sc-এর প্রতিটি proper suffix হয় খালি, নয়তো S′c — যেখানে S′ হলো S-এর একটি proper suffix. তাই প্রার্থী হলো S-এর trie-তে থাকা proper suffix-গুলো, লম্বা থেকে ছোট ক্রমে: f(v), f(f(v)), …, root — যার প্রথমটির c-edge আছে, সেটির c-সন্তানই উত্তর। এটি induction: অগভীর সব node-এর link ঠিক থাকলে u-এর link-ও ঠিক, আর BFS-এর ক্রম এই অনুমান নিশ্চিত করে।
Output link কেন লাগে. একই অবস্থানে কয়েকটি pattern শেষ হতে পারে (“she” আর “he”). সেগুলো পেতে fail-এর পুরো শিকল ধরে হাঁটা যায়, কিন্তু শিকলের বেশিরভাগ node হয়তো pattern-ই নয় — অপচয়। তাই প্রতিটি node-এ রাখা হয় out(v) = fail-শিকলে প্রথম যে node-এ pattern শেষ। তখন প্রতিটি লাফ একটি নতুন ঘটনা দেয়।
৮ · লেখা একবার পড়ে সব pattern — O(n + m + z)
খোঁজার নিয়ম: state = root. লেখার প্রতিটি অক্ষর c-এর জন্য, বর্তমান node-এ c-edge না থাকলে fail ধরে পিছিয়ে যান যতক্ষণ না পাওয়া যায় (বা root-এ পৌঁছান), তারপর c-edge ধরে নামুন। নতুন node নিজে pattern হলে রিপোর্ট করুন, তারপর output link-এর শিকল ধরে বাকিগুলো। নিচে লেখা “ahishers” (0-based index).
| state (node) | |
| এই অক্ষরে পাওয়া | |
| মোট ঘটনা |
- a. কোনো pattern a দিয়ে শুরু হয় না; root-এই থাকি।
- h. root → node 1. এখনো কোনো pattern শেষ হয়নি।
- i. node 1-এর i-সন্তান node 6 (“hi”).
- s. node 7 = “his”, একটি pattern — শুরু index 3 − 3 + 1 = 1. out(7) নেই, কারণ fail(7) = s কোনো pattern নয়।
- h. “his”-এর পরে h-এর কোনো edge নেই। fail(7) = 3 (“s”) — সেখান থেকে h আছে, তাই node 4 (“sh”). লক্ষ করুন, লেখার কোনো অক্ষর আবার পড়তে হয়নি; শুধু trie-তে সরে গেছি।
- e. node 5 = “she” — শুরু 3. তারপর out(5) = 2, “he” — শুরু 4. একই অক্ষরে দুটি ঘটনা, কোনো খোঁজাখুঁজি ছাড়াই।
- r. “she”-র r-edge নেই; fail(5) = 2 (“he”), সেখানে r আছে — node 8 (“her”). এটিই failure link-এর আসল কাজ: she থেকে hers-এর পথে লাফ।
- s. node 9 = “hers”, শুরু 4. লেখা শেষ: ৮টি অক্ষর পড়ে চারটি pattern-এর চারটি ঘটনা।
বিশ্লেষণ. Trie বানাতে O(m). খোঁজার সময় প্রতিটি fail-লাফ বর্তমান node-এর গভীরতা অন্তত ১ কমায়, আর প্রতিটি অক্ষর গভীরতা বড়জোর ১ বাড়ায় — তাই পুরো লেখায় মোট লাফ ≤ n, অর্থাৎ KMP-র মতোই amortized O(n). প্রতিটি output-লাফ একটি রিপোর্ট, তাই মোট O(n + m + z), z = মোট ঘটনার সংখ্যা। লক্ষ করুন z নিজে বড় হতে পারে — “aaaa…a” লেখায় {a, aa, aaa}-এর ঘটনা প্রায় 3n — তাই কোনো অ্যালগরিদম z-এর চেয়ে কম সময়ে সব ঘটনা লিখতে পারে না।
কনটেস্টের প্রচলিত কোড আরও এক ধাপ এগোয়: BFS-এর সময়ই প্রতিটি node আর প্রতিটি অক্ষরের জন্য চূড়ান্ত গন্তব্য হিসাব করে রাখে — node v-এ c-edge না থাকলে goto(v, c) = goto(fail(v), c). তখন খোঁজার লুপে একটিও fail-লাফ লাগে না, প্রতি অক্ষরে ঠিক একটি ধাপ। বিনিময়ে প্রস্তুতি আর জায়গা O(m·σ), σ = alphabet size. এটি আসলে একটি deterministic finite automaton (DFA). নিচের কোড এই রূপটিই বানায়।
#include <array>
#include <iostream>
#include <queue>
#include <string>
#include <vector>
using namespace std;
int main() {
vector<string> pats{"he", "she", "his", "hers"};
vector<array<int, 26>> nxt(1); nxt[0].fill(-1); // node 0 = root, -1 = no edge
vector<int> fail{0}, out{-1}, word{-1};
auto newNode = [&] { nxt.emplace_back(); nxt.back().fill(-1); fail.push_back(0); out.push_back(-1); word.push_back(-1); };
for (int id = 0; id < (int)pats.size(); ++id) { // 1) trie of all patterns
int v = 0;
for (char ch : pats[id]) {
if (nxt[v][ch - 'a'] < 0) { nxt[v][ch - 'a'] = nxt.size(); newNode(); }
v = nxt[v][ch - 'a'];
}
word[v] = id;
}
queue<int> q; // 2) BFS: fail links + full goto table
for (int c = 0; c < 26; ++c) { if (nxt[0][c] < 0) nxt[0][c] = 0; else q.push(nxt[0][c]); }
while (!q.empty()) {
int v = q.front(), f = fail[v]; q.pop();
out[v] = word[f] >= 0 ? f : out[f]; // nearest proper suffix that is a pattern
for (int c = 0; c < 26; ++c) {
int u = nxt[v][c];
if (u < 0) nxt[v][c] = nxt[f][c]; // missing edge: go where fail[v] goes
else { fail[u] = nxt[f][c]; q.push(u); }
}
}
cout << "nodes: " << nxt.size() << " fail: [";
for (size_t v = 0; v < fail.size(); ++v) cout << (v ? ", " : "") << fail[v];
cout << "]\n";
string text = "ahishers"; int v = 0, found = 0; // 3) one pass over the text
for (int i = 0; i < (int)text.size(); ++i) {
v = nxt[v][text[i] - 'a'];
for (int u = word[v] >= 0 ? v : out[v]; u > 0; u = out[u], ++found) // every pattern ending at i
cout << "'" << pats[word[u]] << "' at " << i - (int)pats[word[u]].size() + 1 << '\n';
}
cout << "matches: " << found << '\n';
}আউটপুট:
nodes: 10 fail: [0, 0, 0, 0, 1, 2, 0, 3, 0, 3]
'his' at 1
'she' at 3
'he' at 4
'hers' at 4
matches: 4
fail অ্যারেটি stepper-এর সাথে হুবহু মেলে: f(4) = 1, f(5) = 2, f(7) = 3, f(9) = 3, বাকিগুলো 0. চারটি ঘটনার শুরুর index-ও এক — his 1, she 3, he 4, hers 4. BFS-এর লুপে nxt[v][c] = nxt[f][c] হলো goto-টেবিল ভরাট; f-এর সারি আগেই পূর্ণ, কারণ f অগভীর। একই কারণে fail[u] = nxt[f][c] এক ধাপেই ঠিক link দেয় — অংশ ৭-এর “f(v), f(f(v)), …” শিকল টেবিলের ভেতরে আগেই ভাঁজ করা আছে। খোঁজার লুপে u প্রথমে বর্তমান node (যদি pattern হয়), তারপর out ধরে — প্রতিটি লাফ একটি ঘটনা।
from collections import deque
pats = ["he", "she", "his", "hers"]
nxt, fail, out, word = [[-1] * 26], [0], [-1], [-1] # node 0 = root
for p_id, p in enumerate(pats): # 1) trie of all patterns
v = 0
for ch in p:
c = ord(ch) - 97
if nxt[v][c] < 0:
nxt[v][c] = len(nxt); nxt.append([-1] * 26); fail.append(0); out.append(-1); word.append(-1)
v = nxt[v][c]
word[v] = p_id
q = deque() # 2) BFS: fail links + full goto table
for c in range(26):
if nxt[0][c] < 0: nxt[0][c] = 0
else: q.append(nxt[0][c])
while q:
v = q.popleft()
f = fail[v]
out[v] = f if word[f] >= 0 else out[f] # nearest proper suffix that is a pattern
for c in range(26):
u = nxt[v][c]
if u < 0: nxt[v][c] = nxt[f][c] # missing edge: jump as fail[v] would
else: fail[u] = nxt[f][c]; q.append(u)
print("nodes:", len(nxt), " fail:", fail)
text, v, found = "ahishers", 0, 0 # 3) one pass over the text
for i, ch in enumerate(text):
v = nxt[v][ord(ch) - 97]
u = v if word[v] >= 0 else out[v]
while u > 0: # every pattern ending at position i
p = pats[word[u]]
print(f"'{p}' at {i - len(p) + 1}"); found += 1
u = out[u]
print("matches:", found)আউটপুট C++-এর সাথে হুবহু এক:
nodes: 10 fail: [0, 0, 0, 0, 1, 2, 0, 3, 0, 3]
'his' at 1
'she' at 3
'he' at 4
'hers' at 4
matches: 4
collections.deque দিয়ে BFS-এর queue, কারণ list-এর pop(0) প্রতিবার O(n). word[v] বলে node v-এ কোন pattern শেষ (−1 = কোনোটি নয়), তাই রিপোর্টে pattern-এর দৈর্ঘ্য থেকে শুরুর index বের হয়। একই pattern দুবার দিলে word শুধু শেষটি রাখে — বাস্তব কোডে প্রতিটি node-এ pattern-এর একটি তালিকা রাখুন।
যেখানে Aho-Corasick রোজ চলে
Antivirus আর নেটওয়ার্ক নিরাপত্তা (প্রতিরক্ষা). একটি antivirus প্রতিটি ফাইলে হাজারো পরিচিত ক্ষতিকর byte-সারি (signature) খোঁজে; ClamAV-এর মতো open-source antivirus আর Snort-এর মতো intrusion detection system অনেক signature একসাথে মেলাতে Aho-Corasick বা তার রূপান্তর ব্যবহার করে। প্রতিটি নেটওয়ার্ক packet একবার পড়েই সব নিয়ম পরীক্ষা — signature বাড়লেও প্রতি byte-এ কাজ প্রায় একই থাকে। Spam আর নিষিদ্ধ শব্দ. মেসেজিং অ্যাপ বা অনলাইন মার্কেটপ্লেসের মন্তব্যে গালিগালাজ বা প্রতারণার চেনা বাক্য ধরা, ইমেইলের spam filter-এর চেনা শব্দ খোঁজা — সবই অনেক-pattern-এর সমস্যা। Unix-এর fgrep (আজকের grep -F) কয়েকটি স্থির string একসাথে খোঁজে; Aho নিজেই Unix-এর egrep আর fgrep লিখেছিলেন, আর fgrep-এ এই অ্যালগরিদমই ছিল।
আরও গভীরে: লেখার সব ঘূর্ণন সাজিয়ে প্রতিটির শেষ অক্ষর পড়লে পাওয়া যায় Burrows-Wheeler transform (Michael Burrows ও David Wheeler, ১৯৯৪) — শেষে একটি সবচেয়ে ছোট চিহ্ন “$” জুড়ে দিলে এটি suffix array থেকেই সরাসরি আসে: BWT[r] = T[sa[r] − 1] (sa[r] = 0 হলে “$”). এতে একই অক্ষর পাশাপাশি জড়ো হয়, তাই bzip2 (Julian Seward) এর ওপর সংকোচন করে। Paolo Ferragina আর Giovanni Manzini ২০০০ সালে দেখান, BWT-এর ওপর বানানো FM-index সংকুচিত অবস্থাতেই pattern-এর ঘটনা গুনতে পারে O(m)-এর কাছাকাছি সময়ে — আর এটিই BWA ও Bowtie-এর মতো DNA read aligner-এর ভিত্তি, যারা মানুষের প্রায় ৩০০ কোটি অক্ষরের genome-এর index কয়েক GB-তে রাখে।
পরীক্ষা ও ইন্টারভিউতে:
- “banana”-র suffix array আর LCP array লিখুন। — SA = [5, 3, 1, 0, 4, 2], LCP = [0, 1, 3, 0, 0, 2].
- Prefix doubling-এর time complexity? — log n টি ধাপ; comparison sort-এ O(n log² n), radix sort-এ O(n log n).
- Kasai কেন O(n)? — লেখার ক্রমে চললে h প্রতি ধাপে বড়জোর ১ কমে, h ≤ n, তাই মোট বৃদ্ধি ≤ 2n.
- আলাদা substring কতগুলো? — n(n+1)/2 − ΣLCP; banana-য় 15.
- Aho-Corasick-এর failure link কী, আর খোঁজার খরচ? — বর্তমান string-এর trie-তে থাকা সবচেয়ে লম্বা proper suffix; O(n + m + z).
৯ · AI-এর যুগে suffix structure — কোড ছোট, ভুল গভীরে
“Suffix array আর LCP-র কোড দাও” — AI কয়েক সেকেন্ডে কোড দেবে, আর ছোট উদাহরণে প্রায়ই সেটি চলবে। কিন্তু এই বিষয়ের ভুলগুলো লুকোয় সীমানায়: শেষে sentinel “$” রাখা না-রাখার গোলমাল, rank 0 থেকে নাকি 1 থেকে, Kasai-এ h কমাতে ভুলে যাওয়া (তখন উত্তর ঠিক থাকে কিন্তু O(n²) হয়ে যায়), Aho-Corasick-এ output link বাদ দিয়ে “she” পেলে “he” হারানো। আর complexity জিজ্ঞেস করলে AI প্রায়ই std::sort-এর doubling-কেও “O(n log n)” বলে দেয়।
এই বিষয়ে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| doubling-এ i + k ≥ n হলে দ্বিতীয় rank 0 ধরা, যা আসল অক্ষরের rank-এর সাথে মিশে যায় | “aaaa” আর “abab” দিয়ে naive sort-এর সাথে মেলান |
Kasai-এ h-- নেই, বা rank 0-তে h রিসেট নেই | “aaaa…a” (n = 105) চালিয়ে সময় দেখুন; ছোট ইনপুটে naive LCP-র সাথে মেলান |
| binary search-এ পুরো suffix তুলনা, ফলে ঘটনা বাদ | “aaaa”-তে “aa” খুঁজুন — উত্তর ৩টি ঘটনা |
| longest common substring-এ separator ছাড়া জোড়া, বা একই string-এর জোড়া গোনা | s = “aa”, t = “b” — উত্তর খালি হওয়া উচিত |
| Aho-Corasick-এ শুধু বর্তমান node রিপোর্ট, output link বাদ | {he, she} দিয়ে “she” খুঁজুন — দুটি ঘটনা হওয়া উচিত |
| “O(n + m)” দাবি, z বাদ দিয়ে; বা DFA-রূপের O(m·σ) জায়গা না বলা | {a, aa, aaa} আর “aaaa…a” দিয়ে আউটপুটের আকার গুনুন |
AI-কে সমাধান লিখে দিতে না বলে, আপনার কোড আর হাতে করা trace যাচাই করতে বলুন:
Suffix structure-এর কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Suffix tree ১৯৭৩ সালেই O(n)-এ বানানো যেত — তাহলে ১৯৯০ সালে Manber আর Myers কেন suffix array প্রস্তাব করলেন?
তাত্ত্বিকভাবে suffix tree প্রায় নিখুঁত: O(n) জায়গা, O(n) নির্মাণ, আর pattern খোঁজা O(m). কিন্তু “O(n) জায়গা”-র ভেতরের ধ্রুবকটি বড়। প্রতিটি node-এ সন্তানদের pointer, edge-এর label-এর দুটি index, আর Ukkonen বা McCreight-এর পদ্ধতির জন্য একটি suffix link — সাবধানে বানালেও অক্ষরপ্রতি সাধারণত ১০ থেকে ২০ byte বা তারও বেশি। ১৯৮০-এর দশকের শেষে Udi Manber আর Gene Myers (তখন University of Arizona-য়) বড় লেখা আর জিনগত ক্রম নিয়ে কাজ করছিলেন, যেখানে মেমরিই ছিল আসল সীমা।
তাঁদের প্রস্তাব (SODA ১৯৯০, পরে SIAM Journal on Computing ১৯৯৩) চমৎকারভাবে সরল: tree নয়, শুধু suffix-গুলোর শুরুর index সাজানো অবস্থায় — অক্ষরপ্রতি একটি পূর্ণসংখ্যা (৪ byte), সাথে LCP-র তথ্য রাখলে আরও কয়েক byte. খোঁজা O(m log n), আর LCP-র সাহায্যে O(m + log n). নির্মাণের জন্য তাঁরা দেন এই পর্বের prefix doubling, O(n log n). অর্থাৎ তাঁরা জেনেশুনে তাত্ত্বিক সেরা খরচ থেকে একটি log গুণক ছেড়ে দিলেন, বিনিময়ে মেমরি কয়েক গুণ কমল আর কোড হলো সরল ও cache-বান্ধব — একটানা অ্যারে, কোনো pointer-এর পেছনে দৌড় নেই।
পরের দশকে দেখা গেল, ছাড়টি স্থায়ী নয়। ২০০১ সালে Kasai আর সহলেখকেরা O(n)-এ LCP দেন; ২০০৩ সালে কয়েকটি দল প্রায় একসাথে O(n)-এ suffix array বানানোর পদ্ধতি প্রকাশ করে (Kärkkäinen ও Sanders-এর DC3, আর Kim ও সহলেখক, Ko ও Aluru-র আলাদা পদ্ধতি); ২০০৪ সালে Abouelhoda, Kurtz ও Ohlebusch দেখান, “enhanced suffix array” (SA + LCP + আরও কিছু অ্যারে) suffix tree-র প্রায় সব অ্যালগরিদম একই সময়ে চালাতে পারে। আজ বেশিরভাগ বাস্তব সিস্টেম suffix tree-র বদলে suffix array বা তার সংকুচিত রূপ ব্যবহার করে।
লক্ষ করুন, suffix array আসলে suffix tree-র leaf-গুলোকে lexicographic DFS-এর ক্রমে পড়া, আর LCP array tree-র শাখা-node-গুলোর গভীরতা — একই তথ্য, অন্য আকারে। পর্ব ৩১-এর ভাবনার প্রশ্নে যে ক্রম দেখেছেন (trie → suffix tree → suffix array → FM-index), প্রতিটি ধাপ একই তথ্যকে আরও চ্যাপ্টা আর ঘন করে।
মূল উপলব্ধি: Big-O-র ধ্রুবক আর মেমরির আকৃতি বাস্তবে বড় ভূমিকা রাখে। একটু ধীর কিন্তু অনেক ছোট ও সরল স্ট্রাকচার প্রায়ই জেতে — আর পরে গবেষণা সেই ধীরতাটুকুও প্রায়ই মুছে দেয়।
প্র ০২Aho আর Corasick ১৯৭৫ সালে Bell Labs-এ কোন সমস্যা সমাধান করছিলেন, আর অ্যালগরিদমটি KMP-র সাথে কীভাবে যুক্ত?
তাঁদের প্রবন্ধের নামই গল্পটি বলে: “Efficient string matching: an aid to bibliographic search” (Communications of the ACM, ১৯৭৫). Bell Labs-এ গ্রন্থপঞ্জি খোঁজার একটি প্রোগ্রামে ব্যবহারকারী অনেকগুলো শব্দ দিতেন, আর প্রোগ্রামটি প্রবন্ধের তথ্যভান্ডারে সেগুলো খুঁজত। আগের পদ্ধতিতে শব্দগুলো কার্যত আলাদা আলাদা খোঁজা হতো, তাই শব্দ বাড়লে সময়ও বাড়ত। Alfred Aho প্রস্তাব করেন সব শব্দ মিলিয়ে একটি যন্ত্র (automaton) বানাতে, যা লেখা একবার পড়েই সব শব্দ খোঁজে। নতুন পদ্ধতিতে বড় খোঁজাগুলো আগের চেয়ে কয়েক গুণ দ্রুত হয়।
KMP (Knuth, Morris আর Pratt — প্রকাশ ১৯৭৭, যদিও ধারণাটি কয়েক বছর আগে থেকেই প্রচলিত ছিল) একটি pattern-এর জন্য “failure function” বানায়: এতক্ষণ যা মিলেছে, তার সবচেয়ে লম্বা proper suffix যা pattern-এরও prefix. Aho-Corasick ঠিক এই ধারণাটির অনেক-pattern রূপ: একটি pattern মানে trie-র একটি সোজা শিকল, আর তখন trie-র failure link হুবহু KMP-র failure function. অনেক pattern হলে শিকলটি শাখা-প্রশাখায় ছড়ানো একটি trie হয়ে যায়, আর “prefix” মানে হয় “trie-র কোনো node”. দুটির amortized বিশ্লেষণও একই: প্রতিটি fail-লাফ গভীরতা কমায়, আর গভীরতা অক্ষরপ্রতি বড়জোর ১ বাড়ে।
Aho নিজে পরে Unix-এর জন্য egrep আর fgrep লেখেন; fgrep (fixed-string grep) এই অ্যালগরিদমই ব্যবহার করত, আর egrep regular expression থেকে DFA বানানোর ধারণার ওপর। তিনি “AWK” ভাষারও তিন স্রষ্টার একজন (নামের A), আর Jeffrey Ullman-এর সাথে compiler-এর বিখ্যাত “Dragon Book”-এর লেখক — ২০২০ সালের Turing Award তাঁরা দুজনে পেয়েছেন। অর্থাৎ একটি গ্রন্থাগারের ব্যবহারিক সমস্যা থেকে এসেছে এমন একটি অ্যালগরিদম, যা আজ নিরাপত্তা, জীববিজ্ঞান আর টেক্সট-প্রক্রিয়াকরণের ভিত্তি।
মূল উপলব্ধি: Aho-Corasick হলো KMP-র failure function-কে একটি trie-তে ছড়িয়ে দেওয়া। একটি ভালো ধারণা যখন সঠিকভাবে সাধারণীকরণ করা যায়, তখন এক pattern-এর সমাধান হাজার pattern-এর সমাধান হয়ে ওঠে।
প্র ০৩একটি antivirus বা IDS-এ লাখো signature — Aho-Corasick-এর নকশায় বাস্তবে কোন কোন সমস্যা আসে?
প্রতিরক্ষামূলক নিরাপত্তায় signature-ভিত্তিক খোঁজা এখনো একটি মৌলিক স্তর: পরিচিত ক্ষতিকর ফাইলের বৈশিষ্ট্যপূর্ণ byte-সারি বা পরিচিত আক্রমণের packet-এর অংশ একটি তালিকায় থাকে, আর প্রতিটি ফাইল বা packet সেই তালিকার সাথে মেলানো হয়। Signature লাখের ঘরে, আর নেটওয়ার্কে ডেটা আসে সেকেন্ডে কয়েক গিগাবিট — তাই প্রতিটি signature আলাদা খোঁজা অসম্ভব। Aho-Corasick এখানে স্বাভাবিক উত্তর, কারণ খরচ প্রায় শুধু ডেটার দৈর্ঘ্যের ওপর নির্ভর করে, signature-এর সংখ্যার ওপর নয়।
প্রথম বাস্তব সমস্যা মেমরি. এখানে alphabet হলো byte, σ = ২৫৬. লাখো signature-এর trie-তে কয়েক কোটি node হতে পারে, আর পূর্ণ DFA-রূপে প্রতিটি node-এ ২৫৬টি ঘর — বহু GB. তাই বাস্তব সিস্টেম node-এর সন্তান ঘন বা পাতলা অনুযায়ী আলাদা উপায়ে রাখে (পর্ব ৩১-এর array, hash map আর sorted vector-এর মতো সিদ্ধান্ত), বা শুধু fail link রেখে DFA বাদ দেয়। মেমরি কমলে cache-এ বেশি অংশ ধরে, যা গতিও বাড়ায় — Snort-এর মতো IDS-এ কয়েক ধরনের Aho-Corasick বাস্তবায়ন বেছে নেওয়ার সুযোগ থাকে ঠিক এই কারণেই।
দ্বিতীয় সমস্যা স্ট্রিম. একটি signature দুটি packet-এর সীমানায় ভাগ হয়ে আসতে পারে। Aho-Corasick-এর state শুধু একটি node নম্বর, তাই এক packet শেষে state মনে রেখে পরের packet-এ সেখান থেকে চালালেই হয় — অ্যালগরিদমটি স্বভাবতই streaming. তৃতীয় সমস্যা ভুল-সতর্কতা আর জটিল নিয়ম: অনেক নিয়মে wildcard, “এই অংশের পরে ২০ byte-এর মধ্যে ওই অংশ” ধরনের শর্ত থাকে। তখন সাধারণত Aho-Corasick দিয়ে প্রথমে নিয়মের স্থির অংশগুলো দ্রুত ছেঁকে নেওয়া হয়, তারপর অল্প কিছু প্রার্থীর ওপর ধীর কিন্তু বিস্তারিত যাচাই — দুই স্তরের নকশা।
আরেকটি সীমা মনে রাখা দরকার: signature শুধু চেনা জিনিস ধরে। নতুন বা রূপ-বদলানো ক্ষতিকর কোড ধরতে আচরণ-বিশ্লেষণের মতো অন্য স্তর লাগে। তাই Aho-Corasick এখানে একটি দ্রুত প্রথম ছাঁকনি, পুরো প্রতিরক্ষা নয়।
মূল উপলব্ধি: কাগজে O(n + m + z) সুন্দর, কিন্তু বাস্তবে মেমরির আকার, cache, স্ট্রিমের সীমানা আর নিয়মের জটিলতা নকশা ঠিক করে। ভালো সিস্টেম অ্যালগরিদমকে একটি বড় পাইপলাইনের দ্রুত প্রথম ধাপ হিসেবে ব্যবহার করে।
প্র ০৪Burrows-Wheeler transform কেন লেখাকে সংকোচনযোগ্য করে, আর suffix array-র সাথে তার সম্পর্ক কী?
১৯৯৪ সালে Digital Equipment Corporation-এর গবেষণাগারের একটি প্রতিবেদনে Michael Burrows আর David Wheeler একটি অদ্ভুত রূপান্তর প্রকাশ করেন। লেখার শেষে একটি বিশেষ চিহ্ন “$” জুড়ুন (যা সব অক্ষরের চেয়ে ছোট), তারপর লেখার সব ঘূর্ণন (rotation) — “banana$”, “anana$b”, “nana$ba”… — অভিধানের ক্রমে সাজান, আর প্রতিটির শেষ অক্ষর পরপর পড়ুন। banana$-এর BWT হলো “annb$aa”. লক্ষ করুন a-গুলো আর n-গুলো জড়ো হয়েছে।
কেন জড়ো হয়? সাজানো ক্রমে একই রকম শুরুর ঘূর্ণনগুলো পাশাপাশি আসে — যেমন ইংরেজি লেখায় “he ” দিয়ে শুরু সব ঘূর্ণন একসাথে। আর তাদের শেষ অক্ষর হলো সেই শুরুর ঠিক আগের অক্ষর — “he”-র আগে প্রায়ই “t” (the). অর্থাৎ BWT প্রতিটি প্রসঙ্গের আগের অক্ষরগুলো একসাথে করে, আর বাস্তব লেখায় একই প্রসঙ্গের আগে প্রায়ই একই অক্ষর থাকে। ফলে লম্বা একই-অক্ষরের সারি তৈরি হয়, যা move-to-front, run-length আর Huffman coding (পর্ব ৫১) দিয়ে খুব ভালো সংকুচিত হয়। Julian Seward-এর bzip2 (১৯৯৬) ঠিক এই পাইপলাইন ব্যবহার করে, আর সাধারণ লেখায় প্রায়ই gzip-এর চেয়ে ছোট ফাইল দেয়।
আসল চমক হলো, এটি উল্টানো যায় — শুধু শেষ কলামটি থেকে পুরো লেখা ফেরত পাওয়া যায়, কারণ প্রথম কলাম হলো শেষ কলামের সাজানো রূপ, আর একই অক্ষরের ঘটনাগুলো দুই কলামে একই ক্রমে থাকে (“LF-mapping”). Suffix array-র সাথে সম্পর্ক সরাসরি: “$”-যুক্ত লেখায় ঘূর্ণন সাজানো আর suffix সাজানো একই ক্রম দেয়, তাই BWT[r] = T[sa[r] − 1] (sa[r] = 0 হলে “$”). অর্থাৎ suffix array থাকলে BWT এক লাইনের লুপ।
২০০০ সালে Ferragina আর Manzini দেখান, BWT-এর ওপর অল্প কিছু গণনা-টেবিল রাখলে pattern P-এর অক্ষরগুলো পেছন থেকে একে একে নিয়ে (“backward search”) suffix array-র সেই টানা অংশটি বের করা যায়, যেটি অংশ ৪-এ binary search দিয়ে পেয়েছিলেন — কিন্তু পুরো suffix array না রেখেই। এই FM-index-ই BWA (Heng Li ও Richard Durbin, ২০০৯) আর Bowtie-র (Ben Langmead ও সহলেখক, ২০০৯) মতো DNA aligner-এর ইঞ্জিন, যারা কোটি কোটি ছোট DNA টুকরো genome-এ বসায়।
মূল উপলব্ধি: BWT হলো সাজানো suffix-এর আরেকটি চেহারা। একই সাজানো ক্রম একদিকে সংকোচন দেয়, অন্যদিকে খোঁজার index — অ্যালগরিদমের একটি ধারণা প্রায়ই সম্পূর্ণ ভিন্ন দুটি সমস্যার সমাধান হয়ে যায়।
প্র ০৫কনটেস্টে substring-এর সমস্যায় suffix array, string hashing আর suffix automaton — কখন কোনটি বাছবেন?
ICPC বা Codeforces-এর string সমস্যায় প্রায়ই একাধিক পথ খোলা থাকে, আর বাছাইটি নির্ভর করে প্রশ্নের ধরন, ইনপুটের আকার আর কোড লেখার ঝুঁকির ওপর। প্রথম বিকল্প polynomial hashing + binary search (পর্ব ৫৮-এর Rabin-Karp ধারণা): দুটি substring সমান কি না O(1)-এ, তাই “দুটি suffix-এর LCP” binary search-এ O(log n). কোড ছোট, ভুলের সুযোগ কম; কিন্তু উত্তর সম্ভাব্যতাভিত্তিক — collision-এর ঝুঁকি থাকে, আর কিছু প্ল্যাটফর্মে অন্যরা ইচ্ছা করে hash ভাঙার ইনপুট দিতে পারে (পর্ব ৩২-এর anti-hash আলোচনা). দুটি আলাদা modulus বা এলোমেলো base নিলে ঝুঁকি অনেক কমে।
দ্বিতীয় বিকল্প suffix array + LCP: নিশ্চিত (deterministic) উত্তর, O(n log n) প্রস্তুতি, আর LCP-র ওপর sparse table দিলে যেকোনো দুটি suffix-এর LCP O(1). আলাদা substring গোনা, k-তম lexicographic substring, অনেক string-এর common substring, বা “অন্তত k বার আসে এমন সবচেয়ে লম্বা substring” (k টি পাশাপাশি সারির ভেতরের k − 1 টি lcp-র minimum, sliding window-এ) — এ ধরনের প্রশ্নে এটি স্বাভাবিক। অনেক দল একটি পরীক্ষিত suffix array-র কোড নিজেদের টিম নোটবুকে রাখে।
তৃতীয় বিকল্প suffix automaton (Blumer ও সহলেখকেরা, ১৯৮৫): লেখার সব substring চেনে এমন সবচেয়ে ছোট automaton, যার state বড়জোর 2n − 1, আর বাঁ থেকে ডানে অক্ষর যোগ করতে করতে amortized O(n)-এ বানানো যায়। আলাদা substring গোনা, প্রতিটি substring কতবার আসে, বা একটি লেখা ধরে আরেকটির সাথে longest common substring — এগুলো এতে খুব স্বাভাবিক, আর লেখার শেষে অক্ষর যোগ হতে থাকলেও কাজ করে। কিন্তু এর গঠন বোঝা কঠিন, আর ভুল হলে debug করা আরও কঠিন।
বাস্তব পরামর্শ: আগে ভাবুন প্রশ্নটি “sorted order” চায় কি না (k-তম ছোট substring, অভিধানের ক্রম) — তাহলে suffix array. শুধু সমতা-পরীক্ষা হলে hashing প্রায়ই দ্রুততম পথ। Online যোগ বা occurrence-গণনা হলে suffix automaton. আর যে পথই নিন, ছোট ইনপুটে brute force-এর সাথে stress test চালান — string-এর কোডে off-by-one সবচেয়ে প্রচলিত শত্রু।
মূল উপলব্ধি: একই সমস্যার একাধিক সঠিক সমাধান থাকতে পারে; সেরাটি বাছাই করে প্রশ্নের গঠন, নিশ্চয়তার প্রয়োজন আর আপনার নিজের পরীক্ষিত কোড। একটি অস্ত্র ভালোভাবে জানা দশটি আধাআধি জানার চেয়ে বেশি কাজের।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: “abab”-এর suffix array, rank array আর LCP array বের করুন (0-based). তারপর আলাদা substring-এর সংখ্যা আর longest repeated substring বলুন।
suffix: abab (0), bab (1), ab (2), b (3). সাজানো: ab (2), abab (0), b (3), bab (1) — তাই SA = [2, 0, 3, 1], rank = [1, 3, 0, 2]. পাশাপাশি জোড়া: ab/abab → 2, abab/b → 0, b/bab → 1; LCP = [0, 2, 0, 1].
আলাদা substring = 4·5/2 − (2 + 0 + 1) = 10 − 3 = 7 (a, b, ab, ba, aba, bab, abab). সর্বোচ্চ lcp = 2, সারি ১-এ — longest repeated substring “ab”.
-
Aho-Corasick হাতে চালান: pattern {ab, bc, abc}. Trie আঁকুন, node নম্বর দিন (ঢোকানোর ক্রমে), প্রতিটি node-এর failure link আর output link বের করুন। তারপর লেখা “abcbc”-এ প্রতিটি অক্ষরের পর state আর পাওয়া ঘটনাগুলো লিখুন।
node: 1 = a, 2 = ab, 3 = b, 4 = bc, 5 = abc. fail: f(1) = 0, f(3) = 0, f(2) = 3 (“ab”-এর শেষে “b”), f(4) = 0, f(5) = 4 (“abc”-এর শেষে “bc”). out(5) = 4, কারণ “bc” নিজে pattern; বাকিদের output link নেই।
খোঁজা: a → 1; b → 2, “ab” শুরু 0; c → 5, “abc” শুরু 0, আর out(5) ধরে “bc” শুরু 1; b → node 5-এ b নেই, fail 4-এ নেই, fail 0-তে আছে → 3; c → 4, “bc” শুরু 3. মোট ৪টি ঘটনা: ab@0, abc@0, bc@1, bc@3.
-
ভেবে বলুন: (ক) দৈর্ঘ্য n-এর “aaa…a”-র suffix array আর LCP array কী? সূত্র দিয়ে আলাদা substring গুনুন। (খ) সব অক্ষর আলাদা (যেমন “abcde”) হলে ΣLCP কত, আর আলাদা substring কয়টি? (গ) Kasai-এ “if (h > 0) h--” লাইনটির বদলে h = 0 লিখলে উত্তর বা সময়ের কী হবে?
(ক) ছোট suffix আগে: SA = [n−1, n−2, …, 0], LCP = [0, 1, 2, …, n−1]. ΣLCP = n(n−1)/2, তাই আলাদা substring = n(n+1)/2 − n(n−1)/2 = n — a, aa, …, n টি a. (খ) পাশাপাশি দুটি suffix-এর প্রথম অক্ষরই আলাদা, তাই সব lcp 0 আর সব n(n+1)/2 টি substring আলাদা — “abcde”-তে ১৫.
(গ) উত্তর ঠিকই থাকবে, কারণ প্রতিবার শূন্য থেকে মেলালেও সঠিক LCP পাওয়া যায় — শুধু লেমার সুবিধা হারায়। কিন্তু সময় হয় Σlcp + n, যা “aaa…a”-তে Θ(n²). এটিই AI-এর একটি প্রচলিত ভুল, যা ছোট পরীক্ষায় ধরা পড়ে না।
-
কোড লিখুন: দুটি string s আর t-এর longest common substring বের করার একটি ফাংশন লিখুন, অংশ ৫-এর
suffixArrayআরkasaiব্যবহার করে। ধরে নিন দুটিতেই শুধু ছোট হাতের অক্ষর। Time ও space complexity বলুন, আর “banana”, “ananas” দিয়ে মিলিয়ে দেখুন।“#” (code 35) সব ছোট হাতের অক্ষরের চেয়ে ছোট আর দুটি string-এর কোনোটিতে নেই। পাশাপাশি দুটি সারির একটি s-এর ভেতরে (sa < |s|) আর অন্যটি t-এর ভেতরে (sa > |s|) শুরু হলে তাদের lcp একটি প্রার্থী:
string longestCommonSubstring(const string& s, const string& t) { string u = s + '#' + t; // '#' occurs once, so no LCP crosses it auto sa = suffixArray(u); auto lcp = kasai(u, sa); int n = s.size(), best = 0, at = 0; for (int r = 1; r < (int)u.size(); ++r) if ((sa[r - 1] < n) != (sa[r] < n) && lcp[r] > best) { best = lcp[r]; at = sa[r]; } return u.substr(at, best); }“banana” আর “ananas”-এ ফল “anana” (দৈর্ঘ্য ৫). “#”-এর suffix-টি (sa = |s|) কোডে t-এর দিকে গোনা হয়, তবু সমস্যা নেই — তার প্রথম অক্ষর “#”, তাই কারও সাথে তার lcp 0. সময় O(N log² N) এই
suffixArray-এ (radix sort দিলে O(N log N)), N = |s| + |t| + 1; জায়গা O(N). অংশ ৬-এর যুক্তি বলে শুধু পাশাপাশি সারি দেখাই যথেষ্ট।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমSuffix array থেকে Kasai-এর পদ্ধতিতে LCP array বানানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- suffix array উল্টে rank array বানানো: rank[sa[r]] = r
- h = 0 নিয়ে লেখার ক্রমে i = 0, 1, 2, … ঘোরা
- rank[i]-এর ঠিক আগের সারির suffix j বের করা
- h-তম অক্ষর থেকে শুরু করে i আর j-এর অক্ষর মেলানো, প্রতিটি মিলে h বাড়ানো
- lcp[rank[i]] = h লিখে h থেকে ১ বাদ দেওয়া (যদি h > 0)
“banana” string-এর suffix array (0-based শুরুর index, suffix-গুলোর অভিধানের ক্রমে) কোনটি?
- [0, 1, 2, 3, 4, 5]
- [5, 3, 1, 0, 4, 2]
- [3, 2, 5, 1, 4, 0]
- [5, 1, 3, 0, 4, 2]
Prefix doubling-এ প্রতিটি ধাপে জোড়াগুলো comparison sort-এর বদলে radix sort দিয়ে সাজালে দৈর্ঘ্য n-এর লেখায় suffix array বানানোর মোট সময় কত?
- O(n² log n)
- O(n log² n)
- O(n log n)
- O(n)
দৈর্ঘ্য n-এর একটি string-এর LCP array-র সব মানের যোগফল S হলে string-টিতে কতগুলো আলাদা (distinct) খালি-নয় substring আছে?
- n(n+1)/2 − S
- n(n+1)/2 + S
- n² − S
- n − S
Kasai-এর অ্যালগরিদম LCP array O(n) সময়ে বানাতে পারে মূলত কোন তথ্যের জন্য?
- প্রতিটি suffix-এর hash আগে থেকে রাখা থাকে
- সাজানো ক্রমে পাশাপাশি দুটি LCP-র পার্থক্য সর্বোচ্চ ১
- প্রতিটি LCP-র মান log n-এর বেশি হয় না
- লেখার ক্রমে পরের suffix-এর LCP আগেরটির চেয়ে বড়জোর ১ কম
Pattern {he, she, his, hers}-এর Aho-Corasick trie-তে “she” node-এর failure link কোন node-এ যায়?
- root (খালি string)
- “he”
- “h”
- “hers”
k টি pattern-এর মোট দৈর্ঘ্য m, লেখার দৈর্ঘ্য n, আর মোট ঘটনা z হলে Aho-Corasick-এ (trie বানানোসহ) সব ঘটনা খুঁজতে সময় কত (alphabet ধ্রুবক ধরে)?
- O(k·n + m)
- O(n·m)
- O(n + m + z)
- O(n + m), z যত বড়ই হোক
দুটি string s আর t-এর longest common substring suffix array দিয়ে খুঁজতে কেন s আর t-এর মাঝে এমন একটি অক্ষর বসানো হয় যা দুটির কোনোটিতে নেই?
- যাতে কোনো দুটি suffix-এর common prefix s থেকে t-তে গড়িয়ে না যায়
- যাতে suffix array-র আকার অর্ধেক হয়
- যাতে prefix doubling-এর লুপ থামে
- কারণ separator ছাড়া Kasai-এর অ্যালগরিদম চলে না
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- প্রতিটি substring কোনো suffix-এর prefix. Suffix trie Θ(n²), suffix tree O(n) কিন্তু ভারী (Weiner, McCreight, Ukkonen), suffix array অক্ষরপ্রতি একটি int (Manber ও Myers, ১৯৯০).
- Prefix doubling: (rank[i], rank[i + k]) জোড়া সাজিয়ে প্রথম 2k অক্ষরের rank; comparison sort-এ O(n log² n), radix-এ O(n log n); SA-IS/DC3-এ O(n).
- Pattern খোঁজা: P-এর ঘটনাগুলো SA-র একটি টানা অংশ — দুটি binary search, O(m log n); প্রতিটি তুলনায় শুধু প্রথম m অক্ষর।
- Kasai: লেখার ক্রমে চললে LCP এক ধাপে বড়জোর ১ কমে, তাই O(n). আলাদা substring = n(n+1)/2 − ΣLCP; longest repeated = max LCP; common substring = separator দিয়ে জুড়ে আলাদা-উৎসের পাশাপাশি জোড়া।
- Aho-Corasick: pattern-এর trie + BFS-এ failure link + output link; লেখা একবার পড়ে O(n + m + z). Antivirus, IDS, spam filter, fgrep — আর BWT/FM-index একই সাজানো suffix-এর আরেক রূপ।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৬০ · Amortized analysis — aggregate, accounting আর potentialপরবর্তী পর্বKasai আর Aho-Corasick-এর “মোট লাফ” যুক্তির পূর্ণ তত্ত্ব।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ৫৮ · String matching — Rabin-Karp, KMP আর Z algorithmআগের ভিত্তিএকটি pattern-এর খোঁজা আর failure function — Aho-Corasick-এর পূর্বসূরি।
- পর্ব ৩১ · Trie — শব্দ আর prefix খোঁজার treeআগের ভিত্তিsuffix trie আর Aho-Corasick-এর trie যেখান থেকে আসে।
- পর্ব ৩৭ · Sparse table, sqrt decomposition আর Mo’s algorithmসাথে কাজে লাগেLCP array-র ওপর O(1) range minimum — যেকোনো দুটি suffix-এর LCP.
- C++ প্রোগ্রামিং · STL algorithmভাষার দিক
sort,lower_boundআর comparator — suffix array-র কোডের হাতিয়ার। - Python প্রোগ্রামিং · Stringপাশের ট্র্যাকslicing, তুলনা আর string-এর পদ্ধতি — suffix-এর কাজে Python-এর দিক।