Foundationপ্রথম নীতি থেকে
LEVEL 13লেসন ২/২৩কঠিন১ ঘণ্টা ৫ মিনিট

Finite Automata — DFA, NFA, ও তাদের সমতুল্যতা

Finite Automata: DFA, NFA, and Equivalence

DFA আর NFA-র 5-টুপল সংজ্ঞা, subset construction দিয়ে NFA→DFA রূপান্তরের সম্পূর্ণ হাতে-কলমে হিসাব, আর কেন দুইটা একই ভাষা চেনে অথচ একটা লিখতে সহজ, আরেকটা চালাতে।

এই লেসন শেষে আপনি পারবেন

  • DFA-র 5-টুপল আনুষ্ঠানিক সংজ্ঞা লিখে যেকোনো ছোট ভাষার জন্য DFA ডিজাইন করতে পারবেন
  • NFA-র সংজ্ঞা (ε-transition-সহ) লিখতে এবং ε-closure হিসাব করতে পারবেন
  • Subset construction algorithm হাতে-কলমে চালিয়ে একটা NFA-কে সমতুল্য DFA-তে রূপান্তর করতে পারবেন
  • কেন NFA লেখা সহজ কিন্তু DFA চালানো সহজ তা ব্যাখ্যা করতে পারবেন, আর কখন state blow-up ঘটে তা চিনতে পারবেন
  • DFA/NFA-র formal acceptance criterion-কে digital-logic FSM ও lexer-এর সাথে যুক্ত করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

digital-logic/finite-state-machines-এ আমরা FSM ডিজাইন করেছিলাম Moore/Mealy মডেলে — state register, next-state logic, output logic। সেটা ছিল hardware-এর ভাষা। এখন প্রশ্নটা উল্টে ধরি: কোন স্ট্রিং একটা FSM “accept” করে — এটা একটা আনুষ্ঠানিক, গাণিতিক প্রশ্ন, আর তার উত্তরের উপরেই দাঁড়িয়ে আছে প্রতিটা regex engine, প্রতিটা lexer, আর Σ*-এর মধ্যে “regular ভাষা” নামের পুরো শ্রেণিটা।

দুইজন ইঞ্জিনিয়ারের গল্প দিয়ে শুরু করি। দুজনেই একই কাজ করছেন — এমন একটা automaton ডিজাইন যেটা বাইনারি স্ট্রিং-এ substring "01" আছে কি না চেনে।

প্রথমজন ভাবলেন সহজভাবে: “শুরুতে থাকি q0-এ। 0 দেখলে দুই জায়গায় যেতে পারি — একরকম q0-এই থাকি (হয়তো এই 0-টা পরের 01-এর অংশ না), অথবা q1-এ যাই (এই 0-টাই সেই 01-এর শুরু)।” এটা তিনি প্রতিটা 0-তে “দুই সম্ভাবনা একসাথে” ভেবে ডিজাইন করলেন — এটাই non-determinism, স্বাভাবিকভাবে চিন্তার কাছাকাছি।

দ্বিতীয়জন চান এমন একটা automaton যা একটা মাইক্রোকন্ট্রোলারে চালানো যাবে — যেখানে প্রতিটা মুহূর্তে ঠিক একটা state-এ থাকতে হবে, কোনো “সম্ভাবনা” রাখা যাবে না। তাঁকে ডিজাইন করতে হলো একটা automaton যেখানে প্রতিটা state-এ প্রতিটা input symbol-এর জন্য ঠিক একটা পরবর্তী state আছে।

দুইজনের automaton আলাদা দেখতে, আলাদাভাবে ভাবা, কিন্তু একই ভাষা চেনে। এই লেসনে আমরা প্রমাণ করব এটা কাকতালীয় না — বরং একটা general theorem: নন-ডিটারমিনিস্টিক আর ডিটারমিনিস্টিক finite automaton-এর ক্ষমতা ঠিক সমান, আর একটা থেকে অন্যটা বানানোর একটা যান্ত্রিক পদ্ধতি (subset construction) আছে।

মূল ধারণা

DFA — আনুষ্ঠানিক সংজ্ঞা

Deterministic Finite Automaton (DFA) একটা 5-টুপল:

M=(Q,Σ,δ,q0,F)M = (Q, \Sigma, \delta, q_0, F)

উপাদানঅর্থ
QState-এর সসীম, অ-খালি সেট
ΣInput alphabet (সসীম)
δTransition function: δ: Q × Σ → Q — প্রতিটা (state, symbol)-এর জন্য ঠিক একটা পরবর্তী state
q0Start state, q0 ∈ Q
FAccepting (final) state-এর সেট, F ⊆ Q

Extended transition function δ̂: Q × Σ* → Q — একটা গোটা string দিলে শেষ state কোথায় তা বলে, δ থেকে recursively সংজ্ঞায়িত (লক্ষ্য করুন — এটা mathematics/induction লেসনের structural induction-এর ঠিক প্যাটার্ন):

δ^(q,ε)=q,δ^(q,wa)=δ(δ^(q,w),a)\hat\delta(q, \varepsilon) = q, \qquad \hat\delta(q, wa) = \delta(\hat\delta(q, w), a)

গৃহীত ভাষা:

L(M)={w∈Σ∗:δ^(q0,w)∈F}L(M) = \{ w \in \Sigma^* : \hat\delta(q_0, w) \in F \}

উদাহরণ — “শেষে 01” চেনার DFA (Σ = {0, 1}):

