পাঠ ৪১ · ৫৭-এর মধ্যে · মডিউল ৯
Home / Courses / Design and Analysis of Algorithms / KMP অ্যালগরিদম

KMP অ্যালগরিদম

The Knuth-Morris-Pratt algorithm
১৪ মিনিট পড়া মধ্যম-কঠিন · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • ফেইলিউর/LPS অ্যারের সুনির্দিষ্ট সংজ্ঞা এবং এটি কীভাবে ধাপে ধাপে নির্মাণ করা হয়
  • LPS অ্যারে ব্যবহার করে সম্পূর্ণ KMP সার্চ কীভাবে কাজ করে
  • কেন KMP-এর টেক্সট পয়েন্টার কখনও পেছনে যায় না — এবং এই সম্পত্তিই কীভাবে $O(n+m)$ ওয়ার্স্ট-কেস প্রমাণ করে
  • ওভারল্যাপিং ম্যাচসহ একাধিক টেস্ট কেসে KMP-কে নেইভ বেসলাইনের বিপরীতে যাচাই করা

১ · সমস্যা — নেইভ ম্যাচিং কেন কাজ পুনরাবৃত্তি করে

L40-এ দেখা নেইভ ম্যাচিং একটি মিসম্যাচ পেলে টেক্সট পয়েন্টার $i$ মাত্র একধাপ এগিয়ে দিয়ে পুরো তুলনা প্রক্রিয়া নতুন করে শুরু করে ($j = 0$ থেকে)। কিন্তু যদি প্যাটার্নের একটি অংশ ইতিমধ্যে মিলে থাকে (যেমন প্যাটার্ন "ababaca"-এর প্রথম ৫টি ক্যারেক্টার "ababa" মিলে যাওয়ার পর ৬ষ্ঠ ক্যারেক্টারে মিসম্যাচ হয়), তাহলে সেই তথ্য ফেলে দিয়ে নতুন করে শুরু করাটা অপচয়। KMP এই মিলে-যাওয়া অংশের গঠন থেকে বুঝে নেয় প্যাটার্ন পয়েন্টার ঠিক কোথায় "ফিরে" যাওয়া উচিত — টেক্সট পয়েন্টারকে একবারও পেছনে না সরিয়ে।

২ · ফেইলিউর/LPS অ্যারে — সুনির্দিষ্ট সংজ্ঞা

প্যাটার্ন $P$ (দৈর্ঘ্য $m$)-এর জন্য LPS অ্যারে $\pi[0 \dots m-1]$ এভাবে সংজ্ঞায়িত: $$\pi[i] = \max\{\, k : k < i+1 \text{ এবং } P[0 \dots k-1] = P[i-k+1 \dots i] \,\}$$ সহজ ভাষায়: $\pi[i]$ হলো $P[0 \dots i]$ (অর্থাৎ প্যাটার্নের প্রথম $i+1$টি ক্যারেক্টার) উপসর্গের দীর্ঘতম প্রকৃত প্রিফিক্স-এর দৈর্ঘ্য যা একই সাথে সেই একই উপসর্গের একটি সাফিক্স ও বটে। "প্রকৃত প্রিফিক্স (proper prefix)" মানে পুরো স্ট্রিং নিজে বাদে যেকোনো প্রিফিক্স — অর্থাৎ $k < i+1$।

উদাহরণস্বরূপ, প্যাটার্ন "ababaca"-এর জন্য LPS অ্যারে হাতে-কলমে গণনা করা যাক:

  • $\pi[0]$ — "a"-এর কোনো প্রকৃত প্রিফিক্স নেই (দৈর্ঘ্য ১ স্ট্রিং-এর প্রকৃত প্রিফিক্স শুধু খালি স্ট্রিং) → $\pi[0] = 0$
  • $\pi[1]$ — "ab": প্রিফিক্স "a", সাফিক্স "b" — মেলে না → $\pi[1] = 0$
  • $\pi[2]$ — "aba": প্রিফিক্স "a" = সাফিক্স "a" ✓ → $\pi[2] = 1$
  • $\pi[3]$ — "abab": প্রিফিক্স "ab" = সাফিক্স "ab" ✓ → $\pi[3] = 2$
  • $\pi[4]$ — "ababa": প্রিফিক্স "aba" = সাফিক্স "aba" ✓ → $\pi[4] = 3$
  • $\pi[5]$ — "ababac": দীর্ঘতম মেলা প্রিফিক্স-সাফিক্স? "abac"-এর সাফিক্স "c"-এ শেষ, কিন্তু প্রিফিক্স "a"-এ শুরু — কোনো দৈর্ঘ্যেই মেলে না → $\pi[5] = 0$
  • $\pi[6]$ — "ababaca": প্রিফিক্স "a" = সাফিক্স "a" ✓ (দৈর্ঘ্য ২ চেষ্টা করলে "ab" বনাম "ca" মেলে না) → $\pi[6] = 1$

