📖 第 2.8 章:枚举与模拟 ⏱️ 约 60 分钟 🥉 Bronze

📖 第 2.8 章:枚举与模拟


2.8.0 从两道题出发

先看两道典型的 Bronze 题目——它们分别代表了本章的两大主题。

📋 题目 1:熄灯问题(简化版) 有一个 3×3 的灯阵,每个格子要么亮(1)要么灭(0)。现在灯全部亮着。 按下任意一个格子的开关,会翻转它自己和上下左右相邻格子的亮灭状态。 问:至少按多少次开关,才能把所有灯熄灭? 数据范围: 3×3 灯阵,开关只有「按」和「不按」两种选择。

📋 题目 2:爬行的蠕虫 一只蠕虫在一口深 n 厘米的井底。每分钟它先向上爬 u 厘米, 如果这之后还没爬出井口,就向下滑 d 厘米(休息时滑落)。 问:多少分钟后蠕虫爬出井口? 数据范围: 1 ≤ n, u, d ≤ 100,保证 u > d。

两道题,两种思路

熄灯问题:答案藏在「每个开关按或不按」的所有组合里——共 2⁹ = 512 种。把每种组合都试一遍,检查哪种能把灯全熄灭。这就是枚举:暴力搜索整个答案空间。

爬行的蠕虫:没有任何组合可选——过程是确定的,一分钟一分钟往下推就行。爬、检查、滑落,循环往复。这就是模拟:按题意忠实复现过程。

这两招是 Bronze 级别最朴素也最可靠的得分手段:

  1. 枚举:答案空间有限时,把每个候选答案都验证一遍。不需要聪明,只需要不重不漏。
  2. 模拟:题目描述了一个过程时,让计算机一步步照做。不需要算法,只需要不出错。

拆解本章需要回答的问题

小节回答的问题
2.8.1枚举:什么是解空间?怎么枚举才不重不漏又够快?
2.8.2模拟:怎么写大模拟题才能不写错?
2.8.3枚举 × 模拟:两者结合的黄金套路(完整解开篇熄灯题)

学习目标(学完本章你将能够):

  1. 为一道题建立正确的「解空间模型」,做到不重不漏
  2. 用三种手段缩小枚举空间:缩小范围、利用对称性、边枚举边排除
  3. 掌握子集枚举(位运算 mask)与排列枚举(next_permutation)
  4. 用「纸上流程 → 模块化 → 统一量纲」的方法写对大模拟题
  5. 熟练处理日期、时间类模拟题(平闰年、大小月)
  6. 使用「枚举决策 + 模拟验证」套路解决 USACO Bronze 常见的开关类问题

2.8.1 枚举(Enumeration)

什么是解空间?

解空间是「所有可能的答案(候选解)组成的集合」。枚举就是逐个检查候选解,把满足条件的挑出来。

比如:

  • 「熄灯问题」的解空间 = 9 个开关每个按/不按,共 2⁹ = 512 种方案;
  • 「三个数中选两个」的解空间 = C(3,2) = 3 种选法;
  • 「1 到 n 的整数」的解空间就是 1, 2, ..., n。

枚举的正确性取决于两件事:完备(每个可行解都被访问到)与不重(同一方案不被统计两次)。而枚举的效率取决于解空间的大小——所以枚举的功夫全在「如何让解空间变小」上。

枚举三要点

  1. 建立解空间模型:先把「候选答案到底是什么」想清楚,再动手写循环。是选一个数?选一对数?选一个子集?还是一组排列?
  2. 减少枚举空间:
    • 缩小范围:利用数学关系直接收紧循环边界(如只需要枚举到 √n);
    • 利用对称性:(a, b) 和 (b, a) 是同一方案时,只枚举 a ≤ b 的一半;
    • 边枚举边排除:一旦发现当前候选已经不可能,立即跳过(这是剪枝的雏形)。
  3. 选择枚举顺序:有时按特定顺序枚举能提前找到答案(如求「字典序最小的方案」就按字典序枚举),有时能让「不重不漏」自然成立。

下面用一道题把三要点全部走一遍。

完整实战 1:和为零的数对

📋 题目:和为零的数对 给定 n 个整数,问其中有多少对数 (i, j)(i < j)满足 a[i] + a[j] = 0? 数据范围: 1 ≤ n ≤ 10⁶,|a[i]| ≤ 10⁶。

