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

Context-Free Grammar ও Pushdown Automata — Stack যখন Memory

Context-Free Grammars and Pushdown Automata

CFG আর pushdown automaton (PDA) একই ক্ষমতার দুই রূপ — DFA-র সসীম state-এর সাথে মাত্র একটা stack যোগ করলেই `0ⁿ1ⁿ`-এর মতো ভাষা চেনা সম্ভব হয়, আর recursive descent parser-এর call stack-ই সেই PDA stack, লুকিয়ে থাকা অবস্থায়।

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

  • CFG-র derivation, parse tree, ও leftmost/rightmost derivation formally আলাদা করতে পারবেন
  • একটা grammar-এর ambiguity দুইটা distinct parse tree দেখিয়ে প্রমাণ করতে পারবেন এবং inherent ambiguity-র ধারণা ব্যাখ্যা করতে পারবেন
  • Pushdown automaton-এর formal সংজ্ঞা লিখতে এবং একটা নির্দিষ্ট ভাষার জন্য PDA ডিজাইন করতে পারবেন
  • CFG ↔ PDA সমতুল্যতা এবং recursive descent parser-এর call stack কীভাবে আক্ষরিক অর্থে একটা PDA stack — এই সংযোগ ব্যাখ্যা করতে পারবেন
  • Context-free ভাষার pumping lemma প্রয়োগ করে দেখাতে পারবেন `{aⁿbⁿcⁿ}` context-free না

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

আগে এটা বুঝি

এই module-এর প্রথম লেসনেই আমরা একটা experiment চালিয়েছিলাম — regex দিয়ে balanced parentheses ধরার চেষ্টা, আর সেটা depth ২-র পরে ভেঙে পড়েছিল। গত লেসনে আমরা সেই ব্যর্থতাটা প্রমাণ করে ফেললাম — pumping lemma দিয়ে দেখালাম {0ⁿ1ⁿ} কোনো DFA, NFA, বা regex দিয়ে সাধারণভাবে ধরা অসম্ভব।

অথচ প্রতিটা প্রোগ্রামিং ভাষার parser ঠিক এই ধরনের ভাষা প্রতিদিন পার্স করে — (((1+2)*3)-4), নেস্টেড JSON object, নেস্টেড if-else ব্লক। compilers/parsing-এ আমরা একটা recursive descent parser লিখেছিলাম যেটা এই কাজ অনায়াসে করত। তাহলে প্রশ্নটা — কী সেই অতিরিক্ত ক্ষমতা যেটা DFA-র নেই কিন্তু parser-এর আছে?

উত্তরটা আশ্চর্যরকম ছোট: একটামাত্র stack।

এই লেসনে আমরা এই সরল কিন্তু গভীর সত্যটা আনুষ্ঠানিকভাবে দেখব — DFA-তে একটা unbounded stack যোগ করলেই সেটা 0ⁿ1ⁿ-জাতীয় ভাষা চিনতে সক্ষম হয়ে ওঠে (এটাই pushdown automaton), আর এই automaton ঠিক ততটাই শক্তিশালী যতটা context-free grammar — যেই grammar দিয়ে compilers/grammars-and-ambiguity-তে আমরা arithmetic expression বর্ণনা করেছিলাম। আর সবচেয়ে চমকপ্রদ অংশ শেষে: আপনি যখন recursive descent parser লিখেছিলেন, আপনি না জেনেই একটা PDA বাস্তবায়ন করে ফেলেছিলেন — function call stack-ই সেই PDA-র stack।

মূল ধারণা

CFG-এর আনুষ্ঠানিক পুনরালোচনা — derivation ও parse tree

লেসন ১-এ আমরা CFG-র 4-টুপল সংজ্ঞা দিয়েছিলাম — G = (V, Σ, R, S), প্রতিটা rule A → γ (A একটা nonterminal, γ যেকোনো string)। এবার derivation-কে আরো নিখুঁতভাবে সংজ্ঞায়িত করি।

Leftmost derivation: প্রতিটা ধাপে sentential form-এর বামতম nonterminal-টাই replace করা হয়। Rightmost derivation: ডানতম nonterminal replace হয়। (দুইটাই একই string generate করতে পারে, কিন্তু ধাপের ক্রম ভিন্ন — এটা compilers/parsing-এ LL বনাম LR parser-এর পার্থক্যের ভিত্তি।)

Parse tree: derivation-এর একটা গ্রাফিক্যাল রূপ — root S, প্রতিটা internal node একটা nonterminal যার children তার production-এর ডান পাশের symbol, leaf-গুলো terminal (বাম থেকে ডানে পড়লে মূল string)। একটা parse tree অনেক ভিন্ন derivation-এর প্রতিনিধিত্ব করতে পারে (ধাপের ক্রম ভিন্ন হলেও একই গাছ), কিন্তু leftmost derivation আর parse tree-র মধ্যে একটা 1-1 সম্পর্ক আছে — এই কারণেই ambiguity সংজ্ঞায়িত করার সময় parse tree বা leftmost derivation, যেকোনো একটাতেই স্থির হওয়া যায়।

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

একটা grammar G ambiguous যদি L(G)-এর কোনো string w-এর জন্য দুইটা ভিন্ন parse tree (সমতুল্যভাবে, দুইটা ভিন্ন leftmost derivation) থাকে।

