YifanChen's Blog

一个专注技术的新手程序员

0%

链接

39.组合总和
40.组合总和II
131.分割回文串

知识

40.组合总和II

创建一个和a数组大小相同的b数组,将其中的元素全部置为0。

1
2
vector<int> a;
vector<int> b(a.size(), 0);

131.分割回文串

substr(i, j) 会从索引 i 开始,取长度为 j 的子字符串。

void backtracking(const string& s, int startIndex)中使用const的原因:

  1. 防止修改const 关键字确保 s 字符串在 backtracking 函数中不会被修改。这是一种安全措施,可以防止函数意外地更改输入数据,从而保持数据的完整性。在处理函数参数时,尤其是在不应该或不需要修改输入的情况下,使用 const 可以提供这种保护。
  2. 接口设计:在函数原型中使用 const 声明参数可以向函数的用户清楚地表明这个参数是用来输入数据的,不应该被函数改变。这有助于提高代码的可读性和可维护性,使得其他开发者更容易理解每个函数的作用和行为。

初次尝试

39.组合总和

本题是集合里元素可以用无数次,那么和组合问题的差别,其实仅在于startIndex上的控制。本题若是想不重不漏,则下一层遍历的起始位置应该与上一层取出的数相同。而对于组合问题,下一层遍历的起始位置应该是上一层取出的数的下一个(因为组合问题中的元素不能重复使用)。据此,我写出了以下的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex)
{
if (s > target) return;

// 终止条件
if (s == target)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < candidates.size(); i ++ )
{
// 处理节点
s += candidates[i];
path.push_back(candidates[i]);
// 递归
backtracking(candidates, target, s, i);
// 回溯
s -= candidates[i];
path.pop_back();
}
return;
}

vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return res;
}
};

40.组合总和II

本题我能顺畅地写出不加去重的版本,如下所示。但对去重没有思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex)
{
// 终止条件
if (s > target) return;

if (s == target)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < candidates.size(); i ++ )
{
// 处理节点
path.push_back(candidates[i]);
s += candidates[i];
// 递归
backtracking(candidates, target, s, i + 1);
// 回溯
path.pop_back();
s -= candidates[i];
}
}

vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return res;
}
};

对以下测试样例会出现报错:

1
2
3
4
5
6
7
8
candidates =
[10,1,2,7,6,1,5]
target =
8
Output
[[1,2,5],[1,7],[1,6,1],[2,6],[2,1,5],[7,1]]
Expected
[[1,1,6],[1,2,5],[1,7],[2,6]]

很明显,上述代码是需要去重的。

131.分割回文串

拿到本题,我没有思路,因为没有做过分割问题,直接看卡尔的讲解。

实现

39.组合总和

本题与组合问题的区别:集合中的元素可以重复选取,组合中元素的数量不加限定。集合中都是正整数(若有0,则会进入死循环),且集合中没有重复的元素(这意味着不用做去重的操作)。

本题通过和来限制树的深度,而组合问题通过组合中元素的数量来限制树的深度。本题的树形结构如下所示:
Snipaste_2024-05-03_08-36-04.png

由于集合中的元素可以重复使用,因此下一层的集合中应该包括本层选取的元素。现在开始写本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
vector<vector<int>> res;
vector<int> path;

// 可以用sum表示组合的和,也可以不用sum,让target不断做减法,直到target == 0
// startIndex用于设置下一层递归的起点
void backtracking(vector<int>& candidate, int target, int sum, int startIndex)
{
// 终止条件
if (sum > target) return;
if (sum == target)
{
res.push_back(path);
return;
}

// 单层搜索的逻辑
for (int i = startIndex; i < candidate.size(); i ++ )
{
// 处理节点
path.push_back(candidate[i]);
sum += candidate[i];
// 递归,注意下一层的startIndex是从i开始,因为集合中的元素可以重复选取
backtracking(candidate, target, sum, i);
// 回溯
sum -= candidate[i];
path.pop_back();
}
}

上述代码和回溯算法的模板是类似的。本题依然可以做剪枝的操作。具体来说,是对for循环进行剪枝。对candidate数组进行排序后,若某个分支的和大于target,那么就没必要对其后面的分支进行搜索了。加入剪枝操作的完整代码如下所示(注意添加了注释的部分,就是实现剪枝的具体代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex)
{
if (s > target) return;
if (s == target)
{
res.push_back(path);
return;
}

// 剪枝操作
for (int i = startIndex; i < candidates.size() && s + candidates[i] <= target; i ++ )
{
s += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, s, i);
s -= candidates[i];
path.pop_back();
}
return;
}

vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end()); // 排序
backtracking(candidates, target, 0, 0);
return res;
}
};

剪枝操作总结:对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i],相当于把下一层组合可能的sum从小到大扫了过去)已经大于target,就可以结束本轮for循环的遍历

  • 时间复杂度: $O(n \times 2^n)$,注意这只是复杂度的上界,因为剪枝的存在,真实的时间复杂度远小于此。本题的时间复杂度分析同77. 组合。

  • 空间复杂度: $O(target)$

为何是$O(target)$:

  1. 递归栈深度: 空间复杂度首先取决于递归调用的最大深度,因为这直接影响了调用栈的大小。在组合总和问题中,你可以多次选择同一个数字,直到其和超过目标值 target 或恰好等于 target。最糟糕的情况发生在选择了最小元素直到达到 target 时,这种情况下,递归的最大深度大约是 target / min(candidates)。如果最小的候选数很小,理论上递归的深度可以接近 target
  2. 路径存储: 在递归过程中,我们还需要存储当前的组合路径(即当前选取的数字集合)。在最坏的情况下,即当所有选取的数字加起来等于 target 时,路径的长度也可以接近于 target / min(candidates)。尽管路径的具体长度依赖于候选数字的大小,但在分析空间复杂度时,我们考虑最坏情况,即多次选取最小值,使得路径长度和递归深度都接近于 target

40.组合总和II

本题差别:本题的集合中有重复的元素(之前的所有组合问题的集合中都没重复元素),不能有重复的组合。这说明我们要去重。另外,集合中的元素在组合中只能使用一次,这需要用一个变量进行控制。

一种朴素的想法:用之前的方法搜索组合,搜索出若干组合,其中肯定有重复的。用map或者set进行去重,输出去重后的所有组合。本方法实现起来较麻烦,且特别容易超时。

接下来介绍在搜索的过程中直接去重的方法:使用过的元素不重复使用。为了讲清楚本题的去重过程,卡尔自创了两个词汇:树层去重,树枝去重。去重要考虑到这两个维度。接下来画树形图,从两个维度看如何去重。去重前还需要对集合进行排序。去重需要一个数组used来告诉我们哪些元素使用过,哪些元素没用过。用过的元素的下标在used中对应的值为1,没用过的元素的下标在used中对应的值为0。
20230310000918.png

上述树除去used数组外的基本部分,还是下一层第一个取的数是上一层取的数往后挪一位(即backtracking(candidates, target, s, i + 1))。这样的目的是避免重复。对于树枝(树往深度方向走),是可以重复取值的,因为取的是一个集合中不同位置的数值相同的元素。对于树层(同一层树往横向走),不可以重复取值,必然会与之前的某个组合重复。对集合排序的目的就是将值相邻的元素放在一起,若同一层的两个分支的值相同,那么靠左边的分支会包含靠右边的分支的所有情况。因此去重的关键在于树层去重。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
vector<int> path;
vector<vector<int>> res;

// 本代码的重点在于树层去重的过程
// used数组用于标记某个元素是否使用过,用过1,没用过0
// 调用本函数前需要对集合做排序,目的是让值相同的元素在位置上相邻,方便做树层去重
void backtracking(vector<int> nums, int targetSum, int sum, int startIndex, vector<int> used)
{
// 终止条件
if (sum > targetSum) return;
if (sum == targetSum)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
// for循环是在同一层遍历各个节点,因此接下来就要写树层去重的逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 树层去重, i > 0的目的是让i - 1 >= 0,也可以写作i > startIndex
// used[i - 1] == 0对应于上面树的情况,就是第1个1没用,直接用了第2个1,此时重复读取,需要树层去重
// 若nums[i] == nums[i - 1] && used[i - 1] == 1,则说明是树枝的状态,由于不需要树枝去重,所以此时不需要去重
// 后续在回溯算法中遇到去重问题并使用used数组时,基本都是这种写法
if (i > startIndex && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 收集元素
path.push_back(nums[i]);
sum += nums[i];
used[i] = 1;
// 递归
backtracking(nums, targetSum, sum, i + 1, used);
// 回溯
path.pop_back();
sum -= nums[i];
used[i] = 0;
}
}

可以用used数组进行去重,也可以用startIndex进行去重,这里不再深入讲解。用startIndex去重比较抽象,因此理解用used数组去重即可,更易于理解且通用。本题的关键在于理解去重的思路。

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& candidates, int target, int s, int startIndex, vector<int> used)
{
// 终止条件
if (s > target) return;
if (s == target)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < candidates.size(); i ++ )
{
// 树层去重
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) continue;
// 处理节点
path.push_back(candidates[i]);
used[i] = 1;
s += candidates[i];

// 递归
backtracking(candidates, target, s, i + 1, used);

// 回溯
path.pop_back();
used[i] = 0;
s -= candidates[i];
}
}

vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
// used数组用于标记candidates数组中的元素是否使用过,因此used数组大小应该与candidates数组大小保持相同
vector<int> used(candidates.size(), 0);
sort(candidates.begin(), candidates.end()); // 别忘记排序
backtracking(candidates, target, 0, 0, used);
return res;
}
};

时间复杂度: $O(n \times 2^n)$。同77.组合和39.组合总和。
空间复杂度:$O(n)$。原因:树的最大深度为n(同candidates数组的长度)。

131.分割回文串

aab。有两种分割方案:aa|b和a|a|b。本题要求我们返回所有的分割方案。如何使用回溯算法解决这个问题?

分割问题和组合问题非常相似。例如abcdef,对组合问题,如果选择了a,则在bcdef中选择下一个字母;如果选择了b,则在cdef中选择下一个字母。同理,对于分割问题,如果分割了a,则接下来分割bcdef。再分割b,则接下来分割cdef。接下来画分割问题的树形结构。

131.分割回文串.jpg

切割线到了字符串的末尾,则切割完毕。结果都在叶子节点。画树形结构较为简单,具体的代码实现中有几个难点,现在开始写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
vector<string> path;
vector<vector<string>> res;

// 注意传入的变量类型是const string,再加上引用
// startIndex控制下一次切割的位置
void backtracking(const string& s, int startIndex)
{
// 终止条件
// 切割线到字符串的末尾,则终止
// 切割线用startIndex表示
if (startIndex >= s.size())
{
// 将判断是否是回文的逻辑放入单层搜索的逻辑中
// 因此终止条件中的path都是符合回文条件的
res.push_back(path);
return;
}

// 单层搜索的逻辑
for (int i = startIndex; i < s.size(); i ++ )
{
// 如何用代码表示切割出的子串
// 切割的子串:[startIndex, i],左闭右闭的区间
// 用于判断是否回文的函数
if (isPalindrome(s, startIndex, i)) // 传入字符串,子串的起始位置,子串的终止位置
{
path.push_back(子串); // 是回文,则将子串放入path中
}
else continue;
// 递归
backtracking(s, i + 1); // 下一层切割点从上一层切割点的下个位置开始,否则会重复
// 回溯
path.pop_back();
}
}

isPalindrome函数用双指针算法可以轻松实现。注意本题的两个细节:

  • startIndex是切割线

  • 如何表示子串的范围:[startIndex, i]

完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Solution {
public:
vector<string> path;
vector<vector<string>> res;

// 判断[start, end]是否是回文子串
bool isPalindrome(const string& s, int start, int end)
{
for (int i = start, j = end; i <= j; i ++ , j -- )
if (s[i] != s[j])
return false;
return true;
}

void backtracking(const string& s, int startIndex)
{
// 终止条件
if (startIndex >= s.size())
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i < s.size(); i ++ )
{
// 是回文子串,则将其加入path中
if (isPalindrome(s, startIndex, i))
path.push_back(s.substr(startIndex, i - startIndex + 1));
else continue;

// 递归
backtracking(s, i + 1);

// 回溯
path.pop_back();
}
}

vector<vector<string>> partition(string s) {
backtracking(s, 0);
return res;
}
};

  • 时间复杂度: $O(n \times 2^n)$,时间复杂度同77.组合、39.组合总和、40.组合总和II。

  • 空间复杂度: $O(n^2)$,原因解释如下:

    1. 递归栈的空间:最深的递归发生在当字符串每个字符都被分割时,因此递归深度最大为$n$(其中$n$是字符串的长度)。每一层递归需要保存当前索引和路径,这些额外的空间可以认为是常数级别的。
    2. 路径存储空间 (pathres):
      • path 变量在最坏情况下(每个字符都独立成一个回文串时)会存储$n$个元素。
      • res 变量存储的是所有可能的分割方案。在极端情况下,如输入字符串完全由相同字符组成(例如 “aaaa”),分割方案的数量和其中每个方案的长度都可能接近$n$。但通常来说,我们只计算这个变量直接占用的空间,即指针或引用的空间,这通常也是$O(n^2)$,因为每个回文分割的保存都可能需要一个长度为 的$n$字符串的复制。
    3. 辅助空间
      • 检查回文所用的额外空间是常量级的,不随输入大小变化。

    将以上所有考虑结合,整个算法的空间复杂度主要由存储所有分割方案的数组 res 决定。由于每个分割方案可能包含多个字符串,而每个字符串又可能需要$O(n)$的空间,因此在最坏情况下,这部分的空间复杂度为$O(n⋅k)$,其中 $k$是分割方案的数量,这在极端情况下可以达到$O(n^2)$。

心得与备忘录

39.组合总和

  1. 本题通过target来限制树的深度,而77. 组合通过组合中元素的个数来限制树的深度。
  2. 本题是集合里元素可以用无数次,那么和组合问题的差别,其实仅在于startIndex上的控制。本题若是想不重不漏,则下一层遍历的起始位置应该与上一层取出的数相同。而对于组合问题,下一层遍历的起始位置应该是上一层取出的数的下一个(因为组合问题中的元素不能重复使用)。
  3. 本题的时间复杂度:$O(n \times 2^n)$,空间复杂度:$O(target)$。
  4. 本题可以进行剪枝操作。具体来说,是对for循环进行剪枝。对candidate数组进行排序后,若某个分支的和大于target,那么就没必要对其后面的分支进行搜索了。体现在代码上,就是对总集合排序之后,如果下一层的sum(就是本层的sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。本题的剪枝不好想,要多加注意

40.组合总和II

  1. 本题的难点:集合有重复元素,但组合不能重复。

  2. 本题需要对组合去重,但不能在搜索完整棵树后用哈希法去重,容易超时。需要在搜索的过程中去重,这需要用到used数组。其中用过的元素标记为1,没用过的元素标记为0。

  3. 去重:只需要树层去重(树的同一层若两元素值相同,则右侧的值所在的路径必然被包含在左侧的值所在的路径中),不需要树枝去重(集合中的元素值可以相同,每个元素均可以使用一次,因此不需要对树枝去重)。

  4. 本题不可忽视的几个细节:

    • 集合需要进行排序,这是为了将值相同的元素放在集合中相邻的位置,便于树层去重

    • used数组的大小需要与candidates数组保持相同,因为其是用来标记candidates数组中元素的使用情况的

    • 注意树层去重的代码的写法,建议结合实际例子(实现中的图片)进行理解

      1
      2
      // 树层去重
      if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) continue;
      • candidates[i] == candidates[i - 1] && used[i - 1] == 0说明同一树层上相邻的两个元素相同,此时需要进行树层去重
      • used[i - 1] == 1,说明同一树枝上的candidates[i - 1]被使用过(同一树枝从上往下遍历,未进行回溯,因此candidates[i - 1]始终被标记为被使用过,即used[i - 1] = 1
      • used[i - 1] == 0,说明同一树层上的candidates[i - 1]被使用过(同一树层从左往右经历过回溯的过程:先对candidates[i - 1]所在的树枝从上往下遍历,然后回溯,再对candidates[i]所在的树枝从上往下遍历。在回溯的过程中,candidates[i - 1]被重新标记为未被使用过,即used[i - 1] = 0
  5. 本题的去重代码不好写,同时细节较多需要注意。因此本题容易写错,需要时常复习。

  6. 后续在回溯算法中遇到去重问题并使用used数组时,基本都是这种写法:if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == 0) continue;

  7. 本题也可以用startIndex进行去重,但比较难理解,因此不要求掌握。

  8. 本题可以像39.组合总和一样进行剪枝操作,只需要在for循环中对i加上限制条件:s + candidates[i] <= target即可。

131.分割回文串

  1. 首先,切割问题其实本质和组合问题是相同的。

    组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…..。接着选取一个b后,再从cdef中再去选取第二个,以此类推。

    切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…..。接着从b那里切下去,在cded中再去切割第二段,以此类推。

    可以观察本题的树形结构图,能够更加直观地理解切割问题和组合问题的相似。

  2. 什么是切割线?

    递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。

  3. 终止条件:切割线startIndex移动到了字符串的末尾,即startIndex >= s.size()

  4. 如何截取子串?[startIndex, i]之间的字符串就是子串。用substr函数截取即可。需要判断子串是否是回文串,是则放入path中,不是则continue

  5. 使用最简单的双指针算法即可写出判断字符串是否是回文串的函数。

  6. 本题的空间复杂度$O(n^2)$。是极端情况下的空间复杂度,原因参见本题的实现部分。

  7. 从主函数传入的参数,在定义其他函数时若需要这个参数,则需要将其设置为const类型。目的是防止其他函数对这个参数的修改,同时向函数的用户清楚地表明这个参数是用来输入数据的。不加const不影响代码的正常运行,但加了const后代码更加规范。

链接

216.组合总和III
17.电话号码的字母组合

初次尝试

216.组合总和III

针对本题,我沿用上题77. 组合的代码,只是在终止条件中添加了条件:sum(path) == n,并在单层搜索逻辑中将终止条件改为i = 9,即可解决本题。据此,我独立写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

int sum(vector<int> path)
{
int s = 0;
for (int i = 0; i < path.size(); i ++ )
s += path[i];
return s;
}

void backtracking(int k, int n, int startIndex)
{
// 终止条件
if (path.size() == k && sum(path) == n)
{
res.push_back(path);
return;
}

// 单层搜索逻辑
for (int i = startIndex; i <= 9; i ++ )
{
// 处理节点
path.push_back(i);
// 递归
backtracking(k, n, i + 1);
// 回溯
path.pop_back();
}
return;
}

vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1);
return res;
}
};

本题应该也是可以进行剪枝优化的。首先的要求当然还是k个数,因此i最大只能取到9 - k + path.size() + 1,即10 - k + path.size()。但这样会导致TLE(超时),原因尚不清楚。直接看卡尔的讲解。

17.电话号码的字母组合

本题应该还是属于组合问题的范畴。先尝试画出本题的树形结构。相当于每个数字对应三个字母,第一个数字对应的三个字母和第二个数组对应的三个字母间进行组合。若有n个数字,则有3n个字母,放入一个string a中,第一个字母从a[0]-a[2]中取,第二个字母从a[3]-a[5]中取,以此类推。相当于依然是一个组合问题,只不过每一层递归for循环的开始和结束是不固定的,需要用上述规则进行更新。据此,我尝试独立写出本题的代码框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<string> res;
string path;
string all;

// 将digits按照数字和字母间的对应关系转换为all字符串
void transfer(string digits)
{
for (char c: digits)
{
if (c == '2') all += "abc";
else if (c == '3') all += "def";
else if (c == '4') all += "ghi";
else if (c == '5') all += "jkl";
else if (c == '6') all += "mno";
else if (c == '7') all += "pqrs";
else if (c == '8') all += "tuv";
else all += "wxyz";
}
}

void backtracking(string digits, int startIndex, int endIndex)
{
// 终止条件
if (path.size() == digits.size()) res.push_back(path);

// 单层递归逻辑
for (int i = startIndex; i <= endIndex; i ++ )
{
// 处理节点
path += all.substr(startIndex, endIndex);
// 递归
backtracking(digits, startIndex, endIndex);
// 回溯
path -= all.substr(startIndex, endIndex);
}
}

vector<string> letterCombinations(string digits) {

}
};

遇到的难点:startIndexendIndex不好确定,因为部分数字不止对应三个字母。直接看卡尔的讲解。果然涉及字符串的题目都不好做啊。

实现

216.组合总和III

[1, 9]。和为n,个数为k的所有组合。本题和上题77. 组合的区别:限制和为n,集合是固定的(1-9),因此相当于在77. 组合的基础上加了一个和的限制。组合不强调元素间的顺序,排列强调元素间的顺序。暴力做法,当k=2时,两层for循环遍历1-9,找到两个相加等于n的数。暴力的想法代码没法写,所有要用回溯算法。回溯算法也是暴力的方式,只不过回溯算法通过递归的方式帮助我们控制for循环的嵌套层数,递归n层即相当于模拟了n层嵌套的for循环。

本题的树形结构如下所示:
Snipaste_2024-05-02_03-55-06.png

树的深度为k,树的宽度是当前层的集合中的元素的个数。现在来写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
vector<int> path;
vector<vector<int>> res;
int sum;