第一步:建立解空间模型。 答案是一对下标 (i, j) 且 i < j——解空间是所有下标对,共 n(n−1)/2 个。

版本 1:朴素双重循环 O(n²)

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (auto &x : a) cin >> x;

    long long cnt = 0;
    for (int i = 0; i < n; i++)
        for (int j = i + 1; j < n; j++)   // j 从 i+1 开始:天然保证 i < j
            if (a[i] + a[j] == 0) cnt++;

    cout << cnt << endl;
    return 0;
}

注意内层 j 从 i + 1 开始——这就是「利用对称性」:(i, j) 和 (j, i) 是同一对,只统计一次。如果写成 j 从 0 开始再判断 i < j,既慢又容易把「不重」写错。

样例验证:

输入:            输出:
5
3 -3 5 -5 2       2

(3, -3) 和 (5, -5) 共 2 对。

n = 10⁶ 时双重循环约 5×10¹¹ 次——超时。换模型:不要枚举数对,改为枚举每个数,问「它的相反数出现几次」。

版本 2:有序去半(排序 + 双指针思路的雏形)O(n log n)

先把数组排序,a[i] 的相反数如果存在,必然集中出现在某个连续段——用双指针可以在一轮扫描内统计完。这个思路在第 3 章双指针部分会展开,这里先看更彻底的版本 3。

版本 3:桶标记 O(n)

值域只有 |a[i]| ≤ 10⁶,开一个桶数组记录每个值出现的次数。扫描到 a[i] 时,之前的数里值为 -a[i] 的都能和它配对:

#include <bits/stdc++.h>
using namespace std;

const int OFFSET = 1000000;   // 值域 [-1e6, 1e6] 平移到 [0, 2e6]
int bucket[2000001];

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (auto &x : a) cin >> x;

    long long cnt = 0;
    for (int i = 0; i < n; i++) {
        // 在「已经见过的数」中找 a[i] 的相反数
        if (-a[i] >= -1000000 && -a[i] <= 1000000)
            cnt += bucket[-a[i] + OFFSET];
        bucket[a[i] + OFFSET]++;   // 然后把自己也记入桶中
    }

    cout << cnt << endl;
    return 0;
}

先查再记的顺序很关键:如果先把自己记进桶再查,a[i] = 0 时会把自己和自己配对。

三版对比:

版本思路复杂度n = 10⁶ 时约需操作
朴素双重循环枚举所有下标对O(n²)5×10¹¹(超时)
排序 + 双指针有序后配对数相邻O(n log n)2×10⁷
桶标记每个数直接查相反数O(n + V)10⁶

这条「朴素 O(n²) → 有序去半 → 桶标记 O(n)」的优化路径是枚举题的通用成长路线:先保证正确,再一步步压解空间。

完整实战 2:子集枚举——选数凑和

当候选答案是「从中挑出若干个数」时,解空间就是子集。n 个数的子集共 2ⁿ 个,正好对应 n 位二进制数——这正是第 2.6 章位运算中位掩码的用武之地。

📋 题目:选数凑和 给定 n 个整数和一个目标值 target,问有多少种非空选法,使得选出的数之和恰好等于 target? 两种选法不同当且仅当选中的下标集合不同。 数据范围: 1 ≤ n ≤ 20,|a[i]| ≤ 10⁹,|target| ≤ 10⁹。

n ≤ 20 暗示 2²⁰ ≈ 10⁶——子集枚举的典型信号:

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    long long target;
    cin >> n >> target;
    vector<long long> a(n);
    for (auto &x : a) cin >> x;

    long long cnt = 0;
    for (int mask = 0; mask < (1 << n); mask++) {  // 枚举每个子集
        long long sum = 0;
        for (int i = 0; i < n; i++)
            if (mask & (1 << i)) sum += a[i];       // 第 i 位为 1 表示选它
        if (sum == target) cnt++;
    }

    cout << cnt << endl;
    return 0;
}

样例验证:

输入:            输出:
5 5
1 2 3 4 5         3

三种选法:{5}、{1,4}、{2,3}。

两个易错点:

  • (1 << n) 中若 n ≥ 31 会溢出——好在 n ≤ 20 时 1 << 20 只有约 10⁶,安全;
  • sum 用 long long:20 个 10⁹ 相加达 2×10¹⁰,int 必爆。

