很多朋友第一次接触数据结构,上来就被“栈”这个字吓住了,觉得它一定很抽象。其实它特别接地气:你往一摞盘子里放新盘子,最后放上去的那个总是最先被拿走,这就是栈;浏览器点“后退”,回到的也是你最近访问过的页面,这还是栈。栈的核心就一句话——后进先出(LIFO,Last In First Out)。而顺序栈,就是用一段连续的内存(也就是数组)去实现这个后进先出的容器。用C++的类把顺序栈封装成一个自定义ADT,是数据结构里非常经典的一次练习,既能练到类设计,又能真正理解“接口与实现分离”到底是什么意思。这篇文章就围绕这个题目,从设计思路到完整代码,再到那些书里不会写的坑,一起捋一遍。适合刚学数据结构、准备课程实验或者期末上机的同学,也适合想把C++基础语法和数据结构结合起来复习的读者。
1. 顺序栈到底在解决什么问题
1.1 从“后进先出”说起:栈这种结构为什么重要
栈是一种操作受限的线性表,它只允许在一端进行插入和删除。这个端叫栈顶,另一端叫栈底。限制操作看起来是“自废武功”,但恰恰是这个限制,让栈变成了计算机世界里最常用的结构之一。函数调用的时候,系统就是靠栈来保存每一层调用的返回地址和局部变量的;表达式求值时,操作数和运算符的临时存放也依赖栈;括号匹配、编辑器里的撤销、深度优先搜索,背后都是栈的模型。你甚至可以这么理解:栈是一种“带有记忆回退能力”的容器,它天然适合处理“嵌套”和“需要撤销”的场景。
顺序栈的意义,在于用最简单的方式把这种能力落地。它基于数组,栈内元素在内存里是连续的,你可以通过下标直接访问栈顶,不需要像链表那样为了找一个节点而一路next。对于“只在栈顶操作”这种模式,数组的随机访问能力其实是很奢侈的,但我们实际关心的只是栈顶那一个位置,所以顺序栈的push和pop都能做到O(1)时间复杂度,这也是它能作为入门例子的原因之一。
1.2 为什么先写顺序栈而不是链式栈
很多教材会把顺序栈和链式栈放在一起讲。顺序栈用数组,链式栈用链表。两者在逻辑上完全等价,都能实现后进先出,但底层的内存组织方式不同。顺序栈的优势是局部性好,因为元素在连续内存里,CPU缓存的命中率更高;缺点是容量需要提前规划,用完了要么报错,要么动态扩容。链式栈的优势是不用担心容量上限,每次push都动态分配一个新节点,缺点是每个节点都要额外存一个指针,内存碎片的压力更大,而且节点访问要跨内存跳转,缓存不友好。
那为什么入门阶段先写顺序栈?我的看法是:顺序栈把“什么时候满、什么时候空、什么时候越界”这些边界问题暴露得很明显,而这些边界恰恰是数据结构的核心。链式栈只要处理“new出来的节点还在不在”就行,问题的复杂度被绕过去了。你先把顺序栈写明白,再去看链式栈,基本上就是换个存储方式,逻辑完全不变。反过来如果你先写链式栈,再回头写顺序栈,反而容易忽略扩容和连续内存的管理这些关键点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手写栈类之前,先理清ADT
2.1 ADT是什么
ADT是Abstract Data Type的缩写,中文一般叫抽象数据类型。它描述的是“一个数据结构对外提供什么样的数据和操作”,不关心内部是怎么实现的。你不需要知道栈底层是数组还是链表,你只需要知道它能push、能pop、能取栈顶、能判断空。这就好比你去餐馆点菜,你只需要知道菜单上有“鱼香肉丝”这道菜,不需要知道后厨是哪个锅炒的、用的什么灶。菜单就是接口,后厨就是实现。
ADT这个概念很多人学的时候觉得虚,但它其实是工程里“封装”思想的源头。把ADT定义清楚,写代码的时候就可以先定接口,再想实现。比如栈的ADT可以先列出这些操作:初始化一个空栈、销毁栈、判断栈空、判断栈满、入栈、出栈、取栈顶元素。有了这张清单,你写类的时候就知道该有哪些公开方法,不会东写一个西写一个。
2.2 顺序栈的ADT定义
一个标准的顺序栈ADT,可以这样描述。数据对象:一组类型为ElemType的元素,元素之间具有线性关系。数据关系:所有元素按入栈顺序排列,且只能在栈顶一端操作。基本操作分两类,一类是查询类操作,只读不改,比如isEmpty、isFull、top、size;另一类是修改类操作,会改变栈的状态,比如push、pop。为什么要把操作分这么细?因为查询类操作可以用const修饰,这对C++来说既是一种语法上的约束,也给使用方传递了一个信号:调用这些方法不会改动栈。
我们可以把顺序栈ADT的关键操作列成一张表,后面写代码时直接对照这张表来核对功能是否完整。
| 操作 | 作用 | 是否改变状态 |
|---|---|---|
| InitStack | 创建一个空栈 | 是 |
| IsEmpty | 判断栈是否为空 | 否 |
| IsFull | 判断栈是否已满 | 否 |
| Push | 把元素压入栈顶 | 是 |
| Pop | 删除栈顶元素 | 是 |
| Top | 读取栈顶元素,不删除 | 否 |
| Size | 返回栈内元素个数 | 否 |
看到没,ADT的核心就是这张表格。你实现的类只要满足这张表,底层怎么折腾都行。这也是为什么说“面向接口编程”:使用方只管调用,实现方只管把内部细节藏好。
2.3 为什么用C++的类而不是C语言struct
C语言里也能实现栈,无非是搞一个结构体,里面放一个数组和栈顶指针,再写一组函数去操作这个结构体。但问题在于,C语言的结构体默认什么都是public的,调用方可以随手修改data[i],直接把栈的内部数据搅乱。你没法阻止一个粗心的调用者越界访问数组,也没办法在代码层面保证“只能通过push/pop来修改”。用C++的class,你就可以把data、capacity、topIndex这些成员放到private区域,外面只能通过public接口操作。这不是语法炫技,而是真正的工程约束。
另外,C++的类还允许构造函数和析构函数。构造函数负责在对象创建时把data分配好、把topIndex初始化成0;析构函数负责在对象销毁时把data释放掉。这样调用方就不需要记得“用完了要释放”,只要栈对象离开作用域,资源自动回收。这种RAII的思想在C++里特别重要,而顺序栈就是体会RAII最好的练习项目之一。
3. 代码实现:从一个最简版顺序栈开始
3.1 类的基本骨架
先不要急着写动态扩容,我们从一个固定容量的版本开始,把整体结构搭起来。类里至少要包含三块:数据成员、构造函数析构函数、基本操作接口。数据成员我用三个:int* data表示底层数组,int capacity表示容量上限,int topIndex表示栈顶位置。这里有一个关键约定需要提前定好:topIndex是指“栈顶元素的下一个位置”,也就是栈内元素个数。为什么这样约定?因为空栈时topIndex为0,满栈时topIndex等于capacity,size()函数直接返回topIndex,非常自然。另一种写法是用topIndex指向栈顶元素本身,空栈时置为-1,也能用,但每次判断和size都要多一步运算。
cpp复制#include <stdexcept>
class SeqStack {
public:
SeqStack(int initCapacity = 16);
~SeqStack();
void push(int value);
void pop();
int top() const;
bool isEmpty() const;
bool isFull() const;
int size() const;
private:
int* data;
int capacity;
int topIndex;
};
构造函数的实现要注意一点:capacity可能被传入非法值,比如-5,这时候要兜底处理。我一般会在构造函数里对capacity做一次合法性检查,小于1就按1处理,避免后面new出一个长度为负数的数组直接抛异常。初始化列表里可以先把data设为nullptr,再在函数体里new,这样即使new失败,对象也处于一个可销毁的状态,不会留下野指针。
cpp复制SeqStack::SeqStack(int initCapacity)
: data(nullptr), capacity(initCapacity), topIndex(0) {
if (capacity < 1) {
capacity = 1;
}
data = new int[capacity];
}
3.2 push和pop为什么必须判满判空
push操作的第一步是判断栈满。如果栈已经满了,你还往数组里写,就会越界访问,这是C++里非常危险的未定义行为,可能当时看起来没事,但过一会儿在另一个函数里莫名其妙崩溃。所以满栈时要么报错,要么扩容。这里先按报错处理,后面再讲扩容。pop操作的第一步是判断栈空。空栈你还想删元素,topIndex减到负数,下一次push就会写到data[-1],轻则逻辑错乱,重则段错误。
cpp复制void SeqStack::push(int value) {
if (isFull()) {
throw std::overflow_error("SeqStack overflow");
}
data[topIndex++] = value;
}
void SeqStack::pop() {
if (isEmpty()) {
throw std::underflow_error("SeqStack underflow");
}
--topIndex;
}
很多人会问,pop的时候需不需要把data[topIndex]清零?我的建议是没必要。因为下一次push到这个位置时,赋新值会覆盖旧值,清零只会白白增加开销。栈的“删除”逻辑上是删除,物理上只是把topIndex往前挪一格,这恰恰是栈高效的原因之一。等pop之后,你不再通过栈接口访问那个位置的元素就行了。
3.3 top、isEmpty、isFull、size这些接口别偷懒
top方法返回栈顶元素。要注意的是,取栈顶和pop是两回事:top只读不改,pop会改变栈状态。所以top要判空,而且建议用const修饰,因为它不修改对象。返回值我在这里先直接用int,后面可以讨论返回引用甚至const引用的问题。isFull和isEmpty都是非常简单的判断,但一定要写,不要直接在主代码里比较topIndex,那样会把topIndex的约定散落到各个地方,改起来很痛苦。
cpp复制int SeqStack::top() const {
if (isEmpty()) {
throw std::underflow_error("SeqStack is empty");
}
return data[topIndex - 1];
}
bool SeqStack::isEmpty() const {
return topIndex == 0;
}
bool SeqStack::isFull() const {
return topIndex == capacity;
}
int SeqStack::size() const {
return topIndex;
}
size这个接口很方便,因为我的topIndex直接代表元素个数,所以返回它就是O(1)。如果topIndex表示栈顶元素下标,这里还要加1,反正也能做。建议刚学的时候把这些辅助函数全都写出来,不要嫌麻烦,因为后面测试的时候你就知道这些接口有多香了。
4. 从“能跑”到“能用”:这个栈还有哪些坑
4.1 固定容量不够用,动态扩容怎么设计
固定容量版本只能算一个教学demo,实际使用时容量很容易不够。最常见的解决方案是动态扩容。思路很简单:当push发现栈满时,申请一块更大的内存,把旧元素搬过去,释放旧内存,更新容量。关键是扩容的时机和倍数。我的经验是:判断时机放在push里,写一个private的reserve方法负责真正的扩容,这样push的职责就非常清晰。
扩容倍数一般取2倍,也有人取1.5倍。为什么不能每次只增加1个?你想想,如果每次push都触发扩容,那么插入n个元素的总开销是1+2+3+...+n,O(n^2)量级,非常糟糕。而倍增策略下,扩容次数大约是log n次,总开销是O(n),均摊到每次push就是O(1)。这就是教科书里说的“均摊时间复杂度”。
cpp复制void SeqStack::reserve(int newCapacity) {
if (newCapacity <= capacity) {
return;
}
int* newData = new int[newCapacity];
for (int i = 0; i < topIndex; ++i) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCapacity;
}
这段代码看起来简单,但有个很重要的工程细节:一定要先new新数组,把数据拷贝完,再delete旧数组,顺序不能反。如果你先delete旧数组,再new新数组,万一new抛异常,旧数据就全丢了,这是一个经典的异常安全性问题。先new再delete,即使new失败,旧数组还完好,对象状态没被破坏。
4.2 拷贝构造和赋值运算符,不写就会double free
这是新手最容易踩的坑,也是面试官最爱问的“Rule of Three”问题。如果类里有裸指针,而且你写了析构函数去delete它,那么编译器自动生成的拷贝构造函数和赋值运算符就是错的。为什么?因为默认拷贝是浅拷贝,两个对象的data指针指向同一块内存。当一个对象析构时delete了这块内存,另一个对象的指针就变成悬空指针;当两个对象都析构时,同一块内存被delete两次,直接double free崩溃。
解决办法是深拷贝。拷贝构造时,new一块独立的内存,把源对象的数据逐个拷贝过来。赋值运算符更麻烦,因为它要先释放当前对象已有的内存,再拷贝源对象。很多人会先判断自赋值,然后再释放和拷贝,这样能工作但不够优雅。我更推荐用copy-and-swap写法:先构造一个临时对象,再把临时对象和当前对象的内部成员交换,这样不仅处理了自赋值,而且异常安全。
cpp复制SeqStack::SeqStack(const SeqStack& other)
: data(nullptr), capacity(other.capacity), topIndex(other.topIndex) {
data = new int[capacity];
for (int i = 0; i < topIndex; ++i) {
data[i] = other.data[i];
}
}
SeqStack& SeqStack::operator=(const SeqStack& other) {
if (this != &other) {
SeqStack tmp(other);
std::swap(data, tmp.data);
std::swap(capacity, tmp.capacity);
std::swap(topIndex, tmp.topIndex);
}
return *this;
}
注意需要包含
4.3 栈顶指针的边界细节:一个索引引发的血案
topIndex的边界是顺序栈最容易出bug的地方。我见过很多同学写pop的时候直接--topIndex,不判空。如果栈本来就是空的,topIndex变成-1,没人发现,接着push一个元素,data[topIndex++] = value实际上写到了data[-1],这就是典型的“潜伏型bug”。程序可能不在出错的位置崩溃,而是在后面某个完全不相干的地方崩,排查起来特别痛苦。
还有一个细节:top()方法返回data[topIndex - 1],当栈为空时,topIndex是0,topIndex - 1就是-1。如果不判空直接返回data[-1],这是未定义行为。很多机器上data[-1]取出来的是一块随机内存的值,程序看起来“运行正常”,但结果完全错误。所以在写top、pop这类操作前,先判空是底线。再配合assert或者抛出异常,能让问题在第一时间暴露出来。
5. 完整代码实现与测试用例
5.1 可以直接编译运行的完整代码
把之前的内容整合一下,就是一个带动态扩容、深拷贝的完整顺序栈。我把代码贴在这里,头文件里需要
cpp复制#include <stdexcept>
#include <algorithm>
class SeqStack {
public:
SeqStack(int initCapacity = 16);
SeqStack(const SeqStack& other);
SeqStack& operator=(const SeqStack& other);
~SeqStack();
void push(int value);
void pop();
int top() const;
bool isEmpty() const;
bool isFull() const;
int size() const;
private:
void reserve(int newCapacity);
int* data;
int capacity;
int topIndex;
};
SeqStack::SeqStack(int initCapacity)
: data(nullptr), capacity(initCapacity), topIndex(0) {
if (capacity < 1) {
capacity = 1;
}
data = new int[capacity];
}
SeqStack::SeqStack(const SeqStack& other)
: data(nullptr), capacity(other.capacity), topIndex(other.topIndex) {
data = new int[capacity];
for (int i = 0; i < topIndex; ++i) {
data[i] = other.data[i];
}
}
SeqStack& SeqStack::operator=(const SeqStack& other) {
if (this != &other) {
SeqStack tmp(other);
std::swap(data, tmp.data);
std::swap(capacity, tmp.capacity);
std::swap(topIndex, tmp.topIndex);
}
return *this;
}
SeqStack::~SeqStack() {
delete[] data;
}
void SeqStack::reserve(int newCapacity) {
if (newCapacity <= capacity) {
return;
}
int* newData = new int[newCapacity];
for (int i = 0; i < topIndex; ++i) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCapacity;
}
void SeqStack::push(int value) {
if (isFull()) {
reserve(capacity * 2);
}
data[topIndex++] = value;
}
void SeqStack::pop() {
if (isEmpty()) {
throw std::underflow_error("SeqStack underflow");
}
--topIndex;
}
int SeqStack::top() const {
if (isEmpty()) {
throw std::underflow_error("SeqStack is empty");
}
return data[topIndex - 1];
}
bool SeqStack::isEmpty() const {
return topIndex == 0;
}
bool SeqStack::isFull() const {
return topIndex == capacity;
}
int SeqStack::size() const {
return topIndex;
}
这段代码可以直接拷到你的工程里编译。当然,这只是基于int类型的基础版本,后面我会再讲怎么把它改成模板类,让它可以装任意类型。
5.2 测试用例怎么写才不算白写
很多同学写完栈类之后,就在main里push几个元素、pop一下,看到输出正常就觉得完事了。但数据结构的代码,边界情况才是真正容易出问题的地方。我建议至少覆盖这几类场景:空栈操作有没有异常、压栈出栈顺序是否正确、栈满扩容后数据是否一致、拷贝出来的对象是否真的独立、赋值之后旧内存有没有泄漏。下面这段测试代码就是按这几个方向写的,用assert做判断,跑通一个就输出一行。
cpp复制#include <iostream>
#include <cassert>
void testBasic() {
SeqStack s;
assert(s.isEmpty());
s.push(1);
s.push(2);
assert(s.top() == 2);
s.pop();
assert(s.top() == 1);
assert(s.size() == 1);
std::cout << "testBasic passed" << std::endl;
}
void testGrow() {
SeqStack s(2);
s.push(1);
s.push(2);
s.push(3);
assert(s.size() == 3);
assert(s.top() == 3);
std::cout << "testGrow passed" << std::endl;
}
void testCopy() {
SeqStack a(2);
a.push(10);
a.push(20);
SeqStack b(a);
a.pop();
assert(b.top() == 20);
SeqStack c;
c = a;
assert(c.size() == 1);
assert(c.top() == 10);
std::cout << "testCopy passed" << std::endl;
}
int main() {
testBasic();
testGrow();
testCopy();
return 0;
}
testCopy里那几个assert特别关键。SeqStack b(a)之后,如果拷贝构造写成了浅拷贝,b和a共享同一块data,那么a.pop()虽然只是改了a的topIndex,但b的data指针还指向那块内存,数据本身还留着20,可能侥幸通过;真正爆雷的是两个对象析构时double free。所以如果你发现程序跑完直接崩溃,九成就是浅拷贝的问题。建议你用这个测试用例去验证自己的拷贝构造和赋值运算符。
5.3 复杂度与面试考查点
顺序栈的基本操作复杂度,用一张表可以看得很清楚。
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| push | 均摊O(1) | 不扩容时O(1),扩容时O(n),均摊O(1) |
| pop | O(1) | 只移动栈顶指针 |
| top | O(1) | 直接按下标访问 |
| isEmpty/isFull/size | O(1) | 都是比较或读一个字段 |
| 空间复杂度 | O(n) | n为当前已分配容量 |
面试里围绕顺序栈有两个高频问题。第一个是“为什么顺序栈扩容后均摊复杂度是O(1)”,你要能说出“扩容次数O(log n),总拷贝量O(n),均摊O(1)”这个推导。第二个是“如果不写拷贝构造和赋值运算符会怎样”,这其实是在考C++的三法则。如果你能再讲出copy-and-swap、异常安全和移动语义,那这一题基本就稳了。
6. 常见问题与排查技巧实录
6.1 段错误:多半是空栈操作
我见过太多同学在写栈相关算法时,先在空栈上调用top,然后程序段错误,第一反应是去查push函数有没有写错。实际上,问题往往出在调用方没有判空。解决思路有两个层面:第一,在栈类的top和pop里主动判空并抛出异常,这样出错时能得到一个正常的错误信息,而不是段错误;第二,在使用方养成“先确认空再取元素”的习惯。即使你的栈类已经有异常处理,也不要依赖它来兜底,因为异常抛出后如果没被捕获,程序还是会终止。
这里有一个排查建议:如果程序在栈操作附近段错误,先检查所有调用top和pop的地方,看它们是否都提前判空了。另一个很实用的技巧是在调试时打印topIndex的值,TopIndex变成-1或者超出capacity,基本就能锁定问题。
6.2 内存泄漏与double free:用工具说话
C++的内存问题,肉眼很难看出来,建议直接上工具。Linux下用valgrind,或者用g++/clang++自带的AddressSanitizer,编译时加上-fsanitize=address -g,运行时它就能帮你检测越界访问、double free、内存泄漏等问题。Windows下用Visual Studio的调试模式或者Dr. Memory,也能达到类似效果。很多同学觉得这些工具是“大佬才用的”,其实完全不是,新手尽早用起来反而能少走很多弯路。
举个例子,如果你忘了写拷贝构造函数,程序运行到两个对象析构时可能会报“double free or corruption”。看到这个报错,第一反应就应该是检查类里有没有裸指针成员,以及有没有实现拷贝构造和赋值运算符。如果你用了valgrind,它甚至会直接告诉你哪一行delete了两次,排查效率高很多。
6.3 编码细节:const、初始化列表、命名
最后说几个看起来小但实际很重要的编码习惯。第一个是const。不会修改对象状态的成员函数,比如isEmpty、isFull、top、size,一定要加const。这不仅能防止你误改成员变量,还能让const对象也能调用这些方法。第二个是初始化列表。构造函数里能用的初始化列表都用初始化列表,尤其是指针成员,先初始化为nullptr或有效值,再在函数体里分配,可以避免变量处于未初始化状态。第三个是命名。topIndex这样的命名,比简写成top或者index要好得多,三个月后你回来看代码,不用猜就能知道它代表什么。
还有一个细节是malloc/free和new/delete的配对问题。C++里数组分配用new[],释放就必须用delete[],不要混着写。虽然对int这种内置类型来说,混用delete和delete[]很多时候碰巧不会崩,但一旦元素是自定义类型,不匹配会直接导致析构函数不调用,又是一个很难查的坑。
7. 再进一步:模板化、共享栈和STL对比
7.1 从int到任意类型:模板化改造
等把int版本的顺序栈写熟了,下一步就是把它改成模板类。改动其实不大:类名写成template
cpp复制template <typename T>
class SeqStack {
public:
SeqStack(int initCapacity = 16);
SeqStack(const SeqStack& other);
SeqStack& operator=(const SeqStack& other);
~SeqStack();
void push(const T& value);
void pop();
T top() const;
bool isEmpty() const;
bool isFull() const;
int size() const;
private:
void reserve(int newCapacity);
T* data;
int capacity;
int topIndex;
};
模板类的成员函数定义一般要写在头文件里,因为模板的实例化需要看到完整定义。如果你把实现放在.cpp里,在另一个文件里使用,链接时就会报找不到函数定义。这个问题几乎每个学模板的同学都会遇到,提前知道能省很多排查时间。
7.2 共享栈:两个栈共用一块内存
共享栈是一个很经典的扩展玩法。思路是:一块数组,从两端同时向中间增长。左侧栈的栈底在数组下标0,右侧栈的栈底在数组下标capacity-1。左侧栈push时topIndex1先加,右侧栈push时topIndex2先减。当两个栈顶相遇时,整个数组才真正满了。这种结构的价值在于,两个栈对内存的需求往往是互补的,一个栈压得多的时候另一个栈可能空着,共享内存可以让总容量更充分利用。
共享栈的判满条件是topIndex1 + 1 == topIndex2,这个条件反应了“两栈顶相邻”。左侧栈空时topIndex1等于-1,右侧栈空时topIndex2等于capacity。逻辑上比单栈稍微复杂一点,但写一遍之后,你对“栈顶指针只是一个位置标记”的理解会深很多。
7.3 与STL的stack容器适配器对比
学完自己实现的顺序栈,再看STL的std::stack会有一种熟悉感。std::stack的接口也是push、pop、top、empty、size这些,但它并不是一个独立的容器,而是容器适配器。它默认用deque作为底层,也可以指定用vector或list。所谓适配器,就是“包一个现成的容器,只暴露符合栈语义的接口”。你在底层上调用push_back和pop_back,对外面的使用者来说,它就是一个栈。
自己实现一遍顺序栈之后,再看std::stack,你会更清楚它“省去了什么”和“保留了哪些核心”。STL的stack为了通用性做了很多复杂设计,但核心逻辑和你手写的顺序栈是一样的。先写一个能跑的版本,再去看STL的源码或者文档,你会发现理解门槛低了很多,这也是我一直建议“数据结构必须手撸一遍”的原因。
写到这里,这个顺序栈从设计到扩展算是完整盘了一遍。回过头想说,我自己当年踩得最狠的一个坑,是在空栈上直接调top,那种一旦出错就段错误的场景,排查起来真的头大。后来我养成了一个习惯:凡是访问容器元素之前,先确认边界,再写逻辑。这个习惯在后续写二叉树、写图算法的时候,帮我躲开了很多难查的bug。如果你也在练数据结构,建议不要只把代码跑通就完事,试着把代码改成模板、加上扩容、补上异常处理,再写几个边界测试用例。全部做一遍之后,你会对栈这个结构,也对C++的类设计,有完全不一样的感觉。
