跳转至

数据结构与算法之美

知识点导图

20211211001

基础复杂度

复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低。

时间复杂度

时间复杂度分析

  1. 只关注循环执行次数最多的一段代码
  2. 加法法则:总复杂度等于量级最大的那段代码的复杂度
  3. 乘法法则:嵌套的代码的复杂度等于嵌套外代码复杂度的乘积

常见时间复杂度

  • 非多项式量级
    • 指数阶:\(O(2^{n})\)
    • 阶乘阶:\(O(n!)\)
  • 多项式量级
    • 常量阶:\(O(1)\)
    • 对数阶:\(O(\log n)\)
    • 线性阶:\(O(n)\)
    • 线性对数阶:\(O(n \log n)\)
    • 平方阶:\(O(n^{2})\)
    • 立方阶:\(O(n^{3})\)
    • k 次方阶:\(O(n^{k})\)

我们把时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题。

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

多项式时间复杂度

  1. \(O(1)\)

    只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 \(O(1)\)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是 \(O(1)\)

  2. \(O(\log n)\)\(O(n \log n)\)

    在采用大 O 标记复杂度的时候,可以忽略系数。

    在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。

  3. \(O(m+n)\)\(O(m*n)\)

    代码的复杂度由两个数据的规模来决定。m 和 n 是表示两个数据规模。我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。但是乘法法则继续有效。

时间复杂度概念

  1. 最好情况时间复杂度

    最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。

  2. 最坏情况时间复杂度

    最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。

  3. 平均情况时间复杂度

    平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。

    平均复杂度只在某些特殊情况下才会用到。

  4. 均摊时间复杂度

    均摊时间复杂度对应的分析方法是摊还分析(或者叫平摊分析)。

    均摊时间复杂度应用的场景比平均复杂度更加特殊、更加有限。

空间复杂度

空间复杂度分析

空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。

我们常见的空间复杂度就是 \(O(1)\)\(O(n)\)\(O(n^{2})\),像 \(O(\log n)\)\(O(n \log n)\) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。

数组

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

线性表(Linear List)

线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。

20211211002

与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。

20211211003

连续的内存空间和相同类型的数据

正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

数组和链表的区别

链表适合插入、删除,时间复杂度 \(O(1)\);数组适合查找,查找时间复杂度为 \(O(\log n)\),支持随机访问,根据下标随机访问的时间复杂度为 \(O(1)\)

数组的访问越界问题

数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。

这种情况下,一般都会出现莫名其妙的逻辑错误,debug 的难度非常的大。而且,很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界。

但并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,像 Java 本身就会做越界检查,比如下面这几行 Java 代码,就会抛出 java.lang.ArrayIndexOutOfBoundsException。

int[] a = new int[3];
a[3] = 10;

ArrayList 与数组相比的优势

ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容

使用 ArrayList,我们就完全不需要关心底层的扩容逻辑。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。

不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。

使用数组更合适的场景

  1. Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
  3. 当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList > array。

对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始

从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。如果用 a 来表示数组的首地址,a[0]就是偏移为 0 的位置,也就是首地址,a[k]就表示偏移 k 个 type_size 的位置,所以计算 a[k]的内存地址只需要用这个公式:

a[k]_address = base_address + k * type_size

如果数组从 1 开始计数,那我们计算数组元素 a[k]的内存地址就会变为:

a[k]_address = base_address + (k-1)*type_size

从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。

实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。

链表

链表结构五花八门,有三种最常见的链表结构,它们分别是:单链表、双向链表和循环链表。

数组与链表的对比

数组需要一块连续的内存空间来存储,对内存的要求比较高。

数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。

而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。链表本身没有大小的限制,天然地支持动态扩容。

和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。

如果代码对内存的使用非常苛刻,那数组就更适合。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。

20211211004

时间复杂度 数组 链表
插入、删除 \(O(n)\) \(O(1)\)
随机访问 \(O(1)\) \(O(n)\)

  1. 单链表

    链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针 next

    20211211005

    从单链表图中,可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。

    与数组一样,链表也支持数据的查找、插入和删除操作。

    在链表中插入或者删除一个数据,并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。

    从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 \(O(1)\)

    20211211006

    但是,链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。所以,链表随机访问的性能没有数组好,需要 \(O(n)\) 的时间复杂度。

  2. 循环链表

    循环链表是一种特殊的单链表。它跟单链表唯一的区别就在尾结点。单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。

    20211211007

    和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。尽管用单链表也可以实现,但是用循环链表实现的话,代码就会简洁很多。

  3. 双向链表

    双向链表是稍微复杂的,在实际的软件开发中更加常用的链表结构。

    单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。

    20211211008

    双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。

    从结构上来看,双向链表可以支持 \(O(1)\) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。

    如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 \(O(1)\) 时间复杂度搞定,而单向链表需要 \(O(n)\) 的时间复杂度。

    除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

  4. 双向循环链表

    20211211009

这里有一个更加重要的知识点需要掌握,那就是用空间换时间的设计思想。当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。

缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。

对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。

写链表代码的技巧

  1. 理解指针或引用的含义

    有些语言有“指针”的概念,有些语言没有指针,取而代之的是“引用”不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址。

    将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

  2. 警惕指针丢失和内存泄漏

    插入结点时,一定要注意操作的顺序。

    删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。当然,对于像 Java 这种虚拟机自动管理内存的编程语言来说,就不需要考虑这么多了。

  3. 利用哨兵简化实现难度

    对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的。

    if (head == null) {
        head = new_node;
    }
    

    如果要删除链表中的最后一个结点,跟插入类似,也需要对于这种情况特殊处理。

    if (head->next == null) {
       head = null;
    }
    

    针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。

    哨兵就要登场了。这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。

    如果引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。

    20211211010

    哨兵结点是不存储数据的,哨兵结点一直存在。

  4. 重点留意边界条件处理

    要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行。

    经常用来检查链表代码是否正确的边界条件有这样几个:

    • 如果链表为空时,代码是否能正常工作
    • 如果链表只包含一个结点时,代码是否能正常工作
    • 如果链表只包含两个结点时,代码是否能正常工作
    • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作

    针对不同的场景,可能还有特定的边界条件。

  5. 举例画图,辅助思考

    对于稍微复杂的链表操作,可以使用举例法和画图法。

  6. 多写多练,没有捷径

    反复练习 5 个常见的链表操作:

    1. 单链表反转
    2. 链表中环的检测
    3. 两个有序的链表合并
    4. 删除链表倒数第 n 个结点
    5. 求链表的中间结点

链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。

LRU 缓存淘汰算法

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

基于链表实现 LRU 缓存淘汰算法

思路是这样的:我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

  1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
  2. 如果此数据没有在缓存链表中,又可以分为两种情况:

    • 如果此时缓存未满,则将此结点直接插入到链表的头部;
    • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

这种基于链表的实现思路,缓存访问的时间复杂度为 \(O(n)\)

后进者先出,先进者后出,这就是典型的“栈”结构。

从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,这时我们就应该首选“栈”这种数据结构。

栈主要包含两个操作,入栈和出栈。

实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。

不管是顺序栈还是链式栈,我们存储数据只需要一个大小为 n 的数组就够了。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 \(O(1)\)

不管是顺序栈还是链式栈,入栈、出栈只涉及栈顶个别数据的操作,所以时间复杂度都是 \(O(1)\)

支持动态扩容的顺序栈

如果要实现一个支持动态扩容的栈,只需要底层依赖一个支持动态扩容的数组。当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中。实际上,支持动态扩容的顺序栈,平时开发中并不常用到。

这样对于出栈操作来说,不会涉及内存的重新申请和数据的搬移,所以出栈的时间复杂度仍然是 \(O(1)\)。但是,对于入栈操作来说,情况就不一样了。当栈中有空闲空间时,入栈操作的时间复杂度为 \(O(1)\)。但当空间不够时,就需要重新申请内存和数据搬移,所以时间复杂度就变成了 \(O(n)\)。也就是说,对于入栈操作来说,最好情况时间复杂度是 \(O(1)\),最坏情况时间复杂度是 \(O(n)\),入栈操作的均摊时间复杂度就为 \(O(1)\)

栈在函数调用中的应用

栈作为一个比较基础的数据结构,一个比较经典的应用场景就是函数调用栈

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

栈在表达式求值中的应用

栈的另一个常见的应用场景,编译器如何利用栈来实现表达式求值

实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。

如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

20211211001011

栈在括号匹配中的应用

除了用栈来实现表达式求值,还可以借助栈来检查表达式中的括号是否匹配。

队列

线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的。

队列最大的特点就是先进先出

队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。

20211211001012

队列跟栈一样,也是一种操作受限的线性表数据结构。

作为一种非常基础的数据结构,队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列;Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等。

顺序队列和链式队列

跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

基于数组的实现方法

队列需要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾。

当 a、b、c、d 依次入队之后,队列中的 head 指针指向下标为 0 的位置,tail 指针指向下标为 4 的位置。

20211211001013

随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。

实际上,在出队时可以不用搬移数据。如果没有空闲空间了,只需要在入队时,再集中触发一次数据的搬移操作。

从代码中看到,当队列的 tail 指针移动到数组的最右边后,如果有新的数据入队,可以将 head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置。

20211211001014

在用数组实现的非循环队列中,队满的判断条件是 tail == n,队空的判断条件是 head == tail。

基于链表的队列实现方法

基于链表的实现,同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。

20211211001015

循环队列

循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在把首尾相连,扳成了一个环。

20211211001016

循环队列的代码实现难度要比非循环队列难多了。要想写出没有 bug 的循环队列的实现代码,需要确定好队空和队满的判定条件

当队满时,(tail+1)%n=head。这时图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间。

阻塞队列

阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。

20211211001017

上述的定义就是一个“生产者 - 消费者模型”!我们可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”。这种基于阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。

不仅如此,基于阻塞队列,我们还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。比如前面的例子,我们可以多配置几个“消费者”,来应对一个“生产者”。

20211211001018

并发队列

线程安全的队列我们叫作并发队列。最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。

线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?各种处理策略又是如何实现?

  1. 非阻塞的处理方式,直接拒绝任务请求
  2. 阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。

两种实现方式对于排队请求的区别:

  • 基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。
  • 基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。

除了队列应用在线程池请求排队的场景之外,队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。

递归

基本上,所有的递归问题都可以用递推公式来表示。

递归需要满足的三个条件:

  1. 一个问题的解可以分解为几个子问题的解
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件

写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

递归代码要警惕堆栈溢出

编写递归代码时,我们会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。

可以通过在代码中限制递归调用的最大深度的方式来避免出现堆栈溢出。递归调用超过一定深度(比如 1000)之后,就不继续往下再递归了,直接返回报错。但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。

递归代码要警惕重复计算

20211211001019

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算。

将递归代码改写为非递归代码

递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,要根据实际情况来选择是否需要用递归的方式来实现。

笼统地讲,所有的递归代码都可以改为这种迭代循环的非递归写法,因为递归本身就是借助栈来实现的。但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。

排序

排序算法 最好时间复杂度 最坏时间复杂度 平均时间复杂度 是否基于比较 是否原地排序 是否稳定
冒泡排序 \(O(n)\) \(O(n^{2})\) \(O(n^{2})\)
插入排序 \(O(n)\) \(O(n^{2})\) \(O(n^{2})\)
选择排序 \(O(n^{2})\) \(O(n^{2})\) \(O(n^{2})\)
快速排序 \(O(n^{2})\) \(O(n \log n)\)
归并排序 \(O(n \log n)\) \(O(n \log n)\)
桶排序 \(O(n)\)
计数排序 \(O(n+k)\) \(k\) 是数据范围
基数排序 \(O(dn)\) \(d\) 是维度

如何分析一个“排序算法”

  1. 排序算法的执行效率

    1. 最好情况、最坏情况、平均情况时间复杂度
    2. 时间复杂度的系数、常数 、低阶
    3. 比较次数和交换(或移动)次数
  2. 排序算法的内存消耗

    针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。

  3. 排序算法的稳定性

    针对排序算法,还有一个重要的度量指标,稳定性。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

    比如有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法。

    稳定排序算法可以保持数值相同的两个对象,在排序之后的前后顺序不变。

冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

20211211001020

20211211001021

当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。

冒泡排序算法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;

 for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}

冒泡排序是原地排序算法,是稳定的排序算法。最好情况时间复杂度是 \(O(n)\),最坏情况时间复杂度为 \(O(n^{2})\),平均情况下的时间复杂度是 \(O(n^{2})\)

有序度是数组中具有有序关系(从小到大)的元素对的个数。

对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。这种完全有序的数组的有序度叫作满有序度

有序度是数组中具有有序关系(从大到小)的元素对的个数。

关于这三个概念,还可以得到一个公式:逆序度 = 满有序度 - 有序度。排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。

插入排序(Insertion Sort)

首先,将数组中的数据分为两个区间:已排序区间未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

20211211001022

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。

插入排序算法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 1; i < n; ++i) {
    int value = a[i];
    int j = i - 1;
    // 查找插入的位置
    for (; j >= 0; --j) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 数据移动
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入数据
  }
}

插入排序是原地排序算法,是稳定的排序算法。最好是时间复杂度为 \(O(n)\)。注意,这里是从尾到头遍历已经有序的数据。最坏情况时间复杂度为 \(O(n^{2})\)。平均时间复杂度是 \(O(n^{2})\)

为什么插入排序要比冒泡排序更受欢迎

冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。

但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。

冒泡排序和插入排序的比较
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
冒泡排序中数据的交换操作
if (a[j] > a[j+1]) { // 交换
   int tmp = a[j];
   a[j] = a[j+1];
   a[j+1] = tmp;
   flag = true;
}

插入排序中数据的移动操作
if (a[j] > value) {
  a[j+1] = a[j];  // 数据移动
} else {
  break;
}

所以,虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 \(O(n^{2})\),但是如果希望把性能优化做到极致,首选插入排序。

选择排序(Selection Sort)

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

20211211001023

选择排序空间复杂度为 \(O(1)\),是一种原地排序算法。最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 \(O(n^{2})\)

选择排序是一种不稳定的排序算法。因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

归并排序(Merge Sort)

适合大规模的数据排序,比以上三种排序算法要更常用。

20211211001024

如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

分治思想跟我们前面讲的递归思想很像,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

用递归代码实现归并排序
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 归并排序算法, A是数组,n表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 递归调用函数
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r  then return

  // 取p到r之间的中间位置q
  q = (p+r) / 2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 将A[p...q]和A[q+1...r]合并为A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}

20211211001025

merge() 函数的伪代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化变量i, j, k
  var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++等于i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }

  // 判断哪个子数组中有剩余的数据
  var start := i,end := q
  if j<=r then start := j, end:=r

  // 将剩余的数据拷贝到临时数组tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }

  // 将tmp中的数组拷贝回A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}

归并排序是稳定的排序算法,不是原地排序算法。归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。

归并排序的时间复杂度,不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 \(O(n \log n)\)

归并排序的空间复杂度,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 \(O(n)\)

快速排序算法(Quicksort)

快排核心思想就是分治分区

快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。

20211211001026

快速排序递推公式的伪代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 快速排序,A是数组,n表示数组的大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r为下标
quick_sort_c(A, p, r) {
  if p >= r then return

  q = partition(A, p, r) // 获取分区点
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}

这里有一个 partition() 分区函数。

20211211001027

但是,如果按照这种思路实现的话,partition() 函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果希望快排是原地排序算法,那它的空间复杂度得是 \(O(1)\),那 partition() 分区函数就不能占用太多额外的内存空间,就需要在 A[p...r]的原地完成分区操作。

原地分区函数的伪代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i

20211211001028

因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。

快排也是用递归来实现的。快排的最好时间复杂度是 \(O(nlogn)\),最坏情况时间复杂度是 \(O(n^{2})\)

桶排序(Bucket sort)

桶排序、计数排序、基数排序的时间复杂度是线性的,所以把这类排序算法叫作线性排序(Linear sort)。之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。

这几种排序算法理解起来都不难,时间、空间复杂度分析起来也很简单,但是对要排序的数据要求很苛刻,所以要掌握这些排序算法的适用场景。

桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

20211211001029

桶排序对要排序数据的要求是非常苛刻的。首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

计数排序(Counting sort)

计数排序其实是桶排序的一种特殊情况。计数排序的算法思想跟桶排序非常类似,只是桶的大小粒度不一样。

计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

基数排序(Radix sort)

20211211001030

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。

对于不等长的数据,可以把所有的数据补齐到相同长度,位数不够的可以在后面补“0”。

排序优化

如何优化快速排序?

最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多

比较常用、比较简单的分区算法:

  1. 三数取中法

    从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。

  2. 随机法

    随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选得很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 \(O(n^{2})\) 的情况,出现的可能性不大。

二分查找(Binary Search)算法是一种针对有序数据集合的查找算法,也叫折半查找算法。

二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。

二分查找是一个时间复杂度为 \(O(\log n)\) 的算法。

\(O(\log n)\) 这种对数时间复杂度,是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 \(O(1)\) 的算法还要高效。

凡是用二分查找能解决的,绝大部分更倾向于用散列表或者二叉查找树。即便是二分查找在内存使用上更节省,但是毕竟内存如此紧缺的情况并不多。二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。

二分查找的递归与非递归实现

