【数据结构与算法基础】05. 栈详解(C++ 实战)
栈是一种特殊的线性表,它遵循**后进先出(LIFO, Last In First Out)**的原则。本文将详细介绍栈的基本概念、操作、实现方式以及实际应用案例,并通过C++代码进行实战演练。
(关注不迷路哈!!!)
文章目录
- [【数据结构与算法基础】05. 栈详解(C++ 实战)](#【数据结构与算法基础】05. 栈详解(C++ 实战))
-
- 一、栈的基本概念
-
- [1.1 栈的定义](#1.1 栈的定义)
- [1.2 栈的特点](#1.2 栈的特点)
- [1.3 栈的用途](#1.3 栈的用途)
- 二、栈的基本操作
- 三、栈的实现方式
-
- [3.1 顺序栈(基于数组)](#3.1 顺序栈(基于数组))
- [3.2 链式栈(基于链表)](#3.2 链式栈(基于链表))
- 四、栈的C++实现
-
- [4.1 顺序栈(基于数组)的C++实现](#4.1 顺序栈(基于数组)的C++实现)
- [4.2 链式栈(基于链表)的C++实现](#4.2 链式栈(基于链表)的C++实现)
- 五、栈的经典应用案例
-
- [5.1 括号匹配问题](#5.1 括号匹配问题)
- [5.2 浏览器的前进和后退功能](#5.2 浏览器的前进和后退功能)
- 六、栈的性能对比
- 七、栈的总结
-
- [7.1 栈的特性](#7.1 栈的特性)
- [7.2 栈的优势](#7.2 栈的优势)
- [7.3 栈的劣势](#7.3 栈的劣势)
- 八、练习题
- 总结
一、栈的基本概念
1.1 栈的定义
栈 是一种只能在一端进行插入和删除操作的线性数据结构。允许进行插入和删除操作的一端称为栈顶(Top) ,另一端称为栈底(Bottom)。
1.2 栈的特点
- 后进先出(LIFO):最后压入栈的元素最先被弹出。
- 操作受限:只能在栈顶进行插入和删除操作,限制了数据的访问方式,提高了特定场景下的效率。
1.3 栈的用途
栈在计算机科学中有广泛的应用,包括但不限于:
- 函数调用和递归:管理函数调用栈。
- 表达式求值和语法分析:如括号匹配、中缀转后缀表达式。
- 回溯算法:如深度优先搜索(DFS)。
- 浏览器的前进和后退功能。
- 撤销(Undo)操作。
二、栈的基本操作
栈的基本操作主要包括:
- Push(压栈):将元素添加到栈顶。
- Pop(出栈):移除并返回栈顶元素。
- Peek/Top(查看栈顶):返回栈顶元素但不移除。
- IsEmpty(判断栈是否为空)
- Size(获取栈的大小)
操作类型 | 方法名 | 时间复杂度 | 功能描述 |
---|---|---|---|
插入操作 | push() | O(1) | 将元素置于栈顶 |
删除操作 | pop() | O(1) | 移除并返回栈顶元素 |
查看操作 | peek() | O(1) | 返回栈顶元素但不移除 |
栈与队列的核心差异对比
特性 | 栈(Stack) | 队列(Queue) |
---|---|---|
操作原则 | LIFO | FIFO |
主要操作 | push/pop | enqueue/dequeue |
典型应用 | 函数调用/表达式求值 | 任务调度/消息队列 |
实现复杂度 | 简单 | 相对复杂 |
三、栈的实现方式
栈可以通过两种主要方式实现:顺序栈(基于数组) 和 链式栈(基于链表)
实现方式 | 底层结构 | 优势 | 劣势 | 适用场景 |
---|---|---|---|---|
顺序栈 | 数组 | 内存连续,访问速度快 | 大小固定,可能浪费空间 | 栈大小可预估的场景 |
链式栈 | 链表 | 动态扩容,灵活度高 | 需要额外指针空间 | 栈大小变化频繁的场景 |
3.1 顺序栈(基于数组)
顺序栈使用数组来存储栈中的元素,通过一个指针(通常称为top
)来指示栈顶的位置。
实现原理
- 数组:用于存储栈中的元素。
- Top指针 :指向栈顶元素的位置。初始时,
top = -1
表示栈为空。
操作实现
- Push :将元素添加到
top + 1
的位置,并更新top
。 - Pop :返回
top
位置的元素,并将top
减一。 - Peek/Top :返回
top
位置的元素。 - IsEmpty :检查
top
是否为-1
。 - Size :返回
top + 1
。
优缺点
- 优点: 实现简单,访问速度快。 内存连续,缓存友好。
- 缺点: 栈的大小固定,扩展性差(除非动态扩容)。 可能浪费空间,如果栈的使用量波动较大。
3.2 链式栈(基于链表)
链式栈使用链表来存储栈中的元素,每个节点包含数据和指向下一个节点的指针。栈顶即为链表的头节点。
实现原理
- 链表节点:包含数据和指向下一个节点的指针。
- Top指针:指向链表的头节点,即栈顶。
操作实现
- Push :在链表头部插入新节点,并更新
top
。 - Pop :移除链表头节点,并更新
top
。 - Peek/Top :返回
top
节点的数据。 - IsEmpty :检查
top
是否为nullptr
。 - Size:遍历链表计算节点数量(通常不常用,可以维护一个计数器)。
优缺点
- 优点: 动态大小,无需预先分配固定空间。 插入和删除操作高效,时间复杂度为O(1)。
- 缺点: 每个节点需要额外的指针空间,内存开销略大。 访问速度可能略低于顺序栈,尤其是在缓存不友好的情况下。
四、栈的C++实现
下面将分别展示顺序栈和链式栈的C++实现。
4.1 顺序栈(基于数组)的C++实现
cpp
#include <iostream>
#include <stdexcept>
class ArrayStack
{
private:
static const int MAX_SIZE = 1000; // 栈的最大容量
int data[MAX_SIZE];
int top;
public:
ArrayStack() : top(-1) {}
// 压栈操作
void push(int value)
{
if (top >= MAX_SIZE - 1)
{
throw std::overflow_error("Stack Overflow");
}
data[++top] = value;
}
// 出栈操作
int pop()
{
if (isEmpty())
{
throw std::underflow_error("Stack Underflow");
}
return data[top--];
}
// 查看栈顶元素
int peek() const
{
if (isEmpty())
{
throw std::underflow_error("Stack is Empty");
}
return data[top];
}
// 判断栈是否为空
bool isEmpty() const { return top == -1; }
// 获取栈的大小
int size() const { return top + 1; }
};
int main()
{
ArrayStack stack;
stack.push(10);
stack.push(20);
stack.push(30);
std::cout << "栈顶元素: " << stack.peek() << std::endl; // 输出 30
std::cout << "栈的大小: " << stack.size() << std::endl; // 输出 3
std::cout << "出栈元素: " << stack.pop() << std::endl; // 输出 30
std::cout << "出栈元素: " << stack.pop() << std::endl; // 输出 20
std::cout << "栈顶元素: " << stack.peek() << std::endl; // 输出 10
std::cout << "栈的大小: " << stack.size() << std::endl; // 输出 1
return 0;
}
代码说明
- ArrayStack类 :实现了基于数组的顺序栈。 成员变量 :
data[MAX_SIZE]
:用于存储栈元素的数组。top
:指示栈顶位置的指针,初始为-1
。 成员函数 :push(int value)
:将元素压入栈顶,若栈满则抛出异常。pop()
:移除并返回栈顶元素,若栈空则抛出异常。peek()
:返回栈顶元素,若栈空则抛出异常。isEmpty()
:判断栈是否为空。size()
:返回栈中元素的数量。 - main函数:演示了栈的基本操作,包括压栈、查看栈顶、出栈等。
4.2 链式栈(基于链表)的C++实现
cpp
#include <iostream>
#include <stdexcept>
// 定义链表节点
struct Node
{
int data;
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
class LinkedStack
{
private:
Node* top;
public:
LinkedStack() : top(nullptr) {}
// 压栈操作
void push(int value)
{
Node* newNode = new Node(value);
newNode->next = top;
top = newNode;
}
// 出栈操作
int pop()
{
if (isEmpty())
{
throw std::underflow_error("Stack Underflow");
}
Node* temp = top;
int poppedValue = temp->data;
top = top->next;
delete temp;
return poppedValue;
}
// 查看栈顶元素
int peek() const
{
if (isEmpty())
{
throw std::underflow_error("Stack is Empty");
}
return top->data;
}
// 判断栈是否为空
bool isEmpty() const { return top == nullptr; }
// 获取栈的大小
int size() const
{
int count = 0;
Node* current = top;
while (current != nullptr)
{
++count;
current = current->next;
}
return count;
}
// 析构函数,释放内存
~LinkedStack()
{
while (!isEmpty())
{
pop();
}
}
};
int main()
{
LinkedStack stack;
stack.push(100);
stack.push(200);
stack.push(300);
std::cout << "栈顶元素: " << stack.peek() << std::endl; // 输出 300
std::cout << "栈的大小: " << stack.size() << std::endl; // 输出 3
std::cout << "出栈元素: " << stack.pop() << std::endl; // 输出 300
std::cout << "出栈元素: " << stack.pop() << std::endl; // 输出 200
std::cout << "栈顶元素: " << stack.peek() << std::endl; // 输出 100
std::cout << "栈的大小: " << stack.size() << std::endl; // 输出 1
return 0;
}
代码说明
- Node结构体:定义了链表节点,包含数据和指向下一个节点的指针。
- LinkedStack类 :实现了基于链表的链式栈。 成员变量 :
top
:指向栈顶节点的指针,初始为nullptr
。 成员函数 :push(int value)
:在链表头部插入新节点,更新top
。pop()
:移除并返回栈顶节点的数据,释放节点内存,若栈空则抛出异常。peek()
:返回栈顶节点的数据,若栈空则抛出异常。isEmpty()
:判断栈是否为空。size()
:遍历链表计算节点数量。 析构函数:释放所有节点的内存,防止内存泄漏。 - main函数:演示了链式栈的基本操作,包括压栈、查看栈顶、出栈等。
五、栈的经典应用案例
5.1 括号匹配问题
问题描述 :给定一个只包括 '('
,')'
,'{'
,'}'
,'['
,']'
的字符串,判断字符串是否有效。有效字符串需满足:左括号必须与相同类型的右括号匹配,且左括号必须以正确的顺序匹配。
解决方案:使用栈来匹配括号。遍历字符串,遇到左括号时压栈,遇到右括号时出栈并与当前右括号匹配,若不匹配则字符串无效。遍历结束后,若栈为空,则字符串有效。
C++代码实现
cpp
#include <iostream>
#include <stack>
#include <string>
#include <unordered_map>
using namespace std;
bool isLegal(const string& s)
{
stack<char> st;
unordered_map<char, char> matching = {{')', '('}, {'}', '{'}, {']', '['}};
for (char c : s)
{
if (c == '(' || c == '{' || c == '[')
{
st.push(c);
} else
{
if (st.empty() || st.top() != matching[c])
{
return false;
}
st.pop();
}
}
return st.empty();
}
int main()
{
string s1 = "{[()()]}";
string s2 = "{( [ ) ] }";
cout << "字符串 \"" << s1 << "\" 是否合法: " << (isLegal(s1) ? "合法" : "非法") << endl; // 合法
cout << "字符串 \"" << s2 << "\" 是否合法: " << (isLegal(s2) ? "合法" : "非法") << endl; // 非法
return 0;
}
代码说明
- isLegal函数 :判断字符串中的括号是否匹配。 栈st :用于存储左括号。 matching映射 :定义右括号与对应左括号的匹配关系。 遍历字符串 : 遇到左括号,压栈。 遇到右括号,检查栈顶是否为对应的左括号,若匹配则出栈,否则返回非法。 最终检查:若栈为空,则所有括号匹配,返回合法;否则返回非法。
- main函数:测试两个示例字符串的合法性。
5.2 浏览器的前进和后退功能
问题描述:利用栈实现浏览器的后退和前进功能。用户访问新页面时,将其压入后退栈;当用户后退时,将页面从后退栈弹出并压入前进栈;当用户前进时,将页面从前进栈弹出并压入后退栈。
实现思路
- 两个栈 : 后退栈(Back Stack) :存储用户访问过的页面,用于后退操作。 前进栈(Forward Stack):存储用户后退后可以前进的页面,用于前进操作。
- 操作流程 : 访问新页面 :将页面压入后退栈,清空前进栈。 后退操作 :从后退栈弹出页面,压入前进栈,显示弹出的页面。 前进操作:从前进栈弹出页面,压入后退栈,显示弹出的页面。
C++代码实现
cpp
#include <iostream>
#include <stack>
#include <string>
using namespace std;
class Browser
{
private:
stack<string> backStack;
stack<string> forwardStack;
string currentPage;
public:
Browser() : currentPage("") {}
// 访问新页面
void visit(const string& url)
{
if (!currentPage.empty())
{
backStack.push(currentPage);
}
currentPage = url;
// 清空前进栈
while (!forwardStack.empty())
{
forwardStack.pop();
}
cout << "访问页面: " << currentPage << endl;
}
// 后退操作
void back()
{
if (backStack.empty())
{
cout << "无法后退" << endl;
return;
}
forwardStack.push(currentPage);
currentPage = backStack.top();
backStack.pop();
cout << "后退到页面: " << currentPage << endl;
}
// 前进操作
void forward()
{
if (forwardStack.empty())
{
cout << "无法前进" << endl;
return;
}
backStack.push(currentPage);
currentPage = forwardStack.top();
forwardStack.pop();
cout << "前进到页面: " << currentPage << endl;
}
// 获取当前页面
string getCurrentPage() const { return currentPage; }
};
int main()
{
Browser browser;
browser.visit("1");
browser.visit("2");
browser.visit("3");
browser.visit("4");
browser.visit("5");
cout << "当前页面: " << browser.getCurrentPage() << endl;
browser.back(); // 后退到 4
browser.back(); // 后退到 3
cout << "当前页面: " << browser.getCurrentPage() << endl;
browser.forward(); // 前进到 4
browser.forward(); // 前进到 5
cout << "当前页面: " << browser.getCurrentPage() << endl;
return 0;
}
代码说明
- Browser类 :模拟浏览器的后退和前进功能。 成员变量 :
backStack
:存储后退页面的栈。forwardStack
:存储前进页面的栈。currentPage
:当前显示的页面。 成员函数 :visit(const string& url)
:访问新页面,将当前页面压入后退栈,清空前进栈。back()
:执行后退操作,将当前页面压入前进栈,从后退栈弹出页面作为当前页面。forward()
:执行前进操作,将当前页面压入后退栈,从前进栈弹出页面作为当前页面。getCurrentPage()
:返回当前页面。 - main函数:演示了访问多个页面后的后退和前进操作。
六、栈的性能对比
栈作为一种基础数据结构,其性能在不同实现方式下有所差异。以下是顺序栈和链式栈的简要对比:
特性 | 顺序栈(基于数组) | 链式栈(基于链表) |
---|---|---|
空间复杂度 | 固定大小,可能浪费空间 | 动态大小,每个节点额外指针 |
时间复杂度 | 所有操作 O(1) | 所有操作 O(1) |
扩展性 | 受限于预分配的数组大小 | 动态扩展,无需预先分配 |
实现复杂度 | 简单 | 稍复杂,需管理节点内存 |
缓存友好性 | 高,内存连续 | 低,节点分散 |
总结:
- 顺序栈适用于栈大小固定或可预估的场景,实现简单且访问速度快。
- 链式栈适用于栈大小动态变化或不可预估的场景,具有更好的扩展性和灵活性。
七、栈的总结
7.1 栈的特性
- 后进先出(LIFO):最后压入栈的元素最先被弹出,这种特性使得栈在处理需要逆序操作的问题时非常有效。
- 操作受限:只能在栈顶进行插入和删除操作,这种限制提高了特定场景下的效率,减少了不必要的操作。
7.2 栈的优势
- 高效的操作:压栈和出栈操作的时间复杂度为O(1),适用于需要频繁进行插入和删除操作的场景。
- 简单易用:栈的基本操作直观,易于理解和实现。
- 广泛应用:栈在各种算法和系统设计中有着广泛的应用,如函数调用、表达式求值、括号匹配等。
7.3 栈的劣势
- 功能受限:由于只能在栈顶进行操作,栈在需要随机访问或中间插入/删除操作的场景下不适用。
- 空间限制:顺序栈的固定大小可能限制其应用,尽管可以通过动态扩容解决,但会增加复杂性。
八、练习题
每k个节点一组翻转链表
问题描述:给定一个包含n个元素的链表,要求每k个节点一组进行翻转,打印翻转后的链表结果。其中,k是一个正整数,且n可被k整除。
提示:可以参考以下步骤实现:
- 遍历链表,每次处理k个节点。
- 翻转每组k个节点。
- 将翻转后的组重新连接到链表中。
C++代码实现示例:
cpp
#include <iostream>
using namespace std;
// 定义链表节点
struct ListNode
{
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
// 翻转从head开始的k个节点
ListNode* reverseKGroup(ListNode* head, int k)
{
ListNode* curr = head;
int count = 0;
// 检查是否有k个节点
while (curr != nullptr && count < k)
{
curr = curr->next;
count++;
}
if (count < k)
{
return head; // 不足k个,不翻转
}
// 翻转k个节点
ListNode* prev = nullptr;
curr = head;
ListNode* next = nullptr;
for (int i = 0; i < k; ++i)
{
next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
// 递归翻转后续组,并连接
if (curr != nullptr)
{
head->next = reverseKGroup(curr, k);
}
return prev;
}
// 辅助函数:打印链表
void printList(ListNode* head)
{
ListNode* curr = head;
while (curr != nullptr)
{
cout << curr->val << " ";
curr = curr->next;
}
cout << endl;
}
// 辅助函数:创建链表
ListNode* createList(int arr[], int n)
{
if (n == 0)
return nullptr;
ListNode* head = new ListNode(arr[0]);
ListNode* curr = head;
for (int i = 1; i < n; ++i)
{
curr->next = new ListNode(arr[i]);
curr = curr->next;
}
return head;
}
int main()
{
int arr[] = {1, 2, 3, 4, 5, 6};
int n = sizeof(arr) / sizeof(arr[0]);
int k = 3;
ListNode* head = createList(arr, n);
cout << "原链表: ";
printList(head);
ListNode* newHead = reverseKGroup(head, k);
cout << "每" << k << "个节点一组翻转后的链表: ";
printList(newHead);
return 0;
}
代码说明:
- reverseKGroup函数 :递归地翻转每k个节点一组。 检查是否有k个节点 :遍历k个节点,若不足则返回原链表头。 翻转k个节点 :使用三个指针(prev, curr, next)翻转当前组的k个节点。 递归处理后续组:将翻转后的组的尾节点连接到后续翻转组的头节点。
- createList和printList函数:辅助函数,用于创建链表和打印链表内容。
- main函数 :创建一个示例链表,调用
reverseKGroup
函数进行每k个节点一组翻转,并打印结果。
输出结果:
cpp
原链表: 1 2 3 4 5 6
每3个节点一组翻转后的链表: 3 2 1 6 5 4
总结
栈作为一种基础且强大的数据结构,遵循**后进先出(LIFO)**的原则,具有高效的操作性能和广泛的应用场景。通过本文的介绍,您应该对栈的基本概念、操作、实现方式以及实际应用有了深入的理解。栈不仅在理论上有重要地位,在实际编程中也扮演着不可或缺的角色,如函数调用管理、表达式求值、括号匹配等问题都可以通过栈优雅地解决。
(觉得有用请点赞收藏,你的支持是我持续更新的动力!)