Week2Day3B:抽象深度优先搜索【2023 安全创客实践训练|笔记】

内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将于15个工作日内将博客设置为仅粉丝可见。
 


抽象形式的 DFS

接下来看一个问题:

给定 n 个整数,要求选出 K 个数,使得选出来的 K 个数的和为 sum。

 

我们依然借助 DFS 来解决这个问题。对于每一个数,枚举选或者不选两种情况,我们可以用 DFS 思想来完成这样的枚举过程。

我们在搜索的过程中,用 S 来记录当前选择的数值总和,k 来记录选择的数的个数,deep 表示当前正在枚举第几个数是否选择。

在第一层 DFS 的时候,我们可以枚举是否选第一个数,如果选第一个数则让 S 加上第一个数且 k 加一,DFS 进入到下一层;否则 DFS 直接进入到下一层。当然,这里我们还需要借助全局变量、参数或修改数组中元素的值等方式来标识出当前的层数,为了减少篇幅,在下文中就直接忽略掉了。

在第二层,对第二个数做同样的处理,DFS 的过程中记录已经选取的数的个数,如果已经选取了 k 个数,判断 S 值是否等于 sum。对于每一层,我们都有两个选择——选和不选。不同的选择,都会使得搜索进入完全不同的分支继续搜索。

下图是这个搜索过程对应的 搜索树,搜索树上的每一个结点都是一个 状态,一个状态包含两个值 S 和 k,也就是一个状态对应当前的数值总和,以及选的数的个数。

 


搜索树和状态

前面说过,DFS 看起来是运行在图上的搜索算法,而前一节给大家展示的 DFS 过程,我们没有看到图的存在,这就是抽象形式的 DFS 的特点。

我们可以根据搜索状态构建一张抽象的图,图上的一个顶点就是一个状态,而图上的边就是状态之间的转移关系(进一步搜索或回溯)。虽然 DFS 是在这张抽象的图上进行的,但我们不必把这张图真正地建立出来。

我们可以认为,一次 DFS 实际上就是在搜索树上完成了一次深度优先搜索。而在上节中的搜索树里的每一个状态,记录了两个值——和值和个数。对于每个数,我们都有两个选择——选和不选。不同的选择,都会使得搜索进入完全不同的分支继续搜索。而每个状态对应的 子树,都是这个状态通过搜索可能达到的状态。

在搜索树上 DFS 的过程如下:

K 个数的和问题的代码实现:

#include <stdio.h>
int n, k, sum, ans;
int a[40];
void dfs(int i, int cnt, int s) {
    if (i == n) {
        if (cnt == k && s == sum) {
            ans++;
        }
        return;
    }
    dfs(i + 1, cnt, s);
    dfs(i + 1, cnt + 1, s + a[i]);
}
int main() {
    scanf("%d%d%d", &n, &k, &sum);
    for (int i = 0; i < n; i++) {
        scanf("%d", &a[i]);
    }
    ans = 0;
    dfs(0, 0, 0);
    printf("%d\n", ans);
    return 0;
}

改变搜索策略

对于前面 k 个数的和的求法,我们除了可以用上面的 DFS 方法以后,还有一种搜索策略。

之前的方法是每次去抉择是否选择第 i 个数,现在我们的策略是从剩下的数中选择一个数。比如有 5 个数 1,2,3,4,5,如果选择了 1,那么剩下 2,3,4,5 四个数;如果选择了 2,那么剩下 1,3,4,5 四个数,还可以选择 3....;选择 4....;选择 5.....。

 

代码实现起来很简单,我们标记每个数的是否被选择了。我们用 s 表示选出来的数的和,cnt 表示选出来的数的个数。

int n, k, sum, ans = 0, a[110];
int xuan[110]; // 标记是否选择了
void dfs(int s, int cnt) {
    if (cnt == k) {
        if (s == sum) {
            ans++;
        }
    }
    for (int i = 0; i < n; i++) {
        if (!xuan[i]) {
            xuan[i] = 1;
            dfs(s + a[i], cnt + 1);
            xuan[i] = 0;
        }
    }
}

