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

String matching — Rabin-Karp, KMP আর Z algorithm

String Matching — Naive Search, Rabin-Karp Rolling Hash, Knuth-Morris-Pratt Prefix Function & the Z Algorithm

Taught in Bangla, this lesson tackles finding a pattern inside a long text. It starts with naive search, then turns windows into numbers with the Rabin-Karp rolling hash, builds the KMP prefix function from borders and searches without moving back in the text, computes the Z array, runs all three on the same input for identical answers, and covers applications, with C++ and Python code.

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

  • সরল pattern matching কোন ইনপুটে Θ(n·m) হয়, আর অপচয়টি ঠিক কোথায়
  • Rabin-Karp-এর rolling hash হাতে হিসাব করা, spurious hit চেনা, double hashing আর anti-hash test-এর ঝুঁকি বোঝা
  • KMP-র prefix function হাতে বের করা, এর শুদ্ধতার যুক্তি আর matching-এর amortized O(n + m) প্রমাণ
  • Z algorithm-এর Z-box কৌশল আর P + '$' + T দিয়ে খোঁজা
  • period, compression যাচাই, Boyer-Moore-এর bad character লাফ আর বাংলা/Unicode লেখায় খোঁজার ফাঁদ

১ · সমস্যাটি — লম্বা লেখার ভেতরে ছোট একটি টুকরো খোঁজা

ব্রাউজারে Ctrl+F চেপে “bKash” লিখলেন — পুরো পাতায় শব্দটি যেখানে যেখানে আছে, হলুদ হয়ে গেল। পর্ব ১১-এ (String) এই কাজের নাম দেখেছিলেন pattern matching: n দৈর্ঘ্যের একটি লেখা (text) T-এর ভেতরে m দৈর্ঘ্যের pattern P কোন কোন index থেকে হুবহু শুরু হয়, সব খুঁজে বের করা। ছোট শোনালেও এই প্রশ্নের পেছনে দাঁড়িয়ে আছে অনেক বড় বড় ব্যবস্থা:

লেখা আর কোড

এডিটরের Find/Replace, লগ-ফাইলে error খোঁজা (grep), থিসিস বা প্রোগ্রামিং অ্যাসাইনমেন্টে নকল (plagiarism) ধরার সফটওয়্যার — সবাই বারবার একটি লেখার ভেতরে টুকরো খোঁজে।

Ctrl+F, grep

DNA আর জীববিজ্ঞান

DNA হলো A, C, G, T — মাত্র চারটি অক্ষরের কোটি কোটি ঘরের লেখা। একটি জিনের নির্দিষ্ট ক্রম কোথায় আছে, খোঁজা মানেই string matching. চার অক্ষরে পুনরাবৃত্তি প্রচুর, তাই সরল পদ্ধতির খারাপ ক্ষেত্র এখানে বাস্তব।

A C G T

নিরাপত্তা (রক্ষার দিক)

অ্যান্টিভাইরাস প্রতিটি ফাইলের বাইটে পরিচিত ক্ষতিকর প্রোগ্রামের “signature” (চেনা বাইট-ক্রম) খোঁজে; নেটওয়ার্কের intrusion detection system প্রতিটি প্যাকেটে আক্রমণের চেনা নকশা খোঁজে। এখানে গতি মানেই সুরক্ষা।

virus signature

সরল পদ্ধতি আর তার সবচেয়ে খারাপ ক্ষেত্র

পর্ব ১১-এর naive string matching মনে করুন: P-কে T-এর প্রতিটি সম্ভাব্য শুরুর ঘর s-এ (s = 0 থেকে n − m) বসিয়ে বাঁ থেকে অক্ষর মেলান, অমিল পেলেই পরের s. মোট n − m + 1টি shift, প্রতিটিতে সর্বোচ্চ m তুলনা — O(n·m). সাধারণ ইংরেজি লেখায় বেশিরভাগ shift প্রথম অক্ষরেই বাতিল হয়, তাই কাজ কম। কিন্তু worst case-টি দেখুন — T = aaaaaaaa (n = 8), P = aaab (m = 4):

T:      a a a a a a a a
s = 0:  a a a b            3 matches, then a != b   -> 4 comparisons
s = 1:    a a a b          again 3 matches + 1      -> 4
s = 2:      a a a b                                 -> 4
s = 3:        a a a b                               -> 4
s = 4:          a a a b                             -> 4
total = (n - m + 1) * m = 5 * 4 = 20 comparisons, 0 matches

প্রতিটি shift-এ আমরা আবার সেই একই aaa মিলিয়ে দেখি, যা আগের shift-এ ইতিমধ্যে জানা ছিল। n = 10⁶ আর m = 10⁵ হলে তুলনা প্রায় 9 × 10¹⁰ — কনটেস্টের ১–২ সেকেন্ডে অসম্ভব। m = n/2 নিলে (n/2 + 1)·(n/2) ≈ n²/4, অর্থাৎ Θ(n²). এই অপচয় দূর করার তিনটি ভিন্ন দর্শন এই পর্বের বিষয়:

কৌশলমূল চিন্তাসময়
Rabin-Karpজানালাকে একটি সংখ্যায় বদলানো; সংখ্যা মিললে তবেই অক্ষর মেলানোপ্রত্যাশিত O(n + m), সবচেয়ে খারাপে O(n·m)
KMPpattern নিজের সাথে কোথায় মেলে, আগে থেকে জেনে রাখা; text-এ কখনো পিছিয়ে না যাওয়াসবসময় O(n + m)
Z algorithmপ্রতিটি ঘর থেকে শুরু করে prefix-এর সাথে কতদূর মেলে, আগের জানা অংশ কাজে লাগিয়ে বের করাসবসময় O(n + m)

এখানে n + m-এর চেয়ে ভালো করা সম্ভব নয়: সবচেয়ে খারাপ ক্ষেত্রে text-এর প্রায় প্রতিটি অক্ষর (অন্তত n − m + ১টি) না দেখলে নিশ্চিত হওয়া যায় না যে সেখানে মিল নেই, আর pattern-টি পুরো না পড়লে জানাই যায় না কী খুঁজছি। তাই O(n + m) মানে সর্বোত্তম — নিম্নসীমা Ω(n + m)-এর সমান।

২ · Rabin-Karp — জানালাকে একটি সংখ্যায় বদলানো

দোকানে মাল মেলানোর সময় অভিজ্ঞ দোকানি প্রতিটি বস্তা খুলে দেখেন না; আগে ওজন মেলান। ওজন না মিললে নিশ্চিত — এটা সেই বস্তা নয়। ওজন মিললে তবেই খুলে দেখেন, কারণ ভিন্ন জিনিসেরও ওজন সমান হতে পারে। Rabin-Karp অ্যালগরিদম ঠিক এভাবে চলে: প্রতিটি m দৈর্ঘ্যের জানালার একটি “ওজন” — hash — বের করে, আর শুধু hash মিললে অক্ষর ধরে যাচাই করে।

hash হিসেবে নেওয়া হয় polynomial hash: অক্ষরগুলোকে একটি base B-এর সংখ্যার অঙ্ক ধরে, একটি মৌলিক সংখ্যা M-এর ভাগশেষে (modular arithmetic):

h(x₀x₁…xm−1) = (x₀·Bm−1 + x₁·Bm−2 + … + xm−1) mod M

আসল কৌশল rolling hash: জানালা এক ঘর ডানে সরালে পুরো hash নতুন করে হিসাব করতে হয় না। বাঁয়ের অক্ষরের অবদান (xs·Bm−1) বাদ দিন, বাকিটা B দিয়ে গুণ করুন (সব অঙ্ক এক ঘর বাঁয়ে সরল), নতুন ডানের অক্ষর যোগ করুন:

hs+1 = ((hs − T[s]·Bm−1)·B + T[s+m]) mod M

Bm−1 mod M একবারই হিসাব করে রাখা যায়, তাই প্রতিটি সরানো O(1). হাতে হিসাবের সুবিধার জন্য খুব ছোট সংখ্যা নিচ্ছি: a = 1, b = 2, c = 3, B = 10, M = 13 — তখন hash হলো অঙ্কগুলো দিয়ে বানানো দশমিক সংখ্যাটির ১৩ দিয়ে ভাগশেষ। T = abccabc, P = abc; h(P) = 123 mod 13 = 6, আর B² mod 13 = 100 mod 13 = 9.

জানালাa=1, b=2, c=3

            
hash-এর হিসাব (mod 13)
জানালা s
হিসাব
জানালার hash
h(P) = 6-এর সমান?
অক্ষর ধরে যাচাই
  1. প্রথম জানালা সরাসরি। abc → 1, 2, 3 → সংখ্যা 123, আর 123 = 9·13 + 6, তাই hash 6. এটি h(P)-এর সমান, তাই তিনটি অক্ষর মিলিয়ে দেখি — সত্যিই মিল: s = 0.
  2. গড়িয়ে নেওয়া (roll). বাঁয়ের a (মান 1) বাদ: 6 − 1·9 = −3 ≡ 10; গুণ 10: 100 ≡ 9; নতুন c যোগ: 9 + 3 = 12. পরীক্ষা: bcc = 233 = 17·13 + 12 — মিলেছে। 12 ≠ 6, তাই অক্ষর মেলানোর দরকারই নেই।
  3. Spurious hit. 12 − 2·9 = −6 ≡ 7; 70 ≡ 5; 5 + 1 = 6. hash মিলে গেল! কিন্তু জানালা cca (331 = 25·13 + 6) — pattern নয়। ভিন্ন লেখার একই hash থেকে আসা এই ভুয়া মিলকে বলে spurious hit. অক্ষর ধরে যাচাই করে আমরা এটি বাদ দিলাম।
  4. আবার roll. 6 − 3·9 = −21 ≡ 5; 50 ≡ 11; 11 + 2 = 13 ≡ 0. জানালা cab = 312 = 24·13, তাই 0 — ঠিক। h(P) নয়, বাদ। খেয়াল করুন, mod-এ বিয়োগের পর ঋণাত্মক হলে M যোগ করে ধনাত্মক করতে হয়।
  5. শেষ জানালা। 0 − 27 ≡ 12; 120 ≡ 3; 3 + 3 = 6. hash মিলেছে, যাচাইয়েও abc — আসল মিল: s = 4.
  6. সারাংশ। ৫টি জানালার প্রতিটির hash O(1)-এ; তিনবার hash মিলেছে, দুবার আসল মিল, একবার spurious. M = 13 খুব ছোট বলে collision সহজে হলো; বাস্তবে M ≈ 10⁹ নিলে এমন ভুয়া মিল প্রায় ঘটেই না।
Pseudocode (0-based index)

