《算法第四版》学习笔记

文章目录

第1章 基础

编写一段计算机程序一般都是实现一种已有的方法来解决某个问题。这种方法大多和使用的编程语言无关——它适用于各种计算机以及编程语言。是这种方法而非计算机程序本身描述了解决问题的步骤。在计算机科学领域中,我们用算法这个词来描述一种有限、确定、有效的并适合用计算机程序来实现的解决问题的方法。算法是计算机科学的基础,是这个领域研究的核心。

学习算法的主要原因是它们能节约非常多的资源,甚至能够让我们完成一些本不可能完成的任务。

1.1 基础编程模型

编写递归代码时最重要的三个点:

  • 递归总有一个最简单的情况——方法的第一条语句总是一个包含return的条件语句。
  • 递归调用总是去尝试解决一个规模更小的问题,这样递归才能收敛到最简单的情况。
  • 递归调用的父问题和尝试解决的子问题之间不应该有交集。

默认情况下,命令行参数、标准输入和标准输出是和应用程序绑定的,而应用程序是由能够接受命令输入的操作系统或是开发环境所支持。

IMG_9187

自动将一个原始类型转换为一个封装类型被称为自动装箱,自动将一个封装类型转换为一个原始数据类型被称为自动拆箱。

1.3 背包、队列和栈

IMG_9327 IMG_9328

背包是一种不支持从中删除元素的集合数据类型——它的目的就是帮助用例收集元素并迭代遍历所有收集到的元素。迭代的顺序不确定且与用例无关。

IMG_9321

队列是一种基于**先进先出(FIFO)**策略的集合类型。

IMG_9322

栈是一种基于**后进先出(LIFO)**策略的集合类型。

IMG_9323

1.3.2 算术表达式求值

IMG_9326

1.3.3 链表

链表是一种递归的数据结构,它或者为空(null),或者是含有泛型元素的结点和指向另一条链表的引用。

IMG_9329

添加和插入首结点

IMG_9332 IMG_9333

实现任意插入和删除操作的标准解决方案是使用双向链表

遍历:for (Node x = first; x != null; x = x.next)

队列的实现可以实现链表结构。优点:

  • 它可以处理任意类型的数据;
  • 所需的空间总是和集合大小成正比;
  • 操作所需的时间总是和集合的大小无关。
IMG_9334 IMG_9335

在结构化存储数据集时,链表是数组的一种重要的替代方式

两种表示对象集合的方式:数组(顺序存储)链表(链式存储)

IMG_9336

1.4 算法分析

IMG_9361

执行最频繁的指令(程序的内循环)决定了程序执行的总时间。

IMG_9362

对于大多数程序,得到其运行时间的数学模型所需的步骤如下:

  1. 确定输入模型,定义问题的规模;
  2. 识别内循环;
  3. 根据内循环中的操作确定成本模型;
  4. 对于给定的输入,判断这些操作的执行频率。
IMG_9363 IMG_9364

在编程领域中,最常见的错误或许就是过于关注程序的性能。首要任务是写出解析正确地代码。第二常见的错误或许是完全忽略了程序的性能。几行优秀的代码有时能够给你带来巨大的收益。

1.5 案例研究:union-find算法

Union-Find 算法,也被称为 Disjoint Set Union (DSU),是一种处理集合的合并和查询问题的数据结构和算法。这种数据结构可以高效地解决一些图论问题。

Union-Find 算法包括两个主要操作:

  1. Union:这个操作将两个集合合并为一个集合。具体来说,如果我们有两个元素,我们可以使用 union 操作将它们所在的集合合并。
  2. Find:这个操作确定一个元素属于哪个集合。具体来说,如果我们有一个元素,我们可以使用 find 操作来查找该元素所在的集合的 “代表” 或 “领导”。

Union-Find 算法在许多领域都有应用,包括:

  1. Kruskal’s 算法:这是一种求解最小生成树问题的算法,它依赖于 Union-Find 数据结构来检查添加新的边是否会形成环。
  2. 连通性问题:在网络或图中,我们经常需要快速地检查两个节点是否连接。Union-Find 可以帮助我们有效地解决这类问题。
  3. 并查集也可以应用于一些动态连通性问题,其中连接和查询操作可以在任何时候发生。
  4. 图像分割和计算等价类:在图像处理和模式识别中,Union-Find 可以用来检测和标记连接的组件。

1.5.2 实现

quick-find算法

缺点:quick-find算法一般无法处理大型问题,因为对于每一对输入union()都需要扫描整个id[]数组。

IMG_9365
quick-union算法

quick-union算法是一种用来解决并查集问题的算法,它的实现是通过构建一个树形结构来表示集合之间的联系。该算法的基本思想是将每个元素看成一个节点,在初始状态下,每个节点都是自己的父节点,表示它们各自为一个集合。在合并两个集合的过程中,我们只需要将其中一个集合的根节点指向另一个集合的根节点,从而将它们合并为一个集合。判断两个元素是否属于同一个集合时,只需要看它们的根节点是否相同即可。由于每次合并操作的复杂度都取决于树的高度,这种算法的时间复杂度通常为 O ( N l o g N ) O(NlogN) O(NlogN),其中 N N N是元素的个数。

IMG_9366 IMG_9367 IMG_9368
加权quick-union算法
IMG_9369 IMG_9370 IMG_9371 IMG_9372

第2章 排序

排序就是将一组对象按照某种逻辑顺序重新排列的过程。

2.1 初始排序算法

2.1.2 选择排序

思路:找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此反复,直到将整个数组排序。

选择排序的运行时间与输入无关

在这里插入图片描述

2.1.3 插入排序

与选择排序不同,插入排序所需的时间取决于输入中元素的初始顺序。

在这里插入图片描述

iShot_2023-05-22_08.34.01

插入排序对于部分有序的数组十分高效,也很适合小规模数组。

Visualization of selection sort and insertion sort

2.1.6 希尔排序

希尔排序是一种基于插入排序的快速排序算法。希尔排序的基本思想是将待排序的数组元素按照某种增量序列进行分组,对每组使用插入排序算法进行排序;然后,每次减小增量,再按组排序,直至增量为1,此时整个数组被看作是一组,再对整体进行一次插入排序。

希尔排序更高效的原因是它权衡了子数组的规模和有序性。透彻理解希尔排序的性能至今仍然是一项挑战。

iShot_2023-05-22_08.50.40 IMG_9392

2.2 归并排序

归并排序的优点是它能够保证将任意长度为N的数组排序所需时间和 N l o g N Nlog N NlogN 成正比。主要缺点是它所需的额外空间和 N N N 成正比。

归并排序分为自顶向下自底向上两种。当数组长度为2的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。自底向上的归并排序比较适合用链表组织的数据

递归实现的归并排序是算法设计中分治思想的典型应用。将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。

iShot_2023-05-23_06.59.05 Mergesort Bottom-up mergesort

2.3 快速排序

快速排序流行的原因是它实现简单、适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多。快速排序的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和 N l o g N Nlog N NlogN成正比。

快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。归并排序递归调用发生在处理整个数组之前;快速排序递归调用发生在处理整个数组之后。

三向切分的快速排序适合有大量重复元素的数组。

iShot_2023-05-24_07.19.18 Quicksort trace

经过精心调优的快速排序在绝大多数计算机上的绝大多数应用中都会比其他基于比较的排序算法更快。

2.4 优先队列

删除最大元素和插入元素,这种数据类型叫做优先队列

优先队列在很多算法和数据结构中都有应用,以下是一些常见的应用场景:

  1. Dijkstra算法:Dijkstra算法是一种单源最短路径算法,使用了优先队列来维护待处理的节点集合,每次从集合中选取距离最短的节点进行处理。
  2. Prim算法:Prim算法是一种最小生成树算法,使用了优先队列来维护待处理的边集合,每次从集合中选取权值最小的边进行处理。
  3. Huffman编码:Huffman编码是一种无损数据压缩算法,使用优先队列来构建哈夫曼树,每次从队列中选取权值最小的两个节点进行合并。
  4. 模拟系统:优先队列可以用于模拟一些系统,比如操作系统中的进程调度、网络中的数据包处理、事件驱动的仿真等。
  5. 贪心算法:一些贪心算法中,需要维护一个较大或较小的元素集合,这可以通过使用优先队列来实现。
IMG_9400

二叉堆

堆(Heap)是一种特殊的树形数据结构,满足堆的特性:每个节点的值都会大于或等于(或小于或等于)其子节点的值,且每个节点的左右子树也分别是一个堆。堆的两种主要类型是最大堆和最小堆。

  1. 最大堆:每个节点的值都大于或等于其子节点的值。在最大堆中,根节点包含的是最大值。
  2. 最小堆:每个节点的值都小于或等于其子节点的值。在最小堆中,根节点包含的是最小值。

堆的主要操作包括:

  1. 插入:在堆中插入新元素。这需要通过一种称为“上浮(swim)”的过程来保持堆的特性。
  2. 删除:从堆中删除元素,通常是删除根节点的元素(最大堆中的最大值或最小堆中的最小值)。这需要通过一种称为“下沉(sink)”的过程来保持堆的特性。
  3. 堆化:将一个无序的数组转化为一个堆。

堆在很多领域都有应用,如优先队列、堆排序以及图形算法(如 Dijkstra’s algorithm 和 Prim’s algorithm)等。

完全二叉树只用数组而不需要指针就可以表示。具体方法是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点分别在位置4、5、6和7,以此类推。

定义:二叉堆是一组能够用堆有序的完全二叉树的元素,并在数组中按照层级储存。

在一个堆中,节点的位置是固定的。当我们用数组来表示堆时,可以根据节点在数组中的索引,找到其父节点和子节点。例如,如果一个节点的索引是 i,那么其父节点的索引是 (i-1)/2(假设数组的起始索引为 0),其左子节点的索引是 2i+1,右子节点的索引是 2i+2。

Heap representations

在不适用指针的情况下我们可以通过数组的索引在树中上下移动:从a[k]向上一层就令 k k k等于 k / 2 k/2 k/2,向下一层则令 k k k等于 2 k 2k 2k 2 k + 1 2k+1 2k+1。用数组(堆)实现的完全二叉树的结构非常严格,但它的灵活性足以高效地实现优先队列。

Heap representations

上浮

Bottom-up heapify (swim)

下沉

Top-down heapify (sink) Heap operations

多叉堆

对于数组中1至N的N个元素,位置 k k k 的结点大于等于位于 3 k − 1 3k-1 3k1 3 k 3k 3k 3 k + 1 3k+1 3k+1的结点,小于等于位于 ⌊ ( k + 1 ) / 3 ⌋ \lfloor (k+1)/3 \rfloor ⌊(k+1)/3

Heap representations

索引优先队列

索引优先队列是一种数据结构,它允许在插入、删除和修改元素时都能保持队列中元素的优先顺序。它的基本操作通常包括插入、删除最大/最小元素、删除任意元素和更改元素的优先级。

相比于普通的优先队列,索引优先队列的特性是:

  • 每个元素都有一个唯一的相关索引,这个索引在整个元素在优先队列中的生命周期中保持不变。
  • 支持通过索引访问元素,并能够更改元素的优先级。
  • 支持通过索引删除元素。

优点:

  1. 由于索引和元素之间的关联,因此可以直接通过索引访问、修改或删除元素,这大大提高了操作效率。
  2. 支持更改元素的优先级,这在许多应用中都是必需的。

缺点:

  1. 实现相对复杂,需要更多的存储空间来保存索引和优先级。
  2. 如果频繁进行优先级更改或删除操作,可能会导致性能下降。

应用:

  1. 在计算机科学中,索引优先队列常用于图算法,如Dijkstra的最短路径算法、Prim的最小生成树算法等。
  2. 用于实现任务调度或事件驱动模拟,其中的任务或事件可以有各种优先级,并且优先级可能会在运行期间发生更改。
  3. 在网络和操作系统中,索引优先队列可以用于处理具有不同优先级的请求或任务。

总的来说,索引优先队列在需要动态改变优先级,或者需要快速找到并删除任意元素的情况下特别有用。

堆排序

我们可以把任意优先队列变成一种排序方法。将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将它们按顺序删去。

