পাঠ ২৩ · ৫৬-এর মধ্যে · মডিউল ৫
Home / Courses / Formal Language & Automata Theory / Theory of Computation / পুশডাউন অটোমাটা

PDA অ্যাকসেপ্টেন্স — ফাইনাল স্টেট বনাম এম্পটি স্ট্যাক

PDA acceptance — final state vs empty stack
৭ মিনিট পড়া মাঝারি · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • ফাইনাল-স্টেট অ্যাকসেপ্টেন্স কনভেনশন — L06/L07-এর DFA/NFA কনভেনশনের সরাসরি সম্প্রসারণ
  • এম্পটি-স্ট্যাক অ্যাকসেপ্টেন্স কনভেনশন — PDA-নির্দিষ্ট, DFA/NFA-এ যার কোনো analog নেই
  • এই দুই কনভেনশনের সমতা প্রমাণের ধারণা — উভয় দিকের কনভার্শন কীভাবে কাজ করে
  • Python-এ একটি সত্যিকারের কনভার্শন অ্যালগরিদম প্রয়োগ করে সমতা কনক্রিটলি verify করা

১ · দুটো ভিন্ন অ্যাকসেপ্টেন্স কনভেনশন

L22-এ PDA-এর ফরমাল সংজ্ঞায় $F \subseteq Q$ (accept স্টেটসমূহ) ছিল, ঠিক DFA/NFA-এর মতোই। কিন্তু PDA-এর একটি বাড়তি জিনিস আছে — স্ট্যাক — আর সেই স্ট্যাকের চূড়ান্ত অবস্থাও একটি স্বাভাবিক, বিকল্প অ্যাকসেপ্টেন্স শর্ত হতে পারে। তাই দুটো ভিন্ন, উভয়ই স্ট্যান্ডার্ড কনভেনশন প্রচলিত আছে:

ফাইনাল স্টেট (Final State)
PDA স্ট্রিং $w$ accept করে যদি কোনো কম্পিউটেশন পাথ পুরো ইনপুট consume করে একটি accept স্টেটে ($F$-এর মধ্যে) থামে — স্ট্যাকের চূড়ান্ত কনটেন্ট সম্পূর্ণ irrelevant।
এম্পটি স্ট্যাক (Empty Stack)
PDA স্ট্রিং $w$ accept করে যদি কোনো কম্পিউটেশন পাথ পুরো ইনপুট consume করে স্ট্যাক সম্পূর্ণ খালি করে ফেলে — বর্তমান স্টেট কী তা সম্পূর্ণ irrelevant (তাই এই কনভেনশনে প্রায়ই $F$ পুরোপুরি বাদ দেওয়া হয়, বা খালি রাখা হয়)।

L22-এর $0^n1^n$ PDA-টি ফাইনাল-স্টেট কনভেনশনে তৈরি ছিল — সেখানে qf-এ পৌঁছানোই ছিল accept শর্ত। এই পাঠে সেই একই ভাষার জন্য একটি এম্পটি-স্ট্যাক PDA বানানো হবে — কোনো accept স্টেটেরই দরকার পড়বে না।

২ · সমতা থিওরেম

এই দুই কনভেনশন সম্পূর্ণ ভিন্ন দেখতে হলেও, এগুলো একই শ্রেণির ভাষা সংজ্ঞায়িত করে — যদিও একটি নির্দিষ্ট PDA দুই কনভেনশনে ভিন্ন আচরণ করতে পারে, প্রতিটি ফাইনাল-স্টেট PDA-এর জন্য একটি সমতুল্য এম্পটি-স্ট্যাক PDA বানানো সম্ভব, এবং উল্টোটাও।

ফাইনাল স্টেট → এম্পটি স্ট্যাক: একটি নতুন "cleanup" স্টেট যোগ করুন — যখনই মূল PDA একটি accept স্টেটে পৌঁছায়, নতুন স্টেটে গিয়ে ε-ট্রানজিশন দিয়ে স্ট্যাকের সব সিম্বল একে একে pop করে খালি করে ফেলা হয়, তারপর accept।

