YifanChen's Blog

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

0%

34. 在排序数组中查找元素的第一个和最后一个位置

本题是整数二分的加强版。本题的要点为:

  1. 写两个函数,分别寻找target的左边界和右边界。本题的区间定义为左闭右闭。

  2. 寻找左边界,说明target在[left, mid]之间,因此在[left, mid]中更新左边界。寻找右边界,说明target在[mid, right]之间,因此在[mid, right]中更新右边界。

  3. 寻找左边界,就要在nums[mid] == target的时候更新right,然后将right赋给左边界。寻找右边界,就要在nums[mid] == target的时候更新left,然后将left赋给右边界。

  4. 实际上的左右边界是mid,而非rightleft,因此在主函数中需要将左边界+1,恢复为mid;将右边界-1,也恢复为mid。也可以直接让左右边界是mid,这样就不需要加1减1,参见我的第二种写法。

  5. target的三种情况:

    • target在数组范围的右边或者左边
    • target 在数组范围中,且数组中存在target
    • target 在数组范围中,且数组中不存在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
      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
      // 左闭右闭写法
      class Solution {
      public:
      // 寻找左边界
      // 说明target在[left, mid]之间
      int findLeftBorder(vector<int>& nums, int target)
      {
      int leftBorder = -2;
      int left = 0, right = nums.size() - 1;

      while (left <= right)
      {
      int mid = left + (right - left) / 2;
      // 在[left, mid]中更新左边界
      if (nums[mid] >= target)
      {
      right = mid - 1;
      leftBorder = right;
      }
      else left = mid + 1;
      }
      return leftBorder;
      }

      // 寻找右边界
      // 说明target在[mid, right]之间
      int findRightBorder(vector<int>& nums, int target)
      {
      int rightBorder = -2;
      int left = 0, right = nums.size() - 1;

      while (left <= right)
      {
      int mid = left + (right - left) / 2;
      if (nums[mid] > target) right = mid - 1;
      // 在[mid, right]中更新右边界
      else
      {
      left = mid + 1;
      rightBorder = left;
      }
      }
      return rightBorder;
      }

      vector<int> searchRange(vector<int>& nums, int target) {
      int leftBorder = findLeftBorder(nums, target);
      int rightBorder = findRightBorder(nums, target);

      // target在数组范围的右边或者左边
      if (leftBorder == -2 || rightBorder == -2) return {-1, -1};
      // target 在数组范围中,且数组中存在target
      if (rightBorder - leftBorder > 1) return {leftBorder + 1, rightBorder - 1};
      // target 在数组范围中,且数组中不存在target
      return {-1, -1};
      }
      };

我写出了以下的变式代码。在这个代码里,通过mid来更新左右边界。这样若找到了左右边界,则直接返回左右边界即可,不需要做加1减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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 左闭右闭写法
class Solution {
public:
// 寻找左边界
// 说明target在[left, mid]之间
int findLeftBorder(vector<int>& nums, int target)
{
int leftBorder = -2;
int left = 0, right = nums.size() - 1;

while (left <= right)
{
int mid = left + (right - left) / 2;
// 在[left, mid]中更新左边界
if (nums[mid] >= target)
{
right = mid - 1;
leftBorder = mid;
}
else left = mid + 1;
}
return leftBorder;
}

// 寻找右边界
// 说明target在[mid, right]之间
int findRightBorder(vector<int>& nums, int target)
{
int rightBorder = -2;
int left = 0, right = nums.size() - 1;

while (left <= right)
{
int mid = left + (right - left) / 2;
if (nums[mid] > target) right = mid - 1;
// 在[mid, right]中更新右边界
else
{
left = mid + 1;
rightBorder = mid;
}
}
return rightBorder;
}

vector<int> searchRange(vector<int>& nums, int target) {
int leftBorder = findLeftBorder(nums, target);
int rightBorder = findRightBorder(nums, target);

// target在数组范围的右边或者左边
if (leftBorder == -2 || rightBorder == -2) return {-1, -1};
// target 在数组范围中,且数组中存在target
if (rightBorder - leftBorder >= 0) return {leftBorder, rightBorder};
// target 在数组范围中,且数组中不存在target
return {-1, -1};
}
};

278.第一个坏版本

我独立写出了以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The API isBadVersion is defined for you.
// bool isBadVersion(int version);

class Solution {
public:
int firstBadVersion(int n) {
int left = 1, right = n;

while (left < right)
{
int mid = left + (right - left) / 2;
if (isBadVersion(mid) == 0) left = mid + 1;
else right = mid;
}
return left;
}
};

在本题中,尽管是左闭右闭的写法,但循环的条件应该为left < right,因为当left = right时,实际上就锁定了第一个坏版本,循环就应当结束。这种题目当出现超时,要着重检查是不是循环的条件不对。

和本题同样的题目:输入一个数组,比如[0, 0, 0, 1, 1, 1, 1],找到第一个为1的数的下标,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <vector>
using namespace std;

int firstBadVersion(vector<int> arr)
{
int left = 0, right = arr.size() - 1;

while (left < right)
{
int mid = left + (right - left) / 2;
if (arr[mid] == 1) right = mid;
else left = mid + 1;
}
return left;
}

int main()
{
vector<int> arr = {0, 0, 0, 1, 1, 1, 1, 1};
cout << firstBadVersion(arr) << endl;
return 0;
}

27. 移除元素

本题直接采用(快慢)双指针解法即可。一遍过,但需要注意不要在for循环中重复定义指针j。本题的暴力做法甚至比双指针做法更复杂,也更容易写错。相向双指针做法暂时不用管。

977.有序数组的平方

暴力解法非常简单,也能通过测试。我先在纸上模拟了双指针的过程,然后独立写出了如下的双指针代码,时间和空间复杂度都是$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
// 双指针经典题
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
int size = nums.size() - 1;
vector<int> res(nums.size(), 0);

for (int i = 0, j = size; i <= j; )
{
if (nums[i] * nums[i] <= nums[j] * nums[j])
{
res[size] = nums[j] * nums[j];
j -- ;
size -- ;
}
else
{
res[size] = nums[i] * nums[i];
i ++ ;
size -- ;
}
}

return res;
}
};

不要追求把代码写得过度简洁,而导致可能的问题,宁可把代码写长一些,也要让代码清楚地表达算法思想。

209.长度最小的子数组

一时想不起来具体怎么写了,只记得遍历整个数组的同时,有数划入窗口,该数被累加到和中,有数划出窗口,则该数被从和中减去。看了我自己的笔记后,我独立写出了以下的代码:

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 minSubArrayLen(int target, vector<int>& nums) {
int len = INT_MAX;
int s = 0; // 滑动窗口的和
int i = 0; // 起始位置

// j为终止位置
for (int j = 0; j < nums.size(); j ++ )
{
s += nums[j]; // 终止位置划入

while (s >= target)
{
int sub = j - i + 1;
if (sub < len) len = sub;
s -= nums[i]; // 起始位置从和中滑出
i ++ ; // 起始位置从滑动窗口中滑出
}
}
if (len == INT_MAX) return 0;
else return len;
}
};

需要注意:

  • i为起始位置,j为终止位置

  • 循环中,终止位置先划入。若和大于等于目标值,则先更新最小长度,再将起始位置划出。

  • 起始位置的值需要先从和中滑出,起始位置再从滑动窗口中滑出。顺序不可颠倒。

  • for循环中是while循环,而非if判断

  • 数组中的每个元素至多被滑入一次再滑出一次,因此时间复杂度是$O(2n)$,即$O(n)$。

