পাঠ ০৯ · ৫৬-এর মধ্যে · মডিউল ২

এপসিলন-NFA ও এপসিলন-ক্লোজার

Epsilon-NFA & epsilon-closure
৮ মিনিট পড়া মাঝারি · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • ε-NFA-এর ফরমাল সংজ্ঞা এবং ε-ট্রানজিশন কীভাবে কাজ করে
  • এপসিলন-ক্লোজারের ফিক্সড-পয়েন্ট গণনা — ধাপে ধাপে হাতে-ট্রেসসহ
  • ε-ট্রানজিশন সরিয়ে সমতুল্য প্লেইন NFA বানানোর সূত্র
  • একটি সত্যিকারের ইমপ্লিমেন্টেশন যা দুটি সাব-মেশিন জোড়া লাগিয়ে রূপান্তরের সঠিকতা যাচাই করে

১ · ε-NFA-এর ফরমাল সংজ্ঞা

একটি এপসিলন-NFAEpsilon-NFAε-ট্রানজিশনসহ একটি NFA — ইনপুট সিম্বল না পড়েই স্টেট বদলানো যায়। L07-এর NFA-এর একটি সম্প্রসারণ — ট্রানজিশন ফাংশন এখন ইনপুট আলফাবেটের পাশাপাশি একটি বিশেষ "ε" (এপসিলন) সিম্বলও গ্রহণ করে:

$$\delta: Q \times (\Sigma \cup \{\varepsilon\}) \to \mathcal{P}(Q)$$

