前端必经之路:你所要了解的数据结构

       数据结构是计算机存储、组织数据的方式,它是相互之间存在一种或多种特定关系的数据元素的集合。数据结构往往同高效的检索算法和索引技术有关,通常情况下,精心选择的数据结构可以带来更高的运行或存储效率。

       数据结构包含三个方面:数据元素之间的逻辑关系(即数据的逻辑结构)、数据元素及其关系在计算机中的存储方式(数据的物理结构存储结构)以及施加在数据上的操作(数据的运算)。

       所有能被输入到计算机中,且能被计算机处理的符号的集合,称为数据。数据是计算机操作对象的总称。数据与数据之间的联系被称为数据的逻辑结构 ,根据关系的紧密程度,逻辑结构被分为四种:集合线性结构树形结构图形结构

       集合表示数据结构中的元素之间除了“同属一个集合” 的相互关系外,别无其他关系。线性结构表示数据结构中的元素存在一对一的相互关系。树形结构表示数据结构中的元素存在一对多的相互关系。而图形结构表示数据结构中的元素存在多对多的相互关系。

       上面四种数据的逻辑结构在计算机存储空间的存放形式(映像)被称为数据的物理结构。物理结构分为顺序存储结构链式存储结构数据索引存储结构数据散列存储结构


       顺序存储结构:把逻辑上相邻的结点存储在物理位置上相邻的存储单元里,结点间的逻辑关系由存储单元的邻接关系来体现。在计算机中用一组地址连续的存储单元依次存储线性表的各个数据元素,称作线性表的顺序存储结构。顺序存储结构可随机存取表中数据,并且插入和删除操作需要移动元素。     


       链式存储结构:在计算机中用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的)。它不要求逻辑上相邻的元素在物理位置上也相邻,因此它没有顺序存储结构所具有的弱点,但也同时失去了顺序表可随机存取的优点。


       链式存储结构每个节点都是由数据域和指针域组成,它比顺序存储结构的存储密度小,逻辑上相邻的节点物理上不必相邻。在插入、删除操作上链式存储结构更灵活(不必移动节点,只要改变节点中的指针),可在查找结点时链式存储要比顺序存储慢。


       数据索引存储结构:除建立存储结点信息外,还建立附加的索引表来标识结点的地址。索引表由若干索引项组成,如果每个节点在索引表中都有一个索引项,则该索引表就被称为稠密索引。若一组节点在索引表中只对应于一个索引项,则该索引表就称为稀疏索引。索引项的一般形式一般是关键字、地址。在搜索引擎中,需要按某些关键字的值来查找记录,为此可以按关键字建立索引,这种索引就叫做倒排索引(因为是根据关键词来找链式地址,而不是通过某个链式搜索关键词,这里反过来了,所以称为倒排索引),带有倒排索引的文件就叫做倒排索引文件,又称为倒排文件。倒排文件可以实现快速检索,这种索引存储方法是目前搜索引擎最常用的存储方法。


       例如在存储单词的过程中,数据索引存储先在某个地址空间存储单词,然后把该单词的关键词和存储地址存到附加的索引表。在查找某个单词的过程中,就先根据关键词找索引表,得到数据存储地址。然后再通过存储地址得到数据。
       索引存储结构是用结点的索引号来确定结点存储地址,其优点是检索速度快,缺点是增加了附加的索引表,会占用较多的存储空间。


       数据散列存储结构:散列存储,又称hash存储,是一种力图将数据元素的存储位置与关键字之间建立确定对应关系的查找技术。散列法存储的基本思想是:它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组叫做哈希表。

       散列是数组存储方式的一种发展,相比数组,散列的数据访问速度要高于数组。要依据数据的某一部分来查找数据时数组一般要从头遍历数组才能确定想要查找的数据位置,而散列是函数通过“想要查找的数据”作为“输入”、“数据的位置”作为“输出”来实现快速访问,因此时间复杂度可以认为为O(1),而数组遍历的时间复杂度为O(n)。
       数据结构是一门讨论“描述现实世界实体的数学模型及其之上的运算在计算机中如何表示和实现”的学科。逻辑结构是面向问题的,而物理结构就是面向计算机的,其基本的目标就是将数据及其逻辑关系存储到计算机的内存中。数据结构就是研究数据的逻辑结构和物理结构以及它们之间相互关系,并对这种结构定义相应的运算。每种逻辑结构都有一组相应的运算,例如检索、插入、删除、更新、排序等,数据的运算最终需在对应的存储结构上用算法实现。
       以上关于数据结构的分类,转载于简书作者“理想是一盏灯”的博客《浅谈数据结构的由来及分类》。之所以引入这篇文章,是为了让大家清楚的了解数据结构的概念,以便更好的理解今天要介绍的数据结构。

        常见的数据结构有数组、列表、栈和队列、链表、字典、散列、集合、树和图等,接下来我将一一介绍这些常见的数据结构。