完整实战 3:排列枚举——密码锁

当候选答案是「顺序也有意义」时,解空间是排列。STL 的 next_permutation 按字典序逐个生成排列,是排列枚举的标准工具。

📋 题目:密码锁 一个三位密码锁由数字 1、2、3 各一个组成。从小到大列出所有可能的密码, 求第 k 个密码是什么。 数据范围: 1 ≤ k ≤ 6。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int k;
    cin >> k;
    string s = "123";
    sort(s.begin(), s.end());   // 必须先排成最小排列
    int idx = 1;
    do {
        if (idx == k) {
            cout << s << endl;
            return 0;
        }
        idx++;
    } while (next_permutation(s.begin(), s.end()));
    return 0;
}

样例验证: k = 4 输出 231(六种排列按字典序是 123、132、213、231、312、321)。

do-while 配合先排序是固定套路:next_permutation 在已经是最大排列时返回 false,从最小排列出发正好一个不漏。

枚举剪枝:预告

「边枚举边排除」的极端形式是:枚举到一半就发现此路不通,立刻回头换一条路——这就是回溯(剪枝)。它是第 5.2 章 BFS 与 DFS 的主角,也是子集枚举从 2ⁿ 走向更聪明搜索的必经之路。现在只需记住:枚举是回溯的骨架,剪枝是枚举的加速器。


2.8.2 模拟(Simulation)

模拟题的特征与心态

模拟题的标志是:题目把过程讲得一清二楚,你只需要照做。日期推算、游戏规则、机器执行、蚂蚁爬行……USACO Bronze 每场比赛几乎都有一道。

模拟题的特点是码量大、逻辑琐碎——难的不是想到做法,而是写对。正确的心理预期是:

模拟题不是比谁聪明,是比谁细心。把「我会了」和「我写对了」之间的距离,用工程习惯填平。

五大技巧

  1. 先在纸上写流程:把题目过程翻译成伪代码步骤,每一步的变量变化列一个表。写代码之前手算一遍样例,纸面流程和样例对得上再动手。
  2. 代码模块化:一个功能一个函数(或结构体)。「翻转一格灯」写成 flip(r, c),「判断闰年」写成 isLeap(y)。模块短小、命名清楚,出错时定位快。
  3. 统一量纲:时间统一换成秒、日期统一换成天数、金额统一换成「分」。所有比较和加减只在统一单位后进行,3 分 20 秒 与 200 秒 混用是模拟题最经典的 bug 来源。
  4. 常量命名:大小月天数表、方向数组 dx/dy、一周七天名称……提取成 const 数组放在文件开头,魔法数字全部消灭。
  5. 分块调试:每写完一个模块就用样例(或自造小数据)单独验证它,再拼装。一次只怀疑一个模块——这比盯着 200 行 main 找错高效十倍。系统方法见附录调试指南。

完整实战 1:爬行的蠕虫(开篇题回收)

按纸上流程走一遍:每分钟「先爬 u,若已出井口立即结束,否则下滑 d」——注意先判断后下滑的顺序,顺序反了会多算一分钟(出井口后不会滑落)。

分钟爬后位置出井?滑后位置
1u否u − d
22u − d否2u − 2d
............
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long n, u, d;
    cin >> n >> u >> d;

    long long pos = 0, minutes = 0;
    while (pos < n) {
        pos += u;            // 先爬
        minutes++;
        if (pos >= n) break; // 到顶就结束,不再下滑
        pos -= d;            // 没到顶,才滑落
    }

    cout << minutes << endl;
    return 0;
}

样例验证: n = 10, u = 2, d = 1 时,位置依次为 2→1、4→3、6→5、8→7、10(到顶)——输出 9 分钟。手算公式:第 k 分钟爬后位置 = ku − (k−1)d = k + 1,k = 9 时恰好为 10。✅

再验一组边界:n = 5, u = 5, d = 3——第一分钟直接爬到 5 出井,输出 1(下滑根本没发生)。

完整实战 2:日期模拟——N 天之后是几月几号

日期是模拟题的常客,考点全在平闰年和大小月上。

📋 题目:N 天之后 给定一个日期 y-m-d 和整数 n,求 n 天之后的日期。 数据范围: 1900 ≤ y ≤ 2100,日期合法,1 ≤ n ≤ 10000。