$\delta(q, \varepsilon)$ বলে দেয় q থেকে কোনো ইনপুট সিম্বল না পড়েই কোন কোন স্টেটে যাওয়া যায় — এই "ফ্রি" মুভ মেশিনকে যেকোনো সময় নিতে পারে, ইনপুট পয়েন্টার একই জায়গায় থেকে যায়। এটি বিশেষভাবে কার্যকর যখন ছোট ছোট অটোমাটাকে একসাথে জোড়া লাগাতে হয় — যেমন দুটি ভাষার ইউনিয়নের জন্য একটি নতুন কমন স্টার্ট স্টেট থেকে উভয় সাব-মেশিনে ε-ট্রানজিশন দেওয়া (সরাসরি L11-এর Kleene's theorem কনস্ট্রাকশনের পূর্বাভাস)।

২ · এপসিলন-ক্লোজার

$\text{ECLOSE}(q)$ হলো q থেকে শুধু ε-ট্রানজিশন ব্যবহার করে (শূন্য বা একাধিকবার) পৌঁছানো যায় এমন সব স্টেটের সেট — একটি স্ট্যান্ডার্ড গ্রাফ-রিচেবিলিটি গণনা (../dsa/-এর BFS/DFS-ধাঁচের একই ধারণা): $\{q\}$ থেকে শুরু করে, বর্তমান সেট থেকে আরও একটি ε-ট্রানজিশনে পৌঁছানো যায় এমন যেকোনো স্টেট যোগ করা হয়, যতক্ষণ না নতুন কোনো স্টেট যোগ হচ্ছে (ফিক্সড পয়েন্ট)।

৩ · ε-ট্রানজিশন সরানো — সমতুল্য প্লেইন NFA

L08-এর সাবসেট কনস্ট্রাকশনের সরাসরি সম্প্রসারণ হিসেবে, একটি ε-NFA-কে একটি সমতুল্য প্লেইন NFA-তে রূপান্তর করা যায় (তারপর L08 প্রয়োগ করে DFA-তে) — প্রতিটি "আসল" ট্রানজিশনের আগে ও পরে ε-ক্লোজার নিয়ে:

$$\delta'(q, a) = \text{ECLOSE}\Big(\bigcup_{p \in \text{ECLOSE}(q)} \delta(p, a)\Big)$$

নতুন অ্যাকসেপ্ট স্টেট সেট: $q$ অ্যাকসেপ্টিং হয় যদি $\text{ECLOSE}(q)$-এর মধ্যে মূল ε-NFA-এর কোনো অ্যাকসেপ্ট স্টেট থাকে (যেহেতু q থেকে বিনামূল্যে সেখানে পৌঁছানো যায়)।

q0 (নতুন start) ε ε s0 — "a"-মেশিন a s1 · accept t0 — "b"-মেশিন b t1 · accept
দুটি স্বাধীন সাব-মেশিন ("a" রিকগনাইজার ও "b" রিকগনাইজার) একটি নতুন কমন স্টার্ট q0 থেকে ε-ট্রানজিশন দিয়ে জোড়া লাগানো — কোড সেলে ঠিক এই উদাহরণটি ব্যবহৃত হয়েছে।

৪ · কোড: এপসিলন-ক্লোজার, রূপান্তর, ও যাচাই

নিচের কোডে $L=\{"a","b"\}$ রিকগনাইজ করার একটি ε-NFA বানানো হয়েছে (উপরের ডায়াগ্রাম অনুযায়ী), এপসিলন-ক্লোজার কয়েকটি স্টেটের জন্য হাতে-যাচাইযোগ্য assert দিয়ে নিশ্চিত করা হয়েছে, তারপর ε-ট্রানজিশন সরিয়ে একটি প্লেইন NFA বানানো হয়েছে, এবং শেষে — দৈর্ঘ্য ০ থেকে ৩ পর্যন্ত সব স্ট্রিং-এ — মূল ε-NFA ও রূপান্তরিত NFA উভয়ের ফলাফল প্রত্যাশিত ভাষার সাথে মিলিয়ে assert করা হয়েছে।

Python
from itertools import product

EPSILON = None  # sentinel representing an epsilon-transition


class EpsilonNFA:
    def __init__(self, states, alphabet, transition, start, accept_states):
        self.states = states
        self.alphabet = alphabet
        self.transition = transition   # dict: (state, symbol_or_EPSILON) -> set(states)
        self.start = start
        self.accept_states = accept_states

    def accepts(self, string):
        current = epsilon_closure(self, self.start)
        for ch in string:
            raw = set()
            for q in current:
                raw |= self.transition.get((q, ch), set())
            nxt = set()
            for s in raw:
                nxt |= epsilon_closure(self, s)
            current = nxt
            if not current:
                break
        return bool(current & self.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)


# sub-machine A: accepts exactly "a"  (s0 --a--> s1)
# sub-machine B: accepts exactly "b"  (t0 --b--> t1)
# joined at a new start q0 via epsilon-transitions to s0 and t0
states = {"q0", "s0", "s1", "t0", "t1"}
alphabet = {"a", "b"}
transition = {
    ("q0", EPSILON): {"s0", "t0"},
    ("s0", "a"): {"s1"},
    ("t0", "b"): {"t1"},
}
enfa = EpsilonNFA(states, alphabet, transition, "q0", {"s1", "t1"})

# hand-verify epsilon-closures
print("ECLOSE(q0) =", sorted(epsilon_closure(enfa, "q0")))
print("ECLOSE(s0) =", sorted(epsilon_closure(enfa, "s0")))
print("ECLOSE(s1) =", sorted(epsilon_closure(enfa, "s1")))
assert epsilon_closure(enfa, "q0") == frozenset({"q0", "s0", "t0"})
assert epsilon_closure(enfa, "s0") == frozenset({"s0"})

plain_nfa = remove_epsilon_transitions(enfa)
print("plain NFA transitions:")
for k, v in sorted(plain_nfa.transition.items(), key=lambda kv: (kv[0][0], kv[0][1] or "")):
    print(" ", k, "->", sorted(v))
print("plain NFA accept states:", sorted(plain_nfa.accept_states))

mismatches = 0
total = 0
for length in range(0, 4):
    for combo in product("ab", repeat=length):
        s = "".join(combo)
        total += 1
        r1 = enfa.accepts(s)
        r2 = plain_nfa.accepts(s)
        expected = s in ("a", "b")
        if not (r1 == r2 == expected):
            mismatches += 1
            print("MISMATCH", repr(s), r1, r2, expected)

print(f"total={total} mismatches={mismatches}")
assert mismatches == 0
print("epsilon-NFA ও epsilon-transition-remove করা NFA উভয়ই অভিন্ন ফলাফল দেয়।")

    
হাতে-ট্রেস: $\text{ECLOSE}(q_0) = \{q_0, s_0, t_0\}$ (সরাসরি দুটি ε-ট্রানজিশন), $\text{ECLOSE}(s_0) = \{s_0\}$ (s0 থেকে কোনো ε-ট্রানজিশন নেই)। রূপান্তরিত NFA-তে: $\delta'(q_0, a) = \text{ECLOSE}(\delta(s_0,a)) = \text{ECLOSE}(\{s_1\}) = \{s_1\}$ — অর্থাৎ নতুন NFA-তে q0 থেকে সরাসরি 'a' পড়েই s1-এ যাওয়া যায়, ε-ট্রানজিশন ছাড়াই। কোড আউটপুট এই হাতে-ট্রেসের সাথে হুবহু মিলবে, এবং শেষে ১৫টি টেস্ট স্ট্রিং-এ (দৈর্ঘ্য ০-৩, আলফাবেট {a,b}) mismatches = 0।
মূল কথা · Key takeaway

ε-ট্রানজিশন ছোট অটোমাটা জোড়া লাগানোকে স্বাভাবিক করে তোলে, এবং এপসিলন-ক্লোজার + L08-এর সাবসেট কনস্ট্রাকশনের সংমিশ্রণে প্রতিটি ε-NFA-কে একটি সমতুল্য DFA-তে রূপান্তর করা যায় — অর্থাৎ ε-ট্রানজিশন যোগ করেও কোনো নতুন গণনাশক্তি আসে না, শুধু বর্ণনার (এবং কনস্ট্রাকশনের) সুবিধা বাড়ে। L11-এ ঠিক এই "জোড়া লাগানোর" কৌশল ব্যবহার করে regex থেকে সরাসরি NFA বানানো হবে।

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

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

প্র ০১ এপসিলন-ক্লোজার সবসময় নিজের স্টেট q-কে অন্তর্ভুক্ত করে কেন?

কারণ q থেকে "শূন্যটি" ε-ট্রানজিশন নিয়ে (অর্থাৎ কিছুই না করে) q-তেই থাকা যায় — এবং সংজ্ঞা অনুযায়ী ECLOSE(q) হলো "শূন্য বা তার বেশি" ε-ট্রানজিশন দিয়ে পৌঁছানো যায় এমন স্টেট। "শূন্য" কেসটি সবসময় সত্য, তাই q নিজেই সবসময় ECLOSE(q)-এর সদস্য — কোডে এটিই প্রতিফলিত হয়েছে closure = {state} দিয়ে শুরু করার মাধ্যমে।

প্র ০২ $\delta'(q,a)$-এর সূত্রে ECLOSE কেন দুইবার প্রয়োগ করা হয় — একবার আগে, একবার পরে?

প্রথমটি (আগে) নিশ্চিত করে q থেকে ε-ট্রানজিশন দিয়ে পৌঁছানো যায় এমন সব স্টেট থেকেও 'a' পড়ার সুযোগ বিবেচনা করা হচ্ছে (q নিজে থেকে 'a' না গেলেও, q-এর ε-প্রতিবেশী কেউ যেতে পারে)। দ্বিতীয়টি (পরে) নিশ্চিত করে 'a' পড়ার পর যে স্টেটে পৌঁছানো গেল, সেখান থেকেও যদি আরও ε-ট্রানজিশন সম্ভব হয়, সেগুলোও অন্তর্ভুক্ত হয়। দুটো বাদ দিলে কিছু বৈধ ট্রানজিশন মিস হয়ে যেত।

প্র ০৩ যদি কোনো ε-ট্রানজিশন একটি অ্যাকসেপ্ট স্টেটের দিকে যায়, নতুন প্লেইন NFA-এর অ্যাকসেপ্ট স্টেট সেটে এর প্রভাব কী?

তাহলে সেই ε-ট্রানজিশনের উৎস স্টেট q-ও নতুন NFA-তে অ্যাকসেপ্টিং হয়ে যাবে — কারণ new_accept = {q : ECLOSE(q) ∩ accept_states ≠ ∅} শর্তটি q-এর ε-ক্লোজারে অ্যাকসেপ্ট স্টেট থাকলেই q-কে অ্যাকসেপ্টিং ধরে। এটি যৌক্তিক: q-তে থেকেই (বিনামূল্যে ε-ট্রানজিশন নিয়ে) একটি অ্যাকসেপ্ট স্টেটে পৌঁছানো সম্ভব, তাই q থেকে ইনপুট শেষ হলেও স্ট্রিং অ্যাকসেপ্টেড হওয়া উচিত।

অনুশীলন

  1. চিন্তা করুন: কোড সেলের ε-NFA-তে $\text{ECLOSE}(t_1)$ কী হবে? হাতে ট্রেস করে verify করুন কোড আউটপুটের সাথে মেলে কি না।

    $\text{ECLOSE}(t_1) = \{t_1\}$ — কারণ t1 থেকে কোনো ε-ট্রানজিশন সংজ্ঞায়িত নেই (transition ডিকশনারিতে (t1, EPSILON) কী নেই), তাই t1-এর ε-ক্লোজারে শুধু t1 নিজেই থাকে, ঠিক s0/s1-এর মতোই।

  2. পরীক্ষা করুন: কোড সেলে for length in range(0, 4): লাইনে max length বাড়িয়ে range(0, 5) করে Run চাপুন — mismatches এখনও ০ থাকে কি না, এবং কোন নতুন স্ট্রিংগুলো টেস্ট হলো দেখুন।

    mismatches এখনও ০ থাকবে। নতুন যোগ হওয়া দৈর্ঘ্য-৪ স্ট্রিংগুলো (যেমন "aabb", "baba" ইত্যাদি) কোনোটিই "a" বা "b" নয়, তাই উভয় মেশিনই সেগুলো reject করবে — যা প্রত্যাশিত expected = s in ("a","b") -এর সাথে মিলে যায়।

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

আগের পাঠ
NFA থেকে DFA ইকুইভ্যালেন্স — সাবসেট কনস্ট্রাকশন