← Arietids Learn · 交互演示目录

数据结构 · 作业题方法复习资料

逐文件精读 ./Homework 中的 29 道 OJ 作业,按经典章节归纳每题的数据结构 → 方法 → 图解 → 复杂度 / 易错点,并提炼可跨题复用的通用套路。每张题卡都配有针对该题机制的可视化图解。

29道题目
5大章节
25核心方法
11通用技巧
2026-06-15整理日期
🧭

知识图谱 · 方法地图

数据结构作业·方法地图 —— 点击任一题号可直接跳到对应题卡。

🔗

线性表与链表

3 题

本章覆盖单链表(带头结点/不带头结点)与顺序表两类基础线性结构。链表侧重指针操作:头插法建表、就地逆置、链表模拟竖式加法;顺序表侧重利用有序性做双指针归并。核心是理解存储方向与指针改写顺序。

核心方法思路识别信号 / 何时用题号
头插法建表与链表模拟竖式加法读入每位都用头插法接到head前,使输入的高位在前自动转为个位在前的存储顺序;相加时从两表表头(个位)开始对齐,当前位和=两数对应位+进位carry,本位=sum%10,进位=sum/10,结果同样头插。题目给两个大整数/多项式按位拆成数字序列,要求逐位相加且数值超出整型范围;或需要把输入的高位优先序列翻转为低位优先时1393
头插法就地逆序(链表逆置)不另开新链,固定原首结点begin为逆序后尾结点,不断把begin后面的结点摘下用头插法挂到头结点之后,后摘下的排在越前面从而整体翻转;只改常数个指针,逆序后须把tail更新为begin。要求O(1)额外空间把单链表整体翻转输出,或链表内部需要倒序而不允许申请新结点时3029
有序顺序表双指针归并去重利用A、B已非递减有序,用双指针i、j同时扫描:相等(交集)两边都成段跳过不入结果,A[i]较小则只属A入结果并成段跳过相同值,A[i]较大对称处理B;末尾剩余段各自去重后加入。两个已排序表求交集/并集/差集/对称差集,且需要去重、要求线性时间不重新排序或查找时3032
1393

两个链表表示的大整数相加

基础
链表模拟竖式加法(逐位进位)单链表逆序相加(高精度大整数加法)
单链表结构体节点链表封装类
核心思想

两个大整数太大无法用普通整型存储,于是把每一位拆成数字存进链表。读入时用头插法逐位插入,输入虽按高位到低位读,链表却变成"个位在前"的顺序;这样从两表头同时往后走就天然对齐数位,模拟手算竖式:本位和 = 两数对应位 + 进位,本位取 sum%10,进位取 sum/10,结果再头插写入新链表,输出即与输入对齐。

关键代码
while (n1 || n2 || carry) {
    int sum = carry;
    if (n1) { sum += n1->val; n1 = n1->next; }
    if (n2) { sum += n2->val; n2 = n2->next; }
    carry = sum / 10;
    dgi* p = new dgi(sum % 10);
    result->addNode(p);   // 头插,结果低位在前
}
图解
建表输入 999(高→低读),头插后链表为个位在前:
9
→
9
→
9
→
∅
n1 表头=个位
建表输入 100(高→低读),头插后:代表 1->0->0 即个位 0
0
→
0
→
1
→
∅
n2 表头=个位
第1位9+0+carry0=9?此例 999+1 见演进;按 example 999+100 演示进位:9+0=9,无进位
9
+
0
+
c0
=
9
本位9 carry0
第2位9+0+0=9,本位9 进位0
9
→
9
+
0
=
9
结果头插 9→9
第3位9+1+0=10,本位0 进位1
9
→
9
→
9
+
1
=
10
本位0 carry1
收尾n1、n2 都空但 carry=1:sum=1,本位1,正是 while 条件含 carry 的作用(避免丢最高位)
∅
+
∅
+
1
=
1
carry 进位成新最高位
结果结果同样头插(低位在前),从表头输出得 1099 → 即 999+100=1099
9
→
9
→
0
→
1
→
∅
表头=个位9,输出 1 0 9 9
复杂度与易错
  • 时间 O(max(m, n)),m、n 为两数位数
  • 空间 O(max(m, n)),新建结果链表所需空间
  • 循环条件必须包含 carry,否则 999+1=1000 这类最高位进位会丢失
  • 进位与本位顺序不能写反:carry=sum/10、本位=sum%10
  • 头插法使链表低位在前,必须理解存储方向,否则会误以为相加错位
  • 两链表长度不同时,短链遍历完后指针为空,需用 if(n1)/if(n2) 跳过,禁止解引用空指针
  • new 出来的结果节点需交由 intList 析构统一 delete,防止内存泄漏
复用技巧
头插法建表使输入高位在前自动转为个位在前while 用 n1||n2||carry 三条件统一处理长度不等与末位进位结果同样头插,输出顺序自然与输入对齐ios::sync_with_stdio(false)+cin.tie(nullptr) 加速读入析构函数遍历释放链表防止内存泄漏friend 友元函数访问私有 head
3029

单链表就地逆序(链表逆置)

基础
头插法就地逆序(不申请新结点,仅改指针)带头结点单链表的头插法就地逆序
带头结点的单链表尾指针tail
核心思想

读入 n 个整数用「带头结点 + 尾指针 tail」建表,每次尾插都是 O(1),保持输入顺序。逆序时不另开新链:把原首结点 begin 固定为逆序后的尾结点,不断把 begin 后面的结点摘下、用头插法挂到头结点之后,后摘下的排得越靠前,整体顺序就被翻转。每步只动常数个指针,故为就地 O(n),最后别忘了把 tail 更新为 begin。

关键代码
void revList() {
    intNode* begin = head->next;
    if (begin == nullptr) return;   // 空表直接返回
    intNode* end = begin->next;
    while (end) {                   // 把end头插到表头之后
        begin->next = end->next;    // 从原位置摘下end
        end->next = head->next;     // end指向当前首结点
        head->next = end;           // end成为新的首结点
        end = begin->next;          // 取begin后的下一个待处理结点
    }
    tail = begin;                   // 原首结点现在是尾结点
}
图解
建表
head
→
1
→
2
→
3
→
∅
输入 3 / 1 2 3,begin 指向 1(红框,固定为未来尾结点),end=begin->next 指向 2
第1步
head
→
2
→
1
→
3
→
∅
摘下 2 头插到 head 之后:begin->next=3, end->next=1, head->next=2;新 end=begin->next=3
第2步
head
→
3
→
2
→
1
→
∅
摘下 3 头插到 head 之后:链变为 head->3->2->1;新 end=begin->next=∅,循环结束
收尾
head
→
3
→
2
→
1
tail→
∅
begin(结点1)成为真正的尾结点,更新 tail=begin;printList 从 head->next 输出「3 2 1」
复杂度与易错
  • 时间 O(n),每个结点恰好被摘下并头插一次
  • 空间 O(1),只改指针不新建结点
  • 逆序后尾结点变为原首结点 begin,忘记更新 tail 会导致后续尾插出错
  • 空链表 (begin==nullptr) 需提前 return,否则解引用空指针
  • 头插三句赋值顺序不能乱:先 begin->next=end->next 摘除,再 end->next=head->next 接表头,最后 head->next=end
  • begin 始终指向原首结点不变,循环靠 end=begin->next 推进;打印时用 first 标志控制空格,避免行末多余空格
复用技巧
哨兵头结点简化插入与边界尾指针实现O(1)尾插建表头插法实现链表就地逆置只改指针不新建结点节省空间逆序后及时修正tail指针
3032

两个有序顺序表的对称差集

基础
双指针归并扫描 + 同步去重有序顺序表的双指针归并求对称差集(并去重)
顺序表(一维数组)
核心思想

对称差集是只在 A 或只在 B、但不同时出现的元素。两表都已按非递减排好,用双指针 i、j 同时从前往后扫描比较 A[i] 与 B[j]:相等是交集,两边等于该值的重复项一并跳过且不入结果;A[i] 小则只在 A,入结果并跳过 A 中相同值;A[i] 大则对称处理 B。某表扫完后,另一表的剩余元素只属于自己,去重后直接加入。一遍线性扫描即可,无需再排序或查找。

关键代码
while (i < n && j < m) {
    if (A[i] == B[j]) {              // 交集,两边都跳过,不入结果
        int x = A[i];
        while (i < n && A[i] == x) i++;
        while (j < m && B[j] == x) j++;
    } else if (A[i] < B[j]) {        // 只在A中
        int x = A[i]; C[cSize++] = x;
        while (i < n && A[i] == x) i++;
    } else {                         // 只在B中
        int x = B[j]; C[cSize++] = x;
        while (j < m && B[j] == x) j++;
    }
}
// 余下:尾部剩余段各自去重后入C
while (i < n) { int x=A[i]; C[cSize++]=x; while(i<n && A[i]==x) i++; }
while (j < m) { int x=B[j]; C[cSize++]=x; while(j<m && B[j]==x) j++; }
图解
A
1
2
2
4
B
2
3
4
例:A=[1,2,2,4],B=[2,3,4],双指针 i、j 比较 A[i] 与 B[j]
第1步
1
2
2
4
↔
2
3
4
A[0]=1 < B[0]=2 → 1 只在 A,入 C,跳过 A 中的 1,i=1。 C=[1]
第2步
1
2
2
4
↔
2
3
4
A[1]=2 == B[0]=2 → 交集!成段跳过 A 中所有 2(i→3)与 B 中所有 2(j→1),不入 C
第3步
1
2
2
4
↔
2
3
4
A[3]=4 > B[1]=3 → 3 只在 B,入 C,跳过 B 中的 3,j=2。 C=[1,3]
第4步
1
2
2
4
↔
2
3
4
A[3]=4 == B[2]=4 → 交集!跳过 A 的 4(i=4)、B 的 4(j=3),不入 C
尾部
∅
|
∅
主循环结束(i=4=n, j=3=m),两个尾部 while 均无剩余。最终 C=[1, 3],输出 1 3
复杂度与易错
  • 时间 O(n + m),一遍线性扫描
  • 空间 O(n + m),结果数组 C(不计输入)
  • 相等时必须同时跳过 A 和 B 中所有等于该值的重复项,否则同一值会被误当成单边元素加入结果
  • 去重的内层 while 要先判越界再比较(i<n && A[i]==x),顺序写反会越界
  • 输入本身可能含重复元素,单纯逐个比较会漏判,必须成段跳过
  • 尾部剩余的两个 while 只会执行其中一个,但仍各自需要去重
  • 结果为空要输出 Empty 而不是空行;元素间用 k>0 控制空格分隔
复用技巧
双指针归并利用有序性单向跳过相同值实现去重扫描结束后处理尾部剩余段空结果特判输出Empty用k>0控制元素间空格分隔
📚

栈与队列

7 题

本章是单调栈/栈模拟的重灾区,覆盖单调栈求下一个更大元素、栈消除相邻重复、栈模拟碰撞、出栈序列合法性判定、双栈中缀转后缀求值;队列侧重环形缓冲区限流与把队列模拟转化为数学计数。核心是抓住栈/队列的后进先出与滑动窗口语义。

核心方法思路识别信号 / 何时用题号
单调栈求右侧第一个更大元素从右往左扫描,栈中维护当前位置右侧按栈顶到栈底高度严格递增的候选答案下标;处理i时弹出栈顶所有不高于h[i]的元素(用<=保证严格更大语义),弹完后栈顶即答案、栈空则为0,再把i压栈供更左位置使用。每元素进出栈各一次为O(n)。为每个位置求右侧(或左侧)第一个严格大于/小于它的元素或下标,朴素O(n^2)会超时时2901
栈模拟相向碰撞(小行星碰撞)正向右负向左,只有左正右负才相撞。从左扫描用栈保存存活元素,正数直接入栈;负数用while不断与栈顶比较:栈顶小则弹出继续向左撞、相等则两败俱伤(弹栈顶且当前不入)、栈顶大则当前被毁停止;碰撞仅在栈顶>0且cur<0时发生。一排同速元素相向移动、按规则发生碰撞抵消,需求最终存活序列时3063
栈消除相邻重复项栈中保存已处理前缀经过所有删除后的结果,新字符只可能与栈顶配对:栈顶等于当前字符则弹栈且当前不入(一对一起删),否则入栈;把多轮局部删除压缩成一次线性扫描。反复删除相邻相同/可抵消的元素直到不能再删,删除会引发连锁反应时3062
入栈贪心模拟判出栈序列合法性依次扫描目标出栈序列每个need,只要栈顶不等于need且入栈序列还有剩余就不断按序压栈,直到栈顶等于need则弹出;压完仍凑不出则非法。出栈序列唯一决定每步必做动作,故贪心模拟是充要判定。给定入栈顺序判断某个序列能否作为合法出栈序列时3035
调度场算法(双栈中缀转后缀)求值两步法:先用运算符栈把中缀转后缀(数字直接输出、左括号入栈、右括号弹到左括号、运算符弹出栈顶所有优先级不低于自己的),再用操作数栈线性求值。shouldPop在优先级相等时靠cur!='^'区分左结合(弹)与右结合(不弹),幂运算用快速幂。求含加减乘除、括号、乘方(右结合)的中缀表达式值,需要正确处理优先级与结合性时3042
环形缓冲区实现滑动窗口限流固定大小为10的环形数组q记录窗口内每槽是否放行,qps维护已放行总数。处理第i个请求时pos=i%10正好是10条前那条记录,先把它从qps减掉并清零(滑出窗口),再判断当前请求:非目标输出0、目标且qps<limit则放行置1且qps++、目标但达上限输出-1。增量维护避免重复遍历窗口。在最近固定K个事件的滑动窗口内对某类事件计数限流,要求O(1)单步处理时3033
队列问题转化为分位置数学计数(免模拟)抓住临界事件(第k人买完最后一张)切分人群:i<=k的人最多买target张,i>k的人在最后决定性一轮还没轮到就结束故最多买target-1张,再与各自票数取min累加。把循环队列模拟降到O(n)无需真实维护队列。循环队列轮转模拟题,可找到一个临界终止事件用数学公式直接算出结果时3058
2901