এম্পটি স্ট্যাক → ফাইনাল স্টেট (এই পাঠের কোড যা বাস্তবায়ন করবে): একটি নতুন বটম-মার্কার স্ট্যাক-সিম্বল $X_0$ (মূল $\Gamma$-তে নেই এমন) এবং দুটো নতুন স্টেট $q_0'$ (নতুন শুরু) ও $q_f$ (নতুন accept) যোগ করুন —

  • $q_0'$ থেকে ε-ট্রানজিশনে মূল PDA-এর শুরু-স্ট্যাক-সিম্বল push করে (নিচে $X_0$ রেখে) মূল শুরু স্টেটে যাওয়া হয়।
  • মূল PDA-এর যেকোনো স্টেট $q$-তে, যদি স্ট্যাকের টপে $X_0$ দেখা যায় (অর্থাৎ মূল স্ট্যাক ইতিমধ্যে খালি হয়ে গেছে), তাহলে ε-ট্রানজিশনে $q_f$-এ চলে যাওয়া হয় — এটাই accept শর্ত।

$$\delta'(q_0', \varepsilon, X_0) = \{(q_0, Z_0 X_0)\} \qquad \delta'(q, \varepsilon, X_0) = \{(q_f, X_0)\} \text{ প্রতিটি } q \in Q \text{-এর জন্য}$$

কেন এই কনভার্শন সঠিক

মূল PDA-তে স্ট্যাক খালি হওয়া মানেই নতুন PDA-তে $X_0$ উন্মুক্ত হয়ে যাওয়া (কারণ $X_0$ সবসময় সবচেয়ে নিচে বসে আছে, মূল স্ট্যাক-কনটেন্টের নিচে) — আর সেই মুহূর্তেই (যেকোনো স্টেট থেকে) $q_f$-এ যাওয়ার সুযোগ তৈরি হয়। তাই "মূল PDA স্ট্যাক খালি করেছে" ঠিক তখনই সত্য যখন "নতুন PDA $q_f$-এ পৌঁছাতে পারে" — দুটো শর্ত সমতুল্য।

৩ · Python-এ সমতা verify করা

নিচের কোড সেলে L22-এর PDA ক্লাস সম্প্রসারণ করে দুটো আলাদা মেথড — accepts_by_final_state ও accepts_by_empty_stack — যোগ করা হয়েছে, তারপর $0^n1^n$-এর জন্য একটি এম্পটি-স্ট্যাক PDA বানিয়ে (কোনো accept স্টেট ছাড়াই) সেটিকে convert_to_final_state_acceptance দিয়ে কনভার্ট করা হয়েছে — এবং উভয় PDA একই ব্যাচ টেস্ট স্ট্রিং-এ ঠিক একই ফলাফল দেয় কিনা তা সরাসরি চেক করা হচ্ছে।

Python
# L23 -- final-state বনাম empty-stack PDA অ্যাকসেপ্টেন্স, সমতা কনক্রিটলি verify করা

class PDA:
    def __init__(self, states, input_alphabet, stack_alphabet, delta, start_state, start_stack, accept_states=None):
        self.states = states
        self.input_alphabet = input_alphabet
        self.stack_alphabet = stack_alphabet
        self.delta = delta
        self.start_state = start_state
        self.start_stack = start_stack
        self.accept_states = accept_states or set()

    def _moves(self, state, remaining_input, stack):
        moves = []
        if not stack:
            return moves
        top = stack[-1]
        for (new_state, push) in self.delta.get((state, None, top), []):
            new_stack = list(stack[:-1])
            new_stack.extend(reversed(push))
            moves.append((new_state, remaining_input, tuple(new_stack)))
        if remaining_input:
            a = remaining_input[0]
            for (new_state, push) in self.delta.get((state, a, top), []):
                new_stack = list(stack[:-1])
                new_stack.extend(reversed(push))
                moves.append((new_state, remaining_input[1:], tuple(new_stack)))
        return moves

    def _reachable_configs(self, string):
        start_config = (self.start_state, string, (self.start_stack,))
        seen = {start_config}
        frontier = [start_config]
        while frontier:
            state, remaining, stack = frontier.pop()
            yield (state, remaining, stack)
            for nxt in self._moves(state, remaining, stack):
                if nxt not in seen:
                    seen.add(nxt)
                    frontier.append(nxt)

    def accepts_by_final_state(self, string):
        return any(remaining == "" and state in self.accept_states
                   for (state, remaining, stack) in self._reachable_configs(string))

    def accepts_by_empty_stack(self, string):
        return any(remaining == "" and len(stack) == 0
                   for (state, remaining, stack) in self._reachable_configs(string))


