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, লুকিয়ে থাকা অবস্থায়।
আগে এটা বুঝি
এই 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
Gambiguous যদিL(G)-এর কোনো stringw-এর জন্য দুইটা ভিন্ন parse tree (সমতুল্যভাবে, দুইটা ভিন্ন leftmost derivation) থাকে।
compilers/grammars-and-ambiguity-তে আমরা এটা অনানুষ্ঠানিকভাবে
দেখেছিলাম id + id * id দিয়ে। এবার সম্পূর্ণ উদাহরণ:
Ambiguous grammar:
E → E + E | E * E | idid + 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দুইটাই বৈধ 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 আলাদা রাখলে):
| উপাদান | অর্থ |
|---|---|
Q | State-এর সসীম সেট (DFA-র মতোই) |
Σ | Input alphabet |
Γ | Stack alphabet (input alphabet থেকে আলাদা হতে পারে) |
δ | Q × (Σ∪{ε}) × Γ → 𝒫(Q × Γ*) — state + ঐচ্ছিক input + stack-top দেখে নতুন state ও stack-এ কী push হবে তা ঠিক করে |
q0 | Start state |
F | Accepting 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 (Type 3)Regex ↔ NFA ↔ DFA — সসীম state, কোনো memory নেই
- Context-Free (Type 2)CFG ↔ PDA — সসীম state + ১টা unbounded stack
- সমতুল্যতার প্রমাণের প্যাটার্ন একইদুই স্তরেই: 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 ভাষার নিজস্ব, সামান্য জটিল সংস্করণ আছে:
যদি
Lcontext-free হয়, তাহলে একটাpআছে এমন যেL-এর যেকোনো strings(|s| ≥ p)-কে ভাগ করা যায়s = uvwxyযেখানে:
|vwx| ≤ p|vx| ≥ 1(v,xদুটোই একসাথে খালি না)- প্রতিটা
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-ব্লক পর্যন্ত কখনো পৌঁছাতে পারবে নাএই ফলাফলটাই লেসন ১-এর হায়ারার্কি ছবিটা সম্পূর্ণ করল: 0ⁿ1ⁿ
regular না কিন্তু context-free (এই লেসনের PDA); aⁿbⁿcⁿ
context-free না কিন্তু context-sensitive (লেসন ১-এর CSG উদাহরণ)।
প্রতিটা স্তরে ঠিক একটা নতুন উদাহরণ যা আগের স্তরে পড়ে না — হায়ারার্কি
strict, প্রতিটা ⊊ প্রমাণিত।
নিজে চালিয়ে দেখুন
PDA Simulator — 0ⁿ1ⁿ ও balanced parens একসাথে
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 সমতুল্যতার ব্যবহারিক প্রকাশ।
নিজে বানান
Recursive Descent Parser-এর ভেতরের PDA উন্মোচন করুন
- compilers/parsing-এর arithmetic grammar (E→E+T|T, T→T*F|F, F→id|(E)) দিয়ে একটা recursive descent parser লিখুন
- প্রতিটা function call-এ একটা "shadow stack" (সাধারণ Python list) manually push/pop করুন, ঠিক PDA-র মতো
- পার্সিং শেষে shadow stack trace print করুন — call sequence-টাই PDA transition sequence
- একই 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-এর সাথে
সম্পর্কিত ছিল।
নিজে বাড়ান:
- একই
tokensইনপুট explicit PDA transition table দিয়ে (উপরের experiment-এর প্যাটার্নে) চালিয়ে দেখুন push/pop-এর ক্রমshadow_stack-এর সাথে মেলে কি না - Deep nesting (
((((((id))))))) দিয়ে চালিয়ে maximum stack depth মাপুন, তারপর Python-এরsys.setrecursionlimit()কমিয়ে দেখুন কোথায় crash করে —mathematics/induction-এর recursion depth experiment-এর সাথে তুলনা করুন - একটা 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 যা সরাসরি দেখায়।
বুঝেছেন কি না দেখুন
1DFA আর PDA-র formal সংজ্ঞার মধ্যে সবচেয়ে গুরুত্বপূর্ণ পার্থক্য কী
(δ-র টাইপ তুলনা করে বলুন), আর এই পার্থক্যটাই কীভাবে 0ⁿ1ⁿ
চেনা সম্ভব করে?
স্মরণ
δ-র টাইপ তুলনা করে বলুন), আর এই পার্থক্যটাই কীভাবে 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 হয় কি না।
প্রয়োগ
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 কৌশল।”
এই দাবির কোথায় ভুল?
যুক্তি
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 লুকানো।
4CFL 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)?
ডিজাইন
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-এর দৃষ্টিতে