秋招复习笔记——代码随想录再刷

距离上一次刷代码题过了小半年了,现在回头来看,回溯动规这种又忘记了,然后堆本来就不会,队列二叉树也生疏了,现在特地回来再记一记笔记加深一下印象。

数组

比较基础,二分法这种比较基础的代码题目;包含一些小小的算法技巧,比如双指针,滑动窗口等。

这里就记一下二分法,我自己喜欢左闭右闭区间,这样mid对左边是right+1,对右边是left-1,比较对称,条件就是left<=right

针对二分查找

查找插入点本质上是在查找最左一个 target 的索引。

插入点的查找问题:

  1. 无重复元素:二分结束时一定有——left指针指向首个大于target的元素,right指针指向首个小于target的元素。易得当数组不包含target时,插入索引为left
  2. 有重复元素:当 nums[m] < target 或 nums[m] > target 时,说明还没有找到target ,因此采用普通二分查找的缩小区间操作;当 nums[m] == target 时,说明小于 target 的元素在区间[left, m-1]中,因此采用right=m-1来缩小区间,从而使指针right向小于 target 的元素靠近。循环完成后,left指向最左边的 target ,right指向首个小于 target 的元素,因此索引 left 就是插入点

查找左右边界:

  1. 左边界:就是寻找插入点,是一样的。
  2. 右边界:转化为查找target+1的左边界。

链表

这个也比较基础,刷的比较早所以掌握的还可以。

这类题目都可以通过设置虚拟头结点来完成添加、删除、反转等操作,最后delete释放内存就好了。

用的多的技巧就是双指针,通过快慢指针来计算倒数第n个元素;成环那个就是记住slow->next和fast->next->next,遇上之后就在当前节点和头结点一起移动,相遇就是成环点。

哈希表

只统计出现与否就用unordered_set,需要统计次数就用unordered_map,如果输出还需要按顺序就用map。

针对是否有元素,unordered_set就是find然后!=end就是存在,添加就是insert,删除用erase。

unordered_map,可以用auto iter来完成find操作,然后通过first和second来指代iter中的key和value值。如果要添加,需要先用pair把两个值放在一起,然后insert到unordered_map中。

C++相关API

最近有一次笔试,两道题全部用到了哈希表,我知道使用unordered_set和unordered_map就可以做,但是API忘记了导致一直debug不过去,最后是手动int数组,浪费了很多内存空间,所以特意回来记一下API的使用。

unordered_set:

unordered_set<int> nums_set(nums1.begin(), nums1.end());
for (int num : nums2) {
    // 发现nums2的元素 在nums_set里又出现过
    if (nums_set.find(num) != nums_set.end()) {
        result_set.insert(num);
    }
}
return vector<int>(result_set.begin(), result_set.end());

std::unordered_map <int,int> map;
for(int i = 0; i < nums.size(); i++) {
    // 遍历当前元素,并在map中寻找是否有匹配的key
    auto iter = map.find(target - nums[i]); 
    if(iter != map.end()) {
        return {iter->second, i};
    }
    // 如果没找到匹配对,就把访问过的元素和下标加入到map中
    map.insert(pair<int, int>(nums[i], i)); 

字符串

填充字符串通常是先开辟空间然后从后往前添加,这样时间复杂度为O(n)。

翻转单词,就是整体翻转加局部翻转就可以了。

这边主要是记录一下常用的C++的库函数。

reverse直接可以翻转,输入begin和end就好;swap交换;还有substr截取字符串。

最后有一个KMP算法,主要是next数组的问题,只在这里遇到过,用于字符串匹配,最后看看就好了,打不了硬背,不会在其他地方遇到这类问题。

栈与队列

这里就是对stack和queue的一些应用。

针对C++,stack中就是push和pop,empty,栈顶还是top;queue的话是push,pop,empty一样,区别是队列使用front来看队首。

这里底层可以用deque来实现queue,所有操作就要加后缀back和front来决定在哪里插入和弹出。

最后有一个优先级队列,最大堆的题目,要看看。(前k个高频)

二叉树

首先是三种遍历方法,递归的很好写,push_back的位置换一下就好;迭代法要记一记套路。

这里记一下前中后序的迭代遍历版本:

class Solution {
public:
    vector<int> preorderTraversal(TreeNode* root) {
        stack<TreeNode*> st;
        vector<int> result;
        if (root == NULL) return result;
        st.push(root);
        while (!st.empty()) {
            TreeNode* node = st.top();                       // 中
            st.pop();
            result.push_back(node->val);
            if (node->right) st.push(node->right);           // 右(空节点不入栈)
            if (node->left) st.push(node->left);             // 左(空节点不入栈)
        }
        return result;
    }
};

class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> result;
        stack<TreeNode*> st;
        TreeNode* cur = root;
        while (cur != NULL || !st.empty()) {
            if (cur != NULL) { // 指针来访问节点,访问到最底层
                st.push(cur); // 将访问的节点放进栈
                cur = cur->left;                // 左
            } else {
                cur = st.top(); // 从栈里弹出的数据,就是要处理的数据(放进result数组里的数据)
                st.pop();
                result.push_back(cur->val);     // 中
                cur = cur->right;               // 右
            }
        }
        return result;
    }
};

