【数据结构和算法设计】算法篇

【数据结构和算法设计】算法篇,第1张

文章目录
  • 7.1 贪心法概述
    • 7.1.1 什么是贪心法
    • 7.1.2 用贪心法求解的问题应具有的性质
      • 1. 贪心选择性质
      • 2. 最优子结构性质
    • 7.1.3 贪心法的一般求解过程
  • 7.2 求解活动安排问题
  • 7.3 求解背包问题
  • 7.4 求解田忌赛马问题
  • 7.5 哈夫曼编码
  • 7.6 求解流水作业调度问题
  • 7.7 求解最优装载问题
  • 7.8 求解多机调度问题
  • 其他题目

贪心Greedy Algorithm 是一种典型的算法设计策略,用于求解问题的最优解,这里介绍用贪心法求解问题的一般方法(尤其是贪心证明),并讨论一些采用贪心法求解的经典示例。


7.1 贪心法概述 7.1.1 什么是贪心法

贪心法的基本思路是,在对问题求解时、总是做出在当前看来是最好的选择,这个局部最优选择仅依赖以前的决策,不依赖于以后的决策——也就是说贪心法不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。

人们通常希望找到全局最优解,那么贪心法是不是没有价值呢?答案是否定的,这是因为在某些求解问题中,当满足一定的条件时,这些局部最优解就转变成了全局最优解,所以贪心法的困难部分,就是要证明所设计的算法确实是整体最优解、或求解了它要解决的问题。

在求解问题时,这些问题通常直接给出、或者可以分析出某些「约束条件」,满足约束条件的问题解称为可行解。

另外,从要求解的问题直接给出、或者可以分析出「衡量可行解好坏的目标函数」,使目标函数取最大(或最小)值的可行解,称为最优解。

例如,求解一个带权无向图 G G G 中从顶点 i i i 到顶点 j   ( i ≠ j ) j\ (i \ne j) j (i=j) 的最短路径,可以分析出:这样的最短路径一定是简单路径,所以:

  • 约束条件:求解的路径为 { ( i , i 1 ) ,   ( i 1 , i 2 ) ,   … ,   ( i m , j ) } \{ (i, i_1),\ (i_1, i_2),\ \dots,\ (i_{m}, j) \} {(i,i1), (i1,i2), , (im,j)} ,其中 ( i , i 1 ) ,   ( i 1 , i 2 ) ,   … ,   ( i m , j ) (i, i_1),\ (i_1, i_2),\ \dots,\ (i_m, j) (i,i1), (i1,i2), , (im,j) 均为图 G G G 的边,且 i k   ( 1 ≤ k ≤ m ) i_k\ (1 \le k \le m) ik (1km) 均不相同。

  • 目标函数就是要使这样的路径最短,即 min ⁡ p a t h l e n g t h { ( i , i 1 ) ,   ( i 1 , i 2 ) ,   … ,   ( i m , j ) } \min_{pathlength} \{(i, i_1),\ (i_1, i_2),\ \dots,\ (i_{m}, j) \} minpathlength{(i,i1), (i1,i2), , (im,j)} ,其中 p a t h l e n g t h = w ( i , i 1 ) + w ( i 1 , i 2 ) + ⋯ + w ( i m , j ) pathlength = w(i, i_1) + w(i_1, i_2) + \dots + w(i_m, j) pathlength=w(i,i1)+w(i1,i2)++w(im,j) w ( i , k ) w(i, k) w(i,k) 表示边 ( i , k ) (i, k) (i,k) 的权值。

贪心法从问题的某一个初始空解出发,采取「逐步构造最优解」的方法、向给定的目标前进,每一步决策产生 n n n 元组解 ( x 0 , x 1 , … , x n − 1 ) (x_0, x_1, \dots , x_{n-1}) (x0,x1,,xn1) 的一个分量。

每一步用作决策依据的选择准则,被称为最优度量标准或贪心准则,也就是说(?),在选择解向量的过程中,添加新的解分量 x k x_k xk 后、形成的部分解 ( x 0 , x 1 , … , x k ) (x_0, x_1, \dots, x_k) (x0,x1,,xk) 不违反可行解约束条件。

每一次贪心选择都将所求问题简化为规模更小的子问题,并期望通过「每次所做的局部最优选择」产生出一个全局最优解

例如前面的求最短路径问题,初始解为空,然后一步一步地添加最短路径的边,直到产生最短路径 ( i , i 1 ) ,   ( i 1 , i 2 ) ,   … ,   ( i m , j ) } (i, i_1),\ (i_1, i_2),\ \dots,\ (i_{m}, j) \} (i,i1), (i1,i2), , (im,j)}

计算机科学中的很多算法都属于贪心法。

例如,在 *** 作系统的磁盘管理中,有一个磁盘移臂调度问题,进程在执行时多次访问磁盘,按访问的先后次序构成一个I/O序列,I/O *** 作的数据存放在磁盘的各个柱面上,磁盘臂通过在这些柱面之间移动磁头、找到相关数据。

移动磁盘臂是要花费时间的,磁盘移臂调度的目的是使平均访问时间最小。

例如,某个磁盘访问序列为 98 , 183 , 37 , 122 , 14 , 124 , 65 , 67 98, 183, 37, 122, 14, 124, 65, 67 98,183,37,122,14,124,65,67 ,开始时磁头位于 53 53 53 柱面上。

磁盘移臂调度有多种算法,这里以先来先服务和最短寻道时间优先算法为例、进行讲解。

  • 先来先服务算法是按I/O请求的先后次序执行,而不考虑它们要访问的物理位置。

    它的执行过程,是将磁头从 53 53 53 移到 98 98 98 ,接着再移到 183 , 37 , 122 , 14 , 124 , 65 183, 37, 122, 14, 124, 65 183,37,122,14,124,65 ,最后移到 67 67 67 ,其过程如图7.1所示。

    总的磁头移动为 640 = 45 + 85 + 146 + 85 + 108 + 110 + 59 + 2 640 = 45 +85+146+85+108+110+59+2 640=45+85+146+85+108+110+59+2


  • 最短寻道时间优先算法,让距离当前磁道最近的I/O请求先执行,即让移动磁头时间最短的那个I/O请求先执行、而不考虑I/O请求的先后次序。

    这一算法的执行过程是:与开始磁头位置 53 53 53 最近的请求位于柱面 65 65 65 ,先执行位于柱面 65 65 65 的请求,将磁头移动到该位置,当位于柱面 65 65 65 时,下一个最近请求位于柱面 67 67 67 ,执行位于柱面 67 67 67 的请求,将磁头移动到该位置,如图7.2所示。

    总的磁头移动为 236 = 12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 236 = 12 + 2 +30+23+84+24+2+59 236=12+2+30+23+84+24+2+59 个柱面,平均寻道长度为 236 / 8 = 29.5 236/8 = 29.5 236/8=29.5


不考虑其他因素,从中可以看到,最短寻道时间优先算法好于先来先服务算法。

实际上,最短寻道时间优先就是采用贪心法的思想。

这种思想在 *** 作系统算法设计中,多次体现出来。

在很多情况下,所有局部最优解合起来不一定构成整体最优解,所以贪心法不能保证对所有问题都得到整体最优解。

因此,采用贪心法求解最优解问题时必须证明,该算法的每一步上做出的选择、都必然最终导致问题的一个整体最优解。

对于许多问题,如背包问题、单源最短路径问题、最小生成树问题等,贪心法确实能产生整体最优解。

而在有些情况下,即使用贪心法不能得到整体最优解,其最终结果却与最优解很近似。

另外,贪心与递归(算法设计思想、而非实际编码过程)不同的是,推进的每一步不是依据某一固定的递归式,而是做一个当时看似最佳的贪心选择,不断地将问题实例归纳为更小的相似子问题。

7.1.2 用贪心法求解的问题应具有的性质

由于贪心法一般不会测试所有可能路径,而且容易过早做决定,因此有些问题可能不会找到最优解,能够采用贪心法求解的问题一般具有两个性质——贪心选择性质和最优子结构,所以贪心算法一般需要证明满足这两个性质。

1. 贪心选择性质

这是指所求问题的整体最优解,可以通过一系列局部最优的选择(即贪心选择)来达到。

即,贪心法仅在当前状态下做出最好选择,即局部最优选择,然后再去求解做出这个选择后、产生的相应子问题的解。

它是贪心法可行的第一个基本要素,也是贪心算法和后面介绍的动态规划的主要区别

对一个具体问题,要确定它是否具有贪心选择性质,必须证明每一步所做的贪心选择最终导致问题的整体最优解。

这通常采用数学归纳法证明:先考虑问题的一个整体最优解,并证明可以修改这个最优解,使其从贪心选择开始,在做出贪心选择后、原问题转化为规模较小的类似问题,这样通过每一步的贪心选择,最后可得到问题的整体最优解

2. 最优子结构性质

如果一个问题的最优解包含其子问题的最优解,则称此问题具有最优子结构性质。

问题的最优子结构性质,是该问题可用动态规划算法、或贪心法求解的关键特征

在证明问题是否具有最优子结构性质时,通常采用反证法来证明,先假设「由问题的最优解导出的子问题的解」不是最优的,然后证明在这个假设下,可以构造出比原问题的最优解更好的解,从而导致矛盾

7.1.3 贪心法的一般求解过程

用贪心法求解问题的基本思路如下:

  1. 建立数学模型来描述问题。

  2. 把求解的问题分成若干个子问题。

  3. 对每个子问题求解,得到子问题的局部最优解。

  4. 把子问题的局部最优解,合并成原来解问题的一个解。

用贪心法求解问题的算法框架如下:

SolutionType Greedy(SType a[], int n) {
	// 假设解向量(x0,x1,...,xn-1)类型为SolutionType,其分量为SType类型
	SolutionType x = {};				// 初始时解向量不包含任何分量
	for (int i = 0; i < n; ++i) { 		// 执行n步 *** 作
		SType xi = Select(a);			// 从输入a中选择一个当前最好的分量
		if (Feasiable(xi))				// 判断xi是否包含在当前解中
			solution = Union(x, xi); 	// 把xi分量合并形成x
	}
	return x;							// 返回生成的最优解
}

7.2 求解活动安排问题

活动安排问题描述见5.8节,这里采用贪心法求解该问题。

注意,本问题的最优解是「选取兼容活动最多的个数」。

【问题求解】假设活动时间的参考原点为 0 0 0

一个活动 i   ( 1 ≤ i ≤ n ) i\ (1 \le i\le n) i (1in) 用一个半闭区间 [ b i , e i ) [b_i, e_i) [bi,ei) 表示,当活动按结束时间(右端点)递增排序后,两个活动 [ b i , e i ) [b_i, e_i) [bi,ei) [ b j , e j ) [b_j, e_j) [bj,ej) 兼容(满足 b i ≥ e j b_i \ge e_j biej b j ≥ e i b_j \ge e_i bjei )实际上就是指它们不相交。

