【数据结构】- 线段树

线段树

简介

线段树是可以维护 区间信息 的数据结构。线段树将每个长度不为 \(1\) 的区间划分成左右两个区间递归求解,故把整个线段划分为一个树形结构,通过合并左右两区间信息来求得该区间的信息。

image

有动态开点线段树、权值线段树和李超线段树、吉司机线段树和可持久化线段树等变体。

普通线段树

结构

\(2id\) 表示左儿子, \(2id+1\) 表示右儿子,建成完全二叉树。

对于树上每个节点用结构体维护管辖区间端点(写了可减小码量)、区间信息。这里以维护区间加、区间乘操作为例。

#define lid (id<<1)
#define rid (id<<1|1)
#define mid (tr[id].l+tr[id].r>>1)
struct seg_tree{
    int l, r;
    int sum, add, mul;
} tr[N*4];

性质

需要开到 \(4n-5\) 的数组存树。

操作

建树

对左右递归建树。

void build(int id, int l, int r) {
    tr[id].l = l; tr[id].r = r;
    if (l == r) {
        tr[id].sum = a[l];
        return;
	}
    build(lid, l, mid);
  	build(rid, mid+1, r);
    pushup(id);
}

上传

递归回来后用子区间信息更新父区间。

void pushup(int id) {
    tr[id].sum = tr[lid].sum + tr[rid].sum;
}

修改&下传

对一段区间操作时不难发现可以找到几个极长区间做到其并集刚好覆盖这个区间而其交集为空。然后在这些区间上打上懒标记(\(lazy\))表示对这些区间及其下辖区间有修改操作。这样就不用递归到长度为 \(1\) 的区间再向上维护了,只有需要访问下辖区间时再下传修改操作影响。所以对于懒标记的理解其实就是一段操作序列集成在几个标记上。将子节点的标记与父节点标记合并后就等于将父节点修改序列接在了子节点已有序列之后以计算总影响。

void update(int id, int mul, int add) {
	tr[id].sum = tr[id].sum * mul + (tr[id].r - tr[id].l + 1) * add;
    tr[id].add = tr[id].add * mul + add;
	tr[id].mul = tr[id].mul * mul;
}
void pushdown(int id) {
    update(lid, tr[id].mul, tr[id].add);
    update(rid, tr[id].mul, tr[id].add);
	tr[id].add = 0;tr[id].mul = 1;
}
void modify(int id, int l, int r, int add, int mul) {
	if (tr[id].l >= l && tr[id].r <= r) {
		update(id, mul, add); return;
	}
	pushdown(id);
	if (l <= mid) modify(lid, l, r, add, mul);
	if (r > mid) modify(rid, l, r, add, mul);
	pushup(id);
}

查询

将查询区间拆到各个最大分管区间后合并各区间信息。

int query(int id, int l, int r) { //如果不存管辖区间就要带着当前区间的s、t
    if (tr[id].l >= l && tr[id].r <= r) return tr[id].sum;
    pushdown(id);
    int res = 0;
    if (l <= mid) res += query(lid, l, r);
    if (r > mid) res += query(rid, l, r);
    return res;
}

例题

动态开点线段树

结构

为了节省空间,我们只对访问到的节点建点。此时就应该更改左右儿子表示方式,直接记录左右儿子的下标。代码变动很小,将#definelid、rid改为ls[id]、rs[id]表示即可。有时需要进行节点回收,防止重复占用。

#define m (s+t>>1)
int ls[N], rs[N], sum[N], tot;
int rub[N], cnt; //回收站
void del(int &x) {
    ls[x] = rs[x] = sum[x] = 0;
    rub[++ cnt] = x; x = 0;
}
int ins() {return cnt ? rub[cnt --] : ++ tot;}

性质

单次操作的时间复杂度是不变的,为 \(O(\log n)\)。由于每次操作都有可能创建并访问全新的一系列结点,因此次 \(m\) 单点操作后结点的数量规模是 \(O(m\log n)\)。最多也只需要个 \(2n-1\) 结点,没有浪费。

