对于 双变量问题,例如两数之和 ,可以枚举右边的 ,转换成 单变量问题,也就是在 左边查找是否有 ,这可以用哈希表维护。
我把这个技巧叫做 枚举右,维护左。
下面这些题目,如果可以,请用一次遍历实现。
思维扩展:
对于有三个或者四个变量的问题,枚举中间的变量往往更好算。
为什么?比如问题有三个下标,需要满足 ,对比一下:
所以枚举中间的变量更简单。
注:有关前后缀分解的内容,见 动态规划题单 的「专题:前后缀分解」。
左闭右开公式:子数组 的元素和为 。把下标区间定义成左闭右开,就不需要加一减一了。
思维扩展:
通常要用到「枚举右,维护左」的技巧(见本题单 §0.1 节)。
前缀和与有序集合:
思维扩展:
推荐先阅读:从集合论到位运算,常见位运算技巧分类总结!
思维扩展:
二维前缀最小值:
差分与前缀和的关系,类似导数与积分的关系。
数组 的差分的前缀和就是数组 (不变)。
思维扩展:
注:部分题目可以不用栈,而是用一个数字记录嵌套深度。
见 单调栈题单。
队列常用在 BFS 中,见 网格图题单 和 图论题单。与此相比,栈常用在 DFS 中,但无需我们手动维护。
个人觉得叫单调双端队列更准确。
单调队列 = 滑动窗口 + 单调栈。必须先掌握滑动窗口和单调栈这两个知识点,再学单调队列。
问:入队、出队、更新答案,这三步的顺序如何思考?
答:有两种情况。如果更新答案时,用到的数据包含当前元素,那么就需要先入队,再更新答案;如果用到的数据不包含当前元素,那么就需要先更新答案,再入队。至于出队,一般写在前面,每遍历到一个新的元素,就看看队首元素是否失效(不满足要求),失效则弹出队首。
模板:
# 计算 nums 的每个长为 k 的窗口的最大值
# 时间复杂度 O(n),其中 n 是 nums 的长度
def maxSlidingWindow(nums: List[int], k: int) -> List[int]:
ans = [0] * (len(nums) - k + 1) # 窗口个数
q = deque() # 双端队列
for i, x in enumerate(nums):
# 1. 右边入
while q and nums[q[-1]] <= x:
q.pop() # 维护 q 的单调性
q.append(i) # 注意保存的是下标,这样下面可以判断队首是否离开窗口
# 2. 左边出
left = i - k + 1 # 窗口左端点
if q[0] < left: # 队首离开窗口
q.popleft()
# 3. 在窗口左端点处记录答案
if left >= 0:
# 由于队首到队尾单调递减,所以窗口最大值就在队首
ans[left] = nums[q[0]]
return ans关于单调队列优化 DP,见 动态规划题单 中的「§11.3 单调队列优化 DP」。
有序集合:
部分题目也可以用二分解决。
基于堆的反悔贪心。
支持删除堆中任意元素。
模板:
# 模板来源 https://leetcode.cn/circle/discuss/mOr1u6/
class LazyHeap:
def __init__(self):
self.heap = [] # 最小堆(最大堆可以把数字取反或重载 __lt__)
self.remove_cnt = defaultdict(int) # 每个元素剩余需要删除的次数
self.size = 0 # 堆的实际大小
# 删除
def remove(self, x: Any) -> None:
self.remove_cnt[x] += 1 # 懒删除
self.size -= 1
# 正式执行删除操作
def _apply_remove(self) -> None:
while self.heap and self.remove_cnt[self.heap[0]] > 0:
self.remove_cnt[self.heap[0]] -= 1
heappop(self.heap)
# 查看堆顶
def top(self) -> Any:
self._apply_remove()
return self.heap[0] # 真正的堆顶
# 出堆
def pop(self) -> Any:
self._apply_remove()
self.size -= 1
return heappop(self.heap)
# 入堆
def push(self, x: Any) -> None:
if self.remove_cnt[x] > 0:
self.remove_cnt[x] -= 1 # 抵消之前的删除
else:
heappush(self.heap, x)
self.size += 1部分题目需要结合懒删除堆。
做法不止一种,部分题目也可以用有序集合/权值树状数组等数据结构解决。
另见 图论题单 中的 Dijkstra 算法。
思维扩展:
部分题目也可以用试填法解决。
模板:
# 模板来源 https://leetcode.cn/circle/discuss/mOr1u6/
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 = self._fa
# 如果 fa[x] == x,则表示 x 是代表元
if fa[x] != x:
fa[x] = self.find(fa[x]) # fa 改成代表元
return 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,其中大部分题目也可以用并查集实现。
另见 图论题单 中的最小生成树。
模板:
# 模板来源 https://leetcode.cn/circle/discuss/mOr1u6/
class UnionFind:
def __init__(self, n: int):
# 一开始有 n 个集合 {0}, {1}, ..., {n-1}
# 集合 i 的代表元是自己,自己到自己的距离是 0
self.fa = list(range(n)) # 代表元
self.dis = [0] * n # dis[x] 表示 x 到(x 所在集合的)代表元的距离
# 返回 x 所在集合的代表元
# 同时做路径压缩
def find(self, x: int) -> int:
fa = self.fa
if fa[x] != x:
root = self.find(fa[x])
self.dis[x] += self.dis[fa[x]] # 递归更新 x 到其代表元的距离
fa[x] = root
return fa[x]
# 判断 x 和 y 是否在同一个集合(同普通并查集)
def same(self, x: int, y: int) -> bool:
return self.find(x) == self.find(y)
# 计算从 from 到 to 的相对距离
# 调用时需保证 from 和 to 在同一个集合中,否则返回值无意义
def get_relative_distance(self, from_: int, to: int) -> int:
self.find(from_)
self.find(to)
# to-from = (x-from) - (x-to) = dis[from] - dis[to]
return self.dis[from_] - self.dis[to]
# 合并 from 和 to,新增信息 to - from = value
# 其中 to 和 from 表示未知量,下文的 x 和 y 也表示未知量
# 如果 from 和 to 不在同一个集合,返回 True,否则返回是否与已知信息矛盾
def merge(self, from_: int, to: int, value: int) -> bool:
x, y = self.find(from_), self.find(to)
dis = self.dis
if x == y: # from 和 to 在同一个集合,不做合并
# to-from = (x-from) - (x-to) = dis[from] - dis[to] = value
return dis[from_] - dis[to] == value
# x --------- y
# / /
# from ------- to
# 已知 x-from = dis[from] 和 y-to = dis[to],现在合并 from 和 to,新增信息 to-from = value
# 由于 y-from = (y-x) + (x-from) = (y-to) + (to-from)
# 所以 y-x = (to-from) + (y-to) - (x-from) = value + dis[to] - dis[from]
dis[x] = value + dis[to] - dis[from_] # 计算 x 到其代表元 y 的距离
self.fa[x] = y
return True附加模板题:CF1850H
能用树状数组解决的题目,也能用线段树解决(反过来不一定)。但树状数组实现简单,代码短。
为方便大家练习,我把适合用树状数组解决的题目分到树状数组中,其余分到线段树中。
模板:
# 模板来源 https://leetcode.cn/circle/discuss/mOr1u6/
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:
t = self.tree
while i < len(t):
t[i] += val
i += i & -i
# 计算前缀和 a[1] + ... + a[i]
# 1 <= i <= n
# 时间复杂度 O(log n)
def pre(self, i: int) -> int:
t = self.tree
res = 0
while i > 0:
res += t[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)另见本题单的「§5.7 对顶堆」,部分题目也可以用权值树状数组第 小解决。
除了可以用树状数组解决,部分题目也可以在归并排序的同时计算。
线段树本质是二叉树,在学习之前,建议先做做 104. 二叉树的最大深度 和 111. 二叉树的最小深度(自底向上写法),当作热身。
线段树:为什么要这样设计? 理解线段树发明的动机。
把任意区间用 个区间表示,线段树的每个节点记录对应区间的信息。
基础模板代码如下。为方便入门理解,我没有做复杂封装。通用模板代码可以参考 AtCoder Library 的 segtree.hpp。
# 模板来源 https://leetcode.cn/circle/discuss/mOr1u6/
# 线段树有两个下标,一个是线段树节点的下标,另一个是线段树维护的区间的下标
# 节点的下标:从 1 开始,如果你想改成从 0 开始,需要把左右儿子下标分别改成 node*2+1 和 node*2+2
# 区间的下标:从 0 开始
class SegmentTree:
def __init__(self, arr, default=0):
# 线段树维护一个长为 n 的数组(下标从 0 到 n-1)
# arr 可以是 list 或者 int
# 如果 arr 是 int,视作数组大小,默认值为 default
if isinstance(arr, int):
arr = [default] * arr
n = len(arr)
self._n = n
self._tree = [0] * (2 << (n - 1).bit_length())
self._build(arr, 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)思维扩展:
把任意区间用 个区间表示,线段树的每个节点记录对应区间的信息。
基础模板代码如下。为方便入门理解,我没有做复杂封装。通用模板代码可以参考 AtCoder Library 的 lazysegtree.hpp。
# 模板来源 https://leetcode.cn/circle/discuss/mOr1u6/
class Node:
__slots__ = 'val', 'todo'
class LazySegmentTree:
# 懒标记初始值
_TODO_INIT = 0 # **根据题目修改**
def __init__(self, arr, default=0):
# 线段树维护一个长为 n 的数组(下标从 0 到 n-1)
# arr 可以是 list 或者 int
# 如果 arr 是 int,视作数组大小,默认值为 default
if isinstance(arr, int):
arr = [default] * arr
n = len(arr)
self._n = n
self._tree = [Node() for _ in range(2 << (n - 1).bit_length())]
self._build(arr, 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)部分题目也可以用珂朵莉树解决。
ST 表 支持区间最值查询(Range Minimum/Maximum Query,RMQ),但不支持修改。
优点是代码短,且查询的时间复杂度是 。所以作为补充内容,附在此处。
class SparseTable:
# 时间复杂度 O(n * log n)
def __init__(self, nums: List[int], op: Callable[[int, int], int]):
n = len(nums)
w = n.bit_length()
st = [[0] * n for _ in range(w)]
st[0] = nums[:]
for i in range(1, w):
for j in range(n - (1 << i) + 1):
st[i][j] = op(st[i - 1][j], st[i - 1][j + (1 << (i - 1))])
self.st = st
self.op = op
# [l, r) 左闭右开,下标从 0 开始
# 返回 op(nums[l:r])
# 时间复杂度 O(1)
def query(self, l: int, r: int) -> int:
k = (r - l).bit_length() - 1
return self.op(self.st[k][l], self.st[k][r - (1 << k)])
# 使用方法举例
nums = [3, 1, 4, 1, 5, 9, 2, 6]
st = SparseTable(nums, max)
print(st.query(0, 5)) # max(nums[0:5]) = 5
print(st.query(1, 1)) # 错误:必须保证 l < r属于离线算法的一种。
通过改变回答询问的顺序,使问题更容易处理。
相应的,在线算法就是按照 的顺序一个一个处理。
部分题目是模拟题。
另见本题单的「§3.5 表达式解析」。
欢迎关注 B站@灵茶山艾府
如果你发现有题目可以补充进来,欢迎评论反馈。