算法分析与设计笔记

第一章——概述

(1)什么是算法

算法是若干指令的有穷序列,满足性质:

  • 输入:有外部提供的量作为算法的输入。
  • 输出:算法产生至少一个量作为输出。
  • 确定性:组成算法的每条指令是清晰,无歧义的。
  • 有限性:算法中每条指令的执行次数是有限的,执行每条指令的是实践也是有限的
    而程序是算法用某种程序设计语言的具体实现,程序可以不满足算法的性质(4)。例如操作系统,是一个在无限循环中执行的程序,而不是一个算法。
    程序=算法+数据结构

(2)问题求解过程

(3)算法的正确性分析

定义:一个算法是正确的,如果它对于每个输入都最终停止,而且产生正确的输出。
不正确算法:①不停止(在某个输出上)。②对所有的输入都停止,但对某输入产生不正确的结果。
近似算法:①对所有的输入都停止。②产生近似正确的解或产生不多的不正确的解。

(4)算法的复杂性分析

算法的时间复杂性T(n);算法的空间复杂性S(n)。n是问题的规模(输入大小)。一般常分为最坏、最好、平均情况。
常用符号表示:

  • 渐进上界记号O:O(g(n)) = { f(n) | 存在正常数c和n0使得对所有n>= n0有:0 <= f(n) <= cg(n) }
  • 渐进下届记号Ω:Ω(g(n)) = { f(n) | 存在正常数c和n0使得对所有n>= n0有:0<=cg(n) <= f(n) }

一般我们更关注一个算法的上界即O(f(n))。
算法的时间复杂度常用来表示时间开销随着数据规模增长的速度的快慢
O(1)<O(log n)<O(n)<O(nlog n)<O(n^2)< O(n^3) <O(2^n) <O(n^n)
分析中的常见函数:
在这里插入图片描述
最优算法:问题的计算时间下界为(f(n)),则计算时间复杂性为O(f(n))的算法是最优算法。

举例说明:Fibonacci数列

无穷数列1,1,2,3,5,8,13,21,34,55,……,称为Fibonacci数列。它可以递归地定义为:
F(n)=1(n=0 or n=1)
F(n)=F(n-1)+F(n-2)
法1:递归算法

int fibonacci(int n)
   {
       if (n <= 1) return 1;
       return fibonacci(n-1)+fibonacci(n-2);
   }

由于
T(n-1)≈T(n-2)
所以
T(n)=2T(n-2)+C=2[2T(n-4)+C]+C=…=(2^(n/2))*(1+C)-C
时间复杂度O(2^n)

法2:非递归算法

int fibo3(int n)
{
    assert(n >= 1);
    int nFirst = 0;
    int nSecond = 1;
    int nThird = 0;
    for(int i = 2 ; i <= n; i++){
        nThird = nFirst + nSecond;
        nFirst = nSecond;
        nSecond = nThird;
    }
    return nThird;
}

时间复杂度O(n),和递归算法想必减少了大量重复的计算。

总结

在这里插入图片描述

补充:问题复杂性分析P/NP

P问题:存在着解P的多项式时间的算法
NP问题:不能确定是否存在多项式时间的算法,但可以在多项式时间内验证一个解是否正确。
NPC问题:存在这样一个NP问题,所有的NP问题都可以归约到它。只要解决了这个问题,那么所有的NP问题都解决了,目前已经发现了3000多个NP完全问题。
NP-hard问题:不一定是NP问题,即可能在多项式时间内无法验证一个解是否正确,但是所有问题都可以归约到它。
归约:如果问题A的输入能够在多项式时间内转化为问题B的输入,并且最后得到相同的结果,且问题B难于问题A,则称问题A可以归约(reducibility)到问题B。
四种问题的关系

第二章——递归与分治

递归:直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数
分治法:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
补充:双递归:当一个函数及它的一个变量是由函数自身定义时,称这个函数是双递归函数。
例如:Ackerman函数A(n,m)
在这里插入图片描述

(1)分治策略与适用条件

将该问题分为k个子问题,对这k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进行下去,直到问题规模足够小,很容易求出其解为止。
分治策略
分解:将问题分解为若干个子问题。
治理:递归地解决各个子问题。不过若子问题的规模足够小,就以直接的方式(不再递归)解决子问题。
合并: 将子问题的解合并成原问题的一个解
简称 分-治-合

