[算法竞赛入门]第八章_高效程序设计


【教学内容相关章节】
8.1算法分析初步 8.2再谈排序与搜索 8.3递归与分治
8.4贪心法
【教学目标】
(1)理解“基本操作”、渐近时间复杂度的概念和大O记号的含义;
(2)掌握“最大连续和”问题的各种算法及其时间复杂度分析;
(3)正确认识算法分析的优点和局限性,能正确使用分析结果;
(4)掌握归并排序和逆序对统计的分治算法;
(5)掌握归并排序和快速选择算法;
(6)熟练掌握二分查找算法,包括找上下界的算法;
(7)能用递归的方式思考和求解问题;
(8)熟练掌握用二分法求解非线性方程的方法;
(9)熟练掌握用二分法把优化问题转化为判定问题的方法;
(10)熟悉能用贪心法求解的各类经典的问题。
【教学要求】
理解渐近时间复杂度的概念和大O记号的含义;正确认识算法分析的优点和局限性,能正确使用分析结果;掌握归并排序和快速排序算法;熟练掌握二分查找算法;熟悉能用贪心法求解的各类经典的问题。
【教学内容提要】
本章介绍了设计高效算法的方法,首先介绍了分析算法效率的工具是渐近时间复杂度,并给出了大O记号的含义;接着介绍了分治法,用它去对数组进行归并排序或快速排序,以及查找过程中使用二分法;还介绍了贪心法求解问题。
【教学重点、难点】
教学重点:
(1)渐近时间复杂度的概念和大O记号的含义,并能对算法进行分析;
(2)掌握归并排序和快速排序算法;
(3)熟练掌握二分查找算法;
(4)熟悉能用贪心法求解的各类经典的问题。
教学难点:
(1)掌握归并排序和快速排序算法;
(2)熟练掌握二分查找算法;
(3)熟悉能用贪心法求解的各类经典的问题。
【课时安排】
8.1算法分析初步 8.2再谈排序与搜索 8.3递归与分治 8.4贪心法



8.1 算法分析初步

本节介绍算法分析的基本概念和方法,力求在编程之前尽量准确地估计程序的时空开销,并用出决策。

8.1.1 渐近时间复杂度

例8-1 最大连续和。

给出一个长度为n的序列A1,A2,…,An,求最大连续和。换句话说,要求找到1≤i≤j≤n,使得A1,A2,…,An尽量大。
【分析】
利用枚举思想,可得如下程序:

程序8-1 最大连续和

      tot = 0;
      best = A[1]; //初始最大值
      for(i = 1; i <= n; i++)
            for(j = i; j <= n; j++) { //检查连续子序列A[i],…,A[j]
                  int sum = 0;
                  for(k = i; k <= j; k++) { //累加元素
                        sum += A[k];
                        tot++;
                  }
                  if(sum > best) best = sum; //更新最大值
            }

注意:best的初值是A[1],不要写成best=0。对于本程序中,用tot主要是计算加法运算的次数,它是衡量算法的“工作量”,即“加法”操作的次数。
提示8-1:统计程序中“基本操作”的数量,可以排除机器速成度的影响,衡量算法本身的优劣程度。
可以将“加法操作”作为基本操作,也可以将其他四则运算、比较运算作为基本运算。下面来计算tot在一般情况的值,设输入规模为n时加法操作的次数为T(n),则:

可以用一个记号来表示:T(n)=Θ(n3),或者说:T(n)与n3同阶。
提示8-2:基本操作的数量往往可以写成关于“输入规模”的表达式,保留最大项并忽略系数后的简单表达式称为算法的渐近时间复杂度,它用于衡量算法中基本操作数随规模的增长情况。
提示8-3:渐近时间复杂度忽略了很多因素,因而分析结果只能作为参考,并不是精确的。尽管如此,如果成功抓住了最主要的运算量所在,算法分析的结果常常十分有用的。

8.1.2 上界分析

下面介绍另外一种推导方法:算法包含3重循环,内层最坏情况下需要循环n次,中层循环最坏情况下也需要n次,外层循环最坏情况下仍然需要n次,因此总运算次数不超过n3。这就是“上界分析。上界也有记号:T(n)=O(n3)。
提示8-4:在算法设计中,常常不进行精确分析,而是假定各种最坏情况同时取到,得到上界。在很多情况下,这个上界和实际情况同阶(称为“紧”的上界),但也有可能会因为分析方法不够好,得到“松”的上界。
下面来优化这个算法。设Si=A1+A2+…+Ai,则Ai+Ai+1+…+Aj=Sj-Si-1,它的含义是连续子序列之和等于两个前缀和之差。这样最内层的循环就可以省略。

程序8-2 最大连续和(2)

      S[0] = 0;
      for(i = 1; i <= n; i++) 
            S[i] = S[i-1] + A[i];
      for(i = 1; i <= n; i++)
            for(j = i; j <= n; j++) { //更新最大值
                  if(S[j] - S[i-1] >= best){
                  best = S[j] - S[i-1];
                  tot++;
            }
      }

注意:上面的程序用到了递推的思想:从小到大依次计算S[1],S[2],S[3]…,每个只需要在前一个的基础上加上一个元素。换句话说,“计算S”的步骤的时间复杂度为O(n)。接下来是一个二重循环,用类似的方法可以分析出:
同样地,用上界分析可以更快地得到结论:内层循环最坏情况下要执行n次,外层也是,因此时间复杂度为O(n2)。

8.1.3 分治法

分治法解决问题,一般分为如下3个步骤:
(1)划分问题:把问题的实例划分成子问题;
(2)递归问题:递归解决子问题;
(3)合并问题:合并子问题的解得到原问题的解。
在本例中,“划分”就是把序列分成元素个数尽量相等的两半;“递归求解”就是分别求出完全位于左半或完全位于右半的最佳序列;“合并”就是求出起点位于左半、终点位于右半的最大连续和序列,并和子问题的最优解比较。
关键在于“合并”步骤。既然起点位于左半,终点位于右半,可以人为地把这样的序列分成两部分,然后独立求解:先寻找最佳起点,然后再寻找最佳终点。

程序8-3 最大连续和(3)(如图8-1所示)

int maxsum(int* A, int x, int y) {
int i, m, v, L, R, max;
if(y - x == 1) return A[x]; //只有一个元素,直接返回
m = x + (y-x)/2; //分治第一步:划分成[x,m)和[m,y)
//分治第二步:递归求解
max = maxsum(A, x, m) > maxsum(A, m, y)? maxsum(A, x, m):maxsum(A, m, y);
v = 0; L = A[m-1]; //分治第三步:合并(1)—从分界点开始往左的最大连续和L
for(i = m-1; i >= x; i--) {
v += A[i];
if(v > L) L=v;
}
v = 0; R = A[m]; //分治第三步:合并(2)—从分界点开始往右的最大连续和R
for(i = m; i < y; i++){
v += A[i];
if(v > R) R=v;
}
if(L+R > max) max = L + R; //把子问题的解与L和R比较
return max;
}

图8-1 最大连续和的分治算法