最简单的情况就是有序数组中不存在重复元素,在其中用二分查找值等于给定值的数据。

一个最简单的二分查找算法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;

  while (low <= high) {
    int mid = (low + high) / 2;
    if (a[mid] == value) {
      return mid;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }

  return -1;
}

low、high、mid 都是指数组下标,其中 low 和 high 表示当前查找的区间范围,初始 low=0, high=n-1。mid 表示[low, high]的中间位置。通过对比 a[mid]与 value 的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为 0,就退出。

容易出错的 3 个地方:

  1. 循环退出条件

    注意是 low<=high,而不是 low。

  2. mid 的取值

    实际上,mid=(low+high)/2 这种写法是有问题的。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。改进的方法是将 mid 的计算方式写成 low+(high-low)/2。更进一步,如果要将性能优化到极致的话,可以将这里的除以 2 操作转化成位运算 low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多。

  3. low 和 high 的更新

    low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。比如,当 high=3,low=3 时,如果 a[3]不等于 value,就会导致一直循环不退出。

实际上,二分查找除了用循环来实现,还可以用递归来实现。

二分查找的递归实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}

private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;

  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}

二分查找应用场景的局限性

首先,二分查找依赖的是顺序表结构,简单点说就是数组。

二分查找不可以依赖其他数据结构比如链表。主要原因是二分查找算法需要按照下标随机访问元素。数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。

二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。

其次,二分查找针对的是有序数据。

二分查找对这一点的要求比较苛刻,数据必须是有序的。如果数据没有序,我们需要先排序。如果针对的是一组静态的数据,没有频繁地插入、删除,可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低。

但是,如果数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。

所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。

再次,数据量太小不适合二分查找。

如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。

不过,这里有一个例外。如果数据之间的比较操作非常耗时,不管数据量大小,都推荐使用二分查找。比如,数组中存储的都是长度超过 300 的字符串,如此长的两个字符串之间比对大小,就会非常耗时。需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,这个时候二分查找就比顺序遍历更有优势。

最后,数据量太大也不适合二分查找。

二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。而二分查找是作用在数组这种数据结构之上的,所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。

二分查找变体一:查找第一个值等于给定值的元素

有序数据集合中存在重复的数据,希望找到第一个值等于给定值的数据。

查找第一个值等于给定值的元素
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid = low + ((high - low) >> 1);
    if (a[mid] >= value) {
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }

  if (low < n && a[low]==value) return low;
  else return -1;
}
查找第一个值等于给定值的元素的另一种实现方法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}

二分查找变体二:查找最后一个值等于给定值的元素

查找最后一个值等于给定值的元素
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

二分查找变体三:查找第一个大于等于给定值的元素

查找第一个大于等于给定值的元素
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

二分查找变体四:查找最后一个小于等于给定值的元素

查找最后一个小于等于给定值的元素
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

变体的二分查找算法写起来非常烧脑,很容易因为细节处理不好而产生 Bug,这些容易出错的细节有:终止条件区间上下界更新方法返回值选择

跳表

实际上,只需要对链表稍加改造,就可以支持类似“二分”的查找算法。改造之后的数据结构叫做跳表(Skip list)。

跳表这种数据结构可能会比较陌生,因为一般的数据结构和算法书籍里都不怎么会讲。但是它确实是一种各方面性能都比较优秀的动态数据结构,可以支持快速地插入、删除、查找操作,写起来也不复杂,甚至可以替代红黑树(Red-black tree)。Redis 中的有序集合(Sorted Set)就是用跳表来实现的。

对链表建立一级“索引”,每两个结点提取一个结点到上一级,把抽出来的那一级叫做索引索引层。图中的 down 表示 down 指针,指向下一级结点。

20211211001031

加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。

跟前面建立第一级索引的方式相似,在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。

20211211001032

20211211001033

当链表的长度 n 比较大时,比如 1000、10000 的时候,在构建索引之后,查找效率的提升就会非常明显。这种链表加多级索引的结构,就是跳表

在跳表中查询任意数据的时间复杂度是 \(O(\log n)\)。比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间,跳表的空间复杂度是 \(O(n)\)

实际上,跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 \(O(\log n)\)

跳表索引动态更新

当不停地往跳表中插入数据时,如果不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。

跳表是通过随机函数来维护“平衡性”。

当往跳表中插入数据的时候,可以选择同时将这个数据插入到部分索引层中。通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。

20211211001034

随机函数的选择很有讲究,从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。

跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。虽然跳表的代码实现并不简单,但是作为一种动态数据结构,比起红黑树来说,实现要简单多了。所以很多时候,为了代码的简单、易读,比起红黑树,更倾向用跳表。

散列表(Hash Table)

散列表的英文叫“Hash Table”,平时也叫它“哈希表”或者“Hash 表”。

散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表

用一个例子来解释一下。假如我们有 89 名选手参加学校运动会。为了方便记录成绩,每个选手胸前都会贴上自己的参赛号码。这 89 名选手的编号依次是 1 到 89。可以截取参赛编号的后两位作为数组下标,来存取选手信息数据。当通过参赛编号查询选手信息的时候,用同样的方法,取参赛编号的后两位,作为数组下标,来读取数组中的数据。

其中,参赛选手的编号我们叫做(key)或者关键字。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫作散列函数(或“Hash 函数”“哈希函数”),而散列函数计算得到的值就叫作散列值(或“Hash 值”“哈希值”)。

20211211001035

散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是 \(O(1)\) 的特性。通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

散列函数

散列函数在散列表中起着非常关键的作用。散列函数,顾名思义,它是一个函数。把它定义成 hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。

散列函数设计的基本要求:

  1. 散列函数计算得到的散列值是一个非负整数

  2. 如果 key1 = key2,那 hash(key1) == hash(key2)

  3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)

    在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

    几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,需要通过其他途径来解决。

散列冲突

常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。

  1. 开放寻址法

    开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个空闲位置,将其插入。

    新的位置的探测方法:

    1. 线性探测(Linear Probing)

      当往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

      散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。不能单纯地把要删除的元素设置为空。将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。

      20211211001036

      线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 \(O(n)\)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

    2. 二次探测(Quadratic probing)

      二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

    3. 双重散列(Double hashing)

      双重散列,意思就是不仅要使用一个散列函数。

      先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

    不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

    装载因子的计算公式是:\(散列表的装载因子=填入表中的元素个数/散列表的长度\)

    装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

  2. 链表法

    链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。

    20211211001037

    当插入的时候,只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 \(O(1)\)。当查找、删除一个元素时,同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 \(O(k)\)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。

散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是散列函数设计散列冲突解决。散列冲突有两种常用的解决方法,开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。

开放寻址法和链表法的优势和劣势

  1. 开放寻址法

    • 优点

      开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。

    • 缺点

      用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

    总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

  2. 链表法

    链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。

    链表法比起开放寻址法,对大装载因子的容忍度更高。只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。

    总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

散列表碰撞攻击

在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。

如果散列表中有 10 万个数据,退化后的散列表查询的效率就下降了 10 万倍。更直接点说,如果之前运行 100 次查询只需要 0.1 秒,那现在就需要 1 万秒。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS)的目的。这也就是散列表碰撞攻击的基本原理。

如何设计散列函数

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接地影响到散列表的性能。

其次,散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。

实际上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等。

装载因子过大了怎么办

针对散列表,当装载因子过大时,也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。

针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以需要通过散列函数重新计算每个数据的存储位置。

对于支持动态扩容的散列表,插入操作的最好时间复杂度是 \(O(1)\),最坏情况时间复杂度是 \(O(n)\),用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 \(O(1)\)

实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果对空间消耗非常敏感,可以在装载因子小于某个值之后,启动动态缩容。

装载因子阈值的设置要权衡时间、空间复杂度。

如何避免低效的扩容

为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表中。

当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。

20211211001038

对于查询操作,为了兼容了新、老散列表中的数据,先从新散列表中查找,如果没有找到,再去老的散列表中查找。

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 \(O(1)\)

工业级散列表举例分析:Java 中的 HashMap

  1. 初始大小

    HashMap 默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。

  2. 装载因子和动态扩容

    最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。

  3. 散列冲突解决方法

    HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。

    于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

  4. 散列函数

    散列函数的设计并不复杂,追求的是简单高效、分布均匀。

    1
    2
    3
    4
    int hash(Object key) {
        int h = key.hashCode()
        return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
    }
    

    其中,hashCode() 返回的是 Java 对象的 hash code。比如 String 类型的对象的 hashCode() 就是下面这样:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    public int hashCode() {
      int var1 = this.hash;
      if(var1 == 0 && this.value.length > 0) {
        char[] var2 = this.value;
        for(int var3 = 0; var3 < this.value.length; ++var3) {
          var1 = 31 * var1 + var2[var3];
        }
        this.hash = var1;
      }
      return var1;
    }
    

何为一个工业级的散列表?工业级的散列表应该具有哪些特性?

应该有这样几点要求:

  • 支持快速地查询、插入、删除操作
  • 内存占用合理,不能浪费过多的内存空间
  • 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况

如何实现这样一个散列表:

  • 设计一个合适的散列函数
  • 定义装载因子阈值,并且设计动态扩容策略
  • 选择合适的散列冲突解决方法

散列表和链表的搭配使用

LRU 缓存淘汰算法

借助散列表,可以把 LRU 缓存淘汰算法的时间复杂度降低为 \(O(1)\)

20211211001039

使用双向链表存储数据,链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext。

散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个链是散列表中的拉链前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中

整个过程涉及的查找操作都可以通过散列表来完成。其他的操作,比如删除头结点、链表尾部插入数据等,都可以在 \(O(1)\) 的时间复杂度内完成。至此,通过散列表和双向链表的组合使用,就实现了一个高效的、支持 LRU 缓存淘汰算法的缓存系统原型。

Redis 有序集合

实际上,在有序集合中,每个成员对象有两个重要的属性,key(键值)和 score(分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。

Java LinkedHashMap

LinkedHashMap 是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap 中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。

哈希算法

不管是“散列”还是“哈希”,这都是中文翻译的差别,英文其实就是“Hash”。所以,常听到有人把“散列表”叫作“哈希表”“Hash 表”,把“哈希算法”叫作“Hash 算法”或者“散列算法”。

将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值

优秀的哈希算法需要满足的几点要求:

  • 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
  • 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
  • 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值

以 MD5 为例,无论要哈希的文本有多长、多短,通过 MD5 哈希之后,得到的哈希值的长度都是相同的,而且得到的哈希值看起来像一堆随机数,完全没有规律。

哈希算法的应用非常非常多,最常见的有安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。

应用一:安全加密

最常用于加密的哈希算法是 MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和 SHA(Secure Hash Algorithm,安全散列算法)。

对用于加密的哈希算法来说,有两点格外重要。第一点是很难根据哈希值反向推导出原始数据,第二点是散列冲突的概率要很小。

实际上,不管是什么哈希算法,只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的。这里就基于组合数学中一个非常基础的理论,鸽巢原理(也叫抽屉原理)。这个原理本身很简单,它是说,如果有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说就是,肯定有 2 只鸽子在 1 个鸽巢内。

哈希算法产生的哈希值的长度是固定且有限的,能表示的数据是有限的,而要哈希的数据是无穷的。一般情况下,哈希值越长的哈希算法,散列冲突的概率越低。即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是很难被破解的。

没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如 SHA-256 比 SHA-1 要更复杂、更安全,相应的计算时间就会比较长。

应用二:唯一标识

通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为文件的唯一标识。通过这个唯一标识来判定文件是否在文件库中,这样就可以减少很多工作量。

应用三:数据校验

以 BT 下载为例,通过哈希算法,对 100 个文件块分别取哈希值,并且保存在种子文件中。当文件块下载完成之后,通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。

应用四:散列函数

相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,都可以通过开放寻址法或者链表法解决。

不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

字典攻击

针对字典攻击,可以引入一个盐(salt),跟用户的密码组合在一起,增加密码的复杂度。拿组合之后的字符串来做哈希算法加密,将它存储到数据库中,进一步增加破解的难度。

应用五:负载均衡

负载均衡算法有很多,比如轮询、随机、加权轮询等。

实现一个会话粘滞(session sticky)的负载均衡算法最直接的方法就是,维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也有几个弊端:

  • 如果客户端很多,映射表可能会很大,比较浪费内存空间
  • 客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大

如果借助哈希算法,可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。 这样,就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。

应用六:数据分片

  1. 如何统计“搜索关键词”出现的次数?

    假如有 1T 的日志文件,这里面记录了用户的搜索关键词,想要快速统计出每个关键词被搜索的次数,这个问题有两个难点,第一个是搜索日志很大,没办法放到一台机器的内存中。第二个难点是,如果只用一台机器来处理这么巨大的数据,处理时间会很长。

    针对这两个难点,可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度。具体的思路是这样的:为了提高处理的速度,用 n 台机器并行处理。从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编号。

    这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。

    实际上,这里的处理过程也是 MapReduce 的基本设计思想。

  2. 如何快速判断图片是否在图库中?

    假设现在图库中有 1 亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而 1 亿张图片构建散列表显然远远超过了单台机器的内存上限。

    同样可以对数据进行分片,然后采用多机处理。准备 n 台机器,让每台机器只维护某一部分图片对应的散列表。每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。

    当要判断一个图片是否在图库中的时候,通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中查找。

应用七:分布式存储

现在互联网面对的都是海量的数据、海量的用户。为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,就需要将数据分布在多台机器上。

如果数据增多,原来的 10 个机器已经无法承受了,我们就需要扩容了,比如扩到 11 个机器。这时候,原来的数据是通过与 10 来取模的。比如 13 这个数据,存储在编号为 3 这台机器上。但是新加了一台机器后,对数据按照 11 取模,原来 13 这个数据就被分配到 2 号这台机器上。因此,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生雪崩效应,压垮数据库。

所以,我们需要一种方法——一致性哈希算法

假设有 k 个机器,数据的哈希值的范围是[0, MAX]。将整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。当有新机器加入的时候,就将某几个小区间的数据,从原来的机器中搬移到新的机器中。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。

这就是一致性哈希算法的基本思想。除此之外,它还会借助一个虚拟的环和虚拟结点,更加优美地实现出来。

“树”里面每个元素叫做“节点”;用来连接相邻节点之间的关系,叫做“父子关系”。

20211211001040

下面这幅图,A 节点就是 B 节点的父节点,B 节点是 A 节点的子节点。B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点。

把没有父节点的节点叫做根节点,也就是图中的节点 E。

把没有子节点的节点叫做叶子节点或者叶节点,比如图中的 G、H、I、J、K、L 都是叶子节点。

关于“树”,还有比较相似的概念:高度(Height)、深度(Depth)、层(Level)。

  • 节点的高度 = 节点到叶子节点到最长路径(边数)
  • 节点到深度 = 根节点到这个节点所经历的边的个数
  • 节点的层数 = 节点的深度 + 1
  • 树的高度 = 根节点的高度

20211211001041

二叉树(Binary Tree)

树结构多种多样,不过最常用还是二叉树。

二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

20211211001042

编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树

编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树

想要存储一棵二叉树,有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。

链式存储法:只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。

20211211001043

顺序存储法

20211211001044

如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。

二叉树的遍历

如何将所有节点都遍历打印出来呢?经典的方法有三种,前序遍历中序遍历后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。

  • 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
  • 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
  • 后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

20211211001045

实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。

递推公式
1
2
3
4
5
6
7
8
前序遍历的递推公式:
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
三种遍历方式的代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void preOrder(Node* root) {
  if (root == null) return;
  print root // 此处为伪代码,表示打印root节点
  preOrder(root->left);
  preOrder(root->right);
}

void inOrder(Node* root) {
  if (root == null) return;
  inOrder(root->left);
  print root // 此处为伪代码,表示打印root节点
  inOrder(root->right);
}

void postOrder(Node* root) {
  if (root == null) return;
  postOrder(root->left);
  postOrder(root->right);
  print root // 此处为伪代码,表示打印root节点
}

二叉树遍历的时间复杂度是 \(O(n)\)

二叉查找树(Binary Search Tree)

二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。

二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。

这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值

20211211001046

  1. 二叉查找树的查找操作

    先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。

    二叉查找树的查找操作
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class BinarySearchTree {
      private Node tree;
    
      public Node find(int data) {
        Node p = tree;
        while (p != null) {
          if (data < p.data) p = p.left;
          else if (data > p.data) p = p.right;
          else return p;
        }
        return null;
      }
    
      public static class Node {
        private int data;
        private Node left;
        private Node right;
    
        public Node(int data) {
          this.data = data;
        }
      }
    }
    
  2. 二叉查找树的插入操作

    二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

    如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

    二叉查找树的插入操作
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public void insert(int data) {
      if (tree == null) {
        tree = new Node(data);
        return;
      }
    
      Node p = tree;
      while (p != null) {
        if (data > p.data) {
          if (p.right == null) {
            p.right = new Node(data);
            return;
          }
          p = p.right;
        } else { // data < p.data
          if (p.left == null) {
            p.left = new Node(data);
            return;
          }
          p = p.left;
        }
      }
    }
    
  3. 二叉查找树的删除操作

    针对要删除节点的子节点个数的不同,需要分三种情况来处理。

    第一种情况是,如果要删除的节点没有子节点,只需要直接将父节点中,指向要删除节点的指针置为 null。

    第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。

    第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,可以应用上面两条规则来删除这个最小节点。

    二叉查找树的删除操作
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public void delete(int data) {
      Node p = tree; // p指向要删除的节点,初始化指向根节点
      Node pp = null; // pp记录的是p的父节点
      while (p != null && p.data != data) {
        pp = p;
        if (data > p.data) p = p.right;
        else p = p.left;
      }
      if (p == null) return; // 没有找到
    
      // 要删除的节点有两个子节点
      if (p.left != null && p.right != null) { // 查找右子树中最小节点
        Node minP = p.right;
        Node minPP = p; // minPP表示minP的父节点
        while (minP.left != null) {
          minPP = minP;
          minP = minP.left;
        }
        p.data = minP.data; // 将minP的数据替换到p中
        p = minP; // 下面就变成了删除minP了
        pp = minPP;
      }
    
      // 删除节点是叶子节点或者仅有一个子节点
      Node child; // p的子节点
      if (p.left != null) child = p.left;
      else if (p.right != null) child = p.right;
      else child = null;
    
      if (pp == null) tree = child; // 删除的是根节点
      else if (pp.left == p) pp.left = child;
      else pp.right = child;
    }
    

    实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

  4. 二叉查找树的其他操作

    除了插入、删除、查找操作之外,二叉查找树中还可以支持快速地查找最大节点和最小节点、前驱节点和后继节点

    二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 \(O(n)\),非常高效。因此,二叉查找树也叫作二叉排序树。

支持重复数据的二叉查找树

很多时候,在实际的软件开发中,在二叉查找树中存储的,是一个包含很多字段的对象。利用对象的某个字段作为键值(key)来构建二叉查找树。把对象中的其他字段叫作卫星数据。

二叉查找树存储的两个对象键值相同时,有两种解决方法:

  1. 二叉查找树中每一个节点不仅会存储一个数据,因此通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。

  2. 每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。

    当要查找数据的时候,遇到值相同的节点,并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

    对于删除操作,也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

二叉查找树的时间复杂度分析

二叉查找树插入、删除查找的操作,时间复杂度其实都跟树的高度成正比,也就是 \(O(height)\)

对于完全二叉树来说,它包含的节点个数在 \(1\) 个到 \(2^{L-1}\) 个之间(假设最大层数是 \(L\))。如果把每一层的节点个数加起来就是总的节点个数 \(n\)。借助等比数列的求和公式,可以计算出,\(L\) 的范围是 \([log_{2}(n+1), log_{2}n +1]\)。完全二叉树的层数小于等于 \(log_{2}n+1\),也就是说,完全二叉树的高度小于等于 \(log_{2}n\)

相对散列表,为什么还要用二叉查找树

  1. 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 \(O(n)\) 的时间复杂度内,输出有序的数据序列。
  2. 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 \(O(\log n)\)
  3. 笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 \(\log n\) 小,所以实际的查找速度可能不一定比 \(O(\log n)\) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
  4. 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
  5. 散列表为了避免过多的散列冲突,装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

红黑树(Red-Black Tree)

平衡二叉树的严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于 1。完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

平衡二叉查找树不仅满足上面平衡二叉树的定义,还满足二叉查找树的特点。最先被发明的平衡二叉查找树是 AVL 树。

很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于 1),比如下面要讲的红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。