用数组 A A A 存放所有的活动, A [ i ] . b   ( 1 ≤ i ≤ n ) A[i].b\ (1\le i \le n) A[i].b (1in) 存放活动起始时间, A [ i ] . e A[i].e A[i].e 存放活动结束时间。

采用贪心法的策略是:每一步总是选择这样一个活动来占用资源,它能够使得余下的未调度的时间最大化,使得兼容的活动尽可能多

为此,先按活动结束时间递增排序,再从头开始、依次选择兼容活动(用 B B B 集合表示),从而得到最大兼容活动子集(包含兼容活动个数最多的子集)。

由于活动按结束时间递增排序,每次总是选择最早完成的兼容活动加入集合 B B B 中,所以选择的兼容活动为未安排的活动留下尽可能多的时间,也就是使得剩余的可安排时间段极大化,以便安排尽可能多的兼容活动。

例如,对于表7.1所示的 n = 11 n = 11 n=11 个活动(已按结束时间递增排序) A = { [ 1 , 4 ) ,   [ 3 , 5 ) ,   [ 0 , 6 ) ,   [ 5 , 7 ) ,   [ 3 , 8 ) ,   [ 5 , 9 ) ,   [ 6 , 10 ) ,   [ 8 , 11 ) ,   [ 8 , 12 ) ,   [ 2 , 13 ) ,   [ 12 , 15 ) } A = \{ [1, 4),\ [3, 5),\ [0, 6),\ [5, 7),\ [3, 8),\ [5, 9),\ [6, 10),\ [8, 11),\ [8, 12),\ [2, 13),\ [12, 15)\} A={[1,4), [3,5), [0,6), [5,7), [3,8), [5,9), [6,10), [8,11), [8,12), [2,13), [12,15)}

设前一个兼容活动的结束时间为 p r e E n d preEnd preEnd(初始时为参考原点 0 0 0 ),求最大兼容活动子集 B B B 的过程如下:

  • i = 1 i = 1 i=1 p r e E n d = 0 preEnd = 0 preEnd=0 ,活动 1   [ 1 , 4 ) 1\ [1, 4) 1 [1,4) 的开始时间大于 0 0 0 ,选择它、 p r e E n d = preEnd = preEnd= 活动 1 1 1 的结束时间 = 4 = 4 =4 B = { [ 1 , 4 ) } B = \{[1, 4)\} B={[1,4)}

  • i = 2 i = 2 i=2 :活动 2   [ 3 , 5 ) 2\ [3, 5) 2 [3,5) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 3 i = 3 i=3 :活动 3   [ 0 , 6 ) 3\ [0, 6) 3 [0,6) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 4 i = 4 i=4 :活动 4   [ 5 , 7 ) 4\ [5, 7) 4 [5,7) 的开始时间大于 p r e E n d preEnd preEnd ,选择它、 p r e E n d = 7 preEnd = 7 preEnd=7 B = { [ 1 , 4 ) ,   [ 5 , 7 ) } B = \{ [1, 4),\ [5, 7)\} B={[1,4), [5,7)}

  • i = 5 i = 5 i=5 :活动 5   [ 3 , 8 ) 5\ [3, 8) 5 [3,8) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 6 i = 6 i=6 :活动 6   [ 5 , 9 ) 6\ [5, 9) 6 [5,9) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 7 i = 7 i=7 :活动 7   [ 6 , 10 ) 7\ [6, 10) 7 [6,10) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 8 i = 8 i=8 :活动 8   [ 8 , 11 ) 8\ [8, 11) 8 [8,11) 的开始时间大于 p r e E n d preEnd preEnd ,选择它、 p r e E n d = 11 preEnd = 11 preEnd=11 B = { [ 1 , 4 ) ,   [ 5 , 7 ) ,   [ 8 , 11 ) } B = \{ [1, 4),\ [5, 7),\ [8, 11)\} B={[1,4), [5,7), [8,11)}

  • i = 9 i = 9 i=9 :活动 9   [ 8 , 12 ) 9\ [8, 12) 9 [8,12) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 10 i = 10 i=10 :活动 10   [ 2 , 13 ) 10\ [2, 13) 10 [2,13) 的开始时间小于 p r e E n d preEnd preEnd ,不选取。

  • i = 11 i = 11 i=11 :活动 11   [ 12 , 15 ) 11\ [12, 15) 11 [12,15) 的开始时间大于 p r e E n d preEnd preEnd ,选择它、 p r e E n d = 15 preEnd = 15 preEnd=15 B = { [ 1 , 4 ) ,   [ 5 , 7 ) ,   [ 8 , 11 ) ,   [ 12 , 15 ) } B= \{ [1, 4),\ [5, 7),\ [8, 11),\ [12, 15)\} B={[1,4), [5,7), [8,11), [12,15)}


    所以最后选择的最大兼容活动子集为 B = { 1 , 4 , 8 , 11 } B = \{1, 4, 8, 11\} B={1,4,8,11}

完整程序如下所示:

#include 
using namespace std;
const int MAXN = 51;

// 问题表示
struct Action {								// 活动的类型表示
	int b, e;								// 活动起始时间、结束时间
	bool operator<(const Action &s) const { // 重载<关系函数
		return e <= s.e;					// 按活动结束时间递增排序
	}
};
int n = 11;
Action A[] = {{0}, {1, 4}, {3, 5}, {0, 6}, {5, 7}, {3, 8}, {5, 9}, 
	{6, 10}, {8, 11}, {8, 12}, {2, 13}, {12, 15}};
// 求解结果表示
bool flag[MAXN];							// 标记选择的活动
int Count = 0;								// 选取的兼容活动个数
void solve() {								// 求解最大兼容活动子集
	memset(flag, 0, sizeof(flag));			// 初始化为false
	sort(A + 1, A + n + 1);					// A[1...n]按活动结束时间递增排序
	int preEnd = 0;							// 前一个兼容活动的结束时间
	for (int i = 1; i <= n; ++i) {			// 扫描所有活动
		if (A[i].b >= preEnd) {				// 找到一个兼容活动
			flag[i] = true;					// 选择A[i]活动
			preEnd = A[i].e;				// 更新preEnd值
		}
	}
}
int main() {
	solve();
	printf("求解结果\n");
	printf("    选取的活动:");	
	for (int i = 1; i <= n; ++i) {
		if (flag[i]) {
			printf("[%d, %d) ", A[i].b, A[i].e);	
			++Count;
		}
	}
	printf("\n共%d个活动\n", Count);
	return 0;
}

上述程序的运行结果如下:

【算法分析】算法的时间主要花费在排序上,排序时间为 O ( n log ⁡ 2 n ) O(n\log_2 n) O(nlog2n) ,所以整个算法的时间复杂度为 O ( n log ⁡ 2 n ) O(n\log_2 n) O(nlog2n)

【算法证明】通常,证明一个贪心选择得出的解是最优解的一般方法是,构造一个初始最优解,然后对该解进行修正,使其第一步为一个贪心选择,证明总是存在一个以贪心选择开始的求解方案

