পাঠ ৪০ · ৫৭-এর মধ্যে · মডিউল ৯
Home / Courses / Design and Analysis of Algorithms / নেইভ ম্যাচিং ও রবিন-কার্প

নেইভ স্ট্রিং ম্যাচিং ও রবিন-কার্প

Naive string matching & the Rabin-Karp rolling hash algorithm
১২ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • নেইভ স্ট্রিং ম্যাচিং-এর সময় জটিলতা এবং এটি কেন ওয়ার্স্ট কেসে ধীর
  • পলিনোমিয়াল রোলিং হ্যাশ কীভাবে গণনা করা হয়, এবং কীভাবে $O(1)$ সময়ে আপডেট করা যায়
  • হ্যাশ কলিশন কী, কেন এটি ঘটে, এবং কেন প্রতিটি হ্যাশ-ম্যাচকে ক্যারেক্টার-বাই-ক্যারেক্টার ভেরিফাই করা বাধ্যতামূলক
  • রবিন-কার্পের গড় ও ওয়ার্স্ট কেস জটিলতা বিশ্লেষণ

১ · নেইভ স্ট্রিং ম্যাচিং

স্ট্রিং ম্যাচিং সমস্যাটি সহজ: একটি টেক্সট $T$ (দৈর্ঘ্য $n$) এবং একটি প্যাটার্ন $P$ (দৈর্ঘ্য $m$) দেওয়া থাকলে, $T$-এর মধ্যে $P$-এর সব শুরুর পজিশন খুঁজে বের করা। এই অ্যালগরিদমের মৌলিক ধারণা DSA কোর্সে ইতিমধ্যে কভার হয়েছে — এখানে আমরা সরাসরি এর জটিলতা বিশ্লেষণ এবং এটিকে একটি রেফারেন্স/বেসলাইন হিসেবে ব্যবহার করব।

নেইভ পদ্ধতি: প্রতিটি সম্ভাব্য শুরুর পজিশন $i \in \{0, 1, \dots, n-m\}$-এর জন্য, $P$-এর প্রতিটি ক্যারেক্টার $T[i \dots i+m-1]$-এর সাথে সরাসরি তুলনা করা হয়। ওয়ার্স্ট কেসে (যেমন $T = $ "aaa...a" এবং $P = $ "aaa...ab") প্রতিটি পজিশনে প্রায় পুরো প্যাটার্নের দৈর্ঘ্য পর্যন্ত তুলনা করতে হতে পারে — তাই সময় জটিলতা $$T(n, m) = O((n - m + 1) \cdot m) = O(nm)$$ যেখানে $n$ ও $m$ কাছাকাছি হলে এটি $O(n^2)$-এ পরিণত হয়।

২ · রবিন-কার্প — হ্যাশিং দিয়ে দ্রুততর যাচাই

রবিন-কার্পের মূল ধারণা: প্রতিটি ক্যারেক্টার আলাদাভাবে তুলনা করার বদলে, প্রতিটি $m$-দৈর্ঘ্যের উইন্ডোর একটি একক সংখ্যা — একটি হ্যাশ — গণনা করা হয়, এবং সেই হ্যাশটি প্যাটার্নের হ্যাশের সাথে তুলনা করা হয়। দুটো হ্যাশ ভিন্ন হলে নিশ্চিতভাবে ম্যাচ নেই (কোনো ক্যারেক্টার তুলনার দরকার নেই) — কিন্তু দুটো হ্যাশ সমান হলে সম্ভাব্য ম্যাচ, যা যাচাই করতে হবে (এই বিষয়ে বিস্তারিত ৪ নম্বর সেকশনে)।

একটি $m$-দৈর্ঘ্যের স্ট্রিং $s = s_0 s_1 \dots s_{m-1}$-এর পলিনোমিয়াল হ্যাশ একটি বেস $b$ এবং মডুলাস $q$ নিয়ে এভাবে সংজ্ঞায়িত করা হয়: $$H(s) = \left( s_0 \cdot b^{m-1} + s_1 \cdot b^{m-2} + \dots + s_{m-1} \cdot b^0 \right) \bmod q$$ যেখানে প্রতিটি $s_i$ হলো সেই ক্যারেক্টারের সংখ্যাসূচক কোড (যেমন ord())। এটি ক্যারেক্টার স্ট্রিংকে একটি $b$-ভিত্তিক সংখ্যা হিসেবে দেখার মতোই — ঠিক যেভাবে "১২৩" সংখ্যাটি $1 \cdot 10^2 + 2 \cdot 10^1 + 3 \cdot 10^0$।