59.螺旋矩阵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
class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> res(n, vector<int>(n, 0));

int i, j;
int startx = 0, starty = 0;
int offset = 1, cnt = 1;
int loop = n / 2;

while (loop -- )
{
for (j = starty; j < n - offset; j ++ )
res[startx][j] = cnt ++ ;
for (i = startx; i < n - offset; i ++ )
res[i][j] = cnt ++ ;
for (j = n - offset; j > starty; j -- )
res[i][j] = cnt ++ ;
for (i = n - offset; i > startx; i -- )
res[i][j] = cnt ++ ;
offset ++ ;
startx ++ ;
starty ++ ;
}
if (n % 2) res[n / 2][n / 2] = cnt;
return res;
}
};

需要注意:

  1. 画图理解(记住本图,就可以写出这道题的代码)

    Snipaste_2024-01-26_06-26-17.png

  2. 顺时针转圈,转多少圈可以填满整个二维数组?从(0, 0)的位置开始转圈,终止的位置为中心(n/2, n/2)。每转一圈横纵坐标均加1,因此一共转了n/2圈。

  3. 切记遵守循环不变量原则,所有边都是左闭右开的。所以是j < n - offset,且offset的初始值为1,因为右边界是开的。

  4. startx, starty, offset每转一圈都要加1。

  5. 定义二维数组的方式是将一位数组复制行数遍。

  6. 若n为奇数,则最后记得向二维数组的中心填入最后一个数。

189.旋转数组

首先我写出了可以正常运行但会超时的代码:

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
void rotate(vector<int>& nums, int k) {
while (k -- )
{
reverse(nums.begin(), nums.end());
reverse(nums.begin() + 1, nums.end());
}
}
};

不超时的代码我写不出来,看卡尔的讲解。

本题的代码如下所示:

1
2
3
4
5
6
7
8
9
class Solution {
public:
void rotate(vector<int>& nums, int k) {
k = k % nums.size();
reverse(nums.begin(), nums.end());
reverse(nums.begin(), nums.begin() + k);
reverse(nums.begin() + k, nums.end());
}
};

本题其实原理不难,类似于旋转字符串的题目,总结如下:

  1. 首先反转整个数组,这样在不考虑顺序的情况下,就将两段数字放在了正确的位置上。
  2. 然后反转前k个数,将前k个数的顺序调整正确。
  3. 最后反转剩下的数,将剩下的数的顺序调整正确。
  4. 需要注意的是,若k > nums.size(),则右移k % nums.size()即可,因为右移nums.size()次相当于没有改变原数组。
  5. 不要对nums.end()进行加减操作,nums.end()不指向一个特定的元素(不要下意识地以为其指向最后一个元素后面的紧邻的位置),对其进行加减操作会导致未定义的随机行为。对nums.begin()进行操作就没有这个问题。因此反转的第三步不要写成reverse(nums.end() - k - 1, nums.end())

153.寻找旋转数组中的最小值

应该是先要将其恢复为有序的数组,然后返回有序数组的第一个元素即可。本题应该结合了二分法和旋转数组。我直接看题解吧。

虽然但是,本题用暴力做法也可以通过:

1
2
3
4
5
6
7
class Solution {
public:
int findMin(vector<int>& nums) {
sort(nums.begin(), nums.end());
return nums[0];
}
};

上述算法的时间复杂度是O(nlogn)。用二分法应该可以将时间复杂度优化为O(logn)。

本题的二分做法如下所示:

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:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;

// 循环的终止条件:left = right。此时必然已经找到了数组中的最小值
while (left < right)
{
int mid = left + (right - left) / 2;
// 中间数字大于右边数字,比如[3,4,5,1,2],则左侧是有序上升的,最小值在右侧
if (nums[mid] > nums[right]) left = mid + 1;
// 中间数字小于右边数字,比如[6,7,1,2,3,4,5],则右侧是有序上升的,最小值在左侧
// 以[6, 7, 1, 2, 3, 4]为例,mid = 2, right = 2,即恰好在[left, mid]中取到最小值1
// 若right = mid - 1,则[left, right]会错过最小值
else if (nums[mid] < nums[right]) right = mid;
// 中间数字等于右边数字,则说明数组中只有一个元素,返回该元素即可
// 也可以直接写作else right = mid;
}
return nums[left];
}
};

本题延续了二分法的思路和代码形式,但细节和二分法略有不同,需要注意复习。

本题的思路:

  • 本题是左闭右闭写法,区间为[left, right]

  • 数组中的最小值要么在数组的右侧,要么在数组的左侧

  • 数组的最小值在数组右侧的情况:[3, 4, 5, 1, 2]。数组的最小值在数组左侧的情况:[6, 7, 1, 2, 3, 4, 5]
  • 若数组的最小值在数组的右侧,由于nums[mid] > nums[right],因此nums[mid]必然不可能是数组的最小值,因此left = mid + 1
  • 对于剩下的情况,即nums[mid] <= nums[right],数组的最小值在数组的左侧。由于可能存在nums[mid] = nums[right]的情况,因此nums[mid]可能是最小值,因此有right = mid
  • 记住始终是nums[mid]nums[right]比较。始终是中间和右边比!

本题的另一种思路(更推荐这种,因为这种思路可以推广到33)

  • nums[mid]nums[right]的关系可以分为大于,等于,小于三种情况
  • nums[mid] == nums[right]时,中间的数字等于最右边的数字,说明数组中只有一个元素,此时返回nums[left]即可,这种情况不需要考虑
  • nums[mid] > nums[right]时,例如[3, 4, 5, 1, 2]。数组的最小值在数组的右侧,nums[mid]必定不为最小值,因此有left = mid + 1
  • nums[mid] < nums[right]时,数组的最小值在数组的左侧。例如[6, 7, 1, 2, 3, 4, 5],也有可能是[6, 7, 1, 2, 3, 4],此时mid = 2, right = 2,即恰好在[left, mid]中取到最小值1。若right = mid - 1,则[left, right]会错过最小值,因此right = mid

154.寻找旋转数组中的最小值II

本题的思路:

  • 延续上题的思路,nums[mid]nums[right]的关系可以分为大于,等于,小于三种情况
  • nums[mid] > nums[right]nums[mid] < nums[right]的情况同上
  • 由于数组中可以有重复的元素,因此需要考虑nums[mid] == mums[right]的情况,例如[2,3,1,1,1]或者[4,1,2,3,3,3,3]。此时,重复值nums[right]可能是最小值,也可能最小值在重复值的左侧,因此right左移一位:right -= 1

本题的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;

while (left < right)
{
int mid = left + (right - left) / 2;
// [5, 6, 7, 1, 2]
if (nums[mid] > nums[right]) left = mid + 1;
// [7, 1, 2, 3, 4]
else if (nums[mid] < nums[right]) right = mid;
else right -= 1;
}
return nums[left];
}
};

33.搜索旋转排序数组