divide-and-conquer(P)
{
if ( | P | <= n0) adhoc(P); //解决小规模的问题
divide P into smaller subinstances P1,P2,...,Pk;//分解问题
for (i=1,i<=k,i++)
yi=divide-and-conquer(Pi); //递归的解各子问题
return merge(y1,...,yk); //将各子问题的解合并为原问题的解
} 

适用条件

  • 可以找到递归出口
  • 可以找到递归体
  • 子问题的解可以合并成该问题的解
  • 子问题之间不包含公共的子问题(如果包含公共部分,会重复解决公共的子问题,效率降低,常采用动态规划算法)

(2)优缺点

优点:结构清晰,可读性强,而且容易用数学归纳法来证明算法的正确性,因此它为设计算法、调试程序带来很大方便。
缺点:递归算法的运行效率较低,无论是耗费的计算时间还是占用的存储空间都比非递归算法要多。
解决方法

  1. 定义一个栈区模拟递归调用的工作栈。虽然该方法通用性强,但是本质上还是递归,优化效果不明显。
  2. 使用递推来实现递归函数。(●’◡’●)
  3. 通过变换将一些递归转化为尾递归,从而迭代求出结果。
    补充:尾递归
    如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。
    当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。
    通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。(●’◡’●)

(3)复杂性分析

用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有在这里插入图片描述
通过迭代法求得方程的解:
在这里插入图片描述
因此我们得到计算时间复杂度的公式:
在这里插入图片描述
利用此公式可以很容易计算出分治法的时间复杂度

(4)案例分析

大整数乘法

假设X和Y为两个n位的大整数
在这里插入图片描述
那么有
在这里插入图片描述
复杂度分析
在这里插入图片描述
为了降低复杂度,必须减少乘法的次数
因此进行变换
在这里插入图片描述
这样的复杂度
在这里插入图片描述
此时根据上面的计算时间复杂度公式可以得到
在这里插入图片描述
确实有了很大的改进(●’◡’●)

快速排序
template<class Type>
int Partition(Type a[], int p, int r)
{
   int i=p,j=r+1;
   Type x=a[p];//注意这个划分基准并不固定
   while(true)
   {
      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;
}
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);//对右半段进行排序
   }
}

对于平均情况,其时间复杂度为O(nlogn),空间复杂度为O(logn)。因为其逻辑结构为一个二叉树,所以空间复杂度为递归深度,时间复杂度为递归深度*每一层遍历n个元素。(下图为Best Case)
在这里插入图片描述

快排的性能比较依赖于Partition轴的选取,当对于一个完全有序的序列,如果依旧选取第一个元素作为轴,那么时间复杂度会达到O(n^2)。(下图为Worst Case)
在这里插入图片描述

解决方法
使用随机选取轴的方法

template<class Type> 
int RandomizedPartition (Type a[], int p, int r) 
{
    int i = Random(p,r); 
    Swap(a[i], a[p]);
    return Partition (a, p, r);
}
线性时间元素选择

题目
给定线性序集中n个元素和一个整数k,1≤k≤n,要求找出这n 个元素中第k小的元素。
思路
·利用快速排序思想、每次递归处理部分子数组

template<class Type> Type RandomizedSelect(Type a[],int p,int r,int k)
{ if (p==r) return a[p];
  int i=RandomizedPartition(a,p,r), j=i-p+1; 
  if (k<=j) return RandomizedSelect(a,p,i,k); 
  else return RandomizedSelect(a,i+1,r,k-j); } 

在最坏情况下,算法randomizedSelect需要O(n^2)计算时间但可以证明,算法randomizedSelect可以在O(n)平时间内找 出n个输入元素中的第k小元素。
优化
思路
如果能在线性时间内找到一个划分基准,使得按这个 基准所划分出的2个子数组的长度都至少为原数组长度的ε倍(0<ε<1是某个正常数),那么就可以在最坏情 况下用O(n)时间完成选择任务。
例如,若ε=9/10,算法递归调用所产生的子数组 的长度至少缩短1/10。所以,在最坏情况下,算 法所需的计算时间T(n)满足递归式 T(n)≤T(9n/10)+O(n) 。由此可得T(n)=O(n)。
解法
利用中位数,构造线性时间复杂度的算法
1、将n个输入元素划分成n/5个组,每组5个元素,只可能有 一个组不是5个元素(忽略该组)。用任意一种排序算法,将 每组中的元素排好序,并取出每组的中位数,共n/5个
2、递归调用select来找出这n/5个元素的中位数。如果n/5是偶数,就找它的两个个中位数中较大的一个。以这个元素作为划分基准。
按照此基准划分所得的两个子数组的长度都至少缩短1/4
在这里插入图片描述

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); 
}