注意:上面程序的L和R分别为从分界线往左、往右能达到的最大连续和。对于t(n)需要用递归的思路进行分析:设序列长度为n时的tot值为T(n),则T(n)=2T(n/2)+n,T(1)=1。其中2T(n/2)是两次长度为 n/2的递归调用,而最后的n是合并的时间(整个序列恰好扫描一遍)。
提示8-5:在算法分析中,往往可以忽略“除法结果是否为整数”,而直接按照实数除法分析。这样的近似对结果影响很小,一般不会改变渐近时间复杂度。
提示8-6:递归方程T(n)=2T(n/2)+n,T(1)=1的解为T(n)=Θ(nlogn)。

8.1.4 正确对待算法分析结果

对于“最大连续和”问题,先后介绍了时间复杂度O(n3)、O(n2)、O(nlogn)的算法,每个新算法较前一个算法来说,都有是重大的改进。尽管分治法看上去很巧妙,它并不是最高效的。把O(n2)算法稍作修改,便可以得到一个O(n)算法:当j确定时,“S[j]-S[i-1]最大”相当于“S[i-1]最小”,因此只需要扫描一次数组,维护“目前遇到过的最小S”即可。
把渐近时间复杂度为多项式的算法称为多项式时间算法(polymonial-time algorithm),也称为有效算法;而n!或者2n这样的低效的算法称为指数时间算法(exponential-time algorithm)。
不过需要注意的是,上界分析的结果在趋势上能反映算法的效率,但有两个不精确性:一是公式本身的精确性;二是对程序实现细节与计算机硬件的依赖性。

8.2 再谈排序与检索

8.2.1 归并排序

第一种高效排序算法是归并排序。按照分治三步法,对归并排序算法介绍如下:
(1)划分问题:把序列分成元素个数尽量相等的两半;
(2)递归问题:把两半元素分别排序;
(3)合并问题:把两个有序表合并成一个。
前两部分很容易完成的,关键在于如何把两个有序表合并成一个。图8-2演示了一个合并的过程。每次只需要把两个序列的最小元素加以比较,删除其中的较小元素并加入合并后的新表即可。由于需要一个新表来存放结果,所以附加空间n。

图8-2 合并过程:时间是线性的,需要线性的辅助空间
归并排序的代码如下:

程序8-4 归并排序(从小到大)

void merge_sort(int* A, int x, int y, int* T) {
    if(y-x > 1){
        int m = x + (y-x)/2; //划分
        int p = x, q = m, i = x;
        merge_sort(A, x, m, T); //递归求解
        merge_sort(A, m, y, T); //递归求解
        while(p < m || q < y) {
            if(q >= y || (p < m && A[p] <= A[q])) //从左半数组复制到临时空间
                T[i++] = A[p++];
            else //从右半数组复制到临时空间
                T[i++] = A[q++];
        }
        for(i = x; i < y; i++) A[i] = T[i]; //从辅助空间复制到A数组
    }
}

代码中的两个条件是关键。首先,只要有一个序列非空,就要继续合并(while(p<m||q<
y)),所以正确的方式是:
(1)如果第二个序列为空(此时第一个序列一定非空),复制A[p]。
(2)否则(第二个序列非空),当且仅当第一个序列也非空,且A[p]≤A[q]时,才复制A[p]。

例8-2 逆序对数。

给一列a1,a2,…,an,求它的逆序对数,即有多少个有序对(i,j),使得i<j但ai>aj。n可以高达106。
【分析】
受到归并排序的启发,可以用“分治三步法”来做:
(1)划分问题:把序列分成元素个数尽量相等的两半;
(2)递归问题:统计i和j均在左边或者均在右边的逆序对个数;
(3)合并问题:统计i在左边,但j在右边的逆序对个数。
解决本问题的关键在于合并:如何求出i在左边,而j在右边的逆对序对数目呢:按照j的不同把这些“跨越两边”的逆序对进行分类:只在对于右边的每个j,统计左边比它大的元素个数。对于合并操作是从小到大进行的,当右边的A[j]复制到T中时,左边还没有来得及复制到T的那些数就是左边所有比A[j]大的数,此时累加器中加上左边元素个数m-p即可(左边所剩的元素在区间[p,m]中,因此元素个数为m-p)。
完整的程序如下:

#include <stdio.h>
int A[] = {8,7,6,5,4,3};
int T[] = {0,0,0,0,0,0};

void inverse_pair(int* A, int x, int y, int* cnt, int* T) {
    if(y-x > 1){
        int m = x + (y-x)/2; //划分
        int p = x, q = m, i = x;
        inverse_pair(A, x, m, cnt, T); //递归求解
        inverse_pair(A, m, y, cnt, T); //递归求解
        while(p < m || q < y) {
            if(q >= y || (p < m && A[p] <= A[q])) /从左半数组复制到临时空间
                    T[i++] = A[p++];
            else { //从右半数组复制到临时空间
                T[i++] = A[q++];
                *cnt += m-p; //计算逆序对数
            }
        }
        for(i = x; i < y; i++) A[i] = T[i]; //从辅助空间复制到A数组
    }
}

int main(){
    int i, cnt = 0;;
    inverse_pair(A, 0, 6, &cnt, T);
    printf("%d\n", cnt);
    return 0;
}

8.2.2 快速排序

快速排序由Hoare于1962年提出,相对归并排序来说不仅速度更快,并且不需要辅助空间。按照分治三步法,将快速排序算法作如下介绍。
(1)划分问题:数组的各个元素重排后分成左右两部分,使得左边的任意元素都小于或等于右边的任意元素。
(2)递归问题:把左右两部分分别排序;
(3)合并问题:不用合并,因为此时数组已经完全有序。

例8-3 第k小的数。

输入n个整数和一个正整数(1≤k≤n),输入这些整数从小到大排序后的第k个(例如,k=1就是最小值)。n≤107。
【分析】
选第k小的数,最显然的方法是先排序,然后直接输出下标为k-1的元素。假设在快速排序的“划分”结束后,数组A[p..r]被分成了A[p..q]和A[q+1..r],则可以根据左边的元素个数q-p+1和k的大小关系只在左边或右边递归求解。可以证明,在期望意义下,程序的时间复杂度为O(n)。
完整的程序如下:

#include <iostream>
#include <cmath>
using namespace std;
const int N = 100;
int Partition(int a[N], int low, int high) //快速排序中的一次划分
{
    int i = low;
    int j = high + 1;
    int x = a[low];
    while(true){
        while(a[++i] < x);
        while(a[--j] > x);
        if( i>=j ) break;
        swap( a[i], a[j] );
    }
    a[low] = a[j];
    a[j] = x;
    return j;
}

int Select_k(int a[N], int low, int high, int k){
//对数组中low-high部分排序 
    int q = Partition(a,low,high);
    int pos = q-low+1; //下标从零开始,要判断k-1 与 high-low的关系 
    if(low==high) return a[low];
    if(k==pos) return a[q];
    else if(k < pos)
        return Select_k(a,low,q-1,k); //左部排序 
    else
        return Select_k(a,q+1,high,k-pos); //右部排序 
    return 0;
}

int main()
{
    int low,q,high,n,i,k,highes,a[N];
    while(cin>>n>>k){
        for( i=0; i<n; i++)
            cin>>a[i];
        cout<<"第"<<k<<"小数:"<<Select_k(a,0,n-1,k)<<endl;
    }
    return 0;
}

8.2.3 二分查找

