搜索

内容很多参考《算法竞赛进阶指南》,对各种搜索及其变形进行总结,此外书上讲得不足的部分会有所补充,比如折半搜索meet in middle思想。书上提了一嘴的dancing links考虑日后有时间加上,毕竟应用不广。剪枝我们不列出来单独作为一个专题,会掺杂在DFS中讲。

(模拟退火算了,严格意义上不算正经搜索,有时间单独列出来讲)

本文比较水,题目较多,所以没什么时间搞图,可以尝试结合网上的图理解一下。。

搜索才是得分王道,你要相信题目是水的,出题人是懒的,数据是弱的,乱搞是能过的

反正是主要写给自己的也没几个人看  竟然有人看感动死我了那就不咕了,干脆写完

深度优先搜索DFS

深度优先搜索,顾名思义就是按深度为最高优先级的顺序搜索,对于一枝或一条路等一直搜到底再改搜别的。具体实现我们可以用递归,每扩展一个新的状态就在这个状态的基础上继续扩展,也就是一条路走到底,死路不通再返回,代码中体现就是先调用dfs函数递归再回溯(回溯即还原现场,相当于你为了进行下一次搜索改变了些参数,搜索失败后我们要换路,就要把改变的那些参数改回来)。

我们需要搞清楚搜索的状态,也就是我们dfs函数传参,标记节点访问的一类东西,最后再进行大力剪枝优化。

先讲两个比较简单的例题

例题 Lake Counting

题目链接 这就是经典的dfs联通块计数,联通块定义参见题目。我们遍历每个格子,如果当前格子有水,说明这个格子处于一个联通块中,我们就把这个连通块全部标为旱地表明搜索过了,并且增加答案。dfs中传参数即当前坐标,然后每次扩展向八个方向搜索即可。

 1 // 接下来的代码就是"走地图"类问题的常见套路了
 2 // 打表出坐标改变量减少代码量
 3 // 同时要判有没有越界 
 4 const int dx[9] = {0, -1, -1, -1, 0, 0, 1, 1, 1};
 5 const int dy[9] = {0, -1, 0, 1, -1, 1, -1, 0, 1};// 八个方向坐标改变值的打表 
 6 void dfs(int x, int y) {// 当前坐标 
 7     a[x][y] = '.';// 到了就标记为旱地 也就是标记为访问过了 
 8     for(int i = 1; i <= 8; ++i) {
 9         int nx = x + dx[i], ny = y + dy[i];
10         if(ny > m || nx > n || nx < 1 || ny < 1 || a[nx][ny] == '.') continue;// 越界或者是旱地 
11         dfs(nx, ny);
12     }
13 }
14 // 下面代码在主函数中 
15 for(int i = 1; i <= n; ++i)
16     for(int j = 1; j <= m; ++j) {
17         if(a[i][j] == 'W') {
18             dfs(i, j);
19             ++ans;
20         }
21     }

例题 八皇后

题目链接 这题我们就一行一行搜,找到个合适位置(列,对角线没标记过)填,最后填满统计答案,输出方案即可。关键在于怎么标记对角线,实际上如果我们把棋盘看做坐标系,利用一次函数,就可以通过坐标对应对角线编号了

 1 bool col[14],lr[27],rl[27];// 行y,标记列x、左上到右下(生成方式y-x+n),右上到左下(生成方式x+y-1) 
 2 int n,ans,jie[14],jtop;// ans表示解数目 jie来存填的列数 
 3 void dfs(int q) {// q表示第几个,同时是搜索到哪一行了 
 4     if(q == n + 1) {
 5         ++ans;
 6         if(ans <= 3) {// 要求只输出三个 
 7             for(int i = 1; i <= n; ++i) printf("%d ", jie[i]);
 8             printf("\n");
 9         }
10     }
11     for(int x = 1; x <= n; ++x){// 枚举当前列
12         int l1 = q - x + n, l2 = x + q - 1;
13         if((!col[x]) && (!lr[l1]) && (!rl[l2])) {// 列 对角线都没填过 
14             jie[++jtop] = x;
15             col[x] = true;
16             lr[l1] = true;
17             rl[l2] = true;// 标记访问过 
18             dfs(q + 1);// 迭代继续搜索 
19             --jtop;
20             col[x] = false;
21             lr[l1] = false;
22             rl[l2] = false;// 所谓回溯 还原现场 进行新的搜索 
23         }
24     }
25 }

树上dfs相关

时刻注意建立无根树时不要忘记建立双向边。对于树的dfs,我们不一定要标记哪个点是否访问过,只需要判断当前边指向点是否是其父节点即可,代码中可以在dfs函数中再传一个参数实现。DFS访问节点的编号顺序叫做DFS序,按节点被访问的顺序给节点重新标号,这个标号叫做时间戳,即第一次访问该节点的时间(整数)。