对于本问题,所有活动按结束时间递增排序后,就要证明:若 X X X 是活动安排问题 A A A 的最优解, X = X ′ ∪ { 1 } X = X^{'} \cup \{ 1\} X=X{1} ,则 X ′ X^{'} X A ′ = { i ∈ A ∣ b i ≥ e 1 } A^{'} = \{ i \in A \mid b_i \ge e_1 \} A={iAbie1} 的活动安排问题的最优解。

  • 首先证明总存在一个以活动 1 1 1 开始的最优解。

    假设第一个选中的活动为 k   ( k ≠ 1 ) k\ (k \ne 1) k (k=1) ,可以构造另一个最优解 Y Y Y Y Y Y 中的活动是兼容的, Y Y Y X X X 的活动数相同。

    那么用活动 1 1 1 取代活动 k k k 得到 Y ′ Y' Y ,因为 e 1 ≤ e k e_1 \le e_k e1ek ,所以 Y ′ Y' Y 中的活动是兼容的,即 Y ′ Y' Y 也是最优的,这就说明总存在一个以活动 1 1 1 开始的最优解。

  • 当做出了对活动 1 1 1 的贪心选择后,原问题就变成了「在活动 2 2 2 、……、活动 n n n 中找与活动 1 1 1 兼容的那些活动」的子问题,可见在每次贪心选择后,留下的是「一个与原问题具有相同形式的最优化子问题」。

    此时要证明,如果 X X X 为原问题的一个最优解,则 X ′ = X − { 1 } X' = X - \{ 1\} X=X{1} 也是活动选择问题 A ′ = { i ∈ A ∣ b i ≥ e 1 } A' = \{ i \in A \mid b_i \ge e_1 \} A={iAbie1} 的一个最优解。


    采用反证法,如果能找到一个 A ′ A' A 的、含有比 X ′ X' X 更多活动的解 Y ′ Y' Y ,则将活动 1 1 1 加入 Y ′ Y' Y 后,就得到 A A A 的一个包含比 X X X 更多活动的解 Y Y Y ,这就与 X X X 是最优解的假设矛盾。

    所以不存在一个 A ′ A' A 的、含有比 X ′ X' X 更多活动的解 Y ′ Y' Y ,即 X ′ X' X A ′ A' A 的最优解。

    从而,问题 A A A 的最优解 X X X 包含其子问题 A ′ A' A 的最优解 X ′ X' X ,最优子结构性质得证

【例7.1】求解畜栏保留问题。

农村有 n n n 头牛,每头牛有一个特定的时间区间 [ b , e ] [b, e] [b,e] 在畜栏里挤牛奶,并且一个畜栏里在任何时刻只能有一头牛挤奶。

现在,农场主希望知道最少畜栏能够满足上述要求,并给出每头牛被安排的方案。

对于多种可行方案,输出一种即可。


解:牛的编号为 1 ∼ n 1 \sim n 1n ,每头牛的挤奶时间相当于一个活动,与前面的活动安排问题不同,这里的活动时间是闭区间,例如 [ 2 , 4 ] , [ 4 , 7 ] [2, 4], [4, 7] [2,4],[4,7] 是交叉的,它们不是兼容活动。

采取与求解活动安排问题类似的贪心思路,将所有活动这样排序:结束时间相同就按开始时间递增排序,否则按结束时间递增排序。

求出一个最大兼容活动子集,将他们安排在一个畜栏中(畜栏编号为 1 1 1 );如果没有安排完,在剩余的活动中求下一个最大兼容活动子集,将它们安排在另一个畜栏中(畜栏编号为 2 2 2 ),依次类推。

也就是说,最大兼容活动子集的个数就是最少畜栏个数。

如图7.3所示,由一个活动集合产生三个最大兼容活动子集:

其过程是:用数组 A A A 存放所有活动,先将活动集合按上述方法排序,图中的活动集合是排序后的结果。

同时,建立一个活动标记数组 a n s ans ans a n s [ i ] ans[i] ans[i] 表示编号为 A [ i ] . n o A[i].no A[i].no 的牛安排挤奶的畜栏编号(从 1 1 1 开始), a n s [ i ] = 0 ans[i]=0 ans[i]=0 表示该牛尚未安排畜栏,将所有元素设置为 0 0 0 ,置当前选取的畜栏编号 n u m = 1 num = 1 num=1 ;从第一个活动开始寻找最大兼容活动子集 1 1 1 ,将其中所有活动编号 i i i 对应的 a n s [ i ] ans[i] ans[i] 设置为 n u m ( 1 ) num(1) num(1) n u m = 2 num = 2 num=2 ,在所有 a n s [ i ] = 0 ans[i] = 0 ans[i]=0 的活动集合中,寻找最大兼容活动子集 2 2 2 ,将其中所有活动编号 i i i 对应的 a n s [ i ] ans[i] ans[i] 设置为 n u m ( 2 ) num(2) num(2) ;依次类推,最后找出最大兼容活动子集个数为 3 3 3

对应的完整程序如下:

#include 
using namespace std;
const int MAXN = 51;

// 问题表示
struct Cow {									// 奶牛的类型声明  
	int no;
	int b, e;
	bool operator<(const Cow &s) const {		// 重载<关系函数
		return e != s.e ? e <= s.e : b <= s.b;
	}
};
int n = 5;										// 下标0的元素不用
Cow A[] = {{0}, {1, 1, 10}, {2, 2, 4}, {3, 3, 6}, {4, 5, 8}, {5, 4, 7}};
// 求解结果表示
int ans[MAXN];									// ans[i]表示第A[i].no头牛的畜栏编号
void solve() {									// 求解最大兼容活动子集个数
	sort(A + 1, A + n + 1);						// A[1...n]按指定方式排序
	memset(ans, 0, sizeof(ans));				// 初始化为0
	int num = 1;								// 畜栏编号
	for (int i = 1; i <= n; ++i) {				// i,j均为排序后的下标
		if (ans[i] == 0) {						// 第i头牛还没有安排畜栏
			ans[i] = num;						// 第i头牛安排畜栏num
			int preEnd = A[i].e;				// 前一个兼容活动的结束时间
			for (int j = i + 1; j <= n; ++j) {	// 查看一个最大兼容活动子集
				if (A[j].b > preEnd && ans[j] == 0) {
					ans[j] = num;				// 将兼容活动子集中的活动安排在num畜栏中
					preEnd = A[j].e;			// 更新结束时间
				}
			}
			++num;								// 查找下一个最大兼容活动子集,num加1
		}
	}
}
int main() {
	solve();
	printf("求解结果\n");
	for (int i = 1; i <= n; ++i)
		printf("    牛%d安排的畜栏:%d\n", A[i].no, ans[i]);
	return 0;
}

上述程序的执行结果如下:

【例7.2】求解区间相交问题。

给定 x x x 轴上的 n n n 个闭区间,去掉尽可能少的闭区间,使得剩下的闭区间都不相交。

对于给定的 n n n 个闭区间,计算去掉的最少闭区间数。


输入描述:对于每组输入数据,输入数据的第一行是正整数 n   ( 1 ≤ n ≤ 40000 ) n\ (1 \le n \le 40 000) n (1n40000) ,表示闭区间数;在接下来的 n n n 行中,每行有两个整数,分别表示闭区间的两个端点。


输出描述:计算出的去掉的最少闭区间数。


输入样例:

3
10 20
15 10
20 15

输出样例:

2

解:采用贪心法求出最大兼容子集,兼容子集中的所有闭区间是不相交的。

设其中的闭区间个数为 a n s ans ans ,则删除 n − a n s n - ans nans 个闭区间得到不相交闭区间。

对应的完整程序如下:

#include 
using namespace std;
const int MAXN = 40001;

// 问题表示
int n;
struct NodeType {
	int b, e;									// 区间首部,尾部
	bool operator<(const NodeType &s) const {
		return e != s.e ? e < s.e : b < s.b;	// 按e递增排序
	}
} A[MAXN];
// 求解结果表示
int ans;
void solve() {
	int t;
	for (int i = 0; i < n; ++i) 	
		if (A[i].b > A[i].e) 					// 交换首尾部, 使首部小于尾部
			swap(A[i].b, A[i].e);
	sort(A, A + n);
	int preEnd = A[0].e;
	ans = 1;
	for (int i = 1; i < n; ++i) {
		if (A[i].b > preEnd) {					// A[i]与前一个求解不相交
			++ans;
			preEnd = A[i].e;
		}
	}
	ans = n - ans;
}
int main() {
	while (~scanf("%d", &n)) {
		for (int i = 0; i < n; ++i)
			scanf("%d%d", &A[i].b, &A[i].e);
		solve();
		printf("%d\n", ans);
	}
	return 0;
}

7.3 求解背包问题

【问题描述】设有编号为 1 , 2 , … , n 1, 2, \dots, n 1,2,,n n n n 个物品,它们的重量分别为 w 1 , w 2 , … , w n w_1, w_2, \dots, w_n w1,w2,,wn ,价值分别为 v 1 , v 2 , … , v n v_1, v_2, \dots, v_n v1,v2,,vn ,其中 w i , v i   ( 1 ≤ i ≤ n ) w_i, v_i\ (1\le i \le n) wi,vi (1in) 均为正数。

有一个背包可以携带的最大重量不超过 W W W

求解目标是,在不超过背包负重的前提下,使背包装入的总价值最大(即效益最大化)。

与0/1背包问题的区别是,这里的每个物品可以取一部分装入背包。

【问题求解】这里采用贪心法求解。

x i x_i xi 表示物品 i i i 装入背包的情况, 0 ≤ x i ≤ 1 0 \le x_i \le 1 0xi1

根据问题的要求,有如下约束条件和目标函数:
∑ i = 1 n w i x i ≤ W 0 ≤ x i ≤ 1 , 1 ≤ i ≤ n max ⁡ { ∑ i = 1 n v i x i } \sum^n_{i = 1} w_i x_i \le W\quad 0 \le x_i \le 1, 1 \le i \le n \ \max \bigg \{ \sum^n_{i = 1} v_i x_i \bigg \} i=1nwixiW0xi1,1inmax{i=1nvixi}

于是问题归结为,寻找一个满足上述约束条件、并使目标函数达到最大的解向量 X = ( x 1 , x 2 , … , x n ) X = (x_1, x_2, \dots, x_n) X=(x1,x2,,xn)

例如, n = 3 , W = 20 , ( w 1 , w 2 , w 3 ) = ( 18 , 15 , 10 ) , ( v 1 , v 2 , v 3 ) = ( 25 , 24 , 15 ) n = 3, W = 20, (w_1, w_2, w_3) = (18, 15, 10), (v_1, v_2, v_3) = (25, 24, 15) n=3,W=20,(w1,w2,w3)=(18,15,10),(v1,v2,v3)=(25,24,15) ,其中的四个可行解如下。

在这四个可行解中,第四个解的效益最大,可以求出它是这个输入样例的最优解。



用贪心法求解的关键是如何选定贪心策略,使得按照一定的顺序选择每个物品,并尽可能装入背包,直到背包装满。

至少有三种看似合理的贪心策略。

  1. 选择价值最大的物品,因为这可以尽可能快地增加背包的总价值。

    然而,虽然每一步选择获得了背包价值的极大增长,但背包容量却可能消耗得太快,使得装入背包的物品个数减少,从而不能保证目标函数达到最大。

  2. 选择重量最轻的物品,因为这可以装入尽可能多的物品,从而增加背包的总价值。

    然而,虽然每一步选择使背包的容量消耗得慢了,但背包的价值却没能保证迅速增长,从而不能保证目标函数达到最大。

  3. 选择单位重量下价值最大的物品,在背包价值增长和背包容量消耗之间寻找平衡。

应用第三种贪心策略,每次从物品集合中,选择单位重量下价值最大的物品,如果其重量小于背包容量,就可以把它装入,并将背包容量减去该物品的重量,然后会面临一个最优子问题——它同样是背包问题,只不过背包容量减少了,物品集合减少了。

因此背包问题具有最优子结构性质。

对于表7.2所示的一个背包问题, n = 5 , W = 100 n = 5, W = 100 n=5,W=100 ,其求解过程如下:

  1. 将价值(即 v / w v / w v/w )递减排序,其结果为 ( 66 / 30 , 20 / 10 , 30 / 20 , 60 / 50 , 40 / 40 ) (66/30, 20/10, 30/20, 60/50,40/40) (66/30,20/10,30/20,60/50,40/40) ,物品重新按 1 ∼ 5 1 \sim 5 15 编号。

  2. 设背包余下可装入的重量为 w e i g h t weight weight ,其初值为 W W W

  3. i = 1 i = 1 i=1 开始, w [ i ] < w e i g h t w[i] < weight w[i]<weight 成立,表明物品 1 1 1 能够装入,将其装入到背包中,置 x [ 1 ] = 1 ,   w e i g h t = w e i g h t − w [ i ] = 70 x[1] = 1,\ weight =weight- w[i] = 70 x[1]=1, weight=weightw[i]=70 i = i + 1 = 2 i = i+1 =2 i=i+1=2

  4. w [ 2 ] < w e i g h t w[2] w[2]<weight 成立,表明物品 2 2 2 能够装入,将其装入到背包中,置 x [ 2 ] = 1 ,   w e i g h t = w e i g h t − w [ 2 ] = 60 x[2] =1,\ weight=weight-w[2] = 60 x[2]=1, weight=weightw[2]=60 i = i + 1 = 3 i = i + 1 = 3 i=i+1=3

  5. w [ 3 ] < w e i g h t w[3] < weight w[3]<weight 成立,表明物品 3 3 3 能够装入,将其装入到背包中,置 x [ 3 ] = 1 , w e i g h t = w e i g h t − w [ 3 ] = 50 x[3] = 1, weight = weight - w[3] = 50 x[3]=1,weight=weightw[3]=50 i = i + 1 = 4 i = i + 1 = 4 i=i+1=4

  6. w [ 4 ] < w e i g h t w[4] < weight w[4]<weight 不成立,且 w e i g h t > 0 weight > 0 weight>0 ,表明只能将物品 4 4 4 部分装入,装入比例为 w e i g h t w [ 4 ] = 50 60 = 80 % \dfrac{weight}{w[4]} = \dfrac{50}{60} = 80\% w[4]weight=6050=80% ,置 x [ 4 ] = 0.8 x[4] = 0.8 x[4]=0.8 ,算法结束,得到 X = ( 1 , 1 , 1 , 0.8 , 0 ) X = (1, 1, 1, 0.8, 0) X=(1,1,1,0.8,0)


说明:由于每个物品可以只取一部分,因此一定可以让总重量恰好为 W W W

当物品按价值递减排序后,除最后一个所取的物品可能只取其一部分外,其他物品要么不拿,要么全部拿走。

对应的完整程序如下:

#include 
using namespace std;
const int MAXN = 51;

// 问题表示
int n = 5;
double W = 100;									// 限重
struct NodeType {
	double w;
	double v;
	double p;									// p=v/w
	bool operator<(const NodeType &s) const {
		return p > s.p;							// 按P递减排序
	}
}; 												// 下标为0的元素不用
NodeType A[] = {{0}, {10, 20}, {20, 30}, {30, 66}, {40, 40}, {50, 60}};
// 求解结果表示
double V;										// 最大价值
double x[MAXN];
void Knap() {									// 求解背包问题并返回总价值
	V = 0;										// V初始化为0
	double weight = W;							// 背包中能装入的余下重量
	memset(x, 0, sizeof(x));					// 初始化x向量
	int i = 1;
	while (A[i].w < weight) {					// 物品i能够全部装入时循环
		x[i] = 1;								// 装入物品i
		weight -= A[i].w;						// 减少背包中能装入的余下重量
		V += A[i].v;							// 累计总价值
		++i;									// 继续循环
	}
	if (weight > 0) {							// 余下重量大于0
		x[i] = weight / A[i].w;					// 将物品i的一部分装入
		V += x[i] * A[i].v;						// 累计总价值
	}
}
void dispA() {
	printf("\tW\tV\tV/W\n");
	for (int i = 1; i <= n; ++i)
		printf("\t%g\t%g\t%3.1lf\n", A[i].w, A[i].v, A[i].p);
}
int main() {
	printf("求解过程\n");
	for (int i = 1; i <= n; ++i)
		A[i].p = A[i].v / A[i].w;
	printf("(1)排序前\n"); dispA();
	sort(A + 1, A + n + 1);
	printf("(2)排序后\n"); dispA();
	Knap();
	printf("(3)求解结果\n");
	printf("    x: [");
	for (int j = 1; j < n; ++j)
		printf("%g, ", x[j]);
	printf("%g]\n", x[n]);
	printf("    总价值=%g\n", V);
	return 0;
}

上述程序的执行结果如下:

【算法证明】假设对于 n n n 个物品,按 v i / w i   ( 1 ≤ i ≤ n ) v_i / w_i\ (1 \le i \le n) vi/wi (1in) 值递减排序,得到 1 , 2 , … , n 1, 2, \dots, n 1,2,,n 的序列,即 v 1 / w 1 ≥ v 2 / w 2 ≥ ⋯ ≥ v n / w n v_1 / w_1 \ge v_2 / w_2 \ge \dots \ge v_n / w_n v1/w1v2/w2vn/wn

X = ( x 1 , x 2 , … , x n ) X = (x_1,x_2, \dots, x_n) X=(x1,x2,,xn) 时本算法找到解。

  • 如果所有的 x i x_i xi 都等于 1 1 1 ,这个解明显是最优解。

  • 否则,设 m i n j minj minj 为满足 x m i n j < 1 x_{minj} < 1 xminj<1 的最小下标。

    考虑算法的工作方式,很明显,当 i < m i n j i < minj i<minj x i = 1 x_i = 1 xi=1 ,当 i > m i n j i > minj i>minj x i = 0 x_i = 0 xi=0 ,并且 ∑ i = 1 n w i x i = W \displaystyle \sum^n_{i = 1}w_ix_i = W i=1nwixi=W

    X X X 的价值为 V ( X ) = ∑ i = 1 n v i x i V(X) = \displaystyle \sum^n_{i=1}v_ix_i V(X)=i=1nvixi

    Y = ( y 1 , y 2 , … , y n ) Y = (y_1, y_2, \dots, y_n) Y=(y1,y2,,yn) 是该背包问题的一个最优可行解,因此有 ∑ i = 1 n w i y i ≤ W \displaystyle \sum^n_{i=1}w_iy_i \le W i=1nwiyiW ,从而有 ∑ i = 1 n w i ( x i − y i ) = ∑ i = 1 n w i x i − ∑ i = 1 n w i y i ≥ 0 \sum^n_{i=1}w_i(x_i - y_i) = \sum^n_{i=1}w_ix_i - \sum^n_{i=1}w_iy_i \ge 0 i=1nwi(xiyi)=i=1nwixii=1nwiyi0 这个解的价值为 V ( Y ) = ∑ i = 1 n v i y i V(Y) = \displaystyle \sum^n_{i=1}v_iy_i V(Y)=i=1nviyi ,则 V ( X ) − V ( Y ) = ∑ i = 1 n v i ( x i − y i ) = ∑ i = 1 n w i v i w i ( x i − y i ) V(X) - V(Y) = \sum^{n}_{i=1}v_i(x_i-y_i) = \sum^n_{i=1}w_i\dfrac{v_i}{w_i}(x_i-y_i) V(X)V(Y)=i=1nvi(xiyi)=i=1nwiwivi(xiyi)

    • i < m i n j i i<minj x i = 1 x_i = 1 xi=1 ,所以 x i − y i ≥ 0 x_i - y_i \ge 0 xiyi0 ,且 v i / w i ≥ v m i n j / w m i n j v_i / w_i \ge v_{minj} / w_{minj} vi/wivminj/wminj

    • i > m i n j i > minj i>minj x i = 0 x_i = 0 xi=0 ,所以 x i − y i ≤ 0 x_i - y_i \le 0 xiyi0 ,且 v i / w i ≤ v m i n j / w m i n j v_i / w_i \le v_{minj} / w_{minj} vi/wivminj/wminj

    • i = m i n j i = minj i=minj v i / w i = v m i n j / w m i n j v_i /w_i = v_{minj} / w_{minj} vi/wi=vminj/wminj

    则有如下证明:
    V ( X ) − V ( Y ) = ∑ i = 1 n w i v i w i ( x i − y i ) = ∑ i = 1 m i n j − 1 w i v i w i ( x i − y i ) + ∑ i = m i n j m i n j w i v i w i ( x i − y i ) + ∑ i = m i n j + 1 n w i v i w i ( x i − y i ) ≥ ∑ i = 1 m i n j − 1 w i v m i n j w m i n j ( x i − y i ) + ∑ i = m i n j m i n j w i v m i n j w m i n j ( x i − y i ) + ∑ i = m i n j + 1 n w i v m i n j w m i n j ( x i − y i ) = v m i n j w m i n j ∑ i = 1 n w i ( x i − y i ) ≥ 0 \begin{aligned} &V(X) - V(Y) = \sum^n_{i=1}w_i\dfrac{v_i}{w_i}(x_i-y_i) \ &= \sum_{i=1}^{minj - 1} w_i\dfrac{v_i}{w_i} (x_i - y_i) + \sum_{i=minj}^{minj} w_i\dfrac{v_i}{w_i} (x_i - y_i) + \sum_{i=minj+1}^{n} w_i\dfrac{v_i}{w_i} (x_i - y_i) \ &\ge \sum^{minj-1}_{i=1}w_i\dfrac{v_{minj}}{ w_{minj}} (x_i-y_i) + \sum_{i=minj}^{minj} w_i\dfrac{v_{minj}}{w_{minj}} (x_i - y_i) + \sum_{i=minj+1}^{n} w_i\dfrac{v_{minj}}{w_{minj}} (x_i - y_i) \ &= \dfrac{v_{minj}}{w_{minj}}\sum^n_{i=1} w_i(x_i-y_i) \ge 0 \end{aligned} V(X)V(Y)=i=1nwiwivi(xiyi)=i=1minj1wiwivi(xiyi)+i=minjminjwiwivi(xiyi)+i=minj+1nwiwivi(xiyi)i=1minj1wiwminjvminj(xiyi)+i=minjminjwiwminjvminj(xiyi)+i=minj+1nwiwminjvminj(xiyi)=wminjvminji=1nwi(xiyi)0

    这样与 Y Y Y 是最优解的假设矛盾,也就是说,没有哪个可行解的价值会大于 V ( X ) V(X) V(X) ,因此解 X X X 是最优解。

【算法分析】排序算法 sort() 的时间复杂度为 O ( n log ⁡ 2 n ) O(n\log_2 n) O(nlog2n)while 循环的时间为 O ( n ) O(n) O(n) ,所以本算法的世界复杂度为 O ( n log ⁡ 2 n ) O(n\log_2 n) O(nlog2n)

说明:背包问题和0/1背包问题类似,所不同的是,在选择物品装入背包时可以选择一部分、而不一定全部装入背包。

这两类问题都具有最优子结构性质,但背包问题可以用贪心法求解,而0/1背包问题却不能用贪心法求解,因为用贪心法求解0/1背包问题,可能得不到最优解

以表7.2所示的背包问题为例,如果作为0/1背包问题,因为重量为 60 60 60 的物品放不下(此时背包中只余下 50 50 50 重量的物品可放),所以只能舍弃它,选择重量为 40 40 40 的物品,这是一个可行解,但显然不是最优解。

【例7.3】求给定非负整数序列中的数字排列成的最大数字。

例如,给定 { 50 , 2 , 1 , 9 } \{ 50, 2, 1, 9\} {50,2,1,9} ,最大数字为 95021 95021 95021

说明该算法的思路(179. Largest Number,以及【宫水三叶の相信科学系列】为什么根据「拼接结果的字典序大小」决定「其在序列里的相对关系」是正确的)。

解:采用贪心思路,将数字大的数字排在前面,那么是不是将整数序列递减排序后、从前往后合并就可以了呢?答案是错误的,如果这样做, { 50 , 2 , 1 , 9 } \{ 50, 2, 1, 9\} {50,2,1,9} 递减排序后为 { 50 , 9 , 2 , 1 } \{ 50, 9, 2, 1\} {50,9,2,1} ,合并后的结果是 50921 50921 50921

应该采用递增的基数排序,再逆序合并原来的数,如图7.4所示。

这里还需要考虑,一个整数是另外一个整数的前缀的特殊情况,如 ( 1 , 10 ) (1, 10) (1,10) ,递增排序后为 ( 1 , 10 ) (1, 10) (1,10)(由于基数排序是稳定的, 1 1 1 排在前面),逆序合并结果是 101 101 101 ,显然是错误的。

所以修改基数排序,当补齐的两个数相同时、让原来位数短的排在后面,以便优先合并,这样 ( 1 , 10 ) (1, 10) (1,10) 递增排序后为 ( 10 , 1 ) (10, 1) (10,1) ,逆序合并结果是 110 110 110

这种思路是错误的!如 ( 5 , 5001 ) (5, 5001) (5,5001) ,补齐后为 ( 5000 , 5001 ) (5000, 5001) (5000,5001) ,排序为 ( 5 , 5001 ) (5, 5001) (5,5001) ,得不到最优解。


还可以将各个整数转换为字符串,按字典序递减排序,再顺序合并原来的数(可以证明,没有相同的数字开头时,这种排序方法是对的)。

错误!示例为 s1 = "90", s2 = "9" ,显然 s1 的字典序大于 s_2 ,结果为 909 909 909 、而非最优解 990 990 990

因此,修改字典序排序,令一个字符串是另一个的前缀时,短的字符串排在前面

错误!示例为 s1 = "89", s2 = "8", s3 = "80" ,按字典序排序的结果为 89808 89808 89808 ,按修改的字典序排序的结果为 88980 8 89 80 88980 ,而非最优解 89880 89 8 80 89880

正确解法是,按如下方式重载比较函数进行排序:

sort(str.begin(), str.end(), [&](const string &a, const string b) {
    return a + b > b + a;
});

【算法证明】对于本问题,所有字符串按上述方法排序后,设字符串为 { s 1 , s 2 , … , s n } \{ s_1, s_2, \dots, s_n\} {s1,s2,,sn}

要证明:若 X X X 是这一问题 A A A 的最优解, X = { s 1 } ∪ X ′ X =\{ s_1 \} \cup X' X={s1}X ,则 X ′ X' X A ′ = { s 2 , … , s n } A' = \{ s_2, \dots, s_n \} A={s2,,sn} 的最优解。

  • 首先证明总存在一个以 s 1 s_1 s1 开始的最优解。

    假设第一个选中的字符串为 s k   ( k ≠ 1 ) s_k\ (k \ne 1) sk (k=1) ,可以构造另一个最优解 Y Y Y ,由于 s 1 + s k > s k + s 1 s_1 + s_k > s_k + s_1 s1+sk>sk+s1 ,所以在 Y Y Y 中调换 s k s_k sk s 1 s_1 s1 ,得到的字符串 Y ′ Y' Y 的字典序不小于 Y Y Y(?),有 Y ′ Y' Y 也是最优的,这就说明总存在一个以 s 1 s_1 s1 开始的最优解。

  • 做出了这一个贪心选择后,原问题就变成了 A ′ A' A 子问题。

    此时要证明,如果 X X X A A A 的一个最优解,则 X ′ = X − { s 1 } X' = X - \{ s_1 \} X=X{s1} 也是 A ′ A' A 的一个最优解。


    采用反证法,如果能找到一个 A ′ A' A 的字典序更大于 X ′ X' X 的解 Y ′ Y' Y(相当于得到的整数更大),则将 s 1 s_1 s1 加入 Y ′ Y' Y 中,就得到一个字符串 s 1 Y ′ s_1Y' s1Y ,它的字典序大于 s 1 X ′ s_1X' s1X ,这就与 X X X 是最优解的假设矛盾。

    因此,不存在一个 A ′ A' A 的字典序更大的解 Y ′ Y' Y ,即 X ′ X' X A ′ A' A 的最优解。

    所以问题 A A A 的最优解 X X X 包含其子问题 A ′ A' A 的最优解 X ′ X' X ,最优子结构性质得证


7.4 求解田忌赛马问题

【问题描述】两千年前的战国时期,齐威王与大将田忌赛马。

双方约定每人各出 300 300 300 匹马,并且在上、中、下三个等级中各选一匹进行比赛。

由于齐威王每个等级的马都比田忌的马略强,比赛的结果可想而知。

现在双方各 n n n 匹马,依次派出一匹马进行比赛,每一轮获胜的一方将从输的一方得到 200 200 200 银币,平局则不用出钱,田忌已知所有马的速度值、并可以安排出场顺序,问他如何安排比赛获得的银币最多?
输入描述:输入包含多个测试用例,每个用例的第一行是正整数 n   ( n ≤ 1000 ) n\ (n \le 1000) n (n1000) ,表示马的数量;后两行分别是 n n n 个整数,表示田忌和齐威王的马的速度值。

输入 n = 0 n = 0 n=0 时结束。


输出描述:每个测试用例输出一行,表示田忌获得的最多银币数。


输入样例:

3
92 83 71
95 87 74
2
20 20
20 20
2
20 19
22 18
0

输出样例:

200
0
0

【问题求解】田忌的马的速度用数组 a a a 表示,齐威王的马的速度用数组 b b b 表示,将 a , b a, b a,b 数组递增排序。

采用常识性的贪心思路,分以下几种情况:

  1. 田忌最快的马比齐威王最快的马快,即 a [ r i g h t a ] > b [ r i g h t b ] a[righta] > b[rightb] a[righta]>b[rightb] ,则两者比赛(两个最快的马比赛)田忌赢

    因为此时田忌最快的马一定赢,而选择与齐威王最快的马比赛,对于田忌来说是最优的,如图7.6(a)所示,图中 ■ \blacksquare 表示已经比赛的马, □ \square 代表尚未比赛的马,箭头指向的马速度更快。

  2. 田忌最快的马比齐威王最快的马慢,即 a [ r i g h t a ] < b [ r i g h t b ] a[righta] < b[rightb] a[righta]<b[rightb] ,则选择田忌最慢的马比齐威王最快的马比赛,田忌输

    因为此时齐威王最快的马一定赢,而选择与田忌最慢的马比赛,对于田忌来说是最优的,如图7.6(b)所示。


  3. 若田忌最快的马与齐威王最快的马的速度相同,即 a [ r i g h t a ] = b [ r i g h t b ] a[righta] = b[rightb] a[righta]=b[rightb] ,又分为以下三种情况:

    • 田忌最慢的马比齐威王最慢的马快,即 a [ l e f t a ] > b [ l e f t b ] a[lefta] > b[leftb] a[lefta]>b[leftb] ,则两者比赛(两个最慢的马比赛),田忌赢。

      因为此时齐威王最慢的马一定输,而选择与田忌最慢的马比赛,对于田忌来说是最优的,如图7.7(a)所示。

    • 田忌最慢的马比齐威王最慢的马慢,并且田忌最慢的马比齐威王最快的马慢,即 a [ l e f t a ] ≤ b [ l e f t b ] a[lefta] \le b[leftb] a[lefta]b[leftb] 并且 a [ l e f t a ] < b [ r i g h t b ] a[lefta] < b[rightb] a[lefta]<b[rightb](不等于!),则选择田忌最慢的马与齐威王最快的马比赛,田忌输。

      因此此时田忌最慢的马一定输,而选择与齐威王最快的马比赛,对于田忌来说是最优的,如图7.7(b)所示。

    • 其他情况,即 a [ r i g h t a ] = b [ r i g h t b ] a[righta] = b[rightb] a[righta]=b[rightb] a [ l e f t a ] ≤ b [ l e f t b ] a[lefta] \le b[leftb] a[lefta]b[leftb] a [ l e f t a ] ≥ b [ r i g h t b ] a[lefta] \ge b[rightb] a[lefta]b[rightb] ,则 a [ l e f t a ] ≥ b [ r i g h t b ] = a [ r i g h t a ] a[lefta] \ge b[rightb] = a[righta] a[lefta]b[rightb]=a[righta] ,即 a [ l e f t a ] = a [ r i g h t a ] ,   b [ l e f t b ] ≥ a [ l e f t a ] = b [ r i g h t b ] a[lefta] = a[righta],\ b[leftb] \ge a[lefta] = b[rightb] a[lefta]=a[righta], b[leftb]a[lefta]=b[rightb] ,即 b [ l e f t b ] = b [ r i g h t b ] b[leftb] = b[rightb] b[leftb]=b[rightb] ,说明比赛区间的所有马的速度全部相同,任何两匹马比赛都没有输赢。


从上述过程看出,每种情况对于田忌来说都是最优的,因此最终获得的比赛方案也一定是最优的。

对应的完整程序如下:

#include 
using namespace std;
const int MAXN = 100;

// 问题表示
int n;
int a[MAXN], b[MAXN];
// 求解结果表示
int ans;										// 田忌获得的最多银币数
void solve() {									// 求解算法
	sort(a, a + n);								// 递增排序
	sort(b, b + n);								// 递增排序
	ans = 0;
	int lefta = 0, leftb = 0;
	int righta = n - 1, rightb = n - 1;
	while (lefta <= righta) {					// 比赛直到结束
		if (a[righta] > b[rightb]) {			// 田忌最快的马比齐威王最快的马快,两者比赛
			ans += 200;
			--righta;
			--rightb;
		} else if (a[righta] < b[rightb]) { 	// 田忌最快的马比齐威王最快的马慢
			ans -= 200;							// 选择田忌最慢的马比齐威王最快的马比赛
			++lefta;
			--rightb;
		} else {								// 田忌最快的马与齐威王最快的马速度相同
			if (a[lefta] > b[leftb]) {			// 田忌最慢的马比齐威王最慢的马快,两者比赛
				ans += 200;
				++lefta;
				++leftb;
			} else {
				if (a[lefta] < b[rightb]) 		// 否则用田忌最慢的马与齐威王最快的马比赛
					ans -= 200;
				++lefta;
				--rightb;
			}			
		}
	}
}

int main() {
	while (true) {
		scanf("%d", &n);
		if (n == 0) break;
		for (int i = 0; i < n; ++i)
			scanf("%d", &a[i]);
		for (int j = 0; j < n; ++j)
			scanf("%d", &b[j]);
		solve();
		printf("%d\n", ans);
	}
	return 0;
}

7.5 哈夫曼编码

【问题描述】设需要编码的字符集为 { d 1 , d 2 , … , d n } \{ d_1, d_2, \dots, d_n\} {d1,d2,,dn} ,它们出现的频率为 { w 1 , w 2 , … , w n } \{w_1, w_2, \dots, w_n\} {w1,w2,,wn} ,应用哈夫曼树,构造最优的不等长的、由 0 , 1 0, 1 0,1 构成的编码方案。

【问题求解】先构建以 n n n 个结点为叶子结点的哈夫曼树,然后由哈夫曼树产生「各叶子结点对应字符」的哈夫曼编码。

哈夫曼树 Huffman Tree 的定义:设二叉树具有 n n n 个带权值的叶子结点,从根结点到每个叶子结点都有一个路径长度。

「从根结点到各个叶子结点的路径长度」与「相应结点权值」的乘积的和,称为该二叉树的带权路径长度,记作如下形式,其中 w i w_i wi 为第 i i i 个叶子结点的权值, l i l_i li 为第 i i i 个叶子结点的路径长度: WPL = ∑ i = 1 n w i × l i \textrm{WPL} = \sum^n_{i=1} w_i \times l_i WPL=i=1nwi×li n n n 个叶子结点可以构造出多种二叉树,其中具有最小带权路径长度的二叉树,称为哈夫曼树(也称最优树)。

根据哈夫曼树的定义,一棵二叉树要使其 WPL \textrm{WPL} WPL 值最小,必须使权值越大的叶子结点越靠近根结点,而使权值越小的叶子结点越远离根结点

那么如何构造一棵哈夫曼树呢?其方法如下:

  1. 由给定的 n n n 个权值 { w 1 , w 2 , … , w n } \{ w_1, w_2, \dots, w_n\} {w1,w2,,wn} 构造 n n n 棵只有一个叶子结点的二叉树,从而得到一个二叉树的集合 F = { T 1 , T 2 , … , T n } F = \{ T_1, T_2, \dots, T_n\} F={T1,T2,,Tn}


  2. F F F 中,选取根结点的权值最小和次小的两棵二叉树作为左右子树,构造一棵新的二叉树,这棵新的二叉树的根结点的权值,为其左、右子树根结点的权值之和,即合并两棵二叉树为一棵二叉树。

  3. 重复步骤2,直到 F F F 中只剩下一棵二叉树时,这棵二叉树就是所要建立的哈夫曼树。

例如,给定 a ∼ e a \sim e ae 这五个字符,它们的权值集合为 W = { 4 , 2 , 1 , 7 , 3 } W = \{ 4, 2, 1, 7, 3\} W={4,2,1,7,3} ,构造哈夫曼树的过程如图7.8所示(图中带阴影的结点,表示所属二叉树的根结点)。

利用哈夫曼树构造的、用于通信的二进制编码,称为哈夫曼编码。

在哈夫曼树中,从根到每个叶子都有一条路径,对路径上的各个分支,约定指向左子树根的分支表示 0 0 0 码、指向右子树的分支表示 1 1 1 码,取每条路径上的 0 0 0 1 1 1 的序列,作为与各个叶子结点对应的字符的编码,这就是哈夫曼编码。

前面的示例产生的哈夫曼编码如图7.9所示。



每个字符编码由 0 , 1 0, 1 0,1 构成,并且没有一个字符编码是另一个字符编码的前缀(因为字符都在叶子结点上,而不在内部结点上),这种编码称为前缀码,哈夫曼编码是一种最优前缀码。

前缀码可以使译码过程变得十分简单,由于任一个字符的编码都不是其他字符的前缀,从编码文件中不断取出代码某一个字符的前缀码,转换为原字符,即可逐个译出文件中的所有字符

哈夫曼编码还是一种变长编码,优点在于可以减少编码后的位数,缺点是不能随机解码。

在哈夫曼树的构造过程中,每次都合并两棵根结点权值最小的二叉树,这体现出贪心法的思想。

那么是否可以像前面介绍的算法一样,先按权值递增排序、然后依次构造哈夫曼树呢?由于每次合并两棵二叉树时,都要找最小和次小的根结点,而且新构造的二叉树也参加这一过程,如果每次都排序,这样花费的时间更多,所以绝对不会这样做,而是在已构造的二叉树中,设计一个小根堆来查找最小和次小的根结点(小根堆)。

n n n 个权值构造的哈夫曼树的总结点个数为 2 n − 1 2n - 1 2n1 ,每个结点的二进制编码长度不会超过树高,可以推出这样的哈夫曼树的高度最多为 n n n

所以用一个数组 h t [ 0 … 2 n − 2 ] ht[0\dots 2n-2] ht[02n2] 存放哈夫曼树,其中 h t [ 0 … n − 1 ] ht[0\dots n-1] ht[0n1] 存放叶子结点, h t [ n … 2 n − 2 ] ht[n\dots 2n - 2] ht[n2n2] 存放其他需要构造的结点, h t [ i ] . p a r e n t ht[i].parent ht[i].parent 为该结点的双亲在 h t ht ht 数组中的下标, h t [ i ] . p a r e n t = − 1 ht[i].parent = -1 ht[i].parent=1 表示它为根结点, h t [ i ] . l c h i l d ,   h t [ i ] . r c h i l d ht[i].lchild,\ ht[i].rchild ht[i].lchild, ht[i].rchild 分别为该结点的左、右孩子的下标。

map 容器 h t c o d e htcode htcode ,存放所有叶子结点的哈夫曼编码,例如 htcode['a'] = "10" 表示字符 'a' 的哈夫曼编码为 10

对应的完整程序如下:

#include 
using namespace std;
const int MAXN = 101;

// 问题表示
int n;										// 叶子结点个数
// 求解结果表示
struct HTreeNode {							// 哈夫曼树结点类型
	char data;								// 字符
	int weight;								// 权值
	int parent;								// 双亲的位置
	int lchild, rchild;						// 左孩子、右孩子位置
};
HTreeNode ht[MAXN];							// 哈夫曼树
map<char, string> htcode;					// 哈夫曼编码
struct NodeType {							// 优先队列结点类型
	int no;
	char data;
	int weight;
	bool operator<(const NodeType &s) const {
		return weight > s.weight;			// 用于创建小根堆
	}
};
void CreateHTree() {						// 构造哈夫曼树
	NodeType e, e1, e2;
	priority_queue<NodeType> pq;
	for (int k = 0; k < 2 * n - 1; ++k)		// 设置所有结点的指针域
		ht[k].lchild = ht[k].rchild = ht[k].parent = -1;
	for (int i = 0; i < n; ++i) {			// 将n个结点入队
		e.no = i;
		e.data = ht[i].data;
		e.weight = ht[i].weight;
		pq.push(e);
	}
	for (int j = n; j < 2 * n - 1; ++j) {	// 构造哈夫曼树的n-1个非叶子结点
		e1 = pq.top(); pq.pop();			// 出队权值最小的结点e1
		e2 = pq.top(); pq.pop();			// 出队权值最小的结点e2
		ht[j].weight = e1.weight + e2.weight;
		ht[j].lchild = e1.no;
		ht[j].rchild = e2.no;				// 构造哈夫曼树的非叶子结点j
		ht[e1.no].parent = j;				// 修改e1.no的双亲为结点j
		ht[e2.no].parent = j;				// 修改e2.no的双亲为结点j
		e.no = j;							// 继续构造队列结点e
		e.weight = e1.weight + e2.weight;
		pq.push(e);
	}
}
void CreateHCode() {						// 构造哈夫曼编码
	string code;
	code.reserve(MAXN);
	for (int i = 0; i < n; ++i) {			// 构造叶子结点i的哈夫曼编码
		code = "";
		int curno = i;
		int f = ht[curno].parent;
		while (f != -1) {
			if (ht[f].lchild == curno) 		// curno为双亲f的左孩子
				code = '0' + code;
			else 							// curno为双亲f的右孩子
				code = '1' + code;
			curno = f; f = ht[curno].parent;
		}
		htcode[ht[i].data] = code;			// 得到ht[i].data字符的哈夫曼编码
	}
}
void DispHCode() {							// 输出哈夫曼编码
	for (auto it : htcode) 
		cout << "  " << it.first << ": " << it.second << endl;
}
void DispHTree() {							// 输出哈夫曼树
	for (int i = 0; i < 2 * n - 1; ++i) {
		printf("  data=%c, weight=%d, lchild=%d, rchild=%d, parent=%d\n",
			ht[i].data, ht[i].weight, ht[i].lchild, ht[i].rchild, ht[i].parent);
	}
}
int WPL() {									// 求WPL
	int wps = 0;
	for (int i = 0; i < n; ++i)
		wps += ht[i].weight * htcode[ht[i].data].size();
	return wps;
}
int main() {
	n = 5;
	ht[0].data = 'a', ht[0].weight = 4;
	ht[1].data = 'b', ht[1].weight = 2;
	ht[2].data = 'c', ht[2].weight = 1;
	ht[3].data = 'd', ht[3].weight = 7;
	ht[4].data = 'e', ht[4].weight = 3;
	CreateHTree();							// 建立哈夫曼树
	printf("构造的哈夫曼树:\n");
	DispHTree();
	CreateHCode();							// 求哈夫曼编码
	printf("产生的哈夫曼编码如下:\n");	
	DispHCode();
	printf("WPL=%d\n", WPL());
	return 0;
}

上述程序的执行结果如下:

说明:在哈夫曼树的构造中,当合并两棵二叉树时,将两个权值最小和次小的根结点作为左或右孩子均可以,这样构造出的哈夫曼树可能不唯一,因此产生的哈夫曼编码也不唯一,但它们的 WPL \textrm{WPL} WPL 一定是唯一的。

例如,上述程序的执行结果和图7.9所示的哈夫曼编码不同,但都是正确的哈夫曼编码, WPL \textrm{WPL} WPL 均为 36 36 36

【算法证明】先讨论两个命题及其证明过程。

  • 命题1:两个最小权值字符对应的结点 x x x y y y ,必须是哈夫曼树中最深的两个结点、且它们互为兄弟(从而做出计算最优树的一步贪心选择)。


    证明:假设 x x x 结点在哈夫曼树(最优树)中不是最深的,那么存在一个结点 z z z ,有 w z > w x w_z > w_x wz>wx ,但它比 x x x 深,即 l z > l x l_z > l_x lz>lx ,此时结点 x x x z z z 的带权和为 w x × l x + w z × l z w_x \times l_x + w_z \times l_z wx×lx+wz×lz

    如果交换 x x x z z z 结点的位置,其他不变,如图7.10所示,则交换后的带权和为 w x × l z + w z × l x w_x \times l_z + w_z \times l_x wx×lz+wz×lx ,则有 w x × l z + w z × l x < w x × l x + w z × l z w_x \times l_z + w_z \times l_x < w_x \times l_x + w_z \times l_z wx×lz+wz×lx<wx×lx+wz×lz



    这是因为 w x × l z + w z × l x − ( w x × l x + w z × l z ) = w x ( l z − l x ) − w z ( l z − l x ) = ( w x − w z ) ( l z − l x ) < 0 w_x \times l_z + w_z \times l_x - (w_x \times l_x + w_z \times l_z) = w_x(l_z - l_x) - w_z(l_z - l_x) = (w_x - w_z) (l_z - l_x) < 0 wx×lz+wz×lx(wx×lx+wz×lz)=wx(lzlx)wz(lzlx)=(wxwz)(lzlx)<0(由前面所设有 w z > w x w_z > w_x wz>wx l z > l x l_z > l_x lz>lx )。

    这与交换前的树是最优树的假设矛盾,所以上述命题成立。

  • 命题2:设 T T T 是字符集 C C C 对应的一棵哈夫曼树,结点 x , y x, y x,y 是兄弟,它们的双亲为 z z z ,如图7.11所示,显然有 w z = w x + w y w_z = w_x + w_y wz=wx+wy

    现删除结点 x , y x, y x,y ,让 z z z 变为叶子结点,那么这棵新树 T 1 T_1 T1 一定是字符集 C 1 = C − { x , y } ∪ { z } C_1 = C - \{ x, y\} \cup \{ z\} C1=C{x,y}{z} 的最优树。


    证明:设 T T T T 1 T_1 T1 的带权路径长度分别为 WPL ( T ) \textrm{WPL}(T) WPL(T) WPL ( T 1 ) \textrm{WPL}(T_1) WPL(T1) ,则有 WPL ( T ) = WPL ( T 1 ) + w x + w y \textrm{WPL}(T) = \textrm{WPL}(T_1) + w_x + w_y WPL(T)=WPL(T1)+wx+wy

    这是因为 WPL ( T 1 ) \textrm{WPL}(T_1) WPL(T1) 含有 T T T 中除 x , y x, y x,y 以外的所有叶子结点的带权路径长度和,另外加上 z z z 的带权路径长度。


    假设 T 1 T_1 T1 不是最优的,则存在另一棵树 T 2 T_2 T2 ,有 WPL ( T 2 ) < WPL ( T 1 ) \textrm{WPL}(T_2) < \textrm{WPL}(T_1) WPL(T2)<WPL(T1)

    由于结点 z ∈ C 1 z \in C_1 zC1 ,则 z z z T 2 T_2 T2 中一定是一个叶子结点。

    若将 x , y x, y x,y 加入 T 2 T_2 T2 中作业结点 z z z 的左、右孩子,则得到表示字符集 C C C 的前缀树 T 3 T_3 T3 ,如图7.12所示,则有 WPL ( T 3 ) = WPL ( T 2 ) + w x + w y \textrm{WPL}(T_3) = \textrm{WPL}(T_2) + w_x + w_y WPL(T3)=WPL(T2)+wx+wy


    由前面的几个式子看到 WPL ( T 3 ) = WPL ( T 2 ) + w x + w y < WPL ( T 1 ) + w x + w y = WPL ( T ) \textrm{WPL}(T_3) = \textrm{WPL} (T_2) + w_x + w_y < \textrm{WPL}(T_1) + w_x + w_y = \textrm{WPL}(T) WPL(T3)=WPL(T2)+wx+wy<WPL(T1)+wx+wy=WPL(T)

    这与 T T T C C C 的哈夫曼树的假设矛盾,本命题即证。


  • 命题一说明,该算法满足贪心选择性质,即通过合并来构造一棵哈夫曼树的过程,可以从合并两个权值最小的字符开始。

    命题二说明,该算法满足最优子结构性质,即该问题的最优解包含其子问题的最优解。

    所以采用哈夫曼算法产生的树一定是一棵最优树。

【算法分析】由于采用小根堆,从堆中删除两个结点(权值最小的两个二叉树根结点)、和加入一个新结点的时间复杂度都是 O ( log ⁡ 2 n ) O(\log_2 n) O(log2n) ,所以构造哈夫曼树算法的时间复杂度为 O ( n log ⁡ 2 n ) O(n \log_2 n) O(nlog2n)

生成哈夫曼编码的算法循环 n n n 次,每次生成路径恰好是一个叶子结点到根结点的路径,平均高度为 O ( log ⁡ 2 n ) O(\log_2 n) O(log2n) ,所以由哈夫曼树生成哈夫曼编码的算法的时间复杂度也是 O ( n log ⁡ 2 n ) O(n\log_2 n) O(nlog2n)(其实也可以 O ( n ) O(n) O(n) 生成)。

【例7.4】有一个英文句子 str="The following code computes the intersection of two arrays." ,统计其中各个字符出现的次数,以其为频度、构造对应的哈夫曼编码,将该英文句子进行编码、得到 enstr ,然后将 enstr 解码为 destr

编写程序实现上述功能。


解:首先统计 str 中各个字符出现的次数,用 map 容器 mp 存放。

采用上述原理构造哈夫曼树 h t ht ht ,继而产生对应的哈夫曼编码 h t c o d e htcode htcode

扫描 str ,将字符 str[i]htcode[str[i]] 替换得到编码 enstr

在对 enstr 解码时,扫描 enstr 的0/1字符串,从哈夫曼树的根结点开始匹配,当找到叶子结点时,用该叶子结点的字符替代匹配的0/1字符串,即可得到解码字符串 destr

对应的完整程序如下:

#include 
using namespace std;
const int MAXN = 101;

// 问题表示
int n;										// 叶子结点个数
string str;
// 求解结果表示
struct HTreeNode {							// 哈夫曼树结点类型
	char data;								// 字符
	int weight;								// 权值
	int parent;								// 双亲的位置
	int lchild, rchild;						// 左孩子、右孩子位置
};
HTreeNode ht[MAXN];							// 哈夫曼树
map<char, string> htcode;					// 哈夫曼编码
struct NodeType {							// 优先队列结点类型
	int no;
	char data;
	int weight;
	bool operator<(const NodeType &s) const {
		return weight > s.weight;			// 用于创建小根堆
	}
};
void Init() {								// 初始化哈夫曼树
	map<char, int> mp;
	for (int i = 0; i < str.size(); ++i)    // 统计str中各字符出现的次数
		++mp[str[i]];
	n = mp.size();							// 哈夫曼树叶子结点的个数
	int k = 0;
	for (auto it : mp) {					// 设置叶子结点的data和weight
		ht[k].data = it.first;
		ht[k].weight = it.second;
		++k;
	}
	for (int k = 0; k < 2 * n - 1; ++k)		// 设置所有结点的指针域为-1,表示空指针
		ht[k].lchild = ht[k].rchild = ht[k].parent = -1;
}
void CreateHTree() {						// 构造哈夫曼树
	NodeType e, e1, e2;
	priority_queue<NodeType> pq;
	for (int i = 0; i < n; ++i) {			// 将n个结点入队
		e.no = i;
		e.data = ht[i].data;
		e.weight = ht[i].weight;
		pq.push(e);
	}
	for (int j = n; j < 2 * n - 1; ++j) {	// 构造哈夫曼树的n-1个非叶子结点
		e1 = pq.top(); pq.pop();			// 出队权值最小的结点e1
		e2 = pq.top(); pq.pop();			// 出队权值最小的结点e2
		ht[j].weight = e1.weight + e2.weight;
		ht[j].lchild = e1.no;
		ht[j].rchild = e2.no;				// 构造哈夫曼树的非叶子结点j
		ht[e1.no].parent = j;				// 修改e1.no的双亲为结点j
		ht[e2.no].parent = j;				// 修改e2.no的双亲为结点j
		e.no = j;							// 继续构造队列结点e
		e.weight = e1.weight + e2.weight;
		pq.push(e);
	}
}
void CreateHCode() {						// 构造哈夫曼编码
	string code;
	code.reserve(MAXN);
	for (int i = 0; i < n; ++i) {			// 构造叶子结点i的哈夫曼编码
		code = "";
		int curno = i;
		int f = ht[curno].parent;
		while (f != -1) {
			if (ht[f].lchild == curno) 		// curno为双亲f的左孩子
				code = '0' + code;
			else 							// curno为双亲f的右孩子
				code = '1' + code;
			curno = f; f = ht[curno].parent;
		}
		htcode[ht[i].data] = code;			// 得到ht[i].data字符的哈夫曼编码
	}
}
void DispHCode() {							// 输出哈夫曼编码
	for (auto it : htcode) 
		cout << "  " << it.first << ": " << it.second << endl;
}
void Encode(string str, string &enstr) {	// 编码字符串str得到enstr
	for (int i = 0; i < str.size(); ++i)
		enstr += htcode[str[i]];
}
void Decode(const string &enstr, string &destr) {	// 解码字符串enstr得到destr
	int r = 2 * n - 2, p;					// 哈夫曼树的根结点为ht[2*n-2]结点
	int i = 0;
	while (i < enstr.size()) {
		p = r;
		while (i < enstr.size()) {
			if (enstr[i] == '0') p = ht[p].lchild;
			else p = ht[p].rchild;
			if (ht[p].lchild == -1 && ht[p].rchild == -1) // p为叶子结点
				break;						// 找到对应的字符
			++i;	
		}
		destr += ht[p].data;				// 在解码字符串中添加ht[p].data
		++i;
	}
}
int main() {
	str = "The following code computes the intersection of two arrays.";
	Init();
	CreateHTree();							// 建立哈夫曼树
	CreateHCode();							// 求哈夫曼编码
	printf("哈夫曼编码:\n");	
	DispHCode();
	
	string enstr;
	Encode(str, enstr);
	cout << "编码结果:\n" << enstr << "\n";
	
	string destr;
	Decode(enstr, destr);
	cout << "解码结果:\n" << destr << "\n";
	return 0;
}

上述程序的执行结果如下:

说明:从这里看出,编码字符串 enstr 的长度( 244 244 244 个字符)远远大于 str 59 59 59 个字符),在实际应用中可以用位图存放,这样可以将 enstr 压缩为 244 / 8 = 31 244/8=31 244/8=31 个字符。