我对本题的初步思路:先找到最小的那个点,然后分别对两段单调递增的区间用二分法进行搜索。根据这个原理,我独立写出了以下的代码:

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
class Solution {
public:
// 二分查找有序数组中的数
// 左闭右闭写法
int searchTarget(vector<int>& nums, int left, int right, int target)
{
while (left <= right)
{
int mid = left + (right - left) / 2;
if (nums[mid] > target) right = mid - 1;
else if (nums[mid] < target) left = mid + 1;
else return mid;
}
return -1;
}

int search(vector<int>& nums, int target) {
// 先找到最小的数字, 下标为left
int left = 0, right = nums.size() - 1;

while (left < right)
{
int mid = left + (right - left) / 2;
// nums[mid] nums[right], [4, 5, 6, 7, 0, 1, 2]
if (nums[mid] > nums[right]) left = mid + 1;
else right = mid;
}

int leftIndex = searchTarget(nums, 0, left - 1, target);
int rightIndex = searchTarget(nums, left, nums.size() - 1, target);

if (leftIndex == -1 && rightIndex == -1) return -1;
else if (leftIndex == -1) return rightIndex;
else if (rightIndex == -1) return leftIndex;
return -1;
}
};

时间复杂度也是$O(logn)$。

更简单的写法如下:

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:
int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;

// 本质是查找target,因此是小于等于。若是查找最小值,则是小于
while (left <= right)
{
int mid = left + (right - left) / 2;

// 第一种情况,直接找到
if (nums[mid] == target) return mid;

// 由于第一种情况已经讨论过nums[mid] == target,因此第二三种情况不用再讨论
// 第二种情况,数组最小值在右侧, [left, mid]为有序
if (nums[mid] > nums[right])
{
// target在[left, mid](有序)内
if (target >= nums[left] && target < nums[mid]) right = mid - 1;
// target在无序区间内
else left = mid + 1;
}

// 第三种情况,数组最小值在左侧,[mid, right]为有序
else
{
// target在[mid, right]区间内
if (target > nums[mid] && target <= nums[right]) left = mid + 1;
// target在无序区间内
else right = mid - 1;
}
}
return -1;
}
};

分三种情况讨论:

  • 直接在mid处找到target
  • 数组最小值在右侧, [left, mid]为有序
    • target在[left, mid]有序区间内
    • target在剩余的无序区间内
  • 数组最小值在左侧,[mid, right]为有序
    • target在[mid, right]有序区间内
    • target在剩余的无序区间内

81.搜索旋转排序数组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
class Solution {
public:
bool search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;

while (left <= right) {
// 跳过数组左侧的重复元素
while (left < right && nums[left] == nums[left + 1]) left++;
// 跳过数组右侧的重复元素
while (left < right && nums[right] == nums[right - 1]) right--;

int mid = left + (right - left) / 2;
if (nums[mid] == target) return true;

// 判断有序部分
if (nums[mid] >= nums[left]) { // 左侧有序
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else { // 右侧有序
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return false;
}
};

第二种,在循环外部直接删去数组尾部与数组头部重复的元素:

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:
bool search(vector<int>& nums, int target) {
// 移除重复的末尾元素以减少干扰
// 可以处理如下情况:[1, 0, 1, 1, 1], [1, 2, 2, 2, 2, 0, 1]
// nums.front() == nums.back()时,可能数组右边有序,也可能左边有序
// 也可写作nums[0] == nums[nums.size() - 1]
while (nums.size() > 1 && nums.front() == nums.back()) nums.pop_back();

int left = 0, right = nums.size() - 1;

while (left <= right)
{
int mid = left + (right - left) / 2;

if (nums[mid] == target) return true;

// [3, 4, 5, 1, 2]
if (nums[mid] > nums[right])
{
// 有序区间[left, mid]
if (target >= nums[left] && target < nums[mid]) right = mid - 1;
// 无序区间[mid, right]
else left = mid + 1;
}
else
{
// 有序区间[mid, right]
if (target > nums[mid] && target <= nums[right]) left = mid + 1;
// 无序区间[left, mid]
else right = mid - 1;
}
}
return false;
}
};

注意:需要先移除重复的末尾元素以减少干扰,再给leftright赋值。

建议采用第二种写法,因为第二种写法相当于在33.搜索旋转排序数组的基础上仅仅添加了移除重复的末尾元素的代码。这道题相当与上一题区别在于这道题包含了重复元素,其实影响到的是,当左端点和右端点相等时,无法判断mid在左半边有序数组还是右半边有序数组,所以只需要一直pop直到左端点和右端点不相等就可以了。

442. 数组中重复的数据

448. 找到所有数组中消失的数字

只有当数字的范围和数组的大小相等,或者有一定偏移关系时,才可以用原地哈希。本题的数字范围1-n,本题的数组中有n个元素,数组下标的范围是0-n-1。这种原地哈希算法适用于和正整数有关,且数字范围和数组长度有关的题目里,映射之后能利用映射关系(下标和值一一对应)来找到解。

对于本题,本质就是将原数组的下标为nums[i] - 1处放上nums[i],最终希望达到的效果是nums[nums[i] - 1] == nums[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
class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
vector<int> res;

// 将nums[i]放到下标为nums[i] - 1的位置上
// 由于原来下标为nums[i] - 1的位置上可能有数,因此需要将该数暂存到nums[i]上
// 之后可以通过while循环将再将该数放到合适的位置上去
// 可以举例子来模拟,即可以弄清楚这个过程
for (int i = 0; i < nums.size(); i ++ )
{
while (nums[nums[i] - 1] != nums[i])
{
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;
}
}

for (int i = 0; i < nums.size(); i ++ )
{
// 若nums[i]上的数字不为i + 1,则说明该数字缺失,将其插入结果集中
if (nums[i] != i + 1)
res.push_back(i + 1);
}

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:
vector<int> findDisappearedNumbers(vector<int>& nums) {
vector<int> res;
int n = nums.size();

for (int i = 0; i < n; i ++ )
{
// 确保将nums[i]放到下标为nums[i] - 1的位置上
while (nums[nums[i] - 1] != nums[i])
{
int idx = nums[i] - 1; // 即将占用的元素的下标
int tmp = nums[idx]; // 暂存下标为idx处的元素,因为其即将被nums[i]占用
nums[idx] = nums[i]; // 将nums[i]放到下标为nums[i] - 1的位置上
nums[i] = tmp; // 将原来数组中下标为nums[i] - 1的数暂存到位置i
}
}

for (int i = 0; i < n; i ++ )
if (nums[i] != i + 1) res.push_back(i + 1);

return res;
}
};

while循环中的顺序:先写:

1
2
int idx = nums[i] - 1;
nums[idx] = nums[i];

确保nums[i]被放在了下标为nums[i] - 1处。

再将原本下标为idx处的元素缓存下来,暂存到下标i处:

1
2
int tmp = nums[idx];
nums[i] = tmp;

由此构成完整的代码:

1
2
3
4
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;

442. 数组中重复的数据

本题依然可以用448的原地哈希法完成,唯一地不同在于,448是将i + 1插入res数组中,本题是将nums[i]插入res数组中,举一个实际的例子即可理解为什么是将nums[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
class Solution {
public:
vector<int> findDuplicates(vector<int>& nums) {
vector<int> res;
int n = nums.size();

for (int i = 0; i < n; i ++ )
{
while (nums[nums[i] - 1] != nums[i])
{
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;
}
}

for (int i = 0; i < n; i ++ )
{
if (nums[i] != i + 1) res.push_back(nums[i]);
}
return res;
}
};

对原地哈希可进行总结:

  • 情景:数组的长度为n,数组中元素的范围为[1, n]

  • 若是找缺失的数字,则插入结果集的是索引下标+1;若是找出现了两遍的数字,则插入结果集的是元素的值nums[i]

  • 使用代码块:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    for (int i = 0; i < n; i ++ )
    {
    while (nums[nums[i] - 1] != nums[i])
    {
    int idx = nums[i] - 1;
    int tmp = nums[idx];
    nums[idx] = nums[i];
    nums[i] = tmp;
    }
    }

    对数组进行原地哈希后,数组中出现过的数字nums[i]会被重新放置在下标为nums[i] - 1的位置上。范围为[1, n]但数组中没出现过的数字nums[j],其本来应该放置在下标为nums[j] - 1处,但由于没有出现过,现在下标为nums[j] - 1处放置了原数组中的重复元素。这是因为循环的条件nums[nums[i] - 1] != nums[i],当未填满的位置填入了重复元素后,while循环也会终止。例如,对[4, 3, 2, 2, 3, 1]进行原地哈希,结果为[1, 2, 3, 4, 3, 2],原数组中出现过的1, 2, 3, 4被放置在下标为0, 1, 2, 3的位置上,原数组中没有出现过5, 6,因此下标为4,5处放置了原数组中重复的元素2, 3。

  • 原地哈希法的时间复杂度都为O(n),空间复杂度都为O(1)

  • 为什么是 O(n) 时间复杂度?

    每个元素在整个过程中最多被处理两次(一次是放置在正确位置,一次是在最终遍历中检查),因此总体时间复杂度是 O(2n)==O(n)。

41. 缺失的第一个正数

本题的思路和448、442相同,只不过while循环多了限制条件,同时返回值时需要考虑一种特殊情况。

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:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();

for (int i = 0; i < n; i ++ )
{
// 为避免nums[i] - 1超出索引的范围,需要对nums[i]的大小进行限制
// 0 <= nums[i] - 1 <= n - 1,因此1 <= nums[i] <= n
// 不需要对此范围之外的数进行操作,也无法用原地哈希法操作它们,因为它们会超出索引范围
while (nums[i] >= 1 && nums[i] <= n && nums[nums[i] - 1] != nums[i])
{
// 这四行代码可以简写为swap(nums[nums[i] - 1], nums[i]);
int idx = nums[i] - 1;
int tmp = nums[idx];
nums[idx] = nums[i];
nums[i] = tmp;
}
}

for (int i = 0; i < n; i ++ )
{
if (nums[i] != i + 1)
return i + 1;
}

// 特殊情况, nums = [1], 上面的循环不会返回结果,此时返回n + 1即可
return n + 1;
}
};

