分享丨【算法题单】常用数据结构(前缀和/栈/队列/堆/字典树/并查集/树状数组/线段树)
511744
2024.04.23
2025.11.14
发布于 未知归属地

数据结构题单 数据结构入门 数据结构新手教程 数据结构题目 力扣数据结构 leetcode数据结构 灵茶山艾府 灵神

零、常用枚举技巧

§0.1 枚举右,维护左

对于 双变量问题,例如两数之和 ,可以枚举右边的 ,转换成 单变量问题,也就是在 左边查找是否有 ,这可以用哈希表维护。

我把这个技巧叫做 枚举右,维护左

讲解

下面这些题目,如果可以,请用一次遍历实现。

§0.1.1 基础

§0.1.2 进阶

思维扩展

§0.2 枚举中间

对于有三个或者四个变量的问题,枚举中间的变量往往更好算。

为什么?比如问题有三个下标,需要满足 ,对比一下:

  • 枚举 ,后续计算中还需保证
  • 枚举 ,那么 自动被 隔开,互相独立,后续计算中无需关心 的位置关系。

所以枚举中间的变量更简单。

§0.3 遍历对角线

:有关前后缀分解的内容,见 动态规划题单 的「专题:前后缀分解」。

一、前缀和

§1.1 基础

讲解

左闭右开公式:下标为左闭右开区间 的元素和就是

思维扩展

§1.2 前缀和与哈希表

通常要用到「枚举右,维护左」的技巧。

讲解

前缀和与有序集合

思维扩展

§1.3 距离和

图解

§1.4 状态压缩前缀和

推荐先阅读:从集合论到位运算,常见位运算技巧分类总结!

§1.5 其他一维前缀和

思维扩展

§1.6 二维前缀和

【图解】一张图秒懂二维前缀和!

二维前缀最小值:

二、差分

§2.1 一维差分(扫描线)

差分与前缀和的关系,类似导数积分的关系。

数组 的差分的前缀和就是数组 (不变)。

原理讲解

§2.1.1 基础

§2.1.2 进阶

思维扩展

§2.2 二维差分

【图解】从一维差分到二维差分

三、栈

§3.1 基础

§3.2 进阶

§3.3 邻项消除

§3.4 合法括号字符串(RBS)

注:部分题目可以不用栈,而是用一个数字记录嵌套深度。

§3.5 表达式解析

§3.6 对顶栈

§3.7 单调栈

单调栈题单

四、队列

队列常用在 BFS 中,见 网格图题单图论题单。与此相比,栈常用在 DFS 中,但无需我们手动维护。

§4.1 基础

§4.2 设计

§4.3 双端队列

§4.4 单调队列

个人觉得叫单调双端队列更准确。

单调队列 = 滑动窗口 + 单调栈。必须先掌握滑动窗口和单调栈这两个知识点,再学单调队列。

:入队、出队、更新答案,这三步的顺序如何思考?

:有两种情况。如果更新答案时,用到的数据包含当前元素,那么就需要先入队,再更新答案;如果用到的数据不包含当前元素,那么就需要先更新答案,再入队。至于出队,一般写在前面,每遍历到一个新的元素,就看看队首元素是否失效(不满足要求),失效则弹出队首。

原理讲解

模板:

Python3
Java
C++
Go
# 计算 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」。

五、堆(优先队列)

§5.1 基础

为什么堆化的时间复杂度是 O(n)?

§5.2 进阶

有序集合

§5.3 第 K 小/大

部分题目也可以用二分解决。

§5.4 重排元素

§5.5 反悔堆

基于堆的反悔贪心。

§5.6 懒删除堆

支持删除堆中任意元素。

模板:

Python3
Java
C++
Go
# 模板来源 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

§5.7 对顶堆(滑动窗口第 K 小/大)

讲解

部分题目需要结合懒删除堆。

另见 图论题单 中的 Dijkstra 算法。

六、字典树(trie)

§6.1 基础

模板

§6.2 进阶

思维扩展

§6.3 字典树优化 DP

§6.4 0-1 字典树(异或字典树)

部分题目也可以用试填法解决。

七、并查集

模板:

Python3
Java
C++
Go
# 模板来源 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)] # 集合大小保存在代表元上

§7.1 基础

更多基础题,见 网格图题单 中的 DFS 和 图论题单 中的 DFS,其中大部分题目也可以用并查集实现。

§7.2 进阶

另见 图论题单 中的最小生成树。

§7.3 GCD 并查集

§7.4 数组上的并查集

§7.5 区间并查集

§7.6 带权并查集(边权并查集)

模板:

Python3
Java
C++
Go
# 模板来源 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

八、树状数组和线段树

能用树状数组解决的题目,也能用线段树解决(反过来不一定)。但树状数组实现简单,代码短。

为方便大家练习,我把适合用树状数组解决的题目分到树状数组中,其余分到线段树中。

§8.1 树状数组

讲解:带你发明树状数组!附数学证明

模板:

Python3
Java
C++
Go
# 模板来源 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)

§8.2 逆序对

除了可以用树状数组解决,部分题目也可以在归并排序的同时计算。

§8.3 线段树(无区间更新)

线段树本质是二叉树,在学习之前,建议先做做 104. 二叉树的最大深度111. 二叉树的最小深度(自底向上写法),当作热身。

线段树:为什么要这样设计? 理解线段树发明的动机。

把任意区间用 个区间表示,线段树的每个节点记录对应区间的信息。

  • 询问:把询问区间拆分成 个区间,对应着线段树的 个节点,把这 个节点的信息合并,即为答案。
  • 单点更新:有 个区间包含被修改的位置,需要更新 个节点的信息。

基础模板代码如下。为方便入门理解,我没有做复杂封装。通用模板代码可以参考 AtCoder Library 的 segtree.hpp

Python3
Java
C++
Go
# 模板来源 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)

思维扩展

§8.4 Lazy 线段树(有区间更新)

把任意区间用 个区间表示,线段树的每个节点记录对应区间的信息。

  • 询问:把询问区间拆分成 个区间,对应着线段树的 个节点,把这 个节点的信息合并,即为答案。
  • 区间更新:仍然是拆分成 个区间,对应着线段树的 个节点。但对于其中的非叶节点,不把更新的内容往下传递给子节点,而是记录「发生了更新,内容为 xxx」,把更新的内容记录下来。直到后续的询问或更新操作,需要访问或修改更下面的子节点信息时,才把更新的内容往下传。

基础模板代码如下。为方便入门理解,我没有做复杂封装。通用模板代码可以参考 AtCoder Library 的 lazysegtree.hpp

Python3
Java
C++
Go
# 模板来源 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)

§8.5 动态开点线段树

部分题目也可以用珂朵莉树解决。

§8.6 ST 表(Sparse Table)

ST 表 支持区间最值查询(Range Minimum/Maximum Query,RMQ),但不支持修改。

优点是代码短,且查询的时间复杂度是 。所以作为补充内容,附在此处。

Python3
Java
C++
Go
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

九、伸展树(Splay 树)

十、根号算法

§8.1 根号分解(Sqrt Decomposition)

§8.2 莫队算法

属于离线算法的一种。

§8.3 其他

专题:离线算法

通过改变回答询问的顺序,使问题更容易处理。

相应的,在线算法就是按照 的顺序一个一个处理。

专题:比较复杂的题目

部分题目是模拟题。

Part A

Part B

另见本题单的「§3.5 表达式解析」。

关联题单

算法题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

如果你发现有题目可以补充进来,欢迎评论反馈。

评论 (295)