前言
动态规划(Dynamic Programming,简称 DP)是一种解决多阶段决策过程最优化问题的方法。它是一种将复杂问题分解成重叠子问题的策略,通过维护每个子问题的最优解来推导出问题的最优解。
动态规划的主要思想是利用已求解的子问题的最优解来推导出更大问题的最优解,从而避免了重复计算。因此,动态规划通常采用自底向上的方式进行求解,先求解出小规模的问题,然后逐步推导出更大规模的问题,直到求解出整个问题的最优解。
动态规划通常包括以下几个基本步骤:
- 定义状态:将问题划分为若干个子问题,并定义状态表示子问题的解;
- 定义状态转移方程:根据子问题之间的关系,设计状态转移方程,即如何从已知状态推导出未知状态的计算过程;
- 确定初始状态:定义最小的子问题的解;
- 自底向上求解:按照状态转移方程,计算出所有状态的最优解;
- 根据最优解构造问题的解。
动态规划可以解决许多实际问题,例如最短路径问题、背包问题、最长公共子序列问题、编辑距离问题等。同时,动态规划也是许多其他算法的核心思想,例如分治算法、贪心算法等。
动态规划是一种解决多阶段决策过程最优化问题的方法,它将复杂问题分解成重叠子问题,通过维护每个子问题的最优解来推导出问题的最优解。动态规划包括定义状态、设计状态转移方程、确定初始状态、自底向上求解和构造问题解等步骤。动态规划可以解决许多实际问题,也是其他算法的核心思想之一。
一、前 n 个数字二进制中 1 的个数
给定一个非负整数 n ,请计算 0 到 n 之间的每个数字的二进制表示中 1 的个数,并输出一个数组。
示例 1:
输入: n = 2
输出: [0,1,1]
解释:
0 --> 0
1 --> 1
2 --> 10
示例 2:
输入: n = 5
输出: [0,1,1,2,1,2]
解释:
0 --> 0
1 --> 1
2 --> 10
3 --> 11
4 --> 100
5 --> 101
来源:力扣(LeetCode)
1.1、思路
分奇数和偶数:
- 偶数的二进制1个数超级简单,因为偶数是相当于被某个更小的数乘2,乘2怎么来的?在二进制运算中,就是左移一位,也就是在低位 多加1个0,那样就说明dp[i] = dp[i / 2]。
- 奇数稍微难想到一点,奇数由不大于该数的偶数+1得到,偶数+1在二进制位上会发生什么?会在低位多加1个1,那样就说明dp[i] = dp[i-1] + 1,当然也可以写成dp[i] = dp[i / 2] + 1。
对于所有的数字,只有两类:
奇数:二进制表示中,奇数一定比前面那个偶数多一个 1,因为多的就是最低位的 1。
0 = 0 1 = 1
2 = 10 3 = 11
偶数:二进制表示中,偶数中 1 的个数一定和除以 2 之后的那个数一样多。因为最低位是 0,除以 2 就是右移一位,也就是把那个 0 抹掉而已,所以 1 的个数是不变的。
2 = 10 4 = 100 8 = 1000
3 = 11 6 = 110 12 = 1100
另外,0 的 1 个数为 0,于是就可以根据奇偶性开始遍历计算了。
1.2、代码实现
状态方程:dp[i]=dp[i>>1]+(i&1)。
class Solution {
public:
vector<int> countBits(int n) {
vector<int> ans(n+1,0);
for(int i=0;i<=n;i++)
{
ans[i]=ans[i>>1]+(i&0x01);
}
return ans;
}
};
时间复杂度:O(n)。对于每个整数,只需要 O(1) 的时间计算「一比特数」。
空间复杂度:O(1)。除了返回的数组以外,空间复杂度为常数。
二、爬楼梯的最少成本
数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。
每当爬上一个阶梯都要花费对应的体力值,一旦支付了相应的体力值,就可以选择向上爬一个阶梯或者爬两个阶梯。
请找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。
示例 1:
输入:cost = [10, 15, 20]
输出:15
解释:最低花费是从 cost[1] 开始,然后走两步即可到阶梯顶,一共花费 15 。
示例 2:
输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出:6
解释:最低花费方式是从 cost[0] 开始,逐个经过那些 1 ,跳过 cost[3] ,一共花费 6 。
来源:力扣(LeetCode)。
2.1、思路
假设数组 cost 的长度为 n,则 n 个阶梯分别对应下标 0 到 n−1,楼层顶部对应下标 n,问题等价于计算达到下标 n 的最小花费。可以通过动态规划求解。
创建长度为 n+1 的数组 dp,其中 dp[i] 表示达到下标 i 的最小花费。
由于可以选择下标 0 或 1 作为初始阶梯,因此有 dp[0]=dp[1]=0。
当 2≤i≤n 时,可以从下标 i−1 使用 cost[i−1] 的花费达到下标 i,或者从下标 i−2 使用 cost[i−2] 的花费达到下标 i。为了使总花费最小,dp[i] 应取上述两项的最小值,因此状态转移方程如下:
dp[i]=min(dp[i−1]+cost[i−1],dp[i−2]+cost[i−2])
依次计算 dp 中的每一项的值,最终得到的 dp[n] 即为达到楼层顶部的最小花费。
2.2、代码实现
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
vector<int> dp(n + 1);
dp[0] = dp[1] = 0;
for (int i = 2; i <= n; i++) {
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[n];
}
};
上述代码的时间复杂度和空间复杂度都是 O(n)。注意到当 i≥2 时,dp[i] 只和 dp[i−1] 与 dp[i−2] 有关,因此可以使用滚动数组的思想,将空间复杂度优化到 O(1)。
优化:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
int prev = 0, curr = 0;
for (int i = 2; i <= n; i++) {
int next = min(curr + cost[i - 1], prev + cost[i - 2]);
prev = curr;
curr = next;
}
return curr;
}
};
时间复杂度:O(n)。
空间复杂度:O(1)。
三、翻转数位
给定一个32位整数 num,你可以将一个数位从0变为1。请编写一个程序,找出你能够获得的最长的一串1的长度。
示例 1:
输入: num = 1775(11011101111)
输出: 8
示例 2:
输入: num = 7(0111)
输出: 4
来源:力扣(LeetCode)。
3.1、思路
本题使用动态规划解决比较容易:使用用两个动态规划数组 current[] reverse[]
current[i] 表示包含第i位的从num二进制低位至第i位连续1的最长长度
reverse[i] 表示包含第i位的从低位到第i位最多翻转1个0->1 的连续1的最长长度
用num[i]表示整数num第i位的值
当num[i]=1时,current[i] = current[i-1]+1,因为current[i-1]一定包含i-1位,也就是和第i位连续,所以前i-1的最大长度连上第i位的长度就等于current[i],同理reverse[i] = reverse[i-1]+1;
num[i]=0时,连续中断,current[i]=0,而reverse[i]允许翻转1次,但是reverse[i]又必须包含第i位,也就是说只能翻转第i位,所以前面不能出现翻转,必须全是1,这个长度恰好就是current[i-1],所以reverse[i] = current[i-1]+1
遍历num所有位数,也就是32位后,reverse数组中的最大值就是答案。
状态方程:
current[i] = num[i]==1?current[i-1]+1:0
reverse[i] = num[i]==1?reverse[i-1]+1:current[i-1]+1
观察状态方程,我们发现current和reverse第i位只和第i-1位有关,所以可以把动态数组优化成两个变量current和reverse,同时更新最大值max并作为结果返回。
解法只说明答案是取reverse最大值而不涉及current最大值,可以思考一下为什么。
该解法针对任何值均需要循环32次,然而一些比较小的数显然不用遍历32次(比如1)。想一想可不可以优化代码用以对某些小值减少循环次数。
3.2、代码实现
class Solution {
public:
int reverseBits(int num) {
int max = 0;
int reverse = 0;
int current = 0;
for(int i=0;i<32;i++){
if((num&1)==1){
current++;
reverse++;
}else{
reverse = current+1;
current = 0;
}
if(reverse>max) max = reverse;
num >>= 1;
}
return max;
}
}
时间复杂度 :O(1)。 固定循环32次,消耗常数时间,与输入值无关
空间复杂度 :O(1) 。只使用常数级额外空间
总结
动态规划(Dynamic Programming)是一种解决多阶段决策最优化问题的方法,它将复杂问题分解成重叠子问题并通过维护每个子问题的最优解来推导出问题的最优解。动态规划可以解决许多实际问题,例如最短路径问题、背包问题、最长公共子序列问题、编辑距离问题等。
动态规划的基本思想是利用已求解的子问题的最优解来推导出更大问题的最优解,从而避免了重复计算。它通常采用自底向上的方式进行求解,先求解出小规模的问题,然后逐步推导出更大规模的问题,直到求解出整个问题的最优解。
动态规划通常包括以下几个基本步骤:
- 定义状态:将问题划分为若干个子问题,并定义状态表示子问题的解;
- 定义状态转移方程:根据子问题之间的关系,设计状态转移方程,即如何从已知状态推导出未知状态的计算过程;
- 确定初始状态:定义最小的子问题的解;
- 自底向上求解:按照状态转移方程,计算出所有状态的最优解;
- 根据最优解构造问题的解。
动态规划的时间复杂度通常为 O ( n 2 ) O(n^2) O(n2)或 O ( n 3 ) O(n^3) O(n3),空间复杂度为O(n),其中n表示问题规模。在实际应用中,为了减少空间复杂度,通常可以使用滚动数组等技巧来优化动态规划算法。