【AcWing算法提高课】2.2.3DFS之迭代加深、双向DFS、IDA*

文章介绍了迭代加深IDDFS的概念和优势,它通过限制搜索深度避免无底洞式搜索,首次找到的解即最优解。文章通过加成序列和送礼物问题展示了IDDFS的适用性,以及如何通过剪枝优化搜索效率。此外,提到了IDA*结合A*的启发函数进一步提高搜索效率,并给出了排书问题的IDA*求解示例。最后,文章讨论了回转游戏的解决方案,利用IDA*和特定的估价函数设计。
摘要由CSDN通过智能技术生成

一、迭代加深 (IDDFS)

有时候我们用DFS搜索可行解时,可能会遇到这样一种情况:有些非合法方案的分支,需要搜索特别深才能判定失败,但是合法方案在其之后一个比较浅的位置,如下图所示,这样直接运用DFS会进入“无底洞”,浪费大量时间。迭代加深则可以有效解决这种问题。

在这里插入图片描述
这里引入迭代加深的概念:一层一层来搜,每一次定义一个层数上限 m a x _ d e p t h max\_depth max_depth,使用DFS搜索,当DFS搜索层数大于 m a x _ d e p t h max\_depth max_depth 时,直接 return (即把层数大于 m a x _ d e p t h max\_depth max_depth 的部分全部剪掉)。从 0 0 0 开始限制层数上限,若搜索结束后没有找到合法方案,则将 m a x _ d e p t h + 1 max\_depth+1 max_depth+1,进行下一次搜索,直到找到一个合法方案,结束。由于限制层数上限由小到大,第一次找到的方案一定是最优解 (层数最小)。

可以将 m a x _ d e p t h max\_depth max_depth 对搜索深度的限制看成在整个搜索空间中划分了一个小的搜索区域,在这个区域内搜索可行解,若区域内无解,则将区域向外扩大一圈 (即 m a x _ d e p t h + 1 max\_depth+1 max_depth+1),然后继续搜索。在这个过程中,搜索区域逐步扩大,可以很大减少搜索到的状态数量。

但是,每次层数上限加一时,DFS都会从头重新开始搜索,这样多次重复搜索会不会浪费时间?假设合法方案在第 10 10 10 层,每个结点均有两个分支,在 m a x _ d e p t h max\_depth max_depth 分别为 0 , 1 , 2 , . . . , 10 0,1,2,...,10 0,1,2,...,10 时,搜索到的结点数依次为 2 1 − 1 , 2 2 − 1 , 2 3 − 1 , . . . , 2 11 − 1 2^1-1,2^2-1,2^3-1,...,2^{11}-1 211,221,231,...,2111,而 ∑ i = 1 10 ( 2 i − 1 ) = 2 11 − 12 \sum\limits^{10}_{i=1}(2^i-1)=2^{11}-12 i=110(2i1)=21112,前 10 10 10 次搜索到的结点数总和还不如第 11 11 11 次搜索到的结点数多,而且在一般情况下,分支结点远多于 2 2 2 个,因此前面重复搜索的部分在整个搜索空间里面只占很少部分,无需在意。

二、加成序列

170.加成序列 题目链接

由于 1 ≤ n ≤ 100 1\le n\le 100 1n100,序列中元素递增,搜索树的最大深度可达 100 100 100 ( x [ i ] = i , n = 100 x[i]=i,n=100 x[i]=i,n=100 时)。但是最优解一定在比较浅的层数中:考虑序列 x [ i ] = 2 i − 1 x[i]=2^{i-1} x[i]=2i1,当 i = 8 i=8 i=8 时, x [ i ] = 128 > 100 ≥ n x[i]=128>100\ge n x[i]=128>100n。因此,本题非常适合使用迭代加深来搜索。

考虑一个可行的搜索方案:从前往后搜索序列中每个元素,对于每个位置,枚举该位置能填的所有数,选择一个填入,然后继续搜索。

