RICCILAB
> projects/regex/hopcroft-dfa-minimization
> REGEX-VIZ|STATUS: ACTIVE|

Hopcroft DFA Minimization

Stage 5 — 같은 language 를 받아들이는 DFA 중 가장 작은 것을 찾습니다. Hopcroft 의 partition refinement 는 accept / non-accept 두 블록에서 시작해, 어떤 symbol 로 움직였을 때 서로 구분되는 state 들을 발견할 때마다 block 을 쪼갭니다. 더 이상 쪼갤 수 없을 때가 최소 DFA 입니다.

_Rust_TypeScript_React_MDX_dagre

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 로 접히는 장면입니다.

regex: (a|b)*cΣ = {a,b,c}4 2 states
source DFA (totalized, colored by block)
abcabcabcabcabcD0D1D2D3
minimized DFA (so far)
abcabcD0{D0,D1,D2,∅}D3{D3}
{D0,D1,D2,∅}{D3}
step 1 / 3initial partition: {accept} | {non-accept} = {0,1,2,4} | {3}2 blocks

왼쪽 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: 8d4ee2estage 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 를 골랐습니다.

  1. 처음 계획했던 roadmap 이 Hopcroft 입니다. 여기서 Moore 로 바꾸면 구현은 쉬워져도 stage 이름과 실제 알고리즘이 어긋납니다.
  2. splitter story 가 시각화에 좋습니다. Hopcroft 는 "이 splitter block 을 기준으로, 이 symbol 의 predecessor 들이 어떤 block 을 갈랐는가" 라는 장면을 자연스럽게 만듭니다.
  3. 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 을 동시에 가를 수 있으므로, 전체 결과는 partition snapshot 으로 읽고 split 은 caption/animation 용 구조로 둡니다.
  • mapping: Vec<usize> 는 totalized source state id → minimized state id 입니다. index sink_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 라서 "이미 최소" 라고 착각하기 쉽습니다. 하지만 D0D1 은 둘 다 accept 이고, a 를 읽으면 같은 accept block 으로 갑니다. 그래서 둘은 같은 block 으로 합쳐지고 visible 최소 DFA 는 1 state 가 됩니다. 작은 DFA 라고 해서 최소 DFA 라는 뜻은 아닙니다.


Stage 5 — Pinned Artifacts

Commit: df94a55artifacts: regenerate stage05

아홉 개를 pin 했습니다.

파일regex원본 statevisible 최소합쳐진 state
a.jsona22(이미 최소)
ab.jsonab33(이미 최소)
a_or_b.jsona|b32D1, D2 (accept)
a_star.jsona*21D0, D1 (accept loop)
a_plus.jsona+22(이미 최소)
a_or_b_star_c.json(a|b)*c42D0, D1, D2
aa_or_ab.jsonaa|ab43D2, D3 (accept)
abc_or_axc.jsonabc|axc64D2, D3 + D4, D5
a_or_b_twice.json(a|b)(a|b)53D1, D2 + D3, D4

앞의 여섯 개는 Stage 3 에서 쓴 pin 을 그대로 들고 왔습니다. 같은 regex 가 Stage 3 / 4 / 5 에서 어떻게 다른 풍경을 보이는지 이어 보기 좋습니다. 뒤의 세 개는 Stage 5 전용 teaching pin 입니다. block 이 실제로 갈라지고, 여러 source state 가 하나의 minimized state 로 접히는 장면을 더 선명하게 보여줍니다.

a* — 가장 놀라운 merge

regex: a*Σ = {a}2 1 state
source DFA (totalized, colored by block)
aaaD0D1
minimized DFA (so far)
aaD0{D0,D1}
{D0,D1}{}
step 1 / 2initial partition: {accept} | {non-accept} = {0,1} | {2}2 blocks

