这系列是根据极客时间《数据结构与算法之美》这个课程做的笔记
本篇目录
- 跳表
- 树
- 堆
- 图
为一个值有序的链表建立多级索引,比如每2个节点提取一个节点到上一级,我们把抽出来的那一级叫做索引或索引层。如下图所示,其中down表示down指针,指向下一级节点。以此类推,对于节点数为n的链表,大约可以建立log2n-1级索引。像这种为链表建立多级索引的数据结构就称为跳表。
计算跳表的高度
如果链表有n个节点,每2个节点抽取抽出一个节点作为上一级索引的节点,那第1级索引的节点个数大约是n/2,第2级索引的节点个数大约是n/4,依次类推,第k级索引的节点个数就是n/(2^k)。假设索引有h级别,最高级的索引有2个节点,则有n/(2^h)=2,得出h=log2n-1,包含原始链表这一层,整个跳表的高度就是log2n。
计算跳表的时间复杂度
假设我们在跳表中查询某个数据的时候,如果每一层都遍历m个节点,那在跳表中查询一个数据的时间复杂度就是O(m * logn)。那这个m是多少呢?如下图所示,假设我们要查找的数据是x,在第k级索引中,我们遍历到y节点之后,发现x大于y,小于后面的节点z,所以我们通过y的down指针,从第k级下降到第k-1级索引。在第k-1级索引中,y和z之间只有3个节点(包含y和z),所以,我们在k-1级索引中最多只需要遍历3个节点,以此类推,每一级索引都最多只需要遍历3个节点。所以m=3。因此在跳表中查询某个数据的时间复杂度就是O(logn)。
跳表本质上就是链表,所以仅插作,插入和删除操时间复杂度就为O(1),但在实际情况中,要插入或删除某个节点,需要先查找到指定位置,而这个查找操作比较费时,但在跳表中这个查找操作的时间复杂度是O(logn),所以,跳表的插入和删除操作的是时间复杂度也是O(logn)。
当往跳表中插入数据的时候,可以选择同时将这个数据插入到部分索引层中,那么如何选择这个索引层呢?可以通过随机函数来决定将这个节点插入到哪几级索引中,比如随机函数生成了值K,那就可以把这个节点添加到第1级到第K级索引中。
根节点、叶子节点、父节点、子节点、兄弟节点,还有节点的高度、深度以及层数,树的高度。
每个节点由3个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。 这种存储方式比较常用,大部分二叉树代码都是通过这种方式实现的。
用数组来存储,对于完全二叉树,如果节点X存储在数组中的下标为i,那么它的左子节点的存储下标为2i,右子节点的下标为2i+1,反过来,下标i/2位置存储的就是该节点的父节点。注意,根节点存储在下标为1的位置。完全二叉树用数组来存储时最省内存的方式。
前序遍历的递推公式: preOrder(r) = print r->preOrder(r->left)->preOrder(r->right) 中序遍历的递推公式: inOrder(r) = inOrder(r->left)->print r->inOrder(r->right) 后序遍历的递推公式: postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r 复制代码
时间复杂度:3种遍历方式中,每个节点最多会被访问2次,所以时间复杂度是O(n)。
根据性质查找 & 前中后序遍历 & 层次遍历 & 高度深度层次计算 & 查找某结点右子树的最小子节点 & 查找某结点左子树的最大子节点 & 查找前驱结点和后继结点 & 左右旋 & 根据子节点情况进行插入和删除操作 & 根据父节点和叔叔结点情况进行旋转
我们在散列表那节中讲过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?
我认为有下面几个原因:
散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。
散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比logn小,所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。
二叉查找树是常用的一种二叉树,他支持快速插入,删除,查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是O(logn)。在很多书籍中,但凡讲到平衡二叉查找树,就会那红黑树做为例子。在工程中,很多用到平衡二叉查找树的地方都会用红黑树。
定义:二叉树中任意一个节点的左右子树的高度相差不能大于1。
所以:完全二叉树,满二叉树都是平衡二叉树,非完全二叉树也有可能是平衡二叉树。
平衡二叉查找树不仅满足上面平衡二叉树的定义,还满足二叉查找树的特点。
发明平衡二叉查找树这类数据结构的初衷是解决普通二叉查找树在频繁的插入,删除等动态更新的情况下,出现时间复杂度退化的问题。 所以,平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”,比较“平衡”,不要出现左子树很高,右子树很矮的情况。这样就能让整颗树的高度相对低一些,相应的插入,删除,查找等操作的效率高一些。
若设计一个新的平衡二叉查找树,只要树的高度不比log2n大很多(如树的高度仍然是对数量级的),尽管它不符合严格的平衡二叉查找树的定义,但它仍然可以被认为是一个合格的平衡二叉查找树。
平衡二叉查找树有很多,如:Splay Tree(伸展树),Treap(树堆)等,但是我们提到平衡二叉查找树,听到的基本都是红黑树。他的出境率甚至要高于“平衡二叉查找树”这几个字,甚至在有些时刻,默认平衡二叉查找树就是红黑树
红黑树:英文“Red-Black-Tree”,简称R-B Tree,有如下特性:
二叉查找树很多操作的性能都跟树的高度成正比,一课极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是log2n,所以要证明红黑树是近似平衡的,我们只需要分析,红黑树的高度是否比较稳定地趋近log2n就好。
所以,红黑树的插入,删除,查找各种操作性能都比较稳定。对于工程应用来说,结果状态可控可预期。
我的红黑树学习之路
要实现一个堆,要先知道堆都支持哪些操作,已及如何存储一个堆。
完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。
往堆中插入一个元素后,需要继续满足堆的两个特性
把最后一个节点放到堆顶,然后利用同样的父子节点对比方法,对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止,这是从上往下的堆化方法。
一个包含n个节点的完全二叉树,树的高度不会超过log2n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,即O(log n)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以往堆中插入一个元素和删除堆顶元素的时间复杂度都是O(log n)。
排序方法有时间复杂度是O(n^2)的冒泡排序,插入排序,选择排序,有时间复杂度是O(nlogn)的归并排序,快速排序,线性排序。
借助堆这种数据结构实现的排序算法就叫作堆排序,这种排序方法的时间复杂度非常稳定,是O(nlogn),并且它还是原地排序算法。
大致分解为两大步骤:建堆和排序
建堆:
第二种和第一种思路截然相反,第一种建堆思路的处理过程是从前往后处理数据,并且每个数据插入堆中时,都是从下往上堆化。第二种对下标从n/2开始到1的数据进行堆化,下标是n/2 + 1到n的节点,是叶子节点,不需堆化
3. 建堆的时间复杂度排序:
建堆结束后,数组中的数据已是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。将它和最后一个元素交换,最大元素就放到了下标为n的位置。这个过程有点类似“删除堆顶元素”的操作,当堆顶元素移除后,把下标为n的元素放到堆顶,然后在通过堆化的方法,将剩下的n-1个元素重新构建成堆。堆化完成之后,在取堆顶元素,放到下标是n-1的位置,一直重复这个过程,直到最后堆中只剩下标为1的一个元素,排序工作就完成了。
①:整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。
②:堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是O(n),排序过程的时间复杂度是O(nlogn),所以堆排序的时间复杂度是O(nlogn)
③:堆排序不是稳定的排序算法,可能改变值相等的数据原始相对顺序。
因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度 k 成正比。
我把每一层的节点个数和对应的高度画了出来,你可以看看。我们只需要将每个节点的高度求和,得出的就是建堆的时间复杂度。
我们将每个非叶子节点的高度求和,就是下面这个公式:
这个公式的求解稍微有点技巧,不过我们高中应该都学过:把公式左右都乘以 2,就得到另一个公式 S2。我们将 S2 错位对齐,并且用 S2 减去 S1,可以得到 S。
S 的中间部分是一个等比数列,所以最后可以用等比数列的求和公式来计算,最终的结果就是下面图中画的这个样子。
因为 h=log2 n,代入公式 S,就能得到 S=O(n),所以,建堆的时间复杂度就是O(n)。
假设:有100个小文件,每个文件大小为100MB,每个文件中储存的都是有序的字符串。现需要将这100个小文件合并成一个有序的大文件。
思路:
删除堆顶数据和往堆中插入数据的时间复杂度都是O(logn),n表示堆中的数据个数,这里就是100
假设:有一个定时器,定时器中维护了很多定时任务
求Topk的问题可抽象成两类:
针对静态数据
可以维护一个大小为k的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果堆顶元素大,就将堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前k大数据了。
遍历数据需要O(n)的时间复杂度,一次堆化操作需要O(logk)的时间复杂度,最坏情况下,n个元素都入堆一次,时间复杂度就是O(nlogk)。
针对动态数据求得Topk就是实时Topk。 一个数据集合有两个操作,一个是添加数据,另一个询问当前的前k大数据。 可以维护一直都维护一个k大小的小顶堆,当有数据被添加到集合时,就那它与堆顶的元素对对比。如果比堆顶元素大,就把堆顶元素删除,并将这个元素插入到堆中,如果比堆顶元素小,这不处理。这样,无论任何时候需要查询当前的前k大数据,就都可以 立刻返回给他。
实现思路:
于是,可以利用两个堆实现动态数据集合中求中位数的操作,插入数据因为涉及堆化,所以时间复杂度变成了O(logn),但求中位数只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是O(1)。
图最直观的一种存储方法是:邻接矩阵(Adjacency Matrix),邻接矩阵的底层依赖一个二维数组。对于无向图来说,如果顶点i与顶点j之间有边,我们就将A[i][j]和A[j][i]标记为1;对于有向图来说,如果顶点i到顶点j之间,有 一条箭头从顶点i指向顶点j的边,那我们就将A[i][j]标记为1。同理,如果有一条箭头从顶点j指向顶点i的边,我们就将A[j][i]标记为1。对于带权图,数组中就存储相应的权重。
用邻接矩阵来表示一个图,虽然简单,直观,但是浪费存储空间。
邻接表(Adjacency List)可以解决邻接矩阵存储方式比较浪费内存空间的问题
每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。另外我需要说明一下,图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点
如何存储微博,微信等社交网络中的好友关系?