পাঠ ১৮ · ৫৮-এর মধ্যে · মডিউল ৪

ফাইনাইট অটোমাটা — NFA থেকে DFA

Finite automata — NFA to DFA
৯ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • NFA ও DFA-র সংজ্ঞা এবং তাদের মধ্যে trade-off (সহজে বানানো বনাম দ্রুত সিমুলেট করা)
  • Thompson's construction — regex থেকে NFA বানানোর মানক পদ্ধতি (নামমাত্র পরিচিতি)
  • epsilon-closure ও subset construction অ্যালগরিদম নিখুঁতভাবে বাস্তবায়ন করা
  • a*b-এর জন্য একটি হাতে-বানানো NFA-কে DFA-তে রূপান্তর করে accept/reject যাচাই করা

১ · ফাইনাইট অটোমাটা কী

একটি ফাইনাইট অটোমাটা (Finite Automaton)একটি formal মেশিন যা স্টেট, স্টেটের মধ্যে ট্রানজিশন, একটি শুরুর স্টেট, ও কিছু accepting স্টেট দিয়ে গঠিত — একটি স্ট্রিং সম্পূর্ণ পড়ে শেষে accepting স্টেটে থামলে সেই স্ট্রিং "গৃহীত" হয়। হলো স্ট্রিং গ্রহণ (accept) বা প্রত্যাখ্যান (reject) করার একটি formal মেশিন — স্টেট, স্টেটগুলোর মধ্যে ইনপুট-সিম্বল-ভিত্তিক ট্রানজিশন, একটি শুরুর স্টেট, এবং কিছু accepting স্টেট। L15-এ আমরা বলেছিলাম regular ল্যাঙ্গুয়েজ (Type 3) ঠিক ফাইনাইট অটোমাটা দিয়ে চেনা যায় — L17-এর regex ও এই পাঠের অটোমাটা আসলে একই শক্তির দুটি ভিন্ন প্রকাশ: যেকোনো regex-এর জন্য একটি সমতুল্য অটোমাটা আছে, আর তার উল্টোটাও সত্যি।

২ · NFA বনাম DFA

NFA (Nondeterministic)
একই স্টেট+সিম্বলে একাধিক সম্ভাব্য পরবর্তী স্টেট থাকতে পারে, এমনকি কোনো ইনপুট না পড়েই স্টেট বদলানো (ε-ট্রানজিশন) সম্ভব। regex থেকে সহজে বানানো যায়, কিন্তু সিমুলেট করতে একসাথে একাধিক স্টেট ট্র্যাক রাখতে হয়।
DFA (Deterministic)
প্রতিটি স্টেট+সিম্বলে ঠিক একটি ট্রানজিশন, কোনো ε-ট্রানজিশন নেই। বানানো কঠিন হতে পারে, কিন্তু সিমুলেট করা তুচ্ছ ও দ্রুত — প্রতি ক্যারেক্টারে একটিমাত্র লুকআপ। এই দৃঢ়তা (determinism) কারণেই বাস্তব লেক্সার সবসময় DFA-ভিত্তিক (L20)।

একটি regex থেকে NFA বানানোর মানক পদ্ধতির নাম Thompson's construction — প্রতিটি regex অপারেটরের (char, concat, union, star) জন্য একটি ছোট, standard NFA-fragment টেমপ্লেট আছে, যেগুলো একে অপরের সাথে জোড়া লাগিয়ে যেকোনো regex-এর NFA বানানো যায়। এই পাঠে আমরা সেই টেমপ্লেট অনুসরণ করেই a*b-এর NFA হাতে বানাব।

৩ · Worked উদাহরণ — a*b-এর NFA

Thompson's construction অনুসরণ করে a*b = concat(star(a), b)-এর NFA-তে ৬টি স্টেট (0-৫) এবং নিচের ট্রানজিশন থাকে (ε মানে epsilon-ট্রানজিশন, কোনো ইনপুট না পড়েই):

a*b-এর NFA — ট্রানজিশন তালিকা

