基本数据结构与算法总结

159 阅读34分钟

1 时间复杂度

1.1 渐近记号

Θ\Theta记号(读作Big Theta,渐近紧确界):

Θ(g(n))=f(n):c1,c2,n0R+,nn0,0c1g(n)f(n)c2g(n)\Theta(g(n)) = \\{ f(n):\exists c_1,c_2,n_0 \in \mathbb{R}^+,\forall n \ge n_0,有 0 \le c_1g(n) \le f(n) \le c_2g(n) \\}

对任意两个函数f(n)f(n)g(n)g(n),有f(n)=O(g(n))f(n) = O(g(n)),当且仅当f(n)=O(g(n))f(n) = O(g(n))f(n)=Ω(g(n))f(n) = \Omega(g(n))

OO记号(读作Big O,渐近上界):

O(g(n))=f(n):c,n0R+,nn0,0f(n)cg(n)O(g(n)) = \\{ f(n):\exists c,n_0 \in \mathbb{R}^+,\forall n \ge n_0,有 0 \le f(n) \le cg(n) \\}

Ω\Omega记号(读作Big Omega,渐近下界):

Ω(g(n))=f(n):c,n0R+,nn0,0cg(n)f(n)\Omega(g(n)) = \\{ f(n):\exists c,n_0 \in \mathbb{R}^+,\forall n \ge n_0,有 0 \le cg(n) \le f(n) \\}

oo记号(读作Little O,非渐近紧确上界):

o(g(n))=f(n):cR+,n0>0,nn0,0f(n)<cg(n)o(g(n)) = \\{ f(n):\forall c \in \mathbb{R}^+, \exists n_0 \gt 0,\forall n \ge n_0,有 0 \le f(n) \lt cg(n) \\}

ω\omega记号(读作Little Omega,非渐近紧确下界):

ω(g(n))=f(n):cR+,n0>0,nn0,0cg(n)<f(n)\omega(g(n)) = \\{ f(n):\forall c \in \mathbb{R}^+, \exists n_0 \gt 0,\forall n \ge n_0,有 0 \le cg(n) \lt f(n) \\}

1.2 主方法

主定理如下:

a1a \ge 1b>1b \gt 1是常数,f(n)f(n)是一个函数,T(n)T(n)是定义在非负整数上的递归式:

T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n)

其中我们将n/bn/b解释为n/b\lfloor n/b \rfloorn/b\lceil n/b \rceil。那么T(n)T(n)有如下渐近界:

  1. 若对某个常数ϵ>0\epsilon \gt 0f(n)=O(nlogbaϵ)f(n)=O(n^{log_ba-\epsilon}),则T(n)=O(nlogba)T(n)=O(n^{log_ba})
  2. f(n)=O(nlogbalgkn)f(n)=O(n^{log_ba}\lg^k n),其中k0k\ge 0,则T(n)=O(nlogbalgk+1n)T(n)=O(n^{log_ba}\lg^{k+1} n)
  3. 若对某个常数ϵ>0\epsilon \gt 0f(n)=Ω(nlogba+ϵ)f(n)=\Omega(n^{log_ba+\epsilon}),且对某个常数c1c \le 1和所有足够大的nnaf(n/b)cf(n)af(n/b) \le cf(n),则T(n)=O(f(n))T(n)=O(f(n))

例子1:

T(n)=9T(n/3)+nT(n)=9T(n/3)+n

利用主定理的情况1,可解得T(n)=O(n2)T(n)=O(n^2)

例子2:

T(n)=T(2n/3)+1T(n)=T(2n/3)+1

利用主定理的情况2,可解得T(n)=O(lgn)T(n)=O(\lg{n})

例子3:

T(n)=3T(n/4)+nlgnT(n)=3T(n/4)+n \lg{n}

利用主定理的情况2,可解得T(n)=O(nlgn)T(n)=O(n \lg{n})

1.3 重要定理

斯特林公式

limn+n!2πn(ne)n=1\lim_{n\to+\infty}\frac{n!}{\sqrt{2\pi n}\left(\frac{n}{e}\right)^n} = 1

n!2πn(ne)nn! \approx \sqrt{2\pi n}\left(\frac{n}{e}\right)^n

斐波拉契数列通项公式

F0=0F1=1Fi=Fi1+Fi2=15(1+52)n(152)n=15(1+52)n+12\begin{aligned} F_0 &= 0\\\\ F_1 &= 1\\\\ F_i &= F_{i-1}+F_{i-2}\\\\ &=\frac{1}{\sqrt5}\left\lfloor\left(\frac{1+\sqrt5}{2}\right)^n-\left(\frac{1-\sqrt5}{2}\right)^n\right\rfloor\\\\ &=\left\lfloor\frac{1}{\sqrt5}\left(\frac{1+\sqrt5}{2}\right)^n+\frac{1}{2}\right\rfloor \end{aligned}

2 排序

2.1 插入排序

由扑克牌排序演变而来,从第二张牌开始,往前插入到正确位置。当待排序数组有序时,时间复杂度达到最优O(n)O(n)

  • 平均时间复杂度:O(n2)O(n^2)
  • 空间复杂度:O(1)O(1)

2.2 归并排序

分治法,对数组A[l..r]A[l..r],取m=l+r2m=\lfloor\frac{l+r}{2}\rfloor,递归地对A[l..m]A[l..m]A[m..r]A[m..r]排序,并将已排序的两部分合并。在合并时需要用到O(n)O(n)的额外空间。

  • 平均时间复杂度:O(nlgn)O(n\lg{n})
  • 空间复杂度:O(n)O(n)

2.3 堆排序

花费O(n)O(n)时间对数组建立最大堆,再通过nnO(lgn)O(\lg{n})时间的pop,完成排序。

  • 平均时间复杂度:O(nlgn)O(n\lg{n})
  • 空间复杂度:O(1)O(1)