寻找右侧第一个更高的元素(下一个更大元素的下标)

进阶
单调栈(从右向左扫描)单调栈求右侧第一个严格更大元素的下标
顺序栈(数组模拟)顺序表
核心思想

为每个位置 i 求右边第一个严格大于 h[i] 的元素下标(1 基,没有则为 0)。朴素逐个向右扫是 O(n²) 会超时。用单调栈从右往左扫描:栈里存当前位置右侧、从栈顶到栈底高度严格递增的候选下标。处理 i 时先弹掉栈顶所有高度 ≤ h[i] 的元素(它们既不更高又被 h[i] 挡住,对更左的位置也没用),弹完后栈顶即答案,再把 i 压栈供更左侧使用。每个下标至多进出栈各一次,故为线性。

关键代码
int top = 0;
for (int i = n; i >= 1; i--) {
    while (top > 0 && h[st[top]] <= h[i]) top--; // 弹掉不高于 h[i] 的候选
    ans[i] = (top == 0) ? 0 : st[top];           // 栈顶即右侧第一个更高者下标
    st[++top] = i;                               // 当前下标入栈
}
图解
2
5
3
4
1
示例 h[1..5] = 2 5 3 4 1(下标 1 基),目标答案 = 2 0 4 0 0
第1步 i=5
1i=5
→
栈空
栈空 ⇒ ans[5]=0;压入 5。栈(顶→底):[5]
第2步 i=4
4i=4
→
5:h=1
h[5]=1 ≤ 4,弹出 5 ⇒ 栈空 ⇒ ans[4]=0;压入 4。栈:[4]
第3步 i=3
3i=3
→
4:h=4
h[4]=4 > 3,不弹 ⇒ ans[3]=4;压入 3。栈:[3,4]
第4步 i=2
5i=2
→
3:h=3
4:h=4
h[3]=3 ≤ 5 弹 3,h[4]=4 ≤ 5 弹 4 ⇒ 栈空 ⇒ ans[2]=0;压入 2。栈:[2]
第5步 i=1
2i=1
→
2:h=5
h[2]=5 > 2,不弹 ⇒ ans[1]=2;压入 1。栈:[1,2]
ans[1]=2
ans[2]=0
ans[3]=4
ans[4]=0
ans[5]=0
最终输出:2 0 4 0 0(0 表示右侧不存在更高元素)
复杂度与易错
  • 时间 O(n):每个下标至多进栈出栈各一次,均摊线性
  • 空间 O(n):单调栈 st 与答案数组 ans 各占 O(n)
  • 弹栈条件用 h[st[top]] <= h[i](含等号)才能保证答案是“严格更大”;若要“大于等于”须改为 <
  • 下标从 1 开始,ans=0 专表示“右侧不存在更高元素”,真实下标不会为 0
  • 数组要开 MAXN=100005 容纳 n 个元素加哨兵余量,越界会出错
  • 输出用空格分隔且行尾不留多余空格,靠 i>1 判断控制分隔符
  • 从左向右也能做,但栈里存的是“尚未找到答案”的下标,逻辑相反易写错;从右向左更直观
复用技巧
单调栈数组模拟栈(st 数组 + top 指针)从右向左扫描以便栈顶直接给出答案用 <= 弹出实现“严格更大”的语义均摊分析(每个元素进出栈各一次)
3033

滑动窗口限流(QPS 限流器)

进阶
固定大小为10的滑动窗口 + 计数器 qps环形缓冲区实现滑动窗口限流
环形缓冲区(定长数组模拟循环队列)定长数组 q[10]
核心思想

在最近 10 个请求构成的滑动窗口内,对目标 ID 的"被放行"请求做限流。套餐 a/b/c 决定放行上限 limit=3/6/9。用长度 10 的环形数组 q 记录每个槽是否放行了一个目标请求,用计数器 qps 维护窗口内已放行总数。处理第 i 个请求时,先把槽位 pos=i%10(正好是 10 条之前那条记录)从窗口中移除(qps 减旧值并清零),让最早请求滑出,再判断当前请求,从而用 O(1) 空间、O(1) 单步实现定长窗口统计。

关键代码
int pos = i % 10;          // 该槽位存的是10条之前那个请求
qps -= q[pos];             // 让最早的请求滑出窗口
q[pos] = 0;
if (id != target)      res = 0;          // 非目标,不计数
else if (qps < limit) { res = 1; q[pos] = 1; qps++; }  // 放行
else                   res = -1;         // 达上限,限流
图解
第1步
q[0]=1
→
q[1]
→
q[2]
→
…
→
q[9]
i=0 pos=0:id=5=target 且 qps(0)<3 → 放行 res=1,q[0]=1,qps=1
第2步
q[0]=1
→
q[1]=1
→
q[2]
→
…
→
q[9]
i=1 pos=1:qps(1)<3 → 放行 res=1,qps=2
第3步
q[0]=1
→
q[1]=1
→
q[2]=1
→
…
→
q[9]
i=2 pos=2:qps(2)<3 → 放行 res=1,qps=3(窗口已满额)
第4步
q[0]=1
→
q[1]=1
→
q[2]=1
→
q[3]=0
→
…
i=3 pos=3:qps(3) 不小于 3 → 限流 res=-1,qps 仍=3,q[3] 不置位
第5步
q[0]=1
→
q[1]=1
→
q[2]=1
→
q[3]=0
→
q[4]=0
→
…
i=4 pos=4:10 条内已放行满 3 个 → 限流 res=-1。输出:1 1 1 -1 -1
滑出
旧 q[pos]
→
先 qps-=q[pos]; q[pos]=0
→
再处理当前请求
环形复用:pos=i%10 处存的正是"10 条之前"那条记录,必须先减旧值滑出、再判断当前,顺序不可颠倒
复杂度与易错
  • 时间 O(n),每个请求 O(1) 处理
  • 空间 O(1)(固定长度 10 的数组,与 n 无关)
  • 窗口大小固定为 10 要与数组长度 q[10] 一致,pos=i%10 不能写错模数
  • 必须先把滑出窗口的旧槽位从 qps 减掉并清零,再处理当前请求,顺序不能颠倒
  • 非目标 id 输出 0 且不占名额;被限流的目标输出 -1 也不占名额;只有放行才置 q[pos]=1 并 qps++
  • limit 是"窗口内放行上限"而非请求总数限制,判断用 qps<limit(严格小于)
  • 输出用空格分隔,第一项前不加空格(i>0 才输出空格)
复用技巧
数组模拟循环队列(i%窗口大小作为槽位)增量维护窗口计数(出窗减、入窗加)避免重复遍历用计数器代替对窗口逐个求和字符到数值的分支映射
3035

判断合法的出栈序列

基础
入栈贪心模拟 + 栈判定栈的出栈序列合法性判定
顺序栈(数组模拟)
核心思想

给定入栈顺序 inSeq 和目标出栈顺序,贪心模拟:依次取出目标里要弹出的 need,只要栈顶不是 need 且入栈序列还有剩余,就不断按序压栈,直到栈顶恰好等于 need 时弹出。若把所有元素都压完仍凑不出 need,则该出栈序列非法。因为出栈序列唯一决定每一步动作,没有别的选择,所以贪心模拟是充要判定。

关键代码
int top = 0, nextIn = 1; bool ok = true;
for (int i = 1; i <= n; i++) {
    int need; cin >> need;
    if (ok) {
        // 栈顶不是要的数就继续按序压栈
        while ((top == 0 || st[top] != need) && nextIn <= n)
            st[++top] = inSeq[nextIn++];
        if (top > 0 && st[top] == need) top--;   // 匹配则弹出
        else ok = false;                          // 凑不出则非法
    }
}
cout << (ok ? "YES" : "NO") << "\n";
图解
输入入栈序列 inSeq =
1
→
2
→
3
;目标出栈序列 =
3
1
2
第1步need=3:栈空,按序压入 1、2、3,栈顶=3 匹配 → 弹出
压栈过程(栈底→栈顶):
1
→
2
→
3
top栈顶=3=need,pop
弹出后栈:
1
→
2
topnextIn 已越过 3(入栈耗尽)
第2步need=1:栈顶=2≠1,且 nextIn>n 无法再压栈
while 退出后检查栈:
1
→
2
topst[top]=2≠1 → 置 ok=false
第3步need=2:ok 已为 false,仅读入不判断
最终结果:
NO
出栈序列 [3,1,2] 非法
复杂度与易错
  • 时间 O(n),每个元素至多入栈一次、出栈一次
  • 空间 O(n),存储入栈序列与模拟栈
  • while 条件需同时判断 top==0(空栈)与 nextIn<=n(入栈耗尽),否则会访问 st[0] 或越界
  • 循环结束后必须再判一次 st[top]==need,因为可能是入栈耗尽而非匹配成功才退出
  • 即使已判定 ok=false,仍要继续 cin 读完剩余目标序列,避免输入流错位(用 if(ok) 包裹判断但循环照常读取)
  • 出栈序列长度必须与入栈序列长度同为 n
复用技巧
数组模拟栈(top 指针)贪心模拟:用 while 一次性补足入栈用 nextIn 游标顺序消费入栈序列ok 标志位短路后续判断下标从 1 开始便于 top==0 判空
3042

表达式求值(中缀转后缀并计算)

综合
调度场算法(中缀转后缀)+ 后缀表达式求值双栈实现中缀转后缀(调度场算法)求值,含幂运算与右结合
顺序栈stack<char>(运算符栈)顺序栈stack<long long>(操作数栈)vector<Token>(后缀表达式序列)
核心思想

要计算含加减乘除、乘方和括号的整数中缀表达式,直接处理优先级和括号很麻烦。采用经典两步法:先用调度场算法把中缀式转成无括号的后缀式(逆波兰),再用一个操作数栈线性扫描求值。转换时维护一个运算符栈,遇运算符就先把栈顶优先级不低于它的算符弹出输出;关键在用 shouldPop 在优先级相等时靠 cur!='^' 区分,让加减乘除左结合、乘方右结合(2^3^2 按 2^(3^2) 算)。