$0 \xrightarrow{\varepsilon} 1$,   $0 \xrightarrow{\varepsilon} 3$   (স্টেট 0: হয় লুপে ঢুকি, নয়তো সরাসরি বের হই — "শূন্যবার a" সম্ভাবনা)
$1 \xrightarrow{a} 2$   (একটি 'a' পড়া)
$2 \xrightarrow{\varepsilon} 1$,   $2 \xrightarrow{\varepsilon} 3$   (স্টেট 2: হয় আরেকবার লুপে ফিরি, নয়তো লুপ থেকে বের হই)
$3 \xrightarrow{\varepsilon} 4$   ('b'-এর অংশে প্রবেশ)
$4 \xrightarrow{b} 5$   (চূড়ান্ত 'b' পড়া — স্টেট 5 হলো একমাত্র accepting স্টেট)

৪ · Subset construction অ্যালগরিদম

Subset constructionNFA-কে সমতুল্য DFA-তে রূপান্তরের standard অ্যালগরিদম — প্রতিটি DFA স্টেট আসলে একগুচ্ছ NFA স্টেটের সেট, যা "একসাথে সম্ভাব্য সব NFA স্টেট" প্রতিনিধিত্ব করে। অ্যালগরিদমের মূল ধারণা: প্রতিটি DFA স্টেট হলো NFA স্টেটের একটি সেট — "নির্দিষ্ট ইনপুট পড়ার পর NFA একসাথে কোন কোন স্টেটে থাকতে পারত" তার সবগুলো একত্রে ধরে রাখা। ধাপগুলো —

  1. epsilon-closure(S): স্টেট-সেট S থেকে শুধু ε-ট্রানজিশন অনুসরণ করে যত স্টেটে পৌঁছানো যায়, তার সম্পূর্ণ সেট।
  2. DFA-র শুরুর স্টেট = epsilon_closure({NFA-র শুরুর স্টেট})।
  3. প্রতিটি (এখনও-না-processed) DFA স্টেট S এবং প্রতিটি ইনপুট সিম্বল c-এর জন্য: S-এর যেকোনো NFA-স্টেট থেকে c পড়ে যাওয়া যায় এমন সব NFA-স্টেটের ইউনিয়ন নাও, তারপর তার epsilon-closure নাও — এটাই নতুন DFA স্টেট।
  4. নতুন কোনো DFA স্টেট আর তৈরি না হওয়া পর্যন্ত ধাপ ৩ চালিয়ে যাও। যে DFA স্টেটে NFA-র কোনো accepting স্টেট আছে, সেটিই DFA-র accepting স্টেট।
start A {0,1,3,4} B {1,2,3,4} C {5} accept D ∅ trap a b a b a, b a, b
subset construction থেকে পাওয়া চার-স্টেট DFA — A শুরুর স্টেট, C একমাত্র accepting স্টেট (NFA-স্টেট 5 ধারণ করে বলে), D একটি "trap"/dead স্টেট যেখানে পৌঁছালে আর কখনো accept করা সম্ভব না।
Python
# a*b -এর NFA (Thompson's construction অনুসরণ করে হাতে বানানো)
# ট্রানজিশন: (from, symbol_or_None, to) -- None মানে epsilon
NFA = {
    'states': {0, 1, 2, 3, 4, 5},
    'alphabet': {'a', 'b'},
    'transitions': [
        (0, None, 1), (0, None, 3),   # শূন্যবার 'a' নেওয়ার সুযোগ
        (1, 'a', 2),                  # একটি 'a' পড়া
        (2, None, 1), (2, None, 3),   # লুপে ফিরে যাওয়া বা বের হওয়া
        (3, None, 4),                 # 'b' অংশে প্রবেশ
        (4, 'b', 5),                  # চূড়ান্ত 'b'
    ],
    'start': 0,
    'accept': {5},
}

def epsilon_closure(nfa, states):
    stack = list(states)
    closure = set(states)
    while stack:
        s = stack.pop()
        for (frm, sym, to) in nfa['transitions']:
            if frm == s and sym is None and to not in closure:
                closure.add(to)
                stack.append(to)
    return frozenset(closure)

def move(nfa, states, symbol):
    return {to for (frm, sym, to) in nfa['transitions'] if frm in states and sym == symbol}

