这篇文章开始讨论有关“树”的一些简单的概念和算法。
树是一种基本的数据结构,之所以叫树是因为来自于仿生——树枝分叉的结构或者树根分叉的结构,它非常好的表示出了各个节点之间的逻辑关系,它也是图论当中一个很重要的结构。从它的名字的角度,我们发现很多科学思维的生发都是源于对自然的敏锐的观察的,这给科研人员提供了一个非常好的方法。
我们观察自然界的树结构,很容易发现没有哪棵树的两个枝叶长到了一起,而抽象化的树结构也是这样,从根节点出发,一条路径越走越深,是不会有回路的,基于这个性质,我们可以外推树的很多别的特性。
1.一棵树中任意两个节点有且仅有一条路径连通。(很显然,如果不是这样,便会产生回路)
2.一棵树如果有n个节点,那么它恰好有n-1条边。
3.在一棵树中加入一条边将会构成一个回路。
基于树的抽象模型,我们在生活中各个方面其实都用到了这种结构,比如生物中的遗传系谱图、公司组织构图,书的目录,世界杯足球队的对阵等等,通过这种基本的数据结构,我们能够将生活很多杂乱的数据变得有条理、有逻辑而形成体系,这便是抽象化的事物给我们的现实生活带来的遍历,也是我们科学知识的初衷。
二叉树:
树结构中一个最基本也是最好用的结构就是二叉树,性质如其名字,每个节点至多有两个子节点的树结构叫做二叉树。
满二叉树:对于高度为h的二叉树,除了第h层以外,1~h-1层的节点的子节点数都达到最大(2个),那么这样的结构成为二叉树。
完全二叉树:对于一个二叉树,除去叶节点的剩余节点的子节点数都达到了最大(2个),那么这样的二叉树称为完全二叉树。
完全二叉树有一个奇妙的性质,我们从根节点开始按照“s”型给各个节点标号,会发现,对于第k个节点,它的左儿子的标号是2k , 右儿子的标号是2k + 1,反过来,它的根节点是(int)k/2。基于这条很好的性质,我们能够将一个完全二叉树用一个一位数组存储记录。
基于完全二叉树的堆排序:
首先我们来给出最小堆的定义:对于一个完全二叉树,这里我们在每个节点中放入权值,并用一个一维数组heap[]来记录,对于任意一个有子节点的节点i,都有heap[2i] > heap[i],heap[2i + 1] > heap[i]。这个定义通俗点来理解,就是说对于任意一个节点的权值都比它的两个子节点的权值小。最大堆也有着类似的定义。
基于最小堆的定义,我们很容易看到,对于一个长度为n的最小堆,heap[1](也就是堆顶的元素),一定是最小的元素,那么基于此,我们来想一想如何利用这样一个最小堆来完成对数的排序。
step1:取出堆顶元素。
step2:将堆底最后一个元素拿到堆顶,此时显然破坏了最小堆的性质,我们需要重新构造出一个有n-1个节点的最小堆。
step3:重复step1,循环直到堆变成了空集。
很显然,按照这样的输出顺序便将n个数从小到大进行了排序,这边是所谓的基于完全二叉树的堆排序过程。
那么我们现在要解决的一个重要问题便是,如何构造一个这样非常有利于排序的最小堆呢?
从定义出发,我们尝试将这个大问题给子问题化,显然如果每个有儿子的节点都满足最小堆的性质,那么这个完全二叉树便是一个最小堆,因此我们只需要遍历所有有儿子的节点,使其满足最小堆的性质即可。即找到当前节点及其两个儿子的最小权值,然后利用交换,使得当前的根节点记录这个最小权值即可。
简单的参考代码及注释如下。
#include<cstdio> int h[101]; //记录最小堆的二叉树 int n; void swap(int x , int y) //交换函数 { int t; t = h[x]; h[x] = h[y]; h[y] = t; } void siftdown(int i) //调整第i个节点,与其两个子节点,使其满足最小堆 { int t , flag = 0; while(2*i <= n && flag == 0) { if(h[i] > h[2*i]) t = 2*i; //将根节点i和左儿子比较 else t = i; if(2*i + 1 <= n) //将根节点i和右儿子比较 { if(h[t] > h[2*i + 1]) t = 2*i + 1; } if(t != i) { swap(t , i); i = t; } else flag = 1; } } void creat() //创建最小堆 , 遍历有儿子节点从第n/2往前面,第n/2是最后一个有儿子的节点 { int i; for(i = n/2;i >= 1;--i) siftdown(i); } int deletemin() { int t; //删除堆顶元素,并将堆底元素放到堆顶,重新构建最小堆 , 完成n个整数由小到大的排序 t = h[1]; h[1] = h[n]; n--; siftdown(1); return t; } int main() { int i , num ; scanf("%d",&num); for(i = 1;i <= num;i++) scanf("%d",&h[i]); n = num; creat(); for(i = 1;i <= num;i++) printf("%d ",deletemin()); return 0; }
并查集:
考虑这样一个谜题,现在警方已知n个黑社会,m条线索,每条线索表示A是B的boss,那么请问你这n个嫌疑人中,有多少个帮派,各个帮派的大boss又是谁?
我们抽象化得来看谜题中给出的各个量之间的关系,n个人视为n个点,而每条线索其实就表征了两个点之间的关系,想象一下,所谓几个帮派是不是就是整个图中形成的不相交集合(这便是所谓并查集的内涵)?而在每个小集合当中,我们基于相关的线索,是否也能够构建出一个”有方向“的树结构,比如说,A是B的boss,那么就将A视为B的祖先,那么这棵”帮派树“构造下来,根节点便是这个帮派的大boss。
那么好了,现在整体思路有了,现在我们面临这样一个问题,给出一条线索之后,我们如何判断相关的两个人是否属于某个帮派呢?这边是并查集的核心所在了。其实非常类似我们用一维数组记录二叉树,这里也是利用数组记录树结构,我们设置f[i]记录vi的祖先,假设当前给出一条线索说,A是B的boss,我们通过f[]数组来访问A、B所在帮派的大boss,如果相同,说明他们本来就在一个帮派里面了,如果不相同呢?那么需要把B加入到A的帮派当中,为什么是B加入到A当中呢?因为A是B的boss嘛,帮派显然也是要从高层往下构建嘛。这里需要注意的是,这个过程中我们只关心A、B是否在一个帮派当中,因此我们需要访问的是A、B两人所在树结构的根节点,也就是说,我们在构建并查集的时候,对于f[i],我们需要一直记录vi所在树结构的根节点。
那么现在问题又来了,如何访问vi所在树结构的根节点呢?这里也是并查集算法比较巧妙的一个地方,我们初始化f[i] = i来表示每个人都是自己的boss,然后当给出线索表明vj是vi的boss之后,我们记录,f[i] = j。因此对于访问vi的根节点这件事情,就很好处理了,我们访问vi的父节点vj,判断f[j]是否等于j,否则访问f[j]的父节点vk……,很显然嘛,这形成了一个递归,直到找到了根节点后返回。
其实上面基于的模型考虑到了边的方向性,其实在很多实际问题的处理中,即使没有边的方向性,并查集也是能够处理的,这里这样描述只是为了更清晰的引入并查集这个算法的过程。例如一开始的谜题,线索仅仅给出A和B是同伙,请你求解这n个黑社会中有几个帮派,就是一种忽略边的方向性的模型。
经过上文的分析,我们能够简单的代码实现,参考如下。
#include<cstdio> int f[1000] = {0} , n , k , m , sum = 0; void init() { int i; for(i = 1;i <= n;i++) f[i] = i; } int getf(int v) { if(f[v] == v) return v; else { f[v] = getf(f[v]); return f[v]; } } void Merge(int v , int u) { int t1 , t2; t1 = getf(v); t2 = getf(u); if(t1 != t2) { f[t2] = t1; } } int main() { int i , x , y; scanf("%d %d",&n,&m); init(); for(i = 1;i <= m;i++) { scanf("%d %d",&x,&y); Merge(x , y); } for(i = 1;i <= n;i++) { if(f[i] == i) sum++; } printf("%d ",sum); }
图的最小生成树:Kruskal算法
来考虑这样一个谜题:给出n个城镇和n个城镇之间修建m条道路的费用(每条道路的起点终点都是某个城镇),那么现在为了使n个城镇中任意两个城镇都有路走,我们修建公路的最小费用是多少?
首先考虑这样一个问题,既然题目的要求仅仅是任意了两个城市有路可走,那么应该想到,我们修出来的路是不需要构成环的,这很好理解,因为在印个环结构中,去掉任意一条边来破坏这个环结构,构成环的那些点依然是彼此连通的。诶,没有环结构,想一想,是不是就是我们提到树结构的特点呢?而我们要找的就是原图(n个点、m条边)的一个子图,也就叫做生成树,结合权值之和最小,这便是所谓的“最小生成树”。
Kruskal算法给出了这样一个贪心算法:
step1:将m条边的权值由小到大进行排序。
step2:从最小的边开始想只有点的树结构中添加边,并删除该边,前提是添加该边不会使树结构出现环。
step3:很明显,n个节点形成的树结构的边数是n-1,因此该步骤是重复step2,直到当前树结构中有n-1条边。
Kruskal算法的正确性是不言自明的,就像上帝给的你一道乍现的灵光,让你感觉一切的文字解释都显得累赘而丑陋。因此这里需要用到那个经典的词语,显然。
但是现在我们面临一个重要的问题,整个过程一个核心步骤是判断是否有环出现(因为整个算法过程一直在回避圈,因为这个算法也成为“避圏法”),如何实现呢?结合我们刚刚介绍过的并查集,可以看大,当前我们想要添加ei,连接着vi和vj,如果我们利用并查集的方法查一下vi和vj的根节点,如果相同,显然表明添加了ei会形成环;如果不相同,就可以放心大胆的将当前权值最小的边添加到正在构造的树结构里啦。
下面有简单的参考代码。
#include<cstdio> #include<algorithm> using namespace std; struct edge { int u; int v; int w; }; bool cmp(edge a , edge b) { return a.w < b.w; } struct edge e[10]; int n , m; int f[7]={0},sum = 0 , cnt = 0; int getf(int v) { if(f[v] == v) return v; else { f[v] = getf(f[v]); return f[v]; } } int Merge(int v , int u) { int t1 , t2; t1 = getf(v); t2 = getf(u); if(t1 != t2) { f[t2] = t1; return 1; } return 0; } int main() { int i; scanf("%d %d",&n,&m); for(i = 1;i <= m;i++) scanf("%d %d %d",&e[i].u,&e[i].v,&e[i].w); sort(e + 1,e + m + 1,cmp);//给m个边的权值排序 for(i = 1;i <= n;i++)//并查集父节点初始化 f[i] = i; for(i = 1;i <= m;i++)//图的最小生成树:Kruskal算法 { if(Merge(e[i].u , e[i].v))//如果连通,则删除此边,否则选择该边 { cnt++; sum = sum + e[i].w; } if(cnt == n - 1) break; } printf("%d",sum); }