NOIP 一轮复习 I: 数据结构

目录

  • 线段树
  • 猫树
  • 树状数组

阅读须知:

  1. 作者数据结构水平不高,作为整个 NOIP 一轮复习的开篇,作者希望在轻松愉快的氛围下开启漫长艰难的复习。
  2. 文章中对于基础知识简单的,不会有太多介绍,主要以记录 trick 以及攥写题解为主,为作者的高一零基础OI生涯开一个好头。
  3. 看得开心

0. 写在前面

其实作者觉得,大部分在 OI 赛场上遇到的数据结构题,难点不在于数据结构的实现,而在于如何把问题转化为可以用数据结构解决的问题,并找到适配的手段。所以我的想法是,把特征与做法系统性的相对应,让数据结构不再成为解决问题的瓶颈。

1. 线段树

线段树是 OI 中最常见的基础数据结构,本节下仅总结技巧与应用。

1.1 线段树的标记维护

什么样的标记能够用线段树维护呢?通常要满足这样三个条件:

  1. 对于一次区间修改,若要对某个节点打上标记,能够快速更新该节点的信息(update & pushdown)
  2. 标记具有结合律,即在知晓前后顺序的情况下能够合并(pushdown)
  3. 对于所有无标记的节点,能够根据两个子节点的信息,推出当前节
    点的信息(pushup)

e.g. 对于区间加、查询区间和的问题,其三个条件均满足,则可以使用
线段树维护;对于区间加并维护最大子段和问题,注意到区间加后,最大前缀与后缀不是能够快速维护的信息,即不满足1,所以无法扩展不带修时的方法维护。

在生产生活中我们常常会用到复杂标记的维护(例如维护历史和的线段树),虽然我们知道满足结合律的标记都是可以用线段树维护的,但是复杂标记的转移与维护往往并不显而易见,甚至让人摸不着头脑。好在,如果维护的操作对实际值的影响是一个线性变换,我们可以尝试用矩阵乘法的方式,在线段树维护标记矩阵,从而减少思考量。

1.1.1 [THUSC 2017] 大魔法师

注意到在每个节点内,我们需要维护的信息有:\(S_a, S_b, S_c\) 以及非常多的加/乘标记,(对于赋值操作,等价于先乘 \(0\) 后再加 \(v\))。我们不妨考虑矩阵乘法:注意到对于一个区间 \([l, r]\) 来说,每次区间 \(+v/\times v/=v\) 对于 \(s\) 的影响仅跟区间长度 \(len\) 有关,所以我们猜想,每个点的信息矩阵,仅需要包含 \(S_a, S_b, S_c, len\) 即可。

(以下计算方式,均默认标记矩阵 \(\times\) 信息矩阵,规定了计算顺序)

\[\begin{bmatrix} S_a \\ S_b \\ S_c \\ len \end{bmatrix} \]

然后我们接着来分析每一个操作:

1-3 操作:此部分操作本质相同,例如对于操作1,矩阵容易写出:

\[\begin{bmatrix} 1& 1& 0& 0\\ 0& 1& 0& 0\\ 0& 0& 1& 0\\ 0& 0& 0& 1 \end{bmatrix} \begin{bmatrix} 1& 0& 0& 0\\ 0& 1& 1& 0\\ 0& 0& 1& 0\\ 0& 0& 0& 1 \end{bmatrix} \begin{bmatrix} 1& 1& 0& 0\\ 0& 1& 0& 0\\ 1& 0& 1& 0\\ 0& 0& 0& 1 \end{bmatrix} \]

4 操作:即令 \(S_a + len \times v\)

5 操作:注意到 \(b_lv + b_{l+1}v+\dots +b_rv=v(b_l+b_{l+1}+\cdots+ b_{r})=S_bv\)

6 操作:即为 \(S_c\times 0+len\times v\)

\[\begin{bmatrix} 1& 0& 0& v\\ 0& 1& 0& 0\\ 0& 0& 1& 0\\ 0& 0& 0& 1 \end{bmatrix} \begin{bmatrix} 1& 0& 0& 0\\ 0& v& 0& 0\\ 0& 0& 1& 0\\ 0& 0& 0& 1 \end{bmatrix} \begin{bmatrix} 1& 0& 0& 0\\ 0& 1& 0& 0\\ 0& 0& 0& v\\ 0& 0& 0& 1 \end{bmatrix} \]

矩阵归纳操作的强大能力,使得大多数操作都可以被顺利解决。