蒜头君手上有一些小木棍,它们长短不一,蒜头君想用这些木棍拼出一个等边三角形,并且每根木棍都要用到。 例如,蒜头君手上有长度为 1,2,3,3 的 4 根木棍,他可以让长度为1,2 的木棍组成一条边,另外 2 跟分别组成 2 条边,拼成一个边长为 3 的等边三角形。蒜头君希望你提前告诉他能不能拼出来,免得白费功夫。

输入格式

首先输入一个整数 n(3≤n≤10),表示木棍数量,接下来输入 n 根木棍的长度 p_i​(1≤p_i​≤10000)。

输出格式

如果蒜头君能拼出等边三角形,输出yes,否则输出no

样例输入

5
1 2 3 4 5

样例输出

yes

参考程序

#include <stdio.h>
int l[30];
int sum, n;
int ok;
void dfs(int id, int l1, int l2, int l3) {
    if (id == n) {
        if (l1 == sum && l2 == sum && l3 == sum) {
            ok = 1;
        }
        return;
    }
    dfs(id + 1, l1 + l[id], l2, l3);
    dfs(id + 1, l1, l2 + l[id], l3);
    dfs(id + 1, l1, l2, l3 + l[id]);
}
int main() {
    scanf("%d", &n);
    sum = 0;
    for (int i = 0; i < n; ++i) {
        scanf("%d", &l[i]);
        sum += l[i];
    }
    ok = 0;
    sum /= 3;
    dfs(0, 0, 0, 0);
    if (ok) {
        puts("yes");
    } else {
        puts("no");
    }
    return 0;
}
另一种思路

对于状态 (id,nowsum,st),表示枚举到第 id 条边、当前这条边的长度累加至 nowsum 的状态,我们可以从当前尚未选取的木棍(编号最小为st,保证当前边按照编号递增的顺序选取木棍)中选择一个,如果是 a_i​,如果此时 nowsum+a_i​=totalsum/3​,则继续看状态 (id+1,0,0) 是否可行,否则继续看状态 (id,nowsum+a_i​,i+1) 是否可行。直到 id≥3 时说明找到一组合法解。


N 皇后问题

N 皇后问题是一个经典的问题,在一个 N×N 的棋盘上放置 N 个皇后,每行刚好放置一个并使其不能互相攻击(同一行、同一列、同一斜线上的皇后都会自动攻击)。

上图就是一个合法的 8 皇后的解。N 皇后问题是指:计算一共有多少种合法的方法放置 N 个皇后。

 

 

很显然,我们依然会用 dfs 来求解 N 皇后问题,我们的搜索策略如下。

从第 0 行开始,我们依次给每一行放置一个皇后,对于一个确定的行,我们只需要去枚举放置的列即可,在保证放置合法的情况下,一直搜索下去。

下图是 N=4 时搜索树的局部形态(这个搜索树是按照逐列放置的):

上图中每个状态下的 −2,−1,0 表示的是该状态的冲突次数(在多少列或斜线发生冲突),只有当冲突次数为 0 时才是合法状态。

 

确定了搜索策略,现在我们还有一个问题没有解决,那么就是如何判断是否发生了冲突。因为我们是逐行放置的,所以行内是不会冲突的,对于列也很好处理,如果某列被占用了,只需要用一个数组标记即可。

而最难处理的是同一斜线了。一种方法是我们可以直接暴力枚举这个皇后对应的两条对角线上的点,判断是否有冲突。但是这种方法,既麻烦,效率也比较低。下面我们介绍一种巧妙的方法。

有一个规律,这条对角线上的位置的行加列的值相同,都是 4。0+4=1+3=2+2=3+1=4+0=4

 

而这样的对角线上的位置的坐标的行减去列的值也是相同的,都是 −2。

而我们发现,对于每条对角线,和值或者差值都是不一样的,而我们正好可以用这一点来标记一条对角线是否被占用,使得标记对角线就像标记列一样简单了。

