算法学习(二)贪心算法

♀尐吖头ヾ 提交于 2019-11-26 17:01:54

 

贪心算法

一、基本思想

      所谓贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解

     贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关

    最短路径问题(广度优先搜索BFS、Dijkstra算法)都属于贪婪算法,只是在其问题策略的选择上,刚好可以得到最优解。

    所以对所采用的贪心策略一定要仔细分析其是否满足无后效性。

二、基本步骤

  1. 建立数学模型来描述问题。
  2. 把求解的问题分成若干个子问题。
  3. 对每一子问题求解,得到子问题的局部最优解。
  4. 把子问题的解局部最优解合成原来解问题的一个解。

三、适用问题

贪心算法要求最优解问题可以拆分成一个个的子问题,把解空间的遍历视作对子问题树的遍历,则以某种形式对树整个的遍历一遍就可以求出最优解,但大部分情况下这个前提是不存在的。

贪心算法和动态规划本质上是对子问题树的一种修剪,两种算法要求问题都具有的一个性质就是子问题最优性(组成最优解的每一个子问题的解,对于这个子问题本身肯定也是最优的)。动态规划方法代表了这一类问题的一般解法,而贪心算法是动态规划方法的一个特例,可以证明每一个子树的根的值不取决于下面叶子的值,而只取决于当前问题的状况。

综上,贪心算法主要特点:

  1. 贪心策略适用的前提是:局部最优策略能导致产生全局最优解
  2. 对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析,就可做出判断

四、贪心一般范式

 从问题的某一初始解出发;

    while (能朝给定总目标前进一步)

    { 

          利用可行的决策,求出可行解的一个解元素;

    }

    由所有解元素组合成问题的一个可行解;

需要注意是贪心算法只能通过解局部最优解的策略来达到全局最优解,因此,在每一步循环进行前一定要注意判断问题是否适合采用贪心算法策略,找到的解是否一定是问题的最优解。

五、典型问题

1、买卖股票的最佳时机

问题描述:

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

例1:

输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

例2:

输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

分析:

  1. 是否符合贪心算法的要求:由于整体的最优解为最大利润值,是由局部子问题的最优解之和,所以符合
  2. 把整个问题分解为若干个子问题:由于问题限制在同一天只能卖出或买入一种操作,所以我们可以将股票的变动想象为一个个阶段涨幅,如图:只需考虑每个阶段最大涨幅,而无需考虑细节。如[7,1,2,5,3,6,4],只在乎1-5和3-6之间的涨幅

       Profit Graph

写入如下代码:

public int maxProfit(int[] prices) {
        if (prices.length <= 1) return 0;   
        int max = 0, index = 0;       
        for (int i = 1;i < prices.length;i++) {
            if (prices[i] < prices[i-1]) {    //找出最大涨幅点
                max += prices[i-1] - prices[index];//将涨幅记录
                index = i;             //从最大涨幅的下一个点开始
            }
        }
        //若后部分整体全是上涨的特殊情况
        max += prices[prices.length-1] - prices[index];
        return max;
    }

进一步思考:

      在上面代码中我们采取了较贪心的策略,取每一涨幅阶段的最大值之和为最大利润;

      接下来我们尝试更贪心的想法,针对每一阶段的涨幅中,如[1,2,5]这一阶段来看,我们选择了最低价格1谷值的时候买进在最高价格5峰值的时候卖出,其实这等同于在1买进,在2卖出(2-1),再在2同一天买进,在5卖出(5-2),即(2-1)+(5-2) = 5-1的,而问题是允许当天卖出后再买入的(只要买入之前卖出就行)

      这样其实相当于将各涨幅之间的细化了,考虑到了每一步的增长,如下图所示:这在人的角度看上去计算更复杂了,但计算机却更加简单,因为算法可以直接简化为只要今天比昨天大,就卖出

Profit Graph

由此写出更简洁的代码:

public int maxProfit(int[] prices) {
        if (prices.length < 2) return 0;
        int max = 0;
        for (int i = 1;i < prices.length;i++) {
            if (prices[i] > prices[i-1]) {
                //只要比前一天价格高就连续卖出
                max += prices[i] - prices[i-1];    
            }
        }
        return max;
}

Tips:这只是为贪心算法提供的一种题例,在真实的股票市场中,股票的价格千变万化,只会在某个瞬间,股票的价格才是确定的,A股一天的交易总时间为4个小时,这种瞬间太多了,想要在买入前找到价格最低的那一瞬间,这几乎是不可能的。

2、跳跃游戏

问题描述:

给定一个非负整数数组,你最初位于数组的第一个位置。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个位置。

例1:

输入: [2,3,1,1,4]
输出: true
解释: 从位置 0 到 1 跳 1 步, 然后跳 3 步到达最后一个位置。

例2:

输入: [3,2,1,0,4]
输出: false
解释: 无论怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 , 所以你永远不可能到达最后一个位置。

