数组/链表/【环形数组】实现 队列/栈/双端队列【移动语义应用】【自动扩缩】
队列与栈:从原理到实现的全面解析(含C++实战与核心知识点)
引言:操作受限的数据结构
在计算机科学中,队列(Queue)和栈(Stack)是两种经典的“操作受限”数据结构。与可随机访问的数组、链表不同,它们的核心特性体现在严格的操作规则上:
- 栈(Stack):遵循“先进后出(LIFO,Last In First Out)”,仅允许在一端(栈顶)插入和删除元素。
- 队列(Queue):遵循“先进先出(FIFO,First In First Out)”,仅允许在一端(队尾)插入元素,在另一端(队头)删除元素。
这两种结构看似简单,却是很多高级算法(如深度优先搜索DFS、广度优先搜索BFS)和系统设计(如缓存、任务调度)的基础。本文将从核心API出发,分别用链表和数组(含环形数组优化)实现它们,并深入解析实现中的C++关键技术点。
一、队列与栈的核心API
无论是队列还是栈,其核心操作都围绕“增、删、查、判空/大小”展开。以下是它们的模板API定义,后续实现需严格遵循这些接口。
1. 栈(Stack)的核心API
栈的操作集中在“栈顶”,核心API如下:
template <typename E>
class MyStack {
public:// 向栈顶插入元素(入栈),时间复杂度O(1)void push(const E& e);// 从栈顶删除元素(出栈),返回被删除元素,时间复杂度O(1)E pop();// 查看栈顶元素(不删除),时间复杂度O(1)E peek() const;// 返回栈中元素个数,时间复杂度O(1)int size() const;// 判断栈是否为空,时间复杂度O(1)bool isEmpty() const;
};
2. 队列(Queue)的核心API
队列的操作分为“队尾插入”和“队头删除”,核心API如下:
template <typename E>
class MyQueue {
public:// 向队尾插入元素(入队),时间复杂度O(1)void push(const E& e);// 从队头删除元素(出队),返回被删除元素,时间复杂度O(1)E pop();// 查看队头元素(不删除),时间复杂度O(1)E peek() const;// 返回队列中元素个数,时间复杂度O(1)int size() const;// 判断队列是否为空,时间复杂度O(1)bool isEmpty() const;
};
二、链表实现队列和栈
链表(本文以std::list为例)天然支持高效的头尾操作,因此实现队列和栈非常直观。
1. 链表实现栈
栈的“先进后出”特性适合用链表的尾部作为栈顶(链表尾部插入/删除为O(1)):
#include <list>
#include <iostream>
#include <stdexcept>template <typename E>
class MyLinkedStack {
private:std::list<E> list; // 底层链表容器public:// 入栈:向链表尾部插入元素void push(const E& e) {list.push_back(e);}// 出栈:从链表尾部删除元素并返回E pop() {if (isEmpty()) {throw std::runtime_error("Stack is empty");}E topVal = list.back(); // 获取尾部元素list.pop_back(); // 删除尾部元素return topVal;}// 查看栈顶元素:返回链表尾部元素E peek() const {if (isEmpty()) {throw std::runtime_error("Stack is empty");}return list.back();}// 元素个数:返回链表大小int size() const {return list.size();}// 判断为空:链表是否为空bool isEmpty() const {return list.empty();}
};// 测试示例
int main() {MyLinkedStack<int> stack;stack.push(1);stack.push(2);stack.push(3);// 出栈顺序:3 -> 2 -> 1(先进后出)while (!stack.isEmpty()) {std::cout << stack.pop() << " "; // 输出:3 2 1}return 0;
}
2. 链表实现队列
队列的“先进先出”特性适合用链表的尾部插入(队尾)、头部删除(队头):
#include <list>
#include <iostream>
#include <stdexcept>template <typename E>
class MyLinkedQueue {
private:std::list<E> list; // 底层链表容器public:// 入队:向链表尾部插入元素void push(const E& e) {list.push_back(e);}// 出队:从链表头部删除元素并返回E pop() {if (isEmpty()) {throw std::runtime_error("Queue is empty");}E frontVal = list.front(); // 获取头部元素list.pop_front(); // 删除头部元素return frontVal;}// 查看队头元素:返回链表头部元素E peek() const {if (isEmpty()) {throw std::runtime_error("Queue is empty");}return list.front();}// 元素个数:返回链表大小int size() const {return list.size();}// 判断为空:链表是否为空bool isEmpty() const {return list.empty();}
};// 测试示例
int main() {MyLinkedQueue<int> queue;queue.push(1);queue.push(2);queue.push(3);// 出队顺序:1 -> 2 -> 3(先进先出)while (!queue.isEmpty()) {std::cout << queue.pop() << " "; // 输出:1 2 3}return 0;
}
三、数组实现队列和栈
数组(或动态数组std::vector)的尾部操作是O(1),但头部操作(插入/删除)通常是O(n)(需搬移元素)。通过“环形数组”技巧,可将数组头部操作优化至O(1)。
1. 数组实现栈
栈仅需尾部操作,直接用动态数组的尾部作为栈顶即可:
#include <vector>
#include <stdexcept>template <typename E>
class MyArrayStack {
private:std::vector<E> arr; // 底层动态数组public:// 入栈:向数组尾部插入元素void push(const E& e) {arr.push_back(e);}// 出栈:从数组尾部删除元素并返回E pop() {if (isEmpty()) {throw std::runtime_error("Stack is empty");}E topVal = arr.back(); // 获取尾部元素arr.pop_back(); // 删除尾部元素return topVal;}// 查看栈顶元素:返回数组尾部元素E peek() const {if (isEmpty()) {throw std::runtime_error("Stack is empty");}return arr.back();}// 元素个数:返回数组大小int size() const {return arr.size();}// 判断为空:数组是否为空bool isEmpty() const {return arr.empty();}
};
2. 环形数组:优化数组头部操作的核心
普通数组头部操作效率低(O(n)),而环形数组通过“逻辑环形”设计,让头部操作(插入/删除)效率提升至O(1)。
(1)环形数组核心原理
环形数组通过两个指针start
和end
标记有效元素范围,结合取模运算实现“逻辑环形”:
- 区间定义:有效元素范围为左闭右开区间
[start, end)
,即start
指向第一个有效元素,end
指向最后一个有效元素的下一个位置。 - 取模运算:当
start
或end
超出数组边界时,通过% arr.size()
让其“绕回”数组头部,形成逻辑环形。 - 扩缩容:当元素满时自动扩容(翻倍),当元素数为容量1/4时自动缩容(减半),保证空间效率。
(2)环形数组完整实现(模板类)
#include <iostream>
#include <vector>
#include <stdexcept>template<typename T>
class CycleArray {
private:std::vector<T> arr; // 底层存储容器int start; // 指向第一个有效元素(左闭)int end; // 指向最后一个有效元素的下一个位置(右开)int count; // 当前有效元素数量// 扩缩容核心函数:将元素复制到新容量数组,重置start和endvoid resize(int newSize) {std::vector<T> newArr(newSize); // 创建新数组// 复制旧数组元素到新数组(线性排列)for (int i = 0; i < count; ++i) {newArr[i] = arr[(start + i) % arr.size()];}// 移动语义:高效转移新数组所有权(避免深拷贝)arr = std::move(newArr);// 重置指针:新数组中有效元素从0开始,end = countstart = 0;end = count;}public:// 委托构造函数:无参构造默认容量为1的环形数组CycleArray() : CycleArray(1) {}// explicit构造函数:禁止隐式类型转换,指定初始容量explicit CycleArray(int size) : arr(size), start(0), end(0), count(0) {}// 头部插入元素void addFirst(const T& val) {if (isFull()) {resize(arr.size() * 2); // 满则扩容(翻倍)}// start左移:若start=0,左移后绕回数组尾部start = (start - 1 + arr.size()) % arr.size();arr[start] = val;count++;}// 头部删除元素void removeFirst() {if (isEmpty()) {throw std::runtime_error("CycleArray is empty");}// 重置start位置元素为默认值(逻辑清空)arr[start] = T();// start右移:绕回逻辑start = (start + 1) % arr.size();count--;// 缩容:元素数为容量1/4且不为空时,缩容至1/2if (count > 0 && count == arr.size() / 4) {resize(arr.size() / 2);}}// 尾部插入元素void addLast(const T& val) {if (isFull()) {resize(arr.size() * 2); // 满则扩容}arr[end] = val; // end位置插入元素// end右移:绕回逻辑end = (end + 1) % arr.size();count++;}// 尾部删除元素void removeLast() {if (isEmpty()) {throw std::runtime_error("CycleArray is empty");}// end左移:获取最后一个有效元素位置end = (end - 1 + arr.size()) % arr.size();// 重置end位置元素为默认值(逻辑清空)arr[end] = T();count--;// 缩容逻辑同上if (count > 0 && count == arr.size() / 4) {resize(arr.size() / 2);}}// 获取头部元素T getFirst() const {if (isEmpty()) {throw std::runtime_error("CycleArray is empty");}return arr[start];}// 获取尾部元素T getLast() const {if (isEmpty()) {throw std::runtime_error("CycleArray is empty");}// end是右开,尾部元素为end-1位置return arr[(end - 1 + arr.size()) % arr.size()];}// 判断是否已满(元素数=容量)bool isFull() const {return count == arr.size();}// 获取元素数量int size() const {return count;}// 判断是否为空bool isEmpty() const {return count == 0;}
};
(3)关键C++知识点解析
环形数组实现中涉及多个核心C++特性,需重点理解:
① 模板类(Template Class)
template<typename T>
class CycleArray { ... };
- 作用:让类支持任意数据类型(如
int
、string
),实现代码复用。 - 使用:实例化时指定类型,如
CycleArray<int> arr(10)
。
② 委托构造函数(Delegating Constructor)
CycleArray() : CycleArray(1) {}
- 作用:一个构造函数调用同类的另一个构造函数,避免代码重复。
- 逻辑:无参构造时,委托
CycleArray(1)
初始化,默认容量为1。
③ explicit关键字
explicit CycleArray(int size) : arr(size), start(0), end(0), count(0) {}
- 作用:禁止隐式类型转换,防止意外调用构造函数。
- 示例:
CycleArray arr = 10;
会报错(隐式转换被禁止),必须显式调用CycleArray arr(10);
。
④ 移动语义与std::move
arr = std::move(newArr); // 移动语义!
④ 移动语义与 std::move
移动语义是C++11引入的核心特性,用于高效转移资源所有权,避免深拷贝带来的性能损耗。在环形数组的resize
函数中,std::move
起到了关键作用:
为什么需要移动语义?
传统的赋值操作(如arr = newArr
)会触发深拷贝,即复制整个数组内容,时间复杂度为O(n)。而移动语义通过转移资源所有权(如指针),仅需O(1)时间。
std::move
的本质
std::move
本身不移动任何东西,它只是将左值强制转换为右值引用(T&&
),从而触发移动构造函数或移动赋值运算符。
环形数组中的应用
在resize
函数中:
- 先将旧数组元素复制到新数组(线性排列)。
- 通过
std::move
将新数组的资源所有权转移给arr
,避免二次复制。
代码示例
// resize函数关键部分
std::vector<T> newArr(newSize); // 创建新数组
// 复制旧数组元素到新数组(线性排列)
for (int i = 0; i < count; ++i) {newArr[i] = arr[(start + i) % arr.size()];
}
// 移动语义:高效转移资源所有权
arr = std::move(newArr);
移动赋值运算符的简化实现
// std::vector的移动赋值运算符(简化版)
vector& operator=(vector&& other) noexcept {// 1. 释放当前资源delete[] data;// 2. 接管other的资源data = other.data;size = other.size;capacity = other.capacity;// 3. 清空other(防止析构时重复释放)other.data = nullptr;other.size = 0;other.capacity = 0;return *this;
}
性能对比
操作 | 时间复杂度 | 内存操作 |
---|---|---|
传统赋值(深拷贝) | O(n) | 复制所有元素 |
移动赋值(移动语义) | O(1) | 转移指针,无需复制元素 |
3. 环形数组实现队列
基于环形数组的O(1)头尾操作,实现队列变得非常简单:
#include <stdexcept>template <typename E>
class MyArrayQueue {
private:CycleArray<E> arr; // 底层使用环形数组public:// 入队:调用环形数组的尾部插入void push(const E& e) {arr.addLast(e);}// 出队:调用环形数组的头部删除E pop() {if (isEmpty()) {throw std::runtime_error("Queue is empty");}E frontVal = arr.getFirst();arr.removeFirst();return frontVal;}// 查看队头元素E peek() const {if (isEmpty()) {throw std::runtime_error("Queue is empty");}return arr.getFirst();}// 获取队列大小int size() const {return arr.size();}// 判断队列是否为空bool isEmpty() const {return arr.isEmpty();}
};
4. 环形数组 vs 普通数组:操作复杂度对比
操作 | 普通数组 | 环形数组 |
---|---|---|
尾部插入(push_back) | O(1) | O(1) |
尾部删除(pop_back) | O(1) | O(1) |
头部插入(push_front) | O(n) | O(1) |
头部删除(pop_front) | O(n) | O(1) |
随机访问(get(i)) | O(1) | O(1) |
结论:环形数组在保留随机访问能力的同时,将头部操作从O(n)优化到O(1),是实现队列和栈的理想选择。
四、总结与应用场景
1. 队列与栈的实现选择
实现方式 | 适用场景 | 优势 |
---|---|---|
链表实现 | 需要频繁增删,元素数量不稳定 | 无需扩容,空间灵活 |
数组实现 | 随机访问需求,元素数量较稳定 | 缓存友好,访问效率高 |
环形数组实现 | 需高效头尾操作,空间敏感 | 头部操作O(1),自动扩缩容 |
2. 典型应用场景
-
栈的应用:
- 函数调用栈(递归实现)
- 表达式求值(后缀表达式计算)
- 浏览器后退/前进功能
- 括号匹配检查
-
队列的应用:
- 广度优先搜索(BFS)
- 任务调度(FIFO策略)
- 消息队列(生产者-消费者模型)
- 网络数据包处理
3. 关键C++知识点回顾
- 模板编程(泛型实现)
- 环形数组的取模运算(逻辑环形)
- 移动语义与
std::move
(资源高效转移) - explicit构造函数(防止隐式转换)
- 委托构造函数(代码复用)
五、思考题解答
为什么编程语言标准库(如C++的std::vector)不默认使用环形数组?
- 随机访问效率:普通数组的随机访问无需计算偏移量(直接
arr[i]
),而环形数组需通过(start + i) % capacity
计算,存在额外开销。 - 缓存局部性:普通数组的连续内存布局更符合CPU缓存机制,环形数组可能因“绕回”导致缓存失效。
- 使用场景:标准库的
vector
更侧重通用场景(如随机访问、尾部操作),而环形数组更适合特定场景(如队列、双端队列)。
通过本文的实现和解析,你已掌握队列和栈的核心原理、多种实现方式及C++关键技术点。这些知识不仅是数据结构的基础,更是理解高级算法和系统设计的关键。
双端队列(Deque):灵活的头尾操作数据结构
双端队列(Double-Ended Queue,简称Deque)是队列的扩展,允许在队头和队尾同时进行插入和删除操作,突破了普通队列“先进先出”的严格限制,灵活性更高。本文将详细讲解双端队列的核心API、链表与数组实现方式,以及典型应用场景。
一、双端队列的核心API
双端队列的操作覆盖队头和队尾,核心API如下(模板类定义):
template<typename E>
class MyDeque {
public:// 从队头插入元素,时间复杂度 O(1)void addFirst(const E& e);// 从队尾插入元素,时间复杂度 O(1)void addLast(const E& e);// 从队头删除元素,返回被删除元素,时间复杂度 O(1)E removeFirst();// 从队尾删除元素,返回被删除元素,时间复杂度 O(1)E removeLast();// 查看队头元素(不删除),时间复杂度 O(1)E peekFirst() const;// 查看队尾元素(不删除),时间复杂度 O(1)E peekLast() const;// 返回元素个数,时间复杂度 O(1)int size() const;// 判断是否为空,时间复杂度 O(1)bool isEmpty() const;
};
与普通队列的区别:
- 普通队列仅支持
addLast
(队尾插入)和removeFirst
(队头删除); - 双端队列额外支持
addFirst
(队头插入)和removeLast
(队尾删除),操作更灵活,无需严格遵循FIFO。
二、链表实现双端队列
链表(如std::list
)天然支持高效的头尾操作(插入/删除均为O(1)),是实现双端队列的理想选择。
实现代码
#include <list>
#include <stdexcept>
#include <iostream>template<typename E>
class MyListDeque {
private:std::list<E> list; // 底层链表容器,支持O(1)头尾操作public:// 队头插入元素void addFirst(const E& e) {list.push_front(e); // 链表头部插入}// 队尾插入元素void addLast(const E& e) {list.push_back(e); // 链表尾部插入}// 队头删除元素并返回E removeFirst() {if (isEmpty()) {throw std::runtime_error("Deque is empty");}E frontVal = list.front(); // 获取队头元素list.pop_front(); // 删除队头元素return frontVal;}// 队尾删除元素并返回E removeLast() {if (isEmpty()) {throw std::runtime_error("Deque is empty");}E lastVal = list.back(); // 获取队尾元素list.pop_back(); // 删除队尾元素return lastVal;}// 查看队头元素E peekFirst() const {if (isEmpty()) {throw std::runtime_error("Deque is empty");}return list.front();}// 查看队尾元素E peekLast() const {if (isEmpty()) {throw std::runtime_error("Deque is empty");}return list.back();}// 元素个数int size() const {return list.size();}// 判断为空bool isEmpty() const {return list.empty();}
};// 测试示例
int main() {MyListDeque<int> deque;deque.addFirst(1); // 队头插入1 → [1]deque.addFirst(2); // 队头插入2 → [2, 1]deque.addLast(3); // 队尾插入3 → [2, 1, 3]deque.addLast(4); // 队尾插入4 → [2, 1, 3, 4]std::cout << deque.removeFirst() << " "; // 删除队头2 → 输出2std::cout << deque.removeLast() << " "; // 删除队尾4 → 输出4std::cout << deque.peekFirst() << " "; // 查看队头 → 输出1std::cout << deque.peekLast() << " "; // 查看队尾 → 输出3// 最终输出:2 4 1 3return 0;
}
实现解析
链表实现双端队列的核心是复用链表的头尾操作:
std::list
的push_front
/pop_front
支持队头O(1)插入/删除;push_back
/pop_back
支持队尾O(1)插入/删除;- 无需额外逻辑处理,直接映射API即可,实现简洁高效。
三、数组实现双端队列(基于环形数组)
普通数组的头尾操作效率低(O(n)),但环形数组通过“逻辑环形”设计,可将头尾操作优化至O(1),非常适合实现双端队列。
实现代码
基于前文实现的CycleArray
(环形数组),直接复用其addFirst
/addLast
/removeFirst
/removeLast
方法:
#include <stdexcept>
// 引入环形数组类(前文实现的CycleArray)
#include "cycle_array.h" // 假设环形数组定义在该文件中template <typename E>
class MyArrayDeque {
private:CycleArray<E> arr; // 底层依赖环形数组public:// 队头插入元素void addFirst(const E& e) {arr.addFirst(e);}// 队尾插入元素void addLast(const E& e) {arr.addLast(e);}// 队头删除元素并返回E removeFirst() {if (isEmpty()) {throw std::runtime_error("Deque is empty");}E frontVal = arr.getFirst();arr.removeFirst();return frontVal;}// 队尾删除元素并返回E removeLast() {if (isEmpty()) {throw std::runtime_error("Deque is empty");}E lastVal = arr.getLast();arr.removeLast();return lastVal;}// 查看队头元素E peekFirst() const {if (isEmpty()) {throw std::runtime_error("Deque is empty");}return arr.getFirst();}// 查看队尾元素E peekLast() const {if (isEmpty()) {throw std::runtime_error("Deque is empty");}return arr.getLast();}// 元素个数int size() const {return arr.size();}// 判断为空bool isEmpty() const {return arr.isEmpty();}
};// 测试示例
int main() {MyArrayDeque<int> deque;deque.addLast(1); // 队尾插入1 → [1]deque.addFirst(2); // 队头插入2 → [2, 1]deque.addLast(3); // 队尾插入3 → [2, 1, 3]deque.addFirst(4); // 队头插入4 → [4, 2, 1, 3]std::cout << deque.removeLast() << " "; // 删除队尾3 → 输出3std::cout << deque.removeFirst() << " "; // 删除队头4 → 输出4std::cout << deque.peekFirst() << " "; // 查看队头 → 输出2std::cout << deque.peekLast() << " "; // 查看队尾 → 输出1// 最终输出:3 4 2 1return 0;
}
实现解析
环形数组为双端队列提供了高效支持:
CycleArray::addFirst
/removeFirst
对应队头操作;addLast
/removeLast
对应队尾操作;- 环形数组的自动扩缩容机制确保空间效率,避免溢出或浪费。
四、双端队列的优势与应用场景
双端队列结合了栈和队列的特性,在需要灵活头尾操作的场景中表现突出。
核心优势
- 操作灵活性:同时支持队头/队尾插入删除,突破FIFO限制;
- 效率高效性:链表和环形数组实现均支持O(1)时间复杂度的头尾操作;
- 多功能复用:可同时模拟栈(用队头/队尾作为栈顶)和队列(用队尾入、队头出)。
典型应用场景
-
滑动窗口问题:
如“滑动窗口最大值”问题,用双端队列存储窗口内元素的索引,队头始终为当前窗口最大值,通过队头删除过期元素、队尾删除小于新元素的元素,实现O(n)时间复杂度。 -
实现栈和队列:
- 用双端队列模拟栈:仅使用
addLast
和removeLast
(队尾作为栈顶); - 用双端队列模拟队列:仅使用
addLast
和removeFirst
(标准FIFO)。
- 用双端队列模拟栈:仅使用
-
缓存设计:
如LRU(最近最少使用)缓存的简化版,用双端队列存储访问记录,最近访问的元素移至队头,满时删除队尾元素。 -
数据流处理:
需同时处理首尾数据的场景(如实时日志分析、双向消息队列)。
五、链表实现 vs 数组实现对比
特性 | 链表实现(MyListDeque) | 数组实现(MyArrayDeque) |
---|---|---|
时间复杂度(头尾操作) | O(1) | O(1) |
空间效率 | 无浪费(按需分配节点) | 可能有空闲空间(环形数组容量) |
缓存友好性 | 较差(节点内存不连续) | 较好(数组内存连续) |
扩缩容成本 | 低(插入/删除节点即可) | 中(需复制元素,依赖移动语义) |
随机访问支持 | 不支持(需遍历) | 支持(通过环形数组计算索引) |
六、总结
双端队列是一种灵活高效的数据结构,核心价值在于队头和队尾的O(1)操作。通过链表实现可获得灵活的空间分配,通过环形数组实现可获得更好的缓存性能。其应用场景广泛,尤其在滑动窗口、缓存设计和模拟栈/队列混合操作中不可或缺。
关键C++知识点回顾
- 模板类泛型实现(支持任意数据类型);
- 链表
std::list
的头尾操作特性; - 环形数组的逻辑设计与取模运算;
- 代码复用(基于环形数组快速实现双端队列)。
掌握双端队列的实现原理,不仅能提升对数据结构灵活性的理解,更能为复杂算法问题提供高效解决方案。