GLFLS课程:线段树进阶

主要前置知识:线段树,动态开点。

线段树1的模板应该都还记得吧。忘记了可以戳这里

线段树合并与分裂

线段树合并

两个权值线段树 \(T_1\)\(T_2\) 的合并是一个递归的过程。我们不妨设要合并的子树为 \(x\)\(y\),其对应区间均为 \([l,\ r]\) 那么分类讨论如下:

  \((1)\) 首先若 \(x = 0\)\(y = 0\),则 \(x\)\(y\) 中有空节点,直接返回 \(x + y\) 即可。

  \((2)\) 再将 \(x,\ y\) 的左和右儿子分别合并,令合并后的节点编号分别为 \(p,\ q\)

  \((3)\)\(y\) 的左儿子设为 \(p\),右儿子设为 \(q\),完成合并。返回 \(y\)

特别的,当 \(x\)\(y\) 为叶子时,直接合并信息。在合并过程中,需要实时 pushdown/spreadpushup

线段树合并的时间复杂度与删去的节点数同级。

\(Example\ code:\)

int merge(int x, int y, int pl, int pr){
    if(!x || !y) return x | y; // or x + y
    if(pl == pr){
        tree[y].sum += tree[x].sum;
        return y;
    }
    int mid = (pl + pr) / 2;
    tree[y].ls = merge(tree[x].ls, tree[y].ls, pl, mid), tree[y].rs = merge(tree[x].rs, tree[y].rs, mid + 1, pr);
    pushup(y);
    return y;
    // Due to single update, pushdown isn't required.
    // If your func isn't a single update one, DO remember to pushdown!
}

\(Template: P4556:\)

我们首先不难发现,使用离散化的朴素做法的空间&时间复杂度均为 \(O(NM)\),刚好过不了这题,哈哈。

因此我们采用树上差分的技巧。每次操作相当于将 \(z\) 覆盖在 \(x\)\(y\) 的所有路径上。使用数组 \(b\) 作为差分计数数组,则每次维护操作的实现通过下式完成。

\[{\large b_{x,\ z}++,\ b_{y,\ z}++;} \]

\[\large {b_{lca(x,\ y),\ z}--,\ b_{fa(lca(x,\ y)),\ z}--} \]

若原来的 \(c\) 表示 \(b\) 的子树和,且 \(x\) 的子节点为 \(s_1,\ s_2,\ ...,\ s_k\),那么 \(c_x\)\(c_{s_1},\ c_{s_2},\ ...,\ c_{s_k}\)\(b_x\) 对应相加后得到。

想想上面这一步要如何优化?

用一棵动态开点的权值线段树代替 \(b_x\),且支持修改,维护区间最大值与其位置。在进行完 \(M\) 次操作后,对线段树跑一次 dfs,并运用线段树合并求子树和。空间&时间复杂度均为 \(O((N + M)\log(N+M))\)

事实上,这道题 TZYLT 用树剖写,跑得比我线段树合并还快。呃呃。

线段树分裂

我们将权值线段树中前 \(k\) 小的数与其余数分在两棵权值线段树上,可以维护原线段树合并所维护的可重集。

函数 split(x, y, k) 分裂线段树上以 \(x\) 为根节点的子树,另一棵线段树为 \(y\),其中定义 \(v\ =\ val_{ls(x)}\)。分类讨论如下:

  \((1)\)\(v < k\),左端无需修改,直接执行 split(rs(x), rs(y), k - v)

  \((2)\)\(v = k\),即 \(x\) 的左子树正好包含前 \(k\) 个,于是将右子树归给 \(y\),即 rs(y) = rs(x), rs(x) = 0;

  \((3)\)\(v > k\),右子树全部大于 \(k\),直接归给 \(y\),接着递归左子树,执行 split(ls(x), ls(y), k)

单次时间复杂度为 \(O(\log n)\)

\(Template:\ P5494\)

线段树维护区间最值和历史最大值

经典例题:Gorgeous Sequence (hdu 5306)

一个长度为 \(n\) 的序列 \(a\), 做 \(m\) 次操作,操作有以下 3 种:
$0\ L\ R\ x $ 即对于 \([L,\ R]\) 区间内的每个 \(a_i\), 用 \(min(a_i,\ x)\) 替换;
\(1\ L\ R\) 即输出 \([L,\ R]\) 区间内的最大值 \(a_i\)
\(2\ L\ R\) 即输出 \([L,\ R]\) 区间内所有 \(a_i\) 的和。

对于线段树的每一个节点,我们需要定义标记:区间和 sum, 区间最大值 maxn, 最大值个数 num严格次大值 submaxn

做区间最值的第 0 种修改操作, 用 min(a[i], x) 替换区间 \([L,\ R]\) 中的每一个 \(a_i\)

我们对区间的每一个节点进行剪枝搜索,对于某个节点,我们分以下3类进行讨论:

  \((1)\) 当 $maxn \le x $ 时,这次不产生修改,退出。

  \((2)\)\(submaxn < x < maxn\) 时,这次修改会影响最大值,更新 sum = sum - t(maxn - x)maxn = x,打上 lazy_tag 后退出。

  \((3)\)\(submaxn \ge x\) 时,无法直接更新,只能递归其左右儿子。

\(Template:\ P6242.\)

这种线段树又叫吉如一线段树 或 吉司机线段树,貌似并不常考,大可酌情练习。

可持久化线段树

应用场景:需要记录不同历史节点的数据状态,且各个历史副本的差异并不大,因此只需要记录改动的场景适合可持久化。

\(Template:\ P3834:\)

为什么这道题不能直接用线段树维护?想一想线段树可以维护的操作的共同特征。

对于一个长度为 \(n\) 的序列, 我们先将序列离散化,离散化后的元素值为 \(1 ~ n\)。离散化不影响查询第 \(k\) 小元素(显然)。

对于每一个 \(i\),都建立一棵区间为 \([1,\ i]\) 的新段数,共 \(n\) 棵树。查询每个 \([1,\ i]\) 区间内的第 \(k\) 小元素,复杂度都为 \(O(\log n)\)。每个节点的权值表示这个区间内有多少个元素,以及他们在哪些子树上。叶节点的序号即元素的值。

画画图,发现每一棵树只与上一棵树只有从根节点到叶节点的链上的节点不同。

查询实现:

首先考虑查询 \([1,\ i]\) 中的第 \(k\) 小元素。发现查询顺序是从根节点直接走到要查询的叶节点。

考虑查询 \([L,\ R]\) 中的第 \(k\) 小元素:我们首先需要得到 \([L,\ R]\) 的线段树。使用前缀和的思想, \([L,\ R]\)\([1,\ R]\) 减去 \([1,\ L - 1]\)。具体实现为把两棵结构完全相同的树上对应的节点的权值相减,然后查询其第 \(k\) 小元素。查询方式同上 \([1,\ k]\) 中查询操作。

执行一次操作的时间复杂度为 \(O(\log n)\)。如果直接建立 \(n\) 个线段树,每个线段树需要 \(O(n)\) 的空间,总共需要 \(O(n^2)\) 的空间。这并不可接受,因此我们需要减少存储空间。

我们不难发现,每两棵线段树间的差别只有 \(O(\log n)\) 个节点,只需要存储这部分的节点就足够了,并把新建的节点指向前一个树的子节点,因此 \(n\) 个线段树的总空间复杂度为 \(O(n\log n)\)

\(\large{\mathit{EOF}}\)

posted @ 2024-07-08 21:10  QianXiquq  阅读(62)  评论(0)    收藏  举报