关键代码
bool shouldPop(char top, char cur) {
    if (top == '(') return false;           // 左括号在栈内不被普通算符弹出
    if (priority(top) > priority(cur)) return true;
    // 优先级相等时:仅当当前不是 '^' 才弹(左结合弹、右结合不弹)
    return priority(top) == priority(cur) && cur != '^';
}
// 转后缀:遇运算符时
while (!ops.empty() && shouldPop(ops.top(), s[i])) {
    postfix.push_back({false, 0, ops.top()}); ops.pop();
}
ops.push(s[i]);
// 求后缀:b 是右操作数,a 是左操作数
long long b = nums.top(); nums.pop();
long long a = nums.top(); nums.pop();
nums.push(applyOp(a, b, token.op));
图解
第1步
2
→
*
→
(
→
3
→
+
→
4
→
)
输入中缀串 2*(3+4),从左到右逐字符扫描
第2步后缀
2
|算符栈
*
(
'2'→数字直接输出;'*'入空栈;'('左括号入栈(优先级0,但不被普通算符弹出)
第3步后缀
2
3
4
|算符栈
*
(
+
'3'输出;'+'遇栈顶'(',shouldPop 对 '(' 返回 false,直接入栈;'4'输出
第4步后缀
2
3
4
+
|算符栈
*
遇 ')':弹栈输出直到 '(',弹出 '+' 入后缀,再丢弃配对的 '('
第5步后缀
2
3
4
+
*
|算符栈
∅
扫描结束,清空算符栈弹出 '*',得后缀式 2 3 4 + *
求值
2
3
4
→
+
线性扫后缀:数字入操作数栈;遇 '+' 弹出 b=4、a=3,算 a+b=7 入栈(先弹的是右操作数)
求值
2
7
→
*
⇒
14
遇 '*' 弹出 b=7、a=2,算 2*7=14 入栈;栈中剩唯一值即结果 14
右结合
2
^
3
^
2
⇒
2^(3^2)=512
幂运算右结合:cur=='^' 时 shouldPop 返回 false,后来的 '^' 不弹先来的 '^',故 2^3^2 按 2^(3^2) 算
复杂度与易错
  • 时间 O(n),n 为表达式长度;每个字符与每个算符各进出栈一次,幂运算单次为 O(log exp)
  • 空间 O(n),后缀序列与两个栈的空间
  • 幂运算右结合,必须在优先级相等时不弹栈(cur=='^' 返回 false),否则 2^3^2 会算成 (2^3)^2=64 而非 512
  • 后缀求值时先弹出的是右操作数 b、后弹出的是左操作数 a,顺序写反会让减法和除法出错
  • 左括号优先级最低(priority 返回 0),但遇运算符时不能把 '(' 弹出,shouldPop 里需对 top=='(' 特判返回 false
  • 遇到 ')' 时要把配对的 '(' 弹出丢弃,不能留在栈里
  • 用 long long 防溢出,但未做除零/负指数等非法输入校验,假设输入合法
复用技巧
调度场算法(双栈中缀转后缀)shouldPop统一处理优先级+结合性,相等优先级靠cur!='^'区分左右结合后缀表达式线性求值用操作数栈快速幂(平方求幂)数字按位累加value=value*10+(c-'0')解析多位整数ios::sync_with_stdio(false)加速输入
3058

买票需要的时间

进阶
分位置计数求和(O(n)直接计算,免队列模拟)队列问题的数学计数优化(免模拟)
顺序表(固定数组)
核心思想

表面是循环队列模拟(每秒队首买一张,没买完回队尾,直到第 k 人买完)。但总耗时恰等于"第 k 人买完最后一张前所有人累计买到的票数之和"。设 target=tickets[k]:排在 k 前面或本人(i≤k)每轮都先于他买票,最多买 target 张;排在后面的人(i>k)在决定性的最后一轮还没轮到就结束,最多买 target-1 张。各人再与自身票数取 min 后累加,O(n) 直接算出,无需真实维护队列。

关键代码
int target = tickets[k];
int answer = 0;
for (int i = 0; i < n; ++i) {
    if (i <= k)               // 在 k 前面或就是 k,最多买 target 张
        answer += smaller(tickets[i], target);
    else                      // 在 k 后面,最后一轮没轮到,最多买 target-1 张
        answer += smaller(tickets[i], target - 1);
}
return answer;
图解
例子
n=4, k=0, tickets=[5,1,1,1],目标人是下标 0,target=tickets[0]=5
i=0
5
i=1
1
i=2
1
i=3
1
高亮格为关键人 k=0;上限分界:i≤k 用 target=5,i>k 用 target-1=4
分界
k 及之前
上限 5
→
k 之后
上限 4
以"k 买完最后一张"这一临界事件切分前后人群,上限相差 1
第1步
i=0 (i≤k)
→
min(5,5)=5
第2步
i=1 (i>k)
→
min(1,4)=1
第3步
i=2 (i>k)
→
min(1,4)=1
第4步
i=3 (i>k)
→
min(1,4)=1
汇总
5
+
1
+
1
+
1
=
8
逐人取 min 后累加:5+1+1+1 = 8,即为总耗时(输出 8)
复杂度与易错
  • 时间 O(n)(单趟扫描,每人 O(1) 计数)
  • 空间 O(n)(仅存票数数组;计数逻辑本身 O(1))
  • i>k 的人上限是 target-1 而非 target——最后一轮还没轮到流程就结束,这是最易错处。
  • i==k 归入 i≤k 分支,上限是 target(本人正好买满)。
  • i≤k 与 i>k 的分界是初始下标的相对位置,别和动态队列里的实时位置混淆。
  • 仍要与 tickets[i] 取 min,否则某人票数本就少于上限时会高估。
复用技巧
把队列模拟转化为分位置数学计数(免模拟)以临界事件(k买完最后一张)切分前后人群min裁剪取实际可买票数手写min仅依赖iostream用固定数组替代vector
3062

删除字符串中的所有相邻重复项

基础
手写顺序栈一次扫描消除相邻重复用栈消除相邻重复项
顺序栈char数组
核心思想

反复删除相邻相同字符直到不能再删,会引发连锁删除。用栈一次扫描即可:栈里始终保存"前缀消除后的结果",新字符只可能与栈顶配对。遍历每个字符 ch,若栈非空且栈顶等于 ch,就弹出栈顶且 ch 也不入栈(成对删掉);否则把 ch 入栈。最后从栈底到栈顶输出。

关键代码
for (int i = 0; s[i] != '\0'; ++i) {
    char ch = s[i];
    if (!result.empty() && result.top() == ch) {
        // 栈顶与当前字符相邻相同,成对删除
        result.pop();        // 删栈顶,ch 也不入栈
    } else {
        result.push(ch);     // 不能抵消,暂时保留
    }
}
// 从栈底到栈顶输出即为最终字符串
图解
第1步
a
→
b
→
b
→
a
→
c
→
a
输入串 abbaca,逐个字符扫描,只看栈顶决定入栈或成对弹出
第1步
读 a
→
a
栈顶
栈空,a 入栈 → 栈[a]
第2步
读 b
→
a
→
b
栈顶
栈顶 a ≠ b,b 入栈 → 栈[a,b]
第3步
读 b
→
a
→
b̶
栈顶 b == b,成对删除:弹出栈顶 b 且本字符不入栈 → 栈[a]
第4步
读 a
→
a̶
栈顶 a == a,成对删除 → 栈[](空,连锁删除发生)
第5步
读 c
→
c
栈顶
栈空,c 入栈 → 栈[c]
第6步
读 a
→
c
→
a
栈顶
栈顶 c ≠ a,a 入栈 → 栈[c,a]
输出
c
→
a
从栈底到栈顶输出(不可按出栈顺序,否则反序)→ 结果 ca
复杂度与易错
  • 时间 O(n),每个字符至多入栈一次、出栈一次
  • 空间 O(n),栈最多存放全部字符
  • 访问栈顶前必须先确认栈非空,否则空栈 top 会越界读到非法位置
  • 配对时是弹出栈顶且当前字符不入栈(一次删两个),别只删一个
  • 最终结果可能为空串,需正常输出(空串直接输出换行)
  • 输出顺序必须从栈底到栈顶,按出栈顺序会得到反序结果
  • char 数组要开到 MAX_LEN+5 留余量,cin>> 读入的串末尾自带 '\0'
复用技巧
用栈把多轮局部删除压缩成一次线性扫描只需比较栈顶即可判断相邻重复手写顺序栈用topIndex==-1表示空栈,入栈先++再写用char数组配合\0结尾读取字符串,避免依赖string/strlen出栈无需返回值时只做--topIndex
3063

深渊长廊的魔像对决

进阶
单调栈/栈模拟碰撞栈模拟相向碰撞(小行星碰撞)
顺序栈
核心思想

一排同速移动的魔像,正数向右、负数向左,只有"左正右负"才会相撞。从左到右扫描,用栈保存当前可能存活的魔像,栈底到栈顶恰好对应长廊从左到右的顺序。正数直接入栈;遇到负数时用 while 不断与栈顶最近的右行魔像比战斗力:栈顶小则弹出继续向左撞,相等则同归于尽,栈顶大则当前被粉碎。循环结束后若当前仍存活就入栈,最终栈中元素即为答案。

关键代码
bool alive = true;
while (alive && !survivors.empty() && survivors.top() > 0 && cur < 0) {
    long long topPower = absoluteValue(survivors.top());
    long long curPower = absoluteValue(cur);
    if (topPower < curPower)        survivors.pop();          // 栈顶被粉碎,继续向左
    else if (topPower == curPower){ survivors.pop(); alive=false; } // 同归于尽
    else                            alive = false;            // 当前被粉碎
}
if (alive) survivors.push(cur);     // 幸存则入栈
图解
第1步
1
读入 1(正),直接入栈 → 栈底=1
第2步
1
→
2
读入 2(正),直接入栈(栈底到栈顶即左到右)
第3步
1
→
2
→
3
读入 3(正),直接入栈 → 栈=[1,2,3]
第4步
1
→
2
→
3
栈顶3>0←
-10
读入 -10(负):触发相向碰撞,cur 自右向左连撞栈顶
碰撞
1
→
2
→
∅
|3|<|-10| 弹3
碰撞
1
→
∅
|2|<|-10| 弹2
碰撞
∅
|1|<|-10| 弹1,栈空循环结束,alive 仍为真
第5步
-10
幸存的 -10 入栈 → 最终栈=[-10],输出 -10
复杂度与易错
  • 时间 O(n),每个魔像最多入栈一次、出栈一次
  • 空间 O(n),栈最多存放 n 个魔像
  • 碰撞判定必须同时满足"栈非空、栈顶为正、当前为负"三条,缺一不可
  • 战斗力相等是同归于尽:既要弹栈顶又要置 alive=false 让当前不入栈,两边都不能漏
  • 栈顶小被粉碎后当前魔像要继续向左比较下一个栈顶,不能只比一次(用 while 而非 if)
  • 正数无论如何直接入栈,不要误判它会和左边碰撞
  • 栈空时输出 Empty,不要漏掉空结果的处理;战斗力较大时用 long long 更稳妥
复用技巧
栈底到栈顶天然保持左到右顺序,避免额外排序或反转用 while 循环处理一个负数引发的连续碰撞碰撞只发生在 栈顶>0 且 cur<0 这一唯一相向条件用 alive 标志区分当前魔像是被撞毁还是幸存入栈手写顺序栈与绝对值,体现栈基本操作
🌳

树与二叉树

10 题

本章是本次作业最大章节,覆盖二叉树的原地展开为先序链表、同层next指针填充、根到叶路径求和(含高精度)、带权路径长度WPL、二叉堆(小根堆)、二叉排序树BST查找路径、以及用父指针求最近公共祖先LCA。核心是数组模拟指针建树、非递归遍历避免爆栈、利用结构性质O(1)空间改链。

核心方法思路识别信号 / 何时用题号
二叉树原地展开为先序链表两种实现:Morris式(对当前结点若有左子树,找左子树最右结点作前驱,把原右子树挂到前驱右指针、左子树搬到右边、左指针置空,沿right前进)O(1)空间;或非递归先序遍历(显式栈先压右后压左,prev滚动改链,改指针前先暂存原孩子)。展开后只走right且顺序为先序。要求把二叉树原地拉平成只沿right的单链表且顺序为先序遍历,强调O(1)额外空间或防递归爆栈时30463054
完美/满二叉树同层next指针填充不用队列层序,利用上一层已连好的next去连下一层:同父令left.next=right,跨父令right.next=cur.next.left(本层最右则为0);沿本层next横向扫完后用levelStart=left下降一层,直到叶子层。O(1)额外空间。完美/满二叉树要求为每个结点填同层右邻指针,且希望省去队列、O(1)额外空间时30473055
根到叶路径枚举+高精度累加求数字之和非递归DFS枚举每条根到叶路径(显式栈带state或depth标记,进入压path、到叶累加、回溯弹path),把路径数字拼成十进制数。因路径可长达10万位超long long,用int/vector逆序数组(下标0是个位)做高精度竖式加法逐位带进位。求所有根到叶路径拼成的数字之和,且树可能很深导致数字位数超整型、递归会爆栈时30483056
非递归遍历计算带权路径长度WPLWPL=所有叶子权值×深度之和。用两个并行栈同步保存结点编号与对应深度做迭代DFS,弹出结点若是叶子(左右孩子都为0)就累加weight×depth,否则压孩子并深度+1。根深度从0计,weight用long long防溢出。求二叉树/哈夫曼树的带权路径长度,需要结点深度信息且要避免递归爆栈时3066
数组实现小根堆(上浮下沉)下标0占位、从1开始存完全二叉树,父=i/2、左右孩=2i/2i+1。插入放末尾再siftUp(比父小就换上去);取最小输出heap[1]后用末尾元素覆盖堆顶、pop_back再siftDown(与较小孩子比较)。每次操作O(log n)。需要反复取/删最小(或最大)元素并动态插入,即优先队列场景时3065
二叉排序树BST构建与查找路径按比目标小向左、否则向右的规则下行:插入时方向孩子为空就在此挂新结点,查找时沿途记录经过结点值命中即停、走到空则路径尾补-1表失败。用vector+int下标模拟左右孩子指针。建树O(n·h)、查找O(h)。需要构建二叉排序树并输出从根到某关键字的查找路径,或利用BST有序性做有序插入查找时3090
父指针上行+路径标记求LCA不显式建树,把孩子关系反向记成parent数组(读结点i孩子a、b时parent[a]=parent[b]=i)。先从x沿父指针走到根把路径上结点标记on_path,再从y上行遇到的第一个被标记结点即最近公共祖先。用循环上行代替递归,根的parent为0作终止哨兵。求二叉树两结点最近公共祖先(LCA),给定孩子关系且希望避免求深度、避免递归时2649
2649

二叉树中两个结点的最近公共祖先(LCA)

基础
父指针上行 + 路径标记法求LCA利用父指针求最近公共祖先LCA
父指针数组(parent)孩子表示的二叉树布尔标记数组(on_path)
核心思想

不显式建树,而是把"孩子关系"反向记成父指针数组 parent,使任一结点都能沿 parent 上行到根。先从 x 上行到根,把沿途结点全部标记到 on_path;再让 y 上行,遇到的第一个被标记结点就是两条到根路径的最早交汇点,即最近公共祖先(LCA)。这样把 LCA 转化为"两条到根路径求第一个公共点",无需求深度、无需递归。

关键代码
// 1) 孩子关系反存为父指针
for (int i = 1; i <= n; i++) {
    cin >> a >> b;
    if (a) parent[a] = i;   // a 为 0 表示无左孩子
    if (b) parent[b] = i;
}
// 2) 标记 x 到根的整条路径
for (int p = x; p; p = parent[p])
    on_path[p] = true;
// 3) y 向上走,第一个落在 x 路径上的点即 LCA
int ans = y;
while (!on_path[ans]) ans = parent[ans];
cout << ans << endl;
图解
例:n=5,查询 x=4, y=5。读入孩子并反存父指针后得到这棵树
12345
parent[2]=parent[3]=1,parent[4]=parent[5]=2,parent[1]=0(根的哨兵)。蓝色高亮为下面要走过的关键结点
第1步
4
→
2
→
1
→
0
x=4 沿父指针上行直到 parent=0 终止,把整条路径标记:on_path[4]=on_path[2]=on_path[1]=true
第2步
5
→
2
y=5 上行:on_path[5] 未标记 → 走到 parent[5]=2;结点 2 已被标记(on_path[2]=true),停止
第3步
5
命中
2
第一个落在 x 路径上的点即 LCA,输出 ans=2,即 4 与 5 的最近公共祖先为结点 2
复杂度与易错
  • 时间 O(n):建父指针 O(n),两次上行各不超过树高 O(h)≤O(n)
  • 空间 O(n):父指针数组与标记数组各占 n+1
  • 读入孩子时若孩子编号为 0 表示空,必须 if(a)/if(b) 判断,否则会误写 parent[0]
  • 上行依赖根结点 parent 为 0 才能让 for(p=x;p;...) 自然终止,结点编号需从 1 开始
  • x 与 y 之一是另一个的祖先(或 x==y)时仍正确:x 的路径含自身,y 上行会命中
  • 假定输入确为一棵合法二叉树,否则上行可能不收敛
复用技巧
孩子关系反向存成父指针数组标记一条到根路径再让另一点上行求交点用循环上行代替递归避免爆栈根的 parent 为 0 作为终止哨兵
3046

二叉树原地展开为先序链表

进阶
Morris式线索化展开(前驱右指针拼接)二叉树原地展开为先序单链表(Morris式右链接)
二叉树(数组静态存储)结构体数组模拟指针单链表(复用right指针)
核心思想

把二叉树原地改成一条只沿 right 走的链表,顺序恰为先序(根-左-右),且不用栈或递归只花 O(1) 额外空间。对每个有左子树的结点 cur,找到其左子树的最右结点 pre(左子树先序的最后一个),把 cur 原来的右子树挂到 pre.right,再把整棵左子树搬到 cur 的右边并置空 cur 的左指针,然后沿 right 继续。

关键代码
while (cur != 0) {
    if (tree[cur].left != 0) {
        int pre = tree[cur].left;
        while (tree[pre].right != 0)   // 找左子树最右结点
            pre = tree[pre].right;
        tree[pre].right = tree[cur].right; // 原右子树挂到前驱后
        tree[cur].right = tree[cur].left;  // 左子树搬到右边
        tree[cur].left  = 0;               // 置空左指针
    }
    cur = tree[cur].right;            // 沿右链前进
}
图解
初始树(结点1左2右5;结点2左3右4;3、4、5为叶)。先序应为 1 2 3 4 5
12534
第1步cur=1:左子树为以2为根的子树,其最右结点 pre=4。把 1 的原右子 5 挂到 4.right;再令 1.right=2、1.left=0。cur 前进到 2。
1
→
2
left=0
此时 4.right 已指向 5(原右子树挂在左子树尾部之后)
第2步cur=2:左子树仅结点3,其最右 pre=3。把 2 的原右子 4 挂到 3.right;令 2.right=3、2.left=0。cur 前进到 3。
1
→
2
→
3
第3-5步cur=3 无左子,沿 right 到 4;cur=4 无左子,沿 right 到 5(即最初挂上的原右子);cur=5 无左子,5.right=0 结束。
1
→
2
→
3
→
4
→
5
→
∅
沿 right 读出 1 2 3 4 5,正是先序序列
复杂度与易错
  • 时间 O(n):每个结点最多被当作前驱查找路径访问常数次,左子树最右链各边总共被走 O(n) 次
  • 空间 O(1) 额外空间:除输入存储外仅用常数个指针变量,无栈无递归
  • 拼接顺序不能乱:必须先 pre.right=cur.right,再 cur.right=cur.left,最后 cur.left=0,三步顺序错误会丢失子树
  • 找前驱 pre 时是沿左子树一路向 right 走到底,不要写成向 left 走
  • 下标 0 约定为空,结点从 1 开始存,数组大小要开 n+1
  • value 用 long long 防止数值溢出
  • 展开后 cur 沿新的 right 前进(此时已是原左子树),而非原来的 right
复用技巧
数组下标模拟指针(0表示空指针)复用right指针把树就地改成单链表找左子树最右结点作为拼接前驱迭代代替递归避免爆栈与额外空间Morris/线索化思想
3047

填充每个节点的 next 指针(完美二叉树同层连接)

进阶
利用已建立的上层 next 指针,逐层把下一层节点串成链(O(1)额外空间的层序连接)完美二叉树同层右指针填充(数组存树+逐层next串联)
数组模拟二叉树(结构体数组)完美二叉树隐式链表(next指针串成同层链)
核心思想

这是一棵用结构体数组存的完美二叉树(下标1为根,0表空),要给每个节点填 next,指向同层右邻居、最右节点为0。诀窍是不用队列:边走边用「上一层已经连好的 next」去连下一层——同父的左孩子直接连右孩子,右孩子则跨到 cur.next 的左孩子;每层从最左节点 levelStart 横扫,再 levelStart=left 下降一层。每个节点只访问一次,额外空间 O(1)。

关键代码
int levelStart = root;
while (tree[levelStart].left != 0) {      // 还有下一层
    int cur = levelStart;
    while (cur != 0) {                     // 横向扫当前层
        int l = tree[cur].left, r = tree[cur].right;
        tree[l].next = r;                  // 同父:左->右
        if (tree[cur].next != 0)           // 跨父:右->右邻居的左孩子
            tree[r].next = tree[tree[cur].next].left;
        cur = tree[cur].next;              // 沿上层next横向走
    }
    levelStart = tree[levelStart].left;    // 下降一层
}
图解

例子:完美二叉树 7 个节点,1为根,2/3为第二层,4/5/6/7为叶子层。下图实线为父子边,虚箭头为最终填出的 next 链(同层向右串联)。

1234567
实线=父子边;横向虚箭头=填好的 next(2→3、4→5→6→7)。关键:5→6 是「跨父」连出来的。
第1步
1
→
∅
levelStart=1,处理第一层。cur=1:左2右3,连 next[2]=3;cur.next=0,本层扫完。
第2步
2
→
3
下降 levelStart=left(1)=2,处理第二层。cur=2:左4右5,连 next[4]=5;cur.next=3≠0,跨父连 next[5]=left(3)=6。
第3步
2
→
3
→
∅
cur=cur.next=3:左6右7,连 next[6]=7;cur.next=0,本层扫完。
第4步
4
→
5
→
6
→
7
下降 levelStart=left(2)=4,tree[4].left==0(叶子层),外层循环结束。最终 next:1→0,2→3,3→0,4→5,5→6,6→7,7→0。
复杂度与易错
  • 时间 O(n),每个节点恰好被处理一次
  • 空间 O(1) 额外空间(next 存在节点内,不需要队列;不计输入数组)
  • 下标0被用作空节点哨兵,节点编号从1开始,tree 要开 n+1 大小。
  • 连右孩子的 next 前必须先判断 cur.next≠0,否则会越界访问 tree[0].left。
  • 循环终止条件是 tree[levelStart].left==0(到达叶子层就停),不是 cur==0。
  • value 用 long long,防止结点值过大溢出。
  • 本题前提是完美二叉树:每个内部节点都有左右孩子,跨父连边的逻辑才成立。
复用技巧
数组模拟指针(下标当指针,0当空指针/哨兵)复用上一层已连好的 next 来连下一层,省掉队列用 levelStart=left 沿最左链下降逐层推进迭代代替递归,避免额外栈空间
3048

二叉树根到叶路径数字求和

综合
迭代DFS枚举根到叶路径 + 高精度大数累加根到叶路径枚举与高精度加法
静态数组存二叉树(下标做左右孩子指针)显式栈(模拟递归的帧)vector高精度数(低位在前)路径栈path
核心思想

一棵二叉树每个结点存一位数字(0-9),从根到每个叶子拼成一个数,要把所有这样的数相加。做法是用一次 DFS 枚举所有根到叶路径:进结点压入 path,到叶子就把当前路径数字累加进总和,离开时弹出回溯。由于位数可能很长会溢出整型,总和用 vector 低位在前做高精度加法;为防递归爆栈,用显式栈加 state 标记(0 进入/1 回溯)手工模拟递归的进入与返回两个时机。

关键代码
if (cur.state == 0) {            // 首次进入该结点
    path.push_back(tree[cur.node].value);
    st.push({cur.node, 1});      // 先压回溯帧,保证孩子处理完再弹path
    if (isLeaf(tree, cur.node)) {
        addPathToSum(path, sum); // 到叶子:把这条路径数字累加进总和
    } else {                     // 先压右后压左 -> 左先出栈(前序)
        if (tree[cur.node].right) st.push({tree[cur.node].right, 0});
        if (tree[cur.node].left)  st.push({tree[cur.node].left, 0});
    }
} else {                         // state==1 回溯
    path.pop_back();
}
图解

例子的树:1 号(值1)有左孩 2 号(值2)、右孩 3 号(值3),2、3 号都是叶子。

123
高亮叶 2:到达它时 path=[1,2],拼成数字 12 累加进总和
第1步
1
→
压回溯帧(state=1),再压右3、左2
第2步
1
→
2
叶
弹出左孩 2(叶):path=[1,2] 表示 12,sum = 0 + 12 = 12,再回溯弹出 2
第3步
1
→
3
叶
弹出右孩 3(叶):path=[1,3] 表示 13,sum = 12 + 13 = 25,再回溯弹出 3

高精度加法(vector 低位在前,下标0是个位),把 12 与 13 逐位对齐相加:

个位
2
+
3
=
5
无进位
十位
1
+
1
=
2
无进位
sum 向量 = [5, 2](个位5、十位2),去高位前导0后从高到低输出
十位 2
→
个位 5
最终输出:25
复杂度与易错
  • 时间 O(总路径数字位数之和):每个结点遍历一次,每条到叶路径再做一次与当前 sum 等长的高精度加法
  • 空间 O(n) 存树、显式栈与 path;总和 vector 占 O(最大位数)
  • 回溯帧(state=1)必须先于孩子压栈,否则 path 无法在恰当时机弹出,路径会被污染
  • 总和不能用 int 直接累加,位数会溢出,必须用 vector 逐位带进位相加
  • addPathToSum 中 path 从高位末尾向个位扫,要与 sum 低位对齐(pos 从0开始)
  • 进位 carry 在主循环结束后还要单独 while 处理,否则最高位的进位会丢失
  • 用 0 表示空孩子(left==0/right==0 判叶),结点下标从1开始,tree 数组开到 n+1
  • 输出前去前导0要保留至少一位(size>1 才弹0),避免和为0时输出空
复用技巧
显式栈+state(0进入/1回溯)迭代代替递归避免爆栈进入压栈先压自己的回溯帧(state=1)再压孩子,保证孩子处理完才回溯弹pathvector低位在前做高精度加法,逐位取模留余进位孩子入栈顺序:先压右再压左,使左子树先被弹出处理(保持前序)下标0当空指针(left==0/right==0判叶子)输出前去掉高位多余的前导0
3054

二叉树展开为链表

进阶
非递归先序遍历 + 原地改链先序遍历原地展开为单链表
数组模拟二叉树(结构体数组)手写顺序栈(IntStack)入度标记数组 hasParent
核心思想

用一次非递归先序遍历同时完成遍历与改链:手写顺序栈代替递归(n 可达 10 万,退化成链时递归会爆栈),处理当前结点 cur 时先压右孩子再压左孩子保证下轮先弹左,符合"根左右"。改链用 prev 记录先序中上一个结点,每访问 cur 就令 prev.left=0、prev.right=cur,逐步串成只走 right 的单链表。

关键代码
nodes.push(root);
int prev = 0;
while (!nodes.empty()) {
    int cur = nodes.pop();
    int L = tree[cur].left, R = tree[cur].right; // 先暂存原孩子
    if (prev != 0) { tree[prev].left = 0; tree[prev].right = cur; }
    if (R != 0) nodes.push(R);   // 先压右
    if (L != 0) nodes.push(L);   // 再压左,下一轮先弹左
    prev = cur;
}
if (prev != 0) { tree[prev].left = 0; tree[prev].right = 0; } // 收尾
图解

例子:根 1,左孩子 2、右孩子 3(均为叶子,靠入度为 0 找到根 1)。

123
原二叉树:1 的左=2、右=3
第1步
1
栈[1],弹出 1:prev=0 不改链;暂存 L=2,R=3;先压 3 再压 2,栈变 [3,2],prev=1
第2步
3
2
弹出栈顶 2:prev=1 非空 → tree[1].left=0、tree[1].right=2;2 无孩子;prev=2,栈[3]
第3步
3
弹出 3:prev=2 → tree[2].left=0、tree[2].right=3;3 无孩子;prev=3,栈空
第4步
收尾
栈空,循环结束:tree[3].left=0、tree[3].right=0,清掉末尾残留指针
1
right→
2
right→
3
→
∅
展开结果:沿 right 链从 1 输出 → 1 2 3(每个结点 left 均为 0)
复杂度与易错
  • 时间 O(n),每个结点恰好入栈出栈一次
  • 空间 O(n),结构体数组与显式栈,最坏栈深 O(n)
  • 改链前必须先暂存 originalLeft/originalRight,否则覆盖 right 后再压栈会丢子树
  • 压栈顺序必须"先右后左",反了会变成根右左
  • 循环结束后还要把最后一个结点 prev 的 left 和 right 都置 0,否则末尾残留旧指针
  • 根结点不在输入给出,须靠 hasParent 找入度为 0 的点,不能想当然认为是 1 号
  • 结点值范围 -1e9~1e9,用 long long 更稳妥;递归实现在退化成链(深度 10 万)时会运行时栈溢出
复用技巧
数组模拟指针(下标当编号,0表空)迭代+显式栈代替递归避免爆栈栈先压右后压左实现先序prev指针滚动改链改指针前先暂存原孩子防止结构丢失入度为0找根节点first标志控制输出分隔空格
3055

填充右侧节点指针

进阶
利用已建立的 next 指针逐层横向遍历,O(1) 额外空间填充下一层 next满二叉树按层连接 next 指针(利用已建指针逐层迭代,O(1)额外空间)
数组模拟二叉树(结构体数组)完美二叉树/满二叉树
核心思想

这是一棵满二叉树(完美二叉树),可以省掉层序队列:同一父节点必有 left.next = right;若父节点在本层还有右邻居(cur.next≠0),则 right.next = cur.next.left。所以只要上一层的 next 已建好,就能沿这一层的 next 横向移动,顺手把下一层全部连好。逐层从根往下,levelStart 记录每层最左节点作横向起点,额外空间仅 O(1)。

关键代码
void connectNextPointers(int root) {
    tree[root].next = 0;
    int levelStart = root;
    while (tree[levelStart].left != 0) {     // 还有下一层
        int cur = levelStart;
        while (cur != 0) {                   // 沿本层 next 横向走
            tree[tree[cur].left].next = tree[cur].right;        // 同父:left->right
            if (tree[cur].next != 0)
                tree[tree[cur].right].next = tree[tree[cur].next].left; // 跨父:right->nextLeft
            else
                tree[tree[cur].right].next = 0;                 // 本层最右
            cur = tree[cur].next;
        }
        levelStart = tree[levelStart].left;  // 下一层最左节点
    }
}
图解
123next
example:n=3,根 1(左 2 右 3)。先 tree[1].next=0;levelStart=1。
第1步
1
→
∅
根层只有节点 1,next=0(最右父节点 cur.next==0)。tree[1].left=2≠0,进入连下一层。
第2步
2
→
3
→
∅
cur=1:同父 left(2).next=right(3);cur.next==0 故 right(3).next=0 本层最右。
第3步
2
↓
叶
levelStart=tree[1].left=2;tree[2].left==0 已到叶子层,循环结束。最终输出按编号 1..n:0 / 3 / 0。
复杂度与易错
  • 时间 O(n),每个节点恰好被访问并连接一次
  • 空间 O(n) 存树本身;额外辅助空间仅 O(1),不用队列/递归栈
  • 仅对满二叉树成立:right.next=next.left 依赖每个父节点都有两个孩子,普通二叉树需改用队列层序
  • 终止条件用 tree[levelStart].left!=0 判断是否到叶子层,而非靠层数计数
  • 本层最右父节点 cur.next==0,必须显式把它的 right.next 置 0
  • 输出按编号 1..n 顺序(第 i 行输出节点 i 的 next),不是按层序或先序
  • 0 既表示空孩子也表示空 next,需统一约定;节点值范围 [-1e9,1e9] 用 long long 读入保结构
复用技巧
数组模拟指针(用编号代替 Node*,0 表示空)利用上一层已建好的 next 横向遍历来建下一层,省去层序队列用 hasParent 标记找根(未被任何节点作为孩子出现的即为根)迭代代替递归,规避 n=1e5 时的爆栈风险用 levelStart=本层最左节点 作为每层横向遍历起点
3056

根节点到叶节点数字之和

综合
手写栈非递归DFS + 高精度十进制加法非递归DFS求根到叶路径数字之和(含大整数累加)
数组模拟二叉树手写顺序栈int数组模拟大整数路径数组pathDigits
核心思想

每条根到叶的路径把沿途结点数字依次拼成一个十进制整数,求所有这些数之和。因为 n 可达 10 万且树可能退化成长链,递归 DFS 会爆栈,所以用手写顺序栈做非递归 DFS,栈元素携带结点编号和深度,深度用来定位该数字在路径数组中的位置。又因单条路径可长达 10 万位,远超 long long,所以用 int 数组逆序保存大整数(下标 0 是个位),每到叶子就按竖式逐位带进位累加。根是唯一没有父亲的结点(入度为 0)。

关键代码
void calculateSum(int root) {
    ItemStack nodes;
    nodes.push({root, 1});
    while (!nodes.empty()) {
        StackItem cur = nodes.pop();
        int node = cur.node, depth = cur.depth;
        pathDigits[depth] = tree[node].value;     // 按深度记录路径上的数字
        if (isLeaf(node)) {
            addCurrentPathToSum(depth);           // 到叶子: 把整条路径数字累加进大整数
        } else {                                  // 先压右再压左, 左子树先出栈
            if (tree[node].right) nodes.push({tree[node].right, depth + 1});
            if (tree[node].left)  nodes.push({tree[node].left,  depth + 1});
        }
    }
}
图解
124
输入: 结点1(值1,左2,右4) 为根(无父亲); 结点2、4 为叶。左路径 1→2 拼成 12, 右路径 1→4 拼成 14。
第1步
{1,1}
↓
栈压入 {结点1,深度1}; 弹出非叶, pathDigits[1]=1; 先压右{4,2} 再压左{2,2}, 保证左子树先出栈。
第2步
1
2
pathDigits[1..2]
弹出 {2,2}: pathDigits[2]=2, 是叶。路径数字 12, 大整数累加得 sumDigits(逆序)={2,1} 即 12。
第3步
1
4
pathDigits[1..2]
弹出 {4,2}: pathDigits[2]=4, 是叶。路径数字 14, 竖式累加 12+14: 个位 2+4=6, 十位 1+1=2 → sumDigits={6,2}。
输出
6
2
→
26
栈空。大整数逆序存为 {6,2}, 从高位到个位输出并去前导 0, 得 26。
复杂度与易错
  • 时间 O(n + S), S 为所有叶子路径长度之和(每到叶子做一次与路径等长的大整数加法); 退化成链时最坏可达 O(n²) 量级
  • 空间 O(n), 结点表、手写栈、路径数组、大整数数组均为 O(n) 级别
  • 路径可长达 10 万位, 必须用大整数逆序数组, 绝不能用 long long
  • 树退化成链时递归会栈溢出, 必须用手写栈做非递归 DFS
  • 大整数累加要带进位, 进位可能使位数增长, 需同步更新 sumLength
  • 大整数是逆序存的, 输出要从高位到个位并去掉前导 0
  • 答案为 0 时应输出单个 0 而非空行
  • left/right 为 0 表示空孩子, 叶子判定是左右孩子都为 0; pathDigits 只用 1..depth 这一段, 更深层旧值不影响结果, 无需清理
复用技巧
数组模拟指针建二叉树迭代代替递归避免爆栈栈中携带depth定位路径位置高精度大整数逆序数组逐位带进位无父节点即为根(入度法找根)先压右孩子再压左孩子保证左子树优先
3065

手写小根堆(插入与删除最小值)

基础
数组下标从1开始的二叉堆,siftUp上浮、siftDown下沉二叉堆/优先队列的数组实现与上浮下沉
小根堆(二叉堆)vector模拟完全二叉树优先队列
核心思想

题目要支持「插入一个数」和「取出当前最小数」,最适合用小根堆。用 vector 把完全二叉树按层存进数组,下标 0 占位、从 1 开始,于是节点 i 的父亲是 i/2、左右孩子是 2i、2i+1。插入时把新值放数组末尾再上浮(siftUp),取最小时输出堆顶 heap[1]、把末尾搬上来再下沉(siftDown),每次操作均 O(log n)。

关键代码
void siftUp(int i){              // 上浮:比父亲小就换上去
    while(i>1 && heap[i]<heap[i/2]){
        swap(heap[i], heap[i/2]); i/=2;
    }
}
void siftDown(int i){           // 下沉:与较小孩子比较
    int sz=heap.size()-1;
    while(i*2<=sz){
        int c=i*2;
        if(c+1<=sz && heap[c+1]<heap[c]) c++;   // 选较小孩子
        if(heap[i]<=heap[c]) break;
        swap(heap[i],heap[c]); i=c;
    }
}
// 删除堆顶:输出heap[1],把末尾搬到堆顶后下沉
heap[1]=heap.back(); heap.pop_back(); siftDown(1);
图解
第1步
_
5
插入 5:下标 0 占位,5 放在 heap[1],堆=[_,5]
第2步
_
5
3
插入 3:放末尾 heap[2],3<父 heap[1]=5,上浮交换
→
_
3
5
上浮后 堆=[_,3,5],3 到堆顶满足堆序
第3步
_
3
5
8
插入 8:放末尾 heap[3],8>父 heap[1]=3,不动 → [_,3,5,8]
此时堆的二叉树形态(下标标注):
3i=15i=28i=3
堆顶 heap[1]=3 即当前最小值;父子关系:i=1 的孩子是 2、3
第4步
_
3
5
8
操作2取最小:输出堆顶 3;把末尾 8 搬到 heap[1],pop 掉末尾 → [_,8,5]
下沉
_
8
5
8 与较小孩子 5 比较,8>5 → 交换下沉
→
_
5
8
下沉后 堆=[_,5,8],恢复堆序
第5步
_
5
8
操作2取最小:输出堆顶 5;末尾 8 搬到 heap[1] 并下沉,无孩子 → [_,8]
最终输出:3 换行 5
复杂度与易错
  • 时间 每次插入/删除 O(log n),n 个操作共 O(n log n)
  • 空间 O(n),仅存堆数组
  • 小根堆下沉要和「较小的」孩子比较交换,写成较大孩子会错
  • siftUp 循环条件必须 i>1,到根(下标1)即停,否则 i/2 会越界访问占位元素 heap[0]
  • siftDown 中右孩子可能不存在,必须先判 c+1<=sz 再访问 heap[c+1]
  • 相等时(<=)应停止,避免无谓交换甚至死循环
  • 删除堆顶要先把末尾值赋给 heap[1] 再 pop_back,顺序写反会丢数据
  • 数组有效大小是 heap.size()-1(因下标0占位),算 sz 别忘了减 1
复用技巧
下标0占位、从1开始存堆,父子关系用 i/2 与 2i/2i+1 直接计算上浮(siftUp)与下沉(siftDown)维护堆序删除堆顶时用末尾元素覆盖堆顶再下沉,避免留空洞下沉时先在两个孩子中选较小者再比较用 vector 动态扩缩代替手动数组管理
3066

二叉树的带权路径长度 WPL

基础
迭代式DFS累加叶子结点权值×深度二叉树带权路径长度WPL的非递归遍历计算
顺序存储的静态二叉树(结构体数组)栈(vector模拟)布尔标记数组
核心思想

WPL(带权路径长度)等于所有叶子结点的权值乘以其深度之和。用结构体数组静态存树(孩子用编号表示,0 代表空),通过 hasParent 标记找出唯一没有父亲的结点作为根;再用两个并行的栈同步保存「结点编号」与「对应深度」,做非递归 DFS:弹出叶子就累加 weight×depth,否则把孩子压栈并令深度加一。用迭代代替递归是为了树很深时避免系统栈溢出。

关键代码
while (!stackNode.empty()) {
    int node = stackNode.back();  stackNode.pop_back();
    int depth = stackDepth.back(); stackDepth.pop_back();
    if (isLeaf(tree[node])) {
        wpl += tree[node].weight * depth;      // 叶子: 权值×深度
    } else {
        if (tree[node].right) { stackNode.push_back(tree[node].right);
                                stackDepth.push_back(depth + 1); }
        if (tree[node].left)  { stackNode.push_back(tree[node].left);
                                stackDepth.push_back(depth + 1); }
    }
}
图解
例:3 个结点。1号(左2,权5,右3) 为内部结点;2号(权1)、3号(权4) 为叶子。2、3 被标记 hasParent,唯独 1 无父,故根=1(根深度=0)。
1权5 · 深02叶 权1 · 深13叶 权4 · 深1
高亮的 2、3 号为叶子,仅它们参与 WPL 累加(内部根 1 的权 5 不计入)。
第1步
1d=0
∅
栈底→栈顶:弹出 1(d=0),非叶 → 压右孩子 3(d=1)、再压左孩子 2(d=1)。wpl=0
第2步
2d=1
→
3d=1
弹出栈顶 2(d=1),是叶子 → wpl += 1×1 = 1
第3步
3d=1
弹出 3(d=1),是叶子 → wpl += 4×1,累计 wpl=5
第4步
∅
栈空,结束。输出 WPL = 5
复杂度与易错
  • 时间 O(n),每个结点恰好入栈出栈一次
  • 空间 O(n),结构体数组与栈深度最坏为 O(n)
  • 根不一定是 1 号结点,必须靠「无父结点」(hasParent) 确定,否则 WPL 算错
  • 深度从 0 开始:根深度为 0,且非叶根的权值不计入 WPL
  • 只对叶子(左右孩子编号都为 0)累加权值,内部结点的 weight 不计入
  • 孩子编号为 0 表示空指针,判叶子与压栈前都要判 0
  • wpl 与 weight 都要用 long long,权值累加很大时防止 int 溢出
复用技巧
数组下标模拟指针存树(静态二叉树)用hasParent入度标记找根结点迭代DFS代替递归避免爆栈两个并行栈同步保存结点与其深度weight用long long防溢出
3090

二叉排序树的查找路径

基础
二叉排序树构建 + 自根到目标的查找路径回溯二叉排序树(BST)的建立与查找路径输出
二叉排序树(BST)用vector数组模拟的二叉链表数组下标作为指针
核心思想

先把 n 个数依次插入一棵二叉排序树(BST):每个数从根出发,比当前结点小就向左、否则向右,遇到空孩子就挂上去。再查找关键字 k,按同样的下行规则从根走到 k,沿途记录经过的结点值即为查找路径;若走到空(-1)说明查找失败,在路径末尾补一个 -1。

关键代码
// 插入:小于向左,否则向右,遇空位即挂上
while (true) {
    if (x < tree[current].value) {
        if (tree[current].left == -1) {
            tree[current].left = tree.size();   // 先记下孩子下标
            tree.push_back(Node(x));            // 再插入新结点
            return;
        }
        current = tree[current].left;
    } else {
        if (tree[current].right == -1) {
            tree[current].right = tree.size();
            tree.push_back(Node(x));
            return;
        }
        current = tree[current].right;
    }
}

// 查找:沿途记录路径,命中即停,走到空补 -1
while (current != -1) {
    path.push_back(tree[current].value);
    if (target == tree[current].value) return path;
    current = (target < tree[current].value) ? tree[current].left
                                             : tree[current].right;
}
path.push_back(-1);
图解
例:依次插入 5 3 8 7 1,查找 k=7。建成的 BST 如下(红色为查找 7 的路径 5 → 8 → 7):
53817
第1步
5
→
从根 5 出发,记录 5;目标 7 ≥ 5,向右
第2步
5
→
8
→
到达 8,记录 8;7 < 8,向左
第3步
5
→
8
→
7
→
到达 7,命中 target==7,停止
结果
5
→
8
→
7
→
输出路径:5 8 7(若走到空则末尾追加 -1)
复杂度与易错
  • 时间 建树 O(n·h),单次查找 O(h),h 为树高(最坏 O(n),平衡时 O(log n))
  • 空间 O(n),存储 n 个结点
  • 相等元素的归属:代码用 x < value 才向左,等于时归入右子树(右边为 ≥),需与题意一致
  • 插入空树要单独判断 tree.empty(),否则访问 tree[0] 越界
  • 孩子下标必须在 push_back 之前用 tree.size() 取,push_back 之后 size 已改变
  • 查找失败时别忘了输出 -1,且 -1 也要参与空格分隔的格式
  • 根结点固定为下标 0,整个算法依赖第一个插入的数当根
复用技巧
用 vector<Node> 加 int 下标模拟左右孩子指针,避免裸 new/delete 和内存管理新结点下标恰为 push_back 前的 tree.size(),先记录孩子下标再插入插入与查找用同一套 小于向左 否则向右 的下行规则,逻辑对称查找失败时在路径尾部追加 -1 作为约定的失败标记ios::sync_with_stdio(false)+cin.tie 关闭同步加速读入
🕸️

图

5 题

本章覆盖图的两大遍历(BFS/DFS)与并查集。BFS用于无权图/网格图最短路与有向图可达性;DFS(栈模拟)用于网格连通分量与子集判定;并查集用于求无向(弱)连通分量个数。核心是邻接表/链式前向星/网格三种存图方式与visited标记防重。

核心方法思路识别信号 / 何时用题号
BFS求无权图/网格最短路无权图每条边权看作1,BFS从起点按层向外扩展,队列保证先访问的距离不大于后访问的,故第一次到达终点时的距离即最短。dist数组兼作visited(-1未访问),入队即赋距离并标记防重复入队;网格用dx/dy方向数组处理四邻,越界/障碍/已访问跳过。无权图或网格迷宫(每步代价相同)求从起点到终点的最少步数/最短路径长度时31033120
BFS判有向图可达性从s做一次遍历,凡能遍历到的点都说明s可达它,遍历中碰到t即可达。队列保存待扩展节点,visited在入队时立即标记防重复入队与死循环;有向图只加单向边,找到目标提前返回。有向图判断从s能否到达t(只问可达性、不要最短路)时,BFS/DFS均可3118
栈模拟DFS求网格连通分量+子集判定遍历grid2每个格子,遇未访问的1就用显式栈DFS走完整个连通块,把visited写回原网格(置0)既标记又防重复;遍历中只要发现某格在grid1为0就给岛屿打不合格标记ok=false,但必须走完整个岛屿再判断,不能一发现就提前break(否则剩余格子被当新岛屿重复统计)。网格中求连通分量(岛屿)数量,或判断一个连通块是否被另一网格完全包含(子集判定)时2895
并查集求无向(弱)连通分量个数弱连通即忽略边方向,把有向边u->v当无向边,读边就unite(u,v)无需建图;所有边处理完后统计根节点个数即连通分量数。用路径压缩(查根时把路径结点直接挂到根)和按集合大小合并(小挂大)使近乎常数。求无向图(或有向图忽略方向的弱)连通分量个数,或动态合并集合判连通时,不要误用Tarjan求强连通3102
2895

统计子岛屿(grid2 中被 grid1 完全包含的岛屿数量)

进阶
栈模拟 DFS 的连通分量遍历 + 子岛屿判定网格连通分量的 DFS 遍历与子集判定
二维数组(网格图)顺序栈(stack<pair<int,int>>)
核心思想

给两张同样大小的 0/1 网格 grid1、grid2,上下左右相连的 1 算一个岛屿。遍历 grid2,遇到未访问的 1 就用栈做一次 DFS 把整片连通块走完;过程中只要某格在 grid1 里是 0 就给岛屿打标记 ok=false(只标记、不提前退出),走完整块后若 ok 仍为真则计数加一。把访问过的格子直接写回 grid2 置 0,既当 visited 又防重复统计。

关键代码
if (grid2[i][j] == 0) continue;
bool ok = true;
stack<pair<int,int>> st;
st.push({i, j}); grid2[i][j] = 0;     // 标记起点
while (!st.empty()) {
    auto [x, y] = st.top(); st.pop();
    if (grid1[x][y] == 0) ok = false; // 只标记,不提前退出
    for (int k = 0; k < 4; ++k) {
        int nx = x+dx[k], ny = y+dy[k];
        if (nx<0||nx>=m||ny<0||ny>=n) continue;
        if (grid2[nx][ny] == 1) {      // 先标记后入栈
            grid2[nx][ny] = 0;
            st.push({nx, ny});
        }
    }
}
if (ok) ++ans;
图解
例子grid1 = [[1,0],[1,1]],grid2 = [[1,1],[1,1]] 全是 1,是同一个岛屿。逐格对照 grid1:
g1:1
g1:0
g1:1
g1:1
grid1 中 (0,1) 是 0(红色含义),所以 grid2 这个岛屿覆盖到了 grid1 的 0 格,必定不合格。
0,00,11,01,1起点(0,0)入栈→DFS 遍历整块高亮(0,1):grid1 此处=0 → ok=false
第1步扫到 (0,0)=1,ok=true,置 0 入栈。栈底→顶:
0,0
第2步弹 (0,0),grid1=1 合格;邻居 (1,0)(0,1) 置 0 入栈。
1,0
→
0,1
第3步弹 (0,1),grid1=0 → ok=false;邻居 (1,1) 置 0 入栈。
1,0
→
1,1
第4步弹 (1,1)、弹 (1,0),grid1 均=1,邻居都已置 0,栈空。
∅
结果整块走完,因途中出现过 ok=false,该岛屿不是子岛屿,ans 不增加,输出 0。
复杂度与易错
  • 时间 O(m*n),每个格子至多入栈、出栈一次
  • 空间 O(m*n),最坏栈中存放整片连通区域;直接复用 grid2 当 visited,无额外标记数组
  • 不能一遇到 grid1[x][y]==0 就 break:必须先把整个岛屿所有格子标记完,否则剩余格子会被外层循环当成新岛屿重复统计
  • 判定标记 ok 只能置为 false,不要在循环里又把它重新置回 true
  • 先 grid2[nx][ny]=0 再 push(先标记后入栈),否则同一格可能被重复压栈处理;起点 (i,j) push 后也要立即置 0
  • 访问网格前先做 nx/ny 越界判断,避免下标越界
复用技巧
把 visited 写回原网格(grid2 置 0)省去额外标记数组用显式 stack 迭代代替递归 DFS,避免大网格深递归爆栈dx/dy 方向数组统一处理上下左右四邻入栈时立即置 0(先标记后入栈)防止同一格被重复加入栈ios::sync_with_stdio(false)+cin.tie 加速读入结构化绑定 auto [x,y]=st.top() 简化取坐标
3102

有向图的连通分量(弱连通分量个数)

基础
并查集(路径压缩 + 按集合大小合并)并查集求无向图连通分量(弱连通)
并查集parent数组setSize数组(按秩/按大小合并)
核心思想

求弱连通分量就是忽略边的方向后,两点是否在同一无向连通块里,本质等价于求无向图的连通分量个数。因此不必真正建图,读入每条有向边 u→v 就把 u、v 两个集合合并;处理完所有边后,统计根节点(findRoot(i)==i)的个数即为答案。自环和重边因合并同根会直接返回,不影响结果。

关键代码
int findRoot(int x) {            // 路径压缩,纯循环
    int root = x;
    while (parent[root] != root) root = parent[root];
    while (parent[x] != x) { int p = parent[x]; parent[x] = root; x = p; }
    return root;
}
void unite(int a, int b) {       // 按集合大小合并
    int ra = findRoot(a), rb = findRoot(b);
    if (ra == rb) return;        // 同集合(重边/已连通)直接返回
    if (setSize[ra] < setSize[rb]) swap(ra, rb);  // 小挂大
    parent[rb] = ra; setSize[ra] += setSize[rb];
}
// 主流程:每条边 if(u!=v) unite(u,v); 最后统计 findRoot(i)==i 的个数
图解
初始
1
2
3
4
parent=[1,2,3,4],setSize 全为 1,共 4 个独立集合(n=4)
第1步
1
←
2
3
4
读边 1→2:root(1)=1, root(2)=2,合并使 parent[2]=1,集合 {1,2};集合数 4→3
第2步
1
←
2
←
3
4
读边 2→3:root(2)=1, root(3)=3,合并使 parent[3]=1,集合 {1,2,3};集合数 3→2
第3步
1
2
3
4↺
读边 4→4:u==v 为自环,跳过,不做合并(即使 unite 也因同根直接返回)
1234根=1 (大小3)根=4 (大小1)
统计阶段:满足 findRoot(i)==i 的根有 节点1 与 节点4,共 2 个,即输出 2
复杂度与易错
  • 时间 O((n+m)·α(n)),近似 O(n+m)
  • 空间 O(n)(只用 parent 与 setSize 两个数组)
  • 弱连通≠强连通:本题忽略方向,不要去做 Tarjan/Kosaraju 求强连通
  • 节点编号从 1 开始,初始化与统计都用 1..n,别写成 0..n-1
  • 自环 u→u 不改变连通性应跳过(即使不跳,unite 同根也会直接 return)
  • 统计集合数要用 findRoot(i)==i 判断根,而非直接看 parent[i]==i(未压缩时可能漏判)
  • 按大小合并时务必把 setSize 累加到新根上,否则后续合并失衡
复用技巧
把有向边当无向边处理求弱连通并查集路径压缩按集合大小合并降低树高用循环实现find避免递归爆栈无需建图,边读边合并节省空间统计根节点个数即为集合数
3103

无向图最短路径

基础
BFS广度优先搜索求无权图最短路无权图BFS最短路(链式前向星存图)
链式前向星(数组模拟邻接表)顺序队列(手写IntQueue)距离数组distance[]
核心思想

无权图里每条边长度都看作 1,求 s 到 t 的最短路就是求最少边数。BFS 从起点一层层向外扩展:第 0 层是 s,第 1 层是它的直接邻居……由于按距离从小到大发现节点,某点第一次被访问时一定走的是最少边数,所以第一次碰到 t 得到的 distance 就是答案。用 distance[i]=-1 既表示未访问又当 visited 标记,自环和重边都自动失效。

关键代码
distance[s] = 0;
nodes.push(s);
while (!nodes.empty()) {
    int current = nodes.pop();
    for (int e = graph.firstEdge(current); e != 0; e = graph.nextOf(e)) {
        int nb = graph.targetOf(e);
        if (distance[nb] != -1) continue;          // 已访问跳过(去重/防环)
        distance[nb] = distance[current] + 1;       // 层数+1
        if (nb == t) return distance[nb];           // 第一次到t即最短
        nodes.push(nb);
    }
}
return -1;                                          // s与t不连通
图解
例子:5 个点,边 1-2 / 2-4 / 4-5 / 2-3,求 s=1 到 t=5 的最短路。按层展开(同色为同一距离层):
12435d=0d=1d=2d=2d=3 ← t
distance 数组随 BFS 逐层填充(-1=未访问):
起点
1
0
2
-1
3
-1
4
-1
5
-1
出队1
1
0
2
1
3
-1
4
-1
5
-1
出队2
1
0
2
1
3
2
4
2
5
-1
出队4
1
0
2
1
3
2
4
2
5
3
出队 4 时邻居 5==t,刚标记 distance[5]=3 立即返回 3(路径 1→2→4→5 共 3 条边)。
复杂度与易错
  • 时间 O(n+m):每个点最多入队一次,每条边只遍历一次
  • 空间 O(n+m):邻接表存 2m 条有向边,队列与 distance 各 O(n)
  • s==t 必须特判返回 0,否则起点被标记后会漏判而返回 -1。
  • distance 初始化为 -1 而非 0,否则无法区分起点(距离 0)和未访问点。
  • 链式前向星边号从 1 开始,head[u]==0 表示空表,0 不能当真实边号。
  • 找到 t 应在标记 distance 后立即 return,不要等出队再判断。
  • 无向边要正反各存一条,邻接表数组须开 2*m;读入时 u==v 显式忽略自环。
复用技巧
distance数组兼作visited标记(-1未访问,>=0为最短距离)链式前向星:head[u]存第一条边,nextEdge[e]串同起点的边,边号从1开始用0当空数组手写顺序队列代替STL queue(每点最多入队一次,无需循环队列)无向边拆成两条有向边正反各存一次读入时u==v显式忽略自环,提前return处理s==t
3118

判断有向图中两点是否可达

基础
广度优先搜索(BFS)求可达性BFS判断有向图s到t可达性
邻接表队列访问标记数组
核心思想

给定有向图,判断从起点 s 能否到达终点 t。从 s 出发做一次 BFS 遍历,凡能被遍历到的点都是 s 可达的,所以只要遍历途中碰到 t 就说明可达。用队列保存待扩展节点,用 visited 数组防止重复入队,出队时若节点等于 t 立刻输出 True,队列空了仍没遇到则输出 False。只判可达性,BFS/DFS 皆可。

关键代码
visited[s] = true;
q.push(s);
while (!q.empty()) {
    int u = q.front(); q.pop();
    if (u == t) { cout << "True\n"; return 0; }
    for (int v : graph[u])
        if (!visited[v]) {
            visited[v] = true;   // 入队即标记
            q.push(v);
        }
}
cout << "False\n";
图解
1234
例:n=4 边 1→2, 2→3, 3→4, 1→3;起点 s=1(根),终点 t=4(高亮)。下方为队列随 BFS 的演进。
第1步visited[1]=true 入队
1
第2步出队1(≠4),扩展邻居2、3并入队
1
→
2
→
3
第3步出队2(≠4),邻居3已访问跳过
2
→
3
第4步出队3(≠4),扩展邻居4并入队
3
→
4
第5步出队4,4==t,输出 True 并结束
4
→
∅
复杂度与易错
  • 时间 O(n + m),每个节点最多入队一次,每条边最多被检查一次
  • 空间 O(n + m),邻接表存边 O(n+m),visited 与队列 O(n)
  • 有向图只能 graph[u].push_back(v) 加单向边,不可当无向图加双向
  • visited 要在入队时立刻置真,而非出队时,否则同一点可能被多次压入队列
  • 顶点编号从1开始,容器大小要开 n+1 防越界
  • 需处理 s==t:s 先入队,出队时判 u==t 即可正确返回 True
  • 输出大小写与换行要严格匹配 True/False
复用技巧
邻接表存有向图visited数组在入队时标记防止重复入队ios::sync_with_stdio(false)+cin.tie加速读入找到目标提前return剪枝顶点从1编号故数组开n+1
3120

网格迷宫最短路(从S到A的最少步数)

基础
广度优先搜索(BFS)求最短路网格图BFS求无权最短路
二维字符网格队列二维距离数组dist
核心思想

网格中每走一步代价都是 1,这是无权图上的单源最短路,最适合用 BFS。从 S 出发按层一圈圈向外扩展,队列保证先访问的格子距离一定不更大,所以第一次到达 A 时的 dist 就是最少步数。用 dist 数组兼作访问标记(-1 表示未访问),入队即赋距离并标记,走不到就输出 -1。

关键代码
dist[sx][sy] = 0; q.push({sx, sy});
int dx[4]={1,-1,0,0}, dy[4]={0,0,1,-1};
while (!q.empty()) {
    auto [x, y] = q.front(); q.pop();
    if (grid[x][y] == 'A') { cout << dist[x][y]; return 0; }
    for (int k = 0; k < 4; ++k) {
        int nx = x+dx[k], ny = y+dy[k];
        if (nx<0||nx>=n||ny<0||ny>=m) continue;
        if (grid[nx][ny]=='#' || dist[nx][ny]!=-1) continue;
        dist[nx][ny] = dist[x][y] + 1;   // 入队即赋距离并标记
        q.push({nx, ny});
    }
}
cout << -1;  // 走不到
图解
例子网格 3×3(S=起点,A=终点,#=障碍),格内数字为 BFS 算出的 dist(最短步数),灰格为障碍不可走:
S0.1#.1#..2.3A4
连线即 BFS 实际走过的最短路径:S(0,0)→(1,0)→(2,0)→(2,1)→A(2,2),共 4 步,高亮即首次命中 A。
第1步
S
→
队列
dist[S]=0 入队,按层扩展开始
第2步
S
→
(0,1)=1
(1,0)=1
右/下两个'.'入队(dist=1);'#'与越界跳过
第3步
(0,1)
右邻'#'、下邻'#',全跳过,无新格入队
第4步
(1,0)
→
(2,0)=2
下邻'.'入队(dist=2);右邻'#'跳过
第5步
(2,0)
→
(2,1)=3
右邻'.'入队(dist=3)
第6步
(2,1)
→
A=4
右邻'A'入队(dist=4),出队时命中 A,输出 4
复杂度与易错
  • 时间 O(n·m),每个格子最多入队、出队各一次,每次扩展常数个方向
  • 空间 O(n·m),dist 数组与队列规模均与格子数同阶
  • 必须在入队时就赋 dist 标记已访问,而不是出队时才标记,否则同一格会被多次入队导致超时
  • 起点 S 也要先 dist=0 并入队,否则会从 S 重复出发
  • 边界(越界)检查要在访问 grid[nx][ny] 之前完成,避免数组越界
  • 障碍 # 与已访问格子(dist≠-1)都要跳过
  • 无法到达 A 时循环结束后别漏掉输出 -1
复用技巧
方向数组dx/dy统一处理上下左右dist数组复用为visited标记(-1为未访问)入队即赋距离并标记,避免同一格重复入队找到目标立即返回(首次到达即最短)ios::sync_with_stdio(false)+cin.tie加速读入
🔍

排序与查找

4 题

本章覆盖二分查找的多种变体(lower_bound下界、两有序数组第k小的折半淘汰)、离散化(排序去重+二分映射排名)、以及结构体多关键字自定义排序。核心是利用有序性把单次查询降到对数级,以及构造满足严格弱序的比较链。

核心方法思路识别信号 / 何时用题号
二分查找lower_bound(第一个不小于target)利用数组有序把单次查询降到O(log n)。维护候选answer:mid处值>=target时记录answer并把right收到mid-1继续向左找更靠前位置,<target时left移到mid+1。比较用>=(而非>)才是下界而非上界,answer初值0表示无元素不小于target。有序数组多次查询第一个不小于(或第一个大于)某值的位置/下标,线性扫描会超时时3088
两有序数组第k小(折半淘汰)利用两数组有序,每轮比较各自第half=k/2个元素,较小一侧的前half个必排在第k名之前可整体丢弃并把k减去丢弃数量,O(log k)轮定位答案。边界:某数组空则去另一数组取第remain个、remain==1取两队首较小者、checkA/checkB越界夹到末尾。两个分别有序的数组求合并后第k小元素,且多次询问、不能真正归并到第k个时3089
离散化(排序去重+二分映射排名)把全部数复制排序后用unique去重得严格递增数组values,对每个原元素用lower_bound二分找其在values中首次出现位置+1作为排名(相同值得同名次)。把可能很大的数值压缩到1..m连续小整数,单次查询O(log m)。需要把每个数替换成它在所有不同取值中的名次/排名,或把大值域压缩到小连续区间(坐标压缩)时3094
结构体多关键字自定义排序封装结构体并预计算复合键(如sum=a+b)避免比较时重复计算,给std::sort传lambda比较链按优先级逐级比较,前一级分出胜负就立即返回。注意各键升降序方向、相等返回false以满足严格弱序。按多个关键字(主键/次键/末键,方向可不同)对记录排序,需要结果唯一稳定时3095
3088

查找第一个不小于目标值的位置

基础
二分查找(lower_bound)二分查找之lower_bound(第一个不小于target的下标)
顺序表(一维数组/vector)
核心思想

数组 scores 已按非递减顺序存储,要找第一个不小于目标值 x 的下标。每次询问若线性扫描是 O(n),q 次会超时;利用有序性改用二分查找把单次降到 O(log n)。二分时用变量 answer 记录候选:mid 处值 ≥ target 就先记下并向左收缩找更靠前的位置,否则到右半部分继续。

关键代码
int left = 1, right = scores.size() - 1, answer = 0;
while (left <= right) {
    int mid = left + (right - left) / 2;
    if (scores[mid] >= target) {   // mid 可能是答案
        answer = mid;
        right = mid - 1;           // 继续向左找更靠前的
    } else {
        left = mid + 1;            // 答案在右半部分
    }
}
return answer;
图解
_
10
20
20
30
40
下标 1..5(下标0为哨兵不用),已升序;查询 target = 20,answer 初值 0
第1步
10
20
20
30
40
left=1,right=5 → mid=3,scores[3]=20 ≥ 20:记 answer=3,right=mid-1=2,向左收缩
第2步
10
20
20
30
40
left=1,right=2 → mid=1,scores[1]=10 < 20:左半全淘汰,left=mid+1=2
第3步
10
20
20
30
40
left=2,right=2 → mid=2,scores[2]=20 ≥ 20:记 answer=2,right=mid-1=1
第4步
10
20
20
30
40
此时 left=2 > right=1,循环结束,返回 answer=2,即第一个不小于 20 的下标
复杂度与易错
  • 时间 每次询问 O(log n),总体 O((n+q) + q·log n)
  • 空间 O(n) 存数组,二分本身 O(1)
  • 数组必须有序二分才成立;answer 初值 0 表示没有元素 ≥ target(x 比所有元素都大)
  • 比较用 >= 而非 >,否则找到的是第一个严格大于 target 的位置(变成 upper_bound)
  • 循环条件 left <= right 配合 right=mid-1,写成开区间易死循环或漏判
  • 下标从 1 开始,right 初始化为 size()-1;mid = left+(right-left)/2 防溢出
复用技巧
二分查找下界写法(>= 时记录答案并左收)用 answer 变量保存候选下标避免边界漏判mid = left + (right-left)/2 防止下标溢出数组从下标1开始,搜索区间设为[1, n]ios::sync_with_stdio(false)+cin.tie(nullptr) 关同步加速IO
3089

两个有序数组中的第 k 小元素

进阶
折半淘汰法(双指针二分排除)求第 k 小两个有序数组合并求第k小(折半淘汰/双指针二分)
顺序表(一维数组/vector)
核心思想

两个分别升序的数组 a、b,求合并后第 k 小元素。利用有序性,每轮比较两侧各自第 half=k/2 个元素,较小一侧的前 half 个必定排在第 k 名之前,整体丢弃并把 k 减去丢弃数量,从而每轮把 k 大约减半,O(log k) 轮即可定位答案,无需真正归并。

关键代码
int half = remain / 2;
int checkA = min(startA + half - 1, n);   // 越界夹到末尾
int checkB = min(startB + half - 1, m);
if (a[checkA] <= b[checkB]) {             // a 侧前缀更小,整体淘汰
    remain -= (checkA - startA + 1);
    startA = checkA + 1;
} else {                                   // b 侧前缀更小,整体淘汰
    remain -= (checkB - startB + 1);
    startB = checkB + 1;
}
// 出口:某侧空 -> 另一侧取第 remain 个;remain==1 -> 取两队首较小者
图解
初始
1
3
5
a
2
4
6
b
求第 k=4 小:startA=startB=1,remain=4
第1步
1
3
5
a
2
4
6
b
half=2:比较 a[2]=3 与 b[2]=4,3≤4,淘汰 a 前 2 个(1,3);startA=3,remain=4-2=2
第2步
5
a
2
4
6
b
half=1:比较 a[3]=5 与 b[1]=2,2 更小,淘汰 b 的 1 个(2);startB=2,remain=2-1=1
出口
5
↔
4
remain==1:取 min(a[3]=5, b[2]=4) = 4,即答案
复杂度与易错
  • 时间 单次询问 O(log(k)) ≈ O(log(n+m)),q 次共 O(q·log(n+m));建表 O(n+m)
  • 空间 O(n+m) 存数组,查询本身仅 O(1) 额外空间(迭代无递归栈)
  • 比较的是各侧第 half 个元素 a[startA+half-1],不是中点;下标 -1 不能漏
  • checkA/checkB 可能越界,必须夹到 n/m,否则数组越界访问
  • 淘汰数量 removed=checkA-startA+1(含端点),k 要减真实数量而非固定 half
  • remain==1 的出口必须有,否则 half=0 永远排不动会死循环
  • 某数组已空(startA>n)时直接去另一侧取第 remain 个,下标是 startB+remain-1
复用技巧
折半淘汰:每轮按 k/2 排除一侧前缀,把 k 减半双指针 startA/startB 标记两数组当前有效起点,避免真正删除元素下标从 1 开始 + 哨兵式越界夹取(checkA>n 时夹到 n)简化边界迭代代替递归,避免递归栈且省去函数调用开销ios::sync_with_stdio(false)+cin.tie 关闭同步加速读入应对多次询问
3094

数组元素的离散化排名

基础
离散化 + lower_bound 二分查找排名离散化(坐标压缩):去重排序后用二分求每个元素的排名
顺序表(vector动态数组)有序数组(用于二分查找)
核心思想

把每个数替换成它在所有不同取值里由小到大的名次。做法是离散化:复制全部数后排序,用 unique 去重得到严格递增的 values,再对每个原始元素用 lower_bound 二分找到它第一次出现的位置,下标加一即排名。相同的值得到相同名次。

用二分而非逐个扫描,是因为 values 已有序,单次查询从 O(m) 降到 O(log m),把可能很大的数值压缩到 1..m 的连续小整数。

关键代码
sort(values.begin(), values.end());
values.erase(unique(values.begin(), values.end()), values.end());

for (int i = 0; i < n; ++i) {
    int rank = lower_bound(values.begin(), values.end(), a[i])
               - values.begin() + 1;
    cout << rank;
    if (i + 1 < n) cout << ' ';
}
图解
第1步
40
10
40
30
10
原数组 a = [40, 10, 40, 30, 10](要把每个数换成它的名次)
第2步
10
10
30
40
40
复制后 sort 得到 [10, 10, 30, 40, 40](升序,但仍有重复)
第3步
10
→
30
→
40
unique + erase 去重得 values = [10, 30, 40],下标 0,1,2 对应名次 1,2,3
第4步
10
30
40
lower_bound(40)=下标2 → 排名3
10
30
40
对 a[0]=40 二分定位到下标 2,排名 = 2 + 1 = 3(高亮为命中格)
第5步
a0=40
a1=10
a2=40
a3=30
a4=10
3
1
3
2
1
按原顺序映射:40→3, 10→1, 40→3, 30→2, 10→1,相同值得到相同名次
输出
3
1
3
2
1
结果 "3 1 3 2 1",元素间用空格分隔,末尾无多余空格
复杂度与易错
  • 时间 O(n log n),排序与 n 次二分查找均为对数级
  • 空间 O(n),需保存原数组与去重后的有序数组
  • 排名从 1 开始计数,lower_bound 减 begin 得到的是 0-based 下标,必须 +1,漏掉会全部少 1
  • 必须先 sort 再 unique,unique 只能去除相邻重复,未排序会去重失败
  • unique 不会真正缩短容器,要配合 erase 删掉尾部残留元素
  • 相同值应得相同名次,要用 lower_bound(第一个不小于的位置),不能误用 upper_bound
  • 输出时用 i+1<n 判断边界,末尾不要多打空格
复用技巧
离散化(排序+unique去重+lower_bound映射)lower_bound返回迭代器减begin得到下标ios::sync_with_stdio(false)与cin.tie(nullptr)加速输入输出下标加一得到1-based排名
3095

学生成绩排序(按总分、第二门成绩、学号排序)

基础
多关键字稳定比较 + std::sort结构体多关键字自定义排序
结构体数组(vector)
核心思想

把每个学生封装成结构体,读入时就预先算好总分 sum=a+b,避免在比较时反复相加。排序用多关键字字典序:先按总分降序,总分相同看第二门成绩 b 降序,仍相同则按学号 id 升序,保证结果唯一可复现。

关键代码
sort(students.begin(), students.end(),
     [](const Student& x, const Student& y) {
    if (x.sum != y.sum) return x.sum > y.sum; // 总分降序
    if (x.b   != y.b)   return x.b   > y.b;   // 次门成绩降序
    return x.id < y.id;                       // 学号升序
});
图解
原始
①50+50
sum100 b50
②80+20
sum100 b20
③60+40
sum100 b40
读入即算 sum:三人 sum 全为 100,主键打平,进入次键比较
比较链
sum 相等?
→
b 降序排
→
id 不需要
前一级分出胜负就立即 return;本例 b 各不相同,无需用学号区分
按 b 降序
①b=50
→
③b=40
→
②b=20
b: 50 > 40 > 20,最终顺序 1, 3, 2
输出
1 100
3 100
2 100
逐行输出 学号 与 总分
复杂度与易错
  • 时间 O(n log n),std::sort 主导
  • 空间 O(n),存放结构体数组
  • 比较器必须满足严格弱序:相等时返回 false,用 != 逐级判断才安全,否则 sort 可能崩溃
  • 主键 sum 与次键 b 都是降序(>),但学号 id 是升序(<),方向不要写反
  • 总分相同时若漏掉 id 这一级,输出顺序可能不确定
  • 应预存 sum,比较里直接写 x.a+x.b 会重复计算且易写错
复用技巧
预计算sum避免比较时重复求和lambda多关键字比较器ios::sync_with_stdio(false)+cin.tie加速IO比较链逐级short-circuit返回用'\n'代替endl减少刷新
🧩

跨章节通用技巧

11 项

这些是反复出现、跨题复用的“底层套路”,记住它们比记单题更划算。

数组模拟指针/静态存储建树

用结构体数组或vector配合int下标代替裸指针new/delete来存二叉树或链表,约定下标0(或-1)表示空指针/哨兵,节点编号从1开始。避免内存管理麻烦,便于父子关系用i/2、2i等整除计算,是树题的通用存储手段。

哨兵结点/哨兵值简化边界

带头结点的链表用哨兵头结点统一插入与边界处理;树用下标0/-1当空指针哨兵;根的parent置0作上行终止哨兵;二分越界时夹到数组末尾当哨兵。消除大量空指针判断与边界特判。

迭代+显式栈代替递归避免爆栈

当n可达10万、树可能退化成链时,递归深度可达10万会运行时栈溢出,故用手写顺序栈/显式栈做非递归DFS/先序遍历(常配state或depth标记模拟递归帧),并查集find也用循环代替递归。是树与图遍历题的关键防御手段。

双指针归并扫描

利用两序列已有序,用两个指针同向(或折半)推进比较,实现归并求交并差集、求第k小;扫描时成段跳过相同值实现去重。把需要排序或查找的操作降到线性/对数。

单调栈

维护栈内元素单调(递增/递减),从一侧扫描,弹出不满足条件的栈顶后栈顶即为答案,每元素进出栈各一次O(n)。用于求下一个更大元素、相向碰撞、相邻消除等局部比较类问题。

邻接表/链式前向星存图

用邻接表(vector)或链式前向星(head/nextEdge数组,边号从1起0当空)存稀疏图,无向边拆成正反两条有向边;遍历某点出边即沿表/链走。配合visited(或dist=-1)标记防重复访问与环。

方向数组dx/dy处理网格四邻

用dx={1,-1,0,0}、dy={0,0,1,-1}两个数组配合循环统一处理上下左右四个方向的移动,先做越界判断再访问网格,避免写四段重复代码。网格BFS/DFS的标配。

visited标记与距离/网格复用防重复

用distance/dist数组(-1未访问)兼作visited标记,或把访问标记直接写回原网格(置0),省去额外标记数组;关键是入队/入栈时就立即标记而非出队时,否则同一节点会被多次加入导致超时甚至死循环。

高精度大整数运算

当数值位数可能超过long long(如长路径拼成的数字、大整数加法),用int数组或vector按十进制逐位存储(下标0是个位/低位在前),竖式从低位对齐逐位带进位相加,主循环后还要单独处理最高位进位,输出时去前导0。

ios::sync_with_stdio(false)+cin.tie(nullptr)加速IO

关闭C++流与C stdio的同步并解绑cin/cout,大幅加速大规模数据的读入读出,是数据量大(n、q达十万级或多次询问)时防止IO超时的标准开头优化。

入度法/无父结点找根

输入只给每个结点的孩子而不直接给根时,用hasParent布尔数组标记所有被当作孩子出现过的编号,唯一没有父亲(入度为0)的结点即为根;并查集中findRoot(i)==i统计根个数同理。不能想当然认为根是1号。

🎯

复习 · 应试建议

  1. 建表/建树看到n达十万级且结构可能退化成链(链表或单边树)时,一律改用迭代+显式栈做非递归遍历,递归会运行时栈溢出——这是本次作业最高频的踩坑点,涉及3046/3048/3054/3056/3066等多题。
  2. 凡是改链/改指针(链表逆置、二叉树展开、next填充),改之前必须先把原孩子/原后继暂存到局部变量,且赋值三句的顺序固定不能乱;Morris式展开记口诀:先把原右子挂到左子树最右前驱,再让right=left,最后left置空。
  3. 单调栈三问要分清:求下一个更大/更小、是否含等号(>=弹得到严格更大)、从左还是从右扫(从右扫栈顶直接给答案更直观);碰撞类(3063)与消除类(3062)本质都是栈顶与当前元素的局部比较。
  4. BFS/DFS的visited一定在入队/入栈那一刻就标记,绝不能等到出队时才标记,否则同一节点重复入队导致超时或死循环;dist=-1可同时兼作未访问标记,网格题可把标记写回原数组省内存。
  5. 无权图(每步代价相同)和网格迷宫求最短路一律BFS(第一次到达即最短);只问可达性BFS/DFS皆可;弱连通分量用并查集(把有向边当无向边),千万别误用Tarjan求强连通。
  6. 数值会超long long的信号:大整数加法、长路径拼成的数字——立即上高精度(逆序数组、低位在前、逐位带进位、主循环后补最高位进位、输出去前导0);加法循环条件必须含carry否则999+1丢最高位。
  7. 二分查找记牢边界模板:lower_bound用>=且right=mid-1、answer记录候选;两有序数组第k小用折半淘汰(每轮比第k/2个、丢小的一侧、k减实际丢弃数)且必须有remain==1出口防死循环。
  8. 输入只给孩子不给根时,用hasParent入度法找无父结点当根,不要默认根是1号(3054/3055/3056/3066都靠这个);并查集统计根个数即连通分量数。
  9. 顺序表去重/求集合运算要利用有序性成段跳过相同值,相等(交集)时两边都要跳过全部重复项;多关键字排序的lambda比较链相等必须返回false以满足严格弱序,各键升降序方向不要写反。
  10. 数组模拟指针(下标0或-1当空)是树与链表静态存储的通用套路,可避免new/delete;数据量大时开头固定加ios::sync_with_stdio(false)+cin.tie(nullptr)防IO超时。