分治法
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); } }
实现
merge()
函数开辟新数组,把数组元素复制一份到新数组,以便稍后移动回原来的数组;
确定数组大小: 第一个数组存储从 p 到 mid 的元素,第二个数组存储从 mid + 1 到 r 的元素
定义两个对象n1
和n2
,分别表示第一个数组和第二个数组的元素个数cconst int n1 = mid - p + 1; const int n2 = r - mid;
开辟数组:使用
malloc()
函数 由于末尾要放一个无穷大的数,所以要多开一个位置cint* L = malloc((n1 + 1) * sizeof(int)); int* R = malloc((n2 + 1) * sizeof(int));
复制元素到新数组
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;
依次比较大小,把较小的元素放入原数组; 由于一共只有从 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; } }
释放内存
cfree(L); free(R);
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); }
测试代码
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; }
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 是正整数。那么:
- 为什么是 b 的幂?
- 对所有正整数证明:向上取整和向下取整
4.5 Akra-Bazzi 方法
4.5.1 简介
Akra-Bazzi 方法 求解以下形式的递归式:
递归式的解为:
4.5.2 使用 Akra-Bazzi 方法
4.5.3 证明 Akra-Bazzi 方法
4.5.4 Tri- Merge Sort
5. 常见的分治算法
5.1 Karatsuba 大数乘法
5.2 Strassen 矩阵乘法
为了方便起见,我们只考虑方阵,且矩阵边长为 2 的幂。
能想出来这种东西的人一定是个天才! --mdr
5.2.1 矩阵乘法
给定两个矩阵
5.2.2 普通的矩阵乘法算法及时间复杂度
我们需要计算
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. 2. 4. 均花费
- 使用主定理(其中
, ) 观察主定理的第一种情况,我们发现:
显而易见比平常的的算法时间复杂度低。