前言
本博客对以下6种经典算法及相关问题进行一个集合汇总。包含各种算法的基本思想、问题的思考思路,以及代码实现(C++)。
最后学习算法需要知道的事情:
时间复杂度大小排序:O(1) < O(logn) < O(n) < O(nlogn) < O(n^ 2) < O(n^ 3) < O(2^ n)
博主提醒您:不要畏难,每天进步一点点。
穷举法
基本思想
穷举法是算法设计中最简单也是最为“暴力”的一种算法,穷举法的基本思想就是**将问题的所有可能的答案一一列举(这就是穷举),然后根据条件判断此答案是否合适,合适就保留,不合适就丢弃。**例如:找出1到100之间的素数,就需要遍历1到100之间的所有整数,再一一进行判断。
问题:百鸡问题
- 鸡翁一,值钱五,鸡母一,值钱三,鸡雏三,值钱一,百钱买百鸡,问翁、母、雏各几何?
输入:无
输出:鸡翁,鸡母,鸡雏的数量 - 分析:
本题需要找到一个符合三种鸡的数量与各自的价值相乘相加和为100,且三种鸡的数量和为100的组合,由使用穷举法解决问题,首先需要确定穷举的范围,在本题中可以看出我们需要穷举的每一种鸡的范围的约束条件是:每一种鸡的数量都不能超过100(约束了鸡雏的数量)、购买每一种鸡花费的价格都不能超过100(约束了鸡翁、鸡母的数量) - 代码如下:
#include <iostream>
using namespace std;
int main ()
{
int x,y,z;//x为鸡翁,y为鸡母,z为鸡雏
for(x=0;x<=20;x++){
for(y=0;y<=33;y++){
for(z=0;z<=100;z+=3){
if(x*5+y*3+z/3==100&&x+y+z==100){
cout<<"x="<<x<<" y="<<y<<" z="<<z<<endl;
}
}
}
}
return 0;
}
递归与分治
基本思想
- 递归:若一个过程直接或间接地调用自己,则称这个过程是递归的过程(简单来说:一个函数自己调用自己的过程)。递归分为直接递归(自己调用自己)与间接递归(A调用B,B调用A)。
- 分治:所谓分治就是分而治之,对于一个较大规模的问题,使用分治的思想就是将它分为多个互相独立且与原问题形式相同的规模较小的问题,递归地解决这些子问题,然后将各子问题的解合并,即可得到原问题的解。
问题:二分查找
- 假设本题给定数组为[1,3,4,6,8,9,13,16,20,25],输入一个数x,使用二分查找的方法在给定数组中对其进行查找,若找到返回x的位置(索引),否则返回-1。
输入:需要查找的数x
输出:x的位置或者-1 - 分析:
首先,数组中元素按升序(降序)排列是二分查找的必要条件,二分查找首先需要将数组中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功。 - 代码如下:
#include <iostream>
using namespace std;
int main()
{
int a[10]={1,3,4,6,8,9,13,16,20,25};
int x;
cin>>x;
int left=0; //初始化到最左端
int right=10-1; //初始化到最又端
while(left<=right){
int middle=(left+right)/2;
if(a[middle]==x)
{ //查找成功
cout<<middle;
return middle;
}
if(x>a[middle])
{ //x大于中间值,说明查找范围在中间值右边,左端点靠过来
left=middle+1;
}else
{ //x小于中间值,说明查找范围在中间值左边,右端点靠过来
right=middle-1;
}
}
cout<<-1;
return 0;
}
问题:合并排序
- 给定默认数组[5,8,2,9,4,6,3,1,10,7],使用合并排序的方法,将该数组按从小到大的顺序排序并输出。
输入:无
输出:1 2 3 4 5 6 7 8 9 10 - 分析:
合并排序的解决办法就是利用了分治的算法思想,第一步(分):首先将待排序的元素分成大小大致相同的2个子集合,然后再对这两个子集合继续进行分割,同样分成大小大致相同的2个子集合,最后分成每个子集合只剩1个元素的时候,第二步(治):两两合并且排序,合并到最后,即可得到所要求的排好序的集合。 - 代码如下:
#include <iostream>
using namespace std;
void Copy(int a[],int b[],int left,int right)
{
//将b[0]至b[right-left+1]拷贝到a[left]至a[right]
int size=right-left+1;
for(int i=0; i<size; i++) {
a[left++]=b[i];
}
}
void Merge(int a[],int b[],int left,int i,int right)
{
//合并有序数组a[left:i],a[i+1:right]到b,得到新的有序数组b
int a1cout=left, //指向第一个数组开头
a1end=i, //指向第一个数组结尾
a2cout=i+1, //指向第二个数组开头
a2end=right, //指向第二个数组结尾
bcout=0; //指向b中的元素
for(int j=0; j<right-left+1; j++) {
//执行right-left+1次循环,数组
if(a1cout>a1end) {
b[bcout++]=a[a2cout++];
continue;
} //如果第一个数组结束,拷贝第二个数组的元素到b
if(a2cout>a2end) {
b[bcout++]=a[a1cout++];
continue;
} //如果第二个数组结束,拷贝第一个数组的元素到b
if(a[a1cout]<a[a2cout]) {
b[bcout++]=a[a1cout++];
continue;
} //如果两个数组都没结束,比较元素大小,把较小的放入b
else {
b[bcout++]=a[a2cout++];
continue;
}
}
}
void MergeSort(int a[],int left,int right)
{
//对数组a[left:right]进行合并排序
int *b=new int[right-left+1];
if(left<right) {
int i=(left+right)/2;//取中点
MergeSort(a,left,i);//左半边进行合并排序
MergeSort(a,i+1,right);//右半边进行合并排序
Merge(a,b,left,i,right);//左右合并到b中
Copy(a,b,left,right);//从b拷贝回来
}
}
int main()
{
int n=10;
int a[]={5,8,2,9,4,6,3,1,10,7};
MergeSort( a, 0, n-1);
for(int j=0; j<n; j++) {
cout<<" "<<a[j];
}
return 1;
}
问题:快速排序
- 给定默认数组[5,8,2,9,4,6,3,1,10,7],使用合并排序的方法,将该数组按从小到大的顺序排序并输出。
输入:无
输出:1 2 3 4 5 6 7 8 9 10 - 分析:
使用快速排序,首先选取数组中某个元素t=a[s],然后将其他元素重新排列,使a[0…n-1]中所有在t前面的元素都小于或等于t,所有在t后面的元素都大于或等于t。这里我们使用最简单的选择策略:每次都选取数组第一个元素当做中轴(即前面的t),然后同时从左右开始扫描,左边找到一个比中轴大的,右边找到一个比中轴小的,然后交换两个元素的位置。
- 代码如下:
#include <iostream>
using namespace std;
int Partition(int a[],int p,int r){
int i=p,j=r+1;
int x=a[p];
//将小于x的元素交换到左边区域,将大于x的元素交换到右边区域
while(true){
while(a[++i]<x&&i<r); //直到找到左边存在大于x的元素停止
while(a[--j]>x); //直到找到右边存在小于x的元素停止
if(i>=j){
//用来结束while循环
break;
}
//交换两个找到的元素的位置
swap(a[i],a[j]);
}
//此时已经找到了原来划分点x正确的位置,为a[j]
a[p]=a[j]; //将以前雀占鸠巢的元素值放在a[p]上
a[j]=x; //x回到正确的位置
return j; //从这里继续开始划分
}
void QuickSort(int a[],int p,int r){
if(p<r){
int q=Partition(a,p,r);
//对左半段排序
QuickSort(a,p,q-1);
//对右半段排序
QuickSort(a,q+1,r);
}
}
int main()
{
int a[]={5,8,2,9,4,6,3,1,10,7};
int n=10;
QuickSort(a,0,n-1);
for(int i=0;i<n;i++){
cout<<a[i]<<" ";
}
return 0;
}
问题:循环赛日程表
- 设有n=2^k个运动员要进行网球循环赛。现要设计一个满足以下要求的比赛日程表:
- 每个选手必须与其他n-1个选手各赛一次;
- 每个选手一天只能赛一次;
- 循环赛一共进行n-1天。
- 按上述要求将比赛日程表设计成有n行和n-1列的表。在表中第i行和第j列处填入第i个选手在第j天遇到的选手。(无输入;输出一个日程表,二维数组表示)
- 分析:
按分治策略,可以将所有选手对分为两半,n个选手的比赛日程表就可以通过为n/2个选手设计的比赛日程表来决定。递归地用这种一分为二的策略对选手进行分割,直到只剩下两个选手时,比赛日程表的制定就变得简单了。这时只要让这两个选手比赛就可以了。 - 代码如下:
#include <iostream>
using namespace std;
void Table(int k,int **a){
int n=1;
for(int i=1;i<=k;i++){
n*=2;
}
for(int i=1;i<=n;i++){
a[1][i]=i;
}
int m=1;
for(int s=1;s<=k;s++){
n/=2;
for(int t=1;t<=n;t++){
for(int i=m+1;i<=2*m;i++){
for(int j=m+1;j<=2*m;j++){
a[i][j+(t-1)*m*2]=a[i-m][j+(t-1)*m*2-m];
a[i][j+(t-1)*m*2-m]=a[i-m][j+(t-1)*m*2];
}
}
}
m*=2;
}
}
int main()
{
//假设选手人数为8人
int n=9;
int k=3;
//创建一个n行n列的数组,但只用到了[1:n-1][1:n-1]
int **a=new int*[n];
for(int i=0;i<n;i++){
a[i]=new int[n];
}
Table(k,a);
for(int i=1;i<n;i++){
for(int j=1;j<n;j++){
cout<<a[i][j]<<" ";
}
cout<<endl;
}
return 0;
}
关于循环日程表的问题,如果还有疑惑,请参考这篇博客循环日程表解析
动态规划
关于动态规划
动态规划是用来解决多阶段决策过程最优化的一种数量方法。其特点在于,它可以把一个n维决策问题变换为几个一维最优化问题,从而一个一个地去解决。
需要指出:动态规划是求解某类问题的一种方法,是考察问题的一种途径,而不能仅仅把它当做一种算法。必须对具体问题进行具体分析,运用动态规划的原理和方法,建立相应的模型,然后再用动态规划方法去求解。
动态决策问题的特点:系统所处的状态和时刻是进行决策的重要因素;即在系统发展的不同时刻(或阶段)根据系统所处的状态,不断地做出决策;找到不同时刻的最优决策以及整个过程的最优策略。
多阶段决策问题:是动态决策问题的一种特殊形式;在多阶段决策过程中,系统的动态过程可以按照时间进程分为状态相互联系而又相互区别的各个阶段;每个阶段都要进行决策,目的是使整个过程的决策达到最优效果。
动态规划算法的基本要素:
- 最优子结构:问题的最优解包含着其子问题的最优解。这种性质称为最优子结构性质。利用问题的最优子结构性质,以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解。
- 重叠子问题:递归算法求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。这种性质称为子问题的重叠性质。动态规划算法,对每一个子问题只解一次,而后将其解保存在一个表格中,当再次需要解此问题时,只是简单地用常数时间查看一下结果。
- 备忘录方法:备忘录方法的控制结构与直接递归方法的控制结构相同,区别在于备忘录方法为每个解过的子问题建立了备忘录以备需要时查看,避免了相同子问题的重复求解。
基本思想
动态规划算法与分治法类似,其基本思想是将待求解问题分解成若干子问题,先求解子问题,再结合这些子问题的解得到原问题的解。与分治法不同的是,适合用动态规划法求解的问题经分解得到的子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,以致最后解决原问题需要耗费指数级时间。然而,不同子问题的数目常常只有多项式量级。在用分治法求解时,有些子问题被重复计算了许多次。如果能够保存已解决的子问题的答案,在需要的时再找出已求得的答案,这样可以避免大量的重复计算,从而得到多项式时间算法。为达到此目的,可以用一个表来记录所有已解决的子问题的答案。这就是动态规划的基本思想。
动态规划算法适用于解最优化问题,通常可以按以下4个步骤设计(1~3为动态规划算法的基本步骤):
- 找出最优解的性质,并刻画其特征结构
- 递归地定义最优值
- 以自底向上的方式计算最优值
- 根据计算最优值时得到的信息,构造最优解
问题:最长公共子序列
- 给定两个序列X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A},找出X与Y的最长公共子序列。
- 分析:
按照解决动态规划问题的3个基本步骤的思路分步骤分析:- 第一:最长公共子序列的结构(找出最优解的性质,并刻画其特征结构)
设序列X={x1,x2,…,xm}和Y={y1,y2,…,yn}的最长公共子序列为Z={z1,z2,…,zk},则有如下情况:
(由既然是公共子序列,那么Z中元素zi必然同时属于X和Y,下面X(m-1)={x1,x2,…,xm-1},Y,Z同理)
- 若xm=yn,则zk=xm=yn,且Z(k-1)是X(m-1)和Y(n-1)的最长公共子序列
- 若xm!=yn且zk!=xm,则Z是X(m-1)和Y的最长公共子序列
- 若xm!=yn且zk!=yn,则Z是X和Y(n-1)的最长公共子序列
- 第二:子问题的递归结构(递归地定义最优值)
由最长公共子序列问题的最优子结构性质可知,要找出X与Y的最长公共子序列,可按以下方式递归地进行:
- 当xm=yn时,找出X(m-1)和Y(n-1)的最长公共子序列,然后在其尾部加上xm(或yn),即可得X和Y的最长公共子序列。
- 当xm!=yn时,必须解两个子问题,即找出X(m-1)和Y的一个最长公共子序列及X和Y(n-1)的一个最长公共子序列。这两个子问题都包含一个公共子问题,即计算X(m-1)和Y(n-1)的最长公共子序列。
- 第三:计算最优值(以自底向上的方式计算最优值)
首先建立子问题最优值的递归关系。用c[i][j]来记录序列Xi和Yj的最长公共子序列的长度。其中,Xi={x1,x2,…,xi};Y同理。当i=0或j=0时,空序列是Xi和Yj的最长公共子序列,此时c[i][j]=0。其他情况下,由最优子结构性质可建立递归关系如下:
c [ i ] [ j ] = { 0 i = 0 或 j = 0 c [ i − 1 ] [ j − 1 ] + 1 i , j > 0 ; x i = y j m a x { c [ i ] [ j − 1 ] , c [ i − 1 ] [ j ] } i , j > 0 ; x i ! = y j c[i][j]=\left\{ \begin{aligned} 0&&i=0或j=0\\ c[i-1][j-1]+1&&i,j>0;xi=yj\\ max\{c[i][j-1],c[i-1][j]\}&&i,j>0;xi!=yj \end{aligned} \right. c[i][j]=⎩⎪⎨⎪⎧0c[i−1][j−1]+1max{c[i][j−1],c[i−1][j]}i=0或j=0i,j>0;xi=yji,j>0;xi!=yj
最后使用用动态规划自底向上计算最优值。
- 第一:最长公共子序列的结构(找出最优解的性质,并刻画其特征结构)
- 代码如下:
#include<stdio.h>
#include<string.h>
char a[500],b[500];
char num[501][501]; ///记录中间结果的数组
char flag[501][501]; ///标记数组,用于标识下标的走向,构造出公共子序列
void LCS(); ///动态规划求解
void getLCS(); ///采用倒推方式求最长公共子序列
int main()
{
int i;
strcpy(a,"ABCBDAB");
strcpy(b,"BDCABA");
memset(num,0,sizeof(num));
memset(flag,0,sizeof(flag));
LCS();
printf("%d\n",num[strlen(a)][strlen(b)]);
getLCS();
return 0;
}
void LCS()
{
int i,j;
for(i=1;i<=strlen(a);i++)
{
for(j=1;j<=strlen(b);j++)
{
if(a[i-1]==b[j-1]) ///注意这里的下标是i-1与j-1
{
num[i][j]=num[i-1][j-1]+1;
flag[i][j]=1; ///斜向下标记
}
else if(num[i][j-1]>num[i-1][j])
{
num[i][j]=num[i][j-1];
flag[i][j]=2; ///向右标记
}
else
{
num[i][j]=num[i-1][j];
flag[i][j]=3; ///向下标记
}
}
}
}
void getLCS()
{
char res[500];
int i=strlen(a);
int j=strlen(b);
int k=0; ///用于保存结果的数组标志位
while(i>0 && j>0)
{
if(flag[i][j]==1) ///如果是斜向下标记
{
res[k]=a[i-1];
k++;
i--;
j--;
}
else if(flag[i][j]==2) ///如果是斜向右标记
j--;
else if(flag[i][j]==3) ///如果是斜向下标记
i--;
}
for(i=k-1;i>=0;i--)
printf("%c",res[i]);
}
问题:0-1背包问题
- 给定n种物品和一背包。物品i的重量是wi,其价值为vi,背包的容量为c。问应如何选择装入背包中的物品,使得装入背包中的物品的总价值最大?
- 分析:
0-1背包问题中,对每种物品只有装入或不装入两种选择,并且不能将物品i装入背包多次,也不能只装入部分的物品i。首先将此问题形式化描述:要求找出一个n元0-1向量(x1,x2,…,xn),x=0或x=1,使得w1x1+w2x2+…+wnxn<=c,而且v1x1+v2x2+…vnxn达到最大。 - 代码如下:
1 void FindMax()//动态规划
2 {
3 int i,j;
4 //填表
5 for(i=1;i<=number;i++)
6 {
7 for(j=1;j<=capacity;j++)
8 {
9 if(j<w[i])//包装不进
10 {
11 V[i][j]=V[i-1][j];
12 }
13 else//能装
14 {
15 if(V[i-1][j]>V[i-1][j-w[i]]+v[i])//不装价值大
16 {
17 V[i][j]=V[i-1][j];
18 }
19 else//前i-1个物品的最优解与第i个物品的价值之和更大
20 {
21 V[i][j]=V[i-1][j-w[i]]+v[i];
22 }
23 }
24 }
25 }
26 }
贪心算法
基本思想
当一个问题具有最优子结构性质时,可用动态规划法求解。但有时会有更简单有效的算法。现在我们来介绍另一种经典算法——贪心算法。
顾名思义,贪心算法总是做出在当前看来是最好的选择。也就是说,贪心算法并不从整体最优上加以考虑,所做的选择只是在某种意义上的局部最优选择。贪心算法中,较大子问题的解恰好包含了较小子问题的解作为子集,这与动态规划算法设计中的优化原则本质上是一致的。
贪心算法的一般框架:
GreedyAlgorithm(parameters){
初始化;
重复执行以下操作:
选择当前可以选择的最优解;
将所选择的当前解加入到问题的解中;
直至满足问题求解的结束条件。
}
问题:活动安排问题
- 有n个活动申请使用同一个礼堂,每项活动有一个开始时间和一个截止时间,如果任何两个活动不能同时举行,问如何选择这些活动,从而使得被安排的活动数达到最多?
- 分析:
本题可形式化为:设S={1,2,…,n}为活动集合,si和fi分别为活动的开始和截止时间,i=1,2,…,n。定义活动i与j相容:si>=fj或者sj>=fi,i!=j,求S最大的两两相容的活动子集。
假设活动情况如下:
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
s[i] | 1 | 3 | 0 | 5 | 3 | 5 | 6 | 8 | 8 | 2 | 12 |
f[i] | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
使用策略:早完成的活动先安排
把活动按照截止时间从小到大排序,使得f1<=f2<=…<=fn,然后从前向后挑选,只要与前面选择的活动相容,便将这项活动选入最大相容集合A。该算法的贪心选择的意义是:使剩余的可安排时间段极大化,以便安排尽可能多的相容活动。
- 代码如下:
#include <iostream>
using namespace std;
//注意活动的排序是按照结束时间非递减排序
void GreedySelector(int n,int s[],int f[],bool A[]){
//进行活动选择,将是否选择活动的情况存入bool数组A中
//从第一个活动开始(第一个活动必选)
A[0]=true;
int j=0; //当前活动索引
for(int i=1;i<n;i++){ //遍历从第2个活动开始的所有活动,i是下一个等待判断的活动索引
if(s[i]>=f[j]){
//如果索引为i的活动起始时间大于当前活动的结束时间(当前活动已结束)
A[i]=true; //选定索引为i的活动
j=i; //将当前活动置为索引为i的活动
}else{
//如果索引为i的活动起始时间小于当前活动的结束时间(当前活动未结束)
A[i]=false; //不选定
}
}
}
int main()
{
int n=11;
int s[n]={1,3,0,5,3,5,6,8,8,2,12};
int f[n]={4,5,6,7,8,9,10,11,12,13,14};
bool A[n]={false};
GreedySelector(n,s,f,A);
cout<<"选定了活动:";
for(int i=0;i<n;i++){
if(A[i]){
cout<<i+1<<" ";
}
}
return 0;
}
问题:哈夫曼编码
- 通讯过程中需将传输的信息转换为二进制码。由于英文字母使用的频率不同,若频率高的字母对应短的编码,频率低的字母对应长的编码,相同信息转换为二进制后,传输的数据总量就会最低,要求找到一个编码方案,使传输的数据量最少。
- 分析:
为能正确译码,编码需采用前缀码,哈夫曼提出构造最优前缀码的贪心算法,由此产生的编码方案称为哈夫曼编码。
算法思路:以n个字母为结点构成n棵仅含一个点的二叉树集合,字母的频率即为结点的权。每次从二叉树集合中找出两个权最小者合并为一棵二叉树:增加一个根结点将这两棵树作为左右子树,新树的权为两棵子树的权之和。重复进行上述合并操作,直到只剩一棵树(即为哈夫曼树)为止。
(本题仅给出哈夫曼树核心代码伪代码) - 代码如下:
template<class T>
BinaryTree<int>HuffmanTree(T f[],int n){
//根据权f[1:n]构造哈夫曼树
//创建一个单结点数的数组
Huffman<T>*W=new Huffman<T>[n+1];
BinaryTree<int> z,zero;
for(int i=1;i<=n;i++){
z.MakeTree(i,zero,zero);
W[i].weight=f[i];
W[i].tree=z;
}
//数组变成一个最小堆
MinHeap<Huffman<T>>Q(1);
Q.Initialize(w,n,n);
//将堆中的树不断合并
Huffman<T>x,y
for(i=1;i<n;i++){
Q.DeleteMin(x);
Q.DeleteMin(y);
z.MakeTree(0,x.tree,y.tree);
//合并权
x.weight+=y.weight;
x.tree=z;
Q.Insert(x);
}
Q.DeleteMin(x); //最后的树
Q.Deactivate();
delete[] w;
return x.tree;
}
问题:单源最短路径
- 给定一个图G=(V,E),其中每条边的权是一个非负实数。另外给定V中的一个顶点v,称为源。求从源v到所有其它各个顶点的最短路径。
- 分析:
单源最短路径问题的贪心选择策略:选择从源v出发目前用最短的路径所到达的顶点,这就是目前的局部最优解。
本题中我们首先设置一个集合S;用数组dis[]来记录v到S中各点的目前最短路径长度。然后不断地用贪心选择来扩充这个集合,并同时记录或修订数组dis[];直至S包含所有V中顶点。
下面的代码实现我们使用Dijkstra算法,该算法的做法是:由近到远逐步计算,每次最近的顶点的距离就是它的最短路径长度。然后再从这个最近者出发。即依据最近者修订到各顶点的距离,然后再选出新的最近者。重复上述操作,直到所有顶点都走到。
(本题给出了Dijkstra算法伪代码,代码看不懂没关系,思路一定要清晰,代码下面给出了图解帮助理解) - 代码如下:
Procedure Dijkstra{
S:={1}; //初始化S
for i:=2 to n do //初始化dis[]
dis[i]=C[1,i] //初始时为源到顶点i一步的距离
for i:=1 to n do{
从V-S中选取一个顶点u使得dis[u]最小;
将u加入到S中; //将新的最近者加入S
for w ∈V-S do{ //依据最近者u修订dis[w]
//这句话是Dijkstra的关键!!!选取最小距离并更新dis[]
dis[w]:=min(dis[w],dis[u]+C[u,w]);
}
}
}
- 算法过程图解:
问题:最小生成树
- 设G=(V,E)是一个无向连通带权图,即一个网络。E的每条边(v,w)的权为c[v][w]。
如果G的一个子图G·是一颗包含G的所有顶点的树,则称G·为G的生成树。
生成树的各边的权的总和称为该生成树的耗费。
在G的所有生成树中,耗费最小的生成树称为G的最小(优)生成树。 - 分析:
本题我们使用Kruskal算法的思想来解决:在保证无回路的前提下依次选出权重较小的n-1条边。贪心策略:如果(i,j)是E中尚未被选中的边中权重最小的,并且(i,j)不会与已经选择的边构成回路,于是就选择(i,j)
定义如下数据结构:
结构数组e[]表示图的边,e[i].u、e[i].v、e[i].w分别表示边i的两个端点及其权重。
函数Sort(e,w)将数组e按权重w从小到大排序。
一个连通分支中的顶点表示为一个集合。
函数Initialize(n)将每个顶点初始化为一个集合。
函数Find(u)给出顶点u所在的集合。
函数Union(a,b)给出集合a和集合b的并集。
重载运算符!=判断集合的不相等。
(本题只给出Kruskal算法核心代码) - 代码如下:
Kruskal(int n,*e){
Sort(e,w); //将边按权重从小到大排序
Initialze(n); //初始时每个顶点为一个集合
k=1; //k累计已选边的数目
j=1; //j为所选的边在e中的序号
while(k<n){ //选择n-1条边
a=Find(e[j].u);b=Find(e[j].v); //找出第j条边的两个端点所在的集合
if(a!=b){
//若不同,第j条边放入树中并合并这两个集合
T[k]=j;
Union(a,b);
k=k+1;
}
j++; //继续考察下一条边(仍按权重从小到大遍历)
}
}
问题:背包问题
- 给定n个物品和一个背包,物品i的重量为wi,价值为vi,背包容量为C。如果在装入背包时,物品可以切割,即可以只装入一部分,问如何选择装入背包的物品的价值最大?(注意区分本题和动态规划中0-1背包问题的区别,0-1背包问题不适用于贪心算法)
设n=3,C=20,(v1,v2,v3)=(25,24,15),(w1,w2,w3)=(18,15,10) - 分析:
影响背包效益值的因素:背包的容量C、放入背包中的物品的重量及其可能带来的效益值
可行的策略:在背包效益值的增长速率和背包容量消耗速率之间取得平衡,即每次装入的物品应使它所占用的每一单位容量能获得当前最大的单位效益。
在这种策略下的量度是:已装入的物品的累计效益值与所用容量之比(单位重量价值)。
此时,将按照物品的单位效益值——vi/wi值的非增次序进行选择:v1/w1 < v3/w3 < v2/w2
即首先将物品2装入背包,其次选择装入物品3 - 代码如下:
#include <iostream>
using namespace std;
void Knapsack(int n,float C,float v[],float w[],float x[]){
//Sort(n,v,w); //因为我们初始化的时候已经排好序,这里省略
int i;
for(i=1;i<=n;i++){
//每种物品选取比例,初始化为0
x[i]=0;
}
for(i=1;i<=n;i++){
if(w[i]>C){
//当前单位重量价值最大的物品,物品重量大于背包容量
break;
}
//当前单位重量价值最大的物品,物品重量不大于容量C,全部装入
x[i]=1;
C-=w[i]; //剩余容量减小w[i]
}
if(i<=n){
//说明触发了上面for循环中的break,直接用物品i填满剩下容量C即可
x[i]=C/w[i];
}
}
int main()
{
int n=3; //3件物品
float C=20; //容量20
//初始化,物品需要按单位重量价值非递减排序x1>x2>x3
float v[4]={0,24,15,25}; //因为是从索引1开始,所以v[0]用0填充
float w[4]={0,15,10,18}; //重量同理
float x[4]={0}; //每种物品选取比例,初始化为0
Knapsack(n,C,v,w,x);
cout<<"物品选取比例情况为:";
for(int i=1;i<=n;i++){
cout<<x[i]<<" ";
}
return 0;
}
贪心算法总结
贪心算法是从初态出发,逐步递增扩大解,最后扩大为完整解。每次扩大时,都要在若干方案中选择一定的扩大方式,选择的依据是当前状态下某种意义的最优选择。这种选择与其他步骤(其他子解)无关,这也是与动态规划法的重要区别!这是一种只顾当前“利益”的方法,即保证当前是最优解的,这就是“贪心”叫法的来历。显然,这种只顾当前利益的做法,不一定总能获得最好的全局利益。因此,使用贪心算法时要特别注意。
回溯法
基本思想
寻找问题的解的一种可靠方法是:首先列出所有候选解,然后依次检查每一个,在检查完所有或部分候选解后,即可找到所需要的解。但是,只有当一个问题候选解数量有限,并且通过检查所有或部分候选解能够得到所需解时,上述方法才是可行的。根据这个思想,产生了回溯法和分支限界法这两种对候选解进行系统检查的方法。
回溯法的基本做法是搜索,一种组织得井井有条的、能避免不必要搜索的穷举式搜索法。回溯法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。
算法搜索至解空间树任意一点时,先判断该结点是否包含问题的解:
- 如果肯定不包含,则跳过对该结点为根的子树的搜索,逐层向其祖先结点回溯;
- 如果可能包含,进入该子树,继续按深度优先策略搜索。
回溯法的基本步骤:
- 针对所给问题,定义问题的解空间
- 确定易于搜索的解空间结构
- 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索
常用的剪枝函数:
- 用约束函数在扩展结点处剪去不满足约束的子树
- 用限界函数剪去得不到最优解的子树
空间复杂性分析:
- 用回溯法解题的一个显著特征是在搜索过程中动态产生问题的解空间。在任何时刻,算法只保存从根结点到当前扩展结点的路径。
- 如果解空间树中从根结点到叶节点的最长路径的长度为h(n),则回溯法所需的计算空间通常为O(h(n))。
- 显示地存储整个解空间则需要O(2h(n))或O(2h(n)!)内存空间
值得注意:
- 一个所有儿子已经产生的结点称作死结点
- 一个扩展结点变成死结点之前,它一直是扩展结点
- 具有限界函数的深度优先生成法称为回溯法
问题:装载问题
问题:0-1背包问题
问题:旅行售货员问题
问题:n皇后问题
-
在一个N*N的国际象棋棋盘上放置n个皇后,使得它们中任意两个都不互相“攻击”,即任意两个皇后不可在同一行、同一列、同一斜线上。
输入:皇后的数量n
输出:x种摆放方法 -
分析:
-
代码如下(递归解法):
#include<cstdio>
#include<iostream>
#include<cstring>
using namespace std;
const int maxn=105;
int tot,n;
int c[maxn];
void search_queen(int cur){
if(cur==n)
{
tot++; //d递归边界。只要走到这所有皇后必然不冲突
}
else
{
for(int i=0;i<n;i++){
int ok=1;
c[cur]=i; //尝试把第cur行的黄后放到第i列
for(int j=0;j<cur;j++){ //检查是否与之前放好的皇后冲突,横向上肯定不会冲突,因此只需要检查纵向,斜向
if(c[cur]==c[j]||cur-c[cur]==j-c[j]||cur+c[cur]==j+c[j]){
ok=0;
break;
}
}
if(ok) search_queen(cur+1); //如果此位置合法,继续递归寻找下一个皇后的位置
}
}
}
int main()
{
cout<<"请输入皇后的个数(注意:n不要太大哟!):n=";
cin>>n;
search_queen(0);
cout<<n<<"皇后所有解的个数为 :"<<tot<<endl;
return 0;
}
分支限界法简单介绍
基本思想
搜索算法的类型:
- 基于枚举策略的搜索
- 深度优先算法
- 广度优先算法
- 优化+枚举的搜索
- 回溯算法=深度优先搜索+剪枝策略
- 分支限界算法=广度优先搜索+剪枝策略
- 启发式搜索:启发式搜索是一种基于规则的优化搜索算法。
分支限界法简介:
(广度优先搜索+剪枝优化)
- 将根结点加入队列中
- 接着从队列中取出首结点,使其成为当前扩展结点,一次性生成它的所有孩子结点,判断孩子结点是舍弃还是保存。舍弃那些不可能导致可行解或最优解的孩子结点,其余的结点则保存在队列中
- 重复上述扩展过程,直到找到问题的解或队列为空时为止(每一个活结点最多只有一次机会成为扩展结点)
分类:
- 队列式分支限界法
- 优先队列式分支限界法
分支限界法的一般解题步骤为:
- 定义问题的解空间
- 确定问题的解空间组织结构(树或图)
- 搜索解空间。搜索前要定义判断标准(约束函数或限界函数),如果选用优先队列式分支限界法,必须确定优先级
分支限界法的时间性能:分支限界法和回溯法实际上都属于蛮力穷举法,遍历具有指数阶个结点的解空间树,在最坏情况下,时间复杂性肯定为指数阶。与回溯法不同的是,分支限界法首先扩展解空间树中的上层结点,并采用限界函数,有利于实行大范围剪枝,同时,**根据限界函数不断调整搜索方向,选择最有可能取得最优解的子树优先进行搜索。**所以,如果选择了结点的合理扩展顺序以及设计了一个好的限界函数,分支限界法可以快速得到问题的解。
分支限界法与回溯法的比较:
- 相同点:
- 均需要先定义问题的解空间,确定的解空间组织结构一般都是树或图
- 在问题的解空间树上搜索问题的解
- 搜索前均需确定判断条件,该判断条件用于判断扩展生成的结点是否为可行结点
- 搜索过程中必须判断扩展生成的结点是否满足判断条件,如果满足,则保留该扩展生成的结点,否则舍弃
- 不同点:
- 搜索目标:回溯法的求解目标是找出解空间树中满足约束条件的所有解;分支限界法的求解目标是找出满足约束条件的一个解,或是在满足约束条件的解中找出在某种意义下的最优解
- 搜索方式不同:回溯法以深度优先的方式搜索解空间树;分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树。
- 扩展方式不同:在回溯法搜索中,扩展结点一次生成一个孩子结点;分支限界法搜索中,扩展结点一次生成它的所有孩子结点。