8 皇后问题参考程序

#include <stdio.h>
int ans = 0;
int row[10], x1[20], x2[20];
int check(int c, int i) {
    return !row[i] && !x1[c + i] && !x2[c - i + 8];
}
void dfs(int c) {
    if (c == 8) {
        ans++;
        return;
    }
    for (int i = 0; i < 8; i++) {
        if (check(c, i)) {
            row[i] = x1[c + i] = x2[c - i + 8] = 1;
            dfs(c + 1);
            row[i] = x1[c + i] = x2[c - i + 8] = 0;
        }
    }
}
int main() {
    dfs(0);
    printf("%d\n", ans);
    return 0;
}

例题:数独

数独

蒜头君今天突然开始还念童年了,想回忆回忆童年。他记得自己小时候,有一个很火的游戏叫做数独。便开始来了一局紧张而又刺激的高阶数独。蒜头君做完发现没有正解,不知道对不对? 不知道聪明的你能否给出一个标准答案?

标准数独是由一个给与了提示数字的 9×9 网格组成,我们只需将其空格填上数字,使得每一行,每一列以及每一个 3×3 宫都没有重复的数字出现。

输入格式

一个 9×9 的数独,数字之间用空格隔开。*表示需要填写的数字。

输出格式

输出一个 9×9 的数独,把出入中的*替换成需要填写的数字即可。

输入样例

* 2 6 * * * * * *
* * * 5 * 2 * * 4
* * * 1 * * * * 7
* 3 * * 2 * 1 8 *
* * * 3 * 9 * * *
* 5 4 * 1 * * 7 *
5 * * * * 1 * * *
6 * * 9 * 7 * * *
* * * * * * 7 5 *

输出样例

1 2 6 7 3 4 5 9 8
3 7 8 5 9 2 6 1 4
4 9 5 1 6 8 2 3 7
7 3 9 4 2 5 1 8 6
8 6 1 3 7 9 4 2 5
2 5 4 8 1 6 3 7 9
5 4 7 2 8 1 9 6 3
6 1 3 9 5 7 8 4 2
9 8 2 6 4 3 7 5 1

 

 

解析

类似于类似八皇后问题,对每个位置搜索填什么数,标记方法为标记某行某个数字是否出现,标记某列某个数字是否出现,标记某个小方格某个数字是否出现。

对于方格 (x,y),起对应的小方格为编号为 x/3​∗3+y/3​。


搜索剪枝

我们前面已经学习到搜索过程最后会生成一棵搜索树。

剪枝,顾名思义,就是通过一些判断,砍掉搜索树上不必要的子树。有时候,我们会发现某个结点对应的子树的状态都不是我们要的结果,那么我们其实没必要对这个分支进行搜索,砍掉这个子树,就是剪枝。

可行性剪枝

回顾一下之前讨论过的这个问题:

给定 n 个整数,要求选出 K 个数,使得选出来的 K 个数的和为 sum。

如上图,当 k=2 的时候,如果已经选了 2 个数,再往后选多的数是没有意义的。所以我们可以直接减去这个搜索分支,对应上图中的剪刀减去的那个子树。

又比如,如果所有的数都是正数,如果一旦发现当前的和值都已经大于 sum 了,那么之后不管怎么选和值都不可能回到 sum 了,我们也可以直接终止这个分支的搜索。

我们在搜索过程中,一旦发现如果某些状态无论如何都不能找到最终的解,就可以将其“剪枝”了。

 

 

最优性剪枝

对于求最优解的一类问题,通常可以用最优性剪枝,比如在求解迷宫最短路的时候,如果发现当前的步数已经超过了当前最优解,那从当前状态开始的搜索都是多余的,因为这样搜索下去永远都搜不到更优的解。通过这样的剪枝,可以省去大量冗余的计算。

此外,在搜索是否有可行解的过程中,一旦找到了一组可行解,后面所有的搜索都不必再进行了,这算是最优性剪枝的一个特例。