平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些

红黑树的英文是“Red-Black Tree”,简称 R-B Tree。它是一种不严格的平衡二叉查找树。

红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

  • 根节点是黑色的
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;

红黑树是“近似平衡”的

平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重

在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过 \(\log_{2}n\),所以加入红色节点之后,最长路径不会超过 \(2 \log_{2}n\),也就是说,红黑树的高度近似 \(2 \log_{2}n\)。所以,红黑树的高度只比高度平衡的 AVL 树的高度(\(log_{2}n\))仅仅大了一倍,在性能上,下降得并不多。这样推导出来的结果不够精确,实际上红黑树的性能更好。

因为红黑树是一种性能非常稳定的二叉查找树,所以,在工程中,但凡是用到动态插入、删除、查找数据的场景,都可以用到它。不过,它实现起来比较复杂,如果自己写代码实现,难度会有些高,这个时候,其实更倾向用跳表来替代它。

红黑树的实现

左旋(rotate left):围绕某个节点的左旋

右旋(rotate right):围绕某个节点的右旋

20211211001047

插入操作的平衡调整

红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以,关于插入操作的平衡调整,有这样两种特殊情况,但是也都非常好处理。

  • 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。
  • 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。

除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:左右旋转改变颜色

红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫做关注节点。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。

新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。下面依次来看每种情况的调整过程。为了简化描述,把父节点的兄弟节点叫做叔叔节点,父节点的父节点叫做祖父节点。

  1. 如果关注节点是 a,它的叔叔节点 d 是红色,就依次执行下面的操作:

    • 将关注节点 a 的父节点 b、叔叔节点 d 的颜色都设置成黑色
    • 将关注节点 a 的祖父节点 c 的颜色设置成红色
    • 关注节点变成 a 的祖父节点 c
    • 跳到 CASE 2 或者 CASE 3

    20211211001048

  2. 如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的右子节点,就依次执行下面的操作:

    • 关注节点变成节点 a 的父节点 b
    • 围绕新的关注节点b 左旋
    • 跳到 CASE 3

    20211211001049

  3. 如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的左子节点,就依次执行下面的操作:

    • 围绕关注节点 a 的祖父节点 c 右旋
    • 将关注节点 a 的父节点 b、兄弟节点 c 的颜色互换
    • 调整结束

    20211211001050

删除操作的平衡调整

删除操作的平衡调整分为两步,第一步是针对删除节点初步调整。初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。

  1. 针对删除节点初步调整

    这里需要注意一下,红黑树的定义中“只包含红色节点和黑色节点”,经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,“红 - 黑”或者“黑 - 黑”。如果一个节点被标记为了“黑 - 黑”,那在计算黑色节点个数的时候,要算成两个黑色节点。

    在下方示意图中,如果一个节点既可以是红色,也可以是黑色,在画图的时候,用一半红色一半黑色来表示。如果一个节点是“红 - 黑”或者“黑 - 黑”,用左上角的一个小黑点来表示额外的黑色。

    1. 如果要删除的节点是 a,它只有一个子节点 b,就依次进行下面的操作:

      • 删除节点 a,并且把节点 b 替换到节点 a 的位置,这一部分操作跟普通的二叉查找树的删除操作一样
      • 节点 a 只能是黑色,节点 b 也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点 b 改为黑色
      • 调整结束,不需要进行二次调整

      20211211001051

    2. 如果要删除的节点 a 有两个非空子节点,并且它的后继节点就是节点 a 的右子节点 c。就依次进行下面的操作:

      • 如果节点 a 的后继节点就是右子节点 c,那右子节点 c 肯定没有左子树。我们把节点 a 删除,并且将节点 c 替换到节点 a 的位置。这一部分操作跟普通的二叉查找树的删除操作无异
      • 然后把节点 c 的颜色设置为跟节点 a 相同的颜色
      • 如果节点 c 是黑色,为了不违反红黑树的最后一条定义,我们给节点 c 的右子节点 d 多加一个黑色,这个时候节点 d 就成了“红 - 黑”或者“黑 - 黑”
      • 这个时候,关注节点变成了节点 d,第二步的调整操作就会针对关注节点来做。

      20211211001052

    3. 如果要删除的是节点 a,它有两个非空子节点,并且节点 a 的后继节点不是右子节点,就依次进行下面的操作:

      • 找到后继节点 d,并将它删除,删除后继节点 d 的过程参照 CASE 1
      • 将节点 a 替换成后继节点 d
      • 把节点 d 的颜色设置为跟节点 a 相同的颜色
      • 如果节点 d 是黑色,为了不违反红黑树的最后一条定义,我们给节点 d 的右子节点 c 多加一个黑色,这个时候节点 c 就成了“红 - 黑”或者“黑 - 黑”
      • 这个时候,关注节点变成了节点 c,第二步的调整操作就会针对关注节点来做

      20211211001053

  2. 针对关注节点进行二次调整

    1. 如果关注节点是 a,它的兄弟节点 c 是红色的,就依次进行下面的操作:

      • 围绕关注节点 a 的父节点 b 左旋
      • 关注节点 a 的父节点 b 和祖父节点 c 交换颜色
      • 关注节点不变
      • 继续从四种情况中选择适合的规则来调整

      20211211001054

    2. 如果关注节点是 a,它的兄弟节点 c 是黑色的,并且节点 c 的左右子节点 d、e 都是黑色的,就依次进行下面的操作:

      • 将关注节点 a 的兄弟节点 c 的颜色变成红色
      • 从关注节点 a 中去掉一个黑色,这个时候节点 a 就是单纯的红色或者黑色
      • 给关注节点 a 的父节点 b 添加一个黑色,这个时候节点 b 就变成了“红 - 黑”或者“黑 - 黑”
      • 关注节点从 a 变成其父节点 b
      • 继续从四种情况中选择符合的规则来调整。

      20211211001055

    3. 如果关注节点是 a,它的兄弟节点 c 是黑色,c 的左子节点 d 是红色,c 的右子节点 e 是黑色,就依次进行下面的操作:

      • 围绕关注节点 a 的兄弟节点 c 右旋
      • 节点 c 和节点 d 交换颜色
      • 关注节点不变
      • 跳转到 CASE 4,继续调整。

      20211211001056

    4. 如果关注节点 a 的兄弟节点 c 是黑色的,并且 c 的右子节点是红色的,就依次进行下面的操作:

      • 围绕关注节点 a 的父节点 b 左旋
      • 将关注节点 a 的兄弟节点 c 的颜色,跟关注节点 a 的父节点 b 设置成相同的颜色
      • 将关注节点 a 的父节点 b 的颜色设置为黑色
      • 从关注节点 a 中去掉一个黑色,节点 a 就变成了单纯的红色或者黑色
      • 将关注节点 a 的叔叔节点 e 设置为黑色
      • 调整结束

      20211211001057

递归树

递归树与时间复杂度分析

借助递归树来分析递归算法的时间复杂度。

20211211001058

因为每次分解都是一分为二,所以代价很低,把时间上的消耗记作常量 \(1\)。归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。把每一层归并操作消耗的时间记作 \(n\)

现在,只需要知道这棵树的高度 \(h\),用高度 \(h\) 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 \(O(n∗h)\)。从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树。我们前两节中讲到,满二叉树的高度大约是 \(log_{2} ​n\),所以,归并排序递归实现的时间复杂度就是 \(O(n \log n)\)。我这里的时间复杂度都是估算的,对树的高度的计算也没有那么精确,但是这并不影响复杂度的计算结果。

实战一:分析快速排序的时间复杂度

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 \(T(n)=2T(\frac{2}{n}​)+n\),很容易就能推导出时间复杂度是 \(O(n \log n)\)。但是,并不可能每次分区都这么幸运,正好一分为二。

从概率论的角度来说,快排的平均时间复杂度就是 \(O(n \log n)\)

实战二:分析斐波那契数列的时间复杂度

斐波那契数列
1
2
3
4
5
int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}
20211211001059

\(f(n)\) 分解为 \(f(n−1)\)\(f(n−2)\),每次数据规模都是 −1 或者 −2,叶子节点的数据规模是 1 或者 2。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 −1,那最长路径大约就是 \(n\);如果每次都是 −2,那最短路径大约就是 \(2n​\)

每次分解之后的合并操作只需要一次加法运算,把这次加法运算的时间消耗记作 \(1\)。所以,从上往下,第一层的总时间消耗是 \(1\),第二层的总时间消耗是 \(2\),第三层的总时间消耗就是 \(2^{2}\)。依次类推,第 \(k\) 层的时间消耗就是 \(2k−1\),那整个算法的总的时间消耗就是每一层时间消耗之和。

如果路径长度都为 \(n\),那这个总和就是 \(2^{n}−1\)

如果路径长度都是 \(\frac{n}{2}\)​ ,那整个算法的总的时间消耗就是 \(2^{\frac{n}{2}}​−1\)

所以,这个算法的时间复杂度就介于 \(O(2^{n})\)\(O(2^{\frac{n}{2}}​)\) 之间。虽然这样得到的结果还不够精确,只是一个范围,但是也基本上知道了上面算法的时间复杂度是指数级的,非常高。

实战三:分析全排列的时间复杂度

“如何把 n 个数据的所有排列都找出来”,这就是全排列的问题。

如何编程打印一组数据的所有排列呢?这里就可以用递归来实现。

打印一组数据的所有排列
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 调用方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
  if (k == 1) {
    for (int i = 0; i < n; ++i) {
      System.out.print(data[i] + " ");
    }
    System.out.println();
  }

  for (int i = 0; i < k; ++i) {
    int tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;

    printPermutations(data, n, k - 1);

    tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;
  }
}

20211211001060

第一层分解有 \(n\) 次交换操作,第二层有 \(n\) 个节点,每个节点分解需要 \(n−1\) 次交换,所以第二层总的交换次数是 \(n∗(n−1)\)。第三层有 \(n∗(n−1)\) 个节点,每个节点分解需要 \(n−2\) 次交换,所以第三层总的交换次数是 \(n∗(n−1)∗(n−2)\)

以此类推,第 \(k\) 层总的交换次数就是 \(n∗(n−1)∗(n−2)∗...∗(n−k+1)\)。最后一层的交换次数就是 \(n∗(n−1)∗(n−2)∗...∗2∗1\)。每一层的交换次数之和就是总的交换次数。

这个公式的求和比较复杂,看最后一个数,\(n∗(n−1)∗(n−2)∗...∗2∗1\) 等于 \(n!\),而前面的 \(n−1\) 个数都小于最后一个数,所以,总和肯定小于 \(n∗n!\),也就是说,全排列的递归算法的时间复杂度大于 \(O(n!)\),小于 \(O(n∗n!)\),虽然没法知道非常精确的时间复杂度,但是这样一个范围已经反映全排列的时间复杂度是非常高的。

堆(Heap)和堆排序

堆这种数据结构的应用场景非常多,最经典的莫过于堆排序了。堆排序是一种原地的、时间复杂度为 \(O(n \log n)\) 的排序算法。

快速排序在平均情况下的时间复杂度为 \(O(n \log n)\)

尽管这两种排序算法的时间复杂度都是 \(O(n \log n)\),甚至堆排序比快速排序的时间复杂度还要稳定,但是,在实际的软件开发中,快速排序的性能要比堆排序好。

堆是一种特殊的树,要满足这两点:

  1. 堆必须是一个完全二叉树
  2. 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。换一种说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。

对于每个节点的值都大于等于子树中每个节点值的堆,叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,叫做“小顶堆”。

实现一个堆

完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。

一个用数组存储堆的例子:

20211211001061

从图中可以看到,数组中下标为 i 的节点的左子节点,就是下标为 \(i∗2\) 的节点,右子节点就是下标为 \(i∗2+1\) 的节点,父节点就是下标为 \(\frac{i}{2}\)​ 的节点。

堆上的操作

  1. 往堆中插入一个元素

    往堆中插入一个元素后,需要继续满足堆的两个特性。

    如果把新插入的元素放到堆的最后,不符合堆的特性了,就需要进行调整,让其重新满足堆的特性,这个过程就叫做堆化(heapify)。

    堆化实际上有两种,从下往上和从上往下。

    • 从下往上

      20211211001062

      顺着节点所在的路径,向上或者向下,对比,然后交换。

      20211211001063

      可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。

      往堆中插入数据的过程
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      public class Heap {
        private int[] a; // 数组,从下标1开始存储数据
        private int n;  // 堆可以存储的最大数据个数
        private int count; // 堆中已经存储的数据个数
      
        public Heap(int capacity) {
          a = new int[capacity + 1];
          n = capacity;
          count = 0;
        }
      
        public void insert(int data) {
          if (count >= n) return; // 堆满了
          ++count;
          a[count] = data;
          int i = count;
          while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
            swap(a, i, i/2); // swap()函数作用:交换下标为i和i/2的两个元素
            i = i/2;
          }
        }
       }
      
    • 从上往下

      把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。

  2. 删除堆顶元素

    从堆的定义的第二条中,任何节点的值都大于等于(或小于等于)子树节点的值,可以发现,堆顶元素存储的就是堆中数据的最大值或者最小值。

    假设构造的是大顶堆,堆顶元素就是最大的元素。当删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后再迭代地删除第二大节点,以此类推,直到叶子节点被删除。

    把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法

    因为移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。

    20211211001064

    删除堆顶元素的过程
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    public void removeMax() {
      if (count == 0) return -1; // 堆中没有数据
      a[1] = a[count];
      --count;
      heapify(a, count, 1);
    }
    
    private void heapify(int[] a, int n, int i) { // 自上往下堆化
      while (true) {
        int maxPos = i;
        if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
        if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
        if (maxPos == i) break;
        swap(a, i, maxPos);
        i = maxPos;
      }
    }
    

    一个包含 \(n\) 个节点的完全二叉树,树的高度不会超过 \(log_{2} ​n\)。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 \(O(\log n)\)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 \(O(\log n)\)