states: {q0, q1, q2}     (q2 = accept)
δ(q0, 0) = q1     δ(q0, 1) = q0
δ(q1, 0) = q1     δ(q1, 1) = q2
δ(q2, 0) = q1     δ(q2, 1) = q0
start: q0,   F = {q2}

লক্ষ্য করুন — প্রতিটা state-এ প্রতিটা symbol-এর জন্য একটা row আছে (δ টোটাল ফাংশন) — এটাই determinism-এর শর্ত। বাস্তবে প্রায়ই এই টেবিল “sparse” রাখা হয় — অনুল্লিখিত transition একটা implicit “dead state”-এ যায়, কিন্তু আনুষ্ঠানিক সংজ্ঞায় dead state-ও Q-র সদস্য।

NFA — nondeterminism ঢোকানো

Nondeterministic Finite Automaton (NFA) একই 5-টুপল আকৃতি, কিন্তু δ বদলে যায়:

δ:Q×(Σ∪{ε})→P(Q)\delta: Q \times (\Sigma \cup \{\varepsilon\}) \rightarrow \mathcal{P}(Q)

তিনটা পার্থক্য:

  1. একাধিক পরবর্তী state — δ(q, a) একটা state না, state-এর একটা সেট ফেরত দেয় (শূন্য, এক, বা একাধিক)
  2. ε-transition — কোনো input না পড়েই state বদলানো যায় (δ(q, ε))
  3. আংশিক — কোনো (q, a)-এর জন্য δ(q,a) = ∅ হতে পারে (সেই path “মরে যায়”)

Acceptance-এর সংজ্ঞা বদলে যায়: একটা string w accept হয় যদি অন্তত একটা path থাকে q0 থেকে শুরু করে w পড়ে কোনো accepting state-এ পৌঁছানোর। “সব path accept করতে হবে” না — শুধু একটা।

ε-closure — একটা concept যা শুধু NFA-তে দরকার:

ECLOSE(q)={q}∪{r:q→ε⋯→εr}\text{ECLOSE}(q) = \{q\} \cup \{r : q \xrightarrow{\varepsilon} \cdots \xrightarrow{\varepsilon} r\}

অর্থাৎ q থেকে শুধু ε-transition দিয়ে (শূন্য বা তার বেশি) পৌঁছানো যায় এমন সব state। উদাহরণ — আমাদের গল্পের প্রথম ইঞ্জিনিয়ারের NFA:

states: {p0, p1, p2}     (p2 = accept)
δ(p0, 0) = {p0, p1}      (দুই সম্ভাবনা — একসাথে!)
δ(p0, 1) = {p0}
δ(p1, 1) = {p2}
δ(p2, 0) = {p2}
δ(p2, 1) = {p2}
start: p0,   F = {p2}
(এখানে কোনো ε-transition নেই — সরলতার জন্য)

δ(p0, 0) = {p0, p1} — ঠিক গল্পের বর্ণনা: 0 দেখে NFA “দুই জায়গায় একসাথে থাকে”।

ভেতরে কী ঘটছে

Subset construction — NFA থেকে DFA বানানোর যান্ত্রিক পদ্ধতি

এখানেই এই লেসনের কেন্দ্রীয় algorithm। মূল ধারণা: DFA-র প্রতিটা state হবে NFA-র state-এর একটা সেট। DFA প্রতি মুহূর্তে “NFA সম্ভবত কোন কোন state-এ থাকতে পারত” — সেই পুরো সম্ভাবনার সেটটাই একটামাত্র (deterministic!) state হিসেবে ট্র্যাক করে।

Algorithm:

১. DFA-র start state = ECLOSE(NFA-র start state)
২. একটা কাজ-তালিকায় (worklist) এই নতুন state রাখুন
৩. যতক্ষণ worklist খালি না হয়:
     একটা DFA state S (= NFA state-এর সেট) তুলে নিন
     প্রতিটা input symbol a-এর জন্য:
       T = ECLOSE( ∪_{q ∈ S} δ(q, a) )
       DFA-তে transition S --a--> T যোগ করুন
       T যদি নতুন হয়, worklist-এ যোগ করুন
৪. যে DFA state-এ NFA-র কোনো accepting state আছে, সেটাই DFA-র accepting state

সম্পূর্ণ worked example

NFA নিই — Σ = {0,1}, ভাষা: “01” substring থাকতেই হবে, এবার ε-transition-সহ (compilers/lexical-analysis-এর regex→NFA প্যাটার্নের কাছাকাছি একটা রূপ):

states: {A, B, C, D}    (D = accept)
δ(A, 0) = {A, B}
δ(A, 1) = {A}
δ(B, 1) = {C}
δ(C, ε) = {D}
δ(D, 0) = {D}
δ(D, 1) = {D}
start: A,   F = {D}

ECLOSE হিসাব: ECLOSE(A)={A}, ECLOSE(B)={B}, ECLOSE(C)={C,D}, ECLOSE(D)={D} (C-তে ε দিয়ে D-তে যাওয়া যায়, তাই C-র ε-closure-এ D-ও ঢোকে)।

ধাপ ১ — start state: S0 = ECLOSE(A) = {A}

ধাপ ২ — S0 = {A} থেকে:

0: ∪ δ(q,0) for q∈{A} = δ(A,0) = {A,B}
   ECLOSE({A,B}) = {A} ∪ {B} = {A,B}   →  নতুন state S1 = {A,B}

1: ∪ δ(q,1) for q∈{A} = δ(A,1) = {A}
   ECLOSE({A}) = {A}                    →  পুরনো state S0