有一个 n×m 大小的迷宫。其中字符'S'表示起点,字符'T'表示终点,字符'*'表示墙壁,字符'.'表示平地。你需要从'S'出发走到'T',每次只能向上下左右相邻的位置移动,并且不能走出地图,也不能走进墙壁。保证迷宫至少存在一种可行的路径,输出'S'走到'T'的最少步数。

现在我们考虑用 DFS 来解决这个问题,第一个搜到的答案 ans 并不一定是正解,但是正解一定小于等于 ans。于是如果当前步数大于等于 ans 就直接剪枝,并且每找到一个可行的答案,都会更新 ans。

#include <stdio.h>
int n, m;
char maze[110][110];
int vis[110][110];
int dir[4][2] = { { -1, 0}, {0, -1}, {1, 0}, {0, 1}};
int ans = 100000;
int in(int x, int y) {
    return 0 <= x && x < n && 0 <= y && y < m;
}
void dfs(int x, int y, int step) {
    if (step >= ans) {
        return;
    }
    if (maze[x][y] == 'T') {
        ans = step;
        return;
    }
    vis[x][y] = 1;
    for (int i = 0; i < 4; i++) {
        int tx = x + dir[i][0];
        int ty = y + dir[i][1];
        if (in(tx, ty) && maze[tx][ty] != '*' && !vis[tx][ty]) {
            dfs(tx, ty, step + 1);
        }
    }
    vis[x][y] = 0;
}
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) {
        scanf("%s", maze[i]);
    }
    int x, y;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < m; j++) {
            if (maze[i][j] == 'S') {
                x = i;
                y = j;
            }
        }
    }
    dfs(x, y, 0);
    printf("%d\n", ans);
    return 0;
}

剪枝实践

现在你看的的代码框的代码就是我们之前写的第一种搜索方法,对于每个数一一枚举选和不选。不过里面的数据不再是输入的,而是有一组特殊的数据。

从 1,2,3,⋯,30 这 30 个数中选 8 个数出来,使得和值为 200。

#include <stdio.h>
int n, k, sum, ans;
int a[40];
void dfs(int i, int cnt, int s) {

    if (i == n) {
        if (cnt == k && s == sum) {
            ans++;
        }
        return;
    }
    dfs(i + 1, cnt, s);
    dfs(i + 1, cnt + 1, s + a[i]);
}
int main() {
    n = 30;
    k = 8;
    sum = 200;
    for (int i = 0; i < 30; i++) {
        a[i] = i + 1;
    }
    ans = 0;
    dfs(0, 0, 0);
    printf("%d\n", ans);
    return 0;
}

你可以先尝试直接点击 运行,你会发现这个程序运行的时间可能比你预期的时间要长很多。

这一步我们尝试加一下可行性剪枝。首先是如果cnt > k了,那么肯定不是我们需要的答案了。

dfs函数的开头写下

if (cnt > k) {
    return;
}
 

这时候再点击 运行,加上一个剪枝以后,程序运行的时间明显快了很多。

我们还可以加上一个剪枝,因为所有的数都是正整,如果s > sum的话,后面的搜索也是不可能的。

所以,在刚才加上的剪枝后面继续写到

if (s > sum) {
    return;
}
 

再次点击 运行,你会发现现在的答案是秒出的。

 

#include <stdio.h>
int n, k, sum, ans;
int a[40];
void dfs(int i, int cnt, int s) {
    if (cnt > k) {
        return;
    }
    if (s > sum) {
        return;
    }
    if (i == n) {
        if (cnt == k && s == sum) {
            ans++;
        }
        return;
    }
    dfs(i + 1, cnt, s);
    dfs(i + 1, cnt + 1, s + a[i]);
}
int main() {
    n = 30;
    k = 8;
    sum = 200;
    for (int i = 0; i < 30; i++) {
        a[i] = i + 1;
    }
    ans = 0;
    dfs(0, 0, 0);
    printf("%d\n", ans);
    return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值