面试 | 带你一天速成数据结构与算法
点击左上方蓝字关注我们
作者:阿秀
阿秀的校招笔记:https://interviewguide.cn
绝大部分学校的数据结构课程应该是:
作者:不吃鱼的喵酱
链接:https://www.zhihu.com/question/303208441/answer/538673362
先说第一块,线性结构。这里涉及的主要知识点就是顺序表和链表,以及衍生出来的栈和队列。顺序表不必多说,就是内存中一块连续的区域,紧密排列了若干个相同类型的数据。显然,这种设计需要事先知道同样的元素一共有多少,不然就无法开辟出合适的内存区域(即会存在浪费或者不足)。为了解决数组这种元素数量不灵活的缺点而提出的方法就是链表。链表的基本单位是节点,每个节点拥有一个数据区和一个next指针,其中数据区用于存放数据,next指针指向下一个节点。与顺序表相比,链表可以根据需要自由选择节点的数量,从而解决了内存分配不合适的问题。
但是链表并不是万能的,是否选用链表要根据实际情况进行斟酌(后面是重点)。第一,顺序表可以随机访问其中的元素,也就是说,使用顺序表可以以一个恒定的小代价访问其中的任意一个元素,即查找的时间复杂度为O(1);链表查找其中某一个位置的特定元素则必须从头开始一个一个的沿着next指针数过去,即查找的时间复杂度为O(N)。第二,顺序表在插入和删除元素的时候需要找到特定位置的元素,然后将其后面的全部元素都向前移动或者向后移动,以填补或腾出空位,因此顺序表的插入和删除的时间复杂度都是O(N);但是链表只需要摘去或者挂上一个节点就行了,因此链表的插入和删除的时间复杂度都是O(1)。
顺序表的构造思路十分简单,只要一个一个往里塞就行。在实践中,一般使用一个下标保存当前顺序表的结尾位置,插入元素时直接在这里插入,然后让下标向后移动。链表一般分为头插法和尾插法两种方式。头插法就是把新节点直接插在节点链的头部,比较适合构造栈;尾插法把新节点插在链表末尾,比较适合构造队列,而且需要额外的指针指向尾节点。插入过程如下:
第一步,将新节点的next指针指向要插入的位置的后一个节点(new_node->next = p->next;) 第二步,把要插入的位置的前一个节点的next指针指向新节点(p->next = new_node;)
删除节点过程如下:
第一步,将要删除的节点的上一个节点的next指针指向被删除的节点的下一个节点(p->next = deleted_node->next;) 第二步,释放被删除的节点(free(delete_node);)
双链表在单链表的基础上增加了一个前向指针previous,即对于每一个节点可以同时找到它的上一个和下一个节点。这能让链表在构造的时候代码更好写,具体情况参考书上。双链表一般不怎么考,根据需要选用。
队列和栈是被特化了规则的线性结构,属于逻辑结构的范畴,并不拘泥于某种特定的物理结构实现。换句话说,任何满足先进先出(FIFO)的结构都可以被描述成队列,而任何满足后进先出(LIFO)的结构都可以被描述成栈。
使用顺序表构造队列需要一个头指针和一个尾指针。进入的元素在尾指针处插入,取出的元素从头指针处去除;使用链表构造队列需要使用尾插法,并从头部移除元素。队列就是简单的排队,在诸如计算机网络的分组交换、CPU时间片轮转等场合有广泛的应用。
使用顺序表构造栈只需要一个栈顶指针。元素从栈顶指针处入栈(push),同样从栈顶指针处出栈(pop)。使用链表构造栈需要使用头插法,并从头部移除元素(此时指向链表头结点本身的指针即为栈顶指针)。栈在诸如编译时的括号匹配、程序运行时的函数跳转等场合有广泛的应用。
在上文中,我们会发现,在使用顺序表实现队列,并频繁地插入和移除元素后,两个指针渐渐会来到表的结尾,这时候我们就需要逻辑上的环来避免这一问题。将节点自增从pointer++;改成(++pointer)%length;即可解决这一问题。当指针来到结尾处时会由于模运算回到开头。链表则需要把尾节点的next从悬空改成指向头结点,并且让原来指向头结点的指针指向尾节点即可。这样一来,p即为末尾,而p->next即为开头。
块状表是一种结合了顺序表和链表的结构。块状表吸收了链表的next指针所带来的动态优势,同时把链表的数据区扩展成一个小的顺序表。这样一来,既可以满足动态请求内存的需要,又可以避免查找元素时O(N)复杂度的困扰(事实上可以把O(N)降低到O(N/M+1),M是小顺序表的长度)。块状表是一种相对折中的方案,可根据需要选取,并且一般考试不会考。
伴随着线性结构而来的就是常用的各种排序算法,我这里只说思路不说实现,并且只提供平均时间复杂度。
最基础的就是冒泡排序,基于交换思想。其想法是将每一个元素与它后边的元素相比,如果前面的更大就交换位置。对于每一个元素来讲,当交换停止时,都满足前面的元素小于它,后面的元素大于它,因此整个数组有序。冒泡排序的平均复杂度是O(N²)。
除了交换的思想,还有一种常用的思想是插入。基于这种思想的排序法是插入排序和选择排序。插入排序会维护一个小的有序队列,在排序开始时这个队列的长度是0,此后,每一次将一个新的元素插入这个有序队列中合适的位置,则当全部的元素都插入这个队列时排序完成。插入排序平均复杂度是O(N²)。选择排序则是每一次都遍历所有未排序的元素,从中选出最小的或者最大的元素插入有序队列的头或者尾,平均复杂度同样是O(N²)。
同样基于插入思想却又与上两者不同的方法是希尔排序和桶排序。希尔排序与插入排序基本相同,但是在开始时会规定一个增量(一般是数组长度的一半),并且每一趟将这个增量缩小至之前的一半,直至增量变为1。希尔排序根据增量把每隔N个的所有元素分为同一组,对每一组内使用插入排序。当增量为1时,对整组元素逐个排序。尽管希尔排序的平均复杂度也是O(N²),但是在实践中一般比插入排序更快(因为每一次处理的都是部分有序的数列,移动元素的次数较少)。桶排序则更好的体现了插入的思想,事先将最小值到最大值之间的区间分成N个桶,每个桶涵盖了相同的数据范围。每次从数组中取出一个元素放入对应的桶内,并将其插入到桶内的所有元素组成的小数组中的合适位置,以此完成排序。最后只需要按顺序把每个桶中的所有元素倒出来就行了。桶排序对于空间的需求相对较大,但是相应的会减少时间上的需求,平均复杂度我懒得算了,但是可以确定是log级别平方级别之间的,但是在桶划分不合理时会退化到O(N²)。
快速排序则是基于分治法,属于最难理解的一个。快速排序从局部数组中(在第一趟中,这个局部指的是整个数组)随机选取一个中间数,然后将大于它的数全部移动到右边,小于它的数全部移动到左边,再对左右两个局部数组递归进行上述操作,直至在某一趟中每个局部数组都只有一个元素。在交换结束时,每一个数都满足左边的比它小,右边的比它大,因此整个数组有序。快排的平均复杂度是O(N*logN),因此叫快速排序,但是在整个数组已经有序时会退化为O(N²)。
归并排序同样基于分治法,也是上述所有排序法中唯一一个外部排序法。归并排序的基本思想是合并N个有序数组,当N为1时排序完成。归并排序主要分为两步,第一步把大数据集分成N个小数据集,并使用任意一种内部排序法对每一个小数据集进行排序;第二步是每次将其中的K个已经有序的小数据集进行合并(称为K路归并)。归并排序的平均复杂度是O(MNlogN),其中M为每个小数据集中数据的个数,N为小数据集的数量,log的底数为K。
基数排序则是最无聊的一种排序法。假设有一个数据集是{456, 123, 789},基数排序先比较个位数字并排列有序,再比较十位数字并排列有序,最后比较百位数字并排列有序。人类在查找纸质字典的目录时就是在进行基数排序。基数排序不太容易衡量复杂度,也不太可能考。
还有一些常用的排序方法,比如堆排序和二叉排序树,我们会在后面讨论。另外,要是有人跟你说睡排序和猴子排序,请直接把他打死。
讲完线性结构,我们再来讲讲树状结构。树状结构最基础的就是二叉树,我们就从这里入手,顺便看看复杂度中的log是怎么来的。
首先要先普及一些概念:每一棵树有唯一的根节点,在此基础上向下生长。每一个节点的所有直接后代称为它的孩子节点,孩子的直接先代称为父亲节点。没有孩子的节点称为叶子节点。树中不能有环,每一个节点都必须有且仅有一个父亲节点(根节点除外)。根据定义我们同样可以推理出,以每个非叶节点的每一个孩子节点作为根节点,都可以得到一棵子树。从根节点到叶子结点的最长路径称为树的度(或者深度)。
在此基础上,每个非叶节点至多只有两个孩子的树称为二叉树。显然二叉树的深度介于log₂N与N之间。当深度为N时,二叉树退化为线性表。二叉树节点的两个孩子分别称为左孩子和右孩子,同理会衍生出左子树和右子树的概念。
链式存储的二叉树十分直接,每个节点包含一个数据区和两个孩子指针。数据区用于存储数据,孩子指针分别指向两个孩子,如果没有孩子就悬空。这一节的重难点其实在二叉树的线性存储,即将二叉树保存在顺序表中。这种方式会成为堆排序的理论基础,并且在存储完全二叉树时有明显的优势。下面我们将展开来讲。
在说明线性存储之前,我们必须要引入满二叉树的概念。根据定义,除最后一层无任何子节点外,每一层上的所有结点都有两个子结点二叉树被称为满二叉树。如下图
对于一棵满二叉树,我们按照从左到右,从上到下的顺序给每一个节点编上号(我的教材是从1开始编号,因为方便运算),就能轻易发现一个事实:假设某一个节点的编号是N,那它的的两个孩子节点的序号分别是2N和2N+1。下面,我们把这个编号作为数组下标,就可以得到二叉树的线性存储方式了。
对于不满的二叉树,我们要先把它补齐成满二叉树,然后把补上的节点空出来,就可以完成线性存储。存储一棵二叉树所需的总的线性空间与它的度有关,即2的N次方。显然,满二叉树极少浪费线性空间,而偏差较大的二叉树会极大地浪费线性空间。
建立一棵二叉树十分简单,一般有两种方式:从根向叶子和从叶子向根。前者可以被用来建立二叉排序树,一会儿会讲到;后者可以用来建立哈夫曼树,网上资料很多,有看不明白的地方自己再查一下。这两种树都属于常考内容,应用也十分广泛。
正常的树都是从根向叶子生长,所以逆生长的哈夫曼树就显得比较特别。哈夫曼树一般用于压缩算法,可以用来生成前缀码。前缀码指的是,在一套编码体系中,任何一个字的密文都不是其他字的密文的前缀,或者说对于任何一个字的密文,从头开始连续截取任意长度,得到的结果都不能构成另外一个字的密文。不是前缀这个特性保证了编码没有歧义,因此可以按顺序处理而不必担心出现错误。摩斯电码是非前缀码,因此每两个字之间需要提供明显的停顿用以显示表明这是不同的两个字。如果这个停顿不够明显,也就是发报速度比较快,就比较容易造成歧义。
前缀码的一个特性就是每个字长短不一,显然出现频率更高的字使用更短的密文能获得较大的空间和时间优势。所以,哈夫曼树的第一步就是从统计字频开始的。这一步只需要遍历文本流就可以,很简单,按下不表。
第二步就要开始建树了。由于哈夫曼树是逆生长树,采取的是合并子树的思想,所以最先被选择的一定是最深的子树。合并子树的方法如下:在最开始的时候,把每一个节点都视为一棵只有一个根节点的树。每一次迭代,选取频率最低的两棵子树进行合并,直到最后只剩下一棵树为止。举例来说,假设刚开始有a,b,c三棵子树,频率分别是0.1,0.2,0.7,那么第一次迭代就会选择0.1跟0.2进行合并,得到一棵频率为0.3的新子树,然后再把这棵新子树与0.7合并完成建树。显然,从根节点开始,寻找c只需要一步,而寻找a和b各需要两步,平均字长为1.3。
建立哈夫曼树的目的是为了进行哈夫曼编码。前文也提到了,这是一种压缩算法。压缩的过程就是建立上述的哈夫曼树,然后遍历哈夫曼树写出每个字的密文,再按照查字典的方式把每个字转换过去。而解压缩的时候,则需要先重建哈夫曼树,再一位一位对照密文从树根开始向下寻找,找到叶子结点就可以认为解码出了一个字,然后下一位回到树根重新寻找。解码的过程比较类似状态机模型,要写成模块化的模式还真不是太好写,反而是面向过程的方式比较好写。在具体编码过程中,指定左孩子的编码为1或是右孩子的编码为1都不会影响结果,事实上也没什么标准,甚至在中途随意翻转都可以。不同的程序员跑出来的哈夫曼编码结果不同是很正常的一件事,只要不影响编码和解码的使用就行。
说完了不正常生长的哈夫曼树,再来说说正常生长的二叉排序树。二叉排序树的定义是:每一个节点的左孩子小于它,而右孩子大于它(等于的情况事先声明一下放左还是放右就行,对于结果无实质影响)。根据这个定义,我们可以递归地得出性质:对于每一个节点,其左子树全部小于它,其右子树全部大于它。因此,为了满足性质,在建立二叉排序树时只需要从根节点开始,递归地比较每一层元素,如果其比要插入的元素大则走向其左孩子,反之走向其右孩子,直至最终来到叶子结点,并把该元素插入。当全部的元素都被插入了,二叉排序树就建立完成了。
接下来就要取出其中的有序数列了,也就是进行二叉树的遍历。二叉树的遍历一般分为先序遍历、中序遍历和后序遍历三种。先序遍历即先访问根节点,再寻找左孩子,最后寻找右孩子。中序遍历是先寻找左孩子,再访问根节点,最后寻找右孩子。后序遍历先寻找左孩子,再寻找右孩子,最后访问根节点。可以说,先中后指的是访问根节点的时机。由于遍历是递归的,使用中序遍历一路寻找到的最“左”的左孩子就是二叉排序树中的最小元素,且中序遍历的输出顺序就是从小到大的顺序。根据前序遍历和中序遍历或后序遍历和中序遍历可以重建整棵树,这也是考试的热点和难点。
二叉排序树的平均复杂度是O(N*logN),其中的log就来自于二叉树的深度。当数组已经有序时二叉排序树会退化为O(N²),而且绝大部分时候都建不出漂亮的二叉树,所以这个log其实是有很大水分的。
为了尽量建出漂亮的二叉树,人们想出了很多办法,其中一项就是平衡二叉树(AVL树)。平衡二叉树是指每一个节点满足左子树的度与右子树的度相差不超过1的二叉排序树。由于其限制了节点的左右孩子,因此能让整棵树更加紧凑,从而大量挤出了log中的水分。建立AVL树需要用到复杂的旋转操作,几乎不会考到,所以我不讲了。
使用二叉排序树排序尽管复杂度较低,而且十分容易理解,但是需要O(N)级别的辅助空间,并不是很划算。仔细想一想,既然能把二叉树存放在顺序表里,那顺序表本身是不是也能被看成是线性存储的二叉树呢?答案是肯定的。一张顺序表可以被看做是一个完全二叉树,即除了最后一层外每一层元素都是满的,且最后一层的元素全都集中在左边的二叉树。显然,满二叉树是完全二叉树。
堆就是完全二叉树的一种应用,硬要说的话也属于反向生长。常用的堆分为大顶堆和小顶堆两种,前者满足父亲节点大于孩子节点,后者满足父亲节点小于孩子节点。根据递归我们可以推算出,大顶堆堆顶的元素是最大的,小顶堆堆顶的元素是最小的。堆排序就是在不断地重复建堆并移走堆顶元素的过程,显然平均复杂度是O(N*logN),而且由于完全二叉树的性质,这个log没有水分。
堆排序最神奇的地方就是它不需要借助一个链式的二叉树的辅助,而是直接在顺序表中操作元素,因此它的空间复杂度是O(1)级别的。回想一下二叉树的线性存储相关的内容,我们会猛然想起父亲节点与孩子节点的序号之间的关系,从而明白为什么不需要辅助树。
第一步,在逻辑上建堆。这一步要求我们根据实际情况(即数组下标从1或者0开始)来推演父亲节点与孩子节点真实的下标关系,在脑海中建立这个堆。第二步,满足堆的性质。这一步我们要对所有的非叶子节点从下到上进行调整,使其满足大顶堆或者小顶堆的性质。具体做法是找到第一个非叶子节点,并对它与它的孩子节点进行调整,使其满足堆的性质;然后从这个节点开始向前线性地调整每一个非叶子节点,直至根节点。此时整个堆都满足性质。第三步,取得堆顶元素。这一步会把堆顶元素与堆内序号最大的元素进行交换,并且堆内元素数量减一。显然这个堆顶元素满足剩余未排序元素都比它小或比它大,而前面的已排序元素都比它大或比它小,因此它在已排序队列中的相对位置是确定的,即头或尾。第四步,恢复堆的性质。由于刚才把一个无序元素插入了堆顶,导致堆的性质被破坏,接下来我们需要恢复它的性质。这一步不需要逆序遍历,而是从堆顶开始,将刚插入的元素逐步向下落,直至停在合适的位置。举例来讲,对于大顶堆,要保证堆顶元素是最大的,因此要把它分别与左右孩子进行比较,并且把三者中最大的一个升上堆顶。由于左右孩子在刚才的操作中都没有变动,因此各自满足在子树中是最大的的性质,此时若堆顶元素比他们两个都大,便能推理出堆顶元素是最大的。同理,升上去的三者中最大的元素也能满足这个推理。将堆顶元素向下落的操作要递归地进行到该元素不需要再进行交换为止,此时整个堆恢复性质。第五步,重复第三步和第四步的操作,直至堆被清空。此时,整个数列有序。
堆排序非常喜欢考,不仅考方法论还要考实现,而且这东西略抽象,不是很好掌握。刚才突然心血来潮写了个实现,凑合着看一下吧。我比较懒,用CPU时间换了内存,数组下标里面各种运算。其实可以拿临时变量装一下来节省CPU的。C语言需要事先声明函数才能使用,我也没照顾可读性写函数原型,看的时候记得倒着看。
#include <stdio.h>
void reconstruct_heap(int a[], int index, int last)
{
int tmp;
if(index * 2 + 1 > last) // 检查是否有孩子
{
return;
}
if(index * 2 + 1 == last) // 检查是否只有左孩子
{
if(a[last] > a[index])
{
tmp = a[index];
a[index] = a[last];
a[last] = tmp;
}
}
else
{
if(a[2 * index + 1] > a[index] && a[2 * index + 1] > a[2 * index + 2]) // 左孩子最大
{
tmp = a[index];
a[index] = a[2 * index + 1];
a[2 * index + 1] = tmp;
reconstruct_heap(a, 2 * index + 1, last);
}
else if(a[2 * index + 2] > a[index] && a[2 * index + 2] > a[2 * index + 1]) // 右孩子最大
{
tmp = a[index];
a[index] = a[2 * index + 2];
a[2 * index + 2] = tmp;
reconstruct_heap(a, 2 * index + 2, last);
}
}
}
void establish_heap(int a[], int last)
{
int i = (last - 1) / 2;
while(i >= 0) // 使每一个元素满足大顶堆的性质
{
reconstruct_heap(a, i, last);
i--;
}
}
void heap_sort(int a[], int n) // n为数组长度
{
int tail = n - 1, tmp;
establish_heap(a, tail); // 建立大顶堆
while(tail > 0)
{
tmp = a[0]; // 取出堆顶元素
a[0] = a[tail];
a[tail] = tmp;
tail--;
reconstruct_heap(a, 0, tail);
}
}
int main(void) {
int a[] = {13, 8, 3, 0, 7, 16, 18, 15, 12, 11, 19, 10, 9, 6, 1, 14, 17, 5, 4, 2};
int n = 20, i;
heap_sort(a, n);
for(i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
return 0;
}
锦标赛排序很多书上都不写了,但是在这里我想提一下。锦标赛排序是选择排序的优化版,每一次将相邻的两个元素进行比赛,选出其中的优胜者(较大者或较小者,看需求)。其思路类似于小组赛-十六强-八强-半决赛-决赛的过程,在决赛中选出的一定是全局最优元素。接下来我们提取这个全局最优元素,然后抹除其存在,并且把它参与过的所有比赛进行重赛,从而得到全局次优。当最后所有的元素都被抹除,锦标赛排序就完成了。锦标赛排序最聪明的地方就在于它保存了之前已经进行过的比赛,从而在选取了全局极值以后不需要对绝大部分比赛进行重赛,因而节省了时间。其平均时间复杂度为O(N*logN),空间复杂度为O(N)级别。这里的log同样没有水分,因为建立起来的比赛树几乎是满的(但不必是完全二叉树)。
虽然绝大多数情况下我们见到的树都是二叉树,但是并不妨碍多叉树在日常生活中起到重要作用。一个经典的例子就是3D计算机图形学中使用的八叉树,用来分割三维空间,在查找元素时能大大加速。多叉树相对于二叉树来讲没有孩子数量一定的限制,因此通常用一个孩子列表来保存全部的孩子节点,这一种应用在网页的DOM中尤其广泛,倒不如说整个XML规范文档以及JSON规范都可以抽象成多叉树。操作系统的目录树也属于这一应用范畴。这个孩子列表在实践中一般是个块状表,既保证伸缩性又保证快速访问。
森林就是多棵树,这个没什么好讲的。
最后我们来提一下B+树,有的教材上也用B树来代指B+树。B+树是一种极其鬼畜的多叉树,结构复杂但是十分有效,极其常用于索引的建立,包括数据库索引、目录树索引等等(散列同样常用,但是我忘了在书上的哪一章节了)。B+树的主要难点在于节点的分裂与合并,非叶节点与叶子结点的大小的上界与下界,以及树深度的伸缩,属于研究生课程的范畴,我不打算深入去讲,因为非常抽象并且不好实现,甚至比AVL树的四种旋转还要难理解。在设计B+树时需要考量节点大小,而这个大小一般是由计算机的一些物理性质决定的,缺乏计组的基础我觉得我也给你说不明白。总之,这东西很有用,但是不考。
最后一部分是网状结构,也就是图论相关的东西。这一章东西不多,除了三种常用算法以外大部分知识点在以后也很难用到,实在学不会可以考虑跳过。另外,离散数学课程里会再讲一遍这部分内容,迟早会学的滚瓜烂熟的。
关于图,有一些概念是要先提及的。首先,图是由顶点和边组成的,根据边的方向性又可以分为有向图和无向图(这一点看地图上的单行路就明白了)。如果在一个有向图中任意两个顶点可以相互到达,则称这张图为强连通图;反之,若不满足强连通图的定义,但是将所有的有向边修改为无向边后原有向图能构成连通图,则称该有向图为弱连通图。由于不像树一样要求唯一的父亲,图是允许有环的,并以此分为有环图和无环图。
通常存储图有两种方式,即集合的方式和矩阵的方式。前者维护两个集合,即一个顶点集合V和一个边集合E。顶点集合中保存了所有顶点的信息以及序号,边集合保存了被一条边连通的两个顶点的序号以及边的代价(无权图可认为每一条边代价都是一样的)。由于稀疏图占了日常生活中的图的绝大多数,因此集合的方式是保存图的主要方式。矩阵的方式取消了边集合,改用一个矩阵保存每两个顶点之间的代价。显然,顶点与自己的代价是0,与邻居的代价已知,与不直接相连的顶点代价为无穷大。只有在绝大多数顶点都彼此直接相连的情况下,矩阵的方式才能更节省空间。
图中每个顶点的入度和出度,也就是汇入顶点的边的数量以及顶点发出的边的数量,往往具有重大的意义。边集合往往只能快速统计其中的一项,而统计另一项开销较大。显然,矩阵的方式是更直观的,可以以O(1)的代价查找任意两个节点之间的连通情况,反而是集合的方式必须以O(N)的代价进行查找。在统计入度和出度上矩阵的方法看上去也更快。根据具体需求选择时间换空间或者空间换时间是算法选取的一大原则。
为了节省矩阵的空间开销,矩阵的链式存储应运而生。这种方法只关心矩阵中存在的元素,而忽略不存在的元素。每一个矩阵会被存储为一个行数组和一个列数组,以及一系列节点。两个数组中的每个元素各带有一个指针,指向该行或该列的第一个元素;每个节点保存了行号和列号,同时带有两个指针,分别指向该行的直接后继和该列的直接后继。使用这种结构的矩阵平衡了空间和时间的开销,对于稀疏矩阵提升尤其明显,但是随着矩阵中元素数量的增加效率会降低。
集合的方式这边也拿出了邻接表来进行快速查找。邻接表就是很简单的使用链表,为每一个节点建立一个链式的出度表,从而达到快速查找的目的。如果需要统计入度,那么应同时维护一张逆邻接表来对入度建立索引。当然,无向图可以把出度和入度混在一起记录,反正是无向的。
为了克服需要同时维护两张表的缺陷,人们发明了十字链表和邻接多重表,分别用于处理有向图和无向图。十字链表将每一条边作为节点,这个节点记录弧头和弧尾,同时拥有一个head指针和一个tail指针;每个顶点也拥有两个指针,一个指向第一条入度的边,另一个指向第一条出度的边。使用时,顶点的入度指针沿着head指针一路找过去,完成对入度的遍历;而出度指针沿着tail一路找过去,完成对出度的遍历。
考虑这样一张有向图,并假设顶点集合和边集合都已经整理好了。那么,根据这两个集合,我们可以建立十字链表:
其中绿色的就是入度指针。从图中我们可以看出顶点A的入度一共有CA和DA两条边,因此沿着head指针能找到这两条边;同理,黄色的是出度指针,沿着tail指针就可以完成对出度的遍历。十字链表巧妙地节省了一张表。
邻接多重表基于对邻接表的改进。由于其适用于无向图,所以不存在head和tail,但是依旧有两个指针。邻接多重表的节点结构与十字链表类似,并且同样用于存放边,不同的是每一个顶点后面紧跟着一个指针,并且每个节点还多出来了一个标志位用来存放是否被访问过。举例来讲,假设一个节点其中的顶点序号是2和5,那么2后面的指针会指向下一个出现了2的顶点(顶点顺序无所谓),而5后面的指针指向下一个出现了5的节点。顶点节点只保留一个指针,指向第一条连接此顶点的边。假设顶点序号是2,那么只要跟随每一个节点中编号为2的顶点后面的指针就可以完成对出入度的遍历。由于与十字链表类似我就不画图了。
说完了图的存储,下面来聊聊图的遍历。遍历就是从一个顶点出发,沿某一种规则访问全部的顶点,并且每个顶点只访问一次。遍历主要分为深度优先遍历和广度优先遍历两种。顾名思义,深度优先就是一条路走到黑再回头,广度优先则是每条路都走一点。深度优先使用一个栈,对于每一个顶点先把它全部邻接的、未被访问的顶点都压入栈,然后从栈顶弹出一个节点作为接下来要访问的顶点。形象地说,当顶点有邻居1时会去访问邻居1,然后访问邻居1的邻居1,直到没有新的邻居再退回来访问邻居2。当栈被清空时遍历结束。广度优先则使用一个队列,对于每一个顶点先把它全部邻接的、未被访问的顶点都压入队列,然后从队列头弹出一个节点作为接下来要访问的顶点。形象地说,当顶点有邻居1时会去访问邻居1,然后访问邻居2,直到没有新的邻居再退回来访问邻居1的邻居1。当队列被清空时遍历结束。这段话写得应该不抽象,很好理解。
深度优先遍历在诸如迷宫求解的时候应用较好,如果途中有环则需要记录已经访问过的顶点,否则不需要;广度优先遍历适合浅层的关系,比如AI寻路(作为A*算法的基础),比如通过社交关系网查询两个用户之间的距离。当然,不使用队列和栈也可以,那样就要使用递归。
图的最小生成树的作用是去除图中的环,同时使整体代价尽可能的小。常用算法包括普里姆算法(Prim)和克鲁斯卡尔算法(Kruskal)。普里姆算法的思想是将图划分为已连通和未连通部分,初始时已连通部分为任意顶点,在每一次迭代中计算每一个已连通部分的直接邻居到已连通部分的代价,然后选取代价最小的顶点连通,直至最后连通整张图。这只是思路,实现上并不是这么写的,有很多玄妙的部分,但是这里我懒得写了,因为要用到太多的辅助图。克鲁斯卡尔算法则是首先将所有的边按照代价排序,并假设所有的顶点各自处于一个聚类中,每次迭代选取一条连接两个不同聚类的、代价最小的边(即连接同一个聚类的边即使代价更小也必须舍弃),然后将这两个聚类划拨为同一个聚类,直至最后只剩下一个聚类。
最小生成树算法可以应用于网络布设中,使用最低成本达到连通所有节点的目的。但是,这种做法并不能保证任意两个节点之间的距离都是最短的,同样也容易造成星型布局,并使得上游节点遭受随之而来的带宽压力。但这种做法可以使总成本最低。
如果需要求某一个顶点到所有顶点的最短路径,常用的算法是迪杰斯特拉算法(Dijkstra,对,就是提出goto有害论的那个)。迪杰斯特拉算法会维护一张表,记录该顶点到所有顶点的距离。初始时只把该顶点的直接邻居全加入并更新代价,从中选取代价最小的邻居作为新的起点,再把新的起点到它的所有的直接邻居的代价加上起点到它的代价与表中已有代价对比,选择代价较小的保留,比较结束后选择代价最小的留下,作为更新的起点,直至最后所有顶点都被留下。
迪杰斯特拉算法在计算机网络中有大量应用(OSPF协议),也就是在路由器估计网络拥塞状况并智能选择更空闲的路径。计网中还有一种RIP协议,你们学到了就知道了。
以上三种算法年年考实现。
看到这里,如果所有的知识点你都能掌握了,那么已经足够你拿到优秀了。剩下的部分是拓扑排序,不是很喜欢考,但还是提一下。
拓扑排序用于清理AOV网(Activity On Vertex)。比如某一系列课程的复杂的前置关系就可以看成是一个AOV网,它是一个有向无环图。拓扑排序负责从其中找出一个顺序,可以在不违反所有前置课程条件的情况下完成对每一门课程的学习。拓扑排序每一次移除一个入度为0的顶点,然后移除该顶点的所有出度边,重复此操作直至最后移除全部的顶点。拓扑排序亦可用于复杂关系网的死锁检测。这是十分工业而且贴近管理的东西,一般不会在代码项目中遇到。
此外还有一个更贴近管理的东西叫AOE网(Activity On Edge),同样记录了前置条件,但是目的是找出打成最终目标的最长路径(关键路径),从而估算出工期。小范围调整非关键路径上的活动不会影响最终的工期。概括来说,要求关键路径分为以下几步:第一步,从起点开始到终点为止,计算每个活动的最早开始时间。这里的最早开始时间指的是这个活动无论如何也不可能早于这个时间开始,因为它的前置条件还没有完成。第二步,从终点的最早开始时间反推回去,求每个活动的最晚开始时间。这里的最晚开始时间指的是这个活动无论如何也不能晚于这个时间,不然它后面的活动不能按时开始。第三步,相减。那些最早开始时间等于最晚开始时间的活动就是关键活动,所有的关键活动组成的就是关键路径。
END
点赞三连,支持一下吧↓