LeetCode 热题 HOT 100
本博客所有文章采用的授权方式为 自由转载-非商用-非衍生-保持署名 ,转载请务必注明出处,谢谢。
博客地址:VegaLau 的博客;
微信公众号:aurusr;
内容系本人学习、研究和总结,如有雷同,实属荣幸!如有错误,欢迎通过以上联系方式指正。
introduction
LeetCode 热题 HOT 100 - till 2021.04.14
标星* 小节 代表相关算法板块未涉及的 分类。
Ⅰ Array and String 数组及字符串
数组结构以及字符串相关的算法题目。
1 Two Pointers 双指针
使用双指针的思路。快慢指针:指针k(慢指针)用于替换元素,指针i(快指针)用于遍历序列。定义两个指针 slow 和 fast 分别为慢指针和快指针,其中慢指针表示处理出的数组的长度,快指针表示已经检查过的数组的长度,即 nums[fast] 表示待检查的第一个元素,nums[slow−1] 或 nums[slow] 为上一个应该被保留的元素所移动到的指定位置。对撞指针:两个指针 left 和 right 初始位于序列两端。
283. Move Zeroes 移动零
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
示例:
输入:[0,1,0,3,12]
输出:[1,3,12,0,0]
题解:
class Solution {
public void moveZeroes(int[] nums) {
int k = 0; // nums数组中[0, ..., k)的元素均为非0元素
for (int i = 0; i < nums.length; i++) {
if (nums[i] != 0) {
if (i == k) { // 用于优化数组前几个元素都为非0的情况
k++;
} else {
nums[k++] = nums[i];
nums[i] = 0;
}
}
}
}
}
tips:
- i是否等于k的判断要写在判断当前元素是否不为0之后,否则每次进入循环体i总等于k;
- 对于i不等于k的情况,就不需要交换i和k索引处元素,直接将k处元素赋值i处元素的值并将i处元素置0即可;
- 时间复杂度:O(n) n - 序列长度;
- 空间复杂度:O(1) 没有创建新的序列,只需要常数空间存放若干变量。
11. Container With Most Water 盛最多水的容器
给你 n 个非负整数 a1,a2,…,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
示例:
输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
思路:
设每一状态下水槽面积为 S(i,j),(0 <= i < j < n),由于水槽的实际高度由两板中的短板决定,则可得面积公式 S(i, j) = min(h[i], h[j]) × (j - i)。在每一个状态下,无论长板或短板收窄 1 格,都会导致水槽底边宽度 -1:若向内移动短板,水槽的短板 min(h[i], h[j]) 可能变大,因此水槽面积 S(i, j) 可能增大;若向内移动长板,水槽的短板 min(h[i], h[j]) 不变或变小,下个水槽的面积一定小于当前水槽面积。
题解:
class Solution {
public int maxArea(int[] height) {
int ans = 0;
for (int i = 0, j = height.length - 1; i < j;) {
ans = Math.max(Math.min(height[i], height[j]) * (j - i), ans);
if (height[i] < height[j]) {
i++;
} else {
j--;
}
}
return ans;
}
}
tips:
- 此题与167题思路相同;
- 时间复杂度:O(n) n - 序列长度;
- 空间复杂度:O(1) 没有创建新的序列,只需要常数空间存放若干变量。
240. Search a 2D Matrix II 搜索二维矩阵 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;
int n = matrix[0].length;
for (int i = 0, j = n - 1; i < m && j >= 0;) {
if (matrix[i][j] == target) {
return true;
} else if (matrix[i][j] < target) {
i++;
} else {
j--;
}
}
return false;
}
}
tips:
- 此题与167题思路相同;
- 时间复杂度:O(m + n);
- 空间复杂度:O(1)
15. 3Sum 三数之和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。答案中不可以包含重复的三元组。
示例:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
题解:
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
int len = nums.length;
if (len < 3) return result;
Arrays.sort(nums);
for (int i = 0; i < len - 2; i++) {
if (i > 0 && nums[i] == nums[i - 1]) continue;
if (nums[i] + nums[i + 1] + nums[i + 2] > 0) break;
if (nums[i] + nums[len -1] + nums[len - 2] < 0) continue;
int left = i + 1;
int right = len - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
List<Integer> ele = new ArrayList<>();
ele.add(nums[i]);
ele.add(nums[left]);
ele.add(nums[right]);
result.add(ele);
while (left < right && nums[left + 1] == nums[left]) left++;
left++;
while (left < right && nums[right - 1] == nums[right]) right--;
right--;
} else if (sum < 0) {
left++;
} else {
right--;
}
}
}
return result;
}
}
tips:
- 此题与18题思路相同;
- 时间复杂度:快速排序O(nlogn) + 两重循环O(n^2) = O(n^2);
- 空间复杂度:忽略存储答案的空间,O(nlogn)
42. Trapping Rain Water 接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例:
输入: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 个单位的雨水(蓝色部分表示雨水)。
思路:
对于下标 i,雨水能到达的最大高度等于下标 i 两边的最大高度的最小值,下标 i 处能接的雨水量等于下标 i 处的水能到达的最大高度减去 height[i]。暴力算法是对数组 height 中的每个元素,分别向左和向右扫描并记录左边和右边的最大高度,然后计算每个下标位置能接的雨水量,其时间复杂度为O(n^2)。动态规划算法是如果已经知道每个位置两边的最大高度,创建两个长度为 n 的数组 leftMax 和 rightMax。对于 0≤i<n,leftMax[i] 表示下标 i 及其左边的位置中height 的最大高度,rightMax[i] 表示下标 i 及其右边的位置中height 的最大高度,当 1≤i≤n−1 时,leftMax[i]=max(leftMax[i−1],height[i]),同理当 0≤i≤n−2 时,rightMax[i]=max(rightMax[i+1],height[i]),则可以在 O(n) 的时间内得到能接的雨水总量。
双指针的思路:
可以将空间复杂度降到 O(1)。下标 i 处能接的雨水量由leftMax[i] 和 rightMax[i] 中的最小值决定。由于数组 leftMax 是从左往右计算,数组 rightMax 是从右往左计算,因此可以使用双指针和两个变量代替两个数组。指针移动的过程中维护两个变量 maxL 和 maxR 的值。
题解:
class Solution {
public int trap(int[] height) {
int len = height.length;
int result = 0;
if (len == 0) return result;
int left = 0, right = len - 1;
int maxL = height[left], maxR = height[right];
while (left < right) {
if (maxL < maxR) { // “只取当前有效的leftMax[i]或rightMax[i]的一边更新result”,即较小的最大柱子高度,另一边虽然可能有比当前maxSide更高的柱子,但是当前maxSide已经大于有效边的maxSide,继续遍历下去只会比当前maxSide更大,maxSide同样还是会大于有效边的maxSide,故有效边的left/right更新一定是正确的
result += maxL - height[left];
maxL = Math.max(maxL, height[++left]);
} else {
result += maxR - height[right];
maxR = Math.max(maxR, height[--right]);
}
} // 退出循环后总有一个单位长度未计算,但是此单位长度一定为数组中的最大值
return result;
}
}
tips:
- 时间复杂度:O(n);
- 空间复杂度:O(1)
2 Sliding Window 滑动窗口
使用双指针,滑动窗口的思路,定义两个指针left和right分别表示子数组(滑动窗口)的开始位置和结束位置,实现不同的功能。
3. Longest Substring Without Repeating Characters 无重复字符的最长子串
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。
示例:
输入:s = “pwwkew”
输出:3
解释:因为无重复字符的最长子串是 “wke”,所以其长度为 3。
思路:
定义两个指针left和right分别表示子数组(滑动窗口)的开始位置和结束位置。判断子串是否含有重复字符还需要使用额外的数据结构比如数组存储ASCII为数组索引值的字符在子串(滑动窗口)中出现的频率。此题是不满足要求移动左指针寻找新的满足要求的子数组。
题解:
class Solution {
public int lengthOfLongestSubstring(String s) {
int[] charFreq = new int[128]; // 存储ASCII为数组索引值的字符在子串(滑动窗口)中出现的频率
int left = 0, right = -1; // nums[left, right]为滑动窗口
int maxLen = 0;
while (left < s.length()) {
if (right + 1 < s.length() && charFreq[s.charAt(right + 1)] == 0) {
charFreq[s.charAt(++right)]++;
} else {
charFreq[s.charAt(left++)]--;
}
maxLen = Math.max(maxLen, right - left + 1); // 当前子串(滑动窗口)一定不含有重复字符
}
return maxLen;
}
}
tips:
- 对于数组来说需要注意是否越界问题,同理对于字符串来说,使用charAt()方法取值也需要注意是否越界的问题;
- char类型在运算的时候会被首先提升为int类型然后再计算,适用于算术运算、比较运算、赋值运算和数组的索引赋值,此题运用了数组的索引赋值;
- 判断时只需要使用子串起始索引left就可以,不需要再附加终止索引right的判断;
- 当字符串中出现较长的相同连续字符时left与right会交替的向前移动(滑动窗口);
- 时间复杂度:O(n);
- 空间复杂度:O(∣Σ∣),此题字符集为所有 ASCII 码在 [0,128) 内的字符,即∣Σ∣=128。
438. Find All Anagrams in a String 找到字符串中所有字母异位词
给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。字符串只包含小写英文字母。字母异位词指字母相同,但排列不同的字符串。不考虑答案输出的顺序。
示例:
输入:s: “cbaebabacd” p: “abc”
输出:[0, 6]
解释:起始索引等于 0 的子串是 “cba”, 它是 “abc” 的字母异位词。起始索引等于 6 的子串是 “bac”, 它是 “abc” 的字母异位词。
思路:
窗口长度固定,每次向右移动一位,判断当前滑动窗口是否符合题意。
题解:
class Solution {
public List<Integer> findAnagrams(String s, String p) {
int len = p.length();
List<Integer> list = new ArrayList<>();
if (len > s.length()) return list;
int[] pFreq = new int[26];
int[] sFreq = new int[26];
int left = 0, right = len - 1;
boolean flag = true;
for (int i = 0; i < len; i++) {
pFreq[p.charAt(i) - 'a']++;
sFreq[s.charAt(i) - 'a']++;
}
while (right < s.length()) {
for (int i = 0; i < 26; i++) {
if (pFreq[i] != sFreq[i]) {
flag = false;
break;
}
}
if (flag) list.add(left);
sFreq[s.charAt(left++) - 'a']--;
if (right + 1 < s.length()) {
sFreq[s.charAt(++right) - 'a']++;
} else {
right++;
}
flag = true;
}
return list;
}
}
tips:
- 此题中判断s字符串滑动窗口中字母与p字符串中字母出现的频率是否相等,即sFreq数组与pFreq元素是否相等,还可以使用java.util.Arrays类中的static boolean equals(int[] a, int[] a2)方法,如果两个指定的 int 型数组彼此相等,则返回 true;
- 时间复杂度:O(n);
- 空间复杂度:O(∣Σ∣),除去返回答案所用集合的空间。此题字符集(所有小写字母 )为所有 ASCII 码在 [97, 122] 内的字符,即∣Σ∣=26*2=52。
76. Minimum Window Substring 最小覆盖子串
给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
示例:
输入:s = “ADOBECODEBANC”, t = “ABC”
输出:”BANC”
思路:
不断增加right使滑动窗口增大,直到窗口包含了字符串t的所有元素,不断增加left使滑动窗口缩小,直到碰到一个必须包含的元素,这个时候不能再left++了,记录此时滑动窗口对应的字符串和上次保存的字符串取长度更小的进行保存,故初始定义结果字符串时其长度需要大于s字符串。使left再增加一个位置,此时滑动窗口肯定不满足条件了,那么继续使得right++寻找新的满足条件的滑动窗口。此题是满足要求移动左指针寻找新的不满足要求的子数组(与其他滑动窗口题目不同,左指针移动后右指针也移动)。
题解:
class Solution {
public String minWindow(String s, String t) {
if (t.length() > s.length()) return "";
int left = 0, right = t.length() - 1;
int len = t.length();
int[] tFreq = new int[58];
int[] sFreq = new int[58];
boolean flag = true;
String result = s + "a";
for (int i = 0; i < len; i++) {
tFreq[t.charAt(i) - 'A']++;
sFreq[s.charAt(i) - 'A']++;
}
while (right < s.length()) {
for (int i = 0; i < 58; i++) {
if (tFreq[i] != 0 && tFreq[i] > sFreq[i]) {
flag = false;
break;
}
}
if (flag) {
while (sFreq[s.charAt(left) - 'A'] > tFreq[s.charAt(left) - 'A']) sFreq[s.charAt(left++) - 'A']--;
result = result.length() > right - left + 1 ? s.substring(left, right + 1) : result;
sFreq[s.charAt(left++) - 'A']--;
}
flag = true;
if (right + 1 < s.length()) {
sFreq[s.charAt(++right) - 'A']++;
} else {
right++;
}
}
return result.length() > s.length() ? "" : result;
}
}
tips:
- 此题思路与438题相近,不同的是438题中需要两个频数数组相等,此题中sFreq >= tFreq时滑动窗口满足要求,否则sFreq < tFreq时不满足要求;
- 记录频次时减去’A’,char类型在运算的时候会被首先提升为int类型然后再计算,不需要查A对应的ASCII值;
- 判断时只需要使用子串终止索引right即可;
- 时间复杂度:O(n);
- 空间复杂度:O(∣Σ∣),除去返回答案所用集合的空间。此题字符集(所有大小写字母 )为所有 ASCII 码在 [65, 122] 内的字符(不包括大小写字母之间的91~96 ASCII值的字符),即∣Σ∣=58*2=116。
3 Hash Table 哈希表
使用底层实现为哈希表的Set及Map集合等的数组相关题目。哈希表(散列表)是根据关键码值(Key value)直接进行访问的数据结构。它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。哈希表插入、查找及删除的时间复杂度均为O(1)。
347. Top K Frequent Elements 前 K 个高频元素
给你一个整数数组 nums
和一个整数 k
,请你返回其中出现频率前 k
高的元素。你可以按任意顺序返回答案。
示例:
输入:nums = [1,1,1,2,2,3], k = 2
输出:[1,2]
题解:
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freq = new HashMap<>();
int len = nums.length;
for (int i = 0; i < len; i++) {
freq.put(nums[i], freq.getOrDefault(nums[i], 0) + 1);
}
List<Integer>[] bucket = new List[len + 1];
for (int key : freq.keySet()) {
int value = freq.get(key);
if (bucket[value] == null) bucket[value] = new ArrayList();
bucket[value].add(key);
}
int count = 0; // 计数当前为出现频率第k高的元素
int[] result = new int[k];
for (int i = len; count < k; i--) {
if (bucket[i] != null) {
for (int ele : bucket[i]) {
if (count < k) {
result[count++] = ele;
} else {
break;
}
}
}
}
return result;
}
}
tips:
- 与451题思路相同;
- 时间复杂度:O(n)
- 空间复杂度:O(n)
49. Group Anagrams 字母异位词分组
给定一个字符串数组,将字母异位词组合在一起。字母异位词指字母相同,但排列不同的字符串。所有输入均为小写字母。不考虑答案输出的顺序。
示例:
输入:[“eat”, “tea”, “tan”, “ate”, “nat”, “bat”]
输出:
[
["ate","eat","tea"],
["nat","tan"],
["bat"]
]
思路:
互为字母异位词的两个字符串包含的字母相同,因此两个字符串中的相同字母出现的次数一定是相同的,将每个字母出现的次数使用字符串表示,作为哈希表的键。键的类型为String,内容为从a到z的出现字母拼接此字母的出现次数。
题解:
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> resMap = new HashMap<>();
for (String str : strs) {
int[] freq = new int[26];
for (int i = 0; i < str.length(); i++) {
freq[str.charAt(i) - 'a']++;
}
StringBuffer keySb = new StringBuffer();
for (int i = 0; i < 26; i++) {
if (freq[i] != 0) {
keySb.append((char) (i + 'a')).append(freq[i]);
}
}
String keyStr = keySb.toString();
List<String> value = resMap.getOrDefault(keyStr, new ArrayList<String>());
value.add(str);
resMap.put(keyStr, value);
}
return new ArrayList<List<String>>(resMap.values());
}
}
tips:
- 使用java.lang.StringBuffer类,线程安全的可变字符序列,一个类似于 String 的字符串缓冲区。StringBuffer append(char c) 方法将 char 参数的字符串表示形式追加到此序列,并返回当前对象自身。使用链式编程(如果方法的返回值是一个对象,可以根据对象继续调用方法);
- 通过键获取值时,使用 java.util.Map<K,V>接口类中的default V getOrDefault (Object key, V defaultValue) 方法(JDK1.8 新特性)返回指定键映射到的值,如果此映射不包含该键的映射,则返回 defaultValue。来在第一次获取值时新建值ArrayList
对象; - 返回值使用ArrayList(Collection<? extends E> c) 构造一个包含指定 collection 的元素的列表,这些元素是按照该 collection 的迭代器返回它们的顺序排列的。来构造新的结果集合,且参数使用Map<K,V>类中的Collection
values() 方法(新特性)返回此map集合中包含的值的Collection视图; - 时间复杂度:OO(n*(k+∣Σ∣)),其中 n 是 strs 中的字符串的数量,k 是 strs 中的字符串的的最大长度,Σ 是字符集,在本题中字符集为所有小写字母,∣Σ∣=26;
- 空间复杂度:O(n*(k+∣Σ∣)),需要用哈希表存储全部字符串,而记录每个字符串中每个字母出现次数的数组需要的空间为 O(∣Σ∣),在渐进意义下小于 前者,可以忽略不计。
4 Hash Table (LUT) 哈希表(查找表)
使用底层实现为哈希表的Set及Map集合等的数组相关题目。查找表(Look-Up Table)是由同一类型的数据元素(或记录)构成的集合。查找操作为根据给定的某个K值,在查找表中寻找一个其键值等于K的数据元素,利用查找表的key及value来解决问题。
1. Two Sum 两数之和
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 的那 两个 整数,并返回它们的数组下标。你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。你可以按任意顺序返回答案。
示例:
输入:nums = [3,2,4], target = 6
输出:[1,2]
思路:
使用Map集合存储数组的值及其索引,对于数组每一个元素 i,首先查询哈希表中是否存在 target - i 再将 i 插入到哈希表中,即能保证同一个元素在答案里不重复出现。
题解:
class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> numsMap = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (numsMap.containsKey(complement)) return new int[] {numsMap.get(complement), i};
numsMap.put(nums[i], i);
}
return null;
}
}
tips:
- 时间复杂度:O(n),哈希表插入、查找及删除的时间复杂度均为O(1);
- 空间复杂度:O(n)
128. Longest Consecutive Sequence 最长连续序列
给定一个未排序的整数数组 nums
,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。设计并实现时间复杂度为 O(n)
的解决方案。
示例:
输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4],它的长度为 4。
思路:
使用Set集合存储数组中的所有元素,遍历数组中的元素,如果集合中存在 当前元素-1 的元素则代表此元素不是连续子序列(数组内)的左边界(查找表),不存在则代表此元素为一个连续子序列(此子序列也可能长度为1)的左边界:不断查找当前元素值+1的数在数组中是否存在且同时记录连续子序列长度以更新结果。
题解:
class Solution {
public int longestConsecutive(int[] nums) {
Set<Integer> eles = new HashSet<>();
int result = 0;
for (int i = 0; i < nums.length; i++) {
eles.add(nums[i]);
}
for (int num : nums) {
int len = 1;
if (!eles.contains(num - 1)) {
while (eles.contains(++num)) len++;
result = Math.max(result, len);
}
}
return result;
}
}
tips:
- 可以使用增强for循环来遍历集合和数组,其底层使用的是迭代器Iterator,当遍历有序的集合或者数组时,遍历的元素有序(按其在序列中的存储顺序);
- 时间复杂度:O(n),外层循环需要 O(n) 的时间复杂度,只有当一个数是连续序列的第一个数的情况下才会进入内层循环中匹配连续序列中的数,因此数组中的每个数只会进入内层循环一次;
- 空间复杂度:O(n),哈希表存储数组中所有的数需要 O(n) 的空间。
5 Quick Sort - Partition (Divide and Conquer) 快速排序 - 三路快排(分治算法)
使用快速排序、三路快排的思路。
75. Sort Colors 颜色分类
给定一个包含红色、白色和蓝色,一共 n
个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。使用整数 0
、 1
和 2
分别表示红色、白色和蓝色。
示例:
输入:nums = [2,0,1]
输出:[0,1,2]
思路:
题解:
class Solution {
public void sortColors(int[] nums) {
int zero = -1; // [0, ..., zero]的元素都为0
int two = nums.length; // [two, ..., nums.length - 1]的元素都为2
for (int i = 0; i < two; ) { // 索引i用于遍历
if (nums[i] == 0) {
nums[i++] = nums[++zero];
nums[zero] = 0;
} else if (nums[i] == 2) { // 这里i不能递增,因为和--two处元素交换,--two处的元素为一不确定的值(未检查的值)
nums[i] = nums[--two];
nums[two] = 2;
} else { // nums[i] == 1
i++;
}
}
}
}
tips:
- 时间复杂度:O(n) n - 序列长度;
- 空间复杂度:O(1),没有创建新的序列,只需要常数空间存放若干变量。
215. Kth Largest Element in an Array 数组中的第K个最大元素
在未排序的数组中找到第 k 个最大的元素。
示例:
输入:[3,2,3,1,2,4,5,5,6] 和 k = 4
输出:4
思路:
用快速排序来解决这个问题,先对原数组排序,再返回倒数第 k 个位置,这样平均时间复杂度是 O(nlogn)。每次经过「划分」操作后,一定可以确定一个元素的最终位置,即 x 的最终位置为 q,并且保证 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。所以只要某次划分的 q 为倒数第 k 个下标的时候,就已经找到了答案。 只需关心这一点,至于 a[l⋯q−1] 和 a[q+1⋯r] 是否是有序的,不需要关心。改进快速排序算法:在分解的过程当中,对子数组进行划分,如果划分得到的 q 正好就是需要的下标,就直接返回 a[q];否则,如果 q 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。
题解:
class Solution {
Random random = new Random();
public int findKthLargest(int[] nums, int k) {
int index = nums.length - k; // 第k大元素的索引
return randomPartition(nums, 0, nums.length - 1, index);
}
// 用于判别继续向左半部分还是右半部分划分(迭代),pivot中轴值在下一迭代部分中随机选取
private int randomPartition(int[] nums, int left, int right, int index) {
int pivotIndex = random.nextInt(right - left + 1) + left;
swap(nums, pivotIndex, right); // 将pivot中值交换到数组末尾
int result = partition(nums, left, right);
if (result == index) return nums[result];
return result > index ? randomPartition(nums, left, result - 1, index) : randomPartition(nums, result + 1, right, index);
}
// 划分的方法
private int partition(int[] nums, int left, int right) {
int pivot = nums[right]; // 中轴值(位于数组末尾)
int i = left - 1; // [left, ..., i]的元素都小于pivot,[i + 1, ..., right]的元素都大于等于pivot
for (int j = left; j < right; j++) {
if (nums[j] < pivot) {
swap(nums, ++i, j);
}
}
swap(nums, i + 1, right); // 将中轴值归于正确的位置
return i + 1; // 中轴值的索引
}
// 数组中索引i及索引j处的元素对调位置
private void swap(int[] array, int i, int j) {
int temp = array[j];
array[j] = array[i];
array[i] = temp;
}
}
tips:
- 这里也用到了双指针的思想,指针i用于替换元素(慢指针),指针j用于遍历数组(快指针);
- 时间复杂度:O(n),快速排序的性能和「划分」出的子数组的长度密切相关。直观地理解如果每次规模为 n 的问题都划分成 1 和 n - 1,每次递归的时候又向 n−1 的集合中递归,这种情况是最坏的,时间代价是 O(n^2)。这里引入随机化来加速这个过程,它的时间代价的期望是 O(n)。(证明过程:《算法导论》9.2)
- 空间复杂度:O(logn),递归使用栈空间的空间代价的期望为O(logn)。
6 Prefix Sum 前缀和
通常涉及连续子数组的问题,使用前缀和来解决。通过前缀和数组可以轻松得到每个区间(子数组)的和。前缀和数组sum保存数组num前 n 位的和,sum[i] = num[0] + num[1] + … + num[i],则区间(子数组)num[i, …, j]所有元素的和为sum[j] - sum [i - 1]。
560. Subarray Sum Equals K 和为K的子数组
给定一个整数数组和一个整数 k,你需要找到该数组中和为 k 的连续的子数组的个数。
示例:
输入:nums = [1,1,1], k = 2
输出:2 , [1,1] 与 [1,1] 为两种不同的情况。
思路:
定义 pre[i] 为 nums[0..i] 里所有数的和,即以元素i为结尾的前缀和,使用前缀和及哈希表(查找表)的思路,建立哈希表 map,以前缀和为键,出现次数为对应的值,记录 pre[i] 出现的次数。每遍历一个元素i,考虑以 i 结尾的和为 k 的连续子数组个数,只需要统计有多少个前缀和为 pre[i]−k 的 pre[j],即nums[j+1, …, i]连续子数组的和为k,总的数组中和为k的连续子数组的不同种数就为以每遍历的元素i结尾情况的总和。
题解:
class Solution {
public int subarraySum(int[] nums, int k) {
Map<Integer, Integer> prefixSumCount = new HashMap<>();
prefixSumCount.put(0, 1); // 前缀和为0的次数为1
int sum = 0;
int result = 0;
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
result += prefixSumCount.getOrDefault(sum - k, 0);
prefixSumCount.put(sum, prefixSumCount.getOrDefault(sum, 0) + 1);
}
return result;
}
}
tips:
- 满足要求的连续子数组可以为当前元素的前缀和(即为nums[0, …, i]),故统计前缀和出现次数的map集合需要初始化为 key 为 0 value 为 1 的键值对;
- 时间复杂度:O(n);
- 空间复杂度:O(n),哈希表在最坏情况下可能有 n 个不同的键值。
7 Binary Search 二分查找*
排序数组中的搜索问题,一般使用 二分查找 解决。
二分/折半查找算法,是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。对于二分查找算法的题目,重点是边界条件的判断(首先要明确变量的含义,其次注意各位置比较运算符的正确性)。
4. Median of Two Sorted Arrays 寻找两个正序数组的中位数
给定两个大小分别为 m
和 n
的正序(从小到大)数组 nums1
和 nums2
。请你找出并返回这两个正序数组的 中位数 。
示例:
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
思路:
使用二分查找及划分数组的思路。将两个数组进行切分,由于 0 <= m1 <= len1 ,为了保证 0 <= m2 <= len2,则必须保证 len1 <= len2。
题解:
class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int len1 = nums1.length;
int len2 = nums2.length;
if (len1 > len2) return findMedianSortedArrays(nums2, nums1);
int k = (len1 + len2 + 1) / 2; // 需要从两个数组中总共选取的元素的个数
int left = 0, right = len1; // m1可能取的值的范围
while (left < right) { // 因为循环内不会出现m1 = len1的情况,所以不会出现m2 = 0的情况
int m1 = (left + right) / 2;
int m2 = k - m1;
if (nums1[m1] < nums2[m2 - 1]) { // 不会有越界的问题
left = m1 + 1;
} else {
right = m1;
}
}
int m1 = left; // 从nums1数组中选取元素的个数,最大为len1
int m2 = k - m1; // 从nums2数组中选取元素的个数
int c1 = Math.max(m1 == 0 ? Integer.MIN_VALUE : nums1[m1 - 1], m2 == 0 ? Integer.MIN_VALUE : nums2[m2 - 1]);
if ((len1 + len2) % 2 == 1) return c1;
int c2 = Math.min(m1 == len1 ? Integer.MAX_VALUE : nums1[m1], m2 == len2 ? Integer.MAX_VALUE : nums2[m2]);
return (c1 + c2) * 0.5;
}
}
tips:
- 选取最短的数组以进行二分查找采用递归的方式(最多只自己调用自己一次);
- 对于return (c1 + c2) * 0.5,一旦运算当中有不同类型的数据,那么结果将会是数据类型范围大的那种,一个int类型与double类型运算时–>int类型将会自动提升为double类型进行运算;
- 时间复杂度:O(log(min(m, n))),其中 m 和 n 分别是数组 nums1 和 nums2 的长度。查找的区间是 [0, min(m, n)],而该区间的长度在每次循环之后都会减少为原来的一半。所以只需要执行 log(min(m, n)) 次循环;
- 空间复杂度:O(1)
33. Search in Rotated Sorted Array 搜索旋转排序数组
整数数组 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 。
示例:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
思路:
题目描述的旋转后的排序数组,二分查找过程中每次将数组一分为二后,其中一定有一部分是有序的,另一个部分可能是有序也能是部分有序。对于前半部分有序的情况(即 nums[left] <= nums[mid],这里使用小于等于是为了当循环只剩两个数的时候mid值会和left值相等,而target对应的数在right位置),当target在此前半部分中(即 nums[left] <= target && target < nums[mid],这里还需要加上 nums[left] <= target 的判断才能保证target在此前半部分中,使用<=是为了target可能在left位置,后半部分有序同理)则在此部分中继续搜索,否则在另一可能部分有序部分搜索;对于后半部分有序的情况同理。eg:排序数组 [1 2 3 4 5 6 7] 旋转后可以大致分为 [2 3 4 5 6 7 1] 和 [6 7 1 2 3 4 5] 两类。
题解:
class Solution {
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) return mid;
if (nums[left] <= nums[mid]) { // 左边有序
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else { // 右边有序
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return -1;
}
}
tips:
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
34. Find First and Last Position of Element in Sorted Array 在排序数组中查找元素的第一个和最后一个位置
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]。
示例:
输入:nums = [5,7,7,8,8,8,8,10], target = 8
输出:[3,6]
思路:
此题 while循环使用 left < right 的判断方式,则当退出循环时 left = right,判断 nums[left] == target即找到。搜索过程中数组的中间元素mid使用位运算来求解,将位模式右移一位。求target在数组中的开始位置,mid中间数下取整,即mid = left + right » 1,当while循环的最后一次执行中,保证left = mid(因target <= nums[mid]时right = mid,防止陷入死循环);求target在数组中的结束位置,mid中间数上取整,即mid = left + right + 1 » 1,当while循环的最后一次执行中,保证right = mid(因target >= nums[mid]时left = mid,防止陷入死循环)。
题解:
class Solution {
public int[] searchRange(int[] nums, int target) {
int[] result = {-1, -1};
int len = nums.length;
if (len == 0) return result;
int left = 0, right = len - 1;
while (left < right) {
int mid = left + right >> 1;
if (target <= nums[mid]) {
right = mid;
} else {
left = mid + 1;
}
}
if (nums[left] != target) {
return result;
} else { // 已找出target在数组中的开始位置
result[0] = left;
right = len - 1; // 不再重新定义left = 0,使搜索范围缩小为target开始位置到数组末尾,提高程序执行效率
while (left < right) {
int mid = left + right + 1 >> 1;
if (target >= nums[mid]) {
left = mid;
} else {
right = mid - 1;
}
}
result[1] = left; // 即使target只出现了一次,进入此循环的有效数组范围任包括target开始位置,故可找到其结束位置=开始位置
return result;
}
}
}
tips:
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
287. Find the Duplicate Number 寻找重复数
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设 nums 只有 一个重复的整数 ,找出 这个重复的数 。
示例:
输入:nums = [1,3,4,2,2]
输出:2
思路:
使用二分查找的思路。假设数组nums中重复的数为 target,在二分查找的循环体内定义变量count表示数组中比当前mid值小的元素的个数,那么对于 mid = [1, …, i, …, target−1] 里的所有数满足 count ≤ i,mid = [target, …, i, …, len] 里的所有数满足 count > i,具有单调性(故可以使用二分查找的方法)。
正确性验证:考虑 nums 数组一共有 n+1 个位置,填入的数字都在 [1, n] 间,有且只有一个数重复放了两次以上。对于所有测试用例,如果 target 出现了两次,其余的数各出现了一次,则小于 target 的数 i 满足 count = i,大于等于 target 的数 j 满足 count = j+1;如果 target 出现了三次及以上,那么必然有一些数不在 nums 数组中了,这个时候相当于用 target 去替换了这些数,无论被替换的数 i 小于 target 或者大于等于 target,亦满足条件。
题解:
class Solution {
public int findDuplicate(int[] nums) {
int len = nums.length;
int left = 1, right = len - 1;
while (left < right) {
int mid = left + right >> 1;
int count = 0; // 数组中小于mid值元素的个数
for (int num : nums) {
if (num <= mid) count++;
}
if (count <= mid) {
left = mid + 1;
} else {
right = mid;
}
} // 退出循环时left = right
return left;
}
}
tips:
- 二分查找过程中无需使用索引,因为数组的所有元素都在 [1, len] 间;
- 时间复杂度:O(nlogn),二分查找需要二分 O(logn) 次,每次判断时又需要 O(n) 次遍历求解数组中小于等于 mid 元素的个数,因此总时间复杂度为 O(nlogn);
- 空间复杂度:O(1)
8 Data Structure and Design 数据结构及其设计
使用队列、栈、集合、链表等数据结构的常规题目,以及实现特定功能的数据结构设计题目。
239. Sliding Window Maximum 滑动窗口最大值
给你一个整数数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。
示例:
输入: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
思路:
有序双端队列(有序的也即为单调队列)的思路。遍历数组,使用一个队列存储所有还没有被移除的下标(并非值)。在队列中,这些下标按照从小到大的顺序被存储,并且它们在数组 nums 中对应的值是严格单调递减的。当滑动窗口向右移动时,需要把一个新的元素放入队列中。如果当前遍历的数(窗口移动时的新增元素)比队尾的值大,则需要不断弹出队尾值,直到队列重新满足从大到小的要求,再添加当前遍历的数(因为之前比当前遍历的数小的元素的值之后滑动窗口选取元素最大值的时候一定用不上)。刚开始遍历时,有一个形成窗口的过程,当窗口大小形成(遍历到第一个窗口的最后一个索引)后,每次移动时,判断队首的值的数组下标是否在滑动窗口中,如果在则需要弹出队首的值,当前窗口的最大值即为队首的数。
题解:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int[] result = new int[nums.length - k + 1];
Deque<Integer> deque = new LinkedList<Integer>(); // 双端队列
for (int i = 0; i < nums.length; i++) {
while (deque.size() > 0 && nums[i] > nums[deque.getLast()]) deque.removeLast();
deque.addLast(i); // 如果当前遍历的数比队尾的值大,则需要不断弹出队尾值,直到队列重新满足从大到小的要求,再添加当前遍历的数
if (i - k + 1 >= 0) result[i - k + 1] = nums[deque.getFirst()]; // 当前窗口的最大值即为队首的数
if (i - k + 1 >= deque.getFirst()) deque.removeFirst(); // 判断队首的值的数组下标是否在滑动窗口中,如果在则需要弹出队首的值
}
return result;
}
}
tips:
- 对java.util.Queue< E > 接口类的熟悉;
- 时间复杂度:O(n);
- 空间复杂度:O(k),新增最大长度为k的双端队列。
20. Valid Parentheses 有效的括号
给定一个只包括 ‘(‘,’)’,’{‘,’}’,’[‘,’]’ 的字符串 s ,判断字符串是否有效。有效字符串需满足:左括号必须用相同类型的右括号闭合。左括号必须以正确的顺序闭合。
示例:
输入:s = “([)]”
输出:false
思路:
遍历字符串中的所有字符,使用一个栈存储左括号,遍历到右括号则与栈顶弹出的左括号进行匹配,不匹配或者栈为空则返回false,退出循环后判断栈是否为空,不为空则返回false,否则返回true。栈顶元素反映了在嵌套的层次关系中最近需要匹配的元素。
题解:
class Solution {
public boolean isValid(String s) {
int len = s.length();
if (len % 2 == 1) return false;
Stack<Character> stack = new Stack<>();
for (int i = 0; i < len; i++) {
char ch = s.charAt(i);
if (ch == '(' || ch == '{' || ch == '[') {
stack.push(ch);
} else {
if (ch == ')') {
if (stack.empty() || stack.pop() != ch - 1) return false;
} else {
if (stack.empty() || stack.pop() != ch - 2) return false;
}
}
}
if (!stack.empty()) return false;
return true;
}
}
tips:
- 当数组长度为奇数时则直接返回false;
- 查询ASCII表,‘(’ 与 ‘)’间ASCII值差为1,‘[’ 与 ‘]’ 和 ‘{’ 与 ‘}’间ASCII值差为2;
- 时间复杂度:O(n);
- 空间复杂度:O(n),栈中字符数量最多为n
32. Longest Valid Parentheses 最长有效括号
给你一个只包含 '('
和 ')'
的字符串,找出最长有效(格式正确且连续)括号子串的长度。
示例:
输入:s = “”( ) ) ( ) ( ( ( ) )””
输出:4
思路:
遍历字符串中的所有字符,使用一个栈存储左括号,并且保证栈底永远为上一个未匹配的右括号的索引(即表示新的有效子串开始索引的上一个索引),其作用为更新有效子串长度时,当弹出 ‘(‘后栈顶元素可能为上一个未匹配的右括号的索引(即新的有效子串开始索引的上一个索引)(比如“) ( ) ( ( ) )”的例子),所以如果不保存上一个未匹配的右括号的索引,求有效子串长度时栈为空,无法获得当前有效子串开始索引的上一个索引。当遍历到右括号则弹出栈顶匹配的元素(左括号)。
题解:
class Solution {
public int longestValidParentheses(String s) {
Stack<Integer> stack = new Stack<>();
stack.push(-1); // 保证栈底永远为上一个未匹配的右括号的索引(即表示新的有效子串开始索引的上一个索引,故需要初始化栈底元素-1)
int result = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(') { // 当前元素为'('则将其索引添加到栈顶
stack.push(i);
} else {
stack.pop(); // 弹出与')'匹配的'('的索引(弹出后栈顶为上一个未匹配的左括号的索引或者上一个未匹配的右括号的索引(即新的有效子串开始索引的上一个索引)),或者当栈中只剩一个元素时弹出需要更新的上一个未匹配的右括号的索引(弹出后栈为空)
if (stack.empty()) {
stack.push(i); // 当栈中只剩一个元素时弹出后为空,更新上一个未匹配的右括号的索引
} else {
result = Math.max(result, i - stack.peek()); // 当前右括号匹配的有效子串长度,即当前右括号的索引减去上一个未匹配的左括号的索引的长度(不能使用当前右括号的索引减去上一个未匹配的右括号的索引,因为这其中可能还会有未匹配的左括号)或者当栈内所有'('全部匹配完毕则减去上一个未匹配的右括号的索引(新的有效子串开始索引的上一个索引)
}
}
}
return result;
}
}
tips:
- 做一道算法题之前,首先考虑代表性强的输入示例,能涵盖各种尽可能多的条件/情况(边界条件)。eg:此题的一个示例:( ) ) ( ) ( ( ( ) );
- 时间复杂度:O(n);
- 空间复杂度:O(n),栈的大小在最坏情况下会达到 n + 1
84. Largest Rectangle in Histogram 柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例:
输入:[2,1,5,6,2,3]
输出:10
思路:
使用单调栈(单调递增栈:栈内元素保持单调递增 / 单调递减栈:栈内元素保持单调递减)的思路。遍历数组,当新的元素(柱子高度)比栈顶元素(柱子高度对应的数组索引)大或者相等,就入栈(存入栈中的都为数组的索引),否则如果新的元素较小就一直将栈内元素弹出,直到栈顶比新元素小。每个元素(高度)都会入栈出栈一次,在出栈操作时得到前后边界并计算面积以更新结果,最终遍历结束对每个高度都求了一次面积,结果为其中的最大值。
每个元素(高度)计算面积时的边界为:left = 栈顶索引(一定为当前弹出高度左边的第一个比当前弹出高度底的位置,中间为之前弹出的比当前弹出高度高的元素,所以计算面积时此左边界一定正确),right = 当前元素高度的索引(一定为当前弹出高度右边的第一个比当前弹出高度底的位置,中间为之前弹出的比当前弹出高度高的元素,所以计算面积时此右边界一定正确)。因为边界位置总位于有效面积边界索引的左右两边,故高度数组需要左右各扩容一位且值(高度)为0。
题解:
class Solution {
public int largestRectangleArea(int[] heights) {
Stack<Integer> stack = new Stack<>();
int result = 0;
int len = heights.length;
int[] newHeights = new int[len + 2];
for (int i = 1; i < len + 1; i++) {
newHeights[i] = heights[i - 1];
}
stack.push(0);
for (int i = 1; i < len + 2; i++) {
while (newHeights[i] < newHeights[stack.peek()]) {
int heightIndex = stack.pop();
int left = stack.peek();
int right = i;
result = Math.max(result, (right - left - 1) * newHeights[heightIndex]);
}
stack.push(i);
}
return result;
}
}
tips:
- 时间复杂度:O(N),输入数组里的每一个元素最多入栈一次出栈一次;
- 空间复杂度:O(N),栈的空间最大为 N
85. Maximal Rectangle 最大矩形
给定一个仅包含 0
和 1
、大小为 rows x cols
的二维二进制矩阵,找出只包含 1
的最大矩形,并返回其面积。
示例:
输入:matrix = [[“1”,”0”,”1”,”0”,”0”],[“1”,”0”,”1”,”1”,”1”],[“1”,”1”,”1”,”1”,”1”],[“1”,”0”,”0”,”1”,”0”]]
输出:6
思路:
使用单调栈的思路。建立高度数组heights[matrix[0].length + 2],首先遍历矩阵数组的每一行,其次遍历此行内的每一个元素,对每行内的元素matrix[i][j],如果其等于1则在其高度数组heights[j+1]++(表示在已经遍历过的前几行当前列累加的1的高度上再加1单位长度的高度),如果等于0则将高度数组heights[j+1]当前列的累加1的高度重新置0,在一行中的所有元素遍历完成之后建立了当前行的有效高度数组heights[],之后建立单调栈当新的元素(1的高度)比栈顶元素(1的高度对应的数组索引)大或者相等,就入栈(存入栈中的都为数组的索引),否则如果新的元素较小就一直将栈内元素弹出,直到栈顶比新元素小。每个元素(高度)都会入栈出栈一次,在出栈操作时得到前后边界并计算面积以更新结果,最终遍历结束对每个高度都求了一次面积,结果为其中的最大值。对每一行执行上述操作即可求出结果。
题解:
class Solution {
public int maximalRectangle(char[][] matrix) { // 矩阵数组的元素类型为char
int result = 0;
int row = matrix.length;
if (row == 0) return result;
int col = matrix[0].length;
int[] heights = new int[col + 2];
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++){
if (matrix[i][j] == '1') {
heights[j + 1]++;
} else {
heights[j + 1] = 0;
}
}
Stack<Integer> stack = new Stack<>();
stack.push(0);
for (int j = 1; j < col + 2; j++) {
while (heights[j] < heights[stack.peek()]) {
int heightIndex = stack.pop();
int left = stack.peek();
int right = j;
result = Math.max(result, heights[heightIndex] * (right - left - 1));
}
stack.push(j);
}
}
return result;
}
}
tips:
- 与84题思路相似;
- 对于此类题目高度数组两端分别需要添加0元素的做法也成为哨兵;
- 时间复杂度:O(mn),其中 m 和 n 分别是矩阵的行数和列数;
- 空间复杂度:O(n),栈的空间最大为 n
221. Maximal Square 最大正方形
在一个由 '0'
和 '1'
组成的二维矩阵内,找到只包含 '1'
的最大正方形,并返回其面积。
示例:
输入:matrix = [[“1”,”0”,”1”,”0”,”0”],[“1”,”0”,”1”,”1”,”1”],[“1”,”1”,”1”,”1”,”1”],[“1”,”0”,”0”,”1”,”0”]]
输出:4
思路:
使用单调栈的思路,当有效矩形面积的宽度大于等于当前高度时更新结果。
题解:
class Solution {
public int maximalSquare(char[][] matrix) {
int result = 0;
int row = matrix.length;
if (row == 0) return result;
int col = matrix[0].length;
int[] heights = new int[col + 2];
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
if (matrix[i][j] == '1') {
heights[j + 1]++;
} else {
heights[j + 1] = 0;
}
}
Stack<Integer> stack = new Stack<>();
stack.push(0);
for (int j = 1; j < col + 2; j++) {
while (heights[j] < heights[stack.peek()]) {
int heightIndex = stack.pop();
int left = stack.peek();
int right = j;
int height = heights[heightIndex];
int width = right - left - 1;
if (width >= height) result = Math.max(result, height * height); // 宽度大于高度才能形成正方形
}
stack.push(j);
}
}
return result;
}
}
tips:
- 与85题思路相同;
- 时间复杂度:O(mn),其中 m 和 n 分别是矩阵的行数和列数;
- 空间复杂度:O(n),栈的空间最大为 n
739. Daily Temperatures 每日温度
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。
示例:
输入:temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
输出:[1, 1, 4, 2, 1, 1, 0, 0]
思路:
使用单调栈的思路,栈内存入的数为原数组索引值,其索引值对应的元素单调递减。结果数组为要想观测到更高的气温,至少需要等待的天数,等价于需要找到原数组后面第一个比当前大的数。遍历数组,数组中的值为待入栈元素,待入栈元素如果小于等于栈顶元素则直接入栈,如果大于则将栈顶元素出栈并且更新弹出的栈顶元素的结果数组对应的值(索引差),重复上述操作直到待入栈元素小于等于栈顶元素时入栈。因为每次出栈时,待入栈元素总为第一个比当前出栈元素大的元素,故两者索引差总为正确的(即满足题意)。如果气温在这之后都不会升高则在该位置用 0 来代替,int数组默认值为0,故未找到则为0。
题解:
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int len = temperatures.length;
int[] result = new int[len];
Stack<Integer> stack = new Stack<>();
stack.push(0);
for (int i = 1; i < len; i++) {
while (!stack.empty() && temperatures[i] > temperatures[stack.peek()]) { // 需要加上栈不为空的判断
result[stack.peek()] = i - stack.pop(); // 先执行赋值运算符左边的stack.peek()
}
stack.push(i);
}
return result;
}
}
tips:
- 数组是一种引用类型;
- 赋值运算的赋值运算符两边都有算数运算时,先运行左边的。例如 int[] b = new int[4]; int c = 1; b[++c] = ++c; 的结果为 b = [0, 0, 3, 0];
- 时间复杂度:O(n)
- 空间复杂度:O(n)
394. Decode String 字符串解码
给定一个经过编码的字符串,返回它解码后的字符串。编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k ,例如不会出现像 3a
或 2[4]
的输入。
示例:
输入:s = “3[a2[c]]”
输出:”accaccacc”
思路:
使用栈结构实现要求。输入字符串的括号内嵌套括号,需要从内向外生成与拼接字符串,这与栈的先入后出特性相对应。构建辅助栈 stack, 遍历字符串 s 中每个字符 c,当 c 为数字字符时,转化为数字 times,用于后续倍数计算;当 c 为字母时,在 res 尾部添加 c;当 c 为 [ 时,将当前 times 和 res 入栈,并将 res 置空和 times 置 0(记录此 [ 前的临时结果 res 入栈,用于发现对应 ] 后的拼接操作;记录此 [ 前的倍数 times 入栈,用于发现对应 ] 后,获取 times × res(当前 [ 到 ] 内的字符串 res) 字符串。入栈后 res 和 times 重新记录);当 c 为 ] 时,stack 出栈,拼接字符串 res = last_res + cur_times * res,其中 last_res 是上个 [ 到当前 [ 的字符串,cur_times 是当前 [ 到 ] 内字符串的重复倍数,res 是当前 [ 到 ] 内的字符串。(栈中存的为两个 [ 间的字符串和数字,字符串表示的为上一个应该和当前字符串拼接的字符串,数字为当前字符串需要重复的次数)
题解:
class Solution {
public String decodeString(String s) {
StringBuilder res = new StringBuilder();
int times = 0;
Stack<String> stackRes = new Stack<>();
Stack<Integer> stackTimes = new Stack<>();
for (Character ch : s.toCharArray()) {
if (ch == '[') {
stackRes.push(res.toString());
stackTimes.push(times);
res = new StringBuilder();
times = 0;
} else if (ch == ']') {
StringBuilder temp = new StringBuilder();
int mult = stackTimes.pop();
for (int i = 0; i < mult; i++) temp.append(res);
res = new StringBuilder(stackRes.pop() + temp);
} else if (ch >= '0' && ch <= '9') {
times = times * 10 + Integer.parseInt(ch + ""); // k可能不止一位数
} else res.append(ch);
}
return res.toString();
}
}
tips:
- for循环的条件判断语句是动态变化的(每次循环都会执行一次),按倍数拼接字符串循环的条件判断语句不能写为 i <stackTimes.pop(); ,因为在第一次进入循环的时候栈顶已弹出,之后的循环再次弹出栈顶则错误;
- StringBuilder 和 String 可以使用 + 来拼接,结果为String;
- 时间复杂度:O(n)
- 空间复杂度:O(n),辅助栈在极端情况下需要线性空间
155. Min Stack 最小栈
设计一个支持 push
,pop
,top
操作,并能在常数时间内检索到最小元素的栈。pop、top 和 getMin 操作总是在 非空栈 上调用。
push(x) —— 将元素 x 推入栈中。
pop() —— 删除栈顶的元素。
top() —— 获取栈顶元素。
getMin() —— 检索栈中的最小元素。
示例:
输入:[“MinStack”,”push”,”push”,”push”,”getMin”,”pop”,”top”,”getMin”]
[[],[-2],[0],[-3],[],[],[],[]]
输出:[null,null,null,null,-3,null,0,-2]
思路:
使用链表来实现,每一个节点都会存储当前栈(以当前节点为头节点的链表)中的最小元素,所以当pop删除栈顶元素时,如果当前删除元素为最小元素,下一个栈顶元素节点获取最小元素时也不会包括上一个被删除的元素。
题解:
class MinStack {
private Node head;
/** initialize your data structure here. */
public MinStack() {
}
public void push(int val) {
if (head == null) {
head = new Node(val, val);
} else {
head = new Node(val, Math.min(val, head.minVal), head);
}
}
public void pop() {
head = head.next;
}
public int top() {
return head.val;
}
public int getMin() {
return head.minVal;
}
private class Node {
int val;
int minVal;
Node next;
Node(int val, int minVal) {
this(val, minVal, null);
}
Node(int val, int minVal, Node next) {
this.val = val;
this.minVal = minVal;
this.next = next;
}
}
}
/**
* Your MinStack object will be instantiated and called as such:
* MinStack obj = new MinStack();
* obj.push(val);
* obj.pop();
* int param_3 = obj.top();
* int param_4 = obj.getMin();
*/
tips:
- this关键字用来访问本类内容,用法有三种:在本类的成员方法中访问本类的成员变量;在本类的成员方法中访问本类的另一个成员方法;在本类的构造方法中访问本类的另一个构造方法。此题创建对象使用到了第三种用法;
- 时间复杂度:O(1);
- 空间复杂度:O(n),其中 n 为总操作数。最坏情况下会连续插入 n 个元素,此时栈占用的空间为 O(n)。
146. LRU Cache LRU 缓存机制
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。实现 LRUCache 类:
ㅤa) LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存;
ㅤb) int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
ㅤc) void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
在 O(1) 时间复杂度内完成这两种操作。
示例:
输入:[“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]
思路:
使用哈希表和链表的数据结构实现功能。哈希表查找快但是数据无固定顺序,链表有顺序之分且插入删除快,但是查找慢。故 LRU 缓存算法的核心数据结构就是哈希链表(双向链表和哈希表)的结合体,即借助哈希表赋予了链表快速查找的特性,可以快速查找某个 key 是否存在缓存(链表)中,同时可以快速删除、添加节点。哈希表(HashMap集合)的key为关键字key,value为双向链表中的对应Node节点(此节点的key属性为关键字key,val属性为关键字key的值)。处理链表节点的同时需要更新哈希表中对应节点的映射。
使用双向链表而不是单向链表:需要删除操作。删除一个节点不仅要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度为 O(1)。
哈希表中已经存了 key,链表中还要存键值对:当缓存容量已满,不仅要删除最后一个 Node节点,还要将 map 中映射到该节点的 key 同时删除,而这个 key 只能由 Node 得到。如果 Node 结构中只存储 val,那么就无法得知 key 是什么,就无法删除 map 中的键,造成错误。
题解:
class LRUCache {
private int cap;
private Map<Integer, Node> map;
private DoubleList list;
public LRUCache(int capacity) {
this.cap = capacity;
this.map = new HashMap<>();
this.list = new DoubleList();
}
public int get(int key) {
if (!map.containsKey(key)) return -1;
int val = map.get(key).val;
put(key, val); // 需要将此键值对更新到链表的头节点
return val;
}
public void put(int key, int value) {
if (map.containsKey(key)) list.revome(map.get(key));
Node node = new Node(key, value);
if (list.size() == cap) {
Node last = list.removeLast();
map.remove(last.key);
}
list.addFirst(node);
map.put(key, node);
}
}
class Node {
int key, val;
Node pre, next;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
class DoubleList {
private Node head, tail;
private int size;
public void addFirst(Node node) {
if (head == null) {
head = node;
tail = node;
} else {
Node temp = head;
head = node;
head.next = temp;
temp.pre = head;
}
size++;
}
public void revome(Node node) {
if (head == node && tail == node) {
head = null;
tail = null;
} else if (head == node) {
head = head.next;
head.pre = null;
} else if (tail == node) {
tail = tail.pre;
tail.next = null;
} else {
node.pre.next = node.next;
node.next.pre = node.pre;
}
size--;
}
public Node removeLast() {
Node last = tail;
revome(tail); // 这里只能使用调用此类中的remove方法,因为tdil可能为链表中唯一节点元素(即是头结点又是未节点)或者仅为尾节点
return last;
}
public int size() {
return size;
}
}
/**
* // 使用LinkedHashMap类的实现,详细实现机制查看JDK API文档及LinkedHashMap类源码
* class LRUCache {
*
* private Map<Integer, Integer> cache;
*
* public LRUCache(int capacity) {
* this.cache = new LinkedHashMap<>(capacity, 0.75f, true) {
* @Override
* protected boolean removeEldestEntry(Map.Entry eldest) {
* return cache.size() > capacity;
* }
* };
* }
*
* public int get(int key) {
* return cache.getOrDefault(key, -1);
* }
*
* public void put(int key, int value) {
* cache.put(key, value);
* }
* }
*/
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache obj = new LRUCache(capacity);
* int param_1 = obj.get(key);
* obj.put(key,value);
*/
tips:
- 时间复杂度:O(1),对于 put 和 get 方法都是 O(1);
- 空间复杂度:O(capacity),哈希表和双向链表最多存储 capacity 个元素
9 Sort 排序*
对数组按特定规律的大小顺序进行排序,一般调用库函数 static
56. Merge Intervals 合并区间
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
示例:
输入:intervals = [[1,3],[2,6],[7,10],[15,18]]
输出:[[1,6],[7,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6]。
思路:
使用排序的思路。先根据区间的起始位置进行快速排序(升序),再进行 n−1 次 两两合并或者添加到结果集。
题解:
class Solution {
public int[][] merge(int[][] intervals) {
int len = intervals.length;
Arrays.sort(intervals, new Comparator<int[]>() { // 泛型为一维数组,第一参数为泛型数组(故最终为二维数组)
public int compare(int[] o1, int[] o2) {
return o1[0] - o2[0];
}
});
int[][] result = new int[len][2]; // 初始化为可能的最大长度
int index = -1;
result[++index] = intervals[0];
for (int i = 1; i < len; i++) {
if (intervals[i][0] > result[index][1]) { // 当前区间的起始位置大于结果数组中最后区间的终止位置则不合并,直接将当前区间加入结果数组
result[++index] = intervals[i];
} else { // 将当前区间合并至结果数组的最后区间
result[index][1] = Math.max(result[index][1], intervals[i][1]);
}
}
return Arrays.copyOf(result, index + 1); // 返回值需要截取有效长度
}
}
tips:
- 泛型E可以是数组;
- 使用成员变量 index 记录result二维数组的有效长度;
- 使用java.util.Arrays类中的 static
void sort(T[] a, Comparator<? super T> c) 方法,根据指定比较器产生的顺序对指定对象数组进行排序。 对二维数组intervals进行排序; - 快速排序的时间复杂度为O(nlogn),空间复杂度为O(logn);
- 时间复杂度:O(nlogn),排序sort方法的时间复杂度,除去排序的开销只需要一次线性扫描;
- 空间复杂度:O(logn),除存储答案所需要的空间外,排序所需要的空间复杂度为O(logn)
10 Union Find 并查集
并查集一般用于解决深度优先搜索(回溯)问题。
399. Evaluate Division 除法求值
给你一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi] 和 values[i] 共同表示等式 Ai / Bi = values[i] 。每个 Ai 或 Bi 是一个表示单个变量的字符串。另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请你根据已知条件找出 Cj / Dj = ? 的结果作为答案。返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。注意:输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。Ai, Bi, Cj, Dj 由小写英文字母与数字组成。
示例:
输入: equations = [[“a”,”b”],[“b”,”c”]], values = [2.0,3.0], queries = [[“a”,”c”],[“b”,”a”],[“a”,”e”],[“a”,”a”],[“x”,”x”]]
输出:[6.00000,0.50000,-1.00000,1.00000,-1.00000]
思路:
并查集。使用带权值的并查集,每条连接都有其对应权值,当 equations 数组中的两个数字 a 和 b(使用变量 a 及 b 代表具体值)具有倍数关系则将其存入到并查集中,这里并查集使用数组实现,将 equations 中所有的数字变量先存入 Map 集合中,键为变量的字符串表示,值为其在实现并查集的数组中的索引。对于一对有倍数关系的两数字变量 equation(a 和 b),将 a 的父节点设置为 b 节点,权值则为 a / b。与此同时,变量之间的倍数关系具有传递性,当进行路径压缩时,将路径上的权值进行累加相乘即可;进行两不连通节点的合并时,设置其中一节点 index1 的父节点 root1 的父节点为另一节点 index2 的父节点 root2 即可,权值则为 value * weight[index2] / weight[index1]。
题解:
class Solution {
public double[] calcEquation(List<List<String>> equations, double[] values, List<List<String>> queries) {
int equationsSize = equations.size();
Map<String, Integer> map = new HashMap<>(2 * equationsSize); // 使用传入初始容量的实例化方式,使哈希表无需自动扩容以提高性能
int index = 0;
UnionFind unionFind = new UnionFind(2 * equationsSize); // 极端情况下 equations 中的数字变量都不同
for (int i = 0; i < equationsSize; i++) {
List<String> equation = equations.get(i);
String a = equation.get(0);
String b = equation.get(1);
if (!map.containsKey(a)) map.put(a, index++); // 将变量与 index 进行映射,使得并查集底层可以使用数组实现
if (!map.containsKey(b)) map.put(b, index++);
unionFind.union(map.get(a), map.get(b), values[i]);
}
int queriesSize = queries.size();
double[] result = new double[queriesSize];
for (int i = 0; i < queriesSize; i++) {
List<String> query = queries.get(i);
String c = query.get(0);
String d = query.get(1);
if (!map.containsKey(c) || !map.containsKey(d)) {
result[i] = -1.0;
} else {
result[i] = unionFind.isConnected(map.get(c), map.get(d));
}
}
return result;
}
private class UnionFind { // 并查集:成员内部类
private int[] parent;
private double[] weight;
public UnionFind(int size) {
this.parent = new int[size];
this.weight = new double[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
weight[i] = 1;
}
}
public void union(int index1, int index2, double value) {
int root1 = find(index1);
int root2 = find(index2);
if (root1 == root2) return;
parent[root1] = root2;
weight[root1] = value * weight[index2] / weight[index1];
}
public int find(int index) {
if (index != parent[index]) {
int directRoot = parent[index];
parent[index] = find(directRoot);
weight[index] *= weight[directRoot];
}
return parent[index];
}
public double isConnected(int index1, int index2) {
int root1 = find(index1);
int root2 = find(index2);
if (root1 == root2) {
return weight[index1] / weight[index2];
} else {
return -1.0;
}
}
}
}
tips:
- HashMap 的一种构造方法:HashMap(int initialCapacity) 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap;
- 时间复杂度:O((N+Q)logA),N 为输入方程 equations 的长度,Q 为查询数组 queries 的长度,A 为 equations 中不同字符的个数。构造并查集的时间代价为 O(NlogA):每一次执行合并操作的时间复杂度是 O(logA);查询并查集的时间代价为 O(QlogA),每一次查询时执行路径压缩的时间复杂度是 O(logA)
- 空间复杂度:O(N):并查集底层使用的两个数组 parent 和 weight 存储每个变量的连通分量信息,长度为 2N
11 Greedy Algorithm 贪心算法
贪心算法,指在对问题进行求解时,在每一步选择中都采取最好或者最优(即最有利)的选择,从而能够导致结果是最好或者最优的算法。
贪婪算法所得到的结果不一定是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果。
使用到每一步选择都采取最优或最差的情况(贪心)的思想,也为贪心算法。
406. Queue Reconstruction by Height 根据身高重建队列
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
示例:
输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
思路:
贪心算法,使用排序的思路。
降序:将输入数组按h降序k升序排序,遍历数组,将元素 int[] 插入到k位置即 ele[1] 位置上(高个子先站好,矮个子插入到k位置上,前面肯定有k个高个子,矮个子再插到前面也满足已插入元素的k要求)。
[7,0], [7,1], [6,1], [5,0], [5,2], [4,4] // 降序排序后的数组,再遍历插入
[7,0]
[7,0], [7,1]
[7,0], [6,1], [7,1]
[5,0], [7,0], [6,1], [7,1]
[5,0], [7,0], [5,2], [6,1], [7,1]
[5,0], [7,0], [5,2], [6,1], [4,4], [7,1]
升序:将输入数组按h升序k降序排序,遍历数组,当前这个人就是剩下未安排的人中最矮的人,他的k值就代表他在剩余空位的索引值,如果有多个人高度相同,要按照k值从大到小领取索引值。
[ 0, 1, 2, 3, 4, 5 ] [ 4, 4 ] 4
[ 0, 1, 2, 3, 5 ] [ 5, 2 ] 2
[ 0, 1, 3, 5 ] [ 5, 0 ] 0
[ 1, 3, 5 ] [ 6, 1 ] 3
[ 1, 5 ] [ 7, 1 ] 5
[ 1 ] [ 7, 0 ] 1
[ [ 5, 0 ], [ 7, 0 ], [ 5, 2 ], [ 6, 1 ], [ 4, 4 ], [ 7, 1 ] ]
题解:
class Solution {
public int[][] reconstructQueue(int[][] people) { // 降序
Arrays.sort(people, new Comparator<int[]>() { // 泛型为一维数组,第一参数为泛型数组(故最终为二维数组)
@Override
public int compare(int[] o1, int[] o2) {
int res = o2[0] - o1[0];
if (res == 0) res = o1[1] - o2[1];
return res;
}
});
List<int[]> list = new ArrayList<>();
for (int[] ele : people) list.add(ele[1], ele);
return list.toArray(new int[people.length][2]);
}
}
/*class Solution {
public int[][] reconstructQueue(int[][] people) { // 升序
int[][] result = new int[people.length][2];
Arrays.sort(people, new Comparator<int[]>() { // 泛型为一维数组,第一参数为泛型数组(故最终为二维数组)
@Override
public int compare(int[] o1, int[] o2) {
int res = o1[0] - o2[0];
if (res == 0) res = o2[1] - o1[1];
return res;
}
});
List<Integer> index = new ArrayList<>();
for (int i = 0; i < people.length; i++) index.add(i);
for (int[] ele : people) {
result[index.get(ele[1])] = ele; // 数组可以这样赋值
index.remove(ele[1]); // 移除的应该为对应的链表中的索引而不是其在结果数组中的索引
}
return result;
}
}*/
tips:
- 泛型E可以是数组;
- 使用java.util.ArrayList
类中的 void add(int index, E element) 方法,将指定的元素插入此列表中的指定位置; - 使用java.util.ArrayList
类中的 T[] toArray(T[] a) 方法,按适当顺序(从第一个到最后一个元素)返回包含此列表中所有元素的数组,返回数组的运行时类型是指定数组的运行时类型; - java.util.ArrayList
类 size、isEmpty、get、set、iterator 和 listIterator 操作都以固定时间运行。add 操作以分摊的固定时间 运行,也就是说,添加 n 个元素需要 O(n) 时间。其他所有操作都以线性时间运行(大体上讲); - 时间复杂度:O(n^2),需要 O(nlogn) 的时间进行排序,随后需要 O(n^2) 的时间遍历每一个人并将他们放入队列中。由于前者在渐近意义下小于后者,因此总时间复杂度为 O(n^2);
- 空间复杂度:O(logn) 降序 / O(n) 升序,除去答案所需空间。
253. Meeting Rooms II 会议室 II
给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],…] (si < ei),为避免会议冲突,同时要考虑充分利用会议室资源,请你计算至少需要多少间会议室,才能满足这些会议安排。
示例:
输入: [[10, 24], [11, 50], [32, 77], [48, 84], [50, 73]]
输出:3
思路:
贪心算法。先将 Interval 数组由其 start 属性值进行升序排序,遍历排序后的 Interval 数组,使用一个小顶堆(使用java.util.PriorityQueue实现)存储每个元素的 end 属性,判断当前元素的 start 属性(一个新时间安排的开始时间)是否大于小顶堆中的栈顶元素(之前已考虑过时间安排的最早结束时间),小于则代表需要再安排一个会议室(即之前将其结束时间加入到小顶堆中),否则接着按栈顶元素所在会议室继续使用,同时更新此会议室的结束时间(已经将当前新时间安排加入到小顶堆中,只需移除队列头元素即可)。最终小顶堆的大小即为需要会议室的个数。
题解:
/**
* Definition for an interval.
* public class Interval {
* int start;
* int end;
* Interval() { start = 0; end = 0; }
* Interval(int s, int e) { start = s; end = e; }
* }
*/
class Solution {
public int minMeetingRooms(Interval[] intervals) {
Arrays.sort(intervals, new Comparator<Interval>() {
@Override
public int compare(Interval o1, Interval o2) {
return o1.start - o2.start;
}
});
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int i = 0; i < intervals.length; i++) {
minHeap.offer(intervals[i].end);
if (intervals[i].start >= minHeap.peek()) minHeap.poll();
}
return minHeap.size();
}
}
tips:
- java.util.PriorityQueue类是一个基于优先级堆的无界优先级队列。优先级队列的元素按照其自然顺序进行排序,或者根据构造队列时提供的 Comparator 进行排序,具体取决于所使用的构造方法。优先级队列不允许使用 null 元素。依靠自然顺序的优先级队列还不允许插入不可比较的对象(可能导致 ClassCastException)。 此队列的头是按指定排序方式确定的最小元素。如果多个元素都是最小值,则头是其中一个元素(选择方法是任意的)。队列获取操作 poll、remove、peek 和 element 访问处于队列头的元素。此实现不是同步的,此实现为排队和出队方法(offer、poll、remove() 和 add)提供 O(log(n)) 时间,为 remove(Object) 和 contains(Object) 方法提供线性时间;为获取方法(peek、element 和 size)提供固定时间。此题使用PriorityQueue类来实现小顶堆的功能;
- 时间复杂度:O(logn)
- 空间复杂度:O(n)
12 Broad First Search 广度优先搜索*
使用到广度优先搜索的数组/字符串题目。一般其能使用 BFS 计算,也能使用 DFS 计算,只是 BFS 更加便利。
301. Remove Invalid Parentheses 删除无效的括号
给你一个由若干括号和字母组成的字符串 s
,删除最小数量的无效括号,使得输入的字符串有效。返回所有可能的结果。答案可以按 任意顺序 返回。1 <= s.length <= 25。s 由小写英文字母以及括号 ‘(‘ 和 ‘)’ 组成。
示例:
输入:s = “(a)())()”
输出:[“(a())()”,”(a)()()”]
思路:
广度优先搜索。使用 Set 集合去重,对于广度优先搜索的每个层级(最外层 while 循环)只删除一个括号(每个层级的字符串元素的长度相同),观察此层级删除一个括号后的集合是否存在合法的字符串,若存在则无需继续广度优先搜索,直接返回此层集中合法的所有字符串集合。
题解:
class Solution {
public List<String> removeInvalidParentheses(String s) {
Set<String> level = new HashSet<>();
level.add(s);
while (!level.isEmpty()) {
List<String> result = new ArrayList<>(); // 结果集合
for (String ele : level) if (isVaild(ele)) result.add(ele); // 将此层级合法的字符串添加到结果集合
if (!result.isEmpty()) return result; // 存在合法字符串直接返回(结束搜索)
Set<String> nextLevel = new HashSet<>(); // 下一层级的字符串(使用 Set 集合去重)
for (String ele : level) { // 删除当前层级每个字符串的某一特定位置字符后的所有情况
for (int i = 0; i < ele.length(); i++) {
if (ele.charAt(i) == '(' || ele.charAt(i) == ')') { // 仅删除括号,其它字符跳过
StringBuilder sb = new StringBuilder(ele);
nextLevel.add(sb.deleteCharAt(i).toString());
}
}
}
level = nextLevel;
}
return new ArrayList<String>();
}
private boolean isVaild(String s) { // 判断字符串是否合法
int count = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(') {
count++;
} else if (s.charAt(i) == ')') count--;
if (count < 0) return false; // 存在右括号位于左括号前:不合法
}
return count == 0; // 是否存在剩余的未匹配左括号
}
}
tips:
- 时间复杂度:O(n^3)
- 空间复杂度:O(n)
13 General - Array & Math 常规 - 数组和数学
常规数组题目,遍历,条件判断,… 。以及数学题。
121. Best Time to Buy and Sell Stock 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
示例:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
题解:
class Solution {
public int maxProfit(int[] prices) {
int min = prices[0];
int result = 0;
for (int i = 1; i < prices.length; i++) {
if (prices[i] > min) {
result = Math.max(result, prices[i] - min);
} else {
min = prices[i];
}
}
return result;
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
169. Majority Element 多数元素
给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。你可以假设数组是非空的,并且给定的数组总是存在多数元素。
示例:
输入:[3,2,3]
输出:3
思路:
使用摩尔投票(Moore vote)算法的思路。候选人(result)初始化为nums[0],票数count初始化为1。遍历数组当遇到与result相同的数,则票数count++,否则票数count–。当票数count为0时需更换候选人为当前遍历的元素(使count=0的元素),并将票数count重置为1。遍历完数组后,result即为最终答案。
投票法是遇到相同的则票数 + 1,遇到不同的则票数 - 1。且“多数元素”的个数 > n/2,其余元素的个数总和 <= n/2,因此 “多数元素”的个数 - 其余元素的个数总和 的结果 >= 1。相当于每个“多数元素”和其他元素两两相互抵消,抵消到最后肯定还剩余至少1个“多数元素”。
题解:
class Solution {
public int majorityElement(int[] nums) {
int result = nums[0], count = 1;
for (int i = 1; i < nums.length; i++) {
if (nums[i] == result) {
count++;
} else if (--count == 0) {
result = nums[i];
count = 1;
}
}
return result;
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
448. Find All Numbers Disappeared in an Array 找到所有数组中消失的数字
给定一个范围在 1 ≤ a[i] ≤ n ( n = 数组大小 ) 的 整型数组,数组中的元素一些出现了两次,另一些只出现一次。找到所有在 [1, n] 范围之间没有出现在数组中的数字。在不使用额外空间且时间复杂度为O(n)的情况下完成这个任务,你可以假定返回的数组不算在额外空间内。
示例:
输入:[4,3,2,7,8,2,3,1]
输出:[5,6]
思路:
由于 nums 的数字范围均在 [1,n] 中, 此说明含义:数组元素的 索引 和 值 是 一对多 的关系,可以利用这一范围之外的数字,来表达「是否存在」的含义。遍历 nums 每遇到一个数 x,就将 nums[x−1] 增加 n。由于 nums 中所有数均在 [1,n] 中,增加以后这些数必然大于 n。再次遍历 nums,若 nums[i] 未大于 n,就说明没有遇到过数 i+1,就找到了缺失的数字。当遍历到某个位置时,其中的数可能已经被增加过,因此需要对 n 取模还原出它本来的值。
题解:
class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
int n = nums.length;
List<Integer> result = new ArrayList<>();
for (int index : nums) {
index = (index - 1) % n;
nums[index] += n;
}
for (int i = 0; i < n; i++) {
if (nums[i] <= n) result.add(i + 1);
}
return result;
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
581. Shortest Unsorted Continuous Subarray 最短无序连续子数组
给你一个整数数组 nums ,你需要找出一个 连续子数组 ,如果对这个子数组进行升序排序,那么整个数组都会变为升序排序。请你找出符合题意的 最短 子数组,并输出它的长度。
示例:
输入:nums = [2,6,4,8,10,9,15]
输出:5
解释:你只需要对 [6, 4, 8, 10, 9] 进行升序排序,那么整个表都会变为升序排序。
思路:
从左到右循环 nums,记录最大值为 max,若 nums[i] < max, 则表明位置 i 需要调整, 循环结束,记录需要调整的最大位置 i 为 high;从右到左循环 nums,记录最小值为 min, 若 nums[i] > min, 则表明位置 i 需要调整,循环结束,记录需要调整的最小位置 i 为 low。
题解:
class Solution {
public int findUnsortedSubarray(int[] nums) {
int max = Integer.MIN_VALUE, min = Integer.MAX_VALUE;
int high = 0, low = 1; // 当high指针更新时low指针在下一次遍历中也一定会更新,不更新则原序列升序应返回0,则需将原始左右指针定义为差值为-1(返回值为 high - low + 1)
for (int i = 0; i < nums.length; i++) {
if (nums[i] >= max) {
max = nums[i];
} else high = i;
}
for (int i = nums.length - 1; i >= 0; i--) {
if (nums[i] <= min) {
min = nums[i];
} else low = i;
}
return high - low + 1;
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
621. Task Scheduler 任务调度器
给你一个用字符数组 tasks 表示的 CPU 需要执行的任务列表。其中每个字母表示一种不同种类的任务。任务可以以任意顺序执行,并且每个任务都可以在 1 个单位时间内执行完。在任何一个单位时间,CPU 可以完成一个任务,或者处于待命状态。然而,两个 相同种类 的任务之间必须有长度为整数 n 的冷却时间,因此至少有连续 n 个单位时间内 CPU 在执行不同的任务,或者在待命状态。你需要计算完成所有任务所需要的 最短时间 。1 <= task.length <= 10^4,
tasks[i] 是大写英文字母,n 的取值范围为 [0, 100]。
示例:
输入:tasks = [“A”,”A”,”A”,”B”,”B”,”B”], n = 2
输出:8
解释:A -> B -> (待命) -> A -> B -> (待命) -> A -> B。在本示例中,两个相同类型任务之间必须间隔长度为 n = 2 的冷却时间,而执行一个任务只需要一个单位时间,所以中间出现了(待命)状态。
思路:
构造的思路。建立大小为 n+1 的桶,个数为任务数量最多的那个任务,例如当等待时间 n=2 ,A 任务(任务数量最多的任务)个数为 6 个时其形式如下(每行相同颜色代表一个桶):
将一个桶看作一轮任务:
情况 1) :总时间 = (桶个数 - 1) * (n + 1) + 最后一个桶的任务数
没有其他任务,完成任务 A 所需的时间应该是 (6 - 1) * 3 + 1 = 16,因为最后一个桶不存在等待时间;
或者存在其他任务,其他任务并没有对总体时间产生影响,因为它们被安排在了 A 任务(任务数量最多的任务)的冷却期间。与此同时,B 和 A 数量相同,这会导致最后一个桶子中需要多执行一次 B 任务,现在需要的时间是 (6 - 1) * 3 + 2 = 17。
情况 2) :总时间 = 任务的数量
当冷却时间短,任务种类很多时,可以临时扩充某些桶子的大小插进任务 F,在第一、二个桶里插入了任务 F,无论再继续插入多少任务都可以类似处理,而且新插入元素肯定满足冷却要求。这种情况下每个任务之间都不存在空余时间,冷却时间被完全填满。
题解:
class Solution {
public int leastInterval(char[] tasks, int n) {
int[] freq = new int[26];
int lastCount = 0; // 最后一个桶中元素的个数
int size = 0; // 桶的大小
for (char task : tasks) size = Math.max(size, ++freq[task - 'A']);
for (int i = 0; i < freq.length; i++) {
if (freq[i] == size) lastCount++;
}
return Math.max(tasks.length, (size - 1) * (n + 1) + lastCount);
}
}
tips:
- 时间复杂度:O(N),N 为任务的数量即 tasks 字符串数组的长度
- 空间复杂度:O(1)
31. Next Permutation 下一个排列
实现获取 下一个排列 的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。必须原地修改,只允许使用额外常数空间。
示例:
输入:nums = [1,2,3,4,6,5]
输出:[1,2,3,5,4,6]
思路:
倒序遍历数组,寻找到第一对升序的相邻元素 i-1 和 i,此时数组中从 i 到末尾都为降序,升序的元素 i-1 即为该替换的元素,在此元素 i-1 后查找一个比它大的且尽量小的元素与其替换,因数组从 i 到末尾都为降序,故再次倒序遍历数组,查找第一个大于元素 i 的元素即为替换的元素,替换元素 i-1 和j,与此同时,替换后数组从 i 到末尾还为降序,替换后还需要使排列增加的幅度尽可能小,故再对数组从 i 到末尾的子数组进行升序排序。
题解:
class Solution {
public void nextPermutation(int[] nums) {
int len = nums.length;
if (len <= 1) return;
for (int i = len - 1; i > 0; i--) {
if (nums[i] > nums[i - 1]) { // 倒序遍历查找第一对升序的相邻元素
for (int j = len - 1; j >= i; j--) { // 倒序遍历查找第一个大于nums[i-1]的元素,并且一定能找到
if (nums[j] > nums[i - 1]) {
int temp = nums[j];
nums[j] = nums[i - 1];
nums[i - 1] = temp;
reverse(nums, i, len - 1);
return; // 整个nextPermutation方法结束,将不会执行后续的语句
}
}
}
}
reverse(nums, 0, len - 1); // 未找到升序的相邻元素,则整个数组为降序(不存在下一个更大的排列)
}
private void reverse(int[] nums, int left, int right) {
while (left < right) {
int temp = nums[right];
nums[right--] = nums[left];
nums[left++] = temp;
}
}
}
tips:
- Java中标识符(在程序中自定义的类名、方法名和变量名等)不能是关键字(Java中已经定义好的单词,具有特殊含义,例如 public, class, static, void 等),故这里reverse方法名不能使用switch;
- 需要重新排序的子数组(数组)都为降序,则可以使用反转的方法来降低时间复杂度;
- 使用return语句起到结束方法继续执行的作用(短路);
- 时间复杂度:O(n),降序数组的升序排列时间复杂度也为O(n)
- 空间复杂度:O(1)
55. Jump Game 跳跃游戏
给定一个非负整数数组 nums
,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
示例:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
思路:
如果某一个作为 起跳点 的格子 i 可以跳跃的距离是 nums[i],那么表示后面 nums[i] 个格子都可以作为 起跳点。对每一个能作为 起跳点 的格子都尝试跳一次,把 能跳到最远的距离 不断更新。如果某次循环更新后的 能跳到最远的距离 大于数组最后一个下标,则退出循环。
题解:
class Solution {
public boolean canJump(int[] nums) {
int maxLen = 0; // 可以到达的最大有效距离的索引
for (int i = 0; i <= maxLen; i++) { // 条件判断语句的边界条件在每次执行循环体的过程中动态更新
maxLen = Math.max(maxLen, i + nums[i]);
if (maxLen >= nums.length - 1) return true;
}
return false;
}
}
tips:
- 使用了for循环条件判断语句动态变化的思路;
- 时间复杂度:O(n);
- 空间复杂度:O(1)
461. Hamming Distance 汉明距离
两个整数之间的汉明距离指的是这两个数字对应二进制位不同的位置的数目。给出两个整数 x
和 y
,计算它们之间的汉明距离。0 ≤ x
, y
< 23^1。
示例:
输入: x = 1, y = 4
输出:2
解释:1ㅤ(0 0 0 1)
ㅤㅤㅤ4ㅤ(0 1 0 0)
ㅤㅤㅤ ㅤ ㅤ ↑ㅤ ↑
题解:
class Solution {
public int hammingDistance(int x, int y) { // 调用库函数的形式
return Integer.bitCount(x ^ y);
}
}
/*class Solution {
public int hammingDistance(int x, int y) {
int z = x ^ y;
int result = 0;
for (int i = 0; i < 31; i++) { // 0 ≤ x, y,第32位都为0,只需比较31位
if (z % 2 == 1) result++;
z /= 2;
}
return result;
}
}*/
tips:
- 类似求十进制数的每位数,求二进制的每位数每次对原数除以2舍去低位再对2取模即为每位的数(1或0);
- 调用库函数 java.lang.Integer类中的static int bitCount(int i) 方法,返回指定 int 值的二进制补码表示形式的 1 位的数量;
- 时间复杂度:O(1)
- 空间复杂度:O(1)
48. Rotate Image 旋转图像
给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。
示例:
输入:matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
输出:[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
思路:
对于矩阵数组matrix中第 i 行的第 j 个元素,矩阵顺时针旋转 90 度后,它将出现在倒数第 i 列的第 j 个位置,即对于矩阵中的元素 matrix[row][col],在旋转后它的新位置为 matrix_new[col][len-1−row]。使用翻转操作代替旋转操作,首先将矩阵的所有元素相对中轴横线进行上下翻转:
matrix[row][col] –> matrix[len−1-row][col]
其次将矩阵的所有元素相对斜对角线进行翻转:
matrix[len−1-row][col] –> matrix[col][len−1-row]
两次翻转后,即为满足要求的矩阵数组。
题解:
class Solution {
public void rotate(int[][] matrix) {
int len = matrix.length;
for (int i = 0; i < len / 2; i++) { // 将矩阵的所有元素相对中轴横线进行上下翻转
for (int j = 0; j < len; j++) {
int temp = matrix[i][j];
matrix[i][j] = matrix[len - 1 - i][j];
matrix[len - 1 - i][j] = temp;
}
}
for (int i = 0; i < len - 1; i++) { // 将矩阵的所有元素相对斜对角线进行翻转
for (int j = i + 1; j < len; j++) {
int temp = matrix[i][j];
matrix[i][j] = matrix[j][i];
matrix[j][i] = temp;
}
}
}
}
tips:
- 时间复杂度:O(n^2),每一次翻转操作都需要枚举矩阵中一半的元素(n^2 / 2);
- 空间复杂度:O(1)
62. Unique Paths 不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。求解问总共有多少条不同的路径。
示例:
输入:m = 3, n = 7
输出:28
思路:
使用数学运用的思路。从左上角到右下角的过程中,总共需要移动 m+n−2 次,其中有 m−1 次向下移动,n−1 次向右移动。因此不同路径的总数就等于从 m+n−2 次移动中选择 m−1 次向下移动的方案数(剩下 n-1 次则填补为向右移动)(也可以理解为在 m+n−2 个空位上选择不同的 m−1 个空位填充为向下移动),即组合数:
C(m-1,m+n-2) = A(m-1,m+n-2) / A(m-1,m-1) = (m+n−2)(m+n−3)⋯n / (m-1)! = (m+n−2)(m+n−3)⋯n / (m-1)(m-2)…1(分子乘到n是由于m+n−2 - (m-1) + 1)
计算上述公式的遍历次数为 m-1 次,为使得遍历次数最小,选取较小的行数(颠倒行列数不影响最后的结果)。与此同时,为使得结果的计算过程中不超过long类型的范围,在每次循环体中都要“乘以一尽量小的分子(从i=n开始往大递增乘)和除以一尽量小的分母(从fac=1往大递增除)”。
题解:
class Solution {
public int uniquePaths(int m, int n) {
if (m > n) return uniquePaths(n, m);
long result = 1; // 结果的计算过程中可能超出int类型的范围
for (int i = n, fac = 1; i <= m + n - 2; i++, fac++) { // 遍历 m-1 次
result = result * i / fac;
}
return (int) result;
}
}
tips:
- 选取最少遍历次数 m-1 / n-1 采用递归的方式(最多只自己调用自己一次);
- 时间复杂度:O(min(m, n));
- 空间复杂度:O(1)
136. Single Number 只出现一次的数字
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。算法应该具有线性时间复杂度,不使用额外空间。
示例:
输入:[2,2,1]
输出:1
思路:
假设数组中有 2m+1 个数,其中有 m 个数各出现两次,一个数出现一次。令 a1、a2、…、am 为出现两次的 m 个数,am+1 为出现一次的数。由异或运算的性质,数组中的全部元素的异或运算结果总是可以写成:
(a1⊕a1) ⊕ (a2⊕a2)⊕⋯⊕(am⊕am)⊕am+1 = 0⊕0⊕⋯⊕0⊕am+1 = am+1
故数组中全部元素的异或运算结果即为数组中只出现一次的数字。
题解:
class Solution {
public int singleNumber(int[] nums) {
int result = 0;
for (int num : nums) {
result ^= num;
}
return result;
}
}
tips:
- 时间复杂度:O(n);
- 空间复杂度:O(1)
Ⅱ Linked List 链表
链表相关的算法题目。
对于链表相关题目,无论使用哪种思路解题,大都有会使用到 虚拟头节点 dummyHead 的思路,用于返回结果链表的头节点,以及在原链表需要删除的元素可能为头节点的情况下为了便于删除节点,或者便于在新结果链表中添加元素/节点。 与此同时,链表相关题目都会使用到指针。
1 Recursive 递归
递归是一个反复调用自身的过程,这就说明它每一级/层的功能都是一样的,因此只需要关注一级/层递归的解决过程即可。递归不要进入到递归中看其执行流程(压栈与返回值),而是利用明确的定义来实现算法逻辑。
递归:由递归模型建模,递归模型由递归函数与递归边界条件(递归结束条件)组成。递归函数则由在这一层的递归中应该完成什么任务和返回值(即应该给上一层递归返回什么值)组成;边界条件即不再调用递归函数而直接返回某值所需的条件。
使用递归解法的链表题目。一般链表题目都需要使用指针或者更新某节点的成员变量(next),使用递归或者迭代两种解法都可以解题:迭代实现思路简单,但是细节问题多;而递归实现则比较简洁。递归操作链表并不高效,和迭代解法相比,虽然时间复杂度都是 O(n),但是迭代解法的空间复杂度是 O(1),而递归解法需要堆栈,空间复杂度是 O(N)。
21. Merge Two Sorted Lists 合并两个有序链表
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例:
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]
思路:
1)递归:由递归模型建模,递归模型由递归函数与递归边界条件(递归结束条件)组成,边界条件即不再调用递归函数而直接返回某值。此题在回溯过程中拼接了结果链表。
递归模型:
递归函数merge:两个链表头部值较小的一个节点与剩下元素的 merge 操作结果拼接。
递归边界条件:当 l1 或 l2 为空(传递进来的节点为空),直接返回另一不为空的节点。
2)迭代:使用cur指针将 l1 和 l2 链表值更小的头节点添加到结果中,当一个节点被添加到结果中之后将对应链表中的节点向后移一位。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
// 递归
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
if (l1 == null || l2 == null) return l1 == null ? l2 : l1; // 递归结束条件
if (l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
}
// 迭代
/*class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummyHead = new ListNode(); // 虚拟头节点,用于返回结果链表的头节点和便于添加元素
ListNode cur = dummyHead;
while (l1 != null && l2 != null) { // l1 与 l2 其中总有一个先遍历结束
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 ? l2 : l1; // 还有一个原链表未遍历完,直接将结果链表末尾指针指向未遍历完的链表即可
return dummyHead.next;
}
}*/
tips:
- 对于迭代,方法传入的参数 l1 与 l2 链表的头节点,在返回值的时候用不到,故可以直接将头节点 l1 和 l2 当作遍历两个升序链表的指针;
- 时间复杂度:O(m+n),递归;O(m+n),迭代;无论递归还是迭代,执行次数都不会超过两链表长度之和
- 空间复杂度:O(m+n),递归;O(1),迭代
206. Reverse Linked List 反转链表
给你单链表的头节点 head
,请你反转链表,并返回反转后的链表。链表中节点的数目范围是 [0, 5000]。
示例:
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
思路:
迭代:使用指针指向链表的当前节点及其前一节点和后一节点,反转链表cur,next=pre;,后移三个指针完成遍历。
递归:递归到原链表的最后一个节点时返回原链表最后一个节点(递归的结束条件),回溯过程中进行链表的反转。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
// 递归
class Solution {
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) return head; // head==null用于判断当原链表长度为0时返回null,head.next==null用于判断当原链表递归到最后一个节点(递归结束条件)
ListNode newHead = reverseList(head.next); // 用于保存反转后链表的头节点,即原链表的最后一个节点(第一次回溯时返回的值)
head.next.next = head; // 反转
head.next = null; // 用于当递归回溯到原链表的头节点(也即反转链表的最后一个节点)时,其next应指向null,否则其next还指向原链表的next节点,会使得反转后的链表产生循环
return newHead;
}
}
// 迭代
/*class Solution {
public ListNode reverseList(ListNode head) {
ListNode pre = null; // 当前节点的前一节点指针
ListNode cur = head; // 当前节点的指针
while (cur != null) { // 当前节点cur指针不为空时再求其next指针
ListNode next = cur.next; // 当前节点的下一节点指针
cur.next = pre; // 反转
pre = cur; // pre指针后移
cur = next; // 当前节点cur指针后移
}
return pre; // 迭代结束cur指针指向原链表最后一个节点的下一个null引用,而pre指针则为原链表中最后一个节点
}
}*/
tips:
- 时间复杂度:O(n),迭代;O(n),递归
- 空间复杂度:O(1),迭代;O(n),递归,使用栈空间,递归深度达到 n 层
19. Remove Nth Node From End of List 删除链表的倒数第 N 个结点
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。使用一趟扫描实现。
示例:
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
思路:
1)双指针,滑动窗口:迭代。定义pre指针为需删除节点的前一节点,end指针为链表的末尾null,pre指针与end指针间的距离为n+1,故可以将pre指针和end指针都初始化为虚拟头节点dummyHead,先向右平移n+1位将end指针与pre指针的距离固定到n+1,之后同时平移pre指针和end指针直到end指针指向链表末尾null(滑动窗口),则代表找到需删除节点的前一节点pre的正确位置,删除其下一节点。
2)递归:
递归函数:
执行任务:当前层递归参数的next为上一层递归的返回值;
返回值:当前层递归的返回值为当前层递归参数的下一节点或当前层递归参数。
递归边界条件:当递归参数head为空即到达链表末尾时。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
// 双指针,滑动窗口:迭代
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummyHead = new ListNode();
dummyHead.next = head;
ListNode pre = dummyHead, end = dummyHead;
for (int i = 0; i < n + 1; i++) end = end.next;
while (end != null) {
pre = pre.next;
end = end.next;
}
pre.next = pre.next.next;
return dummyHead.next;
}
}
// 递归
/*class Solution {
int count = 0; // 成员变量
public ListNode removeNthFromEnd(ListNode head, int n) { // 回溯的过程中拼接链表,只是在倒数第n个位置时舍去了原链表中本应拼接的下一节点而拼接了后一个节点
if (head == null) return null;
head.next = removeNthFromEnd(head.next, n);
return ++count == n ? head.next : head;
}
}*/
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1),双指针,滑动窗口:迭代;O(n),递归
2 Pointers 指针
对于引用类型(eg:链表中的节点):
指针即代表一种引用(对象/引用类型的引用),指向引用对象的地址值,用于操作指向(引用)对象的成员变量。eg:ListNode removed = point.next; point.next = point.next.next;,removed 即为一指针:指向 point 的成员变量 next 也即 point 的下一节点(point 的下一节点对象的引用/地址);而 point.next 并非指针,代表 point 对象的成员变量 next 也即 point 的下一节点,point.next = point.next.next 为将 point 对象的成员变量 next 更新为 point.next.next,即将 point 的下一节点更新为 point 的后一节点。
234. Palindrome Linked List 回文链表
请判断一个链表是否为回文链表。用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题。
示例:
输入:1->2->1
输出:true
思路:
双指针。cur指针用于反转链表。使用快慢指针寻找链表的中点。将链表的前半部分反转,然后和后半部分进行比较。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public boolean isPalindrome(ListNode head) {
ListNode cur = head, pre = null; // cur指针用于反转链表(更新其成员变量next),pre指针总是指向cur指针指向节点的前一节点
ListNode slow = head, fast = head; // 快慢指针,用于辅助cur和pre指针反转链表(仅引用地址,并未更新其成员变量next,即未进行链表操作)
while (fast != null && fast.next != null) {
cur = slow; // 更新cur指针指向slow(当前反转的节点)
slow = slow.next; // 自更新,每次后移一位,**需要在反转操作前更新**(否则影响指针的正常后移)
fast = fast.next.next; // 自更新,每次后移两位,**需要在反转操作前更新**(否则影响指针的正常后移)
cur.next = pre; // 反转前半部分链表(是由cur指针操作的)
pre = cur; // 更新pre指针指向当前反转的节点
} // 退出循环后slow指针指向链表的中间节点(总是指向前半部分反转链表后的第一个节点,即后半未反转链表的第一个节点:因为slow和fast指针在每次循环中分别各自移动一位和两位),则pre指针总是指向前半部分反转链表的最后一个节点,fast指针指向链表的末尾节点(链表长度为奇数时)或末尾节点的next空引用(链表长度为偶数时)
if (fast != null) slow = slow.next; // 链表的长度为奇数时将slow指针移向用于比较的后半部分链表的起始节点
while (pre != null || slow != null) {
if (pre.val != slow.val) return false;
pre = pre.next;
slow = slow.next;
}
return true; // 当传入的参数head=null或head.next=null也成立(不会进入上述循环)
}
}
tips:
- 指针自更新移动(a=a.next; / a=a.next.next; …)时需要注意当前自更新指针指向的节点在之前的程序执行中是否有其他指针指向此节点且已经改变了链表的顺序:防止空指针异常,且保证自更新指针的正常移动;
- 时间复杂度:O(n)
- 空间复杂度:O(1)
2. Add Two Numbers 两数相加
给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。请你将两个数相加,并以相同形式返回一个表示和的链表。你可以假设除了数字 0 之外,这两个数都不会以 0 开头。
示例:
输入:l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
输出:[8,9,9,9,0,0,0,1]
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
boolean flag = false; // 用于标记当前位是否需要进位
ListNode dummyHead = new ListNode(); // 虚拟头节点,用于返回结果链表的头节点
ListNode cur = dummyHead; // 指针
while (l1 != null || l2 != null) { // l1 与 l2 的长度不一的相同
int ele1 = l1 == null ? 0 : l1.val; // 其中一个链表到达末尾则其位上的值为0
int ele2 = l2 == null ? 0 : l2.val;
int sum = flag ? ele1 + ele2 + 1 : ele1 + ele2;
flag = sum >= 10 ? true : false; // 更新是否需要进位情况
cur.next = new ListNode(sum % 10); // 新建节点添加到结果链表的尾部
cur = cur.next;
l1 = l1 == null ? l1 : l1.next; // 防止空指针异常
l2 = l2 == null ? l2 : l2.next;
}
if (flag) { // 原两链表最高位还需要进位则需再创建一个节点将其值置于1
cur.next = new ListNode(1);
}
return dummyHead.next;
}
}
tips:
- 模拟:数学+指针。模拟两个数手动相加的过程,使用指针将当前位的值创建的节点添加到结果;
- 时间复杂度:O(max(m,n)),m 和 n 分别为两个链表的长度
- 空间复杂度:O(1),返回值不计入空间复杂度
3 Merge Sort 归并排序
利用归并的思想,采用分治策略(将问题分成一些小的问题然后递归求解,而治的阶段则将分的阶段得到的各答案“修补”在一起,即分而治之)。分阶段为递归拆分子序列的过程(不进行任何排序操作),治阶段需要将两个已经有序的子序列合并成一个有序序列。编写递归函数,每一次都一分为二拆分序列的子区间,然后在方法栈弹出的时候,一步一步合并两个有序序列,最后完成排序工作。「合并两个有序序列」的步骤利用了序列的部分有序性。
23. Merge k Sorted Lists 合并K个升序链表
给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。k == lists.length,0 <= k <= 10^4。
示例:
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
思路:
归并排序的思路。mergeTwoLists 方法用于治,与21题迭代方式相同,用于两个排序链表的合并。在 merge 方法中实现分,通过递归实现。分阶段为递归拆分子序列的过程(不进行任何排序操作),治阶段需要将两个已经有序的子序列合并成一个有序序列。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) return null;
return merge(lists, 0, lists.length - 1);
}
private ListNode merge(ListNode[] lists, int left, int right) {
if (left == right) return lists[left];
if (left + 1 == right) return mergeTwoLists(lists[left], lists[right]);
int mid = left + (right - left) / 2;
ListNode l1 = merge(lists, left, mid);
ListNode l2 = merge(lists, mid + 1, right);
return mergeTwoLists(l1, l2);
}
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummyHead = new ListNode();
ListNode cur = dummyHead;
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 ? l2 : l1;
return dummyHead.next;
}
}
tips:
- 时间复杂度:O(nklogk)
- 空间复杂度:O(logk),递归会使用到 O(logk) 代价的栈空间
148. Sort List 排序链表
给你链表的头结点 head
,请将其按 升序 排列并返回 排序后的链表。在 O(n log n)
时间复杂度和常数级空间复杂度下,对链表进行排序。
示例:
输入:head = [-1,5,3,4,0]
输出:[-1,0,3,4,5]
思路:
1)归并排序:mergeTwoLists 方法用于治,与21题迭代方式相同,用于两个排序链表的合并。在 sortList 方法中实现分,通过递归实现。分阶段为递归拆分子序列的过程(不进行任何排序操作,使用快慢指针寻找链表的中点),治阶段需要将两个已经有序的子序列合并成一个有序序列。
2)迭代:模拟归并排序递归过程
将链表拆分成子链表进行合并。定义 subLen 表示每次需要排序的子链表的长度(初始值为1);每次将链表拆分成若干个长度为 subLen 的子链表(最后一个子链表的长度可以小于 subLen,最后一个子链表可能为合并用第一个或者第二个子链表),按照每两个子链表一组进行合并,合并后即可得到若干个长度为 subLen×2 的有序子链表(最后一个子链表的长度可以小于 subLen×2),合并两个子链表与21题迭代方式相同;每次当前分组情况全部排序合并完毕后,将 subLen 的值增大一倍,进行下一分组情况的排序,对更长的有序子链表进行合并操作,直到有序子链表的长度大于或等于链表长度 len,整个链表排序完毕。每个长度为 subLen 的子链表已经有序,合并两个长度为 subLen 的有序子链表,得到长度为 subLen×2 的子链表,一定也是有序的。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
// 迭代解法:模拟归并排序递归过程 tc: O(nlogn) sc: O(1)
class Solution {
public ListNode sortList(ListNode head) {
int len = 0; // 链表长度
ListNode node = head;
while (node != null) {
len++;
node = node.next;
}
ListNode dummyHead = new ListNode();
dummyHead.next = head;
for (int subLen = 1; subLen < len; subLen <<= 1) {
ListNode pre = dummyHead, cur = dummyHead.next;
while (cur != null) {
ListNode head1 = cur;
for (int i = 1; i < subLen && cur.next != null; i++) cur = cur.next; // 合并用第一个子链表不会为空
ListNode head2 = cur.next; // 合并用第二个子链表可能为空
cur.next = null;
cur = head2;
for (int i = 1; i < subLen && cur != null && cur.next != null; i++) cur = cur.next; // 第二个子链表可能为空
ListNode next = null;
if (cur != null) {
next = cur.next;
cur.next = null;
}
ListNode merged = mergeTwoLists(head1, head2);
pre.next = merged;
while (pre.next != null) pre = pre.next;
cur = next;
}
}
return dummyHead.next;
}
private ListNode mergeTwoLists(ListNode l1, ListNode l2) { // 合并两个有序链表的方法
ListNode dummyHead = new ListNode();
ListNode cur = dummyHead;
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 ? l2 : l1;
return dummyHead.next;
}
}
// 归并排序解法 tc: O(nlogn) sc: O(logn)
/*class Solution {
public ListNode sortList(ListNode head) { // 在回溯的过程中不断合并两个有序链表
if (head == null || head.next == null) return head; // 递归结束条件,head是否为空的判断条件用于当原链表为空时直接返回null
ListNode slow = head, fast = head.next; // 使用快慢指针寻找链表的中点,快指针每次移动 2 步,慢指针每次移动 1 步,当快指针到达链表末尾时,慢指针指向的链表节点即为链表的中点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
} // 退出循环后slow指针位于链表中位,后以中位为分界,将链表拆分成两个子链表
ListNode mid = slow.next; // mid指向二分链表后半部分的头节点
slow.next = null; // 将二分链表前半部分的末尾置空
return mergeTwoLists(sortList(head), sortList(mid));
}
private ListNode mergeTwoLists(ListNode l1, ListNode l2) { // 合并两个有序链表的方法
ListNode dummyHead = new ListNode();
ListNode cur = dummyHead;
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 ? l2 : l1;
return dummyHead.next;
}
}*/
tips:
- 时间复杂度:O(nlogn)
- 空间复杂度:O(logn),归并排序;O(1),迭代:模拟归并排序递归过程
4 General - Linked List & Math 常规 - 链表和数学
常规链表题目。以及使用到指针的数学题。
141. Linked List Cycle 环形链表
给定一个链表,判断链表中是否有环。如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。如果链表中存在环,则返回 true 。否则,返回 false 。使用 O(1)(即,常量)内存解决此问题。链表中节点的数目范围是 [0, 10^4]。
示例:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
思路:
数学+双指针。定义快慢指针,其初始值都为链表头节点,快指针每次走两步,慢指针每次走一步,快慢指针同时移动,当快慢都走了 t 次后相遇则代表链表为环形链表(当链表为环形链表时,t 为环中节点个数的倍数时快慢指针就会相遇,故对环形链表其快慢指针总会相遇)。
题解:
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null) {
if (fast.next == null) return false; // 遍历到链表末尾(非环形奇数长度链表)则返回false
fast = fast.next.next;
slow = slow.next;
if (fast == slow) return true; // 比较两节点的地址值而不是比较两节点的值
}
return false; // 遍历到链表末尾(非环形偶数长度链表)则返回false
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
142. Linked List Cycle II 环形链表 II
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。不允许修改给定的链表。使用 O(1) 空间解决此题。
示例:
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
思路:
数学+双指针。定义快慢指针,其初始值都为链表头节点,快指针每次走两步,慢指针每次走一步,快慢指针同时移动,当快慢都走了 t 次后相遇则代表链表为环形链表(当链表为环形链表时,t 为环中节点个数的倍数时快慢指针就会相遇,故对环形链表其快慢指针总会相遇)。
当快慢指针第一次相遇后:设链表共有 a+b 个节点,其中链表头部到链表环入口 有 a 个节点(不计链表环入口节点), 链表环 有 b 个节点(a 和 b 是未知数),设两指针分别走了 f,s 步,则 fast 指针走的步数是 slow 指针走的步数的 2 倍,即 f=2s,fast 比 slow 多走了 n 个环的长度,即 f=s+nb(双指针都走过 a 步,然后在环内绕圈直到重合,重合时 fast 比 slow 多走环的长度整数倍),将以上两式相减得 s=nb,故 f=2nb,即 fast 和 slow 指针分别走了 2n,n 个环的周长(n 为未知数,不同链表的情况不同)。
将指针从链表头部一直向前走并统计步数 k,则所有走到链表环入口节点时的步数为 k=a+nb(先走 a 步到达链表环入口节点,之后每绕 1 圈环即 b 步都会再次到入口节点)。因为当前 slow 指针走过的步数为 nb 步,只要让 slow 指针再走 a 步停下来,就可以到环的入口。
使用双指针法,构建一个指针,此指针和 slow 指针同时向前走 a 步后,两者在链表环入口节点重合。从链表头部 head 走到链表环入口节点需要 a 步,故将 fast 指针重复利用,将其重新定义为上述构建的指针:将 fast 指针重新指向链表头部节点 head。
slow 指针的位置不变,将 fast 指针(重定义后的指针)和 slow 指针同时向前移动一步(此时 f=0,s=nb),当 fast 指针走到 f=a 步时,slow 指针走到 s=a+nb 步,此时两指针重合(即对于环链表,此两指针一定会相遇)并同时指向链表环入口,返回 slow 指针指向的节点。
题解:
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head; // 快慢指针
while (fast != null) {
if (fast.next == null) return null; // 遍历到链表末尾(非环形奇数长度链表)则返回null
fast = fast.next.next;
slow = slow.next;
if (fast == slow) break;
}
if (fast == null) return null; // 遍历到链表末尾(非环形偶数长度链表)则返回null
fast = head; // 链表为环形链表,将fast指针重置指向头节点
while (fast != slow) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
160. Intersection of Two Linked Lists 相交链表
编写一个程序,找到两个单链表相交的起始节点。如果两个链表没有交点,返回 null。在返回结果后,两个链表仍须保持原有的结构。可假定整个链表结构中没有循环。程序需满足 O(n) 时间复杂度,且仅用 O(1) 内存。
示例:
输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3
输出:Reference of the node with value = 8
解释:相交节点的值为 8 。从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,0,1,8,4,5]。在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
思路:
设「第一个公共节点」为 node,「链表 headA」的节点数为 a,「链表 headB」的节点数为 b,「两链表的公共尾部」的节点数为 c,则有:头节点 headA 到 node 前,共有 a−c 个节点,头节点 headB 到 node 前,共有 b−c 个节点。构建两个节点指针 cur,cur2 分别指向两链表头节点 headA,headB,指针 cur 先遍历完链表 headA ,再开始遍历链表 headB,当走到 node 时,共走步数为 a+(b−c),指针 cur2 先遍历完链表 headB ,再开始遍历链表 headA,当走到 node 时,共走步数为 b+(a−c),此时指针 cur,cur2 重合( a+(b−c) = b+(a−c) ),并有两种情况:若两链表有公共尾部 (即 c>0 ) ,指针 cur,cur2 同时指向「第一个公共节点」node;若两链表无公共尾部 (即 c=0 ) ,指针 cur,cur2 同时指向 null。返回 cur 指针指向的节点即可。
题解:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) return null; // 优化
ListNode cur = headA, cur2 = headB;
while (cur != cur2) {
cur = cur != null ? cur.next : headB;
cur2 = cur2 != null ? cur2.next : headA;
}
return cur;
}
}
tips:
- 时间复杂度:O(a+b),a和b分别为两链表的长度
- 空间复杂度:O(1)
Ⅲ Tree 树
树相关的算法题目。
1 Use Both Children and Return One 子节点用二返一
对于递归用函数:
- root must be used
- update ans, can use both children
- return value with only one child
- 后序遍历
时间复杂度:O(n) n - 节点数
空间复杂度:O(h) h - 层数(递归调用栈可以达到 h 层的深度;最坏情况下,二叉树的高度等于二叉树中的节点个数)
124. Binary Tree Maximum Path Sum 二叉树中的最大路径和
路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。
路径和 是路径中各节点值的总和。
给你一个二叉树的根节点 root ,返回其 最大路径和 。
示例:
输入:root = [-10,9,20,null,null,15,7]
输出:42
解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42
题解:
class Solution {
private int count; // 用于判断是否回溯到第一次执行的maxPathSum方法
private int max = Integer.MIN_VALUE; // 记录maxPathSum
public int maxPathSum(TreeNode root) {
int temp = ++count;
if (root == null) return 0;
int left = Math.max(0, maxPathSum(root.left));
int right = Math.max(0, maxPathSum(root.right));
max = Math.max(max, left + root.val + right);
if (temp != 1) {
return Math.max(left, right) + root.val; // 对于返回值,root(当前节点)一定会被使用;此返回值为使用根(当前)结点的最大路径和
} else {
return max;
}
}
}
tips:
对于负数值的说明:
- 调用maxPathSum函数传入的参数为叶子节点时,且叶子节点为负数,返回值就为当前叶子节点的负数值;
- 当回溯到原来的调用位置时,就会与0进行比较,从而舍弃此条左/右边路(代表不选左/右节点);
- 更新max值时就不一定是历史最大值与左+当前节点+右的值比较了,会舍弃负数值,变为历史最大值与当前节点或者左+当前节点或当前节点+右比较。
543. Diameter of Binary Tree 二叉树的直径
给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
注意:两结点之间的路径长度是以它们之间边的数目表示。
示例:
输入:
1
/ \
2 3
/ \
4 5
输出:3
解释:它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]。
题解:
class Solution {
private int max; // 记录diameterOfBinaryTree
public int diameterOfBinaryTree(TreeNode root) {
if (root == null) return 0;
getDiameter(root);
return max;
}
private int getDiameter(TreeNode root) {
if (root == null) return 0;
int left = getDiameter(root.left);
int right = getDiameter(root.right);
int leftTemp = 0;
int rightTemp = 0;
if (root.left != null) leftTemp = ++left;
if (root.right != null) rightTemp = ++right;
max = Math.max(max, leftTemp + rightTemp);
return Math.max(leftTemp, rightTemp);
}
}
2 Construct Binary Tree from Preorder Inorder or Postorder Traversal 由前序中序或后序遍历构造二叉树
使用分治算法,递归,归并排序的思想:将一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并(分而治之)。分治算法在每一层递归上都有三个步骤:分解,将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;解决,若子问题规模较小而容易被解决则直接解决,否则递归地解各个子问题;合并,将各个子问题的解合并为原问题的解。
时间复杂度:O(n),其中 n 是树中的节点个数。
空间复杂度:O(n),需要使用 O(n) 的空间存储哈希表,以及 O(h)(其中 h 是树的高度)的空间表示递归时栈空间。这里 h < n,所以总空间复杂度为 O(n)。
105. Construct Binary Tree from Preorder and Inorder Traversal 从前序与中序遍历序列构造二叉树
根据一棵树的前序遍历与中序遍历构造二叉树,可以假设树中没有重复的元素。
示例:
输入:preorder = [3,9,20,15,7];inorder = [9,3,15,20,7]
输出:
3
/ \
9 20
/ \
15 7
思路:
前序遍历数组的第 1 个数一定是当前递归子二叉树的根结点,于是可以在中序遍历中找这个根结点的索引,然后将“前序遍历数组”和“中序遍历数组”分为两个部分,就分别对应当前递归子二叉树的左子树和右子树,再分别递归便可实现要求。
题解:
class Solution {
private int[] preorder; // 前序遍历的数组
private Map<Integer,Integer> hashMap; // 用于获取中序遍历数组中某值对应的索引
public TreeNode buildTree(int[] preorder, int[] inorder) {
this.preorder = preorder;
this.hashMap = new HashMap<>();
for (int i = 0; i < inorder.length; i++) {
hashMap.put(inorder[i], i);
}
return buildTree(0, preorder.length - 1, 0, hashMap.size() - 1);
}
private TreeNode buildTree(int preLeft, int preRight, int inLeft, int inRight) {
if (preLeft > preRight || inLeft > inRight) return null; // 递归结束的条件
int pivot = preorder[preLeft]; // 每次递归得到的此次根节点的值
int pivotIndex = hashMap.get(pivot);
TreeNode root = new TreeNode(pivot);
root.left = buildTree(preLeft + 1, preLeft + pivotIndex - inLeft, inLeft, pivotIndex - 1);
root.right = buildTree(preLeft + pivotIndex - inLeft + 1, preRight, pivotIndex + 1, inRight);
return root;
}
}
3 Depth First Search (Preorder Inorder or Postorder Traversal) 深度优先搜索(前序中序或后序遍历)
本质上为二叉树的前序、中序或后序遍历的相关题目。
对于树,深度优先搜索(DFS)即为树的 前序/中序/后续 遍历;广度优先搜索(BFS)即为树的 层序遍历(逐层从上到下扫描整棵树)。
深度优先搜索一般都使用递归的方式。对于特定题目,递归访问节点一般有拼接和访问两种方式,拼接方式即需要使用递归函数的返回值赋值给当前访问节点的左/右子节点,访问方式可以没有(或者不使用)返回值,使用返回值时一般其有特定含义。
递归是一个反复调用自身的过程,这就说明它每一级/层的功能都是一样的,因此只需要关注一级/层递归的解决过程即可。递归不要进入到递归中看其执行流程(压栈与返回值),而是利用明确的定义来实现算法逻辑。
递归:由递归模型建模,递归模型由递归函数与递归边界条件(递归结束条件)组成。递归函数则由在这一层的递归中应该完成什么任务和返回值(即应该给上一层递归返回什么值)组成;边界条件即不再调用递归函数而直接返回某值所需的条件。
使用递归的二叉树树题目。一般递归函数(方法)的参数都为一个二叉树中的节点,只需将关注点落在对于当前递归层级的参数节点需要实现什么功能,递归边界条件一般都为访问到null节点,故一般在方法回溯的过程(对于二叉树为自底向上)中不断累加中间计算结果,对于每一层级的递归函数(方法)只需关注对于当前层级遍历到的节点其子树的中间计算结果是什么。
94. Binary Tree Inorder Traversal 二叉树的中序遍历
给定一个二叉树的根节点 root ,返回它的 中序 遍历。树中节点数目在范围 [0, 100] 内。通过迭代算法完成。
示例:
输入:root = [1,null,2,3]
输出:[1,3,2]
思路:
中序遍历:先遍历左子树,再输出父节点,再遍历右子树。
迭代:模拟递归的过程。定义一个节点的特定操作类,对于任何一个节点的访问和输出操作都要定义到此类中,并且对于访问操作需要再定义三个相关操作对象按中序遍历的逆序存入栈中:每次从栈中弹出一个操作对象,进行节点的输出或者访问(模拟递归方法的调用)。
递归:将返回结果定义到成员变量位置,对于递归方法,先访问其左节点,之后输出节点信息,再访问其右节点。递归边界条件为遇到叶子节点则只执行输出(左右子节点都为空)。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
// 迭代
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null) return result; // 二叉树的根节点为 null 则直接返回
Stack<Command> stack = new Stack<>();
stack.push(new Command(false, root));
while (!stack.empty()) {
Command command = stack.pop();
if (command.task) { // 输出节点的信息
result.add(command.node.val);
} else { // 访问节点
if (command.node.right != null) stack.push(new Command(false, command.node.right));
stack.push(new Command(true, command.node));
if (command.node.left != null) stack.push(new Command(false, command.node.left));
}
}
return result;
}
}
class Command { // 对于一个节点的特定操作类
boolean task; // 指令属性:true 代表输出(保存)节点属性,false 代表访问节点
TreeNode node; // 作用节点属性:指令作用的节点
Command(boolean task, TreeNode node) {
this.task = task;
this.node = node;
}
}
// 递归
/*class Solution {
List<Integer> result = new ArrayList<>(); // 成员变量
public List<Integer> inorderTraversal(TreeNode root) {
if (root != null) { // 避免二叉树的根节点为 null
if (root.left != null) inorderTraversal(root.left);
result.add(root.val);
if (root.right != null) inorderTraversal(root.right);
}
return result;
}
}*/
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
98. Validate Binary Search Tree 验证二叉搜索树
给定一个二叉树,判断其是否是一个有效的二叉搜索树。一个二叉搜索树具有如下特征:节点的左子树只包含小于当前节点的数;节点的右子树只包含大于当前节点的数;所有左子树和右子树自身必须也是二叉搜索树。
示例:
输入:[5,1,4,null,null,3,6]
5
/ \
1 4
/ \
3 6
输出:false
思路:
递归:中序遍历。对于二叉搜索树BST,中序遍历为升序。定义成员变量 pre 保存中序遍历的上一节点,在中序遍历的过程中进行比较,当当前遍历节点的值小于中序遍历上一节点的值时返回 false(对于整个程序,为 ture 即满足中序遍历的顺序时则继续判断,而一旦 为 false 即有一个节点不满足中序遍历的要求时则整个程序不断回溯返回 false)。
迭代:中序遍历。对于整个程序,为 ture 即满足中序遍历的顺序时则继续判断,而一旦为 false 即有一个节点不满足中序遍历的要求时则返回 false 。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
// 递归:中序遍历
class Solution {
TreeNode pre = null; // 中序遍历的上一节点
public boolean isValidBST(TreeNode root) {
if (root == null) return true; // 左子节点或右子节点为空,应返回 true
if (!isValidBST(root.left)) return false; // 不能直接使用 return isValidBST(root.left) 语句,因为在左节点满足要求时还需继续执行对当前节点是否满足要求的判断(左节点不满足要求就无需继续判断,整个程序不断回溯返回 false)
if (pre != null && root.val <= pre.val) return false; // 关键判断(pre != null :对于中序遍历第一个节点则跳过判断直接赋值;同理不能直接使用 return root.val > pre.val 语句,因为在当前节点满足要求时还需继续执行对其右子节点是否满足要求的判断;如果当前节点不满足要求则无需继续判断,整个程序不断回溯返回 false)
pre = root; // 满足要求更新 pre 节点
return isValidBST(root.right);
}
}
// 迭代:中序遍历
/*class Solution {
public boolean isValidBST(TreeNode root) {
TreeNode pre = null;
Stack<Command> stack = new Stack<>();
stack.push(new Command(false, root));
while (!stack.empty()) {
Command command = stack.pop();
if (command.task) {
if (pre != null && command.node.val <= pre.val) return false; // 满足要求则还需继续判断(程序继续执行)
pre = command.node;
} else {
if (command.node.right != null) stack.push(new Command(false, command.node.right));
stack.push(new Command(true, command.node));
if (command.node.left != null) stack.push(new Command(false, command.node.left));
}
}
return true;
}
}
class Command {
boolean task;
TreeNode node;
Command(boolean task, TreeNode node) {
this.task = task;
this.node = node;
}
}*/
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
236. Lowest Common Ancestor of a Binary Tree 二叉树的最近公共祖先
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”。树中节点数目在范围 [2, 10^5] 内。所有 Node.val 互不相同 。p != q。p 和 q 均存在于给定的二叉树中。
示例:
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1ㅤ|ㅤroot = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:3ㅤ|ㅤ5
思路:
递归:前序遍历。若 root 是 p,q 的 最近公共祖先则只能为以下情况:p 和 q 在 root 的子树中,且分列 root 的 异侧(即分别在左、右子树中);p=root ,且 q 在 root 的(左或右)子树中;q=root ,且 p 在 root 的(左或右)子树中。通过递归对二叉树进行前序遍历,当遇到节点 p 或 q 时返回,自底向上回溯,当节点 p,q 在节点 root 的异侧时,节点 root 即为最近公共祖先,则向上返回 root ;当节点 p,q 在节点 root 的同侧时,节点 root 的左/右子节点中等于 p或q 的即为最近公共祖先,则向上返回此 root 的左/右子节点。对于递归函数,五种情形如代码 1) - 5) 。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { // 前序遍历:深度搜索递归找到p/q节点或未找到则不断回溯返回不再深度递归当前子树
if (root == null || root == p || root == q) return root; // 当前递归层级访问节点为null节点或者p、q节点则直接返回:无需再深度搜索访问(递归边界条件)
TreeNode left = lowestCommonAncestor(root.left, p, q); // 访问左子节点
TreeNode right = lowestCommonAncestor(root.right, p, q); // 访问右子节点
if (left == null || right == null) return left == null ? right : left; // 1) 当前root节点的子树(左/右都)不包含p/q节点返回null(还未搜索到p/q);2) 在左/右子树已找到最近公共祖先(此子树中包含p和q节点,同时另一子树的返回结果只能为null)应继续返回此结果;// 3) 特定左/右子树的根节点(root.left/root.right)为特定p/q,且另一子树未搜索到另一p/q(即另一p/q在此特定左/右子树中),返回此特定p/q(即最近公共祖先为p/q中的一个,p和q为父子关系);4) 在左/右子树中存在p/q节点,另一p/q节点还未找到
return root; // 5) 当前root节点的左右子树分别包括p和q节点(left != null && right != null),则此root节点即为最近公共祖先
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
226. Invert Binary Tree 翻转二叉树
翻转一棵二叉树。
示例:
输入:
4
/ \
2 7
/ \ / \
1 3 6 9
输出:
4
/ \
7 2
/ \ / \
9 6 3 1
思路:
递归:后序遍历。后序遍历树中的所有节点,所有节点都会被访问一次,回溯的过程中翻转二叉树。
递归模型:
递归函数:每一层的递归中访问到的节点将其左右子树(节点)进行翻转,返回值(即应该给上一层递归返回什么值)为当前翻转左右子树(节点)后的根节点;
递归边界条件:遍历到null节点则直接返回null节点不再自身调用方法(递归)。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) return root; // 递归边界条件
TreeNode temp = root.right; // 指针:交换当前节点的两个子节点,需要使用临时变量保存首先被重定向的待交换节点
root.right = invertTree(root.left); // 当前节点的右子树(节点)指向其原来的左子树(节点)
root.left = invertTree(temp); // 当前节点的左子树(节点)指向其原来的右子树(节点)
return root; // 返回当前翻转左右子树(节点)后的根(当前)节点
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(h),h为二叉树的高度
104. Maximum Depth of Binary Tree 二叉树的最大深度
给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。叶子节点是指没有子节点的节点。
示例:
输入:[3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
输出:3
思路:
递归:后序遍历。每个节点都会被访问一次。将 null 节点也看作为一颗子二叉树(递归结束条件),在回溯的过程中累加二叉树的深度。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) return 0; // 递归的边界条件
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
}
tips:
- 时间复杂度:O(n),每个节点在递归中只被遍历一次
- 空间复杂度:O(h),h 为二叉树的高度。递归函数需要栈空间,而栈空间取决于递归的深度,故空间复杂度等价于二叉树的高度
101. Symmetric Tree 对称二叉树
给定一个二叉树,检查它是否是镜像对称的。运用递归和迭代两种方法解决这个问题。
示例:
输入:[1,2,2,3,4,4,3]
1
/ \
2 2
/ \ / \
3 4 4 3
输出:true
思路:
1) 深度优先遍历:递归。思路与100题相似,将需要判断的二叉树复制一份,以相反的递归方式深度遍历两颗二叉树:对于原二叉树即为前序遍历(先访问当前节点再访问左子节点和右子节点),对于复制的二叉树即为先访问当前节点再访问右子节点和左子节点。对于对称二叉树,其在结构上对称,并且对称的节点具有相同的值。递归遍历过程中当前两节点满足要求需要继续递归检查下去,只有在某一个节点不符合要求时方法不断回溯返回 false。
2) 广度优先遍历:迭代。层序遍历。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
// 深度优先遍历:递归
class Solution {
public boolean isSymmetric(TreeNode root) {
return check(root, root);
}
private boolean check(TreeNode p, TreeNode q) { // 以相反的递归方式深度遍历两颗二叉树
if (p == null && q == null) return true; // 当前遍历的两镜像对称节点都为空:符合要求
if (p == null || q == null) return false; // 当前遍历的两镜像对称节点其中一个节点为空另一个不为空:两颗二叉树不对称
if (p.val != q.val) return false; // 当前遍历的两镜像对称节点值不相等:两颗二叉树不对称
if (!check(p.left, q.right)) return false; // 当前两镜像对称节点的一对镜像对称子节点相等(满足要求)需要继续递归检查下去
return check(p.right, q.left); // 访问另一对镜像对称子节点
}
}
// 广度优先遍历:迭代
/*class Solution {
public boolean isSymmetric(TreeNode root) { // 按层级遍历,对于相同层级中的节点:访问顺序总是成对访问相同层级中的两结构对称的节点
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root); // 因为总是成对的访问同层级中的对称节点,故初始条件下需要添加两次根节点
queue.offer(root);
while (!queue.isEmpty()) { // 成对的访问同层级中的结构对称节点
TreeNode p = queue.poll(); // 同层级中一对镜像对称节点之一
TreeNode q = queue.poll(); // 同层级中一对镜像对称节点之一
if (p == null && q == null) continue; // 都为 null 则符合条件(对称)跳出此次循环
if (p == null || q == null || p.val != q.val) return false; // 当同层级中一对镜像对称节点其中一个为 null 另一个不为 null 时或者此两节点的值不相等:两颗二叉树不对称,直接返回 false
queue.offer(p.left); // 成对的添加下一层级中的结构对称节点
queue.offer(q.right);
queue.offer(p.right);
queue.offer(q.left);
}
return true;
}
}*/
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
437. Path Sum III 路径总和 III
给定一个二叉树,它的每个结点都存放着一个整数值。找出路径和等于给定数值的路径总数。路径不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。二叉树不超过1000个节点,且节点数值范围是 [-1000000,1000000] 的整数。
示例:
输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], sum = 8
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
输出:3
解释:和等于 8 的路径有 5 -> 3、5 -> 2 -> 1 和 -3 -> 11
思路:
递归:前序遍历。存在两个递归函数,递归函数 pathSum 处理不包含root节点其和为targetSum的路径个数:对于每层级递归中访问的节点root计算不包含root节点其和为targetSum的路径个数(以root节点的左右子节点为路径起始寻找满足要求的路径)和包含root节点其和为targetSum的路径个数(以root节点为为路径起始寻找满足要求的路径);返回值为对于当前递归层级访问节点的子树所有满足条件的路径数(不包含当前节点的路径数+包含当前节点的路径数)。递归函数 findPath 处理包含root节点(以root为根节点的二叉树中)其和为targetSum的路径个数。两个递归函数复合起来的本质上就为以二叉树中的每个节点为路径起始搜索满足要求的路径数。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public int pathSum(TreeNode root, int targetSum) { // 处理不包含root节点其和为targetSum的路径个数
if (root == null) return 0; // 递归边界条件
int result = findPath(root, targetSum); // 处理包含root节点其和为targetSum的路径个数
result += pathSum(root.left, targetSum); // 在root的左子树中寻找和为targetSum的路径个数(处理不包含root节点其和为targetSum的路径个数)
result += pathSum(root.right, targetSum); // 在root的右子树中寻找和为targetSum的路径个数(处理不包含root节点其和为targetSum的路径个数)
return result;
}
private int findPath(TreeNode root, int targetSum) { // 处理包含root节点(以root为根节点的二叉树中)其和为targetSum的路径个数
if (root == null) return 0; // 未要求路径的终止节点为叶子节点,可以一直搜索到null节点则直接返回0
int result = 0;
if (root.val == targetSum) result++; // 以当前节点为终止的路径符合条件,但是不能直接返回1,因为二叉树中存在负数可能后面还有路径满足条件
result += findPath(root.left, targetSum - root.val);
result += findPath(root.right, targetSum - root.val);
return result;
}
}
tips:
- 时间复杂度:O(n^2)
- 空间复杂度:O(n^2)
297. Serialize and Deserialize Binary Tree 二叉树的序列化与反序列化
序列化是将一个数据结构或者对象转换为连续的比特位的操作,进而可以将转换后的数据存储在一个文件或者内存中,同时也可以通过网络传输到另一个计算机环境,采取相反方式重构得到原数据。请设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。树中结点数在范围 [0, 10^4] 内。
示例:
输入:root = [1,2,3,null,null,4,5]
输出:[1,2,3,null,null,4,5]
思路:
深度优先搜索,递归:前序遍历。使用重载方法实现序列及反序列化方法。在序列/反序列化过程中,将中间结果存储到递归函数(方法)的参数中。序列化二叉树过程中需要将null节点也保存在序列化字符串中,null,null
用于标记缺少左、右子节点(即上一节点为叶子节点)。对于序列化,遇到空子树时序列化为 null,否则继续递归序列化;对于反序列化,如果遇到元素为 null 则当前节点某一子树为空树,回溯解析当前节点的另一子树。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
public class Codec {
// Encodes a tree to a single string.
public String serialize(TreeNode root) {
return serialize(root, new StringBuilder()).toString();
}
// Decodes your encoded data to tree.
public TreeNode deserialize(String data) {
return deserialize(new LinkedList<String>(Arrays.asList(data.split(",")))); // 将字符串分割转换为字符串数组再将其转换为List集合
}
private StringBuilder serialize(TreeNode root, StringBuilder sb) { // 序列化方法的重载,使用StringBuilder加快序列化字符串的拼接:前序遍历
if (root == null) return sb.append("null").append(","); // 递归边界条件
sb.append(root.val).append(",");
serialize(root.left, sb);
serialize(root.right,sb);
return sb;
}
private TreeNode deserialize(List<String> ser) { // 反序列化方法的重载,使用List便于对序列化字符串进行访问操作:前序遍历(在回溯的过程中拼接节点)
String cur = ser.remove(0); // 当前节点的值
if (cur.equals("null")) return null; // 递归边界条件
TreeNode root = new TreeNode(Integer.parseInt(cur));
root.left = deserialize(ser);
root.right = deserialize(ser);
return root; // **返回当前已拼接好子树的根节点**
}
}
// Your Codec object will be instantiated and called as such:
// Codec ser = new Codec();
// Codec deser = new Codec();
// TreeNode ans = deser.deserialize(ser.serialize(root));
tips:
- 使用 java.util.Arrays 类中的 static
List asList(T... a) 方法,返回一个受指定数组支持的固定大小的列表(参数a - 支持列表的数组)。 返回的为参数数组的列表视图,不能直接使用,需要传入集合(列表)实现类的构造函数中创建一个当前列表视图的集合(列表)对象; - 时间复杂度:O(n),在序列化和反序列化函数中只访问每个节点一次
- 空间复杂度:O(n)
617. Merge Two Binary Trees 合并二叉树
给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为 NULL 的节点将直接作为新二叉树的节点。合并必须从两个树的根节点开始。
示例:
输入:
Tree 1 Tree 2
1 2
/ \ / \
3 2 1 3
/ \ \
5 4 7
输出:
3
/ \
4 5
/ \ \
5 4 7
思路:
深度优先搜索,递归:前序遍历。二叉树1和2以相同的遍历方式访问相同位置处的节点,递归边界条件为当前递归层级访问的两节点其中一节点为空直接返回不为空的子树(都为空的情况合并在内)。回溯过程中拼接二叉树的节点。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public TreeNode mergeTrees(TreeNode root1, TreeNode root2) { // 前序遍历:拼接+合并的方式,以root1树为基准
if (root1 == null) return root2; // root1此位置为空:拼接root2此位置子树
if (root2 == null) return root1; // root2此位置为空:返回root1此位置原本子树
root1.val = root1.val + root2.val; // 合并:将当前位置root2节点的值累加到root1节点上
root1.left = mergeTrees(root1.left, root2.left); // 本质上也为拼接(root1原来更新/未更新值的子树 或者 root2子树)
root1.right = mergeTrees(root1.right, root2.right);
return root1; // 返回当前已拼接好子树的根节点
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
538. Convert BST to Greater Tree 把二叉搜索树转换为累加树
给出二叉 搜索 树的根节点,该树的节点值各不相同,请你将其转换为累加树(Greater Sum Tree),使每个节点 node 的新值等于原树中大于或等于 node.val 的值之和。树中的节点数介于 0 和 10^4 之间。
示例:
输入:[4,1,6,0,2,5,7,null,null,null,3,null,null,null,8]
输出:[30,36,21,36,35,26,15,null,null,null,33,null,null,null,8]
思路:
深度优先搜索,递归:中序遍历的变形(先访问右子节点,再访问当前节点,最后访问左子节点)。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
int sum; // 成员变量:用于记录当前遍历过程中的累加和
public TreeNode convertBST(TreeNode root) {
sum = 0;
convert(root);
return root;
}
private void convert(TreeNode root) {
if (root != null) { // 递归边界条件:访问到null节点
convert(root.right);
sum += root.val;
root.val = sum;
convert(root.left);
}
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
4 Broad First Search (Level Order Traversal) 广度优先搜索(层序遍历)
本质上为二叉树的层序遍历的相关题目。
对于树,深度优先搜索(DFS)即为树的 前序/中序/后续 遍历;广度优先搜索(BFS)即为树的 层序遍历(逐层从上到下扫描整棵树)。
广度优先搜索一般都借助队列数据结构并使用迭代的方式。
102. Binary Tree Level Order Traversal 二叉树的层序遍历
给你一个二叉树,请你返回其按 层序遍历 得到的节点值。(即逐层地,从左到右访问所有节点)
示例:
输入:[3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
输出:
[
[3],
[9,20],
[15,7]
]
思路:
广度优先遍历,利用队列数据结构(以保持访问过的结点的顺序,以便按这个顺序来访问这些结点的邻接结点)实现。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int count = queue.size(); // 当前层级的节点个数
List<Integer> level = new ArrayList<>();
while (count != 0) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) queue.offer(node.left); // 添加当前层级节点的邻接节点
if (node.right != null) queue.offer(node.right); // 添加当前层级节点的邻接节点
count--;
}
result.add(level);
}
return result;
}
}
tips:
- 时间复杂度:O(n),每个节点进队出队各一次,渐进时间复杂度为 O(n)
- 空间复杂度:O(n)
279. Perfect Squares 完全平方数
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。1 <= n <= 10^4。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
示例:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
思路:
1) 【记忆化BFS】图的广度优先遍历。将 0 ~ n 的值转化为一有向无权图(多叉树)中的节点,且相同值的节点唯一,对于任意节点其可以与其他节点相连接,连接的条件为两节点值的差为一完全平方数,并且总是由值较大的节点指向值较小的节点。
对于和为 n 的完全平方数的最少数量即:图中从值为 n 的节点到达值为 0 的节点的最短路径(任意两连通节点的差值总为一完全平方数)。
2) 动态规划。状态表达式:dp[i] 表示最少需要多少个数的平方和来表示整数 i ,这些数必然落在区间 [1, sqrt(n)] 。枚举这些数,假设当前枚举到 j,则还需要取若干数的平方,构成 i−j^2 。此时该子问题和原问题类似,只是规模变小了,这符合了动态规划的要求,于是可以得到状态转移方程:
dp[i] = 1 + mindp[i - j^2],其中 j ∈ [1, sqtr(i)]
dp[0] = 0 为 base case,实际上无法表示数字 0,只是为了保证状态转移过程中遇到 j 恰为 sqtr(i) 的情况合法。同时计算 dp[i] 时所需要用到的状态仅有 dp[i-j^2],其必然小于 i (和为 i 的平方和数 dp[i] 前面已计算得到结果),因此只需要从小到大地枚举 i 来计算 dp[i] 即可。
题解:
// 广度优先遍历:记忆化BFS
class Solution {
public int numSquares(int n) {
Queue<Integer> queue = new LinkedList<>();
queue.offer(n);
boolean[] isVisited = new boolean[n + 1]; // 记录某一节点是否被访问过:图中不同数字的节点唯一,即不会出现两个值重复的节点;与此同时,在某条路径下较少的步数访问到了某个值的节点,在后面的步数中再次访问到此节点时此路径下最后到达0的步数一定比前者多,故需要舍去
isVisited[n] = true; // 将值为 n 的节点置为已访问
int result = 0; // 图中从 n 节点到达 0 节点的步数
while (!queue.isEmpty()) {
int count = queue.size(); // 当前层(步数)下的节点数:当前步数可以到达的数值节点
result++;
while (count != 0) {
int cur = queue.poll();
for (int i = 1; ; i++) { // 从 1、4、9... 开始:枚举当前节点可以到达的下一节点
int next = cur - i * i; // 下一节点的值
if (next < 0) break; // 小于 0 代表枚举结束
if (next == 0) return result; // 找到最短路径:方法直接返回
if (!isVisited[next]) {
queue.offer(next); // 将有效的下一节点(下一步可以到达的节点)存入队列
isVisited[next] = true; // 将此下一节点标记为已访问
}
}
count--;
}
}
return result;
}
}
// 动态规划
/*class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1]; // 状态表达式:和为整数 i 的完全平方数的最少数量为 dp[i]
for (int i = 1; i < n + 1; i++) { // 从小到大枚举计算每一个整数的平方和最小个数(前面计算得到的结果后面可能会用到)
int minStep = Integer.MAX_VALUE; // 所需完全平方数的最少数量,即 min{dp[i - j^2]}
for (int j = 1; j * j <= i; j++) minStep = Math.min(minStep, dp[i - j * j]); // 求解 min{dp[i - j^2]}
dp[i] = minStep + 1; // 状态转移方程
}
return dp[n];
}
}*/
tips:
- 时间复杂度:O(n * sqrt(n)),由四平方和定理:任意一个正整数都可以被表示为至多四个正整数的平方和,三层循环中最外层最多循环四次,中间层循环最多n次,最内层循环至多 sqrt(n) 次,广度优先遍历;状态转移方程的时间复杂度为 O(sqrt(n)),共需要计算 n 个状态(数值),因此总时间复杂度为 O(n * sqrt(n)),动态规划
- 空间复杂度:O(n)
5 Pointers 指针*
使用到指针的树相关题目,一般都又会涉及到链表相关。
114. Flatten Binary Tree to Linked List 二叉树展开为链表
给你二叉树的根结点 root ,请你将它展开为一个单链表:展开后的单链表应该同样使用 TreeNode ,其中 right 子指针指向链表中下一个结点,而左子指针始终为 null 。展开后的单链表应该与二叉树 先序遍历 顺序相同。树中结点数在范围 [0, 2000] 内。使用原地算法(O(1) 额外空间)展开这棵树。
示例:
输入:root = [1,2,5,3,4,null,6]
输出:[1,null,2,null,3,null,4,null,5,null,6]
思路:
指针:迭代。前序遍历访问各节点的顺序是根节点、左子树、右子树。如果一个节点的左子节点为空,则该节点不需要进行展开操作。如果一个节点的左子节点不为空,则该节点的左子树中的最后一个节点被访问之后,该节点的右子节点被访问。该节点的左子树中最后一个被访问的节点是左子树中的最右边的节点,也是该节点右子节点的前驱节点。故问题转化为寻找当前节点右子树的前驱节点。
具体做法为:对于当前节点,如果其左子节点不为空,则在其左子树中找到最右边的节点,作为前驱节点,将当前节点的右子节点赋给前驱节点的右子节点,然后将当前节点的左子节点赋给当前节点的右子节点,并将当前节点的左子节点设为空。对当前节点处理结束后,继续处理链表中的下一个节点,直到所有节点都处理结束。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public void flatten(TreeNode root) { // **链表中的节点为原来二叉树中的节点**(两者等价)
TreeNode cur = root; // 用于遍历单链表的**指针**,初始值指向root节点
while (cur != null) {
if (cur.left != null) {
TreeNode pre = cur.left; // 前驱节点**指针**:指向当前链表节点(也即当前访问子二叉树的根节点)的左子树中的前序遍历最后一个节点,初始值指向cur.left
while (pre.right != null) pre = pre.right; // 搜索当前访问节点的左子树中的前序遍历最后一个节点
pre.right = cur.right; // 将前驱节点的下一节点next(right)指针指向当前节点的右子树
cur.right = cur.left; // 将当前链表节点的下一节点next(right)指针指向其左子树
cur.left = null; // 当前二叉树节点转换为链表节点成功,将其左子节点置空
}
cur = cur.right;
}
}
}
tips:
- 时间复杂度:O(n),展开为单链表的过程中需要对每个节点访问一次,在寻找前驱节点的过程中,每个节点最多被额外访问一次
- 空间复杂度:O(1)
208. Implement Trie (Prefix Tree) 实现 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 。1 <= word.length, prefix.length <= 2000。word 和 prefix 仅由小写英文字母组成。
示例:
输入:[“Trie”, “insert”, “search”, “search”, “startsWith”, “insert”, “search”]
ㅤㅤㅤ[[], [“apple”], [“apple”], [“app”], [“app”], [“app”], [“app”]]
输出:[null, null, true, false, true, null, true]
思路:
指针,数据结构设计。定义前缀树(字典树、单词查找树)Trie 的节点类,TrieNode 节点中并没有直接保存字符值的数据成员,其使用字母映射表 next[26] 保存对当前结点而言下一个可能出现的所有字符的链接,可以通过一个父节点来预知它所有子节点的值。包含三个单词 “sea”,”sells”,”she” 的 Trie 为:
插入:类似构建链表,只是其next指针为一数组,当前节点的值通过字母映射表隐式地定义在next节点数组中。首先从根结点开始与 word 第一个字符进行匹配,一直匹配到前缀链上没有对应的字符,这时开始不断开辟新的节点,直到插入完 word 的最后一个字符,同时还要将最后一个结点isEnd置为true;,表示它是一个单词的末尾。
查找/前缀匹配:从根结点开始,一直向下匹配,如果出现结点值为空就返回 false,如果匹配到了最后一个字符,对于查找只需判断 TrieNode.isEnd ,对于前缀匹配则无需判断 isEnd 直接返回 true。
题解:
class Trie {
class TrieNode { // 成员内部类:前缀树节点类
boolean isEnd; // 当前节点是否为一单词的结尾
TrieNode[] next; // next指针:可能的子节点数组(对于当前节点,**其next[i]上有值即不为null则代表当前节点代表的字母有(char)'a'+i,其next[i]上的值TrieNode代表下一节点**),可以有多个子节点(对于前缀树同一层h的节点代表字典中单词的word.charAt(h),即每个单词的第h个字母)
public TrieNode() { // 构造方法:前缀树节点对象的初始化
isEnd = false;
next = new TrieNode[26];
}
}
TrieNode root; // 成员变量:当前Trie(前缀树)的根节点
/** Initialize your data structure here. */
public Trie() {
this.root = new TrieNode();
}
/** Inserts a word into the trie. */
public void insert(String word) {
TrieNode cur = this.root; // 深度遍历前缀树(也可以看作一条在深度方向的链表)的节点指针
for (char ch : word.toCharArray()) {
if (cur.next[ch - 'a'] == null) cur.next[ch - 'a'] = new TrieNode();
cur = cur.next[ch - 'a'];
}
cur.isEnd = true;
}
/** Returns if the word is in the trie. */
public boolean search(String word) {
TrieNode cur = this.root;
for (char ch : word.toCharArray()) {
if (cur.next[ch - 'a'] == null) return false;
cur = cur.next[ch - 'a'];
}
return cur.isEnd;
}
/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
TrieNode cur = this.root;
for (char ch : prefix.toCharArray()) {
if (cur.next[ch - 'a'] == null) return false;
cur = cur.next[ch - 'a'];
}
return true;
}
}
/**
* Your Trie object will be instantiated and called as such:
* Trie obj = new Trie();
* obj.insert(word);
* boolean param_2 = obj.search(word);
* boolean param_3 = obj.startsWith(prefix);
*/
tips:
- 时间复杂度:O(∣S∣),∣S∣ 为每次插入或查询的字符串的长度
- 空间复杂度:O(∣T∣⋅Σ),∣T∣ 为所有插入字符串的长度之和,Σ 为字符集的大小,本题 Σ=26
Ⅳ Backtracking 回溯
1 One Dimensional Problem 一维问题
对于一维回溯问题:
回溯算法本质上就是在一个树形问题上做深度优先遍历,因此此类问题首先需要把问题转换为树形问题,对于树形结构的同一层级中的节点即为结果中某一特定位置(同一位置)上的可能候选节点。与此同时,也一般涉及到剪枝操作,以去除不必要的深度搜索。
对于排列/组合问题:
1) 排列问题:顺序有影响([2, 2, 3]
与 [2, 3, 2]
视为不同列表),需要记录哪些数字已经使用过,则需要使用 isUsed
数组。
2) 组合问题:顺序无影响( [2, 2, 3]
与 [2, 3, 2]
视为相同列表),需要按照某种顺序来进行搜索,则需要使用 start
变量。
17. Letter Combinations of a Phone Number 电话号码的字母组合
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。0 <= digits.length <= 4。digits[i] 是范围 [‘2’, ‘9’] 的一个数字。
示例:
输入:digits = “23”
输出:[“ad”,”ae”,”af”,”bd”,”be”,”bf”,”cd”,”ce”,”cf”]
思路:
使用回溯的思路。
题解:
class Solution {
String[] letterMap = new String[] {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
List<String> result = new ArrayList<>();
public List<String> letterCombinations(String digits) {
if (digits.length() == 0) return result;
findCombinations(digits, 0, "");
return result;
}
private void findCombinations(String digits, int index, String cur) { // index 表示当前递归层级拼接的第几个字母,cur 用于保存中间结果
if (index == digits.length()) { // 拼接了最后一个字母后index为digits长度减1,需要再次调用一次方法index为digits长度才添加到结果中
result.add(cur);
return;
}
String digitLetters = letterMap[digits.charAt(index) - '0'];
for (int i = 0; i < digitLetters.length(); i++) findCombinations(digits, index + 1, cur + digitLetters.charAt(i));
}
}
tips:
- 不能使用 StringBuilder 作为保存中间结果的方法参数,因为其为一个对象引用,整个方法在递归及回溯过程中为同一个 StringBuilder 对象,其中之前保存的结果会累加。而使用 String ,虽然其也为应用数据类型,但是在每次递归调用方法时传入的为一拼接字符串,故其指向了新对象(适用于当前递归层级的对象);
- 时间复杂度:O(3^m * 4^n),m 是输入中对应 3 个字母的数字个数,n 是输入中对应 4 个字母的数字个数,也可以表示为 O(2^N),N为 digits 的长度
- 空间复杂度:O(N)
46. Permutations 全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列。你可以 按任意顺序 返回答案。1 <= nums.length <= 6。nums 中的所有整数 互不相同。
示例:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
思路:
将结果定义到成员变量位置,递归参数中保存中间结果,定义一与数组等长的 boolean 类型数组表示原数组某一索引位置上的数当前是否被使用。
回溯移除元素并非是当前方法将要执行完毕的最后一步移除元素(即执行一次回溯,移除当前方法传入参数 cur 的最后一个元素):对于在当前方法(当前递归层级)内,需要在再次递归调用前将参数 cur 拼接下一有效的数字,以传入递归调用函数,在递归调用函数执行完以后,需要在当前方法内将之前拼接的数字删除即回溯(因为之前为了再次递归调用,对于当前方法中的中间结果 cur 多了一位,需要回溯到适用于当前递归层级的状态,以为了下一次在同一位置进行拼接数字);故此回溯是指对于当前递归层级拼接排列的一中间结果已确定,需要再拼接下一位置的数字,拼接下一位置的数字需要使用递归调用函数的方式,同时对于下一位置数字的可能由 for 循环决定(即有多种),为了在下一次执行循环体中正常的拼接对于排列某一特定位置(同一位置)的数字,需要在当前循环体执行完之前将当前循环体开始执行时以为了下次递归调用而拼接的数字删除。这也解释了当排列拼接到满足长度要求添加排列到结果集后结束方法 return 前不需要删除 cur 中的最后一个元素。
题解:
class Solution {
private List<List<Integer>> result;
private boolean[] isUsed; // 某一索引上的数字当前是否被使用
public List<List<Integer>> permute(int[] nums) {
result = new ArrayList<>();
isUsed = new boolean[nums.length];
dfs(nums, 0, new ArrayList<Integer>());
return result;
}
private void dfs(int[] nums, int len, List<Integer> cur) { // len 表示当前排列已拼接数字的长度(也表示当前递归层级的方法中在尝试拼接排列中的第几位数字),cur 表示当前排列拼接的中间结果
if (len == nums.length) {
result.add(new ArrayList<Integer>(cur)); // 拷贝(不能使用引用的方式)
return;
}
for (int i = 0; i < nums.length; i++) {
if (!isUsed[i]) { // 拼接当前未被使用的数字
isUsed[i] = true;
cur.add(nums[i]);
dfs(nums, len + 1, cur);
isUsed[i] = false; // 回溯前需要将isUsed[i]置为false,即未使用
cur.remove(cur.size() - 1); // 回溯前需要将当前拼接的数字从中间结果中删除(不能使用remove(nums[i]),传入参数需要是索引)
}
}
}
}
tips:
- 时间复杂度:O(n * n!)
- 空间复杂度:O(n)
39. Combination Sum 组合总和
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。所有数字(包括 target)都是正整数。解集不能包含重复的组合。1 <= candidates.length <= 30。candidate 中的每个元素都是独一无二的。
示例:
输入:candidates = [2,3,6,7], target = 7
输出:
[
[7],
[2,2,3]
]
思路:
思路与77题相似。对于 candidates = [2, 3, 6, 7], target = 7,以 target = 7 为根节点,每创建一个分支的时做减法,每一个箭头表示:从父亲节点的数值减去边上的数值,得到孩子节点的数值。边的值就是 candidate 数组的每个元素的值,减到 0 或者负数的时候停止,即节点 0 和负数节点成为叶子节点,所有从根节点到节点 0 的路径(只能从上往下,没有回路)就是题目要找的一个结果。
去重:为了使路径(结果)不产生重复,每一次搜索的时候设置下一轮搜索的起点 start:从 candidates 数组中的第几个元素开始,即对于树形结构的同一层级,从第二个节点开始,都不能再搜索同一层节点已经使用过的 candidate 里的元素,故对于每个节点其下一轮搜索的起点 start 都为对于当前节点其父节点拼接了 candidate 中的哪个元素。
剪枝:将候选数组 candidates 先进行排序,则按顺序对候选数组中的元素进行搜索时,如果 target 减去一个数得到负数,那么减去一个更大的数依然是负数。故在当前递归层级的节点中搜索下一节点时,当得到负数就跳出循环搜索整个方法结束(对于当前节点的下一轮搜索完毕)。
题解:
class Solution {
private List<List<Integer>> result;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
result = new ArrayList<>();
Arrays.sort(candidates);
dfs(candidates, target, 0, new ArrayList<Integer>());
return result;
}
private void dfs(int[] candidates, int target, int start, List<Integer> cur) { // start 表示搜索范围的起始值,cur 表示中间结果
if (target == 0) {
result.add(new ArrayList<Integer>(cur));
return;
}
for (int i = start; i < candidates.length; i++) { // 对于当前递归层级(某一个节点):进行下一轮搜索
if (target - candidates[i] < 0) break; // 剪枝
cur.add(candidates[i]);
dfs(candidates, target - candidates[i], i, cur); // 父节点拼接了i,对于此节点的搜索起始即为i
cur.remove(cur.size() - 1);
}
}
}
tips:
- 时间复杂度:O(S),S 为所有可行解的长度之和
- 空间复杂度:O(target),取决于递归的栈深度,在最差情况下需要递归 target 层
78. Subsets 子集
给你一个整数数组 nums
,数组中的元素 互不相同。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按任意顺序返回解集。1 <= nums.length <= 10。nums 中的所有元素 互不相同。
示例:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
思路:
思路与77题相似。添加组合到结果集合中无需再附加条件判断,在 start 等于候选数组 nums 的长度时代表已搜索到所有可能组合的末尾则直接返回结束方法。
题解:
class Solution {
private List<List<Integer>> result;
public List<List<Integer>> subsets(int[] nums) {
result = new ArrayList<>();
dfs(nums, 0, new ArrayList<Integer>());
return result;
}
private void dfs(int[] nums, int start, List<Integer> cur) { // start 为搜索范围的其实值
result.add(new ArrayList<Integer>(cur));
if (start == nums.length) return; // 递归边界条件
for (int i = start; i < nums.length; i++) {
cur.add(nums[i]);
dfs(nums, i + 1, cur); // 起始值应为 i+1
cur.remove(cur.size() - 1);
}
}
}
tips:
- 时间复杂度:O(2^n)
- 空间复杂度:O(n)
22. Generate Parentheses 括号生成
数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。1 <= n <= 8。
示例:
输入:n = 3
输出:[”((()))”,”(()())”,”(())()”,”()(())”,”()()()”]
思路:
回溯,深度优先搜索,剪枝。
class Solution {
public List<String> generateParenthesis(int n) {
List<String> result = new ArrayList<>();
dfs("", n, n, result); // 将结果对象使用参数传递的方式进行访问
return result;
}
private void dfs(String cur, int left, int right, List<String> result) { // left及right表示剩余左右括号的数量,cur表示当前路径拼接的字符串(每一递归层级内都为一新的字符串对象引用,故在回溯时无需删除当前拼接字符)
if (left == 0 && right == 0) { // 到达子节点添加当前路径字符串
result.add(cur);
return;
}
if (left > right) return; // 剪枝(存在多余的右括号跑到左括号前面:不符合有效括号组合)
if (left > 0) dfs(cur + '(', left - 1, right, result); // 还剩余有左/右括号才产生分支(子节点)
if (right > 0) dfs(cur + ')', left, right - 1, result);
}
}
tips:
- 时间复杂度:O(4^n/sqrt(n)),时间代价即为组合数
- 空间复杂度:O(n),递归深度最大为 2 * n
2 2-D Problem 二维问题
对于二维回溯问题,一般为二维矩阵的深度优先搜索问题,在一条路径行不通的情况下进行回溯,寻找其他可能的路径。
79. Word Search 单词搜索
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。m == board.length。n = board[i].length。1 <= m, n <= 6。1 <= word.length <= 15。board 和 word 仅由大小写英文字母组成。
示例:
输入:board = [[“A”,”B”,”C”,”E”],[“S”,”F”,”C”,”S”],[“A”,”D”,”E”,”E”]], word = “ABCCED”
输出:true
思路:
回溯,深度优先搜索。使用 ‘\0’ 标记当前已访问过的单元格,其不能再次被访问,但是回溯过程中需要再将其置为原来的值,递归深度优先搜索使用的 || 或的方式,当某一条路行不通时会一个一个的回溯寻找其他可能的路径,但是不会再走之前已经走过的路,因为之前走过的路已经返回 false ,只会去判断下一条路。
题解:
class Solution {
public boolean exist(char[][] board, String word) {
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
if (dfs(board, word.toCharArray(), i, j, 0)) return true; // 起点可以是矩阵中的任意位置(当前起点未找到需要继续寻找下一起点的可能性)
}
}
return false;
}
private boolean dfs(char[][] board, char[] word, int i, int j, int k) { // i 和 j 为当前递归层级访问矩阵中单元格的索引,k 为当前在单词中匹配字母的索引
if (i < 0 || i >= board.length || j < 0 || j >= board[0].length || board[i][j] != word[k]) return false; // 当前访问单元格越界或者与下一字母不匹配(也包括当前单元格已访问过的情况):递归边界条件
if (k == word.length - 1) return true; // 整个 word 中的字母匹配完毕,不断回溯返回 true(由或运算 || 的短路特性)
board[i][j] = '\0'; // 将当前匹配的单元格置为空字符,表示已访问过(从开始的时候占据了这个位置,向四个方法寻找)
boolean isFound = dfs(board, word, i + 1, j, k + 1) || dfs(board, word, i - 1, j, k + 1) ||
dfs(board, word, i, j + 1, k + 1) || dfs(board, word, i, j - 1, k + 1); // 下上右左的顺序递归深度搜索下一单元格
board[i][j] = word[k]; // 回溯前需要将已访过的单元格置为原来的值(虽然当前单元格的值与word匹配,但是其四个方向继续搜索下去不与word匹配,则需要回溯,不使用此单元格)
return isFound;
}
}
tips:
- 时间复杂度:O(3^K * MN),K为字符串word的长度,M和N为矩阵的行数和列数,最差情况下,需要遍历矩阵中长度为 K 字符串的所有方案,时间复杂度为 O(3^K),矩阵中共有 MN 个起点,时间复杂度为 O(MN),故时间复杂度为O(3^K * MN)
- 空间复杂度:O(K),搜索过程中的递归深度不超过 K
200. Number of Islands 岛屿数量
给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。此外,你可以假设该网格的四条边均被水包围。m == grid.length。n == grid[i].length。1 <= m, n <= 300。
示例:
输入:grid = [
ㅤ[“1”,”1”,”1”,”1”,”0”],
ㅤ[“1”,”1”,”0”,”1”,”0”],
ㅤ[“1”,”1”,”0”,”0”,”0”],
ㅤ[“0”,”0”,”0”,”0”,”0”]
]
输出:1
思路:
思路类似79题。回溯,深度优先搜索。
题解:
class Solution {
public int numIslands(char[][] grid) {
int result = 0;
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (grid[i][j] == '1') { // 未访问过的新岛屿陆地
result++;
dfs(grid, i, j);
}
}
}
return result;
}
private void dfs(char[][] grid, int i, int j) {
if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] == '0') return; // 当前访问单元格越界或者为水域(也包括当前陆地单元格已访问过的情况):递归边界条件
grid[i][j] = '0'; // 将当前已访问的陆地单元格置为水域即 '0',使得深度搜索的过程中不重复访问,同时对于每轮搜索的起点已访问过的岛屿陆地应跳过
dfs(grid, i + 1, j); // 下上右左的顺序递归深度搜索下一单元格
dfs(grid, i - 1, j);
dfs(grid, i, j + 1);
dfs(grid, i, j - 1);
}
}
/*class Solution {
private boolean[][] isVisited; // 定义一与二维网格大小相同的二维数组:表示某单元格是否被访问过
private int result;
public int numIslands(char[][] grid) {
this.isVisited = new boolean[grid.length][grid[0].length];
this.result = 0;
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (isVisited[i][j]) continue;
dfs(grid, i, j);
if (grid[i][j] == '1') result++;
}
}
return result;
}
private void dfs(char[][] grid, int i, int j) {
if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] == '0' || isVisited[i][j]) return;
isVisited[i][j] = true;
dfs(grid, i + 1, j);
dfs(grid, i - 1, j);
dfs(grid, i, j + 1);
dfs(grid, i, j - 1);
}
}*/
tips:
- 时间复杂度:O(mn),m 和 n 分别为二维网格的行数和列数
- 空间复杂度:O(mn),在最坏情况下,整个二维网格均为陆地,深度优先搜索的深度达到 mn
3 Topological Sort 拓扑排序*
拓扑排序:对于有向无环图(DAG),对图中的顶点进行排序,使得对每一条有向边 (u, v)(u -> v ,即 u 指向 v),在排序中均有 u 比 v 先出现。也即对某点 v 而言,只有当 v 的所有源点在排序中均出现了,v 才能出现。
207. Course Schedule 课程表
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。1 <= numCourses <= 10^5,0 <= prerequisites.length <= 5000,prerequisites[i].length == 2,0 <= ai, bi < numCourses。
示例:
输入:numCourses = 2, prerequisites = [[1,0]]ㅤ|ㅤnumCourses = 2, prerequisites = [[1,0],[0,1]]
输出:trueㅤ|ㅤfalse
思路:
拓扑排序,回溯,深度优先搜索。通过深度优先遍历判断图中是否存在有环。
定义一个状态数组 flag,用于判断每个节点 i(课程)的状态(flag[i] 对应第 i 各节点):未被 DFS 访问:i == 0;已被其它节点启动的 DFS 访问:i == -1;已被当前节点启动的 DFS 访问:i == 1。
对图中 numCourses 个节点依次执行 DFS 深度优先遍历(每调用一次 dfs() 方法即为一次 DFS 的起点),判断每个节点起步的 DFS 是否存在环(递归边界条件),若存在环直接返回 false,不存在环则返回 true 继续 DFS 进行检查。
递归边界条件:当 flag[i] == 1,说明在本轮 DFS(以某一节点起步的深度优先搜索,期间一直没有进行回溯,不断在递归调用及深度优先搜索)搜索中节点 i 被第二次访问,即课程安排图有环 ,直接返回 false。
题解:
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> adjacency = new ArrayList<>(); // 邻接表
int[] flag = new int[numCourses]; // 状态数组:值仅为 0,1,-1
for (int i = 0; i < numCourses; i++) adjacency.add(new ArrayList());
for (int[] link : prerequisites) adjacency.get(link[1]).add(link[0]); // 因题意,这里图中指向 (i, j) 代表 i 指向 j,i 为 j 的前置课程
for (int i = 0; i < numCourses; i++) {
if (!dfs(adjacency, flag, i)) return false; // 以 0 节点为起点启动 DFS(当有多个不连通的图时还起到了多起点的作用)
}
return true;
}
private boolean dfs(List<List<Integer>> adjacency, int[] flag, int i) {
if (flag[i] == 1) return false; // **对于一轮中的 DFS 重复访问某节点才为环**
if (flag[i] == -1) return true; // 当前访问节点已被其它节点启动的 DFS 访问,无需再重复搜索,直接返回 true
flag[i] = 1; // 将当前访问节点的状态置为 1,标记其被本轮 DFS 访问过
for (int j : adjacency.get(i)) {
if (!dfs(adjacency, flag, j)) return false; // false 不断回溯返回,true 则继续检查
}
flag[i] = -1; // **回溯即代表本轮以 i 为起点的 DFS 结束**:其所有邻接节点都已被遍历且并没有发现环,将当前节点 i 的状态置为 -1,表示它变为已被其它节点启动的 DFS 访问过,并返回 false
return true;
}
}
tips:
- 邻接表为集合 List 的形式:i 索引位置处的元素即对应图中的 i 节点,其存储的为 i 节点所指向的节点集合。邻接表一般用于表示有向图,而邻接矩阵一般用于表示无向图;
- 时间复杂度:O(N+M),N 为节点数量,M 为临边数量,遍历一个图需要访问所有节点和所有边
- 空间复杂度:O(N+M),空间代价为建立邻接表所需的额外空间,adjacency 长度为 N ,并存储 M 条临边的数据
Ⅴ Dynamic Programming 动态规划
动态规划相关的算法题目。
将大问题划分为小问题进行解决,从而一步步获取最优解的处理算法。动态规划算法与分治算法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
与分治算法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的 ( 即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解 )。若用分治算法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。
338. Counting Bits 比特位计数
给定一个非负整数 num。对于 0 ≤ i ≤ num 范围中的每个数字 i ,计算其二进制数中的 1 的数目并将它们作为数组返回。要求算法的空间复杂度为O(n)。要求在C++或任何其他语言中不使用任何内置函数(如 C++ 中的 __builtin_popcount)来执行此操作。
示例:
输入:5
输出:[0,1,1,2,1,2]
思路:
动态规划+位运算。
n 为奇数时:奇数 n 二进制表示中 1 的个数一定比前面的偶数 n - 1 多一个 1,因为多的这个 1 就是 n 最低位的 1。
eg:0 <-> 0,1 <-> 1;2 <-> 10,3 <-> 11。
n 为偶数时:偶数 n 二进制表示中 1 的个数一定和其除以 2 之后的 n/2 一样多,因为 n 最低位为 0,除以 2 就是右移一位,即将最低位的 0 抹掉,所以 1 的个数是不变的。
eg:2 <-> 10,4 <-> 100,8 <-> 1000;3 <-> 11,6 <-> 110,12 <-> 1100。
对于 n = 0,其二进制中 1 的个数为 0,遍历数字 0~n,对于偶数 i 其 result[i] = result[i >> 1] + 0(状态转移方程);对于奇数 i,其 result[i] = result[i - 1] + 1,而 result[i - 1] = result[(i - 1) >> 1] = result[i >> 1],故 result[i] = result[i >> 1] + 1(状态转移方程)。将两种情况合二为一,对于奇数和偶数情况的状态转移方程仅第二部分不同,定义 i & 1,当 i 为偶数其结果为 0,为奇数其结果为 1。
题解:
class Solution {
public int[] countBits(int n) {
int[] result = new int[n + 1]; // 整数 i 的二进制表示中的 1 的数目为 dp[i]
for (int i = 0; i <= n; i++) {
result[i] = result[i >> 1] + (i & 1); // 状态转移方程
}
return result;
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
238. Product of Array Except Self 除自身以外数组的乘积
给你一个长度为 n 的整数数组 nums,其中 n > 1,返回输出数组 output ,其中 output[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。请不要使用除法,且在 O(n) 时间复杂度内完成此题。
示例:
输入:[1,2,3,4]
输出:[24,12,8,6]
思路:
output[i] 表示的乘积 = 当前数 i 左边的乘积 * 当前数 i 右边的乘积。使用两次循环来分别求解左边的乘积和右边的乘积。
题解:
class Solution {
public int[] productExceptSelf(int[] nums) {
int len = nums.length; // len > 1
int[] result = new int[len];
int l = 1, r = 1; // l为当前索引(包含)左边元素的乘积,r为当前索引(包含)右边元素的乘积
for (int i = 0; i < len; i++) { // result[i] = nums[0,...,i-1]中元素的乘积
result[i] = l;
l *= nums[i];
}
for (int i = len - 1; i >= 0; i--) { // result[i] *= nums[i+1,...,end]中元素的乘积
result[i] *= r; // 这里为 *=
r *= nums[i];
}
return result;
}
}
tips:
- 时间复杂度:O(n);
- 空间复杂度:O(1),输出数组不被视为额外空间
279. Perfect Squares 完全平方数
见 Ⅲ. 4. 279
322. Coin Change 零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。你可以认为每种硬币的数量是无限的。1 <= coins.length <= 12。0 <= amount <= 10^4。
示例:
输入:coins = [1, 2, 5], amount = 11ㅤ|ㅤcoins = [2], amount = 3
输出:3ㅤ|ㅤ-1
解释:11 = 5 + 5 + 1ㅤ|ㅤ没有任何一种硬币组合能组成总金额
思路:
思路与279题相似。自底向上,考虑金额为 i 的情况,之会使用到之前小于 i 的已考虑过的情况。对每一总金额,减去所有可能的硬币币值,再取凑成减去后的剩余总金额的最少硬币数的最小值即可。
所需硬币的最少个数 curMin,对于每个金额的情况初始化值为 amount + 1(存在面值为1的硬币时,最多也只需要 amount 个)。当一种情况无效时(即没有任何一种硬币组合能组成总金额 i,则 dp[i] = amount + 1);当考虑之后总金额大于 i 的情况,一种 coin 情况使用到之前无效的 dp[i],其 curMin 只会还是比 amount 大,当使用另一种 coin 情况其 curMin 会比 amount 小,自然会淘汰无效的情况。
题解:
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1]; // 状态表达式:凑成总金额 i 所需的最少的硬币个数为 dp[i]
for (int i = 1; i <= amount; i++) { // base case: dp[0] = 0
int curMin = amount + 1; // 所需硬币的最少个数
for (int coin : coins) {
if (i - coin < 0) continue; // 面额为负则跳过此次循环
curMin = Math.min(curMin, dp[i - coin]);
}
dp[i] = curMin + 1; // 状态转移方程
}
return dp[amount] > amount ? -1 : dp[amount];
}
}
tips:
- 时间复杂度:O(nk),n 为面额数,k 为总金额
- 空间复杂度:O(k)
198. House Robber 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。1 <= nums.length <= 100。0 <= nums[i] <= 400。
示例:
输入:[1,2,3,1]
输出:4
思路:
动态规划。状态转移方程为从第3号房屋开始判断,对于打劫到第 i 间房屋,其最大可获得金额为两种情况:
1) 打劫当前房屋的金额 + 打劫前一号房屋可获得的最大金额
2) 不打劫当前房屋,即打劫上一号房屋可获得的最大金额
题解:
class Solution { // 求解数组中不相邻元素的最大和
public int rob(int[] nums) {
if (nums.length == 1) return nums[0];
if (nums.length == 2) return Math.max(nums[0], nums[1]); // 动态规划条件判断前提
int[] dp = new int[nums.length]; // 状态表达式:打劫到第i号房屋可以获得的最高金额为dp[i]
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]); // base case
for (int i = 2; i < nums.length; i++) dp[i] = Math.max(nums[i] + dp[i - 2], dp[i - 1]); // 状态转移方程
return dp[nums.length - 1];
}
}
tips:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
337. House Robber III 打家劫舍 III
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额。0 <= Node.val <= 10^4。
示例:
输入:root = [3,4,5,1,3,null,1]
输出:9
思路:
动态规划【两种状态转移】。二叉树上的每个节点都有选中和不选中两种状态,在不同时选中有父子关系的节点下,二叉树的最大节点值和。
当前节点被选中时,其左/右子节点都不能被选中,故当前子树的最大节点值和 = 左/右子节点都不被选中的最大节点值和相加;
当前节点不被选中时,其左/右子节点都可以选中或者不选中,故当前子树的最大节点值和 = 左节点选中/不选中下最大节点值和的较大值 + 右节点选中/不选中下最大节点值和的较大值。
后序遍历二叉树,对每个节点定义长度为2的数组存储上述的选中 & 不选中当前节点子树的最大节点值和。
题解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public int rob(TreeNode root) {
int[] result = postorderTraversal(root);
return Math.max(result[0], result[1]);
}
private int[] postorderTraversal(TreeNode node) {
if (node == null) return new int[] {0, 0}; // 递归边界条件
int[] left = postorderTraversal(node.left);
int[] right = postorderTraversal(node.right);
int[] root = new int[2]; // root[0] 代表打劫当前节点下该子树的最高盗取金额;root[1] 代表不打劫当前节点下该子树的最高盗取金额
root[0] = node.val + left[1] + right[1];
root[1] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
return root;
}
}
tips:
- 自底向上的动态规划类似二叉树的后序遍历;
- 时间复杂度:O(n)
- 空间复杂度:O(n),递归调用时栈的最大深度。
309. Best Time to Buy and Sell Stock with Cooldown 最佳买卖股票时机含冷冻期
给定一个整数数组prices,其中第 prices[i] 表示第 i 天的股票价格 。设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例:
输入:prices = [1,2,3,0,2]
输出:3
解释:对应的交易状态为 [买入, 卖出, 冷冻期, 买入, 卖出]
思路:
动态规划【三种状态转移】,思路类似337题;也使用了定义状态机的思路:
hold:成为hold状态,有两条路线,hold下rest继续hold & rest下buy成为hold。其最大收益为 Math.max(上一hold, 上一rest减去当前买入价);
sold:成为sold状态,只有从hold状态售出一条路线。其最大收益为 上一hold加上当前售出价;
rest:成为rest状态,有两条路线,rest下继续rest & sold后rest成为rest。其最大收益为 Math.max(上一rest, 上一sold)。
hold的base case初始化为-∞:为了使第一天可以买入、第一天不能卖出(使状态格无效)。
题解:
class Solution {
public int maxProfit(int[] prices) {
int hold = Integer.MIN_VALUE, sold = 0, rest = 0;
for (int price : prices) {
int preSold = sold;
sold = hold + price;
hold = Math.max(hold, rest - price);
rest = Math.max(preSold, rest);
}
return Math.max(rest, sold);
}
}
tips:
- 类似91题,在空间复杂度上也进行了降维;
- 时间复杂度:O(n)
- 空间复杂度:O(1)
416. Partition Equal Subset Sum 分割等和子集
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例:
输入:nums = [1,5,11,5]ㅤ|ㅤnums = [1,2,3,5]
输出:trueㅤ|ㅤfalse
解释:数组可以分割成 [1, 5, 5] 和 [11]ㅤ|ㅤ数组不能分割成两个元素和相等的子集
思路:
01背包问题。在 n 个物品中选出一定物品,(完全)填满 sum/2 的背包。降维类似补充题2。
状态转移:F(i, j) = F(i - 1, j) || F(i - 1, j - nums[i])
题解:
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) sum += num;
if ((sum & 1) == 1) return false; // 奇数
sum >>= 1;
boolean[] dp = new boolean[sum + 1];
for (int i = 0; i <= sum; i++) dp[i] = nums[0] == i;
for (int i = 1; i < nums.length; i++) {
for (int j = sum; j >= nums[i]; j--) dp[j] = dp[j] || dp[j - nums[i]];
}
return dp[sum];
}
}
tips:
- 时间复杂度:O(n * sum)
- 空间复杂度:O(sum)