// sum为当前路径已有的和, 将其与targetSum(即n)做一个比较,相等即符合题目的要求
// 本题的startIndex用途同77. 组合中的startIndex,初始值为1
void backtracking(int targetSum, int k, int sum, int startIndex)
{
// 终止条件
if (path.size() == k)
if (targetSum == sum)
res.push_back(path);

// 单层搜索的逻辑
for (int i = startIndex; i <= 9; i ++ )
{
// 处理节点
sum += i;
path.push_back(i);
// 递归
backtracking(targetSum, k, sum, i + 1);
// 回溯
sum -= i;
path.pop_back();
}
}

完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(int k, int n, int startIndex, int sum)
{
// 终止条件
if (path.size() == k && sum == n)
res.push_back(path);

// 单层搜索逻辑
for (int i = startIndex; i <= 9; i ++ )
{
// 处理节点
sum += i;
path.push_back(i);
// 递归
backtracking(k, n, i + 1, sum);
// 回溯
sum -= i;
path.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return res;
}
};

接下来对上述代码进行剪枝优化。第一个剪枝在于满足targetSum的要求。剪枝代码放在终止条件之前:

1
2
if (sum > targetSum)
return;

还有一个剪枝(满足集合中元素个数的要求),和77.组合中的剪枝是相同的。当前组合中有path.size()个元素,还需要k - path.size()个元素,因此i的最大起始位置为9 - (k - path.size()) + 1。因此:

1
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ )

加上完整的剪枝优化后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(int k, int n, int startIndex, int sum)
{
// 剪枝操作1
if (sum > n) return;

// 终止条件
if (path.size() == k && sum == n)
res.push_back(path);

// 单层搜索逻辑
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ ) // 剪枝操作2
{
// 处理节点
sum += i;
// if (sum > n) return;也可以放在此处
path.push_back(i);
// 递归
backtracking(k, n, i + 1, sum);
// 回溯
sum -= i;
path.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return res;
}
};

我发现,若仅仅进行剪枝操作2,但不进行剪枝操作1,程序就会报错:TLE。

我还发现,尽管if (sum > n) return;放在sum += i之后,程序可以通过测评。但正统的写法应当为在剪枝前,先把回溯给做了,否则可能会漏掉满足要求的组合(程序没有进行回溯,就试图去寻找新的满足要求的组合了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 当然这个剪枝也可以放在调用递归之前,只不过要记得把回溯操作给做了
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
// 处理节点
sum += i;
path.push_back(i);
if (sum > targetSum) { // 剪枝操作
sum -= i; // 剪枝之前先把回溯做了
path.pop_back(); // 剪枝之前先把回溯做了
return;
}
// 递归
backtracking(targetSum, k, sum, i + 1);
// 回溯
sum -= i;
path.pop_back();
}

本题的时间和空间复杂度同77.组合的时空复杂度。

17.电话号码的字母组合

电话拨号盘,每个数字代表一个字符串。首先需要做映射。将输入的字符串(一串数字)映射为对应的字符串。可以用map或者二维数组做映射,这里使用二维数组。数组中的每个元素是字符串。

1
2
3
4
5
6
7
8
9
10
11
12
string letterMap[10] = {
" ", // 0
" ", // 1
abc, // 2
def, // 3
ghi, // 4
jkl, // 5
mno, // 6
pqrs, // 7
tuv, // 8
wxyz, // 9
}

这样拿到digits中的数字,将其作为下标放入字符串,即可得到数字对应的字符串(举例:letterMap[2] = "abc")。

暴力做法:输入两个数字,则要进行两重for循环。输入n个数字,则要进行n重for循环。此时想到用回溯算法进行暴力求解。回溯算法可通过递归的方式实现对for循环的嵌套。以输入2,3为例,尝试画出本题的树形结构:
Snipaste_2024-05-02_22-19-15.png

结果就在树形结构的叶子节点中。树的深度是输入数字的个数,树的宽度由每一个数字对应的字符串的长度控制。现在尝试写本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
string s; // 用于存储单个结果
vector<string> res; // 收获结果集

// index用于标识传入的字符串digits在当前递归中遍历到哪一个字符(实际上是数字)了
// startIndex一般用于一个集合中求组合,避免得到重复的组合
// 本题是在多个集合中各取一个元素出来做组合,因此不需要startIndex来帮助控制集合中之前遍历过哪些元素
void backtracking(string digits, int index)
{
// 终止条件
// index指向digits的最后一位的下一位,才终止。若index指向digits的最后一位,其后应该还有处理最后一位的逻辑
if (index == digits.size())
{
res.push_back(s); // 收获结果
return;
}

// 单层搜索逻辑
// 取出digits中的数字
int digit = digits[index] - '0'; // 字符转换为数字
// 找出digit对应的字符串
string letter = letterMap[digit];
// 遍历digit对应的字符串
for (int i = 0; i < letter.size(); i ++ )
{
// 处理节点
s.push_back(letter[i]);
// 下一层递归,index后移一位
backtracking(digits, index + 1);
// 回溯
s.pop_back();
}
}

本题看似复杂,但画图理解后逻辑清晰,代码也不长。

可以将代码写得更简洁,递归函数传入三个参数:void backtracking(string digits, int index, string s),然后单层搜索逻辑的三行代码写成一行:backtracking(digits, index + 1, s + letter[i]);s的值本身并没有改变,这就是将回溯的过程隐藏在参数中了。

本题完整可运行的程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Solution {
public:
string s; // 存储单个组合
vector<string> res; // 结果集

// 存储数字和字符串之间的映射关系
vector<string> letterMap = {
" ", // 0
" ", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz" // 9
};

// index用于标记当前层遍历到了digits中的哪个位置
void backtracking(string digits, int index)
{
// 终止条件
if (index == digits.size())
{
res.push_back(s);
return; // 这个return不能去掉,否则程序会报错
}

// 单层递归逻辑
int digit = digits[index] - '0'; // 取出当前层的数字
string letter = letterMap[digit]; // 取出当前层需要遍历的字符串
for (int i = 0; i < letter.size(); i ++ )
{
// 处理节点
s.push_back(letter[i]);
// 向下一层递归
backtracking(digits, index + 1);
// 回溯
s.pop_back();
}
return; // 这个return可要可不要,但为了和回溯法模板保持一致,因此还是加上
}

vector<string> letterCombinations(string digits) {
if (digits.empty()) return res; // 必须加上这句话,特判digits为空的情况
backtracking(digits, 0);
return res;
}
};

简化后的写法(隐藏回溯逻辑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
string s;
vector<string> res;

vector<string> all = {
"",
"",
"abc",
"def",
"ghi",
"jkl",
"mno",
"pqrs",
"tuv",
"wxyz"
};

void backtracking(string digits, int index, string s)
{
if (index == digits.size())
{
res.push_back(s);
return;
}

// 单层搜索逻辑
int digit = digits[index] - '0';
string letter = all[digit];
for (int i = 0; i < letter.size(); i ++ )
backtracking(digits, index + 1, s + letter[i]);
}

vector<string> letterCombinations(string digits) {
if (digits.empty()) return res;
backtracking(digits, 0, s);
return res;
}
};

时间复杂度分析

对于每个按键,电话按键可能对应不同数量的字母:

  • 按键 2, 3, 4, 5, 6, 8 每个都对应 3 个字母。
  • 按键 7 和 9 对应 4 个字母。

如果输入的字符串中有 m 个按键对应 4 个字母,n 个按键对应 3 个字母,那么所有可能的组合数量是 4^m * 3^n。因为这是回溯算法的常见分析模式,每一步选择会进入下一层递归,直到达到输入字符串的长度。在每一层递归中,根据当前按键可能的字母数量,我们有不同的选择分支。

因此,整个算法需要考虑的总路径数或调用次数是 O(4^m * 3^n)

空间复杂度分析

空间复杂度主要由两部分构成:

  1. 递归调用栈:最大深度为输入字符串的长度,即 m + n。然而,这通常认为是 O(m+n),不是主要的空间消耗部分。

  2. 输出存储空间:存储所有可能组合的空间,这是算法的主要空间消耗。每个组合都是一个新的字符串,因此需要的总空间是与生成的组合数量相同,即 O(4^m * 3^n)

如果空间复杂度中不计入输出存储空间,则空间复杂度是O(m+n)。若计入,则是O(4^m * 3^n)。

心得与备忘录

216.组合总和III

  1. 本题的思路和77.组合的完全相同,只不过加了限制条件:组合中所有元素之和为n。
  2. 本题有两种写法。第一种是我在初次尝试中的写法,最大限度地沿用了77.组合的代码,只不过另外实现了一个sum函数来统计path数组中所有元素之和,并在终止条件中与n进行比较。这种写法存在一个巨大的缺陷,就是无法进行剪枝。因为只有实现了剪枝操作1:if (sum > n) return;后,才能实现剪枝操作2:for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ ),而写法1的递归函数的参数中没有sum,因此剪枝操作1必然无法实现,这导致剪枝操作2也无法实现(强行添加剪枝操作2,程序直接报错TLE(超时))。
  3. 本题的第二种写法更为正统,递归函数传入的参数中包含了sum,即当前path数组中元素之和。需要特别注意的是,处理节点的过程和回溯过程是一一对应的,sum在处理有加,在回溯就要有减。
  4. 基于本题的第二种写法,可以对代码进行两种剪枝操作。剪枝操作1:if (sum > n) return;。该操作可以放在终止条件之前,也可以放在单层搜索逻辑中处理节点时对sum的计算之后(具体细节详见实现部分,建议不要纠结这里的细节)。剪枝操作2:for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i ++ )。这和77.组合中的剪枝操作完全相同。
  5. 只有实现了剪枝操作1后,才能实现剪枝操作2。若单独实现剪枝操作2,会导致程序超时(TLE)。

17.电话号码的字母组合

  1. 本题的两大创新之处indexletterMap。前者用于表示遍历digits遍历到了哪一位,后者用于表示数字和字符串之间的映射关系。

  2. 画出树形结构对于解决回溯法问题的帮助:确定树形结构的宽度,可以确定单层搜索逻辑中的for循环怎么写;确定树形结构的深度,可以确定单层搜索逻辑中的递归部分怎么写。

    Snipaste_2024-05-02_22-19-15.png

    在本题中,树的当前层中的各个节点是letters中的各个元素:

    1
    2
    int digit = digits[index] - '0';
    string letters = letterMap[digit];

    树的深度是digits.size(),可通过index + 1不断向树的下一层递归。

  3. 如果输入的字符串中有 m 个按键对应 4 个字母,n 个按键对应 3 个字母,则本题的时间复杂度和空间复杂度都是$O(4^m \times 3^n)$。

  4. 本题看题意较为麻烦,但如果能画出树形结构,同时学会使用vector<string>来存储数字和字符串之间的映射关系,然后通过index来取出特定数字对应的字符串,就可以写出简明而清晰的代码。

  5. 写本题代码时,最好参照回溯法的模板代码,不要省略return,否则可能导致报错。另外,在主函数中要特判digits为空的情况。

链接

理论基础
77. 组合

知识

理论基础

什么是回溯法

回溯算法比较抽象,因此较难。

回溯和递归是相辅相成的,有递归就会有回溯。回溯的逻辑一般隐藏在递归函数的下面。二叉树在递归的过程中会有回溯的操作,只不过有的题目显式地用到了回溯,有的题目没有显式地用回溯。回溯函数一般指递归函数,因为没有一个函数可以完全用来回溯。

回溯法的效率:回溯法是纯暴力的搜索,并不是一个高效的算法。如果想让回溯法高效一些,可以加一些剪枝的操作。部分题能够用暴搜做出来就不错了,用层层嵌套的for循环都根本搜不出来,要依靠回溯法才能把所有结果搜出来。

使用原因以及解决的问题

回溯法能解决的问题:

  • 组合问题。比如给定集合1234,找出长度为2的组合。
  • 排列问题。排列强调元素的顺序,而组合不强调元素的顺序。比如集合12,求其组合,只有12一种组合;但若求其排列,有12/21两种排列。
  • 子集问题。比如给定集合1234,问该集合有多少子集。
  • 切割问题。比如给一个字符串,问有几种切割方式。或者加一些特定的条件,比如给一个字符串,如何切割才能保证它的子串都是回文子串,问有几种切割方式。
  • 棋盘问题。比如N皇后、解数独。

如何理解回溯法

回溯法特别抽象。想要清晰地了解回溯法,最好将其抽象为一个图形结构所有的回溯法都可以抽象为一个树形结构,确切的说是N叉树。原因:回溯就是递归的过程,递归有终止。N叉树的宽度是在回溯法中处理的集合的大小,一般用for循环遍历。树的深度是递归的深度,因为递归必有终止,递归会层层向上返回。后序讲解具体问题时,都会将具体问题抽象为对应的树形结构,方便大家理解。

回溯模板

一般来说,回溯法的递归函数都是没有返回值的,即void。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// backtracking为习惯的起名
// 回溯法的参数一般较多,一开始时一般无法确定所有参数,写具体逻辑时遇到想用的参数,再添加参数即可
void backtracking(参数)
{
// 终止条件。到终止条件时,一般来说就可以收集结果了
// 除去子集问题和部分棋盘问题,我们一般在叶子节点收集结果
// 对于子集问题,则需要在每个节点处收集结果
if (终止条件)
{
(叶子节点处)收集结果 // 例如将子集12从数组中放入结果集中
return;
}

// 单层搜索的逻辑
// for循环用于遍历本层集合中的每个元素,对应一个节点的所有子节点
for (集合元素)
{
处理节点 // 例如将子集12放进数组中
递归函数 // 树形结构往深处走
回溯操作 // 撤销对节点的处理,例如从子集12中弹出2,再加入3,才能得到新的子集13。同理,还需要撤销3加入4,得到14的组合。没有回溯操作,就会一直加入新的元素,就不会求出所有的组合情况。
}
return;
}

每道回溯法的题目都有各自的特点,但最终的代码都离不开上述模板的风格。

总结

后序会用本期视频讲解的理论知识求解具体问题,就可以加深对本期视频讲解的理论知识的理解。

初次尝试

77. 组合

根据回溯模板,我写下了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
vector<vector<int>> res; // 结果集
vector<int> tmp; // 暂时存储数据

void backtracking(int n, int k)
{
// 终止条件
if ()
{
res.push_back(tmp);
return;
}

// 单层递归逻辑
for (int i = 0; i < n; i ++ )
{
// 处理节点
tmp.push_back(i);
k -- ;
// 递归函数
backtracking(n, k);
// 回溯操作
k ++ ;
}
return;
}

vector<vector<int>> combine(int n, int k)
{
backtracking(n, k);
return res;
}
};

尽管和最终版本的代码已经非常接近,但是我不知道怎么写终止条件,也无法确定传入的参数是否齐全。先来看卡尔的讲解。

实现

77. 组合

组合中的元素是无序的。给定集合1234,找出所有大小为2的组合。有12,13,14;23,24;34。暴力做法:两层for循环。

1
2
3
for (int i = 0; i < size; i ++ )
for (int j = i + 1; j < size; j ++ )
cout << i << ' ' << j << endl;

两层for循环即可求解k=2下的所有组合。三层for循环即可求解k=3下的所有组合。若k很大,则要嵌套很多层for循环。因此直接用for循环是无法解决本题的,就需要用到回溯算法。

回溯算法也是一个纯暴力的方式,模拟的也是嵌套for循环的过程。回溯算法是利用递归来控制有多少层for循环。递归里的每一层都是一个for循环。嵌套n层for循环即相当于递归n层

所有的回溯法都可以抽象为一个树形结构。以本题为例,画回溯算法搜索的过程。

Snipaste_2024-05-01_19-07-25.png

上述树形结构的叶子节点就是要求的所有组合。第一次取数是从集合中依次取,第二次取数是取第一次取的数的后面一个数,这样才能保证不重不漏。通过传入参数startIndex来实现这点,即控制每次搜索的起始位置。

二叉树的题目有递归三部曲,回溯算法也有回溯三部曲(回溯函数即递归函数,二者不作区分):

  • 递归函数的参数和返回值,返回值一般为void
  • 确定递归的终止条件
  • 单层搜索(递归)的逻辑

根据回溯三部曲对着上述图示写组合问题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 一个组合存放在一个一维数组中,名为path
// 还需要一个二维数组res,将所有集合放在一起,作为结果集返回
// 上述两个数组可以作为加引用的参数,也可以作为全局变量。但参数不宜过多,会影响代码的可读性,因此将它们放入全局变量中
vector<int> path;
vector<vector<int>> res;

// n是集合中数的个数,k是组合的大小
// startIndex的作用:本层递归结束后,下一层递归如何知道从哪个数开始取,就要用到startIndex
// startIndex是本层递归搜索的起始位置,初始时为1,即从1开始搜索
void backtracking(int n, int k, int startIndex)
{
// 终止条件,到叶子节点时,收集结果并返回
// path的大小为k,说明找到了大小为k的组合,将之放入结果集并返回
if (path.size() == k)
{
res.push_back(path); // 收集结果
return;
}

// 单层搜索的逻辑
// 每层都是一个for循环,起始点从startIndex开始
for (int i = startIndex, i <= n; i ++ )
{
// 处理节点
path.push_back(i);
// 递归过程, i + 1才能让下一层递归从下一个节点开始搜索
backtracking(n, k, i + 1);
// 回溯过程。不可以一直往里加,得到一个结果后需要弹出旧的元素
path.pop_back();
}
}

时间复杂度:$O(n \times 2^n)$
空间复杂度:$O(n)$
原因:

  • 对时间复杂度

    • 解空间的大小

      对于组合问题,如生成一个集合的所有子集,解空间包含了该集合的所有可能子集。对于包含$n$个元素的集合,其子集总数是$2^n$。这是因为每个元素在每个子集中都有出现或不出现两种可能,因此,子集的总数是$2^n$。

    • 每个解的生成时间

      虽然生成每个子集看起来很快,但是实际上,为了构建每个子集,可能需要遍历所有元素来决定每个元素是否包含在当前子集中,这需要$O(n)$的时间。因此,对于所有子集,生成时间是每个子集的生成时间与子集总数的乘积,即$O(n \times 2^n)$。

  • 对空间复杂度
    在讨论空间复杂度时,我们通常关注的是算法在执行过程中需要额外分配的空间量。对于回溯算法来说,主要的空间消耗源于两部分:递归调用的栈空间和用于存储当前解的路径(在此例中为 path)。以下是详细分析:

    • 递归栈空间
      在回溯算法中,递归的深度决定了栈空间的使用量。在这个特定的问题中(从 n 中选择 k 个数的组合),递归的最大深度是 k,因为每一层递归对应于选择一个元素,直到选择了 k 个元素。因此,递归栈的空间复杂度是 O(k)。

    • 存储当前解的路径空间
      path 变量用于存储当前的部分解,即已选择的元素集合。因为一次最多选择 k 个元素,所以 path 的最大长度也是 k。因此,存储 path 所需的空间也是 O(k)。

    • 结果集 result
      虽然result用来存储所有可能的组合,其大小可以达到组合数$C(n, k)$,但在分析空间复杂度时,我们通常不把输出空间计算在内,因为这部分空间是用来存储算法的最终结果,而非算法执行过程中的临时数据。如果包括result的空间,空间复杂度确实是$O(C(n, k))$,但这不是额外的空间,而是算法结果的必要空间。

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
vector<vector<int>> res; // 结果集
vector<int> path; // 存放一个集合

void backtracking(int n, int k, int startIndex)
{
// 终止条件
if (path.size() == k)
{
res.push_back(path); // 收集结果
return;
}

// 单层搜索逻辑
for (int i = startIndex; i <= n; i ++ )
{
// 处理节点
path.push_back(i);
// 递归函数,下一层搜索从i + 1开始
backtracking(n, k, i + 1);
// 回溯操作
path.pop_back();
}
return;
}

vector<vector<int>> combine(int n, int k)
{
backtracking(n, k, 1);
return res;
}
};

本题的代码并不复杂,基本是按照回溯法的模板来的。本题还可以做剪枝。回溯法是一个纯暴力的算法,想优化就要做剪枝。接着详细讲如何做剪枝,以及回溯算法常见的剪枝套路

组合问题的剪枝操作

回溯算法是一种纯暴力的算法,优化就是做剪枝,但也改变不了其暴力算法的本质。如何做剪枝操作?

以n=4, k = 4为例。

Snipaste_2024-05-01_21-14-10.png

对取2,取3和取4的分支,可以做剪枝。若不做剪枝,回溯算法会搜索整个树形结构,因此剪枝的效果非常明显。剪枝的代码该怎么写?

1
2
3
4
5
6
7
8
9
// 剪枝操作在单层搜索逻辑中
// for循环的逻辑在当前层的节点上,i对应于节点的每一个孩子,即for循环遍历了当前节点的所有孩子
// 要对节点的子孩子进行剪枝,因此对for循环的范围里进行优化即可
for (int i = startIndex; i <= n; i ++ )
{
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 沿着当前分支继续往下搜索
path.pop_back(); // 回溯操作
}

现在的具体问题是:如何缩小i的范围?
总共要选取k个元素,当前选取了path.size()个元素,还剩下k - path.size()个元素有待选取。因此i至多从n - (k - path.size()) + 1开始。加1的原因是起始位置包括了startIndex。也可以举具体的例子。以n=4, k=3, path.size()=0为例,i至多从2开始枚举(这意味着i可以从1开始枚举,也可以从2开始枚举,但若从3开始枚举,则必然搜索不到大小为3的组合)。因此对上述代码修改为:

1
for (int i = startIndex; i <= n - (k - path.size()) + 1; i ++ )

这样可以确保在搜索的范围中可以找到大小为k的组合。剪枝的操作改动本处即可,代码其他地方不需要做改动。