本题中,数的个数为n个,但数的范围不在[1, n]中。需要返回缺失的第一个正整数。虽然乍一看不完全符合上题总结的原地哈希法的使用条件,但加上限制条件的原地哈希法依然可以被应用于解决本题。

203.移除链表元素

本题不能这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

for (ListNode* cur = dummyHead; cur != NULL; cur = cur->next)
{
if (cur->next != NULL && cur->next->val == val)
cur->next = cur->next->next;
}
return dummyHead->next;
}
};

这样写会导致删除节点后,cur 指针向后移动到了 cur->next->next,从而可能跳过了紧接着的需要删除的节点。比如:

1
1 -> 2 -> 2 -> 3, target = 2

上述写法会导致第三个节点2不能被删除。

本题应当用while循环写,对一个节点,如果是目标节点,则将其删除,否则,向后移动一个节点,不能同时既删除节点又后移一位。本题正确的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
while (cur->next != NULL)
{
// 如果既删又后移,则会漏掉节点
if (cur->next->val == val) cur->next = cur->next->next; // 要么删
else cur = cur->next; // 要么后移
}
return dummyHead->next;
}
};

本题的完整主函数:

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
#include <iostream>
using namespace std;

struct ListNode
{
int val;
ListNode* next;
ListNode(int x): val(x), next(NULL) {}
};

ListNode* remove(ListNode* head, int val)
{
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
while (cur->next != NULL)
{
if (cur->next->val == val) cur->next = cur->next->next;
else cur = cur->next;
}

return dummyHead->next;
}

int main()
{
// head = [1,2,6,3]
ListNode* node1 = new ListNode(1);
ListNode* node2 = new ListNode(2);
ListNode* node3 = new ListNode(6);
ListNode* node4 = new ListNode(3);
node1->next = node2;
node2->next = node3;
node3->next = node4;

ListNode* head = remove(node1, 3);
for (ListNode* cur = head; cur != NULL; cur = cur->next) cout << cur->val << ' ';
cout << endl;
return 0;
}

构造链表时,也可以采用函数写法:

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
#include <iostream>
using namespace std;

struct ListNode
{
int val;
ListNode* next;
ListNode(int x): val(x), next(NULL) {}
};

ListNode* appendNode(ListNode*& head, int val)
{
// 头为空,则将新节点作为头节点
if (head == NULL) head = new ListNode(val);
// 头不为空,则遍历到链表最后一个节点,将新节点添加到最后一个节点之后
else
{
ListNode* cur = head;
while (cur->next != NULL) cur = cur->next;
cur->next = new ListNode(val);
}
return head;
}

ListNode* remove(ListNode* head, int val)
{
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
while (cur->next != NULL)
{
if (cur->next->val == val) cur->next = cur->next->next;
else cur = cur->next;
}

return dummyHead->next;
}

int main()
{
// head = [1,2,6,3]
ListNode* node = NULL;
appendNode(node, 1);
appendNode(node, 2);
appendNode(node, 6);
appendNode(node, 3);

ListNode* head = remove(node, 3);
for (ListNode* cur = head; cur != NULL; cur = cur->next) cout << cur->val << ' ';
cout << endl;
return 0;
}

在定义链表时,特别要注意下面用来赋值的这句话:ListNode(int x): val(x), next(NULL) {}

707.设计链表

本题的细节很多,需要特别注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// get函数的复杂写法
int get(int index) {
if (index < 0 || index > _size - 1) return -1;

LinkedList* cur = _dummyHead;
index ++ ;
while (cur != NULL)
{
cur = cur->next;
index -- ;
if (index == 0) break;
}
return cur->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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class MyLinkedList {
public:
struct LinkedList
{
int val;
LinkedList* next;
LinkedList(int x): val(x), next(NULL) {}
};

MyLinkedList() {
_dummyHead = new LinkedList(0);
_size = 0;
}

int get(int index) {
if (index < 0 || index > _size - 1) return -1;

LinkedList* cur = _dummyHead->next;
while (index -- ) cur = cur->next;
return cur->val;
}

void addAtHead(int val) {
LinkedList* head = new LinkedList(val);
head->next = _dummyHead->next;
_dummyHead->next = head;
_size ++ ;
}

void addAtTail(int val) {
LinkedList* tail = new LinkedList(val);
LinkedList* cur = _dummyHead;
while (cur->next != NULL) cur = cur->next;
cur->next = tail;
_size ++ ;
}

void addAtIndex(int index, int val) {
if (index < 0 || index > _size) return;
LinkedList* newNode = new LinkedList(val);
LinkedList* cur = _dummyHead;
while (index -- ) cur = cur->next;
newNode->next = cur->next;
cur->next = newNode;
_size ++ ;
}

void deleteAtIndex(int index) {
if (index < 0 || index > _size - 1) return;
LinkedList* cur = _dummyHead;
while (index -- ) cur = cur->next;
cur->next = cur->next->next;
_size -- ;
}

private:
LinkedList* _dummyHead;
int _size;
};

注意事项:

  1. 带下划线的变量表示类中的变量,而非局部变量

  2. 记得在private中定义类中的变量

  3. 注意插入节点时先更新后面的边,再更新前面的边

  4. 别忘记_size ++ / _size —

  5. 注意对参数index进行判断

  6. while (index -- ) cur = cur->next的意思是,首先判断index是否大于0,是,则index = index - 1,然后执行cur = cur->next

206.反转链表

我记得有递归写法,迭代写法,先尝试实现迭代写法,其本质是双指针。记住下面的图,即可写出本题的双指针法的代码:
leetcode206.png

注意:pre从NULL开始,cur在NULL结束。

一个经典的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* pre = NULL;
ListNode* cur = head;

while (cur->next)
{
ListNode* tmp = cur->next;
cur->next = pre;
pre = cur;
cur = tmp;
}
return cur;
}
};