排序的重要意义之一,就是为检索带来方便。如果先将数组排好序,就可以查找得更快。在有序表中查找元素常常使用二分查找(Binary Search),有时也译为“折半查找”。它的基本思想:
(1)先将升序(或降序)输入n个元素到一个数组中;
(2)设low指向数组的低端,high指向数组的高端,mid=(low+high)/2;
(3)测试mid所指的位置,是否是查找的元素;
(4)若mid所指的元素大于要查找值,表示被查找的元素在low和mid之间,否则,表示被查找的元素在mid和high之间。
(5)修改low或high的值,重新计算mid,继续查找。
提示8-7:逐步缩小范围法是一种常见的思维方法。二分查找便是基于这种思路,它遵循分治三步法,把原序列划分成元素个数尽量接近的两个子序列,然后递归查找。二分查找只适用于有序序列。
一般把二分查找写成非递归的形式,完整的程序如下:

程序8-5 二分查找(迭代实现)

#include <stdio.h>
#include <assert.h>

int A[] = {1,2,3,4,5,6,7,8,9,10,11};

int bsearch(int* A, int x, int y, int v) { //二分查找的非递归形式(迭代)
    int m;
    while(x < y) {
        m = x+(y-x)/2;
        if(A[m] == v) return m;
        else if(A[m] > v) y = m;
        else x = m+1;
    }
    return -1;
}

int main() {
    int i;
    for(i = 1; i <= 11; i++)
        assert(bsearch(A, 0, 11, i) == i-1);
    printf("Ok!\n");
    return 0;
}

提示8-8:二分查找一般写成非递归形式。
下面给出一个问题:如果数组中有多个元素都是v,现在要查找v,则上面函数的返回值什么都不是。若在原数组中存在多个元素v,查找时返回它出现的第一个位置。如果不存在,返回这样一个下标i:在此处插入v(原来的元素A[i]、A[i+1],…全部往后移动一个位置)后序列仍然有序。

程序8-6 二分查找求下界

int lower_bound(int* A, int x, int y, int v) {
    int m;
    while(x < y) {
        m = x+(y-x)/2;
        if(A[m]>=v) y=m;
        else x=m+1;
    }
    return x;
}

下面来分析这段程序。首先,最后的返回值不仅可能是x,x+1,x+2,…,y-1,还可能是y——如果v大于A[y-1],只能插入这里。尽管查找区间在左闭右开区间[x,y),返回值的候选区间却是闭区间[x,y]。A[m]和v的各种关系所带来的影响如下。
(1)A[m]=v:至少已经找到一个,而左边可能还有,因此区间变为[x,m]。
(2)A[m]>v:所求位置不可能在后面,但有可能是m,因此区间变为[x,m]。
(3)A[m]<v:m和前面都不可行,因此区间变为[m+1,y]。
合并一下:A[m]≥v时新区间为[x,m];A[m]<v时新区间为[m+1,y]。
类似地,可以写一个upper_bound程序,当v存在返回它出现的最后一个位置的后面一个位置。如果不存在,返回这样一个下标i:在此处插入v(原来的元素A[i]、A[i+1],…全部往后移动一个位置)后序列仍然有序。upper_bound程序如下:

int upper_bound(int* A, int x, int y, int v) {
    int m;
    while(x < y) {
        m = x+(y-x)/2;
        if(A[m]<=v) x=m+1;
        else y=m;
    }
    return x;
}

设lower_bound和upper_bound的返回值分别为L和R,则v出现的子序列为[L,R)。这个结论当v不在时成立:此时L=R,区间为空。

例8-4 范围统计。

给出n个整数xi和m个询问,对于每个询问(a,b),输出闭区间[a,b]内的整数xi的个数。
【分析】
下面考虑两个问题:
问题1:大于等于a的第一个元素的下标L等于a的lower_bound的值。如果所有元素都小于a,则L=n,相当于把不存在的元素看作无穷大。
问题2:小于等于b的最后一个元素的“下一个下标”R等于b的upper_bound的值。如果所有元素都大于b,则R=0,相当于把不存在的元素看作无穷大。
这样问题的答案就是区间[L,R]的长度。即R-L。另外,STL中已经包含了lower_bound和upper_bound,可以直接使用。
完整的程序如下:

提示8-9:用“上下界”函数求解范围统计问题的技巧非常有用,建议用心体会左闭右开区间的使用方法和上下界函数的实现细节。

#include <cstdio>
#include <algorithm> //STL算法的头文件,包含sort,lower_bound和upper_bound
using namespace std;
int v[10000];
int main(){
    int n, m, a, b;
    scanf("%d%d", &n, &m);
    for(int i = 0; i < n; i++) scanf("%d", &v[i]);
    sort(v, v+n); //从小到大排序
    for(int i = 0; i < m; i++){
        scanf("%d%d", &a, &b); //询问[a,b]内的整数个数
        printf("%d\n", upper_bound(v, v+n, b)-lower_bound(v, v+n, a));
    }
}

提示8-9:用“上下界”函数求解范围统计问题的技巧非常有用,建议用心体会左闭右开区间的使用方法和上下界函数的实现细节。

8.3 递归与分治

8.3.1 棋盘覆盖问题

有一个2k×2k个方格棋盘,恰有一个方格是灰色的,其他为白色,你的任务是用包含3个方格的L型骨牌覆盖所有白色方格。灰色方格不能被子覆盖,且任意一个白色方格不能同时被两个或更多骨牌覆盖。如图8-3所示为L型骨牌(三格板)的4种旋转方式。


①号 ②号 ③号 ④号
图8-3 L型骨牌
【分析】
给定一个2k×2k的特殊棋盘,问如何放置L型骨牌,去覆盖除了特殊方格以外的所有方格,且任何2个L型骨牌不得重叠。
易知,覆盖任意一个2k×2k的特殊棋盘,用到的骨牌数恰好为(4k-1)/3。
如果对k≥2的棋盘,直接考虑如何覆盖是比较复杂的,下面采用分治法解决棋盘覆盖。
(1)问题分解过程如下。
以k=2为例,用二分法进行分解,得到棋盘如图8-4所示,用双线划分的4个k=1的棋盘。但要注意这4个棋盘,并不都是与原问题相似且独立的子问题。因为当如图8-4所示的残缺方格在左上部时,第一个子问题与原问题相似,而右上角、左下角和右下角3个子棋盘(也就是图8-4中标识为2、3、4号子棋盘),并不是原问题的相似子问题,自然就不能独立求解了。当使用一个①号三格板(图中阴影)覆盖2、3、4号3个子棋盘的各一个方格后,如图8-4(b)所示,把覆盖后的方格,也看作是残缺方格(称为“伪”缺方格),这时的2、3、4号子问题就是与原问题相似且独立的子问题了。

(a) (b)
图8-4 一个4×4的残缺棋盘
从以上例子还可以发现,当残缺方格在第一个子棋盘,用①号三格板覆盖其余3个子棋盘的交界方格,可以使另外3个子棋盘转化为可独立求解的子问题;同样地(如图8-5所示),当残缺方格在第二个子棋盘时,则首先用②号三格板进行棋盘覆盖,当残缺方格在第三个子棋盘时,则首先用③号三格板进行棋盘覆盖,当残缺方格在第四个子棋盘时,则首先用④三格板进行棋盘覆盖,这样就使另外3个子棋盘转化为可独立求解的子问题。
同样地k=1,2,3,4,…都是如此,k=1为停止条件。

