对于 双变量问题,例如两数之和 ai+aj=t,可以枚举右边的 aj,转换成 单变量问题,也就是在 aj 左边查找是否有 ai=t−aj,这可以用哈希表维护。
我把这个技巧叫做 枚举右,维护左。
下面这些题目,如果可以,请用一次遍历实现。
思维扩展:
对于三个或者四个变量的问题,枚举中间的变量往往更好算。
为什么?比如问题有三个下标,需要满足 0≤i<j<k<n,对比一下:
所以枚举中间的变量更简单。
思维扩展:
通常要用到「枚举右,维护左」的技巧。
前缀和与有序集合:
思维扩展:
推荐先阅读:从集合论到位运算,常见位运算技巧分类总结!
思维扩展:
二维前缀最小值:
差分与前缀和的关系,类似导数与积分的关系。
数组 a 的差分的前缀和就是数组 a。
注:部分题目可以不用栈,而是用一个数字记录嵌套深度。
见 单调栈题单。
队列常用在 BFS 中,见 网格图题单 和 图论题单。与此相比,栈常用在 DFS 中,但无需我们手动维护。
个人觉得叫单调双端队列更准确。
单调队列 = 滑动窗口 + 单调栈。
问:入队、出队、更新答案,这三步的顺序如何思考?
答:有两种情况。如果更新答案时,用到的数据包含当前元素,那么就需要先入队,再更新答案;如果用到的数据不包含当前元素,那么就需要先更新答案,再入队。至于出队,一般写在前面,每遍历到一个新的元素,就看看队首元素是否失效(不满足要求),失效则弹出队首。
关于单调队列优化 DP,见 动态规划题单 中的「§11.3 单调队列优化 DP」。
部分题目也可以用二分解决。
基于堆的反悔贪心。
部分题目需要结合懒删除堆。
另见 图论题单 中的 Dijkstra 算法。
思维扩展:
部分题目也可以用试填法解决。
模板:
Python3
Java
C++
Go
class UnionFind:
def __init__(self, n: int):
# 一开始有 n 个集合 {0}, {1}, ..., {n-1}
# 集合 i 的代表元是自己,大小为 1
self._fa = list(range(n)) # 代表元
self._size = [1] * n # 集合大小
self.cc = n # 连通块个数
# 返回 x 所在集合的代表元
# 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
def find(self, x: int) -> int:
# 如果 fa[x] == x,则表示 x 是代表元
if self._fa[x] != x:
self._fa[x] = self.find(self._fa[x]) # fa 改成代表元
return self._fa[x]
# 判断 x 和 y 是否在同一个集合
def is_same(self, x: int, y: int) -> bool:
# 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
# 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
return self.find(x) == self.find(y)
# 把 from 所在集合合并到 to 所在集合中
# 返回是否合并成功
def merge(self, from_: int, to: int) -> bool:
x, y = self.find(from_), self.find(to)
if x == y: # from 和 to 在同一个集合,不做合并
return False
self._fa[x] = y # 合并集合。修改后就可以认为 from 和 to 在同一个集合了
self._size[y] += self._size[x] # 更新集合大小(注意集合大小保存在代表元上)
# 无需更新 _size[x],因为我们不用 _size[x] 而是用 _size[find(x)] 获取集合大小,但 find(x) == y,我们不会再访问 _size[x]
self.cc -= 1 # 成功合并,连通块个数减一
return True
# 返回 x 所在集合的大小
def get_size(self, x: int) -> int:
return self._size[self.find(x)] # 集合大小保存在代表元上
更多基础题,见 网格图题单 中的 DFS 和 图论题单 中的 DFS,其中大部分题目也可以用并查集实现。
能用树状数组解决的题目,也能用线段树解决(反过来不一定)。但树状数组实现简单,代码短。
为方便大家练习,我把适合用树状数组解决的题目分到树状数组中,其余分到线段树中。
模板:
Python3
Java
C++
Go
class FenwickTree:
def __init__(self, n: int):
self.tree = [0] * (n + 1) # 使用下标 1 到 n
# a[i] 增加 val
# 1 <= i <= n
# 时间复杂度 O(log n)
def update(self, i: int, val: int) -> None:
while i < len(self.tree):
self.tree[i] += val
i += i & -i
# 计算前缀和 a[1] + ... + a[i]
# 1 <= i <= n
# 时间复杂度 O(log n)
def pre(self, i: int) -> int:
res = 0
while i > 0:
res += self.tree[i]
i &= i - 1
return res
# 计算区间和 a[l] + ... + a[r]
# 1 <= l <= r <= n
# 时间复杂度 O(log n)
def query(self, l: int, r: int) -> int:
if r < l:
return 0
return self.pre(r) - self.pre(l - 1)
除了可以用树状数组解决,部分题目也可以在归并排序的同时计算。
线段树本质是二叉树,在学习之前,建议先做做 104. 二叉树的最大深度 和 111. 二叉树的最小深度(自底向上写法),当作热身。
线段树:为什么要这样设计? 理解线段树发明的动机。
把任意区间用 O(logn) 个区间表示,线段树的每个节点记录对应区间的信息。
基础模板代码如下。为方便入门理解,我没有做复杂封装。通用模板代码可以参考 AtCoder Library 的 segtree.hpp。
Python3
Java
C++
Go
# 线段树有两个下标,一个是线段树节点的下标,另一个是线段树维护的区间的下标
# 节点的下标:从 1 开始,如果你想改成从 0 开始,需要把左右儿子下标分别改成 node*2+1 和 node*2+2
# 区间的下标:从 0 开始
class SegmentTree:
def __init__(self, n: int, init_val=0):
# 线段树维护一个长为 n 的数组(下标从 0 到 n-1),元素初始值为 init_val
# init_val 可以是 list 或者 int
# 如果是 int,那么创建一个 list
if isinstance(init_val, int):
init_val = [init_val] * n
self._n = n
self._tree = [0] * (2 << (n - 1).bit_length())
self._build(init_val, 1, 0, n - 1)
# 合并两个 val
def _merge_val(self, a: int, b: int) -> int:
return max(a, b) # **根据题目修改**
# 合并左右儿子的 val 到当前节点的 val
def _maintain(self, node: int) -> None:
self._tree[node] = self._merge_val(self._tree[node * 2], self._tree[node * 2 + 1])
# 用 a 初始化线段树
# 时间复杂度 O(n)
def _build(self, a: List[int], node: int, l: int, r: int) -> None:
if l == r: # 叶子
self._tree[node] = a[l] # 初始化叶节点的值
return
m = (l + r) // 2
self._build(a, node * 2, l, m) # 初始化左子树
self._build(a, node * 2 + 1, m + 1, r) # 初始化右子树
self._maintain(node)
def _update(self, node: int, l: int, r: int, i: int, val: int) -> None:
if l == r: # 叶子(到达目标)
# 如果想直接替换的话,可以写 self._tree[node] = val
self._tree[node] = self._merge_val(self._tree[node], val)
return
m = (l + r) // 2
if i <= m: # i 在左子树
self._update(node * 2, l, m, i, val)
else: # i 在右子树
self._update(node * 2 + 1, m + 1, r, i, val)
self._maintain(node)
def _query(self, node: int, l: int, r: int, ql: int, qr: int) -> int:
if ql <= l and r <= qr: # 当前子树完全在 [ql, qr] 内
return self._tree[node]
m = (l + r) // 2
if qr <= m: # [ql, qr] 在左子树
return self._query(node * 2, l, m, ql, qr)
if ql > m: # [ql, qr] 在右子树
return self._query(node * 2 + 1, m + 1, r, ql, qr)
l_res = self._query(node * 2, l, m, ql, qr)
r_res = self._query(node * 2 + 1, m + 1, r, ql, qr)
return self._merge_val(l_res, r_res)
# 更新 a[i] 为 _merge_val(a[i], val)
# 时间复杂度 O(log n)
def update(self, i: int, val: int) -> None:
self._update(1, 0, self._n - 1, i, val)
# 返回用 _merge_val 合并所有 a[i] 的计算结果,其中 i 在闭区间 [ql, qr] 中
# 时间复杂度 O(log n)
def query(self, ql: int, qr: int) -> int:
return self._query(1, 0, self._n - 1, ql, qr)
# 获取 a[i] 的值
# 时间复杂度 O(log n)
def get(self, i: int) -> int:
return self._query(1, 0, self._n - 1, i, i)
思维扩展:
把任意区间用 O(logn) 个区间表示,线段树的每个节点记录对应区间的信息。
基础模板代码如下。为方便入门理解,我没有做复杂封装。通用模板代码可以参考 AtCoder Library 的 lazysegtree.hpp。
Python3
Java
C++
Go
class Node:
__slots__ = 'val', 'todo'
class LazySegmentTree:
# 懒标记初始值
_TODO_INIT = 0 # **根据题目修改**
def __init__(self, n: int, init_val=0):
# 线段树维护一个长为 n 的数组(下标从 0 到 n-1),元素初始值为 init_val
# init_val 可以是 list 或者 int
# 如果是 int,那么创建一个 list
if isinstance(init_val, int):
init_val = [init_val] * n
self._n = n
self._tree = [Node() for _ in range(2 << (n - 1).bit_length())]
self._build(init_val, 1, 0, n - 1)
# 合并两个 val
def _merge_val(self, a: int, b: int) -> int:
return a + b # **根据题目修改**
# 合并两个懒标记
def _merge_todo(self, a: int, b: int) -> int:
return a + b # **根据题目修改**
# 把懒标记作用到 node 子树(本例为区间加)
def _apply(self, node: int, l: int, r: int, todo: int) -> None:
cur = self._tree[node]
# 计算 tree[node] 区间的整体变化
cur.val += todo * (r - l + 1) # **根据题目修改**
cur.todo = self._merge_todo(todo, cur.todo)
# 把当前节点的懒标记下传给左右儿子
def _spread(self, node: int, l: int, r: int) -> None:
todo = self._tree[node].todo
if todo == self._TODO_INIT: # 没有需要下传的信息
return
m = (l + r) // 2
self._apply(node * 2, l, m, todo)
self._apply(node * 2 + 1, m + 1, r, todo)
self._tree[node].todo = self._TODO_INIT # 下传完毕
# 合并左右儿子的 val 到当前节点的 val
def _maintain(self, node: int) -> None:
self._tree[node].val = self._merge_val(self._tree[node * 2].val, self._tree[node * 2 + 1].val)
# 用 a 初始化线段树
# 时间复杂度 O(n)
def _build(self, a: List[int], node: int, l: int, r: int) -> None:
self._tree[node].todo = self._TODO_INIT
if l == r: # 叶子
self._tree[node].val = a[l] # 初始化叶节点的值
return
m = (l + r) // 2
self._build(a, node * 2, l, m) # 初始化左子树
self._build(a, node * 2 + 1, m + 1, r) # 初始化右子树
self._maintain(node)
def _update(self, node: int, l: int, r: int, ql: int, qr: int, f: int) -> None:
if ql <= l and r <= qr: # 当前子树完全在 [ql, qr] 内
self._apply(node, l, r, f)
return
self._spread(node, l, r)
m = (l + r) // 2
if ql <= m: # 更新左子树
self._update(node * 2, l, m, ql, qr, f)
if qr > m: # 更新右子树
self._update(node * 2 + 1, m + 1, r, ql, qr, f)
self._maintain(node)
def _query(self, node: int, l: int, r: int, ql: int, qr: int) -> int:
if ql <= l and r <= qr: # 当前子树完全在 [ql, qr] 内
return self._tree[node].val
self._spread(node, l, r)
m = (l + r) // 2
if qr <= m: # [ql, qr] 在左子树
return self._query(node * 2, l, m, ql, qr)
if ql > m: # [ql, qr] 在右子树
return self._query(node * 2 + 1, m + 1, r, ql, qr)
l_res = self._query(node * 2, l, m, ql, qr)
r_res = self._query(node * 2 + 1, m + 1, r, ql, qr)
return self._merge_val(l_res, r_res)
# 用 f 更新 [ql, qr] 中的每个 a[i]
# 0 <= ql <= qr <= n-1
# 时间复杂度 O(log n)
def update(self, ql: int, qr: int, f: int) -> None:
self._update(1, 0, self._n - 1, ql, qr, f)
# 返回用 _merge_val 合并所有 a[i] 的计算结果,其中 i 在闭区间 [ql, qr] 中
# 0 <= ql <= qr <= n-1
# 时间复杂度 O(log n)
def query(self, ql: int, qr: int) -> int:
return self._query(1, 0, self._n - 1, ql, qr)
部分题目也可以用珂朵莉树解决。
把询问排序,通过改变回答询问的顺序,使问题更容易处理。
相应的,在线算法就是按照 queries 的顺序一个一个处理。
欢迎关注 B站@灵茶山艾府
如果你发现有题目可以补充进来,欢迎评论反馈。