目录
二分
双指针
单调队列
滑动窗口
哈希
单调栈
二叉树
快速排序/快速选择
堆/优先队列
归并排序
回溯/递归/DFS
BFS
图
并查集
动态规划
贪心
二分
二分本质不是单调性,只需要区间针对某个性质能够分成两段,一段满足一段不满足即可。
找到能够划分区间左右两半的性质,如果if (check(mid)) 条件成立,判断答案在左区间还是右区间,如果答案在左区间并且mid也可能是答案,按模板1来划分;如果答案在右区间并且mid也可能是答案,按模板2来划分()。
模板1mid使用下中位数,模板2使用下中位数+1,终结条件为$low==high$,注意区间左右均为闭区间
版本1
最大值最小问题,第一个>=target的元素,满足check条件的区间左边界
区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用,其更新操作是r = mid或者l = mid + 1。计算mid时不需要加1。
1 | int bsearch_1(int l, int r) |
版本2
最小值最大问题,最后一个<= target的元素,找满足check条件的区间右边界
区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用,其更新操作是r = mid - 1或者l = mid。
因为r更新为mid-1,如果mid仍然计算下取整,则l和r差1时大者永远取不到,会死循环,因此计算mid时需要加1。
1 | int bsearch_2(int l, int r) |
浮点数二分
注意while判断条件考虑浮点误差应为while (r - l > eps)
1 | double bsearch_3(double l, double r) |
旋转数组二分
双指针
常见题型:
左右指针:三数之和、盛最多水的容器
快慢指针:利用某种单调性确保 i,j两指针保持相同的移动方向
原地移除数据元素、求满足条件的子串
1 | for (int i = 0, j = 0; i < n; i++) { |
最长无重复字符子串
注意check的条件是cnt[a[i]]
意味着只需要检查新加入的最右端元素的出现次数
1 | const int N = 100010; |
单调队列
1 | int hh = 0, tt = -1; |
滑动窗口的最小值
1 | int hh = 0, tt = -1; |
滑动窗口
1 | int left = 0, right = 0; |
最小覆盖子串
1 | string minWindow(string s, string t) { |
找到字符串中所有给定子串的字母异位子串
1 | vector<int> findAnagrams(string s, string p) { |
和为k的子数组
注意这道题如果数组存在负数,则右指针右移,左指针不满足右移的单调性,不能使用滑动窗口。应使用前缀和+哈希:
1 | int subarraySum(vector<int>& a, int k) { |
哈希
常见题目:两数之和、和为k的子数组、字母异位词分组、最长连续序列
最长连续序列
1 | int longestConsecutive(vector<int>& nums) { |
单调栈
单调性:元素下标i < j 但元素值 a[i] > a[j]时,a[j]必定有更长的生命周期,a[i]可被删除,因此最终栈内元素始终单调递增
求数组每个元素左边第一个比它小/大的元素
1 | int hh = 0; |
接雨水
1 | int trap(vector<int>& a) { |
柱状图最大矩形面积
1 | def largestRectangleArea(self, heights) -> int: |
二叉树
数据结构的遍历形式主要分为迭代和递归,二叉树由于其非线性,只能采用递归遍历形式:
1 | void traverse(TreeNode* root) { |
二叉树的解题思路分为两种:一是递归遍历二叉树,二是分解子问题。回溯通常定义一个返回值为空的函数做递归遍历,分治定义的函数返回子树的计算结果。以前序遍历为例:
1 | // 回溯思想 |
二叉树的层序遍历:本质是BFS
1 | vector<vector<int>> levelTraverse(TreeNode* root) { |
二叉搜索树:1)左子树均小于根节点,右子树均大于根节点。2)BST的左子树和右子树也是BST。
快速排序/快速选择
快速排序 $O(nlgn)$
1 | void qSort(int q[], int l, int r) |
快速选择$O(n)$
1 | int qSelect(vector<int>& a, int l, int r, int k) { |
####
堆/优先队列
堆是满足任一父节点大于/小于其子节点的完全二叉树。
插入元素 右下插入
1 | heap[++size] = x; |
删除堆顶 交换后删除右下元素
1 | heap[1] = heap[size]; |
删除任一元素
1 | heap[k] = heap[size]; |
$O(1)$时间建堆,数列错位相减可证明
1 | for (int i = n / 2; i; i--) { |
通用操作
1 | void down(int u) { |
最大堆
1 | priority_queue, greater > que; |
最小堆
1 | priority_queue que; |
前k大的数
快速选择算法 $O(n)$ + 排序$O(klgk)$
1 | class Solution { |
最小堆(注意求前k大的数应该用最小堆)$O(nlgk)$
C++优先队列默认为最大堆,greater为最小堆
1 | vector<int> getLastNumbers_Solution(vector<int> input, int k) { |
数据流的中位数
维护一个最大堆来存放较小一半的数和一个最小堆来存放较大一半的树,保证两个堆的数目保持一致或最多差一,如果两堆顶逆序交换即可。
1 | priority_queue<int> maxHeap; |
归并排序
数组归并排序
注意合并时所需额外空间的处理 vector<int> tmp(r - l + 1);
1 | void mergeSort(vector<int>& a, int l, int r) { |
合并两个有序链表
1 | struct ListNode { |
链表归并排序
快慢指针寻找链表中点
逆序对的数量
分治:构成逆序对的两个数同在分治后的左侧区间或右侧区间,或者分别位于左右两个区间需要在归并时计算
归并计算逆序对:对右侧区间的每个数计算左侧区间中大于它的数的个数,最后全部求和
1 | long long mergeSort(int l, int r) { |
合并k个有序链表
分治或最小堆
####
回溯法
DFS回溯需要恢复状态主要是考虑每次枚举状态转移时当前起始点应保持一致,如果枚举导致其发生变化则需要恢复起始状态
回溯考虑的三要素:
可选择列表、当前路径、枚举顺序(可能需要标记枚举位置)
如果当前选择列表为空:
将当前路径加入答案集合
否则:
for choice in 选择列表:
将choice加入当前路径
将choice移出选择列表
递归调用
将choice移除当前路径
将choice重新加入选择列表
指数枚举 $O(2^n)$
1 | // 递归 状态压缩 |
组合枚举 $O(C^k_n)$
枚举每个数是否被选中,增加选择k个数的限制条件,为避免组合型枚举重复枚举,人为指定顺序按顺序枚举
1 | // 递归 |
排列枚举 $O(n!)$
1 | int n; |
1 |
|
枚举每一个位置 i , 用state确定位置 i 是否用过,在每个位置上都尝试填数组第u个数
n皇后
u标记决策树每层表示棋盘的行数,每行枚举列的每个位置
1 | const int N = 10; |
带返回值的DFS/矩阵路线型
判断矩阵中是否存在某字符串路径
1 | bool dfs(vector<vector<char>> &matrix, string &str, int u, int x, int y) { |
寻找矩阵中价值最大的路径
1 | int n, m; |
递归/DFS
空间复杂度 $O(最大递归深度)$
BFS
BFS求无权图的最短路:
1 |
时间复杂度 $O(状态数*转移方式)$
空间复杂度 $O(状态数)$
最短距离模型
迷宫起点到终点的最少步数
1 | const int INF = 1e8; |
Flood Fill/连通域计数
DFS和BFS均可实现,可在线性时间找到某个点的连通块,但DFS数据较大可能会爆栈
湖泊计数
1 | typedef pair<int, int> PII; |
最小步数模型
棋盘整体从一个状态变换为另一状态所需的最小步数,状态表示通常使用字符串,距离使用哈希
e.x 八数码 (题目链接)
1 | int bfs(string start) { |
e.x 魔板(题目链接)
1 | char g[2][4]; |
图
图的表示:
邻接矩阵int G [maxv][maxv]
或<vector<vector<int> > G
邻接表vector<int> G[maxv]
邻接表边带权:
struct edge {
int to;
int cost;
}
vector<edge> G[maxv]
1 | //读入 |
并查集
静态连通性问题使用BFS/DFS,动态连通性问题使用并查集
连通本质上是一种等价关系,满足自反性、对称性和传递性
按秩合并:增加树高rank数组,每次从rank小的树向rank大的树连边,避免退化
路径压缩:每次查询到根节点将该节点的parent直接连到根
对n个元素的并查集操作一次时间$O(α(n))$,$α$为阿克曼函数的反函数,比$O(lgn)$快。
1 | void init() { |
边带权并查集
根节点绑定集合元素的大小
对于迷宫包围问题,可以利用虚拟节点营造出连通特性
每个元素绑定点到根节点的距离,适用于多分类的情况
扩展域并查集
对于合法性问题利用并查集等价关系
POJ1182 食物链
动态规划
- 状态表示
f(i, j) 表示集合[i, j]的某一属性,例如集合中的最大值、最小值或数量
- 状态计算
根据集合的划分计算
时间复杂度:状态数目 * 状态转移方式
空间复杂度:子问题的个数
背包问题
N个物品,体积为V的背包,每类物品体积为$v_i$,价值权重为$w_i$,求满足体积限制的背包的最大价值
01背包
每类物品只能用一次
状态f(i, j) 表示从前i类物品中选,所选物品体积小于j的所有选法的集合中 价值最大选法的价值
1 | f[0][0-V] = 0 |
完全背包
每类物品可以使用无限次,与01背包的区别主要在于集合的划分变为$f[i, j] = f[i-1, j-v[i]k] + kw[i]$
因此完全背包的状态计算可以优化为$f[i, j] = max(f[i-1, j], f[i, j-v[i]] + w[i])$,优化后可以使用滚动数组进一步简化为一维,和01背包只有j的计算顺序不同
多重背包
每类物品有$s_i$个,与完全背包状态划分计算相同,只不过k由$s[i]$约束.
多重背包的优化
二进制拆分优化
由 $O(NS)$优化至$O(N\lg S)$
分组背包
每组物品只能选一个,状态f(i, j)的划分根据第i组物品选第k个来拆分计算
1 | // f[i][j] = max(f[i-1][j], f[i-1][j - v[i][k]] + w[i][k])k |
计数DP
方案数类初始化通常为f[0] = 1,因为空集也可以看作一种划分方案
整数划分方案数
求1到n中任意个数之和为x的方案数
- 转换为完全背包问题,状态f(i, j)表示为从1-i个数中选择(每个数可选无数次)使得和恰好为j的方案数
状态计算f[i][j] = f[i-1][j] + f[i-1, j-i] + f[i-1][j-2*i] +...
f[i][j] = f[i - 1][j] + f[i, j - i]
- 状态f(i, j)表示所有总和为i恰好表示为j个数之和的方案数,状态计算根据j个数的最小值是否为1划分,对于最小值为1的情况,可以由去掉1的状态f(i - 1, j - 1)转移而来;对于最小值大于1的情况,可以由每个数减去1的状态f(i - j, j)转移而来
f[i][j] = f[i-1, j-1] + f[i - j][j]
线性DP
递推顺序是线性序列
数字三角形
状态f(i, j) 表示从起点走到(i, j)的所有路径的集合
注意 i 表示水平方向,j表示左下倾斜方向,初始化时需要注意f[i][j+1]
右哨兵也会被用到
1 | // f[i][j] = max(f[i-1][j-1], f[i-1][j]) + a[i][j] |
最长上升子序列
状态f(i) 表示以i结尾的所有上升子序列的集合
状态划分根据上一个数位置分类
1 | f[i] = max(f[j] + 1), j = 0, 1, 2,...,i-1 && a[j] < a[i] |
// TODO 优化
状态f(i)表示长度为i+1的上升子序列中末尾元素的最小值
由$O(n^2)$优化为$O(n\lg n)$
最长公共子序列
f(i, j) 表示s1[1..i]和s2[1..j]的所有公共子序列
状态划分根据s1[i]和s2[j]是否包含在子序列中分为四类:
1 | f[i, j] = max(f[i-1][j], f[i][j-1], f[i-1][j-1] + 1, f[i-1][j-1]); |
编辑距离
注意编辑距离的初始化
1 | for (int i = 0; i <= p; i++) f[i][0] = i; |
区间DP
状态表示某区间,递推通常先循环区间长度,再循环区间左起点
石子合并
状态f(i, j)表示将第 i 堆到第 j 堆合并的所有合并方式中代价的最小值,因此每个区间的状态初始化为正无穷
状态划分根据最后一次合并的分界线的位置分类
1 | for (int len = 2; len <= n; len++) |
能量项链
凸多边形的划分方案
状态划分:根据[L, R]边所属的三角形的另一个顶点位置来划分
1 | for (int len = 3; len <= n + 1; len ++ ) |
数位DP
数位DP通常用于解决两个整数a,b之间存在多少满足某个条件的数(且条件与数字每一位有关)的问题。
假设给定数x,包含n位,表示为$t_nt_{n-1}…t_1$,那么当我们求解n位数字$t_nt_{n-1}…t_1$的状态所对应的答案时就需重复计算n-1位数字$t_{n-1}t_{n-2}…t_1$的状态所对应的答案,因此具有重复子问题。
考虑DP状态为dp(idx, tight, sum)
计数问题
给定两个整数 a 和 b,求 a 和 b 之间的所有数字中x的出现次数,x属于0到9
count(int n, int x) 假设一个数为abcdefg,对1 <= pppxqqq <= abcdefg分类讨论:
如果ppp = 000 到 abc-1:
- 如果x不为0, qqq可以取000到999, cnt = abc * 1000
- 如果x为0, qqq可以取000到999, 但由于x为0,ppp不能为0只能从001到abc-1, cnt = (abc-1)* 1000
如果ppp = abc :
- d < x, cnt = 0
- d = x, qqq可以取000到efg, cnt = efg + 1
- d > x, qqq可以取000到999, cnt = 1000
1 | int getNum(vector<int> &nums, int l, int r) { |
状态DP
状态DP的初始化通常将不合法状态的f值初始化为正无穷或负无穷
不能打劫相邻位置的偷盗最大值
状态f[i]
表示打劫第i家的最大值
f[i] = max(f[i-1], f[i-2] + a[i])
状态f[i]
拆分为两个状态,f[i][0]
表示打劫至第i家且不选当前位置,f[i][1]
表示打劫至第i家且选当前位置,状态机的边表示从当前i转移到i+1的路径
1 | const int N = 100001; |
股票买卖
只能买卖一次 记录最小值和最大差值
无限次买卖 贪心交易所有上涨交易
最多进行k次交易
手中持有股票状态为1,未持有股票状态为0
f[i, j, 0]表示前i天已经进行j次交易且当前无股票
f[i, j, 1]表示前i天正在进行j次交易且当前有股票
含一天冷冻期
f[i, 0]表示前i天且当前有股票
f[i, 1]表示前i天且当前在冷冻期
f[i, 2]表示前i天且当前无股票且不在冷冻期
f[i][0] = max(f[i-1][0], f[i-1][2] - w[i])
f[i][1] = f[i-1][0] + w[i]
f[i][2] = max(f[i-1][1], f[i-1][2])
状态压缩DP
状态表示中的某一下标表示的是由状压state表示的集合
集合类 - 最短Hamilton路径
状态f(i, j)表示从0走到j,走过的点的集合是i的二进制表示的所有路径的集合的路径长度的最小值
状态计算根据上一点的位置是0, 1,…, n-1划分
f[i][j] = min(f[i - {j}][k] + a[k][j]), k = 0, 1, 2,...,n-1
1 | const int N = 20, M = 1 << N; |
棋盘类 - 骨牌的完美覆盖
状态f(i, j)表示第i列第j个状态,j状态位等于1表示上一列有横放格子,本列有格子捅出来
1 | const int N = 12, M = 1 << 12; |
树形DP
没有上司的舞会