详解贪心算法

详解贪心算法

关于动态规划的例题和讲解,请看详解动态规划

2020.03.04 add leetcode-45-跳跃游戏ii
2020.03.05 add leetcode-435-无重叠区间
2020.03.10 add leetcode-402-移掉K位数字

references:

贪心算法详解

程序员算法基础——贪心算法

详解贪心算法(Python实现贪心算法典型例题)

递归,分治算法,动态规划和贪心选择的区别

什么是贪心算法

狭义的贪心算法指的是解最优化问题的一种特殊方法,解决过程中总是做出当下最好的选择,因为具有最优子结构的特点,局部最优解可以得到全局最优解;这种贪心算法是动态规划的一种特例。能用贪心解决的问题,也可以用动态规划解决。

而广义的贪心指的是一种通用的贪心策略,基于当前局面而进行贪心决策。

适用条件

  1. 符合贪心策略

    所谓贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。这是贪心算法可行的第一个基本要素,也是贪心算法与动态规划算法的主要区别。

    贪心算法则通常以自顶向下的方式进行,以迭代的方式作出相继的贪心选择,每作一次贪心选择就将所求问题简化为规模更小的子问题。

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

  2. 最优子结构性质

    当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。问题的最优子结构性质是该问题可用动态规划算法或贪心算法求解的关键特征。

总结如下

①贪心选择性质:在求解一个问题的过程中,++如果在每一个阶段的选择都是当前状态下的最优选择,即局部最优选择++,并且最终能够求得问题的整体最优解,那么说明这个问题可以通过贪心选择来求解,这时就说明此问题具有贪心选择性质。

②最优子结构性质:当一个问题的最优解包含了这个问题的子问题的最优解时,就说明该问题具有最优子结构

如何实现

实现该算法的过程:

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

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

求出可行解的一个解元素;

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

summary

算法局限性

从问题的某一个初始解出发逐步逼近给定的目标,以尽可能快的地求得更好的解。当达到算法中的某一步不能再继续前进时,算法停止。

该算法存在问题:

  1. 不能保证求得的最后解是最佳的;

  2. 不能用来求最大或最小解问题;

  3. 只能求满足某些约束条件的可行解的范围。

贪心/分治/dp的区别

image

贪心算法:贪心算法采用的是逐步构造最优解的方法。在每个阶段,都在一定的标准下做出一个看上去最优的决策。决策一旦做出,就不可能再更改。做出这个局部最优决策所依照的标准称为贪心准则。

       分治算法:分治法的思想是将一个难以直接解决大的问题分解成容易求解的子问题,以便各个击破、分而治之。 

       动态规划:将待求解的问题分解为若干个子问题,按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。 

更多详情可以看:

递归,分治算法,动态规划和贪心选择的区别

examples

找零钱问题

对于++固定数量++的人民币的面值有1元 5元 10元 20元 50元 100元,下面要求设计一个程序,输入找零的钱,输出找钱方案中最少张数的方案,比如123元,最少是1张100的,1张20的,3张1元的,一共5张!

解题思路:贪心攻略设置为优先选择面额大的纸币,其次往后推。

const giveChange=(money,count,k)=>{
    let map=new Map(),sum=0,i=0,res=0;
    for(let i=0;i<money.length;i++){
        map.set(money[i],count[i]);
        sum+=money[i]*count[i];
    }
    // 如果售货员拥有的零钱数目根本达不到找零需要的钱
    if(sum<k) return null;
    money.sort((a,b)=>b-a);
    while(i<money.length){
        if(k>=money[i]){
            let n=Math.floor(k/money[i]);
            // 如果只用最大面额
            if(n>=map.get(money[i])){
                n=map.get(money[i]);
            }
            k-=n*money[i];
            res+=n;
            console.info(`use ${n} ${money[i]}`);
        }
        i++;
    }
    return res;
};

一些思考

对于这个题其实有一些变种,比如零钱兑换,最朴素的一个问题,我有不限数量的 [1,3,4] 来找 6 元零钱,4+1+1 明显不如 3+3, 那么这不是推翻了刚刚我们的贪心算法,实则不然

这里的问题跟前文不同的是:

关于前文:

  • 零钱面值固定,零钱数目固定,如果有最优解必然是使用了相对于 amount(即需要找的零钱总数) 而言最大面额的钱,因此是自顶向下的,不断将问题规模变小最后得到答案