2.4 快速排序

任取数组AA的一个点A[p]A[p]作为pivot,将小于A[p]A[p]的元素放入数组左边部分,大于A[p]A[p]的元素放入数组右边部分,分别对左边和右边递归,完成排序。

选取pivot可以采用随机选择,或取数组最左端值A[l]A[l]、中间点值A[m]A[m]、最右端值A[r]A[r]的中位数。

在递归到数组范围很小时(如16个数),可以利用插入排序替代快速排序从而降低递归栈深度。

  • 平均时间复杂度:O(nlgn)O(n\lg{n})
  • 空间复杂度:O(1)O(1)

2.5 快速选择

利用快速排序的方法找到第kk小的元素。和快速排序的原理相通,但是在找到pivot过后只需要递归查找左边或右边部分,而不需要两边都继续递归,因此效率更高,期望时间复杂度为O(n)O(n)

最坏情况下快速选择的时间复杂度为O(n2)O(n^2)。通过改进pivot的选择,可以得到一个最坏时间复杂度为O(n)O(n)的选择算法,但是因为常数因子过高,实际效率常常并不如一般的快速选择算法。

2.6 内省排序(Introsort)

这个排序算法首先从快速排序开始,当递归深度超过一定深度(深度为排序元素数量的对数值)后转为堆排序,在递归到数组范围很小时又切换为插入排序。

采用这个方法,内省排序既能在常规数据集上实现快速排序的高性能,又能在最坏情况下仍保持O(nlgn)O(n\lg{n})的时间复杂度。

目前C++ STL的sort采用的是Introsort算法。和Introsort原理相同的Introselect算法也被C++ STL采用为默认选择算法(nth_element)。

  • 平均时间复杂度:O(nlgn)O(n\lg{n})
  • 空间复杂度:O(1)O(1)

2.7 计数排序

计数排序使用一个额外的数组CC ,其中第ii个元素是待排序数组AA中值等于ii的元素的个数。然后根据数组CC来将AA中的元素排到正确的位置。由于数组AA的元素的值可能很大,因此数组CC的存储空间也可能需要很大,具有一定局限性。

由于计数排序对Cache不友好,在数组CC空间超过L3 Cache的大小时,计数排序效率降到最低。对于较大的数组排序来说,计数排序的实际效率不如基数排序好。

  • 平均时间复杂度:O(n+r)O(n+r),其中rr代表元素的最大值
  • 空间复杂度:O(r)O(r)

2.8 基数排序

基数排序通过将整数按位数(或字节数)切割成不同的数字,再从低到高按每个位数(或字节数)分别比较。

基数排序通常选择1个字节作为比较的基数,这样对L1 Cache友好,效率较高。在数字所占字节数较大时,基数排序的效率会受到影响。

  • 平均时间复杂度:O(d(n+k))O(d(n+k)),其中dd代表每个数字比较的轮数,kk代表基数的位数
  • 空间复杂度:O(n+k)O(n+k)

2.9 排序算法总结

排序算法平均情况最好情况最坏情况空间复杂度稳定性
插入排序Θ(n2)\Theta(n^2)Ω(n)\Omega(n)O(n2)O(n^2)O(1)O(1)稳定
归并排序Θ(nlgn)\Theta(n\lg{n})Ω(nlgn)\Omega(n\lg{n})O(nlgn)O(n\lg{n})O(n)O(n)稳定
堆排序Θ(nlgn)\Theta(n\lg{n})Ω(nlgn)\Omega(n\lg{n})O(nlgn)O(n\lg{n})O(1)O(1)不稳定
快速排序Θ(nlgn)\Theta(n\lg{n})Ω(nlgn)\Omega(n\lg{n})O(n2)O(n^2)O(1)O(1)不稳定
计数排序Θ(n+r)\Theta(n+r)Ω(n+r)\Omega(n+r)O(n+r)O(n+r)O(r)O(r)稳定
基数排序Θ(d(n+k))\Theta(d(n+k))Ω(d(n+k))\Omega(d(n+k))O(d(n+k))O(d(n+k))O(n+k)O(n+k)稳定

表中dd代表每个数字比较的轮数,kk代表基数的位数,rr代表元素的最大值

比较排序中,目前性能最优的为内省排序(Introsort),即快速排序、堆排序、插入排序的结合。

非比较排序中,目前性能最优的为基数排序,强于单线程下的内省排序。

多线程排序中,目前性能最优的为多线程乱序发射的内省排序。

链表排序中,目前性能最优的为归并排序

2.10 外部排序

外部排序一般采用败者树优化的外部归并排序算法,对无法完全放入内存的数据进行排序,具体方法如下:

  1. 对于总量为nn的数据,在内存足够的情况下,每次读入mm(根据内存得到)的数据,利用内部排序后输出到磁盘上。
  2. 利用kk路归并(通常采用败者树实现)对kk个文件进行归并。每次从kk个文件中读取字节存入输入缓冲区,归并后的结果存入输出缓冲区,再存入磁盘。

其中kk一般为2的幂(最大化利用败者树效果),外部排序总共需要n/m\lceil n/m\rceil次内部排序和logk(n/m)\lceil\log_k(n/m)\rceil次数据总量为nn的归并,因此总时间复杂度一般为O(nlgm+nlogk(n/m))=O(nlgn)O(n\lg{m}+n\log_k(n/m))=O(n\lg{n}),和内部排序时间复杂度相同。但是当内存容量相对于数据总量较小时,主要的时间开销在磁盘I/O上,因此一般会比仅在内存上操作的内部排序慢得多。