আসল কৌশলটি হলো রোলিং আপডেট: টেক্সটে উইন্ডো একধাপ ডানে সরানো হলে, পুরনো হ্যাশ থেকে নতুন হ্যাশ $O(1)$ সময়ে বের করা যায় — পুরো উইন্ডো আবার স্ক্যান না করে। উইন্ডো $T[i \dots i+m-1]$ থেকে $T[i+1 \dots i+m]$-এ সরানো হলে: $$H_{i+1} = \left( (H_i - T[i] \cdot b^{m-1}) \cdot b + T[i+m] \right) \bmod q$$ অর্থাৎ — প্রথমে পুরনো প্রথম ক্যারেক্টারের অবদান বাদ দাও, বাকিটাকে একধাপ "শিফট" করো (গুণ করে $b$ দিয়ে), এবং নতুন শেষ ক্যারেক্টার যোগ করো। প্রতিটি ধাপে ধ্রুবক সংখ্যক গাণিতিক অপারেশন — তাই $O(1)$ প্রতি শিফটে।

T = a b c a b x a b c H_i H_i+1 বাদ দাও: T[i]·b^(m-1) শিফট করো: ×b, তারপর যোগ T[i+m] সব mod q তে — মোট O(1) কাজ হ্যাশ মিলল? তাহলে ভেরিফাই করো
উইন্ডো একধাপ সরলে পুরো $m$ ক্যারেক্টার আবার স্ক্যান না করে, পুরনো হ্যাশ থেকে $O(1)$ সময়ে নতুন হ্যাশ বের করা যায়।

৩ · বাস্তবায়ন ও নেইভ বেসলাইনের বিপরীতে যাচাই

নিচের কোডে নেইভ ম্যাচিং এবং রবিন-কার্প — দুটোই সম্পূর্ণভাবে বাস্তবায়ন করা হয়েছে। রবিন-কার্পে রোলিং আপডেট সূত্রটি সরাসরি প্রয়োগ করা হয়েছে (প্রতিটি শিফটে হ্যাশ নতুন করে গণনা করা হয় না)। এরপর একগুচ্ছ টেস্ট স্ট্রিং-এ (খালি প্যাটার্ন, টেক্সটের চেয়ে বড় প্যাটার্ন, ওভারল্যাপিং ম্যাচ, কোনো ম্যাচ না থাকা সহ) দুটো অ্যালগরিদমের ফলাফল assert দিয়ে মিলিয়ে দেখা হয়েছে।

Python
def naive_match(text, pattern):
    # নেইভ O(nm): প্রতিটি সম্ভাব্য শুরুর পজিশনে ক্যারেক্টার-বাই-ক্যারেক্টার তুলনা
    n, m = len(text), len(pattern)
    positions = []
    if m == 0 or m > n:
        return positions
    for i in range(n - m + 1):
        match = True
        for j in range(m):
            if text[i + j] != pattern[j]:
                match = False
                break
        if match:
            positions.append(i)
    return positions


def rabin_karp_match(text, pattern, base=256, mod=1_000_000_007):
    # রবিন-কার্প: পলিনোমিয়াল রোলিং হ্যাশ, O(1) প্রতি শিফটে আপডেট
    n, m = len(text), len(pattern)
    positions = []
    spurious_hits = []   # হ্যাশ মিলেছিল কিন্তু আসল ক্যারেক্টার মেলেনি -- এমন পজিশন
    if m == 0 or m > n:
        return positions, spurious_hits

    high_order = pow(base, m - 1, mod)   # b^(m-1) mod q, একবারই গণনা করা হয়

    pattern_hash = 0
    window_hash = 0
    for i in range(m):
        pattern_hash = (pattern_hash * base + ord(pattern[i])) % mod
        window_hash = (window_hash * base + ord(text[i])) % mod

    for i in range(n - m + 1):
        if window_hash == pattern_hash:
            # হ্যাশ মিলেছে -- কিন্তু এটি একটি সম্ভাব্য ম্যাচ মাত্র, নিশ্চিত নয়!
            # কলিশনের সম্ভাবনার কারণে আসল ক্যারেক্টারগুলো তুলনা করে ভেরিফাই করতেই হবে।
            if text[i:i + m] == pattern:
                positions.append(i)
            else:
                spurious_hits.append(i)
        if i < n - m:
            # রোলিং আপডেট: পুরনো ক্যারেক্টার বাদ, শিফট, নতুন ক্যারেক্টার যোগ -- সবই O(1)
            window_hash = (window_hash - ord(text[i]) * high_order) % mod
            window_hash = (window_hash * base + ord(text[i + m])) % mod
            window_hash %= mod

    return positions, spurious_hits


