1. 从40分到更高:这个题目到底在考什么
俄罗斯方块这个题,在CCF系列的编程能力测试里算是比较经典的一道模拟题。很多人第一次拿到题目的时候觉得“不就是个方块下落吗”,然后兴冲冲写完一交,40分。再改改,还是40分。这个分数段特别尴尬——说明你大方向没跑偏,基本逻辑能跑通一部分用例,但离满分总差那么一口气。
我自己第一次做这道题的时候也是40分,当时百思不得其解,明明本地测试各种情况都跑过了,为什么就是过不了。后来把题目翻来覆去读了三遍,又拿纸笔把每一步的坐标变化手推了一遍,才发现问题出在对“方块整体下落”这个行为的理解上——不是逐格下落,而是一次性下落到不能再落为止。这个理解偏差直接导致大量边界用例挂掉。
这篇文章主要面向正在刷CCF题目、特别是卡在俄罗斯方块这道题上拿不到高分的同学。我会从题目核心要求出发,把40分代码的典型问题拆开揉碎讲清楚,然后给出完整的优化思路和可复现的代码实现。不管你是刚接触模拟题的新手,还是已经写了但一直卡在某个分数段的老手,应该都能从中找到有用的东西。
先说一下这道题的基本设定。题目通常会给出一个15行10列的棋盘,用0和1表示空格和已填充的格子。然后给一个由4个小方块组成的俄罗斯方块形状(标准7种之一),以及它从顶部下落的起始列位置。你需要模拟这个方块在棋盘上一直下落,直到某个方块的下方要么是棋盘底部,要么是已经被填充的格子,然后把它固定在棋盘上,输出最终棋盘状态。
听起来确实不复杂,但魔鬼全在细节里。
2. 40分代码的典型问题拆解
2.1 为什么偏偏是40分
CCF题目的评分机制是按测试点给分的。40分通常意味着你通过了大约40%的测试点,这些测试点一般是比较基础的情况——比如方块直接落到底部、棋盘上没有太多已有方块、起始列比较靠中间等等。而挂掉的那些测试点,往往涉及一些边界情况。
我总结了一下,40分代码最常见的问题集中在以下几个方面:
第一个坑:下落逻辑写成了逐格判断。很多人写模拟的时候,习惯性地让方块一格一格往下掉,每掉一格就判断一次是否碰撞。这个思路本身没错,但问题在于判断碰撞的时候只检查了方块最下面那一行,而忽略了方块形状本身可能是不规则的。比如T形方块或者L形方块,它们底部并不是平的,有些列有方块有些列没有。如果你只检查最底下一行,就会出现“悬空”的情况——方块明明还有一部分没碰到东西,你却让它停下来了。
第二个坑:坐标映射搞反了。题目给的方块形状通常是一个4x4的矩阵,你需要把它映射到棋盘的对应位置上。这里涉及到行和列的偏移计算。我见过不少人把行偏移和列偏移搞混,或者起始列的理解有偏差——题目说的“从第几列开始”到底是从0开始计数还是从1开始,这个一定要看清楚。
第三个坑:没有处理方块完全无法下落的情况。有些测试点里,方块一开始生成的位置就已经和棋盘上已有的方块重叠了。这种情况下,按照题目要求,方块应该直接固定在当前位置,而不是继续尝试下落。很多40分代码在这里没有做特殊处理,导致死循环或者输出错误。
第四个坑:棋盘更新的时机不对。方块固定之后,需要把它的所有格子标记到棋盘上。有些人只标记了最下面一行,或者标记的时候行列搞反了,导致最终输出的棋盘和预期不符。
2.2 一个真实的调试过程
拿我自己踩过的坑来说。当时我写了一个版本,本地测试了几个简单用例都过了,提交上去40分。我把挂掉的测试点编号记下来,然后自己构造了对应的输入去测。发现有一个用例是这样的:棋盘底部已经有一些方块了,形状是一个凹槽,然后下落的是一个长条形方块(I形),起始列正好对准凹槽。
我的代码让这个方块停在了凹槽上方一格的位置,而正确答案应该是它继续下落,直到碰到凹槽底部。问题出在哪呢?我判断碰撞的时候,只检查了方块当前最下面一行的每个格子正下方是否有障碍。但I形方块是竖着放的,它只有一列有方块,其他三列都是空的。我的代码在检查其他三列的时候,发现下方是空的,就认为还可以继续下落。但实际上,有方块的那一列已经碰到障碍了,整个方块就应该停下来。
这个bug很典型——判断碰撞必须检查方块占据的所有格子,而不是只检查某一行或某一列。
2.3 从40分到满分的核心思路转变
想要从40分突破,核心思路的转变在于:把方块看作一个整体,而不是一堆独立的格子。
具体来说,你需要做这几件事:
- 提取出方块的所有有效格子坐标(相对于方块自身的4x4矩阵)
- 计算方块在棋盘上的绝对坐标
- 判断整体下落时,检查方块所有格子的下方是否都为空
- 固定方块时,把所有格子都标记到棋盘上
这个思路转变听起来简单,但实际操作中涉及到不少坐标计算的细节。下面我会一步步拆解。
3. 核心逻辑的完整实现方案
3.1 数据结构的选择与坐标系统设计
先说数据结构。棋盘用一个二维数组board[15][10]来表示,0表示空,1表示有方块。方块用一个4x4的二维数组shape[4][4]来表示,同样0和1。
坐标系统的设计很关键。我建议统一用行号从上到下递增、列号从左到右递增的方式。棋盘的行号是0到14,列号是0到9。方块矩阵的行号是0到3,列号是0到3。
方块在棋盘上的位置,我用一个基准点来表示——通常是方块4x4矩阵的左上角对应到棋盘上的位置。假设基准点是(baseRow, baseCol),那么方块矩阵中第i行第j列的格子,在棋盘上的绝对坐标就是(baseRow + i, baseCol + j)。
起始的时候,baseRow = 0,baseCol由题目给出。这里要注意,题目给的起始列可能是从1开始计数的,你需要转换成从0开始。比如题目说“从第3列开始下落”,那baseCol = 2。
#include <stdio.h> int board[15][10]; int shape[4][4]; // 获取方块所有有效格子的绝对坐标 int cells[4][2]; // 存储每个有效格子的(row, col) int cellCount = 0; void getCells(int baseRow, int baseCol) { cellCount = 0; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (shape[i][j] == 1) { cells[cellCount][0] = baseRow + i; cells[cellCount][1] = baseCol + j; cellCount++; } } } }这段代码的作用是把方块的所有有效格子提取出来,方便后续判断。cellCount记录有效格子的数量,标准俄罗斯方块都是4个。
3.2 下落判断的精确逻辑
下落判断是整个题目的核心。逻辑是这样的:假设方块当前在(baseRow, baseCol)位置,我们尝试把它下移到(baseRow + 1, baseCol),然后检查这个新位置是否合法。
合法的条件是:方块的所有有效格子都在棋盘范围内,且对应的棋盘位置都是空的。
int canMove(int baseRow, int baseCol) { getCells(baseRow, baseCol); for (int k = 0; k < cellCount; k++) { int r = cells[k][0]; int c = cells[k][1]; if (r >= 15 || c < 0 || c >= 10) return 0; // 越界 if (board[r][c] == 1) return 0; // 已被占用 } return 1; }注意这里r >= 15的判断。因为方块是从顶部开始下落的,baseRow初始为0,随着下落逐渐增大。如果某个格子的行号超过了14,说明已经超出棋盘底部了,不能继续下落。
有了这个函数,下落过程就很简单了:
int baseRow = 0; int baseCol = startCol - 1; // 转换成0-based while (canMove(baseRow + 1, baseCol)) { baseRow++; }这个循环会一直执行,直到方块不能再往下移动为止。此时baseRow就是方块最终停留的位置。
3.3 方块固定与棋盘更新
方块停止后,需要把它固定到棋盘上:
getCells(baseRow, baseCol); for (int k = 0; k < cellCount; k++) { int r = cells[k][0]; int c = cells[k][1]; board[r][c] = 1; }然后输出整个棋盘:
for (int i = 0; i < 15; i++) { for (int j = 0; j < 10; j++) { printf("%d", board[i][j]); if (j < 9) printf(" "); } printf("\n"); }到这里,核心逻辑就完整了。但实际写的时候,还有一些细节需要注意。
3.4 边界情况的处理
情况一:方块初始位置就非法。如果方块在(0, baseCol)位置就已经和棋盘上的方块重叠了,按照题目要求,它应该直接固定在当前位置。但这种情况在标准题目中很少出现,因为方块是从顶部下落的,顶部通常是空的。不过为了保险,可以在下落循环之前先判断一下canMove(0, baseCol),如果不合法就直接固定。
情况二:方块部分超出右边界。题目给的起始列通常保证方块不会超出边界,但如果你自己构造测试用例,需要注意这一点。canMove函数里已经做了c >= 10的判断,所以不会出问题。
情况三:方块形状的旋转。标准题目通常只给一个固定形状,不需要旋转。但如果题目要求支持旋转,那就需要额外处理。不过根据我的经验,CCF的俄罗斯方块题目一般不需要旋转,只需要下落。
4. 常见问题与排查技巧实录
4.1 为什么我的代码本地能过但提交挂掉
这是最常见的问题。原因通常有两个:一是本地测试用例覆盖不够,没有测到边界情况;二是对题目的理解有偏差,比如起始列的计算方式、输出的格式要求等。
我的建议是:构造尽可能多的测试用例,特别是边界情况。比如:
- 方块落在最左边一列
- 方块落在最右边一列
- 棋盘上已有方块形成各种奇怪的形状
- 方块形状是I形、T形、L形等不同形状
每构造一个用例,手动推演一下正确答案,然后和你的代码输出对比。如果对不上,就单步调试,看看是哪一步出了问题。
4.2 坐标计算总是搞混怎么办
坐标计算是这类题目的难点。我的经验是:在纸上画图。把棋盘画出来,把方块画上去,然后手动标出每个格子的坐标。这样直观地看一遍,比在脑子里空想靠谱得多。
另外,建议在代码里加一些调试输出。比如在每次下落之后,打印出当前方块的位置和所有格子的坐标。这样运行的时候就能清楚地看到每一步的变化,哪里不对一目了然。
4.3 常见错误速查表
| 错误现象 | 可能原因 | 排查方法 |
|---|---|---|
| 方块悬空停住 | 碰撞检测只检查了部分格子 | 检查canMove是否遍历了所有有效格子 |
| 方块穿透已有方块 | 碰撞检测条件写反了 | 检查board[r][c] == 1的判断 |
| 输出格式错误 | 空格或换行处理不当 | 对照题目要求的输出格式逐字符检查 |
| 数组越界 | 坐标计算超出范围 | 在canMove中加边界判断 |
| 死循环 | 下落条件永远为真 | 检查canMove的返回值逻辑 |
4.4 几个实用的调试技巧
技巧一:用小棋盘测试。把15x10的棋盘缩小成5x5或者更小,这样手动推演和调试都更方便。确认逻辑正确后再换回原尺寸。
技巧二:打印中间状态。在每次下落之后,打印当前棋盘和方块位置。虽然输出会很多,但能帮你快速定位问题。
技巧三:对比法。写一个暴力版本(比如逐格模拟),和你的优化版本对比输出。如果结果不一致,说明其中一个有问题。
技巧四:边界值测试。专门测试起始列为0和9的情况,以及方块形状最宽的情况(比如I形横放占4列)。
5. 完整代码实现与逐段解析
5.1 完整代码
#include <stdio.h> #include <string.h> int board[15][10]; int shape[4][4]; int cells[4][2]; int cellCount; void getCells(int baseRow, int baseCol) { cellCount = 0; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (shape[i][j] == 1) { cells[cellCount][0] = baseRow + i; cells[cellCount][1] = baseCol + j; cellCount++; } } } } int canMove(int baseRow, int baseCol) { getCells(baseRow, baseCol); for (int k = 0; k < cellCount; k++) { int r = cells[k][0]; int c = cells[k][1]; if (r >= 15 || c < 0 || c >= 10) return 0; if (board[r][c] == 1) return 0; } return 1; } int main() { // 读入棋盘 for (int i = 0; i < 15; i++) { for (int j = 0; j < 10; j++) { scanf("%d", &board[i][j]); } } // 读入方块形状 for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { scanf("%d", &shape[i][j]); } } // 读入起始列 int startCol; scanf("%d", &startCol); int baseCol = startCol - 1; // 下落 int baseRow = 0; while (canMove(baseRow + 1, baseCol)) { baseRow++; } // 固定 getCells(baseRow, baseCol); for (int k = 0; k < cellCount; k++) { board[cells[k][0]][cells[k][1]] = 1; } // 输出 for (int i = 0; i < 15; i++) { for (int j = 0; j < 10; j++) { printf("%d", board[i][j]); if (j < 9) printf(" "); } printf("\n"); } return 0; }5.2 关键段落解析
读入部分没什么好说的,注意棋盘是15行10列,方块是4x4矩阵。
下落部分是核心。while (canMove(baseRow + 1, baseCol))这个循环会一直执行,直到方块不能再往下移动。这里有个细节:canMove的参数是baseRow + 1,也就是尝试下移一格后的位置。如果这个位置合法,就更新baseRow,继续尝试。如果不合法,循环结束,baseRow就是最终位置。
固定部分调用getCells重新计算方块在当前baseRow下的所有格子坐标,然后逐个标记到棋盘上。这里注意,getCells在canMove里也被调用了,所以cells数组的内容会被覆盖。但没关系,因为我们在固定之前重新调用了一次。
输出部分注意每行末尾不要有多余空格。我的写法是if (j < 9) printf(" "),这样最后一个数字后面不会跟空格。
5.3 时间复杂度分析
这个算法的时间复杂度很低。下落循环最多执行15次(因为棋盘只有15行),每次canMove最多检查4个格子。所以总的时间复杂度是O(15 * 4) = O(60),常数级别。对于任何规模的输入都绰绰有余。
空间复杂度也很低,棋盘和方块矩阵都是固定大小,总共不到100个int。
6. 从这道题延伸出的通用模拟题技巧
6.1 模拟题的核心解题框架
俄罗斯方块这道题是典型的模拟题。模拟题的解题框架可以总结为三步:
第一步:明确状态。这道题的状态就是棋盘和方块的位置。你需要用合适的数据结构来表示这些状态。
第二步:定义操作。这道题的操作就是“下落一格”和“固定方块”。你需要把这些操作写成函数,确保每个函数只做一件事。
第三步:模拟过程。按照题目描述的流程,反复执行操作,直到达到终止条件。
这个框架适用于绝大多数模拟题。不管是俄罗斯方块、扫雷、还是更复杂的模拟场景,都可以套用。
6.2 坐标计算的通用技巧
坐标计算是模拟题最容易出错的地方。我的经验是:
- 统一坐标系统。所有坐标都用同一种方式表示,不要一会儿0-based一会儿1-based。
- 画图辅助。在纸上画出坐标系统,标出关键点的坐标。
- 边界检查。每次坐标计算后,都要检查是否越界。
- 单元测试。对坐标计算函数单独测试,确保输入输出符合预期。
6.3 如何构造有效的测试用例
构造测试用例的能力,直接决定了你能否发现代码中的bug。我的建议是:
- 从简单到复杂。先构造最简单的用例(空棋盘、方块直接落到底部),再逐步增加复杂度。
- 覆盖所有分支。每个if-else分支都要有对应的测试用例。
- 边界值优先。起始列为0、起始列为9、方块形状最宽、棋盘最满等情况优先测试。
- 随机测试。写一个随机用例生成器,大量测试,对比暴力版本和优化版本的输出。
6.4 代码风格与调试习惯
最后说几点代码风格和调试习惯方面的建议:
- 函数化。把每个独立的功能写成函数,比如
getCells、canMove、fixBlock等。这样代码更清晰,也更容易调试。 - 注释。关键步骤加注释,特别是坐标计算的部分。过几天再来看,没有注释你很可能看不懂自己写的代码。
- 调试输出。在关键位置加
printf,打印中间状态。调试完成后可以注释掉或者删掉。 - 版本管理。每改一次代码就保存一个版本,这样如果改错了可以回退。
我在实际做题的时候,习惯先把思路写在纸上,包括数据结构、函数划分、关键步骤。然后再开始写代码。这样写出来的代码结构清晰,bug也少。直接上手就写代码,往往写到一半发现思路不对,又要推倒重来,反而更浪费时间。
另外,对于CCF这类题目,读懂题目比写代码更重要。我见过很多人题目都没读明白就开始写,写完发现理解错了,白费功夫。花十分钟把题目读三遍,把关键信息标出来,比急着写代码效率高得多。
俄罗斯方块这道题,从40分到满分,核心就是那么几个点:整体下落的逻辑、所有格子的碰撞检测、坐标计算的准确性。把这几个点吃透,代码自然就能过。希望这些经验对正在刷题的你有所帮助。