如何基于堆实现排序

  1. 建堆

    首先将数组原地建成一个堆。所谓“原地”就是,不借助另一个数组,就在原数组上操作。建堆的过程,有两种思路。

    1. 第一种是借助我们前面讲的,在堆中插入一个元素的思路。

      尽管数组中包含 \(n\) 个数据,但是可以假设,起初堆中只包含一个数据,就是下标为 1 的数据。然后,调用前面讲的插入操作,将下标从 2 到 \(n\) 的数据依次插入到堆中。这样就将包含 \(n\) 个数据的数组,组织成了堆。

    2. 第二种实现思路,跟第一种截然相反,是从后往前处理数组,并且每个数据都是从上往下堆化。

      20211211001065

      20211211001066

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      private static void buildHeap(int[] a, int n) {
        for (int i = n/2; i >= 1; --i) {
          heapify(a, n, i);
        }
      }
      
      private static void heapify(int[] a, int n, int i) {
        while (true) {
          int maxPos = i;
          if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
          if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
          if (maxPos == i) break;
          swap(a, i, maxPos);
          i = maxPos;
        }
      }
      

      在这段代码中,对下标从 \(\frac{n}{2}\)​ 开始到 1 的数据进行堆化,下标是 \(\frac{n}{2}+1\)\(n\) 的节点是叶子节点,我们不需要堆化。实际上,对于完全二叉树来说,下标从 \(\frac{n}{2}+1\)\(n\) 的节点都是叶子节点。

      堆排序的建堆过程的时间复杂度是 \(O(n)\)

  2. 排序

    建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。把它跟最后一个元素交换,那最大元素就放到了下标为 \(n\) 的位置。

    这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,把下标为 \(n\) 的元素放到堆顶,然后再通过堆化的方法,将剩下的 \(n−1\) 个元素重新构建成堆。堆化完成之后,再取堆顶的元素,放到下标是 \(n−1\) 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。

    20211211001067

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    // n表示数据的个数,数组a中的数据从下标1到n的位置。
    public static void sort(int[] a, int n) {
      buildHeap(a, n);
      int k = n;
      while (k > 1) {
        swap(a, 1, k);
        --k;
        heapify(a, k, 1);
      }
    }
    

    整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 \(O(n)\),排序过程的时间复杂度是 \(O(n \log n)\),所以,堆排序整体的时间复杂度是 \(O(n \log n)\)

    堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。

为什么快速排序要比堆排序性能好

  1. 堆排序数据访问的方式没有快速排序友好。

    对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。比如,堆排序中,最重要的一个操作就是数据的堆化。比如这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。

  2. 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。

    对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。

    但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。

堆的应用一:优先级队列

优先级队列,顾名思义,它首先应该是一个队列。队列最大的特性就是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。

如何实现一个优先级队列呢?方法有很多,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。

很多数据结构和算法都要依赖优先级队列。比如,赫夫曼编码、图的最短路径、最小生成树算法等等。

1. 合并有序小文件

假设有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。将这些 100 个小文件合并成一个有序的大文件。

将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。

2. 高性能定时器

假设有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。

但是,这样每过 1 秒就扫描一遍任务列表的做法比较低效,主要原因有两点:第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。

针对这些问题,就可以用优先级队列来解决。按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。

这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。

这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行。这样,定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到(T-1)秒这段时间里,定时器都不需要做任何事情。

当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。

这样,定时器既不用间隔 1 秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。

堆的应用二:利用堆求 Top K

堆的另外一个非常重要的应用场景,就是“求 Top K 问题”。

把这种求 Top K 的问题抽象成两类。一类是针对静态数据集合,也就是说数据集合事先确定,不会再变。另一类是针对动态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。

针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。

遍历数组需要 \(O(n)\) 的时间复杂度,一次堆化操作需要 \(O(\log K)\) 的时间复杂度,所以最坏情况下,\(n\) 个元素都入堆一次,时间复杂度就是 \(O(n \log K)\)

针对动态数据求得 Top K 就是实时 Top K。举一个例子,一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前 K 大数据。

如果每次询问前 K 大数据,都基于当前的数据重新计算的话,那时间复杂度就是 \(O(n \log K)\)\(n\) 表示当前的数据的大小。实际上,我们可以一直都维护一个 \(K\) 大小的小顶堆,当有数据被添加到集合中时,就拿它与堆顶的元素对比。如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 \(K\) 大数据,都可以立刻返回。

堆的应用三:利用堆求中位数

如果数据的个数是奇数,把数据从小到大排列,那第 \(\frac{n}{2}​+1\) 个数据就是中位数(注意:假设数据是从 \(0\) 开始编号的);如果数据的个数是偶数的话,那处于中间位置的数据有两个,第 \(\frac{n}{2}​\) 个和第 \(\frac{n}{2}​+1\) 个数据,这个时候,可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第 \(\frac{n}{2}\) 个数据。

对于一组静态数据,中位数是固定的,可以先排序,第 \(\frac{n}{2}\)​ 个数据就是中位数。每次询问中位数的时候,直接返回这个固定的值就好了。所以,尽管排序的代价比较大,但是边际成本会很小。但是,如果面对的是动态数据集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。

借助堆这种数据结构,不用排序,就可以非常高效地实现求中位数操作

维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。

也就是说,如果有 \(n\) 个数据,\(n\) 是偶数,我们从小到大排序,那前 \(\frac{n}{2}\)​ 个数据存储在大顶堆中,后 \(\frac{n}{2}\)​ 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 \(n\) 是奇数,情况是类似的,大顶堆就存储 \(\frac{n}{2}​+1\) 个数据,小顶堆中就存储 \(\frac{n}{2}\) 个数据。

20211211001068s

如果新加入的数据小于等于大顶堆的堆顶元素,就将这个新数据插入到大顶堆;否则,就将这个新数据插入到小顶堆。

这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况:如果 \(n\) 是偶数,两个堆中的数据个数都是 \(\frac{n}{2}\)​;如果 \(n\) 是奇数,大顶堆有 \(\frac{n}{2}​+1\) 个数据,小顶堆有 \(\frac{n}{2}​\) 个数据。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。

于是,就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 \(O(\log n)\),但是求中位数只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 \(O(1)\)

实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,原理是类似的。

有一个包含 10 亿个搜索关键词的日志文件,如何快速获取到 Top 10 最热门的搜索关键词

处理这个问题,有很多高级的解决方法,比如使用 MapReduce 等。但是,如果将处理的场景限定为单机,可以使用的内存为 1GB。那这个问题该如何解决呢?

因为用户搜索的关键词,有很多可能都是重复的,所以首先要统计每个搜索关键词出现的频率。可以通过散列表、平衡二叉查找树或者其他一些支持快速查找、插入的数据结构,来记录关键词及其出现的次数。

假设选用散列表。顺序扫描这 10 亿个搜索关键词。当扫描到某个关键词时,去散列表中查询。如果存在,就将对应的次数加一;如果不存在,就将它插入到散列表,并记录次数为 1。以此类推,等遍历完这 10 亿个搜索关键词之后,散列表中就存储了不重复的搜索关键词以及出现的次数。

然后,再根据前面讲的用堆求 Top K 的方法,建立一个大小为 10 的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中。

以此类推,当遍历完整个散列表中的搜索关键词之后,堆中的搜索关键词就是出现次数最多的 Top 10 搜索关键词了。

10 亿的关键词还是很多的。假设 10 亿条搜索关键词中不重复的有 1 亿条,如果每个搜索关键词的平均长度是 50 个字节,那存储 1 亿个关键词起码需要 5GB 的内存空间,而散列表因为要避免频繁冲突,不会选择太大的装载因子,所以消耗的内存空间就更多了。而机器只有 1GB 的可用内存空间,所以无法一次性将所有的搜索关键词加入到内存中。可以根据哈希算法的这个特点,将 10 亿条搜索关键词先通过哈希算法分片到 10 个文件中。创建 10 个空文件 00,01,02,……,09。遍历这 10 亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同 10 取模,得到的结果就是这个搜索关键词应该被分到的文件编号。

对这 10 亿个关键词分片之后,每个文件都只有 1 亿的关键词,去除掉重复的,可能就只有 1000 万个,每个关键词平均 50 个字节,所以总的大小就是 500MB。1GB 的内存完全可以放得下。

针对每个包含 1 亿条搜索关键词的文件,利用散列表和堆,分别求出 Top 10,然后把这个 10 个 Top 10 放在一块,然后取这 100 个关键词中,出现次数最多的 10 个关键词,这就是这 10 亿数据中的 Top 10 最频繁的搜索关键词了。

图的表示

图(Graph),和树比起来,是一种更加复杂的非线性表结构。

图中的元素我们就叫做顶点(vertex)。图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫做(edge)。

20211211001069

就拿微信举例子。可以把每个用户看作一个顶点。如果两个用户之间互加好友,那就在两者之间建立一条边。所以,整个微信的好友关系就可以用一张图来表示。其中,每个用户有多少个好友,对应到图中,就叫做顶点的(degree),就是跟顶点相连接的边的条数。

边有方向的图叫做“有向图”。以此类推,没有方向的图就叫做“无向图”。

20211211001070

无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,把度分为入度(In-degree)和出度(Out-degree)。

顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。

带权图(weighted graph)。在带权图中,每条边都有一个权重(weight)。

20211211001071

邻接矩阵存储方法

图最直观的一种存储方法就是,邻接矩阵(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。对于带权图,数组中就存储相应的权重。

20211211001072

用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。

对于无向图来说,如果 A[i][j]等于 1,那 A[j][i]也肯定等于 1。实际上,只需要存储一个就可以了。也就是说,无向图的二维数组中,如果将其用对角线划分为上下两部分,那只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了。

还有,如果我存储的是稀疏图(Sparse Matrix),也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。

但这也并不是说,邻接矩阵的存储方法就完全没有优点。首先,邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。其次,用邻接矩阵存储图的另外一个好处是方便计算。这是因为,用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵之间的运算。比如求解最短路径问题时会提到一个 Floyd-Warshall 算法,就是利用矩阵循环相乘若干次得到结果。

邻接表存储方法

针对上面邻接矩阵比较浪费内存空间的问题,来看另外一种图的存储方法,邻接表(Adjacency List)。

每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。另外需要说明一下,图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点。

20211211001073

邻接矩阵存储起来比较浪费空间,但是使用起来比较节省时间。相反,邻接表存储起来比较节省空间,但是使用起来就比较耗时间。

在基于链表法解决冲突的散列表中,如果链过长,为了提高查找效率,可以将链表换成其他更加高效的数据结构,比如平衡二叉查找树等。邻接表长得很像散列。所以,也可以将邻接表同散列表一样进行“改进升级”。

可以将邻接表中的链表改成平衡二叉查找树。实际开发中,可以选择用红黑树。这样,就可以更加快速地查找两个顶点之间是否存在边了。当然,这里的二叉查找树可以换成其他动态数据结构,比如跳表、散列表等。除此之外,还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间否是存在边。

搜索算法

图有两种主要存储方法,邻接表和邻接矩阵。

广度优先搜索和深度优先搜索是图上的两种最常用、最基本的搜索算法,比起其他高级的搜索算法,比如 A、IDA 等,要简单粗暴,没有什么优化,所以,也被叫作暴力搜索算法。所以,这两种搜索算法仅适用于状态空间不大,也就是说图不大的搜索。

下面用邻接表来存储图。

深度优先搜索算法和广度优先搜索算法,既可以用在无向图,也可以用在有向图上。在下面的讲解中,都针对无向图来讲解。

图的代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Graph { // 无向图
  private int v; // 顶点的个数
  private LinkedList<Integer> adj[]; // 邻接表

  public Graph(int v) {
    this.v = v;
    adj = new LinkedList[v];
    for (int i=0; i<v; ++i) {
      adj[i] = new LinkedList<>();
    }
  }

  public void addEdge(int s, int t) { // 无向图一条边存两次
    adj[s].add(t);
    adj[t].add(s);
  }
}

广度优先搜索(BFS)

广度优先搜索(Breadth-First-Search),平常都简称 BFS。直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。

20211211001074

s 表示起始顶点,t 表示终止顶点。搜索一条从 s 到 t 的路径。实际上,这样求得的路径就是从 s 到 t 的最短路径。

广度优先搜索的代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void bfs(int s, int t) {
  if (s == t) return;
  boolean[] visited = new boolean[v];
  visited[s]=true;
  Queue<Integer> queue = new LinkedList<>();
  queue.add(s);
  int[] prev = new int[v];
  for (int i = 0; i < v; ++i) {
    prev[i] = -1;
  }
  while (queue.size() != 0) {
    int w = queue.poll();
   for (int i = 0; i < adj[w].size(); ++i) {
      int q = adj[w].get(i);
      if (!visited[q]) {
        prev[q] = w;
        if (q == t) {
          print(prev, s, t);
          return;
        }
        visited[q] = true;
        queue.add(q);
      }
    }
  }
}

private void print(int[] prev, int s, int t) { // 递归打印s->t的路径
  if (prev[t] != -1 && t != s) {
    print(prev, s, prev[t]);
  }
  System.out.print(t + " ");
}

visited 是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q]会被设置为 true。

queue 是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,只有把第 k 层的顶点都访问完成之后,才能访问第 k+1 层的顶点。当访问到第 k 层的顶点的时候,需要把第 k 层的顶点记录下来,稍后才能通过第 k 层的顶点来找第 k+1 层的顶点。所以,用这个队列来实现记录的功能。

prev 用来记录搜索路径。当从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。不过,这个路径是反向存储的。prev[w]存储的是,顶点 w 是从哪个前驱顶点遍历过来的。比如,通过顶点 2 的邻接表访问到顶点 3,那 prev[3]就等于 2。为了正向打印出路径,需要递归地来打印,可以看下 print() 函数的实现方式。

为了方便理解,下面是广度优先搜索的分解图。

20211211001075

20211211001076

20211211001077

最坏情况下,终止顶点 t 离起始顶点 s 很远,需要遍历完整个图才能找到。这个时候,每个顶点都要进出一遍队列,每个边也都会被访问一次,所以,广度优先搜索的时间复杂度是 O(V+E),其中,V 表示顶点的个数,E 表示边的个数。当然,对于一个连通图来说,也就是说一个图中的所有顶点都是连通的,E 肯定要大于等于 V-1,所以,广度优先搜索的时间复杂度也可以简写为 O(E)。

广度优先搜索的空间消耗主要在几个辅助变量 visited 数组、queue 队列、prev 数组上。这三个存储空间的大小都不会超过顶点的个数,所以空间复杂度是 O(V)。

深度优先搜索(DFS)

深度优先搜索(Depth-First-Search),简称 DFS。最直观的例子就是“走迷宫”。

搜索的起始顶点是 s,终止顶点是 t,希望在图中寻找一条从顶点 s 到顶点 t 的路径。如果映射到迷宫那个例子,s 就是起始所在的位置,t 就是出口。

20211211001078

这里面实线箭头表示遍历,虚线箭头表示回退。从图中可以看出,深度优先搜索找出来的路径,并不是顶点 s 到顶点 t 的最短路径。

实际上,深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现。

深度优先搜索代码实现也用到了 prev、visited 变量以及 print() 函数,它们跟广度优先搜索代码实现里的作用是一样的。不过,深度优先搜索代码实现里,有个比较特殊的变量 found,它的作用是,当已经找到终止顶点 t 之后,就不再递归地继续查找了。

深度优先搜索的代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
boolean found = false; // 全局变量或者类成员变量

public void dfs(int s, int t) {
  found = false;
  boolean[] visited = new boolean[v];
  int[] prev = new int[v];
  for (int i = 0; i < v; ++i) {
    prev[i] = -1;
  }
  recurDfs(s, t, visited, prev);
  print(prev, s, t);
}

private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
  if (found == true) return;
  visited[w] = true;
  if (w == t) {
    found = true;
    return;
  }
  for (int i = 0; i < adj[w].size(); ++i) {
    int q = adj[w].get(i);
    if (!visited[q]) {
      prev[q] = w;
      recurDfs(q, t, visited, prev);
    }
  }
}

每条边最多会被访问两次,一次是遍历,一次是回退。所以,图上的深度优先搜索算法的时间复杂度是 O(E),E 表示边的个数。

深度优先搜索算法的消耗内存主要是 visited、prev 数组和递归调用栈。visited、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数,所以总的空间复杂度就是 O(V)。

字符串匹配基础

BF 算法

BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。这种算法的字符串匹配方式很“暴力”,当然也就会比较简单、好懂,但相应的性能也不高。

在开始讲解这个算法之前,先定义两个概念。它们分别是主串和模式串。

比方说,在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。把主串的长度记作 n,模式串的长度记作 m。因为是在主串中查找模式串,所以 n>m。

作为最简单、最暴力的字符串匹配算法,BF 算法的思想可以用一句话来概括,那就是,在主串中,检查起始位置分别是 0、1、2....n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的

20211211001079

在极端情况下,每次都比对 m 个字符,要比对 n-m+1 次,所以,这种算法的最坏情况时间复杂度是 \(O(n*m)\)

尽管理论上,BF 算法的时间复杂度很高,是 \(O(n*m)\),但在实际的开发中,它却是一个比较常用的字符串匹配算法。原因有两点:

  1. 实际的软件开发中,大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候,就可以就停止了,不需要把 m 个字符都比对一下。所以,尽管理论上的最坏情况时间复杂度是 \(O(n*m)\),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多。
  2. 朴素字符串匹配算法思想简单,代码实现也非常简单。简单意味着不容易出错,如果有 bug 也容易暴露和修复。在工程中,在满足性能要求的前提下,简单是首选。这也是我们常说的 KISS(Keep it Simple and Stupid)设计原则