这样写的结果是导致未将列表的最后一个节点(即反转后的头节点)的 next 指针正确设置。

本题的递归写法其实更加好写,但其空间复杂度为O(n),高于双指针写法:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
ListNode* reverseList(ListNode* head) {
// 终止条件
if (head == NULL || head->next == NULL) return head;

ListNode* last = reverseList(head->next);
head->next->next = head;
head->next = NULL;
return last;
}
};

24. 两两交换链表中的节点

首先尝试用双指针做法解决本题。直接看答案,记住本题的方法。其实不需要双指针,本题是一道单纯的模拟题,但要搞清楚循环终止条件。看过博客后,我写出了以下的代码:

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:
ListNode* swapPairs(ListNode* head) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* cur = dummyHead;

// 终止条件:分别对应有偶数个节点和有奇数个节点
while (cur->next && cur->next->next)
{
// 存1
ListNode* tmp = cur->next;
// 存3
ListNode* tmp1 = cur->next->next->next;
// d->2
cur->next = cur->next->next;
// 2->1
cur->next->next = tmp;
// 1->3
tmp->next = tmp1;

// 后移两位
cur = cur->next->next;
}
return dummyHead->next;
}
};

19.删除链表的倒数第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
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* cur = dummyHead;

int num = -1;
// 计算节点数目
while (cur)
{
cur = cur->next;
num ++ ;
}

// 倒数第n个节点是正数第num - n个节点
cur = dummyHead;
while (num > n) // 不可以写成(num - n) --
{
cur = cur->next;
num -- ;
}
cur->next = cur->next->next;
return dummyHead->next;
}
};

看博客,复习巧妙解法。本题的巧妙解法是快慢双指针。快指针先移动n位,然后快慢指针同时移动,直到快指针移动到链表的最后一个节点。此时,慢指针就指向了需要删除的节点。据此,我写出了本题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* fast = dummyHead;
ListNode* slow = dummyHead;
// && fast可加可不加,因为本题有限制n<=链表长度,若无此限制,则必须加,否则会出现空指针异常
while (n -- && fast) fast = fast->next;
while (fast->next)
{
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummyHead->next;
}
};

也可以让快指针先移动n + 1步,然后快慢指针同时移动,直到快指针移动到链表的NULL节点。

160.相交链表

本题拿到我没什么思路,直接看以前的博客。本题的思路:首先计算两个链表的长度,将较长的链表作为链表a,较短的链表作为链表b。然后a链表从sizea - sizeb处开始, b链表从0处开始遍历,直到找到二者的交汇点。本质上体现的是一种末尾对齐的思想。示意图和代码如下所示:

面试题02.07.链表相交_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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* cura = headA, *curb = headB;
int sizea = 0, sizeb = 0;

// 计算a的长度
while (cura)
{
cura = cura->next;
sizea ++ ;
}

// 计算b的长度
while (curb)
{
curb = curb->next;
sizeb ++ ;
}

// 让a为较长的链表, b为较短的链表
if (sizea < sizeb)
{
swap(sizea, sizeb);
swap(headA, headB);
swap(cura, curb);
}

// a链表从sizea - sizeb处开始, b链表从0处开始
cura = headA, curb = headB;
int delta = sizea - sizeb;
while (delta -- ) cura = cura->next;

while (curb)
{
if (cura == curb) return cura;
else
{
cura = cura->next;
curb = curb->next;
}
}
return NULL;
}
};

时间复杂度O(n + m),空间复杂度O(1)。

142.环形链表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
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head, * slow = head;

// fast != NULL保证fast->next不报空指针异常
// fast->next != NULL保证fast->next->next不报空指针异常
while (fast && fast->next)
{
fast = fast->next->next;
slow = slow->next;

if (fast == slow)
{
ListNode* node1 = head, * node2 = fast;
while (node1 != node2)
{
node1 = node1->next;
node2 = node2->next;
}
return node1;
}
}
return NULL;
}
};

本题的思路:快慢指针必然会在环中的某处相交,且慢指针在进入环的第一圈中就会和快指针相交。记下交点,让一个指针从起点开始走,另一个指针从交点开始走,二者相交的位置就是环的入口。详细的数学推导和细节见博客。

时间复杂度O(n),空间复杂度O(1)。

242.有效的字母异位词

本题简单,用数组做哈希,用数组统计一个字符串中各个字母出现的次数,然后遍历另一个字符串,在数组中做减减操作,最后判断数组中的所有元素是否都为0。我独立写出了本题的代码:

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:
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;

vector<int> hash(26, 0);

for (int i = 0; i < s.size(); i ++ )
{
int tmp1 = s[i] - 'a';
hash[tmp1] ++ ;
int tmp2 = t[i] - 'a';
hash[tmp2] -- ;
}

for (int i = 0; i < 26; i ++ )
{
if (hash[i]) return false;
}
return true;
}
};

需要注意,数组的长度为26,而非s.size(),因为s和t中只含有26个英文字母。可以不用vector,用int类型的数组即可。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;

int hash[26] = {0};

for (int i = 0; i < s.size(); i ++ )
{
hash[s[i] - 'a'] ++ ;
hash[t[i] - 'a'] -- ;
}

for (int i = 0; i < 26; i ++ )
if (hash[i]) return false;

return true;
}
};

349. 两个数组的交集

由于本题数据范围的限制,可以用数组做哈希,也可以用unordered_set做哈希。我首先写出了数组做哈希的写法(数组索引的范围与元素取值的范围相同),数组做哈希非常快:

数组哈希,数组去重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
int hash1[1010] = {0};
int hash2[1010] = {0};
vector<int> res;

for (int i = 0; i < nums1.size(); i ++ )
hash1[nums1[i]] ++ ;

for (int i = 0; i < nums2.size(); i ++ )
hash2[nums2[i]] ++ ;

for (int i = 0; i < 1010; i ++ )
{
if (hash1[i] && hash2[i]) res.push_back(i);
}
return res;
}
};

尝试用unordered_set做本题。我不记得怎么用unordered_set做了,也忘记了unordered_set的基本做法,复习博客。

数组哈希,set去重

可以将数组和set结合,这样只需要一个数组即可完成本题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> res; // 用于结果集去重
int hash[1010] = {0};

for (int i = 0; i < nums1.size(); i ++ )
hash[nums1[i]] ++ ;

for (int i = 0; i < nums2.size(); i ++ )
if (hash[nums2[i]]) res.insert(nums2[i]);

return vector<int>(res.begin(), res.end());
}
};

这里的set只是起到了去重的功能,没有起到哈希的功能,哈希的任务还是由数组承担了。注意如何将set转换为vector输出,直接vector<int> (res.begin(), res.end())

set哈希,set去重

也可以完全用set做本题,set既用来做哈希,又用来去重,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 完全用set做本题
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> res;

unordered_set<int> s1(nums1.begin(), nums1.end());

