পাঠ ১১ · ৫৬-এর মধ্যে · মডিউল ২
Home / Courses / Formal Language & Automata Theory / Theory of Computation / ক্লিনির থিওরেম

ক্লিনির থিওরেম — রেগেক্স ও ফাইনাইট অটোমাটার ইকুইভ্যালেন্স

Kleene's theorem — regex & finite automata equivalence
৯ মিনিট পড়া মাঝারি · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • ক্লিনির থিওরেমের নির্ভুল বিবৃতি এবং কেন এটি M2-এর একটি ইউনিফাইং ফলাফল
  • regex→NFA কনস্ট্রাকশনের প্রতিটি কেস — বেস কেস ও তিনটি ইনডাক্টিভ কেস — এবং কেন প্রতিটি সঠিক
  • DFA→regex দিকের সংক্ষিপ্ত পরিচিতি (state elimination)
  • একটি সম্পূর্ণ, এন্ড-টু-এন্ড, code-verified কনস্ট্রাকশন — regex থেকে NFA থেকে DFA, L10-এর সাথে ক্রস-চেক করা

১ · থিওরেমের বিবৃতি

ক্লিনির থিওরেম: একটি ভাষা রেগুলার (অর্থাৎ কোনো DFA বা, L08-এর ইকুইভ্যালেন্স অনুযায়ী সমতুল্যভাবে, কোনো NFA দ্বারা রিকগনাইজড, L06-L08) যদি এবং শুধুমাত্র যদি সেটি কোনো রেগুলার এক্সপ্রেশন (L10) দ্বারা বর্ণিত হয়। এই দুটি সম্পূর্ণ ভিন্ন-দেখতে ফরমালিজম — একটি "মেশিন" (অটোমাটা), অন্যটি একটি "নোটেশন" (regex) — ঠিক একই ক্লাস অফ ল্যাঙ্গুয়েজ চিহ্নিত করে। এটিই M2-এর পুরো যাত্রার (L06-L10) একটি ইউনিফাইং, চূড়ান্ত ফলাফল।

২ · দুই-দিকের প্রমাণ কাঠামো

"যদি এবং শুধুমাত্র যদি" (iff) বিবৃতি প্রমাণ করতে দুটো দিক দেখাতে হয় — উভয়ই কনস্ট্রাক্টিভ:

regex → NFA
L10-এর রিকার্সিভ সংজ্ঞার প্রতিটি কেস অনুযায়ী, ধাপে ধাপে একটি NFA তৈরি (এই পাঠে সম্পূর্ণ ইমপ্লিমেন্টেড)।
DFA → regex
"state elimination" — DFA থেকে ধাপে ধাপে স্টেট সরিয়ে regex ফ্র্যাগমেন্ট দিয়ে ট্রানজিশন রিলেবেল করা (সংক্ষেপে উল্লেখ, ইমপ্লিমেন্টেশন আবশ্যক নয়)।

৩ · regex → NFA — L10-এর কাঠামো হুবহু অনুসরণ করে