DFS序特点是节点编号配对的一个区间就对应一棵子树,我们也可以看成左右括号配对,一个括号序列对应唯一一个有根树,此时不要求节点编号,可用于解决树同构问题。接下来我们给出一段代码,求树的重心(树中删掉一个结点会产生多棵子树,称g为树T的重心,当在T中删掉p之后剩下的几颗子树大小中的最大值最小,有点绕可以画图理解。需要注意的是,有的树会有两个重心,比如两个结点间连一条边这种树,我们仅讨论取其中一个),以每个节点为根子树大小,节点深度,构建树上DFS大体框架。

这里体现了处理无根树上某些问题中“换根”的思想,就是假设无根树是有根树去做,当改变根节点时答案要怎么更新。这点会在树形动态规划中再次体现。

 1 int size[maxn],fa[maxn],dep[maxn],g,gpart;// 从左到右依次为子树大小,父节点,深度,重心,删去重心最大子树大小
 2 void dfs1(int p, int last) {// p为结点编号,last为上一次访问结点,即当前结点的父亲
 3     dep[p] = dep[last] +1;// 结点的深度,就是他父亲结点深度+1,许多时候我们只需要知道相对深度关系,所以根节点的深度是0是1大多情况下是无所谓的
 4     size[p] = 1;// 初始状态下本身占一个大小
 5     int max_part = 0;// 删去p号结点后最大子树大小
 6     for(int i  = head[p]; i; i = e[i].next) {// 邻接表 不是很清楚的可以上网搜索 这是一个存图的方式
 7         if(e[i].to == last) continue;// 当前边指向父节点 不能返回去访问
 8         dfs1(e[i].to, p);
 9         size[p] += size[e[i].to];// 统计信息 一个点子树大小就是他所有儿子子树大小加起来 + 1(本身算一个大小)叶子结点无儿子 大小就是1
10         max_part = max(max_part, size[e[i].to]);
11     }
12     max_part = max(max_part, n - size[p]);// 换根 我们上面的过程是假定这是一棵有根树进行的 但实际上这是棵无根树 删去p结点的最大子树可以是他儿子子树中的一个 也可以是去掉p以下的子树那一部分
13     if(max_part > gpart) {// 更新答案
14         gpart = max_part;
15         g = p;
16     }
17 }

我们可以大概知道dfs的框架:

类型 函数名称(参数) {// 注意有的dfs是可以带返回值的

  如果 (达到边界条件) {

    更新答案

    返回

  }

  剪枝优化

  遍历后继状态 {

    改变参数

    继续迭代搜索

    把参数改回来,回溯

  }

}

注意要根据题目不同来调整,不能死套,比如求树的重心就要在每个点更新答案

剪枝

剪枝,顾名思义,就是剪掉搜索树上枝条,减小时间复杂度。剪枝我们有几种常见方法,考虑要尽量全面,有的时候一个不起眼的剪枝会让运行时间天差地别。

1.优化搜索顺序

  这个点容易被遗漏,层次不固定的情况下,搜索顺序会对时间复杂度产生极大影响。例如数独一题中,我们优先搜索能填的数最少的格子。这个东西的思考往往需要“启发式”地思考,充分发挥人类智慧。

2.排除等效冗余

  如果对不同分支进行搜索结果一样,也就是殊途同归,那我们就可以只搜一条分支。还是数独一题,我们每次只填一个数就在搜索树上向下迭代,不需枚举每个位置填什么数,因为会被迭代更深层的搜索状态给覆盖到。

3.可行性剪枝

  就是走到某个状态此路不通就返回,例如数独一题中,我们如果发现有某个位置不能填任何数,或者某个数还没有被填,但是不能填在某行/列/九宫格中,则我们这样填肯定是无解的,就马上返回上一状态。

4.最优性剪枝

  在最优性求解问题(例如要求答案最大、最小的情况)中,我们搜到某个状态发现答案不比之前更优,就可以直接返回了。例如生日蛋糕一题中,如果我们钦定每层蛋糕高度和半径序列分别就是1到n的一个排列(即最下层半径高度均为n,往上一层半径高度均为n-1,以此类推),那么可以发现还剩几层蛋糕的时候至少要产生的表面积是知道的,如果当前搜到的表面积加上剩下至少产生的表面积比答案大,肯定是不会比答案更优的。无论如何最后结果大于已经搜到表面+剩下层数至少有的表面积,那结果一定比当前答案大,没必要继续进行搜索,可以剪枝。

最后一个就是记忆化搜索,由于比较重要会单独列出,是从暴力搜索转换到动态规划的考虑关键点。

其实上面我们就顺便讲了一道题目数独2(据说正解DLX?不懂),巨难写,但是很值得一写(反正我抽了两个下午才写完)。顺带一提这个题有双倍经验SP1110 UVA1309

例题 小木棍

链接 剪枝好题,就是洛谷上时限太紧,不写链表好像卡不过去。。。

