浅谈暴力与搜索算法
前言
暴力与搜索算法也许是
暴力
暴力的重要性
暴力和部分分通常能够保证在考试中拿到一个大众的水平,可以说是兜底得吧。
打的全是暴力但是有 ,你说重不重要。
暴力的时间分配
以下纯笔者个人习惯,不喜勿喷。
在考试过程中,我通常会在两个时间段集中思考部分分。
-
开考前
内,这个阶段对题目通读一遍,在读题的过程中就要对每个题有一个短时间的思考,思考自己已经会的部分分、感觉可以拿到的部分分还有对于这一题的难度进行一个估计。做到读完题对考试有一个大概的了解,也知道了每一题比较基础的部分分。 -
在考试过程中决策认定继续冲正解不划算或者没有时间的时候,就要全力冲部分分了。思考如何获得更加高阶的部分分,包括但不限于:特殊性质、随机性质或者降低程序的复杂度等等。
这两个阶段都是极为重要的,一方面能够为考试兜底,另一方面也可以通过部分分摸索出正解。
从全排列到状压或搜索
这算是多次考试中得出来的一个经验吧。
全排列
很多题目按照题意模拟,不加任何优化通常是直接全排列可以解决的。
状压
很多题目中都可以将全排列换成状压,将复杂度降到
举两个例子:
连续两年了联赛出现了,状压重要性不用多说。
搜索
本质上还是枚举所有的状态,但是在枚举的过程中加入各种优化手段来提升程序运行效率,有可能可以获得较高的分数。
重视平方暴力
对于很多数据范围
这类部分分考的最多的就是
举个例子:
考虑随机性质
随机性质在最近几次
如果某一档部分分说:保证
直接放暴力过
包括但不限于排列随机生成、树随机生成保证树高......
那么一般而言,你的暴力程序是可以通过这一档的。
以下几个题目都有随机性质的部分分
利用随机性质
需要采用针对该随机性质的算法。
考虑特殊性质
见的次数比较多的特殊性质有以下几种
- 树的形态为一条链
- 树的形态为一朵菊花
- 不需要可持久化
- 题目给定的某个参数比较特殊
- 不存在某一种操作
- .....
对于每一种特殊性质,都要结合题目考虑看是否对解题有帮助,并可以以此为线索深入思考正解。
减少暴力代码的时间开销
倍增暴力做,字符串 。对于复杂度并不是正解的程序,考虑剪枝优化提高效率,详见后文搜索板块的剪枝技巧。
让部分分代码更加简洁清晰
是不是经常遇到,对于一道题目不同部分分采用多种做法时经常出现代码混乱,数组、变量用串的情况。
下面这种办法可以让你的代码变得更加简洁清晰,不会出现变量数组用串的情况。
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;
}
暴力总结
上面很长的篇幅,对于如何拿稳拿高部分分也只是冰山一角,真正适合自己的方法需要大家在平常的训练和考试中积累,领悟,如果有新的技巧欢迎补充啊!
搜索
朴素的
剪枝技巧
剪枝优化能够提升搜索效率,拿到更多的分。本文先介绍几个常见的剪枝概念,再配合一个例题进行讲解。
常见剪枝策略
- 最优性剪枝:即如果当前搜索的解已经确定比已有的解更劣时,就不用继续搜索了。
- 可行性剪枝:如果能够判断当前状态已经不合法或无法达到合法的状态,就不用继续搜索了。
- 卡时:如果已经达到时限但是搜索没有结束,就将已经搜过的最优解输出,或者宣告无解。
- 对于数据进行处理:可以对数据进行排序、预处理等操作,让搜索树的分支尽可能小。
的 剪枝(本文不进行讲解)。- 根据特定的题目确定特殊的剪枝技巧
- .......
例题 小木棍
这是一道练习剪枝的好题目,需要用若干种剪枝技巧才能通过。
先明确这题大概的思路,我们从多到少枚举原始木棍的数量,假设此时枚举的木棍长度为 dfs(v,res,las)
表示当前已经确定完了
我们考虑对搜索过程进行剪枝。
-
我们从多到少枚举原始木棍的数量,一旦当前已经合法,就不再进行搜索。
-
记枚举的数量为
,所有木棍的长度之和为 ,当且仅当 时才进行搜索。 -
由于长木棍比短木棍适用性更差,因此如果在某个状态时选择放了长木棍,那么后续的可能状态就越少,因此我们将木棍按照长度排序,拼的每一根木棍都按照长度递减的顺序放,达到减少搜索树分支的目的。
-
根据本题的条件,有一个特别重要的剪枝:如果放了长度为
的木棍不合法,并且 或者 那么说明当前这一个状态就不可能合法了。可以这样理解,当前这个状态必须要放一个长度为 的木棍,已经放了但是不合法,那么就可以确定当前这个状态不合法了。 -
这是一个细节的优化,由于将木棍按照长度递增排好了序,而且每次要找最后一个长度小于等于
的木棍,从它开始枚举。这个我们可以在搜索之前就预处理出来,降低常数。 -
这也是一个细节优化,如果放了长度为
的木棍不合法,那么对于剩下的长度为 的木棍就都不需要考虑了,我们可以在搜索前预处理出每根木棍前面第一个与之长度不同的木棍位置。
加上这些剪枝,就已经可以通过了,如果还有新的剪枝办法,也欢迎补充哦!
这一题几乎涵盖了所有的剪枝手段,有套路性的,也有根据这题的条件而特有的。而后者往往需要我们找性质的能力,需要不断地积累提高。
双向 & 双向
双向搜索,即从起点和终点状态都开始搜索,让它们在中间“碰”上,这就是双向搜索。
为什么要采用双向搜索
用一张图片的对比来直观的说明:
从两点同时开始搜,让它们在中间相遇相较于从起点一直搜到终点,搜索树的深度减小了一半,但由于搜索树的大小一般是指数级别的,将其深度缩小一半其实减少了很多状态。
例题
如果直接枚举每个点往右还是往下,路径长度是
迭代加深
迭代加深算法可以视为是将
还是用一张图片对比来感受迭代加深的作用。
例题 埃及分数
这题要求最小的项数,因此我们枚举项数,也就是搜索树的深度,逐步加深,再进行
我们设
这题剪枝主要有以下几个:
- 如果
那么就可以直接返回了。 - 如果
也可以返回了。 - 每次分母的下界是
, 为上一项的分母。 - 记已经枚举过的最后一项最小值为
,那么之后枚举分母的上界为 。
可以看出,这一题是迭代加深和剪枝优化搭配起来使用的,在本部分介绍的技巧都不是孤立的,要学会融会贯通,搭配使用,取长补短以达到最高的效率。
思考一个问题:如果让你来完成一个搜索的工作,人脑通常会优先选择“最有可能”“看起来最优”的方向,而这也就是
估价函数的设定
不难看出
- 估价值小于等于实际值:优化效率相较于后者不高,保证答案正确。
- 估价值大于实际值:优化效率很高,但是不保证答案正确。
为什么呢?
当估价等于
估价函数的设定因题而异,具体情况具体分析。比较常见的有:最短路、不同的位置数等。
例题 魔法猪学院
关于本题我们将每个点的估价设为其到终点的最短路径长度,跑
是迭代加深算法与
例题 八数码难题
听说有人八维数组艹过去了这题。
这题的正解有很多,比如前文提到的双向
我们将估价函数设定为当前局面与目标局面有多少个位置不同,让搜索树的深度逐步加深,记深度限制为
舞蹈链
终于到最后一个部分了。 本文会详细讲述舞蹈链的原理及其应用。
舞蹈链用于解决以下这一类问题,我们称之为 精准覆盖问题 :
有一些问题需要被回答,还有一些学生你决定是否选择,如果选择的话,该学生会回答其中若干个问题。现在你要选出一些学生,满足所有的问题都被回答,且被回答恰好一次。
更加形式化的描述:
给定许多集合
先看舞蹈链的例题
例题 舞蹈链
这里首先介绍
假设得到了这样一个
- 我们选择第一行,并将与第一行相关的行列删除,得到如下矩阵。
- 此时我们继续将第一行及与之相关的行列删除,得到如下矩阵。
-
此时矩阵删空,但是由于上一次选择的行并不是全
,因此这样的选择方式不合法,回溯到第 步。 -
我们选择将第二行及其相关行列删除,得到如下矩阵。
- 将第一行删除。
- 矩阵再次删空,并且最后一次操作是删掉的行是全
,因此该解合法,输出答案 。
根据此过程归纳,
-
在当前剩余的矩阵
内选择一行 ,将 行及其相关的行列删除,得到新的矩阵 。 -
如果矩阵
非空,则继续执行 操作;如果矩阵
为空,并且第 行全 ,宣告有解,输出答案;如果矩阵
为空,并且第 行非全 ,恢复被 删除的所有行列,回到步骤 。
不难看出,该算法存在大量的删除行列以及恢复行列的操作,朴素暴力的维护复杂度难以接受。
在双向十字链表上不断跳跃的过程被形象的比喻为“跳跃”,因此用来优化
优化的 算法
定义
舞蹈链只记录是
双向十字链表维护这些信息:每个结点上、下、左、右的结点,同时维护这个结点所在行列。对于每一行维护一个行指示,对于每一列,维护一个表示这一列的结点、这一列的元素个数。
整个链表大概就长成这个样子:
过程
操作
新建一个包含
新建
第
特别的,
如果
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);
}
操作
在第
新开一个节点,该结点行为
将其插入第
将其插入第
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;
}
算法的时间复杂度与矩阵中
至此,舞蹈链的模板就全部讲完了,请确保上述内容你都已经理解再看后续板块。
精准覆盖问题应用、建模
这是题目考察的重点和难点。
将行列的意义进行推广。
-
行表示决策,每行对应着一个集合,你可以选或者不选。
-
列表示状态,每一列对应着一个条件。
这也是后续解题过程中需要重点思考,从题目抽象出行、列,再套用舞蹈链求解。
数独
先考虑这一题的条件是什么,经过梳理包含以下几个:
-
恰好填入了一个数,用 列来描述。 -
第
行恰好填入了一个数 ,用 列来描述。 -
第
列恰好填入了一个数 ,用 列来描述。 -
每个宫恰好填入了一个数
,用 列来描述。
那么问题就被转化成了需要在每个格子填入一个
那么,决策是什么呢?
可以发现在每一个格子填入一个数,就是决策。共有
某一个决策如果能满足某一个条件,那么在表示这个决策的这一行,被满足的条件的那一列就是
最多有
还有最后一个问题:如何限制某个位置必须填某个数?这个很好办,把这一个格子的其它决策的每一列都置为
点击查看代码
#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;
}
智慧珠游戏
这一题本质做法与上一题类似。现将每一个图案以任意一个点视为基准点,通过上下左右的方式来刻画。
那么本题要满足的条件就是
-
每个位置是否被覆盖。
-
每种拼图是否被用过。
总共
然后我们枚举每一个点
比较麻烦的一点是,这一题每个拼图可以旋转
其实这个可以用枚举来解决,我们只要刻画一种样式,其余的旋转
这样可以很大程度上减少代码量。你也可以头铁刻画 。
点击查看代码
#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;
}
搜索总结
本文所介绍的只是一些搜索的方法,如何在考场上用写出效率较高的搜索,拿到较高的部分分、甚至是直接过题都需要能够将这些技巧融会贯通,并且对于不同的题目能有一些特定的剪枝技巧、选择恰当的搜索方式,这都是要在知识点扎实的基础上通过做题、测试不断总结归纳出来的。祝愿大家可以做到暴力进队啊!
结尾
鲜花怒马少年时,不负韶华行且知。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现