source DFA 는 2 state 입니다. D0 은 start 이자 accept 이고, 빈 문자열을 받아들입니다. D1a 를 한 번 이상 읽은 뒤의 accept 입니다. D0 --a--> D1, D1 --a--> D1 이므로 둘이 같은 transition 모양을 가진 것은 아닙니다. 그런데 minimization 은 모양이 아니라 미래 행동 을 봅니다.

초기 partition 은 {D0,D1} | {∅} 입니다. D0D1 은 둘 다 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

regex: a|bΣ = {a,b}3 2 states
source DFA (totalized, colored by block)
ababababD0D1D2
minimized DFA (so far)
ababD0{D0,∅}D1{D1,D2}
{D0,∅}{D1,D2}
step 1 / 3initial partition: {accept} | {non-accept} = {0,3} | {1,2}2 blocks

source DFA 는 D0 --a--> D1, D0 --b--> D2 입니다. D1D2 는 둘 다 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

regex: aa|abΣ = {a,b}4 3 states
source DFA (totalized, colored by block)
aabbabababD0D1D2D3
minimized DFA (so far)
ababD0{D0,D1,∅}D2{D2,D3}
{D0,D1,∅}{D2,D3}
step 1 / 4initial partition: {accept} | {non-accept} = {0,1,4} | {2,3}2 blocks

source DFA 는 D0 --a--> D1, 그리고 D1 --a--> D2, D1 --b--> D3 입니다. D2D3 은 둘 다 accept leaf 입니다. 이름도 다르고 도달 경로도 다르지만, 이후 가능한 모든 입력에 대해 같은 방식으로 반응합니다. 그래서 같은 block 으로 합쳐집니다.

이 예의 교훈은 같아 보이는 기준이 구조가 아니라 행동 이라는 점입니다. D2 는 "aa 를 소비한 뒤의 state" 이고, D3 은 "ab 를 소비한 뒤의 state" 입니다. 하지만 둘 다 "지금 accept 이고, 더 읽으면 sink 로 간다" 입니다. 그러면 최소 DFA 입장에서는 같은 state 입니다.

abc|axc — symmetric prefix states

regex: abc|axcΣ = {a,b,c,x}6 4 states
source DFA (totalized, colored by block)
abxccbcxacabxabxabcxabcxabcxD0D1D2D3D4D5
minimized DFA (so far)
abcxabcxD0{D0,D1,D2,D3,∅}D4{D4,D5}
{D0,D1,D2,D3,∅}{D4,D5}
step 1 / 5initial partition: {accept} | {non-accept} = {0,1,2,3,6} | {4,5}2 blocks

이번에는 merge 가 두 군데에서 일어납니다. source DFA 는 6 state 입니다.

  • D0 start
  • D1 after a
  • D2 after ab
  • D3 after ax
  • D4 after abc (accept)
  • D5 after axc (accept)

D2D3 은 "c 를 받으면 accept 로, 다른 symbol 을 받으면 sink 로" 라는 같은 행동을 합니다. D4D5 는 둘 다 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

regex: (a|b)(a|b)Σ = {a,b}5 3 states
source DFA (totalized, colored by block)
ababababababD0D1D2D3D4
minimized DFA (so far)
ababD0{D0,D1,D2,∅}D3{D3,D4}
{D0,D1,D2,∅}{D3,D4}
step 1 / 4initial partition: {accept} | {non-accept} = {0,1,2,5} | {3,4}2 blocks

이 regex 는 {a,b} 위의 정확히 길이 2 인 문자열만 accept 합니다. source DFA 는 5 state 입니다.

  • D0 start
  • D1 a 를 한 글자 읽은 뒤
  • D2 b 를 한 글자 읽은 뒤
  • D3 두 글자 읽은 뒤의 accept
  • D4 두 글자 읽은 뒤의 또 다른 accept

D1D2"글자를 하나 소비했고 한 글자만 더 받으면 accept" 라는 행동을 공유합니다. D3D4dead-end accept 를 공유합니다. 두 pair 가 각각 합쳐져 5 → 3 visible 입니다. 최소 DFA 는 사실상 "지금까지 몇 글자를 먹었는가 (0 / 1 / 2)" 를 세는 상태기계입니다.

