浅谈暴力与搜索算法
前言
暴力与搜索算法也许是 \(\text{OI}\) 中非常重要的一个领域,在想不到正解的前提下,把部分分拿高拿稳也能取得不错的成绩。而拿稳部分分的这门本领,需要每个 \(\text{oier}\) 在日常的学习中不断地积累、训练,笔者在这里提供的是自己的一些经验、思考。全文分为两大板块,分别讲述 暴力的技巧 和 搜索综合 ,希望能对正在阅读的你有所帮助。
暴力 \(\text{tricks}\)
暴力的重要性
暴力和部分分通常能够保证在考试中拿到一个大众的水平,可以说是兜底得吧。
打的全是暴力但是有 \(\text{WC Au}\) ,你说重不重要。
暴力的时间分配
以下纯笔者个人习惯,不喜勿喷。
在考试过程中,我通常会在两个时间段集中思考部分分。
-
开考前 \(\text{30 min}\) 内,这个阶段对题目通读一遍,在读题的过程中就要对每个题有一个短时间的思考,思考自己已经会的部分分、感觉可以拿到的部分分还有对于这一题的难度进行一个估计。做到读完题对考试有一个大概的了解,也知道了每一题比较基础的部分分。
-
在考试过程中决策认定继续冲正解不划算或者没有时间的时候,就要全力冲部分分了。思考如何获得更加高阶的部分分,包括但不限于:特殊性质、随机性质或者降低程序的复杂度等等。
这两个阶段都是极为重要的,一方面能够为考试兜底,另一方面也可以通过部分分摸索出正解。
从全排列到状压或搜索
这算是多次考试中得出来的一个经验吧。
全排列
很多题目按照题意模拟,不加任何优化通常是直接全排列可以解决的。
\(O(n!)\) 通常能通过 \(n \leq 10\) 的数据,可以获得最低一档的暴力分。
状压
很多题目中都可以将全排列换成状压,将复杂度降到 \(O(2^n)\) ,通常能通过 \(n \leq 20\) 的数据,获得稍高一点的分。
举两个例子:
\(\text{NOIP2022 }\)建造军营 这道题,状压就可以比阶乘复杂度多拿到 \(20pts\) 。
\(\text{NOIP2021}\) 数列 通过状压可以直接获得 \(50pts\) ,比直接枚举每一位的数字的分高了很多。
连续两年了联赛出现了,状压重要性不用多说。
搜索
本质上还是枚举所有的状态,但是在枚举的过程中加入各种优化手段来提升程序运行效率,有可能可以获得较高的分数。
重视平方暴力
对于很多数据范围 \(n\leq 10^5\) 或者 \(n\leq 10^6\) 的题目,通常正解是需要数据结构维护或者要发现一些特定的性质。这一类题一般都有一档部分分让 \(O(n^2)\) 可以通过,而且分值一般可以占到一半甚至以上。
这类部分分考的最多的就是 \(\text{dp}\) ,将状态设计好,推出转移方程,这类题目通常朴素 \(\text{dp}\) 就是 \(O(n^2)\) 级别的,一方面拿到了不错的部分分,另一方面想办法优化兴许就是正解。
举个例子:
\(\text{Cai:}\)图样图森破 这道题只要推出来朴素的 \(\text{dp}\) 就可以拿到 \(50pts\) ,用线段树合并来优化就是正解。
\(\text{NOI2016}\) 优秀的拆分 这题直接暴力 \(O(n^2)\) 哈希可以拿到 \(95pts\) 的高分。
考虑随机性质
随机性质在最近几次 \(\text{CCF}\) 组织的考试中都有所出现,相较于往年有很大比重的增加。
如果某一档部分分说:保证 \(\text{xx}\) 随机,那么一般有两种情况。
直接放暴力过
包括但不限于排列随机生成、树随机生成保证树高......
那么一般而言,你的暴力程序是可以通过这一档的。
以下几个题目都有随机性质的部分分
\(\text{CSP2022}\) 数据传输 随机保证树高是 \(\log n\) ,而这只要打出了平方暴力就可以额外得到这 \(24pts\) 。
\(\text{WC2023}\) 树形结构 随机保证子树和在 \(n \log n\) 左右,平方暴力可以过 \(48pts\) 。
利用随机性质
需要采用针对该随机性质的算法。
\(\text{NOIP2022}\) 比赛 由于随机生成的排列,不同前缀 \(\max\) 的数量是 \(\log\) 级别的,因此用单调栈维护一下可以通过 \(32pts\) 。
考虑特殊性质
见的次数比较多的特殊性质有以下几种
- 树的形态为一条链
- 树的形态为一朵菊花
- 不需要可持久化
- 题目给定的某个参数比较特殊
- 不存在某一种操作
- .....
对于每一种特殊性质,都要结合题目考虑看是否对解题有帮助,并可以以此为线索深入思考正解。
\(\text{NOIP2022}\) 建造军营 链的部分分是简单的组合数学 ,有 \(10pts\) 。
\(\text{NOIP2922}\) 喵了个喵 存在 \(k=2n-2\) 的部分分 \(15pts\) ,这也是引导出正解的关键。
\(\text{WC2023}\) 楼梯 \(\text{A}\) 性质允许离线下来,把所有的询问一同处理。
减少暴力代码的时间开销
倍增暴力做,字符串 \(\text{hash}\) 艹 。对于复杂度并不是正解的程序,考虑剪枝优化提高效率,详见后文搜索板块的剪枝技巧。
让部分分代码更加简洁清晰
是不是经常遇到,对于一道题目不同部分分采用多种做法时经常出现代码混乱,数组、变量用串的情况。
下面这种办法可以让你的代码变得更加简洁清晰,不会出现变量数组用串的情况。
struct Subtask1 {
int ....... ;
inline void function1() { /* */ }
inline void function2() { /* */ }
......
inline void solve() {
// solve subtask1
}
}sub1;
struct Subtask2 {
//solve subtask2
}sub2;
struct Subtask3 {
//solve subtask3
}sub3;
......
int main() {
//Input
if(satisfy subtask1) sub1.solve();
else if(satisfy subtask2) sub2.solve();
else sub3.solve();
return 0;
}
暴力总结
上面很长的篇幅,对于如何拿稳拿高部分分也只是冰山一角,真正适合自己的方法需要大家在平常的训练和考试中积累,领悟,如果有新的技巧欢迎补充啊!
搜索
朴素的 \(\text{dfs}\) 和 \(\text{bfs}\) 较为简单,本文不在赘述,下面的内容主要简述一些进阶的搜索方法和搜索技巧。
剪枝技巧
剪枝优化能够提升搜索效率,拿到更多的分。本文先介绍几个常见的剪枝概念,再配合一个例题进行讲解。
常见剪枝策略
- 最优性剪枝:即如果当前搜索的解已经确定比已有的解更劣时,就不用继续搜索了。
- 可行性剪枝:如果能够判断当前状态已经不合法或无法达到合法的状态,就不用继续搜索了。
- 卡时:如果已经达到时限但是搜索没有结束,就将已经搜过的最优解输出,或者宣告无解。
- 对于数据进行处理:可以对数据进行排序、预处理等操作,让搜索树的分支尽可能小。
- \(\text{Minimax} 博弈问题\)的 \(\text{alpha-beta}\) 剪枝(本文不进行讲解)。
- 根据特定的题目确定特殊的剪枝技巧
- .......
例题 \(\text{P1120}\) 小木棍
这是一道练习剪枝的好题目,需要用若干种剪枝技巧才能通过。
先明确这题大概的思路,我们从多到少枚举原始木棍的数量,假设此时枚举的木棍长度为 \(len\) ,设计搜索状态为 dfs(v,res,las) 表示当前已经确定完了 \(v\) 根,目前正在拼的木棍剩余长度为 \(res\) ,上一次选取的木棍长度为 \(las\) 。每次枚举将那个木棍拼到当前正在拼的那一根上。
我们考虑对搜索过程进行剪枝。
-
我们从多到少枚举原始木棍的数量,一旦当前已经合法,就不再进行搜索。
-
记枚举的数量为 \(c\) ,所有木棍的长度之和为 \(sum\) ,当且仅当 $ c \mid sum$ 时才进行搜索。
-
由于长木棍比短木棍适用性更差,因此如果在某个状态时选择放了长木棍,那么后续的可能状态就越少,因此我们将木棍按照长度排序,拼的每一根木棍都按照长度递减的顺序放,达到减少搜索树分支的目的。
-
根据本题的条件,有一个特别重要的剪枝:如果放了长度为 \(a\) 的木棍不合法,并且 \(res=a\) 或者 \(res=len\) 那么说明当前这一个状态就不可能合法了。可以这样理解,当前这个状态必须要放一个长度为 \(a\) 的木棍,已经放了但是不合法,那么就可以确定当前这个状态不合法了。
-
这是一个细节的优化,由于将木棍按照长度递增排好了序,而且每次要找最后一个长度小于等于 \(rest\) 的木棍,从它开始枚举。这个我们可以在搜索之前就预处理出来,降低常数。
-
这也是一个细节优化,如果放了长度为 \(a\) 的木棍不合法,那么对于剩下的长度为 \(a\) 的木棍就都不需要考虑了,我们可以在搜索前预处理出每根木棍前面第一个与之长度不同的木棍位置。
加上这些剪枝,就已经可以通过了,如果还有新的剪枝办法,也欢迎补充哦!
这一题几乎涵盖了所有的剪枝手段,有套路性的,也有根据这题的条件而特有的。而后者往往需要我们找性质的能力,需要不断地积累提高。
双向 \(\text{bfs}\) & 双向 \(\text{dfs}\)
双向搜索,即从起点和终点状态都开始搜索,让它们在中间“碰”上,这就是双向搜索。
为什么要采用双向搜索
用一张图片的对比来直观的说明:

从两点同时开始搜,让它们在中间相遇相较于从起点一直搜到终点,搜索树的深度减小了一半,但由于搜索树的大小一般是指数级别的,将其深度缩小一半其实减少了很多状态。
例题 \(\text{ABC271F.XOR on Grid Path}\)
如果直接枚举每个点往右还是往下,路径长度是 \(40\) ,所以时间复杂度是 \(O(2^{2n})\) 无法通过,我们可以先从起点走一半,把可行的异或值记录下来,再从起点走一半,看到的点有没有记录相同的异或值,如果有那么有解。由于只走一半,所以时间复杂度是 \(O(2^n)\) 可以通过。
迭代加深
迭代加深算法可以视为是将 \(\text{dfs}\) 和 \(\text{bfs}\) 结合起来,如果单纯使用 \(\text{dfs}\) 的话,可能出现在一条路径上走到黑也找不到解的情况。而迭代加深就是通过每次限制搜索树的深度,在搜索树的各个分支上 "齐头并进",在某些特殊情况下表现更出色。
还是用一张图片对比来感受迭代加深的作用。

例题 \(\text{P1763}\) 埃及分数
这题要求最小的项数,因此我们枚举项数,也就是搜索树的深度,逐步加深,再进行 \(\text{dfs}\) 。
我们设 \(res\) 为剩余分数, \(d\) 为当前深度,\(step\) 是限制的深度,当前要添加一项 \(\frac{1}{i}\) 。
这题剪枝主要有以下几个:
- 如果 \(res\leq \frac{step-d+1}{10^7}\) 那么就可以直接返回了。
- 如果 \(res>\frac{step-d+1}{i}\) 也可以返回了。
- 每次分母的下界是 \(\min(las+1,\frac{1}{res})\) ,\(las\) 为上一项的分母。
- 记已经枚举过的最后一项最小值为 \(mn\) ,那么之后枚举分母的上界为 \(mn\) 。
可以看出,这一题是迭代加深和剪枝优化搭配起来使用的,在本部分介绍的技巧都不是孤立的,要学会融会贯通,搭配使用,取长补短以达到最高的效率。
\(\text{A*}\)
思考一个问题:如果让你来完成一个搜索的工作,人脑通常会优先选择“最有可能”“看起来最优”的方向,而这也就是 \(\text{A*}\) 算法的本质所在:估价函数的加入,在搜索过程中就会优先搜索“最有潜力”的方向。我们记某个状态的权值 \(v_s=f_s+g_s\) ,其中 \(f_s\) 是搜到现在为止的代价,而 \(g_s\) 为后续过程期望代价。
估价函数的设定
不难看出 \(\text{A*}\) 算法最重要的就是确定恰当的估价函数,接下来我们分析一下估价值与实际值的大小差异会带来怎样的影响。
- 估价值小于等于实际值:优化效率相较于后者不高,保证答案正确。
- 估价值大于实际值:优化效率很高,但是不保证答案正确。
为什么呢?
当估价等于 \(0\) 时相当于就是普通的搜索,当估价值越来越大时,对于搜索的影响也就越来越大,因此优化效率增高。当到达终点时,\(g_s\) 就变成了 \(0\) ,也就是此时 \(v_s\) 就等于这个状态的真实代价。而一旦估价超过实际,假设真实的最优解其期望代价大于真实代价,那么可能另外一个解到达终点的时候,最优解还没有被更新。
估价函数的设定因题而异,具体情况具体分析。比较常见的有:最短路、不同的位置数等。
例题 \(\text{SDOI2010}\) 魔法猪学院
\(\text{ps.}\) 此题 \(\text{A*}\) 算法无法通过,正解为可持久化可并堆。如果能达到仅剩下 \(\text{hack}\) 未通过的程度,说明你的 \(\text{A*}\) 是合格的。
关于本题我们将每个点的估价设为其到终点的最短路径长度,跑 \(\text{dijkstra}\) 的过程中按照 \(f+g\) 递增的顺序排序,每次取出已有代价加上期望代价最小的更新。
\(\text{IDA*}\)
是迭代加深算法与 \(\text{A*}\) 算法的结合,在迭代加深的过程中,引入了估价函数。结合下面这道例题具体讲解。
例题 \(\text{P1379}\) 八数码难题
听说有人八维数组艹过去了这题。
这题的正解有很多,比如前文提到的双向 \(\text{bfs}\) 也可以通过,这里介绍 \(\text{IDA*}\) 的做法。
我们将估价函数设定为当前局面与目标局面有多少个位置不同,让搜索树的深度逐步加深,记深度限制为 \(step\) ,如果已有操作次数加上期望操作次数大于 \(step\) 就直接返回。
舞蹈链 \(\text{DLX}\)
终于到最后一个部分了。 本文会详细讲述舞蹈链的原理及其应用。
舞蹈链用于解决以下这一类问题,我们称之为 精准覆盖问题 :
有一些问题需要被回答,还有一些学生你决定是否选择,如果选择的话,该学生会回答其中若干个问题。现在你要选出一些学生,满足所有的问题都被回答,且被回答恰好一次。
更加形式化的描述:
给定许多集合 \(S_i(1\leq i\leq n)\) ,以及一个集合 \(X\) ,现要求选出满足以下条件的无序多元组 \((T_1,T_2,...,T_m)\) :
- \(\forall T_i \in {(S_1, S_2, ..., S_n)}\)
- \(\forall i,j \in[1,m], i\neq j,T_i \cap T_j= \phi\)
- \(\cup_{i=1}^m T_i=X\)
先看舞蹈链的例题
例题 \(\text{P4929}\) 舞蹈链
这里首先介绍 \(\text{X算法}\) 来解决精准覆盖问题。
假设得到了这样一个 \(\text{01}\) 矩阵。
- 我们选择第一行,并将与第一行相关的行列删除,得到如下矩阵。
- 此时我们继续将第一行及与之相关的行列删除,得到如下矩阵。
-
此时矩阵删空,但是由于上一次选择的行并不是全 \(1\) ,因此这样的选择方式不合法,回溯到第 \(2\) 步。
-
我们选择将第二行及其相关行列删除,得到如下矩阵。
- 将第一行删除。
- 矩阵再次删空,并且最后一次操作是删掉的行是全 \(1\) ,因此该解合法,输出答案 \(1,4,5\) 。
根据此过程归纳, \(\text{X}\) 算法流程如下:
-
在当前剩余的矩阵 \(M\) 内选择一行 \(r\) ,将 \(r\) 行及其相关的行列删除,得到新的矩阵 \(M^{\prime}\)。
-
如果矩阵 \(M^{\prime}\) 非空,则继续执行 \(1\) 操作;
如果矩阵 \(M^{\prime}\) 为空,并且第 \(r\) 行全 \(1\) ,宣告有解,输出答案;
如果矩阵 \(M^{\prime}\) 为空,并且第 \(r\) 行非全 \(1\) ,恢复被 \(r\) 删除的所有行列,回到步骤 \(1\) 。
不难看出,该算法存在大量的删除行列以及恢复行列的操作,朴素暴力的维护复杂度难以接受。
\(\text{Donald E. Knuth}\) 提出用双向十字链来维护这些操作。
在双向十字链表上不断跳跃的过程被形象的比喻为“跳跃”,因此用来优化 \(\text{X}\) 算法的双向十字链表被称为“\(\text{Dancing Links}\)”。
\(\text{Dancing Links}\) 优化的 \(\text{X}\) 算法
定义
舞蹈链只记录是 \(1\) 的那些结点。
双向十字链表维护这些信息:每个结点上、下、左、右的结点,同时维护这个结点所在行列。对于每一行维护一个行指示,对于每一列,维护一个表示这一列的结点、这一列的元素个数。