所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。

RK 算法

RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。

对朴素的字符串匹配算法稍加改造,引入哈希算法,时间复杂度立刻就会降低。

RK 算法的思路是这样的:通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题,后面我们会讲到)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。

20211211001080

不过,通过哈希算法计算子串的哈希值的时候,需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。

哈希算法的设计可以提高哈希算法计算子串哈希值的效率。假设要匹配的字符串的字符集中只包含 K 个字符,我们可以用一个 K 进制数来表示一个子串,这个 K 进制数转化成十进制数,作为子串的哈希值。

比如要处理的字符串只包含 a~z 这 26 个小写字母,那就用二十六进制来表示一个字符串。把 a~z 这 26 个字符映射到 0~25 这 26 个数字,a 就表示 0,b 就表示 1,以此类推,z 表示 25。

在十进制的表示法中,一个数字的值是通过下面的方式计算出来的。对应到二十六进制,一个包含 a 到 z 这 26 个字符的字符串,计算哈希的时候,只需要把进位从 10 改成 26 就可以。

20211211001081

假设字符串中只包含 a~z 这 26 个小写字符,用二十六进制来表示一个字符串,对应的哈希值就是二十六进制数转化成十进制的结果。

这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。

20211211001082

从这里例子中,很容易就能得出这样的规律:相邻两个子串 s[i-1]和 s[i](i 表示子串在主串中的起始位置,子串的长度都为 m),对应的哈希值计算公式有交集,也就是说,可以使用 s[i-1]的哈希值很快的计算出 s[i]的哈希值。如果用公式表示的话,就是下面这个样子:

20211211001083

这里有一个小细节需要注意,那就是 26^(m-1) 这部分的计算,可以通过查表的方法来提高效率。事先计算好 \(26^{0}\)\(26^{1}\)\(26^{2}\) …… \(26^{m-1}\),并且存储在一个长度为 m 的数组中,公式中的“次方”就对应数组的下标。当需要计算 26 的 x 次方的时候,就可以从数组的下标为 x 的位置取值,直接使用,省去了计算的时间。

20211211001084

整个 RK 算法包含两部分,计算子串哈希值和模式串哈希值与子串哈希值之间的比较。第一部分,我们前面也分析了,可以通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以这部分的时间复杂度是 O(n)。

模式串哈希值与每个子串哈希值之间的比较的时间复杂度是 O(1),总共需要比较 n-m+1 个子串的哈希值,所以,这部分的时间复杂度也是 O(n)。所以,RK 算法整体的时间复杂度就是 O(n)。

哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致 RK 算法的时间复杂度退化,效率下降。极端情况下,如果存在大量的冲突,每次都要再对比子串和模式串本身,那时间复杂度就会退化成 \(O(n*m)\)。但也不要太悲观,一般情况下,冲突不会很多,RK 算法的效率还是比 BF 算法高的。

BM 算法

BM(Boyer-Moore)算法是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的 KMP 算法 的 3 到 4 倍。BM 算法的原理很复杂,比较难懂。

BM 算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。

  1. 坏字符规则

    BM 算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的。

    20211211001085

    从模式串的末尾往前倒着匹配,当发现某个字符没法匹配的时候,我们把这个没有匹配的字符叫作坏字符(主串中的字符)。

    20211211001086

    拿坏字符 c 在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符 c 与模式串中的任何字符都不可能匹配。这个时候,可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。

    20211211001087

    这个时候我们发现,模式串中最后一个字符 d,还是无法跟主串中的 a 匹配,这个时候,坏字符 a 在模式串中是存在的,模式串中下标是 0 的位置也是字符 a。这种情况下,可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。

    20211211001088

    当发生不匹配的时候,把坏字符对应的模式串中的字符下标记作 si。如果坏字符在模式串中存在,把这个坏字符在模式串中的下标记作 xi。如果不存在,把 xi 记作 -1。那模式串往后移动的位数就等于 si-xi。(注意,这里说的下标,都是字符在模式串的下标)。

    20211211001089

    这里要特别说明一点,如果坏字符在模式串里多处出现,那在计算 xi 的时候,选择最靠后的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。

    利用坏字符规则,BM 算法在最好情况下的时间复杂度非常低,是 \(O(\frac{n}{m})\)

    不过,单纯使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动位数,有可能是负数。所以,BM 算法还需要用到“好后缀规则”。

  2. 好后缀规则

    当模式串滑动到图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。

    20211211001090

    把已经匹配的 bc 叫作好后缀,记作{u}。拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u},那就将模式串滑动到子串{u}与主串中{u}对齐的位置。

    20211211001091

    如果在模式串中找不到另一个等于{u}的子串,就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况。

    20211211001092

    如果好后缀在模式串中不存在可匹配的子串,那在一步一步往后滑动模式串的过程中,只要主串中的{u}与模式串有重合,那肯定就无法完全匹配。但是当模式串滑动到前缀与主串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。

    20211211001093

    所以,针对这种情况,不仅要看好后缀在模式串中,是否有另一个匹配的子串,还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。

    所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab。从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是{v},然后将模式串滑动到如图所示的位置。

    20211211001094

    当模式串和主串中的某个字符不匹配的时候,可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这种处理方法还可以避免前面提到的,根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。

BM 算法代码实现

查找坏字符在模式串中出现的位置,如果拿坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能。

可以将模式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下标了。

关于这个散列表,实现一种最简单的情况,假设字符串的字符集不是很大,每个字符长度是 1 字节,用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。

20211211001095

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private static final int SIZE = 256; // 全局变量或成员变量
private void generateBC(char[] b, int m, int[] bc) {
  for (int i = 0; i < SIZE; ++i) {
    bc[i] = -1; // 初始化bc
  }
  for (int i = 0; i < m; ++i) {
    int ascii = (int)b[i]; // 计算b[i]的ASCII值
    bc[ascii] = i;
  }
}

掌握了坏字符规则之后,先把 BM 算法代码的大框架写好,先不考虑好后缀规则,仅用坏字符规则,并且不考虑 si-xi 计算得到的移动位数可能会出现负数的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public int bm(char[] a, int n, char[] b, int m) {
  int[] bc = new int[SIZE]; // 记录模式串中每个字符最后出现的位置
  generateBC(b, m, bc); // 构建坏字符哈希表
  int i = 0; // i表示主串与模式串对齐的第一个字符
  while (i <= n - m) {
    int j;
    for (j = m - 1; j >= 0; --j) { // 模式串从后往前匹配
      if (a[i+j] != b[j]) break; // 坏字符对应模式串中的下标是j
    }
    if (j < 0) {
      return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
    }
    // 这里等同于将模式串往后滑动j-bc[(int)a[i+j]]位
    i = i + (j - bc[(int)a[i+j]]); 
  }
  return -1;
}

20211211001096

至此,已经实现了包含坏字符规则的框架代码,只剩下往框架代码中填充好后缀规则了。

好后缀的处理规则中最核心的内容:

  • 在模式串中,查找跟好后缀匹配的另一个子串
  • 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串

在不考虑效率的情况下,这两个操作都可以用很“暴力”的匹配查找方式解决。

因为好后缀也是模式串本身的后缀子串,所以,可以在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。

如何表示模式串中不同的后缀子串呢?因为后缀子串的最后一个字符的位置是固定的,下标为 m-1,只需要记录长度就可以了。通过长度,可以确定一个唯一的后缀子串。

20211211001097

现在,要引入最关键的变量 suffix 数组。suffix 数组的下标 k,表示后缀子串的长度,下标对应的数组值存储的是,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值。

20211211001098

如果只记录刚刚定义的 suffix,实际上,只能处理规则的前半部分,也就是,在模式串中,查找跟好后缀匹配的另一个子串。所以,除了 suffix 数组之外,还需要另外一个 boolean 类型的 prefix 数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。

20211211001099

计算并填充这两个数组的值的过程非常巧妙。

拿下标从 0 到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是 k,就记录 suffix[k]=j(j 表示公共后缀子串的起始下标)。如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,就记录 prefix[k]=true。

20211211001100

suffix 数组和 prefix 数组计算过程的代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// b表示模式串,m表示长度,suffix,prefix数组事先申请好了
private void generateGS(char[] b, int m, int[] suffix, boolean[] prefix) {
  for (int i = 0; i < m; ++i) { // 初始化
    suffix[i] = -1;
    prefix[i] = false;
  }
  for (int i = 0; i < m - 1; ++i) { // b[0, i]
    int j = i;
    int k = 0; // 公共后缀子串长度
    while (j >= 0 && b[j] == b[m-1-k]) { // 与b[0, m-1]求公共后缀子串
      --j;
      ++k;
      suffix[k] = j+1; //j+1表示公共后缀子串在b[0, i]中的起始下标
    }
    if (j == -1) prefix[k] = true; //如果公共后缀子串也是模式串的前缀子串
  }
}

在模式串跟主串匹配的过程中,遇到不能匹配的字符时,如何根据好后缀规则,计算模式串往后滑动的位数?

假设好后缀的长度是 k。先拿好后缀,在 suffix 数组中查找其匹配的子串。如果 suffix[k]不等于 -1(-1 表示不存在匹配的子串),就将模式串往后移动 j-suffix[k]+1 位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k]等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。可以用下面这条规则来处理。

20211211001101

好后缀的后缀子串 b[r, m-1](其中,r 取值从 j+2 到 m-1)的长度 k=m-r,如果 prefix[k]等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样可以把模式串后移 r 位。

20211211001102

如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移 m 位。

20211211001103

BM 算法的完整版代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// b表示模式串,m表示长度,suffix,prefix数组事先申请好了
private void generateGS(char[] b, int m, int[] suffix, boolean[] prefix) {
  for (int i = 0; i < m; ++i) { // 初始化
    suffix[i] = -1;
    prefix[i] = false;
  }
  for (int i = 0; i < m - 1; ++i) { // b[0, i]
    int j = i;
    int k = 0; // 公共后缀子串长度
    while (j >= 0 && b[j] == b[m-1-k]) { // 与b[0, m-1]求公共后缀子串
      --j;
      ++k;
      suffix[k] = j+1; //j+1表示公共后缀子串在b[0, i]中的起始下标
    }
    if (j == -1) prefix[k] = true; //如果公共后缀子串也是模式串的前缀子串
  }
}

BM 算法的性能分析及优化

整个算法用到了额外的 3 个数组,其中 bc 数组的大小跟字符集大小有关,suffix 数组和 prefix 数组的大小跟模式串长度 m 有关。

如果处理字符集很大的字符串匹配问题,bc 数组对内存的消耗就会比较多。因为好后缀和坏字符规则是独立的,如果运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免 bc 数组过多的内存消耗。不过,单纯使用好后缀规则的 BM 算法效率就会下降一些了。

基于目前讲的这个版本,在极端情况下,预处理计算 suffix 数组、prefix 数组的性能会比较差。

实际上,BM 算法的时间复杂度分析起来是非常复杂,这篇论文 “A new proof of the linearity of the Boyer-Moore string searching algorithm” 证明了在最坏情况下,BM 算法的比较次数上限是 5n。这篇论文 “Tight bounds on the complexity of the Boyer-Moore string matching algorithm” 证明了在最坏情况下,BM 算法的比较次数上限是 3n。

KMP 算法

很多时候,提到字符串匹配,首先想到的就是 KMP 算法。

实际上,KMP 算法跟 BM 算法的本质是一样的。

KMP 算法基本原理

KMP 算法是根据三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字来命名的,算法的全称是 Knuth Morris Pratt 算法,简称为 KMP 算法。

KMP 算法的核心思想,跟上一节讲的 BM 算法非常相近。假设主串是 a,模式串是 b。在模式串与主串匹配的过程中,当遇到不可匹配的字符的时候,希望找到一些规律,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况。

在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀。

20211211001104

当遇到坏字符的时候,就把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。

20211211001105

KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?

只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v},长度是 k。把模式串一次性往后滑动 j-k 位,相当于,每次遇到坏字符的时候,就把 j 更新为 k,i 不变,然后继续比较。

20211211001106

为了表述起来方便,我把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串;对应的前缀子串,叫作最长可匹配前缀子串

20211211001107

类似 BM 算法中的 bc、suffix、prefix 数组,KMP 算法也可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。把这个数组定义为 next 数组,很多书中还给这个数组起了一个名字,叫失效函数(failure function)。

数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。

20211211001108

KMP 算法的框架代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// a, b分别是主串和模式串;n, m分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
  int[] next = getNexts(b, m);
  int j = 0;
  for (int i = 0; i < n; ++i) {
    while (j > 0 && a[i] != b[j]) { // 一直找到a[i]和b[j]
      j = next[j - 1] + 1;
    }
    if (a[i] == b[j]) {
      ++j;
    }
    if (j == m) { // 找到匹配模式串的了
      return i - m + 1;
    }
  }
  return -1;
}

失效函数计算方法

可以用非常笨的方法,比如要计算下面这个模式串 b 的 next[4],就把 b[0, 4]的所有后缀子串,从长到短找出来,依次看看,是否能跟模式串的前缀子串匹配。很显然,这个方法也可以计算得到 next 数组,但是效率非常低。

20211211001109

这里的处理非常有技巧,类似于动态规划。

按照下标从小到大,依次计算 next 数组的值。当要计算 next[i]的时候,前面的 next[0],next[1],……,next[i-1]应该已经计算出来了。利用已经计算出来的 next 值,是否可以快速推导出 next[i]的值呢?

如果 next[i-1]=k-1,也就是说,子串 b[0, k-1]是 b[0, i-1]的最长可匹配前缀子串。如果子串 b[0, k-1]的下一个字符 b[k],与 b[0, i-1]的下一个字符 b[i]匹配,那子串 b[0, k]就是 b[0, i]的最长可匹配前缀子串。所以,next[i]等于 k。但是,如果 b[0, k-1]的下一字符 b[k]跟 b[0, i-1]的下一个字符 b[i]不相等呢?这个时候就不能简单地通过 next[i-1]得到 next[i]了。这个时候该怎么办呢?

20211211001110

假设 b[0, i]的最长可匹配后缀子串是 b[r, i]。如果把最后一个字符去掉,那 b[r, i-1]肯定是 b[0, i-1]的可匹配后缀子串,但不一定是最长可匹配后缀子串。所以,既然 b[0, i-1]最长可匹配后缀子串对应的模式串的前缀子串的下一个字符并不等于 b[i],那么就可以考察 b[0, i-1]的次长可匹配后缀子串 b[x, i-1]对应的可匹配前缀子串 b[0, i-1-x]的下一个字符 b[i-x]是否等于 b[i]。如果等于,那 b[x, i]就是 b[0, i]的最长可匹配后缀子串。

20211211001111

次长可匹配后缀子串肯定被包含在最长可匹配后缀子串中,而最长可匹配后缀子串又对应最长可匹配前缀子串 b[0, y]。于是,查找 b[0, i-1]的次长可匹配后缀子串,这个问题就变成,查找 b[0, y]的最长匹配后缀子串的问题了。

20211211001112

按照这个思路,可以考察完所有的 b[0, i-1]的可匹配后缀子串 b[y, i-1],直到找到一个可匹配的后缀子串,它对应的前缀子串的下一个字符等于 b[i],那这个 b[y, i]就是 b[0, i]的最长可匹配后缀子串。

KMP 算法的代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// b表示模式串,m表示模式串的长度
private static int[] getNexts(char[] b, int m) {
  int[] next = new int[m];
  next[0] = -1;
  int k = -1;
  for (int i = 1; i < m; ++i) {
    while (k != -1 && b[k + 1] != b[i]) {
      k = next[k];
    }
    if (b[k + 1] == b[i]) {
      ++k;
    }
    next[i] = k;
  }
  return next;
}

KMP 算法复杂度分析

KMP 算法只需要一个额外的 next 数组,数组的大小跟模式串相同。所以空间复杂度是 \(O(m)\),m 表示模式串的长度。

KMP 算法包含两部分,第一部分是构建 next 数组,第二部分才是借助 next 数组匹配。

先来分析第一部分的时间复杂度。找一些参照变量,i 和 k。i 从 1 开始一直增加到 m,而 k 并不是每次 for 循环都会增加,所以,k 累积增加的值肯定小于 m。而 while 循环里 k=next[k],实际上是在减小 k 的值,k 累积都没有增加超过 m,所以 while 循环里面 k=next[k]总的执行次数也不可能超过 m。因此,next 数组计算的时间复杂度是 \(O(m)\)

再来分析第二部分的时间复杂度。i 从 0 循环增长到 n-1,j 的增长量不可能超过 i,所以肯定小于 n。而 while 循环中的那条语句 j=next[j-1]+1,不会让 j 增长的,那有没有可能让 j 不变呢?也没有可能。因为 next[j-1]的值肯定小于 j-1,所以 while 循环中的这条语句实际上也是在让 j 的值减少。而 j 总共增长的量都不会超过 n,那减少的量也不可能超过 n,所以 while 循环中的这条语句总的执行次数也不会超过 n,所以这部分的时间复杂度是 \(O(n)\)

