算法分析与设计:贪心算法

1、贪心算法

贪心算法,是在每一次选择中,总是做出当前看来最好的选择,而不从整体的最优考虑,选择只是某种意义上局部的最优解。生活中很多问题需要对资源优化分配,达到资源利用率最大化。贪心算法虽然不能对所有的问题都求得整体最优解,但是对大部分的问题都能求得最优近似解,对部分问题也能得到最优解,例如单源最短路径最小生成树等。

● 语言描述与基本思想

贪心算法的语言描述为:贪心算法一步步进行,每次都对当前的局部最优解判断:若添加入部分解后仍是可行解就加入;否则舍弃该局部解,寻找下一个局部最优解,直到将所有数据全部枚举完成或可以确定达到目标
基本思想可以概括为:从问题的某一个初始解出发,向给定的目标推进,每次都做出当前最佳的贪心选择,不断将问题实例归纳为更小的相似子问题。

● 基本性质

贪心算法常用于解决最大值最小值的优化问题。贪心算法能求得最优解的问题一般具有两个重要性质

  1. 贪心选择性质:问题的整体最优解可以通过一系列局部最优的选择达到。这是贪心算法可行的第一个基本要素,也是贪心算法问题动态规划问题主要区别。贪心算法通常自顶向下,以迭代的方式做出相继的贪心选择。
  2. 最优子结构性质:问题的整体最优解包含子问题的最优解

● 贪心算法与动态规划对比

贪心算法较为简便,效率更高,但求出的解不一定是整体最优解;动态规划通过求子问题的解构造原问题的解,相对更为复杂,但通过若干局部解的比较,去掉了次优解,得到的必定是整体最优解。
贪心算法与动态规划对比

● 基本步骤

贪心算法主要分3个步骤:

  1. 建立数学模型分析问题
  2. 选定合适的贪心选择标准
  3. 根据贪心选择标准完成算法

2、经典问题

● 找零问题

  1. 问题描述
    假设有面值为50元、20元、10元、5元、1元的货币,现需要找给顾客x元现金,求最少给出的货币总数。
  2. 问题分析
    在现实的合理货币标准下,找零问题是最简单的贪心算法例题之一。若货币标准由题目给定,可能需要动态规划求解。
    设y∈{50,20,10,5,1}使得找x-y元现金成为原问题的一个子问题,当wx为原问题的最优解时,wx-y=wx-1必然是原问题的最优解;否则原问题将有wx’=wx-y+1<wx的更优解。由上反证法可证明找零问题具有最优子结构性质
    在题目所给条件下,该问题也具有贪心选择性质
  3. 算法实现
//找零问题
//贪心算法求最小货币数,cash为货币数组,x为找零总数,res为各货币使用总数
int getMinCash(int x,int *cash,int *res)     
{
	sort(cash);
    int ans = 0;
    int i = 0;
    while(x > 0){
        if(x >= cash[i]){
            x -= cash[i];
            ++res[i];
            ++ans;
        }
        else
            ++i;
    }
    return ans;
}

● 活动安排问题

  1. 问题描述
    设n个活动的集合E={1,2,…,n},每个活动需要使用统一资源,而同一时间只能有一个活动使用这一资源。每个活动i都有一个开始时间si和一个结束时间fi,且必有si<fi。任意一个活动将在半开区间[si,fi)内占用资源。对于两个活动i和j,若区间[si,fi)和[sj,fj)不相交,称i和j是相容的;否则称i和j是不相容的或冲突的。求在一定时间内所能安排活动的最大数,也即求集合E的最大相容子集。
  2. 问题分析
    活动安排问题是贪心算法有效求解的例题之一。我们选择结束时间作为贪心选择的标准,每次都选择当前可容的结束时间最早的活动。这样做的目的是为剩余的未安排活动留下尽可能多的时间。该问题的最优子结构性质可由数学归纳法证明;贪心选择性质可由反证法证明。
  3. 算法实现
//活动安排问题
//获取最大相容活动个数和子集,s为开始时间集合,f为结束时间集合,n为活动总数,res为选择的活动
int getMaxActivity(int *s,int *f,bool* res,int n)  
{
    int lastF = 0;
    int ans = 0;
    for(int i = 1;i <= n;i++){
        if(s[i] >= lastF){
            ++ans;
            res[i] = true;
            lastF = f[i];
        }
    }
    return ans;
}