7.6 求解流水作业调度问题

流水作业调度问题的描述见5.9节,采用回溯法求解,在6.5节中采用优先队列式分枝限界法求解,这里采用贪心法求解。

【问题求解】采用归纳思路。

  • 当只有一个作业 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 时,显然最少时间 T min ⁡ = a 1 + b 1 T_{\min} = a_1 + b_1 Tmin=a1+b1

  • 当有两个作业 ( a 1 , b 1 ) (a_1,b_1) (a1,b1) ( a 2 , b 2 ) (a_2, b_2) (a2,b2) 时,若 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 在前、 ( a 2 , b 2 ) (a_2, b_2) (a2,b2) 在后执行,有如图7.13所示的两种情况,图7.13(a)求出最少时间 T min ⁡ = a 1 + b 1 + a 2 + b 2 − b 1   ( b 1 < a 2 ) T_{\min} = a_1 + b_1 + a_2+b_2 - b_1\ (b_1 < a_2) Tmin=a1+b1+a2+b2b1 (b1<a2) ,图7.13(b)求出最少时间 T min ⁡ = a 1 + b 1 + a 2 + b 2 − a 2   ( a 2 < b 1 ) T_{\min} = a_1 + b_1 + a_2 + b_2 - a_2\ (a_2< b_1) Tmin=a1+b1+a2+b2a2 (a2<b1)

    合并起来, T min ⁡ = a 1 + b 1 + a 2 + b 2 − min ⁡ ( a 2 , b 1 ) T_{\min} = a_1 +b_1+a_2+b_2 -\min(a_2, b_1) Tmin=a1+b1+a2+b2min(a2,b1)

    ( a 2 , b 2 ) (a_2, b_2) (a2,b2) 在前、 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 在后执行,可以求出最少时间 T min ⁡ = a 2 + b 2 + a 1 + b 1 − min ⁡ ( b 2 , a 1 ) T_{\min} = a_2 + b_2 + a_1 + b_1 - \min(b_2, a_1) Tmin=a2+b2+a1+b1min(b2,a1)



    将两种执行顺序合并起来,有: T min ⁡ = a 1 + b 1 + a 2 + b 2 − max ⁡ ( min ⁡ ( a 2 , b 1 ) , min ⁡ ( a 1 , b 2 ) ) T_{\min} = a_1 + b_1 + a_2 + b_2 - \max(\min(a_2, b_1), \min(a_1, b_2)) Tmin=a1+b1+a2+b2max(min(a2,b1),min(a1,b2)) 归纳起来,对于两个作业 ( a 1 , b 1 ) (a_1,b_1) (a1,b1) ( a 2 , b 2 ) (a_2, b_2) (a2,b2) ,若 min ⁡ ( a 1 , b 2 ) ≤ min ⁡ ( a 2 , b 1 ) \min(a_1, b_2) \le \min(a_2, b_1) min(a1,b2)min(a2,b1) ,则 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 放在 ( a 2 , b 2 ) (a_2, b_2) (a2,b2) 前面执行;反之,若 min ⁡ ( a 1 , b 2 ) ≥ min ⁡ ( a 2 , b 1 ) \min(a_1, b_2) \ge \min(a_2, b_1) min(a1,b2)min(a2,b1) ,则 ( a 2 , b 2 ) (a_2, b_2) (a2,b2) 放在 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 前面执行 ■ \blacksquare