class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        stack<TreeNode*> st;
        vector<int> result;
        if (root == NULL) return result;
        st.push(root);
        while (!st.empty()) {
            TreeNode* node = st.top();
            st.pop();
            result.push_back(node->val);
            if (node->left) st.push(node->left); // 相对于前序遍历,这更改一下入栈顺序 (空节点不入栈)
            if (node->right) st.push(node->right); // 空节点不入栈
        }
        reverse(result.begin(), result.end()); // 将结果反转之后就是左右中的顺序了
        return result;
    }
};

层序遍历的话借助队列实现就好了,如下:

class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        queue<TreeNode*> que;
        if (root != NULL) que.push(root);
        vector<vector<int>> result;
        while (!que.empty()) {
            int size = que.size();
            vector<int> vec;
            // 这里一定要使用固定大小size,不要使用que.size(),因为que.size是不断变化的
            for (int i = 0; i < size; i++) {
                TreeNode* node = que.front();
                que.pop();
                vec.push_back(node->val);
                if (node->left) que.push(node->left);
                if (node->right) que.push(node->right);
            }
            result.push_back(vec);
        }
        return result;
    }
};

一些概念和解题套路

深度与高度:

  • 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
  • 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始

求深度的话,递归方法就是前序遍历;迭代方法就直接层序就可以。求高度只能用后序遍历。

二叉树的大多数题目就是在以上四种遍历方式中,对元素进行一些处理然后输出。

构造二叉树:

  • 后序和中序构造:后序的最后一个就是中间节点,在中序中找到后就可以切割,然后按照切出来的左右序列的大小去切割后序;
  • 前序和中序构造:前序的第一个就是中间节点,之后的操作是类似的。

如果直接从数组构造二叉树,不管是什么要求,基本都是先构造中间节点,然后去填补左右子树。

二叉搜索树:中序遍历下是有序数列。

对于二叉树来说非常重要的问题!!!就是递归的时候是否需要返回值,是这样判断的:

  • 如果需要搜索整棵二叉树且不用处理递归返回值,递归函数就不要返回值
  • 如果需要搜索整棵二叉树且需要处理递归返回值,递归函数就需要返回值
  • 如果要搜索其中一条符合条件的路径,那么递归一定需要返回值,因为遇到符合条件的路径了就要及时返回

如果有返回值,还要区分是只针对一边来处理,还是对整棵树来处理:如果要搜索一条边,递归函数返回值不为空的时候,立刻返回,如果搜索整个树,直接用一个变量left、right接住返回值,这个left、right后序还有逻辑处理的需要,也就是后序遍历中处理中间节点的逻辑

典型例题

这里记一下现在还会没啥思路的题目:

  1. 对称二叉树:就是类似后序遍历,一个左右中,一个右左中,递归比较,先排除元素存在再排除值不相等,然后分成outside inside与一下就好了。
  2. 平衡二叉树:通过后序遍历来求高度,同时在获取高度的递归函数中可以直接判断是否平衡。
  3. 求所有路径,就是回溯法,这个可以在后面的回溯法里面多看看回溯法的模板。
  4. 前序/后序和中序构造二叉树:这个上面有记思路,就按照这个写代码就好了,递归需要四个左右节点参数。
  5. 直接构造二叉树:这个就是递归,中间切分区间然后中间节点cur构造好之后用左右指针去接递归函数的返回值。
  6. 二叉搜索树的题目,可不用vector来新建数组,在递归遍历的过程中设置pre来记录上一个节点就可以了。
  7. 找二叉树的最近公共祖先,所以一定是后序遍历:要同时搜索左右子树,如果自己就是p或者q就直接return给上层;如果不是,接住了两侧的节点后,如果两个都存在就返回自己,相当于找到了;如果只有一个就返回存在的那个,不然就直接返回NULL。(如果是二叉搜索树更简单,直接在区间里面遍历,符合条件的第一个节点就是公共祖先)
  8. 关于二叉搜索树的增删:添加节点——直接找到符合要求的叶子结点,根据大小作为该叶子的孩子就好;删除节点——分五种情况,没找到就直接return,找到了是叶子也可以直接删除,如果只有一个孩子那就孩子顶替这个节点,如果两个孩子那就把左子树移动到右子树最左侧节点。

