存储结构
前面提到数据的存储结构有四种,其实本质上只有两种:顺序存储和链式存储。 栈、队列、树、图、散列表等这些的数据结构的底层实现都是顺序存储或链式存储,它们只是在这两种基本存储结构上提供了适合自身结构的API罢了。
单链表
这种情况很明显,链表存储。
栈和队列
比如栈和队列,都是操作受限制的线性表,数据元素有除了首节点和尾节点外,内部元素都有唯一的前驱和后继,栈只能在一端进行插入和删除,即入栈和出栈;队列只能在一端插入,另外一端删除,和线性表本质上没有区别,只是操作上受了限制。
树
树可以用数组实现和链表实现,用数组实现就是堆,是一个完全二叉树;用链表实现,就是常见的指针的那种方式:struct node* left和struct node* right。由此基础上衍生出二叉查找树(Binary Search Tree, BST)、平衡二叉树(Balanced Binary Search Tree,由苏联数学家Adelson-Velskii和Landis, AVL树)、红黑树(RB Tree)、B-树、B+树等,为了应对不同的场景而设计出来的结构。
二叉查找树-BST
线性表的搜索时间复杂度为O(n),而二分查找需要O(logn)的时间,使用二分查找的前提是数据有序。二叉查找树是二叉树与二分查找结合的结果,可以在最坏情况下使得查找、插入、删除都在O(logn)时间内完成。
在最好情况下,二叉查找树的搜索情况与二分查找的判定树类似,每次查找都可以缩小一半的查找范围,查找次数最多为从根到叶子,比较次数为树高度logn。
最坏情况下,二叉查找树退化为只有左子树或右子树的情况,每次查找的范围缩小为n-1,即退化为了线性查找O(n)。为了提高查找效率,引入了平衡二叉树(AVL树), 在BST的基础上增加一些限制条件,使得查找效率为树的高度logn。
平衡二叉树(AVL树)
AVL树增加的限制条件:
- 左右子树的高度差绝对值不超过1(平衡性)
- 左右子树也是平衡二叉树
节点左右子树的高度差称为平衡因子。在AVL树中,因为插入、删除节点导致左右子树的高度差大于1,那么会进行平衡调整,保持左右子树满足AVL树的条件。常见的平衡调整有LL型、RR型、LR型、RL型。
理想状态下,树的高度是logn,左右子树高度一样,这样的情况称为理想平衡。但是维持这样的理想平衡需要大量的时间进行平衡调整,才能维护其平衡性。因此,如果可以适当地放宽平衡的标准。保持左右子树的大致平衡,即需要少量几次调整就可以保持平衡,这样称为适度平衡。
红黑树
红黑树就是这样的一种AVL树,当然了,红黑树本身也有其自身的一些性质。 红黑树是一种适度平衡的AVL树,对平衡性进一步放宽: 红黑树的左右子树高度差不超过两倍。
红黑树在二叉查找树的基础上增加了以下条件:
- 每个节点是红色或黑色的
- 根节点是黑色的
- 每个叶子节点是黑色的
- 如果一个节点是红色的,那么其孩子节点是黑色的。
- 从任一个节点到叶子节点的路径上,包含的黑色节点数目相同。
从以上的5个性质中,可以得出,对任何一个节点,其左右子树的高度差不超过两倍。因为从任一个节点到叶子节点的路径上,黑色节点数目相等,一个子树可能全是黑节点,一个子树可能是黑节点和红节点交替出现,这样就多了一倍的红节点,从而使得树高度翻了一倍。
对于在红黑树中插入、删除节点导致树不平衡是,需要维持其平衡性。
AVL树、红黑树是限制左右子树的高度来保持平衡,维持树高度logn,提高了查找、插入、删除的效率。
多路平衡查找树(B-树、B树)
二叉查找树的查找效率与树高度成正比,logn。平衡二叉树减少了树高,提高了效率,但是还不够彻底。因为每个节点只有一个关键字,能否进一步压缩树的高度?这样查找效率就更高了
如果在一个几节点上存储多个关键字和多个指向子树的指针,既保持二叉查找树的性质,又具有平衡性,这样的查找树称为多路平衡查找树,又称为B-树,或B树。
一颗m阶B-树具有以下性质:
- 每个节点最多又m颗子树
- 根节点至少有两颗子树
- 内部节点(除根节点和叶子节点外),至少有⌈m/2⌉颗子树
- 叶子节点在同一层,并且不带信息(为空指针)
- 非叶子节点的关键字个数比子树少1
从上面5个性质可知,根节点至少有一个关键字和两颗子树,其他非叶子节点的关键字个数范围为[⌈m/2⌉ - 1, m-1], 子树个数范围[⌈m/2⌉, m](可以有第5个性质得出)。
常见的如3阶B-树,其内部节点子树个数范围2 <= k <= 3,(因此,关键字个数范围为1 <= k <= 2), 所以又称为2-3树。
B-树有平衡、有序、多路的特点。平衡和有序为AVL中的特点,这是继承过来的,多路为B-树自身特性。
对于B-树的插入、删除元素后,如果破坏了其性质,需要进行调整子树维持其性质。
常见的有插入操作导致节点中关键字个数超过了m-1,这是发生了节点的上溢, 那么需要对发生上溢的节点进行分裂操作,以满足B-树性质。
删除操作导致节点中关键字小于⌈m/2⌉-1, 这是发生了节点的下溢, 这是需要对下溢节点进行处理,常见的处理方式有3中:
- 左借:向下溢节点的左兄弟节点借用一个关键字
- 右借:向下溢节点的右兄弟节点借用一个关键字
- 合并:左右兄弟节点包含的关键字均不足
⌈m/2⌉,也就是左右兄弟节点关键字个数刚好为⌈m/2⌉ - 1,刚好满足下限,不能再借用了。
AVL树比二叉搜索树的树高低,而多路平衡查找树比AVL树高更低,搜索效率更高。
B+树
B+树是B-树的变种。一颗m阶B+树满足以下性质
- 每个节点最多又m颗子树
- 根节点至少有两颗子树
- 内部节点(除根节点和叶子节点外),至少有⌈m/2⌉颗子树
- 叶子节点在同一层,并且不带信息(为空指针)
- 非叶子节点的关键字个数与子树个数相同
- 倒数第二层节点包含了全部的关键字,节点内部有序且节点间按顺序链接
- 所有非叶子节点只作为索引部分,节点中仅包含子树中的最大(或最小)关键字
1-4的性质为B-树的, 5-7性质为B+树的。从以上性质可知,根节点至少有两个关键字,非叶子节点的关键字与子树个数相等,范围为[⌈m/2⌉, m], 而B-树非叶子节点的关键字个数比子树少1。同时,B+树一般有两个指针,一个指向树根,一个指向倒数第二层关键字最小的节点。
B-树、B+树是通过增加节点中存储关键字的个数和指向孩子节点指针数,来降低树的高度,降低了树的查找次数,这样就提高了查找效率。
图
图有两种表示方式,邻接矩阵和邻接表,邻接矩阵就是二位数组,而邻接表就是链表。 邻接矩阵可以快速判断两个顶点之间是否有边和方便计算各顶点的度。缺点是增加、删除顶点不方便,而且如果顶点很多而边很少的话,这样就比较耗费空间(稀疏矩阵了)。而邻接表存储就按需申请内存了,增加、删除顶点也方便,缺点是不方便判断两个顶点之间是否有边和不便于计算各顶点的度。
散列表
散列表就是通过散列函数把关键字映射到一个大数组里。对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。
数组和链表小结
综上,数据结构种类很多,但是底层存储无非数组或者链表,二者的优缺点如下:
数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N)
链表因为元素不需要连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。正是因为存储空间不连续,无法根据一个索引位置计算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。