堆排序是一种利用堆数据结构设计的一种排序方法,它可以将一个无序数组变成一个有序数组。堆排序的基本思想分为两个主要的步骤:

  1. 建立堆:首先,需要将待排序的序列构建成一个大顶堆或小顶堆。如果我们希望得到的是升序序列,就应该构建大顶堆;反之,如果希望得到降序序列,就应该构建小顶堆。构建堆的过程,实质上是不断调整堆的过程。从下往上,从右至左,将每个非叶子节点当作根节点,将其和其子树调整为堆。
  2. 调整堆:堆顶元素是最大(或最小)的元素。删除堆顶元素后,需要将堆底元素调到堆顶,然后再从根节点开始进行一次从上往下的调整(重建堆),调整过程中,较大(或较小)的子节点会上浮,这样就能保证堆顶元素始终是最大(或最小)元素。重复这个过程,直到堆中只剩下一个元素,排序就完成了。

堆排序的主要优点是其时间复杂度为O(nlogn),在速度上较快,且是原地排序,不需要额外的存储空间。但是,由于堆排序不稳定(即相等的元素可能会因为排序而改变原来的相对位置),并且在实际数据的排序速度上不及插入排序和快速排序,因此在某些情况下,可能不是最佳的排序选择。

Trace of heapsort

iShot_2023-06-05_06.46.28

堆排序的缺点是它无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序。

2.5 应用

Performance characteristics of sorting algorithms

稳定性:稳定排序算法是如果两个对象具有相同的键,那么它们的相对位置在排序后的序列中不会改变。换句话说,稳定排序算法维持了相等元素的相对顺序。

在大多数情况中,快速排序是最佳选择。如果稳定性更重要而空间不是问题,归并排序可能是最好的。

第3章 查找

3.1 符号表

符号表最主要的目的就是将一个键和一个值联系起来。

定义:符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应的值。

符号表也称为符号字典或关联数组,其底层实现可以是数组,也可以是其他数据结构,如链表、哈希表、平衡树(如红黑树)等。选择何种数据结构取决于符号表的具体需求,如查找效率、插入效率、删除效率等。

符号表通常用于存储和查找键值对。每个键对应一个值,这些键可以是任何可比较的类型,如整数、字符串等,值则可以是任何类型。符号表提供一种机制,可以通过键查找到对应的值。

有序符号表是符号表的一种,它保持了元素(键或键值对)的有序性。在有序符号表中,键(或键值对)按照某种顺序(通常是键的自然顺序或指定顺序)存储。比如,你可以在有序符号表中进行范围查询(找出位于两个给定键之间的所有键),或找出最大和最小的键。

符号表和字典在大多数编程语言中是相同的概念,都是用于存储和查找键值对的数据结构。在Python中,这种数据结构就叫做字典(Dictionary)。然而,这两个词并非完全等价,字典通常用于指一种无序的键值对集合,而符号表可能指更广义的概念,包括有序或无序的键值对集合。

IMG_9416 Symbol-table API

数组是最简单的符号表。

3.1.2 有序符号表

Ordered Symbol-table API Examples of ordered symbol table operations

3.1.4 无序链表中的顺序查找

使用链表实现符号表,插入和查找的时间复杂度为 O ( n ) O(n) O(n),非常低效。

IMG_9424

3.1.5 有序数组中的二分查找

IMG_9425 IMG_9426

基于有序数组中的二分查找的符号表的缺点是put()方法太慢了。二分查找减少了比较次数但无法减少运行所需时间。

IMG_9427

3.2 二叉查找树

二叉查找树(Binary Search Tree,简称 BST)是一种特殊的二叉树,它满足以下性质:

  1. 节点的值:每个节点都有一个与之相关联的键(通常是数字)和可能的关联数据。
  2. 左子树的键小于节点的键:任意节点(记为 N)的左子树中的所有节点的键都要小于节点 N 的键。
  3. 右子树的键大于节点的键:任意节点(记为 N)的右子树中的所有节点的键都要大于节点 N 的键。
  4. 左子树和右子树也是二叉查找树:节点 N 的左子树和右子树也分别是二叉查找树。
1686525015850 IMG_9435

同一个集合可以用多棵不同的二叉查找树表示。

在查找中,如果查找的键较小就选择左子树,较大则选择右子树。

IMG_9436 IMG_9437 IMG_9438 IMG_9439

最好情况下位 l g N lgN lgN,最坏情况下为 N N N

删除结点

  • 将指向即将被删除的结点的链接保存为t;
  • 将x指向它的后继结点min(t.right);
  • 将x的右链接指向deleMin(t.right),也就是在删除后所有节点仍然都大于x.key的子二叉查找树;
  • 将x的左链接设为t.left。
IMG_9440 IMG_9442

3.3 平衡查找树

平衡查找树(Balanced Search Tree)是一种特殊的二叉查找树,它能在增加或删除节点时保持其高度较低。平衡查找树的设计目的是为了解决普通二叉查找树在最坏情况下可能导致时间复杂度退化为 O ( n ) O(n) O(n) 的问题。

平衡查找树的关键特点是任何节点的两个子树的高度差的绝对值不超过一定的限制。这种限制确保了树的高度始终为对数级别,从而使得插入、删除和查找操作的时间复杂度为 O ( l o g n ) O(log n) O(logn)

常见的平衡查找树包括:

  1. AVL树:AVL树是一种自平衡二叉查找树,要求任何节点的两个子树的高度差的绝对值不超过1。这种限制是较为严格的,因此AVL树的平衡程度较高。
  2. 红黑树:红黑树是一种自平衡二叉查找树,通过给节点标记颜色并在插入或删除节点时进行调整以保持平衡。红黑树的平衡性不如AVL树严格,但在某些情况下,它的插入和删除操作可能更快。
  3. B树:B树是一种自平衡的m叉查找树,通常用于数据库和文件系统。B树的节点可以有多个子节点,并且可以存储多个键。
  4. Treap:Treap是一种结合了二叉查找树和堆的数据结构,也被称为树堆。通过随机化和旋转操作,Treap能在平均情况下保持较好的平衡。
  5. Splay树:Splay树是一种自调整的二叉查找树,通过一系列旋转操作,把最近访问过的元素移到树的根部,从而试图减小常用元素的访问时间。

平衡查找树在很多计算机科学和工程领域有广泛应用,如数据库索引、内存管理、数据压缩等。由于它们可以保证操作的高效性,所以在需要维护动态数据集并频繁执行查找、插入和删除操作的场景中具有很大的价值。

2-3查找树

2-3查找树是一种自平衡的查找树数据结构,其中的每个节点可以存储一或两个的键以及相应的值,并且可以有两个或三个子节点。这是一个扩展自二叉查找树的概念,使树保持更好的平衡,从而获得更有效的查找性能。

一颗2-3查找树或为一棵空树,或由以下结点组成:

  • 2-节点:包含一个键和两个子节点。这个节点的左子树包含的所有键都小于节点的键,右子树包含的所有键都大于节点的键。
  • 3-节点:包含两个键和三个子节点。左子节点包含的所有键都小于较小的键,中间子节点包含的所有键都在两个键的中间,右子节点包含的所有键都大于较大的键。

2-3查找树的基本操作包括插入、删除和查找:

  1. 插入:当插入一个新的键时,首先按照二叉查找树的方式找到合适的叶子节点位置。然后,根据叶子节点的类型执行不同的操作。如果是2-节点,直接将其转换为3-节点。如果是3-节点,需要进行一系列的分裂和合并操作来保持树的平衡。
  2. 删除:删除一个键较为复杂。首先需要找到要删除的键,然后根据情况可能需要从兄弟节点借一个键或者通过合并操作来保持树的平衡。
  3. 查找:查找操作与常规的二叉查找树类似,但在遇到3-节点时需要比较两个键。

2-3查找树的优点是它能够自动保持良好的平衡,无论插入和删除的顺序如何。这使得在2-3查找树上的操作具有对数时间复杂度,这对于大型数据集来说是非常高效的。

2-3查找树是B树的一种特例,是为了理解更复杂的B树和B+树提供基础的一种数据结构。

2-3树的缺点:

  • 复杂性:与普通的二叉查找树相比,2-3树的插入和删除操作更为复杂。需要处理更多的情况,比如节点分裂、合并,以及在兄弟节点间转移键等。
  • 空间浪费:在2-3树中,有些节点可能只存储一个键(2-节点)。这意味着这些节点的存储空间没有被充分利用,特别是当键和指针的大小相对较大时,这种空间浪费可能变得更加明显。
  • 不适合大范围查询:2-3树不像B+树那样,其叶子节点之间没有链接,因此不适合用于大范围的数据查询。例如,在数据库系统中,B+树通常比2-3树更受欢迎,因为B+树可以更高效地支持范围查询。
  • 不是最优的平衡查找树:虽然2-3树保持了较好的平衡性,但在某些情况下,其他平衡查找树(如红黑树、AVL树)的性能可能更好。
  • 代码实现较复杂:由于2-3树的节点可以有不同的键的数量,并且插入和删除操作需要处理多种不同的情况,因此代码的实现相对较复杂。
  • 在连续插入和删除时,可能需要多次调整:虽然2-3树可以保持良好的平衡,但是在连续的插入或删除操作中可能需要频繁的调整,这在某些情况下可能是低效的。
1686697779375

查找

IMG_9443

向2-结点中插入新键

IMG_9444

向一颗只含有一个3-结点的树中插入新键

IMG_9445

向一个父结点为2-结点的3-结点插入新键

IMG_9446

向一个父结点为3-结点的3-结点中插入新键

IMG_9447

分解根结点

IMG_9448

局部变换

将一个4-结点分解为一颗2-3树可能有6种情况。

IMG_9449

全局性质

当根结点被分解为3个2-结点时,所有空链接到根结点的路径长度才会加1.

IMG_9450

和标准的二叉树由上向下生长不同,2-3树的生长是由下向上的。

IMG_9451

红黑二叉查找树

红黑二叉查找树(Red-Black Binary Search Tree)是一种自平衡的二叉查找树。在这种数据结构中,每个节点都有一个颜色属性,可以是红色或黑色。通过对节点着色和对树进行少量的额外操作,红黑树能够在插入和删除操作中保持大致平衡,从而保证查找操作的高效性。

红黑树遵循以下五个性质:

  1. 每个节点要么是红色,要么是黑色。
  2. 树的根节点是黑色的。
  3. 每个叶子节点(通常是NIL或空节点)是黑色的。
  4. 如果一个节点是红色的,那么它的两个子节点都是黑色的(也就是说,不能有两个连续的红色节点)。
  5. 对于每个节点,从该节点到其所有后代叶子节点的简单路径上,黑色节点的数量相同(称为黑高)。

这些性质强制性地限制了树的结构,从而使得在树的最长路径上的节点数不会超过最短路径上的节点数的两倍。这保证了树的高度大致对数级,从而使得插入、删除和查找操作具有 O(log n) 的时间复杂度。

常见操作:

  1. 插入:在红黑树中插入节点与在普通的二叉查找树中类似,首先找到合适的位置然后插入节点。通常新插入的节点被标记为红色,然后通过一系列的旋转和重新着色操作来维护红黑树的性质。
  2. 删除:删除操作比插入操作更复杂。删除节点后,需要通过旋转和重新着色来重新平衡树。
  3. 查找:查找操作与普通的二叉查找树一样,不需要进行任何特殊的处理。

红黑树在实际应用中非常广泛,例如,在许多编程语言的库和容器(如C++的 std::map, Java的 TreeMap)以及数据库和文件系统中都使用了红黑树。红黑树的一个优点是它在插入和删除时的最坏情况性能仍然非常良好,而且实现相对于其他高度平衡的树(如AVL树)来说更简单。

插入操作

红黑二叉查找树背后的基本思想是用标准的二叉查找树(完全由2-节点构成)和一些额外的信息(替换3-结点)来表示2-3树。

树中的链接分为两种类型:红链接将两个2-结点链接起来构成一个3-结点,黑链接则是2-3树中的普通链接。

IMG_9453

红黑树的的另一种定义是含有红黑链接并满足下列条件的二叉查找树

  1. 红链接均为左链接。
  2. 没有任何一个节点同时和两条红链接相连。
  3. 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同。
