什么是有限狀態機(Finite State Machine)?
什么是確定性有限狀態機(deterministic finite automaton, DFA )?
什么是非確定性有限狀態機(nondeterministic finite automaton, NDFA, NFA)?
[1] wiki-en: Finite state machine
[2] wiki-zh-cn: Finite state machine
[3] brilliant: finite-state-machines
上面的這3個地址里的介紹已經寫的很好了。第3個地址的是一個簡約的FSM介紹,比較實用,而且基本上能讓你區分清楚DFA和NFA。但是本文還是嘗試比較清楚的梳理清楚它們之間的來龍去脈。
0x02 Deterministic finite automaton, DFA
簡單說,DFA包含的是5個重要的部分:
- \(Q\) = 一個有限狀態集合
- \(\Sigma\) = 一個有限的非空輸入字符集
- \(\delta\) = 一系列的變換函數
- \(q_0\) = 開始狀態
- \(F\) = 接受狀態集合
在有限狀態機的圖里面,有幾個約定:
- 一個圓圈表示非接受狀態
- 兩個圓圈表示接受狀態
- 箭頭表示狀態轉移
- 箭頭上的字符表示輸入字符
例如下面兩個DFA的圖示:
DFA圖1([3]):
https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/DFAexample.svg/700px-DFAexample.svg.png
DFA圖2:
https://swtch.com/~rsc/regexp/fig0.png
DFA的特征是,每個狀態,輸入一個新字符,都有一個唯一的輸出狀態。
例如,DFA圖1和圖2的每個\(S_i\)在遇到0或者1時輸出的狀態時唯一的。
在DFA圖1中,可以詳細看下買個參數是什么([3]):
- Q = \(\{s_1, s_2\}\)
- \(\Sigma\) = {0,1}
- \(q_0\) = \(s_1\)
- F = {\(s_1\)}
特別的,我們看下轉換函數集合,實際上可以用一個表格來表示([3]):
當前狀態 | 輸入狀態 | 輸出狀態 |
---|---|---|
s1 | 1 | s1 |
s1 | 0 | s2 |
s2 | 1 | s2 |
s2 | 0 | s1 |
0x03 Nondeterministic finite automaton, NDFA, NFA
那么,NFA和DFA的區別是什么呢?下面兩個NFA的圖示:
NFA圖1:
https://ds055uzetaobb.cloudfront.net/brioche/uploads/zgipUhyx8b-ndfa2.png?width=2400
NFA圖2:
https://swtch.com/~rsc/regexp/fig2.png
NFA的特征是,和DFA相比:
- 每個狀態,輸入一個新字符,可能有多個不同的輸出狀態。
- 每個狀態,可以接受空輸入字符,用符號\(\epsilon\)表示。
例如NFA圖2里,s2接受輸入字符b之后,可能是s1,也可能是s3。而在NFA圖1里,初始字符可以接受空輸入\(\epsilon\),不消耗任何字符,轉換為b或者e狀態,並且還是個多路分支。
0x04 Regular Expression
[4] wiki-en: Regex Expression
[5] wiki-zh-cn: Regex Expression
[6] Regular Expression Matching Can Be Simple And Fast
正則表達式和DFA/NFA的關系是什么?我們先看看正則表達式本身。[4]和[5]的wiki里列出了很多正則的表達式符號,但是不如文章[6]簡潔實用。
首先,任何通配符都必須有逃逸字符。正則表達式的逃逸字符是\
,例如\+
不表示通配符,而表示的是匹配+
字符。
其次,實際上根據[6],正則表達式最重要的通配符就是三個:
e*
表示0個或多個ee+
表示1個或多個ee?
表示0個或1個e
最后,根據[6],正則表達式最基礎的組合方式也就是三個:
e1e2
表示e1和e2的拼接e1|e2
表示e1或者e2e1(e2e3)
表示分組,括號里的優先級更高,和括號在四則運算表達式里的作用一樣
這里特別提一下,如果上述里的e替換成了一個集合,那么e*
會變成{e1,e2}*
,這個叫做集合{e1,e2}
的克林閉包(Kleene closure, Kleene operator, Kleene star),下面的兩個wiki介紹了它們的定義:
[7] wiki-en: Kleene closure
[8] wiki-zh-cn: 克林閉包
它的定義是遞歸方式的,令目標集合是V:
- $V_{0}={\epsilon }$
- \(V_1\) = V
- \(V_{i+1} = { wv : w ∈ V_i and v ∈ V } for each i > 0.\)
從而,V的克林閉包如下:
一個克林閉包的例子如下:
{"ab", "c"}* = {ε, "ab", "c", "abab", "abc", "cab", "cc", "ababab", "ababc", "abcab", "abcc", "cabab", "cabc", "ccab", "ccc", ...}.
從而,也可以定義克林正閉包(Kleene Plus):
一個克林正閉包的例子如下:
{"a", "b", "c"}+ = { "a", "b", "c", "aa", "ab", "ac", "ba", "bb", "bc", "ca", "cb", "cc", "aaa", "aab", ...}.
0x05 Regular Expression 2 NFA
根據文章[4],正則表達式的三個重要的通配符,可以通過如下的方式轉換為對應的NFA,這里用[s]表示非接受狀態,用[[s]] 表示接受狀態:
表達式 e
:
[s]--e-->
表達式 e1e2
:
[s0]--e1-->[s1]--e2-->
表達式 e1|e2
:
--e1-->
/
[s]
\
--e2-->
表達式 e?
,可以看到它等價於e|\(\epsilon\)
--e-->
/
[s]
\
------>
表達式 e*
,上半部分本來有輸出箭頭,但是既然它能立刻繞回去上一個狀態(轉N圈),就可以直接從下半部分的箭頭出去
--e--
/ |
[s] <---
\
------>
表達式 e+
,我們可以看成是它的等價形式ee*
,那么就是
--e--
/ |
--e-->[s] <---
\
------>
但是我們可以簡化下,把分支上半部分合並到左側,因為左側也是表示輸入e然后到狀態[s]:
----
↓ |
--e-->[s]
\
------>
有了這些基本的轉換規則,就可以把正則表達式轉換為NFA,這幾個圖最好自己動手畫一下,不動手可能還是沒有實際的感覺。
0x06 NFA 2 DFA
由於NFA的定義是DFA的超集,一個DFA可以直接看做是一個NFA。
那么,NFA是否可以轉化為DFA呢?
當然可以,很顯然,對比DFA和NFA的區別,有兩點要做到:
- 需要消滅所有的空輸入 \(\epsilon\)
- 需要合並那些同一個字符的多路分支為一個分支,為了這點,轉換后的DFA的每個狀態是由NFA的狀態構成的集合,例如{a,b}作為一個整體構成轉換后的DFA的一個狀態
[9] nfa-2-dfa example
我們先看一個實際的例子[9],直接手工體驗下這個轉換過程:
DFA圖3:
https://www.cs.odu.edu/~toida/nerzic/390teched/regular/fa/figures/nfa-dfa1.jpg
目標是找到NFA對應DFA的5個部分,有2個是現成的,剩下3個:
- 狀態集合Q
- x 輸入字符集合E,這個保持不變
- x 初始狀態q0,對應DFA的初始化狀態為 {q0}
- 轉移函數集合\(\delta\)
- 輸出狀態集合F
第1輪,考慮DFA里的Q第1個元素{}
- 初始化Q={}
- NFA的初始狀態是0,我們把
{0}
這個集合,作為一個元素,加入Q,從而:- Q={ {0} }
- NFA里,下一個輸入a后可以是狀態1,也可以是狀態2,也就是\(\delta\)(0, a) = {1, 2}。因此,對應的DFA里:
- Q={ {0},{1,2} }
- \(\delta\)({0}, a) = {1, 2}
- NFA里,\(\delta\)(0, b) = {},因此空集被加入到DFA的Q里:
- Q = { {},{0},{1,2} }
- \(\delta\)({0}, a) = {1, 2}, \(\delta\)({0}, b) = {}
第2輪,考慮DFA里Q的第2個元素{1,2}
- 此時{1,2}在DFA的Q里,考慮從{1,2}這個元素出發會到哪里
- NFA里,\(\delta\)(1, a) = {1, 2}, \(\delta\)(2, a) = {},從而
- DFA里新增 \(\delta\)({1,2}, a) = {1,2}, Q 則保持不動:
- Q = { {},{0},{1,2} }
- \(\delta\)({0}, a) = {1, 2}, \(\delta\)({0}, b) = {}, \(\delta\)({1,2}, a) = {1,2}
- DFA里新增 \(\delta\)({1,2}, a) = {1,2}, Q 則保持不動:
- NFA里\(\delta\)(1, b) = {}, \(\delta\)(2, b) = {1,3},從而
- DFA里新增 \(\delta\)({1,2}, b) = {1,3}, Q 新增{1,3}:
- Q = { {},{0},{1,2},{1,3} }
- \(\delta\)({0}, a) = {1, 2}, \(\delta\)({0}, b) = {}, \(\delta\)({1,2}, a) = {1,2}, \(\delta\)({1,2}, b) = {1,3}
- DFA里新增 \(\delta\)({1,2}, b) = {1,3}, Q 新增{1,3}:
第3輪,考慮DFA里Q的新增元素{1,3}
- NFA里,\(\delta\)(1, a) = {1, 2}, \(\delta\)(3, a) = {1, 2}
- DFA新增\(\delta\)({1,3}, a) = {1, 2}, Q 則保持不動
- Q = { {},{0},{1,2},{1,3} }
- \(\delta\)({0}, a) = {1, 2}, \(\delta\)({0}, b) = {}, \(\delta\)({1,2}, a) = {1,2}, \(\delta\)({1,2}, b) = {1,3}, \(\delta\)({1,3}, a) = {1, 2}
- DFA新增\(\delta\)({1,3}, a) = {1, 2}, Q 則保持不動
- NFA里,\(\delta\)(1, b) = {}, \(\delta\)(3, b) = {}
- DFA新增\(\delta\)({1,3}, b) = {}, Q 則保持不動
- Q = { {},{0},{1,2},{1,3} }
- \(\delta\)({0}, a) = {1, 2}, \(\delta\)({0}, b) = {}, \(\delta\)({1,2}, a) = {1,2}, \(\delta\)({1,2}, b) = {1,3}, \(\delta\)({1,3}, a) = {1, 2}, \(\delta\)({1,3}, b) = {}
- DFA新增\(\delta\)({1,3}, b) = {}, Q 則保持不動
- 沒有新的狀態,結束,由於0和1是NFA的接受狀態,Q里面有含有0和1的狀態是DFA的接受狀態,也就是F={ {0}, {1,2}, {1,3} }
至此,整個轉換結束,對應的DFA:
- 狀態集合:Q = { {},{0},{1,2},{1,3} }
- 轉移函數:\(\delta\)({0}, a) = {1, 2}, \(\delta\)({0}, b) = {}, \(\delta\)({1,2}, a) = {1,2}, \(\delta\)({1,2}, b) = {1,3}, \(\delta\)({1,3}, a) = {1, 2}, \(\delta\)({1,3}, b) = {}
- 輸出狀態集合:F={ {0}, {1,2}, {1,3} }
則轉換后的DFA如圖:
https://www.cs.odu.edu/~toida/nerzic/390teched/regular/fa/figures/dfa1.jpg
有了這個手工操作的經驗,上面這個例子里面,反復做一個動作:
- 得到一個新的DFA元素,例如{1,2}
- 考慮它接受一個輸入,例如b,分別
- 考慮狀態1接受b的轉移狀態集合,{}
- 考慮狀態2接受b的轉移狀態集合, {1,3}
- 因此,{1,2}接受b后,轉換到{1,3}
太啰嗦了,我們做一些簡化:
- 把轉換后的DFA的元素標記為大寫字母,例如T={1,2}, U={1,3};
- 把上面這個操作過程寫成一個函數:move(T,b)
- 那么上面這個過程就是:
move(T,b)=U
- 這個過程就是表示找到所有T里的元素在NFA里經過輸入b后能直接到達的狀態的集合U
進一步,如果在NFA里,某個s狀態經過空轉換\(\epsilon\)能到達的集合,我們標記為\(\epsilon\)-closure(s)。
例如:
-----> [1]
/
[0]
\
------> [2]
那么,\(\epsilon\)-closure(0) = {1,2}
進一步
---->[3]
/
-----> [1]----->[4]
/
[0]
\
------> [2]
那么,\(\epsilon\)-closure(0) = {1,2,3,4}
這么看來,\(\epsilon\)閉包是不是很形象。
有了\(\epsilon\)-closure(s),我們當然可以對DFA里的T的每個元素做\(\epsilon\)-closure,於是就可以定義:
- \(\epsilon\)-closure(T) = T里所有元素ti的\(\epsilon\)-closure(ti)的並集。
那么,我們上面手工操作move(T,b),之后,如果對應的NFA里也有\(\epsilon\),我們要達到最開始的轉換NFA到DFA的兩個目標之一:
- 需要消滅所有的空輸入 \(\epsilon\)
我們就需要對上面討論過的這個過程做升級:
- 找到所有T里的元素在NFA里經過輸入b后能直接到達的狀態的集合U
也就是去掉直接兩個字,升級成:
- 找到所有T里的元素在NFA里經過輸入b后能到達的狀態的集合U
實際上,通過上面的討論,經過燒腦,是可以理解到這個過程就是一個復合動作:
- \(\epsilon\)-closure(move(T,b))
於是,再經過燒腦,我們可以得到NFA轉換成DFA的算法:
算法: 子集構造法(subset construction):
- T0=\(\epsilon\)-closure(q0); DFAState={}, DFAState[T0]=false; DFATransitioin={};
- 其中q0是NFA的初始狀態
- 賦值為false,表示它還沒有被標記
- 開始循環
- 取出Q里的一個沒有標記的元素,例如T。DFAState[T]=true立刻標記它,表示處理過了。
- 如果都標記了,退出循環
- 對輸入的每個字符a
- 計算U=\(\epsilon\)-closure(move(T,a))
- 如果U不在DFAState里面,就加入:DFAState[U]=false;
- 加入轉換函數:DFATransitioin[T,a]=U
- 繼續循環
- 取出Q里的一個沒有標記的元素,例如T。DFAState[T]=true立刻標記它,表示處理過了。
從而,正則表達式可以轉成NFA,再進一步轉成DFA,實際上NFA轉成DFA最糟的情況是,原來NFA需要n個狀態,DFA需要\(2^n\)個狀態。
注:這是因為,由n個狀態構成的集合{s1,s2,...},它的所有子集組成的集合叫做冪集,冪集里的每個集合,都可能是DFA的狀態,而冪集里的集合的個數是\(2^n\)(可以計算下)。
為了加深印象,可以在這個在線工具里輸入正則表達式直接看到對應的NFA和DFA的結果:
[10] Regex => NFA => DFA - CyberZHG
0x07 Use State Machines
由於從Regex Expression到NFA到DFA,里面有一個地方是輸入是用字符串的字符表示。會讓人以為只有正則表達式需要DFA和NFA。
而實際上,我們可以在任何需要使用狀態轉換的地方用NFA和DFA。很自然的,需要考慮這些概念:
- 有哪些狀態?應該定義哪些狀態?例如一個操作最簡單的有Init/UnInit兩種狀態。
- 輸入是什么?程序里的輸入是「行為」,可能是用戶點擊,也可能是某個事件到達,在這些場景,你需要抽象這些輸入,可以看成不同的「字符」,也可以根據它們需要轉換的狀態,看成是同一個「字符」。
- 輸出是什么?當然是另外一個狀態了。
- 跟正則表達式什么關系?
- 看法1: 沒有關系,我們只關心狀態轉換是否是在允許的操作內,如果不是就是程序出現某種「未定義」行為,直接報錯。這是消除Bugly的良方。
- 看法2: 一個由某些輸入字符構成的字符串,表示了由UI操作、事件構成的操作序列,如果匹配,則表示這些操作集合是合法的,否則就是中間某個步驟是「未定義的」。
如何更好的寫一個DFA構成的狀態機代碼?這里有一個Unity3D框架里的狀態機的開發解釋,很清晰的構架:
[11] Unity3D里的FSM(Finite State Machine)有限狀態機
下面我們看一個例子,在實踐上,如何設計狀態機的轉換。
首先,經過考慮,設計一組狀態:
- S={INIT,STARTING, PLAYING, STOP, ERROR}
其次,考慮每個狀態可以到達哪些狀態:
- INIT -> [ STARTING ], 初始狀態可以到達開始中
- STARTING -> [PLAYING, ERROR],開始中狀態可以到達游玩中或者出錯
- PLAYING -> [STOP, ERROR], 游玩中可以到達停止或出錯
- ERROR -> [STOP],出錯狀態,做好出錯處理后停止
- STOP -> [INIT],結束狀態應該可以重置成初始化狀態
因此,考慮初始和停止狀態:
- 初始狀態:INIT
- 停止狀態集合:[STOP]
那么,可以逆向計算每個狀態允許的前置狀態集合(enableStates):
- INIT: [STOP]
- STARTING: [INIT]
- PLAYING: [STARTING]
- STOP: [PLAYING, ERROR]
- ERROR: [STARTING, PLAYING]
練習題1:在這個狀態轉換中, { Q, \(\Sigma\), \(\delta\), \(q_0\), F} 分別是什么?
練習題2:它是DFA,還是NFA?
練習題3:如果是NFA,它有空輸入轉換么?
練習題4:如果是NFA,試下轉成DFA?
練習題5:畫出NFA/DFA的轉換圖。
實踐中,我們會按需寫如下的狀態轉換函數,代碼只是示例:
function EnterState(toState, onPreEnter, onAction, onPostEnter){
const fromState = this.state;
if(enableStates[fromState].includes(toState)){
onPreEnter(fromState, toState);
this.state = toState;
onPreEnter(fromState, toState);
return true;
}else{
// log
return false;
}
}
實際上,如果考慮輸入字符后,可以做一個更完備的版本:
function enableToState(fromState, context){
// 把context轉換成抽象的字符
const c = convertToAplha(fromState, context);
// 根據fromState和c找到對應的可能輸出集合
const toState = DFATransitioin(fromState, c);
return toState;
}
function EnterState(toState, onPreEnter, onPostEnter){
const fromState = this.state;
if(enableToState(fromState, context).includes(toState)){
onPreEnter(fromState, toState);
this.state = toState;
onPostEnter(fromState, toState);
return true;
}else{
// log
return false;
}
}
0x08 Another Case
再來一個,考慮一個游戲點播系統,經過一些思考后設計如下的狀態,理論上任何兩個狀態之間都可以有轉換關系,實踐上我們要設計出「合理」的轉換,保證程序的「正確」,程序可以在任何兩個狀態之間轉換,但那不是我們需要的,我們需要在考慮合理性,設計出預期的允許轉換的關系。
- IDLE: 空閑狀態
- NEW_SESSION: 用戶掃碼付費后,獲得一個時間片,開始一個會話
- GAME_STARTING: 啟動一個游戲
- GAME_PLAYING: 啟動成功,游玩中
- NO_GAME: 沒有啟動游戲
- GAME_RESTARTING: 游戲重啟中
- WAIT_RENEWAL: 時間到,給一個90秒的等待續費時間
- TIME_OVER: 沒有續費,時間消耗完畢
這些狀態,如何設計出嚴密的轉換關系呢?簡單粗暴地,我們可以使用「表格」這種看似原始的方式:
表格,是的,程序里的很多復雜狀態轉換,如果你願意畫表格的話,是可以非常清楚地畫出它們之間的正確關系。實際上,表格背后的思想是向量的叉乘,例如一組狀態V1和另一組狀態V2,它們共同決定另結果狀態V3,那么所有的狀態組合就是V1 叉乘 V2。也就是笛卡爾乘積。多維度的情況也是這樣的,只是通常我們寫程序的時候不會以這種「向量」思維去考慮。但是如果你是在一個「向量化」思維為一等公民的編程語言里寫程序的話,就會習慣這種思維,例如R語言等。但即使我們不以向量化來寫代碼,也可以在分析時使用「向量化分析」。
從數學上來說,上述游戲狀態的自叉乘,得到的是一個「稀疏矩陣」。也就是有很多單元格是0的矩陣。表示這兩種狀態之間不會有任何「轉換函數」使得它們之間發生轉換。那么我們的狀態轉換里就可以對這類轉換嚴格給予拒絕,並報錯。程序如果出現問題,我們只要分析這些錯誤日志,就很容易定位出問題的原因。
注意,上述表格里,一個狀態S1能否轉換到另一個狀態S2,並不僅僅取決於S0和S1之間是否有一條路徑,還取決於S1之前的狀態是什么?例如對於WAIT_RENEAL來說,它要符合如下的「轉換模式」才允許:
// S1之前是S0,加上輸入字符是p(例如p代表續費,paid),才允許從S1再次回到S0
S0->S1->S0;
但是,僅僅只有這個模式還不能解決所有問題。在上面的表格里,從GAME_STARTING, GAME_RESTARTING進入到WAIT_RENEWAL之后,如果WAIT_RENEWAL的過程中,字符p(paid)先到,那么就直接使用上述的S0->S1->S0模式,否則如果出現游戲啟動成功先完成,假設用字符g(game live)代表這個行為,那么由於g比p先到,這個時候我們如果不能忽略字符g,:
- 本來期待收到字符g(game),使得轉換
S0---g--->S3
發生 - 但是由於時間耗完先發生,用字符z(zero time)表示,先發生了:
S0---z--->S1
- 此時,g(game live)來到,但是由於我們期待S1應該有一個90秒的時間片去獲得一個字符p(paid),此時應該瞬間做一個跳轉:
[S0]---z--->[S1]---g--->[S3]---(e)--->[S1]---p---->[S3]
注意到:這里面,S3到S1這個步驟,並沒有任何的輸入字符,所以這是一個空輸入字符。實際上,NEW_SESSION這里,根據是否有首發游戲,會選擇進入GAME_STARTING或者NO_GAME狀態。這里也並沒有一個合適的輸入字符,啟動游戲用的是s(start game),沒啟動游戲就是空輸入(e),直接進入NO_GAME狀態。完整的狀態轉換圖如下:
考慮狀態轉換圖的特征:
- WAIT_RENEWAL含有空輸入e的轉換路徑
- WAIT_RENEWAL在輸入p之后,可以到達多個分支(GAME_STATING, GAME_PLAYING, GAME_RESTARTING)
因此,這是一個NFA確定無疑。完整的5個要素如下:
- Q: {IDLE, NEW_SESSION, GAME_STARTING, GAME_PLAYING, GAME_RESTATING, NO_GAME, WAIT_RENEWAL, TIME_OVER}
- \(\Sigma\): {p, g, s, f,c, z1, z2, z3,e}
- \(\delta\): 見上面的表格
- \(q_0\): IDLE
- \(F\): IDLE
0x09 AutoMata Theory
實際上,具體實現時,使用了一個stack來保存狀態轉換的歷史,並且在處理WAIT_RENEAL的過程中,狀態轉換使用了改stack里前面轉換過的狀態做決策。如果發生start->wait_renewal,在wait_renwal的狀態下,start完成,具體實現利用stack做了簡化,直接修改歷史為start->playing->wait_renewal。因此已經不是嚴格意義的「有限狀態機」,因為有限狀態機有一個要求是每次只使用當前狀態和tansition函數來做狀態轉移,如果再利用了歷史狀態stack,並進行了push/pop,那么它算是一個「下推自動機」,也就是PushDown State Machine,簡單說就是給有限狀態機配上了「內存」的能力。有限狀態機和下推自動機都屬於自動機的一種,wiki上有具體的說明AutoMata Theory。下面這個圖說明了不同能力自動機的包含關系,這只是大類划分,具體細分此處不再展開。
下面這個圖展示了這些自動機之間更精細的能力包含關系:
簡單解釋下:
- Finite State Machine(FSM):DFA和NFA的能力是等價的。
- FSM可以處理的語言是正則表達式,也就是所謂的正則語言(Regular laguage)
- PushDown Automata(PDA)就是在FSM的基礎上增加了一個能夠記住歷史的Stack。因此PDA的能力比FSM強。
- PDA也分確定性和非確定性,取決於同樣的輸入字符,是否有分支輸出,以及是否能用空輸入。
- 非確定性PDA能力比確定性PDA強。
- PDA可以處理的是上下文無關語言(Context-free Language)
- 上面我們說的PDA只能使用一個Stack,嚴格描述是:PDA with one push down store
- 這個時候,它有一個缺點:如果從stack里pop一個狀態出來,我們就「忘記了」這個pop掉的狀態。
- 彌補這個缺點的方式是:再增加一個stack,這樣被pop出來的狀態可以push到另外一個stack里。需要查詢這些信息的時候,可以在兩個stack之間來回倒騰。
- 增加了外掛后,嚴格的名字就是:PDA with two push down store
- PDA with two push down store也分確定性和非確定性的,這倆的能力就一樣了。
- 實際上, PDA with two push down store的能力就已經等價於 Turing Machine 了。
- 當然,Turing Machine也有確定性和非確定性之分,再往下就是各種天馬行空的變種了,不必理會。
- Turing Machine就是假設紙帶是無限長,任意可讀寫。
- PDA with two push down store和Turing Machine可以處理的語言就是遞歸可枚舉語言: Recursively enumerable language
- 在此之間,比PDA能力更強的,比圖靈機弱一點的是一個叫做線性有界自動機: Linear bounded Automata, LBA
- 簡單說,LBA就是在圖靈機的基礎上,做了一些限制:
- Turing Machine的紙帶是可任意讀寫,但是LBA每次只能讀寫輸入字符的一個線性函數的有限部分數據。
- LBA可以處理的語言是上下文敏感語言:Context-sensitive language
於是,這樣我們就能看得懂上面這個不同自動機之間的包含關系到底是什么了。上面每一種自動機都對應一種形式語言,這些形式語言之間也就自然而然有了下面的包含關系:
這四種語言都屬於喬姆斯基形式語言分層(Chomsky hierarchy)里的一類,分別是:
- Type-0:遞歸可枚舉語言( Recursively enumerable language )
- Type-1:上下文敏感語言( Context-sensitive language )
- Type-2: 上下文無關語言( Context-free language )
- Type-3: 正規語言( Regular language)
喬姆斯基在1950年發明了喬姆斯基形式文法描述它們。再下去就屬於編譯原理相關的部分,不再展開。
0x0A Why Developers Never Use State Machines
根據實際需要,可以做的簡單,也可以做的細致,不同層度上保證程序的正確性。但是實際上,狀態機在網絡協議的開發中比較常見,例如經典的TCP狀態轉換圖:
[13] rfc-793:TRANSMISSION CONTROL PROTOCOL
有限狀態機很有用,可是為什么大部分程序員平常寫程序沒用到它呢?
[12] Why Developers Never Use State Machines
We seem to shy away from state machines due to misunderstanding of their complexity and/or an inability to quantify the benefits. But, there is less complexity than you would think and more benefits than you would expect as long you don’t try to retrofit a state machine after the fact. So next time you have an object that even hints at having a “status” field, just chuck a state machine in there, you’ll be glad you did.
這篇文章分析了可能的原因:「高估了它的復雜,以及低估了它的好處」,我覺的很有道理,特別是我發現在UI項目里使用嚴格的狀態機管理狀態后,程序的問題更容易被trace,也更能保證程序正確之后,我發現狀態機確實好用。
0x0B How using good theory leads to good programs?
而在這篇介紹Thompson NFA的文章里,作者的兩段話很有意思:
[6] Regular Expression Matching Can Be Simple And Fast
Historically, regular expressions are one of computer science's shining examples of how using good theory leads to good programs. They were originally developed by theorists as a simple computational model, but Ken Thompson introduced them to programmers in his implementation of the text editor QED for CTSS. Dennis Ritchie followed suit in his own implementation of QED, for GE-TSS. Thompson and Ritchie would go on to create Unix, and they brought regular expressions with them. By the late 1970s, regular expressions were a key feature of the Unix landscape, in tools such as ed, sed, grep, egrep, awk, and lex.
Today, regular expressions have also become a shining example of how ignoring good theory leads to bad programs. The regular expression implementations used by today's popular tools are significantly slower than the ones used in many of those thirty-year-old Unix tools.
這值得我們思考,程序是什么?
--end--