递归与分治策略
第2章 递归与分治策略
分治,递归概述
递归是一种具体的算法,分治是一种思想;实现分治这种思想可以使用递归这种具体的算法也可以使用其他的算法,也就是说分治可以用递归来实现,也可以不用递归。
分治是解编程题常用的一种思想,而大多数分治思想都是用递归来实现的。
分治
分治(divide and conquer)的全称称为“分而治之”,分治即是将大问题划分为若干个规模较小、可以直接解决的子问题,然后解决这些子问题,最后将这些子问题的解合并起来,即是原问题的解。
分治是一种思想,它不涉及到具体的算法,而大多数情况下,分治都是借由递归来实现的。
递归
递归是一个很难阐述的概念。从c语言角度来讲,递归就是这个函数反复调用自身,然后将问题一步步地缩小,直到这个问题已经缩小到可以直接解决的程度,然后再一步一步地返回,最终解决原问题。
递归的逻辑中有两个重要的概念:
1.递归边界。递归边界是分解的尽头。
2.递归式。递归式是将问题规模一步步缩小的手段。
1. 分治策略:
将一个难以直接解决的大问题, 分割成一些规模较小的相同子问题, 各个击破, “分而治之”
- 设计思想:
- 将要求解的问题划分成k 个小问题
- 然后对这k 个子问题递归求解
- 合并子问题的解
如下图:
2. 递归
定义1. [递归函数] 用函数自身给出定义的函数称为递归函数.
定义2. [递归算法] 直接或间接地调用自身的算法称为递归算法.
注释:
对于函数f(n) 的递归定义, 必须满足如下条件:
- 基本部分: 其中对于n 的一个或多个值, f(n) 必须是直接定义的(即非递归)
- 递归部分: 右侧所出现的所有f 的参数都必须有一个比n 小, 以重复运用递归部分来改变右侧出现的f ,直至出现f 的基本部分
总结一下:
- 基本部分常数
- 递归部分慢慢变小,最终出现基本部分,也就变成了常数
2.1 例2-1 阶乘函数
阶乘函数f(n)可递归地定义为
- 阶乘函数的自变量n 的定义域是非负整数
- 定义式的第(1)式给出了函数的初始值, 是非递归定义
- 定义式的第(2)式左右两边都引用了阶乘函数记号, 是
一个递归定义式
算法描述
Algorithm. F(n).
if n = 0:
return 1
else:
return F(n - 1) * n
阶乘函数算法计算复杂性分析
-
算法输入规模为n
-
算法基本操作是乘法
-
算法基本操作执行次数记作M(n)
计算M(n)
-
当n > 0 时, F(n) = F(n - 1) * n
-
计算F(n) 时, 用到的乘法数量M(n) 需要满足公式
M(n) = M(n - 1) + 1, n > 0F(n)用到的乘法要比F(n-1)多1位,因为 F(n) = F(n - 1) * n
-
n=1时
F(1) = F(0) * 1
又因为F(0)==1
【由题目可直接得到,易知M(0)=0,也就是计算F(0) 时, 用到的乘法数量为0,也就是不用算) 】
综上所述:
易得M(n) = n
2.2 递归算法复杂性分析方案
(1) 决定用哪个参数表示输入规模度量,上例为n
(2) 找出算法的基本操作,上例为“乘法”
(3) 检查基本操作的执行次数是否只依赖于输入规模
(4) 建立算法基本操作执行次数的递归式及相应的初始条
件
(5) 解递归式, 或者确定它的渐近函数
2.3 例2-2 Fibonacci 数列
无穷数列1, 1, 2, 3, 5, 8, 13, . . . , 称为Fibonacci 数列:
算法描述
Algorithm. Fib(n)
if n <= 1:
return 1
return Fib(n - 2) + Fib(n - 1)
2.4 例2-3 Ackerman 函数(???)
Ackerman 函数A(n,m) 包含两个独立整形变量m >= 0,n >= 0, 定义如下:
-
m=0时
A(n, 0) = n + 2
-
m=1时
A(1, 1) = A(A(0, 1), 0) = A(1, 0) = 2 A(n, 1) = A(A(n - 1, 1), 0) = A(n - 1, 1) + 2 = 2 * n
-
m=2时
A(n, 2) = A(A(n - 1, 2), 1) = 2A(n - 1, 2) A(1, 2) = A(A(0, 2), 1) = A(1, 1) = 2 A(n, 2) = 2^(n)
-
m=3时(看ppt)
2.5
2.6 面试题 08.06. 汉诺塔问题
Tower of Hanoi
相传在很久以前,有个寺庙里的几个和尚整天不停地移动着 64 个盘子,日复一日,年复一年。据说,当 64 个盘子全部移完的那一天就是世界末日...
问题描述
有 A,B,C 三根柱子,A 上面有 n 个盘子,我们想把 A 上面的盘子移动到 C 上,但是要满足以下三个条件:
-
每次只能移动一个盘子;
-
盘子只能从柱子顶端滑出移到下一根柱子;
-
盘子只能叠在比它大的盘子上。
解题思路:递归与分治
这是一道递归方法的经典题目,乍一想还挺难理清头绪的,我们不妨先从简单的入手。
假设 n = 1
只有一个盘子,很简单,直接把它从 A 中拿出来,移到 C 上;
如果 n = 2
呢?这时候我们就要借助 B 了,因为小盘子必须时刻都在大盘子上面,共需要 4 步。
如果 n > 2
呢?思路和上面是一样的,我们把 n 个盘子也看成两个部分,一部分有 1 个盘子,另一部分有 n - 1 个盘子。
观察上图,你可能会问:“那 n - 1 个盘子是怎么从 A 移到 C 的呢?”
注意,当你在思考这个问题的时候,就将最初的 n 个盘子从 A 移到 C 的问题,转化成了将 n - 1 个盘子从 A 移到 C 的问题, 依次类推,直至转化成 1 个盘子的问题时,问题也就解决了。这就是分治的思想。
而实现分治思想的常用方法就是递归。不难发现,如果原问题可以分解成若干个与原问题结构相同但规模较小的子问题时,往往可以用递归的方法解决。具体解决办法如下:
-
n = 1
时,直接把盘子从 A 移到 C; -
n > 1
时,- 先把上面 n - 1 个盘子从 A 移到 B(子问题,递归);
- 再将最大的盘子从 A 移到 C;
- 再将 B 上 n - 1 个盘子从 B 移到 C(子问题,递归)。
代码如下:
package main
func hanota(A []int, B []int, C []int) []int {
if A == nil{
return A
}
move(len(A),&A,&B,&C)
return C
}
// n:要移动的盘子数目a. A,B,C分别代表当前情况下的第1,2,3个柱子
func move(n int ,A,B,C *[]int){
if n==1{
*C=append(*C,(*A)[len(*A)-1])// 不能直接写(*A)[len(0)-1],因为最底下可能存在对移动不影响的大盘子
*A=(*A)[:len(*A)-1]
}else {
// 将除最底下的盘子的上面的移动到第二个
move(n-1,A,C,B)
//把最底下的由第一个移动到第三个
move(1,A,B,C)
//把第二个整体移动到第三个
move(n-1,B,A,C)
}
}
func main(){
}
算法复杂性分析
- 选择盘子的数量n 作为输入规模度量
- 选择盘子的移动作为算法的基本操作
- 移动次数为M(n)
移动n个盘子的任务被拆解为:
(n-1)个盘子移动两次
1个盘子移动一次
Hanoi问题求解算法
Algorithm. Hanoi(n, source, helper, target)
if n > 0
Hanoi(n - 1, source, target, helper)
if source
target.append(source.pop())
hanoi(n - 1, helper, source, target)
2.7 递归小结
-
结构清晰, 可读性强, 而且容易用数学归纳法来证明算法的正确性, 为设计算法、调试程序带来很大方便
-
递归算法的运行效率较低, 无论是耗费的计算时间还是占用的存储空间都比非递归算法要多
-
在递归算法中消除递归调用, 使其转化为非递归算法
3. 分治
3.1 分治法的基本思想
分治法解决问题一般特征
(1) 问题的规模缩小到一定程度就可以容易地解决
(2) 问题可分解为若干规模较小的相同问题
(3) 利用子问题的解可以合并为该问题的解
分治算法步骤
- 分(Divide): 将原始问题分解为较小的相同子问题
- 治(Conquer): 递归解决这些子问题
- 合并(Combine): 将较小子问题的解合并获得较大问题的解, 直到获得原始问题的解
分治是一种思想,递归是一种具体算法,分治可以用递归这种算法也可以不用递归这种算法
分治算法时间复杂性公式
- a \(\geq\) 1: 每次分解时产生的子问题的个数
- b > 1: 每次分解时问题大小下降的因子
- f(n): 表示分解问题和合并子问题解的代价
3.2 主(Master)定理
定理1. [主定理] 给定常数a \(\geq\) 1, b > 1, d \(\geq\) 0, 假设n = bk(k = 1, 2...), 如果f(n) = O(nd), 即
则
3.3 二分搜索技术
1. 查找问题定义:
给定按升序排序的n 个元素A[0...n - 1], 在n 个元素中找出一特定元素x.
2. 分治法解决问题一般特征
(1) 问题的规模缩小到一定程度就可以容易地解决
(2) 问题可分解为若干规模较小的相同子问题
(3) 利用子问题的解可以合并为该问题的解
(4) 问题所分解出的子问题之间不包含公共子问题
3. 对此处的问题进行具体分析
(1)如果n = 1, 则只要比较这个元素和x 就可以确定x 是否存在(条件1成立)
(2)比较x 和A 的中间元素A[m],
-
若x = A[m], 则x在A 中的位置是m; 若x < A[m], 则在A[0...m-1]中查找x
-
若x > A[m], 则在A[m + 1...n] 中查找x
(条件2,3成立)
(3)问题分解出的子问题相互独立(条件4成立)
4. 二分搜索算法
5. 时间复杂度分析
3.4 二分法&分治例题
3.5 大整数乘法
研究高效的大整数乘法算法的具有广泛的现实需求
- int: -32768-32767 范围的整数
- unsigned int: 0-65535 范围的正整数
- long: -2147483648-2147483647 的整数
- unsigned long: 0-4294967295 的正整数
笔算算法
两个n 位整数相乘, 第一个数中的n 个数字都分别被第二个数中的n 个数字相乘, 这样就一共要做n2 次乘法.
整数相乘例子
例2. 整数23 和14 相乘. 这两个数字可以这样表示:
23 = 2 * 101 + 3 * 100
14 = 1 * 101 + 4 * 100
现在把它们相乘:
23 * 14
= (2 * 10 1 + 3 * 10 0) * (1 * 10 1 + 4 * 10 0)
= (2 * 1)102 + (2 * 4 + 3 * 1)10 1 + (3 * 4)10 0
产生一个正确的结果322, 但使用了4 次乘法
整数相乘算法
设X 和Y 都是n 位二进制数, 计算它们的乘积XY .将X 和Y 都分为2段, 每段的长为n=2 位(假设n 是2的幂)
由此, X = A * 2n/2 + B, Y = C * 2n/2 + D
例如
x = 10110110, xL = 1011, xR = 0110, 则
x = 1011 * 24 + 0110
3.2 主(Master)定理
定理1. [主定理] 给定常数a \(\geq\) 1, b > 1, d \(\geq\) 0, 假设n = bk(k = 1, 2...), 如果f(n) = O(nd), 即
则
高斯发现
德国数学家Carl Friedrich Gauss(1777-1855) 很早就发现, 两个复数乘法初看上去涉及4 次乘法, 但实际上可以化简为3 次乘法运算
改进算法
设X 和Y 都是n 位二进制数, 计算它们的乘积XY .X = A2n=2 + B, Y = C2n=2 + D. 把XY 写成另一种形式
上式只需做3 次n=2 位整数的乘法, 6 次加、减法和2次移位.
算法复杂性分析
写出改进算法时间复杂性递归公式(做3 次n=2 位整数的乘法, 6 次加、减法和2 次移位), 并用主定理计算T(n)
其解为
3.6 Strassen 矩阵乘法
(1)由定义所求的的矩阵乘法的时间复杂度
- 设\(A\) 和\(B\) 是两个n \(\times\) n 矩阵, 它们的乘积C 是一个n \(\times\) n 矩阵. 乘积矩阵$C \(中元素\)c_{ij}$ 定义为
- 计算矩阵乘积C, 每计算一个\(c_{ij}\), 需要做n 次乘法和n - 1 次加法, 因此, 求出矩阵C 的\(n^{2}\) 个元素所需的时间为\(O(n^3)\)
矩阵里的某个元素\(c_{ij}\)需要做n-1+n次基本操作,一共矩阵里有\(n^{2}\)个元素,所以最后的时间复杂度为\(O(n^3)\)
(2)Strassen 分治法
德国数学家Volker Strassen 在1969 年提出采用分治方法, 将矩阵A, B 和C 中每一矩阵都分块成4个大小相等的子矩阵, 每个子矩阵都是\(\frac{n}{2}\times \frac{n}{2}\)的方阵
所以可将方程$C = AB $重写为:
由此可得
- 如果n = 2, 则2 个2 阶方阵的乘积共需8 次乘法和4 次加法
- 当子矩阵的阶大于2 时, 可以继续将子矩阵分块, 直到子矩阵的阶降为2
(3)Strassen 矩阵乘积算法复杂性分析
计算2 个 n 阶方阵的乘积转化为
-
计算8 个 \(\Large \frac{n}{2}\) 阶方阵的乘积
-
计算4 个\(\Large \frac{n}{2}\)阶方阵的加法.
2 个 \(\Large \frac{n}{2} \times\frac{n}{2}\) 矩阵的加法可以在$O(n^2) $时间内完成
因为 \(\Large \frac{n}{2}\) 阶方阵有\(\Large\frac{n^2}{4}\)个元素
该递归方程的解是\(T(n) = O(n^3)\)
计算过程:
\(\because 8>2^2\)
\(\therefore T(n)=n^{log_{2}{8}}\)
主(Master)定理
定理1. [主定理] 给定常数a \(\geq\) 1, b > 1, d \(\geq\) 0, 假设n = bk(k = 1; 2; : : 😃, 如果f(n) = O(nd), 即
则
3.7 归并排序(MERGESORT)
排序问题: 给定长度为n 的一个数组, 设计一个有效的排序算法, 产生输入数组的一个重排, 使数组元素按从小到大的顺序排列.
(1)合并排序算法基本思想
- 将待排序数组A[0, n - 1] 分成大小大致相同的2 个子数组
- $A[0, \lfloor\frac{n}{2}\rfloor-1] $
- $A[\lfloor\frac{n}{2}\rfloor,n-1] $
- 分别对2 个子数组进行排序
- 将排好序的子数组合并成为一个有序数组
(2)MERGESORT 算法
把一个数组从无序变成有序
MERGESORT(A)
- if len(A) <= 1 return A
- q \(\leftarrow\) \(\frac{len(A)}{2}\)
- L \(\leftarrow\) A[0...q], R \(\leftarrow\) A[q + 1...len(A) - 1]
- L \(\leftarrow\) MERGESORT(L)
- R \(\leftarrow\) MERGESORT(R)
- return MERGE(L,R)
MERGE(L,R)
- i \(\leftarrow\) 0; j \(\leftarrow\) 0; aux \(\leftarrow\) [ ]
- while i < len(L) and j < len(R)
- if L[i] \(\leq\) R[j]
- aux.add(L[i]) ; i \(\leftarrow\) i + 1
- else aux.add(R[j]) ; j \(\leftarrow\) j + 1
- if i == len(L)
- aux.add(R[j : len(R) - 1])
- else aux.add(L[i : len(L) - 1])
- return aux
(3)时间复杂度分析
- 合并排序算法最坏时间复杂性T(n) 满足\[T(n) = \begin{cases} O(1) &\text{,n = 1}\\[2ex] 2T(\frac{n}{2}) + O(n) &\text{,n > 1}\\[2ex] \end{cases} \]
O(n):来源是Merge函数因为merge函数在某种程度上对n/2进行了遍历
while i < len(L) and j < len(R)
\(O(n)\)是因为使用了非递归的MERGE函数
- 根据主定理, 得出T(n) = O(n log n)
主(Master)定理
定理1. [主定理] 给定常数a \(\geq\) 1, b > 1, d \(\geq\) 0, 假设n = bk(k = 1; 2; : : 😃, 如果f(n) = O(nd), 即
则
3.8 快速排序
算法描述
算法思想: 对给定数组中的元素进行重新排列, 确定数组中元素的一个位置q, 得到一个快速排序的划分
QUICKSORT功能,将数组A的A[p]到A[q]变为有序(比如从小到大)
QUICKSORT(A, p, r)
- if p < r
- then q \(\leftarrow\) PARTITION(A, p, r)
- QUICKSORT(A, p, q)
- QUICKSORT(A, q + 1, r)
PARTITION在A[p]到A[r]以A[p]为界划分成两部分A[p]的左边比A[p]小,A[p]的右边比A[p]大
PARTITION(A, p, r)
- x \(\leftarrow\) A[p], i \(\leftarrow\) p + 1, j \(\leftarrow\) r
- while i \(\leq\) j
- while A[j] \(\geq\) x and j \(>\) p
- j \(\leftarrow\) j -1
- while A[i] \(\leq\) x and i \(<\) r
- i \(\leftarrow\) i + 1
- if i < j then A[i] \(\leftrightarrow\) A[j]
- i \(\leftarrow\) i + 1, j \(\leftarrow\) j - 1
- A[p] \(\leftrightarrow\) A[j], return j
(1)快速排序算法复杂性分析
快速排序算法的运行时间依赖于:
- 划分的平衡与否
- 划分的平衡与否依赖于算法的输入
- 如果划分平衡, 时间复杂性为\(O(n log n)\)
- 如果划分不平衡, 时间复杂性为\(O(n^2)\)
最坏时间复杂性
Quicksort 的最坏情况发生在Partition 输出的两个区域中, 一个仅包含1 个元素, 另一个包含n - 1个元素的情况;假设上述不平衡的划分发生在算法的每一步迭代中, 则
排序过程中每次都出现上述情况就是最坏情况
每次问题的规模只减小了1,易知时间复杂度为\(O(n^2)\)
最优时间复杂性
设如果Partition 算法产生两个大小为n=2 的区域,则
根据主定理, 可以得出
(2)随机化快速排序算法
- 快速排序算法取决于划分的对称性
- 采用随机策略进行划分
- 算法每一步在数组A[p, r] 中随机选出一个元素作为划分元素, 可以期望划分是较对称的
RANDOMIZED-QUICKSORT算法
RANDOMIZED-QUICKSORT(A, p, r)
- if p < r
- then q =RANDOMIZED-PATITION(A, p, r)
- RANDOMIZED-QUICKSORT(A, p, q)
- RANDOMIZED-QUICKSORT(A, q + 1, r)
RANDOMIZED-PARTITION算法
RANDOMIZED-PARTITION(A, p, r)
- i=Random(p, r)
- exchange A[p] \(\leftrightarrow\) A[i]
- Return PARTITION(A, p, r)
3.9 循环日程赛表
问题描述
设有\(n = 2^k\) 运动员要进行网球循环赛,设计一个满足如下要求的比赛日程表:
-
每个选手必须与其他n - 1 个选手各赛一次
-
每个选手一天只能赛一场
-
循环赛一共进行n - 1 天
设计成有n 行和n - 1 列的表
分治算法
- 将所有选手对分为两半, n 个选手的比赛日程表可以通过为\(\Large\frac{n}{2}\)个选手设计的比赛日程表来完成
- 递归地对选手进行分割, 直到只剩下两个选手为止
时间复杂性分析
分治法时间复杂性如下:
根据主定理, \(T(n) = O(n log n)\)
4. 本章小结
-
分治法是一种通用算法设计技术
-
许多分治算法的时间复杂性T(n) 满足方程
\[T(n) = aT(\frac{n}{b}) + f(n) \] -
(Master)主定理确定了该方程解的增长次数