关于后者:

  • 零钱面额不固定,零钱数目不受限,在这种场景下就适合使用 dp,因为是自底向上的,先求出子问题再比较总结得出来答案

关于动态规划解决不固定面额不固定数目的找零钱问题:

    def coinChange(coins: List[int], amount: int) -> int:
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0

        for coin in coins:
            for x in range(coin, amount + 1):
                dp[x] = min(dp[x], dp[x - coin] + 1)
        return dp[amount] if dp[amount] != float('inf') else -1

股票问题

121. 买卖股票的最佳时机

122. 买卖股票的最佳时机 II

源码参考:

分发糖果+接雨水

references:

【LeetCode】贪心算法–分发糖果(135)

42. 接雨水

接雨水这个题相信大家都很熟悉了,实际上要解决问题的关键是

  • 以指针 a 为基准向右移动一直找到比 a 大的值所在的位置 b,这样 a-b 之间的位置(不包括 a b)均可以接雨水,是的,这确实写起代码来就是双指针问题,但算法实际为贪心算法
  • 我们这个解法似乎无懈可击,但仔细看一下或者使用 LeetCode 上的测试用例会发现像 4 2 3 这样的场景就没办法覆盖,怎么办呢,当然是反着再来遍历一次,显然此时的总和就是想要的答案

135. 分发糖果

分发糖果这个题自然我们只要比较当前值 ratings[i]ratings[i-1] 大且 res[i] < res[i-1] 我们就有理由让 res[i] = res[i-1] +1

  • 这就是贪心策略
  • 但基于此贪心策略我们会很快发现 2 1 0 或者 2 0 1 这样的场景得到的答案是 1 1 11 1 2,所以我们也要反着来一次将缺了的补上即 3 2 12 1 2

具体代码可以参考github

总结

也正是如上的两个问题给了我们一个解题思路那就是先找贪心策略,然后发现这个策略有缺陷尤其是涉及顺序问题时那么我们可以考虑反着再来遍历一次来得到答案。

背包问题

0-1 背包问题

显然我们在详解回溯法一文中已经讲过背包问题可以用回溯算法解决,但经过这次贪心算法的学习,可以发现回溯算法解决的只是0-1背包问题,下面进行详细描述

所谓0-1背包问题是指的是备用的各个物品都是只有1个,我们只有选择和不选择两种情况。

对于0-1背包问题的解决如前所述我们可以采用回溯法,但通过对比之后会发现迭代或者说用dp来解决的话思路更加的清晰。下面的代码展示了从一开始使用递归到后面使用动态规划暂存结果的方式解决问题的全过程。

references:

彻底理解0-1背包问题

/**
 * @param w 物品的重量数组
 * @param v 物品的价值数组
 * @param idx 当前待选择的物品索引
 * @param capacity 当前背包有效容量
 * @returns {number}
 */
// 为什么要写状态转移方程呢,存在状态吗,首先如果最优答案中没有物品x,那么此时的组合可能就是c-w[x]的最大价值量
// 因此我们发现当前状态可能会跟前一个状态发生关联,
// 首先确定状态转移方程:F(i,n)=max(F(i-1,n),v(i)+F(i-1,n-w[i]))
const knapsack=(w,v,idx,capacity)=>{
    if(idx<0||capacity<=0) return 0;
    // 不放第idx个物品的总价值
    let res=knapsack(w,v,idx-1,capacity);
    if(w[idx]<=capacity){
        res=Math.max(res,v[idx]+knapsack(w,v,idx-1,capacity-w[idx]));
    }
    return res;
};
/**
 * 0-1背包问题优化之存储已经获取的结果
 * @param w
 * @param v
 * @param C
 * @returns {number}
 */
const knapsack1=(w,v,C)=>{
    let memo=new Array(w.length);
    for(let i=0;i<memo.length;i++){
        memo[i]=new Array(C+1);
    }
    const solveKS=(w,v,idx,C)=>{
        if(idx<0||C<=0)return 0;
        if(memo[idx][C]) return memo[idx][C];
        let res=solveKS(w,v,idx-1,C);
        if(w[idx]<C){
            res=Math.max(res,v[idx]+solveKS(w,v,idx-1,C-w[idx]));
        }
        memo[idx][C]=res;
        return res;
    };
    return solveKS(w,v,w.length-1,C);
};
/**
 * 解决0-1背包问题的动态规划算法
 * @param w
 * @param v
 * @param C
 */
