算法----动态规划

本文深入讲解动态规划的基本概念和核心技巧,通过经典案例解析01背包、完全背包、打家劫舍系列、股票系列等问题,帮助读者掌握动态规划解决实际问题的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

动态规划

只问最优解不问具体解。

目录

动态规划

背包问题

01背包

完全背包

打家劫舍系列

股票系列

子序列


背包问题

背包问题技巧:

一维滚动数组

1.如果是0-1背包,即数组中的元素不可重复使用,nums放在外循环,target在内循环,且内循环倒序;

for num in nums: 

    for i in range(target, num, -1):

2.如果是完全背包,即数组中的元素可重复使用,nums放在外循环,target在内循环。且内循环正序。

for num in nums:

    for i in range(num, target):

3.如果组合问题需考虑元素之间的顺序(排列),需将target放在外循环,将nums放在内循环。

for i in range(1, target+1):

    for num in nums:  

二位数组遍历物品从下标1开始,为了防止dp[i-1]越界,因此要先初始化dp[0][x]。一维数组则从下标0开始

 

01背包

每个元素都有两种选择,把O(2^n)的问题转化为O(n),可以看作是对回溯的一种简化。

416. 分割等和子集

给定一个只包含正整数非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

输入: [1, 5, 11, 5]

输出: true

解释: 数组可以分割成 [1, 5, 5] 和 [11].
class Solution {
    public boolean canPartition(int[] nums) {
       public boolean canPartition(int[] nums) {
           //dp[i][j]=true表示在前i个数中选择出元素使其和为j
        int sum=0;
        for(int i=0;i<nums.length;i++)
            sum+=nums[i];
        if(sum%2!=0)return false;
        boolean[][] dp=new boolean[nums.length][sum/2+1];
        //dp数组初始化 加nums[0]<sum/2的判断是为了返回值数组越界
        if(nums[0]<=sum/2)dp[0][nums[0]]=true;
        //要满足i-1>=0,i从1开始,i=0的要先初始化
        for(int i=1;i<nums.length;i++)
        {
            for(int j=0;j<=sum/2;j++)
            {
                dp[i][j]=dp[i-1][j];
                if(j>=nums[i])
                    dp[i][j]=dp[i-1][j]||dp[i-1][j-nums[i]];
            }
        }
        return dp[nums.length-1][sum/2];
    }
}
//一维滚动数组优化,下标i不需要从1开始,因为不需要dp[i-1]。
public boolean canPartition(int[] nums) {
        //dp[i][j]=true表示在前i个数中选择出元素使其和为j
        int sum=0;
        for(int i=0;i<nums.length;i++)
            sum+=nums[i];
        if(sum%2!=0)return false;
        boolean[] dp=new boolean[sum/2+1];
        //dp数组初始化,每个数都可以不选,所以dp[0]=true;
        dp[0]=true;
        for(int i=0;i<nums.length;i++)
        {
            for(int j=sum/2;j>=nums[i];j--)//注意逆序保证了dp[j-nums[i]]是“dp[i-1][j-nums[i]]" dp[j]是"dp[i-1][j]"
            {
                dp[j]=dp[j]||dp[j-nums[i]];
            }
        }
        return dp[sum/2];
    }

除了这种方法,还可以令dp[i][j]表示容量为j的背包在前i个石头中选能装下石头的最大价值,最每个石头的重量和价值都为stone[i],后判断dp[n][sum/2]是否等于sum/2即可验证背包是否装满。

1049. 最后一块石头的重量 II

有一堆石头,每块石头的重量都是正整数。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

  • 如果 x == y,那么两块石头都会被完全粉碎;
  • 如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x

最后,最多只会剩下一块石头。返回此石头最小的可能重量。如果没有石头剩下,就返回 0

class Solution {
   /*相当于用加号和减号把石头的重量连起来,并使结果最小。所以问题转换为把石头分为两拨,一拨是带加号,一拨是带减号。目标是求带减号的那拨石头,使其和<=sum/2,并接近于sum/2,这就相当于选石头放进容积为sum/2的包,使得质量和最大,各个石头的体积和质量相等都为stones[i]。分割等和子集和其过程一样,只是求的使容量为sum/2的背包能否装满*/