回溯

回溯算法首先要把模板记住,基本的话都是一个vector<vector<int>> result记录最终结果,一个vector<int> path记录回溯过程中的每一个符合条件的解

回溯需要分清组合和全排列,两者是有区别的。

如果回溯法中要避免取到重复,那就添加参数startIndex,然后for循环里面每次在这个位置都带入i+1,同时for循环从startIndex开始。

这里有一个题目是:重新安排行程,很难,涉及到map映射,一些语法问题以及整体回溯思路,有空多看看,其实就是图论的题目。

这里总结一下几个基本的回溯类型:

  1. 组合问题:递归的时候,需要n个数取k个这两个关键数值,同时需要自行设定一个startIndex辅助回溯;这里还可以剪枝,for进行横向遍历的时候i只需要判断到n-(k-path.size())+1就可以。
  2. 组合问题:如果可以无限次取,那么startIndex所在位置就不需要i+1,直接i带进去配合每一次一开始的if判断然后push_back和return就可以了。
  3. 组合问题:去重,需要判断回溯的过程中,i的元素值是否==i-1的元素值(前提是已经sort排序过了,不然也没法回溯)。
  4. 子集:需要在return判断之前就push_back,不然会把当前层的元素集合漏掉;不能重复选取则需要一个
  5. 子序列问题:涉及子集排序,需要注意在同一树层,用过的元素就不可再使用(我自己写了代码看了返回结果我是这么理解的,你如果不去写这个哈希数组判断用没用过这个数,那你这个逻辑在回溯的时候pop出去,少了一个元素他仍然是满足要求的,会被重复记录),同时要注意unordered_set不要定义成全局,且只需要insert不要erase(erase的话相当于没有用这个哈希数组)。
  6. N皇后:一个问题是怎么判断是否合法,只要根据row和col当前的string值是不是‘Q’,判断列和两个斜线就可以(行因为是被回溯法所控制,每一次就取出一行计算,也就不用重新检验了);还有就是怎么回溯,只要回溯到n=row就可以return记录了,如果检验不是有效的就直接continue。
  7. 解数独:bool的backtracking回溯,判断是否可行不难,主要是写代码的时候要加上if backtracking为真,就可以直接return true了;如果for把9个数都试过了都不行,就可以return false。

贪心

这里的题目就没什么套路,总体而言就是求局部最优,最终得到全局最优。遇到题目就试一下,没找到反例那就是对的。

这里贪心法比较难的是要考虑两个维度的问题,通常的做法就是先考虑其中的一个,贪心完了再去考虑另外一个

贪心法还很适合用来处理重叠区间的问题,这类问题都是先sort来排序第一个元素,我喜欢从小到大排完,然后i=1开始for循环,num[i][0]和num[i-1][1]来比较并完成处理,同时要记得如果要合并,那么num[i][1]要记得按照要求更新!!

这里只记录一下刷了题到现在还是没思路的一些题目:

  1. 摆动序列:已经做了很多次了,但每次都会在小细节上错误……只有发生摆动了才更新preDiff。
  2. 股票买卖:只有一支股票就可以贪心,把利润拆分在每天,然后只统计正的。
  3. 加油站:这道题也是做了好几遍总也记不住,要记住局部最优,也就是说目前的剩余油量>0就是局部最优的,那么一旦当前累加的剩余油量<0,就抛弃,从下一个地方开始重新累加,最终更新出来的累加起始点就是结果。
  4. 重建队列:这一题贪心体现在两个维度,先取身高从高到低排下来,然后一个个按照下标位置插入结果数组即可。
  5. 划分字母区间:首先哈希一遍,存储的是string里面当前字母的最远位置;然后设置双指针,快指针遍历,将当前字母的最远位置存进快指针,如果判断到for循环的i与快指针一样,那么就可以压入结果数组。
  6. 单调递增的数字:转成string好处理,然后倒序遍历,设置flag记录借位位置,借位前一位-=1,然后把借位全改成9就行。
  7. 监控二叉树:这道题是贪心法之中最难的一个!!首先可以确定的是,叶子结点不放监控划算,所以采用后序遍历,且两节点都需要处理,所以需要返回值,返回值可以设置成记录当前节点状态的值(有监控,被覆盖和无覆盖),然后根据左右孩子情况来进行分类讨论,最后return出来的就是根节点的状态,根节点没覆盖结果还得+=1。

