算法刷题笔记:一维DP没那么难,状态想清楚就赢了一半
哈喽大家好!今天我们聊聊一维动态规划,这可是算法里的重头戏。
很多同学听到DP就头疼,其实动态规划的核心就一句话:记住你算过的东西,别重复算。听起来简单,但怎么"记住"、"记住什么",才是精髓。
今天我带你用这些题,从入门到进阶,慢慢体会DP的美妙。准备好了吗?出发!
1. 爬楼梯
题目:假设你正在爬楼梯,需要 n 阶才能到达楼顶。每次你可以爬 1 或 2 个台阶。问有多少种不同的方法可以爬到楼顶?
思路过程
第一步:暴力枚举? 假设 n=5,我们可以枚举所有走法:
- 1+1+1+1+1
- 1+1+1+2
- 1+2+2
- 2+1+2
- 2+2+1
等等,这也太多了吧!而且n越大,枚举量指数级爆炸。
第二步:换个角度想 走到第5阶的最后一步,可能是从第4阶走1步上来的,也可能是从第3阶走2步上来的。
也就是说:
- 走到第5阶的方法数 = 走到第4阶的方法数 + 走到第3阶的方法数
这不就是斐波那契数列吗!
第三步:定义状态 dp[i] = 走到第 i 阶的方法数
第四步:找转移方程 dp[i] = dp[i-1] + dp[i-2]
解释:走到第i阶,要么从i-1走1步,要么从i-2走2步。
初始条件:
dp[1] = 1(只有一种走法:走1步)dp[2] = 2(两种走法:1+1 或者 直接走2步)
代码
java
/**
* 爬楼梯
* 时间复杂度:O(n)
* 空间复杂度:O(1) - 只用两个变量滚动
*/
public int climbStairs(int n) {
if (n <= 2) {
return n;
}
int prev1 = 2; // dp[i-1]
int prev2 = 1; // dp[i-2]
for (int i = 3; i <= n; i++) {
int cur = prev1 + prev2; // dp[i] = dp[i-1] + dp[i-2]
prev2 = prev1; // 更新 dp[i-2]
prev1 = cur; // 更新 dp[i-1]
}
return prev1;
}
复杂度分析
- 时间复杂度:O(n),循环n次
- 空间复杂度:O(1),只用了两个变量(可以优化到不用数组)
一句话总结
爬楼梯本质是斐波那契数列,关键发现:走到第i阶 = 走到i-1阶 + 走到i-2阶。
2. 杨辉三角
题目:给定一个非负整数 numRows,生成「杨辉三角」的前 numRows 行。
思路过程
第一步:观察规律
markdown
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
第二步:找规律 每个数是它左上方 和右上方两个数的和:
- 第3行第2个数字2 = 1 + 1(来自第2行的两个1)
- 第4行第2个数字3 = 1 + 2(来自第3行的1和2)
第三步:定义状态 dp[i][j] = 杨辉三角第 i 行第 j 列的值(i从0开始计数)
第四步:转移方程
- 每行第一个和最后一个都是1:
dp[i][0] = dp[i][i] = 1 - 中间数字:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
代码
java
/**
* 杨辉三角
* 时间复杂度:O(n²)
* 空间复杂度:O(n²)
*/
public List<List<Integer>> generate(int numRows) {
List<List<Integer>> triangle = new ArrayList<>();
for (int i = 0; i < numRows; i++) {
List<Integer> row = new ArrayList<>();
for (int j = 0; j <= i; j++) {
// 每行第一个和最后一个都是1
if (j == 0 || j == i) {
row.add(1);
} else {
// 中间数字 = 左上 + 右上
int val = triangle.get(i - 1).get(j - 1)
+ triangle.get(i - 1).get(j);
row.add(val);
}
}
triangle.add(row);
}
return triangle;
}
复杂度分析
- 时间复杂度:O(n²),生成n行的三角形
- 空间复杂度:O(n²),存储完整三角形
一句话总结
杨辉三角的精髓:每个数等于上方两数之和,用二维DP逐行计算即可。
3. 打家劫舍
题目:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有现金,但相邻的房屋装有防盗系统,如果两间相邻的房屋在同一晚上被闯入,会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一晚上能够偷窃到的最高金额。
思路过程
第一步:简化问题 想象你从第一间房子开始走,遇到每间房子都要决定:偷还是不偷?
- 如果偷了第i间,那第i-1间就不能偷
- 如果不偷第i间,那就可以自由决定前面怎么偷
第二步:定义状态 dp[i] = 偷到第 i 间房子时,能偷到的最大金额(包括第i间)
等等,这里有个问题要考虑清楚:
dp[i]是考虑前i间房子能偷到的最大金额(不一定要偷第i间)
那我换个说法: dp[i] = 考虑第0到第i间房子,能偷到的最大金额
第三步:找转移方程 对于第i间房子(i >= 1),只有两种选择:
- 不偷第i间 :
dp[i] = dp[i-1] - 偷第i间 :那第i-1间不能偷,所以最大金额是
dp[i-2] + nums[i]
取最大值:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
第四步:初始条件
dp[0] = nums[0](只有第一间,偷它)dp[1] = max(nums[0], nums[1])(两间挑贵的偷)
代码
java
/**
* 打家劫舍
* 时间复杂度:O(n)
* 空间复杂度:O(n),可优化到O(1)
*/
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
// 两种选择:不偷这间(继承dp[i-1])或偷这间(dp[i-2] + nums[i])
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[n - 1];
}
// 空间优化版本
public int rob_optimized(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int prev2 = nums[0]; // dp[i-2]
int prev1 = Math.max(nums[0], nums[1]); // dp[i-1]
for (int i = 2; i < nums.length; i++) {
int cur = Math.max(prev1, prev2 + nums[i]);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(n),可优化到 O(1)
一句话总结
经典的"选或不选"DP:当前房子不偷则继承前一个状态,偷了就要跳过前一个房子。
4. 完全平方数
题目 :给定正整数 n,找到若干个完全平方数(如 1, 4, 9, 16 ...)使得它们的和等于 n。你需要让组成 n 的完全平方数的数量最少。返回最少需要几个完全平方数。
思路过程
第一步:问题转换 这道题可以这样想:凑出数字n,每次可以选择一个完全平方数,问最少选几次。
这不就是经典的完全背包问题吗?
第二步:定义状态 dp[i] = 凑出数字 i 需要的最少完全平方数个数
第三步:找转移方程 对于数字 i,假设我们选择了一个完全平方数 j*j(j从1到√i):
- 在选了 jj 的情况下,还需要凑出 i - jj
- 所以总个数 = 1 + dpi - j\*j
遍历所有可能的 j,取最小值: dp[i] = min(dp[i], 1 + dp[i - j*j])
第四步:初始条件 dp[0] = 0(数字0需要0个完全平方数)
代码
java
/**
* 完全平方数
* 时间复杂度:O(n * √n)
* 空间复杂度:O(n)
*/
public int numSquares(int n) {
int[] dp = new int[n + 1];
// 初始化:假设都需要n个(最大值)
Arrays.fill(dp, n);
dp[0] = 0;
for (int i = 1; i <= n; i++) {
// 遍历所有可能的完全平方数
for (int j = 1; j * j <= i; j++) {
// dp[i] = min(dp[i], 1 + dp[i - j*j])
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
复杂度分析
- 时间复杂度:O(n * √n),外层n次,内层最多√n次
- 空间复杂度:O(n)
一句话总结
把问题转化为完全背包:数字i可以由jj和i-jj组成,dpi取所有可能中的最小值。
5. 零钱兑换
题目 :给你一个整数数组 coins 表示不同面额的硬币,和一个整数 amount 表示总金额。计算并返回凑成总金额所需的最少硬币个数 。如果没有任何硬币组合能组成总金额,返回 -1。每种硬币的数量是无限的。
思路过程
上一题的思路完全适用!
第一步:确认背包类型 和完全平方数一样,硬币数量无限,是完全背包问题。
第二步:定义状态 dp[i] = 凑出金额 i 需要的最少硬币个数
第三步:转移方程 对于金额 i,遍历所有硬币面额: dp[i] = min(dp[i], 1 + dp[i - coin])
第四步:初始条件 dp[0] = 0(金额0需要0个硬币) 其他初始化为一个很大的数,表示"凑不出来"
代码
java
/**
* 零钱兑换
* 时间复杂度:O(amount * len(coins))
* 空间复杂度:O(amount)
*/
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
int[] dp = new int[amount + 1];
// 初始化:金额i默认凑不出来,设为一个大数
Arrays.fill(dp, amount + 1);
dp[0] = 0; // 金额0需要0个硬币
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0) {
// 选了这枚硬币后,还剩 i-coin
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 检查是否真的能凑出来
return dp[amount] > amount ? -1 : dp[amount];
}
复杂度分析
- 时间复杂度:O(amount * len(coins))
- 空间复杂度:O(amount)
一句话总结
和完全平方数一模一样的套路:遍历每种硬币,更新dpi = min(dpi, dpi-coin + 1)。
6. 单词拆分
题目:给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判断 s 能否被空格拆分成一个或多个在字典中出现的单词。说明:拆分时可以重复使用字典中的单词。
思路过程
第一步:理解题意 比如 s = "leetcode",wordDict = "leet", "code"
- "leetcode" 可以拆分成 "leet" + "code"
- 所以返回 true
第二步:定义状态 dp[i] = 字符串 s 的前 i 个字符(s0:i)能否被拆分成字典中的单词
第三步:找转移方程 对于位置 i,遍历所有可能的分割点 j:
- 如果
dp[j] = true(前j个字符能拆分) - 且
s[j:i]在字典中(substring从j到i-1) - 那么
dp[i] = true
第四步:优化 判断字符串是否在字典中,用哈希集合O(1)查找。
代码
java
/**
* 单词拆分
* 时间复杂度:O(n² * m),n是字符串长度,m是字典单词平均长度(substring的O(m))
* 空间复杂度:O(n + m),dp数组 + 哈希集合
*/
public boolean wordBreak(String s, List<String> wordDict) {
// 哈希集合:O(1) 查找
Set<String> dict = new HashSet<>(wordDict);
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true; // 空字符串可以被拆分
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
// 如果前j个字符能拆分,且 s[j:i] 在字典中
if (dp[j] && dict.contains(s.substring(j, i))) {
dp[i] = true;
break; // 找到一个就够了
}
}
}
return dp[n];
}
复杂度分析
- 时间复杂度:O(n²),虽然有substring操作,但字符串不可变所以实际操作会稍慢
- 空间复杂度:O(n + m),dp数组 + 哈希集合
一句话总结
把字符串切成两半,前半部分能拆分 + 后半部分是单词 = 前i个字符能拆分。
7. 最长递增子序列
题目 :给你一个整数数组 nums,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。
思路过程
第一步:理解"子序列" 子序列和子数组不同,子数组要求连续,子序列只需要保持相对顺序。 比如 3, 5, 7, 1 的子序列可以是 3, 5, 7、3, 7、5, 7 等
第二步:定义状态 dp[i] = 以 numsi 结尾的最长递增子序列长度
为什么要"以numsi结尾"?因为我们需要用一个明确的结尾来定义子序列,这样转移才有方向。
第三步:找转移方程 对于每个 i,遍历它之前的所有 j(j < i):
- 如果
nums[j] < nums[i](可以接在后面) - 那么
dp[i] = max(dp[i], dp[j] + 1)
第四步:优化------二分查找 上面的方法时间复杂度是 O(n²)。有没有办法优化?
我们可以用贪心 + 二分:
- 维护一个有序数组 tailk,表示长度为 k+1 的递增子序列的最小结尾元素
- 遍历每个元素,用二分找到它应该插入的位置
- 最终数组长度就是答案
这个优化版本时间复杂度 O(n log n),但比较难理解,先掌握 O(n²) 的基础版本!
代码
java
/**
* 最长递增子序列 - 基础DP版本
* 时间复杂度:O(n²)
* 空间复杂度:O(n)
*/
public int lengthOfLIS_basic(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
int[] dp = new int[n];
// 初始化:每个元素自己就是一个长度为1的子序列
Arrays.fill(dp, 1);
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
// 如果 nums[j] < nums[i],可以把 nums[i] 接在后面
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
/**
* 最长递增子序列 - 二分优化版本
* 时间复杂度:O(n log n)
* 空间复杂度:O(n)
*/
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) return 0;
// tail[i] 存储长度为 i+1 的递增子序列的最小结尾元素
int[] tail = new int[nums.length];
int size = 0; // 当前有序数组的长度
for (int num : nums) {
// 用二分找到 num 应该插入的位置
int left = 0, right = size;
while (left < right) {
int mid = left + (right - left) / 2;
if (tail[mid] < num) {
left = mid + 1;
} else {
right = mid;
}
}
// 插入或替换
tail[left] = num;
// 如果插入位置是末尾,说明找到了更长的子序列
if (left == size) {
size++;
}
}
return size;
}
复杂度分析
- 基础版本:时间 O(n²),空间 O(n)
- 二分版本:时间 O(n log n),空间 O(n)
一句话总结
以每个元素结尾定义状态,遍历前面所有元素找能接在后面的最大值;二分优化用有序数组维护最小结尾。
8. 乘积最大子数组
题目 :给你一个整数数组 nums,请你找出数组中乘积最大的连续子数组(子数组最少包含一个元素),返回其最大乘积。
思路过程
第一步:直觉反应 最大值?那直接遍历找最大乘积不就行了?
不行! 因为乘法有负数:
- -2, 3, -4 的最大乘积是 24(3 * -4 * 3 = -36?不对)
- 实际上 -2, 3, -4 中,3, -4 乘积是 -12,3 是 3
- 但如果全是负数呢?-1, -2, -3 乘积最大是 6(-1 * -2 * -3 = -6,不对)
等等,让我重新算:
- -1, -2, -3:最大乘积是 6(-2 * -3 = 6)
关键发现 :两个负数相乘得正数!
所以我们不仅要记住最大值 ,还要记住最小值!
第二步:定义状态 maxDp[i] = 以 numsi 结尾的最大乘积 minDp[i] = 以 numsi 结尾的最小乘积
第三步:找转移方程 对于 numsi,有三种情况:
- 单独作为子数组:
nums[i] - 和前面的最大值相乘:
nums[i] * maxDp[i-1] - 和前面的最小值相乘:
nums[i] * minDp[i-1]
取最大:maxDp[i] = max(nums[i], nums[i] * maxDp[i-1], nums[i] * minDp[i-1]) 取最小:minDp[i] = min(nums[i], nums[i] * maxDp[i-1], nums[i] * minDp[i-1])
第四步:答案 遍历所有位置,取 maxDpi 的最大值
代码
java
/**
* 乘积最大子数组
* 时间复杂度:O(n)
* 空间复杂度:O(1) - 只需两个变量
*/
public int maxProduct(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int maxProd = nums[0]; // 全局最大乘积
int curMax = nums[0]; // 以当前元素结尾的最大乘积
int curMin = nums[0]; // 以当前元素结尾的最小乘积
for (int i = 1; i < nums.length; i++) {
// 乘以当前元素可能改变正负,所以要同时保存之前的max和min
int tempMax = curMax;
int tempMin = curMin;
// 三种情况取最大
curMax = Math.max(nums[i],
Math.max(tempMax * nums[i], tempMin * nums[i]));
// 三种情况取最小
curMin = Math.min(nums[i],
Math.min(tempMax * nums[i], tempMin * nums[i]));
// 更新答案
maxProd = Math.max(maxProd, curMax);
}
return maxProd;
}
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(1)
一句话总结
因为负数乘负数得正数,所以要同时维护最大和最小乘积,用当前元素分别与两者相乘后取最大/最小。
9. 分割等和子集
题目 :给定一个只包含正整数的非空数组 nums。判断是否可以将这个数组分成两个子集,使得两个子集的元素和相等。
思路过程
第一步:转换问题
- 两个子集元素和相等 → 总和是偶数
- 找子集使得元素和 = 总和/2 → 背包问题
本质上:能不能从数组中挑一些数字,它们的和恰好等于 target = sum/2?
第二步:定义状态 dp[i] = 是否能凑出和为 i(true/false)
第三步:转移方程 对于每个数字 num,遍历和(倒序!重要!):
- 如果
dp[j - num] = true,那dp[j]也能凑出来 dp[j] = dp[j] || dp[j - num]
为什么要倒序? 因为正序会导致同一个数字被重复使用多次。比如 num=1,target=3:
- 正序:dp1 = true → dp2 = true → dp3 = true(用了三次1)
- 倒序:dp3 检查时,dp2 还是旧值,不会重复用
第四步:初始条件 dp[0] = true(和为0可以通过不选任何元素凑出)
代码
java
/**
* 分割等和子集
* 时间复杂度:O(n * target)
* 空间复杂度:O(target)
*/
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) {
sum += num;
}
// 总和为奇数,无法平分
if (sum % 2 != 0) return false;
int target = sum / 2;
// dp[j] = 是否能凑出和为j
boolean[] dp = new boolean[target + 1];
dp[0] = true; // 和为0,总能凑出来(什么都不选)
for (int num : nums) {
// 倒序遍历!防止同一个数字被重复使用
for (int j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
复杂度分析
- 时间复杂度:O(n * target)
- 空间复杂度:O(target)
一句话总结
0-1背包的经典应用:能不能从数组中"选"一些数字凑出 target,注意背包问题要倒序遍历。
10. 最长有效括号
题目 :给你一个只包含 '(' 和 ')' 的字符串,找出其中最长有效括号子串的长度。
思路过程
第一步:理解有效括号
- "()" 是有效的
- "(())" 是有效的
- "()()" 是有效的
- ")(" 是无效的
第二步:定义状态 dp[i] = 以 si 结尾的最长有效括号长度
为什么要"以si结尾"?因为只有结尾确定了,我们才能判断有效括号在哪里结束。
第三步:找转移方程 分两种情况:
情况1:当前是 ')',前一个是 '('
scss
...()
- 直接拼接:
dp[i] = dp[i-2] + 2 - dpi-2 是前面已经有效的部分
情况2:当前是 ')',前一个是 ')'
...))
这时候要往前找配对的 '('。如果 si-dp\[i-1-1] 是 '(',就能配对:
css
... ( dp[i-1] ) ) ← i
↑
这个位置的'('和s[i]配对
- 配对后:
dp[i] = dp[i-1] + 2 - 还要加上前面的有效部分:
+ dp[i - dp[i-1] - 2]
第四步:处理越界 记得检查 i-2 和 i-dpi-1-2 是否 >= 0
代码
java
/**
* 最长有效括号
* 时间复杂度:O(n)
* 空间复杂度:O(n)
*/
public int longestValidParentheses(String s) {
if (s == null || s.length() == 0) return 0;
int n = s.length();
int[] dp = new int[n];
int maxLen = 0;
for (int i = 1; i < n; i++) {
if (s.charAt(i) == ')') {
// 情况1:...()
if (s.charAt(i - 1) == '(') {
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
}
// 情况2:...))
else if (i - dp[i - 1] - 1 >= 0
&& s.charAt(i - dp[i - 1] - 1) == '(') {
dp[i] = dp[i - 1] + 2
+ (i - dp[i - 1] - 2 >= 0 ? dp[i - dp[i - 1] - 2] : 0);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
/**
* 方法2:栈(更直观)
* 时间复杂度:O(n)
* 空间复杂度:O(n)
*/
public int longestValidParentheses_stack(String s) {
int maxLen = 0;
Stack<Integer> stack = new Stack<>();
stack.push(-1); // 初始基准位置
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(') {
stack.push(i);
} else {
stack.pop();
if (stack.isEmpty()) {
// 栈空,说明这个')'配不上,push当前索引作为新基准
stack.push(i);
} else {
// 当前索引 - 栈顶索引 = 有效括号长度
maxLen = Math.max(maxLen, i - stack.peek());
}
}
}
return maxLen;
}
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(n)
一句话总结
以每个字符结尾定义状态:'()'直接拼接,'))'则找前面配对的'('并加上更前面的有效部分。
总结
今天我们学了这些一维动态规划题目,来回顾一下核心套路:
| 题目 | 状态定义 | 转移关键 |
|---|---|---|
| 爬楼梯 | dpi = 到第i阶的方法数 | dpi = dpi-1 + dpi-2 |
| 杨辉三角 | dpij = 第i行j列的值 | dpij = dpi-1j-1 + dpi-1j |
| 打家劫舍 | dpi = 前i间房的最多偷窃 | dpi = max(dpi-1, dpi-2+numsi) |
| 完全平方数 | dpi = 凑出i的最少个数 | 遍历所有平方数取最小 |
| 零钱兑换 | dpi = 凑出i的最少硬币 | 遍历所有硬币取最小 |
| 单词拆分 | dpi = 前i字符能否拆分 | dpi = dpj && dict.contains(sj:i) |
| 最长递增子序列 | dpi = 以numsi结尾的最长 | 遍历前面找能接的 |
| 乘积最大子数组 | 维护max和min两个状态 | 负负得正,同时记录最大和最小 |
| 分割等和子集 | dpj = 能否凑出和j | 0-1背包,倒序遍历 |
| 最长有效括号 | dpi = 以si结尾的最长 | 分'()'和'))'两种情况 |
DP的核心:
- 定义清楚状态 - "以...结尾"是最常用的套路
- 找准转移方程 - 当前状态和之前状态的关系
- 初始化别忘 - dp0 或者边界条件
- 优化要想到 - 空间滚动、数组变变量、二分优化
恭喜你又完成了一篇!如果觉得有用,欢迎点个赞,我们下期见!