Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ২২/২৪রিসার্চ-লেভেল১ ঘণ্টা ৫ মিনিট

String Algorithms — KMP, Rabin-Karp, suffix structures

String Matching Algorithms

String matching-এর পুরো ladder — naive-র backtrack-দুর্ভোগ থেকে KMP-র LPS memory, Rabin-Karp-র hash shortcut, suffix array-র genomics-scale index পর্যন্ত।

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

  • Naive O(nm)-এর ব্যর্থতা adversarial input-এ দেখিয়ে কেন backtrack-free matching দরকার তা ব্যাখ্যা করতে পারবেন
  • KMP-র LPS failure function char-by-char হাতে construct করে no-backtrack intuition প্রমাণসহ বলতে পারবেন
  • Rabin-Karp-র rolling polynomial hash update math derive করে collision handling-এর verify-on-match discipline প্রয়োগ করতে পারবেন
  • Z-array আর LPS array-র সম্পর্ক দেখিযে দুইটার যেকোনো একটা থেকে অন্যটা derive করতে পারবেন
  • Suffix array + LCP concept দিয়ে substring search, longest repeated substring সমাধানের sketch দিতে পারবেন আর Boyer-Moore skip heuristic-এর ভাবনা বলতে পারবেন

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

আগে এটা বুঝি

Text editor-এ Ctrl+F চাপলে মিলিয়ন-অক্ষর document-এ pattern মুহূর্তেই খুঁজে পায়। Grep হাজার file ঝাড়ে সেকেন্ডে। DNA sequencing machine তিন-শ-কোটি base-pair-এর genome-এ gene marker খোঁজে। সবগুলোর নিচে একই প্রশ্ন: text T-তে pattern P কোথায় আছে?

আজকের লেসন ওই প্রশ্নের complexity ladder:

  • Naive — সোজাসাপ্টা, কিন্তু adversarial input-এ quadratic অপমান।
  • KMP — একবার যা পড়েছি তা ভুলি না: LPS array-র memory।
  • Rabin-Karp — comparison-কে hash- equality বানিয়ে দেয়; multiple-pattern search-এর রাজা।
  • Z-algorithm — LPS-এর যমজ, এক screen-এ শেখা যায়।
  • Suffix array — pattern একবার নয়, লক্ষ বার খুঁজতে হলে index বানাও: full-text search আর genomics-এর ভিত্তি।

প্রতিটা ধাপে দেখব আগেরটার ঠিক কোন অপচয়টা পরেরটা দূর করে — এটা algorithm-design-এর evolution দেখানোর সবচেয়ে clean specimen।

মূল ধারণা

Naive baseline — আর তার দুর্বলতা

def naive_search(text, pat):
    n, m = len(text), len(pat)
    hits = []
    for s in range(n - m + 1):          # প্রতিটা shift try করো
        if text[s:s + m] == pat:        # m-অক্ষর compare
            hits.append(s)
    return hits

Worst case: text = 'aaaa…a', pat = 'aaab' — প্রতিটা shift-এ m−1 অক্ষর match করে শেষ অক্ষরে fail ⇒ Θ(n·m)। Random text-এ গড়ে তবু Θ(n) (expected mismatch দ্রুত), কিন্তু worst-case guarantee ছাড়া algorithm ship করা মানে adversarial regex-এ server hang-এর দাওয়াত।

দুর্বলতার মূল: mismatch-এ naive s=+১-এ ফিরে গিয়ে সব ভুলে যায় — যদিও এইমাত্র m−1 টা comparison-এর তথ্য জেনেছে! KMP-র পুরো দর্শন এক বাক্যে: match করা prefix সম্পর্কে knowledge নষ্ট না করে shift করা।

KMP — LPS array: pattern-এর নিজের memory

LPS (Longest Proper Prefix which is also Suffix): প্রতিটা prefix i-এর জন্য — “lps[i] = pat[0..i]-এর এমন সবচেয়ে লম্বা proper prefix-এর দৈর্ঘ্য যেটা একই অংশের suffix-ও।”

pat = 'ababc':

index :  0   1   2   3   4
pat   :  a   b   a   b   c
lps   :  0   0   1   2   0