由此可得到一个贪心选择的性质,对于给定的作业 ( a , b ) (a, b) (a,b)

  • a ≤ b a \le b ab 时,让这类作业中 a a a 比较小的作业尽可能先执行。

    原因是,假设 a 1 ≤ a 2 a_1 \le a_2 a1a2 ,且 a 1 ≤ b 1 , a 2 ≤ b 2 a_1 \le b_1, a_2 \le b_2 a1b1,a2b2 ,则 a 1 ≤ b 2 a_1 \le b_2 a1b2 ,从而 min ⁡ ( a 1 , b 2 ) = a 1 ≤ min ⁡ ( a 2 , b 1 ) \min(a_1, b_2) = a_1 \le \min(a_2, b_1) min(a1,b2)=a1min(a2,b1)

    因此, ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 先执行。

  • a > b a > b a>b 时,让这类作业中 b b b 比较小的作业尽可能后执行。

    原因是,假设 b 1 > b 2 b_1> b_2 b1>b2 ,且 a 1 > b 1 , a 2 > b 2 a_1 > b_1, a_2 > b_2 a1>b1,a2>b2 ,则 a 1 > b 2 a_1 > b_2 a1>b2 ,从而 min ⁡ ( a 1 , b 2 ) = b 2 < min ⁡ ( a 2 , b 1 ) \min(a_1, b_2) = b_2 < \min(a_2, b_1) min(a1,b2)=b2<min(a2,b1)

    因此, ( a 2 , b 2 ) (a_2, b_2) (a2,b2) 后执行。

