算法设计课程总结1~2(渐进分析记号,复杂性比较,递归,分治)

来源:互联网 发布:淘宝店铺宝贝卖点怎么写 编辑:程序博客网 时间:2024/06/04 18:43

[1]算法概述

算法与程序

算法:由若干条指定组成的有穷序列,具有输入(零个至多个)、输出(至少一个)、确定性(无二义性语句)、有限性(执行次数和时间有限)、可行性(每一步都可实现)。
程序:算法用某种程序设计语言的具体实现,可以不满足有限性(如操作系统)。

算法复杂性

时间复杂性 T(n):需要时间资源的量。
空间复杂性 S(n):需要空间资源的量。

算法复杂性集中反映算法的效率,依赖于问题规模、算法输入和算法本身,不依赖于具体机器。这里只讨论时间复杂性。

时间复杂性还可以分为最坏情况平均情况最好情况
这里写图片描述
注意平均情况的计算要用每个实例出现的概率对每个实例的时间复杂性加权平均。

T(n)的渐进复杂性 t(n)是它略去低阶项后留下的主项:
这里写图片描述

增长的阶——渐进分析记号

增长的阶可以用来粗略度量某一计算过程所需的(时间)资源。(时间)资源变化f(n)具有复杂性的阶常记为:
这里写图片描述
注意,以下的不等条件都只要求存在正常数n0,在n>n0时满足即可。也就是f和g的函数值(表示占有的时间或资源)在大过一定规模后的比较:
[1]渐进上界存在正常数c:
这里写图片描述即f(n)阶不超过g(n)阶(g既可以是f的高阶,也可以是同阶)。
[2]渐进下界存在正常数c:
这里写图片描述即f(n)阶以g(n)阶为下界(注意不是下确界,f的阶完全可以远大过g)。
实际上,找到这个c以后,不等式三边同时乘以k=1/c,得到:
这里写图片描述
可见渐进下界和渐进上界是互对称的,都是一方的阶不超过另一方。
[3]高阶记号对于任何正常数c:
这里写图片描述即f(n)阶小于g(n)阶。
[4]同阶记号存在正常数c1,c2:
这里写图片描述即f(n)阶等于g(n)阶。

渐进分析记号的一些特性

如果用g到f的符号来大致描述渐进上界、渐进下界、高阶记号、同阶记号,那么分别是>=、<=、>、=。显然所有带等号成分的都可以有反身性
这里写图片描述
渐进上界O和渐进下界Ω的互对称性前面已经提过,此外渐进上界还有其它的一些算术运算特性:
这里写图片描述

常见的时间复杂度比较

时间复杂度常用渐进上界来表示,常见的多项式级别的有:
这里写图片描述
常见的指数级别的有:
这里写图片描述

[2]递归与分治策略

递归和分治

递归:直接或间接调用自身的算法,递归函数是用函数自身给出定义的函数。
分治:将一个规模为n的问题分解为k个规模较小的子问题。

用分治法解决问题应满足一些条件:
①子问题互相独立
②子问题与原问题相同(重叠子问题性质),这样相同的子问题的算法方案可以被重复利用
③各个子问题的解能合并成原问题的解
④当问题规模缩小到一定程度就容易解决

复杂度递推通项公式

如果复杂度能表示成这样的递推公式:
这里写图片描述
那么解除递推后得到的式子将是这样的:
这里写图片描述

关于它的证明如下,假设N可以表示成和b的幂有关的式子:
这里写图片描述
则递推式中的这两部分可以这样表示:
这里写图片描述
如果再假设T(1)=1,那么递推式就变成了这样:
这里写图片描述
为构造递推中类似的项,两边同时除以a^m,得:
这里写图片描述
很显然等号右边第一项可以作为下一递推层等号左边的项,总是可以这样不断展开:
这里写图片描述
把这些式子全部加起来,消去那些相同的项,得到:
这里写图片描述
T(b^m)就是T(N),所以只要把a^m移过去:
这里写图片描述
现在已经是解除递推的式子了,但是式中的m必须要去除,因为这只是我们临时引入的一个参数,这对于前面a^m很容易,因为m就等于log(b)N,但对于后面的求和比较麻烦,事实上我们只要去找一个渐进上界而已,可以去判定这个分数和1比较如何:
[1]分母大
这里写图片描述
这里用到了第一章学的渐进上界的算数运算特性加和max。可以看到这时看起来与N^k无关,因为它的阶相比而言较小,这个式中的log(b)a在分母大的条件下是大于k的。而且后面的加和部分作为系数小于可寻找的常量,所以在渐进上界中被省略掉(这也是算数运算特性)。
[2]一样大
这里写图片描述
注意只有在一样大的时候log(b)a才能用k代换,而且后面的加和系数不能被省略掉,这个系数就是m,只要将它变换回log(b)N即可。
[3]分子大
这时候显然Σ加和系数不能被忽略,也不能像一样大的时候直接求得其值,要把这个和求出来,显然只要做等比数列求和就可以了:
这里写图片描述