动态规划

我个人认为最最困难的一个部分,很多时候dp就根本想不到怎么推导出来。

主要是各种背包问题,每次过两个月再看都会忘记,现在真的要好好记一记!!!

解题套路

动规五部曲:

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

把以上几步记记牢,所有的动态规划题目都是如此来解答!!

0-1背包

有n件物品和一个最多能背重量为w的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

推导的更经典过程是通过二维dp,从左上角往右下角推导,分成两种情况:没拿和拿了,分别递推过来取更大值。

但从i(物品)的层数来看,原先二维的递推公式中,每次是否取物体都是根据上一层结果推导而来,也就是dp[i]的递推是从dp[i-1]来的,那么可以拷贝dp[i-1]下来来节省空间,于是就有了滚动数组的版本:
dp[j]直接代表最大物品价值,递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); 初始化全变成0就好。这里最大的区别是遍历顺序!原先的话物品和背包的先后并不关键,现在只能先正向物品然后反向背包。从代码实现来说,第一层for循环,i从0开始到物品的数量为止,而第二层j循环从背包容量开始递减直到大于当前物流的重量。

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

与0-1背包的区别就是,物品有无限个可以多次放入。

递推同样可以缩减成一维的滚动数组,区别在于:
这里嵌套for循环的先后顺序可以交换!同时全部都是从小到大的遍历顺序。所以为了两个背包问题可以一起记,就全部记成先遍历物品在遍历背包!

打家劫舍

不能连着偷两个房间,递推公式的时候要注意如果当前第i间房间不偷,只是考虑i-1位置处而不是说i-1位置一定要偷!

故递推公式如下:

dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);

这里同时要注意,之前的背包问题,基本dp数组大小都是背包容量+1,打家劫舍dp大小只需要跟房间数量一样多就可以了。

股票买卖

最简单的买卖版本:只能选择某一天买入这只股票,并选择在未来的某一个不同的日子卖出该股票。

  • dp数组定义:定义一个二维dp数组,dp[i][0]代表第i天持有股票的最大现金,dp[i][1]代表第i天不持有股票的最大现金。
  • 初始化:dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0。
  • 递推公式:这个比较好理解。dp[i][0] = max(dp[i - 1][0], -prices[i]); dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);

之后就会衍生出很多不同版本的买卖方式,比如可多次买卖,可买卖的有多支,卖出后需要过几天才能买入等等。

子序列

是动态规划的重要问题,需要先做题再来进行一下总结。

这里主要区别是:之前的dp数组的最后一个或几个就是题目的结果;而子序列的问题大多结果都在dp遍历过程中得到,所以需要预设一个变量来接住这个结果!

涉及到多个子序列的动态规划:

  • 确定dp[i][j]定义
    dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]。 (特别注意: “以下标i - 1为结尾的A” 标明一定是以A[i-1]为结尾的字符串)
  • 确定递推公式
    根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来。即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1;根据递推公式可以看出,遍历i和j要从1开始!
  • dp数组初始化
    根据dp[i][j]的定义,dp[i][0]和dp[0][j]其实都是没有意义的!但dp[i][0] 和dp[0][j]要初始值,因为 为了方便递归公式dp[i][j] = dp[i - 1][j - 1] + 1; 所以dp[i][0]和dp[0][j]初始化为0
  • 确定遍历顺序
    其实是都可以的,这里外层for循环遍历A,内层for循环遍历B。

编辑距离

与子序列类型的题目类似,也是动态规划的经典题目,仍从动规五部曲来记录。最终的结果就存储在右下角的dp中。

本题是两字符串,s中出现t的个数。

  • 确定dp数组定义
    dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。这里定义成i-1和j-1同样是为了dp初始化的简便。
  • 确定递推公式
    与子序列问题一样,同样分成遍历元素相等与否分成两种情况:s[i-1] 与 t[j-1]相等,s[i-1] 与 t[j-1]不相等。
    如果s[i-1]==t[j-1],dp[i][j]可以由两部分构成:一部分是用s[i-1]来匹配,那么个数为dp[i-1][j-1]。即不需要考虑当前s子串和t子串的最后一位字母,所以只需要dp[i-1][j-1]。一部分是不用s[i-1]来匹配,个数为dp[i-1][j]。
    当s[i-1]与t[j-1]不相等时,dp[i][j]只有一部分组成,不用s[i-1]来匹配(就是模拟在s中删除这个元素),即:dp[i - 1][j]。
  • dp初始化
    从递推公式出发,可以看出在这个dp的二维数组中推导都是从左上角和上侧元素向当前元素推导,所以第一行和第一列需要进行初始化。
    dp[i][0]表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。
    再来看dp[0][j],dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。那么dp[0][j]一定都是0,s如论如何也变成不了t。