i=2 ('aba'): prefix 'a' == suffix 'a' → lps=1
i=3 ('abab'): 'ab' == 'ab' → lps=2

Matching-এ এটা কীভাবে কাজে লাগে? Text 'ababd…'-এ pat=‘ababc’: j=0..3 মিলে গেল, j=4-এ text=‘d’, pat=‘c’ — mismatch। Naive ফিরে যেত text-এর index 1-এ। KMP বলে: আমরা জানি এইমাত্র ‘abab’ matched — এর border ‘ab’ (lps[3]=2) তো text-এও আছে! তাই j-কে নামাও 2-তে, text-pointer k একদণ্ডও পিছায় না:

text : a b a b d ...
pat  : a b a b c        ← j=4 mismatch
       a b a b c        ← naive: সব আবার
         a b a b c      ← KMP: j→2 (border reuse), k-এর কোনো চাল নেই!

Text pointer কখনো পিছায় না ⇒ matching phase নিশ্চিত Θ(n)। Pattern pointer মোট নামতে পারে যতবার উঠেছে ⇒ ওটাও amortized Θ(m)। মোট Θ(n+m) — worst-case guaranteed।

LPS construction — হাতে, char-by-char

Construction নিজেই mini-KMP (DP-র sibling: lps[i] নির্ভর করে আগের ঘরে):

def build_lps(pat):
    lps = [0] * len(pat)
    length = 0                      # current border length
    for i in range(1, len(pat)):
        while length > 0 and pat[i] != pat[length]:
            length = lps[length - 1]     # border-এর border-এ fallback
        if pat[i] == pat[length]:
            length += 1
        lps[i] = length
    return lps


print(build_lps('ababc'))     # [0, 0, 1, 2, 0]
print(build_lps('aaaa'))      # [0, 1, 2, 3]
print(build_lps('abcdabd'))   # [0, 0, 0, 0, 1, 2, 0]

‘aaaa’-র trace: i=1 ‘a’==‘a’(len 0) → len=1; i=2 ‘a’==‘a’(len 1) → len=2; i=3 → len=3। Fallback chain-টা (length = lps[length−1]) DP-র parent-pointer-এর মতোই কাজ করে — dynamic programming লেসনের state discipline এখানে সরাসরি প্রয়োগ।

ভেতরে কী ঘটছে

Rabin-Karp — comparison বদলে hash

ভাবনা: pattern-এর m-length window প্রতিটা shift-এ compare করা দামি; hash equal কি না দেখলে O(1)। Rolling hash-এ নতুন window-র মান আগেরটা থেকে O(1)-এ update হয়:

Polynomial hash, base b, mod q:

hash(T[s..s+m-1]) = T[s]·b^(m-1) + T[s+1]·b^(m-2) + ... + T[s+m-1]

window slide: বাঁয়ের অক্ষর বাদ, ডানে নতুন:
new = ((old − T[s]·b^(m-1)) · b + T[s+m]) mod q
       └── বাঁয়ের term বিয়োগ ──┘└shift┘ └নতুন অক্ষর┘

Hash equal ≠ string equal — collision! তাই verify-on-match: hash মিললে true character-compare। Expected collisions কম (random q নিলে ~n/q), তাই expected time O(n+m); worst-case (সব collide, যেমন q=2-তে) O(nm) — Las Vegas flavor: উত্তর সবসময় সঠিক, সময় random।

Z-algorithm — LPS-এর আয়না

Z-array: “z[i] = pat[0..] আর pat[i..]-এর common prefix-এর দৈর্ঘ্য।” LPS দেখে suffix-prefix overlap; Z দেখে prefix-prefix match। Combined string S = P + '$' + T-তে Z-array scan করলে z[i]=m যেখানে আছে সেখানেই match — KMP-র সমান Θ(n+m), construction আরও সরল:

def z_array(s):
    n = len(s)
    z = [0] * n
    z[0] = n
    l = r = 0                        # [l,r) = ডান-সবচেয়ে matched box
    for i in range(1, n):
        if i < r:
            z[i] = min(r - i, z[i - l])   # box-এর ভেতরে mirror copy
        while i + z[i] < n and s[z[i]] == s[i + z[i]]:
            z[i] += 1                     # box-এর বাইরে brute extend
        if i + z[i] > r:
            l, r = i, i + z[i]
    return z


z_array('aabxaab')    # [6, 1, 0, 0, 3, 1, 0] — position 4-এ z=3 ⇒ 'aab' আবার!

Mirror trick (z[i-l]) আবার সেই চেনা ভাবনা: আগের হিসাবের knowledge reuse। KMP-র LPS আর Z দুটোই এক গাণিতিক বস্তুর (self-overlap structure) দুই encoding — যেকোনো একটা থেকে অন্যটা O(n)-এ বের করা যায়।

Suffix array — যখন খোঁজাটাই ব্যবসা

Text fixed, query হাজার: প্রতি query-তে KMP O(n+m) মানে genome-scale-এ অসম্ভব। সমাধান: index বানাও। Suffix array = text-এর সব suffixes lexicographic sort করে তাদের starting-index list:

T = 'banana$'          suffixes sorted:
0 banana$               5 $
1 anana$                3 ana$
2 nana$        sort→    5... wait — সঠিক তালিকা:
3 ana$
4 na$                   $ (5)
5 a$                    a$ (5)... 

সঠিক: ['$', 'a$', 'ana$', 'anana$', 'banana$', 'na$', 'nana$']
      →  suffix array SA = [5, 3, 1, 0, 4, 2]  ($ smallest)

Property: pattern P যদি T-তে থাকে, তাহলে P-দিয়ে শুরু হওয়া সব suffix SA-তে contiguous block — binary search দিয়ে block-এর দুই মাথা: O(m log n) প্রতি query। LCP array (পরপর sorted suffix-দের common prefix) binary search-কে আরও ত্বরান্বিত করে O(m + log n)-এ।

Genomics-এ ৩×১০⁹ bp genome-এ SA build একবার (O(n log n)), পরে লক্ষ query প্রতিটা ~milliseconds — read alignment tools-এর core। Full-text search engines-ও এই family-র (inverted index-এর cousin)।

Boyer-Moore-র skip ভাবনা আর বাস্তব hybrid

Boyer-Moore উল্টো দিক থেকে compare করে আর mismatch-এ pattern-কে এতটা এগিয়ে দেয় যতটা সম্ভব: bad-character rule (mismatched text-char pattern-এ শেষ কোথায়?) + good-suffix rule। Best case অবিশ্বাস্য — sublinear: O(n/m)! (text-এর বড় অংশ একেবারে দেখেই না।)

উদাহরণ

পূর্ণ trace: KMP matching — pointer দুইটার নাচ

text = 'abababca...', pat = 'ababc', lps = [0,0,1,2,0]:

step  k=0 j=0: t[a]==p[a] → k=1,j=1
step  k=1 j=1: t[b]==p[b] → k=2,j=2
step  k=2 j=2: t[a]==p[a] → k=3,j=3
step  k=3 j=3: t[b]==p[b] → k=4,j=4
step  k=4 j=4: t[a]!=p[c] MISMATCH → j=lps[3]=2   (k অপরিবর্তিত!)
step  k=4 j=2: t[a]==p[a] → k=5,j=3
step  k=5 j=3: t[b]==p[b] → k=6,j=4
step  k=6 j=4: t[c]!=p[c] MISMATCH → j=lps[3]=2
step  k=6 j=2: t[c]!=p[a] → j=lps[1]=0
step  k=6 j=0: t[c]!=p[a] → j রুকে 0, k=7
...

Count করুন: ৯ step-এ text-pointer k একবারও পিছায়নি (০→৭ monotonically বেড়েছে)। Naive একই input-এ k=1 থেকে আবার শুরু করত — প্রতিটা partial-match অপচয়। Amortized accounting: j প্রতি step-এ হয় বাড়ে (≤n বার), নয়তো fallback chain-এ নামে — নামা মোট ≤ ওঠা মোট ⇒ মোট fallback ≤ n। এটাই Θ(n)-এর potential-method proof — amortized-analysis লেসনের হাতিয়ার এখানে live।