(a) (b)
图8-5 其他4×4的残缺棋盘
(2)棋盘的识别
首先,子棋盘的规模是一个必要的信息,有了这个信息,只要知道左上角的方格在原棋盘中的行、列号就可以标识这个子棋盘了;其次子棋盘中残缺方格或“伪”残缺方格直接用它们在原棋盘中的行、列号标识。
①tr表示棋盘左上角方格的行号;
②tc表示棋盘左上角方格的列号
③dr表示特殊方格所在的行号
④dc表示特殊方格所在的列号,
⑤size表示方形棋盘行数或列数。
(3)数据结构设计
用二维数组Board[][]来模拟棋盘,Board[1][1]是棋盘的左上角方格。title表示L型骨牌的编号,其初始值为0。覆盖残缺棋盘所需要的三格板数目为:(size2-1)/3。将这些三格板编号为1到(size2-1)/3,则将覆盖残缺棋盘的三格板编号存储在数组Board的对应位置,这样输出数组内容就是问题的解。结合图8-5所示,不难理解程序。如果是一个4 ×4的棋盘,特殊方格为(2,1),那么程序的输出为对于如图8-6(a)所示的棋盘,其结果为8-6(b)所示的棋盘。其中特殊方格为0,相同数字的为同一骨牌。

(a) (b)
图8-6 一个4×4的残缺棋盘及其解
完整的程序如下:

#include <iostream>
using namespace std;
const int N = 11;
int Board[N][N];
int tile = 0;

//tr表示棋盘左上角方格的行号,tc表示棋盘左上角方格的列号,
//dr表示特殊方格所在的行号,dc表示特殊方格所在的列号,size表示方形棋盘边长。
//title表示L型骨牌的编号,其初始值为0
void ChessBoard(int tr, int tc, int dr, int dc, int size)
{
    if(size == 1)
        return;
    int t = ++tile, s = size/2;
//覆盖左上角子棋盘
    if(dr<tr+s && dc<tc+s)
//特殊方格在此棋盘中
        ChessBoard(tr, tc, dr, dc, s);
    else { // 此棋盘无特殊方格
// 用t号L型骨型牌覆盖右下角
        Board[tr+s-1][tc+s-1] = t;
// 覆盖其余方格
        ChessBoard(tr, tc, tr+s-1, tc+s-1, s);
    }
//覆盖右上角子棋盘
    if(dr<tr+s && dc>=tc+s)
        ChessBoard(tr, tc+s, dr, dc, s);
    else {
        Board[tr+s-1][tc+s] = t;
        ChessBoard(tr, tc+s, tr+s-1, tc+s, s);
    }
//覆盖左下角子棋盘
    if(dr>=tr+s && dc<tc+s)
        ChessBoard(tr+s, tc, dr, dc, s);
    else {
        Board[tr+s][tc+s-1] = t;
        ChessBoard(tr+s, tc, tr+s, tc+s-1, s);
    }
//覆盖右下角子棋盘
    if(dr>=tr+s && dc>=tc+s)
        ChessBoard(tr+s, tc+s, dr, dc, s);
    else {
        Board[tr+s][tc+s] = t;
        ChessBoard(tr+s, tc+s, tr+s, tc+s, s);
    }
}

void DisplayBoard(int size){
    for(int i=1; i<=size; ++i) {
        for(int j=1; j<=size; ++j)
            printf("%2d ", Board[i][j]);
        printf("\n");
    }
}

int main()
{
    ChessBoard(1, 1, 2, 1, 4);
    DisplayBoard(4);
    return 0;
}

8.3.2 循环日程表问题

设有n=2k个运动员要进行网球循环赛。需要设计比赛日程表。每个选手必须与其他n-1个选手个比赛一次;每个选手一天只能赛一次;循环赛一共进行n-1天。按此要求设计一张比赛日程表,它有n行和n-1列,第i行第j列为第i个选手在第j天遇到的选手。
【分析】
本题方法有很多,递归是其中一种比较容易理解的方法。图8-7所示是k=3时的一个可行解,它是4块拼起来的。左上角是k=2时的一组解,左下角是左上角每个数加4得到,而右上角、右下角分别由左下角、左上角复制得到的。

图8-7 循环日程表问题k=3时的解
完整的程序如下:

#include <iostream>
using namespace std;
int table[100][100];

void Creattable(int r1,int c1,int r2,int c2,int size){
    int i,j; int halfsize=size/2;
    if(size>1) //递归创建左上角的赛程表
        Creattable(0,0,halfsize,halfsize,halfsize);
    else table[0][0]=1;
    for(i=0;i<size;i++)
        for(j=0;j<size;j++) { //右上角的赛程是左上角的赛程加上halfsize
            if(i<halfsize && (j>=halfsize && j<size))
                //左下角的赛程和右上角的赛程相同
                table[i][j]=table[i][j-halfsize]+halfsize;
            if((i>=halfsize && i<size) && j<halfsize)
                //右下角的赛程和左上角的赛程相同
                table[i][j]=table[i-halfsize][j+halfsize];
            if((i>=halfsize && i<size) && (j>=halfsize && j<size))
                table[i][j]=table[i-halfsize][j-halfsize];
        }
}

int main(){
    int i,j,k,n=1;
    cin>>k; //输入k
    for(i=1;i<=k;i++)
        n=n*2; //计算n=2k
    Creattable(0,0,n,n,n);
    for(i=0;i<n;i++) {
        cout<<"运动员"<<table[i][0]<<"的每日赛程: ";
        for(j=1;j<n;j++)
            cout<<table[i][j]<<" ";
        cout<<endl;
    }
    return 0;
}

8.3.3 巨人与鬼

在平面上有n个巨人和n个鬼,没有三者在同一条直线上。每个巨人需要选择一个不同的鬼,向其发送质子流消灭它。质子流由巨人发射,沿直线行进,遇到鬼后消失。由于质子流交叉是很危险的,所有质子流经过的线段不能有交点。请设计一种给巨人和鬼配对的方法。
【分析】
由于只需要任意一种配对方法,从直观上来说本题一定是有解的。由于每一个巨人和鬼都需要找一个目标,我们不妨先给“最特殊”的巨人或鬼找“搭档”。
考虑y坐标最小的点(即最低点)。如果有多个这样的点,考虑最左边的点(即其中最左边的点),则所有点的极角在范围[0,π)内。不妨设它是一个巨人,然后把所有其他点按照极角从小到大排序。
(1)如果第一个点是鬼,那么配对完成,剩下的巨人和鬼仍然是一样多,而且肯定不会和这一条线段交叉,如图8-8(a)所示。
(2)如果第一个点是巨人,那么继续检查,直到已检查的点中鬼和巨人一样多为止。找到这个“鬼和巨人”配对区间后,只需要把区间内的点配对,再把区间外的点配对即可,如图8-8(b)所示。这个配对过程是递归的,好比棋盘覆盖中一样。不会找不到这样的配对区间,因为检查完第一个点后鬼少一个,而检查完最后一个点时鬼多,而巨人和鬼的数量差每次只能改变1。因此“从少到多”的过程中一定会有“一样多”的时候。
完整的程序如下:

#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <algorithm>
using namespace std;
const int INF = 1<<30;
const int MAX = 20;
struct node{
    int x;
    int y;
    int k;
};
node A[MAX];
int cur_p;
bool cmp(node a, node b)
{
    double ka, kb; //斜率
    int dax = a.x - A[cur_p].x;
    int day = a.y - A[cur_p].y;
    if(dax == 0) ka = INF;
    else ka = 1.0*day/dax;
    int dbx = b.x - A[cur_p].x;
    int dby = b.y - A[cur_p].y;
    if(dbx == 0) kb = INF;
    else kb = 1.0*dby/dbx;
    if(ka*kb < 0) { // 斜率1正1负
        return ka > kb;
    } else {
        return ka < kb;
    }
}
 
void dfs(int st, int ed)
{
    int n = ed-st;
    if(n==2) {
        printf("%s(%d,%d) match %s(%d,%d)\n", A[st].k==0?"巨人":"鬼", A[st].x, A[st].y,
                A[st+1].k==0?"巨人":"鬼", A[st+1].x, A[st+1].y);
        return;
    }
    int m = st;
    for(int i=st+1; i<ed; i++) {
        if(A[i].y<A[m].y ||
         (A[i].y==A[m].y && A[i].x<A[m].x)) m = i;
    }
    cur_p = ed-1;
    swap(A[m], A[cur_p]);
    sort(A+st, A+cur_p, cmp);
    if(A[st].k != A[cur_p].k) {
        swap(A[st], A[cur_p-1]);
        dfs(cur_p-1, ed);
        dfs(st, cur_p-1);
    } else {
        int c[2] = {0};
        for(int i=st; i<ed; i++) {
            c[A[i].k]++;
            if(c[0] == c[1]) break;
        }
        dfs(st, st+c[0]*2);
        dfs(st+c[0]*2, ed);
    }
}
 
int main(){
    #ifndef ONLINE_JUDGE
    freopen("in.txt", "r", stdin);
    #endif
    int T;
    scanf("%d", &T);
    int n;
    while(T--) {
        scanf("%d", &n);
        for(int i=0; i<n; i++) {
            scanf("%d%d%d", &A[i].k, &A[i].x, &A[i].y);
        }
        dfs(0, n);
        printf("\n");
    }
    return 0;
}

8.3.4 非线性方程求根

一次向银行借a元钱,分b月还清。如果需要每个月还c元,月利率是多少?(按复利计算)?例如借2000元,分4个月共还510元,则月利率为0.797%。答案应不超过100%。
【分析】
设月利率为x,则第一个月还钱后还需要还a(1+x)-c元。重复b个月后可以得到一个方程,解出x即可。例如a=2000,b=4,c=510时,方程为:
f(x)=(((2000(1+x)-c)(1+x)-c)(1+x)-c)(1+x)-c=0
注意到f(x)在x∈[0,100]内关于是单调递增的,因此f(x)和0的关系与x和方程的解x0的大小关系等价。可以采用二分法来解决此问题。
完整的程序如下:

#include<stdio.h>
int main() {
    double a, c, x = 0, y = 100;
    int i, b;
    scanf("%lf%d%lf", &a, &b, &c);
    while(y-x > 1e-5) {
        double m = x+(y-x)/2;
        double f = a;
        for(i = 0; i < b; i++) f += f*m/100.0-c;
        if(f < 0) x=m; else y=m;
    }
    printf("%.3lf%%\n", x);
    return 0;
}

8.3.5 最大值最小化

把一个包含n个正整数的序列划分成m个连续的子序列(每个正整数恰好属于一个序列)。设i个序列的各数之和为S(i),你的任务是让所有S(i)的最大值尽量小。例如序列1 2 3 2 5 4划分成3个序列的最优方案为1 2 3|2 5|4,其中S(1)、S(2)、S(3)分别为6、7、4,最大值为7;如果划分成1 2|3 2|5 4,最大值为9,不如刚才好。n≤106。所有数之和不超过109。
【分析】
“最大值尽量小”是一种很常见的优化目标。对于这个问题:能否把输入序列划分成m个连续的子序列,使得所有S(i)均不超过x?此问题的答案用P(x)表示,则让P(x)为真的最小x就是原题的答案。对P(x)计算,每次尽量往右划分即可。
二分最小值x,把优化问题转化为判定问题P(x)。设所有数之和为M,则二分数为O(logM),计算P(x)的时间复杂度为O(n)(从左到右扫描一次即可),因此总时间复杂度为O(nlogM)。
完整的程序如下:

8.3.5 最大值最小化
        把一个包含n个正整数的序列划分成m个连续的子序列(每个正整数恰好属于一个序列)。设i个序列的各数之和为S(i),你的任务是让所有S(i)的最大值尽量小。例如序列1 2 3 2 5 4划分成3个序列的最优方案为1 2 3|2 5|4,其中S(1)、S(2)、S(3)分别为6、7、4,最大值为7;如果划分成1 2|3 2|5 4,最大值为9,不如刚才好。n≤106。所有数之和不超过109。
【分析】
“最大值尽量小”是一种很常见的优化目标。对于这个问题:能否把输入序列划分成m个连续的子序列,使得所有S(i)均不超过x?此问题的答案用P(x)表示,则让P(x)为真的最小x就是原题的答案。对P(x)计算,每次尽量往右划分即可。
二分最小值x,把优化问题转化为判定问题P(x)。设所有数之和为M,则二分数为O(logM),计算P(x)的时间复杂度为O(n)(从左到右扫描一次即可),因此总时间复杂度为O(nlogM)。
完整的程序如下:
#include <iostream>
#include <ctime>
using namespace std;
#define N 10
#define INF 1000

int juge(int a[],int mid,int k)
{
    int i;
    int seg=0;
    int sum=0;
    for(i=0;i<N;i++)
    {
        sum+=a[i];
        if(sum>mid) {
//从左到右将数组元素之和与mid比较,如是大于则再起一段,最后看段的大小
            sum=a[i];
            seg++;
        }
    }
    if(seg>=k) //若是段超过3,则必然不和条件
        return 0;
    else
        return 1;
}

int value(int a[],int low,int high,int segment) //分治法求解
{
    if(low>high)
        return high+1;
    else
    {
        int mid=(low+high)/2;
        if(juge(a,mid,segment)==1) //如果试验数mid符合要求,递归到前一半
            return value(a,low,mid-1,segment);
        else //如果试验数mid不符合要求,递归到后一半
            return value(a,mid+1,high,segment);
    }
}

int main()
{
    srand((unsigned)time(NULL));
    int a[N];
    for(int ifor=0;ifor<N;ifor++)
        a[ifor]=rand()%20;
    for(int ifor=0;ifor<N;ifor++)
        cout<<a[ifor]<<" ";
    //int a[N]={9,19,15,13,13,9,14,1,1,7};
    int m=3;
    cout<<endl;
    //求出队列中所有数的和max,还要求出当中最小的数min
    int min=INF,max=0;
    for(int i=0;i<N && a[i]!=' ';i++)
    {
        max+=a[i];
        if(a[i]<min)
            min=a[i];
    }
    cout<<endl;
    int tem=value(a,min,max,m); //调用value函数求值
    cout<<tem<<endl;

    return 0;
}