复杂度分析
在这里插入图片描述

第三章——动态规划

第四章——贪心算法

基本性质和思路

贪心算法总是作出在当前看来最好的选择,也就是说贪心算法并不从整体上最优考虑,它所作出的选择只是在某种意义上的局部最优选择。
所以,贪心算法不能对所有的问题都得到整体最优解
能使用贪心算法解决的问题具有以下两个性质:
①贪心选择性质
所谓贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。这是贪心算法可行的第一个基本要素,也是贪心算法与动态规划算法的主要区别。
②最优子结构性质
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。问题的最优子结构性质是该问题可用动态规划算法或贪心算法求解的关键特征。

例1:活动安排问题

设有n个活动的集合E={1,2,…,n},其中每个活动都要求使用同一资源,如演讲会场等,而在同一时间内只有一个活动能使用这一资源。每个活动i都有一个要求使用该资源的起始时间si和一个结束时间fi,且si <fi 。如果选择了活动i,则它在半开时间区间[si, fi)内占用资源。若区间[si, fi)与区间[sj, fj)不相交,则称活动i与活动j是相容的。也就是
说,当si≥fj或sj≥fi时,活动i与活动j相容。现在需要求出能够相容最多活动的方案。

①优先选开始时间早的
反例如下
在这里插入图片描述
②优先选占用时间少的
反例如下
运用这个策略,则应该选择B,但实际上我们很容易发现选AC更好
③优先考虑结束时间最早的
思路:
将活动按结束时间升序排列成 A、B、C……我们选择的活动则是该序列的子序列。
简单证明:
一开始我们选择A,因为如果不选A,则我们选择的任意序列都可以把
第一个活动改为A而不减少甚至增加活动数。
同理,后面的活动我们选择不与前面活动冲突的第一个活动。
后面将进行严谨的证明
伪代码:

void GreedySelector(int n,Type s[],Type f[],bool A[])
{
sort(A+1,A+n+1,cmp);//按照结束时间升序排列
A[1]=true;
int j=1;
for(int i=2;i<=n;i++){
if(s[i]>=f[j]){A[i]=true;j=i;}//f[j]为第j个活动的结束时间,s[i]为第i个活动的起始时间
else A[i]=false;
  }
}

算法复杂度显然是排序中的O(nlogn)

贪心算法并不总能求得问题整体最优解,但是对于活动安排问题,贪心算法却总能求得整体最优解,这是因为该问题具有贪心选择性质,下面利用数学归纳法给予证明。

求证:算法执行到第k步,已选择k项活动i1=1,i2…ik。则存在最优解A,其包含活动i1=1,i2…ik。根据上述命题,对于任意k,算法的前k步的组合都将导致最优解,最多到第n步,将得到问题实例的最优解。

