【算法】高级数据结构
并查集
并查集简要来说就是一个聚类操作,然后选区类里面的一个元素作为代表,用于将一类的元素划分到一个集合当中。
例题

并查集标程,并查集的构造需要三个核心功能:
如何查有多少个集合:直接 for 循环遍历,查找有多少个 s[i]=i ,就有多少个集合
问2,查找,合并的复杂度是多少:
合并操作的复杂度其实就是查找的复杂度,查找最坏情况下要遍历所有的节点,所以是$O(n)$
标程代码
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 
 | #include<iostream>using namespace std;
 int s[5001];
 int n,m,p;
 void init(){
 for(int i=1;i<=n;i++){
 s[i]=i;
 }
 }
 int find_set(int x){
 return x==s[x]?x:find_set(s[x]);
 }
 void union_set(int x,int y){
 int xroot = find_set(x);
 int yroot = find_set(y);
 if(xroot!=yroot){
 s[xroot] = s[yroot];
 }
 }
 
 int main(){
 cin>>n>>m>>p;
 init();
 int a,b;
 while(m--){
 cin>>a>>b;
 union_set(a,b);
 }
 while(p--){
 cin>>a>>b;
 if(find_set(a)==find_set(b)){
 cout<<"Yes"<<endl;
 }
 else{
 cout<<"No"<<endl;
 }
 }
 return 0;
 }
 
 | 
以上代码中的并查集操作实际上是比较低效的,可以通过优化来达到更高效的操作,以下是一些优化步骤:
优化合并操作
由于在上面的代码中,我们都把集合赋值给y 那一方,导致出现这样一种状况,可能把高度较大的树并到了高度较小的树当中,导致原本高的那颗树又加 1 层,变得更高,查找时就更慢。我们可以优化这种操作,让他把高度较小的树并到高度较大的树中

具体代码实现:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 
 | int height[5001];void init(){
 for(int i=1;i<=n;i++){
 s[i]=i;
 height[i]=0;
 }
 }
 void union_set(int x,int y){
 int xroot = find_set(x);
 int yroot = find_set(y);
 
 if(height[xroot] == height[yroot]){
 height[xroot] = height[xroot] + 1;
 s[yroot] = xroot;
 }
 
 else{
 if(height[xroot] < height[yroot])
 s[xroot] = s[yroot];
 else
 s[yroot] = s[xroot];
 }
 }
 
 | 
优化查询操作
在查询过程中,我们是沿着节点一个个递归往回查找的,如果在递归回去的过程中,我们可以把递归最后一层的结果往回传递,来更新查询路径,如下

具体实现代码
| 12
 3
 4
 5
 6
 
 | int find_set(int x){if(s[x]!=x){
 s[x] = find_set(s[x]);
 }
 return x;
 }
 
 | 
二叉树
概念
二叉树就是每层每个节点最多有两个子节点:左孩子和右孩子。如果每一层的节点数都是满的,称为满二叉树。如果保持严格从左到右的顺序添加节点,且只在最后一层有缺失,称为完全二叉树

遍历
二叉树有三种常见的遍历方式
- 先序遍历:按照父结点->左孩子->右孩子的顺序遍历
- 中序遍历:按照左孩子->父结点->右孩子的顺序遍历
- 后序遍历:按照左孩子->右孩子->父结点的顺序遍历
代码就不列出了,就是简单的递归,取决于把 cout 放在哪里罢了。
这里如果只知道先序遍历与后序遍历,是不能确定一棵树的,下面这种分支,先序和后序的结果都一样

已知先序和中序如何还原树:

已知中序和后序的还原过程也和上面类似。
例题

标程
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 
 | #include<iostream>#include<cstdio>
 using namespace std;
 int n;
 struct treenode{
 int l,r;
 }node[1000010];
 
 inline void preorder(int t){
 cout<<t;
 if(node[t].l) preorder(node[t].l);
 if(node[t].r) preorder(node[t].r);
 }
 
 inline void inorder(int t){
 if(node[t].l) inorder(node[t].l);
 cout<<t;
 if(node[t].r) inorder(node[t].r);
 }
 
 inline void postorder(int t){
 if(node[t].l) postorder(node[t].l);
 if(node[t].r) postorder(node[t].r);
 cout<<t;
 }
 
 int main(){
 cin>>n;
 for(int i=1;i<=n;i++){
 int a,b;
 cin>>a>>b;
 if(a) node[i].l = a;
 if(b) node[i].r = b;
 }
 preorder(1);
 cout<<endl;
 inorder(1);
 cout<<endl;
 postorder(1);
 cout<<endl;
 return 0;
 }
 
 | 