IMG_9454

如果我们将红链接的节点合并,得到的就是一颗2-3树。

一个结点的颜色,指的是指向该结点的链接的颜色。红黑树既是二叉查找树,也是2-3树。

IMG_9455

颜色表示:

IMG_9456

旋转:

在实现的某些操作中可能会出现红色右链接或者两条连续的红链接,但在操作完成前这些情况都会被小心地旋转并修复。旋转操作会改变红链接的指向。旋转分为左旋转右旋转

左旋转只是将用两个键中的较小者作为根结点变为将较大者作为根结点。右旋转同理。

IMG_9457 IMG_9458

在旋转后重置父结点的链接:

h = rotateLeft(h),将旋转节点 h 的红色右链接,使得 h 指向了旋转后的子树的根结点。

在插入新的键时我们可以使用旋转操作帮助我们保证2-3树和红黑树之间的一一对应关系,因为旋转操作可以保持红黑树的两个重要特性:有序性和完美平衡性。

向单个2-结点中插入新键:

IMG_9459

向树底部的2-结点插入新键

IMG_9460

向一颗双键树(即一个3-结点)中插入新键

分为三种情况

IMG_9461

颜色转换

IMG_9466

根结点总是为黑色

在每次插入后都将根结点设为黑色。每当根结点由红变黑时树的黑链接高度就会加1。

向树底部的3-结点插入新键

IMG_9467

将红链接在树中向上传递

要在一个3-结点下插入新键,先创建一个临时的4-结点,将其分解并将红链接由中间键传递给它的父结点。重复这个过程,我们就能将红链接在树中向上传递,直到遇到一个2-节点或者根结点。

IMG_9468

总之,只要谨慎地使用左旋转、右旋转和颜色转换这三种简单的操作,我们就能够保证插入操作后红黑树和2-3树的一一对应关系。在沿着插入点到根结点的路径向上移动时在所经过的每个结点中顺序完成以下操作,我们就能完成插入操作:

  • 如果右子节点是红色的而左子结点是黑色的,进行做旋转;
  • 如果左子结点是红色的且它的左子结点也是红色的,进行右旋转;
  • 如果左右子节点均为红色,进行颜色转换。
IMG_9469

删除操作

IMG_9470

红黑树的性质

所有基于红黑树的符号表实现都能保证操作的时间为对数级别(范围查找除外,它所需的额外时间和返回的键的数量成正比)。

IMG_9471 IMG_9472

红黑树因为是平衡的,所以查找比二叉查找树更快。

IMG_9473

升序插入

iShot_2023-06-20_07.32.22

3.4 散列表

散列表是算法在时间和空间上作出权衡的经典例子。

IMG_9477

散列函数

散列函数的作用就是将键转化为数组的索引

如果我们有一个能够保存M个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引([0,M-1]范围内的整数)的散列函数。

散列函数方法:

  1. 除留余数法

    • 实现思路:取键值对一个不大于哈希表表长的素数p,用键值除以该素数,然后取余数作为哈希地址。
    • 优点:简单,适用于键值是整数的情况。
    • 缺点:需要选择一个合适的素数以减少冲突。
  2. 乘法散列法

    • 实现思路:将键值乘以一个0到1之间的常数A(0 < A < 1),然后取结果的小数部分,再乘以哈希表的大小M,并取整作为哈希地址。
    • 优点:不太依赖选择的常数A,适合处理实数键。
    • 缺点:计算量相对较大。
  3. 平方取中法

    • 实现思路:将键值平方,然后取结果的中间几位作为哈希地址。
    • 优点:当键值的分布不均匀时,平方值的中间几位往往分布较均匀。
    • 缺点:对键值的选择敏感,不同的键值集合可能有不同的效果。
  4. 折叠法

    • 实现思路:将键值分成几个部分,然后将这些部分叠加或异或在一起,作为哈希地址。
    • 优点:简单,对键值分布不敏感。
    • 缺点:可能会产生较多的冲突。
  5. 数字分析法

    • 实现思路:选择键值中分布均匀的数字作为哈希地址。
    • 优点:当键值的某些数字分布较为均匀时,效果较好。
    • 缺点:需要对键值的分布有一定了解。
  6. 字符串转换法(对于字符串键):

    • 实现思路:将字符串的字符转换为整数,然后使用另一种散列函数,如除留余数法。
    • 优点:可以处理字符串键。
    • 缺点:通常计算量较大。
  7. 哈希函数的组合

    • 实现思路:结合多种哈希函数,如将两个哈希函数的结果加在一起。
    • 优点:可以减少冲突,提高散列的均匀性。
    • 缺点:计算量较大,实现较复杂。

一个优秀的散列方法需要满足三个条件:

  • 一致性——等价的键必然产生相等的散列值;
  • 高效性——计算简便;
  • 均匀性——均匀地散列所有的键。

碰撞处理

  1. 开放寻址法

    • 实现思路:当发生冲突时,根据某种探测序列在哈希表中寻找下一个空的位置。
    • 优点:不需要额外的存储空间来存储冲突的元素。
    • 缺点:随着哈希表填充度增加,冲突的可能性变大,性能下降。
  2. 链地址法

    • 实现思路:哈希表的每个位置都存储一个链表或其他数据结构,以存储映射到该位置的所有元素。
    • 优点:处理冲突灵活,对哈希函数的选择不是特别敏感。
    • 缺点:增加了额外的存储开销,链表过长会影响查找效率。
  3. 双散列

    • 实现思路:使用两个独立的哈希函数。当第一个哈希函数导致冲突时,使用第二个哈希函数。
    • 优点:通常能有效减少冲突。
    • 缺点:增加了计算复杂性。
  4. 再哈希法

    • 实现思路:使用多个哈希函数。当一个哈希函数导致冲突时,尝试下一个哈希函数。
    • 优点:冲突的概率相对较低。
    • 缺点:增加了计算复杂性。

在设计哈希表时,选择适当的哈希函数和解决冲突的方法是非常重要的。这需要根据具体的应用场景和性能需求来进行权衡。

IMG_9480

散列最主要的目的是在于均匀地将键散步开来,因此在计算散列后键的顺序信息就丢失了。如果需要快速找到最大或最小的键,或是查找某个范围内的键,散列表都不是合适的选择,因为这些操作的运行时间都将会是线性的。

IMG_9482 IMG_9483

3.5 应用

IMG_9502

集合API

某些符号表的用例不需要处理值,它们只需要能够将键插入表中并检测一个键在表中是否存在。

IMG_9503

字典类用例

许多应用程序都将符号表看做一个可以方便地查询并更新其中信息的动态字典。

IMG_9504

索引类用例

索引是一个键对应多个值的符号表。将每个键关联的所有值都放入一个数据结构中(比如一个Queue)并用它作为值就可以轻松构造一个索引。

IMG_9505
反向索引

反向索引一般指用值来查找键的操作。

IMG_9506

稀疏向量

IMG_9507 IMG_9508

第4章 图

图论是数学的一个分支,研究图的性质和应用。在图论中,图通常是由顶点(节点)和边(连接节点的线段)组成的。图论的应用非常广泛,涵盖了许多领域,以下是一些示例:

  1. 计算机网络:图论在网络的设计、路由和拥塞控制中发挥着至关重要的作用。例如,互联网可以看作是一个庞大的图,其中站点是顶点,而连接是边。

  2. 社交网络:社交网络如Facebook, Twitter等可以被模拟成图,其中人们是顶点,他们之间的友谊或关系是边。图论可以用于分析社交网络的结构,如查找社区、影响分析等。

  3. 交通运输:图论被用来模拟和优化交通网络,如公路、铁路和航空网络。Dijkstra算法和A*算法是两个常用的图论算法,用于寻找最短路径。

  4. 供应链和物流:图论在供应链管理和物流中的应用包括路径规划、仓库位置选择、运输优化等。

  5. 生物信息学:在基因组学和蛋白质组学中,生物分子之间的相互作用可以用图来表示。图论算法可用于研究这些结构和相互作用。

  6. 电力网络:电力系统的网络可以通过图论来建模,用于分析电力流和优化电网的可靠性。

  7. 推荐系统:图论被用于构建推荐系统,如通过用户和项目的关联来生成个性化的推荐。

  8. 化学:化学结构,尤其是分子结构,可以用图来表示。图同构算法可以用于判断两个化合物是否具有相同的化学结构。

  9. 项目管理:在项目管理中,PERT图和甘特图使用图论来帮助计划和调度任务。

  10. 游戏:在电子游戏和棋类游戏中,图论可以用于AI决策制定、路径查找和游戏策略分析。

  11. 地理信息系统:在GIS中,图论被用于空间分析,比如寻找最近的店铺或优化送货路线。

这只是图论的一些应用示例,它的应用是非常广泛的,不断涌现的新技术和研究领域也在继续推动图论的应用。

IMG_9509

4.1 无向图

定义:图是由一组顶点和一组能够将两个顶点相连的边组成。

一般使用 0 0 0 V − 1 V-1 V1 来表示一张含有 V V V 个顶点的图中的各个顶点。

IMG_9510

特殊的图:

  • 自环,即一条连接一个顶点和其自身的边;
  • 连接同一对顶点的两条边称为平行边。
IMG_9511

含有平行边的图称为多重图,没有平行边或自环的图称为简单图

术语概述

  • 子图 - 由原图的一些顶点和一些边组成的图。
  • - 与一个顶点相关联的边的数量。在有向图中,度分为入度和出度。
  • 路径 - 顶点的序列,其中每个顶点通过边与下一个顶点相连,由边顺序连接的一系列顶点。
  • 简单路径 - 一条没有重复顶点的路径。
  • - 一条路径,其中起点和终点是相同的。
  • 简单环 - (除了起点和终点必须相同之外)不含有重复顶点和边的环。路径或环的长度为其中所包的边数。
  • 连通图 - 从任意一个顶点都存在一个路径到达另一个顶点的图。
  • 无环图 - 不包含环的图。
  • - 一幅无环连通图。
  • 森林 - 互不相连的树组成的集合。
IMG_9512 IMG_9513

当且仅当一幅含有 V V V 个结点的图 G G G 满足下列5个条件之一时,它就是一棵树:

  • G 有 V-1 条边且不含有环;

  • G 有 V-1 条边且是连通的;

  • G 是连通的,但删除任意一条边都会使它不再连通;

  • G 是无环图,但添加任意一条边都会产生一条环;

  • G 中的任意一对顶点之间仅存在一条简单路径。

  • 密度 - 已经连接的顶点对占所有可能被连接的顶点对的比例。

  • 稀疏图 - 被连接的顶点对很少。

  • 稠密图 - 只有少部分顶点没有边连接。

IMG_9514
  • 二分图 - 能够将所有结点分为两部分的图,其中图的每条边所连接的两个顶点都分别属于不同的部分。
IMG_9515

无向图的数据类型

IMG_9516 IMG_9517

图的数据结构实现包含以下两个要求:

  • 它必须为可能在应用中碰到的各种类型的图预留出足够的空间;
  • Graph的实例方法的实现一定要快——它们是开发处理图的各种用例的基础。

三种图的数据结构表示方法:

  • 邻接矩阵。使用一个 V × V 的布尔矩阵。当顶点v和顶点w之间有相连接的边时,定义v行w列的元素值为true,否则为false。这种表示方法不符合第一个条件——含有上百万个顶点的图是很常见的, V 2 V^2 V2个布尔值所需的空间不满足。
  • 边的数组。我们可以使用一个Edge类,它含有两个int实例变量。这种表示方法很简洁但不满足第二个条件。
  • 邻接表数组。使用一个顶点为索引的列表数组,其中的每个元素都是和该顶点相邻的顶点列表。这种数据结构能够同时满足上述两个条件。
IMG_9518

邻接表的数据结构,它将每个顶点的所有相邻顶点都保存在该顶点对应的元素所指向的一张链表中。在这种数据结构中,每条边都会出现两次。

IMG_9519

这种Graph的实现的性能有如下特点:

  • 使用的空间和 V+E 成正比;
  • 添加一条边所需的时间为常数;
  • 遍历顶点v的所有相邻顶点所需的时间和v的度数成正比。
