画了24张图,终于把常见的数据结构说清楚了

数据结构想必你们都不会陌生,对于一个成熟的程序员而言,熟悉和掌握数据结构和算法也是基本功之一。数据结构自己其实不过是数据按照特色关系进行存储或者组织的集合,特殊的结构在不一样的应用场景中每每会带来不同的处理效率。
程序员

经常使用的数据结构可根据数据访问的特色分为线性结构和非线性结构。线性结构包括常见的链表、栈、队列等,非线性结构包括树、图等。数据结构种类繁多,本文将经过图解的方式对经常使用的数据结构进行理论上的介绍和讲解,以方便你们掌握经常使用数据结构的基本知识。面试

本文提纲redis

 1  数组

数组能够说是最基本最多见的数据结构。数组通常用来存储相同类型的数据,可经过数组名和下标进行数据的访问和更新。数组中元素的存储是按照前后顺序进行的,同时在内存中也是按照这个顺序进行连续存放。数组相邻元素之间的内存地址的间隔通常就是数组数据类型的大小。算法

 2  链表

链表相较于数组,除了数据域,还增长了指针域用于构建链式的存储数据。链表中每个节点都包含此节点的数据和指向下一节点地址的指针。因为是经过指针进行下一个数据元素的查找和访问,使得链表的自由度更高。数据库

这表如今对节点进行增长和删除时,只须要对上一节点的指针地址进行修改,而无需变更其它的节点。不过事物皆有两极,指针带来高自由度的同时,天然会牺牲数据查找的效率和多余空间的使用。编程

通常常见的是有头有尾的单链表,对指针域进行反向连接,还能够造成双向链表或者循环链表。数组

链表和数组对比

链表和数组在实际的使用过程当中须要根据自身的优劣势进行选择。链表和数组的异同点也是面试中高频的考察点之一。这里对单链表和数组的区别进行了对比和总结。缓存

 3  跳表

从上面的对比中能够看出,链表虽然经过增长指针域提高了自由度,可是却致使数据的查询效率恶化。特别是当链表长度很长的时候,对数据的查询还得从头依次查询,这样的效率会更低。跳表的产生就是为了解决链表过长的问题,经过增长链表的多级索引来加快原始链表的查询效率。这样的方式可让查询的时间复杂度从O(n)提高至O(logn)。
微信

跳表经过增长的多级索引可以实现高效的动态插入和删除,其效率和红黑树和平衡二叉树不相上下。目前redis和levelDB都有用到跳表。数据结构

从上图能够看出,索引级的指针域除了指向下一个索引位置的指针,还有一个down指针指向低一级的链表位置,这样才能实现跳跃查询的目的。

 4  

栈是一种比较简单的数据结构,经常使用一句话描述其特性,后进先出。栈自己是一种线性结构,可是在这个结构中只有一个口子容许数据的进出。这种模式能够参考腔肠动物...即进食和排泄都用一个口...

栈的经常使用操做包括入栈push和出栈pop,对应于数据的压入和压出。还有访问栈顶数据、判断栈是否为空和判断栈的大小等。因为栈后进先出的特性,常能够做为数据操做的临时容器,对数据的顺序进行调控,与其它数据结构相结合可得到许多灵活的处理。

 5  队列

队列是栈的兄弟结构,与栈的后进先出相对应,队列是一种先进先出的数据结构。顾名思义,队列的数据存储是如同排队通常,先存入的数据先被压出。常与栈一同配合,可发挥最大的实力。

 6  

树做为一种树状的数据结构,其数据节点之间的关系也如大树同样,将有限个节点根据不一样层次关系进行排列,从而造成数据与数据之间的父子关系。常见的数的表示形式更接近“倒挂的树”,由于它将根朝上,叶朝下。

树的数据存储在结点中,每一个结点有零个或者多个子结点。没有父结点的结点在最顶端,成为根节点;没有非根结点有且只有一个父节点;每一个非根节点又能够分为多个不相交的子树。