证明:
①当k=1时,证明存在最优解包含活动1(即截止时间最早的活动)。任取最优解A,A中活动按截止时间递增排序,如果A中第一个活动为:j≠1,那么可以用1替换A中的活动j,得到A‘,由于f[1]<f[j],所以A’也是最优解,且含有1。
②归纳步骤,当算法执行到了第k步,选择k项活动i1=1,i2…ik。根据归纳假设存在最优解包含i1=1,i2…ik。A中剩下的活动选自集合S‘
在这里插入图片描述
B是S’的最优解(如果不是,S‘的最优解为B’,B’的活动比B多,那么B’U{i1,i2,…,ik}是S的最优解,且比A的活动多,这与A的最优性矛盾),将S‘看成子问题,根据归纳基础,存在S’中的最优解B’‘,有S’中的第一个活动ik+1,且B’‘与B数量相同,于是
{i1,i2…,ik}UB’‘={i1,i2,…,ik+1}U(B’'-{ik+1})

例2:最优装载

题目: 有一批集装箱要装上一艘载重量为c的轮船。其中集装箱i的重量为Wi。最优装载问题要求确定在装载重量不受限制的情况下,将尽可能多的集装箱装上轮船。
思路:既然最终追求的是数量最多,所以顾名思义利用贪心算法从质量最轻的开始放,直到任何一个集装箱都放不下。
伪代码:

void Loading(int x[],Type w[],Type c,int n)
{
      int *t=new int[n+1];
      Sort(w,t,n);//将集装箱按照重量升序排列到t数组中
      for(int i=1;i<=n;i++) x[i]=0;
      for(int i=1;i<=n && w[t[i]]<=c;i++) {x[t[i]]=1;c-=w[t[i]];}
}

不难发现,该问题是0-1背包问题的子问题,集装箱相当于物品,物品重量是Wi,价值Vi都等于1,轮船载重限制C相当于背包装量限制b
0-1背包目前没有多项式时间的算法,但这个特殊的子问题有。

例3:哈夫曼编码

整体介绍:为字符进行编码,要求出现频率大的字符编码长度较短,使得平均码长最短,并且保证前缀码的性质(任一字符的代码不是其它字符代码的前缀)
在这里插入图片描述
其中f©为字符c出现的频率,dT©为字符c的码长。

基本思路:
构造树:
0-左子树、1-右子树,一个码对应一个叶子结点,最大位数为树深,每个非叶子节点的权值等于左子节点的权值和右子节点的权值之和。
算法思路:
贪心算法,每次选择权值最小的两个根节点进行合并,直到合并所有的结点。
举例分析:
在这里插入图片描述
在这里插入图片描述
具体实现:
以f为键值的优先队列Q用 在贪心选择时有效地确定算法当前要合并的2棵具有最小频率的树。一旦2棵具有最小频率的树合并后,产生一棵新的树,其频率为合并的2棵树的频率之和,并将新树插入优先队列Q。经过n-1次的合并后,优先队列中只剩下一棵树,即所要求的树T。
算法复杂度分析:
算法huffmanTree用最小堆实现优先队列Q。初始化优先队列需要O(n)计算时间,由于最小堆的removeMin和put运算均需O(logn)时间,n-1次的合并总共需要O(nlogn)计算时间。因此,关于n个字
符的哈夫曼算法的计算时间为O(nlogn) 。

例4:最小生成树

设G =(V,E)是无向连通带权图,即一个网络。E中
每条边(v,w)的权为c[v][w]。如果G的子图G’是一棵包
含G的所有顶点的树,则称G’为G的生成树。生成树上
各边权的总和称为该生成树的耗费。在G的所有生成树
中,耗费最小的生成树称为G的最小生成树。

最小生成树性质

设G=(V,E)是连通带权图,U是V的真子集。如果
(u,v)∈E,且u∈U,v∈V-U,且在所有这样的边中,
(u,v)的权c[u][v]最小,那么一定存在G的一棵最小生成
树,它以(u,v)为其中一条边。

Prim算法

构造G的最小生成树的Prim算法的基本思想是:
首先置S={1},然后,只要S是V的真子集,就作如下
的贪心选择:选取满足条件i∈S,j∈V-S,且c[i][j]最小的边,将顶点j添加到S中。这个过程一直进行到S=V时为止。
在上述Prim算法中,还应当考虑如何有效地找出满足条件i∈S,j∈V-S,且权c[i][j]最小的边(i,j)。实现这个目的的较简单的办法是设置2个数组closest和lowcost。在Prim算法执行过程中,先找出V-S中使lowcost值最小的顶点j,然后根据数组closest选取边(j,closest[j]),最后将j添加到S中,并对closest和lowcost作必要的修改。
用这个办法实现的Prim算法所需的计算时间为 O(n^2 )

Kruskal算法

Kruskal算法构造G的最小生成树的基本思想是,
首先将G的n个顶点看成n个孤立的连通分支。将所有的边按权从小到大排序。然后从第一条边开始,依边权递增的顺序查看每一条边,并按下述方法连接2个不
同的连通分支:当查看到第k条边(v,w)时,如果端点v 和w分别是当前2个不同的连通分支T1和T2中的顶点时,
就用边(v,w)将T1和T2连接成一个连通分支,然后继续查看第k+1条边;如果端点v和w在当前的同一个连通分支中,就直接再查看第k+1条边。

关于集合的一些基本运算可用于实现Kruskal算法按权的递增顺序查看等价于对优先队列执行
removeMin运算。可以用堆实现这个优先队列。对一个由连通分支组成的集合不断进行修改,需要用到抽象数据类型并查集UnionFind所支持的基本运算. 当图的边数为e时,Kruskal算法所需的计算时间是O(eloge)。

例5:多机调度问题

n个作业如何尽可能快地在m个机器上处理完??? 约定,每个作业均可在任何一台机器上加工处理,但未完
工前不允许中断处理。作业不能拆分成更小的子作业。

这是一个NPC问题,可以用贪心选择策略得到近似解。采用最长处理时间优先的贪心策略。
按此策略,当n<=m时,只要将机器i的[0,ti]时间区间分配给作业i即可,算法只需要O(1)时间。当 n>m时,首先将n个作业依其所需的处理时间从大到小排序。然后依此顺序将作业分配给空闲的处理机。算法所需的计算时间为O(nlogn)。
例如,设7个独立作业{1,2,3,4,5,6,7}由3台机器M1,M2和M3加工处理。各作业所需的处理时间分别为{2,14,4,16,6,5,3}。按算法greedy产生的作业调度如下图所示,所需的加工时间为17。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OeZd8QMf-1620705761760)(D7E0C88DDA0D41B2A23ADC52A93A80D5)]