优化性能的方法

  1. 并行计算:

    • 用多个磁盘驱动器并行处理数据,可以加速顺序磁盘读写。
    • 使用多线程优化内部排序和归并操作。
    • 使用异步输入输出,可以同时排序和归并,同时读写。
    • 使用多台计算机建立分布式计算,分担计算任务。
  2. 优化算法:

    • 优化内部排序算法,对于特殊数据可以考虑用基数排序替代普通的比较排序。
    • 优化归并算法,利用二叉堆、配对堆、败者树等数据结构的特点进行优化。

3 数据结构

3.1 基本数据结构

3.1.1 链表

链表是一种链式存储结构,通过指针将结点连接起来,每个结点分配单独的内存。因此链表结点太小的话对cache不友好,性能会很低。

链表的插入、删除的时间是O(1)O(1),随机访问的时间是O(n)O(n)。可以通过O(n2)O(n^2)的插入排序和O(nlgn)O(n\lg{n})的归并排序对链表排序。

3.1.2 栈和队列

栈是后进先出(last-in, first-out, LIFO)的ADT,只能弹出最近插入的元素。而队列则相反。队列是先进先出(first-in, first-out, FIFO)的ADT,只能弹出最先插入且还在队列中的元素。

栈和队列都可以采用链表和数组实现。当栈或队列所需的内存较小时,用固定大小的数组实现效率较高。当所需内存较大或所需内存不固定需要动态分配时,采用动态数组实现栈和队列效率较高。栈和队列的插入、删除时间都是O(1)O(1),但是不支持随机访问。

双向队列既可以弹出最后插入的元素,也可以弹出最先插入的元素,类似于栈和队列的结合体。

优先队列和普通队列不同,优先队列一般由最大堆最小堆实现,只能弹出最大或最小的值。因为内部结构不同,优先队列的插入、删除时间一般是O(lgn)O(\lg{n})

3.1.3 二叉树

树是可以有多个指向的链式结构。二叉树是最多只有2个指向的树,二叉树结点指向的两个子节点被称为左孩子和右孩子,同理结点本身被称作父亲或父结点。树只有一个根结点,处于树的最上层。树中无孩子的结点被称作树叶,处于树的最下层。

除了链式ADT以外,通过数组也可以实现二叉树。

3.2 散列表

散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算一个关于键值的函数(通常称为散列函数),将所需查询的数据映射到表中一个位置来访问记录,从而加快了查找速度。

哈希表通常采用链接法开放寻址法解决位置冲突。

链接法通过对哈希表中每个位置后跟一个链表来解决冲突。链接法哈希表的插入时间是O(1)O(1),最坏情况下查找时间为O(n)O(n)。在简单均匀散列的假设下,查找的平均时间为O(1+α)O(1+\alpha),其中α\alpha是链表的期望长度,也是散列表的装载因子

开发寻址法将所有元素都存放在散列表里,当插入的元素冲突时,会通过探查(probe)找到空槽来放置待插入的元素。探查通常采用线性探查二次探查双重探查法,其中双重探查因为利用2个不同的散列函数进行位置查找,冲突概率会比线性和二次探查要小,是用于开发寻址法的最好方法之一。装载因子为α\alphaα\alpha < 1)的开放寻址散列表的一次不成功查找的期望探查次数至多为1/(1α)1/(1-\alpha),一次成功查找的期望探查次数至多为1αln11α\frac{1}{\alpha}\ln \frac{1}{1-\alpha},一次插入的期望探查次数至多为1/(1α)1/(1-\alpha)

如果散列表是半满的,则一次成功的查找中,探查的期望数小于1.387。如果散列表为90%满的,则探查的期望数小于2.559。

3.3 unordered_map的散列表实现

gcc中c++ unordered_map的实现采用了动态数组+链接法的方式。哈希表的大小采用动态增长的方式,每当元素个数超过数组大小时,就将数组大小翻倍并向后找到第一个质数,将原来的元素重新哈希(rehash)到新的表中。unordered_map的每个桶通过指针串联起来(即每个桶的链表的最后一个结点都指向下一个桶),这样便于遍历,否则每次遍历都需要访问整个动态数组。每个桶的链表都存有key-value对和size_t类型的哈希编码,查找时先对比哈希编码再对比key值,当key的对比较为麻烦时,可以有效提升查找的效率。

3.3 树

3.3.1 完全二叉树的定义

完美二叉树、完全二叉树、满二叉树的定义如下:

描述
完美二叉树除了叶子结点之外的每一个结点都有两个孩子,每一层都被完全填充。
完全二叉树除了最后一层之外的其他每一层都被完全填充,并且所有结点都保持向左对齐。
满二叉树除了叶子结点之外的每一个结点都有两个孩子结点。

3.3.2 二叉搜索树

二叉搜索树是以二叉树结构组织的搜索树,其中每个结点的值大于等于其左子树的任意结点,小于等于其右子树的任意结点。二叉搜索树的插入、删除、查找(有序)的时间是O(lgn)O(\lg{n})

二叉搜索树删除结点xx需要考虑以下三种情况:

  1. 如果xx没有子结点,则直接删除,并修改它的父结点,用NIL作为新的子结点来替换xx
  2. 如果xx只有一个子结点,则将这个子结点提升到xx的位置上,修改xx的父结点,用xx的子结点代替xx
  3. 如果xx有两个子结点,则找到xx的后继yy(即xx的右子树中最左边的结点,亦即大于xx的最小结点),让yy占据xx的位置。这时候需要考虑两种情况:
    1. 如果xx不是yy的父结点,让yy占据xx的位置,使xx的右子树成为yy的新的右子树,xx的左子树成为yy的新的左子树。若yy原来有右子树,则将其提升一个位置,代替原来yy的位置。
    2. 如果xxyy的父节点,将yy提升到xx的位置,并使xx的左子树成为yy的新左子树。

3.3.3 AVL树

