参考教程:代码随想录 (programmercarl.com)
基本概念
用问题来引入是一个更好的开始(对随想录网站中对应章节问题的回答):
C++中stack 是容器么?
我们先要明白什么是容器:
容器这个词在编程中是一个非常重要的概念,特别是在像 C++ 这样的编程语言中。要理解容器,我们可以从日常生活中的容器(比如盒子、袋子等)来类比。
1. 日常生活中的容器:
- 想象一下,一个盒子可以用来装东西。盒子可以装不同的东西,比如书、玩具或者食物。我们可以轻松地把这些东西放进盒子里,也可以随时从盒子里取出来。
- 这个盒子就是一个“容器”,因为它**“容纳”**了其他东西,并且提供了对这些东西的管理。
2. 编程中的容器:
- 在编程中,容器也是类似的概念。编程中的容器是一个数据结构,它用来存储和管理一组数据(比如数字、字符串、对象等)。
- 比如说,想象你有一组数字
[1, 2, 3, 4, 5]
,你可以把它们存放在一个容器里,这样你可以方便地进行各种操作,比如添加新数字、删除数字、查找某个数字等。
3. C++ 中的容器:
- C++ 中的容器是由**标准模板库(STL)**提供的一些工具,它们帮助你管理数据。
- 常见的容器有:
vector
:类似于一个动态数组,你可以随时增加或减少它的大小。list
:一个链表,允许你在任意位置快速插入或删除数据。map
:一种关联容器,可以让你通过“键”来快速找到对应的“值”。set
:一种集合,里面的元素都是唯一的,且自动排序。
4. 容器的好处:
- 自动管理:容器会帮你管理数据的存储和组织,你不需要手动处理内存或管理数据位置。
- 提供操作:容器通常会提供一些常用的操作,比如添加、删除、查找等,这些操作可以让你更方便地处理数据。
- 通用性:C++ 中的容器是“模板化”的,意味着它们可以容纳任何类型的数据,比如数字、字符串,甚至是你自己定义的对象。
显然,stack并非是容器,而是一种容器适配器。
日常生活中的类比:
- 想象你有一个普通的纸箱(就像编程中的一个普通容器),这个纸箱可以用来装任何东西。
- 现在,假设你给这个纸箱加了一个“盖子”,并且盖子上只开了一个小孔,允许你每次只能从这个小孔里拿出或放入一件物品。
- 这个带盖子的纸箱就是一个“适配器”。虽然纸箱里面还是装着很多东西(就像容器里存放的数据),但这个适配器改变了你如何与这些东西进行交互。
编程中的容器适配器:
- 在编程中,容器适配器就是在已有的容器之上封装了一些特定的操作,使其行为更像某种特定的数据结构。
- 这些适配器限制了容器的某些功能,或者仅允许特定的操作,使其更容易满足特定的需求。
总的来说,栈是以底层容器完成其所有的工作(其为容器适配器),对外提供统一的接口(一些可直接操作的函数:push,pop),底层容器是可插拔的(栈并不依赖于某一种特定的容器,用户可以选择不同的容器作为栈的底层实现。这种灵活性使得栈适配器能够适应不同的需求和性能要求。
栈与队列的常见实现方式
栈:
使用单向链表实现栈
单向链表是一种链式数据结构,每个节点包含一个数据部分和一个指向下一个节点的指针。使用单向链表实现栈,我们可以将链表的头部作为栈的顶部。
基本操作:
push
操作:- 创建一个新节点,并将其插入到链表的头部。这个新节点的
next
指针指向当前的头部节点,然后将栈的头部指针更新为这个新节点。 - 由于每次插入都在头部完成,这个操作的时间复杂度为
O(1)
。
- 创建一个新节点,并将其插入到链表的头部。这个新节点的
pop
操作:- 移除链表的头部节点,并将头部指针更新为下一个节点。这一操作同样只需要常数时间,因此时间复杂度也是
O(1)
。
- 移除链表的头部节点,并将头部指针更新为下一个节点。这一操作同样只需要常数时间,因此时间复杂度也是
top
操作:- 直接返回链表头部节点的值(即栈顶元素),时间复杂度为
O(1)
。
- 直接返回链表头部节点的值(即栈顶元素),时间复杂度为
template<typename T>
class Stack {
private:
struct Node {
T data;
Node* next;
Node(T val) : data(val), next(nullptr) {}
};
Node* head;
public:
Stack() : head(nullptr) {}
void push(T value) {
Node* newNode = new Node(value);
newNode->next = head;
head = newNode;
}
void pop() {
if (head) {
Node* temp = head;
head = head->next;
delete temp;
}
}
T top() const {
if (head) {
return head->data;
}
throw std::runtime_error("Stack is empty");
}
bool empty() const {
return head == nullptr;
}
};
用动态数组实现栈
push
:push_back
方法将新元素添加到vector
的末尾。由于vector
能够动态调整大小,因此当vector
已满时,它会自动扩展容量,将当前数据复制到新的更大空间中。
pop
:pop_back
方法移除vector
的末尾元素。移除元素时,vector
不会缩小容量,只是简单地将末尾元素标记为无效。如果vector
为空,这个方法会抛出异常,因此我们在调用pop
前需要检查栈是否为空。
top
:back
方法返回vector
末尾的元素,也就是栈顶元素。如果vector
为空,这个方法也会抛出异常,所以需要提前检查。
empty
:empty
方法检查vector
是否为空,这也是判断栈是否为空的依据。
size
:size
方法返回vector
中元素的数量,也就是栈中元素的数量。
#include <vector>
#include <stdexcept> // 用于抛出异常
#include <iostream>
template<typename T>
class Stack {
private:
std::vector<T> arr; // 使用 vector 来替代手动管理的数组
public:
Stack() = default; // 默认构造函数
void push(T value) {
arr.push_back(value); // 直接将元素添加到 vector 末尾
}
void pop() {
if (!arr.empty()) {
arr.pop_back(); // 直接移除 vector 末尾的元素
} else {
throw std::runtime_error("Stack underflow"); // 栈为空时抛出异常
}
}
T top() const {
if (!arr.empty()) {
return arr.back(); // 获取 vector 末尾的元素
}
throw std::runtime_error("Stack is empty"); // 栈为空时抛出异常
}
bool empty() const {
return arr.empty(); // 判断 vector 是否为空
}
int size() const {
return arr.size(); // 返回栈的大小
}
};
队列:
单向链表实现队列——与栈的实现方式差别较小
- 入队(Enqueue):将新元素添加到链表的末尾。这可以通过在链表的末尾创建一个新节点,并更新尾指针来实现。时间复杂度为
O(1)
。 - 出队(Dequeue):从链表的头部移除第一个元素。只需要更新头指针指向下一个节点,并释放原头节点的内存。时间复杂度为
O(1)
。
循环数组(Circular Arrays)实现队列
什么是循环数组?
循环数组是一种特殊的数组实现方式,它将数组的开头和末尾连接起来,形成一个“环”,即循环结构。通过这种结构,可以避免数组的空间浪费和频繁的数据移动。
如何用循环数组实现队列?
-
队列的特性:
- 队列的元素从数组的一个固定位置开始插入(尾部),并从另一个固定位置移除(头部)。
- 当队列的尾指针达到数组的末端时,它会绕回到数组的开头继续插入元素。
-
基本操作:
- 入队(Enqueue):将新元素添加到数组的尾部位置。如果尾指针达到数组末端,则将其重置为数组的开头。时间复杂度为
O(1)
。 - 出队(Dequeue):从数组的头部移除元素,并将头指针向后移动。如果头指针达到数组末端,则将其重置为数组的开头。时间复杂度为
O(1)
。
- 入队(Enqueue):将新元素添加到数组的尾部位置。如果尾指针达到数组末端,则将其重置为数组的开头。时间复杂度为
template<typename T>
class CircularQueue {
private:
std::vector<T> arr;
int head;
int tail;
int size;
int capacity;
public:
CircularQueue(int cap) : capacity(cap), head(0), tail(0), size(0) {
arr.resize(capacity);
}
void enqueue(T value) {
if (size < capacity) {
arr[tail] = value;
tail = (tail + 1) % capacity;
size++;
} else {
throw std::runtime_error("Queue overflow");
}
}
void dequeue() {
if (size > 0) {
head = (head + 1) % capacity;
size--;
} else {
throw std::runtime_error("Queue underflow");
}
}
T front() const {
if (size > 0) {
return arr[head];
}
throw std::runtime_error("Queue is empty");
}
bool empty() const {
return size == 0;
}
int current_size() const {
return size;
}
};
力扣对应题目
学习来源:代码随想录 (programmercarl.com)
基础部分:对两种数据结构加强理解
232.用栈实现队列
关键思想:使用两个栈来完成操作,一个栈负责接收输入(stack_1),一个栈负责最后输出(stack_2)
class MyQueue {
public:
stack<int> st_1;
stack<int> st_2;
int size_1 = st_1.size()
MyQueue() {
}
void two_stack() {
int size_1 = st_1.size();
while (size_1--) {
//此处原先有问题,不应该是st_1.size()--,会导致size逐渐减小,应该设立一个变量先接收,然后让其逐渐变小
int y = st_1.top();
st_2.push(y);
st_1.pop();
}
}
void push(int x) {
st_1.push(x);
two_stack();
}
int pop() {
two_stack();
int top_e = st_2.top;
st_2.pop()
return top_e;
}
int peek() {
two_stack();
int top_e = st_2.top;
return top_e;
}
bool empty() {
if (st_2.size() != 0)return false;
else return true;
}
};
//逻辑优化:减少不必要的two_stack调用:
每次 push 和 pop 时都调用 two_stack 会导致效率低下。实际上,你只需要在 pop 和 peek 时才进行栈元素的转移,并且只在 st_2 为空时才进行转移。
225. 用队列实现栈
关键要点:
使用两个队列,一个用来作为操作主体,一个用来给另一个队列做备份。既然栈单次都是对一个元素进行操作,那我们就干脆只在操作主体中维持一个尾部元素即可,这样先入先出与先入后出就并无区别了
void transfer() {
while (q_1.size() > 1) {
q_2.push(q_1.front());
q_1.pop();
}//此处的while循环,就是把q_1中除了尾部元素以外的所有数据都弹入q_2这一备份队列中
}
之后的操作就是先弹出元素——>对单一元素进行操作,模拟——>重新初始化操作主体
int pop() {
transfer();
int top_1 = q_1.front();
q_1.pop();
q_1 = q_2; // 上面的pop操作完成了,现在是初始化过程。再将que2赋值给que1
while (!q_2.empty()) { // 清空que2
q_2.pop();
}
return top_1;
}
完整代码如下:
class MyStack {
public:
queue<int> q_1;
queue<int> q_2;
MyStack() {
}
void transfer() {
while (q_1.size() > 1) {
q_2.push(q_1.front());
q_1.pop();
}//此处的while循环,就是把q_1中除了尾部元素以外的所有数据都弹入q_2这一备份队列中
}
void push(int x) {
q_1.push(x);
}
int pop() {
transfer();
int top_1 = q_1.front();
q_1.pop();
q_1 = q_2; // 上面的pop操作完成了,现在是初始化过程。再将que2赋值给que1
while (!q_2.empty()) { // 清空que2
q_2.pop();
}
return top_1;
}
int top() {
transfer();
int top_1 = q_1.front();
q_1 = q_2; // 再将que2赋值给que1
while (!q_2.empty()) { // 清空que2
q_2.pop();
}
return top_1;
}
bool empty() {
return q_1.empty();
}
};
栈的经典问题:
20. 有效的括号(匹配问题)
关键点:利用括号对称性的性质,可以通过栈来轻易解决。在判断内容时,一定要先想好什么情况括号是无效的。
第一种情况,字符串里左/右方向的括号多余了 ,所以不匹配
第二种情况,括号没有多余,但是 括号的类型没有匹配上
class Solution {
public:
stack<string> str;
bool isValid(string s) {
if (s.size() % 2 != 0)return false;
for (int i; i < s.size(); i++) {
if (s[i] == "(") str.push(")");
else if (s[i] == "{") str.push("}");
else if (s[i] == "[") str.push("]");
else if (str.empty() || str.top() != s[i]) return false;
else str.pop();
}
return str.empty();
}
};
这样是一个初步的解决方案,但还有更好的形式——>即让右半边的括号先入栈,每次只用比较栈顶元素和输入元素即可——能够使代码更为简化
1047. 删除字符串中的所有相邻重复项(匹配问题)、
关键点:每次我们只用关注两个元素,一个当前的输出元素,一个栈顶(上一个输出)元素。去栈里看一下我们是不是遍历过相同数值的相邻元素。就可以得到答案。
有可能大伙一开始会跟我一样有疑问就是这样好像只能实现一次去重,实际上,当我们弹出最顶端的一个相邻重复字符后,栈里只剩下先前不重复的字符,此时继续检测,实际上已经变成二次检测的情况了(即二次去重)。
class Solution {
public:
string removeDuplicates(string s) {
stack<char> st;
for (int i = 0; i < s.size(); i++) {
if (st.size()!=0) {
if (i[s] != st.top()){
st.push(i[s]);
}
if (i[s] == st.top()){
st.pop();
}
}
else{
st.push(i[s]);
}
}
string result = "";
while (!st.empty())
{
result += st.top();
st.pop();
}
reverse(result.begin(), result.end());
return result;
}
};