← 目录 第 21 / 60 节 全部 60 节

21 · 递归:出口、递推与栈

把问题照原样拆成同类的小问题:写清出口、保证参数变小、数清递归树的深度与节点数,并知道什么时候必须改成循环。
⭐ 已学范围普及-预计 60 分钟递归递归树爆栈
本节出现的新函数 / 新语法(先看懂再用)

把一个规模为 n 的问题,交给规模更小的同类问题去解决——这就是递归。它能停下来,只因为存在一个不需要再拆的小情形。

先看两个错法,它们分别代表递归的两类死法。

两个错法
int f(int n) { return f(n - 1) + 1; }          // ① 没有出口

int g(int n) {                              // ② 有出口,但同一件事算了又算
    if (n <= 2) return 1;
    return g(n - 1) + g(n - 2);
}

① 的调用链 f(n) → f(n−1) → f(n−2) → …永不停,栈一直往下压,直到用完栈空间,程序收到 SIGSEGV(Windows 上是 0xC00000FD),评测判 RE。② 有出口,但 g(n−1) 展开时已经把 g(n−2)、g(n−3) 都算过一遍,外面又为 g(n−2) 把这一整批下标重新算一遍。调用次数是 φⁿ 级——g(40) 约 3×10⁸ 次,g(50) 到 4×10¹⁰ 次,判 TLE。小数据它跑得飞快,这才是最坑的地方。这个“重复计算”的毛病下一节用记忆化治。

写递归只问三句话:① 最小的情形,答案是多少?② n 的答案能不能用更小的答案拼出来?③ 每次调用,参数是不是真的变小了?三句话答不上任何一句,代码一定是错的。

这一节有三张卡要先看懂:递归函数长什么样、调用一次在内存里留下什么、assert 怎么帮你定位错误。