1.优化搜索顺序:先把木棍长度排序,从大到小,从人类直觉来说,大的木棍更难找地方放,就可以先放大的,这是一个“启发式”的思考过程。
2.排除等效冗余
2.1对于一根准备拼接的大木棍,我们现在手中有几根小木棍,我们先选哪根拼进大木棍是无所谓的,比如长度分别为x、y,我们选择了x,还能拼上y,跟我们先选y再拼x是一样的,所以我们要钦定一个搜索顺序来剪枝。具体在代码中体现就是每次搜索记录下扫到哪根小木棍了,传递下标,下次从这个下标+1开始搜索。
2.2对于当前正在拼接的大木棍,我们记录下新尝试的小木棍长度,如果不能拼,则其他相同长度的小木棍也不能拼,直接跳过。
2.3如果当前正在拼接的小木棍是第一根拼接进这个大木棍的,而且拼接失败了,就可以直接返回。因为这根小木棍放进剩下哪根空的大木棍都是一样的,你这根放哪根空的大木棍中都不行,直接返回。
2.4如果是最后一根小木棍,也直接返回。因为我们钦定了递减顺序,然后接下来的搜索相当于拿后面多根小木棍来凑到当前失败小木棍的长度,贪心地想,多根小木棍来替代一根小木棍是肯定不优的,就相当于把这一根和剩下多根换了个位置,都是失败的方案。

 1 void dfs(int cnt, int nowlen, int last) {// 拼好的个数 当前正在拼的长度 上次选择完的木棍下一个 
 2     if(nowlen == len) dfs(cnt + 1, 0, 1);
 3     if(cnt == supcnt) {
 4         solve = 1;
 5         return;
 6     }
 7     int fail = 0;
 8     for(int i = last; i <= n; ++i) {// 剪枝2.1 
 9         if(used[i] || a[i] + nowlen > len || fail == a[i]) continue;
10         used[i] = 1;
11         dfs(cnt, a[i] + nowlen, i + 1);
12         used[i] = 0;
13         if(solve) return;
14         fail = a[i];// 剪枝2.2 体现在前 
15         if(!nowlen || nowlen + a[i] == len) return;// 剪枝2.3 2.4 
16     }
17 }
18 // 要对a数组从大到小排序

推荐题目

三大毒瘤题(好像也不算,就是代码过于难写。。。),写完不说dfs毕业,起码考试遇到能全部打出暴力。。。

·NOIP2004提高组 虫食算 据说有高斯消元解法?

·NOIP2008提高组 靶形数独 数独剪枝不满过不了的

·NOIP2011提高组 Mayan游戏 消消乐是吧

广度优先搜索BFS

广度优先搜索简称BFS,就是我们多条路一起走,看哪条路先到头。实际上我们多路并行比较困难,我们可以每次在待扩展状态中找一个深度最小的进行扩展是等效的。BFS我们采用队列来实现,每次我们取出队列头来扩展状态,扩展出状态全部放入队尾,直到队列为空即可。

同时这里的“深度”可以大致理解为走了多少步,BFS的精髓就在这里了,我们拿走的“步数”(其实就是到达该状态所用代价)最小的状态进行扩展,就能保证到达最终状态时用的是最小的步数。也就是说,我们保证每次取代价最小的状态进行扩展就能保证第一次到达某个状态所用代价就是最小代价,这点是核心,无论BFS怎么变体,都是为了遵守这个原则或者在遵循这个原则的基础上提高效率。我称之为BFS队列中状态的“代价单调性”。

同时BFS是A*算法的基础。

拓扑排序

在一张有向无环图即DAG中,若一个点序列A满足对于此DAG的所有边<u, v>,u在A中出现在v前面,则称A为此DAG的一个拓扑序。我们每次拿入度为0的点计入序列,然后把这个点连向的点入度全-1,直到没有点可以扩展。我们可以采用BFS来实现。

实际上可以这么理解,你有一堆事要干,现在你只知道某件事要在另一件事干完之后才能干,拓扑排序就是给要干的这些事安排一个符合先干什么,后干什么的顺序。

过程大概如下:

1.把所有入度为0的点加入队列

2.取出队头u,加入拓扑序列

3.把队头u所有连向点v的入度-1,若v入度减为0,则将v入队

4.重复2、3直到队列为空即可

实际应用中,我们往往不一定需要这个序列,只需要按照拓扑排序的顺序进行更新即可。有点像动态规划的过程。

 1 for(int i = 1; i <= n; ++i)
 2     if(!deg[i]) q[++tail] = i;// deg表示一个点入度
 3 while(tail >= qhead) {
 4     int p = q[qhead++];
 5     for(int i = head[p]; i; i = e[i].next) {
 6         --deg[e[i].to];
 7         if(!deg[e[i].to]) q[++tail] = e[i].to;
 8                 // 这里进行操作
 9     }
10 }

例题 可达性统计