IMG_9520

深度优先搜索(DFS)

IMG_9522

用一个递归方法来遍历所有顶点,在访问其中一个顶点时:

  • 将它标记为已访问;
  • 递归地访问它的所有没有被标记过的邻居顶点。

这种方法称为深度优先搜索(DFS)

IMG_9524

寻找路径

IMG_9525

广度优先搜索(BFS)

可以解决例如单点最短路径问题。

IMG_9526

使用一个队列来保存所有已经被标记过但其邻接表还未被检查的顶点。先将起点加入队列,然后重复以下步骤直到队列为空:

  • 取队列中的下一个顶点v并标记它;
  • 将与v相邻的所有未被标记过的顶点加入队列。
IMG_9527 IMG_9528 IMG_9529

深度优先搜索一幅图的方式是寻找离起点更远的顶点,只在碰到死胡同时才访问近处的顶点;广度优先搜索则会首先覆盖起点附近的顶点,只在临近的所有顶点都被访问之后才向前进。

DFS的路径通常较长且曲折;BFS的路径则短而直接。

连通分量

在图论中,连通分量(Connected Component)是一个重要的概念。一个无向图的连通分量是一个子图,这个子图满足两个条件:一是子图内的任意两个顶点都是连通的(即在子图中存在从一个顶点到另一个顶点的路径);二是子图是最大的,意思是说如果再加入这个子图以外的任何一个顶点,那么它就不再满足条件一。

在完成只需要判断连通性或是需要完成有大量连通性查询和插入操作混合类似的任务时,更倾向于union-find算法,而深度优先搜索则更适用实现图的抽象数据结构,因为它能更有效地利用已有的数据结构。

IMG_9538

符号图

IMG_9539 IMG_9542 IMG_9540

符号图用到3种数据结构:

  • 一个符号表st,键的类型为String(顶点名),值的类型为int(索引);
  • 一个数组keys[],用作反向索引,保存每个顶点索引所对应的顶点名;
  • 一个Graph对象G,它使用索引来引用图中顶点。
IMG_9541 IMG_9543

4.2 有向图

在有向图中,边是单向的:每条边所连接的两个顶点都是一个有序对,它们的邻接性是单向的。

IMG_9545

定义:一幅有方向性的图(或有向图)是由一组顶点和一组有方向的边组成的,每条有方向的边都连接着有序的一对顶点。

在一幅有向图中,一个顶点的出度为由该顶点指出的边的总数;一个顶点的入度为指向该顶点的边的总数。

IMG_9546

有向图的数据类型

IMG_9547 IMG_9549

有向图的可达性

IMG_9550

应用:

  • 标记-清除的垃圾收集
    • 在一幅有向图中,一个顶点表示一个对象,一条边则表示一个对象对另一个对象的引用。标记-清除的垃圾回收策略会周期性运行DFS的有向图可达性算法来标记所有可以被访问到的对象,然后清理所有对象,回收没有被标记的对象,以腾出内存供新的对象使用。
IMG_9551

环和有向无环图

拓扑排序是一个在有向无环图(Directed Acyclic Graph,简称DAG)上的排序算法。在拓扑排序中,节点代表任务,而边代表任务之间的依赖关系。拓扑排序的一个重要性质是,对于图中的任意一条边(u, v),节点u在拓扑排序中都出现在节点v的前面。换句话说,如果任务B依赖于任务A的完成,那么在拓扑排序中,任务A一定排在任务B的前面。

拓扑排序不仅仅是一个线性顺序,它可能有多个合法的拓扑排序序列,但它必须满足前面提到的依赖关系条件。

常见的拓扑排序算法有Kahn算法和深度优先搜索算法。

拓扑排序的应用非常广泛,包括但不限于:

  1. 任务调度:当你有一系列任务,其中一些任务依赖于其他任务完成时,拓扑排序可以帮助你找出完成这些任务的顺序。
  2. 课程安排:例如,学生需要完成课程A才能学习课程B,而课程B是课程C的先决条件。在这种情况下,拓扑排序可以帮助确定应该以何种顺序完成课程。
  3. 构建系统:在软件工程中,当构建一个项目时,必须先构建其依赖项。拓扑排序可以帮助确定依赖项的构建顺序。
  4. 解决约束满足问题:在某些情况下,问题可以表示为一个有向无环图,其中节点代表变量,边代表约束。通过拓扑排序,可以找到一种满足所有约束的变量赋值顺序。
  5. 数据序列化:当数据对象之间存在依赖关系时,例如在对象关系映射或网络通信中,拓扑排序可以用于确定正确的序列化和反序列化顺序。
IMG_9552 IMG_9553
IMG_9554 Graph Algorithm - Topological Sorting - DEV Community

在处理这种拓扑排序时,该有向图不能是有环的,必须是一个有向无环图(DAG,Directed Acyclic Graph)。

可以使用DFS来判断一幅有向图是不是DAG。

IMG_9555

有向图中基于深度优先搜索的顶点排序,它的基本思想是深度优先搜索正好只会访问每个顶点一次。如果将dfs()的参数顶点保存在一个数据结构中,遍历这个数据结构实际上就能访问图中的所有顶点,遍历的顺序取决于这个数据结构的性质以及是在递归调用之前还是之后进行保存。有3种顶点排序方式:

  • 前序:在递归调用之前将顶点加入队列。
  • 后序:在递归调用之后将顶点加入队列。
  • 逆后序:在递归调用之后将顶点压入栈。(一幅有向无环图的拓扑排序即为所有顶点的逆后序排序)
IMG_9561 IMG_9562

在实际应用中,拓扑排序和有向环的检测总会一起出现。

有向图中的强连通性

定义:如果两个顶点v和w是互相可达的,则称它们为强联通的。也就是说,既存在一条从v到w的有向路径,也存在一条从w到v的有向路径。如果一幅有向图中的任意两个顶点都是强连通的,则称这幅有向图也是强连通的。

IMG_9563

强连通性将所有顶点分为了一些等价类,每个等价类都是又相互均为强连通的顶点的最大子集组成,这些子集称为强连通分量

IMG_9564 IMG_9565 IMG_9566
Kosaraju算法

Kosaraju算法能实现非平方级别的时间复杂度 O ( V + E ) O(V+E) O(V+E)。它的思路如下:

  1. 第一遍深度优先搜索:从任意一个顶点开始,对原始图进行深度优先搜索。维护一个栈,每当完成一个顶点的搜索,就把这个顶点压入栈中。这个栈基本上是按照顶点的完成时间来排序的,返回逆后序节点。
  2. 转置图:得到原图的转置,即将原图中的所有边的方向反转。
  3. 第二遍深度优先搜索:当栈非空时,弹出一个顶点。从这个顶点开始,对转置图进行深度优先搜索。搜索过程中遍历到的所有顶点都属于同一个强连通分量。标记这些顶点,以便在后续的搜索中不再访问它们。
  4. 重复步骤:重复第三步,直到栈为空。每一次的深度优先搜索都会找到一个强连通分量。

Kosaraju算法的精妙之处在于它利用了图的转置以及深度优先搜索的性质。第一遍深度优先搜索确定了一个顶点的处理顺序,而第二遍深度优先搜索则按照这个顺序来找到强连通分量。

算法- 强连通分量Kosaraju 算法| Earth Guardian

IMG_9581 IMG_9582

4.3 最小生成树

加权图是一种为每条边关联一个权值或是成本的图模型。

在一个连通图(Connect Graph)中,一个生成树指的是一个子图,它包含了原图的所有节点,并且所有的节点都是通过边相连的。也就是说,生成树是原图的一个极小连通子图(无环)。

最小生成树(MST)指的是,给定一幅加权无向图,找到它的一颗最小生成树。所有可能的生成树中,所有边的权值和最小的那一个。在计算最小生成树的过程中,经常使用到两个算法,Prim算法和Kruskal算法。

Prim算法是从一个节点开始,逐步添加新的边来形成最小生成树。每次添加的都是权值最小的边,且这个边连接的两个节点至少有一个节点已经在生成树中。

Kruskal算法则是从所有的边开始,每次添加的都是所有剩余的边中权值最小的,且这个边连接的两个节点不能都已经在生成树中,否则会形成环。

这两个算法都是贪心算法,通过每一步都取最优解(权值最小的边),最终获得全局最优解(权值和最小的生成树)。

IMG_9583 IMG_9584 IMG_9585

原理

树的两个重要性质:

  • 用一条边连接树中的任意两个顶点都会产生一个新的环;
  • 从树中删去一条边将会得到两颗独立的树。

图的一种切分是将图的所有顶点分为两个非空且不重叠的集合。横切边是一条连接两个属于不同集合的顶点的边。

在无向图中,切分(cut)是指将图的顶点集分为两个非空子集。切分的容量是所有从子集一侧指向另一侧的边的权重之和。切分定理(cut theorem)是一种基本的图论结果,它表明对于任何图的切分,存在一条边,其权重小于或等于切分的容量,并且该边是连接源点和汇点的最小费用路径的一部分。

最小切分问题是在给定无向图和其中的两个点(源点和汇点)的情况下,找到能将源点和汇点分隔开的最小切分。

IMG_9586IMG_9587IMG_9588

对于每一种切分,权重最小的横切边必然属于最小生成树。不过,权重最小的横切边并不一定是所有横切边种唯一属于图的最小生成树的边。实际上,许多切分都会产生若干条最小生成树的横切边。

切分定理是解决最小生成树问题的所有算法的基础。使用切分定理找到最小生成树的一条边,不断重复直到找到最小生成树的所有边。

IMG_9589

加权无向图的数据类型

IMG_9590 IMG_9591 IMG_9592 IMG_9593

Prim算法

Prim的算法是一种贪心算法,用于求解无向图的最小生成树问题。该算法以任意顶点为起点,逐渐延伸到覆盖整个图,生成一个最小生成树。

具体步骤如下:

  1. 首先,选择图中任意一个顶点作为起点。
  2. 接着,每一步都要选择一条连接已经选中顶点和未选中顶点并且权值最小的边,将这条边及其对应的未选中顶点加入到生成树中。
  3. 重复步骤2,直到图中所有的顶点都被选中,这样就得到了一个最小生成树。

这个算法是以数学家Robert C. Prim命名的。Prim的算法时间复杂度为O(V^2),其中V是顶点的数量。如果使用优先队列(如斐波那契堆)来存储边,那么复杂度可以降低到O(E+VlogV),其中E是边的数量。

IMG_9594

File:Prim-animation.gif - Wikipedia

Prim算法的即时实现

Prim算法的即时(Immediate)实现主要有以下步骤:

  1. 初始化:选取一个起始点(可以是任何一个顶点),加入到最小生成树的集合中。
  2. 迭代:在剩下的顶点中,找到一个距离最小生成树集合最近的顶点,加入到最小生成树的集合中,并将该顶点到最小生成树集合中的边也加入到最小生成树中。
  3. 重复:重复步骤2,直到所有的顶点都被包含在最小生成树集合中。

在实现这个算法时,一种有效的策略是使用优先队列(如二叉堆)来保存所有的边,以便在每一步中快速找到最小的边。在算法的每一步,我们可以更新优先队列,以考虑新添加的顶点到其余各顶点的距离。这样,即时Prim算法的时间复杂度可以被优化到O(ElogV),其中E是边的数量,V是顶点的数量。

需要注意的是,Prim算法假设图是连通的,也就是从任意一个顶点可以到达其他任何顶点。如果图不是连通的,Prim算法将不能找到一个包含所有顶点的最小生成树。

IMG_9599

Kruskal算法

Kruskal算法的步骤如下:

  1. 将所有边按照权重从小到大排序。
  2. 创建一个空的最小生成树(或森林)。
  3. 从最小权重开始,依次遍历每条边。检查该边的两个顶点是否已经在最小生成树中被连接。如果没被连接,就将这条边加入最小生成树。如果已经被连接,就跳过这条边以避免形成循环(在生成树中不能有循环)。
  4. 重复步骤3,直到生成树包含了图中的所有顶点。

Kruskal算法的一个重要特性是它的贪心性质:它在每一步都选择当前可用的最小成本边,这就确保了最终生成的树具有最小的总成本。虽然每一步看起来可能并不是全局最优的,但最后的结果确实全局最优的。这就是所谓的贪心策略。

