算法之美第二章
12 May 2014 by LelouchHe
闲话
第一章的习题,终于在时隔一年多之后完结了,回首解题的过程,感觉十分复杂.这一年來发生了很多事情,再加上自己本来就朝三暮四的性格,导致学习的效率十分的低下
现在决定严格的按照顺序來学习,不再东一下西一下的乱搞,搞到最后,每个都烂尾了
接下来的一段时间,会主攻算法之美这本书(话说中文版真的是学计算机的人翻译的么?),争取是一周一章的节奏,包括知识点总结和习题
至于完成之后干什么,目前还没有好的想法,初步的思路是算法导论或算法设计或算法引论三选一,或者全搞,最后搞具体数学做补充
好吧,我一下子就把未来一年都铺垫好了,汗
概述
本章的重点是分治算法,分治是一种非常重要的算法了,如果广义的来看算法分类,基本所有的算法都可以或多或少的算到分治当中.一旦涉及到问题的分类和问题之间的关系构造与维护,基本就会扯上分治
比如下一章的图的分解,看上去貌似只是图这种数据结构自身的操作,但细想,dfs不也是将问题分解为当前节点和相邻节点两个部分,然后分别解决的么?后面的最短路径,贪心/DP等等,莫过如此
但广义角度谈分治,对实际的操作意义不是很大,所以本章还是挑选了一些非常明显和重要的分治应用的例子,让我们了解这种思想
分治的通用步骤
通常,分治分为三个步骤:
- 将原问题分解为一组与原问题类型相同的子问题,但每个子问题规模比原问题小
- 递归的求解子问题
- 将所有子问题的解进行恰当的合并,得到原问题的解
步骤虽简,但如何灵活运用,需要不断的进行总结和练习,现在针对三个步骤,有几个需要特别注意容易忽视的点:
分解
作为分治的第一步,首先需要分析原问题是否存在子问题,这个涉及到边界或者初始值,即后面递归终点的判断.只有原问题确实有子问题,且有必要分解時,我们才开始分解,否则,就需要做为原子问题(不可分),充当递归终点
递归求解第一要素是什么?
从我的经验来看,应该是大多数人都不重视的递归终点.类似归纳法一样,递归终点是归纳/递归的起点,可以是问题无法划分,也可以是问题的规模已经可以求解了.没有递归终点,再怎么递归求解都是假的.所以,这个作为分解的第一步,是非常必要的.主要有以下几个思考的方向:
- 从递归终点开始: 其实思考分治问题的一个角度,或者叫分析所有问题的角度,都是从小case入手,即通过最小的,无法划分的原子问题,來尝试求解整个问题.当然,此处可能还不能称之为”递归终点”(因为还没有要使用递归的意思),但这是一个很好的思考问题的开始,这也暗示,分治法是非常通用的
- 递归终点规模: 下面会提到问题的规模,这里简单说下,终点不一定是规模最小的问题,相反,终点应该是可以直接求解的问题,”规模”这里只是一个比较常用的衡量问题可解性的指标,但不是唯一的.”减小”只是问题的一个方面,也有很多问题是要”增大”到一定规模,才能直接求解的,不要忽视这个方面
- 递归终点个数: 问题不止有一个递归终点的,比如奇偶性质的问题,一般都有2个递归终点(0/1,或一奇一偶),有些问题的终点case更多,大致的原则就是完全覆盖.当然,在分析问题的开始,可能无法掌握全部终点case,这里只是提醒,不要找到一个终点就沾沾自喜,要同后面的问题分解与递归合并起来,迭代几轮,才能获得正确的理解
然后,在分解的过程中,不要拘泥于常见的二分,即将原问题分解为2个子问题.二分并不是分治的全部,子问题的数量和参数的规模往往取决于原问题的状态,很多情况下,二分是合适的(二分简直就是利器,思考无门,尝试二分),但不是全部,目前遇到过的可能的分法有:
- 常量划分: 常见的有Fib数列$F _ n = F _ {n - 2} + F _ {n - 1}$,就是将原问题分解为2个子问题,每个子问题的规模都比原问题小了常数而已.其实我们应该比较熟悉这种方式的,惯用的数学归纳法的常见证明,不就是利用$n - 1$推$n$么?此处注意的是,子问题的数量和规模都可能进行常量划分(比如k个子问题,规模小了m之类)
- 全划分: 常见的有Catalan数$C(n) = \sum _ {i = 0} ^ n {C(i) C(n - i)}$,子问题是所有规模小的问题.这种在数学归纳法中也偶有碰到,即利用全部的子问题,而不只是最接近的子问题.这种划分由于不常见,很容易被忽略,需要特别的注意,如果问题性质确实这样,就要大胆的操作
- 多维划分: 常见的有lcs(最长公共子串),递归公式就不写出了.虽然这个是属于DP的,但是从更广层面,也是属于分治的.对于多维问题,首先得分析出问题的维度,然后再分析子问题的划分.这类问题往往融合的其他更复杂的性质(比如DP),但分析的方法是一致的
- 倍数划分: 这个就是最常见的划分了,比如快排$Q(n) = Q(n / 2) + Q(n / 2)$或者更通用的$Q(n) = a Q(n / b)$.a和b不一定相同,这点是容易被忽略而犯错的
在这里罗列这些的目的,并不是让大家遇到问题時一个一个枚举尝试,而是告诉大家,子问题划分的方式是非常多的,也许将来会碰到更复杂,奇怪,甚至恐怖的划分方式,但只要严格分析问题,说明划分的正确性,我们都应该接受.我本人就遇到过类似的情况,一种划分比较罕见,结果就没有勇气分析下去了,但结果证明这种划分是正确的.不要害怕,只要对的,奇怪些无所谓
最后,子问题必须满足的性质是”类型相同,规模减小”:
- 类型相同: 不代表完全一致,有些复杂问题的子问题可能和原问题差距很大,但子问题的进一步划分,即子问题的子问题就类似了,这种”二阶段”的类型相同,也是可以进行划分的(还记得铺地板的题目么?).更复杂的问题可能还有多阶段,或者每个阶段都是不同类型问题的混合,都是有可能的
- 规模减小: 不代表一定减小,同二阶段分治类似,有时的子问题仅仅是原问题的变换,为进一步划分做准备.当然,最后一定要减小规模,否则怎么解决完毕?
再多嘴一句,”规模减小”其实也只是形象的说法,减小只是递归解决的常见情况而已,小大都是相互的,只要解决问题就可以,不用拘泥于绝对数量
递归
重点之一依然是上面提到过的递归终点,不过此时,我们对问题有了进一步的了解,通过分解问题,也明确了最后问题会分解成的规模和类型,此时的递归终点探索在于找到这些case,完善递归终点,或者证明这样的东西不存在,推翻分治的基础,从而让我们尝试其他思路
除了迭代的思考递归终点和问题分解之外,递归求解的过程还有一些其他需要思考和迭代的地方:
- 问题的表示 承接自问题分解,上一步時,可能不会去思考问题的具体表示,只需要大概的区分子问题即可,但是此处,需要将问题的表示明确下来,更多的从数学表示或实现方面來入手(因为我们要开始解决问题啦).比如需要多少维的表示,有哪些中间变量,递归终点怎么表达,递归终点的结果怎么表达之类的,都是需要在此处给出比较明确的结论的
- 问题的重新分解 有的时候问题分解的很理想,在此步骤就无法进行下去了,比如分解時我们有一个不通用的假设(不是所有问题都满足的条件之类的),到了这一步无法进行下去了,这时,我们就需要重新分解问题.其实可以看到,这也是一个迭代的过程,两方面都在不同程度上相互改进
问题的重新分解,一是大方向的变化,比如以前沿着思路A展开的,在递归求解時发现了矛盾,那么下一次就可以沿着思路B开始;还有更普遍的情况,我们欠缺的只是一两个性质而已,很多情况下,可以通过增加状态或增加维度來解决(想想那么多的DP问题).主要的提醒点在于一旦无法继续,要能想到这两个方向的补救措施,不要茫然失措
合并
合并子问题的难度不定,有的问题合并非常trival,有的则是问题的难点所在(比如平面内最短点对),但此时,与初次遇到问题時相比,我们有几个优势:
- 子问题解 合并時,我们已经有了子问题的解了(假设中,或已经求得),比一开始什么都不知道的情况有了很大的改善,至少我们有了部分解,一百步走了五十步了
- 问题性质 不仅对于解,对于问题我们也有了清楚的了解,从基本case(递归终点),问题间递归关系(子问题分解和递归),我们对问题的本质有了进一步的认识
当然,这些优势不代表一定可以解决问题,此处只是给各位打一针预防,不要把合并想的非常简单(大部分情况比较简单),合并的复杂度有可能决定了问题的复杂度,不要找不到简单的合并策略,就直接否定了分治的使用,这样是完全错误的
总结
从概述的分析中,可以看到,分治算法的运用,并不是顺序的,而是不停的迭代:
尝试分解 -> 递归表示 + 递归终点 -> 合并 -> 新的分解 -> 新的表示和终点 ->新的合并 …
这样不断的迭代,不断的掌握问题性质,不断的深挖问题本质,才能得到问题的求解.不要寄希望于一蹴而就,相反,如果真的遇到陌生的问题,不停的试错才可能是正常的解决问题之道
分治的理论依据
怎么证明分治的合理呢?其实在我看来,分治的本质就是数学归纳,我们上面讨论的众多情况,其实都在数学归纳法中体现.如果我们数学学的好,见惯众多恶心复杂的利用归纳的问题,那么算法问题其实很微不足道(可惜数学学的一塌糊涂啊..泪奔..)
归纳的关键有2个:
- 归纳基础,即我们的归纳终点,一个可以验证,可以直接求解的基础问题.
- 递归关系,即我们的分解+递归+合并,三个步骤都会沾一些,这也说明了分治的三步,其实就是一步而已,只不过分割称三个终点不同的子步骤,方便我们focus攻关的重点而已
归纳是一个非常棒的思路,具体的可以参看算法引论,通篇都在讲这一个道理
我们上面提到的各种变化,其实都是数学证明/求解的技巧而已,由于不熟悉,所以才觉得非常神奇.所以说,学好数学,很重要
主定理
求解完分治问题,在估计复杂度時,有一个主定理是非常方便的
假设一个分治问题的递归关系为:
\[\left \\\{ \begin{aligned} T(n) = a T(n / b) + \Theta (f(n)) \\\\ T(0) = O(1) \end{aligned} \right. \Rightarrow T(n) = \left \\\{ \begin{aligned} n ^ {\log _ b a} & \, & n ^ {\log _ b a} = \Omega (f(n)) \\\\ f(n) \log n & \, & n ^ {\log _ b a} = \Theta (f(n)) \\\\ f(n) & \, & n ^ {\log _ b a} = O(f(n)) \end{aligned} \right.\]可以简单的通过画图得到验证(但估计证明过程肯定也很复杂),我们只需要简单的使用即可.看上去复杂,通用的说法就是看子问题求解($n ^ {\log _ b a}$)和合并($f(n)$)哪个更耗时,如果一样的话,就要加$\log n$即可
整数乘法
我们使用书上的一个例子–整数乘法来熟悉一下整个分治的解题流程
问题
有2个n位的二进制整数x和y,求其乘积
问题的表示
首先尝试使用当前要计算乘积的数对(x, y)来表示问题.其实暂时也想不到更好的方式来表示了,题目中就出现了2个整数
问题的分解
分解问题主要将其规模减小(就本题而言,增大规模貌似没有什么意义),下面我们尝试几种减小规模的思路
单一值减一
将$(x, y)$分解为$(x - 1, y)$,这个看起来也比较合理,然后处理递归终点和合并子问题:
- 递归终点 x会不断的减小,减到什么时候可以直接求解呢?显然,x=0或者x=1都是可以的,其中(0, y) = 0, (1, y) = y,都是可以直接计算的.更大的数其实也是可以的
- 合并子问题 当已知(x - 1, y)时,如何计算(x, y)?这个合并时比较明显的,$(x, y) = x \times y = ((x - 1) + 1) \times y = (x - 1) \times y + y = (x - 1, y) + y$,这个公式还是比较直接的,利用乘法的性质即可
最后分析问题的复杂度,可以直接利用合并公式,$T(x, y) = T(x - 1, y) + O(n)$,得$T(x, y) = O(n) \times O(n) = O(n ^ 2)$.注意,此处的合并复杂度$O(n)$是关于y的位数的,不随着x的变化而变化(因为最后总要与n位的y相加)
这样,我们得到了一个比较简单的整数乘法的算法,复杂度是$O(n ^ 2)$,递归公式如下:
\[(x, y) = \left \\\{ \begin{aligned} 0 & \, & x = 0 \\\\ (x - 1, y) + y& \, & x > 1 \end{aligned} \right.\]单一值减半
除去减一的方法,我们还可以尝试折半,将某值(比如x)折半,再利用乘法性质组合结果.即将$(x, y)$分解为$(x / 2, y)$,接下来还是递归终点和子问题合并:
- 递归终点 和上面类似,x=0或1时,可以直接求解
- 合并子问题 利用乘法性质
分析其复杂度,最多减少n次,每次都可能存在$O(n)$的加法运算,所以复杂度是$O(n ^ 2)$,并没有得到很大的改进
对半拆分(存疑)
同样,我们还可以根据某值(比如x)的二进制位来进行拆分成相同位数(n -> n / 2)的两部分,然后分别计算最后的乘积.接下来,还是递归终点和子问题合并:
- 递归终点 和上面类似,x=0或1的时候,可以直接得到问题的解
- 合并子问题 仍然是利用乘法的性质,$(x, y) = x y = (2 ^ {n / 2} x _ l + x _ r) y = 2 ^ {n / 2} (x _ l \times y) + (x _ r \times y) = 2 ^ {n / 2}(x _ l, y) + (x _ r, y)$
但这个的时间复杂度一直没有分析清楚,按照我的理解,复杂度是$T(n) = 2 T(n / 2) + O(N + n)$,N是原先y的位数,n是当前x的位数,这样下来总的复杂度是$O(N \log N)$,明显是不对的(普通的乘法应该是$O(n ^ 2)$才对)
全部折半
上面三种方法都是拆分一个值,现在我们尝试分解2个值,以二分为例,令$x = 2 ^ {n / 2} x _ l + x _ r, y = 2 ^ {n / 2} y _ l + y _ r$,然后再相乘求解.接下里分析递归终点和子问题合并:
- 递归终点 x/y任意为1时,就可以直接求解了
- 合并子问题 根据乘法原则进行变化即可,$(x, y) = (2 ^ {n / 2} x _ l + x _ r) (2 ^ {n / 2} y _ l + y _ r) = 2 ^ n x _ l y _ l + 2 ^ {n / 2} (x _ l y _ r + x _ r y _ l) + x _ r y _ r = 2 ^ n (x _ l, y _ l) + 2 ^ {n / 2}[(x _ l, y _ r) + (x _ r, y _ l)] + (x _ r, y _ r)$
复杂度分析,$T(n) = 4 T(n / 2) + O(n) = O(n ^ 2)$,还是普通的乘法操作
优化
可以利用乘法性质,对这种分解进行优化,其实这个还算容易看得出来,4次乘法是多余的,因为中间的$(x _ l, y _ r) + (x _ r, y _ l)$其实没必要单独计算,只要计算其和就可以了,利用公式$(x _ l + x _ r) (y _ l + y _ r) = x _ l y _ l + x _ r y _ r + (x _ l y _ r + x _ r y _ l) \Rightarrow x _ l y _ r + x _ r y _ l = (x _ l + x _ r) (y _ l + y _ r) - (x _ l x _ r + y _ l y _ r)$,利用原先必须计算的两个独立乘积,和1个新的乘积,就可以完成原先的4个乘积了
现在的复杂度是$T(n) = 3 T(n / 2) + O(n) = T(n ^ {\log _ 2 3})$,比平方复杂度优化了不少
排序
还是利用经典的问题,来审视分治的流程
问题
n个元素的无序数组,设计算法将其变成有序
问题的表示
涉及到数组的问题,一般的常见表示有两种:
- 数量表示 使用(n)来表示,这种情况一般是处理和个数相关的问题,与数组元素的位置没有关系
- 范围表示 使用(l, r)来表示,这种表示包含了个数(r - l + 1),同时包含了位置,对于位置相关的问题,可以采用类似的表示
当然,这只是常见的情况,在分析问题时遇到其他情况,也要接受
问题的分解
下面我们使用上面的2种表示,逐一进行问题分解,考察如何在不同表示下分解/迭代问题
减一
当使用(n)表达问题時,通过减一來减小问题规模,就是将(n)分解为(n - 1).什么时候n个数的排序可以变为n - 1个数的排序呢?自然是知道了某个数的确切位置時.其中,最简单的情况应该就是最大值和最小值了.下面看下递归终点和子问题合并:
- 递归终点 n = 0或1時,就是自然的终点了,选择一个自我感觉不错的即可(其实二者的意义类似,只不过出于不同的规范,选择不同的终点)
- 子问题合并 挑选出最大值,然后将剩余的n - 1个数排序,那么合并操作其实就是简单的将最大值添加到n - 1个数的后面即可(最小值同理)
这种算法的复杂度是$T(n) = O(n) + T(n - 1) + O(1)$(从前往后分别是挑选最大值,子问题和子问题合并的复杂度),计算可知,最后$T(n) = O(n ^ 2)$.这种排序是经典的选择排序.
进一步
除了最大值和最小值,我们还可以尝试其他的值么?那是肯定的.最大/最小只不过是一种”选择”的方式,自可以选择其他的值v,首先确定v的位置,然后处理剩下的n - 1个数的排序.
比如我们决定按顺序考虑,每次考虑数组的首个元素,设为v.怎样才能得到v的位置呢?自然的思路是排序了,不过这就绕回原问题了,所以不能这样做(鸡生蛋,蛋生鸡啊).仔细思考下,通过排序决定位置,包含了很多不需要的数据,比如根本不需要除了v之外的其他值的相对顺序,找到比v小的值的数量,即可确定v的位置了.
沿着”找到比v小的值的数量”这个思路,就有一个简单的算法來完成,遍历整个数组,统计比v小的数量,这样就得到了v的位置(假设为k).好的,我们完成了问题分解,问题确实变成了小问题(n -> n - 1),接着看看递归终点和子问题合并:
- 递归终点 显然,只有v的时候,就不需要减小了
- 子问题合并 n个数,自然只能映射到[0, n),现在v已经占据了k,所以直接将v插入到(n - 1)有序序列的第k个位置即可(从0开始数)
我们就又成功解决了问题,复杂度是$T(n) = O(n) + T(n - 1) + O(n)$(分别是查找位置k,子问题,插入位置k),最后得到$T(n) = O(n ^ 2)$
可以看大,相比最大/最小值,随机选取一个值的复杂度类似,但实现上,觉得甚是不爽,比如插入v的时候,需要移动$O(n)$的数组,这个是否可以避免呢?既然我们知道v的位置是k,为什么不一开始就放到k去,非要最后再插入呢?
所以我们换一种分解问题的思路,还是选择首个元素v,找到位置k,然后把v和原k位置的$v ‘$互换,这样v就到了一个固定不变的位置,只要不动v,就不会影响其排序结果.但现在,原问题变成了分割的2个部分,[0, k)和[k + 1, n),个数分别是k和n - k - 1,都小于n,这样问题的规模减小了,让我们看下递归终点和子问题合并:
- 递归终点 和上面一致,当剩余0或1个元素時,自然就没必要排序了.其实剩余2个元素,也只需一次比较即可知道顺序(点出这个的目的在于告诉大家,不一定非要到最基本的情况,能直接求解即可).不过2个元素有一个麻烦,就是必须保证存在2个元素,这往往意味着多一个判断,从解法上来看,可能不太美观(美观啊)
- 子问题合并 分别求出(k)和(n - k - 1)后,得到2个有序的数组,merge一下,注意跳过在k的v即可
此时复杂度为$T(n) = O(n) + T(k) + T(n - k - 1) + O(n), k \in [0, n)$,平均下来,$T(n) = O(n \log n)$
再进一步,最后的合并还是好麻烦,还需要跳过k(好反人类啊),有什么办法不需要k搀和么?自然的想法就是如果前后两个分割的序列是独立的就好了,即[0, k)所有的元素都小于[k + 1, n),这样二者排序之后,原序列就自然有序了,不需要merge或者跳过k了.怎么做到这点呢?在前面统计小于v的个数時,我们会遍历所有的元素,有小有大,小的必然是在[0, k),大的必然在[k + 1, n)(虽然只有最后才知道k的大小),既然此时有了这个信息,就可以利用这个來做划分,小于的放到”小于数组”里,大于的放到”大于数组”里,这样就成功拆分了.接下来看递归终点和子问题合并:
- 递归终点 同于上面的情况
- 子问题合并 已经得到了排序后的(k)和(n - k - 1),按照顺序[..k..] + v + [..n-k-1..]就好了
这样的复杂度比原先少了合并的$O(n)$,但总的复杂度依然是$O(n \log n)$
变换表示
随着”进一步”的分析,问题越来越和位置相关了,那么就來看下如果利用位置相关的(l, r)怎么处理最后一种情况
原问题是处理(0, n - 1)的无序序列,按照最后一种的思路,其实就是将其划分为(0, k - 1)和(k + 1, n - 1)两个子问题.再看下递归终点和子问题合并:
- 递归终点 (l, r)自然是为空或只有1个元素時,直接得到结果,即l>=r
- 子问题合并 (l, k - 1)和(k + 1, r)已经排序了,v已经在k位置,那么就不需要合并了,直接得到排序序列
此时,复杂度是$T(l, r) = O(n) + T(l, k - 1) + T(k + 1, r)$,平均下来,依然是$O(n \log n)$
可以看到,此时的合并操作非常简洁,直接得到了问题的解.
为什么会这样呢?
因为我们在问题表达上自带了位置信息.上一种表达((n))只有数量,没有位置,导致的结果就是在合并時需要手动指定.其实可以看得出来,对于排序而言,位置是一个重要的信息,不在问题表示中体现,就需要在问题表示之外人工体现(比如合并時).排序本身就是位置和值的重新映射,位置自然是必要信息
折半
考虑过”减一”分解之后,再來看看传统的”折半”,即将问题(n)分解为(n / 2)來考察,得到2个有序序列之后,然后解决原序列的排序.我们看下递归终点和子问题合并:
- 递归终点 类似的,当n=0/1的时候,直接得到结果
- 合并子问题 2个有序序列,merge一下即可(上面我们曾经也使用过merge),就能得到原问题的排序
这样,复杂度是$T(n) = 2 T(n / 2) + O(n) = O(n \log n)$
这样的方法很类似与上面减一的方法,唯一不同的是我们没有挑选元素v的位置k來完成问题的分解,而是直接简单粗暴的一分为二了.
总结
可以看到,分治的大体步骤是:
- 分析问题表达,将问题参数化(有可能的话适当泛化),使问题的规模可度量
- 尝试分解问题,将问题规模减小(或者增大)
- 分析该分解下的递归终点和子问题合并,能否正确得到原问题结论
- 3中出现错误時,返回1,更新问题的表达,补充证明中缺失的信息
分解问题的方法还有很多,从不同角度,不同维度分析问题,肯定都能得到很多不同的分解方法.此处主要是为了提醒大家注意以下几点:
- 分解不唯一 有谁和你说这道题目只能这样做,千万别信他;要是自己觉得自己使用的分解很怪,不要慌张,条条大路通罗马
- 充分利用信息 有两层意思,一是当我们没有很好分解问题思路時,仔细分析问题的各种信息,看是否能有所帮助,二是当我们已经有了分解问题的方式時,看看有哪些信息是多余的,对已有的解法进行优化.题目的信息,是我们解题的唯一凭证,不忽略可能的信息,同时也不浪费时间/空间在不需要的信息上
- 分解的正确性 如何判断分解问题是否正确呢?这就回到上面不断重复出现的”递归终点”和”子问题合并”,简而言之就是所谓”递归公式”(分成2部分,是因为总有人忽略终点).能得到递归公式的,并能证明正确性的,一般就是合理的分解方式(当然,可能不是最优).所以这是一个迭代的过程,先表达问题,再分解,再推公式,再反推分解方式或者表达,每次迭代都加深理解,充分挖掘隐藏的信息
- 各步骤隔离 正如3中所言,分治是迭代的过程,但不代表所有的步骤是混杂的.初学解题時,务必要严格的区分各个步骤,不要混淆.我经历过无数次,把步骤混合起来,试图快速求解,到最后,什么都没有分析清楚,一团乱麻
- 子问题合并的复杂性 不要低估合并的复杂度,有些题目分解是最耗时的(比如正数乘法),而有些题目,合并是最耗时的(比如折半的合并排序),不要因为复杂而放弃分析,要确认确实不可行,才更改表达补充信息重新来过
FFT
FFT的数学原理没有看懂,但背后的解题思路还是可以借鉴一下的,此处不讨论数学,只讨论解题
铺垫
多项式有两种等价的表达方式,系数表达($\sum a _ i x ^ i$)和值对表达($(x _ i, A(x _ i))$).其中,n次多项式需要n+1个不同的值对來唯一确定
在处理多项式相乘時,如果采用值对表达的方式,复杂度是$O(n)$(只需要将二多项式在对应$x _ i$处的值相乘即可),而系数表达的话,复杂度是$O(n ^ 2)$.
如果能找到一种快速的变换方式(复杂度不高于$O(n ^ 2)$),那么整个多项式乘法就可以以小于$O(n ^ 2)$來计算了.
此处的FFT(快速傅立叶变换)就是完成这样工作的,它的复杂度是$O(n \log n)$,所以最后的多项式乘法复杂度就是$O(n \log n)$
问题
有多项式$A(x) = \sum ^ n a _ i x ^ i$,求2n个值对$(x _ i, A(x _ i)), x _ i \neq x _ j$,$x _ i$需要解题者自己提供
ps:反正就是要2n个不同的值对就行,至于是哪些值对,问题本身不关心,也就是说我们可以随意自取
注意,这是一个很强的可以利用的性质,问题不关心我们选取了哪些$x _ i$,只要有2n个不同的$x _ i$和对应的$A(x _ i)$,就完成了我们的任务
问题的表达
问题中有n个值要进行计算,每个都是n次方的多项式值,所以一个合理的表示是将这两部分都加入问题表达,即(m, n),其中m表示需要求值的数量,n表示多项式的次数.那么原问题是(2n, n).
这只是初步的预计,后续可以迭代的进行修改
其实我们完全可以直接根据公式来计算这n个值.直接计算一个n次多项式的值的复杂度是$O(n)$(假设x很小,基本运算是单位时间),那么2n个值的复杂度为$O(n ^ 2)$.有这样的复杂度,和直接计算多项式乘法复杂度相同了,所以这个应该是我们寻求算法的上限
问题的分解
依据上一个问题的思路,尝试各种减小问题规模的方法
求值数量减一
将(m, n)分解为(m - 1, n)感觉是个不错的开始,多项式次数不变,但求值的个数减一.接下来我们看下递归终点和子问题合并:
- 递归终点 当m=1,只剩下一个值时,我们不得不进行求值了,此时的复杂度$O(n)$
子问题合并并不是一个非常直观的过程,所以单独展开来说
子问题合并
现在的问题是我们有了前m-1个值对$(x _ i, A(x _ i))$,如何求得第m个值对.
由于可以随意选择这m个数,我们可以选择$x _ m = s$(随意的某个特定取值而已),这样就能在$O(n)$的复杂度下计算$A(x _ m)$.但这样的算法和直接计算没有任何区别,并没有利用到已经解决的子问题,所以不可取
那么此时的目标是利用子问题来求解,也就是说$x _ m$不能使随意的特定值,相反,$x _ m = f(x _ 0, x _ 1, \dots, x _ {m - 1})$,从而使得$A(x _ m) = g(A(x _ 0), A(x _ 1), \dots, A(x _ {m - 1}))$.同时,这样的性质可以延续到子问题中(才能保证问题类型相同)
从简单入手,假如已知$A(x _ 0)$,如何挑选合适的$x _ 1$,从而得到$A(x _ 1)$呢?仔细想想,其实没有很好的办法,都只能选择一个$x _ 1$,然后按照$O(n)$来直接计算$A(x _ 1)$.
为什么?
因为目前只有$(x _ 0, A(x _ 0))$两个独立且没有太大关系的值,虽然我们知道$A(x)$多项式的表达,但是却没有在这里体现出来,也就无法复用在$x _ 1$的求解中了.回想下整数乘法中的优化,是如何利用3次乘法得到4个需要的乘积项的.
这告诉我们一个原则,整体往往容易掩盖部分,比如$x y$和$A(x)$,掩盖了其中可以重复利用的细节,而这些重复,往往是优化的关键步骤.如果有个名称的话,这应该是上面说的”充分利用信息”.此处我们就要剖析这种细节
假设$A(x)$为3次多项式,则有$A(x) = \sum a _ i x ^ i = a _ 0 + a _ 1 x + a _ 2 x ^ 2 + a _ 3 x ^ 3$,怎么拆开可以利用的细节呢?
尝试前后拆分,即$A _ {prev} = a _ 0 + a _ 1 x, A _ {post} = a _ 2 x ^ 2 + a _ 3 x ^ 3 = x ^ 2 (a _ 2 + a _ 3 x)$(时刻不忘向相似方向努力!!).一个好的现象是多项式次数降低了($n \Rightarrow n / 2$),但由于$A _ {prev}, A _ {post}$之间没有可重复利用的,需要求解的值反而翻倍了($m \Rightarrow 2 m$),所以肯定不会改善复杂度的
其实可以想到,不论怎么拆分,多项式次数肯定会减小的(不要太奇葩,我们要找相似),关键在于如何减少需要求解的值,或至少维持不变.
我们再次尝试,来按奇偶拆分,即$A _ e (x) = a _ 0 + a _ 2 x ^ 2, A _ o (x) = a _ 1 x + a _ 3 x ^ 3 = x (a _ 1 + a _ 3 x ^ 2)$,看到了整齐划一的平方项,应该能想到平方根的性质$x ^ 2 = (- x) ^ 2$,这样在处理相反数时,我们有了可以利用的项了,即$A _ e (x ^ 2) = a _ 0 + a _ 1 x, A _ o (x ^ 2) = a _ 1 + a _ 3 x \Rightarrow A (x) = A _ e (x ^ 2) + x A _ o (x ^ 2), A(- x) = A _ e (x ^ 2) - x A _ o (x ^ 2)$,求解的值数量不变,但次数降低了,由$(m, n)$变换为了$(m, n / 2)$,问题的规模减少了
走到这里,感觉貌似减一的分解测试是不行的.我们还不容易找到的子问题合并方法,要求的是多项式次数折半,看来是时候需要改变分解策略了
多项式次数折半
此时,$(m, n)$分解为$(m, n / 2)$,再来讨论递归终点和子问题合并
- 递归终点 n=0/1时,就可以直接求解了,此时没什么优化的措施
- 子问题合并 求得(m, n / 2)之后,即已知$A _ e (x ^ 2)$和$A _ o (x ^ 2)$,只需要将$x$和$- x$分别代入,就能求得对应的$A(x), A(- x)$了
不知道各位看到问题了没有,虽然我们解决了$A(x)$和$A(- x)$,但是$A _ e (x ^ 2)$和$A _ o (x ^ 2)$怎么办?此处已经不能随意选择x的值了,只能利用这个$x ^ 2$,但$x ^ 2$怎么区分正负呢?只好进入复数域了,此时有$j ^ 2 = -1$(j为单位虚数).这样就又可以一正一负的挑选了.
那最初究竟挑选哪些值呢?1次方多项式的话,只需要一个数即可,比如s,那么有它组成的2次方多项式对应的取值,则是其平方根$\pm \sqrt {s}$,4次方多项式对应的就是$\pm \sqrt [4] {s}, \pm \sqrt [4] {s} j$,依次类推.但我们总共有2n个不同的值,如果s是任意的数,我们最后会有$O(2 ^ n)$个初始$x _ i$,所以需要让这些值也是重复的,令$s = 1$就可以解决这个问题了,此时的$x _ i = w ^ i$,其中w是1的n次方根,$w ^ n = 1 \Rightarrow w = e ^ {\frac{2 \pi}{n} j}$
下面我们用伪码来描述整个过程:
fft(A, w) {
if (w == 1) {
return A(1);
}
A(x) = Ae(x^2) + x * Ao(x^2);
Ve = fft(Ae, w^2);
Vo = fft(Ao, w^2);
for (int i = 0; i < n; i++) {
V(i) = A(w^i) = Ae(w^2i) + w^i * Ao(w^2i);
}
return V;
}
总结
其实我还应该带着大家探索下有没有别的分治的方法来解决FFT问题,但此处我们先总结下上面流程的一些要点:
- 子问题类型相同 类型相同,不是一句空话,也就是原问题该有的,子问题都得有,不能原问题有一个性质(而且还使用到了!!!),子问题就消失了,这样不算类型相同.就比如第一种分解时试图取0或特殊值遇到的情况.一旦出现这种情况,就证明类型错误了,一般有两种,像此处的改变分解方式,放弃利用这种独特性质,或者更改问题表达,将这个性质作为问题参数的一部分(这个后面如果遇到再看)
- 榨取有利信息 高效解题的一个重要特征就是充分利用了题目信息,所以试图设计高校算法,就需要”榨取”信息.像上面得到折半分解的思路,就是化独立整体为重复部分的过程,一堵墙我们很难拿来用,但一堆砖头却可以拿来搭很多东西.有的时候确实需要不关系细节的整体,但信息较少,或整体包涵内容太多时,要考虑分解整体,思考部分
- 分治的归纳 记住,分治的本质就是归纳,所以原问题的解肯定要涉及到子问题,否则,我们就直接trival的解原问题就好了,干嘛要求子问题呢?像上面那样直接取特值的分解方法,不仅违反了”类型相同”,而且还没有利用子问题,所以肯定是不合适的(当然,不用分治的话,自然无所谓了,比如那些直接总结公式$O(1)$出来的问题)
- 分割迭代 分解-终点-合并 三个一体的迭代中,要分割分析,勇于迭代.分割是为了防止我们陷入混乱(东一下西一下很容易乱),迭代是为了不要陷入错误太久.比如最后FFT的求解,就是从一个不太合适的分解开始,分析合并的难点和解决,然后又迭代回去修改分解方案,再回来讨论终点和合并,最后扩展到问题的解的.所以不要害怕,分割迭代
- 多样性 此处列举了方法看起来挺固定的,也确实是普通问题的常见思路,但一定不要拘泥,通用的分解肯定不如具体问题具体性质来的方便
关于多样性
这里我想多说一句,同后面可能的DP的讨论类似,在考虑子问题合并时,自然的想法是哪些子问题可以合并为原问题,就好像站在某点,思考哪些点是可以”跳过来”的.
还有一种思考方式是,站在某点,想可以”跳去”哪里
这两个思路是不一致的,前者往往是综合的,如同本章分析的所有问题一样,是得到完整的原问题解的过程,而后者是局部的,即子问题可能前进到多个父问题,但每个父问题都是部分解决.(给人的感觉很像MST的prim和kruskal,前者每一时候都是当前问题的解,但后者每一时候只是解的一部分)
两种思考方向都是可以的,只不过有的时候一种比另一种更容易罢了.像本章所述的几个问题,都是按照”跳过来”的方式思考的,因为子问题数量和种类有限,思考不是很复杂.但对于其他的问题,可能返回来想更容易(比如在DP中,复杂的状态转移就不容易”跳过来”,相反,”跳去”是相对容易的),我们就可以先反向的思考,得到所有的复杂的转移方式,然后再统一处理合并的问题
此处还是要强调”分割步骤”的重要性,有些时候,每个步骤都非常复杂,必须分割,否则混合起来越想越乱,最后只能不了了之了
写在最后
理解分治的本质就是归纳,而归纳是解决一大类问题的方法,基本可以运用到所有的题目上去
分治不一定只要得到求解的具体算法,这也是深入了解问题性质的一个思考方向,没准在思考过程中,就柳暗花明也说不定
后面会结合本章的习题,细细的品味分治的妙处