ধাপ ৩ — S1 = {A,B} থেকে:

0: δ(A,0) ∪ δ(B,0) = {A,B} ∪ ∅ = {A,B}
   ECLOSE({A,B}) = {A,B}                →  পুরনো state S1

1: δ(A,1) ∪ δ(B,1) = {A} ∪ {C} = {A,C}
   ECLOSE({A,C}) = {A} ∪ {C,D} = {A,C,D} → নতুন state S2 = {A,C,D}

ধাপ ৪ — S2 = {A,C,D} থেকে (D accepting, তাই S2 accepting):

0: δ(A,0) ∪ δ(C,0) ∪ δ(D,0) = {A,B} ∪ ∅ ∪ {D} = {A,B,D}
   ECLOSE({A,B,D}) = {A,B,D}            →  নতুন state S3 = {A,B,D}

1: δ(A,1) ∪ δ(C,1) ∪ δ(D,1) = {A} ∪ ∅ ∪ {D} = {A,D}
   ECLOSE({A,D}) = {A,D}                →  নতুন state S4 = {A,D}

ধাপ ৫, ৬ — S3, S4 (উভয়ই D ধারণ করে, তাই accepting):

S3={A,B,D}:  0 → ECLOSE({A,B}∪{D}) = {A,B,D} = S3
             1 → ECLOSE({A}∪{D})   = {A,D}   = S4

S4={A,D}:    0 → ECLOSE({A,B}∪{D}) = {A,B,D} = S3
             1 → ECLOSE({A}∪{D})   = {A,D}   = S4

সব state processed — worklist খালি। ফলাফল DFA:

DFA stateNFA statesAccepting?δ(·,0)δ(·,1)
S0{A}নাS0S1
S1{A,B}নাS1S2
S2{A,C,D}হ্যাঁS3S4
S3{A,B,D}হ্যাঁS3S4
S4{A,D}হ্যাঁS3S4
Subset construction-এর ফলাফল — ৫টা NFA state থেকে ৫টা DFA state (কাকতালীয়ভাবে সমান, সাধারণত DFA-র state বেশি হয়)।

যাচাই করুন: "001" — S0 →⁰ S1 →⁰ S1 →¹ S2 (accepting, ঠিক আছে, 001-এ 01 substring আছে)। "10" — S0 →¹ S0 →⁰ S1 (non-accepting, ঠিক — 10-এ 01 নেই)।

State blow-up — কেন এটা exponential হতে পারে

উপরের উদাহরণে NFA-র ৪টা state, DFA-র ৫টা — প্রায় সমান। কিন্তু সাধারণভাবে subset construction-এ DFA-র state সংখ্যা 2^|Q_NFA| পর্যন্ত হতে পারে (Q_NFA-র প্রতিটা উপসেটের জন্য একটা DFA state সম্ভব)। এটা ব্যবহারিক নয় শুধু তাত্ত্বিক ভীতি না — একটা ক্লাসিক উদাহরণ:

ভাষা: {w : w-এর ডান দিক থেকে n-তম character 1}

এই ভাষার জন্য একটা (n+1)-state NFA আছে (একটা “guess” state যা যেকোনো মুহূর্তে “এইটাই সেই n-তম থেকে শেষ position” ধরে নিয়ে গোনা শুরু করে), কিন্তু যেকোনো সমতুল্য DFA-র অন্তত 2ⁿ টা state লাগবেই (প্রমাণযোগ্য, Myhill-Nerode theorem দিয়ে) — কারণ DFA-কে শেষ n character-এর প্রতিটা সম্ভাব্য combination আলাদা state-এ মনে রাখতে হয়।

NFA বনাম DFA — trade-off-টা কোথায় কোথায় প্রকাশ পায়
  1. Design/লেখা সহজNFA — একাধিক সম্ভাবনা একসাথে express করা যায় (regex থেকে সরাসরি)
  2. রান-টাইম simulationNFA-তে প্রতি ধাপে state-সেট ট্র্যাক করতে হয় — O(|Q|) per step
  3. DFA-তে রান-টাইমপ্রতি ধাপে O(1) টেবিল lookup — hardware/embedded-এর জন্য আদর্শ
  4. Compile-time costSubset construction একবার — সাধারণত সস্তা, কিন্তু worst-case exponential

DFA minimization — এক নজরে

Subset construction-এর পরে DFA-তে প্রয়োজনের চেয়ে বেশি state থাকতে পারে — দুইটা state functionally সমান হতে পারে (একই ভবিষ্যৎ আচরণ) অথচ আলাদা লেবেলে আছে। Myhill-Nerode theorem বলে: দুইটা state p, q “equivalent” যদি কোনো suffix w-এর জন্য δ̂(p,w) ∈ F ⟺ δ̂(q,w) ∈ F — অর্থাৎ কোনো ভবিষ্যৎ ইনপুট তাদের আলাদা করতে না পারে।

Partition refinement algorithm (সংক্ষেপে): শুরুতে দুইটা দল — accepting আর non-accepting। বারবার একটা দলকে ভাঙুন যদি তার দুইটা state কোনো symbol-এ ভিন্ন দলে যায়। কোনো দল আর ভাঙা না গেলে থামুন — প্রতিটা অবশিষ্ট দল একটা মিনিমাইজড state।

