Data Structures Algorithms 简明教程
Overview
Data Structure(数据结构)是一种系统的方式,以便有效利用数据。以下术语是数据结构的基本术语。
-
Interface − 每个数据结构都有一个接口。接口表示数据结构支持的操作集。一个接口仅提供受支持操作的列表、它们可以接受的参数类型以及这些操作的返回类型。
-
Implementation - 实现提供了数据结构的内部表示。实现还提供了数据结构操作中使用的算法的定义。
Characteristics of a Data Structure
-
Correctness − 数据结构实现应该正确地实现其接口。
-
Time Complexity − 数据结构操作的运行时间或执行时间必须尽可能小。
-
Space Complexity − 数据结构操作的内存使用量应尽可能小。
Need for Data Structure
随着应用程序变得复杂且数据丰富,如今,应用程序面临着三个常见问题。
-
Data Search - 考虑一个商店的 100 万(106)件物品的库存。如果应用程序要搜索一件物品,则它必须每次在 100 万(106)件物品中搜索一件物品,这会减慢搜索速度。随着数据的增长,搜索将变得更加缓慢。
-
Processor speed - 处理器速度尽管非常高,如果数据增长到数十亿条记录,就会受到限制。
-
Multiple requests - 因为成千上万的用户可以在 Web 服务器上同时搜索数据,即使是速度最快的服务器在搜索数据时也会失败。
为了解决上述问题,数据结构应运而生。数据可以按数据结构组织,按这种方式,可能不需要搜索所有项目,并且可以几乎即时搜索所需数据。
Environment Setup
Local Environment Setup
如果你仍然愿意为 C 编程语言设置环境,则需要在计算机上有以下两个工具:(a)文本编辑器和(b)C 编译器。
Installation on UNIX/Linux
如果您使用 Linux or UNIX ,那么通过从命令行输入以下命令来检查您的系统上是否安装了 GCC -
$ gcc -v
如果你在自己的机器上安装了 GNU 编译器,则它应当打印如下消息:
Using built-in specs.
Target: i386-redhat-linux
Configured with: ../configure --prefix = /usr .......
Thread model: posix
gcc version 4.1.2 20080704 (Red Hat 4.1.2-46)
如果未安装 GCC,则您将不得不使用 https://gcc.gnu.org/install/ 中提供的详细说明自己安装它。
本教程是基于 Linux 编写的,并且所有给定的示例都已在 Linux 系统的 Cent OS 版本中编译。
Installation on Mac OS
如果你使用 Mac OS X,获取 GCC 的最简单方法是从 Apple 网站下载 Xcode 开发环境,并遵循简单的安装说明。一旦设置了 Xcode,你将能够使用 GNU 编译器针对 C/C++。
Xcode 当前可从 developer.apple.com/technologies/tools/ 获取。
Installation on Windows
要在 Windows 上安装 GCC,需要安装 MinGW。要安装 MinGW,请访问 MinGW 主页 www.mingw.org ,然后单击 MinGW 下载页面的链接。下载 MinGW 安装程序的最新版本,它应当命名为 MinGW-<版本>.exe。
安装 MinWG 时,您至少必须安装 gcc-core、gcc-g++、binutils 和 MinGW 运行时,但您可能希望安装更多。
将 MinGW 安装的 bin 子目录添加到您的 PATH 环境变量,以便您可以通过其简单名称在命令行中指定这些工具。
安装完成后,你将可以使用 Windows 命令行来运行 gcc、g++、ar、ranlib、dlltool 以及其他一些 GNU 工具。
Data Structure Basics
本章介绍数据结构相关的基本术语。
Data Definition
数据定义通过以下特征定义特定数据。
-
Atomic - 定义应当定义单个概念。
-
Traceable - 定义应当能够映射到某个数据元素。
-
Accurate − 定义应明确。
-
Clear and Concise − 定义应易于理解。
Data Structures and Types
Data structures 在编程语言中引入,目的是存储、组织和操作数据。它们的设计方式让访问和处理数据变得更加容易和简单。这些数据结构不仅限于一种特定的编程语言;它们只是一些在内存中构建数据的代码片段。
数据类型经常被误认为数据结构的一种类型,但即使它们被称为抽象数据类型,也不是完全正确的。数据类型表示数据类型,而数据结构只是一组类似或不同的数据类型。
通常只有两种类型的数据结构 −
-
Linear
-
Non-Linear
Linear Data Structures
数据存储在线性数据结构中按顺序。这些是基本结构,因为元素按顺序存储,而无需应用任何数学运算。
线性数据结构通常很容易实现,但由于内存分配可能变得复杂,时间和空间复杂度会增加。一些线性数据结构的示例包括 −
-
Arrays
-
Linked Lists
-
Stacks
-
Queues
基于数据存储方法,这些线性数据结构分为 static 和 dynamic 数据结构。
Array Data Structure
数组是一种线性数据结构,定义为具有相同或不同数据类型的元素集合。它们以单维和多维形式存在。当需要将多个类似性质的元素一起存储在同一个地方时,这些数据结构才会出现。
数组索引和内存地址之间的区别在于,数组索引充当键值来标记数组中的元素。然而,内存地址是可用空闲内存的起始地址。
以下是理解数组概念的重要术语。
-
Element - 存储在数组中的每个项称为一个元素。
-
Index - 数组中每个元素的位置都有一个数字索引,用于标识元素。
Syntax
在 C 和 C++ 编程语言中创建数组 −
data_type array_name[array_size] = {elements separated using commas}
or,
data_type array_name[array_size];
在 Java 编程语言中创建数组 −
data_type[] array_name = {elements separated by commas}
or,
data_type array_name = new data_type[array_size];
Need for Arrays
数组用作从小型排序问题到更为复杂问题(例如旅行商问题)的许多问题的解决方案。除了数组以外,还有许多能够为这些问题提供有效时间和空间复杂度的数据结构,那么使用数组有什么好处?答案在于随机访问查找时间。
数组提供了 O(1) 的随机访问查找时间。这意味着访问数组的第 1 个索引和数组的第 1000 个索引所需的时间都是一样的。这是由于数组带有指针和偏移值。指针指向内存的正确位置,偏移值表明在所述内存中查找距离。
array_name[index]
| |
Pointer Offset
因此,在包含 6 个元素的数组中,要访问第 1 个元素,数组指向第 0 个索引。类似地,要访问第 6 个元素,数组指向第 5 个索引。
Array Representation
数组表示为一系列存储桶,其中每个存储桶存储一个元素。这些存储桶的索引从“0”到“n-1”,其中 n 是该特定数组的大小。例如,大小为 10 的数组将具有索引为 0 到 9 的存储桶。
此索引也将类似于多维数组。如果它是一个二维数组,它将在每个存储桶中具有子存储桶。然后,它将被索引为 array_name[m][n],其中 m 和 n 是数组中每一级的尺寸。
根据上述说明,以下是要考虑的重要事项。
-
Index starts with 0.
-
数组长度为 9,这意味着它可以存储 9 个元素。
-
可以通过其索引访问每个元素。例如,我们可以获取索引 6 处的元素,如 23。
Basic Operations in the Arrays
数组中的基本操作是插入、删除、搜索、显示、遍历和更新。通常执行这些操作以修改数组中的数据或报告数组的状态。
数组支持的基本操作如下。
-
Traverse - 逐个打印所有数组元素。
-
Insertion - 在给定的索引处添加一个元素。
-
Deletion - 删除给定索引处的元素。
-
Search - 使用给定索引或按值搜索元素。
-
Update - 更新给定索引处的元素。
-
Display − 显示数组的内容。
在 C 中,当一个数组用大小初始化时,它会按以下顺序将其默认值分配给其元素。
Insertion Operation
在插入操作中,我们将一个或多个元素添加到数组。根据要求,可以在数组的开头、结尾或任何给定索引处添加新元素。这是使用编程语言的输入语句完成的。
Algorithm
以下是将元素插入线性阵列中的算法,直到我们到达数组的末尾 −
1. Start
2. Create an Array of a desired datatype and size.
3. Initialize a variable ‘i’ as 0.
4. Enter the element at ith index of the array.
5. Increment i by 1.
6. Repeat Steps 4 & 5 until the end of the array.
7. Stop
在这里,我们看到了插入操作的实际实现,我们在其中在数组的末尾添加数据 −
Example
对于数组插入操作的其他变体, click here 。
Deletion Operation
在此数组操作中,我们将从数组的特定索引处删除一个元素。当我们将后续索引中的值分配给当前索引时,就会发生此删除操作。
Search Operation
使用键在数组中搜索元素;键元素顺序比较数组中的每一个值,以检查键是否存在于数组中。
Traversal Operation
此操作会遍历数组的所有元素。我们使用循环语句完成此操作。
Linked List Data Structure
如果数组容纳的数据类型相似,则链表包含数据类型不同的元素,且按顺序排列。
但如何创建这些链表?
链表是通过链接连接在一起的“节点”集合。这些节点包含要存储的数据以及链表中下一个节点地址的指针。在数组中,大小限制在定义中,但在链表中没有定义的大小。任何数量的数据都可以存储在链表中并可从中删除。
有三种类型的链表−
-
Singly Linked List − 节点仅指向列表中下一个节点的地址。
-
Doubly Linked List − 节点指向先前和下一个节点的地址。
-
Circular Linked List − 列表中的最后一个节点将指向列表中的第一个节点。它可以是单向链接或双向链接。
Linked List Representation
可以将链表可视化为一个节点链,其中每个节点指向下一个节点。
根据上述说明,以下是要考虑的重要事项。
-
链表包含称为 first(头)的链接元素。
-
每个链接包含数据字段和称为 next 的链接字段。
-
每条链接使用其 next 链接与其下一个链接相连。
-
最后一个链接包含一个链接为 null,以标记列表的结尾。
Types of Linked List
以下为不同类型的链表。
Singly Linked Lists
单链表在每个节点中包含两个“存储单元”;一个存储单元保存数据,而另一个存储单元保存列表中下一个节点的地址。只能向一个方向遍历,因为同一列表的两个节点之间只存在一个链接。
Basic Operations in the Linked Lists
链表中的基本操作包括插入、删除、搜索、显示以及删除指定关键处的元素。这些操作在单向链表中执行,如下所示:
-
Insertion - 在列表开头添加一个元素。
-
Deletion - 删除列表开头的一个元素。
-
Display - 显示完整列表。
-
Search - 使用指定的关键搜索元素。
-
Delete - 使用指定的关键删除元素。
Insertion Operation
在链表中添加一个新节点是多步活动。我们将在此处借助图表了解此内容。首先,使用相同结构创建一个节点,并找到要插入该节点的位置。
想象一下,我们在 A(LeftNode)和 C(RightNode)之间插入一个节点 B(NewNode)。然后将 B.next 指向 C -
NewNode.next −> RightNode;
它应该看起来像这样 -
现在,左侧的下一个节点应该指向新节点。
LeftNode.next −> NewNode;
这会将新节点放在两个节点中间。新列表应该看起来像这样 -
可以在链表中以三种不同的方式进行插入。解释如下:
Algorithm
1. START
2. Create a node to store the data
3. Check if the list is empty
4. If the list is empty, add the data to the node and assign the head pointer to it.
5 If the list is not empty, add the data to a node and link to the current head. Assign the head to the newly added node.
6. END
Algorithm
1. START
2. Create a new node and assign the data
3. Find the last node
4. Point the last node to new node
5. END
Deletion Operation
删除也是一个多步过程。我们将以图片表示形式进行学习。首先,使用搜索算法找到要删除的目标节点。
现在,目标节点的左侧(前一个)节点应该指向目标节点的下一个节点 -
LeftNode.next −> TargetNode.next;
这将删除指向目标节点的链接。现在,使用以下代码,我们将删除目标节点指向的内容。
TargetNode.next −> NULL;
我们需要使用已删除的节点。我们可以将它保留在内存中,否则我们可以简单地释放内存并完全擦除目标节点。
如果节点被插入列表的开头,则应采取类似的步骤。在将其插入末尾时,列表的倒数第二个节点应指向新节点,并且新节点将指向 NULL。
链表中的删除也通过三种不同的方式执行。它们如下 -
Algorithm
1. START
2. Iterate until you find the second last element in the list.
3. Assign NULL to the second last element in the list.
4. END
Reverse Operation
此操作是全面的。我们需要使最后一个节点由头节点指向并反转整个链表。
首先,我们遍历到列表的末尾。它应指向 NULL。现在,我们将其指向其前一个节点 -
我们必须确保最后一个节点不是最后一个节点。因此,我们将拥有一个 temp 节点,它看起来像指向最后一个节点的头节点。现在,我们将使所有左侧节点逐一指向其前一个节点。
除了由头节点指向的节点(第一个节点),所有节点都应指向其前驱,使其成为其新的后继。第一个节点将指向 NULL。
我们将使头节点使用 temp 节点指向新的第一个节点。
Search Operation
使用键元素在列表中搜索元素。此操作与数组搜索相同;比较列表中的每个元素与给定的键元素。
Doubly Linked List Data Structure
双向链表是链表的一个变体,与单向链表相比,双向链表可以轻松地双向导航。以下是理解双向链表概念的一些重要术语。
-
Link - 链表的每个链接都可以存储一个称为元素的数据。
-
Next - 链表的每个链接都包含一个链接到下一个链接,称为 Next。
-
Prev - 链表的每个链接都包含一个链接到前一个链接,称为 Prev。
-
Linked List − 链表包含连接到称为 First 的第一个链接和称为 Last 的最后一个链接的连接链接。
Doubly Linked List Representation
根据上述说明,以下是要考虑的重要事项。
-
双链表包含称为 first 和 last 的链接元素。
-
每个链接包含数据字段和称为 next 的链接字段。
-
每条链接使用其 next 链接与其下一个链接相连。
-
每条链接使用其 prev 链接与其前一个链接相连。
-
最后一条链接带有 null 作为链接,以标记列表的末尾。
Basic Operations
以下是列表支持的基本操作。
-
Insertion - 在列表开头添加一个元素。
-
Deletion - 删除列表开头的一个元素。
-
Insert Last − 在列表末尾添加一个元素。
-
Delete Last − 从列表末尾删除一个元素。
-
Insert After − 在列表项后添加一个元素。
-
Delete − 使用键从列表中删除元素。
-
Display forward − 以向前的方式显示完整列表。
-
Display backward − 以向后方式显示完整列表。
Algorithm
1. START
2. Create a new node with three variables: prev, data, next.
3. Store the new data in the data variable
4. If the list is empty, make the new node as head.
5. Otherwise, link the address of the existing first node to the next variable of the new node, and assign null to the prev variable.
6. Point the head to the new node.
7. END
Algorithm
1. START
2. Check the status of the doubly linked list
3. If the list is empty, deletion is not possible
4. If the list is not empty, the head pointer is shifted to the next node.
5. END
Circular Linked List Data Structure
Circular Linked List 是链表的一个变体,其中第一个元素指向最后一个元素,而最后一个元素指向第一个元素。单向链表和双向链表都可以变成循环链表。
Doubly Linked List as Circular
在双向链表中,最后一个节点的下一个指针指向第一个节点,而第一个节点的前一个指针指向最后一个节点,使两者都成为循环。
根据上述说明,以下是要考虑的重要事项。
-
单链表和双向链表的最后链接的下一个指针在两种情况下都指向列表的第一个链接。
-
在双向链表的情况下,第一个链接的前一个指针指向列表的最后一个指针。
Basic Operations
循环列表支持以下重要操作。
-
insert − 在列表开头插入一个元素。
-
delete − 从列表开头删除一个元素。
-
display − 显示列表。
Algorithm
1. START
2. Check if the list is empty
3. If the list is empty, add the node and point the head to this node
4. If the list is not empty, link the existing head as the next node to the new node.
5. Make the new node as the new head.
6. END
Algorithm
1. START
2. If the list is empty, then the program is returned.
3. If the list is not empty, we traverse the list using a current pointer that is set to the head pointer and create another pointer previous that points to the last node.
4. Suppose the list has only one node, the node is deleted by setting the head pointer to NULL.
5. If the list has more than one node and the first node is to be deleted, the head is set to the next node and the previous is linked to the new head.
6. If the node to be deleted is the last node, link the preceding node of the last node to head node.
7. If the node is neither first nor last, remove the node by linking its preceding node to its succeeding node.
8. END
Stack Data Structure
栈是一种抽象数据类型 (ADT),在大多数编程语言中都很流行。它之所以被称为栈,是因为它具有与现实世界栈类似的操作,例如一叠卡片或一堆盘子等。
栈遵循后进先出的 (LIFO) 结构,其中插入的最后一个元素将成为第一个被删除的元素。
Stack Representation
栈 ADT 只允许在一端进行所有数据操作。在任何给定时间,我们只能访问栈的顶部元素。
下图描绘了一个栈及其操作:
可以通过数组、结构、指针和链表来实现栈。栈可以是固定大小的,也可以具有动态调整大小的能力。这里,我们要使用数组来实现栈,这使其成为固定大小的栈实现。
Basic Operations on Stacks
栈操作通常用于执行栈 ADT 的初始化、使用和去初始化。
栈 ADT 中最基本的运算包括:push()、pop()、peek()、isFull()、isEmpty()。这些都是用于执行数据操作和检查栈状态的内置运算。
栈使用始终指向栈内最顶层元素的指针,因此称为 top 指针。
Algorithm
1 − Checks if the stack is full.
2 − If the stack is full, produces an error and exit.
3 − If the stack is not full, increments top to point next empty space.
4 − Adds data element to the stack location, where top is pointing.
5 − Returns success.
Algorithm
1 − Checks if the stack is empty.
2 − If the stack is empty, produces an error and exit.
3 − If the stack is not empty, accesses the data element at which top is pointing.
4 − Decreases the value of top by 1.
5 − Returns success.
Algorithm
1. START
2. If the size of the stack is equal to the top position of the stack, the stack is full. Return 1.
3. Otherwise, return 0.
4. END
Expression Parsing
编写算术表达式的方法称为 notation 。算术表达式可以用三个不同但等效的符号编写,即不改变表达式的本质或输出。这些符号是 −
-
Infix Notation
-
Prefix (Polish) Notation
-
Postfix (Reverse-Polish) Notation
这些符号以它们在表达式中如何使用运算符命名。我们将在本章中学习同样的内容。
Infix Notation
我们在 infix 符号中编写表达式,例如 a - b + c,其中运算符用于在操作数之间 in 。对于我们人类来说,阅读、编写和用中缀表示法说话很容易,但对于计算设备来说却并非如此。处理中缀表示法的算法在时间和空间消耗方面可能既困难又昂贵。
Prefix Notation
在此表示法中,运算符是 prefix*ed to operands, i.e. operator is written ahead of operands. For example, *+ab 。这等同于它的中缀表示法 a + b 。前缀表示法也称为 Polish Notation 。
Postfix Notation
这种符号样式称为 Reversed Polish Notation 。在这种符号样式中,运算符是 postfix*ed to the operands i.e., the operator is written after the operands. For example, *ab+ 。这等同于它的中缀表示法 a + b 。
下表简要尝试显示所有三种符号之间的差异 −
Parsing Expressions
正如我们所讨论的,设计一个算法或程序来解析中缀符号并不是一种非常有效的方法。相反,这些中缀表示法首先转换为后缀或前缀表示法,然后计算。
为了解析任何算术表达式,我们还需要注意运算符的优先级和结合性。
Precedence
当一个操作数位于两个不同的运算符之间时,首先取得操作数的运算符是由一个运算符对另一个运算符的优先级决定的。例如 −
由于乘法运算优先级高于加法,因此 b * c 将首先得到评估。运算符优先级表将在后面提供。
Associativity
关联性描述了具有相同优先级的运算符在表达式中出现时的规则。例如,在表达式 a + b- c 中,+ 和 - 都具有相同的优先级,那么表达式的哪一部分将首先被求值,将由这些运算符的关联性决定。这里,+ 和 - 都是左关联的,因此表达式将求值为 (a + b) − c 。
优先级和结合性决定了表达式求值顺序。以下是运算符优先级和结合性表(从最高到最低)-
上表显示了运算符的默认行为。在表达式求值的任何时间点,都可以通过使用括号来更改顺序。例如 −
在 a + b*c 中,表达式部分 b * c 将首先得到评估,其中乘法的优先级高于加法。我们在这里使用括号来使 a + b 首先得到评估,例如 (a + b)*c 。
Postfix Evaluation Algorithm
现在,我们将研究如何评估后缀表示法的算法 −
Step 1 − scan the expression from left to right
Step 2 − if it is an operand push it to stack
Step 3 − if it is an operator pull operand from stack and perform operation
Step 4 − store the output of step 3, back to stack
Step 5 − scan the expression until all operands are consumed
Step 6 − pop the stack and perform operation
Queue Data Structure
队列与栈类似,也是一种抽象数据结构。使队列与栈不同的一件事是队列两端都是开放的。因此,它遵循先进先出 (FIFO) 结构,即首先插入的数据项也将首先被访问。数据通过一端插入队列,并从另一端删除 。
队列的实际示例可以是单车道单行道,其中的车辆先进入,先退出。更多实际示例可以看作是售票窗口和公共汽车站的队列。
Basic Operations
队列操作还包括队列的初始化、使用和永久从内存中删除数据。
队列 ADT 中最基本的操作包括:enqueue()、dequeue()、peek()、isFull()、isEmpty()。这些都是内置操作,用于执行数据操作和检查队列的状态。
队列使用两个指针: front 和 rear 。前指针从前端访问数据(帮助入队),而后指针从后端访问数据(帮助出队)。
Algorithm
1 − START
2 – Check if the queue is full.
3 − If the queue is full, produce overflow error and exit.
4 − If the queue is not full, increment rear pointer to point the next empty space.
5 − Add data element to the queue location, where the rear is pointing.
6 − return success.
7 – END
Algorithm
1 – START
2 − Check if the queue is empty.
3 − If the queue is empty, produce underflow error and exit.
4 − If the queue is not empty, access the data where front is pointing.
5 − Increment front pointer to point to the next available data element.
6 − Return success.
7 – END
Algorithm
1 – START
2 – If the count of queue elements equals the queue size, return true
3 – Otherwise, return false
4 – END
Graph Data Structure
图是一种抽象数据类型 (ADT),它由一组通过链接相互连接的对象组成。这些对象称为 vertices ,而链接称为 edges 。
通常,一个图表示为 G = {V,E},其中 G 是图空间,V 是顶点集合,E 是边集合。如果 E 为空,则该图被称为 forest 。
在我们继续之前,让我们熟悉一些重要的术语 −
-
Vertex − 图的每个节点都表示为一个顶点。在以下示例中,标记的圆圈表示顶点。因此,A 到 G 是顶点。我们可以使用一个数组来表示它们,如下面的图像所示。这里 A 可以通过索引 0 标识。B 可以使用索引 1 进行标识,依此类推。
-
Edge − 边表示两个顶点之间的路径或两条顶点之间的线。在以下示例中,从 A 到 B,从 B 到 C 的线等表示边。我们可以使用一个二维数组来表示一个数组,如下面的图像所示。这里 AB 可以在第 0 行,第 1 列中表示为 1,BC 在第 1 行,第 2 列中表示为 1,依此类推,而其他组合保持为 0。
-
Adjacency − 如果两个节点或顶点通过一条边相互连接,则它们是相邻的。在以下示例中,B 邻接到 A,C 邻接到 B,依此类推。
-
Path − 路径表示两个顶点之间的边缘序列。在以下示例中,ABCD 表示从 A 到 D 的路径。
Operations of Graphs
图的主要操作包括使用顶点和边创建图,并显示所述图。但是,使用图执行的最常见和最流行的操作之一是遍历,即按特定顺序访问图的每个顶点。
图中有两种类型的遍历 −
-
Depth First Search Traversal
-
Breadth First Search Traversal
Representation of Graphs
在表示图表时,我们必须仔细描绘图表中存在的元素(顶点和边)以及它们之间的关系。在图片上,用一组有限的节点和它们之间的连接链接来表示一个图表。但是,我们也可以用其他最常用的方式来表示图表,例如 −
-
Adjacency Matrix
-
Adjacency List
Types of graph
有两种基本类型的图——
-
Directed Graph
-
Undirected Graph
有向图,顾名思义,由具有从顶点指向外部或指向顶点的方向的边线组成。无向图有完全无方向的边线。
Directed Graph
Undirected Graph
Spanning Tree
spanning tree 是无向图的一个子集,它包含图中所有用图中最少数量的边线连接的顶点。精确地说,生成树的边线是原始图中的边线的一个子集。
如果图中的所有顶点是连接的,那么至少存在一棵生成树。在一个图中,可能存在多棵生成树。
Minimum Spanning Tree
Minimum Spanning Tree (MST) 是连通的加权无向图的边线的子集,它用最小的总边线权重将所有顶点连接在一起。为了推导 MST,可以使用 Prim 算法或 Kruskal 算法。因此,我们将在本章讨论 Prim 算法。
正如我们所讨论的,一个图可能有多棵生成树。如果有 n 个顶点,生成树应有 n−1 个边线。在此上下文中,如果图的每个边线都与一个权重相关联,并且存在多棵生成树,我们需要找到图的最小生成树。
此外,如果存在任何重复的加权边线,图可能有多个最小生成树。
在上述图中,我们展示了一棵生成树,尽管它不是最小生成树。这棵生成树的成本是 (5+7+3+3+5+8+3+4)=38 。
Depth First Traversal
深度优先搜索 (DFS) 算法以深度方向遍历图,并使用栈来记住以在任何迭代中发生死锁时获取要开始搜索的下一个顶点。
正如上面给出的示例中,DFS 算法首先从 S 到 A 到 D 到 G 到 E 到 B,然后到 F,最后到 C。它采用以下规则。
-
Rule 1 - 访问邻接的未访问顶点。将其标记为已访问。显示它。将其推入堆栈。
-
Rule 2 - 如果未找到邻接顶点,则从堆栈弹出一个顶点。(它将弹出堆栈中所有没有邻接顶点的顶点。)
-
Rule 3 - 重复规则 1 和规则 2,直到堆栈为空。
由于 C 没有任何未访问的邻接节点,因此我们将继续弹出堆栈,直到找到具有未访问邻接节点的节点。在这种情况下,没有,并且我们一直弹出,直到堆栈为空。
Spanning Tree
生成树是图 G 的一个子集,它以尽可能少数量的边覆盖所有顶点。因此,生成树没有回路并且不能断开连接。
根据这个定义,我们可以得出结论:每个相连且无向的图 G 至少有一个生成树。断开连接的图没有任何生成树,因为它无法跨越到其所有顶点。
我们从一个完全图中找到了三个生成树。一个完整的无向图可以有 nn-2 个生成树,其中 n 是节点数。在上述示例中, n is 3, 因此 33−2 = 3 生成树是可能的。
General Properties of Spanning Tree
我们现在明白一个图可以有多个生成树。以下是与图 G 相连的生成树的一些性质 −
-
连通图 G 可以有多个生成树。
-
图 G 所有的可能的生成树具有相同的边和顶点数。
-
生成树没有任何回路(环)。
-
从生成树中移除一条边会使图断开连接,即生成树为 minimally connected 。
-
向生成树中添加一条边会创建一个回路或环,即生成树为 maximally acyclic 。
Mathematical Properties of Spanning Tree
-
生成树有 n-1 条边,其中 n 是节点(顶点)数。
-
通过从一个完全图中移除 e - n + 1 条边,我们能构建一个生成树。
-
一个完全图可以有最多 nn-2 个生成树。
因此,我们可以得出结论:生成树是连通图 G 的一个子集,断开连接的图没有生成树。
Application of Spanning Tree
生成树本质上用于查找一条最短路径以连接图中的所有节点。生成树的常见应用包括 −
-
Civil Network Planning
-
Computer Network Routing Protocol
-
Cluster Analysis
让我们通过一个小例子来理解一下。考虑城市网络是一个巨大的图,现在计划以一种方式布设电话线路,即,我们可以用最少的线路连接到所有城市节点。这就是生成树派上用场的地方。
Minimum Spanning Tree (MST)
在一个带权重的图中,极小生成树是生成树,其权重比相同图形的所有其他生成树的权重都小。在现实世界中,此权重可以衡量为距离、拥堵、交通负荷或赋予边的任何任意值。
Tree Data Structure
树是一个具有基于层次结构的非线性抽象数据类型。它包括通过链接连接的存储数据的节点。树数据结构源自一个称为根节点的单个节点,并具有连接到根节点的子树。
Important Terms
以下是关于树的一些重要术语。
-
Path - 路径是指沿着树的边的一系列节点。
-
Root - 树顶部的节点称为根。每棵树只有一个根,且从根节点到任何节点只有一条路径。
-
Parent - 除了根节点之外的任何节点向上只有一个边到一个称为父节点的节点。
-
Child - 通过其向下边缘连接在给定节点之下的节点称为其子节点。
-
Leaf - 没有子节点的节点称为叶节点。
-
Subtree - 子树表示节点的后代。
-
Visiting - 访问是指在控件位于节点上时检查节点的值。
-
Traversing − 遍历指按照特定顺序经过节点。
-
Levels - 节点的级别表示节点的代。如果根节点位于第 0 级,则其下一个子节点位于第 1 级,而其孙节点位于第 2 级,依此类推。
-
Keys - 键表示节点的值,基于该值对节点执行搜索操作。
Types of Trees
有三种类型的树:
-
General Trees
-
Binary Trees
-
Binary Search Trees
Binary Trees
二叉树是在其中根节点最多只能容纳 2 个子树(左子树和右子树)的通用树。根据子节点的数量,二叉树分为三类。
Full Binary Tree
-
满二叉树是一种二叉树类型,其中每个节点要么有 0 个,要么有 2 个子节点。
Complete Binary Tree
-
完全二叉树是一种二叉树类型,其中所有的叶子节点必须位于同一层级。然而,完全二叉树中的根节点和内部节点可以有 0 个、1 个或 2 个子节点。
Perfect Binary Tree
-
完美二叉树是一种二叉树类型,其中所有的叶子节点都位于同一层级,且除叶子节点外的每个节点都有 2 个子节点。
Binary Search Trees
二叉查找树具有二叉树的所有属性,还包括基于一些约束的额外属性,使其比二叉树更高效。
二叉查找树 (BST) 中的数据总是以这样的方式存储:左子树中的值始终小于根节点中的值,右子树中的值始终大于根节点中的值,即左子树 < 根节点 ≤ 右子树。
Advantages of BST
-
二叉查找树比二叉树更有效率,因为执行各种操作的时间复杂度降低了。
-
由于键的顺序仅基于父节点,因此搜索操作变得更简单。
-
BST 的对齐方式也支持范围查询,该查询用于查找存在于两个键之间的值。这有助于数据库管理系统。
Disadvantages of BST
二叉查找树的主要缺点是,如果节点中的所有元素都大于或小于根节点,* 则树会变得不平衡 *。简而言之,树会完全向一侧倾斜。
此 skewness 会使树成为链表而不是 BST,因为搜索操作的最坏情况时间复杂度变为 O(n)。
为了克服二叉查找树中的这种不平衡问题,引入了 Balanced Binary Search Trees 的概念。
Tree Traversal
遍历是一个访问树的所有节点并可能打印其值的过程。因为,所有节点都通过边(链接)连接,所以我们总是从根(头)节点开始。也就是说,我们无法随机访问树中的节点。有三种方法可以遍历树 -
-
In-order Traversal
-
Pre-order Traversal
-
Post-order Traversal
通常,我们遍历一棵树以搜索或定位树中给定的项目或键,或打印它包含的所有值。
In-order Traversal
在这种遍历方法中,首先访问左子树,然后访问根,最后访问右子树。我们应该始终记住,每个节点本身都可能表示一个子树。
如果遍历一棵二叉树 in-order ,输出将按升序生成排序的键值。
从 A 开始,按照中序遍历,我们将移动到它的左子树 B 。 B 也以中序遍历。此过程一直持续到所有节点都已访问。这棵树的中序遍历的输出将是−
D → B → E → A → F → C → G
Pre-order Traversal
在这种遍历方法中,首先访问根节点,然后访问左子树,最后访问右子树。
从 A 开始,并按照先序遍历,我们首先访问 A 本身,然后移动到它的左子树 B 。 B 也以先序遍历。此过程一直持续到所有节点都已访问。这棵树的先序遍历的输出将是−
A → B → D → E → C → F → G
Binary Search Tree
二叉查找树(BST)是其中所有节点都符合以下所述属性的树——
-
节点的左子树的键小于或等于其父节点的键。
-
节点的右子树的键大于或等于其父节点的键。
因此,BST 将其所有子树划分为两个部分;左子树和右子树,并且可以定义为——
left_subtree (keys) ≤ node (key) ≤ right_subtree (keys)
Representation
BST 是按照可以维持 BST 属性的方式排列的一组节点。每个节点都有一个键和一个相关值。在搜索时,将所需的键与 BST 中的键比较,如果找到,则检索相关值。
以下是 BST 的图像表示——
我们观察到,根节点键(27)在左子树上具有所有值较小的键,在右子树上具有值较大的键。
Basic Operations
以下是树的基本操作——
-
Search - 在树中搜索一个元素。
-
Insert - 在树中插入一个元素。
-
Pre-order Traversal - 以先序方式遍历树。
-
In-order Traversal - 以中序方式遍历树。
-
Post-order Traversal - 以后序方式遍历树。
Defining a Node
定义一个节点,该节点存储一些数据以及对其左子节点和右子节点的引用。
struct node {
int data;
struct node *leftChild;
struct node *rightChild;
};
Search Operation
每当要搜索一个元素时,从根节点开始搜索。然后,如果数据小于键值,则在左子树中搜索元素。否则,在右子树中搜索该元素。对每个节点遵循相同的算法。
Algorithm
1. START
2. Check whether the tree is empty or not
3. If the tree is empty, search is not possible
4. Otherwise, first search the root of the tree.
5. If the key does not match with the value in the root, search its subtrees.
6. If the value of the key is less than the root value, search the left subtree
7. If the value of the key is greater than the root value, search the right subtree.
8. If the key is not found in the tree, return unsuccessful search.
9. END
Insert Operation
每当要插入一个元素时,首先找到它的适当位置。从根节点开始搜索,然后,如果数据小于键值,则在左子树中搜索空位置并插入该数据。否则,在右子树中搜索空位置并插入该数据。
Algorithm
1 – START
2 – If the tree is empty, insert the first element as the root node of the tree. The following elements are added as the leaf nodes.
3 – If an element is less than the root value, it is added into the left subtree as a leaf node.
4 – If an element is greater than the root value, it is added into the right subtree as a leaf node.
5 – The final leaf nodes of the tree point to NULL values as their child nodes.
6 – END
Inorder Traversal
二叉查找树中的中序遍历操作按照以下顺序访问其所有节点——
-
首先,遍历根节点/当前节点的左子节点(如果存在)。
-
接下来,遍历当前节点。
-
最后,如果存在,遍历当前节点的右侧子树。
AVL Trees
要发明的第一种自平衡二叉搜索树是 AVL 树。AVL 树的名称是由其发明者的名字命名的 − Adelson-Velsky 和 Landis。
在 AVL 树中,左右子树的高度差,称为 Balance Factor ,最多必须为 1。一旦差值超过 1,该树会自动执行平衡算法,直至差值再次变为 1。
BALANCE FACTOR = HEIGHT(LEFT SUBTREE) – HEIGHT(RIGHT SUBTREE)
在 AVL 树的平衡算法中通常有四种旋转情况:LL、RR、LR、RL。
LL Rotations
当将节点插入导致不平衡树的右子树时,执行 LL 旋转。这是一个单一的左旋转,再次使树平衡 −
Fig : LL Rotation
发生不平衡的节点变为左子节点,新添加的节点变为右子节点,中间节点变为父节点。
RR Rotations
当将节点插入导致不平衡树的左子树时,执行 RR 旋转。这是一个单一的右旋转,再次使树平衡 −
Fig : RR Rotation
发生不平衡的节点变为右子节点,新添加的节点变为左子节点,中间节点变为父节点。
LR Rotations
LR 旋转是之前单一旋转的扩展版本,也称为双旋转。当将节点插入左子树的右子树中时,执行此操作。LR 旋转将左旋转与右旋转相结合。有几个需要遵循的步骤来执行此操作。
-
考虑一个示例,其中“A”为根节点,“B”为“A”的左子节点,“C”为“B”的右子节点。
-
由于不平衡发生在 A 处,因此对 A 的子节点 B 和 C 应用左旋转。
-
旋转后,C 节点变为 A 的左子节点,B 变为 C 的左子节点。
-
不平衡仍然存在,因此在根节点 A 和左子节点 C 处应用右旋转。
-
经过最终的右旋转,C 变为根节点,A 变为右子节点,B 为左子节点。
Fig : LR Rotation
RL Rotations
RL 旋转也是先前单旋转的扩展版本,因此它被称为双旋转,并且当将一个节点插入右子树的左子树中时执行该旋转。RL 旋转是右旋转和左旋转的组合。需要遵循多个步骤来执行此操作。
-
考虑一个示例,“A”作为根节点,“B”作为“A”的右子节点,“C”作为“B”的左子节点。
-
由于不平衡发生在 A 处,因此对 A 的子节点(即 B 和 C)应用右旋转。
-
旋转后,C 节点变成 A 的右子节点,B 变成 C 的右子节点。
-
不平衡仍然存在,因此在根节点 A 和右子节点 C 处应用左旋转。
-
在最后一次左旋转之后,C 成为根节点,A 成为左子节点,B 成为右子节点。
Fig : RL Rotation
Basic Operations of AVL Trees
对 AVL 树结构执行的基本操作包括对二叉查找树执行的所有操作,因为 AVL 树的核心实际上只是一个保存其所有属性的二叉查找树。因此,对 AVL 树执行的基本操作为 Insertion 和 Deletion 。
Insertion
通过遵循插入的二叉查找树属性将数据插入 AVL 树,即左子树必须包含小于根值的值,右子树必须包含所有更大的值。然而,在 AVL 树中,在插入每个元素后,都会检查树的平衡因子;如果它不超过 1,则树保持原样。但是,如果平衡因子超过 1,则应用平衡算法来调整树,使得平衡因子再次小于或等于 1。
Algorithm
AVL 树的插入操作涉及以下步骤 -
Step 1 − 创建一个节点
Step 2 − 检查树是否为空
Step 3 − 如果树为空,则创建的新节点将成为 AVL 树的根节点。
Step 4 − 如果树不为空,则执行二叉查找树插入操作,并检查树中节点的平衡因子。
Step 5 − 假设平衡因子超过 ±1,我们对所述节点应用合适的旋转,并从第 4 步继续插入。
START
if node == null then:
return new node
if key < node.key then:
node.left = insert (node.left, key)
else if (key > node.key) then:
node.right = insert (node.right, key)
else
return node
node.height = 1 + max (height (node.left), height (node.right))
balance = getBalance (node)
if balance > 1 and key < node.left.key then:
rightRotate
if balance < -1 and key > node.right.key then:
leftRotate
if balance > 1 and key > node.left.key then:
node.left = leftRotate (node.left)
rightRotate
if balance < -1 and key < node.right.key then:
node.right = rightRotate (node.right)
leftRotate (node)
return node
END
Insertion Example
让我们通过构建一个包含 1 到 7 个整数的示例 AVL 树来理解插入操作。
从第一个元素 1 开始,我们创建一个节点并测量平衡,即 0。
由于二叉查找属性和平衡因子都得到满足,我们再向树中插入另一个元素。
计算两个节点的平衡因子,发现为 -1(左子树的高度为 0,右子树的高度为 1)。由于它没有超过 1,我们向树中添加另一个元素。
现在,添加第三个元素后,平衡因子超过 1 并变成 2。因此,应用旋转。由于不平衡发生在两个右侧节点上,所以在这种情况下应用 RR 旋转。
树被重新排列为 −
类似地,使用这些旋转插入并重新排列下一个元素。重新排列后,我们实现树为 −
Deletion
AVL 树中的删除发生在三种不同的情况下 −
-
Scenario 1 (Deletion of a leaf node) − 如果要删除的节点是叶节点,则删除它而不进行任何替换,因为它不会干扰二叉搜索树属性。然而,平衡因子可能会被破坏,因此应用旋转来恢复它。
-
Scenario 2 (Deletion of a node with one child) − 如果要删除的节点有一个子节点,则用其子节点中的值替换该节点中的值。然后删除子节点。如果平衡因子被破坏,则应用旋转。
-
Scenario 3 (Deletion of a node with two child nodes) − 如果要删除的节点有两个子节点,则找到该节点的中序后继并用中序后继值替换其值。然后尝试删除中序后继节点。如果删除后平衡因子超过 1,则应用平衡算法。
START
if root == null: return root
if key < root.key:
root.left = delete Node
else if key > root.key:
root.right = delete Node
else:
if root.left == null or root.right == null then:
Node temp = null
if (temp == root.left)
temp = root.right
else
temp = root.left
if temp == null then:
temp = root
root = null
else
root = temp
else:
temp = minimum valued node
root.key = temp.key
root.right = delete Node
if (root == null) then:
return root
root.height = max (height (root.left), height (root.right)) + 1
balance = getBalance
if balance > 1 and getBalance (root.left) >= 0:
rightRotate
if balance > 1 and getBalance (root.left) < 0:
root.left = leftRotate (root.left);
rightRotate
if balance < -1 and getBalance (root.right) <= 0:
leftRotate
if balance < -1 and getBalance (root.right) > 0:
root.right = rightRotate (root.right);
leftRotate
return root
END
Deletion Example
使用上面给出的同一棵树,让我们在三种情况下执行删除 −
-
从上面这棵树中删除元素 7 −
由于元素 7 是一个叶节点,我们通常在不干扰树中的任何其他节点的情况下删除该元素
-
从获得的输出树中删除元素 6 −
然而,元素 6 不是一个叶节点,并且有一个子节点与之相连。在这种情况下,我们用其子节点替换节点 6:节点 5。
树的平衡变为 1,并且因为它没有超过 1,所以树保持原样。如果我们进一步删除元素 5,我们不得不应用左旋转;由于不平衡发生在 1-2-4 和 3-2-4,所以可能是 LL 或 LR。
删除元素 5 后平衡因子受到干扰,因此我们应用 LL 旋转(这里我们也可以应用 LR 旋转)。
一旦在路径 1-2-4 上应用 LL 旋转,节点 3 保持为节点 2 的右子节点(现在由节点 4 占据)的样子。因此,该节点被添加到节点 2 的右子树中,并作为节点 4 的左子节点。
-
从剩余的树中删除元素 2 −
如场景 3 中所提到的那样,该节点有两个子节点。因此,我们找到它是一个叶节点的中序后继(例如,3)并用中序后继替换其值。
树的平衡仍然保持为 1,因此我们在不进行任何旋转的情况下将树保留原样。
Red Black Trees
Red-Black 树是另一种类型的平衡二叉查找树,带有两种颜色的节点:红色和黑色。这是一棵自平衡二叉查找树,它利用这些颜色来在插入和删除操作期间维持平衡因子。因此,在 Red-Black 树操作期间,内存会使用 1 位存储来容纳每个节点的颜色信息。
在 Red-Black 树(也称为 RB 树)中,在为节点分配颜色时有一些不同的条件需要遵循。
-
根节点始终为黑色。
-
任意的两个相邻节点都不得为红色。
-
树中的每条路径(从根节点到叶节点)都必须包含相同数量的黑色节点。
尽管 AVL 树比 RB 树更平衡(AVL 树中的平衡算法比 RB 树中的严格),但通过 RB 树可以更有效地进行多次且快速的插入和删除操作。
Fig: RB trees
Basic Operations of Red-Black Trees
对 Red-Black 树的操作包括通常在二叉查找树上执行的所有基本操作。RB 树的一些基本操作包括:
-
Insertion
-
Deletion
-
Search
Red-Black 树的插入操作遵循二叉查找树的相同插入算法。该元素按照二叉查找属性插入,另外,对节点进行红黑颜色编码以根据红黑树属性对树进行平衡。
按照以下给定的程序将元素插入红黑树,同时维护二叉查找树和红黑树属性。
Case 1 - 检查树是否为空;如果树为空,则将当前节点作为根,并将该节点着色为黑色。
Case 2 - 但如果树不为空,则创建一个新节点并将其着色为红色。此处我们面临两种不同的情况:
-
如果新节点的父级为黑色节点,则退出操作,树保持原样。
-
如果此新节点的父级为红色,并且父级的兄弟节点的顏色为黑色或不存在,则应用适当的旋转并相应地重新着色。
-
如果此新节点的父级为红色,并且父级的兄弟节点的颜色为红色,则将父级、兄弟节点和祖先节点重新着色为黑色。仅当祖先节点是 not 根节点时才会重新着色;如果是根节点,则仅重新着色父级和兄弟节点。
Insertion Example
让我们构造一棵 RB 树以详细了解插入操作的前 7 个整数:
检查树是否为空,因此添加的第一个节点是根,并将其着色为黑色。
现在,树不为空,因此我们创建一个新节点,并用红色添加下一个整数,
节点不违反二叉查找树和 RB 树属性,因此我们继续添加另一个节点。
树不为空;我们用下一个整数创建一个新的红色节点。但新节点的父节点不是黑色节点,
现在树违反了二叉搜索树和 RB 树的属性;因为父节点的兄弟节点是 NULL,我们应用了合适的旋转并重新给节点着色。
现在 RB 树属性已恢复,我们向树中添加另一个节点 −
树再次违背 RB 树的平衡属性,因此我们检查父节点的兄弟节点颜色,在本例中为红色,因此我们只需重新给父节点和兄弟节点着色。
接下来,我们插入元素 5,这使树再次违反 RB 树的平衡属性。
并且由于兄弟节点是 NULL,我们应用了合适的旋转并重新给颜色。
现在,我们插入元素 6,但 RB 树属性被违反,需要应用插入案例之一 −
父节点的兄弟节点是红色的,因此我们重新给父节点、父节点的兄弟节点和祖父节点着色,因为祖父节点不是根节点。
现在,我们添加最后一个元素 7,但这个新节点的父节点是红色的。
因为父节点的兄弟节点是 NULL,我们应用合适的旋转(RR 旋转)
最终的 RB 树已完成。
Deletion
红黑树上的删除操作必须以一种方式执行,这种方式必须恢复二叉搜索树和红黑树的所有属性。按照以下步骤在红黑树上执行删除操作 −
首先,我们基于二叉搜索树属性执行删除。
Case 1 - 如果要删除的节点或节点的父节点为红色,则直接删除它。
Case 2 - 如果节点是双黑色,则只需删除双黑色(双黑色发生在待删除节点为黑色叶节点时,因为它添加了也被视为黑色节点的 NULL 节点)
Case 3 - 如果双黑节点的兄弟节点也是黑色节点并且其子节点也是黑色,请按照以下步骤操作 −
-
Remove double black
-
将其父节点重新着色为黑色(如果父节点为红色节点,它将变为黑色;如果父节点已经是黑色节点,它将变成双黑色)
-
用红色重新给父节点的兄弟节点着色
-
如果双黑节点仍然存在,我们应用其他情况。
Case 4 − 如果双黑节点的兄弟节点为红色,则执行以下操作 −
-
交换父节点和父节点兄弟节点的颜色。
-
朝着双黑节点的方向旋转父节点
-
重新应用合适的情况。
Case 5 − 如果双黑节点的兄弟节点是黑节点,但兄弟节点的一个最近的子节点为红色,则按照以下步骤进行 −
-
交换双黑节点的兄弟节点和相应子节点的颜色。
-
旋转兄弟节点和双黑节点在相反的方向(也就是说,如果双黑节点是右子节点,则进行左旋转,反之亦然)
-
Apply case 6.
Case 6 − 如果双黑节点的兄弟节点是黑节点,但兄弟节点的一个更远的子节点为红色,则按照以下步骤进行 −
-
交换双黑节点的父节点和兄弟节点的颜色。
-
朝着双黑节点的方向旋转父节点(也就是说,如果双黑节点是右子节点,则进行右旋转,反之亦然)
-
Remove double black
-
将红色子节点的颜色更改为黑色。
Deletion Example
考虑以上构建的红黑树,让我们从树中删除一些元素。
从树中删除元素 4、5、3。
要删除元素 4,让我们首先执行二元查找删除。
执行二元查找删除后,红黑树属性不受干扰,因此树保持原样。
然后,我们使用二元查找删除来删除元素 5
但是在执行二元查找删除后,红黑属性被破坏,即树中的所有路径未保存相同数量的黑节点;因此,我们交换颜色来平衡树。
然后,我们从获得的树中删除节点 3 −
应用二进制查找删除,我们正常删除节点 3,因为它是一个叶节点。由于 3 是一个黑色节点,我们得到了一个双重节点。
我们应用情况 3 删除,因为双重黑色的兄弟节点是黑色,其子节点也是黑色。在这里,我们删除双重黑色,为双重黑色的父级和兄弟节点重新着色。
所有所需的节点都被删除,并且 RB 树属性得到了保持。
B Trees
B 树是扩展的二叉查找树,专门用于 m 路搜索,因为 B 树的阶为“m”。树的阶定义为一个节点可容纳的最大子节点数。因此,B 树的高度比 AVL 树和 RB 树的高度要低得多。
它们是二分搜索树的一般形式,因为它包含多个键和两个子项。
B 树的各种属性包括 -
-
B 树中的每个节点最多容纳 m 个子项和 (m-1) 个键,因为树的阶为 m。
-
B 树中的每个节点(除根节点和叶节点外)至少可以容纳 m/2 个子节点
-
根节点必须至少有两个子节点。
-
B 树中的所有路径都必须在同一级别结束,即叶子节点必须在同一级别。
-
B 树始终维护排序后的数据。
B 树还广泛用于磁盘访问,因为它高度较低,减少了磁盘访问时间。
Note - 磁盘访问是计算机磁盘的内存访问,信息存储在其中,磁盘访问时间是系统访问磁盘内存所花费的时间。
Basic Operations of B Trees
B 树中支持的操作为插入、删除和搜索,每个操作的时间复杂度为 O(log n) 。
Insertion
B 树的插入操作类似于二叉搜索树,但将元素插入相同节点中,直至达到最大键。使用以下过程执行插入:
Step 1 − 计算一个节点可以容纳的最大 $\mathrm{\left ( m-1 \right )}$ 和最小 $\mathrm{\left ( \left \lceil \frac{m}{2}\right \rceil-1 \right )}$ 数目的键,其中 m 表示 B 树的阶。
Step 2 - 使用二进制搜索插入将数据插入到树中,一旦键达到最大数量,节点将分成两半,中键成为内部节点,而左右键成为其子节点。
Step 3 - 所有叶子节点必须在同一层。
键 5、3、21、9、13 根据二进制搜索属性全部添加到节点中,但如果我们添加键 22,则它将违反最大键属性。因此,节点分成两半,中键移动到父节点,然后继续插入。
在插入 11 时也会出现另一次小故障,因此节点被分成两半,中键移动到父级。
在插入 16 时,即使将节点分成两部分,父节点也溢出,因为其达到最大键数。因此,先拆分父节点,中键成为根。然后,将叶节点分成两半,叶节点的中键移动到其父级。
插入所有元素后,将实现最终的 B 树。
Example
// Insert the value
void insertion(int item) {
int flag, i;
struct btreeNode *child;
flag = setNodeValue(item, &i, root, &child);
if (flag)
root = createNode(i, child);
}
Deletion
B 树中的删除操作与二叉搜索树的删除操作略有不同。从 B 树中删除节点的过程如下:
Case 1 - 如果要删除的键位于叶节点中并且删除不违反最小键属性,则只需删除该节点。
Case 2 - 如果要删除的键位于叶节点中,但删除违反了最小键属性,则向其左兄弟节点或右兄弟节点借一个键。如果两个兄弟节点都具有相同数量的最小键,则将节点合并到其中任何一个中。
Case 3 - 如果要删除的键位于内部节点中,则用其左子节点或右子节点中的键替换该键,具体取决于哪个子节点具有更多的键。但是,如果两个子节点都具有最小数量的键,则它们会被合并在一起。
Case 4 - 如果要删除的键位于内部节点中,违反了最小键属性,并且其子节点和兄弟节点都具有最小数量的键,则合并子节点。然后,将其兄弟节点与父节点合并。
下面是 B 树中删除操作的 C++ 功能代码片段 −
// Deletion operation
void deletion(int key){
int index = searchkey(key);
if (index < n && keys[index] == key) {
if (leaf)
deletion_at_leaf(index);
else
deletion_at_nonleaf(index);
} else {
if (leaf) {
cout << "key " << key << " does not exist in the tree\n";
return;
}
bool flag = ((index == n) ? true : false);
if (C[index]->n < t)
fill(index);
if (flag && index > n)
C[index - 1]->deletion(key);
else
C[index]->deletion(key);
}
return;
}
// Deletion at the leaf nodes
void deletion_at_leaf(int index){
for (int i = index + 1; i < n; ++i)
keys[i - 1] = keys[i];
n--;
return;
}
// Deletion at the non leaf node
void deletion_at_nonleaf(int index){
int key = keys[index];
if (C[index]->n >= t) {
int pred = get_Predecessor(index);
keys[index] = pred;
C[index]->deletion(pred);
} else if (C[index + 1]->n >= t) {
int successor = copysuccessoressor(index);
keys[index] = successor;
C[index + 1]->deletion(successor);
} else {
merge(index);
C[index]->deletion(key);
}
return;
}
B+ Trees
B+ 树是 B 树的扩展,旨在使插入、删除和搜索操作更有效率。
B+ 树的性质与 B 树的性质相似,不同之处在于 B 树可以在所有内部节点和叶子节点中存储键和记录,而 B+ 树则在叶子节点中存储记录,在内部节点中存储键。B+ 树的一个显著特性是,所有叶子节点都以单链表格式彼此连接,并且提供了一个数据指针来指向磁盘文件中存在的数据。这有助于以相等数量的磁盘访问来获取记录。
由于主内存的大小有限,因此 B+ 树充当了存储在主内存中无法存储的记录的数据存储。为此,内部节点存储在主内存中,而叶子节点存储在辅助内存存储中。
Properties of B+ trees
-
B+ 树中的每个节点,根节点除外,最多容纳 m 个子节点和 (m-1) 个键,最少容纳 $\mathrm{\left \lceil \frac{m}{2}\right \rceil}$ 个子节点和 $\mathrm{\left \lceil \frac{m-1}{2}\right \rceil}$ 个键,因为树的阶数为 m 。
-
根节点必须至少有两个子节点和至少一个搜索键。
-
B 树中的所有路径都必须在同一级别结束,即叶子节点必须在同一级别。
-
B+ 树始终维护已排序数据。
Basic Operations of B+ Trees
B+ 树支持的操作是插入、删除和搜索,所有操作的时间复杂度为 O(log n) 。
它们与 B 树操作类似,因为将数据存储在此两种数据结构中的基本思想是相同的。然而,两者的不同之处在于,与 B 树不同,数据仅存储在 B+ 树的叶结点中。
Insertion
B+ 树的插入从叶结点开始。
Step 1 − 计算要添加到 B+ 树结点上的最大和最小键数。
Step 2 − 将元素一个接一个地插入叶结点,直到超过最大键数。
Step 3 − 将结点分成两半,其中左子结点包含最小键数,而剩余键存储在右子结点中。
Step 4 − 但是,如果内部结点也超过了最大键属性,则将结点分成两半,其中左子结点包含最小键,而剩余键存储在右子结点中。但是,将右子结点中最小的数字设为父结点。
Step 5 − 如果叶结点和内部结点都具有最大键,则它们都会以类似的方式分割,并且右子结点中的最小键添加到父结点中。
Deletion
在 B+ 树中的删除操作中,我们需要考虑数据结构中的冗余。
Case 1 − 对于存在于超过最小键数目的叶节点且副本不存在于内部节点中的键,简单地删除它。
Case 2 − 对于存在于仅包含最小键数目的叶节点且副本不存在于内部节点中的键,从其兄弟节点借用一个键并删除所需的键。
Case 3 − 对于存在于叶节点且副本存在于内部节点中的键,有多种可能的情况 −
-
叶节点和内部节点中都存在超过最小键数目的情况下:简单地删除该键,并且仅将中序后继添加到内部节点。
-
叶节点中存在最小键数目:删除该节点,并且从其最近的兄弟节点借用一个节点,并且将其值也替换为内部节点。
-
如果要删除的叶节点副本在其祖先节点中,则删除该节点并删除该空位。该祖先节点被填入已删除节点的中序后继。
Case 4 − 如果要删除的键存在于最小键属性中的一个违反规则的节点中,其父级和兄弟节点都包含最小键数目,则删除该键,并且将其兄弟节点与其父级合并。
Splay Trees
Splay 树是二叉搜索树的变体,因为它包含所有 BST 操作,如插入、删除和搜索,然后是另一个称为 splaying 的扩展操作。
例如,应该将一个值为 “A” 的值插入到树中。如果树是空,则将 “A” 添加到树的根节点并退出;但是,如果树不为空,则使用二进制查找插入操作来插入元素,然后在新节点上执行伸展操作。
类似地,在 splay 树中搜索一个元素之后,包含该元素的节点也必须被 splaying。
但是我们如何执行 splaying?简单来说,splaying 就是将一个操作节点移到根处的过程。它有六种类型的旋转。
-
Zig rotation
-
Zag rotation
-
Zig-Zig rotation
-
Zag-Zag rotation
-
Zig-Zag rotation
-
Zag-Zig rotation
Zig-Zig rotation
当操作节点同时具有父节点和祖先节点时,执行 zig-zig 旋转。该节点向右移动两个位置。
第一次旋转将树向右移动一个位置:
第二次向右旋转将再次将节点移动一个位置。移动后的最终树看起来如下:
Zag-Zag rotation
当操作节点同时具有父节点和祖先节点时,也执行 zag-zag 旋转。该节点向左移动两个位置。
经过第一次旋转后,树看起来如下:
然后,第二次旋转后的最终树如下所示。然而,操作节点仍然不是根,因此 splaying 被认为不完整。因此,在这种情况下再次应用其他合适的旋转,直到该节点成为根节点。
Zig-Zag rotation
当操作节点同时具有父节点和祖先节点时,执行 zig-zag 旋转。但不同之处在于,祖先节点父节点和子节点采用 LRL 格式。该节点先向右旋转,然后向左旋转。
经过第一次旋转后,树如下:
经过第二次旋转后的最终树如下:
Zag-Zig rotation
当操作节点同时具有父节点和祖先节点时,也执行 zag-zig 旋转。但不同之处在于,祖先节点、父节点和子节点采用 RLR 格式。该节点先向左旋转,然后向右旋转。
进行了第一次旋转,得到如下树:
经过第二次旋转,最终得到如下树。然而,操作节点尚未成为根节点,因此需要执行另一次旋转,将该节点设为根。
Basic Operations of Splay Trees
伸展树包含二叉搜索树提供的所有基本操作:插入、删除和搜索。然而,在每个操作之后,都会有一个附加操作将其与二叉搜索树操作区分开来:伸展。我们已经学过伸展,因此,让我们理解其他操作的过程。
Insertion
伸展树中的插入操作与在二叉搜索树中执行插入操作的方式完全相同。在伸展树中执行插入操作的过程如下:
-
检查树是否为空;如果为空,则添加新节点并退出
-
如果树不为空,则使用二叉搜索插入将新节点添加到现有树中。
-
然后,选择并应用合适类型的伸展到新添加的节点。
Zag (Left) Rotation is applied on the new node
Tries Data Structure
trie 是一种多路搜索树,它根本用于从字符串或一组字符串中检索特定的键。它以一种有效的方式存储数据,因为它对字母表中的每个字母都使用指针。
trie 数据结构基于字符串的公共前缀。考虑到集中字符串的数量,根节点可以具有任意数目的节点。trie 的根节点不包含任何值,只包含其子节点的指针。
有三种类型的 trie 数据结构 −
-
Standard Tries
-
Compressed Tries
-
Suffix Tries
trie 的实际应用包括:自动更正、文本预测、情感分析和数据科学。
Insertion
trie 中的插入操作是一种简单的方法。trie 中的根不包含任何值,并且插入操作从根的直接子节点开始,这些子节点充当其子节点的键。然而,我们观察到trie中的每个节点都代表输入字符串中的一个字符。因此,字符一个接一个地添加到trie中,而trie中的链接充当指向下一级节点的指针。
Deletion
trie 中的删除操作使用自下而上的方法执行。在trie中搜索元素并进行删除(如果找到)。然而,在执行删除操作时需要记住一些特殊的情况。
Case 1 - 键是唯一的 - 在这种情况下,整个键路径从节点中删除。(唯一键表示没有其他路径从一条路径分支出来)。
Case 2 - 键不是唯一的 - 更新叶节点。例如,如果要删除的键是 see ,但它是另一个键 seethe 的前缀;我们删除 see 并将 t、h 和 e 的布尔值更改为 false。
Case 3 - 要删除的键已经有一个前缀 - 直到前缀的值被删除,并且前缀保留在树中。例如,如果要删除的键是 heart ,但存在另一个键 he ;因此,我们删除 a、r 和 t,直到只剩下 he。
Heap Data Structure
堆是平衡二叉树数据结构的一个特例,其中将根节点键与其子节点进行比较并相应排列。如果 α 有子节点 β ,则:
key(α) ≥ key(β)
由于父级的值大于子级的值,因此此属性生成 Max Heap 。基于此标准,堆可以分为两种类型:
For Input → 35 33 42 10 14 19 27 44 26 31
Min-Heap - 其中根节点的值小于或等于其任一子节点。
Max-Heap - 其中根节点的值大于或等于其任一子节点。
两棵树都是使用相同的输入和到达顺序构建的。
Max Heap Construction Algorithm
我们将使用相同的示例来说明如何创建最大堆。创建最小堆的过程类似,但我们追求最小值而不是最大值。
我们将通过一次插入一个元素推导出最大堆的算法。在任何时候,堆都必须保持其属性。在插入时,我们还假设我们将一个节点插入到已经堆化的树中。
Step 1 − Create a new node at the end of heap.
Step 2 − Assign new value to the node.
Step 3 − Compare the value of this child node with its parent.
Step 4 − If value of parent is less than child, then swap them.
Step 5 − Repeat step 3 & 4 until Heap property holds.
Note - 在最小堆构造算法中,我们希望父节点的值小于子节点的值。
让我们通过动画图解来了解最大堆的构造。我们考虑前面使用过的相同输入样本。
Max Heap Deletion Algorithm
让我们推导出一个从最大堆中删除的算法。最大(或最小)堆中的删除始终发生在根处以删除最大(或最小)值。
Step 1 − Remove root node.
Step 2 − Move the last element of last level to root.
Step 3 − Compare the value of this child node with its parent.
Step 4 − If value of parent is less than child, then swap them.
Step 5 − Repeat step 3 & 4 until Heap property holds.
Recursion Algorithms
一些计算机编程语言允许模块或函数调用自身。这种技术称为递归。在递归中,一个函数 α 要么直接调用自身,要么调用一个函数 β 而该函数反过来又调用了原来的函数 α 。函数 α 称为递归函数。
Example − 一个调用自身的函数。
int function(int value) {
if(value < 1)
return;
function(value - 1);
printf("%d ",value);
}
Example − 一个调用另一个函数(而该函数反过来又调用自身)的函数。
int function1(int value1) {
if(value1 < 1)
return;
function2(value1 - 1);
printf("%d ",value1);
}
int function2(int value2) {
function1(value2);
}
Properties
一个递归函数可以像循环一样无限运行。为了避免递归函数无限运行,递归函数必须具有以下两个特性:
-
Base criteria − 必须至少有一个基本标准或条件,即满足此条件时,函数停止递归调用自身。
-
Progressive approach − 递归调用应该逐步进行,即每次进行递归调用时,它都更接近基本标准。
Implementation
许多编程语言通过 stacks 实现递归。通常,每当一个函数 ( caller ) 调用另一个函数 ( callee ) 或自身作为被调用方时,调用方函数会将执行控制权转移到被调用方。这个转移过程还可能涉及将一些数据从调用方传递到被调用方。
这意味着,调用方函数必须暂时挂起其执行,并在执行控制权从被调用方函数返回时继续执行。在这里,调用方函数需要从它暂停执行时的执行点准确地开始。它还需要它当时正在处理的确切相同的数据值。为此,将会为调用方函数创建一个激活记录(或堆栈帧)。
这个激活记录保存有关局部变量、形式参数、返回地址和传递给调用方函数的所有信息。
Analysis of Recursion
有人可能会争论为何使用递归,因为相同的任务可以用迭代来完成。第一个原因是,递归使程序更具有可读性,并且由于采用了最新的增强型 CPU 系统,因此递归比迭代更有效。
Tower of Hanoi Using Recursion
河内塔是一个数学谜题,由三根塔(桩)和多个圆环组成,如下图所示 -
这些圆环大小不同,并且按升序堆叠,即较小的圆环放在较大的圆环上面。还有其他变种的谜题,其中磁盘的数量不断增加,但塔数保持不变。
Rules
任务是将所有磁盘移动到另一个塔,而不违反排列顺序。解决河内塔的几个规则如下 -
-
任何给定时间只能在塔之间移动一个磁盘。
-
只能移除“顶部”磁盘。
-
没有大磁盘可以放在小磁盘上。
以下是解决带有三个圆盘的河内塔谜题的动画表示。
带有 n 个磁盘的河内塔谜题可以在最少 2n−1 步中解决。此演示显示带有 3 个磁盘的谜题已经进行了 23 - 1 = 7 步。
Algorithm
要编写河内塔算法,首先我们需要学习如何解决圆盘更少的问题,例如 → 1 或 2。我们用 source 、 destination 和 aux 标记三个塔(仅帮助移动磁盘)。如果我们只有一个磁盘,则可以轻松地从源点移动到目标桩。
如果我们有 2 个磁盘 -
-
首先,我们将较小的(顶部)磁盘移动到辅助桩。
-
然后,我们将较大的(底部)磁盘移动到目标桩。
-
最后,我们将较小的磁盘从辅助桩移动到目标桩。
所以现在,我们有能力设计一个用于超过两个磁盘的河内塔算法。我们将磁盘堆栈分成两部分。最大的磁盘(第 n 个磁盘)在一部份中,所有其他 (n-1) 个磁盘在第二部分中。
我们的最终目的是将磁盘 n 从源点移动到目标点,然后将所有其他 (n1) 个磁盘放在它上面。我们可以想象以递归方式对所有给定的磁盘集应用相同的操作。
要遵循的步骤如下 -
Step 1 − Move n-1 disks from source to aux
Step 2 − Move nth disk from source to dest
Step 3 − Move n-1 disks from aux to dest
河内塔的递归算法可以驱动如下 -
START
Procedure Hanoi(disk, source, dest, aux)
IF disk == 1, THEN
move disk from source to dest
ELSE
Hanoi(disk - 1, source, aux, dest) // Step 1
move disk from source to dest // Step 2
Hanoi(disk - 1, aux, dest, source) // Step 3
END IF
END Procedure
STOP
Fibonacci Series Using Recursion
斐波那契数列通过将两个前一个数字相加来生成后续数字。斐波那契数列从两个数字开始 − F0 & F1 。F0 & F1 的初始值可以分别取 0, 1 或 1, 1。
斐波那契数列满足以下条件:
Fn = Fn-1 + Fn-2
因此,一个斐波那契数列看起来可能是这样 −
F8 = 0 1 1 2 3 5 8 13
或者,这样 −
F8 = 1 1 2 3 5 8 13 21
为了说明,F8 的斐波那契显示为 −