本文章配合书籍——算法设计与分析(第2版)-清华大学出版社-李春葆主编,食用更佳
概述
什么是算法
算法是求解问题的一系列计算步骤,用来将输入数据转换成输出结果
如果一个算法对其每一个输入实例,都能输出正确的结果并停止,则称它是正确的。
算法设计应满足以下几条目标:
- 正确性
- 可使用性
- 可读性
- 健壮性
- 高效率与低存储量需求
算法具有以下5个重要特征:
- 有限性
- 确定性
- 可行性
- 输入性
- 输出性
算法和数据结构的联系与区别
联系
:数据结构是算法设计的基础。算法的操作对象是数据结构,在设计算法时,通常要构建适合这种算法的数据结构。数据结构设计主要是选择数据的存储方式,如确定求解问题中的数据采用数组存储还是采用链表存储等。算法设计就是在选定的存储结构上设计一个满足要求的好算法。
区别
:数据结构关注的是数据的逻辑结构、存储结构以及基本操作,而算法更多的是关注如何在数据结构的基础上解决实际问题。算法是编程思想,数据结构则是这些思想的逻辑基础。
算法分析
算法分析是分析算法占用计算机资源的情况。两个主要方面是分析算法的时间复杂度
和空间复杂度
时间复杂度分析
(大O符号)f(n)=O(g(n))(读作“f(n)是g(n)的大O”)当且仅当存在正常量c和n0,使当n≥n0时,f(n)≤cg(n),即g(n)为f(n)的上界
。
-
上界的阶越低,结果就越有价值
-
一个算法的时间用大O符号表示时,总是采用最有价值的g(n)表示,称之为“紧凑上界”或“紧确上界”
(大符号)f(n)= (g(n))(读作“f(n)是g(n)的大”)当且仅当存在正常量c和n0,使当n≥n0时,f(n)≥cg(n),即g(n)为f(n)的下界
。
-
下界的阶越高,结果就越有价值
-
一个算法的时间用大符号表示时,总是采用最有价值的g(n)表示,称之为“紧凑下界”或“紧确下界”。
(大符号)f(n)= (g(n))(读作“f(n)是g(n)的大”)当且仅当存在正常量c1、c2和n0,使当n≥n0时,有c1g(n)≤f(n)≤c2g(n),即g(n)与f(n)的同阶
。
- 大符号比大O符号和大符号都精确,f(n)=(g(n),当且仅当g(n)既是f(n)的上界又是f(n)的下界。
空间复杂度分析
一个算法的存储量包括形参所占空间和临时变量所占空间。在对算法进行存储空间分析时,只考察临时变量
所占空间。
三个渐进符号的定义与时间复杂度中的类似
递归
在定义一个过程或函数时出现调用本过程或本函数的成分,称之为递归。若调用自身,称之为直接递归
。若过程或函数p调用过程或函数q,而q又调用p,称之为间接递归
。
任何间接递归都可以等价地转换为直接递归。
如果一个递归过程或递归函数中递归调用语句是最后一条执行语句,则称这种递归调用为尾递归
。
一般来说,能够用递归解决的问题应该满足以下三个条件:
- 需要解决的问题可以转化为一个或多个子问题来求解,而这些子问题的求解方法与原问题完全相同,只是在数量规模上不同。
- 递归调用的次数必须是有限的。
- 必须有结束递归的条件来终止递归。
递归算法的执行时间
可以用递归形式(即递归式)来表示,递归式也称为递归方程,这使得求解递归方程
对算法分析来说即为重要
用主方法求解递归方程
主方法
(master method)提供了解如下形式递归方程的一般方法:
T
(
n
)
=
a
T
(
n
/
b
)
+
f
(
n
)
T(n)=aT(n/b)+f(n)
T(n)=aT(n/b)+f(n)
其中a≥1,b>1为常数,该方程描述了算法的执行时间,算法将规模为n的问题分解成a个子问题,每个子问题的大小为n/b。
例如,对于递归方程T(n)=3T(n/4)+n2,有:a=3,b=4,f(n)=n2。
应用该定理的过程是,首先把函数f(n)与函数进行比较,递归方程的解由这两个函数中较大的一个决定:
情况(1),函数n(logb(a))比函数f(n)更大,则T(n)=O(n(logb(a)))。
情况(2),函数n(logb(a))和函数f(n)一样大,则T(n)=O(n(logb(a))log₂n)。
情况(3),函数n^(logb(a))比函数f(n)小,则T(n)=O(f(n))。
【例2.17】分析以下递归方程的时间复杂度:
T(n)=1 当n=1
T(n)=4T(n/2)+n 当n>1
解:这里a=4,b=2,f(n)=n。
因此,n(log₂4)=n2,比f(n)大,满足情况(1),
所以T(n)=O(n^(logb(a))) = O(n^2)。
分治法
设计思想
对于一个规模为n的问题:若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。
求解过程
① 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题。
② 求解子问题:若子问题规模较小而容易被解决则直接求解,否则递归地求解各个子问题。
③ 合并:将各个子问题的解合并为原问题的解。
一般的算法设计框架
divide-and-conquer(P)
{ if |P|≤n0 return adhoc(P); //问题规模较小就直接解决,否则进行分解
将P分解为较小的子问题 P1,P2,…,Pk;
for(i=1;i<=k;i++) //循环处理k次
yi=divide-and-conquer(Pi); //递归解决Pi
return merge(y1,y2,…,yk); //合并子问题
}
人们从大量实践中发现,在用分治法设计算法时,最好使子问题的规模大致相同。
当k=1时称为减治法
,许多问题可以取 k=2,称为二分法
。
求解循环日程安排问题
【问题描述】设有n=2^k个选手要进行网球循环赛,要求设计一个满足以下要求的比赛日程表:
(1)每个选手必须与其他n-1个选手各赛一次。
(2)每个选手一天只能赛一次。
(3)循环赛在n-1天之内结束。
【问题求解】按问题要求可将比赛日程表设计成一个n行n-1列的二维表,其中第i行、第j列表示和第i个选手在第j天比赛的选手。
假设n位选手被顺序编号为1、2、…、n(2^k)。
【问题推导】
由k=1创建k=2的过程
由k=2创建k=3的过程
将n=2^k问题划分为4部分:
(1)左上角:左上角为2(k-1)个选手在前半程的比赛日程(k=1时直接给出,否则,上一轮求出的就是2(k-1)个选手的比赛日程)。
(2)左下角:左下角为另2(k-1)个选手在前半程的比赛日程,由左上角加2(k-1)得到,例如22个选手比赛,左下角由左上角直接加2(即2(2-1))得到,23个选手比赛,左下角由左上角直接加4(即2(3-1))得到。
(3)右上角:将左下角直接复制到右上角得到另2^(k-1)个选手在后半程的比赛日程。
(4)右下角:将左上角直接复制到右下角得到2^(k-1)个选手在后半程的比赛日程。
#include <stdio.h>
#define MAX 101
//问题表示
int k; //求解结果表示
int a[MAX][MAX]; //存放比赛日程表(行列下标为0的元素不用)
void Plan(int k)
{ int i,j,n,t,temp;
n=2; //n从2^1=2开始
a[1][1]=1; a[1][2]=2; //求解2个选手比赛日程,得到左上角元素
a[2][1]=2; a[2][2]=1;
for (t=1;t<k;t++) //迭代处理2^2(t=1)…,2^k(t=k-1)个选手
{ temp=n; //temp=2^t
n=n*2; //n=2^(t+1)
for (i=temp+1;i<=n;i++ ) //填左下角元素
for (j=1; j<=temp; j++)
a[i][j]=a[i-temp][j]+temp; //产生左下角元素
for (i=1; i<=temp; i++) //填右上角元素
for (j=temp+1; j<=n; j++)
a[i][j]=a[i+temp][(j+temp)% n];
for (i=temp+1; i<=n; i++) //填右下角元素
for (j=temp+1; j<=n; j++)
a[i][j]=a[i-temp][j-temp];
}
}
求解最大连续子序列和问题
【问题描述】给定一个有n(n≥1)个整数的序列,要求求出其中最大连续子序列的和。
例如:
序列(-2,11,-4,13,-5,-2)的最大子序列和为20
序列(-6,2,4,-7,5,3,2,-1,6,-9,10,-2)的最大子序列和为16。
规定一个序列最大连续子序列和至少是0(长度为0的子序列),如果小于0,其结果为0。
【问题求解】对于含有n个整数的序列a[0..n-1]
,若n=1,表示该序列仅含一个元素,如果该元素大于0,则返回该元素;否则返回0。
若n>1,采用分治法求解最大连续子序列时,取其中间位置mid=(n-1)/2(向下取整)
,该子序列只可能出现3个地方。
(1)该子序列完全落在左半部即a[0..mid]
中。采用递归求出其最大连续子序列和maxLeftSum。
(2)该子序列完全落在右半部即a[mid+1..n-1]
中。采用递归求出其最大连续子序列和maxRightSum。
(3)该子序列跨越序列a的中部而占据左右两部分。由左右最大延伸和相加得到
结果:max3( maxLeftSum,
maxRightSum,
maxLeftBorderSum+maxRightBorderSum )
# include <stdio.h>
long max3(long a,long b,long c)
{
if (a<b) a=b;
if (a>c) return a;
else return c;
}
long maxSubSum(int a[],int left,int right)
//求a[left..high]序列中最大连续子序列和
{ int i,j;
long maxLeftSum,maxRightSum;
long maxLeftBorderSum,leftBorderSum;
long maxRightBorderSum,rightBorderSum;
if (left==right) //子序列只有一个元素时
{ if (a[left]>0) //该元素大于0时返回它
return a[left];
else //该元素小于或等于0时返回0
return 0;
}
int mid=(left+right)/2; //求中间位置
maxLeftSum=maxSubSum(a,left,mid); //求左边
maxRightSum=maxSubSum(a,mid+1,right); //求右边
maxLeftBorderSum=0,leftBorderSum=0;
for (i=mid;i>=left;i--) //求出以左边加上a[mid]元素
{ leftBorderSum+=a[i]; //构成的序列的最大和
if (leftBorderSum>maxLeftBorderSum)
maxLeftBorderSum=leftBorderSum;
}
maxRightBorderSum=0,rightBorderSum=0;
for (j=mid+1;j<=right;j++) //求出a[mid]右边元素
{ rightBorderSum+=a[j]; //构成的序列的最大和
if (rightBorderSum>maxRightBorderSum)
maxRightBorderSum=rightBorderSum;
}
return max3(maxLeftSum,maxRightSum,
maxLeftBorderSum+maxRightBorderSum);
}
执行时间: T ( n ) = O ( n l o g 2 n ) 执行时间:T(n)=O(nlog₂n) 执行时间:T(n)=O(nlog2n)
动态规划算法
设计思想
动态规划是一种解决多阶段决策问题的优化方法,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。
动态规划与其他方法的比较
动态规划
的基本思想与分治法
类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。
在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
动态规划
方法又和贪心法
有些相似,在动态规划中,可将一个问题的解决方案视为一系列决策的结果。
不同的是,在贪心法中,每采用一次贪心准则便做出一个不可回溯的决策,还要考察每个最优决策序列中是否包含一个最优子序列。
求解最大连续子序列和问题
【问题求解】对于含有n个整数的序列a,设
b
j
=
M
A
X
a
i
+
a
i
+
1
+
…
+
a
j
(
1
≤
j
≤
n
)
bj=MAX{ai+ai+1+…+aj} (1≤j≤n)
bj=MAXai+ai+1+…+aj(1≤j≤n)
表示a[1…j]的前j个元素中的最大连续子序列和,则bj-1表示a[1…j-1]的前j-1个元素中的最大连续子序列和。
当bj-1>0时,bj=bj-1+aj,当bj-1≤0时,放弃前面选取的元素,从aj开始重新选起,bj=aj。用一维动态规划数组dp存放b,对应的状态转移方程如下:
dp[0]=0 边界条件
dp[j]=MAX{dp[j-1] +aj,aj} 1≤j≤n
则序列a的最大连续子序列和等于dp[j](1≤j≤n)中的最大者
算法如下:
//问题表示
int n=6;
int a[]={0,-2,11,-4,13,-5,-2}; //a数组不用下标为0的元素
//求解结果表示
int dp[MAXN];
void maxSubSum() //求dp数组
{ dp[0]=0;
for (int j=1;j<=n;j++)
dp[j]=max(dp[j-1]+a[j],a[j]);
}
void dispmaxSum() //输出结果
{ int maxj=1;
for (int j=2;j<=n;j++) //求dp中最大元素dp[maxj]
if (dp[j]>dp[maxj]) maxj=j;
for (int k=maxj;k>=1;k--) //找前一个值小于等于0者
if (dp[k]<=0) break;
printf(" 最大连续子序列和: %d\n",dp[maxj]);
printf(" 所选子序列: ");
for (int i=k+1;i<=maxj;i++)
printf("%d ",a[i]);
printf("\n");
}
【算法分析】maxSubSum()的
时间复杂度为O(n)
回溯法
设计思想
是一个类似穷举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时就“回溯”(即回退),尝试其它路径,所以回溯法有“通用的解题法”之称。
问题的解空间
一个复杂问题的解决方案是由若干个小的决策步骤组成的决策序列,解决一个问题的所有可能的决策序列构成该问题的解空间
。
应用回溯法求解问题时,首先应该明确问题的解空间。解空间中满足约束条件的决策序列称为
可行解
。一般来说,解任何问题都有一个目标,在约束条件下使目标达到最优的可行解称为该问题的
最优解
。
问题的解由一个不等长或等长的解向量X={x1,x2,…,xn}组成,其中分量xi表示第i步的操作。
所有满足约束条件的解向量组构成了问题的解空间
。
问题的解空间一般用树形式来组织,也称为解空间树
或状态空间
,树中的每一个结点确定所求解问题的一个问题状态。
树的根结点位于第1层,表示搜索的初始状态,第2层的结点表示对解向量的第一个分量做出选择后到达的状态,以此类推。
例如,以下求集合{a,b,c}的幂集的解空间树:
求解过程分为3步,分别对a、b、c元素做决策,该解空间的每个叶子结点都构成一个解。
求解问题类型
- 找所有解
- 找最优解
解空间树通常有两种类型
-
子集树:当所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树。
-
排列树:当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。
什么是回溯法
在包含问题的所有解的解空间树中,按照深度优先搜索
的策略,从根结点(开始结点)出发搜索解空间树。
当从状态si搜索到状态si+1后,如果si+1变为死结点,则从状态si+1回退到si,再从si找其他可能的路径,所以回溯法体现出走不通就退回再走的思路。
剪枝函数
避免无效搜索,提高回溯的搜索效率
用
约束函数
在扩展结点处剪除不满足约束的子树;
用限界函数
剪去得不到问题解或最优解的子树。
用回溯法解题的一般步骤
- 确定问题的解空间树,问题的解空间树应至少包含问题的一个(最优)解。
- 确定结点的扩展规则。
- 以
深度优先方式
搜索解空间树,并在搜索过程中可以采用剪枝函数来避免无效搜索。
结论:回溯法 = 深度优先搜索 + 剪枝
结点
活结点:指自身已生成但其孩子结点没有全部生成的结点
扩展结点:指正在产生孩子结点的结点
死结点:指由根结点到该结点构成的部分解不满足约束条件,或者其子结点已经搜索完毕
当当前扩展结点成为死结点时,此时应往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点
求解n皇后问题
【问题描述】在n×n的方格棋盘上,放置n个皇后,要求每个皇后不同行、不同列、不同左右对角线。如下图所示是6皇后问题的一个解。
q [ 1..6 ] = 2 , 4 , 6 , 1 , 3 , 5 q[1..6]={2,4,6,1,3,5} q[1..6]=2,4,6,1,3,5
第2章采用递归技术求解,这里采用回溯法求解。实际上,2.3.2小节的递归算法对应的就是
回溯法的递归框架
,这里讨论采用非递归框架
求解皇后问题。
求解步骤图及其描述查看书P191
非递归回溯算法对应的算法:
void Queens(int n) //求解n皇后问题
{ int i=1; //i表示当前行,也表示放置第i个皇后
q[i]=0; //q[i]是当前列,每个新考虑的皇后初始位置置为0列
while (i>=1) //尚未回溯到头,循环
{ q[i]++; //原位置后移动一列
while (q[i]<=n && !place(i)) //试探一个位置(i,q[i])
q[i]++;
if (q[i]<=n) //为第i个皇后找到了一个合适位置(i,q[i])
{ if (i==n) //若放置了所有皇后,输出一个解
dispasolution(n);
else //皇后没有放置完
{ i++; //转向下一行,即开始下一个新皇后的放置
q[i]=0; //每个新考虑的皇后初始位置置为0列
}
}
else i--; //若第i个皇后找不到合适的位置,则回溯到上一个皇后
}
}
bool place(int i) //测试第i行的q[i]列上能否摆放皇后
{ int j=1;
if (i==1) return true;
while (j<i) //j=1~i-1是已放置了皇后的行
{ if ((q[j]==q[i]) || (abs(q[j]-q[i])==abs(j-i)))
//该皇后是否与以前皇后同列,位置(j,q[j])与(i,q[i])是否同对角线
return false;
j++;
}
return true;
}
分枝限界法
设计思想
分枝限界法类似于回溯法,也是一种在问题的解空间树上搜索问题解的算法。
但在一般情况下,分枝限界法与回溯法的求解目标不同。回溯法的求解目标是找出解空间树中满足约束条件的所有解,
而分枝限界法的求解目标则是找出满足约束条件的一个
解,或是在满足约束条件的解中找出使某一目标函数值达到极大或极小的解,即在某种意义下的最优解
。
所谓“分枝”就是采用
广度优先
的策略,依次搜索活结点的所有分枝,也就是所有相邻结点。
求最优解时,选择哪一个子结点?
- 采用一个
限界函数
,计算限界函数值,选择一个最有利的子结点作为扩展结点,使搜索朝着解空间树上有最优解的分枝推进,以便尽快地找出一个最优解。
设计合适的限界函数
限界函数设计难以找出通用的方法,需根据具体问题来分析。一般地,先要确定问题解的特性:
- 目标函数是求最大值:则设计上界限界函数ub(根结点的ub值通常大于或等于最优解的ub值),若si是sj的双亲结点,应满足ub(si)≥ub(sj),当找到一个可行解ub(sk)后,将所有小于ub(sk)的结点剪枝。
- 目标函数是求最小值:则设计下界限界函数lb(根结点的lb值一定要小于或等于最优解的lb值),若si是sj的双亲结点,应满足lb(si)≤lb(sj),当找到一个可行解lb(sk)后,将所有大于lb(sk)的结点剪枝。
组织活结点表
根据选择下一个扩展结点的方式来组织活结点表,不同的活结点表对应不同的分枝搜索方式。
- **队列式分枝限界法:**队列式分枝限界法将活结点表组织成一个队列,并按照队列
先进先出(FIFO)原则
选取下一个结点为扩展结点。步骤如下:
- 将根结点加入活结点队列。
- 从活结点队中取出队头结点,作为当前扩展结点。
- 对当前扩展结点,先从左到右地产生它的所有孩子结点,用约束条件检查,把所有满足约束条件的孩子结点加入活结点队列。
- 重复步骤②和③,直到找到一个解或活结点队列为空为止。
- **优先队列式分枝限界法:**优先队列式分枝限界法的主要特点是将活结点表组组成一个优先队列,并
选取优先级最高的活结点成为当前扩展结点
。步骤如下:
- 计算起始结点(根结点)的优先级并加入优先队列(与特定问题相关的信息的函数值决定优先级)。
- 从优先队列中
取出优先级最高的结点作为当前扩展结点
,使搜索朝着解空间树上可能有最优解的分枝推进,以便尽快地找出一个最优解。 - 对当前扩展结点,先从左到右地产生它的所有孩子结点,然后用约束条件检查,对所有满足约束条件的孩子结点计算优先级并加入优先队列。
- 重复步骤2和3,直到找到一个解或优先队列为空为止。
确定最优解的解向量方法
① 对每个扩展结点保存从根结点到该结点的路径。
② 在搜索过程中构建搜索经过的树结构。
分枝限界法的时间性能
在最坏情况下,时间复杂性是指数阶。
分枝限界法与回溯法的主要区别
方法 | 解空间搜索方式 | 存储结点的数据结构 | 结点存储特性 | 常用应用 |
---|---|---|---|---|
回溯法 | 深度优先 | 栈 | 活结点的所有可行子结点被遍历后才从栈中出栈 | 找出满足条件的所有解 |
分枝限界法 | 广度优先 | 队列,优先队列 | 每个结点只有一次成为活结点的机会 | 找出满足条件一个解或者特定意义的最优解 |
用优先队列式求解0/1背包问题
【问题描述】有n个重量分别为{w1,w2,…,wn}的物品,它们的价值分别为{v1,v2,…,vn},给定一个容量为W的背包。
设计从这些物品中选取一部分物品放入该背包的方案,每个物品要么选中要么不选中,要求选中的物品不仅能够放到背包中,而且重量和为W具有最大的价值。
假设一个0/1背包问题是,n=3,重量为w=(16,15,15),价值为v=(45,25,25),背包限重为W=30,解向量为x=(x1,x2,x3)。
编号 | 1 | 2 | 3 |
---|---|---|---|
重量 | 16 | 15 | 15 |
价值 | 45 | 25 | 25 |
采用优先队列式分枝限界法求解就是将一般的队列改为优先队列,但必须设计限界函数,因为优先级是以限界函数值为基础的。
限界函数的设计方法与前面的相同。这里用大根堆表示活结点表,取优先级为活结点所获得的价值。
与非优先队列式的区别在于设计NodeType结构体的比较重载函数:
bool operator<(const NodeType &s) const //重载<关系函数
{
return ub<s.ub; //ub越大越优先出队
}
算法如下:
struct NodeType //队列中的结点类型
{ int no; //结点编号
int i; //当前结点在搜索空间中的层次
int w; //当前结点的总重量
int v; //当前结点的总价值
int x[MAXN]; //当前结点包含的解向量
double ub; //上界
bool operator<(const NodeType &s) const //重载<关系函数
{
return ub<s.ub; //ub越大越优先出队
}
};
void bound(NodeType &e) //计算分枝结点e的上界
{ int i=e.i+1; //考虑结点e的余下物品
int sumw=e.w; //求已装入的总重量
double sumv=e.v; //求已装入的总价值
while ((sumw+w[i]<=W) && i<=n)
{ sumw+=w[i]; //计算背包已装入载重
sumv+=v[i]; //计算背包已装入价值
i++;
}
if (i<=n) //余下物品只能部分装入
e.ub=sumv+(W-sumw)*v[i]/w[i];
else //余下物品全部可以装入
e.ub=sumv;
}
void EnQueue(NodeType e,queue<NodeType> &qu)
//结点e进队qu
{ if (e.i==n) //到达叶子结点
{ if (e.v>maxv) //找到更大价值的解
{ maxv=e.v;
for (int j=1;j<=n;j++)
bestx[j]=e.x[j];
}
}
else qu.push(e); //非叶子结点进队
}
void bfs() //求0/1背包的最优解
{ int j;
NodeType e,e1,e2; //定义3个结点
priority_queue<NodeType> qu; //定义一个优先队列(大根堆)
e.i=0; //根结点置初值,其层次计为0
e.w=0; e.v=0;
e.no=total++;
for (j=1;j<=n;j++)
e.x[j]=0;
bound(e); //求根结点的上界
qu.push(e); //根结点进队
while (!qu.empty()) //队不空循环
{ e=qu.top(); qu.pop(); //出队结点e
if (e.w+w[e.i+1]<=W) //剪枝:检查左孩子结点
{ e1.no=total++;
e1.i=e.i+1; //建立左孩子结点
e1.w=e.w+w[e1.i];
e1.v=e.v+v[e1.i];
for (j=1;j<=n;j++) e1.x[j]=e.x[j]; //复制解向量
e1.x[e1.i]=1;
bound(e1); //求左孩子结点的上界
EnQueue(e1,qu); //左孩子结点进队操作
}
e2.no=total++; //建立右孩子结点
e2.i=e.i+1;
e2.w=e.w; e2.v=e.v;
for (j=1;j<=n;j++) e2.x[j]=e.x[j]; //复制解向量
e2.x[e2.i]=0;
bound(e2); //求右孩子结点的上界
if (e2.ub>maxv) //若右孩子结点剪枝
EnQueue(e2,qu);
}
}
【算法分析】无论采用队列式分枝限界法还是优先队列式分枝限界法求解0/1背包问题,最坏情况下要搜索整个解空间树,所以
最坏时间和空间复杂度均为O(2^n)
,其中n为物品个数。
贪心算法
设计思想
贪心法的基本思路是在对问题求解时总是做出在当前看来是最好的选择,也就是说贪心法不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。
人们通常希望找到整体最优解,所以采用贪心法需要证明
设计的算法确实是整体最优解或求解了它要解决的问题。
每一步用作决策依据的选择准则被称为最优量度标准
(或贪心准则
)
贪心法求解的问题应具有的性质
贪心选择性质:指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。
最优子结构性质:指一个问题的最优解包含其子问题的最优解
求解流水作业调度问题
【问题描述】有n个作业(编号为1~n)要在由两台机器M1和M2组成的流水线上完成加工。每个作业加工的顺序都是先在M1上加工,然后在M2上加工。M1和M2加工作业i所需的时间分别为ai和bi(1≤i≤n)。
流水作业调度问题要求确定这n个作业的最优加工顺序,使得从第一个作业在机器M1上开始加工,到最后一个作业在机器M2上加工完成所需的时间最少。可以假定任何作业一旦开始加工,就不允许被中断,直到该作业被完成,即非优先调度
。
【问题求解】采用归纳思路。当只有一个作业(a1,b1),显然最少时间Tmin=a1+b1。
当有两个作业(a1,b1)和(a2,b2)时,可以得到一个贪心的性质:
对于(a1,b1)和(a2,b2)中的元素
若min(a1,b2)≤min(a2,b1),则(a1,b1)放在(a2,b2)前面;
反之,若min(a2,b1)≥min(a1,b2) (a2,b2)放在(a1,b1)前面。
即,让(a,b)中a比较小的尽可能先执行,(a,b)中b比较小的尽可能后执行!
Johnson贪心算法。其步骤如下:
(1)把所有作业按M1、M2的时间分为两组,a[i]≤b[i]对应第1组N1,a[i]>b[i]对应第0组N2。
(2)将N1的作业按a[i]递增排序,N2的作业按b[i]递减排序。(实际上,N2的作业也按b[i]递增排序,从后面向前面顺序执行)
(3)按顺序先执行N1的作业,再执行N2的作业,得到的就是耗时最少的最优调度方案。
示例:n=4
编号 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
M1 | 5 | 12 | 4 | 8 |
M2 | 6 | 2 | 14 | 7 |
(1)把所有作业按M1、M2的时间分为两组,a[i]≤b[i]对应第1组N1,a[i]>b[i]对应第0组N2。
组号 | 1 | 0 | 1 | 0 |
---|---|---|---|---|
时间time | 5 | 2 | 4 | 7 |
(2)将N1(组号=1, a[i]≤b[i] )的作业按a[i](用时间time存放)递增排序,N2 (组号=1, a[i]>b[i] )的作业按b[i] (用时间time存放)递增排序。
排序前:
编号 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
M1 | 5 | 12 | 4 | 8 |
M2 | 6 | 2 | 14 | 7 |
组号 | 1 | 0 | 1 | 0 |
时间time | 5 | 2 | 4 | 7 |
排序后:
编号 | 2 | 3 | 1 | 4 |
---|---|---|---|---|
M1 | 12 | 4 | 5 | 8 |
M2 | 2 | 14 | 6 | 7 |
组号 | 0 | 1 | 1 | 0 |
时间time | 2 | 4 | 5 | 7 |
(3)按顺序先执行N1的作业(顺序),再执行N2的作业(反序),得到的就是耗时最少的最优调度方案。
排序后:
编号 | 2 | 3 | 1 | 4 |
---|---|---|---|---|
M1 | 12 | 4 | 5 | 8 |
M2 | 2 | 14 | 6 | 7 |
组号 | 0 | 1 | 1 | 0 |
时间time | 2 | 4 | 5 | 7 |
最优调度方案:3 1 4 2
求在最优调度下总时间,用f1累计M1上的执行时间(初始时f1=0);f2累计M2上的执行时间(初始时f2=0),最终f2即为最优调度下的消耗总时间。
对于最优调度方案best,用i扫描best的元素,f1和f2的计算如下:
f1=f1+a[best[i]]
f2=max{f1,f2}+b[best[i]]
即:
f1=f2=0
作业3:f1=0+4=4,f2=max{4,0}+14=18
作业1:f1=4+5=9,f2=max{9,18}+6=24
作业4:f1=9+8=17,f2=max{17,24}+7=31
作业2:f1=17+12=29,f2=max{29,31}+2=33
算法如下:
//问题表示
int n=4;
int a[N]={5,12,4,8}; //对应M1的时间
int b[N]={6,2,14,7}; //对应M2的时间
struct NodeType
{ int no; //作业序号
bool group; //1代表第一组N1,0代表第二组N2
int time; //a,b的最小时间
bool operator<(const NodeType &s) const
{
return time<s.time; //按time递增排序
}
};
//求解结果表示
int best[N]; //最优调度序列
int solve() //求解流水作业调度问题
{ int i,j,k;
NodeType c[N];
for(i=0;i<n;i++) //n个作业中,求出每个作业的最小加工时间
{ c[i].no=i;
c[i].group=(a[i]<=b[i]);
//a[i]<=b[i]对应第1组N1,a[i]>b[i]对应第0组N2
c[i].time=a[i]<=b[i]?a[i]:b[i];
//第1组存放a[i],第0组存放b[i]
}
sort(c,c+n); //c元素按time递增排序
j=0; k=n-1;
for(i=0;i<n;i++) //扫描c所有元素,产生最优调度方案
{ if(c[i].group==1) //第1组,按time递增排列放在best的前面部分
best[j++]=c[i].no;
else //第0组,按time递减排列放到best的后面部分
best[k--]=c[i].no;
}
int f1=0; //累计M1上的执行时间
int f2=0; //最优调度下的消耗总时间
for(i=0;i<n;i++)
{ f1+=a[best[i]];
f2=max(f2,f1)+b[best[i]];
}
return f2;
}
void main()
{ printf("求解结果\n");
printf(" 总时间: %d\n",solve());
printf(" 调度方案: ");
for(int i=0;i<n;i++)
printf("%d ",best[i]+1);
printf("\n");
}
【算法分析】算法的主要时间花费在排序上,所以
时间复杂度为O(nlog2n)
。比采用回溯法和分枝限界法求解更高效。