এটা একটা unique (সেরা সম্ভব) DFA দেয় — Myhill-Nerode theorem এটাও প্রমাণ করে যে এই মিনিমাম DFA আইসোমরফিজম পর্যন্ত অনন্য। আমরা পূর্ণ প্রমাণে যাচ্ছি না, কিন্তু ব্যবহারিক তাৎপর্য বিশাল — lexer generator (flex, re2c) DFA generate করার পরপরই এটা minimize করে, যাতে embedded-এর মতো memory-সংবেদনশীল জায়গায় টেবিল ছোট থাকে।

উদাহরণ

সমতুল্যতা — দুই দিকের প্রমাণ-স্কেচ

দাবি: DFA আর NFA ঠিক একই শ্রেণির ভাষা চেনে (regular ভাষা)।

দিক ১ — প্রতিটা DFA একটা NFA (তুচ্ছ): যেকোনো DFA-কে NFA হিসেবে দেখুন যেখানে δ(q,a) একটা state-এর বদলে {δ(q,a)} (একক উপাদানের সেট), আর কোনো ε-transition নেই। সংজ্ঞা অনুযায়ী এটা বৈধ NFA, একই ভাষা চেনে। ∎ (এই দিকটা structural — কোনো নির্মাণ লাগে না, শুধু “special case” observation।)

দিক ২ — প্রতিটা NFA-র সমতুল্য DFA আছে: এইমাত্র subset construction দিয়ে গঠনমূলকভাবে দেখানো হলো — যেকোনো NFA দিলে আমরা একটা DFA বানাতে পারি (worklist algorithm), আর induction দিয়ে (string দৈর্ঘ্যের উপর, mathematics/induction-এর কৌশলে) প্রমাণ করা যায় প্রতিটা string দুই automaton-এই একই রায় (accept/reject) পায়। ∎

দুইটা একসাথে: যেহেতু প্রতিটা দিক থেকে রূপান্তর সম্ভব, DFA আর NFA যে ভাষার শ্রেণি চেনে তা হুবহু সমান — এই শ্রেণিটার নাম regular languages, ঠিক Chomsky hierarchy-র Type 3।

নিজে চালিয়ে দেখুন

EXPERIMENT

NFA simulation বনাম compile-to-DFA — একটা গতি তুলনা

Python 3· ২০ মিনিট
from itertools import product

# ── NFA সংজ্ঞা — এই লেসনের worked example ──────────────────────
nfa_delta = {
    ('A', '0'): {'A', 'B'},
    ('A', '1'): {'A'},
    ('B', '1'): {'C'},
    ('D', '0'): {'D'},
    ('D', '1'): {'D'},
}
nfa_eps = {'C': {'D'}}     # ε-transition: C -> D
nfa_start, nfa_accept = 'A', {'D'}


def eclose(states, eps):
    stack, result = list(states), set(states)
    while stack:
        s = stack.pop()
        for t in eps.get(s, ()):
            if t not in result:
                result.add(t); stack.append(t)
    return frozenset(result)


def nfa_accepts(w):
    current = eclose({nfa_start}, nfa_eps)
    for ch in w:
        nxt = set()
        for q in current:
            nxt |= nfa_delta.get((q, ch), set())
        current = eclose(nxt, nfa_eps)
    return bool(current & nfa_accept)


# ── Subset construction — NFA থেকে DFA বানানো ────────────────────
def subset_construction(delta, eps, start, accept, alphabet):
    start_set = eclose({start}, eps)
    dfa_delta, worklist, seen = {}, [start_set], {start_set}
    while worklist:
        S = worklist.pop()
        for a in alphabet:
            nxt = set()
            for q in S:
                nxt |= delta.get((q, a), set())
            T = eclose(nxt, eps)
            dfa_delta[(S, a)] = T
            if T not in seen:
                seen.add(T); worklist.append(T)
    dfa_accept = {S for S in seen if S & accept}
    return dfa_delta, start_set, dfa_accept


alphabet = ['0', '1']
dfa_delta, dfa_start, dfa_accept = subset_construction(
    nfa_delta, nfa_eps, nfa_start, nfa_accept, alphabet)


def dfa_accepts(w):
    state = dfa_start
    for ch in w:
        state = dfa_delta[(state, ch)]
    return state in dfa_accept


# ── সমতা যাচাই — সব ছোট স্ট্রিং-এ ────────────────────────────────
mismatches = 0
for length in range(0, 8):
    for combo in product('01', repeat=length):
        w = ''.join(combo)
        if nfa_accepts(w) != dfa_accepts(w):
            mismatches += 1
            print("MISMATCH:", w)
print(f"length 0-7-র সব স্ট্রিং-এ mismatch সংখ্যা: {mismatches}")

# ── গতি তুলনা ────────────────────────────────────────────────────
import time
long_string = "10" * 100000 + "01"

t0 = time.perf_counter(); nfa_accepts(long_string); t_nfa = time.perf_counter() - t0
t0 = time.perf_counter(); dfa_accepts(long_string); t_dfa = time.perf_counter() - t0
print(f"NFA simulation: {t_nfa*1000:.2f} ms")
print(f"DFA lookup    : {t_dfa*1000:.2f} ms")
print(f"speedup       : {t_nfa/t_dfa:.1f}x")
length 0-7-র সব স্ট্রিং-এ mismatch সংখ্যা: 0
NFA simulation: 38.42 ms
DFA lookup    : 9.87 ms
speedup       : 3.9x