# --- এম্পটি-স্ট্যাক PDA for L = {0^n 1^n}, কোনো accept state দরকার নেই ---
es_delta = {
    ("q0", "0", "Z0"): [("q0", ("X", "Z0"))],
    ("q0", "0", "X"): [("q0", ("X", "X"))],
    ("q0", "1", "X"): [("q1", ())],
    ("q1", "1", "X"): [("q1", ())],
    ("q0", None, "Z0"): [("q0", ())],   # খালি স্ট্রিং: সরাসরি বটম-মার্কার pop
    ("q1", None, "Z0"): [("q1", ())],   # সব X pop হয়ে গেলে বটম-মার্কারও pop -> স্ট্যাক খালি
}
es_pda = PDA({"q0", "q1"}, {"0", "1"}, {"Z0", "X"}, es_delta, "q0", "Z0", accept_states=set())


def convert_to_final_state_acceptance(empty_stack_pda):
    """স্ট্যান্ডার্ড কনস্ট্রাকশন: empty-stack PDA -> সমতুল্য final-state PDA।
    নতুন বটম-মার্কার X0 এবং নতুন স্টেট q0', qf যোগ করা হয়।"""
    new_start, new_accept, X0 = "q0__NEW", "qf__NEW", "X0__NEW"
    new_states = set(empty_stack_pda.states) | {new_start, new_accept}
    new_stack_alphabet = set(empty_stack_pda.stack_alphabet) | {X0}
    new_delta = {k: list(v) for k, v in empty_stack_pda.delta.items()}

    new_delta[(new_start, None, X0)] = [(empty_stack_pda.start_state,
                                          (empty_stack_pda.start_stack, X0))]
    for q in empty_stack_pda.states:
        new_delta.setdefault((q, None, X0), []).append((new_accept, (X0,)))

    return PDA(new_states, empty_stack_pda.input_alphabet, new_stack_alphabet, new_delta,
               new_start, X0, accept_states={new_accept})


fs_pda = convert_to_final_state_acceptance(es_pda)

print("স্ট্রিং      | এম্পটি-স্ট্যাক | কনভার্টেড ফাইনাল-স্টেট | মিলছে?")
print("-" * 60)
all_match = True
for s in ["0011", "000111", "", "10", "0011000", "0111"]:
    a = es_pda.accepts_by_empty_stack(s)
    b = fs_pda.accepts_by_final_state(s)
    all_match = all_match and (a == b)
    print(f"{s!r:12s} | {a!s:14s} | {b!s:22s} | {a == b}")
print("\nসবগুলো মিলেছে (সমতা নিশ্চিত):", all_match)

    
লক্ষ্য করুন convert_to_final_state_acceptance-এ new_delta.setdefault((q, None, X0), []).append(...) লাইনটি — এটি মূল PDA-এর প্রতিটি স্টেট $q$-এর জন্য একটি ε-ট্রানজিশন যোগ করছে, কারণ মূল স্ট্যাক ঠিক কোন স্টেটে খালি হবে তা আমরা আগে থেকে জানি না (নন-ডিটারমিনিস্টিকভাবে বিভিন্ন পাথে বিভিন্ন সময়ে খালি হতে পারে) — তাই প্রতিটি স্টেট থেকেই এই "escape hatch" থাকা প্রয়োজন।
মূল কথা · Key takeaway

ফাইনাল স্টেট আর এম্পটি স্ট্যাক — দুটো ভিন্ন দেখতে অ্যাকসেপ্টেন্স নিয়ম, কিন্তু উভয়ই একই শ্রেণির ভাষা (কনটেক্সট-ফ্রি ল্যাঙ্গুয়েজ) সংজ্ঞায়িত করে, কারণ যেকোনো একটি কনভেনশনের PDA-কে অন্য কনভেনশনের একটি সমতুল্য PDA-তে যান্ত্রিকভাবে কনভার্ট করা যায় — L24-এ CFG-PDA সমতা প্রমাণের সময় এম্পটি-স্ট্যাক কনভেনশনই বেশি সুবিধাজনক হবে, তাই এই দুই কনভেনশন হাতে-কলমে জানা এখন কাজে লাগবে।

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

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

প্র ০১ এম্পটি-স্ট্যাক কনভেনশনে কেন সাধারণত কোনো accept স্টেট সেট $F$-এর দরকার পড়ে না?

কারণ এম্পটি-স্ট্যাক কনভেনশনে অ্যাকসেপ্টেন্সের শর্তই হলো "স্ট্যাক সম্পূর্ণ খালি" — এটি সম্পূর্ণভাবে স্ট্যাকের অবস্থার উপর নির্ভরশীল, বর্তমান স্টেট কী তার উপর নয়। তাই $F$ সেটটি সংজ্ঞায় থাকলেও অ্যাকসেপ্টেন্সের সিদ্ধান্তে এর কোনো ভূমিকা থাকে না — উপরের কোডে es_pda-এর accept_states=set() (খালি সেট) দেওয়া হয়েছে, কারণ accepts_by_empty_stack মেথড কখনোই accept_states চেক করে না।

