☰
蓝桥杯国赛“完美正方形”深度解析:DFS剪枝与精确覆盖算法实战
2026/11/1 13:06:46 网站建设 项目流程

1. 从一道国赛真题说起:当“完美”遇上“正方形”

如果你参加过蓝桥杯,或者对算法竞赛稍有了解,那么“完美正方形”这个名字你一定不陌生。作为第六届蓝桥杯软件类国赛(C/C++本科A/B组)的一道压轴大题,它以其独特的背景、精巧的构思和对深度优先搜索(DFS)算法的极致考验,成为了许多选手记忆中的一道“坎”。这道题远不止是简单的图形填充,它更像是一个微缩版的“七巧板”终极挑战,要求你用一堆给定尺寸的小正方形,去严丝合缝地拼出一个大正方形,不能有空隙,不能有重叠,每一块都必须用上,且只能用一次。

听起来是不是有点像小时候玩的拼图?但当你真正面对它时,会发现这比拼图复杂得多。拼图有凹凸的提示,而这里只有冷冰冰的数字和一块空白的画布。题目给出的不是形状,而是边长:一个由19个不同边长的小正方形组成的集合,要求将它们全部填入一个边长为47的大正方形中。这19个边长分别是:2, 5, 9, 11, 16, 17, 19, 21, 22, 24, 26, 30, 31, 33, 35, 36, 41, 46, 47。看到这串数字,尤其是最后那个47,你可能会想,是不是直接把边长为47的那个放进去就完事了?显然没那么简单,因为大正方形的边长就是47,这意味着那个最大的正方形必须占据整个大正方形的一条边,但它无法单独填满整个区域,其他18个正方形必须围绕着它,以一种极其精妙的方式排列组合。

这就是“完美正方形”问题的核心魅力所在:它是一个典型的精确覆盖问题。我们有一个“棋盘”(47x47的网格),有19个“棋子”(不同大小的正方形),每个棋子放置时会覆盖棋盘上特定的一片区域。目标是用这19个棋子,恰好覆盖整个棋盘,不重不漏。这类问题天然就是深度优先搜索(DFS)的用武之地。但朴素的、无脑的DFS在这里会遭遇“组合爆炸”——可能的放置顺序和位置多到宇宙毁灭也算不完。因此,如何为DFS加上“智慧”的剪枝,如何高效地表示和操作这个“棋盘”,就成了解决这道题,乃至理解此类填充问题的关键。接下来,我们就抛开竞赛的紧张氛围,以一名算法爱好者的视角,一步步拆解这道经典题目,还原从暴力尝试到智能搜索的完整思考与实践过程。

2. 问题本质剖析:精确覆盖与搜索空间的恐怖规模

在动手写任何代码之前,我们必须彻底理解我们面对的是什么。这不仅仅是“把方块放进去”,而是一个约束满足问题(CSP),它有几个铁律:

  1. 容器固定:目标是一个47x47的网格(为方便,我们可以用坐标(0,0)到(46,46)表示)。所有操作必须在这个边界内。
  2. 碎片固定:有且仅有19个特定边长的正方形,每个边长对应一个正方形,每个正方形必须被使用恰好一次。
  3. 填充规则:正方形必须水平/垂直放置(不能旋转),放置后,其覆盖的每个单元格都标记为“已占用”。
  4. 目标状态:所有47*47=2209个单元格都被占用,且没有两个正方形覆盖的单元格有交集。

如果我们把每个1x1的单元格看作一个需要被覆盖的“任务”,那么每个正方形就提供了一种覆盖其中若干个连续“任务”的方案。问题转化为:是否存在一种方案,从这19种方案中各选一次,恰好完成所有2209个任务?这就是精确覆盖问题的典型定义。

最直观的搜索思路是:从左上角(0,0)开始,尝试放入第一个正方形,标记被覆盖的格子,然后递归地尝试放入下一个正方形,直到放完所有正方形并铺满整个大正方形,或者中途发现无法继续而回溯。

但让我们估算一下最坏情况下的搜索空间。假设我们不考虑位置,只考虑19个正方形的排列顺序,就有19! ≈ 1.22e17种可能。这已经是天文数字。再乘上每个正方形可能放置的位置(对于边长为s的正方形,在47x47的网格中大约有(47-s+1)^2个可能位置),这个搜索树大到任何计算机都无法在有限时间内遍历。

因此,纯暴力DFS是死路一条。我们必须引入强大的剪枝策略和启发式方法,将搜索引导到最有可能成功的分支上,并果断砍掉那些明显无望的分支。这是解决本问题的核心,也是算法设计艺术性的体现。

