17 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)),因为每个元素最多被插入和移除队列一次。