随机数问题以下基本类型:
- O(1) 时间从集合中取随机数,要排除黑名单中的数:方法是在逻辑上讲黑名单中的数都放到数组的后半部分去 710.黑名单中的随机数
- 大数据流中的随机抽样问题:当内存无法加载全部数据时,如何从包含未知大小的数据流中随机选取k个数据,并且要保证每个数据被抽取到的概率相等。
- 蓄水池抽样算法
- 解法:假设数据流含有 N 个数,如果要保证所有的数被抽到的概率相等,那么每个数抽到的概率应该为 1/N。遍历所有数据,只记录选中的数,当遇到第 i 个数时,以 1/i 的概率保留它,(i-1)/i 的概率保留原来的数。
- 例题:382.链表随机节点-任意目标数 398.随机数索引-固定目标数
- 随机洗牌:一个没有重复元素的数组数组 nums,设计算法来打乱。打乱后,数组的所有排列应该是 等可能 的。
- 解法:对于下标 x 而言,从 [x, n - 1][x,n−1] 中随机出一个位置与 x 进行值交换,当所有位置都进行这样的处理后,我们便得到了一个公平的洗牌方案。
- 例题:384.打乱数组
字符串处理:见到括号就用栈,见到计数就用哈希表,然后面向测试用例慢慢修bug
用栈解决字符串处理问题:
对于各类「区间求和」问题,该用什么方式进行求解:
- 数组不变,区间查询:前缀和、树状数组、线段树
- 数组单点修改,区间查询:树状数组、线段树
- 数组区间修改,单点查询:差分、线段树
- 数组区间修改,区间查询:线段树
「线段树」能解决所有问题,但是其代码很长,而且常数很大,实际表现不算很好。所以只有在不得不用的时候才考虑「线段树」。
【线段树 / 树状数组入门级运用】Python实现 算法学习笔记(2) : 树状数组 算法学习笔记(14): 线段树 这可能是全b站最通俗易懂的线段树入门教学视频了
/* 前缀和 */
for(int i = 1; i <= nums.size(); i++)
prefix[i] = prefix[i-1] + nums[i-1];
/* 差分数组 */
// 构建
diff[0] = nums[0];
for(int i = 1; i < nums.size(); i++)
diff[i] = nums[i] - nums[i-1];
// 还原
res[0] = diff[0];
for(int i = 1; i < nums.size(); i++)
res[i] = res[i-1] + diff[i];
class BinaryIndexTree { // 树状数组
vector<int>& nums;
vector<int> c; // c 维护一些区间的和,c[i] = (n[i]-lowbit(i), n[i]]
static int lowbit(int x) { return x & (-x); }
void add(int index, int val) {
// 从 index 开始到 c 数组尾部,所有包含 i 的区间都要加上 val
for (int i = index; i <= nums.size(); i += lowbit(i)) c[i] += val;
}
// 查询区间 [0,index] 的和
int query(int index) {
int res = 0;
// 从 index 开始到 c 数组首部,累加所有 0~index 内的区间 c[i]
for (int i = index; i; i -= lowbit(i)) res += c[i];
return res;
}
public:
BinaryIndexTree(vector<int>& nums) : nums(nums) {
c.resize(nums.size() + 1); // c 的下标从 1 开始,因为 lowbit(0)=0,会造成死循环
// 建立 c,从 0 开始累加 nums[i]
for (int i = 1; i <= nums.size(); i++) add(i, nums[i - 1]);
}
// 更新数组中的某个值
void update(int index, int val) {
add(index + 1, val - nums[index]);
nums[index] = val;
}
// 查询区间 [l,r] 的和
int query(int l, int r) { return query(r + 1) - query(l); }
};
class SegmentTree { // 线段树
int* f; // f[k] 表示 第 k 段区间的区间和,k 从 1 开始
vector<int>& nums;
// 构建线段树的第 k 段区间 [l,r]
void buildTree(int k, int l, int r) {
if (l == r) { // 单节点的区间和就是节点值
f[k] = nums[l - 1];
return;
}
// 递归构建左右区间
int m = (l + r) >> 1;
buildTree(k + k, l, m);
buildTree(k + k + 1, m + 1, r);
f[k] = f[k + k] + f[k + k + 1];
}
// 向第 k 段区间 [l,r] 中的节点 i 加上 n
void add(int k, int l, int r, int i, int n) {
f[k] += n; // 从根到叶子节点都要加上 n
if (l == r) {
return;
}
int m = (l + r) >> 1;
if (m >= i) // i 在左半边
add(k + k, l, m, i, n);
else // i 在右半边
add(k + k + 1, m + 1, r, i, n);
}
// 在第 k 段区间 [l,r] 中查找子区间 [x,y] 的区间和
int calc(int k, int l, int r, int x, int y) {
if (l == x && r == y) { // 找到相同区间
return f[k];
}
int m = (l + r) >> 1;
if (m >= y) { // [x,y] 落在左半边 [l,m]
return calc(k + k, l, m, x, y);
} else if (m < x) { // [x,y] 落在右半边 [m+1, r]
return calc(k + k + 1, m + 1, r, x, y);
} else { // [x,y] 跨过了左右半边,分别计算 [x,m] 和 [m+1,y]
return calc(k + k, l, m, x, m) + calc(k + k + 1, m + 1, r, m + 1, y);
}
}
public:
SegmentTree(vector<int>& nums) : nums(nums) {
f = new int[nums.size() * 4 + 1];
buildTree(1, 1, nums.size());
}
// 更新数组中的某个值
void update(int pos, int val) {
// 向 nums[pos] 加上 val - nums[pos]
add(1, 1, nums.size(), pos + 1, val - nums[pos]);
nums[pos] = val; // 更新 nums
}
int calc(int l, int r) { return calc(1, 1, nums.size(), l + 1, r + 1); }
};
双指针可以解决链表中环的问题
- 141.判断链表是否有环
- 142.求链表中环的入口
- 链表的首位相接构成环160.相交链表
- 静态链表 287.寻找重复数
- x->f(x)->f(f(x))... 连续求函数值产生链表202.快乐数
DFS(回溯)可以遍历出所有可能的解,从而得到这些解中符合题目要求(通常是某种最值)的那个 BFS 问题的本质就是让你在一幅「图」中找到从起点 start 到终点 target 的最近距离,即终点是确定的,所求的是起点和终点的(最值)距离(单源最短路径,路径不能为负)。
需要转换成 BFS 的问题特征:通过对某一个初始状态做一系列操作,转化成目标状态,求最少需要的操作次数 抽象成图的问题:节点即每种可能的状态,边表示对处于每一种状态时所做的“选择”,当前状态通过做选择转移到另一种状态。 很多益智游戏都是这样,虽然看起来特别巧妙,但都架不住暴力穷举,常用的算法就是回溯算法或者 BFS 算法。只要穷举出所有可行的解,就能找到符合需求的解。
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
queue<Node> q; // 保存待遍历节点
unordered_set<Node> visited; // 避免走回头路
q.push(start); // 将起点加入队列
visited.insert(start);
int step = 0; // 记录扩散的步数
while (!q.empty()) {
/* 每次 for 遍历一层节点 */
for (int i = q.size(); i > 0; i--) {
Node cur = q.front();
q.pop();
/* 划重点:这里判断是否到达终点 */
if (cur == target)
return step;
/* 将 cur 的相邻节点加入队列 */
for (Node x : cur.adj()) {
if (visited.find(x) == visited.end()) {
q.push(x);
visited.insert(x);
}
}
}
/* 划重点:更新步数在这里 */
step++;
}
// 穷举完都没找到目标,目标不存在
return -1;
}
莫里斯遍历
莫里斯遍历不需要递归或者临时的栈空间就可以完成遍历,空间复杂度是常数。但是为了解决从子节点找到父节点的问题,需要临时修改树的结构,建立左子树中最右边节点到父节点的关系,在遍历完成之后复原成原来的树结构。 时间复杂度分析:虽然有嵌套的循环,但是对于任意根节点来说,只会进行两次搜索前驱节点加上对自身的遍历,综合来说,树的每个节点的遍历次数最多是 3 次。即 O(3n)
后序遍历的性质:后序遍历可以通过前序遍历得到,按“父右左”的顺序遍历,然后反转结果。相应地,可以通过修改前序遍历的代码得到后序遍历的代码:
- 把原来的所有的 right 改成 left,原来的 left 改成 right
- 最后添加一步结果反转
路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中至多出现一次。该路径至少包含一个节点,且不一定经过根节点。
-
求路径上节点值相同的最大路径长度 687.最长同值路径
-
求路径上节点和的最大值 124.二叉树中的最大路径和
-
求最长的路径长度 543.二叉树的直径
-
判断是否存在路径上节点和等于 target 的从根开始的路径 112.路径总和
-
输出所有路径上节点和等于 target 的从根开始的路径 113.路径总和II
-
二维矩阵
- 如何删除链表中指针指定节点?将其值与下一个节点的值交换,删除下一个
- 如何删除数组中索引指定元素?将其值与数组最后一个元素值交换,删除最后一个
单调栈用途不太广泛,可以用来在一维数组中寻找每个元素的下一个更大/小的元素 739.每日温度 其中一维数组可以是循环数组 503.下一个更大元素-循环数组 求数组中某个子序列的面积=子序列的长度*子序列的最小高度 84.柱状图中最大的矩形,在栈顶元素 top 出栈时,入栈元素为 cur,则 top 就是子序列[top-1,cur]的最小值 单调栈的单调性还可以用来保证子串的最小字典序 316.去除重复字母 402.移掉K位数字
二分法通常以应用题的形式出现,求某个限定条件 t 下的最值,且限定条件和所求值之间存在单调函数关系。通常将所求值 x 当做自变量,限定条件作为因变量,建立函数关系 f(x),根据 f(x) 与 t 的关系进行二分查找,寻找最值。其中,要查找的 x 的范围通常不会直接给出,需要根据题目条件获取,通常和题目直接给出的数组有关(元素的最值,所有元素的和等)。
快慢指针 如果要操作的节点有明确的位置关系(中位数,倒数第 n 个),可以考虑快慢指针,如快指针先走 n 步,快指针一次走 2 步。快慢指针也用于列表中一次遍历原地去重,删除目标元素。 左右指针 如果列表有序,可以考虑左右指针。 滑动窗口 左右指针的一种应用,通常用于在 O(n) 时间内求字符串子串相关问题。
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, // 可选,目标子串 t 的元素出现的次数
window; // 窗口内元素出现的次数
for (char c : t) need[c]++; // 用目标字符串初始化 need
int left = 0, right = 0;
int valid = 0; // 可选,记录窗口中有几个元素满足要求了
while (right < s.size()) {
// c 是将移入窗口的字符,右移窗口
char c = s[right++];
// 进行窗口内数据的一系列更新
...
// 判断左侧窗口是否要收缩,滑动窗口需要保持题目要求的某种状态
while (window needs shrink) {
// d 是将移出窗口的字符,左移窗口
char d = s[left++];
// 进行窗口内数据的一系列更新
...
}
}
}
一维 二维
题目要求“不超过 K”条件,可以转化为一维条件“恰好 = k”,最后需要额外遍历一次“恰好条件”(1~K)求最值 787.K站中转内最便宜的航班
dp的顺序,一般从起点出发,一直dp求解到终点;如果dp附加有额外的状态,可以考虑从终点出发,dp求解到起点 174.地下城游戏 514.自由之路
二维 DP 可以从一维数组中产生,这被称为区间 DP,为求一维数组 arr 中的某个最值,可以从其子串中递推。通常定义 dp[i][j] 表示对于 arr 的子串 arr[i..j] 的所求最值 516.最长回文子序列 1312.让字符串成为回文串的最少插入次数 877.石子游戏
关于「状态」的穷举,最重要的一点就是:状态转移所依赖的状态必须被提前计算出来。
可以根据 base case 和最终状态进行推导。
例:一个二维的 dp 问题,如果已知 dp[i][i],dp[i][j] 依赖 dp[i][k] 和 dp[k][j],其中 i<k<j
,要求 dp[0][n],显然应该先按行从下往上,再按列从左往右的方法,这样在计算 dp[i][j] 时,每个 dp[i][k] 和 dp[k][j] 都已经被计算出来了。 312.戳气球
j
------------>
^
* . . . . # |
. * . . . . |
. . * - - ? |
. . . * . | | i
. . . . * | |
. . . . . * |
只要涉及求最值,没有任何奇技淫巧,一定是穷举所有可能的结果,然后对比得出最值
图 787.K站中转内最便宜的航班 二叉树 337.打家劫舍-二叉树
- 两个字符串 dp[i][j],表示以 s1[i] 和 s2[j] 结尾的...
- 一个字符串通常 dp[i],表示以 s[i] 结尾的...
- 如果涉及到两个字符的位置如回文,可以设 dp[i][j], 表示子串s[i...j]的...
背包问题具备的特征:给定一个 target,target 可以是数字也可以是字符串,再给定一个数组 nums,nums 中装的可能是数字,也可能是字符串,问:能否使用 nums 中的元素做各种排列组合得到target。
对于一个背包问题:
- 分析是哪种背包问题:
- 组合问题:“恰好”装满为 j 的背包,有几种方法
- 判断问题:能否“恰好”装满容量为 j 的背包
- 最值问题:容量为 j 的背包,最多“可以”装多少,最少使用几个物品
- 01背包 还是 完全背包,即 nums 数组中的元素是否可以重复使用。
- 如果是组合问题,是否需要考虑元素之间的顺序,解法不同
在组合问题中
- 一般的组合问题,不考虑结果中元素的顺序,即求组合数
- 一般的思路是先遍历 num[],分类讨论是否选中 num[i],如果把第 i 个物品装入背包,那么恰好装满背包的方法数,取决于使用前面 i 个物品(完全背包)装满容量为 j-num[i] 的背包的方法数,即 dp[i][j-coins[i]]。
- 如果要考虑结果中元素的顺序,{1,2} 和 {2,1} 作为两个结果,即求排列数 377.组合总和Ⅳ-完全-排列数
- 考虑把第 i 个物品装入背包时 dp[i][j] 不再等于 dp[i][j-num[i]],因为新加入的 num[i] 可以插入 dp[i][j-coins[i]] 的结果集的不同位置中产生更多的排列
- 例如,考虑 num[]={1,2,3},target=5,易知 dp[3][2] 的结果集为 {1,1} {2},共 2 个,求 dp[3][5],当选中 num[2]=3 时,结果集除了对应 dp[3][2] 的 {1,1,3} {2,3} 还有 {3,1,1} {1,3,1} {3,2} 共 5 个
排列和组合主要影响的是遍历的次序
- 组合数问题,外层循环遍历备选数 nums,内层循环遍历背包容量 target。采用经典的二维 dp 定义即可。
- 排列数问题,外层循环遍历背包容量 target,内层循环遍历备选数 nums。定义 dp[i] 表示和为 i 的排列个数,则
dp[i] = ∑dp[i-nums[k]], 0<=k<nums.size()
背包问题的最值问题,求最少使用几个物品时,不必考虑结果集中元素的顺序,采用组合数或者排列数的写法都可以
回溯算法经典框架,考虑结果中每一个位置上的选择,递归层级表示了当前考虑的位置。
result = [] // 结果集
function backtrack(路径, 选择列表){
if (满足结束条件)
result.add(路径)
return
for(选择 in 选择列表) // 遍历位置 i 的所有选择
做选择
backtrack(路径, 选择列表) // 考虑位置 i+1 的选择
撤销选择
}
回溯算法能解决排列组合问题。子集问题是组合问题的特例,求子集相对于求所有选中个数的组合。
类型 | 题目 | 关键点 |
---|---|---|
子集 | 90.子集II-备选数重复 78.子集-备选数唯一 784.字母大小写全排列 |
子集和组合的求解方法相同,只是子集要记录所有组合 |
组合 | 39.组合总和-备选数唯一-重复选择 40.组合总和II-备选数重复-一次选择 216.组合总和III-备选数唯一-一次选择-选k个 77.组合-n个数里挑k个 |
子集、组合类问题,关键是用一个 index 参数来控制选择列表,防止元素重复被选 |
排列 | 46.全排列-备选数唯一 47.全排列-备选数重复 剑指offer38.字符串的排列 |
排列类问题使用 used 数组来标识元素是否已被选择,防止元素重复被选 |
搜索 | 22.括号生成 131.分割回文串 401.二进制手表 698.划分为k个相等的子集 |
遍历所有的可能性,判断每一种情况是否符合题意 |
矩阵搜索 | 37.解数独 51.N皇后 79.单词搜索 |
对于矩阵中的每一个点,需要根据题意确定递归的方向,通常有“上下左右”四个方向 |
子集、组合与排列是不同性质的概念。子集、组合是无关顺序的,而排列是和元素顺序有关的,如 [1,2] 和 [2,1] 是同一个组合(子集),但 [1,2] 和 [2,1] 是两种不一样的排列!!!!因此被分为两类问题
回溯和动态规划本质上都是搜索,动态规划通过重复子问题加快了搜索速度。回溯能记录下所有选择的路径,动态规划不能记录所有路径,但是能用来求选择的数量、判断能否成功、求所有选择中的某个最值。 通常只有在需要列举出所有解决方案的时候使用回溯,其他情况下使用 DP,如背包问题中的组合问题用 DP 解决,详见上文“动态规划-背包问题”章节。
DFS 可以解决一类常见的问题:给定一个序列(元素唯一),枚举这个序列的所有子序列(子集) 基本**是:依次考虑序列中的每一个元素 nums[i],可以选择 nums[i],也可以不选择,记录在 track 中。枚举所有子序列,即可求得符合题目要求的“最优子序列”。显然,这个问题也等价于枚举从 N 个数中选择 K 个数的所有方案。 78.子集-元素唯一 77.组合-n个数里挑k个
DFS 的经典框架,考虑备选集中的每一个元素是否被选中
result = [] // 结果集
cur = 0; // 选择 nums[i] 产生的影响
function dfs(路径, i, nums) { // 考虑 nums[i] 的选择
if (i == nums.length) // 考虑完所有的元素
if (满足题目条件)
result.add(路径)
return
在 路径 中记录选择
dfs(i+1, nums, cur+nums[i]) // 选择 nums[i],考虑 nums[i+1]
// 如果元素可以被重复选择,则选择完 nums[i],可以继续选 nums[i],使用下面的语句
// dfs(i, nums, cur+nums[i])
在 路径 中撤销选择
dfs(i+1, nums, cur) // 不选择 nums[i],考虑 nums[i+1]
}
经典回溯是站在“路径”的角度思考,考虑路径上的每一步选择哪个元素;DFS 是站在“备选集”的角度思考,考虑备选集中的每一个元素是否被选中。
针对备选集有重复的情况 90.子集II-备选数重复
- 站在路径的角度思考更容易去重,只需要先将备选数组排序,再保证路径的递归选择树每一层不出现重复的节点即可。
- 如果站在选择集的角度,只能在最后将 tracking 加入结果集时手动判断重复,容易超过时间限制。
在组合问题中,经典回溯和 DFS 函数的定义都需要一个 index 参数 39.组合总和-被选数唯一-重复选择
- 经典回溯中 index 表示这一步中可选元素的范围为 [index, len),只在备选集的后半部分中选择是为了防止重复选择,每次递归时 index 变化是跳跃递增的
- DFS 中 index 表示当前考虑的是备选集中的第 index 个元素,DFS 方法天生不会选到重复元素,每次递归时 index 变化是连续递增的
区间问题肯定按照区间的起点或者终点进行 排序 所有区间问题类型:
- 最多不重叠区间 只有一个会议室,还有若干会议,如何将尽可能多的会议安排到这个会议室里?问最多能开几个会议
- 原型:一个区间列表,在某个范围内从列表中选择最多的区间,要求所选区间不重叠
- 解法:将这些会议(区间)按结束时间(右端点)升序排序,贪心选择结束时间最早的。435.无重叠区间 452.用最少数量的箭引爆气球
- 短区间拼接长区间 给你若干较短的视频片段,和一个较长的视频片段,请你从较短的片段中尽可能少地挑出一些片段,拼接出较长的这个片段,求最少需要的短视频片段数量
- 原型:一个区间列表,在其中选择区间拼接成大区间,要求选中的区间必须数值连续(即部分重叠)
- 解法:将这些视频片段(区间)按开始时间(左端点)排序,贪心选择连续且结束时间最晚的。1024.视频拼接
- 区间合并 给你若干区间,请你将所有有重叠部分的区间进行合并,求合并后的区间列表
- 将这些区间按起点升序终点降序排序,遍历与前一个区间比较,合并交叉的。56.合并区间
- 删除被覆盖区间 给你若干区间,其中可能有些区间比较短,被其他区间完全覆盖住了,请你删除这些被覆盖的区间,求剩余区间数量
- 参照上一题的思路,将这些区间按起点升序终点降序排序,遍历与前一个区间比较,依次合并交叉并删除被覆盖,注意这里的合并只是逻辑合并,方便判断覆盖,不影响区间数量。1288.删除被覆盖区间
- 两个区间列表的交集 有两个部门同时预约了同一个会议室的若干时间段,请你计算会议室的冲突时段。
- 原型:两个区间列表,列表内区间不重叠,求两个列表间的区间交集
- 解法:将这些区间按起点升序排序,双指针依次遍历两个列表,求区间交集。986.区间列表的交集
- 区间重叠 给你输入若干形如 [begin, end] 的区间,代表若干会议的开始时间和结束时间,请你计算至少需要申请多少间会议室。
- 原型:一个区间列表,求同一时刻 最多 有几个区间重叠
- 解法:将起点和终点都投影到时间轴上,遍历所有时间点,遇到起点就+1,遇到终点就-1。253.会议室II
数组中相邻元素构成某种限制,一个循环数组
- 首尾添加法。首部补充原尾部元素,尾部补充原首部元素,基本方法不变,最后的结果除去添加的额外元素 135.分发糖果
- 整体添加法。尾部补充整个数组,基本方法不变,不必真的添加,遍历时对下标取余即可 503.下一个更大元素-循环数组
- 删除法。要求 [0,n-1],循环数组中 0 和 n-1 不能同时选中,则可分别计算 [0,n-2] 和 [1,n-1] 求得最大值 213.打家劫舍-循环数组
题解算法中通常使用 0x3f3f3f3f
表示 INT 范围内的无穷大,这会带来以下几个好处:
0x3f3f3f3f
的十进制是 1061109567,也就是 10^9 级别的(和 0x7fffffff 一个数量级),而一般场合下的数据都是小于 10^9 的,所以它可以作为无穷大使用而不致出现数据大于无穷大的情形。- 满足“无穷大加一个有穷的数依然是无穷大”:由于一般的数据都不会大于 10^9,所以当我们把
0x3f3f3f3f
加上一个数据时,它并不会溢出。 - 满足“无穷大加无穷大还是无穷大”:事实上
0x3f3f3f3f+0x3f3f3f3f=2122219134
,这非常大但却没有超过 32-bit int 的表示范围。 - 如果我们想要将某个数组清零,我们通常会使用
memset(a,0,sizeof(a))
这样的代码来实现。但是当我们想将某个数组全部赋值为无穷大时(例如解决图论问题时邻接矩阵的初始化),就不能使用 memset 函数而得自己写循环了,这是因为 memset 是按字节操作的,它能够对数组清零是因为 0 的每个字节都是 0。然而如果我们将无穷大设为0x3f3f3f3f
,因为0x3f3f3f3f
的每个字节都是0x3f
,所以要把一段内存全部置为无穷大,我们只需要memset(a,0x3f,sizeof(a))
。
所以在通常的场合下,const int INF = 0x3f3f3f3f;
真的是一个非常棒的选择。
数字转字符串 string to_string (int val);
字符串转数字 int stoi(const std::string& str, std::size_t* pos = 0, int base = 10)
int atoi(const char *str);
字符大小写转换 int tolower( int ch );
int toupper( int ch )
判断字符是否是字母 int isalpha( int ch );
判断字符是否是数字 int isdigit( int ch );
// 小顶堆
priority_queue<int, vector<int>, std::greater<int>> heap;
// 自定义比较函数的堆
auto cmp = [](T a, T b) { return a < b; };
priority_queue<T, vector<T>, decltype(cmp) > pq(cmp);