8.4 贪 心 法

所谓贪心法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。
贪心算法不是对所有问题都能得到整体最优解,但对范围相当广泛的许多问题他能产生整体最优解或者是整体最优解的近似解。
贪心算法的基本思路如下:
(1)建立数学模型来描述问题;
(2)把求解的问题分成若干个子问题;
(3)对每一子问题求解,得到子问题的局部最优解;
(4)把子问题的解局部最优解合成原来解问题的一个解。
利用贪心策略解题,需要解决两个问题:
(1)确定问题是否能用贪心策略求解
一般来说,适用于贪心策略求解的问题具有以下特点:
① 可通过局部的贪心选择来达到问题的全局最优解。运用贪心策略解题,一般来说需要一步步的进行多次的贪心选择。在经过一次贪心选择之后,原问题将变成一个相似的,但规模更小的问题,而后的每一步都是当前看似最佳的选择,且每一个选择都仅做一次。
② 原问题的最优解包含子问题的最优解,即问题具有最优子结构的性质。在背包问题中,第一次选择单位质量最大的货物,它是第一个子问题的最优解,第二次选择剩下的货物中单位重量价值最大的货物,同样是第二个子问题的最优解,依次类推。
(2)如何选择一个贪心标准?
正确的贪心标准可以得到问题的最优解,在确定采用贪心策略解决问题时,不能随意的判断贪心标准是否正确,尤其不要被表面上看似正确的贪心标准所迷惑。在得出贪心标准之后应给予严格的数学证明。

8.4.1 最优装载问题

给出n个物体, 第i个物体重量为wi。选择尽量多的物体, 使得总重量不超过C。
【分析】
由于目标是物体的“数量”尽量多,所以装重的没有装轻的划算。只需把所有物体按重量从小到大排序,依次选择每个物体,直到装不下为止。这就是一种贪心法,因为每次都是选择能装下的最轻的物体,是一种“只顾眼前”的策略,这样的策略却能保证得到最优解。
完整的程序如下:

#include <stdio.h>
#define N 100
int x[N],w[N],t[N];

void Sort(int w[],int t[],int n) {
    //将重量数组w从小到大排序,数组t[i]表示数组w第i小的元素在数组w中的下标 
    int i,j,temp,k,w1[N];
    for(i=1;i<=n;i++) //设辅助数组w1的作用是用于排序,原数组w元素不移动
        w1[i]=w[i];
    for(i=1;i<=n;i++) //初始化数组t,记下原重量数组w中每个元素的位置
        t[i]=i;
    for(i=1;i<n;i++){ //用选择排序法进行排序
        k=i;
        for(j=i+1;j<=n;j++)
            if(w1[k]>w1[j]) k=j;
        if(k!=j) { //将数组w中的元素进行交换,同时数组t中元素也交换
            //排序完成后,t[i]应为重量第i小的物体在原重量数组w所处的位置(下标)
            temp=t[i];
            t[i]=t[k];
            t[k]=temp;
            temp=w1[i];
            w1[i]=w[k];
            w1[k]=temp;
        }
    }
}

void Loading(int x[],int w[], int c,int n) {
    int i;
    Sort(w,t,n); //对数组w进行排序,数组t记下数组w中元素的大小关系
    for(i=1;i<=n;i++) x[i]=0; //初始化数组x
    for(i=1;i<=n && w[t[i]]<=c;i++)	{ //贪心选择
        //数组x表示是否选择物体的状态,x[t[i]]=1表示选择重量第i小的物体
        x[t[i]]=1;
        c-=w[t[i]];
    }
}

int main(){
    int i,n,c;
    int max=0;
    while(scanf("%d%d",&n,&c)!=EOF) //输入物体数n,总重量c
    {
        for(i=1;i<=n;i++) //输入重量数组w的元素
            scanf("%d",&w[i]);
        Loading(x,w,c,n);
        printf("选择的物体为:\n");
        for(i=1;i<=n;i++) {
            //当x[i]=1选择数组w中第i个物体的w[i](重量);当x[i]=0不选择
            printf("%3d",x[i]);
            max+=w[i]*x[i]; //计算最大总重量max
        }
        printf("\n");
        printf("选择物体的最大总重量为:%d\n",max);
    }
}

运行结果:
输入
20 200
125 89 142 65 298 100 150 86 88 42 55 16 129 238 45 110 217 168 180 80
输出结果
选择的物体为:
1 1 1 1 0 1 1 1 1 1 1 1 1 0 1 1 0 1 1 1
选择物体的最大总重量为:1887
说明:上面输出的一串0和1数字是数组x中的值,为1表示选择重量数组w对应位置的物体,否则不选择。

8.4.2 部分背包问题

有n个物体, 第i个物体的重量为wi, 价值为vi, 在总重量不超过C的情况下让总价值尽量高。每一个物体可以只取走一部分,价值和重量按比例计算。
【分析】
本题在上一题的基础上增加了价值,所以不能简单的像上题那样先拿轻的(轻的可能价值也小),也不能先拿价值大的(可能它特别重),而应该综合考虑两个因素。一种直观的贪心策略产生:优先拿“价值/重量比”最大的,直到重量和正好为C。由
于每个物体可以只拿一部分,因此一定可以让总重量恰好为C(或者全部拿走重量也不足C),而且除了最后一个以外,所有的物体要么不拿,要么拿走全部。
完整的程序如下:

#include <stdio.h>
#include <iostream>
#include<stdlib.h>
#define MAXSIZE 100 //假设物体总数
#define M 15 //背包的载荷能力
using namespace std;

//算法核心,贪心算法
void GREEDY(float w[], float x[], int sortResult[], int n)
{
    float c = M;
    int i = 0;
    int temp = 0;
    for (i = 0; i < n; i++) //准备输出结果
        x[i] = 0;
    for (i = 0; i < n; i++) {
        for(int j=0;j<n;j++)
            if(sortResult[j]==i+1) {
                temp = j;//得到取物体的顺序
                break;
            }
        if (w[temp] > c) {
            break;
        }
        x[temp] = 1;//若合适则取出
        c -= w[temp];//将容量相应的改变
    }
    if (i <= n)//使背包充满
        x[temp] = c / w[temp]; //取某件物品的一部分
    return;
}

void sort(float x[], int sortResult[], int n)
{
    int i = 0, j = 0;
    int index = 0, k = 0;
    for (i = 0; i < n; i++)//对映射数组赋初值0
        sortResult[i] = 0;
    for (i = 0; i < n; i++) {
        float temp = 0;
        index = i;
//找到性价比最高的商品,并保存下标
        for (j = 0; j < n; j++) {
            if ((temp < x[j]) && (sortResult[j] == 0)) {
                temp = x[j];
                index = j;
            }
        }
//对w[i]作标记
        if (sortResult[index] == 0)
            sortResult[index] = ++k;
    }
    cout<<"映射数组sortResult:"<<endl;
    for (i = 0; i < n; i++)
        cout<<sortResult[i]<<" ";
    return;
}

