(a)栈接口与实现

官官方信息技术老师·16 页·深入(追求细节与边界)·0 次浏览·38 天前
数据结构接口设计底层数据

栈接口与实现

看完你能说清栈的接口约定,并亲手写出数组与链表两种实现

按 空格/→ 演示下一步

问一句
1 / 16 页

全部页面点击任意一页,跳回舞台从这页播放

数据结构接口设计底层数据

栈接口与实现

看完你能说清栈的接口约定,并亲手写出数组与链表两种实现

1第 1 页 · 栈接口与实现

什么是栈

家里叠盘子:洗好的盘子一个压一个往上放,取的时候也只从最上面拿——最先放的反而最后被用。这种「只在一端进出」的存放方式,就是数据结构里的栈。

一端操作
只能从同一端(栈顶)放入和取出元素
后进先出
最后放入的元素最先被取出,即 LIFO
三个核心操作
push 入栈、pop 出栈、peek 只看不取栈顶
叠盘子对应 →栈

盘子的最上层对应栈顶,放盘取盘对应 push 和 pop

2第 2 页 · 什么是栈

LIFO:后进先出

沿箭头从上往下看:三个元素依次进栈,再依次出栈,注意进出顺序刚好相反。

图解渲染中…
b0:栈顶永远指向最后进栈的元素,进出都发生在栈顶
3第 3 页 · LIFO:后进先出

栈的实例:身边的栈

上一页说栈是「后进先出」。听起来抽象?其实你每天都在用:浏览器后退、Excel 撤销、函数嵌套调用,背后都是同一个栈在悄悄工作。

浏览器后退
每点开新网页压栈,后退就是弹栈,回到上一个地址
函数调用
函数层层嵌套,只有最后调用的先执行完返回
括号匹配
遇到左括号压栈,右括号弹栈配对,栈空即合法
撤销操作
Ctrl+Z 撤销的正是最近一次操作,与 LIFO 完全一致
叠放的餐盘对应 →栈的实例

最后放上去的盘子最先被取走,对应各场景的「最近优先」处理

4第 4 页 · 栈的实例:身边的栈

接口与实现分离

你已经知道栈是后进先出的容器。但写程序前得先约定:外界只能对它做哪些操作?先把「能做什么」说清楚,再谈「怎么做」,这就是接口与实现的分离。

接口:约定
只声明 push、pop、peek、isEmpty 等操作,不写任何代码细节
实现:机制
用数组还是链表来存数据,是接口背后隐藏的部分
抽象数据类型
接口加行为约定构成 ADT,使用者只依赖约定而非内部结构
可替换性
实现换掉,只要操作语义不变,调用方代码一行不改
自动售货机对应 →栈接口

你只用投币按钮对应操作接口,内部货道机构对应实现

5第 5 页 · 接口与实现分离

栈的 ADT 操作集

图中以栈顶为枢纽:所有操作只发生在 top 一端,沿箭头看每次调用如何改变栈顶。

图解渲染中…
t3:pop 后栈顶回退到原来第二个元素e1:空栈时 isEmpty 为 true,此时 pop 应报错
6第 6 页 · 栈的 ADT 操作集

栈接口的定义

python

用 Python 抽象基类给出栈接口:五个方法,各自的语义与前置条件。

代码高亮加载中…

继承 ABC 强制子类实现全部方法——接口只声明契约;pop 与 peek 的区别在于是否移除元素,空栈是两者共同的边界条件。

7第 7 页 · 栈接口的定义

一次 pop 的完整约定

pop 一次调用,内部走三步约定,缺一步都不完整。

1
判空检查
先看 size 是否为 0,空栈直接抛异常,防止访问非法内存
2
取栈顶
暂存栈顶元素引用,供返回使用
3
删栈顶
移动 top 指针、size 减一,最后返回暂存的元素
8第 8 页 · 一次 pop 的完整约定

实现思路总览

前面我们把 pop 的约定写得清清楚楚,却从没说过数据放在哪。这正是接口分离的好处:只要行为守约,底层既可以是连续数组,也可以是链式列表。本页先看这两条路线的全貌。

