椰程信奥·互动课件 专题9·数据结构1:栈与队列
10:00 1 / 20
🧭 椰程信奥 · CSP复赛专题系列

专题9 · 数据结构1

栈与队列都没有发明新的存储,它们只是给"线性表"装上了不同的门:
栈只开一个门(先入后出),队列开前后两道门(先入先出)。
本讲按视频节奏:原理 → 手写实现 → STL → 对比选型 → 两道真题 → 两道拓展。

数组模拟栈 · 栈指针 循环队列(判空判满) 括号匹配 · 猴子选大王
路线

跟着视频走:五个段落,一条主线

视频 42′54″。主线是「先手写、再用 STL」——只有亲手推过指针怎么动,才知道 STL 那个函数到底替你做了什么。

🎬 视频节奏

  1. 0–12′ 栈:FILO 原理 → 数组模拟(栈指针)→ STL stack
  2. 12–25′ 队列:FIFO 原理 → 数组模拟 → 循环队列 → STL queue
  3. 25–30′ 对比:结构特性、操作逻辑、适用场景、怎么选型
  4. 30–42′ 例题:表达式括号匹配(栈)/ 猴子选大王(队列)
  5. 42′+ 拓展:后缀表达式求值 / Blah 数集

🎯 这一讲要拿下什么

  • 能徒手用数组写出栈和队列,说清指针每一次怎么动。
  • 能解释循环队列为什么要牺牲一个格子,以及判空 / 判满的三件套。
  • 看到关键词(匹配、撤销、排队、轮转)能立刻选对结构。
  • 括号匹配与约瑟夫环两道真题能独立 AC。
✅ 达标判定:给你一个空数组和两个指针,你能把"入 / 出"的每一步画出来,并且不越界。
本质

数据结构 = 存储方式 + 允许的操作

限制得越死,越不容易写错——栈和队列就是「被限制的数组」普通数组:哪儿都能读写↓↓↓↓↓读写自由 → 也最容易越界、最容易写乱栈 stack只能从顶端进出后进先出 LIFO队列 queue进出先进先出 FIFO代价:能做的事变少了。收益:不会写出越界,也不会写错顺序。

数组什么都能干(随机访问 O(1)),但自由也意味着容易写错。栈和队列主动把操作砍到只剩两三种,换来「不容易写错」。

📦 数组

任意位置读 / 写 / 插 / 删。
自由度最高,边界全靠自己。

🥞 栈 Stack

只能从一头进出。
后放进去的先拿出来 —— FILO。

🚶 队列 Queue

从一头进、另一头出。
先来的先走 —— FIFO。

🥞 生活原型:一摞盘子

洗好的盘子一个个往上摞,用的时候只能从最上面拿。最后摞上去的,第一个被拿走 —— 这就是栈。想拿最底下那个?必须先把上面的全拿掉。

🚶 生活原型:排队买票

先来的人站前面,先买到先走;新来的人只能站到队尾,不许插队 —— 这就是队列。处理顺序完全由"到达顺序"决定。

要"回头找最近的那一个"(配对、撤销、回溯、后缀表达式)→ 栈;
要"按顺序处理、讲先来后到"(排队、轮转、广度优先搜索)→ 队列。
3D🎲 栈 = 一摞盘子,只有顶上一个口🖱 拖拽旋转 · 双击复位
为什么用 3D:栈的本质是「只有一个口」。平面示意图画不出这个「口」,而能拖着转一圈看的立体盘堆,能让学生自己发现:除了顶上,哪儿都碰不到。
原理 · 第一段

栈:只有一个口的容器

FILO(First In Last Out,先入后出)。唯一能操作的那一端叫栈顶;封死的那一端叫栈底。

🥞 亲手摞一次:只能从上往下拿

盘架是空的。点「摞一个盘子」开始。

📖 术语表(背下来,读题才不卡)

栈顶 top唯一允许进出的一端,用指针 top 指向它。
栈底 bottom下标 0 那一端,封死不动。
入栈 push往栈顶放一个元素。
出栈 pop从栈顶拿走一个元素。
取顶 top()只看不拿,指针不动。
栈深 size当前元素个数 = top + 1(top 从 −1 起)。
⚠️ 为什么 top 从 −1 开始?因为"空"要表示成"没有任何下标"。top = -1 时 top+1 = 0 正好是个数,stk[top] 也不会被误读。
手写 · 第一段