//得到本算法的所有输入信息
void getData(float p[], float w[], int *n){
    int i = 0;
    printf("please input the total count of object: ");
    scanf("%d", n);
    printf("Please input array of p :\n");
    for (i = 0; i < (*n); i++)
        scanf("%f", &p[i]);
    printf("Now please input array of w :\n");
    for (i = 0; i < (*n); i++)
        scanf("%f", &w[i]);
    return;
}

void output(float x[], int n)
{
    int i;
    printf("\n\nafter arithmetic data: advise method\n");
    for (i = 0; i < n; i++)
        printf("x[%d]\t", i);
    printf("\n");
    for (i = 0; i < n; i++)
        printf("%2.3f\t", x[i]);
    return;
}

int main()
{
    float p[MAXSIZE], w[MAXSIZE], x[MAXSIZE];
    int i = 0, n = 0;
    int sortResult[MAXSIZE];
    getData(p, w, &n); //获取数据
    for (i = 0; i < n; i++)
        x[i] = p[i] / w[i]; //得到每件物品的单位重量的价值
    sort(x, sortResult, n); //得到映射数组,数组中按照物品单位重量的价值从大到小的顺序做了标记,方便取物品
    GREEDY(w,x,sortResult,n); //按照映射数组标记的顺序取物品,和总重量比较
    output(x, n);
    return 0;
}

8.4.3 乘船问题

有n个人, 第i个人重量为wi。每艘船的载重量均为C, 最多乘两个人。用最少的船装载所有人。
【分析】
考虑最轻的人i,他应该和谁一起坐呢?如果和每个人都无法一起坐同一艘船,则唯一的方案就是每人坐一艘船。否则选择能和i一起坐船的人中最重的一个j。这样的方法是贪心的,因此它只是让“眼前”船的浪费尽量少。这个贪心策略是对的,可以用反证法说明。
假设这样做不是最好的,考虑最好方案中i是什么样的。
情况1:i不和任何一个人坐同一艘船,那么可以把j拉过来和他一起坐,总船数不会增加(且可能会减少!),并且符合刚才的贪心策略。
情况2:i和另外一人k同船,由贪心策略,k比j轻。把j和k交换后k原来所在的船仍然不会超重(因为j比k轻),而i和k所在的船也不会超重(由贪心法过程),因此所得到的新解不会更差,且符合贪心策略。
综上所述,虽然可能不采取贪心策略也能得到最优解,但是只考虑贪心策略肯定不会丢失最优解。贪心法往往容易实现。在本题中,只需每次寻找最小值和能与它同船的最大值配对即可。
完整的程序如下:

#include <iostream>
#include <ctime>
#include <algorithm>
using namespace std;
#define N 20 //人数
#define C 120 //船的承重
static int bcount=0;

void boat_num(int weight[],int left,int right) {
    int first,j;
    for(int i=0;i<=right;i++) {
        first=weight[i]; //第一个人的重量
        for(j=i+1;j<=right;j++)
            if(weight[j]>C-first) {
//如果第j个人的重量加上第一个人的重量大于C,那么第j个人和他后面的人都必须一个人一只船 
                cout<<"从第"<<j<<"到"<<right<<"的人需要独自乘船 ";
                bcount+=right-j+1; //计算一个人一只船的人数
                cout<<bcount<<endl;
                break;
            }
//第j-1个人和第一个人的重量之和是可承受范围的最大值,符合贪心算法
        cout<<"第" <<i<<"个人和第"<<j-1<<"个人同船 ";
        bcount++;
        cout<<bcount<<endl;
        right=j-2; //将right置为j-2,进行下次迭代。
    }
}

int main()
{
    int weight[N];
    srand((unsigned)time(NULL));
    for(int i=0;i<N;i++)
        weight[i]=40+rand()%60;
    sort(weight,weight+N); //将人的重量从小到大排好序
    for(i=0;i<N;i++) {
        if(i>0 && 0==i%10)
            cout<<endl;
        cout<<weight[i]<<" ";
    }
    cout<<endl;
    boat_num(weight,0,N-1); //求需要的船数
    cout<<"一共需要"<<bcount<<"条船"<<endl;
    return 0;
}

下面讨论几个具有一定相似之处的问题。它们都和数轴上的线段或区间有关。

8.4.4 选择不相交区间

数轴上有n个开区间(ai,bi),选择尽量多个区间,使得这些区间两两没有公共点。
【分析】
首先明确一个问题:如果有两个区间x, y,区间x完全包含y。那么显然选x是不划算的,因为x和y最多只能选一个,选x还不如选y,这样不仅区间数目不会减少,而且给其他区间留出了更多的位置。这样,我们按照bi从小到大的顺序给区间排序。贪心策略是:一定要选第一个区间。
现在区间已经排序成b1≤b2≤b3≤…,考虑a1和a2的大小关系。
情况1:a1>a2,如图8-9(a)所示,区间2包含区间1。前面已经讨论过,这种情况下一定不会选择区间2。不仅区间2如此,以后所有区间中只要有一个i满足a1>ai,i都不要选。在今后的讨论中,我们不考虑这些区间。
情况2:排除了情况1,一定有a1≤a2≤a3≤…,如图8-9(b)所示。如果区间2和区间1完全不相交,那么没有影响(因此一定要选区间1),否则区间1和区间2最多只能选一个。注意到如果不选区间2,黑色部分其实是没有任何影响的(它不会挡住任何一个区间),区间1的有效部分其实变成了灰色部分,它被区间2所包含!由刚才的结论,区间2是不能选的。以此类推,不能因为选任何区间而放弃区间1,因此选择区间1是明智的。
% &
(a) a1>a2 (b) a1<a2<a3
图8-9 贪心策略图示
选择了区间一以后,需要把所有和区间一相交的区间排除在外,需要记录上一个被选择的区间编号。这样,在排序后只需要扫描一次即可完成贪心过程,得到正确结果。

#include <stdio.h>
#include <string.h>
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
const int maxn = 1111;
int n;
int a[maxn]; 

struct node{
	int x;
	int y;
	int num;
}q[maxn];
bool cmp(const node &a, const node &b)
{
	return a.y < b.y;
}
int main()
{
	while (scanf("%d", &n) != EOF)
	{
		for (int i = 0; i < n; i++)
		{
			scanf("%d%d", &q[i].x, &q[i].y);
			q[i].num = i + 1;
		}
		sort(q, q + n, cmp);
		int cnt = 0;
		int flag_x = q[0].x;
		int flag_y = q[0].y;
		int i = 1;
		a[cnt++] = q[0].num;
		int a[maxn];
		while (i < n)
		{
			if (flag_y < q[i].x)
			{
				a[cnt++] = i;
				flag_x = q[i].x;
				flag_y = q[i].y;
			}
			i++;
		}
		for (int i = 0; i < cnt; i++)
		{
			printf("Num : %d --- x = %d --- y = %d\n", i + 1, q[a[i]].x, q[a[i]].y);
		}		
	}
	system("pause");
	return 0;
}

8.4.5 区间选点问题