const knapsack2=(w,v,C)=>{
    let size=w.length;
    if(size===0) return 0;
    //  此矩阵的意思就是dp[i][j]表示前i+1个物品放入到容量为j的背包中的最大价值
    let dp=new Array(size);
    for(let i=0;i<size;i++){
        dp[i]=new Array(C+1);
    }
    // 为何要初始化第一行,因为只有一个物品放入到容量为j的背包中的时候的价值量
    // 是边界值
    //初始化第一行,仅考虑容量为C的背包放第0个物品的情况
    for(let i=0;i<C+1;i++){
        dp[0][i]=w[0]<=i?v[0]:0;
    }
    // 填充其它行
    for(let i=1;i<size;i++){
        for(let j=0;j<C+1;j++){
            // 首先设定值就是前i个物品放入时的价值量
            dp[i][j]=dp[i-1][j];
            if(w[i]<=j){
                // key:这一步保证了不会让总重量超过背包的容量,且此时背包的容量是j
                dp[i][j]=Math.max(dp[i][j],v[i]+dp[i-1][j-w[i]]);
            }
        }
    }
    console.info(dp);
    return dp[size-1][C];
};


/**
 * 解决 0-1 背包问题的动态规划算法,这种方法更容易理解
 * 一般来讲我们都是先从容量入手来写状态转移方程 F(c, arr)=max(F(c-wi, arr[:i])+pi, F(c, arr[:i]))  arr[:i] 表示获取到
 * arr 从 0 到 i(不包含 i)
 * 备注:可能会疑问为什么不把数组中的所有重量元素都按这个规则遍历一次,为什么只取最后一个元素来做比较呢,实际上只取最后一个元素就能涵盖其他所有
 * 情况,因此这个状态转移方程是没问题的
 * 边界条件:F(0,arr) = F(c, []) =0 同时数组长度为 1 的情况也满足状态转移方程,所以无需特别做边界条件
 * 暂存结果方案:可以直接用状态转移方程来做 dp[i][j] i 标识容量从 0 到 c,长度为 c+1,j 标识数组的长度从 0 到 n,长度为 n+1
 * @param w
 * @param v
 * @param C
 */
const knapsack3=(w,v,C)=>{
    let n = w.length, res= new Array(C+1)
    for (let i=0;i<C+1;i++){
        res[i] = new Array(n+1);
    }
    // 容量为 0 的时候所有值均为 0
    for (let i=0;i<n+1;i++){
        res[0][i] = 0
    }
    for (let i=1;i<C+1;i++){
        res[i][0] = 0;
        for(let j=1;j<n+1;j++){
            res[i][j] = res[i][j-1];
            if (i>=w[j-1]){
                res[i][j] = Math.max(res[i][j], res[i-w[j-1]][j-1]+v[j-1]);
            }
        }
    }
    console.info('=====>',res);
    return res[C][n]
}
部分背包问题

references:

贪心算法-------部分背包问题

java 实现部分背包问题

  • 部分背包问题的解决非常像找零钱的套路,找零钱的贪心目的是用最小的张数表达最大的面额
  • 而部分背包问题则可以用最大的价值同时不超重,因此我们的贪心攻略是总是选择性价比高的,然后往后推
// 部分背包问题的解决非常像找零钱的套路,找零钱的贪心目的是用最小的张数表达最大的面额
// 而部分背包问题则可以用最大的价值同时不超重,因此我们的贪心攻略是总是选择性价比高的,然后往后推
/**
 * 按性价比排序
 * @param w
 * @param v
 */