RABIN-KARP(T, P, B, M):            // n = |T|, m = |P|, m <= n
    high = B^(m-1) mod M
    hp = hash(P);  hw = hash(T[0..m-1])
    for s = 0 to n - m:
        if hw == hp and T[s..s+m-1] == P:     // verify every hash hit
            report a match at s
        if s + m < n:
            hw = ((hw - T[s] * high) * B + T[s+m]) mod M

সময়ের হিসাব — “প্রত্যাশিত” কেন

প্রথম hash দুটি O(m), প্রতিটি roll O(1), মোট O(n + m). বাকি থাকে যাচাইয়ের খরচ: প্রতিটি hash মিলের জন্য O(m). base B যদি 0 থেকে M − 1-এর মধ্যে এলোমেলোভাবে বাছা হয়, তাহলে দুটি ভিন্ন m দৈর্ঘ্যের string-এর hash সমান হওয়া মানে একটি m − 1 মাত্রার বহুপদীর মূল হলো B — আর এমন মূল সর্বোচ্চ m − 1টি। তাই একটি জানালায় spurious hit-এর সম্ভাবনা ≤ (m − 1)/M, মোট প্রত্যাশিত spurious hit ≤ n·(m − 1)/M. M ≈ 10⁹ আর n·m ≤ 10⁹-এর মতো হলে এটি ১-এরও কম। ফলে expected running time:

O(n + m + occ·m + n·m²/M)

এখানে occ = আসল মিলের সংখ্যা। শেষ পদটি বড় M-এ নগণ্য, তাই সাধারণ ক্ষেত্রে O(n + m). কিন্তু occ নিজেই বড় হতে পারে: T = aaaa…a আর P = aaa হলে প্রতিটি জানালা আসল মিল, প্রতিটিতে m তুলনা — Θ(n·m). তাই Rabin-Karp-এর সবচেয়ে খারাপ সময় O(n·m); শুধু “প্রথম মিল” চাইলে, বা যাচাই বাদ দিলে, এই সমস্যা থাকে না।

যাচাই বাদ দিলে অ্যালগরিদমের চরিত্র বদলায়। অক্ষর ধরে যাচাই করলে উত্তর সবসময় সঠিক, শুধু সময় ভাগ্যের ওপর — এটি Las Vegas ধাঁচের। যাচাই বাদ দিলে সময় নিশ্চিত O(n + m), কিন্তু খুব ছোট সম্ভাবনায় উত্তর ভুল — এটি Monte Carlo ধাঁচের। কনটেস্টে প্রায়ই দ্বিতীয়টি চলে, তখন ভুলের সম্ভাবনা কমাতে দুটি hash লাগে (নিচে)। এলোমেলো অ্যালগরিদমের এই দুই জাতের পুরো আলোচনা পর্ব ৬১-এ।

Double hashing, anti-hash test আর এলোমেলো base

একটি mod-এ M ≈ 10⁹ মানে প্রতিটি তুলনায় ভুলের সম্ভাবনা মোটামুটি 10⁻⁹. কিন্তু অনেক তুলনা হলে? ধরুন 10⁶টি substring-এর hash একটি set-এ রেখে “কয়টি আলাদা” গুনছেন। জোড়ার সংখ্যা প্রায় 5 × 10¹¹, আর birthday paradox-এর হিসাবে প্রায় √M ≈ 3 × 10⁴টি hash-এর পরেই অন্তত একটি collision-এর সম্ভাবনা ৫০%-এর কাছে পৌঁছায়। সমাধান double hashing: দুটি আলাদা মৌলিক mod (যেমন 10⁹ + 7 আর 998244353) দিয়ে দুটি hash, জোড়া হিসেবে তুলনা — কার্যত M ≈ 10¹⁸.

আরেকটি বিপদ ইচ্ছাকৃত। base আর mod যদি কোডে স্থির লেখা থাকে (যেমন B = 31, M = 2⁶⁴), প্রতিপক্ষ আগে থেকে এমন ইনপুট বানাতে পারে যার hash মিলে যায় — কনটেস্টের ভাষায় anti-hash test. Codeforces-এর মতো প্ল্যাটফর্মে hack-পর্যায়ে এভাবে বহু সমাধান ভাঙা হয়েছে; পর্ব ১১-এর ভাবনার প্রশ্নে Thue–Morse ক্রমের উদাহরণ দেখেছেন, যা 2⁶⁴ mod-এ যেকোনো বিজোড় base-এর hash ভাঙে। প্রচলিত প্রতিরক্ষা: বড় মৌলিক mod, আর base প্রোগ্রাম চলার সময় এলোমেলোভাবে বাছা (যেমন সময় বা random_device থেকে)। তখন খারাপ test আগে থেকে বানানো যায় না। hash function আর hash table-এর দিক থেকে collision-এর আলোচনা পর্ব ৩২ (Hash table)-এ।

৩ · KMP-এর মূল ধারণা — border আর prefix function

সরল পদ্ধতির অপচয় মনে করুন: P = abab দিয়ে খুঁজতে গিয়ে aba মিলিয়ে চতুর্থ অক্ষরে অমিল হলো। আমরা তখন জানি text-এর শেষ তিনটি অক্ষর aba. P-কে এক ঘর সরালে শুরু হবে ba…-র সাথে ab… তুলনা — নিশ্চিত অমিল, তুলনা না করেই জানা। দুই ঘর সরালে a-র নিচে a — মিল নিশ্চিত, আবার মেলানোর দরকার নেই। অর্থাৎ, কতদূর সরানো নিরাপদ আর কতটুকু আগে থেকেই মেলানো আছে, তা শুধু pattern-এর ওপর নির্ভর করে, text-এর ওপর নয়। তাই সেটি আগে একবার হিসাব করে রাখা যায়। এটিই Knuth–Morris–Pratt (KMP) অ্যালগরিদমের মূল কথা।

যা হিসাব করে রাখতে হবে তার নাম border: একটি string-এর এমন অংশ যা একই সাথে তার prefix আর suffix, কিন্তু পুরো string নয়। ababa-র border দুটি — a আর aba. abc-এর কোনো (খালি ছাড়া) border নেই। এবার সংজ্ঞা:

সংজ্ঞা

Prefix function π: π[i] = s-এর প্রথম i + 1টি অক্ষরের (s[0..i]) সবচেয়ে লম্বা border-এর দৈর্ঘ্য। একে failure function-ও বলে, কারণ অমিল (failure) হলে কোথায় ফিরে যেতে হবে, এটাই বলে দেয়। π[0] = 0 সবসময়।

π কীভাবে O(m)-এ বানানো যায়

দুটি পর্যবেক্ষণই যথেষ্ট। (১) s[0..i]-এর একটি border-এর শেষ অক্ষর বাদ দিলে সেটি s[0..i−1]-এর একটি border. তাই s[0..i]-এর border খুঁজতে s[0..i−1]-এর border-গুলো নিয়ে দেখতে হয়, কোনটির পরের অক্ষর s[i]-এর সমান। (২) s[0..i−1]-এর সব border একটি শিকলে পাওয়া যায়: সবচেয়ে লম্বাটি k₁ = π[i−1], তার পরেরটি π[k₁ − 1], তার পরেরটি π[π[k₁ − 1] − 1]… কারণ একটি border-এর border-ও মূল string-এর border, আর দ্বিতীয় সবচেয়ে লম্বা border অবশ্যই সবচেয়ে লম্বাটির border. তাই অ্যালগরিদম: k = π[i−1] থেকে শুরু; s[i] ≠ s[k] হলে k = π[k − 1] করে পিছিয়ে যাওয়া; মিললে π[i] = k + 1, k শূন্যে পৌঁছেও না মিললে π[i] = 0. নিচে s = ababaca:

প্রতিটি i-এর হিসাব
i, s[i]
যে k-গুলো চেষ্টা হলো
তুলনা
π[i]
π এ পর্যন্ত
s[0..i] আর তার সবচেয়ে লম্বা border

            
  1. i = 0. এক অক্ষরের string-এর কোনো proper border নেই, তাই π[0] = 0 — সংজ্ঞা থেকেই।
  2. i = 1, s[1] = b. k = π[0] = 0, অর্থাৎ বাড়ানোর মতো border খালি; শুধু দেখতে হবে s[1] = s[0] কি না। b ≠ a, তাই π[1] = 0.
  3. i = 2, s[2] = a. k = 0, s[2] = a = s[0] — খালি border-কে এক অক্ষর বাড়ানো গেল: π[2] = 1 (a).
  4. i = 3, s[3] = b. k = π[2] = 1: আগের border a-র পরের অক্ষর s[1] = b, আর s[3] = b — মিলেছে, π[3] = 2 (ab). একটিমাত্র তুলনা।
  5. i = 4, s[4] = a. k = π[3] = 2, s[2] = a = s[4] — π[4] = 3 (aba). এ পর্যন্ত k প্রতি ধাপে এক করে বেড়েছে।
  6. i = 5, s[5] = c — পিছিয়ে যাওয়ার শিকল। k = 3: s[3] = b ≠ c. পরের সবচেয়ে লম্বা border: k = π[2] = 1: s[1] = b ≠ c. আবার: k = π[0] = 0: s[0] = a ≠ c. k শূন্য, আর কোনো border নেই — π[5] = 0. তিনটি তুলনা, কিন্তু k-ও ৩ থেকে ০-তে নেমেছে।
  7. i = 6, s[6] = a. k = π[5] = 0, s[0] = a = s[6] — π[6] = 1. পুরো অ্যারে: 0 0 1 2 3 0 1. মোট অক্ষর-তুলনা ৮টি — 2m = 14-এরও কম।

কেন O(m)? k প্রতিটি i-তে সর্বোচ্চ ১ বাড়ে, তাই মোট বাড়া ≤ m − 1. প্রতিটি “পিছিয়ে যাওয়া” (k = π[k − 1]) k-কে অন্তত ১ কমায় (π[k − 1] < k), আর k কখনো ঋণাত্মক হয় না। যতটুকু উঠেছে তার বেশি নামা যায় না — তাই মোট পিছিয়ে যাওয়া ≤ m − 1, সব মিলিয়ে O(m). ধাপ ৬-এ এক i-তেই তিনটি তুলনা হয়েছে, কিন্তু তার আগে ধাপ ৩–৫-এ k তিনবার বেড়ে সেই “পুঁজি” জমিয়ে রেখেছিল। এই হিসাবটিই পরের অংশে KMP-এর মূল প্রমাণ।

Pseudocode (0-based index)

PREFIX-FUNCTION(s):                // m = |s|
    pi[0] = 0
    for i = 1 to m - 1:
        k = pi[i - 1]                          // longest border of s[0..i-1]
        while k > 0 and s[i] != s[k]:
            k = pi[k - 1]                      // next shorter border
        if s[i] == s[k]: k = k + 1
        pi[i] = k
    return pi

