这篇同样转自http://mindhacks.cn上刘未鹏的blog。
查了一下,上篇知其所以然(以学习算法为例)是08年7月写的,现在已经是10年11月,过去了两年零4个月,这说明了三件事情:1,一个问题其实你可以一直放在脑子里面,利用暗时间对其软泡硬磨,时间足够久你总会有一点新的感悟,问题其实就像那句老话说的那样,不怕贼偷就怕贼惦记,聚精会神的思考一天,也许比不上惦记一个星期(据说数学家庞加莱就特别会惦记问题)。2,事实上,当你感觉懂了的时候,你至少得反问自己一句,真的懂了吗?当你确信自己真的懂了的时候,你至少得讲给别人听,别人听懂了吗?考察你自己是否真懂了的一个很好的依据是,你是否有一种“哦,原来是这样啊,这下再也不可能忘记了”的感觉。3,我其实没有忘记这个博客。如我之前说的,记录只是学习和思考的副作用,只要还在学习和思考,就必然会有新的记录。
我有一个习惯,看定理必看证明。一个你不明白其证明的定理在我看来比不知道这个定理还要糟糕,因它给你造成一种懂了的错觉。在没有明白背后的证明之前,任何一个定理对你来说都是等价的——等价于背乘法口诀(只不过有的长一点有的短一点)。一个原本美妙的定理,把其证明扔掉就是真正的买椟还珠,暴殄天物。
从现实意义来说,去理解一个定理的证明会带来巨大的好处,首当其冲的好处就是你很难再忘掉它。这一点其实很容易解释——在理解一个定理的证明之前,定理对你而言是一堆没有内在联系的词句,而在理解了证明之后,定理就归约为证明它所需的条件加上逻辑,“逻辑”本来就存在于你的大脑里面,而证明的过程中除了公理和用到的常见定理(往往没几条)之外,宽泛地说,需要你去记的,一般来说也只有一个或两个关键的insights,也就是我们常说的证明中的神来之笔,比如几何证明里面的某条看上去莫名其妙的辅助线,一旦你知道了这条辅助线,那么整个证明就毫无难处,那么该定理的信息量便直接缩减为一条辅助线的信息量;虽然看上去这一步信息并没有缩减多少,但是如果你考虑到类似的辅助线不仅会用在这个特定的定理上,往往会在很多地方用到。很多关键的证明手法是通用的。那么其实你就是把所有以这个辅助线为关键证明手法的定理的集合的信息量归约为了这条辅助线。如果你进而甚至能够理解了作这条辅助线的思想精髓,那就更牛逼了,因为解决问题的思路更具有一般性,理解了寻找正确的辅助线的思路,你就根本不需要去记得某条特定辅助线的作法,你就把所有以作一条或几条辅助线为证明核心的定理的集合的信息量归约为了这个“寻找辅助线的思路”。
这是一个树状的知识结构,越往上层走,需要记忆的节点就越少。所谓触类旁通者,其实便是因为他擅长去理解解法背后的更具一般性的东西。所以我还有一个习惯,就是看到美妙的证明和解法总是会去一遍又一遍的去反复揣摩,试图理解想出这个证明的人到底是怎么想出来的,有没有什么一般性的方法可循,很多时候,在这样揣摩的过程中,你会理解到更深刻的东西,对问题性质更深刻的认识,对解决问题的思路更深刻的认识,这些认识不仅对于你理解当前这个定理或问题有极大的帮助,同时也有助于你解决以后会遇到的表面不同但本质一样的问题。
与看定理必看证明类似,看一个问题的解法,必然要看解法所诞生的过程,背后是否隐藏着更具一般性的解决问题的思路和原则。否则一个解法就只是一个问题的解法,跟背口诀一样。即便记住了也无法推广,即便当时记住了也容易遗忘。
举个经典的例子:每本算法书都会讲动态规划,每本讲动态规划的书都会讲背包问题,每次讲背包问题都会讲可重复背包和01背包,我们就拿《Algorithms》这本还算不错的算法书对背包问题的讲解来说吧,重复背包问题的递归公式是这样的:
K(W) = max { K(W-Wi) + Vi : Wi <= W }
这个公式的理解倒是很简单:为了把问题降阶,我们在最终的最优解里面去掉一个元素,对这个元素的可能性进行讨论,它必然是任何Vi之一(前提是Wi <= W,否则就装不下),而在去掉这个元素之后,剩下的元素肯定构成问题 K(W-Wi) 的最优解,于是递归关系出现了。
此外也可以这样来理解:要拿一组最优元素,那么总得开始一个个拿吧,对第一个拿的元素进行讨论,而问题的最优解等于讨论的各个分支的最优解中的最优者;如果拿掉Vi之后,剩下来要怎么拿才能最优呢?这就是一个 K(W-Wi) 的问题了。
01背包问题就大不一样了——每个物品都只有一件,拿掉之后就不能再拿了。我们不妨看看重复背包问题的解法是不是能用到01背包上呢?还是讨论第一个拿的元素,设被拿掉的是第i个元素,问题就归结为把剩下的物品(注意,可拿的物品少了一件)最优地装入容量为 W-Wi 的包里,所以,问题的参数便变成了两个,一个是背包剩余容量 W-Wi,另一个是剩余可拿的物品集合 S\{i} (表示去掉i之后的子集),显而易见第二个参数是物品集合的各种可能的子集,那么其可能性个数就是 2^n ,这就导致子问题的个数是 2^n, 由于要依次计算每个子问题,那么算法复杂度显然也是 2^n ,是不可接受的。
那么,《Algorithms》上又是怎么来讲解01背包问题的解法的呢?以下是原文:
Our earlier subproblems now become completely useless. We must therefore refine our concept of a subproblem to carry additional information about the items being used. We add a second parameter, 0 <= j <= n: K(W, j) = maximum value achievable using a knapsack of capacity w and items 1..j: The answer we seek is K(W, n).
首先作者说了,之前重复背包问题的解法在这里完全废掉了,所以我们必须重新定义子问题,并且子问题的条件必须要包含目前拿剩下的物品。以上这些都还不错,关键是接下来就让人吐血了。作者接着说道,我们给子问题加上一个新的参数j…
凭什么啊?
还是让我们回顾一下这样一幅经典的漫画吧:
“我们给子问题加上一个参数j”,这就像你在看数学证明时看到无比邪恶的“我们考虑…“一样,一看到这样的句子,你就知道,这个问题的证明远远不像看上去那么简单,之所以你一路看下去理解上全无困难,那完全是因为作者直接把最重要的一个insight告诉你了,举个很简单的例子,证明素数无最大,谁都会第一时间想到去反证:假设存在一个最大的素数P,那么找到比P大的素数就是证明中最关键的一步,怎么找的?一般书上是不会说的,你会看到书上这样说:假设P是最大的素数,那么我们考虑P’ = 小于等于P的所有素数的乘积+1。那么P’一来显然大于P,二来不能被小于它的所有素数整除,那么P’就成了大于P的素数。
如果你经常注意反证法,你会发现一个有趣的现象,反证法里面经常会有这样一句“我们考虑”,而“我们考虑”后面几乎肯定接着一个天外飞仙一般的insight。素数无最大这个古老的证明里面的“我们考虑”尚算是比较有迹可循的(我们想要构造一个更大的素数,而素数的等价定义就是“不能被小于它的所有素数整除,为了达到这个目的,构造的方法就较明显了)。但是有非常非常多的证明,其中关键的一步就跟嗑药磕出来做梦做出来走路跌跟头跌出来的一样(不信去翻一翻《Proofs from THE Book》),让你完全不知道他怎么想到的。
话说回来,虽然有很多数学证明的关键步骤是很难逆向工程的(因为很多时候想出那个关键步骤的本人其实也是尝试了各种方法,撞了无数堵墙,在寻求证法的尝试空间中作了N次回溯才“妙手偶得”,与其说是妙手偶得,不如说是绞尽脑汁),但并非全无章法可循,否则陶哲轩也不会写出《Solving Mathematical Problems》这样的著作来,而求解问题也就成了真正的Black Art了。
算法的解法则比精妙的数学证明稍加更容易逆向工程一点。只要你有耐心仔细地去琢磨算法的关键步骤和本质,总能从中窥探到一些更general的思想和思路来。
此外,很多经典问题,算法书上的讲法虽然时时令我们失望,但如果去网上一搜,则通常会发现更优秀的解释来。比如背包问题就是如此。
简单地说,如果你对于每个问题都能真正弄清以下这几个问题的答案,那么可以肯定的是,你的理解,记忆,以及学习的效率都会得到质的提高:
回到人民群众喜闻乐见的经典例子:背包问题。为什么01背包问题的正确(高效)算法是正确(高效)的。表面的解释是,因为01背包问题的子问题定义是 K(W, j),其两个维度相乘的可能性一共有nW种,也就是说一共要计算nW个子问题,而计算每个子问题的复杂度是O(1)的。
但是如果仅仅满足于这样的解释,可以说是隔靴搔痒,并没有触及到本质。算法本质上可以看做是在一个解空间当中的搜索问题,所以要分析一个算法的好坏,首先弄清它的解空间的结构,然后分析它是怎么来探索这个解空间的。
弄清解空间的是第一步,例如排序算法,其解空间可以看做是所有可能的下标排列组合,其中有且仅有一个排列是正确的排序排列(简单起见假设元素各不相同)。那么一个算法在探索这个解空间方面的行为就决定了它的效率高低,最简单的,如果一个算法每次只能检查解空间中的一个点,那么这个算法的复杂度就是解空间的大小。对排序算法而言也就是n!。从这个角度来看,我们就会很容易的发现,所有基于比较的排序算法,其复杂度为什么是以O(nlogn)为下界的,因为一次比较操作最多有两个结果,a>b或a<b,既然只有两种结果,那么最多只能将解空间进行2分,如果每次都能完美的2分,那么找到那个唯一点最终需要的步骤就是log(n!) = O(nlogn)。如此就不难理解什么基于比较的排序算法的复杂度最好不过如此了。
回到01背包问题,01背包问题的解空间其实也是类似的。一次选取就是一个01数组,其中每个元素代表其所对应的物品要不要选取。很显然,这个解空间的大小是2^n。在01背包的算法里面,每当我们解出K(W, j)(需要O(W)次计算)之后,解空间就会被折半(排除掉1/2的可能性),一共如此做n次,就能得到最终解。由于每次折半的代价是O(W),便不难理解为什么算法复杂度是O(nW)了。
那么,为什么每次计算出K(W,j)就能使解空间折半呢?那就需要来看看这个算法是如何探索解空间的,算法探索解空间的方式在其递归公式里面:
K(W, j) = max { K(W, j-1), K(W-Wj, j – 1) + Vj }
也就是说,首先看你要不要选取第一个物品,有两种可能性(两个分支),每个分支都是一个更低阶的子问题,即在其中的任意一个分支下都要决定要不要选取第二个物品(又是两个分支),如此下递归去,可以构建出一棵有2^n方个叶子节点的树,每条从根结点到叶子节点的路径“01..101”就对应一个解,其中每个分叉代表“选”或“不选”当前的物品。
建立在对这个解空间的理解上,我们再来看为什么01背包问题的正确解法能做到O(nW)。(首先你最好将这棵树画在纸上,其中每个节点都是一个子问题K(W,j),每条分叉都是0或1。)当我们计算出所有的K(W, 1)(需要O(W)次操作)之后,我们容易注意到,所有离叶子节点的距离为1的内部节点K(W, 2)到叶子节点的两个分支都必然只能取其一了,也就是说,有一半的叶子节点被排除掉了(对解空间折半)。当我们进而计算出K(W,2)之后,同样的道理,我们容易看到,到叶子节点距离为2的内部节点的两个分支也只能取其一了,这就进而再次将解空间折半。由于每次折半需要O(W)的复杂度,所以就不难理解算法的总复杂度为O(nW)了。另一种理解的方法是,当我们计算出K(W,j)的时候,从内部节点K(W,j)到根节点的唯一路径便确定了。经过O(nW)次计算,从根节点到那个唯一解(叶子节点)的路径便完全确定了。
知道怎么做是从正确(高效)解法得到的,而知道为什么必须得那样做则往往是从错误(低效)的解法当中得到的。
然而遗憾的是,绝大多数算法书或教程都只顾一上来就告诉你正确的做法是什么,对于一些常见的错误解法,或者常见的低效解法,却根本不加分析。经验告诉我们,理解错误的做法为什么错误同样甚至更为重要,往往是在理解了错误的解法为什么错误之后,我们才能深刻的体会到为什么正确的解法是如此正确。
还是拿经典的背包问题来作例子,你几乎看不到哪本书会告诉你一个典型的低效解法为什么低效的深刻原因。我们都知道动态规划的核心在于子问题的划分,同样的问题,不同的划分办法得到的复杂度完全不一样。前面已经提到了,重复背包问题的思路在01背包问题上会带来指数级的复杂度,但是为什么呢?如果你满足于说:因为如果拿重复背包问题的思路来解01背包问题,那么子问题定义的第二个维度(物品的子集)(见前文)是指数级的,那么要计算所有子问题,当然是指数级的。那么你只是看到这个问题的表象。
如果从对解空间的探索方式来说,可以容易看出这个现象的本质,我们回顾一下01背包问题的正确(高效)算法:
K(W, j) = max { K(W, j-1), K(W-Wj, j – 1) + Vj }
这个算法讨论的是两种情况,“要”或者“不要”选取第j个物品,这两种情况所对应的解空间是完全不交的,这就有效地将解空间划分为了不重复的两个部分。
而再来看利用重复背包问题思路的解法:
K(W, S) = max { K(W-Wi, S\{i}) + Vi : Wi <= W }
这里讨论的是首先拿掉哪一个物品,还是那句话,讨论的每一个分支都对应了算法对解空间的一个切分,我们容易看出,在“先拿物品i”和”先拿物品j“这两个分支里面,存在大量的重复,因为先拿物品i再拿j,和先拿物品j再拿i对应的是完全一样的一组选取。事实上,如果你将这个递归公式画成树状结构,会发现有n!个叶子节点。n!是什么概念?01背包问题的解空间大小本质上就只有2^n次方,穷举也不过O(2^n)的复杂度,结果这样一切分却变成了n!,可见这种对解空间的切分方法的冗余度是多么高了。你不妨看看,每一次计算K(W, S)子问题能对解空间排查多少呢?是否能像前面正确的算法那样,每次都能有效排查一半情况?理解了这一点之后,我们便注意到在划分解空间,也就是定义子问题的时候的一个原则,就是在建立递归公式的时候,尽量将解空间进行不交的切分。同时我们便有了趁手的工具去分析一个动态规划的解法的效率。
最后再举一个例子:算法书上几乎必讲的霍夫曼树。你所看的算法书在讲霍夫曼树的时候给了证明吗?讲过霍夫曼树的历史八卦吗?也许你看了霍夫曼树的构造方法之后觉得:“哦,这样啊,显然”。但是你可曾想到,在最优编码这个问题上,连香农本人之前给出的解法都只是suboptimal的,而且霍夫曼本人在得到这个算法之前也是绞尽脑汁几近放弃。如果你10分钟就“理解”了,那么百分之百只是背了课文而已。