上述剪枝操作在回溯算法的剪枝操作中特别常见。大部分回溯算法的剪枝操作都是在i的范围里做文章,即缩小i的范围

心得与备忘录

77. 组合

  1. 本题的一个直接的想法是用k层for循环暴力解决,然而由于k的大小不确定,有待输入,因此需要用回溯法。回溯算法也是一个纯暴力的方式,模拟的也是嵌套for循环的过程。回溯算法是利用递归来控制有多少层for循环。递归里的每一层都是一个for循环。嵌套n层for循环即相当于递归n层
  2. 所有回溯法的题目都可以抽象为一个树形结构,本题的树形结构参加实现部分。
  3. 基于本题的树形示意图,以及回溯法的模板代码,就可以写出本题的具体代码。需要特别注意的是,本题的递归函数需要传入三个参数,除去n和k外,还需要传入startIndex。这是因为每层递归的起始点都需要在上一层递归起始点的基础上加1,因此需要一个参数来标明当前这层递归的起始点。
  4. 本题可以进行剪枝优化。

组合问题的剪枝操作

  1. 可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了

  2. 注意代码中i,就是for循环里选择的起始位置。

    1
    for (int i = startIndex; i <= n; i++) 
  3. 接下来看一下优化过程如下(可以画线段图理解):

    • 已经选择的元素个数:path.size();
    • 还需要的元素个数为: k - path.size();
    • 在集合n中至多要从该起始位置: n - (k - path.size()) + 1,开始遍历。若i超过了这个最大的起始位置,则组合中凑不齐k个元素

    为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。

    举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。从2开始搜索都是合理的,可以是组合[2, 3, 4]。

  4. 上述剪枝操作在回溯算法的剪枝操作中特别常见。大部分回溯算法的剪枝操作都是在i的范围里做文章,即缩小i的范围

链接

669. 修剪二叉搜索树
108.将有序数组转换为二叉搜索树
538.把二叉搜索树转换为累加树
总结篇

初次尝试

669. 修剪二叉搜索树

本题据说比增加和删除节点更难,我拿到后没有思路,直接看卡尔的讲解。

108.将有序数组转换为二叉搜索树

虽然这是道简单题,但我也没想出来怎么做。

538.把二叉搜索树转换为累加树

本题的基本思路是:累加树中的新节点(除叶子节点外)是其本身加上其右子树的所有节点之和。叶子节点如果是他父节点的左孩子,则值为他的父节点的新值减去原本父节点的值。叶子节点若是他父节点的右孩子,则值为他的父节点的新值加上叶子节点原本的旧值。要计算右子树的值,应当用双指针算法加上中序遍历。

本题还有另一种思路,更加简单粗暴。直接将二叉搜索树转换为一个递增的数组。然后某个节点的新值就是从其本身到数组末尾的所有元素之和。根据上述原理,我写下了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<int> nums;

// 将二叉搜索树通过中序遍历转换为有序的数组
void traversal(TreeNode* root)
{
if (root == NULL) return;

traversal(root->left); // 左
nums.push_back(root->val); // 中
traversal(root->right); // 右
}

// 更新节点的值
void sum(TreeNode* root)
{
if (root == NULL) return;
for (int i = 0; i < nums.size(); i ++ )
{
if (nums[i] > root->val)
root->val += nums[i];
}
}

// 通过层次遍历,遍历每个节点,依次更新所有节点的值
TreeNode* convertBST(TreeNode* root) {
traversal(root);
if (root == NULL) return NULL;

queue<TreeNode*> q;
q.push(root);

while (q.size())
{
TreeNode* node = q.front(); q.pop();
sum(node);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
return root;
}
};

虽然从原理上来说,上述做法应该是没有问题的,但因为并发修改之类的问题,上述代码的实际运行结果和预期就是不同。我搞不清楚为什么,暂且记录下来。直接看卡尔的讲解。

实现

669. 修剪二叉搜索树

给二叉搜索树,给定范围,在范围内修剪二叉搜索树,使得二叉搜索树中所有节点的数值都在范围内。本题不仅要删除不止一个节点,还要改变树的结构。

450.删除二叉搜索树中的节点的原理:通过递归,从当前层往上一层返回值,上一层的左/右孩子来接住返回值,达到删除节点的效果。

常见的误区:常见的错误代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 返回修剪后二叉树的根节点
TreeNode* traversal(TreeNode* root, int low, int high)
{
// 终止条件
if (root == NULL) return NULL;
// 修剪二叉搜索树
// 当前遍历的节点不在[low, high]的范围内
// 这样写很明显是错误的,若根节点的两个子节点都返回NULL,那么二叉树就只剩下根节点了,其他节点全部被删除
// 实际上根节点的两棵子树中都可能有范围内的节点
if (root->val < low || root->val > high) return NULL;

root->left = traversal(root->left, low, high);
root->right = traversal(root->right, low, high);
return root;
}

正确的思路为:若发现某个节点小于范围的左边界,那么该节点的右子树中可能有范围内的节点,因为该节点中右子树的值都要大于该节点的值。因此尽管要删除这个节点,但还需要继续在其右子树中遍历,来挑出其中符合条件的节点。现在来写正确的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 返回修剪后二叉树的根节点
TreeNode* traversal(TreeNode* root, int low, int high)
{
// 本题的终止条件和删除二叉搜索树中的节点的终止条件类似,分类讨论,先发现要删除的节点然后完成删除操作
// 终止条件1
if (root == NULL) return NULL;

// 终止条件2
// root节点的值小于左边界,但其右子树中可能有符合要求的节点,故应该继续向右遍历
if (root->val < low)
{
// root节点的右子树在修剪后的根节点
TreeNode* right = traversal(root->right, low, high);
return right; // 向上返回该根节点
}

// 终止条件3
// root节点的值大于右边界,但其左子树中可能有符合要求的节点,故应该继续向左遍历
if (root->val > high)
{
// root节点的左子树在修剪后的根节点
TreeNode* left = traversal(root->left, low, high);
return left; // 向上返回该根节点
}

// 单层递归逻辑,分别修剪根节点的左右子树,然后将修剪后的左右子树接上去
root->left = traversal(root->left, low, high);
root->right = traversal(root->right, low, high);

return root; // root在终止条件中处理了
}

本题的代码其实不复杂,特别是相比于450.删除二叉搜索树中的节点。那题的终止条件需要分5种情况讨论,本题的终止条件只需要分3种情况讨论。本题也有迭代写法。本题掌握递归法即可。本题代码量不多,但很考察大家对二叉树移除节点和二叉搜索树特性的理解。

我独立写下了精简注释版本的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int low, int high) {
// 终止条件1
if (root == NULL) return NULL;

// 终止条件2,root节点的值小于左边界
// 此时root节点的右子树中依然可能有符合要求的节点在,因此还需对右子树进行修剪
if (root->val < low)
{
TreeNode* right = trimBST(root->right, low, high);
return right;
}

// 终止条件3,root节点的值大于右边界
// 此时root节点的左子树中依然可能有符合要求的节点在,因此还需对左子树进行修剪
if (root->val > high)
{
TreeNode* left = trimBST(root->left, low, high);
return left;
}

// 单层递归逻辑
// 用来接住3个终止条件的返回值
root->left = trimBST(root->left, low, high);
root->right = trimBST(root->right, low, high);
return root;
}
};

本题的迭代法代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 迭代法
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int low, int high) {
if (root == NULL) return NULL;

// 确保root在[low, high]的区间内
// 以下写法可以避免死循环
while (root && (root->val < low || root->val > high))
{
if (root->val < low) root = root->right;
else root = root->left;
}

TreeNode* cur = root;

// 确保cur的左子树中没有小于low的节点
while (cur)
{
while (cur->left && cur->left->val < low)
{
cur->left = cur->left->right;
}
cur = cur->left;
}

cur = root; // 恢复cur

// 接着检查cur的右子树中没有大于high的节点
while (cur)
{
while (cur->right && cur->right->val > high)
{
cur->right = cur->right->left;
}
cur = cur->right;
}

return root;
}
};

迭代法原理不复杂,但代码非常容易写错,因此不推荐迭代写法。

108.将有序数组转换为二叉搜索树

要构造的二叉搜索树是平衡二叉树。做这个要求的原因是任何有序数组都能够轻易构造成链式的二叉搜索树。

构造二叉树的一般思路:在数组中选取一个中间节点,将数组分为左区间和右区间。递归遍历左区间,构成左子树。递归遍历右区间,构成右子树。

解题思路:root节点选取为数组中间位置的节点。因为只有这样选才可以保证左右区间中节点的数量相同,构造的二叉树才是平衡二叉树。再根据二叉搜索树的性质:中节点的值大于左子节点,小于右子节点来构造二叉搜索树。构造出的二叉搜索树的结构不唯一。对于数组中有偶数个元素的情况,root节点可以选取为中间偏左那个节点,也可以选取为中间偏右那个节点。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 注意引用&。如果每层递归不用引用,就需要在内存空间中重复复制数组,导致程序的性能很差
// 使用引用后,递归遍历时都在同一个内存地址里操作数组
// 区间左右边界的定义很重要,此处对区间的定义是左闭右闭
TreeNode* traversal(vector<int>& nums, int left, int right)
{
// 终止条件:非法区间
if (left > right) return NULL;

int mid = (left + right) / 2; // 数组下标相加不可能爆内存

// 构造二叉树的根节点
TreeNode* root = new TreeNode(nums[mid]);
// 利用左区间构造左子树
root->left = traversal(nums, left, mid - 1); // 因为是左闭右闭的区间,所以right = mid - 1
// 同理,利用右区间构造右子树
root->right = traversal(nums, mid + 1, right); // 因为是左闭右闭的区间,所以left = mid + 1

return root;
}

TreeNode* sortedArrayToBST(vector<int>& nums)
{
// 区间定义左闭右闭,因此right = nums.size() - 1
TreeNode* root = traversal(nums, 0, nums.size() - 1);
return root;
}

本代码并不复杂。本题也可用迭代法实现,但较为复杂。本题优先掌握递归法即可。

本题的精简版本代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
// 区间左闭右闭
TreeNode* traversal(vector<int>& nums, int left, int right)
{
// 终止条件
if (left > right) return NULL;

// 构造root节点
int mid = (left + right) / 2;
TreeNode* root = new TreeNode(nums[mid]);

// 构造左子树
root->left = traversal(nums, left, mid - 1);
// 构造右子树
root->right = traversal(nums, mid + 1, right);

return root;
}

TreeNode* sortedArrayToBST(vector<int>& nums) {
TreeNode* root = traversal(nums, 0, nums.size() - 1);
return root;
}
};

538.把二叉搜索树转换为累加树

换个思路:给一个有序的数组,将其变成一个累加数组。倒序遍历即可,将前一个节点加到本节点中。倒序遍历有序数组,本质就是按照右中左的顺序遍历二叉搜索树。将前一个节点的值加到本节点中,就需要用到双指针。pre指针指向前一个节点,cur指针指向当前节点。现在开始写递归法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int pre = 0; // 记录前一个节点的数值

// 由于要遍历整个二叉树,在遍历的过程中去更新节点数值即可,因此不需要返回值
void traversal(TreeNode* cur)
{
// 终止条件
if (cur == NULL) return;

// 单层递归逻辑:右中左
traversal(cur->right); // 右
cur->val += pre; // 中
pre = cur->val; // 移动pre
traversal(cur->left); // 左
}

在对二叉搜索树的遍历不够熟悉的情况下,可以将二叉搜索树想象成一个有序的数组。

将pre定义为指针也是可行的,代码会略微复杂,因为要判断指针是否为空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
TreeNode* pre = NULL;

void traversal(TreeNode* cur)
{
if (cur == NULL) return;

// 右
traversal(cur->right);
// 中
if (pre) cur->val += pre->val;
pre = cur;
// 左
traversal(cur->left);
}

TreeNode* convertBST(TreeNode* root) {
traversal(root);
return root;
}
};

本题用迭代法也可以做,而且是迭代法的模板题,但我用迭代法写本题总是容易写错。因此还是推荐递归法。

心得与备忘录

669. 修剪二叉搜索树

  1. 本题乍一看非常简单,结果就是掉进常见误区中,即发现一个节点不在区间内,就直接返回NULL。这样的问题在于以该节点为根节点的子树中可能有满足条件的节点。如果直接返回NULL,相当于把可能满足条件的节点一并删除了。
  2. 从常见误区中爬出来,又会觉得本题非常难,因为似乎要调整二叉树的结构。其实本题不需要像450.删除二叉搜索树中的节点那样分五种情况讨论来调整二叉树的结构。本题只需要在终止条件中分出三种情况讨论:

    • root节点为空,则返回空
    • root节点小于区间左边界,则root节点的右子树中可能存在符合要求的节点。此时调用递归函数对root节点的右子树进行修剪,将修剪后右子树的头节点向上返回。
    • root节点大于区间右边界,则root节点的左子树中可能存在符合要求的节点。此时调用递归函数对root节点的左子树进行修剪,将修剪后左子树的头节点向上返回。

    最后在单层递归逻辑中,分别让root节点的左右指针接住修剪后的左右子树即可。终止条件负责返回,单层递归逻辑负责接收。

  3. 本题的基本原理和450.删除二叉搜索树中的节点相同,都是通过递归函数的返回值来移除节点,然后在单层递归逻辑中接住上一层递归的返回值。
  4. 本题的迭代法思路简单(终止条件->确保root在[low, high]的区间内->确保cur的左子树中没有小于low的节点->接着检查cur的右子树中没有大于high的节点),但代码非常容易写错(while循环中套着while,循环条件写得不对容易出现死循环),因此不推荐。还是建议老老实实地用递归法完成本题。

108.将有序数组转换为二叉搜索树

  1. 构造二叉树的一般思路:取数组最中间的元素作为二叉树的root节点。利用数组的左区间构造root节点的左子树,利用数组的右区间构造root节点的右子树。
  2. 在数组中元素个数为偶数时,数组最中间的元素有两个。此时,选取这两个元素中的任意一个作为root节点都可以。这样会构造出两棵不同的二叉搜索树,因此本题的结果不唯一。
  3. 本题递归函数的传入参数为数组和左右下标。在构造二叉树的时候尽量不要重新定义左右区间数组,而是用下标来操作原数组。
  4. int mid = (left + right) / 2最好写成int mid = left + (right - left) / 2。原因是两个整数相加可能会超出整数的最大范围。本题虽然采取第一种写法没事,但要有清醒的意识,避免出事。

  5. 注意循环不变量原则:区间要么一直保持为左闭右闭,要么一直保持为左闭右开。这关乎到终止条件的具体写法、递归时传入的区间下标以及主函数中调用递归函数时传入的下标。

  6. 本题的递归写法思路非常简单:先写终止条件,再取数组最中间的元素作为二叉树的root节点,再利用数组的左区间构造root节点的左子树,最后利用数组的右区间构造root节点的右子树,最后返回root节点即可。
  7. 本题的迭代写法代码比较复杂,不要求掌握。还是优先掌握递归写法。

538.把二叉搜索树转换为累加树

  1. 先想如何把有序(递增)数组变为累加数组:倒序遍历数组,然后用双指针算法即可,即当前元素的新值等于当前元素的旧值加上前一个元素的值。根据这个思路解决本题。二叉搜索树通过中序遍历可以转换为有序数组,倒序遍历数组即相当于反中序遍历二叉搜索树。对树中节点值的累加也是通过双指针实现的。
  2. 本题的递归函数不需要返回值,原因:由于要遍历整个二叉树,在遍历的过程中去更新节点数值即可,因此不需要返回值。
  3. 本题的pre指针可以是整数类型的变量,也可以是指针类型的变量。若采用整数类型的变量,可以避免对指针是否为空的判断(整数的初始值为0),因此采用整数类型的变量作为pre更加方便。
  4. 本题是迭代的模板题,但我用迭代法写本题总是容易写错。因此还是推荐递归法。

总结篇

  1. 解决二叉树类题目的基本方法是递归法。一般使用了递归三部曲来分析题目,看到二叉树,看到递归,都应该想:返回值、参数是什么?终止条件是什么?单层逻辑是什么?

  2. 大多数题也都有迭代解法,但是一般代码更长也更容易写错,可以用于提升自己。

二叉树类的题目可以分为以下几类:

二叉树的遍历方式

深度优先遍历
二叉树:前中后序递归法:递归三部曲初次亮相
二叉树:前中后序迭代法(一):通过栈模拟递归
二叉树:前中后序迭代法(二)统一风格

广度优先遍历
二叉树的层序遍历:通过队列模拟

求二叉树的属性

二叉树:是否对称
递归:后序,比较的是根节点的左子树与右子树是不是相互翻转

二叉树:求最大深度
递归:后序,求根节点最大高度就是最大深度,通过递归函数的返回值做计算树的高度
迭代:层序遍历

二叉树:求最小深度
递归:后序,求根节点最小高度就是最小深度,注意最小深度的定义
迭代:层序遍历

二叉树:求有多少个节点
递归:后序,通过递归函数的返回值计算节点数量
迭代:层序遍历

二叉树:是否平衡
递归:后序,注意后序求高度和前序求深度,递归过程判断高度差

二叉树:找所有路径
递归:前序,方便让父节点指向子节点,涉及回溯处理根节点到叶子的所有路径

二叉树:递归中如何隐藏着回溯
详解二叉树:找所有路径中递归如何隐藏着回溯

二叉树:求左叶子之和
递归:后序,必须三层约束条件,才能判断是否是左叶子。

二叉树:求左下角的值
递归:顺序无所谓,优先左孩子搜索,同时找深度最大的叶子节点。
迭代:层序遍历找最后一行最左边

二叉树:求路径总和
递归:顺序无所谓,递归函数返回值为bool类型是为了搜索一条边,没有返回值是搜索整棵树。

二叉树的修改与构造

翻转二叉树
递归:前序,交换左右孩子

构造二叉树
递归:前序,重点在于找分割点,分左右区间构造

构造最大的二叉树
递归:前序,分割点为数组最大值,分左右区间构造

合并两个二叉树
递归:前序,同时操作两个树的节点,注意合并的规则

求二叉搜索树的属性

二叉搜索树中的搜索
递归:二叉搜索树的递归是有方向的
迭代:因为有方向,所以迭代法很简单

是不是二叉搜索树
递归:中序,相当于变成了判断一个序列是不是递增的

求二叉搜索树的最小绝对差
递归:中序,双指针操作

求二叉搜索树的众数
递归:中序,清空结果集的技巧,遍历一遍便可求众数集合

二叉搜索树转成累加树
递归:中序,双指针操作累加

二叉树公共祖先问题

二叉树的公共祖先问题
递归:后序,回溯,找到左子树出现目标值,右子树节点目标值的节点。

二叉搜索树的公共祖先问题
递归:顺序无所谓,如果节点的数值在目标区间就是最近公共祖先

二叉搜索树的修改与构造

二叉搜索树中的插入操作
递归:顺序无所谓,通过递归函数返回值添加节点

二叉搜索树中的删除操作
递归:前序,想清楚删除非叶子节点的情况

修剪二叉搜索树
递归:前序,通过递归函数返回值删除节点

构造二叉搜索树
递归:前序,数组中间节点分割

链接

235. 二叉搜索树的最近公共祖先
701.二叉搜索树中的插入操作
450.删除二叉搜索树中的节点

初次尝试

235. 二叉搜索树的最近公共祖先

拿到本题,我发现这道题和236. 二叉树的最近公共祖先基本相同,只不过二叉树的条件被增强为二叉搜索树。我首先尝试用236题的思路和代码解决本题。据此,我独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
// 终止条件1
if (root == NULL) return NULL;
// 终止条件2
if (root == p || root == q) return root;

// 后序遍历
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);

if (left && right) return root;
if (left) return left;
if (right) return right;
return NULL;
}
};

然后尝试利用二叉搜索树的特性来进行优化。我想到的优化是:用双指针后序遍历二叉搜索树,若pre和cur分别指向两个目标节点,那么cur就是两节点的最近公共祖先。直接看卡尔的讲解吧。

701.二叉搜索树中的插入操作

本题应该充分利用二叉搜索树的性质就可以求解。若插入节点的值大于当前节点,则向当前节点的右子树插。若插入节点的值小于当前节点,则向当前节点的左子树插。我选择的策略是尽量往叶子节点那一层插入。根据这个思路,我写下了如下的代码(迭代写法,实际上就是枚举将val节点插入二叉搜索树的各种可能):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public:
TreeNode* insertIntoBST(TreeNode* root, int val) {
// root为空,则直接返回由val创建的节点
if (root == NULL) return new TreeNode(val);

// 后序移动cur指针,不要直接移动root
TreeNode* cur = root;

while (cur)
{
// cur无左子节点的情况,则条件合适就将val节点作为cur的左子节点
if (cur->left == NULL && val < cur->val)
{
TreeNode* insert = new TreeNode(val);
cur->left = insert;
return root;
}

// cur无右子节点的情况,则条件合适就将val节点作为cur的右子节点
if (cur->right == NULL && val > cur->val)
{
TreeNode* insert = new TreeNode(val);
cur->right = insert;
return root;
}

// 否则继续走到叶子节点位置,在叶子节点之下插入val节点
if (val > cur->val) cur = cur->right;
else if (val < cur->val) cur = cur->left;
if (cur->left == NULL && cur->right == NULL)
{
TreeNode* insert = new TreeNode(val);
if (val > cur->val) cur->right = insert;
else cur->left = insert;
return root;
}
}
// 必须随便返回一个东西,虽然必然不会执行这行代码,但不返回一个东西本程序就会报错
return NULL;
}
};