考虑剪枝,对于本题:

  1. 优化搜索顺序:在枚举某一位置能填的所有数时,从大到小枚举;
  2. 排除等效冗余:用一个 bool 数组记录每个数是否被枚举过;
  3. 可行性剪枝:枚举的数一定大于在其前一个位置的数,且不超过 n n n
  4. 最优性剪枝:迭代加深搜到的第一个可行方案就是最优解,无需剪枝。

代码实现:

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 110;

int n;
int path[N];

bool dfs(int u, int depth){
    if (u > depth) return 0;
    if (path[u - 1] == n) return 1;
    
    bool st[N] = {0};
    for (int i = u - 1; i >= 0; i --)
        for (int j = i; j >= 0; j --){
            int s = path[i] + path[j];
            if (s > n || s <= path[u - 1] || st[s]) continue;
            
            st[s] = 1;
            path[u] = s;
            if (dfs(u + 1, depth)) return 1;
        }
    
    return 0;
}

int main(){
    path[0] = 1;
    while (cin >> n, n){
        int depth = 1;
        while (!dfs(1, depth)) depth ++;
        
        for (int i = 0; i < depth; i ++)
            cout << path[i] << " ";
        cout << endl;
    }
    
    return 0;
}

二、送礼物

171.送礼物 题目链接

题目背景与01背包问题相同,但数据范围 1 ≤ W , G [ i ] ≤ 2 31 − 1 1\le W,G[i]\le 2^{31}−1 1W,G[i]2311 使得该题不能用DP求解 (01背包的DP方法时间复杂度为 O ( N V ) O(NV) O(NV) V V V 为背包体积)。注意到 1 ≤ N ≤ 46 1\le N\le 46 1N46,尝试暴搜求解。

但是若直接用朴素DFS,依次枚举每个物品选或不选,总共有 2 N = 2 46 2^N=2^{46} 2N=246 种方案,肯定会超时。这里介绍一种新的方法:双向DFS,其思想与双向BFS相同。

思想:将所有物品按从大到小排序,划分成两个部分搜索。先暴搜处理出前 N / 2 N/2 N/2 个物品所能凑成的所有重量并排序判重,再枚举后 N / 2 N/2 N/2 个物品的选择情况,对于每一种选择方案,设其重量为 S S S,最多举起重量为 W W W,在前 N / 2 N/2 N/2 个物品锁凑成的所有重量中二分找到最大的且不超过 W − S W-S WS 的重量 X X X,用 S + X S+X S+X 更新最大值。这样时间复杂度为 O ( 2 N / 2 ∗ ( 1 + N / 2 ) ) O(2^{N/2}*(1+N/2)) O(2N/2(1+N/2))
同时,这里运用了空间换时间的重要思想:将前 N / 2 N/2 N/2 个物品的搜索结果记录下来,在搜索后 N / 2 N/2 N/2 个物品时,可直接查表得前面的搜索结果。

注意,在视频中y总还提到了进一步的时间优化 ( N = 46 , K = 25 N=46,K=25 N=46,K=25),但是在实际测试中发现,最后两个测试点会超时。
其实,y总的分析有误:前半部分搜索的复杂度并非只是深搜的 2 K 2^K 2K,而应该再加上排序的时间,实际复杂度 2 K ∗ ( 1 + K ) 2^K*(1+K) 2K(1+K),后半部分搜索复杂度为 K ∗ 2 N − K K*2^{N-K} K2NK,当 N = 46 N=46 N=46 时,计算得 K = 23   ( = N / 2 ) K=23\ (=N/2) K=23 (=N/2) 时总耗时最短。

代码实现:

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 46;

typedef long long LL;

int n, m, k;
int w[N], weights[1 << 25];
int cnt = 1, ans;

void dfs1(int u, int s){
    if (u == k){
        weights[cnt ++] = s;
        return;
    }
    
    dfs1(u + 1, s);
    if (s <= m - w[u]) dfs1(u + 1, s + w[u]);
}

