算法之动态规划(线性dp)

在这里插入图片描述

什么是动态规划

若某些问题可以被分成与该问题相同的小问题解决及某一状态与之前的状态有联系,则该问题可以使用动态规划解决。

动态规划能解决的问题

背包问题

0-1背包
完全背包

打家劫舍
股票问题
其他最值问题

动规往往可以解决的是最值问题,如背包问题的最大值,和最大组合数,打家劫舍的最大偷钱数,股票问题的最大收益。

动规解决问题的方法

1.确认dp数组及其下标的含义。
2.写出递推公式。
3.初始化dp数组。
4.确定遍历顺序。

动态规划经典OJ

0-1背包

0-1背包描述:0-1背包的描述是有一些物品,它们有价值和重量,吧它们装进容量为weight的背包中(每个物品只能选一次),背包的最大价值为多少。
0-1背包的数学描述:有一集合A = { (v,w) |v > 0,w > 0},要选出集合result,使得result中的元素w的和小于weight,使得v的和最大。

由上述数学描述可得,传统背包问题的本质是求一个集合限制了一个维度,求另一个维度的最值,并且result数组是否会出现排列对结果无影响。


分割等和子集

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

  • 注意: 每个数组中的元素不会超过 100 数组的大小不会超过 200

问题解析:分割成两个和相等的子集,其实就是背包容量为sum/2的背包是否能被装满,典型的背包问题,对于一个数组而言,背包的价值和重量都是数组的值。
dp[i] 代表容量为i的背包的最大价值,对于遍历到的某一物品来说,要么取该物品得到最大价值,要么不去该物品,这则是该状态的前一个状态

int max(int a, int b)
{
    
    return a > b? a: b;
}
bool canPartition(int* nums, int numsSize)
{
    // 背包问题
    int sum = 0;
    int target = 0;
    for(int i = 0; i < numsSize; i++)
    {
        sum+=nums[i];
    }
    if(sum % 2)
        return false;


    target = sum/2;
    int dp[target+1];

    memset(dp, 0 , sizeof(int)*(target+1));

    
    for(int i = 0; i < numsSize; ++i) 
    {
        for(int j = target; j > 0; --j) 
        {
            if(j < nums[i])
            {
                ;
            }
            else
            dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
        }
    }

    return dp[target] == target;
}

1.对于该题目,因为所求的依旧是传统背包问题(及能否装满)则遍历顺序无所谓。
2.初始化dp数组时,dp[0],装满容量为0的背包,应该初始化为0,其后所有背包的容量都会比0大,所以其他都初始化为0.
3.本题我使用了滚动数组解决,在滚动数组中,为了不重复取某一元素,则遍历背包时要从后往前遍历。

该题的思想和最后一块石头的重量有相似之处


目标和

  • 给你一个非负整数数组 nums 和一个整数 target 。

  • 向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。

  • 返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

问题解析:该问题主要有2个维度,一个是加法的和,一个是所以减法的和,它们之间有关系,加法和为x,减法和为x-sum,它们两相加要等于target,所以我可以得到x=target+sum/2由于题目数组为整数数组,所以如歌x向下取整,则直接返回false。那么该题目就变成了返回装满容量为x的背包有多少种方法。
求0-1背包方法问题:方法其实是组合数,所以对于方法问题要求遍历顺序,则方法问题的dp[i]代表装满背包i有多少种方法,则dp[i]+=dp[i-nums[j]]
拓展:其实方法数也可以看作传统0-1背包,只要吧数组的所有组合看作新的数组,每种组合的和看作这个新数组的每个元素的重量,每个元素的价值为1,则依旧是一个传统0-1背包问题。

int Sum(int* arr, int size)
{
    int sum = 0;
    for(int i = 0; i < size; i++)
    {
        sum+=arr[i];
    }

    return sum;

}
int findTargetSumWays(int* nums, int numsSize, int target) 
{
    // - 加法和为x,减法和为sum-x
    // - 则x = (sum+target)/2 - 若向下取整了,则代表x是一个小数,则不符合题意,直接返回
    // - 装满容量为x的背包的方法数。
    int sum = Sum(nums, numsSize);
    if((sum+target)%2 != 0)
    return 0;
    if(abs(target) > sum)
    return 0;
    int x = (sum+target)/2;

    int dp[x+1];
     // - dp[0]必须是1,否则若dp[i-nums[j]] i-nums[j] = 0时,方法数为0错误。
     dp[0] = 1;
     for(int i = 1; i < x+1; i++)
     {
         dp[i] = 0;
     }

    for(int i = 0; i < numsSize; i++)
    {
        for(int j = x; j >= nums[i]; j--)
        {
            dp[j] += dp[j-nums[i]];
        }

    }

    return dp[x];

}