排列问题

产生一个数组中的全排列,是一个比较经典的递归问题,后面回溯中的排列树解空间的遍历,就是基于此。

#include<iostream>#include<algorithm>using namespace std;int a[5];int c=0;//计数用//寻找list[]中下标0~k-1不变,下标k~m做全排列的所有序列void Perm(int list[],int k,int m){    if(k==m)//排列到了最后一个元素,只有这一种情况了,这时输出    {        for(int i=0;i<=m;i++)            cout<<list[i]<<" ";        cout<<"//";        c++;//计数        if(0==c%6)            cout<<endl;    }    else//未到最后一个元素,要递归地寻找子排列,当前是在第k层做的    {        for(int i=k;i<=m;i++)//对于从k到m的每个元素        {            swap(list[i],list[k]);//都放(交换)到k的位置上,以使k这一层的所有可能都考虑到            Perm(list,k+1,m);//对于这样的每种可能,递归地进入到第k+1层继续寻找            swap(list[i],list[k]);//寻找完这层的这种可能以后,要交换回来,以免对上面的层有影响        }    }}int main(){    for(int i=0;i<5;i++)//初始化        a[i]=i+1;    Perm(a,0,4);return 0;}

这里写图片描述

整数划分问题

整数划分问题是将一个正整数n表示成若干正整数n1+n2+..+nk之和,求出所有不同的划分方法有多少种。如果是刚才那个排列问题,实际上”前缀”自然地给它们分了组,所以可以很清晰地把所有解分类。对这个问题也应该考虑解该怎么分类,不然胡乱找划分不是算法容易做的事情,也很容易丢。
书上给的方式是,根据划分中最大加数来给他们分类,也就是说把最大加数ni不大于m的划分个数记为q(n,.m),这样我们要求的也就是q(n,n)即最大加数不超过n,对整数n划分。q(n,m)情况有以下几种:
这里写图片描述
对第一种情况,最大加数是1或者n本身就是1时就只能1+1+…+1;
对第二种情况,最大加数超过n是不现实的,其实就是q(n,n)+0;
对第三种情况,加数和n相等的划分就只有n这一种;
对第四种情况,也就是一般的情况,q(n,m-1)是最大加数也达不到m的情况,m不妨缩减1,q(n-m,m)是最大加数就是m的情况,那么不妨让n减掉一个必存在的m。

#include<iostream>using namespace std;int q(int n,int m){    if(n<1 || m<1)//鲁棒性        return 0;    if(1==n || 1==m)//1        return 1;    else if(n<m)//2        return q(n,n);    else if(n==m)//3        return q(n,m-1)+1;    else//4        return q(n,m-1)+q(n-m,m);}int main(){    cout<<q(6,6);return 0;}

运行结果是11,即6有11种不同的划分。

汉诺塔问题

把n个合法的圆盘从A移动到B上(借助C),需要先把上面n-1个从A移动到C上(借助B),再把第n个大圆盘移动到B上,再把C上的n-1个圆盘移动到B上(借助A)。
这里写图片描述

#include<iostream>using namespace std;void move(char i,char j){    cout<<"一个圆盘从"<<i<<"移动到"<<j<<"上"<<endl;}void Hanoi(int n,char a,char b,char c){    if(n>0)    {        Hanoi(n-1,a,c,b);        move(a,b);        Hanoi(n-1,c,b,a);    }}int main(){    Hanoi(4,'A','B','C');return 0;}

这里写图片描述

二分搜索问题

