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

Regular Expressions ও Pumping Lemma — ক্ষমতা আর তার সীমা

Regular Expressions and the Pumping Lemma

Regex-এর আনুষ্ঠানিক সংজ্ঞা, Thompson's construction দিয়ে regex→NFA, backtracking engine কেন ও কখন exponential সময় নেয় (ReDoS), আর pumping lemma দিয়ে প্রমাণ কোন ভাষা regex দিয়ে কখনোই ধরা সম্ভব না।

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

  • Regular expression-এর আনুষ্ঠানিক, inductive সংজ্ঞা লিখতে এবং তার সাথে DFA/NFA-র সমতুল্যতা (Kleene's theorem) বিবৃত করতে পারবেন
  • Thompson's construction দিয়ে একটা ছোট regex থেকে হাতে NFA বানাতে পারবেন
  • Backtracking regex engine কীভাবে automaton simulation থেকে ভিন্নভাবে কাজ করে তা ব্যাখ্যা করতে পারবেন এবং catastrophic backtracking চিনতে পারবেন
  • Pumping lemma-র আনুষ্ঠানিক বিবৃতি লিখতে এবং একটা adversarial proof হিসেবে প্রয়োগ করতে পারবেন
  • `{0ⁿ1ⁿ}` regular না — এই দাবির একটা সম্পূর্ণ, কঠোর প্রমাণ pumping lemma দিয়ে লিখতে পারবেন

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

আগে এটা বুঝি

২০১৯ সালের ২ জুলাই, Cloudflare-এর Web Application Firewall-এ একটা নতুন নিয়ম চালু হলো — একটা নিরীহ দেখতে regular expression, যেটা নির্দিষ্ট ধরনের ক্ষতিকর ইনপুট আটকানোর জন্য লেখা। কয়েক মিনিটের মধ্যে পৃথিবীজুড়ে Cloudflare-এর সব সার্ভারের CPU একসাথে ১০০%-এ পৌঁছে গেল। HTTP request প্রসেস হওয়া বন্ধ হয়ে গেল — লক্ষ লক্ষ ওয়েবসাইট প্রায় আধা ঘণ্টা ধরে অগম্য হয়ে রইল।

কারণ? একটা single regex, একটামাত্র বিশেষ ধরনের ইনপুট string পেয়ে, matching শেষ করতে কার্যত অসীম সময় নিচ্ছিল।

এটা bug ছিল না — কোনো typo, কোনো off-by-one error না। Regex-টা “সঠিক”ভাবেই লেখা, সঠিক ভাষা ধরে। সমস্যাটা গভীরে — regex engine কীভাবে matching করে তার একটা মৌলিক বৈশিষ্ট্যে, যেটা আজকের লেসনের কেন্দ্রীয় বিষয়।

গত লেসনে আমরা দেখেছি DFA-তে প্রতি character-এ O(1) কাজ — পুরো string-এ O(n), সবসময়, কোনো ব্যতিক্রম ছাড়া। কিন্তু আপনার প্রোগ্রামিং ভাষার regex engine (Python re, JavaScript, PCRE) DFA simulate করে না — এটা সম্পূর্ণ ভিন্ন একটা কৌশল ব্যবহার করে, যার worst-case সময় exponential হতে পারে। এই ফারাকটাই Cloudflare-এর outage-এর মূল কারণ, আর এটাই এই লেসনের দ্বিতীয় অর্ধেকের বিষয়।

প্রথম অর্ধেকে আমরা দেখব regex-এর আনুষ্ঠানিক সংজ্ঞা আর তার NFA-র সাথে সমতুল্যতা — গত লেসনের subset construction-এর ঠিক আগের ধাপ। তারপর দেখব ঠিক কোন ভাষাগুলো regex দিয়ে কখনোই লেখা সম্ভব না — আর কীভাবে সেটা প্রমাণ করব, অনুমান না করে।

মূল ধারণা

Regular expression — inductive সংজ্ঞা

Regex লেখার সময় আমরা intuitive ভাবে ব্যবহার করি — |, *, group। আনুষ্ঠানিক সংজ্ঞা এই একই জিনিস, কিন্তু mathematics/induction-এর কাঠামোয়: base case + inductive case।

Base case — Σ-এর উপর তিনটা প্রাথমিক regex:

∅        — কোনো string না (খালি ভাষা)
ε        — শুধু খালি string
a ∈ Σ    — শুধু single-character string "a"

Inductive case — R₁, R₂ যদি regex হয়, তাহলে এগুলোও regex:

গঠননামভাষা
(R₁|R₂)Union (alternation)L(R₁) ∪ L(R₂)
(R₁R₂)Concatenation{xy : x ∈ L(R₁), y ∈ L(R₂)}
(R₁*)Kleene star{x₁x₂⋯xₖ : k ≥ 0, প্রতিটা xᵢ ∈ L(R₁)}

আর কিছুই regex না — এটাই inductive সংজ্ঞার নিয়ম (কোনো অন্য গঠন অনুমোদিত না, exhaustive)।

L(R) — regex R-এর ভাষা — এই টেবিলের সংজ্ঞা অনুযায়ী recursively বের করা হয়। উদাহরণ: (a|b)*abb -এর ভাষা হলো সব string যা a বা b-র যেকোনো ক্রম দিয়ে শুরু হয়ে ঠিক abb দিয়ে শেষ হয়।

Kleene’s theorem — regex ↔ automaton

উপপাদ্য (Kleene, 1956): একটা ভাষা L কোনো regular expression দিয়ে বর্ণনাযোগ্য যদি এবং কেবল যদি L কোনো DFA (সমতুল্যভাবে, NFA) দিয়ে চেনা যায়।

এটা একটা “যদি এবং কেবল যদি” (iff) দাবি — দুই দিকই প্রমাণ করতে হবে:

  • regex → automaton: যেকোনো regex থেকে একটা সমতুল্য NFA বানানো যায় (Thompson’s construction, এক্ষুনি দেখব)
  • automaton → regex: যেকোনো DFA থেকে একটা সমতুল্য regex বানানো যায় (state elimination method — এই লেসনে বিস্তারিত না, কিন্তু algorithm-টা বিদ্যমান ও যান্ত্রিক)

দুই দিক মিলিয়ে: regex-definable ভাষা = regular ভাষা, হুবহু একই শ্রেণি যা গত লেসনে DFA/NFA দিয়ে সংজ্ঞায়িত হয়েছিল। এটাই Chomsky hierarchy-র Type 3-এর তৃতীয় সমতুল্য বর্ণনা — grammar (লেসন ১), automaton (লেসন ২), আর এখন regex।

ভেতরে কী ঘটছে

Thompson’s construction — regex থেকে NFA, structural induction দিয়ে

মূল কৌশল: regex-এর সংজ্ঞার প্রতিটা case-এর জন্য একটা fixed NFA fragment টেমপ্লেট, ঠিক একটা start state আর একটা accept state-সহ। জটিল regex-এর fragment ছোট fragment জোড়া দিয়ে বানানো হয় — mathematics/induction-এর structural induction ঠিক এই প্যাটার্নে কোড হয়ে ওঠে।

Base: ε              Base: a                Base: ∅
  ┌──ε──┐              ┌──a──┐                (কোনো accept-যোগ্য
 →○      ●→            →○     ●→               path নেই — একটা
  └─────┘              └─────┘                 বিচ্ছিন্ন state)

Union (R1|R2):                Concat (R1R2):
      ┌─ε→[ R1 ]─ε─┐            →[ R1 ]──ε──[ R2 ]→
   →○─┤             ├●→          (R1-র accept = R2-র start,
      └─ε→[ R2 ]─ε─┘             ε দিয়ে জোড়া)

Star (R1*):
        ┌────────ε────────┐
        ↓                 │
   →○───ε──→[ R1 ]──ε──→──┴──●→
    │                          ↑
    └─────────ε────────────────┘
Thompson's construction-এর ৫টা টেমপ্লেট — প্রতিটা regex গঠনের জন্য একটা fixed NFA fragment।

প্রতিটা fragment-এর ভেতরের state-গুলো fragment-এর বাইরে থেকে অদৃশ্য — শুধু একটা start আর একটা accept বাইরে জোড়া লাগানোর জন্য উন্মুক্ত। এটাই compositionality — বড় regex-এর NFA বানাতে পুরো global structure নতুন করে ভাবতে হয় না, শুধু sub-regex-এর fragment জোড়া লাগালেই হয়।

সম্পূর্ণ worked example — a(b|c)*

compilers/lexical-analysis-এ আমরা এই ধরনের NFA “থেকে এসেছে” বলে ধরে নিয়েছিলাম — এবার হাতে বানাই।

ধাপ ১ — a: টেমপ্লেট থেকে সরাসরি: s0 --a--> s1।

ধাপ ২ — b, c: s2 --b--> s3 এবং s4 --c--> s5।

ধাপ ৩ — (b|c): Union টেমপ্লেট প্রয়োগ, নতুন u_start, u_accept:

u_start --ε--> s2 --b--> s3 --ε--> u_accept
u_start --ε--> s4 --c--> s5 --ε--> u_accept

ধাপ ৪ — (b|c)*: Star টেমপ্লেট প্রয়োগ, নতুন st_start, st_accept:

st_start --ε--> u_start   (fragment-এ ঢোকা)
u_accept --ε--> st_start  (আবার লুপ)
st_start --ε--> st_accept (শূন্যবার — সরাসরি স্কিপ)
u_accept --ε--> st_accept (এক বা একাধিকবার পরে বেরোনো)

ধাপ ৫ — a(b|c)*: Concat টেমপ্লেট প্রয়োগ — s1 (আগের a-র accept) থেকে st_start-এ ε দিয়ে জোড়া:

s0 --a--> s1 --ε--> st_start --ε--> u_start --b--> s3 --ε--> u_accept
                        ↑                                        │
                        └───────────────ε───────────────────────┘
                     (loop) ... এবং u_start --ε--> s4 --c--> s5 --ε--> u_accept
st_start --ε--> st_accept (final)

এই পুরো NFA-টা এখন গত লেসনের subset construction-এ সরাসরি feed করা যায় — একটা DFA বেরিয়ে আসবে যেটা a(b|c)* চেনে O(1)/character-এ। এটাই সম্পূর্ণ pipeline: regex (মানুষ লেখে) → NFA (Thompson’s, যান্ত্রিক) → DFA (subset construction, যান্ত্রিক) → minimized DFA (partition refinement, যান্ত্রিক)। প্রতিটা তীর একটা প্রমাণিত, deterministic algorithm — মানুষের হাতে শুধু প্রথম ধাপ।

Backtracking regex engine — সম্পূর্ণ ভিন্ন একটা কৌশল

এখন প্রশ্ন — Python-এর re, PCRE, JavaScript regex কি এই pipeline অনুসরণ করে? না। এরা ব্যবহার করে backtracking — একটা সম্পূর্ণ ভিন্ন algorithm পরিবার, যেটা regex-কে recursive search হিসেবে ট্রিট করে।

Backtracking matcher regex-কে বাম থেকে ডানে চেষ্টা করে; |-এ প্রথম বিকল্প নেয়, *-এ যতদূর সম্ভব greedily নেয়। যদি পরে ব্যর্থ হয়, backtrack করে — আগের সিদ্ধান্ত পাল্টে অন্য বিকল্প চেষ্টা করে। এটাই *-এর প্রতিটা সম্ভাব্য বিভাজন exhaustively চেষ্টা করতে পারে যদি প্রথম কয়েকটা চেষ্টা ব্যর্থ হয়।

কেন এই ডিজাইন? কারণ backtracking engine সহজেই এমন feature সমর্থন করে যা automaton-ভিত্তিক পদ্ধতিতে অসম্ভব — backreference (\1), lookahead/lookbehind। এই feature-গুলো regular ভাষার সংজ্ঞার বাইরে (লেসন ১-এর misconception অংশ মনে করুন) — তাই কোনো DFA/NFA এগুলো represent করতেই পারে না। Backtracking-ই একমাত্র স্বাভাবিক উপায় এগুলো implement করার।

মূল্য: সেই নমনীয়তার বিনিময়ে backtracking engine-এর worst-case সময় exponential, DFA simulation-এর গ্যারান্টিড O(n)-এর বিপরীতে।

Catastrophic backtracking — কীভাবে exponential হয়

Classic উদাহরণ: pattern (a+)+b — string "aaaaaaaaaaaaaaaaaaaa" (কোনো ট্রেইলিং b নেই, তাই ব্যর্থ হতেই হবে)-এর বিরুদ্ধে চালান।

কেন এত ধীর? (a+)+ -এর ভেতরের a+ আর বাইরের + — দুইটাই “এক বা একাধিকবার” বোঝায়, তাই n টা a-কে groups-এ ভাগ করার সম্ভাব্য উপায় সূচকীয়ভাবে বেশি (2^(n-1)-এর কাছাকাছি — এটা composition-এর সংখ্যা, mathematics-এর combinatorics লেসনের “compositions of n” গণনার ঠিক প্রয়োগ)। শেষে b না পেয়ে matcher ব্যর্থ হয়, কিন্তু ব্যর্থ হওয়ার আগে প্রতিটা সম্ভাব্য বিভাজন চেষ্টা করে ফেলে।

স্ট্রিংসম্ভাব্য গ্রুপিং ((a+)+ -এর জন্য)সংখ্যা
"a"(a)1
"aa"(aa), (a)(a)2
"aaa"(aaa), (aa)(a), (a)(aa), (a)(a)(a)4
"aaaa"…8
"a"×n…2^(n-1)
'(a+)+' -এ 'aaa' ভাগ করার সম্ভাব্য উপায়গুলো — n বাড়ার সাথে সাথে সূচকীয় বৃদ্ধি।

এটাই ReDoS (Regular Expression Denial of Service) — যদি কোনো attacker আপনার input validation regex-এ এমন একটা pattern খুঁজে পায় যা catastrophic backtracking-এর শিকার, তাহলে মাত্র কয়েকশ character-এর একটা string পাঠিয়েই আপনার সার্ভারের একটা CPU core সেকেন্ডের বদলে ঘণ্টার পর ঘণ্টা ব্যস্ত রাখতে পারে — Cloudflare-এর গল্পটা ঠিক এভাবেই ঘটেছিল।

উদাহরণ

দুই জগতের একটা পাশাপাশি তুলনা

Automaton simulation বনাম backtracking — একই ভাষা, দুই বাস্তবায়ন
  1. Regex লেখাদুই পদ্ধতিতেই মানুষ একই সিনট্যাক্স লেখে — এখানে কোনো পার্থক্য দেখা যায় না
  2. Automaton pipelineThompson (regex→NFA) → subset construction (NFA→DFA) → O(n) matching, সবসময়
  3. Backtracking pipelineregex → recursive matcher, সরাসরি চালানো, ব্যাকট্র্যাক-সহ — O(n) সাধারণত, কিন্তু O(2ⁿ) worst-case
  4. Feature সমর্থনAutomaton: শুধু regular constructs। Backtracking: backreference, lookaround-ও সমর্থন করে

(a|aa)+b-তে একটা হাতে-চলা trace

আরেকটা classic evil pattern, ছোট string-এ হাতে দেখি কেন backtracking কাজ বাড়ে। Pattern (a|aa)+ (string শেষে b আশা করে), input "aaa" (কোনো b নেই)।

চেষ্টা ১: (a)(a)(a)      — গ্রুপ প্রতিটা একটা 'a', ৩ গ্রুপ শেষে b খোঁজে — নেই, ব্যর্থ
চেষ্টা ২: (a)(aa)        — b খোঁজে — নেই, ব্যর্থ
চেষ্টা ৩: (aa)(a)        — b খোঁজে — নেই, ব্যর্থ
চেষ্টা ৪: (aaa)          — b খোঁজে — নেই, ব্যর্থ
... সব বিকল্প শেষ, সম্পূর্ণ ব্যর্থ

মাত্র ৪টা চেষ্টা "aaa"-তে — কিন্তু এটা 2^(n-1)-এর প্যাটার্ন (n=3 হলে 2²=4, মিলে যাচ্ছে)। n=30-এ এটা হয়ে যায় প্রায় ৫৩ কোটি চেষ্টা — একটা আধুনিক CPU-তেও কয়েক সেকেন্ড থেকে মিনিট। n=50-এ এটা কয়েক ঘণ্টা। automaton-based matcher একই কাজ n-এর সমানুপাতিক সময়ে করত, প্রতিটা n-এ।

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

EXPERIMENT

Catastrophic backtracking নিজে মাপুন — exponential বনাম linear

Python 3· ১৫ মিনিট
import re, time

evil = re.compile(r'^(a+)+b$')

def backtracking_time(n):
    s = "a" * n              # কোনো ট্রেইলিং b নেই — অবশ্যই ব্যর্থ হবে
    t0 = time.perf_counter()
    evil.match(s)
    return time.perf_counter() - t0


def linear_matcher(s: str) -> bool:
    """একই ভাষা (a+b) — কিন্তু automaton-এর মতো একবার স্ক্যান, backtrack নেই।"""
    if not s.endswith("b"):
        return False
    body = s[:-1]
    return len(body) > 0 and all(ch == 'a' for ch in body)


print(f"{'n':>4} {'backtracking (ms)':>20} {'linear (ms)':>14}")
for n in [10, 15, 20, 25, 28]:
    t_bt = backtracking_time(n) * 1000
    t0 = time.perf_counter(); linear_matcher("a" * n); t_lin = (time.perf_counter() - t0) * 1000
    print(f"{n:>4} {t_bt:>20.2f} {t_lin:>14.4f}")
   n   backtracking (ms)   linear (ms)
  10                0.05        0.0008
  15                1.62        0.0009
  20               52.40        0.0009
  25             1683.90        0.0010
  28            13452.70        0.0011

প্রতি n বাড়ানো ৫-এ backtracking সময় প্রায় ৩০-৩৫ গুণ বাড়ছে — 2⁵ = 32-এর কাছাকাছি, ঠিক তাত্ত্বিক 2^(n-1) prediction-এর সাথে মিলে। linear_matcher পুরোটাই ~0.001ms-এ স্থির — একদম আশানুরূপ O(n)। n=40-এ backtracking সময় হতো কয়েক ঘণ্টা; n=100-এ মহাবিশ্বের বয়সের চেয়ে বেশি — অথচ linear_matcher n=10⁸-এও এক সেকেন্ডের কম নেবে।

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

backtracking regex engine-এর matching time pathological pattern-এ ইনপুট দৈর্ঘ্যের সাথে exponentially বাড়ে, যেখানে একটা সরল, হাতে-লেখা automaton-সমতুল্য matcher একই ভাষায় linear সময়ে থাকে — এটাই ReDoS-এর প্রত্যক্ষ প্রমাণ।

ভেতরে কী ঘটছে

Pumping Lemma — regular ভাষার একটা কাঠামোগত সীমাবদ্ধতা

গত লেসনে আমরা দেখেছি DFA-র state সংখ্যা সসীম। এই সসীমতাই একটা গভীর পরিণতি টেনে আনে, যেটা এখন আমরা কঠোরভাবে বিবৃত করব।

সহজ যুক্তি (pigeonhole): একটা p-state DFA-তে যদি একটা string s accept হয় যার দৈর্ঘ্য |s| ≥ p, তাহলে s পড়ার সময় DFA কমপক্ষে p+1 টা state visit করে (শুরুর state + প্রতিটা character পড়ার পরের state)। কিন্তু DFA-তে মাত্র p টা state আছে — তাই pigeonhole নীতি অনুযায়ী (mathematics/relations-এর pigeonhole, লেসন ১-এও উল্লেখ করা হয়েছিল) কোনো state অন্তত দুইবার visit হবেই, প্রথম p character পড়ার মধ্যে।

সেই পুনরাবৃত্ত state-এর মাঝের অংশটা একটা loop — DFA-তে সেই loop-টা শূন্যবার, একবার, দুইবার, যতবার খুশি ঘোরা যায় আর প্রতিবারই বাকি string-এর সাথে মিলিয়ে string-টা এখনও accept হবে। এই “যতবার খুশি ঘোরানো”-ই pumping।

আনুষ্ঠানিক বিবৃতি

যদি L একটা regular ভাষা হয়, তাহলে একটা সংখ্যা p ≥ 1 (pumping length) আছে এমন যে: L-এর যেকোনো string s-এর জন্য যেখানে |s| ≥ p, s-কে তিন ভাগে ভাগ করা যায় s = xyz যেখানে:

  1. |xy| ≤ p
  2. |y| ≥ 1 (y খালি না)
  3. প্রতিটা i ≥ 0-এর জন্য, xyⁱz ∈ L

p = সমতুল্য DFA-র state সংখ্যা (বা তার চেয়ে কম যেকোনো বৈধ bound)। xy -র দৈর্ঘ্য p-এর মধ্যে সীমাবদ্ধ রাখার শর্তটাই নিশ্চিত করে যে y লুপ-অংশটা string-এর প্রথম p character-এর ভেতরেই পড়বে — এটাই pigeonhole argument-এর সরাসরি ফলাফল।

সম্পূর্ণ প্রমাণ — {0ⁿ1ⁿ : n ≥ 0} regular না

এই module-এর প্রথম লেসন থেকেই এই দাবিটা ঝুলে আছে — এবার প্রমাণ।

দাবি: L = {0ⁿ1ⁿ : n ≥ 0} regular না।

প্রমাণ (contradiction দিয়ে, pumping lemma প্রয়োগ করে)।

ধরি L regular। তাহলে pumping lemma অনুযায়ী একটা p আছে যেমন বর্ণিত উপরে।

আমরা বাছি: s = 0ᵖ1ᵖ। লক্ষ্য করুন s ∈ L (n = p নিয়ে) এবং |s| = 2p ≥ p — শর্ত পূরণ।

প্রতিপক্ষ যেকোনো বিভাজন s = xyz বাছুক, শর্ত মেনে (|xy| ≤ p, |y| ≥ 1)। যেহেতু s-এর প্রথম p character সবগুলোই 0 (কারণ s = 0ᵖ1ᵖ), আর |xy| ≤ p, তাই xy সম্পূর্ণভাবে সেই প্রথম p-টা 0-এর মধ্যে পড়ে। অতএব x আর y দুটোই শুধু 0 দিয়ে গঠিত — y = 0ᵏ কোনো k ≥ 1-এর জন্য (|y| ≥ 1 শর্ত থেকে k ≥ 1)।

আমরা বাছি i = 2 (pump up):

xy2z=0 p−k0k⋅0k⏟y দুইবার0(z-এর 0 অংশ, যদি থাকে)1pxy^2z = 0^{\,p-k}\underbrace{0^k \cdot 0^k}_{y \text{ দুইবার}}0^{\text{(z-এর 0 অংশ, যদি থাকে)}}1^p

সরল করে — মোট 0-র সংখ্যা p + k (মূল p-এর সাথে অতিরিক্ত k যোগ হলো, 1-র সংখ্যা অপরিবর্তিত p)। যেহেতু k ≥ 1:

xy2z=0 p+k1p,p+k≠pxy^2z = 0^{\,p+k}1^p, \qquad p + k \neq p

0-র সংখ্যা 1-র সংখ্যার সমান না, তাই xy²z ∉ L।

এটা pumping lemma-র ৩ নম্বর শর্তের সরাসরি বিরোধিতা — যেটা দাবি করেছিল সব i ≥ 0-এর জন্য xyⁱz ∈ L, কিন্তু আমরা একটা i (= 2) দেখিয়েছি যেখানে সেটা মিথ্যা। Contradiction।

অতএব আমাদের অনুমান (“L regular”) মিথ্যা। ∎

নিজে বানান

BUILD IT

Thompson's Construction — regex থেকে NFA, structural recursion দিয়ে

Python · ●●●●○
  1. একটা ছোট regex subset পার্স করুন — literal, concatenation (implicit), union (|), star (*), group (())
  2. প্রতিটা AST node-এর জন্য Thompson টেমপ্লেট প্রয়োগ করে NFA fragment বানান (base cases আগে)
  3. Union, concat, star-এর জন্য recursively sub-fragment জোড়া লাগান
  4. সম্পূর্ণ NFA-টা লেসন ২-এর subset construction-এ feed করুন — একটা backtracking-মুক্ত matcher পান
  5. ফলাফল matcher-টাকে Python-এর `re` module-এর সাথে তুলনা করুন, ছোট regex-এ

লক্ষ্য: regex-এর inductive সংজ্ঞা সরাসরি কোডে রূপান্তর করা — এটাই এই module-এর “Regex Engine” প্রজেক্টের কেন্দ্রীয় ধাপ।

import itertools

# ── ১. খুব ছোট regex parser (recursive descent — compilers/parsing দেখুন) ──
# গ্রামার:  expr := term ('|' term)*
#           term := factor+
#           factor := atom ('*')?
#           atom := char | '(' expr ')'

class Node: pass
class Char(Node):
    def __init__(self, c): self.c = c
class Concat(Node):
    def __init__(self, parts): self.parts = parts
class Union(Node):
    def __init__(self, opts): self.opts = opts
class Star(Node):
    def __init__(self, inner): self.inner = inner


def parse(regex: str) -> Node:
    pos = 0
    def peek(): return regex[pos] if pos < len(regex) else None
    def expr():
        nonlocal pos
        opts = [term()]
        while peek() == '|':
            pos += 1
            opts.append(term())
        return opts[0] if len(opts) == 1 else Union(opts)
    def term():
        nonlocal pos
        parts = []
        while peek() not in (None, '|', ')'):
            parts.append(factor())
        return parts[0] if len(parts) == 1 else Concat(parts)
    def factor():
        nonlocal pos
        a = atom()
        if peek() == '*':
            pos += 1
            return Star(a)
        return a
    def atom():
        nonlocal pos
        if peek() == '(':
            pos += 1
            e = expr()
            pos += 1              # ')'
            return e
        c = peek(); pos += 1
        return Char(c)
    return expr()


# ── ২. Thompson's construction — প্রতিটা node-এর জন্য (start, accept, transitions) ──
class Frag:
    def __init__(self, start, accept, delta, eps):
        self.start, self.accept, self.delta, self.eps = start, accept, delta, eps

_counter = itertools.count()
def new_state(): return f"q{next(_counter)}"

def thompson(node: Node) -> Frag:
    if isinstance(node, Char):
        s, a = new_state(), new_state()
        return Frag(s, a, {(s, node.c): {a}}, {})

    if isinstance(node, Concat):
        frags = [thompson(p) for p in node.parts]
        delta, eps = {}, {}
        for f in frags:
            delta.update(f.delta); eps.update(f.eps)
        for f1, f2 in zip(frags, frags[1:]):
            eps.setdefault(f1.accept, set()).add(f2.start)
        return Frag(frags[0].start, frags[-1].accept, delta, eps)

    if isinstance(node, Union):
        frags = [thompson(o) for o in node.opts]
        s, a = new_state(), new_state()
        delta, eps = {}, {s: set(), a: set()}
        for f in frags:
            delta.update(f.delta); eps.update(f.eps)
            eps[s].add(f.start)
            eps.setdefault(f.accept, set()).add(a)
        return Frag(s, a, delta, eps)

    if isinstance(node, Star):
        f = thompson(node.inner)
        s, a = new_state(), new_state()
        eps = dict(f.eps)
        eps[s] = {f.start, a}
        eps.setdefault(f.accept, set()).update({f.start, a})
        return Frag(s, a, f.delta, eps)

    raise ValueError(node)


# ── ৩. ব্যবহার — regex "a(b|c)*" ─────────────────────────────────
ast = parse("a(b|c)*")
frag = thompson(ast)
print("start:", frag.start, " accept:", frag.accept)
print("transitions:", frag.delta)
print("epsilon:", frag.eps)

নিজে বাড়ান:

  1. এই frag-কে গত লেসনের to_dfa()-তে সরাসরি feed করুন (NFA format মিলিয়ে নিন) — ফলাফল DFA দিয়ে string match করুন
  2. + (এক বা একাধিকবার) আর ? (ঐচ্ছিক) সমর্থন যোগ করুন — টিপ: R+ ≡ RR*, R? ≡ (R|ε), তাই parser-এ desugar করলেই হয়, নতুন Thompson টেমপ্লেট লাগবে না
  3. Character class ([a-z]) সমর্থন যোগ করুন — এটা মূলত একটা বড় union-এর syntactic sugar
  4. একটা evil = "(a+)+b" pattern parse করে NFA বানিয়ে দেখুন — NFA-র state সংখ্যা n-এর সাথে linearly বাড়ে (exponential না!), কারণ Thompson’s construction backtrack করে না — এটাই প্রমাণ করে exponential সমস্যাটা regex-এর ভাষায় না, নির্দিষ্ট matching algorithm-এ

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

যেখানে এই দুই জগতের পার্থক্য সিদ্ধান্ত নেয়

RE2 (Google) ও Rust-এর regex crate। এরা ইচ্ছাকৃতভাবে backreference/lookaround সমর্থন করে না, বিনিময়ে একটা কঠোর গ্যারান্টি দেয়: matching সময় সবসময় ইনপুট দৈর্ঘ্যে linear, pattern যাই হোক। Google-এর নিজস্ব infrastructure-এ (untrusted input-এ regex চালাতে হয় এমন জায়গায়, যেমন web search) এই গ্যারান্টিই বাধ্যতামূলক — একটা attacker-নিয়ন্ত্রিত pattern কখনো সার্ভার আটকাতে পারবে না।

npm/PyPI-তে বাস্তব CVE। ReDoS একটা স্বীকৃত vulnerability ক্যাটাগরি (CWE-1333) — শত শত published npm package-এ nested quantifier-যুক্ত validation regex (email, URL) পাওয়া গেছে যেগুলো crafted input দিয়ে exploit করা যায়। এই লেসনের experiment-এর মতোই একটা single HTTP request দিয়ে একটা Node.js সার্ভার সম্পূর্ণ অচল করে দেওয়া সম্ভব হয়েছিল একাধিক বাস্তব ঘটনায়।

Lexer generator-রা কখনো এই ঝুঁকি নেয় না। compilers/lexical-analysis-এ আমরা flex/re2c-এর কথা বলেছিলাম — এরা backreference সমর্থন করে না, কারণ এরা এই লেসনের automaton pipeline অনুসরণ করে (Thompson → subset construction → DFA)। একটা compiler-এর lexer কখনো ReDoS-এ আটকায় না, কারণ এটা গঠনগতভাবেই সম্ভব না — একটা DFA-তে “backtrack” করার কোনো ধারণাই নেই।

Input validation-এ ব্যবহারিক পরামর্শ। Web framework-এ email/URL validation regex লেখার সময় nested quantifier এড়িয়ে চলা, timeout সেট করা (অনেক ভাষার regex module timeout প্যারামিটার সমর্থন করে), অথবা RE2-ভিত্তিক library ব্যবহার করা — তিনটাই বাস্তব mitigation, আর তিনটাই এই লেসনের তত্ত্ব থেকে সরাসরি আসে।

Syntax highlighting engine। VS Code, Sublime Text-এর মতো editor-এর syntax highlighter প্রতি keystroke-এ regex চালায় — এখানে সামান্যতম catastrophic backtracking-ও UI freeze করে দিতে পারে, তাই এই টুলগুলো বিশেষভাবে সতর্ক regex টাইমআউট ও automaton-based engine ব্যবহারে।

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

“Pumping lemma দিয়ে প্রমাণ করা যায় একটা ভাষা regular — যেমন 'এই ভাষা pumping lemma মেনে চলে, তাই এটা regular'।”

এটা একটা সাধারণ আর বিপজ্জনক ভুল প্রয়োগ। Pumping lemma শুধু একটা necessary condition — “regular হলে অবশ্যই pump করা যায়”, উল্টোটা না। এটা শুধু non-regularity প্রমাণ করতে ব্যবহার করা যায় (contrapositive দিয়ে: “pump করা না গেলে regular না”), কখনো regularity প্রমাণ করতে না।

এমনকি এমন non-regular ভাষাও আছে যা pumping lemma-র শর্ত মেনে চলে (satisfy করে) — যেমন L = \{a^ib^jc^k : i,j,k \ge 0,\ \text{যদি } i=1 \text{ তাহলে } j=k\}। এই ভাষা non-regular (প্রমাণযোগ্য Myhill-Nerode দিয়ে), কিন্তু pumping lemma দিয়ে সেটা দেখানো কঠিন কারণ s-এর কৌশলী choice-এও pumping লঙ্ঘন এড়ানো যায়। এই কারণে pumping lemma একটা অসম্পূর্ণ টুল — regularity নিশ্চিতভাবে যাচাই করতে দরকার Myhill-Nerode theorem (গত লেসনের DFA minimization অংশে উল্লেখিত), যেটা একটা প্রকৃত iff টেস্ট।

নিয়ম: pumping lemma ব্যর্থ হলে ভাষা অবশ্যই non-regular। কিন্তু pumping lemma সফল হলে কিছুই প্রমাণিত হয় না।

“Catastrophic backtracking শুধু backreference/lookahead-এর মতো 'exotic' regex feature ব্যবহার করলেই ঘটে।”

না — এই লেসনের (a+)+b উদাহরণটাই এর বিপরীত প্রমাণ। এটা সম্পূর্ণ pure regular constructs (+, group, |) দিয়ে তৈরি — কোনো backreference বা lookahead নেই। এমনকি ভাষাটাও আসলে regular (a+b-র সমান, যদিও লেখাটা বেশি জটিল দেখতে)।

সমস্যাটা ভাষায় না — matching algorithm-এ। Backtracking engine একই string-এর জন্য exponentially অনেক উপায়ে group বিভাজন চেষ্টা করে, এমনকি যখন চূড়ান্ত উত্তর (accept/reject) automaton দিয়ে O(n)-এই বের করা সম্ভব। এই কারণেই এই লেসনের build section-এর শেষ exercise-টা গুরুত্বপূর্ণ — Thompson’s construction দিয়ে বানানো NFA-র state সংখ্যা (a+)+b-তে linear, কারণ automaton কখনো “কোন গ্রুপে কোন a পড়ল” তা আলাদাভাবে ট্র্যাক করে না — সব সম্ভাব্য বিভাজন একই সাথে, একটা state-সেটে implicitly represented (subset construction-এর ঠিক সেই ধারণা)।

“যেহেতু আমার regex ছোট আর সরল দেখতে, backtracking নিয়ে চিন্তা করার দরকার নেই।”

Pattern-এর length না, তার গঠন (structure) বিপদজনক কি না তা নির্ধারণ করে। (a+)+b মাত্র ৬ character — এই লেসনের সবচেয়ে ছোট উদাহরণগুলোর একটা, তবু কয়েক ডজন a-তেই ঘণ্টার পর ঘণ্টা সময় নেয়।

বিপরীতভাবে অনেক লম্বা, জটিল দেখতে regex (অনেক character class, অনেক alternation) সম্পূর্ণ নিরাপদ হতে পারে যদি তার group-গুলোর মধ্যে কোনো ambiguous overlap না থাকে — যেমন একটা email-validation regex-এ [a-z]+@[a-z]+\.[a-z]+ কাঠামোগতভাবে নিরাপদ, কারণ প্রতিটা + আলাদা, অ-ওভারল্যাপিং character class-এ কাজ করে।

নিয়ম: দৈর্ঘ্য না, nested/ambiguous quantifier খুঁজুন — যেখানে একই ধরনের character একাধিক quantifier-এর আওতায় পড়তে পারে।

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

1

Pumping lemma-র তিনটা শর্ত (|xy| ≤ p, |y| ≥ 1, xyⁱz ∈ L সব i-তে) থেকে কোনটা ভঙ্গ দেখালেই একটা ভাষা non-regular প্রমাণিত হয়? প্রথম দুইটা শর্ত কেন ভঙ্গ করা যায় না (এগুলো কার নিয়ন্ত্রণে)?

স্মরণ

শুধু তৃতীয় শর্ত (xyⁱz ∈ L সব i ≥ 0-এর জন্য) ভঙ্গ দেখানো প্রমাণের লক্ষ্য — আমরা একটা i খুঁজে বের করি যেখানে xyⁱz ∉ L।

প্রথম দুইটা শর্ত (|xy| ≤ p, |y| ≥ 1) প্রতিপক্ষের নিয়ন্ত্রণে — এগুলো বিভাজন xyz-এর উপর নিষেধাজ্ঞা, যেটা প্রতিপক্ষ (adversary) বেছে নেয়, আমরা না। আমাদের যুক্তি এই দুইটা শর্তের আওতায় যত বিভাজনই সম্ভব হোক, সবগুলোর জন্য কাজ করতে হবে — এই কারণেই 0ⁿ1ⁿ-এর প্রমাণে আমরা |xy| ≤ p শর্ত ব্যবহার করে দেখিয়েছি y শুধু 0 দিয়ে গঠিত হতে বাধ্য (কারণ প্রথম p character সবই 0) — এটা আমাদের case analysis সংকুচিত করে দিয়েছে, আমরা “ভঙ্গ” করিনি।

2

Pumping lemma দিয়ে প্রমাণ করুন L = \{ww : w \in \{0,1\}^*\} (যেকোনো স্ট্রিং তার নিজের সাথে জোড়া লাগানো, যেমন 0101, 1111) regular না।

প্রয়োগ

ধরি L regular, pumping length p।

বাছি: s = 0^p 1 0^p 1। লক্ষ্য করুন s ∈ L — এটা w = 0^p1 নিয়ে ww (|w| = p+1, |s| = 2p+2 ≥ p, শর্ত পূরণ)।

যেকোনো বিভাজন s = xyz যেখানে |xy| ≤ p, |y| ≥ 1। যেহেতু s-এর প্রথম p character সবই 0 (কারণ s শুরু হয় 0^p দিয়ে), আর |xy| ≤ p, তাই xy সম্পূর্ণভাবে সেই প্রথম 0-ব্লকের মধ্যে পড়ে — y = 0^k কোনো k ≥ 1-এর জন্য।

i = 0 বাছি (pump down — y বাদ দিয়ে):

xy0z=0 p−k1 0p1xy^0z = 0^{\,p-k}1\,0^p1

এটা কি ww আকারের কোনো w-এর জন্য? মোট দৈর্ঘ্য 2p + 2 - k (বিজোড়, যেহেতু মূল দৈর্ঘ্য জোড় ছিল আর k ≥ 1 বিয়োগ হলো) — কিন্তু ww আকারের যেকোনো string-এর দৈর্ঘ্য অবশ্যই জোড় (2|w|)। বিজোড় দৈর্ঘ্যের কোনো string L-এ থাকতে পারে না। অতএব xy^0z \notin L।

Contradiction — L non-regular। ∎

একটা বিকল্প (আরো সরাসরি) যুক্তি: এমনকি দৈর্ঘ্য জোড় থাকলেও (অন্য s, i বাছাইয়ে), 0-ব্লকের প্রথমার্ধ থেকে কমে যাওয়া মানে প্রথম আর দ্বিতীয় p-দৈর্ঘ্যের অর্ধেক আর একরকম থাকে না — সেই যুক্তিও গ্রহণযোগ্য, তবে দৈর্ঘ্যের প্যারিটি যুক্তিটাই সবচেয়ে পরিষ্কার এখানে।

3

একজন সহকর্মী দাবি করছেন: “আমি pumping lemma দিয়ে L = \{0^n1^m : n \neq m\} non-regular প্রমাণ করতে পারছি না, তাই এটা regular হতে হবে।” এই যুক্তিতে সমস্যা কী? (আসলে এই ভাষাটা non-regular — কিন্তু সহকর্মীর যুক্তির প্রক্রিয়াটা কেন ভুল তা ব্যাখ্যা করুন।)

যুক্তি

সহকর্মীর যুক্তি-প্রক্রিয়া মৌলিকভাবে ভুল, স্বাধীনভাবে সিদ্ধান্তটা (এই ক্ষেত্রে) সঠিক হলেও। সমস্যা দুইটা:

১. “প্রমাণ করতে পারছি না” ≠ “প্রমাণ নেই”। হয়তো সহকর্মী শুধু সঠিক s বেছে নিতে ব্যর্থ হয়েছেন, অথবা case analysis অসম্পূর্ণ রেখেছেন। Pumping lemma প্রয়োগে ব্যর্থতা একটা দক্ষতার প্রশ্ন — এর মানে “ভাষাটা pump হয়” না।

২. এমনকি সত্যিকারের pumping lemma ব্যর্থতা (“এই ভাষা pumping lemma মেনে চলে”) থেকেও regularity প্রমাণিত হয় না — এই লেসনের প্রথম misconception ঠিক এই ভুল ব্যাখ্যা করে। Pumping lemma শুধু একমুখী: non-regular ⟹ pump করা যায় না-এর contrapositive (pump করা যায় না ⟹ non-regular) বৈধ, কিন্তু “pump করা যায় ⟹ regular” বৈধ না।

সঠিক পন্থা: এই ভাষাটা (n ≠ m) আসলে non-regular — প্রমাণ করা যায় s = 0^p1^{p+p!} -জাতীয় কৌশলী choice দিয়ে (pump করে এমন i বানানো যাতে নতুন 0-সংখ্যা পুরনো 1-সংখ্যার সমান হয়ে যায়, যা L-এর সংজ্ঞা লঙ্ঘন করে)। কিন্তু সাধারণভাবে, একটা ভাষা regular কি না নিশ্চিত করতে pumping lemma-র ব্যর্থতার উপর নির্ভর না করে Myhill-Nerode equivalence class সরাসরি গণনা করা (বা একটা DFA সরাসরি বানানোর চেষ্টা করে দেখা এটা সম্ভব কি না) নির্ভরযোগ্য পথ।

4

আপনি একটা code review করছেন যেখানে একটা input-validation regex আছে: ^(\d+,)+\d+$ (comma-separated সংখ্যার তালিকা যাচাই করতে, যেমন "1,22,333")। এটা কি ReDoS ঝুঁকিতে আছে? বিশ্লেষণ করে বলুন, আর যদি ঝুঁকি থাকে তাহলে একটা নিরাপদ বিকল্প প্রস্তাব করুন।

ডিজাইন

ঝুঁকি বিশ্লেষণ: pattern-টা (\d+,)+ — বাইরের + এর ভেতরে \d+, — এখানে nested quantifier আছে ঠিকই, কিন্তু এই লেসনের evil pattern ((a+)+)-এর সাথে একটা গুরুত্বপূর্ণ পার্থক্য আছে: প্রতিটা (\d+,) repetition-এর মধ্যে একটা বাধ্যতামূলক delimiter (,) আছে যা প্রতিটা group-এর সীমানা দ্ব্যর্থহীনভাবে নির্ধারণ করে দেয়। "1,22,333," স্ট্রিং-এ \d+-এর জন্য সম্ভাব্য বিভাজন খুঁজতে গেলে ,-এর অবস্থান আগেই বিভাজন নির্দিষ্ট করে দেয় — কোনো ambiguous overlap নেই যেমন (a+)+-এ ছিল (যেখানে delimiter-বিহীন a-এর ধারাবাহিকতায় group-এর সীমানা যেকোনো জায়গায় হতে পারত)।

উপসংহার: এই নির্দিষ্ট pattern-টা কম ঝুঁকিপূর্ণ, delimiter-এর কারণে। তবে সম্পূর্ণ নিরাপদ প্রমাণ করতে চাইলে actually Thompson’s construction দিয়ে এর NFA বানিয়ে state সংখ্যা input length-এর সমানুপাতিক (linear) কি না যাচাই করা সবচেয়ে নির্ভরযোগ্য — অনুমানের বদলে গঠনমূলক প্রমাণ।

তবুও একটা সতর্কতা: শেষে $ অ্যাংকর থাকায় পুরো string মিলতেই হবে — যদি ইনপুট প্যাটার্নের সাথে না মেলে (যেমন শেষে অতিরিক্ত non-digit character), backtracking engine বিভিন্ন বিভাজন চেষ্টা করবে ব্যর্থ হওয়ার আগে, তবে delimiter-এর কারণে এই চেষ্টার সংখ্যা input length-এ linear, exponential না।

নিরাপদ বিকল্প (যেকোনো সন্দেহেই ভালো অভ্যাস): RE2-ভিত্তিক library ব্যবহার করুন, অথবা regex সম্পূর্ণ এড়িয়ে s.split(',') করে প্রতিটা অংশ str.isdigit() দিয়ে যাচাই করুন — এটা এই লেসনের linear_matcher experiment-এর ঠিক প্যাটার্ন, আর গ্যারান্টিভাবে O(n), কোনো regex engine-এর অভ্যন্তরীণ আচরণের উপর নির্ভর না করেই।

এরপর কী

আজ আমরা regex-এর আনুষ্ঠানিক সংজ্ঞা, DFA/NFA-র সাথে তার সমতুল্যতা, আর pumping lemma দিয়ে regular ভাষার একটা কঠোর সীমা দেখলাম — 0ⁿ1ⁿ regex/DFA দিয়ে কখনো ধরা সম্ভব না, প্রমাণসহ। পথে দেখলাম কেন বাস্তব regex engine তাত্ত্বিক মডেল থেকে সরে গিয়ে backtracking ব্যবহার করে, আর তার মূল্য কী (ReDoS)।

কিন্তু 0ⁿ1ⁿ নিশ্চয়ই একটা “সহজ” ভাষা মনে হয় — balanced parentheses, arithmetic expression-এর নেস্টিং, JSON-এর গঠন সবই একই রকম। যদি finite automaton এটা ধরতে না পারে, তাহলে কী পারে?

পরের লেসনে উত্তর — pushdown automaton (PDA), যেটা একটা DFA-র উপর মাত্র একটা নতুন জিনিস যোগ করে: একটা stack। আমরা দেখব context-free grammar (compilers/grammars-and-ambiguity-তে যা আমরা অনানুষ্ঠানিকভাবে ব্যবহার করেছিলাম) আর PDA ঠিক একই ক্ষমতার, ঠিক যেমন regex আর DFA/NFA ছিল আজ। আর এই module-এরই একটা প্রজেক্ট — Regex Engine — আপনাকে এই লেসনের Thompson’s construction আর গত লেসনের subset construction জোড়া দিয়ে একটা সম্পূর্ণ, backtracking-মুক্ত regex matcher বানাতে বলবে, শুরু থেকে শেষ পর্যন্ত।

আরও পড়ুন

  • Introduction to the Theory of Computation (3rd ed.), Sections 1.3 ও 1.4 — Michael Sipser · Regular expression, Kleene's theorem, ও pumping lemma-র ক্যাননিক্যাল treatment
  • Regular Expression Matching Can Be Simple And Fast — Russ Cox · Backtracking বনাম automaton-based matching-এর সবচেয়ে পরিষ্কার ব্যাখ্যা — RE2-র নকশার ভিত্তি
  • Compilers: Principles, Techniques, and Tools (2nd ed.), Section 3.7 — Aho, Sethi, Ullman, Lam · Thompson's construction-এর মূল algorithm ও প্রমাণ