数组(Array)


       线性表是具有相同类型的数据元素的一个有序序列。线性表中数据元素之间的关系是一对一的关系,除了第一个和最后一个数据元素之外,其它数据元素都是首尾相接的。用顺序存储方式存储的线性表称为顺序表,线性表的顺序存储结构把表中所有元素按顺序存储在一块连续的存储空间中。
       数组就是顺序表的实现方式。数组的标准定义是:一个存储元素的线性集合,元素可以通过索引来任意存取,索引通常是数字,每个元素对应唯一的索引。数组的表现形式就是内存中的一段连续的内存地址,数组名称指向连续内存地址的首位。数组的优点是根据索引查找元素的速度快,且根据索引遍历数组很方便。缺点是数组的大小是固定的,创建后便不能再进行扩容,并且数组只能存储一种数据类型。此外,对数组进行插入、移除某个元素的操作效率低下,因为这要移动到其它的元素。
(几乎所有编程语言都有类似的数据结构,但JavaScript的数组有所不同。JS中的数组不是真正的数组,它是一种特殊的对象,用来表示偏移量的索引是该对象的属性。这些数字索引在内部被转换为字符串类型,因为JS对象中的属性名必须是字符串。数组在JS中只是一种特殊的对象,所以效率上不如其它语言中的数组高)。
       在JavaScript中,数组常见的基本操作有: 添加元素、删除元素、修改元素、获取元素

push

将元素添加到数组末尾

unshift

将元素添加到数组开头

pop

删除数组末尾的元素

shift

删除数组第一个元素

splice

从数组中间位置添加或删除元素

reverse

将数组中元素的顺序进行翻转

sort

按照字典顺序对元素进行排序

indexOf

查找元素在数组中的位置

lastIndexOf

反向查找元素在数组中的位置

       JS中的数组,在定义时无需指定数据类型和数组长度,并且可以同时存储多种不同的数据类型,但大多数语言中的数组没这个能力。


列表(ArrayList)


       列表和数组其实非常相似,它也是一组有序的数据,存放在连续的内存中。区别在于数组的大小是固定的,而列表的大小是动态变化的(从这个角度看JS的数组更应该称为列表)。数组在声明时必须先指定长度,而列表无需指定长度。数组可以存储基本类型和对象类型,而列表只能存储对象类型。数组只能存放同一类型的数据,而列表可以存放不同类型的数据。
       列表长度之所以可以任意增加,是因为每次增加长度后,内存中都会开辟一段新的大于原来的连续内存空间,然后将原来的元素都复制到新的内存空间中,并在增加了长度的新内存空间上添加元素,然后把原来的内存空间销毁。这就是列表动态增加的原理。
       列表是一种最自然的数据组织方式,如果数据存储的顺序不重要,也不必对数据进行查找,那么列表就是一种非常合适的数据结构。


栈(Stack)


       栈是一种特殊的列表,栈内的元素只能通过列表的一端访问,这一端称为栈顶。栈是一种先入后出的数据结构,任何不在栈顶的元素都无法访问。为了得到栈底的元素,必须先取出上面的元素。
       对栈的两种主要操作是将一个元素压入栈和将一个元素弹出栈,入栈使用push()方法,出栈使用pop()方法。而peek()方法则用来预览栈顶元素而不删除它。此外,clear()方法用于清除栈内所有元素,length属性记录栈内元素的个数。
       实现一个栈,首先要决定存储数据的底层数据结构,我们可以采用数组来创建一个栈类:

队列(Queue)

 

       队列也是一种列表,不同的是队列只能在队尾插入元素,在队首删除元素。队列用于存储按顺序排列的数据,它是一种先进先出的数据结构,这点和栈不一样。

       队列的两种主要操作是:向队列中插入新元素和删除队列中的元素。插入操作也叫入队,删除操作也叫出队。入队操作在队尾插入新元素,出队操作删除队头的元素。

       我们依然可以用JS中的数组来实现队列这种数据结构:

链表(LinkedList)

 

       顺序表必须占用一整块事先分配大小的固定的存储空间,不便于存储空间的管理。为此出现了可以实现存储空间动态管理的链式存储方式——链表。

       链表是由一组节点组成的集合,它是线性表的链式存储结构。每个存储节点不仅包含有元素本身的信息(称为数据域),也包含后继节点的地址信息(称为指针域),这样可以通过一个节点的指针域方便地找到后继节点的位置。若一个节点中的某个指针域不需要指向其他任何节点,则将它的值置为空,用常量null表示。

       由于线性表中的每个元素最多只有一个前驱元素和一个后继元素,即数据元素之间是一对一的逻辑关系,所以采用链式存储时,一种最简单也是最常用的方法是:在每个节点中除数据域外,只设置一个指针域,用以指向其后继节点,这样构成的链表称为线性单向链接表,简称单链表。

       不同于数组元素靠它们的位置进行引用,链表元素是靠相互之间的关系进行引用。对于下图,我们说a2跟在a1后面,而不说a2是链表中第二个元素。遍历链表,就是跟着链接,从链表的首元素一直走到尾元素,且链表的尾元素始终指向一个null节点。

       许多链表的实现都在链表最前面有一个特殊节点,叫做头节点。链表中插入一个节点的效率很高,向链表中插入一个节点,需要修改它前面的节点(前驱节点),使其指向新加入的节点,而新加入的节点则指向原来前驱节点指向的节点。

       从单链表中删除一个元素也很简单,将待删除元素的前驱节点指向带删除元素的后继节点,同时将待删除元素指向null,元素就删除成功了。

       单链表还有其它操作,我们现在来实现一个单链表的数据结构。我们设计的单链表包含两个类:Node类用来表示节点,它包含两个属性:element用来保存节点上的数据,next用来保存指向下一个节点的链接。而LinkList类提供插入节点、删除节点、显示列表元素的方法,以及其它一些辅助方法。

       尽管从单链表的头节点遍历到尾节点很简单,但反过来,从后向前遍历就没这么方便了。我们可以将单链表变成双链表,即让每个节点包含两个指针域,一个指向前驱节点,一个指向后继节点。

       双链表插入一个节点需要更多的工作,我们需要指出该节点正确的前驱和后继,但是在从链表中删除节点时,效率就提高了,因为我们不需要再从头节点遍历查找待删除节点的前驱节点了,而是直接根据指针域来获取待删除节点的前驱节点。我们稍微修改一下刚才的单链表,来实现一个双链表的数据结构:

 

        此外,还有一种形式的链式存储结构——循环链表。它的特点是表中尾节点的指针域不再是空,而是指向头节点,整个链表形成一个环。由此,从表中任一节点出发均可找到链表中的其他节点。

       创建循环链表和创建单链表基本一致,唯一的区别是在创建循环链表时,让其头节点的next属性指向它本身,这样向链表中添加元素时,最后一个节点的next总会指向头节点:

       当然,遍历循环链表,有一些方法就需要稍作修改才能保证正常运行,比如原来单链表的display()方法用在循环链表里会陷入死循环,所以我们在while循环的循环条件里要多一步判断,检查循环是否遇到了头节点,如果遇到头节点就退出循环:

字典(Dictionary)

 

       很多编程语言中都有字典的概念,字典是一种以键-值对形式存储数据的数据结构,就像生活中我们根据在中文字典中根据拼音去查找汉字一样。字典的主要特点就是一一对应的关系,比如保存一部手机的信息,使用列表的方式为 [“小米”,“红色”,2999 ],而使用字典的方式为 { “name”: “小米”,“颜色”:“红色”,“价格”:2999 },可以通过key值取出相应的value值。字典中不可以存在相同的key,但value可以。

     有些编程语言中称这种映射关系为字典,但有些编程语言称这种映射关系为Map(映射)。在JavaScript中,似乎对象本身就是一种字典。但是这里我们还是按照其他语言经常使用字典的方式去封装一个字典类型, 方便我们按照其他语言的方式去使用字典。

       字典常见的操作有添加元素、查找元素、移除元素、清空所有元素、获取字典长度、展示所有元素,接下来我们对字典类型进行封装:

 