模块化 + 常量表的做法:

#include <bits/stdc++.h>
using namespace std;

bool isLeap(int y) {
    return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}

int daysOfMonth(int y, int m) {
    static const int d[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    if (m == 2 && isLeap(y)) return 29;
    return d[m];
}

int main() {
    int y, m, d, n;
    cin >> y >> m >> d >> n;

    d += n;
    while (d > daysOfMonth(y, m)) {   // 当前月份装不下了就进位
        d -= daysOfMonth(y, m);
        m++;
        if (m > 12) {
            m = 1;
            y++;
        }
    }

    printf("%04d-%02d-%02d\n", y, m, d);
    return 0;
}

样例验证:

输入:2024 2 28 2     输出:2024-03-01   (2024 是闰年,2 月有 29 天)
输入:2023 2 28 2     输出:2023-03-02   (2023 是平年,2 月只有 28 天)
输入:2024 12 31 1    输出:2025-01-01   (跨年进位)

注意 daysOfMonth(y, m) 在 while 条件和循环体里各调用一次——月份进位后要重新查天数,写错这里(比如循环外缓存)跨月时必错。

完整实战 3:胜负判定表——win() 函数

很多游戏类模拟题的核心是一张「谁克谁」的判定表。以石头剪刀布为例:

#include <bits/stdc++.h>
using namespace std;

char names[3][8] = {"Rock", "Paper", "Scissors"};   // 0=石头 1=布 2=剪刀

// 返回 1 表示 a 赢 b,-1 表示 a 输 b,0 平局
int win(int a, int b) {
    if (a == b) return 0;
    return (a - b + 3) % 3 == 1 ? 1 : -1;
}

int main() {
    int a, b;
    cin >> a >> b;
    cout << names[a] << " vs " << names[b] << ": ";
    int r = win(a, b);
    if (r == 1) cout << "first wins" << endl;
    else if (r == -1) cout << "second wins" << endl;
    else cout << "tie" << endl;
    return 0;
}

这里用了一个小巧的取模公式:把三种手势编号成环(石头 0 → 布 1 → 剪刀 2 → 石头 0),(a − b + 3) % 3 == 1 恰好表示「a 克 b」——比三行 if 更不容易写反。这套写法在 USACO 的「HPS(Hoop-Toss?)」石头剪刀布系列题(见第 6.2 章)中直接可用。


2.8.3 枚举 × 模拟:黄金套路

枚举决策 + 模拟验证

把两招合起来就是 Bronze 最强大的套路之一:

决策空间大但结构有规律时——枚举「第一步的所有决策」,剩下的过程用模拟推完。

开篇的熄灯问题正是如此。直接枚举 9 个开关的 2⁹ 种按法当然可行,但有一个更优雅的观察:

第 0 行的灯一旦确定按法,第 1 行怎么按就被唯一确定了——因为第 0 行还亮着的灯,只能靠按第 1 行正下方的开关来熄灭(第 0 行自己的开关已经定死了)。同理第 1 行确定后第 2 行也随之确定。

所以只需枚举第一行的 2³ = 8 种按法,剩下的 6 个开关由「逐行熄灭上方残留亮灯」的规则模拟推出来,最后检查最末行是否全灭即可:

#include <bits/stdc++.h>
using namespace std;

int main() {
    int grid[3][3];
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            cin >> grid[i][j];   // 1=亮, 0=灭

    int best = INT_MAX;   // 最少按键数
    int bestMask = -1;    // 对应的第一行按法

    // 枚举第一行的按法:bit j = 1 表示按下 (0, j)
    for (int mask = 0; mask < 8; mask++) {
        int g[3][3], press[3][3];
        memcpy(g, grid, sizeof(grid));
        memset(press, 0, sizeof(press));
        int cnt = 0;

        // 第 0 行的按法由 mask 决定
        for (int j = 0; j < 3; j++)
            if (mask & (1 << j)) { press[0][j] = 1; cnt++; }

        // 应用第 0 行的按键:翻转自己和上下左右
        for (int j = 0; j < 3; j++)
            if (press[0][j]) {
                int di[] = {0, 0, 0, 1, -1};
                int dj[] = {0, 1, -1, 0, 0};
                for (int k = 0; k < 5; k++) {
                    int ni = di[k], nj = j + dj[k];
                    if (ni >= 0 && ni < 3 && nj >= 0 && nj < 3)
                        g[ni][nj] ^= 1;
                }
            }

        // 第 i 行还亮着的灯,只能靠按第 i+1 行正下方的开关熄灭
        for (int i = 0; i + 1 < 3; i++) {
            for (int j = 0; j < 3; j++)
                if (g[i][j] == 1) {
                    press[i + 1][j] = 1;
                    cnt++;
                }
            // 应用第 i+1 行新确定的按键
            for (int j = 0; j < 3; j++)
                if (press[i + 1][j]) {
                    int di[] = {0, 0, 0, 1, -1};
                    int dj[] = {0, 1, -1, 0, 0};
                    for (int k = 0; k < 5; k++) {
                        int ni = i + 1 + di[k], nj = j + dj[k];
                        if (ni >= 0 && ni < 3 && nj >= 0 && nj < 3)
                            g[ni][nj] ^= 1;
                    }
                }
        }

        // 最末行必须全灭,方案才成立
        bool ok = true;
        for (int j = 0; j < 3; j++)
            if (g[2][j] == 1) { ok = false; break; }

        if (ok && cnt < best) {
            best = cnt;
            bestMask = mask;
        }
    }

    if (bestMask < 0) {
        cout << "无解" << endl;
    } else {
        cout << "最少按键 " << best << " 次" << endl;
        cout << "第一行按法(列0到2): ";
        for (int j = 0; j < 3; j++)
            cout << ((bestMask >> j) & 1);
        cout << endl;
    }
    return 0;
}

样例验证: 输入全亮的 3×3(1 1 1 / 1 1 1 / 1 1 1),输出:

最少按键 5 次
第一行按法(列0到2): 101

即按下四角加中心共 5 个开关——这与 2⁹ 全枚举对拍的结果一致(本程序的答案已用 512 种初始状态逐一和暴力全枚举对拍验证过)。

为什么这个套路如此常见?

  • 枚举量从 2⁹ 降到 2³——枚举空间被「逐行确定」的结构压缩了;
  • 枚举之后的每一步都是机械的模拟——正确性由「照做」保证;
  • 更妙的是,枚举第一行按法时顺便得到了「按键最少」的方案——枚举顺序不影响正确性,但影响能不能顺手求最优。

USACO Bronze 的「关灯」「按行染色」「翻硬币」系列题全是这个模板:枚举第一行的全部决策 → 逐行模拟确定后续 → 检查最后一行。把 3×3 推广到 R×C(第一行按法 2^C 种),就是完整的 USACO 真题规模。


⚠️ 常见错误

错误原因修复方案
枚举边界差一i < n 写成 i <= n,或 j 从 0 起重复枚举明确区间开闭约定,i < j 用 j = i + 1 起步
1 << n 溢出n ≥ 31 时 int 位移未定义子集枚举 n ≤ 20 才用 int;更大时改 long long 或换搜索
子集求和用 int20 个 10⁹ 相加达 2×10¹⁰sum 一律 long long
模拟更新顺序颠倒蠕虫题「先滑后爬」多算一分钟;先记桶再查把自己配对纸上列表格逐步核对,写完用样例逐行跟踪
闰年判断写错只写 y % 4 == 0,漏掉百年不闰记住完整公式:(y%4==0 && y%100!=0) || y%400==0
大小月硬编码二月天数、31/30 天月份靠背用 daysOfMonth(y, m) 常量表 + 函数
win 表写反「a 克 b」和「b 克 a」弄反用 (a - b + 3) % 3 == 1 这类对称公式,或写完用一组样例验证
越界方向的翻转漏判边界熄灯题按 (0,0) 翻转上/左邻居翻转前统一检查 0 <= ni < R && 0 <= nj < C

调试建议

模拟题写完之后,按这个清单自造数据检查:

  1. 最小规模:n = 1、只有一行、只有一分钟——边界循环体是否还正确?
  2. 刚好卡边界:蠕虫第一分钟就出井;日期加 0 天;子集只选第一个数。
  3. 跨段进位:日期跨月、跨年;蠕虫整除到顶。
  4. 对拍:写一个朴素暴力版(如 2⁹ 全枚举),用小数据随机对拍——这是验证枚举题的终极手段。

本章小结

本章最重要的思想可以总结为两句话:

  1. 枚举的功夫在解空间:建对模型 → 压缩空间(缩范围 / 用对称 / 边排除)→ 选好顺序。
  2. 模拟的功夫在工程:纸上流程、模块化、统一量纲、常量命名、分块调试——用习惯换正确率。

速查表

枚举三要点一句话
建立解空间模型先想清楚候选答案是什么(数 / 数对 / 子集 / 排列)
减少枚举空间缩小范围、利用对称性(j = i+1)、边枚举边排除
选择枚举顺序求字典序最小就按字典序枚举;利用结构逐行确定
模拟五技巧一句话
纸上流程伪代码 + 变量表,手算样例后再写代码
模块化一个功能一个函数,命名即文档
统一量纲时间转秒、日期转天数,单位统一后再运算
常量命名大小月表、方向数组用 const 数组,消灭魔法数字
分块调试每个模块单独过样例,一次只怀疑一处
枚举 × 模拟黄金套路:
枚举第一行的所有决策(2^C 种)
  → 逐行模拟:上一行的亮灯决定本行的按法
  → 检查最后一行是否满足条件

💪 练习题

🟢 题目 1:虫群计数

题目描述:

给定 n 个整数,问其中有多少对 (i, j)(i < j)满足 a[i] + a[j] == 10?

数据范围: 2 ≤ n ≤ 1000,0 ≤ a[i] ≤ 10。

输入格式:

第一行一个整数 n
第二行 n 个整数 a[1..n]

输出格式:

一个整数,表示满足条件的数对数量

样例输入:

5
3 7 5 5 10

样例输出:

3

提示: 直接用本章的双重循环 O(n²) 就够了(n ≤ 1000 时约 10⁶ 次)。配对有 (3,7)、(5,5)、(5,5) 共 3 对——注意下标不同、值相同的两对要分别计数。


🟢 题目 2:日期差

题目描述:

给定两个日期,求它们之间相差多少天(含首尾两端中的较早日期、不含较晚日期,即「从日期 1 走到日期 2 需要多少天」)。

数据范围: 两个日期均在 1900-01-01 到 2100-12-31 之间且合法,保证日期 1 不晚于日期 2。

输入格式:

两行,每行格式为 y m d(空格分隔)

输出格式:

一个整数,相差的天数

样例输入:

2024 2 28
2024 3 2

样例输出:

3

提示: 复用本章的 isLeap 和 daysOfMonth。逐天推进会超时吗?先算算最坏情况约 200 年 × 366 天 ≈ 7×10⁴ 次——逐天模拟完全够快。(更快的做法是儒略日公式,但模拟更不容易错。)


🟡 题目 3:熄灯问题原版

题目描述:

把本章的熄灯问题推广到 R × C 的灯阵(1 ≤ R, C ≤ 5)。给定初始灯态(不一定全亮),问是否存在按键方案把所有灯熄灭?若存在,输出最少按键数;若不存在,输出 -1。

数据范围: 1 ≤ R, C ≤ 5。

输入格式:

第一行 R C
接下来 R 行,每行 C 个 0/1 整数(1=亮)

输出格式:

一个整数:最少按键数,或 -1

样例输入:

2 3
0 1 0
1 0 1

样例输出:

2

提示: 枚举第一行的 2^C 种按法,其余行逐行模拟确定,检查最后一行。答案与 2^(R·C) 全枚举对拍验证。


🔴 题目 4:蛇形矩阵

题目描述:

从矩阵左上角出发,按「右、下、左、上」的顺序螺旋填充 1 到 n²,输出 n×n 的蛇形(螺旋)矩阵。

数据范围: 1 ≤ n ≤ 20。

输入格式:

一个整数 n

输出格式:

n 行,每行 n 个整数,右对齐占 4 列

样例输入:

4

样例输出:

   1   2   3   4
  12  13  14   5
  11  16  15   6
  10   9   8   7

提示: 纯模拟。方向数组 dx/dy + 「下一步出界或已填过就右转」。用 printf("%4d", ...) 控制格式。这是方向模拟 + 边界检查的集大成者——写对了,你就掌握了本章的所有边界技巧。


下一章预告: 进入第三部分,数据结构——数组与前缀和将把本章「桶标记」的思路推广成更强大的统计工具。