    public int lastStoneWeightII(int[] stones) {
     //dp[i][j]表示容量为j的背包在前i个石头中选能装下石头的最大价值
     int sum=0;
     for(int stone:stones)sum+=stone;
     int target=sum/2;
     int[][] dp=new int[stones.length][target+1];
     for(int i=stones[0];i<=target;i++)dp[0][i]=stones[0];
     for(int i=1;i<stones.length;i++)//因为要用到i-1,所以i>=1,i=0要初始化。
     {
         for(int j=0;j<=target;j++) 
         {
             dp[i][j]=dp[i-1][j];
             if(j>=stones[i])
                dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-stones[i]]+stones[i]);
         }
     }
     return sum-dp[stones.length-1][target]*2;

    
    }
}

494. 目标和

给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

 

该题每个元素不是选或不选而是取正或取负 但也是两种选择

class Solution {
    public int findTargetSumWays(int[] nums, int S) {
        //dp[i][j]表示在前i个数中选,目标和为j的方法数。
        int sum=0;
        for(int num:nums)sum+=num;
        if(Math.abs(S)>sum)return 0;//注意判断S是否超过了sum,否则return的结果会使数组越界。
        int [][] dp=new int[nums.length][sum*2+1];
        //因为下标不能为负数,所以整体加上sum
        if(nums[0]==0)dp[0][sum]=2;  //坑在这儿nums[0]=2,因为选择0和不选对结果的影响一样,但是两种方法。如dp[0][2]=dp[0][0]+dp[0][4] dp[0][0]=2 1.选0和2 2.只选2
        else{
        dp[0][sum+nums[0]]=1;
        dp[0][sum-nums[0]]=1;}
        for(int i=1;i<nums.length;i++)//该方法下就不能节约成滚动数组,因为当前值既依赖左上也依赖右上值。
        {
            for(int j=0;j<sum*2+1;j++)
            {
       
                if(j-nums[i]>=0)
                    dp[i][j]+=dp[i-1][j-nums[i]];
                if(j+nums[i]<=sum*2)
                    dp[i][j]+=dp[i-1][j+nums[i]];
            }
        }
        return dp[nums.length-1][S+sum];
    }
}

完全背包

完全背包和01背包的唯一区别就是完全背包每个元素的数量是无限的,可以选择无限个。

322. 零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1
class Solution {
    public int coinChange(int[] coins, int amount) {
        //dp[i][j]表示在前i种硬币里选凑出金额j的最少硬币个数
        int[][]dp =new int[coins.length][amount+1];
        //dp[i][j]初始化为amount+1,用于最后判断是否没有一种硬币组合能组合出这种面额
        for(int i=0;i<coins.length;i++)
        {
            for(int j=0;j<=amount;j++)
                dp[i][j]=amount+1;
        }
        for(int j=0;j<=amount;j++)
        {
            if(j%coins[0]==0)
                dp[0][j]=j/coins[0];
        }
        for(int i=1;i<coins.length;i++)
        {
            for(int j=0;j<=amount;j++)
            {
                dp[i][j]=dp[i-1][j];
                if(j-coins[i]>=0)
                    dp[i][j]=Math.min(dp[i-1][j],dp[i][j-coins[i]]+1);
            }
        }
        if(dp[coins.length-1][amount]==amount+1)return -1;
        return dp[coins.length-1][amount];
    }
}

//一维滚动数组优化
public int coinChange(int[] coins, int amount) {
        //dp[i][j]表示在前i种硬币里选凑出金额j的最少硬币个数
        int[] dp= new int[amount+1];
        dp[0]=0;
        for(int j=1;j<=amount;j++)dp[j]=amount+1;
        for(int i=0;i<coins.length;i++)
        {
            for(int j=coins[i];j<=amount;j++)  //与01背包唯一的区别是容量遍历顺序为正序,正序保证了dp[j-coins[i]]是"dp[i][j-coins[i]]"
            {
                dp[j]=Math.min(dp[j],dp[j-coins[i]]+1);
            }
        }
        return dp[amount]==amount+1?-1:dp[amount];
    }

518. 零钱兑换 II

 

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。 