两条路线
基于向量(数组)或基于列表(链表),接口完全一致
数组实现
尾端做栈顶,尾插尾删 O(1),可能需要扩容
列表实现
头部做栈顶,头插头删 O(1),无扩容但有节点开销
选择依据
操作都在端点,两者均 O(1),按内存与场景取舍
同一块插座的电器对应 →接口与实现分离

插座孔位不变(接口),墙后走明线还是暗线(实现)随意

9第 9 页 · 实现思路总览

基于数组的栈

一个数组的两头,左端固定是栈底,右端才是栈顶;size 只是一个计数器,不指向数据本身。

图解渲染中…
b1:栈顶元素位于已用区末端,即数组下标 size-1 处b4:size 只计数,不是下标;栈顶下标 = size-1b2:空槽早已存在,push 不创建新空间,只是覆盖并计数加一c1:pop 不搬动元素,只读末尾并把 size 减一
10第 10 页 · 基于数组的栈

基于链表的栈

从左往右看:head 永远是栈顶,push 和 pop 都只动头部,不碰后面任何节点。

图解渲染中…
b1:链表首节点即栈顶,指针 head 指向它c1:栈中其余元素,栈操作不访问它们d1:尾节点 next 为空,标记栈的底部
11第 11 页 · 基于链表的栈

关键操作的实现代码

python

对照两种实现的 push/pop 源码,看栈顶位置的选择如何决定代码形态。

代码高亮加载中…

两边都选 O(1) 端作栈顶:数组选尾部,链表选头部。若数组用头部、链表用尾部,操作都退化为 O(n)。

12第 12 页 · 关键操作的实现代码

两种实现的对比

数组栈和链表栈都能实现栈,但性能与适用场景不同,容易混淆,逐维度对比。

数组栈
  • 存取快:数组连续,缓存友好
  • 空间固定:满时需整体扩容搬迁
  • 无每元素指针,内存更紧凑
链表栈
  • 存取稍慢:节点分散,缓存不友好
  • 空间弹性:按需分配节点,无需扩容
  • 每节点多一个指针,有额外开销
容量可预估、追求速度时选数组栈;规模波动大、无法预估上限时选链表栈。
13第 13 页 · 两种实现的对比

自测:栈的接口与实现

一道选择题检验对栈顶操作与实现取舍的理解

自测:栈的接口与实现
一道选择题检验对栈顶操作与实现取舍的理解
14第 14 页 · 自测:栈的接口与实现

要点收束

  • ✓栈是行为约束:只看栈顶、LIFO,与底层结构无关
  • ✓接口与实现分离:使用者只依赖 ADT 操作集
  • ✓数组实现:栈顶下标即插入位置,O(1) 增删
  • ✓链表实现:头结点即栈顶,天然动态扩容
  • ✓边界要约定清楚:空栈 pop、满栈 push 各有定义
延伸主题:双栈共享一个数组的实现技巧用两个队列模拟一个栈栈的应用:表达式求值与括号匹配
15第 15 页 · 要点收束

课后思考

先自己想,想不动了再看参考答案——思路比结论重要。

1为什么函数调用天然适合用栈,而不是队列?如果调用顺序换成 FIFO 会发生什么?

参考答案想想嵌套调用:最先调用的函数最后返回,正是 LIFO。若用队列,最先调用的函数反而最先返回,调用者的上下文还没用完就被覆盖,嵌套结构无法维持。

2对空栈调用 peek 或 pop,接口应该约定什么?返回特殊值和抛异常各适合什么场景?

参考答案核心是调用方能否区分「空」与「值恰好为 null/哨兵」。语言支持异常时可抛异常让错误显式暴露;或提供 isEmpty 前置判断,约定写在接口文档里,调用方负责。

3数组栈和链表栈的边界情况各藏在哪?什么场景下你会放弃默认选择换另一种?

参考答案数组栈怕扩容瞬间开销与预留空间浪费;链表栈每个元素都要付节点指针的内存与缓存局部性代价。大量随机访问或内存紧张时值得重新权衡。

16第 16 页 · 课后思考