标注*号的就是还没写完的题目
动态规划
1、最小路径和 ✔
思路:
- n为行数,m为列数,设dp[][] 为n×m的矩阵, dp[i][j] 就是走到该位置的最小路径和
- 确定边界:
- 当 i = 0 ,j ≠ 0 时,只能从左边来,所以 grid[i][j] += grid[i][j-1];
- 当 i ≠ 0 ,j = 0 时,只能从上边来,所以 grid[i][j] += grid[i-1][j];
- 除去上面两种情况,其他都可以从上边或左边过来,这时候就要选择路径和更小的那个:
grid[i][j] += Math.min( grid[i-1][j] , grid[i][j-1] ); - 通过观察可以发现我们可以直接在原矩阵的基础上进行修改,而不需要占用额外的空间。
代码:
public int minPathSum(int[][] grid) {
int n = grid.length,m = grid[0].length;
for(int i = 0;i < n;i++){
//行
for(int j = 0;j < m;j++){
//列
if(i == 0 && j == 0) continue;
else if(i == 0){
//第一行,只能从左边来
grid[i][j] += grid[i][j-1];
}else if(j == 0){
//第一列,只能从上边来
grid[i][j] += grid[i-1][j];
}else{
//非第一行第一列 需要判断哪边的值更小,就加哪边
grid[i][j] += Math.min(grid[i-1][j],grid[i][j-1]);
}
}
}
return grid[n-1][m-1];
}
复杂度分析:
- 时间复杂度:O(n*m),需要遍历整个矩阵一次
- 空间复杂度:O(1)
2、最长递增子序列 ✔
这道题考虑使用动态规划:
我们定义dp[i]为当前index = i时数组的最长严格递增子序列长度。关键的问题在于我们怎么去计算dp数组的每个值。
状态转移公式
假设我们已经知道了 dp[0…4] 的所有结果,我们如何通过这些已知结果推出 dp[5] 呢?
很明显,nums[5] = 3,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到最后,就可以形成一个新的递增子序列,再将这个新的子序列长度加一
显然,可能形成很多种新的子序列,但是我们只选择最长的那一个,把最长子序列的长度作为 dp[5] 的值即可。
所以这道题我们需要做的有:
- 在刚开始时定义 dp[i] = 1,因为最短的序列就是单独一个数字
- 遍历数组的时候,比较 i(后) 与 j (前),如果 nums[i] > nums[j] 则 nums[i] 可以接上 nums[j] 成为一个更长的递增子序列。我们就可以让 dp[i] = Math.max(dp[i],dp[j]+1) <– 如果这一步能想明白,那么整道题就迎刃而解
代码:
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); //将数组全部替换成1
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1); //最重要的公式,求出最长的递增子序列
}
}
}
int ans = 0;
for (int i = 0; i < n; i++) {
ans = Math.max(ans, dp[i]); //遍历取出最大值
}
return ans;
}
复杂度分析:
- 时间复杂度:O(n^2),其中 n 为数组 nums 的长度。动态规划的状态数为 n,计算状态 dp[i] 时,需要 O(n) 的时间遍历 dp[0…i−1] 的所有状态,所以总时间复杂度为 O(n^2)
- 空间复杂度:O(n),需要额外使用长度为 n 的 dp 数组。
3、三角形 ✔
思路:
- 使用动态规划,定义 dp[][] ,大小为n行m列,dp[i][j] 就是走到该位置的最小路径和
- 确定边界: i为行数
- 当 j = 0 时,只能从 j 来,所以 triangle.get(i).set ( j , triangle.get(i).get(j) + triangle.get(i-1).get(j));
- 当 j = i 时,只能从 j-1 来,所以 triangle.get(i).set ( j , triangle.get(i).get(j) + triangle.get(i-1).get(j-1));
- 除去上面两种情况,其他都可以从 j 或 j-1 过来,这时候就要选择路径和更小的那个:
int min = Math.min ( triangle.get(i-1).get(j) , triangle.get(i-1).get(j-1) );
triangle.get(i).set ( j , triangle.get(i).get(j) + min); - 当 i=n-1 时,即为最后一行,我们需要维护一个最小路径和ans
代码:
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
if(n == 1) return triangle.get(0).get(0); // 如果只有一行
int ans = Integer.MAX_VALUE;
for(int i=1;i < n;i++){
//从第二行开始遍历
for(int j = 0;j <= i;j++){
// 列为0,即在左边,只能从j下来
if(j == 0) triangle.get(i).set(j,triangle.get(i).get(j) + triangle.get(i-1).get(j));
// 列为i,即在右边,只能从j-1下来
else if(j == i) triangle.get(i).set(j,triangle.get(i).get(j) + triangle.get(i-1).get(j-1));
// 列在中间,可以从j或j-1下来 需要判断
else{
int min = Math.min(triangle.get(i-1).get(j),triangle.get(i-1).get(j-1));
triangle.get(i).set(j,triangle.get(i).get(j) + min);
}
if(i == n-1) ans = Math.min(ans,triangle.get(i).get(j)); //最后一行,维护最小答案
}
}
return ans;
}
运行结果:
复杂度分析:
- 时间复杂度:O(n^2),n为三角形行数,并且在更改原集合的情况下,我们频繁使用get和set方法,也会消耗一定的性能
- 空间复杂度:O(1)
看了题解之后的答案:
定义二维 dp 数组,自底向上的递推
状态定义:
dp[i][j] 表示从点 (i, j)到底边的最小路径和
状态转移方程(自底向上):
dp[i][j] = min(dp[i+1][j],dp[i+1][j+1]) + triangle[i][j]
代码:
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
// dp[i][j] 表示从点 (i, j) 到底边的最小路径和。
int[][] dp = new int[n + 1][n + 1];
// 从三角形的最后一行开始递推。
for (int i = n - 1; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle.get(i).get(j);
}
}
return dp[0][0];
}
复杂度分析:
- 时间复杂度:O(N^2),N 为三角形的行数
- 空间复杂度:O(N^2),N 为三角形的行数
空间优化:
在上述代码中,我们定义了一个 N 行 N 列 的 dp 数组
但是在实际递推中我们发现,计算 dp[i][j] 时,只用到了下一行的 dp[i + 1][j] 和 dp[i + 1][j + 1]
因此 dp 数组不需要定义 N 行,只要定义 1 行就足够:
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[] dp = new int[n + 1]; //用于防止j+1越界
for (int i = n - 1; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[j] = Math.min(dp[j], dp[j + 1]) + triangle.get(i).get(j);
}
}
return dp[0];
}
复杂度分析:
- 时间复杂度:O(N^2),N 为三角形的行数
- 空间复杂度:O(N),N 为三角形的行数
4、找零钱 ✔
思路:
- dp[j] 代表的含义:就是amount在这个coins组合下最少用多少枚硬币
可以转化为完全背包问题 : 填满容量为amount的背包最少需要多少硬币
-
初始化dp的问题:后面要求的是最少的硬币,所以初始化不能对结果造成影响,而因为硬币的数量一定不会超过amount(面值最低为1),所以直接初始化dp数组的值为amount+1,特例 dp[0] = 0;
-
最重要的转移方程: dp[j] = Math.min(dp[j], dp[j-coin] + 1)
当前填满容量j最少需要的硬币 = min( 之前填满容量j最少需要的硬币, 填满容量 j - coin 需要的硬币 + 1个当前硬币) -
返回dp[amount],如果dp[amount]的值为10001没有变过,说明找不到硬币组合,返回-1
代码:
public int coinChange(int[] coins, int amount) {
int a = amount + 1;
int[] dp = new int[a]; //定义amount+1
Arrays.fill(dp, a); //因为硬币的数量一定不会超过amount
dp[0] = 0; //当amount == 0 时 返回0
for(int coin : coins){
for(int j = coin; j < a; j++){
//从每个硬币的面值开始遍历到amount
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
//这个转移方程就是dp[amount]在这个coins组合下最少用多少枚硬币
}
}
return dp[amount] == a ? -1 : dp[amount];
}
思路:
- dp[j] 代表 j 金额 在当前的 coins 组合下有多少种组合方式
也可转化为完全背包之组合问题——dp[j] 代表装满容量为j的背包有几种硬币组合
- 列出转移方程:dp[j] = dp[j] + dp[j - coin]
当前填满 j 容量的方法数 = 之前填满 j 容量的硬币组合数 + 填满 j - coin 容量的硬币组合数
也就是当前硬币coin的加入,可以把 j - coin 容量的组合数加入进来 - dp[0] = 1; 即金额为0时只有一种组合coins的方式(都不用)
代码:
public int change(int amount, int[] coins) {
// dp[j] 代表 amount金额 在当前的 coins 组合下有多少种组合方式
int[] dp = new int[amount+1];
dp[0] = 1;
for(int coin:coins){
for(int j = coin;j < amount+1;j++){
dp[j] = dp[j] + dp[j-coin];
}
}
return dp[amount];
}
5、最大字段和 ✔
这道题要我们找到一个具有最大和的连续子数组,可以转换为求以 i 结尾的连续子数组的最大和,考虑使用动态规划,列出转移方程:dp[i] = Math.max( dp[i-1]+nums[i] , nums[i] ),遍历数组输出dp[ ]最大值即可
代码:
public int maxSubArray(int[] nums) {
//dp[i]表示下标j的连续子数组最大和
int n = nums.length,ans = nums[0];
int[] dp = new int[n];
dp[0] = nums[0];
for(int i = 0;i < n;i++){
if(i != 0) dp[i] = Math.max(nums[i],dp[i-1]+nums[i]);
ans = Math.max(dp[i],ans);
}
return ans;
}
但是通过观察我们可以发现,dp数组每次都只会使用dp[i],我们可以考虑用一个数字pre代替dp数组,改进后的代码:
public int maxSubArray(int[] nums) {
//简化 --> pre为前一个数的连续子数组最大和
int pre = 0,ans = nums[0];
for(int num:nums){
pre = Math.max(num,num+pre);
ans = Math.max(ans,pre);
}
return ans;
}
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
6、爬楼梯 ✔
通过观察可知该题为斐波那契数列,根据数列特性可写出递归代码:
public int climbStairs(int n) {
if(n == 1) return 1;
if(n == 2) return 2;
return climbStairs(n-1)+climbStairs(n-2);
}
这样实现算法时空复杂度很高,并且有可能会导致栈溢出。
考虑使用正向循环(滚动数组):
public int climbStairs(int n) {
int a=0,b=0,sum=1;
for(int i=1;i<=n;i++){
a = b;
b = sum;
sum = a+b;
}
return sum;
}
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
7、分割等和子集 ✔
思路:
-
特例: 如果sum为奇数,那一定找不到符合要求的子集,返回False
-
dp[j]含义: 有没有和为j的子集,有为True,没有为False
-
初始化dp数组: 长度为target + 1,用于存储子集的和从0到target是否可能取到的情况。
比如和为0一定可以取到(也就是子集为空),那么dp[0] = True -
接下来开始遍历nums数组,对遍历到的数nums[i]有两种操作,一个是选择这个数,一个是不选择这个数。
- 不选择这个数:dp不变
- 选择这个数:dp中已为True的情况再加上nums[i]也为True。比如dp[0]已经为True,那么dp[0 + nums[i]]也是True
-
在做出选择之前,我们先逆序遍历子集的和从nums[i]到target的所有情况,判断当前数加入后,dp数组中哪些和的情况可以从False变成True
(为什么要逆序:是因为dp后面的和的情况是从前面的情况转移过来的,如果前面的情况因为当前nums[i]的加入变为了True,比如dp[0 + nums[i]]变成了True,那么因为一个数只能用一次,dp[0 + nums[i] + nums[i]]不可以从dp[0 + nums[i]]转移过来。如果非要正序遍历,必须要多一个数组用于存储之前的情况。而逆序遍历可以省掉这个数组)
状态转移方程: dp[j] = dp[j] or dp[j - nums[i]]
- 如果不选择当前数,那么和为j的情况保持不变,dp[j]仍然是dp[j],原来是True就还是True,原来是False也还是False;
- 如果选择当前数,那么如果j - nums[i]这种情况是True的话和为j的情况也会是True。比如和为0一定为True,只要 j - nums[i] == 0,那么dp[j]就变成了True
dp[j]和dp[j-nums[i]]只要有一个为True,dp[j]就变成True,因此用or连接两者
-
返回dp[target]
代码:
public boolean canPartition(int[] nums) {
// 求出 sum
int sum = Arrays.stream(nums).sum();;
// sum为奇数 说明不能分割为两个相等的子集
if(sum % 2 == 1) return false;
int target = sum >> 1;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
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)
8、目标和 ✔
这道题用枚举递归也能做:
class Solution {
int count = 0;
public int findTargetSumWays(int[] nums, int S) {
forSum(nums,S,0);
return count;
}
public void forSum(int[] nums,int S,int i){
if(S == 0 && i == nums.length){
count ++;
return;
}
if(i == nums.length) return;
forSum(nums,S + nums[i],i + 1);
forSum(nums,S - nums[i],i + 1);
}
}
但是用递归时间复杂度是O(2^n),其中n是数组nums的长度,时间复杂度为指数级别,效率比较差。
我们再看看dp:
思路:
- 01背包问题是选或者不选,但本题是必须选,是选+还是选-。先将本问题转换为01背包问题。
假设所有符号为+的元素和为x,符号为-的元素和的绝对值是y
我们想要的 S = 正数和 - 负数和 = x - y
而已知x与y的和是数组总和:sum = x + y
可以求出 x = (S + sum) / 2 = target
也就是我们要从nums数组里选出几个数,令其和为target
于是就转化成了求容量为target的01背包问题 => 要装满容量为target的背包,有几种方案 - 特例判断
如果S大于sum,不可能实现,返回0
如果x不是整数,也就是S + sum不是偶数,不可能实现,返回0
比如: nums: [1, 1, 1, 1, 1], S: 4 => 无解 - dp[j]代表的意义:填满容量为j的背包,有dp[j]种方法。因为填满容量为0的背包有且只有一种方法,所以dp[0] = 1
- 状态转移:dp[j] = dp[j] + dp[j - num],
当前填满容量为j的包的方法数 = 之前填满容量为j的包的方法数 + 之前填满容量为j - num的包的方法数
也就是当前数num的加入,可以把之前和为j - num的方法数加入进来。 - 返回dp[target]
代码:
public int findTargetSumWays(int[] nums, int S) {
// 求出sum
int sum = 0;
for(int num : nums){
sum += num;
}
// 特例判断
if(S > sum || (sum + S) % 2 == 1) return 0;
int target = (sum + S) >> 1;
int[] dp = new int[target + 1];
dp[0] = 1;
// 状态转移:dp[j] = dp[j] + dp[j - num]
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)
链表
1、链表逆序(反转链表) ✔
class Solution {
public ListNode reverseList(ListNode head) {
ListNode pre = null,cur = head;
ListNode temp = new ListNode(0);
while(cur != null){
temp = cur.next; //暂存后继节点
cur.next = pre; //更改指向
pre = cur; //更新pre
cur = temp; //访问下一节点
}
return pre;
}
}
复杂度分析:
- 时间复杂度:O(a+b),最差情况下(即 |a - b| = 1 , c=0 ),此时需遍历 a+b 个节点。
- 空间复杂度:O(1)
思路:
- 使用一个count记录,把要反转的链表拿出来
- 对链表进行反转
- 将反转的链表接回原链表
结果看起来还行,但是自己思考的思路比较复杂,看了题解之后发现有更简洁的写法:
思路:
- 建一个虚拟头节点dummy,指向head节点
- 建立hh指针,一直往右移动至left的前一位置
- 使用a、b指针,将目标节点的next指针翻转
- 让hh.next(也就是left节点)的next指针指向b
- 让hh的next指针指向a
- 返回dummy.next
代码:
public ListNode reverseBetween(ListNode head, int l, int r) {
ListNode dummy = new ListNode(0);
dummy.next = head;
r -= l; // 调整r指针 变成要被反转的链表的步数
ListNode hh = dummy;
while (l-- > 1) hh = hh.next;
// ↑ 使hh指针在要被反转的前一个位置上,此时a在翻转链表的第一个元素,b在第二个
ListNode a = hh.next, b = a.next;
while (r-- > 0) {
// 每走一步 r--
ListNode tmp = b.next;
b.next = a;
a = b;
b = tmp;
}
hh.next.next = b;
hh.next = a;
return dummy.next;
}
2、链表求交点 ✔
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
//双指针 在A的尾部衔接上B 两个指针一起走
//最后如果相同就返回A 不相同走到最后 A B均为null
ListNode A = headA, B = headB;
while (A != B) {
A = A != null ? A.next : headB;
B = B != null ? B.next : headA;
}
return A;
//另外一个版本的双指针 如果A比B长 就先让A走(A-B)步 再双指针遍历
//如果有相同点返回A 没有相同点返回null 比较麻烦
}
复杂度分析:
- 时间复杂度:O(N)
- 空间复杂度:O(1)
3、链表求环(环形链表) ✔
同样使用双指针求解,快慢指针一起遍历链表,如果两指针相遇则说明存在环,反之则不存在
public boolean hasCycle(ListNode head) {
if(head == null) return