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

03 · 多重循环与图形输出

把形状拆成“第 i 行几个空格、几个字符”的两列数,再用双重 for 机械翻译成代码;同时把嵌套循环的执行次数算清楚,学会先估量级再动手。
⭐ 已学范围入门预计 50 分钟嵌套循环图形打表
本节出现的新函数 / 新语法(先看懂再用)

循环嵌套的层数等于图形的维数。一维是“打 10 个星”,二维是“打 n 行、每行若干个”,三维(第 14 节的枚举)是“给 n×n 的每个格子再列出 n 种可能”。这一节只解决二维:外层管行,内层管列,换行属于行。

图形题不考创意,考的是:你能不能在 30 秒内,把眼睛看到的形状写成含 i 的两个表达式。

先记住这个判断标准:图形题的判题方式对新手极其友好——跑一遍,形状歪了就是公式错,形状对了答案基本就对。它把“抽象公式 → 循环边界”这条链压缩成肉眼可验证的产物,所以在入门阶段练它最划算。

例题 · 数字直角三角形洛谷 P5721入门去洛谷做这题 ↗

输入一个整数 n(1 ≤ n ≤ 13),输出一个由数字组成的直角三角形。

第 1 行有 n 个数,第 2 行有 n − 1 个,……,第 n 行有 1 个数;数字从 1 开始依次加 1,每个数固定占 2 个字符,不足两位时在左边补 0。同一行的数之间不加空格。

n ≤ 13 是因为 n = 13 时最大编号 n(n+1)/2 = 91,恰好两位数;再多一位,补零的写法就要换。

输入: 5 输出: 0102030405 06070809 101112 1314 15

这道题的图形有两层结构:行的长度递减,以及编号跨行连续。前者靠内层上界,后者靠一个定义在循环外面的计数器。先把下面两个东西的骨架看清,再谈怎么写。

新函数拆解 · 嵌套 for(双层循环)
for (int i = 1; i <= n; ++i) { // 外层:第几行 for (int j = 1; j <= m(i); ++j) { // 内层:这一行打几个 ...一次只处理一个格子... } }
组成含义 / 格式要求
外层 for控制“第几行”。i 从 1 走到 n,进入内层时 i 固定不动,整轮内层共用同一个 i。
内层 for控制“这一行的第几个”。外层每走一步,内层都从初值重新跑一整轮,不会接着上一行。
内层上界写成 i 的表达式图形题的上界几乎总是含 i 的公式:i、n-i+1、2*i-1、n-i。上界错一个符号,图形立刻歪。
变量名不能重复内层变量与外层同名会遮蔽外层变量 i,外层循环失控;g++ 直接报 warning 或 error。
总次数要会算矩形是 n×m,三角形是 Σ(n−i+1) = n(n+1)/2。写之前先估这个数,超过 10⁸ 就要警惕。
换行的位置换行语句必须与内层 for 平级:一行打完才换。它属于“行”,不属于“格子”。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 3 行 5 列的矩形:外层 3 轮、内层每轮 5 个 → 共 15 个 '*'
    for (int i = 1; i <= 3; ++i) {      // i = 1,2,3
        for (int j = 1; j <= 5; ++j) {  // 每行都从 j = 1 重新开始
            putchar('*');
        }
        putchar('\n');                  // ★ 与内层 for 平级,写进内层图形就散架
    }
    return 0;
}
// 输出:
// *****
// *****
// *****
注意

最常见的 WA:把 putchar('\n') 写进内层 → 每个星号后面都换行,图形变成一条竖线。

内层上界可以写表达式(j <= n - i + 1),因为每进入一次内层都会重新计算上界。

行内计数器 j 每行都归零;跨行累加的计数器必须定义在两层循环之外——这两类变量别混。

嵌套三层以上时,先写清每层“代表什么”,否则下标含义会自己骗自己。

新函数拆解 · putchar(输出单个字符)
int putchar(int c); // 把一个字符写进标准输出,返回写出的字符
组成含义 / 格式要求
参数 c要输出的字符,用单引号写:'*'、' '、'\n'。形参是 int,实际传的是它的 ASCII 码。
返回值成功返回写出的那个字符(转成 int),失败返回 EOF。图形题里基本不用管。
'0' + d数字字符在 ASCII 里是连续的:'0' = 48,所以 '0' + 7 就是字符 '7'。d 必须在 0~9 之间。
'a' + k同理,'a' + 2 是 'c',用于循环打一串字母。
速度一次一个字节,比 cout << 字符 快 2~5 倍。10⁶ 级字符输出优先用它。
必须用单引号putchar("*") 传的是字符串指针,类型不符,编译不过。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int main() {
    putchar('*');            // *
    putchar(' ');            // 一个空格
    putchar('\n');           // 换行
    putchar('0' + 7);        // 数字 7 → 字符 '7'
    putchar('0' + 42 % 10);  // 先取个位 2,再变字符 '2'
    // putchar("*");         // ✗ 编译错误:双引号是字符串,不能当字符用
    return 0;
}
注意