Johnson算法就是采用这种贪心思路。

其步骤如下:

  1. 把所有作业按 M 1 , M 2 M_1, M_2 M1,M2 的执行时间分为两组, a [ i ] ≤ b [ i ] a[i] \le b[i] a[i]b[i] 对应第一组 N 1 N_1 N1 a [ i ] > b [ i ] a[i] > b[i] a[i]>b[i] 对应第二组 N 2 N_2 N2

  2. N 1 N_1 N1 的作业按 a [ i ] a[i] a[i] 递增排序, N 2 N_2 N2 的作业按 b [ i ] b[i] b[i] 递减排序。

  3. 按顺序先执行 N 1 N_1 N1 的作业,再执行 N 2 N_2 N2 的作业,得到的就是耗时最少的最优调度方案。

上面有个问题:先执行 N 2 N_2 N2 、再执行 N 1 N_1 N1 不行吗?事实上,我们从 ■ \blacksquare 处可以发现,如果将其写成比较函数、并对所有作业排序,则 N 1 N_1 N1 必然排在 N 2 N_2 N2 前面。

具体来说,令作业 ( a 1 , b 1 ) , ( a 2 , b 2 ) (a_1, b_1), (a_2, b_2) (a1,b1),(a2,b2) ,前者属于 N 1   ( a 1 ≤ b 1 ) N_1\ (a_1 \le b_1) N1 (a1b1) ,后者属于 N 2   ( a 2 > b 2 ) N_2\ (a_2 > b_2) N2 (a2>b2)