二分搜索在排好序的数组a[0:n-1]中搜索指定元素的下标,时间复杂度O(logn)。
这是分治法中最经典的问题,从前面的复杂度递推通项公式中可以看到,并不是分成的子问题数b越大越好,分治法设计算法最好让子问题的规模大致相同(方便设计算法),并且很多时候是分成2份(所以二叉树的模型很常用)。

#include<iostream>using namespace std;//二分查找(正序数组,查找项,数组长)int BinarySearch(int a[],int x,int n){    int lft=0;//左游标    int rgt=n-1;//右游标    //两个游标所夹的部分即是正在处理的子问题,在这个问题里只要处理一侧的子问题    //左游标>右游标时查找失败退出    while(lft<=rgt)    {        int mdl=(lft+rgt)/2;//分成两份且左右两个子问题规模大致相同        if(x==a[mdl])//查找成功退出            return mdl;        if(x>a[mdl])//大,走右侧子问题            lft=mdl+1;//mdl也不可能了,所以+1        else//小,走左侧子问题            rgt=mdl-1;//mdl也不可能了,所以-1    }    return -1;//失败退出返回-1}int main(){    int a[8]={1,3,5,8,11,20,80,113};    cout<<BinarySearch(a,11,8);return 0;}

结果是4。时间复杂度O(logn)是因为最坏情况下while循环执行O(logn)次(每次二分),且循环体内运算为O(1)。

大整数乘法的复杂性分析

如果两个n位二进制(用二进制是方便分析计算机真实的工作)大整数X和Y的乘法,做二分:
这里写图片描述

这里写图片描述
则这个n位二进制整数的乘法,需要进行4次2/n位二进制整数的乘法、三次不超过2n位的整数加法、两次移位。移位和加法合起来也不过O(n),所以递推式是:
这里写图片描述
代入复杂度递推通项公式,a=4,b=2,k=1,知a>b^k,所以复杂度是O(n^(logb->a))=O(n^2)。

而同样的方式,把XY的求解写成另外一种形式:
这里写图片描述
式中红色部分之所以不用(A+B)(D+C),是因为加法可能增加数位,使乘法变困难,但减法不会,这是对计算机而言方便一些的技巧。
和刚刚那个式子比较一下,可以看出这样做的目的:
这里写图片描述
可以看到把AD和BC强行用AC、BD以及这个新增的橙色乘法框来表示了,减少了重复计算,上面那个式子中要做4次乘法,下面这个式子中只需要做3次。所以复杂度递推式:
这里写图片描述
也就是把a的值从4减少到了3,相应地,复杂度降低为O(n^log3)。

方阵乘法的复杂性分析

方阵四分块,实际上就是行列分别二分,然后作分块矩阵的乘法:
这里写图片描述
所以一共要做8次n/2规模的矩阵乘法,4次矩阵加法,加法一共用的时间显然是n的常数倍,规模就是O(n),所以得这个算法时间复杂度的递推式:
这里写图片描述
用复杂度递推通项公式,a=8,b=2,k=2,知a>b^k,所以复杂度是O(n^(logb->a))=O(n^3),不比用原始定义直接计算更有效,因为没有减少乘法运算次数,矩阵乘法要比加减法耗费时间多得多。

Srassen矩阵乘法仍然需要四分块,但只用7次乘法和若干次加减法(具体怎么做随用随查不贴了),所以递推式变为:
这里写图片描述

合并排序

合并排序将待排序的数组左右两边分开,分别对其合并排序,然后两个排好序的子数组再合并回来。
这里写图片描述