这意味着树是具有层次关系的,父子关系清晰,家庭血缘关系明朗;这也是树与图之间最主要的区别。

别看树好像很高级,其实可看做是链表的高配版。树的实现就是对链表的指针域进行了扩充,增长了多个地址指向子结点。同时将“链表”竖起来,从而凸显告终点之间的层次关系,更便于分析和理解。

树能够衍生出许多的结构,若将指针域设置为双指针,那么便可造成最多见的二叉树,即每一个结点最多有两个子树的树结构。二叉树根据结点的排列和数量还可进一度划分为彻底二叉树、满二叉树、平衡二叉树、红黑树等。

彻底二叉树:除了最后一层结点,其它层的结点数都达到了最大值;同时最后一层的结点都是按照从左到右依次排布。

满二叉树:除了最后一层,其它层的结点都有两个子结点。

平衡二叉树

平衡二叉树又被称为AVL树,它是一棵二叉排序树,且具备如下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,而且左右两个子树都是一棵平衡二叉树。

二叉排序树:是一棵空树,或者:若它的左子树不空,则左子树上全部结点的值均小于它的根结点的值;若它的右子树不空,则右子树上全部结点的值均大于它的根结点的值;它的左、右子树也分别为二叉排序树。

树的高度:结点层次的最大值

平衡因子:左子树高度 - 右子树高度

二叉排序树意味着二叉树中的数据是排好序的,顺序为左结点<根节点<右结点,这代表二叉排序树的中序遍历结果是有序的。(还不懂二叉树四种遍历方式[前序遍历、中序遍历、后序遍历、层序遍历]的同窗赶忙补习!)

平衡二叉树的产生是为了解决二叉排序树在插入时发生线性排列的现象。因为二叉排序树自己为有序,当插入一个有序程度十分高的序列时,生成的二叉排序树会持续在某个方向的字数上插入数据,致使最终的二叉排序树会退化为链表,从而使得二叉树的查询和插入效率恶化。


平衡二叉树的出现可以解决上述问题,可是在构造平衡二叉树时,却须要采用不一样的调整方式,使得二叉树在插入数据后保持平衡。主要的四种调整方式有LL(左旋)、RR(右旋)、LR(先左旋再右旋)、RL(先右旋再左旋)。这里先给你们介绍下简单的单旋转操做,左旋和右旋。LR和RL本质上只是LL和RR的组合。

在插入一个结点后应该沿搜索路径将路径上的结点平衡因子进行修改,当平衡因子大于1时,就须要进行平衡化处理。从发生不平衡的结点起,沿刚才回溯的路径取直接下两层的结点,若是这三个结点在一条直线上,则采用单旋转进行平衡化,若是这三个结点位于一条折线上,则采用双旋转进行平衡化。

左旋:S为当前须要左旋的结点,E为当前结点的父节点。

左旋的操做能够用一句话简单表示:将当前结点S的左孩子旋转为当前结点父结点E的右孩子,同时将父结点E旋转为当前结点S的左孩子。可用动画表示:

右旋:S为当前须要左旋的结点,E为当前结点的父节点。右单旋是左单旋的镜像旋转。

左旋的操做一样能够用一句话简单表示:将当前结点S的左孩子E的右孩子旋转为当前结点S的左孩子,同时将当前结点S旋转为左孩子E的右孩子。可用动画表示:

红黑树

平衡二叉树(AVL)为了追求高度平衡,须要经过平衡处理使得左右子树的高度差必须小于等于1。高度平衡带来的好处是可以提供更高的搜索效率,其最坏的查找时间复杂度都是O(logN)。可是因为须要维持这份高度平衡,所付出的代价就是当对树种结点进行插入和删除时,须要通过屡次旋转实现复衡。这致使AVL的插入和删除效率并不高。