整个链表大概就长成这个样子:

过程
\(\text{build}\) 操作
新建一个包含 \(c\) 列的双十字链表。
新建 \(c\) 个点作为列指示,\(0\) 号点与这些列指示结点相连。
第 \(i\) 个点左结点为 \(i-1\) ,右结点 \(i+1\) ,上、下结点均为 \(i\) 。
特别的, \(0\) 结点的左结点为 \(c\) ,\(c\) 结点的右结点为 \(0\) 。
如果 \(0\) 结点的右结点为 \(0\) ,则说明这个 \(\text{DLX}\) 为空。
inline void build(int r,int c) {
idx=c; ans=-1;
for(int i=0;i<=c;i++) L[i]=i-1, R[i]=i+1, U[i]=D[i]=i;
L[0]=c; R[c]=0; mem(fir); mem(sz);
}
\(\text{insert}\) 操作
在第 \(r\) 行第 \(j\) 列插入一个点。
新开一个节点,该结点行为 \(r\) ,列为 \(j\) 。
将其插入第 \(c\) 列,插在列指示和列指示下结点的中间。
将其插入第 \(r\) 行,如果有行指示,插在行指示和行指示右结点中间,否则标记该行行指示为当前节点。
inline void ins(int r,int c) {
++idx; row[idx]=r; col[idx]=c; ++sz[c];
U[idx]=c; D[idx]=D[c]; U[D[c]]=idx; D[c]=idx;
if(!fir[r]) fir[r]=L[idx]=R[idx]=idx;
else L[idx]=fir[r], R[idx]=R[fir[r]], L[R[fir[r]]]=idx, R[fir[r]]=idx;
}
\(\text{remove} 操作\)
将第 \(c\) 列及其相关的行列删除。
找到第 \(c\) 列的列指示结点,将其与左右两列的列指示点断开,从列指示点一直想下跳,直到回到该结点。
对于跳到的每一个点,再从其出发不断向右节点跳,直到回来,将经过的每一个点与其上下结点断开。
inline void rem(int c) {
L[R[c]]=L[c]; R[L[c]]=R[c];
for(int i=D[c];i!=c;i=D[i])
for(int j=R[i];j!=i;j=R[j]) U[D[j]]=U[j], D[U[j]]=D[j], --sz[col[j]];
}
\(\text{recover} 操作\)
恢复第 \(c\) 列及其相关行列。
同 \(\text{remove}\) 操作,注意恢复的顺序跟删除的顺序相反,先将最后删除的添加回来(类似栈的思想)。
inline void rec(int c) {
for(int i=U[c];i!=c;i=U[i])
for(int j=L[i];j!=i;j=L[j]) U[D[j]]=D[U[j]]=j, ++sz[col[j]];
L[R[c]]=R[L[c]]=c;
}
\(\text{dance} 操作\)
进行搜索的函数。其算法过程如下:
-
如果 \(0\) 号结点右结点是 \(0\) ,则表示矩阵为空,记录答案并返回。(与 \(\text{X}\) 算法不同,因为如果上一次操作删除的不是一个全 \(1\) 的行的话,那么一定会有某一列的指示结点未被删除,则 \(0\) 号结点右结点不为其自身)。
-
选择列元素个数最少的一列,将这一列删掉。
\(\text{ps.}\) 选择元素最少的列的原因是:这样可选择的行就最少,让搜索树的分叉最少。
-
遍历这一列上的每一个结点,枚举是否将其所在行删去。
-
选定了某一行,将这一行上所有为 \(1\) 的位置所在列删除。
-
递归调用 \(\text{dance}\) ,如果可行则返回,如果不可行恢复被选择的行,选择另一行尝试。
-
若无解则返回。
inline bool dance(int d) {
if(!R[0]) return ans=d, 1;
int c=R[0];
for(int i=R[0];i;i=R[i]) if(sz[i]<sz[c]) c=i;
rem(c);
for(int i=D[c];i!=c;i=D[i]) {
stk[d]=row[i];
for(int j=R[i];j!=i;j=R[j]) rem(col[j]);
if(dance(d+1)) return 1;
for(int j=L[i];j!=i;j=L[j]) rec(col[j]);
}
return rec(c), 0;
}
算法的时间复杂度与矩阵中 \(1\) 的个数有关,理论复杂度仍然是指数级的,大概在 \(O(c^n)\) 左右,\(c\) 为某个非常接近于 \(1\) 的常数,\(n\) 为矩阵中 \(1\) 的个数。
至此,舞蹈链的模板就全部讲完了,请确保上述内容你都已经理解再看后续板块。
精准覆盖问题应用、建模
这是题目考察的重点和难点。
将行列的意义进行推广。
-
行表示决策,每行对应着一个集合,你可以选或者不选。
-
列表示状态,每一列对应着一个条件。
这也是后续解题过程中需要重点思考,从题目抽象出行、列,再套用舞蹈链求解。
\(\text{P1784}\) 数独
先考虑这一题的条件是什么,经过梳理包含以下几个:
-
\((r,c)\) 恰好填入了一个数,用 \(9 \times 9=81\) 列来描述。
-
第 \(r\) 行恰好填入了一个数 \(w\),用 \(9 \times 9=81\) 列来描述。
-
第 \(c\) 列恰好填入了一个数 \(w\),用 \(9 \times 9=81\) 列来描述。
-
每个宫恰好填入了一个数 \(w\) ,用 \(9 \times 9=81\) 列来描述。
那么问题就被转化成了需要在每个格子填入一个 \(1-9\) 的数字,满足以上共 \(324\) 个条件。
那么,决策是什么呢?
可以发现在每一个格子填入一个数,就是决策。共有 \(9\times 9 \times 9=729\) 种不同的决策,对应着 \(729\) 行。
某一个决策如果能满足某一个条件,那么在表示这个决策的这一行,被满足的条件的那一列就是 \(1\) 。
最多有 \(4 \times 729=2916\) 个 \(1\) 。
还有最后一个问题:如何限制某个位置必须填某个数?这个很好办,把这一个格子的其它决策的每一列都置为 \(0\) 即可。因为这个格子必须填入数这个条件必须满足,所以就只会选择在这个格子填指定的数的那一个决策。
点击查看代码
#include<bits/stdc++.h>
#define mem(a) memset(a,0,sizeof(a))
using namespace std;
const int N=5005;
int cnt,ans[10][10];
struct DLX {
int idx,ans,n,m,stk[N],fir[N],sz[N],row[N],col[N],U[N],R[N],D[N],L[N];
inline void build(int r,int c) {
n=r; m=c; idx=c; ans=-1;
for(int i=0;i<=c;i++) L[i]=i-1, R[i]=i+1, U[i]=D[i]=i;
L[0]=c; R[c]=0; mem(fir); mem(sz);
}
inline void ins(int r,int c) {
++idx; row[idx]=r; col[idx]=c; ++sz[c];
U[idx]=c; D[idx]=D[c]; U[D[c]]=idx; D[c]=idx;
if(!fir[r]) fir[r]=L[idx]=R[idx]=idx;
else L[idx]=fir[r], R[idx]=R[fir[r]], L[R[fir[r]]]=idx, R[fir[r]]=idx;
}
inline void rem(int c) {
R[L[c]]=R[c]; L[R[c]]=L[c];
for(int i=D[c];i!=c;i=D[i])
for(int j=R[i];j!=i;j=R[j])
D[U[j]]=D[j], U[D[j]]=U[j], --sz[col[j]];
}
inline void rec(int c) {
for(int i=U[c];i!=c;i=U[i])
for(int j=L[i];j!=i;j=L[j])
U[D[j]]=D[U[j]]=j, ++sz[col[j]];
R[L[c]]=L[R[c]]=c;
}
inline bool dance(int d) {
if(!R[0]) return ans=d, 1;
int c=R[0];
for(int i=R[0];i;i=R[i]) if(sz[i]<sz[c]) c=i;
rem(c);
for(int i=D[c];i!=c;i=D[i]) {
stk[d]=row[i];
for(int j=R[i];j!=i;j=R[j]) rem(col[j]);
if(dance(d+1)) return 1;
for(int j=L[i];j!=i;j=L[j]) rec(col[j]);
}
return rec(c), 0;
}
}dlx;
inline void Get(int r,int &x,int &y) { x=(r-1)/9+1; y=r%9; if(!y) y=9; }
int main() {
dlx.build(729,324);
for(int i=0,a,id,g;i<9;i++)
for(int j=0;j<9;j++) {
cin>>a;
for(int u=1;u<=9;u++) if(!(a && u!=a)) {
id=9*(i*9+j)+u;
g=(i/3)*3+j/3;
dlx.ins(id,i*9+j+1);
dlx.ins(id,81+i*9+u);
dlx.ins(id,2*81+j*9+u);
dlx.ins(id,3*81+g*9+u);
}
}
dlx.dance(1);
for(int i=1,r,x,y;i<dlx.ans;i++) {
r=dlx.stk[i]; Get((r-1)/9+1,x,y);
ans[x][y]=r%9;
if(!ans[x][y]) ans[x][y]=9;
}
for(int i=1;i<=9;i++,putchar(10))
for(int j=1;j<=9;j++,putchar(32)) putchar(ans[i][j]+'0');
return 0;
}
\(\text{NOI2005}\) 智慧珠游戏
这一题本质做法与上一题类似。现将每一个图案以任意一个点视为基准点,通过上下左右的方式来刻画。
那么本题要满足的条件就是
-
每个位置是否被覆盖。
-
每种拼图是否被用过。
总共 \(67\) 个条件,对应 \(67\) 行。
然后我们枚举每一个点 \((r,c)\) 开始的每一种拼图 \(i\),将其视为决策,如果选择,那么表示拼图在最终以 \((r,c)\) 为基准点存在。
比较麻烦的一点是,这一题每个拼图可以旋转 \(90^{\circ},180^{\circ},270^{\circ}\) ,并且可以翻折。这用总共就存在 \(8\) 种不同的样式,增加代码难度。
其实这个可以用枚举来解决,我们只要刻画一种样式,其余的旋转 \(90^{\circ}\) 其实只需要将 “上”视为“右”,将“右”视为“下”,将“下”视为左,将“左”视为上即可。翻折的话直接把左右互逆即可。
这样可以很大程度上减少代码量。你也可以头铁刻画 \(8\) 种情况。
点击查看代码
#include<bits/stdc++.h>
#define mem(a) memset(a,0,sizeof(a))
#define vc vector
#define pb push_back
using namespace std;
const int N=2e5+5;
const int dx[5]={-1,0,1,0,0}, dy[5]={0,1,0,-1,0};
// U 0 R 1 D 2 L 3
const int len[12]={2,3,3,4,4,5,4,5,4,6,4,4};
const int mv[12][10]={
{3,2}, {1,1,1}, {3,3,2}, {1,2,3,0}, {2,2,1,1}, {1,2,0,1,1},
{0,1,1,2}, {1,1,3,2,3}, {1,1,2,1}, {2,2,0,3,1,1}, {2,1,2,1}, {0,1,1,1}
};
char a[20][20],ans[20][20],w[N];
bool vis[155];
struct Node { int x,y; };
int cnt,id[20][20],cov[20][20];
vc<Node> o[N];
struct DLX {
int ans,idx,stk[N],row[N],col[N],U[N],R[N],D[N],L[N],fir[N],sz[N];
inline void build(int c) {
idx=c; ans=-1;
for(int i=0;i<=c;i++) L[i]=i-1, R[i]=i+1, U[i]=D[i]=i;
L[0]=c; R[c]=0; mem(fir); mem(sz);
}
inline void ins(int r,int c) {
++idx; row[idx]=r; col[idx]=c; ++sz[c];
U[idx]=c; D[idx]=D[c]; U[D[c]]=idx; D[c]=idx;
if(!fir[r]) fir[r]=L[idx]=R[idx]=idx;
else L[idx]=fir[r], R[idx]=R[fir[r]], L[R[fir[r]]]=idx, R[fir[r]]=idx;
}
inline void rem(int c) {
L[R[c]]=L[c]; R[L[c]]=R[c];
for(int i=D[c];i!=c;i=D[i])
for(int j=R[i];j!=i;j=R[j]) U[D[j]]=U[j], D[U[j]]=D[j], --sz[col[j]];
}
inline void rec(int c) {
for(int i=U[c];i!=c;i=U[i])
for(int j=L[i];j!=i;j=L[j]) U[D[j]]=D[U[j]]=j, ++sz[col[j]];
L[R[c]]=R[L[c]]=c;
}
inline bool dance(int d) {
if(!R[0]) return ans=d, 1;
int c=R[0];
for(int i=R[0];i;i=R[i]) if(sz[i]<sz[c]) c=i;
rem(c);
for(int i=D[c];i!=c;i=D[i]) {
stk[d]=row[i];
for(int j=R[i];j!=i;j=R[j]) rem(col[j]);
if(dance(d+1)) return 1;
for(int j=L[i];j!=i;j=L[j]) rec(col[j]);
}
return rec(c), 0;
}
}dlx;
inline bool ok(int x,int y,char c) { return (x>0 && x<=10 && y>0 && y<=x && a[x][y]==c); }
inline void add(int x,int y,int k,int t,int f,char c) {
for(int i=0,nx=x,ny=y,j;i<len[k];i++) {
j=(mv[k][i]+t)%4;
nx=nx+dx[j]; ny=ny+f*dy[j];
if(!ok(nx,ny,c)) return ;
}
cov[x][y]=++cnt; w[cnt]=k+'A'; o[cnt].pb(Node{x,y});
dlx.ins(cnt,id[x][y]); dlx.ins(cnt,56+k);
for(int i=0,nx=x,ny=y,j;i<len[k];i++) {
j=(mv[k][i]+t)%4;
nx=nx+dx[j]; ny=ny+f*dy[j];
if(cov[nx][ny]!=cnt) dlx.ins(cnt,id[nx][ny]), o[cnt].pb(Node{nx,ny});
cov[nx][ny]=cnt;
}
}
inline void init() {
cnt=0;
for(int i=1;i<=10;i++)
for(int j=1;j<=i;j++)
for(int k=0;k<12;k++) if((a[i][j]=='.' && !vis[k]) || (a[i][j]==k+'A'))
for(int t=0;t<4;t++)
for(int f=-1;f<=1;f+=2)
add(i,j,k,t,f,a[i][j]);
}
int main() {
dlx.build(67);
for(int i=1;i<=10;i++) {
scanf("%s",a[i]+1);
for(int j=1;j<=i;j++) {
id[i][j]=++cnt;
if(a[i][j]!='.') vis[a[i][j]-'A']=1;
}
}
init(); dlx.dance(1);
if(dlx.ans==-1) return puts("No solution"), 0;
for(int i=1,j;i<dlx.ans;i++) {
j=dlx.stk[i];
for(auto &u:o[j]) ans[u.x][u.y]=w[j];
}
for(int i=1;i<=10;i++,putchar(10))
for(int j=1;j<=i;j++) putchar(ans[i][j]);
return 0;
}
搜索总结
本文所介绍的只是一些搜索的方法,如何在考场上用写出效率较高的搜索,拿到较高的部分分、甚至是直接过题都需要能够将这些技巧融会贯通,并且对于不同的题目能有一些特定的剪枝技巧、选择恰当的搜索方式,这都是要在知识点扎实的基础上通过做题、测试不断总结归纳出来的。祝愿大家可以做到暴力进队啊!
结尾
鲜花怒马少年时,不负韶华行且知。

浙公网安备 33010602011771号