AVL树是最早被发明的自平衡二叉查找树。在AVL树中,任一节点对应的两棵子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是O(lgn)O(\lg{n})。增加和删除元素的操作则可能需要借由一次或多次树旋转,以实现树的重新平衡。

AVL树插入最多需要两次旋转,删除则需要最多O(lgn)O(\lg n)次旋转。

高度为hh的AVL树至少有Fibonacci(h+3)1Fibonacci(h+3)-1个结点,因此AVL树的最小高度为log2(n+1)\lceil\log_2(n+1)\rceil,最大高度为1.44log2(n+2)0.328\lfloor1.44\log_2(n+2)-0.328\rfloor

3.3.4 Splay树

伸展树(Splay Tree)是一种能够自我平衡的二叉查找树,它能在均摊O(lgn)O(\lg{n})的时间内完成基于伸展(Splay)操作的插入、查找、修改和删除操作。伸展树的最坏情况下的单次操作时间复杂度为O(n)O(n),但是最坏情况下的均摊时间复杂度为O(lgn)O(\lg{n})

当一个节点xx被访问过后,伸展操作会将xx移动到根节点。为了进行伸展操作,我们会进行一系列的旋转,每次旋转会使xx离根节点更近。通过每次访问节点后的伸展操作,最近访问的节点都会离根节点更近,且伸展树也会大致平衡,这样我们就可以得到期望均摊时间复杂度的下界——均摊O(lgn)O(\lg{n})

伸展树的插入、查找、删除实现和普通的BST差不多,每次操作后将访问的结点通过Splay操作移动到树根(若是删除操作则Splay被删除的结点的父结点)。

3.3.5 红黑树

红黑树是使用最多的一种自平衡二叉搜索树,需要额外的1位color位(红/黑)来维持树的平衡。红黑树通过旋转与调整color位,保持树的近似平衡(即左子树与右子树结点数的差值可能大于1)。

因为红黑树只保证黑色结点的绝对平衡,而不保证红色结点,因此红黑树的高度最大为2log2(n+1)2\log_2(n+1)。而且对黑高为dd的结点,其左右子树的高度差最多为dd,因此其左右子树结点数差最多为22d2d2^{2d} - 2^d。当d=10d=10时,结点数差最多为220210=10475522^{20} - 2^{10}=1047552

因为红黑树非绝对平衡,极端情况下最大高度比AVL树高出接近一倍,所以一般不采取数组的方式实现。假设红黑树的结点数为1023,则其高度最大为2log2(1023+1)=202\log_2(1023+1)=20,用数组实现至少需要220=10485762^{20}=1048576个元素,空间浪费极大。

红黑树的性质如下:

  1. 每个结点或是红色的,或是黑色的。
  2. 根结点是黑色的。
  3. 每个叶结点(NIL)是黑色的。
  4. 如果一个结点是红色的,则它的两个子节点都是黑色的。
  5. 对每个结点,从结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点(黑高相同)。

3.3.5.1 红黑树的旋转

旋转分为左旋和右旋,具体如图:

3.3.5.2 红黑树的插入

和普通BST一样,在相应叶结点处插入结点。插入结点zz后,需要考虑以下三种情况,通过旋转和变色进行修复:

  • 情况1:zz的叔结点yy是红色的。
  • 情况2:zz的叔结点yy是黑色的且zz是一个右孩子。
  • 情况3:zz的叔结点yy是黑色的且zz是一个左孩子。

注意情况2可以转为情况3。

红黑树插入结点后最多需要两次旋转和一次颜色翻转(情况1)来维持红黑树的性质。

3.3.5.3 红黑树的删除

删除结点zz后,若zz只有单个子树xx,则将该子树xx移动到zz的位置上,此时结点yy和结点zz相同。

zz有两个子树,令yyzz的后继,xxyy的右子树,则将用结点yy替换结点zz,使yy的颜色和zz相同,再将yy的右子树xx移动到原先yy的位置上。

当结点yy原来的颜色为黑色,考虑以下四种情况,通过旋转和变色进行修复:

  • 情况1:xx的兄弟结点ww是红色的。
  • 情况2:xx的兄弟结点ww是黑色的,且ww的两个子结点都是黑色的
  • 情况3:xx的兄弟结点ww是黑色的,ww的左孩子是红色的,ww的右孩子是黑色的
  • 情况4:xx的兄弟结点ww是黑色的,且ww的右孩子是红色的

注意情况1可以转为情况2、3、4,情况3可以转为情况4。

红黑树删除结点后最多需要三次旋转(和两次颜色翻转,参考 Sedgewick的Left-Leaning Red-Black Trees)来维持红黑树的性质。

3.3.5.4 红黑树的遍历

由于红黑树实现中包含了parent结点,可以利用parent结点实现红黑树的迭代器。寻找结点xx后继的算法如下:

  1. xx有右子树,则返回xx的右子树中最左边的结点。
  2. xx是它的父结点的右子树,则令x=x.parentx=x.parent,否则跳到步骤4。
  3. 重复步骤3。
  4. 返回x.parentx.parent

3.3.6 平衡二叉树选择

按照几种平衡二叉树的不同优点,可以通过以下情况进行选择:

数据结构插入数据查找数据
红黑树随机,偶尔顺序随机,偶尔顺序
AVL树顺序随机
Splay树顺序顺序

优化后的AVL树和红黑树性能差别不大,AVL树的查找时间略低于红黑树,插入和删除时间略高于红黑树。因此一般对于插入密集型任务选择红黑树,而查找密集型任务选择AVL树。