注意,上述写法一定要区分cur指针和root节点。在写出了迭代写法后,应该也可以根据相同的原理写出递归写法。递归的写法其实非常简单,我尝试了一段时间后写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
TreeNode* insertIntoBST(TreeNode* root, int val) {
// 终止条件
if (root == NULL) return new TreeNode(val);

// 要插入的节点的值小于root节点的值,则将其插入root节点的左子树
if (root->val > val) root->left = insertIntoBST(root->left, val);
// 要插入的节点的值大于root节点的值,则将其插入root节点的右子树
else if (root->val < val) root->right = insertIntoBST(root->right, val);
return root;
}
};

450.删除二叉搜索树中的节点

本题的难点在于删除二叉搜索树中的节点后,要调整二叉树的结构,甚至要改变root节点。我想到的办法就是中序遍历时使用双指针。当cur指针找到要删去的节点时,cur指针再向后移动一位,然后直接用cur指针指向pre指针,就完成了对要删去节点的删除。根据这个算法思路,我尝试写代码,但写不出来,直接看卡尔的讲解。

实现

235. 二叉搜索树的最近公共祖先

基于236. 二叉树的最近公共祖先,要好好利用二叉搜索树的特性。思路:从上往下遍历二叉树,若当前遍历的节点的值大于p和q的值,则p和q的最近公共祖先一定在当前节点的左子树中,此时从当前遍历的节点开始向左遍历。若当前遍历的节点的值小于p和q的值,则p和q的最近公共祖先一定在当前节点的右子树中,此时从当前遍历的节点开始向右遍历。若当前遍历的节点的值在p和q之间,则当前节点就是p和q的公共节点。

现在的问题是,当前节点是否是p和q的最近公共祖先?其实是的。因为p和q分别在当前节点的左右子树中,如果从当前节点开始继续向下遍历,那么不是错过p就是错过q。接下来开始写递归法的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
TreeNode* traversal(TreeNode* cur, TreeNode* p, TreeNode* q)
{
// 终止条件
if (cur == NULL) return NULL;

// 单层递归逻辑
// 本题不用涉及前中后序,因为二叉搜索树本身是有序的,只要有左和右即可,中在哪里都可以
// 若当前遍历的节点的值大于p和q的值,则p和q的最近公共祖先一定在当前节点的左子树中,此时从当前遍历的节点开始向左遍历
if (cur->val > p->val && cur->val > q->val)
{
TreeNode* left = traversal(cur->left, p, q);
// 在向左遍历的过程中找到了p和q的最近公共祖先,则返回之
if (left != NULL) return left;
}

// 若当前遍历的节点的值小于p和q的值,则p和q的最近公共祖先一定在当前节点的右子树中,此时从当前遍历的节点开始向右遍历
if (cur->val < p->val && cur->val < q->val)
{
TreeNode* right = traversal(cur->right, p, q);
// 在向右遍历的过程中找到了p和q的最近公共祖先,则返回之
if (right != NULL) return right;
}

// 剩下的情况:当前节点的值在p和q之间,则当前节点就是p和q的最近公共祖先
return cur;
}

本题迭代法的代码也比较简单,原因是二叉搜索树确定了搜索的方向。迭代法代码如下所示:

1
2
3
4
5
6
7
8
9
10
TreeNode* traversal(TreeNode* cur, TreeNode* p, TreeNode* q)
{
while (cur)
{
if (cur->val > p->val && cur->val > q->val) cur = cur->left;
else if (cur->val < p->val && cur->val < q->val) cur = cur->right;
else return cur;
}
return NULL; // 一定不要忘记这句话,否则函数会因为没有返回值报错
}

对于二叉搜索树的题目,迭代法似乎比递归法还更简单。

701.二叉搜索树中的插入操作

插入新节点的方式有多种,得到的二叉搜索树不唯一。必然可以在二叉搜索树的叶子节点处插入新的节点。若在二叉搜索树的其他位置处插入节点,则改变了二叉搜索树的结构,将本题做复杂了。递归法代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
TreeNode* insert(TreeNode* root, int val)
{
// 终止条件
// 卡尔的理解:遍历到空节点(叶子节点的左子节点或右子节点)时,将val节点作为叶子节点的左子节点或者右子节点
// 此时将val节点向上返回给叶子节点
// 卡尔的理解方式比较复杂,不如直接理解为传入一个空的树,返回根据val创建的新节点即可
if (root == NULL)
{
TreeNode* node = new TreeNode(val);
return node;
}

// 卡尔的理解:在val节点作为叶子节点的左子节点时,当前节点的左指针指向val节点
// 在val节点作为叶子节点的右子节点时,当前节点的右指针指向val节点
if (val < root->val) root->left = insert(root->left, val);
if (val > root->val) root->right = insert(root->right, val);

return root;
}

卡尔相当于是从叶子节点开始,自下往上讲解递归法的原理。我的理解方式是从root节点开始,自上而下的理解递归的思路。我的理解方式要简单一些,有利于快速写出本题的递归版本的代码。本题也可以用迭代法,由于是二叉搜索树明确了搜索的方向,所以迭代法的代码也会比较简单。

看了代码随想录的讲解后,我独立写出了正统的迭代法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 迭代写法
class Solution {
public:
TreeNode* insertIntoBST(TreeNode* root, int val) {
// 终止条件
if (root == NULL) return new TreeNode(val);

// 用cur节点作为当前遍历到的节点,用parent节点作为cur节点的父节点
// 因为最后是在叶子节点下插入新节点,因此cur节点必定会遍历到空节点,此时需要利用parent节点来指向新节点
TreeNode* parent = new TreeNode(0);
TreeNode* cur = root;

// cur == NULL时终止循环
while (cur)
{
// parent节点随着cur节点移动,但始终落后一步,因此parent节点是当前节点的上一个节点(父节点)
parent = cur;
// 根据二叉搜素树的有序性移动cur节点
if (val < cur->val) cur = cur->left;
else cur = cur->right;
}

// 最终在二叉搜索树的叶子节点下插入新节点,这就需要用到parent节点,因为cur节点已经为空
if (val < parent->val) parent->left = new TreeNode(val);
else parent->right = new TreeNode(val);
return root;
}
};

正统的迭代法采取的是双指针的思路,需要两个节点,一个是cur节点,其是遍历二叉搜索树时的当前节点。另一个是parent节点,其是当前节点的上一个节点(父节点)。

450.删除二叉搜索树中的节点

删除节点后,要保证二叉树依然是二叉搜索树。本题比较难,因为要修改二叉树的结构。删除节点后的二叉树结构不唯一,只要符合二叉搜索树的定义即可。下面开始分析可能的情况。

  • 没找到要删除的节点
  • 要删除的节点是叶子节点,左为空,右也为空(此时删除节点较为简单,因为不需要改变二叉树的结构)
  • 要删除的节点左不为空,右为空。则让该节点的父节点直接指向该节点的左子节点
  • 要删除的节点左为空,右不空。则让该节点的父节点直接指向该节点的右子节点
  • 要删除的节点左不空,右不空。本情况最复杂,因为要大幅调整二叉搜索树的结构。拿以下二叉树为例:
    tstmp_20240424064451

例如删去节点7。让7的右子树继位,那么7的左子树应该放在右子树的哪里。7的左子树应该放在右子树中的左下角(右子树中的最小值)。让7的左子树继位也可以,原理相同。

接下来开始写代码(代码不多但不好理解):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 不写cpp中需要释放内存的逻辑,只写核心代码
// 返回新二叉树的根节点
TreeNode* delete(TreeNode* root, int key)
{
// 终止条件
// 不需要遍历整个二叉树,找到要删除的点就是终止条件
// 找到要删除的点后就要删除该点,因此删除该点的操作在终止条件中

// 没找到要删除的节点/传入的二叉树为空
if (root == NULL) return NULL;

// 找到了要删除的节点
if (root->val == key)
{
// 要删除的节点是叶子节点,左为空,右也为空
// return NULL的意思是该节点的父节点指向NULL
if (root->left == NULL && root->right == NULL) return NULL;

// 要删除的节点左不为空,右为空
// 将要删除的节点的左孩子直接向上返回即可
else if (root->left && root->right == NULL) return root->left;

// 要删除的节点左为空,右不空
// 将要删除的节点的右孩子直接向上返回即可
else if (root->left == NULL && root->right) return root->right;

// 要删除的节点左不空,右不空
else
{
TreeNode* cur = root->right; // 要删除节点的右子树
while (cur->left) cur = cur->left; // 让cur指向右子树的左下角
cur->left = root->left; // 将要删除节点的左子树嫁接到右子树的左下角

// 移除要删除的节点,此时要删除的节点左为空右不为空,因为直接向上返回其右孩子即可
return root->right;
}
}

// 单层递归
// 二叉搜素树确定了搜索的方向
// root的左子树在删去节点后,左子树的新根节点嫁接到root->left上
if (key < root->val) root->left = delete(root->left, key);
// root的右子树在删去节点后,右子树的新根节点嫁接到root->left上
if (key > root->val) root->right = delete(root->right, key);
return root; // 看似没有处理root,但实际上已经在终止条件中处理了
}

上述代码不算复杂,但是很难想。注意:删除节点的操作是通过返回值来进行的,然后让本层递归的上一层去接住本层递归的返回值。本题也可以用迭代法实现。本题的进阶版本:在一般的二叉树中删除节点,更难。吃透本题即可。

本题精简的注释版本如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public:
TreeNode* deleteNode(TreeNode* root, int key) {
// 终止条件,分为五种情况
// 情况1:找不到要删除的节点
if (root == NULL) return NULL;

if (root->val == key)
{
// 情况2,要删除的节点左右都为空
if (root->left == NULL && root->right == NULL) return NULL;
// 情况3,要删除的节点左为空右不空
else if (root->left == NULL && root->right) return root->right;
// 情况4,要删除的节点左不空右为空
else if (root->left && root->right == NULL) return root->left;
// 情况5,要删除的节点左不空右不空
else
{
TreeNode* cur = root->right;
while (cur->left) cur = cur->left;
cur->left = root->left;
return root->right;
}
}

// 单层递归逻辑
if (root->val > key) root->left = deleteNode(root->left, key);
else root->right = deleteNode(root->right, key);
return root;
}
};

心得与备忘录

235. 二叉搜索树的最近公共祖先

  1. 本题和236的区别在于,本题的条件得到了强化,不仅是二叉树,而且是二叉搜索树。
  2. 由于二叉搜索树的方向性,本题不需要回溯,自上往下查找目标区间即可。
  3. 本题的核心思路:从上往下遍历二叉树,若当前节点的值大于p和q节点的值,则p和q的最近公共祖先在当前节点的左子树中,从当前节点开始向左遍历。若当前节点的值小于p和q节点的值,则p和q的最近公共祖先在当前节点的右子树中,从当前节点开始向右遍历。若当前节点的值在[p->val, q->val]的范围内,则当前节点就是p和q的最近公共祖先。注意区间是左闭右闭,因为存在p为q的父节点的情况。
  4. 为什么当前节点的值在[p->val, q->val]的范围内,就可以确定当前节点就是p和q的最近公共祖先,而不仅仅是一个普通的公共祖先?这个原因在于:当前节点的值>p->val,则说明p在当前节点的左子树中;当前节点的值<q->val,则说明q在当前节点的左子树中。因此若从当前节点开始向下移动到下一个节点处,那么不是错过p就是错过q。因此当前节点就是p和q的最近公共祖先。
  5. 因为二叉搜索树的有序性,本题的迭代写法也非常简单,甚至比递归解法更简单。

701.二叉搜索树中的插入操作

  1. 本题可以采用递归法和迭代法。但递归法的代码较为简单,因此推荐使用递归法。迭代法的代码略微复杂,但采用的双指针思路也非常清晰,因此也可以掌握迭代法。
  2. 插入新节点的方式有多种,得到的二叉搜索树不唯一。但必然可以在二叉搜索树的叶子节点处插入新的节点。因此不用改变二叉搜索树的结构,就可以实现插入操作。这是本题简单易解的根本所在。
  3. 本题递归法的思路:

    • 递归函数的返回值:返回插入新节点后二叉搜索树的根节点。也可以返回空,但那样写比较麻烦。
    • 终止条件:root节点为空时,说明输入的二叉树为空,因此直接返回val节点即可。
    • 单层递归逻辑:若val > root->val,说明val节点应该被插入在root节点的右子树中,因此有root->right = insert(root->right, val)insert(root->right, val)返回了root的右子树在插入新节点后的头节点,用root的右节点指向该头节点即可)。若val < root->val,说明val节点应该被插入在root节点的左子树中,因此有root->left = insert(root->left, val)。最终返回root即可。
  4. 卡尔相当于是从叶子节点开始,自下往上讲解递归法的原理。我的理解方式是从root节点开始,自上而下的理解递归的思路。我的理解方式要简单一些,有利于快速写出本题的递归版本的代码。

  5. 本题的迭代写法思路也非常清晰。迭代法采取的是双指针的思路,需要两个节点,一个是cur节点,其是遍历二叉搜索树时的当前节点。另一个是parent节点,其是当前节点的上一个节点(父节点)。我在初次尝试中实现的迭代法代码复杂且由于要考虑多种情况,非常容易写错。因此建议如果要写迭代法,就采用基于双指针思路的迭代法。

450.删除二叉搜索树中的节点

  1. 本题相比于在二叉搜索树中插入节点,复杂得多,原因是删除二叉搜索树中的节点且保持该二叉树仍为二叉搜索树,会改变二叉搜索树原本的结构。

  2. 本题的第一个难点在于分析出删除一个节点的五种情况。

    • 找不到要删除的节点

    • 要删除的节点的左右子节点均为空

    • 要删除的节点的左空右不空

    • 要删除的节点的左不空右空

    • 要删除的节点的左右均不空

    由于在终止条件中要找到需要删除的节点,因此删除节点的操作也在终止条件中完成。

  3. 本题的第二个难点在于实现第五种情况的代码。我直接附上相应的代码并进行解释:

    1
    2
    3
    4
    TreeNode* cur = root->right;
    while (cur->left) cur = cur->left;
    cur->left = root->left;
    return root->right;

    先找到要删除的节点root,然后让cur指针指向root的右子节点。接着用cur遍历root的右子树,直到cur指向右子树的左下角。将root的左子树嫁接到右子树的左下角,最后返回root的右子节点。以root的右子节点为根节点的二叉树就是一棵二叉搜索树。可以举例子画图理解上述构造二叉搜索树的操作。

  4. 本题的基本原理在于:删除节点的操作是通过返回值进行的,然后让本层递归的上一层去接住本层递归的返回值。

  5. 删除一般的二叉树中的节点要采用另外的算法,因此不要求掌握。
  6. 本题也有迭代写法,需要用到双指针算法,代码也更加复杂,因此不要求掌握。
  7. 按理来说,cpp中需要显式地释放被删除节点占用的内存。但不释放也不会对代码的正常运行造成影响。

链接

530.二叉搜索树的最小绝对差
501.二叉搜索树中的众数
236. 二叉树的最近公共祖先

初次尝试

530.二叉搜索树的最小绝对差

本题虽然是个easy题,但我想不出来怎么做。唯一的思路是双指针,至于怎么递归,按照怎样的顺序,我想不出来。

501.二叉搜索树中的众数

本题显然又要充分利用二叉搜索树的特性:中序遍历二叉搜索树,得到的数组是递增的,统计数组中出现频次最高的元素即可。本题我目前发现了两个需要注意的点:

  1. 出现频次最高的元素可能不止一个,因此需要返回一个数组。
  2. 本二叉搜索树的性质为:左子树中的所有节点小于等于根节点,右子树中的所有节点大于等于根节点。

本题似乎也应该采用双指针的做法。若pre->valcur->val相等,则cnt数组(用于统计元素值出现的次数)中pre->val的值加1。但这里有两个问题:

  • 节点的值可能为负数,因此不能将节点的值直接映射为数组的下标
  • 若出现次数最多的元素不止一个,该如何返回数组

上述两个问题都不好解决,我直接看卡尔的讲解吧。

236. 二叉树的最近公共祖先

本题比较难,我拿到后没有什么想法,猜测可能要用到回溯。直接看卡尔的讲解。

实现

530.二叉搜索树的最小绝对差

目标:求任意两节点间的最小绝对差。由于是二叉搜索树,用中序遍历会成为一个有序的序列,据此思路尝试解出此题。我独立写出了本题的第一种解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
vector<int> vec;

void inorder(TreeNode* root)
{
if (root == NULL) return;

inorder(root->left);
vec.push_back(root->val);
inorder(root->right);
}

int getMinimumDifference(TreeNode* root) {
inorder(root);

int min = INT_MAX;
for (int i = 0; i < vec.size() - 1; i ++ )
{
if (min > vec[i + 1] - vec[i]) min = vec[i + 1] - vec[i];
}

return min;
}
};

和98.验证二叉搜索树相同,本题应该也可以用maxvalue法和双指针法。这两种方法本质上都是不用额外的数组,直接在中序遍历时计算两个相邻节点的差值,然后选取最小的差值。现在我来尝试这两种解法。这两种解法我还是想不出来,看卡尔的讲解。

接下来讲解如何在中序遍历时利用两个指针直接得出最小绝对差,而不用把二叉树转变为数组。难点:中序遍历二叉树时前后指针如何移动(控制)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int res = INT_MAX;

TreeNode* pre = NULL;

// 返回二叉树的某一特性或者二叉树节点的某个数值,才需要返回值。本题的情况不需要返回值
// 确切来说,一找到就需要立刻去返回的才需要返回值。需要遍历整棵二叉树且用全局变量来记录返回结果的,函数就不需要返回值
void traversal(TreeNode* cur)
{
// 终止条件
if (cur == NULL) return;

// 单层递归逻辑:中序遍历
traversal(cur->left); // 左
// 中
if (pre != NULL) res = min(res, cur->val - pre->val);
pre = cur;
traversal(cur->right); // 右
}

移动pre和cur的核心在于:cur由中序遍历来移动,pre由赋值移动。cur是当前节点,pre是当前节点的上一个节点。

根据上述核心代码,我写下了本题的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
int res = INT_MAX;
TreeNode* pre = NULL;

void traversal(TreeNode* cur)
{
if (cur == NULL) return;

// 左
traversal(cur->left);
// 中
if (pre != NULL && cur->val - pre->val < res)
res = cur->val - pre->val;
// 也可写作
// if (pre != NULL) res = min(res, cur->val - pre->val);
pre = cur;
// 右
traversal(cur->right);
}

int getMinimumDifference(TreeNode* root) {
traversal(root);
return res;
}
};

本题也可以用迭代法,但不推荐。

501.二叉搜索树中的众数

二叉搜索树中可能有重复的元素。众数可能不止一个,因此输出众数的集合。暴力做法:对一棵普通的二叉树,遍历二叉树,用map统计每个元素出现的频次,然后将map转换为vector,对vector进行排序,然后在数组中求众数。

如何利用二叉搜索树的特性去求众数的集合?遍历顺序:中序遍历。中序遍历得到的数组中的所有元素是单调递增的。求众数的具体方法:先遍历一遍二叉树,记录下所有元素出现的最高频率。再遍历一遍二叉树,将出现频率为最高频率的元素放入结果集中。其实可以不遍历两遍二叉树,遍历一遍二叉树即可,需要用到一些代码技巧。

双指针算法的思路:用count来统计当前元素出现的次数。当pre->val == cur->val时,当前元素出现的次数加1。当pre->val != cur->val,则count归一。初始时,pre指向NULL,count指向左叶子节点,count也为一。当count == maxcount时,将当前元素放入结果集中。

这里有个问题:如果不事先遍历一遍二叉树,怎么知道maxcount一定是真正的最高频率?后面的具体代码实现中会处理这个问题。现在开始写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
TreeNode* pre = NULL;
int count = 0; // 当前元素出现的频率
int maxcount = 0; // 整个二叉树中(已经遍历过的节点)的元素出现的最高频率
vector<int> res; // 结果集

// 遍历整个二叉树,结果放入全局变量中,因此递归函数不需要返回值
void traversal(TreeNode* cur)
{
// 终止条件
if (cur == NULL) return;

// 单层的递归逻辑
// 左
traversal(cur->left);
// 中:处理逻辑
// 统计count
if (pre == NULL) count = 1; // 双指针的初始位置:pre为NULL,cur指向左叶子节点
else if (pre->val == cur->val) count += 1; // 当前元素出现的次数+1
else count = 1; // 双指针指向的节点的值不相等,则count又回到1
pre = cur; // pre跟随cur移动

// 若当前节点的出现次数等于整个二叉树中元素出现的最大次数,则将其放入结果集中
if (count == maxcount) res.push_back(cur->val);

// 此时存在问题:maxcount不是真正的maxcount,因此需要代码去更新res数组
// 实时更新res数组,就不需要遍历两遍二叉树了
if (count > maxcount)
{
maxcount = count; // 更新maxcount
// maxcount都被更新了,原先的结果集中的结果全废了,清空res
res.clear();
res.push_back(cur->val); // 将当前节点的数值放入结果集中
}

// 右
traversal(cur->right);

return;
}