IMG_9600

Kruskal's Algorithm and Minimum Spanning Tree

IMG_9601

4.4 最短路径

IMG_9602

定义:在一幅加权有向图中,从顶点s到顶点t的最短路径是所有从s到t的路径中的权重最小者。

最短路径的性质

  • 路径是有向的。
  • 权重不一定等价于距离。权重也可以表示时间、花费或是某种完全无关的东西,也不一定会和距离的远近成正比。
  • 并不是所有顶点都是可达的。
  • 负权重会使问题更复杂。
  • 最短路径不一定是唯一的。
  • 可能存在平行边和自环。

**最短路径树(SPT)**定义:给定一幅加权有向图和一个顶点s,以s为起点的一颗最短路径树是图的一幅子图,它包含s和从s可达的所有顶点。这棵有向树的根节点为s,树的每条路径都是有向图中的一条最短路径。

IMG_9803

通过构造最短路径树,可以为用例提供从s到图中任何顶点的最短路径,表示方法为一组指向父结点的链表。

IMG_9804

最短路径的数据结构

IMG_9805
  • 最短路径树中的边 edgeTo。
  • 到达起点的距离 distTo。

边的松弛(relaxation)

IMG_9807

顶点的松弛

IMG_9815

Dijkstra算法

定义:

  • 初始化:选择一个源点,设置该源点的最短路径估计值为0,其它所有点的最短路径估计值为无穷大。
  • 从未被访问的节点中选择一个当前最短路径估计值最小的节点,记为当前节点。
  • 更新当前节点的所有邻居节点的最短路径估计值:如果通过当前节点到该邻居节点的距离小于该邻居节点的当前最短路径估计值,则更新它。
  • 标记当前节点为已访问。
  • 重复上述步骤,直到所有节点都被访问过。

限制:只适用于权重非负的加权图。

Data Structures and Algorithms: Weighted Graph Processing — Part 1: Dijkstra  | by Sethuram.S.V | Medium

IMG_9818 IMG_9819

无环加权有向图中的最短路径算法

特点:

  • 能够在线性时间内解决单点最短路径问题;
  • 能够处理负权重的边;
  • 能够解决相关的问题,例如找出最长的路径。

将顶点的放松和拓扑排序结合,组成一种解决无环加权有向图中的最短路径问题的算法。

思路:将distTo[s]初始化为0,其他distTo[]初始化为无穷大,然后一个一个地按照拓扑顺序放松所有顶点。

IMG_0385 IMG_0386

解决并行任务调度问题

问题与无环加权有向图中的最长路径问题是等价的。

IMG_0387

一般加权有向图中的最短路径问题

当图只存在正权重的边时,我们的重点在于寻找近路。如果图存在负权重的边时,我们可能会为了经过负权重的边而绕弯。

当图中存在负权重的环时,在这种情况下,从s到v的最短路径是不可能存在的,因为可以用这个负权重环构造权重任意小的路径。

Bellman-Ford算法

Bellman-Ford算法所需的时间和EV成正比,空间和V成正比。

基于队列的Bellman-Ford算法
IMG_0455 IMG_0456 Shortest_path_Dijkstra_vs_BellmanFord IMG_0459

第5章 字符串

5.1 字符串排序

5.1.1 键索引计数法

键索引计数法是一种用于小整数键的排序方法。它是许多字符串排序算法,如高位优先(MSD)和低位优先(LSD)的基础。

算法思路:

考虑我们有一系列对象,其中每个对象都有一个 0 到 R-1 之间的整数键(R 是整数键的范围)。键索引计数法的步骤如下:

  1. 计数:为每个键值 k,计算出小于 k 的键的数量。这可以通过一个简单的遍历和一个计数数组来完成。
  2. 转移:将对象移动到其在排序结果中的最终位置。你可以创建一个辅助数组,然后遍历原始数组,利用计数数组放置每个对象在正确的位置。
  3. 复制回去:从辅助数组中复制对象回原始数组。

优点:

  1. 线性时间复杂度:对于固定大小的 R,键索引计数法可以在 O(N) 时间内完成,其中 N 是要排序的对象的数量。
  2. 简单易懂:算法的基本思路相对直观,容易实现。
  3. 稳定性:这种排序方法是稳定的,即具有相同键的对象之间的相对顺序在排序后不会改变。

缺点:

  1. 限定的键类型:键索引计数法主要用于小范围的整数键。对于较大范围的整数或非整数键,该方法不适用或需要进行修改。
  2. 额外的空间:需要一个计数数组和一个辅助数组,这可能会增加额外的空间需求。

总的来说,键索引计数法是一个非常高效且稳定的排序方法,特别适合小范围的整数键。但它的应用范围受到键的类型和范围的限制。

算法思路举例:

假设你是一名老师,你手里有一堆学生的考试卷,你想根据他们的成绩进行排序。但这次的考试特别简单,只有5分,所以学生们的分数只有0分、1分、2分、3分、4分或5分。

这就意味着,你完全可以预测学生的分数范围。所以你在桌子上放置了6个桶(因为有6个可能的分数),分别标记为0、1、2、3、4和5。

现在你开始一个接一个地看学生的考试卷:

  1. 如果某个学生得了3分,你就把他的卷子放到标记为“3”的桶里。
  2. 如果另一个学生得了0分,你就把他的卷子放到标记为“0”的桶里。

你这样做,直到所有的卷子都被放到对应的桶里。

最后,你只需要从左到右(从0到5)依次取出每个桶里的卷子,这样你就得到了一个按分数排序的考试卷堆。

键索引计数法的核心就是这样:因为你知道了“键”的可能范围(在这个例子中,就是分数的范围),你可以为每一个可能的键值预留一个位置(桶)。然后,你只需要把数据放到对应的位置上,再从左到右收集数据,就得到了排序好的结果。

键索引计数法的基本思路如下:

  1. 计数:计算出每个键值的数量。例如,键值为 k 的字符串有 C[k] 个。
  2. 累加:根据键值计数来确定每个键值在排序结果中的起始位置。比如说,键值 k 的起始位置就是前 k 个键值的数量之和。
  3. 排序:遍历原始数据,根据每个元素的键值和相应的位置,放到结果数组的正确位置,并更新键值对应的位置。
  4. 复制回原数组:从结果数组中复制到原始数组。
IMG_0463 IMG_0464
int N = a.length;

String[] aux = new String[N];
int[] count = new int[R+1];

// 计算出现频率
for (int i = 0; i < N; i++) {
  count[a[i].key() + 1]++;
}
// 将频率转换为索引
for (int r = 0; r < R; r++) {
  count[r+1] += count[r];
}
// 将元素分类
for (int i = 0; i < N; i++) {
  aux[count[a[i].key()]++] = a[i];
}
// 回写
for (int i = 0; i < N; i++) {
  a[i] = aux[i];
}

5.1.2 低位优先的字符串排序

低位优先(Least Significant Digit First,LSD)的字符串排序。

  • 这种方法主要用于固定长度的字符串。
  • 从字符串的最右侧(也就是最低位)开始排序。
  • 例如,对于 “329”, “457”, “657”, “123” 这样的数字字符串,我们首先根据最后一位数字(个位)进行排序,然后再根据倒数第二位(十位)进行排序,最后根据倒数第三位(百位)进行排序。
  • 这种排序方法常用计数排序实现,因为它可以保证稳定性。
  • 该方法的优势在于对于具有相同前缀的字符串,它可以很快地将它们按照后缀来排序。
IMG_0465

5.1.3 高位优先的字符串排序

高位优先的字符串排序(Most Significant Digit, MSD 字符串排序)是一种基数排序方法,适用于字符串数据。它从字符串的最高位(即最左边的字符)开始进行排序,并逐渐考虑较低的位。

算法思路:

  1. 对于字符串的第一个字符(最高位),使用键索引计数法对数组进行排序。
  2. 根据第一个字符将字符串数组分成几部分,每一部分内部的字符串在第一个字符上都是相同的。
  3. 递归地对每一部分的字符串使用高位优先的排序,但是对第二个字符进行排序,以此类推。
  4. 递归的基准情况可以是字符串的长度或子数组的大小。

优点:

  1. 适应性:对于随机的字符串,MSD 排序通常比其他字符串排序算法更快。
  2. 局部性:当存在大量公共前缀或小的子数组时,算法可以非常高效。
  3. 预测性:对于某些应用,高位优先的排序可以提前确定某些结果,这在某些场景下可能非常有用。

缺点:

  1. 额外的空间:需要额外的空间来存储计数和辅助数组。
  2. 处理小数组低效:对于小数组,MSD 排序的开销可能比简单的排序算法要大。
  3. 过于复杂:与其他简单的字符串排序算法相比,它的实现稍微复杂一些。
  4. 不稳定性:原始的 MSD 排序是不稳定的,但可以进行修改使其稳定。

思路:首先用键索引计数法将所有字符串按照首字母排序,然后(递归地)再将每个字母所对应的子数组排序。

IMG_0466 IMG_0476

高位优先的字符串排序的最坏情况是所有的键均相同

IMG_0477

5.1.4 三向字符串快速排序

三向字符串快速排序是普通的的快速排序和高位优先的字符串排序的结合。

三向字符串快速排序(3-way string quicksort)是一种处理字符串数组的排序方法,特别适用于存在大量重复字符串的数组。它是标准的快速排序和键索引计数法的结合。

排序思路:

  1. 选择一个字符为分割字符(如当前考虑的所有字符串的第 k 个字符)。
  2. 将数组划分为三部分:
    • 所有小于分割字符的字符串。
    • 所有等于分割字符的字符串。
    • 所有大于分割字符的字符串。
  3. 递归地对小于分割字符的字符串和大于分割字符的字符串进行排序。
  4. 对于等于分割字符的字符串,递归地考虑下一个字符。

优点:

  1. 处理重复字符串高效:对于有大量重复的字符串,它可能比其他算法快很多倍。
  2. 适应性:即使在没有太多重复字符串的情况下,它仍然具有很好的性能。

缺点:

  1. 额外的空间:与标准的快速排序相比,三向字符串快速排序在递归调用时可能需要额外的空间。
  2. 复杂性:相对于其他基础的排序算法,三向字符串快速排序在实现上稍微复杂一些。
IMG_0478 IMG_0479

5.2 单词查找树

单词查找树

单词查找树(Trie),也被称为前缀树或字典树,是一种用于存储动态字符串集合的树状数据结构。在Trie中,键不是直接与其节点关联的,而是通过键中的字符与从根到指定节点的路径关联。

Trie的特点:

  1. 根节点不包含字符,除根节点外的每一个节点都只包含一个字符。
  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  3. 每个节点的所有子节点包含的字符都不相同。

优点

  1. 查找效率高:对于长度为k的字符串,查找的时间复杂度近似于O(k)。
  2. 有前缀的存储和查找:非常适合查找前缀相关的字符串,例如“aut”可以快速找到“auto”、“automatic”等词。
  3. 节约空间:对于有大量公共前缀的字符串集合,Trie可以节约存储空间。

缺点

  1. 空间开销:如果字符串集合没有很多共同的前缀,那么Trie可能会占用比其他数据结构更多的空间。
  2. 复杂性:实现Trie比较复杂,特别是当涉及到删除操作和其他一些高级功能时。
  3. 速度不一定总是最快的:尽管Trie的查找速度非常快,但是在某些情况下,基于散列表或平衡树的实现可能会更有效。

实际应用

  1. 自动补全:如搜索引擎在输入时给出的推荐词汇。
  2. 拼写检查:找到与错误拼写词最接近的正确单词。
  3. 路由器的IP路由选择
  4. 数字电话书:用于存储电话号码。
  5. 字典:快速查找某个词是否存在。
IMG_0480 IMG_0481 IMG_0483

单词查找树(Trie)的操作通常都是基于前缀的搜索。以下是查找、插入和删除操作的基本思路:

1. 查找:

目标:确定一个字符串是否存在于Trie中。