由该题目的递推公式可知,遍历顺序从左—>右,而dp[0]则是装满容量为0的背包的组合数,该值没有意义,纯粹是为了推出其他结果,以后有很多题目dp[0]都没有意义,只是要推出其他结果而以。


一和零

  • 给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

  • 请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

  • 如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

问题分析:该题和普通的0-1背包没有什么区别,只是多了一个背包装东西,要保证每个背包都有最多的0和1,而价值则是1,及每个子集,dp[i][j]装满容量为i和j的背包的最大价值,对于遍历到的集合而言,要么装,要么不装,dp[i][j]=max(dp[i][j],dp[i-nums1][j-nums0])

int findMaxForm(char** strs, int strsSize, int m, int n) 
{
    // - 有m个0,n个1的最大子集长度为dp[m][n];

    int dp[m+1][n+1];
    memset(dp, 0, sizeof(dp));

    for(int i = 0; i < strsSize; i++)
    {
        int oneNum = 0, zeroNum = 0;
        for (int j = 0; j < strlen(strs[i]); j++) 
        {
            if (strs[i][j] == '0') 
                zeroNum++;
            else 
                oneNum++;
        }
        
        for(int k = m; k >= zeroNum; k--)
        {
            for(int l = n; l >= oneNum; l--)
            {
                dp[k][l] = fmax(dp[k][l], dp[k-zeroNum][l-oneNum]+1);

            }

        }

    }    
    return dp[m][n];
}

改题目的2个维度会有概率和一个维度的非滚动数组的dp公式搞混,该题我所写的dp公式依旧是滚动数组的方法,所以要好好了解dp数组的含义。


完全背包

完全背包描述:完全背包与0-1背包很像,唯一的差别是0-1背包每个物品只有一个,而完全背包每个物品有无限个,及可以重复取某一物品。

零钱兑换II

  • 给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

  • 请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

  • 假设每一种面额的硬币有无限个。

  • 题目数据保证结果符合 32 位带符号整数。

问题分析:该题和0-1背包的唯一不同就是某值可以重复取,所以使用滚动数组遍历背包容量时,要从前往后遍历。

int change(int amount, int* coins, int coinsSize) 
{
    // - 完全背包

    int dp[amount+1];
    memset(dp, 0, sizeof(dp));
    dp[0] = 1;

    // - 求组合

    for(int i = 0; i < coinsSize; i++)
    {
        for(int j = 1; j <= amount; j++)
        {
            if(j < coins[i])
            dp[j] = dp[j];
            else
            dp[j]+=dp[j-coins[i]];
        }

    }    

    return dp[amount];
}

组合总和IV

  • 给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。

  • 题目数据保证答案符合 32 位整数范围。

问题分析:完全背包问题,并且求排列问题,所以对遍历顺序有要求。


int combinationSum4(int* nums, int numsSize, int target)
{
    // - 可以重复取,完全背包
    int dp[target+1];

    // - 求排列问题 - 先遍历背包,在物品
    memset(dp, 0, sizeof(dp));

    dp[0] = 1;

    for(int i = 1; i < target+1; i++)
    {
        for(int j = 0; j < numsSize; j++)
        {
            if(i < nums[j] || dp[i] > INT_MAX - dp[i - nums[j]])
                dp[i] = dp[i];
            else
            dp[i]+=dp[i-nums[j]];
        }
    }
    return dp[target];
    
}

dp[0]依旧初始化为1,由于对遍历顺序由影响,所以在循环体内需要对数组的下标做合法性判断。


零钱兑换

  • 给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

  • 计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

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

问题分析:本题为完全背包问题,但是不同的是,以往都是求最大值,但是该题要求最小值,所以在初始化dp数组时很可能导致惯性思维让数组除dp[0]外全部初始化为0,这是错误的,因为组合数时属于[1,n]的所以如果初始化为0则无论如何都无法更新数组的值。

int min(int a,int b) {return a>b?b:a;}
int coinChange(int* coins, int coinsSize, int amount)
{
    int dp[amount+1];
    for(int i = 0; i < amount+1; i++)
    {
        dp[i] = INT_MAX;
    }
    dp[0]=0;

    for(int i=0;i<coinsSize;i++)
    {
        for(int j=1;j<=amount;j++)
        {
        	// - dp[i-coins[j]] != INT_MAX是保证dp[j]是有意义的。
            if(j >= coins[i] && dp[j-coins[i]]!=INT_MAX)
            {

                dp[j]=min(dp[j-coins[i]]+1,dp[j]);
            }
        }
    }
    if(dp[amount]==INT_MAX) return -1;
    return dp[amount];
}