真正的编辑距离,word1和word2可以增删改,所以递推公式和初始化都需要调整。

  • 递推公式
    同样也是分成word1[i-1]==word2[j-1]和!=两种情况,其中相等比较简单,不需要改,所以dp[i][j]=dp[i-1][j-1]。
    如果不相等,只要记得增和删对于动规而言的操作步数是一样的,所以都只需要考虑删就可以了(比如word1加一个元素,就相当于word2删一个元素)。word1删除一个元素,那么就是以下标i-2为结尾的word1与j-1为结尾的word2的最近编辑距离再加上一个操作;word2删除一个元素,那么就是以下标i-1为结尾的word1与j-2为结尾的word2的最近编辑距离再加上一个操作;如果是替换,那就直接是在i-1和j-1的基础上添加一步修改。综上,dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
  • 初始化
    dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。那么dp[i][0]就应该是i,对word1里的元素全部做删除操作,即:dp[i][0] = i;同理dp[0][j] = j;

回文串

代码随想录中最后一类经典动规问题,快要结束了!最终的结果需要int变量来接住。

  • dp数组定义
    布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
  • 递推公式
    同样还是两种,s[i]与s[j]是否相等,且不相等也很简单,直接dp[i][j]=false;
    如果s[i]==s[j],会出现三种情况:情况一:下标i与j相同,同一个字符例如a,当然是回文子串;情况二:下标i与j相差为1,例如aa,也是回文子串;情况三:下标i与j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是i+1与j-1区间,这个区间是不是回文就看dp[i+1][j-1]是否为true
  • dp数组初始化
    这个比较简单,一开始肯定是没匹配上,所以都是false。
  • 遍历顺序
    一定要从下到上,从左到右遍历,这样保证dp[i+1][j-1]都是经过计算的。

tips

  • 针对二维数组的dp推导,如果递推公式可以看出i层信息是借助i-1层推导的,就可以用滚动数组来完成降维,原先的两个顺序遍历就变成i顺序j倒序遍历
  • 装满背包的最大价值,公式是:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
  • 求装满背包有几种方法,公式都是:dp[j] += dp[j - nums[i]];
  • 完全背包问题,需要区分组合和排列的区别,组合就无所谓顺序;如果是组合:
for (int i = 0; i < coins.size(); i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}

如果是排列:

for (int j = 0; j <= amount; j++) { // 遍历背包容量
    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
    }
}
  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包。
  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品。
  • 打家劫舍问题:主要就是考虑i-2和i-1处位置,考虑偷,i-2处加上i的价值;不偷,直接i-1。
  • 股票买卖问题:设置dp[len][2],二维数组来保存当前是否持有股票,其变种问题主要是改变第二维大小来保存不同状态。
  • 子序列问题:为了二维dp的第一行和第一列初始化简单,所以dp的定义就是i-1代表i下标位置处,j-1代表j下标位置处,之后根据题设完成递推公式推导。左上向右下循环。
  • 编辑距离问题:一共就没几道,就这些套路,记住就好了。左上向右下循环。dp大小n+1*n+1。
  • 回文串:同样就这一种套路,记住就好。dp大小n*n。

典型例题