compilers/grammars-and-ambiguity-তে আমরা এটা অনানুষ্ঠানিকভাবে দেখেছিলাম id + id * id দিয়ে। এবার সম্পূর্ণ উদাহরণ:

Ambiguous grammar:
  E → E + E | E * E | id

id + id * id string-এর জন্য দুইটা parse tree:

Tree ১ (+ আগে বাঁধা, 'wrong' precedence):    Tree ২ (* আগে বাঁধা, 'right' precedence):

        E                                          E
      / | \                                      / | \
     E  +  E                                    E  *  E
     |    /|\                                  /|\    |
    id   E * E                               E + E   id
         |   |                               |    |
        id  id                               id   id

মান: (id+id)*id হিসেবে group                মান: id+(id*id) হিসেবে group
একই string, দুইটা ভিন্ন parse tree — এটাই ambiguity-র আনুষ্ঠানিক প্রমাণ।

দুইটাই বৈধ derivation একই grammar থেকে — grammar নিজে বলে দেয় না কোনটা “সঠিক”। এটাই ambiguity-র সমস্যা: parser-কে বাইরে থেকে একটা নিয়ম (precedence, associativity) চাপিয়ে দিতে হয়, অথবা grammar-টাই এমনভাবে rewrite করতে হয় যাতে দ্ব্যর্থতা দূর হয়ে যায়।

compilers/grammars-and-ambiguity-র precedence layering ঠিক এই সমাধান:

Disambiguated grammar:
  E → E + T | T
  T → T * F | F
  F → id | ( E )

এখন id + id * id-এর জন্য একটামাত্র parse tree সম্ভব — grammar নিজেই precedence (* আগে বাঁধে) আর associativity (বাম-থেকে) এনকোড করে ফেলেছে rule-এর স্তরায়নে।

ভেতরে কী ঘটছে

Pushdown Automaton — DFA + একটা Stack

আনুষ্ঠানিক সংজ্ঞা — একটা 6-টুপল (কনভেনশনভেদে 7-টুপল, stack bottom marker আলাদা রাখলে):

P=(Q,Σ,Γ,δ,q0,F)P = (Q, \Sigma, \Gamma, \delta, q_0, F)

উপাদানঅর্থ
QState-এর সসীম সেট (DFA-র মতোই)
ΣInput alphabet
ΓStack alphabet (input alphabet থেকে আলাদা হতে পারে)
δQ × (Σ∪{ε}) × Γ → 𝒫(Q × Γ*) — state + ঐচ্ছিক input + stack-top দেখে নতুন state ও stack-এ কী push হবে তা ঠিক করে
q0Start state
FAccepting state-এর সেট

δ-র টাইপটা মনোযোগ দিয়ে দেখুন — এটা DFA-র δ-র চেয়ে অনেক বেশি কিছু এনকোড করে: প্রতিটা transition stack-এর top দেখতে পারে (pop-এর মতো) আর নতুন symbol push করতে পারে, এবং input না পড়েও (ε) transition নিতে পারে। এই কারণেই PDA স্বভাবতই nondeterministic — NFA-র মতোই, “কোনো একটা path accept করলেই” গ্রহণ। (Deterministic PDA-ও সংজ্ঞায়িত করা যায়, কিন্তু — NFA/DFA-র বিপরীতে — deterministic PDA strictly কম শক্তিশালী সাধারণ PDA-র চেয়ে; এই অসামঞ্জস্যটাই compilers/parsing-এ LR(k) parser-এর সীমাবদ্ধতার তাত্ত্বিক শিকড়।)

একটা সম্পূর্ণ PDA — {0ⁿ1ⁿ : n ≥ 0}

states: {q_push, q_pop, q_accept}
stack alphabet: {Z, 0}    (Z = bottom marker)

δ(q_push, 0, X)  = {(q_push, 0X)}     — যেকোনো 0 পড়লে push, state একই
δ(q_push, 1, 0)  = {(q_pop, ε)}       — প্রথম 1 দেখলে pop শুরু, একটা 0 বাদ দিন
δ(q_pop, 1, 0)   = {(q_pop, ε)}       — প্রতিটা 1-এ একটা 0 pop
δ(q_pop, ε, Z)   = {(q_accept, Z)}    — ইনপুট শেষ, stack-এ শুধু Z বাকি থাকলে accept

start: q_push,  initial stack: Z,  F = {q_accept}

"000111" ইনপুটে ট্রেস:

state    বাকি ইনপুট   stack (top বামে)
q_push   000111       Z
q_push   00111        0Z
q_push   0111         00Z
q_push   111           000Z
q_pop    11            00Z      (প্রথম 1 — pop)
q_pop    1              0Z      (দ্বিতীয় 1 — pop)
q_pop    ε               Z      (তৃতীয় 1 — pop, ঠিক সমান!)
q_accept ε               Z      (ε-move, stack-এ শুধু Z, accept)

Push-ধাপে stack-এর গভীরতাই “কতগুলো 0 দেখেছি”-র গণনা — ঠিক সেই memory যেটা DFA-র কখনো ছিল না (গত লেসনের pigeonhole যুক্তি মনে করুন)। Stack unbounded, তাই যেকোনো n-এর জন্য কাজ করে।

CFG ↔ PDA সমতুল্যতা

উপপাদ্য: একটা ভাষা context-free (কোনো CFG দিয়ে generate হয়) যদি এবং কেবল যদি কোনো PDA সেটা accept করে।

