正则表达式描述一个字符串集合,有限自动机判断输入是否属于这个集合。词法分析器在此基础上还要回答:从当前位置开始,应该截取多长、归为哪一种 token?理解这三个问题的区别,才能把自动机理论用到真实词法器中。
正则语言:能记住有限种情况
连接 ab、交替 a|b、重复 a* 加上空串与空语言,构成经典正则表达式的基本运算。字符类和 +、? 等通常是便利写法。例如 [0-9]+ 表示一个或更多十进制数字。
有限自动机的“有限”指状态数量固定,不随输入增长。它可以记住当前数字是否已经出现小数点、字符串是否以某个固定后缀结尾;一般不能靠固定状态数记住任意深度的括号嵌套。嵌套注释等语言规则可以由词法器附加计数器处理,但这已超出纯有限自动机。
现代正则库的语法不全等于经典正则语言。捕获组记录匹配位置,本身不使语言超出正则范围;反向引用要求再次匹配先前任意长度的内容,则可能描述非正则语言。 RE2 支持捕获组,但不支持反向引用。RE2 语法
NFA:并行保留所有仍可能成功的路径
NFA 允许同一状态在同一字符上走向多个状态,也可允许不消费字符的 ε 转移。输入被接受的条件是存在一条接受路径,不要求所有路径都成功。
Thompson 构造把每个正则子表达式变成带入口、出口的片段,再组合片段:连接把前一个出口接到后一个入口;交替增加 ε 分叉与汇合;星号同时增加“跳过整个片段”和“从出口回到入口重复”的路径。缺少回边的图只能表示可选一次,不能表示任意重复。
对展开后的基本正则表达式,构造的状态数与表达式规模成正比。这个结论讨论的是自动机大小,不能用来直接比较回溯引擎的匹配时间。
NFA 模拟维护活跃状态集合,每读一个字符更新集合,并把 ε 能到达的状态补齐。每一轮对状态去重,可避免反复探索相同的“状态、输入位置”组合。设 NFA 有 m 个状态,输入长度为 n,简单 Thompson 模拟的时间界为 O(mn),活跃集合空间为 O(m)。固定模式时常简称对输入线性。Russ Cox:NFA 模拟
DFA:把一组 NFA 状态当成一个状态
DFA 对每个状态和输入符号只有一个下一状态。子集构造把“可能处于哪些 NFA 状态”提前编码成一个 DFA 状态。
图中读入 ab 后包含 q2,因此接受;若继续读 a,又回到集合 B,当前整串不再以 ab 结尾。状态表达的是读完整个当前前缀后仍需记住的信息。
子集构造的步骤是:
- 起始集合为
ε-closure({start}),闭包包含集合自身。
- 对一个集合 S 和字符 c,计算
T = ε-closure(move(S, c))。
- 若 T 尚无编号,分配新状态并放入工作队列;记录 S 在 c 上到 T 的转移。
- 直到没有新集合。包含任意 NFA 接受状态的集合标记为接受。
空集合可以表示显式死状态,也可以用缺失转移表示。实际实现常按字符等价类或区间处理,无须枚举所有 Unicode 码位。
若 NFA 有 m 个状态,潜在子集最多有 2^m 个;只生成可达集合仍可能指数增长。不能保证真实词法规则的 NFA 与 DFA 状态数相近。
一个直观例子是 (0|1)*1(0|1){k-1}:要求倒数第 k 个字符为 1。NFA 可以猜测哪个 1 是目标,DFA 则需要区分最近 k 位的组合;不同的 k 位后缀可用后续输入区分,因而需要至少 2^k 种状态。反之 (a*)* 与 a* 是同一语言,不是说明最小 DFA 指数增长的例子。
子集构造落地:把图中的三个集合全部算出来
图中的 NFA 不需要 ε 边,因此 move 后的集合本身就是下一集合。下面既直接模拟 NFA,又构造可达 DFA,最后对字母表 {a,b} 上长度 0 到 6 的全部 127 个串比较结果。
from collections import deque
from itertools import product
edges = {(0, "a"): {0, 1}, (0, "b"): {0}, (1, "b"): {2}}
start, accept = frozenset({0}), {2}
def move(states, char):
return frozenset().union(*(edges.get((q, char), set()) for q in states))
numbers, subsets, transitions = {start: 0}, [start], {}
work = deque([start])
while work:
current = work.popleft()
for char in "ab":
target = move(current, char)
if target not in numbers:
numbers[target] = len(subsets)
subsets.append(target)
work.append(target)
transitions[numbers[current], char] = numbers[target]
assert set(subsets) == {frozenset({0}), frozenset({0, 1}), frozenset({0, 2})}
for length in range(7):
for letters in product("ab", repeat=length):
text = "".join(letters)
nfa, dfa = start, 0
for char in text:
nfa = move(nfa, char)
dfa = transitions[dfa, char]
assert bool(nfa & accept) == bool(subsets[dfa] & accept) == text.endswith("ab")
| 当前 DFA 集合 | 输入 a | 输入 b | 当前是否接受 |
| A={0} | B | A | 否 |
| B={0,1} | B | C | 否 |
| C={0,2} | B | A | 是 |
转移表展示了为什么接受状态不是“永远成功”:C 读 a 后回 B,读 b 后回 A。这里判断的是整个输入是否以 ab 结尾;若需求是搜索任意位置出现的 ab,接受后的控制逻辑或自动机就要另行定义。
加入 ε 边后,起点与每次 move 的结果都要取闭包。下面的闭包模型允许 ε 环,通过 visited 集合保证每个状态只扩展一次:
def closure(seeds, epsilon):
seen, work = set(seeds), list(seeds)
while work:
state = work.pop()
for target in epsilon.get(state, ()):
if target not in seen:
seen.add(target)
work.append(target)
return frozenset(seen)
assert closure({0}, {0: {1}, 1: {0, 2}}) == frozenset({0, 1, 2})
assert closure({7}, {}) == frozenset({7})
assert closure(set(), {0: {1}}) == frozenset()
一个 DFA 状态,装着哪些 NFA 状态?
这里最容易混淆的是“只走一条路径”和“只保存一个集合名字”。下面把同一次前缀扫描的两种表示放在一起:NFA 保留所有可能状态,DFA 用 A、B、C 命名整个集合。直接改输入或选取前缀,可以检验两者的接受结果始终一致。读完 ab 后再读 a,接受状态也会离开;“已经接受过”不是永久接受。
Sk+1=ε-closure(move(Sk,ck))
正在呈现知识画面
一个 DFA 状态,装着哪些 NFA 状态?读字符时更新的是整个活跃集合。q0 始终保留;读到 a 增加 q1,紧接 b 才能到 q2。DFA 只是给这些集合起名字,接受状态也可能在继续输入后离开。
{"id":"automaton-state-subsets","title":"一个 DFA 状态,装着哪些 NFA 状态?","summary":"读字符时更新的是整个活跃集合。q0 始终保留;读到 a 增加 q1,紧接 b 才能到 q2。DFA 只是给这些集合起名字,接受状态也可能在继续输入后离开。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label class=\"form-field\"><span data-i18n=\"input\"></span><input type=\"text\" id=\"input\" value=\"aabab\" maxlength=\"8\" autocomplete=\"off\" spellcheck=\"false\"></label><div class=\"control\"><label for=\"prefix\" data-i18n=\"prefix\"></label><output for=\"prefix\" id=\"prefix-value\"></output><input id=\"prefix\" type=\"range\" min=\"0\" max=\"5\" value=\"5\" step=\"1\"></div><div id=\"chars\" class=\"track\"></div><svg id=\"nfa\" class=\"diagram\" viewBox=\"0 0 360 180\" role=\"img\"></svg><div class=\"metrics\"><div><span data-i18n=\"active\"></span><strong id=\"active\"></strong></div><div><span data-i18n=\"dfa\"></span><strong id=\"dfa\"></strong></div></div><div id=\"transitions\" class=\"table\"></div><p id=\"equation\" class=\"formula\"></p><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"accept\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"reject\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"invalid\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.metrics strong{font-size:19px}.table .tr{grid-template-columns:repeat(3,minmax(0,1fr))}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function draw(){const src=$('#input').value,valid=/^[ab]{0,8}$/.test(src);$('#prefix').max=src.length;const n=Math.min(src.length,+$('#prefix').value);set('prefix-value',n);let S=new Set([0]),prev=S;for(const c of src.slice(0,n)){prev=S;const next=new Set([0]);if(c==='a'&&S.has(0))next.add(1);if(c==='b'&&S.has(1))next.add(2);S=next;}cells('chars',Array.from({length:8},(_,i)=>({text:src[i]||'·',cls:i<n?'on':'empty'})));graph('nfa',[0,1,2].map((i)=>({x:55+i*125,y:80,name:'q'+i,cls:valid&&S.has(i)?'on':'',sub:i===2?'accept':''})),[[0,1,n>0&&src[n-1]==='a'],[1,2,n>0&&src[n-1]==='b'&&prev.has(1)]]);$('#nfa').insertAdjacentHTML('beforeend','<path class=\"edge '+(valid&&n>0?'on':'')+'\" d=\"M37 63 C0 5 110 5 73 63\" marker-end=\"url(#arrow-nfa)\"/><text x=\"55\" y=\"14\" text-anchor=\"middle\">a,b</text><text x=\"117\" y=\"70\" text-anchor=\"middle\">a</text><text x=\"242\" y=\"70\" text-anchor=\"middle\">b</text>');table('transitions',[['q0 → q0','a, b','↺'],['q0 → q1','a','→'],['q1 → q2','b','→']]);set('active',valid?fmt([...S].map(i=>'q'+i)):'—');set('dfa',valid?(S.has(2)?'C = {q0,q2}':S.has(1)?'B = {q0,q1}':'A = {q0}'):'—');set('equation',n?'move('+fmt([...prev])+', '+src[n-1]+') = '+fmt([...S]):'S₀ = {0}');set('explanation',t(!valid?'invalid':S.has(2)?'accept':'reject'));$('#dfa').dataset.states=valid?[...S].join(','):'';}$('#input').oninput=()=>{$('#prefix').max=$('#input').value.length;$('#prefix').value=$('#input').value.length;draw()};$('#prefix').oninput=draw;draw();","audio":false,"strings":{"accept":"当前前缀以 ab 结尾。继续读入仍会改变状态。","active":"NFA 活跃集合","dfa":"对应 DFA 状态","heading":"一个 DFA 状态,装着哪些 NFA 状态?","input":"输入串","intro":"输入 a、b 或直接选择前缀长度。上方保留当前所有可能状态,下方显示同一集合的 DFA 名字。","invalid":"这里只接受 a 和 b;请修改输入。","prefix":"已读前缀长度","reject":"当前前缀不以 ab 结尾。未读部分尚未参与判断。","reset":"重置","scope":"识别整个字符串是否以 ab 结尾,字母表仅 a/b,最多 8 字符。本 NFA 没有 ε 边;闭包因此不增加状态。不是子串搜索。","scopeLabel":"模型范围与约定"}}
最小化:只有未来行为相同才可合并
两个 DFA 状态等价,是指从它们出发,任何后续字符串都得到相同的接受结果。最小化先按输出分类,再不断拆开未来行为不同的状态。
普通识别器可先分接受与非接受。词法器必须进一步区分接受动作:Identifier 与 Number 即使都是接受,也不能因为只看布尔接受性而合并,否则会丢掉 token 类型和规则优先级。
Hopcroft 算法用分割器及其转移前驱细化分区,并通过工作队列和较小子块策略控制重复处理。只写“反复扫描直到不再变化”是一般划分细化,并不足以证明 Hopcroft 的复杂度。对 n 个状态、字母表大小 k 的常见表述是 O(kn log n);固定字母表时简写 O(n log n)。这也不是对所有计算模型宣称统一最优下界。
最小状态数不等于最快机器码。分支布局、表大小、缓存命中和向量化也影响吞吐;是否最小化及具体生成形式由工具与配置决定。
最长匹配与规则优先级
把多条 token 规则组成识别器后,一次扫描要保留最近接受位置及接受动作,直到不能继续,再回到该位置输出 token。若一直没有接受位置,则进入词法错误处理。
| 输入前缀 | 可能的规则 | 结果 |
if 后接空格 | 关键字、标识符都匹配 2 字符 | 按等长优先级选关键字 |
ifx 后接空格 | 关键字匹配 2,标识符匹配 3 | 标识符更长,优先级不能让 if 抢先 |
== | = 与 == | 选择长度 2 的运算符 |
re2c 的规则选择就是先比较匹配长度,再在等长时选择较早规则。关键字也可以先按标识符扫描,再查表分类,两条路线都有效。re2c 手册
“单次 DFA 转移常数时间”也不自动保证整个最长匹配词法器线性。例如规则 a 与 a*b,输入为大量 a 后接 c:朴素扫描器每次为寻找 b 扫到尾部,再退回只接受一个 a,重复后可能达到平方级。需要结合规则的回退距离、实现的记忆化或其他扫描策略讨论整体复杂度。
量化回退代价:转移便宜,但同一字符可能被访问很多次
对规则 a 与 ab,输入 aaaaac。从位置 0 开始,为尝试更长的 ab 读过 5 个 a,直到 c 才确认不能完成;最近接受位置只允许输出一个 a。下一轮从位置 1 重来,又读 4 个 a,依此类推。仅 a 上的推进就有 5+4+3+2+1=15 次。
def rescans(n):
source = "a" * n + "c"
visits = 0
for start in range(n):
cursor = start
while source[cursor] == "a":
visits += 1
cursor += 1
return visits
assert rescans(5) == 15
for n in (1, 10, 100):
assert rescans(n) == n * (n + 1) // 2
这里单独统计成功跨过 a 的次数,不含每轮在 c 上失败的检查。二次项已经来自重复后缀扫描,不必把每次状态转移本身变成昂贵操作。改善需要约束规则回退、记录失败后缀信息或采用适当扫描算法;不能把“使用 DFA”当作整个 token 流处理自动线性的证明。
转移很便宜,为什么扫描仍会变成平方级?
复杂度要数同一个输入位置被访问了几次。将一轮扫描画成一行,就能看到结尾 c 产生的三角形:第一行跨过 n 个 a,下一行跨过 n−1 个,最后只跨过一个。结尾换成 b 后,规则 a*b 一次覆盖整串,不再重复扫描后缀。图只统计 a 上的推进,错误字符 c 的处理另算。
W(n)=i=1∑ni=2n(n+1)
正在呈现知识画面
转移很便宜,为什么扫描仍会变成平方级?遇到结尾 c 时,每次只能退回输出一个 a,却会重复扫描剩余后缀。n 个 a 的推进次数为 n(n+1)/2;结尾 b 则一次接受整个 token。
{"id":"maximal-munch-work","title":"转移很便宜,为什么扫描仍会变成平方级?","summary":"遇到结尾 c 时,每次只能退回输出一个 a,却会重复扫描剩余后缀。n 个 a 的推进次数为 n(n+1)/2;结尾 b 则一次接受整个 token。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"n\" data-i18n=\"n\"></label><output for=\"n\" id=\"n-value\"></output><input id=\"n\" type=\"range\" min=\"1\" max=\"12\" value=\"6\" step=\"1\"></div><div class=\"presets\" role=\"group\" data-i18n-label=\"ending\"><button data-mode=\"c\" data-i18n=\"bad\"></button><button data-mode=\"b\" data-i18n=\"good\"></button></div><p id=\"source\" class=\"source\"></p><div id=\"workgrid\"></div><div class=\"metrics\"><div><span data-i18n=\"work\"></span><strong id=\"work\"></strong></div><div><span data-i18n=\"tokens\"></span><strong id=\"tokens\"></strong></div></div><p id=\"equation\" class=\"formula\"></p><p class=\"hint\" data-i18n=\"hint\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n#workgrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px}#workgrid i{height:12px;background:var(--surface);border-radius:2px}#workgrid i.on{background:var(--second)}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}let ending='c';function draw(){const n=+$('#n').value;set('n-value',n);set('source','a'.repeat(n)+ending);$('#workgrid').replaceChildren(...Array.from({length:144},(_,i)=>{const e=document.createElement('i'),row=Math.floor(i/12),col=i%12;e.className=(ending==='c'?row<n&&col>=row&&col<n:row===0&&col<n)?'on':'';return e}));const work=ending==='c'?n*(n+1)/2:n;set('work',work);set('tokens',ending==='c'?n:1);set('equation',ending==='c'?'1 + … + '+n+' = '+n+'×'+(n+1)+'/2 = '+work:n+' = '+work);pressed(ending);}$('#n').oninput=draw;document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{ending=b.dataset.mode;draw()});draw();","audio":false,"strings":{"bad":"结尾 c:逐次回退","ending":"末尾字符","good":"结尾 b:完整接受","heading":"转移很便宜,为什么扫描仍会变成平方级?","hint":"每行是一轮 token 扫描,每列是同一个输入位置;灰格未访问。","intro":"规则只有 a 与 a*b。改变连续 a 的数量和最后一个字符,金色格表示一次真正跨过 a 的扫描。","n":"a 的数量 n","reset":"重置","scope":"朴素最长匹配扫描器;只统计成功跨过 a,不含终止字符检查、构建自动机或优化扫描策略。最多 n=12。","scopeLabel":"模型范围与约定","tokens":"已接受 token 数","work":"跨过 a 的总次数"}}
自动机与回溯引擎怎么比较
| 方法 | 保留的信息 | 主要成本 |
| 回溯搜索 | 路径选择栈及捕获状态 | 某些模式、失败输入产生大量重复探索 |
| Thompson NFA 模拟 | 当前活跃状态集合 | 每字符更新集合,模式规模影响常数 |
| 完整 DFA | 已枚举的集合状态与转移 | 扫描直接,但编译期与表空间可能很大 |
| 惰性 DFA | 按需构造并缓存状态 | 缓存容量、驱逐与重建成本 |
真实库可能组合多种策略。捕获位置和贪婪消歧需要额外信息,但并不迫使实现采用指数回溯。对不可信模式还应考虑编译内存限制;对不可信输入则需考虑匹配工作量,二者不能混为一个“线性安全”的标签。
继续阅读
词法器设计 将这些规则落到 token、位置、缓冲与错误恢复接口;递归下降与 LL 分析 继续处理 token 之间的嵌套结构。
Keywords: regular language, Thompson construction, NFA, DFA, subset construction, epsilon closure, Hopcroft, maximal munch, capture, backreference, ReDoS