这里我把刷了这几遍仍然不记得的问题记录一下(记性真的不好):

  1. 整数拆分:嵌套的for循环拆成j和i-j,关键是递推公式,dp[i]就是拆分后的最大乘积,而j拆分出来之后,只拆分成两个就是j*(i-j),如果是多个那就是j*dp[i-j];以上三者取最大那就是新的dp[i]。
  2. 不同的二叉搜索树:其实最后一次做已经有印象就是拆分成几种情况,叠加起来的递推公式的形式,但没写出代码来……这里最重要的思想是先固定根节点递归,分成左右子树的元素多少然后进行叠加,也就是dp[i]+=dp[j-1]*dp[i-j]
  3. 目标和:就是一个0-1背包问题,只不过要清楚target=x-(sum-x),然后带入0-1背包模板就行,其中不用考虑物品价值的问题,这道题是求组合种类。
  4. 一和零:这是一个两维度的0-1背包!每一个string数组就是物品,而所给m和n就是背包的两维度容量,虽然dp是二维但其实质就是0-1背包,string遍历正序而背包容量需要倒序遍历。
  5. 完全平方数:把完全背包的模型带入就可以求解,但是自己写代码的时候是从i=1开始的,要从i=0和dp[0]开始动态规划。
  6. 单词拆分:完全背包问题,同时还是求排列数,所以外层遍历背包,dp就是是否可拆分,是就true,初始化dp[0]=true,同时借助unordered_set来进行wordDict的搜索。
  7. 树形dp:对于左右子树处理后需要返回值,故必须为后序遍历。这里的dp就定义成一个2元素的一维数组,一个代表不偷的最大值,一个代表偷的最大值,之后通过当前节点偷不偷的逻辑分类讨论并再次存入这个一维数组进行递推就可以了
  8. 含有冷冻期买卖股票:主要是要分析并得到结论,题目需要把动规分成四个状态:状态一:持有股票状态;状态二:保持卖出股票的状态(两天前就卖出了股票,度过一天冷冻期。或者是前一天就是卖出股票状态,一直没操作);状态三:今天卖出股票;状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!然后就代入并推导递推公式就可以了。
  9. 最长递增子序列:dp定义为i之前包括nums[i]在内的最长递增子序列,每一次的递推公式就是在0-i内,通过j循环,如果nums[i]>nums[j]即满足递增这个要求,那么dp[i]=max(dp[i],dp[j]+1);
  10. 最长公共子序列:定义是类似的,但是递推的时候,如果text1[i-1]==text2[j-1],即i与j处是相等的,那么dp[i][j]=dp[i-1][j-1]+1,这是显然的;如果不想等,那就看看text1[0,i-2]与text2[0,j-1]的最长公共子序列和text1[0,i-1]与text2[0,j-2]的最长公共子序列,取最大的,这一步我总记不住,所以要记录一下。最后的结果就是dp数组的最后一个元素。
  11. 最长回文子序列:这题和回文子串的逻辑是类似的,dp[i][i]=1初始化,然后i倒序j从i+1开始顺序遍历,s[i]==s[j]就dp更新,否则就是取dp[i+1][j]和dp[i][j-1]中的较大值,最终结果就是dp[0][n-1]。

单调栈

通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。时间复杂度为O(n)

单调栈题目,可以只保存数组的下标i即可完成,如果需要求一个元素右边第一个更大元素,单调栈就是递增的(栈头到栈底),此时栈在当前元素<=栈头元素时就直接将当前下标i入栈;直到else语句,那么进while循环直到当前元素不大于栈头,while里面处理result[st.top]具体的赋值问题然后出栈,最后跳出while语句再st.push(i)

接雨水

单调栈经典问题,也没啥特多好分析的,同样也是分三种情况来写case,主要得记住该单调栈是递增的,然后代码记一下就行。

class Solution {
public:
    int trap(vector<int>& height) {
        int n = height.size();
        stack<int> st;
        st.push(0);
        int result = 0;
        for (int i = 1; i < n; i++) {
            if (height[i] < height[st.top()])
                st.push(i);
            else if (height[i] == height[st.top()]) {
                st.pop();
                st.push(i);
            } else {
                while (!st.empty() && height[i] > height[st.top()]) {
                    int mid = st.top();
                    st.pop();
                    if (!st.empty()) {
                        int h = min(height[st.top()], height[i]) - height[mid];
                        int w = i - st.top() - 1;
                        result += h * w;
                    }
                }
                st.push(i);
            }
        }
        return result;
    }
};

柱形最大面积

可以理解成接雨水的反面,单调栈此时是递减的,其余逻辑均是类似的,也就记一下代码就行了。注意,这里为了避免最终记录结果走不进最后一个case(也就是避免原数组本身就是单调的),需要在收尾均添加0来辅助运算

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        int result = 0;
        heights.insert(heights.begin(), 0);
        heights.push_back(0);
        int n = heights.size();
        stack<int> st;
        st.push(0);
        for (int i = 1; i < n; i++) {
            if (heights[i] > heights[st.top()])
                st.push(i);
            else if (heights[i] == heights[st.top()]) {
                st.pop();
                st.push(i);
            } else {
                while (!st.empty() && heights[i] < heights[st.top()]) {
                    int mid = st.top();
                    st.pop();
                    if (!st.empty()) {
                        int h = heights[mid];
                        int w = i - st.top() - 1;
                        result = max(result, h * w);
                    }
                }
                st.push(i);
            }
        }
        return result;
    }
};