于是分情况比较讨论:

  • a 1 > a 2 a_1 > a_2 a1>a2 :可知 b 1 ≥ a 1 > a 2 > b 2 b_1 \ge a_1 > a_2 > b_2 b1a1>a2>b2 ,于是 min ⁡ ( a 1 , b 2 ) = b 2 < min ⁡ ( a 2 , b 1 ) = a 2 \min(a_1, b_2) = b_2 < \min(a_2, b_1) = a_2 min(a1,b2)=b2<min(a2,b1)=a2 ,所以 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 先执行。

  • a 1 ≤ a 2 a_1 \le a_2 a1a2 :可知 a 1 ≤ min ⁡ ( a 2 , b 1 ) a_1 \le \min(a_2, b_1) a1min(a2,b1) ,因此要判断元素 a 1 , b 2 a_1, b_2 a1,b2 谁更小。

    • a 1 ≤ b 2 a_1 \le b_2 a1b2 ,则 min ⁡ ( a 1 , b 2 ) = a 1 ≤ min ⁡ ( a 2 , b 1 ) \min(a_1, b_2) = a_1 \le \min(a_2, b_1) min(a1,b2)=a1min(a2,b1) ,所以 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 先执行。

    • a 1 > b 2 a_1 > b_2 a1>b2 ,则 min ⁡ ( a 1 , b 2 ) = b 2 < min ⁡ ( a 2 , b 1 ) \min(a_1, b_2) = b_2 < \min(a_2, b_1) min(a1,b2)=b2<min(a2,b1) ,所以 ( a 1 , b 1 ) (a_1, b_1) (a1,b1) 先执行。