def subset_construction(nfa):
    alphabet = sorted(nfa['alphabet'])
    start_set = epsilon_closure(nfa, {nfa['start']})
    dfa_states = {start_set}
    unmarked = [start_set]
    dfa_transitions = {}
    while unmarked:
        current = unmarked.pop()
        for symbol in alphabet:
            target = epsilon_closure(nfa, move(nfa, current, symbol))
            dfa_transitions[(current, symbol)] = target
            if target not in dfa_states:
                dfa_states.add(target)
                unmarked.append(target)
    dfa_accept = {s for s in dfa_states if s & nfa['accept']}
    return {'states': dfa_states, 'alphabet': alphabet, 'transitions': dfa_transitions,
            'start': start_set, 'accept': dfa_accept}

DFA = subset_construction(NFA)
print(f"subset construction থেকে পাওয়া DFA স্টেট সংখ্যা: {len(DFA['states'])}")
for st in sorted(DFA['states'], key=lambda s: sorted(s)):
    tag = "ACCEPT" if st in DFA['accept'] else ""
    print(f"  DFA স্টেট {sorted(st)} {tag}")

def run_dfa(dfa, s):
    state = dfa['start']
    for ch in s:
        state = dfa['transitions'].get((state, ch), frozenset())
    return state in dfa['accept']

print("\naccept হওয়া উচিত:")
for s in ["b", "ab", "aab", "aaab"]:
    print(f"  run_dfa({s!r}) = {'accept' if run_dfa(DFA, s) else 'reject'}")

print("\nreject হওয়া উচিত:")
for s in ["a", "ba", ""]:
    label = "(empty string)" if s == "" else ""
    print(f"  run_dfa({s!r}) = {'accept' if run_dfa(DFA, s) else 'reject'} {label}")

    
কোড আসলে ঠিক ৪টি DFA স্টেট আবিষ্কার করে — উপরের ডায়াগ্রামের A={0,1,3,4} (শুরু), B={1,2,3,4}, C={5} (accept), এবং একটি খালি সেট (dead/trap স্টেট, যেখানে কোনো NFA স্টেট নেই — একবার সেখানে পৌঁছালে আর কখনো accept সম্ভব নয়)। প্রতিটি accept/reject ফলাফল কোড চালিয়ে পাওয়া, হাতে বসিয়ে দেওয়া নয় — উপরের হাতে-কলমে ট্রেসের সাথে মিলিয়ে দেখুন।
মূল কথা · Key takeaway

NFA বানানো সহজ (প্রতিটি regex অপারেটরের একটি সরল টেমপ্লেট), কিন্তু সিমুলেট করা জটিল (একসাথে একাধিক স্টেট ট্র্যাক রাখতে হয়)। Subset construction এই দুটোর মধ্যে একটি সেতু — NFA-র "একাধিক সম্ভাব্য স্টেট" ধারণাকে DFA-র "একটি নির্দিষ্ট স্টেট (যা আসলে একটি সেট)" ধারণায় রূপান্তর করে, একবারের জন্য (compile time), যাতে রানটাইমে (L20-এর লেক্সার স্ক্যানিং) প্রতি ক্যারেক্টারে মাত্র একটি লুকআপ যথেষ্ট হয়। এই DFA-তে অনেক বেশি স্টেট থাকতে পারে যতটা প্রয়োজন — L19-এ আমরা দেখব কীভাবে সেগুলো মিনিমাইজ করা যায়।

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

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

প্র ০১ স্টেট D (dead/trap) থেকে বের হওয়ার কোনো পথ নেই কেন — এবং এটি DFA-তে রাখা কেন গুরুত্বপূর্ণ (বাদ দিয়ে দিলে কী সমস্যা হতো)?

D আসলে খালি সেট — কোনো NFA স্টেট এতে নেই, তাই এর কোনো আউটগোয়িং ট্রানজিশন কখনোই কোনো non-empty সেটে যেতে পারে না (খালি সেট থেকে move() সবসময় খালি রিটার্ন করে)। DFA-র সংজ্ঞা অনুযায়ী প্রতিটি স্টেট+সিম্বলে ঠিক একটি ট্রানজিশন থাকতে হয় (determinism) — D বাদ দিলে C-এর পর 'a' পড়লে DFA-র কোনো বৈধ পরবর্তী স্টেট থাকত না, যা DFA-র সংজ্ঞা লঙ্ঘন করত। D রাখাই "অবৈধ ইনপুটের পর সঠিকভাবে reject করার" একমাত্র উপায়।