CFG → PDA (গঠনমূলক, সহজ দিক): যেকোনো CFG থেকে একটা standard PDA বানানো যায় যা leftmost derivation-কে সরাসরি simulate করে —

১. Stack-এ start symbol S পুশ করে শুরু করুন
২. যতক্ষণ stack-এর top একটা nonterminal A:
     grammar-এর যেকোনো rule A → γ nondeterministically বেছে নিন,
     A pop করুন, γ push করুন (উল্টো ক্রমে, যাতে γ-র প্রথম symbol top-এ থাকে)
৩. যতক্ষণ stack-এর top একটা terminal a:
     ইনপুট থেকে a পড়ুন, মিললে pop করুন, না মিললে reject
৪. Stack খালি ও ইনপুট শেষ হলে accept

এই construction-এ NFA-র nondeterminism ঠিক grammar-এর “কোন rule বাছব” সিদ্ধান্তের প্রতিরূপ — PDA আসলে সব সম্ভাব্য derivation “একসাথে” (conceptually) অনুসরণ করে, যেকোনো একটা successfully ইনপুট মিলিয়ে দিলেই accept।

PDA → CFG (কঠিন দিক): সম্ভব, কিন্তু construction জটিল — প্রতিটা PDA state-transition-stack-symbol triple-এর জন্য একটা nonterminal বানিয়ে stack-এর push/pop-কে grammar rule-এ এনকোড করা হয়। এই লেসনের সীমার বাইরে বিস্তারিত, কিন্তু অস্তিত্ব প্রমাণিত — এটাই যথেষ্ট সমতুল্যতা প্রতিষ্ঠা করতে।

Regular বনাম Context-Free — একই প্যাটার্নে দুই স্তরের সমতুল্যতা
  1. Regular (Type 3)Regex ↔ NFA ↔ DFA — সসীম state, কোনো memory নেই
  2. Context-Free (Type 2)CFG ↔ PDA — সসীম state + ১টা unbounded stack
  3. সমতুল্যতার প্রমাণের প্যাটার্ন একইদুই স্তরেই: generator (grammar/regex) ↔ recognizer (automaton), দুই দিকেই গঠনমূলক রূপান্তর

Recursive descent parser — একটা লুকানো PDA

এখানেই এই লেসনের সবচেয়ে সুন্দর সংযোগ। compilers/parsing-এ আপনি লিখেছিলেন এমন কিছু:

def parse_E():
    parse_T()
    while peek() == '+':
        consume('+')
        parse_T()

def parse_T():
    parse_F()
    while peek() == '*':
        consume('*')
        parse_F()

এখানে কোনো explicit stack variable নেই — অথচ এটা একটা PDA। কীভাবে? প্রতিটা function call push — call stack-এ একটা নতুন frame যোগ হয়, যা বলে “আমরা এখন E বা T বা F পার্স করছি, আর শেষ হলে কোথায় ফিরে যাব”। প্রতিটা function return pop — সেই frame সরে যায়। Function call stack-টাই আক্ষরিক অর্থে PDA-র Γ*।

এই অন্তর্দৃষ্টিটা ব্যাখ্যা করে কেন recursive descent parser স্বাভাবিকভাবে নেস্টেড expression সামলায় কোনো বিশেষ কৌশল ছাড়াই — নেস্টিং-এর “গভীরতা” মনে রাখাটা programming language-এর নিজস্ব call stack ব্যবস্থাপনা থেকেই বিনামূল্যে পাওয়া যায়। এটাই আবার mathematics/induction-এর সেই থিমের প্রতিফলন — recursion আর stack-based computation একই জিনিসের দুই মুখ।

উদাহরণ

CFL Pumping Lemma — সংক্ষেপে, আর একটা প্রয়োগ

গত লেসনের pumping lemma শুধু regular ভাষার জন্য। Context-free ভাষার নিজস্ব, সামান্য জটিল সংস্করণ আছে:

যদি L context-free হয়, তাহলে একটা p আছে এমন যে L-এর যেকোনো string s (|s| ≥ p)-কে ভাগ করা যায় s = uvwxy যেখানে:

  1. |vwx| ≤ p
  2. |vx| ≥ 1 (v, x দুটোই একসাথে খালি না)
  3. প্রতিটা i ≥ 0-এর জন্য uv^iwx^iy ∈ L

পার্থক্যটা লক্ষ্য করুন — এখন দুইটা “pump” করার অংশ (v আর x), প্রতিবার একসাথে সমান সংখ্যকবার পুনরাবৃত্ত হয় (parse tree-তে এরা একই recursive nonterminal-এর দুই সন্তান — CFG-র recursive rule structure থেকে এই আকৃতিটা আসে)।

উদাহরণ — {aⁿbⁿcⁿ : n ≥ 0} context-free না।

এই module-এর প্রথম লেসনে আমরা এই ভাষাটাকে context-sensitive হিসেবে উদাহরণ দিয়েছিলাম, এবার সেটা কেন context-free-ও না তার সংক্ষিপ্ত যুক্তি:

s = aᵖbᵖcᵖ বাছুন। যেকোনো বৈধ বিভাজন uvwxy-তে |vwx| ≤ p — অর্থাৎ vwx একটানা একটা window, দৈর্ঘ্য সর্বোচ্চ p। যেহেতু s-এ তিনটা ব্লক (a-দের, b-দের, c-দের), আর প্রতিটা ব্লকের দৈর্ঘ্য p, একটা ≤p-দৈর্ঘ্যের window সর্বোচ্চ দুইটা সন্নিহিত ব্লকের সীমানা স্পর্শ করতে পারে — তিনটাতে একসাথে পৌঁছাতে পারে না (কারণ প্রথম আর তৃতীয় ব্লকের মধ্যে দূরত্ব অন্তত p — মাঝেরটা পুরোটা)।

অতএব vx (pump হওয়া অংশ) সর্বোচ্চ দুই ধরনের অক্ষর ধারণ করে, তিন ধরনের না। i = 2 (pump up) করলে সেই এক বা দুই ধরনের অক্ষরের সংখ্যা বাড়বে, কিন্তু তৃতীয় ধরনটা (যেটা vwx-এর বাইরে ছিল) অপরিবর্তিত থাকবে — ফলে তিনটা সংখ্যা আর সমান থাকবে না, uv²wx²y ∉ L। Contradiction — L context-free না। ∎ (সম্পূর্ণ কঠোর প্রমাণে সবগুলো সম্ভাব্য window-position আলাদা case হিসেবে দেখাতে হয় — এখানে মূল অন্তর্দৃষ্টিটাই দেখানো হলো।)

s = a a a ... a  b b b ... b  c c c ... c
    |←―――p――――→| |←―――p――――→| |←―――p――――→|
              ↑
     এখানে থেকে একটা ≤p দৈর্ঘ্যের window শুরু হলে
     সর্বোচ্চ a-ব্লকের বাকি অংশ + পুরো b-ব্লকের কিছু অংশ ছুঁতে পারে —
     c-ব্লক পর্যন্ত কখনো পৌঁছাতে পারবে না
একটা ≤p-দৈর্ঘ্যের window কখনো তিনটা ব্লককে একসাথে স্পর্শ করতে পারে না — CFL pumping lemma-র মূল জ্যামিতিক যুক্তি।

এই ফলাফলটাই লেসন ১-এর হায়ারার্কি ছবিটা সম্পূর্ণ করল: 0ⁿ1ⁿ regular না কিন্তু context-free (এই লেসনের PDA); aⁿbⁿcⁿ context-free না কিন্তু context-sensitive (লেসন ১-এর CSG উদাহরণ)। প্রতিটা স্তরে ঠিক একটা নতুন উদাহরণ যা আগের স্তরে পড়ে না — হায়ারার্কি strict, প্রতিটা ⊊ প্রমাণিত।

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

EXPERIMENT

PDA Simulator — 0ⁿ1ⁿ ও balanced parens একসাথে

Python 3· ১৫ মিনিট
class PDA:
    def __init__(self, delta, start, accept_states, initial_stack="Z"):
        self.delta = delta          # (state, input_or_None, stack_top) -> (new_state, push_str)
        self.start = start
        self.accept_states = accept_states
        self.initial_stack = initial_stack

    def run(self, w: str, max_steps=10000) -> bool:
        # (state, remaining input, stack) — depth-first, backtrack করে
        stack0 = self.initial_stack
        agenda = [(self.start, w, stack0)]
        seen = set()
        steps = 0
        while agenda and steps < max_steps:
            steps += 1
            state, rest, stack = agenda.pop()
            key = (state, rest, stack)
            if key in seen:
                continue
            seen.add(key)

            if not rest and state in self.accept_states:
                return True

            top = stack[0] if stack else None
            # ε-move (input ছাড়া)
            if (state, None, top) in self.delta:
                for new_state, push in self.delta[(state, None, top)]:
                    agenda.append((new_state, rest, push + stack[1:]))
            # input-consuming move
            if rest and (state, rest[0], top) in self.delta:
                for new_state, push in self.delta[(state, rest[0], top)]:
                    agenda.append((new_state, rest[1:], push + stack[1:]))
        return False


# ── 0ⁿ1ⁿ PDA ──────────────────────────────────────────────────────
pda_0n1n = PDA(
    delta={
        ("push", "0", "Z"): [("push", "0Z")],
        ("push", "0", "0"): [("push", "00")],
        ("push", "1", "0"): [("pop", "")],
        ("pop", "1", "0"):  [("pop", "")],
        ("pop", None, "Z"): [("accept", "Z")],
    },
    start="push", accept_states={"accept"},
)

for w in ["", "01", "0011", "000111", "011", "0001111", "10"]:
    print(f"0^n1^n  {w!r:10} → {pda_0n1n.run(w)}")

# ── Balanced parentheses PDA ─────────────────────────────────────
pda_parens = PDA(
    delta={
        ("q", "(", "Z"): [("q", "PZ")],
        ("q", "(", "P"): [("q", "PP")],
        ("q", ")", "P"): [("q", "")],
        ("q", None, "Z"): [("accept", "Z")],
    },
    start="q", accept_states={"accept"},
)

for w in ["()", "(())", "((()))", "(()", ")(", ""]:
    print(f"parens  {w!r:10} → {pda_parens.run(w)}")
0^n1^n  ''         → True
0^n1^n  '01'       → True
0^n1^n  '0011'     → True
0^n1^n  '000111'   → True
0^n1^n  '011'      → False
0^n1^n  '0001111'  → False
0^n1^n  '10'       → False
parens  '()'       → True
parens  '(())'     → True
parens  '((()))'   → True
parens  '(()'      → False
parens  ')('       → False
parens  ''         → True

