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

并查集标程,并查集的构造需要三个核心功能:
如何查有多少个集合:直接 for
循环遍历,查找有多少个 s[i]=i
,就有多少个集合
问2,查找,合并的复杂度是多少:
合并操作的复杂度其实就是查找的复杂度,查找最坏情况下要遍历所有的节点,所以是$O(n)$
标程代码
1 2 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 层,变得更高,查找时就更慢。我们可以优化这种操作,让他把高度较小的树并到高度较大的树中

具体代码实现:
1 2 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]; } }
|
优化查询操作
在查询过程中,我们是沿着节点一个个递归往回查找的,如果在递归回去的过程中,我们可以把递归最后一层的结果往回传递,来更新查询路径,如下

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

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

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

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

标程
1 2 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)$ 因为是按照树的层级往上跳的
删除元素
元素的删除,就把叶子节点的元素覆盖到那个节点,如果是根节点,把覆盖后的节点下沉,如果是非根节点,还要考虑会上移(比如小根堆中,这个叶子节点可能比覆盖的节点还小)。

例题

代码
1 2 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 个元素)这样的话就不平衡了
代码
1 2 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 的左节点去。左旋同理
左旋——自己变为右孩子的左孩子。原来右孩子的左孩子变为自己的右孩子
右旋——自己变为左孩子的右孩子。原来左孩子的右孩子变为自己的左孩子

插入过程时旋转上升