为了解决这样的问题,能不能找一种结构可以兼顾搜索和插入删除的效率呢?这时候红黑树便申请出战了。

红黑树具备五个特性:

  1. 每一个结点要么是红的要么是黑的。
  2. 根结点是黑的。
  3. 每一个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的。
  4. 若是一个结点是红的,那么它的两个儿子都是黑的。
  5. 对于任意结点而言,其到叶结点树尾端NIL指针的每条路径都包含相同数目的黑结点。

红黑树经过将结点进行红黑着色,使得本来高度平衡的树结构被稍微打乱,平衡程度下降。红黑树不追求彻底平衡,只要求达到部分平衡。这是一种折中的方案,大大提升告终点删除和插入的效率。C++中的STL就经常使用到红黑树做为底层的数据结构。

红黑树VS平衡二叉树

除了上面所说起的树结构,还有许多普遍应用在数据库、磁盘存储等场景下的树结构。好比B树、B+树等。这里就先不介绍了诶,下次在讲述相关存储原理的时候将会着重介绍。(实际上是由于懒)

 7  

了解完二叉树,再来理解堆就不是什么难事了。堆一般是一个能够被看作一棵树的数组对象。堆的具体实现通常不经过指针域,而是经过构建一个一维数组与二叉树的父子结点进行对应,所以堆老是一颗彻底二叉树。

对于任意一个父节点的序号n来讲(这里n从0算),它的子节点的序号必定是2n+1,2n+2,所以能够直接用数组来表示一个堆。

不只如此,堆还有一个性质:堆中某个节点的值老是不大于或不小于其父节点的值。将根节点最大的堆叫作最大堆或大根堆,根节点最小的堆叫作最小堆或小根堆。

堆经常使用来实现优先队列,在面试中常常考的问题都是与排序有关,好比堆排序、topK问题等。因为堆的根节点是序列中最大或者最小值,于是能够在建堆以及重建堆的过程当中,筛选出数据序列中的极值,从而达到排序或者挑选topK值的目的。

 8  散列表

散列表也叫哈希表,是一种经过键值对直接访问数据的机构。在初中,咱们就学过一种可以将一个x值经过一个函数得到对应的一个y值的操做,叫作映射。散列表的实现原理正是映射的原理,经过设定的一个关键字和一个映射函数,就能够直接得到访问数据的地址,实现O(1)的数据访问效率。在映射的过程当中,事先设定的函数就是一个映射表,也能够称做散列函数或者哈希函数。

散列表的实现最关键的就是散列函数的定义和选择。通常经常使用的有如下几种散列函数:

直接寻址法:取关键字或关键字的某个线性函数值为散列地址。

数字分析法:经过对数据的分析,发现数据中冲突较少的部分,并构造散列地址。例如同窗们的学号,一般同一届学生的学号,其中前面的部分差异不太大,因此用后面的部分来构造散列地址。

平方取中:当没法肯定关键字里哪几位的分布相对比较均匀时,能够先求出关键字的平方值,而后按须要取平方值的中间几位做为散列地址。这是由于:计算平方以后的中间几位和关键字中的每一位都相关,因此不一样的关键字会以较高的几率产生不一样的散列地址。

取随机数法:使用一个随机函数,取关键字的随机值做为散列地址,这种方式一般用于关键字长度不一样的场合。

除留取余法:取关键字被某个不大于散列表的表长 n 的数 m 除后所得的余数 p 为散列地址。这种方式也能够在用过其余方法后再使用。该函数对 m 的选择很重要,通常取素数或者直接用 n。

定好散列函数以后,经过某个key确会获得一个惟一value址。可是却会出现一些特殊状况。即经过不一样key可能会访问到同一个地址,这个现象称之为冲突。

冲突在发生以后,当在对不一样的key进行操做时会使得形成相同地址的数据发生覆盖或者丢失,是很是危险的。因此在设计散列表每每还须要采用冲突解决的办法。