步骤

  1. 从根节点开始。
  2. 对于输入字符串的每个字符,按照该字符从当前节点向下搜索。
  3. 如果存在一个匹配的子节点,移动到该子节点,继续下一个字符的搜索。
  4. 如果在某个字符处没有匹配的子节点,那么该字符串不在Trie中,返回false。
  5. 如果已经处理了输入字符串的所有字符并且最后一个字符的节点标记为结束节点(通常用于表示一个完整的单词),则字符串存在于Trie中。

2. 插入:

目标:将一个字符串插入到Trie中。

步骤

  1. 从根节点开始。
  2. 对于输入字符串的每个字符,按照该字符从当前节点向下搜索。
  3. 如果存在一个匹配的子节点,移动到该子节点,继续下一个字符的搜索。
  4. 如果在某个字符处没有匹配的子节点,创建一个新的子节点,并继续插入后续的字符。
  5. 在插入的字符串的最后一个字符所在的节点,标记为结束节点。

3. 删除:

目标:从Trie中删除一个字符串。

步骤

  1. 使用查找操作来找到该字符串。
  2. 如果字符串存在,从字符串的最后一个字符开始回溯。
  3. 删除每一个字符的节点,但在以下情况中停止:
    • 如果当前节点有其他子节点(这意味着存在其他共享该前缀的字符串)。
    • 如果当前节点被标记为另一个字符串的结束节点。
  4. 删除字符串的过程结束。

需要注意的是,删除操作可能不会从Trie中删除所有关于字符串的信息。如果Trie中的其他字符串与待删除的字符串共享前缀,则这些前缀仍然会保留在Trie中。

这些操作确保Trie可以高效地存储和查询字符串,尤其是当需要查询前缀或者插入和删除时。

三向单词查找树

三向单词查找树(Ternary Search Tree,TST)是一种用于存储字符串的数据结构,可以看作是二叉查找树(BST)和标准单词查找树(Trie)的折衷方案。它结合了这两种数据结构的一些优点,同时也有自己独特的特性。

三向单词查找树的结构

在三向单词查找树中,每个节点包含一个字符、三个子节点(左子节点、中子节点和右子节点),以及一个标记(用于标记字符串的结束)。

  • 左子节点:包含比当前节点字符小的字符。
  • 中子节点:包含与当前节点字符相同的下一个字符。
  • 右子节点:包含比当前节点字符大的字符。

三向单词查找树的优点

  1. 空间效率:相比标准的Trie,TST通常使用更少的空间,特别是当字符串集合中的许多字符串共享相同的前缀时。
  2. 查找效率:对于大多数场景,TST提供了很好的查找效率,尤其是当字符串长度不均匀时。
  3. 灵活性:它更适合处理那些变化较大的字符串集合,例如自然语言处理中的单词。
  4. 前缀查询:TST能高效地支持前缀查询操作。

三向单词查找树的缺点

  1. 性能不稳定:其性能依赖于数据的特点和树的结构,有时可能不如标准Trie或平衡二叉查找树。
  2. 实现复杂度:相比普通的二叉查找树,TST的实现更复杂。

与单词查找树(Trie)的区别

  1. 空间占用:Trie由于每个节点分支数量等于字母表的大小,因此可能会占用大量空间,特别是在存储大量短字符串时。TST通常更加空间高效。
  2. 查找速度:Trie的查找操作可能在某些情况下比TST更快,特别是在字母表大小较小且字符串长度大致相同的场景中。
  3. 实现复杂性:Trie的结构通常比TST更简单,易于实现和理解。

总的来说,三向单词查找树在处理具有共同前缀的字符串集合时,提供了一种既节省空间又保持较高查询效率的解决方案,但其性能受数据特性和树结构的影响较大。

IMG_0525

三向单词查找树(Ternary Search Tree,TST)的查找、插入和删除操作都是基于树的递归遍历,这些操作与传统的二叉查找树相似,但需要考虑每个节点的三个子树(左、中、右)。

查找操作

查找操作是根据给定的键(字符串)来定位树中的特定节点。

  1. 开始:从根节点开始。
  2. 字符比较:将键的当前字符与节点的字符比较。
  3. 导航树
    • 如果键的字符小于节点的字符,转到左子树。
    • 如果键的字符大于节点的字符,转到右子树。
    • 如果键的字符等于节点的字符,转到中子树,并移动到键的下一个字符。
  4. 找到或未找到:如果能够逐个字符地遍历完整个键而且最后一个字符对应的节点标记为字符串结束,则成功找到。如果在任何时候节点为null或键中的字符无法完全匹配,说明键不在树中。

插入操作

插入操作用于将一个新的键添加到树中。

  1. 开始:从根节点开始。
  2. 字符比较:将键的当前字符与节点的字符比较。
  3. 导航和创建
    • 如果键的字符小于节点的字符,如果左子树为空,则创建新节点,否则转到左子树。
    • 如果键的字符大于节点的字符,如果右子树为空,则创建新节点,否则转到右子树。
    • 如果键的字符等于节点的字符,如果中子树为空并且还有更多的字符,创建新的中间节点,否则转到中子树。
  4. 完成插入:当所有键的字符都已被遍历,并到达合适的插入点,标记最后一个节点为字符串的结束。

删除操作

删除操作在TST中比较复杂,因为它可能需要重新连接树的多个部分。

  1. 查找要删除的键:首先按照查找操作找到要删除的键。
  2. 删除标记:如果找到了该键,去除最后一个字符节点上的结束标记。
  3. 清理无用节点:如果去除结束标记的节点之后没有子节点,该节点变成了无用节点,需要删除。这可能会引起一连串的删除,直到遇到一个有中子节点或者有结束标记的节点为止。
  4. 递归删除:从删除节点的父节点开始,递归地检查并清理无用节点。

在实际应用中,删除操作相对复杂且不常用。一些实现可能选择不实现删除操作,或者标记删除而不是物理删除节点。

IMG_0526

与其他所有二叉查找树一样,每个单词查找树结点的二叉查找树表示也取决于键的插入顺序。

空间:三次单词查找树每个键只含有三个节点,因此三向单词查找树所需要的空间远小于对应的单词查找树。

最坏情况:一个结点可能变成一个完全的R向结点,不平衡且像一条链表一样展开。

IMG_0531

5.3 子字符串查找

定义:给定一段长度为N的文本和一个长度为M的模式(pattern)字符串,在文本中找到一个和该模式相符的子字符串。

暴力子字符串查找算法

IMG_0532 IMG_0533

Knuth-Morris-Pratt 子字符串查找算法

v2-e33c0391971a733a7039c8608b5d2c63_b

IMG_0557

KMP算法的核心思路可以分为两个主要部分:

1. 构造部分匹配表(Partial Match Table,也称为前缀表或失败函数)

这个表是KMP算法的关键,它记录了模式串中每个位置之前的子串中,最长的相等的前缀和后缀的长度。通过这个表,我们可以在不匹配时,找到一个更好的开始匹配的位置,而不是像简单的匹配算法那样,仅仅从下一个位置开始。

举例来说,对于模式串 “ABCDABD”,它的部分匹配表是:

=
A B C D A B D
0 0 0 0 1 2 0

这个表告诉我们,例如在模式串的第六个字符(‘B’)处不匹配时,我们知道在这之前的子串 “ABCDAB” 的最长相等前后缀是 “AB”(长度为2)。这意味着我们可以将模式串向右移动4位(6 - 2),而不是一位一位地移动。

2. 搜索算法

在进行实际的搜索时,KMP算法会利用这个部分匹配表来移动模式串:

  • 当模式串的某个字符与文本串不匹配时,我们可以查找部分匹配表,看在模式串中最后一个匹配的字符前的最长相等前后缀的长度,然后相应地移动模式串。
  • 如果没有匹配的部分(即表中的值为0),则简单地将模式串向右移动一位。
  • 如果整个模式串遍历完成,那么表明找到了一个匹配。

优势

KMP算法的优势在于它避免了无谓的比较。在最坏的情况下,KMP算法的时间复杂度是O(n),其中n是文本字符串的长度。相比之下,朴素的字符串搜索算法在最坏情况下的时间复杂度是O(mn),其中m是模式串的长度。

KMP通俗易懂思路:

想象你在一本书里查找一个特定的词语(比如"abcdabd"),你会从书的开头开始,一直对照这个词语读下去。在用普通方法查找时,每当你发现字不匹配时,你会把注意力重新放在词语的第一个字母上,并从书里你停下的下一个字母开始再次对比。

但这种方法有点浪费时间,因为你在重复检查一些你已经知道不匹配的部分。这就像你在寻找"abcdabd",当你读到"abcdabc"时发现不匹配,普通的方法会让你从"b"(即第二个字母)开始重新比较,但其实你已经知道前面的"abc"是匹配的,完全没必要重头开始。

KMP算法就是为了解决这个问题而生的。它首先会预先分析这个词语,创建一个所谓的"部分匹配表"。这个表帮助你确定在不匹配的情况下可以跳过多少个字符。

继续用"abcdabd"为例,KMP算法会先分析它,然后得出一个表,这个表告诉你每个字符后面有多少个字符是可以在不匹配的情况下跳过的。比如,到了"abcdabd"的第四个字母"d"发现不匹配,你可以直接跳过接下来的三个字母,因为根据之前的分析,你知道接下来的三个字母不可能构成你要找的词语。

这样,KMP算法就通过避免重复检查已知不匹配的部分,显著提高了查找效率。这在长字符串或者有很多重复模式的文本中尤其有用。

DFA(确定性有限自动机,Deterministic Finite Automaton)是有限状态机的一种特殊形式,其中每个状态对于给定的输入都只有一个唯一的后继状态。DFA在理论计算机科学、语言处理、软件工程等领域中非常重要,它们是理解和设计语言解析和模式识别算法的基础。

DFA的基本概念

  1. 状态(States)
    • DFA包含一组有限的状态。
    • 其中有一个特定的状态被定义为起始状态。
    • 也可能有一个或多个状态被定义为接受状态,它们表示成功完成了某种计算或识别过程。
  2. 输入字母表(Input Alphabet)
    • DFA基于一个定义好的输入字母表运行。
    • 字母表是有效输入字符的集合。
  3. 转移函数(Transition Function)
    • DFA的核心是转移函数,它定义了如何根据当前状态和输入字符转移到下一个状态。
    • 对于DFA来说,给定当前状态和一个输入字符,有且只有一个唯一的状态作为输出。
  4. 接受状态(Accepting States)
    • 当DFA到达接受状态时,它表示接受了输入字符串。
    • 一个字符串是否被DFA接受取决于它是否能从起始状态经过一系列状态转移最终到达接受状态。

DFA的应用

DFA在计算机科学中有多种应用,例如:

  • 模式匹配:例如在文本处理中识别特定的字符串模式。
  • 词法分析:在编译器中,词法分析器用DFA来识别语言中的词汇单元(如变量名、关键字)。
  • 协议设计:在网络或通信协议中用于确定有效的消息序列。
  • 游戏开发:在游戏设计中,用于建立角色或系统的不同状态。

通俗易懂理解DFA:

想象你开车来到一个自动化的停车场。停车场入口处有一个自动门和一个票务机器,它们共同构成了一个DFA。

状态(State)

  • 初始状态:门是关闭的,机器等待车辆靠近。
  • 中间状态:门打开,车辆进入。
  • 接受状态:门关闭,车辆已进入。

输入(Input)

  • 车辆靠近:你驾车向门靠近。
  • 车辆离开:车辆通过门并离开入口区域。

转移规则(Transition Function)

  • 门关闭车辆靠近时,门会开启。
  • 门开启车辆通过门离开时,门会关闭。

过程

  1. 开始:门关闭。你驾车接近停车场(输入:车辆靠近),这是你向DFA提供的输入。
  2. 门开启:票务机器检测到车辆,发送指令打开门。状态从“门关闭”变为“门开启”。
  3. 通过门:你开车通过门(输入:车辆离开)。
  4. 门关闭:你通过后,门关闭。状态从“门开启”变为“门关闭”。
  5. 结束:停车场成功接收了你的车辆,你完成了从起始状态(门关闭,车外)到接受状态(门关闭,车内)的转换。

在这个例子中,停车场门的开关状态可以看作是DFA的不同状态,车辆的靠近和离开是输入信号,而票务机器的逻辑就是根据当前状态和输入确定下一状态的转移函数。每个状态和输入的组合都有一个明确的结果,这正是“确定性”的含义。

