第二章--递归--复习--全排列整数划分汉诺塔二分搜索棋盘覆盖合并排序快速排序

学习要点

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=11q(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);
}

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值