分治法
1. 定义
为了解决一个问题,可以将原问题分解成几个规模较小但类似与原问题的子问题,然后递归地求解子问题,最后将子问题的解合并成原问题的解。
分治模式在每层递归时有三个步骤:
- 分解:将原问题分解为若干子问题,这些子问题是原问题的规模较小的实例。
- 解决:递归地求解子问题。
- 合并:将子问题的解合并成原问题的解。
当子问题足够大,需要递归求解时,称为递归情况,当子问题足够小,不需要递归求解时,递归已经触底,称为基本情况。
2. 示例:归并排序
归并排序 算法遵循上述的分治模式。它的三个步骤可以表述如下:
- 分解:将乱序的
个元素的数组划分为两个长度为 的子数组。 - 解决:使用归并排序递归地排序这两个子数组。
- 合并:将两个已经排好序的数组合并成一个排好序的大数组。
递归触底:当每个子数组长度为 1 时,这个数组已经排好序 😃。这时不再需要继续分解数组。
归并排序的关键操作是合并这步,想法很简单:
- 看两个数组的第一个元素,比较大小,如果数组 A 的第一个元素小于 B 的第一个元素,就把 A 的第一个元素抽出来,反之就把 B 的第一个元素抽出来;
- 重复第一步,直到有一个数组空了,就把另一个数组剩下的元素直接并到大数组结尾就好。
- 实际操作中,判断数组是否空这步有一个好方法:
- 在 A B 数组的最后各塞一个无穷大的数,重复第一步
- 当真的有一个数组空的时候,就不需要采取别的操作,因为 B 数组的任何数都比无穷大小,因此总能把未空的数组的所有元素全部合并
代码实现:
实现
merge_sort()
函数- 传入一个数组,以及要排序的元素的起始下标和终止下标
- 如果起始下标等于终止下标,则递归触底,直接返回;
- 否则,把这个区间分成两半(注意两半不能有交叉),分别对这两半进行归并排序,最后调用
merge()
函数进行合并
cvoid merge_sort(int A[], int p, int r) { if (p < r) { int mid = (p + r) / 2; merge_sort(A, p, mid); merge_sort(A, mid + 1, r); merge(A, p, mid, r); } }
1
2
3
4
5
6
7
8
9实现
merge()
函数开辟新数组,把数组元素复制一份到新数组,以便稍后移动回原来的数组;
确定数组大小: 第一个数组存储从 p 到 mid 的元素,第二个数组存储从 mid + 1 到 r 的元素
定义两个对象n1
和n2
,分别表示第一个数组和第二个数组的元素个数cconst int n1 = mid - p + 1; const int n2 = r - mid;
1
2开辟数组:使用
malloc()
函数 因为末尾要放一个无穷大的数,所以要多开一个位置cint* L = malloc((n1 + 1) * sizeof(int)); int* R = malloc((n2 + 1) * sizeof(int));
1
2复制元素到新数组
cfor (int i = 0; i < n1; i++) { L[i] = A[p + i]; } L[n1] = INT_MAX; for (int i = 0; i < n2; i++) { R[i] = A[(mid + 1) + i]; } R[n2] = INT_MAX;
1
2
3
4
5
6
7
8
依次比较大小,把较小的元素放入原数组; 因为一共只有从 r 到 p 这些数,定义一个对象 k 从 r 循环到 p,把大数放到原数组中
cint i = 0, j = 0; for (int k = p; k <= r; k++) { if (L[i] <= R[j]) { A[k] = L[i]; i += 1; } else { A[k] = R[j]; j += 1; } }
1
2
3
4
5
6
7
8
9
10释放内存
cfree(L); free(R);
1
2
merge()
函数全部代码:cvoid merge(int A[], const int p, const int mid, const int r) { const int n1 = mid - p + 1; const int n2 = r - mid; int* L = malloc((n1 + 1) * sizeof(int)); int* R = malloc((n2 + 1) * sizeof(int)); for (int i = 0; i < n1; i++) { L[i] = A[p + i]; } L[n1] = INT_MAX; for (int i = 0; i < n2; i++) { R[i] = A[(mid + 1) + i]; } R[n2] = INT_MAX; int i = 0, j = 0; for (k = p; k <= r; k++) { if (L[i] <= R[j]) { A[k] = L[i]; i += 1; } else { A[k] = R[j]; j += 1; } } free(L); free(R); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27测试代码
cint N[10] = { 13, 5, 9, 6, 11, 2, 4, 7, 1, 8 }; int main(void) { merge_sort(N, 0, 9); for (int i = 0; i < 10; i++) { printf("%d ", N[i]); } return 0; }
1
2
3
4
5
6
7
8
9
10
4. 用递归式分析分治算法
4.1 递归式
递归式,顾名思义就是用一个递归的式子描述算法的运行时间。
以前面的分治算法为例:
- 对于数组长度为 1 的基本情况,我们不做任何操作
- 对于长度为 2 的递归情况,我们把它分解成两个规模为
的子问题,再加一步 的合并操作。 - 即:
用主定理可方便求得此递归式的结果是 。我们将在 4.4 讲述主定理。
4.2 代入法
代入法求解分为两步:
- 猜测解的形式
- 用数学归纳法求出解中的常数,并验证解的正确性
示例:对于递归式
- 猜测解的形式:
- 用数学归纳法证明该式子,即证明对于足够大的常数
,有 :- 假设对
成立: 代入,得 对于任何 成立。 得到:若对于 成立,则对于 成立。 - 证明对于
和 成立: 当 时, ,此时可以找到一个足够大的 (比如取 2),使 成立。 同理可证 成立。 - 由上面两步,因为在正整数
只有等于 2 或 3 时, 才等于 1,所以我们证明了(为什么?) 的上界是 。
- 假设对
有时复杂的递归式可以通过换元法化简。
示例:
- 令
,则有 ; - 令
,则有 ; - 根据上面的示例,可得
; - 代回原式,得
。
4.3 递归树
4.4 主方法和主定理
4.4.1 概念
主方法是用主定理来解某种递归式的通用的方法,分为三种情况。
它适合求解以下形式的递归式:
这个递归式描绘了一种算法的运行时间:每个子问题规模相同;
4.4.2 主定理
令
其中
- 若
, ,则 。 - 若
,则 。(这一点有个扩展版本:若 对某个常数 成立,则 。) - 若
, ,且 ,则
4.4.3 使用主定理
4.4.4 证明主定理
对 b 的幂证明
为什么是 b 的幂?
如果 n 是 b 的幂,那么在递归的任意一层中, (在结果大于 1 时)都是正整数。
在第二点中会对 可能不是正整数的情况进行证明。引理 1:
若 , , 是定义在 b 的幂上的非负函数, 是定义在 b 的幂上的递归式:其中 i 是正整数。那么:
引理 1 的证明: 设
对于某个非负整数 成立。这意味着 。 通过迭代展开来求解 。第 0 层 (初始调用):
第 1 层 (展开
):第 2 层 (展开
):
观察这个模式,在第
次迭代(或者说在递归树的第 层,根为第 0 层)之后,我们可以得到:(注意求和的上限是
,因为 是第 0 项)递归会持续进行,直到子问题的规模达到 1,即当
时。因为
,所以 。所以,递归的深度是
。当
时,我们代入上面的通用公式:现在我们来处理这两个部分。
第一部分(递归终止项):
我们知道
(对数的定义),所以, 。因此,这一项变为
。根据对数换底公式的一个性质
,我们有 。因为
(表示 是一个常数或者被常数界定),所以 。第二部分(各层代价之和):
这部分保持不变,它代表了递归过程中在每一层(除了最底层的基础情况)合并子问题解或分解问题所产生的代价之和。
将这两部分结合起来,我们就得到了引理的结果:
至此,引理 1 证明完毕。这个引理是主定理证明的核心步骤:它将递归式
分解为了两部分:一部分是所有叶子节点(基础情况)的总代价,另一部分是所有内部节点的总代价(即所有 项的总和)。主定理的三种情况实际上就是比较这两部分哪一个在渐近意义上占主导地位。证明主定理的三种情况
引理 1 告诉我们,对于
:我们将这个和式记为
。主定理的三种情况取决于函数
相对于 的增长速度,这实质上是在比较 与 的大小。情况 1: 若
对某个常数 成立。这意味着存在正常数
和 ,使得对于所有 且 是 的幂, 。 我们需要估计 :代入
的上界:因为
,上式变为:这是一个等比数列求和,公比
。因为 且 ,所以 。因此,
因为
是一个正的常数,且 是低阶项,所以将其代回引理 1 的结果:
因此,
。
(更严格地说,因为 是非负的,所以 。如果 ,那么 至少是某个小的正项,但其上界是 ,所以最终由 主导。)情况 2: 若
对某个常数 成立。我们先考虑
的情况: 。这意味着存在正常数
和 ,使得 对 。代入
的 界:这个和式中有
项,每一项都是 。因此,
。将其代回引理 1 的结果:
因此,
。如果
: 。当 接近 时,这一项接近 0。当 较小时,这一项接近 。下面证明这个和式的结果是
:为简化符号,设
。因为 ,则 。则
。因此,和式中的项可以写为
.因为
是一个常数,我们可以将其吸收到 符号中:我们可以将
因子提出来:现在,我们需要计算和式
,这是一个关于 的 次幂的和。令
。当 , 。当 , 。所以,和式可以改写为:
我们知道:
因此,
因为
,所以 .因为
,所以 。因此,
将这个结果代回
的表达式:所以
。情况 3: 若
对某个常数 成立,并且若 对某个常数 和所有足够大的 (正则性条件) 成立。正则性条件
可以被迭代应用:现在看和式
:因为
是非负的,我们只关注上界。我们知道
。所以,
.我们需要证明这个和式由
(即 的项) 主导。我们从后往前看。设
. 则 。因为
,所以 .一般地,
。所以
因为
,这是一个收敛的几何级数(即使项数趋于无穷也收敛)。因此,
。因为
本身就是和式中的第一项(当 时, ),且所有项非负,所以 。因此,
。将其代回引理 1 的结果:
在情况 3 中,条件
意味着 的增长速度快于 。因此,
。因此,
。
证毕。
对所有正整数证明:向上取整和向下取整
4.5 Akra-Bazzi 方法
4.5.1 简介
Akra-Bazzi 方法求解以下形式的递归式:
其中:
是一个实数, 是一个常数,使得对于 , 且 ,- 对于
, 是一个常数, - 对于
, 是一个常数, 是一个常数,且 是一个满足下面指定的多项式增长条件的非负函数。
多项式增长条件
定义: 如果存在正常数
注: 如果
总能找到唯一实数
递归式的解为:
例子:
- 若
,则 且 。 - 若
,则 且 。 - 若
,则 且 。 - 若
,则 且 。 - 若
,则 且 。
4.5.2 使用 Akra-Bazzi 方法
4.5.3 证明 Akra-Bazzi 方法
放一个简单的归纳证明,参考 Tom Leighton 的 Notes on Better Master Theorems for Divide-and-Conquer Recurrences。
定理 1: 给定一个由方程 (1) 指定形式的递归式,令
定理 1 的证明利用了以下来自微积分的简单引理。
引理 1: 如果
证明: 从多项式增长条件可得:
其中定义
其中
类似地,
其中我们定义
其中
使用归纳法来证明定理 1:将
根据
定理 1 的证明: 首先证明存在一个正常数
对包含
归纳步骤的论证如下:
前提是
还需证明上界,即存在一个正常数
上界与下界的证明几乎完全相同,不再完整复述。我们只需要确保
注: 如果
变种:
虽然上面分析的递归式类别相当广泛,但在实践中出现的递归式通常与方程 (1) 中指定的类别略有不同。例如,在算法设计中,以下形式的递归式很常见:
一般来说,在递归式中包含取整函数(floor 和 ceiling)并不会显著改变解的性质,但证明这一事实的过程往往相当繁琐且具有特殊性。接下来,我们将会描述一类通用的变种(包括取整函数),并表明该类中的变种不影响递归式的解(除了常数项)。特别地,定理 1 的解对于以下形式的所有递归式都成立:
其中
和 都满足上面指定的条件,- 存在某个常数
,使得当 时,对于 , , - 存在正常数
和 ,使得对于所有 ,所有 ,以及所有 , , 且 为足够大的常数,使得对于任何 和任何 , (a) (b) (c) (d) .
可以使用标准的泰勒级数展开和渐近分析来证明存在这样一个常数
例如,可以选择
为了分析更一般的递归式,需要下面这个和引理 1 相似的引理。
引理 2: 存在正常数
引理 2 的证明与引理 1 的证明相同,只是使用上面第 3 节的约束 3 来代替第 2 节的多项式增长条件。
定理 2: 给定一个方程 (2) 中指定的形式的递归式,令
证明: 证明与定理 1 的证明非常相似,主要区别在于使用了一个稍微更强的归纳假设。特别地,首先证明存在一个正常数
通过对包含
归纳步骤的论证如下:
前提是
上界的证明非常相似。在这种情况下,我们通过归纳证明存在一个正常数
基本情况如前所述。归纳步骤的论证如下:
前提是
因此,我们可以得出结论:
备注: 值得注意的是,对
是
5. 常见的分治算法
5.0 归并排序
上面介绍过,不再赘述。
5.1 Karatsuba 大数乘法
Karatsuba 算法是一种用于快速计算两个大整数乘积的算法。它比传统的小学乘法算法(按位相乘再相加)具有更好的渐近时间复杂度。
5.1.1 传统大数乘法算法
假设我们要计算两个
我们可以将每个数分成两半,每半大约有
其中
那么,
这个过程需要计算 4 个
加法和移位操作的时间复杂度是
因此,递归式为:
根据主定理,其中
由于
这就是传统算法的时间复杂度。
5.1.2 Karatsuba 算法的描述
Karatsuba 算法的核心思想是通过巧妙的代数变换,将 4 次
与上面类似,我们将
我们需要计算
Karatsuba 观察到
这样,我们只需要计算三次
然后,最终结果是:
算法步骤:
- 分解: 将
位数 分解为 ,每个约 位。 (这里假设 是偶数,如果不是,可以补零或者取 ) - 计算和: 计算
和 。这些是 的加法。 - 递归计算乘积:
- 组合结果:
- 计算
(两次 的减法) (两次移位和两次 的加法)
- 计算
5.1.3 Karatsuba 算法的时间复杂度
得到递归式: 算法进行了 3 次
位整数的乘法,以及常数次的 的加法、减法和移位操作。使用主定理:(其中
) , 。比较
与 : ,其中 。这符合主定理的情况 1,因此,
。
由于
5.2 Strassen 矩阵乘法
为了方便起见,我们只考虑方阵,且矩阵边长为 2 的幂。
5.2.1 矩阵乘法的定义
给定两个矩阵
5.2.2 普通的矩阵乘法算法及时间复杂度
对于两个
由于矩阵
因此,普通矩阵乘法算法的时间复杂度是
如果进行普通的分治算法:
如果将
则
这需要 8 次
根据主定理,
所以,
5.2.3 Strassen 矩阵乘法算法的描述
- 核心:让递归树的分支减少一个:递归进行 7 次而不是 8 次
矩阵乘法。 - 步骤:
- 把每个输入矩阵分解为 4 个
的子矩阵: ; - 创建 10 个
的矩阵 ,其中 ,每个矩阵保存步骤 1 中创建的矩阵的和或差。 - 通过步骤 1 和 2 创建的 18 个矩阵,计算出 7 个
矩阵的乘积 ,其中 ; - 将
中的不同组合相加或相减,得到结果矩阵的四个部分 。 - 将上述四个矩阵拼接成一个
的矩阵 。
- 把每个输入矩阵分解为 4 个
- 详细过程:
- 步骤 2:
- 步骤 3:
- 步骤 4:
- 步骤 2:
- 可以看到,我们用了 7 次
的矩阵乘法 和 18 次 的矩阵加法,完成了 的矩阵乘法!😃(将一次乘法优化成 18 次加法 😦)
5.2.4 Strassen 矩阵乘法算法的时间复杂度
得到递归式:
- 步骤 1. 花费
时间,步骤 2. 4. 均花费 时间,步骤 3. 进行 7 次 的矩阵乘法,得到如下递归式:
- 步骤 1. 花费
使用主定理(其中
, ) 。比较
与 : 。因为
,所以存在 ,使得 ,这符合主定理的情况 1。因此,
。因为
,所以 Strassen 算法的 优于普通矩阵乘法的 。
5.3 快速数论变换 (Fast Number Theoretic Transformation, NTT)
快速数论变换 (NTT) 是一种在特定有限域(通常是整数模素数
5.3.1 背景:多项式乘法与卷积
两个多项式
(其中,如果
直接计算卷积需要
其原理是:
- 点值表示: 将多项式
和 分别在 个选定的点上求值,得到它们点值表示 和 。 必须大于等于 (结果多项式的次数加1)。 - 点值乘法: 对于每个点
,计算 。这需要 次乘法。 - 插值: 从
的 个点值表示 中恢复出系数表示 。
DFT (以及 FFT 作为其快速算法) 使用复数域中的
5.3.2 NTT 的数学基础
- 有限域/环: NTT 在整数模素数
的环 中进行运算。 - 原根 (Primitive Root of Unity): 为了使 NTT 能够顺利进行,我们需要一个元素
满足: for 是变换的长度,通常取为大于等于结果多项式次数的最小的 2 的幂。 这样的 称为 中的 次原根。 为了存在 次原根,素数 必须满足 是 的倍数 (即 )。这样的素数称为 NTT-friendly prime。例如,对于 , 是一个常用的 NTT 素数,其一个 次原根 (对于合适的 ) 可以是 ,其中 是 的一个原根 (primitive root modulo ),例如 对于 。
- 逆元: 在进行逆变换 (INTT) 时,需要计算
。这要求 与 互素,由于 是素数且通常 ,这个条件一般都满足。
5.3.3 NTT 和 逆NTT (INTT) 的定义
给定一个序列 (多项式系数)
逆 NTT (INTT) 定义为:
其中
5.3.4 使用 NTT 计算多项式乘法(卷积)的步骤
设要计算
- 选择参数:
- 确定结果多项式
的最大可能次数 。 - 选择变换长度
,为大于 的最小的 2 的幂。 - 选择一个素数
使得 ,且 足够大以容纳 的系数(即 的任何系数 的绝对值都小于 ,如果系数可能为负;或者 如果系数非负)。 - 找到
中的一个 次原根 。
- 确定结果多项式
- 填充: 将多项式
和 的系数序列用 0 填充到长度 。 - 正变换: 计算
和 。这使用类似 Cooley-Tukey FFT 的分治算法,时间复杂度为 次模 运算。 - 点值乘法: 计算点值乘积序列
,其中 for 。这需要 次模 乘法。 - 逆变换: 计算
。这也是 次模 运算。 序列 的前 个元素就是乘积多项式 的系数。
5.3.5 NTT 的时间复杂度
NTT 和 INTT 的分治实现(如 Cooley-Tukey 算法)都具有
- 参数选择和填充:
。 - 两次 NTT:
。 - 点值乘法:
。 - 一次 INTT:
。 因此,使用 NTT 计算两个次数近似为 的多项式乘法的总时间复杂度是 。
这比 Karatsuba 算法的
6. 常见的用分治法解决的问题
6.1 最近点对问题
在一个平面上的
6.2 最大子数组和问题
给定一个整数数组(可正可负),找到一个具有最大和的连续子数组。
此问题有更优解法
对于这个问题来说,分治算法的时间复杂度为
然而,存在更优的解法(动态规划或贪心),时间复杂度为
6.3 凸包问题
给定平面上
此问题有更优解法
对于这个问题来说,分治算法的时间复杂度为
然而,存在更优的解法:Jarvis 算法的时间复杂度为
习题
20401 [2.2] 使用主定理,求解以下递归式,并指出运用了主定理的哪种情况。
- (a)
- (b)
- (c)
- (d)
- (e)
- (f)
- (g) [4.3M]
(思考:这个递归式能直接用主定理的三种基本情况求解吗?为什么?)
- (a)
对于递归式
,使用代入法证明其解为 。对于归并排序的递归式
,请:- (a) 画出其递归树。
- (b) 计算出树的每一层的代价总和。
- (c) 计算树的高度,并求出所有层代价的总和,从而得出
的渐近界。
使用换元法,求解递归式
的时间复杂度。本教程 6.2 节提到了最大子数组和问题。请设计一个分治算法来解决它。
- (a) 分解:如何将数组分成子问题?
- (b) 解决:子问题的解是什么?
- (c) 合并:这是最关键的一步。一个最大和的子数组可能存在于三个地方:① 完全在左半部分;② 完全在右半部分;③ 跨越了中点。请详细描述如何在线性时间
内找到跨越中点的最大子数组,并结合左右子问题的解,得到原问题的解。 - (d) 写出该分治算法的递归式并求解其时间复杂度。
在一个数组
中,如果存在 且 ,则称 是一个逆序对。请设计一个分治算法来计算一个数组中逆序对的总数。- 提示:尝试修改归并排序算法。在
merge()
过程中,当右边子数组的元素R[j]
被复制回原数组时,左边子数组L
中还剩下多少个元素?这与逆序对有什么关系? - 分析你的算法的时间复杂度。
- 提示:尝试修改归并排序算法。在
本教程 5.1 节描述的 Karatsuba 算法中,我们把一个大数乘法分解成 3 个规模为
的乘法。那我们是否可以继续优化,比如把问题分解成 5 个规模为 的子问题?如果可以,那么时间复杂度会是多少?它会比 Karatsuba 算法更好吗?在本教程的
merge()
函数实现中,使用了宏INT_MAX
。如果不允许使用这个值,你将如何修改merge()
函数中的主循环来完成合并操作?