zoukankan      html  css  js  c++  java
  • 动态规划经典问题

    1.股票买卖问题:
    首先以leet188.买卖股票的最佳时机IV为例,因为这道题是最一般的情况。
    给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

    动态规划的本质其实就是穷举,穷举有不同的方法,比如说扔鸡蛋问题中的递归方法,即把无法解决的问题直接丢给递归,这样的递归穷举可读性很好但是不易改进,而另一种递归方法就如同之前的子序列问题,是利用状态进行穷举。

    这里要使用的是状态进行穷举,利用状态进行穷举首先要找到问题总共有几种状态,再找出每个「状态」对应的「选择」。我们要穷举所有「状态」,穷举的目的是根据对应的「选择」更新状态。
    状态穷举的模板:

    for 状态1 in 状态1的所有取值:
        for 状态2 in 状态2的所有取值:
            for ...
                dp[状态1] [状态2] [...] = 择优(选择1,选择2...)
    

    具体到当前问题,每天都有三种「选择」:买入、卖出、无操作,我们用 buy, sell, rest 表示这三种选择。

    但问题是,并不是每天都可以任意选择这三种选择的,因为 sell 必须在 buy 之后,buy 必须在 sell 之后(第一次除外)。那么 rest 操作还应该分两种状态,一种是 buy 之后的 rest(持有了股票),一种是 sell 之后的 rest(没有持有股票)。而且别忘了,我们还有交易次数 k 的限制,就是说你 buy 还只能在 k > 0 的前提下操作。

    不要怕复杂,我们现在的目的只是穷举,你有再多的状态,要做的就是一把梭全部列举出来。这个问题的「状态」有三个,第一个是天数,第二个是当天允许交易的最大次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨用 1 表示持有,0 表示没有持有)。
    很明显得用一个三维数组dp来装下这三种状态的全部组合,用for循环来完成穷举:
    dp[i] [k] [0 or 1] , 其中i属于0到n之间,表示的是天数,即第几天,k则属于0到K之间,表示当天允许交易的最大次数,最后一个是用来表示那一天的时候是否持有股票。

    在确定了dp(状态及选择)之后,就得找状态转移方程,我们这里可以借助状态转移图来实现:(图省略了)
    当状态为0时,可以是通过前一天状态为1卖掉股票或者是前一天状态为0但无操作而得来
    当状态为1时,可以是通过前一天状态为0买入股票活是前一天状态为1但无操作而得来

    dp[i][k][0]=max( dp[i-1][k][0] + dp[i-1][k][1] + prices[i] )
    //dp = max ( 无操作 , sell)
    dp[i][k][1]=max( dp[i-1][k][1] + dp[i-1][k-1][0] - prices[i] )
    //dp = max ( 无操作 , buy)
    

    如果 buy,就要从利润中减去 prices[i],如果 sell,就要给利润增加 prices[i]。今天的最大利润就是这两种可能选择中较大的那个。而且注意 k 的限制,我们在选择 buy 的时候,把最大交易数 k 减小了 1,很好理解吧(既然昨天到今天进行了一次交易,那么对昨天来说,最大交易数肯定得k-1,不能是k了,当然你也可以在 sell 的时候减 1,一样的。

    (这里注意,由题目给的示例来说,买入+卖出只算一笔交易,所以只选择在sell时k-1,而buy时不减去,当然反过来也是一样的)
    这里计算利润的方式也值得注意,在买入时减去价格,卖出时加上当时的价格,自动的利润就出来了

    最终要求的是 dp[n-1] [K] [0] 之所以最后一个状态要取0是因为价格一定大于等于0,当然是最后把股票卖出的利润最大

    现在,我们已经完成了动态规划中最困难的一步:状态转移方程。不过还差最后一点点,就是定义 base case,即最简单的情况。

    dp[-1][K][0] = 0; //i=-1 表示还没有开始,利润当然是0
    dp[-1][K][1] = -infinity; //还没开始的时候,不可能持有股票,负无穷表示不可能
    dp[i][0][0] = 0; //K从1开始的,当k=0时表示不允许交易,即利润一定是0
    dp[i][0][1] = -infinity; //不允许交易的时候一定不可能持有股票,负无穷表示不可能
    

    数组索引是 -1 怎么编程表示出来呢,负无穷怎么表示呢?这都是细节问题,有很多方法实现。现在整体框架已经完成,下面开始具体化。

    下面具体看例子:
    leet121.
    这里是上述问题在k=1时的特殊情况:可以根据现状简化状态转移方程,并处理一下base case

    class Solution {
        public int maxProfit(int[] prices) {
            int n=prices.length;
            int[][] dp=new int[n][2];
            for(int i=0;i<n;i++){
                if(i-1==-1){
                    dp[i][0]=0;
                    dp[i][1]=-prices[i];
                    continue;
                }
                dp[i][0]=Math.max(dp[i-1][0],dp[i-1][1]+prices[i]);
                dp[i][1]=Math.max(dp[i-1][1],-prices[i]);
            }
            return dp[n-1][0];
    
        }
    }
    

    这里也显示了动态规划写法中的套路,当某个状态由很多情况时用for循环,若只有几个值时直接列出
    当然可以发现,这里的状态转移方程之中只和相邻的状态有关,所以其实无需整个dp数组,而是利用两个值来记录并更新:

    class Solution {
        public int maxProfit(int[] prices) {
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE;
    
            for(int i=0;i<prices.length;i++){
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
                dp_i_1=Math.max(dp_i_1,-prices[i]);
            }
            return dp_i_0;
    
        }
    }
    

    leet122
    这里是k为无穷大的情况,就相当于k的限制解除了,不再是一个真正的状态了,或者理解为,k为无穷大的时候,k,k+1,k-1都是一样的大小,套用上面仅记录相邻状态的写法:

    class Solution {
        public int maxProfit(int[] prices) {
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE;
    
            for(int i=0;i<prices.length;i++){
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
                dp_i_1=Math.max(dp_i_1,dp_i_0-prices[i]);
            }
            return dp_i_0;
    
        }
    }
    

    leet123
    这里是k=2的情况即k真正开始发挥作用了

    class Solution {
        public int maxProfit(int[] prices) {
            int max_k=2;
            int n=prices.length;
            int[][][] dp=new int[n][max_k+1][2];
            for(int i=0;i<n;i++){
                for(int k=max_k;k>=1;k--){
                    if(i-1==-1){
                        dp[i][k][0]=0;
                        dp[i][k][1]=-prices[i];
                        continue;
                    }
                    dp[i][k][0]=Math.max(dp[i-1][k][0],dp[i-1][k][1]+prices[i]);
                    dp[i][k][1]=Math.max(dp[i-1][k][1],dp[i-1][k-1][0]-prices[i]);
                }
            }
            return dp[n-1][max_k][0];
    
        }
    }
    

    当然这里因为k=2,所以把k=1,2和condition为1或0的共四种情况列出来也可以。

    leet188.
    这里相当于是之前讨论的情况,按道理来说,把上面的代码中max_k改为题目中输入的k即可,但如果直接这样,会导致内存不够,因为k可以非常非常大,当大到一定程度的时候,就会占用太多内存,并且此时k其实无限制作用了,这里,题目中的交易是按照买+卖两步算一次交易,即当k>n/2的时候(n为总共几天),k就相当于无限制作用,即上面k为无穷大的情况。

    class Solution {
        public int maxProfit(int max_k,int[] prices) {
            int n=prices.length;
            if(max_k>n/2){
                return maxProfit_withoutk(prices);
            }
            int[][][] dp=new int[n][max_k+1][2];
            for(int i=0;i<n;i++){
                for(int k=max_k;k>=1;k--){
                    if(i-1==-1){
                        dp[i][k][0]=0;
                        dp[i][k][1]=-prices[i];
                        continue;
                    }
                    dp[i][k][0]=Math.max(dp[i-1][k][0],dp[i-1][k][1]+prices[i]);
                    dp[i][k][1]=Math.max(dp[i-1][k][1],dp[i-1][k-1][0]-prices[i]);
                }
            }
            return dp[n-1][max_k][0];
    
        }
    
        public int maxProfit_withoutk(int[] prices) {
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE;
    
            for(int i=0;i<prices.length;i++){
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
                dp_i_1=Math.max(dp_i_1,dp_i_0-prices[i]);
            }
            return dp_i_0;
    
        }
    }
    

    leet309.最佳买卖股票时机含冰冻期
    这题是k为无穷大的情况下,加个冰冻期,也就是在buy的时候,必须从i-2,而不是i-1。

    class Solution {
        public int maxProfit(int[] prices) {
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE;
            int dp_pre_0=0;
    
            for(int i=0;i<prices.length;i++){
                int temp=dp_i_0;
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
                dp_i_1=Math.max(dp_i_1,dp_pre_0-prices[i]);
                dp_pre_0=temp;
            }
            return dp_i_0;
        }
    }
    

    leet714.买卖股票的最佳时机含手续费

    就是在计算中加个fee就行

    class Solution {
        public int maxProfit(int[] prices,int fee) {
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE;
    
            for(int i=0;i<prices.length;i++){
                int temp=dp_i_0;
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
                dp_i_1=Math.max(dp_i_1,temp-prices[i]-fee);
            }
            return dp_i_0;
        }
    }
    

    关键就在于找到所有可能的「状态」,然后想想怎么更新这些「状态」。一般用一个多维 dp 数组储存这些状态,从 base case 开始向后推进,推进到最后的状态,就是我们想要的答案。具体到股票买卖问题,我们发现了三个状态,使用了一个三维数组,无非还是穷举 + 更新,不过我们可以说的高大上一点,这叫「三维 DP」。

    2.打家劫舍问题
    leet198.
    本题相对来说比较普通,很明显是动态规划,状态很好找,就是一个个房子,选择就是抢还是不抢。当抢了当前的房子,那么下一家不能抢,不抢当前的房子,那么下一家可抢可不抢。因为状态只有一个,所以用一维dp[] 就可以搞定。

    class Solution {
        public int rob(int[] nums) {
            int n=nums.length;
            int[] dp=new int[n+2];
    
            for(int i=n-1;i>=0;i--){
                dp[i]=Math.max(dp[i+1],nums[i]+dp[i+2]);
            }
    
            return dp[0];
    
        }
    }
    

    当然,通过状态转移方程发现dp[i] 只与 dp[i+1] & dp[i+2] 有关,那么可以进一步优化:

    int rob(int[] nums) {
        int n = nums.length;
        // 记录 dp[i+1] 和 dp[i+2]
        int dp_i_1 = 0, dp_i_2 = 0;
        // 记录 dp[i]
        int dp_i = 0; 
        for (int i = n - 1; i >= 0; i--) {
            dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
            dp_i_2 = dp_i_1;
            dp_i_1 = dp_i;
        }
        return dp_i;
    }
    

    leet213.
    相较于上一题,本题把排变成了一个圈,也就是一个环形数组。如何处理这个环形数组是个关键,事实上,如果抛开连接起来的头和尾,与上题并没有什么不同,那么就单独看一看对于头尾的策略:头偷尾不偷,尾偷头不偷,头尾都不偷。那么稍微更改上面的代码,加两个变量代表start和end,就可以控制nums中的那一部分进行研究。优化:头尾都不偷的时候,选择范围比前两种少,那么就不用讨论了。

    class Solution {
        public int rob(int[] nums) {
            int n=nums.length;
            if(n==1) return nums[0];
            return Math.max(rob_cal(nums,0,n-2),rob_cal(nums,1,n-1));
    
        }
    
        int rob_cal(int[] nums,int start,int end){
            int rob_0=0;
            int rob_1=0;
    
            for(int i=end;i>=start;i--){
                int temp=rob_0;
                rob_0=Math.max(rob_0,nums[i]+rob_1);
                rob_1=temp;
            }
    
            return rob_0;
        }
    }
    

    leet337.
    这题换成了在二叉树上打劫,我们可以设置一个dp函数表示,当前root处偷和不偷造成的各自最大值是多少。

    class Solution {
        public int rob(TreeNode root) {
            int[] res=dp(root);
            return Math.max(res[0],res[1]);
        }
    
        int[] dp(TreeNode root){
            if(root==null){
                return new int[]{0,0};
            }
    
            int[] left=dp(root.left);
            int[] right=dp(root.right);
    
            int do_it=root.val+left[0]+right[0];
            int not_do=Math.max(left[0],left[1])+Math.max(right[0],right[1]);
    
            return new int[]{not_do,do_it};
        }
    }
    

    3.高楼扔鸡蛋问题
    若干层楼,若干个鸡蛋,让你算出最少的尝试次数,找到鸡蛋恰好摔不碎的那层楼。
    题目是这样:你面前有一栋从 1 到NN层的楼,然后给你K个鸡蛋(K至少为 1)。现在确定这栋楼存在楼层0 <= F <= N,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于F的楼层都会碎,低于F的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层F呢?
    PS:F 可以为 0,比如说鸡蛋在 1 层都能摔碎,那么 F = 0。

    也就是让你找摔不碎鸡蛋的最高楼层F,但什么叫「最坏情况」下「至少」要扔几次呢?比方说现在先不管鸡蛋个数的限制,有 7 层楼,你怎么去找鸡蛋恰好摔碎的那层楼?最原始的方式就是线性扫描:我先在 1 楼扔一下,没碎,我再去 2 楼扔一下,没碎,我再去 3 楼……以这种策略,最坏情况应该就是我试到第 7 层鸡蛋也没碎(F = 7),也就是我扔了 7 次鸡蛋。什么叫做「最坏情况」,鸡蛋破碎一定发生在搜索区间穷尽时,不会说你在第 1 层摔一下鸡蛋就碎了,这是你运气好,不是最坏情况。

    最坏情况下的最少,就是说都考虑最坏情况的条件下,需要的次数的最小值。

    实际上,如果不限制鸡蛋个数的话,二分思路显然可以得到最少尝试的次数,但问题是,现在给你了鸡蛋个数的限制K,直接使用二分思路就不行了。比如说只给你 1 个鸡蛋,7 层楼,你敢用二分吗?你直接去第 4 层扔一下,如果鸡蛋没碎还好,但如果碎了你就没有鸡蛋继续测试了,无法确定鸡蛋恰好摔不碎的楼层F了。这种情况下只能用线性扫描的方法,算法返回结果应该是 7。

    有的人也许会有这种想法:二分查找排除楼层的速度无疑是最快的,那干脆先用二分查找,等到只剩 1 个鸡蛋的时候再执行线性扫描,这样得到的结果是不是就是最少的扔鸡蛋次数呢?很遗憾,并不是,比如说把楼层变高一些,100 层,给你 2 个鸡蛋,你在 50 层扔一下,碎了,那就只能线性扫描 1~49 层了,最坏情况下要扔 50 次。

    方法一:普通的动态规划
    「状态」很明显,就是当前拥有的鸡蛋数K和需要测试的楼层数N。随着测试的进行,鸡蛋个数可能减少,楼层的搜索范围会减小,这就是状态的变化。

    「选择」其实就是去选择哪层楼扔鸡蛋。回顾刚才的线性扫描和二分思路,二分查找每次选择到楼层区间的中间去扔鸡蛋,而线性扫描选择一层层向上测试。不同的选择会造成状态的转移。

    现在明确了「状态」和「选择」,动态规划的基本思路就形成了:肯定是个二维的dp数组或者带有两个状态参数的dp函数来表示状态转移;外加一个 for 循环来遍历所有选择,择最优的选择更新结果 :我们在第i层楼扔了鸡蛋之后,可能出现两种情况:鸡蛋碎了,鸡蛋没碎。注意,这时候状态转移就来了如果鸡蛋碎了,那么鸡蛋的个数K应该减一,搜索的楼层区间应该从[1..N]变为[1..i-1]i-1层楼;如果鸡蛋没碎,那么鸡蛋的个数K不变,搜索的楼层区间应该从 [1..N]变为[i+1..N]N-i层楼。

    (这里有一个小细节):在第i层楼扔鸡蛋如果没碎,楼层的搜索区间缩小至上面的楼层,是不是应该包含第i层楼呀?不必,因为已经包含了。开头说了 F 是可以等于 0 的,向上递归后,第i层楼其实就相当于第 0 层,可以被取到,所以说并没有错误。

    因为我们要求的是最坏情况下扔鸡蛋的次数,所以鸡蛋在第i层楼碎没碎,取决于那种情况的结果更大

    def dp(K, N):
        for 1 <= i <= N:
            # 最坏情况下的最少扔鸡蛋次数
            res = min(res, 
                      max( 
                            dp(K - 1, i - 1), # 碎
                            dp(K, N - i)      # 没碎
                         ) + 1 # 在第 i 楼扔了一次
                     )
        return res
    

    递归的 base case 很容易理解:当楼层数N等于 0 时,显然不需要扔鸡蛋;当鸡蛋数K为 1 时,显然只能线性扫描所有楼层:

    def dp(K, N):
        if K == 1: return N
        if N == 0: return 0
        ...
    

    至此,其实这道题就解决了!只要添加一个备忘录消除重叠子问题即可:

    def superEggDrop(K: int, N: int):
    
        memo = dict()
        def dp(K, N) -> int:
            # base case
            if K == 1: return N
            if N == 0: return 0
            # 避免重复计算
            if (K, N) in memo:
                return memo[(K, N)]
    
            res = float('INF')
            # 穷举所有可能的选择
            for i in range(1, N + 1):
                res = min(res, 
                          max(
                                dp(K, N - i), 
                                dp(K - 1, i - 1)
                             ) + 1
                      )
            # 记入备忘录
            memo[(K, N)] = res
            return res
    
        return dp(K, N)
    

    这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是子问题个数 × 函数本身的复杂度

    函数本身的复杂度就是忽略递归部分的复杂度,这里dp函数中有一个 for 循环,所以函数本身的复杂度是 O(N)。

    子问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。

    所以算法的总时间复杂度是 O(K*N^2), 空间复杂度为子问题个数,即 O(KN)。

    这个问题很复杂,但是算法代码却十分简洁,这就是动态规划的特性,穷举加备忘录/DP table 优化,真的没啥新意。

    首先,有读者可能不理解代码中为什么用一个 for 循环遍历楼层[1..N],也许会把这个逻辑和之前探讨的线性扫描混为一谈。其实不是的,这只是在做一次「选择」

    比方说你有 2 个鸡蛋,面对 10 层楼,你得拿一个鸡蛋去某一层楼扔对吧?那选择去哪一层楼扔呢?不知道,那就把这 10 层楼全试一遍。至于鸡蛋碎没碎,下次怎么选择不用你操心,有正确的状态转移,递归会算出每个选择的代价,我们取最优的那个就是最优解。

    方法二:带二分搜索的优化动态规划
    这一种方法其实核心是因为状态转移方程的单调性。在上述方法中的状态转移方程之中,核心是:

    # 当前状态为 K 个鸡蛋,面对 N 层楼
    # 返回这个状态下的最优结果
    def dp(K, N):
        for 1 <= i <= N:
            # 最坏情况下的最少扔鸡蛋次数
            res = min(res, 
                      max( 
                            dp(K - 1, i - 1), # 碎
                            dp(K, N - i)      # 没碎
                         ) + 1 # 在第 i 楼扔了一次
                     )
        return res
    

    这里最关键的就是在该层时做出的“选择”,即碎与没碎,首先我们根据dp(K, N)数组的定义(有K个鸡蛋面对N层楼,最少需要扔 dp(K, N) 次),很容易知道K固定时,这个函数随着N的增加一定是单调递增的,无论你策略多聪明,楼层增加的话,测试次数一定要增加。那么注意dp(K - 1, i - 1)dp(K, N - i)这两个函数,其中i是从 1 到N单增的,如果我们固定KN把这两个函数看做关于i的函数,前者随着i的增加应该也是单调递增的,而后者随着i的增加应该是单调递减的。换句话说,在坐标轴之中把函数画出来,一定是一个单增直线与一个单减直线相交于一点,并且只取上部分(即相交点以上的地方,也就是两个直线中较大的值),又因为整个for i 循环部分都是在找最小值,所以这里可以改成这两个函数上部分的最小值(也就是相交点)与res的比较,整个部分就变成了二分搜索找最小值。(找valley值)直接看代码吧,整体的思路还是一样,只是加快了搜索速度:

    def superEggDrop(self, K: int, N: int) -> int:
    
        memo = dict()
        def dp(K, N):
            if K == 1: return N
            if N == 0: return 0
            if (K, N) in memo:
                return memo[(K, N)]
    
            # for 1 <= i <= N:
            #     res = min(res, 
            #             max( 
            #                 dp(K - 1, i - 1), 
            #                 dp(K, N - i)      
            #                 ) + 1 
            #             )
    
            res = float('INF')
            # 用二分搜索代替线性搜索
            lo, hi = 1, N
            while lo <= hi:
                mid = (lo + hi) // 2
                broken = dp(K - 1, mid - 1) # 碎
                not_broken = dp(K, N - mid) # 没碎
                # res = min(max(碎,没碎) + 1)
                if broken > not_broken:
                    hi = mid - 1
                    res = min(res, broken + 1)
                else:
                    lo = mid + 1
                    res = min(res, not_broken + 1)
    
            memo[(K, N)] = res
            return res
    
        return dp(K, N)
    

    这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是子问题个数 × 函数本身的复杂度。函数本身的复杂度就是忽略递归部分的复杂度,这里dp函数中用了一个二分搜索,所以函数本身的复杂度是 O(logN)。子问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。所以算法的总时间复杂度是 O(K N logN), 空间复杂度 O(KN)。效率上比之前的算法 O(KN^2) 要高效不少。

    这种方法就是对第一种方法进行剪枝,用一个二分法来替代第一种方法的循环过程,其实是在普通的动态规划的写法上的加工,动态规划本身的部分并没有改变。

  • 相关阅读:
    《几何与代数导引》习题1.34.2
    《几何与代数导引》习题1.35.3
    《几何与代数导引》习题1.27
    【Android游戏开发之八】游戏中添加音频详解MediaPlayer与SoundPoo!并讲解两者的区别和游戏中的用途!
    【Android游戏开发之十】(优化处理)详细剖析Android Traceview效率检视工具,分析程序运行速度!并讲解两种创建SDcard方式!
    【Android游戏开发之十】(优化处理)详细剖析Android Traceview效率检视工具,分析程序运行速度!并讲解两种创建SDcard方式!
    【Android游戏开发之七】(游戏开发中需要的样式)再次剖析游戏开发中对SurfaceView中添加组件方案!
    【Android游戏开发之六】在SurfaceView中添加系统控件,并且相互交互数据!
    【Android游戏开发十八】解放手指,利用传感器开发游戏!(本文讲解在SurfaceView中用重力传感器控制圆球的各方向移动)
    【Android游戏开发十一】手把手让你爱上Android sdk自带“9妹”(9patch 工具),让Android游戏开发更方便!
  • 原文地址:https://www.cnblogs.com/shiji-note/p/14459940.html
Copyright © 2011-2022 走看看