当我们提到“计算(calculation)”这个词时,指的是“回答一个数学问题”。这个数学问题可以是“求两个正整数的乘积”,“求出一个线性方程组的所有解”,“给一列数字排序”,“给定一个无向图求最大独立集的大小”等等。在计算这些问题的时候,可以有很多不同的方法。对于第一个问题,我们可以依据“乘法本质是加法”来计算, 例如\(3\times 5\)我们只需做\(5\)次\(+3\)操作;也可以用列竖式的方法,我们发现在数字比较大的时候这是比还原成加法更快的策略;还可以用快速傅里叶变换,这在数字非常大的时候是比列竖式还要快的方法。对于第二个问题,可以用高斯消元法,这在中国古代就已经发现了;现代人们还提出了比高斯消元更快的算法。由此可见,对于同一个问题有不同方法,不同的方法效率不同,流传千年的方法都还可能被改进。但是对于第三个问题,人们提出了一系列算法(快速排序、归并排序等),并证明这已经是效率最高的方法不可能再被改进了。对于第四个问题,人们发现除了暴力枚举所有的子集似乎不存在更快的算法了。由此可见,人们不仅可以提出效率更高的计算方法,还可以证明不存在更快的计算方法。讨论“计算的效率”的学科就是“计算复杂性理论(Computational Complexity Theory)”。
问题与计算(Problem & Computation)
为了讨论计算的效率,我们首先需要形式化定义“问题”和“计算”。
什么是一个“数学问题”呢?一个数学问题就是给定一些输入,要求给出一个确定的输出(答案)。输入和输出都可以编码为二进制串,所以我们可以这样定义“问题(problem)”:一个问题是一个函数\(f:\{0,1\}^\ast \to \{0,1\}^\ast\)。
那么,对一个问题\(f\)的“计算(computation)”就是对于每个合法的输入\(x\in \{0,1\}^\ast\),我们都要想尽办法求出答案\(f(x)\)。这里的“想尽办法”指的是物理世界所允许的所有办法,包括在纸上打草稿、用电路模拟等等。所以“计算”是一个含义非常广泛的概念,人们可以为它建立各种不同的模型,只要其中涉及的操作满足物理定律。
计算模型(The Computation Model)
图灵机(Turing Machine)
艾伦·图灵(Alan Turing)提出了以下计算模型,称为图灵机:
图灵机模型包含\(k\)条无限长的纸带,每条纸带上有一个可移动的读写头。可以假设每条纸带可以向左右无限延伸,纸带上的每一格可以标记符号(符号的种类必须是有限个,例如\(\{0,1\}\))。第一条纸带是只读的,初始时上面已经写好了输入串,称为输入带。其余纸带初始时为空,称为工作带(类比草稿纸)。图灵机运行时,可以改写当前每条纸带上的读写头指向的位置的符号;每个读写头进行完改写操作时,可以向左移动一格、向右移动一格或不动。所有读写头改写一次并移动一次的操作称为“运行一步”。在运行了有限步以后,如果最后一条纸带上的二进制串对应着问题的答案,计算就完成了。(所以,虽然我们假设了纸带是无限长的,但每次计算用到的纸带长度一定是有限的)
当输入为\(x\)时,如果采用一种策略使得图灵机运行\(n\)步以后能得到结果,就称存在一个算法计算\(f(x)\)的时间为\(n\)。计算\(f(x)\)所需要的时间往往是和符号串\(x\)的长度有关的。 如果存在一个算法,使得图灵机在给定输入\(x\)时总能在\(T(|x|)\)步内计算得到结果,就称算法计算\(f\)的时间复杂度(time complexity)为\(T\),其中\(|x|\)是二进制串\(x\)的长度,\(T\)是\(\mathbb{N}\to\mathbb{N}\)的函数。
通用图灵机(The Universal Turing Machine)
根据以上图灵机的定义可以看出,一台图灵机就相当于我们现在所说的“一个算法”或“一段计算机程序”。一个算法或一段程序就是在给定输入下会以特定的方式移动图灵机的读写头,做特定的读写, 最后给出特定的输出。例如,我们可以设计一个算法专门用来计算两个数的和,或专门用来求解线性方程组。换言之,一个特定的图灵机实现一个特定的算法功能,这个功能是通过对图灵机的编程(控制读写头的移动和读写)来实现的。
不同的图灵机被编写了不同的程序,纸带条数也可能不同,用来实现不同的计算功能。艾伦·图灵(Alan Turing)最早注意到,存在一台图灵机,它可以模拟任何图灵机的计算。也就说,对于任意一台图灵机(一个算法),我们都可以在一台特定的图灵机上编程,使得它能够模拟这台图灵机的计算。这台图灵机称为“通用图灵机”。
首先,我们意识到任何一台图灵机都可以编码为一个01串。这是显然的,因为我们已经可以用自然语言描述一台图灵机的算法,只需把精确的描述语言编码成01串。这只需把图灵机的各个状态满足的读写头移动的函数编写成二进制串,就可以让不同的图灵机对应不同的01串。反过来,我们可以让任何一个01串都对应一台图灵机,只需让所有不满足我们编码规则的01串全都对应到一台最简单的在任何输出下都直接停机输出0的图灵机上。这样,我们就可以让任何一个01串唯一对应一台图灵机。由于我们可以让01串与自然数一一对应,所以我们认为我们可以让每台图灵机与自然数一一对应。这样,我们就可以依次枚举世界上所有的图灵机,记为\(M_0,M_1,\cdots,M_n,\cdots\)。
如果存在通用图灵机,那么它满足:通用图灵机是上面列出的图灵机中的一个,它接收一个二元组\((x,\alpha)\)(对应的二进制串)作为输入,输出图灵机\(M_\alpha\)在输入\(x\)下的输出。下面我们证明确实能构造这样一台通用图灵机\(U\):\(U\)有3条纸带(1条输入带,2条工作带)。当它接收到输入\((x,\alpha)\)时,开始用第一条工作带模拟\(M_\alpha\)的计算。\(M_\alpha\)有\(k\)条工作带,我们把这\(k\)条工作带在读写头处对齐,然后压缩成一条纸带——压缩后的纸带后每个位置上变成了一个关于符号的\(k\)元组。很容易把每个\(k\)元组按照特定规则再次编码成01串,所以确实可以把这\(k\)条纸带的所有信息压缩在\(U\)的第一条工作带上。每当\(M_{\alpha}\)上的读写头执行了读写时,我们就在\(U\)的第一条工作带上对应地模拟这一修改;如果\(M_\alpha\)的读写头发生了移动,我们就遍历整条工作带进行修改,使得这些压缩后的纸带依然是在读写头处对齐的。 当\(M_\alpha\)给出了输出后,我们把输出誊写到\(U\)的第二条工作带上。这样我们就完成了模拟,可见通用图灵机是存在的。
然而我们注意到,每一次读写头的移动都会消耗通用图灵机用\(O(kT)\)的复杂度来模拟(\(T\)是\(M_\alpha\)的复杂度,它和纸带的有效长度是同一量级的)。这样一个\(O(T)\)的算法就需要\(O(T^2)\)的时间在通用图灵机上模拟。是否存在一个更好的模拟方法,能实现更好的模拟的复杂度呢?人们发现了一个巧妙的方法,可以在\(O(T\log T)\)的复杂度下实现模拟。我们在通用图灵机上引入冗余符号\(\times\)。对于(未压缩的)每条纸带,假设这条纸带上有\(s\)个有效字符,我们可以补充空字符\(\square\)使其长度恰好为\(2^t-1\)。以读写头为起点(\(0\))把这\(2^t-1\)依次向右排列,我们可以把纸带看作从\(0\)开始向左右延申的长度倍增的块(长度为1、长度为2、长度为4、……)。这样\(0\)及其右侧有\(t\)个块,左侧对称地也有\(t\)个块。在左侧的块中所有位置填上冗余字符\(\times\)。我们在读写头移动时,维护这样一个对称平衡的性质:如果右侧的块是满的(没有\(\times\)),那么左侧的块是空的 (全是\(\times\));如果右侧块是空的,那么左侧块是满的;如果右侧块恰好为一半,那么左侧块也恰好为一半;位置0始终不为空。初始时,纸带是满足对称平衡的。当纸带整体需要向左移动一格时,假设原来是对称平衡的,我们查看位置0左侧的第一个块,如果该块是空的或是只填了一半,那么用位置0的字符填入使得该块变成一半满或全满;加入该块已经满了,那么再考虑该块左边的块,如果它半满或为空则可以把右侧的块填入,否则再考虑它左边的块……依次类推,我们把位置0左移了一格并保持左侧的所有块都处于空、半满或全满的状态。接着只需按照对称平衡的原则重新分配右侧的字符即可。这样的模拟方法看似移动一次也最坏可能要\(O(T)\)的复杂度,但我们可以从整体分析:观察到,如果某次挪动向左右涉及到了第\(r\)个块,那么中间的\((r-1)\times 2\)个块一定是半满的。这就意味着当第\(r\)个块被移动以后,需要至少再经过\(2^r\)次读写头移动操作才会再次挪动第\(r\)个块。所以对于一个复杂度为\(O(T)\)的算法,第\(r\)个块最多被移动\(O(\dfrac{T}{2^r})\)次,移动一次的复杂度为\(O(2^r)\)。所以总体来看每个块被移动的复杂度为\(O(T)\),总共有\(O(\log T)\)个块,综上移动的总复杂度为\(O(T\log T)\)。
不可计算性(Uncomputability)
所以现在我们知道,任何一个计算过程(或一个程序)都可以在一台三条纸带的通用图灵机上完成。一个自然的问题是,是不是所有的问题\(f:\{0,1\}^\ast\to \{0,1\}^\ast\)都可以在这台图灵机上完成计算呢?这里的完成计算是指图灵机在每个输入上都能运行有限步之后停机并输出正确的答案。
在讨论通用图灵机的过程中,我们发现“图灵机”本身可以被编码为一个二进制串。这意味着一台图灵机可以作为一个“输入数据”传入另一台图灵机。利用这一特点,我们可以构造一些这样的\(f\)来说明,并不是所有问题都是图灵机可计算的,存在一些问题是图灵机不可计算的。
不可计算问题的构造和证明实数不可数、哥德尔不完备性定理很类似,都使用了对角线方法(diagonal method)。我们可以列一张表格,第\(i\)行第\(j\)列内填入图灵机\(M_i\)在输入\(j\)后的输出\(M_i(j)\)。我们可以利用这张表格的对角线构造一个问题:\(f(\alpha)=\left\{\begin{matrix}0,&M_\alpha(\alpha)=1\\1,&\text{otherwise}\end{matrix}\right.\)。假设这个问题可以用通用图灵机\(U\)来计算,假设通用图灵机对应着编号\(u\)的图灵机\(M_u\),那么\(M_u(u)=f(u)\)。而如果\(M_u(u)=1\),则\(f(u)=0\);如果\(M_u(u)\neq 1\),则\(f(u)=1\)。矛盾。所以不存在这样的\(U\)。这意味着\(f\)是不可计算的。
下面这个称为“停机问题(The Halting Problem)”的函数也是不可计算的:\(\text{HALT}(x,\alpha)=\left\{\begin{matrix}1,&\text{if }M_\alpha(x)\text{ halts}\\0,&\text{otherwise}\end{matrix}\right.\)。假设存在一台图灵机\(M_h\)可以计算停机问题,那么我们可以把上一段中的\(f\)等价地写作:\(g(\alpha)=\left\{\begin{matrix}0,&M_h(\alpha,\alpha)=1\land M_{\alpha}(\alpha)=1\\1,&\text{otherwise}\end{matrix}\right.\)。我们可以构造一台图灵机\(U\),输入\(\alpha\)时首先计算\(M_h(\alpha,\alpha)\)。若\(M_h(\alpha,\alpha)=1\),则\(M_\alpha(\alpha)\)会停机输出结果,我们能够判断\(M_\alpha(\alpha)\)是否等于1,是则输出0否则输出1;若\(M_h(\alpha,\alpha)\neq 1\),输出0。这样我们就得到\(f\)是可计算的,矛盾。因此\(M_h\)不存在,也即停机问题是图灵机不可计算的。
丘奇-图灵论题(Church-Turing Thesis)
除了图灵机以外,人们还提出了很多别的计算模型。例如,丘奇(Church)提出了\(\lambda\)-calculus,哥德尔提出了递归函数(recursive functions)。然而人们逐渐发现,这些不同的计算模型其实都是等价的,也即模型A能计算的函数都能用模型B来计算,模型B能计算的函数也都能用模型A来计算。图灵机不可计算的函数用其他计算模型也不可计算。因此,我们不必谈论“图灵机可计算”或“递归函数可计算”这样依赖于计算模型的概念,而可以直接谈论“可计算”的概念,因为计算这一概念是独立于具体计算模型而存在的。
在所有这些等价的计算模型中,图灵机模型是最简单直接的,因为它不依赖于任何形式化系统和数学假设,而是把计算直接和物理过程联系起来。图灵机的定义强调计算的物理可实现性。
我们已经看到有函数是图灵机不可计算的,但这样的函数是极其不自然的,几乎可以肯定它在物理世界也是不可计算的。到目前为止,我们还没有发现任何“直觉上可计算”的函数是图灵机不可计算的——“物理可实现的计算都是图灵机可计算的”,这就是丘奇-图灵论题。我们可能永远无法证明这一点,因为我们无法严格定义什么是“物理可实现的计算”或“直觉上可行的计算”,但丘奇-图灵论题已经成为计算理论的共识。