putchar 一次只能打一个字符;要打整段文字用 cout 或 puts。

要打多位数,要么自己拆十位个位(本节例题),要么等后面学 printf("%02d", x)。

'0' + x 只在 x ∈ [0,9] 时是数字字符;x = 10 会打出字符 ':',这是“补零写错”最常见的翻车点。

第一步不是写代码,是把图形翻译成两列数

任何字符图形都只有三件事:第 i 行先打几个空格、再打几个什么字符、打完换行。把前两件事写成含 i 的表达式,代码就只剩机械翻译。

例题 P5721 没有空格,只有“两位数”。两位数固定占 2 个字符,所以每行字符数 = 2 × 每行个数:

行 i空格数数的个数字符数
10n2n
20n − 12(n − 1)
i0n − i + 12(n − i + 1)
n012

两列数一写出来,循环边界就定了:外层 i = 1..n,内层 j = 1..n-i+1,内层循环体只干一件事——编号加一,然后打两位。

关键认知:编号 cnt 要跨行连续,所以它定义在所有 for 之外;行内计数器 j 每行归零,跨行计数器 cnt 从不归零。分清这两类变量,多重循环就不会乱。

核心 9 行(片段:嵌在 main 内,n 已读入)
int cnt = 0;                                  // 跨行累加,必须在两层循环之外
for (int i = 1; i <= n; ++i) {                // 外层 = 第几行
    for (int j = 1; j <= n - i + 1; ++j) {    // 内层 = 这一行第几个数
        ++cnt;
        if (cnt < 10) putchar('0');           // 一位数,补前导 0
        if (cnt >= 10) putchar('0' + cnt / 10);
        putchar('0' + cnt % 10);
    }
    putchar('\n');                            // 换行属于“行”,所以在外层
}

复杂度:内层共执行 Σ(n−i+1) = n(n+1)/2 次,n ≤ 13 时是 91 次;输出字符数 182。这个规模怎么写都不会超时,所以图形题的时间全花在“公式对不对”上,而不是“快不快”上。

完整代码 · P5721 数字直角三角形
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    int cnt = 0;                                  // 编号跨行连续,所以放在循环外
    for (int i = 1; i <= n; ++i) {                // i:第几行
        for (int j = 1; j <= n - i + 1; ++j) {    // 第 i 行只有 n-i+1 个数
            ++cnt;                                // 先加再用:第一个数必须是 1
            if (cnt < 10) putchar('0');           // 一位数补前导 0,凑够 2 个字符
            if (cnt >= 10) putchar('0' + cnt / 10);  // 两位数才打十位
            putchar('0' + cnt % 10);              // 个位任何情况都要打
        }
        putchar('\n');                            // 换行与内层 for 平级
    }
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>万能头,cin/cout 与 putchar 都在里面,竞赛标准写法。
int cnt = 0;初值必须是 0 且写在双层循环外。写进内层 → 每行都从 1 重数,图形变成“每行 0102…”。
for (int i = 1; i <= n; ++i)外层走 n 次,一次负责一整行。i 只在这里被修改。
j <= n - i + 1内层上界随 i 变:i=1 时打 n 个,i=n 时打 1 个。“每行少一个”全靠这一处。
++cnt;先自增再输出。cnt 的初值 0 让第一个数是 1。
if (cnt < 10) putchar('0');补前导 0。这里不能写 else,因为下面还有两句要执行。
if (cnt >= 10) putchar('0' + cnt / 10);只有两位数才打十位。n ≤ 13 → cnt ≤ 91,两位一定够。
putchar('0' + cnt % 10);'0' + 数字 = 数字字符;cnt % 10 取个位,永远要打。
putchar('\n');位置与内层 for 平级:一行结束才换行。放进内层就变成每个数一行。
return 0;正常结束。评测机看到 0 才认为程序跑完了。
换了形状,只改两个数

所有基础字符图形都是“先空格、后字符”。把这两列的公式背下来,剩下的都是打字工作:

图形第 i 行空格数第 i 行字符数内层怎么写
n×n 方阵0n一个内层:1..n
左下三角0i一个内层:1..i
左上三角0n − i + 1一个内层:1..n-i+1
右下三角n − ii两个内层:1..n-i,再 1..i
等腰三角形n − i2i − 1两个内层:1..n-i,再 1..2*i-1
倒等腰i − 12(n − i) + 1两个内层:1..i-1,再 1..2*(n-i)+1

两列数相加就是这一行实际打出的字符总数——这意味着行末绝不能多打一个空格,公式里没有它。同一份代码打三种形状,只改上界:

一份代码,三种图形(n = 4 时见下方推演)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {                 // ① 方阵:每行都 n 个
        for (int j = 1; j <= n; ++j) putchar('*');
        putchar('\n');
    }
    putchar('\n');                                 // 图形之间空一行,方便肉眼对比

    for (int i = 1; i <= n; ++i) {                 // ② 左下三角:每行 i 个
        for (int j = 1; j <= i; ++j) putchar('*');
        putchar('\n');
    }
    putchar('\n');

    for (int i = 1; i <= n; ++i) {                 // ③ 等腰:先空格,后星号
        for (int j = 1; j <= n - i; ++j) putchar(' ');      // 空格让星号居中
        for (int j = 1; j <= 2 * i - 1; ++j) putchar('*');  // 每行多 2 个
        putchar('\n');
    }
    return 0;
}

n = 4 时第 ③ 段的输出是 3 空格+1 星、2 空格+3 星、1 空格+5 星、0 空格+7 星。星号总数 1+3+5+7 = 16 = 4²,这个等式后面数学补给里会推。

内层换成一件事:打一张表

图形题的骨架可以脱离“星号”复用:把内层那一刻要输出的东西换掉,就能打任意二维表格。下面打 n×n 乘法表,每格右对齐占 4 列(取 n ≤ 99,保证 i·j ≤ 9801 放得下)。

n×n 乘法表,每格 4 列右对齐
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i) {                 // 外层:第 i 行
        for (int j = 1; j <= n; ++j) {             // 内层:第 j 列,共 n² 格
            int v = i * j;                         // 这一格的值
            int w = 1, t = v;                      // w = v 的十进制位数,至少 1 位
            while (t >= 10) { t /= 10; ++w; }      // 第三层循环:每除一次 10 少一位
            for (int k = 1; k <= 4 - w; ++k) putchar(' ');   // 右对齐:先补空格
            cout << v;                             // 再打数字本身
        }
        putchar('\n');                             // 换行只在外层
    }
    return 0;
}
三重循环是怎么嵌起来的
代码片段含义与理由
int w = 1, t = v;位数计数器。任何正整数至少有 1 位,所以从 1 起步。
while (t >= 10) { t /= 10; ++w; }第三层循环,次数 = 位数 − 1,由数据决定而不是由 n 决定。这是“多重循环”里最轻的一种。
for (int k = 1; k <= 4 - w; ++k) putchar(' ');右对齐的关键:先补齐 4 − w 个空格再打数。w = 4 时循环 0 次,也是对的。
cout << v;数字交给 cout,不用自己拆位输出。
putchar('\n');与内层 for 平级。写进内层 → 每个格子占一行,n² 行。

这段代码里 putchar 与 cout 混着用是安全的,因为默认没有关掉 sync_with_stdio,两套流共享同一个缓冲顺序。第 1 节那句“关了同步就别混用”说的正是这个前提——一旦写了 ios::sync_with_stdio(false),就必须全程只用一套。

总工作量 ≈ 4n²。n = 99 时约 4×10⁴,瞬间跑完;把 n 提到 10⁵ 就是 4×10¹⁰,而且输出本身有几百 GB——所以“先把量级算出来”永远排在“先写循环”前面。

图形题的固定四步

① 手画 n = 4 的目标图形,数出每行的空格数与字符数,写成含 i 的表达式。

② 估总量:总字符数(n²、n(n+1)/2、n(n+1) 这些量级)和总循环次数,确认不超时、不超输出限制。

③ 外层 for 行、内层 for 列,换行必须与内层平级。

④ 用 n = 1、n = 2 和最大数据各跑一次,重点看行末有没有多余空格、末尾有没有多余空行。

图形输出 6 个高频错误(都带具体症状)

换行写进内层。症状:WA,输出变成一列竖线(每个字符一行)。修法:把 putchar('\n') 的缩进与内层 for 对齐,它不属于格子。