for (int i = 0; i < nums2.size(); i ++ )
if (s1.find(nums2[i]) != s1.end()) res.insert(nums2[i]);

return vector<int> (res.begin(), res.end());
}
};

202. 快乐数

我记得本题有个巧妙的做法。本题使用的数据结构应该是set。我直接看博客复习本题的写法。我错误的根本原因还是对本题的算法思路理解不清晰。在明确了思路后,我写下了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
bool isHappy(int n) {
unordered_set<int> s;

while (1)
{
int sum = 0; // 用于存储各位数字的平方和

while (n)
{
int digit = n % 10;
sum += digit * digit;
n = n / 10;
}

if (sum == 1) return true;
if (s.find(sum) != s.end()) return false;
n = sum;
s.insert(sum);
}
}
};

本题的思路其实很简单,关键在于对平方和的计算和分类讨论(分为三类)开一个死循环,计算n的各位数字的平方和。若平方和为1,则是快乐数。若平方和在set中出现过,则说明进入了死循环,不是快乐数。否则,将平方和加入到set中,将sum赋给n,进入下一重循环

时间复杂度和空间复杂度都是O(logn),详情参见博客。

1. 两数之和

本题要用map解决。用map的key存储索引,map的value存储nums中的值。首先将数组中的值依次存入map中,然后再在map中搜索target - nums[i],若找到,则返回一对索引。本题思路我是清楚的,但由于忘了map的一些写法,因此复习博客。

实际上,我对本题的理解还是不够深刻。应该是用map的key存储数组中的值,map的value存储数组中的元素的下标,因为我们的目的是快速查找值是否出现过,被快速查找的对象应该被作为key。

先查再插

看完博客后,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> m;

for (int i = 0; i < nums.size(); i ++ )
{
int t = target - nums[i];
if (m.find(t) != m.end())
{
auto it = m.find(t);
return {i, it->second};
}
m.insert({nums[i], i});
}
return {};
}
};

本题的思路为:先查后插。先查现有的map中有无target - nums[i],有,则将其索引和i一起加入结果集。无,则将遍历到的元素继续插入map中。这样天然的可以防止同一个元素被取出两次

记住map的一些用法:

  • m.insert({nums[i], i})
  • m.find(key) != m.end()
  • auto it = m.find(t); int value = it->second;

插完再查

本题更复杂版本的代码,由于没有先查后插,导致要对找到的索引进行判断,其不能等于当前遍历到的索引,否则会导致同一个数字被使用了两次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> m;

for (int i = 0; i < nums.size(); i ++ )
m.insert({nums[i], i});

for (int i = 0; i < nums.size(); i ++ )
{
int t = target - nums[i];
auto it = m.find(t);
if (it != m.end() && it->second != i) return {i, it->second};
}
return {};
}
};

压缩字符串(面试真题)

aaaabb压缩为a4b2,将abcde保持原样不动。我独立写出了以下的代码,可以通过所有的测试样例:

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
#include <iostream>

using namespace std;

string compress(string s)
{
if (s.size() == 1) return s;

string res;
char tmp = s[0];
int size = 1;

for (int i = 1; i < s.size(); i ++ )
{
res += tmp;
while (i < s.size() && s[i] == s[i - 1])
{
i ++ ;
size ++ ;
}

if (size > 1) res += to_string(size); // 也可以写成res += '0' + size;
if (i < s.size()) tmp = s[i];
size = 1;
}

// 处理最后一个字符
if (s[s.size() - 1] != s[s.size() - 2]) res += s[s.size() - 1];

return res;
}

int main()
{
// 将aaabb转换为a3b2输出
// 将abcde原样输出
string s = "aaa";
string res = compress(s);
cout << res << endl;
}

其实没有必要在for循环中嵌套while循环,直接用一个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
#include <iostream>
#include <string>

using namespace std;

// aaabb->a3b2
string compress(string s)
{
if (s.size() == 0 || s.size() == 1) return s;

string res;
char tmp = s[0];
int size = 1;

for (int i = 1; i < s.size(); i ++ )
{
// 出现相同字母
if (s[i] == s[i - 1]) size ++ ;
// 出现不同字母
else
{
// 将上一个字符和其出现次数(>1)插入res中
res += tmp;
if (size > 1) res += to_string(size);
// 恢复现场
tmp = s[i];
size = 1;
}
}

// 处理字符串的最后一位
res += tmp;
if (size > 1) res += to_string(size);

return res;
}

int main()
{
string s = "aaaanbv";
string res = compress(s);
cout << res << endl;
}

本题的关键在于分两种情况讨论:出现相同的字母/出现不同的字母,最后记得处理字符串的最后一位

通过本题,记住常用操作——将数字转换为字符:to_string(size)

可以写出上述操作的逆过程的代码:

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
#include <iostream>
#include <string>

using namespace std;

bool isdigit(char s)
{
if (s >= '0' && s <= '9') return true;
return false;
}

string decompress(string s)
{
string res;

// s的第一个元素必为字母,从第二个元素开始可能为数字
// 一对对处理,先处理字母,再处理数字(可能有,也可能没有)
for (int i = 0; i < s.size(); i ++ )
{
// 处理字母
char tmp = s[i];

// 处理数字,计算count
int count = 0;
while (i + 1 < s.size() && isdigit(s[i + 1]))
{
count = count * 10 + s[i + 1] - '0';
i ++ ;
}

// 字母加入结果集
if (count == 0) res += tmp;
// 若有数字,则将字母重复数字遍
else while (count -- ) res += tmp; // 也可调用函数res.append(count, tmp);
}
return res;
}

int main()
{
string s = "a56b12";
string res = decompress(s);
cout << res << endl;
}

本题的关键在于字母和数字成对出现(当然数字可能没有),成对地处理字母和数字,将它们成对地放入res中。

454.四数相加II

本题用哈希做的时间复杂度应该为O(n^2)。先枚举nums1和nums2中所有元素之和的组合,然后再在nums3和nums4中查找所有元素之和为-nums1[i] - nums2[j]的情况。由于涉及到索引,所以要用map,map的key存数值,map的value存索引。value似乎要存一组索引,比如(i, j),我忘记怎么写了,看下博客。

实际上,应该是用map的key存储和,map的value存储出现这个和的次数。据此,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> m;

for (int i = 0; i < nums1.size(); i ++ )
for (int j = 0; j < nums2.size(); j ++ )
m[nums1[i] + nums2[j]] ++ ;

int count = 0;
for (int i = 0; i < nums3.size(); i ++ )
{
for (int j = 0; j < nums4.size(); j ++ )
{
if (m.find(-nums3[i] - nums4[j]) != m.end())
count += m.find(-nums3[i] - nums4[j])->second;
}
}
return count;
}
};

更简洁的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> m;

for (int num1: nums1)
for (int num2: nums2)
m[num1 + num2] ++ ;

int count = 0;
for (int num3: nums3)
for (int num4: nums4)
if (m.find(-num3 - num4) != m.end())
count += m[-num3 - num4];

return count;
}
};

注意:

  • 用map的key存储和,map的value存储出现这个和的次数
  • map的key不可重复,map的value可以重复。本题中的map起到一个将相同的和归拢,并用value统计其出现次数的作用
  • cpp中的map中的value是支持++操作的,且value可以通过key直接索引到,就像普通的数组那样
  • 时间和空间复杂度均为$O(n^2)$,空间复杂度为$O(n^2)$是两数组的数字各不相同,产生了$n^2$种组合。

383. 赎金信