链接 acwing164 注意这个题可达点包含自身。那么一个点的可达点就是他本身以及他所有直接相连点的可达点,我们可以建反图拓扑排序来进行这个统计过程,每个点可达点可以用bool数组标记出,但是问题就在于N=30000,时间不够,空间也不够。

我们一开始做法是用bool数组来标记某个点的可达性,我们可以尝试使用状态压缩,但是这样的话一个long long也只能存63位信息,远远不够。

这时候STL里面(准确来说应该不算,反正就是C++自带的一个数据结构,在bitset头文件中)就有一个很厉害的东西叫bitset。bitset叫做压位数组,顾名思义,一个bool变量要占1个字节,空间开销过大。但其实没必要,我们只需要一位的空间即可存储0/1,于是我们可以尝试调动起一个int或者long long变量的每一位来存储,将空间压缩至用bool数组的1/8。bitset可以当bool数组用,就是访问单个元素时时间复杂度比纯粹数组大。更关键的是bitset之间可以像int一样进行按位与,按位或,按位异或运算,本身也可以进行移位运算,还可以统计1的个数。其中,进行按位与运算,统计1的个数完美符合这道题的要求。

时间复杂度O((n + m)/w)。n + m来源于拓扑排序,1/w(其实是omega)来源于bitset,w的具体值网上最可认可的说法是与电脑位数有关,64位电脑就是64。

例题 神经网络

链接 NOIP2003提高组 很板的题,拓扑排序的同时更新就行了。注意有个坑点,c是否为0都要入队,不然有的点入度不能变成0,拓扑无法进行

题目推荐

说实话复杂的题我好像也不会几个

·最大食物链计数

·绿豆蛙的归宿 需要知道期望怎么算

·函数调用 CSP-S2020 关键是发掘这是个DAG

·排水系统 NOIP2020 阴间玩意要写高精度

常规的bfs问题

这里的“常规”有着比较显著的特征:一个状态可以经过多种途径扩展,且要求达到最终态代价最小。

例题 马的遍历

LuoguP1443 马的遍历 

1.状态多途径扩展 2.最小步数 显然就是bfs了吧,第一次到达某个状态代价就是对应最小步数

同时顺带一提,下面代码中展示的用常量数组存下状态变化是个很好用的套路,免去了写一堆if的麻烦

这题输出可以用printf("%-5d");来对齐

const int dx[9] = {0, 1, 2, 2, 1, -1, -2, -2, -1};
const int dy[9] = {0, 2, 1, -1, -2, -2, -1, 1, 2};// 坐标变换走日字 
struct pos {
    int x,y;
};// 坐标结构体 
queue<pos>q;
void bfs(){
    q.push(st);// 把初始状态放入队列 
    while(!q.empty()) {
        pos p1 = q.front(); q.pop();// 取出一个状态进行扩展
        for(int i = 1; i <= 8; ++i) {
            pos p2;
            p2.x = p1.x + dx[i];
            p2.y = p1.y + dy[i];
            if(!visit[p2.x][p2.y] && p2.x <= n && p2.x >= 1 && p2.y <= m && p2.y >= 1) {
                q.push(p2);// 我们这里能直接放入队尾的原因是保证了队列中状态代价递减 
                ans[p2.x][p2.y] = ans[p1.x][p1.y] + 1;
                visit[p2.x][p2.y] = true;
                // 这里加visit数组的原因:据前文所述 第一次到达某状态就获得了初状态到该状态最小答案 没必要继续搜 
            }
        }
    }
}

例题 矩阵距离

acwing173 矩阵距离 讲的就是一个求出每个坐标点到距离最近的,A[x][y]为1的点的距离(曼哈顿距离)。只有一个点很好办,就是经典的“洪水填充”问题。多个点怎么办呢?我们发现把所有初始点扔进队列,仍然满足其中状态“代价单调性”,于是此题迎刃而解。

例题 立体推箱子

acwing172 链接 我们需要存状态:躺情况(立/横躺/竖躺),坐标(横躺存左边的点 竖躺存上边)。这个题变化太多,我们还可以把从某个状态变到另外一个状态怎么变化存下来,核心见代码注释。

