说实话,看到2024年3月GESP七级这道俄罗斯方块题的时候,我第一反应是:这题出得有水平。题目素材是大众熟知的游戏,规则一句话就能看懂,连没玩过俄罗斯方块的人也能秒懂,但真正在考场上写起来,能把一次提交就拿到AC的人,十个里未必有一个。原因很简单——它没有一个标准算法的套路可以背,考察的全是读题、边界处理、状态同步这些"手稳不稳"的细节。
这篇文章我会把这道真题从题意拆解、下落模拟、消行处理、完整代码到实战踩坑完整复盘一遍。如果你正在准备C++七级,或者想把二维数组模拟题练扎实,这篇都值得慢慢看完。
1. 先拆题:GESP七级这场俄罗斯方块到底在问什么
1.1 题目场景复述
题目按最常见的版本描述是这样的:给定一个 n 行 m 列的棋盘,每个格子用字符 0 表示空、1 表示已经有方块。然后给出一个由 # 和 . 组成的小形状(高 h、宽 w),这个形状会从棋盘正上方、指定列的起点开始竖直下落,整个过程不允许旋转、不允许左右平移。
形状落地之后,会停在第一个撞到棋盘上已有方块或者棋盘底部的位置。接下来套用经典俄罗斯方块规则:只要某一行被完全填满,这一整行就消除,它上面的所有行整体向下移动一行。消除之后如果又形成了新的完整行,就继续消除,直到没有完整行可消为止。
输出要求一般是两行:第一行是被消除的总行数,第二行及之后是最终棋盘的完整状态。有些地区题目要求输出剩余方块总数,逻辑是等价的,只需要在最终棋盘上再做一次统计。
1.2 考察的知识点定位
这道题放在C++七级非常合适,因为它不依赖任何高级数据结构和算法,核心就是二维数组模拟。但模拟题的难点从来不是写法,而是"每一步的状态是否和真实世界一致"。
具体拆开看,这道题至少覆盖四个考点:
- 二维数组的输入、存储、逐字符处理;
- 形状如何用字符矩阵表示,如何把 # 格子抽象成可遍历的坐标集合;
- 碰撞检测中的三类边界:棋盘上边界、左右边界、已有方块重叠;
- 消行时整体下移的方向、顺序和连锁处理。
最终代码量大概在80到120行,算法时间复杂度是 O(nm) 量级,棋盘规模即使放大到 100×100 也不需要担心超时。也就是说,这题不卡性能,卡的是逻辑严谨性。
2. 下落逻辑:碰撞判断和落点计算的两个关键边界
2.1 形状的存储与预处理
形状本身是一个由 # 和 . 组成的矩形区域,比如高度为2、宽度为3的形状可能是:
#.. ###我建议在下落之前,不直接保存整个形状矩阵,而是提取出所有实心格子的相对偏移量,存成一个 vector<pair<int,int>> 列表。前面这个形状对应的偏移量就是 {(0,0), (1,0), (1,1), (1,2)}。
这样做的原因是:后面所有碰撞判断只需要关心实心格子,空格子不影响碰撞,循环遍历偏移量列表的写法也更简洁。无论形状是1×1的小方块、横向长条还是L形,代码都不需要额外分支。
vector<pair<int,int>> cells; vector<string> shape; cin >> h >> w; shape.resize(h); for (int i = 0; i < h; i++) { cin >> shape[i]; for (int j = 0; j < w; j++) { if (shape[i][j] == '#') { cells.push_back({i, j}); } } }习惯上把形状左上角所在的棋盘行号记作 r0,列号记作 c0。那么任意一个实心格子的绝对坐标就是:
r = r0 + dr c = c0 + dc后面所有函数都围绕这个坐标换算展开。
2.2 canPlace 函数中容易被写错的边界处理
下落的核心是一个判断函数:给定形状左上角的位置 (r0, c0),问形状放在这里是否合法。合法需要同时满足三个条件:
- 所有实心格子不能出现在棋盘左右边界之外;
- 所有已经进入棋盘内部的实心格子不能和已有方块重叠;
- 所有实心格子的行号不能超过棋盘底部。
这里最容易被忽略的是:形状在下降过程中,可能有一部分格子还没进入棋盘,也就是行号 r < 0。这时候这些格子在空中,不应该参与碰撞判断,应该直接跳过。正确的写法是:
bool canPlace(int r0, int c0) { for (auto [dr, dc] : cells) { int r = r0 + dr; int c = c0 + dc; if (r < 0) continue; // 还在棋盘上方,不参与碰撞 if (r >= n || c < 0 || c >= m) return false; if (board[r][c] == '1') return false; } return true; }我见过很多学生把r < 0写成return false,后果就是形状永远卡在棋盘上空,因为只要先尝试向下走一步,形状顶部还在空中,就被判定为"越界",循环直接终止。有的题目样例比较宽松,这个错还测不出来,等交上去就是一片WA。
2.3 下落循环的初始行号设计
我们规定形状初始位置为左上角正好位于棋盘第一行的上方,也就是:
int r0 = -h;此时形状整体在棋盘外面,底部贴着棋盘上边缘。然后不断尝试canPlace(r0 + 1, c0),如果能走就继续往下走,不能走就停下来:
while (canPlace(r0 + 1, c0)) { r0++; }这个写法最贴近物理直觉:每次只看"下一步能不能落",能落就落,不能落就停在现在的位置。循环结束时,r0 就是形状左上角的最终行号。
放置方块时再次遍历 cells,把对应格子设为 '1' 即可:
for (auto [dr, dc] : cells) { int r = r0 + dr; int c = c0 + dc; if (r >= 0 && r < n && c >= 0 && c < m) { board[r][c] = '1'; } }注意这里依然要做一次防御性判断,防止极端数据下形状有部分格子落在棋盘外。理论上完全合法的情况下不会走进去,但防御性检查是模拟题的好习惯。
3. 消行处理:完整行检查、整体下移与连锁反应
3.1 判断某一行是否被填满
消行之前先要能判断完整行。这一步没什么玄学,遍历一行所有列,只要出现一个不是 '1' 的格子,这行就没满:
bool isFull(int r) { for (int j = 0; j < m; j++) { if (board[r][j] != '1') return false; } return true; }在 n、m 都不大的情况下,这样逐行逐列检查完全够用。如果棋盘规模到了几千,可以以后维护每行方块计数的思路优化,但GESP七级完全不需要。
3.2 直接模拟整体下移的写法与顺序陷阱
经典的做法是从下往上扫描棋盘,发现满行就消除并整体下移:
bool changed = true; while (changed) { changed = false; for (int i = n - 1; i >= 0; i--) { if (isFull(i)) { changed = true; for (int r = i; r >= 1; r--) { board[r] = board[r - 1]; } board[0] = string(m, '0'); // 顶部补一行空行 break; // 重新从底部扫描 } } }这里有两个关键点必须说清楚。
第一,必须从下往上扫描。如果从上往下扫,一旦消掉下面某一行,上面所有行整体下移,之前已经检查过的行又有了新内容,很容易漏检新形成的满行。
第二,每消掉一行后要么 break 重来,要么对当前索引做补偿,总之不能让循环索引直接跳过刚刚下移过来的那一行。用break配合外层while是最稳妥的,虽然看起来多扫了几遍,但棋盘最多也就十几行,性能完全无所谓。
3.3 更简洁的过滤法写法
上面那种直接模拟下移的写法很容易写乱,我在比赛里更推荐过滤法:把所有非满行从下往上收集起来,再在顶部补空行,最后翻转顺序。
实际效果和整体下移完全一致,但代码简单很多,不用处理连锁反应的循环索引问题:
int removed = 0; vector<string> remain; for (int i = n - 1; i >= 0; i--) { if (isFull(i)) { removed++; } else { remain.push_back(board[i]); } } while ((int)remain.size() < n) { remain.push_back(string(m, '0')); } reverse(remain.begin(), remain.end()); board = remain;这里从下往上收集,所以remain里的顺序是反的,最后必须reverse恢复。过滤法的好处是一次性把本回合所有满行全部消掉,天然就是俄罗斯方块的标准规则——同一时刻消掉所有完整行,而不是逐行依次消。
3.4 两种写法的取舍建议
直接模拟下移的写法思路直白,适合在草稿纸上推演验证;过滤法代码干净,落地不易错。我个人的建议是:平时练习两种都写一遍,考场上优先用过滤法。
原因是模拟题最怕的不是算法复杂,而是某一步索引写错之后整个棋盘乱掉。过滤法把"删除满行"和"上方下移"两个操作用一个 vector 的筛选和补零表达清楚,几乎不需要维护复杂的计数器,出错的概率低很多。
4. 完整可交的C++代码:数据结构、子函数与主流程
4.1 完整代码
下面这段是 C++17 的完整实现,输入输出采用标准重定向,可以直接在本地测试:
#include <bits/stdc++.h> using namespace std; int n, m, h, w, sc; vector<string> board; vector<pair<int, int>> cells; bool canPlace(int r0, int c0) { for (auto [dr, dc] : cells) { int r = r0 + dr; int c = c0 + dc; if (r < 0) continue; if (r >= n || c < 0 || c >= m) return false; if (board[r][c] == '1') return false; } return true; } bool isFull(int r) { for (int j = 0; j < m; j++) { if (board[r][j] != '1') return false; } return true; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; board.resize(n); for (int i = 0; i < n; i++) cin >> board[i]; cin >> h >> w; vector<string> shape(h); for (int i = 0; i < h; i++) { cin >> shape[i]; for (int j = 0; j < w; j++) { if (shape[i][j] == '#') { cells.push_back({i, j}); } } } cin >> sc; sc--; // 输入列号从1开始,内部从0开始 int r0 = -h; while (canPlace(r0 + 1, sc)) r0++; for (auto [dr, dc] : cells) { int r = r0 + dr; int c = sc + dc; if (r >= 0 && r < n && c >= 0 && c < m) { board[r][c] = '1'; } } int removed = 0; vector<string> remain; for (int i = n - 1; i >= 0; i--) { if (isFull(i)) removed++; else remain.push_back(board[i]); } while ((int)remain.size() < n) { remain.push_back(string(m, '0')); } reverse(remain.begin(), remain.end()); board = remain; cout << removed << '\n'; for (int i = 0; i < n; i++) { cout << board[i] << '\n'; } return 0; }4.2 主流程解读
主函数一共四步,和手推过程完全一致:
第一步,读取棋盘和形状,同时把形状里的 # 格子转成偏移量列表。第二步,从 r0 = -h 开始模拟下架,循环结束后得到最终落点。第三步,把形状写进棋盘。第四步,用过滤法消行并输出。
整个程序里核心函数只有两个:canPlace 负责碰撞判断,isFull 负责满行判断。其他代码都是顺序逻辑。两个函数总共不超过25行,这就是模拟题的典型结构——把复杂过程拆成若干个小决策,比把所有逻辑堆在 main 里清晰得多。
4.3 复杂度分析
一次下落最多从顶部走到棋盘底部,移动步数不超过 n+h。每一步 canPlace 要遍历形状的实心格子数,记为 k,那么下落过程的复杂度是 O((n+h)k)。消行阶段最多扫描全部棋盘一遍,每次 isFull 检查 m 个格子,复杂度 O(nm)。总体算法复杂度是 O(nm),对于GESP七级的棋盘规模来说,时间和空间都毫无压力。
5. 实测中最容易翻车的四个细节
5.1 输入列号是1-based,忘了减一
考试时形状下落所在的起始列,通常是按自然列号从1开始计的。程序内部棋盘列号从0开始,所以读入 sc 之后必须执行sc--。
这个错特别隐蔽,因为样例里起始列可能正好是中间位置,偏移一格之后依然能出结果,肉眼甚至看不出差异,但交上去后边界数据全挂。凡是碰到"从1开始计数的编号"就统一减一,是我反复强调的第一个考场肌肉记忆。
5.2 形状还没进棋盘就被判定触底
这就是前面 canPlace 里r < 0误写成return false的问题。形状从棋盘上方下落,最开始几轮遍历时,所有 # 格子的实际行号都是负数。如果此时直接返回 false,下落循环就会立刻结束,形状以"几乎完全在棋盘外"的状态放置,输出的棋盘和初始棋盘几乎一样,方块仿佛凭空消失了。
我提醒学生的时候会说一句口诀:空中部分不碰撞,落地部分才判断。这个边界不处理好,整个程序的第一阶段就是错的。
5.3 消行时循环索引乱跳
在直接模拟下移的写法里,连续多行同时满时最容易出错。比如棋盘最后两行都满,从下往上扫描,消掉倒数第一行后整体下移,原来的倒数第二行跑到最后一行。如果这时候没有重新检查最后一行,而是继续往上走,就会漏掉一次消除。
过滤法可以彻底绕开这个问题。如果你非要写直接模拟,记得用外层 while 包住内层 for,并且每次消行后 break 重新扫描,宁可多扫两遍也不要让索引逻辑绕进去。考场上没有调试器的时候,少一个费脑子的流程就少一个出错的概率。
5.4 形状与棋盘重合时的放置顺序
放置方块必须在消行之前完成,这个顺序看起来是常识,但实际写代码时有人会先检查棋盘里有没有已有满行,想优先消掉再放新形状。真这么做就错了,因为新落下的方块是参与消行的有效方块,它可能正好补在一个缺了一格的行上,直接触发消除。必须先落下、再消行,这个先后顺序是俄罗斯方块规则的基本盘。
另外,形状如果有上下左右留白的 . 行,也要记得通过 cells 偏移量过滤掉,不能让空格子去参与放置和碰撞判断。用偏移量列表天然解决这个问题,这也是我推荐提取 cells 的原因之一。
6. 从这道题反推七级模拟题的拿分习惯
6.1 先把函数拆开再写main
我在帮学生复盘这套真题时反复强调:模拟题如果想要一遍写对,不要急着写主流程,先把所有"判断型"的小函数写出来。比如这道题的 isFull、canPlace,都是输入一个坐标或下标、输出一个 bool 的纯判断函数,最容易单独验证。
写完小函数之后,再设计主流程的四步逻辑:下落、放置、消行、输出。每一步对应的代码块都不超过10行,即使中间某一步写错,通过打印棋盘也能快速定位到具体函数,而不是在几十行纠缠不清的循环里找bug。
6.2 自测数据要刻意制造极端情况
光过样例远远不够。至少应该手算三组数据再提交:
- 形状是1×1的小方块,落在已有方块正上方,验证碰撞边界;
- 棋盘顶部就有方块,形状落上去后卡在棋盘外一部分,验证 r<0 的跳过逻辑;
- 最后一行原来只缺一个格,新落下的方块恰好补上,验证消行和输出。
再狠一点,可以把棋盘做成全部是0,形状直接落到地面,验证底部越界的判断;也可以把形状做成只有一行的横条,验证不同形状高度对落点的影响。这些极端用例能覆盖代码里90%的边界分支。
6.3 考场上不要把简单题想复杂
这种模拟题最容易出现的情况不是不会写,而是写着写着想太多,给自己加戏。比如有人会去考虑"方块在下落过程中会不会被卡住需要提前终止",有人会去想"消行时要不要处理方块悬空"。所有上述问题在题目规则里都没有,棋盘是静态网格,方块垂直下落,不会旋转也不会横移,消行不改变格子是否稳定。
读题时圈出四件事:下落方式、碰撞条件、消行规则、输出内容。题目没有说的规则一律不要实现,这是拿满分和拿部分分之间最实际的分界线。
这里还要分享一个小经验:这类模拟题写完代码之后,最好在草稿纸上画一张小的棋盘,手工推一遍样例的完整过程。比如4×4就能推完的小规模数据,推完之后再跑程序,两边结果一致再提交。这个方法慢不了几分钟,但能拦住绝大多数低级错误。
考试时间分配上,这种模拟题建议控制在30到40分钟。前10分钟读题、画图、拆函数,中间15到20分钟写代码,最后留5到10分钟造数据、自查边界。这样即使一次没AC,也还有时间修复,不会因为死磕一道题而影响后面的心态。