দুইটা পর্যবেক্ষণ: (১) mismatches == 0 — subset construction-এর সমতুল্যতা প্রমাণটা প্রতিটা সম্ভাব্য ছোট string-এ পরীক্ষামূলকভাবে নিশ্চিত হলো। (২) speedup সংখ্যাটা এখানে state সংখ্যা ছোট বলে মাত্র ~৪ গুণ — বড়, জটিল regex (অনেক alternation/repetition) থেকে আসা NFA-তে এই পার্থক্য আরো নাটকীয় হয়, যেটাই lexer generator-রা কেন compile-time-এ DFA বানিয়ে রাখে তার বাস্তব কারণ।

এটা কী প্রমাণ করে

একই ভাষা দুইভাবে চালানো যায় — প্রতি-ইনপুট NFA simulation (state-সেট trace) বনাম আগে থেকে compile করা DFA table lookup। দ্বিতীয়টা উল্লেখযোগ্যভাবে দ্রুত, বিশেষত বড় ইনপুটে — ঠিক এই লেসনের subset construction-এর ব্যবহারিক মূল্য।

নিজে বানান

BUILD IT

সাধারণ Subset Construction — যেকোনো NFA-তে চলে

Python · ●●●○○
  1. NFA-কে একটা generic ডেটা কাঠামোয় প্রকাশ করুন (states, alphabet, transition dict, ε-transition dict, start, accept)
  2. ECLOSE(S) ফাংশন লিখুন — একটা state-সেট থেকে ε দিয়ে পৌঁছানো সব state যোগ করুন
  3. Worklist algorithm দিয়ে DFA state, transition, আর accepting set বানান
  4. DFA-কে আবার একটা readable টেবিল হিসেবে print করুন
  5. একটা দ্বিতীয়, স্বাধীন NFA দিয়ে পুরো pipeline টেস্ট করুন
from dataclasses import dataclass, field

@dataclass
class NFA:
    states: set
    alphabet: set
    delta: dict            # (state, symbol) -> set[state]
    eps: dict = field(default_factory=dict)   # state -> set[state]
    start: str = "q0"
    accept: set = field(default_factory=set)


@dataclass
class DFA:
    states: set            # প্রতিটা DFA state আসলে frozenset[str]
    alphabet: set
    delta: dict             # (frozenset, symbol) -> frozenset
    start: frozenset = None
    accept: set = field(default_factory=set)

    def accepts(self, w: str) -> bool:
        state = self.start
        for ch in w:
            if (state, ch) not in self.delta:
                return False            # trap — undefined transition
            state = self.delta[(state, ch)]
        return state in self.accept


def eclose(nfa: NFA, states: set) -> frozenset:
    stack, result = list(states), set(states)
    while stack:
        s = stack.pop()
        for t in nfa.eps.get(s, ()):
            if t not in result:
                result.add(t); stack.append(t)
    return frozenset(result)


def to_dfa(nfa: NFA) -> DFA:
    start = eclose(nfa, {nfa.start})
    delta, worklist, seen = {}, [start], {start}

    while worklist:
        S = worklist.pop()
        for a in nfa.alphabet:
            raw = set()
            for q in S:
                raw |= nfa.delta.get((q, a), set())
            T = eclose(nfa, raw)
            if T:                                  # খালি সেট = dead — transition বাদ
                delta[(S, a)] = T
                if T not in seen:
                    seen.add(T); worklist.append(T)

    accept = {S for S in seen if S & nfa.accept}
    return DFA(states=seen, alphabet=nfa.alphabet, delta=delta,
               start=start, accept=accept)


def print_dfa(dfa: DFA):
    names = {S: f"S{i}" for i, S in enumerate(sorted(dfa.states, key=lambda s: sorted(s)))}
    print(f"{'state':6} {'NFA-set':22} {'accept?':8}", *sorted(dfa.alphabet))
    for S in sorted(dfa.states, key=lambda s: names[s]):
        row = [names.get(dfa.delta.get((S, a)), "-") for a in sorted(dfa.alphabet)]
        print(f"{names[S]:6} {str(set(S)):22} {'yes' if S in dfa.accept else 'no':8}", *row)


# ── টেস্ট — নতুন NFA: 'ab' দিয়ে শুরু হওয়া স্ট্রিং, Σ={a,b} ─────────
nfa2 = NFA(
    states={"p0", "p1", "p2", "p3"},
    alphabet={"a", "b"},
    delta={
        ("p0", "a"): {"p1"},
        ("p1", "b"): {"p2"},
        ("p2", "a"): {"p2"}, ("p2", "b"): {"p2"},   # accept-এ থাকলে সব input গ্রহণ
    },
    start="p0", accept={"p2"},
)

dfa2 = to_dfa(nfa2)
print_dfa(dfa2)

for w in ["ab", "aba", "ba", "abbbb", "a"]:
    print(f"{w!r:8} → {dfa2.accepts(w)}")

নিজে বাড়ান:

  1. DFA minimization (partition refinement) যোগ করুন — to_dfa-র আউটপুটে redundant state আছে কি না দেখুন, থাকলে merge করুন
  2. Σ = {0,1} আর n = 4 নিয়ে “শেষ থেকে n-তম character 1” ভাষার NFA বানান (n+1 state), subset construction চালান, আর ফলাফল DFA-র state সংখ্যা 2ⁿ-এর কাছাকাছি কি না যাচাই করুন — state blow-up নিজের চোখে দেখুন
  3. একটা সরাসরি (compile না করে) NFA simulator লিখুন যেটা প্রতি ধাপে state-সেট ট্র্যাক করে, আর to_dfa-র সাথে output মিলিয়ে দেখুন সবসময় একই সিদ্ধান্ত দেয় কি না
  4. Dead-state trimming যোগ করুন — এমন DFA state বাদ দিন যেখান থেকে কোনো accepting state-এ পৌঁছানোই যায় না