// ......省略了一些东西比如初始状态的处理 输入输出 
struct sta {
    short x,y,lie;// lie1立 2横躺 3竖躺 存的坐标如上文所说 
    friend bool operator == (const sta &s1, const sta &s2) {
        return s1.x == s2.x && s1.y == s2.y && s1.lie == s2.lie;
    }
}st,ed,q[800005];
const short dx[5] = {0, 1, -1, 0, 0};
const short dy[5] = {0, 0, 0, 1, -1};// 1上2下3左4右 
const short nxt_x[5][5] = {{0, 0, 0, 0, 0}, {0, -2, 1, 0, 0}, {0, -1, 1, 0, 0}, {0, -1, 2, 0, 0}};
const short nxt_y[5][5] = {{0, 0, 0, 0, 0}, {0, 0, 0, -2, 1}, {0, 0, 0, -1, 2}, {0, 0, 0, -1, 1}};
const short nxt_l[5][5] = {{0, 0, 0, 0, 0}, {0, 3, 3, 2, 2}, {0, 2, 2, 1, 1}, {0, 1, 1, 3, 3}};
// 好了这里的nxt就是前文讲的"怎么变" 
// 拿(1, 1)来说明,这是数组下标,代表立着,向上按键
// nxt_x[1][1] = -2 nxt_y[1][1] = 0 nxt_l[1][1] = 3
// 代表立着 向上案件 会让x坐标-2 y坐标不变 躺立状态变为3即竖躺 
bool outmap(short x, short y) {
    return x < 1 || y < 1 || x > n || y > m;
}
bool valid(sta s) {// 判断状态合法
    if(outmap(s.x, s.y)) return 0;
    if(mp[s.x][s.y] == '#') return 0;
    if(s.lie == 1 && mp[s.x][s.y] != '.') return 0;// 松的不行
    if(s.lie == 2 && mp[s.x][s.y + 1] == '#') return 0;
    if(s.lie == 3 && mp[s.x + 1][s.y] == '#') return 0; 
    return 1;
}
int bfs() {
    head = 1, tail = 0;
    for(short i = 1; i <= n; ++i) {
        for(short j = 1; j <= m; ++j) {
            dis[i][j][1] = dis[i][j][2] = dis[i][j][3] = 0;
        }
    }
    dis[st.x][st.y][st.lie] = 1;
    q[++tail] = st;
    while(tail >= head) {
        sta p = q[head++],now;
        for(short i = 1; i <= 4; ++i) {
            now.x = p.x + nxt_x[p.lie][i];
            now.y = p.y + nxt_y[p.lie][i];
            now.lie = nxt_l[p.lie][i];
            if(!valid(now)) continue;
            if(!dis[now.x][now.y][now.lie]) {
                q[++tail] = now;
                dis[now.x][now.y][now.lie] = dis[p.x][p.y][p.lie] + 1;
                if(now == ed) return dis[now.x][now.y][now.lie] - 1;
            }
        }
    }
    return -1;
}

推荐题目

·acwing188

·acwing189

都是典题

·NOIP2002提高组 字串变换 虽然说是假题,但是不影响练习bfs,需要用点技巧,比如存状态代价用map<string, int>

广搜变形

01bfs

01bfs,又名双端队列bfs,是指每次扩展状态需要消耗/不需消耗代价,而且消耗的代价全部相同,采用双端队列维护代价单调性的一种bfs做法。需要消耗代价记为1,扩展后放到队尾,不需要消耗代价记为0,扩展后放到队头,也能保证单调性。

例题 通信线路

链接 LuoguP1948 这个题的另一个做法分层图最短路暂且不提,就也是把点扩展成广义的“状态”多元组,然后跑最短路的得到最小代价。这个题简要题意就是求一条从1到n路径,要求路径上第k+1大边权最小(路径数不够答案为0),虽然不是最大值最小,但是答案具有单调性(较小的答案对应方案行那么更大的答案对应方案也行),容易想到二分答案。边权>mid的边改边权为1,否则改为0,求起点到终点最短路,判断dis[n]是不是小于等于k,小于等于就满足,缩小右边界继续二分。这里最短路就0/1两种边权,01bfs即可。

deque<int>q;
vector<edge>e[1001];
bool vis[10005]; 
bool judge(int x) {
    memset(d, 0x3f, sizeof(d));
    memset(vis, 0, sizeof(vis));
    d[1] = 0;
    vis[1] = 1;
    q.push_back(1);
    while(q.size()) {
        int p = q.front(); q.pop_front();
        if(p == n) break;
        for(int i = 0; i < (int)e[p].size(); ++i) {
            int v1 = e[p][i].to, w1 = e[p][i].w;
            if(!vis[v1] || d[v1] >= d[p] + 1) {
                if(w1 > x) {
                    d[v1] = d[p] + 1;
                    vis[v1] = 1;
                    q.push_back(v1);
                }
                else {
                    d[v1] = d[p];
                    vis[v1] = 1;
                    q.push_front(v1);
                }
            }
        }
    }
    q.clear();
    return d[n] <= k;
}

多状态维度/优先队列广搜

我们发现一个状态有多个量,就把数组多开几个维度记录就好了,跟上面的立体推箱子差不多其实。当我们发现扩张代价毫无规律,就只能上优先队列(堆)来优化了,每次取出代价最小的状态进行扩张即可。

例题 装满的油箱

链接 acwing176 考虑扩展状态,每次加不加油,每次通不通过这条边,上优先队列存状态即可,其他没什么区别。

