力扣算法题库引用自:https://cloud.tencent.com/developer/article/167101
关于动态规划的几道基本题目:
(子序列:去除某些字符,相对顺序保持不变;子串:截取的)
- 最长递增子序列的长度(300 题,背下来思想,背下来解法)
这个是子序列,不是子串。
重点:设计dp 数组,dp[i] 为以数字nums[i] 结尾的最长递增子序列的长度。
public int lengthOfLIS(int[] nums) { if (nums.length == 0) { return 0; } // 定义dp 数组,dp[i] 表示以第i 个数字结尾,最长上升子序列的长度。 int[] dp = new int[nums.length]; dp[0] = 1; int maxAns = 1; // 我们从小到大计算dp 数组的值,在计算dp[i] 之前,我们已经计算出dp[0…i−1] 的值,则状态转移方程为: // dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i] for (int i = 1; i < nums.length; i++) { dp[i] = 1; for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } maxAns = Math.max(maxAns, dp[i]); } return maxAns; } // 时间复杂度O(n^2)
- 最大子数组和(53 题,背下来思想,背下来解法)
设计dp 数组,dp[i] 代表走到nums[i] 的时候,选还是不选,得到的结果是多少
dp 数组每个元素的含义是以nums[i] (最后一个元素)结尾的最大子数组和。
dp[i] 定义为数组nums 中以num[i] 结尾的最大连续子串和, 则有dp[i] = max(dp[i-1] + nums[i], num[i]);
这个的理解其实也比较简单,因为dp 数组代表的就是遍历到了第i 个nums 的数字的时候最大子数组和。所以下面的逻辑也比较好解释了。
public int maxSubArray(int[] nums) { if (nums.length == 0) { return 0; } // 状态只能是遍历到nums 数组每个元素的时候,最大和为多少 int[] dp = new int[nums.length]; dp[0] = nums[0]; int ans = nums[0]; for (int i = 1; i < nums.length; i++) { // 做判断,选这个数字,还是不选,是加和,还是自己 dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]); ans = Math.max(dp[i], ans); } return ans; }
- 最大乘积子数组的乘积(152 题,背下来思想,背下来解法)
因为这道题涉及乘法,乘法是负负得正。所以虽然类似最大子数组和,但是又不太一样
dp 数组要弄两个,一个是最大的,一个是最小的
在遍历找ans 的时候,要分别判断nums[i] 和前一个最大的乘积、和前一个最小的乘积、当前值,哪个大或者小,来做最终的更新。
public int maxProduct(int[] nums) { int length = nums.length; int[] maxF = new int[length]; int[] minF = new int[length]; maxF[0] = nums[0]; minF[0] = nums[0]; int ans = maxF[0]; for (int i = 1; i < length; i++) { maxF[i] = Math.max(maxF[i - 1] * nums[i], Math.max(nums[i], minF[i - 1] * nums[i])); minF[i] = Math.min(maxF[i - 1] * nums[i], Math.min(nums[i], minF[i - 1] * nums[i])); ans = Math.max(ans, maxF[i]); } return ans; }
- 最小编辑距离(72 题,
给定两个字符串s1 和s2,计算出将s1 转换成s2 所使用的最少操作数。可以对一个字符串进行如下操作:插入、删除、替换。
理解:s1 插入或者删除,就是对应s2 的删除或者插入。修改a 或者修改b,其实上都一样。
本质不同的操作实际上只有三种:在单词 A 中插入一个字符;
在单词 B 中插入一个字符;
修改单词 A 的一个字符。
对于这道题,dp 数组就是将s1 转为s2 的最小操作步骤。
public static int minDistance(String s1, String s2) { int m = s1.length(); int n = s2.length(); // 初始化dp 二维数组,放入初始条件 int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i <= m; i ++) { dp[i][0] = i; } for (int j = 1; j <= n; j++) { dp[0][j] = j; } for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (s1.charAt(i - 1) == s2.charAt(j - 1)) { // 如果字符相同,那么就是上一个位置的结果 dp[i][j] = dp[i - 1][j - 1]; } else { // 如果字符不同,那么就取最小的一个 // A 字符串增加一个;B 字符串增加一个;A 修改一个 dp[i][j] = Math.min(dp[i-1][j], Math.min(dp[i][j-1], dp[i - 1][j - 1])) +1; } } } return dp[m][n]; }
- 最长公共子序列(1143 题,
给定两个字符串text1
和text2
,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回0
。
dp[i][j] 表示遍历到两个字符串第i 个和第j 个的时候,最长公共子序列的长度
// 自底向上的解法 public int longestCommonSubsequence(String s1, String s2) { int m = s1.length(); int n = s2.length(); int[][] dp = new int[m + 1][n + 1]; // 定义:s1[0..i-1] 和 s2[0..j-1] 的 lcs 长度为 dp[i][j] // 目标:s1[0..m-1] 和 s2[0..n-1] 的 lcs 长度,即 dp[m][n] // base case: dp[0][..] = dp[..][0] = 0 for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { // 现在 i 和 j 从 1 开始,所以要减一 if (s1.charAt(i - 1) == s2.charAt(j - 1)) { // s1[i-1] 和 s2[j-1] 必然在 lcs 中 dp[i][j] = 1 + dp[i - 1][j - 1]; } else { // s1[i-1] 和 s2[j-1] 至少有一个不在 lcs 中 dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]); } } } return dp[m][n]; } // 此解法可以使用状态压缩,使得空间复杂度为O(N)
- 最长回文子串(也就是最长回文子数组)第5 题,
给你一个字符串s
,找到s
中最长的回文子串。
中心扩展法:经典面试题:最长回文子串
动态规划看官方题解class Solution { public String longestPalindrome(String s) { String result = ""; for (int i = 0; i < s.length(); i++) { String s1 = findPalindrome(s, i, i); String s2 = findPalindrome(s, i, i + 1); result = s1.length() > result.length() ? s1 : result; result = s2.length() > result.length() ? s2 : result; } return result; } public String findPalindrome(String s, int l, int r) { while(l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) { l --; r ++; } return s.substring(l + 1, r); } }
这道题对比下面那道题,这道题求最长回文子串这个“字符串”的时候,二维数组要使用boolean 进行存储。对于求“长度” 的时候,要二维矩阵要使用数字
此题比较重要的是,外循环要枚举“串的长度”,然后枚举左边界。
class Solution { public String longestPalindrome(String s) { int len = s.length(); if (len < 2) { return s; } int maxLen = 1; int begin = 0; // dp[i][j] 表示s[i...j] 是否为回文串 boolean[][] dp = new boolean[len][len]; for (int i = 0; i < len; i++) { dp[i][i] = true; } char[] charArray = s.toCharArray(); // 递推开始 // 枚举子串长度,长度从2 开始,长度为2、3、4、5... for (int L = 2; L <= len; L++) { // 枚举左边界,左边界上限设置可以宽松些 // 上面for 循环设置的是串的长度;下面的是左边界在哪里 for (int i = 0; i < len; i++) { // 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得 int j = L + i - 1; // 如果右边界超限制,则退出循环 if (j >= len) { break; } if (charArray[i] != charArray[j]) { dp[i][j] = false; } else if (charArray[i] == charArray[j]){ // 字符相等,且长度小于等于2 if (j - i <= 2) { dp[i][j] = true; } else { // 且字符相等,长度大于2 dp[i][j] = dp[i + 1][j - 1]; } } // 只要dp[i][j] == true 成立,就表示字符串s[i...j] 是回文子串,此时记录长度和起始位置 // 每次循环要记录一下“最长的” if (dp[i][j] && j - i + 1 > maxLen) { maxLen = j - i + 1; begin = i; } } } return s.substring(begin, begin + maxLen); } }
- 最长回文子序列的长度(516 题
对 dp 数组的定义是:在子串s[i..j]
中,最长回文子序列的长度为dp[i][j]
子序列解题模板:最长回文子序列
对于状态转移方程中的,如果i 和j 位置的两个字符不相等的情况:
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]) ,
为啥不用考虑新增的某个字符是否构成新的“回文子序列”?
因为dp[i + 1][j] 是已经算完了的。好好理解“已经算完了”是什么意思,就是已经包含了那个字符的结果了。
注意遍历顺序,i
从最后一个字符开始往前遍历,j
从i + 1
开始往后遍历,这样可以保证每个子问题都已经算好了
代码这么写的原因:最长回文子序列:子序列问题通用思路 - 知乎
“为了保证每次计算的时候,左、左斜下、下 三个方向的值已经计算出来,所以有两种遍历方式。下面的代码选择的是‘反着遍历’”。
class Solution { public int longestPalindromeSubseq(String s) { int n = s.length(); int[][] dp = new int[n][n]; // 因为是从长度较短的子序列向长度较长的转移 // 所以从i = n - 1 开始 for (int i = n - 1; i >= 0; i--) { dp[i][i] = 1; char c1 = s.charAt(i); for (int j = i + 1; j < n; j++) { char c2 = s.charAt(j); if (c1 == c2) { dp[i][j] = dp[i + 1][j - 1] + 2; } else { // 因为本身第i 个和第j 个不一致, // 而下面转移方程中是dp[i][j - 1] 和dp[i + 1][j] 这样, // 就不涉及第i 个和最后一个字符相等怎么处理这种事情了 dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); } } } // dp[0][n - 1] 的含义就是从第0 个字符串到第n - 1 个字符串的最长回文子序列的长度 return dp[0][n - 1]; } }
-
最长上升子序列的个数(673 题)
给定一个未排序的整数数组,找到最长递增子序列的个数。
class Solution { public int findNumberOfLIS(int[] nums) { int n = nums.length; int maxLen = 0; int ans = 0; // dp[i] 为以nums[i] 结尾的最长上升子序列的长度 int[] dp = new int[n]; // cnt[i] 为考虑以nums[i] 结尾的最长上升子序列的个数 int[] cnt = new int[n]; for (int i = 0; i < n; i++) { dp[i] = 1; cnt[i] = 1; for (int j = 0; j < i; j++) { // nums[i] > nums[j] 的情况是 // nums[i] 可以接在nums[j] 后面形成上升子序列 if (nums[i] > nums[j]) { // 求最长上升子序列的长度,正常状态转移方程如下 // dp[i] = Math.max(dp[i], dp[j] + 1); // 就是下面两种情况 // (1)if (dp[i] < dp[j] + 1); // 说明以nums[i] 结尾的最长递增子序列长度 // 是小于等于以nums[j] 结尾的dp[j] // dp[i] 会被dp[j] + 1 直接更新 // 所以这个时候,cnt[i] 就直接等于cnt[j] // (2)if (dp[i] == dp[j] + 1); // 说明找到了一个新的符合条件的nums[i] // 可以附加到以j 结尾的最长递增子序列之后了 // 所以,对于cnt[i] 要直接累加cnt[j] // 举例来说,之前cnt[j] 是2,组成元素分别为{1, 7} 和{2, 7} // 这个时候来了个数字num[i] = 9,可以附加到最后 // (这块还是没有太理解,以后再说吧...) if (dp[i] < dp[j] + 1) { dp[i] = dp[j] + 1; cnt[i] = cnt[j]; } else if (dp[i] == dp[j] + 1) { // 对于dp[i],因为相等,所以就不用重新赋值了 cnt[i] += cnt[j]; } } } // 最终结果判定 & 保存 if (dp[i] > maxLen) { maxLen = dp[i]; ans = cnt[i]; } else if (dp[i] == maxLen) { ans += cnt[i]; } } return ans; } }
以下思路及代码大部分来自于上述链接“王脸小”同学,同时有一部分摘抄自“labuladong 的算法小抄,以及网上的各个链接。
鸣谢@王脸小
同时题目参考:小浩算法
基础:
1. 读题后先想想有什么思路,不要让题解局限了想法,即使暴力解法也可以。
2. 懂得“递归”的思想。递归的基本思想是某个函数直接或者间接地调用自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。我们只需要关注如何把原问题划分为符合条件的子问题,而不需要研究这个子问题是如何解决的。递归的两个特征:自我调用(为了解决子问题)和结束条件(定义了最简子问题的答案)。
递归其实是有一个栈,每一层递归都是一个压栈的操作。
对于递归的内部逻辑理解:如果进入方法之后前几行代码就是判断某个条件,然后return,这样的操作其实是最底层的出栈,然后回到上一层的栈帧中,继续执行判断条件之后的代码。
关于其他的解释:
递归(recursion):递归常被用来描述以自相似方法重复事物的过程,在数学和计算机科学中,指的是在函数定义中使用函数自身的方法。(A调用A)
迭代(iteration):重复反馈过程的活动,每一次迭代的结果会作为下一次迭代的初始值。(A重复调用B)
递归:递推 + 回归。是一个树形结构。当“递推”到达底部时就会开始“回归”,其过程相当于树的深度优先遍历。
迭代:是一个环结构,从初始状态开始,每次迭代都遍历这个环,并更新状态,多次迭代直到到达结束状态。
发现一个规律:
如果使用递归的话,会发现递归的方法里面是有很多参数的。
比如说针对数组的操作,需要包含start、end 这些参数。这样可以方便下次调用的时候,变换参数。
同时,递归方法中的代码大致分为三个结构。
第一个部分是结束操作返回临界条件
第二部分是递归函数的引入
第三部分是逻辑处理代码
参考下面的归并排序,很明显
基础
排序算法时间复杂度、空间复杂度、稳定性比较_排序算法的时间复杂度和空间复杂度
排序算法
- 快速排序
参考链接:https://zhuanlan.zhihu.com/p/26891027
解题思路:这个写法很简单,在纸上画一画就可以写得出来。选择最左边的一个值作为flag,然后从右边开始找。整体一个大循环,是一次遍历,将小于flag 的移到左边,大于的移到右边。大循环内部是右侧向左,左侧向右的操作。一次大循环过后,对flag 左右的两段分别进行快排。-> 整体的方向就是一个大数组 -> 以flag 左右分,分别处理 -> 整体到细节,不用合并。因为这个是二分,二分完了之后还是相同的逻辑,所以是要调用自己的。
记住函数签名:void quickSort(int[ ] nums, int start, int end)
5 8 7 6 3 1 2 -
// 对于start 和end,它们是每次循环,自身调用自己的时候的标志 public static void quickSort(int[] nums, int start, int end) { // 所以在这里,需要使用start 和end 作为实际执行的时候的游标 int low = start; int high = end; int key = nums[low]; // 如果low 等于high,那么就说明不需要再向下执行了。所以这里只是小于而不是小于等于。 // 这是针对这个数组的一次循环 while (low < high) { // 从右边开始,与key 的大小左对比 while (low < high && nums[high] >= key) { high --; } // 如果跳出了上面的while 循环,说明发现了比key 小的值,那么执行交换 // 同时因为上面的while 循环有个low < high 的判断,所以这里也要加一下 if (low < high) { nums[low] = nums[high]; // 对low 赋值了,所以low 这个游标应该加一 low ++; } // 然后再从左边开始 while (low < high && nums[low] <= key) { low ++; } // if (low < high) { nums[high] = nums[low]; high --; } } // 此时说明low 和high 已经相遇了 nums[low] = key; // 如果low - 1 还是大于start 的,那么对相遇点左边的继续执行此函数 if (low - 1 > start) { quickSort(nums, start, low - 1); } // 如果high + 1 小于end,那么对相遇点右边的数组元素再执行此函数 if (high + 1 < end) { quickSort(nums, high + 1, end); } }
为什么先从右边开始:https://blog.csdn.net/he37176427/article/details/97795963
因为选择左边为key 的时候,从右边开始向左找,最终左右游标相遇的时候,那个值肯定是小于key 的。这个时候交换key 和相遇时的值,是符合条件的。但是如果选择了最左边的值为key,同时又从左边开始,那么最终两个游标相遇的时候,那个值肯定是大于key 的,这样就不符合快排的思想了。
快速排序的处理过程就是一个倒转的树结构,所以没有什么需要到最后进行“汇总”的代码。
- 归并排序
参考链接:【算法】排序算法之归并排序 - 知乎
代码参考力扣讲解
解题思路:整体思路就是一个拆分、合并的过程。
记住函数签名:void mergeSort(int[ ] nums, int l, int r)class Solution { int[] tmp; public int[] sortArray(int[] nums) { tmp = new int[nums.length]; mergeSort(nums, 0, nums.length - 1); return nums; } public void mergeSort(int[] nums, int l, int r) { if (l >= r) { return; } int mid = (l + r) >> 1; mergeSort(nums, l, mid); mergeSort(nums, mid + 1, r); int i = l, j = mid + 1; int cnt = 0; while (i <= mid && j <= r) { if (nums[i] <= nums[j]) { tmp[cnt++] = nums[i++]; } else { tmp[cnt++] = nums[j++]; } } while (i <= mid) { tmp[cnt++] = nums[i++]; } while (j <= r) { tmp[cnt++] = nums[j++]; } // 一共r - l + 1 个数字 // 把nums 中这些位置的元素替换掉,替换成tmp 中排好序的数字 for (int k = 0; k < r - l + 1; ++k) { nums[k + l] = tmp[k]; } } }
注意上面return 逻辑的理解,因为是基于递归而实现,在最后拆分为只有一个元素的时候,return 是返回到递归的上一层,然后继续执行后面的代码。(递归的思想)
-
堆排序
时间复杂度O(n log n) 最好、最坏、平均都是
空间复杂度O(1)
堆排序三个过程:1)建堆;2)调整;3)输出
建堆的过程就是把一个数组维护成每个点都比叶子节点大或者小的结构
排序,一个参数:nums[i]
建堆,两个参数:nums[i], int len
调整,三个参数:nums[i], int i, int len
其中,len 代表数组长度,i 代表
记住函数签名:
(1)调整:heapify(int[ ] nums, int i, int length)
(2)建堆:buildMaxHeap(int[ ] nums, int len)
举例:5 8 7 6 3 1 2public static int[] sort(int[] sourceArray) { // 对 arr 进行拷贝,不改变参数内容 int[] arr = Arrays.copyOf(sourceArray, sourceArray.length); int len = arr.length; buildMaxHeap(arr, len); for (int i = len - 1; i > 0; i--) { swap(arr, 0, i); len--; heapify(arr, 0, len); } return arr; } // 建堆,过程就是从第一个非叶子节点开始调整 private static void buildMaxHeap(int[] arr, int len) { for (int i = (int) Math.floor(len / 2) - 1; i >= 0; i--) { heapify(arr, i, len); } } // 对每个节点i 进行调整 // 这是创建大顶堆的过程,创建大顶堆,然后可以进行正序排列 private static void heapify(int[] arr, int i, int len) { int left = 2 * i + 1; int right = 2 * i + 2; int largest = i; if (left < len && arr[left] > arr[largest]) { largest = left; } if (right < len && arr[right] > arr[largest]) { largest = right; } if (largest != i) { swap(arr, i, largest); // 对于调整了的那个节点,还需要再继续向下进行调整 heapify(arr, largest, len); } } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }
正序排列,创建大顶堆。
因为在后续处理的时候,是把堆顶元素放到数组最后,然后再调整堆。
建堆的时候,是从第一个非叶子节点开始调整。从下往上调整。
逆序排列,创建小顶堆。
215 数组中第k 大的元素
- 冒泡排序
public static void bubble(int[] nums) { int length = nums.length; // 外层循环决定执行几次,只需要执行length - 1 次就好了 for (int i = 0; i < length - 1; i++) { // 内层循环用来执行交换 for (int j = 0; j < length -1 - i; j++) { if (nums[j] > nums[j + 1]) { nums[j] = nums[j] + nums[j + 1]; nums[j + 1] = nums[j] - nums[j + 1]; nums[j] = nums[j] - nums[j + 1]; } } } for (int i = 0; i < nums.length; i++) { System.out.println(nums[i]); } }
- 树的前中后遍历,迭代
前序遍历:根左右。使用栈,先把根节点push,然后while 判断栈是否为空,不空则pop 出一个node,然后右不空push 右,左不空push 左(因为FILO)。结束。
中序遍历:左根右。do-while 循环。先把所有左子树节点放入栈中,直到最后,是最后一个左子树节点。然后出栈,再判断其右子树,处理右子树。如果由子树继续为空,则继续pop;不空,则继续push。
后序边拆:左右根,可以根、右、左,然后反转。
参考:Java迭代实现二叉树的前序、中序、后序遍历_yangfeisc的专栏-CSDN博客_java二叉树的前序遍历
二分查找
做二分法需要自己理解并总结出,需要怎么进入循环,需要怎么退出循环。
即怎么判断边界条件。如果用数学方法表示mid,mid = (l + r)/2,这种是很明确的表示方法。但是到了编程的时候,就涉及mid 是应该(l + r)/2 还是(l + r + 1) / 2,判断l <= r 为结束条件,还是l < r 为结束条件。具体参考:算法浅谈——人人皆知却很多人写不对的二分法 - 知乎
二分法要注意不要造成死循环
对于区间的设置,可以使用“左闭右开”,对于每个人,设置一个自己的固定模式,这样更容易记一些。
- 数组
4. 寻找两个正序数组的中位数 (hard)
题目要求时间复杂度Olog(m+n)。如果有log 的时间复杂度,差不多应该都是使用二分法。
根据数组的定义,其中一个数组长度为m,另一个数组长度为n。
如果m + n 为奇数【如7】,那么中位数就是下标为(m + n) / 2 【7/2 = 3】的数字。如果m + n 为偶数【如10】,那么中位数就是下标为(m + n) / 2 和(m + n) / 2 - 1 【4,5】的数字。可以利用整数除法向下取整的特点,求数组中下标为(m + n - 1) / 2 和(m + n) / 2 的元素,然后求出两个数字的平均值,就是数组的中位数。
以下题目来自- 查找排序数组给定target 的左边界,查找排序数组给定target 的右边界。代码来自labuladong
按照题目的解法理解的话,给定target 的左边界,比如说下面的例子0 4 5 5,给定的target 是5,给定target 的左边界就是第一个5 的下标,就是2;右边界就是第二个5,下标是3
查找给定数组的左边界的下标,相当于查找排序数组中元素小于target 的元素的个数
比如说,[0, 4, 5, 5] target = 5,那么得到的结果就是2。查找给定数组的左边界的下标,相当于查找排序数组中元素小于target 的元素的个数。 所以在下面的代码中的判断,才返回的是nums.length。 重点在于num[mid] == target 时,right = mid。将范围逐步压缩。 public static int left_bound(int[] nums, int target) { if (nums[nums.length - 1] < target) { return nums.length; } int left = 0; int right = nums.length - 1; while (left < right) { int mid = (left + right) / 2; if (nums[mid] == target) { right = mid; } else if (nums[mid] > target) { right = mid; } else if (nums[mid] < target) { left = mid + 1; } } if (nums[left] != target) { return -1; } return left; } 或者 看这个,这个能看懂 public static int left_bount(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left < right) { int mid = (left + right) / 2; if (nums[mid] == target) { right = mid; } else if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } } if (nums[right] == target) { return right; } else { return -1; } } 注意在wile 块里面的循环逻辑里,如果有“等于”的判断,那么while 条件里,就不能是“小于等于”的判断
查找右边界 public static int right_bound(int[] nums, int target) { if (nums.length == 0) return -1; int left = 0, right = nums.length; while (left < right) { int mid = (left + right) >> 1; if (nums[mid] == target) { left = mid + 1; } else if (nums[mid] > target) { right = mid; } else if (nums[mid] < target) { left = mid + 1; } } return left - 1; } 求mid 的时候要+1 就不会进入死循环。 + 1 代表只剩两个数字的时候,选左边还是选右边 有时间再研究总结吧 public static int right_bound(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left < right) { int mid = (left + right + 1) / 2; if (nums[mid] == target) { left = mid; } else if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] < target) { left = mid + 1; } } if (nums[right] == target) { return right; } else { return -1; } }
- 33. 搜索旋转排序数组 直接使用二分法
一个数组,已经排过序,在某个元素那里做了旋转。要求查找出数组中是否存在某个元素。要求olog(n) 的时间复杂度。
olog(n) 时间复杂度即说明了使用二分法。
旋转数组是原来一个有序数组,进行了旋转,得到一个“局部有序”的数组。例如,[0,1,2,4,5,6,7]
在下标3
处经旋转后可能变为[4,5,6,7,0,1,2]。
这个旋转的方式是在下标K 处旋转,是数组变成(num[k], num[k + 1] ... nums[0], nums[1] ...)
可以通过在常规的二分查找的时候,找到mid 为分割位的前半部分[1, mid],和[mid + 1,r] 哪部分是有序的,然后在有序的部分判断target 是否存在,从而根据有序的部分可以推断出如何改变二分查找的上下界。
思路:这道题的重点思想就在,无序的那部分可能是不太好处理,我们可以转头去处理有序的那部分。每次循环通过nums[l],nums[r] 和nums[middle] 的大小,来判断l ~ middle 或者middle ~ r 的区间是否是递增的。class Solution { public int search(int[] nums, int target) { if (nums.length == 0) { return -1; } int low = 0; int high = nums.length - 1; while (low <= high) { int middle = (low + high) >> 1; if (nums[middle] == target) { return middle; } // 说明此区间有序 if (nums[low] <= nums[middle]) { if (target >= nums[low] && target <= nums[middle]) { high = middle - 1; } else { low = middle + 1; } // 说明另外一个区间有序 } else { if(target >= nums[middle] && target <= nums[high]) { low = middle + 1; } else { high = middle - 1; } } } return -1; } }
- 287. 寻找重复数 dict
包含n + 1 个数字,数字范围1 ~ n,存在一个重复的数字。找出这个数字。
利用条件【一共有n + 1 个数字,数字的范围在1 ~ n】。
这道题要注意的地方是,进行二分的不是数组,而是1 ~ n 这个数字区间。
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。
假设 nums 只有 一个重复的整数 ,找出 这个重复的数 。
你设计的解决方案必须不修改数组 nums 且只用常量级 O(1) 的额外空间。
这道题二分的是1 ~ n 这n 个数字首先,我们让中指针指向n/2, 由题意可得,小于n/2的元素只会有(n/2)-1个。 比如说,数组是4,1,3,2,5 对于数组中每个元素,小于1 的元素有0 个,小于2 的元素有1 个,小于3 的元素有2个... 如果超过了(n/2)-1,说明在[1,n/2)区间有重复的数字,此时我们将待查找的数据规模缩小到[1, n/2) 如果数组没有超过(n/2)-1。说明在[n/2, n]区间有重复的数字,此时我们讲查找的数据规模缩小到[n/2, n] 不断重复上述过程,直到找到某一个重复的数字。 时间复杂度O(nlogn), 二分法本身是O(logn), 每次缩小规模时需要统计一次数组(O(n)) 空间复杂度O(1) 代码中start 是数字范围1 ~ n 的1;代码中的end 是数字范围,一共n + 1 个数字,范围1 ~ n,所以最大的是nums.length - 1。 public static int findDuplicate(int[] nums) { int start = 1, end = nums.length - 1; int mid = (start + end) / 2; while (start <= end){ int count = 0; for(int num: nums){ // 计算小于中间数字mid 的数字个数 if(num < mid) { count += 1; } else { break; } } if( count >= mid) { // 说明重复数字在此区间内 end = mid - 1; } else { // 说明重复数字在另一个区间 start = mid + 1; } mid = (start + end) / 2; } return mid; } 参考:https://zhuanlan.zhihu.com/p/102298178
- 34. 在排序数组中查找元素的区间
原创(@王漂亮)提示解法:取开始下标(mid = (l + r) // 2); 取结束下标(mid = (a + b + 1) // 2)
题目要求,o(log n) 时间复杂度,给定一个升序有序数组和一个目标值target,找出给定目标值在数组中开始和结束的位置
要求o(log n) 即为“二分法”。考虑到要求找到target 在数组中的开始和结束位置,即找到第一个等于target 值的位置和第一个大于target 值的位置,然后减一。
这道题的核心就是分两次,二分查找第一个等于target 的位置和第一个大于target 的位置。
为了代码复用,定义方法binarySearch(nums, target, lower) 表示在nums 数组中二分查找target 的位置,如果lower 为true,则查找第一个大于等于target 的下标,否则查找第一个大于target 的下标。
解释:lower = true,代表查找target 左下标那边,大于等于的原因是target 下标有可能不存在。
因为target 有可能不存在,所以最后需要校验结果是否正确。
8public int[] searchRange(int[] nums, int target) { int low = binarySearch(nums, target, true); int high = binarySearch(nums, target, false) - 1; if (low <= high && high < nums.length && nums[low] == target && nums[high] == target) { return new int[]{low, high}; } return new int[]{-1, -1}; } private static int binarySearch(int[] nums, int target, boolean lower) { int left = 0; int right = nums.length - 1; int ans = nums.length; while (left <= right) { int mid = (left + right) / 2; if (lower) { if (nums[mid] >= target) { right = mid - 1; ans = mid; } else { left = mid +1; } } else { if (nums[mid] > target) { right = mid -1; ans = mid; } else { left = mid + 1; } } } return ans; }
- 矩阵
记住递增矩阵的性质。
1. 左上最小,右下最大
2. 搜索某个元素,可以从右上角开始,如果大于target,则向左走;小于target,则向下走
3. 给定一个target,小于等于target 的都在左上角,大于的都在右下角- 240. 搜索二维矩阵 II
原创(@王漂亮)提示解法:从左下/右上开始search, O(m+n)
这道题注意矩阵的性质,左上到右下是依次递增的。所以从右上角开始,对每个元素和target 比较大小。
public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length; int n = matrix[0].length; int i = m - 1; int j = 0; while(i >= 0 && j < n) { if (matrix[i][j] == target) { return true; } else if (matrix[i][j] < target) { j ++; } else { i --; } } return false; }
- 378. 有序矩阵中第K小的元素
原创(@王漂亮)提示解法:依次添加右和下,pop出其中较小的,每次pop k-1,pop k次返回
使用矩阵的性质,矩阵左上角最小,右下角最大。同时这种矩阵如果指定一个元素target,在矩阵中可以得到,小于target 的元素都在左上角,大于的都在右下角。如果元素matrix[i][j] <= target,则matrix[0][j] ~ matrix[i][j] 的值都小于等于target。依据此思路,可以使用二分法一个个试出来有序矩阵中的第k 小的元素。
这到题是针对数组值做二分,然后每次判断某个值在矩阵中的位置。class Solution { public int kthSmallest(int[][] matrix, int k) { int n = matrix.length; int left = matrix[0][0]; int right = matrix[n-1][n-1]; while (left < right) { int mid = (left + right) / 2; if (check(matrix, mid, k, n)) { right = mid; } else { left = mid +1; } } return left; } // 检查当前数字【mid】在矩阵中的情况 // 返回结果:小于等于目标值mid 的数字个数是否大于等于k 个 // 传参需要用到:矩阵,要查询的数字mid,范围k public boolean check(int[][] matrix, int mid, int k, int n) { // 从左下开始寻找,i 行,j 列。 int i = n - 1; int j = 0; // 符合要求的个数 int num = 0; while (i >= 0 && j < n) { if (matrix[i][j] <= mid) { // 元素上面的那些都是符合要求的 num += i + 1; j ++; } else { i --; } } // 因为要寻找第k 小的元素,返回一个boolean,判断寻找到的数字的个数,是否多于k 个 return num >= k; } }
- 240. 搜索二维矩阵 II
- 其他
- 69. X的平方根
原创(@王漂亮)提示解法:在[0, x]中二分查找
二分查找,在[0,x] 范围内。设ans 为最终答案,x 的平方根整数部分ans 就是k方 <= x 的最大k 值,因此可以对k 进行二分查找。class Solution { public int mySqrt(int x) { int l = 0, r = x; int ans = -1; while (l <= r) { int mid = (l + r) / 2; if ((long)mid * mid <= x) { ans = mid; l = mid + 1; } else { r = mid - 1; } } return ans; } }
- 69. X的平方根
链表(适当使用哑结点,可命名为hair[head 上层为hair])
无法高效获取长度,无法根据偏移快速访问元素,是链表的两个劣势。然而面试的时候经常碰见诸如:
- 获取倒数第k个元素(两个指针,第一个指针先走k步,然后第二个指针开始。等第一个指针走到了末尾,第二个指针指向的就是倒数第k个)
- 获取中间位置的元素(第一个指针每次走两步,第二个指针每次走一步)
- 判断链表是否存在环(快慢指针)
- 判断环的长度与位置(3 中两个指针相遇后继续移动,再次相遇移动的次数就是环的长度)
这些相关的问题都可以通过灵活运用双指针来解决。
- 基础题
- 206. 反转链表
迭代、递归。
迭代:当前节点的next 要指向前一个节点,同时当前节点的下一个节点也要存储。prev/curr/curr = head, 然后进入while 循环,判断条件为curr 不为空。取出curr 的next,改变curr.next 指向,改变prev 的指向,改变curr 为next。while 循环结束,返回prev,即为反转的头结点。
递归:
这个其实就直接mock 一个虚拟头结点。
这里是有三个节点:pre,cur,next。
next 的作用就是保存前面那些需要处理的节点的下一个节点,这样没处理的链就可以保留下来了。/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; cur.next = prev; prev = cur; cur = next; } return prev; } }
- 206. 反转链表
- 反转链表II
- 92
给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反转从第left 到第right 位置的链表节点,返回 反转后的链表
这里也需要三个节点:pre,cur,next。
其中pre 是不能变的,所以对比上面的反转一整个链表来说,就需要一个next.next 的存在。
先赋值next 节点(next = cur.next)
然后处理cur 的next
然后处理next 的next
然后处理pre 的nextpublic ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummyNode = new ListNode(-1); dummyNode.next = head; ListNode pre = dummyNode; for (int i = 0; i < left - 1; i++) { pre = pre.next; } ListNode cur = pre.next; ListNode next; for (int i = 0; i < right - left; i++) { next = cur.next; cur.next = next.next; next.next = pre.next; pre.next = next; } return dummyNode.next; }
- 92
- 重难点 (M->H)
- 138. 复制带随机指针的链表 di[node] = Node, key为原节点,val为新节点, di[node].next = di.get(node.next), O(2n)
/* // Definition for a Node. class Node { int val; Node next; Node random; public Node(int val) { this.val = val; this.next = null; this.random = null; } } */ class Solution { public Node copyRandomList(Node head) { if(head == null) { return null; } Node pointer = head; // 使用这个map 做原链表和老链表的映射,每个node 一一对应 Map<Node, Node> map = new HashMap<>(); while (pointer != null) { Node newNode = new Node(pointer.val); // 新链表节点只是存于map 中,还没有连接 map.put(pointer, newNode); pointer = pointer.next; } pointer = head; while (pointer != null) { Node newNode = map.get(pointer); // random 节点赋值 if (pointer.random != null) { newNode.random = map.get(pointer.random); } // next 节点赋值 if (pointer.next != null) { newNode.next = map.get(pointer.next); } pointer = pointer.next; } return map.get(head); } }
- 21. 合并两个有序链表 递归,迭代(dummy_node)
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode preHead = new ListNode(-1); ListNode prev = preHead; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { prev.next = l1; l1 = l1.next; } else { prev.next = l2; l2 = l2.next; } prev = prev.next; } prev.next = l1 == null ? l2 : l1; return preHead.next; } }
- 23. 合并K个排序链表 for i in range(0, cnt-interval, interval*2)
使用“堆”的数据结构完成,时间复杂度O(logKN)/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode mergeKLists(ListNode[] lists) { Queue<ListNode> pq = new PriorityQueue<>((v1, v2) -> v1.val - v2.val); for (ListNode node : lists) { if (node != null) { pq.offer(node); } } ListNode dummyHead = new ListNode(0); ListNode tail = dummyHead; while (!pq.isEmpty()) { ListNode minNode = pq.poll(); tail.next = minNode; tail = tail.next; if (minNode.next != null) { pq.add(minNode.next); } } return dummyHead.next; } }
- 25. K 个一组翻转链表 cnt++, cnt--
(看官方题解就好了,看官方题解,官方题解。但是官方题解看不懂。这是一道hard)
public ListNode reverseKGroup(ListNode head, int k) { ListNode hair = new ListNode(-1); hair.next = head; ListNode pre = hair; while (head != null) { ListNode tail = pre; // 查看剩余部分长度是否大于k for(int i = 0; i < k; i++) { tail = tail.next; if (tail == null) { return hair.next; } } ListNode pos = tail.next; ListNode[] reserved = reverseList(head, tail); // 翻转部分装回链表 pre.next = reserved[0]; reserved[1].next = pos; pre = reserved[1]; head = pos; } return hair.next; } public ListNode[] reverseList(ListNode left, ListNode right) { ListNode[] result = new ListNode[]{right, left}; // 最后返回的时候,因为不是一个孤单的链表,所以反转之后,left 得指向rightNext ListNode rightNext = right.next; ListNode pre = rightNext; ListNode leftNext; while (left != rightNext) { leftNext = left.next; left.next = pre; pre = left; left = leftNext; } return result; }
- 138. 复制带随机指针的链表 di[node] = Node, key为原节点,val为新节点, di[node].next = di.get(node.next), O(2n)
- 双指针技巧
有个简单的搞法,就把slow 和fast 的起始值都设置成head 就好了。
对于快慢指针,寻找链表的中点可以使用快慢指针的做法,快指针每次移动 2 步,慢指针每次移动 1 步,当快指针到达链表末尾时,慢指针指向的链表节点即为链表的中点。
如果是奇数个节点,那么slow 就是那个中点;如果是偶数个节点,那么slow 指向的就是前半个链表的最后一个节点。- 141. 环形链表
环形链表即判断链表是否有环。使用快慢指针。fast指针走两步,slow 指针走一步。public class Solution { public boolean hasCycle(ListNode head) { if (head == null || head.next == null) { return false; } ListNode slow = head; ListNode fast = head.next; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { return true; } } return false; // 第二种解法 // if (head == null || head.next == null) { // return false; // } // ListNode slow = head; // ListNode fast = head.next; // while (slow != fast) { // if (fast == null || fast.next == null) { // return false; // } // slow = slow.next; // fast = fast.next.next; // } // return true; } }
- 142. 环形链表 II
可以设置一个set,走过一个就存一个,如果有重复,那第一个重复的就是相交节点。
另外就是设置快慢指针。设置快慢指针依旧是快的走两步,慢的走一步。快慢指针相遇的时候,再设置一个可以判断快慢指针的解法可以直接看题解。
利用链表的性质解题的时候,fast 和slow 要同时指向head,然后开始向后遍历才可以
整体过程就是,先遍历找到重合的点;然后再找相交的点。
public ListNode detectCycle(ListNode head) { if (head == null) { return null; } ListNode slow = head; ListNode fast = head; while (fast != null) { if (fast.next != null) { slow = slow.next; fast = fast.next.next; } else { return null; } if (fast == slow) { ListNode ptr = head; while (ptr != slow) { ptr = ptr.next; slow = slow.next; } return ptr; } } return null; }
- 160. 相交链表 di
对于两个链表,一个指针flagA走链表a,一个指针flagB走链表b,flagA 走完了链表A 后,再走链表b,flagB 走完了链表b 后再走链表A,两个指针走过的距离一样,那么他们就会相遇。
此题的“相交”如图
public ListNode getIntersectionNode(ListNode headA, ListNode headB) { if (headA == null || headB == null) { return null; } ListNode preA = headA; ListNode preB = headB; while (preA != preB) { if (preA != null) { preA = preA.next; } else { preA = headB; } if (preB != null) { preB = preB.next; } else { preB = headA; } } return preA; }
- 19. 删除倒数第N个节点 快指针先走n步
删除倒数第N 个节点,首先得找到这个节点。用first 指针和second 指针,first 先走n 个节点,然后second 再开始。这样当first 走到尾的时候,second 就到了倒数第n 割接点。具体实现要考虑怎么方便怎么来。
public ListNode removeNthFromEnd(ListNode head, int n) { ListNode hair = new ListNode(0, head); ListNode first = head; ListNode second = hair; for (int i = 0; i < n; i++) { first = first.next; } while (first != null) { first =first.next; second = second.next; } second.next = second.next.next; return hair.next; }
- 141. 环形链表
- 其他
- 234. 回文链表
原创(@王漂亮)提示解法:left数组, left从后往前,指针从前往后,依次对比,slow, fast = head, head.next
将值复制到数组中用前后双指针法;用递归;用快慢指针,找到中间,然后将其中一个进行翻转,然后进行比较
做法:找到第一个链表的结尾。如果奇数个,那么第一个链表多一个。然后翻转后面的链表,进行对比
// 找到前半部分尾节点,并翻转后半部分链表(重点)
// 这样就可以避免了链表奇偶性的判断public boolean isPalindrome(ListNode head) { if (head == null) { return true; } // 找到前半部分尾节点,并翻转后半部分链表(重点) // 这样就可以避免了链表奇偶性的判断 ListNode firstHalfEnd = endOfFirstHalf(head); ListNode reversedSecondHalfStart = reverseList(firstHalfEnd.next); ListNode p1 = head; ListNode p2 = reversedSecondHalfStart; boolean result = true; while (p1!= null && p2 != null) { if (p1.val != p2.val) { result = false; break; } p1 = p1.next; p2 = p2.next; } return result; } private ListNode reverseList(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; cur.next = prev; prev = cur; cur = next; } return prev; } // 有可能返回的是技术个数的中间打那个;有可能返回的事偶数个,前面那个链表的最后一个节点 private ListNode endOfFirstHalf(ListNode head) { ListNode fast = head; ListNode slow = head; while (fast.next != null && fast.next.next != null) { fast = fast.next.next; slow = slow.next; } return slow; }
- 328. 奇偶链表
原创(@王漂亮)提示解法:保存下even_head, odd.next, even.next = odd.next.next, even.next.next
奇偶链表就是将一个链表中的奇数位放到前部分,偶数位节点放到后部分。解法就是分离奇偶节点,然后合并。迭代解法的终止条件就是偶数节点为空或者偶数节点的next 为空。class Solution { public ListNode oddEvenList(ListNode head) { if (head == null || head.next == null || head.next.next == null) { return head; } ListNode evenHead = head.next; ListNode odd = head; ListNode even = evenHead; while (even != null && even.next != null) { odd.next = even.next; odd = odd.next; even.next = odd.next; even = even.next; } odd.next = evenHead; return head; } }
- 2. 两数相加
原创(@王漂亮)提示解法:迭代(dummy_node), 最后不要忘了 if carry>0: h.next = ListNode(1)
两数字相加,注意进位就好了class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { int flag = 0; ListNode head = null; head = new ListNode(); ListNode temp = head; while (l1 != null || l2 != null) { int l1Val; int l2Val; if (l1 == null) { l1Val = 0; } else { l1Val = l1.val; l1 = l1.next; } if (l2 == null) { l2Val = 0; } else { l2Val = l2.val; l2 = l2.next; } int result = l1Val + l2Val + flag; ListNode listNode = new ListNode(); listNode.val = result % 10; flag = result / 10; temp.next = listNode; temp = temp.next; } if (flag >= 1) { ListNode listNode = new ListNode(); listNode.val = flag; temp.next = listNode; } return head.next; } }
- 148. 排序链表
原创(@王漂亮)提示解法:mergesort (slow, fast找到mid,再分别mergesort); merge dummynode
(看不懂)
- 234. 回文链表
注:一般要分为两段的链表的双指针slow,fast = head, head.next; 不需要分为两段的slow,fast = head, head 对于slow 和fast,都设置head 为初始节点就行。
字符串
- 滑动窗口
- 76. 最小覆盖子串 - M while all(map(lambda x: s_c[x] >= t_c[x], t_c.keys())):
题目:给你一个字符串s
、一个字符串t
。返回s
中涵盖t
所有字符的最小子串。如果s
中不存在涵盖t
所有字符的子串,则返回空字符串""
。
保证如果有答案,那么保证它是唯一的答案。
(看不懂)(hard 做不出来) -
//模板 /* 滑动窗口算法框架 */ void slidingWindow(string s, string t) { Map<Character, Integer> need = new HashMap<>(); Map<Character, Integer> window = new HashMap<>(); for (char c : t.toCharArray()) need.put(c,need.getOrDefault(c,0)+1); int left = 0, right = 0; int valid = 0; while (right < s.size()) { // c 是将移入窗口的字符 char c = s.charAt(right); // 右移窗口 right++; // 进行窗口内数据的一系列更新 ... /*** debug 输出的位置 ***/ System.out.println("window: ["+left+","+ right+")"); /********************/ // 判断左侧窗口是否要收缩 while (window needs shrink) { // d 是将移出窗口的字符 char d = s[left]; // 左移窗口 left++; // 进行窗口内数据的一系列更新 ... } } } //题解 class Solution { public String minWindow(String s, String t) { if (s == null || s == "" || t == null || t == "") { return ""; } // 维护两个map记录窗口中的符合条件的字符以及need的字符 // 其中key 是字母本身,value 是字符出现的次数 // 存放t。含义是存储"需要的字符"(用它做标的去查询)以及需要的数量 Map<Character, Integer> needsMap = new HashMap<>(); // 滑动窗口中符合条件的字符 Map<Character, Integer> windowMap = new HashMap<>(); // 将t 存入needsMap 中 for (int i = 0; i < t.length(); i++) { char temp = t.charAt(i); needsMap.put(temp, needsMap.getOrDefault(temp, 0) + 1); } // 滑动窗口的左边界 int left = 0; // 滑动窗口的右边界 int right = 0; // count 用来记录当前滑动窗口windowMap 中符合need 要求的字符的数量 // 当count == need.size() 的时候,即可shrink 窗口 int count = 0; // 符合要求最优解的开始位置 int start = 0; int len = Integer.MAX_VALUE; // 一次遍历找"可行解" while(right < s.length()) { // 更新窗口 char c = s.charAt(right); // 扩大窗口 right ++; if (needsMap.containsKey(c)) { windowMap.put(c, windowMap.getOrDefault(c, 0) + 1); if (needsMap.get(c).equals(windowMap.get(c))) { count ++; } } // shrink 左边界,找到符合条件的最优解 while (count == needsMap.size()) { // 不断"打擂",寻找满足条件的len 的最短值,并记录最短子串起始start 位置 if (right - left < len) { len = right - left; start = left; } // 更新窗口 char d = s.charAt(left); // 窗口缩小 left ++; if (needsMap.containsKey(d)) { // window.put(d,window.get(d)-1); // ——bug: // 若一进去就将window对应的键值缩小, // 就永远不会满足下面的if,while也会一直执行, // 直到left越界,因此,尽管和上面对窗口的处理几乎一样, // 但是这个处理的顺序还是很关键的!要细心! // 如果滑动窗口windowMap 中包含了 if (needsMap.get(d).equals(windowMap.get(d))) { // count 用来记录当前滑动窗口windowMap 中符合need 要求的字符的数量 count --; } windowMap.put(d, windowMap.get(d) - 1); } } } return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len); } }
- 438. 找字符串中所有字母的异位词
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
(看不懂)public static List<Integer> findAnagrams(String s, String t) { List<Integer> result = com.google.common.collect.Lists.newArrayList(); int left, right; left = right = 0; Map<Character, Integer> needs = Maps.newHashMap(); Map<Character, Integer> windows = Maps.newHashMap(); for (int i = 0; i < t.length(); i++) { Character c = t.charAt(i); needs.put(c, needs.getOrDefault(c, 0) + 1); } int match = 0; while (right < s.length()) { char c1 = s.charAt(right); if (needs.containsKey(c1)) { windows.put(c1, windows.getOrDefault(c1, 0) + 1); if (Objects.equals(windows.get(c1), needs.get(c1))) { match ++; } } right ++; while (match == needs.size()) { if (right - left == t.length()) { result.add(left); } char c2 = s.charAt(left); if (needs.containsKey(c2)) { windows.put(c2, windows.get(c2) - 1); if (windows.get(c2) < needs.get(c2)) { match --; } } left ++; } } return result; }
- 3. 无重复字符的最长子串 - M
要求无重复字符的最长子串,即找出从每一个字符开始的,不包含重复字符的最长子串,最长的那个字符串即为答案。
此题很明显特征,使用“滑动窗口”。使用两个指针,标识子串的前指针和后指针。同时还需要一个数据结构来判断是否有重复字符,可以用set。前指针移动的时候,从set 中移除;后指针移动的时候,向set 中添加。public static int lengthOfLongestSubString(String s) { int left, right; left = right = 0; int result = 0; Map<Character, Integer> windows = new HashMap<>(); while (right < s.length()) { char c1 = s.charAt(right); windows.put(c1, windows.getOrDefault(c1, 0) + 1); right++; while(windows.get(c1) >1) { char c2 = s.charAt(left); windows.put(c2, windows.get(c2) - 1); left ++; } result = Math.max(result, right - left); } return result; }
- 340. 至多包含 K 个不同字符的最长子串 - H if not di[s[start]]: di.pop(s[start]) # 记得pop if value == 0
(被锁)
给定一个字符串 s ,找出 至多 包含 k 个不同字符的最长子串 T。
示例 1: 输入: s = "eceba", k = 2 输出: 3 解释: 则 T 为 "ece",所以长度为 3。 示例 2: 输入: s = "aa", k = 1 输出: 2 解释: 则 T 为 "aa",所以长度为 2。
public static int lengthOfLongestSubStringKDistinct(String s, int k) { int len = s.length(); char[] charArray = s.toCharArray(); // 这个数组用来统计遍历过程中字母出现的次数 int[] freq = new int[123]; // 使用count 来记录有多少个不同的字符 int count = 0; int result = 0; int left = 0; int right = 0; while (right < len) { if (freq[charArray[right]] == 0) { count ++; } // 遍历到了某个字符之后,用来记录字母是否出现过 freq[charArray[right]] ++; right ++; // 缩小窗口 while (count > k) { freq[charArray[left]] --; if (freq[charArray[left]] == 0) { count --; } left ++; } result = Math.max(result, right - left); } return result; }
- 76. 最小覆盖子串 - M while all(map(lambda x: s_c[x] >= t_c[x], t_c.keys())):
- DP
最长回文子串,dp[i][j] 表示从i 到j 是否为回文子串。
两个for 循环,第一个是循环是字符串的长度,从2 开始
第二个for 循环是枚举左下标,从0 开始
最长回文子序列的长度,dp[i][j] 代表字符串从i 到j 的回文子序列的长度。两次遍历,i 从最后开始。
子序列可以不连续;子串必须是连续的
- 5. 最长回文子串(最长回文子数组)
原创(@王漂亮)提示解法:- M dp[i][j], dp[0][0]=1, 要for r再for l以确保dp[i+1][j-1赋值
如果一个子串,是回文串,并且长度大于2,那么如果将其首尾两个字母去除,它仍然是回文串。通过这种思路,可以使用动态规划的方法解决本题。使用P(i,j) 表示字符串s 的第i 到j 个字母组成的串(表示为s[i:j] )是否为回文串:
· P(i,j) = true,如果S i ... S j 是回文串
· P(i,j) = false,其他情况
对于其他情况,包含两种可能
· s[i,j] 本身不是一个回文串
· i > j,此时s[i,j] 本身不合法
那么可以写出动态规划的状态转移方程
· P(i,j) = P(i+1, j-1) ∧ (Si == Sj)
也就是说,只有s[i+1:j-1] 是回文串,并且s 的第i 和j 个字母相同时,s[i:j] 才会是回文串
而上文是建立在子串长度大于2 的前提,我们还需要考虑动态规划的边界条件,即子串的长度为1 或者2。对于长度为1 的子串,它显然是回文串。对于长度为2 的子串,只要两个字母相同,也是一个回文串。因为可以写出其边界条件:
· P(i, i) = true
` P(i, i+1) = (Si == S i+1)
根据这个思路,就可以完成动态规划了。最终答案即为所有P(i, j) 中j - i + 1(即子串长度)的最大值。
class Solution { public String longestPalindrome(String s) { int len = s.length(); if (len < 2) { return s; } int maxLen = 1; int begin = 0; // dp[i][j] 表示s[i...j] 是否为回文串 boolean[][] dp = new boolean[len][len]; for (int i = 0; i < len; i++) { dp[i][i] = true; } char[] charArray = s.toCharArray(); // 递推开始 // 枚举子串长度,长度从2 开始,长度为2、3、4、5... for (int L = 2; L <= len; L++) { // 枚举左边界,左边界上限设置可以宽松些 // 上面for 循环设置的是串的长度;下面的是左边界在哪里 for (int i = 0; i < len; i++) { // 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得 int j = L + i - 1; // 如果右边界超限制,则退出循环 if (j >= len) { break; } if (charArray[i] != charArray[j]) { dp[i][j] = false; } else if (charArray[i] == charArray[j]){ // 字符相等,且长度小于等于2 if (j - i <= 2) { dp[i][j] = true; } else { // 且字符相等,长度大于2 dp[i][j] = dp[i + 1][j - 1]; } } // 只要dp[i][j] == true 成立,就表示字符串s[i...j] 是回文子串,此时记录长度和起始位置 // 每次循环要记录一下“最长的” if (dp[i][j] && j - i + 1 > maxLen) { maxLen = j - i + 1; begin = i; } } } return s.substring(begin, begin + maxLen); } }
- 1143. 最长公共子序列 - M [longest common subsequence (LCS)]
dp[i+1][]j+1], dp = (max(dp[i-1][j], dp[i][j-1])) or dp[i-1][j-1]+1
是典型的二维动态规划的问题。
给定两个字符串text1
和text2
,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回0
。
(在文章最开始有)
- 91. 解码方法 dp[i] = dp[i-1] + dp[i-2] (有条件的), 1. s[i] != '0'; 10<=s[i-1:i+1]<=26
一条包含字母
A-Z
的消息通过以下映射进行了 编码 :'A' -> "1" 'B' -> "2" ... 'Z' -> "26" 要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,"11106" 可以映射为: "AAJF" ,将消息分组为 (1 1 10 6) "KJF" ,将消息分组为 (11 10 6) 注意,消息不能分组为 (1 11 06) ,因为 "06" 不能映射为 "F" ,这是由于 "6" 和 "06" 在映射中并不等价。 给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数
用dp 数组表示,dp[i] 表示到了第i 个字符的时候,共有多少种解法。
进行到第i 个字符的时候,判断使用一个字符还是两个字符进行解码
public static int numDecodings(String s) { int n = s.length(); int[] dp = new int[n + 1]; dp[0] = 1; for (int i = 1; i <= n; i++) { if (s.charAt(i - 1) != '0') { dp[i] = dp[i - 1]; } // 这里包含条件"当前数字为0" 的情况 if (i > 1 && s.charAt(i - 2) != '0' && calculateSum(s.charAt(i - 2), s.charAt(i - 1)) <= 26) { dp[i] += dp[i - 2]; } } return dp[n]; } public static Integer calculateSum(char a, char b) { return (a - '0') * 10 + (b - '0'); }
- 5. 最长回文子串(最长回文子数组)
- 其他高频题
- 28. 实现 strStr() - E
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1 。
public static int strStr(String haystack, String needle) { int n = haystack.length(); int m = needle.length(); for (int i = 0; i + m <= n; i ++) { boolean flag = true; for (int j = 0; j < m; j++) { if (haystack.charAt(i + j) != needle.charAt(j)) { flag = false; break; } } if (flag) { return i; } } return -1; }
- 14. 最长公共前缀 - E 先排序再比较first,last; for z in zip(*strs): if len(set(z)) == 1:res += z[0]
编写一个函数来查找字符串数组中的最长公共前缀。
如果不存在公共前缀,返回空字符串
""
。
LCP
编写一个函数来查找字符串数组中的最长公共前缀。
如果不存在公共前缀,返回空字符串 ""。
public String longestCommonPrefix(String[] strs) { if (strs == null || strs.length == 0) { return ""; } // 第一个字符串的长度 int length = strs[0].length(); // 字符串数组的个数 int count = strs.length; for (int i = 0; i < length; i++) { char c = strs[0].charAt(i); for (int j = 1; j < count; j++) { if (i == strs[j].length() || strs[j].charAt(i) != c) { return strs[0].subSequence(0, i).toString(); } } } return strs[0]; }
- 125. 验证回文串 - E s[start].isalnum()
如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样。则可以认为该短语是一个 回文串 。
字母和数字都属于字母数字字符。
给你一个字符串 s,如果它是 回文串 ,返回 true ;否则,返回 false 。
public static boolean isPalindrome(String s) { StringBuilder stringBuilder = new StringBuilder(); int length = s.length(); for (int i = 0; i < length; i++) { char c = s.charAt(i); if (Character.isLetterOrDigit(c)) { stringBuilder.append(Character.toLowerCase(c)); } } StringBuilder reversed = new StringBuilder(stringBuilder.toString()).reverse(); return reversed.toString().equals(stringBuilder.toString()); }
- 49. 字母异位词分组 - M di["".join(sorted(s))].append(s)
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的字母得到的一个新单词,所有源单词中的字母通常恰好只用一次。
public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap(); for (String string : strs) { char[] array = string.toCharArray(); Arrays.sort(array); String key = new String(array); List list = map.getOrDefault(map.get(key), new ArrayList()); list.add(array); map.put(key, list); } return new ArrayList<List<String>>(map.values()); }
- 28. 实现 strStr() - E
- 其他杂题
- 8. 字符串转换整数 (atoi) - M try: int(s[:idx]) except: break
- 227. 基本计算器 II stack存数字和+-*/,数字一添加结束就看能不能做*/,最后一起算+-
public int calculate(String s) { Deque<Integer> stack = new LinkedList<Integer>(); char preSign = '+'; int num = 0; int n = s.length(); for (int i = 0; i < n; i++) { if (Character.isDigit(s.charAt(i))) { num = num * 10 + s.charAt(i) - '0'; } if (!Character.isDigit(s.charAt(i)) && s.charAt(i) != ' ' || i == n - 1) { switch(preSign) { case '+': stack.push(num); break; case '-': stack.push(-num); break; case '*': stack.push(stack.pop() * num); break; case '/': stack.push(stack.pop() / num); break; } preSign = s.charAt(i); num = 0; } } int ans = 0; while(!stack.isEmpty()) { ans += stack.pop(); } return ans; }
- 8. 字符串转换整数 (atoi) - M try: int(s[:idx]) except: break
二叉树
- 二叉树的构造
- 297. 序列化与反序列化 - H se: return " ".join(res); de: nums = iter(data.split()), num = next(nums)
public String serialize(TreeNode root) { return rserialize(root, ""); } // 这是先根遍历,根、左、右。继续向下的时候,如果左边一直有,那么就是一直左左左 public String rserialize(TreeNode root, String str) { if (root == null) { str += "None,"; } else { str += String.valueOf(root.val) + ","; str = rserialize(root.left, str); str = rserialize(root.right, str); } return str; } public TreeNode deserialize(String data) { String[] dataArray = data.split(","); List<String> dataList = new LinkedList<String>(Arrays.asList(dataArray)); return rdeserialize(dataList); } // 所以在解析的时候,也是按照根、左、右的顺序处理 public TreeNode rdeserialize(List<String> dataList) { if (dataList.get(0).equals("None")) { dataList.remove(0); return null; } TreeNode root = new TreeNode(Integer.valueOf(dataList.get(0))); dataList.remove(0); root.left = rdeserialize(dataList); root.right = rdeserialize(dataList); return root; }
- 144. 二叉树前序遍历 根左右(先根遍历,根被先遍历,所以是根、左、右)
- 94. 二叉树中序遍历 左根右(中序遍历,根被中遍历,所以是左、根、右)
- 145. 二叉树后序遍历 左右根; 迭代(dfs+stack从上到下右到左):r, stack = [], [root] while stack:
(后续遍历,根后便利,所以是左、右、根
public List<Integer> postorderTraversal(TreeNode root) { List<Integer> result = new ArrayList<>(); postorder(root, result); return result; } public void postorder(TreeNode root, List<Integer> res) { if (root == null) { return; } postorder(root.left, res); postorder(root.right, res); res.add(root.val); }
- 102. 层序遍历 单队列,q = deque([(root, layer)]),q.popleft()
- 103. 锯齿形层次遍历 双队列, cur, nex = deque([root]), deque()
public List<List<Integer>> zigzagLevelOrder(TreeNode root) { List<List<Integer>> ans = new LinkedList<List<Integer>>(); if (root == null) { return ans; } Queue<TreeNode> nodeQueue = new ArrayDeque<TreeNode>(); nodeQueue.offer(root); boolean isOrderLeft = true; while (!nodeQueue.isEmpty()) { Deque<Integer> levelList = new LinkedList<Integer>(); int size = nodeQueue.size(); for (int i = 0; i < size; ++i) { TreeNode curNode = nodeQueue.poll(); if (isOrderLeft) { levelList.offerLast(curNode.val); } else { levelList.offerFirst(curNode.val); } if (curNode.left != null) { nodeQueue.offer(curNode.left); } if (curNode.right != null) { nodeQueue.offer(curNode.right); } } ans.add(new LinkedList<Integer>(levelList)); isOrderLeft = !isOrderLeft; } return ans; }
- 二叉树的对角线遍历 递归,helper(node, layer)
- 105. 前序与中序构造二叉树 递归,自调,idx = inorder.index(preorder[0])
给定两个整数数组 preorder 和 inorder ,其中 preorder 是二叉树的先序遍历, inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。
- 106. 中序与后序构造二叉树
- 297. 序列化与反序列化 - H se: return " ".join(res); de: nums = iter(data.split()), num = next(nums)
- 高频题目
- 101. 对称二叉树 helper: isMatch(left, right)
给你一个二叉树的根节点
root
, 检查它是否轴对称。
使用递归:public boolean isSymmetric(TreeNode root) { if (root == null) { return true; } return dfs(root.left, root.right); } public boolean dfs(TreeNode left, TreeNode right) { // 递归终止条件是两个节点都为空;或者其中只有一个为空;或者两个节点值不相等 if (left == null && right == null) { return true; } if (left == null || right == null) { return false; } if (left.val != right.val) { return false; } // 再递归比较左节点左孩子、右节点有孩子等等 return dfs(left.right, right.left) && dfs(left.left, right.right); }
使用迭代:
public boolean isSymmetric(TreeNode root) { if (root == null || (root.left == null && root.right == null)) { return true; } // 用队列保存节点 LinkedList<TreeNode> queue = new LinkedList<>(); // 根节点的左右孩子分别放入队列中 queue.add(root.left); queue.add(root.right); while(queue.size() > 0) { // 取出两个节点 TreeNode left = queue.removeFirst(); TreeNode right = queue.removeFirst(); // 判断 if (left == null && right == null) { continue; } if (left == null || right == null) { return false; } if (left.val != right.val) { return false; } queue.add(left.left); queue.add(right.right); queue.add(left.right); queue.add(right.left); } return true; }
- 116. 填充每个节点的下一个右侧节点指针 对于任意一次递归,只需要考虑如何设置子节点的 next 属性
递归:
迭代:public Node connect(Node root) { if (root == null) { return null; } if (root.left != null) { root.left.next = root.right; if (root.next != null) { root.right.next = root.next.left; } } connect(root.left); connect(root.right); return root; }
public Node connect(Node root) { if (root == null) { return root; } LinkedList<Node> queue = new LinkedList<>(); queue.add(root.left); queue.add(root.right); while (queue.size() != 0) { int length = queue.size(); Node tempNode = queue.poll(); if (tempNode == null) { return root; } queue.add(tempNode.left); queue.add(tempNode.right); for (int i = 0; i < length - 1; i++) { Node popedNode = queue.poll(); queue.add(popedNode.left); queue.add(popedNode.right); tempNode.next = popedNode; tempNode = popedNode; } } return root; }
- 117. 填充每个节点的下一个右侧节点指针 II 思路同上,在l&r的时候先设置好l,追加设置r or l,很复杂多看看
public Node connect(Node root) { if (root == null) { return root; } // cur 是当前层的没一层链表的指针 Node cur = root; // 外层while 循环是针对二叉树的高度的遍历 while (cur != null) { Node dummy = new Node(0); Node pre = dummy; while (cur != null) { // 遍历当前层,然后串联下一层 if (cur.left != null) { pre.next = cur.left; pre = pre.next; } if (cur.right != null) { pre.next = cur.right; pre = pre.next; } cur = cur.next; } // 上面的逻辑的作用,就是把下面cur 层下面那层的串联起来 cur = dummy.next; } return root; }
- 104. 二叉树的最大深度 return max(maxdepth(root.left), maxdepth(root.right))+1
可以递归,可以迭代。递归代码很简单:public int maxDepth(TreeNode root) { if (root == null) { return 0; } else { return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; } }
- 662. 二叉树最大宽度 self.left[], 每层碰到的第一个节点为left, dfs(node, layer, pos*2(+1))
此题求二叉树所有层的最大宽度,比较直观的方法是求出每一层的宽度,然后求出最大值。
注意:求每一层的宽度时,因为两端点间的null 节点也需要计入宽度,因此可以对节点进行编号。
一个编号为index 的左子节点的编号记为2 * index;右子节点编号为2 * index + 1
那么每层的最大宽度就是用每层节点的最大编号减去最小编号再加1。遍历节点时,可以用广度优先搜索来遍历每一层的节点,并求出最大值。
迭代:public int widthOfBinaryTree(TreeNode root) { int res = 1; // 存放当前层的节点。最开始的时候就只有root 层。 List<Pair<TreeNode, Integer>> arr = new ArrayList<>(); arr.add(new Pair<>(root, 1)); while (!arr.isEmpty()) { List<Pair<TreeNode, Integer>> tmp = new ArrayList<>(); for (Pair<TreeNode, Integer> pair : arr) { TreeNode node = pair.getKey(); int index = pair.getValue(); if (node.left != null) { tmp.add(new Pair<>(node.left, 2 * index)); } if (node.right != null) { tmp.add(new Pair<>(node.right, 2 * index + 1)); } } res = Math.max(res, arr.get(arr.size() - 1).getValue() - arr.get(0).getValue() + 1); arr = tmp; } return res; }
递归dfs:
遍历时如果是先访问左子节点,再访问右子节点,每一层最先访问到的节点会是最左边的节点,即每一层编号的最小值,需要记录下来进行后续的比较。一次深度优先搜索中,需要当前节点到当前行最左边节点的宽度,以及对子节点进行深度优先搜索,求出最大宽度,并返回最大宽度。
Map<Integer, Integer> levelMin = new HashMap<Integer, Integer>(); public int widthOfBinaryTree(TreeNode root) { return dfs(root, 1, 1); } public int dfs(TreeNode node, int depth, int index) { if (node == null) { return 0; } // 每一层最先访问到的节点会是最左边的节点,即每一层编号的最小值 levelMin.putIfAbsent(depth, index); return Math.max(index - levelMin.get(depth) + 1, Math.max(dfs(node.left, depth + 1, index * 2), dfs(node.right, depth + 1, index * 2 + 1))); }
- 543.二叉树的直径 helper: maxgain, self.res = max(left + right + 1, self.res)
// 树的直径可以分解成子问题,就是每个节点的左右子节点的深度的和+1 // 而不是经过每个节点的子树的“直径” int ans = 0; public int diameterOfBinaryTree(TreeNode root) { if (root == null) { return ans; } dfs(root); return ans - 1; } public int dfs(TreeNode node) { if (node == null) { return 0; } int left = dfs(node.left); int right = dfs(node.right); ans = Math.max(left + right + 1, ans); return Math.max(left, right) + 1; }
- 236. 二叉树的最近公共祖先 helper(root), if left + right + mid >=2: res, return left or right or mid
Map<Integer, TreeNode> parent = new HashMap<Integer, TreeNode>(); Set<Integer> visited = new HashSet<Integer>(); public void dfs(TreeNode root) { if (root.left != null) { parent.put(root.left.val, root); dfs(root.left); } if (root.right != null) { parent.put(root.right.val, root); dfs(root.right); } } public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { dfs(root); while (p != null) { visited.add(p.val); p = parent.get(p.val); } while (q != null) { if (visited.contains(q.val)) { return q; } q = parent.get(q.val); } return null; }
- 112. 路径总和
给你二叉树的根节点 root 和一个表示目标和的整数 targetSum 。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。如果存在,返回 true ;否则,返回 false 。
叶子节点 是指没有子节点的节点。
广度优先搜索:
这道题类似层序遍历,可以使用广度优先搜索算法。但是不需要像层序遍历那样,计算queue 中的节点的个数。
递归:public boolean hasPathSum(TreeNode root, int targetSum) { if (root == null) { return false; } Queue<TreeNode> nodeQueue = new LinkedList<>(); Queue<Integer> valueQueue = new LinkedList<>(); nodeQueue.offer(root); valueQueue.offer(root.val); while (!nodeQueue.isEmpty()) { // 这个不像层序遍历,这个不需要感知queue 的长度 TreeNode tempNode = nodeQueue.poll(); Integer tempVal = valueQueue.poll(); // 如果当前节点是叶子节点,那么就进行判断 if (tempNode.left == null && tempNode.right == null) { if (tempVal == targetSum) { return true; } continue; } // 子节点进队列 if (tempNode.right != null) { nodeQueue.offer(tempNode.right); valueQueue.offer(tempNode.right.val + tempVal); } if (tempNode.left != null) { nodeQueue.offer(tempNode.left); valueQueue.offer(tempNode.left.val + tempVal); } } return false; }
public boolean hasPathSum(TreeNode root, int targetSum) { if (root == null) { return false; } if (root.left == null && root.right == null) { return targetSum == root.val; } return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val); }
- 113. 路径总和 II
- 437.路径总和 III
- 124. 最大路径和 - H helper: maxgain, self.res = max(left + right + root.val, self.res)
- 101. 对称二叉树 helper: isMatch(left, right)
- 二叉搜索树
- 98. 验证二叉搜索树 helper(root, low = float("-inf"), high = float("inf"))
二叉搜索树的性质,中序遍历得到一个正序数组 - 426. BST转排序的双向链表 中序, 处理当前节点,last.right = cur, cur.left = last
- 450. 删除BST中的节点 - M 找到后三种情况, 无子节点/一个子节点/有两子结点(max,remove_max)
- 删除区间内的节点
- 98. 验证二叉搜索树 helper(root, low = float("-inf"), high = float("inf"))
堆
使用大顶堆做正序排序;使用小顶堆做逆序排序。
构建堆,就是从最后一个非叶子节点开始,如果构建大顶堆,就是将每个parent 与child 进行对比,如果child 大于parent,那么进行调整。同时调整到child 位置的原parent 可能还是小于其child,所以要继续调整。
堆在算法题目中的应用主要包括以下几点:
- TopK 问题 (尤其是大数据处理)
- 优先队列
- 利用堆求中位数
// 以下代码都认为父节点序号为0
/**
* 调整为大顶堆(此方法只是一个调整的过程,并不是建堆的)
* @param arr 待调整的数组
* @param parent 当前父节点的下标
* @param length 需要对多少个元素进行调整
*/
private static void adjustHeap(int[] arr, int parent, int length){
//临时保存父节点
int temp = arr[parent];
//左子节点的下标
int child = 2 * parent + 1;
//如果子节点的下标大于等于当前需要比较的元素个数,则结束循环
while(child < length){
//判断左子节点和右子节点的大小,若右边大,则把child定位到右边
if(child + 1 < length && arr[child] < arr[child + 1]){
child ++;
}
//若child大于父节点,则交换位置,否则退出循环
if(arr[child] > temp){
//父子节点交换位置
arr[parent] = arr[child];
//因为交换位置之后,不能保证当前的子节点是它子树的最大值,所以需要继续向下比较,
//把当前子节点设置为下次循环的父节点,同时,找到它的左子节点,继续下次循环
parent = child;
child = 2 * parent + 1;
}else{
//如果当前子节点小于等于父节点,则说明此时的父节点已经是最大值了,
//因此无需继续循环
break;
}
}
//把当前节点值替换为最开始暂存的父节点值
arr[parent] = temp;
}
public static void main(String[] args) {
int[] arr = {4,1,9,3,7,8,5,6,2};
// 构建一个大顶堆,从最下面的非叶子节点开始向上遍历
// 这才是建堆的过程
for (int i = arr.length/2 - 1 ; i >= 0; i--) {
adjustHeap(arr,i,arr.length);
}
System.out.println(Arrays.toString(arr));
}
//堆排序,大顶堆,升序
private static void heapSort(int[] arr){
//构建一个大顶堆,从最下面的非叶子节点开始向上遍历
for (int i = arr.length/2 - 1 ; i >= 0; i--) {
adjustHeap(arr,i,arr.length);
}
System.out.println(Arrays.toString(arr));
//循环执行以下操作:1.交换堆顶元素和末尾元素 2.重新调整为大顶堆
for (int i = arr.length - 1; i > 0; i--) {
//将堆顶最大的元素与末尾元素互换,则数组中最后的元素变为最大值
int temp = arr[i];
arr[i] = arr[0];
arr[0] = temp;
//从堆顶开始重新调整结构,使之成为大顶堆
// i代表当前数组需要调整的元素个数,是逐渐递减的
adjustHeap(arr,0,i);
}
}
- 347. 前 K 个高频元素 heapq.nlargest(k, c.keys(), key = c.get);长度为k的堆
首先使用map 来存储每个元素对应的出现次数;然后再使用堆来维护大小关系
使用堆来实现
生成map 的时间复杂度是O(n);public int[] topKFrequent(int[] nums, int k) { Map<Integer, Integer> occurrences = new HashMap<Integer, Integer>(); for (int num : nums) { occurrences.put(num, occurrences.getOrDefault(num, 0) + 1); } // int[] 的第一个元素代表数组的值,第二个元素代表了该值出现的次数 PriorityQueue<int[]> queue = new PriorityQueue<int[]>(new Comparator<int[]>() { public int compare(int[] m, int[] n) { return m[1] - n[1]; } }); for (Map.Entry<Integer, Integer> entry : occurrences.entrySet()) { int num = entry.getKey(), count = entry.getValue(); if (queue.size() == k) { if (queue.peek()[1] < count) { queue.poll(); queue.offer(new int[]{num, count}); } } else { queue.offer(new int[]{num, count}); } } int[] ret = new int[k]; for (int i = 0; i < k; ++i) { ret[i] = queue.poll()[0]; } return ret; }
构建堆(k 个元素)的时间复杂度是log(K);
因为是对n 个元素遍历进行建堆&重建堆,所以整体时间复杂度nlog(k)
- 215. 数组中的第K个最大元素 堆;二分搜索牛逼
(1)使用堆,构建大顶堆;
使用堆的时候,一个是调整的方法
public void adjust(int[] nums, int parent, int length){}
另外一个是建堆的方法
public void heapify(int[] nums){}; 这个方法里面要从第一个非叶子节点开始从下往上整
然后是堆排序的方法,找第k 大的元素,只需要执行k 次即可。每次把最大的堆顶元素和最后一个元素替换,循环执行k 次。则数组中倒数第k 个元素就是解。
(2)使用快排的思想,以一个key,小于的放左边,大于的放右边
- 378. 有序矩阵中第K小的元素 堆,klogk: 一次添加右和下,pop出其中较小的,每次pop k-1,pop k次返回
利用矩阵的性质,使用二分法public int kthSmallest(int[][] matrix, int k) { // 一共n 行 int n = matrix.length; int left = matrix[0][0]; int right = matrix[n - 1][n - 1]; while (left < right) { int mid = (left + right) / 2; if (check(matrix, mid, k, n)) { right = mid; } else { left = mid + 1; } } return left; } public boolean check(int[][] matrix, int mid, int k ,int n) { int i = n - 1; int j = 0; int num = 0; while (i >=0 && j < n) { if (matrix[i][j] <= mid) { num += i + 1; j ++; } else { i --; } } return num >= k; }
- 218. 天际线问题(hard)
动态规划-
动态规划问题的一般形式就是“求最值”,比如求“最长递增子序列”,“最小编辑距离”。而求解最值的问题的核心就是“穷举”。这样最直观。
而动态规划的问题存在几个特点:
-
存在“重叠子问题”,如果暴力穷举,效率低下,所以需要“备忘录”或者“dp table”来优化穷举过程,避免不必要的计算。
-
存在“最优子结构”,这样才能通过子问题的最值得到原问题的最值。
对最优子结构的理解,就是到了某一步的选择,可以得到一个最优的结果。所以说到了这个子选择的时候,结构是最优的。给它个名称,叫:最优子结构。 -
找出“状态转移方程”。动态规划的核心思想就是“穷举”,而动态规划问题中,穷举是通过状态转移方程来完成的。
解题步骤:
明确“状态” -> 定义dp 数组/函数的含义 -> 明确“选择” -> 明确base case
dp 数组中存的就是各个状态,通过选择各个状态,选择待选项,得到转移方程,结合base case,得到最终解。
举例:
322. 凑零钱问题
1. 凑零钱 各个子问题间是相互独立的,互不干扰。
对于“凑零钱问题”,比如说求amount = 11 时的最少硬币数,如果凑出了amount = 10 的最少硬币数(子问题),只需要把子问题的答案加一就是原问题的答案。因为硬币的数量是不限制的,子问题之间没有相互制约,是互相独立的。
2. 列出状态转移方程。先确定“状态”,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额amount。
3. 确定dp 数组的定义:当前目标金额是n,至少需要dp(n) 个硬币凑出该金额(意思就是凑出金额n 最少需要的金币数是dp[n])。
4. 然后确定“选择”并优化,也就是对于每个状态,可以做出什么选择改变当前状态。具体到此问题,无论目标金额是多少,选择就是从面额列表coins 中选择一个硬币,然后目标金额就会减少。
5. 最后明确base case。
(以上参考labuladong)
题目要求“最少”,即求最优解;要凑出总数为n 的最少枚硬币,和凑出n - coin 枚硬币是一样的问题,即存在子问题,是有重复子问题的;根据example 分析,f(11) = min(f(10), f(9), f(6)) + 1。
(参考:凑零钱问题 - 知乎)
上述寥寥几行,就把一个动态规划问题的基本点都描述出来了.
- 1、首先是有重复子问题;
- 2、其次需要最优规划,即是子问题中的最优解再去找上一级。
- 3、列出状态转移方程,其中最重要的就是确定谁是变化的量,这里就是总价
amount
。
public static int coinChange(int[] coins, int amount) {
// dp 数组代表凑出金额i 的时候,最少需要走多少步
int[] dp = new int[amount+1];
// 初始化dp 数组,以方便后续的比较
Arrays.fill(dp, amount + 1);
// 初始化条件
dp[0] = 0;
// 循环amount 次,确认累积到金额i 需要多少次
for (int i = 1; i <= amount; i ++) {
// 按照每个硬币的面额做判断
for (int coin : coins) {
// 当i < coin 的时候,证明没有解,不用做什么变化
if (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 判断当前是否有结果,就和初始化的数字做呼应
return dp[amount] == amount + 1 ? -1 : dp[amount];
}
对于“最优子结构”,很多问题可能都具有“最优子结构”,但是不存在“重叠子问题”,所以不能被归为“动态规划系列”。如果想满足“最优子结构”,子问题间必须相互独立。
基础篇
对于动态规划,拿300 题和53 题举例子。两道题都是使用一维数组的,但是有点不一样,现在我总结不出来,理解一下,理解并总结不出来的话,那就记住好了。注意特点,300 是子序列,53 是子数组。
- 300. 最长上升子序列(文章初始位置有)
原创(@王漂亮)提示解法:dp[n], if nums[i] > nums[j]: dp[i] = max(dp[i], dp[j]+1)
使用一维数组dp[n],定义dp[i] 为考虑前i 个元素,以第i 个数字结尾的最长上升子序列的长度。其中nums[i] 必须被选取。
在计算dp[i] 之前,已经计算出了dp[0 ... i-1] 的值,则状态转移方程为:
dp[i] = max( dp[j] ) + 1, 其中 0 <= j < i 且nums[i] > nums[j]
时间复杂度o(n方),思想就是两个for 循环,对于每一个元素,比较前面所有元素和当前nums[i] 的大小,如果小,那么就+1
按照上述解题步骤:
1)明确状态:就是题目要求,最长递增子序列的最长长度。
2)dp 数组定义:数组存的就是各种状态,定义一维数组,dp[i] 表示以第i 个元素结尾最长上升子序列的长度。
3)明确选择:第i 个数字选或者不选,然后写出状态转移方程。
4)basecase:第0 个数字,第1 个数字。
public int lengthOfLIS(int[] nums) { if (nums.length == 0) { return 0; } // 定义dp 数组,dp[i] 表示以第i 个数字结尾,最长上升子序列的长度。 int[] dp = new int[nums.length]; dp[0] = 1; int maxAns = 1; // 我们从小到大计算 \textit{dp}dp 数组的值,在计算dp[i] 之前,我们已经计算出dp[0…i−1] 的值,则状态转移方程为: // dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i] for (int i = 0; i < nums.length; i++) { dp[i] = 1; for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } maxAns = Math.max(maxAns, dp[i]); } return maxAns; }
如果不能明确出dp 数组的含义,那么可能是dp 数组存储到信息不够,不足以存储下一步,需要把dp 数组扩大成二维或者三维数组。
- 53. 最大子序和 dp[n], dp[i] = max(nums[i], dp[i-1] + nums[i])
给定一个整数数组nums
,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
使用一维数组f[n] 来进行动态规划设计,f[i] 代表以第i 个数字结尾的“连续子数组最大和”,那么答案就是max(f[i])
因此就是求出每个未知的f(i),然后返回f 数组中的最大值即可
那么就是要考虑num[i] 单独成为一段,还是加入前面那一段,这取决于num[i] 和f(i-1)+num[i] 的大小。于是状态转移方程为:
f(i) = max{f(i-1) + nums[i], nums[i]}
按照上述四段式:状态确定;dp 数组确定(存放各个状态,用什么样的dp table);判断如何选择(状态转移方程);base case
// 两种解法,一看就懂 // 第一种 public int maxSubArray(int[] nums) { if (nums.length == 0) { return 0; } int[] dp = new int[nums.length]; dp[0] = nums[0]; int ans = nums[0]; for (int i = 1; i < nums.length; i++) { dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]); ans = Math.max(dp[i], ans); } return ans; } // 第二种 public int maxSubArray(int[] nums) { int pre = 0, maxAns = nums[0]; for (int x : nums) { pre = Math.max(pre + x, x); maxAns = Math.max(maxAns, pre); } return maxAns; }
- 152. 最大乘积子序列 dp[n][2], return max(dp, key=lambda x:x[1])[1]
这道题是求子序列。类比53,可以知道,53 的解法对于此题不适用。因为当前位置的最优解未必是由前一个位置的最优解得到的。
可以根据正负性进行分类讨论。
考虑当前一位置如,如果是一个负数,那么希望它前一个位置结尾的某个段的积也是负数,这样就可以负负得正,并且希望这个积尽可能“负得更多”;如果当前一个数字是正数,那么就希望它前一个位置结尾的某个段的积也是正数,并且希望它尽可能的大。
所以除了f max(i),我们需要再维护一个f min(i),状态转移方程为:
f max(i) = max{f max(i - 1) * ai, f min(i - 1) * ai, ai}
f min(i) = min{f min(i - 1) * ai, f max(i - 1) * ai, ai}
而此题的状态转移方程,第i 个状态只和第i - 1 个状态有关,类比53 题,所以根据“滚动数组”的思想,可以用两个变量来维护i - 1 的状态,就不用两个数组了。
public int maxProduct(int[] nums) { int length = nums.length; int[] maxF = new int[length]; int[] minF = new int[length]; maxF[0] = nums[0]; minF[0] = nums[0]; int ans = maxF[0]; for (int i = 1; i < length; i++) { maxF[i] = Math.max(maxF[i - 1] * nums[i], Math.max(nums[i], minF[i - 1] * nums[i])); minF[i] = Math.min(maxF[i - 1] * nums[i], Math.min(nums[i], minF[i - 1] * nums[i])); ans = Math.max(ans, maxF[i]); } return ans; }
- 279. 完全平方数 dp[n+1],
动态规划的技术建立在重用中间解的结果来计算最终解的思想之上。几乎所有动态规划,首先都会创建一个一维或多维数组DP 来保存中间子解的值,通常数组最后一个值代表最终解。此题是给定正整数n,找出最小的可以加和为此数的完全平方数的个数。
可以依据题目要求写出状态表达式:f[i] 表示最少需要多少个数的平方来表示整数i。
而这些数字肯定落在区间[1, 根号n] 区间。可以枚举找出这些数字,假设当前枚举到j,那么我们还需要若干数的平方,构成i - (j平方)。此时就发现了该子问题和原问题类似,只是规模变小了。
其中f[0] = 0 为边界条件,实际上我们无法表示数字0,只是为了保证状态转移过程中遇到j 恰为(根号j)的情况合法。
同时因为计算f[i] 时所需要用到的状态只有f[i - (j平方)],必然小于i,因此我们只需要从小到大地枚举i 来计算f[i] 即可。
------------------------------------标签:动态规划
首先初始化长度为 n+1 的数组 dp,每个位置都为 0
如果 n 为 0,则结果为 0
对数组进行遍历,下标为 i,每次都将当前数字先更新为最大的结果,即 dp[i]=i,比如 i=4,最坏结果为 4=1+1+1+1 即为 4 个数字
动态转移方程为:dp[i] = MIN(dp[i], dp[i - j * j] + 1),i 表示当前数字,j*j 表示平方数class Solution { public int numSquares(int n) { int[] dp = new int[n + 1]; // 默认初始化值都为0 for (int i = 1; i <= n; i++) { dp[i] = i; // 最坏的情况就是每次+1 for (int j = 1; j * j <= i; j++) { dp[i] = Math.min(dp[i], dp[i - j * j] + 1); // 动态转移方程 } } return dp[n]; } }
- 322. 找零钱问题
public static int coinChange(int[] coins, int amount) { int[] dp = new int[amount + 1]; // 初始化数组,每个元素为amount + 1 的原因是为了避免混淆,初始化成别的也行,在最后做判断的时候确认一下就好 for (int i = 1; i <= amount; i++) { dp[i] = amount + 1; } // 第一层for 循环是对每个子结构做遍历查找,即amount 为1,为2,为3... 的时候 for (int i = 1; i <= amount; i++) { // 对每个选择做判断 for (int c : coins) { if (i - c < 0) { continue; } // 找出最小的 dp[i] = Math.min(dp[i], dp[i - c] + 1); } } // 判断当前是否有结果,就和初始化的数字做呼应 return dp[amount] == amount + 1 ? -1 : dp[amount]; }
- 其他
- 70. 爬楼梯 dp[n+1], dp[0], dp[1], dp[2] = 0, 1, 2 ;dp[i] = dp[i-2] + dp[i-1]
public static int climbStairs(int n) { if (n == 1) { return 1; } if (n == 2) { return 2; } int pre = 1; int cur = 2; int result = 3; for (int i = 3; i <= n; i++) { result = pre + cur; pre = cur; cur = result; } return result; }
- 198. 打家劫舍 dp[n], dp[i] = max(dp[i-1], dp[i-2]+nums[i])
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
一维数组进行动态规划,dp[i] 代表到了第i 个屋子能偷窃到的最高金额。
public int rob(int[] nums) { if (nums == null || nums.length == 0) { return 0; } int length = nums.length; if (nums.length == 1) { return nums[0]; } int dp[] = new int[length]; dp[0] = nums[0]; dp[1] = Math.max(nums[0], nums[1]); for (int i = 2; i < length; i++) { dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]); } return dp[length -1 ]; }
- 62. 不同路径。
题目为一个m x n 的网格,即m 行n 列,左上角有个机器人,每次只能往右或者往下移动一个格,请问移动到右下角一共有多少条路径。
使用动态规划,用f(i, j) 表示从左上角走到(i, j) 的路径数量,其中i 和j 的范围分别是[0, m) 和[0, n)。使用二维数组进行动态规划。
状态转移方程为:
f(i, j) = f(i - 1, j) + f(i, j - 1)
边界条件,第一行和第一列都是1。
public int uniquePaths(int m, int n) { int[][] f = new int[m][n]; for (int i = 0; i < m; i++) { f[i][0] = 1; } for (int j = 0; j < n; j++) { f[0][j] = 1; } for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { f[i][j] = f[i - 1][j] + f[i][j - 1]; } } return f[m - 1][n - 1]; }
- 编辑距离(文章开始处有)
给定两个字符串s1 和s2,计算出将s1 转换成s2 所使用的最少操作数。可以对一个字符串进行如下操作:插入、删除、替换。public static int minDistance(String s1, String s2) { int m = s1.length(); int n = s2.length(); int[][] dp = new int[m + 1][n + 1]; // base case for (int i = 1; i <= m; i++) { dp[i][0] = i; } for (int j = 1; j < n; j++) { dp[0][j] = j; } for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (s1.charAt(i) == s2.charAt(j)) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min(dp[i - 1][j] + 1, Math.min(dp[i][j - 1] + 1, dp[i - 1][j - 1])); } } } return dp[m][n]; }
- 887.高楼扔鸡蛋
框架:这个问题有什么“状态”,有什么“选择”,然后穷举。
1. “状态”就是当前拥有的鸡蛋数k 和需要测试的楼层数N。随着测试的进行,鸡蛋个数减少,楼层搜索范围减少,对应的就是状态的变化。
2. “选择”其实就是选择哪层扔鸡蛋。
3. 明确了“状态”和“选择”,就基本形成了动态规划的基本思路。肯定是个二维的dp 数组或者带有两个参数的dp 函数来表示状态转移;外加一个for 循环来遍历所有的选择,选择最优的选择更新状态。
我们在选择第i 层扔鸡蛋之后,就可能出现两种情况:鸡蛋碎了,鸡蛋没碎。这时候,状态转移方程就来了:
1. 鸡蛋碎了,那么鸡蛋个数K 就减一,搜索的楼层区间应该从[1, N] 变为[1, i - 1]。
2. 鸡蛋没碎,那么鸡蛋个数K 不变,搜索的楼层区间应该从[1, N] 变为[i + 1, N]。
- 正方形问题
1277. 统计全为 1 的正方形子矩阵的个数
我们用f[i][j]
表示以(i, j)
为右下角的正方形的最大边长,同时这个最大边长也可以表示以(i, j) 为右下角的正方形的个数。
看看1277 的题解就很清晰了。
public int countSquares(int[][] matrix) { int row = matrix.length; int column = matrix[0].length; int[][] dp = new int[row][column]; int ans = 0; for (int i = 0; i < row; i++) { for (int j = 0; j < column; j++) { if (i == 0 || j == 0) { dp[i][j] = matrix[i][j]; } else if (matrix[i][j] == 0) { dp[i][j] = 0; } else { dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1; } ans += dp[i][j]; } } return ans; }
221. 最大正方形
在一个由'0'
和'1'
组成的二维矩阵内,找到只包含'1'
的最大正方形,并返回其面积
使用动态规划,我们用dp[i][j] 表示以(i, j) 为右下角,且只包含1 的正方形的边长的最大值。
public int maximalSquare(char[][] matrix) { int row = matrix.length; int column = matrix[0].length; int[][] dp = new int[row][column]; int ans = 0; for (int i = 0; i < row; i++) { for (int j = 0; j < column; j++) { if (i == 0 || j == 0) { if (matrix[i][j] == '1') { dp[i][j] = 1; } } else if (matrix[i][j] == '0') { dp[i][j] = 0; } else { dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1; } ans = Math.max(ans, dp[i][j]); } } return ans; }
- 70. 爬楼梯 dp[n+1], dp[0], dp[1], dp[2] = 0, 1, 2 ;dp[i] = dp[i-2] + dp[i-1]
背包问题
dp对容量的init都是dp[v+1], 从空开始init 如果要减少空间的话,把dp[i]省掉,dp[v+1]的循环逆序
- 01背包问题
01 背包问题看这个,一看就懂:背包问题 - 知乎。只看文字,别看代码。
这个也很不错:动态规划之背包问题系列 - 知乎
class Solution { /** * @param v v[i] 代表第i 个物品的体积 * @param w w[i] 代表第i 个物品的价值 * @param V 背包的总体积 * @return */ public int findMax(int[] v, int[] w, int V) { // 物品个数 int n = v.length; // 构造二维dp 数组。这个二维数组整体多了一个0 行和0 列,所以不用初始化。 // 否则就要初始化第一行,小于第i 个物品体积的遍历体积都是0,大于等于第i 个物品体积的遍历体积的位置数字都是第i 个物品的价值。i = 0 // n 个物品,所以有n 行;因为背包总体积是V,所以遍历V,从0 开始 // 数组初始化,各个元素都是0 int[][] f = new int[n + 1][V + 1]; for (int i = 1; i <= n; i++) { for (int j = 1; j <= V; j++) { // 如果第i 个物品的体积大于当前遍历到的最大的体积,那么当前物品肯定不能选择 if (v[i - 1] > j) { f[i][j] = f[i -1][j]; } else { // 转移方程计算f(i,j) 的大小 // 分为选择还是不选择 f[i][j] = Math.max(f[i][j - v[i - 1]] + w[i-1], f[i - 1][j]); } } } return f[n][V]; } public int findMax2(int[] v, int[] w, int V) { // 可以优化控件,但是不能优化时间,时间还是O(n方) // 由状态转移方程可以看出,第i 行结果只和第i - 1 行结果有关。而为了防止数据被覆盖,所以j 要从后往前遍历 int n = v.length; int[] dpTable = new int[V + 1]; // 需要初始化 for (int k = w[0]; k <= V; k++) { dpTable[k] = w[0]; } for (int i = 1; i <= n; i++) { for (int j = V; j >=1; j--) { if (v[i - 1] > j) { // 如果第i 个物品的体积大于遍历体积数字,那么不变(还是上一行的结果) } else { // 否则做状态转移,判断选不选第i 个物品 dpTable[j] = Math.max(dpTable[j - v[i - 1]] + w[i - 1], dpTable[j]); } } } return dpTable[V]; } public static void main(String[] args) { Solution solution = new Solution(); int[] v = {5,3,4,2}; int[] w = {60,50,70,30}; System.out.println(solution.findMax2(v, w, 5)); } }
- 416. 分割等和子集
对于背包问题,要判断当前有几个“状态”。比如说,容量为w 的背包,n 个物品,就两个状态,用二维数组表示。如:dp[i][j]
这道题定义dp[i][j] 为:前i 个元素是否有可以满足和为j 的子数组public boolean canPartition(int[] nums) { int sum = 0; int length = nums.length; if (length < 2) { return false; } for (int num : nums) { sum += num; } if (sum % 2 != 0) { return false; } int target = sum / 2; boolean[][] dp = new boolean[length + 1][target + 1]; dp[0][0] = true; for (int i = 1; i <= length; i++) { for (int j = 1; j <= target; j++) { if (nums[i - 1] > j) { // 不能选择 dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]]; } } } return dp[length][target]; }
- 494. 目标和 di={0:1}, nex_di, di.get(s, 0)
回溯:// 回溯解法,其中选择列表就是加法或者减法,nums 的遍历在backtrack 中使用参数“i” 实现 int result = 0; public int findTargetSumWays(int[] nums, int S) { if (nums.length == 0) { return 0; } backtrack(nums, 0, 0, S); return result; } void backtrack(int[] nums, int i, int additionResult, int target) { // 判断条件等于length 而不是等于length - 1 的原因是,i == nums.length 的时候,还是需要继续进行循环的 if (i == nums.length) { if (additionResult == target) { result ++; } return; } additionResult += nums[i]; backtrack(nums, i + 1, additionResult, target); additionResult -= nums[i]; additionResult -= nums[i]; backtrack(nums, i + 1, additionResult, target); additionResult += nums[i]; }
- 416. 分割等和子集
- 完全背包问题
也看上面的链接就好(同下面的链接)。
对于完全背包,先看这个链接:动态规划之背包问题系列 - 知乎
再看这个链接:https://zhuanlan.zhihu.com/p/346625269对于完全背包的状态转移方程的理解:
对于下面的公式,如果确定放,背包中应该出现至少一件第i种物品,所以F[i][j]种至少应该出现一件第i种物品,即F[i][j]=F[i][j-C[i]]+W[i]。为什么会是F[i][j-C[i]]+W[i]?因为F[i][j-C[i]]里面可能有第i种物品,也可能没有第i种物品。我们要确保F[i][j]至少有一件第i件物品,所以要预留C[i]的空间来存放一件第i种物品。
class Solution { /** * @param v 每个物品的体积 * @param w 每个物品的价值(重量) * @param V 背包的容积 * @return 最大价值(重量) */ public int findCompleteBackpackResult(int[] v, int[] w, int V) { // n 个物品 int n = v.length; int dp[][] = new int[n + 1][V + 1]; for (int i = 1; i <= n; i++) { for (int j = 1; j <= V; j++) { if (v[i - 1] > j) { dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - v[i - 1]] + w[i - 1]); } } } return dp[n][V]; } public int findCompleteBackpackResult2(int[] v, int[] w, int V) { // n 个物品 int n = v.length; int[] dp = new int[V + 1]; // 初始化dp 数组 for (int k = v[0]; k <= V; k ++) { // k 是当前遍历到的容量,v[0] 是第一个物品的体积,w[0] 是第一个物品的价值(重量) dp[k] = k / v[0] * w[0]; } for (int i = 1; i <= n; i++) { for (int j = 1; j <= V; j++) { if (v[i - 1] > j) { // 如果第i 个物品的体积大于当前遍历到的容积,则第i 个物品无法选择 } else { dp[j] = Math.max(dp[j], dp[j - v[i - 1]] + w[i - 1]); } } } return dp[V]; } public static void main(String[] args) { Solution solution = new Solution(); int[] v = {3,2,5,1,6,4}; int[] w = {6,5,10,2,16,8}; System.out.println(solution.findCompleteBackpackResult(v, w, 10)); System.out.println(solution.findCompleteBackpackResult2(v, w, 10)); } }
- 322. 零钱兑换 if i in coins: dp[i] = 1
- 377. 组合总和 IV if i-c>=0: dp[i] += dp[i-c]
- 二维费用的背包问题
- 474. 一和零 dp[i][j] = max(dp[i][j], dp[i-c["0"]][j-c["1"]]+1)
进阶算法
回溯算法
回溯算法,就是一个决策树遍历的过程。
参考:回溯算法套路详解 - 知乎
决策树解释:比如说解决一个问题,一共有三个步骤,每个步骤可以选择“是”或者“否”。就是一个三层的二叉树(不考虑根节点)。决策选择哪个,形成的一棵树,就叫决策树。
解决这个问题,只需要考虑:
1)路径,即已经做出的选择。2)选择列表,就是当前可以做的选择。3)结束条件,就是已经到达决策树底层,无法再做选择的条件。
框架:
result = Lists.newArrayList();
void backtrack(路径, 选择列表) {
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
}
其核心就是 for 循环里面的递归,在递归调用之前“做选择”,在递归调用之后“撤销选择”
- 46. 全排列 used = [False]*len(nums)
参考这个链接:回溯算法套路详解 - 知乎,回溯算法,遍历决策树,很简单。不要看力扣上的官方讲解。
class Solution { List<List<Integer>> result = new ArrayList<>(); List<List<Integer>> premute(int[] nums) { LinkedList<Integer> track = new LinkedList<>(); backTrack(nums, track); return result; } void backTrack(int[] nums, LinkedList<Integer> track) { // 出发结束条件 if (track.size() == nums.length) { result.add(new ArrayList<>(track)); return; } for (int i = 0; i < nums.length; i ++ ) { if (track.contains(nums[i])) { continue; } track.add(nums[i]); backTrack(nums, track); track.removeLast(); } } }
- 78. 子集 for j in range(i, len(nums)): backtrack(track+[nums[j]], j+1)
参考:https://zhuanlan.zhihu.com/p/109523146 方法1:对于数组中每个元素,选择或者是不选择: List<List<Integer>> result = new ArrayList<>(); public List<List<Integer>> subsets(int[] nums) { int len = nums.length; if (len == 0) { return result; } Deque<Integer> deque = new LinkedList<>(); dfs(nums, len, 0, deque); return result; } public void dfs(int[] nums, int len, int index, Deque<Integer> path) { if (index == len) { result.add(new ArrayList<>(path)); return; } path.addLast(nums[index]); dfs(nums, len, index + 1, path); path.removeLast(); dfs(nums, len, index + 1, path); } 方法2:回溯法
参考:回溯算法团灭子集、排列、组合问题 - labuladong - 博客园
- 22. 括号生成 for p in ["(", ")"]: if counter[p] < n:
public List<String> generateParenthesis(int n) { List<String> resultList = new ArrayList<>(); backtrack(resultList, new StringBuilder(), 0, 0, n); return resultList; } public void backtrack(List<String> ans, StringBuilder cur, int left, int right, int max) { if (cur.length() == max * 2) { ans.add(cur.toString()); return; } if (left < max) { cur.append('('); backtrack(ans, cur, left + 1, right, max); cur.deleteCharAt(cur.length() - 1); } if (right < left) { cur.append(')'); backtrack(ans, cur, left, right + 1, max); cur.deleteCharAt(cur.length() - 1); } }
- 131. 分割回文串 for i in range(1, len(s)+1): if s[:i] == s[:i][::-1]: backtrack()
图:拓扑排序和Union Find
拓扑排序详解 通俗易懂 - 知乎 拓扑排序
- Union Find if self.parent[idx] != idx: self.parent[idx] = self.find(self.parent[idx])
- 200. 岛屿数量 uf = UnionFind(row*col+1) dummy_node = row*col
可以用深度优先搜索、广度优先搜索
这道题的本质,无论是深度优先搜索还是广度优先搜索,都是在搜索的过程中,把原本的陆地标记为“已访问过”。
深度优先搜索:
广度优先搜索:public int numIslands(char[][] grid) { int count = 0; int height = grid.length; int width = grid[0].length; for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { if (grid[i][j] == '1') { // 遍历所有连接的陆地,将其置位"已访问" dfs(grid, i, j, width, height); count ++; } } } return count; } // 函数的目的是做dfs,把周边的是1 的那些都给置位"已遍历过"状态 public void dfs(char[][] grid, int i, int j, int width, int height) { // dfs 的时候首先要记得basecase if (i < 0 || j < 0 || i >= height || j >= width || grid[i][j] == '0' || grid[i][j] == '2') { return; } grid[i][j] = '2'; dfs(grid, i + 1, j, width, height); dfs(grid, i -1, j, width, height); dfs(grid, i, j + 1, width, height); dfs(grid, i, j -1, width, height); }
public int numIslands(char[][] grid) { int count = 0; int height = grid.length; int width = grid[0].length; for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { if (grid[i][j] == '1') { count ++; // 然后进行广度优先搜索,对"已访问过的"陆地进行标记 Queue<Pair<Integer, Integer>> queue = new LinkedList<>(); queue.add(new Pair<>(i, j)); // grid[i][j] = '2'; while (queue.size() != 0) { Pair<Integer, Integer> land = queue.poll(); int r = land.getKey(); int c = land.getValue(); grid[r][c] = '2'; // 然后分别对旁边的那些"陆地"做查询并入队列 if (r + 1 < height && grid[r + 1][c] == '1') { queue.offer(new Pair<>(r + 1, c)); // grid[r + 1][c] = '2'; } if (r - 1 >= 0 && grid[r -1][c] == '1') { queue.offer(new Pair<>(r - 1, c)); // grid[r-1][c] = '2'; } if (c + 1 < width && grid[r][c + 1] == '1') { queue.offer(new Pair<>(r, c + 1)); // grid[r][c + 1] = '2'; } if (c - 1 >= 0 && grid[r][c -1] == '1') { queue.offer(new Pair<>(r, c - 1)); // grid[r][c - 1] = '2'; } } } } } return count; }
- 323. 无向图中连通分量的数目
给定编号从
0
到n-1
的n
个节点和一个无向边列表(每条边都是一对节点),请编写一个函数来计算无向图中连通分量的数目。注意:
你可以假设在 edges 中不会出现重复的边。而且由于所以的边都是无向边,[0, 1] 与 [1, 0] 相同,所以它们不会同时在 edges 中出现。
用并查集、dfs、bfs 都可以做,但是我现在看不懂。
- 200. 岛屿数量 uf = UnionFind(row*col+1) dummy_node = row*col
- 拓扑排序 indegree记录流入个数, outdegree记录流出数组
- 207. 课程表 初始化出入度, 找到入度为0的节点们, 从他们开始dfs, 不断找到入读为0的。
- 210. 课程表 II
- 269. 火星词典 建图,key为前序,value为后继,然后拓扑排序逐个添加入度为零的节点。
- 207. 课程表 初始化出入度, 找到入度为0的节点们, 从他们开始dfs, 不断找到入读为0的。
数据结构设计
- 146. LRU缓存机制 init cache, capacity, head, tail
LRU:最近最少使用(least recently used),是一种缓存机制的设计。
实现:哈希表 + 双向链表
(哈希链表:哈希表 + 双向链表 的结合)
看这个的介绍:LRU算法实现(java) - 简书class LRUCache { // 维护一个双向链表的节点结构 class Node { int key, value; Node pre, next; Node(int key, int value) { this.key = key; this.value = value; pre = this; next = this; } } // LRU Cache的容量 private final int capacity; // dummy节点是一个冗余节点,dummy的next是链表的第一个节点,dummy的pre是链表的最后一个节点 private Node dummy; // 保存key-Node对,Node是双向链表节点,为了“快速查找” private Map<Integer, Node> map; public LRUCache(int capacity) { this.capacity = capacity; dummy = new Node(0, 0); map = new HashMap<>(); } // get 的时候,除了正常取值,还要改变元素顺序,通过两个步骤完成 // 1. 在链表中删除node // 2. 将node 添加到链表末尾 public int get(int key) { Node node = map.get(key); if (node == null) { return -1; } remove(node); add(node); return node.value; } // put 的时候要判断当前节点是否存在,分别分析 public void put(int key, int value) { Node node = map.get(key); // 如果不存在,需要判断当前容量大小,然后做删除最近最少未使用节点 if (node == null) { if (map.size() >= capacity) { // 删除的时候,一个是要删除map 中的值 map.remove(dummy.next.key); // 另一个是要删掉链表中的节点 remove(dummy.next); } node = new Node(key, value); // 然后map 中存 map.put(key, node); // 然后链表中存 add(node); } else { // 如果结构中存在这个值,那么只需要改变这个node 的顺序即可 map.remove(node.key); remove(node); node = new Node(key, value); map.put(key, node); add(node); } } /** * 在链表尾部添加新节点 * * @param node 新节点 */ private void add(Node node) { dummy.pre.next = node; node.pre = dummy.pre; node.next = dummy; dummy.pre = node; } /** * 从双向链表中删除该节点 * * @param node 要删除的节点 */ private void remove(Node node) { node.pre.next = node.next; node.next.pre = node.pre; } }
- 380. 常数时间插入、删除和获取随机元素
return random.choice(arr); di的key为value, val为value在数组中的位置
- 706. 设计哈希映射 size1000的1000个list,表头为空;Node(key, val, nex)
- 155. 最小栈 stack, minstack
- 295-. 数据流的中位数 最大堆+1,最小堆,每次入堆,都有从另一个堆里挤出一个元素
- 208. 实现 Trie (前缀树)
高频系列专题
数组矩阵杂题
- 双指针
- 42. 接雨水 O(3n), res[i] = min(left_max, right_max), 一次性求好左边最大值和右边最大值
参考这个解法:力扣
- 11. 盛最多水的容器 l从前往后,r从后往前,每次移动l和r中较小的值,算当前面积
就用双指针,先移动小的那个
- 42. 接雨水 O(3n), res[i] = min(left_max, right_max), 一次性求好左边最大值和右边最大值
- 数组
- 239. 滑动窗口最大值 - H
优先队列
单调队列
- 41. 缺失的第一个正数 置换,保证数组的第x−1个元素为x
- 51.上一个排列
- 238. 除自身之外的乘积
不能用除法,因为有可能出现0
利用索引左侧所有数字的乘积乘以右侧所有数字的乘积即可
方法:左右乘积列表
初始化两个空数组L 和R。对于索引i,L[i] 代表左侧所有数字的乘积,R[i] 代表右侧所有数字的乘积。
使用两个循环来初始化L 和R。
然后迭代,可以得到结果。
- 239. 滑动窗口最大值 - H
- 矩阵
- 48. 旋转图像 - M
(1) 对于矩阵中第i 行的第j 个元素,旋转后,它出现在第j 行,倒数第i 列的位置。利用一个新的二维矩阵,做中间存储。
(2) 通过翻转代替旋转。先通过水平轴翻转,再通过主对角线翻转,就是顺时针旋转。 - 54. 螺旋矩阵 - M
一层一层地找,一层层地输出 - 304. 2D区域和检索(不可变)
- 48. 旋转图像 - M
- Math
- 204. 计算质数
Board相关题
- 200. 岛屿数量、547 省份数量
这种题就是求一个图的连通分量。
无向图G的极大连通子图称为G的连通分量( Connected Component)。任何连通图的连通分量只有一个,即是其自身,非连通的无向图有多个连通分量。
以下是547 的解决方法如下:- Solution-DFS
class Solution { public int findCircleNum(int[][] isConnected) { // int[][] isConnected 是无向图的邻接矩阵,n 为无向图的顶点数量 int n = isConnected.length; // 定义 boolean 数组标识顶点是否被访问 boolean[] visited = new boolean[n]; // 定义 cnt 来累计遍历过的连通域的数量 int cnt = 0; for (int i = 0; i < n; i++) { // 若当前顶点 i 未被访问,说明又是一个新的连通域,则遍历新的连通域且cnt+=1. if (!visited[i]) { cnt++; dfs(i, isConnected, visited); } } return cnt; } private void dfs(int i, int[][] isConnected, boolean[] visited) { // 对当前顶点 i 进行访问标记 visited[i] = true; // 继续遍历与顶点 i 相邻的顶点(使用 visited 数组防止重复访问) for (int j = 0; j < isConnected.length; j++) { if (isConnected[i][j] == 1 && !visited[j]) { dfs(j, isConnected, visited); } } } } 作者:sweetiee 链接:https://leetcode-cn.com/problems/number-of-provinces/solution/dfs-bfs-bing-cha-ji-3-chong-fang-fa-ji-s-edkl/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
- Solution-BFS
class Solution { public int findCircleNum(int[][] isConnected) { // int[][] isConnected 是无向图的邻接矩阵,n 为无向图的顶点数量 int n = isConnected.length; // 定义 boolean 数组标识顶点是否被访问 boolean[] visited = new boolean[n]; // 定义 cnt 来累计遍历过的连通域的数量 int cnt = 0; Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < n; i++) { // 若当前顶点 i 未被访问,说明又是一个新的连通域,则bfs新的连通域且cnt+=1. if (!visited[i]) { cnt++; queue.offer(i); visited[i] = true; while (!queue.isEmpty()) { int v = queue.poll(); for (int w = 0; w < n; w++) { if (isConnected[v][w] == 1 && !visited[w]) { visited[w] = true; queue.offer(w); } } } } } return cnt; } } 作者:sweetiee 链接:https://leetcode-cn.com/problems/number-of-provinces/solution/dfs-bfs-bing-cha-ji-3-chong-fang-fa-ji-s-edkl/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
- Solution-UF(并查集)
并查集其实就是:合并、查询功能的一种实现。
并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示。/** * 并查集,要实现的功能:合并、查找 */ public int findCircleNum(int[][] isConnected) { // isConnected 是n x n 矩阵 int provinces = isConnected.length; // 保存每个节点对应的祖宗节点下标 int[] parent = new int[provinces]; for (int i = 0; i < provinces; i++) { // 初始化,每个节点是是自己本身的祖宗节点 parent[i] = i; } // 遍历isConnected 二维数组,只需要遍历右上角就可以 for (int i = 0; i < provinces; i++) { for (int j = i + 1; j < provinces; j++) { if (isConnected[i][j] == 1) { union(parent, i, j); } } } // 最后遍历parent 数组,确定结果 int circles = 0; for (int i = 0; i < provinces; i++) { if (parent[i] == i) { circles ++; } } return circles; } // 合并功能。合并index1 和index2 public void union(int[] parent, int index1, int index2) { // 使index1 的祖宗节点等于index2 的祖宗节点。这样两个节点就联合了。 parent[find(parent, index1)] = find(parent, parent[index2]); } // 查找功能 public int find(int[] parent, int index) { // 如果符合此条件,说明index 节点还有祖宗点,则需要继续寻找祖宗节点 if (parent[index] != index) { parent[index] = find(parent, parent[index]); } return parent[index]; }
- Solution-DFS
NSum及股票系列
- NSum系列
- 1. 2Sum
给定一个整数数组nums
和一个整数目标值target
,请你在该数组中找出 和为目标值target
的那 两个 整数,并返回它们的数组下标。
解决方法1:一次遍历,使用map, O(n) 复杂度
解决方法2:排序,双指针,Onlogn
此题即在遍历nums 数组的时候,对于每一个x,寻找target - x。可以使用map 存储,对于。map 的性质,每个元素可以存储两个值(key,value)。所以遍历到x 的时候,在map 中寻找target - x,如果没找到,则将x 及x 的下标存储到map 中。
public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(nums.length); for (int i = 0; i < nums.length; i++) { if (map.containsKey(target - nums[i])) { return new int[]{i, map.get(target - nums[i])}; } else { map.put(nums[i], i); } } return null; }
- 15. 3Sum 排序+双指针,On2,固定一个i,l,r从左右开始扫描
先排序,然后对于每个元素,寻找first,这是一次遍历。然后second & third 的寻找,这是使用双指针的操作方法。
参考题解,从O(N3) 简化成O(N2) 的过程。
public List<List<Integer>> threeSum(int[] nums) { int n = nums.length; Arrays.sort(nums); List<List<Integer>> ans = new ArrayList<List<Integer>>(); // 枚举a for (int first = 0; first < n; first ++) { // 需要和上一次枚举的数不相同 if (first > 0 && nums[first] == nums[first - 1]) { continue; } // c 对应的指针初始指向数组的最右端 int third = n - 1; int target = - nums[first]; // 枚举b for (int second = first + 1; second < third; second ++) { if (second > first + 1 && nums[second] == nums[second - 1]) { continue; } // 保证b 的指针在c 的左侧 while (second < third && nums[second] + nums[third] > target) { -- third; } // 如果指针重合,随着b 的后续增加,就不会有满足a+b+c=0 并且b<c 的c 了,可以退出循环 if (second == third) { break; } if (nums[second] + nums[third] == target) { List<Integer> list = new ArrayList<>(); list.add(nums[first]); list.add(nums[second]); list.add(nums[third]); ans.add(list); } } } return ans; }
- 18. 4Sum 和3Sum思路一样,固定两个再双指针,On3
使用两重循环枚举前两个数字,使用双指针枚举剩下的两个数字。
public static List<List<Integer>> fourSum(int[] nums, int target) { List<List<Integer>> result = new ArrayList<>(); if (null == nums || nums.length < 4) { return result; } Arrays.sort(nums); int length = nums.length; for (int i = 0; i < length - 3; i++) { // 防止重复解 if (i > 0 && nums[i] == nums[i - 1]) { continue; } // 数组情况前提判断 if (nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) { break; } // 数组情况前提判断 if (nums[length - 1] + nums[length - 2] + nums[length - 3] + nums[length - 4] < target) { break; } // 数组情况前提判断 if (nums[i] + nums[length - 3] + nums[length - 2] + nums[length -1] < target) { continue; } for (int j = i + 1; j < length - 2; j++) { // 防止重复解 if (j > i + 1 && nums[j] == nums[j - 1]) { continue; } // 数组情况前提判断 if (nums[i] + nums[j] + nums[j + 1] + nums[j + 2] > target) { break; } // 数组情况前提判断 if (nums[i] + nums[j] + nums[length - 1] + nums[length - 2] < target) { continue; } int left = j + 1; int right = length - 1; while (left < right) { int sum = nums[i] + nums[j] + nums[left] + nums[right]; if (sum == target) { // 赋值 result.add(Arrays.asList(nums[i], nums[j], nums[left], nums[right])); // 因为有可能有多重情况,所以在这里面还需要继续执行左右指针的变动 while (left < right && nums[left] == nums[left + 1]) { left ++; } left ++; while (left < right && nums[right] == nums[right - 1]) { right --; } right --; } else if (sum > target) { right --; } else if (sum < target) { left ++; } } } } return result; }
- 1. 2Sum
- 股票系列
- 121. 买卖股票的最佳时机 维护最小值minPrice,不断更新最大收益maxProfit
只要用一个变量记录一个历史最低价格minPrice,就可以假设股票是在那天买的,那么在第i 天卖出的利润就是price[i] - minPrice
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
(只能买卖一次)
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
public int maxProfit(int[] prices) { int ans = 0; int min = prices[0]; for (int i = 1; i < prices.length; i++) { if (prices[i] > min) { ans = ans > prices[i] - min ? ans : prices[i] - min; } else { min = prices[i]; } } return ans; }
- 122. 买卖股票的最佳时机 II 只要后一天比前一天大,就交易
动态规划、贪心
(可以买卖多次)
给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
动态规划:public int maxProfit(int[] prices) { int n = prices.length; // 定义状态dp[i][0] 表示第i 天交易完后,手里没有股票的最大利润 // 定义状态dp[i][1] 表示第i 天交易完后,手里有股票的最大利润 // 状态转移方程: // dp[i][0]=max{dp[i−1][0],dp[i−1][1]+prices[i]} 因为第i 天交易完成,股票卖出,所以要加price[i]。即当天股票卖出时的价格。 // dp[i][1]=max{dp[i−1][1],dp[i−1][0]−prices[i]} int[][] dp = new int[n][2]; dp[0][0] = 0; dp[0][1] = -prices[0]; for (int i = 1; i < n; 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], dp[i - 1][0] - prices[i]); } return dp[n - 1][0]; } // 升级版 public int maxProfit(int[] prices) { int n = prices.length; int dp0 = 0, dp1 = -prices[0]; for (int i = 1; i < n; ++i) { int newDp0 = Math.max(dp0, dp1 + prices[i]); int newDp1 = Math.max(dp1, dp0 - prices[i]); dp0 = newDp0; dp1 = newDp1; } return dp0; }
贪心:
public int maxProfit(int[] prices) { int ans = 0; int n = prices.length; for (int i = 1; i < n; ++i) { ans += Math.max(0, prices[i] - prices[i - 1]); } return ans; }
什么时候用贪心?什么时候用动态规划?
有空看看,偶然发现的算法教程:搞定大厂算法面试之leetcode精讲
状态转移树中,若后一状态仅仅取决于上一个状态,就用贪心算法;若后一状态取决于之前的多个状态,就用动态规划。(不是太准确)
更准确的说法应该是当局部最优解可以推导最终得出全局最优解时,用贪心,剩下的情况用动态规划
https://www.zhihu.com/question/366752691/answer/2238372073
- 123. 买卖股票的最佳时机 III
王脸小提示:dp[i][s],i为天数,s为状态
题目:给定一个数组,它的第
i
个元素是一支给定的股票在第i
天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
动态规划,因为每天最多可以完成两笔交易,所以在任意一天结束之后,会处于五种状态之一。
看官方题解,此题一共五个状态。只需要维护四个状态即可。
public int maxProfit(int[] prices) { int n = prices.length; int buy1 = -prices[0], sell1 = 0; int buy2 = -prices[0], sell2 = 0; for (int i = 1; i < n; ++i) { buy1 = Math.max(buy1, -prices[i]); sell1 = Math.max(sell1, buy1 + prices[i]); buy2 = Math.max(buy2, sell1 - prices[i]); sell2 = Math.max(sell2, buy2 + prices[i]); } return sell2; }
- 188. 买卖股票的最佳时机 IV dp[i][s],i为天数,s为状态
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格,和一个整型 k 。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。也就是说,你最多可以买 k 次,卖 k 次。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 121. 买卖股票的最佳时机 维护最小值minPrice,不断更新最大收益maxProfit
252. 区间(会议室)
定一个会议时间安排的数组 intervals ,每个会议时间都会包括开始和结束的时间 intervals[i] = [starti, endi] ,请你判断一个人是否能够参加这里面的全部会议
提示:
0 <= intervals.length <= 104
intervals[i].length == 2
0 <= starti < endi <= 106
class Solution {
public boolean canAttendMeetings(int[][] intervals) {
Arrays.sort(intervals, (o1, o2) -> (o1[0] == o2[0] ? o1[1] - o2[1] : o1[0] - o2[0]));
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
}
参考:leetcode 252.会议室 Java_云水冰的博客-CSDN博客
Arrays.sort() 的使用:Arrays.sort()的用法_Lin的博客-CSDN博客_arrays.sort()
253. 会议室II
首先进行一下排序,然后用一个小顶堆,维护当前每个会议室的结束时间,
然后当一个新的时间安排出现的时候,只需要判断一下是否需要新申请一个会议室,还是继续使用之前的会议室。
使用小顶堆的实现方式就是,小顶堆存了会议的结束时间,首先把第一个会议的结束时间存入,作为初始化条件。
遍历其他会议日程。如果会议开始时间(intervals[i][0])大于queue.peek(),就是大于当前最小的时间,那么就可以继续使用当前这个会议室。优先队列的操作方式就是把前一个会议日程踢出,然后add 进去当前会议的结束时间。否则不踢出。
public int minMeetingRooms(int[][] intervals) {
if (intervals == null || intervals.length == 0) {
return 0;
}
Arrays.sort(intervals, new Comparator<int[]>() {
@Override
public int compare(int[] o1, int[] o2) {
return o1[0] - o2[0];
}
});
// 优先队列存放的是,之前的,最早结束的会议的,结束时间。
// 这是一个小顶堆
PriorityQueue<Integer> queue = new PriorityQueue<>((o1, o2) -> o1 - o2);
// 队列初始化存入第一个会议的结束时间
queue.offer(intervals[0][1]);
for (int i = 1; i < intervals.length; i++) {
// 如果当前的会议开始时间,大于队列里面的之前的会议的结束时间
if (intervals[i][0] >= queue.peek()) {
// 那么之前的那个会议和现在的会议就可以使用一个会议室
// 所以之前的会议的记录就可以出队列了
queue.poll();
}
// 如果不是的话,那么就肯定得多一个房间了。
// 所以就将现在的会议结束时间入队。
// 使用小顶堆的意思是:越早结束的会议,房间的空余肯定越多,所以使用小顶堆的堆顶来比较
queue.offer(intervals[i][1]);
}
return queue.size();
}
参考:leetcode253. 会议室II(java):最小堆_yinianxx的博客-CSDN博客
BFS
举例:994,腐烂的桔子。
在给定的网格中,每个单元格可以有以下三个值之一:
值 0 代表空单元格;
值 1 代表新鲜橘子;
值 2 代表腐烂的橘子。
每分钟,任何与腐烂的橘子(在 4 个正方向上)相邻的新鲜橘子都会腐烂。
返回直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能,返回 -1。
主要就是看官方题解,主要就是细节
public int orangesRotting(int[][] grid) {
int[] x = {1, 0, -1, 0};
int[] y = {0, 1, 0, -1};
int row = grid.length;
int column = grid[0].length;
// 存放所有腐烂的句子
Queue<Pair<Integer, Integer>> queue = new ArrayDeque<>();
// 存放每个橘子是第几分钟腐烂的。对于开局就已经腐烂的橘子,他们的value 是0
Map<Pair<Integer, Integer>, Integer> depth = new HashMap<>();
// 处理开局就腐烂的橘子
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
if(grid[i][j] == 2) {
// 存储腐烂橘子
queue.add(new Pair<>(i, j));
// 存储橘子变腐烂的时间,key为橘子的一维数组下标,value为变腐烂的时间
depth.put(new Pair<>(i, j), 0);
}
}
}
int ans = 0;
// 腐烂的橘子队列,不为空
while(!queue.isEmpty()) {
Pair<Integer, Integer> poppedPair = queue.poll();
int r = poppedPair.getKey();
int c = poppedPair.getValue();
for(int k = 0; k < 4; k++) {
int tempI = r + x[k];
int tempJ = c + y[k];
if(tempI >= 0 && tempI < row && tempJ >= 0 && tempJ < column && grid[tempI][tempJ] == 1) {
grid[tempI][tempJ] = 2;
Pair<Integer, Integer> tempPair = new Pair<>(tempI, tempJ);
queue.add(tempPair);
// 记载橘子腐烂的时间
// key 是中心节点散发的各个节点
// value 是中心节点的腐烂时间 + 1
depth.put(tempPair, depth.get(poppedPair) + 1);
ans = depth.get(tempPair);
}
}
}
for(int i = 0; i < row; i++) {
for(int j = 0; j < column; j++) {
if (grid[i][j] == 1) {
return -1;
}
}
}
return ans;
}
DFS
Radix/Bucket Sort
radix sort 基数排序,也叫做bucket sort。
225. 队列实现栈
使用一个队列即可实现
主要是用top_elem 标识栈顶元素,和pop 操作,比较重要。
class MyStack {
private Queue<Integer> queue = new LinkedList<>();
int top_elem = 0;
public MyStack() {
}
// 添加元素到栈顶
public void push(int x) {
//x 是队列的队尾,是栈的栈顶
queue.offer(x);
top_elem = x;
}
// 队列的性质是先进先出,pop 元素的时候只能从队头取出元素
// 但是栈是先进后出,解决办法就是
// 把队列前面的元素全部取出,再加入到队尾,让之前队尾元素排到队头,这样就可以取出了
public int pop() {
int size = queue.size();
// 留下两个元素是为了更新top_elem.
while (size > 2) {
queue.offer(queue.poll());
size --;
}
top_elem = queue.peek();
queue.offer(queue.poll());
// 之前的队尾已经到了队头
return queue.poll();
}
public int top() {
return top_elem;
}
public boolean empty() {
return queue.isEmpty();
}
}
232. 栈实现队列
使用两个栈实现一个队列
添加元素的时候,把元素放到s1 里面,pop 的时候,把s2 作为中转
class MyQueue {
private Stack<Integer> s1, s2;
public MyQueue() {
s1 = new Stack<>();
s2 = new Stack<>();
}
public void push(int x) {
s1.push(x);
}
// 对于pop 操作,也只操作s2 就可以了
public int pop() {
// 先调用peek,保证s2 非空。
// 这样就保证,在s2 空了之后,再把s1 里面的值加进去
// 否则就先不动s1 的数据
peek();
return s2.pop();
}
// 注意peek 是静态的,不会有元素的变更
public int peek() {
if (s2.isEmpty()) {
// 把s1 压入s2 中
while (!s1.isEmpty()) {
s2.push(s1.pop());
}
}
return s2.peek();
}
// 如果两个栈都空,就说明队列为空
public boolean empty() {
return s1.isEmpty() && s2.isEmpty();
}
}
以上参考:队列实现栈|栈实现队列 - 知乎
其他:
560. 560. 和为 K 的子数组
给你一个整数数组 nums
和一个整数 k
,请你统计并返回该数组中和为 k
的连续子数组的个数。
使用“前缀和”
前缀和指一个数组的某下标之前的所有数组元素的和(包含其自身)。前缀和分为一维前缀和,以及二维前缀和。前缀和是一种重要的预处理,能够降低算法的时间复杂度。
(题目解法来自“labuladong”)
使用“前缀和”技巧
public static int subArraySum(int[] nums, int k) {
int n = nums.length;
// 构造
int[] preSum = new int[n + 1];
preSum[0] = 0;
for (int i = 0; i < n; i++) {
preSum[i + 1] = preSum[i] + nums[i];
}
int ans = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
if (preSum[i] - preSum[j] == k) {
ans ++;
}
}
}
return ans;
}
优化:第⼆层 for 循环在⼲嘛呢?翻译⼀下就是, 在计算, 有⼏个 j 能够使得
sum[i] 和 sum[j] 的差为 k。 毎找到⼀个这样的 j , 就把结果加⼀。
把第二个for 循环中的判断公式移项,可以得到if(sum[j] == sum[i] - k) ans ++;
直接记录下有⼏个 sum[j] 和 sum[i] - k 相等, 直接更
新结果, 就避免了内层的 for 循环。 我们可以⽤哈希表, 在记录前缀和的同
时记录该前缀和出现的次数。
public static int subArraySum(int[] nums, int k) {
int n = nums.length;
HashMap<Integer, Integer> preSum = new HashMap<>();
preSum.put(0, 1);
int ans = 0;
// 数组下标0 到下标i 的和
int sum0_i = 0;
for (int i = 0; i < n; i++) {
sum0_i += nums[i];
// 题目是要求有几个和为k 的子数组
// sum0_j 是数组下标0 到下标j 的和,也就是要找的前缀和。用sum0-i 减去sum0-j 得到的就是k
int sum0_j = sum0_i - k;
// 如果map 中存在这个前缀和
if (preSum.containsKey(sum0_j)) {
ans += preSum.get(sum0_j);
// 然后把前缀和sum0-i 加入记录,同时记录出现次数
preSum.put(sum0_i, preSum.getOrDefault(sum0_i, 0) + 1);
}
}
return ans;
}
给⼀棵⼆叉树, 和⼀个⽬标值, 节点上的值有正有负, 返回树中和等于⽬标值的路径条数
@Data
class TreeNode {
int val;
TreeNode left;
TreeNode right;
}
public int pathNum(TreeNode root, int sum) {
if (root == null) {
return 0;
}
int pathNumMe = pathNum(root, sum);
int pathNumLeft = pathNum(root.left, sum);
int pathNumRight = pathNum(root.right, sum);
return pathNumLeft + pathNumRight + pathNumMe;
}
public static int count(TreeNode node, int sum) {
if (node == null) {
return 0;
}
int isMe = (node.val == sum) ? 1 : 0;
int leftBro = count(node.left, sum - node.val);
int rightBro = count(node.right, sum - node.val);
return isMe + leftBro + rightBro;
}
归并排序:典型的分治算法;分治,典型的递归结构。
coupang
九章算法 | Coupang面试题:捡苹果 - 知乎
2020 年面试记录 [Microsoft / Coupang / CoinMarketCap] - Popco - 博客园