const sort=(w,v)=>{
    let temp=[];
    for(let i=0;i<w.length;i++){
        temp[i]=w[i]===0?0:v[i]/w[i];
    }
    for(let i=temp.length-1;i>=0;i--){
        for(let j=0;j<=i;j++){
            if(temp[j]<temp[j+1]){
                let temp0=temp[j];
                temp[j]=temp[j+1];
                temp[j+1]=temp0;


                let temp1=v[j];
                v[j]=v[j+1];
                v[j+1]=temp1;


                let temp2=w[j];
                w[j]=w[j+1];
                w[j+1]=temp2;

            }
        }
    }
};
const knapsackPart=(w,v,c)=>{
    let res=0,left=c,cn=new Map();
    sort(w,v);
    for(let i=0;i<v.length;i++){
        if (left <= 0) break;
        let temp0 = left / w[i];
        if (temp0 <= 1) {
            cn.set(w[i], temp0);
            left = left - temp0 * w[i];
            res += temp0 * v[i];
        } else {
            cn.set(w[i], 1);
            left -= w[i];
            res += v[i];
        }
    }
    console.info(cn);
    return res;
};

# python 版解决方案
def part_package(w: List[int], v: List[int], c: int) -> int:
    n, ans = len(w), 0
    res = [[]] * n
    # 首先按单价排序
    for i in range(n):
        res[i] = [w[i], v[i]]
    res.sort(key=lambda val: val[1], reverse=True)
    # 如果 v 标识的是总价不是单价,那么其实还是按单价排序
    # res.sort(key=lambda val: float(val[1] / val[0]) if val[0] != 0 else 0, reverse=True)
    for val in res:
        if c == 0:
            return ans
        if val[0] <= c:
            c = c - val[0]
            ans += val[1] * val[0]
            # 如果 v 标识的是总价不是单价,那么其实还是按单价排序
            # ans += val[1]
        else:
            ans += val[1] * c
            # 如果 v 标识的是总价不是单价,那么其实还是按单价排序
            # ans += float(float(val[1] / val[0]) * c)
            c = 0
    return ans
跳跃游戏-leetcode-55
class Solution:
    # 我们可以穷举一下 [2,3,1,1,4] 这种情况
    # nums[3] >=1 and F(3) == True
    # nums[2] >=2 and F(2) == True
    # nums[1] >=3 and F(1) == True
    # nums[0] >=4 and F(0) == True
    # 只要满足上面任何一种条件 F(4) 就是 True
    # 所以我们可以用动态规划来解决问题,状态转移方程就是 F(n) = (nums[n-1] >=1 and F(n-1) == True) or (...)
    # 包含子问题的最优解即拥有最佳子结构性质
    # 边界条件 F(0) = True
    # 暂存结果:需要暂存 F(x) 的值
    def canJump(self, nums: List[int]) -> bool:
        n = len(nums)
        res = [None] * (n)
        res[0] = True
        for i in range(1, n):
            for j in range(i):
                if res[j] is True and nums[j] >= i - j:
                    res[i] = True
                    break
            if res[i] is None:
                res[i] = False
        print('====>', res)
        return res[n-1]

    # 结果上面的方法超时了,因为最差的情况下时间复杂度为 O(N^2)
    # 私以为那是普通人能想到的最直观的解题思路,其实相当于用一个二维数组在存储数据,因为有两个判断条件,且为双层嵌套循环
    # 有一种比较巧妙的判断方式是通过遍历一次找到最远能到达哪里
    # 时间复杂度:O(N)
    # 空间复杂度:O(1)
    def canJump_0(self, nums: List[int]) -> bool:
        n, max_val = len(nums), 0
        for i in range(n - 1):
            # 处理为 0 的特殊情况
            if nums[i] == 0 and max_val == i:
                return False
            else:
                max_val = max(max_val, i + nums[i])
        if max_val >= n - 1:
            return True
        else:
            return False

    # 既然正向遍历有这样的特殊情况,不如反向来一次
    # 这种方法就很直观的展示出了贪心算法自顶向下的一种应用
    def canJump_1(self, nums: List[int]) -> bool:
        n = len(nums)
        if n < 1:
            return True
        last_pos = n - 1
        for i in range(n-1, -1, -1):
            if nums[i] + i >= last_pos:
                last_pos = i
        return last_pos == 0

跳跃游戏ii-leetcode-45

这是一个典型的贪心算法的应用,当然也是基于题目要求:总是能够到达最后一个位置,因此我们可以总是往远的跳

