基础算法精讲

🐌😇

基础算法精讲专题

01 相向双指针

167. 两数之和 II - 输入有序数组

双指针思路:

对于一个递增有序的数组来说,查找数组中两数之和=给定target的值,且整个数组只有唯一答案。

初始化双指针 i指向数组第一个元素,j指向数组最后一个元素,分类讨论如下:

  • numbers[i] + numbers[j] > target,说明 区间[i, j-1]中任何数和numbers[j]相加都大于target ,说明numbers[j]不是最终匹配的数,不考虑numbers[j]j--向前挪动指针,
  • numbers[i] + numbsers[j] < target,说明 区间[i+1, j]中任何数和numbers[i]相加都小于target,说明numbsers[i]不是最终匹配的数,不考虑numbers[i]i++向后挪动指针
  • numbers[i] + numbers[j] == target,匹配成功,退出循环

上述,双指针搜索过程中,利用了数组已按 非递减顺序排列这个性质,通过巧妙初始化两个前后指针,每次比较利用O(1)时间复杂度来==排除一个不符合条件的数字==(利用O(1)时间知道了数组中O(n)复杂度的信息,从而能够优化暴力解法),最终的时间复杂度优化为 O(n)

代码小细节:

  1. 循环退出条件:

    • 由于题目给出了这个index范围1 <= index1 < index2 <= numbers.length,所以while循环可以退出条件可以写为: while(i < j)

    • 当然 由于题目保证:每个输入都有唯一答案,所以也可以写:while(true)

  2. 返回值:

    • 题目明确说明:这是 从下标1 开始的整数数组,例如: [2, 7, 11, 15] target=9,这里返回元素 2 7下标,从1开始就是返回 [1, 2],而数组下标默认从 0开始,故后面的返回值都要+1

完整代码如下:

C++代码

 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:
    vector<int> twoSum(vector<int>& numbers, int target) {
        // 法一:暴力做法  时间复杂度 O(n^2)
//        int i, j;
//        for (i = 0; i < numbers.size(); ++i)
//            for (j = 0; j < numbers.size(); ++j) {
//                if ((numbers[i]+numbers[j]) == target)
//                    break;
//            }
//        return {i, j};
        // 法二:双指针  时间复杂度 O(n)
        // 注意:这是 从下标1 开始的整数数组
        // [2, 7, 11, 15] target=9
        // 这里返回 2 7下标,从1开始就是返回 [1, 2]
        // 而数组下标默认从 0开始,故后面的返回值都要+1
        int i = 0, j = numbers.size()-1;
        // 由于题目给出了这个index范围1 <= index1 < index2 <= numbers.length,所以while循环可以退出条件可以写为如下:
        // 当然 由于题目保证:每个输入都有唯一答案,所以也可以写:while(true)
        while (i < j) {
            if (numbers[i] + numbers[j] > target) j--;
            else if (numbers[i] + numbers[j] < target) i++;
            else break;
        }
        return {i+1, j+1};
    }
};

15. 三数之和

双指针思路

从上一题解法,可以知道,只要数组是有序的,就可以使用相向双指针来解

  • 首先,先对nums数组排序,调用:sort(nums.begin(), nums.end());
  • 从后往前枚举:nums[k],要使得:nums[i] + nums[j] + nums[k] = 0,等价于:nums[i] + nums[j] = -nums[k],那么问题就是在for循环中枚举nums[k]同时,进行两数求和

时间复杂度:外层for循环,套上里边双向指针每次O(n)时间复杂度,总的时间复杂度为O($n^2$)

代码小细节:

  1. for循环这个k退出条件,一开始写k > 2,想着在数组中第0~2为的元素就是最后可能符合解了,但是有一个样例:nums = [0,0,0], 由于我这个判断不通过,不会进入循环,也就没有找出唯一解,为了解决这个问题,把k > 2改为 k>=2,把 控制三个数不相等放在后面while(i < j)中,由于 j初始化本来就比k 小1。所以 只要限制if(i < j),即可达到:i < j < k 的效果

  2. 注意到题目要求有答案中不可以包含重复的三元组,一开始没考虑导致样例不过:

    1
    2
    3
    4
    
    nums = [0,0,0,0]
    //  输出:[[0,0,0],[0,0,0]]
    //  预期结果:[[0,0,0]]
    // 解决:需要去重
    

    后面发现这个要求后,先对数组进行了排序,故每次枚举i检查当前数 是否和前一个数相同,相同跳过; 每次枚举 j 检查当前数是否 和后一个数相同,相同跳过即可; 还有一个 枚举k也需要相同的检查,添加判断语句如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    if (k < nums.size()-1 && nums[k] == nums[k+1]){
                    // 由于每次在 for循环中都会改变 k值,所以这里直接continue,不用改变k值
                    continue;
    }
    				if (i > 0 && nums[i] == nums[i-1]) {
                        i++; continue;
                    }
                    if (j < k-1 && nums[j] == nums[j+1]) {
                        j--; continue;
                    }
    

    由于这个三数之和可以有多个解,不像上述两数之和只有一个解,一开始只考虑一个解,while循环逻辑写成:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
      while (i < j) {
                    if (i > 0 && nums[i] == nums[i-1]) {
                        i++; continue;
                    }
                    if (j < k-1 && nums[j] == nums[j+1]) {
                        j--; continue;
                    }
                    if(nums[i] + nums[j] < -nums[k]) i++;
                    else if(nums[i] + nums[j] > -nums[k]) j--;
                    else break;
                }
                if (i < j)
                    vi.push_back({nums[i], nums[j], nums[k]});
    

    即找到一个符合要求的两数之和等于-nums[k]我就break跳出循环了,实际上是需要继续枚举的,一开始没有考虑导致卡在这个样例[-1,0,1,2,-1,-4,-2,-3,3,0,4],这个样例比如nums[k]=-3,而nums[i], nums[j]可以有多种组合,修改上述代码while循环如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
     while (i < j) {
                    if (i > 0 && nums[i] == nums[i-1]) {
                        i++; continue;
                    }
                    if (j < k-1 && nums[j] == nums[j+1]) {
                        j--; continue;
                    }
                    if(nums[i] + nums[j] < -nums[k]) i++;
                    else if(nums[i] + nums[j] > -nums[k]) j--;
                    else {
                        vi.push_back({nums[i], nums[j], nums[k]});
                        i++, j--;
                    }
                }
    

完整代码如下:

 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<vector<int>> threeSum(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        int i, j, k;
        vector<vector<int>> vi;
        // 枚举 nums[k]
        // 要使得:nums[i] + nums[j] + nums[k] = 0
        // 相当于:nums[i] + nums[j] = -nums[k]
        // 转换成两数之和,使用双向指针求解
        // 时间复杂度:O(n^2)
        // 这里有个边界情况:k > 2时,样例:nums = [0,0,0]
        // 由于判断不通过,不会进入循环,也就没有找出唯一解,为了解决这个问题,把k > 2改为 k>=2
        // 把 控制三个数不相等放在后面 if (i < j)中,由于 j初始化本来就比k 小1
        // 所以 只要限制if(i < j)即可达到:i < j < k 的效果
        for (k = nums.size()-1; k >= 2; k--) {
            i = 0, j = k-1;
            if (k < nums.size()-1 && nums[k] == nums[k+1]){
                // 由于每次在 for循环中都会改变 k值,所以这里直接continue,不用改变k值
                continue;
            }
            while (i < j) {
                if (i > 0 && nums[i] == nums[i-1]) {
                    i++; continue;
                }
                if (j < k-1 && nums[j] == nums[j+1]) {
                    j--; continue;
                }
                if(nums[i] + nums[j] < -nums[k]) i++;
                else if(nums[i] + nums[j] > -nums[k]) j--;
                // 注意这里由于不是限制说只有一个解,所以需要一直循环,把所有满足条件解都找到
                else {
//                    continue;
                    vi.push_back({nums[i], nums[j], nums[k]});
                    // 注意要改变一下 i, j的值,不然会死循环
                    i++; j--;
                }
            }
//            if (i < j)
//                vi.push_back({nums[i], nums[j], nums[k]});
        }
        return vi;
    }
};

改进代码

看了灵神讲解,发现只有在nums[i] + nums[j] + nums[k] = 0,就是找到一个解情况下,才需要考虑去重问题,所以在while(i < j)循环中,把去重的部分写到条件匹配语句中,可以避免很多不必要的判断

  • 原来代码:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
     while (i < j) {
                    if (i > 0 && nums[i] == nums[i-1]) {
                        i++; continue;
                    }
                    if (j < k-1 && nums[j] == nums[j+1]) {
                        j--; continue;
                    }
                    if(nums[i] + nums[j] < -nums[k]) i++;
                    else if(nums[i] + nums[j] > -nums[k]) j--;
                    // 注意这里由于不是限制说只有一个解,所以需要一直循环,把所有满足条件解都找到
                    else {
    //                    continue;
                        vi.push_back({nums[i], nums[j], nums[k]});
                        // 注意要改变一下 i, j的值,不然会死循环
                        i++; j--;
                    }
                }
    
  • 改进代码:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
     while (i < j) {
                    if(nums[i] + nums[j] < -nums[k]) i++;
                    else if(nums[i] + nums[j] > -nums[k]) j--;
                    // 注意这里由于不是限制说只有一个解,所以需要一直循环,把所有满足条件解都找到
                    else {
    //                    continue;
                        vi.push_back({nums[i], nums[j], nums[k]});
                        // 注意要改变一下 i, j的值,不然会死循环
                        i++; 
                        while(i < j && nums[i] == nums[i-1]) {
                        	i++; // 这里找到一个符合的nums[i]了,执行过i++, i > 0一定成立
                    	}
                        j--;
                     	while(j > i && nums[j] == nums[j+1]) {
                        	j--; // 这里找到一个符合的nums[j]了,执行过j--,j < k-1一定成立
                    	}
    
                    }
                }
    

另外有两个小优化:

  • 如果 nums[k] 和离他最近的两个最大的数相加小于0,即:nums[k]+nums[k-1]+nums[k-2] < 0,可以直接break中止掉了,后面的枚举都不会有解了

  • 如果nums[k]和剩余数字中最小的两个数相加大于0,即:nums[k]+nums[i]+nums[i+1] > 0,那么可以枚举下一个nums[k]了,在[i, j]中找"两数之和"了

  • 添加代码如下:

    1
    2
    3
    4
    
    if (k >= 2 && nums[k]+nums[k-1]+nums[k-2]<0)
        break;
    if (k >= 2 && nums[i]+nums[i+1]+nums[k]>0)
        continue;
    

完整代码:

 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:
      vector<vector<int>> threeSum(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        int i, j, k;
        vector<vector<int>> vi;
        for (k = nums.size()-1; k >= 2; k--) {
            i = 0, j = k-1;
            if (k < nums.size()-1 && nums[k] == nums[k+1]){
                // 由于每次在 for循环中都会改变 k值,所以这里直接continue,不用改变k值
                continue;
            }
            if (k >= 2 && nums[k]+nums[k-1]+nums[k-2]<0)
                break;
            if (k >= 2 && nums[i]+nums[i+1]+nums[k]>0)
                continue;
            while (i < j) {
                if(nums[i] + nums[j] < -nums[k]) i++;
                else if(nums[i] + nums[j] > -nums[k]) j--;
                    // 注意这里由于不是限制说只有一个解,所以需要一直循环,把所有满足条件解都找到
                else {
//                    continue;
                    vi.push_back({nums[i], nums[j], nums[k]});
                    // 注意要改变一下 i, j的值,不然会死循环
                    i++;
                    while(i < j && nums[i] == nums[i-1]) {
                        i++; // 这里找到一个符合的nums[i]了,执行过i++, i > 0一定成立
                    }
                    j--;
                    while(j > i && nums[j] == nums[j+1]) {
                        j--; // 这里找到一个符合的nums[j]了,执行过j--,j < k-1一定成立
                    }

                }
            }
        }
        return vi;
    }
};

课后作业

16.最接近的三数之和

18.四数之和

2824.统计和小于目标的下标对数目

611.有效三角形的个数

02相向双指针

11. 盛最多水的容器

双指针思路

面积公式:S(i,j)=min(h[i],h[j])×(j−i)

  • 一开始初始化两个指针指向 数组两端left right,盛水容量取决于 高度较低的那一端

  • 在数组中间[left, right] 范围内随便挑选一个下标为 k 的高度和较短的 一端构成容器

    • height[k] < min(height[left], height[right]):由于底边长 < 原变长 ,故容量小于原来的体积
    • height[k] > min(height[left], height[right]):由于底边长 < 原变长,高度取决于短的一段,故容量小于原来的体积
  • 通过上面的分析,可以发现:height[left]和height[right]中短的一段保留移动 另一端已经无法获得比当前更大容量,移动指向高度较短的一端的指针,放弃较小边,以获得较高边-较大容量的机会

时间复杂度分析:O(n);空间复杂度分析:O(1)

对比暴力枚举:水槽两板围成面积 S(i,j) 的状态总数为C(n,2)

完整代码:

 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:
    int maxArea(vector<int>& height) {
        int left = 0, right = height.size()-1;
        int maxVal = 0;
        while (left < right) {
            maxVal = max(maxVal, min(height[left], height[right]) * (right - left));
            if (height[left] > height[right]) right--;
            else  left++;
        }
        return maxVal;
    }
    // 更加优雅写法
    int maxArea1(vector<int>& height) {
        int i = 0, j = height.size() - 1, res = 0;
        while(i < j) {
            res = height[i] < height[j] ?
                  max(res, (j - i) * height[i++]):
                  max(res, (j - i) * height[j--]);
        }
        return res;
    }
};

42. 接雨水

算法思路

image-20231226194235657

如上图,要求每个柱子所能接多少雨水,取决于当前柱子左边隔板最大高度 与 右边隔板最大高度,两个最大高度中取最小值,乘上柱子边长减去柱子本身高度,即为:柱子所能接收雨水的量。

按照上述的思路,这道题可以使用前后缀最大值的思路来求解,下面两种方法:

  • 方法一是使用两个数组来存储前后缀最大值,然后通过再遍历一遍数组,获取所有柱子所能接收雨水的量;
  • 方法二则是使用双指针,本质上是用两个常量保存当前指针 所指柱子前后缀最大值,节省了数组空间。

前后缀分解预处理代码:

1
2
3
4
5
6
7
8
9
    vector<int> pre_max;  // 创建两个数组,用于存储前缀最大值 和 后缀最大值
    pre_max.push_back(height[0]);
    int size = height.size();
    for (int i = 1; i < size; ++i)
        pre_max.push_back(max(pre_max[i-1], height[i]));
    vector<int> suf_max(size, 0);
    suf_max[size-1] = height[size-1];
    for (int i = size-2; i >= 0; --i)
        suf_max[i] = max(suf_max[i+1], height[i]);

时间复杂度:O(n);空间复杂度:O(n)

双指针思路:

不同于上述前后缀数组的预处理,这里需要思考一下如何移动两个指针的问题?

假设左边指针 指向的柱子的左边的部分前缀最大和已知,右边指针指向的柱子的右边的部分后缀最大和已知,如下图:

image-20231226200136266

  • 对于==右边绿线描绘的柱子==,右边后缀最大和已知,左边部分后缀最大和也已知,由于柱子的容量总是有 最短木板效应,若是 右边后缀最大和 < 左边部分后缀最大和,而左边部分后缀最大和总是非递减的,此时可以直接计算==右边绿线柱子==所能接水容量,然后 右指针向左移动
  • 对于==左边绿线描绘的柱子==,左边后缀最大和已知,右边部分后缀最大和已知,由于柱子的容量总有 最短木板效用,若是 左边后缀最大和 < 右边部分后缀最大和,而右边部分后缀最大和也是非递减的,此时可以直接计算==左边绿线柱子==所能接水容量,然后 左指针 向右移动

时间复杂度:O(n);空间复杂度:O(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
class Solution {
public:
    int trap(vector<int>& height) {
        int preMax = 0, sufMax = 0;
        int left = 0, right = height.size()-1;
        int ans = 0;
        // 双指针这里不需要等号吧,如果等于的时候还有容量,
        // 说明这个位置两边必定有大于该位置的数,那么在循环过程中就会先计算这个位置了
        // 不加等号也是可以的,最后必定会停在一个无法接水的位置。
        while (left <= right) {
            preMax = max(preMax, height[left]);
            sufMax = max(sufMax, height[right]);
            if (preMax < sufMax) {
                ans += preMax-height[left++];
            } else {
                ans += sufMax-height[right--];
            }
        }
        return ans;
    }
    int trap1(vector<int>& height) {
        vector<int> pre_max;  // 创建两个数组,用于存储前缀最大值 和 后缀最大值
        pre_max.push_back(height[0]);
        int size = height.size();
        for (int i = 1; i < size; ++i)
            pre_max.push_back(max(pre_max[i-1], height[i]));
        vector<int> suf_max(size, 0);
        suf_max[size-1] = height[size-1];
        for (int i = size-2; i >= 0; --i)
            suf_max[i] = max(suf_max[i+1], height[i]);
        int ans = 0;
        for (int i = 0; i < size-1; ++i)
            ans += (min(pre_max[i], suf_max[i]) - height[i]);

        return ans;
    }
};

注:一般把窗口大小不固定的技巧称为 双指针,大小固定的技巧称为 滑动窗口。

03 同向双指针 滑动窗口

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
25
26
27
class Solution {
public:
    // 时间复杂度:O(n)
    // 空间复杂度:O(1)
    int minSubArrayLen(int target, vector<int>& nums) {
        int size = nums.size();
        int ans = size+1, left = 0;
        int sum = 0;
        for (int i = 0; i < size; ++i) {
            sum += nums[i];
            // 单调性:
            // 对于本题而言,left移动时,子数组的和是在不断变小的
            // while循环里边的条件不断变化,从满足要求——> 不满足要求
            // 只有满足了 单调性,才可以使用双指针
            while(sum >= target) {
                ans = min(ans, i - left + 1);
                sum -= nums[left];
                left += 1;
            }
        }
        return ans <= size ? ans : 0;
        // if (ans == size+1)
        //     return 0;
        // else
        //     return ans;
    }
};

713. 乘积小于 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
public:
    int numSubarrayProductLessThanK(vector<int>& nums, int k) {
        // 暴力解法:由于每次 right = left 指针回溯
        // 故时间复杂度为:O(n^2)
        int len = nums.size();
        int ans = 0;
        int product = 1;
        int left = 0, right = left;
        while (left < len) {
            while (right < len && product * nums[right] < k) {
                ans += 1;
                product *= nums[right];
                right++;
            }
            left++; right = left;
            product = 1;
        }
        return ans;
    }
    int numSubarrayProductLessThanK1(vector<int>& nums, int k) {
        // 固定右端点,从 0 开始拓展,用下标 right 表示,表示 以right 为结尾的子数组
        // 左端点默认从 0 开始
        // 这里需要使用前缀 成绩数组预处理一下 这样就需要开一个 n大小数组,空间复杂度过高
        // 从上面 由左到右 遍历的话,前缀乘积数组可以暂时 用一个sumProduct 暂存,这样就可以达到O(1)时间复杂度
        // 加速代码如下
        ios::sync_with_stdio(0);
        cin.tie(0);
        cout.tie(0);
        if (k <= 1) return 0;  // 特判,防止 后面循环出错

        int len = nums.size();
        int ans = 0;
        int product = 1;
        int right = 0, left = 0;
        while (right < len) {
            product *= nums[right];
            // 这里循环 product >= k要保证 left <= right,前提是:k <= 1
            while (product >= k) {
                product /= nums[left++];
            }
            ans += (right - left + 1);
            right++;
        }
        return ans;
    }
};

3. 无重复字符的最长子串

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        map<char, int> mp;
        int ans = 0;
        int r = 0, l = 0;
        while (r < s.length()) {
            if (mp.count(s[r]) > 0)
                mp[s[r]] += 1;
            else mp[s[r]] = 1;
            while (mp[s[r]] > 1) {
                mp[s[l++]] -= 1;
            }
            ans = max(ans, r-l+1);
            // 注意 r++,否则会出现 死循环问题
            r++;
        }
        return ans;
    }
};

课后作业

1004.最大连续 1 的个数 III

1234.替换子串得到平衡字符串

1658.将 x 减到 0 的最小操作数

2962.统计最大元素出现至少 K 次的子数组

04 二分查找 红蓝染色法

二分查找讲解

二分查找在 闭区间上的写法,对比开区间 和 半闭半开区间 三种写法的区别

问题: 返回 有序数组中第一个 $\geq$ 8的数的位置,如果所有数都 < 8,返回数组长度

image-20231229164938347

暴力做法: 遍历每个数,询问它是否 $\geq$ 8 ?

  • 时间复杂度:O(n)

高效做法:

解法一(左闭右闭)

上述暴力做法没有利用到 数组是有序这个性质,在解题时考虑数组有序性质。初始化两个指针,L和R分别指向询问的左右边界,即:闭区间[L, R], M指向当前正在询问的数

  • 红色背景:表示false,即:< 8
  • 蓝色背景:表示true,即:$\geq$ 8
  • 白色背景:表示不确定

image-20231229164953288

  • M是==红色== => [L, M]都是==红色==,剩余不确定的区间为 [M+1, R]。因此下一步 $L \space \longleftarrow \space M+1$,这同时也说明: L-1 指向的一定是==红色==。 (注:如果改成 L = M,就变成 左开右闭 区间进行处理,这种做法会导致死循环)

    image-20231229165011395

  • 继续询问

    image-20231229181732162

  • M是 ==蓝色== => [M, R]都是 ==蓝色==,剩余不确定的区间为 [L, M-1 ]。因此下一步 $R \space \longleftarrow \space M-1$ ,这同时也说明:R+1 指向一定是==蓝色==,更新如下:

    image-20231229182017926

  • 继续询问

    image-20231229182112655

  • 继续更新R,此时:R < L,循环结束。此时要查找的数是哪个指针指向的下标呢? 注意循环不变量

    • L - 1 始终是 ==红色==
    • R + 1始终是 ==蓝色==

    根据循环不变量,可以知道R + 1就是我们要找的答案。由于循环结束后,R + 1 = L,所以答案也可以用L表示

    image-20231229182221885

解法二(左闭右开区间)