3. 核心策略:如何为DFS装上“导航”和“刹车”

要让DFS能在合理的时间(比如几秒到几分钟)内找到解,我们需要一套组合策略。这些策略的强弱,直接决定了程序的效率。

3.1 策略一:最紧凑填充——从左到右,从上到下

这是控制搜索顺序的基础策略。我们规定一个固定的填充顺序:总是寻找当前棋盘上未被覆盖的、最靠左、最靠上的那个格子(记为(x, y))。然后,我们只尝试将剩余的正方形覆盖到这个格子上。

为什么这样做是有效的?

  • 消除对称性:如果不固定顺序,同一个解会因为放置顺序不同而被重复搜索无数次。固定从左上角开始填充,相当于我们总是优先填补“空洞”的左上角,这自然定义了一种唯一的填充路径,避免了因顺序不同导致的重复解。
  • 简化决策:在每一步,我们只需要关心“当前这个空洞怎么填”,而不是“下一个方块放哪儿”。这大大缩小了每一步的选择范围。

3.2 策略二:按需取材——正方形按从大到小排序尝试

在确定了要填充的左上角空洞(x, y)后,我们有哪些正方形可以放呢?所有还没被使用的、边长小于等于该位置到右边和下边剩余空间的正方形,理论上都可以放。但尝试的顺序至关重要。

一个非常有效的启发式是:优先尝试剩余正方形中边长最大的那个。

背后的逻辑是什么?

  • “先难后易”原则:大正方形放置的选择相对较少,对棋盘空间的划分影响也大。尽早把大家伙安排好,可以更快地暴露出矛盾(空间不够或形状不规则),从而促使搜索尽早回溯,避免在小块拼接上浪费大量时间后才因大块无处安放而失败。
  • 减少分支:先放大的,剩下的空间更容易被较小的方块填充。如果先放小的,可能会把棋盘分割成许多奇形怪状的小区域,导致后续的大方块根本无法放入,但搜索树却已经深入了很多层。

因此,在程序开始时,我们就应该将19个正方形的边长列表从大到小排序。在每一步选择时,也按照这个排序后的顺序,依次尝试那些能放入当前位置的正方形。

3.3 策略三:预见失败——可行性剪枝(未来空间检查)

这是最强有力的剪枝策略之一。在准备将一个正方形放入当前位置(x, y)之前,我们可以快速检查一下:即使成功放入这个方块,剩下的空间是否有可能用剩余的正方形填满?

一个经典的检查方法是“面积剪枝”:计算剩余未覆盖的格子总数(即剩余面积),再计算剩余所有正方形的面积之和。如果剩余面积大于剩余正方形面积之和,说明肯定填不满,剪枝。但本题所有正方形必须全部使用,所以剩余面积必须等于剩余正方形面积之和,这个剪枝是隐含的。

更精细的剪枝是“轮廓线剪枝”或“最小边长剪枝”。我们可以观察当前未覆盖区域形成的“轮廓”。如果存在某个未被覆盖的连续区域,其宽度或高度小于剩余正方形中的最小边长,那么这个区域永远无法被填充,可以直接回溯。实现这种剪枝需要实时维护棋盘的空洞信息,有一定复杂度,但对于大幅提升效率至关重要。

一个相对容易实现且效果不错的简化版是:在放置正方形后,立即检查新产生的“左上角空洞”(new_x, new_y)。如果这个空洞所在的行,其右侧连续的空格长度小于剩余正方形中的最小边长,那么这个空洞所在的行就无法被任何剩余正方形填充,可以回溯。

3.4 策略四:高效记账——棋盘的状态表示与回溯

我们需要一个数据结构来记录47x47的棋盘中,每个格子是否被覆盖。最简单的是用一个二维数组board[47][47],用0表示空,用正方形的编号(如1到19)表示被哪个正方形覆盖。

放置操作:当决定将边长为s的正方形square_id放入左上角(x, y)时,我们需要将区域[x, x+s)和[y, y+s)内的所有board[i][j]设置为square_id。回溯操作:当需要撤销这次放置时,再将同一区域内的board[i][j]恢复为0。

这里有一个关键优化:避免每次都检查整个区域是否为空。在放置前,我们需要确保目标区域的所有格子当前都是0。最朴素的方法是写一个双重循环检查s*s个格子。当s较大且放置频繁时,这是个开销。我们可以通过维护每行/每列的“连续空格”信息来加速,但对于本题规模,直接检查s*s区域在剪枝强大后是可以接受的。