内层上界忘了跟 i 变。写成 j <= n 得到方阵;写成 j <= i 得到上小下大的三角形。症状:WA,形状不对但“看着很像”,最容易自我怀疑。

行末多一个空格。公式里没有这一格,多打就是错。症状:逐字节比对的 OJ 判 WA 或提示格式错误,部分 OJ 会放过,但别赌。修法:空格只打在字符前面。

补零只补一半。写成 if (cnt < 10) putchar('0'); putchar('0' + cnt); —— 第二句仍然只打一个字符,cnt = 12 时会打出乱码。症状:n ≥ 5 就 WA,n ≤ 4 反而全对。

putchar 用了双引号。putchar("\n") 症状:CE,报 no matching function for call to 'putchar(const char [2])'。

内层上界写成常量造成 TLE。症状:n ≤ 2000 却跑 3 秒以上。三层各 2000 就是 8×10⁹ 次,必须先算 Σ 再写。

自己动手复现

把例题不看页面敲一遍,提交 P5721,输入 5,核对输出是否与题面逐字符一致。

把 n - i + 1 改成 i 再提交:图形变成上小下大,但编号依然连续——因为 cnt 从不归零。想清楚为什么编号还对,只有形状错了。

再把外层改成倒序 for (int i = n; i >= 1; --i),先预测输出再运行。预测错了,说明你还没分清“i 的走向”和“图形的走向”。

最后加第三段:n = 4 时输出金字塔,要求 1、3、5、7 个星号。把内层拆成“先打 n−i 个空格,再打 2i−1 个星”,两个 for 各自独立,不要合在一起。

数学补给 · 等差数列与三角数:先算总量,再动手打

图形里每一行都在重复同一件事,所以“总量”必然是等差求和。先记住这两张脸:

第 i 行有几个元素总元素数n = 1000 时常见图形
n(常数)n²10⁶方阵
in(n+1)/25×10⁵左下三角
2i − 1n²10⁶等腰三角形
2(n − i) + 1n²10⁶倒等腰

第二个公式的来历:Σi=1..n(2i − 1) = 1 + 3 + 5 + … + (2n − 1) = n²。前 n 个奇数之和是平方数——所以金字塔的星号总数是 n²,n = 4 时必须打出 16 个星,少了就是公式漏了一行。

还有一个常用反解:给定项数 N,求最小的 k 使 k(k+1)/2 ≥ N。即 k = ⌈(√(8N+1) − 1) / 2⌉。N = 10⁷ 时 k ≈ 4472,所以挑战题里那个 while 最多转 4472 圈,比开数组既快又省内存。

结论:写图形题前先算“总共要输出多少字符”。超过 10⁷ 个字符的图形,出题人一定不会让你全打出来,他会改成问“第 k 行第 m 个是什么”——那时你需要的就不是循环,是公式。

举一反三 · 想清楚这些再往下走
  1. 把例题内层上界 n - i + 1 换成 i,编号仍然连续。为什么?如果题目改成“每行都从 1 重新编号”,代码里该改哪一行、删哪一个变量?
  2. 输出 n 行 m 列的表,第 i 行第 j 列写 i * j,n、m ≤ 2000。这一格最大是多少?用 int 够不够?(先算 2000×2000,再想 int 上限 2.1×10⁹)
  3. 如果题目只要第 k 行、不要整张图,双重循环还该不该写?能不能用一条含 k 的公式直接算出这一行?这正是从“打表”走向“公式”的第一步。
  4. 空心三角形只打边界字符,中间打空格。怎么用 if 判断“这个位置在不在边界上”?注意:输出字符变少了,循环次数一个都没少——为什么?
  5. 同样输出 10⁶ 个字符,cout << '*' 和 putchar('*') 差在哪一步?已经写了 ios::sync_with_stdio(false) 为什么还可能 TLE?
  6. 如果在例题里再加一层循环(每个格子再枚举 k 次),总次数从 n² 变成 n³。n = 500 时是多少次?为什么算法题里“n 多一个数量级”经常等于“重写算法”?
自测:能全部回答再进下一节
挑战题 · Cantor 表
洛谷 P1014普及-去洛谷做这题 ↗

把全体正有理数排成下面的表,第 r 行第 c 列写的就是 r/c:
1/1 1/2 1/3 1/4 …
2/1 2/2 2/3 …
3/1 3/2 …
4/1 …

