第三章 递归与分治
递归的基本思想
1. 引子-故事中的故事
老和尚讲故事:“从前有个庙,庙里有个老和尚,老和尚给小和尚讲故事,讲的什么故事呢?故事是:从前有个庙,庙里有个老和尚,老和尚给小和尚将故事,讲的什么故事呢?故事是......”
可以将上述描述用递归程序的方式表示出来:
void Story()
{
printf("从前有个庙,庙里有个老和尚,老和尚给小和尚讲故事, \r\n");
printf("讲的什么故事呢?故事是:\r\n");
Story();
}
递归是常用的一种求解问题的思想,包括三个基本的要素:
1. 递归终止条件
2. 终止处理办法
3. 递归处理方法
举例:Fibonacci数列:意大利数学家Fibonacci在年写成的《计算之书》提出这样一个有趣的问题:每一对兔子每个月不多不少恰好能生一对(一雌一雄)新兔子,而且每对新生兔子出生两个月后就成熟并具备繁殖能力;另外,假定所有的兔子都不会死亡。假如养了初生的小兔一对,试问 n 个月后共有多少对兔子?
分析:
第 n 个月的兔子对数为 F(n),它们按照兔子的成熟属性可以分为两类:
成熟兔子对 = ? 第 n-1 个月的兔子数 F(n-1)
新生兔子对 = ? 第 n-2 个月的兔子书 F(n-2)
则递归方程为 ,根据递归方程,写出递归程序:
long fib(int n)
{
if (n <= 1) return 1;
return fib(n-1) + fib(n-2)
}
总结:
1. 递归是一种特殊的迭代,但是在迭代前不知道还要迭代多少次。
2. 递归函数一定有参数,且参数会在迭代的过程中逐步逼近某个值。
3. 递归函数中一定有处理终点,而这个点就是递归出口。
2. 递归算法的实例
字符串的全排列:给定一组互不相同的字符,求这组字符的全排列。输入:一个字符串;输出:输出该字符串的全排列,排列的先后顺序不影响结果。
例:输入 ABC;输出 ABC ACB BAC BCA CAB CBA
问题分析:
简单情景,当输入字符串长度为 1 时,可以直接输出;
当输入的字符串长度超过 1 时,只需要从字符串中选一个字符作为输出字符串的首字符,对其余的字符进行递归处理(全排列)即可。递归程序如下,时间复杂度为 O(n!)。
void permutations(string str, int i, int n)
{
if (i == n-1)
{//递归出口
cout << str << endl;
return;
}
//递归长度大于 1 的字符串
for (int j = i; j < n; j++)
{ //交换当前第一个字符与其他位置字符
swap(str[i], str[j]); // STL 函数
//递归处理子串 str[i+1, n-2]
permutations(str, i+1, n);
// 还原到输入宗富川 str 的顺序
swap(str[i], str[j]);
}
分治策略的基本原理
分:将规模比较大的问题分解为若干个规模比较小的问题,若分一次不够,则可以递归处理,将分解的子问题的继续分解,直至分解的子问题为规模足够小基础问题。
治:求解规模足够小的基础问题
合:将求出的小规模问题的解合并为一个更大规模的问题的解,自底向上逐步求出原来问题的解。
因此分治算法可以细分为三个阶段:Divide、Conquer、Combine。Divide 阶段是把原问题分割成小问题;Conquer阶段是递归处理流程;Combine阶段是运用小问题的答案合成出原问题的解答。
分治算法的框架程序如下:
divide_and_conquer(P){
(1) if (|p| <= n0) adhoc(P); // 递归出口,用特定的程序解决基础问题
(2) divide P into smaller subinstances P1, P2, ..., PK // 分解出子问题
(3) for (i = 1, i<= k, i++)
yi = divide_and_conquer(Pi); // 递归求解各个子问题
(4) return merge(y1, y2, ..., yk); // 将各个子问题的解合并为原问题的解}
设计分治策略,把原问题分解成 k 个规模较小的子问题,这个步骤是分治算法的基础和关键,人们往往遵循两个原则:
1. 平衡子问题原则,分割出的 k 个子问题其规模最好大致相当;
2. 独立子问题原则,分割出的 k 个子问题之间重叠越少越好,最好 k 个子问题是相互独立,不存在重叠在问题。
1. 分治策略-Master定理
【Master定理】:假设 a >= 1 和 b >= 1 是常数,f(n) 是定义在非负整数上的一个确定的非负函数,T(n) 也是定义在非负整数上的一个非负函数,且满足递归方程:T(n) = aT(n/b) + f(n),如果 f(n) 符合下述三类条件,T(n)的渐近复杂度为:
(1)若对于某常数 ,有
(f(n)的上界),则
;
(2)若 ,则有
;
(3)若存在常数 ,有
(f(n)的下界),且对于某常数 c>1 和所有充分大的正整数 n 有
,则有
。
例1:求 的渐近阶。
利用 Mater 定理,其中 a = 9, b = 3, f(n) = n,则 ,则时间复杂度为 O( n^2)。
例2:求 的渐近阶。
利用 Master 定理,a = 1, b = 3/2, f(n) = 1,则 ,则时间复杂度为 O(logn)。
例3:求 的渐近阶。
利用 Master 定理,其中 a = 2, b = 2, f(n) = n^2,则 ,则时间复杂度为 O(n^2)
例4:求 的渐近阶。
利用 Master 定理,a = 2, b = 2, f(n) = nlog(n),则 ,可以验证
是 f(n)的下界,但是不满足
,因此无法满足 Master 定理,需要使用推导的方法得到最终的时间复杂性函数。
2. 合并排序
任意给定一个包含 n 个整数的集合把 n 个整数按升序排列。
输入:每个测试用例包括两行,第一行输入整数个数,第二行输入 n 个整数,数与数之间用空格隔开。最后一行包含-1,表示输入结束。
输出:每组测试数据的结果输出占一行,输出按照升序排列的 n 个整数。
样例输入:
7
49 38 65 97 13 27
-1
样例输出:
13 27 38 49 65 76 97
合并排序基本思想:
分:根据整数集合的规模把原始的整数集合(记为A = {a[l], ..., a[r]})平均分成两部分:A1 = {a[l], ..., a[(l+r)/2]} 元素为第一部分,A2 = {a[(l+r)/2 + 1, ..., a[r]} 为第二部分。
治:如果划分后的子集 A1 和 A2 只包含一个整数,则不需要任何操作,把这单个整数当成以排好序的集合,否则,把该子集继续分割,然后递归调用。
合:设计子程序 merge (合并程序)把两个已经升序排列的子集B1,B2合并为一个整体升序排列的集合 B。
void mergeSort(int iDatas[], int iBuffer[], int iLow, int iHigh){
if (iHigh > iLow){
int iMid = (iLow + iHigh)/2;
mergeDort(iDatas, iBuffer, iLow, iMid);
mergeSort(iDatas, iBuffer, iMid, iHigh);
merge(iDatas, iBufferm, iLow, iMid, iHigh);
for (int i == iLow; i<= iHigh; i++)
iDatas[i] = iBuffer[i];
}