struct sta {
    int city,fuel,cost;
    friend bool operator > (const sta &s1, const sta &s2) {
        return s1.cost > s2.cost;
    }
};
int bfs(int s) {
    for(int i = 1; i <= n; ++i) {
        for(int j = 0; j <= c; ++j) {
            d[i][j] = 0x3f3f3f3f;
            vis[i][j] = 0;
        }
    }
    d[s][0] = 0;
    priority_queue<sta, vector<sta>, greater<sta> >q;
    q.push((sta){s, 0, 0});
    while(q.size()) {
        sta p = q.top(); q.pop();
        if(vis[p.city][p.fuel]) continue;
        vis[p.city][p.fuel] = 1;
        if(p.city == ei) return d[p.city][p.fuel];
        if(p.fuel + 1 <= c) {// 加油
            d[p.city][p.fuel + 1] = min(d[p.city][p.fuel + 1], d[p.city][p.fuel] + petrol[p.city]);
            q.push((sta){p.city, p.fuel + 1, p.cost + petrol[p.city]});
        }
        for(int i = head[p.city]; i; i = e[i].next) {
            if(p.fuel >= e[i].w) {
                d[e[i].to][p.fuel - e[i].w] = min(d[e[i].to][p.fuel - e[i].w], d[p.city][p.fuel]);
                q.push((sta){e[i].to, p.fuel - e[i].w, p.cost});
            }
        }
    }
    return -1;
}

迭代加深

所谓迭代加深,就是dfs的同时限制搜索深度,题目特征一般会如下:

1.答案不知道,但是不会大,可以直接暴搜判定行不行(这一种题相当于枚举答案),这个时候由于状态呈指数级增长,就不要轻易尝试二分了

2.搜索要求“层数”最小,或者搜索层数不会大

迭代加深一般用处不大,但是以此为基础改进的IDA*用处就很大了。

例题 Addition Chains

链接 UVA529 (家里网不好上不去uva就贴洛谷链接了)

这里我们显然可以打出暴搜,每次搜到头取最小答案,但是这样开销过大,考虑迭代加深,设置个最大深度,搜到最大深度就返回就行了。

这样显然不够,首先套路地走一遍优化流程,优化掉ij重复枚举,排除冗余(好像就排了一半常数......),然后优化搜索顺序,从大到小枚举当前数

另外有个超级大力剪枝,a[i]一定由a[i-1]和某个a[j](1 <= j <= i - 1)转移过来。证明首先考虑反证法,假设最优解中最后一个数a[i]不由a[i−1]转移来,那么我们可以就可以去掉a[i−1](a[i]都够到n了,把a[i-1]去掉不影响答案合法性)得到更小的答案,所以a[i]一定是由a[i−1]与某个a[j]转化而来。倒数第二位数同理,只要这个第二位数能被其他数表示出来就可以去掉中间一些没用的数,而由倒数第三位数转移而来则没有数可以去掉,所以由数学归纳法得证。

 1 void dfs(int x) {
 2     if(a[x] == n) {
 3         solve = 1;
 4         return;
 5     }
 6     if(x == step) return;
 7     for(short i = x; i >= x; --i) {// 优化搜索顺序
 8         for(short j = x; j >= 1; --j) {
 9             int temp = a[i] + a[j];
10             if(used[temp] || temp > n || temp <= a[x - 1]) continue;// 减少重复搜索
11             used[temp] = 1;
12             a[x + 1] = temp;
13             dfs(x + 1);
14             if(solve) goto end;
15         }
16     }
17     end:
18         for(short i = n; i >= 1; --i) used[i] = 0;
19 }

题目推荐

应用其实也不是很广,感觉不如IDA*

就书上的两道题

·巴士 acwing186

·导弹防御系统 acwing187

折半搜索meet in middle

折半搜索可以降低dfs时间复杂度,一般题目具有如下特征:

1.直接暴搜过不去,但是数据规模稍微小个十几二十就过得去了。

2.答案可以由两部分拼接而成,要求这个拼接的过程时间复杂度不是很高。

比如直接暴搜时间复杂度是O(2^n)的,使用折半搜索可以把时间复杂度降低至O(g(n)2^(n/2)),这种时间复杂度计法不知道是否严谨,但是可以看出指数少了一倍,对算法的优化是飞跃式的。其中g(n)表示合并单个数的时间复杂度

怎么说呢,实际上折半搜索用得也不是很广,但是这种“对半撇”(meet in middle)的思想十分重要,例如CSP-S2022 T1假期计划,虽然算法本身跟折半搜索没啥关系,但是这个题用到了这种折半后拼接的思想。

难点在于如何拼接答案

例题 送礼物

链接 acwing171 暴力搜索是显然的,但是2^46难过,这个时候我们就需要折半搜索meet in middle了,把数组分为两半 前一半暴力枚举出可能达到的值 后一半也一样,然后关键就在这里,拼接答案,我们把前一半去重排序 后一半每个数二分查找 <= W-这个数 的最大值,答案取最大值即可,时间复杂度O(N*2^(N/2))。记要合并的数有M=2^(N/2)个,合并单个数时间复杂度O(logM)=O(N),所以时间复杂度是这个。