更重要的优化是快速找到“最左上的空洞”。我们不必每次从头扫描整个board。可以维护一个全局变量(cur_x, cur_y)表示当前填充位置。放置一个正方形后,新的(cur_x, cur_y)可以从原位置向右、向下扫描找到第一个为0的格子。由于我们总是优先填充左上角,这个空洞的位置是单调非递减的。

4. 从思路到代码:深度优先搜索的实现骨架

理解了核心策略,我们可以勾勒出DFS的递归函数框架。这里使用C++语言进行示意,因为它能提供足够的控制力和效率。

首先,定义全局数据结构和变量:

const int N = 47; // 大正方形边长 const int SQUARE_CNT = 19; // 正方形数量 int board[N][N] = {0}; // 棋盘,0为空 int squares[SQUARE_CNT]; // 正方形边长列表 bool used[SQUARE_CNT] = {false}; // 标记正方形是否已使用 int cur_x = 0, cur_y = 0; // 当前待填充的左上角位置 bool found = false; // 是否已找到解

初始化squares数组为题目给定的19个边长,并从大到小排序。

int init_squares[] = {2,5,9,11,16,17,19,21,22,24,26,30,31,33,35,36,41,46,47}; std::copy(std::begin(init_squares), std::end(init_squares), squares); std::sort(squares, squares + SQUARE_CNT, std::greater<int>()); // 从大到小排序

核心的DFS递归函数dfs(int placed_count):

  1. 边界条件:如果placed_count == SQUARE_CNT(19个全放完),并且cur_x == N(意味着所有行都已扫描完,没有空洞),则找到解,输出并设置found = true。
  2. 寻找空洞:如果当前(cur_x, cur_y)已被覆盖,则更新(cur_x, cur_y)到下一个空洞。如果找不到空洞(即所有格子非0),但placed_count < SQUARE_CNT,说明出错,回溯。
  3. 尝试放置:遍历所有未使用的正方形(按从大到小的顺序): a.快速剪枝1:如果该正方形边长s大于从(cur_x, cur_y)到右边界或下边界的距离,则跳过。 b.放置检查:检查区域[cur_x, cur_x+s)x[cur_y, cur_y+s)是否全部为0。 c.可行性剪枝(可选):在这里可以加入3.3节提到的精细剪枝,例如检查放置后产生的新的潜在空洞是否过小。 d.放置:标记该区域为当前正方形ID,标记该正方形为已使用。 e.保存状态:保存当前的(cur_x, cur_y)。 f.更新空洞位置:计算新的空洞位置。一个简单策略是从(cur_x, cur_y)向右找到第一个0,如果到行末则跳到下一行开头。 g.递归:调用dfs(placed_count + 1)。 h.回溯:如果递归返回后found为假,则恢复棋盘区域为0,恢复正方形使用状态,恢复(cur_x, cur_y)到保存的状态。
  4. 递归返回:如果所有正方形尝试完毕都未成功,则函数返回,回溯到上一层。

注意:这是一个高度简化的骨架。在实际实现中,步骤2(更新空洞位置)和步骤3c(可行性剪枝)的实现细节对性能影响巨大。一个低效的“找空洞”函数可能会成为性能瓶颈。

5. 性能跃迁:关键优化技巧与踩坑实录

按照上述框架,你可能已经能写出一个能运行的程序,但很可能跑几分钟甚至几小时都出不来结果。下面分享几个让我从“能跑”到“秒出”的关键优化点和踩过的坑。

5.1 优化一:跳跃式更新“当前空洞”

最朴素更新(cur_x, cur_y)的方法是:放置方块后,从原位置(cur_x, cur_y)开始,向右、向下逐格扫描board,找到第一个0。这在递归深处会进行大量重复扫描。

优化方案:在放置方块前,我们就知道放置的矩形区域。放置后,新的空洞只可能出现在三个地方:

  1. 原空洞位置(cur_x, cur_y)的右边(cur_x, cur_y+1)(如果没出界且为空)。
  2. 原空洞所在行的末尾,下一行的开头。
  3. 更后面的某个位置(如果放置的方块比较大,跳过了很多行)。

一个高效的方法是:实现一个函数find_next_hole(int &x, int &y),它从(x, y)开始找,找到后直接更新x, y。在递归调用前后,我们保存和恢复的是(cur_x, cur_y)这个坐标值,而不是在回溯时重新扫描。这避免了大量重复计算。

5.2 优化二:位运算加速棋盘检查与更新(针对C++)