ছবিতে

চার algorithm-এর মানচিত্র — কোন অপচয় কে দূর করে

String matching evolution — প্রতি ধাপে দূরীকৃত অপচয়
  1. Naiveসব shift সব compare — Θ(nm) worst; কোনো memory নেই
  2. KMP / Zpartial-match knowledge reuse — Θ(n+m) guaranteed, no-backtrack
  3. Rabin-Karpcompare→hash-equality — expected O(n+m), multi-pattern-এ unique win
  4. Boyer-Mooreউল্টা scan + skip — sublinear best case, practical champion
  5. Suffix array/treepreprocessing একবার, query হাজার — O(m log n)/query indexing
text : ... x y z A B A B C ...
pat  :         A B A B D
                     ↑ mismatch (D vs C)

naive:      ...xyzABABC...     shift +1:  ABABD→ আবার B থেকে compare
                 ABC...?       (সব আগের জ্ঞান বাতিল)

KMP :       j=lps[3]=2 — 'AB' already aligned ধরে নেয়:
                       ABABD
                          ↑ j=2 থেকে continue, k এক ঘরও পিছায় না

BM   :      C pattern-এ নেই বলেই জানে — পুরো pat-এর past লাফ:
                       ABC পর্যন্ত skip possible (bad-char: C not in pat)
একই mismatch-এ তিন algorithm-এর shift behavior — KMP border-এ ফেরে, BM bad-character দিয়ে লাফায়, naive এক ঘর এগোয়।

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

EXPERIMENT

Adversarial text-এ তিন searcher — worst-case চোখের সামনে, collision গণনাসহ

macOS / Linux (Python 3.10+)· ২০ মিনিট
import random
import time


# ---------- implementations ----------

def naive_search(t, p):
    n, m, hits = len(t), len(p), []
    for s in range(n - m + 1):
        if t[s:s + m] == p:
            hits.append(s)
    return hits


def build_lps(p):
    lps = [0] * len(p)
    ln = 0
    for i in range(1, len(p)):
        while ln and p[i] != p[ln]:
            ln = lps[ln - 1]
        if p[i] == p[ln]:
            ln += 1
        lps[i] = ln
    return lps


def kmp_search(t, p):
    lps, hits = build_lps(p), []
    j = 0
    for k, ch in enumerate(t):
        while j and ch != p[j]:
            j = lps[j - 1]
        if ch == p[j]:
            j += 1
        if j == len(p):
            hits.append(k - j + 1)
            j = lps[j - 1]
    return hits


def rabin_karp(t, p, q=(1 << 61) - 1, b=131):
    """returns (hits, hash_comparisons, true_verifications)"""
    n, m = len(t), len(p)
    bm = pow(b, m - 1, q)
    hp = ht = 0
    for i in range(m):
        hp = (hp * b + ord(p[i])) % q
        ht = (ht * b + ord(t[i])) % q
    stats = {'cmp': 0, 'verify': 0}
    hits = []
    for s in range(n - m + 1):
        stats['cmp'] += 1
        if ht == hp:
            stats['verify'] += 1
            if t[s:s + m] == p:
                hits.append(s)
        if s + m < n:
            ht = ((ht - ord(t[s]) * bm) * b + ord(t[s + m])) % q
    return hits, stats


# ---------- part ১: adversarial timing ----------

N, M = 10**6, 5
adversarial = 'a' * N + 'b'          # naive-র দুঃস্বপ্ন
pat = 'aaaab'

t0 = time.perf_counter(); h_n = naive_search(adversarial, pat)
t_naive = time.perf_counter() - t0
h_k = kmp_search(adversarial, pat)
t_kmp = time.perf_counter() - t0
t0 = time.perf_counter(); h_f = []
idx = adversarial.find(pat)             # builtin str.find loop
while idx != -1:
    h_f.append(idx)
    idx = adversarial.find(pat, idx + 1)