需要说明的是,虽然用矩乘维护标记的思考量大大减少了,但是若矩阵维护的信息大小为 \(k\),总复杂度为 \(O(k^3n\log n)\),我们有这样一些技巧能够降低常数:

  1. 矩乘时修改顺序
inline mat operator* (const mat& b) const {
		mat c;
		 for(int i = 0; i < 4; ++ i)
		 	for(int k = 0; k < 4; ++ k)
				if(g[i][k]) 
					for(int j = 0; j < 4; ++ j) c.g[i][j] = add(c.g[i][j], 1LL * g[i][k] * b.g[k][j] % mod);
		return c;
	}

我们把 ijk 的顺序换为 ikj 的顺序并加上特判后,速度明显的快了。

  1. pushdown 时,特判单位矩阵
  2. 注意到我们维护的信息矩阵通常是一个 \(k \times 1\) 的向量,可以通过手写向量 \(\times\) 矩阵的方式减小常数。
  3. 有的时候把矩乘内部循环展开,可能会有奇效。
  4. 如果实在怎样都卡不过去,如 [NOIP2022] 比赛,就只能手写矩阵的全部转移,并观察有哪些是无效转移(例如必为 \(0\) 的位置),复杂度就变成了与常规方法相同。(所以矩乘也不失为一种分析转移的方法。)

最后,不要忘了在 build 时把每个节点的标记矩阵都初始化为单位矩阵。

1.2 动态开点

当使用线段树合并或值域范围很大(权值线段树)时,我们常常用动态开点的方式代替 u << 1 | 1, u << 1

当然,动态开点还有一些别的好处。

1.2.1 AT_soundhound2018_summer_final_e Hash Swapping

1.3 标记永久化

一种可以实现标记下传的方法。

我们在修改/查询时,按着懒标记不下传,而是在查询区间 \([l, r]\) 时,把信息与路径上所有的懒标记进行合并取答案。以区间取 min,区间求 min 为例:我们在每个节点维护 \(v\) 表示当前节点及子树内的最小值,再在查询过程中维护 \(lazy\) 表示路径上所有节点标记的最小值(因为只有这些节点的懒标记会对 \([l, r]\) 造成影响。)那么答案就是 \(\min(v, lazy)\)

那么,我们还需要知道,怎样的信息可以用标记永久化维护?

考虑依次打下三个标记 \(a, b, c\),不妨设想其中 \(a, c\) 打在根节点,\(b\) 打在非根,那么当我们从根开始访问时,合并懒标记时的顺序为 \(a, c, b\),而为了保持答案不变,标记必须拥有交换律

e.g. 上文提到的加法/区间取 max /区间取 min 都是具有交换律的例子;赋值、矩乘都不具有交换律。

1.4 线段树二分

听起来像是二分+线段树,实际上也是用在线段树上二分的方法,代替二分+线段树,所以当你意识到你在写二分+线段树时,不妨想想有没有什么方法转化为线段树二分。

维护一个仅包含 \(0,1\) 的序列,带修,每次查询第 \(k\)\(1\) 的位置。

每次可以二分位置+你喜欢的数据结构查询前缀和,但是最好也是 \(O( \log^2 n)\)。我们注意到,线段树本身就是一种”二分“的结构,所以直接在线段树二分似乎也能解决问题。例如在上面的问题中,我们设 solve(u, l, r, k) 返回在 \([l, r]\) 区间内第 \(k\)\(1\) 的位置,那么设 \([l, mid]\)\(1\) 的数量为 \(k_1\),则当 \(k \le k_1\) 时,答案必在 \([l, mid]\) 中,反之等价于在 \([mid+1,r]\) 中第 \(k - k_1\) 的位置,注意到两个问题具有相同的子结构,递归即可,复杂度变为 \(O(\log n)\)

另外,既然线段树二分代替的是“二分+线段树”,显然求解的问题一般也要满足单调性

1.5 线段树合并

在需要处理子树信息,并合并多棵子树的复杂信息时,常使用线段数合并。

合并两棵线段树的过程十分暴力:设 merge(x, y) 表示合并 \(x, y\) 两棵子树,那么:

  • \(x, y\) 其一为空时,返回另一节点
  • \(x, y\) 为叶子,直接合并
  • merge(ls[x], ls[y]), merge(rs[x], rs[y]),再合并当前节点。

1.5.4 P2824 [HEOI2016/TJOI2016] 排序