对于47x47的棋盘,用int数组有点浪费。我们可以用位棋盘的思想,使用C++的std::bitset或直接使用整数的位操作。例如,可以用一个unsigned long long数组来表示每一行的占用情况(47位需要多个ull拼接)。这样,检查一个矩形区域是否全空,可以通过对几行进行位与(&)操作快速完成;放置方块也可以通过位或(|)操作快速标记。这能带来常数级的巨大加速。

不过,位运算实现起来较为复杂,容易出错。在竞赛环境中,如果时间紧迫,一个正确但稍慢的二维数组版本可能更稳妥。但在追求极致性能时,位棋盘是必经之路。

5.3 踩坑:排序与尝试顺序的微妙影响

我最初尝试时,虽然将正方形从大到小排序了,但在为特定空洞选择正方形时,我遍历了所有未使用的正方形。这其实不是最优的。因为对于一个小空洞,尝试放一个很大的正方形(即使边长超过空间,在剪枝1中跳过)在循环判断上也是开销。

更优的做法:在递归函数的开始,根据当前空洞(cur_x, cur_y),预先计算出能放的最大边长max_s(即min(N-cur_x, N-cur_y))。然后,只遍历那些边长<= max_s且未使用的正方形。由于正方形列表已排序,我们可以用一个循环从大到小遍历,遇到边长> max_s的就可以提前结束循环。

5.4 踩坑:忽视“必须用完所有正方形”带来的提前剪枝

本题要求所有19个正方形必须全部使用。这意味着,在搜索的任何中间状态,剩余未覆盖的面积必须等于剩余所有正方形的面积之和。这是一个非常强的约束。

我们可以在递归开始时加入一个检查:计算当前棋盘剩余的空格数(可以通过总格子数减去已放置正方形的面积和得到)。如果这个数不等于剩余正方形的面积之和,直接回溯。这个计算可以在O(1)内完成,如果我们维护一个remaining_area变量,每次放置和回溯时更新它。

这个剪枝威力巨大,因为它能提前否决那些“虽然当前能放下某个方块,但总面积已经对不上”的错误分支。

6. 最终代码结构与结果分析

综合以上所有策略和优化,一个高效的解决方案包含以下模块:

  1. 数据准备与初始化:读入/定义19个边长,从大到小排序。初始化棋盘和状态变量。
  2. DFS递归函数:包含寻找空洞、遍历候选正方形、放置检查、面积剪枝、放置、状态更新、递归、回溯等步骤。
  3. 输出函数:当找到解时,以清晰的形式打印棋盘。通常用数字1-19表示每个格子属于哪个正方形。

在我的实现中,加入了上述所有优化(位棋盘除外,但使用了紧凑的状态表示和高效剪枝)后,程序在普通的个人电脑上可以在10秒以内找到解。

输出的解是一个47x47的数字矩阵,每个数字代表覆盖该格子的正方形编号。验证解是否正确,只需检查:

  • 所有格子是否为1-19的数字。
  • 每个数字(代表一个正方形)出现的次数是否等于其边长的平方。
  • 每个数字构成的区域是否是一个连续的实心正方形。

对于本题给定的数据,解是存在的,这也是它被称为“完美”正方形的原因。找到解后,你可以用图形化的方式将其绘制出来,那将是一个由19个色彩各异的方块组成的、充满数学美感的图案。

7. 举一反三:从一道题到一类题

“完美正方形”虽然特指边长为47的情况,但它代表的是一类棋盘覆盖或几何分割问题。解决它的思想可以迁移到许多场景:

  • 其他尺寸的完美正方形:是否存在边长为其他整数的完美正方形?历史上数学家们对此有深入研究。例如,边长为112的正方形可以用21个不同大小的正方形完美分割。我们的算法稍作修改(调整边长和正方形列表)即可求解此类问题。
  • 矩形填充问题:目标容器变成矩形,填充物可能是正方形、矩形或其他多边形。核心的DFS+剪枝+启发式顺序框架依然适用。
  • 资源调度与排布:这可以抽象为在固定空间内放置不可重叠的物体。例如,芯片布局、广告牌排版、集装箱装载等。虽然实际问题约束更多(物体可旋转、形状不规则),但回溯搜索的核心思想是相通的。

回过头看,解决“完美正方形”的过程,是一次经典的算法优化实战:从问题建模(精确覆盖),到选择算法框架(DFS),再到针对性地设计剪枝策略(顺序剪枝、最优性剪枝、可行性剪枝),最后进行工程优化(状态表示、搜索顺序)。它锻炼的不仅仅是编码能力,更是对问题深度理解、对算法效率的掌控以及对计算复杂度的敬畏。当你最终看到程序输出那个完美的数字方阵时,那种通过智慧和代码征服复杂问题的成就感,或许正是算法竞赛最吸引人的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询