tests = [
    ("abababab", "aba"),
    ("aaaaaaaa", "aa"),
    ("abcdefg", "xyz"),
    ("mississippi", "issi"),
    ("", "a"),
    ("abc", ""),
    ("abc", "abcd"),
    ("aaaa", "a"),
    ("thequickbrownfoxjumpsoverthelazydogthequickbrownfox", "thequickbrownfox"),
]

print(f"{'text':<54} | {'pattern':<18} | {'নেইভ':<16} | মিলেছে?")
all_ok = True
for text, pattern in tests:
    naive_pos = naive_match(text, pattern)
    rk_pos, spurious = rabin_karp_match(text, pattern)
    ok = (naive_pos == rk_pos)
    all_ok = all_ok and ok
    print(f"{text:<54} | {pattern:<18} | {str(naive_pos):<16} | {'হ্যাঁ' if ok else 'না -- BUG!'}")
    assert naive_pos == rk_pos, f"মিলছে না: {text!r}, {pattern!r}"

print("\nসব", len(tests), "টি টেস্ট কেসে নেইভ ও রবিন-কার্প পুরোপুরি একমত (assert পাস করেছে):", all_ok)

    

৪ · হ্যাশ কলিশন — কেন ভেরিফিকেশন বাধ্যতামূলক

দুটো ভিন্ন স্ট্রিং-এর হ্যাশ ভ্যালু সমান হয়ে যাওয়াকে হ্যাশ কলিশন বলে। কবুতর-গর্ত নীতি (pigeonhole principle) অনুযায়ী এটি অনিবার্য — সসীম সংখ্যক হ্যাশ ভ্যালু ($0$ থেকে $q-1$) দিয়ে অসীম সংখ্যক সম্ভাব্য স্ট্রিং প্রতিনিধিত্ব করতে হয়। তাই হ্যাশ মিলে যাওয়া মানেই স্ট্রিং মিলে যাওয়া নয় — এটি শুধু একটি "সম্ভাব্য ম্যাচ" যা অবশ্যই ক্যারেক্টার-বাই-ক্যারেক্টার তুলনা করে নিশ্চিত করতে হবে। উপরের কোডে ঠিক এই কাজটিই করা হয়েছে: if text[i:i + m] == pattern: লাইনটি প্রতিটি হ্যাশ-ম্যাচকে যাচাই করে।

কলিশন সরাসরি চোখে দেখার জন্য, নিচে ইচ্ছাকৃতভাবে একটি খুব ছোট মডুলাস ($q = 2$) ব্যবহার করা হয়েছে, যা বাস্তব ব্যবহারে কখনও করা উচিত নয় (এত ছোট মডুলাসে অনেক স্ট্রিং-এর হ্যাশ একই হয়ে যায়) — কিন্তু এটি স্পষ্টভাবে দেখায় যে ভেরিফিকেশন ধাপ ছাড়া অ্যালগরিদমটি ভুল ম্যাচ রিপোর্ট করে ফেলত।

Python
text = "abcdabcxabca"
pattern = "abca"

naive_pos = naive_match(text, pattern)
# ইচ্ছাকৃতভাবে খুব ছোট base=2, mod=2 -- বাস্তবে ব্যবহার করবেন না, শুধু কলিশন দেখানোর জন্য
rk_pos, spurious = rabin_karp_match(text, pattern, base=2, mod=2)

print("নেইভ ম্যাচ পজিশন:      ", naive_pos)
print("রবিন-কার্প ম্যাচ পজিশন:", rk_pos)
print("চূড়ান্ত ফলাফল মিলেছে?  ", naive_pos == rk_pos)

print(f"\nমোট {len(spurious)}টি স্পিউরিয়াস হিট (হ্যাশ মিলেছিল, ক্যারেক্টার মেলেনি):")
for i in spurious:
    window = text[i:i + len(pattern)]
    print(f"  পজিশন {i}: উইন্ডো {window!r} -- প্যাটার্ন {pattern!r}-এর সাথে হ্যাশ সমান, কিন্তু আসলে ভিন্ন স্ট্রিং")