void dfs2(int u, int s){
    if (u >= n){
        int l = 0, r = cnt - 1;
        while (l < r){
            int mid = l + r + 1 >> 1;
            if (weights[mid] <= m - s) l = mid;
            else r = mid - 1;
        }
        ans = max(ans, s + weights[l]);
        return;
    }
    
    dfs2(u + 1, s);
    if (s <= m - w[u]) dfs2(u + 1, s + w[u]);
}

int main(){
    cin >> m >> n;
    for (int i = 0; i < n; i ++) cin >> w[i];
    
    sort(w, w + n);
    reverse(w, w + n);
    
    k = n / 2;
    dfs1(0, 0);
    
    sort(weights, weights + cnt);
    cnt = unique(weights, weights + cnt) - weights;
    
    dfs2(k, 0);
    
    cout << ans;
    
    return 0;
}

三、IDA*

IDA*,顾名思义,即IDDFS+A*,在迭代加深的基础上增加了A*中的启发函数,用于估计某个状态到终点至少需要的步数 (估价不超过真实值)。当当前层数加上估价距离大于限制的搜索层数上限 m a x _ d e p t h max\_depth max_depth 时,直接 return。不同于A*基于优先队列BFS,IDA*基于IDDFS,不需要手写队列或者优先队列,代码量相对较短,实现较为容易。

四、排书

180.排书 题目链接

在搜索时,每个状态有多少个分支?可以先枚举一段书的长度,再枚举这段书插入的位置。长度为 i i i 的段有 n − i + 1 n-i+1 ni+1 种,对于每种选择,都可以将其插入 n − i n-i ni 个不同位置 (拿去 i i i 本书后剩下 n − i n-i ni 本书,有 n − i + 1 n-i+1 ni+1 个空挡,其中有一个空挡是原来取出书的位置,故可以插入另外 n − i n-i ni 个位置)。
此外,这些方案有重复:取出 [ x , y ] [x,y] [x,y] 段插入 z z z 后与取出 [ y + 1 , z ] [y+1,z] [y+1,z] 段插入 x x x 前得到的结果是相同的,即每种实际排书操作被计算了两次。
因此,每个状态的分支数量有 1 2 ∑ i = 1 14 ( 15 − i + 1 ) ( 15 − i ) = 560 \frac12\sum\limits^{14}_{i=1}(15-i+1)(15-i)=560 21i=114(15i+1)(15i)=560 种。可以使用双向BFS,这里用IDA*求解。

IDA*的核心与A*相同,需要设计出一个估价函数。这里引入后继:即某个元素之后的那个元素,如在从小到大的序列中, 1 1 1 的后继是 2 2 2 2 2 2 的后继是 3 3 3
在一次排书的操作 (取出 [ x , y ] [x,y] [x,y] 段插入 z z z 后) 中,序列中有且仅有 x − 1 , y , z x-1,y,z x1,y,z 三者的后继被改变。统计出当前序列中后继“不正确” (后继不是它加一得到的数) 的数的个数 t o t tot tot,在最好情况下,每次排书操作使得 t o t − 3 tot-3 tot3,至少需要 ⌈ t o t / 3 ⌉   ( = ⌊ ( t o t + 2 ) / 3 ⌋ ) \lceil tot/3\rceil\ (=\lfloor (tot+2)/3\rfloor) tot/3 (=⌊(tot+2)/3⌋) 次操作才能到达目标状态。

代码实现:

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

const int N = 15;

int n;
int q[N];
int w[5][N];

int f(){
    int tot = 0;
    for (int i = 0; i + 1 < n; i ++)
        if (q[i + 1] != q[i] + 1)
            tot ++;
    return (tot + 2) / 3;
}