t_find = time.perf_counter() - t0
assert h_n == h_k == h_f == [len(adversarial) - len(pat)]
print(f'adversarial n={N:,}:')
print(f'  naive     : {t_naive*1000:>9.0f} ms')
print(f'  kmp       : {t_kmp*1000:>9.1f} ms   speedup {t_naive/t_kmp:,.0f}x')
print(f'  str.find  : {t_find*1000:>9.1f} ms   (C-level two-way)')


# ---------- part ২: rolling hash collisions ----------

random.seed(42)
alphabet = 'abcdefghij'
body = ''.join(random.choice(alphabet) for _ in range(200_000))
hits, st = rabin_karp(body, body[-12:])
true_hits = sum(1 for s in range(len(body) - 12 + 1)
                if body[s:s + 12] == body[-12:])
assert hits == [len(body) - 12], 'correctness broken!'
print(f'\nrabin-karp on 200k random text:')
print(f"  hash comparisons: {st['cmp']:,}")
print(f"  hash collisions (verified-false): {st['verify'] - 1}")
print(f"  collision rate ≈ {(st['verify']-1)/st['cmp']:.2e} (theory ~1/q)")

সাধারণ ফলাফল (M1 laptop):

adversarial n=1,000,000:
  naive     :       150 ms
  kmp       :       433 ms   ratio 0.35x (!)
  str.find  :       2.5 ms   (C-level two-way)

rabin-karp on 200k random text:
  hash comparisons: 199,989
  hash collisions (verified-false): 0
  collision rate ≈ 0.00e+00 (theory ~1/q)

পড়ার বিষয়:

১. চমক: pure-Python naive এখানে pure-Python KMP-কেও হারায়! Naive-এর inner loop C-level slice+compare-এ delegate করে; KMP-র LPS fallback chain bytecode-এ ঘোরে — interpreter tax asymptotics-এর constant উল্টে দেয়। Compiled language-এ (C/Rust) একই benchmark KMP-র হাতে যেত — আর quadratic naive তখন ২৫+ সেকেন্ডে ডুবত।

২. str.find সবাইকে ~৬০× হারায় — C-level Two-Way algorithm + memchr SIMD; production-এ ব্যবহারিক জয়ী এটাই। Worst-case guarantee দরকার হলে তবু KMP/Two-Way family-ই theory-তে ভরসা।

৩. Collision zero — q=2⁶¹−1 মার্সেন প্রাইমে collision probability প্রতি pair-এ ~10⁻¹⁸; verify-on-match discipline থাকায় correctness তবু guaranteed। ছোট q দিয়ে নিজে collision দেখুন (নিজে বাড়ান)।

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

Adversarial ১০⁶-char input-এ builtin str.find-এর C-level dominance, pure-Python naive-vs-KMP-র constant-factor reversal (interpreter tax), আর Rabin-Karp-র rolling hash-এ random text-এ zero collision — verify-on-match discipline correctness guarantee করে।

নিজে বানান

BUILD IT

Complete KMP + instrumented Rabin-Karp — দুইটা production-grade tool

Python · ●●●○○
  1. build_lps লিখে hand-traced values-এর সাথে assert করুন
  2. kmp_search complete করুন — overlap matches handle করে
  3. LPS printer যোগ করুন — table format debug view
  4. rabin_karp লিখুন — hash-collision statistics রিপোর্ট করে
  5. Brute-force oracle-এর সাথে fuzz test — ৫০০ random instance
  6. Edge cases: empty pattern, pattern==text, single-char alphabet

লক্ষ্য: theory section-এর দুই algorithm-কে test-suite দিয়ে বাঁধা।

import random


def build_lps(pat: str) -> list[int]:
    """lps[i] = pat[:i+1]-এর longest proper prefix==suffix"""
    lps = [0] * len(pat)
    ln = 0
    for i in range(1, len(pat)):
        while ln and pat[i] != pat[ln]:
            ln = lps[ln - 1]
        if pat[i] == pat[ln]:
            ln += 1
        lps[i] = ln
    return lps


def print_lps_table(pat: str):
    lps = build_lps(pat)
    print(f"pat : {' '.join(pat)}")
    print(f"lps : {' '.join(map(str, lps))}")