所以,综合两部分的时间复杂度,KMP 算法的时间复杂度就是 \(O(m+n)\)

Trie树

Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。

尽管比较耗费内存,但是对内存不敏感或者内存消耗在接受范围内的情况下,在 Trie 树中做字符串匹配还是非常高效的,时间复杂度是 \(O(k)\),k 表示要匹配的字符串的长度。

20211211001113

其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。

Trie 树构造的分解过程:

20211211001114

20211211001115

当在 Trie 树中查找一个字符串的时候,比如查找字符串“her”,将要查找的字符串分割成单个的字符 h,e,r,然后从 Trie 树的根节点开始匹配。如图所示,绿色的路径就是在 Trie 树中匹配的路径。

20211211001116

Trie 树主要有两个操作,一个是将字符串集合构造成 Trie 树。这个过程分解开来的话,就是一个将字符串插入到 Trie 树的过程。另一个是在 Trie 树中查询一个字符串

Trie 树是一个多叉树。对于多叉树来说,借助散列表的思想,通过一个下标与字符一一映射的数组,来存储子节点的指针。

20211211001117

假设字符串中只有从 a 到 z 这 26 个小写字母,在数组中下标为 0 的位置,存储指向子节点 a 的指针,下标为 1 的位置存储指向子节点 b 的指针,以此类推,下标为 25 的位置,存储的是指向的子节点 z 的指针。如果某个字符的子节点不存在,就在对应的下标的位置存储 null。

1
2
3
4
class TrieNode {
  char data;
  TrieNode children[26];
}

当在 Trie 树中查找字符串的时候,就可以通过字符的 ASCII 码减去“a”的 ASCII 码,迅速找到匹配的子节点的指针。比如,d 的 ASCII 码减去 a 的 ASCII 码就是 3,那子节点 d 的指针就存储在数组中下标为 3 的位置中。

可以结合下面的代码帮助理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Trie {
  private TrieNode root = new TrieNode('/'); // 存储无意义字符

  // 往Trie树中插入一个字符串
  public void insert(char[] text) {
    TrieNode p = root;
    for (int i = 0; i < text.length; ++i) {
      int index = text[i] - 'a';
      if (p.children[index] == null) {
        TrieNode newNode = new TrieNode(text[i]);
        p.children[index] = newNode;
      }
      p = p.children[index];
    }
    p.isEndingChar = true;
  }

  // 在Trie树中查找一个字符串
  public boolean find(char[] pattern) {
    TrieNode p = root;
    for (int i = 0; i < pattern.length; ++i) {
      int index = pattern[i] - 'a';
      if (p.children[index] == null) {
        return false; // 不存在pattern
      }
      p = p.children[index];
    }
    if (p.isEndingChar == false) return false; // 不能完全匹配,只是前缀
    else return true; // 找到pattern
  }

  public class TrieNode {
    public char data;
    public TrieNode[] children = new TrieNode[26];
    public boolean isEndingChar = false;
    public TrieNode(char data) {
      this.data = data;
    }
  }
}

如果要在一组字符串中,频繁地查询某些字符串,用 Trie 树会非常高效。构建 Trie 树的过程,需要扫描所有的字符串,时间复杂度是 \(O(n)\)(n 表示所有字符串的长度和)。但是一旦构建成功之后,后续的查询操作会非常高效。

每次查询时,如果要查询的字符串长度是 k,那只需要比对大约 k 个节点,就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以说,构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。

Trie 树真的很耗内存吗?

Trie 树的本质是避免重复存储一组字符串的相同前缀子串,但是现在每个字符(对应一个节点)的存储远远大于 1 个字节。按照上面举的例子,数组长度为 26,每个元素是 8 字节,那每个节点就会额外需要 26*8=208 个字节。而且这还是只包含 26 个字符的情况。

如果字符串中不仅包含小写字母,还包含大写字母、数字、甚至是中文,那需要的存储空间就更多了。所以,也就是说,在某些情况下,Trie 树不一定会节省存储空间。在重复的前缀并不多的情况下,Trie 树不但不能节省内存,还有可能会浪费更多的内存。

当然,不可否认,Trie 树尽管有可能很浪费内存,但是确实非常高效。

为了解决这个内存问题,可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。可以使用的数据结构有有序数组、跳表、散列表、红黑树等。

假设用有序数组,数组中的指针按照所指向的子节点中的字符的大小顺序排列。查询的时候,可以通过二分查找的方法,快速查找到某个字符应该匹配的子节点的指针。但是,在往 Trie 树中插入一个字符串的时候,为了维护数组中数据的有序性,就会稍微慢了点。

实际上,Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。这样可以节省空间,但却增加了编码难度。

20211211001118

Trie 树与散列表、红黑树的比较

在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有极其严苛的要求。

  1. 字符串中包含的字符集不能太大。
  2. 要求字符串的前缀重合比较多,不然空间消耗会变大很多。
  3. 如果要用 Trie 树解决问题,那就要自己从零开始实现一个 Trie 树,还要保证没有 bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
  4. 通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。

综合这几点,针对在一组字符串中查找字符串的问题,在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。

AC自动机

多模式串匹配算法实现一个高性能的敏感词过滤系统。

基于单模式串和 Trie 树实现的敏感词过滤

对敏感词字典进行预处理,构建成 Trie 树结构。这个预处理的操作只需要做一次,如果敏感词字典动态更新了,比如删除、添加了一个敏感词,只需要动态更新一下 Trie 树就可以了。

当用户输入一个文本内容后,把用户输入的内容作为主串,从第一个字符(假设是字符 C)开始,在 Trie 树中匹配。当匹配到 Trie 树的叶子节点,或者中途遇到不匹配字符的时候,将主串的开始匹配位置后移一位,也就是从字符 C 的下一个字符开始,重新在 Trie 树中匹配。

基于 Trie 树的这种处理方法,有点类似单模式串匹配的 BF 算法。

经典的多模式串匹配算法:AC 自动机

AC 自动机算法,全称是 Aho-Corasick 算法。

其实,Trie 树跟 AC 自动机之间的关系,就像单串匹配中朴素的串匹配算法,跟 KMP 算法之间的关系一样,只不过前者针对的是多模式串而已。

所以,AC 自动机实际上就是在 Trie 树之上,加了类似 KMP 的 next 数组,只不过此处的 next 数组是构建在树上罢了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class AcNode {
  public char data; 
  public AcNode[] children = new AcNode[26]; // 字符集只包含a~z这26个字符
  public boolean isEndingChar = false; // 结尾字符为true
  public int length = -1; // 当isEndingChar=true时,记录模式串长度
  public AcNode fail; // 失败指针
  public AcNode(char data) {
    this.data = data;
  }
}

所以,AC 自动机的构建,包含两个操作:

  • 将多个模式串构建成 Trie 树
  • 在 Trie 树上构建失败指针(相当于 KMP 中的失效函数 next 数组)

构建好 Trie 树之后,如何在它之上构建失败指针?

举个例子。这里有 4 个模式串,分别是 c,bc,bcd,abcd;主串是 abcd。

20211211001119

Trie 树中的每一个节点都有一个失败指针,它的作用和构建过程,跟 KMP 算法中的 next 数组极其相似。

假设沿 Trie 树走到 p 节点,也就是下图中的紫色节点,那 p 的失败指针就是从 root 走到紫色节点形成的字符串 abc,跟所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串。

这里的最长可匹配后缀子串,稍微解释一下。字符串 abc 的后缀子串有两个 bc,c,拿它们与其他模式串匹配,如果某个后缀子串可以匹配某个模式串的前缀,就把这个后缀子串叫作可匹配后缀子串

从可匹配后缀子串中,找出最长的一个,就是刚刚讲到的最长可匹配后缀子串。将 p 节点的失败指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点,就是下图中箭头指向的节点。

202112110011120

计算每个节点的失败指针这个过程看起来有些复杂。其实,如果把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。

可以像 KMP 算法那样,当要求某个节点的失败指针的时候,通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说,可以逐层依次来求解每个节点的失败指针。所以,失败指针的构建过程,是一个按层遍历树的过程。

首先 root 的失败指针为 NULL,也就是指向自己。当已经求得某个节点 p 的失败指针之后,如何寻找它的子节点的失败指针呢?

假设节点 p 的失败指针指向节点 q,看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到。如果找到了节点 q 的一个子节点 qc,对应的字符跟节点 pc 对应的字符相同,则将节点 pc 的失败指针指向节点 qc。

202112110011121

如果节点 q 中没有子节点的字符等于节点 pc 包含的字符,则令 q=q->fail(fail 表示失败指针,这里有没有很像 KMP 算法里求 next 的过程?),继续上面的查找,直到 q 是 root 为止,如果还没有找到相同字符的子节点,就让节点 pc 的失败指针指向 root。

202112110011122

构建失败指针的代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void buildFailurePointer() {
  Queue<AcNode> queue = new LinkedList<>();
  root.fail = null;
  queue.add(root);
  while (!queue.isEmpty()) {
    AcNode p = queue.remove();
    for (int i = 0; i < 26; ++i) {
      AcNode pc = p.children[i];
      if (pc == null) continue;
      if (p == root) {
        pc.fail = root;
      } else {
        AcNode q = p.fail;
        while (q != null) {
          AcNode qc = q.children[pc.data - 'a'];
          if (qc != null) {
            pc.fail = qc;
            break;
          }
          q = q.fail;
        }
        if (q == null) {
          pc.fail = root;
        }
      }
      queue.add(pc);
    }
  }
}

通过按层来计算每个节点的子节点的失效指针,刚刚举的那个例子,最后构建完成之后的 AC 自动机就是下面这个样子:

202112110011123

AC 自动机到此就构建完成了。现在来看下,如何在 AC 自动机上匹配主串?

还是拿之前的例子来讲解。在匹配过程中,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。

  • 如果 p 指向的节点有一个等于 b[i]的子节点 x,就更新 p 指向 x,这个时候需要通过失败指针,检测一系列失败指针为结尾的路径是否是模式串。这一句不好理解,可以结合代码看。处理完之后,将 i 加一,继续这两个过程;
  • 如果 p 指向的节点没有等于 b[i]的子节点,那失败指针就派上用场了,让 p=p->fail,然后继续这 2 个过程。

关于匹配的这部分,这段代码输出的就是,在主串中每个可以匹配的模式串出现的位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public void match(char[] text) { // text是主串
  int n = text.length;
  AcNode p = root;
  for (int i = 0; i < n; ++i) {
    int idx = text[i] - 'a';
    while (p.children[idx] == null && p != root) {
      p = p.fail; // 失败指针发挥作用的地方
    }
    p = p.children[idx];
    if (p == null) p = root; // 如果没有匹配的,从root开始重新匹配
    AcNode tmp = p;
    while (tmp != root) { // 打印出可以匹配的模式串
      if (tmp.isEndingChar == true) {
        int pos = i-tmp.length+1;
        System.out.println("匹配起始下标" + pos + "; 长度" + tmp.length);
      }
      tmp = tmp.fail;
    }
  }
}

AC 自动机实现的敏感词过滤系统,是否比单模式串匹配方法更高效呢?

Trie 树构建的时间复杂度是 \(O(m*len)\),其中 len 表示敏感词的平均长度,m 表示敏感词的个数。那构建失败指针的时间复杂度是多少呢?这里给出一个不是很紧确的上界。

假设 Trie 树中总的节点个数是 k,每个节点构建失败指针的时候,(可以看下代码)最耗时的环节是 while 循环中的 q=q->fail,每运行一次这个语句,q 指向节点的深度都会减少 1,而树的高度最高也不会超过 len,所以每个节点构建失败指针的时间复杂度是 \(O(len)\)。整个失败指针的构建过程就是 \(O(k*len)\)

不过,AC 自动机的构建过程都是预先处理好的,构建好之后,并不会频繁地更新,所以不会影响到敏感词过滤的运行效率。

用 AC 自动机做匹配的时间复杂度是多少?

跟刚刚构建失败指针的分析类似,for 循环依次遍历主串中的每个字符,for 循环内部最耗时的部分也是 while 循环,而这一部分的时间复杂度也是 \(O(len)\),所以总的匹配的时间复杂度就是 \(O(n*len)\)。因为敏感词并不会很长,而且这个时间复杂度只是一个非常宽泛的上限,实际情况下,可能近似于 \(O(n)\),所以 AC 自动机做敏感词过滤,性能非常高。

实际上,因为失效指针可能大部分情况下都指向 root 节点,所以绝大部分情况下,在 AC 自动机上做匹配的效率要远高于刚刚计算出的比较宽泛的时间复杂度。只有在极端情况下,如图所示,AC 自动机的性能才会退化的跟 Trie 树一样。

202112110011124

算法思想

贪心算法(greedy algorithm)

贪心算法有很多经典的应用,比如霍夫曼编码(Huffman Coding)、Prim 和 Kruskal 最小生成树算法、还有 Dijkstra 单源最短路径算法

如何理解“贪心算法”?

假设有一个可以容纳 100kg 物品的背包,可以装各种物品。有以下 5 种豆子,每种豆子的总量和总价值都各不相同。为了让背包中所装物品的总价值最大,如何选择在背包中装哪些豆子?每种豆子又该装多少呢?

202112110011125

这个问题很简单,只要先算一算每个物品的单价,按照单价由高到低依次来装就好了。它本质上借助的就是贪心算法。

贪心算法解决问题的步骤

  • 第一步,当我们看到这类问题的时候,首先要联想到贪心算法:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
  • 第二步,我们尝试看下这个问题是否可以用贪心算法解决:每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。
  • 第三步,我们举几个例子看下贪心算法产生的结果是否是最优的。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。

实际上,用贪心算法解决问题的思路,并不总能给出最优解。

贪心算法实战分析

  1. 分糖果

    有 m 个糖果和 n 个孩子。现在要把糖果分给这些孩子吃,但是糖果少,孩子多(m<n),所以糖果只能分配给一部分孩子。

    可以把这个问题抽象成,从 n 个孩子中,抽取一部分孩子分配糖果,让满足的孩子的个数(期望值)是最大的。这个问题的限制值就是糖果个数 m。

    现在来看看如何用贪心算法来解决。对于一个孩子来说,如果小的糖果可以满足,就没必要用更大的糖果,这样更大的就可以留给其他对糖果大小需求更大的孩子。另一方面,对糖果大小需求小的孩子更容易被满足,所以,可以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对期望值的贡献是一样的。

    每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。

  2. 钱币找零

    假设有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数分别是 c1、c2、c5、c10、c20、c50、c100。现在要用这些钱来支付 K 元,最少要用多少张纸币呢?

    在贡献相同期望值(纸币数目)的情况下,希望多贡献点金额,这样就可以让纸币数更少,这就是一种贪心算法的解决思路。

  3. 区间覆盖

    假设有 n 个区间,区间的起始端点和结束端点分别是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?

    202112110011126

    这个问题的解决思路是这样的:假设这 n 个区间中最左端点是 lmin,最右端点是 rmax。这个问题就相当于,选择几个不相交的区间,从左到右将[lmin, rmax]覆盖上。按照起始端点从小到大的顺序对这 n 个区间排序。

    每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间。这实际上就是一种贪心的选择方法。

    202112110011127

用贪心算法实现霍夫曼编码

假设有一个包含 1000 个字符的文件,每个字符占 1 个 byte(1byte=8bits),存储这 1000 个字符就一共需要 8000bits,有没有更加节省空间的存储方式呢?

假设通过统计分析发现,这 1000 个字符中只包含 6 种不同字符,假设它们分别是 a、b、c、d、e、f。而 3 个二进制位(bit)就可以表示 8 个不同的字符,所以,为了尽量减少存储空间,每个字符用 3 个二进制位来表示。那存储这 1000 个字符只需要 3000bits 就可以了,比原来的存储方式节省了很多空间。不过,还有没有更加节省空间的存储方式呢?

霍夫曼编码就要登场了。霍夫曼编码是一种十分有效的编码方法,广泛用于数据压缩中,其压缩率通常在 20%~90% 之间。

霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。霍夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。根据贪心的思想,可以把出现频率比较多的字符,用稍微短一些的编码;出现频率比较少的字符,用稍微长一些的编码。

对于等长的编码来说,解压缩起来很简单。但是,霍夫曼编码是不等长的,每次应该读取 1 位还是 2 位、3 位等等来解压缩呢?这个问题就导致霍夫曼编码解压缩起来比较复杂。为了避免解压缩过程中的歧义,霍夫曼编码要求各个字符的编码之间,不会出现某个编码是另一个编码前缀的情况。

202112110011128

假设这 6 个字符出现的频率从高到低依次是 a、b、c、d、e、f。把它们编码下面这个样子,任何一个字符的编码都不是另一个的前缀,在解压缩的时候,每次会读取尽可能长的可解压的二进制串,所以在解压缩的时候也不会歧义。经过这种编码压缩之后,这 1000 个字符只需要 2100bits 就可以了。

202112110011129

尽管霍夫曼编码的思想并不难理解,但是如何根据字符出现频率的不同,给不同的字符进行不同长度的编码呢?这里的处理稍微有些技巧。

