写在前面
- 主要分类一下刷题遇到的一些题型。
- 有很多思路的图都来源于力扣的题解,如侵权会及时删除。
- 不过代码都是个人实现的,所以有一些值得记录的理解。
- 之前的算法系列参看:
一、哈希表
1. 两数之和
- 思路:
- 时间复杂度是O(N)的算法是使用哈希表,这样只用遍历数组一次;
- 这题因为数组是没有排序的,而且需要返回数的下标,所以其实和剑指offer算法题02的十、4. 和为s的两个数字使用的双指针思路不一样,
没法在空间复杂度为O(1)的情况下少于O(N^2)的时间复杂度。其实也是可以在O(NlogN)时间复杂度下用O(1)的空间复杂度实现的,只需要先增加一个排序即可。 - 代码:
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map;
for(int i=0;i<nums.size();++i) {
if(map.find(target - nums[i]) != map.end()) {
return {i, map[target - nums[i]]};
}
map[nums[i]] = i;
}
return {};
}
};
2. 字母异位词分组
-
思路:
-
可以用排序后的字符串作为
key
,这样同字母构成的字符串的key
就相同了; -
其实难点是在于STL的使用,涉及的STL包括
string
,unordered_map
和二维数组,以及sort
函数; -
代码:
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
vector<vector<string>> re;
unordered_map<string, int> map;
for(int i=0;i<strs.size();++i) {
// 复制字符串做排序
string sorted_str = strs[i];
sort(sorted_str.begin(), sorted_str.end());
// 检查map中是否已经存在key
if(map.find(sorted_str) == map.end()) {
re.push_back(vector<string>());
map[sorted_str] = re.size() - 1;
}
// 根据key值将strs[i]分类
re[map[sorted_str]].push_back(strs[i]);
}
return re;
}
};
3. 最长连续序列
- 思路一:[hash set]
- 利用hash set,也就是用空间换时间;
- 先将所有元素存入hash set;
- 然后遍历每个元素,进行
x->x+1->x+2...
的生长,直到找到右边界; - 为了避免重复生长,导致时间复杂度大于O(N),仅对集合中不存在
x-1
的x
执行生长,因为这样的元素必定不会被别的生长遍历到;
- 这个思路最简单,但是在生长的时候还是要逐一遍历,因此所需时间最长;
- 代码一:
class Solution {
public:
int longestConsecutive(vector<int>& nums) {
unordered_set<int> set;
// 用hash set保存
for(int i=0;i<nums.size();++i) {
set.insert(nums[i]);
}
int max_len = 0;
for(int i=0;i<nums.size();++i) {
int cur = nums[i];
int right = cur;
if(set.find(cur-1) == set.end()) {
// 不存在前一个元素,表明cur不可能由别的元素经过while遍历到
while(set.find(right+1) != set.end()) {
// 通过逐一生长右边界,统计以cur开始的最长连续序列
++right;
}
}
int len = right - cur + 1;
if(max_len < len) {
max_len = len;
}
}
return max_len;
}
};
- 思路二:[动态规划]
- 使用hash map保存每个元素对应的最长连续子序列长度;
- 因此,一个新加的元素
cur
的最长连续子序列长度为:
m a p [ l e f t , . . . c u r , . . . r i g h t ] = m a p [ c u r − 1 ] + 1 + m a p [ c u r + 1 ] map[left,...cur,...right] = map[cur-1] + 1 + map[cur+1] map[left,...cur,...right]=map[cur−1]+1+map[cur+1]
- 也就是新加入的元素将元素左右的子序列连接在一起了;
- 理论上,原left到right的map值都应该更新为新的长度,但实际上后续的计算只会用到这个序列的边界的值,因为序列内的元素都已经被访问过了,不可能再被串联,因此只需要更新边界元素值即可;
- 当然,需要增加一个判断,确保相同值的元素仅被处理一次;
- 这种思路仅需要遍历一次所有元素,因此时间是最短的,但因为用的是hash map,所以空间开销大于思路一;
- 代码二:
class Solution {
public:
/*
动态规划:map[left-cur-right] = map[cur-1] + 1 + map[cur+1]
*/
int longestConsecutive(vector<int>& nums) {
unordered_map<int, int> map;
int max_len = 0;
for(int i=0;i<nums.size();++i) {
int cur = nums[i];
if(map.find(cur) != map.end()) {
// 需要判断cur是否已经被访问过了
continue;
}
int left = 0;
if(map.find(cur-1) != map.end()) {
left = map[cur - 1];
}
int right = 0;
if(map.find(cur + 1) != map.end()) {
right = map[cur + 1];
}
int len = left + 1 + right;
if(max_len < len) {
max_len = len;
}
// cur值要记录是否已被访问,且要在边界更新前,以免被重写
map[cur] = -1;
// 只更新左右边界的最大长度,因为边界内的元素不会被访问了,所以不需要更新
map[cur - left] = len;
map[cur + right] = len;
}
return max_len;
}
};
- 思路三:[并查集]
- 实际上是把寻找连续子序列的过程转换成寻找连通分量的过程;
- 但这个并查集的实现需要做特殊的处理,具体来说是在合并时将当前元素
x
并入x+1
所在连通分量,也就是说x+1
是x
的根节点,方向不能改变; - 这样才能通过
find
函数直接找到最右边界,然后得到子序列长度; - 特别注意,一定要在
find
函数中使用路径压缩,这会极大减少运行的时间,但两种路径压缩方式之间相差不大; - 路径压缩后,运行时间略多于思路二,但远少于思路一;
- 代码三:
- 采用的是第一种路径压缩方式;
- 三个并查集基本函数均是通用实现,但在合并时需要注意合并的方向;
class Solution {
private:
unordered_map<int, int> map;
void set_init(vector<int>& nums) {
for(int i=0;i<nums.size();++i) {
map[nums[i]] = nums[i];
}
}
int set_find(int x) {
// 返回的是最右边界
if(map[x] == x) {
return x;
}
map[x] = set_find(map[x]);
return map[x];
}
void set_union(int x, int y) {
// x是y的根节点
int parent_x = set_find(x);
int parent_y = set_find(y);
if(parent_x != parent_y) {
map[y] = parent_x;
}
}
public:
int longestConsecutive(vector<int>& nums) {
// 初始化map
set_init(nums);
// 合并相邻的nums[i]
for(int i=0;i<nums.size();++i) {
if(map.find(nums[i]+1) != map.end()) {
// 只按照nums[i] -> nums[i] + 1合并,方向不能改变
set_union(nums[i]+1, nums[i]);
}
}
int max_len = 0;
for(int i=0;i<nums.size();++i) {
int cur = nums[i];
int right = set_find(cur);
int len = right - cur + 1;
if(max_len < len) {
max_len = len;
}
}
return max_len;
}
};
4. 找到所有数组中消失的数字
- 思路:
- 用哈希表记录即可(可用
vector
而不用unordered_set
,因为key
范围已知); - 但这里要求不用额外的空间,又下标范围恰好和值范围等大,则可以用原数组作为哈希表使用;
- 代码:
class Solution {
/*
原地哈希:
index: 0 1 2 3 4 [0, n-1] = value - 1
value: 1 2 3 4 5 [1, n]
+ n : 6 7 8 9 10[1, n] + n > n
*/
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
int n = nums.size();
for(int i=0;i<nums.size();++i) {
nums[(nums[i]-1)%n] += n;
}
vector<int> re;
for(int i=0;i<nums.size();++i) {
if(nums[i] <= n) {
// 小于等于n的数均是没有+n的,也就是缺失的数
re.push_back(i+1);
}
}
return re;
}
};
5. 和为 K 的子数组 [前缀和]
- 思路:
- 说实话,这道题其实很容易误入双指针或者动态规划的歧途;
补充:双指针和动态规划的适用范围
- 但实际上,
- 双指针通常只能处理 字符串类型或者非负整数数组类型的数据,如果
nums[i]
都是正数,确实是可以用双指针来做的,参看:剑指offer算法题02的十、5. 和为s的连续正数序列 [滑动窗口]; - 动态规划可以处理有正有负的求和问题,但它一般是求解最值问题而不是恰好问题,恰好问题除非能转为背包问题,否则一般是不考虑动态规划的,参考:剑指offer算法题02的七、3. 连续子数组的最大和;
- 双指针通常只能处理 字符串类型或者非负整数数组类型的数据,如果
- 估计就是因为之前做过很多类似的题目,所以这里的思路就会乱七八糟的,所以上面先根据目前遇到的题目暂时总结一下双指针和动态规划的适用范围;
- 这道题只能用常规的枚举方法,对于每个
i
,向后枚举每个[i:j]
区间之和是否是k
,时间复杂度最低能到O(N^2); - 但可以引入哈希表,将时间复杂度降至O(N);
- 假设第
i
个数的前缀和(含i
)是prefix_num[i]
; - 则区间和
sum[i:j] = prefix_sum[j] - prefix_sum[i-1]
; - 因此,在判断以当前
j
结尾的区间是否能凑成k
,只需看之前的前缀和是否存在prefix_sum[i-1] = prefix_sum[j] - k
;
- 假设第
- 注意
prefix_sum[i-1]
会取到prefix_sum[-1]
的,因此需要增加一个prefix_sum[-1] = 0 -> 1
的映射,这是前缀和解法均需要考虑的点; - 代码:
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
// map含义:<first, 前缀和是first的元素个数>
unordered_map<int, int> map;
int prefix_sum = 0;
// prefix_sum[-1]
map[prefix_sum] = 1;
int re = 0;
for(int i=0;i<nums.size();++i) {
// 计算前缀和
prefix_sum += nums[i];
// sum[i:j] = prefix_sum[j]-prefix_sum[i-1] = k
// prefix_sum[j] - k = prefix_sum[i-1]
if(map.find(prefix_sum - k) != map.end()) {
re += map[prefix_sum - k];
}
++map[prefix_sum];
}
return re;
}
};
变体1. 0 和 1 个数相同的子数组 [前缀和]
-
思路:
-
其实是可以转换为5. 和为 K 的子数组 [前缀和] 来做的;
-
令所有的
0
替换为-1
,则问题转换为寻找和为0的最长子数组; -
还是用前缀和来做,只是哈希表中不是存放前缀和的个数,而是存放第一次出现该前缀和的下标;
- 因为要子数组最长,所以肯定是用当前下标减去最左边满足要求的下标的;
-
代码:
class Solution {
/*
* 如果将所有的0换成-1,则等价于找和为0的最长子数组
* prefix_sum[j+1] - prefix_sum[i] = sum[i,j]
* prefix_sum[i] = prefix_sum[j+1] - sum[i,j]
*/
public:
int findMaxLength(vector<int>& nums) {
// map含义:<first, 第一次出现前缀和是first的元素下标>
unordered_map<int, int> map;
int re_max = 0;
int prefix_sum = 0;
map[prefix_sum] = 0; // index=0的前缀和是prefix_sum
for(int i=1;i<=nums.size();++i) {
if(nums[i-1] == 0) {
// 将所有的0换成-1
prefix_sum += -1;
}
else {
prefix_sum += nums[i-1];
}
// 因为这里的map[0]=0是要用作下标的,所以下面的判断不能用0判断有无记录
if(map.find(prefix_sum) != map.end()) {
re_max = max(re_max, i - map[prefix_sum]);
}
else {
// 首次记录
map[prefix_sum] = i;
}
}
return re_max;
}
};
变体2. 左右两边子数组的和相等 [前缀和]
- 思路:
- 还是前缀和;
- 先求所有的元素之和;
- 然后对于每个元素,如果它的前缀和等于所有元素之和减去当前元素后的一半,则满足条件;
- 只返回第一次满足条件的下标;
- 比5. 和为 K 的子数组 [前缀和] 和变体1都要简单,因为无需做前缀和的公式推导;
- 这题其实是不需要用哈希表的,但还是放在这个题型里面,因为思路和5. 和为 K 的子数组 [前缀和] 是类似,而且暂时没有前缀和的模块(逃~);
- 代码:
class Solution {
public:
int pivotIndex(vector<int>& nums) {
// 求和
int sum = 0;
for(int i=0;i<nums.size();++i) {
sum += nums[i];
}
// 找第一个中心下标
int re_index = -1;
int prefix_sum = 0;
for(int i=0;i<nums.size();++i) {
int rest_sum = sum - nums[i];
// 注意下面的取余必须是非负数才行
if(abs(rest_sum) % 2 == 1) {
prefix_sum += nums[i];
continue;
}
if(prefix_sum == rest_sum/2) {
re_index = i;
// 取第一次满足的下标即可
break;
}
prefix_sum += nums[i];
}
return re_index;
}
};
二、二维矩阵
1. 旋转图像
- 思路:
- 其实是找规律,逐个将元素顺时针转换即可;
- 难点是找到这个规律;
- 最稳健的方法就是从特殊推一般,推几个特殊位置就可以慢慢看出规律了;
- 推导的过程在下面代码的注释里;
- 代码:
class Solution {
public:
/*
(0,0) -> (0, n-1) -> (n-1, n-1) -> (n-1,0)
(0,1) -> (1, n-1) -> (n-1, n-2) -> (n-2,0)
(0,2) -> (2, n-1) -> (n-1, n-3) -> (n-3,0)
直到(0,n-2), 即完成最外圈的旋转
(1,1) -> (1, n-2) -> (n-2, n-2) -> (n-2,1)
(1,2) -> (2, n-2) -> (n-2, n-3) -> (n-3,1)
直到(1,n-3), 即完成倒数第二圈的旋转
每次从(i,i)开始走,直到奇数i => n/2, 偶数i => n/2 - 1 (含)
*/
void rotate(vector<vector<int>>& matrix) {
int n = matrix[0].size();
for(int i=0;i<=(n-1)/2;++i) {
for(int j=i;j<=n-2-i;++j) {
// 依次旋转四个元素
int tmp = matrix[i][j];
matrix[i][j] = matrix[n-1-j][i];
matrix[n-1-j][i] = matrix[n-1-i][n-1-j];
matrix[n-1-i][n-1-j] = matrix[j][n-1-i];
matrix[j][n-1-i] = tmp;
}
}
}
};
2. 单词搜索
-
思路:
-
深度搜索遍历+剪枝;
-
注意的点如下:
- 深搜的时候可以上下左右同时都要遍历;
- 如果当前位置和字符串对不上,则四个位置的进一步深搜都不需要做,直接返回即可(剪枝);
- 如果匹配上了单词,则余下的深搜都可以终止,这可以用一个全局变量来实现(剪枝);
-
代码:
class Solution {
private:
bool re; // 用于辅助剪枝
void dfs(vector<vector<int>>& visited, const vector<vector<char>>& board, const string& word, int i, int j, int k) {
int m = board.size();
int n = board[0].size();
if(board[i][j] == word[k]) {
if(k == word.size() - 1) {
// 单词匹配上了
re = true;
return;
}
visited[i][j] = 1;
if(i+1 < m && !visited[i+1][j] && !re) {
dfs(visited, board, word, i+1, j, k+1);
}
if(j+1 < n && !visited[i][j+1] && !re) {
dfs(visited, board, word, i, j+1, k+1);
}
if(i-1 >= 0 && !visited[i-1][j] && !re) {
dfs(visited, board, word, i-1, j, k+1);
}
if(j-1 >= 0 && !visited[i][j-1] && !re) {
dfs(visited, board, word, i, j-1, k+1);
}
visited[i][j] = 0;
}
else {
return;
}
}
public:
bool exist(vector<vector<char>>& board, string word) {
int m = board.size();
int n = board[0].size();
vector<vector<int>> visited(m, vector<int>(n, 0));
re = false;
for(int i=0;i<m;++i) {
for(int j=0;j<n;++j) {
dfs(visited, board, word, i, j, 0);
if(re) {
break;
}
}
}
return re;
}
};
3. 岛屿数量
- 思路:
- 就是深度优先遍历所有相邻为
'1'
的点,则为一个岛屿; - 需要用一个
visited
矩阵记录是否已经经过当前的'1'
; - 深度优先遍历时,上下左右都要走,因为没有限制走法,所以也不要自己人为地加限制,不然会漏掉一些情况;
- 进一步优化:这里也可以不用
visited
矩阵,直接将grid
中经过的'1'
置为'0'
即可; - 矩阵的调试也可以用
print_info
函数,调试可以更加直观; - 代码:
class Solution {
private:
void dfs(vector<vector<int>>& visited, vector<vector<char>>& grid, int i, int j) {
if(visited[i][j] || grid[i][j]!='1') {
return;
}
visited[i][j] = 1;
if(i+1 < grid.size()) {
dfs(visited, grid, i+1, j);
}
if(j+1 < grid[0].size()) {
dfs(visited, grid, i, j+1);
}
if(i-1 >= 0) {
dfs(visited, grid, i-1, j);
}
if(j-1 >= 0) {
dfs(visited, grid, i, j-1);
}
}
void print_info(vector<vector<int>>& m) {
for(int i=0;i<m.size();++i) {
for(int j=0;j<m[0].size();++j) {
printf("%d ", m[i][j]);
}
printf("\n");
}
printf("\n");
}
public:
int numIslands(vector<vector<char>>& grid) {
vector<vector<int>> visited(grid.size(), vector<int>(grid[0].size(), 0));
int re_count = 0;
for(int i=0;i<grid.size();++i) {
for(int j=0;j<grid[0].size();++j) {
if(!visited[i][j] && grid[i][j]=='1') {
++re_count;
dfs(visited, grid, i, j);
//print_info(visited);
}
}
}
return re_count;
}
};
[4]. 搜索二维矩阵 II
- 思路:
- 和剑指offer算法题01中的二、1. 二维数组中的查找同题;
- 从右上角开始搜索,即可类似二叉搜索树进行查找;
- 代码:
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
int m = matrix.size();
int n = matrix[0].size();
int cur_x = 0, cur_y = n-1;
while(cur_x<m && cur_y>=0) {
if(target == matrix[cur_x][cur_y]) {
return true;
}
if(target > matrix[cur_x][cur_y]) {
// 往下走
++cur_x;
}
else {
// 往左走
--cur_y;
}
}
return false;
}
};
三、字符串和回溯
1. 电话号码的字母组合 [全排列]
-
思路:
-
是全排列的类型,而且不用考虑数字本身的全排列,比如“23”,则第一位的字母只能是“2”对应的字母,第二位只能是“3”对应的字母;
-
全排列的类型用递归来实现即可,类似于剑指offer算法题01的三、2. 字符串的排列的解法;
-
但这题相当于是纵向全排列,也就是每一位有多种选择;剑指offer算法题01中的相当于是横向全排列,也就是每一位只有一种选择,但是彼此之间可以调换位置;相对来说横向全排列会更难一些;
-
数字和字母之间的对应关系可以用map来构建(这种写法比较优雅);
-
另外注意,如果数字串本身为空,则返回的vector也是为空,也就是说长度为0的cur不用压入结果;
-
代码:
class Solution {
private:
void dfs(string &digits, string cur, int x, vector<string> &re, unordered_map<char, string> &map) {
if(x!=0 && x==digits.length()) {
re.push_back(cur);
return;
}
string letters = map[digits[x]];
for(int j=0;j<letters.length();++j) {
cur[x] = letters[j];
dfs(digits, cur, x+1, re, map);
}
return;
}
public:
vector<string> letterCombinations(string digits) {
vector<string> re;
// 存放对应关系
unordered_map<char, string> map{
{'2', "abc"},
{'3', "def"},
{'4', "ghi"},
{'5', "jkl"},
{'6', "mno"},
{'7', "pqrs"},
{'8', "tuv"},
{'9', "wxyz"}
};
// 用于保存当前排列
string cur(digits.length(), ' ');
// 递归
dfs(digits, cur, 0, re, map);
return re;
}
};
2. 括号生成 [全排列]
- 思路:
- 还是全排列的思路,用递归来实现即可;
- 核心点:
- 括号不是任意顺序的,必须先用左括号,才能再用右括号;
- 括号不是无限数量的,只有n个左括号和n个右括号,用完就没有了;
- 字符串的总长度是2n;
- 代码:
class Solution {
private:
void dfs(string cur, int x, int numLeft, int numRight, vector<string> &re) {
// printf("%s\n", cur.c_str());
if(numRight==0 && numRight==0) {
re.push_back(cur);
return;
}
else {
if(numLeft > 0) {
// 左括号有剩余
cur[x] = '(';
dfs(cur, x+1, numLeft-1, numRight, re);
}
if(numRight > 0 && numLeft < numRight) {
// 右括号有剩余且数量多于左括号
cur[x] = ')';
dfs(cur, x+1, numLeft, numRight-1, re);
}
}
}
public:
vector<string> generateParenthesis(int n) {
vector<string> re;
string cur(2*n, ' ');
dfs(cur, 0, n, n, re);
return re;
}
};
3. 下一个排列
- 思路:
- 说实话,这个题目我愣是看了半天都没看出问题的核心点在哪里,看了题解也是云里雾里的,最后还是在评论区中觅到一点天机
(不是; - 题解的思路如下:
-
核心的点大概是如下:
-
希望修改后的字典序更大,但修改前后的字典序相差要尽可能小;
-
(1) 要找从后往前数第一个升序序列中的倒数第二个元素,记为
nums[i]
;- 这是因为倒序序列已经是字典序最大了,交换倒序序列中的数字不可能出现字典序更大的序列;
- 从后往前找是因为后面的数字对字典序的影响小,类似于数字的权重;
- 因此第一个升序的倒数第二个元素是权重最小但又有机会增加字典序的元素;
-
(2) 再找从后往前数第一个大于
nums[i]
的数,记为nums[j]
;- 其实目的是想从
i
后面找一个比nums[i]
大但又相差最近的数; - 因为
i
之后均为倒序,因此从后往前找必定是一个递增序列,一旦大于nums[i]
肯定是最小的大于nums[i]
的数;
- 其实目的是想从
-
(3) 交换两个数,然后为
i
之后到结尾的序列排成升序,其实是翻转即可;- 因为
i
之后的序列是倒序,即使交换之后也是倒序,交换前有nums[j-1]>nums[j]>nums[i]>nums[j+1]
,所以交换后有nums[j-1]>nums[i]>nums[j+1]
; - 之所以换成升序,是因为升序是字典序最小的排序,也就是说,
i
之后的序列从倒序(字典序最大)换成升序(字典序最小),然后nums[i]
又刚好换成最逼近的大于它的数,因此在字典序上变换前后的两个序列刚好是相邻的;
- 因为
-
(4) 最后,如果整个序列是倒序的话,也就是说这时候字典序最大,就把整个序列翻转即可,变成字典序最小,即又回到了循环的起点;
-
不得不说,这个思路真的把握住了字典序的本质特点,十分巧妙,很amazing!
-
而且我一开始都不知道把这道题归到哪个类别中,后来想到字典序,还是放在字符串里面比较合适;
-
值得补充的是:
reverse()
函数可以直接使用C++的库,它的实现本质是双指针; -
代码:
class Solution {
public:
void nextPermutation(vector<int>& nums) {
bool isLast = true;
int i = nums.size() - 1 - 1;
// 找从后往前数第一个升序序列中的倒数第二个元素
// 也就是第一个小于后一个数的数
// 这是因为所有的倒序序列均已经是字典序最大了,没法通过调整数字的顺序获得更大的字典序
while(i >= 0) {
if(nums[i] < nums[i + 1]) {
isLast = false;
break;
}
--i;
}
if(isLast) {
// The range used is [first,last)
reverse(nums.begin(), nums.end());
}
else {
int j = nums.size() - 1;
// 找从后往前数第一个大于nums[i]的元素
// 这是因为num[i]后的序列是倒序序列,从后往前遍历的话数是递增的
// 所以第一个找到的大于nums[i]的元素就是最贴近nums[i]的数
while(j > i) {
if(nums[j] > nums[i]) {
break;
}
--j;
}
swap(nums[i], nums[j]);
reverse(nums.begin()+i+1, nums.end());
}
}
};
4. 组合总和 [回溯]
- 思路:
- 其实很难说这道题也是字符串类型的题目,但它确实是回溯的类型,所以也勉为其难
(不是把它归到字符串类型里面; - 还是用递归进行搜索,没有时间复杂度更小的方法了;
- 唯一的难点在于:
- 保证每种排列只出现一次,但是元素可以重复使用;
- 也就是说当前层的元素要么使用上一层的元素,要么使用上一层的元素之后的元素;
- 所以还要记录上一层元素到底用到哪个元素;
- 其实类似于纵向的全排列,即每一位可以用包括当前元素在内的多种元素,因为可以重复;
- 代码:
class Solution {
private:
vector<vector<int>> re;
void dfs(vector<int>& candidates, int target, vector<int> cur, int k) {
if(target == 0) {
re.push_back(cur);
return;
}
// 当前层元素必须是要么和上层元素相同,要么是在上层元素之后
for(int i=k;i<candidates.size();++i) {
if(target >= candidates[i]) {
// candidates[i]是当前层元素
cur.push_back(candidates[i]);
// 因此传到下一层的k是i
dfs(candidates, target - candidates[i], cur, i);
cur.pop_back();
}
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
dfs(candidates, target, vector<int>(), 0);
return re;
}
};
5. 全排列 [全排列]
- 思路:
- 全排列基础题(毕竟名字就是全排列);
- 是一种横向全排列,巧妙的思路是通过交换两个元素达到全排列的效果;
- 注意每层的
i
要从k
开始遍历,k
和自己交换就是这一位保持不变的意思; - 和剑指offer算法题01中的2. 字符串的排列几乎同题,而且不需要考虑字符重复的问题;
- 代码:
class Solution {
private:
vector<vector<int>> re;
void dfs(vector<int>& nums, int k) {
if(k == nums.size() - 1) {
re.push_back(nums);
return;
}
// i从k开始遍历
for(int i=k;i<nums.size();++i) {
swap(nums[k], nums[i]);
dfs(nums, k+1);
swap(nums[k], nums[i]);
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
dfs(nums, 0);
return re;
}
};
6. 子集 [回溯]
- 思路:
- 每个元素可以选择或者不选择,简单的做一个深度优先搜索即可;
- 在实现上要比全排列的简单;
- 但这道题还有一种迭代实现的方式,每次在已经构建好的元素的排列组合来构建本次的排列组合,相当巧妙;
- 递归实现的时间复杂度是
O
(
2
∗
2
N
)
O(2*2^N)
O(2∗2N):
- 共有 O ( 2 N ) O(2^N) O(2N)种排列组合;
- 每种排列组合的构建需要经历 N N N次处理,即 N N N次选或者不选;
- 但其实相邻两次处理仅有一位的不同,所以时间复杂度应该是少于 O ( N 2 N ) O(N2^N) O(N2N)的,大概是 O ( 2 N ) O(2^N) O(2N)级别;
- 具体而言应该是 O ( 2 N + 1 ) = O ( 2 ∗ 2 N ) O(2^{N+1})=O(2*2^N) O(2N+1)=O(2∗2N),因为可以把递归的路径考虑成满二叉树,则处理的次数和满二叉树的节点数一致;
- 迭代实现的时间复杂度是
O
(
2
N
)
O(2^N)
O(2N):
- 共有 O ( 2 N ) O(2^N) O(2N)种排列组合;
- 每种排列组合的构建仅需要经历 1 1 1次处理,即在之前的排列上增加最后一位数字;
- 和递归实现相比,避免了因为树的分叉而造成的多余处理,实际上是相当于不需要考虑不选某一位数字的情况,只需要考虑选择某一位数字的情况,因此时间复杂度少一半;
- 迭代实现不仅在时间复杂度的渐进常数上有优势,而且因为不需要调用函数堆栈,在时间和空间上都有很大优势;
- 代码:
- 递归思路:
class Solution {
private:
vector<vector<int>> re;
void dfs(vector<int>& nums, vector<int> cur, int k) {
if(k == nums.size()) {
re.push_back(cur);
return;
}
// 不放nums[k]
dfs(nums, cur, k+1);
// 放nums[k]
cur.push_back(nums[k]);
dfs(nums, cur, k+1);
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<int> cur;
dfs(nums, cur, 0);
return re;
}
};
- 迭代思路:
class Solution {
public:
/*
类似动态规划思路:dp[i]表示前i个数的解集,dp[i] = dp[i - 1] + collections(i)
其中,collections(i)表示把dp[i-1]的所有子集都加上第i个数形成的子集
时间复杂度是O(2^n):
1. n个元素共有2^n个排列组合,每个元素可以选或者不选;
2. 构建每个排列组合的时间复杂度是O(1),因为仅需在末尾增加一个元素;
相当于是利用了前面构建的元素的排列组合来构建本次的排列组合,相当巧妙
例如[1,2,3],一开始解集为[[]],表示只有一个空集。
遍历到1时,依次拷贝解集中所有子集,只有[],把1加入拷贝的子集中得到[1],然后加回解集中。
此时解集为[[], [1]]。
遍历到2时,依次拷贝解集中所有子集,有[], [1],把2加入拷贝的子集得到[2], [1, 2],然后加回解集中。
此时解集为[[], [1], [2], [1, 2]]。
遍历到3时,依次拷贝解集中所有子集,有[], [1], [2], [1, 2],把3加入拷贝的子集得到[3], [1, 3], [2, 3], [1, 2, 3],然后加回解集中。
此时解集为[[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]。
*/
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> re;
re.push_back(vector<int>());
for(int i=0;i<nums.size();++i) {
int size = re.size();
for(int j=0;j<size;++j) {
vector<int> tmp = re[j];
tmp.push_back(nums[i]);
re.push_back(tmp);
}
}
return re;
}
};
7. 字符串解码
- 思路:
- 规范的子结构是:
数字[重复字符串]
,因为一旦出现数字,后面必须有[重复字符串]
的结构,这样定义就比较清晰一点; - 难点在于重复字符串中可以嵌套子结构;
- 嵌套的结构处理有两种方式:栈和递归;
- (1) 如果用栈,这里需要两个栈,一个保存数字部分,一个保存重复的字符串部分;
- (2) 如果用递归,则:
- 遇到子结构就递归调用处理,也就是遇到第一个数字就递归;
- 递归中,先处理数字部分
tmp_len
,然后处理重复字符串部分tmp_s
; - 如果重复字符串部分有数字,则继续递归;
- 如果遇到右括号
]
则表示子结构已结束,完成了本层的递归,返回数字*重复字符串
的字串给上一层; - 注意,指针
i
仅遍历一遍数字即可,因此传参用的是引用传递;
- 代码:
class Solution {
private:
/*
decodeStringAux:处理s[i]是数字的情况
*/
string decodeStringAux(string& s, int& i) {
string re;
// 处理数字到'['前的部分
int tmp_len = 0;
while(i<s.length() && s[i]!='[') {
tmp_len = tmp_len*10 + (s[i]-'0');
++i;
}
// 跳过'['
++i;
// 处理'['后到']'的字符部分
string tmp_s;
while(i<s.length()) {
if(s[i]>='a' && s[i]<='z') {
// 是字符则直接追加
tmp_s += s[i]; // char用+=追加
++i;
}
else {
if(s[i] == ']') {
// 遇到']'表明需要用tmp_len构建re并返回
for(int j=0;j<tmp_len;++j) {
re = re.append(tmp_s); // string用append或者+=追加
}
// 跳过']'
++i;
return re;
}
else {
// 遇数字则继续递归,递归结果作为tmp_s的一部分
tmp_s.append(decodeStringAux(s, i));
}
}
}
return re;
}
public:
string decodeString(string s) {
string re;
int i = 0;
while(i < s.length()) {
if(s[i]>='0' && s[i]<='9') {
re.append(decodeStringAux(s, i));
}
else {
re += s[i];
++i;
}
}
return re;
}
};
补充:关于string的字符串追加
- (1) 如果是追加
char
类型,则只能使用+
运算符重载; - (2) 如果是追加
string
类型,则既可以用+
运算符重载,也可以用append(string&)
函数; - 推荐均使用运算符重载;
四、链表
1. 两数相加
- 思路:
- 其实是比较简单的链表生成操作;
- 只要处理当前的值和进位即可;
- 但注意最后的进位仍要处理;
- 另外进位要赋初始值,避免出错(别的变量也可以考虑赋初始值,保险一点);
- 代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
int rest = 0; // 进位
int cur = 0; // 当前值
ListNode* re_head = nullptr;
ListNode* re_cur = nullptr;
while(l1 && l2) {
cur = (l1->val + l2->val + rest) % 10;
rest = (l1->val + l2->val + rest) / 10;
if(re_head == nullptr) {
re_cur = new ListNode(cur);
re_head = re_cur;
}
else {
re_cur->next = new ListNode(cur);
re_cur = re_cur->next;
}
l1 = l1->next;
l2 = l2->next;
}
// l1还没完
while(l1) {
cur = (l1->val + rest) % 10;
rest = (l1->val + rest) / 10;
if(re_head == nullptr) {
re_cur = new ListNode(cur);
re_head = re_cur;
}
else {
re_cur->next = new ListNode(cur);
re_cur = re_cur->next;
}
l1 = l1->next;
}
// l2还没完
while(l2) {
cur = (l2->val + rest) % 10;
rest = (l2->val + rest) / 10;
if(re_head == nullptr) {
re_cur = new ListNode(cur);
re_head = re_cur;
}
else {
re_cur->next = new ListNode(cur);
re_cur = re_cur->next;
}
l2 = l2->next;
}
// 处理最后的rest进位
if(rest != 0) {
re_cur->next = new ListNode(rest);
re_cur = re_cur->next;
}
return re_head;
}
};
[2]. 合并两个有序链表
- 思路:
- 就是普通的指针修改;
- 注意不要出现
x->next = y->next
的格式,一般是有错误的,正确的一般是x = y->next
的形式; - 使用伪头指针可以避免头节点的特殊处理;
- 和剑指offer算法题01四、4. 合并两个排序的链表同题;之前写的时候还增加了一些剪枝策略,可以减少一点时间,在某些情况下无需遍历完所有的节点(但不及这个写法优雅<(  ̄^ ̄)
叉腰); - 代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
// 伪头指针
ListNode *fakeHead = new ListNode();
ListNode *cur = fakeHead;
while(list1!=nullptr && list2!=nullptr) {
if(list1->val > list2->val) {
cur->next = list2;
list2 = list2->next;
}
else {
cur->next = list1;
list1 = list1->next;
}
cur = cur->next;
}
while(list1!=nullptr) {
cur->next = list1;
list1 = list1->next;
cur = cur->next;
}
while(list2!=nullptr) {
cur->next = list2;
list2 = list2->next;
cur = cur->next;
}
cur->next = nullptr;
return fakeHead->next;
}
};
3. 合并K个升序链表
-
思路:
-
还是同2. 合并两个有序链表的思路,找各个链表的当前头指针下最小的插入到新链表即可;
-
但由于有多个链表,每次用循环比较各个链表的头指针会相当耗时,故使用小顶堆维护最小节点;
-
直接将所有的链表的所有节点放入堆中,然后依次从堆中取节点构建链表即可;
-
需要考虑的点有两个:
- 堆内维护的是指向节点的指针;
- 由于C++没有小顶堆,只有大顶堆,所以还要通过定义新的辅助类,重载
<
运算符来间接实现;
-
代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
private:
// 因为C++只有大顶堆,所以需要重载<函数实现小顶堆
class ListHelp {
public:
ListNode *p;
ListHelp(ListNode *newP) {
p = newP;
}
// 注意重载的参数均为引用而不是指针
bool operator < (const ListHelp &lh) const{
if(p && lh.p && p->val > lh.p->val) {
return true;
}
else {
return false;
}
}
};
// 用辅助类实现小顶堆
priority_queue<ListHelp> q;
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
ListNode* fakeHead = new ListNode();
// 直接把所有链表的所有节点都放入堆
for(int i=0;i<lists.size();++i) {
ListNode* tmp = lists[i];
while(tmp) {
//q.push(ListHelp(tmp)); // 用构造函数构建新对象,不用加new
q.push({tmp}); // 直接用所有的成员变量构建新对象
tmp = tmp->next;
}
}
// 从堆里拿元素构建链表
ListNode *cur = fakeHead;
while(!q.empty()) {
ListHelp tmp = q.top();
cur->next = tmp.p;
cur = cur->next;
q.pop();
}
cur->next = nullptr;
return fakeHead->next;
}
};
4. LRU 缓存
-
思路:
-
正常的缓存直接用hash map即可,如redis之类的内存数据库就是这样;
-
但这里增加了最近最少使用的限制,需要增加双向链表来辅助实现时间相关的队列;
-
队列的头部是最新使用的节点(新插入或者新访问),尾部是最少使用的节点(即将删除);
-
增加节点的规则:
- 如果有相同的
key
,则修改其value
,并将其移动到队列的头部; - 否则,如果尚未达到最大容量,则直接加入到队列的头部;
- 否则,先删除尾部的一个节点,再加入到队列的头部;
- 如果有相同的
-
访问节点的规则:
- 将该节点移动到队列的头部;
-
为什么使用双向链表而不是单向链表?
-
因为移动、增加或者删除节点时,需要获得当前节点的前一个节点和后一个节点的指针,如果是单项链表需要从
head
开始遍历一遍,时间复杂度是O(N),使用双向链表时间复杂度是O(1); -
代码:
-
直接使用STL库来实现会简洁很多:
- 双向链表用
std::list
来实现; - 哈希表用
std::unordered_map
来实现;
- 双向链表用
-
但要注意:
- 用
list<Node>::iterator
作为链表节点的指向; - 用
*iterator
获取链表节点的值; - 哈希表的值是
list<Node>::iterator
,置空和判空时很巧妙地使用了list.end()
;- 因为
list.end()
是一个伪尾节点,存放的数据是不含该节点的; - 每次
push_back()
后,数据是放在list.end()
的前一个节点; - 由于
std::list
的迭代器不会失效,因此置空可以将map[key] = list.end()
,判空的时候也可以直接用map[key] == list.end()
;
- 因为
- 用
class LRUCache {
private:
class Node {
public:
int key;
int value;
Node(int a, int b): key(a), value(b) {};
};
int _size;
list<Node> cache_list;
unordered_map<int, list<Node>::iterator> map;
public:
LRUCache(int capacity) {
_size = capacity;
}
int get(int key) {
if(map.find(key) != map.end() && map[key] != cache_list.end()) {
// 已经在cache_list中,移动到链表末尾
Node tmp = *map[key];
cache_list.erase(map[key]);
map[key] = cache_list.end();
cache_list.emplace_back(tmp);
map[key] = prev(cache_list.end());
return tmp.value;
}
return -1;
}
void put(int key, int value) {
if(map.find(key) != map.end() && map[key] != cache_list.end()) {
// 已经在cache_list中,移动到链表末尾
Node tmp = *map[key];
cache_list.erase(map[key]);
map[key] = cache_list.end();
tmp.value = value;
cache_list.emplace_back(tmp);
map[key] = prev(cache_list.end());
}
else {
if(cache_list.size() < _size) {
// cache_list中仍有空间,直接存入
cache_list.emplace_back(key, value);
map[key] = prev(cache_list.end());
}
else {
// cache_list已满,先逐出再存入
Node tmp = cache_list.front();
cache_list.pop_front();
map[tmp.key] = cache_list.end();
cache_list.emplace_back(key, value);
map[key] = prev(cache_list.end());
}
}
}
};
- 下面是用自己实现的双向链表,在实现上会更加麻烦;
- 但运行效率上会比用
std::list
快一点,空间开销也小;
class MyListNode {
public:
int key, value;
MyListNode *pre, *next;
MyListNode(): key(0), value(0), pre(nullptr), next(nullptr) {}
MyListNode(int k, int v): key(k), value(v), pre(nullptr), next(nullptr) {}
};
/*
1. hash map:保证get函数是O(1)
2. 双向列表:保证在任意位置增加和删除节点是O(1)
*/
class LRUCache {
private:
int _capacity, _size;
unordered_map<int, MyListNode*> _map; // hash map
MyListNode *_list_head, *_list_tail; // 伪头、尾节点
void print_info() {
MyListNode *cur = _list_head;
while(cur != nullptr) {
printf("{%d, %d} ", cur->key, cur->value);
cur = cur->next;
}
printf("\n");
}
public:
LRUCache(int capacity) {
_capacity = capacity;
_size = 0;
_list_head = new MyListNode();
_list_tail = new MyListNode();
_list_head->next = _list_tail;
_list_tail->pre = _list_head;
}
int get(int key) {
if(_map.find(key) != _map.end()) {
// 从双向列表中断开节点
MyListNode *pre = _map[key]->pre;
pre->next = _map[key]->next;
_map[key]->next->pre = pre;
// 移动节点到头部
_map[key]->next = _list_head->next;
_list_head->next = _map[key];
_map[key]->next->pre = _map[key];
_map[key]->pre = _list_head;
//print_info();
return _map[key]->value;
}
else {
return -1;
}
}
void put(int key, int value) {
if(_map.find(key) != _map.end()) {
// key已经存在,直接修改值
_map[key]->value = value;
// 从双向列表中断开节点
MyListNode *pre = _map[key]->pre;
pre->next = _map[key]->next;
_map[key]->next->pre = pre;
// 移动节点到头部
_map[key]->next = _list_head->next;
_list_head->next = _map[key];
_map[key]->next->pre = _map[key];
_map[key]->pre = _list_head;
}
else {
if(_size < _capacity) {
++_size;
}
else {
// 删除双向列表的尾部节点
MyListNode *tail = _list_tail->pre;
_map.erase(tail->key);
tail->pre->next = tail->next;
tail->next->pre = tail->pre;
delete tail;
}
// 在双向列表头部插入
MyListNode *cur = new MyListNode(key, value);
cur->next = _list_head->next;
cur->next->pre = cur;
_list_head->next = cur;
cur->pre = _list_head;
_map[key] = cur;
}
//print_info();
}
};
补充:std::list的迭代器用法
- 一些常见用法的示例如下:
class Node {
int value;
};
list<Node> test_list;
list<Node>::iterator iter = test_list.begin(); // 指向开头元素
// 注意:
//begin()和end()取的是迭代器
//front()和back()取的是节点对象
Node node = *iter; // 取节点值
int value = iter->value; // 取节点内元素
iter = test_list.end(); // 迭代器置空
if(iter == test_list.end()) ... // 迭代器判空
test_list.emplace_back(node);
iter = std::prev(test_list.end()); // 取尾元素的迭代器
// 等价于
iter = test_list.end();
--iter;
5. 排序链表 [归并排序]
-
思路一:sort函数
-
先用
vector
存下每个节点的指针,然后用sort
函数排序; -
需要重载
cmp
函数; -
实现上最为容易,但空间复杂度不是常数空间;
-
代码:
class Solution {
private:
static bool cmp(ListNode* &a, ListNode* &b) {
// 必须是严格小于,否则会出现空的a或者b
if(a->val < b->val) {
return true;
}
else {
return false;
}
}
public:
ListNode* sortList(ListNode* head) {
if(head == nullptr) {
return head;
}
vector<ListNode*> list;
ListNode* cur = head;
while(cur != nullptr) {
list.push_back(cur);
cur = cur->next;
}
sort(list.begin(), list.end(), cmp);
head = list[0];
cur = head;
for(int i=1;i<list.size();++i) {
cur->next = list[i];
cur = cur->next;
}
cur->next = nullptr;
return head;
}
};
-
思路二:归并排序
-
这个思路是究极麻烦,因为要用链表实现;
-
一定要注意的两个点是:
- 一定要断开子链表,操作是在子链表最后一个节点的
next
赋值nullptr
; - 合并后一定要将子链表连接回主链表,操作是在主链表的前一个节点连接回子链表的头节点,在子链表的最后一个节点连接回主链表的下一个节点;
- 一定要断开子链表,操作是在子链表最后一个节点的
-
归并排序的实现思路有两种:
- 自顶向下:就是递归的方法,实现上要更简单,但空间复杂度和思路一相同,不能做到常数空间;
- 寻找中点断开链表用的是快慢指针;
- 注意寻找中点时的边界条件,即只剩0节点、1节点、2节点和3节点;
- 时间长于
sort
方法和自底向上方法;
- 自底向上:就是用多重循环,实现上究极复杂,但空间复杂度是常数空间;
- 断开链表是从小的
step
到大的step
,直到step>=len
,step
每次增长一倍(乘2); - 断开链表的时候注意边界条件,即
sub1
不满step
,sub1
刚好为step
,sub2
不满step
和sub2
也满step
; - 应该是链表排序的正统实现方式;
- 时间和
sort
方法相差不大;
- 断开链表是从小的
- 自顶向下:就是递归的方法,实现上要更简单,但空间复杂度和思路一相同,不能做到常数空间;
-
但不管是哪一种实现,它们的
mergeList
都是一样,且实现思路就是合并两个有序链表的思路,需要返回合并后的链表头节点; -
(与思路无关的)补充:
- 实现一个
print_info
函数来打印链表能在调试的时候用来救命(≧﹏ ≦); - 出现死循环的时候注意是不是
cur = cur->next;
忘记写了; - 伪头/尾节点在链表的相关操作中相当好用,单向链表可以加伪头节点,双向链表可以加伪头/尾节点,但记得初始化时要和
head
相连,而且在结束的时候注意delete
掉内存,防止内存泄漏;
- 实现一个
-
自顶向下的代码:
class Solution {
private:
ListNode* mergeList(ListNode *head1, ListNode *head2) {
ListNode *fake_head = new ListNode(); // 伪头节点
ListNode *cur = fake_head;
while(head1!=nullptr && head2!=nullptr) {
if(head1->val <= head2->val) {
cur->next = head1;
head1 = head1->next;
}
else {
cur->next = head2;
head2 = head2->next;
}
cur = cur->next;
}
if(head1 != nullptr) {
cur->next = head1;
}
if(head2 != nullptr) {
cur->next = head2;
}
cur = fake_head->next;
delete fake_head;
return cur; // 返回头节点
}
void print_info(ListNode *head) {
ListNode *cur = head;
while(cur != nullptr) {
printf("%d, ", cur->val);
cur = cur->next;
}
printf("\n");
}
public:
ListNode* sortList(ListNode* head) {
if(head==nullptr || head->next==nullptr) {
return head;
}
ListNode *fake_head = new ListNode(); // 伪头节点
fake_head->next = head;
ListNode *slow = fake_head, *fast = fake_head;
ListNode *sub1 = head, *sub2;
while(fast->next != nullptr) {
slow = slow->next;
fast = fast->next;
if(fast->next != nullptr) {
fast = fast->next;
}
}
if(slow != nullptr) {
sub2 = slow->next;
slow->next = nullptr; // 断开sub1
}
// 记得重新赋值sub,否则sub不能代表sub的head
sub1 = sortList(sub1);
sub2 = sortList(sub2);
// 记得连接上fake_head
fake_head->next = mergeList(sub1, sub2);
return fake_head->next;
}
};
- 自底向上的实现:
class Solution {
private:
ListNode* mergeList(ListNode *head1, ListNode *head2) {
ListNode *fake_head = new ListNode(); // 伪头节点
ListNode *cur = fake_head;
while(head1!=nullptr && head2!=nullptr) {
if(head1->val <= head2->val) {
cur->next = head1;
head1 = head1->next;
}
else {
cur->next = head2;
head2 = head2->next;
}
cur = cur->next;
}
if(head1 != nullptr) {
cur->next = head1;
}
if(head2 != nullptr) {
cur->next = head2;
}
cur = fake_head->next;
delete fake_head;
return cur; // 返回头节点
}
void print_info(ListNode *head) {
ListNode *cur = head;
while(cur != nullptr) {
printf("%d, ", cur->val);
cur = cur->next;
}
printf("\n");
}
public:
ListNode* sortList(ListNode* head) {
ListNode *fake_head = new ListNode(); // 伪头节点
fake_head->next = head;
int len = 0;
ListNode *cur = head;
while(cur != nullptr) {
++len;
cur = cur->next;
}
int step = 1; // 合并的子list的长度
ListNode *sub1, *sub2;
ListNode *pre = fake_head;
cur = head;
while(step < len) {
while(cur != nullptr) {
sub1 = cur; // 第一个sub头
for(int i=0;i<step-1 && cur!=nullptr;++i) {
cur = cur->next;
}
if(cur == nullptr) {
// sub1就不足step
break;
}
sub2 = cur->next; // 第二个sub头
if(sub2 == nullptr) {
// sub1刚好是step,即sub2为空
break;
}
// sub1和sub2均不为空
cur->next = nullptr; // 断开sub1
cur = sub2;
for(int i=0;i<step-1 && cur!=nullptr;++i) {
cur = cur->next;
}
if(cur == nullptr) {
// sub2不足step,即sub2后为nullptr
ListNode *temp = mergeList(sub1, sub2);
pre->next = temp; // 接起来pre和合并的sub
}
else {
// sub1和sub2都满step
ListNode *temp = cur;
cur = cur->next;
temp->next = nullptr; // 断开sub2
temp = mergeList(sub1, sub2); // temp指向sub的head
pre->next = temp; // 接起来pre和合并的sub
while(temp->next != nullptr) {
// 令temp指向sub的最后一个节点
temp = temp->next;
}
pre = temp;
temp->next = cur; // 接起来合并的sub和cur
}
}
step *= 2;
// 记得重置pre和cur以开启下一次循环
pre = fake_head;
cur = fake_head->next;
}
return fake_head->next;
}
};
[6]. 反转链表
- 思路:
- 用三个指针,前一个节点
pre
,当前节点cur
,后一个节点aft
; - 令
cur
指向aft
,然后三个指针均后移一个节点; aft
是为了能让cur
能够按照原链表后移;- 直到
cur
为空; - 最后要令
head->next = nullptr
; - 这种方法时间复杂度是O(N),空间复杂度是O(1),是标准解法;
- 另外也可以用数组的方式存起来再反转,但空间复杂度是O(N);
- 和剑指offer算法题01中的四、3. 反转链表同题;
- 代码:
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if(head == nullptr || head->next == nullptr) {
return head;
}
ListNode *pre = head, *cur = head->next, *aft = cur->next;
while(cur != nullptr) {
aft = cur->next;
cur->next = pre;
pre = cur;
cur = aft;
}
head->next = nullptr;
return pre;
}
};
7. 回文链表
- 思路一:
- 用栈保存一次遍历顺序;
- 然后再遍历一次,和栈顶元素逐一比较即可;
- 时间复杂度是O(2N),空间复杂度是O(N);
- 实现很简单,但空间复杂度较高,不是O(1);
- 代码一:
class Solution {
public:
bool isPalindrome(ListNode* head) {
stack<int> s;
// 一次遍历,入栈
ListNode *cur = head;
while(cur != nullptr) {
s.push(cur->val);
cur = cur->next;
}
// 二次遍历,和栈顶元素比较
cur = head;
while(cur != nullptr) {
if(cur->val != s.top()) {
return false;
}
else {
s.pop();
cur = cur->next;
}
}
return true;
}
};
- 思路二:
- 从中间断开链表,然后反转后半部分链表;
- 再分别逐一遍历两个链表中的元素;
- 实现很复杂,但时间复杂度是O(2N),空间复杂度是O(1);
- 一些需要注意的点如下:
- 从中间断开链表是用快慢指针找到中间节点的;
- 使用伪头节点作为
slow
和fast
的起始节点; fast
移动的条件是fast->next != nullptr
;
- 使用伪头节点作为
- 反转链表需要用三个指针实现,反转完成后,头节点是
pre
指针;pre
的初始值为nullptr
,cur
的初始值是要反转的最后一个节点,这样cur->next = pre
就直接将最后一个节点的下一节点置空了;aft
的赋值要在cur->next = pre
之前完成,然后用cur = aft
移动cur
节点;
- 比较完成再复原的时候,需要再反转一次;
- 从中间断开链表是用快慢指针找到中间节点的;
- 代码:
class Solution {
public:
bool isPalindrome(ListNode* head) {
// 伪头节点,注意一定要连上head
ListNode *fake_head = new ListNode();
fake_head->next = head;
// 用快慢指针断开链表
ListNode *slow = fake_head, *fast = fake_head;
while(fast->next != nullptr) {
slow = slow->next;
fast = fast->next;
if(fast->next != nullptr) {
fast = fast->next;
}
}
// 反转链表
ListNode *pre, *cur, *aft;
pre = nullptr;
cur = slow->next;
while(cur != nullptr) {
aft = cur->next;
cur->next = pre;
pre = cur;
cur = aft;
}
ListNode *head2 = pre;
// 判断两个链表的前缀是否相等
ListNode *cur1 = head, *cur2 = head2;
while(cur1!=nullptr && cur2!=nullptr) {
if(cur1->val != cur2->val) {
return false;
}
cur1 = cur1->next;
cur2 = cur2->next;
}
// 反转第二个链表并重新连接复原
pre = nullptr;
cur = head2;
while(cur != nullptr) {
aft = cur->next;
cur->next = pre;
pre = cur;
cur = aft;
}
slow->next = pre;
return true;
}
};
五、二叉树
1. 二叉树的中序遍历
- 思路:
- 中序遍历是左根右,按照顺序递归即可;
- 代码:
class Solution {
private:
void dfs(TreeNode* root, vector<int>& re) {
if(root == nullptr) {
return;
}
dfs(root->left, re);
re.push_back(root->val);
dfs(root->right, re);
}
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> re;
dfs(root, re);
return re;
}
};
2. 验证二叉搜索树
-
思路:
-
(1)第一种方法:自顶向下遍历二叉树(推荐);
-
深度遍历函数用参数维护当前子树的理论上界和理论下界;
-
如果当前子树根节点不在理论上界和理论下界之中,则可以判断不符合二叉搜索树;
-
这种方法时间和空间的开销最佳,但理论上界和理论下界的处理要妥当;
-
这里需要用
long long
类型或者double
类型(推荐)来存放理论上界和理论下界; -
int
类型转double
类型的速度比转long long
类型的快; -
(2)第二种方法:中序遍历并用栈保持遍历结果;
-
如果遍历结果是单调增,即可判断符合二叉搜索树;
-
需要用一个额外的空间来存放遍历结果,时间上也略长,因为整个二叉搜索树均需要完整遍历,无法提前剪枝;
-
但实现起来最简单;
-
(3)第三种方法:自底向上遍历二叉树;
-
深度遍历函数返回当前子树的实际下界和实际上界;
-
如果当前根节点小于等于左子树的上界,或者大于等于右子树的下界,则可以判断不符合二叉搜索树;
-
实际的时间和空间消耗都是最大的,因为要用
vector
做返回类型,需要频繁进行值复制; -
代码:
-
方法一:自顶向下遍历
class Solution {
private:
bool dfs(TreeNode* root, double lower, double upper) {
if(root->val <= lower || root->val >= upper) {
return false;
}
bool left_val = true, right_val = true;
if(root->left != nullptr) {
left_val = dfs(root->left, lower, root->val);
}
if(root->right != nullptr) {
right_val = dfs(root->right, root->val, upper);
}
return left_val && right_val;
}
public:
bool isValidBST(TreeNode* root) {
// 注意题目所给范围已经是完整的int范围,所以上下界需要进一步拓展
return dfs(root, LONG_MIN, LONG_MAX);
}
};
- 方法二:中序遍历+栈检验
class Solution {
private:
stack<int> s;
void dfs(TreeNode* root) {
if(root == nullptr) {
return;
}
dfs(root->left);
s.push(root->val);
dfs(root->right);
}
public:
bool isValidBST(TreeNode* root) {
dfs(root);
// 检验站内元素是否单调增
int tmp = s.top();
s.pop();
while(!s.empty()) {
if(tmp <= s.top()) {
return false;
}
tmp = s.top();
s.pop();
}
return true;
}
};
- 方法三:自底向上遍历
class Solution {
private:
bool re;
vector<int> dfs(TreeNode* root) {
vector<int> left_vec = {root->val, root->val}, right_vec = {root->val, root->val};
if(re && root->left != nullptr) {
left_vec = dfs(root->left);
if(left_vec[1] >= root->val) {
re = false;
}
}
if(re && root->right != nullptr) {
right_vec = dfs(root->right);
if(right_vec[0] <= root->val) {
re = false;
}
}
return {left_vec[0], right_vec[1]};
}
public:
bool isValidBST(TreeNode* root) {
re = true;
dfs(root);
return re;
}
};
补充:关于常用类型最大值和最小值的写法
int
最小值和最大值:min = int(~((unsigned)(-1) >> 1))
,max = int((unsigned)(-1) >> 1)
;int(-1)
的补码是1111111111...111
(32个1,含1个符号位);(unsigned)(-1)
就是取1111111111...111
为原码, 是unsigned_int
的最大值,为2^32-1
;- 右移一位为
011111111...111
(31个1,符号位为0),是int
的最大值,为2^31-1
; - 按位取反是
10000000...000
,是-0
作-2^31
的补码(为了充分利用空间),也是int
的最小值; - 也就是说,
int
类型在存储时用的是补码来存储;
int
最小值和最大值也可以直接写:INT_MIN
和INT_MAX
;long
最小值和最大值也可以直接写:LONG_MIN
和LONG_MAX
;float
最小值和最大值也可以直接写:FLT_MIN
和FLT_MAX
;double
最小值和最大值也可以直接写:DBL_MIN
和DBL_MAX
;
[3]. 对称二叉树
- 思路:
- 转换成判断两棵子树是否对称;
- 和剑指offer算法题01中的五、4. 对称的二叉树同题;
- 代码:
class Solution {
private:
bool isSymmetricHelp(TreeNode* leftTree, TreeNode* rightTree) {
if(leftTree == nullptr && rightTree == nullptr) {
return true;
}
else {
if(leftTree == nullptr || rightTree == nullptr) {
return false;
}
}
if(leftTree->val != rightTree->val) {
return false;
}
return isSymmetricHelp(leftTree->left, rightTree->right) && isSymmetricHelp(leftTree->right, rightTree->left);
}
public:
bool isSymmetric(TreeNode* root) {
return isSymmetricHelp(root->left, root->right);
}
};
[4]. 二叉树的层序遍历
-
思路:
-
方法1:用两个队列交换记录每一层的广度优先遍历;
-
方法2:只用一个队列记录,但每层遍历前先用一个变量
q_size
记录当前队列(也就是当前层)元素的数量; -
方法1容易想到,但是实现起来比较繁琐;方法2实现较为整洁;两者的时间复杂度和空间复杂度均相同;
-
和剑指offer算法题01中的五、5. 变体1. 按层输出的从上到下打印二叉树同题;
-
代码:
-
这里只写方法2的实现;
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> re;
if(root == nullptr) {
return re;
}
queue<TreeNode*> q;
q.push(root);
while(!q.empty()) {
vector<int> cur;
int q_size = q.size(); // 记录本层需要遍历的元素个数
for(int i=0;i<q_size;++i) {
TreeNode* tmp = q.front();
cur.push_back(tmp->val);
q.pop();
if(tmp->left != nullptr) {
q.push(tmp->left);
}
if(tmp->right != nullptr) {
q.push(tmp->right);
}
}
re.push_back(cur);
}
return re;
}
};
[5]. 二叉树的最大深度
- 思路1:
- 深度遍历一次即可;
- 和剑指offer算法题01中的五、10. 二叉树的深度同题;
- 代码1:
class Solution {
public:
int maxDepth(TreeNode* root) {
if(root == nullptr) {
return 0;
}
int left = maxDepth(root->left);
int right = maxDepth(root->right);
return max(left, right) + 1;
}
};
-
思路2:
-
也可以用广度优先遍历,逐层增加高度即可;
-
代码2:
class Solution {
public:
int maxDepth(TreeNode* root) {
if(root == nullptr) {
return 0;
}
queue<TreeNode*> q;
q.push(root);
int depth = 0;
while(!q.empty()) {
++depth;
int q_size = q.size();
for(int i=0;i<q_size;++i) {
TreeNode* cur = q.front();
q.pop();
if(cur->left != nullptr) {
q.push(cur->left);
}
if(cur->right != nullptr) {
q.push(cur->right);
}
}
}
return depth;
}
};
[6]. 从前序与中序遍历序列构造二叉树
- 思路:
- 从前序遍历可以确定中序遍历根节点的位置,从中序遍历可以划分前序遍历左右子树的范围;
- 划分左右子树是通过子树的元素个数确定的;
- 划分左右子树后,可以继续递归求解左右子树;
- 构建节点时只需构建root节点,左右子树由递归构建;
- 和剑指offer算法题01中的五、1. 重建二叉树同题;
- 在寻找中序遍历根节点时除了用循环也可以用哈希表减少时间,但增加了空间开销;
- 代码:
class Solution {
private:
/*
preorder[p1, p2]和inorder[p3, p4]共同表示一棵子树
*/
TreeNode* buildTreeHelp(vector<int>& preorder, vector<int>& inorder, int p1, int p2, int p3, int p4) {
int root_p1 = p1;
int root_val = preorder[root_p1];
int root_p2 = -1;
// inorder中找根节点
for(int i=p3;i<=p4;++i) {
if(root_val == inorder[i]) {
root_p2 = i;
break;
}
}
// preorder中找左右子树分界点
int left_nums = root_p2 - p3; // 左子树元素个数
int left_pr1 = root_p1 + left_nums;
int right_nums = p4 - root_p2; // 右子树元素个数
// 建root节点
TreeNode *root = new TreeNode(root_val);
if(left_nums > 0) {
root->left = buildTreeHelp(preorder, inorder, root_p1+1, left_pr1, p3, root_p2-1);
}
else {
root->left = nullptr;
}
if(right_nums > 0) {
root->right = buildTreeHelp(preorder, inorder, left_pr1+1, p2, root_p2+1, p4);
}
else {
root->right = nullptr;
}
return root;
}
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
return buildTreeHelp(preorder, inorder, 0, preorder.size()-1, 0, inorder.size()-1);
}
};
7. 二叉树展开为链表
- 思路:
- 先序遍历是根左右,左子树中的元素一定在右子树的元素之前;
- 是十分巧妙的二叉树指针操作;
- 只修改指针的话空间复杂度是O(1);
- 代码:
class Solution {
public:
/*
先序遍历是根左右,也就是说左子树需要全部位于右子树之前
1. 将右子树移动到左子树的最右下节点(左子树先序遍历的最后一个节点)的right指针下
2. 将左子树(连同刚刚移动的右子树)移动到root->right,并置root->left = nullptr
3. 移动root指针到root->right(也就是刚刚左子树的根节点),继续处理
*/
void flatten(TreeNode* root) {
TreeNode *cur = root;
while(cur != nullptr) {
TreeNode *tmp = cur->left;
if(tmp != nullptr) {
// 左节点不为空才移动右子树和左子树
while(tmp->right != nullptr) {
tmp = tmp->right;
}
// 第一步
tmp->right = cur->right;
// 第二步
cur->right = cur->left;
cur->left = nullptr;
}
// 第三步
cur = cur->right;
}
}
};
8. 实现 Trie / 前缀树 [多叉树]
-
思路:
-
由于字母的类型有
26
种,所以前缀树每个节点都有26
个子节点; -
一个粒子如下:
-
因此实现上,就不是仅有两个指针指向子节点,而是用一个指针数组存
26
个指针分别指向26
个子节点; -
是二叉树的扩展,为
26
-叉树; -
(1) 每个节点的结构如下:
-
从扩展节点结构中可以看出:
- 树的每个节点均必须是要存指向子节点的指针的(即在自己的成员变量中定义自己的指针);
- 而且只能用指针,不能用有空间的对象,不然在定义的时候就会无限递归开辟空间;
-
(2) 实现的过程如下:
-
如果是判断存在前缀,则无需判断当前节点是否
is_end == true
; -
但如果是判断是否存在单词,则需要判断当前节点是否
is_end == true
; -
另外,一定不会出现通过别的路径到达当前
is_end == true
节点的,有且仅有一种可以到达当前节点的路径; -
代码:
class Trie {
private:
vector<Trie*> children; // 指向子节点的指针数组
bool is_end; // 标记当前节点是否为某个单词的结尾
public:
Trie() {
// 相当于先构建一个对象,再用vector的=重载函数复制过去
children = vector<Trie*>(26, nullptr);
is_end = false;
}
void insert(string word) {
Trie* cur_node = this;
for(int i=0;i<word.length();++i) {
int cur_letter = word[i] - 'a';
if(cur_node->children[cur_letter] == nullptr) {
cur_node->children[cur_letter] = new Trie();
}
cur_node = cur_node->children[cur_letter];
}
// 最后一个字母的节点标记is_end
cur_node->is_end = true;
}
bool search(string word) {
Trie* cur_node = this;
for(int i=0;i<word.length();++i) {
int cur_letter = word[i] - 'a';
if(cur_node->children[cur_letter] == nullptr) {
return false;
}
cur_node = cur_node->children[cur_letter];
}
// 检查是仅前缀还是完整的单词
if(cur_node->is_end) {
return true;
}
else {
return false;
}
}
bool startsWith(string prefix) {
Trie* cur_node = this;
for(int i=0;i<prefix.length();++i) {
int cur_letter = prefix[i] - 'a';
if(cur_node->children[cur_letter] == nullptr) {
return false;
}
cur_node = cur_node->children[cur_letter];
}
// 不需要进一步检查是否为完整单词
return true;
}
};
9. 翻转二叉树
- 思路:
- 其实就是左右节点对调就行了;
- 而且无论是从上往下对调(广度优先遍历)还是从下往上对调(深度优先遍历)均可以,顺序没有要求;
- 代码:
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if(root == nullptr) {
return root;
}
TreeNode *left = invertTree(root->left);
TreeNode *right = invertTree(root->right);
root->left = right;
root->right = left;
return root;
}
};
[10]. 二叉树的最近公共祖先
-
题目:https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/
-
思路:
-
和剑指offer算法题01中的五、12. 变体. 二叉树的最近公共祖先同题;
-
就是深度优先搜索,然后记录左右子树是否含有
p
或者q
节点;
-
递归求解过程如下:
-
注意这样的一个点:如果
root==p
或者root==q
,则root
就是所求的公共祖先,没有必要继续向下遍历了; -
代码:
-
这个实现是额外增加了dfs函数的写法,也可以直接用
lowestCommonAncestor
函数来递归,参看:剑指offer算法题01中的五、12. 变体. 二叉树的最近公共祖先;
class Solution {
private:
TreeNode *re;
// 返回true表明该子树至少含有p或者q中的一个
bool dfs(TreeNode *root, TreeNode *p, TreeNode *q) {
if(root == nullptr) {
return false;
}
bool is_left = dfs(root->left, p, q);
bool is_right = dfs(root->right, p, q);
if(root == p || root == q) {
// root是其中一个节点,则需要检查它的孩子是否有另一个true
// 但实际上其实是不需要检查了,root就是所求节点
// 因为最先遇到的是root,另一个节点必定在它的子树里面
if(is_left || is_right) {
re = root;
}
return true;
}
else {
// 否则,就要检查两个孩子是否均有一个true
if(is_left && is_right) {
re = root;
}
if(is_left || is_right) {
return true;
}
else {
return false;
}
}
}
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
dfs(root, p, q);
return re;
}
};
11. 路径总和 III [前缀和]
- 思路:
- 这道题其实是有点绕的,看题解都看了半天才悟到一点天机>﹏<;
- 由于路径必须是沿“父->子”的形式走,因此这里可以用前缀和之差来计算两两节点之间路径和;
- 前缀和定义:从
root
出发到当前节点(含当前节点)经过的所有节点的值之和; - 因此
a
和b
节点之间的路径和(含a
和b
)为:
p a t h [ a , b ] = p r e f i x ( b ) − p r e f i x ( a . p a r e n t ) path[a,b] = prefix(b) - prefix(a.parent) path[a,b]=prefix(b)−prefix(a.parent) - 其中
prefix(x)
返回节点x
的前缀和; - 上面公式的另外一种形式是:
p r e f i x ( a . p a r e n t ) = p r e f i x ( b ) − p a t h [ a , b ] prefix(a.parent) = prefix(b) - path[a,b] prefix(a.parent)=prefix(b)−path[a,b] - 也就是说,遍历到节点
b
时,如果要判断是否有某个以b
为结尾的路径满足路径和为targetSum
,则只需判断之前是否遍历过前缀和为prefix(b) - targetSum
的父节点即可; - 于是,整个算法实现如下:
- 深度优先遍历;
- 遍历到当前节点时,判断之前是否遍历过前缀和为
prefix(b) - targetSum
的父节点,若有,统计有多少个这样的父节点,个数就是符合条件的路径个数; - 将当前节点的前缀和记录下来,依次递归遍历左右子节点,因为当前节点是左右子节点的父节点;
- 递归结束后,移除当前节点的前缀和的记录,因为没有别的节点是以当前节点作为父节点的了;
- 记录节点的前缀和用
unordered_map
实现,这里只存前缀和是key
的节点有value
个; - 注意一开始要先存入
0 -> 1
的映射,也就是前缀和为0的节点有一个,这是因为prefix(a.parent)
中的a
如果是root
节点则无法计算(也就是当某个节点的前缀和恰好等于targetSum
时),因为深度优先遍历时root
之前并无记录,故可视作在root
前增加了一个前缀和为0的节点; - 代码:
/**
一个例子如下,括号中是当前节点的前缀和:
5(5)
4(9) 8(13)
11(20) 13(26) 4(17)
7(27) 2(22) 5(22) 1(18)
*/
class Solution {
private:
// 前缀和映射表,前缀和为[root to cur],含当前节点
// [first, second]表示:当前节点的前缀和为first的父节点的数量是second
// 前缀和有可能超过int的范围,故用long long类型
unordered_map<long long, int> prefix_map;
// 目标是找prefix_cur - prefix_parent == targetSum的数量
// 等价于first的目标值 = prefix_cur - targetSum
int dfs(TreeNode* root, const int targetSum, long long prefix) {
if(root == nullptr) {
return 0;
}
else {
long long prefix_cur = prefix + root->val; // 当前节点的前缀和
int re = 0;
if(prefix_map.find(prefix_cur-targetSum) != prefix_map.end()) {
re = prefix_map[prefix_cur-targetSum];
}
if(prefix_map.find(prefix_cur) == prefix_map.end()) {
prefix_map[prefix_cur] = 0;
}
++prefix_map[prefix_cur]; // 把当前节点放到父节点路径里面
re += dfs(root->left, targetSum, prefix_cur);
re += dfs(root->right, targetSum, prefix_cur);
--prefix_map[prefix_cur]; // 当前节点从父节点路径里面移除
return re;
}
}
public:
int pathSum(TreeNode* root, int targetSum) {
// first = 0表示当前路径恰好是从root出发的
// 也就是对某个节点而言,它的前缀和恰好等于targetSum
// 如果不加这个值,则所有从root出发的符合条件的路径都不计数
prefix_map[0] = 1;
return dfs(root, targetSum, 0);
}
};
补充:关于long和long long的区别
-
int
类型肯定是32位的; -
long (int)
类型由编译平台决定,可能是32位或者64位; -
long long (int)
类型肯定是64位的; -
因此,如果需要突破
int
的范围,则推荐使用long long
; -
long
一般情况下不使用,因为无法确定它可表示数的范围; -
一些编译器内部定义的别名如下:
char => int8_t
short int => int16_t
int => int32_t
long int => int32_t / int64_t
long long int => int64_t
float => float32_t
double => float64_t
12. 把二叉搜索树转换为累加树
- 思路:
- 其实二叉搜索树用右根左次序遍历就是从大到小排序,用一个全局变量
sum
记录累加即可; - 代码:
class Solution {
private:
int sum;
// 按照右根左遍历即是从大到小遍历,可以计算累加和
void dfs(TreeNode *root) {
if(root == nullptr) {
return;
}
dfs(root->right);
sum += root->val;
root->val = sum;
dfs(root->left);
}
public:
TreeNode* convertBST(TreeNode* root) {
sum = 0;
dfs(root);
return root;
}
};
- 但我一开始并没有想到用全局变量累加,而是用了局部变量的传递,所以实现起来复杂不少;
- 需要多用一个传入参数
sum_prefix
记录当前的已有的前缀和;- 如果右子树不为空,则用右子树的返回值作为前缀和;
- 如果右子树为空,则用传入的前缀和作为前缀和;
- 本节点的累加和 = 本节点值 + 前缀和;
- 如果左子树为空,则返回本节点的累加和;
- 如果左子树不为空,则返回左子树的返回值;
- 代码实现起来果然就长了不少,而且逻辑其实还是有点绕的,递归函数的设计也有点巧妙;
- 代码:
class Solution {
private:
// 返回以root为子树的所有节点值之和
int dfs(TreeNode *root, int sum_plefix) {
if(root->right != nullptr) {
// 计算右子树之和
int sum_right = dfs(root->right, sum_plefix);
// 累加和 = root->val + 右子树之和
root->val += sum_right;
}
else {
// 累加和 = root->val + 前缀和
root->val += sum_plefix;
}
if(root->left != nullptr) {
int sum_left = dfs(root->left, root->val);
return sum_left;
}
else {
return root->val;
}
}
public:
TreeNode* convertBST(TreeNode* root) {
if(root == nullptr) {
return root;
}
int sum = dfs(root, 0);
return root;
}
};
13. 二叉树的直径
- 思路:
- 某棵子树的直径就是它左右子树的高度之和;
- 但最大直径需要遍历其所有的子树,取其中的直径最大值;
- 代码:
class Solution {
private:
int re;
// 返回当前子树的最大高度
int dfs(TreeNode *root) {
if(root == nullptr) {
return 0;
}
int left_height = dfs(root->left);
int right_height = dfs(root->right);
// 计算当前子树的直径
int diameter = left_height + right_height;
if(re < diameter) {
re = diameter;
}
return max(left_height, right_height) + 1;
}
public:
int diameterOfBinaryTree(TreeNode* root) {
re = 0;
int depth = dfs(root);
return re;
}
};
14. 合并二叉树
-
思路:
-
就是同时深度优先搜索遍历两棵树;
-
可以生成一棵新的树,也可以直接在原树上修改;
-
代码:
-
生成一棵新的树的代码如下:
class Solution {
TreeNode* dfs(TreeNode *root1, TreeNode *root2) {
if(root1 == nullptr) {
return root2;
}
if(root2 == nullptr) {
return root1;
}
TreeNode *new_node = new TreeNode(root1->val + root2->val);
new_node->left = dfs(root1->left, root2->left);
new_node->right = dfs(root1->right, root2->right);
return new_node;
}
public:
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
return dfs(root1, root2);
}
};
- 在其中一棵树中修改的代码稍微复杂一点;
- 因为没有新建节点的操作,所以要对当前节点是否为空的边界条件特别处理,而且需要在父节点时就讨论子节点的情况;
- 这样讨论的逻辑好理解一点,在逻辑上不易出错,能避免被重叠树的指向被修改,但在写法上会有点繁琐;
- 实现如下:
class Solution {
// root1是被重叠的树,root2是另一颗树,且均不为空
void dfs(TreeNode *root1, TreeNode *root2) {
root1->val += root2->val;
if(root1->left == nullptr) {
if(root2->left != nullptr) {
root1->left = root2->left;
}
}
else {
if(root2->left != nullptr) {
dfs(root1->left, root2->left);
}
}
if(root1->right == nullptr) {
if(root2->right != nullptr) {
root1->right = root2->right;
}
}
else {
if(root2->right != nullptr) {
dfs(root1->right, root2->right);
}
}
}
public:
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
if(root1 == nullptr) {
return root2;
}
if(root2 == nullptr) {
return root1;
}
dfs(root1, root2);
return root1;
}
};
15. 二叉树中的最大路径和
- 思路:
- 其实没有想象中的那么难,感觉只是中等题的难度,关键的点在于设计遍历的次序;
- 设计基于的事实如下:
- 最大路径和一定经过某个节点,满足经过的路径均在该节点和它左右子树的节点里面;
- 假设当前节点就是满足上述条件的节点,那么最大路径和由三个部分组成:
- (1) 以左孩子为开始节点的最大路径和(若大于0);
- (2) 当前节点的值;
- (3) 以右孩子为开始节点的最大路径和(若大于0);
- 因此,设计的深度优先遍历函数如执行:
- (1) 计算在当前节点所在子树能够获得的最大路径和;
- (2) 返回以当前节点为开始节点的最大路径和与0之间的最大值;
- 代码:
class Solution {
/*
后序遍历:左右根
1. 在当前root计算经过当前root的最大路径和
2. 向上返回以root为起始点的最大路径和
需要考虑负数的影响
*/
private:
int re_max;
int dfs(TreeNode* root) {
if(root == nullptr) {
return 0;
}
// 1. 在当前root计算经过当前root的最大路径和
int left = dfs(root->left);
int right = dfs(root->right);
int route_sum = left + root->val + right;
if(route_sum > re_max) {
re_max = route_sum;
}
// 2. 向上返回以root为起始点的最大路径和
// 注意,若以root为起始点的最大路径和仍小于0,则舍弃
// 也就是说向上返回的最小值是0
return max(max(left, right) + root->val, 0);
}
public:
int maxPathSum(TreeNode* root) {
re_max = INT_MIN; // 初始值是整数最小值
int tmp = dfs(root);
return re_max;
}
};
16. 二叉树的序列化与反序列化
-
思路:
-
(1) 序列化:
- 遍历二叉树,然后将每个节点值拼接到
string
类型的字符串中; - 每个节点值用
,
分隔; - 空叶子节点用
NULL
字符串标识;
- 遍历二叉树,然后将每个节点值拼接到
-
(2) 反序列化:
- 先拆分
string
类型字符串,将每个字符串分开并保存到vector
数组中; - 根据数组中的节点值,遍历构建二叉树;
- 先拆分
-
遍历的方式有两种:
- (1) 深度优先遍历:
- 序列化的格式:
node1,node2,...noden
;- 末尾没有逗号,因为没法构造末尾有逗号的DFS写法;
- 序列化时:
- 深度优先遍历的顺序:
根 -> 左 -> 右
; - 每个
root
节点都要记录到字符串中,即使节点为空;
- 深度优先遍历的顺序:
- 反序列化时:
- 深度优先遍历的顺序:
根 -> 左 -> 右
; - 如果字符串值为空,则直接返回,否则赋值给
root
,并递归处理它的左右子树;
- 深度优先遍历的顺序:
- 思路好想一点,实现较为简单;
- 序列化的格式:
- (2) 广度优先遍历:
- 序列化的格式:
node1,node2,...noden,
;- 末尾有逗号,因为不能得知哪个节点是最后一个节点,所以统一加逗号;
- 而且这样反序列化的时候也更方便一点;
- 序列化时:
- 每个节点的左右节点都要记录到队列中,即使节点为空;
- 每个队列中的节点都要记录到字符串中,即使节点为空;
- 反序列化时:
- 每个节点如果不为空,则都要放到队列中等待赋值;
- 队列中的每个节点的左右节点都要按照字符串中的顺序赋值,即使字符串值为空;
- 空间复杂度优于深度优先遍历的实现;
- 序列化的格式:
- (1) 深度优先遍历:
-
特别注意:
- 如果指针并没有用
new
申请空间,则传参数的时候只能用引用来传递指针; - 不能用指针值传递,否则后续在函数内部构造的空间不会附在原来的指针上的;
- 如果指针并没有用
-
代码1:深度优先遍历实现
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Codec {
private:
void DFS1(TreeNode* root, string& s) {
if(root == nullptr) {
s += "NULL";
return;
}
s += to_string(root->val);
s += ',';
DFS1(root->left, s);
s += ',';
DFS1(root->right, s);
}
// TreeNode*&必须加引用,不然左右子树和root没有办法连接上
void DFS2(vector<string>& words, int& i, TreeNode*& root) {
if(i >= words.size()) {
return;
}
string word = words[i];
++i;
if(word == "NULL") {
return;
}
else {
root = new TreeNode(stoi(word));
DFS2(words, i, root->left);
DFS2(words, i, root->right);
}
}
public:
// Encodes a tree to a single string.
string serialize(TreeNode* root) {
string s;
DFS1(root, s);
return s;
}
// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
// 解码字符串,保存到数组中
vector<string> words;
int i = 0, j = 0;
while(j < data.length()) {
if(data[j] == ',') {
words.emplace_back(data.substr(i, j-i));
i = j+1;
}
++j;
}
words.emplace_back(data.substr(i, j-i));
TreeNode* root = nullptr;
int index = 0;
DFS2(words, index, root);
return root;
}
};
- 代码2:广度优先遍历实现
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
###
六、队列和栈
1. 有效的括号
-
思路:
-
用一个栈来进行匹配即可;
-
本来是打算用计数的方式来判断的(省点空间),结果不仅实现复杂,而且不能判断不同类型括号之间扫描的先后顺序,因此也不能判断是否合规,如
{[}]
的这个例子,所以还是老老实实地用栈做吧; -
注意,也不能用多个栈,不然不同类型括号之间还是不能判断扫描的先后顺序;
-
代码:
class Solution {
public:
bool isValid(string s) {
stack<char> stack;
for(int i=0;i<s.length();++i) {
switch(s[i]) {
case '(':
case '[':
case '{':
stack.push(s[i]);
break;
case ')':
if(!stack.empty() && stack.top() == '(') {
stack.pop();
}
else {
return false;
}
break;
case ']':
if(!stack.empty() && stack.top() == '[') {
stack.pop();
}
else {
return false;
}
break;
case '}':
if(!stack.empty() && stack.top() == '{') {
stack.pop();
}
else {
return false;
}
break;
default:
return false;
}
}
if(!stack.empty()) {
return false;
}
else {
return true;
}
}
};
[2]. 最小栈 [单调栈]
-
思路:
-
和剑指offer算法题01的3. 包含min函数的栈同题;
-
一度想用队列(滑动窗口)的那种方法来做,就是用一个双向队列:
- 适用于队列或者滑动窗口,先入先出结构;
- 维持最大值:
- 队列头是最大值,之后是单调非增;
- 压入时,如果
val
比队列尾的元素大,则逐一弹出,直到不比它大; - 弹出时,如果弹出的元素和队列头的元素相同,则将队列头的元素弹出;
- 返回最大值时,直接返回队列头元素;
- 或者维持最小值:
- 和上面同理,队列头是最小值,之后是单调非减;
- 压入时,如果
val
比队列尾的元素小,则逐一弹出,直到不比它小; - 弹出时,如果弹出的元素和队列头的元素相同,则将队列头的元素弹出;
- 返回最小值时,直接返回队列头元素;
-
但实际上不需要这么复杂,用一个栈来实现即可:
- 适用于栈,先入后出结构;
- 维持最大值:
- 栈顶是最大值,从底到顶是单调非减;
- 压入时,如果栈为空或者
val
比栈顶的元素大,则压入; - 弹出时,如果弹出的元素和栈顶的元素相同,则将栈顶的元素弹出;
- 返回最大值时,直接返回栈顶元素;
- 或者维持最小值:
- 栈顶是最小值,从底到顶是单调非增;
- 压入时,如果栈为空或者
val
比栈顶的元素小,则压入; - 弹出时,如果弹出的元素和栈顶的元素相同,则将栈顶的元素弹出;
- 返回最小值时,直接返回栈顶元素;
-
先入后出结构和先入先出结构的单调栈内的单调性刚好相反,但与栈和队列的性质对应,即:
- 辅助的栈顶元素或者辅助的队首元素就是最值;
- 余下的元素均维持为非严格单调排序,也就是说相等的元素需要入栈或队列;
- 栈顶或者队首元素和弹出的元素相同时也需要弹出;
-
也就是说,栈用栈辅助,队列用双向队列辅助,虽然双向队列本质上还是一个单调栈;
-
代码:
class MinStack {
private:
stack<int> s;
stack<int> min_s;
public:
MinStack() {
}
void push(int val) {
s.push(val);
if(min_s.empty() || val<=min_s.top()) {
min_s.push(val);
}
}
void pop() {
if(min_s.top() == s.top()) {
min_s.pop();
}
s.pop();
}
int top() {
return s.top();
}
int getMin() {
return min_s.top();
}
};
[3]. 滑动窗口最大值 [单调栈]
- 思路:
- 和剑指offer算法题01中的六、4. 滑动窗口的最大值 [单调栈] 同题;
- 还是用单调栈的思路来做,但这里要用双向队列来实现单调栈;
- 注意要等滑动窗口完全满
k
个元素后才开始记录结果,并不是从一开始就记录最大值; - 代码:
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> q; // front是最大值,其余单调非增
vector<int> re;
for(int i=0;i<nums.size();++i) {
if(q.empty()) {
q.push_back(nums[i]);
}
else {
while(!q.empty() && q.back()<nums[i]) {
// 相等的仍保留在q中
q.pop_back();
}
q.push_back(nums[i]);
}
if(i >= k-1) {
// 够滑动窗口才开始记录最大值
re.push_back(q.front());
}
if(i>=k-1 && q.front()==nums[i-(k-1)]) {
// 弹出q中即将不在滑动窗口内的元素
q.pop_front();
}
}
return re;
}
};
4. 每日温度 [单调栈]
- 还是用单调栈的思路;
- 在栈内维持一个递减的温度序列(其实是单调非增序列,当然栈内记录的是温度对应的那一天),因为如果入栈的元素大于栈顶元素,则栈顶元素对应的那天就可以找到离它最近的且温度高于它的那天(就是今天),它就可以被弹出栈,所以栈内的元素必定是递减的;
- 用单调栈的话正序遍历一次即可;
- 另外,这里也可以暴力搜索,逆序遍历,然后用一个哈希表保存温度对应的出现下标,处理今天的时候遍历一次哈希表,找大于今天温度且最近的下标即可,但时间复杂度和空间复杂度都不如单调栈;
- 代码:
class Solution {
public:
/*
单调栈:
1. 维护一个栈顶是最小值,从顶到底递增的栈
2. 记录每天的下标即可
*/
vector<int> dailyTemperatures(vector<int>& temperatures) {
stack<int> s;
vector<int> re(temperatures.size(), 0);
for(int i=0;i<temperatures.size();++i) {
if(s.empty()) {
// 栈为空
s.push(i);
}
else {
// 栈不为空
while(!s.empty() && temperatures[s.top()]<temperatures[i]) {
// 处理比今天温度低的记录
re[s.top()] = i - s.top();
s.pop();
}
s.push(i);
}
}
return re;
}
};
5. 柱状图中最大的矩形 [单调栈]
- 思路:
- 这里还是单调栈的思路,好难想到啊{{{(>_<)}}};
- 维持一个递增的单调栈(实际上是单调非减),当遇到的柱子小于栈顶的柱子时,才讨论栈顶柱子能够达到的最大矩形;
- 比较难理解的点有两个:
- 对于每个柱子,只需要考虑以它的最大高度可以构成的最大矩形,这样讨论就已经可以囊括所有的最大情况;
- 计算栈顶柱子能够达到的最大矩形时,宽度的右边界是当前遇到的柱子,左边界是栈内栈顶的下一个柱子而不是栈顶柱子,因为矩形可以向左延申,相当于是在左边和右边分别找一个低于栈顶的柱子;
- 由于计算面积时需要在左右都找一个低于计算的柱子的柱子,为了方便讨论(因为在原来的数组中肯定会有一个柱子没有左右都低于它的柱子的),需要在数组前后增加一个高度为0的柱子(也就是所谓的哨兵柱子),这样数组中的每个柱子都能够在左右找到一个高度低于它的柱子;
- 代码:
class Solution {
public:
/*
单调栈:
1. 每个柱子仅考虑以它的最大高度可以构成的最大矩形
2. 维护一个单调栈,栈顶到底单调递减
*/
int largestRectangleArea(vector<int>& heights) {
stack<int> s;
// 在前后增加高度为0的柱子作为哨兵,以减少讨论
vector<int> new_heights(heights.size() + 2, 0);
for(int i=0;i<heights.size();++i) {
new_heights[i+1] = heights[i];
}
int re_max = 0;
for(int i=0;i<new_heights.size();++i) {
if(s.empty()) {
s.push(i);
}
else {
while(!s.empty() && new_heights[s.top()]>new_heights[i]) {
int cur = s.top(); // 当前栈顶柱子,用于计算面积
s.pop();
int width = i - s.top() - 1; // 宽取栈内前一个柱的下标
int height = new_heights[cur]; // 高取当前柱的高度
// 面积 = 底 * 高
int square = width * height;
if(square > re_max) {
re_max = square;
}
}
s.push(i);
}
}
return re_max;
}
};