প্র ০২ DFA স্টেট A={0,1,3,4} এবং B={1,2,3,4} শুধু একটি NFA-স্টেট (0 বনাম 2) ছাড়া প্রায় একই — তাহলে subset construction কেন এগুলোকে দুটি আলাদা DFA স্টেট হিসেবে রাখল, একটিতে মার্জ করল না?

Subset construction কখনো "কতটা মিল আছে" দেখে স্টেট মার্জ করে না — এটি শুধু সঠিকভাবে NFA সিমুলেট করার জন্য প্রয়োজনীয় সেটগুলো তৈরি করে, প্রতিটি ভিন্ন reachable সেট একটি ভিন্ন DFA স্টেট। A ও B ভিন্ন থাকা প্রয়োজনীয়, কারণ এই মুহূর্তে তাদের আচরণ একই মনে হলেও ভবিষ্যতে ভিন্ন হতে পারে এমন সম্ভাবনা থাকতে পারে (এখানে সৌভাগ্যক্রমে আসলে নেই, দুটোই সমান আচরণ করে)। দুটি স্টেট সত্যিই সমতুল্য কিনা তা প্রমাণ করে সেগুলো নিরাপদে মার্জ করা — এটাই ঠিক L19-এর DFA মিনিমাইজেশনের কাজ, subset construction-এর নয়।

প্র ০৩ ইনপুট "aab"-এর জন্য উপরের DFA-তে A থেকে শুরু করে হাতে-কলমে প্রতিটি ধাপ ট্রেস করুন — কোন স্টেটে শেষ হয়, এবং সেটি accept নাকি reject?

$A \xrightarrow{a} B \xrightarrow{a} B \xrightarrow{b} C$। প্রথম 'a' পড়ে A থেকে B, দ্বিতীয় 'a' পড়ে B নিজের উপর self-loop করে B-তেই থাকে, তারপর 'b' পড়ে B থেকে C-তে যায়। শেষ স্টেট C, যা DFA-র accepting সেট-এর অংশ (কারণ NFA-স্টেট 5 এতে আছে) — তাই accept। এটি উপরের কোড সেলের run_dfa("aab")-এর ফলাফলের সাথে হুবহু মেলে।

অনুশীলন

  1. হাতে চালান: ইনপুট "ba"-এর জন্য উপরের DFA হাতে-কলমে ট্রেস করুন (প্রতিটি ধাপের স্টেট লিখুন), তারপর accept/reject সিদ্ধান্ত দিন এবং কেন তা ভাষার সংজ্ঞার সাথে মেলে ব্যাখ্যা করুন।

    $A \xrightarrow{b} C \xrightarrow{a} D$। প্রথম 'b' পড়ে A থেকে সরাসরি accepting স্টেট C-তে যায় (যেহেতু ভাষা "শূন্য বা তার বেশি a, তারপর ঠিক একটি b")। কিন্তু তারপর আরেকটি 'a' পড়তে হয়, আর C থেকে যেকোনো সিম্বলে D (dead trap)-এ চলে যায়। শেষ স্টেট D, non-accepting — reject। এটি সঠিক, কারণ a*b ভাষায় 'b'-এর পরে আর কিছু থাকতে পারে না — "ba"-তে b-এর পরে একটি a আছে, যা এই প্যাটার্নের বাইরে।

  2. পরীক্ষা করুন: উপরের কোড সেলে accept_tests তালিকায় "aaaab" যোগ করে Run চাপুন — ফলাফল কী আসবে বলে আপনি প্রত্যাশা করেন, এবং কেন?

    "aaaab"-এর জন্য DFA বারবার B-তে self-loop করবে (চারটি 'a'-এর জন্য) তারপর শেষ 'b'-তে C-তে পৌঁছাবে — accept। এটি দেখায় B-এর self-loop-টি ঠিক "যতগুলো ইচ্ছা a" ধারণাটি বাস্তবায়ন করছে — কোনো নির্দিষ্ট সংখ্যক a-তে DFA আটকে থাকে না, শুধু "কমপক্ষে একটি a দেখেছি" এই তথ্যটুকুই (B স্টেট) মনে রাখা যথেষ্ট।

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

আগের পাঠ
লেক্সিক্যাল স্পেসিফিকেশনের জন্য রেগুলার এক্সপ্রেশন