操作

修改

void modify(int& id, int s, int t, int l, int r, int v) {  // 一切以空间为主,所以就带着管辖区间端点递归了
    if (!id) id = ins();  // 当结点为空时,创建一个新的结点
    if (s >= l && t <= r) {sum[id] += v; return;}
    if (l <= m) modify(ls[id], s, m, l, r, v);
    if (r > m) modify(rs[id], m+1, t, l, r, v);
    pushup(id);
}

查询

int query(int id, int s, int t, int l, int r) {
    if (!id) return 0;
    if (s >= l && t <= r) return sum[id];
    int ans = 0;
    if (l <= m) ans += query(ls[id], s, m, l, r);
    if (r > m) ans += query(rs[id], m+1, t, l, r);
    return ans;
}

有时要对同一值域维护多棵线段树,然后操作这些线段树里的信息。常在权值线段树上进行,需要先记录下每棵线段树的根。

int rt[N], idx;

合并

形象地说,合并过程就是将两棵线段树「重叠」起来得到一棵新的线段树。

//rt[a] = merge(rt[a], rt[b], 1, n);
int merge(int a, int b, int s, int t) {
	if (!a || !b) return a | b;
	if (s == t) {sum[a] += sum[b]; del(b); return a;}
	ls[a] = merge(ls[a], ls[b], s, m);
	rs[a] = merge(rs[a], rs[b], m+1, t);
	pushup(a); del(b);
	return a;
}

分裂

可以合并就能分裂。即将一段区间从总区间里「抽出」,从 \(1\) 节点往下递归找交集即可。

//split(rt[a], rt[++ idx], 1, n, l, r);
void split(int &a, int &b, int s, int t, int l, int r) {
    if (!a) return;
	if (t < l || r < s) return; //判交集
	if (l <= s && t <= r) {b = a; a = 0; return;}
  	if (!b) b = ins();
  	if (l <= m) split(ls[a], ls[b], s, m, l, r);
  	if (r > m) split(rs[a], rs[b], m+1, t, l, r);
  	pushup(a); pushup(b);
}

例题

可持久化(权值)线段树

结构

可持久化线段树,是一种完全可持久化数据结构。可持久化数组基于可持久化线段树。

可持久化权值线段树又名主席树,也是一种可持久化数据结构。可持久化并查集基于可持久化权值线段树。

所以存的不管是权值还是序列,都是可持久化线段树。数组只是借助了线段树的结构保持 log 的时空复杂度,也就相当于没有任何上传/下传操作的序列线段树。

相比动态开点线段树,除了需要开rt[N]、idx存储每个版本 \(idx\) 的根节点与ls[]、rs[]、sum[]/val[]这些数组需要开大些外别无二异。

int rt[N], idx, ls[N<<5], rs[N<<5], sum[N<<5], val[N<<5], tot;

性质

其可持久化方法为记录每一次修改时的根节点,并且对被更改的那条 \(\log n\) 的链单独开点储存,其他部分直接承袭前一个版本后便记录下了这个历史状态。依赖于动态开点线段树,而主席树常用于权值线段树中询问区间第 \(k\) 小。

操作

建树

rt[0]初始版本的构建也是很重要滴。

void build(int &id, int s, int t) {
	id = ++ tot;
	if (s == t) {
        //val[id] = ...
        return;
    }
	build(ls[id], s, mid);
	build(rs[id], mid+1, t);
}

修改

与动态开点线段树不同在于这里是强制性++ tot,因为虽然同一条链上每个节点管辖区间、父子关系不变,但对于权值线段树,节点的sum[]值改变了,对于序列线段树,叶子节点的val[]值改变了。

