文章目录
1 哈希
1.1 1.两数之和🟢
题目:给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
链接:1. 两数之和
示例 :
输入:nums = [3,3], target = 6
输出:[0,1]
思路:
两个 for 循环可以解决,但第2个 for 循环可以用哈希表来快速查,不用一个个遍历
代码:
class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> hashMap = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
// 查表,看看是否有能和 nums[i] 凑出 target 的元素
int need = target - nums[i];
if (hashMap.containsKey(need)) {
return new int[]{hashMap.get(need), i};
}
// 查不到则存入映射,这样只用一次for循环
hashMap.put(nums[i], i);
}
return null;
}
}
1.2 49.字母异位词分组🟡
题目:给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
链接:49. 字母异位词分组
示例 :
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
思路:
难点在于怎么判断哪些单词属于异味词,因为不能直接用 ==
来判断。
观察异位词的特点可以看出他们排序后可以用 ==
来直接判断
代码:
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> hashMap = new HashMap<>();
for (String str : strs) {
// 字符串转换成数组对字符串的字符排序
char[] array = str.toCharArray();
Arrays.sort(array);
String key = new String(array);
// 获取key对应的集合,若不存在则返回一个空集合
List<String> list = hashMap.getOrDefault(key, new ArrayList<String>());
list.add(str);
hashMap.put(key, list);
}
return new ArrayList<>(hashMap.values());
}
}
1.3 128.最长连续序列🟡
题目:给定一个未排序的整数数组 nums
,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。
请你设计并实现时间复杂度为 O(n)
的算法解决此问题。
链接:128. 最长连续序列
示例 :
输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。
思路:
利用哈希集和可以快速判断 nums
是否存在某个数字。
遍历 nums
,若存在 num-1
,说明当前数字不是该连续序列的起始值,从而过滤掉一些情况。之后不断用哈希集和判断是否存在序列的下一个数值
代码:
class Solution {
public int longestConsecutive(int[] nums) {
// 转化成哈希集合,不需要HashMap,只关注是否存在
Set<Integer> set = new HashSet<Integer>();
for (int num : nums) {
set.add(num);
}
int res = 0;
for (int num : set) {
// num 不是连续子序列的第一个,跳过
if (set.contains(num - 1)) {
continue;
}
// num 是连续子序列的第一个,开始向后计算连续子序列的长度
int curNum = num;
int curLen = 0;
while (set.contains(curNum)) {
curNum += 1;
curLen += 1;
}
// 更新最长连续序列的长度
res = Math.max(res, curLen);
}
return res;
}
}
2 双指针
2.1 283.移动零🟢
题目:给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
链接:283. 移动零
示例 :
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
思路:
快指针遍历数组,慢指针记录数组下一个不为0元素的位置。
快指针遇到不为0的元素时,与慢指针的位置进行交换即可
代码:
class Solution {
public void moveZeroes(int[] nums) {
int left = 0, right = 0;
while (right < nums.length) {
if (nums[right] != 0) {
// 这里也可以用nums[left] = nums[right]
// 然后把left及其后面的元素赋为0
swap(nums, left, right);
left++;
}
right++;
}
}
public void swap(int[] nums, int left, int right) {
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
}
2.2 15.三数之和🟡
题目:给你一个整数数组 nums
,判断是否存在三元组 [nums[i], nums[j], nums[k]]
满足 i != j
、i != k
且 j != k
,同时还满足 nums[i] + nums[j] + nums[k] == 0
。请
你返回所有和为 0
且不重复的三元组。
注意: 答案中不可以包含重复的三元组。
链接:15. 三数之和
题解详细解释:三数之和
示例 :
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。
思路:
3个 for
循环会超时,所以要优化。有重复元素,用哈希表也会比较麻烦
可以一个 for
循环遍历第1个数,剩余两个数不能用 for
循环的话,可以用双指针优化查找时间。
事先排序后,左右各一个指针。计算当前3个数字的和比目标值0大还是小,进而移动左指针(和会变大)或右指针(和会变小)。这样就可以减少循环次数,但要注意去重
代码:
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums);
// 找出a + b + c = 0
// a = nums[i], b = nums[left], c = nums[right]
for (int i = 0; i < nums.length; i++) {
// 排序之后如果第一个元素已经大于零,那么无论如何组合都不可能凑成三元组
if (nums[i] > 0) {
return res;
}
// 去重a
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = nums.length - 1;
while (right > left) {
int sum = nums[i] + nums[left] + nums[right];
if (sum > 0) {
right--;
} else if (sum < 0) {
left++;
} else {
res.add(Arrays.asList(nums[i], nums[left], nums[right]));
// 去重b和c,应该放在找到一个三元组之后
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
right--;
left++;
}
}
}
return res;
}
}
2.3 11.盛最多水的容器🟡
题目:给定一个长度为 n
的整数数组 height
。有 n
条垂线,第 i
条线的两个端点是 (i, 0)
和 (i, height[i])
。
找出其中的两条线,使得它们与 x
轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明: 你不能倾斜容器。
链接:11. 盛最多水的容器
示例 :
输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
思路:
最直观的是两个 for 循环遍历所有结果,但这样会超时,所以要进行优化
用双指针,一个指向左边,一个指向右边。最大水量取决于宽度和高度。而高度取决于 left
和 right
中较小的那个,比如 left
较小,那么移动 right
只会让高度不变或者更小,所以要移动高度较低的那条线,这样虽然宽度减小,但是高度有可能增大,容量才有可能变大
抽象成二维数组,因为每次移动都会排除一行或一列,所以不会遗漏
代码:
class Solution {
public int maxArea(int[] height) {
int res = 0, left = 0, right = height.length - 1;
while (left < right) {
// 每次移动高度最短的那条线
if (height[left] < height[right]) {
res = Math.max(res, (right - left) * height[left]);
left++;
} else {
res = Math.max(res, (right - left) * height[right]);
right--;
}
}
return res;
}
}
2.4 42.接雨水🔴
题目:给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
链接:42. 接雨水
示例 :
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
思路:
可以只关注某一个位置能接到多少雨水,应该是 min(左边最高的柱子,右边最高的柱子)-当前高度
但是计算某一侧最高的柱子要是每次都一个个遍历太耗时,可以使用备忘录的方式先提前算出所有位置两侧最高柱子的高度
不过上述题解要定义数组,空间复杂度为 O(n),可以使用双指针进一步优化。思路还是对于每个位置用上面的公式计算当前位置能接多少雨水,不过是左右两个指纹向内移动。用两个变量取代之前的两个备忘录数组。
代码:
1.动态规划解法
class Solution {
public int trap(int[] height) {
if (height.length == 0) {
return 0;
}
int n = height.length;
int res = 0;
// 数组充当备忘录
int[] l_max = new int[n];
int[] r_max = new int[n];
// 初始化 base case
l_max[0] = height[0];
r_max[n - 1] = height[n - 1];
// 从左向右计算 l_max
for (int i = 1; i < n; i++)
l_max[i] = Math.max(height[i], l_max[i - 1]);
// 从右向左计算 r_max
for (int i = n - 2; i >= 0; i--)
r_max[i] = Math.max(height[i], r_max[i + 1]);
// 计算答案 当前位置能接到的雨水取决于min(左,右最高的柱子)-当前高度
for (int i = 1; i < n - 1; i++)
res += Math.min(l_max[i], r_max[i]) - height[i];
return res;
}
}
2.双指针
class Solution {
int trap(int[] height) {
int left = 0, right = height.length - 1;
int lMax = 0, rMax = 0;
int res = 0;
while (left <= right) {
lMax = Math.max(lMax, height[left]);
rMax = Math.max(rMax, height[right]);
// res += min(lMax, rMax) - height[i]
if (lMax < rMax) {
res += lMax - height[left];
left++;
} else {
res += rMax - height[right];
right--;
}
}
return res;
}
}
3 滑动窗口
滑动窗口就两步:
- 右指针不断右移,每一次右移要不要做些额外处理
- 判断左侧窗口是否要收缩,若收缩应该怎么处理
无论右移还是收缩都是更改字符在window 对应的数值并更新指针
滑动窗口 labuladong 有解题模板
3.1 3.无重复字符的最长子串🟡
58一面
题目:给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串 的长度。
示例 :
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
思路:
子串问题那就要用双指针,这题更准确点是用双指针维护一个滑动窗口。
对于当前待添加的元素,若当前滑动窗口含有该元素,则需要删除滑动窗口左侧元素。直到滑动窗口不再包含该元素后,再把该元素添加到滑动窗口,每次添加后判断是否更新 res
代码:
class Solution {
public int lengthOfLongestSubstring(String s) {
Set<Character> window = new HashSet<>();
int left = 0, right = 0;
int res = 0; // 记录结果
while (right < s.length()) {
char cur = s.charAt(right);
// 无重复,窗口右指针右移增大窗口
if (!window.contains(cur)) {
window.add(cur);
right++;
// 更新最长不重复子串的长度
res = Math.max(res, window.size());
// 有重复,左指针右移缩小窗口,直到无重复
} else {
char d = s.charAt(left);
window.remove(d);
left++;
}
}
return res;
}
}
labuladong 模板解法,但这题没必要用 map,set 即可
int lengthOfLongestSubstring(String s) {
Map<Character, Integer> window = new HashMap<>();
int left = 0, right = 0;
int res = 0; // 记录结果
while (right < s.length()) {
char c = s.charAt(right);
right++;
// 进行窗口内数据的一系列更新
window.put(c, window.getOrDefault(c, 0) + 1);
// 判断左侧窗口是否要收缩
while (window.get(c) > 1) {
char d = s.charAt(left);
left++;
// 进行窗口内数据的一系列更新
window.put(d, window.get(d) - 1);
}
// 在这里更新答案
res = Math.max(res, right - left);
}
return res;
}
3.2 438.找到字符串中所有字母异位词🟡
题目:给定两个字符串 s
和 p
,找到 s
中所有 p
的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
示例 :
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
思路:
子串的处理仍然是用滑动窗口解决。本题要找的子串的长度是固定的,也就是可以看作是一个固定长度的滑动窗口不断右移,判断当前窗口的子串是否满足条件即可。
因为只有小写字母,所以可以用长度为26的数组记录每个字母出现的个数。然后判断两个字符串是否满足条件就可以转换为判断两个数组内的值是否相等,需要用 Arrays.equals(arr1,arr2)
,注意不能用 arr1.equals(arr2)
。不断滑动字符串 s
,判断两个计数数组是否相等。
代码:
class Solution {
public List<Integer> findAnagrams(String s, String p) {
int sLen = s.length(), pLen = p.length();
if (sLen < pLen) {
return new ArrayList<Integer>();
}
List<Integer> ans = new ArrayList<Integer>();
int[] sCount = new int[26];
int[] pCount = new int[26];
for (int i = 0; i < pLen; ++i) {
++sCount[s.charAt(i) - 'a'];
++pCount[p.charAt(i) - 'a'];
}
if (Arrays.equals(sCount, pCount)) {
ans.add(0);
}
for (int i = 0; i < sLen - pLen; ++i) {
--sCount[s.charAt(i) - 'a']; // 左边删除
++sCount[s.charAt(i + pLen) - 'a']; // 右边增加
if (Arrays.equals(sCount, pCount)) {
ans.add(i + 1);
}
}
return ans;
}
}
注: 4.3 76.最小覆盖子串🔴 也属于滑动窗口
4 子串
4.1 560.和为 K 的子数组🟡
题目:给你一个整数数组 nums
和一个整数 k
,请你统计并返回 该数组中和为 k
的子数组的个数 。
子数组是数组中元素的连续非空序列。其中, -1000 <= nums[i] <= 1000
。
示例 :
输入:nums = [1,2,3], k = 3
输出:2
思路:
因为 nums 里有负数,所以没法用滑动窗口。最简单的思路就是从当前数开始向后算出所有的子数组。
子数组是连续的,某一段位置 [j,...i]
的和若为 k,那不就是当前位置 i
的前缀和减去位置 j-1
的前缀和等于 k。这里我们只关系次数,并不关心这个 j
到底是多少,所以可以用 HashMap
存储前缀和,值为前缀和出现的次数。
从下标0到当前位置前缀和为 preSum,若当前找到了 preSum - k
,说明从0到当前位置分成了两部分:preSum - k
和 k
,那就说明找到了合适的子数组。
速记:
HashMap
存前缀和,初始[0:1]
,结果不断加上preSum - target
代码:
public class Solution {
public int subarraySum(int[] nums, int k) {
int res = 0, preSum = 0;
// 前缀和为键,出现次数为对应的值
Map<Integer, Integer> mp = new HashMap<>();
// 后面get的时候,若preSum等于k会get(0),所以0对应的值为1
mp.put(0, 1);
for (int num : nums) {
preSum += num;
// 从下标0到当前位置前缀和为preSum
// 若当前找到了preSum - k
// 说明从0到当前位置分成了两部分:preSum - k 和 k
res += mp.getOrDefault(preSum - k, 0);
mp.put(preSum, mp.getOrDefault(preSum, 0) + 1); // 更新前缀和map
}
return res;
}
}
4.2 239.滑动窗口最大值🔴
题目:给你一个整数数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
链接:239. 滑动窗口最大值
示例 :
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
思路:
使用单调队列,从队首到队尾单调递减。滑动窗口新来一个元素,若当前元素比队尾大,那留着队尾也没用,因为队尾不可能是最大了,可以将队尾移除。
由于是单调队列,所以队首就是队列最大,但是由于新来一个元素,队首可能已经不在滑动窗口内了,所以更新结果前要先判断队首的下标是否还在当前滑动窗口内。之后根据当前元素 i
找到当前滑动窗口最左侧下标 i - k + 1
更新成队首即可。
类似题目 - 单调栈:12.4 739.每日温度🟡
速记:维护单调递减队列,更新答案前先判断队首是否还在滑动窗口内
代码:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] res = new int[n - k + 1];
Deque<Integer> q = new ArrayDeque<>(); // 双端队列从左到右单调递减
for (int i = 0; i < n; i++) {
// 1. 入队且要维护单调递减
while (!q.isEmpty() && nums[q.getLast()] <= nums[i]) {
q.removeLast(); // 队尾小于当前元素则出队,维护 q 单调递减
}
q.addLast(i);
// 2. 队首已经不在窗口内则出队首
if (i - q.getFirst() >= k) {
q.removeFirst();
}
// 3. 记录答案
if (i - k + 1 >= 0) { // 要满足数组下标 >= 0
// 由于队首到队尾单调递减,所以窗口最大值就是队首
res[i - k + 1] = nums[q.getFirst()];
}
}
return res;
}
}
4.3 76.最小覆盖子串🔴
TODO:不太熟
题目:给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
注意:
- 对于
t
中重复字符,我们寻找的子字符串中该字符数量必须不少于t
中该字符数量。 - 如果
s
中存在这样的子串,我们保证它是唯一的答案。
链接:76. 最小覆盖子串
示例 :
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
思路:
s
中子串要覆盖 t
,可以用滑动窗口来做。滑动窗口不断右移试图找到覆盖 t
的子串,而当找到后,收缩左侧窗口试图找到最小的子串。如 ABCDA
覆盖了 ABC
,但却不是最小覆盖子串,收缩左侧窗口后的 BCDA
才是最小覆盖子串。
至于判断当前子串是否已经覆盖了 t
,事先统计 t
中每个字符的出现次数。若当前子串字符 c
的次数已经和 t
中 c
的次数相等,说明字符 c
已经是一个有效字符了,valid++
,当 valid == need.size()
说明 t
中所有字符都是有效的了,即找到了符合条件的子串,之后再不断判断要不要收缩左侧窗口。
速记:窗口不断右移找可行解,找到后左侧窗口收缩试图找最优解
代码:
class Solution {
public String minWindow(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; // 窗口中满足需要的字符个数
int start = 0, len = Integer.MAX_VALUE; // 记录最小覆盖子串的起始索引及长度
while (right < s.length()) {
// 1.寻找可行解
char c = s.charAt(right); // c 是将移入窗口的字符
right++; // 右移窗口
// 进行窗口内数据的一系列更新
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1);
// 字符个数相等有效个数才能+1
if (window.get(c).equals(need.get(c)))
valid++;
}
// 2.优化可行解:不断判断左侧窗口是否要收缩
while (valid == need.size()) { // 有效字符个数等于需要的字符个数,可以收缩了
// 在这里更新最小覆盖子串
if (right - left < len) {
start = left;
len = right - left; // 最小覆盖子串的长度
}
char d = s.charAt(left); // d 是将移出窗口的字符
left++; // 左移窗口
// 进行窗口内数据的一系列更新
if (need.containsKey(d)) { // d可能不在need里下面get是null
if (window.get(d).equals(need.get(d)))
valid--; // 出现次数相等时有效个数才-1
window.put(d, window.get(d) - 1);
}
}
}
// 返回最小覆盖子串
return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
}
}
5 普通数组
5.1 53.最大子数组和🟡
题目:给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。
链接:53. 最大子数组和
示例 :
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
思路:
子数组是连续的,所以后面的结果和前面的结果有关。定义 dp[i]
表示以 i
结尾的元素的子数组和,那么其就等于 max(和前面组成子数组,自己组成新的子数组)
。而我们要的结果就是所有子数组中和最大的那个。
由于当前的 dp[i]
只和 dp[i-1]
有关,所以可以用变量 pre
代替 dp
数组
代码:
1.动态规划(易理解版)
class Solution {
public int maxSubArray(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int res = nums[0];
for (int i = 1; i < n; i++) {
// 和前面组成子数组,还是自己成为新的子数组
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
res = Math.max(res, dp[i]);
}
return res;
}
}
2.动态规划(压缩空间版)
class Solution {
public int maxSubArray(int[] nums) {
int pre = 0, res = nums[0];
for (int x : nums) {
pre = Math.max(pre + x, x);
res = Math.max(res, pre);
}
return res;
}
}
5.2 56.合并区间🟡
题目:以数组 intervals
表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi]
。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
链接:56. 合并区间
示例 :
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
思路:
先按区间 start
升序排列,这样只用考虑 end
的关系。下一个区间的 start
小于等于上一个区间的 end
时,则要合并。注意合并后的区间的 end
是两个区间 end
取最大。
List<int[]>
怎么转换成二维数组要留意下。也可以直接用二维数组存结果,不过由于无法确定结果的个数,这样就要用一个变量记录二维数组中有效的结果。
代码:
class Solution {
public int[][] merge(int[][] intervals) {
List<int[]> res = new ArrayList<>(); // 二维数组可看做n个一维数组的list
// 按区间的 start 升序排列
Arrays.sort(intervals, (a, b) -> (a[0] - b[0]));
res.add(intervals[0]);
for (int i = 1; i < intervals.length; i++) {
int[] curr = intervals[i];
// res 中最后一个元素的引用
int[] last = res.getLast();
if (curr[0] <= last[1]) {
last[1] = Math.max(last[1], curr[1]); // 合并end
} else {
// 处理下一个待合并区间
res.add(curr);
}
}
return res.toArray(new int[0][0]);
}
}
5.3 189.轮转数组🟡
题目:给定一个整数数组 nums
,将数组中的元素向右轮转 k
个位置,其中 k
是非负数。
进阶:你可以使用空间复杂度为 O(1)
的 原地 算法解决这个问题吗?
链接:189. 轮转数组
示例 :
输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右轮转 1 步: [7,1,2,3,4,5,6]
向右轮转 2 步: [6,7,1,2,3,4,5]
向右轮转 3 步: [5,6,7,1,2,3,4]
思路:
先整体反转得到 [7,6,5,4,3,2,1]
,再反转前 k 个元素得到 [5,6,7,4,3,2,1]
,再反转后面的即可
速记:三次反转:整体、前 k、后面的
代码:
class Solution {
public void rotate(int[] nums, int k) {
int n = nums.length;
k %= n; // k可能大于数组长度,所以要取模
reverse(nums, 0, n - 1); // 整体反转
reverse(nums, 0, k - 1); // 前k反转
reverse(nums, k, n - 1); // 后面反转
}
// 反转nums数组的[l,r]
private void reverse(int[] nums, int l, int r) {
while (l < r) {
int tmp = nums[l];
nums[l] = nums[r];
nums[r] = tmp;
l++;
r--;
}
}
}
5.4 238. 除自身以外数组的乘积🟡
题目:给你一个整数数组 nums
,返回数组 answer
,其中 answer[i]
等于 nums
中除 nums[i]
之外其余各元素的乘积。
题目数据 保证 数组 nums
之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。
请 不要使用除法, 且在 O(n)
时间复杂度内完成此题。
示例 :
输入: nums = [1,2,3,4]
输出: [24,12,8,6]
思路:
若不考虑空间复杂度,定义前缀乘数组和后缀乘数组, nums[i] = pre[i] * back[i]
优化:前缀乘数组可以先直接放到结果数组 res
中,再从后向前遍历更新 res
即乘上后缀乘 back
速记:
res
先放前缀乘,从后遍历乘后缀乘更新res
代码:
class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] res = new int[n];
res[0] = 1;
// 先存放不包含当前元素的前缀乘
for (int i = 1; i < n; i++) {
res[i] = res[i - 1] * nums[i - 1];
}
// 从后向前遍历乘上 后缀乘
int back = 1; // 用一个变量代替后缀乘数组
for (int i = n - 1; i >= 0; i--) {
res[i] = res[i] * back;
back *= nums[i];
}
return res;
}
}
5.5 41. 缺失的第一个正数🔴
题目:给你一个未排序的整数数组 nums
,请你找出其中没有出现的最小的正整数。
请你实现时间复杂度为 O(n)
并且只使用常数级别额外空间的解决方案。
链接:41. 缺失的第一个正数
示例 :
输入:nums = [3,4,-1,1]
输出:2
解释:1 在数组中,但 2 没有。
思路:
若不考虑空间复杂度,把数放到哈希表,然后从 1
开始枚举判断是否在哈希表即可。而之所以想到哈希表是我们想用 O(1)
的时间复杂度判断元素是否在哈希表中,因此可以把数组改成哈希表的替代产品。
长度为 n
的数组,没有出现的最小正整数一定在 [1,n+1]
之间,如果 [1,n]
都出现了,那结果就是 n+1
。我们可以修改数组让每个元素回到自己的位置,如 1
应该回到下标 0
,2
应该回到下标 1
。遍历数组让其与正确位置交换就可让它回到自己的位置,最后遍历数组,第一个没有回到自己位置的就是缺失的第一个整数。
速记:将在
[1-n]
范围内的元素x
交换到下标x-1
位置,判断谁没在自己的位置
代码:
public class Solution {
public int firstMissingPositive(int[] nums) {
int n = nums.length;
for (int i = 0; i < n; i++) {
// 交换后可能nums[i]还在[1,n],所以是while不断交换
// 若i已经在自己的位置上没必要交换否则陷入死循环一直和自己交换
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
// 例如:数值 3 应该放在索引 2 的位置上
swap(nums, nums[i] - 1, i);
}
}
for (int i = 0; i < n; i++) {
// [1, -1, 3, 4]
if (nums[i] != i + 1) {
return i + 1; // 返回1+1=2
}
}
return n + 1; // 都正确则返回数组长度 + 1
}
private void swap(int[] nums, int index1, int index2) {
int tmp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = tmp;
}
}
5.6 88. 合并两个有序数组(扩展)🟢
题目:给你两个按 非递减顺序 排列的整数数组 nums1
和 nums2
,另有两个整数 m
和 n
,分别表示 nums1
和 nums2
中的元素数目。
请你 合并 nums2
到 nums1
中,使合并后的数组同样按 非递减顺序 排列。
注意: 最终,合并后数组不应由函数返回,而是存储在数组 nums1
中。为了应对这种情况,nums1
的初始长度为 m + n
,其中前 m
个元素表示应合并的元素,后 n
个元素为 0
,应忽略。nums2
的长度为 n
。
进阶: 你可以设计实现一个时间复杂度为 O(m + n)
的算法解决此问题吗?
链接:88. 合并两个有序数组
示例 :
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3] 和 [2,5,6] 。
合并结果是 [1,2,2,3,5,6] 。
思路:
合并两个有序链表只需设两个指针不断向后遍历进行拼接即可。但两个有序数组合并后要放到第一个数组中,若是还和链表一样从前向后遍历会覆盖掉第一个数组原来的元素。但我们可以反其道而行,因为 nums1
后面的 n
个元素为 0
,我们可以设两个指针从后向前进行合并放到 nums1
的后面。
注意和合并链表一样遍历完要考虑剩余的那个,只不过这题合并后是放到 nums1
中,所以无需考虑 nums1
有剩余的情况。
速记:从后向前遍历合并,遍历完后考虑剩余的那个
代码:
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
int i = m + n - 1;
m--;
n--;
while (m >= 0 && n >= 0) {
if (nums1[m] > nums2[n]) {
nums1[i--] = nums1[m--];
}else{
nums1[i--] = nums2[n--];
}
}
// 合并后存num1中,若nums1有剩余无需合并,这段代码写不写都行
// while(m >= 0){
// nums1[i--] = nums1[m--];
// }
// 只需考虑合并剩余的nums2
while(n >= 0){
nums1[i--] = nums2[n--];
}
}
}
6 矩阵
6.1 73.矩阵置零🟡
题目:给定一个 m x n
的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用 原地 算法。
链接:73. 矩阵置零
示例 :
输入:matrix = [[1,1,1],[1,0,1],[1,1,1]]
输出:[[1,0,1],[0,0,0],[1,0,1]]
思路:
比较直观的是用两个数组,分别记录哪些行和哪些列应该置0,然后根据结果将对应的行和列置0
可以使用数组的第一行和第一列代替上面的两个数组,但是要额外用两个变量记录下第一行和第一列是否有0
代码:
class Solution {
public void setZeroes(int[][] matrix) {
int m = matrix.length, n = matrix[0].length;
boolean flagCol0 = false, flagRow0 = false;
// 第一行或第一列是否有0
for (int i = 0; i < m; i++) {
if (matrix[i][0] == 0) {
flagCol0 = true;
}
}
for (int j = 0; j < n; j++) {
if (matrix[0][j] == 0) {
flagRow0 = true;
}
}
// 标记该行和该列是否有0
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (matrix[i][j] == 0) {
matrix[i][0] = matrix[0][j] = 0;
}
}
}
// 如果当前元素所在行或所在列有0,则将当前位置置0
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (matrix[i][0] == 0 || matrix[0][j] == 0) {
matrix[i][j] = 0;
}
}
}
// 将第一行和第一列置0
if (flagCol0) {
for (int i = 0; i < m; i++) {
matrix[i][0] = 0;
}
}
if (flagRow0) {
for (int j = 0; j < n; j++) {
matrix[0][j] = 0;
}
}
}
}
6.2 54.螺旋矩阵🟡
题目:给你一个 m
行 n
列的矩阵 matrix
,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。
链接:54. 螺旋矩阵
示例 :
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,2,3,6,9,8,7,4,5]
思路:
四个变量分别记录上下左右边界,按照顺时针即从左到右、从上到下、从右到左和从下到上的方向遍历。每遍历一行或一列就更新对应的边界,注意更新边界后就要判断下是否超过了边界。
速记:四个变量记录四个边界,遍历一个方向就更新边界并判断是否 break
代码:
class Solution {
public List<Integer> spiralOrder(int[][] matrix) {
int m = matrix.length, n = matrix[0].length;
int top = 0, bottom = m - 1, left = 0, right = n - 1;
List<Integer> res = new ArrayList<>();
while (true) {
// 从左到右
for (int i = left; i <= right; i++) {
res.add(matrix[top][i]);
}
// top这一行已经遍历完,所以++,并判断是否达到边界条件
// 注意先++,再比较,因为top改变了才可能会达到边界条件
if (++top > bottom) {
break;
}
// 从上到下
for (int i = top; i <= bottom; i++) {
res.add(matrix[i][right]);
}
// right这一列已经遍历完,所以--,并判断是否达到边界条件
if (--right < left) {
break;
}
// 从右到左
for (int i = right; i >= left; i--) {
res.add(matrix[bottom][i]);
}
// bottom这一行已经遍历完,所以--,并判断是否达到边界条件
if (--bottom < top) {
break;
}
// 从左到右
for (int i = bottom; i >= top; i--) {
res.add(matrix[i][left]);
}
// left这一列已经遍历完,所以++,并判断是否达到边界条件
if (++left > right) {
break;
}
}
return res;
}
}
6.3 48.旋转图像🟡
题目:给定一个 n × n 的二维矩阵 matrix
表示一个图像。请你将图像顺时针旋转 90 度。
你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。
链接:48. 旋转图像
示例 :
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[[7,4,1],[8,5,2],[9,6,3]]
思路:
若不要求原地,也就是可以使用额外空间的话,可以使用 matrix_new[j][n - i - 1] = matrix[i][j]
先放到一个新数组,再把新数组的值赋给旧数组即可。
如果不使用额外空间,这时候一般要么是两两交换,要么是多次反转。两两交换太绕,这里记住可以使用上下反转再对角线反转即可。
速记:先上下反转再对角线反转
代码:
class Solution {
public void rotate(int[][] matrix) {
int n = matrix.length;
// 先上下反转
for (int i = 0; i < n / 2; i++) { // 注意边界
for (int j = 0; j < n; j++) {
int tmp = matrix[i][j];
matrix[i][j] = matrix[n - i - 1][j];
matrix[n - i - 1][j] = tmp;
}
}
// 再对角线反转
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) { // 注意边界
int tmp = matrix[i][j];
matrix[i][j] = matrix[j][i];
matrix[j][i] = tmp;
}
}
}
}
6.4 240.搜索二维矩阵 II🟡
题目:编写一个高效的算法来搜索 m x n
矩阵 matrix
中的一个目标值 target
。该矩阵具有以下特性:
- 每行的元素从左到右升序排列。
- 每列的元素从上到下升序排列。
示例 :
输入:matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5
输出:true
思路:
常规遍历都是从左上角开始查找。这里若从左上角开始找,遇到一个更大的数无法确定是向右还是向下去找。原因是向右和向下都是升序的。那我们可以从右上角开始找,这样从右往左是递减的,从上到下是递增的,这样就可以确定是向左还是向下了。
速记:两变量记录行和列,右上角向左下查找
代码:
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
int m = matrix.length, n = matrix[0].length;
// 初始化在右上角
int i = 0, j = n - 1;
while (i < m && j >= 0) {
if (matrix[i][j] == target) {
return true;
}
if (matrix[i][j] < target) {
// 需要大一点,往下移动
i++;
} else {
// 需要小一点,往左移动
j--;
}
}
// while 循环中没有找到,则 target 不存在
return false;
}
}
7 链表
设不设虚拟头结点:要看是否修改或删除了头结点
7.1 160.相交链表🟢
题目:给你两个单链表的头节点 headA
和 headB
,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null
。
链接:160. 相交链表
示例 :
输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'
解释:相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,6,1,8,4,5]。
在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
— 请注意相交节点的值不为 1,因为在链表 A 和链表 B 之中值为 1 的节点 (A 中第二个节点和 B 中第三个节点) 是不同的节点。换句话说,它们在内存中指向两个不同的位置,而链表 A 和链表 B 中值为 8 的节点 (A 中第三个节点,B 中第四个节点) 在内存中指向相同的位置。
思路:
因为两个链表长度可能不一致,所以同时遍历可能无法同时到达相交节点。
可以先算出哪个链表较长,让其向后移动两个链表长度的差值,这样两个指针同时遍历就可以同时到达相交节点
上面思路简单但代码比较多。若遍历完当前链表再遍历另一个链表,这样也会同时到达相交节点,或者同时指向 null
如上图中,pA 走了
1 8 4 5 null 5 6 1
到了节点8,pB 走了6 1 8 4 5 null 4 1
同样到了节点8
代码:
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) {
return null;
}
ListNode pA = headA, pB = headB;
while (pA != pB) {
// pA走到末尾转到B链表,PB走到末尾转到A链表
pA = pA == null ? headB : pA.next;
pB = pB == null ? headA : pB.next;
}
return pA;
}
}
7.2 206.反转链表🟢🔥
题目:给你单链表的头节点 head
,请你反转链表,并返回反转后的链表。
链接:206. 反转链表
示例 :
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
思路:
用一个指针 cur
用来遍历单链表,再用另一个指针 pre
指向 cur
的前面一个节点。例如 cur=2
时,原本是 1->2
,现在要变成 2->1
。直接让 cur.next=pre
就可以实现,但这样的话,后面的 3
就没法遍历到了。所以要用一个临时指针 next
先指向 cur.next
,再让 cur.next=pre
。
到这里,就实现了1和2的反转,如果想继续遍历下去,只需让 pre
和 cur
都向后移一位。
代码:
1.迭代法(双指针)
class Solution {
public ListNode reverseList(ListNode head) {
ListNode cur = head;
ListNode pre = null, next = null;
while (cur != null) {
next = cur.next; // 更新 next
cur.next = pre; // 反转操作
pre = cur; // 更新 pre
cur = next; // 更新 cur
}
return pre;
}
}
2.递归(也要会)
class Solution {
public ListNode reverseList(ListNode head) {
return reverse(head, null);
}
public ListNode reverse(ListNode cur, ListNode pre) {
if (cur == null) {
return pre;
}
ListNode next = cur.next;
cur.next = pre;
return reverse(next, cur);
}
}
7.3 92.反转链表 II(扩展)🟡
美团一面
题目:给你单链表的头指针 head
和两个整数 left
和 right
,其中 left <= right
。请你反转从位置 left
到位置 right
的链表节点,返回 反转后的链表 。
链接:92. 反转链表 II
示例 :
输入:head = [1,2,3,4,5], left = 2, right = 4
输出:[1,4,3,2,5]
思路:
- 首先根据
left
找到待反转链表的位置,注意pre
设为null
- 然后利用反转链表的逻辑将待反转链表反转后
- 再把反转后的链表和前后区间的链表相连即可。
- 若不设虚拟头结点,
left=1
时,无法找到头节点,所以必须设虚拟头节点
代码:
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
ListNode dummy = new ListNode(0, head), p0 = dummy;
for (int i = 0; i < left - 1; ++i) {
p0 = p0.next; // 指向待反转链表的前一个节点
}
ListNode cur = p0.next;
ListNode pre = null, next = null;
// 反转之后 pre 是反转区间新的头节点,cur 是下个区间的 head
for (int i = 0; i < right - left + 1; ++i) {
// 和反转链表题目一样的逻辑
next = cur.next;
cur.next = pre; // 执行right - left + 1次反转操作
pre = cur;
cur = next;
}
// tail是反转后区间的末尾,指向下个区间的head
ListNode tail = p0.next;
tail.next = cur;
// 反转区间的前一个节点指向反转区间新的头节点
p0.next = pre;
return dummy.next;
}
}
7.4 25.K 个一组翻转链表🔴🔥
题目:给你链表的头节点 head
,每 k
个节点一组进行翻转,请你返回修改后的链表。
k
是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k
的整数倍,那么请将最后剩余的节点保持原有顺序。
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
示例 :
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
思路:
和反转链表 II 类似,只不过要反转多个区间。
- 首先统计节点个数来确定要进行多少个区间的反转操作
- 然后利用反转链表的逻辑将待反转链表反转后
- 再把反转后的链表和前后区间的链表相连,同时更新待反转区间的前一个节点
p0
,循环下一个区间的反转操作。
视频讲解:反转链表_哔哩哔哩_bilibili
代码:
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 统计节点个数
ListNode dummy = new ListNode(-1, head), p0 = dummy;
int n = 0;
ListNode cur = head;
while (cur != null) {
n++;
cur = cur.next;
}
cur = head;
ListNode pre = null, next = null;
while (n >= k) {
n -= k; // 执行n/k次循环(反转操作)
// 翻转之后 pre 是反转区间新的头节点,cur 是下个区间的 head
for (int i = 0; i < k; ++i) {
// 和反转链表一样的逻辑
next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// tail是反转后区间的末尾,指向下个区间的head
ListNode tail = p0.next;
tail.next = cur;
// 反转区间的前一个节点指向反转区间新的头节点
p0.next = pre;
// p0来到当前区间的末尾,也就是后一个区间的前一个节点
p0 = tail;
}
return dummy.next;
}
}
7.5 234.回文链表🟢
题目:给你一个单链表的头节点 head
,请你判断该链表是否为回文链表。如果是,返回 true
;否则,返回 false
。
链接:234. 回文链表
示例 :
输入:head = [1,2,2,1]
输出:true
进阶: 你能否用 O(n)
时间复杂度和 O(1)
空间复杂度解决此题?
思路:
因为单链表没法从后向前遍历,所以没法用左右双指针遍历判断。但可以先把值放到一个数组中,这样就可以用左右指针向中间遍历来判断。
为了优化空间,可以先找到链表的中间位置,将后面的链表反转。用两个指针分别指向前半部分的链表和后半部分的链表,从而避免创建数组。
代码:
1.值复制到数组然后双指针
class Solution {
public boolean isPalindrome(ListNode head) {
List<Integer> list = new ArrayList<>();
ListNode cur = head;
while (cur != null) {
list.add(cur.val);
cur = cur.next;
}
// 问题变为判断数组中的元素是否是回文
int left = 0, right = list.size() - 1;
while (left < right) {
if (!list.get(left).equals(list.get(right))) {
return false;
}
left++;
right--;
}
return true;
}
}
2.快慢指针
class Solution {
public boolean isPalindrome(ListNode head) {
// 找到中间且靠前的节点
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// slow向后一步左右两边子链表长度一致 1 (2) 2 1
slow = slow.next; // 1 2 (3) 2 1
ListNode left = head;
// 后半部分反转
ListNode right = reverse(slow);
while (right != null) {
if (left.val != right.val)
return false;
left = left.next;
right = right.next;
}
return true;
}
ListNode reverse(ListNode head) {
ListNode pre = null, cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
}
}
7.6 141.环形链表🟢
题目:给你一个链表的头节点 head
,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next
指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos
来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos
不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true
。 否则,返回 false
。
链接:141. 环形链表
示例 :
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
思路:
判断有没有环,也就是判断遍历到的当前节点是否之前也遍历到过,所以可以用 HashSet
来快速判断当前节点是否在之前遍历过的节点中
也可以用快慢指针,慢指针走一步,快指针走两步。若是存在环,快指针一定会在某一节点追上慢指针
代码:
public class Solution {
public boolean hasCycle(ListNode head) {
// 快慢指针初始化指向 head
ListNode slow = head, fast = head;
// 快指针走到末尾时停止
while (fast != null && fast.next != null) {
// 慢指针走一步,快指针走两步
slow = slow.next;
fast = fast.next.next;
// 快慢指针相遇,说明含有环
if (slow == fast) {
return true;
}
}
// 不包含环
return false;
}
}
7.7 142.环形链表 II🟡
题目:如果链表中有某个节点,可以通过连续跟踪 next
指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos
来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos
是 -1
,则在该链表中没有环。注意:pos
不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改 链表。
链接:142. 环形链表 II
示例 :
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
思路:
这题和上题一样,依然可以用 HashSet
快速判断当前节点是否之前遍历过,而且第一次重复的节点就是环的入口。
如果想优化空间,上题的解法快慢指针相遇的地方不一定是环的入口,如例子中会在 -4
相遇。相遇时,只需让一个 tmp
指针从 head
出发,和 slow
一样每次都是走一步,相遇的地方就是环的入口。
代码:
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
// 快慢指针相遇,说明有环
if (slow == fast) {
// tmp从head出发,和slow相遇的节点就是入口
ListNode tmp = head;
while (slow != tmp) {
slow = slow.next;
tmp = tmp.next;
}
return slow;
}
}
return null;
}
}
7.8 28.合并两个有序链表🟢🔥
题目:将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
链接:21. 合并两个有序链表
示例 :
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]
思路:
两个指针分别指向两个链表,谁的节点值小下一个就用谁的,最后连接上非空的那个链表即可。为了方便最终返回头结点,这里要设一个虚拟头结点
代码:
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dummy = new ListNode(-1);
ListNode cur = dummy;
while (list1 != null && list2 != null) {
if (list1.val > list2.val) {
cur.next = list2;
list2 = list2.next;
} else {
cur.next = list1;
list1 = list1.next;
}
cur = cur.next;
}
// 拼接把剩下的链表
cur.next = list1 == null ? list2 : list1;
return dummy.next;
}
}
7.9 2.两数相加🟡
题目:给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。
请你将两个数相加,并以相同形式返回一个表示和的链表。
你可以假设除了数字 0 之外,这两个数都不会以 0 开头。
链接:2. 两数相加
示例 :
输入:l1 = [2,4,3], l2 = [5,6,4]
输出:[7,0,8]
解释:342 + 465 = 807.
思路:
两数相加和平常计算两个数字相加一样,不过是遍历两条链表逐渐相加。主要是存在进位的情况,需要一个变量记录上次相加的进位。
这题需要注意的是如果用 while (l1 != null || l2 != null)
来判断会漏掉最后两个数相加有进位的情况,所以要加上 || 进位 > 0
的条件
代码:
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
// 在两条链表上的指针
ListNode p1 = l1, p2 = l2;
// 虚拟头结点(构建新链表时的常用技巧)
ListNode dummy = new ListNode(-1);
// 指针 p 负责构建新链表
ListNode p = dummy;
// 记录进位
int carry = 0;
// 开始执行加法,两条链表走完且没有进位时才能结束循环
while (p1 != null || p2 != null || carry > 0) {
// 先加上上次的进位
int val = carry;
if (p1 != null) {
val += p1.val;
p1 = p1.next;
}
if (p2 != null) {
val += p2.val;
p2 = p2.next;
}
// 处理进位情况
carry = val / 10;
val = val % 10;
// 构建新节点
p.next = new ListNode(val);
p = p.next;
}
// 返回结果链表的头结点(去除虚拟头结点)
return dummy.next;
}
}
7.10 19.删除链表的倒数第 N 个结点🟡
题目:给你一个链表,删除链表的倒数第 n
个结点,并且返回链表的头结点。
示例 :
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
思路:
最简单的方法就是先遍历一次链表算出链表长度,然后再遍历一次走到待删节点前一个进行删除操作。
之所以要两次遍历是无法确定 len - n
的值,那我们可以让一个指针先走 n
步,此时让另一个节点从头出发,他们俩同时向后遍历,从而确定 len - n
的值
注意可能会删除 head 节点引发空指针异常,所以要设置一个 dummy
节点
代码:
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(-1,head);
ListNode slow = dummy, fast = dummy;
// 因为指针初始是在dummy,所以这里多走一步
for (int i = 0; i < n + 1; i++) {
fast = fast.next;
}
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
slow.next = slow.next.next; // 删除指定元素
return dummy.next;
}
}
7.11 24.两两交换链表中的节点🟡
题目:给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
示例 :
输入:head = [1,2,3,4]
输出:[2,1,4,3]
思路:
链表题目都是涉及到指针移动的问题,最好在纸上画画,防止绕晕。
可以先不考虑怎么移动指针,先考虑交换两个链表节点应该怎么做。
cur.next = cur.next.next
即 1.next=3
,然后我们想让 2.next=1
或者 0.next=2
的时候,会发现此时的 2
因为和 1
的连接断开了, 已经找不到了。所以就要用一个临时节点提前把 2
存起来
交换过两个节点后,现在是 0 2 1 3
,cur=1,prev=0
,按照对 cur
和 prev
的定义,下一次交换时 cur=3,prev=1
,所以对应的代码就是 prev = cur;cur = cur.next;
TODO:k 个一组反转链表的代码中 k=2
代码:
1.迭代解法
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(-1, head);
ListNode prev = dummy, cur = head;
// 下一个待反转的为null 或者 下一对待反转的只有1个元素
while (cur != null && cur.next != null) {
ListNode tmp = cur.next;
cur.next = tmp.next;
prev.next = next;
tmp.next = cur;
// 更新两个指针
prev = cur;
cur = cur.next;
}
return dummy.next;
}
}
2.递归解法
能看懂,但下次遇到想不到,起码把迭代解法搞明白就可以了
class Solution {
// 定义:输入以 head 开头的单链表,将这个单链表中的每两个元素翻转,
// 返回翻转后的链表头结点
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode first = head;
ListNode second = head.next;
ListNode others = head.next.next;
// 先把前两个元素翻转
second.next = first;
// 利用递归定义,将剩下的链表节点两两翻转,接到后面
first.next = swapPairs(others);
// 现在整个链表都成功翻转了,返回新的头结点
return second;
}
}
7.12 138.随机链表的复制🟡
题目:给你一个长度为 n
的链表,每个节点包含一个额外增加的随机指针 random
,该指针可以指向链表中的任何节点或空节点。
构造这个链表的 深拷贝。 深拷贝应该正好由 n
个 全新 节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 next
指针和 random
指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点 。
例如,如果原链表中有 X
和 Y
两个节点,其中 X.random --> Y
。那么在复制链表中对应的两个节点 x
和 y
,同样有 x.random --> y
。
返回复制链表的头节点。
用一个由 n
个节点组成的链表来表示输入/输出中的链表。每个节点用一个 [val, random_index]
表示:
val
:一个表示Node.val
的整数。random_index
:随机指针指向的节点索引(范围从0
到n-1
);如果不指向任何节点,则为null
。
你的代码 只 接受原链表的头节点 head
作为传入参数。
链接:138. 随机链表的复制
示例 :
输入:head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
输出:[[7,null],[13,0],[11,4],[10,2],[1,0]]
思路:
如果只有 next
指针,那边遍历边创建节点并连接上就行。但现在有 random
指针,第一次遍历用 next
连接的节点,再想连接 random
时,如 7.random = 1
,但我们没法找到原有节点 1
对应的新节点。所以关键问题是不知道原节点到新节点的映射,既然是映射,那就可以用 HashMap
。先用哈希表存储映射,第二次遍历根据原有节点指针,连接上新节点指针。
速记:第一次遍历用
HashMap
存储原节点到新节点的映射,第二次遍历连接新节点指针
代码:
class Solution {
public Node copyRandomList(Node head) {
// 原始节点到新节点的映射
Map<Node, Node> map = new HashMap<>();
// 先把节点拷贝出来
for (Node cur = head; cur != null; cur = cur.next) {
map.put(cur, new Node(cur.val));
}
// 再把节点的连接拷贝出来
for (Node cur = head; cur != null; cur = cur.next) {
if (cur.next != null) {
map.get(cur).next = map.get(cur.next); // 构造新节点的next
}
if (cur.random != null) {
map.get(cur).random = map.get(cur.random); // 构造新节点的random
}
}
return map.get(head);
}
}
7.13 148.排序链表🟡
题目:给你链表的头结点 head
,请将其按 升序 排列并返回 排序后的链表 。
进阶:你可以在 O(n log n)
时间复杂度和常数级空间复杂度下,对链表进行排序吗?
链接:148. 排序链表
示例 :
输入:head = [4,2,1,3]
输出:[1,2,3,4]
思路:
对链表没法用 sort
函数排序,所以可以先用 list
存起来,对 list
排序再遍历 list
拼接。
若是 O(n log n)
时间复杂度和常数级空间复杂度,那可以用归并排序。而归并排序可分为:找到中间位置、对左右子链表排序、合并左右子链表。所以可以再写两个函数用于找中间位置和合并两个链表。
注意找的中间位置若是偶数元素是中间靠前的那个元素。如 1 2 3 4 5
,中间是 3
,可分为 1 2 3
和 4 5
。但若是 1 2 3 4
,必须找到的中间位置是 2
,这样才能分成 1 2
和 3 4
。
速记:对链表归并排序,找的中间位置是靠前那个
代码:
class Solution {
public ListNode sortList(ListNode head) {
// 递归结束条件
if (head == null || head.next == null) {
return head;
}
// 找到链表中间靠前节点并断开链表
ListNode midNode = middleNode(head); // 2
ListNode rightHead = midNode.next; // 3
midNode.next = null; // 注意要断开和右边链表的连接,分成两个链表
// 递归对左右链表排序
ListNode left = sortList(head); // 1 2
ListNode right = sortList(rightHead); // 3 4
// 合并两个有序链表
return mergeTwoLists(left, right);
}
// 找到链表中间靠前节点(876.链表的中间结点)1 2 3 4 -> 2
private ListNode middleNode(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode slow = head;
ListNode fast = head.next; // 取靠前就是head.next,取靠后就是head
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
// 合并两个有序链表(21.合并两个有序链表)
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1);
ListNode cur = dummy;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
cur.next = l1;
l1 = l1.next;
} else {
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
// 拼接剩余的链表
cur.next = l1 != null ? l1 : l2;
return dummy.next;
}
}
7.14 23.合并 K 个升序链表🔴
题目:给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 :
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
1->4->5,
1->3->4,
2->6
]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6
思路:
如果是合并两个单链表,那可以设两个指针不断比较哪个值较小即可。但现在链表的数量不固定,无法直接设置对应的指针数量。
最小堆:我们设多个指针的目的是为了判断哪个指针指向的元素较小。可以用最小堆存放每个链表的元素,这样堆顶就是当前几个链表中最小的元素,取出来一个元素后,再把其后面的元素添加到堆中,直到堆为空也就是遍历完所有元素。
分治法:我们还可以从前到后两两合并链表,不过可以使用分治法优化。一分为二,先合并左边的,再合并右边的,再把左右合成最终的链表。对于左边链表的合并,可以继续一分为二,直到无法拆分。
代码:
1.最小堆
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
// 优先级队列,最小堆
PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> (a.val - b.val));
// 将 k 个链表的头结点加入最小堆
for (ListNode head : lists) {
if (head != null) { // 注意head可能为null
pq.add(head);
}
}
ListNode dummy = new ListNode(-1);
ListNode cur = dummy;
while (!pq.isEmpty()) {
// 获取最小节点,接到结果链表中
ListNode node = pq.poll();
cur.next = node;
if (node.next != null) {
pq.offer(node.next);
}
// cur 指针不断前进准备连接下一个节点
cur = cur.next;
}
return dummy.next;
}
}
2.分治法
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
return merge(lists, 0, lists.length - 1);
}
public ListNode merge(ListNode[] lists, int l, int r) {
if (l == r) {
return lists[l]; // 无需合并,直接返回
}
if (l > r) {
return null;
}
int mid = l + (r - l) / 2;
return mergeTwoLists(merge(lists, l, mid), merge(lists, mid + 1, r));
}
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dummyHead = new ListNode(-1);
ListNode cur = dummyHead;
while (list1 != null && list2 != null) {
if (list1.val > list2.val) {
cur.next = list2;
list2 = list2.next;
} else {
cur.next = list1;
list1 = list1.next;
}
cur = cur.next;
}
// 拼接剩下的链表
cur.next = list1 != null ? list1 : list2;
return dummyHead.next;
}
}
7.15 146.LRU 缓存🟡🔥
华为一面
题目:请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache
类:
LRUCache(int capacity)
以 正整数 作为容量capacity
初始化 LRU 缓存int get(int key)
如果关键字key
存在于缓存中,则返回关键字的值,否则返回-1
。void put(int key, int value)
如果关键字key
已经存在,则变更其数据值value
;如果不存在,则向缓存中插入该组key-value
。如果插入操作导致关键字数量超过capacity
,则应该 逐出 最久未使用的关键字。
函数 get
和 put
必须以 O(1)
的平均时间复杂度运行。
链接:146. LRU 缓存
示例 :
输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]
思路:
get
和 put
要求 O(1)
,那显然是 HashMap
,但没有先后顺序,所以可以使用哈希链表 LinkedHashMap
。
不过面试时一般要求不能直接使用 LinkedHashMap
,要根据哈希表和双向链表来实现。主要就是哈希表存放 key
和对应的节点,节点之间组成双向链表的结构,节点内部存放 key
和 value
。
速记:
get
对应getNode
,getNode
中要先remove
再pushFront
,map
的增删只在put
代码:
LinkedHashMap
解法(也要会)
class LRUCache extends LinkedHashMap<Integer, Integer> {
private final int capacity;
public LRUCache(int capacity) {
// 容量、负载因子、按照访问顺序迭代(默认false是按插入顺序迭代)
super(capacity, 0.75F, true);
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
// 判断调用put()方法时是否移除键值对
// 构造函数设为true表明移除最老的(最早访问)
// LinkedHashMap中新访问的会放到链表最后,也就是会移除首元素
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return super.size() > capacity;
}
}
2.哈希表+双向链表解法(面试要求)
class LRUCache {
class Node {
int key, value;
Node pre, next;
Node(int k, int v) {
this.key = k;
this.value = v;
}
}
Map<Integer, Node> map = new HashMap<>();
Node dummy = new Node(-1, -1);
int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
dummy.next = dummy;
dummy.pre = dummy;
}
public int get(int key) {
Node node = getNode(key);
return node == null ? -1 : node.value;
}
public void put(int key, int value) {
Node node = getNode(key);
// 有节点就更新
if (node != null) {
node.value = value;
return;
}
// 不存在这个节点就新建
node = new Node(key, value);
// 新建的节点不会被getNode添加到前面,所以手动添加到前面
pushFront(node);
map.put(key, node); // 唯一一次map新加元素
// 是否超容量
if (map.size() > capacity) {
Node d = dummy.pre;
remove(d);
map.remove(d.key); // 唯一一次map删除元素
}
}
// 根据key取节点,若节点存在则把该节点添加到前面
public Node getNode(int key) { // -1 1 (2) 3
Node node = map.get(key);
if (node == null) {
return null;
}
remove(node); // 删除链表中的节点
pushFront(node); // 添加节点到头部
return node;
}
// 删除链表中的节点
public void remove(Node node) { // 1 (2) 3
node.pre.next = node.next;
node.next.pre = node.pre;
}
// 节点添加到链表头部
public void pushFront(Node node) { // -1 1 (2) 3
node.pre = dummy;
node.next = dummy.next;
dummy.next = node;
node.next.pre = node;
}
}
7.16 143.重排链表(扩展)🟡
题目:给定一个单链表 L
的头节点 head
,单链表 L
表示为:
L0 → L1 → … → Ln - 1 → Ln
请将其重新排列后变为:
L0 → Ln → L1 → Ln - 1 → L2 → Ln - 2 → …
不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
链接:143. 重排链表
示例 :
输入:head = [1,2,3,4]
输出:[1,4,2,3]
思路:
可以看出新的顺序是左右两个指针不断向中间移动,所以可以放到一个 ArrayList
用下标取元素拼接
但有空间复杂度为 O(1)
的方法,可以分成三步,对应三个函数来解决
- 寻找中间靠前节点 ---->
midNode = 2
- 右边链表反转 ---->
4 3
- 合并左右两个链表,即交替相连 ---->
1 2
和4 3
---->1 4 2 3
总体来说这题和 7.13 148.排序链表🟡 类似,分成几个步骤来解决,每个步骤都不难,但代码较多,注意别在某一步写错。
速记:先找中间靠前,再对右边的反转,最后交替合并
代码:
class Solution {
public void reorderList(ListNode head) {
// 找到中间靠前位置
ListNode midNode = middleNode(head);
ListNode right = midNode.next;
midNode.next = null; // 注意要断开和右边链表的连接
// 反转右边的链表
right = reverseList(right);
// 左右链表交替相连
mergeList(head, right);
}
private ListNode middleNode(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode slow = head;
ListNode fast = head.next; // 取靠前就是head.next,取靠后就是head
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
private ListNode reverseList(ListNode head) {
ListNode cur = head;
ListNode pre = null, next = null;
while (cur != null) {
next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
}
// 注意如何进行交替合并
private void mergeList(ListNode left, ListNode right) {
while (left != null && right != null) {
ListNode lNext = left.next;
left.next = right;
left = lNext;
ListNode rNext = right.next;
right.next = left;
right = rNext;
}
}
}
8 二叉树
8.1 94.二叉树的中序遍历🟢
题目:给定一个二叉树的根节点 root
,返回 它的 中序 遍历 。
链接:94. 二叉树的中序遍历
示例 :
输入:root = [1,null,2,3]
输出:[1,3,2]
思路:
递归解决,二叉树的前中后序指的是根节点的位置,并且左节点一定在右节点前边。所以是先递归左,再处理根节点也就是加到 res
,再递归右。
速记:左中后序指的是根节点的位置
代码:
class Solution {
List<Integer> res = new ArrayList<>();
public List<Integer> inorderTraversal(TreeNode root) {
traverse(root);
return res;
}
public void traverse(TreeNode root) {
if (root == null) {
return;
}
traverse(root.left);
// 中间处理就是中序
res.add(root.val);
traverse(root.right);
}
}
8.2 104.二叉树的最大深度🟢
题目:给定一个二叉树 root
,返回其最大深度。
二叉树的 最大深度 是指从根节点到最远叶子节点的最长路径上的节点数。
示例 :
输入:root = [3,9,20,null,null,15,7]
输出:3
思路:
先看局部怎么解决,若是只有两层,如图中 20、15、7
,根节点的最大深度就是子节点的深度加1。所以要求二叉树的最大深度就是求以 root
为根节点的最大深度,就要知道其左右子树的最大深度,所以就是后序遍历(自底向上),先访问左右子节点再访问根节点。
速记:最大深度就是左右子树最大深度取最大+1,所以后序遍历
代码:
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
int left = maxDepth(root.left); // 左子树的深度
int right = maxDepth(root.right); // 右子树的深度
return Math.max(left, right) + 1; // 根节点深度就是 左右子树最大的深度 + 1
}
}
8.3 226.翻转二叉树🟢
题目:给你一棵二叉树的根节点 root
,翻转这棵二叉树,并返回其根节点。
链接:226. 翻转二叉树
示例 :
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]
思路:
交换左右子树,从局部看,如 2,1,3
就是交换 1
和 3
。用递归翻转左子树,再翻转右子树,再交换左右子树即可。
速记:翻转左得
left
,翻转右得right
,左右互换即可
代码:
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) {
return null;
}
TreeNode left = invertTree(root.left); // 翻转左子树
TreeNode right = invertTree(root.right); // 翻转右子树
// 交换左右子树
root.left = right;
root.right = left;
return root;
}
}
8.4 101.对称二叉树🟢
题目:给你一个二叉树的根节点 root
, 检查它是否轴对称。
链接:101. 对称二叉树
示例 :
输入:root = [1,2,2,3,4,4,3]
输出:true
思路:
如上图,第二层不止值要相等,第三层左的左和右的右,左的右和右的左也要相等。所以重新定义一个函数 isValid
传入第二层的 2,2
,方便判断
速记:新定义函数传
left
和right
,检查左的左和右的右,左的右和右的左
代码:
class Solution {
public boolean isSymmetric(TreeNode root) {
return isValid(root.left, root.right);
}
public boolean isValid(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;
}
// 检查图中3 4 | 4 3是否对称
return isValid(left.left, right.right) && isValid(left.right, right.left);
}
}
8.5 543.二叉树的直径🟢
题目:给你一棵二叉树的根节点,返回该树的 直径 。
二叉树的 直径 是指树中任意两个节点之间最长路径的 长度 。这条路径可能经过也可能不经过根节点 root
。
两节点之间路径的 长度 由它们之间边数表示。
链接:543. 二叉树的直径
示例 :
输入:root = [1,2,3,4,5]
输出:3
解释:3 ,取路径 [4,2,1,3] 或 [5,2,1,3] 的长度。
思路:
求直径其实就是根节点左右儿子的深度相加。所以可以套用求深度的代码,在求得左右儿子的深度后更新 res
速记:直径就是所有节点的左右儿子的深度相加取最大
代码:
class Solution {
int res = 0;
public int diameterOfBinaryTree(TreeNode root) {
maxDepth(root);
return res;
}
public int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
int left = maxDepth(root.left); // 左儿子的深度
int right = maxDepth(root.right); // 右儿子的深度
res = Math.max(res, left + right); // 左儿子加右儿子的深度 取最大
return Math.max(left, right) + 1;
}
}
8.6 102.二叉树的层序遍历🟡
题目:给你二叉树的根节点 root
,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。
示例 :
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[9,20],[15,7]]
思路:
二叉树没法很好的一层一层直接遍历,一层一层想象成一个队列放一层,然后所有队列连接起来,从前往后就是层序遍历。
关键是怎么从上一层元素获取到下一层元素,遍历上一层的时候,把当前元素放到 level
中,顺便把他的左右子节点放到队列中,这样遍历完这一层后队列存的刚好就是下一层的元素。
代码:
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new LinkedList<>();
if (root == null) {
return res;
}
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// while 循环控制从上向下一层层遍历
while (!q.isEmpty()) {
int len = q.size(); // for循环中q.size()会变,所以先存起来
// 记录这一层的节点值
List<Integer> level = new LinkedList<>();
// for 循环控制每一层从左向右遍历
for (int i = 0; i < len; i++) {
TreeNode cur = q.poll();
level.add(cur.val);
if (cur.left != null)
q.offer(cur.left);
if (cur.right != null)
q.offer(cur.right);
}
res.add(level);
}
return res;
}
}
8.7 103.二叉树的锯齿形层序遍历(扩展)🟡
题目:给你二叉树的根节点 root
,返回其节点值的 锯齿形层序遍历 。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。
示例 :
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[20,9],[15,7]]
思路:
和层序遍历类似,只不过多了一个变量 isLeft
判断遍历方向。若是从左往右每层遍历的时候插入 level
的尾部就是一般的层序遍历,而每次都插入头部就可以实现从右向左遍历。每遍历一层就对 isLeft
取非也就是改变方向。
速记:从右向左层序遍历,那就每次
addFirst
插头部
代码:
class Solution {
public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if (root == null) {
return res;
}
boolean isLeft = true; // 是否从左往右
Queue<TreeNode> q = new LinkedList<>();
q.add(root);
while (!q.isEmpty()) {
int sz = q.size();
List<Integer> level = new LinkedList<>();
for (int i = 0; i < sz; i++) {
TreeNode node = q.poll();
// 根据isLeft判断插入尾部还是插入头部实现不同方向遍历
if (isLeft) {
level.addLast(node.val);
} else {
level.addFirst(node.val);
}
if (node.left != null) {
q.add(node.left);
}
if (node.right != null) {
q.add(node.right);
}
}
res.add(level);
isLeft = !isLeft; // 下一层切换方向
}
return res;
}
}
8.8 108.将有序数组转换为二叉搜索树🟢
题目:给你一个整数数组 nums
,其中元素已经按 升序 排列,请你将其转换为一棵 平衡 二叉搜索树。
示例 :
输入:nums = [-10,-3,0,5,9]
输出:[0,-3,9,-10,null,5]
解释:[0,-10,5,null,-3,null,9] 也将被视为正确答案:
思路:
二叉搜索树是左比根小,右比根大,那么根节点其实就可以用有序数组中间节点。对于偶数的数组,中间节点有俩,选哪个都行。而根节点的左右儿子节点依然是剩余的区间取中间节点,所以函数就要传入区间的左右范围,递归创建。
速记:取中间节点为根节点,传左右区间递归创建左右儿子
代码:
class Solution {
public TreeNode sortedArrayToBST(int[] nums) {
return build(nums, 0, nums.length - 1);
}
public TreeNode build(int[] nums, int left, int right) {
if (left > right) {
return null;
}
// 总是选择中间位置左边的数字作为根节点
int mid = left + (right - left) / 2;
// 构建根节点,递归构建根的左和根的右
TreeNode root = new TreeNode(nums[mid]);
root.left = build(nums, left, mid - 1);
root.right = build(nums, mid + 1, right);
return root;
}
}
8.9 98.验证二叉搜索树🟡
题目:给你一个二叉树的根节点 root
,判断其是否是一个有效的二叉搜索树。
有效 二叉搜索树定义如下:
- 节点的左子树只包含 小于 当前节点的数。
- 节点的右子树只包含 大于 当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
提示: -2^31 <= Node.val <= 2^31 - 1
链接:98. 验证二叉搜索树
示例 :
输入:root = [5,1,4,null,null,3,6]
输出:false
解释:根节点的值是 5 ,但是右子节点的值是 4 。
思路:
这题很容易只比较左儿子比根小,右儿子比根大。注意要比较左边所有的节点都比根小,右边所有的节点都比根大。所以传参的时候要传最小值和最大值,判断 root.val
是否满足最小值和最大值的情况。
注意题目中说了节点的最小值和最大值范围是 int
类型的最小值和最大值范围内,所以初始化的时候要初始化成 long
类型的最小值和最大值。
速记:根比
min
大,比max
小,递归检查左右子树
代码:
class Solution {
public boolean isValidBST(TreeNode root) {
return isValid(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
public boolean isValid(TreeNode root, long min, long max) {
if (root == null) {
return true;
}
// 若 root.val 不符合 max 和 min 的限制,说明不是合法 BST
if (root.val <= min || root.val >= max) {
return false;
}
// 限定左子树的最大值是 root.val,右子树的最小值是 root.val
return isValid(root.left, min, root.val) && isValid(root.right, root.val, max);
}
}
8.10 230.二叉搜索树中第 K 小的元素🟡
腾讯一面(未做出)-次数应该设成全局变量而不是当做函数参数
题目:给定一个二叉搜索树的根节点 root
,和一个整数 k
,请你设计一个算法查找其中第 k
个最小元素(从 1 开始计数)。
示例 :
输入:root = [5,3,6,2,4,null,null,1], k = 3
输出:3
思路:
二叉搜索树的中序遍历结果是递增的,所以找第 k
小就是中序遍历遇到的第 k
个元素
速记:中序遍历遇到的第
k
个元素
代码:
class Solution {
int res = 0;
int rank = 0; // 记录当前元素的排名
public int kthSmallest(TreeNode root, int k) {
traverse(root, k);
return res;
}
public void traverse(TreeNode root, int k) {
if (root == null) {
return;
}
traverse(root.left, k);
// 中序遍历代码位置
rank++;
if (k == rank) {
// 找到第 k 小的元素
res = root.val;
return;
}
traverse(root.right, k);
}
}
8.11 199.二叉树的右视图🟡
题目:给定一个二叉树的 根节点 root
,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
链接:199. 二叉树的右视图
示例 :
输入: [1,2,3,null,5,null,4]
输出: [1,3,4]
思路:
BFS 比较容易想到,每一层遇到的最后一个就是右视图看到的节点。
DFS 是先递归右子树,保证每次首先遇到的都是这一层的最右边的节点。当深度和当前结果个数一致时说明这时刚到达这一层,把该节点存到结果中。
速记:层序遍历每层最后一个就是右视图的结果
代码:
1.BFS
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) {
return res;
}
Queue<TreeNode> q = new LinkedList<>();
q.add(root);
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
TreeNode node = q.poll();
// 这一层的最后一个加入res
if (i == sz - 1) {
res.add(node.val);
}
if (node.left != null) {
q.add(node.left);
}
if (node.right != null) {
q.add(node.right);
}
}
}
return res;
}
}
2.DFS
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> ans = new ArrayList<>();
dfs(root, 0, ans);
return ans;
}
private void dfs(TreeNode root, int depth, List<Integer> ans) {
if (root == null) {
return;
}
// 这个深度首次遇到
if (depth == ans.size()) {
ans.add(root.val);
}
// 先递归右子树,保证首次遇到的一定是最右边的节点
dfs(root.right, depth + 1, ans);
dfs(root.left, depth + 1, ans);
}
}
8.12 114.二叉树展开为链表🟡
题目:给你二叉树的根结点 root
,请你将它展开为一个单链表:
- 展开后的单链表应该同样使用
TreeNode
,其中right
子指针指向链表中下一个结点,而左子指针始终为null
。 - 展开后的单链表应该与二叉树 先序遍历 顺序相同。
示例 :
输入:root = [1,2,5,3,4,null,6]
输出:[1,null,2,null,3,null,4,null,5,null,6]
思路:
后序遍历,先递归拉平左右子树,再将左子树接到右子树的位置,原来的右子树接到下面
速记:递归拉平左右子树,左接到右,右接到下
代码:
class Solution {
// 定义:将以 root 为根的树拉平为链表
public void flatten(TreeNode root) {
if (root == null) {
return;
}
// 先递归拉平左右子树
flatten(root.left);
flatten(root.right);
/**** 后序遍历位置 ****/
// 1、左右子树已经被拉平成一条链表
TreeNode left = root.left;
TreeNode right = root.right;
// 2、将左子树作为右子树
root.left = null;
root.right = left;
// 3、将原先的右子树接到当前右子树的末端
TreeNode cur = root;
while (cur.right != null) {
cur = cur.right;
}
cur.right = right;
}
}
8.13 105.从前序与中序遍历序列构造二叉树🟡
题目:给定两个整数数组 preorder
和 inorder
,其中 preorder
是二叉树的先序遍历, inorder
是同一棵树的中序遍历,请构造二叉树并返回其根节点。
示例 :
输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出: [3,9,20,null,null,15,7]
思路:
构造二叉树首先要找到根节点,然后想办法构建其左右子树。中前序遍历的第一个元素就是根节点。找到根节点后找到其在中序遍历的位置,左边的就是左子树,右边的就是右子树,然后递归构建即可。为避免每次找中序遍历中根节点的位置,可以用 map
先存下映射。
速记:前序数组第一个是根节点,找到其在中序遍历的位置递归构建左右子树
代码:
class Solution {
Map<Integer, Integer> map = new HashMap<>();
public TreeNode buildTree(int[] preorder, int[] inorder) {
// 存储中序遍历中值到索引的映射,快速定位根节点在中序遍历的位置
for (int i = 0; i < inorder.length; i++) {
map.put(inorder[i], i);
}
return build(preorder, 0, preorder.length - 1,
inorder, 0, inorder.length - 1);
}
/*
前序遍历数组为 preorder[preStart..preEnd],
中序遍历数组为 inorder[inStart..inEnd],
构造这个二叉树并返回该二叉树的根节点
*/
TreeNode build(int[] preorder, int preStart, int preEnd,
int[] inorder, int inStart, int inEnd) {
if (preStart > preEnd) {
return null;
}
// root 节点对应的值就是前序遍历数组的第一个元素
int rootVal = preorder[preStart];
// rootVal 在中序遍历数组中的索引,避免扫描整个中序遍历结果
int index = map.get(rootVal);
int leftSize = index - inStart;
// 先构造出当前根节点
TreeNode root = new TreeNode(rootVal);
// 递归构造左右子树
root.left = build(preorder, preStart + 1, preStart + leftSize,
inorder, inStart, index - 1);
root.right = build(preorder, preStart + leftSize + 1, preEnd,
inorder, index + 1, inEnd);
return root;
}
}
8.14 437.路径总和 III🟡
题目:给定一个二叉树的根节点 root
,和一个整数 targetSum
,求该二叉树里节点值之和等于 targetSum
的 路径 的数目。
路径 不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。
示例 :
输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出:3
解释:和等于 8 的路径有 3 条,如图所示。
思路:
可以前序遍历每一个节点,计算以每个节点为根的路径个数相加。
更好的方法是用前缀和,减少冗余计算,类似 4.1 560.和为 K 的子数组🟡一样,用 HashMap
存前缀和的出现次数,结果就是当前节点和左右节点个数相加,注意要恢复现场。
速记:
HashMap
存前缀和,前序遍历节点统计结果,最后恢复现场
代码:
1.DFS
class Solution {
public int pathSum(TreeNode root, long targetSum) {
if (root == null) {
return 0;
}
// 前序遍历计算以每个节点为根的路径个数
int res = rootSum(root, targetSum);
res += pathSum(root.left, targetSum);
res += pathSum(root.right, targetSum);
return res;
}
// 返回以root为根节点和为targetSum的路径个数
public int rootSum(TreeNode root, long targetSum) {
int res = 0;
if (root == null) {
return 0;
}
int val = root.val;
if (val == targetSum) {
res++;
}
// 以子节点为根,和为targetSum - val的路径个数
res += rootSum(root.left, targetSum - val);
res += rootSum(root.right, targetSum - val);
return res;
}
}
2.前缀和
class Solution {
Map<Long, Integer> map = new HashMap<>();// 保存前缀和
int target;
public int pathSum(TreeNode root, int targetSum) {
target = targetSum;
map.put(0L, 1); // 前缀树为0的个数至少是一个
return dfs(root, 0L);
}
public int dfs(TreeNode root, long curSum) {
if (root == null) {
return 0;
}
curSum += root.val;
int res = 0;
res += map.getOrDefault(curSum - target, 0);
// 将当前前缀和的值保存
map.put(curSum, map.getOrDefault(curSum, 0) + 1);
int left = dfs(root.left, curSum); // 遍历左边
int right = dfs(root.right, curSum); // 遍历右边
res += left + right; // 加上左边和右边个数
// 遍历完一个节点的所有子节点后,将其从map中除去
map.put(curSum, map.get(curSum) - 1);
return res;
}
}
8.15 236.二叉树的最近公共祖先🟡
题目:给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
示例 :
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:5
解释:节点 5 和节点 4 的最近公共祖先是节点 5 。因为根据定义最近公共祖先节点可以为节点本身。
思路:
二叉树搜索题目,使用递归解决。如果遇到了 null
或 p
或 q
则应该 return
。分别往左子树和右子树去查找最近公共祖先。然后结果一层层向上 return
。
最终对于 root
,它的左右两个子树返回了查找到的结果。左子树没查到,说明结果在右子树中;右子树没查找,说明结果在左子树中;左右子树都查到,那 root
就是最近公共祖先。
代码:
class Solution {
// 函数功能: 1.p q都能找到 返回最近公共祖先 2. p q找到一个,返回p q 3. 都没找到 返回null
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 如果根节点为空,或者根节点是p或q中的一个,直接返回根节点的值
if (root == null || root == p || root == q) {
return root;
}
// 在左子树中查找最近公共祖先
TreeNode left = lowestCommonAncestor(root.left, p, q);
// 在右子树中查找最近公共祖先
TreeNode right = lowestCommonAncestor(root.right, p, q);
// 如果左子树为空,说明p和q都不在左子树中,返回右子树的结果
if (left == null) {
return right;
}
// 如果右子树为空,说明p和q都不在右子树中,返回左子树的结果
if (right == null) {
return left;
}
// 如果左子树和右子树都返回了节点,说明p和q分别在root的两侧,root即为最近公共祖先
return root;
}
}
8.16 124.二叉树中的最大路径和🔴
题目:二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。
路径和 是路径中各节点值的总和。
给你一个二叉树的根节点 root
,返回其 最大路径和 。
示例 :
输入:root = [-10,9,20,null,null,15,7]
输出:42
解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42
思路:
和 8.5 543.二叉树的直径🟢 类似,求直径就是求每个节点的最大深度,在所有节点中根据最大深度求最大路径长度。
本题求的是最大路径和,对于某一节点的最大路径和就是节点值+左节点最大贡献+右节点最大贡献。最大贡献就是节点作为最大路径和的一部分向上能提供多大贡献。比如 20
作为根节点最大路径和是 20+左贡献+右贡献=20+15+7
,而 20
向上能提供 20+15
的贡献,注意不能是 20+15+7
,因为路径中 20
只能有一条边,所以要在 15
和 7
中选一个大的和自己组成最大贡献。因为还有负数在,若是最大贡献求得是负数,那最大贡献换成 0
也就是不取这个节点作为路径一部分。
速记:最大路径和就是所有节点的左右贡献值加当前节点值取最大
代码:
class Solution {
int res = Integer.MIN_VALUE; // 有可能全为负数
public int maxPathSum(TreeNode root) {
dfs(root);
return res;
}
private int dfs(TreeNode node) {
if (node == null) {
return 0;
}
int left = dfs(node.left); // 左子树最大贡献值
int right = dfs(node.right); // 右子树最大贡献值
// 后序遍历,计算所有节点的最大路径和,求最大
res = Math.max(res, left + right + node.val);
// 当前节点的最大贡献,如20组成的最大贡献是 20+15,能给-10提供35的贡献
return Math.max(Math.max(left, right) + node.val, 0);
}
}
9 图论
9.1 200.岛屿数量🟡
题目:给你一个由 '1'
(陆地)和 '0'
(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
链接:200. 岛屿数量
示例 :
输入:grid = [
["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
输出:3
思路:
遍历二维网格,遇到陆地时,同时将其和相邻的陆地用 dfs
都淹没掉(置为 0
)。这样一次遍历遇到的 1
的数量就是所有岛屿的数量。
二维矩阵的 DFS 和二叉树的遍历类似
代码:
class Solution {
// 主函数,计算岛屿数量
public int numIslands(char[][] grid) {
int res = 0;
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (grid[i][j] == '1') {
res++; // 每发现一个岛屿,岛屿数量加一
dfs(grid, i, j); // 然后使用 dfs 将其相邻的岛屿淹了
}
}
}
return res;
}
// 从 (i, j) 开始,将与之相邻的陆地都变成海水
void dfs(char[][] grid, int i, int j) {
// 超出索引边界
if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length) {
return;
}
// 已经是海水了
if (grid[i][j] == '0') {
return;
}
// 将 (i, j) 变成海水
grid[i][j] = '0';
// 淹没上下左右的陆地
dfs(grid, i - 1, j);
dfs(grid, i + 1, j);
dfs(grid, i, j - 1);
dfs(grid, i, j + 1);
}
}
9.2 994.腐烂的橘子🟡
题目:在给定的 m x n
网格 grid
中,每个单元格可以有以下三个值之一:
- 值
0
代表空单元格; - 值
1
代表新鲜橘子; - 值
2
代表腐烂的橘子。
每分钟,腐烂的橘子 周围 4 个方向上相邻 的新鲜橘子都会腐烂。
返回 直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能,返回 -1
。
链接:994. 腐烂的橘子
示例 :
输入:grid = [[2,1,1],[1,1,0],[0,1,1]]
输出:4
思路:
橘子每次使周围橘子腐烂的过程可以看做是一次 BFS
,路径长度就是腐烂时间。但题目中一开始有多个腐烂的橘子,即有多个起点。可以想象这些橘子是由一个虚拟橘子感染得来,属于同一层,也就是可以把这些腐烂橘子初始化放到队列中进行广搜即可。
速记:腐烂橘子初始放进队列,然后广搜感染橘子记录步数
代码:
class Solution {
public int orangesRotting(int[][] grid) {
// 1.记录腐烂橘子的上下左右位置
int[] dx = { -1, 1, 0, 0 };
int[] dy = { 0, 0, -1, 1 };
int step = 0, flash = 0;// 遍历步数和新鲜橘子数(后面用于判定是否为-1)
int m = grid.length, n = grid[0].length;// 列
// 2.遍历矩阵 将所有的腐烂橘子入队,并且记录初始新鲜橘子数
Queue<int[]> queue = new LinkedList<>();
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 1) {
flash++; // 新鲜橘子的个数
}
if (grid[i][j] == 2) {
queue.offer(new int[] { i, j }); // 一开始就腐烂的橘子的坐标
}
}
}
// 3.遍历所有腐烂橘子,同时感染四周
// flash必须大于0不然已经没新鲜橘子了step还会继续增加
while (flash > 0 && !queue.isEmpty()) { // 有橘子且队列不空
step++; // 经过一分钟
// 队列中现有的所有腐烂橘子都要进行一次感染
int size = queue.size();
for (int s = 0; s < size; s++) {
int[] pos = queue.poll();// 腐烂橘子
for (int i = 0; i < 4; i++) {
// 4个位置dx[i] dy[i] , xy 为要感染的橘子位置
int x = pos[0] + dx[i];// 第x行
int y = pos[1] + dy[i];// 第y列
if ((x >= 0 && x < m) && (y >= 0 && y < n) && grid[x][y] == 1) {
// xy不越界,并且要感染的地方是 新鲜橘子
grid[x][y] = 2; // 变成腐烂橘子
// 把被感染的橘子 入队
queue.offer(new int[] { x, y });
flash--;// 新鲜橘子-1
}
}
}
}
// 4.感染完了之后如果还有新鲜橘子说明不可能
return flash > 0 ? -1 : step;
}
}
9.3 207. 课程表🟡
题目:你这个学期必须选修 numCourses
门课程,记为 0
到 numCourses - 1
。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites
给出,其中 prerequisites[i] = [ai, bi]
,表示如果要学习课程 ai
则 必须 先学习课程 bi
。
- 例如,先修课程对
[0, 1]
表示:想要学习课程0
,你需要先完成课程1
。
请你判断是否可能完成所有课程的学习?如果可以,返回 true
;否则,返回 false
。
链接:207. 课程表
示例 :
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。
思路:
多个课程之间存在先后关系,可以看成一个有向图。求是否可以学习完所有课程也就是能不能找到拓扑排序,也就是存不存在环。[1,0]
表示先学课程 0
才能学课程 1
,也就是课程 0
到课程 1
添加一条边。环检测可以用 DFS
也可以用 BFS
,我这里用 BFS
更容易理解点。
如果存在可能的学习顺序,那肯定是先学没有前置课程的课,也就是入度为 0
的节点。
- 构建邻接表,
from
到to
的边表示先学from
才能学to
indegree[i]
记录节点i
的入度- 队列中初始化存放入度为
0
的节点。 - 从队列中取出节点
cur
,对节点cur
连接的节点入度都-1
,如果发现了新的入度为0
的节点,表示可以学习该课程了,将其加入队列。重复此过程,直到队列为空。 - 记录最终遍历到的节点数也就是从队列弹出的节点数,若其等于总课程数说明可以完成所有课程的学习。
如下图灰色节点最先入队,因为其初始入度为 0
。当其离队后,将与其连接的边删去,也就是连接的节点入度 -1
,然后将新的入度为 0
的节点加入到队列。
速记:构建有向图,队列初始存放入度为
0
的节点, 判断有没有环
代码:
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 建图并计算节点入度
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; ++i) {
graph.add(new ArrayList<>());
}
int[] indegree = new int[numCourses];
for (int[] info : prerequisites) {
graph.get(info[1]).add(info[0]); // 添加从info[1]指向info[0]的边
indegree[info[0]]++; // info[0]入度+1
}
// 根据入度初始化队列中的节点
Queue<Integer> queue = new LinkedList<Integer>();
for (int i = 0; i < numCourses; ++i) {
if (indegree[i] == 0) {
queue.offer(i);
}
}
// BFS遍历有向图
int count = 0; // 记录遍历的节点个数
while (!queue.isEmpty()) {
// 弹出节点cur,计数+1
int cur = queue.poll();
count++;
// 将cur指向的节点的入度都-1
for (int next : graph.get(cur)) {
indegree[next]--;
// 如果next入度变为0,说明next依赖的节点都已被遍历
if (indegree[next] == 0) {
queue.offer(next);
}
}
}
return count == numCourses;
}
}
9.4 208.实现 Trie (前缀树)🟡
题目:Trie(发音类似 “try”)或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。
请你实现 Trie 类:
Trie()
初始化前缀树对象。void insert(String word)
向前缀树中插入字符串word
。boolean search(String word)
如果字符串word
在前缀树中,返回true
(即,在检索之前已经插入);否则,返回false
。boolean startsWith(String prefix)
如果之前已经插入的字符串word
的前缀之一为prefix
,返回true
;否则,返回false
。
注: word
和 prefix
仅由小写英文字母组成
示例 :
输入
["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
[[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]
输出
[null, null, true, false, true, null, true]
解释
Trie trie = new Trie();
trie.insert("apple");
trie.search("apple"); // 返回 True
trie.search("app"); // 返回 False
trie.startsWith("app"); // 返回 True
trie.insert("app");
trie.search("app"); // 返回 True
思路:
字典树的每个节点存放指向下一个字符的指针数组和 isEnd
。因为只存在小写字母,所以 next
初始大小为 26
即可,如 0
代表字符 a
。而 isEnd
表示该节点在字典树中是否为最后一个节点。
对于插入操作,如 sea
假设现在字典树已经存在 se
。那么从根节点找 s
和 e
时发现已存在那就直接移到下一个,移到 e
发现 e
后面的 a
不存在那就创建个节点存 a
,发现 a
是待插入字符结尾了,所以设置 isEnd
。
查找字符串和查找前缀可以用一个查找前缀函数来解决。searchPrefix
表示查找是否存在前缀 word
,若不存在返回 null
,若存在返回 word
的最后一个节点。搜索一个单词在不在字典树,不仅其前缀要在,而且最后一个字符要是 end
。如字典树存在 sea
,搜索 se
发现字典树虽然存在 se
,但 e
所在的节点并不是 end
,所以 startsWith
为 true
而 search
为 false
速记:定义字典树节点,存
next
指针数组和isEnd
,新增查找前缀函数
代码:
class Trie {
private Trie[] next; // 记录下一位的字符
private boolean isEnd; // 该字符是否为最后一个
public Trie() {
next = new Trie[26]; // 初始化26个字符
isEnd = false; // 默认为不是最后一个字符
}
public void insert(String word) {
Trie node = this; // 得到字典树根节点
for (char c : word.toCharArray()) {
int index = c - 'a'; // 得到该字符在数组中的坐标
// 如果该字符在上一个节点的数组坐标中没有记录,新建一个节点
if (node.next[index] == null) {
node.next[index] = new Trie();
}
// 每次都移动指针到下一个字符节点
node = node.next[index];
}
//最后一个字符节点设置为end
node.isEnd = true;
}
public boolean search(String word) {
// 返回检索到的最后一个字符节点
Trie node = searchPrefix(word);
// 只有单词在字典树中存在并且单词的最后一个字符是字典树的end,才返回true
return node != null && node.isEnd;
}
public boolean startsWith(String prefix) {
// 只要前缀匹配存在于字典树中就返回true
return searchPrefix(prefix) != null;
}
// 存在返回word对应的最后一个字符节点,不存在返回null
public Trie searchPrefix(String word) {
Trie node = this;
for (char c : word.toCharArray()) {
int index = c - 'a';
// 没有找到待搜索的其中一个字母节点,返回null表示不存在
if (node.next[index] == null) {
return null;
}
// 存在就移动指针到下一个字符节点
node = node.next[index];
}
// 返回最后一个待搜索的字符节点(节点可能是end也可能不是)
return node;
}
}
10 回溯
10.1 46.全排列🟡
题目:给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
链接:46. 全排列
示例 :
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
思路:
当路径 path
个数为 nums
时添加到 res
,但注意要 new
一个 path
,不然加的是 path
的引用,后面 path
的改变会影响到 res
排列问题可以往前选,所以循环从 0
开始,但从 0
开始,排列选出的元素又不能重复,所以要用 used
数组来判断之前选过了没。
代码:
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
boolean[] used = new boolean[nums.length];
backtrack(nums, used);
return res;
}
private void backtrack(int[] nums, boolean[] used) {
// 到达叶子节点
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
// 注意从0开始,可以往回选
// [1,2]中可以选择[1,2]也可以是[2,1]
for (int i = 0; i < nums.length; i++) {
// 因为从0开始,所以会重复选择元素,用used去重
if (used[i])
continue;
path.add(nums[i]);
used[i] = true;
backtrack(nums, used);
path.removeLast();
used[i] = false;
}
}
}
10.2 78.子集🟡
题目:给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
链接:78. 子集
示例 :
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
思路:
子集问题无重复不可复选,无重复就不需要去重,不可复选那每次必须选后面的元素,所以 i
从 start
开始,backtarck
函数的参数是 i+1
速记:子集无重不可复选,
i
从start
开始,backtarck
传参i+1
代码:
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
backtrack(nums, 0);
return res;
}
public void backtrack(int[] nums, int start) {
// 不是只记录叶子节点,而是记录所有节点,所以不用if判断
res.add(new LinkedList<>(path));
for (int i = start; i < nums.length; i++) { // 不能往前选所以是 start
path.add(nums[i]);
backtrack(nums, i + 1); // 不能复选所以是 i+1
path.removeLast();
}
}
}
10.3 17.电话号码的字母组合🟡
题目:给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例 :
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
思路:
输入的是数字,数字和字符之间有映射关系,可以使用字符串数组保存对应的映射关系
注意给的输出只能往后选,也就是必须先选 2
里面的一个再选 3
里面的一个,所以 i
从 start
开始。这里需要两层 for
循环,第一层遍历所有数字,第二层遍历数字对应的字符串。注意下 StringBuffer
的相关函数。
速记:先遍历所有数字,再遍历数字对应的字符串
代码:
class Solution {
List<String> res = new ArrayList<>();
StringBuffer path = new StringBuffer();
String[] s = { "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" };
public List<String> letterCombinations(String digits) {
if (digits.isEmpty()) {
return res;
}
backtrack(digits, 0); // 从 digits[0] 开始进行回溯
return res;
}
public void backtrack(String digits, int start) {
// 到达回溯树底部
if (path.length() == digits.length()) {
res.add(path.toString());
return;
}
// 选一个digit代表的层,如2(abc)
for (int i = start; i < digits.length(); i++) {
int idx = digits.charAt(start) - '0';
// 选一个digit层里面的字符,如c
for (char c : s[idx].toCharArray()) {
path.append(c);
backtrack(digits, i + 1); // 递归下一层回溯树
path.deleteCharAt(path.length() - 1);
}
}
}
}
10.4 39.组合总和🟡
题目:给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target
的不同组合数少于 150
个。
链接:39. 组合总和
示例 :
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
思路:
只能往后选所以循环 i
从 start
开始,如只能是 2 3
而不能是 3 2
。但对于当前元素可以复选,所以 backtrack
传参 i
表示下次还可以选 i
位置。
速记:往后选所以循环
i
从start
开始,当前元素可以复选所以backtrack
传参i
代码:
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtrack(candidates, target, 0, 0);
return res;
}
private void backtrack(int[] candidates, int target, int curSum, int start) {
if (curSum > target) {
return;
}
if (curSum == target) {
res.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
path.add(candidates[i]);
// 当前元素可重复选所以传的是i不是i+1
backtrack(candidates, target, curSum + candidates[i], i);
path.removeLast();
}
}
}
10.5 22. 括号生成🟡🔥
题目:数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
链接:22. 括号生成
示例 :
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
思路:
生成一个 2n
的字符串且括号有效。若不考虑是否有效,那就是直接 append('(')
再回溯,然后 append(')')
再回溯。这会把所有长度为 2n
的字符串列出来,需要筛选出有效的字符串。
一种方法是得到所有字符串后再判断该字符串是否有效,但更好的方法是添加括号的时候就判断是否可以添加。如果 left < n
说明还可以继续放左括号。对于答案的任一位置一定是左括号的数量大于等于右括号的数量,所以如果 right < left
那么可以放右括号。
速记:记录左括号和右括号数量,满足条件先放左再放右
代码:
class Solution {
List<String> res = new ArrayList<String>();
StringBuilder path = new StringBuilder();
public List<String> generateParenthesis(int n) {
backtrack(n, 0, 0);
return res;
}
public void backtrack(int n, int left, int right) {
if (path.length() == n * 2) {
res.add(path.toString());
return;
}
// 可以放左括号
if (left < n) {
path.append('(');
backtrack(n, left + 1, right);
path.deleteCharAt(path.length() - 1);
}
// 可以放右括号(答案的任一位置一定是左括号数量大于等于右括号)
if (right < left) {
path.append(')');
backtrack(n, left, right + 1);
path.deleteCharAt(path.length() - 1);
}
}
}
10.6 79.单词搜索🟡
题目:给定一个 m x n
二维字符网格 board
和一个字符串单词 word
。如果 word
存在于网格中,返回 true
;否则,返回 false
。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
链接:79. 单词搜索
示例 :
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true
思路:
在二维数组中搜索单词,那肯定是用 dfs
。在搜索的时候要注意不能走回头路,所以要用一个 visitd
数组来标记已访问的元素。访问完一个元素的四周要回溯设置为未访问。因为不确定从哪里开始 dfs
,所以需要遍历二维数组,以每个元素都为起始点尝试下。
速记:从每一元素开始
dfs
,标记已访问,再向四周dfs
代码:
class Solution {
boolean found = false;
public boolean exist(char[][] board, String word) {
int m = board.length, n = board[0].length;
boolean[][] visited = new boolean[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
dfs(board, i, j, word, 0, visited);
if (found) {
return true;
}
}
}
return false;
}
// 从 (i, j) 开始向四周搜索,试图匹配 word[p..]
void dfs(char[][] board, int i, int j, String word, int p, boolean[][] visited) {
// 整个 word 已经被正确匹配完,找到了一个答案
if (p == word.length()) {
found = true;
return;
}
// 下标是否越界
int m = board.length, n = board[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
return;
}
// 已经找到了答案 || 访问过 || 不相等
if (found || visited[i][j] || board[i][j] != word.charAt(p)) {
return;
}
visited[i][j] = true; // 标记已访问,避免走回头路
// word[p] 被 board[i][j] 匹配,开始向四周搜索 word[p+1..]
dfs(board, i + 1, j, word, p + 1, visited);
dfs(board, i, j + 1, word, p + 1, visited);
dfs(board, i - 1, j, word, p + 1, visited);
dfs(board, i, j - 1, word, p + 1, visited);
visited[i][j] = false; // 取消标记
}
}
10.7 131.分割回文串🟡
题目:给你一个字符串 s
,请你将 s
分割成一些子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。
链接:131. 分割回文串
示例 :
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
思路:
首先不考虑分割的是回文字符,先考虑分割字符能分割成多少种方式。dfs(start)
表示当前搜索到了 start
,那只需要枚举结束位置 i
,把 s[start..i]
加到 path
即可。
那么现在考虑要分割成回文,也就是不能随便分割了,那其实不就是枚举结束位置的时候判断下 s[start..i]
是不是回文,是回文才继续搜索。
速记:分割字符串的基础上添加是否是回文判断
代码:
class Solution {
private List<List<String>> res = new ArrayList<>();
private List<String> path = new LinkedList<>();
private String s;
public List<List<String>> partition(String s) {
this.s = s;
dfs(0);
return res;
}
private boolean isPalindrome(int left, int right) {
while (left < right) {
if (s.charAt(left++) != s.charAt(right--)) {
return false;
}
}
return true;
}
private void dfs(int start) {
if (start == s.length()) { // 搜索到结束位置
res.add(new ArrayList<>(path)); // 复制 path
return;
}
for (int i = start; i < s.length(); i++) { // 枚举子串的结束位置i
// 关键语句:判断s[start..i]是否为回文
if (isPalindrome(start, i)) {
path.add(s.substring(start, i + 1));
dfs(i + 1); // 注意不是start+1,要从i+1开始向后搜
path.removeLast(); // 恢复现场
}
}
}
}
10.8 51.N 皇后🔴
题目:按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n
个皇后放置在 n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n
,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q'
和 '.'
分别代表了皇后和空位。
链接:51. N 皇后
示例 :
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
思路:
首先一行只能放一个皇后,所以对于每一行遍历每一列判断是否可以放皇后。如果当前列可以放皇后,继续递归遍历下一行,判断下一行哪一列可以放皇后,直到所有行都放了有效的皇后,即找到了一个有效的结果。
为了方便赋值可以先把棋盘初始化成一个二维 char
数组,最后将其转换成结果要求的列表的形式。而由于一行只放了一个皇后,所以判断当前放的位置是否有效时,只需判断当前列,和对角线是否已经有皇后。
本题代码虽然多,但核心代码就是很标准的回溯那几行,即在当前列放了后,递归下一行再回溯。只不过要加个判断位置是否有效的判断,若是不加判断就是把所有情况都列出来。
速记:遍历二维数组棋盘每一列,若当前位置有效则放皇后、递归下一行、回溯
代码:
// 棋盘是个n×n的数组,因此可以将第i行看成树的第i层
// 第j列看成树的第j个分支
class Solution {
List<List<String>> res = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
// 填充棋盘元素为.
char[][] chessboard = new char[n][n];
for (char[] cArr : chessboard) {
Arrays.fill(cArr, '.');
}
backtrack(n, chessboard, 0);
return res;
}
public void backtrack(int n, char[][] chessboard, int row) {
if (row == n) {
// 数组转列表
List<String> sList = new ArrayList<>();
for (char[] cArr : chessboard) {
sList.add(String.valueOf(cArr));
}
res.add(sList);
}
// 核心代码
for (int col = 0; col < n; col++) {
if (isValid(n, chessboard, row, col)) { // 这里若没判断则变成把所有情况列出来
chessboard[row][col] = 'Q';
backtrack(n, chessboard, row + 1);
chessboard[row][col] = '.';
}
}
}
public boolean isValid(int n, char[][] chessboard, int row, int col) {
// 检查列
for (int i = 0; i < row; i++) {
if (chessboard[i][col] == 'Q') {
return false;
}
}
// 检查左上角
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
// 检查右上角
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
}
11 二分查找
11.1 35. 搜索插入位置🟢
题目:给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为 O(log n)
的算法。
链接:35. 搜索插入位置
示例 :
输入: nums = [1,3,5,6], target = 2
输出: 1
思路:
有序数组找位置显然是用二分查找。我个人习惯是用闭区间,所以注意 while
循环中 left==right
时也是有效循环,不然不就漏掉 left==right
的情况了吗
至于为什么最后没找到返回的是 left
。下次遇到这种情况建议直接举个例子试试。
如 target=2
,现在找到了 1 3
,left=0,right=1
,那 mid=0
。target>nums[mid]
,所以 left=right=mid=1
。此时 target<nums[mid]
,所以 right=0,left=1
,所以 left=1
便是要插入的位置。
代码:
class Solution {
public int searchInsert(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 闭区间,所以left=right也是有效值
while (left <= right) {
int mid = left + (right - left) / 2;
if (target == nums[mid]) {
return mid;
} else if (target > nums[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left; // 举个例子试下
}
}
11.2 74. 搜索二维矩阵🟡
题目:给你一个满足下述两条属性的 m x n
整数矩阵:
- 每行中的整数从左到右按非严格递增顺序排列。
- 每行的第一个整数大于前一行的最后一个整数。
给你一个整数 target
,如果 target
在矩阵中,返回 true
;否则,返回 false
。
链接:74. 搜索二维矩阵
示例 :
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3
输出:true
思路:
二维矩阵每一行都是递增的,可以看做是一维递增的数组。一维递增数组查找元素那就可以用二分查找来查,只需要根据一维数组的 mid
求出二维数组对应的 mid
元素即可。
速记:二分查找,根据一维的
mid
转换成二维对应元素
代码:
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
int m = matrix.length, n = matrix[0].length;
int l = 0, r = m * n - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
// 根据mid求出二维数组的mid位置元素
int x = matrix[mid / n][mid % n];
if (x < target) {
l = mid + 1;
} else if (x > target) {
r = mid - 1;
} else {
return true;
}
}
return false;
}
}
11.3 34.在排序数组中查找元素的第一个和最后一个位置🟡
题目:给你一个按照非递减顺序排列的整数数组 nums
,和一个目标值 target
。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target
,返回 [-1, -1]
。
你必须设计并实现时间复杂度为 O(log n)
的算法解决此问题。
示例 :
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
思路:
找到开始位置也就是找到第一个 >= target
的位置,而找到结束位置也就是找到第一个 >= target+1
的位置再 -1
就是结束位置。所以写二分查找函数时返回的是第一个 >= target
的位置。
速记:结束位置就是查找第一个
>= target+1
的位置再-1
代码:
class Solution {
public int[] searchRange(int[] nums, int target) {
int start = lowerBound(nums, target);
// 所有数都 < taregt 或者 第一个 >= target的元素不是target
if (start == nums.length || nums[start] != target) {
return new int[]{-1, -1};
}
// 如果 start 存在,那么 end 必定存在
int end = lowerBound(nums, target + 1) - 1;
return new int[]{start, end};
}
// 返回满足 nums[i] >= target 的最左边的i
private int lowerBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// mid = left-1 即 nums[left-1] < target
left = mid + 1;
} else {
// mid = right+1 即 nums[right+1] >= target
right = mid - 1;
}
}
return left; // 返回right + 1也可以
}
}
11.4 33. 搜索旋转排序数组🟡
题目:整数数组 nums
按升序排列,数组中的值 互不相同 。
在传递给函数之前,nums
在预先未知的某个下标 k
(0 <= k < nums.length
)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
(下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7]
在下标 3
处经旋转后可能变为 [4,5,6,7,0,1,2]
。
给你 旋转后 的数组 nums
和一个整数 target
,如果 nums
中存在这个目标值 target
,则返回它的下标,否则返回 -1
。
你必须设计一个时间复杂度为 O(log n)
的算法解决此问题。
链接:33. 搜索旋转排序数组
示例 :
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
思路:
若不考虑时间复杂度,遍历一遍挨个比较就行。时间复杂度为 O(log n)
,那还是要用二分。但由于并不是有序,不能直接用。
可以注意到 mid
分成的两边,肯定有一边是有序的;如果 target
刚好也在这个有序的中间,那可以用二分查找,否则就去另一边继续划分;另一边划分后,肯定也有一边是有序的。
代码:
class Solution {
public int search(int[] nums, int target) {
int n = nums.length;
int l = 0, r = n - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] == target) {
return mid;
}
// 左半有序 注意带等于号 对应 l=mid r=l+1
if (nums[l] <= nums[mid]) {
// target在左半区间内
if (nums[l] <= target && target <= nums[mid]) {
r = mid - 1;
} else {
l = mid + 1;
}
} else {
// 右半有序,且target在右半区间内
if (nums[mid] <= target && target <= nums[r]) {
l = mid + 1;
} else {
r = mid - 1;
}
}
}
return -1;
}
}
11.5 153.寻找旋转排序数组中的最小值🟡
腾讯一面(未做出) - 想到用二分和最后一个比,但具体不知道怎么写
题目:已知一个长度为 n
的数组,预先按照升序排列,经由 1
到 n
次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7]
在变化后可能得到:
- 若旋转
4
次,则可以得到[4,5,6,7,0,1,2]
- 若旋转
7
次,则可以得到[0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]]
旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]]
。
给你一个元素值 互不相同 的数组 nums
,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
你必须设计一个时间复杂度为 O(log n)
的算法解决此问题。
示例 :
输入:nums = [3,4,5,1,2]
输出:1
解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。
思路:
限制了时间复杂度,所以应该用二分查找。以最后一个元素 nums[n-1]
为基准值,在 [0,n-2]
区间内搜索。若 nums[mid] < nums[n - 1]
说明 [mid,n-1]
是递增的,既然这部分区间都递增了,那最小值肯定在 mid
左区间。否则,如 3,4,5,1,2
中 4 > 2
,说明旋转点在 mid
右边,也就是最小值 1
在 mid
右区间。最后的返回值可以举例试一下。
速记:
nums[n-1]
作为基准值,在[0,n-2]区间搜索缩小区间
代码:
class Solution {
public int findMin(int[] nums) {
int n = nums.length;
int l = 0, r = n - 2; // 在[0,n-2]区间搜索
while (l <= r) {
int mid = l + (r - l) / 2;
// 以最右侧的值为基准值
// 如果右半有序,说明[mid,n-1]都是递增的
if (nums[mid] < nums[n - 1]) {
// 最小值在左区间
r = mid - 1;
} else {
// 最小值在右区间
l = mid + 1;
}
}
return nums[l];
}
}
11.6 4.寻找两个正序数组的中位数🔴
题目:给定两个大小分别为 m
和 n
的正序(从小到大)数组 nums1
和 nums2
。请你找出并返回这两个正序数组的 中位数 。
算法的时间复杂度应该为 O(log (m+n))
。
示例 :
输入:nums1 = [1,3,4,9], nums2 = [1,2,3,4,5,6,7,8,9]
输出:4.00000
思路:
要求时间复杂度是 log
,log
那一般就是二分。求中位数其实就是求两个数组中第 k
小的元素,由于两个数组已经有序,比较两个数组第 k/2
个元素的大小,排除掉较小数组的前 k/2
个元素。
如 nums1
有 4
个元素,nums2
有 9
个元素,那中位数就是第 k=7
小的元素。比较两个数组第 3
个元素,发现 4>3
也就是 nums2
的前 k/2
个元素 1 2 3
都不可能是第 7
小的元素。排除掉 3
个元素后,现在 nums2=[3 4 5 6 7 8]
, 变成了求第 4
小的元素,重复上述过程。
速记:中位数就是求第
k
小的元素,每次比较k/2
位置元素大小,排除一个数组k/2
个元素
代码:
class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int length1 = nums1.length, length2 = nums2.length;
int totalLength = length1 + length2;
if (totalLength % 2 == 1) {
int midIndex = totalLength / 2;
return getKthElement(nums1, nums2, midIndex + 1);
} else {
int midIndex1 = totalLength / 2 - 1, midIndex2 = totalLength / 2;
return (getKthElement(nums1, nums2, midIndex1 + 1) + getKthElement(nums1, nums2, midIndex2 + 1)) / 2.0;
}
}
// 找两个数组中第k(从1开始)小的元素
public int getKthElement(int[] nums1, int[] nums2, int k) {
int length1 = nums1.length, length2 = nums2.length;
int index1 = 0, index2 = 0;
int kthElement = 0;
while (true) {
// 边界情况
if (index1 == length1) { // 第一个数组已经都排除了
return nums2[index2 + k - 1];
}
if (index2 == length2) {
return nums1[index1 + k - 1];
}
if (k == 1) { // 比较两个数组第一个位置即可
return Math.min(nums1[index1], nums2[index2]);
}
// 正常情况,每次排除k/2个元素
// 比较两个数组第k/2个元素大小,也就是k/2-1位置
int half = k / 2;
// 防止newIndex超数组边界,所以和length取最小
int newIndex1 = Math.min(index1 + half, length1) - 1;
int newIndex2 = Math.min(index2 + half, length2) - 1;
int pivot1 = nums1[newIndex1], pivot2 = nums2[newIndex2];
if (pivot1 <= pivot2) {
k -= k/2; //把这轮排除掉的元素减去
index1 = newIndex1 + 1; //前newIndex1个元素已经排除了,更新index1位置
} else {
k -= k/2;
index2 = newIndex2 + 1;
}
}
}
}
12 栈
12.1 20.有效的括号🟢
题目:给定一个只包括 '('
,')'
,'{'
,'}'
,'['
,']'
的字符串 s
,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
链接:20. 有效的括号
示例 :
输入:s = "([}}])"
输出:false
思路:
括号问题就用栈做。遇到左括号就入栈,遇到右括号就和栈顶元素比较下。如果当前都不是有效的左右括号,那肯定不是有效括号,可直接输出 false
。如果当前是有效左右括号,就弹出栈顶的左括号。
代码:
class Solution {
public boolean isValid(String s) {
int n = s.length();
if (n % 2 == 1) {
return false;
}
Map<Character, Character> map = new HashMap<>();
map.put(')', '(');
map.put(']', '[');
map.put('}', '{');
Deque<Character> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
char ch = s.charAt(i);
// 不是右括号就push
if (map.containsKey(ch)) {
// 栈空了或者当前右括号和栈顶不匹配 可判断是无效括号
if (stack.isEmpty() || stack.peek() != map.get(ch)) {
return false;
}
// 当前是有效括号,pop左括号
stack.pop();
} else {
stack.push(ch);
}
}
return stack.isEmpty(); // 可能会剩几个左括号
}
}
12.2 155.最小栈🟡
题目:设计一个支持 push
,pop
,top
操作,并能在常数时间内检索到最小元素的栈。
实现 MinStack
类:
MinStack()
初始化堆栈对象。void push(int val)
将元素val推入堆栈。void pop()
删除堆栈顶部的元素。int top()
获取堆栈顶部的元素。int getMin()
获取堆栈中的最小元素。
链接:155. 最小栈
示例 :
输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]
输出:
[null,null,null,null,-3,null,0,-2]
思路:
普通的栈无法确定最小值是哪个,如果用一个变量存最小值,每次 push
更新最小值。但万一 pop
的是最小值,就没办法更新最小值。
既然用一个变量没办法记录最小值,那可以用另一个栈 minStack
的栈顶保存当前栈的最小值。stack
入栈出栈时 minStack
也入栈出栈。
速记:
minStack
栈顶记录当前栈最小值,同步出入栈
代码:
class MinStack {
Deque<Integer> stack;
Deque<Integer> minStack; // 栈顶是当前stack内最小值
public MinStack() {
stack = new LinkedList<Integer>();
minStack = new LinkedList<Integer>();
minStack.push(Integer.MAX_VALUE); // 方便第一次push存最小值
}
public void push(int x) {
stack.push(x);
minStack.push(Math.min(minStack.peek(), x));
}
public void pop() {
stack.pop();
minStack.pop();
}
public int top() {
return stack.peek();
}
public int getMin() {
return minStack.peek();
}
}
12.3 394.字符串解码🟡
TODO:暂时不熟
题目:给定一个经过编码的字符串,返回它解码后的字符串。
编码规则为: k[encoded_string]
,表示其中方括号内部的 encoded_string
正好重复 k
次。注意 k
保证为正整数。
你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。
此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k
,例如不会出现像 3a
或 2[4]
的输入。
链接:394. 字符串解码
示例 :
输入:s = "3[a2[c]]"
输出:"accaccacc"
思路:
难点在于括号内嵌套括号,需要由内向外拼接字符,这与栈的先入后出特性对应
构建两个辅助栈,kStack
用于存数字,resStack
用于存字符。遇到左括号,记录当前 k
和 res
入栈,并清零。而遇到右括号,如 a[2[c]]
此时要出栈开始拼接字符了,先记录当前倍数,再拼接字符,即 2*c=cc
,然后再和括号外的字符即 resStack
弹出的一个字符合并
速记:数字栈和字符栈,左括号都入栈,右括号都出栈
代码:
class Solution {
public String decodeString(String s) {
int k = 0; // 当前数字
StringBuilder res = new StringBuilder();
Deque<Integer> kStack = new LinkedList<>(); // 存放数字
Deque<StringBuilder> resStack = new LinkedList<>(); // 存放字符
for (char c : s.toCharArray()) { // 如a[2[c]]
if (c == '[') { // a[2[ c]]
//碰到左括号,记录k和当前res,并归零
kStack.push(k); // 2
resStack.push(res); // a
k = 0;
res = new StringBuilder();
} else if (c == ']') {
//出最近的一个k,当前res进行计算不入栈 a[2[c]]
int curK = kStack.pop(); // 2
StringBuilder tmp = new StringBuilder();
for (int i = 0; i < curK; i++) {
tmp.append(res); // = curK * res = cc
}
//与括号外合并
res = resStack.pop().append(tmp); // a+cc=acc
} else if (c >= '0' && c <= '9') {
//如果k是多位数需要x10 如12那就是12倍
k = c - '0' + k * 10;
} else {
//如果是字母则缓慢添加 a[2[c]]
res.append(c); // res+=a
}
}
return res.toString();
}
}
12.4 739.每日温度🟡
题目:给定一个整数数组 temperatures
,表示每天的温度,返回一个数组 answer
,其中 answer[i]
是指对于第 i
天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0
来代替。
链接:739. 每日温度
示例 :
输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]
思路:
单调栈典型场景:在一维数组中对每个数往左或往右找到第一个比自己小或比自己大的元素
要找到下一个比当前元素大的元素的下标,可以用一个单调递减的单调栈,从栈底到栈顶单调递减。当有元素入栈前,若当前元素比栈顶大,那入栈后就不满足单调递减了,所以要把栈顶不断出栈直到满足单调递减。而栈顶元素出栈不就意味着找到了比栈顶大的第一个元素了吗,此时更新 res
,然后把当前元素入栈。
速记:找第一个更大则维护单调递减栈,若当前更大则弹栈更新
res
代码:
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] res = new int[n];
// 求更大所以是单调递减栈,因为求下标差值,所以存储下标
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
int temperature = temperatures[i];
// 满足递减需要把小于当前元素的栈顶不断弹出
while (!stack.isEmpty() && temperature > temperatures[stack.peek()]) {
int index = stack.pop();
// 元素弹出的时候就是找到了第一个比他大的元素,不然之前就弹出了
res[index] = i - index;
}
stack.push(i); // 此时满足单调递减了入栈
}
return res;
}
}
12.5 84.柱状图中最大的矩形🔴
题目:给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例 :
输入:heights = [2,1,5,6,2,3]
输出:10
解释:最大的矩形为图中红色区域,面积为 10
思路:
求最大面积可以枚举宽,双重 for
循环确定左右边界求最大高度。也可以枚举高,直到往左右找到第一个小于它高度才停止。如 5
向左找发现 1
比它小,向右找发现 2
比它小,所以高度为 5
的矩形面积的宽就是 5
和 6
的宽度也就是 2
。
核心公式是 res = Math.max(res, (right[i] - left[i] - 1) * heights[i]);
其中 left[i]
表示 i
位置往左找第一个小于位置 i
高度的下标。
对于在数组中往左或往右找第一个比自己小的元素,那毫无疑问可以用单调栈来做。从左向右遍历数组,将栈中比当前元素大的元素弹出栈以保持栈单调递增的特性,弹出后栈顶元素就是第一个比自己小的元素。往右找比第一个小于自己的下标同理。
速记:找第一个更小则维护单调递增栈,出栈后栈顶就是第一个比自己小的位置
代码:
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
// 存i位置左边和右边第一个小于当前高度的下标
int[] left = new int[n], right = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
// 往左找所以从左向右遍历,这样左边的是遍历过的
for (int i = 0; i < n; ++i) {
// 将比自己大的栈顶元素弹出栈以此满足单调递增特性
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
stack.pop();
}
// 存左边第一个小于当前元素的下标,若不存在存-1表示虚拟的无限低的柱子
left[i] = (stack.isEmpty() ? -1 : stack.peek());
stack.push(i);
}
stack.clear(); // 清空栈
// 和往左找类似,只不过从右往左遍历
for (int i = n - 1; i >= 0; --i) {
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
stack.pop();
}
right[i] = (stack.isEmpty() ? n : stack.peek());
stack.push(i);
}
int res = 0;
for (int i = 0; i < n; ++i) {
// 核心公式,其中left[i]和right[i]用单调栈来求
res = Math.max(res, (right[i] - left[i] - 1) * heights[i]);
}
return res;
}
}
12.6 232.用栈实现队列(扩展)🟢🔥
题目:请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push
、pop
、peek
、empty
):
实现 MyQueue
类:
void push(int x)
将元素 x 推到队列的末尾int pop()
从队列的开头移除并返回元素int peek()
返回队列开头的元素boolean empty()
如果队列为空,返回true
;否则,返回false
链接:232. 用栈实现队列
示例 :
输入:
["MyQueue", "push", "push", "peek", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 1, 1, false]
思路:
栈是先入后出,而队列是先入先出。可以用两个栈,inStack
负责存进入元素,outStack
用于取元素。
但是要注意 outStack
取元素时其可能为空,所以若为空,则需要 in2out()
先把 inStack
的元素 push
进 outStack
保证 outStack
取元素时不为空。
速记:
inStack
存入栈,outStack
弹出栈,弹时为空则先in2out
代码:
class MyQueue {
Deque<Integer> inStack;
Deque<Integer> outStack;
public MyQueue() {
inStack = new LinkedList<>(); // 存入元素的栈
outStack = new LinkedList<>(); // 出元素的栈
}
public void push(int x) {
inStack.push(x); // inStack专门入栈
}
public int pop() {
// outStack若为空则先push元素
if (outStack.isEmpty()) {
in2out();
}
return outStack.pop(); // outStack专门出栈
}
public int peek() {
// outStack若为空则先push元素
if (outStack.isEmpty()) {
in2out();
}
return outStack.peek();
}
public boolean empty() {
return inStack.isEmpty() && outStack.isEmpty();
}
// 把inStack的元素都push到outStack中
private void in2out() {
while (!inStack.isEmpty()) {
outStack.push(inStack.pop());
}
}
}
12.7 225.用队列实现栈(扩展)🟢🔥
题目:请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push
、top
、pop
和 empty
)。
实现 MyStack
类:
void push(int x)
将元素 x 压入栈顶。int pop()
移除并返回栈顶元素。int top()
返回栈顶元素。boolean empty()
如果栈是空的,返回true
;否则,返回false
。
进阶: 你能否仅用一个队列来实现栈。
链接:225. 用队列实现栈
示例 :
输入:
["MyStack", "push", "push", "top", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 2, 2, false]
思路:
队列先入先出,而栈先入后出。入队的时候可以让其前边的元素都先出队再入队,这样队列取元素的时候就和栈一样是先入先出了。
如 2 -> 1
,现在入 3
,变成 2 -> 1 -> 3
,把 2 1
出队再入队就变成了 3 -> 2 -> 1
⚠栈实现队列主要操作是取元素,需要两个栈;队列实现栈主要操作是入元素,只需一个队列。
速记:入队后把前面的元素都先出队再入队
代码:
class MyStack {
Queue<Integer> queue;
public MyStack() {
queue = new LinkedList<>();
}
public void push(int x) { // 2 1 - (3)
int n = queue.size(); // 入队前元素个数
queue.offer(x);
// x入队后把前面的元素出队再入队,这样队列就是后入先出的顺序
for (int i = 0; i < n; i++) {
queue.offer(queue.poll());
}
}
public int pop() {
return queue.poll();
}
public int top() {
return queue.peek();
}
public boolean empty() {
return queue.isEmpty();
}
}
12.8 227.基本计算器 II(扩展)🟡🔥
题目:给你一个字符串表达式 s
,请你实现一个基本计算器来计算并返回它的值。
整数除法仅保留整数部分。
你可以假设给定的表达式总是有效的。所有中间结果将在 [-231, 231 - 1]
的范围内。
注意: 不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval()
。
示例 :
输入:s = " 3+5 / 2 "
输出:5
思路:
速记:栈存的是正数和负数,遇乘除要算出结果,最后相加
代码:
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public int calculate(String s) {
// stack存的是每一个带正负的数字,最后相加即可
Deque<Integer> stack = new ArrayDeque<Integer>();
char preSign = '+';
int num = 0;
int n = s.length();
for (int i = 0; i < n; ++i) {
char c = s.charAt(i);
// 连续的数字有可能不止一位
if (Character.isDigit(c)) {
num = num * 10 + c - '0';
}
// 是+-*/或是最后一个字符 将其前面的数字入栈
if (c == '+' || c == '-' || c == '*' || c == '/' || i == n - 1) {
if (preSign == '+') {
stack.push(num);
} else if (preSign == '-') {
stack.push(-num);
} else if (preSign == '*') { // 体现出乘法的优先级
stack.push(stack.pop() * num);
} else {
stack.push(stack.pop() / num);
}
preSign = c; // 更新符号
num = 0; // 重置num
}
}
int res = 0;
while (!stack.isEmpty()) {
res += stack.pop();
}
return res;
}
}
13 堆
13.1 215.数组中的第 K 个最大元素🟡🔥
题目:给定整数数组 nums
和整数 k
,请返回数组中第 k
个最大的元素。
请注意,你需要找的是数组排序后的第 k
个最大的元素,而不是第 k
个不同的元素。
你必须设计并实现时间复杂度为 O(n)
的算法解决此问题。
示例 :
输入: [3,2,1,5,6,4], k = 2
输出: 5
思路:
要求的是第 k 个最大,可以用堆来实现。用最小堆找出前 k 个最大,这样堆顶就是第 k 个最大。
代码:
1.优先队列
class Solution {
public int findKthLargest(int[] nums, int k) {
// 默认是小顶堆,堆顶是最小元素
Queue<Integer> queue = new PriorityQueue<>();
for (int num : nums) {
// 前k个直接添加进去
if (queue.size() < k) {
queue.offer(num);
} else {
// 后面的比堆顶大再添加进去
if (num > queue.peek()) {
queue.poll();
queue.offer(num);
}
}
}
// 堆中是前k个最大元素,堆顶是最小的那个,即第 k 个最大元素
return queue.peek();
}
}
2.快速选择
class Solution {
public int findKthLargest(int[] nums, int k) {
int target = nums.length - k;
// 第k大也就是下标为target的元素
return quickSelect(nums, 0, nums.length - 1, target);
}
public static int partition(int[] nums, int left, int right) {
// 随机在nums[left...right]的范围中, 选择一个数作为pivot
swap(nums, left, (int) (Math.random() * (right - left + 1)) + left);
int pivot = nums[left];
int i = left + 1; // 左侧指针的初始位置
int j = right; // 右侧指针的初始位置
while (true) {
// 左侧指针移动,直到找到一个大于基准值的元素
while (i <= right && nums[i] < pivot) {
i++;
}
// 右侧指针移动,直到找到一个小于基准值的元素
while (j >= left + 1 && nums[j] > pivot) {
j--;
}
// 两边已经分区好了,break
if (i >= j) {
break;
}
// 交换左右指针所指的元素
swap(nums, i, j);
i++;
j--;
}
// j指向的是最后一个小于pivot的元素,所以和j交换
swap(nums, left, j);
return j;
}
public static void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
public static int quickSelect(int[] nums, int left, int right, int target) {
if (left >= right) {
return nums[left];
}
int pivotIndex = partition(nums, left, right);
if (target == pivotIndex) {
return nums[target];
} else if (target < pivotIndex) {
return quickSelect(nums, left, pivotIndex - 1, target);
} else {
return quickSelect(nums, pivotIndex + 1, right, target);
}
}
}
和快速排序相比就 quickSelect
函数不同,快排函数如下
public static void quickSort(int[] nums, int left, int right) {
if (left >= right) {
return;
}
// 获取划分子数组的位置
int pivotIndex = partition(nums, left, right);
// 对左半部分进行快速排序
quickSort(nums, left, pivotIndex - 1);
// 对右半部分进行快速排序
quickSort(nums, pivotIndex + 1, right);
}
13.2 347.前 K 个高频元素🟡🔥
题目:给你一个整数数组 nums
和一个整数 k
,请你返回其中出现频率前 k
高的元素。你可以按 任意顺序 返回答案。
示例 :
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
思路:
前 k 个高频元素,可以先用 HashMap
统计一下每个元素对应的频率,然后问题转换为求前 k 个最大元素。
与第 k 个最大元素不同,可以直接用大顶堆,把所有元素都添加进去,最终堆里的元素就是前 k 个最大元素。不过这样每个元素都要过一遍堆,可以用小顶堆优化下,不用把每个元素都过一遍导致每次都调整堆
注意代码中队列怎么存放 Map,以及怎么定义 Map 类型的小顶堆
代码:
1.小顶堆
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> count = new HashMap<>();
for (int num : nums) {
count.put(num, count.getOrDefault(num, 0) + 1);
}
// 小顶堆
Queue<Map.Entry<Integer, Integer>> pq = new PriorityQueue<>
((a, b) -> a.getValue() - (b.getValue()));
for (Map.Entry<Integer, Integer> entry : count.entrySet()) {
if (pq.size() < k) {
pq.offer(entry);
} else {
if (entry.getValue() > pq.peek().getValue()) {
pq.poll();
pq.offer(entry);
}
}
}
int[] res = new int[k];
for (int i = 0; i < k; i++) {
res[i] = pq.poll().getKey();
}
return res;
}
}
13.3 295.数据流的中位数🔴
题目:中位数是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值,中位数是两个中间值的平均值。
- 例如
arr = [2,3,4]
的中位数是3
。 - 例如
arr = [2,3]
的中位数是(2 + 3) / 2 = 2.5
。
实现 MedianFinder 类:
MedianFinder()
初始化MedianFinder
对象。void addNum(int num)
将数据流中的整数num
添加到数据结构中。double findMedian()
返回到目前为止所有元素的中位数。与实际答案相差10-5
以内的答案将被接受。
链接:295. 数据流的中位数
示例 :
输入
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
输出
[null, null, null, 1.5, null, 2.0]
思路:
如下图所示,求中位数,可以用两个优先队列来做。上面的小顶堆保存较大的一半,下面的大顶堆保存较小的一半。两个堆数量不一样时,先加到小顶堆去,也就是按照 small big small...
的顺序添加元素。这样任一时刻,small
的元素比 big
大 1
或相等。大 1
那 small
多的那个就是中位数,相等则取两个堆顶的平均值。
速记:小顶堆存较大,数量也较大,插入前先去另一个堆过一下
代码:
class MedianFinder {
Queue<Integer> small, big;
public MedianFinder() {
small = new PriorityQueue<>(); // 小顶堆,保存较大的一半
big = new PriorityQueue<>((x, y) -> (y - x)); // 大顶堆,保存较小的一半
}
// 按照small big small...的顺序添加元素
public void addNum(int num) {
if (small.size() != big.size()) {
small.add(num);
big.add(small.poll());
} else {
big.add(num);
small.add(big.poll());
}
}
public double findMedian() {
// small的数量比big大1或相等,所以若不等small的peek就是中位数
return small.size() != big.size() ? small.peek() : (small.peek() + big.peek()) / 2.0;
}
}
14 贪心算法
14.1 121.买卖股票的最佳时机🟢
题目:给定一个数组 prices
,它的第 i
个元素 prices[i]
表示一支给定股票第 i
天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0
。
示例 :
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
思路:
想赚钱,那肯定是低价买入,高价抛出。所以用一个 minPrice
记录最小价格,向后遍历时计算当前价格减 minPrice
得到当前要是抛出赚的钱,求其中的最大值即可。
代码:
class Solution {
public int maxProfit(int[] prices) {
int minPrice = prices[0], res = 0;
for (int i = 1; i < prices.length; i++) {
// 更新价格最小值
if (prices[i] < minPrice) {
minPrice = prices[i];
} else if (prices[i] - minPrice > res) {
// 计算利润最大值
res = prices[i] - minPrice;
}
}
return res;
}
}
14.2 55.跳跃游戏🟡
题目:给你一个非负整数数组 nums
,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true
;否则,返回 false
。
链接:55. 跳跃游戏
示例 :
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
思路:
能否跳到最后一个下标,也就是能跳到的最远距离是否比最后一个下标还远。
不断遍历元素更新能跳到的最远距离,如果当前位置 max <= i
,说明遇到了不可达的下标。
速记:更新能跳到的最远距离,当前位置不可达则
false
代码:
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
int max = 0;
for (int i = 0; i < n - 1; i++) {
// 不断计算能跳到的最远距离
max = Math.max(max, i + nums[i]);
// 可能碰到了 0,卡住跳不动了
if (max <= i) {
return false;
}
}
return max >= n - 1;
}
}
14.3 45.跳跃游戏 II🟡
题目:给定一个长度为 n
的 0 索引整数数组 nums
。初始位置为 nums[0]
。
每个元素 nums[i]
表示从索引 i
向前跳转的最大长度。换句话说,如果你在 nums[i]
处,你可以跳转到任意 nums[i + j]
处:
0 <= j <= nums[i]
i + j < n
返回到达 nums[n - 1]
的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]
。
链接:45. 跳跃游戏 II
示例 :
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
思路:
最小的跳跃次数其实也就是能遇到几个跳跃边界,因为想跳到下一个边界,必须要跳跃一次。到达边界时,更新边界并跳跃次数 +1
。同时,无需遍历最后一个元素,因为可能最后一个元素就是边界,但我们不想再跳到下一个边界了,所以不需要再 +1
。
速记:最小跳跃次数也就是能遇到几个跳跃边界,想跳到下一个边界就要跳一次。
代码:
class Solution {
public int jump(int[] nums) {
int n = nums.length, max = 0, end = 0, res = 0;
// 无需遍历最后一个元素,因为不需要再跳到下一个边界了
for (int i = 0; i < n - 1; i++) {
// 实时更新能跳的最远距离
max = Math.max(max, i + nums[i]);
// 遇到边界必须跳跃一次才能达到下个边界
if (i == end) {
res++;
end = max;
}
}
return res;
}
}
14.4 763.划分字母区间🟡
题目:给你一个字符串 s
。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。
注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s
。
返回一个表示每个字符串片段的长度的列表。
链接:763. 划分字母区间
示例 :
输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca"、"defegde"、"hijhklij" 。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 这样的划分是错误的,因为划分的片段数较少。
思路:
区间内不能出现相同字母,也就是对于区间内每个字符最后出现的位置也在该区间内。划分尽可能多的片段,那就要刚好能满足同一字母最多出现在一个片段。因此可以事先记录每个字符的最后位置,若当前遍历的位置能包含该区间内所有字符的最后位置,也就是达到了该区间的最右侧。
例如,示例中 ababcbaca
中的所有字母的最后出现的下标都在该区间内,说明找到了一个区间,继续找下个区间。
速记:记录每个字母的最后出现下标,遍历到能包含所有最后下标的位置就更新结果
代码:
class Solution {
public List<Integer> partitionLabels(String s) {
int[] last = new int[26];
int length = s.length();
for (int i = 0; i < length; i++) {
// 每个字母最后出现的下标,也可以用map存
last[s.charAt(i) - 'a'] = i;
}
List<Integer> res = new ArrayList<Integer>();
int start = 0, end = 0;
for (int i = 0; i < length; i++) {
// 更新当前区间右侧最大值
end = Math.max(end, last[s.charAt(i) - 'a']);
if (i == end) { // 当前区间已达到最大
res.add(end - start + 1);
start = end + 1; // 更新下一个区间的左端点
}
}
return res;
}
}
15 动态规划
15.1 70. 爬楼梯🟢
题目:假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
链接:70. 爬楼梯
示例 :
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
思路:
后面节点的状态可以由前面的推导出来,所以要用动态规划。设 dp[n]
为 n 阶时的方法个数,那它其实就等于先爬一步,后面有 dp[i-1]
种方法 + 先爬两步,后面有 dp[i-2]
种方法
代码:
class Solution {
public int climbStairs(int n) {
if (n <= 2)
return n;
int[] dp = new int[n + 1];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
// 先爬一步,后面有dp[i-1]种方法
// 先爬两步,后面有dp[i-2]种方法
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
15.2 118.杨辉三角🟢
题目:给定一个非负整数 numRows
,生成「杨辉三角」的前 numRows
行。
在「杨辉三角」中,每个数是它左上方和右上方的数的和。
链接:118. 杨辉三角
示例 :
输入: numRows = 5
输出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]
思路:
下一行的结果可以由前一行的结果推导出来,并且能看出每一行的第一个和最后一个都是 1
。对于非首末元素的值就等于左上角和右上角的和,直接看图可能不直观,可以把杨辉三角旋转一下,如下
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
这样就很明显能看出推导公式为dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]
代码:
class Solution {
public List<List<Integer>> generate(int numRows) {
// 初始化动态规划数组
Integer[][] dp = new Integer[numRows][];
// 遍历每一行
for (int i = 0; i < numRows; i++) {
// 注意:初始化当前行
dp[i] = new Integer[i + 1];
// 每一行的第一个和最后一个元素总是 1
dp[i][0] = dp[i][i] = 1;
// 计算中间元素
for (int j = 1; j < i; j++) {
// 中间元素等于上一行的相邻两个元素之和
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
}
}
// 将动态规划数组转换为结果列表
List<List<Integer>> res = new ArrayList<>();
for (Integer[] row : dp) {
res.add(Arrays.asList(row));
}
// 返回结果列表
return res;
}
}
15.3 198.打家劫舍🟡
题目:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
链接:198. 打家劫舍
示例 :
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
思路:
只有一间屋时没得选,dp[0] = nums[0]
。两间屋时,选最大的那个。之后对于每个位置有两种选择,偷还是不偷当前位置,从中选择最大的那个。
- 要是偷当前位置,这样就不能偷前一个,此时和为
dp[i - 2] + nums[i]
- 若是不偷当前位置,那和就与前一个位置相同,
dp[i - 1]
代码:
class Solution {
public int rob(int[] nums) {
int length = nums.length;
if (length == 1) {
return nums[0];
}
int[] dp = new int[length];
dp[0] = nums[0]; // 只有1间屋
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];
}
}
因为结果只和前两间房屋的最高金额有关,没必要记录所有位置的最高金额,可以用两个变量代替数组
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int length = nums.length;
if (length == 1) {
return nums[0];
}
int first = nums[0], second = Math.max(nums[0], nums[1]);
for (int i = 2; i < length; i++) {
int temp = second;
second = Math.max(first + nums[i], second);
first = temp;
}
return second;
}
}
扩展:213. 打家劫舍 II:这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。
代码:
class Solution {
public int rob(int[] nums) {
int length = nums.length;
if (length == 1) {
return nums[0];
} else if (length == 2) {
return Math.max(nums[0], nums[1]);
}
// 偷第1家 和 不偷第1家取最大
return Math.max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
}
public int robRange(int[] nums, int start, int end) {
int first = nums[start], second = Math.max(nums[start], nums[start + 1]);
for (int i = start + 2; i <= end; i++) {
int temp = second;
second = Math.max(first + nums[i], second);
first = temp;
}
return second;
}
}
15.4 279.完全平方数🟡
题目:给你一个整数 n
,返回 和为 n
的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1
、4
、9
和 16
都是完全平方数,而 3
和 11
不是。
链接:279. 完全平方数
示例 :
输入:n = 13
输出:2
解释:13 = 4 + 9
思路:
n
的平方数的最小数量,那么可以根据和为 n-1x1, n-2x2, n-3x3...
的平方数的最小数量 +1
推导出来。即 dp[n]=min(dp[n-1x1],dp[n-2x2],dp[n-3x3]......)+1
。因为要求 dp[i]
的最小值,所以初始化填充 dp
数组为一个整数最大值。
速记:遍历完全平方数,假设当前完全平方数是组合的最后一个数,动态规划
代码:
class Solution {
public int numSquares(int n) {
// 定义:和为 i 的平方数的最小数量是 dp[i]
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int i = 1; i <= n; i++) {
// j无需罗列到i
for (int j = 1; j * j <= i; j++) {
// i - j*j 只要再加一个平方数 j*j 即可凑出 i
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
}
15.5 322.零钱兑换🟡
题目:给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
链接:322. 零钱兑换
示例 :
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
思路:
与上题的完全平方数解法类似。假如现在已经凑够了 10
,那么只需 1
即可凑够。或者说现在凑够了 9
,那只需 2
就可凑够。
也就是说,可以遍历每个硬币金额,假设当前硬币是最后一个硬币,dp[11]=min(dp[11-1],dp[11-2],dp[11-5])+1
。但注意有可能无法凑出,所以可以初始化 dp
数组为一个不可能的数值,如 amount+1
,这样最后判断下 dp[amount]
是否大于 amount
就知道能不能凑出了。
速记:遍历硬币金额,假设当前金额是组合的最后一个硬币,动态规划
代码:
class Solution {
public int coinChange(int[] coins, int amount) {
int n = coins.length;
int max = amount + 1;
int[] dp = new int[amount + 1];
Arrays.fill(dp, max); // 先填充一个最大值,代表不存在的情况
dp[0] = 0; // dp[0] 应该单独设为0
for (int i = 1; i <= amount; i++) {
for (int j = 0; j < n; j++) {
if (i - coins[j] >= 0) { // 防止数组下标是负数
// 最后一个硬币是coins[j]的组合的个数
dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
}
}
}
// 凑不出时dp[amount] = amount + 1
return dp[amount] > amount ? -1 : dp[amount];
}
}
15.6 139.单词拆分🟡
题目:给你一个字符串 s
和一个字符串列表 wordDict
作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s
则返回 true
。
注意: 不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
链接:139. 单词拆分
示例 :
输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。注意,你可以重复使用字典中的单词。
思路:
字典中的单词拼接成字符串,也可以看做是字符串分割成多个属于字典中的单词。对于字符串 s
可以分割也就是能找到一个分割位置 j
使得前后都是合法的。后面的状态由前面推出,所以分割后前面的字符串可以用 dp[j]
来判断,那只需检查后面的是否在字典中即可,判断在不在的问题可以用哈希表解决。
转移方程:dp[i]= dp[j] && check(s[j..i−1])
,dp[i]
表示前 i
个字符是否满足条件,所以下标是 i-1
。dp[0]
表示前 0
个字符,这样好初始化 dp[0] = true
。
速记:分割位置前的
dp
合法且后面的字符串在字典内则合法
代码:
public class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> set = new HashSet(wordDict);
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true; // dp[0]代表前0个字符这样一定是true,方便初始化
for (int i = 1; i <= s.length(); i++) {
// 只要找到一个符合的j,那dp[i]就可以分割
for (int j = 0; j < i; j++) { // j是分割位置,要小于i
// 分割后前面的和后面的都合法
if (dp[j] && set.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
}
15.7 300. 最长递增子序列🟡
题目:给你一个整数数组 nums
,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]
是数组 [0,3,1,6,2,2,7]
的子序列
链接:300. 最长递增子序列
示例 :
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
思路:
当前位置的结果能由前面的元素的结果推导出来,所以可以用动态规划。
但如果 dp[i]
表示的是最终结果,不选的话直接用前一个的结果,但选的话还要满足递增,那比前面的哪个大算递增呢。如果从前往后一个个比较,但当前位置比前面的大,不代表直接能用前面的结果加1。因为前面的结果可能是前面那个位置的元素没有选的结果。如 1,5,9,4,6
,dp[3]=3
时4就没有选, dp[4]!=dp[3]+1
关键点在于我们无法确定前面的 dp[i]
的结果中 nums[i]
有没有选,那我们可以把 dp[i]
定义成 nums[i]
一定被选时的结果,然后求出所有的 dp[i]
的最大值就是最终结果。
代码:
class Solution {
public int lengthOfLIS(int[] nums) {
// dp数组表示0..i时的结果,且nums[i]必须被选择
int[] dp = new int[nums.length];
// 初始化每个dp都是1即只选当前元素
Arrays.fill(dp, 1);
int res = 1; // 解决nums只有1个元素的情况,也可以下面i从0开始:res=0
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
// 因为必须选择nums[i],所以nums[i]必须大于nums[j]才能满足递增
if (nums[i] > nums[j]) {
// 对于每个j,dp[i]=dp[j]+1,求所有j中最大的dp[i]
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
// 所有dp里面的最大值就是res
res = Math.max(res, dp[i]);
}
return res;
}
}
15.8 152.乘积最大子数组🟡
题目:给你一个整数数组 nums
,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
链接:152. 乘积最大子数组
示例 :
输入: nums = [5,6,-3,4,-3]
输出: 1080
解释: 子数组 [5,6,-3,4,-3] 有最大乘积 1080。
思路:
这道题看着可能和 5.1 53.最大子数组和🟡 类似,要么和前面的合成子数组,要么自己单独成子数组取最大。但那个是相加取最大,也就是只要当前是正数,那和前面相加前面一定变大,若是负数,前面相加后一定变小。但这题是乘积,思考这样一个问题,数组中任意两个数的最大乘积一定是最大的两个数相乘吗?显然不是,也有可能是两个最小的负数相乘。
所以求当前元素结尾的子数组的乘积最大值,那不光要看前面的最大值,也要看前面的最小值,有可能当前是负数,和前面的负数相乘求得的才是最大值。无论最大值还是最小值都可能是 curMax*nums[i]
和 curMin*nums[i]
和 nums[i]
中的一个,所以不断更新 curMax
和 curMin
。
速记:更新前面的最大乘积值和最小乘积值,三数取最大就是当前最大
代码:
// 有个测试用例用int和long都会溢出,所以用double
class Solution {
public int maxProduct(int[] nums) {
double max = nums[0], min = nums[0], res = nums[0];
double length = nums.length;
for (int i = 1; i < length; ++i) {
double curMax = max, curMin = min;
// 由于存在负值,可能之前最小值乘当前负值才最大
max = Math.max(curMax * nums[i], Math.max(nums[i], curMin * nums[i]));
// 由于存在负值,可能之前最大值乘当前负值才最小
min = Math.min(curMin * nums[i], Math.min(nums[i], curMax * nums[i]));
res = Math.max(max, res);
}
return (int) res;
}
}
15.9 32.分割等和子集🟡
题目:给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
链接:416. 分割等和子集
示例 :
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
思路:
这道题可以转换成 01 背包问题,要求选取的数的和恰好等于整个数组的和的一半。
dp[i][j]
表示从前 i
个数里选取若干个整数(可以是 0 个),是否存在选取的整数的和等于 j
。分为两种情况
- 装不下当前物品
i
(下标是nums[i-1]
),此时dp[i][j] = dp[i - 1][j]
- 装得下当前物品,此时可以选择装也可以选择不装,结果取或。若是装
dp[i][j] = dp[i - 1][j - nums[i - 1]]
速记:
01
背包,选取若干个数是否存在等于数组和一半的情况,对应装或不装当前物品
代码:
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) sum += num;
// 和为奇数时,不可能划分成两个和相等的集合
if (sum % 2 != 0) return false;
int n = nums.length;
sum = sum / 2;
// dp[i][j] 表示从前 i 个数里选取若干个整数(可以是 0 个),是否存在选取的整数的和等于 j
boolean[][] dp = new boolean[n + 1][sum + 1];
for (int i = 0; i <= n; i++)
dp[i][0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= sum; j++) {
if (j - nums[i - 1] < 0) { // 容量小于第i个物品
// 背包容量不足,不能装入第 i 个物品
dp[i][j] = dp[i - 1][j];
} else {
// 装入或不装入背包
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
}
}
}
return dp[n][sum];
}
}
15.10 416.最长有效括号🔴
题目:给你一个只包含 '('
和 ')'
的字符串,找出最长有效(格式正确且连续)括号子串的长度。
链接:32. 最长有效括号
示例 :
输入:s = ")()())"
输出:4
解释:最长有效括号子串是 "()()"
思路:
这题可以用动态规划,但是栈来判断括号的有效性更容易想到。栈底存的是当前遍历到的元素中最后一个没有被匹配的右括号的下标,栈中其他元素存左括号下标。
对于 (
直接存入栈,为了满足栈底存的是最后一个没被匹配的左括号的下标,初始栈中存 -1
,不然如果第一个是左括号,那栈底就是左括号下标了。
对于 )
,先弹出栈顶,表示匹配了当前右括号
- 如果栈为空了,说明当前右括号就是没有被匹配的右括号。如
())
,第一个)
会让第一个(
出栈,但由于栈中初始存了-1
,此时栈仍不为空。第二个)
会让初始的-1
出栈,此时栈为空,将该右括号下标存入栈,满足栈底存最后一个没被匹配的右括号。 - 如果栈不为空,当前右括号下标减去栈顶就是该右括号为结尾的长度。如
(()
,遇到第一个)
时长度为2-0=2
。
速记:栈底存最后一个没被匹配的右括号下标,遇到右括号出栈更新
res
代码:
class Solution {
public int longestValidParentheses(String s) {
int res = 0;
Deque<Integer> stack = new ArrayDeque<>();
// 栈底存最后一个没有被匹配的右括号下标,所以初始存-1,不然初始就存左括号下标了
stack.push(-1);
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(') {
stack.push(i); // 左括号存下标入栈
} else {
stack.pop(); // 弹出当前右括号对应的左括号
if (stack.isEmpty()) {
stack.push(i); // 栈底存最后一个没被匹配的右括号下标
} else {
// 弹出栈顶后栈不为空则更新res
res = Math.max(res, i - stack.peek());
}
}
}
return res;
}
}
16 多维动态规划
16.1 62.不同路径🟡
题目:一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
链接:62. 不同路径
示例 :
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
思路:
对于某一位置,只能由上面或左面走过来,就和爬楼梯类似,所以状态转移方程为 dp[i][j] = dp[i-1][j] + dp[i][j-1]
,对于 i=0
和 j=0
时路径个数都是 1
速记:只能由上或者左过来,类似爬楼梯
代码:
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
// 初始化
for (int i = 0; i < m; i++) {
dp[i][0] = 1;
}
for (int j = 0; j < n; j++) {
dp[0][j] = 1;
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
}
空间优化版本:
// dp[i][j]只和第i行和第i-1行有关
class Solution {
public int uniquePaths(int m, int n) {
int[] dp = new int[n];
for (int i = 0; i < n; ++i) {
dp[i] = 1;
}
for (int i = 1; i < m; ++i) { // 加上从上边过来的
for (int j = 1; j < n; ++j) { // 加上从左边过来的
dp[j] += dp[j - 1];
}
}
return dp[n - 1];
}
}
16.2 64.最小路径和🟡
题目:给定一个包含非负整数的 _m_ x _n_
网格 grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明: 每次只能向下或者向右移动一步。
链接:64. 最小路径和
示例 :
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
思路:
每次只能向下或向右,那某一点的状态就是左边的或上边的取最小加上当前位置的值。状态方程为 dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
。不过注意由于含有 i-1
和 j-1
, 这里还是要单独对第一行和第一列初始化。
速记:只能由上或者左过来,取二者最小加上当前值
代码:
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dp = new int[m][n];
// 初始化
dp[0][0] = grid[0][0];
for (int i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int j = 1; j < n; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
}
16.3 5.最长回文子串🟡
题目:给你一个字符串 s
,找到 s
中最长的回文子串。
如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
链接:5. 最长回文子串
示例 :
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
思路:
动态规划:如果一个字符串是回文,那么它左右添加一个相同的字符仍然是回文。可以用 dp
数组记录 [l,r]
是否是回文,先遍历右边界,这样就可以复用已经得到结果的右边界对应的 dp
中心扩散:对于一个字符,若两边字符相同,则不断向两边扩散,记录能扩散的最大值即可。不过要注意中心字符可能是一个如 aca
或两个如 abba
代码:
1.动态规划
public class Solution {
public String longestPalindrome(String s) {
int strLen = s.length();
int maxStart = 0; // 最长回文串的起点
int maxEnd = 0; // 最长回文串的终点
int maxLen = 1; // 最长回文串的长度
// 记录是否是回文子串的DP数组
boolean[][] dp = new boolean[strLen][strLen];
for (int r = 1; r < strLen; r++) { // 从第二个位置开始遍历
for (int l = 0; l < r; l++) { // 遍历r之前的所有位置
if (s.charAt(l) == s.charAt(r) && (r - l <= 2 || dp[l + 1][r - 1])) {
// 当s[l]等于s[r] 且 l到r的距离<=2或l+1到r-1是回文串时
// 则s.substring(l, r + 1)是回文串,更新dp[l][r]为true
dp[l][r] = true;
// 如果当前回文串的长度大于最大回文串的长度
if (r - l + 1 > maxLen) {
maxLen = r - l + 1; // 更新最大回文串的长度
maxStart = l; // 更新最大回文串的起点
maxEnd = r; // 更新最大回文串的终点
}
}
}
}
return s.substring(maxStart, maxEnd + 1); // 返回最长回文串
}
}
2.中心扩散(推荐)
class Solution {
public String longestPalindrome(String s) {
String res = "";
for (int i = 0; i < s.length(); i++) {
// 以 s[i] 为中心的最长回文子串
String s1 = palindrome(s, i, i);
// 以 s[i] 和 s[i+1] 为中心的最长回文子串
String s2 = palindrome(s, i, i + 1);
// res = longest(res, s1, s2)
res = res.length() > s1.length() ? res : s1;
res = res.length() > s2.length() ? res : s2;
}
return res;
}
String palindrome(String s, int l, int r) {
// 防止索引越界
while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) {
// 向两边展开
l--;
r++;
}
// 返回以 s[l] 和 s[r] 为中心的最长回文串
return s.substring(l + 1, r);
}
}
16.4 1143.最长公共子序列🟡
题目:给定两个字符串 text1
和 text2
,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0
。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
- 例如,
"ace"
是"abcde"
的子序列,但"aec"
不是"abcde"
的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例 :
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace" ,它的长度为 3 。
思路:
最长公共子序列是典型的二维动态规划问题,后面的状态可以由前面的状态推出。
当两个指针指向的两个字符串的字符相同时,那当前长度就等于两个指针都向前退一位后的长度加1。
若不等,要么 text1
退1位,要么 text2
退1位,求两者最大。
注意 dp 的长度要设为字符串长度+1。如果是用0表示字符串第1个位置,那就要初始化
dp[0][...]
和dp[...][0]
。但是这样要判断第1个字符是不是在另一个字符串里面,初始化比较麻烦。所以dp[i]
代表的是0...i-1
,这样dp[0]
就是0,不用再写初始化的代码了
代码:
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
// 定义:text1[0..i-1] 和 text2[0..j-1] 的 lcs 长度为 dp[i][j]
int[][] dp = new int[m + 1][n + 1];
// 目标:text1[0..m-1] 和 text2[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 (text1.charAt(i - 1) == text2.charAt(j - 1)) {
// 两个字符串都去除最后一个字符的lcs长度 + 1
dp[i][j] = 1 + dp[i - 1][j - 1];
} else {
// text2减一个字符后和text1的lcs长度
// 与text1减1个字符后和text2的长度 取最大
dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
}
}
}
return dp[m][n];
}
}
16.5 72.编辑距离🟡
题目:给你两个单词 word1
和 word2
, 请返回将 word1
转换成 word2
所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
链接:72. 编辑距离
示例 :
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
思路:
这题类似 16.4 1143.最长公共子序列🟡 都是两个字符串的动态规划问题。对于 dp[i][j]
表示 word1
前 i
个字符变成 word2
前 j
个的操作次数。i=0
表示空字符串而不是第一个字符,不然不方便初始化。
若 word1
和 word2
的两个字符相等,说明无需进行操作,该位置的 dp
还是等于两者前一个位置的 dp
。若是不等,比如删除 word1
的元素,如 abc
和 ac
要删除 b
。那 dp[i][j]
不就是 word1
往前退一个字符和 word2
前 j
个字符的 dp+1
速记:初始化
0
行0
列,增删改中取最小值
代码:
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// 初始化 i=0表示空字符,i=0若表示第一个字符无法轻松初始化
for (int i = 0; i <= m; i++) {
dp[i][0] = i;
}
for (int i = 0; i <= n; i++) {
dp[0][i] = i;
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// i 位置的字符下标为 i-1
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
// 当前两字符相等,无需操作
dp[i][j] = dp[i - 1][j - 1];
} else {
// 在改删增中取最小
dp[i][j] = Math.min(dp[i - 1][j - 1] + 1, Math.min(
dp[i - 1][j] + 1, dp[i][j - 1] + 1));
}
}
}
return dp[m][n]; // word1前m个字符变成word2前n个的操作次数
}
}
17 技巧
17.1 136.只出现一次的数字🟢
题目:给你一个 非空 整数数组 nums
,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。
示例 :
输入:nums = [4,1,2,1,2]
输出:4
思路:
最简单的是 HashMap
统计次数,看哪个是一次就输出哪个。也可以用 HashSet
遍历,存在就删除,不存在就添加,最后 HashSet
剩的就是结果。还可以用 HashSet
存所有元素,计算集和中元素和的两倍减去数组中元素的和就是只出现一次的元素。
不过上述都要 O(n)
的空间,这里使用异或运算。已知任何数与 0 异或,都还是原来的数。任何数和自身异或,都是 0。那么所有元素做异或,偶数次的元素异或得到 0,异或后的结果奇数次的元素。
代码:
class Solution {
public int singleNumber(int[] nums) {
int res = 0;
for (int n : nums) {
res ^= n;
}
return res;
}
}
17.2 169.多数元素🟢
题目:给定一个大小为 n
的数组 nums
,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋
的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
进阶: 尝试设计时间复杂度为 O(n)、空间复杂度为 O(1) 的算法解决此问题。
链接:169. 多数元素
示例 :
输入:nums = [2,2,1,1,1,2,2]
输出:2
思路:
使用摩尔投票法,设置一个 queen
,遇到和 queen
相同的元素,则投票 +1
,遇到不同的则投票 -1
,若投票为 0 则选取当前元素为新的 queen
。相当于消消乐,遍历完一轮,最后的 queen
就是次数超过一半的那个。
速记:设置
queen
,相同则投票+1
,不同投票-1
,投票为 0 时选举新的queen
代码:
class Solution {
public int majorityElement(int[] nums) {
int count = 0, queen = 0;
for (int i = 0; i < nums.length; i++) {
// 投票为0,重新选举queen
if (count == 0) {
queen = nums[i];
}
// 遇到相同的+1,不同的-1
count += (nums[i] == queen) ? 1 : -1;
}
return queen;
}
}
17.3 75.颜色分类🟡
题目:给定一个包含红色、白色和蓝色、共 n
个元素的数组 nums
,原地 对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
我们使用整数 0
、 1
和 2
分别表示红色、白色和蓝色。
必须在不使用库内置的 sort 函数的情况下解决这个问题。
进阶: 你能想出一个仅使用常数空间的一趟扫描算法吗?
链接:75. 颜色分类
示例 :
输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]
思路:
- 遍历数组时,当前元素始终被设置为2,这是因为2在最终排序中应位于数组的末尾。
- 如果当前元素是1,它会覆盖在
n1
的位置,并更新n1
的位置。 - 如果当前元素是0,它会将
n1
的位置覆盖成1
,最终覆盖在n0
的位置,并更新n0
和n1
的位置。 - 通过这种方式,0和1会在遍历过程中按顺序被覆盖到正确的位置。
速记:先刷
2
,小于2
将n1
刷成1
,还小于1
将n0
刷成0
代码:
方法一:双指针
class Solution {
public void sortColors(int[] nums) {
int n = nums.length;
int p0 = 0, p2 = n - 1;
for (int i = 0; i <= p2; i++) {
// 如果当前元素是2,并且当前位置在p2前面或等于p2
while (i <= p2 && nums[i] == 2) {
swap(nums, i, p2);
--p2;
}
if (nums[i] == 0) {
swap(nums, i, p0);
++p0;
}
}
}
public void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
方法二:刷油漆(推荐)
class Solution {
public void sortColors(int[] nums) {
// n0指向下一个刷成0的位置
// n1指向下一个刷成1的位置
int n0 = 0, n1 = 0;
for (int i = 0; i < nums.length; i++) {
int num = nums[i];
nums[i] = 2; // 先刷成2
// 小于2则将n1刷成1
if (num < 2) {
nums[n1++] = 1;
}
// 小于1就将n0刷成0
if (num < 1) {
nums[n0++] = 0;
}
}
}
}
17.4 31.下一个排列🟡
题目:整数数组的一个 排列 就是将其所有成员以序列或线性顺序排列。
- 例如,
arr = [1,2,3]
,以下这些都可以视作arr
的排列:[1,2,3]
、[1,3,2]
、[3,1,2]
、[2,3,1]
。
整数数组的 下一个排列 是指其整数的下一个字典序更大的排列。更正式地,如果数组的所有排列根据其字典顺序从小到大排列在一个容器中,那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列,那么这个数组必须重排为字典序最小的排列(即,其元素按升序排列)。
- 例如,
arr = [1,2,3]
的下一个排列是[1,3,2]
。 - 类似地,
arr = [2,3,1]
的下一个排列是[3,1,2]
。 - 而
arr = [3,2,1]
的下一个排列是[1,2,3]
,因为[3,2,1]
不存在一个字典序更大的排列。
给你一个整数数组 nums
,找出 nums
的下一个排列。
必须 原地 修改,只允许使用额外常数空间。
链接:31. 下一个排列
示例 :
输入:nums = [1,2,3,8,5,7,6,4]
输出:[1,2,3,8,6,4,5,7]
思路:
将左边的一个较小数和右边一个较大数交换,从而使数字变大。而为了满足下一个排列需要使变大幅度尽可能小,所以左边的较小数尽量靠右(从后向前找),右边的较大数尽可能的小(交换前小数后的元素要变成升序再找较大数)。
从后向前找 [i-1,i]
使得 nums[i] > nums[i-1]
,nums[i-1]=5
就是上面说的左边的较小数。也就是 5
和 7
。
对 i
及之后的元素排序,得到 12385 467
。为了使右边的较大数尽可能小,对 i
及之后的元素从左到右找到第一个大于 nums[i-1]=5
的数 nums[k]=6
,和 nums[i-1]
交换,nums[k]
就是上面说的右边的较大数。
如 1238 57 64
-> 12385 467
-> 12386457
,这样再交换能保证变大的幅度较小。
速记:从后向前找第一对升序
[i-1,i]
,后面[i,len]
排序找大于[i-1]
和其交换
代码:
class Solution {
public void nextPermutation(int[] nums) {
int len = nums.length;
for (int i = len - 1; i > 0; i--) {
// 从后往前先找出第一个相邻的后一个大于前一个情况,此时的i-1位置就是需要交换的位置 找到了5 7,i-1对应5
if (nums[i] > nums[i - 1]) {
// 对i自己和之后的元素排序,[i,len)从小到大
Arrays.sort(nums, i, len); // [7 6 4]->[4 6 7] 12385 467
for (int j = i; j < len; j++) {
// 和第一个大于i-1位置的进行交换,5和6交换 12386457
if (nums[j] > nums[i - 1]) {
int temp = nums[j];
nums[j] = nums[i - 1];
nums[i - 1] = temp;
return;
}
}
}
}
// 321的下一个是123,上面的交换执行不了,要重新排列成最小的
Arrays.sort(nums);
return;
}
}
17.5 287.寻找重复数🟡
题目:给定一个包含 n + 1
个整数的数组 nums
,其数字都在 [1, n]
范围内(包括 1
和 n
),可知至少存在一个重复的整数。
假设 nums
只有 一个重复的整数 ,返回 这个重复的数 。
你设计的解决方案必须 不修改 数组 nums
且只用常量级 O(1)
的额外空间。
进阶:
- 如何证明
nums
中至少存在一个重复的数字? - 你可以设计一个线性级时间复杂度
O(n)
的解决方案吗?
链接:287. 寻找重复数
示例 :
输入:nums = [1,3,4,2,2]
输出:2
思路:
可以将数组下标 i
和对应的 nums[i]
建立一个映射,即 i.next = nums[i]
对应 0-1,1-3,2-4,3-2,4-2
,可以抽象成下面的链表。那么数组中有重复的数-》链表存在环;找到重复的数-》找到链表环的入口。
那么这道题其实就可以转换成 7.7 142.环形链表 II🟡。
- 慢指针走一步
slow = slow.next
对应slow = nums[slow]
- 快指针走两步
fast = fast.next.next
对应fast = nums[nums[fast]]
速记:
i.next = nums[i]
转成链表,找重复的数转就是找环的入口
代码:
class Solution {
public int findDuplicate(int[] nums) {
int slow = 0, fast = 0;
while (true) {
// 慢指针每次走一步,对应slow = slow.next
slow = nums[slow];
// 快指针每次走两步,对应fast = fast.next.next
fast = nums[nums[fast]];
// 快慢指针相遇
if (slow == fast) {
// 设置一个新指针从起点出发
int tmp = 0;
while (tmp != slow) {
tmp = nums[tmp];
slow = nums[slow];
}
return slow;
}
}
}
}
18 其它
18.1 单例模式
1.饿汉式
public class Singleton {
// 1、私有化构造⽅法
private Singleton() {
}
// 2、定义⼀个静态变量指向⾃⼰类型
private final static Singleton instance = new Singleton();
// 3、对外提供⼀个公共的⽅法获取实例
public static Singleton getInstance() {
return instance;
}
}
2.懒汉式-双重检查锁
public class Singleton {
// 1、私有化构造⽅法
private Singleton() {
}
// 2、定义⼀个静态变量指向⾃⼰类型
private volatile static Singleton instance;
// 3、对外提供⼀个公共的⽅法获取实例
public static Singleton getInstance() {
// 第⼀重检查是否为 null
if (instance == null) {
// 使⽤ synchronized 加锁
synchronized (Singleton.class) {
// 第⼆重检查是否为 null
if (instance == null) {
// new 关键字创建对象不是原⼦操作
instance = new Singleton();
}
}
}
return instance;
}
}
3.枚举
public enum Singleton {
INSTANCE;
public void doSomething(String str) {
System.out.println(str);
}
}
4.静态内部类
public class Singleton {
// 私有化构造⽅法
private Singleton() {
}
// 对外提供获取实例的公共⽅法
public static Singleton getInstance() {
return SingletonInner.INSTANCE;
}
// 定义静态内部类
private static class SingletonInner {
private final static Singleton INSTANCE = new Singleton();
}
}
18.2 470.用 Rand7() 实现 Rand10()🟡
public int rand10() {
// 首先得到一个数
int num = (rand7() - 1) * 7 + rand7();
// 只要它还大于40,那你就给我不断生成吧
while (num > 40)
num = (rand7() - 1) * 7 + rand7();
// 返回结果,+1是为了解决 40%10为0的情况
return 1 + num % 10;
}
18.3 交替打印奇偶数
写两个线程打印 1-n,⼀个线程打印奇数,⼀个线程打印偶数。
import java.util.concurrent.Semaphore;
class ParityPrinter {
private final int max; // 最⼤打印数字
// 当前打印数字
private int currentPrintNumber = 1;
// 创建两个Semaphore信号量,⽤于控制打印奇数和偶数的顺序
private final Semaphore oddSemaphore = new Semaphore(1);
private final Semaphore evenSemaphore = new Semaphore(0);
public ParityPrinter(int max) {
this.max = max;
}
/**
* 打印奇数
*/
public void printOdd() {
while (currentPrintNumber < max) {
try {
// 获取奇数信号量的许可
oddSemaphore.acquire();
// 如果当前数字是奇数
if (currentPrintNumber % 2 != 0) {
System.out.println(Thread.currentThread().getName()
+ " : " + currentPrintNumber);
currentPrintNumber++;
}
// 释放偶数信号量,允许打印偶数
evenSemaphore.release();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
/**
* 打印偶数
*/
public void printEven() {
while (currentPrintNumber < max) {
try {
// 获取偶数信号量的许可
evenSemaphore.acquire();
// 如果当前数字是偶数
if (currentPrintNumber % 2 == 0) {
System.out.println(Thread.currentThread().getName()
+ " : " + currentPrintNumber);
currentPrintNumber++;
}
// 释放奇数信号量,允许打印奇数
oddSemaphore.release();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
// 打印 1-100
ParityPrinter printer = new ParityPrinter(100);
// 创建打印奇数和偶数的线程
// 实际答题过程中请看清题意,这⾥为线程起名只是为了⽅便理解
Thread t1 = new Thread(printer::printOdd, "Odd Printer");
Thread t2 = new Thread(printer::printEven, "Even Printer");
t1.start();
t2.start();
}
}
18.4 交替打印 ABC
问题描述:写三个线程打印 “ABC”,⼀个线程打印 A,⼀个线程打印 B,⼀个线程打印 C。
import java.util.concurrent.Semaphore;
class ABCPrinter {
// 定义三个Semaphore,分别控制打印A、B、C的许可
private Semaphore semA = new Semaphore(1);
private Semaphore semB = new Semaphore(0);
private Semaphore semC = new Semaphore(0);
// 打印次数
private final int PRINT_COUNT = 10;
/**
* 打印"A"的线程
*/
public void printA() {
for (int i = 0; i < PRINT_COUNT; i++) {
try {
// 获取A的打印许可
semA.acquire();
System.out.println(Thread.currentThread().getName() + ": " + "A");
// 允许打印B
semB.release();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
/**
* 打印"B"的线程
*/
public void printB() {
for (int i = 0; i < PRINT_COUNT; i++) {
try {
// 获取B的打印许可
semB.acquire();
System.out.println(Thread.currentThread().getName() + ": " + "B");
// 允许打印C
semC.release();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
/**
* 打印"C"的线程
*/
public void printC() {
for (int i = 0; i < PRINT_COUNT; i++) {
try {
// 获取C的打印许可
semC.acquire();
System.out.println(Thread.currentThread().getName() + ": " + "C");
// 允许打印A
semA.release();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
ABCPrinter printer = new ABCPrinter();
// 创建打印A,B,C的线程
// 实际答题过程中请看清题意,这⾥为线程起名只是为了⽅便理解
Thread t1 = new Thread(printer::printA, "A Printer");
Thread t2 = new Thread(printer::printB, "B Printer");
Thread t3 = new Thread(printer::printC, "C Printer");
t1.start();
t2.start();
t3.start();
}
}