散列表/哈希表 (HashTable)

 

       散列是一种常用的数据存储技术,散列后的数据可以快速地插入或取用。散列使用的数据结构叫散列表(或哈希表)。哈希表通常是基于数组进行实现的, 但是相对于数组, 它也很多的优势,在哈希表上插入、删除和取用数据都非常快,无论多少数据,插入和删除元素仅需要接近常量的时间,即O(1)的时间级。哈希表的速度比树还要快,并且相对于树来说编码要容易得多。

       但是相对于数组,哈希表也有一些不足。哈希表中的数据是没有顺序的,所以不能以一种固定的方式(比如从大到小)来遍历其中的元素。当要查找一组数据中的最大值和最小值,用哈希表的效率就十分低下了。

       在认识哈希表之前,我们先来体会一下哈希表的应用。假如一家公司有1000个员工, 现在我们需要将这些员工的信息使用某种数据结构来保存起来,你会采用什么数据结构?第一个方案是数组,我们可以按照顺序将所有的员工依次存入一个长度为1000的数组中. 每个员工的信息都保存在数组的某个位置上。但如果我们如果不知道员工对应的索引位置,要查看某个员工的信息(比如查看某个姓名关联的性别、年龄、家庭地址等信息),只能遍历数组一个一个去找,效率显然很低。第二个方案是链表,链表对应插入和删除数据有一定的优势,但是对于获取员工的信息, 每次都必须从头遍历到尾, 显然效率也很低。第三个方案,就是使用哈希表了。

       哈希表通常是基于数组进行实现的,所有元素根据和该元素对应的键,保存在数组的特定位置,该键和我们前面讲到的字典中的键是类似的概念。使用哈希表存储元素时,我们通过一个哈希函数将元素转换成一个特定的数字(哈希化),作为数组下标存放在数组中的该位置。这个数字的范围是0到哈希表的长度。这样每次我们要查看某个员工的资料,我们只需要知道员工的姓名,通过哈希函数将该姓名转换成对应的数字,就找到了该员工资料存放的位置了。

       理想情况下,哈希函数会将每个value值映射为一个唯一的数组索引。然而,键的数量是无限的,数组的长度是有限的,我们必须让哈希函数尽量将键均匀地映射到数组中。

       即使使用一个高效的哈希函数,仍然存在将两个键映射成同一个值的可能,这种现象称为碰撞或冲突。当碰撞发生时,我们需要有方案去解决。

       我们首先要确定的是,要创建一个长度为多少的数组。创建哈希表,通常规定数组长度应该是一个质数,这与哈希函数的实现有关。

       常用的哈希化方式是将元素化作某个数字然后对哈希表长度取余,这样得到的数字就在0到哈希表长度的范围里。在很多应用中,元素是字符串类型,我们可以设计这样一个哈希函数,将字符串中每个字符的ASCII码值相加再对哈希表长度取余。

       然而,通过测试程序我们可以看到,输出结果并没有把所有名字输出。这是因为在哈希化的过程中,有些名字所对应的哈希值重复了,如程序中“Raymond”和“Clayton”的哈希值是一样的。一样的哈希值发生了碰撞,所以后者将前者覆盖掉了。所以我们需要设计一个更好的哈希函数,来避免产生冲突。

       避免碰撞首先要确保哈希表中用来存储数据的数组长度是个质数,这和计算哈希值时使用的取余运算有关。计算机专家研究发现比100大且不会让数据产生碰撞的第一个质数是137,所以我们采用这个数字。在给哈希表一个合适的长度后,就需要一个更好的算法来计算哈希值——霍纳算法。这个算法仍然会先计算字符串中各字符的ASCII码值,不过求和时每次都要乘以一个质数。大多数算法书建议使用一个较小的质数,我们取质数39。

       要注意这种方法也只能更高程度上避免碰撞,大多数算法书取的质数是37,可经过测试取质数37用霍纳算法计算“David”和“Jonathan”这两个名字依然会发生碰撞。

       碰撞的概率依然存在,我们不可能将多份数据存储到一个数组单元里,但我们仍然希望将元素存储到数组中,又该怎么办呢?我们可以使用一种叫开链法的方法。

       开链法是指实现散列表的数组中,每个数组元素又是一个新的数据结构,比如另一个数组或链表,这样就能在另一个数组的不同位置上存储相同哈希值的元素了。

       实现开链法的方法是:在创建存储哈希过的键值的数组时,通过调用一个函数创建一个新的空数组,我们称这个数组为链。然后将每个哈希值为该位置的元素依次从后面添加进这个新数组,这样便创建了一个二维数组。我们使用开链法来完善刚才的哈希表:

       注意我们把霍纳算法的质数改成37,此时David和Jonathan虽然哈希值冲突,但是也都展示出来了。

       另一种处理碰撞的方法是线性探测法。线性探测法采用开放寻址散列技术。当发生碰撞时,线性探测法检查散列表的下一个位置是否为空。如果为空,就将数据存入该位置;如果不为空,则继续检查下一个位置,直到找到一个空的位置为止。该技术是基于这样一个事实:每个散列表都会有很多空的单元格,可以使用它们来存储数据。

       当存储数据使用的数组特别大时,选择线性探测法要比开链法好。一般来说,当数组大小是待存储数据个数的1.5倍时,就采用开链法。如果数组大小是待存储数据的两倍及两倍以上时,则使用线性探测法。我们使用线性探测法来修改刚才的哈希表:

       我们可以看到,刚才哈希值同为80的David和Jonathan,现在Jonathan存放到了80的下一位81了。

       以上关于哈希表的介绍,部分参考于简书作者coderwhy的文章《数据结构(九)之哈希表理论》。若想继续深入了解哈希表,推荐观看该作者的这两篇文章:《数据结构(九)之哈希表理论》《数据结构(十)之哈希表实现》