#include<iostream>using namespace std;int b[8];//辅助空间//有序合并void Merge(int c[],int d[],int l,int m,int r){    int i=l;//左段起点为lft    //左段终点为m    int j=m+1;//右段起点为m+1    //右段终点为rgt    int k=l;//d数组(要复制到的目标)的游标起点为lft    //d数组的游标终点为rgt    while(i<=m && j<=r)//左右游标都没到终点    {        if(c[i]<=c[j])//若此处左段的元素小            d[k++]=c[i++];//复制左侧的东西到d数组里,然后左侧和d数组游标都自增1        else//若此处右段的元素小            d[k++]=c[j++];//复制右侧的东西到d数组里,然后右侧和d数组游标都自增1    }    //左右游标有一个到终点了,那么要把另一游标对应的数组剩余元素复制进去    if(i>m)//如果是左侧游标到终点了        for(int q=j;q<=r;q++)//那么右侧数组中剩余的每个元素            d[k++]=c[q];//都要扔到d数组后面去    else//否则,若是右侧游标到终点了        for(int q=i;q<=m;q++)//那么左侧数组中剩余的每个元素            d[k++]=c[q];//都要扔到d数组后面去}//数组间的复制void Copy(int a[],int b[],int l,int r){    for(int i=l;i<=r;i++)        a[i]=b[i];}//合并排序void MergeSort(int a[],int lft,int rgt)//从下标lft到rgt做合并排序{    if(lft<rgt)//最少应有2个元素    {        int i=(lft+rgt)/2;//二分        MergeSort(a,lft,i);//左侧合并排序,此后左侧有序        MergeSort(a,i+1,rgt);//右侧合并排序,此后右侧有序        Merge(a,b,lft,i,rgt);//将有序的a[lft:i]和a[i+1:rgt]有序合并到b[lft:rgt]中去        Copy(a,b,lft,rgt);//将b[lft:rgt]复制给a[lft:rgt]    }}int main(){    int a[8]={9,7,2,5,21,42,1,-3};    MergeSort(a,0,7);//对数组a合并排序    for(int i=0;i<8;i++)//输出看一下        cout<<a[i]<<" ";return 0;}

这里写图片描述
对其时间复杂性分析一下,Merge有序合并和Copy数组复制需要的是O(n)规模的时间,所以时间复杂度递推式为:
这里写图片描述
还是用复杂度递推通项公式,a=2,b=2,k=1,知a=b^k,因此复杂度为:
这里写图片描述

对合并排序消除递归

课本上的消除递归的思路是,自底向上层层合并,用辅助数组b和原来的数组a交替地暂存合并后的结果:
这里写图片描述
这样就可以用循环来做了,往复一次设为一个循环可以极大地简化循环体的写法,而某些处理能使得最后不必去考虑结果是在b数组中还是在a数组中,它们总会被复制到a数组中。
先对后面代码中的MergePass函数作一些说明,MergePass做的就是上面那张图中红色箭头的表示的事情,把相邻的小的数组段合并成更大的数组段,并给另外一个数组。但是,合并往往不像图中那样正正好好地有偶数个s大小的数组段,还可能有这样的几种情况:
这里写图片描述
MergePass考虑了这些情况,先把前面2个s长度的一起出现的合并好,然后把情况①和②视为同一种(能再挤出一个s),另外也考虑了对情况③的处理。

#include<iostream>using namespace std;//有序合并void Merge(int c[],int d[],int l,int m,int r){    int i=l;//左段起点为lft    //左段终点为m    int j=m+1;//右段起点为m+1    //右段终点为rgt    int k=l;//d数组(要复制到的目标)的游标起点为lft    //d数组的游标终点为rgt    while(i<=m && j<=r)//左右游标都没到终点    {        if(c[i]<=c[j])//若此处左段的元素小            d[k++]=c[i++];//复制左侧的东西到d数组里,然后左侧和d数组游标都自增1        else//若此处右段的元素小            d[k++]=c[j++];//复制右侧的东西到d数组里,然后右侧和d数组游标都自增1    }    //左右游标有一个到终点了,那么要把另一游标对应的数组剩余元素复制进去    if(i>m)//如果是左侧游标到终点了        for(int q=j;q<=r;q++)//那么右侧数组中剩余的每个元素            d[k++]=c[q];//都要扔到d数组后面去    else//否则,若是右侧游标到终点了        for(int q=i;q<=m;q++)//那么左侧数组中剩余的每个元素            d[k++]=c[q];//都要扔到d数组后面去}//合并有序的相邻数组段:块大小为s,数组长为n,从x数组合并到y数组void MergePass(int x[],int y[],int s,int n){    int i=0;    while(i<=n-2*s)//对前面的那些两个s都是完整的部分,注意这里上限要减去2*s,这样结束后i在剩余部分起始点    {        Merge(x,y,i,i+s-1,i+2*s-1);//合并到y里去        i=i+2*s;//游标每次增量是2s    }    //剩下的元素个数少于2s的处理    if(i+s<n)//如果还能挤出一个s来        Merge(x,y,i,i+s-1,n-1);//把它和后面<s的一段(也许没有)合并到y里去    else//挤不出一个s了,剩下的肯定<s的一段(也许没有)了        for(int j=i;j<=n-1;j++)//对剩余的这一小块(如果有)            y[j]=x[j];//直接扔到y里去}//合并排序void MergeSort(int a[],int n)//从下标0到n-1做合并排序{    int *b=new int[n];//开辟一个一样大的辅助空间b,用来作交替交换    int s=1;//当前"有序合并完成"的块的大小,自底向上,显然是从1开始    while(s<n)//这个块的倍增不超过n,否则没有意义    {        MergePass(a,b,s,n);//合并好的块为s,从a数组合并至数组b        s*=2;//块倍增        MergePass(b,a,s,n);//合并好的块为s,从b数组合并至数组a        s*=2;//块倍增    }}int main(){    int a[8]={9,7,2,5,21,42,1,-3};    MergeSort(a,8);    for(int i=0;i<8;i++)        cout<<a[i]<<" ";return 0;}

