【学习笔记】左偏树

前言

题目传送门

左偏树就是可并堆的一种。

顾名思义,就是左边比较长的一种树(即左儿子到叶节点的链比右儿子到叶节点的链长)

左偏树是一种特殊的堆数据结构。它不严格遵守堆的定义,比如可以不是一棵完全二叉树。但满足堆的其他各种性质:

  • \(O(1)\) 时间求最优值
  • \(O(\log n)\) 插入、删除元素

同时,它支持 \(\log n\) 合并。当然要求合并的对象也是左偏树。

一些定义

  • 外结点:最多只有一个儿子的结点
  • 空结点:你可以理解为叶子结点的儿子结点或者说是只有左儿子的结点的右儿子只有右儿子的结点的左儿子,即不存在,但要定义
  • \(\textup{dis}\):节点 \(i\) 到子树中最近的空节点的距离。这里定义外结点的 \(\textup{dis}\)\(1\),空结点的 \(\textup{dis}\)\(0\)

左偏树:

满足以下条件的二叉树,即为左偏树。

  • 对于任意结点,其右儿子的 \(\textup{dis}\) 不大于左儿子的 \(\textup{dis}\)
  • 父亲的权值小于或等于 / 大于或等于儿子的权值。(小根堆 / 大根堆)

左偏树的性质

左偏树具有堆性质,即若其满足小根堆的性质,则对于每个结点 \(x\) ,有 \(v_x < v_{lx},v_x < v_{rx}\)

左偏树具有左偏性质,即对于每个结点 \(x\),有 \(dis_{rc}\le dis_{lc}\)

基本结论

  • \(dis_x=dis_{rc}+1\)
  • 根节点的距离约为 \({\log_2}n\)

基本操作:合并操作

左偏树最基本的操作是合并操作。

定义 merge(x,y) 为合并两棵分别以 \(x,y\) 为根节点的左偏树。

首先不考虑左偏性质,我们描述一下合并两个具有堆性质的树的过程。假设我们要合并的是小根堆。

  1. \(v_x\le v_y\),则让 \(x\) 成为合并后新的根节点,否则让 \(y\) 成为合并后新的根节点。为避免讨论,如果 \(v_x>v_y\) 则交换 \(x,y\)
  2. \(y\)\(x\) 其中一个儿子进行合并,用合并后的根节点代替与 \(y\) 合并的儿子的位置,并返回
  3. 重复以上操作,如果 \(x\)\(y\) 中有一个为空节点,则让 \(x+y\) 代替新结点

\(h\) 为树高, \(h_x\)\(h_y\) 每次都减少了 \(1\) ,上述过程的时间复杂度是 \(O(h)\) 的,当合并的树退化为一条链时,这样做的复杂度是 \(O(n)\)(然后就得挂了)。要使时间复杂度更优,就要使树合并得更平衡 。我们有两种方式:

  • 每次随机选择 \(x\) 的儿子与 \(y\) 合并(去 Treap 他家吧,这里不属于你)
  • 左偏树

我们可以想一个问题,假如现在有两个 vector,你现在要把这两个 vector 和并成一个 vector,你该怎么做呢?

这是个很简单的问题,贪心的来想,我们肯定会把大小较小的那个 vector 里的元素一个一个加入到大小较大的那个 vector 里面。换一种加入方式就不优了。

同理,由于左偏树中左儿子的距离大于右儿子的距离,我们每次将 \(y\)\(x\) 的右儿子合并。由于左偏树的树高是 \(O(\log_2 n)\) 的,所以单次合并的时间复杂度也是 \(O(\log_2 n)\) 的。

但是,两棵左偏树按照上述方法合并后,可能不再保持左偏树的左偏性质。在每次合并完之后,判断对结点 \(x\) 是否有 \(dis_{lc}\le dis_{rc}\) ,若没有则交换 \(lc,rc\),并维护 \(x\) 的距离 \(dis_x=dis_{rc}+1\),即可维护左偏树的左偏性质。

由于合并后的树既满足堆性质又满足左偏性质,所以合并后的树仍然是左偏树。

void merge(int &x, int y) {
	if (!x && !y) return ; // 全都是空结点,被骗了 
	if (!x || !y) x = x + y, tree[x].dis = 1; // 非空结点成为新的根结点 
	else {
		// 避免讨论 
		if (tree[y] < tree[x]) swap(x, y);
		fa[y] = x; // 连向根结点 
		
		merge(tree[x].r, y); // 合并 x 的右结点与 y 
		
		// 保证左偏性质 
		if (tree[tree[x].l].dis < tree[tree[x].r].dis)
			swap(tree[x].l, tree[x].r);
		
		// 利用结论 
		tree[x].dis = tree[tree[x].r].dis + 1;
	}
}

这里再提供一个返回值为新合并出来的根的代码:

int merge(int x, int y) {
	if (!x || !y) return x + y;
	else {
		if (tree[y] < tree[x]) swap(x, y);
		
		tree[x].r = merge(tree[x].r, y);
		fa[tree[x].r] = x;
		
		if (tree[tree[x].r].dis > tree[tree[x].l].dis) 
			swap(tree[x].l, tree[x].r);
		
		tree[x].dis = tree[tree[x].r].dis + 1;
	}
	return x;
}

左偏树的其他基本操作

插入给定值

新建一个值等于插入值的结点,将该节点与左偏树合并即可。时间复杂度 \(O(log_2 n)\)

求最小值