集合(Set)

 

       集合是一种包含不同元素的数据结构,集合中的元素称为成员。集合的两个最重要特性是:① 集合中的成员是无序的。② 集合中不允许相同成员存在。

       不包含任何成员的集合称为空集,全集则是包含一切可能成员的集合。如果两个集合的成员完全相同,则称两个集合相等。如果一个集合中所有的成员都属于另外一个集合,则前一集合称为后一集合的子集。

       对集合的基本操作有并集、交集和补集。并集将两个集合中的成员进行合并,得到一个新集合。交集将两个集合中共同存在的成员组成一个新的集合。补集是属于一个集合而不属于另一个集合的成员组成的集合。

       集合的实现基于数组,下面我们就模拟一个集合Set:

树(Tree)

 

       树是一种非线性的数据结构,以分层的方式存储数据。树由一组以边连接的节点组成,在上面的节点称为根节点。如果一个节点下面包含其它节点,则该节点称为父节点,它下面的节点称为该父节点的子节点。没有任何子节点的节点称为叶子节点。除了根节点外,每个节点有且仅有一个父节点。不包含任何节点的树称为空树。

       二叉树是一种特殊的树,每个节点的子节点个数不能超过两个。树可以分为i个层次,根节点是第1层,它的子节点是第2层,子节点的子节点是第3层。树的层数就是树的深度,对于一颗二叉树来说,第i层的最大节点数为2^( i-1),i>=1。深度为k的二叉树最多包含2^k – 1个节点(k>=1)。而叶子节点的个数总是等于具有两个子节点的父节点个数+1。

       在二叉树中,除了最下一层的叶结点外, 每层节点都有2个子结点的二叉树, 称为满二叉树。它每一层都有2^( i-1)个节点(i>=1),一共有2^k – 1个节点(k>=1)。

       如果除了最后一层外,其它层的节点都达到最大个数,并且最后一层的节点往左侧集中依次排列,则称该二叉树为完全二叉树。

                                                                              (此图是完全二叉树)

                                                                                   (此图不是完全二叉树)

       还有一种特殊的二叉树,叫二叉搜索树(或二叉查找树、二叉排序树)。对于一棵二叉搜索树来说,每个父节点的非空左子树键值都小于父节点键值,每个父节点的非空右子树键值都大于父节点的键值,左右子树本身也是二叉搜索树。二叉搜索树的这一特性,使得查找的效率非常高,这也是二叉搜索树中搜索的来源。

       我们现在来实现一棵二叉搜索树,二叉搜索树由节点构成,所以我们要先写一个节点类,用于生成节点。然后在二叉搜索树上添加插入节点的方法:

       我们现在已经成功创建了这样一棵二叉搜索树:

       当然,我们需要看到测试结果,才能知道我们的程序是否正确。我们需要对这棵二叉树进行遍历,让它以某种规则打印出来。

       线性结构我们通常按照从前到后的顺序遍历,而树可以按照不同的节点顺序进行遍历。遍历树有三种方式:先序遍历、中序遍历和后序遍历。

       先序遍历先访问根节点,然后先序遍历其左子树,再先序遍历其右子树。遍历的过程如下:

       先序遍历的代码实现过程如下:

       代码遍历顺序图解:

       中序遍历先中序遍历其左子树,再访问根节点,再中序遍历其右子树。遍历的过程如下:

       中序遍历的代码实现过程如下:

       代码遍历顺序图解:

       后续遍历先后续遍历左子树,再后续遍历右子树,最后访问根节点。遍历的过程如下:

       后序遍历的代码实现过程如下:

       代码遍历顺序图解:

       讲完二叉树的三种遍历方式,我们现在就来看看,为什么说二叉搜索树的查找效率非常高呢?

       首先是获取最大值和最小值,在二叉搜索树中搜索最值其实是一件非常简单的事,最大值位于底层最右边的节点,最小值位于底层最左边的节点。

       找最小值,我们只需要不断遍历左子树,直到找到最后一个节点;找最大值,我们只要不断遍历右子树,直到找到最后一个节点:

       而在二叉搜索树上查找给定值,我们只需要比较该值和当前节点上的值的大小,来确定是向左子树遍历还是向右子树遍历,直到找到符合条件的值:

       虽然对二叉搜索树进行查找的速度很快,但如果要从二叉搜索树上删除节点,就稍微有点复杂了。

       在二叉搜索树上删除节点分三种情况:①要删除的节点是叶子节点,下面没有子节点。这种情况操作非常简单,直接让该节点的父节点指向该节点的left或right设为null即可。②要删除的节点下面有一个子节点。这种情况还不算复杂,我们只需要让待删除节点的父节点的left或right直接指向待删除结点的子节点即可。③要删除的节点下面左右都有节点。这种情况就非常复杂了。

       我们先来对第一种情况和第二种情况进行处理,稍后再讲解第三种情况。从二叉搜索树删除节点的第一步是判断当前节点是否包含待删除的数据,如果包含,则删除该节点;如果不包含,则比较当前节点上的数据和待删除的数据。如果待删除数据小于当前节点上的数据,则移至当前节点的左子节点继续比较;如果待删除数据大于当前节点上的数据,则移至当前节点的右子节点继续比较。

     现在就要来分析待删除节点有两个子节点的情况了,先偷偷引用其他人博客里的一张图,来分析一下情况三:

       这张图其实可以总结出一个规律:若要删除节点包含两个子节点,我们可以将该节点左子树上最大的值(称为当前节点的前驱)替换当前节点,或拿右子树上最小的值(称为当前节点的后继)替换当前节点。我们这里选择第二种方式,查找右子树上的最小值。因此,我们要写一个方法,用于获取右子树上的最小值。

       二叉搜索树删除这一块比较抽象,如果不太理解,建议根据下面这张图自己在纸上画一下,对比一下每次删除操作后二叉搜索树的变化情况:

       以上关于二叉搜索树的图解,引用于简书作者coderwhy的文章《数据结构(十二)之二叉搜索树》。若想继续深入了解树的概念和二叉搜索树,推荐观看该作者的这两篇文章:《数据结构(十一)之树结构》《数据结构(十二)之二叉搜索树》

       二叉搜索树虽然查询效率很高,但也有其局限性,比如当插入二叉搜索树的数据是一组有序的数据时,就会出现这种情况:

       这样的连续数据使得二叉搜索树分布十分不均匀,我们称这种树为非平衡树。对于一棵平衡树来说,插入、查找等操作的效率为O(logN),而对于一棵非平衡树来说,相当于创建了一个链表,查找效率又退回了O(n)。为了解决二叉搜索树这种问题,计算机专家们又发明了平衡二叉树和红黑树等更完善的数据结构。不过今天就暂时不探讨这些其它的二叉树了。

