北京交通大学《数据结构》 课程学习笔记
一、绪论
1.什么是数据结构
数据结构(Data Structure)是指在计算机中存储和组织数据的方式。它不仅仅关乎数据的存储,还关乎如何有效地进行数据操作、访问、修改和删除等。不同的数据结构适用于不同的应用场景和需求。常见的数据结构包括线性结构(如数组、链表、栈、队列)、非线性结构(如树、图)、哈希表、堆等。
数据结构的选择直接影响程序的效率和性能,良好的数据结构设计能够使得算法的实现更加高效、简洁,并能提高计算机系统的资源利用率。数据结构的学习为解决实际问题提供了基础,尤其是在需要处理大量数据和进行复杂运算的场景中,合理的数据结构能显著提升系统的响应速度和稳定性。
2.算法设计与评价
算法设计是指为了解决某个问题,提出一个具体的、可操作的步骤或过程。一个好的算法不仅要正确解决问题,还需要考虑执行效率、可读性和资源消耗等因素。
常见的算法设计方法有:
分治法:将问题分解为多个子问题,递归求解后合并结果。例如,归并排序和快速排序。
动态规划:通过将问题分解成子问题并存储子问题的解,避免重复计算,常用于求解最优化问题。
贪心法:每一步都选择当前最优解,最终获得问题的全局最优解。
回溯法:通过构造候选解的方式逐步推进,回溯到上一步,直到找到合适的解。
后续会出相应笔记。。。
算法的评价通常涉及以下几个方面:
-
时间复杂度:算法执行所需的时间与输入规模之间的关系,通常用大O符号表示。时间复杂度越低,算法执行效率越高。
-
空间复杂度:算法执行过程中所需的存储空间与输入规模之间的关系。
-
正确性:算法是否能够在所有情况下得到正确的结果。
-
稳定性:对于相同输入数据的不同排列,算法能否保证输出结果的一致性。
-
可扩展性:算法是否能够适应更大规模的输入数据。
二、线性表
1.单链表
单链表是一种链式存储结构,每个节点包含数据域和指针域。每个节点的指针域指向下一个节点,链表中的最后一个节点指向 NULL
,表示链表的结束。
typedef struct Node{
int data; //数据域
struct Node *next; //指针域
}Node;
特点:
-
单链表是单向链表,只能从头节点开始按顺序访问每个元素。
-
每个节点包含两部分:数据部分和指向下一个节点的指针部分。
-
插入和删除操作可以在常数时间内完成。
常见操作:
-
插入节点(头插法、尾插法、指定位置插入)
// 头插法
void insertAtHead(int val) {
Node* newNode = new Node(val);
newNode->next = head; // 新节点指向原头节点
head = newNode; // 更新头节点为新节点
}
// 尾插法
void insertAtTail(int val) {
Node* newNode = new Node(val);
if (!head) {
head = newNode; // 如果链表为空,将新节点作为头节点
} else {
Node* temp = head;
while (temp->next) {
temp = temp->next; // 找到尾节点
}
temp->next = newNode; // 将尾节点的 next 指向新节点
}
}
// 指定位置插入
void insertAtPosition(int val, int pos) {
Node* newNode = new Node(val);
// 如果插入位置是 0(即在链表头部插入)
if (pos == 0) {
newNode->next = head;
head = newNode;
return;
}
// 找到指定位置的前一个节点
Node* temp = head;
for (int i = 0; temp != nullptr && i < pos - 1; ++i) {
temp = temp->next;
}
// 如果位置不合法(即 pos 超出链表长度)
if (temp == nullptr) {
cout << "位置不合法!" << endl;
return;
}
// 插入新节点
newNode->next = temp->next;
temp->next = newNode;
}
-
删除节点(删除头节点、尾节点、指定位置节点)
// 头节点删除
void deleteHead() {
if (head == nullptr) {
cout << "The list is empty!" << endl;
return;
}
Node* temp = head;
head = head->next; // 更新头节点为下一个节点
delete temp; // 释放被删除的头节点
}
// 尾节点删除
void deleteTail() {
if (head == nullptr) {
cout << "The list is empty!" << endl;
return;
}
if (head->next == nullptr) { // 只有一个节点
delete head;
head = nullptr;
return;
}
Node* temp = head;
while (temp->next && temp->next->next) {
temp = temp->next; // 找到倒数第二个节点
}
delete temp->next; // 删除尾节点
temp->next = nullptr;
}
// 删除指定位置节点
void deleteAtPosition(int pos) {
if (head == nullptr) {
cout << "The list is empty!" << endl;
return;
}
if (pos == 0) {
deleteHead();
return;
}
Node* temp = head;
for (int i = 0; temp != nullptr && i < pos - 1; ++i) {
temp = temp->next;
}
if (temp == nullptr || temp->next == nullptr) {
cout << "Position out of range!" << endl;
return;
}
Node* nodeToDelete = temp->next;
temp->next = temp->next->next; // 将当前节点的 next 指向要删除节点的下一个节点
delete nodeToDelete; // 释放被删除的节点
}
-
查找节点(通过值查找)
// 通过值查找节点
Node* findNode(int value) {
Node* temp = head;
while (temp) {
if (temp->data == value) {
return temp; // 找到节点并返回
}
temp = temp->next;
}
return nullptr; // 如果没有找到,返回 nullptr
}
-
遍历链表
void traverse() {
Node* temp = head;
while (temp) {
cout << temp->data << " -> ";
temp = temp->next; // 移动到下一个节点
}
cout << "NULL" << endl;
}
2.双链表
双链表是一种链表结构,其中每个节点不仅包含指向下一个节点的指针(next
),还包含指向前一个节点的指针(prev
)。因此,双链表可以实现双向遍历。
typedef struct Node{
int data; //数据域
struct Node *prev ,*next; //前驱与后驱指针域
}Node;
特点:
-
每个节点有两个指针,一个指向下一个节点,另一个指向前一个节点。
-
通过双链表,可以从头到尾和从尾到头都可以遍历。
常见操作:
-
插入节点(头插法、尾插法、指定位置插入)
// 头插法插入节点
void insertAtHead(int val) {
Node* newNode = new Node(val);
if (!head) {
head = newNode;
} else {
newNode->next = head;
head->prev = newNode; // 设置旧头节点的前驱节点为新节点
head = newNode; // 将新节点设置为头节点
}
}
// 尾插法插入节点
void insertAtTail(int val) {
Node* newNode = new Node(val);
if (!head) {
head = newNode; // 如果链表为空,将新节点作为头节点
} else {
Node* temp = head;
while (temp->next) {
temp = temp->next; // 找到尾节点
}
temp->next = newNode;
newNode->prev = temp; // 设置新节点的前驱节点
}
}
// 指定位置插入节点
void insertAtPosition(int val, int position) {
if (position <= 0) return; // 不合法位置
Node* newNode = new Node(val);
if (position == 1) {
insertAtHead(val);
return;
}
Node* temp = head;
int index = 1;
while (temp && index < position - 1) {
temp = temp->next;
index++;
}
if (!temp) return; // 如果位置超出链表长度
newNode->next = temp->next;
if (temp->next) {
temp->next->prev = newNode; // 如果不是尾节点,更新后继节点的前驱指针
}
temp->next = newNode;
newNode->prev = temp; // 设置新节点的前驱节点
}
-
删除节点(头节点删除、尾节点删除、指定位置节点删除)
// 删除头节点
void deleteHead() {
if (!head) return; // 如果链表为空
Node* temp = head;
head = head->next;
if (head) {
head->prev = nullptr; // 如果链表非空,更新头节点的前驱指针
}
delete temp;
}
// 删除尾节点
void deleteTail() {
if (!head) return; // 如果链表为空
Node* temp = head;
while (temp->next) {
temp = temp->next; // 找到尾节点
}
if (temp->prev) {
temp->prev->next = nullptr; // 更新倒数第二个节点的 next 为 nullptr
} else {
head = nullptr; // 如果链表只有一个节点,删除后链表为空
}
delete temp;
}
// 删除指定位置的节点
void deleteAtPosition(int position) {
if (position <= 0 || !head) return; // 不合法位置或链表为空
if (position == 1) {
deleteHead();
return;
}
Node* temp = head;
int index = 1;
while (temp && index < position) {
temp = temp->next;
index++;
}
if (!temp) return; // 如果位置超出链表长度
if (temp->prev) {
temp->prev->next = temp->next;
}
if (temp->next) {
temp->next->prev = temp->prev;
}
delete temp;
}
-
查找节点
// 查找节点(通过值查找)
Node* findNode(int value) {
Node* temp = head;
while (temp) {
if (temp->data == value) {
return temp; // 找到节点并返回
}
temp = temp->next;
}
return nullptr; // 未找到节点
}
-
遍历链表
// 遍历链表(从头到尾)
void printFromHead() {
Node* temp = head;
while (temp) {
cout << temp->data << " <-> ";
temp = temp->next;
}
cout << "NULL" << endl;
}
// 遍历链表(从尾到头)
void printFromTail() {
if (!head) return;
Node* temp = head;
while (temp->next) {
temp = temp->next; // 遍历到尾部
}
while (temp) {
cout << temp->data << " <-> ";
temp = temp->prev;
}
cout << "NULL" << endl;
}
3.循环链表
循环链表是一种特殊的链表类型,其中链表的尾节点的 next
指针指向链表的头节点。因此,循环链表没有明确的“末尾”节点,遍历时需要特别处理。
特点:
-
链表的尾节点的
next
指针指向头节点,形成一个环状结构。 -
遍历时需要避免死循环,通常需要一个标志来表示遍历的结束。
4.静态链表
静态链表是基于数组实现的链表,数组中的每个元素包含数据域和指针域(即下一个节点的下标)。由于是基于数组实现,所以静态链表的大小是固定的。静态链表通常用于解决动态内存分配时的空间管理问题。
特点:
-
使用一个数组来模拟链表,每个数组元素包含数据和指针。
-
指针存储的是下一个节点的数组索引,最后一个节点的指针为
-1
表示结束。 -
不需要动态内存分配,适合嵌入式系统等内存较为紧张的场景。
三、栈与队列
1.栈
栈是一种线性数据结构,其特征是遵循 后进先出(LIFO, Last In First Out) 的原则。栈只能在一端进行数据的插入和删除操作,这一端称为栈顶,另一端为栈底。
#define MaxSize 50
typedef struct{
int data[MaxSize]; //栈中元素
int top; //栈顶指针
}Stack;
常见操作:
-
push(x): 向栈顶插入元素
x
。
// 向栈顶插入元素 x
void push(int x) {
if (isFull()) {
cout << "Stack Overflow!" << endl; // 栈满,无法插入
return;
}
arr[++top] = x; // 插入元素,并更新栈顶指针
}
-
pop(): 移除栈顶元素。
// 移除栈顶元素
int pop() {
if (isEmpty()) {
cout << "Stack Underflow!" << endl; // 栈空,无法弹出
return -1; // 返回 -1 表示栈为空
}
return arr[top--]; // 返回栈顶元素,并更新栈顶指针
}
-
peek() 或 top(): 返回栈顶元素,但不移除。
// 返回栈顶元素,但不移除
int peek() {
if (isEmpty()) {
cout << "Stack is empty!" << endl;
return -1; // 返回 -1 表示栈为空
}
return arr[top]; // 返回栈顶元素
}
-
isEmpty(): 判断栈是否为空。
// 判断栈是否为空
bool isEmpty() {
return top == -1; // 如果栈顶指针为 -1,说明栈为空
}
-
size(): 返回栈中元素的数量。
// 返回栈中元素的数量
int size() {
return top + 1; // 栈顶指针加 1 即为栈中元素的数量
}
2.队列
队列是一种线性数据结构,遵循 先进先出(FIFO, First In First Out) 的原则。元素从队列的一端(队尾)插入,从另一端(队头)删除。
typedef struct LinkNode{
int data;
struct LinkNode *next; //指针域
}LinkNode;
typedef struct {
LinkNode *front ,*rear; //队头指针与队尾指针
}Queue;
常见操作:
-
enqueue(x): 向队列尾部插入元素
x
。
// 向队列尾部插入元素 x
void enqueue(int x) {
if (isFull()) {
cout << "Queue Overflow!" << endl; // 队列满,无法插入
return;
}
rearIndex = (rearIndex + 1) % capacity; // 循环队列,保证 rearIndex 不越界
arr[rearIndex] = x; // 插入元素
currentSize++; // 队列大小增加
}
-
dequeue(): 从队列头部移除元素。
// 从队列头部移除元素
int dequeue() {
if (isEmpty()) {
cout << "Queue Underflow!" << endl; // 队列空,无法移除
return -1; // 返回 -1 表示队列为空
}
int dequeuedElement = arr[frontIndex];
frontIndex = (frontIndex + 1) % capacity; // 循环队列,保证 frontIndex 不越界
currentSize--; // 队列大小减少
return dequeuedElement; // 返回移除的元素
}
-
front(): 返回队列头部元素,但不移除。
// 返回队列头部元素,但不移除
int front() {
if (isEmpty()) {
cout << "Queue is empty!" << endl;
return -1; // 返回 -1 表示队列为空
}
return arr[frontIndex]; // 返回队列头部元素
}
-
isEmpty(): 判断队列是否为空。
// 判断队列是否为空
bool isEmpty() {
return currentSize == 0;
}
-
size(): 返回队列中元素的数量。
// 返回队列中元素的数量
int size() {
return currentSize; // 直接返回当前队列大小
}
3.栈与队列的应用
栈的应用
-
表达式求值和转换:栈用于表达式的后缀和中缀转换,以及后缀表达式的求值
-
递归调用:程序的函数调用栈是栈的一个典型应用,它用于存储函数的返回地址和局部变量。
-
回溯算法:栈常用于实现回溯算法,如解决迷宫问题、深度优先搜索(DFS)等。
队列的应用
-
任务调度与进程管理:操作系统的任务调度常用队列(尤其是圆形队列)来管理等待的任务。
-
广度优先搜索(BFS):队列用于图的广度优先遍历,保证按层次顺序访问节点。
-
缓冲区管理:如生产者消费者问题,队列用于存放和管理生产者生产的产品,消费者从队列中获取产品。
4.数组与广义表
数组(Array) 是一种线性数据结构,其特点是通过索引(下标)访问元素,内存是连续的,所有元素都具有相同的数据类型。数组的大小固定,支持随机访问,但插入和删除操作效率较低,尤其是在数组中间进行操作时。
广义表(Generalized List) 是一种递归数据结构,它不仅可以包含原子元素(基本数据类型),还可以包含子表。广义表的基本元素可以是数字、字符串、其他广义表等。这种结构使得广义表非常适合用于表示树形结构或者图形结构。
5.稀疏矩阵
稀疏矩阵 是指矩阵中大多数元素为零的矩阵。稀疏矩阵相比于密集矩阵,存储起来更加高效,因为零元素占据了大量的内存空间,因此我们不需要存储零元素,只需要存储非零元素。
存储方法:
-
压缩行存储(CSR,Compressed Sparse Row):将矩阵分为行,并压缩存储每行的非零元素及其列索引。
-
压缩列存储(CSC,Compressed Sparse Column):将矩阵分为列,压缩存储每列的非零元素及其行索引。
-
坐标列表(COO,Coordinate List):存储非零元素的行索引、列索引和值。
假设有一个 4x4 的矩阵:
对于该矩阵,稀疏矩阵的存储可以压缩为:
CSR 格式:存储非零元素的值、行索引和列索引。
值:
[1, 2, 3, 4]
列索引:
[0, 2, 1, 3]
行指针:
[0, 1, 2, 3, 4]
COO 格式:存储非零元素的行索引、列索引和对应的值。
行索引:
[0, 1, 2, 3]
列索引:
[0, 2, 1, 3]
值:
[1, 2, 3, 4]
四、串
1.字符串
字符串是由一系列字符组成的有序集合,在计算机中通常以字符数组的形式存储。字符串是程序设计中最常见的数据类型之一,广泛应用于各种操作,如文本处理、模式匹配、数据传输等。
-
字符编码:字符串的存储和处理依赖于字符编码,如 ASCII、Unicode 等。每个字符根据编码标准分配一个唯一的数值,便于计算机处理。
-
字符串操作:常见的字符串操作包括:字符串的连接、截取、查找、比较、替换、分割等
2.BF算法
BF算法(也称为暴力匹配算法)是一种最简单的字符串匹配算法。它的基本思想是将模式串与目标文本串逐个字符地进行比较,直到匹配成功或遍历整个文本。
BF算法的步骤:
-
从文本的第一个字符开始,逐个与模式串的字符进行比较。
-
如果模式串的某个字符与文本的相应字符不匹配,则模式串右移一位,继续与文本进行比较。
-
如果模式串与文本的某个子串完全匹配,则返回匹配的位置。
-
如果文本中没有完全匹配的子串,则返回未匹配的标志。
// BF算法实现
int BF_Match(const string& text, const string& pattern) {
int n = text.length(); // 文本串长度
int m = pattern.length(); // 模式串长度
// 如果模式串长度大于文本串,返回-1表示匹配失败
if (m > n) {
return -1;
}
// 遍历文本串的每一个位置
for (int i = 0; i <= n - m; ++i) {
int j = 0;
// 比较文本串和模式串的字符
while (j < m && text[i + j] == pattern[j]) {
++j;
}
// 如果所有字符都匹配,返回匹配的起始位置
if (j == m) {
return i;
}
}
// 如果没有找到匹配的模式串,返回-1
return -1;
}
时间复杂度为O(mn)
3.KMP算法
KMP算法是一种改进的字符串匹配算法,通过预处理模式串来减少重复的字符比较,从而优化匹配过程。KMP算法的核心思想是利用模式串的部分匹配信息来避免回溯。
KMP算法的步骤:
-
预处理阶段:根据模式串构建一个部分匹配表(也叫做"失败函数"或"next数组")。该表记录了每个字符在模式串中可以跳过多少字符,从而避免了在匹配失败时回溯。
-
匹配阶段:使用部分匹配表来进行字符串匹配。匹配过程中,当字符不匹配时,可以利用部分匹配表跳过不必要的字符,避免了暴力匹配中的重复比较。
部分匹配表(next数组):
-
next数组的每个元素
next[i]
表示在模式串P
中,从位置i
开始,最长的相同前缀和后缀的长度。若有不匹配的字符,则可以通过next数组知道模式串在下次匹配时应该跳到哪个位置。
// 计算next数组
void computeNextArray(const string& pattern, vector<int>& next) {
int m = pattern.length();
next[0] = -1; // next[0]总是-1
int j = -1;
for (int i = 1; i < m; ++i) {
// 回退到合适的位置
while (j >= 0 && pattern[i] != pattern[j + 1]) {
j = next[j];
}
// 如果匹配,则更新next[i]
if (pattern[i] == pattern[j + 1]) {
++j;
}
next[i] = j;
}
}
// KMP算法实现
int KMP_Match(const string& text, const string& pattern) {
int n = text.length();
int m = pattern.length();
if (m == 0) return 0; // 如果模式串为空,返回0
vector<int> next(m);
computeNextArray(pattern, next); // 计算模式串的next数组
int i = 0; // 文本串的指针
int j = 0; // 模式串的指针
while (i < n) {
if (text[i] == pattern[j]) {
++i;
++j;
if (j == m) {
// 匹配成功,返回匹配位置
return i - j;
}
} else {
if (j > 0) {
// 根据next数组跳跃
j = next[j - 1] + 1;
} else {
++i;
}
}
}
return -1; // 如果没有匹配,返回-1
}
时间复杂度为O(n)
4.改进KMP算法
在KMP(Knuth-Morris-Pratt)算法中,next
数组是用来记录每个位置的前缀和后缀的最大匹配长度。它的主要作用是在匹配失败时,帮助我们跳过一些已经匹配过的部分,从而避免回溯,从而提高匹配效率。next
数组的计算是KMP算法的关键,但它在某些情况下会存在冗余,尤其是在构建数组时,某些位置的 next
值可能会被重复计算。
为了优化这一点,nextval
数组(改进版 next
数组)应运而生。nextval
数组和 next
数组有相似之处,但是它通过优化,使得匹配失败时跳跃的次数更少,从而避免了某些冗余的比较操作。
nextval
数组的计算规则:
-
nextval[i]
的计算: 如果P[i] == P[next[i]]
,那么nextval[i] = next[next[i]]
,即在next[i]
位置的值不合适时,我们尝试跳过更多的字符。 -
如果
P[i] != P[next[i]]
,则nextval[i] = next[i]
。
五、树与二叉树
1.树
树是一种非线性的数据结构,由节点(Node)和边(Edge)组成,是一组有层次关系的元素的集合。树的基本特点是:
-
根节点 (Root):树的顶端节点,没有父节点。
-
内部节点 (Internal Node):有子节点的节点。
-
叶节点 (Leaf Node):没有子节点的节点。
-
边 (Edge):连接两个节点的线。
-
父节点 (Parent) 和 子节点 (Child):树中节点之间的层次关系,父节点连接其子节点。
-
层级 (Level):根节点在第 0 层,其他节点依层次编号。
-
深度 (Depth):从根节点到某个节点的路径长度。
-
高度 (Height):从某个节点到其最远叶子节点的路径长度。
-
子树 (Subtree):某个节点及其所有后代节点构成的树。
2.二叉树
二叉树是一种每个节点最多有两个子节点的树。二叉树的每个节点包含以下三个部分:
-
数据域 (Data):存储数据。
-
左子树指针 (Left):指向左子树的指针。
-
右子树指针 (Right):指向右子树的指针。
// 定义二叉树节点结构
struct TreeNode {
int val; // 节点值
TreeNode* left; // 左子节点指针
TreeNode* right; // 右子节点指针
};
// 创建二叉树节点
TreeNode* create(int val) {
TreeNode* newNode = new TreeNode;
newNode->val = val;
newNode->left = nullptr;
newNode->right = nullptr;
return newNode;
}
// 插入节点(使用递归)
TreeNode* insert(TreeNode* root, int val) {
if (root == nullptr) {
return create(val);
}
// 如果值小于根节点,插入左子树
if (val < root->val) {
root->left = insert(root->left, val);
}
// 如果值大于根节点,插入右子树
else if (val > root->val) {
root->right = insert(root->right, val);
}
return root;
}
二叉树的分类:
-
满二叉树 (Full Binary Tree):每个节点要么是叶子节点,要么有两个子节点。
-
完全二叉树 (Complete Binary Tree):除最后一层外,每层的节点数都达到最大,并且最后一层的节点集中在最左边。
-
平衡二叉树 (Balanced Binary Tree):每个节点的左右子树高度差不超过1。
-
二叉搜索树 (Binary Search Tree, BST):左子树的节点值小于根节点,右子树的节点值大于根节点。
二叉树的数学性质参考二叉树数学性质
3.二叉树的遍历
二叉树遍历的基本方法是根据访问根节点、左子树和右子树的顺序来分类。常见的遍历方法有:
-
先序遍历 (Pre-order Traversal):根节点 -> 左子树 -> 右子树。
// 先序遍历
void PreOrder(TreeNode* root) {
if (root != nullptr) {
cout << root->val << ' ';
PreOrder(root->left);
PreOrder(root->right);
}
}
-
中序遍历 (In-order Traversal):左子树 -> 根节点 -> 右子树。
// 中序遍历
void InOrder(TreeNode* root) {
if (root != nullptr) {
InOrder(root->left);
cout << root->val << " ";
InOrder(root->right);
}
}
-
后序遍历 (Post-order Traversal):左子树 -> 右子树 -> 根节点。
//后序遍历
void PostOrder(TreeNode* root) {
if (root != nullptr) {
PostOrder(root->left);
PostOrder(root->right);
cout << root->val << ' ';
}
}
-
层序遍历 (Level-order Traversal):按层次从上到下、从左到右遍历,通常使用队列实现。
// 层序遍历
void LevelOrder(TreeNode* root) {
if (root == nullptr) return;
queue<TreeNode*> q; // 创建队列
q.push(root); // 将根节点入队
while (!q.empty()) {
TreeNode* current = q.front(); // 获取队首节点
q.pop(); // 出队
cout << current->val << " "; // 打印当前节点值
// 如果左子节点存在,将其入队
if (current->left != nullptr) {
q.push(current->left);
}
// 如果右子节点存在,将其入队
if (current->right != nullptr) {
q.push(current->right);
}
}
}
中序遍历(非递归实现):
// 二叉树中序遍历的非递归算法 void MidNoTravel(Tree* root) { struct StackNode { Tree* node; StackNode* next; StackNode(Tree* n) : node(n), next(NULL) {} }; StackNode* top = NULL; Tree* current = root; while (current!= NULL || top!= NULL) { while (current!= NULL) { StackNode* newNode = new StackNode(current); newNode->next = top; top = newNode; current = current->left; } current = top->node; top = top->next; cout << current->data << " "; current = current->right; } }
递归实现太简单了,不怎么考
4.线索二叉树
线索二叉树是一种通过在二叉树中增加“线索”来改进遍历效率的树。通常,在二叉树的每个节点中,用空指针(原本指向子节点的指针)指向某些节点的位置,以便在遍历时能够直接找到下一个节点,减少了递归或栈的使用。
-
线索化:将每个节点的空指针(左空指针或右空指针)指向它的前驱或后继节点,从而通过指针链表的方式来遍历树。
-
类型:有前驱线索和后继线索两种。前驱线索指向节点的前一个节点,后继线索指向节点的下一个节点。
// 定义线索二叉树的节点
struct ThreadedTreeNode {
int data; // 数据域
ThreadedTreeNode *left; // 左子树指针
ThreadedTreeNode *right; // 右子树指针
bool leftThread; // 标记是否是线索(true为线索)
bool rightThread; // 标记是否是线索(true为线索)
};
// 创建新的线索二叉树节点
ThreadedTreeNode* createNode(int data) {
ThreadedTreeNode* newNode = new ThreadedTreeNode();
newNode->data = data;
newNode->left = nullptr;
newNode->right = nullptr;
newNode->leftThread = false;
newNode->rightThread = false;
return newNode;
}
5.森林
森林是一个由若干棵不相交的树构成的集合。森林与树的关系是:
-
一棵树可以看作是森林的一种特殊情况,森林是树的一个扩展。
-
将树的根节点从树中分离出来,得到的集合称为森林。也就是说,树可以通过移除根节点变成森林。
-
在实际应用中,森林常用于表示由多个不相交的子树组成的复杂结构。
6.哈夫曼树与哈夫曼编码
哈夫曼树是一种带权路径长度最短的二叉树,广泛应用于数据压缩。它基于贪心算法构建。
-
哈夫曼树的构建过程:
-
将所有节点按权值排序(最小的在前)。
-
选取两个权值最小的节点,合并成一个新的节点,新的节点的权值为两个节点的权值之和。
-
重复上述步骤直到只剩下一个节点,即哈夫曼树的根节点。
-
// 哈夫曼树节点结构体
typedef struct HuffmanNode {
char ch; // 字符
int weight; // 权重(频率)
struct HuffmanNode *left, *right; // 左右子节点
} HuffmanNode;
// 优先队列结构体(最小堆)
typedef struct MinHeap {
int size; // 当前堆大小
int capacity; // 堆容量
HuffmanNode **arr; // 节点数组
} MinHeap;
// 创建一个新的哈夫曼树节点
HuffmanNode* createNode(char ch, int weight) {
HuffmanNode *node = (HuffmanNode *)malloc(sizeof(HuffmanNode));
node->ch = ch;
node->weight = weight;
node->left = node->right = NULL;
return node;
}
// 创建一个空的最小堆
MinHeap* createMinHeap(int capacity) {
MinHeap *heap = (MinHeap *)malloc(sizeof(MinHeap));
heap->size = 0;
heap->capacity = capacity;
heap->arr = (HuffmanNode **)malloc(capacity * sizeof(HuffmanNode *));
return heap;
}
// 交换堆中两个节点
void swap(HuffmanNode **a, HuffmanNode **b) {
HuffmanNode *temp = *a;
*a = *b;
*b = temp;
}
// 最小堆的插入操作
void insertMinHeap(MinHeap *heap, HuffmanNode *node) {
int i = heap->size++;
while (i && node->weight < heap->arr[(i - 1) / 2]->weight) {
heap->arr[i] = heap->arr[(i - 1) / 2];
i = (i - 1) / 2;
}
heap->arr[i] = node;
}
// 最小堆的删除操作(移除堆顶)
HuffmanNode* deleteMinHeap(MinHeap *heap) {
if (heap->size == 0) return NULL;
HuffmanNode *root = heap->arr[0];
heap->arr[0] = heap->arr[--heap->size];
// 堆化操作
int i = 0;
while (i * 2 + 1 < heap->size) {
int left = 2 * i + 1;
int right = 2 * i + 2;
int smallest = i;
if (left < heap->size && heap->arr[left]->weight < heap->arr[smallest]->weight)
smallest = left;
if (right < heap->size && heap->arr[right]->weight < heap->arr[smallest]->weight)
smallest = right;
if (smallest == i) break;
swap(&heap->arr[i], &heap->arr[smallest]);
i = smallest;
}
return root;
}
// 构建哈夫曼树
HuffmanNode* buildHuffmanTree(char *chars, int *freq, int size) {
MinHeap *heap = createMinHeap(size);
// 创建每个字符的初始节点并插入堆
for (int i = 0; i < size; i++) {
HuffmanNode *node = createNode(chars[i], freq[i]);
insertMinHeap(heap, node);
}
// 构建哈夫曼树
while (heap->size > 1) {
HuffmanNode *left = deleteMinHeap(heap);
HuffmanNode *right = deleteMinHeap(heap);
// 创建新节点
HuffmanNode *newNode = createNode('\0', left->weight + right->weight);
newNode->left = left;
newNode->right = right;
insertMinHeap(heap, newNode);
}
// 最后堆中剩下的节点即为根节点
return deleteMinHeap(heap);
}
-
哈夫曼编码:通过哈夫曼树生成的编码,通常用0和1表示:
-
左子树编码为0,右子树编码为1。
-
每个字符的编码由根节点到该字符的路径决定。
-
哈夫曼编码能够有效压缩数据,因为频率高的字符编码较短,频率低的字符编码较长。
-
7.并查集
并查集是一种用于处理一些不交集(disjoint set)合并与查询问题的数据结构。并查集能够支持两种操作:
-
Union:将两个不相交的集合合并成一个集合。
-
Find:查询某个元素所属的集合。
class UnionFind {
private:
vector<int> parent; // 存储每个元素的父节点
vector<int> rank; // 存储每个元素的树的深度(秩)
public:
// 构造函数,初始化并查集
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 0);
for (int i = 0; i < n; ++i) {
parent[i] = i; // 每个节点的父节点指向自己
}
}
// 查找操作:查找x所在集合的代表元素(根节点),并进行路径压缩
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩:直接将x的父节点指向根节点
}
return parent[x];
}
// 合并操作:将x和y所在的集合合并
void unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) { // 如果x和y不在同一个集合
// 按秩合并:将树的高度小的根节点连接到树的高度大的根节点
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
rank[rootX]++; // 如果秩相同,合并后根的秩加1
}
}
}
// 判断x和y是否在同一个集合中
bool connected(int x, int y) {
return find(x) == find(y);
}
};
并查集常用于处理动态连通性问题,广泛应用于图算法中,如判断图中是否有环、最小生成树(Kruskal算法)等。
并查集的优化方法:
-
路径压缩 (Path Compression):在查找操作中,通过递归地将沿途的所有节点直接连接到根节点,使得树的高度降低,从而加速后续的查找操作。
-
按秩合并 (Union by Rank):合并操作时,总是将较小的树合并到较大的树上,保持树的平衡,从而减少树的高度,提升效率。
六、图
1.图的基本概念
-
图(Graph):图是由一组顶点(Vertices)和一组边(Edges)组成的集合,其中每条边连接两个顶点。图的基本概念包括:
-
顶点(Vertex):图的基本元素,表示图中的一个点。
-
边(Edge):连接两个顶点的线,表示顶点之间的关系。
-
有向图(Directed Graph):边是有方向的,即每条边有一个起始顶点和一个终止顶点。
-
无向图(Undirected Graph):边是无方向的,即边连接两个顶点没有方向区分。
-
加权图(Weighted Graph):每条边都与一个权值(通常是一个数值)相关联。
-
非加权图(Unweighted Graph):边没有权值。
-
-
图的分类:
-
连通图(Connected Graph):图中的任意两个顶点都有路径相连。
-
非连通图(Disconnected Graph):图中存在一些顶点之间没有路径相连。
-
简单图(Simple Graph):没有自环且每对顶点之间最多有一条边。
-
有向无环图(DAG,Directed Acyclic Graph):图是有向的,并且不包含任何环。
-
2.图的存储
-
邻接矩阵(Adjacency Matrix):使用一个二维数组表示图的边。若图有
n
个顶点,则邻接矩阵是一个n x n
的矩阵,矩阵的每个元素matrix[i][j]
表示顶点i
到顶点j
是否有边(对于有向图,表示从i
到j
是否有边;对于无向图,矩阵是对称的)。-
优点:方便快速地查询边的存在与否,时间复杂度为 O(1)。
-
缺点:如果图是稀疏图,则会浪费大量存储空间。
-
class Graph {
public:
int V; // 顶点数
vector<vector<int>> adjMatrix;
Graph(int vertices) {
V = vertices;
adjMatrix.resize(V, vector<int>(V, 0)); // 初始化邻接矩阵,默认为0
}
// 添加边 (u, v)
void addEdge(int u, int v) {
adjMatrix[u][v] = 1;
adjMatrix[v][u] = 1; // 无向图,反向也要有边
}
// 打印邻接矩阵
void printMatrix() {
for (int i = 0; i < V; i++) {
for (int j = 0; j < V; j++) {
cout << adjMatrix[i][j] << " ";
}
cout << endl;
}
}
// 判断是否有边 (u, v)
bool isEdge(int u, int v) {
return adjMatrix[u][v] == 1;
}
};
-
邻接表(Adjacency List):使用数组或链表来表示每个顶点的邻接顶点列表。对于每个顶点
v
,创建一个链表,其中包含所有与v
相邻的顶点。-
优点:对于稀疏图,存储空间效率较高。
-
缺点:查询边的存在需要遍历链表,时间复杂度为 O(n)(最坏情况下)。
-
class Graph {
public:
int V; // 顶点数
vector<list<int>> adjList; // 每个顶点的邻接表
Graph(int vertices) {
V = vertices;
adjList.resize(V);
}
// 添加边 (u, v)
void addEdge(int u, int v) {
adjList[u].push_back(v);
adjList[v].push_back(u); // 无向图,反向也要添加边
}
// 打印邻接表
void printList() {
for (int i = 0; i < V; i++) {
cout << "Vertex " << i << ": ";
for (int v : adjList[i]) {
cout << v << " ";
}
cout << endl;
}
}
// 判断是否有边 (u, v)
bool isEdge(int u, int v) {
for (int neighbor : adjList[u]) {
if (neighbor == v) {
return true;
}
}
return false;
}
};
3.图的遍历
图的遍历分为:
-
深度优先遍历(DFS,Depth-First Search):从一个顶点开始,沿着图的深度(尽可能深的方向)进行遍历。实现方式通常使用栈或递归。
void DFS(int v, const vector<vector<int>>& graph, vector<bool>& visited) {
visited[v] = true;
cout << v << " "; // 访问当前节点
// 遍历所有与v相邻的节点
for (int u : graph[v]) {
if (!visited[u]) {
DFS(u, graph, visited); // 递归访问未访问的邻居节点
}
}
}
-
广度优先遍历(BFS,Breadth-First Search):从一个顶点开始,首先访问该顶点的所有邻居,然后依次访问邻居的邻居,直到所有顶点都被访问。实现方式通常使用队列。
void BFS(int start, const vector<vector<int>>& graph, vector<bool>& visited) {
queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty()) {
int v = q.front();
q.pop();
cout << v << " "; // 访问当前节点
// 遍历所有与v相邻的节点
for (int u : graph[v]) {
if (!visited[u]) {
visited[u] = true;
q.push(u); // 将未访问的邻居节点入队
}
}
}
}
4.最小生成树
最小生成树(MST,Minimum Spanning Tree)是图中的一棵子树,包含图中所有顶点,并且边的权重之和最小。常见的算法有:
-
Kruskal 算法:基于边的权重排序,逐步选择最小权重的边,并通过并查集判断是否形成环。
struct Edge {
int u, v, weight;
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
class UnionFind {
public:
vector<int> parent, rank;
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 0);
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
void unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
}
}
};
void kruskal(int n, vector<Edge>& edges) {
UnionFind uf(n);
sort(edges.begin(), edges.end());
vector<Edge> mst; // 存储最小生成树的边
for (Edge& edge : edges) {
if (uf.find(edge.u) != uf.find(edge.v)) {
uf.unionSets(edge.u, edge.v);
mst.push_back(edge);
}
}
// 输出最小生成树的边
cout << "Minimum Spanning Tree edges:" << endl;
for (Edge& edge : mst) {
cout << edge.u << " - " << edge.v << " : " << edge.weight << endl;
}
}
-
Prim 算法:从一个起始顶点开始,逐步扩展到图中所有顶点,选择连接已选顶点的最小边。
void prim(const vector<vector<int>>& graph, int n) {
vector<int> key(n, INT_MAX); // key[i]表示从树中的顶点到顶点i的最小边权
vector<bool> inMST(n, false); // inMST[i]表示顶点i是否在最小生成树中
vector<int> parent(n, -1); // parent[i]表示顶点i的父节点
key[0] = 0; // 从顶点0开始
parent[0] = -1; // 起始点没有父节点
for (int count = 0; count < n - 1; count++) {
// 选择当前边权最小的顶点
int u = -1;
int minKey = INT_MAX;
for (int v = 0; v < n; v++) {
if (!inMST[v] && key[v] < minKey) {
minKey = key[v];
u = v;
}
}
// 将选择的顶点标记为已选
inMST[u] = true;
// 更新与u相邻的顶点的key值
for (int v = 0; v < n; v++) {
// 如果v不在MST中且从u到v的边更小,更新key值和parent
if (graph[u][v] != INT_MAX && !inMST[v] && graph[u][v] < key[v]) {
key[v] = graph[u][v];
parent[v] = u;
}
}
}
}
5.最短路
最短路径算法用于寻找图中两个顶点之间的最短路径。常见的算法有:
-
Dijkstra 算法:适用于图中所有边的权重为非负数,基于贪心策略。
struct Edge {
int v, weight;
};
void dijkstra(int start, const vector<vector<Edge>>& graph, int n) {
vector<int> dist(n, INT_MAX); // 初始化距离为无限大
dist[start] = 0;
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
pq.push({0, start}); // 存储(距离, 顶点)
while (!pq.empty()) {
int u = pq.top().second;
pq.pop();
// 遍历u的所有邻接点
for (const Edge& edge : graph[u]) {
int v = edge.v;
int weight = edge.weight;
// 松弛操作
if (dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
pq.push({dist[v], v});
}
}
}
}
-
Floyd 算法:用于计算所有顶点对之间的最短路径,适用于稠密图。
void floydWarshall(vector<vector<int>>& dist, int n) {
// 逐步更新所有节点对之间的最短路径
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] != INT_MAX && dist[k][j] != INT_MAX) {
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
}
6.拓扑排序(AOV)
拓扑排序是对有向无环图(DAG)进行排序,使得对于每条有向边 (u, v)
,顶点 u
在排序中出现在顶点 v
之前。常用的算法有:
-
Kahn 算法:通过计算入度为0的顶点进行排序。
void topologicalSortKahn(const vector<vector<int>>& graph, int n) {
vector<int> inDegree(n, 0); // 记录每个节点的入度
queue<int> q; // 用队列存储入度为0的节点
// 计算每个节点的入度
for (int i = 0; i < n; i++) {
for (int j = 0; j < graph[i].size(); j++) {
inDegree[graph[i][j]]++;
}
}
// 将入度为0的节点入队
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) {
q.push(i);
}
}
// 执行拓扑排序
int count = 0;
while (!q.empty()) {
int node = q.front();
q.pop();
cout << node << " "; // 输出当前节点
// 遍历当前节点的所有邻接节点
for (int neighbor : graph[node]) {
inDegree[neighbor]--;
if (inDegree[neighbor] == 0) {
q.push(neighbor);
}
}
count++;
}
// 如果排序后的节点数不等于图中节点数,说明图中有环
if (count != n) {
return false;
}
}
-
深度优先搜索(DFS):通过深度优先搜索计算拓扑序列。
7.关键路径(AOE)
关键路径法(Critical Path Method,CPM)用于项目管理中,主要用来计算项目中各个任务的最长时间路径。关键路径就是完成项目所需的最短时间路径,任何一个关键路径上的任务延迟都将影响整个项目的完成时间。
在AOE(Activity on Edge)网络中,每个活动用边表示,顶点表示活动的开始和结束。关键路径就是指从项目开始到结束的最长路径。
七、查找
1.简单查找
-
顺序查找:顺序查找是一种最简单的查找方法,逐个元素进行比较,直到找到目标元素或遍历完所有元素。时间复杂度为 O(n),适用于无序或小规模数据的查找。
-
折半查找(二分查找):折半查找是对有序序列进行的查找算法,通过将序列分成两半,每次与中间元素比较,从而缩小查找范围。时间复杂度为 O(log n),只能用于有序数组或列表。
-
分块查找:分块查找是将数据按块划分,然后在块内进行顺序查找,在块间进行折半查找。通过将查找范围限定在某一块内,能提高查找效率。适用于大规模数据,时间复杂度为 O(√n)。
2.二叉排序树(BST)
二叉排序树,也叫二叉搜索树,是一种特殊的二叉树,它的每个节点都满足以下条件:
-
对于任意一个节点,其左子树上所有节点的值都小于该节点的值。
-
对于任意一个节点,其右子树上所有节点的值都大于该节点的值。
性质:
-
每个节点最多有两个子节点(左子节点和右子节点)。
-
左子树和右子树也分别是二叉排序树。
优点:
-
查找操作:对于有序数据,BST 可以提供 O(log n) 的查找时间。
-
插入和删除操作:在平均情况下,插入和删除操作的时间复杂度也是 O(log n)。
缺点:
-
在最坏情况下(当树退化成链表时),查找、插入、删除操作的时间复杂度会退化为 O(n),因此需要额外的平衡机制。
3.红黑树
红黑树是一种自平衡的二叉搜索树,它通过额外的颜色标记来确保树的平衡性。
红黑树性质:
每个节点要么是红色,要么是黑色。
根节点必须是黑色。
每个叶子节点(即空节点)必须是黑色。
如果一个节点是红色的,则它的子节点必须是黑色的(即没有两个连续的红色节点)。
从任何一个节点到其所有叶子节点的路径上,必须经过相同数目的黑色节点。
特点:
-
红黑树的高度在 O(log n) 以内,因此保证了查找、插入和删除操作的时间复杂度为 O(log n)。
-
比 AVL 树在插入和删除时的平衡操作更简单,因此插入和删除操作比 AVL 树更高效。
优点:
-
提供对动态集合的高效支持,操作的时间复杂度为 O(log n)。
-
能保持平衡,避免了树退化为链表的情况。
4.平衡二叉树(AVL)
非常非常重要!!!
AVL树是一种自平衡的二叉搜索树,能够保证每个节点的左子树和右子树的高度差不超过1,从而保证了树的高度始终处于对数级别,避免了树的退化为链表的情况。AVL树是由俄罗斯数学家 Adelson-Velsky 和 Landis 在1962年提出的,是第一个实现了自平衡二叉树的算法。
AVL树是一棵二叉搜索树(BST),它的每个节点除了存储普通的元素外,还存储一个平衡因子(Balance Factor,BF),该平衡因子表示当前节点的左子树高度与右子树高度的差值。根据平衡因子的值,树会保持平衡状态,避免变成类似链表的结构。
- 平衡因子(BF):对于节点
n
,其平衡因子定义为:
BF(n) = Height(左子树) - Height(右子树)
- 如果
BF(n) = 0
,表示该节点的左右子树高度相等。 - 如果
BF(n) = 1
,表示该节点的左子树比右子树高。 - 如果
BF(n) = -1
,表示该节点的右子树比左子树高。 - AVL树的平衡条件:任何节点的平衡因子的绝对值不能大于1,即
|BF(n)| ≤ 1
。
AVL树的性质
- 二叉搜索树性质:左子树的所有节点值小于当前节点的值,右子树的所有节点值大于当前节点的值。
- 平衡性:每个节点的左右子树的高度差不超过1,保证了树的平衡。
- 高度限制:由于平衡因子的限制,AVL树的高度是对数级别的。树的高度最多为
log2(n)
,其中n
是树中节点的个数。
AVL树的操作
AVL树的操作包括插入、删除、查找和旋转等。
1. 查找操作
查找操作与普通的二叉搜索树(BST)相似,通过比较当前节点值与目标值来决定进入左子树还是右子树。查找操作的时间复杂度为 O(log n),因为AVL树保证了树的平衡。
2. 插入操作
插入节点的操作与普通的二叉搜索树相似,但插入后需要调整AVL树的平衡。插入节点后,可能会违反AVL树的平衡性,需要通过旋转操作来恢复平衡。插入操作的时间复杂度为 O(log n)。
3. 旋转操作
-
插入时可能会导致树的某些节点失去平衡(即平衡因子的绝对值大于1)。为恢复平衡,AVL树采用以下四种旋转操作:
- 单右旋(Right Rotation):当节点的左子树比右子树高,且左子树的左子树比右子树更高时,进行右旋。
- 单左旋(Left Rotation):当节点的右子树比左子树高,且右子树的右子树比左子树更高时,进行左旋。
- 左-右旋(Left-Right Rotation):当节点的左子树比右子树高,且左子树的右子树比左子树更高时,先进行左旋再进行右旋。
- 右-左旋(Right-Left Rotation):当节点的右子树比左子树高,且右子树的左子树比右子树更高时,先进行右旋再进行左旋。
旋转操作的目的是将失衡的部分重新调整,使得AVL树恢复平衡,保持其对数级别的高度。
4. 删除操作
删除操作的过程和插入操作类似。首先执行标准的二叉搜索树删除操作,然后检查树是否失衡。对于每个失衡的节点,进行旋转以恢复平衡。
删除节点后,可能会影响树的平衡性,因此可能需要对多个节点进行调整。与插入操作一样,删除操作的时间复杂度是 O(log n)。
旋转操作与插入时类似,根据失衡的类型,选择合适的旋转操作。
AVL树的旋转操作详解
-
单右旋(Right Rotation)
单右旋操作通常用于左子树高度比右子树高度大1,并且左子树的左子树比右子树更高的情况。旋转的目标是将失衡节点的左子节点提升为父节点,同时调整树的结构。
操作步骤:
- 将失衡节点的左子树的根节点提升为新的根节点。
- 将新的根节点的右子树设置为原根节点的左子树。
- 将原根节点变为新的右子节点。
Before Right Rotation:
X
/
Y
/
Z
After Right Rotation:
Y
/ \
Z X
-
单左旋(Left Rotation)
单左旋操作通常用于右子树高度比左子树高度大1,并且右子树的右子树比左子树更高的情况。旋转的目标是将失衡节点的右子节点提升为父节点,同时调整树的结构。
操作步骤:
- 将失衡节点的右子树的根节点提升为新的根节点。
- 将新的根节点的左子树设置为原根节点的右子树。
- 将原根节点变为新的左子节点。
Before Left Rotation: X \ Y \ Z After Left Rotation: Y / \ X Z
-
左-右旋(Left-Right Rotation)
左-右旋操作通常用于左子树比右子树高,并且左子树的右子树比左子树更高的情况。该操作是由左旋后接右旋来恢复平衡。
操作步骤:
- 首先对左子树进行左旋,然后对失衡节点进行右旋。
-
右-左旋(Right-Left Rotation)
右-左旋操作通常用于右子树比左子树高,并且右子树的左子树比右子树更高的情况。该操作是由右旋后接左旋来恢复平衡。
操作步骤:
- 首先对右子树进行右旋,然后对失衡节点进行左旋。
AVL树的优缺点
优点:
- 高效性:AVL树通过确保树的平衡,使得查找、插入和删除操作都能在 O(log n) 的时间内完成。
- 始终平衡:AVL树在任何情况下都保持平衡,避免了最坏情况下退化为链表的情况。
缺点:
- 较多的旋转操作:虽然AVL树保持了较高的查询效率,但每次插入或删除操作后,可能需要进行多次旋转来恢复平衡,因此旋转的开销相对较高。
- 复杂性:相比于普通的二叉搜索树,AVL树的实现较为复杂,尤其是在插入和删除操作时需要执行平衡和旋转。
AVL树代码实现
// AVL树节点结构
typedef struct Node {
int key;
struct Node *left, *right;
int height;
} Node;
// 获取节点的高度
int height(Node *N) {
if (N == NULL)
return 0;
return N->height;
}
// 获取节点的平衡因子
int getBalance(Node *N) {
if (N == NULL)
return 0;
return height(N->left) - height(N->right);
}
// 创建新节点
Node* newNode(int key) {
Node* node = (Node*)malloc(sizeof(Node));
node->key = key;
node->left = node->right = NULL;
node->height = 1; // 新节点高度为1
return node;
}
// 右旋操作
Node* rightRotate(Node *y) {
Node *x = y->left;
Node *T2 = x->right;
// 执行旋转
x->right = y;
y->left = T2;
// 更新高度
y->height = (height(y->left) > height(y->right) ? height(y->left) : height(y->right)) + 1;
x->height = (height(x->left) > height(x->right) ? height(x->left) : height(x->right)) + 1;
return x;
}
// 左旋操作
Node* leftRotate(Node *x) {
Node *y = x->right;
Node *T2 = y->left;
// 执行旋转
y->left = x;
x->right = T2;
// 更新高度
x->height = (height(x->left) > height(x->right) ? height(x->left) : height(x->right)) + 1;
y->height = (height(y->left) > height(y->right) ? height(y->left) : height(y->right)) + 1;
return y;
}
// 插入节点
Node* insert(Node* node, int key) {
// 1. 插入节点
if (node == NULL)
return newNode(key);
if (key < node->key)
node->left = insert(node->left, key);
else if (key > node->key)
node->right = insert(node->right, key);
else // 相同的键不允许插入
return node;
// 2. 更新该节点的高度
node->height = 1 + (height(node->left) > height(node->right) ? height(node->left) : height(node->right));
// 3. 获取该节点的平衡因子,检查该节点是否失衡
int balance = getBalance(node);
// 4. 如果该节点失衡,则进行相应的旋转
// 左左情况
if (balance > 1 && key < node->left->key)
return rightRotate(node);
// 右右情况
if (balance < -1 && key > node->right->key)
return leftRotate(node);
// 左右情况
if (balance > 1 && key > node->left->key) {
node->left = leftRotate(node->left);
return rightRotate(node);
}
// 右左情况
if (balance < -1 && key < node->right->key) {
node->right = rightRotate(node->right);
return leftRotate(node);
}
// 返回未改变的节点指针
return node;
}
// 删除节点
Node* delete(Node* root, int key) {
// 1. 执行标准的BST删除操作
if (root == NULL)
return root;
if (key < root->key)
root->left = delete(root->left, key);
else if (key > root->key)
root->right = delete(root->right, key);
else {
// 该节点为要删除的节点
if (root->left == NULL) {
Node* temp = root->right;
free(root);
return temp;
}
else if (root->right == NULL) {
Node* temp = root->left;
free(root);
return temp;
}
// 找到右子树中的最小节点
Node* temp = root->right;
while (temp && temp->left != NULL)
temp = temp->left;
root->key = temp->key; // 用右子树的最小值替代当前节点
// 删除右子树中的最小节点
root->right = delete(root->right, temp->key);
}
// 2. 更新该节点的高度
root->height = 1 + (height(root->left) > height(root->right) ? height(root->left) : height(root->right));
// 3. 获取该节点的平衡因子,检查是否失衡
int balance = getBalance(root);
// 4. 如果该节点失衡,则进行相应的旋转
// 左左情况
if (balance > 1 && getBalance(root->left) >= 0)
return rightRotate(root);
// 左右情况
if (balance > 1 && getBalance(root->left) < 0) {
root->left = leftRotate(root->left);
return rightRotate(root);
}
// 右右情况
if (balance < -1 && getBalance(root->right) <= 0)
return leftRotate(root);
// 右左情况
if (balance < -1 && getBalance(root->right) > 0) {
root->right = rightRotate(root->right);
return leftRotate(root);
}
return root;
}
详细介绍见平衡二叉树
5.B树与B+树
B树: B 树是一种自平衡的多路查找树,广泛用于文件系统和数据库中。B 树的节点不仅保存数据,还保存多个子节点的指针,支持多个子树。它的关键特点是:
-
阶数:B 树的阶数(或度)决定了每个节点能够拥有的最大子节点数。
-
平衡性:所有叶子节点都位于同一层。
-
搜索效率:B 树将数据分成了多个部分,每部分可以存储多个元素,能够有效减少 I/O 操作,适合用于外部存储(如磁盘)。
B+树: B+ 树是 B 树的变种,主要区别在于:
-
数据存储:B 树的所有节点都可以存储数据,而 B+ 树只在叶子节点存储数据,内部节点仅用于索引。
-
叶子节点链表:B+ 树的所有叶子节点通过链表连接,支持快速的范围查询。
6.散列表(Hash表)
散列表是一种通过哈希函数将元素映射到数组位置的数据结构。其基本思想是使用哈希函数将键值(key)映射到一个固定大小的数组下标(索引),并将对应的值(value)存储在该位置。
#define TABLE_SIZE 10 // 哈希表的大小
// 哈希表节点
typedef struct Node {
int key;
int value;
struct Node* next;
} Node;
// 哈希表
typedef struct HashTable {
Node* table[TABLE_SIZE];
} HashTable;
// 创建新节点
Node* createNode(int key, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->key = key;
newNode->value = value;
newNode->next = NULL;
return newNode;
}
// 创建哈希表
HashTable* createHashTable() {
HashTable* hashTable = (HashTable*)malloc(sizeof(HashTable));
for (int i = 0; i < TABLE_SIZE; i++) {
hashTable->table[i] = NULL;
}
return hashTable;
}
工作原理:
-
哈希函数:将元素的键值转换为一个整数,该整数作为数组的下标。
-
冲突解决:当两个元素被映射到同一个数组位置时,发生哈希冲突。
常见的冲突解决方法有:
-
链式法:每个数组位置存储一个链表,将哈希冲突的元素放入该链表中。
-
开放定址法:在数组中寻找下一个空位置,存储冲突的元素。
// 哈希函数
int hash(int key) {
return key % TABLE_SIZE;
}
// 插入元素
void insert(HashTable* hashTable, int key, int value) {
int index = hash(key);
Node* newNode = createNode(key, value);
// 如果该位置为空,直接插入
if (hashTable->table[index] == NULL) {
hashTable->table[index] = newNode;
} else {
// 否则,链式插入
newNode->next = hashTable->table[index];
hashTable->table[index] = newNode;
}
}
// 查找元素
int search(HashTable* hashTable, int key) {
int index = hash(key);
Node* current = hashTable->table[index];
// 遍历链表查找元素
while (current != NULL) {
if (current->key == key) {
return current->value;
}
current = current->next;
}
// 如果没有找到,返回 -1
return -1;
}
// 删除元素
void delete(HashTable* hashTable, int key) {
int index = hash(key);
Node* current = hashTable->table[index];
Node* prev = NULL;
// 查找待删除元素
while (current != NULL && current->key != key) {
prev = current;
current = current->next;
}
// 如果元素不存在
if (current == NULL) {
printf("Key not found\n");
return;
}
// 如果该节点是第一个节点
if (prev == NULL) {
hashTable->table[index] = current->next;
} else {
prev->next = current->next;
}
free(current);
}
优点:
-
查找、插入和删除操作的平均时间复杂度为 O(1),因此适合高效的数据查找。
-
哈希表的查询速度非常快,特别是对于需要频繁查找的应用。
缺点:
-
哈希函数的选择非常重要,差的哈希函数可能会导致大量的冲突,影响性能。
-
由于可能发生哈希冲突,实际性能受到哈希函数和冲突解决方法的影响。
八、排序
1.插入排序
插入排序是一种简单的排序算法,它的基本思想是将一个待排序的元素插入到已排序序列的正确位置。通过不断地向已排序部分插入新的元素,直到所有元素都有序。
-
时间复杂度:
-
最坏情况:O(n²)
-
最好情况:O(n)
-
平均情况:O(n²)
-
void binaryInsertSort(int arr[], int n) {
for(int i = 1; i < n; i++) {
int key = arr[i];
int left = 0;
int right = i - 1;
while(left <= right) {
int mid = (left + right) / 2;
if(arr[mid] > key)
right = mid - 1;
else
left = mid + 1;
}
for(int j = i - 1; j >= left; j--) {
arr[j + 1] = arr[j];
}
arr[left] = key;
}
}
2.冒泡排序
冒泡排序是一种简单的交换排序,通过重复地交换相邻的元素,将较大的元素“冒泡”到序列的末尾,直到所有元素排序完成。
-
时间复杂度:
-
最坏情况:O(n²)
-
最好情况:O(n)
-
平均情况:O(n²)
-
void bubbleSort(int arr[], int n) {
for(int i = 0; i < n - 1; i++) {
bool swapped = false;
for(int j = 0; j < n - 1 - i; j++) {
if(arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
swapped = true;
}
}
if(!swapped) break;
}
}
3.快速排序
快速排序是一种分治法的排序算法。它通过选择一个基准元素,将序列分成两部分,一部分比基准元素小,另一部分比基准元素大,然后递归地对这两部分继续排序。
-
时间复杂度:
-
最坏情况:O(n²)(当每次划分都非常不均匀时)
-
最好情况:O(n log n)
-
平均情况:O(n log n)
-
void quickSort(int arr[], int low, int high) {
if(low < high) {
int pivot = arr[high];
int i = low - 1;
for(int j = low; j < high; j++) {
if(arr[j] <= pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]);
int pi = i + 1;
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
4.希尔排序
希尔排序是一种基于插入排序的排序算法,采用分组的方式来改善插入排序的效率。通过逐渐减小步长,对数组进行多轮排序,最终步长为1时,再执行一次插入排序。
-
时间复杂度:
-
最坏情况:O(n^3/2)
-
最好情况:O(n log n)
-
平均情况:O(n^3/2)
-
void shellSort(int arr[], int n) {
for(int gap = n/2; gap > 0; gap /= 2) {
for(int i = gap; i < n; i++) {
int temp = arr[i];
int j;
for(j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
5.归并排序
归并排序是一种采用分治法的排序算法,将数组分成两个子数组,对每个子数组递归排序,然后将两个已排序的子数组合并成一个有序数组。
-
时间复杂度:
-
最坏情况:O(n log n)
-
最好情况:O(n log n)
-
平均情况:O(n log n)
-
void merge(int arr[], int left, int mid, int right) {
int n1 = mid - left + 1;
int n2 = right - mid;
int* L = new int[n1];
int* R = new int[n2];
for(int i = 0; i < n1; i++)
L[i] = arr[left + i];
for(int j = 0; j < n2; j++)
R[j] = arr[mid + 1 + j];
int i = 0, j = 0, k = left;
while(i < n1 && j < n2) {
if(L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
while(i < n1) {
arr[k] = L[i];
i++;
k++;
}
while(j < n2) {
arr[k] = R[j];
j++;
k++;
}
delete[] L;
delete[] R;
}
void mergeSort(int arr[], int left, int right) {
if(left < right) {
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
}
6.堆排序
堆排序是一种基于堆数据结构的排序算法,堆是一棵完全二叉树,堆排序通过构建最大堆或最小堆,将数组中的元素进行排序。
-
时间复杂度:
-
最坏情况:O(n log n)
-
最好情况:O(n log n)
-
平均情况:O(n log n)
-
void heapify(int arr[], int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if(left < n && arr[left] > arr[largest])
largest = left;
if(right < n && arr[right] > arr[largest])
largest = right;
if(largest != i) {
swap(arr[i], arr[largest]);
heapify(arr, n, largest);
}
}
void heapSort(int arr[], int n) {
for(int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
for(int i = n - 1; i > 0; i--) {
swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
7.多路归并排序
多路归并排序是归并排序的一种扩展,适用于将多个有序序列合并成一个有序序列的情况。它是一种分治法(Divide and Conquer)思想的排序算法,可以有效地处理外部排序问题。
在外部排序中,数据量非常大,超出了内存的存储能力,因此不能一次性将所有数据加载到内存中进行排序。多路归并排序就是为了这种场景而设计的,它通过将数据分成多个部分分别排序,然后将这些有序部分合并成最终的有序结果。
主要特点与应用
多路合并:在多路归并排序中,我们将输入数据分成多个子序列,每个子序列是有序的,然后使用多路归并的方法将这些有序子序列合并成一个最终的有序序列。常见的实现方法是使用最小堆来从各个有序子序列中取出最小元素。
外部排序:多路归并排序通常应用于外部排序问题,特别是当待排序数据集过大,无法一次加载到内存时,常常将数据分成多个部分,利用磁盘存储进行归并排序。
分治法:类似于标准的归并排序,多路归并排序的核心思想也是分治法。通过递归地将数据集划分为更小的子集,直到每个子集都已排序,然后进行合并。
工作原理
-
数据划分: 将大规模数据划分为多个较小的子集,这些子集可以在内存中排序或从磁盘中读取。如果数据量足够小,可以直接在内存中处理。
-
归并过程: 通过多路归并(通常使用堆或者优先队列),将多个有序子集合并成一个更大的有序集合。对于大规模数据,这个过程需要通过磁盘或其他存储介质进行多轮归并。
-
合并: 假设我们有
k
个有序序列,我们可以使用一个大小为k
的最小堆来进行合并。在每次合并时,堆中存储的是当前各个序列中的最小元素,通过堆的性质,可以高效地获取最小元素并将其从堆中移除,同时将该元素所在序列中的下一个元素插入堆中。