//以修改序列为依次将第i个数改为x为例,modify(rt[i], rt[i-1], 1, n, x)
//序列线段树直接modify(rt[++ idx], rt[p], 1, n, x, v)
void modify(int &id, int tmp, int s, int t, int x) {
	id = ++ tot;
	ls[id] = ls[tmp], rs[id] = rs[tmp]; sum[id] = sum[tmp] + 1;
	if (s == t) return;
	if (x <= mid) modify(ls[id], ls[tmp],  s, mid, x);
	else modify(rs[id], rs[tmp], mid+1, t, x);
}

查询

注意,询问某区间时要通过两端点对应状态的节点存值相减得出此区间信息。

//以查询第k小数为例,query(rt[r], rt[l-1], 1, n, k)
//序列线段树直接query(rt[q], 1, n, x)
int query(int x, int y, int s, int t, int k) {
	if (s == t) return s;
	int all = sum[ls[x]] - sum[ls[y]];
	if (k <= all) return query(ls[x], ls[y], s, mid, k);
	else return query(rs[x], rs[y], mid+1, t, k-all);
}

例题

猫树

能高速查询,接触不多,不忙着学,先鸽了(

李超线段树

结构

李超线段树作为一种特殊的线段树,其维护信息为平面上横坐标在一定范围内的一段崎岖不平的「表面」。

image

形式化描述为:给定一个数 \(k\),维护与直线 \(x=k\) 相交的线段中,交点纵坐标最大的线段的编号。

线段树结构与普通线段树差别不大,需要存储的信息如下:

#define m (s+t>>1)
const double eps = 1e-9;
int f[N*4];
double calc(int line, int x) {return k[line] * x + b[line];}
int cmp(double x, double y) {return x - y > eps ? 1 : (y - x > eps ? -1 : 0);}

性质

这种问题有如下解法:记录懒标记为除开祖先节点已做标记的线段外,完全覆盖此节点区间且与区间的「中线」交点的纵坐标最大的线段,表示该线段可能作为询问其下辖区间时的答案。

这其实是一种标记永久化技巧,在查询至「底部」的过程中不断比较、更新答案线段即可。

而在插入一条线段时,要对一个区间进行标记。没有标记就将这条线段打上就好。否则就需要处理这样两条线段:已作标记的 \(f\) 和待做处理的 \(g\)。当然,这样定义的懒标记无法合并,其实也无需合并,得益于标记永久化,我们现在只需要考虑如何正确地向下递归更新标记。大体思路如下图:

image

如果在中点处旧标记 \(f\) 不如 \(g\)\(swap\) 一下,使得我们总是在用更「糟」,可覆盖区间更小的线段讨论向下更新。

对于现在的 \(f\)\(g\),按照定义,先将 \(f\) 置为当前区间懒标记,再考虑 \(g\) 如何向下递归:

如果两条线段相交,呈“✕”形,则 \(g\) 只能在区间一侧更优(上图例子中为右侧)。具体在哪一侧取决于区间哪一个端点上 \(f\)\(g\) 更劣。递归到对应子区间即可。

如果两条线段不交,呈“⇉”形,则 \(g\) 不可能成为答案,直接返回。

操作

修改&下传

void update(int id, int s, int t, int g) {
    int res = cmp(calc(g, m), calc(f[id], m));
    if (res == 1 || (!res && g < f[id])) swap(g, f[id]);
    int bl = cmp(calc(g, s), calc(f[id], s)),
        br = cmp(calc(g, t), calc(f[id], t));
    if (bl == 1 || (!bl && g < f[id])) update(lid, s, m, g);
    if (br == 1 || (!br && g < f[id])) update(rid, m+1, t, g);
}
void modify(int id, int s, int t, int l, int r, int g) {
    if (s >= l && t <= r) {
        update(id, s, t, g); return;
    }
    if (l <= m) modify(lid, s, m, l, r, g);
    if (r > m) modify(rid, m+1, t, l, r, g);
}

查询

pair<double, int> Max(pdi x, pdi y) {
    if (cmp(x.first, y.first) == -1) return y;
    if (cmp(x.first, y.first) == 1) return x;
    return x.second < y.second ? x : y;
}
pair<double, int> query(int id, int s, int t, int x) {
	double res = calc(f[id], x);
	if (s == t) return {res, f[id]};
	if (x <= m) return Max({res, f[id]}, query(lid, s, m, x));
	else return Max({res, f[id]}, query(rid, m+1, t, x));
}

例题

吉司机线段树

结构

其实就是比维护加、求和操作的普通线段树多用了亿些懒标记。

struct seg_tree {
	int l, r;
	int mx, se, sum, his, cnt;
	int lzy_mx, lzy_se, lzy_his, lzy_res;
} tr[N*4];

在历史最值中还有区间赋值与区间加操作同时出现的情况。节点可以维护如下信息:

struct seg_tree{
	int l, r;
	int mx, his;
	int lzy, lzy_his, tag, tag_his;
	bool vis;
} tr[N*4];

性质

是支持区间最值与历史最值操作的线段树。

区间最值是将区间 \([l,r]\) 的元素对给定的 \(x\)\(\min/\max\)。如区间取 \(\min\),意味着操作的对象是这个区间中大于 \(x\) 的数。

则有如下解法:每个结点维护该区间的最大值 \(mx\)、次大值 \(se\)、区间和 \(sum\) 以及最大值的个数 \(cnt\)。每次递归直至找到 \(se<x\le mx\) 的区间,将区间和减去 \(cnt\times (mx-x)\),将 \(mx\) 赋值为 \(x\);或者 \(mx\le x\) 后直接返回。

历史最值是指定的区间 \([l,r]\) 里所出现过的最大的数。解决这类问题需要在维护了最大值及其懒标记 \(lzy\_mx\) 的基础上再维护历史最大值 \(his\) 及其懒标记 \(lzy\_his\)。解法即每次尝试用当前节点的 \(mx\)(表示这个节点当前的操作序列最终结果)加上父亲节点下传的 \(Fa(lzy\_his)\)(表示父亲节点记录的操作过程中前缀和达到的最值)来更新这个节点的 \(his\)。同理,用 \(lzy\_mx\) 加上下传的 \(Fa(lzy\_his)\) 尝试更新 \(lzy\_his\)

image

同时存在区间最值操作时维护非最大值的懒标记同理。

在历史最值问题中有时区间赋值会与区间加一同出现。此时我们自然需要维护一个赋值懒标记 \(tag\)。但同时维护两种懒标记有些复杂,于是有了如下思路:将赋过一次值后的操作都看作赋值,只用赋值懒标记 \(tag\) 进行维护。因为一旦对某区间赋过了值 \(x\),这个区间再加一个数 \(y\) 就相当于重新赋值成了 \(x+y\)。所以我们在节点处维护一个 \(bool\) 变量判断是否进行过赋值操作。自然从父节点接过来操作序列时也得分讨是接入的区间加标记还是赋值标记。这里以子节点已赋值为例。

image

操作

历史最值

void modify_add / chg(int id, int l, int r, int v) {
	if (tr[id].l >= l && tr[id].r <= r) {
		add / chg(id, v, v); return;
	}
	pushdown(id);
	if (l <= mid) modify(lid, l, r, v);
	if (r > mid) modify(rid, l, r, v);
	pushup(id);
}

区间最值

void modify_min(int id, int l, int r, int v) {
	if (tr[id].mx <= v) return;
	if (tr[id].l >= l && tr[id].r <= r && tr[id].se < v) {
		update(id, v - tr[id].mx, v - tr[id].mx, 0, 0); return;
	}
	pushdown(id);
	if (l <= mid) modify_min(lid, l, r, v);
	if (r > mid) modify_min(rid, l, r, v);
	pushup(id);
}

下传&上传&更新标记

既然懒标记这么多,下传函数就显得尤其重要。并且合并时最大值较小的子区间自动降级为次大值不会被下传到最大值专享的懒标记,更新时注意分讨。上传同理,分讨上传 \(mx\) 与对应 \(cnt\)。区间与历史最值缝合时更为繁琐。可分为pushdownupdate函数专门处理懒标记。对于update,结合上图,我们只需再明确更新顺序即可。

void pushup(int id) {
	tr[id].sum = tr[lid].sum + tr[rid].sum;
	tr[id].mx = max(tr[lid].mx, tr[rid].mx);
	tr[id].his = max(tr[lid].his, tr[rid].his);
	if (tr[lid].mx > tr[rid].mx) {
		tr[id].cnt = tr[lid].cnt;
		tr[id].se = max(tr[lid].se, tr[rid].mx);
	}
	else if (tr[lid].maxst < tr[rid].maxst) {
		tr[id].cnt = tr[rid].cnt;
		tr[id].se = max(tr[lid].mx, tr[rid].se);
	}
	else {
		tr[id].cnt = tr[lid].cnt + tr[rid].cnt;
		tr[id].se = max(tr[lid].se, tr[rid].se);
	}
}
void update(int id, int l1, int l2, int l3, int l4) {
    //l1对应下传的Fa(lzy_mx),l2对应下传的Fa(lzy_his),l3对应下传的Fa(lzy_se),l4对应下传的Fa(lzy_res)
    //lzy1、lzy2、lzy3、lzy4定义即一一对应于当前区间懒标记
	tr[id].sum += l1*tr[id].cnt + l3*(tr[id].r-tr[id].l-tr[id].cnt+1);
	tr[id].his = max(tr[id].his, tr[id].mx + l2);
	tr[id].mx += l1;
	if (tr[id].se != -INF) tr[id].mx += l3;
	lzy2(id) = max(lzy2(id), lzy1(id) + l2); 
	lzy4(id) = max(lzy4(id), lzy3(id) + l4); 
	lzy1(id) += l1; lzy3(id) += l3;
}
void pushdown(int id) {
	int tmp = max(tr[lid].mx, tr[rid].mx);
	if (tr[lid].mx == tmp) update(lid, lzy1(id), lzy2(id), lzy3(id), lzy4(id));
	else update(lid, lzy3(id), lzy4(id), lzy3(id), lzy4(id));
	if (tr[rid].mx == tmp) update(rid, lzy1(id), lzy2(id), lzy3(id), lzy4(id));
	else update(rid, lzy3(id), lzy4(id), lzy3(id), lzy4(id));
	lzy1(id) = lzy2(id) = lzy3(id) = lzy4(id) = 0;
}
-----------------------------------------------------------------------------------------------------
void chg(int id, int l1, int l2) {//l1是Fa(tag),l2是Fa(tag_his)
	if (!tr[id].vis) tr[id].vis = 1, tr[id].tag_his = l2;
	else tr[id].tag_his = max(tr[id].tag_his, l2);
    tr[id].his = max(tr[id].his, l2);
    tr[id].tag = tr[id].mx = l1;
}
void add(int id, int l1, int l2) {//l1是Fa(lzy),l2是Fa(lzy_his)
    if (tr[id].vis) tr[id].tag_his = max(tr[id].tag_his, tr[id].tag + l2), tr[id].tag += l1;
	else tr[id].lzy_his = max(tr[id].lzy_his, tr[id].lzy + l2), tr[id].lzy += l1;
	tr[id].his = max(tr[id].his, tr[id].mx + l2);
    tr[id].mx += l1;
}
void pushdown(int id) {
	add(lid, tr[id].lzy, tr[id].lzy_his);
	add(rid, tr[id].lzy, tr[id].lzy_his);
	tr[id].lzy = tr[id].lzy_his = 0;
	if (!tr[id].vis) return;
	chg(lid, tr[id].tag, tr[id].tag_his);
	chg(rid, tr[id].tag, tr[id].tag_his);
	tr[id].vis = tr[id].tag = tr[id].tag_his = 0;
}

修改&查询

modify区间加函数大致不变。可直接调用updatequery区间求和、取最值函数大致不变。

例题

posted @ 2023-10-04 19:51  b1t_sad  阅读(48)  评论(0)    收藏  举报