一、图灵机(Turing Machine)
先前提到,为了近似估计算法的时间复杂度,首先得界定基本操作这一概念。设计算法时,使用的基本操作无非有这几种:读取变量,写入变量,根据变量值计算。因此,有必要抽象出一种计算模型,使得任何算法都能用这一模型中的基本操作组成。有了这种模型,我们不仅能为复杂度估计提供更为严谨的理论支撑,也能在讨论问题时脱离具体计算机的限制,让得到的结果更具普适性。
由于描述算法通常只需要读取、写入、运算这三种基本操作,因此可以引入一种模型,它只有一条“磁带”用来存变量,还有一个处理器用来完成基本操作。事实上,这便是最简单的一种图灵机——单带确定型图灵机(DTM)。
接下来,为了让这个模型在理论上更具价值,我们给出一系列关于DTM的严格定义。首先对使用的变量作出限制:
Definition 1-2.1:字母表
字母表为一个有限的符号集合,记作A。其元素的某一有限序列称为字符串,并将长度为 n n n的字符串全体记作 A n A^n An , 将字符串的全体 ⋃ n = 1 ∞ A n \bigcup\limits^{\infty}_{n=1}A^n n=1⋃∞An 记作 A ∗ A^* A∗ , 将 A ∗ A^* A∗的某一子集称为语言。
若定义特殊符号 _ 代表空格,则图灵机的变量被限制在 A ˉ = A ∪ { _ } 内 。 \bar{A}=A\cup\{\_\}内。 Aˉ=A∪{_}内。
这个定义不难理解。将涉及的变量约束在一定范围内,并结合通常意义的字符串、语言等概念,给出它们的严格定义。同样,我们将对”基本操作“给出定义:
Definition 1-2.2:指令集
由有限个指令按0,1,…,N编号排列所形成的集合。每一条指令能从当前所在的位置读取变量,并根据变量值,选择将其用另一个变量覆盖,同时向左或向右移动一位。定义-1为停机状态,当图灵机处理该条指令时,将立即终止运行。
初始状态下,执行指令0,所处位置编号为1。
通过指令集能完成读取变量、写入变量、得出运算结果等最基本的操作,因此可以单纯地使用指令集刻画每一个复杂的算法。然而,给出具体的指令集通常非常困难,因此图灵机在理论分析方面价值更大。
介绍了图灵机涉及的变量和操作后,我们将从最基本的DTM开始,逐步引出P,NP等问题类的定义。
二、单带图灵机与P类问题
顾名思义,单带图灵机只涉及一条“磁带”存储变量。这条”磁带“的长度无限,使用空格 _ 对大多数空余位置进行填充。使用更为专业的术语描述为:
Definition 1-2.3:单带图灵机
仅使用一条用于存储变量的存储带,记录当前位置的读写头与执行指令的有限状态控制器的图灵机模型被称为单带图灵机。
下图有助于我们更直观地理解单带图灵机的结构:
在单带图灵机上执行算法需要按一定顺序执行给定的指令,而指令执行的过程依赖于指令间相互跳转的规则,这一规则通常称为转换函数。
转换函数往往依算法而异,形式也不止一种。使用其中最简单的一类转换函数时,每执行一步后都有完全确定的下一步动作。使用这种转换函数的单带图灵机又称为单带确定型图灵机,简称DTM。
Definition 1-2.3:单带确定型图灵机
对于给定的单带图灵机,使用字母表为A,指令集为Q={-1,0,…,N},定义转换函数 Φ : ( Q − { − 1 } ) × A ˉ → Q × A ˉ × { − 1 , 0 , 1 } \Phi:(Q-\{-1\})\times\bar{A}\rightarrow Q \times \bar{A} \times\{-1,0,1\} Φ:(Q−{−1})×Aˉ→Q×Aˉ×{−1,0,1}
对于 x ∈ A ∗ x\in A^* x∈A∗ , Φ \Phi Φ 在输入 x x x上的计算结果为三元组序列 ( n ( i ) , s ( i ) , π ( i ) ) (n^{(i)},s^{(i)},\pi^{(i)}) (n(i),s(i),π(i)),分别代表程序第 i i i步执行的指令,存储的字符串和所在位置。对于程序的每一步,分下面两种情况:
1. 若 n ( i ) = − 1 n^{(i)}=-1 n(i)=−1,即当前指令为停机指令,则程序中止。此时定义程序 ( Φ , x ) (\Phi,x) (Φ,x)的运行时间为 i i i,输出序列为当前存储带上的串 c ∈ A k c \in A^k c∈Ak,串长 k = min { j ∈ N ∣ s j ( i ) = _ } − 1 k=\min\{j \in \mathbb{N} | s_j^{(i)}=\_\}-1 k=min{j∈N∣sj(i)=_}−1。
2. 若 n ( i ) ≥ 0 n^{(i)}\geq0 n(i)≥0,则将当前指令和当前位置 ( n ( i ) , s π ( i ) ( i ) ) (n^{(i)},s_{\pi^{(i)}}^{(i)}) (n(i),sπ(i)(i))输入转换函数,得到三元组 ( ξ , η , ζ ) (\xi,\eta,\zeta) (ξ,η,ζ),并令第 i + 1 i+1 i+1步的状态为: n ( i + 1 ) : = ξ n^{(i+1)}:=\xi n(i+1):=ξ , s π ( i ) ( i + 1 ) : = η s_{\pi^{(i)}}^{(i+1)}:=\eta sπ(i)(i+1):=η , π ( i + 1 ) : = π ( i ) + ζ \pi^{(i+1)}:=\pi^{(i)}+\zeta π(i+1):=π(i)+ζ。
看起来很复杂,实际上…确实挺复杂的,关键在于把握转换函数的本质。输出的结果仅由两个因素决定:当前执行哪条指令,当前处理什么变量。只要给出这两个信息,转换函数就能告诉我们下一步执行哪条指令,用什么变量覆盖当前位置,以及下一步读写头应该左移、右移还是保持不动。对应着看,就不难理解转换函数
Φ
\Phi
Φ 中二元组、三元组的意义了。假设对上一例中定义
Φ
(
0
,
2
)
=
(
−
1
,
5
,
−
1
)
\Phi(0,2)=(-1,5,-1)
Φ(0,2)=(−1,5,−1),则执行0号指令后,图灵机会变成下图这个样子:
由于下一步就停机了,所以该图灵机的输出就是265144w。为了表述方便,可以使用行、列分别为字符和指令序号的表格,来表示完整的指令序列,例如:本文最后给出的那张表格,就代表了一个图灵机程序。
给出DTM的确切定义后,理论上我们就可以用它来表示所有确定型算法。所谓的确定型,就是指程序的输出结果完全由DTM程序唯一决定:执行完一步后,下一步的状态都是完全确定的,无论运行多少次,都会得到一样的结果。
借助DTM,我们可以用上面定义的运行时间来界定算法的复杂度。由简单的数学知识,在
n
n
n迅速增加时,
O
(
2
n
)
O(2^{n})
O(2n)与
O
(
n
!
)
O(n!)
O(n!)的算法运行时间显著提升,而
O
(
n
3
)
O(n^{3})
O(n3)的算法增幅就不那么明显。现实生活中,我们也总是希望得到复杂度具有多项式上界的算法,因此有必要对多项式时间给出定义:
Definition 1-2.3:多项式时间图灵机
若存在多项式 p ( n ) = ∑ i = 0 k a i ⋅ n i p(n)=\sum\limits^{k}_{i=0}a_i\cdot n^i p(n)=i=0∑kai⋅ni,使得DTM程序 ( Φ , s ) (\Phi,s) (Φ,s)的运行时间 T ( n ) ≤ p ( n ) T(n)\leq p(n) T(n)≤p(n)恒成立(其中 n n n 为输入串 s s s 的长度),则称该DTM为多项式时间图灵机。
显然,具有多项式上界的算法比指数型、阶乘型等复杂度增长较快的算法性能更优。因此,如果一个问题能找到多项式时间的算法,那么这个问题相对“简单”;如果某个问题除了暴搜和枚举之外很难找到其它更好的算法,那么这个问题相对“困难”。
我们把那类已有多项式时间算法的问题归为P类,具体定义如下:
Definition 1-2.4:P类(Polynomial)
所有能用多项式时间的确定型图灵机解决的判定问题组成之类称为P。
大多数的复杂性理论皆基于判定问题,其定义将会在后面给出。直观上看,P类问题拥有多项式时间的算法,因此相对后面介绍的NP类问题会更加容易。
三、判定问题与问题核验
先前提到,复杂性理论大多基于判定问题。类比于我们熟悉的判断题,判定问题通常只涉及两种输出:Yes/No,而这两种回答将原问题自然地划分为两个部分。而算法的目标则是,给定输入字符串,只需要回答Yes还是No就行了。
若将每个算法的输入都按一定的编码规则转录为二进制字符串,我们就可以给判定问题一个严谨的定义:
Definition 1-2.5 判定问题
定义判定问题为一个数对 Ψ = ( X , Y ) \Psi=(X,Y) Ψ=(X,Y),其中X是语言, Y ⊆ X Y\subseteq X Y⊆X.对于 ∀ \forall ∀ x ∈ X x \in X x∈X (称为问题 Ψ \Psi Ψ 的实例),若 x ∈ Y x \in Y x∈Y,则称 x x x 为肯定实例,否则称 x x x 为否定实例。
判定问题的解定义为映射 f : X → { 0 , 1 } f:X \rightarrow\{0,1\} f:X→{0,1},对任意实例 x x x, f ( x ) = { 1 , x ∈ Y 0 , x ∉ Y f(x)=\left\{ \begin{aligned} 1, x \in Y\\ 0,x \notin Y \\ \end{aligned} \right. f(x)={1,x∈Y0,x∈/Y
自然,求解判定问题的算法就是寻找这样的映射函数,使其能对给出的每一个实例都能作出正确的判断。
判定问题的求解通常比与之对应的优化问题简单,下面以一个具体例子说明:
问题 简单的 01 背包问题.
给定一个容量为
M
M
M 的背包以及
n
n
n 件物品,每件物品有确定的编号
i
∈
{
1
,
2
,
.
.
.
,
n
}
i \in \{1,2,...,n\}
i∈{1,2,...,n},对应的重量
v
[
i
]
v[i]
v[i] 以及价值
c
[
i
]
c[i]
c[i] 。需在装入物品总重不超过背包容量的前提下,将某些物体装入背包,以满足题目要求。
输入 背包容量
M
M
M,物品件数
n
n
n,以及每件物品的重量
v
[
i
]
v[i]
v[i] 和价值
c
[
i
]
c[i]
c[i] 。
输出 设选择的物品编号为
x
1
,
.
.
.
,
x
k
x_1,...,x_k
x1,...,xk。该问题有两种不同的目标:
优化问题:在
∑
i
=
1
k
v
[
x
i
]
≤
M
\sum\limits_{i=1}^{k}v[x_i]\leq M
i=1∑kv[xi]≤M 的前提下,最大化
∑
i
=
1
k
c
[
x
i
]
\sum\limits_{i=1}^{k}c[x_i]
i=1∑kc[xi]。
判定问题:对于给定常数
B
B
B ,是否存在一种方案,使得
∑
i
=
1
k
c
[
x
i
]
≥
B
\sum\limits_{i=1}^{k}c[x_i]\geq B
i=1∑kc[xi]≥B 成立?
非常明显的是,判定问题比优化问题更易求解。对于每一组可行解,如果价值总和
≥
B
\geq B
≥B ,就可以立即给判定问题一个肯定的答案,而优化问题总是需要枚举所有可行解(允许剪枝)。但是,给定可行解的过程中,两者用的是同一套算法,所以这套算法是否为多项式时间,与问题的提法无关。
解决01背包问题是每个OIer的基本功,状态转移方程非常简单,运行时间为
O
(
n
⋅
M
)
O(n\cdot M)
O(n⋅M)。但是它还是多项式时间不可解的,因为动态规划算法的时间复杂度还依赖背包容量
M
M
M,与我们定义的
T
(
n
)
T(n)
T(n) 为
n
n
n 的函数冲突了。在最后一个专题,还会对背包问题的近似算法提出专门的讨论,此处就不展开叙述了。
注意到,对于每一组可行解,我们马上可以判断它是不是判定问题的解。也就是说,如果有一个神算包
(
O
r
a
c
l
e
a
l
g
o
r
i
t
h
m
)
(Oracle \space algorithm)
(Oracle algorithm),每一次呼叫它,都可以马上得到一组可行解,那么后续的操作是肯定能用多项式算法完成的:只要把重量求和看看是否
≤
M
\leq M
≤M,再看看价值总和是否
≥
B
\geq B
≥B ,就能回答这个实例是肯定还是否定的。
这就让我们思考:既然这类问题很难找到多项式时间算法,能不能退而求其次,在给定一组解时,我们能很快验证它是否为肯定实例?这就引出了比P类稍弱的一类问题:NP类问题。
当然,本着严谨的传统,我们首先对之前的“验证”给出定义:
Definition 1-2.6 核对算法
对于判定问题 Ψ = ( X , Y ) \Psi=(X,Y) Ψ=(X,Y) ,若能找到P类问题 Ψ ′ = ( X ′ , Y ′ ) \Psi'=(X',Y') Ψ′=(X′,Y′) ,使得 X ′ : = { x # c ∣ x ∈ X } , Y = { y ∈ X ∣ ∃ c , y # c ∈ Y ′ } X':=\{x\#c\space |\space x \in X\},Y=\{y \in X\space |\space \exists c ,y\#c \in Y'\} X′:={x#c ∣ x∈X},Y={y∈X ∣ ∃c,y#c∈Y′} 其中字符串 c c c 称为 y y y 的证书,则称关于P类问题 Ψ ′ \Psi' Ψ′ 的算法为一个核对算法。
这里定义的
Ψ
′
\Psi'
Ψ′ 就代表先前提到的验证过程,而
X
′
,
Y
′
X',Y'
X′,Y′ 就是在原来判定问题的基础上,加上一个证书
c
c
c ,若
y
#
c
∈
Y
′
y\#c\in Y'
y#c∈Y′,则
c
c
c 证明了
y
∈
Y
y \in Y
y∈Y。
就这样,我们将问题拆分为两部分:猜测阶段和验证阶段。猜测阶段负责给出一组可行解,在讨论NP问题时,我们可以使用呼叫神算包的方法,跳过这一阶段;如果存在一种多项式时间的核验算法,那么验证阶段的问题可以划分为P类。这一特性刻画了NP问题的本质,但别着急,在给NP问题正式下定义前,我们先给它量身定制一款图灵机。
注意到猜测阶段内,神算包会给出任意一组可行解,这可以被认为是一步得出的。很自然地想到,只要给DTM加一步特殊操作,使得执行该指令时,能直接在存储带上放入一组可行解,那就可以用它模仿猜测和验证两个阶段了!
当然,放哪组可行解 完全看神算包的心情 是随机的,所以这个新的Turing机引入了某些不确定因素。为了与“确定型图灵机”相对,人们就把它命名为“非确定型图灵机”,凭借它,就可以为NP问题给出严谨定义。
四、非确定型图灵机与NP类问题
上面提到,我们只需要将“呼叫神算包”设置成一步基本操作,以此代表猜测阶段,那就可以依靠之前定义的确定型图灵机,为NP类问题下严谨定义了。若将该操作编号为-2,则定义非确定型图灵机(NDTM)如下:
Definition 1-2.7 非确定型图灵机
设 X ⊆ A ∗ X \subseteq A^* X⊆A∗,且对每一 x ∈ X x \in X x∈X,定义 f ( x ) ⊆ A ∗ f(x)\subseteq A^* f(x)⊆A∗为一非空语言。定义NDTM的转换函数为 Φ : { − 2 , − 1 , . . . , N } × A ˉ → { 0 , . . . , N } × A ˉ × { − 1 , 0 , 1 } \Phi:\{-2,-1,...,N\}\times \bar{A} \rightarrow \{0,...,N\}\times\bar{A}\times\{-1,0,1\} Φ:{−2,−1,...,N}×Aˉ→{0,...,N}×Aˉ×{−1,0,1} 仍使用 ( n ( i ) , s ( i ) , π ( i ) ) (n^{(i)},s^{(i)},\pi^{(i)}) (n(i),s(i),π(i))分别代表程序第 i i i步执行的指令,存储的字符串和所在位置。则当 n ≥ − 1 n\geq-1 n≥−1时,指令的执行与确定型图灵机一致; n = − 2 n=-2 n=−2 时,对某一 y ∈ f ( x ) y \in f(x) y∈f(x),图灵机将在存储带上直接写入完整的 y y y ,即 s j ( i + 1 ) = y j s_j^{(i+1)}=y_j sj(i+1)=yj , j = 1 , . . . , l e n ( y ) j=1,...,len(y) j=1,...,len(y),其余部分则保持不变。
上述定义不难理解,
f
(
x
)
f(x)
f(x) 就是神算包有可能猜出的可行解,而将写入可行解归为基本操作,就意味着对猜测阶段的耗时忽略不计。由于
y
∈
f
(
x
)
y \in f(x)
y∈f(x) 的选取并不随着当前状态唯一确定,因此这个图灵机相比DTM而言引入了一些不确定性。
NDTM只注重问题的验证阶段,因此能用多项式时间的NDTM程序表示的算法,也就能在多项式时间内给出验证结果。NP类问题的严谨定义不难给出:
Definition 1-2.8:NP类(Non-deterministic Polynomial)
所有能用多项式时间的非确定型图灵机解决的判定问题组成之类称为NP。
五、多项式规约与NPC问题
虽然上面定义的NP类问题很难,直至今日都未能找到多项式算法,但还是能找到它们之间的相互联系。有时我们会发现,某一个问题只要稍作推导,就能转化成另一个问题,比如二分图匹配就可以借助网络流解决;还有些问题的算法思想类似,比如使用蓝白点思想的Prim和Dijkstra算法,传递闭包Washall算法和最短路Floyd算法,等等…
如果我们已经解决了其中一个问题,并导出了一个算法函数,那么解决另一个与之相关的问题时,就能通过调用这个函数,大大降低程序设计的难度。不难发现,它们的算法复杂度之间也存在微妙联系:如果问题转化过程是多项式时间的,那么只要其中一个为P类,另一个也必定为P类。
这个问题转化的过程被命名为多项式规约。可以将它想象为一个多项式时间的神算包,每一次呼叫,都能在多项式时间内把问题转化好。下面是多项式规约的正式定义:
Definition 1-2.9:多项式规约
对于两个判定问题 Ψ 1 = ( X 1 , Y 1 ) \Psi_1=(X_1,Y_1) Ψ1=(X1,Y1) 与 Ψ 2 = ( X 2 , Y 2 ) \Psi_2=(X_2,Y_2) Ψ2=(X2,Y2) ,令 f : X 2 → { 0 , 1 } f:X_2 \rightarrow\{0,1\} f:X2→{0,1} 为 Ψ 2 \Psi_2 Ψ2的解。若对于问题 Ψ 1 \Psi_1 Ψ1,存在某多项式时间的神算包 X 1 → p ( n ) X 2 X_1\stackrel{p(n)}\rightarrow X_2 X1→p(n)X2,使得呼叫神算包后, f f f 同时为问题 Ψ 1 \Psi_1 Ψ1的解,则称 Ψ 1 \Psi_1 Ψ1多项式地规约到 Ψ 2 \Psi_2 Ψ2。
比较重要的一种多项式规约是多项式变换,NP完备理论正是基于其上。
Definition 1-2.10:多项式变换
对于两个判定问题 Ψ 1 = ( X 1 , Y 1 ) \Psi_1=(X_1,Y_1) Ψ1=(X1,Y1) 与 Ψ 2 = ( X 2 , Y 2 ) \Psi_2=(X_2,Y_2) Ψ2=(X2,Y2)。若存在能在多项式时间内算出的函数 f : X 1 → X 2 f:X_1 \rightarrow X_2 f:X1→X2,使得 ∀ x 1 ∈ Y 1 \forall x_1 \in Y_1 ∀x1∈Y1, f ( x 1 ) ∈ Y 2 f(x_1) \in Y_2 f(x1)∈Y2,且 ∀ x 1 ∈ X 1 − Y 1 \forall x_1 \in X_1-Y_1 ∀x1∈X1−Y1, f ( x 1 ) ∈ X 2 − Y 2 f(x_1) \in X_2-Y_2 f(x1)∈X2−Y2,则称 Ψ 1 \Psi_1 Ψ1多项式地变换到 Ψ 2 \Psi_2 Ψ2。
与多项式规约不大一样的是,原来只是理论上存在的神算包,在多项式变换里被具体成一个函数,使得肯定实例被变换为肯定实例,否定实例被变换为否定实例。这种特殊的规约形式有时也被称为Karp规约。
最后,我们先给出NP完备问题(NPC)的定义,而具体的存在性和证明技巧则在下一次内容继续讨论。
Definition 1-2.11:NP完备问题
对于NP问题 Ψ \Psi Ψ,若其它NP问题都可以多项式变换到 Ψ \Psi Ψ,则称判定问题 Φ \Phi Φ是NP完备的。
五、总结与思考
P类问题是多项式时间可解的,能用多项式时间确定型图灵机解决;NP类问题是多项式时间可验证的,能用多项式时间非确定型图灵机解决,这就是本次的主要内容。下面给出几个问题以供思考:
- 停机问题。仅给定图灵机的字母表A和指令序列Q,问能否在有限时间内达到停机状态?这个问题非常著名,也非常困难,现在还没啥解决方案。
- 多带型图灵机。我们的所有讨论都基于一条存储带的图灵机,那是否存在两条存储带和两个读写头的图灵机呢?事实上,定义双带图灵机的方法很简单,改一下转换函数就行: Φ : ( Q − { − 1 } ) × A 2 → Q × A 2 × { − 1 , 0 , 1 } 2 \Phi:(Q-\{-1\})\times A^2 \rightarrow Q \times A^2 \times \{-1,0,1\}^2 Φ:(Q−{−1})×A2→Q×A2×{−1,0,1}2其实,一台二磁带的图灵机可以通过单带图灵机来模拟,也就是说多条存储带是不必要的。思考一下,为什么?
- 图灵机的意义。为什么我们讨论P/NP要建立在图灵机的基础之上?理论上所有算法都能改写成图灵机的形式(Church的论题),而且图灵机规范了基本操作这一本来模棱两可的概念,使得讨论可以脱离具体的计算机而进行,因此在理论上,图灵机是非常有价值的。
- 神算包。通过上述讨论,我们发现,神算包其实就是一个非常理想化的模型,让我们能安心忽略次要矛盾(如:猜测过程),把握主要矛盾(如:验证过程),但是怎么实现神算包,我们根本不用考虑,把它想象成一个黑箱就行,呼叫一次马上给答案。请大家思考:神算包应该用在什么地方?是不是任意场合都可以随意呼叫神算包?
作为本次内容的结尾,我会给出一段用Python实现单带图灵机的小程序,字母表默认使用 { 0 , 1 } \{0,1\} {0,1}。首先需要给定指令条数,再为每条指令设定相应的转换规则,最后给定输入,就可以得到输出。
#every command is decided by tuple q,x,delta. e.g.
#Command 0, char '0':3,_,-1
#using x=_ to describe blank.
n=int(input('The number of commands:'))
L=[[0,0,0] for _ in range(n)]
for x in range(n):
L[x][0]=input("Command %d, char '0':"%(x)).split(',')
L[x][1]=input("Command %d, char '1':"%(x)).split(',')
L[x][2]=input("Command %d, char '_':"%(x)).split(',')
command,pos=0,2001
s=['_' for _ in range(4001)]
In=input('The input string:')
for x in range(len(In)):
s[2001+x]=In[x]
while command>-1:
if s[pos]=='_':
ch=2
elif s[pos]=='1':
ch=1
elif s[pos]=='0':
ch=0
s[pos]=L[command][ch][1]
pos+=int(L[command][ch][2])
command=int(L[command][ch][0])
last=4000
while(s[last]=='_'):
last-=1
Out=""
for x in s[2001:last+1]:
Out=Out+x
print("Output: %s"%(Out))
大家可以用下面这个图灵机试试看。你会发现,这个图灵机除了把末尾数字0改1,1改0外貌似没有别的啥功能。想一想,能不能把它改造成一个判断奇偶性的算法呢?
Q\S | 0 | 1 | _ |
---|---|---|---|
0 | 0,0,1 | 0,1,1 | 1,_,-1 |
1 | 2,0,0 | 2,1,0 | 2,_,0 |
2 | -1,1,0 | -1,0,0 | 2,_,0 |