在判断部分加入dp[i-coins[j]] != INT_MAX,是为了不更新dp[j],因为没有比较,所以就不必更新dp[j]的值,同时是为了dp[j-coins[i]]的值不超过整形最大值。

该题与完全平方数思路有相似之处,可以参考。


打家劫舍

打家劫舍问题描述:其实还是0-1背包问题的变种,不过限制条件不再是小于某个值,而是在选了某些值后,某些值不能被选择了,要我们求出最大值。

打家劫舍

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

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

问题分析:偷了某个房子后,接下来的一个房子就不能偷了,那么可以对于某个房子偷/不偷入手,如果偷,则上一间房子就不能偷,如果不偷,则无限制。
dp[i] = max(dp[i-2]+nums[i], dp[i-1])

int max(int a, int b)
{

    return a>b?a:b;
}
int rob(int* nums, int numsSize) 
{
    //特殊处理
    if(numsSize == 1)
        return nums[0];
    if(numsSize == 2)
        return max(nums[0], nums[1]);

    int dp[numsSize+1];
    memset(dp, 0, sizeof(dp));
    // - 偷一号房间
    dp[1] = nums[0];
    // - 偷[1,2]号房间
    dp[2] = max(nums[0], nums[1]);

    for(int i = 3; i <= numsSize; i++)
    {
        dp[i] = max(dp[i-2]+nums[i-1], dp[i-1]);
    }    
    return dp[numsSize];
}

dp[i]的含义是到第i间房间我的最大收益为dp[i],由于dp数组推倒值有dp[i-2],所以dp[1],dp[2]都要初始化,没有第0间房间,所以dp[0]无意义。


打家劫舍II

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

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

问题分析:与打家劫舍相比,这道题唯一增加的地方就是房子连成环了,连成环,则第一家和最后一家就可以分开讨论了,如果第一家在我偷窃的计划中,则最后一家就不能在计划中,若最后一家在计划中,则第一家就不能在计划中。

int max(int a, int b)
{
    return a>b?a:b;
}

int mycase(int* nums, int start, int end)
{
    if((end-start+1) == 1)
        return nums[start];
    if((end-start+1) == 2)
        return max(nums[start], nums[end]);

    int dp[end-start+2];
    memset(dp, 0, sizeof(dp));
    // - 偷一号房间
    dp[1] = nums[start];
    // - 偷[1,2]号房间
    dp[2] = max(nums[start], nums[start+1]);

    for(int i = 3; i <= end-start+1; i++)
    {
        if(start == 0)
            dp[i] = max(dp[i-2]+nums[i-1], dp[i-1]);
        else
            dp[i] = max(dp[i-2]+nums[i], dp[i-1]);
    }    
    return dp[end-start+1];

}

int rob(int* nums, int numsSize) 
{
    // - 只有2种情况,第一个房间不在范围内
    // - 最后一个房间不在范围内,两种情况取最大值。

	// - 特殊处理
    if(numsSize == 1)
    return nums[0];
    if(numsSize == 2)
    return max(nums[0], nums[1]);
    // - [1,numsSize-1]
    int res1 = mycase(nums, 0, numsSize-2);
    // - [2,numsSize]
    int res2 = mycase(nums, 1, numsSize-1);    

    return res1 > res2? res1: res2;
}

与打家劫舍一样,只是要计算两次,然后在进行最终比较。

股票问题

股票问题描述:股票问题是多状态dp,基础的股票问题将入和出分为两个状态,多次买卖被分为多个状态,进行递归计算。

买卖股票的最佳时机

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

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

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

问题分析:该问题是经典的股票问题,股票问题可以将入,出分为两个状态,dp[i][0]代表入的状态,dp[i][1]代表出状态,dp[i][0] 代表在第i天持股所获得的最大金额为dp[i][0],dp[i][1] 代表在第i天不持股所获得的最大金额为dp[i][0]。

int max(int a, int b)
{
    return a>b?a:b;
}
int maxProfit(int* prices, int pricesSize) 
{
    // - 股票问题 - 多路dp,对入和出分成2中状态去动规。
    int dp[pricesSize+1][2];
    memset(dp, 0, sizeof(dp));

    dp[1][0] = -prices[0];

    for(int i = 2; i < pricesSize+1; i++)
    {
    	// - 第i天持股的最大金额=要么在第i天买,要么最大收益就在之前几天买。
        dp[i][0] = max(dp[i-1][0], -prices[i-1]);
        dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i-1]);
    }

    return dp[pricesSize][1];
}