二分+线段树的 \(O(n\log^2 n)\) 做法就不再赘述了,下面做法来自 ix35 的博客。

首先我们考虑把整个序列划分成若干有序段,对于每一个有序段用线段树维护。为什么这样做?我们的线段树只能维护值域而无法维护顺序(或像权值线段树那样维护了顺序就不能维护值域。),所以如果我们确定了顺序,那么就可以通过线段树还原序列。接下来对于每次操作 \([l, r]\),我们先找到 \(l, r\) 所在段的线段树,然后把 \([l, l 所在段右端点]\), \([r 所在段左端点, r]\) 分裂出来,然后把中间的段全部合并。注意到我们每次操作最多只会分裂 \(2\) 段,所以分裂次数为 \(O(n)\),创造的新节点数量即为 \(O(n \log n)\),那么复杂度即为 \(O(n \log^2 n)\)。(存疑)

1.6 李超线段树

李超线段树用来维护 \(n\) 条形如 \(y = kx + b\) 的线段,并支持查询给定 \(x\) 时最大/最小的 \(y\)

具体来说,李超线段树在每个节点上维护一个最优线段,即在当前区间 \([l, r]\) 的中点 \(mid\) 处最优的线段,维护的时候遵守这两条原则

  • 每个线最多作为一个节点的最优线段
  • 若查询 \(i\),答案必须在 \([i, i]\) 及其祖先节点维护的最优线段中。

其实很类似标记永久化的思想。

假设当前节点的最优线段为 \(L_0\),插入的线段为 \(L_1\),那么修改过程参照以下流程:

  1. 比较 \(L_0, L_1\)\(mid\) 处的取值,若 \(L_1\) 更优则直接交换。
  2. 向下递归插入,若在左端点处更优则递归插入左儿子;若在右端点处更优则递归插入右儿子。

代码大概长这样:

inline void modf(int u, int l, int r, int p) {
    int mid = l + r >> 1;
    if(a[p](mid) < a[v[u]](mid)) swap(v[u], p);
    if(l == r) return ;
    if(a[p](l) < a[v[u]](l)) modf(u << 1, l, mid, p);
    if(a[p](r) < a[v[u]](r)) modf(u << 1 | 1, mid + 1, r, p);
  }

注意到,下面两个 if 最多只会进入一个,这是复杂度为 \(O(n \log n)\) 的保障。

如果李超线段树维护的是线段,我们可以动态开点,然后把线段的 \(x\) 按照分成 \(\log\) 段,在每段内依次插入,复杂度是 \(O(\log^2n)\) 的。

1.7 可持久化线段树

实际应用中,存在这样一类问题:在对数据结构进行过修改之后,还需要访问以前版本的内容。所有可持久化数据结构都是为了解决这些问题。下文把可持久化版本的线段树简称为主席树。主席树的维护方法很直观,既然要访问以前版本的内容,那么不妨直接把原本的线段树保留下来,对于新版本只保留与原版本不同的地方即可。以单点修改为例,信息会改变的节点仅仅是一条从根到叶子的链,我们新建一条链保存改变的信息就实现了可持久化。

主席树还适用于解决这样的问题:维护大量的信息,但是之间的差距很小,例题中会提到这样的问题。

那么,如果想要对主席树进行区间修改呢?

1.7.1 SP11470 TTM - To the moon

如何对主席树进行区间修改呢?答案是不修改,我们利用上文提到的标记永久化的技巧,只在询问时计算答案即可。

1.7.2 CF464E The Classic Problem

最短路的部分该怎样就是怎样,重点在于如何维护 \(dist\),既然权值都是 \(2^x\),那么在二进制视角下,加法带来的变化实际上就是把一段连续的 \(1\) 推平为 \(0\),然后再将一位置为 \(1\)。然而,在维护最短路的过程中,我们还需要支持比较,不妨用线段树维护,在两棵线段树上比较是简单的,维护哈希值之后线段树二分即可。

每个点开一棵线段树肯定做不到,但注意到 \(dist_v\) 仅由 \(dist_u + w\) 生成,所以用主席树维护即可。考虑操作,单点赋 \(1\) 并不难,但是怎么实现区间推平为 \(0\) 呢。我们发现推平之后主席树的结构是固定的(都是推平为 \(0\)),所以这里的技巧是建一棵全 \(0\) 的树,然后修改直接将对应节点的儿子接到空树上即可。实现细节比较多。