৪ · KMP দিয়ে খোঁজা — text-এ কখনো পিছিয়ে না গিয়ে

খোঁজার অংশটি হুবহু একই লুপ, শুধু s[i]-এর জায়গায় text-এর অক্ষর T[i]. একটি সংখ্যা k রাখি: “এই মুহূর্তে P-এর প্রথম k অক্ষর T-এর সদ্য পড়া শেষ k অক্ষরের সাথে মিলে আছে”। নতুন অক্ষর T[i] এলে: মিললে k বাড়ে; না মিললে k = π[k − 1] করে ছোট border-এ নামি (অর্থাৎ P-কে ডানে সরাই) — text-এর index i কখনো পেছায় না। k = m হলে মিল পাওয়া গেল, শুরু i − m + 1, আর পরের (overlap করা) মিল খুঁজতে k = π[m − 1].

ঢাকার বাসে কন্ডাক্টর স্টপেজের নাম মুখস্থ বলেন: “ফার্মগেট, কারওয়ান বাজার, শাহবাগ…”। মাঝপথে ভুল হলে অভিজ্ঞ কন্ডাক্টর একদম প্রথম স্টপেজ থেকে আবার শুরু করেন না — যতটুকু ঠিক ছিল তার যে শেষ অংশটুকু আবার শুরুর সাথে মেলে, সেখান থেকে ধরেন। π টেবিল হলো সেই অভিজ্ঞতা, আগে থেকে লিখে রাখা।

P = abab (π = 0 0 1 2), T = abaababab:

P-এর বর্তমান অবস্থান= মিলে আছে

            
অবস্থা
i, T[i]
k-এর বদল
পাওয়া মিল
মোট ধাপ (পড়া + পিছানো)
  1. i = 0, 1, 2. a, b, a — তিনটিই P[0], P[1], P[2]-এর সাথে মেলে। k বেড়ে 3: P-এর প্রথম তিন অক্ষর text-এর শেষ তিন অক্ষরের সাথে মিলে আছে।
  2. i = 3, T[3] = a — অমিল। P[3] = b ≠ a. সরল পদ্ধতি এখানে i-কে 1-এ ফিরিয়ে আবার শুরু করত। KMP: k = π[2] = 1 (border a), P[1] = b ≠ a; k = π[0] = 0, P[0] = a = a — k = 1. P-এর নতুন অবস্থান T-এর index 3 থেকে। i একবারও পেছায়নি।
  3. i = 4, 5. b, a মেলে, k = 3.
  4. i = 6, T[6] = b. P[3] = b — k = 4 = m: মিল, শুরু 6 − 4 + 1 = 3. পরের মিল এর সাথে overlap করতে পারে, তাই k = π[3] = 2 — শেষ দুটি অক্ষর ab নতুন সম্ভাব্য মিলের শুরু হিসেবে রেখে দিলাম।
  5. i = 7, T[7] = a. P[2] = a — k = 3. ab আবার মেলাতে হয়নি; π সেটি আগেই নিশ্চিত করেছে।
  6. i = 8, T[8] = b. P[3] = b — k = 4: মিল, শুরু 5. দুটি মিল 3 আর 5 একে অপরের ওপর পড়েছে (T[5..6] = ab দুটিরই অংশ)।
  7. সারাংশ। n = 9টি অক্ষর পড়া আর ২টি পিছানো — মোট ১১টি ধাপ। text-এর প্রতিটি অক্ষর একবারই সামনে এগিয়ে পড়া হয়েছে, তাই KMP একটি stream থেকেও (পুরো text হাতে না রেখে) চালানো যায় — একটি online algorithm.

O(n + m)-এর প্রমাণ — potential যুক্তি

লুপের ভেতরে আরেকটি while লুপ — দেখে মনে হয় O(n·m). আসলে নয়, আর প্রমাণটি পরীক্ষায় প্রায়ই চাওয়া হয়। Amortized analysis-এর ভাষায় একটি potential function নিই: Φ = k (এই মুহূর্তে মিলে থাকা দৈর্ঘ্য)।

  • শুরুতে Φ = 0, আর সবসময় Φ ≥ 0.
  • প্রতিটি text-এর অক্ষরে বাইরের লুপ একবার চলে; সেখানে k সর্বোচ্চ ১ বাড়ে। তাই পুরো চালানোয় Φ মোট বাড়ে ≤ n.
  • প্রতিটি “পিছানো” (k = π[k − 1]) Φ-কে অন্তত ১ কমায়, কারণ π[k − 1] ≤ k − 1. মিলের পরের k = π[m − 1]-ও কমায়।
  • Φ শুরু হয়েছে 0-তে, কখনো ঋণাত্মক হয়নি, তাই মোট কমা ≤ মোট বাড়া ≤ n. অর্থাৎ পুরো চালানোয় while লুপ মোট n বারের বেশি চলে না।

সুতরাং খোঁজার অংশে মোট তুলনা ≤ 2n, আর π বানাতে একই যুক্তিতে ≤ 2m. KMP = Θ(n + m) সময়, O(m) বাড়তি জায়গা (শুধু π অ্যারে) — সবচেয়ে খারাপ ক্ষেত্রেও। অ্যালগরিদমের শুদ্ধতার যুক্তি (loop invariant, পর্ব ০৩): প্রতিটি ধাপের পরে k হলো “P-এর এমন সবচেয়ে লম্বা prefix, যা T[0..i]-এর একটি suffix”. কোনো মিল হারায় না, কারণ border শিকলের বাইরে কোনো দৈর্ঘ্য এই শর্ত মানতে পারে না — π বানানোর পর্যবেক্ষণ (২)-এর হুবহু পুনরাবৃত্তি।

#include <iostream>
#include <string>
#include <vector>
using namespace std;

vector<int> prefixFunction(const string& p) {
    vector<int> pi(p.size(), 0);
    for (size_t i = 1; i < p.size(); ++i) {
        int k = pi[i - 1];                           // longest border so far
        while (k > 0 && p[i] != p[k]) k = pi[k - 1]; // fall back to a shorter border
        if (p[i] == p[k]) ++k;
        pi[i] = k;
    }
    return pi;
}

vector<int> kmpSearch(const string& t, const string& p, long long& steps) {
    vector<int> pi = prefixFunction(p), found;
    int k = 0;                                       // chars of p matched now
    for (int i = 0; i < (int)t.size(); ++i) {
        while (k > 0 && t[i] != p[k]) { k = pi[k - 1]; ++steps; }
        ++steps;
        if (t[i] == p[k]) ++k;
        if (k == (int)p.size()) { found.push_back(i - k + 1); k = pi[k - 1]; }
    }
    return found;
}

int main() {
    string p = "ababaca", t = "abababacababacababaca";
    long long steps = 0;
    cout << "pi(" << p << ") =";
    for (int v : prefixFunction(p)) cout << ' ' << v;
    cout << "\nmatches of " << p << " in " << t << ":";
    for (int s : kmpSearch(t, p, steps)) cout << ' ' << s;
    long long n = 100000, m = 1000;                  // naive worst case: a^n vs a^(m-1)b
    steps = 0;
    auto found = kmpSearch(string(n, 'a'), string(m - 1, 'a') + "b", steps);
    cout << "\nn=" << n << " m=" << m << ": matches=" << found.size() << ", kmp steps=" << steps
         << ", naive comparisons=" << (n - m + 1) * m << '\n';
}

আউটপুট:

pi(ababaca) = 0 0 1 2 3 0 1
matches of ababaca in abababacababacababaca: 2 8 14
n=100000 m=1000: matches=0, kmp steps=199001, naive comparisons=99001000

প্রথম লাইন stepper-এর π-র সাথে হুবহু মেলে। দ্বিতীয় লাইনে তিনটি মিল, ৬ ঘর পরপর: 2-এর মিলের শেষ a (index 8) নিজেই 8-এর মিলের প্রথম অক্ষর — overlap. মিলের পর k = pi[k - 1] = π[6] = 1 ঠিক এই একটি অক্ষর মনে রাখে বলেই দ্বিতীয় মিলটি হারায় না। তৃতীয় লাইনটিই আসল গল্প: সরল পদ্ধতির worst case-এ (n = 10⁵, m = 10³) তুলনা প্রায় ৯.৯ কোটি, KMP-র ধাপ ১,৯৯,০০১ — প্রমাণের 2n সীমার নিচে, প্রায় ৫০০ গুণ কম। steps গোনে প্রতিটি পড়া অক্ষর আর প্রতিটি পিছানো; ঠিক n বার পড়া আর n − m + 1 বার পিছানো (pattern-এর শেষ b-এর সাথে প্রতিটি অমিলে একবার)।

def prefix_function(p):
    pi = [0] * len(p)
    for i in range(1, len(p)):
        k = pi[i - 1]                    # longest border so far
        while k > 0 and p[i] != p[k]:
            k = pi[k - 1]                # fall back to a shorter border
        if p[i] == p[k]:
            k += 1
        pi[i] = k
    return pi

def kmp_search(t, p):
    pi, found, k, steps = prefix_function(p), [], 0, 0
    for i, c in enumerate(t):            # k = chars of p matched now
        while k > 0 and c != p[k]:
            k, steps = pi[k - 1], steps + 1
        steps += 1
        if c == p[k]:
            k += 1
        if k == len(p):
            found.append(i - k + 1)      # match ends at i
            k = pi[k - 1]
    return found, steps

p, t = "ababaca", "abababacababacababaca"
print(f"pi({p}) =", *prefix_function(p))
print(f"matches of {p} in {t}:", *kmp_search(t, p)[0])
n, m = 100000, 1000                      # naive worst case: a^n vs a^(m-1)b
found, steps = kmp_search("a" * n, "a" * (m - 1) + "b")
print(f"n={n} m={m}: matches={len(found)}, kmp steps={steps}, "
      f"naive comparisons={(n - m + 1) * m}")

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

pi(ababaca) = 0 0 1 2 3 0 1
matches of ababaca in abababacababacababaca: 2 8 14
n=100000 m=1000: matches=0, kmp steps=199001, naive comparisons=99001000

Python-এ সরল পদ্ধতির ৯.৯ কোটি তুলনা হাতে-লেখা লুপে চালালে কয়েক সেকেন্ড থেকে মিনিট লাগত; তাই এখানে শুধু সূত্র (n − m + 1)·m ছাপা হয়েছে, আর KMP পুরোটা চালিয়েছে মুহূর্তে। খেয়াল করুন k, steps = pi[k - 1], steps + 1 — একসাথে দুটি মান বদলায়, ডান দিকের সব মান আগে হিসাব হয়। π-র লুপ আর খোঁজার লুপ প্রায় একই; অনেকে তাই সরাসরি p + '#' + t-এর π বানিয়ে যেখানে π[i] = m, সেখানে মিল ধরেন।