新函数拆解 · 递归函数
返回类型 f(参数) { if (出口条件) return 基础值; // ① 出口 ... // ② 把问题变小 return f(更小的参数); // ③ 自己调自己 }
组成含义 / 格式要求
出口(base case)不再递归、直接给答案的最小情形。必须存在,且必须能到达。
递推(recursive step)用 f(更小的参数) 表示 f(参数)。这个等式就是数学上的递推关系,照抄即可。
参数必须变小n−1、n/2、左半区间……只要保证每次调用都向出口靠近。参数不变就是无限递归。
返回类型递归函数首先是函数,返回值能被上一层接住、参与运算。要算数就用 int/long long,只做事就写 void。
调用深度从 main 到最底层出口的调用层数。它由“参数从 n 减到出口要走几步”决定,与调用总次数无关。
返回值累加多层结果靠 return 一层层往上传;想往下传就加一个参数(例如当前深度)。
最小可运行示例
示例
long long fact(int n) {              // 求 n!
    if (n <= 1) return 1;            // ① 出口:0! = 1! = 1
    return (long long)n * fact(n - 1);   // ② ③ n! = n × (n-1)!
}

int main() {
    cout << fact(5) << "\n";         // 120
    return 0;
}
注意

fact 的调用链是 fact(5)→fact(4)→…→fact(1),深度 5。语句 return n * fact(n-1)里,“乘 n”这一步要等下层返回后才执行,所以它发生在“回来的路上”。

递归不是“循环的替代品”,它是“把问题定义照抄成代码”。数学定义里有 f(n)=n·f(n−1),代码里就有一行一样的。

新函数拆解 · 栈帧(每次调用在内存里留下的一格)
调用 f → 压入一帧:形参、局部变量、返回地址 f 返回 → 弹出这一帧,返回值交给调用者
组成含义 / 格式要求
一帧装什么这个函数自己的形参、局部变量,以及“返回后从哪一行继续执行”的地址。
一帧多大通常 32~64 字节。函数里定义了局部数组会猛涨:写一句 int t[1000];,每帧立刻多 4KB。
栈有多小全局数组、new/malloc 在堆上,能用几百 MB;函数调用和局部变量在栈上,Linux 常见 8MB,Windows 默认 1MB。
算深度8MB ÷ 48B ≈ 1.7×10⁵ 层。所以递归深度几千是安全的,几十万就要出事,一百万一定 RE。
爆栈症状Windows 弹 0xC00000FD,Linux 收 SIGSEGV,洛谷上都显示 RE;也可能是“输出到一半突然停住”。
与调用次数的区别调用 10⁶ 次、深度 10 的递归完全安全;调用 10⁶ 次、深度 10⁶ 的递归必炸。
最小可运行示例
示例
long long fact(int n) {
    if (n <= 1) return 1;
    return (long long)n * fact(n - 1);   // 每层一帧
}

// fact(100000);   深度 10⁵,约 5MB,8MB 栈刚好够
// fact(1000000);  深度 10⁶,约 50MB,必 RE
注意

把 const int MAXN = 1e6; 的数组写进函数里,是最常见的爆栈原因——递归还没开始,一帧就已经 4MB。大数组一律开在全局。

深度到 10⁵ 时,先想能不能改迭代;实在要递归,就把栈调大(ulimit -s 或编译选项),但那只是把上限往后推。

新函数拆解 · assert(断言)
assert(条件表达式);
组成含义 / 格式要求
头文件<cassert>,bits/stdc++.h 里已经包含,不用另写。
语义条件为真 → 什么都不做;条件为假 → 打印表达式、文件名、行号,然后 abort() 结束程序。
用在哪在递归入口写 assert(k >= 0),把“这个参数不该是负数”这个假设变成一条会喊的检查。
怎么关掉编译时定义 NDEBUG(g++ -DNDEBUG)后所有 assert 消失,提交时零开销。
别写副作用assert 里的表达式在 NDEBUG 下根本不执行,所以 assert(f(x)++) 这种写法是陷阱。
别当输入校验输入不合法时应该输出答案,而不是让程序崩在断言上。assert 是调试工具,不是容错机制。
最小可运行示例
示例
long long fact(int n) {
    assert(n >= 0 && n <= 20);   // 21! 就超出 long long 了
    if (n <= 1) return 1;
    return (long long)n * fact(n - 1);
}

// fact(-1) → Assertion `n >= 0 && n <= 20' failed. 程序立刻停下
注意

assert 最大的价值是“把隐含前提写出来”。递归里最常见的隐含前提是“下标不越界”“深度没超限”,这两条写成断言,调试时间能从一小时缩到一分钟。

本地加 assert、提交前不用删——NDEBUG 一开它们就没了。

递归迭代(循环)
代码形状照抄数学定义,n 的方案里出现 n−1手工维护一个“当前状态”变量
空间深度 × 每帧字节,受栈大小硬限制O(1),与规模无关
速度每层一次函数调用,慢 2~5 倍最快
适合结构本身嵌套:树、分治、括号、嵌套表达式一条链、顺序一眼可见:累加、递推
主要风险爆栈(RE)、重复计算(TLE)下标写错、边界写错(WA)
例题 · 赦免战俘洛谷 P5461普及-去洛谷做这题 ↗

战俘排成 2ⁿ × 2ⁿ 的方阵(0 ≤ n ≤ 10),格子按 (行, 列) 编号,从 1 开始。

赦免规则:对任意一个 2ᵏ × 2ᵏ(k ≥ 1)的方块,把它等分成四个 2ᵏ⁻¹ × 2ᵏ⁻¹ 的小方块,左上角那一块的人全部赦免(这一块不再细分),另外三块各自继续按同一条规则处理。

输出最终方阵:被赦免的格子输出 0,没被赦免的输出 1。共 2ⁿ 行,每行 2ⁿ 个数,用空格分隔。

输入: 3 输出: 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 1 1 0 0 0 1 0 0 0 1 0 0 1 1 0 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1
例题怎么想:把“一整块”当作一个参数

规则本身就是递归的:“2ᵏ 的方块”要按同一条规则处理四个 2ᵏ⁻¹ 的方块。所以函数签名直接写 solve(x, y, k),含义是处理左上角在 (x, y)、边长 2ᵏ 的方块。这一句话必须能独立说清,后面每次调用都只信这句话,不再往上想。

三要素对齐:出口是 k == 0(边长 1,没得再分);递推是先把自己左上角那半块涂成 0,然后对另外三块各调一次 solve(..., k - 1);规模是 k 每次减 1,一定到 0。

递归树长这样(n = 3 时):

递归树:三叉,深度 n
solve(1,1,3)                        涂 4×4,然后 3 个子调用
├─ solve(1,5,2)   右上        涂 2×2
│  ├─ solve(1,7,1)            涂 1 格
│  ├─ solve(3,5,1)
│  └─ solve(3,7,1)
├─ solve(5,1,2)   左下
│  ├─ solve(5,3,1)
│  ├─ solve(7,1,1)
│  └─ solve(7,3,1)
└─ solve(5,5,2)   右下
   ├─ solve(5,7,1)
   ├─ solve(7,5,1)
   └─ solve(7,7,1)

拿到递归树就问三个量:深度= n(决定栈深度);节点数= 1+3+9+…+3ⁿ(决定调用次数);每个节点自己干多少活= 涂 4ᵏ⁻¹ 格。这三样乘起来就是复杂度,下面数学补给里会算到精确值 Θ(4ⁿ)。

还有一个顺序上的自由:三块互不重叠,谁先谁后都行。这一点很重要——它说明递归版和迭代版必然给出同一张图,后面“递归转迭代”就靠它。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1 << 10;         // 2^10 = 1024,n ≤ 10 时的最大边长
int a[MAXN + 5][MAXN + 5];       // 必须放全局:1024² 个 int = 4MB,放栈上直接炸
int N;                            // 边长 2^n

// 处理左上角 (x,y)、边长 2^k 的方块
void solve(int x, int y, int k) {
    assert(k >= 0);               // 调试用:k 不该变成负数
    if (k == 0) return;           // 出口:边长 1,没有可分的
    int h = 1 << (k - 1);         // 半边长 2^(k-1)
    for (int i = x; i < x + h; ++i)
        for (int j = y; j < y + h; ++j)
            a[i][j] = 0;          // 左上角那半块整体赦免

    solve(x + h, y,     k - 1);   // 右上块:行下移 h
    solve(x,     y + h, k - 1);   // 左下块:列右移 h
    solve(x + h, y + h, k - 1);   // 右下块:行、列都移 h
}

int main() {
    ios::sync_with_stdio(false);  // 最多 10⁶ 个数要输出,必须提速
    cin.tie(nullptr);

    int n; cin >> n;
    N = 1 << n;
    for (int i = 1; i <= N; ++i)
        for (int j = 1; j <= N; ++j)
            a[i][j] = 1;          // 先假设谁都没被赦免

    solve(1, 1, n);               // 只负责把该是 0 的格子涂成 0

    for (int i = 1; i <= N; ++i)
        for (int j = 1; j <= N; ++j)
            cout << a[i][j] << (j == N ? '\n' : ' ');
    return 0;
}
逐行拆解
代码片段含义与理由
const int MAXN = 1 << 10;1024。1 << k 就是 2 的 k 次方,比 pow(2, k) 快且没有浮点误差。
int a[MAXN + 5][MAXN + 5];4MB 出头。写在 main 里会放栈上,栈只有 1~8MB,n = 10 时必 RE——这是本节第一号坑。
void solve(int x, int y, int k)签名本身就是问题的定义:哪一块、多大。三个参数缺一个都描述不清。
assert(k >= 0);把“k 不会变负”写下来。真出 bug 时它会立刻停下并报行号,比输出一堆错图更好查。
if (k == 0) return;出口放最前面,且判断的是“最小的规模”。漏了它,h = 1<<(-1) 就是未定义行为。
int h = 1 << (k - 1);半边长。k = 1 时 h = 1,此时涂的就是 1 格,正好对上 n = 1 的图。
for (i = x; i < x + h; ++i)只扫左上角 h×h。注意是 < x + h,写成 <= 会多涂一行。
a[i][j] = 0;递归函数只做“涂 0”一件事。0 和 1 谁表示赦免不重要,重要的是入口统一:先全 1,再涂 0。
solve(x + h, y, k - 1);右上块。三块的顺序可以随便换,因为它们的格子互不重叠——这是“递归树能拆开算”的依据。
a[i][j] = 1;初始化。全局数组默认是 0,不写这一句,整张图全是 0,样例第一行就对不上。
(j == N ? '\n' : ' ')行末只换行不加空格。10⁶ 个数,格式错一处就是整片 WA。

同一件事换一种遍历顺序:递归是“先钻到底再回来”(深度优先),迭代可以“按块的大小从大到小”一层一层涂(宽度优先)。两种顺序结果相同,因为不同层的涂色位置互不重叠:被递归跳过的方块整块已经在上一层变成 0,再涂一遍也还是 0。

递归转迭代
// 迭代版:把递归改成按层扫描,不写函数调用
for (int k = n; k >= 1; --k) {          // 块的大小从 2^n 降到 2
    int len = 1 << k, h = len >> 1;
    for (int x = 1; x + len - 1 <= N; x += len)
        for (int y = 1; y + len - 1 <= N; y += len)
            for (int i = x; i < x + h; ++i)
                for (int j = y; j < y + h; ++j)
                    a[i][j] = 0;
}

代价对比:递归版一共涂 4ⁿ − 3ⁿ = 989527 格(n = 10),迭代版把一些已经是 0 的格子又涂了一遍,共 2621440 次赋值,是前者的 2.65 倍。这点差别只是常数,不改变结论——换来的是彻底摆脱栈深度的限制,代价几乎为零。

这一节的 6 个典型错法(附症状)

RE,且只在 n 大的时候:把 int a[1029][1029] 写进了 main。4MB 局部数组直接超出栈限额。放到全局(函数外面)即可。

WA,整张图几乎全是 0:涂色循环写成 i <= x + h 或 j <= y + h。每块多涂一行一列,多出来的那行一列又会被下一层继续“传染”,n = 3 时最终输出全 0。边界条件只差一个等号,症状却比想象中惨烈得多。

RE / 无限递归:三句递归调用里有一句参数没变(比如写成 solve(x, y, k)),或者出口写成 if (k == 0) return; 却把参数改成 k - 1 之外的东西。本地表现为程序卡死后崩,评测显示 RE。

WA 全 0:忘了初始化 a[i][j] = 1。全局数组默认全 0,递归只负责涂 0,于是整张图全 0。

WA,输出尺寸不对:把 N = 1 << n 写成 N = n。n = 3 时输出 3×3 而不是 8×8。

TLE:10⁶ 个数用 endl 换行,或者没写 ios::sync_with_stdio(false)。

递归专属的三个坑(和本题无关,但每次写递归都会遇到)

RE(爆栈):递归深度 10⁶ 却每帧还带一个局部数组。int t[1000] 让每帧多 4KB,25 层就吃掉 100KB,几千层就炸。

TLE(重复计算):f(n) = f(n-1) + f(n-2) 直接递归,n = 40 时调用约 3×10⁸ 次。调用次数是 φⁿ 级别,不是 n 级别——这种“看着像 O(n)”的错觉是本组最大的坑。

WA(返回值类型太小):递归结果一路 return 上来,中间任何一层用了 int,结果就在那一层被截断。阶乘 21! 就超 int,用 long long。

自己动手复现

关掉页面,从空文件把 P5461 敲一遍并提交,n = 10 也要在 1 秒内出结果。

改一改 ①:把 int a[MAXN+5][MAXN+5]; 挪进 main,原样提交,亲眼看一次 RE,再把 MAXN 改成 1 << 8 让它“能跑”——这就是随机 WA 的来源。

改一改 ②:把 assert(k >= 0) 改成 assert(k > 0),输入 3,看断言在哪一行触发、打印了什么。这一分钟能让你以后一眼认出断言失败。

改一改 ③:把 solve 整段删掉,换成上面的迭代版,答案必须和样例逐字相同。

改一改 ④:加一句统计,输出最终有多少个 1。n = 1、2、3、4 分别得到 3、9、27、81,猜出它等于 3ⁿ 再往下看数学补给。

数学补给 · 递归树:节点数是时间,深度是空间

把每次函数调用画成一个点,它调用的子调用画成孩子,得到一棵递归树。树上三个量分别对应三个问题:

量对应什么赦免战俘里是多少
深度 h最大栈帧数,决定会不会爆栈n(最多 10)
节点总数函数调用总次数(3ⁿ⁺¹ − 1) / 2,n = 10 时 88573
第 k 层节点内的工作量每次调用自己做的循环4ᵏ⁻¹ 次赋值

节点数用等比数列求和:1 + 3 + 9 + … + 3ⁿ = (3ⁿ⁺¹ − 1) / 2。n = 10 代入得 (177147 − 1)/2 = 88573 次调用。

总工作量要按层加权。第 k 层有 3ⁿ⁻ᵏ 个节点,每个节点涂 (2ᵏ⁻¹)² = 4ᵏ⁻¹ 格,所以

总工作量
        n
T(n) =  Σ  3^(n-k) · 4^(k-1)                 // 等比数列,公比 4/3
       k=1

     =  3^(n-1) · [1 + 4/3 + (4/3)^2 + … + (4/3)^(n-1)]

     =  3^(n-1) · ((4/3)^n − 1) / (4/3 − 1)

     =  4^n − 3^n

n = 10 时 4¹⁰ − 3¹⁰ = 1048576 − 59049 = 989527。它比格子总数 4¹⁰ = 1048576 还小,正好说明每个格子只被涂一次——这是“复杂度恰好等于输出规模”的好算法的标志。

同一个式子也能用递归方程写:T(k) = 3T(k−1) + 4ᵏ⁻¹,T(0) = 0。猜 T(k) = 4ᵏ − 3ᵏ 代进去验证:左边 4ᵏ − 3ᵏ,右边 3(4ᵏ⁻¹ − 3ᵏ⁻¹) + 4ᵏ⁻¹ = 3·4ᵏ⁻¹ + 4ᵏ⁻¹ − 3ᵏ = 4ᵏ − 3ᵏ,相等。所以 T(k) = Θ(4ᵏ) = Θ(N²),N = 2ⁿ。

还有一个更快的写法:把 4ᵏ 提出来,令 U(k) = T(k)/4ᵏ,则 U(k) = (3/4)U(k−1) + 1/4,解得 U(k) = 1 − (3/4)ᵏ。这个“先归一化再解”的手法,是处理“子问题不缩半、而是减一”的递推式的通用招。

换成 N 的语言,递推式就是 T(N) = 3T(N/2) + Θ(N²)。这正是主定理的形式 T(N) = aT(N/b) + O(Nᵈ):a = 3、b = 2、d = 2,比较 log₂3 ≈ 1.585 与 d = 2,因为 d 更大,所以 T(N) = Θ(Nᵈ) = Θ(N²)。第十一节里会系统讲主定理,现在只要会用“比大小”这一步。

最后是空间:调用 88573 次,但栈深度只有 n = 10,每帧约 48 字节,总共不到 1KB。这就是为什么本题用递归毫无风险,而 fact(10⁶) 会立刻 RE——调用次数和栈深度是两个数,永远分开算。

顺带一个可验证的结论:最终的 1 的个数恰好是 3ⁿ。n = 3 数一遍是 27,正是 3³。道理是递归树的每个叶子(k = 0 的节点)对应一个从没被涂过的格子,而叶子有 3ⁿ 个。

举一反三 · 想清楚这些再往下走
  1. 把规则改成“左上、右上两块都赦免,只对下面两块继续分”。此时每个节点涂 2·4ᵏ⁻¹ 格,递归式变成 T(k) = 2T(k−1) + 2·4ᵏ⁻¹。用归一化的办法解出 T(k),它还是 Θ(4ᵏ) 吗?最终 1 的个数还是 3ⁿ 吗?
  2. 斐波那契的朴素递归 f(n) = f(n−1) + f(n−2) 画出 n = 5 的递归树,数一数 f(1) 被算了几次、f(2) 被算了几次。为什么越靠下的项被算的次数越多?这个观察是下一节记忆化的出发点。
  3. 求 n 个数全排列的递归函数有 n! 个叶子,但深度只有 n。什么情况下“调用次数大”是安全的、什么情况下“深度大”才危险?举出两个极端例子。
  4. 二分查找的递归版空间复杂度是多少?它的递归调用出现在函数的最后一句(尾递归),编译器在什么条件下能把它优化成循环?
  5. 汉诺塔 n 层的移动次数是 2ⁿ − 1,递归树的节点数也是 2ⁿ − 1 吗?把它和赦免战俘的树比一比,谁的树更“宽”、谁的更“深”?
自测:能全部回答再进下一节
挑战题 · 幂次方
洛谷 P1010普及-去洛谷做这题 ↗

任何一个正整数都可以拆成若干个 2 的幂之和。例如 137 = 2⁷ + 2³ + 2⁰。

现在约定写法:2⁰ 写成 2(0),2¹ 写成 2,2ᵏ(k ≥ 2)写成 2( k 的表示 ),其中指数 k 也用同一套规则表示。多个幂之间用 + 连接,指数从大到小排列。例如 7 = 2² + 2 + 2⁰ 写成 2(2)+2+2(0),137 写成 2(2(2)+2+2(0))+2(2+2(0))+2(0)。

输入:一个正整数 n(1 ≤ n ≤ 20000)。输出:一行,即 n 的表示,中间不加任何空格。

引导性提示(先只看这里)

先只做一层:把一个数拆成 2 的幂之和,指数从大到小排列。20000 < 2¹⁵,所以指数最大是 14。

指数本身也要用同一套规则表示——这就是递归:同一个函数,参数从 n 变成指数 i,规模严格变小。

0 次幂和 1 次幂是两种特例,不能套 2(...) 的格式;另外想清楚 + 只应该出现在什么位置。

想看思路了?点开完整思路

把 n 看成二进制位:第 i 位是 1,就说明 n 的式子里要出现 2ⁱ。指数从 14 扫到 0,天然满足“从大到小”。

定义 print(n):输出 n 的表示(不带外层括号)。对每一位 i,若第 i 位为 1:先按需输出 +;再分三种情况输出这一项。

三种情况:i = 0 输出 2(0);i = 1 输出 2;i ≥ 2 输出 2(,递归调用 print(i),再输出 )。

为什么一定停:递归只在 i ≥ 2 时发生,而指数每次严格小于上一层,最多 14 层就降到 0 或 1,而这两种情况不再递归。所以既不需要显式出口,也不会爆栈。

为什么对:n 的每个二进制 1 位恰好贡献一个片段,片段之间用 + 连着;每个片段的指数表示由同一个函数、更小的参数给出。不重不漏。

总代价 = 递归树节点数 × 每个节点的 15 次循环。n = 20000 = 2¹⁴+2¹¹+2¹⁰+2⁹+2⁵,二进制只有 5 位是 1,树小到可以忽略,微秒级出答案。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

// 输出 n 的表示,不含外层括号
void print(int n) {
    bool first = true;                 // 是不是第一个片段
    for (int i = 14; i >= 0; --i) {    // 20000 < 2^15,指数最大 14
        if ((n >> i) & 1) {            // 第 i 位是 1,说明要写 2^i
            if (!first) cout << "+";   // 加号只出现在片段之间
            first = false;

            if (i == 0)      cout << "2(0)";              // 特例:0 次幂
            else if (i == 1) cout << "2";                 // 特例:1 次幂
            else { cout << "2("; print(i); cout << ")"; }  // 一般情况:指数递归展开
        }
    }
}

int main() {
    int n; cin >> n;
    print(n);
    cout << "\n";
    return 0;
}
关键片段拆解
片段作用
for (int i = 14; i >= 0; --i)从最高位往低扫,输出顺序天然符合“指数从大到小”。14 是因为 2¹⁴ = 16384 ≤ 20000 < 32768 = 2¹⁵。
if ((n >> i) & 1)取 n 二进制第 i 位。等价于 (n / (1 << i)) % 2,但位运算更短也不会溢出。
if (!first) cout << "+";加号只能加在两个片段之间。第一个片段前面加、或者末尾多一个,都是 WA。
if (i == 0) cout << "2(0)";0 次幂是约定出来的特例。若套一般格式,会输出 2()。
else if (i == 1) cout << "2";1 次幂直接写 2,不写 2(1)。这条最容易被忘,样例第一段就会对不上。
print(i);指数递归展开。只有当 i ≥ 2 才会走到这里,而 i 严格小于 14,所以最多 14 层,一定停。
bool first = true;用“是不是第一个”这一位代替“最后一个不加加号”的判断,省掉回头看的问题。
这题最容易错的地方
  • 漏掉 i == 1 的特例,输出 2(1)。
  • 漏掉 i == 0 的特例,对 0 递归,什么也不输出,得到空的 2()。
  • 加号加了两次:既用 first 判断,又在循环外补了一句,导致开头或结尾多一个 +。
  • 把循环写成 for (int i = 0; i <= 14; ++i),输出顺序变成从小到大,与“指数从大到小”的约定冲突。
延伸阅读 / 参考