常见的优化套路比如改变搜索顺序就暂且不提。二分记得手写,bound常数大了过不去

void dfs1(short x, int now) {
    if(x == middle + 1) {
        a[++cnt] = now;
        return; 
    }
    if(now <= w - wei[x]) dfs1(x + 1, now + wei[x]);
    dfs1(x + 1, now);
}
void dfs2(short x, int now) {
    if(x == n + 1) {
        int pos = 0, l = 1, r = cnt, mid = 0;
        while(l <= r) {// 手写二分了 卡不过去 
            mid = (l + r) >> 1;
            if(a[mid] <= w - now) l = mid + 1, pos = mid;
            else r = mid - 1;
        }
        ans = max(ans, a[pos] + now);
        return;
    }
    if(now <= w - wei[x]) dfs2(x + 1, now + wei[x]);
    dfs2(x + 1, now);
}
// 下面在main函数里
sort(wei + 1, wei + n + 1, cmp);// 优化搜索顺序 快到飞起 
middle = min(n - 1, (n >> 1));
dfs1(1, 0);
sort(a + 1, a + cnt + 1);
cnt = unique(a + 1, a + cnt + 1) - a - 1;// 去重可降低时间复杂度 
dfs2(middle + 1, 0);

题目推荐

·LuoguP4799 CEOI2015 世界冰球锦标赛

·LuoguP3067 USACO 平衡牛子集

A*

A*算法是一种启发式的搜索,基于带优先队列的广度优先搜索变化而来。甚至高中信息技术书上都有可见其十分nb。具体而言,我们设当前状态为x,到达该状态已经消耗的代价f(x),我们需要设计一个估价函数g(x)代表x状态到达最终态的估计代价,我们不再取出f(x)最小的状态x进行扩展,而是f(x)+g(x)最小的状态进行扩展。需要注意的是,g(x)必须严格小于等于实际代价,这叫可采纳性。用反证法可以证明,如果g(x)大于实际代价,有可能会导致第一次到达最终态时所用代价不是最小代价。同时,这个g(x)越接近真实代价越好,设计这个g(x)就比较考验人类智慧了。

应用范围大概要求:

1.代价不能为负

2.求解最优化问题

(其实大多数时候IDA*应用更广,关键是IDA*基于dfs,好写啊)

例题 八数码

链接 acwing 179 这是一道不得不提的经典题目。首先八数码问题无解,当且仅当把空位x看作1,按题目输入方式排成一行,逆序对数为奇数时无解,这个东西的证明据书上所说,充分性证明较为复杂,可以上网搜索到相关内容。我们首先将状态压成一个多位数字,每次扩展再进行解码,用map来存代价进行扩展(这里也可以使用康托展开进行哈希,但是没必要)。估价函数设计为当前状态每个数字位置,与最终态对应数字位置,曼哈顿距离之和。注意不能计入x。

比如: 当前状态 最终状态

               123          123

               4x6          456

               758          78x

对于5代价 |3 - 2| + |2 - 2| = 1

对于8代价 |3 - 3| + |3 - 2| = 1

合计估价2,等于实际代价(x向下 向右即可),满足条件。

const int sup_li[11] = {3, 1, 1, 1, 2, 2, 2, 3, 3};
const int sup_col[13] = {3, 1, 2, 3, 1, 2, 3, 1, 2};
const int dx[5] = {0, 1, 0, -1, 0};
const int dy[5] = {0, 0, -1, 0, 1};
const char ops[5] = {0, 'd', 'l', 'u', 'r'};
std::map<int, int> dis;
std::map<int, bool> vis;
struct sta {
    int mat,cost;
    std::vector<char>op;// 操作序列 
    friend bool operator > (const sta &s1, const sta &s2) {// A*
        return s1.cost + dis[s1.mat] > s2.cost + dis[s2.mat];
    }
}st,ans;
std::priority_queue<sta, std::vector<sta>, std::greater<sta> >q;
short bit_10(int x, short bit) {
    return x / p_10[bit - 1] % 10;
}
short calc(int x) {// 估价函数
    short ret = 0;
    for(short i = 1; i <= 3; ++i) {
        for(short j = 1; j <= 3; ++j) {
            short id = 10 - (i - 1) * 3 - j;
            short num = bit_10(x, id);
            if(!num) continue;
            ret += abs(i - sup_li[num]) + abs(j - sup_col[num]);
        }
    } 
    return ret;
}
sta A_star() {
    q.push(st);
    short nowmp[5][5],xi = 0,yi = 0;
    while(q.size()) {
        sta p = q.top(); q.pop();
        if(p.mat == 123456780) return p;
        if(vis[p.mat]) continue;
        vis[p.mat] = 1;
        for(short i = 1; i <= 3; ++i) {
            for(short j = 1; j <= 3; ++j) {
                short id = 10 - (i - 1) * 3 - j;
                nowmp[i][j] = bit_10(p.mat, id);
                if(!nowmp[i][j]) {
                    xi = i;
                    yi = j;
                }
            }
        }
        for(short i = 4; i >= 1; --i) {
            short nowx = xi + dx[i];
            short nowy = yi + dy[i];
            if(nowx < 1 || nowy < 1 || nowx > 3 || nowy > 3) continue;
            sta now = p;
            short temp = nowmp[nowx][nowy];
            nowmp[nowx][nowy] = nowmp[xi][yi];
            nowmp[xi][yi] = temp;
            now.mat = 0;
            for(short j = 1; j <= 3; ++j) {
                for(short k = 1; k <= 3; ++k) {
                    now.mat = now.mat * 10 + nowmp[j][k];
                }
            }
            dis[now.mat] = dis[p.mat] + 1;
            now.cost = calc(now.mat);
            now.op.push_back(ops[i]);
            q.push(now);
            temp = nowmp[nowx][nowy];
            nowmp[nowx][nowy] = nowmp[xi][yi];
            nowmp[xi][yi] = temp;
        }
    }
}    