이 예의 교훈은 최소 DFA 의 state 가 지금까지 본 문자열의 계산상 요약 이라는 점입니다. 이 regex 에서는 첫 글자가 a 였는지 b 였는지를 기억할 필요가 없습니다. 한 글자를 읽었다는 사실만 중요합니다.

ab — 이미 최소

regex: abΣ = {a,b}3 3 states
source DFA (totalized, colored by block)
abbaababD0D1D2
minimized DFA (so far)
ababD0{D0,D1,∅}D2{D2}
{D0,D1,∅}{D2}
step 1 / 4initial partition: {accept} | {non-accept} = {0,1,3} | {2}2 blocks

contrast 를 위해 하나 더 봅시다. ab 의 source DFA 는 3 state 이고, 각 state 는 서로 다른 미래를 갖습니다. D2 는 accept, D1b 를 받으면 accept 로 가는 state, D0a 를 받으면 그 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

regex: aΣ = {a}2 2 states
source DFA (totalized, colored by block)
aaaD0D1
minimized DFA (so far)
aaD0{D0,∅}D1{D1}
{D0,∅}{D1}
step 1 / 3initial partition: {accept} | {non-accept} = {0,2} | {1}2 blocks

가장 단순한 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: b13ef40stage 05: viz components (MinimizationViewer)

위 viewer 들은 전부 이 커밋의 산물입니다. minimization.tsMinimizationViewer.tsx 를 추가하고, DfaGraph.tsx 를 조금 확장했으며, viz/README.md 를 갱신했습니다.

DfaGraph 의 확장

Stage 3 의 DfaGraph 는 single focus 하나를 노란 outline 으로 강조하는 정도였습니다. Stage 5 에서는 세 가지가 더 필요했습니다.

  1. block 별 fill color. 같은 block 에 속한 state 들은 같은 색이어야 합니다. 추가 prop: blockFill?: Record<number, string>.
  2. splitter block 의 multi-outline. 한 state 가 아니라 여러 state 를 동시에 splitter 로 강조해야 합니다. 추가 prop: outlineIds?: number[].
  3. 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

  1. Totalization 은 semantic decision 이고, sink 표시 여부는 rendering decision 입니다. Partial DFA 를 그대로 minimize 하려고 하면 transition 이 없는 칸을 매번 특수 처리해야 합니다. sink 하나를 추가하면 알고리즘 본체는 total DFA 를 상대로 깔끔하게 돌아갑니다. viewer 는 그 sink 를 보여줄지 숨길지 선택할 수 있지만, 계산 의미는 바뀌지 않습니다.

  2. "이미 작다" 와 "이미 최소다" 는 다릅니다. a* 의 source DFA 는 2 state 뿐이지만 1 state 로 줄어듭니다. 두 state 가 모두 accept 이고, 남은 모든 입력에 대해 같은 language 를 받아들이기 때문입니다. 최소 DFA 는 source DFA 보다 작아질 수도 있고, 그대로일 수도 있습니다. 기준은 state 수의 첫인상이 아니라 future behavior 입니다.

  3. machine contract 와 human narration 을 섞지 않습니다. Stage 4 에서도 같은 원칙을 썼지만, Stage 5 에서 더 중요해졌습니다. description 은 "splitter {D3} on c: predecessors split ..." 같은 사람용 문장이고, split: SplitEvent 는 같은 정보의 기계 구조입니다. 테스트는 구조를 읽고 caption 은 문장을 읽습니다. 문장만 다듬어도 테스트가 깨지는 구조를 피합니다.

  4. 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 의 커밋 세 개:

  • 8d4ee2e — stage 05: Hopcroft DFA minimization trace
  • df94a55 — artifacts: regenerate stage05
  • b13ef40 — stage 05: viz components (MinimizationViewer)
EOF 2026-04-21