输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
class Solution {
     public static int change(int amount, int[] coins) {
         //dp[i][j]表示在前i种硬币里选则可以凑成金额为j的硬币组合数
         if(coins.length==0)return amount==0?1:0;
         int[][] dp =new int[coins.length][amount+1];
         for(int i=0;i<=amount;i++)
         {
             if(i%coins[0]==0)dp[0][i]=1;
         }
         for(int i=1;i<coins.length;i++)
         {
             for(int j=0;j<=amount;j++)
             {
                 dp[i][j]=dp[i-1][j];
                 if(j-coins[i]>=0)
                 {
                     dp[i][j]=dp[i-1][j]+dp[i][j-coins[i]];
                 }
             }
         }
         return dp[coins.length-1][amount];

    }
}
//一维滚动数组优化
 public static int change(int amount, int[] coins) {
         //dp[i][j]表示在前i种硬币里选则可以凑成金额为j的硬币组合数
        int [] dp=new int[amount+1];
        dp[0]=1;
        for(int i=0;i<coins.length;i++)
        {
            for(int j=coins[i];j<=amount;j++)
            {
                dp[j]+=dp[j-coins[i]];
            }
        }
        return dp[amount];

    }

377. 组合总和 Ⅳ

给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

示例:

nums = [1, 2, 3]
target = 4

所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。因此输出为 7。

强调元素放入顺序的动态规划不能用二维dp

class Solution {
    public int combinationSum4(int[] nums, int target) {
        //dp[i]表示和为i的组合个数
        int[]dp=new int[target+1];
        dp[0]=1;
//如果强调元素的顺序,就要调换两个for循环的顺序,倘若先循环元素在循环和,2一定在1后面,是不会出现2 1这种情况的。
        for(int j=0;j<=target;j++)
        {
            for(int i=0;i<nums.length;i++)
            {
                if(j-nums[i]>=0)
                    dp[j]+=dp[j-nums[i]];
            }
        }
        return dp[target];
    }
}

139. 单词拆分

给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

//这是一个强调单词组合顺序的完全背包,如果先循环单词再循环长度则 catsdogcats这样的组合不会出现,因为dog一定出现在cats后面。

j必须s的子串的长度而非下标,因为不存在dp[-1],背包的初始情况就无法表示!!!!!

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
    //dp[j]表示长度为j的字符串能否被拆成一个或多个在字典中出现的单词。
       boolean []dp=new boolean[s.length()+1];
       dp[0]=true;
       for(int j=1;j<=s.length();j++)//坑!!j是长度不是下标
       {
          for(int i=0;i<wordDict.size();i++)
           {
               if(j-wordDict.get(i).length()>=0)
                    dp[j]=(s.substring(j-wordDict.get(i).length(),j).equals(wordDict.get(i))&&dp[j-wordDict.get(i).length()])||dp[j];//substring(begin,end)截取的是(begin到end-1)的字串
           }
       }
       return dp[s.length()];
    }
}

打家劫舍系列

198. 打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。

dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]。是考虑选择不是一定要偷第i间房

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

213. 打家劫舍 II

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,能够偷窃到的最高金额。

//把一圈房屋拆成第一个到倒数第二个,第二个到最后一个两排房屋
class Solution {
    public int rob(int[] nums) {
         if(nums.length==1)return nums[0];
         return Math.max(myrob(Arrays.copyOfRange(nums,0,nums.length-1)),myrob(Arrays.copyOfRange(nums,1,nums.length)));
    
    
}
//打家劫舍I
public int myrob(int []nums)
{
    if(nums.length==1)return nums[0];
    int [] dp=new int[nums.length];
        dp[0]=nums[0];dp[1]=Math.max(nums[0],nums[1]);
        for(int i=2;i<nums.length;i++)
        {
            dp[i]=Math.max(dp[i-2]+nums[i],dp[i-1]);
        }
        return dp[nums.length-1];
}
}

股票系列

121. 买卖股票的最佳时机

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

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

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

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

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

dp[i][0] 第i天不持有股票 有两种情况推导而来:1.第i-1天也不持有股票 dp[i][0]=dp[i-1][0] 2.第i-1天持有股票,第i天卖出 dp[i][0]=dp[i-1][1]-prices[i].

dp[i][1] 第i天持有股票 由两种情况推导而来: 1.第i-1天也持有股票 dp[i][1]=dp[i-1][1] 2.第i-1天没有股票,第i天买入,若允许多次买卖,dp[i][1]=dp[i-1][0]-prices[i],dp[i-1][0]包含前面交易的累积利润,若只允许一次买卖,dp[i][1]=-prices[0]

class Solution {
    public int maxProfit(int[] prices) {
        //dp[i][0]表示第i天不持有股票拥有的最大金钱
        //dp[i][1]]表示第i天持有股票拥有的最大金钱
        int [][]dp= new int[prices.length][2];
        dp[0][0]=0;dp[0][1]=-prices[0];
        for(int i=1;i<prices.length;i++)
        {
            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]);
            //如果可以多次交易dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
        }
        return dp[prices.length-1][0];
    }
}

 