লক্ষ্য করুন — একই PDA ক্লাস, শুধু delta টেবিল পাল্টে দুইটা সম্পূর্ণ ভিন্ন ভাষা চেনা গেল, আর ((())) (depth ৩) নিখুঁত কাজ করছে — এই module-এর একদম প্রথম experiment-এর regex-ব্যর্থতার সরাসরি সমাধান, এবার সঠিক টুল দিয়ে।

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

একটা একক, সাধারণ PDA simulation engine (state + stack-top দেখে transition নেয়) দুইটা ভিন্ন context-free ভাষা চেনে শুধু transition টেবিল পাল্টে — এটাই CFG↔PDA সমতুল্যতার ব্যবহারিক প্রকাশ।

নিজে বানান

BUILD IT

Recursive Descent Parser-এর ভেতরের PDA উন্মোচন করুন

Python · ●●●○○
  1. compilers/parsing-এর arithmetic grammar (E→E+T|T, T→T*F|F, F→id|(E)) দিয়ে একটা recursive descent parser লিখুন
  2. প্রতিটা function call-এ একটা "shadow stack" (সাধারণ Python list) manually push/pop করুন, ঠিক PDA-র মতো
  3. পার্সিং শেষে shadow stack trace print করুন — call sequence-টাই PDA transition sequence
  4. একই grammar-এর জন্য একটা explicit PDA (transition table) লিখুন এবং দুইটার trace তুলনা করুন

লক্ষ্য: recursive descent parser আর PDA-র মধ্যেকার “লুকানো সমতা”-কে literally দৃশ্যমান করা।

shadow_stack = []

def trace_push(label):
    shadow_stack.append(label)
    print(f"{'  ' * (len(shadow_stack)-1)}PUSH {label:12} stack={shadow_stack}")

def trace_pop():
    label = shadow_stack.pop()
    print(f"{'  ' * len(shadow_stack)}POP  {label:12} stack={shadow_stack}")


class Parser:
    def __init__(self, tokens):
        self.tokens, self.pos = tokens, 0

    def peek(self):
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None

    def consume(self, expected=None):
        tok = self.tokens[self.pos]; self.pos += 1
        return tok

    def parse_E(self):
        trace_push("E")
        self.parse_T()
        while self.peek() == '+':
            self.consume()
            self.parse_T()
        trace_pop()

    def parse_T(self):
        trace_push("T")
        self.parse_F()
        while self.peek() == '*':
            self.consume()
            self.parse_F()
        trace_pop()

    def parse_F(self):
        trace_push("F")
        if self.peek() == '(':
            self.consume()
            self.parse_E()
            self.consume()          # ')'
        else:
            self.consume()          # id/number
        trace_pop()


tokens = ['(', 'id', '+', 'id', ')', '*', 'id']
print("পার্স করছি:", ' '.join(tokens))
Parser(tokens).parse_E()

আউটপুটে লক্ষ্য করুন shadow_stack-এর সর্বোচ্চ গভীরতা — এটাই input (id+id)*id-এর নেস্টিং গভীরতার সাথে সরাসরি সম্পর্কিত, ঠিক যেমন explicit PDA-তে stack-এর গভীরতা 0ⁿ-এর n-এর সাথে সম্পর্কিত ছিল।

নিজে বাড়ান:

  1. একই tokens ইনপুট explicit PDA transition table দিয়ে (উপরের experiment-এর প্যাটার্নে) চালিয়ে দেখুন push/pop-এর ক্রম shadow_stack-এর সাথে মেলে কি না
  2. Deep nesting (((((((id))))))) দিয়ে চালিয়ে maximum stack depth মাপুন, তারপর Python-এর sys.setrecursionlimit() কমিয়ে দেখুন কোথায় crash করে — mathematics/induction-এর recursion depth experiment-এর সাথে তুলনা করুন
  3. একটা inherently ambiguous ইনপুট (যেমন খালি grammar-এ E→E+E|E*E|id, disambiguation ছাড়া) দিয়ে parser চালিয়ে দেখুন এটা কোন derivation বাছে (greedy leftmost বাছাই, ambiguity লুকিয়ে থাকে)

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

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

Parser generator — yacc/bison, ANTLR। LR parser (compilers/parsing-এ আলোচিত shift/reduce) আক্ষরিক অর্থে একটা deterministic PDA — shift = push, reduce = একাধিক symbol pop করে একটা nonterminal push। parser generator-এর output টেবিল সরাসরি এই PDA-র δ ফাংশনের encoding।

JSON/XML/YAML parser। প্রতিটা structured data format-এর grammar context-free (নেস্টেড object/array/tag), আর প্রতিটা production-grade parser হয় explicit stack (iterative) নয়তো recursive descent (implicit call-stack) ব্যবহার করে — কখনো regex দিয়ে “পার্স” করার চেষ্টা করে না, লেসন ১-এর misconception অনুযায়ী।

Compiler-এর syntax error recovery। compilers/parsing-এর panic-mode recovery PDA-র ভাষায় বর্ণনা করা যায় — stack থেকে symbol pop করতে করতে এমন একটা point খোঁজা যেখান থেকে আবার parsing resume করা নিরাপদ (synchronizing token)।