栈的本质:一个数组 + 一个指针

不需要任何新语法。栈 = int stk[ ] + int top,"删除"只是让指针回退一格。

int stk[8]; int top = -1;
空栈。点「push(下一个)」开始。
对应的三条语句(当前执行的会高亮)
⚠️ 两个必炸的点
① pop() 前不判空 → top 变成 −2 甚至更负,下次 push 写进 stk[-1],数组越界。
② 数组开小了 → 一直 push 直到 top = N-1 还继续加,栈溢出。比赛里统一开大一点(如 1e5 + 5)。
STL · 第一段

会手写之后,用 STL 才不会踩空

#include <stack> stack<int> st; ——接口只有 5 个,但有两个坑年年有人踩。

🔧 接口速查

接口作用复杂度
st.push(x)入栈O(1)
st.pop()出栈 —— 不返回元素!O(1)
st.top()取栈顶(不删除)O(1)
st.size()元素个数O(1)
st.empty()是否为空O(1)
⚠️ 坑 1:pop() 返回 void。
想"取出并拿到值"必须两步:int x = st.top(); st.pop();
直接写 int x = st.pop(); 会编译报错。

🕳️ 另外两个高频坑

坑 2:空栈上调用 top() / pop() 是未定义行为。
STL 不会帮你报错,可能返回一个垃圾值或者直接崩。凡是取顶/出栈,先 if(!st.empty())。
坑 3:栈里没有"遍历"。
stack 故意不提供迭代器 —— 想看中间的就得一路 pop 出来再放回去。如果题目确实要遍历,说明你该用 vector 而不是 stack。
✅ 记忆口诀:先判空,再取顶,取完 top 再 pop。
比赛里 90% 用 STL 就行。但遇到这三类题建议手写数组栈:① 需要同时访问栈中间元素;② 需要自己控制容量/清空(STL 清空只能 while pop);③ 需要把 top 指针本身当状态用(如括号匹配里 top 就是"未配对的层数")。
原理 · 第二段

队列:一头进,另一头出

FIFO(First In First Out,先入先出)。进的一端叫队尾(tail / rear),出的一端叫队首(head / front)——两个指针,各管一头。

🚶 排队演示:不许插队,也不许从队尾溜走

队伍是空的。
✅ 注意:出来的一定是最早进去的那个。这就是"公平",也是 BFS 能保证按层扩散的根本原因。

📖 术语 & 与栈的差别

队首 front出的一端,head 指针指着。
队尾 rear进的一端,tail 指针指着(指向下一个空位)。
入队 push放到 tail,tail 后移。
出队 pop从 head 取走,head 后移。
元素个数tail - head(普通数组版)。
⚠️ 与栈最大的不同:栈只有一个指针(top 又管进又管出),队列必须两个。所有队列的坑,都出在这两个指针的相对位置上。
手写 · 第二段

朴素写法有个致命毛病:假溢出

入队 q[tail++] = x;,出队 head++;。看出问题了吗——head 只增不减,前面空出来的格子永远用不上了。

int q[8]; int head = 0, tail = 0;
空队列。点「入队(下一个)」开始。
⚠️ 假溢出(false overflow):一直"入一个、出一个",head 和 tail 一起往后爬,很快 tail 就顶到数组末尾(tail == 8)报"队满"——可数组前面明明空着一大片。
这不是数组开小了的问题,是指针不回头的问题。解决办法就是下一页的循环队列。
手写 · 第二段

让指针"转圈":取模回到开头

把数组首尾相接想成一个环,指针走到末尾就用 % M 绕回 0。这样 head 前面空出来的格子能被重新利用。

head0
tail0(永远指向下一个空位)
个数0
状态空
点「入队」开始。注意看 tail 那个虚线格子——它永远空着。
⚠️ 为什么要牺牲一个格子?
判空是 head == tail。如果允许把 M 个格子全填满,那么"满"也会变成 head == tail —— 空和满长得一模一样,无法区分。
所以我们规定:永远留一个格子空着,于是判满 = (tail + 1) % M == head。代价是容量只有 M − 1,换来的是判断唯一、绝不出错。
手写 · 第二段