assert naive_pos == rk_pos
print("\n-- ভেরিফিকেশন ধাপ (text[i:i+m] == pattern) থাকায় স্পিউরিয়াস হিটগুলো বাদ পড়েছে,")
print("   এবং চূড়ান্ত ফলাফল তবুও নেইভ বেসলাইনের সাথে সম্পূর্ণ সঠিক থেকেছে।")

    
লক্ষ্য করুন — base=2, mod=2-তে অনেক ভিন্ন উইন্ডোর হ্যাশ একই হয়ে গেছে (স্পিউরিয়াস হিট), কারণ মাত্র দুটি সম্ভাব্য হ্যাশ ভ্যালু ($0$ অথবা $1$) দিয়ে অনেক স্ট্রিং প্রতিনিধিত্ব করতে হচ্ছে। বাস্তবে $q = 10^9 + 7$-এর মতো একটি বড় মৌলিক সংখ্যা ব্যবহার করা হয় যাতে কলিশনের সম্ভাবনা ব্যবহারিকভাবে খুবই কম হয় — কিন্তু শূন্য কখনোই নয়, তাই ভেরিফিকেশন ধাপটি সবসময় প্রয়োজনীয়, যত বড় মডুলাসই ব্যবহার করা হোক না কেন।
জটিলতা বিশ্লেষণ · Complexity

হ্যাশ গণনা ও রোলিং আপডেট প্রতি শিফটে $O(1)$ (মডুলার অ্যারিথমেটিক ধ্রুব-আকারের সংখ্যার উপর, যা RAM মডেলে $O(1)$ ধরা হয় — দেখুন L04)। তাই $n - m + 1$টি উইন্ডোর জন্য হ্যাশ তুলনা করতে মোট $O(n + m)$ সময় লাগে (প্রাথমিক হ্যাশ গণনায় $O(m)$, তারপর প্রতিটি শিফটে $O(1)$)। কিন্তু যদি অনেক হ্যাশ কলিশন ঘটে (যেমন উপরের ইচ্ছাকৃত ছোট-মডুলাস উদাহরণে), তাহলে প্রতিটি কলিশনে $O(m)$ সময়ের ভেরিফিকেশন লাগবে — ওয়ার্স্ট কেসে এটি আবার $O(nm)$-এ ফিরে যেতে পারে। তাই রবিন-কার্পের গড় ও প্র্যাকটিক্যাল কেস $O(n + m)$, কিন্তু তাত্ত্বিক ওয়ার্স্ট কেস নেইভের মতোই $O(nm)$ — একটি বড় মৌলিক মডুলাস বেছে নিলে বাস্তবে কলিশন এত বিরল হয় যে গড় কেসই কার্যত সবসময় ঘটে।

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

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

প্র ০১ হ্যাশ মিলে গেলে কেন সরাসরি ম্যাচ ঘোষণা করা যাবে না — ভেরিফিকেশন ধাপ বাদ দিলে কী সমস্যা হতে পারে?

ভেরিফিকেশন বাদ দিলে অ্যালগরিদম ভুল পজিটিভ (false positive) রিপোর্ট করতে পারে — এমন একটি পজিশন যেখানে হ্যাশ সমান কিন্তু আসল স্ট্রিং ভিন্ন (হ্যাশ কলিশন)। উপরের ছোট-মডুলাস ডেমোতে দেখা গেছে এমন একাধিক স্পিউরিয়াস হিট ঘটেছে — ভেরিফিকেশন ছাড়া অ্যালগরিদমটি এই ভুল পজিশনগুলোকেও প্রকৃত ম্যাচ হিসেবে রিপোর্ট করে ফেলত, যা সম্পূর্ণ ভুল আউটপুট।

প্র ০২ বড় মডুলাস ($q = 10^9+7$) ব্যবহার করলে কি ভেরিফিকেশন ধাপটি সম্পূর্ণ বাদ দেওয়া নিরাপদ?

না। বড় মডুলাস কলিশনের সম্ভাবনা কমায় (ব্যবহারিকভাবে খুবই বিরল করে তোলে), কিন্তু কলিশন সম্পূর্ণ অসম্ভব করে না — কবুতর-গর্ত নীতি অনুযায়ী সসীম হ্যাশ স্পেসে অসীম স্ট্রিং প্রতিনিধিত্ব করলে কলিশন গাণিতিকভাবে অনিবার্য। এমনকি একটি বিরল কলিশনও, ভেরিফিকেশন ছাড়া, একটি ভুল ফলাফল তৈরি করতে পারে। তাই ভেরিফিকেশন ধাপটি অ্যালগরিদমের সঠিকতার জন্য আবশ্যিক, শুধু একটি ঐচ্ছিক নিরাপত্তা নয়।