一些小细节,这里使用了内联函数 inline,内联函数请求编译器在每个调用点直接替换该函数的代码,而不是正常的调用函数的过程,他会嵌入主函数当中,可以减少频繁调用函数所产生的开销,这里是有一些使用条件的,由于这几个遍历方式所带来的代码体量小,不会显著增大主函数的代码量,并且递归是频繁调用的,所以使用内联函数可以节约很多调用所带来的开销。
堆
堆的概念
堆是一个树形结构,是一颗完全二叉树,严格满足根节点是整颗树的最大或者最小值,同时各个父结点的值比孩子节点的值大或者小(取决于是大根堆还是小根堆)。
插入元素
堆的插入操作,就是把要插入的元素先放到数组末尾,然后在按照堆的规则往上跳

复杂度是 $O(logn)$ 因为是按照树的层级往上跳的
删除元素
元素的删除,就把叶子节点的元素覆盖到那个节点,如果是根节点,把覆盖后的节点下沉,如果是非根节点,还要考虑会上移(比如小根堆中,这个叶子节点可能比覆盖的节点还小)。

例题

代码
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 
 | #include<iosream>using namespace std;
 const int MAXN = 100010;
 int len = 0;
 int heap[MAXN+1];
 
 
 void down(int k){
 while(2*k<=len){
 int node = 2*k;
 
 if(node+1<=len&&heap[node]>heap[node+1]){
 node += 1;
 }
 if(heap[k] <= heap[node]) break;
 swap(heap[k],heap[node]);
 k = node;
 }
 }
 
 void up(int k){
 while(k>1&&heap[k/2]>heap[k]){
 swap(heap[k],heap[k/2]);
 k/=2;
 }
 }
 
 
 void insert(int x){
 heap[++len] = x;
 up(len);
 }
 
 
 
 void pop(){
 swap(heap[1],heap[len]);
 len--;
 down(1)
 }
 
 int main(){
 int n;
 cin>>n;
 len=0;
 for(int i=1;i<=n;i++){
 int x;
 cin>>x;
 insert(x);
 }
 for(int i=1;i<=n;i++){
 cout<<heap[1]<<" ";
 pop();
 }
 }
 
 
 | 
再来看一道例题

求中位数,最简单的方法就是用插入排序,每次插入排序后长度为奇数时,输出他的中间数即可。
如果用堆去做,可以将堆分为一个大根堆和一个小根堆,满足小根堆的根节点大于大根堆的根节点,这样就可以把数组等分成两个部分,然后用大根堆的根去存储中位数即可。
这里需要注意的是,堆也是需要动态调整的,如果一直出现很小的数,就会一直往大根堆里放,导致不等分,所以有两种情况:
- 小根堆不能比大根堆多,因为中位数是大根堆的根节点
- 大根堆不能比小根堆多 3 个元素以上(包括 3 个元素)这样的话就不平衡了
代码
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 
 | #include<iostream>#include<queue>
 using namespace std;
 priority_queue<int> que1;
 priority_queue<int,vector<int>,greater<int>> que2;
 
 int main(){
 int n;
 cin>>n;
 for(int i=1;i<=(n-1)/2;i++){
 int x;
 cin>>x>>y;
 int mid = que2.top();
 if(x>=mid){
 que2.push(x);
 }
 else{
 que1.push(x);
 }
 if(y>=mid){
 que2.push(y);
 }
 else{
 que1.push(y);
 }
 if(que2.size()>que1.size()){
 que1.push(que2.top());
 que2.pop();
 }
 else if(que1.size()-que2.size()==3){
 que2.push(que1.front());
 que1.pop();
 }
 cout<<que1.top()<<endl;
 }
 }
 
 | 
二叉搜索树与平衡树
概念
二叉搜索树(BST),是指对每一个节点的键值,都大于它左孩子节点的键值,小于它右孩子节点的键值。
BST不是完全二叉树,因为在插入时并不是严格从左到右插入的,会插到符合条件的地方。
使用中序遍历就能得到二叉搜索树的有序排序

插入与平衡树
因为比较复杂,所以仅作概念介绍
Treap树
Treap 树是一种平衡树,名字的由来是 Tree+Heap,Treap 树中的每个节点有两个值,键值和优先级。Treap 树的优先级是随机分配的(比起带权分配,完全随机更加能保证树的平衡性,对所有节点都是公平的)

树的左旋与右旋
BST 的插入如果按照简单的插入方法,就会导致 BST 的分支往一边堆积,导致不平衡,查找复杂度由 $O(logn)$ 变为 $O(n)$ ,这里只探讨 Treap 树的平衡方法-旋转:
我对一个点的旋转操作是这样理解的,以右旋为例,想象它从这个点被拎起来,被拎起来后原来的父节点就要挤到它的右节点去,原来的右节点被挤下来,然后继续拼到 Q 的左节点去。左旋同理
左旋——自己变为右孩子的左孩子。原来右孩子的左孩子变为自己的右孩子
右旋——自己变为左孩子的右孩子。原来左孩子的右孩子变为自己的左孩子

插入过程时旋转上升