প্র ০২ যদি একটি PDA-এর ট্রানজিশন এমনভাবে ডিজাইন করা হয় যে স্ট্যাক মাঝপথে ভুলবশত খালি হয়ে যেতে পারে (ইনপুট এখনো বাকি থাকতেই), তাহলে কী সমস্যা হতে পারে?

মাঝপথে স্ট্যাক খালি হয়ে গেলে _moves-এ if not stack: return moves শর্ত সত্য হয়ে যায় — অর্থাৎ সেই কনফিগারেশন থেকে আর কোনো ট্রানজিশনই সম্ভব নয় (কারণ প্রতিটি ট্রানজিশনের একটি স্ট্যাক-টপ প্রয়োজন, এমনকি ε-ট্রানজিশনেও)। তাই যদি ইনপুট তখনো বাকি থাকে, সেই কম্পিউটেশন পাথ একটি dead end-এ চলে যায় এবং সেই পাথ দিয়ে স্ট্রিং কখনোই accept হতে পারবে না — এই কারণেই একটি বটম-মার্কার সিম্বল (যেমন $Z_0$) রাখা এত গুরুত্বপূর্ণ, যাতে স্ট্যাক অপ্রত্যাশিতভাবে "উধাও" না হয়ে যায়।

প্র ০৩ কনভার্টেড ফাইনাল-স্টেট PDA-তে new_delta[(new_start, None, X0)]-এ কেন (empty_stack_pda.start_stack, X0) — অর্থাৎ দুটো সিম্বল — push করা হচ্ছে, শুধু একটি নয়?

কারণ নতুন PDA-তে $X_0$ সবসময় স্ট্যাকের একেবারে নিচে "স্থায়ী বটম মার্কার" হিসেবে থাকতে হবে (যাতে পরে মূল স্ট্যাক খালি হলে সেটা চেনা যায়), অথচ মূল PDA-এর নিজস্ব বটম মার্কার ($Z_0$)-ও দরকার যাতে মূল PDA স্বাভাবিকভাবে কাজ করতে পারে। তাই দুটো একসাথে push করা হয় — $Z_0$ উপরে (মূল PDA-এর কাজের জন্য), আর $X_0$ তার নিচে (কনভার্শনের "sentinel" হিসেবে, যা কখনো pop হয় না, শুধু উন্মুক্ত হয়)।

অনুশীলন

  1. চিন্তা করুন: ফাইনাল-স্টেট → এম্পটি-স্ট্যাক দিকের কনভার্শনে (এই পাঠে শুধু বর্ণনা করা হয়েছে, কোড করা হয়নি) "cleanup" স্টেটে কেন ε-ট্রানজিশন ব্যবহার করা প্রয়োজন, ইনপুট-consuming ট্রানজিশন নয়?

    কারণ cleanup ধাপে কোনো নতুন ইনপুট সিম্বল পড়া হচ্ছে না — মূল PDA ইতিমধ্যে পুরো ইনপুট consume করে ফেলেছে (accept স্টেটে পৌঁছে গেছে), এখন শুধু স্ট্যাকের অবশিষ্ট সিম্বলগুলো পরিষ্কার করা হচ্ছে। যদি ইনপুট-consuming ট্রানজিশন ব্যবহার করা হতো, তাহলে এটি ভুলভাবে আরও ইনপুট সিম্বল দাবি করতো, যা ইতিমধ্যে সম্পূর্ণ-consumed স্ট্রিং-এর জন্য ভুল আচরণ হতো।

  2. পরীক্ষা করুন: উপরের কোড সেলে টেস্ট তালিকায় "00001111" ($n=4$) যোগ করে চালান — es_pda এবং fs_pda কি একই ফলাফল দেয়?

    হ্যাঁ — "00001111" $0^n1^n$ ভাষার সদস্য ($n=4$), তাই উভয় PDA-ই True ফেরত দেবে। এম্পটি-স্ট্যাক PDA-তে স্ট্যাক ঠিক ইনপুট শেষ হওয়ার সাথে সাথে সম্পূর্ণ খালি হয়ে যায়, আর কনভার্টেড ফাইনাল-স্টেট PDA-তে ঠিক তখনই $X_0$ উন্মুক্ত হয়ে qf__NEW-এ যাওয়ার সুযোগ তৈরি হয় — দুই কনভেনশনের সমতা আবারও নিশ্চিত হয়।

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

আগের পাঠ
L22 · PDA — ফরমাল ডেফিনিশন