判空、判满、求个数:三行公式

✍️ 完整模板(背这一段就够)

const int M = 100005;
int q[M], head = 0, tail = 0;

bool empty(){ return head == tail; }
bool full() { return (tail + 1) % M == head; }
int  size() { return (tail - head + M) % M; }

void push(int x){
  if(full()) return;          // 满了就别写,否则覆盖队首
  q[tail] = x;
  tail = (tail + 1) % M;     // ★ 取模回绕
}
void pop(){
  if(empty()) return;
  head = (head + 1) % M;     // ★ 取模回绕
}
int front(){ return q[head]; }

🧠 三个公式怎么来的

判空head 追上 tail → 一个元素都没有:head == tail。
判满tail 的再下一格就是 head → 再也放不进:(tail+1)%M == head。
求个数因为可能 tail < head(绕回来了),所以必须先 +M 再 %M。
⚠️ 最常写错的一行:(tail - head) % M。
C++ 里负数取模结果还是负的((-1) % 4 == -1),所以必须写成 (tail - head + M) % M。这是复赛里真会 WA 的坑。
✅ 另一种做法:不牺牲格子,额外用一个 cnt 变量记个数。判空 cnt==0、判满 cnt==M,就不会混淆了 —— 两种都可以,选一种练熟。
STL · 第二段

queue:接口和 stack 像,但两头都能看

🔧 接口速查

接口作用
q.push(x)入队(放到队尾)
q.pop()出队(删队首,同样不返回)
q.front()看队首(最早来的)
q.back()看队尾(最新来的)★ stack 没有这个
q.size() / q.empty()个数 / 判空
⚠️ 同样是先判空再 front/pop;同样是 pop() 不返回值,要 int x=q.front(); q.pop();。

🤔 那 STL 的 queue 会假溢出吗?

不会。 STL 的 queue 默认底层是 deque(双端队列),它内部就是分块的循环结构,会自动扩容,你不用管指针回绕。

✅ 所以实战建议:比赛直接用 queue;手写循环队列的价值在于——它让你真正理解 front/back/pop 背后的代价,以及为什么 deque 要那么设计。
⚠️ 唯一要小心的:queue 也不支持遍历,不能 for 一遍。要看所有元素?只能一路 pop 出来 —— 那时请改用 deque 或 vector。
对比 · 第三段

同样的输入,出来的顺序完全不同

把 1、2、3、4 依次放进去,再依次取出来 —— 这是区分两者最直观的实验。

🥞 栈:只有右边一个口

取出顺序—

🚶 队列:左出右进两个口

取出顺序—
点「放入下一枚」连续放 4 个,再点「取出一枚」看顺序差别。

结构

栈:一头开口
队列:两头开口

指针

栈:1 个(top)
队列:2 个(head/tail)

顺序

栈:FILO(逆序)
队列:FIFO(保序)

典型场景

栈:括号匹配、撤销、DFS
队列:排队模拟、BFS

例题 · 第四段

为什么括号匹配一定要用栈?

因为"后出现的左括号,要先被配对"——这正好是 FILO。最里面的那层括号,必须最先闭合。

① 表达式(当前字符高亮)

点「扫一个字符」开始。

② 栈里存着"还没配对的左括号"

栈深0
✅ 三条判定规则:① 遇到 ( → 入栈;② 遇到 ) → 若栈空则立刻判 NO,否则弹出一个;③ 扫完(遇到 @)→ 栈空才 YES,栈非空说明左括号多了。
例题 · 第四段

20 行:把三条规则直接翻译成代码

✍️ 标程(数组模拟栈)