把每个字符看作一个节点,并且附带着把频率放到优先级队列中。从队列中取出频率最小的两个节点 A、B,然后新建一个节点 C,把频率设置为两个节点的频率之和,并把这个新节点 C 作为节点 A、B 的父节点。最后再把 C 节点放入到优先级队列中。重复这个过程,直到队列中没有数据。

202112110011130

现在,给每一条边加上画一个权值,指向左子节点的边我们统统标记为 0,指向右子节点的边,统统标记为 1,那从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。

202112110011131

小结

实际上,贪心算法适用的场景比较有限。这种算法思想更多的是指导设计基础算法。比如最小生成树算法、单源最短路径算法,这些算法都用到了贪心算法。

贪心算法的最难的一块是如何将要解决的问题抽象成贪心算法模型,只要这一步搞定之后,贪心算法的编码一般都很简单。贪心算法解决问题的正确性虽然很多时候都看起来是显而易见的,但是要严谨地证明算法能够得到最优解,并不是件容易的事。所以,很多时候,我们只需要多举几个例子,看一下贪心算法的解决方案是否真的能得到最优解就可以了。

分治算法(divide and conquer)

MapReduce 是 Google 大数据处理的三驾马车之一,另外两个是 GFS 和 Bigtable。它在倒排索引、PageRank 计算、网页分析等搜索引擎相关的技术中都有大量的应用。

实际上,MapReduce 框架只是一个任务调度器,底层依赖 GFS 来存储数据,依赖 Borg 管理机器。它从 GFS 中拿数据,交给 Borg 中的机器执行,并且时刻监控机器执行的进度,一旦出现机器宕机、进度卡壳等,就重新从 Borg 中调度一台机器执行。

尽管 MapReduce 的模型非常简单,但是在 Google 内部应用非常广泛。它除了可以用来处理这种数据与数据之间存在关系的任务,比如 MapReduce 的经典例子,统计文件中单词出现的频率。除此之外,它还可以用来处理数据与数据之间没有关系的任务,比如对网页分析、分词等,每个网页可以独立的分析、分词,而这两个网页之间并没有关系。网页几十亿、上百亿,如果单机处理,效率低下,我们就可以利用 MapReduce 提供的高可靠、高性能、高容错的并行计算框架,并行地处理这几十亿、上百亿的网页。

MapRedue 的本质就是分治算法。分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。

分治算法的递归实现中,每一层递归都会涉及这样三个操作:

  1. 分解:将原问题分解成一系列子问题
  2. 解决:递归地求解各个子问题,若子问题足够小,则直接求解
  3. 合并:将子问题的结果合并成原问题

分治算法能解决的问题,一般需要满足下面这几个条件:

  1. 原问题与分解成的小问题具有相同的模式
  2. 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别,等我们讲到动态规划的时候,会详细对比这两种算法
  3. 具有分解终止条件,也就是说,当问题足够小时,可以直接求解
  4. 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了

假设有 n 个数据,期望数据从小到大排列,那完全有序的数据的有序度就是 n(n-1)/2,逆序度等于 0;相反,倒序排列的数据的有序度就是 0,逆序度是 n(n-1)/2。除了这两种极端情况外,通过计算有序对或者逆序对的个数,来表示数据的有序度或逆序度。

202112110011132

现在的问题是,如何编程求出一组数据的有序对个数或者逆序对个数呢

因为有序对个数和逆序对个数的求解方式是类似的,所以你可以只思考逆序对个数的求解方法。

套用分治的思想来求数组 A 的逆序对个数。可以将数组分成前后两半 A1 和 A2,分别计算 A1 和 A2 的逆序对个数 K1 和 K2,然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1+K2+K3。

如何快速计算出两个子问题 A1 与 A2 之间的逆序对个数呢?

这里就要借助归并排序算法了。归并排序中有一个非常关键的操作,就是将两个有序的小数组,合并成一个有序的数组。实际上,在这个合并的过程中,就可以计算这两个小数组的逆序对个数了。每次合并操作,都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数了。

202112110011133

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private int num = 0; // 全局变量或者成员变量

public int count(int[] a, int n) {
  num = 0;
  mergeSortCounting(a, 0, n-1);
  return num;
}

private void mergeSortCounting(int[] a, int p, int r) {
  if (p >= r) return;
  int q = (p+r)/2;
  mergeSortCounting(a, p, q);
  mergeSortCounting(a, q+1, r);
  merge(a, p, q, r);
}

private void merge(int[] a, int p, int q, int r) {
  int i = p, j = q+1, k = 0;
  int[] tmp = new int[r-p+1];
  while (i<=q && j<=r) {
    if (a[i] <= a[j]) {
      tmp[k++] = a[i++];
    } else {
      num += (q-i+1); // 统计p-q之间,比a[j]大的元素个数
      tmp[k++] = a[j++];
    }
  }
  while (i <= q) { // 处理剩下的
    tmp[k++] = a[i++];
  }
  while (j <= r) { // 处理剩下的
    tmp[k++] = a[j++];
  }
  for (i = 0; i <= r-p; ++i) { // 从tmp拷贝回a
    a[p+i] = tmp[i];
  }
}

分治思想在海量数据处理中的应用

解决数据量大到内存装不下的问题,就可以利用分治的思想。可以将海量的数据集合根据某种方法,划分为几个小的数据集合,每个小的数据集合单独加载到内存来解决,然后再将小数据集合合并成大数据集合。实际上,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。

回溯算法(Back Tracking)

回溯算法很多时候都应用在“搜索”这类问题上。不过这里说的搜索,并不是狭义的指我们前面讲过的图的搜索算法,而是在一组可能的解中,搜索满足期望的解。

回溯的处理思想,有点类似枚举搜索。枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,把问题求解的过程分为多个阶段。每个阶段,都会面对一个岔路口,先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。

举一个经典的回溯例子,那就是八皇后问题。

有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。

202112110011134

把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。在放置的过程中,不停地检查当前放法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种放法,继续尝试。

回溯算法非常适合用递归代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int[] result = new int[8];//全局或成员变量,下标表示行,值表示queen存储在哪一列
public void cal8queens(int row) { // 调用方式:cal8queens(0);
  if (row == 8) { // 8个棋子都放置好了,打印结果
    printQueens(result);
    return; // 8行棋子都放好了,已经没法再往下递归了,所以就return
  }
  for (int column = 0; column < 8; ++column) { // 每一行都有8中放法
    if (isOk(row, column)) { // 有些放法不满足要求
      result[row] = column; // 第row行的棋子放到了column列
      cal8queens(row+1); // 考察下一行
    }
  }
}

private boolean isOk(int row, int column) {//判断row行column列放置是否合适
  int leftup = column - 1, rightup = column + 1;
  for (int i = row-1; i >= 0; --i) { // 逐行往上考察每一行
    if (result[i] == column) return false; // 第i行的column列有棋子吗?
    if (leftup >= 0) { // 考察左上对角线:第i行leftup列有棋子吗?
      if (result[i] == leftup) return false;
    }
    if (rightup < 8) { // 考察右上对角线:第i行rightup列有棋子吗?
      if (result[i] == rightup) return false;
    }
    --leftup; ++rightup;
  }
  return true;
}

private void printQueens(int[] result) { // 打印出一个二维矩阵
  for (int row = 0; row < 8; ++row) {
    for (int column = 0; column < 8; ++column) {
      if (result[row] == column) System.out.print("Q ");
      else System.out.print("* ");
    }
    System.out.println();
  }
  System.out.println();
}

两个回溯算法的经典应用

  1. 0-1 背包

    有一个背包,背包总的承载重量是 Wkg。现在有 n 个物品,每个物品的重量不等,并且不可分割。现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?

    这个背包问题,物品是不可分割的,要么装要么不装,所以叫 0-1 背包问题。显然,这个问题已经无法通过贪心算法来解决了。现在来看看,用回溯算法如何来解决。

    对于每个物品来说,都有两种选择,装进背包或者不装进背包。对于 n 个物品来说,总的装法就有 2^n 种,去掉总重量超过 Wkg 的,从剩下的装法中选择总重量最接近 Wkg 的。不过,如何才能不重复地穷举出这 2^n 种装法呢?

    这里就可以用回溯的方法。可以把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。

    这里还稍微用到了一点搜索剪枝的技巧,就是当发现已经选择的物品的重量超过 Wkg 之后,就停止继续探测剩下的物品。可以看具体的代码:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    public int maxW = Integer.MIN_VALUE; //存储背包中物品总重量的最大值
    // cw表示当前已经装进去的物品的重量和;i表示考察到哪个物品了;
    // w背包重量;items表示每个物品的重量;n表示物品个数
    // 假设背包可承受重量100,物品个数10,物品重量存储在数组a中,那可以这样调用函数:
    // f(0, 0, a, 10, 100)
    public void f(int i, int cw, int[] items, int n, int w) {
      if (cw == w || i == n) { // cw==w表示装满了;i==n表示已经考察完所有的物品
        if (cw > maxW) maxW = cw;
        return;
      }
      f(i+1, cw, items, n, w);
      if (cw + items[i] <= w) {// 已经超过可以背包承受的重量的时候,就不要再装了
        f(i+1,cw + items[i], items, n, w);
      }
    }
    
  2. 正则表达式

    实际上,正则表达式里最重要的一种算法思想就是回溯。

    假设正则表达式中只包含“ * ”和“ ? ”这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“ * ”匹配任意多个(大于等于 0 个)任意字符,“ ? ”匹配零个或者一个任意字符。基于以上背景假设,如何用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配?

    依次考察正则表达式中的每个字符,当是非通配符时,就直接跟文本的字符进行匹配,如果相同,则继续往下处理;如果不同,则回溯。

    如果遇到特殊字符的时候,就有多种处理方式了,也就是所谓的岔路口,比如“ * ”有多种匹配方案,可以匹配任意个文本串中的字符,先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class Pattern {
      private boolean matched = false;
      private char[] pattern; // 正则表达式
      private int plen; // 正则表达式长度
    
      public Pattern(char[] pattern, int plen) {
        this.pattern = pattern;
        this.plen = plen;
      }
    
      public boolean match(char[] text, int tlen) { // 文本串及长度
        matched = false;
        rmatch(0, 0, text, tlen);
        return matched;
      }
    
      private void rmatch(int ti, int pj, char[] text, int tlen) {
        if (matched) return; // 如果已经匹配了,就不要继续递归了
        if (pj == plen) { // 正则表达式到结尾了
          if (ti == tlen) matched = true; // 文本串也到结尾了
          return;
        }
        if (pattern[pj] == '*') { // *匹配任意个字符
          for (int k = 0; k <= tlen-ti; ++k) {
            rmatch(ti+k, pj+1, text, tlen);
          }
        } else if (pattern[pj] == '?') { // ?匹配0个或者1个字符
          rmatch(ti, pj+1, text, tlen);
          rmatch(ti+1, pj+1, text, tlen);
        } else if (ti < tlen && pattern[pj] == text[ti]) { // 纯字符匹配才行
          rmatch(ti+1, pj+1, text, tlen);
        }
      }
    }
    

回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。

尽管回溯算法的原理非常简单,但是却可以解决很多问题,比如深度优先搜索、八皇后、0-1 背包问题、图的着色、旅行商问题、数独、全排列、正则表达式匹配等等。

动态规划(Dynamic Programming)

动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。不过,它也是出了名的难学。它的主要学习难点跟递归类似,那就是,求解问题的过程不太符合人类常规的思维方式。

0-1 背包问题

对于一组不同重量、不可分割的物品,需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 回溯算法实现。注意:我把输入的变量都定义成了成员变量。
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {22463};  // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw) { // 调用f(0, 0)
  if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
    if (cw > maxW) maxW = cw;
    return;
  }
  f(i+1, cw); // 选择不装第i个物品
  if (cw + weight[i] <= w) {
    f(i+1,cw + weight[i]); // 选择装第i个物品
  }
}

假设背包的最大承载重量是 9。有 5 个不同的物品,每个物品的重量分别是 2,2,4,6,3。如果把这个例子的回溯求解过程,用递归树画出来,就是下面这个样子:

202112110011135

递归树中的每个节点表示一种状态,用(i, cw)来表示。其中,i 表示将要决策第几个物品是否装入背包,cw 表示当前背包中物品的总重量。比如,(2,2)表示将要决策第 2 个物品是否装入背包,在决策前,背包中物品的总重量是 2。

从递归树中,能发现,有些子问题的求解是重复的,比如图中 f(2, 2) 和 f(3,4) 都被重复计算了两次。我们可以借助递归那一节讲的“备忘录”的解决方式,记录已经计算好的 f(i, cw),当再次计算到重复的 f(i, cw) 的时候,可以直接从备忘录中取出来用,就不用再递归计算了,这样就可以避免冗余计算。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {22463};  // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; // 备忘录,默认值false
public void f(int i, int cw) { // 调用f(0, 0)
  if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
    if (cw > maxW) maxW = cw;
    return;
  }
  if (mem[i][cw]) return; // 重复状态
  mem[i][cw] = true; // 记录(i, cw)这个状态
  f(i+1, cw); // 选择不装第i个物品
  if (cw + weight[i] <= w) {
    f(i+1,cw + weight[i]); // 选择装第i个物品
  }
}

现在来看看动态规划是怎么做的。

把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。

把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。于是,我们就成功避免了每层状态个数的指数级增长。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。

第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。我们用 states[0][0]=true 和 states[0][2]=true 来表示这两种状态。

第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),2(0+2 or 2+0),4(2+2)。我们用 states[1][0]=true,states[1][2]=true,states[1][4]=true 来表示这三种状态。

以此类推,直到考察完所有的物品后,整个 states 状态数组就都计算好了。下图是整个计算的过程。图中 0 表示 false,1 表示 true。我们只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。

202112110011136