(对比图来自 www.zhihu.com/question/19…

实际使用中常使用红黑树作为首选,C++ STL、Java、Linux内核都选择了红黑树作为平衡二叉树。Redis选择了跳转表用来替换红黑树,跳转表更适用于并发访问和修改,实现也更简单。

3.3.7 跳转表

跳转表的每个结点分配有随机的level,每一层都可看作是一个有序的单链表。跳转表的任意一个结点,其层数为xx的概率为(12)x(\frac{1}{2})^x,即每提高一层概率下降一半。

在跳转表中查找结点时,从最上层结点开始向右水平查找,若没找到,则跳转到下一层继续向右查找,直到找到节点或者到最底层为止。

跳转表的插入、查找、删除时间的期望为O(lgn)O(\lg{n})。但是最坏时间为O(n)O(n)。跳转表的期望空间复杂度为O(n)O(n),最坏情况下为O(nlgn)O(n\lg n)

单线程下一般红黑树要快于跳转表,实测深度优化的带内存池版本的跳转表效率也比CLRS的无优化的红黑树略慢一点。跳转表一般用于可以多线程多处理器优化的场景下。

3.3.8 树堆

树堆(Treap=Tree+Heap)是一种具有堆序的二叉搜索树。Treap需要记录一个额外的数据,即优先级,从而维护堆序。Treap只需要用左旋或右旋(单旋转)来维持二叉树性质以及堆序,实现难度比Splay树更小一些。

树堆的插入:和普通的BST一样,在叶结点插入,然后循环比较,若当前结点的优先级大于父结点,则向父结点旋转。期望时间复杂度O(lgn)O(\lg{n}),最坏情况下O(n)O(n)

树堆的查找:和普通BST相同。

树堆的删除:先找到要删除的结点,将它旋转到叶结点上,再删除。期望时间复杂度O(lgn)O(\lg{n}),最坏情况下O(n)O(n)

3.3.9 B树

B树是为磁盘或其他直接存取的辅助存储设备而设计的一种平衡搜索树。B树类似于红黑树,但B树在降低磁盘I/O操作数方面更优,一般数据库系统都使用B树或B树的变种(如B+树)来存储信息。B树(2-3-4树)的结构如下:

B树的叶结点具有相同的深度,即树的高度。B树的每个结点的关键字个数有上界和下界(除根以外)。假设tt为B树的最小度数,则B树结点的关键字个数的下界为t1t-1,上界为2t12t-1。B树的结点的孩子数为关键字个数+1,因此当t=2t=2时,一个内部结点只能有2、3、4个孩子,即一棵2-3-4树。

假设B树的总结点数为nn,内部结点的孩子的最大个数为MM,最小个数为mm(一般m=M/2m=\lceil M/2\rceil,对于B*树,m=2M/3m=\lceil 2M/3\rceil),则B树的最小高度为:

hmin=logM(n+1)1h_{min} = \lceil\log_M(n+1)\rceil-1

B树的最大高度为:

hmax=logmn+12h_{max} = \left\lfloor\log_m\frac{n+1}{2}\right\rfloor

B树的查找:通过和每个关键字比较,找出最小的下标ii,使得第ii个关键字\ge需要查找的关键字,返回结果或继续向后查找。

B树的插入:按照多叉树的方法插入,当要插入的结点已满(有2t12t-1个关键字,则将其分裂为两个t1t-1个关键字的结点,将中间关键字提升到父结点作为两棵新树的划分点。在插入过程中每遇到一个满的结点都要将其分裂,而不是只分裂最后插入的结点,从而确保每当要分裂时其父结点不是满的。

B树的删除:B树的删除较为复杂,其具体情况如下:

  1. 若关键字kk在结点xx中,且xx是叶结点,则从xx中删除kk
  2. 若关键字kk在结点xx中,且xx是内部结点,则做如下操作:
    • a. 若结点xx中前于kk的子结点yy至少包含tt个关键字,则找出kk在以yy为根的子树中的前驱kk'。递归地删除kk',并在xx中用kk'代替kk
    • b. 对称地,若结点yy有少于tt个关键字,则检查结点xx中后于kk的子结点zz。若zz至少有tt个关键字,则找出kk在以zz为根的子树中的后继kk'。递归地删除kk',并在xx中用kk'代替kk
    • c. 否则,若yyzz都只含有t1t-1个关键字,则将kk和整个zz合并到yy中,释放zz并从yy中递归地删除kk
  3. 若关键字kk当前不在内部结点xx中,则确定必包含kk的子树的根x.cix.c_i(若kk确实在树中)。若x.cix.c_i只有t1t-1个关键字,必须执行步骤3a或3b来保证降至一个至少包含tt个关键字的结点。然后,通过对xx的某个合适的子结点进行递归而结束。
    • a.x.cix.c_i只含有t1t-1个关键字,但是它的一个相邻的兄弟至少包含tt个关键字,则将xx中的某一关键字降至x.cix.c_i中,将x.cix.c_i的相邻左兄弟或右兄弟的一个关键字升至xx,将该兄弟中相应的孩子指针移到x.cix.c_i中,这样就使得x.cix.c_i增加了一个额外的关键字。
    • b.x.cix.c_i以及x.cix.c_i的所有相邻兄弟都只包含t1t-1个关键字,则将x.cix.c_i与一个兄弟合并,即将xx的一个关键字移至新合并的结点,使之成为该结点的中间关键字。

具体删除情况如图:

B树的删除会在向下搜索的过程中调整树的结构,因此需要先确定该关键字是否在树中,这点与其他平衡二叉树有区别。

3.3.9.1 B+树

B+树的所有值都存在叶结点上,而内部结点只存有关键字用以索引,因此B+树的删除比B树要简单一些。由于B+树的值都在叶结点上,所有叶结点连成一条链表,因此B+树很容易支持区间查找。

假设B+树的阶为bb,高度为hh,则

  1. B+树存储值(value)的最大个数为vmax=bhbh1v_{max}=b^h-b^{h-1}
  2. B+树存储值(value)的最小个数为vmin=2b2h12b2h2v_{min}=2\left\lceil \frac{b}{2}\right\rceil^{h-1}-2\left\lceil \frac{b}{2}\right\rceil^{h-2}
  3. B+树存储关键字(key)的最大个数为kmax=bh1k_{max}=b^h-1
  4. B+树存储关键字(key)的最小个数为kmin=2b2h11k_{min}=2\left\lceil \frac{b}{2}\right\rceil^{h-1}-1

3.3.9.2 B*树

B*树将结点的最低利用率从1/21/2提升到了2/32/3。假设2t2t为B*树的最小度数,则B树结点的关键字个数的下界为2t12t-1,上界为3t13t-1

3.4 堆

3.4.1 二叉堆

二叉堆是满足堆序的完全二叉树,即父节点的键值总是保持固定的序关系于任何一个子节点的键值(最大堆或最小堆),且每个节点的左子树和右子树都是一个二叉堆。二叉堆的实现最简单,只需要基于数组实现。

二叉堆通常用于堆排序,以及实现优先队列。二叉堆的插入、删除都是O(lgn)O(\lg n),建堆是O(n)O(n),查找最小值(最小堆)或最大值(最大堆)是O(1)O(1),但是合并是O(n)O(n)。需要频繁合并的情况下,通常考虑左式堆、二项堆以及斐波拉契堆。

3.4.2 锦标赛树

锦标赛树是通过比较两个相邻元素大小建立的树,基于优胜劣汰机制,胜者可以进入下一轮比较,直到最后选出最终胜者,因此满足堆序。锦标赛树的结构如下图:

锦标赛树的变形有胜者树败者树,胜者树即每次保存的都是胜者,而败者树每次保存的是败者,但是向上传递的是胜者。败者树需要额外一个结点保存最终的胜者。在kk路合并中,因为每次弹出的都是最小值(最小堆),而插入的值总比弹出的值大,因此败者树更优,此时插入和删除可以合并成一次更新操作,只需要一轮比较。败者树的结构如下图:

固定大小的锦标赛树(例如kk路合并时)可以用数组实现,但是大小不固定需要频繁删除的情况下只能考虑树形链式结构或者双重动态数组实现,此情况下对cache不友好,效率较低。

3.4.3 DD堆 vs 败者树

DD堆相较于二叉堆主要是插入更快,而删除更慢。二叉堆的插入、删除均是log2n\log_2{n}次比较,而DD堆插入是logdn\log_d{n}次比较,删除是(d1)logdn(d-1)\log_d{n}次比较。因此选择、排序等不需要插入操作的情况二叉堆比DD堆更优。

败者树是锦标赛树的kk路合并场景下的优化。锦标赛树在结点数不是2的幂的情况下额外开销较大,因此一般固定其大小为2的幂(例如kk路合并)。

  1. kk次选择的性能对比:败者树空间为2n2n,建立败者树的比较次数为nn,每次pop的比较次数为log2(2n)=log2(n)+1\log_2(2n)=\log_2(n)+1,因此选择前kk小的数总比较次数为n+k(log2(n)+1)n + k(\log_2(n)+1)二叉堆空间为nn,建堆的比较次数为2n2n,每次pop的比较次数为log2n\log_2{n},因此选择前kk小的数总比较次数为2n+klog2(n)2n + k\log_2(n)。当kk较小时,败者树的比较次数要明显少于二叉堆(接近22倍)。
  2. 排序的性能对比:败者树的比较次数大约为n+nlog2(2n)=2n+nlog2nn + n\log_2(2n)=2n+n\log_2{n}二叉堆的比较次数大约为2n+nlog2n2n + n\log_2{n}。因此两者的时间开销差别不大,但是败者树空间开销比二叉堆大一倍,而且当元素个数不是2的幂的情况下,败者树的时间开销会更大一些。
  3. kk路合并的性能对比:kk路合并主要用于外部排序的多路归并,每次kk路比较包含一次插入操作和一次删除操作。总数为nnkk路合并中,败者树只需要一次更新操作,总比较次数为n(1+log2k)n(1+\log_2{k})DD总比较次数为ndlogdknd\log_d{k}。因此败者树要比DD堆更优,且随着kk值增大,败者树的优势也相应增大。

因此排序以及一般情况下二叉堆更优,而kk次选择和kk路合并等特殊情况下败者树更优。

3.4.4 左式堆

左式堆(或左偏树)的左边结点永远比右边结点数多。左式堆的rank为其根结点右子树的rank,rank即到叶结点的距离,null的rank为-1,叶结点的rank为0。左式堆的插入、删除都是基于合并实现的。合并两个左式堆时,选择最大的(最大堆)根结点作为新堆的根结点,再将其右子树和另外一个堆进行递归合并。每次合并后检查左右子树的rank,若左子树的rank小于右子树的rank,则交换左右子树。

左式堆的插入、删除、合并时间均为O(lgn)O(\lg{n}),但是插入和删除的常数因子较大,而且其链式存储结构对cache不友好,因此仅在需要频繁合并堆的时候才选择左式堆,一般情况下性能不如二叉堆。

3.4.5 二项堆

二项堆是指满足以下性质得二项树的集合:

  1. 每颗二叉树都满足最大堆(或最小堆)性质,即任意结点的key大于等于其父结点的key。
  2. 不能有两颗或以上的二项树有相同度数(包括度数为0),即具有度数k的二项树只能有0个或1个。

二项堆的二项树结构由结点数的二进制表示唯一确定,因此结点数为nn的二项堆最多只有log2n\log_2{n}棵二项树。例如,13的结点数为23+22+202^3+2^2+2^0,即具有度数为3、2、0的三棵二项树,如下图:

二项堆的基本操作为合并,其插入、删除操作都可由合并操作演变而来。

二项堆的合并:从度数为0的二项树开始,和二进制加法相似,若度数为kk的二项树有2棵,则取最大值为根结点并将其合并为一个度数为k+1k+1的二项树,逐位累加上去。因此最大度数分别为nnmm的二项堆合并后,度数最大为max(n,m)+1\max(n, m)+1。二项堆合并的时间复杂度为O(lgn)O(\lg{n})

二项堆的插入:可视为与结点数为1的二项堆合并,因此最坏情况下时间复杂度为O(lgn)O(\lg{n}),而平摊分析的时间复杂度为O(1)O(1)

二项堆的查找:可用一个特殊的指针指向二项堆所有根结点中key最大的结点,并在相关操作中维护该指针,因此时间复杂度为O(1)O(1)

二项堆的删除:需要先根据指向最大值的特殊指针找到要删除的结点,删除后将该结点的子树拆分成一个新的二项堆,再将新的二项堆与原来的二项堆合并。因此删除的时间复杂度为O(lgn)O(\lg{n})

二项堆的降key(decrease key):对特定值降key后需要和其父结点循环比较并交换从而维护堆序,因此需要最坏O(lgn)O(\lg{n})的时间。

3.4.6 斐波拉契堆

斐波拉契堆的平摊分析性能比二项堆更好,且对于稠密图每次decrease key只要O(1)O(1)的平摊时间,相比二项堆的O(lgn)O(\lg{n})来说有巨大改进。斐波拉契堆通常用于Dijkstra算法的优化,以及需要更好平摊性能的可合并的优先队列。由于斐波拉契堆实现较为复杂,一般情况下很少用到它。

3.4.7 配对堆

配对堆是一种实现简单、平摊复杂度优越的数据结构,可被认为是一种简化的斐波拉契堆。配对堆是一种具有堆序的多叉树,通常用双向链表连接兄弟结点,具体结构如下图:

配对堆的合并:配对堆的合并非常简单,只需要将根结点key最大的堆作为新堆,然后将另外一个堆连接到新堆的子树上。平摊时间复杂度为O(1)O(1)

配对堆的插入:可视作与仅有一个结点的堆合并。平摊时间复杂度为O(1)O(1)

配对堆的查找:直接返回堆的第一个结点即可。平摊时间复杂度为O(1)O(1)

配对堆的删除:删除根结点,首先将子堆从左到右一对一对地合并,再从右到左一个一个地合并成为新堆。平摊时间复杂度为O(22lglgn)O(2^{2\sqrt{\lg{\lg{n}}}})

配对堆的降key(decrease key):若降key后根结点仍为最大结点,则堆结构不变。否则相当于删除根结点后,再插入降key后的根结点。

3.4.8 堆的性能比较

最大堆查找最大值删除最大值插入降Key合并
二叉堆O(1)O(1)O(lgn)O(\lg{n})O(lgn)O(\lg{n})O(lgn)O(\lg{n})O(n)O(n)
败者树O(1)O(1)O(lgn)O(\lg{n})O(lgn)O(\lg{n})O(lgn)O(\lg{n})O(1)O(1)
左式堆O(1)O(1)O(lgn)O(\lg{n})O(lgn)O(\lg{n})O(lgn)O(\lg{n})O(n)O(n)
二项堆O(1)O(1)O(lgn)O(\lg{n})O(1)O(1)O(lgn)O(\lg{n})O(n)O(n)
斐波拉契堆O(1)O(1)O(lgn)O(\lg{n})O(1)O(1)O(1)O(1)O(1)O(1)
配对堆O(1)O(1)O(lgn)O(\lg{n})O(1)O(1)O(22lglgn)O(2^{2\sqrt{\lg{\lg{n}}}})O(1)O(1)

3.5 不相交集

并查集是一种树形数据结构,用于处理不相交集(Disjoint Set)的合并及查询问题。并查集通常包含以下两种操作:

  1. 查找(Find):确定元素所在的子集,可以用来确定两个元素是否在同一子集。
  2. 合并(Union):将两个子集合并成为同一子集。

并查集的操作的期望时间复杂度为O(α(n))O(\alpha(n)),其中α(n)\alpha(n)n=A(x,x)n=A(x, x)的反函数,AA是急速增加的阿克曼函数,因此α(n)\alpha(n)增长相当缓慢。当n=10600n=10^{600}时,α(n)\alpha(n)也仅仅等于44,因此通常情况下可以近似地认为每个操作的时间复杂度为O(1)O(1)

4 图论

4.1 基本的图算法

图表示为G=(V,E)G=(V,E),其中V|V|代表图的结点数,E|E|代表图的边数。图一般采用邻接表邻接矩阵实现。在稀疏图(边的条数E|E|远小于V2|V|^2)中,一般采用邻接表,从而节省空间。而稠密图中则通常采用邻接矩阵的方式。

邻接表:一个包含V|V|条链表的数组,每个链表的结点代表一条边,其空间为O(V+E)O(V+E)

邻接矩阵:一个大小为V×V|V|\times|V|的二维数组(矩阵),其空间为O(V2)O(|V|^2)

广度优先搜索(Breadth-First-Search,BFS):从起点ss开始,按照与ss的距离的从小到大的顺序搜索,即算法需要搜索完所有离起点ss的距离为kk的结点后,再搜索距离为k+1k+1的结点。BFS通常使用队列实现。

深度优先搜索(Depth-First-Search,DFS):从起点ss开始,顺着一条边一直搜索到最远的结点,再回到ss从另一条边开始搜索,直到搜索完毕。DFS通常采用递归或者栈实现。

拓扑排序:对于有向无环图,可以通过DFS或者BFS进行拓扑排序,从而得到图的结点能够到达的先后顺序。算法为先找到入度为0的结点(即顺序最小的结点),将这些结点可以到达的其它结点的入度1-1,循环执行该方法直到所有结点都排序完成。

强连通分量:有向图G=(V,E)G=(V,E)的强连通分量是一个最大结点集合CVC\subseteq V,对于该集合CC中的任意一对结点uuvv来说,路径uvu\to vvuv\to u同时存在,即从集合中任意一点出发,可以到达集合中的任意其它点。

查找强连通分量可以使用Tarjan算法,其算法步骤如下:

TODO

4.2 最小生成树

一个连通无向图中所有点都连通且无环的一个子集称为生成树最小生成树即边权总和最小的生成树。一般得到最小生成树的算法有Kruskal算法Prim算法

有向图中的最小生成树称为最小树形图,即从一个点出发能走到其他所有点的边权最小的有向生成树。一般用朱刘算法得到最小树形图。

4.2.1 Kruskal算法

Kruskal算法步骤如下:

  1. 对图的所有边按边权从小到大排序。
  2. 按顺序遍历每条边,若边的两点不相连,则将该边加入生成树
  3. 重复步骤2直到产生最小生成树(一共加入V1|V|-1条边)

因为Kruskal算法需要判断边的两点是否已经相连,因此需要对所有点建立并查集。Kruskal算法的时间复杂度为O(ElgV)O(E\lg{V})

4.2.2 Prim算法

Prim算法步骤如下:

  1. 选择起点ss,将所有与ss相连的边加入优先队列(最小堆)。
  2. 从优先队列中取出边权最小的边(u,v)(u, v)加入生成树AA,将vv的所有相连的其它边(与AA不相连)加入优先队列。
  3. 重复V1|V|-1次步骤2。

利用二叉堆实现的Prim算法的时间复杂度为O(VlgV+ElgV)=O(ElgV)O(V\lg{V}+E\lg{V})=O(E\lg{V}),而斐波拉契堆(降key复杂度为O(1)O(1))优化的Prim算法的时间复杂度为O(E+VlgV)O(E+V\lg{V})

4.2.3 朱刘算法

TODO

4.3 最短路径

4.3.1 Bellman-Ford算法

Bellman-Ford算法解决的是一般情况下的单源最短路径问题,也可以用来判断负环是否存在。Bellman-Ford算法步骤如下:

  1. 对所有边进行松弛操作。
  2. 重复V1|V|-1次步骤1。

由于从源结点ss到任意点的无负环最短路径最多只有V1|V|-1条边,因此需要对所有边重复V1|V|-1次松弛操作。

判负环:假设图G=(V,E)G=(V,E)中源结点ss到结点vv的最短路径估计值v.dv.d,则对于任意边(u,v)E(u,v)\in E,有v.d>u.d+w(u,v)v.d\gt u.d+w(u, v),其中w(u,v)w(u,v)为边权,则GG中存在负环。

Bellman-Ford算法的时间复杂度为O(VE)O(VE)

4.3.2 Dijkstra算法

Dijkstra算法解决的是所有边权都非负情况下的单源最短路径问题。Dijkstra算法步骤如下:

  1. 对图GG的所有结点VV建立优先队列(最小堆)。
  2. 从队列中取出距离最小的结点uu(即u.du.d最小)。
  3. 对结点uu连接的所有出边(u,v)(u, v)进行松弛操作。
  4. 重复步骤2直到所有结点都取出(即V|V|次)。

利用二叉堆实现的Dijkstra算法的时间复杂度为O(ElgV)O(E\lg{V}),斐波拉契堆优化的Dijkstra算法的时间复杂度为O(VlgV+E)O(V\lg{V}+E)

4.3.3 DAG的单源最短路径

有向无环图(Directed Acyclic Graph,DAG)只需要对图GG进行拓扑排序,再按照顺序依次松弛每个结点连接的所有出边,就可以得到最短路径。该算法的时间复杂度为O(V+E)O(V+E)

4.3.4 所有结点对最短路径

对于稠密图,可以用Floyd-Warshall算法得到所有结点对的最短路径。Floyd-Warshall算法是一个动态规划算法,其思路为:对任意点i,j,ki, j, k,若dij>dik+dkjd_{ij} \gt d_{ik} + d_{kj},则令dij=dik+dkjd_{ij} = d_{ik} + d_{kj}。因此Floyd-Warshall算法的时间复杂度为O(V3)O(V^3)

对于稀疏图,Johnson算法可以在O(V2lgV+VE)O(V^2\lg{V}+VE)的时间内得到所有结点对的最短路径。对于无负权边的图,只需要对所有点运行一次斐波拉契堆优化的Dijkstra算法,就能得到所有结点对的最短路径,该算法的运行时间为O(V2lgV+VE)O(V^2\lg{V}+VE)。对于有负权边的图,需要建立一个新结点ss,对所有点vVv\in V,连接ssvv,并且使w(s,v)=0w(s, v)=0。然后运行一次Bellman-Ford算法,在判负环的同时得到每个点的势h(v)=v.dh(v)=v.d,其中v.dv.d为从ssvv的最短距离。对所有边(u,v)E(u,v)\in E,计算得到新的边权w^=w(u,v)+h(u)h(v)\hat{w}=w(u,v)+h(u)-h(v)。此时所有边权都为正,因此可以对所有边运行一次Dijkstra算法,得到所有点对的赋权最短路径δ^(u,v)\hat{\delta}(u,v),通过公式δ(u,v)=δ^(u,v)h(u)+h(v)\delta(u,v)=\hat{\delta}(u,v)-h(u)+h(v)得到所有点对的最短路径。

4.4 最大流

TODO

5 字符串匹配

5.1 KMP算法

TODO

5.2 BM算法

TODO

5.3 Karp-Rabin算法

TODO

5.4 Trie树

TODO

5.5 后缀数组

TODO

6 动态规划

TODO

参考

  • 《算法导论》
  • 《数据结构》——邓俊辉 部分图来自网上,忘记了出处。