数轴上有n个闭区间[ai,bi]。取尽量少的点,使得每个区间内都至少有一个点(不同区间内含的点可以是同一个)。
【分析】
如果区间i内已经有一个点被取到,我们称此区间已经被满足。受到上一题的启发,我们先讨论区间包含的情况。由于小区间被满足时大区间一定也被满足。所以在区间包含的情况下,大区间不需要考虑。
把所有区间按b从小到大排序(b相同时a从大到小排序),则如果出现区间包含的情况,小区间一定排在前面。第一个区间应该取哪一个点?贪心策略是:取最后一个点,如图8-10所示。
根据刚才的讨论,所有需要考虑的区间的a也是递增的,可以把它画成上图的形式,如果第一个区间不取最后的一个,而是取中间的,如灰色点,那么把它移动到最后一个点后,被满足的区间增加了,而且原先满足的区间现在一定被满足。不难看出,这样的贪心策略是正确的。
图8-10 贪心策略

#include <stdio.h>
#include <string.h>
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
const int maxn = 1111;
int n;
struct node{
	int x;
	int y;
}q[maxn], temp;
 
bool cmp(const node &a, const node &b)
{
	return a.y < b.y;
}
 
int main()
{
	while (scanf("%d", &n) != EOF)
	{
		for (int i = 0; i < n; i++)
		{
			scanf("%d%d", &q[i].x, &q[i].y);
		}
		sort(q, q + n, cmp);
		int i = 1;
		int cnt = 1;
		temp.x = q[0].x;
		temp.y = q[0].y;
		while (i < n)
		{
			if (temp.x > q[i].x)
			{
				temp.x = q[i].x;
			}
			if (temp.y < q[i].x)
			{
				cnt++;
				temp.x = q[i].x;
				temp.y = q[i].y;
			}
			i++;
		}
		printf("%d\n", cnt);
	}
	system("pause");
	return 0;
}

8.4.6 区间覆盖问题

数轴上有n个闭区间[ai,bi],选择尽量少的区间覆盖一条指定线段[s,t]。
【分析】
本题的突破口仍然是区间包含和排序扫描,不过先要进行一次预处理。每个区间在[s,t]外的部分都应该预先被切掉,因为它们的存在是毫无意义的。在预处理后,在相互包含的情况下,小区间显然不应该考虑。
把各区间按照a从小到大排序。如果区间1的起点不是s,无解(因为其他区间的起点更大,不可能覆盖到s点),否则选择起点在s的最长区间。选择此区间后[ai,bi],新的起点应该设置为bi,并且忽略所有区间在bi之前的部分,就像预处理一样。虽然贪心策略比上题复杂,但是仍然只需要一次扫描,如图8-11所示。s为当前有效起点(此前部分已被覆盖),则应该选择区间2。
)
图8-11 区间覆盖问题

8.4.7 Huffman编码

本小节介绍的Huffman问题同时具有理论和实用价值,它可以用来进行文件压缩。假设某个文件只有6种字符:a,b,c,d,e,f,可以用3个位来表示,如表8-1所示(以下3个表中,频率的单位均为“千次”)。
图8-1 各种字符的编码
*
这样,一共需要(45+13+12+16+9+5)3=300千特(即二进制的位)。第二种方法是采用变长编码,如表8-2所示。
图8-2 变长编码举例
+
总长度为1
45+313+312+316+49+45=224千比特,比定长码省。是否还可以有更省的?如表8-3所示。
图8-3 错误的变长码举例
,
总长度只有1
(45+13)+2*(12+16+9+5))=142千比特。这样的编码是有问题的。如果收到了001,到底是aab还是cb还是ad?这样码有歧义,歧义产生的原因是其中一个字符的编码是另一个码的前缀(prex) 。表8-3的码没有这样的情况,任一个编码都不是另一个的前缀。我们把满足这样性质的编码称为前缀码(prexcode) 。这样,可以正式的叙述编码问题。

例8-5 编码问题。

给出n个字符的频率ci,给每个字符赋予一个01编码串,使得任一个字符的编码不是另一个字符编码的前缀,而且编码后总长度(每个字符的频率与编码长度乘积的总和)尽量小。
【分析】
在解决这个问题之前,首先来看一个结论:任何一个前缀编码都可以表示成所有非叶结点都恰好有两个儿子的二叉树。如图8-12所示,每个非叶结点与左儿子的边上写1,与右儿子的边上写0。

图8-12 前缀码的二叉树表示
每个叶子对应一个字符,编码为从根到该叶子的路径上的01序列。在上图中,N的编码为001,而E的编码为11。为了证明一般情形,我们需要说明两件事情。
结论1:n个叶子的二叉树一定对应一个前缀码。如果编码a为编码b的前缀,则a所对应的结点一定为b所对应结点的祖先。而两个叶子不会有祖先后代的关系。
结论2:最优前缀码一定可以写成二叉树。逐一个字符构造即可。每拿到一个编码,都可以构造出从根到叶子的一条路径,沿着已有结点走,创建不存在的结点。这样得到的二叉树不可能有单儿子结点。因为如果存在,只要用这个儿子代替父亲,得到的仍然是前缀码,且总长度更短。
接下来的问题变为:如何构造一棵最优的编码树。
Huffman算法把每个字符看作一个单结点子树放在一个树集合中,每棵子树的权值等于相应字符的频率。每次取权值最小的两棵子树合并成一棵新树,并重新放到树集合中。新树的权值等于两棵子树权值之和。
下面分两步证明Huffman算法的正确性。
结论1:设x和y是频率最小的两个字符,则存在前缀码使得x和y具有相同码长且仅有最后一位编码不同。换句话说,第一步贪心选择一定保留最优解。
证明:假设深度最大的结点为a,则a一定有一个兄弟b。不妨设f(x)≤f(y), f(a)≤f(b),则f(x)≤f(a), f(y)≤f(b)。如果x不是a,把x和a交换;如果y不是b,把y和b交换。这样得到的新编码树不会比原来的差。
结论2:设T是加权字符集C的最优编码树,x和y是树T中两个叶子,且互为兄弟,z是它们的父亲。若z看成是具有频率f(z)=f(x)+f(z)的字符,则树T'=T-{x,y}是字符集C' =C-{x,y}∪{z}的一棵最优编码树。换句话说,原问题的最优解包含子问题的最优解。
证明:设T'的编码长度为L,其中字符{x,y}的深度为h,则把字符{x,y}拆成两个后,长度变为L-(f(x)+f(y))·h+f(x)·(h+1)=L+f(x)+f(y)。因此,T'必须是C'的最优编码树,T才是C的最优编码树。
结论1通常称为贪心选择性质,结论2通常称为最优子结构性质。根据这两个结论,Huffman算法正确。在程序实现上,可以先按照频率把所有字符排序成表P,然后另外设置队列Q。每次合并两个结点后放到队列Q中。由于后合并的频率和一定比先合并的频率和大,因此Q内的元素是有序的。类似有序表的合并过程,每次只需要检查P和Q的首元素即可找到频率最小的元素,时间复杂度为O(n)。算上排序,总O(nlogn)。

本 章 小 结
本章介绍了在解决实际问题中的怎样去设计经典的高效算法,通过归并排序、快速排序和二分查找算法的分析,给出解某些实际问题的高效算法。本章还介绍了贪心算法,运用它还可以解决某些经典问题,设计出高效的算法。
对每一个内容进行了相关的题目的讲解,给出了分析过程和源程序,还给出在解决问题的一些小技巧,这对进行在线练习非常有用。

posted @ 2019-03-20 16:07  Xu_Lin  阅读(497)  评论(0编辑  收藏  举报