33 KiB
线性结构
双端栈
双端栈(Deque Stack 或 Double-ended Stack)是一种允许在两端进行操作的栈数据结构。通常的栈只能在一端进行操作(称为栈顶),即后进先出(LIFO, Last In First Out),但双端栈允许在栈的两端都可以执行进栈和出栈操作。
注意,双端栈和双端队列在C++中都可以用std::deque来实现,只有使用上的区别
双端栈的特点
- 两端操作:双端栈可以在栈的两端进行进栈(push)和出栈(pop),即可以从栈的前端或后端插入和删除元素。
- 灵活性:与单端栈相比,双端栈提供了更大的灵活性,因为可以根据需要选择从哪一端操作数据。
操作
假设双端栈具有两个端点:
- 左端(front)
- 右端(back)
主要操作包括:
push_front()
:在左端插入元素。push_back()
:在右端插入元素。pop_front()
:从左端弹出元素。pop_back()
:从右端弹出元素。front()
:访问左端的元素。back()
:访问右端的元素。
实现
双端栈通常是通过**双端队列(deque,double-ended queue)**实现的。C++ 中的标准库容器 std::deque
就是一个可以在两端高效进行插入和删除操作的数据结构,非常适合用来实现双端栈。
应用
双端栈适合用于一些需要在两端频繁操作的场景,比如:
- 滑动窗口问题:在滑动窗口中,你可能需要同时在窗口的两端添加或移除元素。
- 双向搜索算法:在某些搜索算法中,可以从两端同时进行搜索。
双端队列
双端队列(Deque,Double-Ended Queue)是一种特殊的队列数据结构,它允许在两端进行插入和删除操作。与普通队列(只能从一端入队、另一端出队)不同,双端队列可以从队首和队尾进行入队和出队操作,因此具有更大的灵活性。
双端队列的主要操作:
- 从队首插入元素(push_front):在队列的前端插入一个元素。
- 从队尾插入元素(push_back):在队列的末尾插入一个元素。
- 从队首删除元素(pop_front):移除队列前端的元素。
- 从队尾删除元素(pop_back):移除队列末尾的元素。
- 访问队首元素(front):查看队列前端的元素。
- 访问队尾元素(back):查看队列末端的元素。
双端队列的分类:
- 输入受限双端队列:只允许从队首删除元素,但只能从一端插入。
- 输出受限双端队列:只允许从队尾插入元素,但可以从两端删除元素。
双端队列的常见应用:
- 滑动窗口问题:通过双端队列可以高效地维护一组窗口内的最大或最小值,常用于动态数据流的问题。
- 任务调度:在一些调度问题中,可以根据任务的优先级从两端进行任务插入和删除。
实现方式:
双端队列可以使用双向链表或动态数组实现。使用双向链表可以在常数时间内从两端插入和删除元素,而使用动态数组在平均情况下可以实现较高的访问效率,但插入和删除操作的效率可能会有所下降,尤其是在需要调整数组大小的时候。
C++中的双端队列:
C++ 标准库中提供了 deque
容器,它支持双端队列的所有基本操作。例如:
#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),通常使用双向链表作为底层数据结构。双向链表中的每个节点都包含指向前驱节点和后继节点的指针,这样我们可以在常数时间内从两端进行插入和删除操作。
设计思路
- 节点结构:双向链表的每个节点需要包含一个数据字段,以及两个指针字段:分别指向前一个节点和后一个节点。
- 双端队列结构:需要维护两个指针,分别指向队列的头节点(front)和尾节点(rear)。还需要跟踪队列的大小。
操作逻辑
- 插入操作:插入时需要更新相应端的指针,同时要处理队列为空时的特殊情况。
- 删除操作:从队列的两端删除元素后,更新指针,处理删除后队列为空的情况。
- 访问操作:队首和队尾的元素可以直接通过指针访问。
双向链表节点的定义
struct Node {
int data;
Node* prev; // 指向前一个节点
Node* next; // 指向后一个节点
Node(int val) : data(val), prev(nullptr), next(nullptr) {}
};
双端队列类的定义
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 打印队列中的所有元素。
- 析构函数负责在对象销毁时释放所有节点,防止内存泄漏。
示例
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
既能像链表一样支持高效的双端操作,也能像数组一样支持随机访问。
具体结构:
- 块表(map):
deque
使用一个指针数组来管理多个小块(chunk/block)。每个指针指向一个内存块,每个内存块保存一定数量的元素。 - 小块(block):每个小块内存大小固定,
deque
元素分布在不同的小块中,但不同的小块在物理内存中不一定连续。 - 分段存储:当你从队首或队尾插入元素时,
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))。
单调队列的作用
单调队列的主要作用是:
- 在滑动窗口中维护最大值或最小值:单调队列可以在常数时间内得到滑动窗口的最值。通过维护一个递增或递减的队列,在每次窗口滑动时,可以快速移除不符合要求的元素,并保持队列的单调性。
- 优化动态区间问题:当我们需要动态地在某个区间内找最大/最小值时,单调队列可以在
O(n)
的时间复杂度内处理问题,而不需要每次重新遍历区间。
基本原理
单调队列之所以高效,关键在于保持队列中的元素是有序的。根据需求,可以维护递增或递减队列:
- 递增队列:队列中的元素从头到尾是递增的,这样可以在队列头部得到最小值。
- 递减队列:队列中的元素从头到尾是递减的,这样可以在队列头部得到最大值。
每当滑动窗口移动时,我们会将新的元素插入队列,同时确保队列的单调性。队列中的元素可能会失效(比如已经不在滑动窗口的范围内),这些元素需要被及时移除。
C++ 实现滑动窗口最大值问题
接下来,我们以经典的“滑动窗口最大值”问题为例,给出单调队列的 C++ 实现。
题目描述
给定一个大小为 n
的数组,和一个大小为 k
的滑动窗口。窗口从数组的左端移动到右端,每次只向右移动一位,求每个窗口中的最大值。
C++ 实现代码
#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;
}
代码解释
- deque:我们使用双端队列
dq
来存储数组元素的下标,确保队列中的元素始终是从大到小排列的。这样队列头部的元素永远是当前窗口的最大值。 - 删除无效元素:如果队列中的元素已经不在当前滑动窗口的范围内(即下标小于 (i - k + 1)),我们将其从队列头部移除。
- 保持队列单调性:在将当前元素插入队列之前,移除所有队列中比当前元素小的元素,因为它们不可能再成为未来窗口的最大值。
- 记录结果:一旦窗口大小达到 (k),我们将队列头部的元素(即最大值)加入结果数组。
时间复杂度
该算法的时间复杂度为 (O(n)),因为每个元素最多被插入和移除队列一次。
优先队列
优先队列(Priority Queue)是一种特殊的队列数据结构,其中每个元素都有一个优先级。在优先队列中,出队操作会优先处理优先级最高的元素,而不是像普通队列那样遵循“先进先出”(FIFO)的原则。
特性
- 插入元素(Enqueue):可以将元素加入队列,通常是 O(log n) 时间复杂度。
- 取出最大/最小元素(Dequeue):每次从队列中取出优先级最高的元素,通常是 O(log n) 时间复杂度。
- 查看最大/最小元素:可以在 O(1) 时间内查看当前优先级最高的元素。
优先队列常用于需要频繁处理最高优先级任务的场景,比如操作系统的进程调度、图算法中的最短路径问题(如 Dijkstra 算法)。
C++ 中的实现
C++ 标准库提供了一个名为 std::priority_queue
的容器,可以直接用来实现优先队列。这个容器底层实现通常是堆(默认是最大堆),即每次访问的是最大元素。
1. 默认最大堆实现
#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 表达式。
#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. 自定义优先级的对象
如果队列中存放的是自定义对象,则需要为该对象实现比较函数。例如,假设有一个任务结构体,优先级根据任务的优先级数值决定:
#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站视频
如果不使用标准库,可以手动实现优先队列,最常见的方法是基于堆。以下是一个简单的基于数组的二叉堆实现的优先队列(最大堆):
#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;
}
代码讲解:
-
siftUp()
和siftDown()
:这两个函数是核心的调整堆结构的操作。siftUp
在插入新元素后执行,上浮新元素以保持最大堆结构。siftDown
在移除堆顶元素后执行,下沉新的堆顶以保持最大堆结构。 -
插入操作:
- 当调用
push()
函数时,将新元素添加到堆的末尾,并调用siftUp()
函数调整堆的结构,确保最大堆的性质得到维护。
- 当调用
-
删除操作:
- 当调用
pop()
函数时,删除堆顶元素(最大值)。为了保持堆的连续性,使用最后一个元素替代堆顶,然后调用siftDown()
来恢复最大堆性质。
- 当调用
-
top()
函数:返回当前堆中的最大元素,但不删除它。如果堆为空,则抛出异常。 -
empty()
函数:用于检查堆是否为空。
示例输出:
最大值: 20
第二大值: 15
ST表
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]
中的最小值(或最大值、其他运算)。
预处理
预处理的过程如下:
- 初始化:长度为
2^0 = 1
的区间的最值就是数组本身。因此,[ st[i][0] = arr[i] \quad \text{对于每个} \ i \in [0, n-1]。 ] - 填充其他长度的区间:
对于 (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]
,我们可以通过分解成两个重叠区间的方式来计算区间最值:
- 找到满足
2^j \leq (R - L + 1)
的最大 (j),这个j
可以通过查找预处理的log
数组得到(常用二进制计算)。 - 使用两个长度为
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 表代码添加的详细注释:
#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;
}
详细注释讲解:
-
数据结构:
st
是一个二维数组,存储每个区间的最小值。st[i][j]
存储的是从位置i
开始,长度为2^j
的区间的最小值。log
是一个辅助数组,用于存储每个数的对数值,便于快速确定区间大小。
-
预处理:
log
数组用来预处理快速计算\log_2
值,避免在查询时重复计算对数。st
表的初始化从长度为2^0 = 1
的区间开始,其值就是数组的原始元素,然后通过倍增法构建更长的区间。
-
查询:
- 查询区间
[L, R]
时,找到最大的j
使得 (2^j \leq (R - L + 1)),并使用两个长度为2^j
的区间覆盖原始区间[L, R]
。 - 两个区间分别为
[L, L + 2^j - 1]
和[R - 2^j + 1, R]
,查询这两个子区间的最小值。
- 查询区间
-
主要操作:
log[i / 2] + 1
用来快速得到当前区间的对数值,确保动态规划的填充和查询过程中的高效计算。min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1])
使用两个较小的区间合并得到更大的区间最值。
-
时间复杂度:
- 预处理:(O(n \log n)),遍历每个长度的区间进行最小值计算。
- 查询:(O(1)),每次查询只需要比较两个最小值即可。
集合与森林
并查集
当然可以!下面是带有详细注释的 C++ 并查集代码,并说明 rank
的作用。
#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)。