公司动态

蓝桥杯国赛“完美正方形”深度解析:DFS剪枝与精确覆盖算法实战

📅 2026/8/29 17:36:01
蓝桥杯国赛“完美正方形”深度解析:DFS剪枝与精确覆盖算法实战
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它有几个铁律容器固定目标是一个47x47的网格为方便我们可以用坐标(0,0)到(46,46)表示。所有操作必须在这个边界内。碎片固定有且仅有19个特定边长的正方形每个边长对应一个正方形每个正方形必须被使用恰好一次。填充规则正方形必须水平/垂直放置不能旋转放置后其覆盖的每个单元格都标记为“已占用”。目标状态所有47*472209个单元格都被占用且没有两个正方形覆盖的单元格有交集。如果我们把每个1x1的单元格看作一个需要被覆盖的“任务”那么每个正方形就提供了一种覆盖其中若干个连续“任务”的方案。问题转化为是否存在一种方案从这19种方案中各选一次恰好完成所有2209个任务这就是精确覆盖问题的典型定义。最直观的搜索思路是从左上角(0,0)开始尝试放入第一个正方形标记被覆盖的格子然后递归地尝试放入下一个正方形直到放完所有正方形并铺满整个大正方形或者中途发现无法继续而回溯。但让我们估算一下最坏情况下的搜索空间。假设我们不考虑位置只考虑19个正方形的排列顺序就有19! ≈ 1.22e17种可能。这已经是天文数字。再乘上每个正方形可能放置的位置对于边长为s的正方形在47x47的网格中大约有(47-s1)^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, xs)和[y, ys)内的所有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::greaterint()); // 从大到小排序核心的DFS递归函数dfs(int placed_count)边界条件如果placed_count SQUARE_CNT19个全放完并且cur_x N意味着所有行都已扫描完没有空洞则找到解输出并设置found true。寻找空洞如果当前(cur_x, cur_y)已被覆盖则更新(cur_x, cur_y)到下一个空洞。如果找不到空洞即所有格子非0但placed_count SQUARE_CNT说明出错回溯。尝试放置遍历所有未使用的正方形按从大到小的顺序 a.快速剪枝1如果该正方形边长s大于从(cur_x, cur_y)到右边界或下边界的距离则跳过。 b.放置检查检查区域[cur_x, cur_xs)x[cur_y, cur_ys)是否全部为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)到保存的状态。递归返回如果所有正方形尝试完毕都未成功则函数返回回溯到上一层。注意这是一个高度简化的骨架。在实际实现中步骤2更新空洞位置和步骤3c可行性剪枝的实现细节对性能影响巨大。一个低效的“找空洞”函数可能会成为性能瓶颈。5. 性能跃迁关键优化技巧与踩坑实录按照上述框架你可能已经能写出一个能运行的程序但很可能跑几分钟甚至几小时都出不来结果。下面分享几个让我从“能跑”到“秒出”的关键优化点和踩过的坑。5.1 优化一跳跃式更新“当前空洞”最朴素更新(cur_x, cur_y)的方法是放置方块后从原位置(cur_x, cur_y)开始向右、向下逐格扫描board找到第一个0。这在递归深处会进行大量重复扫描。优化方案在放置方块前我们就知道放置的矩形区域。放置后新的空洞只可能出现在三个地方原空洞位置(cur_x, cur_y)的右边(cur_x, cur_y1)如果没出界且为空。原空洞所在行的末尾下一行的开头。更后面的某个位置如果放置的方块比较大跳过了很多行。一个高效的方法是实现一个函数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. 最终代码结构与结果分析综合以上所有策略和优化一个高效的解决方案包含以下模块数据准备与初始化读入/定义19个边长从大到小排序。初始化棋盘和状态变量。DFS递归函数包含寻找空洞、遍历候选正方形、放置检查、面积剪枝、放置、状态更新、递归、回溯等步骤。输出函数当找到解时以清晰的形式打印棋盘。通常用数字1-19表示每个格子属于哪个正方形。在我的实现中加入了上述所有优化位棋盘除外但使用了紧凑的状态表示和高效剪枝后程序在普通的个人电脑上可以在10秒以内找到解。输出的解是一个47x47的数字矩阵每个数字代表覆盖该格子的正方形编号。验证解是否正确只需检查所有格子是否为1-19的数字。每个数字代表一个正方形出现的次数是否等于其边长的平方。每个数字构成的区域是否是一个连续的实心正方形。对于本题给定的数据解是存在的这也是它被称为“完美”正方形的原因。找到解后你可以用图形化的方式将其绘制出来那将是一个由19个色彩各异的方块组成的、充满数学美感的图案。7. 举一反三从一道题到一类题“完美正方形”虽然特指边长为47的情况但它代表的是一类棋盘覆盖或几何分割问题。解决它的思想可以迁移到许多场景其他尺寸的完美正方形是否存在边长为其他整数的完美正方形历史上数学家们对此有深入研究。例如边长为112的正方形可以用21个不同大小的正方形完美分割。我们的算法稍作修改调整边长和正方形列表即可求解此类问题。矩形填充问题目标容器变成矩形填充物可能是正方形、矩形或其他多边形。核心的DFS剪枝启发式顺序框架依然适用。资源调度与排布这可以抽象为在固定空间内放置不可重叠的物体。例如芯片布局、广告牌排版、集装箱装载等。虽然实际问题约束更多物体可旋转、形状不规则但回溯搜索的核心思想是相通的。回过头看解决“完美正方形”的过程是一次经典的算法优化实战从问题建模精确覆盖到选择算法框架DFS再到针对性地设计剪枝策略顺序剪枝、最优性剪枝、可行性剪枝最后进行工程优化状态表示、搜索顺序。它锻炼的不仅仅是编码能力更是对问题深度理解、对算法效率的掌控以及对计算复杂度的敬畏。当你最终看到程序输出那个完美的数字方阵时那种通过智慧和代码征服复杂问题的成就感或许正是算法竞赛最吸引人的地方。