Linguistics ও NLP। প্রাকৃতিক ভাষার sentence structure context-free grammar (বা তার সম্প্রসারণ) দিয়ে মডেল করা প্রায় ৬০ বছর ধরে computational linguistics-এর কেন্দ্রে — ঠিক Chomsky-র মূল উদ্দেশ্য যা থেকে এই পুরো হায়ারার্কি জন্মেছিল।

Security — deeply nested input attack। JSON/XML parser-এ অতিরিক্ত গভীর নেস্টিং ([[[[[[[...]]]]]]]) পাঠিয়ে stack overflow ঘটানো একটা পরিচিত DoS কৌশল — সরাসরি এই লেসনের “recursive descent = call stack = PDA stack, কিন্তু বাস্তব stack সসীম” insight থেকে। এই কারণে production parser (যেমন Python-এর json module, বা web framework-এর body parser) maximum depth সীমিত রাখে।

Configuration ও query language design। SQL-এর মতো নেস্টেড subquery ভাষা, বা cs-theory/formal-languages-and-chomsky-hierarchy-র config-format উদাহরণ — এই সবগুলোর parser design প্রশ্ন একই: CFG লিখুন, তারপর হয় parser generator (LR/LALR) নয়তো recursive descent বেছে নিন — regex দিয়ে না।

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

“একটা PDA-র stack থাকলে সেটা যেকোনো matching/counting সমস্যা সামলাতে পারবে — যেমন 0ⁿ1ⁿ2ⁿ।”

এই লেসনের CFL pumping lemma অংশ ঠিক এর বিপরীত প্রমাণ করেছে — {aⁿbⁿcⁿ} context-free না, তাই কোনো PDA-ই এটা চিনতে পারে না, স্ট্যাক যতই বড় হোক।

মূল কারণ: PDA-র stack একটামাত্র LIFO কাঠামো — সবচেয়ে সম্প্রতি push হওয়া জিনিসটাই প্রথমে pop হয়। 0ⁿ1ⁿ চিনতে এটা যথেষ্ট (push 0-এ, pop 1-এ — একটামাত্র matching)। কিন্তু 0ⁿ1ⁿ2ⁿ-এ দুইটা স্বাধীন matching লাগে (0 বনাম 1, এবং 1 বনাম 2) — একটা single stack একসাথে দুইটা independent গণনা রাখতে পারে না এমনভাবে যাতে উভয়ই যাচাই করা যায়। এই কাজে দরকার linear-bounded automaton-এর tape (লেসন ১), যেখানে সামনে-পেছনে চলে একাধিকবার scan করা যায়।

নিয়ম: “matching দরকার” দেখলেই PDA যথেষ্ট ধরে নেবেন না — matching-এর সংখ্যা আর independence পরীক্ষা করুন।

“Ambiguous grammar সবসময় rewrite করে unambiguous বানানো যায় — শুধু যথেষ্ট চেষ্টা লাগে।”

এই লেসনের “Inherent ambiguity” callout ঠিক এই বিশ্বাস ভাঙে। \{a^ib^jc^k : i=j \text{ অথবা } j=k\}-এর মতো ভাষার জন্য কোনো grammar-ই — যত রূপান্তরই করা হোক — অবশ্যই ambiguous থাকবে, কারণ ambiguity এখানে ভাষার নিজের একটা মৌলিক ধর্ম, নির্দিষ্ট grammar-এর লেখার ধরনের সমস্যা না।

ব্যবহারিক জগতে সৌভাগ্যবশত programming language design-এ এই সমস্যা বিরল — ভাষা designer-রা সচেতনভাবে grammar এমনভাবে ডিজাইন করেন যাতে inherent ambiguity এড়ানো যায় (dangling-else-এর মতো সমস্যা আছে, কিন্তু সেগুলো disambiguation rule দিয়ে সমাধানযোগ্য, কারণ সেই grammar inherently ambiguous না, শুধু naive ভাবে লেখা ambiguous)। তবে theoretical/formal language নিয়ে কাজ করার সময় এই সীমাটা মনে রাখা জরুরি — “আরেকটু চেষ্টা করলেই disambiguate হবে” সবসময় সত্যি না।

“Recursive descent parser-এ যেহেতু কোনো 'stack' ভ্যারিয়েবল visible না, তাই এটা PDA-র মতো কাজ করে না — এটা সম্পূর্ণ ভিন্ন একটা কৌশল।”

এই লেসনের কেন্দ্রীয় insight ঠিক এই ভুল ধারণা দূর করার জন্য। Stack অদৃশ্য কারণ এটা programming language-এর runtime নিজেই পরিচালনা করে (function call stack) — কিন্তু গাণিতিকভাবে এটা হুবহু একটা PDA-র stack-এর ভূমিকা পালন করে। প্রতিটা function call একটা push (grammar-এর একটা nonterminal-কে তার production দিয়ে replace করার সমতুল্য), প্রতিটা return একটা pop।

এই সংযোগ শুধু তাত্ত্বিক কৌতূহল না — এর ব্যবহারিক পরিণতি আছে: recursive descent parser-এর maximum nesting depth সীমিত (call stack-এর আকার দিয়ে), ঠিক যেমন একটা explicit-stack PDA implementation-এর maximum depth memory দিয়ে সীমিত হতো। “Stack visible না” মানে “স্ট্যাক নেই” না — এটাই এই লেসনের build section-এর shadow-stack experiment যা সরাসরি দেখায়।

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