bool dfs(int depth, int max_depth){
    if (depth + f() > max_depth) return false;
    if (f() == 0) return true;
    
    for (int len = 1; len <= n; len ++)
        for (int l = 0; l + len - 1 < n; l ++){
            int r = l + len - 1;
            for (int k = r + 1; k < n; k ++){
                memcpy(w[depth], q, sizeof q);
                int y = l;
                for (int x = r + 1; x <= k; x ++, y ++) q[y] = w[depth][x];
                for (int x = l; x <= r; x ++, y ++) q[y] = w[depth][x];
                if (dfs(depth + 1, max_depth)) return true;
                memcpy(q, w[depth], sizeof q);
            }
        }
        
    return false;
}

int main(){
    int T;
    cin >> T;
    
    while (T --){
        cin >> n;
        for (int i = 0; i < n; i ++) cin >> q[i];
        
        int depth = 0;
        while (depth < 5 && !dfs(0, depth)) depth ++;
        
        if (depth >= 5) puts("5 or more");
        else cout << depth << endl;
    }
    
    return 0;
}

五、回转游戏

181.回转游戏 题目链接

(从直觉上来讲最优解所需步数不会太多)
用IDA*求解。这样设计估价函数:由于每一次操作至多会改变中间八个格子中的一个数,统计一下当前中间八个格子中哪个数最多,用 8 8 8 减去这最多的数的个数,即得到一个估价。

本题最难之初在于如何模拟这 8 8 8 种移动操作。可以将该 # 形棋盘中的每个格子编上序号,如下图所示,将其作为一个长度为 24 24 24 的序列存储。

在这里插入图片描述
再对于每个移动操作,按移动方向顺序存储下来移动的长度为 7 7 7 的序列中各个元素在原序列中的下标,在进行移动时只需将后 6 6 6 个元素前移一位,首元素移动到末尾即可。

代码实现:

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 24;

int op[8][7] = {
    {0, 2, 6, 11, 15, 20, 22},
    {1, 3, 8, 12, 17, 21, 23},
    {10, 9, 8, 7, 6, 5, 4},
    {19, 18, 17, 16, 15, 14, 13},
    {23, 21, 17, 12, 8, 3, 1},
    {22, 20, 15, 11, 6, 2, 0},
    {13, 14, 15, 16, 17, 18, 19},
    {4, 5, 6, 7, 8, 9, 10}
};

int opposite[8] = {5, 4, 7, 6, 1, 0, 3, 2};
int center[8] = {6, 7, 8, 11, 12, 15, 16, 17};

int q[N];
int path[100];

int f(){
    int sum[4];
    memset(sum, 0, sizeof sum);
    
    for (int i = 0; i < 8; i ++) sum[q[center[i]]] ++;

    int s = 0;
    for (int i = 1; i <= 3; i ++) s = max(s, sum[i]);
    return 8 - s;
}

void operate(int x){
    int t = q[op[x][0]];
    for (int i = 0; i < 6; i ++) q[op[x][i]] = q[op[x][i + 1]];
    q[op[x][6]] = t;
}

bool dfs(int depth, int max_depth, int last){  //last存储上一步操作
    if (depth + f() > max_depth) return 0;
    if (f() == 0) return 1;
    
    for (int i = 0; i < 8; i ++)
        if (last != opposite[i]){  //剪枝:如果上一步操作与当前操作相反,直接跳过
            operate(i);
            path[depth] = i;
            if (dfs(depth + 1, max_depth, i)) return 1;
            operate(opposite[i]);
        }
        
    return 0;
}

int main(){
    while (cin >> q[0], q[0]){
        for (int i = 1; i < N; i ++) cin >> q[i];
        
        int depth = 0;
        while (!dfs(0, depth, -1)) depth ++;
        
        if (!depth) puts("No moves needed");
        else{
            for (int i = 0; i < depth; i ++) 
                cout << char('A' + path[i]);
            cout << endl;
        }
        cout << q[6] << endl;
    }
    
    return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值