প্র ০৩ প্যাটার্নের দৈর্ঘ্য টেক্সটের দৈর্ঘ্যের চেয়ে বড় হলে (যেমন উপরের টেস্টে ("abc", "abcd")) উভয় অ্যালগরিদম কীভাবে সঠিকভাবে সামলায়?

দুটো ফাংশনেই শুরুতে if m == 0 or m > n: return ... চেক আছে, যা এই এজ কেসটিকে আলাদাভাবে সামলায় — প্যাটার্ন টেক্সটের চেয়ে বড় হলে কোনো ম্যাচ সম্ভবই না, তাই সরাসরি খালি লিস্ট রিটার্ন করা হয়। এই চেক না থাকলে range(n - m + 1)-এ ঋণাত্মক সংখ্যা চলে আসত এবং লুপ ভুলভাবে চলত (Python-এ range() ঋণাত্মক দৈর্ঘ্যে খালি রেঞ্জ দেয়, তাই এই নির্দিষ্ট কেসে হয়তো ক্র্যাশ করত না, কিন্তু এক্সপ্লিসিট চেক কোডকে স্পষ্ট ও নির্ভরযোগ্য করে তোলে)।

অনুশীলন

  1. চিন্তা করুন: যদি টেক্সট হয় "zzzzzzzzzz" (১০টি "z") এবং প্যাটার্ন "zz", তাহলে নেইভ ম্যাচিং কতগুলো ম্যাচ পজিশন খুঁজে পাবে, এবং কেন এটি একটি "ওভারল্যাপিং ম্যাচ" উদাহরণ?

    মোট ৯টি ম্যাচ পজিশন পাওয়া যাবে ($0$ থেকে $8$ পর্যন্ত) — প্রতিটি সংলগ্ন জোড়া "zz" একটি ম্যাচ, এবং এই ম্যাচগুলো একে অপরের সাথে ওভারল্যাপ করে (পজিশন $0$-এর ম্যাচ ইনডেক্স $0,1$ কভার করে; পজিশন $1$-এর ম্যাচ ইনডেক্স $1,2$ কভার করে — ইনডেক্স $1$ দুটো ম্যাচেই আছে)। নেইভ অ্যালগরিদম স্বাভাবিকভাবেই সব ওভারল্যাপিং ম্যাচ খুঁজে পায় কারণ এটি প্রতিটি শুরুর পজিশন স্বাধীনভাবে পরীক্ষা করে।

  2. পরীক্ষা করুন: উপরের প্রথম কোড সেলে tests লিস্টে ("zzzzzzzzzz", "zz") যোগ করে Run চেপে আপনার অনুমান যাচাই করুন — নেইভ ও রবিন-কার্প কি একমত?

    হ্যাঁ, উভয় অ্যালগরিদমই [0, 1, 2, 3, 4, 5, 6, 7, 8] রিটার্ন করবে এবং assert পাস করবে। এটি নিশ্চিত করে যে রবিন-কার্পের রোলিং হ্যাশ ওভারল্যাপিং ম্যাচের ক্ষেত্রেও নেইভ বেসলাইনের সাথে সম্পূর্ণ সামঞ্জস্যপূর্ণ — উইন্ডো প্রতিবার একধাপ করে সরে এবং প্রতিটি পজিশনে স্বাধীনভাবে হ্যাশ পরীক্ষা করা হয়।

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

  • কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ অ্যাসিম্পটোটিক অ্যানালাইসিস, রিকারেন্স, ডিভাইড অ্যান্ড কনকার, গ্রিডি, DP, গ্রাফ অ্যালগরিদম, ব্যাকট্র্যাকিং, স্ট্রিং অ্যালগরিদম, অ্যামর্টাইজড অ্যানালাইসিস, NP-কমপ্লিটনেস ও অ্যাপ্রক্সিমেশন — বাকি পাঠগুলো শীঘ্রই যুক্ত হবে।
  • Data Structures & Algorithms কোর্স সহোদর কোর্স স্ট্রিং ম্যাচিং-এর মৌলিক ধারণা ও আরও ইমপ্লিমেন্টেশন উদাহরণ সেই কোর্সেই আছে।
  • পরবর্তী পাঠে যান L41 KMP অ্যালগরিদম — হ্যাশিং ছাড়াই কীভাবে $O(n+m)$ ওয়ার্স্ট-কেস গ্যারান্টি পাওয়া যায়, একটি ফেইলিউর অ্যারে ব্যবহার করে।
আগের পাঠ
ট্র্যাভেলিং সেলসম্যান — ব্রাঞ্চ-অ্যান্ড-বাউন্ড