KMP(Knuth-Morris-Pratt)算法本身并不直接使用确定性有限自动机(DFA),但其背后的思想与DFA有着紧密的联系。KMP算法是一种高效的字符串匹配算法,主要用于在一个文本字符串中查找一个模式(或子串)的出现位置。它的核心在于预处理模式串,构建一个部分匹配表(也称为“失配表”或“前缀函数”),这个表在本质上和DFA有相似之处。

KMP算法与DFA的联系

在KMP算法中,部分匹配表是根据模式串的前缀信息构建的。对于模式串中的每个位置,表中的值指示当在文本串中匹配到该位置发生失配时,模式串应该回溯到哪个位置继续匹配。这个“回溯位置”其实就是在模式串中最长的相等的前缀和后缀的长度。KMP算法利用这个表来避免从头开始重新匹配,从而提高了匹配效率。

而在DFA中,针对一个特定的输入和当前状态,自动机会转移到一个新的状态。构建一个针对模式串的DFA意味着为模式串的每个可能的前缀定义状态,并为每个可能的字符输入定义状态转移。理论上,可以将KMP算法的部分匹配表转换为一个DFA,其中每个状态对应于模式串的一个前缀,并且转移是根据部分匹配表和下一个输入字符确定的。

Boyer-Moore 字符串查找算法

Boyer-Moore字符串搜索算法是由Robert S. Boyer和J Strother Moore在1977年提出的一种高效的字符串搜索算法。它被认为是在实践中最快的单模式字符串搜索算法之一,尤其是当被搜索的字符串非常长时。

核心思路:

Boyer-Moore算法的主要思想是从目标字符串的末尾开始匹配模式字符串。算法维护两个启发式的规则——坏字符规则(Bad Character Rule)和好后缀规则(Good Suffix Rule),用以在不匹配时跳过尽可能多的字符。

  1. 坏字符规则

    • 当发生不匹配时,算法查看文本中参与不匹配的字符(坏字符)。
    • 如果坏字符不包含在模式中,模式可以完全跳过坏字符之前的部分。
    • 如果坏字符在模式中,则模式滑动至模式中最右边的坏字符与文本中的坏字符对齐。
  2. 好后缀规则

    • 当模式中的一部分(后缀)与文本匹配,但随后出现一个不匹配的字符时,算法利用已经匹配的部分(好后缀)信息。
    • 算法搜索模式中是否存在另一个相同的后缀,并将模式滑动至这个重复后缀的位置。
    • 如果不存在,算法会尝试找到好后缀的后缀与模式的前缀匹配的情况,相应地滑动模式。

这两个规则结合起来使得算法在每次不匹配发生时可以跳过尽可能多的字符。

优点:

  1. 高效率:在最好情况下,Boyer-Moore算法的时间复杂度可以达到O(N/M)(N是文本长度,M是模式长度),远远超过其他算法。
  2. 后移位数更多:由于它使用了坏字符规则和好后缀规则,所以它通常可以跳过更多的字符,这使得其平均性能很好。
  3. 对长文本更友好:在处理非常长的文本时,Boyer-Moore表现出更加出色的性能。

缺点:

  1. 预处理时间:算法需要预处理模式字符串来构建坏字符表和好后缀表,这需要额外的时间和空间。
  2. 复杂性:相对于其他算法(如KMP),Boyer-Moore的实现相对复杂,难以理解和编码。
  3. 不稳定:在最坏情况下,性能可能会下降到O(N*M),虽然这在实践中很少见。

应用场景:

  • 文本编辑器的查找功能。
  • 数据库文本搜索。
  • 全文搜索引擎。
  • 任何需要高效字符串搜索的场合,尤其是搜索长文本。

与KMP算法的区别:

  • 算法策略:KMP算法侧重于避免重新检查已匹配的字符,而Boyer-Moore算法侧重于通过利用不匹配的信息来跳过尽可能多的字符。
  • 搜索方向:KMP从头至尾单向搜索,Boyer-Moore通常从尾至头逆向搜索。
  • 预处理:KMP算法预处理模式字符串,构造一个部分匹配表(也称为失败函数),而Boyer-Moore预处理坏字符表和好后缀表。
  • 性能:Boyer-Moore在实际应用中通常比KMP快,尤其是在模式字符串与目标文本匹

boyerMooreStringSearch-2

Rabin-Karp 指纹字符串查找算法

Rabin-Karp 算法是一种用于字符串查找的算法,它通过哈希函数来避免在主文本字符串中检查每一个子串。这个算法由 Michael Rabin 和 Richard Karp 在 1987 年发明。

核心思路

Rabin-Karp 算法的核心在于它使用了滚动哈希(也称为 Rabin 指纹)来快速筛选可能匹配的子串。

  1. 预处理: 首先计算模式串(待查找的字符串)的哈希值。
  2. 滚动哈希: 对主文本进行遍历,计算每个与模式串长度相等的子串的哈希值。
  3. 哈希比较: 如果某个子串的哈希值与模式串的哈希值相同,则进行进一步的逐字符比较来确认是否真的匹配。
  4. 哈希更新: 当移动到下一个子串时,不需要重新计算整个子串的哈希值,而是可以通过去掉前一个字符的贡献,并添加新字符的贡献来更新哈希值。

优点

  1. 高效的最坏情况时间复杂度: Rabin-Karp 算法的最坏情况下的时间复杂度为 O(nm),但平均情况下通常比直接的字符串比较方法要快。
  2. 常数时间的滑动窗口更新: 由于其滚动哈希的特性,更新哈希值只需要常数时间。
  3. 同时比较多个模式串: Rabin-Karp 可以被扩展用于同时查找多个模式串,因为它可以同时处理多个哈希值。
  4. 检测两个字符串的相似度: 它也常用于检测两个字符串的相似度,例如在查重系统中。

缺点

  1. 哈希碰撞: 如果不同的子串可能产生相同的哈希值,这会导致算法误判并执行更多的逐字符比较。
  2. 依赖于哈希函数: 哈希函数的选择对于避免冲突和保证算法的效率至关重要。
  3. 最坏情况性能: 当所有哈希值都相同时(例如在有大量重复字符的文本中),算法的性能会退化到 O(nm)。

应用场景

  1. 文本编辑器: 用于搜索文档中的字符串。
  2. 数据比对: 如版本控制系统中比对文件的不同版本。
  3. 防止抄袭: 检测文档中的剽窃情况。
  4. 生物信息学: 搜索DNA序列中的模式。
  5. 网络安全: 在一些网络安全应用中,如入侵检测系统中用于检测已知的恶意签名。

综上所述,Rabin-Karp 算法由于其平均情况下较高的效率和对多模式串搜索的支持,在许多涉及字符串查找的应用中被广泛使用。然而,选择合适的哈希函数来降低哈希碰撞的几率是实现这个算法时需要特别注意的地方。

Rabin-Karp Pattern Matching Algorithm | by Utsav Poudel | Level Up Coding

总结

IMG_0554

5.4 正则表达式

算法核心思路:

  1. 编译阶段:
    • 正则表达式首先被编译成一种内部表示,通常是一个状态机,比如确定性有限自动机(DFA)或非确定性有限自动机(NFA)。
    • 每个符号或符号组合(如字符集、量词等)在这个状态机中都有一个相应的状态或状态集。
  2. 匹配阶段:
    • 文本字符串从左到右扫描。
    • 对于每个字符,状态机尝试找到从当前状态通过该字符可以到达的下一个状态。
    • 如果有状态转移,状态机进入下一个状态;如果没有,它尝试其他路径或报告匹配失败。
  3. 回溯:
    • 在使用NFA时,可能会存在多条路径可以匹配。
    • 如果一个路径失败,算法将回溯到上一个决策点,并尝试另一条路径。
  4. 成功与失败:
    • 如果状态机到达一个接受状态,并且所有输入字符都被消耗,那么匹配成功。
    • 如果没有路径能够消耗所有输入字符,那么匹配失败。

非确定有限状态自动机

正则表达式使用的是NFA(非确定有限状态自动机)。

IMG_0556

当且仅当一个NFA从状态0开始从头读取了一段文本中的所有字符,进行了一系列状态转换并最终到达了接受状态时,称该NFA识别了一个文本字符串。

确定有限状态自动机(DFA, Deterministic Finite Automaton)和非确定有限状态自动机(NFA, Nondeterministic Finite Automaton)是两种用来识别字符串是否属于某个特定语言的计算模型。尽管它们在理论上是等价的——它们识别的语言类别是相同的(即正则语言)——但是在定义和操作上有一些显著的差异。

确定有限状态自动机(DFA)

在DFA中,对于每一个状态和输入符号,都有一个确定的状态转移。这意味着:

  1. 唯一性:对于自动机中的每一个状态和输入字母,都有一个且只有一个明确的转移状态。
  2. 无ε转移:DFA中不允许使用ε转移,也就是说,不允许在没有输入符号的情况下进行状态转移。
  3. 完备性:每个状态对于所有可能的输入符号都有一个转移状态定义。如果对于某个状态没有为某个输入定义下一个状态,那么这通常会被解释为转移到一个隐含的死状态,该状态不是接受状态,且从该状态出发没有任何转移能够到达接受状态。
  4. 确定性:在任何时刻,自动机的下一个状态是唯一确定的。

非确定有限状态自动机(NFA)

NFA在定义上比DFA更为宽松,因为它允许:

  1. 多重性:对于自动机中的某些状态和输入字母,可能有多个或零个转移状态。
  2. ε转移:NFA允许ε转移,也就是说,自动机可以在没有输入符号的情况下进行状态转移。
  3. 非完备性:并不要求每个状态对于所有可能的输入符号都有转移状态定义。如果没有定义转移,那么自动机在读入该符号时保持在原状态(不考虑ε转移)。
  4. 非确定性:在任何时刻,自动机的下一个状态可能是不确定的,它可能根据当前状态和输入符号转移到多个不同的状态。

转换和等价性

尽管在定义和操作上DFA和NFA不同,但它们是等价的,因为任何NFA都可以被转换成一个识别同一语言的DFA。这个转换是通过所谓的子集构造法实现的,它可能会导致DFA的状态数呈指数级增长,因为DFA必须表示NFA中的所有可能状态组合。然而,对于任何给定的正则语言,都存在一个最小化的DFA,它的状态数是所有识别该语言的DFA中最少的。

简而言之,DFA和NFA在表达力上是相同的,但在实际构造和理解上有不同的便利性:DFA通常更易于实现(比如在硬件中),而NFA则通常更简洁易于构造,特别是在正则表达式转换为自动机的情况下。

模拟NFA运行

有向图来表示自动机。

IMG_0558

构造与正则表达式对应的NFA

IMG_0559 IMG_0559 2 IMG_0560

5.5 数据压缩

压缩数据的原因:

  • 节省保存信息所需的空间;
  • 节省传输信息所需的时间。

任何数据压缩算法的效果都十分依赖输入的特征。

数据压缩是一种减少数据表示所需位数(bits)的技术,以减少存储空间和提高数据传输效率。数据压缩可分为两类:无损压缩和有损压缩。

  1. 无损压缩:在这种方法中,原始数据可以从压缩数据完全恢复。无损压缩技术适用于文本、程序代码和某些类型的图像和音频文件。常见的无损压缩算法包括哈夫曼编码、LZ77、LZ78及其变体(如LZW算法)。

  2. 有损压缩:与无损压缩不同,有损压缩在压缩过程中会丢失一些原始数据,因此无法完全还原原始数据。这种方法通常用于视频和音频数据,其中一些数据的丢失不会显著影响用户体验。JPEG(用于图像)和MP3(用于音频)是常见的有损压缩格式。

数据压缩的基本思想是去除或减少数据中的冗余。在文本文件中,这可能意味着替换重复出现的字符串;在图像文件中,可能意味着减少颜色的精确度或去除人眼不容易察觉的细节。

数据压缩对于存储和传输大量数据非常重要,尤其是在网络带宽有限或存储成本较高的情况下。然而,需要注意的是,压缩效率和数据质量之间往往存在权衡。有损压缩虽然可以实现更高的压缩率,但可能会牺牲数据的一部分质量。无损压缩则保留了数据的完整性,但压缩比通常低于有损压缩。