বাস্তব সিস্টেমে

এই মডেলটা যেখানে সরাসরি ব্যবহৃত হয়

Lexer generator — flex, re2c, Python-এর re module-এর অভ্যন্তরে। compilers/lexical-analysis-এ আমরা দেখেছিলাম lexer regex→NFA→DFA chain-এর উপর দাঁড়িয়ে — এই লেসনেই সেই “subset construction” ধাপটার সম্পূর্ণ গাণিতিক ভিত্তি। flex একটা .l ফাইল থেকে regex পড়ে Thompson’s construction (পরের লেসন) দিয়ে NFA বানায়, তারপর ঠিক এই algorithm দিয়ে DFA কম্পাইল করে, তারপর minimize করে — আউটপুট C কোডে একটা সাধারণ টেবিল-লুকআপ loop, O(1) প্রতি character।

Hardware FSM — digital-logic/finite-state-machines। সেই লেসনের Moore/Mealy machine formally এই DFA সংজ্ঞারই একটা রূপ — δ = next-state combinational logic, Q = state register-এর সম্ভাব্য মান, F = “output = 1” state-গুলো। Nondeterminism hardware-এ অর্থহীন (circuit-এ প্রতি মুহূর্তে একটাই বাস্তব state থাকে) — এই কারণেই hardware সবসময় DFA-সমতুল্য, কখনো NFA না।

grep, ripgrep, RE2। দ্রুত regex ইঞ্জিন (RE2, Rust regex crate, grep -E-র নন-backtracking মোড) subset construction-এর একটা lazily-evaluated রূপ ব্যবহার করে — পুরো DFA আগে থেকে না বানিয়ে, প্রয়োজনমতো state বানায় আর cache করে (lazy DFA)। এতে worst-case exponential blow-up-এর ঝুঁকি এড়ানো যায় কারণ শুধু বাস্তবে-দেখা state-গুলোই তৈরি হয়।

Protocol state machine। TCP connection state (SYN_SENT, ESTABLISHED, CLOSE_WAIT, …) একটা DFA — প্রতিটা state-এ প্রতিটা event (SYN পাওয়া, FIN পাওয়া, timeout) -এর জন্য একটা নির্দিষ্ট পরবর্তী state আছে। networking module-এ এই একই formal মডেল ফিরে আসবে।

String matching algorithm — KMP। Knuth-Morris-Pratt algorithm আসলে pattern-এর জন্য একটা DFA বানায় (failure function-টা δ-এর একটা কম্প্যাক্ট encoding) — যে কারণে KMP O(n), প্রতি character-এ O(1) কাজ, ঠিক DFA-র মতো।

Game AI / UI state management। সহজ NPC behavior বা UI flow (idle → walking → attacking → dead) প্রায়ই একটা DFA হিসেবে ডিজাইন করা হয় — state সংখ্যা সসীম, প্রতিটা event-এ predictable transition, ডিবাগ করা সহজ।

যে ভুলগুলো সবাই করে

“NFA, DFA-র চেয়ে বেশি শক্তিশালী — এটা এমন ভাষা চিনতে পারে যা DFA পারে না।”

এটাই এই লেসনের কেন্দ্রীয় প্রতিষেধ্য দাবি — ভুল। Subset construction algorithm প্রমাণ করে প্রতিটা NFA-র একটা সমতুল্য DFA আছে। দুইটার ক্ষমতা (কোন ভাষা চেনা যায়) হুবহু সমান — regular ভাষার শ্রেণি।

NFA “বেশি ক্ষমতাশালী” মনে হওয়ার কারণ এটা লিখতে/ডিজাইন করতে সহজ — একাধিক সম্ভাবনা explicit ভাবে express করা যায়, প্রতিটা সম্ভাবনার জন্য আলাদা bookkeeping না করেই। কিন্তু এই সুবিধাটা expressiveness-এর না, compactness/ergonomics-এর।

তুলনা করুন: √2-কে দশমিকে লিখতে অসীম digit লাগে, কিন্তু √2 প্রতীকে লিখতে একটা মাত্র চিহ্ন — প্রকাশের সুবিধা প্রকাশযোগ্য সংখ্যার সেট বদলায় না। ঠিক তেমন NFA notation-এর সুবিধা ভাষার শ্রেণি বদলায় না।

“যেহেতু NFA আর DFA সমতুল্য, তাই বাস্তবে NFA না বানিয়ে সরাসরি DFA ডিজাইন করাই ভালো অভ্যাস।”

উল্টো — ব্যবহারিকভাবে NFA দিয়েই ডিজাইন করা হয়, DFA-তে কম্পাইল করা হয়। কারণ regex থেকে NFA বানানো (Thompson’s construction, পরের লেসন) একটা সরল, স্থানীয় (local), compositional algorithm — প্রতিটা regex অপারেটরের (union, concatenation, star) জন্য একটা fixed NFA প্যাটার্ন জোড়া লাগালেই হয়। সরাসরি DFA ডিজাইন করতে পুরো global state space একসাথে ভাবতে হয় — কঠিন, error-prone।

তাই বাস্তব pipeline সবসময়: regex → NFA (compositional, সহজ) → DFA (subset construction, যান্ত্রিক) → minimized DFA (compact, দ্রুত)। প্রতিটা ধাপ automatic — মানুষ শুধু প্রথম ধাপ (regex) লেখে।