const jump=nums=>{
    let end=0,maxPosition=0,steps=0;
    for(let i=0;i<nums.length-1;i++){
        // 总是能够找到最大的位置
        maxPosition=Math.max(maxPosition,nums[i]+i);
        console.info('maxP==>',maxPosition);
        if(i===end){
            end=maxPosition;
            steps++;
        }
    }
    return steps;
};
无重叠区间-leetcode-435
// 无重叠区间-leetcode-435
// 给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
// 注意:
// 可以认为区间的终点总是大于它的起点。
// 区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。
// 输入: [ [1,2], [2,3], [3,4], [1,3] ]
// 输出: 1
// 解释: 移除 [1,3] 后,剩下的区间没有重叠。

从这道题带来的思考:在总结果已知的题目中求最大或者最小时,如果正向不容易用贪心算法,我们不妨反过来继续用贪心算法解决。

python 版

# python 解法
class Solution:
    # [[1,2],[2,3],[3,4],[1,3]] 排序一下即为 [[1,2],[1,3],[2,3],[3,4]]
    # 有一种解题思路是对二维数组排序,将前面的元素和后面的元素对比是否重叠,如果重叠则将元素 1 和元素 2 分别与元素 3 对比,如果 1 与 3 无交集
    # 但是 2 与 3 有交集则记录一个要删的,如果 1 2 均与 3 有交集则再增加一个记录继续往后迭代检查 1,2,3 是否均与 4 有交集,
    # 直到找到没交集的那个位置 x
    # 感谢 ac
    # 时间复杂度为 O(N2)
    # 空间复杂度为 O(1)
    def overlapping(self, l1: List[List[int]], l2: List[int], start: int, end: int) -> bool:
        for i in range(start, end):
            if l2[0] >= l1[i][1]:
                return False
        return True

    def eraseOverlapIntervals_0(self, intervals: List[List[int]]) -> int:
        n, res, i = len(intervals), 0, 0
        intervals.sort(key=(lambda x: [x[0], x[1]]))
        print(intervals)
        while i < n - 1:
            if self.overlapping([intervals[i]], intervals[i + 1], 0, 1):
                res += 1
                # 如果和下一个元素有重叠
                if i + 2 >= n:
                    return res
                for j in range(i + 2, n):
                    if not self.overlapping(intervals, intervals[j], i, j):
                        break
                    else:
                        res += 1
                i = j
            else:
                i += 1
        return res

    # 以上这种方式虽然很容易理解,但时间复杂度还是有点高,我们编写一种时间复杂度低的解决方式,既然是求移除区间的最小数目那就相当于求组成无重叠区间的
    # 最大数目,也就是我们只要让相邻的两个区间不互相重叠就好了,其实这也是一种贪心攻略,此时按照高位进行排序
    # 这种方法太巧妙了,实在不容易想出来,但是也给了我们一种思路,求最大最小值的时候或许可以反着来思考一下
    def eraseOverlapIntervals_1(self, intervals: List[List[int]]) -> int:
        n, res = len(intervals), 1
        intervals.sort(key=(lambda x: x[1]))
        prev = intervals[0]
        print(intervals)
        for i in range(1, n):
            if intervals[i][0] >= prev[1]:
                res += 1
                prev = intervals[i]
        return n - res

JavaScript 版

参考我的github

移掉K位数字-leetcode-402

给定一个以字符串表示的非负整数 num,移除这个数中的 k 位数字,使得剩下的数字最小。

在这道题的解题过程中会用到栈和贪心算法的结合。

python 版

class Solution:
    # 可以这样去思考这个问题,当第一个数比后一个数大的时候,这个数肯定要移走,否则保留
    # 指针位置不变,再将移到当前位置的数和后一个数比较
    
    # 如果到最后 k 仍未消零,则将最后的几位删掉即可
    # 时间复杂度 O(N)
    # 空间复杂度 O(1)
    def removeKdigits(self, num: str, k: int) -> str:
        n, i = len(num), 0
        if n <= k:
            return '0'
        while k > 0 and -1 < i < len(num)-1:
            if num[i] > num[i + 1]:
                num = num[1:] if i == 0 else num[0:i] + num[i + 1:]
                k = k - 1
                i = i - 1 if i >= 1 else i
            elif num[i] < num[i + 1]:
                i = i + 1
            else:
                i = i + 1
        if k > 0:
            num = num[0:len(num) - k]
        while num[0] == '0' and len(num) > 1:
            num = num[1:]
        return num

JavaScript 版

具体代码可以参考我的github

  • 25
    点赞
  • 179
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值