IDA*

IDA*,全名迭代加深启发式搜索,是A*的升级版,但是由于有迭代加深,所以是基于DFS的一种搜索变体。题目特征就是A*/迭代加深搜索的特征。

别看这个名字像叠buff一样吓人,IDA*的代码可比A*好写多了,主要就是发挥一下人类智慧设计估价函数。改成当前代价+估价 > 迭代加深限制层数时就返回。

例题 骑士精神

链接 SCOI2005 骑士精神 也算是个典题了

题目就差没把“迭代加深”四个字糊脸上了。

接下来我们均(xuan)摊(xue)分析大致时间开销,下面这个矩阵是空格在这个位置有多少马可到

2 3 4 3 2
3 4 6 4 3
4 6 8 6 4
3 4 6 4 3
2 3 4 3 2
所以每层搜索树期望规模为 3.84 按4计算,算上迭代加深大概2 ^ 31,过不了(现在机子好了没准瞎搞真过去了。。。)。
首先我们可以加上不回到上一状态的剪枝,这个剪枝十分简单但也很经典,再加上估价函数就变成IDA*了。
研究样例可以发现最优情况可以是一个黑过去,一个白过来于是估价函数就设计为在左下部分(原本该放白马的部分)的黑马数*2。就样例来说,样例1初始局面估4实际答案7,样例2初始局面估10,无解,还是挺高效的。
注意一个经典错误,这个题估价函数不能计入*,比如下面的例子:

11111

0*111

00011

10001

00000

可以两步到位,加上*估价为2 + 1 = 3,就WA掉了。

const int dx[9] = {0, 2, 2, 1, -1, -2, -2, -1, 1};
const int dy[9] = {0, 1, -1, -2, -2, -1, 1, 2, 2};
struct sta {
    bool mp[6][6];
    int bx,by;// 空格位置
    friend bool operator == (const sta &s1, const sta &s2) {
        if(s1.bx != s2.bx || s1.by != s2.by) return 0;
        for(short i = 1; i <= 5; ++i) {
            for(short j = 1; j <= 5; ++j) {
                if(s1.mp[i][j] != s2.mp[i][j]) return 0;
            }
        }
        return 1;
    }
}st,ed;// ed是最终态,main函数里面处理一下就好
short value(sta x) {// 估价函数
    return (x.mp[2][1] + x.mp[3][1] + x.mp[3][2] + x.mp[4][1] + x.mp[4][2] + x.mp[4][3] + x.mp[4][4] + x.mp[5][1] + x.mp[5][2] + x.mp[5][3] + x.mp[5][4] + x.mp[5][5]) * 2;
}
void IDA_star(short dep, sta x, int lstx, int lsty) {
    if(dep + value(x) > maxstep) return;
    if(x == ed) {
        solve = 1;
        return;
    }
    for(short i = 1; i <= 8; ++i) {// 实际上剪枝之后我们搜索树期望扩张规模2.84
        sta now;
        now.bx = x.bx + dx[i];
        now.by = x.by + dy[i];
        if(now.bx < 1 || now.by < 1 || now.bx > 5 || now.by > 5) continue;
        if(now.bx == lstx && now.by == lsty) continue;
        now = x;
        now.bx = x.bx + dx[i];
        now.by = x.by + dy[i];
        now.mp[x.bx][x.by] = now.mp[now.bx][now.by];
        now.mp[now.bx][now.by] = 0;
        IDA_star(dep + 1, now, x.bx, x.by);
    }
}

推荐题目

·AHOI2012 铁盘整理

·UVA11212 编辑书稿 同题 acwing180

·UVA1343 旋转游戏 同题 acwing181

还有个题UVA1603 破坏正方形(acwing182)过于复杂,有兴趣可以写一下

 

posted on 2023-01-25 16:17  loser_kugua  阅读(55)  评论(0编辑  收藏  举报