我也写出了遍历两次二叉树的代码,如下所示。需要特别注意的是,getmaxcount后需要重置pre和count。遍历两次二叉树的代码显得很冗余,因为基本相同的逻辑写了两遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Solution {
public:
TreeNode* pre = NULL;
vector<int> res;
int count = 0;
int maxcount = 0;

// 第一次遍历二叉树,得到maxcount
void getmaxcount(TreeNode* cur)
{
if (cur == NULL) return;

getmaxcount(cur->left);
// 中节点
if (pre == NULL) count = 1;
else if (pre->val == cur->val) count += 1;
else count = 1;
pre = cur;
if (count > maxcount) maxcount = count;

getmaxcount(cur->right);
}

// 第二次遍历二叉树,得到结果集
void traversal(TreeNode* cur)
{
if (cur == NULL) return;

traversal(cur->left);

// 中节点
if (pre == NULL) count = 1;
else if (pre->val == cur->val) count += 1;
else count = 1;
pre = cur;
if (count == maxcount) res.push_back(cur->val);

traversal(cur->right);
}

vector<int> findMode(TreeNode* root) {
getmaxcount(root);
pre = NULL; // getmaxcount后重置pre
count = 0; // getmaxcount后重置count
traversal(root);
return res;
}
};

本题也可以采用迭代法,基本上是迭代的模板加上递归法对中节点的处理逻辑。但本题推荐掌握递归法即可。

236. 二叉树的最近公共祖先

找两个节点p和q的最近公共祖先。条件:二叉树中所有节点的值都是不同的,二叉树中一定存在p和q。根节点是任何两个节点的公共祖先,所以求公共祖先没有意义,求最近公共祖先才有意义。简单的想法:找到节点p和节点q后,从下往上去遍历,直到找到公共的节点。但对二叉树,一般大家熟悉的是从根节点开始从上往下去遍历,实际上也无法从下往上去遍历,但处理顺序可以是从下往上的。回溯的过程就可以让我们去从下往上地处理结果。具体来说,可以判断某个节点的左子树是否出现过p,右子树是否出现过q,如果都出现了,就将该节点向上返回。看该节点的父节点,若父节点的左子树中没出现p,或者右子树中没出现q,则说明该节点是p和q的最近公共祖先。父节点继续将最近公共祖先节点的值向上返回,直到返回到根节点。

从下往上传递p和q节点的最近公共祖先的逻辑写在回溯过程中。想在回溯过程中达到从下往上处理的效果,一定要用后序遍历。后序遍历是左右中,中:处理逻辑。中的具体处理逻辑:判断某个节点的左子树是否出现过p,右子树是否出现过q。即在终止条件中,如果遇到了p或者q,就往上返回。如果一个节点的左子树的返回值不为空,则左子树中出现了p或者q;如果一个节点的右子树的返回值不为空,则右子树中出现了p或者q。如果当前中节点的左右子树的返回值都不为空,则当前的中节点就是p和q最近的公共祖先。还有一种情况。即p就是q的公共祖先。但本情况的处理逻辑和上面是相同的。

具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 返回最近的公共祖先
TreeNode* traversal(TreeNode* root, TreeNode* p, TreeNode* q)
{
// 终止条件1
if (root == NULL) return NULL;
// 终止条件2:遇到节点p或者q,则将它们向上返回
if (root == p || root == q) return root;

// 单层递归的逻辑:后序遍历
// 左:可以告诉我们左子树中是否出现过p或者q
TreeNode* left = traversal(root->left, p, q);
// 右:可以告诉我们右子树中是否出现过p或者q
TreeNode* right = traversal(root->right, p, q);
// 中
// 左右子树中出现了p和q,则root是最近公共祖先,将root返回
if (left != NULL && right != NULL) return root;
// 左子树中没有p和q,右子树为最近公共祖先,则继续将right(即最近公共祖先)向上返回,可以参见下面的实例
if (left == NULL && right != NULL) return right;
// 左子树为最近公共祖先,右子树中没有p和q,则继续将left(即最近公共祖先)向上返回,和上面一行代码同理
if (left != NULL && right == NULL) return left;
// 左右子树都为空,则return NULL
else return NULL;
}

1
2
3
Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 7, q = 4
Output: 2
Explanation: The LCA of nodes 5 and 1 is 3.

img

以上面的二叉树为例,2的左子树返回7,右子树返回4,则2是最近公共祖先。5的左子树返回空,右子树返回2,2是7和4的最近公共祖先,则将2继续向上返回。

为什么上述代码将另一种情况也包含了?以上图为例,若p=7, q=2,则q就是最近的公共祖先。一旦遇到q就返回,就不继续向下遍历了。最终就将q返回到root节点,作为结果了。因此另一种情况不需要特别考虑。

本题的难点:

  • 回溯的过程可以将结果逐层从下往上返回。
  • 从下往上返回结果需要用到后序遍历。先进行左右子树的判断逻辑,再进行中节点的逻辑。只有左右子树的返回值不为空,才将中节点作为最近公共祖先返回。
  • 可以举实例画图理解本题的回溯过程和后序遍历中节点的处理(返回)逻辑。
  • 情况2的处理逻辑包含于情况1中。

心得与备忘录

530.二叉搜索树的最小绝对差

  1. 遇到二叉搜索树,首先需要注意其在中序遍历后得到的数组是递增的这一特性。
  2. 遇到在二叉搜索树上求什么最值啊,差值之类的,就把它想成在一个有序数组上求最值,求差值。
  3. 我之所以一开始没有写出本题的双指针解法,原因在于忘记了如何确定递归函数的返回值。对于本题,需要遍历整棵二叉树且用全局变量来记录返回结果,因此递归函数不需要返回值。
  4. 本题的双指针法的思路和98.验证二叉搜索树相同,但两题的区别在于本题的递归函数没有返回值,而98题的递归函数返回值为bool类型。
  5. 移动pre和cur的核心在于:cur由中序遍历来移动,pre由赋值移动。cur是当前节点,pre是当前节点的上一个节点。

501.二叉搜索树中的众数

  1. 本题虽然是easy难度,但其实是比较难的。
  2. 若是一般的二叉树,而非二叉搜索树,则本题的思路为:首先遍历二叉树,用map统计每个元素出现的次数。然后对map按照value进行排序,将排序后的map的(key, value)中最大的一个(或几个)value对应的key放入结果集中。原理不复杂,但代码实现起来比较麻烦。
  3. 本题是二叉搜索树,因此想要充分利用了其性质的话,肯定要采用中序遍历。本题的核心思路依然是双指针算法。需要一个count来存储当前节点出现的次数,一个maxcount来存储整棵二叉树中出现次数最多的节点出现的次数。如果采用两次遍历的做法,那么需要先遍历一遍二叉树得到maxcount,然后再遍历一遍二叉树,将count == maxcount的节点的值存入结果集中。但实际上,遍历一遍二叉树即可完成上述操作。
  4. 遍历一遍二叉树的做法:初始时,count = 1pre->val == cur->val时,count += 1;否则,count = 1。若count == maxcount,则将当前节点的值放入结果集中。此时出现问题:maxcount不一定是整棵二叉树出现次数最多的节点出现的次数。可以用一个简单的办法解决这个问题:若count > maxcount,则更新maxcount,清空结果集,然后再往结果集中插入当前节点的值。通过这样的操作,就可以动态地去更新结果集,从而避免了对二叉树的两次遍历。

236. 二叉树的最近公共祖先

  1. 本题需要自下往上处理节点,自然而然想到用回溯的思想。

  2. 后序遍历(左右中)就是天然的回溯过程,可以根据左右子树的返回值,来处理中节点的逻辑。因此本题采用后序遍历。

  3. 理解以下的示意图就理解了本题:
    236.二叉树的最近公共祖先1

  4. 本题的代码实际上非常简单而清晰,可以根据代码来理解上面的图片,本题的核心代码为中节点的处理逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 终止条件1
    if (root == NULL) return NULL;
    // 终止条件2:遇到节点p或者q,则将它们向上返回,对应于节点6和5将它们自身向节点7返回的过程
    if (root == p || root == q) return root;

    // 单层递归的逻辑:后序遍历
    // 左:可以告诉我们左子树中是否出现过p或者q
    TreeNode* left = traversal(root->left, p, q);
    // 右:可以告诉我们右子树中是否出现过p或者q
    TreeNode* right = traversal(root->right, p, q);
    // 中
    // 左右子树中出现了p和q,则root是最近公共祖先,将root返回,对应于节点7将其自身向节点10返回的过程
    if (left != NULL && right != NULL) return root;
    // 左子树中没有p和q,右子树为最近公共祖先,则继续将right(即最近公共祖先)向上返回
    // 对应于节点10将节点7向节点8返回的过程
    if (left == NULL && right != NULL) return right;
    // 左子树为最近公共祖先,右子树中没有p和q,则继续将left(即最近公共祖先)向上返回,和上面一行代码同理
    if (left != NULL && right == NULL) return left;
    // 左右子树都为空,则return NULL
    else return NULL;

    用文字来描述,本题的关键代码实现了以下功能:

    • 遇到节点p或q,就将其向上返回
    • 某个节点的左右子树中包含p和q,则该节点就是p和q的最近公共祖先,将该节点向上返回
    • 某个节点的左右子节点中的一个的返回值不为空,则说明那个返回值不为空的左/右节点为p和q的最近公共祖先,将该节点进一步向上返回
    • 其他情况下均返回空即可
  5. 还有一种情况,即p是q的父节点,此时遍历到p的父节点时,p的父节点就会向上返回p,而不会继续遍历p和其下面的子树。因此本情况也包含在上述代码的逻辑中。

链接

654.最大二叉树

617.合并二叉树

700.二叉搜索树中的搜索

98.验证二叉搜索树

知识

700.二叉搜索树中的搜索

什么是二叉树搜索树?其根节点要比左子树里所有节点的数值大,根节点要比右子树里所有节点的数值小。同理,左右子树也符合这个规则。二叉搜索树的上述规则确定了遍历顺序(既不是前序,也不是中序、后序),而二叉搜素树的上述规则也让本题的迭代写法特别简单。

if (node != NULL) 可以简写为if (node)

98.验证二叉搜索树

中序遍历一棵二叉搜索树,得到的数组中的元素是有序的。

初次尝试

654.最大二叉树

本题延续106.从中序与后序遍历序列构造二叉树的思路。步骤可以分为:

  1. 找到nums数组中的最大值,作为root

  2. 找到root在nums中的下标

  3. 将nums按照root分为左数组和右数组

  4. 递归处理左数组和右数组,得到左右子树

我独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
public:
TreeNode* traversal(vector<int>& nums)
{
// 终止条件:数组为空,构造不出树,返回NULL
if (nums.size() == 0) return NULL;

// 找到nums数组中的最大值,作为root
int max = -1;
for (int i = 0; i < nums.size(); i ++ )
{
if (nums[i] > max)
{
max = nums[i];
}
}

// 找到root在nums中的下标
int j = 0;
for (j; j < nums.size(); j ++ )
if (nums[j] == max)
break;

int rootvalue = nums[j];
TreeNode* root = new TreeNode(rootvalue);

// 数组分为左数组和右数组
vector<int> ml(nums.begin(), nums.begin() + j);
vector<int> mr(nums.begin() + j + 1, nums.end());

// 递归处理左数组和右数组,得到左右子树
root->left = traversal(ml);
root->right = traversal(mr);
return root;
}

TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
if (nums.size() == 0) return NULL;
else return traversal(nums);
}
};

617.合并二叉树

对于本题,我也采用前序遍历的方法,独立写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public:
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
// 应该采用前序遍历,先中节点,再左节点,再右节点
// 终止条件
if (root1 == NULL && root2 == NULL) return NULL;
else if (root1 == NULL) return root2;
else if (root2 == NULL) return root1;

int rootvalue = root1->val + root2->val;
TreeNode* root = new TreeNode(rootvalue);

// 左节点
if (root1->left != NULL && root2->left != NULL)
root->left = mergeTrees(root1->left, root2->left);
else if (root1->left != NULL)
root->left = mergeTrees(root1->left, NULL);
else if (root2->left != NULL)
root->left = mergeTrees(NULL, root2->left);

// 右节点
if (root1->right != NULL && root2->right != NULL)
root->right = mergeTrees(root1->right, root2->right);
else if (root1->right != NULL)
root->right = mergeTrees(root1->right, NULL);
else if (root2->right != NULL)
root->right = mergeTrees(NULL, root2->right);

return root;
}
};

由上述代码可知,对左右节点的处理都分了三类讨论,这与终止条件中的三类相对应,这是上述代码可以正确运行的基础。本题应该也可以用层序遍历去做。

700.二叉搜索树中的搜索

哈哈哈这题又给我用前序遍历的写法做出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
TreeNode* searchBST(TreeNode* root, int val) {
// 终止条件
if (root == NULL) return NULL;

// 前序遍历搜索整棵树
if (root->val == val) return root; // 中节点
TreeNode* left = searchBST(root->left, val); // 左节点:在左子树中搜索值为val的节点
TreeNode* right = searchBST(root->right, val); // 右节点:在右子树中搜索值为val的节点

if (left != NULL && right == NULL) return left; // 在左子树中找到目标节点,则返回之
else if (left == NULL && right != NULL) return right; // 在右子树中找到目标节点,则返回之
return NULL; // 左右子树中都没找到,则返回空
}
};

本题用层序遍历应该也可以做。层序遍历的写法我也独立写出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
TreeNode* searchBST(TreeNode* root, int val) {
if (root == NULL) return NULL;

queue<TreeNode*> q;
q.push(root);

while (q.size())
{
TreeNode* node = q.front(); q.pop(); // 取出一个节点
if (node->val == val) return node; // 判断其是否为目标节点,是,则返回之

// 继续往队列中加入当前节点的非空左右子节点
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}

// 若最后没有找到目标节点,则返回空
return NULL;
}
};

98.验证二叉搜索树

本题我觉得应该采用中序遍历,先验证左子树是否符合要求,再验证中节点是否符合要求,最后验证右子树是否符合要求,根据这个思路,我尝试独立写出本题的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public:
bool isValidBST(TreeNode* root) {
// 只有root节点
if (root->left == NULL && root->right == NULL) return true;
// 左子树为空
else if (root->left == NULL)
{
if (root->val >= root->right->val) return false;
else return isValidBST(root->right);
}
// 右子树为空
else if (root->right == NULL)
{
if (root->val <= root->left->val) return false;
else return isValidBST(root->left);
}
// 左右子树都不为空
else
{
if (root->val <= root->left->val || root->val >= root->right->val) return false;
else
{
bool res1 = isValidBST(root->left);
bool res2 = isValidBST(root->right);
if (res1 && res2) return true;
else return false;
}
}
}
};

上述代码分了4种情况讨论:只有root节点;左子树为空;右子树为空;左右子树都不为空。但忽略了一种情况:
root = [5,4,6,null,null,3,7]时,二叉树如下所示:

1
2
3
4
5
graph TD;
A((5)) --> B((4))
A --> C((6))
C --> F((3))
C --> G((7))

此时虽然[5, 4, 6]是二叉搜索树,[6, 3, 7]也是二叉搜素树,但[6, 3, 7]中存在元素3小于root的5,因此整体并不是一棵二叉搜索树。这种局部都是二叉搜索树,但整体不是二叉搜索树的情况,在我的代码中并没有进行特判。因此我只能通过77/85个测试样例。这也是本题的坑之所在。直接看卡尔的讲解。

实现

654.最大二叉树

规则:在数组中选取最大的元素作为root,最大元素的左区间用于构造左子树,规则同上;最大元素的右区间用于构造右子树,规则同上。

例如,321605,根据以上规则得到以下的最大二叉树:
img

遍历方式:前序遍历。凡是涉及构造二叉树的题目,都要用到前序遍历。原因:前序遍历顺序是中左右,先构造root节点,再去构造左子树和右子树。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 返回二叉树的root节点
TreeNode* construct(vector<int>& nums)
{
// 确定递归的终止条件
// 数组中只有一个元素,则唯一一个元素就是root节点
// 本题题目中对数组的要求是非空,因此不需要考虑数组为空的情况
if (nums.size() == 1)
return new TreeNode(nums[0]);

// 单层递归的逻辑
// 中节点的逻辑
// 找到数组中的最大值和其下标
int maxvalue = 0;
int index = 0;
for (int i = 0; i < nums.size(); i ++ )
if (nums[i] > maxvalue)
{
maxvalue = nums[i];
index = i;
}

// 根据最大值定义root节点
TreeNode* root = new TreeNode(maxvalue);

// 左子树的逻辑
// 根据index切割原数组,将其分为左右数组
if (index > 0) // 保证左区间中至少有一个元素
{
vector<int> l(nums.begin(), nums.begin() + index); // 左区间,左闭右开
root->left = construct(l); // 递归构造左子树
}

// 右子树的逻辑
if (index < nums.size() - 1) // 保证右区间中至少有一个元素
{
vector<int> r(nums.begin() + index + 1, nums.end()); // 右区间,左闭右开
root->right = construct(r); // 递归构造右子树
}

return root;
}

上述代码严格按照前序遍历构造二叉树。本代码冗余,且效率低,效率低的原因是每次分割时都构造了两个新的数组。对本代码的优化是每次分割数组时不用构造新的数组,操作下标即可

还有一个重要问题:左右子树的逻辑中要加上if判断。写不写if关键在于终止条件。终止条件保证了数组中至少要有一个元素,因此在左右子树的逻辑中需要加上if判断,也就是保证左右区间中至少有一个元素。当然在左右子树的逻辑中不写if也可以,那就需要在leetcode的主函数外另写一个函数。

完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
// 终止条件
if (nums.size() == 1)
return new TreeNode(nums[0]);

// 前序遍历:中节点
// 找到root的值和下标
int max = 0, index = 0;
for (int i = 0; i < nums.size(); i ++ )
if (nums[i] > max)
{
max = nums[i];
index = i;
}
TreeNode* root = new TreeNode(max);

// 左节点
if (index > 0)
{
vector<int> l(nums.begin(), nums.begin() + index); // 左区间
root->left = constructMaximumBinaryTree(l); // 递归构造左子树
}

// 右节点
if (index < nums.size() - 1)
{
vector<int> r(nums.begin() + index + 1, nums.end()); // 右区间
root->right = constructMaximumBinaryTree(r); // 递归构造右子树
}
return root;
}
};

左右子树中不加if判断的写法(关键在于终止条件nums.size() == 0),这也是我在初次尝试中的写法,是最不容易写错的写法。因为思路直接继承自106.从中序与后序遍历序列构造二叉树,且不需要在左右子树处加if判断。推荐这个写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public:
TreeNode* construct(vector<int>& nums) {
// 终止条件
if (nums.size() == 0)
return NULL;

// 前序遍历:中节点
// 找到root的值和下标
int max = 0, index = 0;
for (int i = 0; i < nums.size(); i ++ )
if (nums[i] > max)
{
max = nums[i];
index = i;
}
TreeNode* root = new TreeNode(max);

vector<int> l(nums.begin(), nums.begin() + index); // 左区间
vector<int> r(nums.begin() + index + 1, nums.end()); // 右区间
root->left = construct(l); // 递归构造左子树
root->right = construct(r); // 递归构造右子树

return root;
}

TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
if (nums.size() == 0) return NULL;
else return construct(nums);
}
};

在本写法的基础上进一步进行优化。不在分割数组时创建新的数组,只改变数组的下标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
TreeNode* construct(vector<int>& nums, int begin, int end) {
// 终止条件,区间是左闭右开的,因此是begin >= end
if (begin >= end)
return NULL;

// 全部操作下标,不需要操作元素的值
int index = begin;
// 从begin + 1开始搜索,i = begin时,index = i = begin,不需要写入循环
for (int i = begin + 1; i < end; i ++ )
if (nums[i] > nums[index])
index = i;
TreeNode* root = new TreeNode(nums[index]);

// 左节点
// 左闭右开
int leftbegin = begin;
int leftend = index;
root->left = construct(nums, leftbegin, leftend); // 递归构造左子树

// 右节点
int rightbegin = index + 1;
int rightend = end;
root->right = construct(nums, rightbegin, rightend); // 递归构造右子树

return root;
}

TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
// 不需要下面这句话,因为已经在construct函数中通过begin == end处理了nums.size() == 0的情况
// if (nums.size() == 0) return NULL;
else return construct(nums, 0, nums.size());
}
};

本版本代码在处理左右节点时不需要进行if判断,原因是若左右区间为空,在递归调用construct函数时会因为begin == end直接返回NULL,而不会出现直接创建数组写法中的空数组的现象(终止条件中未考虑空数组的情况,因此若数组为空不能触发终止条件,会导致程序报错)。因此我也可以在终止条件中考虑空数组的情况,而不在构建左右子树时加上if判断,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
// 终止条件1
if (nums.size() == 0) return NULL;

// 终止条件2
if (nums.size() == 1) return new TreeNode(nums[0]);

// 前序遍历:中节点
// 找到root的值和下标
int max = 0, index = 0;
for (int i = 0; i < nums.size(); i ++ )
if (nums[i] > max)
{
max = nums[i];
index = i;
}
TreeNode* root = new TreeNode(max);

// 左节点,不需加if判断,由于有终止条件1
vector<int> l(nums.begin(), nums.begin() + index); // 左区间
root->left = constructMaximumBinaryTree(l); // 递归构造左子树

// 右节点,不需加if判断,由于有终止条件1
vector<int> r(nums.begin() + index + 1, nums.end()); // 右区间
root->right = constructMaximumBinaryTree(r); // 递归构造右子树
return root;
}
};

617.合并二叉树