图论

这一部分是代码随想录新更新的内容,第一次学所以会好好整理一下。

深度优先搜索

dfs,和之前的回溯法是非常类似的,这里记一下模板,注意要区分与之前回溯法时的一些细小区别。

void dfs(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本节点所连接的其他节点) {
        处理节点;
        dfs(图,选择的节点); // 递归
        回溯,撤销处理结果
    }
}

这里针对题目的话,就是选择的节点略有区别,for循环是遍历本层的所有节点,也就是graph[x].size(),而dfs递归的就是graph[x][i]

广度优先搜索

广搜的搜索方式就适合于解决两个点之间的最短路径问题。

广搜是从起点出发,以起始点为中心一圈一圈进行搜索,一旦遇到终点,记录之前走过的节点就是一条最短路

就代码实现而言,仅仅需要一个容器,能保存我们要遍历过的元素就可以,那么用队列,还是用栈,甚至用数组,都是可以的。(话说栈和队列在C里面本身就是通过数组以及head和tail下标指针实现的)。不过栈如果要保证遍历顺序一致,偶数圈和奇数圈的方向是反的,所以一般就使用队列来实现。模板如下:

int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 表示四个方向
// grid 是地图,也就是一个二维数组
// visited标记访问过的节点,不要重复访问
// x,y 表示开始搜索节点的下标
void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) {
    queue<pair<int, int>> que; // 定义队列
    que.push({x, y}); // 起始节点加入队列
    visited[x][y] = true; // 只要加入队列,立刻标记为访问过的节点
    while(!que.empty()) { // 开始遍历队列里的元素
        pair<int ,int> cur = que.front(); que.pop(); // 从队列取元素
        int curx = cur.first;
        int cury = cur.second; // 当前节点坐标
        for (int i = 0; i < 4; i++) { // 开始想当前节点的四个方向左右上下去遍历
            int nextx = curx + dir[i][0];
            int nexty = cury + dir[i][1]; // 获取周边四个方向的坐标
            if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue;  // 坐标越界了,直接跳过
            if (!visited[nextx][nexty]) { // 如果节点没被访问过
                que.push({nextx, nexty});  // 队列添加该节点为下一轮要遍历的节点
                visited[nextx][nexty] = true; // 只要加入队列立刻标记,避免重复访问
            }
        }
    }

}

针对四方格地图的岛屿判断

这里就是根据上面的那个模板来写,只不过我个人不是很喜欢这样,因为在回溯里面没有return总感觉怪怪的,而且que要push两次,也没有特别感觉这是个迭代法,所以我自己改一下,感觉这样逻辑会更加清晰:

深度优先如下:

class Solution {
private:
    int direction[4][2] = {0,1,1,0,0,-1,-1,0};
    void dfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) {
        if (visited[x][y] || grid[x][y] == '0') return;
        visited[x][y] = true;
        for (int i = 0; i < 4; i++) {
            int nextx = x + direction[i][0];
            int nexty = y + direction[i][1];
            if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size())
                continue;
            dfs(grid, visited, nextx, nexty);
        }
    }
public:
    int numIslands(vector<vector<char>>& grid) {
        int n = grid.size(), m = grid[0].size();
        vector<vector<bool>> visited(n, vector<bool>(m, false));
        int result = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (!visited[i][j] && grid[i][j] == '1') {
                    result++;
                    dfs(grid, visited, i, j);
                }
            }
        }
        return result;
    }
};

广度优先如下:

class Solution {
private:
    int dir[4][2] = {0, 1, 1, 0, 0, -1, -1, 0};
    void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) {
        if (visited[x][y] || grid[x][y] == '0') return;
        queue<pair<int, int>> que;
        que.push({x, y});
        visited[x][y] = true;
        while (!que.empty()) {
            pair<int, int> cur = que.front();
            que.pop();
            int curx = cur.first;
            int cury = cur.second;
            for (int i = 0; i < 4; i++) {
                int nextx = curx + dir[i][0];
                int nexty = cury + dir[i][1];
                if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size())
                    continue;
                bfs(grid, visited, nextx, nexty);
            }
        }
    }
public:
    int numIslands(vector<vector<char>>& grid) {
        int n = grid.size(), m = grid[0].size();
        vector<vector<bool>> visited(n, vector<bool>(m, false));
        int result = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (!visited[i][j] && grid[i][j] == '1') {
                    result++;
                    bfs(grid, visited, i, j);
                }
            }
        }
        return result;
    }
};