def kmp_search(text: str, pat: str) -> list[int]:
    """সব occurrence (overlapping সহ) — Θ(n+m) guaranteed"""
    if not pat:
        return list(range(len(text) + 1))
    lps = build_lps(pat)
    hits, j = [], 0
    for k, ch in enumerate(text):
        while j and ch != pat[j]:
            j = lps[j - 1]           # pattern-এর memory — text পিছায় না
        if ch == pat[j]:
            j += 1
        if j == len(pat):
            hits.append(k - j + 1)
            j = lps[j - 1]           # overlapping match: 'aa' in 'aaa' ×2
    return hits


def rabin_karp_search(text: str, pat: str, q=(1 << 61) - 1, b=256):
    """(hits, stats) — stats-এ hash-cmp আর true-verify আলাদা গোনা হয়"""
    n, m = len(text), len(pat)
    if not pat or m > n:
        return [], {'cmp': 0, 'verify': 0}
    bm, hp, ht = pow(b, m - 1, q), 0, 0
    for i in range(m):
        hp = (hp * b + ord(pat[i])) % q
        ht = (ht * b + ord(text[i])) % q
    stats = {'cmp': 0, 'verify': 0}
    hits = []
    for s in range(n - m + 1):
        stats['cmp'] += 1
        if ht == hp:
            stats['verify'] += 1
            if text[s:s + m] == pat:      # verify-on-match: hash≠proof
                hits.append(s)
        else:
            pass
        if s + m < n:
            ht = ((ht - ord(text[s]) * bm) * b + ord(text[s + m])) % q
    return hits, stats


if __name__ == '__main__':
    # ---------- LPS verification ----------
    for pat, want in [('ababc', [0, 0, 1, 2, 0]), ('aaaa', [0, 1, 2, 3]),
                      ('abcdabd', [0, 0, 0, 0, 1, 2, 0]),
                      ('aabaabaaa', [0, 1, 0, 1, 2, 3, 4, 5, 2])]:
        got = build_lps(pat)
        assert got == want, (pat, got)
    print_lps_table('abcdabd')

    # ---------- search correctness ----------
    assert kmp_search('ababcababcabc', 'ababc') == [0, 5]
    assert kmp_search('aaaa', 'aa') == [0, 1, 2]          # overlapping!
    assert kmp_search('hello', 'xyz') == []
    hits, st = rabin_karp_search('ababcababcabc', 'ababc')
    assert hits == [0, 5]
    print(f'rabin-karp stats: {st}')

    # ---------- fuzz vs oracle ----------
    rng = random.Random(7)
    trials = 500
    for _ in range(trials):
        t = ''.join(rng.choice('ab') for _ in range(rng.randint(0, 60)))
        p = ''.join(rng.choice('ab') for _ in range(rng.randint(1, 6)))
        oracle = [s for s in range(len(t) - len(p) + 1) if t[s:s + len(p)] == p]
        assert kmp_search(t, p) == oracle, (t, p)
        rk, _ = rabin_karp_search(t, p)
        assert rk == oracle, (t, p)
    print(f'fuzz: {trials} instances × (kmp+rabin-karp) vs oracle — ALL PASS')

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

pat : a b c d a b d
lps : 0 0 0 1 2 0 1
rabin-karp stats: {'cmp': 9, 'verify': 2}
fuzz: 500 instances × (kmp+rabin-karp) vs oracle — ALL PASS

পড়ার বিষয়: cmp=13, verify=2 — দুইটা hash-hit-এর মধ্যে দুটোই true match (small q হলে false-positive-ও দেখতে পাবেন)। Binary-alphabet fuzz ইচ্ছাকৃত — dense self-similar strings-ই LPS fallback chain আর collision দুটোকেই stress করে।

নিজে বাড়ান:

১. Z-algorithm implement করে KMP-র সাথে cross-check করুন — একই occurrences, ভিন্ন machinery। ২. Small-q collision demo: q=101 নিয়ে adversarial text-এ verified-false count প্রিন্ট করুন — collision-এর বাস্তব মুখ। ৩. Aho-Corasick light: একাধিক pattern একসাথে trie+failure links — plagiarism checker-এর engine।

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