经常使用的冲突处理方式有不少,经常使用的包括如下几种:

开放地址法(也叫开放寻址法):实际上就是当须要存储值时,对Key哈希以后,发现这个地址已经有值了,这时该怎么办?不能放在这个地址,否则以前的映射会被覆盖。这时对计算出来的地址进行一个探测再哈希,好比日后移动一个地址,若是没人占用,就用这个地址。若是超过最大长度,则能够对总长度取余。这里移动的地址是产生冲突时的增列序量。

再哈希法:在产生冲突以后,使用关键字的其余部分继续计算地址,若是仍是有冲突,则继续使用其余部分再计算地址。这种方式的缺点是时间增长了。

链地址法:链地址法其实就是对Key经过哈希以后落在同一个地址上的值,作一个链表。其实在不少高级语言的实现当中,也是使用这种方式处理冲突的。

公共溢出区:这种方式是创建一个公共溢出区,当地址存在冲突时,把新的地址放在公共溢出区里。

目前比较经常使用的冲突解决方法是链地址法,通常能够经过数组和链表的结合达到冲突数据缓存的目的。

左侧数组的每一个成员包括一个指针,指向一个链表的头。每发生一个冲突的数据,就将该数据做为链表的节点连接到链表尾部。这样一来,就能够保证冲突的数据可以区分并顺利访问。

考虑到链表过长形成的问题,还可使用红黑树替换链表进行冲突数据的处理操做,来提升散列表的查询稳定性。

 9  

图相较于上文的几个结构可能接触的很少,可是在实际的应用场景中却常常出现。比方说交通中的线路图,常见的思惟导图均可以看做是图的具体表现形式。

图结构通常包括顶点和边,顶点一般用圆圈来表示,边就是这些圆圈之间的连线。边还能够根据顶点之间的关系设置不一样的权重,默认权重相同皆为1。此外根据边的方向性,还可将图分为有向图和无向图。

图结构用抽象的图线来表示十分简单,顶点和边之间的关系很是清晰明了。可是在具体的代码实现中,为了将各个顶点和边的关系存储下来,却不是一件易事。

邻接矩阵

目前经常使用的图存储方式为邻接矩阵,经过全部顶点的二维矩阵来存储两个顶点之间是否相连,或者存储两顶点间的边权重。

无向图的邻接矩阵是一个对称矩阵,是由于边不具备方向性,若能今后顶点可以到达彼顶点,那么彼顶点天然也可以达到此顶点。此外,因为顶点自己与自己相连没有意义,因此在邻接矩阵中对角线上皆为0。

有向图因为边具备方向性,所以彼此顶点之间并不能相互达到,因此其邻接矩阵的对称性再也不。

用邻接矩阵能够直接从二维关系中得到任意两个顶点的关系,可直接判断是否相连。可是在对矩阵进行存储时,却须要完整的一个二维数组。若图中顶点数过多,会致使二维数组的大小剧增,从而占用大量的内存空间。

而根据实际状况能够分析得,图中的顶点并非任意两个顶点间都会相连,不是都须要对其边上权重进行存储。那么存储的邻接矩阵实际上会存在大量的0。虽然能够经过稀疏表示等方式对稀疏性高的矩阵进行关键信息的存储,可是却增长了图存储的复杂性。

所以,为了解决上述问题,一种能够只存储相连顶点关系的邻接表应运而生。

邻接表

在邻接表中,图的每个顶点都是一个链表的头节点,其后链接着该顶点可以直接达到的相邻顶点。相较于无向图,有向图的状况更为复杂,所以这里采用有向图进行实例分析。

在邻接表中,每个顶点都对应着一条链表,链表中存储的是顶点可以达到的相邻顶点。存储的顺序能够按照顶点的编号顺序进行。好比上图中对于顶点B来讲,其经过有向边能够到达顶点A和顶点E,那么其对应的邻接表中的顺序即B->A->E,其它顶点亦如此。