这里写图片描述

快速排序

快速排序也是分治法的一个例子,从序列中找出一个数,将比它小的所有数放在它左边,将比它大的所有数放在它右边,这个过程称为划分,然后对左边和右边的序列分别作快排。
这里写图片描述
所以递归形式的快排唯一要解决的事情就是怎么实现划分,不妨取要划分的数组中的第一个元素给划分阈值x,这样用两个游标i和j分别从剩下部分的两端开始,向中间走。即i先往右走,发现不小于x的数就停下,j再往左走,发现不大于x的数就停下,这两个数都不符合我们的要求,只要i还是小于j的,我们就交换它们,然后继续这个过程即可。
这里写图片描述
而当游标i的值超过游标j的值以后,将游标j对应位置的值和最左边的基准值交换就可以完成划分了:
这里写图片描述
这里写图片描述
当然这只是对于选取的基准值在最左边的情况。如果基准值选取的是最右边的值,那么就要和i位置对应的那个值交换了。事实上不管选数组里的哪个值做基准值,不妨先和最左边的值做交换,这样这个算法就有通用性了。

#include<iostream>using namespace std;//在a数组中从p到r作划分,返回划分后的基准点游标int Partition(int a[],int p,int r){    int i=p;//左游标    int j=r+1;//右游标    int x=a[p];//阈值(基准值)    while(true)    {        while(a[++i]<x && i<r)//左游标往右走,直到发现不小于x的或越界停下            ;        while(a[--j]>x)//右游标往左走,因为有阈值在头部挡着,不用担心越界            ;        if(i>=j)//走完则退出            break;        swap(a[i],a[j]);//否则交换这次找到的两个数    }    //i超过j后,j的位置应放基准值,和数组头(基准值)交换    a[p]=a[j];    a[j]=x;return j;//基准值位置就是j}//对a[p:r]快速排序void QuickSort(int a[],int p,int r){    if(p<r)    {        int q=Partition(a,p,r);//在a数组中从p到r作划分,划分后的基准点游标给q        QuickSort(a,p,q-1);//左半段快排        QuickSort(a,q+1,r);//右半段快排    }}int main(){    int a[8]={9,7,2,5,21,42,1,-3};    QuickSort(a,0,7);    for(int i=0;i<8;i++)        cout<<a[i]<<" ";return 0;}

这里写图片描述
如果要随机的选择数组中的一个数为阈值,可以选择后再和第一个元素交换,这样可以复用之前Partition的代码:

int RandomPartition(int a[],int p,int r){    int i=Random(p,r);    swap(a[i],a[p]);return Partition(a,p,r);}

选择数组中第k大的数

这也是一个分治的例子,只要划分以后,看看k的值落在前面一半(下图红圈处)还是后面一半,总是可以缩减规模作递归。
这里写图片描述

//从a[p:r]中选择第k大的数int RandomSelect(int a[],int p,int r,int k){    if(p==r)//只有一个元素了        return a[p];    int i=RandomPartition(a,p,r);//随机选择一个数做划分,划分后阈值位置在i    j=i-p+1;//如上图,前面一段一共有j个元素    if(k>=j)//如果k都没有这个j大        return RandomSelect(a,p,i,k);//那么只要在p到i这一段中作选择就行了    else        return RandomSelect(a,i+1,r,k-j);//否则要在后面那段a[i+1:r]中选第k-j大的}