char stk[300];          // 左括号 < 20,开 300 稳
int main(){
  string s; cin >> s;
  int top = -1;                 // 空栈
  for(int i=0;i<(int)s.size();i++){
    char c = s[i];
    if(c == '@') break;         // 结束符
    if(c == '('){
      stk[++top] = c;             // ① 入栈
    } else if(c == ')'){
      if(top < 0){                // ② 栈空 = 右括号多
        cout << "NO
"; return 0;
      }
      top--;                      // 弹出配对
    }
  }
  cout << (top < 0 ? "YES" : "NO") << '
';
  return 0;
}
✅ 这里根本不需要真的存括号:栈里全是 (,所以 top 本身就能当计数器,写成 int cnt 也完全正确。保留数组是为了让你看清栈的形状。

🕳️ 这题的三个失分点

① YES / NO 必须大写。写成 Yes 直接 0 分——题目怎么说就怎么输出。
② 忘记处理 @ 之后的字符。本题 @ 是最后一个,不影响;但养成习惯:遇到结束符就 break。
③ 只在最后判栈空,中间不判。字符串 ) ( 会在第一步就多出一个右括号 —— 必须边扫边判,否则会误判成 YES。
很多同学想:数一数左右括号个数一样不就行了?不行。)( 数量相等但顺序错。栈之所以必须,是因为它同时记住了数量和顺序——"最近的未配对左括号"就是栈顶。
例题 · 第四段

"围成一圈"翻译成队列:队首出、队尾进

报数没报到 m 的猴子出队再入队(等于走到队尾),报到 m 的出队不再回来(淘汰)。一圈就这么绕出来了。

🐒 围成一圈看(顶部 = 当前报数的猴子)

📥 队列的真实样子(左队首 → 右队尾)

当前报数1
已淘汰—
点「报一步」开始。
✅ 循环终止条件:while(q.size() > 1)。只剩 1 个时它就是大王,直接 cout << q.front()。
⚠️ 注意:报数计数器 k 在淘汰后要重置为 1,在"回到队尾"后要 k++ —— 这两个分支别写反。
例题 · 第四段

12 行:队列模拟约瑟夫环

✍️ 标程(STL queue)

#include <queue>
int n, m;
cin >> n >> m;
queue<int> q;
for(int i=1;i<=n;i++) q.push(i);  // 编号入队

int k = 1;                            // 当前报的数
while((int)q.size() > 1){
  int x = q.front(); q.pop();        // 队首出队
  if(k == m){                         // 报到 m:淘汰
    k = 1;                            // ★ 重新从 1 报
  } else {                            // 没报到:回队尾
    q.push(x);
    k++;                              // ★ 报数 +1
  }
}
cout << q.front() << '
';
✅ 复杂度 O(n·m):淘汰一只猴子平均要转 m 次。n≤2000、m≤1000 完全够用。

🤔 为什么这题用队列、不用栈?

因为规则是"按顺序轮转"——这一轮没轮到的,要排到后面去等着,而不是立刻回头处理它。这正是 FIFO。
如果用栈,被压下去的猴子会最先被弹出来,顺序就反了。

⚠️ 两个最高频的写错点
① 把 k = 1 写成 k = 0:报数是从 1 开始的,重置也必须是 1。
② 淘汰后忘了重置 k:会一路报下去,答案立刻错。
⚠️ 循环队列版(视频里强调):如果 n·m 很大,STL queue 也没问题;但用手写循环队列可以避免反复 push/pop 带来的开销,且容量固定 —— 原理完全一样,只是把 front/pop/push 换成你自己维护的 head/tail。
这题本质是约瑟夫环,有递推公式 f(1)=0; f(i)=(f(i-1)+m)%i,答案是 f(n)+1,复杂度 O(n)。但复赛建议先写队列模拟——它不容易写错,且 n 不大时一样过。递推解留作拓展。
拓展

两道挑战题:方向给你,代码自己写

🧮 拓展 1:后缀表达式求值

后缀表达式(逆波兰式)把运算符放在操作数后面,如 3 4 + 5 * = 35,不需要括号。

为什么用栈遇到数字就压进去;遇到运算符就把最近的两个数字弹出来算,结果再压回去。"最近的两个" = 栈顶两个。
✅ 步骤:① 数字 → push;② 运算符 → pop 出 b(先弹的是右操作数)、pop 出 a,算 a op b,push 回去;③ 最后栈里只剩一个数就是答案。
⚠️ 减法/除法顺序不能反:先弹出的是右操作数。

🔢 拓展 2:Blah 数集

数集从 a 开始,每次产生 2x+1 和 3x+1,要求从小到大输出第 n 项。

为什么用双队列把 2x+1 放一个队列 P、3x+1 放另一个队列 Q。两个队列各自都是递增的(因为 x 递增),所以每次只需比较两个队首取小的。
✅ 步骤:① 取 P、Q 队首较小者出队(相等则都出队,去重);② 用它生成两个新数分别入 P、Q;③ 重复 n 次。
⚠️ 关键性质:队列保序 —— 只要入队顺序是递增的,队首就永远是当前最小的。这正是 FIFO 的价值。
都不是"不会做",而是认不出该用哪个结构。判断口诀:
题干出现"最近的 / 上一个 / 嵌套 / 回溯" → 栈;出现"依次 / 轮流 / 排队 / 按顺序最小" → 队列。
拿分

一道题 100 分是分档给的——不会正解也要先拿保底

复赛一道题的 100 分由十几到二十几个测试点组成, 前面几档专门为「没想出正解的人」准备。四道题各拿 30~40 分, 加起来往往就是一等奖和二等奖的分界。

第 1 档 特判 5~10 分 约 2 分钟 第 2 档 暴力 20~30 分 约 8 分钟 第 3 档 特殊性质 40~60 分 约 15 分钟 第 4 档 正解 100 分 约 25 分钟

① 特判档:先别想算法,看数据范围表

题目给的数据范围表就是出题人给你的送分清单。看到「n = 1」「只有一组数据」这类一行, 就先写个 if 直接输出答案。2 分钟换 5~10 分,全场最划算。

② 暴力档:按题意最直白地写一遍

多重循环、DFS 全枚举、朴素 O(n³)……不要优化,只要保证最小那档全对。 它既是保底分,又是后面对拍的标尺——没有它你无法证明正解是对的。

③ 特殊性质档:题目里那句「若……」

常见的送分性质:数据已经有序、所有值完全相同、规模小到可以 O(n²)、 只出现一种类型。题目写出来就是让你拿的,专门写一份即可。

④ 正解档:思路定了,就赢了八成

考场上的时间几乎都花在想思路上,不是写代码。写完先跑样例, 再用第 ② 档的暴力版造小数据对拍——这是唯一能证明你思路对的办法。

考场纪律:每档设时间上限,到点还没调通就 立刻提交当前版本,保住已有分数,再往上冲。最常见的翻车是"正解写了 50 分钟没过,连暴力分都没交上去"。
拿分

本讲 2 道题,各自的四档怎么走

表达式括号匹配 · BRACKET

第1档 · 2 分钟
输入只有 @ → Yes

第2档 · 8 分钟
反复扫描删除相邻的 (),删不动了看是否为空

第3档 · 10 分钟
只有一种括号 → 可以不记类型,单纯计数(前缀非负且总数相等)

第4档 · 15 分钟
栈:遇 ( 入栈,遇 ) 看栈顶,最后栈空

猴子选大王 · MONKEY

第1档 · 2 分钟
n == 1 → 1

第2档 · 8 分钟
数组模拟,每次数 m 个删一个,n ≤ 10⁴ 稳过

第3档 · 10 分钟
m == 2 → 有 O(1) 公式 2·(n−2^⌊log₂n⌋)+1

第4档 · 15 分钟
队列模拟:没数到 m 的出队再入队,数到 m 的出局,直到剩一个

50 分钟时间盒(一题的节奏):
0–5 分钟 读题 + 圈出数据范围表里最小的那几档 → 5–15 分钟 写完第 1、2 档并先交一次(保住 20~30 分)→ 15–40 分钟 冲第 3、4 档 → 40–50 分钟 造小数据对拍、检查 freopen 与文件名。
课堂上别让学生"从头想正解"。先要求每个人 15 分钟内交出第 1、2 档, 再放开冲正解。这个顺序一换,平均分通常能涨一大截——因为它杜绝了"想了 40 分钟,交上去 0 分"。
小结

本讲知识树

受限线性表 栈 FILO 队列 FIFO 括号匹配 / 撤销 后缀表达式求值 猴子选大王 / 排队 BFS

① 循环队列数组大小 M=8,最多能存几个元素?

② 括号匹配中,字符串 ) ( 用"只统计左右括号个数是否相等"来判断,结果是?

③ 用 STL 容器时,"取出并获得值"的正确写法是?

目录