时间复杂度分析:对于已经给定非降序序列,主要时间消耗在遍历序列上,故时间复杂度为O(n);若给定序列无序,还需要进行排序,则时间复杂度由排序的时间复杂度决定,通常为O(nlog n)。

● 删数问题

  1. 问题描述
    对给定的数字串x1x2…xn,删除其中的k个数字,使得剩余数字按原次序组成的新数字最大。
  2. 问题分析
    对于一个长整数,其高位数字越大,数字也就越大。若存在xi与xi+1满足xi<xi+1,则删除xi必然能使数字变大;当序列已经呈非增序时,删除末尾的元素。由于越高位的数字对数字的大小影响也越大,由此可以从左端开始遍历,每次遇到第一个删除的数字就是当前的局部最优解。而每次贪心选择后,问题都化为n-1的子问题,因此该问题具有贪心选择性质
    设An为n问题的最优解,而An-1是n-1问题的最优解。若An-1不是n-1问题的最优解,设该最优解为Bn-1,满足Bn-1>An-1,那么补上删除的xi后,必有An = An-1 + xi · 10n-i < Bn-1 + xi · 10n-i = Bn,即存在Bn>An满足Bn为更优解。由上反证法可得,该问题具有最优子结构性质
  3. 算法实现
//删数问题
//x为数字序列,k为需要删除的数字个数,n为序列长度
void getMaxAfterDelK(int* x,int k,int n)
{
    int i;
    int t = 0;
    while(t < k){
        for(i = 1;i <= n - 1;i++){
            if(x[i] < x[i+1]){
                int tmp = x[i];
                for(int j = i;j <= n - t - 1;j++)	//被删元素后的元素前移
                    x[j] = x[j+1];
                x[n-t] = tmp;
                ++t;
                break;
            }
        }
        if(i == n)  break;
    }
}

时间复杂度分析:删数问题的贪心算法主要时间消耗在遍历数组和移动数组上,共循环k次,所以时间复杂度为O(kn)。

● 揹包问题

  1. 问题描述
    给定n种物品和一个容量为C的揹包,物品i的重量为wi,价值为vi,如何选择装入揹包的物品,使得揹包中物品总价值最大?
  2. 问题分析
    在前面学习动态规划时提到过,揹包问题分为两种:0-1揹包问题可拆分揹包问题(简称揹包问题)。对于0-1揹包问题,由于揹包剩余空间可能降低物品的单位重量价值,因此不适用贪心算法,而适用动态规划。而这里将讨论物品物品可拆分的揹包问题
    由于揹包的容量是有限的,因此物品的重量和价值都会对结果产生影响。因此我们以物品的单位重量价值作为贪心选择的标准。每次选择单位重量价值最高的物品加入揹包。
    每次贪心选择放入R%的物品i后,我们的问题就变成C - R% · wi的揹包容量与剩余物品集合的揹包问题了。设原问题的最优解为A,子问题的最优解为A’。设子问题有更优解满足B‘ > A’,那么加上揹包内的价值R% · vi后,可以得到A = A’ + R% · vi < B’ + R% · vi = B,即B为原问题的更优解。如上反证法可得该问题具有最优子结构性质
    设k为原问题或原问题的一个子问题。对于k的第一个选择,若该选择为贪心选择,则满足贪心选择性质;若该选择不为贪心选择,可以将其替换为贪心选择,而不影响之后的选择,使得得到的新解优于原解。因此,该问题具有贪心选择性质
  3. 算法实现
//揹包问题
//物品结构体
struct Object{
    double w;
    double v;
    double v_w;
    int i;
};

//objects为物品集合,c为揹包容量,n为集合长度,res储存物品的选取重量
double getMaxBagValue(Object* objects,double c,int n,double* res)    //贪心算法求揹包能放的最高价值
{
	sort(objects+1,objects+n+1,[](Object a,Object b)->bool{return a.v_w > b.v_w;});	//对物品按单位重量排序
    int i = 1;
    double value = 0;
    while(c >= objects[i].w){		//能完全装入的完全装入
        c -= objects[i].w;
        value += objects[i].v;
        res[objects[i].i] += objects[i].w;
        ++i;
    }
    if(c > 0){						//不能完全装入的,将剩余空间填满
        double r = c / objects[i].w;
        value += r * objects[i].v;
        res[objects[i].i] += c;
    }
    return value;
}