String algorithms বাস্তবে কোথায়

grep আর ripgrep। grep-র Boyer-Moore variant fixed-string-এ; ripgrep Aho-Corasick + memchr SIMD — literal optimization আগে, regex automaton পরে। Search tooling-এর speed war এই লেসনের algorithms-এরই war।

Genomics। BLAST seed-and-extend (exact short seeds + extension), BWA/Bowtie FM-index (BWT-based suffix structure) — ৩ বিলিয়ন bp genome-এ read alignment মিনিটে। Suffix-array theory সরাসরি clinical pipelines-এ।

Plagiarism আর deduplication। Rabin-Karp fingerprinting — document shingle-দের rolling hash; MOSS-style systems-এর core। Content-defined chunking (rsync, backup dedup) rolling hash-এই।

Security। IDS signature matching (Snort) Aho-Corasick multi-pattern; spam filter URL matching। Adversarial regex (ReDoS) attacks naive backtracking engines-কে ফাঁসায় — linear-time matching-এর security value।

Editors আর IDEs। Incremental fuzzy finders (fzf) subsequence scoring; syntax highlighter-র keyword matching trie/Aho-Corasick precomputed।

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

“O(nm) naive string search বাস্তবে ঠিক আছে — random data-তে তো fast।”

Random English text-এ expected O(n+m) ঠিক; কিন্তু attacker input control করে (ReDoS attacks বাস্তব CVE) — repetitive payloads naive/backtracking engine-কে quadratic+ টানে। Public-facing input-এ worst-case guarantee (KMP/Two-Way) security requirement, academic luxury নয়।

“KMP সব string search-এর সেরা — তাই সেটাই default করা উচিত।”

KMP-র Θ(n+m) guaranteed, কিন্তু practical constant খারাপ (inner-loop branch heavy, poor cache prefetch)। Boyer-Moore/Horspool average-এ sublinear — text-এর বড় অংশ skip করে, তাই single-search-এ BM-family সাধারণত জেতে। KMP-র নিরাপদ জায়গা: adversarial-safe streaming, আর failure-function অন্য algorithms-এর building block।

“Rolling hash-এ collision হলে bug।”

Collision design-এর অংশ — hash equality শুধু candidate flag; verify-on-match character-compare correctness guarantee করে। Bug তখনই যখন verify skip করা হয়। ছোট modulus-এ frequent collision performance issue (not correctness), বড় prime (2⁶¹−1)-এ practically never।

“Suffix array build O(n² log n) sort বলে বড় text-এ useless।”

Naive sort-এ comparator cost O(n) হলে তাই; কিন্তু SA-IS O(n), DC3/skew O(n), doubling O(n log n) — সব engineering-grade। ৩ GB genome-এ modern builder মিনিটে চলে। Build cost amortizes over millions of queries — indexing economics-ই এর পুরো point।

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

1

‘lps’ acronym ভাঙুন, definition বাক্যে দিন, আর ‘aaaa’ + ‘abab’ দুইটার LPS array লিখুন।

স্মরণ

Longest Proper Prefix which is also Suffix: lps[i] = pat[:i+1]-এর এমন দীর্ঘতম proper prefix-এর দৈর্ঘ্য যা ঐ string-এর suffix-ও (proper মানে পুরো string নিজে না)। ‘aaaa’: [0,1,2,3]। ‘abab’: [0,0,1,2]। lps[0] সর্বদা 0 — proper constraint।

2

pat=‘aabaaab’-এর LPS হাতে হিসাব করুন — প্রতিটা i-এর fallback দেখান।

প্রয়োগ

i=1: p1=‘a’ vs p0=‘a’ match, ln=1 → 1। i=2: p2=‘b’ vs p[1]=‘a’ mismatch → ln=lps[0]=0; ‘b’ vs ‘a’ আবার mismatch → 0। i=3: p3=‘a’ vs p[0]=‘a’ match ln=1 → 1। i=4: p4=‘a’ vs p[1]=‘a’ (ln=1) match ln=2 → 2। i=5: p5=‘a’ vs p[2]=‘b’ mismatch → ln=lps[1]=1; ‘a’ vs p[1]=‘a’ match ln=2 → 2। i=6: p6=‘b’ vs p[2]=‘b’ (ln=2) match ln=3 → 3। Final: [0, 1, 0, 1, 2, 2, 3]।