无向图和有向图

无向图的题目,大多就是dfs去进行深搜,代码随想录的大多是栅格地图,就是设置visited数组以及计算next的坐标,然后dfs递归就好;广搜同理,只不过结束queue队列,先入队第一个元素然后只要queue非空就一直出队然后进行遍历。遍历过程中根据题设,完成相应的计算然后return结果。这里还要注意,如果是寻找所有路径,那就相当于是回溯法,写完dfs还要pop_back;如果是这一章其他大多数题目,只是简单的统计数量等,那只要操作初始的visited把当前遍历到的变成true就可以,不用回溯。

有向图就没有其他方法了,只能dfs或者bfs,代码实现其实是类似的,只不过需要多一个参数记录遍历的方向,这是与无向图的区别。

并查集

并查集常用来解决连通性问题

大白话就是当我们需要判断两个元素是否在同一个集合里的时候,我们就要想到用并查集。

并查集主要有两个功能:

  • 将两个元素添加到一个集合中。
  • 判断两个元素在不在同一个集合。

思路就是直接用一维数组来完成,father[A]=B即可知A与B是连通的,相当于B是A的根,相当于是一个有向连通图,且只需要单向即可。

同时,为了防止寻根的深度过大导致的效率不高,可通过路径压缩来将所有根节点外节点直接挂在根节点下,如下代码:

// 并查集里寻根的过程
int find(int u) {
    if (u == father[u]) return u;
    else return father[u] = find(father[u]); // 路径压缩
}

由此,整体代码可如下书写:

int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

这里有向图中,被指向的元素就是根,join(u,v)那么u就是根。

空间复杂度: O(n) ,申请一个father数组。时间复杂度介于O(logn)与O(1)之间,且随着查询或者合并操作的增加,时间复杂度会越来越趋于O(1)。

经典例题

记录一下做题的一些小诀窍和思路:

  1. 飞地的数量:可以通过把grid本身的1变成0来记录数量,省去visited标记数组,同时应该先通过dfs或者bfs把边界进行遍历以此消去边界上的1,然后再遍历全局进行统计。
  2. 被包围的区域:与上一题类似,同样是先边框再中间计算,只不过需要在做标记的时候先标记成第三种记号,再后一次遍历的时候把这个记号改回来就可以了。
  3. 太平洋大西洋水流:dfs的if遍历逻辑换成当前格小于等于周边格就行;需要两个遍历数组来接住是否为true,当两个数组的同一下标均为true即为答案。
  4. 最大人工岛:做两次dfs,第一次先通过遍历,然后unordered_map来接住每一块相连陆地的面积;第二次通过一个unordered_set记录已经被遍历过的陆地,然后有一个变量接住陆地面积和。
  5. 单词接龙:相当于只差一个字母的单词之间能构成连线,整体就是一个无向图,寻找最短路径,故采用广搜完成,可将vector转为unordered_set方便查找,广搜进入que之后,可再次for循环改变当前word的一个位置的字母,在unordered_set中进行寻找则说明是一个连接,然后通过unordered_map接住改变的路径多少,这个单词如果unordered_map没找到就可以count+1存入map。
  6. 代码随想录中的并查集相关都是套模板,没太大的额外难度

一些C++的API

有字符串string类型s,则可直接s.substr截取字符串。(s.substr(起始点,长度);)

to_string(int a)直接把int转成string,而string转成int可以直接stoi(string s)。

vector列表相关,除了push_back和pop_back这一类常用的,还可以用insert来插入:vec.insert(vec.begin() + 位置移动量, 插入元素);

面对vector<vector<int>>数组,可以直接用vec.back()[0]取出二维数组末尾元素。

对于string,添加也可以用push_back函数,也可以用append来自由添加。

C++中,求最大公约数可以直接:__gcd(int a, int b)。

C++中,对于一个序列,可以直接用lower_bound求出大于等于target的第一个元素的地址(使用时需要-nums.begin()来使用),同理upper_bound。

如果要求多个值的最大/最小值,可以在max/min中在元素外加{}来直接求多个值最大最小。

对于unordered_map而言,除了直接用umap.find(),找到key值之外,还可以直接用umap.count(key)来找key。

总结

代码随想录还是要多刷,之后每次再刷都会再次完善一下笔记,最近在做公众号以及牛客的真题,回头还得来复习总结这些模板。

  • 13
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值