1699796467676

局限

通用性的数据压缩是不可能存在的。

数据流的已知结构:

  • 小规模的字母表;
  • 较长的连续相同的位或字符;
  • 频繁使用的字符;
  • 较长的连续重复的位或字符。

游程编码

游程编码(Run-Length Encoding, RLE)是一种简单的数据压缩技术,主要用于压缩包含大量连续重复数据的数据流。它通过将连续重复的数据元素替换为单个数据值和该值的重复次数来减少数据大小。这种技术在处理具有大量相同值的连续区域的数据(如单色位图图像)时特别有效。

工作原理

在游程编码中,连续出现的相同数据值被替换为两部分:一个是数据值本身,另一个是这个值重复的次数(称为“游程长度”)。例如,序列 “AAAABBBCCDAA” 可以被编码为 “4A3B2C1D2A”,表示字符 ‘A’ 连续出现了 4 次,字符 ‘B’ 连续出现了 3 次,依此类推。

应用

  1. 图像压缩:在图像处理中,特别是在处理不含太多细节的单色图像时,游程编码被广泛使用。例如,在扫描文档或简单的线条艺术中,游程编码可以有效地减少文件大小。

  2. 数据传输:在某些数据传输应用中,尤其是当传输的数据包含大量重复信息时,使用游程编码可以减少所需的带宽。

局限性

  1. 不适用于高熵数据:对于缺乏重复或具有高度随机性的数据(即高熵数据),游程编码可能不仅无法有效压缩数据,甚至可能导致数据膨胀。

  2. 简单性限制:游程编码是一种非常基础的压缩技术,它没有利用更高级的模式识别或预测算法,因此在许多复杂的数据集上效果有限。

  3. 特定应用场景:由于其特性,游程编码主要适用于某些特定类型的数据,如某些类型的图像或简单文本文件,而不适用于音频、视频或大多数类型的文档数据。

总的来说,虽然游程编码在某些应用中非常有效,但它是一种有其特定适用范围的压缩技术,对于复杂或高熵的数据集效果有限。

IMG_0584

霍夫曼压缩

霍夫曼编码(Huffman Coding)是一种广泛使用的无损数据压缩算法,由大卫·霍夫曼(David Huffman)在1952年提出。它是一种基于数据字符出现频率进行编码的算法,通常用于文本和图像压缩。霍夫曼编码的核心思想是使用较短的位序列表示频繁出现的字符,而用较长的位序列表示不常出现的字符。

工作原理

  1. 频率分析:首先分析数据集中各个字符的出现频率。

  2. 构建霍夫曼树:基于字符频率构建一个二叉树,其中每个叶子节点代表一个字符。树的构建过程是从叶子节点开始,将频率最低的两个节点组合成一个新节点,这个新节点的频率是它的两个子节点频率之和。重复这个过程直到构建出一个完整的树。

  3. 生成编码:根据霍夫曼树为每个字符生成一个唯一的二进制编码。从根节点到叶子节点的每一步,向左走代表0,向右走代表1。因此,频繁出现的字符将有更短的编码路径。

  4. 编码原始数据:使用生成的编码替换原始数据中的每个字符。

  5. 解码:接收方可以使用相同的霍夫曼树来解码接收到的二进制数据,恢复原始数据。

应用

霍夫曼编码在多种数据压缩应用中被广泛使用,包括文件压缩(如ZIP文件格式)和图像压缩(如JPEG格式的一部分)。它也常与其他压缩算法结合使用,以优化压缩效果。

局限性

  1. 预处理开销:霍夫曼编码需要先分析整个数据集来构建霍夫曼树,这可能会导致额外的计算开销,尤其是对于大型数据集。

  2. 不适合非重复数据:对于没有明显频率差异的数据或高熵数据,霍夫曼编码的压缩效果有限。

  3. 动态数据问题:对于实时或动态变化的数据,不断更新霍夫曼树可能会导致效率低下。

尽管存在这些局限性,霍夫曼编码仍是一种高效且广泛应用的无损压缩技术。

Huffman Coding | Greedy Algo-3 - GeeksforGeeks IMG_0585

单词查找树构建:

File:Huffman huff demo.gif - Wikimedia Commons

LZW压缩算法

LZW(Lempel-Ziv-Welch)压缩是一种流行且高效的无损数据压缩算法,由阿布拉罕·莱赫尔(Abraham Lempel)、雅各布·齐夫(Jacob Ziv)和特里·韦尔奇(Terry Welch)开发。LZW算法在1980年代初期被发明,并被广泛用于各种文件和图像格式,如GIF和TIFF。

工作原理

LZW压缩的核心思想是将输入数据流中的字符串序列替换为较短的代码。它通过构建一个从输入数据中学习的字符串字典来实现这一点,这个字典在压缩过程中动态生成。算法的基本步骤如下:

  1. 初始化字典:LZW算法开始时,字典被初始化为所有可能的单字符字符串。

  2. 处理输入数据:算法逐个读取输入数据中的字符,并尝试在字典中找到最长的匹配字符串。

  3. 更新字典:每次找到匹配时,算法将匹配字符串后的下一个字符加入到字典中,作为新的字符串序列。

  4. 输出代码:对于每个找到的匹配字符串,算法输出代表该字符串的字典索引。

  5. 重复:重复这个过程直到整个输入数据被处理完毕。

应用

LZW算法由于其高效的压缩率和简单的实现,被广泛应用于多种文件压缩和图像压缩格式。最著名的应用之一是GIF图像格式,LZW算法使得GIF在网络上的传输变得更加高效。

局限性

  1. 专利问题:LZW算法曾经受到专利保护,这限制了它的广泛应用。尽管这些专利现在已经过期,但在专利有效期间,这是一个重要的限制因素。

  2. 不适用于所有类型的数据:对于一些没有明显重复模式的数据(如已经压缩过的数据),LZW压缩的效率可能不是很高。

  3. 内存使用:由于字典的动态生成,LZW算法可能需要相对较多的内存来存储这些字典条目,尤其是在处理大型数据集时。

尽管有这些局限性,LZW压缩算法因其平衡的性能和效率,在数据压缩领域仍然占有一席之地。

压缩

LZW (Lempel–Ziv–Welch) Compression technique - GeeksforGeeks IMG_0587

展开

IMG_0588

对比

游程编码 (RLE)

  • 基本原理:将连续重复的数据用一个计数和该数据值来表示。例如,“AAAABBB”编码为“4A3B”。
  • 优点:实现简单,对于具有大量连续重复数据的场景(如某些类型的图像)效果很好。
  • 缺点:对于数据中重复较少或完全没有重复的数据效果不佳。
  • 适用场景:单色图像、简单的图形数据。

霍夫曼编码

  • 基本原理:基于字符频率进行变长编码。频繁出现的字符使用较短的编码,不频繁的使用较长的编码。
  • 优点:为不均匀分布的数据提供了很好的压缩效果,无损压缩,保留了原始数据的完整性。
  • 缺点:需要先分析整个数据集来构建编码树,对于实时压缩或数据流压缩可能不太适用。
  • 适用场景:文本数据、任何需要无损压缩的场景。

LZW压缩

  • 基本原理:动态构建一个字符串字典,将输入数据中的字符串序列替换为较短的代码。
  • 优点:不需要知道数据的先验统计信息,适合于动态数据和实时压缩,广泛应用于各种文件格式。
  • 缺点:对于已经高度压缩的数据或非常随机的数据,压缩效率可能不高。
  • 适用场景:图像文件(如GIF)、一些文档文件格式。

总结

  • 游程编码简单直观,适用于连续重复数据较多的场景。
  • 霍夫曼编码提供有效的无损压缩,特别适合非均匀分布的数据,如文本。
  • LZW压缩适合广泛的应用,特别是在不需要先验数据分析的场景中表现良好。

第6章 背景

B-树

定义:B树是2-3树的扩展。对于M阶的B树,每个节点最多含有M-1对键和链接,最少含有M/2对键和链接。根结点除外。

IMG_0591

B树和2-3树都是自平衡的搜索树结构,但它们在结构和操作上有一些关键的区别:

  1. 节点的子树数量:

    • B树:B树的节点可以有多个子树,其中节点的子树数量受到树的度数 ( T ) 的限制。对于度数为 ( T ) 的B树,每个节点最多有 ( 2T-1 ) 个键和 ( 2T ) 个子节点。
    • 2-3树:2-3树是B树的一个特例,具体是一个度数为2的B树。它的每个节点可以有2个或3个子节点(即节点可以是2-节点或3-节点)。2-节点包含一个键和两个子节点,而3-节点包含两个键和三个子节点。
  2. 键的数量和节点类型:

    • B树:B树的节点可以有不同数量的键,取决于树的度数。
    • 2-3树:2-3树的节点要么有一个键(2-节点),要么有两个键(3-节点)。
  3. 平衡操作:

    • B树:当插入或删除操作导致节点键的数量超出或低于允许的范围时,B树通过分裂过满的节点或合并/借用键来自平衡。
    • 2-3树:2-3树也通过分裂和合并节点来保持平衡,但其操作相对简单,因为节点类型只有两种。
  4. 应用场景:

    • B树:由于其灵活性和能够处理更高度数的节点,B树在实际应用中更为常见,尤其是在数据库和文件系统的索引结构中。
    • 2-3树:2-3树通常用于教学和理论研究,因为它提供了B树的一个简化模型。

总结来说,虽然2-3树可以看作是B树的一种特殊形式,但B树提供了更高的灵活性和适应性,使其更适合于处理大量数据的实际应用。

插入

IMG_0592

后缀数组

后缀数组(Suffix Array)是一种在字符串处理中非常重要的数据结构,它为给定字符串的所有后缀创建了一个有序数组。具体来说,对于一个字符串 ( S ),其后缀数组是一个整数数组 ( SA ),其中 ( SA[i] ) 表示在按字典序排序的 ( S ) 的所有后缀中排名为 ( i ) 的后缀的起始位置。

后缀数组通常与后缀树结合使用,因为它们能够更有效地占用存储空间,同时提供类似的功能。后缀数组的构建通常通过更复杂的后缀树来完成,然后将后缀树转换为后缀数组。

后缀数组的应用非常广泛,主要包括:

  1. 字符串搜索:后缀数组可以用来快速查找一个字符串是否为另一个字符串的子串。
  2. 字符串比较:可以使用后缀数组快速比较两个字符串。
  3. 最长公共前缀(LCP):通过后缀数组,可以高效地计算两个后缀的最长公共前缀。
  4. 数据压缩:在某些数据压缩算法中,后缀数组被用来找到重复的字符串,以减少存储空间。
  5. 生物信息学:在基因序列分析中,后缀数组被用于快速匹配DNA序列。

构建后缀数组是一个复杂的过程,通常涉及对字符串的所有后缀进行排序。高效的后缀数组构建算法如SA-IS、DC3等,可以在 ( O(n) ) 或 ( O(n \log n) ) 时间内完成,其中 ( n ) 是字符串的长度。

IMG_0629

网络流算法

IMG_0630

定义:一个网络流量是一张边的权重为正的加权有向图。一个st-流量网络有两个已知的顶点,即起点s和终点t。

Ford-Fulkerson算法

Ford-Fulkerson算法,也被称为增广路径算法

算法思路

  1. 初始化:最初,假设网络中的流量为零。
  2. 寻找增广路径:使用深度优先搜索(DFS)或广度优先搜索(BFS)在剩余网络中寻找从源点到汇点的路径。剩余网络是指原网络减去已有流量的网络。
  3. 流量调整:沿着找到的增广路径,计算可以增加的最大流量(即该路径上最小的剩余容量)。
  4. 更新流量:更新网络中的流量,增加找到的路径上的流量,同时减少相应的反向边上的流量(如果有的话)。
  5. 重复:重复步骤2到4,直到无法再找到增广路径为止。
  6. 输出最大流:此时网络中的流量配置就是最大流。

File:FordFulkerson.gif - Wikimedia Commons

File:FordFulkersonDemo.gif - Wikipedia
  • 20
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值