将两个二叉树合并为一个二叉树。有相同的节点则将两个节点数值相加,作为新节点。对于一棵树上有而另一棵树上没有的节点,将新的节点补充过来。难点:同时操作两个二叉树。本题使用前序遍历最易理解,顺序是中左右。中序和后序也可,但不太符合直观上合并两棵二叉树的过程。本题也可使用迭代法。接下来写递归的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TreeNode* mergeTree(TreeNode* t1, TreeNode* t2)
{
// 终止条件
// 遍历到t1树的某个位置为空后,需要返回t2树上相同位置的节点(遍历两树是同步的),这样才能将t2树上的该节点的子树加入到合并后的二叉树中
// 已经包含了两树都为空的情况
if (t1 == NULL) return t2;
if (t2 == NULL) return t1;

// 单层递归逻辑,前序遍历
// 直接改tree1的结构,而不重新定义一棵二叉树
t1->val += t2->val; // 中节点
t1->left = mergeTree(t1->left, t2->left); // 左节点
t1->right = mergeTree(t1->right, t2->right); // 右节点

return t1; // 返回合并后的二叉树,即t1
}

本题采用中序/后序遍历也可以,把单层递归逻辑的三行代码调整顺序即可。按照前序遍历的思路想即可,这符合我们正常合并二叉树的习惯。本题除了在t1上修改,也可以定义一棵新的二叉树,写法如下:

1
2
3
4
5
6
7
TreeNode* root = new TreeNode(0);

root->val = t1->val + t2->val;
root->left = mergeTree(t1->left, t2->left);
root->right = mergeTree(t1->right, t2->right);

return root;

上述写法空间复杂度是O(n)。

本题的迭代写法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 迭代写法
class Solution {
public:
TreeNode* mergeTrees(TreeNode* r1, TreeNode* r2) {
// 终止条件
if (r1 == NULL) return r2;
if (r2 == NULL) return r1;

// 迭代写法,常用队列
queue<TreeNode*> q;
q.push(r1); q.push(r2);

while (q.size())
{
TreeNode* n1 = q.front(); q.pop();
TreeNode* n2 = q.front(); q.pop();

// n1和n2都不为空
n1->val += n2->val;

// 若n1和n2的左子树都不为空,则将它们加入队列中
if (n1->left != NULL && n2->left != NULL)
{
q.push(n1->left);
q.push(n2->left);
}

// 若n1和n2的右子树都不为空,则将它们加入队列中
if (n1->right != NULL && n2->right != NULL)
{
q.push(n1->right);
q.push(n2->right);
}

// 若n1的左子树为空,则将n2的左子树赋到n1上
if (n1->left == NULL)
n1->left = n2->left;

// 若n1的右子树为空,则将n2的右子树赋到n1上
if (n1->right == NULL)
n1->right = n2->right;
}
return r1;
}
};

700.二叉搜索树中的搜索

在二叉树中找到数值为目标值的节点然后返回。

什么是二叉树搜索树?其根节点要比左子树里所有节点的数值大,根节点要比右子树里所有节点的数值小。同理,左右子树也符合这个规则。二叉搜索树的上述规则确定了遍历顺序,而二叉搜素树的上述规则也让本题的迭代写法特别简单。递归法的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TreeNode* search(TreeNode* root, int val)
{
// 两个终止条件
if (root == NULL || root->val == val) return root;

// 单层递归的逻辑
TreeNode* res = NULL; // res用于存递归函数的返回值

// 根据二叉搜索树的特性,若val < root的值,则向root的左子树中去遍历
if (val < root->val) res = search(root->left, val);
// 根据二叉搜索树的特性,若val > root的值,则向root的右子树中去遍历
if (val > root->val) res = search(root->right, val);

return res;
}

相比于我在初次尝试中的写法,上述写法代码更简洁,效率也更高。因为我在初次尝试中的写法没有利用二叉搜索树的特性,只是单纯地先搜索root节点,再递归搜索root节点的左子树和右子树。而卡尔的写法先搜索root节点,然后根据root->valval的大小对比,决定是在root节点的左子树中继续搜索还是在其右子树中继续搜索。卡尔的写法并不涉及前中后序,因为二叉搜索树本身的性质已经帮我们确定了遍历的顺序。

本题迭代法的代码也并很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
TreeNode* search(TreeNode* root, int val)
{
while (root != NULL)
{
// 向左子树中搜索
if (val < root->val) root = root->left;
// 向右子树中搜索
else if (val > root->val) root = root->right;
// 相等,说明找到了目标节点
else return root;
}
return NULL; // 树中无目标节点,则返回空
}

迭代法中搜索的方向也由二叉搜索树的特性决定。上述迭代写法也比我在初次尝试中写的迭代写法更为简洁且效率更高,因为卡尔充分利用了二叉搜索树的特性,知道想要找到目标值应该朝着什么方向走。

98.验证二叉搜索树

判断给定的二叉树是否是二叉搜索树。二叉搜索树的定义:左子树中的所有元素都小于root节点,右子树中的所有元素都大于root节点,左右子树都符合这个规则。中序遍历这棵二叉树,其元素是有序的。例如对以下二叉树:

1
2
3
4
5
6
7
graph TD;
A[5] --> B[3]
B[3] --> H[1]
B[3] --> I[NULL]
A --> C[10]
C --> F[6]
C --> G[11]

按照中序遍历,就是[1, 3, 5, 6, 10, 11],数组是有序的。验证二叉树是否是二叉搜索树,就是验证中序遍历后得到的数组是否是单调递增的。开始写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool isvalid(TreeNode* root)
{
if (root == NULL) return true; // 空二叉树是二叉搜索树

vector<int> vec; // 存放树中序遍历后的结果

// 中序遍历
isvalid(root->left); // 左
vec.push_back(root->val); // 中:将遍历到的元素放入数组中
isvalid(root->right); // 右

// 判断vec是否有序
}

根据上述思路,我写下了能够运行的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
vector<int> vec; // 全局变量,用于存放中序遍历二叉树的结果

// 用于中序遍历二叉树的辅助函数
void inorder(TreeNode* root)
{
if (root == NULL) return;
inorder(root->left);
vec.push_back(root->val);
inorder(root->right);
}

bool isValidBST(TreeNode* root) {
if (root == NULL) return true;

inorder(root);

// 判断数组是否是单调递增的
for (int i = 0; i < vec.size() - 1; i ++ )
{
if (vec[i + 1] <= vec[i]) return false;
}
return true;
}
};

其实不需要将二叉树转换为数组,可以直接在遍历二叉树的过程中判断元素是否是单调递增的。接下来看如何不使用数组来判断二叉树是否是二叉搜索树。本题也可以用迭代法实现,但主要关注递归法。

代码误区:if (root->val > root->left->val && root->val < root->right->val) return true;。实际上这样写是错误的。因为二叉搜索树要求的是root要大于左子树中的所有值,root要小于右子树中的所有值。这个错误我在初次尝试部分已经讨论过了。

现在开始写不额外定义数组的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// -2^31 <= Node.val <= 2^31 - 1,因此maxval要小于int的最小值,因此maxval要取为long long的最小值
long long maxval = LONG_MIN;

bool isvalid(TreeNode* root)
{
if (root == NULL) return true;
bool left = isvalid(root->left); // 左:左子树是否是二叉搜索树

// 中
// 若二叉树是二叉搜索树,在中序遍历的情况下,元素是递增的,root->val会持续大于maxval
// maxval相当于记录了遍历过程中当前节点的前一个节点的数值
if (root->val > maxval)
{
maxval = root->val; // 更新maxval
}
// 若元素不是递增的,则return false
else return false;

// 右:右子树是否是二叉搜索树
bool right = isvalid(root->right);

return left && right; // 要求左右子树同时符合条件
}

以二叉树为例:

1
2
3
4
5
6
7
graph TD;
A[5] --> B[3]
B[3] --> H[1]
B[3] --> I[NULL]
A --> C[10]
C --> F[6]
C --> G[11]

在判断6是否合法时,拿5(root,也是maxvalue)和6进行比较了,因此不会进入初次尝试中提到的误区。若左叶子为INT_MIN,且maxval = INT_MIN,则root->val == maxval,就会直接返回false。因此要让maxval小于INT_MIN

上述方法存在问题,若左叶子为LONG_MIN(改变输入节点数值的范围),则maxval就无法取更小的值了。我们可以对上述方法进行优化,不额外定义一个变量,而是直接让二叉树的前一个节点和后一个节点进行比较。写下如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TreeNode* pre = NULL; // pre记录当前节点的前一个节点, root可以和pre进行比较

bool isvalid(TreeNode* root)
{
if (root == NULL) return true;

bool left = isvalid(root->left); // 左
// 中
if (pre != NULL && pre->val >= root->val) return false; // 前一个节点的值大于当前节点,则return false
pre = root; // 将pre从NULL移到root,即从前一个节点移到当前节点
bool right = isvalid(root->right); // 右

return left && right;
}

以上方法被称为双指针优化。其实原理非常简单,就是在中序遍历的过程中一直去判断root(当前节点)和pre(前一个节点)之间的大小关系。root指针是由中序遍历的过程去移动的,pre指针是通过直接赋值去移动的。本题是二叉搜索树中的基础题。

心得与备忘录

654.最大二叉树

  1. 本题的思路和106.从中序与后序遍历序列构造二叉树的思路非常类似,理解了那题就可以顺畅的写出本题的代码。

  2. 本题的单层递归核心逻辑如下:

    1. 找到nums数组中的最大值,作为root

    2. 找到root在nums中的下标

    3. 将nums按照root分为左数组和右数组

    4. 递归处理左数组和右数组,得到左右子树

  3. 本题的终止条件可以写两个也可以写一个。若只有终止条件:if (nums.size() == 1) return new TreeNode(nums[0]);,则需要在构造左右子树时加上if判断,保证左右区间不为空。若再加上终止条件:if (nums.size() == 0) return NULL;,则就不需要在构造左右子树时加上if判断了。加上第二个终止条件的原因并非是为了防止传入的nums数组为空(题目限制了传入的数组中至少有1个元素),而是为了在递归时出现数组为空的情况下顺利退出递归过程。

  4. 在实现中,给出了本题的4种写法。写法1和写法4的区别在于:1有1个终止条件,有if判断。4有2个终止条件,无if判断。最推荐的写法(初学者不易写错,且可直接由106演化而来)是写法2。效率最高的写法是写法3(只操作数组下标,而不新建数组,也不操作数组中的元素)。我将在下面附上写法2的代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    class Solution {
    public:
    TreeNode* construct(vector<int>& nums) {
    // 终止条件
    if (nums.size() == 0)
    return NULL;

    // 找到root的值和下标
    int max = 0, index = 0;
    for (int i = 0; i < nums.size(); i ++ )
    if (nums[i] > max)
    {
    max = nums[i];
    index = i;
    }
    TreeNode* root = new TreeNode(max);

    vector<int> l(nums.begin(), nums.begin() + index); // 左区间
    vector<int> r(nums.begin() + index + 1, nums.end()); // 右区间
    root->left = construct(l); // 递归构造左子树
    root->right = construct(r); // 递归构造右子树

    return root;
    }

    TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
    if (nums.size() == 0) return NULL;
    else return construct(nums);
    }
    };
  5. 本题的优化写法是只操作下标,不新建数组,也不操作数组中元素的值。这样可以降低时间和空间复杂度(我感觉时间复杂度基本没变,但空间复杂度由于不需要新建数组,大大降低了)。注意区间始终要保持左闭右开(基于循环不变量原则)。

617.合并二叉树

  1. 本题虽然涉及到同时操作两棵二叉树,但代码的思路和实现都非常简单。
  2. 本题的递归写法的思路:
    • 终止条件:若一棵树的root节点为空,则返回另一棵树的root节点
    • 单层递归:前序遍历。先合并中节点的值,然后再递归处理左子树和右子树。在树1的基础上,将树1修改为合并后的树。也可定义一棵新的树作为合并后的树。
  3. 本题的迭代写法较为复杂,没必要掌握。

700.二叉搜索树中的搜索

  1. 通过本题可以了解二叉搜索树的性质:其根节点要比左子树里所有节点的数值大,根节点要比右子树里所有节点的数值小。同理,左右子树也符合这个规则。二叉搜索树的上述规则确定了遍历顺序(既不是前序,也不是中序、后序),而二叉搜素树的上述规则也让本题的迭代写法特别简单。
  2. 我在初次尝试中的递归写法和迭代写法并没有利用二叉搜索树的性质,因此代码较长且运行效率较低,相当于初次尝试中的代码是执行了在一般的二叉树中搜索节点的过程。而在实现中卡尔的递归写法和迭代写法则充分利用了二叉搜索树的性质,因此代码较为简洁且运行效率高。
  3. 本题的迭代写法尤其简单,原因是:一般二叉树遍历的迭代法,需要使用栈来模拟深度遍历,使用队列来模拟广度遍历。但对于二叉搜索树,因为二叉搜索树的特殊性,也就是节点的有序性,可以不使用辅助栈或者队列就可以写出迭代法。对于一般二叉树,递归过程中还有回溯的过程,例如走一个左方向的分支走到头了,那么要调头,在走右分支。而对于二叉搜索树,不需要回溯的过程,因为节点的有序性就帮我们确定了搜索的方向。
  4. 本题推荐的写法是卡尔的写法,因为充分利用了二叉搜索树的性质。但如果想不起来卡尔的写法,或者题目中的二叉树并非二叉搜索树,而是一般的二叉树,我在初次尝试中的两种写法不失为一种具有普适性的写法。

98.验证二叉搜索树

  1. 本题的递归解法有三个版本,分别是直接判断中序遍历得到的数组是否递增、maxvalue法和双指针法。其中最简单最直观的方法是方法1,但其也是效率最低的。因此一定要掌握方法1,接着尽力掌握方法3,最后掌握方法2即可。
  2. 本题利用的基本原理:中序遍历一棵二叉搜索树,得到的数组中的元素是有序(递增)的
  3. maxvalue法和双指针法本质都是在中序遍历二叉树的过程中,不用额外的数组去存储二叉树中的所有元素,而是直接判断当前节点是否大于前一个节点。但maxvalue法具有maxvalue的初始值必须小于节点的最小值的问题,因此若节点的最小值可以取到long long的最小值,则maxvalue的初始值无法确定(cpp不提供超出long long范围的整数类型)。因此双指针法更为完美
  4. maxvalue法和双指针法在代码实现上略有区别。二者的单层递归逻辑都是中序遍历,但在对中节点的处理逻辑上有差异。maxvalue法是当元素递增时,更新maxvalue的值,否则返回false。双指针法则是当元素不是递增时,返回false,否则更新pre指针。
  5. 本题的迭代解法要用到栈,代码也比较复杂,不要求掌握。

链接

513.找树左下角的值
112. 路径总和,113. 路径总和ii
106.从中序与后序遍历序列构造二叉树,105.从前序与中序遍历序列构造二叉树

知识

106.从中序与后序遍历序列构造二叉树

vector<int> lm(inorder.begin(), inorder.begin() + i);创建了一个左闭右开的区间。在C++中,std::vector的这个构造函数接受两个迭代器作为参数,分别表示要复制的范围的开始和结束。这个范围遵循左闭右开的原则,即包括开始位置的元素,但不包括结束位置的元素。

注意:不可以写作vector<int> lm(0, i)。传入数字的功效和传入迭代器的功效是完全不同的,传入数字的写法无法运行出正确的结果。只有传入两个迭代器的写法是表示创建了一个左闭右开的区间。

初次尝试

513.找树左下角的值

拿到本题后,我先复习了层序遍历的代码,然后先拿层序遍历法尝试一下。本题果然可以简单地用层序遍历解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
int findBottomLeftValue(TreeNode* root) {
queue<TreeNode*> q;
vector<vector<int>> res;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
vector<int> vec;

while (size -- )
{
TreeNode* node = q.front();
q.pop();
vec.push_back(node->val);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res.push_back(vec);
}
// 下面两行代码可以简写为return res.back().front();
vector<int> ans = res.back();
return ans.front();
}
};

现在尝试用递归做本题。首先需要考虑前/中/后序遍历。发现确实不好写,不知道该采用怎样的遍历顺序,且终止条件也不知道该怎么写,直接看卡尔的讲解。

112. 路径总和

本题用层序遍历肯定不好做,因为涉及到从root节点到叶子节点。我尝试用递归解决本题。本题肯定涉及到回溯,搜索完一条root节点到叶子节点的和之后,若没找到targetSum,还需要返回root节点,寻找到新的叶子节点的路径。我不会做此题,直接看卡尔的讲解。

113. 路径总和ii

本题我尝试用112的方法独立完成,但是没有成功,其实我写的代码离能成功运行的代码已经很接近了,思路没错就是部分细节不对。本题的方法应该和112相同,但代码实现上会复杂一些。

106.从中序与后序遍历序列构造二叉树

本题是二叉树中的难题,我没有什么思路,直接来看卡尔的视频。

105.从前序与中序遍历序列构造二叉树

本题的核心思路和106完全一致,我独立写出了本题的代码。

实现

513.找树左下角的值

树左下角的值:树的最后一行最靠左侧的值(不一定是左孩子)。本题用层序遍历(迭代法)最合适,目标值就是最后一层的第一个值。本题用迭代法比用递归法更简单直观。现在主要讲解递归法。

如何用递归法找到树左下角的值?深度最大的叶子节点一定在最后一行。因为本题不需要处理中节点,只需要先遍历左节点即可,因此本题使用前中后序遍历都可只要保证先遍历左节点,且得到的是深度最大的节点,找到的就是最后一行最靠左侧的节点。现在开始写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 记录二叉树最大深度的全局变量
int maxDepth = INT_MIN; // 初始化为int中的最小值
int res; // 若当前深度大于maxDepth,则更新res为当前节点的值

// depth用于记录当前遍历的节点的深度
void traversal(TreeNode* root, int depth)
{
// 终止条件:遍历到叶子节点
if (root->left == NULL && root->right == NULL)
{
// 若当前深度大于maxDepth,则更新res为当前节点的值
if (depth > maxDepth)
{
maxDepth = depth;
res = root->val;
}
}

// 单层递归的逻辑,前中后序遍历皆可,本题不需要中节点的处理逻辑
// 左节点
if (root->left)
{
depth ++ ;
traversal(root->left, depth); // 递归
depth -- ; // 回溯,即回退到左节点的父节点,然后遍历父节点的右节点,不回溯则会一直向左遍历
// 以上三行代码可以简写为traversal(root->left, depth + 1)
// 因为depth + 1并没有改变depth的值,相当于在traversal中+1,在traversal之后复原
}

// 右节点
if (root->right)
{
depth ++ ;
traversal(root->right, depth);
depth -- ;
}
}

本题的递归解法同样显示地展现了回溯。根据上述核心代码,我写出了本题递归解法的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
public:
int maxDepth = INT_MIN; // 直接int maxDepth = 0也可以
int res;

void traversal(TreeNode* root, int depth)
{
// 终止条件,遍历到叶子节点
// 若当前深度大于最大深度,则需要更新最大深度和结果
if (root->left == NULL && root->right == NULL)
{
if (depth > maxDepth)
{
res = root->val;
maxDepth = depth;
}
}

// 单层递归的逻辑
// 左节点
if (root->left)
{
depth ++ ;
traversal(root->left, depth);
depth -- ;
}

// 右节点
if (root->right)
{
depth ++ ;
traversal(root->right, depth);
depth -- ;
}
}

int findBottomLeftValue(TreeNode* root) {
traversal(root, 1); // root节点的深度一般被规定为1,当然规定为0也可以
return res;
}
};

本题的层序遍历解法其实也有更简便的写法,不需要浪费空间复杂度来存储树中的所有元素的值。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int findBottomLeftValue(TreeNode* root) {
queue<TreeNode*> q;

if (root != NULL) q.push(root);

int res;
while (q.size())
{
int size = q.size();

for (int i = 0; i < size; i ++ )
{
TreeNode* node = q.front();
q.pop();
if (i == 0) res = node->val;
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return res;
}
};

112. 路径总和

题意:有无从root节点到叶子节点的一条路径,路径上所有元素之和等于目标和。推荐使用递归法。本题使用前中后序遍历皆可,因为不涉及中节点的处理逻辑,因此中节点放在哪里都可以。规则看似简单,但代码不好写。现在开始写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 返回值为bool类型,找到一条符合条件的路径,立即从下(叶子节点)往上(根节点)返回true
// cnt:计数器,在主函数中传入目标值
// 如果传入0,从上往下遍历每遍历到一个节点就加上这个节点的值,看遍历到叶子节点时cnt是否等于target,这样代码更复杂,不如直接传入target,每次遍历到一个节点就在target中减去该节点的值,看到叶子节点时cnt是否为0
bool traversal(TreeNode* node, int cnt)
{
// 终止条件:遇到叶子节点且cnt=0
if (node->left == NULL && node->right == NULL && cnt == 0) return true;
if (node->left == NULL && node->right == NULL && cnt != 0) return false;

// 单层递归逻辑
// 左节点
if (node->left)
{
cnt -= node->left->val; // 从cnt中减去左节点的值
// 若返回true,则说明node的左孩子到叶子节点的路径中有符合题目要求的路径
// 此时目标值为cnt -= node->left->val
// 此时应该继续向上返回true,这样才能一层层将true的结果返回给根节点
if (traversal(node->left, cnt)) return true;
// 回溯,遍历到叶子节点后,需要回退到根节点去遍历一条新的路径,因此需要回溯
// 若不回溯,则cnt一直做减法,则遍历完左子树后,遍历右子树时根本不可能找到符合条件的路径
cnt += node->left->val;
}

// 右节点,逻辑同上
if (node->right)
{
cnt -= node->right->val;
if (traversal(node->right, cnt)) return true;
cnt += node->right->val;
}
// 一直不return true, 则return false
return false;
}

以上代码没有对中节点的处理逻辑,因此前中后序都可以。对左右节点的处理逻辑可以简写为一句话,我以左节点为例:if (traversal(node->left, cnt -= node->left->val)) return true;,但不建议这样写,因为无法清晰地看到回溯的逻辑。

本题的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
bool traversal(TreeNode* node, int cnt)
{
// 终止条件:遍历到叶子节点
if (node->left == NULL && node->right == NULL && cnt == 0)
return true;
// if (node->left == NULL && node->right == NULL && cnt != 0)
// return false;

// 左节点
if (node->left)
{
cnt -= node->left->val;
if (traversal(node->left, cnt)) return true;
cnt += node->left->val;
}

// 右节点
if (node->right)
{
cnt -= node->right->val;
if (traversal(node->right, cnt)) return true;
cnt += node->right->val;
}
return false;
}
bool hasPathSum(TreeNode* root, int targetSum) {
if (root == NULL) return false;
return traversal(root, targetSum - root->val);
}
};