৫ · Z algorithm — প্রতিটি ঘর থেকে prefix কতদূর মেলে

আরেকটি টেবিল, আরেকটু সরাসরি অর্থে। একটি string s-এর Z-array: z[i] = s আর s[i..]-এর সবচেয়ে লম্বা সাধারণ prefix-এর দৈর্ঘ্য (z[0] সাধারণত 0 বা n ধরা হয়, ব্যবহার হয় না)। যেমন s = aabxaab-এ z[4] = 3, কারণ index 4 থেকে aab শুরু, যা s-এর প্রথম তিন অক্ষর।

খোঁজার কৌশল: s = P + '$' + T বানান, যেখানে '$' একটি separator character — P বা T কোনোটিতেই নেই। তাহলে কোনো z মান m ছাড়াতে পারে না ('$'-এ এসে মিল থামবেই), আর T-এর অংশে যেখানে z[i] = m, সেখানে P পুরোটা মিলেছে — text-এ শুরু i − m − 1.

দ্রুত বানানোর কৌশল — Z-box: এ পর্যন্ত পাওয়া সবচেয়ে ডানে শেষ হওয়া মিলের অংশ [l, r) মনে রাখি; s[l..r−1] হুবহু s[0..r−l−1]-এর কপি। নতুন i যদি এই বাক্সের ভেতরে পড়ে (i < r), তাহলে s[i..] শুরুটা আসলে s[i−l..]-এর কপি — তাই z[i] অন্তত min(r − i, z[i − l]), বিনা তুলনায়। এর পর শুধু r-এর বাইরে অক্ষর মিলিয়ে বাড়াই। নিচে P = aab, T = baabaa, s = aab$baabaa:

s আর Z-box[ ) = Z-box

            
অবস্থা
i, s[i]
Z-box থেকে শুরু
বাড়ানোর তুলনা
z[i]
[l, r)
  1. i = 1. কোনো বাক্স নেই, তাই সরাসরি মেলাই: s[0] = a = s[1], তারপর s[1] = a ≠ s[2] = b. z[1] = 1. মিলের অংশ [1, 2) — প্রথম Z-box.
  2. i = 2, 3, 4. সবাই r = 2-এর বাইরে বা সমান, তাই প্রথম তুলনা থেকেই: b, $, b — কেউ s[0] = a নয়। z = 0, 0, 0; বাক্স বদলায় না।
  3. i = 5. বাক্সের বাইরে; মেলাই: a = a, a = a, b = b, তারপর s[3] = $ ≠ s[8] = a. z[5] = 3 = m — একটি মিল! '$' থাকায় এর বেশি হওয়া অসম্ভব ছিল। নতুন বাক্স [5, 8).
  4. i = 6 — বাক্সের ভেতরে, ধার করা। s[5..7] হলো s[0..2]-এর কপি, তাই s[6..] শুরু হয় s[1..]-এর মতো। z[1] = 1 জানা, বাক্সে বাকি ঘর r − i = 2; min = 1. এবার শুধু r-এর দিকে বাড়ানোর চেষ্টা: s[1] = a ≠ s[7] = b. z[6] = 1 — নতুন কোনো অক্ষর বাক্সের ভেতরে দ্বিতীয়বার মেলাতে হয়নি।
  5. i = 7. ভেতরে, z[2] = 0 ধার, আর s[0] = a ≠ s[7] = b. z[7] = 0.
  6. i = 8. 8 = r, বাক্সের বাইরে। মেলাই: a = a, a = a, তারপর s শেষ। z[8] = 2; বাক্স [8, 10).
  7. i = 9. ভেতরে: min(r − i = 1, z[1] = 1) = 1, আর i + 1 = 10 = s-এর দৈর্ঘ্য — তুলনার কিছু নেই। z[9] = 1.
  8. ফল। z = − 1 0 0 0 3 1 0 2 1. T-এর অংশে z = 3 (= m) শুধু i = 5-এ, তাই P = aab T-এর index 5 − 3 − 1 = 1-এ আছে: T = baabaa. ঠিক তাই।

কেন O(n)? প্রতিটি i-তে তুলনা দুই রকম। ব্যর্থ তুলনা প্রতি i-তে সর্বোচ্চ একটি (সেটিতেই লুপ থামে) — মোট ≤ n. সফল তুলনা: ধার করা মান যদি z[i − l] < r − i হয়, তাহলে পরের তুলনাটি নিশ্চিত ব্যর্থ (কপির ভেতরেই অমিল আছে), সফল একটিও নয়। অন্য সব ক্ষেত্রে মেলানো শুরু হয় r বা তার পরে, আর প্রতিটি সফল তুলনা নতুন r-কে এক ঘর ডানে ঠেলে। r কখনো কমে না আর n-এর বেশি যায় না — তাই সফল তুলনা মোট ≤ n. সব মিলিয়ে Z-array O(n) সময়ে, O(n) জায়গায়; খোঁজায় n + m + 1 দৈর্ঘ্যের s-এ, তাই O(n + m).

KMP না Z? দুটির শক্তি সমান — একটির টেবিল থেকে অন্যটি O(n)-এ বের করা যায়। Z-এর সংজ্ঞা অনেকের কাছে বেশি সরাসরি (“এখান থেকে কতদূর মেলে”), তাই কনটেস্টে জনপ্রিয়। KMP-এর সুবিধা: text-এর জন্য বাড়তি জায়গা লাগে না আর stream থেকে চালানো যায় — Z-তে P$T পুরোটা একটি string-এ রাখতে হয়। আর KMP-এর π সরাসরি একটি স্বয়ংক্রিয় যন্ত্র (automaton) হয়ে Aho-Corasick-এ বহু pattern-এ ছড়িয়ে যায় (পর্ব ৫৯).

৬ · তিন পদ্ধতি পাশাপাশি — একই ইনপুটে একই উত্তর

নতুন অ্যালগরিদম লিখলে প্রথম কাজ: একটি সহজ, নিশ্চিতভাবে সঠিক brute force-এর সাথে মিলিয়ে দেখা। নিচের প্রোগ্রাম তিনটি ইনপুটে সরল পদ্ধতি, Rabin-Karp (দুটি mod: খুব ছোট 13 আর বড় 10⁹ + 7) আর Z algorithm চালিয়ে মিলগুলো তুলনা করে, সাথে spurious hit গোনে:

#include <algorithm>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using namespace std;

vector<int> rabinKarp(const string& t, const string& p, long long B, long long M, int& spurious) {
    int n = t.size(), m = p.size(); vector<int> found;
    auto v = [](char c) { return c - 'a' + 1; };        // a = 1, b = 2, c = 3, ...
    long long hp = 0, hw = 0, high = 1;                  // high = B^(m-1) mod M
    for (int i = 0; i < m; ++i) hp = (hp * B + v(p[i])) % M, hw = (hw * B + v(t[i])) % M;
    for (int i = 1; i < m; ++i) high = high * B % M;
    for (int s = 0; s + m <= n; ++s) {
        if (hw == hp && t.compare(s, m, p) == 0) found.push_back(s);  // hash hit: verify
        else if (hw == hp) ++spurious;                   // same hash, different text
        if (s + m < n) hw = (((hw - v(t[s]) * high) % M + M) * B + v(t[s + m])) % M;  // roll
    }
    return found;
}

vector<int> zSearch(const string& t, const string& p) {
    string s = p + "$" + t;                              // '$' is in neither string
    int n = s.size(), m = p.size(), l = 0, r = 0;
    vector<int> z(n, 0), found;
    for (int i = 1; i < n; ++i) {
        if (i < r) z[i] = min(r - i, z[i - l]);          // reuse the Z-box [l, r)
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) ++z[i];
        if (i + z[i] > r) l = i, r = i + z[i];
        if (z[i] == m) found.push_back(i - m - 1);
    }
    return found;
}

int main() {
    for (auto [t, p] : vector<pair<string, string>>{{"abccabc", "abc"}, {"aaaaa", "aa"}, {"abababab", "aba"}}) {
        vector<int> naive;                               // brute force for reference
        for (int s = 0; s + (int)p.size() <= (int)t.size(); ++s)
            if (t.compare(s, p.size(), p) == 0) naive.push_back(s);
        int sp13 = 0, spBig = 0;
        auto rk13 = rabinKarp(t, p, 10, 13, sp13), rkBig = rabinKarp(t, p, 131, 1000000007, spBig);
        bool same = naive == rk13 && naive == rkBig && naive == zSearch(t, p);
        cout << t << " / " << p << " -> matches:"; for (int s : naive) cout << ' ' << s;
        cout << " | spurious M=13: " << sp13 << ", M=1e9+7: " << spBig
             << " | rk, z vs naive: " << (same ? "same" : "DIFFERENT") << '\n';
    }
}

আউটপুট:

abccabc / abc -> matches: 0 4 | spurious M=13: 1, M=1e9+7: 0 | rk, z vs naive: same
aaaaa / aa -> matches: 0 1 2 3 | spurious M=13: 0, M=1e9+7: 0 | rk, z vs naive: same
abababab / aba -> matches: 0 2 4 | spurious M=13: 3, M=1e9+7: 0 | rk, z vs naive: same

প্রথম লাইন stepper-এর হুবহু ফল: মিল 0, 4, আর M = 13-এ একটি spurious hit (cca). তৃতীয় লাইনে M = 13-এ তিনটি spurious — bab = 212 আর aba = 121, দুটিই 13 দিয়ে ভাগ করলে ভাগশেষ 4. তবু তিনটি পদ্ধতির মিল একই, কারণ যাচাই ভুয়া মিল ফেলে দেয়; শুধু সময় নষ্ট হয়। M = 10⁹ + 7-এ (base 131) কোনো spurious নেই। দ্বিতীয় লাইনে overlap করা চারটি মিল — aaaaa-এ aa প্রতিটি ঘরে; এটিই Rabin-Karp-এর যাচাই-খরচের Θ(n·m) ক্ষেত্রের ছোট রূপ। ((x % M) + M) অংশটি জরুরি: C++-এ ঋণাত্মক সংখ্যার % ঋণাত্মক ফল দেয়।

