数据结构:数据的组织方式
简单的数据结构:数组、栈、队列……
稍微复杂一点的数据结构:并查集、单调栈、单调队列……
再复杂一点的数据结构:堆、线段树、树状数组、平衡树
再复杂一点的数据结构:数据结构的可持久化、数据结构的嵌套、树上的数据结构
例题:有一台计算机,初始时上面有 n 个待运行的任务。每个任务有一个优先级,用一个数字表示,数字越大表示任务的优先级越高。计算机会进行 m 次操作,操作类型包括以下两种:插入一个新的任务。查询当前优先级最高的任务,并将其执行。保证所有任务的优先级均不相同。n ≤ 106, m ≤ 105
堆(二叉堆)是一种特殊的完全二叉树,在每个点上有一个(键值)点权,点权需要满足一些特殊的性质。
二叉堆分两种:大根堆和小根堆:
大根堆:父结点的键值总是大于或等于任何一个子节点的键值;
小根堆:父结点的键值总是小于或等于任何一个子节点的键值
建立一个二叉堆。
插入一个节点。
访问/删除堆顶元素。
采用数组进行存储。
对于索引为 k 的节点:
2 × k 是其左儿子节点
2 × k + 1 是其右儿子节点
⌊ 2k⌋ 是其父亲节点
以最大堆为例,自上而下维护。
假设除了根节点 k 外,整颗子树都满足堆的性质。
若父节点小于两个子节点中较大的一个,则与其交换,并递归处理。
每个节点需要维护的高度和树高有关,高度越大节点数越少。
设堆的总结点个数为 n,则高度为 k 的节点有 n/2^k。
总共用时h∑k n/k^2 × O(k) = O(n)
从尾部插入,自下而上维护。
如果该节点比父节点更大,则与父节点交换。
递归操作,直到该节点比父节点小为止。
直接返回 heap[1]。
采用 heap[heap_size] 代替 heap[1],并用 push_down() 进行维护。
例题:罗马游戏P2713
给定 n 个人,每个人都有一个分数。一开始,每个人都属于一个独立的团。这些人的统领可以可以发两种命令:
1. Merge(i; j)。把 i 所在的团和 j 所在的团合并成一个团。如果 i; j有一个人是死人,那么就忽略该命令。
2. Kill(i)。把 i 所在的团里面得分最低的人杀死。如果 i 这个人已经死了,这条命令就忽略。每发布一条 kill 命令,你就需要输出被杀的人的分数报上来。
n ≤ 106, m ≤ 105
左偏树是一种可并堆的实现。
左偏树是一棵二叉树,它的节点除了和二叉树的节点一样具有左右子树指针外,还有两个属性:键值和距离。
键值:某个节点储存的用于比较的值,以最大堆为例,父节点的键值需要大于或等于两个子节点的键值。
距离:叶节点的距离定义为 0。其余节点的距离定义为dist[k] = dist[krson] + 1。
左偏树要求满足左偏性质:左子节点的距离大于或等于右子节点的距离。
插入一个节点。
查询键值最小/最大节点。
删除键值最小/最大节点。
合并两棵左偏树(这是左偏树的基本操作,插入和删除操作都依赖于合并操作)。
类型 | 插入 | 取出顶部元素 | 弹出 | 合并 |
二叉堆 | O(nlog(n)) | O(1) | O(log(n)) | O(n1log(n2)) |
左偏树 | O(nlog(n)) | O(1) | O(log(n)) | O(log(n1) + log(n2)) |
以大根堆为例。
从根节点开始递归操作,将根较大那棵树的根作为新的根节点,不妨记为 a,另一颗树记为 b。
将 a 的右子树和 b 合并,作为 a 的右儿子。更新根节点的距离值
对于一个距离为 x 的节点,其子树节点数显然大于等于 2x+1 - 1
一个大小为 n 的子树,其根节点的距离小于等于 log(n + 1) - 1
合并的复杂度为两棵树根节点的距离值之和,复杂度为O(log(n1) + log(n2))
int merge(int x,int y)
{
if(!x||!y)
{
return x+y;
}
if(s[x].val>s[y].val||(s[x].val==s[y].val&&x>y))
{
swap(x,y);
}
int &ul=s[x].lson,&ur=s[x].rson;
ur=merge(ur,y);
s[ur].fa=x;
if(s[ul].dist
插入一个节点:将一个大小为 1 的树和原树合并
查询最大值:返回树根
删除最大值:将根的左子树和右子树合并
例题:
#include
#include
#include
#define maxn 1000001
struct node
{
int dist,lson,rson,val,fa;
}s[maxn];
int n,m,p,t;
int inline read()
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-') f=-1; ch=getchar();}
while(ch>='0'&&ch<='9'){x=x*10+ch-'0';ch=getchar();}
return x*f;
}
void swap(int &x,int &y){t=x;x=y;y=t;}
int getf(int x)
{
//if(f[t]==t) return t;这个错了,应该是下面那样写
//else return f[t]=getf(f[t]);
//while(f[t]) t=f[t];
//return t;
return s[x].fa?getf(s[x].fa):x;
}
int merge(int x,int y)
{
if(!x||!y)
{
return x+y;
}
if(s[x].val>s[y].val||(s[x].val==s[y].val&&x>y))
{
swap(x,y);
}
int &ul=s[x].lson,&ur=s[x].rson;
ur=merge(ur,y);
s[ur].fa=x;
if(s[ul].dist
例题:有一个长度为 n 的序列,从 1 开始编号。有 m 次操作,每次操作可以是:
ADD l r d:将下标在 l 到 r 之间的数加 d
SUM l r:询问下标在 l 到 r 之间的数的和
n ≤ 105, m ≤ 105
线段树是一种二叉树,每个节点表示一段连续区间,每个叶节点代表一个单元区间。
线段树可以用于快速修改/查询一个区间的信息,这一操作的复杂度是 O(log(n))。
复杂度的保证:任意一个区间对应于线段树内的不超过 O(log(n))个节点
对整个结点进行的操作,先在结点上做标记,而并非真正执行,直到需要对节点的左右子进行操作时再根据标记信息作相应修改。懒标记保证了线段树一次操作的复杂度是 O(log(n))。懒标记有两种写法:
标记下传、永久化标记
以区间和为例,修改一个区间时,从根节点递归处理。若待修改的区间包含当前节点所代表的整个区间,则返回当前节点
的区间和信息。否则将标记下传,并分别考虑左右儿子,若修改区间与左/右儿子有交集,则递归处理左/右儿子。递归返回时更新节点的区间和信息。
查询一个区间时,同样从根节点开始递归处理。若待查询的区间包含当前节点所代表的整个区间,则修改根节点的
区间和信息和标记信息,退出递归。否则将标记下传,并分别考虑左右儿子,若修改区间与左/右儿子有交集,则递归处理左/右儿子。递归返回时将左右儿子处理结果相加并返回。
基本思想与标记下传相同。区别在于标记永久地打在一个节点上,不进行下传。在查询过程中,需要累加从根节点到当前节点的标记和。例如在区间和中,用一个变量记录在以这个点为根的子树中的标记的贡献和。
例题:序列维护P2023
老师交给小可可一个维护数列的任务,现在小可可希望你来帮他完成。有长为 N 的数列,不妨设为 a1,a2,…,aN 。有如下三种操作形式:
(1) 把数列中的一段数全部乘一个值;
(2) 把数列中的一段数全部加一个值;
(3) 询问数列中的一段数的和,由于答案可能很大,你只需输出这个数模 P 的值。
n ≤ 105, m ≤ 105
线段树维护,双重标记,一个加法标记一个乘法标记。先乘后加来维护标记,乘法标记下传时需要将乘法标记,区间和和
加法标记都乘上对应标记值
永久化标记没法做,为什么?
不满足交换律。
用于计算前缀和,区间和。与线段树相比,写法简单,常数小。二维树状数组常数优势更明显。功能可以完全被线段树替代。
引例:初始时,有 n 个数。需要进行 m 次操作,每次操作可以是:
ADD a:添加一个数 a。
DELETE a:删除一个数 a。
QUERY x:询问 x 是否存在。
n ≤ 105, m ≤ 105。
伸展树(Splay),也叫分裂树,是一种二叉排序树。
它能在 O(log(n)) 内完成插入、查找和删除操作。
伸展树是一种自调整形式的二叉查找树,它的优势在于不需要记录用于平衡树的冗余信息。
伸展树的基本操作 Splay:将一个节点旋转到根。
进行所有操作时都需要做 Splay 操作。
Rotate 操作:将一个节点旋转到父节点的位置
Splay 的所有基本操作同二叉搜索树,只需要在操作后多一个Splay 操作。
插入操作:从根节点开始递归,若插入键值小于当前节点,则向左儿子走,否则向右儿子走,直到左/右儿子为空,则插入。将插入节点旋转至根。
删除操作:找到待删除节点的前驱、后继,将前驱转到根,将后继转到前驱的儿子位置,则把后继的左儿子删除成空即可。
查询操作:从根节点开始,若待查询键值等于当前节点则返回查询成功。若小于当前节点,则向左儿子走,否则向右儿子走,直至查到或左/右儿子为空。将查询节点旋转至根。
bool pd(int u)
{
return (tr[tr[u].fa].son[0] != u);
}
void update(int u)
{
tr[u].size = tr[tr[u].son[0]].size + tr[tr[u].son[1]].size + tr[u].cnt;
}
void rotate(int u)
{
int p, pf, f, ff, tmp;
f = tr[u].fa;
ff = tr[f].fa;
p = pd(u);
pf = pd(f);
tmp = tr[u].son[p^1];
if (tmp != 0) tr[tmp].fa = f;
tr[f].son[p] = tmp;
tr[f].fa = u;
tr[u].son[p^1] = f;
tr[ff].son[pf] = u;
tr[u].fa = ff;
update(f);
}
void splay(int u, int aim)
{
//清除u到aim上的所有标记,从上往下
while (tr[u].fa != aim)
{
if (tr[tr[u].fa].fa == aim)
{
rotate(u);
break;
}
if (pd(u) == pd(tr[u].fa))
{
rotate(tr[u].fa);
rotate(u);
}
else
{
rotate(u);
rotate(u);
}
}
update(u);
}
void insert(int x)
{
int u = root, fa;
while (u != 0)
{
if (x == tr[u].x)
{
tr[u].cnt++;
splay(u, root);
return;
}
fa = u;
if (x < tr[u].x) u = tr[u].son[0];
else u = tr[u].son[1];
}
//创建一个x节点,把它接在fa的下面
//把新节点转到根splay(*, root)
}
int find(int x)//找权值为x的点
{
}
int prev(int u)
{
splay(u, root);
//返回左儿子的右子孙
}
int succ(int u)
{
}
int del(int x)
{
int u = find(x);
if (!u) return;
int v1 = prev(u), v2 = succ(u);
splay(v1, root);
splay(v2, v1);
tr[v2].son[0] = 0;
update(v2);
update(v1);
update(root);
}
STL 是 Standard Template Library(标准模板库)的简称。
用一种最简单的方式来理解 STL,就是这是一系列由 C++ 帮我们写好了的可以直接使用的数据结构
C++ 的容器 stl
可以当作一个大小可变的数组
内部实现:内存倍增策略
元素访问:以数组的形式访问、使用迭代器进行访问
push_back 函数
resize 函数
对 vector 中的元素进行排序: begin 函数与 end 函数
vector 的应用:图的存储
与 vector 类似的 stl: queue、 deque、 list 等
vector v;
优先队列,对应于数据结构中的堆
push 函数
top 函数
pop 函数
应用:图的最短路算法以及其余需要使用堆进行解题的题目
C++ 中的集合
内部实现:平衡树
insert 函数
erase 函数
fnd 函数
lower_bound 与 upper_bound 函数
set 的嵌套
multiset 的应用
set > s;
与 set 非常类似,但在 set 的基础上多了一维
如果希望实现 set 的功能,则尽量使用 set
map 主要的用处是作为一个扩展的数组
map m;
m['a'] = 30;
m['b'] = 10;
cout << m['a'] << endl;
给定一个长为 n 的数组,数组元素 ai ≤ 105。
再给出 m 次询问,每次询问下标在 l 到 r 中第 k 大的数字是多少。
m ≤ 105, n ≤ 105
一种支持保存线段树各个历史版本的数据结构。
查询任一历史版本所需时间为 O(log(n))。
只能添加新版本,不能修改历史版本,添加一个新版本所需时间为O(log(n))。
支持操作同线段树。
具体实现:
写法基本与线段树相同,需要采用动态开点方式存储线段树,不能使用数组存储。
从根节点开始递归处理修改/查询操作。
每次修改操作需要建立新的节点以保留历史版本。具体写法参考例程。
由于每次修改/查询只涉及 O(log(max(ai))) 个节点,因此时间复杂度为 O(log(max(ai)))
利用主席树维护信息,主席树区间 [l; r] 表示数字在 [l; r] 区间中的个数。
按照数组顺序,从左到右将各个数字依次插入主席树,注意保留各个历史版本。
查询时,对于 [l; r] 的区间,用主席树的第 r 个版本减去第 l - 1 个版本,即可得到区间 [l; r] 中的数字分布。因此从根节点开始递归处理,若左子树大小大于等于 k,则说明第 k 小在左子树中,否则在右子树中,直至叶节点,返回结果。
//主席树
void put(int l, int r, int c1, int &c2, int x)
{
p++; c2 = p;
tr[c2] = tr[c1];
tr[c2].cnt++;
int mid = (l + r) >> 1;
if (x <= mid) put(l, mid, tr[c1].ls, tr[c2].ls, x);
else put(mid + 1, r, tr[c1].rs, tr[c2].rs, x);
}
int findk(int l, int r, int c1, int c2, int k)
{
if (l == r) return l;
int mid = (l + r) >> 1;
if (tr[tr[c2].ls].cnt - tr[tr[c1].ls].cnt >= k)
return findk(l, mid, tr[c1].ls, tr[c2].ls, k);
else
return findk(mid + 1, r, tr[c1].rs, tr[c2].rs,
k - (tr[tr[c2].ls].cnt - tr[tr[c1].ls].cnt));
}
put(1, 1e9, root[i - 1], root[i], a[i]);
cout << findk(1, 1e9, root[l - 1], root[r], k) << endl;
事实上,在主席树的构造过程中,我们使用了数据结构的可持久化的思想。
如果对某个数据结构做一个微小的改动(比如添加一个元素、删除一个元素等),这个数据结构的变化幅度也非常小(比如在 log 级别),我们就可以通过额外对变化部分进行备份来保留下这一数据结构的历史信息。
对于简单的数据结构,如栈、队列,可持久化都是可行的。
并查集的可持久化也是可行的,但我们需要通过按秩合并来保证复杂度,并且合并过程中不使用路径压缩。
堆的可持久化也容易实现。但对左偏树进行可持久化往往更实用一些。可持久化左偏树的一个很重要的应用是用于求解图的 k 短路问题。
平衡树的可持久化问题较为复杂。例如 splay 就是不能可持久化的。原因是: splay 的复杂度分析基于均摊,所以我们并不能保证它的单次结构变化幅度在 log 级别。可以可持久化的平衡树有: AVL、红黑树等。
线段树的可持久化在竞赛中使用量较大,且大部分形态是以对权值线段树的可持久化来展现。
引例:带修改区间第K小
给定一个长为 n 的序列,序列元素 ai ≤ 109。
有 m 条询问/修改指令,每条询问指令询问区间 l 到 r 中第 k 小的数字是多少,每条修改指令将位置 t 的数字修改成 x。
m ≤ 104, n ≤ 104
线段树套线段树。
外层为位置线段树,线段树上的区间 [l1; r1] 用于维护位置在 [l1; r1]内的信息。外层线段树的每一个节点为一颗内层线段树。
内层线段树为权值线段树,区间 [l2; r2] 用于维护值在 [l2; r2] 中的数字有多少个。
每次修改,涉及到外层覆盖了位置 i 的外层线段树节点,共O(log(n)) 个,对这些节点的内层线段树中覆盖了 ai 的内层线段树
节点进行修改,总复杂度 O(log(n)log(max(ai)))。
每次查询时,首先将对应查询区间 [l; r] 的外层线段树节点找出,将这些线段树节点对应的内层线段树根记录,共 O(log(n)) 个,然后对这些节点同时进行二分操作,直至叶节点,返回结果。
//树套树
void add2(int l, int r, int x, int c, int k)
{
//把包含x这个位置的所有线段的cnt加上k
}
void add(int l, int r, int x, int ax, int c, int k)//k=1或-1
{
add2(0, inf, ax, tr[c].root, k);
mid
x<=mid
add(l, mid , x, ax, tr[c].lson, k);
else
//...
}
void dfs(int l, int r, int L, int R, int c)
{
//L,R:询问区间
if (L <= l && R >= r)
{
cnt++;
tmp[cnt] = c;
return;
}
int mid = (l + r) >> 1;
if (L <= mid) dfs(l, mid, L, R, tr[c].lson);
if (R > mid) dfs(mid + 1, r, L, R, tr[c].rson);
}
int findk(int l, int r, int k) //传入0, inf, k
{
//用一个c数组记录所有线段的位置
if (l == r) return l;
int mid = (l + r) >> 1;
int cnt_ = 0;
for (int i = 1; i <= cnt; i++)
{
cnt_ += tr[c[i].lson].cnt;
}
k <= cnt_
……
k > cnt_
……
}
上题我们展示了数据结构嵌套的一个最经典的例子。
数据结构的嵌套指的是在外层数据结构的每个节点中,都保存有一个额外的数据结构。
一般外层数据结构以线段树居多,有时在合适的条件下,树状数组可以代替线段树
内层的数据结构可以是线段树、平衡树、堆,甚至可以是一个 stl。其中线段树套线段树的应用最为广泛,需要熟练掌握。
引例:给出一棵 n 个节点的树,每个节点有一个权值。
有 m 条修改/询问指令。
每条修改指令将树上一条链上的权值增加 x。
每条询问指令询问树上一条链上节点的权值和
m ≤ 104, n ≤ 104
一种对树进行划分的算法。
首先通过轻重边剖分将树分为多条链,保证每个点属于且只属于一
条链。
然后再通过线段树来维护每一条链。
原树上每一条路径属于 O(log(n)) 条链,每段链对应于线段树上的
一个区间。
总复杂度 O(log^2(n))。
先进行一边 DFS, 统计每个节点为根的子树大小,记为 sizei,每个节点的深度,记为 deepi。
从根节点开始,每次选择 sizei 最大的儿子,向下走,直到叶节点,得到一条重链。
对于一个点的非重儿子来说,以他为根节点,可以重新访问出一条重链。
每一条重链采用一棵线段树维护。
可以证明,原树上每一条路径属于 O(log(n)) 条链。
修改/查询时,沿链两端节点向上走。每次选择深度较深的节点,维护该节点到其所在树链链根的信息,并将该节点跳至链根的父亲节点。直到两节点在其最近公共祖先处相遇,即完成修改/查询。
void dfs1(int u)
{
size[u] = 1;
for (int j = first[u]; j; j = nxt[j])
{
if (to[j] == fa[u]) continue;
fa[to[j]] = u;
dep[to[j]] = dep[u] + 1;
dfs1(to[j]);
size[u] += size[to[j]];
if (maxson[u] == 0 || size[to[j]] > size[maxson[u]])
maxson[u] = to[j];
}
}
void dfs2(int u)
{
p++;
dfn[u] = p;
if (maxson[u] != 0)
{
flag[maxson[u]] = 1;//flag:记录一个点是不是重儿子
//top:记录一个点沿着红边往上能走到的点
if (flag[u]) top[maxson[u]] = top[u];
else top[maxson[u]] = u;
dfs2(maxson[u]);
}
for (int j = first[u]; j; j = nxt[j])
{
if (to[j] == fa[u] || to[j] == maxson[u]) continue;
top[to[j]] = to[j];
dfs2(to[j]);
}
}
// 倍增:预处理:O(nlogn) 单次询问:O(logn) 空间:O(nlogn)
//树链剖分: 预处理:O(n) 单次询问:O(logn) 空间:O(n)
//树链剖分求lca的常数大约为倍增法的四分之一~二分之一
int lca(int u, int v)
{
while (top[u] != top[v])
{
if (dep[top[u]] < dep[top[v]]) swap(u, v);
u = fa[top[u]];
}
if (dep[u] < dep[v]) swap(u, v);
return v;
}
int query(int a, int b)
{
int u = a, v = b;
int ans = 0;
while (top[u] != top[v])
{
if (dep[top[u]] < dep[top[v]]) swap(u, v);
ans += query2(dfn[top[u]], dfn[u], 1);//query2:线段树上的查询
u = fa[top[u]];
}
if (dep[u] < dep[v]) swap(u, v);
ans += query2(dfn[v], dfn[u], 1);
return ans;
}
大部分序列上的线段树题目都可以被迁移到树上的链问题。在实现时将线段树用树链剖分加以维护即可。
时间复杂度比正常的序列问题多一个 log
有些题目还会将树链剖分与 dfs 序进行结合,可以同时维护树上链问题与子树问题。