分析:

  1. 是否符合贪心原则:需要到达最后的位置,反向来说就是,到达最后位置之前的倒数第二位置的最优选择,再以倒数第二位置为终点的,倒数第三个位置的最优选择,所有子问题的最优解即能到达最后的位置。
  2. 如何贪心:即以当前位置为终点,前一个位置如何选取;若终点坐标为 i,前一个位置 能到达终点的条件是nums[j] >= i-j即能跳跃到终点,那么可以看出满足此条件的最大的i - j就是此子问题的最优解(跳的最远),因为在 之前的位置若能跳跃到 j 之后的位置则也必然能跳跃到 j 位置(经过 j),所以我们就取最远的 j 位置为下一个终点位置
  3. 结束判断:最后能追溯到起点位置

之后写出如下代码:

public boolean canJump(int[] nums) {
        if (nums.length <= 1) return true;
        int i = nums.length-1;
        for (int j = 0;j < i;j++) {
        //从前往后找,找到的第一个满足条件的也是最大的i-j
            if (nums[j] >= i-j) { 
                i = j;     //将此位置作为终点位置
                j = -1;    //下一轮跳跃
            }
        }
        return i == 0;   //若到达起点前都没有符合条件的则i不等0
    }

进一步思考:

      在上面代码中我们采取了较贪心的策略,但这样的贪心程度是不够的;上面的代码存在明显的适用缺陷,由于每次追溯到前一个位置后,都需要再从0寻找下一个最优位置,大大增加了算法的时间复杂度;特别是遇到像[1,1,1,1,1,1.....,1]这种极端情况,算法的时间复杂度可达O(n!),这不是一段可靠的代码。

      因此我们需要更贪心的策略,可以看出虽然我们每一步都寻找到离当前位置最远的前一位置,但其实是没有必要的,这点类似于上个股票卖出的涨幅问题,我们没必要找最大涨幅,只需要把它处于涨势的阶段全部加起来即可;

      这个问题也是一样的,我们没必要寻找最远的距离的位置,从终点往前,只要找到能跳跃到终点的点,立刻执行跳跃,并把此处作为新的终点。这样做合理的原因是,既然最远距离的点能跳跃到终点,那么他也能跳跃到他与终点之间的任何一个位置,因为在这一段过程中,无论怎么跳跃,最远距离的点总能到达某处立刻跳跃点,它们最终也必须经过最远距离点,所以立刻跳跃是可行的。

public boolean canJump(int[] nums) {              
        int i = nums.length-1;
        for (int j = i-1;j >= 0;j--) {
            if (nums[j] >= i-j) {
                 i = j;
            }
        }
        return i == 0;
}

算法时间复杂度为O(n),且为较稳定的算法,即使是极端情况也能保持O(n)的时间复杂度

3、背包问题

     贪心算法对于题目的求解过程是很简单的,但是也存在它的局限性;贪心算法最困难的或者说难以适用的地方在于构造贪心策略,因为贪心算法要求必须证明整个问题的最优解一定由在贪心策略中存在的子问题的最优解得来的,且子问题的具有贪心选择性质贪心选择性质:贪心算法作出的每步贪心决策都无法改变,因为贪心策略是由上一步的最优解推导下一步的最优解,而上一部之前的最优解则不作保留)。这是很多问题所不满足的,比如背包问题

问题描述:

           有一个背包,背包容量是M。有n个物品。要求尽可能让装入背包中的物品总价值最大,但不能超过总容量。

   例1:M=30  物品n=3:A B C  重量:28 12 12   价值:30 20 20    最大的价值为12+12<30,20+20=40

   例2:M=30  物品n=3:A B C  重量:28 12 12   价值:50 20 20    最大的价值为28<30, 50

   例3:M=30  物品n=3:A B C  重量:28 2 2   价值:30 4 4    最大的价值为28<30, 30

    分析三种贪心策略是否具备贪心选择性质:   

    (1)根据贪心的策略,每次挑选价值最大的物品装入背包,得到的结果是否最优?

    (2)每次挑选所占重量最小的物品装入是否能得到最优解?

    (3)每次选取单位重量价值最大的物品?

由上述列1,2,3对应反例可看出,这3种贪心策略都是证明不了具有贪心选择性质的,所以也就不适合贪心算法

但是此类问题的确是由子问题构成的,为什么却无法得到整体最优解呢?这是因为在求解的过程中子问题是重叠的,即子问题可能被多次用到,多次计算,违反了贪心算法上一步的最优解推导下一步的最优解,而上一部之前的最优解则不作保留的规则

那么我们该如何求解此类由子结构构成整体的问题呢?   

动态规划

这即是下一次要学习的内容,其核心思想是

 

参考文章:

《算法第4版》

《算法导论》

  LeetCode官方网站https://leetcode-cn.com

https://www.cnblogs.com/steven_oyj/archive/2010/05/22/1741375.html

 

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!