综合上述种种,我们可以证明,Johnson贪心算法是正确的。

具体实现采用如下结构体数组 c c c

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递增排序	
	}
};

扫描 a , b a, b a,b 数组得到 c c c ,对数组 c c c t i m e time time a , b a, b a,b 的最小值)递增排序。

用一维数组 b e s t best best 存放最优调度序列,即将 N 1 N_1 N1 的作业序号按顺序存放在 b e s t best best 的前面部分,将 N 2 N_2 N2 的作业序号按反序存放在 b e s t best best 的后面部分即可。

注意:因为 N 2 N_2 N2 组中 t i m e time time b b b 值,按时间递增排序后、对应按 b b b 递增排序的结果,要按反序存放到 b e s t best best 中、达到按 b b b 递减选择作业的目的

例如, n = 4 n = 4 n=4 ,作业的 M 1 M_1 M1 时间 a [ ] = { 5 , 12 , 4 , 8 } a[] = \{ 5, 12, 4, 8\} a[]={5,12,4,8} ,作业的 M 2 M_2 M2 时间 b [ ] = { 6 , 2 , 14 , 7 } b[] = \{6, 2, 14, 7\} b[]={6,2,14,7}

生成的数组 c c c 如表7.5所示,按 t i m e time time 排序后的结果如表7.6所示。

再依次扫描数组 c c c 的所有元素,将第一组元素按 t i m e time time 递增排列放在 b e s t best best 的前面部分,将第二组元素(组号为 0 0 0 )按 t i m e time time 递减排列放在 b e s t best best 的后面部分,得到的结果如表7.7所示。

此时 b e s t best best 中的作业顺序即为最优调度方案,即 3 , 1 , 4 , 2 3, 1, 4, 2 3,1,4,2


现在求最优调度下的总时间,用 f 1 f_1 f1 累计 M 1 M_1 M1 上的执行时间(初始时 f 1 = 0 f_1 = 0 f1=0 ),用 f 2 f_2 f2 累计 M 2 M_2 M2 上的执行时间(初始时 f 2 = 0 f_2 = 0 f2=0 ),最终 f 2 f_2 f2 即为最优调度下的消耗总时间。

对于最优调度方案 b e s t best best ,用 i i i 扫描 b e s t best best 的元素, f 1 f_1 f1 f 2 f_2 f2 的计算如下(推导过程见5.9节):
f 1 = f 1 + a [ b e s t [ i ] ] f 2 = max ⁡ ( f 2 , f 1 ) + b [ b e s t [ i ] ] \begin{aligned} &f_1 = f_1 + a[best[i]] \ &f_2 = \max (f_2, f_1) + b[best[i]] \end{aligned} f1=f1+a[best[i]]f2=max(f2,f1)+b[best[i]]

对应的完整程序如下:

#include 
using namespace std;
const int N = 100;

// 问题表示
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() {								// 求解流水作业调度问题
	NodeType c[N];
	for (int i = 0; i < n; ++i) {			// 在n个作业中,求出每个作业的最小加工时间
		c[i].no = i;
		c[i].group = (a[i] <= b[i]);		// a[i]<=b[i]对应第一组,a[i]>b[i]对应第二组
		c[i].time = min(a[i], b[i]);		// 第一组存放a[i],第二组存放b[i]
	}
	sort(c, c + n);							// c中元素按time递增排序
	int j = 0, k = n - 1;
	for (int i = 0; i < n; ++i) {			// 扫描c的所有元素,产生最优调度方案
		if (c[i].group == 1) 				// 第1组,按time递增排列后放在best的前面部分
			best[j++] = c[i].no;			
		else
			best[k--] = c[i].no;			// 第0组,按time递减排列后放在best的后面部分
	}
	int f1 = 0;								// 累计M1上的执行时间
	int f2 = 0;								// 累计M2上的执行时间,也是最优调度下的消耗总时间
	for (int i = 0; i < n; ++i) {
		f1 += a[best[i]];
		f2 = max(f2, f1) + b[best[i]];
	}
	return f2;
}
int main() {
	printf("求解结果\n");
	printf("    总时间: %d\n", solve());
	printf("    调度方案: ");
	for (int i = 0; i < n; ++i)
		printf("%d ", best[i] + 1);
	printf("\n");
	return 0;
}

上述程序的执行结果如下:

【算法分析】算法的时间主要花费在排序上,所以时间复杂度为 O ( n log ⁡ 2 n ) O(n\log_2n) O(nlog2n) ,比采用回溯法和分枝限界法求解更高效。


7.7 求解最优装载问题

【数据结构和算法设计】算法篇(7) 贪心法 多机调度、最优装载问题


7.8 求解多机调度问题

【数据结构和算法设计】算法篇(7) 贪心法 多机调度、最优装载问题


其他题目





欢迎分享,转载请注明来源:内存溢出

原文地址:https://54852.com/langs/662371.html

(0)
打赏 微信扫一扫微信扫一扫 支付宝扫一扫支付宝扫一扫
上一篇 2022-04-18
下一篇2022-04-18

发表评论

登录后才能评论

评论列表(0条)

    保存