def rabin_karp(t, p, B, M):              # returns (matches, spurious hits)
    n, m = len(t), len(p)
    v = lambda c: ord(c) - ord('a') + 1
    hp = hw = 0
    for i in range(m):
        hp, hw = (hp * B + v(p[i])) % M, (hw * B + v(t[i])) % M
    high, found, spurious = pow(B, m - 1, M), [], 0   # high = B^(m-1) mod M
    for s in range(n - m + 1):
        if hw == hp:                     # hash hit: verify char by char
            if t[s:s + m] == p: found.append(s)
            else: spurious += 1
        if s + m < n:                    # drop t[s], add t[s+m]
            hw = ((hw - v(t[s]) * high) * B + v(t[s + m])) % M
    return found, spurious

def z_search(t, p):
    s = p + "$" + t                      # '$' is in neither string
    z, l, r = [0] * len(s), 0, 0
    for i in range(1, len(s)):
        if i < r: z[i] = min(r - i, z[i - l])   # reuse the Z-box [l, r)
        while i + z[i] < len(s) and s[z[i]] == s[i + z[i]]: z[i] += 1
        if i + z[i] > r: l, r = i, i + z[i]
    return [i - len(p) - 1 for i in range(len(s)) if z[i] == len(p)]

for t, p in [("abccabc", "abc"), ("aaaaa", "aa"), ("abababab", "aba")]:
    naive = [s for s in range(len(t) - len(p) + 1) if t[s:s + len(p)] == p]
    rk13, sp13 = rabin_karp(t, p, 10, 13)
    rk_big, sp_big = rabin_karp(t, p, 131, 1000000007)
    same = naive == rk13 == rk_big == z_search(t, p)
    print(f"{t} / {p} -> matches:", *naive, end="")
    print(f" | spurious M=13: {sp13}, M=1e9+7: {sp_big}"
          f" | rk, z vs naive: {'same' if same else 'DIFFERENT'}")

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

abccabc / abc -> matches: 0 4 | spurious M=13: 1, M=1e9+7: 0 | rk, z vs naive: same
aaaaa / aa -> matches: 0 1 2 3 | spurious M=13: 0, M=1e9+7: 0 | rk, z vs naive: same
abababab / aba -> matches: 0 2 4 | spurious M=13: 3, M=1e9+7: 0 | rk, z vs naive: same

Python-এ % সবসময় অঋণাত্মক ফল দেয়, তাই বাড়তি + M লাগেনি; আর int-এর সীমা নেই বলে overflow-এর ভয়ও নেই — C++-এ hw * B যেন long long-এর সীমা না ছাড়ায়, সেজন্য M ≈ 10⁹ রাখা হয়। pow(B, m - 1, M) তৈরি modular exponentiation. এই কোড m ≤ n ধরে নেয়; বাস্তবে আগে if m > n: return [], 0 যোগ করুন। আরও আত্মবিশ্বাস চাইলে হাজারখানেক এলোমেলো ছোট ইনপুটে একই তুলনা চালান — একে বলে stress testing.

পদ্ধতিপ্রস্তুতিখোঁজা (সবচেয়ে খারাপ)বাড়তি জায়গাবিশেষ শক্তি
NaiveনেইO(n·m)O(1)লিখতে সহজ, এলোমেলো লেখায় গড়ে দ্রুত
Rabin-KarpO(m)প্রত্যাশিত O(n + m); সবচেয়ে খারাপে O(n·m)O(1)যেকোনো দুটি substring O(1)-এ তুলনা, অনেক pattern একসাথে, 2D খোঁজা
KMPO(m)O(n)O(m)deterministic, stream-এ চলে, period আর border
Z algorithmO(n + m) একসাথেO(n + m)সংজ্ঞা সরাসরি, প্রতিটি suffix-এর prefix-মিল

৭ · প্রয়োগ — period, compression আর আরও দ্রুত লাফ

String-এর period আর “একটি টুকরোর পুনরাবৃত্তি কি না”

p হলো s-এর একটি period, যদি সব বৈধ i-তে s[i] = s[i + p] — অর্থাৎ s-কে p ঘর সরালে নিজের সাথে মেলে। একটি সুন্দর তথ্য: p একটি period ⇔ দৈর্ঘ্য n − p-এর একটি border আছে (s[0..n−p−1] = s[p..n−1] — দুটি একই কথা)। তাই সবচেয়ে লম্বা border থেকে সবচেয়ে ছোট period:

ক্ষুদ্রতম period p = n − π[n − 1]

আর s কোনো ছোট টুকরো t-এর পুরোপুরি পুনরাবৃত্তি (s = t t … t) হবে ঠিক তখন, যখন p < n আর p দিয়ে n নিঃশেষে বিভাজ্য। abcabcabc: π[8] = 6, p = 9 − 6 = 3, 9 % 3 = 0 → abc × ৩. abcabca: π[6] = 4, p = 3, কিন্তু 7 % 3 ≠ 0 → period 3 থাকলেও পূর্ণ পুনরাবৃত্তি নয়। ইন্টারভিউর “repeated substring pattern” প্রশ্নটি এভাবে O(n)-এ শেষ। একই ধারণায় compression যাচাই: লগ-ফাইলের একটি লাইন যদি একটি ছোট নকশার পুনরাবৃত্তি হয়, শুধু নকশা আর পুনরাবৃত্তির সংখ্যা রাখলেই চলে।

প্রতিটি prefix কতবার আছে

π দিয়ে আরও একটি ক্লাসিক কাজ: s-এর প্রতিটি দৈর্ঘ্যের prefix s-এর ভেতরে মোট কতবার আছে। প্রথমে প্রতিটি i-তে cnt[π[i]] এক বাড়ান; তারপর দৈর্ঘ্য n − 1 থেকে 1 পর্যন্ত নেমে cnt[π[k − 1]] += cnt[k] (একটি border-এর border-ও সেখানে আছে); শেষে প্রতিটি দৈর্ঘ্যে ১ যোগ (prefix-টি নিজে)। মোট O(n). শুধু P কতবার T-এ আছে জানতে চাইলে KMP-র মিলগুলো গুনলেই হয় — overlap-সহ।

আরও গভীরে: বাস্তবের অনেক search টুল KMP নয়, Boyer–Moore-ধাঁচের অ্যালগরিদম ব্যবহার করে (Robert Boyer ও J Strother Moore, ১৯৭৭). এটি pattern-কে ডান দিক থেকে মেলায়, আর bad character rule-এ অমিলের text-অক্ষরটি pattern-এ না থাকলে পুরো pattern পার করে লাফ দেয়। T = bkash nagad rocket-এ P = rocket: প্রথম অবস্থানে P-এর শেষ t-এর নিচে ফাঁকা জায়গা — pattern-এ নেই, তাই ৬ ঘর লাফ; আবার ফাঁকা জায়গা, আবার ৬; তৃতীয় অবস্থানেই মিল — text-এর ১৮টি অক্ষরের মধ্যে মিলের আগে দেখা হলো মাত্র দুটি। বড় বর্ণমালা আর লম্বা pattern-এ এটি গড়ে n/m-এর কাছাকাছি অক্ষর দেখে — sublinear; GNU grep-এর গতির একটি বড় কারণ এই ধরনের লাফ। অনেকগুলো pattern একসাথে (যেমন হাজার হাজার virus signature) খুঁজতে KMP-কে trie-র ওপর ছড়িয়ে দেওয়া হয় — Aho–Corasick, পর্ব ৫৯-এ।

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

  • "aabaaab"-এর prefix function কত? — 0 1 0 1 2 2 3; i = 5-এ k = 2 থেকে π[1] = 1-এ নেমে তারপর মেলে।
  • KMP-র সময় O(n + m) কেন, যখন ভেতরে while লুপ আছে? — k প্রতি অক্ষরে সর্বোচ্চ ১ বাড়ে, প্রতিটি পিছানো অন্তত ১ কমায়, k ≥ 0; তাই মোট পিছানো ≤ n.
  • Rabin-Karp-এর worst case কখন আর কত? — বহু hash মিল (আসল বা spurious), যেমন T = aaa…a, P = aa…a: প্রতিটি যাচাই O(m), মোট O(n·m).
  • Z algorithm দিয়ে খুঁজতে মাঝে '$' কেন? — যাতে কোনো z মান |P| ছাড়াতে না পারে আর মিল P আর T-এর সীমানা পার না হয়।
  • একটি string একটি টুকরোর পুনরাবৃত্তি কি না, O(n)-এ কীভাবে? — p = n − π[n − 1]; p < n আর n % p = 0 হলে হ্যাঁ।

৮ · বাংলা লেখায় খোঁজা আর অ্যালগরিদমগুলোর ইতিহাস

KMP, Z বা Rabin-Karp যেকোনো সংকেতের সারিতে কাজ করে — বাইট, code point, এমনকি শব্দ। প্রশ্ন হলো কোন সারিতে চালাচ্ছেন। পর্ব ১১-এ দেখেছেন, বাংলা লেখার তিনটি “দৈর্ঘ্য” আছে: UTF-8 বাইট, code point আর চোখে দেখা অক্ষর বা grapheme cluster. খোঁজায় এর তিনটি বাস্তব প্রভাব:

  • বাইট ধরে খোঁজা নিরাপদ, কিন্তু index বাইটে। UTF-8-এর নকশায় শুরুর বাইট আর ধারাবাহিক বাইট আলাদা দেখতে, তাই একটি বৈধ UTF-8 pattern বৈধ UTF-8 text-এ কখনো একটি অক্ষরের মাঝখান থেকে মেলে না। C++-এ std::string-এ KMP চালালে মিল ঠিকই পাবেন — কিন্তু "কলম"-এ "ম"-এর অবস্থান আসবে 6 (বাইট), Python-এর str.find দেবে 2 (code point). দুটি সংখ্যা মিশিয়ে ফেললেই bug.
  • Code point-এর মিল মানে চোখের মিল নয়। "ক্ষমা"-য় "ক" খুঁজলে মিল পাবেন, কারণ যুক্তাক্ষর ক্ষ = ক + ্ + ষ. ব্যবহারকারী কিন্তু “ক” অক্ষরটি সেখানে দেখেন না। প্রয়োজনে মিলের দুই প্রান্ত grapheme-এর সীমানায় পড়ছে কি না যাচাই করুন।
  • একই দেখতে, ভিন্ন বাইট। ড় একটি code point হিসেবে, আবার ড + নুক্তা হিসেবেও লেখা যায়। খোঁজার আগে text আর pattern দুটিকেই একই রূপে আনুন — Unicode normalization (যেমন NFC). না হলে অ্যালগরিদম নির্ভুল হয়েও “পাওয়া যায়নি” বলবে।

Rabin-Karp-এ আরেকটি সূক্ষ্ম দিক: বাইটে hash করলে প্রতিটি সংকেত 0–255, code point-এ করলে 0 থেকে প্রায় ১১ লাখ — স্থির base নিলে B-কে সংকেতের সর্বোচ্চ মানের চেয়ে বড় রাখুন, নইলে ভিন্ন string-এর মধ্যে নিয়মিত (এলোমেলো নয়) collision তৈরি হয়।