本题用数组做哈希就可以,因为对象就是26个小写英文字母。据此,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int cnt[26] = {0};

for (int i = 0; i < magazine.size(); i ++ )
cnt[magazine[i] - 'a'] ++ ;

for (int i = 0; i < ransomNote.size(); i ++ )
cnt[ransomNote[i] - 'a'] -- ;

for (int num: cnt)
if (num < 0) return false;
return true;
}
};

终极优化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int cnt[26] = {0};

// Each letter in magazine can only be used once in ransomNote
if (ransomNote.size() > magazine.size()) return false;

for (char m: magazine)
cnt[m - 'a'] ++ ;

for (char r: ransomNote)
{
cnt[r - 'a'] -- ;
if (cnt[r - 'a'] < 0) return false;
}
return true;
}
};

链接

93.复原IP地址
78.子集
90.子集II

知识

93.复原IP地址

  1. cpp中的string是有pop_back方法的,用于弹出字符串中的最后一个元素。

  2. 字符串中在i的后面插入一个逗点

    1
    s.insert(s.begin() + i + 1 , '.');  
  3. 删除特定位置处的逗点

    1
    s.erase(s.begin() + i + 1);       

初次尝试

93.复原IP地址

我尝试按照131.分割回文串的思路做本题,也写出了相应的代码,但运行结果和答案相差很大,而且代码非常复杂。我来看看卡尔的解法,看看如何写出正确而简单地处理这种字符串类型的回溯题的代码。

78.子集

据卡尔说,子集问题,就是收集树形结构中,每一个节点的结果。 整体代码其实和回溯模板都是差不多的。 对于本题的树形结构,我有一个想法:以1, 2, 3为例,首先选中1前面的空位,则要收集空和123。然后选中1,则要收集1和23。然后选中2,则要收集2和13。然后选中3,则要收集3和12。共有8个子集。但本题的代码我写不出来,直接看卡尔的视频讲解。

90.子集II

本题是40.组合总和II再加上78.子集。利用40题的去重办法(树层去重,用used数组,即if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0)),利用78题的子集问题的解法(主要是在所有节点而不仅仅是叶子节点上收集答案)。据此,我独立写出了本题的代码:

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<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex, vector<int>& used)
{
res.push_back(path);

// 终止条件
if (startIndex >= nums.size()) return;

// 单层递归逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 树层去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, i + 1, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<int> used(nums.size(), 0);
sort(nums.begin(), nums.end()); // 一定记得要对nums排序
backtracking(nums, 0, used);
return res;
}
};

实现

93.复原IP地址

合法的IP地址:

  • 每个整数位于 0 到 255 之间组成
  • 数字前不能有0,即不能有先导0
  • 不能出现非0-9的字符

因此本题不仅需要对字符串进行切割,还要对子串进行合法性的判断。本题在回溯算法的切割问题中是一道较有难度的题。做了131.分割回文串后,再来做本题,会易于理解一些。使用回溯算法暴力枚举分割的每一种情况。画树形结构图。

20201123203735933.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
33
34
35
36
37
38
39
vector<string> res;

// startIndex表示下一层递归切割的位置,即切割线
// 一个IP需要有三个逗点进行分割,pointSum用于统计逗点的数量, pointSum决定了树的深度
void backtracking(string& s, int startIndex, int pointSum)
{
// 终止条件
// 每次加逗点,都是对其前面的子串做合法性判断
// 此时还需要专门对最后一个子串做合法性判断,最后一个子串合法了,才能将整个IP地址加入结果集中
// isvalid用于判断一个子串是否合法:数字前不能有0,数字在0-255之间,子串中不能有非法字符
if (pointSum == 3) {
if (isvalid(s, startIndex, s.size() - 1)) // 左闭右闭
{
res.push_back(s); // s会在后面被修改,具体来说是被切割并加上逗点
return;
}
}

// 单层搜索逻辑
for (int i = startIndex; i < s.size(); i ++ )
{
// 切割后,对产生的第一个子串的合法性进行判断。子串的区间:[startindex, i]
if (isvalid(s, startIndex, i))
{
// 进入下一层递归前,需要在子串后面加上逗点
// 将.插入到s.begin() + i的后面,故传入的参数是s.begin() + i + 1
s.insert(s.begin() + i + 1, '.');
pointSum += 1; // 逗点数量+1

// 递归
// 由于给字符串s额外加了一个逗点,因此是i + 2(本来是i + 1)
backtracking(s, i + 2, pointSum);

// 回溯
s.erase(s.begin() + i + 1); // 删除s中插入的逗点
pointSum -= 1;
}
}
}