图(Graph)

 

       图是一种与树相似的数据结构,它由顶点(Vertex)的集合和边(Edge)的集合组成。顶点表示图中的节点,边表示顶点与顶点之间的连线。顶点和边都可以带有权重,权重可以是任意你想表示的数据。如果图中的边有方向,则称该图为有向图;如果图中的边没有方向,则称该图为无向图。图中的一系列顶点构成路径(注意和边的概念区分开),路径的长度由路径中第一个顶点到最后一个顶点之间边的数量表示。如果路径中第一个顶点和最后一个顶点是同一个,则该路径形成回路,称为。由一条边连在一起的顶点称为相邻顶点

                                                                                              无向图

                                                                                               有向图

       图的实际信息都保存在边上,因为它描述了图的结构。图的结构比二叉树灵活得多,一个顶点即可以只有一条边,也可以有多条边与它相连。

       有一种表示图的边的方法被称为邻接矩阵,它是一个二维数组,其中的元素表示两个顶点之间是否有一条边。

       在该二维数组中, 0表示没有连线,1表示有连线。通过二维数组, 我们可以很快的找到一个顶点和哪些顶点有连线.(比如A顶点, 只需要遍历第一行即可)。另外,顶点到自身的连线,通常使用0表示。

       邻接矩阵的一个问题,就是当图是一个无向图时,邻接矩阵展示出来的二维数组,其实是一个对称图,例如 A -> B之间的数是1时,B -> A 之间的数也是1。这就造成了空间的浪费。而且当图是一个稀疏图时,即矩阵中存在大量的0,我们就浪费的计算机存储空间来表示根本不存在的边。即使只有一个边,我们也必须遍历一行来找出这个边,浪费很多时间。

       另一种表示图的边的方法称为邻接表或邻接数组表。邻接表由图中每个顶点以及和顶点相邻的顶点列表组成(这个列表可以用很多种方式来存储, 数组、链表、字典、哈希表都可以)。

       使用邻接表表示图,当我们在程序中引用一个顶点时,可以高效地访问与这个顶点相连的所有顶点的列表。

       我们用第二种方法来构建一个图类,我们采用二维数组的结构保存图中边的信息:

       对图最常用的基本操作,就是搜索了。在图上可以执行两种基础搜索:深度优先搜索(Depth-First Search, 简称DFS)和广度优先搜索(Breadth-First Search, 简称BFS)。

       深度优先搜索包括从一条路径的其实顶点开始追溯,直到到达最后一个顶点,然后回溯,继续追溯下一条路径,直到到达最后的顶点,如此往复,直到没有路径为止。这不是在搜索特定的路径,而是通过搜索来查看图中有哪些路径可以选择:

       使用深度优先搜索算法,我们要创建一个数组,用来标记每一个顶点是否被访问过。BFS的过程首先访问一个没有访问过的初始顶点,将它标记为已访问,再递归地去访问在初始顶点的邻接表中其他没有访问过的顶点。

       广度优先搜索是从第一个顶点开始,尝试访问尽可能靠近它的顶点。本质上,这种搜索在图上是逐层移动的,首先检查最靠近第一个顶点的层,再逐渐向下移动到离起始顶点最远的层。

       广度优先搜索算法使用了抽象的队列来对已访问过的顶点进行排序。首先我们除了创建一个标记顶点是否被访问过的数组marked外,还要创建一个队列,用来存放每一层的顶点。然后从初始顶点开始,将其标记为已访问顶点,同时将其插入队列。然后,只要队列不为空,就重复执行以下操作:①队头顶点出队v ②依次检查v的所有相邻顶点,如果未访问过,则访问该相邻顶点同时标记为已访问,并将其插入队列。

       这样到最后,不仅初始顶点所有相邻顶点会依次被插入队列,而且每个相邻顶点的相邻顶点也会被依次插入队列,出队的时候便会按先后顺序依次弹出了:

       关于数据结构的介绍就讲到这里,以后如果有时间,还会对其它数据结构进行扩展,并且列举它们的经典应用。不管是学前端还是后端,数据结构是计算机行业面试必问的环节,了解一些基础的数据结构对想从事IT行业的人来说是必不可少的。希望这篇文章能够对你的学习和职业生涯带来帮助。

  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值