ইতিহাস। ১৯৭০ সালের দিকে James H. Morris একটি টেক্সট এডিটর লিখতে গিয়ে এই অ্যালগরিদম বের করেন, আর Vaughan Pratt-এর সাথে একটি টেকনিক্যাল রিপোর্টে লেখেন। প্রায় একই সময়ে Donald Knuth স্বাধীনভাবে একই ধারণায় পৌঁছান — Stephen Cook-এর automata-বিষয়ক একটি তাত্ত্বিক ফল থেকে, যা বলছিল এমন রৈখিক-সময়ের অ্যালগরিদম থাকতেই হবে। তিনজনের যৌথ প্রবন্ধ “Fast pattern matching in strings” প্রকাশিত হয় ১৯৭৭ সালে, SIAM Journal on Computing-এ। একই বছর Robert Boyer আর J Strother Moore প্রকাশ করেন তাঁদের ডান-থেকে-মেলানো অ্যালগরিদম (Communications of the ACM). Richard Karp আর Michael Rabin-এর hashing-ভিত্তিক এলোমেলো অ্যালগরিদম প্রকাশিত হয় ১৯৮৭ সালে। Z algorithm-এর ধারণা আগের গবেষণায় ছিল; “Z algorithm” নামে এটি জনপ্রিয় হয় Dan Gusfield-এর ১৯৯৭ সালের string অ্যালগরিদমের পাঠ্যবই থেকে।

৯ · AI-এর যুগে string matching — off-by-one আর অপ্রমাণিত দাবি

KMP বা Z-এর কোড AI মুহূর্তে লিখে দেয়, আর সাধারণ উদাহরণে প্রায় সবসময় ঠিক চলে। ভুল লুকিয়ে থাকে সূক্ষ্ম জায়গায়: π-র সূচক এক ঘর সরানো (π[k] বনাম π[k − 1]), মিলের পর k রিসেট করতে ভুলে যাওয়া (তখন overlap করা মিল হারায়), Z-box-এ [l, r] বনাম [l, r) গুলিয়ে ফেলা, আর C++-এ ঋণাত্মক mod. সবচেয়ে বিপজ্জনক হলো আত্মবিশ্বাসী complexity দাবি — “Rabin-Karp সবসময় O(n + m)”.

String matching-এ AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
মিলের পর k = 0 — overlap করা মিল বাদ পড়েT = aaaaa, P = aa দিন; উত্তর 0 1 2 3 হওয়া উচিত
পিছানোয় k = pi[k] লেখা — অসীম লুপ বা ভুল মিলababaca-র π হাতে বের করে প্রোগ্রামের সাথে মেলান
Rabin-Karp-কে “সবসময় O(n + m)” বলাযাচাইসহ T = aaa…a, P = aa…a-তে সময় মাপুন; n·m-এর মতো বাড়লে দাবি ভুল
স্থির base আর 2⁶⁴ mod, যাচাই ছাড়াএলোমেলো base আর দুটি মৌলিক mod চান; ছোট M দিয়ে চালিয়ে spurious hit দেখুন
C++-এ (h - x) % M — ঋণাত্মক hashছোট M (যেমন 13) দিয়ে হাতে হিসাবের সাথে মেলান
Z-তে separator ভুলে যাওয়া বা text-এ থাকা অক্ষর ('a') বসানোP = aa, T = aaa দিন; z মান m ছাড়ালে বা মিল বেশি এলে ভুল

AI-কে কোড লিখতে না বলে বরং আপনার কোডের প্রমাণ আর দুর্বলতা নিয়ে প্রশ্ন করুন। নিচের prompt-টি আপনাকে ভাবতে বাধ্য করে:

আমি DSA শিখছি, এখন string matching (KMP, Z, Rabin-Karp). নিচে আমার লেখা কোড দিলাম: [এখানে আপনার কোড পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. আমার prefix function বা Z-array কোডে "abaabab" ইনপুটে প্রতিটি ধাপের k বা [l, r) কী হবে, একটি টেবিল দাও — আমি হাতে মিলিয়ে দেখব। ২. মোট সময় কেন O(n + m) (বা কেন নয়), potential বা "r শুধু বাড়ে" যুক্তি দিয়ে ব্যাখ্যা করো; ধাপ বাদ দিও না। ৩. পাঁচটি কিনারার ইনপুট দাও: overlap করা মিল, P দৈর্ঘ্যে T-এর চেয়ে বড়, এক অক্ষর, সব অক্ষর এক, কোনো মিল নেই। ৪. Rabin-Karp হলে: আমার base আর mod দিয়ে collision কতটা সম্ভব, আর anti-hash test থেকে বাঁচতে কী বদলাব — শুধু ইঙ্গিত দাও। ৫. একটি brute force দিয়ে random stress test কীভাবে লিখব, তার পরিকল্পনা দাও, কোড নয়।

AI-এর (বা নিজের) string matching কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১KMP কেন দুই দিক থেকে প্রায় একই সময়ে আবিষ্কৃত হলো — একজন এডিটর লিখতে গিয়ে, আরেকজন তত্ত্ব থেকে?

KMP-এর গল্প কম্পিউটার বিজ্ঞানের একটি চমৎকার উদাহরণ, যেখানে বাস্তব প্রকৌশল আর বিমূর্ত তত্ত্ব একই উত্তরে পৌঁছেছে। এক দিকে ছিলেন James H. Morris. ১৯৭০ সালের আশেপাশে একটি টেক্সট এডিটর লেখার সময় তাঁর সমস্যা ছিল খুব বাস্তব: ব্যবহারকারী একটি শব্দ খুঁজছেন, আর লেখাটি আসছে একটি একটি অক্ষর করে, পেছনে ফিরে পড়া ব্যয়বহুল। তাই এমন একটি পদ্ধতি দরকার ছিল যা text-এ কখনো পেছায় না। এই প্রয়োজন থেকেই তিনি border-এ ফিরে যাওয়ার কৌশল বের করেন, আর ১৯৭০ সালে Vaughan Pratt-এর সাথে একটি টেকনিক্যাল রিপোর্টে লেখেন।

অন্য দিকে Donald Knuth পৌঁছান তত্ত্বের পথে। Stephen Cook তখন দেখিয়েছিলেন যে একটি বিশেষ ধরনের automaton (two-way deterministic pushdown automaton) যা চিনতে পারে, তা রৈখিক সময়ে অনুকরণ করা যায়। এমন যন্ত্র দিয়ে “T-এ P আছে কি না” চেনা যায় — তাহলে রৈখিক সময়ের string matching অ্যালগরিদম থাকতেই হবে। Knuth সেই অস্তিত্বের প্রমাণ থেকে আসল অ্যালগরিদমটি বের করেন, আর পরে জানতে পারেন যে Morris ইতিমধ্যে বাস্তবে সেটি বানিয়ে ফেলেছেন। তিনজনের যৌথ প্রবন্ধ ১৯৭৭ সালে প্রকাশিত হয়।

এখান থেকে দুটি শিক্ষা। প্রথমত, একটি অস্তিত্ব-প্রমাণ (“এমন অ্যালগরিদম আছে”) নিজেই গবেষণার দিক দেখায় — তত্ত্ব শুধু সীমা টানে না, পথও দেখায়। দ্বিতীয়ত, চাপের মধ্যে বাস্তব সমস্যা (একটি দ্রুত এডিটর) প্রায়ই গভীর অ্যালগরিদমের জন্ম দেয়; আজও বহু অ্যালগরিদম আসে ডেটাবেস, কম্পাইলার বা নেটওয়ার্কের বাস্তব সমস্যা থেকে।

আরও একটি দিক মনে রাখার মতো: KMP-এর “text-এ কখনো না পেছানো” বৈশিষ্ট্য তখন বিলাসিতা ছিল না, কারণ টেপ বা ধীর ডিস্ক থেকে আসা লেখায় পেছানো মানে সময়ের বড় অপচয়। আজকের নেটওয়ার্ক stream বা বিশাল লগ-ফাইলেও একই সুবিধা কাজে লাগে।

মূল উপলব্ধি: একটি ভালো অ্যালগরিদম প্রায়ই দুই দিক থেকে আসে — বাস্তবের প্রয়োজন আর তত্ত্বের নিশ্চয়তা। দুটি দিকই বুঝলে আপনি শুধু কোড নয়, কোডটি কেন সম্ভব তাও জানবেন।

প্র ০২Rolling hash-এর ধারণা string খোঁজার বাইরে কোথায় কাজে লাগে — ফাইল সিঙ্ক আর ডেটা ব্যাকআপে এর ভূমিকা কী?

Rabin-Karp-এর আসল আবিষ্কার শুধু “pattern খোঁজা” নয়, বরং একটি সাধারণ কৌশল: একটি চলমান জানালার fingerprint O(1)-এ হালনাগাদ করা। Michael Rabin ১৯৮১ সালে বহুপদীর ওপর ভিত্তি করে এলোমেলো fingerprint-এর একটি পদ্ধতি দেন, যা আজ “Rabin fingerprint” নামে পরিচিত; Karp-এর সাথে ১৯৮৭ সালের প্রবন্ধে তাঁরা দেখান কীভাবে এটি দিয়ে লেখায় খোঁজা যায়, এমনকি দুই-মাত্রার ছবিতেও।

বাস্তবের সবচেয়ে পরিচিত উদাহরণ rsync — Andrew Tridgell আর Paul Mackerras-এর ১৯৯৬ সালের টুল, যা দুটি কম্পিউটারের মধ্যে ফাইল সিঙ্ক করে শুধু বদলানো অংশ পাঠিয়ে। গন্তব্য কম্পিউটার তার পুরোনো ফাইলকে নির্দিষ্ট মাপের ব্লকে ভাগ করে প্রতিটির একটি দুর্বল (rolling) আর একটি শক্ত checksum পাঠায়। উৎস কম্পিউটার নতুন ফাইলের প্রতিটি অবস্থানে rolling checksum এক বাইট করে সরিয়ে দেখে কোনো পুরোনো ব্লকের সাথে মেলে কি না — হুবহু Rabin-Karp-এর জানালা। দুর্বল checksum মিললে তবেই শক্ত checksum দিয়ে নিশ্চিত হয় — ঠিক যেমন আমরা spurious hit-এর পর অক্ষর ধরে যাচাই করি। ফলে ফাইলের মাঝখানে কয়েকটি বাইট ঢুকলেও বাকি সব ব্লক চেনা যায়।

দ্বিতীয় বড় প্রয়োগ content-defined chunking: ব্যাকআপ আর deduplication সিস্টেম একটি বড় ফাইলকে স্থির মাপে না কেটে, যেখানে rolling hash-এর কয়েকটি bit শূন্য হয় সেখানে কাটে। ফলে ফাইলের শুরুতে কিছু যোগ হলেও পরের কাটার জায়গাগুলো সরে না, আর একই টুকরো দ্বিতীয়বার জমা হয় না। ২০০১ সালের Low-Bandwidth File System (LBFS) গবেষণা এভাবে Rabin fingerprint ব্যবহার করেছিল, আর পরবর্তী অনেক ব্যাকআপ টুল একই ধারণা নিয়েছে।

নকল ধরার ব্যবস্থাতেও একই কৌশল: লেখাকে k দৈর্ঘ্যের টুকরোয় ভেঙে প্রতিটির rolling hash, তারপর সেগুলোর একটি প্রতিনিধি-নমুনা দুটি লেখায় মেলানো (পর্ব ১১-এ MOSS-এর winnowing দেখেছেন)।

মূল উপলব্ধি: “জানালা সরলে hash O(1)-এ বদলায়” — এই একটি ধারণা খোঁজা, সিঙ্ক, ব্যাকআপ আর নকল ধরায় একইভাবে কাজ করে। আর প্রতিটি জায়গায় একই নিয়ম মানা হয়: দুর্বল hash দিয়ে দ্রুত ছেঁকে নেওয়া, তারপর শক্ত পরীক্ষা দিয়ে নিশ্চিত হওয়া।

প্র ০৩অ্যান্টিভাইরাস বা নেটওয়ার্ক নিরাপত্তা ব্যবস্থা লাখো signature একসাথে কীভাবে খোঁজে — আর এখানে worst case কেন বিশেষ গুরুত্বপূর্ণ?

একটি অ্যান্টিভাইরাসের ডেটাবেসে থাকে বিপুল সংখ্যক চেনা ক্ষতিকর বাইট-ক্রম (signature). প্রতিটি signature-এর জন্য আলাদা করে KMP চালালে প্রতিটি ফাইল লাখো বার পড়তে হতো — অসম্ভব। তাই দরকার একসাথে বহু pattern খোঁজা। এর ক্লাসিক উত্তর Alfred Aho আর Margaret Corasick-এর ১৯৭৫ সালের অ্যালগরিদম: সব pattern একটি trie-তে রাখা হয়, আর প্রতিটি নোডে KMP-র π-র মতো একটি “failure link” — অমিল হলে কোন নোডে ফিরে যেতে হবে। তারপর text একবার পড়েই সব pattern-এর সব মিল পাওয়া যায়, সময় text-এর দৈর্ঘ্য + সব pattern-এর মোট দৈর্ঘ্য + মিলের সংখ্যার সমানুপাতিক। মূলে এটি পুরো KMP-কে একটি গাছে ছড়িয়ে দেওয়া — এই পর্বের ধারণা বুঝলে পর্ব ৫৯-এর Aho-Corasick প্রায় স্বাভাবিক মনে হবে।

বাস্তব ব্যবস্থাগুলো সাধারণত একাধিক কৌশল মেশায়। মুক্ত উৎসের অ্যান্টিভাইরাস ClamAV-এর ভেতরে Aho-Corasick-ভিত্তিক আর Boyer-Moore-ভিত্তিক — দুই ধরনের matcher আছে; নেটওয়ার্ক intrusion detection system (যেমন Snort) প্যাকেটের ভেতরে আক্রমণের চেনা নকশা খুঁজতে বহু-pattern অ্যালগরিদম ব্যবহার করে। এই ক্ষেত্রগুলোয় অনেক সময় hashing-এর ছাঁকনি আগে বসে, তারপর নিশ্চিত যাচাই।

worst case এখানে শুধু তত্ত্বের প্রশ্ন নয়। আক্রমণকারী জানেন সুরক্ষা-ব্যবস্থাটি কী অ্যালগরিদম ব্যবহার করে। যদি কোনো ইনপুটে খোঁজা হঠাৎ Θ(n·m) হয়ে যায়, তাহলে ইচ্ছা করে এমন ট্রাফিক পাঠিয়ে স্ক্যানারকে ধীর করে দেওয়া যায় — আর ধীর স্ক্যানার হয় প্যাকেট বাদ দেয় (যার ভেতর দিয়ে আসল আক্রমণ ঢুকতে পারে), নয়তো নেটওয়ার্ক থেমে যায়। একই কারণে regular expression-এর backtracking-ভিত্তিক ইঞ্জিনে “ReDoS” নামের সমস্যা সুপরিচিত। তাই নিরাপত্তা-ব্যবস্থায় গড়ে দ্রুত নয়, সবসময় রৈখিক — এমন অ্যালগরিদম মূল্যবান; Rabin-Karp-এর hash-ও এলোমেলো seed ছাড়া ব্যবহার বিপজ্জনক।

মূল উপলব্ধি: প্রতিপক্ষ থাকলে worst case-ই আসল case. বহু-pattern খোঁজার জন্য KMP-র ধারণার সম্প্রসারণ (Aho-Corasick) এ কারণে নিরাপত্তা-প্রকৌশলের একটি ভিত্তি।

প্র ০৪KMP সবচেয়ে খারাপ ক্ষেত্রে সর্বোত্তম হলেও grep-এর মতো টুল কেন Boyer-Moore-ধাঁচের পদ্ধতি পছন্দ করে?

KMP text-এর প্রতিটি অক্ষর অন্তত একবার দেখে — Θ(n) তুলনা, কখনো কম নয়। Boyer-Moore (১৯৭৭) একটি ভিন্ন প্রশ্ন করে: সব অক্ষর দেখা কি আদৌ দরকার? pattern-কে ডান থেকে মেলালে প্রথম তুলনাটিই হয় pattern-এর শেষ অক্ষরের নিচের text-অক্ষরের সাথে। সেই অক্ষর যদি pattern-এ কোথাও না থাকে, তাহলে pattern-এর m ঘরের কোনো অবস্থানেই মিল সম্ভব নয় যা এই অক্ষরকে ঢেকে রাখে — এক লাফে m ঘর এগোনো যায়। ইংরেজি বা কোডের মতো বড় বর্ণমালার লেখায় লম্বা pattern খুঁজলে এমন লাফই বেশি, তাই গড়ে প্রায় n/m-টি অক্ষর দেখা হয় — sublinear, pattern যত লম্বা তত দ্রুত! এটি প্রথমবার শুনলে অবাক লাগে, কিন্তু যুক্তিটি সরল।

পুরো Boyer-Moore-এ দুটি নিয়ম: bad character আর “good suffix” (যা অনেকটা KMP-র border-এর উল্টো দিকের রূপ)। ১৯৮০ সালে Nigel Horspool দেখান, শুধু bad character-এর একটি সরল রূপেই বাস্তবে প্রায় একই গতি আসে — Boyer-Moore-Horspool আজ বহুল ব্যবহৃত। GNU grep-এর মূল লেখক Mike Haertel ২০১০ সালে একটি সুপরিচিত মেইলে “GNU grep কেন দ্রুত” ব্যাখ্যা করেছিলেন: Boyer-Moore দিয়ে বেশিরভাগ বাইট না দেখেই এগোনো, প্রতিটি লাইন আলাদা করে ভাঙা এড়ানো, আর অভ্যন্তরীণ লুপ যতটা সম্ভব হালকা রাখা।

তবে Boyer-Moore-এর সরল রূপের নিজস্ব worst case আছে (যেমন ছোট বর্ণমালা, DNA-র মতো চার অক্ষরে লাফ ছোট হয়), আর পুরো রূপের worst-case বিশ্লেষণ জটিল। এ কারণে লাইব্রেরিগুলো প্রায়ই মিশ্র কৌশল নেয়: ছোট pattern-এ সরল লুপ (প্রসেসরের দ্রুত বাইট-খোঁজার নির্দেশনা দিয়ে), বড় pattern-এ লাফ-ভিত্তিক বা “Two-Way” ধরনের অ্যালগরিদম, যা সবচেয়ে খারাপেও রৈখিক থাকে আর বাড়তি জায়গা প্রায় লাগে না।

মূল উপলব্ধি: asymptotic সর্বোত্তম (Θ(n + m)) আর বাস্তবে দ্রুত এক কথা নয়। ইনপুটের বৈশিষ্ট্য (বর্ণমালার আকার, pattern-এর দৈর্ঘ্য) জানলে “সব অক্ষর না দেখা”-র মতো কৌশল worst-case-সর্বোত্তম অ্যালগরিদমকেও গড়ে হারিয়ে দিতে পারে।

প্র ০৫মানুষের জিনোমে একটি DNA ক্রম খোঁজা কি শুধু KMP চালানোর ব্যাপার? জীববিজ্ঞানের বিশাল ডেটায় string matching কীভাবে বদলে যায়?

মানুষের জিনোম প্রায় ৩০০ কোটি অক্ষরের (A, C, G, T) একটি লেখা। একটি জিনের ক্রম একবার খুঁজতে KMP চমৎকার: O(n + m), মোটামুটি কয়েক সেকেন্ডের কাজ। কিন্তু বাস্তব সমস্যা অন্যরকম। আধুনিক sequencing যন্ত্র একবারে কোটি কোটি ছোট টুকরো (read, সাধারণত কয়েকশো অক্ষর) পড়ে, আর প্রতিটির জিনোমে অবস্থান বের করতে হয়। প্রতিটি read-এর জন্য পুরো জিনোম KMP দিয়ে পড়া মানে কোটি × ৩০০ কোটি — অসম্ভব।

সমাধান হলো দৃষ্টিভঙ্গি উল্টে দেওয়া: pattern নয়, text-কে একবার প্রস্তুত করে রাখা। জিনোমের ওপর একটি index বানানো হয় — suffix array বা Burrows–Wheeler transform-ভিত্তিক FM-index — যার পরে যেকোনো m দৈর্ঘ্যের read-এর মিল প্রায় O(m) বা O(m log n)-এ পাওয়া যায়, জিনোমের দৈর্ঘ্যের ওপর প্রায় নির্ভর না করে। BWA বা Bowtie-র মতো জনপ্রিয় aligner এই ধারণার ওপর দাঁড়িয়ে। এই কাঠামোগুলো (suffix array, LCP) পর্ব ৫৯-এর বিষয়।

দ্বিতীয় পার্থক্য: জীববিজ্ঞানে হুবহু মিল প্রায়ই যথেষ্ট নয়। বিবর্তন বা যন্ত্রের ভুলে কয়েকটি অক্ষর বদলে যায়, তাই খুঁজতে হয় “প্রায় মিল”। ১৯৯০ সালের বিখ্যাত টুল BLAST একটি চতুর কৌশল নেয়: আগে ছোট দৈর্ঘ্যের হুবহু-মিলের “বীজ” (seed) খোঁজে — hashing বা টেবিল দিয়ে, অনেকটা Rabin-Karp-এর আত্মায় — তারপর সেই বীজের চারপাশে dynamic programming (পর্ব ৫৪-এর edit distance-এর আত্মীয়) দিয়ে মিল বাড়ায়। দ্রুত ছাঁকনি, তারপর যত্নের যাচাই — Rabin-Karp-এর মতোই দর্শন।

আর চার অক্ষরের বর্ণমালা মনে করিয়ে দেয় কেন worst case গুরুত্বপূর্ণ: DNA-তে পুনরাবৃত্ত অংশ প্রচুর (জিনোমের বড় একটি অংশ পুনরাবৃত্ত ক্রম), তাই সরল পদ্ধতি বা ছোট-বর্ণমালায় Boyer-Moore-এর লাফ এখানে দুর্বল।

মূল উপলব্ধি: একটি pattern একবার খোঁজা আর একই text-এ কোটি pattern খোঁজা — দুটি আলাদা সমস্যা। প্রথমটিতে KMP/Z, দ্বিতীয়টিতে text-এর index. কোন দিকটি স্থির আর কোনটি বারবার আসে, সেটিই ডেটা স্ট্রাকচার বেছে দেয়।

অনুশীলন

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

  1. হাতে trace করুন: s = aabaaab-এর prefix function বের করুন। কোন i-তে k পিছিয়ে গেছে, আর কতবার?

    π = 0 1 0 1 2 2 3. ধাপগুলো: i = 1: k = 0, a = a → 1. i = 2: k = 1, s[1] = a ≠ b → k = π[0] = 0, s[0] = a ≠ b → 0. i = 3: k = 0, a = a → 1. i = 4: k = 1, s[1] = a = a → 2. i = 5: k = 2, s[2] = b ≠ a → k = π[1] = 1, s[1] = a = a → 2. i = 6: k = 2, s[2] = b = b → 3.

    পিছানো হয়েছে i = 2-এ একবার (1 → 0) আর i = 5-এ একবার (2 → 1). মোট ২ বার, আর k মোট বেড়েছে ৫ বার — potential যুক্তির সাথে মেলে: পিছানো কখনো বাড়ার চেয়ে বেশি হয় না। শেষ মান 3 মানে aab হলো সবচেয়ে লম্বা border: aabaaaab.

  2. Hash হিসাব করুন: a = 1, b = 2, c = 3, B = 10, কিন্তু এবার M = 9 (মৌলিক নয়)। T = bacabab-এ P = ab খুঁজলে কোন জানালায় hash মেলে, কয়টি spurious hit? M = 9 কেন খারাপ পছন্দ?

    h(P) = 12 mod 9 = 3. জানালাগুলো: ba = 21 → 3, ac = 13 → 4, ca = 31 → 4, ab = 12 → 3, ba → 3, ab → 3. hash মেলে s = 0, 3, 4, 5-এ; আসল মিল 3 আর 5, spurious ২টি (s = 0 আর 4, দুটিই ba).

    কারণ: 10 ≡ 1 (mod 9), তাই Bk ≡ 1 — hash আসলে শুধু অক্ষরের মানের যোগফল mod 9. অক্ষরের ক্রম হারিয়ে যায়, ফলে প্রতিটি anagram (যেমন ab আর ba) collide করে — নিয়মিতভাবে, ভাগ্যক্রমে নয়। শিক্ষা: M মৌলিক আর বড় রাখুন, আর B এমন হোক যার কোনো ছোট ঘাত M-এ ১ হয়ে যায় না; এলোমেলো B বেছে নিলে এই ঝুঁকি নগণ্য।

  3. Period খুঁজুন: s = abcabcabc আর s = ababab-এর π-এর শেষ মান থেকে ক্ষুদ্রতম period বের করুন। কোনটি কোন টুকরোর পুরো পুনরাবৃত্তি? aaaa-র Z-array কী?

    abcabcabc: π = 0 0 0 1 2 3 4 5 6, শেষ মান 6, p = 9 − 6 = 3, 9 % 3 = 0 → abc × ৩. ababab: π-এর শেষ মান 4, p = 6 − 4 = 2, 6 % 2 = 0 → ab × ৩.

    aaaa-র Z-array: − 3 2 1 (z[1] = 3, z[2] = 2, z[3] = 1). এখান থেকেও period পাওয়া যায়: সবচেয়ে ছোট i যেখানে i + z[i] = n — এখানে i = 1, তাই period 1, আর 4 % 1 = 0 → a × ৪. KMP আর Z একই তথ্য দুই দিক থেকে দেখায়।

  4. কোড লিখুন: একটি string s-এর সামনে সবচেয়ে কম অক্ষর যোগ করে palindrome বানাতে হবে (যেমন aacecaaa → aaacecaaa, abcd → dcbabcd). prefix function দিয়ে O(n)-এ সমাধান করুন।

    মূল পর্যবেক্ষণ: s-এর সবচেয়ে লম্বা যে prefix নিজেই palindrome, সেটি রেখে বাকি অংশ উল্টে সামনে বসালেই উত্তর। সেই prefix খুঁজতে u = s + '#' + reverse(s) বানান; u-এর শেষ π মান হলো s-এর এমন সবচেয়ে লম্বা prefix যা reverse(s)-এর suffix — অর্থাৎ যার উল্টো রূপ নিজের সমান, মানে palindrome. '#' নিশ্চিত করে মান n ছাড়াবে না।

    string shortestPalindrome(const string& s) {
        string r(s.rbegin(), s.rend());
        vector<int> pi = prefixFunction(s + "#" + r);
        int keep = pi.back();                     // longest palindromic prefix
        return r.substr(0, s.size() - keep) + s;  // reversed rest goes in front
    }

    aacecaaa-এ keep = 7 (aacecaa), সামনে বসে শুধু a. abcd-এ keep = 1, সামনে dcb. সময় ও জায়গা O(n). সরল পদ্ধতিতে (প্রতিটি prefix palindrome কি না যাচাই) সময় O(n²).

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

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

ক্রমKMP দিয়ে খোঁজার সময় text-এর প্রতিটি নতুন অক্ষরে যা ঘটে, সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. text-এর পরের অক্ষর T[i] নেওয়া
  2. k > 0 আর T[i] ≠ P[k] যতক্ষণ, k = π[k − 1] করে ছোট border-এ নামা
  3. T[i] = P[k] হলে k এক বাড়ানো
  4. k = m হলে মিলের শুরু i − m + 1 লিখে রাখা
  5. মিলের পর k = π[m − 1] করে overlap করা পরের মিলের খোঁজ চালু রাখা

s = aabaab-এর prefix function (π[0] থেকে π[5]) কোনটি?

  • 0 0 0 1 2 3
  • 0 1 0 1 2 3
  • 0 1 1 1 2 3
  • 0 1 0 3 1 0

n দৈর্ঘ্যের text-এ m দৈর্ঘ্যের pattern-এর সব মিল KMP দিয়ে খুঁজলে সবচেয়ে খারাপ ক্ষেত্রে মোট সময় কত?

  • O(n·m)
  • O(n log m)
  • O(n + m)
  • O(m), text-এর দৈর্ঘ্যের ওপর নির্ভর না করে

hash মিললে অক্ষর ধরে যাচাই করে এমন Rabin-Karp অ্যালগরিদম সম্পর্কে কোনটি সঠিক?

  • মাঝে মাঝে ভুল অবস্থানকে মিল বলে দেয়
  • সবসময় O(n + m), যেকোনো ইনপুটে
  • প্রতিটি জানালার hash নতুন করে O(m)-এ হিসাব করতে হয়
  • প্রত্যাশিত সময় O(n + m), কিন্তু সবচেয়ে খারাপে O(n·m)

Z algorithm দিয়ে T-এ P খুঁজতে s = P + '$' + T বানানো হয়। '$'-এর বদলে এমন একটি অক্ষর বসালে কী সমস্যা, যা T-এ আছে?

  • z মান |P| ছাড়িয়ে যেতে পারে, ফলে z[i] = m দিয়ে মিল চেনা ভুল হয়
  • অ্যালগরিদম O(n²) হয়ে যায়
  • '$' ছাড়া অন্য কোনো অক্ষর ব্যবহারই করা যায় না
  • Z-box আর কাজ করে না, প্রতিটি z নতুন করে হিসাব হয়

৮ দৈর্ঘ্যের string abcabcab-এর prefix function-এর শেষ মান π[7] = 5. এর ক্ষুদ্রতম period কত, আর এটি কি কোনো ছোট টুকরোর পুরো পুনরাবৃত্তি?

  • period 5, পুনরাবৃত্তি নয়
  • period 3, পুরো পুনরাবৃত্তি নয়
  • period 3, abc-র পুরো পুনরাবৃত্তি
  • period 1

UTF-8-এ সেভ করা "কলম" লেখায় "ম" খুঁজলে C++-এর std::string::find আর Python-এর str.find কত দেবে?

  • দুটিই 2
  • C++ 2, Python 6
  • C++ 6, Python 2
  • C++ খুঁজেই পায় না (npos), কারণ বাংলা বাইট ভেঙে যায়

শব্দকোষ

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

মনে রাখুন

  1. সরল matching-এর worst case Θ(n·m) (aaa…a / aa…ab); সর্বোত্তম হলো O(n + m).
  2. Rabin-Karp: rolling hash O(1)-এ সরে; hash মিললে যাচাই; প্রত্যাশিত O(n + m), সবচেয়ে খারাপে O(n·m); বড় মৌলিক mod, এলোমেলো base, দরকারে দুটি hash.
  3. π[i] = s[0..i]-এর সবচেয়ে লম্বা border; অমিলে k = π[k − 1]; potential Φ = k দিয়ে প্রমাণ — KMP সবসময় Θ(n + m).
  4. Z algorithm: Z-box [l, r) থেকে মান ধার; P + '$' + T-এ z[i] = m মানে মিল; O(n + m).
  5. ক্ষুদ্রতম period n − π[n − 1]; বাংলা লেখায় বাইট-index, code point-index আর grapheme আলাদা — normalize করে খুঁজুন।

পরের পর্বে: একটি pattern নয়, একই লেখায় হাজার pattern — বা একটি লেখার সব substring নিয়ে প্রশ্ন। Suffix array, LCP আর Aho-Corasick: text-কে একবার সাজিয়ে রাখলে প্রতিটি খোঁজা প্রায় pattern-এর দৈর্ঘ্যের সময়ে।

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

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