1

DFA আর PDA-র formal সংজ্ঞার মধ্যে সবচেয়ে গুরুত্বপূর্ণ পার্থক্য কী (δ-র টাইপ তুলনা করে বলুন), আর এই পার্থক্যটাই কীভাবে 0ⁿ1ⁿ চেনা সম্ভব করে?

স্মরণ

DFA: δ: Q × Σ → Q। PDA: δ: Q × (Σ∪{ε}) × Γ → 𝒫(Q × Γ*)।

তিনটা পার্থক্য: (১) PDA-র transition stack-এর top দেখতে পারে (Γ-এর একটা সদস্য input-এর তৃতীয় parameter হিসেবে), (২) input ছাড়াই (ε) transition নেওয়া যায়, (৩) transition নতুন state-এর সাথে stack-এ push করার একটা string-ও ফেরত দেয় (Γ*)।

0ⁿ1ⁿ-এ এই stack-ই key — প্রতিটা 0-এ push করে “কতগুলো 0 এখনো 1 দিয়ে মেলানো বাকি” ট্র্যাক করা হয়, যেটা DFA-র সসীম state দিয়ে (pigeonhole নীতির কারণে) কখনো সম্ভব না, কারণ n অসীম মান নিতে পারে কিন্তু state সংখ্যা সসীম।

2

এই লেসনের PDA-র transition টেবিলে (0ⁿ1ⁿ-এর জন্য) input "0011" এর উপর step-by-step ট্রেস করে দেখান এটা accept হয় কি না।

প্রয়োগ
state    বাকি ইনপুট   stack (top বামে)
push     0011         Z
push     011          0Z
push     11           00Z
pop      1            0Z       (প্রথম 1 — pop)
pop      ε             Z       (দ্বিতীয় 1 — pop)
accept   ε             Z       (ε-move: stack top = Z, input শেষ)

accept state-এ পৌঁছালাম এবং ইনপুট সম্পূর্ণ পড়া হয়েছে — accept।

মিলিয়ে দেখুন: দুইটা 0 push হয়েছিল, দুইটা 1-এ ঠিক দুইবার pop হলো — সংখ্যা মিলে গেছে, যা 0ⁿ1ⁿ-এর n=2 কেস।

3

একজন সহকর্মী বলছেন: “যেহেতু recursive descent parser-এর কোনো explicit stack variable নেই, এটা PDA-র সংজ্ঞা মেনে চলে না — এটা বরং একটা fundamentally আলাদা, possibly more powerful কৌশল।” এই দাবির কোথায় ভুল?

যুক্তি

দুইটা ভুল আছে।

প্রথমত, “explicit variable না থাকা” ≠ “stack না থাকা”। এই লেসনের hood section-এ দেখানো হয়েছে function call stack-ই আক্ষরিক অর্থে PDA-র Γ* — প্রতিটা function call push, প্রতিটা return pop। এটা programming language runtime দ্বারা পরিচালিত, কিন্তু গাণিতিকভাবে অস্তিত্বশীল আর প্রয়োজনীয়। এই লেসনের build exercise-এ shadow_stack manually track করে ঠিক এটাই দৃশ্যমান করা হয়েছে।

দ্বিতীয়ত, “more powerful” দাবিটাও ভুল — উল্টো দিকে। CFG ↔ PDA সমতুল্যতা উপপাদ্য অনুযায়ী context-free ভাষার জন্য PDA-ই সর্বোচ্চ ক্ষমতা — recursive descent parser এই একই শ্রেণির ভাষার (এবং সাধারণত তার একটা উপশ্রেণির, যেহেতু deterministic recursive descent সব CFG পার্স করতে পারে না, শুধু LL(k)-জাতীয় grammar) বাইরে যেতে পারে না, যতই clever implementation হোক। “More powerful” হতে হলে সেটা context-sensitive বা তার বেশি ভাষা পার্স করতে সক্ষম হতে হবে, যা recursive descent parser (একটা single call stack-ভিত্তিক মেকানিজম) নীতিগতভাবে পারে না।

সংক্ষেপে: recursive descent PDA-র সমান শক্তিশালী (বা কম), বেশি না — শুধু তার stack লুকানো।

4

CFL pumping lemma ব্যবহার করে সংক্ষেপে যুক্তি দিন কেন L = \{a^nb^nc^n : n \ge 0\} context-free না (এই লেসনের প্রমাণের কাঠামো অনুসরণ করে, নিজের ভাষায় লিখুন)। তারপর বলুন — এই একই যুক্তি \{a^nb^n : n \ge 0\} context-free না প্রমাণ করতে প্রয়োগ করলে কী ভুল হবে (কেন সেই প্রয়োগ ব্যর্থ হবে, যেহেতু আমরা জানি a^nb^n আসলে context-free)?

ডিজাইন

aⁿbⁿcⁿ-এর জন্য: s = aᵖbᵖcᵖ বাছুন। যেকোনো বৈধ বিভাজনে |vwx| ≤ p, তাই vwx একটা window যা সর্বোচ্চ দুইটা সন্নিহিত ব্লক স্পর্শ করতে পারে (কারণ প্রতিটা ব্লকের দৈর্ঘ্য p, আর তিনটা ব্লক থাকায় দূরতম দুই ব্লকের মধ্যে দূরত্ব p-এর বেশি)। তাই vx (pump হওয়া অংশ) সর্বোচ্চ দুই ধরনের অক্ষর ধারণ করে। i=2 pump করলে সেই এক/দুই ধরনের অক্ষরের সংখ্যা বাড়বে, তৃতীয়টা অপরিবর্তিত থাকবে — তিন সংখ্যা আর সমান থাকে না। Contradiction।