এই কনস্ট্রাকশন (Thompson's construction নামে পরিচিত) L10-এর প্রতিটি বেস কেস ও ইনডাক্টিভ কেসের জন্য একটি নির্দিষ্ট নিয়ম দেয় — L09-এর ε-NFA মেশিনারি ব্যবহার করে সাব-NFA জোড়া লাগিয়ে:

  • $\emptyset$: একটি স্টেট, কোনো অ্যাকসেপ্ট স্টেট নেই, কোনো ট্রানজিশন নেই — কিছুই অ্যাকসেপ্ট হয় না।
  • $\varepsilon$: একটি স্টেট যা নিজেই start ও accept — কোনো ট্রানজিশন ছাড়াই শুধু খালি স্ট্রিং অ্যাকসেপ্ট।
  • একক সিম্বল $a$: দুটি স্টেট, একটি ট্রানজিশন ($s \xrightarrow{a} t$) — শুধু "a" অ্যাকসেপ্ট।
  • ইউনিয়ন $(R\cup S)$: একটি নতুন কমন স্টার্ট, ε-ট্রানজিশন দিয়ে R ও S উভয় সাব-NFA-এর স্টার্টে জোড়া (হুবহু L09-এর উদাহরণ)।
  • কনক্যাটেনেশন $(RS)$: R-এর প্রতিটি অ্যাকসেপ্ট স্টেট থেকে S-এর স্টার্টে ε-ট্রানজিশন — R শেষ হওয়ার পর S শুরু।
  • ক্লিনি স্টার $(R^*)$: একটি নতুন স্টার্ট ও অ্যাকসেপ্ট স্টেট যোগ করে — নতুন স্টার্ট থেকে R-এর স্টার্টে (একবার চালানোর জন্য) ও নতুন অ্যাকসেপ্টে (শূন্যবার চালানোর জন্য) ε-ট্রানজিশন, এবং R-এর প্রতিটি অ্যাকসেপ্ট থেকে R-এর স্টার্টে (আবার চালানোর জন্য, লুপ) ও নতুন অ্যাকসেপ্টে (থামার জন্য) ε-ট্রানজিশন।

লক্ষ্য করুন প্রতিটি ইনডাক্টিভ কেস সাব-NFA-গুলো রিকার্সিভভাবে বানিয়ে তারপর ε-ট্রানজিশন দিয়ে জোড়া লাগায় — L03-এর স্ট্রাকচারাল ইনডাকশনের সরাসরি প্রয়োগ, এবং L09-এর "ছোট মেশিন জোড়া লাগানো" মোটিভেশনের চূড়ান্ত payoff।

Regular Expression L10-এর রিকার্সিভ সংজ্ঞা Thompson construction (L11) state elimination (উল্লেখমাত্র) DFA / NFA L06-L08-এর ফাইনাইট অটোমাটা
ক্লিনির থিওরেম — regex ও ফাইনাইট অটোমাটার মধ্যে দুই-দিকের রূপান্তর সম্ভব, উভয়ে ঠিক একই ক্লাস অফ ভাষা (রেগুলার ল্যাঙ্গুয়েজ) চিহ্নিত করে।

৪ · DFA → regex — সংক্ষেপে

বিপরীত দিক — একটি DFA/NFA থেকে সমতুল্য regex বের করা — সাধারণত state elimination পদ্ধতিতে করা হয়: DFA-কে একটি জেনারালাইজড ট্রানজিশন গ্রাফে রূপান্তর করে (যেখানে এজ-লেবেল regex ফ্র্যাগমেন্ট হতে পারে), তারপর একে একে (স্টার্ট ও একমাত্র অ্যাকসেপ্ট স্টেট বাদে) প্রতিটি স্টেট সরিয়ে বাকি এজ-লেবেলগুলোকে যথাযথ regex কম্বিনেশন দিয়ে রিলেবেল করা হয়, যতক্ষণ না শুধু স্টার্ট ও অ্যাকসেপ্ট স্টেট অবশিষ্ট থাকে। এই কৌশলটি সত্যিই আছে ও সঠিক, কিন্তু এখানে সম্পূর্ণভাবে ইমপ্লিমেন্ট করা আবশ্যক নয় — regex→NFA দিকটিই এই পাঠের মূল ফোকাস, কারণ সেটিই সরাসরি L10-এর রিকার্সিভ সংজ্ঞার সাথে মেলে।

৫ · কোড: সম্পূর্ণ, এন্ড-টু-এন্ড, code-verified কনস্ট্রাকশন

নিচের কোডে regex_to_nfa রিকার্সিভভাবে (L09-এর ε-NFA মেশিনারি পুনর্ব্যবহার করে) L10-এর $(a\cup b)^*a$ regex থেকে একটি ε-NFA বানায়, তারপর L09-এর remove_epsilon_transitions ও L08-এর subset_construction প্রয়োগ করে একটি DFA-তে রূপান্তর করে — এবং শেষে, শুধু অ্যালগরিদম চালানো নয়, থিওরেমের একটি সত্যিকারের যাচাই হিসেবে, দৈর্ঘ্য ০ থেকে ৫ পর্যন্ত সব স্ট্রিং (৬৩টি) এই DFA ও L10-এর সম্পূর্ণ স্বাধীন সেট-বেসড language_of-এর বিরুদ্ধে টেস্ট করে assert করা হয়েছে।

Python
from collections import deque
from itertools import product

EPSILON = None


# ---------- L09-style epsilon-NFA machinery ----------
class EpsilonNFA:
    def __init__(self, states, alphabet, transition, start, accept_states):
        self.states = states
        self.alphabet = alphabet
        self.transition = transition
        self.start = start
        self.accept_states = accept_states


def epsilon_closure(enfa, state):
    closure = {state}
    frontier = [state]
    while frontier:
        q = frontier.pop()
        for nxt in enfa.transition.get((q, EPSILON), set()):
            if nxt not in closure:
                closure.add(nxt)
                frontier.append(nxt)
    return frozenset(closure)


class NFA:
    def __init__(self, states, alphabet, transition, start, accept_states):
        self.states = states
        self.alphabet = alphabet
        self.transition = transition
        self.start = start
        self.accept_states = accept_states

    def accepts(self, string):
        current = {self.start}
        for ch in string:
            nxt = set()
            for q in current:
                nxt |= self.transition.get((q, ch), set())
            current = nxt
            if not current:
                break
        return bool(current & self.accept_states)


def remove_epsilon_transitions(enfa):
    new_transition = {}
    for q in enfa.states:
        closure_q = epsilon_closure(enfa, q)
        for a in enfa.alphabet:
            raw = set()
            for p in closure_q:
                raw |= enfa.transition.get((p, a), set())
            dest = set()
            for r in raw:
                dest |= epsilon_closure(enfa, r)
            if dest:
                new_transition[(q, a)] = dest
    new_accept = {q for q in enfa.states if epsilon_closure(enfa, q) & enfa.accept_states}
    return NFA(enfa.states, enfa.alphabet, new_transition, enfa.start, new_accept)


# ---------- L08-style subset construction ----------
class DFA:
    def __init__(self, states, alphabet, transition, start, accept_states):
        self.states = states
        self.alphabet = alphabet
        self.transition = transition
        self.start = start
        self.accept_states = accept_states

    def accepts(self, string):
        state = self.start
        for ch in string:
            state = self.transition[(state, ch)]
        return state in self.accept_states


def subset_construction(nfa):
    start_set = frozenset({nfa.start})
    dfa_states = {start_set}
    dfa_transition = {}
    queue = deque([start_set])
    while queue:
        current = queue.popleft()
        for a in nfa.alphabet:
            nxt = frozenset().union(*(nfa.transition.get((q, a), set()) for q in current)) if current else frozenset()
            dfa_transition[(current, a)] = nxt
            if nxt not in dfa_states:
                dfa_states.add(nxt)
                queue.append(nxt)
    accept_states = {S for S in dfa_states if S & nfa.accept_states}
    return DFA(dfa_states, nfa.alphabet, dfa_transition, start_set, accept_states)


# ---------- L10-style set-based regex language generator ----------
def language_of(node, max_length):
    kind = node[0]
    if kind == "empty":
        return set()
    if kind == "eps":
        return {""}
    if kind == "sym":
        a = node[1]
        return {a} if len(a) <= max_length else set()
    if kind == "union":
        _, R, S = node
        return language_of(R, max_length) | language_of(S, max_length)
    if kind == "concat":
        _, R, S = node
        LR = language_of(R, max_length)
        LS = language_of(S, max_length)
        return {x + y for x in LR for y in LS if len(x) + len(y) <= max_length}
    if kind == "star":
        _, R = node
        LR = {w for w in language_of(R, max_length) if w != ""}
        result = {""}
        frontier = {""}
        while frontier:
            new = set()
            for s in frontier:
                for r in LR:
                    cand = s + r
                    if len(cand) <= max_length and cand not in result:
                        new.add(cand)
            result |= new
            frontier = new
        return result
    raise ValueError(node)


# ---------- L11: regex -> epsilon-NFA, recursively, following regex structure (Thompson construction) ----------
def merge_transitions(a, b):
    result = {}
    for d in (a, b):
        for k, v in d.items():
            result.setdefault(k, set()).update(v)
    return result


def regex_to_fragment(node, counter):
    kind = node[0]

    def fresh():
        counter[0] += 1
        return f"n{counter[0]}"

    if kind == "empty":
        q = fresh()
        return {"states": {q}, "transition": {}, "start": q, "accept": set()}

    if kind == "eps":
        q = fresh()
        return {"states": {q}, "transition": {}, "start": q, "accept": {q}}

    if kind == "sym":
        a = node[1]
        s, t = fresh(), fresh()
        return {"states": {s, t}, "transition": {(s, a): {t}}, "start": s, "accept": {t}}

    if kind == "union":
        _, R, S = node
        r = regex_to_fragment(R, counter)
        s = regex_to_fragment(S, counter)
        q0 = fresh()
        transition = merge_transitions(r["transition"], s["transition"])
        transition.setdefault((q0, EPSILON), set()).update({r["start"], s["start"]})
        return {
            "states": r["states"] | s["states"] | {q0},
            "transition": transition,
            "start": q0,
            "accept": r["accept"] | s["accept"],
        }

    if kind == "concat":
        _, R, S = node
        r = regex_to_fragment(R, counter)
        s = regex_to_fragment(S, counter)
        transition = merge_transitions(r["transition"], s["transition"])
        for acc in r["accept"]:
            transition.setdefault((acc, EPSILON), set()).add(s["start"])
        return {
            "states": r["states"] | s["states"],
            "transition": transition,
            "start": r["start"],
            "accept": s["accept"],
        }

    if kind == "star":
        _, R = node
        r = regex_to_fragment(R, counter)
        q0, qf = fresh(), fresh()
        transition = {k: set(v) for k, v in r["transition"].items()}
        transition.setdefault((q0, EPSILON), set()).update({r["start"], qf})
        for acc in r["accept"]:
            transition.setdefault((acc, EPSILON), set()).update({r["start"], qf})
        return {
            "states": r["states"] | {q0, qf},
            "transition": transition,
            "start": q0,
            "accept": {qf},
        }

    raise ValueError(f"unknown regex node: {node}")


def regex_to_nfa(regex_ast, alphabet):
    counter = [0]
    frag = regex_to_fragment(regex_ast, counter)
    return EpsilonNFA(frag["states"], alphabet, frag["transition"], frag["start"], frag["accept"])


# ---------- run it on (a U b)* a ----------
regex = ("concat",
         ("star", ("union", ("sym", "a"), ("sym", "b"))),
         ("sym", "a"))
alphabet = {"a", "b"}

enfa = regex_to_nfa(regex, alphabet)
print(f"regex_to_nfa: {len(enfa.states)} states (Thompson construction)")

plain_nfa = remove_epsilon_transitions(enfa)
dfa = subset_construction(plain_nfa)
print(f"epsilon-transition বাদ দেওয়ার পর NFA states: {len(plain_nfa.states)}, subset-construction DFA states: {len(dfa.states)}")

MAX_LEN = 5
lang = language_of(regex, MAX_LEN)

mismatches = 0
total = 0
for length in range(0, MAX_LEN + 1):
    for combo in product("ab", repeat=length):
        s = "".join(combo)
        total += 1
        expected = s in lang
        got = dfa.accepts(s)
        if expected != got:
            mismatches += 1
            print("MISMATCH", repr(s), "expected", expected, "got", got)

print(f"total={total} mismatches={mismatches}")
assert mismatches == 0
print("regex_to_nfa -> subset_construction দিয়ে বানানো DFA, এবং L10-এর সেট-বেসড language_of -- length<=5 পর্যন্ত সব স্ট্রিং-এ হুবহু মিলেছে -- Kleene's theorem-এর একটি concrete, code-verified confirmation।")

    
চালানোর পর দেখা যায় $(a\cup b)^*a$ regex থেকে Thompson construction ৯টি স্টেটের একটি ε-NFA বানায় (প্রতিটি বেস কেস ও ইনডাক্টিভ কেস নিজস্ব ফ্রেশ স্টেট যোগ করে), ε-ট্রানজিশন সরানোর পরও ৯টি স্টেট থাকে (শুধু ট্রানজিশন রিফর্মুলেটেড হয়), এবং subset construction দিয়ে চূড়ান্ত DFA-তে মাত্র ৩টি রিচেবল স্টেট অবশিষ্ট থাকে। এরপর ৬৩টি টেস্ট স্ট্রিং-এর (দৈর্ঘ্য ০-৫, আলফাবেট {a,b}) প্রতিটিতে DFA-এর ফলাফল L10-এর সম্পূর্ণ ভিন্ন পদ্ধতিতে (সরাসরি সেট-কনস্ট্রাকশন দিয়ে, কোনো অটোমাটা ছাড়াই) গণনা করা ভাষার সাথে হুবহু মেলে — mismatches == 0।
মূল কথা · Key takeaway

ক্লিনির থিওরেম প্রমাণ করে regex ও ফাইনাইট অটোমাটা — দুটো সম্পূর্ণ ভিন্ন-দেখতে ফরমালিজম — ঠিক একই ভাষার ক্লাস (রেগুলার ল্যাঙ্গুয়েজ) বর্ণনা করে। regex→NFA দিকটি রিকার্সিভভাবে regex-এর নিজস্ব কাঠামো অনুসরণ করে (L03-এর স্ট্রাকচারাল ইনডাকশন), এবং L08 ও L09-এর প্রতিটি টুল (subset construction, ε-closure) এখানে একসাথে কাজে লাগে — M2-এর একটি সম্পূর্ণ, সমন্বিত চূড়ান্ত ফলাফল। M3 থেকে এই "রেগুলার ল্যাঙ্গুয়েজ" ধারণাটিকেই আরও গভীরভাবে বিশ্লেষণ করা হবে — ক্লোজার প্রপার্টি, পাম্পিং লেমা, ও মাইহিল-নেরোড থিওরেম দিয়ে।

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

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

প্র ০১ কনক্যাটেনেশন কেসে ($RS$) কেন R-এর প্রতিটি অ্যাকসেপ্ট স্টেট থেকে S-এর স্টার্টে ε-ট্রানজিশন লাগে, শুধু একটি থেকে নয়?

কারণ NFA-তে R একাধিক অ্যাকসেপ্ট স্টেট থাকতে পারে (একাধিক পাথ R-কে সফলভাবে "শেষ" করতে পারে) — RS অ্যাকসেপ্ট করতে হলে R-এর যেকোনো একটি সফল সমাপ্তি থেকেই S শুরু হতে পারা উচিত, যেহেতু NFA-এর অ্যাকসেপ্টেন্স নিয়ম হলো "অন্তত একটি পাথ সফল হলেই যথেষ্ট" (L07)। যদি শুধু একটি অ্যাকসেপ্ট স্টেট থেকে ε-ট্রানজিশন দেওয়া হতো, R-কে "শেষ করার" অন্যান্য বৈধ উপায় হারিয়ে যেত।

প্র ০২ স্টার কেসে ($R^*$) নতুন স্টার্ট থেকে সরাসরি নতুন অ্যাকসেপ্টে একটি ε-ট্রানজিশন কেন দরকার?

এটি "শূন্যবার R" কেসটি হ্যান্ডল করে — $L(R^*)$-এ সবসময় খালি স্ট্রিং $\varepsilon$ থাকে (L10-এর $i=0$ টার্ম), R যাই হোক না কেন। নতুন স্টার্ট থেকে সরাসরি নতুন অ্যাকসেপ্টে একটি ε-ট্রানজিশন ছাড়া, মেশিনকে বাধ্যতামূলকভাবে অন্তত একবার R-এর মধ্য দিয়ে যেতে হতো, যা ε-কে ভুলভাবে বাদ দিয়ে দিত।

প্র ০৩ কোড সেলে regex_to_fragment প্রতিটি রিকার্সিভ কলে নতুন fresh() স্টেট নাম বানায় কেন — একই স্টেট নাম পুনর্ব্যবহার করলে সমস্যা কী হতো?

যদি দুটো ভিন্ন সাব-রিজেক্সের জন্য বানানো সাব-NFA একই স্টেট নাম শেয়ার করত (যেমন উভয়ে "s0" ব্যবহার করত), তাহলে ইউনিয়ন/কনক্যাটেনেশন/স্টার কেসে দুই সাব-NFA-এর ট্রানজিশন ডিকশনারি মার্জ করার সময় একটির ট্রানজিশন আরেকটির উপর ওভাররাইট হয়ে যেত (অথবা ভুলভাবে মিশে যেত) — একটি সম্পূর্ণ ভুল, corrupted মেশিন তৈরি হতো। counter-ভিত্তিক fresh() নিশ্চিত করে প্রতিটি সাব-NFA-এর স্টেট সেট সম্পূর্ণ ডিসজয়েন্ট (কোনো কমন সদস্য নেই), তাই মার্জ করা নিরাপদ।

অনুশীলন

  1. চিন্তা করুন: regex $\emptyset^*$ (খালি ভাষার স্টার)-এর $L(R)$ কী হবে? Thompson construction-এ এটি কীভাবে সঠিকভাবে হ্যান্ডল হয়?

    $L(\emptyset^*) = \{\varepsilon\}$ — L10-এর সূত্র অনুযায়ী, $L(R)^0 = \{\varepsilon\}$ সবসময় থাকে, R যাই হোক না কেন, এমনকি $R=\emptyset$ হলেও। Thompson construction-এ: $\emptyset$-এর ফ্র্যাগমেন্টে কোনো অ্যাকসেপ্ট স্টেট নেই, তাই স্টার কেসের "প্রতিটি accept থেকে লুপ-ব্যাক" অংশটি কোনো ট্রানজিশন যোগ করে না (খালি লুপ) — কিন্তু নতুন-স্টার্ট-থেকে-নতুন-অ্যাকসেপ্ট ε-ট্রানজিশনটি ঠিকই যোগ হয়, তাই চূড়ান্ত মেশিন শুধু $\varepsilon$-ই অ্যাকসেপ্ট করে — সঠিক।

  2. পরীক্ষা করুন: কোড সেলে MAX_LEN = 5-কে MAX_LEN = 7-এ পরিবর্তন করে Run চাপুন — mismatches এখনও ০ থাকে কি না, এবং কতটি টেস্ট স্ট্রিং চেক হলো দেখুন।

    mismatches এখনও ০ থাকবে, এবং মোট টেস্ট স্ট্রিং সংখ্যা $2^0+2^1+\dots+2^7 = 255$-এ বেড়ে যাবে। এটি প্রত্যাশিত — regex→NFA→DFA কনস্ট্রাকশন ও L10-এর সেট-বেসড জেনারেটর একই ভাষা বর্ণনা করে বলে দাবি করা হয়েছে, যা স্ট্রিং-দৈর্ঘ্যের উপর নির্ভর করে না — বেশি স্ট্রিং টেস্ট করলে শুধু নিশ্চয়তা বাড়ে।

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

আগের পাঠ
রেগুলার এক্সপ্রেশন — ফরমাল ডেফিনিশন