第五章——回溯法

当需要找到解集或者要求回答什么解是满足某些约束条件的最佳解时往往需要回溯法。

回溯法基本做法是搜索,可以采用剪枝函数避免无效搜索,提高回溯的效率,有以下两种方法。

  1. 用约束函数再扩展结点处剪去不满足约束的子树。
    2.用限界函数剪去得不到最优解的子树。

回溯法解题的基本步骤

(1)针对所给问题,定义问题的解空间。
(2)确定易于搜索的解空间结构。
(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

伪代码实现

递归回溯

void Backtrack(int t)
{
    if(t>n) Output(x);
    else
    for(int i=f(n,t);i<=g(n,t);i++){
        x[t]=h(i);
        if(Constraint(t)&&Bound(t)) Backtrack(t+1);
    }
    //其中形参t为递归深度,n用来控制递归深度。当t>n时,算法搜索到叶结点,此时由OUTPUT(x)记录或者输出得到的可行解x。
    //f(n,t)和g(n,t)分别表示在当前扩展结点处未搜索过的子树的起始编号和终止编号。h(i)为当前扩展结点x[t]的第i个可选值。Constraint(t)和Bound(t)分别表示当前结点处的约束函数和限界函数。
}

当然此处的递归可以改用迭代的方式,但是时间复杂度并不会降低。
迭代回溯

void IterativeBacktrack(void)
{
    int t=1;
    while(t>0)
    {
        if(f(n,t)<=g(n,t))
          for(int i=f(n,t);i<=g(n,t);i++)
          {
              x[t]=h(i);
              if(Constraint(t)&&Bound(t))
              {
                  if(Solution(t)) Output(x);
                  else t++;
              }
          }
          else t--;
    }
}

子集树和排列树

子集树:从n个元素的集合中找出某种性质的子集。时间复杂度为O(2^n)
排列树:所给问题是确定n个元素满足某种性质的排列。时间复杂度为O(n!)

回溯法求解子集树框架:

void Backtrack(int t)
{
    if(t>n) Output(x);
    else 
      for(int i=0;i<=1;i++){
          x[t]=i;//为0时表示子集中没有该元素,为1时表示有该元素
          if(Constraint(t)&&Bound(t)) Backtrack(t+1);
      }
}

回溯法求解排列树框架:

void Backtrack(int t)
{
    if(t>n) Output(x);
    else 
      for(int i=t;i<=n;i++)
      {
          Swap(x[t],x[i]);
          if(Constraint(t)&&Bound(t)) Backtrack(t+1);
          Swap(x[t],x[i]);
      }
}
  • 4
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值