6 minute read

5/23

T45 跳跃游戏二

1、这一题虽然代码很短,但比上一题难度高上一个档次,因为不太好想直观的解法和证明。

2、关键:直接每次找局部最远未必是最优解,需要对覆盖范围内都做处理

3、代码如下:

class Solution {
public:
    int jump(vector<int>& nums) {
        int ans = 0;
        int cur_right = 0; 
        int next_right = 0; 
        for (int i = 0; i + 1 < nums.size(); i++) {
            next_right = max(next_right, i + nums[i]);
            if (i == cur_right) { 
                cur_right = next_right; 
                ans++;
            }
        }
        return ans;
    }
};

4、正确性证明待独立思考后补充

T55 跳跃游戏

1、这一题我按照贪心的思路蒙了一个算法,结果……AC了!这个思路为何正确,我写的时候不知道,但“蒙一个”是存在逻辑的: 首先,贪心是贪的局部解,最优解则是全局解。既然我们要局部最优,放在本题就对应“局部能跳得尽量远”(全局最优是跳远范围能覆盖到数组终点)。 我的思路如下:

    //从开头开始,寻找当前可跳跃范围内下一个元素值+下一个元素索引之和的最大值
    
    //若最终可以覆盖到终点,返回true;否则,返回false

2、代码如下:

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int n = nums.size();

        // cover 表示目前能够到达的最远下标
        int cover = 0;

        // 只遍历当前能够到达的范围
        for (int i = 0; i <= cover; i++) {

            // 从当前位置 i 出发,最远可以到达 i + nums[i]
            cover = max(cover, i + nums[i]);

            // 如果最远覆盖范围已经到达或超过最后一个下标,说明可以到达终点
            if (cover >= n - 1) {
                return true;
            }
        }

        // 如果循环提前结束,说明某个位置之后无法继续覆盖
        return false;
    }
};

3、正确性证明:先从直觉来看,要想跳到全局,每一个分步都应该尽量利用起最多可利用距离。下面从数学上进行严格证明:

充分性(若能由本算法跳到最终点,则题干条件下必然能跳到最终点): 证毕,该算法已给出一套解。

必要性(若题干条件满足,则该算法必能推出可行解):

设存在一条可行跳跃路径:

0=p₀<p₁<⋯<pₘ=n−1

且对任意:

pₜ₊₁≤pₜ+nums[pₜ]

算法维护的 cover 始终表示当前已知的最远可达位置。

证明算法一定不会在到达终点前停止。

对路径上的点做归纳:

初始:

p₀=0≤cover

所以算法一定能遍历到 p₀。

假设算法能遍历到 pₜ,则更新后:

cover≥pₜ+nums[pₜ]

又因为可行路径满足:

pₜ₊₁≤pₜ+nums[pₜ]

所以:

cover≥pₜ₊₁

因此算法也一定能继续遍历到 pₜ₊₁。

由归纳可得:

pₘ=n−1≤cover

所以算法必然在某一时刻得到:

cover≥n−1

并返回 true。

4、关键:这里的cover是一个有趣的在for循环里不断变大的上限,且前面的每一次循环都是+1这种自增,故能遍历到所有小于等于cover的情况,故上述证明成立。

5/22

T122 买卖股票的最佳时机二

1、这一题就很简单了,属于最基础的股票题。把股价变动抽象为相邻元素间的上升、下降峰,取所有单个上峰的和即为最大总利润。

2、注意,本题可删除单日多次交易的题干条件,单日只能交易一次时该算法同样正确。 关键:单日限制只影响同时卖(b)买(b),假设第一次交易买a卖b,第二次交易买c卖d,其中c b都在同一天交易故相等。由于新卖(d>c=b>a)更大,总差(d-a)依然等于可多次交易的(d-c+b-a)

3、代码如下:

class Solution {
public:
    int maxProfit(vector<int>& prices) {

        int count = 0;

        //计算相邻元素差值
        for (int i = 1; i < prices.size(); i++) {

            int diff = prices[i] - prices[i - 1];

            //有收益就加入
            if (diff > 0)
                count += diff;
        }

        return count;
    }
};

T53 最大子数组和

1、这一题非常经典,虽然我一开始没有思路,但看完代码后独立想出了正确性证明。贪心代码如下:

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int result = INT32_MIN;
        int count = 0;
        for (int i = 0; i < nums.size(); i++) {
            count += nums[i];
            if (count > result) { // 取区间累计的最大值(相当于不断确定最大子序终止位置)
                result = count;
            }
            if (count <= 0) count = 0; // 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
        }
        return result;
    }
};

2、正确性证明: 假设前一段【A………K】处第一次count<=0,证明这一段内的最优解已被记录,且抛弃这一段不影响后续寻找最优解,以及最终算法得到的就是最优解。

首先,【A……K】段内的最优解只有三种形式:左闭到中间、中间一段、中间到右闭。为何不会出现中间到右闭+非该段的连续序列作为全局最优解?原因很简单——既然我们第一次count非正是在K处,那么左侧任意一段左闭起始的都为正,因此整个这一段+同样右侧的子序列必然更大,而整个这一段又非正,因此比它更厉害的都被抛弃了,这个解必然非最优。

第一种形式:会被result记录最大值,如果这里面的最大值超过后面出现的解,我们仍会返回该最大值

第二种形式:同理,必然小于等于第一种形式,忽略

第三种形式:同理,必然小于等于第一种形式,忽略

而这一段count又非正,我们可以作为一个整体抛弃它。对于右侧一段,同理可寻得该段内的最优解,直到右指针遍历到数组最右侧,返回result最大值即可。

小拓展:若寻找最小连续子数字,把数组变成相反数,按最大值方法处理即可,再把结果反过来。

3、所有专题刷完后补充线段树、分治算法解法;注意:第二轮刷题时一律模拟全英面试场景

5/21

T376 摆动序列

1、这一题没有那么直观,有些难度,我一开始想错了,忽视掉它“可以删除部分元素”的意思是可以跳跃删除,而非子字符串那样是只能连续截取的(假设更改条件,最优解是否可直接双指针+左指针跳跃?)

2、最优解看起来很简洁,但不那么好想到:

class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {

        // 长度 0/1 直接返回
        if (nums.size() <= 1)
            return nums.size();

        // 前一个差值
        int preDiff = 0;

        // 当前差值
        int curDiff = 0;

        // 至少保留第一个元素
        int count = 1;

        for (int i = 1; i < nums.size(); i++) {

            curDiff = nums[i] - nums[i - 1];

            // 出现方向变化(峰值/谷值)
            if ((preDiff <= 0 && curDiff > 0) ||
                (preDiff >= 0 && curDiff < 0)) {

                count++;

                // 更新方向
                preDiff = curDiff;
            }
        }

        return count;
    }
};

3、举例说明:如果出现333355555777775555 按照代码逻辑 3 5 过后predif=2,到后面7 5 curdif=-2满足 所以count=3 本质是5 7 5,并非计算的3 5 5这个错误序列,而是正确的中间值。为何这样的逻辑可以成功?

4、这一题的核心逻辑可以如下理解:将总序列抽象为一道道上升、下降的陡坡,相邻的同样方向的陡坡合并为一个坡(也就是不出现方向变化时count不自增),这样遍历一回后,就可以提炼为若干道上下相邻的陡坡,这时再根据题干性质删除不必要的元素,就可以连接这些陡坡,而这些陡坡数量和也必然是最优解。这样看来,会好理解很多。至于平坡的情况,在上一个例子中可以看到,连续相等的数不影响统计,因为predif在3-5的衔接处非零,所以下一个有效坡必然非陡。

5、这一题的本质还是删除连续上升/下降/平坡中的连续中间值,官方题解里有动态规划的解法和证明,后续进行到新专题可以回顾此题。

【贪心专题】

这个专题核心是证明,解题时靠的则是直觉的敏锐度,这基于对多种证明套路的了解。

T455 分发饼干

简单,直接联想到最大化利用,尺寸大的饼干满足胃口大的,排序后双指针移动即可。 代码如下:

class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {

        // 两个数组排序
        sort(g.begin(), g.end());
        sort(s.begin(), s.end());

        int count = 0;

        // 两个指针都先指向最大值
        int child = g.size() - 1;
        int cookie = s.size() - 1;

        while (child >= 0 && cookie >= 0) {

            // 假如饼干满足胃口
            if (s[cookie] >= g[child]) {
                count++;

                // 两个指针同时左移
                child--;
                cookie--;
            }
            else {

                // 饼干不够,当前最大胃口满足不了
                // 胃口指针左移,尝试满足更小胃口
                child--;
            }
        }

        return count;
    }
};

证明思路是“交换法”,简单概括为:假如把一个大饼干给小胃口的人,调换之后,另一组小饼干不一定能满足大胃口的人,因此不会出现更优解,故此为最佳解——即优先将大饼干给大胃口的人。

5/20

【回溯专题总结摘录】

1、如果是一个集合来求组合的话,就需要startIndex,如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex。

2、两种经典去重方式: 图片: https://uploader.shimo.im/f/QK6hj6p1cPbB9RZ6.png!thumbnail?accessToken=eyJhbGciOiJIUzI1NiIsImtpZCI6ImRlZmF1bHQiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3Nzk1ODkxMDEsImZpbGVHVUlEIjoiV3IzRHAyYndyYnVFMVYzSiIsImlhdCI6MTc3OTU4ODgwMSwiaXNzIjoidXBsb2FkZXJfYWNjZXNzX3Jlc291cmNlIiwicGFhIjoiYWxsOmFsbDoiLCJ1c2VySWQiOjg2Mzc1NzV9.XvzfQmceFQAM-vlxP7pmkfVLfVweN1i7mEUGBmIVRt8

图片: https://uploader.shimo.im/f/WeTNifUdloqcfUSc.png!thumbnail?accessToken=eyJhbGciOiJIUzI1NiIsImtpZCI6ImRlZmF1bHQiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3Nzk1ODkxMDEsImZpbGVHVUlEIjoiV3IzRHAyYndyYnVFMVYzSiIsImlhdCI6MTc3OTU4ODgwMSwiaXNzIjoidXBsb2FkZXJfYWNjZXNzX3Jlc291cmNlIiwicGFhIjoiYWxsOmFsbDoiLCJ1c2VySWQiOjg2Mzc1NzV9.XvzfQmceFQAM-vlxP7pmkfVLfVweN1i7mEUGBmIVRt8

3、回溯法性能分析汇总: 子集问题分析: 时间复杂度:O(2^n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2^n) 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n) 排列问题分析: 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * ….. 1 = n!。 空间复杂度:O(n),和子集问题同理。 组合问题分析: 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。 空间复杂度:O(n),和子集问题同理。 N皇后问题分析: 时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * …. * 1。 空间复杂度:O(n),和子集问题同理。 解数独问题分析: 时间复杂度:O(9^m) , m是’.’的数目。 空间复杂度:O(n^2),递归的深度是n^2

T37 解数独

1、回溯专题最后一题,也是困难标签。但是,这一题直接回溯的话本质和N皇后还是很像,格子的形状也类似,这个数字不行就换一个。无剪枝的代码也能高效运行(题目不会出大数目):

class Solution {
public:
    bool row[9][10]{};
    bool col[9][10]{};
    bool box[9][10]{};

    bool dfs(vector<vector<char>>& board) {

        // 找下一个空格
        for (int i = 0; i < 9; i++) {
            for (int j = 0; j < 9; j++) {

                if (board[i][j] != '.') continue;

                int bid = (i / 3) * 3 + j / 3;

                // 尝试填1~9
                for (int num = 1; num <= 9; num++) {

                    if (row[i][num] ||
                        col[j][num] ||
                        box[bid][num])
                        continue;

                    // 填入
                    board[i][j] = num + '0';

                    row[i][num] = true;
                    col[j][num] = true;
                    box[bid][num] = true;

                    if (dfs(board))
                        return true;

                    // 回溯
                    board[i][j] = '.';

                    row[i][num] = false;
                    col[j][num] = false;
                    box[bid][num] = false;
                }

                // 当前空格无解
                return false;
            }
        }

        // 全填完
        return true;
    }

    void solveSudoku(vector<vector<char>>& board) {

        // 初始化状态
        for (int i = 0; i < 9; i++) {
            for (int j = 0; j < 9; j++) {

                if (board[i][j] == '.')
                    continue;

                int num = board[i][j] - '0';

                row[i][num] = true;
                col[j][num] = true;

                int bid = (i / 3) * 3 + j /3;

                box[bid][num] = true;
            }
        }

        dfs(board);
    }
};

2、关键:棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。

3、二维回溯

5/19

T51 N皇后

1、第一道困难级别的题,不过我上课时做过类似的题目,毕竟这个场景太有名了哈哈。顺便,力扣企业上这道题三个月内同时被谷歌苹果考察过,因此非常重要。

2、实际上,这一题还有贪心算法,以及其他可用逻辑,在算法设计课上有详细讲过。

3、这一题可以巧妙加入不同程度的剪枝,因为一个皇后确定后,她周围横刷斜的许多位置便可以直接跳过。不过,这一题的N不超过9,每次判断is valid再回溯也是可行的。

4、经典代码如下:

class Solution {
private:
vector<vector<string>> result;
// n 为输入的棋盘大小
// row 是当前递归到棋盘的第几行了
void backtracking(int n, int row, vector<string>& chessboard) {
    if (row == n) {
        result.push_back(chessboard);
        return;
    }
    for (int col = 0; col < n; col++) {
        if (isValid(row, col, chessboard, n)) { // 验证合法就可以放
            chessboard[row][col] = 'Q'; // 放置皇后
            backtracking(n, row + 1, chessboard);
            chessboard[row][col] = '.'; // 回溯,撤销皇后
        }
    }
}
bool isValid(int row, int col, vector<string>& chessboard, int n) {
    // 检查列
    for (int i = 0; i < row; i++) { // 这是一个剪枝
        if (chessboard[i][col] == 'Q') {
            return false;
        }
    }
    // 检查 45度角是否有皇后
    for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--) {
        if (chessboard[i][j] == 'Q') {
            return false;
        }
    }
    // 检查 135度角是否有皇后
    for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
        if (chessboard[i][j] == 'Q') {
            return false;
        }
    }
    return true;
}
public:
    vector<vector<string>> solveNQueens(int n) {
        result.clear();
        std::vector<std::string> chessboard(n, std::string(n, '.'));
        backtracking(n, 0, chessboard);
        return result;
    }
};

T47 全排列二

1、这一题和上一题的关键区别就是去重,去重不是在最后强行遍历,而是在回溯的过程中利用如下代码: if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) { continue; }

2、完整回溯代码:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& nums, vector<bool>& used) {
        // 此时说明找到了一组
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }
        for (int i = 0; i < nums.size(); i++) {
            // used[i - 1] == true,说明同一树枝nums[i - 1]使用过
            // used[i - 1] == false,说明同一树层nums[i - 1]使用过
            // 如果同一树层nums[i - 1]使用过则直接跳过
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
                continue;
            }
            if (used[i] == false) {
                used[i] = true;
                path.push_back(nums[i]);
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
            }
        }
    }
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end()); // 排序
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};

// 时间复杂度: 最差情况所有元素都是唯一的。复杂度和全排列1都是 O(n! * n) 对于 n 个元素一共有 n! 中排列方案。而对于每一个答案,我们需要 O(n) 去复制最终放到 result 数组 // 空间复杂度: O(n) 回溯树的深度取决于我们有多少个元素

5/18

T46 全排列

1、这道题用分治递归会很慢,回溯法还是更合适

2、从刚才一题加上这一题,可以看出回溯、排列组合中哈希数字used非常常见

3、最优解如下:

class Solution {
public:

    vector<vector<int>> result;

    // 当前排列
    vector<int> path;

    // nums[i] 是否已经使用
    vector<bool> used;

    void backtracking(vector<int>& nums) {

        // 一个排列完成
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }

        for (int i = 0; i < nums.size(); i++) {

            // 已经放进排列里
            if (used[i]) continue;

            // 选择
            used[i] = true;
            path.push_back(nums[i]);

            // 递归填下一个位置
            backtracking(nums);

            // 回溯
            path.pop_back();
            used[i] = false;
        }
    }

    vector<vector<int>> permute(vector<int>& nums) {

        used.resize(nums.size(), false);

        backtracking(nums);

        return result;
    }
};

T491 非递减子序列

1、这一题我开始时思路如下: //先找出所有子序列,再看看是不是递增的(只要出现不递增就直接灭掉) //注意剪枝,子序列开头出现不递增就不必更长了,更长的包含不递增的全部剪去

2、但是,这种解法只打败了11%的leetcode题解耗时,说明还存在大量不必要的内容。最优解:回溯中保证递增+记录用过的数字

class Solution {
public:

    vector<vector<int>> result;
    vector<int> path;

    void backtracking(vector<int>& nums, int startIndex) {

        // 至少两个元素即可加入
        if (path.size() >= 2) {
            result.push_back(path);
        }

        // 本层去重
        bool used[201] = {false};

        for (int i = startIndex; i < nums.size(); i++) {

            // 不递增,剪掉
            if (!path.empty() && nums[i] < path.back()) {
                continue;
            }

            // 本层已经出现过
            if (used[nums[i] + 100]) {
                continue;
            }

            used[nums[i] + 100] = true;

            path.push_back(nums[i]);

            backtracking(nums, i + 1);

            path.pop_back();
        }
    }

    vector<vector<int>> findSubsequences(vector<int>& nums) {

        backtracking(nums, 0);

        return result;
    }
};