再按 Z 字形给表中每一项编号:第 1 项 1/1,第 2 项 1/2,第 3 项 2/1,第 4 项 3/1,第 5 项 2/2,第 6 项 1/3,第 7 项 1/4,……也就是说,编号沿着对角线来回往返,一条一条扫过整张表。

输入格式:一个整数 N(1 ≤ N ≤ 10⁷)。输出格式:一行,形如 a/b,中间不加空格,表示第 N 项。

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

不要开二维数组。这张表是“斜着数”的,先想清楚第 N 项落在第几条斜线上。

第 k 条斜线上有几项?从第 1 条到第 k−1 条一共有几项?把这两个数写成 k 的式子。

斜线号的奇偶决定方向:奇数号从上方出发往左下走,偶数号从下方出发往右上走。先手写前 10 项并标上斜线号,规律自己会跳出来。

N 高达 10⁷,但斜线号只有约 4472,一个 while 累加就够,不需要数组也不需要 sqrt。

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

第 k 条斜线(k = 1, 2, 3, …)含 k 项,且每条斜线上分子 + 分母 = k + 1。前 k 条斜线共 T(k) = k(k+1)/2 项。

于是“第 N 项在哪条斜线”等价于求最小的 k 使 T(k) ≥ N。T(k) 严格递增,从 k = 1 往后 while 累加即可;N ≤ 10⁷ 时 k ≤ 4472,循环量可以忽略。

设 before = (k−1)k/2 为前 k−1 条斜线的项数,则第 N 项在第 k 条斜线里是第 t = N − before 个(从 1 数起)。

方向:k 为奇数时该斜线从 k/1 出发、分子递减,故 a = k − t + 1、b = t;k 为偶数时从 1/k 出发、分子递增,故 a = t、b = k − t + 1。

自检:t 从 1 走到 k 时,a + b 恒为 k + 1,且 a、b 都在 [1, k] 内。用前 10 项逐个对,再提交。

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

int main() {
    long long N;
    cin >> N;

    // 找最小的 k,使 1+2+...+k >= N:第 N 项落在第 k 条斜线上
    long long k = 1;
    while (k * (k + 1) / 2 < N) ++k;

    long long before = (k - 1) * k / 2;   // 前 k-1 条斜线一共多少项
    long long t = N - before;             // N 在第 k 条斜线里是第 t 个(从 1 数)

    long long a, b;                       // 答案 a/b
    if (k % 2 == 1) {                     // 奇数斜线:从 k/1 出发,分子递减
        a = k - t + 1;
        b = t;
    } else {                              // 偶数斜线:从 1/k 出发,分子递增
        a = t;
        b = k - t + 1;
    }

    cout << a << "/" << b << "\n";        // 输出格式是 a/b,不能带空格
    return 0;
}
关键片段拆解
片段作用
while (k * (k + 1) / 2 < N) ++k;用三角数定位斜线。循环次数 = k ≈ 4472,代价可忽略;不用 sqrt 是为了躲开浮点误差。
(k - 1) * k / 2前 k−1 条斜线的总项数。先乘后除:写成 (k-1)/2*k 会在 k−1 为奇数时丢余数。
t = N - before把全局编号 N 换成“斜线内部编号”t ∈ [1, k],后面只需管 t。
k % 2 == 1方向开关。奇数斜线在表中是往左下走,所以分子随 t 减小。
a = k - t + 1; b = t;t = 1 时得 k/1,t = k 时得 1/k,两个端点都对得上表。
cout << a << "/" << b << "\n";用字符 '/' 连接,不加空格。这是本题唯一的格式要求。
这题最容易错的地方
  • 用 sqrt 算 k 再反查,浮点误差会让 k 少 1,N 恰好是三角数时整体错位。
  • 把 t 当成从 0 数起,写出 a = k - t,t = 1 时会得到 (k−1)/1,整体偏一位。
  • 奇偶方向写反。症状:输出是正确答案的镜像,比如 N = 2 打成 2/1。用前 6 项对表即可发现。
  • 输出成 a / b(带空格)或 a÷b,格式不符直接 WA。
  • 用 int 存 k 和 k(k+1)/2:本题 N ≤ 10⁷ 时 k ≤ 4472,不会溢出;但若 N 换成 10¹⁸(k ≈ 1.4×10⁹),k(k+1) 立刻爆 int,从乘法那一步起就得用 long long。
  • 把 1/1 当成第 0 项,导致所有 t 的落点整体偏移一条斜线。
延伸阅读 / 参考