经过邻接表能够得到从某个顶点出发可以到达的顶点,从而省去了对不相连顶点的存储空间。然而,这还不够。对于有向图而言,图中有效信息除了从顶点“指出去”的信息,还包括从别的顶点“指进来”的信息。这里的“指出去”和“指进来”能够用出度和入度来表示。

入度:有向图的某个顶点做为终点的次数和。

出度:有向图的某个顶点做为起点的次数和。

由此看出,在对有向图进行表示时,邻接表只能求出图的出度,而没法求出入度。这个问题很好解决,那就是增长一个表用来存储可以到达某个顶点的相邻顶点。这个表称做逆邻接表。

逆邻接表

逆邻接表与邻接表结构相似,只不过图的顶点连接着可以到达该顶点的相邻顶点。也就是说,邻接表时顺着图中的箭头寻找相邻顶点,而逆邻接表时逆着图中的箭头寻找相邻顶点。

邻接表和逆邻接表的共同使用下,就可以把一个完整的有向图结构进行表示。能够发现,邻接表和逆邻接表实际上有一部分数据时重合的,所以能够将两个表合二为一,从而获得了所谓的十字链表。

十字链表

十字链表彷佛很简单,只须要经过相同的顶点分别链向以该顶点为终点和起点的相邻顶点便可。

但这并非最优的表示方式。虽然这样的方式共用了中间的顶点存储空间,可是邻接表和逆邻接表的链表节点中重复出现的顶点并无获得重复利用,反而是进行了再次存储。所以,上图的表示方式还能够进行进一步优化。

十字链表优化后,可经过扩展的顶点结构和边结构来进行正逆邻接表的存储:(下面的弧头可看做是边的箭头那端,弧尾可看做是边的圆点那端)

data:用于存储该顶点中的数据;

firstin指针:用于链接以当前顶点为弧头的其余顶点构成的链表,即从别的顶点指进来的顶点;

firstout指针:用于链接以当前顶点为弧尾的其余顶点构成的链表,即从该顶点指出去的顶点;

边结构经过存储两个顶点来肯定一条边,同时经过分别表明这两个顶点的指针来与相邻顶点进行连接:

tailvex:用于存储做为弧尾的顶点的编号;

headvex:用于存储做为弧头的顶点的编号;

headlink 指针:用于连接下一个存储做为弧头的顶点的节点;

taillink 指针:用于连接下一个存储做为弧尾的顶点的节点;

以上图为例子,对于顶点A而言,其做为起点可以到达顶点E。所以在邻接表中顶点A要经过AE即边04)指向顶点E,顶点Afirstout针须要指向边04tailvex同时,从B出发可以到达A,因此在逆邻接表中顶点A要经过AB即边10)指向B,顶点Afirstin针须要指向边10的弧头,headlink针。依次类推。

十字链表采用了一种看起来比较繁乱的方式对边的方向性进行了表示,可以在尽量下降存储空间的状况下增长指针保留顶点之间的方向性。具体的操做可能一时间很差弄懂,建议多看几回上图,弄清指针指向的意义,明白正向和逆向邻接表的表示。

 10  总结

数据结构博大精深,没有高等数学的讳莫如深,也没有量子力学的玄乎其神,可是其在计算机科学的各个领域都具备强大的力量。本文试图采用图解的方式对九种数据结构进行理论上的介绍,可是其实这都是不够的。

即使是简单的数组、栈、队列等结构,在实际使用以及底层实现上都会有许多优化设计以及使用技巧,这意味着还须要真正把它们灵活的用起来,才可以算是真正意义上的熟悉和精通。可是本文能够做为常见数据结构的一个总结,当你对某些结构有些淡忘的时候,不妨从新回来看看。


本文分享自微信公众号 - 超悦编程(gh_ca6d8e9bfd68)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索