“Subset construction সবসময় ব্যবহারিকভাবে exponential state blow-up ঘটায়, তাই বাস্তব compiler এটা এড়িয়ে চলে।”

Worst-case bound (2ⁿ) সত্যি, কিন্তু সাধারণ, বাস্তব প্রোগ্রামিং ভাষার lexical grammar-এ এটা প্রায় কখনো ঘটে না। Keyword, identifier, number literal-এর মতো প্যাটার্নে alternation/repetition-এর গঠন সাধারণত “disjoint” বা “local” — একে অপরের সাথে জটিলভাবে জড়ানো না, তাই বাস্তব DFA state সংখ্যা NFA-র সমান মাত্রার কাছাকাছি থাকে।

State blow-up-এর ক্লাসিক উদাহরণ (এই লেসনের “শেষ থেকে n-তম character” ভাষা) কৃত্রিমভাবে ডিজাইন করা — এমন প্যাটার্ন সাধারণ lexer grammar-এ দেখা যায় না। এই কারণেই flex/re2c বাস্তবে দশকের পর দশক ধরে নির্ভরযোগ্যভাবে subset construction ব্যবহার করে আসছে — তাত্ত্বিক worst-case আর ব্যবহারিক average-case ভিন্ন গল্প বলে, ঠিক যেমন advanced-architecture module-এ আমরা asymptotic bound আর measured performance-এর ফারাক দেখেছি।

বুঝেছেন কি না দেখুন

1

DFA আর NFA-র formal সংজ্ঞায় ঠিক কোন উপাদানটা (কোন অংশের টাইপ) বদলে যায়? Acceptance-এর সংজ্ঞা কীভাবে বদলায়?

স্মরণ

δ ফাংশনের টাইপ বদলে যায়:

  • DFA: δ: Q × Σ → Q (একটা input, একটা নির্দিষ্ট output state)
  • NFA: δ: Q × (Σ ∪ {ε}) → 𝒫(Q) (input ঐচ্ছিকভাবে ε-ও হতে পারে, output একটা সেট — শূন্য, এক, বা একাধিক state)

Acceptance বদলায়: DFA-তে একটামাত্র deterministic path আছে, তাই “accept” মানে সেই path-এর শেষ state F-এ। NFA-তে একাধিক সম্ভাব্য path থাকতে পারে (nondeterminism + ε-transition-এর কারণে); “accept” মানে অন্তত একটা path শেষে F-এর কোনো state-এ পৌঁছায় — বাকি path reject করলেও কিছু আসে যায় না।

2

একটা NFA-তে state {q1, q2} থেকে symbol a-তে δ(q1,a) = {q3} আর δ(q2,a) = {q3, q4}। এছাড়া q4-এর ε-closure-এ q5-ও আছে। Subset construction-এ {q1,q2} state থেকে a-তে DFA transition কোন state-এ যাবে?

প্রয়োগ

ধাপ ১ — raw union: δ(q1,a) ∪ δ(q2,a) = {q3} ∪ {q3,q4} = {q3,q4}

ধাপ ২ — ECLOSE প্রয়োগ: q4-এর ε-closure-এ q5 আছে বলে দেওয়া আছে, তাই ECLOSE({q3,q4}) ⊇ {q3,q4,q5} (ধরে নিচ্ছি q3-এর কোনো অতিরিক্ত ε-closure নেই, বলা হয়নি)।

ফলাফল: নতুন DFA state {q3, q4, q5}।

এই প্রশ্নটা algorithm-এর দুইটা ধাপ আলাদাভাবে মনে রাখার জন্য — প্রথমে সব সম্ভাব্য পরবর্তী NFA state-এর সরল union নিন, তারপর সেই সেটের উপর ECLOSE প্রয়োগ করুন। ক্রম উল্টে ফেললে ভুল হবে যদি q1 বা q2-এর নিজেদের ε-closure-এও নতুন কিছু থাকত — যদিও এখানে সেটা প্রশ্নে ধরা নেই বলে ফলাফল একই আসে, সাধারণভাবে ECLOSE transition-এর আগে start state-এর জন্য, আর পরে প্রতিটা নতুন সেটের জন্য প্রয়োগ করতে হয় (এই লেসনের algorithm বিবরণে যেমন লেখা)।

3

একজন জুনিয়র ইঞ্জিনিয়ার বলছেন: “NFA-কে সরাসরি চালানো (state-সেট simulate করা) সবসময় DFA-তে কম্পাইল করার চেয়ে খারাপ, তাই কখনো সরাসরি NFA simulation ব্যবহার করা উচিত না।” এটা মেনে নেবেন? কখন সরাসরি NFA simulation ভালো সিদ্ধান্ত হতে পারে?

যুক্তি

মেনে নেওয়া যায় না — এটা একটা trade-off, “সবসময় খারাপ” না।

যখন DFA কম্পাইল করা ভালো: একই pattern বহুবার (অনেক ইনপুটে, বা একই ইনপুটে বহুবার) match করতে হবে — compile-time cost একবার দিয়ে, প্রতিটা match-এ O(1)/character পাওয়া যায়। lexer এই ক্যাটাগরিতে — একই token pattern লক্ষ লক্ষ character-এ চালানো হয়।