অর্থাৎ "ababaca"-এর LPS অ্যারে হলো [0, 0, 1, 2, 3, 0, 1] — এটি একটি সুপরিচিত টেক্সটবুক উদাহরণ, এবং নিচের কোড সেলে এই একই ফলাফল প্রোগ্রামগতভাবে গণনা করে দেখানো হবে যাতে হাতে-কলমের হিসাব ও কোডের আউটপুট মিলে যায়।

text[i] != pattern[j] (মিসম্যাচ ঘটেছে) j == 0 ? হ্যাঁ হলে i += 1, নাহলে নিচে j = LPS[j - 1] i অপরিবর্তিত থাকে -- পেছনে যায় না i সবসময় ০ থেকে n পর্যন্ত একমুখী
মিসম্যাচে প্যাটার্ন পয়েন্টার $j$ LPS অ্যারে অনুযায়ী "স্মার্টভাবে" পেছনে সরে, কিন্তু টেক্সট পয়েন্টার $i$ কখনও পেছনে যায় না — এটিই $O(n+m)$ প্রমাণের মূল ভিত্তি।

৩ · LPS অ্যারে নির্মাণ — অ্যালগরিদম

LPS অ্যারে নিজেই একটি সুন্দর স্ব-নির্ভরশীল (self-referential) কৌশলে $O(m)$ সময়ে তৈরি করা যায় — প্যাটার্নকে নিজের সাথেই তুলনা করে। দুটি পয়েন্টার রাখা হয়: length (এখন পর্যন্ত পাওয়া মিলের দৈর্ঘ্য) এবং i (যে ইনডেক্সের $\pi$ মান গণনা করা হচ্ছে)। P[i] == P[length] হলে মিল বেড়ে যায় ($\pi[i] = $ length বাড়িয়ে); না মিললে, length != 0 হলে length-কে LPS[length - 1]-এ "পিছিয়ে" নেওয়া হয় (ঠিক যেভাবে সার্চের সময় মিসম্যাচে করা হবে — একই নীতি দুই জায়গাতেই কাজ করে), আর length == 0 হলে সরাসরি $\pi[i] = 0$ ধরে $i$ এগিয়ে যাওয়া হয়।

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

নিচের কোডে প্রথমে LPS অ্যারে নির্মাণ করা হয়েছে এবং হাতে-কলমে গণনা করা "ababaca"-এর ফলাফলের সাথে assert দিয়ে মিলিয়ে দেখা হয়েছে। এরপর সম্পূর্ণ KMP সার্চ বাস্তবায়ন করে L40-এর নেইভ ফাংশনের বিপরীতে একাধিক টেস্ট স্ট্রিং-এ (ওভারল্যাপিং ম্যাচসহ, যেমন "aaaa"-এ প্যাটার্ন "aa") যাচাই করা হয়েছে।

Python
def build_lps(pattern):
    # LPS[i] = দীর্ঘতম প্রকৃত প্রিফিক্স যা pattern[0..i]-এর সাফিক্সও, তার দৈর্ঘ্য
    m = len(pattern)
    lps = [0] * m
    length = 0   # এখন পর্যন্ত পাওয়া মিলের দৈর্ঘ্য (আগের প্রিফিক্স-সাফিক্স)
    i = 1
    while i < m:
        if pattern[i] == pattern[length]:
            length += 1
            lps[i] = length
            i += 1
        else:
            if length != 0:
                # ঠিক সার্চের মতোই -- ছোট প্রিফিক্স-সাফিক্সে "ফিরে" যাও, i এগোয় না
                length = lps[length - 1]
            else:
                lps[i] = 0
                i += 1
    return lps


def naive_match(text, pattern):
    # L40-এর নেইভ বেসলাইন, রেফারেন্সের জন্য এখানে পুনরায় লেখা হলো
    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 kmp_match(text, pattern):
    n, m = len(text), len(pattern)
    positions = []
    if m == 0 or m > n:
        return positions
    lps = build_lps(pattern)
    i = j = 0   # i: টেক্সট পয়েন্টার (কখনও পেছনে যায় না), j: প্যাটার্ন পয়েন্টার
    while i < n:
        if text[i] == pattern[j]:
            i += 1
            j += 1
            if j == m:
                positions.append(i - j)   # সম্পূর্ণ প্যাটার্ন মিলেছে
                j = lps[j - 1]            # ওভারল্যাপিং ম্যাচ খোঁজার জন্য j-কে স্মার্টভাবে সরানো
        else:
            if j != 0:
                j = lps[j - 1]
            else:
                i += 1
    return positions


# --- হাতে-কলমে গণনা করা LPS অ্যারে যাচাই ---
lps_ababaca = build_lps("ababaca")
print("LPS('ababaca') =", lps_ababaca)
assert lps_ababaca == [0, 0, 1, 2, 3, 0, 1], "হাতে-কলমের হিসাবের সাথে মিলছে না!"
print("হাতে-কলমে গণনা করা [0, 0, 1, 2, 3, 0, 1]-এর সাথে সম্পূর্ণ মিলেছে।\n")

# --- KMP বনাম নেইভ, একাধিক টেস্ট কেসে ---
tests = [
    ("abababab", "aba"),
    ("aaaa", "aa"),          # ওভারল্যাপিং ম্যাচ
    ("aaaaaaaaaa", "aaa"),   # ওভারল্যাপিং ম্যাচ, দীর্ঘতর
    ("abcdefg", "xyz"),      # কোনো ম্যাচ নেই
    ("mississippi", "issi"),
    ("", "a"),               # খালি টেক্সট
    ("abc", ""),             # খালি প্যাটার্ন
    ("abc", "abcd"),         # প্যাটার্ন টেক্সটের চেয়ে বড়
    ("ababacaxxababaca", "ababaca"),
]

print(f"{'text':<20} | {'pattern':<10} | {'ম্যাচ পজিশন':<20} | মিলেছে?")
for text, pattern in tests:
    naive_pos = naive_match(text, pattern)
    kmp_pos = kmp_match(text, pattern)
    ok = (naive_pos == kmp_pos)
    print(f"{text:<20} | {pattern:<10} | {str(naive_pos):<20} | {'হ্যাঁ' if ok else 'না -- BUG!'}")
    assert naive_pos == kmp_pos, f"মিলছে না: {text!r}, {pattern!r}"

print(f"\nসব {len(tests)}টি টেস্ট কেসে (ওভারল্যাপিং ম্যাচসহ) KMP ও নেইভ সম্পূর্ণ একমত।")

    
লক্ষ্য করুন ("aaaa", "aa") এবং ("aaaaaaaaaa", "aaa") কেসগুলো — এগুলো ওভারল্যাপিং ম্যাচ-এর উদাহরণ (একটি ম্যাচের শেষ অংশ পরের ম্যাচের শুরুর অংশ)। KMP-এর সার্চ লুপে ম্যাচ পাওয়ার পর j = lps[j - 1] লাইনটি (শূন্যে রিসেট না করে) নিশ্চিত করে যে পরবর্তী ওভারল্যাপিং ম্যাচও খুঁজে পাওয়া যায় — এই লাইন ভুলবশত j = 0 লিখলে ওভারল্যাপিং ম্যাচগুলো বাদ পড়ে যেত।
জটিলতা বিশ্লেষণ · কেন O(n+m), সবসময়

LPS অ্যারে নির্মাণ $O(m)$: i সবসময় এগোয়, আর length প্রতিটি সফল মিলে সর্বোচ্চ একবার করে বাড়ে এবং প্রতিটি ব্যর্থ ধাপে কমে — তাই মোট বৃদ্ধি ও হ্রাসের সংখ্যা মিলিয়ে $O(m)$ (একটি অ্যামর্টাইজড আর্গুমেন্ট, যা M10-এ আরও বিস্তারিত আসবে)। সার্চ ধাপে, মূল পর্যবেক্ষণ হলো টেক্সট পয়েন্টার $i$ কখনও কমে না — মিসম্যাচে শুধু $j$ (LPS অনুযায়ী) পিছিয়ে যায়, $i$ একই থাকে। যেহেতু $i$ সর্বোচ্চ $n$ বার বাড়তে পারে, এবং প্রতিটি ধাপে হয় $i$ বাড়ে অথবা $j$ কমে (আর $j$ মোট সর্বোচ্চ $n$ বার কমতে পারে, কারণ এটি যতবার বেড়েছে তার বেশি কমতে পারে না), সার্চ ধাপের মোট সময় $O(n)$। সব মিলিয়ে সম্পূর্ণ KMP অ্যালগরিদম $O(n + m)$ — এবং এটি ওয়ার্স্ট কেসেও সত্য, রবিন-কার্পের মতো গড়-কেস নির্ভর নয়।

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

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

প্র ০১ মিসম্যাচের পর j = lps[j - 1] কেন সঠিক পছন্দ — কেন j-কে সরাসরি ০-তে রিসেট করলে ভুল হয়ে যায় (দক্ষতার দিক থেকে, নেইভের সমস্যায় ফিরে গিয়ে)?