这里有一个小细节,之前会但是现在有点忘了顺便记一下:当定义 stl 小根堆时,我们调用了 std::greater<>,这个比较内部使用的是 \(>\),所以重载时也需要重载大于号而不是小于号。


另外一类关于主席树的常见用法就是单纯作为线段树(一般是权值线段树)的前缀和,配合差分使用。

1.7.3 CF893F Subtree Minimum Query

也是个不算特别常用,但是没见过不一定能想到的技巧。这种关于深度/深度差的东西,可以考虑在深度上建主席树,然后子树内这个限制是好刻画的。

1.7.4 P4137 Rmq Problem / mex

莫队的做法就不赘述了。说说在线的主席树,关于 mex,我们常用到的关键词有不单调性、上一次以及下一次出现位置。从此出发,可以想到区间 mex 的本质就是\(r + 1\) 之前,上一次出现的位置在 \(l\) 之前的最小值,基于这个想法,我们在每个下标处维护一棵线段树,保存每个值上一次出现的位置。那么查询亦是简单的,在 \(r\) 处的线段树上二分即可。
进一步的,我们注意到对于 \(i\)\(i - 1\) 处的线段树,只有 \(w_i\) 处的值有差距,进而想到用主席树维护,时间复杂度 \(O(n\log n)\)

1.8 线段树优化建图

需要线段树优化优化建图的题,至少我见过的里面都很明显。有这样一类问题:从点向区间连边、从区间向点连边,从区间向区间连边。前两种都能归纳成第三种,有虚点等方式能把复杂度从 \(O(n^2)\) 变成 \(O(n)\),但是还不够。

这个 trick 我也觉得没见过想不出来(,我们考虑把区间放到线段树上,拆成 \(\log n\) 段区间,然后依然建虚点,复杂度就变成了 \(O(\log n)\)
具体来说,我们维护一棵入树和出树,入树满足可以从大区间走到小的子区间;出树满足可以从小区间走到大的父区间。这很好理解,毕竟如果能进入 \([l, r]\),自然能进入 \([l, mid], (mid, r]\),能从 \([l, mid]/(mid, r]\) 出发,那么 \([l, r]\) 享用的出边他们也满足条件。

记得连接出入树的叶子节点,显然自己总是可以走到自己。

1.9 线段树分治

当我们需要维护一种操作,且这种操作添加容易,但删除却很难时,不妨把操作全体离线下来,对于其中一个元素存在的时间段 \([l, r]\),把它拆分成线段树上的 \(\log n\) 段区间之后,把对应的添加操作放到线段树上,表示在该点的子树内表示的所有时刻中,都有该操作存在。
对于每个点上的查询,我们直接遍历整棵线段树,每当走到一个节点时,插入该节点上维护的所有标记,向下遍历,回溯退出时再撤销插入的所有操作。

这样一来,删除变成了撤销,我们得以不通过删除维护删除操作。

2. 猫树

我更倾向于这是一种线段树的平替,用来平衡当 \(n, q\) 不同阶时的复杂度。猫树是一种离线算法,当维护信息可并,且没有修改时,可以将 \(O(n + q \log n)\),变成 \(O(n\log n + q)\),某些时候也许有奇效。
以最大子段和为例,具体来说,我们对整个序列分治,设当前分治区间为 \([l, r]\),分治中点为 \(mid\)。我们对 \(i \in [l, mid]\) 的每个点预处理出 \([i, mid]\) 的最大后缀;对于 \(i \in (mid, r]\),预处理出 \((mid, i]\) 的最大前缀,对于跨过当前中点的答案即可 \(O(1)\) 求出,否则递归到两侧。

猫树其实还有几个应用,但我觉得大概率用不到,所以就不提了。

3. 树状数组

树状数组的本质其实是,一棵仅保留根以及所有节点左儿子的线段树,在时间以及空间复杂度上比之线段树具有一定优势,但是无法处理复杂信息,因此作者并不打算深入记录树状数组。

3.1 时间戳优化

话说这个优化还是我自己想出来的,后来才知道很常用。具体来说,当处理多组数据,且每组数据需要清空树状数组时,常引入时间戳代替清空。

3.2 建树优化

注意到既然是线段树,那么每个节点的父亲是唯一的,如果我们从儿子向父亲贡献初始信息,就能做到 \(O(n)\) 建树。


以上就是数据结构 I 的全部内容了,好多地方删了又改改了又删,但总归我自己看得过去。

reference

posted @ 2025-07-23 19:08  Rainsheep  阅读(192)  评论(0)    收藏  举报