202112110011137

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
weight:物品重量n:物品个数w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
  boolean[][] states = new boolean[n][w+1]; // 默认值false
  states[0][0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (weight[0] <= w) {
    states[0][weight[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划状态转移
    for (int j = 0; j <= w; ++j) {// 不把第i个物品放入背包
      if (states[i-1][j] == true) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) {//把第i个物品放入背包
      if (states[i-1][j]==true) states[i][j+weight[i]] = true;
    }
  }
  for (int i = w; i >= 0; --i) { // 输出结果
    if (states[n-1][i] == true) return i;
  }
  return 0;
}

实际上,这就是一种用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。这也是动态规划这个名字的由来。

动态规划解决方案的时间复杂度非常好分析,耗时最多的部分就是代码中的两层 for 循环,所以时间复杂度是 \(O(n*w)\)。n 表示物品个数,w 表示背包可以承载的总重量。

尽管动态规划的执行效率比较高,但是就刚刚的代码实现来说,我们需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以,有时候会说,动态规划是一种空间换时间的解决思路。

降低空间消耗,只需要一个大小为 w+1 的一维数组就可以解决这个问题。动态规划状态转移的过程,都可以基于这个一维数组来操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public static int knapsack2(int[] items, int n, int w) {
  boolean[] states = new boolean[w+1]; // 默认值false
  states[0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (items[0] <= w) {
    states[items[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划
    for (int j = w-items[i]; j >= 0; --j) {//把第i个物品放入背包
      if (states[j]==true) states[j+items[i]] = true;
    }
  }
  for (int i = w; i >= 0; --i) { // 输出结果
    if (states[i] == true) return i;
  }
  return 0;
}

这里特别强调一下代码中的第 8 行,j 需要从大到小来处理。如果按照 j 从小到大处理的话,会出现 for 循环重复计算的问题。

0-1 背包问题升级版

刚刚讲的背包问题,只涉及背包重量和物品重量。现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?

这个问题依旧可以用回溯算法来解决。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private int maxV = Integer.MIN_VALUE; // 结果放到maxV中
private int[] items = {22463};  // 物品的重量
private int[] value = {34896}; // 物品的价值
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw, int cv) { // 调用f(0, 0, 0)
  if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
    if (cv > maxV) maxV = cv;
    return;
  }
  f(i+1, cw, cv); // 选择不装第i个物品
  if (cw + weight[i] <= w) {
    f(i+1,cw+weight[i], cv+value[i]); // 选择装第i个物品
  }
}

针对上面的代码,我们还是照例画出递归树。在递归树中,每个节点表示一个状态。现在需要 3 个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。

202112110011138

我们发现,在递归树中,有几个节点的 i 和 cw 是完全相同的,比如 f(2,2,4) 和 f(2,2,3)。在背包中物品总重量一样的情况下,f(2,2,4) 这种状态对应的物品总价值更大,我们可以舍弃 f(2,2,3) 这种状态,只需要沿着 f(2,2,4) 这条决策路线继续往下决策就可以。

也就是说,对于 (i, cw) 相同的不同状态,我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。

还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值不再是 boolean 类型的了,而是当前状态对应的最大总价值。我们把每一层中 (i, cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static int knapsack3(int[] weight, int[] value, int n, int w) {
  int[][] states = new int[n][w+1];
  for (int i = 0; i < n; ++i) { // 初始化states
    for (int j = 0; j < w+1; ++j) {
      states[i][j] = -1;
    }
  }
  states[0][0] = 0;
  if (weight[0] <= w) {
    states[0][weight[0]] = value[0];
  }
  for (int i = 1; i < n; ++i) { //动态规划,状态转移
    for (int j = 0; j <= w; ++j) { // 不选择第i个物品
      if (states[i-1][j] >= 0) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) { // 选择第i个物品
      if (states[i-1][j] >= 0) {
        int v = states[i-1][j] + value[i];
        if (v > states[i][j+weight[i]]) {
          states[i][j+weight[i]] = v;
        }
      }
    }
  }
  // 找出最大值
  int maxvalue = -1;
  for (int j = 0; j <= w; ++j) {
    if (states[n-1][j] > maxvalue) maxvalue = states[n-1][j];
  }
  return maxvalue;
}

这个问题的时间复杂度是 \(O(n * w)\),空间复杂度也是 \(O(n * w)\)。跟上一个例子类似,空间复杂度也是可以优化的。

“一个模型三个特征”理论讲解

一个模型”:动态规划适合解决的问题的模型。把这个模型定义为“多阶段决策最优解模型”。

三个特征”:最优子结构无后效性重复子问题

  1. 最优子结构

    最优子结构指的是,问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出问题的最优解。如果把最优子结构,对应到前面定义的动态规划问题模型上,也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来。

  2. 无后效性

    无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。

    第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常“宽松”的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性。

  3. 重复子问题

    用一句话概括一下,那就是,不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。

“一个模型三个特征”实例剖析

假设有一个 n 乘以 n 的矩阵 w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?

202112110011139

从 (0, 0) 走到 (n-1, n-1),总共要走 2(n-1) 步,也就对应着 2(n-1) 个阶段。每个阶段都有向右走或者向下走两种决策,并且每个阶段都会对应一个状态集合。

我们把状态定义为 min_dist(i, j),其中 i 表示行,j 表示列。min_dist 表达式的值表示从 (0, 0) 到达 (i, j) 的最短路径长度。所以,这个问题是一个多阶段决策最优解问题,符合动态规划的模型。

202112110011140

再来看,这个问题是否符合“三个特征”?

我们可以用回溯算法来解决这个问题。如果写一下代码,画一下递归树,就会发现,递归树中有重复的节点。重复的节点表示,从左上角到节点对应的位置,有多种路线,这也能说明这个问题中存在重复子问题。

202112110011141

如果我们走到 (i, j) 这个位置,只能通过 (i-1, j),(i, j-1) 这两个位置移动过来,也就是说,想要计算 (i, j) 位置对应的状态,只需要关心 (i-1, j),(i, j-1) 两个位置对应的状态,并不关心棋子是通过什么样的路线到达这两个位置的。而且,仅仅允许往下和往右移动,不允许后退,所以,前面阶段的状态确定之后,不会被后面阶段的决策所改变,所以,这个问题符合“无后效性”这一特征。

刚刚定义状态的时候,我们把从起始位置 (0, 0) 到 (i, j) 的最小路径,记作 min_dist(i, j)。因为只能往右或往下移动,所以,只有可能从 (i, j-1) 或者 (i-1, j) 两个位置到达 (i, j)。也就是说,到达 (i, j) 的最短路径要么经过 (i, j-1),要么经过 (i-1, j),而且到达 (i, j) 的最短路径肯定包含到达这两个位置的最短路径之一。换句话说就是,min_dist(i, j) 可以通过 min_dist(i, j-1) 和 min_dist(i-1, j) 两个状态推导出来。这就说明,这个问题符合“最优子结构”。

1
min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
两种动态规划解题思路总结

解决动态规划问题,一般有两种思路。

  1. 状态转移表法

    一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。所以,当拿到问题的时候,可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,很容易可以看出来,是否存在重复子问题,以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。

    找到重复子问题之后,接下来,有两种处理思路,第一种是直接用回溯加“备忘录”的方法,来避免重复子问题。从执行效率上来讲,这跟动态规划的解决思路没有差别。第二种是使用动态规划的解决方法,状态转移表法。重点来看状态转移表法是如何工作的。

    先画出一个状态表。状态表一般都是二维的,所以可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,将这个递推填表的过程,翻译成代码,就是动态规划代码了。

    尽管大部分状态表都是二维的,但是如果问题的状态比较复杂,需要很多变量来表示,那对应的状态表可能就是高维的,比如三维、四维。那这个时候,我们就不适合用状态转移表法来解决了。一方面是因为高维状态转移表不好画图表示,另一方面是因为人脑确实很不擅长思考高维的东西。

    从起点到终点,有很多种不同的走法。我们可以穷举所有走法,然后对比找出一个最短走法。不过如何才能无重复又不遗漏地穷举出所有走法呢?我们可以用回溯算法这个比较有规律的穷举算法。

    回溯算法的代码实现如下所示。

    回溯算法的代码实现
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    private int minDist = Integer.MAX_VALUE; // 全局变量或者成员变量
    // 调用方式:minDistBacktracing(0, 0, 0, w, n);
    public void minDistBT(int i, int j, int dist, int[][] w, int n) {
      // 到达了n-1, n-1这个位置了,这里看着有点奇怪哈,你自己举个例子看下
      if (i == n && j == n) {
        if (dist < minDist) minDist = dist;
        return;
      }
      if (i < n) { // 往下走,更新i=i+1, j=j
        minDistBT(i + 1, j, dist+w[i][j], w, n);
      }
      if (j < n) { // 往右走,更新i=i, j=j+1
        minDistBT(i, j+1, dist+w[i][j], w, n);
      }
    }
    

    接下来,画出递归树,以此来寻找重复子问题。在递归树中,一个状态(也就是一个节点)包含三个变量 (i, j, dist),其中 i,j 分别表示行和列,dist 表示从起点到达 (i, j) 的路径长度。从图中看出,尽管 (i, j, dist) 不存在重复的,但是 (i, j) 重复的有很多。对于 (i, j) 重复的节点,我们只需要选择 dist 最小的节点,继续递归求解,其他节点就可以舍弃了。

    202112110011142

    既然存在重复子问题,就可以尝试看下,是否可以用动态规划来解决呢?

    画出一个二维状态表,表中的行、列表示棋子所在的位置,表中的数值表示从起点到这个位置的最短路径。我们按照决策过程,通过不断状态递推演进,将状态表填好。为了方便代码实现,按行来进行依次填充。

    202112110011143

    202112110011144

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public int minDistDP(int[][] matrix, int n) {
      int[][] states = new int[n][n];
      int sum = 0;
      for (int j = 0; j < n; ++j) { // 初始化states的第一行数据
        sum += matrix[0][j];
        states[0][j] = sum;
      }
      sum = 0;
      for (int i = 0; i < n; ++i) { // 初始化states的第一列数据
        sum += matrix[i][0];
        states[i][0] = sum;
      }
      for (int i = 1; i < n; ++i) {
        for (int j = 1; j < n; ++j) {
          states[i][j] = 
                matrix[i][j] + Math.min(states[i][j-1], states[i-1][j]);
        }
      }
      return states[n-1][n-1];
    }
    

    状态转移表法解题思路大致可以概括为,回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码

  2. 状态转移方程法

    状态转移方程法有点类似递归的解题思路。我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。有了状态转移方程,代码实现就非常简单了。一般情况下,有两种代码实现方法,一种是递归加“备忘录”,另一种是迭代递推

    1
    min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
    

    这里强调一下,状态转移方程是解决动态规划的关键。如果我们能写出状态转移方程,那动态规划问题基本上就解决一大半了,而翻译成代码非常简单。但是很多动态规划问题的状态本身就不好定义,状态转移方程也就更不好想到。

    下面用递归加“备忘录”的方式,将状态转移方程翻译成来代码。对于另一种实现方式,跟状态转移表法的代码实现是一样的,只是思路不同。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    private int[][] matrix = 
             {{1359}, {2134}{5267}{6843}};
    private int n = 4;
    private int[][] mem = new int[4][4];
    public int minDist(int i, int j) { // 调用minDist(n-1, n-1);
      if (i == 0 && j == 0) return matrix[0][0];
      if (mem[i][j] > 0) return mem[i][j];
      int minLeft = Integer.MAX_VALUE;
      if (j-1 >= 0) {
        minLeft = minDist(i, j-1);
      }
      int minUp = Integer.MAX_VALUE;
      if (i-1 >= 0) {
        minUp = minDist(i-1, j);
      }
    
      int currMinDist = matrix[i][j] + Math.min(minLeft, minUp);
      mem[i][j] = currMinDist;
      return currMinDist;
    }
    

    状态转移方程法的大致思路可以概括为,找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码

如何量化两个字符串的相似度?

如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(Edit Distance)。

顾名思义,编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是 0。

根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有莱文斯坦距离(Levenshtein distance)和最长公共子串长度(Longest common substring length)。其中,莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。

而且,莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。

关于这两个计算方法,举个例子说明一下。这里面,两个字符串 mitcmu 和 mtacnu 的莱文斯坦距离是 3,最长公共子串长度是 4。

202112110011145

如何编程计算莱文斯坦距离?

这个问题是求把一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,需要依次考察一个字符串中的每个字符,跟另一个字符串中的字符是否匹配,匹配的话如何处理,不匹配的话又如何处理。所以,这个问题符合多阶段决策最优解模型

回溯是一个递归处理的过程。如果 a[i]与 b[j]匹配,递归考察 a[i+1]和 b[j+1]。如果 a[i]与 b[j]不匹配,有多种处理方式可选:

  • 可以删除 a[i],然后递归考察 a[i+1]和 b[j]
  • 可以删除 b[j],然后递归考察 a[i]和 b[j+1]
  • 可以在 a[i]前面添加一个跟 b[j]相同的字符,然后递归考察 a[i]和 b[j+1]
  • 可以在 b[j]前面添加一个跟 a[i]相同的字符,然后递归考察 a[i+1]和 b[j]
  • 可以将 a[i]替换成 b[j],或者将 b[j]替换成 a[i],然后递归考察 a[i+1]和 b[j+1]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
private char[] a = "mitcmu".toCharArray();
private char[] b = "mtacnu".toCharArray();
private int n = 6;
private int m = 6;
private int minDist = Integer.MAX_VALUE; // 存储结果
// 调用方式 lwstBT(0, 0, 0);
public lwstBT(int i, int j, int edist) {
  if (i == n || j == m) {
    if (i < n) edist += (n-i);
    if (j < m) edist += (m - j);
    if (edist < minDist) minDist = edist;
    return;
  }
  if (a[i] == b[j]) { // 两个字符匹配
    lwstBT(i+1, j+1, edist);
  } else { // 两个字符不匹配
    lwstBT(i + 1, j, edist + 1); // 删除a[i]或者b[j]前添加一个字符
    lwstBT(i, j + 1, edist + 1); // 删除b[j]或者a[i]前添加一个字符
    lwstBT(i + 1, j + 1, edist + 1); // 将a[i]和b[j]替换为相同字符
  }
}

根据回溯算法的代码实现,可以画出递归树,看是否存在重复子问题。如果存在重复子问题,那就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。

202112110011146

在递归树中,每个节点代表一个状态,状态包含三个变量 (i, j, edist),其中,edist 表示处理到 a[i]和 b[j]时,已经执行的编辑操作的次数。

在递归树中,(i, j) 两个变量重复的节点很多,比如 (3, 2) 和 (2, 3)。对于 (i, j) 相同的节点,只需要保留 edist 最小的,继续递归处理就可以了,剩下的节点都可以舍弃。所以,状态就从 (i, j, edist) 变成了 (i, j, min_edist),其中 min_edist 表示处理到 a[i]和 b[j],已经执行的最少编辑次数。

这个问题跟前面的动态规划例子非常相似,但这个问题,状态 (i, j) 可能从 (i-1, j),(i, j-1),(i-1, j-1) 三个状态中的任意一个转移过来。

202112110011147

基于刚刚的分析,可以尝试着将把状态转移的过程,用公式写出来。这就是状态转移方程。

1
2
3
4
5
6
7
如果a[i]!=b[j]那么min_edist(i, j)就等于
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1)

如果a[i]==b[j]那么min_edist(i, j)就等于
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1min_edist(i-1,j-1))

其中min表示求三数中的最小值     

了解了状态与状态之间的递推关系,我们画出一个二维的状态表,按行依次来填充状态表中的每个值。

202112110011148

现在既有状态转移方程,又理清了完整的填表过程,代码实现就非常简单了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public int lwstDP(char[] a, int n, char[] b, int m) {
  int[][] minDist = new int[n][m];
  for (int j = 0; j < m; ++j) { // 初始化第0行:a[0..0]与b[0..j]的编辑距离
    if (a[0] == b[j]) minDist[0][j] = j;
    else if (j != 0) minDist[0][j] = minDist[0][j-1]+1;
    else minDist[0][j] = 1;
  }
  for (int i = 0; i < n; ++i) { // 初始化第0列:a[0..i]与b[0..0]的编辑距离
    if (a[i] == b[0]) minDist[i][0] = i;
    else if (i != 0) minDist[i][0] = minDist[i-1][0]+1;
    else minDist[i][0] = 1;
  }
  for (int i = 1; i < n; ++i) { // 按行填表
    for (int j = 1; j < m; ++j) {
      if (a[i] == b[j]) minDist[i][j] = min(
          minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]);
      else minDist[i][j] = min(
          minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]+1);
    }
  }
  return minDist[n-1][m-1];
}

private int min(int x, int y, int z) {
  int minv = Integer.MAX_VALUE;
  if (x < minv) minv = x;
  if (y < minv) minv = y;
  if (z < minv) minv = z;
  return minv;
}

当我们拿到一个问题的时候,可以先不思考,计算机会如何实现这个问题,而是单纯考虑“人脑”会如何去解决这个问题

如何编程计算最长公共子串长度?

最长公共子串作为编辑距离中的一种,只允许增加、删除字符两种编辑操作。从名字上,可能觉得它看起来跟编辑距离没什么关系。实际上,从本质上来说,它表征的也是两个字符串之间的相似程度。

这个问题的解决思路,跟莱文斯坦距离的解决思路非常相似,也可以用动态规划解决。所以,针对这个问题,直接定义状态,然后写状态转移方程。

每个状态还是包括三个变量 (i, j, max_lcs),max_lcs 表示 a[0...i]和 b[0...j]的最长公共子串长度。那 (i, j) 这个状态都是由哪些状态转移过来的呢?

先来看回溯的处理思路。从 a[0]和 b[0]开始,依次考察两个字符串中的字符是否匹配。

  • 如果 a[i]与 b[j]互相匹配,我们将最大公共子串长度加一,并且继续考察 a[i+1]和 b[j+1]
  • 如果 a[i]与 b[j]不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线
  • 删除 a[i],或者在 b[j]前面加上一个字符 a[i],然后继续考察 a[i+1]和 b[j]
  • 删除 b[j],或者在 a[i]前面加上一个字符 b[j],然后继续考察 a[i]和 b[j+1]

反过来也就是说,如果要求 a[0...i]和 b[0...j]的最长公共长度 max_lcs(i, j),只有可能通过下面三个状态转移过来:

  • (i-1, j-1, max_lcs),其中 max_lcs 表示 a[0...i-1]和 b[0...j-1]的最长公共子串长度;
  • (i-1, j, max_lcs),其中 max_lcs 表示 a[0...i-1]和 b[0...j]的最长公共子串长度
  • (i, j-1, max_lcs),其中 max_lcs 表示 a[0...i]和 b[0...j-1]的最长公共子串长度

把这个转移过程,用状态转移方程写出来,就是下面这个样子:

1
2
3
4
5
6
7
如果a[i]==b[j]那么max_lcs(i, j)就等于
max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1))

如果a[i]!=b[j]那么max_lcs(i, j)就等于
max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1))

其中max表示求三数中的最大值

有了状态转移方程,代码实现就简单多了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public int lcs(char[] a, int n, char[] b, int m) {
  int[][] maxlcs = new int[n][m];
  for (int j = 0; j < m; ++j) {//初始化第0行:a[0..0]与b[0..j]的maxlcs
    if (a[0] == b[j]) maxlcs[0][j] = 1;
    else if (j != 0) maxlcs[0][j] = maxlcs[0][j-1];
    else maxlcs[0][j] = 0;
  }
  for (int i = 0; i < n; ++i) {//初始化第0列:a[0..i]与b[0..0]的maxlcs
    if (a[i] == b[0]) maxlcs[i][0] = 1;
    else if (i != 0) maxlcs[i][0] = maxlcs[i-1][0];
    else maxlcs[i][0] = 0;
  }
  for (int i = 1; i < n; ++i) { // 填表
    for (int j = 1; j < m; ++j) {
      if (a[i] == b[j]) maxlcs[i][j] = max(
          maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]+1);
      else maxlcs[i][j] = max(
          maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]);
    }
  }
  return maxlcs[n-1][m-1];
}

private int max(int x, int y, int z) {
  int maxv = Integer.MIN_VALUE;
  if (x > maxv) maxv = x;
  if (y > maxv) maxv = y;
  if (z > maxv) maxv = z;
  return maxv;
}

四种算法思想比较分析

如果将这四种算法思想分一下类,那贪心、回溯、动态规划可以归为一类,而分治单独可以作为一类,因为它跟其他三个都不大一样。前三个算法解决问题的模型,都可以抽象成那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。

回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。

尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。

贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。其中,最优子结构、无后效性跟动态规划中的无异。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。

拓扑排序

参考资料

  1. 课程平台:极客时间,课程链接:https://time.geekbang.org/column/intro/100017301
回到页面顶部