Hopcroft DFA Minimization
Stage 5 — 같은 language 를 받아들이는 DFA 중 가장 작은 것을 찾습니다. Hopcroft 의 partition refinement 는 accept / non-accept 두 블록에서 시작해, 어떤 symbol 로 움직였을 때 서로 구분되는 state 들을 발견할 때마다 block 을 쪼갭니다. 더 이상 쪼갤 수 없을 때가 최소 DFA 입니다.
Stage 3 에서는 subset construction 으로 NFA 를 DFA 로 펼쳤습니다. Stage 4 에서는 그 DFA 위에서 state 하나가 입력을 따라 움직이는 모습을 NFA 옆에 나란히 세웠습니다.
Stage 5 는 그 DFA 를 다시 줄입니다. 같은 language 를 받아들이는 DFA 중 가장 작은 것은 어떻게 생겼고, 어떤 state 들이 왜 합쳐질 수 있는지를 한 step 씩 따라가 봅니다.
알고리즘은 고전적인 Hopcroft partition refinement 입니다. 모든 state 를 accept / non-accept 두 block 으로 나누는 데서 시작해, 어떤 symbol 로 움직였을 때 서로 다른 미래를 갖는 state 들을 발견하면 block 을 쪼갭니다. 더 이상 쪼갤 곳이 없을 때, 각 block 이 최소 DFA 의 state 하나가 됩니다.
아래 viewer 의 slider 를 움직여 봅시다. (a|b)*c 의 DFA 4 state 가 visible 기준 2 state 로 접히는 장면입니다.
(a|b)*cΣ = {a,b,c}4 → 2 states왼쪽 pane 은 Stage 3 의 DFA 에 dashed sink state (∅) 를 얹은 그림입니다. 각 state 의 fill color 는 현재 partition 에서 어느 block 에 속하는지를 뜻합니다. 같은 색이면 아직 구분되지 않은 state 들입니다. 두꺼운 outline 이 쳐진 state 들은 이번 step 의 splitter block 입니다.
오른쪽 pane 은 수렴이 끝난 뒤의 최소 DFA 입니다. step 이 바뀌어도 오른쪽은 고정되어 있습니다. 이 viewer 가 보여주고 싶은 것은 "왼쪽 partition 의 색이 어떻게 오른쪽 DFA 로 접히는가" 입니다. (a|b)*c 에서는 왼쪽의 {D0,D1,D2} 가 한 block 으로 남고, {D3} 이 accept block 으로 남습니다. 그 두 visible block 이 오른쪽의 두 node 입니다.
Sink 이야기부터 합니다.
Stage 3 의 DFA 는 partial 입니다. "현재 state 에서 이 symbol 로 나가는 transition 이 없음" 을 transition 생략으로 표현합니다. 그림이 깔끔해지는 좋은 선택이었습니다.
하지만 고전 DFA minimization 은 보통 total DFA 위에서 정의됩니다. "모든 state 는 모든 symbol 에 대해 정확히 하나의 target 을 가진다" 는 전제가 있어야, "같은 block 안의 두 state 가 symbol c 에 대해 같은 block 으로 가는가?" 라는 질문이 깔끔해집니다. 한쪽은 transition 이 있고 한쪽은 없으면, 그 없음도 비교 가능한 destination 으로 바꿔야 합니다.
해결은 교과서가 오래전에 정리해 둔 대로입니다. missing transition 은 implicit dead sink 로 간다고 본다.
- 원본 DFA 에 없던 state 를 하나 추가합니다. viewer 에서는
∅로 그립니다. - 각
(state, symbol)에 원래 transition 이 없었다면, 그 자리를 sink 로 가는 transition 으로 채웁니다. - sink 는 모든 symbol 로 자기 자신을 가리킵니다. 한 번 sink 에 들어오면 accept 에 도달하지 못합니다.
이 totalized DFA 위에서 Hopcroft 를 돌립니다. 결과 partition 에는 sink 가 들어 있는 block 이 반드시 하나 있습니다. viewer 는 sink 를 dashed ∅ node 로 보여주되, header 의 visible count 에서는 sink-only block 을 제외합니다. 계산은 total 위에서, 독자가 읽는 state 수는 원래 partial DFA 감각에 맞춰서.
이 한 줄을 앞에 두지 않으면 (a|b)*c 의 "최소 DFA 가 몇 state 인가" 같은 질문이 계속 엇갈립니다. 원본 visible DFA 는 4 state 입니다. sink 까지 totalize 하면 5 state 가 되고, Hopcroft 결과는 3 block 입니다. 하지만 sink-only block 을 visible count 에서 빼면 보이는 최소 DFA 는 2 state 입니다. 이 글에서 (a|b)*c 를 "4 → 2" 라고 쓰는 것은 항상 visible count 기준입니다.
이 글은 Stage 5 를 세 commit 에 1:1 로 매핑합니다
구성은 Stage 3 / 4 와 같습니다. Rust core, artifact 재생성, viz 세 commit 으로 나누었고, 글도 그 순서로 따라갑니다. Stage 5 는 Stage 3 의 DFA 결과물을 입력으로 받아 src/minimize.rs 에서 Hopcroft 를 돌리고, viewer 는 DfaGraph 를 재사용하면서 minimization 전용 shell 을 하나 추가합니다.
Repo: github.com/Ricci-curvature/regex-viz
Stage 5 — Hopcroft Core
Commit: 8d4ee2e — stage 05: Hopcroft DFA minimization trace
핵심은 src/minimize.rs, CLI, 테스트 14 개입니다. 이미 구축된 Stage 3 DFA 를 totalize 한 뒤 Hopcroft partition refinement 를 돌리고, partition 이 실제로 쪼개지는 장면만 trace 로 남깁니다.
왜 Hopcroft 인가
같은 결과를 주는 대표적인 DFA minimization 알고리즘이 몇 가지 있습니다. Moore 는 매 round 마다 전체 partition 을 훑고, Hopcroft 는 worklist-of-splitters 로 필요한 쪽을 좁혀 가며, Brzozowski 는 reverse → determinize 를 두 번 반복합니다.
이 stage 에서는 Hopcroft 를 골랐습니다.
- 처음 계획했던 roadmap 이 Hopcroft 입니다. 여기서 Moore 로 바꾸면 구현은 쉬워져도 stage 이름과 실제 알고리즘이 어긋납니다.
- splitter story 가 시각화에 좋습니다. Hopcroft 는 "이 splitter block 을 기준으로, 이 symbol 의 predecessor 들이 어떤 block 을 갈랐는가" 라는 장면을 자연스럽게 만듭니다.
- Brzozowski 는 직관의 우회로가 큽니다. 구현은 매력적이지만, 이 글에서 보여주려는 직관은 "미래 행동이 같은 state 는 같은 block 으로 남는다" 입니다. Hopcroft 가 그 직관에 더 직접적입니다.
내부는 고전 Hopcroft 입니다. 다만 trace narration 은 Moore 의 round-by-round 느낌을 조금 빌립니다. viewer 에서는 "worklist 에서 splitter 를 꺼냄 → symbol 하나를 봄 → block 이 갈라짐" 단위로 움직입니다.
MinimizationTrace 의 모양
pub struct MinimizationTrace {
pub regex: String,
pub nfa: Nfa,
pub alphabet: Vec<char>,
pub source_dfa_states: Vec<DfaState>,
pub source_dfa_transitions: Vec<DfaTransition>,
pub sink_id: usize,
pub steps: Vec<MinimizationStep>,
pub minimized: MinimizedDfa,
}
pub struct MinimizationStep {
pub description: String,
pub partition: Vec<Vec<usize>>,
pub splitter_block: Option<Vec<usize>>,
pub symbol: Option<char>,
pub split: Option<SplitEvent>,
}
pub struct MinimizedDfa {
pub states: Vec<MinimizedDfaState>,
pub transitions: Vec<DfaTransition>,
pub mapping: Vec<usize>,
pub sink_block: usize,
}몇 가지 선택이 중요했습니다.
partition은 매 step 의 전체 snapshot 입니다. delta 만 담으면 JSON 은 작아지지만 viewer 가 이전 step 을 재생해야 합니다. toy 규모에서는 중복보다 단순성이 더 값어치 있습니다.splitter_block은 block index 가 아니라Vec<usize>입니다. partition 은 step 마다 쪼개져 index 가 바뀔 수 있으니, "이번 splitter" 를 내용으로 고정합니다.split: Option<SplitEvent>는 이 step 에서 대표로 보여줄 split 입니다. 한(splitter, symbol)조합이 여러 block 을 동시에 가를 수 있으므로, 전체 결과는partitionsnapshot 으로 읽고split은 caption/animation 용 구조로 둡니다.mapping: Vec<usize>는 totalized source state id → minimized state id 입니다. indexsink_id도 포함합니다.sink_block은 minimized state 중 sink 를 포함한 block 의 id 입니다. viewer 는 이 block 을 dashed∅로 표시하고, sink-only block 이면 visible count 에서 제외합니다. 만약 어떤 real non-accept source state 가 sink 와 동등하다면, 그 state 도 같은 sink block 안에 들어갑니다.
Step 은 "변화가 있는 장면" 에만 찍습니다
Hopcroft 의 worklist 는 splitter block 을 꺼내고, 각 alphabet symbol 에 대해 predecessor set 을 계산합니다. 하지만 모든 (splitter, symbol) 이 실제 split 을 만드는 것은 아닙니다. 어떤 predecessor set 은 현재 block 을 전혀 건드리지 않거나, block 전체를 통째로 포함합니다. 그러면 partition 은 변하지 않습니다.
그런 무변동 조합은 step 으로 emit 하지 않았습니다. slider 가 의미 없는 frame 에 멈추지 않도록 하기 위해서입니다.
그래서 step 시퀀스는 이렇게 생깁니다.
init ← accept / non-accept 초기 partition
splitter × symbol ← 실제로 block 이 갈라진 장면만
splitter × symbol
...
verdict ← fixed point, 최종 block 수(a|b)*c 는 split 이 한 번만 일어납니다. accept block {D3} 을 splitter 로 잡고 c 를 보면, non-accept block {D0,D1,D2,∅} 이 {D0,D1,D2} | {∅} 으로 갈라집니다. abc|axc 는 세 번 갈라지며, accept 근처에서 시작한 구분이 prefix 쪽으로 거슬러 내려옵니다.
테스트 14 개의 구성
아홉 개는 visible 최소 state 수나 mapping 을 직접 assert 합니다: a, ab, a|b, a*, a+, (a|b)*c, aa|ab, abc|axc, (a|b)(a|b) 입니다. 나머지 다섯 개는 invariant 를 확인합니다.
sink_block_always_exists— 모든 regex 에서 sink 는 정확히 하나의 block 에 속합니다.mapping_preserves_acceptance— source state 의 accept 여부가 mapped minimized state 의 accept 여부와 같습니다.minimized_is_deterministic— 최종 minimized DFA 에서(from, label)은 유일합니다.partition_refinement_only_splits— step 이 진행될수록 block 수는 줄어들지 않습니다.step_sequence_starts_with_init_ends_with_verdict— init 과 verdict step 은 splitter/symbol 이 없습니다.
이 invariant 들 덕분에 구현을 리팩터링할 때 숫자 기반 테스트만 믿는 불안이 줄어듭니다. 대표적인 예가 a* 입니다. 처음에는 source DFA 가 2 state 라서 "이미 최소" 라고 착각하기 쉽습니다. 하지만 D0 과 D1 은 둘 다 accept 이고, a 를 읽으면 같은 accept block 으로 갑니다. 그래서 둘은 같은 block 으로 합쳐지고 visible 최소 DFA 는 1 state 가 됩니다. 작은 DFA 라고 해서 최소 DFA 라는 뜻은 아닙니다.
Stage 5 — Pinned Artifacts
Commit: df94a55 — artifacts: regenerate stage05
아홉 개를 pin 했습니다.
| 파일 | regex | 원본 state | visible 최소 | 합쳐진 state |
|---|---|---|---|---|
a.json | a | 2 | 2 | (이미 최소) |
ab.json | ab | 3 | 3 | (이미 최소) |
a_or_b.json | a|b | 3 | 2 | D1, D2 (accept) |
a_star.json | a* | 2 | 1 | D0, D1 (accept loop) |
a_plus.json | a+ | 2 | 2 | (이미 최소) |
a_or_b_star_c.json | (a|b)*c | 4 | 2 | D0, D1, D2 |
aa_or_ab.json | aa|ab | 4 | 3 | D2, D3 (accept) |
abc_or_axc.json | abc|axc | 6 | 4 | D2, D3 + D4, D5 |
a_or_b_twice.json | (a|b)(a|b) | 5 | 3 | D1, D2 + D3, D4 |
앞의 여섯 개는 Stage 3 에서 쓴 pin 을 그대로 들고 왔습니다. 같은 regex 가 Stage 3 / 4 / 5 에서 어떻게 다른 풍경을 보이는지 이어 보기 좋습니다. 뒤의 세 개는 Stage 5 전용 teaching pin 입니다. block 이 실제로 갈라지고, 여러 source state 가 하나의 minimized state 로 접히는 장면을 더 선명하게 보여줍니다.
a* — 가장 놀라운 merge
a*Σ = {a}2 → 1 statesource DFA 는 2 state 입니다. D0 은 start 이자 accept 이고, 빈 문자열을 받아들입니다. D1 은 a 를 한 번 이상 읽은 뒤의 accept 입니다. D0 --a--> D1, D1 --a--> D1 이므로 둘이 같은 transition 모양을 가진 것은 아닙니다. 그런데 minimization 은 모양이 아니라 미래 행동 을 봅니다.
초기 partition 은 {D0,D1} | {∅} 입니다. D0 과 D1 은 둘 다 accept 이고, a 를 읽으면 둘 다 같은 accept block 으로 갑니다. 더 쪼갤 근거가 없습니다. 그래서 두 state 는 하나로 합쳐지고, visible 최소 DFA 는 1 state 입니다. 자기 자신으로 a self-loop 하는 accept state 하나. "a* 는 a 가 0 회 이상이면 accept" 라는 language 를 state machine 으로 가장 작게 쓴 모양입니다.
a|b — accept leaves merge
a|bΣ = {a,b}3 → 2 statessource DFA 는 D0 --a--> D1, D0 --b--> D2 입니다. D1 과 D2 는 둘 다 accept 이고, 어떤 symbol 을 더 읽어도 sink 로만 갑니다. 이후 가능한 모든 입력에 대해 같은 반응을 하므로 한 block 으로 합쳐집니다.
trace 는 init, split, verdict 세 step 입니다. init 에서는 {D1,D2} 가 accept block 이고 {D0,∅} 가 non-accept block 입니다. splitter {D1,D2} 로 symbol a 를 보면 predecessor 는 {D0} 입니다. 그래서 {D0,∅} 가 {D0} | {∅} 로 갈라집니다. 최종 visible DFA 는 start block 과 merged accept block, 두 state 입니다.
aa|ab — symmetric leaf accepts
aa|abΣ = {a,b}4 → 3 statessource DFA 는 D0 --a--> D1, 그리고 D1 --a--> D2, D1 --b--> D3 입니다. D2 와 D3 은 둘 다 accept leaf 입니다. 이름도 다르고 도달 경로도 다르지만, 이후 가능한 모든 입력에 대해 같은 방식으로 반응합니다. 그래서 같은 block 으로 합쳐집니다.
이 예의 교훈은 같아 보이는 기준이 구조가 아니라 행동 이라는 점입니다. D2 는 "aa 를 소비한 뒤의 state" 이고, D3 은 "ab 를 소비한 뒤의 state" 입니다. 하지만 둘 다 "지금 accept 이고, 더 읽으면 sink 로 간다" 입니다. 그러면 최소 DFA 입장에서는 같은 state 입니다.
abc|axc — symmetric prefix states
abc|axcΣ = {a,b,c,x}6 → 4 states이번에는 merge 가 두 군데에서 일어납니다. source DFA 는 6 state 입니다.
D0startD1afteraD2afterabD3afteraxD4afterabc(accept)D5afteraxc(accept)
D2 와 D3 은 "c 를 받으면 accept 로, 다른 symbol 을 받으면 sink 로" 라는 같은 행동을 합니다. D4 와 D5 는 둘 다 dead-end accept 입니다. 결과는 6 → 4 visible 입니다.
slider 를 한 step 씩 움직여 보면 구분이 accept 쪽에서 start 쪽으로 전파됩니다. 첫 split 에서 splitter {D4,D5} 와 symbol c 가 non-accept block 을 {D2,D3} 과 나머지로 가릅니다. 다음 split 에서는 {D2,D3} 이 splitter 가 되어 symbol b 로 {D1} 을 분리합니다. 마지막 split 에서는 {D1} 이 splitter 로 a 를 보며 {D0} 과 sink 를 가릅니다. block 이 chain 을 타고 세분화되는 장면입니다.
(a|b)(a|b) — merge by length
(a|b)(a|b)Σ = {a,b}5 → 3 states이 regex 는 {a,b} 위의 정확히 길이 2 인 문자열만 accept 합니다. source DFA 는 5 state 입니다.
D0startD1a를 한 글자 읽은 뒤D2b를 한 글자 읽은 뒤D3두 글자 읽은 뒤의 acceptD4두 글자 읽은 뒤의 또 다른 accept
D1 과 D2 는 "글자를 하나 소비했고 한 글자만 더 받으면 accept" 라는 행동을 공유합니다. D3 과 D4 는 dead-end accept 를 공유합니다. 두 pair 가 각각 합쳐져 5 → 3 visible 입니다. 최소 DFA 는 사실상 "지금까지 몇 글자를 먹었는가 (0 / 1 / 2)" 를 세는 상태기계입니다.
이 예의 교훈은 최소 DFA 의 state 가 지금까지 본 문자열의 계산상 요약 이라는 점입니다. 이 regex 에서는 첫 글자가 a 였는지 b 였는지를 기억할 필요가 없습니다. 한 글자를 읽었다는 사실만 중요합니다.
ab — 이미 최소
abΣ = {a,b}3 → 3 statescontrast 를 위해 하나 더 봅시다. ab 의 source DFA 는 3 state 이고, 각 state 는 서로 다른 미래를 갖습니다. D2 는 accept, D1 은 b 를 받으면 accept 로 가는 state, D0 은 a 를 받으면 그 D1 로 가는 start 입니다.
totalized partition 은 {D2} | {D0,D1,∅} 에서 시작합니다. splitter {D2} 와 symbol b 가 {D1} 을 분리하고, 다음 splitter {D1} 과 symbol a 가 {D0} 을 sink 에서 분리합니다. 최종 partition 은 {D0} | {D1} | {D2} | {∅} 이고 visible count 는 3 입니다. "최소" 라는 말이 항상 "더 작아진다" 를 뜻하는 건 아닙니다.
pinning 의 의미
Stage 1–4 와 같습니다. 위 viewer 들은 df94a55 에 고정 되어 있어서, 이후 minimize.rs 를 리팩터링해도 step 수, partition snapshot, mapping 은 그대로입니다. 포맷 자체 (MinimizationTrace) 가 바뀐다면 stage05 전체를 regenerate 하고 이 포스트도 함께 갱신해야 합니다.
a — literal sanity check
aΣ = {a}2 → 2 states가장 단순한 literal 예입니다. source DFA 는 D0 --a--> D1 이고 D1 이 accept 입니다. visible 기준으로 이미 최소라서 오른쪽도 2 state 입니다. 다만 totalized 계산에서는 sink 가 추가되므로 trace 는 init → split → verdict 세 step 입니다. splitter {D1} 과 symbol a 가 {D0,∅} 를 {D0} | {∅} 로 갈라, start 와 sink 를 분리합니다.
Stage 5 — Viewer
Commit: b13ef40 — stage 05: viz components (MinimizationViewer)
위 viewer 들은 전부 이 커밋의 산물입니다. minimization.ts 와 MinimizationViewer.tsx 를 추가하고, DfaGraph.tsx 를 조금 확장했으며, viz/README.md 를 갱신했습니다.
DfaGraph 의 확장
Stage 3 의 DfaGraph 는 single focus 하나를 노란 outline 으로 강조하는 정도였습니다. Stage 5 에서는 세 가지가 더 필요했습니다.
- block 별 fill color. 같은 block 에 속한 state 들은 같은 색이어야 합니다. 추가 prop:
blockFill?: Record<number, string>. - splitter block 의 multi-outline. 한 state 가 아니라 여러 state 를 동시에 splitter 로 강조해야 합니다. 추가 prop:
outlineIds?: number[]. - sink rendering. sink state 는 dashed border, 회색 fill, subset caption 숨김, label
∅로 그립니다. 추가 prop:sinkId?: number | null.
기존 focus prop 과 독립적으로 동작하도록 붙였습니다. Stage 3 의 ConstructionViewer 는 새 prop 들을 쓰지 않으므로 breaking change 가 아닙니다.
왜 ConstructionViewer 를 확장하지 않았나
Stage 3 때와 같은 판단입니다. trace payload 가 다르고 (ConstructionTrace vs MinimizationTrace), 시간축의 의미도 다릅니다. ConstructionViewer 의 slider 는 "새 DFA state 가 발견되는 BFS step" 을 따라가고, MinimizationViewer 는 "기존 DFA state 들을 묶은 partition 이 refinement 되는 step" 을 따라갑니다. shell 을 합치려 하면 분기가 많아지고 시각적 의도도 흐려집니다.
공유하는 것은 DfaGraph 라는 render primitive 입니다. 그 위의 shell, 즉 어떤 pane 이 있고 slider 가 무엇을 가리키며 caption 이 어떤 정보를 주는가는 stage 마다 새로 씁니다.
Partition strip
viewer 아래쪽의 색 chip 들은 현재 partition 의 축소 표현입니다. chip 하나가 block 하나이고, 배경색은 왼쪽 pane 의 node fill 과 맞습니다. 이 strip 이 있으니 "지금 block 이 몇 개인가" 를 state diagram 을 훑지 않고도 바로 셀 수 있습니다. (a|b)*c 에서는 split 전에는 두 chip, split 후에는 세 chip 으로 늘어납니다.
sink 는 visible count 에서 빠집니다
왼쪽 pane 의 DFA 는 totalized 입니다. 원본 transition 과 missing transition 을 sink 로 채운 transition 이 함께 보입니다. 그래야 partition 이 왜 그렇게 갈라지는지, 특히 sink 와 구분되는 state 가 어떻게 분리되는지를 직접 읽을 수 있습니다. (a|b)*c 의 split step 을 보면 {D0,D1,D2,∅} 이 {D0,D1,D2} | {∅} 으로 갈라지는데, ∅ 이 같은 pane 에 있어야 non-accept block 안에서 무엇이 분리되는지 보입니다.
오른쪽 pane 에서도 sink block 은 dashed ∅ node 로 남습니다. 다만 header 의 4 → 2 states 같은 숫자는 sink-only block 을 뺀 visible count 입니다. 수렴 결과를 읽을 때 sink 는 계산상의 보조물이라는 점을 유지하면서도, 필요하면 전체 totalized 구조를 눈으로 확인할 수 있게 둔 타협입니다.
Lessons
-
Totalization 은 semantic decision 이고, sink 표시 여부는 rendering decision 입니다. Partial DFA 를 그대로 minimize 하려고 하면 transition 이 없는 칸을 매번 특수 처리해야 합니다. sink 하나를 추가하면 알고리즘 본체는 total DFA 를 상대로 깔끔하게 돌아갑니다. viewer 는 그 sink 를 보여줄지 숨길지 선택할 수 있지만, 계산 의미는 바뀌지 않습니다.
-
"이미 작다" 와 "이미 최소다" 는 다릅니다.
a*의 source DFA 는 2 state 뿐이지만 1 state 로 줄어듭니다. 두 state 가 모두 accept 이고, 남은 모든 입력에 대해 같은 language 를 받아들이기 때문입니다. 최소 DFA 는 source DFA 보다 작아질 수도 있고, 그대로일 수도 있습니다. 기준은 state 수의 첫인상이 아니라 future behavior 입니다. -
machine contract 와 human narration 을 섞지 않습니다. Stage 4 에서도 같은 원칙을 썼지만, Stage 5 에서 더 중요해졌습니다.
description은 "splitter{D3}onc: predecessors split ..." 같은 사람용 문장이고,split: SplitEvent는 같은 정보의 기계 구조입니다. 테스트는 구조를 읽고 caption 은 문장을 읽습니다. 문장만 다듬어도 테스트가 깨지는 구조를 피합니다. -
partition refinement monotonicity 를 invariant 로 테스트합니다.
partition_refinement_only_splits는 step 사이에서partition.len()이 줄어들지 않는지만 확인합니다. 한 줄짜리 성질이지만, worklist 로직을 잘못 건드려 block 을 merge 하는 상황이 생기면 바로 잡아냅니다. 알고리즘의 수학적 성질을 테스트 코드로 번역해 두는 건 리팩터링의 좋은 안전망입니다.
다음 — Stage 6
Backtracking catastrophe. 지금까지는 모두 NFA/DFA simulator 였습니다. 비결정성을 active set 으로 들고 가거나 (Stage 2), DFA state 하나로 접어 두거나 (Stage 3–5), 어느 쪽이든 입력 길이에 선형인 step 안에서 match verdict 를 냅니다. 많은 실제 regex 엔진 (Python re, Perl, Java 의 기본 엔진) 은 이렇게 움직이지 않습니다. DFS 형 backtracker 로 path 하나를 탐색하고, 실패하면 되돌아가 다른 path 를 시도합니다.
대부분의 regex 에서는 차이가 잘 보이지 않습니다. 하지만 (a+)+b × "aaaaa!" 같은 입력이 오면 backtracker 의 path 수가 입력 길이에 대해 급격히 늘어납니다. step 수가 수천, 수만 단위로 튀는 동안 NFA/DFA simulator 는 여전히 몇십 step 안에 끝납니다. 같은 regex, 같은 입력, 같은 verdict 인데 cost profile 은 완전히 다릅니다.
Stage 6 는 그 대비를 보여줄 예정입니다. BacktrackingTrace 를 새로 만들고, path tree 의 depth, failed attempts, total step count 를 명시적인 수치로 기록합니다. Stage 4 의 side-by-side 구도 옆에 붙이면 "regex engine 의 알고리즘 선택이 곧 성능 class 의 선택" 이라는 사실이 한 화면에 정리됩니다.
Repo: github.com/Ricci-curvature/regex-viz
이번 stage 의 커밋 세 개: