LeetCodeHOT100热题02

写在前面

  • 主要是题目太多,所以和前面的分开来记录。
  • 有很多思路的图都来源于力扣的题解,如侵权会及时删除。
  • 不过代码都是个人实现的,所以有一些值得记录的理解。
  • 之前的算法系列参看:

七、动态规划

1. 最长回文子串

题目描述

  • 思路
    思路

  • 由于可以用dp[i+1][j-1]推出到dp[i][j],故只能从左下到右上遍历;

  • 由于i<=j,因此dp矩阵为上三角矩阵;

  • 代码

class Solution {
public:
    /*
    dp[i][j]的含义为:子串[i:j]是回文子串
    dp[i][j] = true, if s[i]==s[j] && dp[i+1][j-1]==true && i+1<=j-1
    		 = true, if s[i]==s[j] && i+1==j
             = false, else
    dp[i][i] = 1
    00 01 02 03 ... 0n
       11 12 13 ... 1n
          22 23 ... 2n
          ...
                    nn
    从dp[i+1][j-1]到dp[i][j]是向右上方走,故dp的顺序是从下往上,从左边到右边
    */
    string longestPalindrome(string s) {
        int n = s.length() - 1;
        vector<vector<bool>> dp(n + 1, vector<bool>(n + 1, false));
        int re_i, re_j;  // 记录最长回文子串左右两个指针
        int re_len = 0;  // 记录最长回文子串的长度
        for(int i=n;i>=0;--i) {
            for(int j=i;j<=n;++j) {
                if(i == j) {
                    // 一个字符的子串
                    dp[i][j] = true;                    
                }
                else {
                    if(i == j - 1) {
                        // 两个字符的子串                        
                        if(s[i] == s[j]) {
                            dp[i][j] = true;
                        }
                        else {
                            dp[i][j] = false;
                        }
                    }
                    else {
                        // 大于两个字符的子串
                        if(dp[i+1][j-1] && s[i]==s[j]) {
                            dp[i][j] = true;
                        }
                        else {
                            dp[i][j] = false;
                        }
                    }
                }
                if(dp[i][j]) {
                    // [i:j]是回文子串,则尝试更新最长回文子串
                    if(j - i + 1 > re_len) {
                        re_i = i;
                        re_j = j;
                        re_len = j - i + 1;
                    }
                }
            }
        }
        return s.substr(re_i, re_len);
    }
};
补充:关于bool类型的%d输出值
  • 如上代码,dp矩阵为bool类型;
  • 输出时,若是如下printf,则均输出一个非0整数,无论是为true还是为false:
printf("%d", dp[i][j]);  // 是一个非0整数
printf("%d", int(dp[i][j]));  // 是一个非0整数
  • 如要输出0/1,则应该如下printf:
printf("%d", dp[i][j] == true);  // 若dp[i][j] = true为1
  • 只有以下printf为1,也就是说,1对应true,0对应false:
printf("%d", 1 == true); // 是1
printf("%d", 0 == false);  // 是1
  • true是1,false是0:
printf("%d", true); // 是1
printf("%d", false);  // 是0
  • 综上,为了稳妥起见,无论是在c还是c++中都还是直接使用int类型代替bool类型比较好;
  • 如果真的要使用bool类型,则输出时需要和true和false类型比较,而不要直接输出
变体1. 回文子串

题目描述

  • 思路
  • 动态规划的思路和最长回文子串的思路相同,只不过统计量不同,这里是统计dp[i][j]==1的个数而不是统计j-i+1的最大值;
  • 一些动态规划的推导在下面代码的注释部分;
  • 另外还有一种Manacher算法,时间复杂度降至O(N),空间复杂度降至O(1),但指针的计算和移动很麻烦,我没有复现成功>﹏<,原理参看博客:Manacher算法,说得很明白了;
  • 代码
class Solution {
public:
    /*
    动态规划:
    dp[i][j]:s[i:j]是否是回文子串
    dp[i][j] = dp[i+1][j-1] && s[i]==s[j] if i+1<=j-1
             = s[i]==s[j]                 otherwise
    dp[i][i] = 1;
    */
    int countSubstrings(string s) {
        int n = s.length();
        vector<vector<int>> dp(n, vector<int>(n, 0));

        int re_sum = 0;
        for(int i=n-1;i>=0;--i) {
            for(int j=i;j<n;++j) {
                if(i == j) {
                    dp[i][j] = 1;
                    ++re_sum;
                }
                else {
                    if(i+1 <= j-1) {
                        if(dp[i+1][j-1] && s[i] == s[j]) {
                            dp[i][j] = 1;
                            ++re_sum;
                        }
                    }
                    else {
                        if(s[i] == s[j]) {
                            dp[i][j] = 1;
                            ++re_sum;
                        }
                    }
                }
            }
        }
        return re_sum;
    }
};
2. 最长有效括号

题目描述

  • 思路

  • (1) 动态规划思路如下:
    思路

  • 针对以第i个字符结尾的最长子串长度进行动态规划;

  • 如果最后两个字符是(),则在dp[i-2]的基础上直接加上2,也就是()的长度即可;

  • 如果最后两个字符是)),也就是需要看是否有和最后一个)对应的(,也就是第i-1 -dp[i-1]个字符是否为(

    • 如果是的话,则长度是完整的))所对应子串长度dp[i-1] + 2,再加上完整的))之前的最长子串长度dp[i-1 -dp[i-1] -1],相当于是拼接了两个子串;
    • 如果不是的话,则这个子串不合规,dp[i]=0
  • 当然还要注意往前查找的时候,计算出的数组下标是否越界(小于0);

  • 另外需要额外记录最大值,因为dp数组的含义不是问题所求的解;

  • 空间复杂度是O(N),时间复杂度是O(N);

  • (2) 也可以使用左右括号的计数器来处理,是类似于双指针的思路:

思路

  • 当左右括号的计数相同时,子串有效;

  • 当右括号数量大于左括号数量时,子串失效,所有的计数归零,然后左指针要指向右指针同步;

  • 然而这样遍历一次是不能统计出((()里面的()的,因此还要倒序再遍历一次;

  • 仍然是左右括号计数相同时,子串有效;

  • 当左括号数量大于右括号数量时,子串失效,所有的计数归零,然后右指针要指向左指针同步;

  • 这样遍历一次不能统计出()))中的(),但这种情况已经在上面的从左到右遍历中统计过了;

  • 注意滞后的指针如果是初始时指向后一位可以方便一点,虽然这一位可能已经超出字符串范围了;

  • 虽然两次遍历有统计上的重复,但由于是找最大值,所以无妨;

  • 空间复杂度是O(1),时间复杂度是O(2N);

  • 推荐还是用动态规划来写,比较优雅一点( ̄︶ ̄)↗;

  • 代码

  • (1) 动态规划:

class Solution {
public:
    /*
    dp[i]:以第i个字符结尾的最长子串长度
    // ()()(
    (1) s[i] = '(', 则dp[i] = 0
    // ()()
    (2) s[i] = ')' && s[i-1] = '(', dp[i] = dp[i-2] + 2
    // )((()()) 7 - 1 - 4 - 1
        s[i] = ')' && s[i-1] = ')' && dp[i-1 -dp[i-1]] = '(', dp[i] = dp[i-1 -dp[i-1] -1] + dp[i-1] + 2
    */
    int longestValidParentheses(string s) {
        vector<int> dp(s.length(), 0);
        int re_max = 0;
        for(int i=1;i<s.length();++i) {
            if(s[i] == '(') {
                dp[i] = 0;
            }
            else {
                if(s[i-1] == '(') {
                    if(i-2 >= 0) {
                        dp[i] = dp[i-2] + 2;
                    }
                    else {
                        dp[i] = 2;
                    }                    
                }
                else {
                	// 定位当前)对应的(的位置并检验是否为(
                    int leftParIndex = i - 1 - dp[i-1];
                    if(leftParIndex>=0 && s[leftParIndex] == '(') {
                        if(leftParIndex-1 >= 0) {
                            // 左括号左边还有字符串,
                            // dp[leftParIndex-1]: 0 -> )(
                            // dp[i-1]: 4 -> ()()
                            dp[i] = dp[leftParIndex-1] + dp[i-1] + 2;
                        }
                        else {
                            // 左括号左边没有字符串
                            dp[i] = dp[i-1] + 2;
                        }
                    }
                    else {
                        dp[i] = 0;
                    }
                }
            }
            // 记录最大值
            if(re_max < dp[i]) {
                re_max = dp[i];
            }
        }
        return re_max;
    }
};
  • (2) 类双指针:
class Solution {
public:
    int longestValidParentheses(string s) {
        int re_max = 0;
        // 从左往右遍历
        int leftParNum = 0, rightParNum = 0;
        int i = s.length() - 1, j = s.length();
        while(i >= 0) {
            if(s[i] == '(') {
                ++leftParNum;
            }
            if(s[i] == ')') {
                ++rightParNum;
            }
            if(leftParNum == rightParNum) {
                int subLen = j - i;
                if(re_max < subLen) {
                    re_max = subLen;
                }
            }
            if(leftParNum > rightParNum) {
                // 则右边不可能配对了
                j = i;
                leftParNum = 0;
                rightParNum = 0;
            }
            --i;
        }
        // 从右往左遍历
        leftParNum = 0, rightParNum = 0;
        i = 0, j = -1;
        while(i < s.length()) {
            if(s[i] == '(') {
                ++leftParNum;
            }
            if(s[i] == ')') {
                ++rightParNum;
            }
            if(leftParNum == rightParNum) {
                int subLen = i - j;
                if(re_max < subLen) {
                    re_max = subLen;
                }
            }
            if(leftParNum < rightParNum) {
                // 则左边不可能配对了
                j = i;
                leftParNum = 0;
                rightParNum = 0;
            }
            ++i;
        }
        return re_max;
    }
};
[3]. 最大子数组和

题目描述

  • 思路
  • 比较容易是用动态规划来做;
  • 剑指offer算法题02七、3. 连续子数组的最大和同题;

思路

  • 代码
class Solution {
public:
    /*
    dp[i]: 以第i个元素结尾的最大子数组和
    dp[i] = max{dp[i-1]+nums[i], nums[i]}
    另外要用一个变量存储最大值
    */
    int maxSubArray(vector<int>& nums) {
        vector<int> dp(nums.size(), 0);
        dp[0] = nums[0];
        int re_max = dp[0];
        for(int i=1;i<nums.size();++i) {
            if(dp[i-1] <= 0) {
                dp[i] = nums[i];
            }
            else {
                dp[i] = dp[i-1] + nums[i];
            }
            if(dp[i] > re_max) {
                re_max = dp[i];
            }
        }
        return re_max;
    }
};
4. 不同路径 [向右向下棋盘]

题目描述

  • 思路

思路1

  • 如果使用深度遍历搜索,时间复杂度是 O ( 2 M N ) O(2^{MN}) O(2MN),远超动态规划的 O ( M N ) O(MN) O(MN)

  • 剑指offer算法题02中的七、6. 礼物的最大价值十分类似,但比它简单一点;

  • 也可以用组合数学一步直接计算到位,如下:
    思路2

  • 代码

  • 动态规划代码如下:

class Solution {
public:
    // dp[i][j]: 走到[i][j]有多少种可能
    // dp[i][j] = dp[i-1][j] + dp[i][j-1]
    // dp[0][j] = 1;  第一行为1
    // dp[i][0] = 1;  第一列为1
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m, vector<int>(n, 1));
        for(int i=1;i<m;++i) {
            for(int j=1;j<n;++j) {
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
        return dp[m-1][n-1];
    }
};
[5]. 最小路径和 [向右向下棋盘]

题目描述

  • 思路
    思路
  • 剑指offer算法题02中的七、6. 礼物的最大价值几乎同题;
  • 和上题的思路一脉相承;
  • 代码
class Solution {
public:
    // dp[i][j]: 到达[i][j]时的最小路径和
    // dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        vector<vector<int>> dp(m, vector<int>(n, 0));
        for(int i=0;i<m;++i) {
            for(int j=0;j<n;++j) {
                if(i==0 && j==0) {
                    // 原点
                    dp[i][j] = grid[i][j];
                }
                if(i==0 && j!=0) {
                    // 第一行
                    dp[i][j] = dp[i][j-1] + grid[i][j];
                }
                if(i!=0 && j==0) {
                    // 第一列
                    dp[i][j] = dp[i-1][j] + grid[i][j];
                }
                if(i!=0 && j!=0) {
                    dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
                }
            }
        }
        return dp[m-1][n-1];
    }
};
[6]. 爬楼梯

题目描述

class Solution {
public:
    // dp[i]: 到第i阶楼梯有多少种方式
    // dp[i] = dp[i-1] + dp[i-2]
    // dp[0] = 1; dp[1] = 1;
    int climbStairs(int n) {
        vector<int> dp(n+1, 0);
        dp[0] = 1;
        dp[1] = 1;
        for(int i=2;i<=n;++i) {
            dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n];
    }
};
7. 编辑距离

题目描述

  • 思路

思路

  • dp递推公式如下:

d p [ i ] [ j ] = { m i n ( d p [ i − 1 ] [ j ] + 1 ,   d p [ i ] [ j − 1 ] + 1 ,   d p [ i − 1 ] [ j − 1 ] ) , 若 w o r d 1 [ i ] = = w o r d 2 [ j ] m i n ( d p [ i − 1 ] [ j ] + 1 ,   d p [ i ] [ j − 1 ] + 1 ,   d p [ i − 1 ] [ j − 1 ] + 1 ) , 若 w o r d 1 [ i ] ! = w o r d 2 [ j ] dp[i][j]=\left\{ \begin{aligned} min(dp[i-1][j] + 1, \ dp[i][j-1] + 1, \ dp[i-1][j-1]), & 若 word1[i] == word2[j] \\ min(dp[i-1][j] + 1, \ dp[i][j-1] + 1, \ dp[i-1][j-1]+1) , &若word1[i] != word2[j] \end{aligned} \right. dp[i][j]={min(dp[i1][j]+1, dp[i][j1]+1, dp[i1][j1]),min(dp[i1][j]+1, dp[i][j1]+1, dp[i1][j1]+1),word1[i]==word2[j]word1[i]!=word2[j]

  • dp[i][j]的含义:

    • 长度是iword1和长度是jword2之间的编辑距离;
  • 如果word1[i] == word2[j]

    • dp[i-1][j] + 1:在长度是i-1word1上再增加一个字符;
    • dp[i][j-1] + 1:在长度是j-1word2上再增加一个字符;
    • 因为dp[i-1][j]dp[i][j-1]dp[i][j]都只相差了一个字符,所以使两个字符串相同是一定要再增加一个字符的;
    • dp[i-1][j-1]:直接用dp[i-1][j-1]的编辑距离;
    • 因为两个字符串最后一位字符相等,所以最后一位都增加不需要增加编辑距离;
  • 如果word1[i] != word2[j]

    • 基本同上;
    • dp[i-1][j-1]+1:两个字符串都再增加一个字符,这样编辑距离也是+1
    • 因为两个字符串最后一位字符不相等,所以最后一位都增加后是一定要进行修改的;
  • 代码

class Solution {
public:
    // dp[i][j]: 长度i的word1和长度j的word2之间的编辑距离
    // dp[i][j] = min{dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1]} 若word1[i] == word2[j]
    //          = min{dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + 1} 若word1[i] != word2[j]
    // dp[0][0] = 0, dp[0][j] = j, dp[i][0] = i
    int minDistance(string word1, string word2) {
        int m = word1.length();
        int n = word2.length();
        vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
        for(int i=0;i<=m;++i) {
            dp[i][0] = i;
        }
        for(int j=0;j<=n;j++) {
            dp[0][j] = j;
        }
        for(int i=1;i<=m;++i) {
            for(int j=1;j<=n;++j) {
                if(word1[i-1] == word2[j-1]) {
                    dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1);
                    dp[i][j] = min(dp[i][j], dp[i-1][j-1]);
                }
                else {
                    dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1);
                    dp[i][j] = min(dp[i][j], dp[i-1][j-1]+1);
                }
            }
        }
        return dp[m][n];
    }
};
8. 不同的二叉搜索树

题目描述

  • 思路

思路

  • 相当于是问二叉搜索树(或者说是二叉树)的形状有多少种;

  • 和普通的二叉树相比,限定为二叉搜索树是为了避免考虑固定形状后节点值的排列

  • 普通二叉树的数量还要在二叉树的基础上增加节点值的遍历,每种形状共有n!种值的排列;

  • 代码

class Solution {
public:
    /*
    动态规划:
    dp[i]: i个节点能够组成的二叉搜索树
    dp[i]: sum(k->[0, i-1], dp[k] * dp[i-k-1])
    dp[k] => 左子树节点
    dp[i-k-1] => 右子树节点
    即dp[i] = 左右子树节点的组合数的乘积之和
    k + i-k-1 = i-1 => 除去根节点的其余节点
    dp[0] = 1;
    dp[1] = 1; 
    限制为二叉搜索树的原因是:
    1. 一旦选定某个节点为根节点,则它左边的节点和右边的节点的数量和值就都可以确定
    2. 相当于是问二叉树的形状有多少种
    3. 普通二叉树的数量还要在二叉树的基础上增加节点的遍历,每种形状共有n!种排列
    */
    int numTrees(int n) {
        vector<int> dp(n+1, 0);
        dp[0] = 1;
        dp[1] = 1;
        for(int i=2;i<=n;++i) {
            for(int k=0;k<i;++k) {
                dp[i] += dp[k] * dp[i-k-1];
            }
        }
        return dp[n];
    }
};
[9]. 买卖股票的最佳时机

题目描述

  • 思路
  • 记录之前的最小值,然后用当前值减去最小值即可;
  • 虽然说是动态规划,但其实有点贪心的感觉;
  • 剑指offer算法题02中的七、10. 股票的最大利润同题;
  • 代码
class Solution {
public:
    /*
    dp[i] = max(prices[i]-min, max)
    */
    int maxProfit(vector<int>& prices) {
        int min_val = prices[0];
        int max_re = 0;
        int i;
        for(i=1;i<prices.size();++i) {            
            if(prices[i] < min_val) {
                // 更新最低价格
                min_val = prices[i];
            }
            if(prices[i] - min_val > max_re) {
                // 更新最大收益
                max_re = prices[i] - min_val;
            }
        }
        return max_re;
    }
};
10. 单词拆分

题目描述

  • 思路
  • 状态转移方程如下:
    d p [ i ] = d p [ i − w o r d [ j ] . l e n ]   & &   d p [ i − w o r d [ j ] . l e n : i ] = = w o r d [ j ] dp[i] = dp[i-word[j].len] \ \&\& \ dp[i-word[j].len:i]==word[j] dp[i]=dp[iword[j].len] && dp[iword[j].len:i]==word[j]
  • dp[i]意为s的前i个字符是否能用字典拼出;
  • 代码
class Solution {
/*
    动态规划:
    1. dp[i] = dp[i-word[j].len] && dp[i-word[j].len:i]==word[j]
    2. dp[i]意为s的前i个字符是否能用字典拼出
*/
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        vector<bool> dp(s.length() + 1, false);
        dp[0] = true;
        for(int i=1;i<=s.length();++i) {
            for(int j=0;j<wordDict.size();++j) {
                int len = wordDict[j].length();
                if(i-len>=0 && dp[i-len] && wordDict[j]==s.substr(i-len, len)) {
                    dp[i] = true;
                    break;
                }
            }
        }
        return dp[s.length()];
    }
};
补充:两种关于C++string类型的值比较方式
  1. 使用str1.compare(str2) == 0,如果返回值小于0,则有字典序上的str1 < str2
  2. 使用str1 == str2,如果有str1 < str2,则有字典序上的str1 < str2
11. 乘积最大子数组

题目描述

  • 思路
    思路
  • 如果是像剑指offer算法题023. 连续子数组的最大和那样求和的话,则只需记录以前一个nums[i-1]为结尾的字串的最大和即可推出以nums[i]结尾的最大和;
  • 但由于乘积有它的特殊性:
    • nums[i]结尾的最大乘积可能是由两个正数相乘得到的;
    • 也有可能是由两个负数相乘得到的;
    • 这取决于当前的nums[i]是正数还是负数;
  • 因此,需要同时记录nums[i-1]最大值最小值,分别对应上面的两种情况,这样无论当前的nums[i]是正数还是负数,都能求得乘积最大值;
  • 事实上,只要不和0相乘,乘积的绝对值肯定是越来越大的;
  • 另外,也可以不用dp数组,而用两个(甚至是一个)临时变量存储nums[i-1],这样没有这么直观,但可以进一步优化空间复杂度为O(1),但要注意两者的相互调用关系;
  • 代码
class Solution {
public:
    /*
    dp[i]:以nums[i]结尾的最大非空连续子数组乘积
    dp_min[i] = min{dp_min[i-1]*nums[i], dp_max[i-1]*nums[i], nums[i]}
    dp_max[i] = max{dp_min[i-1]*nums[i], dp_max[i-1]*nums[i], nums[i]}
    */
    int maxProduct(vector<int>& nums) {
        vector<int> dp_max(nums.size(), 0);
        vector<int> dp_min(nums.size(), 0);
        dp_max[0] = nums[0];
        dp_min[0] = nums[0];

        int re_max = dp_max[0];
        for(int i=1;i<nums.size();++i) {
            dp_max[i] = max(dp_max[i-1]*nums[i], nums[i]);
            dp_max[i] = max(dp_max[i], dp_min[i-1]*nums[i]);

            dp_min[i] = min(dp_max[i-1]*nums[i], nums[i]);
            dp_min[i] = min(dp_min[i], dp_min[i-1]*nums[i]);

            if(dp_max[i] > re_max) {
                // 记录最大值
                re_max = dp_max[i];
            }
        }
        return re_max;
    }
};
12. 打家劫舍

题目描述

  • 思路一
  • dp[i]意为走到第i房屋时偷窃第i房屋可得的最高金额;
  • 则递推方程为:
    d p [ i ] = m a x ( d p [ i − 2 ] + n u m s [ i ] , d p [ i − 3 ] + n u m s [ i ] ) dp[i] = max(dp[i-2]+nums[i], dp[i-3]+nums[i]) dp[i]=max(dp[i2]+nums[i],dp[i3]+nums[i])
  • 注意第i个房屋是一定要偷的,则i-1房屋不能偷;
  • 如果i-2房屋偷了,则,i-4后面的如果可以获得更高的金额的话也可以偷,这是因为i-4i-2不冲突,已经包括在i-2的子问题内了;
  • 同理,i-3房屋如果偷了,则i-5后面的也已经考虑在内了;
  • 因此i-2i-3选一间房屋来偷即可;
  • 有点点复杂,dp[i]的意义不是很直观,推理也不一定对,但这个是我自己想出来的递推关系( •̀ .̫ •́ )✧;
  • 代码一
class Solution {
public:
    /*
    dp[i]:走到i房屋时偷窃i房屋可得的最高金额
    dp[i] = max(dp[i-2]+nums[i], dp[i-3]+nums[i])
    */
    int rob(vector<int>& nums) {
        vector<int> dp(nums.size(), 0); 
        int re_max = 0;   

        for(int i=0;i<nums.size();++i) {
            if(i <= 1) {
                dp[i] = nums[i];
            }
            else {
                dp[i] = max(dp[i], dp[i-2]+nums[i]);
                if(i >= 3) {
                    dp[i] = max(dp[i], dp[i-3]+nums[i]);
                }
            }                        
            re_max = max(re_max, dp[i]);
        }
        return re_max;
    }
};
  • 思路二
    思路二

  • 是另一种思路,dp[i]表示经过第i房屋时可得的最高金额,第i房屋可以不偷;

  • 则要么偷第i房屋,金额是第i-2房屋时的可得最大加上nums[i]

  • 要么不偷,金额用第i-1房屋时的可得最大;

  • 代码二

class Solution {
public:
    /*
    dp[i]:走到i房屋时可得的最高金额
    dp[i] = max(dp[i-2]+nums[i], dp[i-1])
    */
    int rob(vector<int>& nums) {        
        if(nums.size() == 1) {
            return nums[0];
        }
        if(nums.size() == 2) {
            return max(nums[0], nums[1]);
        }
        vector<int> dp(nums.size(), 0); 
        dp[0] = nums[0];
        dp[1] = max(dp[0], nums[1]);

        for(int i=2;i<nums.size();++i) {
            dp[i] = max(dp[i-1], dp[i-2]+nums[i]);    
        }
        return dp[nums.size()-1];
    }
};
变体1. 打家劫舍 III

题目描述

  • 思路
  • 虽然是变体,但其实处理的思路已经大不相同了,虽然仍然可以看出点动态规划的端倪,但总体的思路变成了树的后序遍历
  • 每个节点都考虑在打劫和不打劫之后,其所在的子树能获得的最大金额;

思路

  • 代码
class Solution {
private:
    struct returnType {
        int rob;  // 打劫该节点时子树可获得的最大值
        int unrob;  // 不打劫该节点时子树可获得的最大值
    };

    returnType dfs(TreeNode *root) {
        if(root == nullptr) {
            return {0, 0};
        }
        returnType left = dfs(root->left);
        returnType right = dfs(root->right);
        // 打劫root,则子节点都不能打劫
        int root_rob = root->val + left.unrob + right.unrob;
        // 不打劫root,则子节点可以打劫也可以不打劫
        int root_unrob = max(left.rob, left.unrob) + max(right.rob, right.unrob);
        return {root_rob, root_unrob};
    }
public:
    int rob(TreeNode* root) {
        returnType re = dfs(root);
        return max(re.rob, re.unrob);
    }
};
13. 最大正方形

题目描述

  • 思路

  • 转移方程如下:
    转移方程

  • 一个例子如下:
    思路

  • 对于状态转移方程的说明:
    转移方程说明

  • 另外,第一列和第一行要初始化,遇到'1'dp[i][j] = 1,并不需要考虑左侧、上侧和左上角的元素值;

  • 代码

class Solution {
public:
    /*
    dp[i][j]:以matrix[i][j]结尾的正方形的最大边长
    dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1 (if matrix[i][j]==1)
               0                                             (otherwise)
    */
    int maximalSquare(vector<vector<char>>& matrix) {
        vector<vector<int>> dp(matrix.size(), vector<int>(matrix[0].size(), 0));
        int re_max = 0;

        // 初始化第一列和第一行
        for(int i=0;i<matrix.size();++i) {
            if(matrix[i][0] == '1') {
                dp[i][0] = 1;
            }
            if(dp[i][0] > re_max) {
                re_max = dp[i][0];
            }
        }
        for(int j=0;j<matrix[0].size();++j) {
            if(matrix[0][j] == '1') {
                dp[0][j] = 1;
            }
            if(dp[0][j] > re_max) {
                re_max = dp[0][j];
            }
        }
        
        // 计算剩下的dp
        for(int i=1;i<matrix.size();++i) {
            for(int j=1;j<matrix[0].size();++j) {
                if(matrix[i][j] == '1') {
                    dp[i][j] = min(dp[i-1][j-1], min(dp[i-1][j], dp[i][j-1])) + 1;
                }
                else {
                    dp[i][j] = 0;
                }
                if(dp[i][j] > re_max) {
                    re_max = dp[i][j];
                }
            }
        }
        // 返回面积 = 边长*边长
        return re_max*re_max;
    }
};
14. 完全平方数 [完全背包]

题目描述

  • 思路
  • 完全背包问题,即组成n的每个完全平方数均可以多次使用
  • 注意的点如下:
    • 恰好组成n,因此初始化的时候仅dp[0][0]或者dp[0]可以有初始值,其余为INT_MAX(因为是最小化问题);
    • 用一维形式的话,dp数组空间为n+1两重循环,内循环采用正序遍历;
    • 外循环遍历value,在这里是完全平方数的类型,遍历到sqrt(n)即可;
    • 内循环遍历weightnweight在这里是完全平方数占据的空间,即value*value
  • 其实是类似零钱问题,零钱问题也是完全背包问题,也是恰好装满n
  • 代码
class Solution {
public:
    /*
    dp[i][j]:用前i个完全平方数,和为j的最小数量
    dp[i][j] = min(dp[i-1][j], dp[i-1][j-i^2]+1);
    降为一维,dp[j] = min(dp[j], dp[j-i^2]+1);
    初始值:dp[0] = 0,其余为INT_MAX
    */
    int numSquares(int n) {
        int max_value = int(sqrt(n));
        vector<int> dp(n+1, INT_MAX);
        dp[0] = 0;
        for(int i=1;i<=max_value;++i) {
            int weight = i*i;
            for(int j=weight;j<=n;++j) {
                dp[j] = min(dp[j], dp[j-weight]+1);
            }
        }
        return dp[n];
    }
};
15. 最长递增子序列

题目描述

  • 思路
    思路
  • 动态规划的思路是比较容易想到的,但时间复杂度为O(N^2);
  • 此外还有一个比较复杂的思路,时间复杂度可以降为O(NlogN),但暂时不想研究了(累了,叉腰 );
  • 感觉动态规划的思路是比较正统的,而且是第一道自己写出来的动态规划中等题;
  • 代码
class Solution {
public:
    /*
    dp[i]:以nums[i]结尾的最长严格递增子序列长度
    dp[i] = j:0->i-1 max(dp[j]+1) && nums[j]<nums[i]
    => dp[i]初始值为1
    */
    int lengthOfLIS(vector<int>& nums) {
        vector<int> dp(nums.size(), 1);
        int re_max = 1;
        for(int i=1;i<nums.size();++i) {
            for(int j=0;j<i;++j) {
                if(nums[j] < nums[i]) {
                    dp[i] = max(dp[i], dp[j]+1);
                    // 记录全局最大值
                    re_max = max(re_max, dp[i]);
                }
            }
        }
        return re_max;
    }
};
16. 最佳买卖股票时机含冷冻期

题目描述

-思路
思路
思路

  • 需要注意的点如下:
    • dp矩阵记录的是每天结束时的状态
    • 也就是说,昨天卖出的股票此时已经过了冷冻期了;
    • 仅有当天卖出的股票才在冷静期内;
    • 如果不这样设计的话状态之间的区分会模糊不清;
  • 代码
class Solution {
public:
    /*
    动态规划,每一天结束时有三种状态:
    dp[i][0]:持有股票
    dp[i][1]:未持有股票且在冷冻期
    dp[i][2]:未持有股票且不在冷冻期
    */
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        vector<vector<int>> dp(n, vector<int>(3, 0));
        dp[0][0] = -prices[0];  // 买入当天股票
        dp[0][1] = 0;
        dp[0][2] = 0;
        for(int i=1;i<n;++i) {
            // 1. 继续持有或者当天买入
            dp[i][0] = max(dp[i-1][0], dp[i-1][2] - prices[i]);
            // 2. 今天卖出
            dp[i][1] = dp[i-1][0] + prices[i];
            // 3. 昨天卖出或者昨天就已经不在冷冻期
            dp[i][2] = max(dp[i-1][1], dp[i-1][2]);
        }
        // 最后一天的2和3的最大值一定大于1
        int re_max = max(dp[n-1][1], dp[n-1][2]);
        return re_max;
    }
};
17. 零钱兑换 [完全背包]

题目描述

  • 思路
  • 就是完全背包问题的思路,可以降为一维动态规划;
  • 注意是恰好装满问题,初始化时仅dp[0] = 0,其余为最大值;
  • 最后比较的时候,因为题目说明所有的硬币均为整数,因此最小是1,最大的解也是amount个硬币,所以用amountdp[amount]比较即可知道当前值是否从INT_MAX而来(也就是不能恰好装满),当然也可以直接用INT_MAX来比较;
  • 代码
class Solution {
public:
    /*
    dp[i][j]:用前i个硬币凑j面值的最少硬币数
    dp[0][0] = 0;    dp[0][j] = INT_MAX;
    降为一维:
    dp[0] = 0;  
    dp[j] = min(dp[j], dp[j-weight[i]]+1);
    */
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount+1, INT_MAX);
        dp[0] = 0;
        for(int i=0;i<coins.size();++i) {
            for(int j=coins[i];j<=amount;++j) {
                if(dp[j-coins[i]] != INT_MAX) {
                	// 不是从INT_MAX而来,以防溢出
                    dp[j] = min(dp[j], dp[j-coins[i]] + 1);
                }                
            }
        }
        if(dp[amount] == INT_MAX) {
            return -1;
        }
        else {
            return dp[amount];
        }
    }
};
变体1. 零钱兑换 II

题目描述

  • 思路

  • 17. 零钱兑换很像,也是恰好装满问题;

  • 但初始值不同:

    • 如果用0个硬币凑0元,是有一种组合的,也就是dp[0][0]=1
    • 对于不能凑的情况,直接记录为无组合数即可,也就是dp[0][j]=0
  • 状态转移公式也不同,为两种取法相加,而非取它们的最大值+1;

  • 注意:

    • 这里是因为硬币的取法是组合数,每次取的时候不考虑先后次序,所以才可以用动态规划来做的;
    • 如果考虑取的先后次序,如(1,1,2), (1,2,1)和(2,1,1)为三种不同的取法,则不能用这种方式(起码不能套这个模板吧>︿<);
  • 代码

class Solution {
    /*
    dp[i][j]:用前i个硬币恰好凑j面值的组合数
    dp[0][0] = 1;    dp[0][j] = 0;
    降为一维:
    dp[0] = 1;  
    dp[j] = dp[j] + dp[j-weight[i]];
    */
public:
    int change(int amount, vector<int>& coins) {
        vector<int> dp(amount+1, 0);
        dp[0] = 1;
        for(int i=0;i<coins.size();++i) {
            for(int j=coins[i];j<=amount;++j) {
                dp[j] = dp[j] + dp[j-coins[i]];
            }
        }
        return dp[amount];
    }
};
18. 比特位计数

题目描述

  • 思路
  • 其实就是找规律;
  • i个数的1的个数其实就是i-step个数的i的个数再加1;
  • step是不超过i的最大二次幂;
  • 也就是说dp[i]一定可以由前面的某个dp+1而来,因此可以用动态规划;
  • 有点点已有之事后必再有的意思(啊好文艺);
  • 代码
class Solution {
public:
    /*
    000:0
    001:1 = [000] + 1  dp[1] = dp[0] + 1 = dp[i-1] + 1
    010:1 = [000] + 1  dp[2] = dp[0] + 1 = dp[i-2] + 1 
    011:2 = [001] + 1  dp[3] = dp[1] + 1 = dp[i-2] + 1
    100:1 = [000] + 1  dp[4] = dp[0] + 1 = dp[i-4] + 1
    101:2 = [001] + 1  dp[5] = dp[1] + 1 = dp[i-4] + 1
    110:2 = [010] + 1
    111:3 = [011] + 1
    */
    vector<int> countBits(int n) {
        vector<int> dp(n+1, 0);

        int step = 1;
        for(int i=1;i<=n;++i) {
            if(i >= 2*step) {
                step *= 2;
            }
            dp[i] = dp[i-step] + 1;
        }

        return dp;
    }
};
19. 分割等和子集 [0/1背包]

题目描述

  • 思路
  • 原题等价于:从数组中取若干个数,能否恰好装满容量是数组和一半的背包;
  • 先求数组和,然后再用0/1背包的解法即可;
  • 另外注意的点如下:
    • 是恰好装满问题;
    • 仅一个元素或者数组和是奇数均不符合要求;
  • 代码
class Solution {
public:
    /*
    动态规划:0/1背包问题
    => 原问题可以转换为求是否能恰好装满数组和一半的背包
    dp[i][j]:前i个元素能否恰好装满j
        由于不需要value,即不用求最少/最多的元素数量
        所以dp数组的类型可以是bool类型
    转移方程: 
        dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i]]
    转为一维: 
        dp[j] = dp[j] || dp[j-nums[i]]
    */
    bool canPartition(vector<int>& nums) {
        if(nums.size() <= 1) {
            // 仅一个元素不符合要求
            return false;
        }

        // 求数组和
        int num_sum = 0;
        for(int i=0;i<nums.size();++i) {
            num_sum += nums[i];
        }

        if(num_sum % 2 == 1) {
            // 奇数和也不符合要求
            return false;
        }

        // dp
        int target = num_sum / 2;  // 背包容量
        vector<bool> dp(target+1, false);
        dp[0] = true;        
        for(int i=0;i<nums.size();++i) {
            for(int j=target;j>=0;--j) {
                if(j-nums[i] >= 0) {
                    dp[j] = dp[j] || dp[j-nums[i]];
                }                
            }
        }
        return dp[target];
    }
};
20. 目标和 [0/1背包]

题目描述

  • 思路
  • 实际上是可以转换为0/1背包问题,然后用动态规划来做;

思路

  • 为什么不用positive作为背包的容量?
  • 因为positive的推导式是(target + sum) / 2,有可能为负数,因此不能做背包的容量,而negative可以证明它一定非负,因为target总是小于等于sum
  • 然而,如果target是非法的话,也就是说把所有数组里面的数加起来也没有办法凑出一个target,则negative就有可能是负数,因此需要提前判断排除这种情况;
  • 什么情况下才能转为一个0/1背包问题?
  • 其实这道题和19. 分割等和子集很类似,也是将问题一分为二,然后只考虑一边的情况是否满足,如果满足则另一边的情况也可以得到满足;
  • 两者都是凑一个可以从条件中推导出来的确定的数,这个数要满足非负的要求,而且都是和数组和有关的;
  • 只能说还是很巧妙的;
  • 代码
class Solution {
public:
    /*
    假设所有数之和是sum,添+的数之和是positive,添-的数之和是negative,则有
    sum = positive + negative
    => target = positive - negative = sum - 2*negative = 2*positive - sum
    由于target和sum已知,因此positive和negative均可以算出来
    => negative = (sum - target) / 2
    => positive = (target + sum) / 2
    => 也就是原问题等价于能不能用nums中的元素恰好凑出positive或者negative
    => 但由于target <= sum且target可以为负数,因此negative一定非负,但positive有可能是负数
    => 所以只能用negative作为背包的容量
    因此转换成一个0/1背包问题
    dp[i][j]:用前i个数恰好能凑出j的组合数
    转移方程:
        dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i]]
    初始化:
        dp[0][0] = 1
    */
    int findTargetSumWays(vector<int>& nums, int target) {
        int num_sum = 0;
        for(int i=0;i<nums.size();++i) {
            num_sum += nums[i];
        }

        if(num_sum < target) {
            // sum一定会大于等于target,否则非法
            return 0;
        }

        if((num_sum - target) % 2 == 1) {
            // positive不是整数,则target非法
            return 0;
        }
        
        int negative = (num_sum - target) / 2;
        vector<int> dp(negative+1, 0);
        dp[0] = 1;
        for(int i=0;i<nums.size();++i) {
            for(int j=negative;j>=nums[i];--j) {
                dp[j] = dp[j] + dp[j - nums[i]];
            }
        }
        return dp[negative];
    }
};
21. 戳气球

题目描述

  • 思路
  • 很难想到的动态规划::>_<::,而且我看官方的思路都看不太懂,主要是边界和动态规划的设计很巧妙,后面是看一个大佬的题解才豁然开朗而且笑出了声好一个暴躁小哥 ,如下:

思路

思路

  • 一些难以理解的点:

  • (1) 为什么不用计算i==j(剩一个气球)和i+1==j(剩两个气球)的情况下的dp值?

  • 因为按照自定义来看,dp[i][j]是不含ij的,也就是说至少要三个气球才能计算;

  • 另外,从整个算法来看,剩一个气球和剩两个气球虽然是在子问题中出现了,但它是不符合现实的,因为剩一个气球和剩两个气球总能返回到一个更大的子问题中凑够三个气球再来戳破,而不是在剩一个气球和剩两个气球的时候就把它们戳破;

  • 当然,如果所有的气球加起来也没有三个,才需要讨论戳破一个气球和戳破两个气球的情况;

  • (2) 为什么要增加一前一后两个伪气球?

  • 一方面,是为了规避所有气球加起来也不够三个的情况的讨论,加上两个伪气球就肯定够三个了;

  • 另一方面,如果气球的数量超过两个,我们是无法知道最后剩下是哪两个气球给我们戳破,这个时候还要循环遍历所有的可能来讨论,为了避免这个麻烦,我们可以加上两个伪气球把这个讨论整合到子问题中来讨论,因为子问题也是做了k的组合遍历讨论的;

  • (3) dp数组的填入次序是如何决定?

  • 一个方便的方法是看最后返回的dp下标矩阵的什么位置,这里是返回dp[0][n+1],在矩阵的右上角,因此是从下往上遍历,从左往右遍历

  • 代码

class Solution {
public:
    /*
    动态规划:
    dp[i][j]:戳破(i:j)之间的所有气球可以获得的硬币最大数量,注意是开区间
    1. 转移方程:
    dp[i][j] = {k:i+1->j-1}max(dp[i][k] + nums[i]*nums[k]*nums[j] + dp[k][j]) if i+1<=j-1
             = nums[i]*nums[j] + max(nums[i], nums[j])                        if i+1==j
             = nums[i]                                                        if i==j
    2. 剩两个气球和剩一个气球的情况无需考虑
        因为如果nums.size()>=3,则在戳气球的实际过程中不可能会有两个和一个气球的情况
        虽然子问题里面会有,但并不处理(戳破),而是直接返回到更大的问题(>=3)中再来戳
    3. 由于最后剩下的两个气球可以是数组中的任意两个气球
        所以一定要增加一前一后两个伪气球
        增加了两个气球之后,也不用讨论剩下的气球数目,因为一定是>=3的
    */ 
    int maxCoins(vector<int>& nums) {
        int n = nums.size();

        // 增加一前一后两个伪气球,值为1
        vector<int> new_nums(n+2, 1);
        for(int i=0;i<n;++i) {
            new_nums[i+1] = nums[i];
        }

        vector<vector<int>> dp(n+2, vector<int>(n+2, 0));
        for(int i=n+2-1;i>=0;--i) {
            for(int j=i;j<n+2;++j) {
                if(i == j) {
                    // 剩一个气球,不讨论
                    //dp[i][j] = new_nums[i];
                }
                if(i+1 == j) {
                    // 剩两个气球,不讨论
                    //dp[i][j] = new_nums[i] * new_nums[j] + max(new_nums[i], new_nums[j]);
                }
                if(i+1 <= j-1) {
                	// 有三个气球
                    for(int k=i+1;k<=j-1;++k) {
                        dp[i][j] = max(dp[i][j], dp[i][k] + new_nums[i]*new_nums[k]*new_nums[j] + dp[k][j]);
                    }
                }
            }
        }
        return dp[0][n+1];
    }
};

八、二分法

1. 寻找两个正序数组的中位数

题目描述

  • 思路
  • 很巧妙的二分法(好难啊〒▽〒);
  • 大概是需要利用有序这个条件,寻找第k个数
    • ij两个指针标定当前两个数组已经排除掉的数;
    • 分别比较第i+k/2j+k/2的两个数,并舍弃掉较小的一方的k/2个数;
    • 这是因为这些数一定是在中位数的左边,k/2就是为了将k个数分到两个数组中;
    • 然后从k中删去k/2,继续比较;
  • 需要注意比较时出现的三种情况
    1. 两个数组第i+k/2j+k/2的两个数均没有越界,则按照上面处理即可;
    2. 两个数组有一个越界,则不能舍弃k/2个数,而是尝试舍弃min(a.size()-i,b.size()-j)个数;
    3. 有一个数组指针已走至尽头,则直接在另一个数组舍弃掉k个数(而不用再二分取k/2)即可;
  • 最后移动的指针移动到的数就是第k个数(有可能是i指针也有可能是j指针,因此需要用一个变量记录每次移动);
  • 为什么是找第k个数而不是直接找中位数?
  • 因为中位数有可能是第k个数(奇数),也有可能是第k个数和第k+1个数的平均值(偶数);
  • 一次二分遍历是不能同时找到第k个数和第k+1个数这两个相邻的数的,只能找第k个数;
  • 因为在二分的条件下,不具备判断数相邻的条件;
  • 因此只能封装一个找第k个数的函数,作为求中位数的辅助;
  • 一些图文解释如下:

思路
思路

  • 代码
class Solution {
private: 
    /*
    findKthSortedArrays:返回两个数组中第rest个数的数值
    */
    int findKthSortedArrays(vector<int>& nums1, vector<int>& nums2, int rest) {
        int i = -1, j = -1;
        double re;
        int move;
        while(rest > 0) {
            if(rest == 1) {
                move = 1;
            }
            else {
                move = rest / 2;  // 取一半步长则较小的一方必定都是在中位数左边的
            }
            if(i+move < nums1.size() && j+move <nums2.size()) {
                // 按照rest的一半前进
                if(nums1[i+move] > nums2[j+move]) {
                    // nums2可以前进                    
                    j += move;
                    re = nums2[j];
                }
                else {
                    // nums1可以前进
                    i += move;
                    re = nums1[i];
                }
                rest -= move;
            }
            else {
                move = min(nums1.size()-i-1, nums2.size()-j-1);
                if(move == 0) {
                    // 直接取另一个数组的第rest个数
                    if(i == nums1.size() - 1) {
                        // nums2可以前进    
                        j += rest;
                        re = nums2[j];
                    }
                    else {
                        // nums1可以前进
                        i += rest;
                        re = nums1[i];
                    }
                    rest = 0;
                }
                else {
                    // 按照最短的move前进
                    if(nums1[i+move] > nums2[j+move]) {
                        // nums2可以前进                    
                        j += move;
                        re = nums2[j];
                    }
                    else {
                        // nums1可以前进
                        i += move;
                        re = nums1[i];
                    }     
                    rest -= move;    
                }   
            }
        }
        return re;
    }
public:
    double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
        int len = nums1.size() + nums2.size();
        int rest = len / 2;
        if(len % 2 == 0) {
            // 中位数有两个,取平均
            return 1.0 * (findKthSortedArrays(nums1, nums2, rest) + findKthSortedArrays(nums1, nums2, rest+1)) / 2;
        }
        else {
            // 中位数有一个,直接返回
            return 1.0 * findKthSortedArrays(nums1, nums2, rest+1);
        }
    }
};
2. 搜索旋转排序数组

题目描述

  • 思路
  • 其实利用的是有序的一侧进行target位置的判断,所以可以和有序数组一样用二分法处理;
  • 核心点如下:
    • 有可能一侧有序一侧无序,也有可能两侧均有序;
    • 有序的一侧一定是升序;
    • 判断是否在有序的一侧需要同时判断有序序列的头和尾,这个和普通有序数组的二分法不同,因为单纯地比较有序序列的最小值和target不能判断target一定在有序序列中,因为在无序的一侧可能会有更大值;
  • 另外,关于边界的判断其实还蛮复杂的,最好是先把所有的可能情况都列一个例子出来,再考虑要如何确定边界,保证不漏情况;

思路

  • 实现代码时,因为是用了二分法,所以要用low = mid + 1,虽然这道题写low = mid也不会死循环,但为了保险起见还是+1的写法比较好;

  • 代码

class Solution {
public:
    /*
    主要是利用了有序的一侧来进行判断,所以可以和有序数组一样用二分法处理
    1. 有可能一侧有序一侧无序,也有可能两侧均有序
    2. 有序的一侧一定是升序
    mid的可能情况如下:
    (1) 4,5,6,7(mid),0,1,2
    (2) 4,5,6(mid),7,0,1,2
    (3) 4,5,6,7,0(mid),1,2
    (4) 7(mid),0
    */
    
    int search(vector<int>& nums, int target) {
        int low = 0, high = nums.size() - 1;
        int re;
        while(low < high) {
            int mid = low + (high - low) / 2;
            if(nums[mid] >= nums[low]) {
                // 有序在左侧[low, mid]
                if(nums[mid] >= target && nums[low]<=target) {
                    // [low, target, mid]
                    high = mid;
                }
                else {
                    low = mid + 1;
                }
            }
            else {
                // 有序在右侧[mid, high]
                if(nums[mid] < target && nums[high] >= target) {
                    // (mid, target, high]
                    low = mid + 1;
                }
                else {
                    high = mid;
                }
            }
        }
        if(nums[low] == target) {
            return low;
        }
        else {
            return -1;
        }
    }
};
[3]. 在排序数组中查找元素的第一个和最后一个位置

题目描述

  • 思路
  • 两次二分法查找:
    • 找第一个等于target的下标;
    • 找第一个大于target的下标;
  • 注意边界条件:
    • 可能不存在等于target的下标;
    • 可能不存在大于target的下标;
  • 剑指offer算法题02中的3. 在排序数组中查找数字 I几乎同题,核心思路是一样的;
  • 另外,在使用二分法时注意移动lowhigh时均使用的是mid的值,不要错用了low或者high的值;
  • 代码
class Solution {
private:
    // 搜紧确下界,第一个为target的值
    int searchLowerBound(vector<int>& nums, int target) {
        int low = 0, high = nums.size() - 1;
        while(low < high) {
            int mid = low + (high - low) / 2;
            if(nums[mid] < target) {
                low = mid + 1;
            }
            else {
                high = mid;
            }
        }
        if(nums[low] == target) {
            return low;
        }
        else {
            // 不存在第一个为target的值
            return -1;
        }
    }
    // 搜上界,第一个大于target的值
    int searchUpperBound(vector<int>& nums, int target) {
        int low = 0, high = nums.size() - 1;
        while(low < high) {
            int mid  = low + (high - low) / 2;
            if(nums[mid] <= target) {
                low = mid + 1;
            }
            else {
                high = mid;
            }
        }
        if(nums[low] > target) {
            return low;
        }
        else {
            // 不存在第一个大于target的值
            return -1;
        }
    }
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        if(nums.empty()) {
            return {-1, -1};
        }
        int low = searchLowerBound(nums, target);
        int high = searchUpperBound(nums, target);
        if(low == -1) {
            return {-1, -1};
        }
        else {
            if(high == -1) {
                return {low, int(nums.size())-1};
            }
            else {
                return {low, high - 1};
            }
        }
    }
};
4. 寻找重复数

题目描述

  • 思路

  • 其实是挺难理解的一道题;

  • 比较核心的点如下:

    1. 下标的域是[0, n],值域是[1, n],基本上是同域的;
    2. 仅有一个数重复,而且可以重复多次;
  • 思路一:二分查找
    二分查找

  • 时间复杂度是O(NlogN),空间复杂度是O(1);

  • 比较关键的点是:

    • 统计cnt小于等于nums[i]的数量,一定要包括等于;
    • 目标是找满足cnt > nums[i]最小nums[i]
    • 二分查找需要原数组有序,这里虽然数组值无序,但数组值和数组索引基本同域且有序,所以可以用数组索引代替数组值进行二分查找;
  • 一些推导见代码部分;

  • 代码一

class Solution {
public:
    /*
    [1, n]中只有一个重复的数,共有n+1个数
    1 3 4 2 2
    nums[i]: 1 2 3 4
    cnt:     1 3 4 5
    假设当前数是nums[i],则统计小于等于nums[i]的数
    1. 若less_cnt <= nums[i],则nums[i]不是重复的数,而且小于重复的数
       ==:意味着小于nums[i]的数没有缺失
       <: 意味着小于nums[i]的数有缺失,但一定没有重复,因为只能有一个数重复
    2. 若less_cnt > nums[i],则最小的nums[i]是重复的数,其余的是大于重复的数
    因为前提是:
    1. 仅有一个数重复;
    2. 共有n个数;
    3. 数均在[1, n]中;
    于是问题转化为找第一个满足less_cnt > nums[i]的nums[i]
    本来是需要先排序在做二分查找的,但这里限制了不能修改数组,又有额外条件:
    1. 数组下标是[0, n],而且数组下标是有序的
    所以可以用数组下标的[1, n]代替nums[i]作为二分查找的左右端
    */
    int findDuplicate(vector<int>& nums) {
        int low = 1, high = nums.size()-1;
        while(low < high) {
            int mid = low + (high-low)/2;
            int less_cnt = 0;
            for(int i=0;i<nums.size();++i) {
                if(nums[i] <= mid) {
                    ++less_cnt;
                }
            }
            if(less_cnt <= mid) {
                low = mid + 1;
            }
            else {
                high = mid;
            }
        }
        return low;
    }
};
  • 思路二:双指针
  • 其实双指针的时间复杂度能到O(N),是最优解
  • 但这里还是把这道题放到二分法中,因为这个二分法是很巧妙且典型的,而且双指针的思路很难想到,难点是在如何把这道题转换为有向图求首个入环节点的问题;
    思路二
  • 比较关键的点是:
    • 0可以看作是伪头节点;
    • 推导是先假设所有值均不重复,然后从入度和出度来确定图形状;
    • 而后再逐个增加重复值,考虑此时图形状的可能;
  • 转换之后,和十、双指针7. 变体1. 环形链表 II同题;
  • 一些推导见代码部分;
  • 代码二
class Solution {
public:
    /*
    有n+1个数,取值[1, n],下标[0, n]
    推导如下:
    1. 假设这n+1个数都不重复,取值在[1, n+1],并把每一个数看做一个节点;
    2. 则除了0和n+1外,所有的节点的入度为1(下标[0, n]),出度也为1(取值[1, n+1]且不重复);
    3. 0的出度为1,入度为0,n+1的入度为1,出度为0;
    4. 按照以上的推导可知,此时所有节点必定是以0为头节点,以n+1为尾节点的有向无环图;
    5. 将n+1节点的入度换到[1, n]的任一节点中,则必定会出现环,且该节点就是所求的重复整数;
    如果有多个重复值,则:
    6. 在上述基础上令某个节点的入度为0,重复值节点增加一个入度,相当于断开某个节点重连;
    7. 在环外断开则环内不变,环外路径变短,在环内断开则环外不变,环内路径变短;
    8. 此时必定仍有且只有一个环,只是某些节点可能无法从0开始遍历到;
    9. 但环是一定可以从0开始遍历到的;
    因此,可以转换为求有向有环图的首个入环节点问题,用快慢指针来求解,0是伪头节点;
    快慢指针求入环点的推导如下:
    1. fs = ss + n*circle = 2*ss;
    => ss = n*circle;
    2. ss = a(环外) + b(环内);
    => ss再走a就可以凑够整环回到入环处,从头走a也可以到入环处;
    */
    int findDuplicate(vector<int>& nums) {
        int fast = 0, slow = 0;
        while(fast==0 || fast!=slow) {
            slow = nums[slow];
            fast = nums[fast];
            fast = nums[fast];
        }
        int slow2 = 0;
        while(slow != slow2) {
            slow = nums[slow];
            slow2 = nums[slow2];
        }
        return slow;
    }
};

九、位运算

1. 只出现一次的数字

题目描述

  • 思路
  • 利用的是异或运算的性质,即x xor x = 0x xor 0 = x
  • 因此从头到尾异或一遍即可,相同的数字可以被异或消掉;
  • 此外还有相似类型的进阶题目,参见剑指offer算法题02中的九、2. 数组中数字出现的次数 I九、3. 数组中数字出现的次数 II
  • 代码
class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int result = 0;
        for(int i=0;i<nums.size();++i) {
            result ^= nums[i];
        }
        return result;
    }
};
2. 汉明距离

题目描述

  • 思路
  • 就是异或运算;
  • 另外注意统计异或结果中1的个数时,可以用x = x & (x-1)加快效率;
  • 代码
class Solution {
public:
    int hammingDistance(int x, int y) {
        int n = x ^ y;
        int re = 0;
        // 统计异或结果中1的个数
        while(n != 0) {
            ++re;
            n = n & (n - 1);
        }
        return re;
    }
};

十、双指针

[1]. 无重复字符的最长子串

题目描述

  • 思路
  • 使用双指针来定位子串;
  • 使用hash map来记录元素是否有重复,而且能记录最后一次出现的下标,方便指针i快速移动;
  • 剑指offer算法题02中的七、7. 最长不含重复字符的子字符串同题,但那个归类为动态规划(本质上还是双指针),可以求任意从头开始的子串中的最长不含重复字符的子字符串,但同时需要和子串相同长度的dp数组进行保存。这种做法使用的是一个变量保存最大值,空间复杂度是O(1)。而且双指针的思路比较容易想到。
  • 代码
class Solution {
public:
    /*
    双指针:
        i指向子串前一位,j指向字串后一位
        因此子串长度 = j - i - 1
    使用unordered_map的时候注意:
        若出现了map[key]的形式,则无论是读取还是写入,都将会为不存在的key创建
        之后的map.find将返回true
        所以测试输出时应当小心使用printf("%d\n", map[key]);
    */
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> map;
        int i = -1, j = 0;
        int re = 0;
        while(j < s.length()) {
            if(map.find(s[j])!=map.end() && (map[s[j]]>=i && map[s[j]]<j)) {
                // 更新长度
                re = max(re, j - i - 1);
                // 移动指针i
                i = map[s[j]];                
            }
            map[s[j]] = j;
            // 移动指针j
            ++j;
        }
        // 最后一个无重复子串也要判断
        re = max(re, j - i - 1);
        return re;
    }
};
2. 盛最多水的容器

题目描述

  • 思路
  • 比较核心的点在于每次只需移动短板即可,因为移动长板必定会使得面积变小;

思路

  • 代码
class Solution {
public:
    int maxArea(vector<int>& height) {
        int i = 0, j = height.size() - 1;
        int re_max = 0;
        while(i < j) {      
            // 用短板的高度 * 宽度     
            int area = min(height[i], height[j]) * (j - i);
            // 记录面积最大值
            if(area > re_max) {
                re_max = area;
            } 
            // 将短板往中间移动
            if(height[i] > height[j]) {
                --j;
            }
            else {
                ++i;
            }
        }
        return re_max;
    }
};
变体1. 接雨水

题目描述

  • 其实也不太确定这题是不是可以视作2. 盛最多水的容器的变体,但确实可以将两题对比着看;
  • 思路一:正向反向遍历
  • 这道题的求解关键是:只考虑i处能接的雨水,不考虑它两边的容器形状;
  • 因而,i处能接的雨水 = 它两边最大容器高度的更低一方 - height[i]
  • 为了得到i两边容器高度的最大值,可以正向遍历得左边容器最大值,反向遍历得右边容器最大值;
  • 时间复杂度是O(3N)(可以优化到O(2N)),空间复杂度是O(2N);

思路

  • 代码一
class Solution {
public:
    /*
    正向反向遍历:
    1. 对于每个height[i]而言,它能够接到的雨水(仅考虑在i上,不考虑它两边的实际形状)
        取决于它两边最大高度的更低一方 - height[i]
    2. 因此正向遍历得left_max[i],反向遍历得right_max[i]即可
    */
    int trap(vector<int>& height) {
        vector<int> left_max(height.size(), 0);
        vector<int> right_max(height.size(), 0);

        // 正向遍历
        for(int i=0;i<height.size();++i) {
            left_max[i] = height[i];
            if(i-1>=0 && left_max[i]<left_max[i-1]) {
                left_max[i] = left_max[i-1];
            }
        }
        // 逆向遍历
        for(int i=height.size()-1;i>=0;--i) {
            right_max[i] = height[i];
            if(i+1<height.size() && right_max[i]<right_max[i+1]) {
                right_max[i] = right_max[i+1];
            }
        }
        // 计算接雨水之和
        int re_sum = 0;
        for(int i=0;i<height.size();++i) {
            re_sum += min(left_max[i], right_max[i]) - height[i];
        }
        return re_sum;
    }
};
  • 思路二:双指针

  • 但其实这道题的最优解是用双指针,虽然不太直观,但是本质思路也是对思路一的改进;

  • 通过双指针,避免了对left_max[i]right_max[i]的两次遍历求解;

  • 因为计算接雨水的量时,本质上并不是需要把left_max[i]right_max[i]都求出来,而仅需要它们之间的较小值即可,这样就提供了可优化的空间(但仍然是十分巧妙的双指针,比较难想到);

  • 一些推导的过程见下面代码的注释部分;

  • 时间复杂度降至O(N),空间复杂度降至O(1);

  • 代码二

class Solution {
public:
    /*
    双指针:
    1. 对于每个height[i]而言,它能够接到的雨水(仅考虑在i上,不考虑它两边的实际形状)
        取决于min(left_max[i], right_max[i]) - height[i]
    实际上,可以用双指针巧妙地替代计算left_max[i]和right_max[i]的两次遍历
    2. 定义左指针i,右指针j,i及之前的最大值left_max,j及之后的最大值right_max
    3. 对于i而言:
        left_max[i] = left_max;
        right_max[i] >= right_max;
        故若left_max < right_max,则必有left_max[i]<right_max[i]
    4. 同理,对于j而言:
        left_max[j] >= left_max;
        right_max[j] = right_max;
        故若left_max > right_max,则必有left_max[j]>right_max[j]
    5. 如此可以计算出所有位置两边最大高度更低的一方,交替移动指针即可
    */
    int trap(vector<int>& height) {
        int i = 0, j = height.size() - 1;
        int left_max = height[i], right_max = height[j];
        int re_sum = 0;
        while(i != j) {
            if(left_max < right_max) {
            	// i处的雨水可算,移动左指针
                re_sum += (left_max - height[i]);
                ++i;
                left_max = max(left_max, height[i]);
            }
            else {
            	// j处的雨水可算,移动右指针
                re_sum += (right_max - height[j]);
                --j;
                right_max = max(right_max, height[j]);
            }
        }
        return re_sum;
    }
};
3. 三数之和

题目描述

  • 思路

  • 排序后使用双指针可以将复杂度从 O ( N 3 ) O(N^3) O(N3)降至 O ( N 2 ) O(N^2) O(N2)

  • 可以视作是两数之和的升级版,参看剑指offer算法题02中的十、4. 和为s的两个数字,但增加了重复数的判断;

  • 重复数的判断是难点,但可以通过排序和跳过连续相同的数进行排除,无需使用哈希表(哈希也很难同时判断三个数的重复性吧);
    思路

  • 代码

class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        // 排序
        sort(nums.begin(), nums.end());
        int k = 0;
        vector<vector<int>> re;
        while(k < nums.size()) {
            if(nums[k] > 0) {
                // 剪枝,提前终止
                break;
            }
            // 固定k之后使用双指针
            int i = k + 1, j = nums.size() - 1;
            while(i < j) {
                int sum = nums[k] + nums[i] + nums[j];
                if(sum == 0) {
                    vector<int> temp(3, 0);
                    temp[0] = nums[k];
                    temp[1] = nums[i];
                    temp[2] = nums[j];
                    re.push_back(temp);
                    ++i;
                    // 跳过重复值
                    while(i<j && nums[i]==nums[i-1]) {
                        ++i;
                    }
                }
                if(sum < 0) {
                    // 和不够大
                    ++i;
                    // 跳过重复值
                    while(i<j && nums[i]==nums[i-1]) {
                        ++i;
                    }
                }
                if(sum > 0) {
                    // 和太大
                    --j;
                    // 跳过重复值
                    while(i<j && nums[j]==nums[j+1]) {
                        --j;
                    }
                }
            }
            ++k;
            // 跳过重复值
            while(k<nums.size() && nums[k]==nums[k-1]) {
                ++k;
            }
        }
        return re;
    }
};
4. 删除链表的倒数第 N 个结点

题目描述

  • 思路
  • 用一前一后两个相隔n个节点的指针即可,这样后面的指针到nullptr时,前面的指针正好是倒数第n个节点;
  • 但因为要删除节点,所以前面的指针实际上应该指向倒数第n个节点的再前一个节点;
  • 另外要特别考虑倒数第n个节点是第一个节点(头节点),也就是说不能指向倒数第n个节点的再前一个节点的情况,当然这种特殊的头节点处理可以通过增加一个伪头节点避免;
  • 代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* removeNthFromEnd(ListNode* head, int n) {
        ListNode *i = head, *j = head;
        int count = 0;  // i和j相距的节点数
        while(j != nullptr) {
            j = j->next;
            ++count;
            if(count > n + 1) {
                i = i->next;
            }
        }
        if(count == n) {
            // i是要删除的节点
            head = i->next;
            delete i;
        }
        else {
            // i->next是要删除的节点
            if(i->next != nullptr) {
                // 因为count>0,所以i->next必不为空,故不用else
                ListNode *tmp = i->next;
                i->next = tmp->next;
                delete tmp;
            }
        }
        return head;
    }
};
  • 下面是使用伪头节点的处理方式:
class Solution {
public:
    ListNode* removeNthFromEnd(ListNode* head, int n) {
    	// 增加伪头节点
        ListNode *fakeHead = new ListNode();
        fakeHead->next = head;

        ListNode *i = fakeHead, *j = fakeHead;
        int count = 0;  // i和j相距的节点数
        while(j != nullptr) {
            j = j->next;
            ++count;
            if(count > n + 1) {
                i = i->next;
            }
        }
        // i->next是要删除的节点
        if(i->next != nullptr) {
            // 因为count>0,所以i->next必不为空,故不用else
            ListNode *tmp = i->next;
            i->next = tmp->next;
            delete tmp;
        }        
        return fakeHead->next;
    }
};
5. 颜色分类

题目描述

  • 思路
  • 直接排序的时间复杂度是 O ( N l o g N ) O(NlogN) O(NlogN)
  • 但如果使用双指针来做的话,时间复杂度是 O ( N ) O(N) O(N),这是因为一共就只有三种元素;
  • 如果超过三种元素就不能用双指针来做了;

思路

  • 需要注意以下方面:

    • 要确保next0指针不指向元素0和next2指针不指向元素2,可以用while来实现;
    • cur和两个指针交换元素后,如果交换后cur指向的元素不是1,则该元素仍需再继续处理;
    • 继续处理的方式可以用回退指针来实现;
    • 其实处理的目的就是想让cur指向的元素为1,另外两种元素则分别交换到头和尾;
  • 代码

class Solution {
public:
    /*
    三指针
    1. next0指向连续0的下一个位置,从左到右遍历
    2. next2指向连续2的前一个位置,从右到左遍历
    3. cur指向处理的当前位置,从左到右遍历
    一些规律如下:
    1. next0指向的数一定是1或者2
    2. next2指向的数一定是0或者1
    3. 如果nums[cur] == 1,则不需要交换
    4. 如果nums[cur] == 0或者2,则交换到next0或者next2
    5. 注意交换后的数如果是1,则不需要进一步处理,否则需要回退cur指针重复处理
    */
    void sortColors(vector<int>& nums) {
        int next0 = 0, next2 = nums.size() - 1;
        int cur = 0;
        while(cur < nums.size()) {
            if(nums[cur] == 0) {
                while(next0 < cur && nums[next0] == 0) {
                    ++next0;
                }
                if(next0 < cur) {
                    // next0在cur前面
                    swap(nums[cur], nums[next0]);
                    if(nums[cur] == 2) {
                        // cur回退
                        --cur;
                    }
                }
            }
            else {
                while(next2 > cur && nums[next2] == 2) {
                    --next2;
                }
                if(next2 > cur) {
                    // next2在cur后面
                    swap(nums[cur], nums[next2]);
                    if(nums[cur] == 0) {
                        // cur回退
                        --cur;
                    }
                }
            }
            ++cur;
        }
    }
};
6. 最小覆盖子串 [滑动窗口]

题目描述

  • 思路

  • 用的是双指针的滑动窗口,即左右指针均只能从左向右移动;

  • 边界条件和特殊情况的处理比较麻烦{{{(>_<)}}};
    思路

  • 要点如下

  • 需要用两个哈希表,一个记录小串的字符和出现次数,一个记录大串滑动窗口内的字符和出现次数;

  • 在大串中移动滑动窗口时:

    • 先移动右指针,直到滑动窗口能够覆盖小串的所有字符和次数;
    • 再移动左指针,直到滑动窗口恰好不能覆盖小串的所有字符和次数;
  • 注意左指针移动时可以和右指针重合;

  • 代码

class Solution {
public:
    /*
    1. 移动right,直到全部全部字符均能覆盖
    2. 移动left,直到某个字符不能覆盖
    3. 记录此时的right - left
    */
    string minWindow(string s, string t) {
        unordered_map<char, int> t_map, s_map;
        int min_length = s.length() + 1;
        string re;
        // 初始化两个map
        // t_map用于记录, 后续不再修改;s_map用于计数,后续修改
        for(int i=0;i<t.length();++i) {
            if(t_map.find(t[i]) == t_map.end()) {
                t_map[t[i]] = 1;
                s_map[t[i]] = 0;  // s_map仅初始化
            }
            else {
                t_map[t[i]] += 1;
            }
        }
        int left = 0, right = 0;
        int count = 0;
        // 在s上移动滑动窗口
        while(right < s.length()) {
            if(t_map.find(s[right]) != t_map.end()) {
                // t中含有右指针字符
                s_map[s[right]] += 1;
                if(s_map[s[right]] == t_map[s[right]]) {
                    // 符合条件的字符计数 + 1
                    ++count;
                } 
            }
            if(count == t_map.size()) {
                // 全部找齐了
                while(left <= right) {
                    if(t_map.find(s[left]) != t_map.end()) {
                        // t中含有左指针字符
                        s_map[s[left]] -= 1;
                        // 检验是否已不能覆盖
                        if(s_map[s[left]] < t_map[s[left]]) {
                            --count;
                            // 记录最小子串
                            if(min_length > right-left+1) {
                                min_length = right-left+1;
                                re = s.substr(left, min_length);                            
                            }
                            // break前记得再移动一次左指针
                            ++left;
                            break;
                        }
                    }                    
                    // 移动左指针
                    ++left;
                }
            }
            // 移动右指针
            ++right;
        }
        return re;
    }
};
7. 环形链表

题目描述

  • 思路
  • 当然可以用hash set来做,每经过一个点检查这个点是否已经出现过即可;
  • 但这题可以用一个相当巧妙的快慢双指针来判断;
    思路
  • 要点如下:
    • 首先需要判断不含节点和只含一个节点的情况,它们都不可能出现环;
    • 快慢指针初始的位置都在head节点上,快指针每次都比慢指针多走一步;
    • 注意循环结束的条件是快慢指针有一个为空即可;
  • 双指针的方法会比用hash set的速度快很多,因为不用把元素放入set和在set中判断是否有重复元素;
  • 代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    bool hasCycle(ListNode *head) {        
        if(head==nullptr || head->next == nullptr) {
            // 没有节点或仅有一个节点,则不可能成环
            return false;
        }
        ListNode *fast = head;
        ListNode *slow = head;

        while(fast!=nullptr && slow!=nullptr) {
            slow = slow->next;
            fast = fast->next;
            if(fast != nullptr) {
                // fast指针每次多走一步
                fast = fast->next;
            }
            if(slow == fast) {
                return true;
            }
        }
        return false;
    }
};
变体1. 环形链表 II

题目描述

  • 思路
  • 增加的难度在于需要返回第一个入环的节点,而不只是判断是否有环;
  • 当然可以用hash set来做,这样和单纯的判断是否有环实现完全相同,但空间复杂度是O(N);
  • 如果是双指针的话就稍微复杂一点,需要两次相遇:
    • 第一次如果能相遇,则说明存在环;
    • 但还需要一次相遇找到第一个入环的节点;
    • 简单来说就是再放一个slow指针从head出发,直到和当前的slow指针相遇;
    • 推导过程如下:

推导过程

  • 关于为什么slow指针在入环的第一圈内就能与fast相遇的一些通俗解释:

通俗的解释

  • 注意,上面的n是指从环的角度来看,当前slowfast前面n步;
  • 代码
class Solution {
public:
    /*
    a:从head到第一个环节点经过的节点
    b:一个环的节点
    第一次相遇的时候,fast比slow多走n圈,有:
    slow: s
    fast: f = 2*s = s + nb    
    得s = nb
    如果要到第一个环节点,需要走:a + nb
    因此slow再走a个节点就到第一个环节点,这也正好是从head到第一个环节点距离
    所以还需要一个new_slow从head开始与slow第二次相遇
    */
    ListNode *detectCycle(ListNode *head) {
        if(head == nullptr || head->next==nullptr) {
            // 空节点或者只有一个节点都不可能成环
            return nullptr;
        }
        ListNode *fast = head;
        ListNode *slow = head;
        while(fast!=nullptr && slow!=nullptr) {
            slow = slow->next;
            fast = fast->next;
            if(fast != nullptr) {
                fast = fast->next;  // fast多走一步
            }
            if(slow == fast) {
            	// 存在环,找第一个入环的节点
                fast = head;  // fast充当new_slow
                while(fast != slow) {
                    // 做第二次相遇
                    fast = fast->next;
                    slow = slow->next;
                }
                return slow;
            }
        }
        return nullptr;
    }
};
[8]. 相交链表

题目描述

  • 思路
  • 剑指offer算法题02中的3. 两个链表的第一个公共节点同题;
  • 两个指针分别从两个head出发,为空则跳到另一个head
  • 关键是两个指针最后一定是相同的,无论是否存在公共节点;
  • 代码
class Solution {
public:
    ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
        ListNode *pa = headA, *pb = headB;
        while(pa != pb) {
            if(pa != nullptr) {
                pa = pa->next;
            }
            else {
                pa = headB;
            }
            if(pb != nullptr) {
                pb = pb->next;
            }
            else {
                pb = headA;
            }
        }
        return pa;
    }
};
9. 移动零

题目描述

  • 思路

  • 当然可以使用类似冒泡排序的思路,时间复杂度是O(NlogN);

  • 但更巧妙的是使用双指针,时间复杂度可以降为O(N);

    • 首先将两个指针均移动到第一个0的位置,此时左侧均为非0数;
    • 然后将右指针往右移动,遇到非0数就和左指针交换;
    • 当右指针到结尾时,如果左指针还没有到结尾,就让它到结尾,并将移动过程中的数都置为0
    • 整个过程中最多遍历每个数两次;
      思路
      思路
  • 代码

class Solution {
public:
    void moveZeroes(vector<int>& nums) {
        int p1 = 0, p2 = 0;
        while(p1 < nums.size() && nums[p1] != 0) {
            // p1移动到第一个0处,左侧全为非0
            ++p1;
        }
        p2 = p1;
        while(p2 < nums.size()) {      
            // p2继续向右走,遇到非0则和p1交换值     
            if(nums[p2] != 0) {
                nums[p1] = nums[p2];
                ++p1;
            }
            ++p2;
        }
        while(p1 < nums.size()) {
            // 将剩余的值填0
            nums[p1] = 0;
            ++p1;
        }
    }
};
10. 找到字符串中所有字母异位词 [滑动窗口]

题目描述

  • 思路
  • 用双指针来处理,形成滑动窗口,如果滑动窗口内的字符能够恰好覆盖匹配的字符,则记录下标即可;
  • 如何判断滑动窗口内的字符恰好覆盖匹配的字符?
  • 这里需要用两个哈希表,一个用于统计目标匹配的字符数量cnt_map,一个统计当前滑动窗口能匹配的字符数量map
  • 如何移动双指针?
  • 这里需要分类讨论,边界的讨论比较繁琐,假设左指针i和右指针j
    • (1) 如果s[j]是p中的字符,且map[s[j]]还没有放满,则将该字符放入map中,并++j
    • 此时如果map完全匹配cnt_map,则还要++i,让map空出位置来进行匹配;
    • (2) 如果s[j]是p中的字符,但map[s[j]]已经满了,则移动左指针,同时将s[i]map中取出,直到map可以放下s[j]为止;
    • (3) 如果s[j]不是p中的字符,则移动左指针和右指针到j+1处(因为s[j]及之前的子串必定不满足条件),并在移动左指针的过程中把到j之前的s[i]map中取出;
  • 代码
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        unordered_map<char, int> cnt_map, map;  // cnt_map用来统计p中字符出现的次数        
        for(char c:p) {
            ++cnt_map[c];
        }

        vector<int> re;
        int rest_num = p.length(); 
        int i = 0, j = 0;
        while(j < s.length()) {           
            if(cnt_map[s[j]]>0 && map[s[j]]<cnt_map[s[j]]) {
                // 情况1:从map中填入p字符                
                ++map[s[j]];
                --rest_num;

                // 完整重排p
                if(rest_num == 0) {
                    re.push_back(i);
                    // 放字符放回map中
                    --map[s[i]];
                    ++rest_num;
                    // 移动左指针
                    ++i;
                }
                // 移动右指针
                ++j;
            }
            else {
                if(cnt_map[s[j]] == 0) {
                    // 情况3:p中不存在这个字符
                    while(i < j) {
                        // 放字符放回map中
                        --map[s[i]];
                        ++rest_num;
                        // 移动左指针
                        ++i;
                    }
                    ++i;
                    ++j;
                }
                else {
                    // 情况2:p中有这个字符但map放不下
                    while(s[i] != s[j]) {
                        --map[s[i]];
                        ++rest_num;
                        ++i;
                    }
                    ++i;
                    ++j;
                }
            }
        }
        return re;
    }
};
11. 最短无序连续子数组

题目描述

  • 思路
  • 其实是有点贪心法+两遍双指针的感觉;
  • 包含乱序的示意图如下:

示意图

  • 则乱序区间中的数有以下特点,加粗的字表明了贪心规则:
    • 从左往右扫描,乱序的右边界一定小于当前找到的最大值;
    • 因为正常的数从左往右是升序,符合升序的数一定大于等于当前的最大值;
    • 记录最后一个乱序的数就是乱序区间的右边界;
    • 从右往左扫描,乱序的左边界一定大于当前找到的最小值;
    • 因为正常的数从右往左是降序,符合降序的数一定小于等于当前的最小值;
    • 记录最后一个乱序的数就是乱序区间的左边界;
  • 因此进行两轮扫描即可找到乱序区间的左右边界;
  • 之所以说是双指针,是因为每轮找边界的时候都需要一个指针进行遍历,一个指针记录在后面记录最后一个乱序的数
  • 当然,因为两轮扫描的长度是一样的,也可以把两轮扫描合并成一轮扫描;
  • 注意,如果存在乱序区间,则乱序区间的长度至少是2
  • 代码
class Solution {
public:
    int findUnsortedSubarray(vector<int>& nums) {
        int left = 0, right = -1;
        int max_num = INT_MIN, min_num = INT_MAX;
        for(int i=0;i<nums.size();++i) {
            if(max_num <= nums[i]) {
                // nums[i]越来越大,表明是正常升序
                max_num = nums[i];
            }
            else {
                // nums[i]是乱序,更新乱序的右边界
                right = i;
            }
        }
        for(int i=nums.size()-1;i>=0;--i) {
            if(min_num >= nums[i]) {
                // nums[i]越来越小,表明是正常降序
                min_num = nums[i];
            }
            else {
                // nums[i]是乱序,更新乱序的左边界
                left = i;
            }
        } 
        // 如果right == left,则表明无乱序,因为不会存在长度为1的乱序
        // 如果right != left,则表明存在乱序,且乱序的长度必定大于等于2
        return right - left + 1;
    }
};

十一、堆

1. 数组中的第K个最大元素 [类快排查找]

题目描述

  • 思路

  • 当然可以和求全部前k个元素一样,用来做,实现参考:剑指offer算法题02中的十一、1. 最小的k个数,几乎是一样的,但用小顶堆,需要自定义排序函数,时间复杂度是O(Nlogk);

  • 用一般的排序则时间复杂度至少是O(NlogN);

  • 另外,无论是求第k个元素还是求前k个元素也可以用类快排方式来求解,加上随机策略之后平均时间复杂度是O(N),不加的话最差是O(N^2),比用堆的方法更慢;

  • 这里将实现类快排查找方式,实现过程如下:

    • 随机选择一个[low, high]之间的元素作为pivot,并把它交换到nums[low]处,而不是直接选择nums[low]作为pivot,这可以避免最差的时间复杂度是O(N^2);
    • 然后按照快排的思路,将pivot移动到中间位置,左边的值均大于pivot,右边的值均小于pivot
    • 如果当前pivot的下标是k-1,则直接返回其值即是第k个元素,因为下标是从0开始的;
    • 如果pivot的下标i小于k-1,则只搜索[i+1,high],否则,只搜索[low, i-1],也就是相比于快排只搜索一边;
  • 一些需要注意的点:

    • 由于pivot是从nums[low]开始的,所以指针的移动应该先移动jhigh向左移
    • 实现的时候应该有两层while循环,直至ij指针相遇;
    • 每个whileif判断都应该有i<j这个条件;
    • 结尾需要记得将pivot重新赋值;
    • 如果要自己实现swap函数,则直接用引用+临时变量即可;
  • 代码

class Solution {
private:
    int re;
    void my_swap(int &a, int &b) {
        // 用引用+临时变量即可
        int temp = a;
        a = b;
        b = temp;
    }
    void quickFind(vector<int>& nums, int low, int high, int& k) {
        if(low > high) {
            // 和quickSort不一样,等号的时候也要走一遍,不然i==k-1可能无法取得
            return;
        }
        int i = low, j = high;

        // 引入随机选择pivot,能够避免最坏为O(n^2),整体是O(n)
        int rand_index = low + rand()%(high-low+1);
        my_swap(nums[low], nums[rand_index]);

        // 往下是快排的写法
        int pivot = nums[low];        
        while(i < j) {
            // 把pivot放到中间,前大后小
            while(i<j && pivot>=nums[j]){
                --j;
            }
            if(i < j) {
                nums[i] = nums[j];  // nums[j] = pivot
                ++i;
            }
            while(i<j && pivot<=nums[i]) {
                ++i;
            }
            if(i < j) {
                nums[j] = nums[i];  // nums[i] = pivot
                --j;
            }
        }       
        // 重新赋值
        nums[i] = pivot;

        if(i == k-1) {
            re = nums[i];
            return;
        }
        else {
            // k-1在[low, i-1]中
            if(i > k-1) { quickFind(nums, low, i-1, k); }
            // k-1在[i+1, high]中
            if(i < k-1) { quickFind(nums, i+1, high, k); }            
        }
    }
public:
    int findKthLargest(vector<int>& nums, int k) {
        quickFind(nums, 0, nums.size()-1, k);
        return re;
    }
};
2. 前 K 个高频元素 [类快排查找]

问题描述

  • 思路
  • 剑指offer算法题02中的十一、1. 最小的k个数几乎同题;
  • 就是找出现频率最高的前k个数;
  • 特殊之处,或者说增加的难度主要有两点:
    • (1) 需要先统计出现的频率,而不是直接用数组的值;
    • 对策是用unordered_map来遍历一遍统计即可;
    • (2) 需要使用小顶堆,但标准的priority_queue是大顶堆,需要进一步改造;
    • 对策是自定义排序函数,或者不用堆而用类快排的方式;
  • 思路一:类快排查找
  • 和快排类似的实现,但是只搜索一边即可;
  • 注意的点如下:
    • 需要另外定义一个quickFind()函数,注意返回类型和快排一样也必须是void类型,参数包括数组arr,下标lowhigh,寻找的位置k,用递归实现;
    • 初始传入的下标必须是紧确界,都可以在数组中取到;
    • 递归终止条件和快排不一样,范围要小一点,不能包含low == high
    • while移动指针的时候注意等于的时候也需要移动;
    • 一定要用随机选择pivot策略,否则时间复杂度的期望不能到O(N);
    • 发现结果的条件是i == k-1,如果用k而且k == arr.size()时是找不到的,因为此时的k不在数组的下标范围内;
  • 代码
class Solution {
private:
    vector<int> re;
    void quickFind(vector<pair<int, int>>& arr, int low, int high, int k) {
        // 等号也必须取到
        if(low > high) {
            return;
        }        

        // 取随机数,[low, high]间有high-low+1个元素,1要加上
        int rand_index = low + rand()%(high - low + 1);
        swap(arr[low], arr[rand_index]);

        pair<int, int> pivot = arr[low];
        int i = low, j = high;
        while(i < j) {
            // 注意等于号
            while(i<j && arr[j].second<=pivot.second) {
                --j;
            }
            if(i<j) {
                arr[i] = arr[j];
                ++i;
            }
            // 注意等于号
            while(i<j && arr[i].second>=pivot.second) {
                ++i;
            }
            if(i<j) {
                arr[j] = arr[i];
                --j;
            }
        }
        arr[i] = pivot;

        // 一定要i==k-1,而不能i==k,否则如果k==arr.size()则是找不出的
        if(i == k-1) {
            //printf("k=%d\n", i);
            for(int index=0;index<=i;++index) {
                re.push_back(arr[index].first);
            }
            return;
        }
        else {
            if(i < k) {
                quickFind(arr, i+1, high, k);
            }
            else {
                quickFind(arr, low, i-1, k);
            }
            return;
        }
    }
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> count_map;
        // 统计出现的次数
        for(int i=0;i<nums.size();++i) {
            if(count_map.find(nums[i]) == count_map.end()) {
                count_map[nums[i]] = 0;
            }
            ++count_map[nums[i]];
        }

        // 转存到数组
        vector<pair<int, int>> count_arr;
        for(auto i=count_map.begin();i!=count_map.end();++i) {
            count_arr.push_back({i->first, i->second});
        }

        // 类快排查找
        // 注意上下界均是紧确界
        quickFind(count_arr, 0, count_arr.size()-1, k);

        return re;
    }
};
  • 思路二:小顶堆
  • 当然也可以用小顶堆来做,时间复杂度略高,但实际运行的时间不一定高多少;
  • 找最大的k个元素用小顶堆
  • 找最小的k个元素用大顶堆
  • 自定义比较函数的方式有两种;
  • (1) 用仿函数来实现:
  • 这种方式是STL标准的实现方式,推荐使用;
  • 参考cpp官方文档和博客:优先队列(priority_queue)–自定义排序
  • 代码
class Solution {
private:
    // 仿函数
    struct cmp {
        bool operator() (const pair<int, int>& a, const pair<int, int>& b) {
            return a.second > b.second;
        }
    };
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> count_map;
        // 统计出现的次数
        for(int i=0;i<nums.size();++i) {
            if(count_map.find(nums[i]) == count_map.end()) {
                count_map[nums[i]] = 0;
            }
            ++count_map[nums[i]];
        }

        // 转存到小顶堆
        priority_queue<pair<int, int>, vector<pair<int, int>>, cmp> heap;
        for(auto i=count_map.begin();i!=count_map.end();++i) {
            if(heap.size() < k) {
                heap.push({i->first, i->second});
            }
            else {
                pair<int, int> tmp = heap.top();
                if(i->second > tmp.second) {
                    heap.pop();
                    heap.push({i->first, i->second});
                }
            }
        }

        // 记录到数组
        vector<int> re;
        while(!heap.empty()) {
            re.push_back(heap.top().first);
            heap.pop();
        }

        return re;
    }
};
  • (2) 用普通的函数指针来实现:
  • 这种写法是leetcode官方题解给出的,似乎是C++11新标准的写法;
  • 使用了decltype()函数,用于返回传入参数的类型;
  • 注意cmp是一个静态函数;
  • 代码
class Solution {
private:
    static bool cmp(const pair<int, int>& a, const pair<int, int>& b) {
        return a.second > b.second;
    }
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> count_map;
        // 统计出现的次数
        for(int i=0;i<nums.size();++i) {
            if(count_map.find(nums[i]) == count_map.end()) {
                count_map[nums[i]] = 0;
            }
            ++count_map[nums[i]];
        }

        // 转存到小顶堆
        priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(&cmp)> heap(cmp);
        for(auto i=count_map.begin();i!=count_map.end();++i) {
            if(heap.size() < k) {
                heap.push({i->first, i->second});
            }
            else {
                pair<int, int> tmp = heap.top();
                if(i->second > tmp.second) {                   
                    heap.pop();
                    heap.push({i->first, i->second});
                }
            }
        }

        // 记录到数组
        vector<int> re;
        while(!heap.empty()) {
            re.push_back(heap.top().first);
            heap.pop();
        }

        return re;
    }
};
  • 另外,sort函数的cmp可以用函数指针,也可以用仿函数,但用仿函数的时候用的是T()的形式而不是T,实际上传入的参数仍然是函数指针而不是类对象,参考博客:C++ 仿函数和自定义排序函数的总结
补充:关于priority_queue的自定义cmp
  • 推荐使用仿函数的形式;
  • 仿函数的定义如下:
// 仿函数
struct cmp {
    bool operator() (const T& a, const T& b) {
        return a严格小于b的条件;
    }
};
  • priority_queue的定义如下:
priority_queue<T, vector<T>, cmp> heap;
  • 有三个参数类型的说明;
  • 因为priority_queuevector的配接器,所以要显式给出vector的类型;
  • 不是cmp(),而是直接传入整个仿函数类类型cmp
  • 如果是标准类型,如int,也可以直接用STL中的标准greater仿函数,用法如下:
priority_queue<int, vector<int>, greater<int>> heap;

十二、贪心法

1. 跳跃游戏

题目描述

  • 思路

  • 本来还打算用动态规划来做的,但动态规划时间复杂度是 O ( N 2 ) O(N^2) O(N2),因为对每个i判断dp[i]是否可达,都要遍历它前面的位置看是否有机会到i

  • 其实直接用贪心法就可以了,时间复杂度是 O ( N ) O(N) O(N)

  • 核心点

    • 从前往后遍历;
    • 记录当前可达的最远下标
    • 遍历一旦超过最远下标即终止;
    • 最后如果最远下标大于等于数组的长度,即说明可达整个数组,返回true
  • 另外要注意遍历时不要超过数组的长度;
    思路

  • 代码

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int max_reach = 0;
        int cur = 0;
        while(cur <= max_reach && cur < nums.size()) {
        	// 仅当cur处于最大可达距离内才遍历
            max_reach = max(cur + nums[cur], max_reach);
            ++cur;
        }
        if(max_reach >= nums.size()-1) {
            return true;
        }
        else {
            return false;
        }
    }
};
2. 合并区间

题目描述

  • 思路

  • 先排序,再顺次遍历合并;

  • 合并 [ L 1 , R 1 ] [L_1,R_1] [L1,R1] [ L 2 , R 2 ] [L_2,R_2] [L2,R2]时:

    • 如果 R 1 > = L 2 R_1>=L_2 R1>=L2,则两个区间可合并;
    • 如果 R 1 > R 2 R_1>R_2 R1>R2,则合并后的区间是 [ L 1 , R 1 ] [L_1,R_1] [L1,R1],否则为 [ L 1 , R 2 ] [L_1,R_2] [L1,R2]
    • 如果不能合并就尝试进行下一个区间的合并;
  • 可以证明如果先排序再这样处理是肯定不会漏掉某个能够合并的区间的,证明如下;
    证明过程

  • 代码

class Solution {
public:
    // 必须是static,因为是成员函数,不用static的话不能在sort中使用
    // 但如果不是成员函数的话就不需要用static
    // 传入的参数用引用的话时间和空间都能极大地节省
    static bool cmp(vector<int>& a, vector<int>& b) {
        if(a[0] < b[0]) {
            return true;
        }
        if(a[0] > b[0]) {
            return false;
        }
        else {
            if(a[1] < b[1]) {
                return true;
            }
            else {
                return false;
            }
        }
    }

    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        sort(intervals.begin(), intervals.end(), cmp);
        vector<vector<int>> re;
        int i = 0;
        while(i < intervals.size()) {
            vector<int> tmp = intervals[i];
            ++i;
            while(i < intervals.size() && tmp[1] >= intervals[i][0]) {
                // 注意比较前一个区间的右区间和下一个区间的右区间的大小
                tmp[1] = max(tmp[1], intervals[i][1]);
                ++i;
            }
            re.push_back(tmp);
        }
        return re;
    }
};
补充:关于sort函数的自定义cmp
  • bool类型返回值;
  • 传入两个参数ab应当用const和引用&修饰,以减少空间和时间开销(很重要!);
  • true代表a排在b前面;
  • 如果是成员函数,应当使用static + private修饰,否则则不需要;
  • vectorstring类型都可以用sort进行排序;
  • 一个例子如上所示;
  • 特别注意的是:为true的时候必须是严格小于,=的情况下不能返回true值,否则存在相等值时递归的过程可能会陷入死循环或者空递归;
3. 根据身高重建队列

题目描述

  • 思路
  • 共有两个限制[h_i, k_i]
  • 贪心法实现如下:
    • 先按照h_i来从高到低排序;
    • 然后依次遍历每个人,把它插入到从前往后数的第k_i个位置上;
  • 为什么贪心法可以奏效?
    • 首先对遍历到的第i个人来说,必定有k_i <= i,也就是说插入的操作是往前面已经排好的队伍里面插入;
    • (1) 对于第i个人来说,这样的插入一定是满足条件的,因为前面的人都比它高,所以它放在哪个位置就k_i是多少;
    • (2) 对于前面已经排好的人来说,因为第i个人比它们矮,所以无论第i个人插入到哪个位置都不会改变它们原来的k_i值,所以也满足条件;
    • 于是每处理一个人,满足条件的人数就加一,遍历完后就是结果;
  • 代码
  • 插入的操作最好是用list容器来进行;
  • 注意容器的迭代器使用,取值(*i),需要加括号,而取指针i->等价于先取(*i)(*i)->
  • for循环的终止条件用的是!=container.end()
  • list.insert()的第一个参数是迭代器,必须通过list.begin()自增(迭代器自增已重载)得到;
  • 而且list底层是双向环状链表,本身也不支持随机读取的逻辑;
  • 当然,如果是vector的迭代器,因为它的空间本来就是连续的,而且迭代器本质上是普通指针,所以用vector.begin() + int的形式也可以;
class Solution {
private:
    static bool cmp(const vector<int>& a, const vector<int>& b) {
        if(a[0] > b[0]) {
            return true;
        }
        else {
            if(a[0] == b[0] && a[1] < b[1]) {
                return true;
            } 
        }
        return false;
    }
public:
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        // 排序people
        sort(people.begin(), people.end(), cmp);

        list<vector<int>> re_list;
        for(auto i=people.begin();i!=people.end();++i) {
            // 顺序遍历,并依次把每个元素插入到第k_i个位置
            int tmp = (*i)[1];
            auto tmp_it = re_list.begin();
            while(--tmp >= 0) {
                ++tmp_it;
            }
            re_list.insert(tmp_it, *i);  // insert函数是在position之前插入

            // print_info
            // for(auto j=re_list.begin();j!=re_list.end();++j) {
            //     printf("[%d, %d], ",(*j)[0], (*j)[1]);
            // }
            // printf("\n");
        }

        vector<vector<int>> re;
        for(auto i=re_list.begin();i!=re_list.end();++i) {
            re.push_back(*i);
        }

        return re;
    }
};
4. 任务调度器

题目描述

  • 思路
  • 当然可以按照题目的思路来模拟一下整个调度的过程,每次选不在等待时间内而且剩余执行次数最多的任务来执行即可;
  • 但实际上可以构造一个二维矩阵(桶),通过讨论这个矩阵获得最短执行时间,如下:

思路
思路
思路

  • 一些自己的推导过程见下面代码的注释部分;
  • 代码
class Solution {
public:
    /*
    贪心法:
    1. 先找出执行次数最多的任务,记录次数为k,如果有并列最多的,记录并列数为x
    2. 构建一个k行n+1列的矩阵,且最后一行仅有x个任务,这样:
        (1) 只要每一行内的任务类型不重复,则不会发生有任务处于待命状态而冲突
        (2) 相同类型的任务填入不同行也不会发生冲突
        (3) 如果矩阵填不满,则所需要的最短时间不会变,仍然是填满矩阵的时间
        (4) 如果矩阵填满而且超出,则每一行可以增加列来填入
            也就是能够安排一种方案让所有任务都不需要等待执行
    3. 则完成任务的时间为:
        (1) (k-1) * (n+1) + x, if 填不满或刚好填满
        (2) tasks.size(), if 超出
    */
    int leastInterval(vector<char>& tasks, int n) {
        // 桶计数
        vector<int> bucket_count(26, 0);
        int k = 0;  // 执行次数最多的任务的执行次数
        int x = 0;  // 有并列最多的任务的并列数
        for(int i=0;i<tasks.size();++i) {
            ++bucket_count[tasks[i]-'A'];
            if(bucket_count[tasks[i]-'A'] > k) {
                k = bucket_count[tasks[i]-'A'];
                x = 1;
            }
            else {
                if(bucket_count[tasks[i]-'A'] == k) {
                    ++x;
                }
            }
        }

        // 注意size()返回的是unsigned_int类型,需要做类型转换
        return max(int(tasks.size()), (k - 1) * (n + 1) + x);
    }
};

十三、图

1. 课程表 [有向无环图]

题目描述

  • 思路

  • 本质是判断图是不是一个有向无环图;

  • 等价于能不能从图中获得一个拓扑排序;

  • 关于拓扑排序的一些介绍:
    拓扑排序

  • 拓扑排序方法如下:
    拓扑排序

  • 本质上就是不断找当前入度为0的节点然后解除它们的入度;

    • 维护入度为0是通过队列来实现的,所以相当于是广度优先遍历;
    • 注意不要用循环来找,不然时间复杂度是O(N^2),用队列仅需O(N);
  • 使用的数据结构包括:

    • 二维vector:记录每个节点对应的出节点;
      • 注意不是记录入节点;
      • 因为如果仅记录入节点,则无论如何都要每次遍历所有节点来删除入度的;
      • 如果记录出节点的话,则只需要遍历该节点的所有出节点即可;
      • 这样时间复杂度降为O(e),也就是所有边再遍历一遍;
    • 一维vector:记录每个节点的入度;
      • 因为要删除入度为0的节点,所以还要再记录入度值;
  • 这样实现的时间复杂度是最低的,而且也比较容易理解;

  • 代码

  • 队列实现版本:

class Solution {
public:
    /*
    1. 本质上是判断当前有向图中是否存在环
    2. 可以转换成是否能够从有向图中获得一个拓扑排序
        如果可以获得,则无环,反之则有环;
    3. 拓扑排序是:
        1) 每次取入度为0的节点
        2) 直至全部取完,则无环;
        3) 或无入度为0的节点但未取完,则有环;
    */
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        vector<vector<int>> out_nodes(numCourses);
        vector<int> in_num(numCourses, 0);

        for(auto p: prerequisites) {
            out_nodes[p[1]].emplace_back(p[0]);
            ++in_num[p[0]];
        } 

        queue<int> q;
        for(int i=0;i<numCourses;++i) {
            if(in_num[i] == 0) {
                q.emplace(i);
            }
        }

        vector<int> re;
        while(!q.empty()) {
            int delete_node = q.front();
            q.pop();
            re.emplace_back(delete_node);

            // 遍历所有出节点,删除它们的入度
            for(auto out_node: out_nodes[delete_node]) {
                --in_num[out_node];
                if(in_num[out_node] == 0) {
                    q.emplace(out_node);
                }
            }
        }

        if(re.size() == numCourses) {
            return true;
        }
        else {
            return false;
        }
    }
};
补充:无向图和有向图判断是否存在环的方法

判断环方法

  • 有向图推荐用拓扑排序
  • 无向图推荐用数学方法
    • 如果是无环的话,边数最大只能为节点数-1
    • 如果边数超过节点数-1就表明必定存在环;
    • 判断比有向图简单;
变体1. 课程表 II

题目描述

  • 思路

  • 课程表的思路完全相同,也是用拓扑排序;

  • 只是需要额外记录拓扑排序的顺序再返回;

  • 代码

class Solution {
public:
    /*
    1. 本质上是判断当前有向图中是否存在环
    2. 可以转换成是否能够从有向图中获得一个拓扑排序
        如果可以获得,则无环,反之则有环;
    3. 拓扑排序是:
        1) 每次取入度为0的节点
        2) 直至全部取完,则无环;
        3) 或无入度为0的节点但未取完,则有环;
    */
    vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) 
    {
        vector<vector<int>> out_nodes(numCourses);
        vector<int> in_num(numCourses, 0);

        for(int i=0;i<prerequisites.size();++i) {
            // 记录每个节点的出节点
            out_nodes[prerequisites[i][1]].emplace_back(prerequisites[i][0]);
            // 记录每个节点的入度
            ++in_num[prerequisites[i][0]];
        }

        queue<int> q;
        for(int i=0;i<numCourses;++i) {
            if(in_num[i] == 0) {
                q.emplace(i);
            }
        }
    
        vector<int> re;
        while(!q.empty()) {
            int delete_node = q.front();
            re.emplace_back(delete_node);
            q.pop();

            // 对每个出节点的入度减一
            for(int i=0;i<out_nodes[delete_node].size();++i) {
                int out_node = out_nodes[delete_node][i];
                --in_num[out_node];
                if(in_num[out_node] == 0) {
                    q.emplace(out_node);
                }
            }
        }

        if(re.size() == numCourses) {
            return re;
        }
        else {
            return {};
        }
    }
};

其他类型:

1. 多数元素

题目描述

class Solution {
public:
    /*摩尔投票法*/
    int majorityElement(vector<int>& nums) {
        int major;
        int votes = 0;
        for(int i=0;i<nums.size();++i) {
            if(votes == 0) {
                major = nums[i];
                ++votes;
            }
            else {
                if(major == nums[i]) {
                    ++votes;
                }
                else {
                    --votes;
                }
            }
        }
        return major;
    }
};
[2]. 除自身以外数组的乘积

题目描述

  • 思路
  • 就是分两次遍历,得到一个前缀乘积列表和一个后缀乘积列表
  • 然后再进行一次遍历,通过前缀和后缀乘积相乘可以得到结果;
  • 当然也可以两次甚至一次遍历完成,但下标的处理会复杂一些;
  • 剑指offer算法题02中的其他类型、4. 构建乘积数组同题;
  • 代码
class Solution {
public:
    vector<int> productExceptSelf(vector<int>& nums) {
        int n = nums.size();

        vector<int> pre_multipy(nums.size(), 0);  // 到i的前向乘积(含i)
        vector<int> post_multipy(nums.size(), 0);  // 到j的后向乘积(含j)
        
        // 计算前向乘积
        pre_multipy[0] = nums[0];
        for(int i=1;i<n;++i) {
            pre_multipy[i] = pre_multipy[i-1] * nums[i];
        }
        // 计算后向乘积
        post_multipy[n-1] = nums[n-1];
        for(int i=n-2;i>=0;--i) {
            post_multipy[i] = post_multipy[i+1] * nums[i];
        }
        
        // 计算结果
        vector<int> re(n, 0);
        re[0] = post_multipy[1];
        re[n-1] = pre_multipy[n-2];
        for(int i=1;i<n-1;++i) {
            re[i] = pre_multipy[i-1] * post_multipy[i+1];
        }
        return re;
    }
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值