lps[j - 1] মান বলে দেয় ইতিমধ্যে মিলে যাওয়া অংশের মধ্যে দীর্ঘতম কতটুকু প্রিফিক্স আবার প্যাটার্নের শুরুর সাথে সাফিক্স হিসেবে মিলে যায় — অর্থাৎ সেই অংশটুকু আবার নতুন করে তুলনা করার দরকার নেই। j = 0-তে রিসেট করলে এই তথ্য ফেলে দেওয়া হতো, এবং টেক্সট পয়েন্টার $i$-কেও পেছনে ফিরিয়ে তুলনা আবার শুরু করতে হতো — ঠিক নেইভ ম্যাচিং-এর মতোই ধীর হয়ে যেত (ওয়ার্স্ট কেসে $O(nm)$)। LPS-ভিত্তিক স্মার্ট জাম্পই KMP-কে $O(n+m)$ রাখে।

প্র ০২ LPS অ্যারে নির্মাণের কোডে length = lps[length - 1] লাইনটি সার্চ ধাপের j = lps[j - 1] লাইনের সাথে হুবহু একই রকম কেন?

কারণ LPS অ্যারে নির্মাণ আসলে প্যাটার্নকে নিজের বিরুদ্ধে ম্যাচ করার একটি প্রক্রিয়া — মিসম্যাচ হলে একই যুক্তি প্রযোজ্য: "ইতিমধ্যে মিলে যাওয়া অংশের মধ্যে দীর্ঘতম প্রিফিক্স-সাফিক্স কতটুকু, তা আবার ব্যবহার করো, শূন্য থেকে শুরু কোরো না।" এই স্ব-নির্ভরশীলতাই (self-similarity) কারণ কেন LPS অ্যারে নিজেই $O(m)$ সময়ে তৈরি করা সম্ভব, যদিও উপর থেকে দেখলে এটি একটি "রিকার্সিভ" সমস্যা মনে হতে পারে।

প্র ০৩ উপরের কোডে প্যাটার্ন খালি বা টেক্সটের চেয়ে বড় হলে কীভাবে সামলানো হয়েছে, এবং এই চেক না থাকলে কী সমস্যা হতে পারত?

kmp_match ফাংশনের শুরুতে if m == 0 or m > n: return positions চেক আছে। এই চেক না থাকলে, খালি প্যাটার্নের ক্ষেত্রে build_lps("")-এ lps[length - 1]-এ ঋণাত্মক ইনডেক্সিং বা অসংজ্ঞায়িত আচরণ ঘটতে পারত (Python-এ ঋণাত্মক ইনডেক্স ভিন্ন অর্থ বহন করে, যা নীরবে ভুল ফলাফল দিতে পারত, ক্র্যাশ না করেই) — তাই এক্সপ্লিসিট এজ-কেস চেক দিয়ে এই অস্পষ্টতা এড়ানো হয়েছে।

অনুশীলন

  1. চিন্তা করুন: প্যাটার্ন "aabaaab"-এর LPS অ্যারের প্রথম তিনটি মান ($\pi[0], \pi[1], \pi[2]$) কী হবে বলে আপনার ধারণা? (হিন্ট: সংজ্ঞা প্রয়োগ করুন — "a", "aa", "aab"।)

    $\pi[0] = 0$ (দৈর্ঘ্য ১-এর কোনো প্রকৃত প্রিফিক্স নেই)। $\pi[1] = 1$ ("aa"-এর প্রিফিক্স "a" = সাফিক্স "a")। $\pi[2] = 0$ ("aab"-এর প্রিফিক্স "a" বা "aa" কোনোটাই সাফিক্স "b" বা "ab"-এর সাথে মেলে না)।

  2. পরীক্ষা করুন: উপরের কোড সেলে print(build_lps("aabaaab")) যোগ করে Run চেপে সম্পূর্ণ LPS অ্যারে দেখুন এবং আপনার হাতে-কলমের হিসাবের প্রথম তিনটি মানের সাথে মিলিয়ে দেখুন।

    সম্পূর্ণ ফলাফল হবে [0, 1, 0, 1, 2, 2, 3] — প্রথম তিনটি মান ঠিক আগের প্রশ্নের হাতে-কলমের হিসাবের সাথে মিলে যায়। বাকি মানগুলো একই পদ্ধতিতে (দীর্ঘতম প্রিফিক্স-সাফিক্স খুঁজে) গণনা করা যায় — যেমন $\pi[3] = 1$ কারণ "aaba"-এর প্রিফিক্স "a" তার সাফিক্স "a"-এর সাথে মেলে।

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

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