113. 路径总和ii

本题由于要遍历整棵树,找到所有路径,因此递归函数不要返回值,遍历完整棵树后,在函数外定义好的 pathres中自然会被递归函数写入应有的结果。如果只是要找树中符合条件的一条路径,那么递归函数就要返回值,一旦遍历到叶子节点时找到了合适的路径,就立即返回true,要是一直找不到符合条件的路径,再返回false。

本题的代码相较于上一题要复杂一些,但核心思路其实是相同的。以下几点是与112的主要差异。

  • 首先定义一个 vector<int> path来存放一条符合条件的路径,再定义一个 vector<vector<int>> res来存放树中所有符合条件的路径。
  • 在针对左右节点的操作中,需要回溯,但与112不同的是,本题中的回溯不仅需要回溯 cnt,还需要回溯 path中的元素(先将左右节点放入path中,回溯时又从path中弹出左右节点)。
  • 由于本题的递归函数没有返回值,因此只需要返回空即可,即 return;
  • 本题需要在主函数中在path中事先插入根节点的值,作为启动的引线(可以想想极端情况,树中只有一个节点,即根节点,如果不在path中事先插入根节点的值,那么即使根节点的值等于 targetSum,res中也不会存有根节点的值)。

本题的具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

// 本题的递归函数不需要返回值
void traversal(TreeNode* node, int cnt)
{
if (node->right == NULL && node->left == NULL && cnt == 0)
{
res.push_back(path);
return;
}

if (node->right == NULL && node->left == NULL) return;

// 左节点
if (node->left)
{
path.push_back(node->left->val);
cnt -= node->left->val;
traversal(node->left, cnt); // 递归
path.pop_back(); // 回溯
cnt += node->left->val; // 回溯
}

// 右节点
if (node->right)
{
path.push_back(node->right->val);
cnt -= node->right->val;
traversal(node->right, cnt); // 递归
path.pop_back(); // 回溯
cnt += node->right->val; // 回溯
}
return;
}
vector<vector<int>> pathSum(TreeNode* root, int targetSum) {
if (root == NULL) return res;
path.push_back(root->val); // 事先把根节点放进路径

traversal(root, targetSum - root->val);
return res;
}
};

106.从中序与后序遍历序列构造二叉树

任务:从中序和后序数组中构造出一棵唯一的二叉树。思路:如何用两个数组构造二叉树?
中序:左中右,例:9 3 15 20 7
后序:左右中,例:9 15 7 20 3
由于需要确定根节点,根节点是中节点,而根据中序数组无法确定出根节点的位置,这时就需要后序数组。后序数组的最后一个元素一定是中节点,也就是根节点。以上述两数组为例,根节点就是3。

1
2
graph TD;
A[3]

再看中序数组,root是3,则左节点是9,右节点是15,20,7。这一步操作就是利用后序数组中找到的root节点在中序数组中完成切割的任务。

1
2
3
graph TD;
A[3] --> B[9];
A --> C[x];

再利用中序数组中切割出的两部分在后序数组中完成切割。由上一步知,左节点是9,右节点是15,20,7。结合后序数组知,右节点是15,7,20。右节点中最后一个元素是20,因此20是右子树的中节点。

1
2
3
graph TD;
A[3] --> B[9];
A --> C[20];

再利用后序数组去切割中序数组。20是右子树的中节点,中序数组中的右区间为15,20,7,因此左节点是15,右节点是7。

1
2
3
4
5
graph TD;
A[3] --> B[9];
A --> C[20];
C --> F[15];
C --> G[7];

关键:先通过后序数组找到root节点,再利用root节点切割中序数组,再利用中序数组切割出的左右区间切割后序数组,交替进行。可以具体为以下六步:

  1. 后序数组为空,说明无root节点,返回空的树
  2. 后序数组最后一个元素作为root
  3. 寻找中序数组中root的位置作为切割点
  4. 切割中序数组,切为左右区间
  5. 根据切割中序数组得到的左右区间切割后序数组
  6. 递归处理,构造下一层子树

现在开始写本题的伪代码(注重整体思路而非细节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 确定递归函数的参数和返回值
// 返回值:二叉树的根节点
TreeNode* traversal(vector<int> inorder, vector<int> postorder)
{
// 终止条件:后序数组为空
if (postorder.size() == 0) return NULL;

// 后序数组的最后一个元素为root节点的值
int rootvalue = postorder[postorder.size() - 1];
TreeNode* root = new TreeNode(rootvalue);

// 优化:若postorder中只有一个元素,则该元素必为叶子节点(root节点也是叶子节点)
if (postorder.size() == 1) return root;

// 单层递归的逻辑
// 寻找中序数组中root的位置作为切割点
int i;
// 返回中序数组中root的index
for (i = 0; i < inorder.size(); i ++ )
{
if (inorder[i] == rootvalue)
break;
}

// 根据root切中序数组,得到一个左中序数组和一个右中序数组
// 切后序数组,拿上一步得到的左中序数组来切出后序数组的左区间和右区间,得到一个左后序数组和一个右后序数组
// 递归处理左区间和右区间,即递归构建root的左右子树
root->left = traversal(左中序, 左后序);
root->right = traversal(右中序, 右后序);
return root;
}

注意:

  • 切中序数组和切后序数组时需要有统一的区间定义。要么都坚持左闭右开,要么都坚持左闭右闭。
  • 先切中序数组,再切后序数组。因为中序数组中的左区间和后序数组中的左区间一定相同。
  • 建议debug时打印切中序数组得到的左中序和右中序数组,打印切后序数组的左后序和右后序数组。

中序和前序数组也可以唯一地确定一棵二叉树。前序数组是中左右,拿着前序数组的第一个元素,即为root元素,拿去切中序数组。剩下的思路是相同的。

中序和后序数组可以唯一地确定二叉树,中序和前序数组也可以唯一地确定二叉树,但后序和前序数组不能唯一地确定二叉树。原因:前序数组和后序数组都可以直接知道root在哪里,但前序和后序数组的左右区间的分割点我们找不到。中序数组的重要性在于其把左右区间给分隔开了。举例:

1
2
3
4
5
graph TD;
A[1] --> B[2];
B[2] --> C[3]
B[2] --> E[null]
A --> D[null];

对以上二叉树,前序数组:123,后序数组:321

1
2
3
4
5
graph TD;
A[1] --> B[null];
A[1] --> C[2]
C[2] --> D[null]
C[2] --> E[3]

对以上二叉树,前序数组:123,后序数组:321
虽然两棵二叉树的前序数组和后序数组完全一致,但这两棵二叉树完全不同,因此仅靠前序和后序数组不能唯一地确定二叉树

本题是二叉树中的难题、复杂题,需要经常复习。以上笔记cover了易错点。

根据上述原理,我写出了以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Solution {
public:
TreeNode* traversal(vector<int>& inorder, vector<int>& postorder)
{
if (postorder.size() == 0) return NULL;

int rootvalue = postorder[postorder.size() - 1];
TreeNode* root = new TreeNode(rootvalue);
if (postorder.size() == 1) return root;

// 找到inorder中root的下标
int i;
for (i = 0; i < inorder.size(); i ++ )
{
if (inorder[i] == rootvalue)
break;
}

// 将inorder根据i分为左中数组和右中数组
vector<int> lm;
vector<int> rm;
for (int j = 0; j < i; j ++ )
{
lm.push_back(inorder[j]);
cout << inorder[j] << ' '; // 打印lm数组中的所有元素
}
cout << endl;
for (int j = i + 1; j < inorder.size(); j ++ )
{
rm.push_back(inorder[j]);
cout << inorder[j] << ' '; // 打印rm数组中的所有元素
}
cout << endl;

postorder.resize(postorder.size() - 1);

// 将postorder根据上一步的结果分为左后数组和右后数组
vector<int> lb;
vector<int> rb;
for (int l = 0; l < lm.size(); l ++ )
{
lb.push_back(postorder[l]);
cout << postorder[l] << ' '; // 打印lb数组中的所有元素
}
cout << endl;
for (int l = lm.size(); l < postorder.size(); l ++ )
{
rb.push_back(postorder[l]);
cout << postorder[l] << ' '; // 打印rb数组中的所有元素
}
cout << endl;

root->left = traversal(lm, lb);
root->right = traversal(rm, rb);
return root;
}

TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
if (inorder.size() == 0 || postorder.size() == 0) return NULL;
return traversal(inorder, postorder);
}
};

该代码可以通过201 / 202个测试样例,但有一个测试样例超时了,这就需要对上述代码进行优化。写出上述代码时,我犯了一个错误。在将 postorder根据 inorder的结果分为左后数组和右后数组的过程中,我的第一版代码采用了以下的写法(逻辑:把左中数组的最后一个元素作为分界点,在后序数组中查询之,并以此分界点将后序数组划分为左后数组和右后数组):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<int> lb;
vector<int> rb;
int value = lm[lm.size() - 1];
int k;
for (k = 0; k < lm.size(); k ++ )
{
if (postorder[k] == value)
break;
}
//cout << "k=" << k << endl;
for (int l = 0; l < k; l ++ )
{
lb.push_back(postorder[l]);
cout << postorder[l] << ' ';
}
cout << endl;
for (int l = k + 1; l < postorder.size(); l ++ )
{
rb.push_back(postorder[l]);
cout << postorder[l] << ' ';
}
cout << endl;

当左中序数组 lm为空时,int value = lm[lm.size() - 1];会访问 lm[-1]。该元素显然不存在,故会报错:index error。因此不能这样写,应该采用按照左中序数组的元素数量分割后序数组的写法。

我对上述代码进行简易的优化后(将resize函数改为pop_back函数,时间复杂度略微降低),就可以通过本题,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {
public:
TreeNode* traversal(vector<int>& inorder, vector<int>& postorder)
{
if (postorder.size() == 0) return NULL;

int rootvalue = postorder[postorder.size() - 1];
TreeNode* root = new TreeNode(rootvalue);
if (postorder.size() == 1) return root;

int i;
for (i = 0; i < inorder.size(); i ++ )
{
if (inorder[i] == rootvalue)
break;
}
vector<int> lm, rm;
for (int j = 0; j < i; j ++ ) lm.push_back(inorder[j]);
for (int j = i + 1; j < inorder.size(); j ++ ) rm.push_back(inorder[j]);

postorder.pop_back();

vector<int> lb, rb;
for (int j = 0; j < lm.size(); j ++ ) lb.push_back(postorder[j]);
for (int j = lm.size(); j < postorder.size(); j ++ ) rb.push_back(postorder[j]);


root->left = traversal(lm, lb);
root->right = traversal(rm, rb);

return root;
}

TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
if (inorder.size() == 0 || postorder.size() == 0) return NULL;
return traversal(inorder, postorder);
}
};

当然,还有进一步优化的空间,代码也可以写得更为简洁。我写下了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
TreeNode* traversal(vector<int>& inorder, vector<int>& postorder)
{
// 终止条件1:postorder中为空
if (postorder.size() == 0) return NULL;

// 终止条件2:postorder中只有一个元素,即为root
int rootvalue = postorder[postorder.size() - 1];
TreeNode* root = new TreeNode(rootvalue);
if (postorder.size() == 1) return root;

// postorder中的最后一个元素为root,在inorder中找到root的下标
int i;
for (i = 0; i < inorder.size(); i ++ )
{
if (inorder[i] == rootvalue)
break;
}

// 将inorder以root作为分割点分割为左中序数组和右中序数组
vector<int> lm(inorder.begin(), inorder.begin() + i); // 左中序数组
vector<int> rm(inorder.begin() + i + 1, inorder.end()); // 右中序数组

// 删去postorder的最后一个元素(即root)
// 也可写作postorder.resize(postorder.size() - 1);
postorder.pop_back();

// 将postorder以上一步分割得到的左中序数组分割为左后序数组和右后序数组
vector<int> lb(postorder.begin(), postorder.begin() + lm.size()); // 左后序数组
vector<int> rb(postorder.begin() + lm.size(), postorder.end()); // 右后序数组

// 递归构建root的左子树和右子树
root->left = traversal(lm, lb); // 构建左子树,传入的参数为左中序数组和左后序数组
root->right = traversal(rm, rb); // 构建右子树,传入的参数为右中序数组和右后序数组

return root;
}

TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
// 中序数组或后序数组为空,则树不存在,返回空
if (inorder.size() == 0 || postorder.size() == 0) return NULL;
return traversal(inorder, postorder);
}
};

代码的运行时间基本可以减半,我觉得主要原因是 vector<int> lm(inorder.begin(), inorder.begin() + i);这种写法的时间复杂度要优于遍历数组然后赋值。

本题的下标索引写法(能大大降低空间复杂度,不需要每层递归产生新的数组,只需要对索引重新赋值即可,本写法的代码逻辑和上面的写法完全一致):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Solution {
public:
TreeNode* traversal(vector<int>& inorder, vector<int>& postorder, int inbegin, int inend, int postbegin, int postend)
{
if (postend == postbegin) return NULL;

int rootvalue = postorder[postend - 1];
TreeNode* root = new TreeNode(rootvalue);
if (postend - postbegin == 1) return root;

// inorder中找到root
int i;
for (i = inbegin; i < inend; i ++ )
{
if (inorder[i] == rootvalue)
break;
}

// root分割inorder为左中序数组和右中序数组
int leftinorderbegin = inbegin;
int leftinorderend = i;
int rightinorderbegin = i + 1;
int rightinorderend = inend;

// 后序数组删去root
postend -- ;

// 将postorder分割为左后序数组和右后序数组
int leftpostorderbegin = postbegin;
int leftpostorderend = postbegin + leftinorderend - leftinorderbegin;
int rightpostorderbegin = postbegin + leftinorderend - leftinorderbegin;
int rightpostorderend = postend;

root->left = traversal(inorder, postorder, leftinorderbegin, leftinorderend, leftpostorderbegin, leftpostorderend);
root->right = traversal(inorder, postorder, rightinorderbegin, rightinorderend, rightpostorderbegin, rightpostorderend);

return root;
}

TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
if (inorder.size() == 0 || postorder.size() == 0) return NULL;
return traversal(inorder, postorder, 0, inorder.size(), 0, postorder.size());
}
};

105.从前序与中序遍历序列构造二叉树

本题的代码思路和上题完全一致,我独立一遍写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 本题类似于106:利用中序数组和后序数组构造二叉树
// 本题关键思路:前序数组第一位是root
// 在inorder中查找root
// 以root作为分割点,将inorder分为左中序数组和右中序数组
// 根据上一步结果,将preorder分为左前序数组和右前序数组
// 递归处理左右子树
class Solution {
public:
TreeNode* traversal(vector<int>& preorder, vector<int>& inorder)
{
if (preorder.size() == 0) return NULL;

int rootvalue = preorder[0];
TreeNode* root = new TreeNode(rootvalue);
if (preorder.size() == 1) return root;

// 在inorder中查找root
int i;
for (i = 0; i < inorder.size(); i ++ )
{
if (inorder[i] == rootvalue)
break;
}

// 以root作为分割点,将inorder分为左中序数组和右中序数组
vector<int> lm(inorder.begin(), inorder.begin() + i);
vector<int> rm(inorder.begin() + i + 1, inorder.end());

// 删去preorder的头元素(即root元素)
reverse(preorder.begin(), preorder.end());
preorder.pop_back();
reverse(preorder.begin(), preorder.end());

// 根据上一步结果,将preorder分为左前序数组和右前序数组
vector<int> lf(preorder.begin(), preorder.begin() + lm.size());
vector<int> rf(preorder.begin() + lm.size(), preorder.end());

// 递归处理左右子树
root->left = traversal(lf, lm);
root->right = traversal(rf, rm);

return root;
}

TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
if (preorder.size() == 0 || inorder.size() == 0) return NULL;
return traversal(preorder, inorder);
}
};

本题的下标索引写法如下所示。用下标索引写法,本题删去前序数组中的第一个元素(root)就非常简单,只需要 prebegin ++即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
TreeNode* traversal(vector<int>& preorder, vector<int>& inorder, int prebegin, int preend, int inbegin, int inend)
{
if (prebegin == preend) return NULL;

int rootvalue = preorder[prebegin];
TreeNode* root = new TreeNode(rootvalue);
if (prebegin - preend == 1) return root;

// inorder中找root
int i;
for (i = inbegin; i < inend; i ++ )
{
if (inorder[i] == rootvalue)
break;
}

// 用root划分inorder
int leftinbegin = inbegin;
int leftinend = i;
int rightinbegin = i + 1;
int rightinend = inend;

// preorder中删去root
prebegin ++ ;

// 用划分inorder的结果划分preorder
int leftprebegin = prebegin;
int leftpreend = prebegin + leftinend - leftinbegin;
int rightprebegin = prebegin + leftinend - leftinbegin;
int rightpreend = preend;

root->left = traversal(preorder, inorder, leftprebegin, leftpreend, leftinbegin, leftinend);
root->right = traversal(preorder, inorder, rightprebegin, rightpreend, rightinbegin, rightinend);
return root;
}

TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
if (preorder.size() == 0 || inorder.size() == 0) return NULL;
return traversal(preorder, inorder, 0, preorder.size(), 0, inorder.size());
}
};

心得与备忘录

513.找树左下角的值

  1. 本题用层序遍历法解决更简单,用递归法解决更麻烦。
  2. 本题的层序遍历解法不需要浪费空间复杂度来存储整棵二叉树中的元素值或者二叉树中某一层的元素值。直接存储每一层最左侧元素的值,然后一层层从上往下遍历即可,最后自然会更新为树左下角的值。
  3. 本题的递归解法的核心在于:保证优先左边搜索,然后记录深度最大的叶子节点,此时得到的节点就是树的最后一行最左边的节点。
  4. 递归三部曲:

    • 确定函数的返回值和传入的参数
      本题可以用全局变量记录树的最大深度和结果,这样traversal函数就不需要返回值,只需要在函数中更新记录结果的全局变量即可。传入的参数为根节点和当前节点的深度。
    • 确定终止条件
      当遍历到叶子节点时,终止。若叶子节点的深度大于最大深度,则更新最大深度和结果。
    • 单层递归逻辑
      前中后序遍历都可以,只需要保证优先遍历左节点即可。注意要显式地回溯,这样遍历完父节点的左节点后才能回到父节点,然后接着遍历父节点的右节点。否则会一直向左遍历。

112. 路径总和

  1. 本题推荐用递归解,因为不需要处理中节点,所以前中后序遍历都可。
  2. 本题的递归函数的返回值为bool类型。因为一旦找到一条符合条件的路径,要立即从下(叶子节点)往上(根节点)返回true,因此递归函数需要返回值。
  3. 本题传入递归函数的参数除去node外,还需要传入cnt。cnt直接是targetSum(确切来说是 targetSum - root->val;),遍历到一个节点就减去该节点的值(从根节点开始遍历,因此传入时就要减去根节点的值),观察遍历到叶子节点时cnt是否为0即可。
  4. 递归三部曲:

    • 确定函数的返回值和传入的参数:返回值bool,传入的参数node和cnt
    • 确定终止条件:遍历到叶子节点且cnt == 0,则返回true
    • 单层递归逻辑

      先处理左节点,首先cnt值减去左孩子的值,然后递归判断左孩子到叶子节点的路径中是否有满足条件的路径,若有,则需要从下往上返回true。然后再回溯,将减去的cnt值加回来。若不回溯,cnt就一直减小,导致右孩子到叶子节点的路径中不可能有满足条件的路径。对右节点的处理也是类似的。

  5. 本题需要特别注意:调用递归函数时,传入的参数是 targetSum - root->val,而不是 targetSum。原因是在递归函数的终止条件中,若二叉树中只有root节点不为NULL,且cnt为零时,就返回true,因此本处的cnt应该已经将root节点的值排除在外了。若cnt不把root节点的值排除在外,则在二叉树只有root节点的情况下,永远不可能返回true,因为cnt不可能为0。

113. 路径总和ii

  1. 本题和112的基本思路完全一致,但应当关注和112的差异,主要有以下5点差异。
  2. 本题需要在函数外先定义一个 vector<int> path来存放一条符合条件的路径,再定义一个 vector<vector<int>> res来存放树中所有符合条件的路径。
  3. 本题的递归函数不需要返回值,112的递归函数需要返回值。具体原因见本题的实现部分。
  4. 由于本题的递归函数不需要返回值,因此本题的递归函数在返回时只需要写 return;
  5. 在处理左右节点的逻辑中,需要对 cnt操作并将左右节点的值放入 path中,然后再调用递归函数。因此在回溯时,也需要同时恢复 cnt的值和 path数组。
  6. 本题需要在主函数中在path中事先插入根节点的值,作为启动的引线。

106.从中序与后序遍历序列构造二叉树

  1. 本题的具体操作过程可以举一个具体的例子来展现,参见本题的实现部分。

  2. 本题的核心思路在于以下五步:

    • 根据后序数组找到root(后序数组中的最后一位)
    • 在中序数组中查找root的下标
    • 以root为分割点,将中序数组分割为左中序数组和右中序数组
    • 在后序数组中找到与左中序数组相同的一段,即为左后序数组,剩下的后序数组为右后序数组
    • 递归构建root的左右子树(根据左右中序数组和左右后序数组)
  3. 本题的调试方法:打印出左右中序数组和左右后序数组,观察它们是否符合预期

  4. 中序和后序数组可以唯一地确定一棵二叉树,前序和中序数组也可以唯一地确定一棵二叉树,但前序和后序数组不可以唯一地确定一棵二叉树,我在实现部分举出了反例。原因:前序数组和后序数组都可以直接知道root在哪里,但前序和后序数组的左右区间的分割点我们找不到。中序数组的重要性在于其把左右区间给分隔开了。

  5. 本题和下一题的更佳写法是下标索引写法,即每次用下标索引来分割。虽然时间复杂度基本保持不变,但空间复杂度相比于传统写法大大降低了,因为不需要每层递归产生新的数组,只需要对索引重新赋值即可。

  6. 下标索引写法挺容易出错的,特别是在确定左右区间的边界上,搞不定的话就采用简单直接的普通写法。

  7. 我写的下标索引写法,还可以做进一步优化,若将划分postorder的代码:

    1
    2
    3
    4
    int leftpostorderbegin = postbegin;
    int leftpostorderend = postbegin + leftinorderend - leftinorderbegin;
    int rightpostorderbegin = postbegin + leftinorderend - leftinorderbegin;
    int rightpostorderend = postend;

    直接写作:

    1
    2
    3
    4
    int leftpostorderbegin = postbegin;
    int leftpostorderend = postbegin + i - inbegin;
    int rightpostorderbegin = postbegin + i - inbegin;
    int rightpostorderend = postend - 1;

    即将

    1
    2
    3
    4
    5
    postend -- ;
    int leftinorderbegin = inbegin;
    int leftinorderend = i;
    int rightinorderbegin = i + 1;
    int rightinorderend = inend;

    直接代入划分postorder的代码中,时间复杂度可以进一步降低。对于106题的下标索引写法也是如此。

105.从前序与中序遍历序列构造二叉树

  1. 本题的核心思路和上题完全相同,也是在于以下五步:

    • 前序数组第一位是root
    • 在inorder中查找root
    • 以root作为分割点,将inorder分为左中序数组和右中序数组
    • 根据上一步结果,将preorder分为左前序数组和右前序数组
    • 递归处理左右子树
  2. 本题和上题的不同之处在于,上题在分割出左右中序数组后,需要删去后序数组的最后一个元素,即root元素。本题则是在分割出左右中序数组后,需要删去前序数组的第一个元素,即root元素。删去数组的最后一个元素的操作相对简单,只需要 pop_back即可,但删去数组的第一个元素的操作相对复杂。我想到的办法是翻转数组,删去数组最后一个元素,再翻转数组。
  3. 用下标索引写法,本题删去前序数组中的第一个元素(root)就非常简单,只需要 prebegin ++即可。

技术栈

  • Frontend: React
  • Server-side Rendering: Next.js
  • Styling: Tailwind CSS
  • Data abstraction layer: Prisma
  • Storage: MongoDB
  • Authentication: NextAuth
  • Deploy: Vercel
  • Typescript
  • the entire website fully

The entire website fully responsive across all devices.

实现的功能

  • credential login: username + password
  • profile: automatically generated once we register
  • homepage: loaded with a random movie-billboard
  • movies: load from database
  • favourites: add movies as favourites
  • botton: shows more information about the movie
  • play the movie
  • Google and GitHub oauth login

精简笔记与全栈开发的一般流程

登录页面

先在pages中创建auth.tsx,然后在components中创建Input.tsx,将其加入到auth.tsx中。实现next auth时需要创建pages/api/[…nextauth].ts文件。

通过授权登录保护登录界面以外的路径

保护路由:

  • serverAuth.ts(lib):利用 next-auth 的 getSession 实现API路由的用户登录检查,防止未登录用户访问。
  • current.ts(pages/api):API路由,使用 serverAuth.ts 验证用户登录,返回用户信息或错误。

前端用户信息获取:

  • fetcher.ts(lib):通过 axios 封装的简易数据获取函数。
  • useCurrentUser.ts(hooks):自定义Hook,结合 swr 和 fetcher.ts 从 /api/current 获取当前登录用户信息。

客户端路由保护与个人资料页面:

  • index.tsx 和 profiles.tsx(pages):通过 getServerSideProps 中的 getSession 实现路由保护,控制访问权限。
  • 页面重定向:auth.tsx 调整登录后重定向逻辑,确保正确导向 /profiles。

实现细节:

  • 用户状态展示:在 profiles.tsx 中展示用户信息,并实现点击默认头像返回首页的功能。
  • 环境配置调整:通过修改 .env 和 package.json 解决开发中遇到的端口配置问题,保证登录流程正常。

导航组件

在这个项目中,开发了一个导航组件(Navigation Component),涉及了几个主要的文件和它们之间的关系,以及各自的功能和作用:

  1. index.tsx(主页文件):这是应用的入口页面,最初被清理以只保留基本结构。它通过 getServerSideProps 功能检查用户的会话,基于会话存在与否决定重定向到登录页面或显示主内容。后续,Navbar 组件被引入到此文件中,作为页面的一部分。

  2. Navbar.tsx(导航栏组件):位于 components 文件夹内,Navbar 是顶部的导航条组件,负责显示应用的导航链接。它包括了一个动态背景变化的功能,随着页面滚动,背景从透明变为不透明。

  3. NavbarItem.tsx(导航项组件):同样位于 components 文件夹内,用于表示 Navbar 中的单个导航链接。它通过 props 接收 label 来显示不同的导航项。

  4. MobileMenu.tsx(移动菜单组件):这个组件负责在小屏幕上显示一个可展开的移动菜单。它通过 visible prop 控制显示状态,包含多个 NavbarItem 组件来展示导航选项。

  5. AccountMenu.tsx(账户菜单组件):用于在 Navbar 组件中显示用户的账户菜单,它提供了注销功能并可以通过 visible prop 控制其显示。

项目中还实现了一些额外的交互特性,比如:

  • 使用 React 的 useState 和 useEffect Hooks 来管理组件的状态和生命周期,例如控制菜单的显示状态和根据页面滚动动态改变导航栏背景。
  • 通过 useCallback 来优化事件处理函数,避免不必要的重新渲染。
  • 导航组件和移动菜单的显示逻辑,包括在小屏幕上通过点击“浏览”来展开和隐藏导航项。
  • 在 Navbar 组件中,引入了 react-icons 库中的图标来增强视觉效果,并通过条件渲染实现了箭头图标的旋转动画,以指示菜单的展开和收起状态。

整体而言,这个导航组件通过组合多个子组件和利用 React 的特性,实现了一个响应式、具有交互性的用户界面,能够适应不同的设备和屏幕尺寸。

广告牌组件

首先将定义有电影信息的json文件手动添加到MongoDB中。
然后创建新的api:random,用于随机返回一部电影。
hooks/useBillboard.ts中写代码以避免对首页推荐电影的重复加载。在components中新建Billboard.tsx,然后在index.tsx中引入Billboard
接着在Billboard.tsx中填入具体的内容,目的是fetch the data for a random movie,并继续加入电影、电影名、电影介绍和More info按钮。

电影列表和电影卡片组件

定义api: pages/api/movies/index.ts,加载所有电影。
接着在hooks文件夹中创建useMovieList.ts,用于返回api/movies中得到的数据。
接着在components中创建MovieList.tsx,并在pages/index.tsx中装入MovieList并传入必要的参数,并使用上面定义的hook: useMovieList.ts
接着在components文件夹中创建MovieCard.tsx文件,用于实现电影卡片组件,并将MovieCard放入MovieList.tsx中。

收藏功能

定义api: pages/api/favorite.ts,用于实现用户在收藏和取消收藏电影时对数据库的操作。
再定义一个api: pages/api/favorites.ts,用于加载我们收藏的电影列表。
接着写一个hook: useFavorites.ts,用于调用第二个api从而加载我们收藏的电影列表。
再写一个组件components/FavoriteButton.tsx,作为收藏按钮。
将该按钮加入MovieCard中。然后在Trending Now列表以外再创建一个My Favorites列表,这是在pages/index.tsx中实现的。
最后让favorite按钮变得可交互。这样在Trending Now列表上的电影上的加号时,其就会被添加到My List,然后加号会变成勾。这样一部电影就被收藏了。取消收藏也是同理。

电影播放功能

首先定义api: 创建pages/api/movies/[movieId].ts,用于通过外部传入的movieId找到电影。
再创建hooks/useMovie.ts,调用上述api,并负责给上述api传入参数movieId。
接着写播放按钮:components/PlayButton.tsx,并在components/Billboard.tsx中加入播放按钮。
现在实现了点击播放按钮,跳转到另一个页面的功能,接着在MovieCard组件中也实现这个功能。
然后具体写跳转到的/watch页面,创建pages/watch/[movieId].tsx,并在这个页面中实现加载视频名字、返回等功能。

电影详细信息功能

实现的功能:点击More Info按钮,会显示电影的信息。
创建用于状态管理(打开或关闭More Info)的钩子:hooks/useInfoModel.ts
创建显示电影详细信息的组件:components/InfoModal.tsx
在pages/index.tsx中加入上述组件。
给InfoModal组件加上关闭、播放、收藏按钮,最后加上电影信息等字样。
利用onClick函数实现点击关闭按钮关闭页面的功能。
pages/index.tsx中实现对组件InfoModal.tsx的触发,从而展现电影的详细信息。
components/Billboard.tsx中实现点击More Info按钮触发组件InfoModal.tsx,从而展现电影的详细信息。
在电影卡片组件中同样实现点击按钮展现电影详细信息。
修复个人profile中名字始终加载为username的问题。

给全栈项目开发新功能的一般过程

熟悉了上述精简版本的笔记后,我们可以对全栈项目(react + next.js)的开发做一些总结。

对于实现登录页面和通过授权登录保护登录界面以外的路径,这两项属于偏后端的范畴,主要利用的是next.js的一些特性(特别是pages和api)。这两个任务没有什么一般性的套路,需要用到的文件夹也比较复杂,包括pages, hooks, lib等,跟着讲义一步步实现。

对于实现导航组件,这个属于偏前端的范畴,主要需要在pages中定义一个菜单界面,再在components中定义若干个组件。在这里需要注意组件的复用。组件拼凑组合起来就能实现一个网页。同时还需要注意如何实现交互特性和其他的一些细节。

对于广告牌组件、电影列表和电影卡片组件、收藏功能、电影播放功能、电影详细信息功能,这些都属于前后端交互的范畴,是有统一的开发套路的。一般来说是先定义api,再定义hook(调用api),再定义组件(调用hook获取api的数据),再将组件加入到页面(pages)中。这就是开发全栈项目的一般的新功能(非拓荒)的一般过程。

我的思考

  1. Tailwind CSS的好处:我的主要感受是不需要手写css文件,直接在classname中写内容就可以。注意使用Tailwind CSS前,需要进行必要的配置。Tailwind CSS的具体优点如下所示:

    • 快速原型开发
      Tailwind 的实用工具类使得快速原型设计变得非常简单。你可以通过组合不同的类来快速构建界面,而不需要离开 HTML 文件去编写和调试 CSS 文件,这可以显著加快开发速度。

    • 一致性和可重用性
      通过使用 Tailwind 提供的实用工具类,可以在整个项目中保持样式的一致性。由于你在不同的地方复用相同的实用工具类,这自然而然地导致了样式的可重用性和一致性。

    • 可定制和可配置
      Tailwind CSS 高度可定制。你可以根据项目的设计指南调整配置文件(如颜色、字体大小、边距等),这使得创建符合品牌指南的设计变得简单。

    • 减少 CSS 的复杂性
      由于采用实用工具类的方式,你可以避免编写过多的自定义 CSS 和处理复杂的 CSS 继承关系,这降低了代码的复杂性。

    • 响应式设计友好
      Tailwind CSS 内置了响应式设计的支持,通过简单的前缀可以轻松地实现不同屏幕尺寸的样式适配,而不需要编写额外的媒体查询。

    • 减少未使用的 CSS
      通过与 PurgeCSS 的集成,Tailwind CSS 可以在构建过程中自动移除未使用的 CSS,这意味着最终的样式表非常精简,加载时间快。

    • 总结
      尽管 Tailwind CSS 提供了诸多好处,如加速开发、提高一致性和可维护性,但它也有一定的学习曲线,尤其是对于习惯了传统 CSS 开发方式的开发者来说。此外,一些开发者可能会对在 HTML 中大量使用实用工具类表示担忧,担心这会导致 HTML 文件的可读性降低。不过,对于许多项目和团队而言,Tailwind CSS 提供的好处远远超过了这些潜在的缺点。

  2. Google oauth比较难用。在本地将项目跑起来时,Google oauth功能正常,但当我尝试在vercel上部署本项目时,Google oauth就完全无法正常使用,甚至每次产生的报错信息都不相同。与此形成鲜明对比的是,GitHub oauth比较好用,配置和更改都较为简单,且将项目部署在vercel上以后再使用GitHub oauth也不会出问题。

  3. Next.js和React各自的作用:

    React 和 Next.js 在一个项目中的共存实际上非常常见,并且它们各自扮演着互补的角色。理解它们的主要用途有助于更好地利用这两个库/框架来构建你的应用。

    React

    React 是一个用于构建用户界面的 JavaScript 库,由 Facebook 开发。它的主要特点是组件化开发和声明式编程,使得开发复杂、高性能的单页应用(SPA)变得简单。React 本身主要关注于视图层(UI),允许开发者以组件的形式构建复杂的用户界面。它并不提供诸如路由、服务器端渲染等功能,这些通常需要通过其他库或框架来实现。

    Next.js

    Next.js 是一个基于 Node.js 的框架,它为 React 应用提供了额外的结构和功能,如自动的代码分割、服务器端渲染(SSR)、静态站点生成(SSG)、基于文件的路由系统、API 路由等。Next.js 旨在解决 React 单页应用的一些限制,特别是在 SEO 和首屏加载性能方面。通过服务器端渲染,Next.js 可以提前渲染页面,使其内容能够被搜索引擎索引,同时也提升了页面加载的速度。

    它们是如何一起工作的

    • React 在项目中的角色:负责定义应用的组件结构、状态管理和用户交互逻辑。开发者会使用 React 来创建应用的各个界面组件。
    • Next.js 在项目中的角色:提供框架和额外功能,帮助这些 React 组件以更高效、优化的方式被呈现和服务。例如,Next.js 通过文件系统提供的路由功能,自动将位于 pages/ 目录下的 React 组件转换为可访问的页面

    总结

    在一个项目中,React 用来构建用户界面的组件,而 Next.js 则用来增强 React 应用,提供路由、预渲染(SSR 或 SSG)等功能,以及优化应用的性能和可访问性。Next.js 让开发者能够更专注于业务逻辑和组件本身,而不是底层的架构问题,从而简化了 React 应用的开发和部署过程。简言之,你可以将 React 视为构建应用的砖块,而 Next.js 则是将这些砖块组织起来,建造出结构化、高效、易于维护的应用的框架。我的理解:React只能做前端,而React+Next.js就可以做全栈了

  4. Prisma是一款现代化的ORM框架,它可以连接到多种数据库类型(如 PostgreSQL 、 MySQL 、 SQLite 和 SQL Server等),在本项目中我们用Prisma连接了MongoDB。在ORM的帮助下,我们不需要写SQL语句,只需要定义数据库中的数据名称和数据类型,就可以实现对数据库的各种操作。

  5. 本项目中的大多数代码都是Typescript(.ts)代码或者TypeScript JSX(.tsx)代码。前者是基于javascript开发的。TypeScript 是 JavaScript 的一个超集,这意味着它包含了 JavaScript 的所有功能,并在此基础上添加了更多的特性。后者是 TypeScript 的扩展,允许在 TypeScript 文件中使用 JSX 语法。JSX 是一种语法糖,允许开发者在 JavaScript 代码中写像HTML一样的标记语言,这在React 开发中非常常见。由于 TypeScript 默认不理解 JSX 语法,TSX(.tsx 文件扩展名)提供了一种方式来使用 TypeScript 和 JSX。因此,.tsx 文件通常用于包含 JSX 的 TypeScript 项目,尤其是在开发 React 组件时。简而言之,当代码中需要有类似HTML的代码时,即需要创建一个页面或者页面的一部分时,用tsx。无类似HTML的代码,则用ts。在本项目中,定义所有组件的components文件夹中的文件全用了tsx,因为要写HTML代码;同理,pages文件夹中除了api文件夹以外的所有文件用的也是tsx。剩下的文件夹中的文件普遍用的是ts,包括hooks文件夹,lib文件夹和pages/api文件夹。

  6. 本项目中几个主要文件夹的作用:

    属于 Next.js 的特定文件夹:

    • pages:这是 Next.js 特有的一个文件夹,用于基于文件系统的路由。在 Next.js 中,pages 目录下的每一个文件都会自动对应一个路由,这是 Next.js 框架的核心特性之一。pages中还有api文件夹,因此在本项目中可以像前后端分离的项目那样在后端定义api,然后在前端调用。只不过本项目中是在next.js实现的伪后端中定义api,然后在react实现的纯前端中调用api
    • public:这个文件夹也是 Next.js 的标准部分,用于存放静态文件,如图片、字体等。在项目中,你可以通过相对路径直接引用 public 文件夹中的资源。
    • styles:虽然存放样式的做法在前端项目中非常常见,但在 Next.js 项目中,styles 文件夹通常用于组织 CSS 或 SCSS 文件。Next.js 支持 CSS Modules 和内置的 Sass 支持,这个文件夹通常用来利用这些特性。本项目中的styles文件夹中只有一个global.css文件,主要负责对tailwind css的配置和定义一些默认的css格式。

    通常属于开发者根据项目需求创建的文件夹(既适用于 React,也适用于 Next.js):

    • components:存放 React 组件的文件夹。这些组件可以在不同的页面中复用。这是 React 项目的常见结构,但在 Next.js 项目中同样适用。

    • hooks:存放自定义 React 钩子(Hooks)。自定义钩子是 React 16.8 引入的功能,用于在函数组件之间复用状态逻辑。

    • lib:通常用于存放一些工具库或者用于与 API 交互的函数等。这个文件夹的具体用途依项目需求而定,既适用于纯 React 项目,也适用于 Next.js 项目。

    数据库相关的文件:

    prisma:这个文件夹通常用于存放与 Prisma 相关的配置和模型文件。Prisma 是一个流行的 Node.js 和 TypeScript ORM(对象关系映射),用于构建数据库访问。这不是 Next.js 或 React 特有的,而是根据你的项目是否需要与数据库交互来决定使用。

    总结:
    Next.js 特有:pages 和 public 文件夹是 Next.js 特定的,而 styles 虽然不是 Next.js 特有的,但其在 Next.js 项目中的使用方式往往利用了 Next.js 的一些特性。

    React 和 Next.js 通用:components、hooks、lib 和 prisma 文件夹是根据开发者的项目需求创建的,它们既适用于 React 项目,也适用于 Next.js 项目。这些文件夹的使用反映了现代前端项目的一些最佳实践,如组件化开发、自定义钩子的使用等。

  7. 本项目中使用到了hook的以下功能:

    • 状态管理 (useState)
      这是 Hooks 最基本的用途之一,允许在函数组件中添加状态。这对于实现按钮点击、输入表单处理、切换UI组件显示隐藏等功能至关重要。

    • 数据获取(useSWR
      useSWR 是一个由 Vercel 团队开发的 React Hook,它是 SWR (Stale-While-Revalidate) 数据获取库的一部分。SWR 是一种缓存策略,其名称来自 HTTP 缓存无效化策略,意味着“先返回缓存中的数据(陈旧的),然后发送请求(重新验证),最后用新数据替换旧数据”。useSWR 主要用于数据获取场景,特别是在需要频繁请求更新数据的应用中,它提供了一种简单而强大的方法来获取、缓存、更新远程数据。

    • 副作用处理 (useEffect)
      用于执行副作用操作,如数据获取(调用API)、订阅/取消订阅事件、直接操作DOM。这对于在组件加载、更新或卸载时执行外部操作非常有用。

    • 性能优化 (useCallback)
      useCallback 可以避免在每次渲染时都进行不必要的计算或创建新的函数实例,从而提高性能。

      需要特别注意的是,hook是一种概念,因此不局限于定义在某个特定的文件夹(如 hooks 文件夹)中,而是可以在函数的任何地方使用。在本项目中,hooks文件夹中的hooks主要负责对api的调用,而components, pages等文件夹中的hooks主要负责状态管理和性能优化。

Amazon SDE OA

  1. 题目不难,简单的算法
  2. 第二题涉及到链表的增加头、尾节点和删去头节点,但用最简单直接的做法会超时
  3. 第二题的优化做法没有完全实现(出现报错),导致第二题没有完美地做出来,应该会被拒
  4. 时间一共70分钟,乍一看很充裕,但是如果碰到要优化的地方,时间就不够用了,因此下次做OA一定要做快一些,留出充足的时间给可能需要的优化和debug。