买卖股票的最佳时机 II

  • 给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。

  • 在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。

  • 返回 你能获得的 最大 利润 。

问题分析:该问题与买卖股票的最佳时机的区别是可以多次交易,要么第i天买入,要么第i天持有,在第i天买入,则要保证在第i-1天不持有股票,所以由dp[i-1][1]推出,dp[i][1]同理。

int max(int a, int b)
{
    return a>b?a:b;
}
int maxProfit(int* prices, int pricesSize) 
{
    // - 股票可以多次买卖
    int dp[pricesSize+1][2];
    memset(dp, 0, sizeof(dp));

    dp[1][0] = -prices[0];
    dp[1][1] = 0;
    for(int i = 2; i < pricesSize+1; i++)
    {
        // - dp[i-1][1]-prices[i-1]原来的利润-购买的价格
        // - 要么已经交易过了,要么没有交易。
        dp[i][0] = max(dp[i-1][0], dp[i-1][1]-prices[i-1]);
        dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i-1]);
    }    

    return dp[pricesSize][1];
}

本题可以多次交易,并且要注意第i天只有买/不买第i天股票的2种选择,要买第i天的股票就要保证先不持有股票才能购买。

买卖股票的最佳时机 III

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

  • 设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

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

问题分析:该问题限制了交易次数,最多可以完成两笔交易,则可以分为多个不同的状态,dp[i][0]代表第i天不操作,dp[i][1]代表第一次持有股票的金额,dp[i][2]代表第一次不持有股票的金额,dp[i][3]代表第二次持有股票的金额,dp[i][4]代表第二次不持有股票的金额,分为4种状态进行多路dp,注:出现第i天不操作的情况是为了统一和计算方便,总结出多次交易dp数组的规律。

int max(int a, int b)
{
    return a>b?a:b;
}
int maxProfit(int* prices, int pricesSize) 
{
    int dp[pricesSize+1][5];
    // dp[i][0] - 在第i天不操作
    // dp[i][1] - 在第i天持有股票
    // dp[i][2] - 在第i天不持有股票
    // dp[i][3] - 在第i天第二次持有股票
    // dp[i][4] - 在第i天第二次不持有股票
    memset(dp, 0, sizeof(dp));
    dp[1][0] = 0;
    dp[1][1] = -prices[0];
    dp[1][2] = 0;
    dp[1][3] = -prices[0];
    dp[1][4] = 0;


    for(int i = 2; i < pricesSize+1; i++)
    {
        dp[i][0] = dp[i-1][0];
        dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i-1]);
        dp[i][2] = max(dp[i-1][2], dp[i-1][1]+prices[i-1]);
        dp[i][3] = max(dp[i-1][3], dp[i-1][2]-prices[i-1]);
        dp[i][4] = max(dp[i-1][4], dp[i-1][3]+prices[i-1]);
    }

    return dp[pricesSize][2] > dp[pricesSize][4]? dp[pricesSize][2]: dp[pricesSize][4];
}

对于股票问题只要列出第i天可能的所有状态并且做动态规划,你能很好解决。

买卖股票的最佳时机 IV

  • 给你一个整数数组 prices 和一个整数 k ,其中 prices[i] 是某支给定的股票在第 i 天的价格。

  • 设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。也就是说,你最多可以买 k 次,卖 k 次。

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

问题分析:与上道题相似,只不过最多可交易k次,也就是说,由2*k+1个状态,多出的一个状态时在第i天不进行任何操作,方便总结规律。

class Solution {
public:
    int maxProfit(int k, vector<int>& prices)
    {
        vector<vector<int>> dp(prices.size()+1, vector<int>(2*k+1, 0));

        for(int i = 1; i < 2*k+1; i+=2)
        {
            dp[1][i] = -prices[0];
        }

        // - 状态
        for(int i = 1; i < 2*k+1; i+=2)
        {
            // - 每天的股票
            for(int j = 2; j < prices.size()+1; j++)
            {
                // - 入 --- 第j天的股票,下标为j-1
                dp[j][i] = max(dp[j-1][i], dp[j-1][i-1]-prices[j-1]);
                // - 出
                dp[j][i+1] = max(dp[j-1][i+1], dp[j-1][i]+prices[j-1]);

            }
        }
        return dp[prices.size()][2*k];
    }
};
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值