839 lines
33 KiB
Markdown
839 lines
33 KiB
Markdown
# 线性结构
|
||
|
||
## 双端栈
|
||
|
||
双端栈(**Deque Stack** 或 **Double-ended Stack**)是一种允许在两端进行操作的栈数据结构。通常的栈只能在一端进行操作(称为**栈顶**),即**后进先出**(LIFO, Last In First Out),但双端栈允许在栈的**两端**都可以执行进栈和出栈操作。
|
||
|
||
> 注意,双端栈和双端队列在C++中都可以用std::deque来实现,只有使用上的区别
|
||
|
||
### 双端栈的特点
|
||
|
||
1. **两端操作**:双端栈可以在栈的**两端**进行进栈(push)和出栈(pop),即可以从栈的前端或后端插入和删除元素。
|
||
2. **灵活性**:与单端栈相比,双端栈提供了更大的灵活性,因为可以根据需要选择从哪一端操作数据。
|
||
|
||
### 操作
|
||
|
||
假设双端栈具有两个端点:
|
||
|
||
- **左端(front)**
|
||
- **右端(back)**
|
||
|
||
主要操作包括:
|
||
|
||
- **`push_front()`**:在左端插入元素。
|
||
- **`push_back()`**:在右端插入元素。
|
||
- **`pop_front()`**:从左端弹出元素。
|
||
- **`pop_back()`**:从右端弹出元素。
|
||
- **`front()`**:访问左端的元素。
|
||
- **`back()`**:访问右端的元素。
|
||
|
||
### 实现
|
||
|
||
双端栈通常是通过**双端队列(deque,double-ended queue)**实现的。C++ 中的标准库容器 `std::deque` 就是一个可以在两端高效进行插入和删除操作的数据结构,非常适合用来实现双端栈。
|
||
|
||
### 应用
|
||
|
||
双端栈适合用于一些需要在两端频繁操作的场景,比如:
|
||
|
||
- **滑动窗口问题**:在滑动窗口中,你可能需要同时在窗口的两端添加或移除元素。
|
||
- **双向搜索算法**:在某些搜索算法中,可以从两端同时进行搜索。
|
||
|
||
## 双端队列
|
||
|
||
双端队列(Deque,**Double-Ended Queue**)是一种特殊的队列数据结构,它允许在**两端进行插入和删除**操作。与普通队列(只能从一端入队、另一端出队)不同,双端队列可以从**队首和队尾**进行入队和出队操作,因此具有更大的灵活性。
|
||
|
||
### 双端队列的主要操作:
|
||
|
||
1. **从队首插入元素(push_front)**:在队列的前端插入一个元素。
|
||
2. **从队尾插入元素(push_back)**:在队列的末尾插入一个元素。
|
||
3. **从队首删除元素(pop_front)**:移除队列前端的元素。
|
||
4. **从队尾删除元素(pop_back)**:移除队列末尾的元素。
|
||
5. **访问队首元素(front)**:查看队列前端的元素。
|
||
6. **访问队尾元素(back)**:查看队列末端的元素。
|
||
|
||
### 双端队列的分类:
|
||
|
||
- **输入受限双端队列**:只允许从队首删除元素,但只能从一端插入。
|
||
- **输出受限双端队列**:只允许从队尾插入元素,但可以从两端删除元素。
|
||
|
||
### 双端队列的常见应用:
|
||
|
||
- **滑动窗口问题**:通过双端队列可以高效地维护一组窗口内的最大或最小值,常用于动态数据流的问题。
|
||
- **任务调度**:在一些调度问题中,可以根据任务的优先级从两端进行任务插入和删除。
|
||
|
||
### 实现方式:
|
||
|
||
双端队列可以使用**双向链表**或**动态数组**实现。使用双向链表可以在常数时间内从两端插入和删除元素,而使用动态数组在平均情况下可以实现较高的访问效率,但插入和删除操作的效率可能会有所下降,尤其是在需要调整数组大小的时候。
|
||
|
||
### C++中的双端队列:
|
||
|
||
C++ 标准库中提供了 `deque`容器,它支持双端队列的所有基本操作。例如:
|
||
|
||
```cpp
|
||
#include <deque>
|
||
#include <iostream>
|
||
|
||
int main() {
|
||
std::deque<int> dq;
|
||
|
||
dq.push_back(1); // 在队尾插入1
|
||
dq.push_front(2); // 在队首插入2
|
||
|
||
std::cout << dq.front() << std::endl; // 输出队首元素(2)
|
||
std::cout << dq.back() << std::endl; // 输出队尾元素(1)
|
||
|
||
dq.pop_back(); // 移除队尾元素
|
||
dq.pop_front(); // 移除队首元素
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
要用指针实现一个双端队列(Deque),通常使用**双向链表**作为底层数据结构。双向链表中的每个节点都包含指向前驱节点和后继节点的指针,这样我们可以在常数时间内从两端进行插入和删除操作。
|
||
|
||
### 设计思路
|
||
|
||
1. **节点结构**:双向链表的每个节点需要包含一个数据字段,以及两个指针字段:分别指向前一个节点和后一个节点。
|
||
2. **双端队列结构**:需要维护两个指针,分别指向队列的头节点(front)和尾节点(rear)。还需要跟踪队列的大小。
|
||
|
||
### 操作逻辑
|
||
|
||
- **插入操作**:插入时需要更新相应端的指针,同时要处理队列为空时的特殊情况。
|
||
- **删除操作**:从队列的两端删除元素后,更新指针,处理删除后队列为空的情况。
|
||
- **访问操作**:队首和队尾的元素可以直接通过指针访问。
|
||
|
||
### 双向链表节点的定义
|
||
|
||
```cpp
|
||
struct Node {
|
||
int data;
|
||
Node* prev; // 指向前一个节点
|
||
Node* next; // 指向后一个节点
|
||
Node(int val) : data(val), prev(nullptr), next(nullptr) {}
|
||
};
|
||
```
|
||
|
||
### 双端队列类的定义
|
||
|
||
```cpp
|
||
class Deque {
|
||
private:
|
||
Node* front; // 队首指针
|
||
Node* rear; // 队尾指针
|
||
int size; // 当前队列中的元素个数
|
||
|
||
public:
|
||
Deque() : front(nullptr), rear(nullptr), size(0) {}
|
||
|
||
// 检查队列是否为空
|
||
bool isEmpty() {
|
||
return size == 0;
|
||
}
|
||
|
||
// 返回队列的大小
|
||
int getSize() {
|
||
return size;
|
||
}
|
||
|
||
// 在队首插入元素
|
||
void push_front(int val) {
|
||
Node* newNode = new Node(val);
|
||
if (isEmpty()) {
|
||
front = rear = newNode; // 如果队列为空,队首和队尾都指向新节点
|
||
} else {
|
||
newNode->next = front; // 新节点的next指向当前的队首
|
||
front->prev = newNode; // 当前队首的prev指向新节点
|
||
front = newNode; // 更新队首指针
|
||
}
|
||
size++;
|
||
}
|
||
|
||
// 在队尾插入元素
|
||
void push_back(int val) {
|
||
Node* newNode = new Node(val);
|
||
if (isEmpty()) {
|
||
front = rear = newNode; // 如果队列为空,队首和队尾都指向新节点
|
||
} else {
|
||
newNode->prev = rear; // 新节点的prev指向当前的队尾
|
||
rear->next = newNode; // 当前队尾的next指向新节点
|
||
rear = newNode; // 更新队尾指针
|
||
}
|
||
size++;
|
||
}
|
||
|
||
// 从队首删除元素
|
||
void pop_front() {
|
||
if (isEmpty()) {
|
||
std::cout << "Deque is empty, cannot pop from front!" << std::endl;
|
||
return;
|
||
}
|
||
Node* temp = front;
|
||
front = front->next; // 更新队首指针
|
||
if (front != nullptr) {
|
||
front->prev = nullptr; // 队首前驱指针置空
|
||
} else {
|
||
rear = nullptr; // 如果删除后队列为空,更新队尾
|
||
}
|
||
delete temp; // 释放节点内存
|
||
size--;
|
||
}
|
||
|
||
// 从队尾删除元素
|
||
void pop_back() {
|
||
if (isEmpty()) {
|
||
std::cout << "Deque is empty, cannot pop from back!" << std::endl;
|
||
return;
|
||
}
|
||
Node* temp = rear;
|
||
rear = rear->prev; // 更新队尾指针
|
||
if (rear != nullptr) {
|
||
rear->next = nullptr; // 队尾后继指针置空
|
||
} else {
|
||
front = nullptr; // 如果删除后队列为空,更新队首
|
||
}
|
||
delete temp; // 释放节点内存
|
||
size--;
|
||
}
|
||
|
||
// 获取队首元素
|
||
int getFront() {
|
||
if (isEmpty()) {
|
||
std::cout << "Deque is empty, no front element!" << std::endl;
|
||
return -1;
|
||
}
|
||
return front->data;
|
||
}
|
||
|
||
// 获取队尾元素
|
||
int getBack() {
|
||
if (isEmpty()) {
|
||
std::cout << "Deque is empty, no back element!" << std::endl;
|
||
return -1;
|
||
}
|
||
return rear->data;
|
||
}
|
||
|
||
// 打印双端队列
|
||
void printDeque() {
|
||
if (isEmpty()) {
|
||
std::cout << "Deque is empty!" << std::endl;
|
||
return;
|
||
}
|
||
Node* current = front;
|
||
while (current != nullptr) {
|
||
std::cout << current->data << " ";
|
||
current = current->next;
|
||
}
|
||
std::cout << std::endl;
|
||
}
|
||
|
||
// 析构函数,释放所有节点
|
||
~Deque() {
|
||
while (!isEmpty()) {
|
||
pop_front();
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
### 代码解释
|
||
|
||
- **push_front** 和 **push_back** 分别用于在队首和队尾插入新元素。
|
||
- **pop_front** 和 **pop_back** 分别用于从队首和队尾删除元素。
|
||
- **getFront** 和 **getBack** 用于访问队首和队尾元素。
|
||
- **printDeque** 打印队列中的所有元素。
|
||
- 析构函数负责在对象销毁时释放所有节点,防止内存泄漏。
|
||
|
||
### 示例
|
||
|
||
```cpp
|
||
int main() {
|
||
Deque dq;
|
||
dq.push_back(10); // 队尾插入10
|
||
dq.push_front(20); // 队首插入20
|
||
dq.push_back(30); // 队尾插入30
|
||
|
||
dq.printDeque(); // 输出: 20 10 30
|
||
|
||
dq.pop_front(); // 队首删除
|
||
dq.printDeque(); // 输出: 10 30
|
||
|
||
dq.pop_back(); // 队尾删除
|
||
dq.printDeque(); // 输出: 10
|
||
|
||
std::cout << "Front: " << dq.getFront() << std::endl; // 输出队首元素: 10
|
||
std::cout << "Back: " << dq.getBack() << std::endl; // 输出队尾元素: 10
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
### 运行结果
|
||
|
||
```
|
||
20 10 30
|
||
10 30
|
||
10
|
||
Front: 10
|
||
Back: 10
|
||
```
|
||
|
||
在 C++ 标准库中,`deque`(双端队列)既支持**双端插入和删除**,又可以进行**随机访问**,这与其底层实现方式有关。C++ 的 `deque` 并不是直接用链表实现的,而是通过一种特殊的**分段连续存储**(segmented array)来实现,这使得它可以兼具高效的双端插入删除和随机访问功能。
|
||
|
||
### `deque` 的底层实现原理
|
||
|
||
`deque` 的底层通常是一个类似**动态数组的块表结构**,由一组**连续的小块**(chunk 或者 block)组成。每一个小块大小固定,而这些小块之间不需要在内存中是连续的。为了管理这些小块,`deque` 会使用一个指针数组(类似索引表),每个指针指向一个块。这种设计使得 `deque` 既能像链表一样支持高效的双端操作,也能像数组一样支持随机访问。
|
||
|
||
#### 具体结构:
|
||
|
||
1. **块表(map)**:`deque` 使用一个指针数组来管理多个小块(chunk/block)。每个指针指向一个内存块,每个内存块保存一定数量的元素。
|
||
2. **小块(block)**:每个小块内存大小固定,`deque` 元素分布在不同的小块中,但不同的小块在物理内存中不一定连续。
|
||
3. **分段存储**:当你从队首或队尾插入元素时,`deque` 会动态调整块表和小块的数量,不需要像数组那样整体移动数据。
|
||
|
||
#### 关键点:
|
||
|
||
- **插入和删除**:从队首和队尾插入删除元素时,`deque` 只需在块表的头部或尾部插入新块,或者移除块表的头尾块。因此,插入删除的开销是常数时间(O(1)),和链表类似。
|
||
- **随机访问**:尽管不同小块的内存位置不连续,但由于使用了块表和固定大小的小块,随机访问时,`deque` 通过两步操作来计算元素位置:
|
||
|
||
- **首先确定元素在块表中的位置**:通过除法 `index / block_size`,确定该元素属于哪个块。
|
||
- **再定位块内偏移**:通过取模 `index % block_size`,确定该元素在该块内的偏移量。
|
||
|
||
这两步操作的时间复杂度都是 O(1),因此 `deque` 可以像数组一样提供 O(1) 的随机访问。
|
||
|
||
### `deque` 和 `vector` 的对比
|
||
|
||
- **`vector`**:`vector` 是一个连续的动态数组,所有元素存储在一块连续的内存区域中。它提供了高效的随机访问(O(1)),但是插入和删除操作(尤其是队首操作)在最坏情况下需要移动大量元素,开销较大。
|
||
- **`deque`**:`deque` 是一种分段的存储方式,不需要保证整个数据在内存中的连续性。这使得它可以同时支持高效的双端插入删除(O(1)),并且还能在 O(1) 时间内随机访问元素。
|
||
|
||
### 结论
|
||
|
||
C++ 中的 `deque` 之所以可以支持随机访问,是因为它的底层采用了**分段数组**的存储方式,而不是链表。虽然 `deque` 的内存布局是分散的,但通过使用块表和小块,`deque` 可以在常数时间内定位任意元素的位置,从而实现与数组类似的随机访问性能。
|
||
|
||
因此,`deque` 是一种非常灵活的数据结构,既可以支持高效的随机访问,又能高效地从两端插入和删除元素。
|
||
|
||
## 单调队列
|
||
|
||
单调队列(Monotonic Queue)是一种特别的数据结构,常用于解决一些具有滑动窗口性质的问题。在处理滑动窗口最大值、最小值或者其他具有区间性质的优化问题时,单调队列能够有效地减少时间复杂度,使一些暴力解法从 \(O(n^2)\) 优化为 \(O(n)\) 或 \(O(n \log n)\)。
|
||
|
||
### 单调队列的作用
|
||
|
||
单调队列的主要作用是:
|
||
|
||
1. **在滑动窗口中维护最大值或最小值**:单调队列可以在常数时间内得到滑动窗口的最值。通过维护一个递增或递减的队列,在每次窗口滑动时,可以快速移除不符合要求的元素,并保持队列的单调性。
|
||
2. **优化动态区间问题**:当我们需要动态地在某个区间内找最大/最小值时,单调队列可以在 \(O(n)\) 的时间复杂度内处理问题,而不需要每次重新遍历区间。
|
||
|
||
### 基本原理
|
||
|
||
单调队列之所以高效,关键在于保持队列中的元素是有序的。根据需求,可以维护递增或递减队列:
|
||
|
||
- **递增队列**:队列中的元素从头到尾是递增的,这样可以在队列头部得到最小值。
|
||
- **递减队列**:队列中的元素从头到尾是递减的,这样可以在队列头部得到最大值。
|
||
|
||
每当滑动窗口移动时,我们会将新的元素插入队列,同时确保队列的单调性。队列中的元素可能会失效(比如已经不在滑动窗口的范围内),这些元素需要被及时移除。
|
||
|
||
### C++ 实现滑动窗口最大值问题
|
||
|
||
接下来,我们以经典的“滑动窗口最大值”问题为例,给出单调队列的 C++ 实现。
|
||
|
||
#### 题目描述
|
||
|
||
给定一个大小为 \(n\) 的数组,和一个大小为 \(k\) 的滑动窗口。窗口从数组的左端移动到右端,每次只向右移动一位,求每个窗口中的最大值。
|
||
|
||
#### C++ 实现代码
|
||
|
||
```cpp
|
||
#include <iostream>
|
||
#include <deque>
|
||
#include <vector>
|
||
|
||
using namespace std;
|
||
|
||
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
|
||
deque<int> dq; // 单调队列,存储的是数组元素的下标
|
||
vector<int> result;
|
||
|
||
for (int i = 0; i < nums.size(); ++i) {
|
||
// 移除已经不在窗口范围内的元素
|
||
if (!dq.empty() && dq.front() == i - k) {
|
||
dq.pop_front();
|
||
}
|
||
|
||
// 移除队列中所有小于当前元素的值,因为它们不可能成为最大值
|
||
while (!dq.empty() && nums[dq.back()] < nums[i]) {
|
||
dq.pop_back();
|
||
}
|
||
|
||
// 将当前元素下标添加到队列
|
||
dq.push_back(i);
|
||
|
||
// 当窗口长度达到k时,记录当前窗口的最大值
|
||
if (i >= k - 1) {
|
||
result.push_back(nums[dq.front()]); // 队列头部元素就是当前窗口的最大值
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
int main() {
|
||
vector<int> nums = {1, 3, -1, -3, 5, 3, 6, 7};
|
||
int k = 3;
|
||
vector<int> result = maxSlidingWindow(nums, k);
|
||
|
||
for (int max_val : result) {
|
||
cout << max_val << " ";
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
#### 代码解释
|
||
|
||
1. **deque**:我们使用双端队列 `dq` 来存储数组元素的下标,确保队列中的元素始终是从大到小排列的。这样队列头部的元素永远是当前窗口的最大值。
|
||
2. **删除无效元素**:如果队列中的元素已经不在当前滑动窗口的范围内(即下标小于 \(i - k + 1\)),我们将其从队列头部移除。
|
||
3. **保持队列单调性**:在将当前元素插入队列之前,移除所有队列中比当前元素小的元素,因为它们不可能再成为未来窗口的最大值。
|
||
4. **记录结果**:一旦窗口大小达到 \(k\),我们将队列头部的元素(即最大值)加入结果数组。
|
||
|
||
### 时间复杂度
|
||
|
||
该算法的时间复杂度为 \(O(n)\),因为每个元素最多被插入和移除队列一次。
|
||
|
||
## 优先队列
|
||
优先队列(Priority Queue)是一种特殊的队列数据结构,其中每个元素都有一个优先级。在优先队列中,**出队操作会优先处理优先级最高的元素**,而不是像普通队列那样遵循“先进先出”(FIFO)的原则。
|
||
|
||
### 特性
|
||
1. **插入元素**(Enqueue):可以将元素加入队列,通常是 O(log n) 时间复杂度。
|
||
2. **取出最大/最小元素**(Dequeue):每次从队列中取出优先级最高的元素,通常是 O(log n) 时间复杂度。
|
||
3. **查看最大/最小元素**:可以在 O(1) 时间内查看当前优先级最高的元素。
|
||
|
||
优先队列常用于需要频繁处理最高优先级任务的场景,比如操作系统的进程调度、图算法中的最短路径问题(如 Dijkstra 算法)。
|
||
|
||
---
|
||
|
||
### C++ 中的实现
|
||
|
||
C++ 标准库提供了一个名为 `std::priority_queue` 的容器,可以直接用来实现优先队列。这个容器底层实现通常是**堆**(默认是最大堆),即每次访问的是最大元素。
|
||
|
||
#### 1. **默认最大堆实现**
|
||
```cpp
|
||
#include <iostream>
|
||
#include <queue>
|
||
#include <vector>
|
||
|
||
int main() {
|
||
// 创建一个最大堆的优先队列
|
||
std::priority_queue<int> pq;
|
||
|
||
// 插入元素
|
||
pq.push(10);
|
||
pq.push(20);
|
||
pq.push(15);
|
||
|
||
// 输出并移除优先级最高的元素
|
||
std::cout << "优先级最高的元素: " << pq.top() << std::endl; // 输出 20
|
||
pq.pop();
|
||
|
||
std::cout << "第二高优先级的元素: " << pq.top() << std::endl; // 输出 15
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
#### 2. **最小堆的实现**
|
||
默认情况下,`std::priority_queue` 是最大堆,要实现最小堆,可以通过**自定义比较函数**来实现。可以利用 `std::greater` 或使用 lambda 表达式。
|
||
|
||
```cpp
|
||
#include <iostream>
|
||
#include <queue>
|
||
#include <vector>
|
||
|
||
int main() {
|
||
// 创建一个最小堆的优先队列
|
||
std::priority_queue<int, std::vector<int>, std::greater<int>> pq;
|
||
|
||
// 插入元素
|
||
pq.push(10);
|
||
pq.push(20);
|
||
pq.push(15);
|
||
|
||
// 输出并移除优先级最高的元素(最小堆,输出最小值)
|
||
std::cout << "优先级最高的元素: " << pq.top() << std::endl; // 输出 10
|
||
pq.pop();
|
||
|
||
std::cout << "第二高优先级的元素: " << pq.top() << std::endl; // 输出 15
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
#### 3. **自定义优先级的对象**
|
||
如果队列中存放的是自定义对象,则需要为该对象实现比较函数。例如,假设有一个任务结构体,优先级根据任务的优先级数值决定:
|
||
|
||
```cpp
|
||
#include <iostream>
|
||
#include <queue>
|
||
#include <vector>
|
||
|
||
struct Task {
|
||
int id;
|
||
int priority;
|
||
|
||
// 自定义比较运算符,使得 priority 小的优先处理
|
||
bool operator<(const Task& other) const {
|
||
return priority < other.priority;
|
||
}
|
||
};
|
||
|
||
int main() {
|
||
std::priority_queue<Task> pq;
|
||
|
||
// 插入任务
|
||
pq.push({1, 5});
|
||
pq.push({2, 3});
|
||
pq.push({3, 10});
|
||
|
||
// 输出并移除优先级最高的任务
|
||
std::cout << "优先级最高的任务 ID: " << pq.top().id << std::endl; // 输出 3 (priority 10)
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
这里定义了 `operator<` 来决定优先级高低。默认情况下,`std::priority_queue` 会将优先级最高的任务(`priority` 最大)排在队首。
|
||
|
||
### 底层实现
|
||
`std::priority_queue` 底层使用的是**堆**(heap),常见实现是**二叉堆**。堆是一种二叉树的完全树结构,其中最大堆的父节点总是大于等于其子节点,最小堆的父节点总是小于等于其子节点。堆的性质使得插入和删除操作可以在 O(log n) 时间复杂度内完成。
|
||
|
||
---
|
||
|
||
### 自己实现一个优先队列
|
||
这里有一个讲的不错的[b站视频](https://www.bilibili.com/video/BV1AF411G7cA/)
|
||
<iframe src="//player.bilibili.com/player.html?isOutside=true&aid=297973330&bvid=BV1AF411G7cA&cid=570109806&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>
|
||
|
||
如果不使用标准库,可以手动实现优先队列,最常见的方法是基于堆。以下是一个简单的基于数组的二叉堆实现的优先队列(最大堆):
|
||
```cpp
|
||
#include <iostream>
|
||
#include <vector>
|
||
#include <stdexcept>
|
||
|
||
class MaxHeap {
|
||
private:
|
||
std::vector<int> heap; // 使用 vector 动态数组存储堆元素
|
||
|
||
// 上浮操作,用于在插入新元素后调整堆的结构
|
||
void siftUp(int idx) {
|
||
// 当元素不是根节点时,继续调整
|
||
while (idx > 0) {
|
||
int parent = (idx - 1) / 2; // 计算父节点的索引
|
||
// 如果父节点的值已经大于等于当前元素,堆结构正确,结束调整
|
||
if (heap[parent] >= heap[idx]) break;
|
||
// 否则,交换父节点和当前节点的值
|
||
std::swap(heap[parent], heap[idx]);
|
||
// 更新当前节点的索引为父节点的索引,继续调整
|
||
idx = parent;
|
||
}
|
||
}
|
||
|
||
// 下沉操作,用于在删除堆顶元素后调整堆的结构
|
||
void siftDown(int idx) {
|
||
int n = heap.size(); // 获取堆的大小
|
||
// 当当前节点有左子节点时,继续调整(因为左子节点一定存在)
|
||
while (2 * idx + 1 < n) {
|
||
int left = 2 * idx + 1; // 左子节点的索引
|
||
int right = 2 * idx + 2; // 右子节点的索引
|
||
int largest = idx; // 假设当前节点是最大的
|
||
|
||
// 如果左子节点存在并且比当前节点大,更新 largest 为左子节点
|
||
if (left < n && heap[left] > heap[largest]) largest = left;
|
||
// 如果右子节点存在并且比当前最大节点大,更新 largest 为右子节点
|
||
if (right < n && heap[right] > heap[largest]) largest = right;
|
||
// 如果 largest 没有改变,说明堆已经调整完毕,退出循环
|
||
if (largest == idx) break;
|
||
|
||
// 否则,交换当前节点和最大子节点的值
|
||
std::swap(heap[idx], heap[largest]);
|
||
// 更新当前节点的索引为最大子节点的索引,继续调整
|
||
idx = largest;
|
||
}
|
||
}
|
||
|
||
public:
|
||
// 插入一个新元素
|
||
void push(int value) {
|
||
heap.push_back(value); // 将新元素添加到堆的末尾
|
||
siftUp(heap.size() - 1); // 调整堆以保持最大堆性质
|
||
}
|
||
|
||
// 移除堆顶元素(最大值)
|
||
void pop() {
|
||
if (heap.empty()) throw std::runtime_error("Heap is empty"); // 异常处理,堆为空时抛出错误
|
||
heap[0] = heap.back(); // 用最后一个元素替换堆顶
|
||
heap.pop_back(); // 删除最后一个元素
|
||
if (!heap.empty()) siftDown(0); // 调整堆以保持最大堆性质
|
||
}
|
||
|
||
// 获取堆顶元素(最大值),但不删除
|
||
int top() const {
|
||
if (heap.empty()) throw std::runtime_error("Heap is empty"); // 异常处理,堆为空时抛出错误
|
||
return heap[0]; // 返回堆顶元素
|
||
}
|
||
|
||
// 判断堆是否为空
|
||
bool empty() const {
|
||
return heap.empty(); // 堆为空返回 true
|
||
}
|
||
};
|
||
|
||
int main() {
|
||
MaxHeap pq; // 创建一个最大堆对象
|
||
|
||
// 插入元素到堆中
|
||
pq.push(10);
|
||
pq.push(20);
|
||
pq.push(15);
|
||
|
||
// 输出堆顶元素(最大值)
|
||
std::cout << "最大值: " << pq.top() << std::endl; // 输出 20
|
||
|
||
pq.pop(); // 移除堆顶元素
|
||
std::cout << "第二大值: " << pq.top() << std::endl; // 输出 15
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
### 代码讲解:
|
||
1. **`siftUp()` 和 `siftDown()`**:这两个函数是核心的调整堆结构的操作。`siftUp` 在插入新元素后执行,上浮新元素以保持最大堆结构。`siftDown` 在移除堆顶元素后执行,下沉新的堆顶以保持最大堆结构。
|
||
|
||
2. **插入操作**:
|
||
- 当调用 `push()` 函数时,将新元素添加到堆的末尾,并调用 `siftUp()` 函数调整堆的结构,确保最大堆的性质得到维护。
|
||
|
||
3. **删除操作**:
|
||
- 当调用 `pop()` 函数时,删除堆顶元素(最大值)。为了保持堆的连续性,使用最后一个元素替代堆顶,然后调用 `siftDown()` 来恢复最大堆性质。
|
||
|
||
4. **`top()` 函数**:返回当前堆中的最大元素,但不删除它。如果堆为空,则抛出异常。
|
||
|
||
5. **`empty()` 函数**:用于检查堆是否为空。
|
||
|
||
### 示例输出:
|
||
```text
|
||
最大值: 20
|
||
第二大值: 15
|
||
```
|
||
|
||
## ST表
|
||
> [OI Wiki链接](https://oi-wiki.org/ds/sparse-table/)
|
||
|
||
ST表(Sparse Table)是一种用于解决静态区间查询问题的数据结构,通常用于查询不可变数组中的区间最值(最小值、最大值、最大公约数等)。它的构建时间复杂度为 \(O(n \log n)\),查询时间复杂度为 \(O(1)\),非常适合处理多次查询的场景。ST表的原理基于“倍增法”,通过预处理使得每次查询时可以通过少量预处理信息快速得出结果。
|
||
|
||
### 原理
|
||
ST表的核心思想是将原数组分块,并记录每个块中的区间最值。具体来说,给定一个数组 `arr`,ST表预处理的是所有长度为 \(2^j\) 的区间的最值,其中 \(j\) 表示区间的大小为 \(2^j\)。
|
||
|
||
#### 数据结构
|
||
ST表用一个二维数组 `st[i][j]` 表示:
|
||
- \(st[i][j]\) 表示从位置 \(i\) 开始,长度为 \(2^j\) 的区间中的最值,即区间 `[i, i + 2^j - 1]` 中的最小值(或最大值、其他运算)。
|
||
|
||
#### 预处理
|
||
预处理的过程如下:
|
||
1. 初始化:长度为 \(2^0 = 1\) 的区间的最值就是数组本身。因此,\[
|
||
st[i][0] = arr[i] \quad \text{对于每个} \ i \in [0, n-1]。
|
||
\]
|
||
2. 填充其他长度的区间:
|
||
对于 \(j \geq 1\),区间 `[i, i + 2^j - 1]` 可以通过合并两个长度为 \(2^{j-1}\) 的区间得到:\[
|
||
st[i][j] = \min(st[i][j-1], st[i + 2^{j-1}][j-1])。
|
||
\]
|
||
其中,`min` 操作可以替换为其他操作(如 `max` 或 `gcd`),根据具体需求调整。
|
||
|
||
#### 查询
|
||
对于任意区间 `[L, R]`,我们可以通过分解成两个重叠区间的方式来计算区间最值:
|
||
1. 找到满足 \(2^j \leq (R - L + 1)\) 的最大 \(j\),这个 \(j\) 可以通过查找预处理的 `log` 数组得到(常用二进制计算)。
|
||
2. 使用两个长度为 \(2^j\) 的区间覆盖 `[L, R]`:\[
|
||
\min(st[L][j], st[R - 2^j + 1][j])
|
||
\]
|
||
因为两个区间 `[L, L + 2^j - 1]` 和 `[R - 2^j + 1, R]` 会完全覆盖区间 `[L, R]`。
|
||
|
||
### C++ 实现
|
||
以下是 ST 表求区间最小值的 C++ 实现:
|
||
|
||
下面是为这段 ST 表代码添加的详细注释:
|
||
|
||
```cpp
|
||
#include <iostream>
|
||
#include <vector>
|
||
#include <cmath>
|
||
using namespace std;
|
||
|
||
// ST表类,支持区间最小值查询
|
||
class SparseTable {
|
||
vector<vector<int>> st; // 用于存储不同区间长度的最小值
|
||
vector<int> log; // 用于快速查找 log2 值
|
||
int n; // 数组长度
|
||
|
||
public:
|
||
// 构造函数,传入数组并进行预处理
|
||
SparseTable(const vector<int>& arr) {
|
||
n = arr.size(); // 获取输入数组的大小
|
||
int maxLog = log2(n) + 1; // 最大区间长度所需的 log 值,最多到 log2(n)
|
||
st.assign(n, vector<int>(maxLog)); // 分配 st 表存储空间,n 行,每行 maxLog 列
|
||
log.assign(n + 1, 0); // 初始化 log 数组,存储 log2 的值,0 到 n 共 n+1 个位置
|
||
|
||
// 初始化 log 数组
|
||
// log[i] 表示 log2(i),从 2 开始进行预处理
|
||
for (int i = 2; i <= n; ++i)
|
||
log[i] = log[i / 2] + 1;
|
||
|
||
// 初始化 st 表中的长度为 1 的区间 (即 st[i][0]),这些区间的最小值就是数组本身
|
||
for (int i = 0; i < n; ++i)
|
||
st[i][0] = arr[i];
|
||
|
||
// 预处理区间长度为 2^j 的最小值,动态规划填充 st 表
|
||
for (int j = 1; j < maxLog; ++j) { // 枚举区间长度 2^j
|
||
for (int i = 0; i + (1 << j) <= n; ++i) { // 枚举起点 i,保证 i + 2^j 不越界
|
||
// 合并两个长度为 2^(j-1) 的区间,得到长度为 2^j 的区间最小值
|
||
st[i][j] = min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查询区间 [L, R] 的最小值
|
||
int query(int L, int R) {
|
||
// 根据区间长度 R - L + 1 找到最大的 j 使得 2^j <= R - L + 1
|
||
int j = log[R - L + 1];
|
||
// 返回两个部分的最小值,分别为 [L, L + 2^j - 1] 和 [R - 2^j + 1, R]
|
||
return min(st[L][j], st[R - (1 << j) + 1][j]);
|
||
}
|
||
};
|
||
|
||
int main() {
|
||
// 输入数组
|
||
vector<int> arr = {1, 3, 2, 7, 9, 11, 3, 5, 8, 10};
|
||
|
||
// 创建 ST 表,并传入数组进行预处理
|
||
SparseTable st(arr);
|
||
|
||
// 查询区间 [3, 7] 的最小值
|
||
cout << "Minimum in range [3, 7]: " << st.query(3, 7) << endl; // 输出 3
|
||
|
||
// 查询区间 [1, 4] 的最小值
|
||
cout << "Minimum in range [1, 4]: " << st.query(1, 4) << endl; // 输出 2
|
||
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
### 详细注释讲解:
|
||
1. **数据结构:**
|
||
- `st` 是一个二维数组,存储每个区间的最小值。`st[i][j]` 存储的是从位置 `i` 开始,长度为 \(2^j\) 的区间的最小值。
|
||
- `log` 是一个辅助数组,用于存储每个数的对数值,便于快速确定区间大小。
|
||
|
||
2. **预处理:**
|
||
- `log` 数组用来预处理快速计算 \( \log_2 \) 值,避免在查询时重复计算对数。
|
||
- `st` 表的初始化从长度为 \(2^0 = 1\) 的区间开始,其值就是数组的原始元素,然后通过倍增法构建更长的区间。
|
||
|
||
3. **查询:**
|
||
- 查询区间 `[L, R]` 时,找到最大的 \( j \) 使得 \(2^j \leq (R - L + 1)\),并使用两个长度为 \(2^j\) 的区间覆盖原始区间 `[L, R]`。
|
||
- 两个区间分别为 `[L, L + 2^j - 1]` 和 `[R - 2^j + 1, R]`,查询这两个子区间的最小值。
|
||
|
||
4. **主要操作:**
|
||
- `log[i / 2] + 1` 用来快速得到当前区间的对数值,确保动态规划的填充和查询过程中的高效计算。
|
||
- `min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1])` 使用两个较小的区间合并得到更大的区间最值。
|
||
|
||
5. **时间复杂度:**
|
||
- **预处理**:\(O(n \log n)\),遍历每个长度的区间进行最小值计算。
|
||
- **查询**:\(O(1)\),每次查询只需要比较两个最小值即可。
|
||
|
||
# 集合与森林
|
||
|
||
## 并查集
|
||
当然可以!下面是带有详细注释的 C++ 并查集代码,并说明 `rank` 的作用。
|
||
|
||
```cpp
|
||
#include <vector>
|
||
using namespace std;
|
||
|
||
class UnionFind {
|
||
public:
|
||
// 构造函数,初始化并查集
|
||
UnionFind(int size) {
|
||
parent.resize(size); // 动态数组存储每个节点的父节点
|
||
rank.resize(size, 1); // 动态数组存储每个节点的秩(树的高度)
|
||
|
||
// 初始化每个节点的父节点为自己
|
||
for (int i = 0; i < size; i++) {
|
||
parent[i] = i;
|
||
}
|
||
}
|
||
|
||
// 查找操作,查找节点p的根节点,同时进行路径压缩
|
||
int find(int p) {
|
||
if (parent[p] != p) {
|
||
parent[p] = find(parent[p]); // 路径压缩
|
||
}
|
||
return parent[p]; // 返回根节点
|
||
}
|
||
|
||
// 合并操作,将节点p和节点q所在的集合合并
|
||
void unionSets(int p, int q) {
|
||
int rootP = find(p); // 找到p的根节点
|
||
int rootQ = find(q); // 找到q的根节点
|
||
|
||
if (rootP != rootQ) { // 如果两个节点不在同一集合
|
||
// 按秩合并
|
||
if (rank[rootP] > rank[rootQ]) {
|
||
parent[rootQ] = rootP; // 将小树合并到大树下
|
||
} else if (rank[rootP] < rank[rootQ]) {
|
||
parent[rootP] = rootQ; // 将小树合并到大树下
|
||
} else {
|
||
parent[rootQ] = rootP; // 如果秩相同,任意合并
|
||
rank[rootP]++; // 提高合并后的树的秩
|
||
}
|
||
}
|
||
}
|
||
|
||
private:
|
||
vector<int> parent; // 存储每个节点的父节点
|
||
vector<int> rank; // 存储每个节点的秩(树的高度)
|
||
};
|
||
```
|
||
|
||
### `rank` 的作用:
|
||
|
||
- **树的高度**:`rank` 数组用于记录每个集合的树的高度(或深度)。通过保持较小的树总是连接到较大的树,减少了树的高度,从而提高了查找操作的效率。
|
||
- **避免不必要的深度**:如果不使用 `rank`,在多次合并中可能导致某棵树的高度迅速增加,使得查找操作的时间复杂度增加到 O(n)。
|
||
## 树的孩子兄弟表示法
|
||
|
||
# 特殊树
|
||
|
||
## 二叉堆,树状数组
|
||
|
||
## 线段树
|
||
|
||
## 字典树(Trie树)
|
||
|
||
## 笛卡尔树
|
||
|
||
## 平衡树(AVL, treap, splay)
|
||
|
||
# 常见图
|
||
|
||
## 稀疏图
|
||
|
||
## 偶图(二分图)
|
||
|
||
## 欧拉图
|
||
|
||
## 有向无环图
|
||
|
||
## 连通图与强连通图
|
||
|
||
## 双连通图
|
||
|
||
# 哈希表
|
||
|
||
## 数值哈希函数构造
|
||
|
||
## 字符串哈希函数构造
|
||
|
||
## 哈希冲突的常用处理方法
|