子序列

子序列可以不连续,子数组必须连续。

dp[i]可以表示以A[i]结尾(必须包含A[i])的xxx,也可以表示在前i个元素中选择不一定包含第i个元素,取决于状态转移是否要求和上一个状态连续或有逻辑判断关系。

300. 最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

 dp[i]表示以nums[i]结尾的最长严格递增子序列的长度(子序列必须包含第i个元素)。之所以这样设定而不是dp[i]仅仅是前i个元素中选择,包不包含nums[i]都可,是为了方便进行状态转移!!

class Solution {
    public int lengthOfLIS(int[] nums) {
        //dp[i]表示以nums[i]结尾的最长严格递增子序列的长度(子序列必须包含第i个元素)。
        int[] dp =new int[nums.length];
        for(int i=0;i<nums.length;i++)
        {
            dp[i]=1;
            for(int j=0;j<i;j++)
            {
                if(nums[j]<nums[i])
                    dp[i]=Math.max(dp[i],dp[j]+1);
            }
        }
        int ans=0;
        for(int i=0;i<dp.length;i++)
        {
            ans=Math.max(dp[i],ans);
        }
        return ans;

    }
}

718. 最长重复子数组

给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1] 。

 因为每次在if(A[i-1]==B[j-1])成立时更新dp[i][j],由此也能看出dp[i][j]必须是以A[I],B[j]结尾的,而不是在前i个,前j个元素里选择

//子数组就是连续子序列
class Solution {
    public int findLength(int[] A, int[] B) {
        //dp[i][j]表示A的第i个元素和B的第j个元素结尾的最长公共子数组长度
        //若表示A[i]B[j]结尾的则i=0,j=0要提前初始化好/
        int[][] dp=new int[A.length+1][B.length+1];
        int ans=0;

        for(int i=1;i<=A.length;i++ )
        {
            for(int j=1;j<=B.length;j++)
            {
                if(A[i-1]==B[j-1])//下标-1
                {
                    dp[i][j]=Math.max(dp[i][j],dp[i-1][j-1]+1);
                }
                ans=Math.max(ans,dp[i][j]);
            }
        }
        return ans;

    }
}

1143. 最长公共子序列

给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0;

输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace",它的长度为 3。
class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        //dp[i][j]表示tex1前i个元素和text2前j个元素的最长公共子序列长度。不一定必须包含该元素
        int [][]dp=new int[text1.length()+1][text2.length()+1];
        int ans=0;
        for(int i=1;i<=text1.length();i++)
        {
            for(int j=1;j<=text2.length();j++)
            {
                if(text1.charAt(i-1)==text2.charAt(j-1))
                {
                    dp[i][j]=dp[i-1][j-1]+1;
                }
                else
                {
                    dp[i][j]=Math.max(dp[i-1][j],dp[i][j-1]);
                }
            }
        }
        return dp[text1.length()][text2.length()];
    }
}

5. 最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。

输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

如果按照ij从小到大的顺序枚举端点,无法保证计算dp[i][j]时dp[i+1][j-1]已经计算过,所以按子串长度和子串起始位置每局,初始边界条件时长度为1和2的子串。

class Solution {
    public String longestPalindrome(String s) {
    int[] ans=new int[2];
    //dp[i][j]=true表示以i开头j结尾的子串是回文子串
    boolean[][]dp=new boolean[s.length()][s.length()];
    //初始化回文子串长度为1和2的
    for(int i=0;i<s.length();i++)
    {
        dp[i][i]=true;
        if(i<s.length()-1&&s.charAt(i)==s.charAt(i+1))
        {
             dp[i][i+1]=true;
        }
           
    }
    //遍历子串长度 注意长度<=s.length()而不是<s.length()
    for(int len=3;len<=s.length();len++)
    {
        for(int l=0;l+len-1<s.length();l++)
        {
            int r=l+len-1;
            if(s.charAt(l)==s.charAt(r)&&dp[l+1][r-1])
            {
                dp[l][r]=true;
            }

        }
    }
    for(int len=1;len<=s.length();len++)
    {
        for(int i=0;i+len-1<s.length();i++)
        {
            if(dp[i][i+len-1])
            {
                ans[0]=i;
                ans[1]=i+len-1;
            }
        }
    }
    return s.substring(ans[0],ans[1]+1);
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值