由于左偏树的堆性质,左偏树上的最小值为其根节点的值。

删除最小值

等价于删除左偏树的根节点。合并根节点的左右儿子即可。记得维护已删除结点的信息。

给定一个结点,求其所在左偏树的根节点

如果你是一个一个往上提的话,那么时间复杂度最高可为 \(O(n)\)。因此我们使用并查集路径压缩优化这个过程。

int findroot(int x) {
	if (x == fa[x]) return x;
	return fa[x] = findroot(fa[x]);
}

void mergeroot(int x, int y) {
	int xx = findroot(x), yy = findroot(y);
	fa[xx] = fa[yy];
}

使用这种写法,需要记得维护 fa[x] 的值。

例如:当我们让 \(x\) 成为一个根的时候,那么此时就要 fa[x] = x

再使用路径压缩优化后,时间复杂度降低到 \(O(\log n)\) 级别。

代码实现

#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 10;

int n, m, del[N];
int fa[N];

struct node {
	int val, id, l, r, dis;
	bool operator<(const node &t) {
		return val < t.val || (val == t.val && id < t.id);
	}
} tree[N];

void merge(int &x, int y) {
	if (!x && !y) return ;
	if (!x || !y) x = x + y, tree[x].dis = 1;
	else {
		if (tree[y] < tree[x]) swap(x, y);
		fa[y] = x;
		
		merge(tree[x].r, y);
		
		if (tree[tree[x].l].dis < tree[tree[x].r].dis)
			swap(tree[x].l, tree[x].r);
		
		tree[x].dis = tree[tree[x].r].dis + 1;
	}
}

int findroot(int x) {
	if (x == fa[x]) return x;
	return fa[x] = findroot(fa[x]);
}

void mergeroot(int x, int y) {
	int xx = findroot(x), yy = findroot(y);
	fa[xx] = fa[yy];
}

int main() {
	cin >> n >> m;
	
	for (int i = 1; i <= n; i++) {
		tree[i].id = i;
		fa[i] = i;
	} 
	
	for (int i = 1; i <= n; i++)
		cin >> tree[i].val;
	
	while (m--) {
		int opt, a, b;
		cin >> opt;
		
		if (opt == 1) {
			cin >> a >> b;
			if (del[a] == 1 || del[b] == 1) continue;
			if (findroot(a) == findroot(b)) continue;
			
			int aa = findroot(a), bb = findroot(b);
			
			if (tree[aa] < tree[bb]) fa[bb] = aa;
			else fa[aa] = bb;
			
			merge(aa, bb);
		} else {
			cin >> a;
			
			if (del[a] == 1) {
				cout << "-1\n";
				continue;
			}
			
			int aa = findroot(a);
			int lch = tree[aa].l, rch = tree[aa].r;
			
			cout << tree[aa].val << '\n';
			
			fa[lch] = lch, fa[rch] = rch;
			if (lch != rch) {
				merge(lch, rch);
				tree[aa].l = tree[aa].r = 0; // 形式上删除 aa 
				fa[aa] = lch; // 但是仍然有很多节点的 findroot 是 aa,因此让 aa 指向新的根节点 lch 即可解决
				fa[lch] = lch;
			}
			
			del[aa] = 1; // 形式上删除 aa 
		}
	}
	
	return 0;
} 

如果按照上述代码实现的话,会有很多冗余要考虑,因此这里再提供一个简洁一点的:

#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 10;

int n, m, del[N];
int fa[N];

struct node {
	int val, id, l, r, dis;
	bool operator<(const node &t) {
		return val < t.val || (val == t.val && id < t.id);
	}
} tree[N];

int merge(int x, int y) {
	if (!x || !y) return x + y;
	else {
		if (tree[y] < tree[x]) swap(x, y);
		
		tree[x].r = merge(tree[x].r, y);
		fa[tree[x].r] = x;
		
		if (tree[tree[x].r].dis > tree[tree[x].l].dis) 
			swap(tree[x].l, tree[x].r);
		
		tree[x].dis = tree[tree[x].r].dis + 1;
	}
	return x;
}

int findroot(int x) {
	if (x == fa[x]) return x;
	return fa[x] = findroot(fa[x]);
}

int main() {
	cin >> n >> m;
	
	for (int i = 1; i <= n; i++) {
		tree[i].id = i;
		fa[i] = i;
	} 
	
	for (int i = 1; i <= n; i++)
		cin >> tree[i].val;
	
	while (m--) {
		int opt, a, b;
		cin >> opt;
		
		if (opt == 1) {
			cin >> a >> b;
			if (del[a] == 1 || del[b] == 1) continue;
			if (findroot(a) == findroot(b)) continue;
			
			int aa = findroot(a), bb = findroot(b);
			fa[aa] = fa[bb] = merge(aa, bb); 
		} else {
			cin >> a;
			
			if (del[a] == 1) {
				cout << "-1\n";
				continue;
			}
			
			int aa = findroot(a);			
			cout << tree[aa].val << '\n';
			
			// 这里 aa 删除后,由于还有很多结点的 fa[] 值为 aa,因此更新 aa 的 fa[] 值 
			fa[tree[aa].l] = fa[tree[aa].r] = fa[aa] = merge(tree[aa].l, tree[aa].r);
			tree[aa].l = tree[aa].r = 0;
			del[aa] = 1;
		}
	}
	
	return 0;
} 
posted @ 2025-12-11 22:13  chrispang  阅读(1)  评论(0)    收藏  举报