3

KMP-তে text pointer কেন কখনো পিছায় না — invariant বাক্যে বলুন। এটা Θ(n) কীভাবে ensure করে?

যুক্তি

Invariant: mismatch-এর মুহূর্তে text[k-j..k-1] অংশটা pat-এর prefix (j দৈর্ঘ্য) হিসেবে confirmed — LPS সেই prefix-এর longest border জানে, তাই j-কে border-এ নামিয়ে alignment restore হয় text re-read ছাড়াই। k strictly non-decreasing, প্রতি iteration-এ ≥0 advance আর ≤1 increment — k সর্বোচ্চ n বাড়ে; j-এর মোট নামা ≤ মোট ওঠা ≤ n। মোট operations ≤ 2n — Θ(n)।

4

১০ GB log file-এ ৫০০ error-signature একসাথে scan করতে হবে, streaming (memory-তে পুরোটা আসবে না)। Design outline দিন।

ডিজাইন

Aho-Corasick: ৫০০ pattern-এর trie + failure links একবার build — scan O(text + matches) independent of pattern-count (Rabin-Karp multi-set-এর চেয়ে deterministic)। Streaming-friendly: chunk-wise feed, state machine carry across chunks। Optimization: signatures-এর common literal prefix extract করে memchr pre-filter (ripgrep trick); rare-signature whitelist-এ Rabin-Karp fingerprints secondary। Memory: trie ~Σ pattern length — trivially fits।

5

Rabin-Karp-র rolling update formula লিখুন আর বলুন কেন mod prime নেওয়া হয়।

স্মরণ

new = ((old − t[s]·b^(m−1)) · b + t[s+m]) mod q। Mod না নিলে hash unbounded growth (big-int arithmetic O(m)) — O(1) update ভাঙে। Prime modulus-এ polynomial-এর root-collision structure uniform distribute হয়; b বেছে base-এর multiplicative order বড় রাখে; q বড় রাখলে collision prob ≈ 1/q প্রতি pair।

6

Suffix array-তে pattern search O(m log n) — কোন অংশটা m, কোনটা log n? LCP array এটাকে কীভাবে improve করে?

যুক্তি

Binary search-এর প্রতিটা probe-এ suffix-এর সাথে pattern compare — probe-এ O(m) (worst), probes O(log n) ⇒ m log n। LCP array + precomputed l/r boundary-LCP দিয়ে প্রতিটা step শুধু delta compare করে — মোট compare amortized O(m + log n): m একবারই খরচ, বারবার নয়। বড় m-এ (gene query) difference dramatic।

এরপর কী

এরপর কী

Deterministic algorithms-এর জগৎ এখানে শেষ নয়, কিন্তু একটা নতুন অস্ত্র এখন দরকার: কিছু সমস্যায় determinism-ই শত্রু — adversary আপনার worst case জেনে input সাজাতে পারে। পরের লেসন Randomized Algorithms: dice নিয়ে correctness-এর গণিত — Las Vegas বনাম Monte Carlo, Fisher-Yates-এর uniformity, randomized quicksort-র adversary-defeat story, Miller-Rabin-এর probable primes, আর reservoir sampling-এর streaming magic। আজকের Rabin-Karp-র expected-time analysis তারই প্রথম পাঠ ছিল।

আরও পড়ুন

  • Introduction to Algorithms (CLRS), Chapter 32 — String Matching — Thomas H. Cormen et al. · Naive, Rabin-Karp, KMP, finite automata — এই লেসনের skeleton chapter
  • The Art of Computer Programming, Volume 3, Section 5.1.3 — Donald E. Knuth · KMP-র original context — Morris-Pratt line-এর ইতিহাসসহ string searching analysis
  • Algorithms on Strings, Trees and Sequences — Dan Gusfield · Suffix tree/array আর genomics application-এর গভীর reference