学习要点
1)理解递归的概念
直接或者间接调用自身的算法,当有多个算法构成嵌套调用的时候,按照“后调用先返回”的原则进行,函数之间的信息传递必须通过栈来实现,整个程序运行时候所需要的数据空间都安排在栈中,每调用一次,就在栈顶分配存储区间,每退出一个,就释放在栈顶的存储区间。如果从第i层调用递归,然后进入之后,调用第i+1层。返回第i层递归调用,则返回第i-1层调用。
2)分治法适用的条件
该问题的规模缩小到一定的程度就可以容易地解决;
该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质
利用该问题分解出的子问题的解可以合并为该问题的解;
该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
3)掌握有效的分治策略
基本步骤:n分解为k个小规模问题(这些问题相互独立,与原问题相同)--> 递归解决这些子问题 --> 子问题合并得到原问题的解
4)计算时间复杂度的主定理
总体思想就是
分解:对k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进行下去,直到问题规模足够小,很容易求出其解为止。
合并:将求出的小规模的问题的解合并为一个更大规模的问题的解,自底向上逐步求出原来问题的解
举例子
1、fibo数列--我们观察结束条件,和递归方程
第n个Fibonacci数可递归地计算如下:
int fibonacci(int n)
{
if (n <= 1) return 1;
return fibonacci(n-1)+fibonacci(n-2);
}
2、排列问题--全排列,观察如何分解子问题
设计一个递归算法生成n个元素{r1,r2,…,rn}的全排列。
我们把ri拿出来,放在最前面,于是(ri)perm(X)表示在全排列perm(X)的每一个排列前+前缀ri得到的排列。
于是可以归纳定义出全排列的方法:(把问题规模缩小)
当n=1时,perm(R)=(r),其中r是集合R中唯一的元素;
当n>1时,perm(R)由(r1)perm(R1),(r2)perm(R2),…,(rn)perm(Rn)构成。每个元素都有编程第一个元素的可能性,那就交换把它们放到第一个,然后递归调用,知道剩下一个元素就可以结束了
//产生list[k:m]的全排列
void Perm(Type list[], int k, int m){
//结束条件
if(k == m){
for(int i=0; i<=m; i++) cout<<list[i];
cout<<endl;
}
//单层循环
else
for(int i=k; i<=m; i++){
//还有多个元素等待排列,分别把它们交换到第一个位置,然后排列下面剩余的元素
swap(list[k], list[i]);
Perm(list, k+1, m);
swap(list[k], list[i]);
}
}
3、整数划分--很难想到递归的参数的含义
将正整数n表示成一系列正整数之和:n=n1+n2+…+nk, 其中n1≥n2≥…≥nk≥1,k≥1。 正整数n的这种表示称为正整数n的划分。求正整数n的不同划分个数。
例如:例如正整数6有如下11种不同的划分: 6; 5+1; 4+2,4+1+1; 3+3,3+2+1,3+1+1+1; 2+2+2,2+2+1+1,2+1+1+1+1; 1+1+1+1+1+1。
我们要怎么定义递归的参数,才可以把这些情况分开,没有交集产生?(最大的加数)
如果设p(n)为正整数n的划分数,则难以找到递归关系,因此考虑增加一个自变量:将最大加数n1不大于m的划分个数记作q(n,m)。可以建立q(n,m)的如下递归关系。
将最大加数n不大于m的划分记做q(n,m)
更加详细的:还是很难看出他们之间子结构的关系。。。太巧妙了
n=1 | 1 | q(1,1) |
n=2 | 2 1+1 | q(2,2)=1+q(2,1) |
n=3 | 3 2+1 1+1+1 | q(3,3)=1+q(3,2) q(3,2)=q(2,2) |
n=4 | 4 3+1 2+2 2+1+1 1+1+1+1 | q(4,4)=1+q(4,3) q(4,3)=q(4,2)+q(1,3) q(4,2)=q(4,1)+q(2,2) q(4,1)=1 |
n=5 | 5 4+1 3+2 3+1+1 2+2+1 2+1+1+1 1+1+1+1+1 | q(5,5)=1+q(5,4) q(5,4)=q(5,3)+q(1,4) q(5,3)=q(5,2)+q(2,3) q(5,2)=q(5,1)+q(3,2) q(5,1)=1 |
n=6 | 6 5+1 4+2 4+1+1 3+3 3+2+1 3+1+1+1 2+2+2 2+2+1+1 2+1+1+1+1 1+1+1+1+1+1 | q(6,6)=1+q(6,5) q(6,5)=q(6,4)+q(1,5) q(6,4)=q(6,3)+q(2,4) q(6,3)=q(6,2)+q(3,3) q(6,2)=q(6,1)+q(4,2) q(6,1)=1 |
int q(int n, int m)
{
if((n<1)||(m<1)) return 0;
if((n==1)||(m==1)) return 1;
if(n<m) return q(n,n);
if(n==m) return q(n, m-1)+1;
return q(n, m-1) + q(n-m, m);
}
4、hanoi塔--不要在意那些递归细节,我只需要这一层的逻辑就可以了
设a,b,c是3个塔座。开始时,在塔座a上有一叠共n个圆盘,这些圆盘自下而上,由大到小地叠在一起。各圆盘从小到大编号为1,2,…,n,现要求将塔座a上的这一叠圆盘移到塔座b上,并仍按同样顺序叠置。在移动圆盘时应遵守以下移动规则: 规则1:每次只能移动1个圆盘; 规则2:任何时刻都不允许将较大的圆盘压在较小的圆盘之上; 规则3:在满足移动规则1和2的前提下,可将圆盘移至a,b,c中任一塔座上。
要把所有的盘子从a移动到b,并且顺序不变,那就把底下n-1个较小的盘子从a移动到c,b柱子空出,然后把第n个盘子从a移动到b,然后把那n-1个盘子从c移动到b,叠起来,a就空了。
void hanoi(int n, int a, int b, int c){
// n个盘子 从a 移动到b c空出
if (n > 0){
hanoi(n-1, a, c, b);
move(a,b);
hanoi(n-1, c, b, a);
}
}
hanoi用递归的方式给出,但是每个圆盘的具体移动方式不清楚,手工很难模拟,但是便于理解,也很容易验证正确性。
5、二分搜索技术--注意有两种写法(不能包括mid这个数字)
典型的分治法策略,给定已按升序排好序的n个元素a[0:n-1],现要在这n个元素中找出一特定元素x。
算法复杂度分析: 每执行一次算法的while循环, 待搜索数组的大小减少一半。因此,在最坏情况下,while循环被执行了O(logn) 次。循环体内运算需要O(1) 时间,因此整个算法在最坏情况下的计算时间复杂性为O(logn) 。
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <=
int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
if (nums[middle] > target) {
right = middle - 1; // target 在左区间,所以[left, middle - 1]
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,所以[middle + 1, right]
} else { // nums[middle] == target
return middle; // 数组中找到目标值,直接返回下标
}
}
// 未找到目标值
return -1;
}
写法二:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 定义target在左闭右开的区间里,即:[left, right)
while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间,所以使用 <
int middle = left + ((right - left) >> 1);
if (nums[middle] > target) {
right = middle; // target 在左区间,在[left, middle)中
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,在[middle + 1, right)中
} else { // nums[middle] == target
return middle; // 数组中找到目标值,直接返回下标
}
}
// 未找到目标值
return -1;
二分法是非常重要的基础算法,为什么很多同学对于二分法都是一看就会,一写就废?其实主要就是对区间的定义没有理解清楚,在循环中没有始终坚持根据查找区间的定义来做边界处理。区间的定义就是不变量,那么在循环中坚持根据查找区间的定义来做边界处理,就是循环不变量规则。
左闭右闭区间:left<=right
if (nums[middle] > target) right = middle - 1;
else if (nums[middle] < target) left = middle + 1;
左闭右开区间:left<right
if (nums[middle] > target) right = middle;
else if (nums[middle] < target) left = middle + 1;
6、棋盘覆盖-- 一分为四,判断特殊方格在哪个棋盘中
在一个2k×2k 个方格组成的棋盘中,恰有一个方格与其它方格不同,称该方格为一特殊方格,且称该棋盘为一特殊棋盘。在棋盘覆盖问题中,要用图示的4种不同形态的L型骨牌覆盖给定的特殊棋盘上除特殊方格以外的所有方格,且任何2个L型骨牌不得重叠覆盖。
当k>0时,将2k×2k棋盘分割为4个2k-1×2k-1 子棋盘(a)所示。 特殊方格必位于4个较小子棋盘之一中,其余3个子棋盘中无特殊方格。
为了将这3个无特殊方格的子棋盘转化为特殊棋盘,可以用一个L型骨牌覆盖这3个较小棋盘的会合处,如 (b)所示,从而将原问题转化为4个较小规模的棋盘覆盖问题。递归地使用这种分割,直至棋盘简化为棋盘1×1。
结束条件 --> 分割方格 --> 判断特殊方格在不在左上?左下?右上?右下?方格中-->有特殊方格,递归,继续分割棋盘。没有特殊方格,填一个在右下?左下?右上?左上?(这个和上面的顺序是一样的,反正就是在中间的填上)
时间复杂度分析,覆盖一个2^n*2^n的棋盘要(4^n-1)/3个L型骨牌,于是T(n)=4*T(n-1)+O(1)
T(n)=4T(n-1)=4*4T(n-2)=...=4^nT(0)=4^k,于是T(n)=4^k
7、合并排序--合并两个大小差不多的集合,怎么合并、排序是我认为需要想一下的地方
基本思想:将待排序元素分成大小大致相同的2个子集合,分别对2个子集合进行排序,最终将排好序的子集合合并成为所要求的排好序的集合。
对合并排序一直有一个很大很大的难点,怎么合并已经排好序的两段??
就是分情况讨论了,这里一定要理解清楚,这个情况是怎么分的,数字是怎么动的
8、快速排序--选点作为划分,大于这个数的放一边,小于这个数的放一边,然后合并,依次递归
在快速排序中,记录的比较和交换是从两端向中间进行的,关键字较大的记录一次就能交换到后面单元,关键字较小的记录一次就能交换到前面单元, 记录每次移动的距离较大,因而总的比较和移动次数较少。
void QuickSort (Type a[], int p, int r)
{
if (p<r) {
int q=Partition(a,p,r);
QuickSort (a,p,q-1); //对左半段排序
QuickSort (a,q+1,r); //对右半段排序
}
}
Partition (Type a[], int p, int r)函数的具体部分:a是数组 p第一个数,就是开始的下标 r是结束下标
int Partition (Type a[], int p, int r)
{
// a是数组 p第一个数,就是开始的下标 r是结束下标
int i = p, j = r + 1;
Type x=a[p];
//随便抓取一个数,作为划分基准
// 将< x的元素交换到左边区域
// 将> x的元素交换到右边区域
while (true){
//最终 i指示在大于x的数,j指示小于x的数
while (a[++i] <x);
while (a[--j] >x);
if (i >= j) break;
Swap(a[i], a[j]);
}
a[p] = a[j];
a[j] = x;
return j;
//返回一个正确的位置
}
快速排序算法的性能取决于划分的对称性。通过修改算法partition,可以设计出采用随机选择策略的快速排序算法。在快速排序算法的每一步中,当数组还没有被划分时,可以在a[p:r]中随机选出一个元素作为划分基准,这样可以使划分基准的选择是随机的,从而可以期望划分是较对称的。
随机化策略的快速排序算法,就是a[p:r]中随机选取一个元素作为划分基准,在最坏情况下,算法randomizedSelect需要O(n2)计算时间 但可以证明,算法randomizedSelect可以在O(n)平均时间内找出n个输入元素中的第k小元素。
线性时间的选择:如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的2个子数组的长度都至少为原数组长度的ε倍(0<ε<1是某个正常数),那么就可以在最坏情况下用O(n)时间完成选择任务。
上述算法将每一组的大小定为5,并选取75作为是否作递归调用的分界点。这2点保证了T(n)的递归式中2个自变量之和n/5+3n/4=19n/20=εn,0<ε<1。这是使T(n)=O(n)的关键之处。当然,除了5和75之外,还有其他选择。
Type Select(Type a[], int p, int r, int k)
{
if (r-p<75) {
用某个简单排序算法对数组a[p:r]排序;
return a[p+k-1];
};
for ( int i = 0; i<=(r-p-4)/5; i++ )
将a[p+5*i]至a[p+5*i+4]的第3小元素
与a[p+i]交换位置;
//找中位数的中位数,r-p-4即上面所说的n-5
Type x = Select(a, p, p+(r-p-4)/5, (r-p-4)/10);
int i=Partition(a,p,r, x),
j=i-p+1;
if (k<=j) return Select(a,p,i,k);
else return Select(a,i+1,r,k-j);
}