把区间的左端点 或者 右端点,当成是开区间,例如:初始化R = length,指向数组最后一个元素的后一个位置,注意几个分支中 L、R指针的移动条件:

  • nums[mid] >= target:R = M; // 这里缩小区间为 [L, M),故可以把 R直接用M赋值
  • nums[mid] < target:L = M+1; // 这里缩小区间为 [M+1, R),需要把左端点赋值给L
  • 当 L = R时,左闭右开区间 没有元素了,情况结束,所以while的循环条件为:L < R,此时L 与R指针都是指向 所要找的区间端点答案,直接返回即可

解法三(左开右开区间)

思路类似解法二,只需要把条件分支修改L指针,改成L = M; 接着把while循环条件写成:while(L + 1 < R)即可。

小结

如何处理:$\geq \space > \space < \space \leq$ 这四种情况,最后解决题目

上述的解法针对的题目背景如下:

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。

上述的思路都是在讲解:在数组nums中找到一个最左边的下标low,有:nums[low] >= target,解法一左闭右闭区间的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
		// 左闭右闭 区间写法
        // while循环条件为:left <= right
        std::function<int(vector<int>, int)> lower_bound = [&] (vector<int> nums, int target){
            int left = 0, right = nums.size()-1; // 闭区间[left, right]
            while (left <= right) { // 区间不为空
                // left + (right - left) / 2 这种写法可以防止整数溢出问题
                // 普通写法:mid = (left + right) / 2
                int mid = (left + right) / 2;
                if (nums[mid] < target) {
                    left = mid + 1;  // [mid+1, right]
                } else {
                    right = mid - 1;  // [left, mid-1]
                }
            }
            return left;
        };

接口参数说明:lower_bound(vector<int> nums, int target);,这里返回值是找到数组中从左往右数 第一个nums[low] >= target的下标 low。

而其他三种情况:找到数组中从左往右数 第一个nums[low] > target下标、找到数组中从右往左数 第一个nums[low] <= target下标、找到数组中从左往右数 第一个nums[low] < target的下标,都可以由上述这种情况来转换:

  1. 情况一:low1 = lower_bound(nums, target + 1);,low即为所求的下标
  2. 情况二:low2 = lower_bound(nums, target + 1) - 1;, low即为所求的下标
  3. 情况三:low3 = lower_bound(nums, target) - 1;, low即为所求的下标

image-20231229220055076

上面的情况就是如上的数组,闭区间 [low, low2]中的元素都等于target,nums[low3]是指向第一个小于target的数,nums[low1]是指向第一个大于target的数

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

 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
class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        // 左闭右闭 区间写法
        // while循环条件为:left <= right
        std::function<int(vector<int>, int)> lower_bound = [&] (vector<int> nums, int target){
            int left = 0, right = nums.size()-1; // 闭区间[left, right]
            while (left <= right) { // 区间不为空
                // left + (right - left) / 2 这种写法可以防止整数溢出问题
                // 普通写法:mid = (left + right) / 2
                int mid = (left + right) / 2;
                if (nums[mid] < target) {
                    left = mid + 1;  // [mid+1, right]
                } else {
                    right = mid - 1;  // [left, mid-1]
                }
            }
            return left;
// No viable conversion from '(lambda at /mnt/g/CodeFile/CODE_CPP/Data-Structure/Basic/LC-BasicAlgorithm/34.在排序数组中查找元素的第一个和最后一个位置.cpp:13:60)' to 'std::function<int (vector<int>, int)>'
// 注意:一个函数没有 特定类型的返回值,lambda表达式会上面这个错误
//            return 1;

        };
        // 左闭右开 区间写法
        // while循环条件为:left < right
        std::function<int(vector<int>, int)> lower_bound2 = [&] (vector<int> nums, int target) {
            int left = 0, right = nums.size(); // 左闭右开 [left, right)
            while (left < right) { // 区间不为空
                // left + (right - left) / 2 这种写法可以防止整数溢出问题
                // 普通写法:mid = (left + right) / 2
                int mid = (left + right) / 2;
                if (nums[mid] < target) {
                    left = mid + 1;  // [mid+1, right)
                } else {
                    right = mid;  // [left, mid)
                }
            }
            return left;
        };

        // 左开右开 区间写法
        // while循环条件为:left+1 < right
        std::function<int(vector<int>, int)> lower_bound3 = [&] (vector<int> nums, int target) {
            int left = -1, right = nums.size(); // 左开右开 (left, right)
            while (left+1 < right) { // 区间不为空
                // left + (right - left) / 2 这种写法可以防止整数溢出问题
                // 普通写法:mid = (left + right) / 2
                int mid = (left + right) / 2;
                if (nums[mid] < target) {
                    left = mid;  // (mid, right)
                } else {
                    right = mid;  // (left, mid)
                }
            }
            return left;
        };
        int start = lower_bound(nums, target);
        // 表明数组中 没有目标的等于 target的元素
        if(start == nums.size() || nums[start] != target)
            return {-1, -1};
        int end = lower_bound(nums, target + 1) - 1;
        // 如果说 start存在,则end一定会有解,直接返回即可
        return {start, end};

    }
    // 时间复杂度:取决于 lower_bound的复杂度,由于每次都是去掉数组的一半元素
    // 所以这里 数组不断减半,时间复杂度为O(logn)
};

课后作业

搜索插入位置 https://leetcode.cn/problems/search-insert-position/

二分查找 https://leetcode.cn/problems/binary-search/

H 指数 II https://leetcode.cn/problems/h-index-ii/

05 二分查找 搜索旋转排序数组

对于红蓝染色法:

  1. right 左移使右侧变蓝 (判断条件为 true )
  2. left 右移使左侧变红 (判断条件为 false )
  3. 故确定二分处 ( mid ) 的染色条件是关键

注意:红蓝染色的交界处才是 二分查找 目标求解的分界点处。

162. 寻找峰值

下面采用二分查找—左开右开区间写法

 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:
    // 疑问:右边界要初始成为n - 2(如果用闭区间的话),这个是怎么想到的呢?
    // 答:因为 n-1 要么是答案,要么在答案右侧,所以 n-1 一定是蓝色,无需二分。
    // 对于红蓝染色法 区间的说明:
    // 区间表示【不知道要染成红色还是蓝色】的下标组成的范围。
    // 如果区间内全为红色,那么答案自然就是 n-1。
    // 按照定义,n-1 要么是峰顶,要么在峰顶右侧,所以一定染成蓝色,自然就不需要在初始的区间内了。

    // 时间复杂度:O(logn)
    // 空间复杂度:O(1)
    int findPeakElement(vector<int>& nums) {
        // 二分范围:[0, n-2]
        // 开区间为:(-1, n-1)
        int left = -1, right = nums.size()-1;
        while (left + 1 < right) {
            int mid = left + (right - left)/2;
            if (nums[mid] > nums[mid+1]) // 蓝色
                right = mid;
            else
                left = mid;
        }
        // 这里说明为何要 return right
        // 看while循环中分支判断条件:
        //   只有 nums[mid] > nums[mid+1]的时候,将right更新为mid
        //   这里说明:right总是想着峰顶靠近,最后到达封顶的下标位置
        return right;
    }
};

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

红蓝染色法

  • 红色: 表示 false,即 最小值左侧
  • 蓝色:表示true,即最小值及其右侧

根据这一定义,n - 1必然是蓝色的

这样分析下来,这一题可以和上一题一样,在[0, n-2]中二分:

  • nums[mid] < last:那么有两种情况:

    • 在一段 递增数组中
    • 在两段 递增数组中的第二段

    无论哪种情况,这个元素要么是最小值,要么在最小值右侧,这种情况下染成蓝色

  • nums[mid] > last:那么只有一种情况:

    • 排除 nums[mid]在左边(只有 一段递增数组)
    • 之呢个在 两段递增数组中的 前一段

    说明,这个元素nums[mid]一定在最小值左侧,这种情况染成红色

那么二分范围 和 染色规则确定了,可以写代码了

 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) {
        // [0, n-2]
        // (-1, n-1)
        // 采用 左开右开 写法,注意初始化,刚刚初始化left = 0样例过不了了
        int left = -1, right = nums.size()-1;
        while (left+1 < right) {
            // int mid = left + (right - left) / 2;
            int mid = (left + right) / 2;
            if (nums[mid] < nums[nums.size()-1])
                right = mid;
            else
                left = mid;
        }
        // 返回 指针left 或者 right,得看情况
        // right改变分支时,判断语句是:nums[mid] < nums[nums.size()-1]
        // 这说明:mid 指向元素在 最小元素的右侧,或者就是最小元素
        // 等到循环停止时,right恰好指向的是最小元素,直接返回nums[right]
        return nums[right];
    }
};

33. 搜索旋转排序数组

**法一:**两次二分

算法思路:

结合上一题,首先先 寻找旋转排序数组中的最小值,然后再一次二分在一段 升序的序列中寻找target目标值。

两次二分的时间复杂度都为O(log 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
class Solution {
public:
    // 法一:结合上一题:寻找旋转排序数组中的最小值,一次二分找到最小值分界点
    // 然后接下来看 target在哪个升序区间范围内,再一次二分进行查找
    int search(vector<int>& nums, int target) {
        int left = -1, right = nums.size()-1;
        while (left+1 < right) {
            // int mid = left + (right - left) / 2;
            int mid = (left + right) / 2;
            if (nums[mid] < nums[nums.size()-1])
                right = mid;
            else
                left = mid;
        }
        if(target < nums[right]) return -1;
        int l, r;
        // target <= last,表明:target在数组中 第二段递增区间
        // target > last, 表明:target在数组中,第一段递增区间
        if(target <= nums[nums.size()-1]) {
            l = right, r = nums.size()-1;
        } else {
            // 这里的 l r初始化没有考虑到一下这种情况
            // nums = [1] ; target = 1
            // 考虑到的是 nums[right]左边还有 元素情况
            // 万一 nums[right]就是最小值,左边已经没有元素,就会因为没进入循环,误判
            // 添加特判样例
            // 或者 像上面的if判断语句修正:target < nums[nums.size()-1] => target <= nums[nums.size()-1]
            l = 0, r = right -1;
        }
        while(l <= r) {
            int mid = (l + r) / 2;
            if (nums[mid] == target)
                return mid;
            else if(nums[mid] < target) {
                l = mid+1;
            } else {
                r = mid-1;
            }
        }
        return -1;
    }
};

法二: 一次二分

**算法思路:**分三种情况讨论,

【第一种情况】什么时候nums[mid]在 target及其右侧,这种情况染成蓝色;

  1. 如果nums[mid]比最后一个元素大,说明nums[mid]在左边升序数组这一段,若此时target 也大于最后一个数,那么target与nums[mid]在同一段,并且此时nums[mid] >= target(说明nums[mid]在target及其右侧),那么 mid及其右侧都染成蓝色

【第二种情况】nums[mid]小于等于最后一个数,那么nums[mid]在右边升序数组这一段,此时 target 大于等于最后一个数,target在左边这一段,mid及其右侧也染成蓝色

【第三种情况】 (情况二不成立,target在第二段) nums[mid]大于等于 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
class Solution {
public:
    int search(vector<int>& nums, int target) {
        std::function<bool(int)> is_blue = [&] (int i) {
            int end = nums[nums.size()-1];
            if(nums[i] > end) {
                return target > end && nums[i] >= target;
            } else {
                return target > end || nums[i] >= target;
            }
        };

        int left = -1, right = nums.size();
        while (left + 1 < right) {
            int mid = (left + right) / 2;
            if(is_blue(mid))
                right = mid;
            else
                left = mid;
        }
        if(right == nums.size() || nums[right] != target)
            return -1;
        return right;
    }
};

课后作业

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

06 反转链表 K个一组翻转

206. 反转链表

算法思路:

image-20240104004451383

如上图所示,链表每个结点都包含:val(节点值)和指向下一个结点的next指针。

想要反转链表,常见思路是使用两个指针:

  • pre指向当前结点的前一个结点
  • cur指向 当前结点

修改当前cur的next指针指向pre指针指向的前向结点cur->next = pre;,这样就达到了将当前cur指针指向结点next值 “反转"的目的,不过由于直接将cur->next = pre;,若是没有记录指向后续指针结点,那就找不到下一个要"反转"的结点,故需要添加多一个指针nxt,指向当前指针的下一个结点,如下:

image-20240104004938097

修改当前结点的next指针,指向前向结点,如下:

image-20240104005023021

接着,更新cur值和pre值,准备对下一个结点进行"反转”,如下:

image-20240104005115831

如此循环直到把所有结点都修改完,如下:

image-20240104005155557

反转结束后,从原来链表上看:

  • pre:指向反转这一段的末尾
  • cur:指向反转链表末尾的下一个节点

完整代码:

 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
struct ListNode {
   int val;
     ListNode *next;
     ListNode() : val(0), next(nullptr) {}
     ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    // 自己的写法
    ListNode* reverseList(ListNode* head) {
        // head = [1,2,3,4,5]
        // cout << head->val << endl;s
        // 输出:1
        if(head == nullptr)
            return head;
        ListNode *p = head->next, *q = nullptr;
        while(p) {
            head->next = q;
            q = head, head = p;
            p = p->next;
        }
        if(head) head->next = q;
        return head;
    }
    // 博主的思路
    // 执行用时:击败100% 用户
    ListNode* reverseList1(ListNode* head) {
        ListNode *pre = nullptr;
        ListNode *cur = head, *nxt = nullptr;
        while(cur) {
            nxt = cur->next;
            cur->next = pre;
            pre = cur;
            cur = nxt;
        }
        return pre;
    }
};
  • 时间复杂度:O(n)

  • 空间复杂度:O(1)

92. 反转链表 II

算法思路:

想要反转 链表中部分结点,应该如何做?

==记住重要性质==:

  • pre:指向反转这一段的末尾
  • cur:指向反转链表末尾的下一个节点

image-20240104005747397

具体来看下面这个示例:

image-20240104005846245

按照第一题的性质,中间一段反转之后,结点2的next值指向nullptr,此时pre指针指向结点4、cur指针指向结点5,增加的操作:

  • 将结点2的next值指向cur指针指向的结点
  • 将结点1的next值指向pre指针指向的结点

==特殊情况==:当left=1,即要反转的一段链表的第一个结点就是头结点时,没有上面的$p_0$所指向的结点,此时可以在原链表 head头结点前面加上一个哨兵结点,如下:

image-20240104010345025

完整代码:

 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
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    ListNode* reverseBetween(ListNode* head, int left, int right) {
        // 初始化哨兵结点,next值指向 head
        ListNode *dummy = new ListNode(-1, head);
        ListNode *p0 = dummy;
        // p0指向要反转链表部分 第一个结点的上一个结点
        for(int i = 0; i < left-1; ++i)
            p0 = p0->next;

        ListNode *pre = nullptr;
        ListNode *cur = p0->next, *nxt = nullptr;
        int len = right - left + 1;
        for(int i = 0; i < len; ++i){
            nxt = cur->next;
            cur->next = pre; // 核心,每次反转修改的 指针指向
            pre = cur;
            cur = nxt;
        }
        p0->next->next = cur;
        p0->next = pre;

        // 若是返回head,当left=1,会被一下案例卡住测试
        // [3,5]
        // left=1, right=2
        // [5,3]
        // 故需要返回 dummy哨兵结点指向的下一个结点 (保证为链表的头结点)
        return dummy->next;
    }
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

25. K 个一组翻转链表

思路和上一题92一致,补充的操作:把p0更新成下一段要翻转的链表的前一个节点

image-20240104014254879

如上图:由于翻转K个结点之后,结点1变成了 要翻转的链表的前一个结点,即是p0->next原来的值,由于翻转过程中会修改p0->next,故:

提前创建一个临时变量 nxt = p0->next;,最后更新p0 = nxt;,开启下一轮循环。

image-20240104014554075

如上图,最后修改整个链表之后,发现哨兵结点的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
42
43
44
45
46
47
48
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    ListNode* reverseKGroup(ListNode* head, int k) {
        int num = 0;
        ListNode *p = head;
        // 首先获取链表结点数 num
        while(p) {
            num++;
            p = p->next;
        }
        // 初始化哨兵结点,next值指向 head
        ListNode *dummy = new ListNode(-1, head);
        ListNode *p0 = dummy;

        // 这里 pre, cur, nxt指针初始化可以放在循环外部
        // 从内层循环分析,每次翻转K个结点之后,cur恰好指向下一组K个结点中第一个结点位置
        // nxt 在进入循环就 赋值给下一组K个结点的 下一个结点
        // pre虽然没有能每次都在 cur指向结点前一个结点,但是其初始值并不会影响什么
        // 一开始 将pre赋值给 第一个结点的next指针,后面翻转最后一步 第一个结点的Next指针也需要重新赋值
        // 所以没有任何影响
        ListNode *pre = nullptr;
        ListNode *cur = p0->next, *nxt = nullptr;
        while(num >= k) {
            num -= k;
            for(int i = 0; i < k; ++i){
                nxt = cur->next;
                cur->next = pre; // 核心,每次反转修改的 指针指向
                pre = cur;
                cur = nxt;
            }
            nxt = p0->next;
            p0->next->next = cur;
            p0->next = pre;
            p0 = nxt;
        }
 		// K个一组翻转链表之后, 哨兵结点dummy 的next还是指向头结点
        // 上面p0 一开始赋值就是哨兵结点,也有参与指针运算
        return dummy->next;
    }
};

课后作业

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

445. 两数相加 II

2816.翻倍以链表形式表示的数字

07 快慢指针 环形链表 重排链表

876. 链表的中间结点

简单思路:

遍历一遍链表求得 链表结点个数len,求中间结点编号 mid = len/2 + 1;,然后从head结点1开始遍历到mid结点即可返回

 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
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    ListNode* middleNode(ListNode* head) {
        ListNode *p = head;
        int len = 0;  // 由于最后p = nullptr才退出,从p = head到p = nullptr,一共经过结点个数就是长度
        while(p) {
            p = p->next;
            len++;
        }
        int mid = len / 2 + 1;
        p = head;
        for (int i = 2; i <= mid; ++i){
            p = p->next;
        }
        return p;
    }
};

快慢指针思想:

image-20240109000329834

如上图,初始化两个指针slowfast指针指向头结点,每次循环:快指针走两步,慢指针走一步

比如长度为3的时候,循环一次后,慢指针指向中间结点,如下:

image-20240109000648552

可以通过数学归纳法,证明:

  • 奇数长度链表:快指针指向最后一个结点时,慢指针指向的恰好是中间结点
  • 偶数长度链表:快指针指向空,慢指针一定在其规定的中间结点上

image-20240109000900706

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Solution {
public:
    ListNode* middleNode(ListNode* head) {
       ListNode *slow = head, *fast = head;
       while(fast && fast->next){
           fast = fast->next->next;
           slow = slow->next;
       }
       return slow;
    }
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

141. 环形链表

完整代码:

 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
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};

class Solution {
public:
    // 思路一:暴力 数据范围 10^4,那么走 10^4 次还没走到头就是有环了

    // 思路二:快慢指针
    // fast 和 slow指针 同时开始
    // 每次 fast指针移动两个结点,slow指针移动一个结点
    // 当 fast 追上 slow 有环,反之如果 fast直接指向nullptr末尾,无环
    // 进阶:你能用 O(1)(即,常量)内存解决此问题吗?
    bool hasCycle(ListNode *head) {
        ListNode *fast = head, *slow = head;
        while (fast && fast->next) {
            slow = slow->next;
            fast = fast->next->next;
            if(fast == slow)
                return true;
        }
        return false;
    }
};

上面说明:当fast追上slow指针时,说明该单链表中存在环。不过这暗示着,slow指针一定会走到环里边,这是两个指针相遇的充分必要条件。用相对速度思考,fast指针相对于slow指针每次循环都走一步,最后若是存在环,fast指针会循环回来 遇上 slow指针。

  • 时间复杂度:由于slow指针 进入环后,循环次数 < 环的长度,故复杂度为O(n)
  • 空间复杂度:O(1)

142. 环形链表 II

代码思路:

这题不仅需要判断是否有环,还需要找到 环的入口

设 头结点到入口距离为a,入口到相遇点 距离为b,相遇点到入口的距离为c

首先这里有个结论:当快慢指针相遇时,慢指针还没有走完一整圈

image-20240109002703479

image-20240109002716304

image-20240109003615579

思考:快慢指针第一次相遇时,为什么慢指针 的移动距离小于环长?

  • 最好情况:慢指针 入环就和快指针 相遇
  • 最坏情况:慢指针第一次进入环时,快指针刚好在慢指针前面,用相对速度分析:快指针需要 走(环长 - 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
/**
 * Definition for singly-linked list.
 **/
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};

class Solution {
public:
    // 法一:哈希表+ 设定一个最多循环次数

    // 法二:利用快慢指针相对的 速度,通过数学推导来找环 入口
    ListNode *detectCycle(ListNode *head) {
        ListNode *slow = head, *fast = head;
        while(fast && fast->next) {
            slow = slow->next;
            fast = fast->next->next;
            if(fast == slow) {
                while(slow != head){
                    slow = slow->next;
                    head = head->next;
                }
                return slow;
            }
        }
        return nullptr;
    }
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

143. 重排链表

代码思路:

首先找到中间结点3,然后把中间结点后边这段 链表反转,得到如下的顺序:5->4->3,结合之前讲的链表的中间结点 以及 上面的 反转链表 来完成这两步,得到两段方向指向不同的链表,如图1:

image-20240112134942530

将右边链表的头结点 称为 head2,如何合并?每次循环的时候,先用p = head->next 保存head的next指针,接着修改指针指向:head->next = head2; q = head2->next; head2->next = p;

image-20240112202229394

如此不断循环,知道 head2 指向 结点3,或者 head2->next = nullptr,就可以退出循环。

梳理一下整个流程大概如下:

  • 找到链表中间结点 mid
  • 将 链表后半段 进行反转
  • 按照顺序重排指针

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(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
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    // 重排链表思路如下:
    void reorderList(ListNode* head) {
        ListNode *fast = head, *slow = head;
        // 1. 快慢指针 找到中间结点 slow
        while(fast && fast->next){
            fast = fast->next->next;
            slow = slow->next;
        }
        // 2. 反转后半段 链表
        ListNode *pre = nullptr;
        ListNode *cur = slow, *nxt = nullptr;
        while(cur){
            nxt = cur->next;
            cur->next = pre;
            pre = cur;
            cur = nxt;
        }
        // 3. 循环迭代 每次修改head->next 指针 和 head2->next 指针
        ListNode *head2 = pre, *p = nullptr, *q = nullptr;
        while(head2->next){
            p = head->next; // 暂存 head下一个结点
            head->next = head2; // 第一次重排
            q = head2->next; // 暂存 head2下一个结点
            head2->next = p; // 第二次重排
            head = p, head2 = q; // head 和 head2 指向原来的next结点
        }
    }
};

课后作业

234. 回文链表

08 前后指针 链表删除 链表去重

单链表删除结点整体思路:

对于单链表来说,要想删除其中的结点,需要利用其 上一个结点,也就是pre结点,做法:把上一个结点指向要删除结点的 下一个结点。如下图:

image-20240112213020521

注意:这个 “被删除结点"还是存在的,如果语言自带垃圾回收机制,那它会帮你回收这部分内存,如果是C++可以手动回收,避免内存泄漏。

237. 删除链表中的节点

完整代码:

 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
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};

class Solution {
public:
    // 注意:要先 用node下一个结点 val 替换 当前 node的val,再进行指针操作
    // 否则,可能后面 while循环条件原因,样例:
    // [4,5,1,9] 1
    // 无法通过测试
    void deleteNode(ListNode* node) {
        ListNode* p = node->next;
        // 定义 指针p指向 node指针的下一个结点
        // 最后删除一个结点的时候,p指向最后一个结点,node指向末尾第二个结点
        // 直接将 node的next指针指向空,即为删除一个结点
        node->val = p->val;
        while(p->next){
            node = p, p = p->next;
            node->val = p->val;
        }
        node->next = nullptr;
    }
};

19. 删除链表的倒数第 N 个结点

代码思路:

首先遍历一遍链表 求出长度len,题目要求即为 删除索引为 len-n+1的结点(索引从1开始),需要先找到 索引为 len-n的结点 修改其next指针,从而达到 删除结点。 **注意:**为了保证操作一致性,需要 在head结点之前 申请一个first结点

完整代码:

 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
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    // 思路:首先遍历一遍链表 求出长度len,题目要求即为 删除索引为 len-n+1的结点(索引从1开始)
    //      需要先找到 索引为 len-n的结点 修改其next指针,从而达到 删除结点
    //      注意:为了保证操作一致性,需要 在head结点之前 申请一个first结点
    ListNode* removeNthFromEnd(ListNode* head, int n) {
        ListNode *first = new ListNode(0, head);
        ListNode *pre = first, *cur = head;
        int len = 0;
        while(cur){
            len++;
            cur = cur->next;
        }
        int index = len - n + 1;
        for(int i = 0; i < index-1; ++i)
            pre = pre->next;
        cur = pre->next;
        pre->next = cur->next;
        delete cur;
        return first->next;
    }
};

83. 删除排序链表中的重复元素

代码思路:

略。

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        ListNode *p = head, *q = nullptr;
        while(p){
            if(p->next && p->next->val == p->val){
                while(p->next && p->next->val == p->val)
                    q = p->next, p->next = q->next, delete q;
            }
            p = p->next;
        }
        return head;
    }
};

82. 删除排序链表中的重复元素 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
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        ListNode* p = head, *q = nullptr, *pre = nullptr;
        // 技巧:在 head头结点 前面再定义一个 指引结点
        ListNode* first = new ListNode(0, head);
        // 注意!!! first->next = head
        // 技巧:定义pre指针,指向当前 循环结点p 的前一个结点
        //      这样就还可以 在遇到重复元素的结点p的时候,修改pre->next指针,删除p结点
        pre = first;
        while(p) {
            if(p->next && p->next->val == p->val){
                // 出现重复数字结点,只保留当前第一个重复数字结点
                while(p->next && p->next->val == p->val)
                    q = p->next, p->next = q->next, delete q;
                // 剔除 重复数字的 唯一结点
                pre->next = p->next;
                delete p;
                p = pre->next;
            } else{
                pre = p;
                p = p->next;
            }
        }
        return first->next;
    }
};

课后作业

203. 移除链表元素

09 二叉树 递归 数学归纳法 栈

如何思考递归?为什么需要使用递归?为什么递归是对的?计算机是怎么执行递归过程的?

二叉树与递归问题思考:

image-20240114022345520

  1. 如何思考二叉树相关问题:

    image-20240114022908635

  2. 为什么需要使用递归?

    image-20240114022942859

    执行同一份代码,循环与递归的差异:

    • 循环:可以在循环内部 操作循环外部的变量。每次操作的都是同一个变量
    • 递归:问题之间是有嵌套关系的,需要把计算结果返回给 上一级问题做计算。

    事实上,动态规划也是递归的一种。把原问题拆分成子问题,然后拆分到最后子问题答案显而易见,就得到了整个问题的解。

    image-20240114023331989

    在上述问题中,递归的边界条件就是空结点,直接返回0作为空结点树的深度。返回的过程就是递归中的

  3. 想清楚上面问题使用递归解决是对的

    递归问题:边界条件情况怎么计算,非边界条件情况怎么计算都想清楚了。现在需要证明正确性:

    数学归纳法:

    • 命题中最基本的情况——边界条件处理
    • 命题中相邻规模情况之间的联系——非边界条件处理

    => 从上边两个条件,可以推出最终的结论是对的

  4. 计算机如何执行递归?

    递归每次计算之后,需要把 计算结果返回给它的父节点,此时要知道此时结点的父节点是谁,那就需要提前将父节点的信息保存起来。==要知道返回给谁==是需要一个数据结构的,这个数据结构有个特点就是 “后进先出”,也就是 栈。

    最坏情况下,二叉树只有左儿子没有右儿子,那这课二叉树就变成一个链状结构。那这个情况下,栈的大小就会达到O(n),因此空间复杂度为O(n)。

  5. 另一种递归思路:

    在递归过程中,除了把结点传下去,还可以把路径上的结点传下去。在递归过程中,还可以维护一个全局变量,每次路径上结点+1之后,就更新全局变量的最大值。遍历完整颗树,这个全局变量就是答案了。

104. 二叉树的最大深度

最大深度定义:根节点到最远叶子结点的最长路径上的节点数。

完整代码:

递归思路一:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Solution {
public:
    int maxDepth(TreeNode* root) {
        // 边界条件
        if(root == nullptr) return 0;
        // 递归计算左子树的最大深度
        int leftDepth = maxDepth(root->left) + 1;
        // 递归计算右子树的最大深度
        int rightDepth = maxDepth(root->right) + 1;
        return max(leftDepth, rightDepth); // 最后返回二者的最大值
    }
};

递归思路二:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Solution {
public:
    int maxDepth(TreeNode* root) {
        int ans = 0;
        std::function<void(TreeNode*, int)> func = [&] (TreeNode* root, int cnt) {
            if (root == nullptr)
                return;
            cnt += 1;
            ans = max(ans, cnt);
            func(root->left, cnt);
            func(root->right, cnt);
        };
        func(root, 0);
        return ans;

    }
};

时间复杂度:O(n)

空间复杂度:O(n)

课后作业

111. 二叉树的最小深度

112. 路径总和

113. 路径总和 II

129. 求根节点到叶节点数字之和

257. 二叉树的所有路径

1448. 统计二叉树中好节点的数目

10 二叉树 相同 对称 平衡 右视图

前一小节讲了解决二叉树问题的两种递归思路,但要灵活运用递归,还需要通过做题目来打开思路。

100. 相同的树

解题思路:

定义相同:对于两棵二叉树,根结点val相同,并且左右子树相同。

  • 这样原问题就可以拆分为两个子问题(两棵子树是否相同)来进行判断—非边界条件
  • 两棵子树有一棵是 空的,无法继续递归下去,如果两个结点都为空就返回true,否则就返回false—边界条件

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

class Solution {
public:
    bool isSameTree(TreeNode* p, TreeNode* q) {
        if (p == nullptr || q == nullptr)
            return p == q;  // 同时结点都为空,返回true;否则,返回false

        return p->val == q->val && isSameTree(p->left, q->left) && isSameTree(p->right, q->right);
    }
};

时间复杂度:O(n)

空间复杂度:O(n)

101. 对称二叉树

解题思路:

根结点已经对称,不用管。此时需要看左子树和右子树是否是对称的,注意==这里将左右子树==看作是两棵独立的树,那这样就转换为上述比较两棵树是否"一样"的问题了,不过这里是判断两棵树是否堆成,将递归条件修改一下就可以了。

  • 递归比较 左边的左子树和 右边的右子树,看它们是否对称
  • 递归比较 左边的右子树和 右边的左子树,看它们是否对称

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Solution {
public:
    bool isSymmetric(TreeNode* root) {
        // Variable 'isSymmetric' cannot be implicitly captured in a lambda with no capture-default specified
        // [] 不能在Lambda中隐式获取 with 没有默认获取标识符
        // 使用 [=] 传值会有警告:Variable 'isReflect' is uninitialized when used within its own initialization
        //      并且提交上去会报错
        std::function<bool(TreeNode*, TreeNode*)> isReflect = [&](TreeNode *p, TreeNode *q) {
            if(p == nullptr || q == nullptr)
                return p == q;
            return p->val == q->val && isReflect(p->left, q->right) && isReflect(p->right, q->left);
        };
        return isReflect(root->left, root->right);
    }
};

110. 平衡二叉树

解题思路:

高度平衡二叉树定义为:

一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。

参考之前讲的二叉树的最大深度,通过递归得到左右两棵子树的高度。高度有了,算一下高度差绝对值,就可以判断是否为高度平衡二叉树。

思考:若是这棵树不是高度平衡二叉树,返回值设置为什么?

递归调用 求左右子树最大高度的函数返回值设置为 高度height,而``height >= 0`恒成立,若是高度不平衡可以返回一个负数,就可以统一函数的边界情况返回值 和 非边界情况返回值类型了。

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
    bool isBalanced(TreeNode* root) {
        std::function<int(TreeNode*)> get_height = [&] (TreeNode *node) {
            if(node == nullptr)
                return 0;
            int left_height = get_height(node->left);
            if(left_height == -1)
                return -1;
            int right_height = get_height(node->right);
            if(right_height == -1)
                return -1;
            // 判断左右子树 高度差是否大于1,如果是整棵树不平衡,返回-1;否则,返回两棵子树最大高度+1
            return abs(left_height-right_height)>1 ? -1 : max(left_height, right_height)+1;
        };
        int ans = get_height(root);
        // 这里可以简写为 ans != -1
        return ans == -1 ? false : true;
    }
};

199. 二叉树的右视图

解题思路:

可以先 递归右子树,再递归左子树。两个问题:

  1. 怎么把答案记下来
  2. 怎么判断这个结点是否需要记录到答案中

可以用到前一节讲的一个方法:==在递归的同时记录一个节点个数== 或者 递归深度,如果递归深度等于答案长度 那么这个节点就需要记录到答案中。这句话要怎么理解呢?下面举一个简单例子来说明:

  • 答案初始为空,长度也为0,把根结点 记录
  • 继续递归,深度为1,答案长度也为1,记录当前节点

以此类推。这个利用 右视图中相同高度的节点会 遮挡后边节点原理 这个技巧来完成的。

思路二:层序遍历,取每一层的最后一个节点。

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Solution {
public:
    vector<int> rightSideView(TreeNode* root) {
        vector<int> ans;

        std::function<void(TreeNode*, int)> func = [&] (TreeNode *node, int depth) {
            if(node == nullptr)
                return;
            if(depth == ans.size())
                ans.push_back(node->val);
            func(node->right, depth+1);
            func(node->left, depth+1);
        };
        func(root, 0);
        return ans;
    }
};

课后作业

226.翻转二叉树

1026.节点与其祖先之间的最大差值

1080.根到叶路径上的不足节点

1110.删点成林

1372.二叉树中的最长交错路径

11 二叉搜索树 前序 中序 后序

98.验证二叉搜索树

解题思路:

这道题三种解题思路:

image-20240119033131551

二叉搜索树定义:对于一个节点来说,它的左子树所有节点值都小于 它的值,右子树所有节点值都大于 它的值。同时它的 左右子树都是二叉搜索树。

用不同的角度去看这个定义,就会得到不同的方法:

  • 方法一:观察下边每个节点都有一个对应的区间范围,在这个范围中,对节点元素进行判断,符合要求即为二叉搜索树。

    image-20240119033413649

    使用递归实现,递归的时候,除了传入节点,还需要传入这个开区间的范围

    • 对于每个节点,先判断它的节点值 是否在开区间内,然后往下进行递归
    • 往左边递归,把开区间的右边界更新为节点值
    • 往右边递归,把开区间的左边界更新为节点值

    注意:对于根结点,函数入口,没有父节点,把区间范围更新为(-$\infin$ , +$\infin$ )

  • 方法二:中序遍历所有节点,理论上可以得到一个 严格递增的数组。如果不是二叉搜索树,则不是一个递增数组。

    在遍历过程中记录上一个节点值,比较当前节点值 是否 大于上一个遍历的节点值,如果全部节点值都符合这个规律,则为二叉搜索树。

  • 方法三:后序遍历,将 节点值的范围往上传递,比如:对于 根结点5来说,5大于左子树传上来范围的最大值4,小于右子树传上来范围的最小值6,那么这棵树就符合二叉搜索树的定义。

    image-20240119134810018

    看上去,似乎左子树只需要返回 给父节点一个最大值,右子树只需要返回 给父节点一个最小值就行了,但是这样不对,例如:将下边的节点4 改为 节点值6,若是值传右子树最小值3,那么就无法比较 根结点值5 和 节点值6的大小了,会出现判断失误,如下:

    image-20240119135332501

    为了简化代码逻辑,递归到空结点,这种情况可以返回范围 ($\infin$ , -$\infin$ ),另外中途发现不是一棵二叉搜索树,可以额外返回一个 Flase,或者返回 负无穷到正无穷(这个可以保证 x <= l_max or x >= r_min 这个条件本身不成立。然后每次 递归到最后,返回的是 min(l_min, x), max(r_max, x) 这个为何要 x参与比较,因为一开始空结点的时候,返回的是inf, -inf,使用x参与比较可以把空结点返回值给剔除掉。

    这个题解比较巧妙,需要每次返回一个 pair<int, int>对象(如果使用C++写的话),下边提供一个UP给的python代码,来理解题目:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    class Solution:
        def isValidBST(self, root: Optional[TreeNode]) -> bool:
            def f(node):
                if node is None:
                    return inf, -inf
                l_min, l_max = f(node.left)
                r_min, r_max = f(node.right)
                x = node.val
                if x <= l_max or x >= r_min:  # 这棵树不是二叉搜索树,退出,可以发现下边这个二元组返回的 范围,可以保证 x <= l_max or x >= l_min 这个条件本身不成立,
    #而且 还可以巧妙地发现 在后边符合条件返回 min(l_min, x), max(r_max, x)的时候,还是返回-inf, inf
    #  这样 如果某个节点已经 判断出不是一棵二叉搜索树,那么其最后返回到根结点的二元组还是(-inf, inf)
                    return -inf, inf
                return min(l_min, x), max( r_max, x)
            return f(root)[1] != inf # 这里 如果 返回的最大值为 inf,说明这不是一棵二叉搜索树
    

    复杂度分析:

    • 时间复杂度:O(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
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 Solution {
public:

    // 法一:前序遍历 将节点值传递给子树 进行二叉搜索树判定
    bool isValidBST(TreeNode* root) {
        unsigned int inf = 2.7e9;  // 范围已经超出 2^31 ,如果赋值给 int类型,并且添加符号想着得到负无穷
        // 最后 int类型由于 负数下溢出 会转换为 正数...
        const long long infinit = 0x3f3f3f3f3f3f3f3f;
        std::function<bool(TreeNode*, long long , long long)> isValid = [&] (TreeNode *root, long long left, long long right) {
            if(root == nullptr)
                return true;
            int val = root->val;
            return (val < right && val > left) && isValid(root->left, left, val) && isValid(root->right, val, right);
        };
        long long l = -infinit, r = infinit; // 这里应该 left 为 负无穷,一开始传入成正无穷 样例过不了还检查半天
        // 第一次传入 left, right使用默认的 infinit即可
        return isValid(root, l, r);
    }

    // 法二:中序遍历,检查每个相邻节点值 是否 遵循严格递增顺序
    bool isValidBST1(TreeNode* root) {
        // 由于 -2^31 <= Node.val <= 2^31 - 1
        // 正无穷 和 负无穷 得使用比 int 数值范围大的数来赋值
        const long long infinit = 0x3f3f3f3f3f3f3f3f;
        long long pre = -infinit;
        std::function<bool(TreeNode*)> isValid = [&](TreeNode *node) {
            if(node == nullptr)
                return true;
            if(!isValid(node->left))
                return false;
            if(node->val <= pre)
                return false;
            pre = node->val;  // 更新节点值
            return isValid(node->right);
        };
        return isValid(root);
    }

    // 前面 前序遍历 是把节点值范围 往下传
    // 还可以把节点值的范围 往上传
    // 法三:后序遍历 将节点值传递到父节点
    // 执行用时:击败97.80% 使用 C++ 的用户
    bool isValidBST2(TreeNode* root) {
        const long long infinit = 0x3f3f3f3f3f3f3f3f;
        std::function<pair<long long, long long>(TreeNode*)> isValid = [&](TreeNode *node) {
            if(node == nullptr)
                return make_pair(infinit, -infinit);
            pair<long long, long long> ret = isValid(node->left);
            long long l_min = ret.first, l_max = ret.second;
            ret = isValid(node->right);
            long long r_min = ret.first, r_max = ret.second;
            long long x = node->val;
            if(x <= l_max || x >= r_min)
                return make_pair(-infinit, infinit);
            return make_pair(min(l_min, x), max(r_max, x));
        };
        return isValid(root).second != infinit;
    }
};

课后作业

203.二叉搜索树中第K小的元素

501.二叉搜索树中的众数

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

700.二叉搜索树中的搜索

1373.二叉搜索子树的最大键值和

12 二叉树 最近公共祖先

最近公共祖先定义:对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大一个节点也可以是它自己的祖先

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

解题思路:

分类讨论如下

image-20240120043346511

  • 当前节点是空节点:直接返回即可

  • 当前节点是 p 或 q:不需要递归寻找,返回 p 或 q即可(注意:返回p 或 q不代表最终答案为 p/q的值)。假设此时的节点p:

    • 当节点q在节点p的子树中时,不需要递归,节点p就是其所求的公共祖先;
    • 当节点q不在节点p的子树中时,也不需要递归,应该遍历根结点的其他子树

    image-20240120043930405

  • 如果左右子树都找到了?最近公共祖先就是当前结点,例如下边的 结点3:

    image-20240120140452670

  • 如果左子树找到了,右子树没有,返回递归左子树后的结果即可

    image-20240120140622975

分类讨论最终结果如下:

image-20240120140806670

完整代码:

 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* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if(root == nullptr || root == p || root == q)
            return root;
        TreeNode* left, *right;
        left = lowestCommonAncestor(root->left, p, q);
        right = lowestCommonAncestor(root->right, p, q);
        if(left && right)
            return root;
//        if(left != nullptr && right == nullptr)
//            return left;
//        if(left == nullptr && right != nullptr)
//            return right;
//        return nullptr;
        // 上边逻辑简写如下:
        if(left)
            return left;
        else
            return right;
    }
};

时间复杂度:O(n)

空间复杂度:O(n)

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

解题思路:

利用二叉搜索树的性质来解最近公共祖先。

当前节点值valp->valq->val 比较:

  • 情况一val > p->val && val < q->val:p和q在两颗子树中,返回当前节点
  • 情况二val == p->val || val == q->val:当前节点为p或者q,返回当前节点
  • 情况三val > p->val && val > q->val:p和q都在左子树中,返回递归左子树的结果,最终归结为情况2
  • 情况四val < p-val && val < q->val:p和q都在右子树中,返回递归右子树的结果,最终归结为情况2

image-20240120143247756

思考:当前节点是空节点,是否需要判断?

题目保证p和q都在这棵树中,那么根节点不是空节点,如果都在左边,左子树非空;如果都在右边,右子树非空;对于其他情况,p和q都在两侧或者当前节点是p或q,当前节点都不是空节点。所以不需要判断当前节点是否为空节点。

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Solution {
public:
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        int val = root->val;
        // 1. p和q都在左子树
        if(val > p->val && val > q->val)
            return lowestCommonAncestor(root->left, p, q);
        // 2. p和q都在右子树
        if(val < p->val && val < q->val)
            return lowestCommonAncestor(root->right, p, q);
        // 3. p和q在左右子树 或者 当前节点就为p或q
        return root;

    }
};

时间复杂度:O(n)

空间复杂度:O(n)

课后作业

1123.最深叶节点的最近公共祖先

2096.二叉树一个节点到另一个节点每一步的方向

13 二叉树 层序遍历 BFS 队列

102.二叉树的层序遍历

解题思路:

之前将的递归是从根结点出发,径直地往下走,是深度优先搜索。而层序遍历是一行一行遍历:

法一:双数组模拟层序遍历

  • 初始化一个 cur数组,开始循环:

    image-20240120145631391

  • 再创建一个nxt数组,遍历cur数组中每个节点,把左右儿子记录到下一层节点数组nxt中。由于需要得到每一层的节点值,在遍历 cur数组的同时,需要把节点值记录到数组vals

    image-20240120145721107

  • 遍历结束后,把cur替换成nxt,开始下一轮循环

    image-20240120145949089

  • 如此循环

    image-20240120150034789

    image-20240120150051844

    image-20240120150103518

    image-20240120150129901

时间复杂度:由于每个节点都会遍历一次,所以复杂度为O(n)

空间复杂度:由于在满二叉树中,最后一层大约会有 n/2个节点,空间复杂度为O(n)

法二:尝试使用一个数组解决上边的问题(队列)

image-20240122235958087

  • 每次将当前层节点出队后,把下一层的节点入队,可以达到和两个数组一样的效果

    image-20240123002536933

  • 如何知道每一层要循环多少次?可以在循环开始的时候,获取一下队列的长度—循环次数,循环完毕,队列中剩下的就是下一层的节点

    image-20240123141914289

    image-20240123142206089

  • 最后根据队列是否为空,来判断是否退出循环

完整代码:

 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
class Solution {
public:
    // 法一:使用 cur数组 和 nxt数组模拟队列
    // 执行用时:击败 86.71% 使用 C++ 的用户
    // 消耗内存分布:击败 11.26% 使用 C++ 的用户
    vector<vector<int>> levelOrder(TreeNode* root) {
        if(root == nullptr)
            return {};
        vector<vector<int>> ans;
        vector<TreeNode*> cur(1, root), nxt;
        vector<int> vals;
        while(!cur.empty()) {
            for(int i = 0; i < cur.size(); ++i){
                if(cur[i]->left)
                    nxt.push_back(cur[i]->left);
                if(cur[i]->right)
                    nxt.push_back(cur[i]->right);
                vals.push_back(cur[i]->val);
            }
            ans.push_back(vals);
            vals = {};
            cur = nxt;
            nxt = {};
        }
        return ans;
    }
    // 法二:使用队列容器
    // 执行:击败 51.62% 使用 C++ 的用户
    // 消耗内存分布: 击败 8.86%  C++ 的用户
    vector<vector<int>> levelOrder1(TreeNode* root) {
        if(root == nullptr)
            return {};
        vector<vector<int>> ans;
        queue<TreeNode*> qt; qt.push(root);
        TreeNode *tmp;
        while(!qt.empty()) {
            vector<int> vals;
            int len = qt.size();
            for (int i = 0; i < len; ++i){
                tmp = qt.front(); qt.pop();
                vals.push_back(tmp->val);
                if(tmp->left) qt.push(tmp->left);
                if(tmp->right) qt.push(tmp->right);
            }
            ans.push_back(vals);
        }
        return ans;
    }
};

小结:之前做的 《求二叉树最大深度》也可以使用层序遍历来做,这个最大深度指的是上边的 ans 数组长度。比较一下层序遍历和递归,计算机在实现递归会用一个栈来维护入栈出栈过程,代码简洁;而层序遍历需要自己手动编写 入队出队的代码。相比于递归写法,层序遍历代码就没那么简洁了。

103.二叉树的锯齿形层序遍历

解题思路:

类比102题,唯一不同就是需要将 偶数层的数组元素翻转一下再添加到结果数组中

  • 法一:利用 栈来翻转偶数层数组元素
  • 法二:直接搞多一个数组,将偶数层正序元素翻转一下即可

完整代码:

 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
73
74
75
76
class Solution {
public:
    // 下边的代码有问题:通过不了这个测试样例 root = [1,2,3,4,null,null,5]
    // 实际输出:[[1],[3,2],[5,4]]  预期结果:[[1],[3,2],[4,5]]
    vector<vector<int>> zigzagLevelOrder(TreeNode* root) {
        // 需要特判一下 root == nullptr直接返回 ,否则后边:vals.push_back(tmp->val); 指针root访问val出错
        if(root == nullptr)
            return {};
        vector<vector<int>> ans;
        queue<TreeNode*> qt;
        qt.push(root);
        TreeNode *tmp;
        bool layer = false; // true 代表偶数层 从右向左扫描入队节点; false 代表奇数层 从左往右扫描入队节点
        while(!qt.empty()) {
            layer = !layer;
            vector<int> vals;
            int len = qt.size();
            for(int i = 0; i < len; ++i){
                tmp = qt.front(); qt.pop();
                vals.push_back(tmp->val);
                if(layer){
                    if(tmp->right) qt.push(tmp->right);
                    if(tmp->left) qt.push(tmp->left);
                }else{
                    if(tmp->left) qt.push(tmp->left);
                    if(tmp->right) qt.push(tmp->right);
                }
            }
            ans.push_back(vals);
        }
        return ans;
    }
    // 法一:利用栈来反转 偶数层的数组元素
    // 正常层序遍历,不过返回值 需要呈现 每一层 节点值顺序不同
    // - 奇数层:正常出队,正常把 节点val添加到层次 数组中
    // - 偶数层:正常出队,把出队节点入栈先,等到整层都遍历完之后,再将栈中 节点val添加到层次 数组中
    // 执行用时分布:击败 100.00% 使用 C++ 的用户
    // 消耗内存分布:击败 9.25% 使用 C++ 的用户
    vector<vector<int>> zigzagLevelOrder1(TreeNode* root) {
        if(root == nullptr)
            return {};
        vector<vector<int>> ans;
        queue<TreeNode*> qt;
        stack<TreeNode*> st;
        qt.push(root);
        TreeNode *tmp;
        bool layer = true; // false 奇数层取数; true 偶数层取数
        // 这里意义 一开始已经取完第0层节点值,接下来循环开始从第1层开始取节点值
        while(!qt.empty()){
            layer = !layer;
            vector<int> vals;
            int len = qt.size();
            for(int i = 0; i < len; ++i){
                tmp = qt.front(); qt.pop();
                if(tmp->left) qt.push(tmp->left);
                if(tmp->right) qt.push(tmp->right);
                if(!layer){
                    vals.push_back(tmp->val);
                }else{
                    st.push(tmp);
                }
            }
            if(!layer){
                ans.push_back(vals);
                continue;
            }
            while(!st.empty()){
                tmp = st.top(); st.pop();
                vals.push_back(tmp->val);
            }
            ans.push_back(vals);
        }
        return ans;
    }
    
};

// 法二:直接构造多一个数组,将偶数层的数组元素反转即可,下边参照博主代码,使用python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Solution:
    def zigzagLevelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
        if root is None:
            return []
        ans = []
        cur = [root]
        even = False
        while cur:
            next = []
            vals = []
            for node in cur:
                vals.append(node.val)
                if node.left: nxt.append(node.left)
                if node.right: nxt.append(node.right)
            cur = nxt
            ans.append(vals[::-1] if even else vals)
            even = not even
        return ans

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Solution {
public:
    // 法一:返回最后一层的第一个节点
    // 使用层序遍历即可
    int findBottomLeftValue(TreeNode* root) {
        vector<vector<int>> ans;
        vector<TreeNode*> cur(1, root), nxt;
        vector<int> vals;
        while(!cur.empty()) {
            for(int i = 0; i < cur.size(); ++i){
                if(cur[i]->left)
                    nxt.push_back(cur[i]->left);
                if(cur[i]->right)
                    nxt.push_back(cur[i]->right);
                vals.push_back(cur[i]->val);
            }
            ans.push_back(vals);
            vals = {};
            cur = nxt;
            nxt = {};
        }
        vals = ans[ans.size()-1];
        return vals[0];
    }
    // 法二:把层序遍历改成 从右到左遍历,最后一个节点就是最底层最左边的节点了
    // 从右到左遍历,改变一下节点的入队顺序即可
    int findBottomLeftValue1(TreeNode* root) {
        queue<TreeNode*> qt; qt.push(root);
        TreeNode *tmp;
        while(!qt.empty()) {
            vector<int> vals;
            int len = qt.size();
            for (int i = 0; i < len; ++i){
                tmp = qt.front(); qt.pop();
                vals.push_back(tmp->val);
                if(tmp->right) qt.push(tmp->right);
                if(tmp->left) qt.push(tmp->left);
            }
            if(qt.empty())
                return tmp->val;
        }
        // 判题机对于有返回值的函数 有明显返回值的情况会报错
        // 不过这一题保证了一定有返回值
        return root->val;
    }
    // 执行用时分布:击败7.08% 使用 C++ 的用户
    // 消耗内存分布:击败 97.70% 使用 C++ 的用户
    // 上边代码可以简化如下:
    int findBottomLeftValue2(TreeNode* root) {
        queue<TreeNode*> qt; qt.push(root);
        TreeNode *tmp;
        while(!qt.empty()) {
            tmp = qt.front(); qt.pop();
            if(tmp->right) qt.push(tmp->right);
            if(tmp->left) qt.push(tmp->left);
        }
        // 返回最后一个节点的值即可
        return tmp->val;
    }
};

课后作业

104.二叉树的最大深度

111.二叉树的最小深度

199.二叉树的右视图

116.填充每个节点的下一个右侧节点指针

117.填充每个节点的下一个右侧节点指针 II

1302.层数最深叶子节点的和

1609.奇偶树

2415.反转二叉树的奇数层

2641.二叉树的堂兄弟节点 II

14 回溯 子集型 分割回文串

回溯问题讲解

  • 回溯概念
  • 回溯三问:思考回溯问题的套路
  • 子集型回溯—回溯问题一种类型

image-20240123160036441

  1. 单纯的循环嵌套,表达能力有局限

    image-20240123160240977

  2. 思考:原问题和子问题,可以使用递归解决,通过递归可以达到多重循环的效果

    image-20240123160316531

  3. 回溯:有一个增量构造答案的过程,这个过程通常可以使用递归实现

    image-20240123160432869

  4. 递归写法关键:处理好 边界条件和非边界条件逻辑即可,其他事情可以交给数学归纳法。思考递归,重点思考如何推导两个相邻问题和子问题之间的转换—非边界条件逻辑,如下:

    image-20240123160628758

回溯三问: 思考回溯问题 和 动态规划问题的模板

image-20240123160710576

首先把要把数字和要枚举的字母对应起来,使用数组。

回溯问题分类:

  • 子集型回溯(0-1背包问题也可以算作一种子集型回溯)
  • 组合型回溯
  • 排列型回溯

小结:

  • 子集型回溯 和 组合型回溯解题方法:
    • 选当前元素 或者 不选—输入视角
    • 枚举选哪个,选举的集合都是一个答案—答案视角
    • 注:有的题目适合用 第一种做法(比如:22.括号生成),有的题目更适合用 枚举选哪个思路来解题(比如:131.分割回文串),平时做题的时候,两种方法都可以考虑,比较哪一种比较好写就使用哪一种思路来写即可

17.电话号码的字母组合

完整代码:

 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 {
private:
    // 这个一开始 设置为numToStr[10] 导致后边访问
    string numToStr[9] = {"", "abc", "def", "ghi", "jkl", "mno", "pqrs",
                           "tuv", "wxyz"};
public:
    // 下面这种做法,由于 tmp是一个局部变量,无法在多个函数之间进行传递值?反正跑出来结果是这样的
    vector<string> letterCombinations(string digits) {
        vector<string> ans;
        int len = digits.length();
        std::function<void(int, string)> dfs = [&](int pos, string str) {
            if(pos == len){
                ans.push_back(str);
                return;
            }
            int size = numToStr[digits[pos]-1].length();
            for(int i = 0; i < size; ++i){
                string tmp = str;
                dfs(pos+1, tmp + numToStr[digits[pos]-1][i]);
            }
        };
        dfs(0, "");
        return ans;
    }
    // 下边这种 path字符串初始化在函数外边 是一个非临时变量(相对于函数来说),结果可以存储到ans中
    vector<string> letterCombinations1(string digits) {
        vector<string> ans;
        int len = digits.length();
        if(len == 0) return {};
        string path(len, 0); // 初始化 字符串长度为len,每一个字符使用 '0'填充
        std::function<void(int)> dfs = [&](int pos) {
            if(pos == len){
//                ans.push_back(str);
            // emplace_back  与  push_back 区别是啥来着..
            //    ans.push_back(path);
                ans.emplace_back(path);
                return;
            }
            // 注意:digits[pos]这里是字符,不是数字,不能直接和1相减,要与'1'相减
            // 如果和 1相减 会数组越界
            //int size = numToStr[digits[pos]-1].length();
            int size = numToStr[digits[pos] - '1'].length();
            for(int i = 0; i < size; ++i){
                // 下边的numToStr[digits[pos]-1] 没有这个字符串,访问会出现下边错误
                // Line 1053: Char 9: runtime error: reference binding to null pointer of type 'char' (basic_string.h)
                //SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/basic_string.h:1062:9
                path[pos] = numToStr[digits[pos]-'1'][i];
                dfs(pos+1);
            }
            // 上边使用 循环遍历用下标访问 字符串数组元素,更加优雅是使用C++11 中的迭代循环,这样不用下标访问数组位置
            // 可以避免一些报错
            // for (char c : MAPPING[digits[i] - '0']) {
            //                path[i] = c;
            //                dfs(i + 1);
            //            }
            //
        };
        dfs(0);
        return ans;
    }

};

灵神写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
MAPPING = "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"

class Solution:
    def letterCombinations(self, digits: str) -> List[str]:
        n = len(digits)
        if n == 0:
            return []
        ans = []
        path = [''] * n
        def dfs(i: int) -> None:
            if i == n:
                ans.append(''.join(path))
                return
            for c in MAPPING[int(digits[i])]:
                path[i] = c
                dfs(i + 1)
        dfs(0)
        return ans

**时间复杂度:**对于回溯,看作循环,输入数字长度为n(递归栈深度为n,可以看作n重 循环,最终的每一个字符串长度都为n),数字映射到字符串数组长度最多为4,嵌套遍历每一个字符串用乘法法则计算:$4\times 4 \times…$(总的使用排列组合有$4^n$种情况)

  • [灵神解释:O(n$4^n$),其中 n为 digits 的长度。最坏情况下每次需要枚举 4个字母,递归次数为一个满四叉树的节点个数,那么一共会递归 O(4^n)次(等比数列和),再算上加入答案时需要 O(n) 的时间,所以时间复杂度为 O(n4^n);

  • 小疑问:vector中每次添加一个元素都需要O(n)时间复杂度么?

  • GPT回答:在 C++ 的标准库中,std::vector 是一个动态数组实现,它具有自动扩展和收缩的功能。当向 std::vector 添加元素时,如果当前容量不足,会触发重新分配内存的过程。这意味着每次添加元素并触发容量扩展时,都需要将原有的元素复制到新的内存空间中,这个操作的时间复杂度是 O(n),其中 n 是当前 std::vector 的大小]

**空间复杂度:**O(n)

78.子集

解题思路:

  • 问题核心:每个元素都可以 选 或 不选

    image-20240124220930330

  • 站在 输入 的角度:

    image-20240124221029980

  • 站在答案的 视角,每个节点都是答案

    image-20240124222229148

    image-20240124222411550

完整代码:

 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<vector<int>> subsets(vector<int>& nums) {
            vector<vector<int>> ans;
            int size = nums.size();
            vector<int> path;
            function<void(int)> dfs = [&](int i) {
                if(i == size){
                    ans.emplace_back(path);
                    return;
                }
                // 不选当前的数字
                dfs(i+1);
                // 选 nums[i]
                path.push_back(nums[i]);
                dfs(i+1);
                path.pop_back(); // 恢复现场
            };
            dfs(0);
            return ans;
    }
    // 法二:答案视角,每个节点都是答案,选哪个数
    vector<vector<int>> subsets1(vector<int>& nums) {
        vector<vector<int>> ans;
        vector<int> path;
        int n = nums.size();
        function<void(int)> dfs = [&] (int i) {
            ans.emplace_back(path);
            if(i == n) return;
            for(int j = i; j < n; ++j){ // 枚举选择数字
                path.push_back(nums[j]);
                dfs(j+1);
                path.pop_back();  // 恢复现场
            }
        };
        dfs(0);
        return ans;
    }
};

时间复杂度:

  • 法一输入视角:其中 n 为 nums 的长度。每次都是选或不选,递归次数为一个满二叉树的节点个数,那么一共会递归 O($2^n$) 次(等比数列和),再算上加入答案时需要 O(n) 的时间,所以时间复杂度为 O($n2^n$)
  • 法二答案视角:O($n2^n$),其中 n 为 nums 的长度。答案的长度为子集的个数,即 $2^n$,同时每次递归都把一个数组放入答案,因此会递归 $2^n$次,再算上加入答案时需要 O(n)的时间,所以时间复杂度为 O($n2^n$)。

空间复杂度:O(n)

语言收获:

C++中vecotr容器不同函数用法

1
2
3
4
5
// 向 容器中添加元素方法(容器尾部添加)
push_back();
emplace_back();
// 将 容器中尾部元素弹出
pop_back()

131.分割回文串

解题思路:

  • 将原问题转换为 子集型问题:假定两个字符之间都有一个逗号,每次考虑选择一个逗号,或者不选

    image-20240124225645215

  • 枚举每个逗号:

    image-20240124225755592

  • 回溯三问:

    image-20240124225824923

完整代码:

 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 {
private:
    bool isPlalindrome(string str){
        int n = str.size();
        int mid = n / 2;
        bool flag = true;
        for(int i = 0; i <= mid; ++i){
            if(str[i] != str[n-1-i]){
                flag = false; break;
            }
        }
        return flag;
    };
public:
    // 站在答案视角 灵神做法
    vector<vector<string>> partition(string s){
        vector<vector<string>> ans;
        vector<string> result;
        int n = s.length();
        std::function<void(int)> dfs = [&](int i) {
            if(i == n){
                ans.push_back(result);
                return;
            }
            for(int j = i; j < n; ++j){
                string str = s.substr(i, j-i+1);
                if(isPlalindrome(str)){
                    result.push_back(str);
                    dfs(j+1);
                    result.pop_back();
                }
            }
        };
        dfs(0);
        return ans;
    }
    // 法二:站在输入的视角,选择逗号 or 不选择逗号


};

**时间复杂度:**O($n2^n$),其中 n 为 s 的长度。答案的长度至多为逗号子集的个数,即 O($2^n$),因此会递归 O($2^n$) 次,再算上判断回文和加入答案时需要 O(n) 的时间,所以时间复杂度为 O($n2^n$)

**空间复杂度:**O(n)

课后作业

784.字母大小写全排列

1601.最多可达成的换楼请求数目

2397.被列覆盖的最多行数

306.累加数

2698.求一个整数的惩罚数

15 回溯 组合型 剪纸

image-20240125215939117

  1. 组合型回溯的 解题思路
  2. 剪枝 技巧
  3. 分析回溯的 时间复杂度

77. 组合

  • 回顾上一讲中的 子集搜索树

    image-20240125224024101

    可以看出每一层中,节点中的数字个数是相等的。恰好可以表示集合中选择 1个数的三种情况、2个数的三种情况以及 选择3个数的一种情况(关键在于选择数的时候遵循 数字大小顺序,不会造成集合重复)——这恰好是组合问题

  • 在 子集搜索树的基础上添加一个限制:选择n个数对应的路径长度也为 n

    从n个数中选 k个数的组合,可以看成是长度固定的子集

    image-20240125224543272

  • 剪枝优化组合问题

    image-20240125224738368

    上面情况是倒序枚举,正序枚举即是 从[i, n]n - i + 1个数中选出d个数,若是 n - i + 1 < d,最后无法选出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
class Solution {
public:
    // 法一:答案视角,每个节点都是答案,选哪个数
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> ans;
        vector<int> path;
        std::function<void(int)> dfs = [&](int i) {
            int d = k - path.size();  // 剩下需要枚举的 数字个数
            if(i < d) return; // 剪枝
            if(path.size() == k){
                ans.push_back(path);
                return;
            }
            // 上边的剪枝也可以隐含 写在循环中
            // for(int j = i; j >= d-1; j--)
            for(int j = i; j >= 1; j--){
                path.push_back(j);  // 合法数字范围 [1, n]
                dfs(j - 1);
                path.pop_back();
            }
        };
        dfs(n);  // 逆序枚举
        return ans;
    }
    // 法二:输入视角,对于每个数字 选 或 不选
};

**时间复杂度分析:**分析回溯问题的时间复杂度,有一个通用公式:路径长度×搜索树的叶子数。对于本题,它等于 O(k⋅C(n,k))。

**空间复杂度:**O(k)

216. 组合总和 III

代码思路:

类似上一题,只不过上一题是从[1, n]中选择所有可能的k个数的组合;这一题要求改为 数字只能在[1, 9]中选取,且只选取 k个数组合 相加之后的和为n的

image-20240125235210053

完整代码: 这里我的思路是从1开始枚举到9,然后利用参数 nums计算集合元素个数,使用sum计算集合元素之和是否达到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<vector<int>> combinationSum3(int k, int n) {
        vector<vector<int>> ans;
        vector<int> path;

        int sum = 0;
        std::function<void(int, int)> dfs = [&] (int i, int nums){
            int d = k - path.size();  // 还需要多少个数
            if(sum == n && nums == k){
                ans.push_back(path);
                return;
            }
            // 9 - i + 1 < d 表示剩余可选的数字选完也不满足 k个数要求
            if(sum > n || nums == k || (9-i+1)<d) // 提前剪枝
                return;
            for(int j = i; j <= 9; j++){
                sum += j;
                path.push_back(j);
                dfs(j+1, nums+1);
                // 不选 数字j,恢复现场
                sum -= j;
                path.pop_back();
            }
        };
        dfs(1, 0);
        return ans;
    }
};

代码思路提出的剪枝写法,参考灵神python写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Solution:
    def combinationSum3(self, k: int, n: int) -> List[List[int]]:
        ans = []
        path = []
        def dfs(i: int, t: int) -> None:
            d = k - len(path)  # 还要选 d 个数
            if t < 0 or t > (i * 2 - d + 1) * d // 2:  # 剪枝
                return
            if d == 0:
                ans.append(path.copy())
                return
            for j in range(i, d - 1, -1):
                path.append(j)
                dfs(j - 1, t - j)
                path.pop()
        dfs(9, n)
        return ans

22. 括号生成

代码思路:

这个 括号生成 和 括号嵌套是一样的,左括号的右边必须有一个对应的右括号,右括号左边也有一个对应的左括号。对于字符串的每个前缀要求:左括号的个数 >= 右括号的个数,最后整个字符串,左右括号的个数 = n。

将这道题 归类为组合型回溯,可以用以下的生成树来理解:

image-20240126003103021

image-20240126003134887

问题思考归纳如下:

image-20240126003211056

两种递归分支:

image-20240126003339582

完整代码:

 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:
    vector<string> generateParenthesis(int n) {
        vector<string> ans;
        string path = "";
        int m = n * 2;
        // 下边 参数 open代表左括号 个数
        // i 代表path中字符个数为 i,i从0开始 且 i < m, i == m 退出递归
        // 时间复杂度:O(n * C(2n, n)),由于左右括号有限制,实际递归次数没有这么多,精确估算了解卡特兰数
        // 空间复杂度:O(n)
        std::function<void(int, int)> dfs = [&](int i, int open) {
            if(i == m){
                ans.push_back(path);
                return;
            }
            if(open < n){
                path += '(';
                dfs(i+1, open+1);
                path.pop_back(); // 恢复现场
            }
            if(i-open < open){
                path += ')';
                dfs(i+1, open);
                path.pop_back(); // 恢复现场
            }
        };
        dfs(0, 0);
        return ans;
    }
    // 法二:枚举下一个左括号的位置
};

课后作业

换一种写法完成上面三题。

301.删除无效的括号

16 回溯 排列型 N皇后

课程大纲:

image-20240127215345095

46.全排列

解题思路:

  • 问题:给出一个不含 重复数字的数组,返回其所有可能的全排列。此时,全排列的个数就是 数组长度的阶乘。

  • 相比组合,排列中元素的顺序是有区别的!!!,所以{1,2,3}和{1,3,2}是相同的组合,却是不同的排列。

    image-20240127220134848

  • 选了一个数之后,如何让递归下一层的函数知道还能选择哪一个数呢?

    • 法一:使用数组path 记录路径上的已选数字,集合 s记录剩余未选数字

      image-20240127220344049

    • 法二:使用 i表示需要构造大于 i的排列,而用一个布尔数组表示剩余可选的数

      image-20240128203116908

完整代码:

 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
class Solution {
public:
    // 法一:使用 flag哈希表来 存储是否选择的数(自己的做法)
    vector<vector<int>> permute(vector<int>& nums) {
        map<int, bool> mp;
        for(int i = 0; i < nums.size(); ++i){
            mp[nums[i]] = true;
        }
        vector<int> vals;
        vector<vector<int>> ans;
        std::function<void(int)> dfs = [&](int i){
            mp[nums[i]] = false;
            vals.push_back(nums[i]);

            for(int j = 0; j < nums.size(); ++j){
                // 当前数字可用
                if(mp[nums[j]]){
                    dfs(j);
                    // 恢复现场
                    mp[nums[j]] = true;
                    vals.pop_back();
                }
            }
            // 完成全排列,将排列一种情况 push到 ans中,返回
            if(vals.size() == nums.size()){
                ans.push_back(vals);
                return;
            }
        };
        // 如果只是 调用 dfs(0),那么只能生成 以nums[0]开头的排列组合
        // 缺少其他 nums[i]开头的排列组合,此时需要 遍历整个nums数组,
        // 传入数组每个元素让其能够保证每个元素都排在第一位
        for(int i = 0; i < nums.size(); ++i){
//            dfs(nums[i]);  // 这里应该传入nums数组下标i 来保证传入的顺序,
//            如果使用nums[i]传入,函数内访问nums[nums[i]]可能会数组越界
            dfs(i);
            // 恢复现场
            mp[nums[i]] = true;
            vals.pop_back();
        }
        return ans;
    }
    // 法二:使用数组path 记录路径上的已选数字,集合s记录剩余未选数字
    vector<vector<int>> permute1(vector<int>& nums) {
        int n = nums.size();
        vector<vector<int>> ans;
        vector<int> path(n), on_path(n);
        // 参数说明:
        // i 表示需要构造大于等于 i的排列,s 表示数组中剩余可以选的数
        function<void(int)> dfs = [&](int i) {
            if(i == n){
                ans.emplace_back(path);
                return;
            }
            for(int j = 0; j < n; ++j){
                // 这里的 on_path[j] 相当于 flag作用,来检索当前下标j对应的nums[j]是否也是可用的
                if(!on_path[j]){
                    path[i] = nums[j];
                    on_path[j] = true;
                    dfs(i+1);
                    on_path[j] = false; // 恢复现场
                }
            }
        };
        dfs(0);
        return ans;
    }
};

**灵神写法:**完整对应法二,使用Python写更加直观

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        n = len(nums)
        ans = []
        path = [0] * n
        on_path = [False] * n
        def dfs(i :int) -> None:
            if i == n:
                ans.append(path.copy())
                return
            for j, on in enumerate(on_path):
                if not on:
                    path[i] = nums[j]
                    on_path[j] = True
                    dfs(i+1)
                    on_path[j] = False # 恢复现场
		dfs(0)
        return ans

时间复杂度:可以用:叶子个数 * 根到叶子的路径长度 来计算,这样时间复杂度为O(n * n!),当然这样是每条路径都计算了一遍,对于上层的根结点来说,由于是路径上共享的节点,会被重复统计很多次。

image-20240128115425572

思考:有没有更加精确的办法,直接计算这棵树有多少个节点? 知道了节点个数,也就知道了递归的次数。

image-20240128115521396

使用排列组合的方式来计算,每一层所需的元素个数为m,总的要排列元素数为n,则每一层的结点数为C(m, n)

累加一下,得到如下精确计算公式:image-20240128201750430

在大O标记算法复杂度中,估计算法的上界:

image-20240128202609793

节点个数知道了 ,每个叶子结点复制新数组时间复杂度为O(n),总的时间复杂度为O(n * n!)

[灵神完整分析:时间复杂度:O(n⋅n!),其中 n为 nums的长度。搜索树中的节点个数低于 3⋅n!。实际上,精确值为 ⌊e⋅n!⌋,其中 e=2.718⋯ 为自然常数。每个非叶节点要花费 O(n)的时间遍历 onPath数组,每个叶结点也要花费 O(n)的时间复制 path数组,因此时间复杂度为 O(n⋅n!)]

空间复杂度:除去答案,其余用到的空间为O(n)

51.N 皇后

解题思路:

image-20240128204810996

使用一个 col数组,记录第 i 行的皇后在第 col[i]列,col数组就是一个0~n-1 的排列。若是只有不同行和不同列不同这两个要求,那么这一题和全排列是等价的。

n皇后比 全排列多了一个限制: 不同皇后不能处于同一斜线。这需要如何表示?一次函数求截距,截距一致表示有不同皇后在 同一个斜线上

从上到下枚举的皇后位置,故这里只需要 看看左上方向 or 右上方向 有无皇后同一斜线即可:

image-20240128205309877

优化:

image-20240128210741221

使用 哈希表 或者 布尔数组对上述的 r+c 和 r-c进行优化判断。

  • 时间复杂度:O($n^2$ ⋅n!)。搜索树中至多有 O(n!)个叶子,每个叶子生成答案每次需要 O($n^2$ ) 的时间,所以时间复杂度为 O($n^2$⋅n!)。实际上搜索树中远没有这么多叶子,n=9时只有 352 种放置方案,远远小于 9!=362880。
  • 空间复杂度: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
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
73
74
75
76
77
78
79
80
81
class Solution {
private:
    // r 表示行号 c 表示列号
    // 这个函数使用 循环用于solveNQueens函数判断 当前位置能不能放皇后,导致其时间复杂度为O(N)
    // 下边对这个进行优化,将复杂度优化为O(1)
    bool isValid(int r, int c, vector<int> &col){
        for(int R = 0; R < r; ++R)
            if(r+c == R+col[R] || r-c == R-col[R]) return false;
        return true;
    }
public:
    // 写法一:从上到下枚举的皇后位置,故这里只需要 看看 左上方向 or 右上方向 有无皇后同一斜线即可
    // 15ms 击败 24.35% 使用 C++ 的用户
    vector<vector<string>> solveNQueens(int n) {
        vector<vector<string>> ans;
        vector<int> col(n, 0);
        set<int> col_index;

        for(int i=0;i<n;i++){
            col_index.insert(i);
        }

        // r表示 当前要枚举的行号;s表示 剩余可以枚举的列号
        function<void(int, set<int>&)> dfs = [&] (int r, set<int> &s) {
            // 使用O(n^2)时间复杂度生成棋盘
            if(r == n){
                vector<string> board(n);
                for(int i = 0; i < n; ++i){
                    board[i] = string(col[i], '.') + 'Q' + string(n - 1 - col[i], '.');
                }
                ans.push_back(board);
                return;
            }
            // r表示行号 ; c表示列号 ; col[i]表示第i行col[i]列放着一个皇后
            for(auto c : s){
                if(isValid(r, c, col)){
                    col[r] = c;
                    set<int> s2 = s;
                    s2.erase(c);
                    dfs(r+1, s2);
                }
            }
        };
        dfs(0, col_index);
        return ans;
    }
    // 写法二:优化 判断位置是否可以放置皇后 从上边isValid函数O(n)复杂度 ——> 当前使用三个布尔数组O(1)复杂度
    // 4ms 击败92.00% 使用 C++ 的用户
    vector<vector<string>> solveNQueens1(int n){
        vector<vector<string>> ans;
        // col数组:表示 第i行 存放皇后位置在 col[i]列处
        // on_path数组:表示第c列中 是否有皇后,有on_path[c] = true
        vector<int> col(n), on_path(n);
        // 数组下标 用于表示 不同斜线截距,相同截距若是有放置皇后,则置为 true
        vector<int> diag1(n*2-1), diag2(n*2-1);

        // r表示 当前要枚举的行号
        function<void(int)> dfs = [&](int r) {
            if(r == n) { // 构造对应的棋盘答案
                vector<string> board(n);
                for(int i = 0; i < n; ++i)
                    // 由于 col[i]中保存的Q位置是 数组下标开始的 也就是从0开始的下标,而实际Q位置应该从1开始
                    // 所以下边利用这个性质 来构造每一行皇后的位置
                    board[i] = string(col[i], '.') + 'Q' + string(n-1-col[i], '.');
                ans.emplace_back(board);
                return;
            }
            for(int c = 0; c < n; ++c){
                int rc = r-c+(n-1);
                if(!on_path[c] /* 当前列c没有皇后 */ && !diag1[r+c] && !diag2[rc]){
                    col[r] = c; // 当前行 r的皇后在 c列
                    on_path[c] = diag1[r+c] = diag2[rc] = true;
                    dfs(r+1); // 递归第 r+1 行
                    on_path[c] = diag1[r+c] = diag2[rc] = false; // 恢复现场
                }
            }
        };
        dfs(0);
        return ans;
    }
};

课后作业

52.N 皇后 II

提示:和51题一样,只不过这个题目不用构造答案,而是返回 解法个数。

  • 时间复杂度:由于不用构造答案,不用额外花费O($n^2$) 时间复杂度,总的时间复杂度为:O(n · n!),当然是指优化后判断是否能放皇后O(1)处理的情况
  • 空间复杂度: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
class Solution {
public:
    // 0ms 100.00% 使用 C++ 的用户
    int totalNQueens(int n) {
        int count = 0; // 解法个数
        // col数组:表示 第i行 存放皇后位置在 col[i]列处
        // on_path数组:表示第c列中 是否有皇后,有on_path[c] = true
        vector<int> col(n), on_path(n);
        // 数组下标 用于表示 不同斜线截距,相同截距若是有放置皇后,则置为 true
        vector<int> diag1(n*2-1), diag2(n*2-1);

        // r表示 当前要枚举的行号
        function<void(int)> dfs = [&](int r) {
            if(r == n) { // 构造对应的棋盘答案
                count++; // 解法+1
                return;
            }
            for(int c = 0; c < n; ++c){
                int rc = r-c+(n-1);
                if(!on_path[c] /* 当前列c没有皇后 */ && !diag1[r+c] && !diag2[rc]){
                    col[r] = c; // 当前行 r的皇后在 c列
                    on_path[c] = diag1[r+c] = diag2[rc] = true;
                    dfs(r+1); // 递归第 r+1 行
                    on_path[c] = diag1[r+c] = diag2[rc] = false; // 恢复现场
                }
            }
        };
        dfs(0);
        return count;
    }
};

17 动态规划 从记忆搜索到递推 打家劫舍

DP讲解:

image-20240128223034540

回溯—> 记忆化搜索(带备忘录的回溯)—>动态规划

198.打家劫舍

解题思路:

  • 回溯思考:相邻房间不能选,从最后一个房间(编号为n)考虑问题

    • 若最后一个房间选,则 只能从编号为[1, n-2]的房间进行选择
    • 若最后一个房间不选,则 可以从编号为[1, n-1]的房间进行选择

    画出搜索树状图,如下:

    image-20240128223432398

    将上面的分类,归结为 “回溯三问”,如下:

    image-20240128223525009

    回溯的时间复杂度为 指数级别的。

  • 观察到上边搜索树中,有一部分 树的结点是重复计算的,可以第一次计算的时候就保存到一个全局数组中,之后就不用重复计算了,这种思想也称为"记忆化搜索”

    image-20240128224014942

    优化后的搜索树只有 O(n)个结点,因此时间复杂度也优化到O(n)

    • 复杂度计算方式:**使用状态个数 **$\times$ 单个状态计算所需时间,由上述的搜索树可以看出,状态个数(每个结点算是一个状态)为O(n),每个结点计算时间为O(1),所以时间复杂度为O(n)

    • 空间复杂度:O(n)

  • 从记忆化搜索 —> 递推(动态规划)

    image-20240128224850120

    时间复杂度为:O(n),空间复杂度 仍然为O(n)

  • 优化动态规划的时间复杂度,采用 滚动数组的技巧,长度取决于递推关系式前后依赖的数值数量

    image-20240128225549364

    • 时间复杂度:O(n)
    • 空间复杂度:O(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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 动态规划的的四个解题步骤是:
//1. 定义子问题
//2. 写出子问题的递推关系
//3. 确定 DP 数组的计算顺序
//4. 空间优化(可选)
class Solution {
public:
    // 动态规划
    int rob(vector<int>& nums) {

        // 原问题:
        // f(n):从[1, n] 房间中偷取的最大金额
        // 子问题:
        // f(k): 从[1, k] 房间中偷取的最大金额
        // 初始条件:f(0) = 0, f(1) = nums[0] // 数组从下标0开始,而房间数量从1开始
        // 递归式子:f(k) = max(f(k-1), f(k-2) + nums[k-1])
        //  - f(k-1)表示 第k间房间不偷,前k-1间房间偷
        //  - f(k-2)表示 第k间房间 和 前k-2间房间 偷,第k-1间房间不偷
        int n = nums.size();
        vector<int> dp(n+1, 0);
        dp[1] = nums[0];
        for(int k = 2; k <= n; ++k){
            dp[k] = max(dp[k-1], dp[k-2]+nums[k-1]);
        }
        return dp[nums.size()];
    }
    // 法一:回溯做法
    // 回溯时间复杂度为指数级别 递增,会超时
    int rob1(vector<int> &nums) {
        int n = nums.size();
        function<int(int)> dfs = [&](int i) {
            if(i < 0)
                return 0;
            int res = max(dfs(i - 1), dfs(i - 2) + nums[i]);
            return res;
        };
        return dfs(n-1);
    }
    // 法二:记忆化搜索
    // 将重复计算的结果保存到 一个数组中 每次要计算之前先查看数组是否有计算好的结果
    int rob2(vector<int> &nums) {
        int n = nums.size();
        vector<int> memo(n, -1); // 初始化memo数组 大小为n 值为-1
        function<int(int)> dfs = [&](int i) {
            if(i < 0)
                return 0;
            if(memo[i] != -1)
                return memo[i];
            int res = max(dfs(i - 1), dfs(i - 2) + nums[i]);
            memo[i] = res;
            return res;
        };
        return dfs(n-1);
    }
    // 法三:从记忆化搜索 到 递推
    // 空间复杂度 仍为 O(n)
    int rob3(vector<int> &nums) {
        int n = nums.size();
        vector<int> f(n + 2);
        for (int i = 0; i < n; ++i)
            f[i + 2] = max(f[i + 1], f[i] + nums[i]);
        return f[n + 1];
    }
    // 法四:动态规划
    // 空间优化为 O(1)
    int rob4(vector<int> &nums) {
        int f0 = 0, f1 = 0;
        for (int x : nums) {
            int new_f = max(f1, f0 + x);
            f0 = f1;
            f1 = new_f;
        }
        return f1;
    }
};

课后作业

70.爬楼梯

746.使用最小花费爬楼梯

2466.统计构造好字符串的方案数

213.打家劫舍 II

18 0-1背包 完全背包 目标和 零钱兑换

学习大纲:

image-20240129194146771

解题思路:

1、0-1背包:有n个物品,第 i 个物品的体积为 w[i],价值为 v[i]。每个物品至多选一个,求体积和 不超过 capacity 时的最大价值和

image-20240129194357682

0-1背包参考代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# capacity:背包容量
# w[i]: 第i个物品的体积
# v[i]: 第i个物品的价值
# 返回值:所选物品体积和不超过 capacity 的前提下,所能得到的最大价值和
def zero_one_knapsack(capacity: int, w: List[int], v: List[int]) -> int:
    n = len(w)
    
    @cache  # 改成记忆化搜索,python可以添加一个cache装饰器,而其他语言可以用 一个cache数组存储
    def dfs(i, c):
        if i < 0:
            return 0
        if c < w[i]:
            return dfs(i-1, c) # 背包剩余容量c 不够装下w[i]
        return max(dfs(i-1, c), dfs(i-1, c-w[i]) + v[i])
    
    return dfs(n-1, capacity) # 从背包最后一个 物品开始枚举,其实剩余容量为capacity

推荐学习路线:二叉树递归—> 回溯—> 记忆化搜索—> 递推

  1. 目标和 494.目标和

    将原问题转化如下:

    image-20240129203954248

    选一些数 ,这些数的和恰好等于一个特定数(sum)的方案数 — 0-1背包的常见变形

    把上边0-1背包,最大和计算公式:

    1
    
    dfs(i, c) = max(dfs(i-1, c), dfs(i-1, c-w[i]) + v[i]);
    

    改为:

    1
    
    dfs(i, c) = dfs(i - 1, c) + dfs(i - 1, c - w[i]);
    

    这里函数参数解释:

    • dfs(i, c):表示从前 i 个数中选取一些数 恰好组成c的方案数
    • 这里使用加法原理:方案数 是小一级子问题的总和

    法一:递归+数组保存= 记忆化搜索

    • 时间复杂度:O(n⋅(target+S)),其中 n为 nums的长度,S为 nums 的元素之和。
    • 空间复杂度:O(n⋅(target+S))

    法二:递归—> 循环递推

    • 时间复杂度:O(n⋅(target+S)),其中 n为 nums 的长度,S 为 nums 的元素之和
    • 空间复杂度:O(n⋅(target+S))

    法三:空间优化:滚动数组

    image-20240129215837112

    从上面的递推式子,可以看出,每次计算出$f[i][c]$ 或者 $f[i][c-w[i]]$ 之后,$f[i+1][c]$ 只依赖这两个元素,而不关$f[i-1][c] 和 f[i-1][c-w[i]]$ 什么事了,可以用列为2的二维数组,也就是两个一维数组来存储计算中间值即可。

    • 时间复杂度:O(n⋅(target+S)),其中 n为 nums 的长度,S 为 nums 的元素之和
    • 空间复杂度:O((target+S))

    法四:空间优化,使用一个数组来存储动态规划的中间状态(==非常难想,还没怎么理解,之后再来看看==)

    image-20240129220711340

    将 $f[i+1][c]和f[i][c]$合并到同一个数组中,从左向右计算,会把结果给覆盖掉,导致计算出错。改为:从右往左算,即从数组后边算到前边

完整代码:

  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
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class Solution {
public:
    // 法一:递归
    int findTargetSumWays(vector<int>& nums, int target) {
        // 添加正数的和 为p
        // 添加负数的绝对值 为s - p(s为所有元素的和)
        // target = p - (s-p) = 2p - s
        // p = (s+t) / 2
        // 原问题转化为:从 nums中选择一些数字,使得其和 为(s+t)/2
        //      由于nums[i]>=0,所以(s+t) 也是非负数
        for(int i = 0; i < nums.size(); ++i)
            target += nums[i];
        if(target < 0 || target % 2)
            return 0;  // target(s+t 之后的数)不能为 奇数,因为p = (s+t)/2,为整数
        target /= 2;

        int n = nums.size();

        function<int(int, int)> dfs = [&](int i, int c){
            // 修改边界条件
            // 下边为原来的边界条件
//            if(i < 0)
//                return 0;
            if(i < 0)
                return c == 0 ? 1 : 0; // c=0,表示找到一个合法方案;c!=0,合法方案为0
            if(c < nums[i])
                return dfs(i - 1, c);
            return dfs(i - 1, c) + dfs(i - 1, c - nums[i]);
        };
        return dfs(nums.size()-1, target);
    }
    // 法二:记忆化搜索  没通过测试...
    int findTargetSumWays1(vector<int>& nums, int target){
        for(int i = 0; i < nums.size(); ++i)
            target += nums[i];
        if(target < 0 || target % 2)
            return 0;  // target(s+t 之后的数)不能为 奇数,因为p = (s+t)/2,为整数
        target /= 2;

        int n = nums.size(), cache[n][target+1];
        memset(cache, -1, sizeof(cache));  // -1表示没有访问过
        function<int(int, int)> dfs = [&](int i, int c){
            // 修改边界条件
            // 下边为原来的边界条件
//            if(i < 0)
//                return 0;
            if(i < 0)
                return c == 0 ? 1 : 0; // c=0,表示找到一个合法方案;c!=0,合法方案为0]
            int& res = cache[i][c];
            if(res != -1) return res;
            if(c < nums[i])
                return res = dfs(i - 1, c); // res引用,传递最新结果更新数组,并且作为返回值
            return dfs(i - 1, c) + dfs(i - 1, c - nums[i]);
        };
        return dfs(nums.size()-1, target);
    }
    // 法三:动态规划,二维数组递推
    int findTargetSumWays2(vector<int>& nums, int target){
        for(int i = 0; i < nums.size(); ++i)
            target += nums[i];
        if(target < 0 || target % 2)
            return 0;  // target(s+t 之后的数)不能为 奇数,因为p = (s+t)/2,为整数
        target /= 2;

        int n = nums.size(), f[n+1][target + 1];
        memset(f, 0, sizeof(f));
        f[0][0] = 1;// 第一行第一个数为1,其余数为0 因为 数组第0个元素不存在,此时 c!=0,背包容量还剩余 不符合条件
        // 第一列 第一行为1, 其余行虽然 c=0 背包容量归零了,但是还有枚举 剩余数字权力,后边 更新实际的方案数
        for(int i = 0; i < n; ++i)
            for(int c = 0; c <= target; ++c){
                if(c < nums[i]) f[i+1][c] = f[i][c];
                else f[i+1][c] = f[i][c] + f[i][c-nums[i]];
            }
        return f[n][target];
    }
    // 法四:空间优化,滚动数组
    int findTargetSumWays3(vector<int>& nums, int target){
        for(int i = 0; i < nums.size(); ++i)
            target += nums[i];
        if(target < 0 || target % 2)
            return 0;
        target /= 2;

        int n = nums.size();
        int f[2][target+1];
        memset(f, 0, sizeof(f));
        f[0][0] = 1;
        for(int i = 0; i < n; ++i)
            for(int c = 0; c <= target; ++c){
                if(c < nums[i]) f[(i+1)%2][c] = f[i%2][c];
                else f[(i+1)%2][c] = f[i%2][c] + f[i%2][c-nums[i]];
            }
        return f[n%2][target];  // 所有数组 第一维度都 改为 %2,这样就可以在第一二列来回切换计算值
    }
    // 法五:空间优化为 一维数组
    int findTargetSumWays4(vector<int>& nums, int target){
        for(int i = 0; i < nums.size(); ++i)
            target += nums[i];
        if(target < 0 || target % 2)
            return 0;
        target /= 2;

        int n = nums.size();
        int f[target+1];
        memset(f, 0, sizeof(f));
        f[0] = 1;
        // 初始版本
        for(int i = 0; i < n; ++i)
            for(int c = target; c >= 0; c--){
                if(c < nums[i])  // 由于 f[c] = f[c] 没有任何意义,所以只需要讨论c>=nums[i]情况
                    f[c] = f[c]; // f[i+1][c] = f[i][c]
                else
                    f[c] = f[c] + f[c-nums[i]];
            }

        // 简化版本
//        for(int i = 0; i < n; ++i){
//            for(int c = target; c >= nums[i]; --c)
//                f[c] += f[c-nums[i]];
//        }
        return f[target];
    }
};

3、完全背包:有n个物品,第i种物品的体积为 w[i],价值为v[i]。每种物品无限次重复选,求体积和 不超过capacity时的 最大价值和

image-20240129222730381

完全背包与上边0-1背包的唯一区别:一个物品可以选择多次,或者不选

  • 完全背包状态转移:dp[i][c] = max(dp[i-1][c], dp[i][c - w[i]] + v[i])
    • 可以看到上边 dp[i][c-w[i]] 表示选择当前物品i,还可以重复选择
  • 0-1背包状态转移:dp[i][c] = max(dp[i-1][c], dp[i][c - w[i]] + v[i])
    • 可以看到上边dp[i-1][c-w[i]] 表示选择当前物品i,不可以再选择了
  1. 零钱兑换 322.零钱兑换

    将原问题 转化为如下问题:把上边状态转移等式,物品价值v[i]看作1,表示物品个数;把求最大总价值(最大物品个数) 改为 求最小总价值(最小物品个数)

    image-20240129224403282

完整代码:

 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 coinChange(vector<int>& coins, int amount) {
        int n = coins.size(), cache[n][amount+1];
        memset(cache, -1, sizeof(cache)); // -1 表示没有访问过
        function<int(int, int)> dfs = [&](int i, int c) -> int {
            if(i < 0) return c==0 ? 0 : INT32_MAX / 2; //除 2 是防止下面 + 1 溢出
            int &res = cache[i][c];  // 引用 res改变,cache[i][c]也会改变
            if(res != -1) return res;
            if(c < coins[i]) return res = dfs(i-1,c);
            return res = min(dfs(i-1, c), dfs(i, c-coins[i]) + 1);
        };
        int ans = dfs(n-1, amount);
        // 这里 return ans < INT32_MAX ? ans : -1;
        // 会有样例过不了:coins = [2] amount = 3
        // 输出:1073741823(估计 就是INT32_MAX/2) 预期结果:-1
        return ans < INT32_MAX/2 ? ans : -1;
    }
    // 法二:将记忆化搜索 翻译成 递推
    // 需要注意两点:初始化 f[0][0] = 0,其余f元素初始化为无穷大,因为这道题要求最小硬币数
    // min取最小值,可以每次将无穷大的选法 排除
    int coinChange1(vector<int>& coins, int amount){

    }
    // 视频看到 13.10
};

课后作业

2915.和为目标值的最长子序列的长度

416.分割等和子集

279.完全平方数

518.零钱兑换 II

19 线性DP 最长公共子序列LCS 编辑距离

学习大纲:

image-20240130235139831

默认情况下,子数组和子串 是连续的;子序列不一定是连续的,例如:abcde中,ace就是一个子序列。

子序列定义:由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串

最长公共子序列:所有公共子序列中,取最大值。

1143.最长公共子序列

解题思路:

类比子序列,考虑每个字符串中的字母 选 或者 不选,从最后一个字母开始考虑,两两组合就有四种情况

image-20240131004228527

image-20240131004350461

思考如下问题:

image-20240131004455556

从上边反证法,可以得到结论:

  • $s[i] = s[j]$ 时,不需要考虑 $dfs(i - 1, j)$ 和 $dfs(i, j - 1)$的情况(这两个情况获得的最长公共子序列 不会大于 $dfs(i-1, j-1)$即选择了$s[i]与s[j]$时的最大公共子序列)
  • $s[i] \ne s[j]$ 时,由于$dfs(i-1, j)$ 和 $dfs(i, j-1)$中最终会递归到$dfs(i-1, j-1)$的情况,所以不需要考虑$dfs(i - 1, j- 1)$

最终简化后的递推式子,如下:

image-20240131005151736

image-20240131005208023

优化

从上边的递推式子可以看出,如果把状态填入二维表格,那么当前计算的状态 只和 左边一个状态、上边一个状态、还有左上角一个状态有关,可以按照优先行填充方法 从左往右、从上到下填充二位数组,即可求出:$f[n][m]$

**使用一个数组计算最终状态:**从上边的填表顺序可以看出,使用两个一维数组就可以完成整个过程迭代。不过现在想用一个一维数组达到同样的效果,那么在计算 $f[i][j]$ 的时候,在一维数组中就是 $f[j]$ 依赖于 前边刚计算的$f[j-1]$,还有上一轮计算的 $f[j]$,剩下上一轮计算的 $f[j-1]$被这一轮计算的 $f[j-1]$覆盖掉了。那么需要使用一个临时变量将上一次计算的$f[j-1]$保存下来,再计算这一轮的$f[j-1]$。如此迭代 n次,就可以得到最终结果:$f[m]$

完整代码:

 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
class Solution {
public:
    // 法一:记忆化搜索
    int longestCommonSubsequence(string text1, string text2) {
        int n = text1.length(), m = text2.length();
        int cache[n][m];
        // Code analysis has been suspended
        memset(cache, -1, sizeof(cache));  // 初始化为-1,表示没有访问过
        function<int(int, int)> dfs = [&](int i, int j) -> int {
            if(i < 0 || j < 0) return 0;
            int &res = cache[i][j]; // 引用,res修改,cache也会跟着修改
            if(res != -1) return res;
            if(text1[i] == text2[j]) {
                return res = dfs(i - 1, j - 1) + 1;
            } else {
                return res = max(dfs(i - 1, j), dfs(i, j - 1));
            }
        };
        return dfs(n-1, m-1); // 由于要访问数组下标,所以这里的参数都应该-1
    }
    // 法二:改成递推
    // 一比一翻译,注意:为了避免出现负数下标,需要将上述的 i-1 改为 i,j-1也改为 j
    int longestCommonSubsequence1(string text1, string text2) {
        int n = text1.length(), m = text2.length();
        int f[n+1][m+1];
        memset(f, 0, sizeof(f));
        for(int i = 0; i < n; ++i)
            for(int j = 0; j < m; ++j){
                if(text1[i] == text2[j]){
                    f[i+1][j+1] = f[i][j] + 1;
                } else {
                    f[i+1][j+1] = max(f[i][j+1], f[i+1][j]);
                }
                // 也可以改写成 三目运算符
//                f[i+1][j+1] = (tex1[i] == text2[j]) ? f[i][j]+1 : max(f[i][j+1], f[i+1][j]);
            }
      //  return f[n+1][m+1];  返回错下标 越界了
        return f[n][m];
    }
    // 法三:空间优化,使用滚动数组
    int longestCommonSubsequence2(string text1, string text2){
        int n = text1.length(), m = text2.length();
        int f[2][m+1];
        memset(f, 0, sizeof(f));
        for(int i = 0; i < n; ++i)
            for(int j = 0; j < m; ++j) {
                // 下边的 text1 == text2 直接对比两个字符串了 难怪没通过测试样例
//                f[(i+1)%2][j+1] = text1 == text2 ? f[i%2][j]+1 : max(f[i%2][j+1], f[(i+1)%2][j]);
                f[(i+1)%2][j+1] = text1[i] == text2[j] ? f[i%2][j]+1 : max(f[i%2][j+1], f[(i+1)%2][j]);
            }
        return f[n%2][m];
    }
    // 法四:空间优化,一个一维数组,另外一维作为 迭代次数,迭代n次就可以得到结果
    // 这个有个测试样例 没有通过
    // "mhunuzqrkzsnidwbun"
    // "szulspmhwpazoxijwbq"
    // 输出:8  预期结果:6
    int longestCommonSubsequence3(string text1, string text2){
        int n = text1.length(), m = text2.length();
        int f[m+1];
        memset(f, 0, sizeof(f));
        int pre = f[0]; // 保留上一轮的 前一个状态
        int tmp;
        for(int i = 0; i < n+1; ++i)  // 迭代轮数,总共需要迭代n+1次,才可以达到和二维数组 第n行的结果
            for(int j = 0; j < m; ++j) {
                tmp = f[j+1]; // 保留上一轮的 前一个状态
                f[j+1] = text1[i] == text2[j] ? pre+1 : max(f[j+1], f[j]); // 更新这一轮的当前状态
                pre = tmp;
            }
        return f[m];
    }
};

时间复杂度:O(n $\times$ m),可以用上边计算公式:状态个数 $\times$ 单个状态计算时间

空间复杂度(没优化的前):O(n $\times$ m)

72.编辑距离

解题思路:

类比上一题,最长公共子序列中,原问题和子问题的递归式子,等价转换如下:

image-20240201213726877

完整代码:

 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 minDistance(string word1, string word2) {
        // 题意:经过最少操作数,将 word1 转换为 word2
        // 可以对 word1 进行三种操作:插入、删除、替换一个字符
        int n = word1.length(), m = word2.length();
        int f[2][m+1];
        // 初始化数组
        //1. 当 n = 0的时候,需要进行 m次插入操作 转换为 word2
        // 注意下边循环 终止条件应该为: j <= m 要初始化所有情况
        // 不然样例:word1 = "" word2 = ""  过不了,究其原因是:最后返回f[0][m] 但是没初始化
        for(int j = 0; j <= m; ++j) f[0][j] = j;
//        memset(f, 0, sizeof(f)); // 初始化有问题
        for(int i = 0; i < n; ++i){
            // 2. 当 m = 0的时候,需要进行 n次删除操作,转换为 word2
            f[(i+1)%2][0] = i+1;  // 下标为 i+1, 同理也需要进行i+1 次操作
            for(int j = 0; j < m; ++j){
                if(word1[i] == word2[j]) f[(i+1)%2][j+1] = f[i%2][j]; // 不用操作
                // 所有的代码都没有问题,就是下边这个分支 忘记添加一个else 导致样例过不了 找了快一个小时了...
                else f[(i+1)%2][j+1] = min(f[(i+1)%2][j], min(f[i%2][j+1], f[i%2][j])) + 1; // 从左到右,分别是:插入、删除、替换操作
            }
        }

        return f[n%2][m];
    }
};

和上一次一样,这个代码可以使用普通二维数组(一对一翻译 记忆化搜索),也可以使用一个一维数组进行优化。

课后作业

583.两个字符串的删除操作

712.两个字符串的最小ASCII删除和

1458.两个子序列的最大点积

97.交错字符串

20 线性DP 最长递增子序列 LIS

学习大纲

递增子序列(IS, Increasing Subsequence)—> 最长递增子序列(LIS, Longest Increasing Subsequence)

  • 方案一:回溯→记忆化搜索→递推,O($n^2$)时间复杂度
  • 方案二:二分+贪心,O($nlogn$) 时间复杂度

300. 最长递增子序列

解题思路:

子序列本质上 是数组的子集,可以用子集型回溯来思考。

  • 思路1: 选 或 不选。为了比大小,==需要知道上一个选的数字==,比较麻烦

  • 思路2: 枚举选哪个。比较当前选的数字 和 ==下一个要选的数字==

    • 直接枚举 前面比 当前数小的数字即可(==枚举顺序是 从右往左枚举==)
    • 只需要 一个参数,就是当前已经选中最后一个数字 数组下标

    image-20240203160936362回溯

  • 思路3:nums的LIS等价于 nums与排序去重后的 numsLCS(最长公共子序列)

    image-20240203180726079

  • 思路4交换状态 与 状态值 — 贪心

    image-20240203181227147

    image-20240203181252603

    从上述更新g数组过程发现,只有定义g[i]为长度为i+1末尾元素最小值的时候,才更有机会去拓展g数组的长度。如果没有2, 4更新,末尾元素为5无法组成长度为4的上升子序列的。

    注:按照上述定义的问题,没有重叠的子问题,不能算是动态规划,变成一个贪心问题。

    从上边推导看出,g数组是一个严格递增的序列,每次要么修改一个数、要么添加一个数,下边进行严格证明:

    image-20240203185012504

    image-20240203185222335

    image-20240203185240245

    上述的构造g数组的算法,使用贪心思路 + 二分查找更新数组的方式,可以达到时间复杂度为O($nlogn$),空间复杂度为O(n)。若是直接将nums数组当作g数组,只需要维护一个额外g_len变量标记当前g数组的长度,使得空间复杂度为O(1)

  • 变形:如果 LIS最长递增子序列中,可以有相同元素?代码要如何修改,修改过程如何?

    image-20240203190758423

    C++中,二分查找内置函数使用upper_bound

回溯三问:

image-20240203161029016

  • 递归公式:$dfs(i) = max${$dfs(j)$} + 1, $j < i 且 nums[j] < nums[i]$
  • 递推公式:$f[i] = max${$f[j]$} + 1,$j < i 且 nums[j] < nums[i]$

时间复杂度:O(n)个状态,每个状态计算需要O(n)时间,总的时间复杂度为O($n^2$)

空间复杂度:O(n)

完整代码:

方案一:回溯→记忆化搜索→递推,O($n^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
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
class Solution {
public:
    // 递归搜索
    int lengthOfLIS(vector<int>& nums) {
        int n = nums.size();

        std::function<int(int)> dfs = [&](int i) {
            int res = 0;
            for(int j = 0; j < i; ++j){
                if(nums[j] < nums[i]) {
                    res = max(res, dfs(j));
                }
            }
            return res + 1;
        };
        int ans = 0;
        for(int i = 0; i < n; ++i)
            ans = max(ans, dfs(i));
        return ans;
    }
    // 法二:重复子问题,优化为 记忆化搜索
    int lengthOfLIS1(vector<int>& nums) {
        int n = nums.size();

        int memo[n];
        memset(memo, 0, sizeof(memo));
        std::function<int(int)> dfs = [&](int i) {
            int& res = memo[i];
            if(memo[i]){
                return memo[i];
            }
            for(int j = 0; j < i; ++j){
                if(nums[j] < nums[i]){
                    res = max(res, dfs(j));
                }
            }
            return ++res;
        };
        int ans = 0;
        for(int i = 0; i < n; ++i)
            ans = max(ans, dfs(i));
        return ans;
    }
    // 法三:递推写法,初始状态很重要
    int lengthOfLIS2(vector<int>& nums) {
        int n = nums.size();
        int f[n];
        // 每一个数字 都可以作为递增子序列,所以最小的递增子序列为1,
        // 不过由于后边的计算每个状态逻辑首先都是不计算自己 后边得到结果才把1添加上去,所以这里也可以初始化为0
//        for(int i = 0; i < n; ++i)
//            f[n] = 1;
        memset(f, 0, sizeof(f));
        // 外层循环 计算 每个以nums[i]结尾的序列 的最长递增子序列长度
        for(int i = 0; i < n; ++i){
            // 内层循环 每个状态 计算的逻辑
            for(int j = 0; j < i; ++j){
                if(nums[j] < nums[i]){
                    f[i] = max(f[i], f[j]);
                }
            }
            f[i]++;  // 将 nums[i] 也添加进递增子序列里边
            // 状态f[i] 真正表示 以nums[i]结尾的元素序列中 包括nums[i]最长递增子序列长度
        }
        // 由于 是考虑最长递增子序列,这个是有规定元素大小的 所以答案最大值 不是包括结尾元素的最长递增子序列
        // 而是 整个数组中 不规定结尾元素的最长递增子序列,所以还需要遍历所有的状态值 去max
        int ans = 0;
        for(int i = 0; i < n; ++i)
            ans = max(ans, f[i]);
        return ans;
    }

};

方案二:二分+贪心,O($nlogn$) 时间复杂度

 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:
    // 法四:贪心 + 二分查找
    // 写法一:使用额外的空间
    int lengthOfLIS3(vector<int>& nums){
        // 1. 使用内置 lower_bound 二分查找
        vector<int> g;
        // 左闭右开 写二分
        for(int x : nums){
            auto it = lower_bound(g.begin(), g.end(), x);
            if(it == g.end()) g.push_back(x);  // >= x的g[j]不存在
            else *it = x;
        }
        return g.size();

        // 2. 自己手写二分
//        vector<int> res;
//        int len=nums.size();
//        res.push_back(nums[0]);
//        for (int i=1;i<len;i++) {
//            if (nums[i]>res.back()) {
//                res.push_back(nums[i]);
//            } else {
//                int l=0;
//                int r=res.size()-1;
//                while (l<r) {
//                    int m=(l+r)/2;
//                    if (res[m]<nums[i]) l=m+1;
//                    else r=m;
//                }
//                res[l]=min(res[l],nums[i]);
//            }
//        }
//        return res.size();
    }

    // 写法二:原地修改
    int lengthOfLIS4(vector<int>& nums){
        auto end = nums.begin();
        for(int x : nums){
            // 左闭右开  [begin, end)
            auto it = lower_bound(nums.begin(), end, x);
            *it = x;
            if(it == end) // >=x 的 g[j]不存在
                ++end;
        }
        return end-nums.begin();
    }

};

课后作业

673.最长递增子序列的个数

1964.找出到每个位置为止最长的有效障碍赛跑路线

1671.得到山形数组的最少删除次数

354.俄罗斯套娃信封问题

1626.无矛盾的最佳球队

21 状态机DP 买卖股票系列

学习大纲:

image-20240204234225781

122. 买卖股票的最佳时机 II

解题思路:

$prices = [7, 1, 5, 3, 6, 4]$

最后一天发生了什么?

  • 递推式子:从第0天开始 到 第5天结束时的利润 = 从第0天开始到第4天结束时的利润 + 第5天的利润

  • 可以从前往后思考,也可以从后往前思考(这里的前后指时间段)每一天的利润/亏损、交易操作,不过为了方便改成递推式子,上边是从后往前 倒着思考

**关键词:**天数、是否持有股票

子问题是什么? 到第 i 天结束时,持有/未持有股票的最大利润

下一个子问题? 到第 i-1 天结束时,持有/未持有股票的最大利润

思考另外一个问题: 在第 i 天可以进行哪些操作?

  • 假设 当前第i-1天结束时,未持有股票,那么第i天只能进行 买入操作(和不操作),会从第i-1天结束时==未持有股票状态== 跳转为 第i天结束时 ==持有股票状态==
  • 假设 当前第i-1天结束时,持有股票,那么第i天只能进行 卖出操作(和不操作),会从第i-1天结束时==持有股票状态== 跳转为 第i天结束时 ==未持有股票状态==

image-20240205003515634

从上述图中可以看出,状态转移一共有 4种情况。

从上述分析之后,可以定义状态转移方程如下:

image-20240205004529010

  • $dfs(i, 0) = max(dfs(i-1, 0), dfs(i-1, 1) + prices[i])$
  • $dfs(i,1) = max(dfs(i-1,1), dfs(i-1,0)-prices[i])$

递归边界:

image-20240205011606012

补充:也可以将i = 0 的情况当成递归边界,但是这样写 需要保证prices数组不为空。若是将i = -1的情况当成递归边界,这样prices数组为空也是可以的,这样写适用性更广。

递归入口:

  • 递推公式:$max(dfs(n-1, 0), dfs(n-1, 1)) = dfs(n-1, 0)$
  • 递推入口有两种情况:最后一天持有股票 和 最后一天不持有股票。不过仔细推敲,如果最后一天持有股票,那么这个股票卖不出去,这样是亏钱的,所有最优一天不持有股票的情况是较优选中

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

将上述的递归写法 一对一翻译成 递推写法:

image-20240205022034542

进一步优化:

  • 由于上边递推式子可以看出,$f[i][status]$ 只依赖于 $f[i-1][status]$两个变量,所以可以用一个长为 2 的数组来表示,这种优化技巧也称为滚动数组优化
  • 时间复杂度:O(n),空间复杂度:O(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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class Solution {
public:
    // 法一:递归搜索
    int maxProfit(vector<int>& prices) {
        int n = prices.size();

        // error: default initialization of an object of const type 'const int'
        // 记得 const常量要在 定义的时候就初始化
        const int inf = 0x3f3f3f3f;
//        memset(inf, 0x3f, sizeof(inf));
        /**
         * 参数 i : 表示第几天
         * 参数 status : 表示当前天数是否 持有股票,1代表持有,0代表不持有
         */
        std::function<int(int, int)> dfs = [&](int i, int status) -> int {
            if(i < 0)
                return status == 1 ? -(inf) : 0;

            if(status){
                return max(dfs(i - 1, 1), dfs(i - 1, 0) - prices[i]);
            } else{
                return max(dfs(i - 1, 0), dfs(i - 1, 1) + prices[i]);
            }
        };
        return dfs(n-1, 0);
    }

    // 法二:记忆化搜索
    int maxProfit1(vector<int>& prices) {
        int n = prices.size();

        const int inf = 0x3f3f3f3f;
        int memo[n][2];
        // -1表示还没有计算过
        memset(memo, -1, sizeof(memo));
        std::function<int(int, int)> dfs = [&](int i, int status) -> int{
            if(i < 0)
                return status == 1 ? -(inf) : 0;
            int &res = memo[i][status];
            if(res != -1) return res;
            if(status)
                // 第一种情况:第i-1天 就持有股票,第i天 无交易
                // 第二种情况:第i-1天 没持有股票,第i天才 买入股票prices[i]
                return res = max(dfs(i - 1, 1), dfs(i - 1, 0) - prices[i]);
            else
                return res = max(dfs(i - 1, 0), dfs(i - 1, 1) + prices[i]);
        };
        return dfs(n-1, 0);
    }

    // 法三:递归 翻译成 递推
    int maxProfit2(vector<int>& prices) {
        int n = prices.size();
        const int inf = 0x3f3f3f3f;
        int f[n+1][2];
        f[0][0] = 0, f[0][1] = -(inf);
        // 注意:这里的f[i][0]表示从第1天到第i天 第i天没有持有股票时的利润
        // f[i][1] 表示从第1天到第i天 第i天持有股票时的利润
        // 但是prices[i]代表的是 第i天的股票价格,这是从第0天开始计算的,所以股票价格的第0天对应着状态表示的第1天
        for(int i = 1; i <= n; ++i){
            f[i][0] = max(f[i-1][0], f[i-1][1] + prices[i-1]);
            f[i][1] = max(f[i-1][1], f[i-1][0] - prices[i-1]);
        }
        return f[n][0];
    }

    // 法四:空间优化,由于上边递推式子可以看出,f[i][status] 只依赖于 f[i-1][status]两个变量
    //      所以可以用一个长为二的滚动数组来表示即可
    int maxProfit3(vector<int>& prices) {
        const int inf = 0x3f3f3f3f;
        int f0 = 0, f1 = -(inf);
        for(int price : prices){
            //1. 计算 从开始日期 到 第i天 没有持有股票状态收益
            // - 第i-1天 也没有持有股票,此时第 i 天无交易操作
            // - 第i天,持有股票,此时第 i 天将股票卖出
            int new_f0 = max(f0, f1 + price);
            //2. 计算 从开始日期 到 第i-1天 持有股票状态收益
            // - 第i-1天 持有股票,此时第 i 天无交易操作
            // - 第i-1天 没持有股票,此时第 i 天将股票买入
            f1 = max(f1, f0 - price);
//            int f1 = max(f1, f0 - price);

            // 通过Debug 输出,可以看到每次 f1输出都是0,这说明每次没有起到更新作用
            // 但是一开始百思不得其解,后边发现是 每次f1都重新定义了
//            cout << f1 << endl;
//            cout << "f0: " << f0 << endl;

            // 将 tmp 赋值给 第i天 没有持有股票的状态
            f0 = new_f0;
        }
        return f0;
    }

};

309. 买卖股票的最佳时机含冷冻期

解题思路:

这道题和 122 题唯一不同之处: 满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

  • 卖出股票后,无法在第二天买入股票(冷冻期为1天)
  • 买入股票 的前一天 不能有卖出股票的操作

注意:

  • 边界条件多了 dfs(-2, 0)=0的情况,所以状态空间二维数组可以初始化为$f[n+2][2]$ ,然后真正的状态从下标为2开始,对应prices数组的prices[0]

  • dfs(−2,1) 是访问不到的,所以下面翻译成递推时,无需初始化这个状态(不需要写 $f[0][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
class Solution {
public:
    // 法一:记忆化搜索
    int maxProfit1(vector<int> &prices) {
        int n = prices.size(), memo[n][2];
        memset(memo, -1, sizeof(memo)); // -1表示 还没有计算过
        const int inf = 0x3f3f3f3f;
        function<int(int, bool)> dfs = [&](int i, bool hold) -> int {
            if(i < 0) return hold ? -(inf) : 0;
            int &res = memo[i][hold];
            if(res != -1) return res;
            if(hold) {
                return res = max(dfs(i-1, true), dfs(i-2, false)-prices[i]);
            }else{
                return res = max(dfs(i-1, false), dfs(i-1, true)+prices[i]);
            }
        };
        return dfs(n - 1, 0);
    }

    // 法二:递推写法
    // 这道题和 122 题唯一不同之处:
    // 满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
    //  - 卖出股票后,无法在第二天买入股票(冷冻期为1天)
    //  - 买入股票 的前一天 不能有卖出股票的操作
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        const int inf = 0x3f3f3f3f;
        int f[n+2][2];
        memset(f, 0, sizeof(f));
        f[1][1] = -(inf); // 现在的 f[1][1] 对应着 i = -1情况
//        f[0][1] = -(inf);
// 注意,dfs(−2,1) 是访问不到的,所以下面翻译成递推时,无需初始化这个状态(不需要写 f[0][1]=−∞ )

        // 由于 存在冷冻期 所以这里的 买入股票需要 向后回退两个状态,所以将起始天数挪到i = 2位置
        // 同时注意:这里由于 f数组 向后挪动两格,对应prices时 下标需要-2

        // 注意!!! 一开始 总是没能过样例,原来是 将f状态后移两格之后 在循环迭代需要遍历到 下标为n+1位置
        // 一开始循环边界:for(int i = 2; i < n; ++i)  后边的循环边界:for(int i = 2; i < n+2; ++i)
        for(int i = 2; i < n+2; ++i){
            // 对于卖出股票
            f[i][0] = max(f[i-1][0], f[i-1][1] + prices[i-2]);
            // 买入操作 必须保证前两天都未持有 股票
            // - 允许 001 即过去两天持有股票数为0,今天买入股票
            // - 不允许 101
            // 操作 f[i-1][0] - prices[i-1] 修改一下 为:f[i-2][0] - prices[i-1]
            f[i][1] = max(f[i-1][1], f[i-2][0] - prices[i-2]);
        }
        // 由于 数组添加了 最开头两个防止越界的列 但还是返回最后天数没有持有股票的收益
        // f[n+2][2] 则 返回 f[n+1][0] ,小心这个数组的下标
        return f[n+1][0];
    }
};

188.买卖股票的最佳时机 IV (至多交易k次)

解题思路:

image-20240210213301111

  • $dfs(i, j, 0) = max(dfs(i-1, j, 0), dfs(i-1, j, 1) + prices[i])$
  • $dfs(i, j, 1) = max(dfs(i-1, j, 1), dfs(i-1, j-1, 0) - prices[i])$

递归边界:

image-20240210214332464

递归入口:

  • $max(dfs(n-1, k, 0), dfs(n-1, k, 1)) = dfs(n-1, k, 0)$

注:这道题与上边的买卖股票不同,做出以下的限制

  • 不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)- 这个和之前一样
  • 最多可以完成k笔交易(买入股票+卖出股票 算是一笔交易)

注:

  • 由于最后未持有股票,手上的股票一定会卖掉,所以代码中的 j-1 可以是在买股票的时候,也可以是在卖股票的时候,这两种写法都是可以的。
  • 状态$f[n, k, 0]$ 表示 截止第n天没有持有股票,前边至多进行k次交易所获得最大利润

翻译为 递推写法:

image-20240210220439759

恰好/至少交易k次,如何思考?

image-20240210225312295

  • 改成「恰好」完成 k笔交易要怎么做?

    递归到 i<0 时,只有 j=0 才是合法的,j>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
    
    # 恰好
    class Solution:
        def maxProfit(self, k: int, prices: List[int]) -> int:
            # 递推
            n = len(prices)
            f = [[[-inf] * 2 for _ in range(k + 2)] for _ in range(n + 1)]
            f[0][1][0] = 0  # 只需改这里
            for i, p in enumerate(prices):
                for j in range(1, k + 2):
                    f[i + 1][j][0] = max(f[i][j][0], f[i][j][1] + p)
                    f[i + 1][j][1] = max(f[i][j][1], f[i][j - 1][0] - p)
            return f[-1][-1][0]
    
            # 记忆化搜索
            # @cache
            # def dfs(i: int, j: int, hold: bool) -> int:
            #     if j < 0:
            #         return -inf
            #     if i < 0:
            #         return -inf if hold or j > 0 else 0
            #     if hold:
            #         return max(dfs(i - 1, j, True), dfs(i - 1, j - 1, False) - prices[i])
            #     return max(dfs(i - 1, j, False), dfs(i - 1, j, True) + prices[i])
            # return dfs(n - 1, k, False)
    
  • 改成「至少」完成 k笔交易要怎么做?

    递归到「至少 000 次」时,它等价于「交易次数没有限制」,那么这个状态的计算方式和 122. 买卖股票的最佳时机 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
    
    # 至少
    class Solution:
        def maxProfit(self, k: int, prices: List[int]) -> int:
            # 递推
            n = len(prices)
            f = [[[-inf] * 2 for _ in range(k + 1)] for _ in range(n + 1)]
            f[0][0][0] = 0
            for i, p in enumerate(prices):
                f[i + 1][0][0] = max(f[i][0][0], f[i][0][1] + p)
                f[i + 1][0][1] = max(f[i][0][1], f[i][0][0] - p)  # 无限次
                for j in range(1, k + 1):
                    f[i + 1][j][0] = max(f[i][j][0], f[i][j][1] + p)
                    f[i + 1][j][1] = max(f[i][j][1], f[i][j - 1][0] - p)
            return f[-1][-1][0]
    
            # 记忆化搜索
            # @cache
            # def dfs(i: int, j: int, hold: bool) -> int:
            #     if i < 0:
            #         return -inf if hold or j > 0 else 0
            #     if hold:
            #         return max(dfs(i - 1, j, True), dfs(i - 1, j - 1, False) - prices[i])
            #     return max(dfs(i - 1, j, False), dfs(i - 1, j, True) + prices[i])
            # return dfs(n - 1, k, 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
37
38
39
40
41
42
43
44
45
46
class Solution {
public:
    // 法一:记忆化搜索
    int maxProfit(int k, vector<int>& prices) {
        int n = prices.size(), memo[n][k+1][2];
        memset(memo, -1, sizeof(memo)); // -1表示还没有计算过
        const int inf = 0x3f3f3f3f;
        function<int(int, int, bool)> dfs = [&](int i, int j, bool hold) -> int {
            if(j < 0) return -(inf);
            if(i < 0) return hold ? -(inf) : 0;
            int &res = memo[i][j][hold];
            if(res != -1) return res;
            if(hold)
                // 买入作为 一次交易;那么卖出 就不用重复计算了(因为买入 和 卖出 一起作为一次交易)
                return res = max(dfs(i-1, j, true), dfs(i-1, j-1, false) - prices[i]);
            else
                return res = max(dfs(i-1, j, false), dfs(i-1, j, true) + prices[i]);
        };
        return dfs(n-1, k, 0);
    }
    // 法二:递推写法
    int maxProfit1(int k, vector<int>& prices) {
        int n = prices.size(), f[n+1][k+2][2];
        // 1. 从上边的 递归返回条件来看,j = -1,交易次数<0时,才返回 -(inf)
        //  0~k次交易机会 需要从数组下标 1~k+1对应,在前面插入一个状态 j = -1对应数组下标0的情况
        //  所以初始化数组 第二维度大小为 k+2
        // 2. 第一维度 从上边递归返回条件来看,i = -1,根据是否持有股票返回对应的值
        //  1~n 天对应 数组下标1 ~ n,在前面插入一个状态 i = -1 对应数组下标0的情况
        //  所以初始化数组 第一维度大小为 n+1
        memset(f, -0x3f, sizeof(f)); // 初始化数组所有元素为 负无穷
        // f[*][0][*] = -inf  交易次数 = -1 对应第二维度数组下标为0,初始化为负无穷 代表不可能
        // f[0][j][0] = 0  j>=1 对于第-1天 没有持有股票边界情况 初始化为0
        // f[0][j][1] = -inf 对j>=1 于第-1天 持有股票边界情况 初始化为负无穷 代表不可能
        for(int j = 1; j <= k+1; ++j)
            f[0][j][0] = 0;
        // 填表顺序 从第一维度 再第二维度
        for(int i = 0; i < n; ++i)
            for(int j = 1; j <= k+1; ++j){
                // 对于第一维度 天数来说 数组最开始插入 -1天边界情况 对应 f[0][*][*]
                // 所以实际上 第0天 对应数组下标为 f[1][*][*] 而对应股票价格仍然是prices[0]
                f[i+1][j][0] = max(f[i][j][0], f[i][j][1] + prices[i]); // 卖出
                f[i+1][j][1] = max(f[i][j][1], f[i][j-1][0] - prices[i]); // 买入
            }
        return f[n][k+1][0];
    }
};

课后作业

121.买卖股票的最佳时机

123.买卖股票的最佳时机 III

714.买卖股票的最佳时机含手续费

1911.最大子序列交替和

22 区间DP 最长回文子序列 最优三角剖分

学习大纲

image-20240210230449765

516.最长回文子序列

解题思路:

  • 思路一:求 s 和 反转后的s的LCS(最长公共子序列)

  • 思路二:子序列,从枚举角度来看,本质上就是==选或不选==的问题,从两侧向内缩小问题规模

    image-20240210232414565

    (上边思考方式类似LCS,唯一不同的是 从两侧选字母,相同字母可以都选,不同字母需要只选一个 或者 都不选)

    定义$dfs(i, j)$ 表示从 $s[i]$ 到 $s[j]$ 的最长回文子序列的长度

    image-20240210232545291

    递归边界:

    • $dfs(i, i) = 1$
    • $dfs(i+1, i)=0$

    比如:子序列bb,会出现从$dfs(i, i+1)$ 递归到 $dfs(i+1, i)$ 的情况

    递归入口: $dfs(0, n-1)$

    **时间复杂度:**状态有$n^2$ 个,每个状态只需要O(1)时间计算,时间复杂度为O($n^2$)

    **空间复杂度:**空间复杂度为状态个数,即O($n^2$)

    将上述的搜索 翻译成递推写法:

    image-20240213190008591

    需要上述的状态转移方程中,状态枚举的顺序,这取决于状态生成顺序,从二维数组的==左下角 计算到 右上角==

完整代码:

 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
73
74
75
76
77
78
79
80
class Solution {
public:
    // 法一:递归写法
    int longestPalindromeSubseq(string s) {
        int n = s.length();

        std::function<int(int, int)> dfs = [&](int i , int j) -> int {
            if(i > j)
                return 0;
            if(i == j)
                return 1;
            if(s[i] == s[j])
                return dfs(i+1, j-1) + 2;
            return max(dfs(i+1, j), dfs(i, j-1));
        };
        return dfs(0, n-1);
    }
    // 法二:记忆化搜索
    // - 错误写法(return memo[0][n-1])
    // - 正确写法(return dfs(0, n-1))
    int longestPalindromeSubseq1(string s) {
        int n = s.length();

        int memo[n][n];
        memset(memo, 0, sizeof(memo)); // 0代表还未初始化,对于i > j的下三角元素来说是初始化
        for(int i = 0; i < n; ++i)
            memo[i][i] = 1;
        // 似乎计算顺序问题 下边这种写法不正确
        std::function<int(int, int)> dfs = [&](int i , int j) -> int {
//            if(i > j)
//                return 0;
//            if(i == j)
//                return 1;
            if(i >= j)
                return memo[i][j];
            int &res = memo[i][j];
            if(res != 0)
                return res;
            if(s[i] == s[j])
                return res = (dfs(i+1, j-1) + 2);
            return res = max(dfs(i+1, j), dfs(i, j-1));
        };
//        return memo[0][n-1];  这样没有调用dfs 只能返回0
        return dfs(0, n-1);
    }
    // 法二:记忆化搜索 能过版本
    int longestPalindromeSubseq2(string s) {
        int n = s.length(), memo[n][n];
        memset(memo, -1, sizeof(memo)); // -1 表示还没有计算过
        function<int(int, int)> dfs = [&](int i, int j) -> int {
            if (i > j) return 0; // 空串
            if (i == j) return 1; // 只有一个字母
            int &res = memo[i][j];
            if (res != -1) return res;
            if (s[i] == s[j]) return res = dfs(i + 1, j - 1) + 2; // 都选
            return res = max(dfs(i + 1, j), dfs(i, j - 1)); // 枚举哪个不选
        };
        return dfs(0, n - 1);
    }

    // 法三:翻译成 递推写法
    int longestPalindromeSubseq3(string s) {
        int n = s.length();
        int f[n][n];
        memset(f, 0, sizeof(f));
        for(int i = n-1; i >= 0; --i){
            f[i][i] = 1;
            // 这里的 j = i+1 保证了 i < j,并且 i = n-1时,j = n不会进入循环,也就是不会越界访问下边的语句
            for(int j = i+1; j < n; ++j){
                if(s[i] == s[j]){
                    f[i][j] = f[i+1][j-1] + 2;
                }else{
                    f[i][j] = max(f[i+1][j], f[i][j-1]);
                }
            }
        }
        return f[0][n-1];
    }
    // 法四:空间优化为 O(n) 暂时还没有头绪
};

1039.多边形三角剖分的最低得分

解题思路:

image-20240213220448516

定义 $dfs(i, j)$表示从 ij这个多边形的最低得分

image-20240213220636800

递归边界: $dfs(i, i+1) = 0$

递归入口: $dfs(0, n-1)$

时间复杂度:由于状态有 O($n^2$)个,每个状态需要O(n)时间来计算,故复杂度为O($n^3$)

空间复杂度:状态个数,即O($n^2$)

翻译成递推写法:

image-20240213222619654

完整代码:

 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
class Solution {
public:
    // 法一:递归写法
    // 超时
    int minScoreTriangulation(vector<int>& values) {
        int n = values.size();

        const int inf = 0x3f3f3f3f;
        std::function<int(int, int)> dfs = [&] (int i, int j) -> int {
            if(i+1 == j)
                return 0;
            int res = inf;
            for(int k = i+1; k < j; ++k)
                res = min(res, dfs(i, k)+dfs(k, j)+ values[i]*values[j]*values[k]);
            return res;
        };

        return dfs(0, n-1);
    }
    // 法二:记忆化搜索
    int minScoreTriangulation1(vector<int>& values) {
        int n = values.size();

        const int inf = 0x3f3f3f3f;
        int memo[n][n];
        // 初始化每个元素为 inf,表示还未填充
        memset(memo, 0x3f, sizeof(memo));
        std::function<int(int, int)> dfs = [&](int i, int j) ->int {
            if(i+1 >= j)
                return 0;
            int& res = memo[i][j];
            if(res != inf)
                return res;
            // 注意:k的取值为[i+1, j-1]
            for(int k = i+1; k < j; ++k)
                // 由于这下边 要去min最小值,所以需要定义res一开始为inf,否则res=0总是最小值
                res = min(res, dfs(i, k)+dfs(k, j)+values[i]*values[j]*values[k]);
            return res;
        };
        return dfs(0, n-1);
    }
    // 法三:翻译成 递推写法
    int minScoreTriangulation2(vector<int>& values){
        int n = values.size();

        int f[n][n];
        const int inf = 0x3f3f3f3f;
        memset(f, 0, sizeof(f));
        // 注意:i < k,f[i]由f[k]转移过来 需要倒序枚举
        //      j > k,f[i][j]由f[i][k]转移过来 需要正序枚举
        // i, k, j至少围成一个三角形,也就是 j至少要从i+2开始,所以i 倒序枚举从n-3开始
        for(int i = n-3; i >= 0; --i)
            for(int j = i+2; j < n; ++j){
                int res = inf;
                for(int k = i+1; k < j; ++k)
                    res = min(res, f[i][k]+f[k][j]+values[i]*values[j]*values[k]);
                f[i][j] = res;
            }
        return f[0][n-1];
    }
};

课后作业

375.猜数字大小 II

132.分割回文串 II

1312.让字符串成为回文串的最少插入次数

1771.由子序列构造的最长回文串的长度

1547.切棍子的最小成本

1000.合并石头的最低成本

23 树形DP(1) 树的直径

学习大纲:

image-20240213223956323

543.二叉树的直径

解题思路:

  • 二叉树直径定义:指树中任意两个节点之间最长路径的 长度 。这条路径可能经过也可能不经过根节点 root

  • 复习:二叉树的最大深度

    image-20240213235415085

  • 换个角度看直径:

    image-20240213235528851

    从这样看:最大直径 = 左子树最大深度 + 右子树最大深度 + 2

  • 算法:

    遍历整棵二叉树,==在计算最长链的同时==,顺带把直径算出来

    在当前结点 [拐弯] 的直径长度 = 左子树的最长链 + 右子树的最长链 + 2

    而==以当前结点为根的子树最长链==计算公式如下:

    返回给父节点 是以当前节点为根的子树的最长链 = Max(左子树的最长链,右子树的最长链) + 1

  • 时间复杂度:每个结点都遍历一遍,时间复杂度为O(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
class Solution {
public:
    int diameterOfBinaryTree(TreeNode* root) {
        int ans = 0;
        std::function<int(TreeNode *)> dfs = [&](TreeNode *root) -> int{
            if(root == nullptr)
                return -1;
            // 返回值为最大深度
            int l_len = dfs(root->left);
            int r_len = dfs(root->right);
            // 计算每个节点的链长,并且更新全局变量ans
            ans = max(ans, l_len + r_len + 2);
            return max(l_len, r_len) + 1;
        };
        dfs(root);
        return ans;
    }
private:
    // 链长 计算需要用到最大深度,而函数的返回值为最大深度
    // 可以传入一个全局变量,这样在递归的时候就可以计算每个节点的链长
    //      取所有节点链长的最大值 为最大深度
    int longestDepth(TreeNode* root) {
        // 叶子节点最长链为0,叶子节点的左右子节点为 虚拟节点 最长链为-1
        if(root == nullptr)
            return -1;
        return max(longestDepth(root->left), longestDepth(root->right)) + 1;
    }
};

124.二叉树中的最大路径和

解题思路:

image-20240214011613390

  • 算法: 遍历二叉树,在计算最大链和的同时,顺带更新答案的最大值

    当前结点 [拐弯] 的最大路径和 = 左子树最大链和 + 右子树最大链和 + 当前节点值

    返回给父节点(函数返回值):max(左子树最大链和,右子树最大链和) + 当前节点值

    注意:==如果返回值为负数==,则返回0

  • 时间复杂度:O(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
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

class Solution {
public:
    int maxPathSum(TreeNode* root) {
        const int inf = 0x3f3f3f3f;
        // 由于节点值 有负数,且路径上至少要有一个点,答案可以 初始为 负无穷大
        int val = -(inf);
        std::function<int(TreeNode*)> dfs = [&] (TreeNode *root) -> int{
            if(root == nullptr)
                return 0;
            int l_val = dfs(root->left);
            int r_val = dfs(root->right);
            // 更新全局的 路径和
            val = max(val, l_val+r_val+root->val);
//            return (max(l_val, r_val) + root->val < 0) ? 0 : max(l_val, r_val) + root->val;
            return max(max(l_val, r_val)+root->val, 0);
        };
        dfs(root);
        return val;
    }
};

2246.相邻字符不同的最长路径

解题思路:

  • 引入邻居概念:如果两个点 x 与 y之间有边相连,可以说 x是y的邻居,y是x的邻居

  • 两种思路如下:

    image-20240214013444118

    image-20240214013632018

  • 代码思路详剖:

  • 枚举子树 x 的所有子树 y ,维护从 x 出发的最长路径 maxLen,那么可以更新答案为从 y 出发的最长路径加上 maxLen,再加上 1(边 x−y),即合并从 x 出发的两条路径。递归结束时返回maxLen

    对于本题的限制,我们可以在从子树 y 转移过来时,==仅考虑从满足 s[y]≠s[x]的子树 y 转移过来==,所以对上述做法加个 if 判断就行了。

  • 时间复杂度:O(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
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
73
74
class Solution {
private:
    map<int, vector<int>> mp;
public:
    // 思路二:遍历x的子树的同时 求最大+次长
    // 注意:思路可行,但是运行超时 通过样例比例  138/142  后边改了变量名 居然勉强能过???
    int longestPath(vector<int>& parent, string s) {
        int n = parent.size();
        // 0是根结点 边不存在,其余结点与根结点有边 或者 其余结点之间有边
        for(int i = 1; i < n; ++i){
            mp[parent[i]].push_back(i);
        }

        int ans = 0;
        // 注:这道题的 最长路径 指的是 路径上结点的个数
        // 而函数 返回值为 路径长度
        // 结点个数 = 路径长度 + 1
        std::function<int(int)> dfs = [&] (int x) ->int {
//            int x_len = 0;
            int maxLen = 0;
            // map容器 使用迭代器访问
            // 报错:No viable conversion from 'std::pair<const int, int>' to
            // 'map<int, int>::iterator' (aka '_Rb_tree_iterator<std::pair<const int, int>>')
//            for(auto it : mp) {
//
//            }
            // 这里 x为 根结点下标,y为 孩子结点下标
            vector<int> vi = mp[x];
            for(int y : vi) {
//                int y_len = dfs(y) + 1;
                int len = dfs(y) + 1;
                if(s[y] != s[x]) {
//                    ans = max(ans, x_len+y_len);
                    ans = max(ans, maxLen + len);
                    // 更新最长的子结点 路径
                    maxLen = max(maxLen, len);
//                    x_len = max(x_len, y_len);
                }
            }
            return maxLen;
        };

        // 注:如果 x的邻居包含 父节点,可以额外传入 父节点fa
        std::function<int(int, int)> dfs1 = [&] (int x, int fa) ->int {
            int x_len = 0;
            // map容器 使用迭代器访问
            // 报错:No viable conversion from 'std::pair<const int, int>' to
            // 'map<int, int>::iterator' (aka '_Rb_tree_iterator<std::pair<const int, int>>')
//            for(auto it : mp) {
//
//            }
            // 这里 x为 根结点下标,y为 孩子结点下标
            vector<int> vi = mp[x];
            for(int y : vi) {
                if(y == fa) continue;
                int y_len = dfs1(y, x) + 1;
                if(s[y] != s[x]) {
                    ans = max(ans, x_len+y_len);
                    // 更新最长的子结点 路径
                    x_len = max(x_len, y_len);
                }
            }
            return x_len;
        };

        dfs1(0, -1);

//        dfs(0);
        return ans+1;
    }
    // dfs函数 解释:
    // maxLen 表示遍历过的父、子间的最长路径,len 表示当前这一条路径长度,
    //      遍历一遍后,可以覆盖所有情况;最终找到父到最长的两个子的路径长度和
};

课后作业

687.最长同值路径

1617.统计子树中城市之间最大距离

2538.最大价值和与最小价值和的差值

24 树形DP(2) 打家劫舍III 树上最大独立集

回顾:上期学习了,树形DP中的树的直径,下边学习两种树形DP — 树上最大独立集 和 树上最小支配集

337.打家劫舍 III

解题思路:

image-20240215022515707

分类讨论:

  • 根结点2选择,那么与根结点相连的 两个子节点都不可以选
  • 根结点2 不选择,那么与根结点相连的 两个子节点可以 选/不选

回顾之前 数组版本的打家劫舍:选或不选的思路,如果不选第i个数,那么从第i-1个数转移过来;如果选第i个数,那么从i-2个数转移过来。

如果照搬上边的思路,比如:==当前选了根结点==,此时儿子结点不能选,那么就需要从儿子的儿子结点转移过来,这样考虑 ==至多需要考虑四个孙子结点==,并且没考虑一个结点都要判断儿子是否为空、孙子是否为空,非常繁琐复杂。

重新思考:能不能直接从 儿子结点状态转移过来?

  • 每个节点 都有 选 或 不选 两种情况,将这两种情况当作两个状态

将上述想法转换如下:

image-20240215023217613

  • 时间复杂度:由于每个结点 只递归一次,所以复杂度为O(n)
  • 空间复杂度:在最坏情况下,这棵二叉树是一条链,递归需要O(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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

class Solution {
public:
    int rob(TreeNode* root) {
        std::function<vector<int>(TreeNode*)> dfs = [&] (TreeNode* node) -> vector<int> {
            if(node == nullptr)
                return {0, 0};  // 选, 不选
            vector<int> left = dfs(node->left);
            vector<int> right = dfs(node->right);
            // 偷 根结点房子 那么不能偷左右节点
            int rob = left[1] + right[1] + node->val;
            // 不偷 根结点房子 那么可以偷左右节点房子,也可以不偷左右节点房子,取最大效益方案
            int not_rob = max(left[0], left[1]) + max(right[0], right[1]);

            return {rob, not_rob};
        };
        vector<int> ans = dfs(root);
        // 答案是 根结点 选或不选的最大值
        return max(ans[0], ans[1]);
    }
};

// 灵神写法
class Solution1 {
    pair<int, int> dfs(TreeNode *node) {
        if (node == nullptr) // 递归边界
            return {0, 0}; // 没有节点,怎么选都是 0
        auto [l_rob, l_not_rob] = dfs(node->left);  // 递归左子树
        auto [r_rob, r_not_rob] = dfs(node->right); // 递归右子树
        int rob = l_not_rob + r_not_rob + node->val; // 选
        int not_rob = max(l_rob, l_not_rob) + max(r_rob, r_not_rob);  // 不选
        return {rob, not_rob};
    }

public:
    int rob(TreeNode *root) {
        // 结构化绑定写法
        auto [root_rob, root_not_rob] = dfs(root);
        return max(root_rob, root_not_rob); // 根节点选或不选的最大值
    }
};

总结

最大独立集 和 树上最大独立集的关系

image-20240215025304295

若是 每棵树的点权都为1,则求的就是最大独立集

树形DP如何思考?

  • 树和子树的关系:类似原问题和子问题的关系,所以树天然具有递归的特点。

  • 如何由子问题算出原问题,是思考树形DP的出发点

常见的套路:

  1. 选或不选
  2. 枚举选哪个

课后作业

没有上司的舞会

上述做题思路可以推广到一般树上,如果选当前结点,它的儿子都不能选;如果不选当前结点,每个儿子都可以考虑 选/不选,取最值。公式如下:

image-20240215025125372

1377.T 秒后青蛙的位置

1377 思考题:如果有多个目标位置呢?

2646.最小化旅行的价格总和

(会员题)https://leetcode.cn/problems/choose-edges-to-maximize-score-in-a-tree/

https://codeforces.com/problemset/problem/1689/C

25 树形DP(3) 监控二叉树 树上最小支配集

968.监控二叉树

解题思路:

原题:给定一个二叉树,在树的节点上安装摄像头。节点上的每个摄像头都可以监视其父对象、自身及其直接子对象。 计算监控树的所有节点所需的最小摄像头数量。

image-20240215025954338

image-20240215030229709

注:一个节点的父节点 和 儿子节点都安装了摄像头,那么它即可以是黄色节点,也可以是红色节点

1、分类讨论如下:

image-20240215030444660

根结点为蓝色公式:蓝色 = min(左蓝,左黄,左红) + min(右蓝,右黄,右红) + 1

image-20240215030652863

根结点为黄色公式:黄色 = min(左蓝,左红) + min(右蓝,右红)

image-20240215030808104

根结点为红色公式:红色 = min(左蓝+右红,左红+右蓝,左蓝+右蓝)

2、整棵树根结点情况:

由于二叉树的根结点没有父节点,不可能是 黄色,所以:

最终答案 = min(根节点为蓝色,根节点为红色)

3、递归边界考虑:

image-20240215031134624

时间复杂度:递归整棵二叉树,每个节点递归一次,时间复杂度为O(n)

空间复杂度:最坏情况下,这棵二叉树会形成一条链,递归所需的栈空间为O(n)

变形1: 在节点x安装摄像头,需要花费cost[x],求监控所有节点的最小花费是多少?(上述监控二叉树,相当于把每个节点安装摄像头 花费为1)

将上述公式中,选择当前节点公式中的1改成 当前节点安装摄像头花费cost[x]即可,如下: int choose = min(l_choose, min(l_by_fa, l_by_children)) + min(r_choose, min(r_by_fa, r_by_children)) + cost[x];

**变形2:**如果一个红色节点有3个儿子呢? 有4个儿子呢?

image-20240215033655995

可以发现公式中讨论情况很多,如下:

image-20240215033825075

最后得到化简后的式子:

image-20240215033935344

完整代码:

 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
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

class Solution {
private:
    tuple<int, int, int> dfs(TreeNode *node) {
        if(node == nullptr) {
            return {INT32_MAX/2, 0, 0}; // 除2 防止加法溢出
        }

        // 结构化绑定 + 搭配 tuple使用
        auto [l_choose, l_by_fa, l_by_children] = dfs(node->left);
        auto [r_choose, r_by_fa, r_by_children] = dfs(node->right);
        int choose = min(l_choose, min(l_by_fa, l_by_children)) + min(r_choose, min(r_by_fa, r_by_children)) + 1;
        int by_fa = min(l_choose, l_by_children) + min(r_choose, r_by_children);
        int by_children = min(l_choose+r_by_children, min(l_by_children+r_choose, l_choose+r_choose));
        return {choose, by_fa, by_children};
    }
public:
    int minCameraCover(TreeNode* root) {
        auto [choose, _, by_children] = dfs(root);
        return min(choose, by_children);
    }
};

// 灵神写法:使用化简后的公式进行计算 速度更快
class Solution1 {
    tuple<int, int, int> dfs(TreeNode *node) {
        if (node == nullptr) {
            return {INT32_MAX / 2, 0, 0}; // 除 2 防止加法溢出
        }
        auto [l_choose, l_by_fa, l_by_children] = dfs(node->left);
        auto [r_choose, r_by_fa, r_by_children] = dfs(node->right);
        int choose = min(l_choose, l_by_fa) + min(r_choose, r_by_fa) + 1;
        int by_fa = min(l_choose, l_by_children) + min(r_choose, r_by_children);
        int by_children = min({l_choose + r_by_children, l_by_children + r_choose, l_choose + r_choose});
        return {choose, by_fa, by_children};
    }

public:
    int minCameraCover(TreeNode *root) {
        auto [choose, _, by_children] = dfs(root);
        return min(choose, by_children);
    }
};

课后作业

保安站岗 https://www.luogu.com.cn/problem/P2458

保安站岗 Python 代码 https://www.luogu.com.cn/paste/y9iiynzw

保安站岗 Go 代码 https://www.luogu.com.cn/paste/ei5ihtp6

LCP34. 二叉树染色(选做) https://leetcode.cn/problems/er-cha-shu-ran-se-UGC/

LCP64. 二叉树灯饰(选做) https://leetcode.cn/problems/U7WvvU/

26 单调栈 每日温度 接雨水

739.每日温度

解题思路:

题意:假设有一张 每日温度的折线图,这里的数字表示每一天温度。请你计算每一天的一个更大温度 出现在几天后?

image-20240217223850577

  • 暴力做法:比如从上边温度为5的下一个数开始,向右一个一个地遍历,直到找到一个大于5的数,最坏情况下(所以元素都一样),需要花费O($n^2$)时间

  • 优化(==从右到左==的思路):承接上边图片说明文字,分类讨论:

    • 对于比 2 小的数,例如前边的1,由于5出现,==2不可能成为1的下一个更大的数==
    • 对于大于等于2的数,比如前边的3,==2更不可能成为其下一个更大的数==

    结论:由于5的出现,2或者3不可能 作为5 左侧某个数的下一个更大的数,那就可以放心把 2和3去掉,不去遍历这两个数。

image-20240217224935111

由于 在记录一个数时,会把所有$\leq$ 这个数的数据从栈中弹出,不可能出现栈中数据:上面大下面小的情况,这说明栈中存储的数据是有序的,所以这个数据结构也叫做单调栈。

  • 时间复杂度:每个元素只会入栈一次,出栈至多一次,所以循环代码至多执行n次,复杂度为O(N)

  • 空间复杂度:最坏情况下,栈里边有n个元素,复杂度为O(n),不过考虑到本地值域很小 $30 \leq temperatures[i] \leq 100$,栈中最多只有71个元素,所以复杂度也可以为O(min(n, U)) (其中 U = max-min+1),常数级别。

  • 优化(==从左到右==思路):从左往右扫描数组,元素1入栈,接着元素4,由于4>1,比1大的元素已经找到,不需要存到栈中,出栈,此时元素4入栈。接着扫描3,入栈;扫描5,此时5>3 且 5 > 4,所以栈中元素向左搜索更大的数即为5,不用存储在栈中,出栈,此时5入栈,以此类推。

    • 可以看到,**从左到右写,站里面存储的数还没有找到下一个更大的数,一旦发现一个比栈顶更大的数,**就==立刻更新栈顶的答案==,同时把栈顶元素出栈。

小结:上边两种不同的思路,其实是看待同一个问题的不同角度。可以把下一个更大的数存到栈中(从右到左思路),也可以反过来,把没有找到下一个更大的数的那些数存到栈里边(从左到右思路)。用16个字总结一下单调栈的两种写法:及时去掉无用数据,保证栈中数据有序

完整代码:

 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
class Solution {
public:
    // 法一:利用单调栈 单调递减规律求解(自己思考 30分钟+20分钟Debug)
    vector<int> dailyTemperatures(vector<int>& temperatures) {
        int n = temperatures.size();
        vector<int> ans(n, 0);
        // pair<int, int> 第一个int表示 数组元素,第二个int表示 元素在温度数组的下标
        stack<pair<int, int>> st; // 维护一个 单调栈 栈内元素单调递减
        // 顺序遍历  温度数组,每个元素入栈,下一个元素入栈之前 检查是否符合单调递减
        // 若符合:暂时没有找到 入栈之前 栈顶元素的 下一个更大值隔阂天数
        // 若不符合,栈顶元素出栈,找到栈顶元素从左到右数数 碰到的第一个最大值
        pair<int, int> tmp;
        for(int i = 0; i < n; ++i){
            // 如果栈不为空
            if(!st.empty()){
               tmp = st.top();
               // 如果下边没有判断 st.empty() 栈是否为空,会报以下可能错误:
               // runtime error: reference binding to misaligned address 0xbebebebebebec0b6 for type 'std::pair<int, int>',
               // which requires 4 byte alignment (stl_deque.h)
               while(tmp.first < temperatures[i]){
                   ans[tmp.second] = i - tmp.second;
                   st.pop();
                   if(st.empty()) break;
                   tmp = st.top();
               }
//               st.push(make_pair(temperatures[i], i));
            }
            st.push(make_pair(temperatures[i], i));
        }
        return ans;
    }
    // 法二:单调栈 Up思路
    // 从右到左的思路
    vector<int> dailyTemperatures1(vector<int>& temperatures) {
        int n = temperatures.size();
        vector<int> ans(n, 0);
        stack<int> st;
        for(int i = n-1; i >= 0; --i){
            int t = temperatures[i];
            while(!st.empty() && t >= temperatures[st.top()])
                st.pop();
            if(!st.empty()){
                ans[i] = st.top() - i;
            }
            st.push(i);
        }
        return ans;
    }
    // 法三:单调栈 Up思路
    // 从左到右的思路
    // 注:从左到右计算 更可以体现单调栈的性质:一旦当前遍历温度t <= 栈顶元素
    //      可以推出,t同时也小于等于栈里面任意数,所以当t <= 栈顶元素
    //          意味着 t无法更新栈顶,也无法更新栈里面任何元素(的答案) 因为栈是单调的
    vector<int> dailyTemperatures2(vecotr<int>& temperatures){
        int n = temperatures.size();
        vector<int> ans(n, 0);
        stack<int> st;
        for(int i = 0; i < n; ++i){
            int t = temperatures[i];
            while(!st.empty() && t > temperatures[st.top()]){
                int j = st.top(); st.pop();
                ans[j] = i - j;
            }
            st.push(i);
        }
        return ans;
    }
};

42.接雨水

解题思路:

之前学过接雨水 前后缀分解 和相向双指针做法,都是假设每个位置都有一个水桶。计算每个水桶能接多少水,这样思考是竖着计算

image-20240217233130612

横着计算思路:从左到右遍历 ,栈中元素 5 2 1 0,如下:

image-20240217233606069

从计算过程可以发现:面积由三个下标决定,即 当前元素下标、栈顶元素下标、栈顶元素下边元素的下标。

例如:栈元素为 5 2 ,此时扫描元素为4时,元素5和4的下标查-1 就是高位2 宽为3区域的长,此时min(5, 4) - 栈顶元素2 就是高位2 宽为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
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
73
74
class Solution {
public:
     // 26.单调栈 例题
    // 调试了一下下边代码,发现pre和suf数组没出错,不能过样例,后面反思一下 是自己思路有误
    //      pre[i]含义 应该为 第i根柱子 从右往左数 最后一根比它高的柱子高度
    // 解题思路:维护两个单调栈(单调递减) 求两个数组
    //      数组 pre[i]:表示第i根柱子 从右往左数 第一根比它高的柱子 高度
    //      数组 suf[i]:表示第i根柱子 从左往右数 第一根比它高的柱子 高度
    //      如果没有比柱子高的 数组元素值为 第i根柱子本身高度
    int trap2(vector<int>& height) {
        int n = height.size();
        vector<int> pre(height), suf(height);
        // pair<int, int> 第一个int表示 数组元素,第二个int表示 元素在温度数组的下标
        stack<pair<int, int>> st;
        pair<int, int> tmp;
        for(int i = 0; i < n; ++i){
            if(!st.empty()){
                tmp = st.top();
                while(tmp.first < height[i]){
                    suf[tmp.second] = height[i];
                    st.pop();
                    if(st.empty()) break;
                    tmp = st.top();
                }
            }
            st.push(make_pair(height[i], i));
        }
        while(!st.empty()) st.pop();
        for(int i = n-1; i >= 0; --i){
            if(!st.empty()){
                tmp = st.top();
                while(tmp.first < height[i]){
                    pre[tmp.second] = height[i];
                    st.pop();
                    if(st.empty()) break;
                    tmp = st.top();
                }
            }
            st.push(make_pair(height[i], i));
        }
        // >>>debug代码
//        for(int i = 0; i < n; ++i)
//            cout << pre[i] << " ";
//        cout << endl;
//        for(int i = 0; i < n; ++i)
//            cout << suf[i] << " ";
        // >>>debug代码
        int ans = 0;
        for(int i = 0; i < n; ++i){
            ans += (min(pre[i], suf[i])-height[i]);
        }
        return ans;
    }
    // 26.单调栈 例题
    // Up主思路实现
    int trap3(vector<int>& height) {
        int ans = 0;
        stack<int> st;
        int n = height.size();
        for(int i = 0; i < n; ++i){
            int h = height[i]; // 右边柱子高度
            while(!st.empty() && h >= height[st.top()]){
                int bottom_h = height[st.top()];  // 找中间柱子高度
                st.pop();
                if(st.empty()) break;
                int left = st.top(); // 找左边柱子 下标
                int dh = min(h, height[left]) - bottom_h; // 填坑 高度
                ans += dh * (i - left - 1);
            }
            st.push(i);
        }
        return ans;
    } 
};

小结

如果要计算的内容 涉及到上一个或者下一个更大或更小的元素,可以考虑使用单调栈解决。

image-20240217234650658

课后作业

1475.商品折扣后的最终价格

901.股票价格跨度

1019.链表中的下一个更大节点

1944.队列中可以看到的人数

84.柱状图中最大的矩形

85.最大矩形

27 单调队列 滑动窗口最大值

239.滑动窗口最大值

解题思路:

image-20240217234803641

  • 暴力解法:枚举每个长为k的连续子数组,对每个子数组遍历找到最大值。如果k = n/2,大约为数组的一半,时间复杂度为O($n^2$)

  • 优化解法(单调队列):假设有一架飞机 飞过一排山峰,飞机只能看到视野内的山峰高度,一眼望去就可以看到 视野内最高山峰

    一开始将山峰高度 记录到一个队列中:2 1,接着:由于4的出现,左边的2和1比4小,离开窗口时间比4早,永远不会作为窗口最大值。可以将队列中的2 1出列,然后将4入队列。

    从上边例子中可以看出,每次遇到一个数==都把前面比它小的数去掉==,当然如果有相同数字也可以去掉保留最右边那个数,所以 队列中记录的数**从左到右是严格递减的。**那么最大值自然是队列中最左边那个数了。

    需要一个什么样的数据结构维护上边的数字?

    image-20240217235920541

时间复杂度:分析方法类似单调栈。每个下标入队出队最多一次,所以下边二重循环次数最多n次,复杂度为O(n)

空间复杂度:在不考虑返回值ans数组大小情况下,窗口大小为k,双端队列中有O(k)个元素,空间复杂度为O(k)。

  • 精确分析,在内循环中,将相同的队尾元素弹出,所以队列中没有相同的数,时间复杂度为 O(min(k, U)),其中$U = len(set(nums))$。例如:题目中指出 $1 \leq k \leq nums.length$,数组长度范围为$10^5$,但是数组的取值 $-10^4 \leq nums[i] \leq 10^4$,所以队列中元素至多为20001个,但是k长度范围为 $10^5$

完整代码:

 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:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> ans;
        int n = nums.size();
        deque<int> q; // 双端队列
        for(int i = 0; i < n; ++i){
            int val = nums[i];
            //1. 元素进入窗口
            while(!q.empty() && nums[q.back()] <= nums[i]){
                q.pop_back(); // 将队尾不符合单调性 元素出队,维护q的单调性
            }
            q.push_back(i);  // 当前元素下标入队
            //2. 元素出窗口
            if(i - q.front() + 1 > k) { // 队首 离开窗口
                q.pop_front();
            }

            //3. 记录答案
            if(i >= k-1) { // 窗口最右边游标 开始大于k-1,表示窗口大小符合k
                // 由于 队首到队尾单调递减,所以窗口最大值就是队首
                ans.push_back(nums[q.front()]);
            }
        }
        return ans;
    }
};

小结

这里的无用指两个方面:

  1. 像单调栈一样,在把元素加入队尾之前,根据大小关系,弹出队尾元素
  2. 当队首元素离开窗口后,也需要及时把它弹出队列

image-20240218001610020

课后作业

1438.绝对差不超过限制的最长连续子数组

2398.预算内的最多机器人数目

862.和至少为 K 的最短子数组

1499.满足不等式的最大值

1696.跳跃游戏 VI

2944.购买水果需要的最少金币数

附录

参考文献

版权信息

本文原载于kitebin.top,遵循CC BY-NC-SA 4.0协议,复制请保留原文出处。

Built with Hugo
主题 StackJimmy 设计