时间复杂度分析:揹包问题的贪心算法时间主要消耗在对物品序列排序,其时间复杂度通常为O(nlog n);若给出的序列以满足按单位重量价值非增序,则时间消耗主要在遍历物品序列上,其时间复杂度为O(n)。

● 单源最短路径

  1. 问题描述
    给定一个带权有向图G={V,E},每条边的权值为非负实数。从图上的一点v0()出发,求到达任意另一点vi最短路径
  2. 问题分析
    最短路径问题显然具有最优子结构性质:最短路径必然能分成多个子问题的最短路径,否则该路径可以以最短路径替代,以得到更优的路径。
    最短路径问题的贪心选择性质证明如下:
    我们取当前的最短路径为贪心选择标准,若最短路径不具有贪心选择性质,则必然存在点vx位于贪心路径之外。那么v0到vi的最短路径d(0,i)可以表示为:
    d(0,i) = d(0,x) + d(x,i)
    设v0到vi的贪心选择路径为dist(0,i),则一定有:
    d(0,i) < dist(0,i)
    由于各边的权值均为非负,那么应有:
    d(0,x) ≤ d(0,i)
    再由上面的第二个式子可以得到:
    d(0,x) < dist(0,i)
    这个式子表示顶点v0到vx的距离小于v0到vi的贪心选择距离。根据贪心选择的定义,vx应当在贪心选择路径中。
    由上反证法可证明最短路径问题的贪心选择性质

Dijkstra算法是典型的最短路径算法。它将顶点分为两个集合:已得到最短路径的顶点集合S未确定最短路径的顶点集合V-S,并设一个数组D保存v0到V-S内顶点的当前最短路径
算法步骤如下:
● 初始状态下,S中只有v0,D中记录v0到其它各顶点出弧的权值;若不邻接,则记为无穷大;
● 从D中选择一条最短、且邻接点不在S中的路径,并将邻接点加入S中,该最短路径就是v0到该点的最短路径
● 遍历该邻接点的所有出弧,计算出弧的权值源到邻接点最短路径的和,并与D中记录的源到弧头的最短路径比较。如果和小于原记录,则更新数组D。
● 重复步骤2和3,直到所有的顶点都加入S,或目标顶点vi 加入S。

  1. 算法实现
//Dijkstra算法求最短路径
//图的结构体
struct Graph{
	int **martix;	//邻接矩阵
	int v;			//顶点个数
};

//G为图,dist为最短路径数组,prev为前置顶点集合,v0为出发点
void dijkstra(Graph G,int* dist,int *prev,int v0)
{
    bool visited[G.v] = {false};
    visited[v0] = true;
    //初始化
    for(int i = 0;i < G.v;i++){
        if(G.martix[v0][i] > 0 && i != v0){
            dist[i] = G.martix[v0][i];
            prev[i] = v0;
        }
        else{
            dist[i] = 0x7fffffff;
            prev[i] = -1;
        }
    }
    dist[v0] = 0;
    prev[v0] = v0;
    //循环n-1次
    for(int i = 0;i < G.v;i++){
        if(i == v0)     continue;
        int min_num = 0x7fffffff;
        int v;                  //下一个加入的点
        for(int j = 0;j < G.v;j++){     //遍历查询最短的路径
            if(visited[j] == false && dist[j] < min_num){
                min_num = dist[j];
                v = j;
            }
        }
        visited[v] = true;
        for(int j = 0;j < G.v;j++){     //更新dist
            if(visited[j] == false && G.martix[v][j] > 0 && min_num + G.martix[v][j] < dist[j]){
                dist[j] = min_num + G.martix[v][j];
                prev[j] = v;
            }
        }
    }
}

//构造最优路径
void showPath(int* prev,int v)
{
    if(v == prev[v])
        cout << 'v' << v;
    else{
        cout << 'v' << v << "<-";
        showPath(prev,prev[v]);
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章