এই যুক্তি aⁿbⁿ-তে প্রয়োগ করলে কেন ব্যর্থ হয়: এখানে মাত্র দুইটা ব্লক (a-দের, b-দের)। s = aᵖbᵖ বাছলে, vwx (দৈর্ঘ্য ≤p) একইভাবে সর্বোচ্চ দুইটা ব্লক স্পর্শ করতে পারে — কিন্তু এখানে মোটই দুইটা ব্লক আছে, তাই এই “সর্বোচ্চ দুইটা” সীমাবদ্ধতা কোনো বাস্তব restriction তৈরি করে না। vwx পুরোপুরি a-ব্লকে, পুরোপুরি b-ব্লকে, অথবা সীমানা জুড়ে থাকতে পারে — প্রতিটা ক্ষেত্রে v আর x-কে এমনভাবে বাছা সম্ভব যে pump করার পরেও a-সংখ্যা b-সংখ্যার সমান থাকে (যেমন vwx সম্পূর্ণ সীমানায় থাকলে, v কিছু a, x সমান সংখ্যক b ধারণ করবে যদি বিভাজন symmetric হয়) — আসলে গ্রামার-এর recursive rule (S → aSb) ঠিক এই symmetric pump-কে বৈধ রাখে, তাই কোনো contradiction পাওয়া যায় না। এই কারণেই aⁿbⁿ CFL pumping lemma পাস করে (যেমনটা হওয়ার কথা, যেহেতু এটা সত্যিই context-free) — pumping lemma ব্যর্থ হয় না, তাই কোনো non-regularity/non-context-freeness দাবি করা যায় না, যা এই ভাষার প্রকৃত (context-free) স্ট্যাটাসের সাথে সামঞ্জস্যপূর্ণ।

শিক্ষা: pumping lemma প্রয়োগে ব্লক সংখ্যা আর window-এর “সর্বোচ্চ কয়টা ব্লক স্পর্শ করতে পারে”-র হিসাবটাই নির্ধারণ করে যুক্তিটা আদৌ contradiction দেয় কি না — গত লেসনের misconception অংশের প্রতিধ্বনি: pumping lemma না-ভাঙা মানেই ভাষাটা সেই শ্রেণিতে পড়ে, এমন প্রমাণ না, কিন্তু এখানে আমরা ইতিবাচকভাবে জানি aⁿbⁿ context-free (grammar আছে), তাই ব্যর্থ হওয়া প্রত্যাশিত ও সামঞ্জস্যপূর্ণ।

এরপর কী

চারটা লেসনে আমরা Chomsky hierarchy-র প্রথম দুই স্তর সম্পূর্ণ আনুষ্ঠানিকভাবে কভার করলাম — regular (grammar ↔ DFA/NFA ↔ regex) আর context-free (grammar ↔ PDA), দুই স্তরেই সমতুল্যতা প্রমাণ, pumping lemma দিয়ে সীমা, আর বাস্তব compiler/tool-এর সাথে সরাসরি সংযোগ।

মডিউলের বাকি পথ এই সিঁড়ি বেয়ে আরো উপরে ওঠে: Turing machine (Type 0, অসীম tape, সবচেয়ে শক্তিশালী মডেল), তারপর Church-Turing thesis (কেন Turing machine-ই “computation”-এর সীমা বলে বিশ্বাস করা হয়), তারপর decidability ও halting problem — যেখানে আমরা প্রথমবার এমন সমস্যা দেখব যা কোনো algorithm দিয়েই সমাধানযোগ্য না, RE হওয়া সত্ত্বেও। এই module-এর “Turing Machine Simulator” প্রজেক্ট ঠিক সেই পরবর্তী স্তরের হাতে-কলমে কাজ।

এই চারটা লেসন থেকে সরাসরি বেরিয়ে আসা “Regex Engine” প্রজেক্ট আপনাকে regex → Thompson’s NFA → subset construction DFA → PDA-ভিত্তিক group-capture (ঐচ্ছিক সম্প্রসারণ) — পুরো chain-টা শুরু থেকে শেষ পর্যন্ত নিজে বানাতে বলবে, backtracking ছাড়াই।

আরও পড়ুন

  • Introduction to the Theory of Computation (3rd ed.), Chapter 2 — Michael Sipser · CFG, PDA, ও তাদের সমতুল্যতার ক্যাননিক্যাল treatment; CFL pumping lemma-র সম্পূর্ণ প্রমাণ
  • Introduction to Automata Theory, Languages, and Computation (3rd ed.), Chapter 6 — Hopcroft, Motwani, Ullman · CFG→PDA ও PDA→CFG দুই দিকের গঠনমূলক প্রমাণ
  • Compilers: Principles, Techniques, and Tools (2nd ed.), Section 4.2 — Aho, Sethi, Ullman, Lam · Ambiguity ও grammar transformation-এর ব্যবহারিক পরিণতি — parser design-এর দৃষ্টিতে