যখন সরাসরি NFA simulation ভালো: (ক) pattern মাত্র একবার বা কম সংখ্যকবার ব্যবহার হবে — compile করার overhead বাঁচানোই লাভজনক না। (খ) worst-case exponential state blow-up-এর ঝুঁকি আছে (এই লেসনের “n-তম character” উদাহরণ) — সরাসরি simulation-এ প্রতি character O(|Q_NFA|) লাগে, কিন্তু state সংখ্যা কখনো exponential হয় না, তাই memory-নিরাপদ। (গ) lazy/on-the-fly DFA (RE2-জাতীয় ইঞ্জিন) — এটা আসলে দুইয়ের মিশ্রণ: subset construction চলে, কিন্তু “lazily”, শুধু বাস্তবে দরকার হলে নতুন DFA state বানায়, আর memory বাজেট শেষ হলে পুরনো state ফেলে দেয়।

সংক্ষেপে: frequency আর predictability-র উপর নির্ভর করে সিদ্ধান্ত — বহু-ব্যবহারে compile, একবার-ব্যবহারে বা ঝুঁকিপূর্ণ pattern-এ lazy/direct simulation।

4

আপনাকে একটা ভাষা দেওয়া হলো: Σ = {a,b,c}, ভাষা = “স্ট্রিং-এর শেষ তিনটা character-এর মধ্যে অন্তত একটা c”। (ক) একটা compact NFA ডিজাইন করুন (state সংখ্যা বলুন)। (খ) subset construction চালালে DFA-তে কত state আসতে পারে ধারণা করুন, আর কেন।

ডিজাইন

(ক) NFA ডিজাইন: একটা “guess” state ব্যবহার করা যায় — প্রতিটা c দেখলে NFA nondeterministically সিদ্ধান্ত নিতে পারে “এটাই হয়তো সেই গুরুত্বপূর্ণ c, এখন থেকে বাকি ৩টার মধ্যে গোনা শুরু করি।”

q0 --a,b,c--> q0             (এখনো "গুরুত্বপূর্ণ c" দেখিনি)
q0 --c--> q1                 (guess: এই c-টাই গুরুত্বপূর্ণ, গোনা শুরু)
q1 --a,b,c--> q2 --a,b,c--> q3   (accept)

{q0, q1, q2, q3} — মাত্র ৪টা state। লক্ষ্য করুন q0-এ c দেখে দুই জায়গায় (নিজের কাছে q0, আর q1-এ) একসাথে যাওয়া হয় — সেই nondeterminism-টাই compactness আনছে।

(খ) DFA state সংখ্যা: সমতুল্য DFA-কে “শেষ ৩টা character কী ছিল” মনে রাখতে হবে (কারণ পরবর্তী ইনপুট দেখেই বলা যাবে না window-টা সরে গিয়ে c window থেকে বেরিয়ে গেল কি না)। শেষ ৩টা character-এর সম্ভাব্য সংমিশ্রণ |Σ|³ = 3³ = 27, প্লাস শুরুর “এখনো ৩টা character-ও আসেনি” অবস্থাগুলো (length 0,1,2) — মোট রাফ হিসাবে প্রায় 27 + few state, অর্থাৎ প্রায় ৩০টা state, ৪টার তুলনায় বিশাল বৃদ্ধি।

এটা এই লেসনের state blow-up-এর একটা মৃদু রূপ — |Σ|ᵏ বৃদ্ধি, k = “কতদূর পেছনে মনে রাখতে হয়”। সম্পূর্ণ exponential (2ⁿ) না, কিন্তু একই কারণে ঘটছে: NFA-র “guess now, verify later” কৌশল DFA-তে “সব সম্ভাব্য window state আগে থেকেই আলাদা রাখা”-তে রূপান্তরিত হয়।

এরপর কী

আজ আমরা দেখলাম DFA আর NFA আনুষ্ঠানিকভাবে সমতুল্য — subset construction একটা গঠনমূলক প্রমাণ, এবং lexer/DFA-based tool-এর ভিত্তি। কিন্তু NFA কোথা থেকে আসে? আমরা এখনো হাতে হাতে NFA ডিজাইন করছি — বাস্তবে regex থেকে এটা স্বয়ংক্রিয়ভাবে বানানো হয়।

পরের লেসনে আমরা regular expression-এর আনুষ্ঠানিক সংজ্ঞা দেখব, Thompson’s construction দিয়ে regex → NFA রূপান্তর হাতে-কলমে করব (এই লেসনের subset construction-এর ঠিক আগের ধাপ), আর তারপর সবচেয়ে গুরুত্বপূর্ণ প্রশ্নে যাব: কোন ভাষা regex দিয়ে ধরা যায় না, আর তা কীভাবে প্রমাণ করব — pumping lemma। সেখানেই আমরা প্রথম লেসনের 0ⁿ1ⁿ-এর non-regularity কঠোরভাবে প্রমাণ করব, আর দেখব কেন backtracking regex engine (Python re, PCRE) এই তাত্ত্বিক মডেলের বাইরে গিয়ে catastrophic backtracking-এর ঝুঁকি নেয়।

আরও পড়ুন

  • Introduction to the Theory of Computation (3rd ed.), Chapter 1 — Michael Sipser · DFA/NFA formal সংজ্ঞা ও subset construction-এর ক্যাননিক্যাল treatment
  • Introduction to Automata Theory, Languages, and Computation (3rd ed.), Chapter 2 — Hopcroft, Motwani, Ullman · Minimization ও Myhill-Nerode theorem-এর সম্পূর্ণ প্রমাণ
  • Compilers: Principles, Techniques, and Tools (2nd ed.), Section 3.7 — Aho, Sethi, Ullman, Lam · Subset construction-এর ঠিক এই ব্যবহারিক প্রয়োগ — lexer generator (flex/re2c) কীভাবে এটা ব্যবহার করে