上述代码的精妙之处在于,就是在原来的字符串s的基础上进行修改,修改就是在合适的位置上添加逗点。本题的关键在于如何模拟切割的过程。切割的过程本质上和组合问题的取数的过程是一样的。另外还需要对子串进行合法性的判断,子串是[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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 直接在s的基础上添加逗号,得到可能的IP地址
class Solution {
public:
vector<string> res;

// 判断区间[start, end]的合法性
// 三个要求:1. 没有非数字的字符
// 2. 在0-255之间
// 3. 没有先导0
bool isvalid(string& s, int start, int end)
{
if (start > end) return false;

string tmp = s.substr(start, end - start + 1);

// 先导0
if (tmp.size() > 1 && tmp[0] == '0') return false;

int sum = 0;
int d = 1;

for (int i = tmp.size() - 1; i >= 0; i -- )
{
// 非数字的字符
if (tmp[i] < '0' || tmp[i] > '9') return false;
sum += (tmp[i] - '0') * d;
d = d * 10;
if (sum > 255) return false;
}
return true;
}

// startIndex为分割线,dotSum为逗点数目
void backtracking(string& s, int startIndex, int dotSum)
{
// 终止条件
if (dotSum == 3)
{
// 对第四段(s的最后一段)做合法性判断
if (isvalid(s, startIndex, s.size() - 1))
{
res.push_back(s);
}
return;
}

// 单层搜索逻辑
// 区间[startIndex, i]
for (int i = startIndex; i < s.size(); i ++ )
{
// 对区间合法性进行判断
if (isvalid(s, startIndex, i))
{
// 合法,则插入逗点
s.insert(s.begin() + i + 1, '.');
dotSum ++ ;

// 递归,本区间终止于i, 故下一个区间开始于i + 2
backtracking(s, i + 2, dotSum);

// 回溯
s.erase(s.begin() + i + 1);
dotSum -- ;
}
}
}

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

isvalid函数可以写的更简洁更自然:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool isvalid(string& s, int start, int end)
{
if (start > end) return false;

// 先导0
if (s[start] == '0' && start != end) return false;

int num = 0;
for (int i = start; i <= end; i ++ )
{
// 非数字字符
if (s[i] < '0' || s[i] > '9') return false;
// 在0-255之间
num = num * 10 + s[i] - '0';
if (num > 255) return false;
}
return true;
}
  • 时间复杂度: $O(3^4)$,IP地址最多包含4个数字,每个数字最多有3种可能的分割方式(1位,2位,3位);则搜索树的最大深度为4,每个节点最多有3个子节点(对应每个数字可能是1位,2位,3位的情况)。
  • 空间复杂度: $O(n)$,原因如下:
    • 递归栈:递归的深度固定,最多为4,因为IP地址由四部分组成。但这并不直接决定空间复杂度,因为递归深度很小。
    • 存储当前解:在递归过程中,需要存储当前正在构建的IP地址,这需要额外的空间。此外,每次递归调用时,都可能创建字符串的子串来表示IP地址的某一部分。字符串的最大长度为输入字符串的长度n,因此需要额外的空间来存储这些子串。
    • 输出解的集合:输出的解的数量并不直接影响空间复杂度的理论计算,但实际上会使用额外空间来存储所有可能的IP地址。然而,这些空间通常不计入空间复杂度计算中,因为它不依赖于递归过程中的临时存储需求。

78.子集

之前讲的组合问题、分割问题,我们都是在树形结构的叶子节点上收获结果,因此在终止条件处收获结果。可以画出如下的树形结构:

78.子集

观察如上树形结构,发现我们想收获的结果其实在每一个节点中。因此不是在终止条件中收获结果,而是每进入一层递归就将单个结果放入结果集中。现在开始对着树形结果写代码:

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
// 一维数组存放单个结果
vector<int> path;
// 二维数组作为结果集
vector<vector<int>> res;

// startIndex:下一层递归从哪里开始取数
void backtracking(vector<int> nums, int startIndex)
{
// 每进入一层递归,都要将当前的path放入结果集中
// 因为要将叶子节点的集合放入结果集中,然后再结束,因此先有本逻辑,再有终止条件
res.push_back(path);

// 终止条件:走到叶子节点,叶子节点的剩余集合都为空
// 本终止条件可以不写,因为单层搜索逻辑中的for循环已经对startIndex的大小进行了限制
if (startIndex >= nums.size()) return;

// 单层搜索逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 每进入一层递归,都要收获当前节点的结果,放入单个结果数组中
path.push_back(nums[i]);
// 进入下一层递归
backtracking(nums, i + 1);
// 回溯
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
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex)
{
res.push_back(path);

for (int i = startIndex; i < nums.size(); i ++ )
{
path.push_back(nums[i]);
backtracking(nums, i + 1);
path.pop_back();
}
return;
}

vector<vector<int>> subsets(vector<int>& nums) {
backtracking(nums, 0);
return res;
}
};

90.子集II

与78的区别:给的集合中允许有重复的元素,因此需要对重复子集去重。本题的关键在于去重,本题是子集+组合总和II(树层去重)的结合,并没有新的知识点。

本题的树形结构。used数组用于记录某个元素是否出现过。因为去重要让大小相邻的元素挨在一起,因此需要先对数组进行排序。本题的去重是树层去重(树层上相邻的元素如果相等,则不能重复取,否则会得到重复的子集),树枝不需要去重。

90.子集II

现在开始写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vector<int> path; // 单个结果
vector<vector<int>> res; // 结果集

void backtracking(vector<int>& nums, int startIndex, vector<int>& used)
{
// 终止条件不需要写,在for循环中实际上已经限制了startIndex的大小
res.push_back(path); // 收获结果,需要在每个节点都收获结果

// 单层递归逻辑
for (int i = startIndex; i < nums.size(); i ++ )
{
// 树层去重, used[i - 1] == 0意味着第i - 1个元素是第i个元素的回溯
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] = 0) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, i + 1, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}

回溯中的去重逻辑都这么写。本题去重也可以用startIndex和i进行比较来实现,但这种去重写法并不通用,遇到排列问题时依然要用used数组的写法进行去重。去重的写法掌握used数组写法即可。

本题的时间和空间复杂度和78相同。时间复杂度: $O(n\times2^n)$,空间复杂度: $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
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex)
{
res.push_back(path);
unordered_set<int> s;

for (int i = startIndex; i < nums.size(); i ++ )
{
// set去重
if (s.find(nums[i]) != s.end()) continue;

// 处理节点
s.insert(nums[i]);
path.push_back(nums[i]);
// 递归
backtracking(nums, i + 1);
// 回溯
path.pop_back();
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end()); // set去重依然需要排序
backtracking(nums, 0);
return res;
}
};

因此,本题的去重写法有三种:

  • used数组去重
  • startIndex去重
  • set去重

掌握第一种即可。第一种是思路最清晰也最通用的。

本题像78一样,也可以不写终止条件,因为startIndex的大小在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
class Solution {
public:
vector<int> path;
vector<vector<int>> res;

void backtracking(vector<int>& nums, int startIndex, vector<int>& used)
{
res.push_back(path);

for (int i = startIndex; i < nums.size(); i ++ )
{
// 去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
// 处理节点
path.push_back(nums[i]);
used[i] = 1;
// 递归
backtracking(nums, i + 1, used);
// 回溯
path.pop_back();
used[i] = 0;
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<int> used(nums.size(), 0);
sort(nums.begin(), nums.end());
backtracking(nums, 0, used);
return res;
}
};

心得与备忘录

93.复原IP地址

  1. 本题是131.分割回文串的加强版。因为和131同样是用回溯法求解的分割问题,所以基本原理是相同的,比如startIndex用于作为分割线,分割的区间是[startIndex, i]
  2. 本题的终止条件和131有显著地不同。131的终止条件是startIndex移动到字符串的末尾,而本题的终止条件是添加了3个逗点,且最后一段区间是合法的。3个逗点的终止条件也限制了树的深度。
  3. 一般处理字符串的问题都比较复杂。本题对字符串处理的精妙之处在于直接在原本的字符串s上进行修改,添加逗点,作为分隔符从而得到合法的IP地址。本题还用到了两个和字符串有关的STL,分别是inserterase函数。
  4. 本题对区间合法性的判断较为复杂,共有3个要求:
    • 段位以0为开头的数字不合法
    • 段位里有非正整数字符不合法
    • 段位如果大于255了不合法
    • 段位若大于255,则立即判断为不合法,return false。若完成for循环后再对num进行判断,可能出现整数型变量溢出
  5. 本题的时间复杂度:$O(3^4)$,空间复杂度:$O(n)$
  6. 本题的细节比较多,比较容易写错,属于回溯法解决分割问题中的难题,需要不断复习。

78.子集

  1. 子集是收集树形结构中树的所有节点的结果。而组合问题、分割问题是收集树形结构中叶子节点的结果。
  2. 子集也是一种组合问题,因为它的集合是无序的,子集{1,2}和子集{2,1}是一样的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始。
  3. 先收集结果集,再写终止条件的原因:当递归到叶子节点时,要先将叶子节点的结果放入结果集中,再终止,因此先写收集结果集的逻辑,再写终止条件。否则叶子节点的结果无法被放入结果集中。
  4. 本题也可以不写终止条件,因为单层递归逻辑的for循环中实际上限制了startIndex的大小,因此最后return即可。但初学者还是建议写终止条件,和标准的回溯模板保持一致。
  5. 本题的时间复杂度: $O(n\times2^n)$,空间复杂度: $O(n)$。时间和空间复杂度的分析同77.组合

90.子集II

  1. 本题是40.组合总和II与78.子集这两题的结合。

  2. 40的精华在于去重(树层去重),78的精华在于在每个节点处都收集结果(而不是像组合、分割问题那样在叶子节点,即终止条件处收集结果),而是在递归函数的开始处(进入递归相当于进入一个节点)收集结果。本题结合了两题的精华。

  3. 树层去重掌握used数组写法即可,具体代码为:

    1
    if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0) continue;
  4. 树层去重前,需要对nums数组进行排序。
  5. 本题的时间和空间复杂度和上一题(78)相同。
  6. 去重共有三种写法,掌握思路最清晰也最通用的used数组写法即可。
  7. 本题像78一样,也可以不写终止条件,因为startIndex的大小在for循环中已经得到了限制。

链接

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 ++即可。