• DFS剪枝与搜索


    前言

    设计DFS搜索首先要保证的是: 设计合理的搜索顺序, 能够涵盖所有的状态。

    然而DFS搜索的状态数量是按照指数级别增长, 而且这些状态有些是'无用的', 为此, 我们需要通过剪枝策略去减少搜索的状态, 从而提高DFS的效率

    DFS的剪枝策略可以分为5大类:

    1. 优化搜索顺序

    2. 排除等效冗余

    3. 可行性剪枝

    4. 最优化剪枝

    5. 记忆化搜索(DP)

    其中记忆化搜索主要用在DP上。

    对于要使用DFS搜索的题目, 我们就可以从以上这几个角度去出发, 发掘题目中的种种性质, 从而减少搜索的方案数, 优化搜索的复杂度

    当然,减少递归的层数能够减少系统资源的利用,从而加快程序的运行速度

    例题

    小猫爬山

    题目链接]

    读完题意之后, 我们可以从这样去设计搜索的顺序:

    每个猫咪看作搜索的层数, 看猫咪能放入哪个缆车中

    对于每只小猫, 有两种安置的策略:

    1. 把小猫放置在已经租用的缆车中

    2. 把小猫放在新的缆车中

    这种搜索顺序可以涵盖所有的可能, 确定好搜索顺序之后, 再分析一下剪枝

    剪枝:

    1. 优化搜索顺序:

    先搜索体积大的猫, 那么缆车最后存放的猫的数量也就越少, 搜索的方案也就越少

    1. 排除等效冗余:

    本题的搜索顺序是不存在等效冗余的, 所以无法从这个角度切入

    1. 可行性剪枝:

    只有当前缆车的空余容量大于某个小猫的体积

    1. 最优化剪枝:

    首先, 答案肯定不会超出猫咪的数量

    其次, 当搜索的结果大于答案, 则当前的方案肯定不会是最优解, 所以可以提前结束搜索, 进行剪枝

    那么最终, 我们可以根据这些分析, 写出本题的代码啦

    #include <iostream>
    #include <algorithm>
    using namespace std;
    
    constexpr int N = 20;
    int c[N], n, m, res;
    int w[N];
    
    void dfs(int u, int cat) {
        // 最优化剪枝
        if (u >= res) return;
        // 安置好最后一个小猫
        if (cat > n) {
            res = u;
            return;
        }
    
        for (int i = 1; i <= u; i++) {
            if (w[i] >= c[cat]) { // 可行性剪枝
                w[i] -= c[cat];
                dfs(u, cat + 1);
                w[i] += c[cat];
            }
        }
        w[u + 1] -= c[cat];
        dfs(u + 1, cat + 1);
        w[u + 1] += c[cat];
    }
    
    int main() {
        cin >> n >> m;
        fill(w, w + N, m);
        res = n;
        for (int i = 1; i <= n; i++) cin >> c[i];
        // 优化搜索顺序
        // 因为体积越大的猫,下面的节点就越少,搜索的状态也就越少
        sort(c + 1, c + n + 1, greater<int>());
        dfs(1, 1);
    
        cout << res << '\n';
    
        return 0;
    }
    

    分成互质组

    题目链接

    本题有两种可行的搜索顺序:

    1. 固定组, 看看有哪些数可以放到这个组里

    2. 固定数,看看这个数能放到哪些组里

    对于这两种搜索顺序,是无法从优化搜索顺序和排除等效冗余角度入手的,接下来会分别对这两种搜索顺序从其可以入手的剪枝策略进行分析

    顺序1:固定组

    搜索顺序:依次枚举所有元素,看其能否加入当前组,否则开辟新组存下此元素

    剪枝

    1. 可行性剪枝:

      判断当前元素能否放入该组中,如果能则将该元素存入当前组中,继续向下搜索

    2. 最优化剪枝:

      如果当前答案大于最优答案,那么无需继续向下搜索

    3. 排除等效冗余:

      start开始枚举,确保枚举的每组数据的组合只出现一次

    #include <iostream>
    using namespace std;
    constexpr int N = 14;
    int a[N], n, ans, group[N][N];
    bool st[N]; // 标记第i个元素是否属于某个组
    
    int gcd(int a, int b) {return b ? gcd(b, a % b) : a; }
    
    inline bool check(int g[], int n, int x) {
        for (int i = 0; i < n; i++)
            if (gcd(g[i], x) > 1) return 0;
        return 1;
    }
    
    void dfs(int u, int c, int sum, int start) {
        // 最优化剪枝
        if (u >= ans) return;
        if (sum == n) {
            ans = u;
            return;
        }
    
        bool ok = 1;
        for (int i = start; i < n; i++) 
            // 可行性剪枝
            if (!st[i] && check(group[u], c, a[i])) {
                st[i] = 1;
                group[u][c] = a[i];
                dfs(u, c + 1, sum + 1, i + 1);
                st[i] = 0;
                ok = 0;
            }
        if (ok) dfs(u + 1, 0, sum, 0);
    }
    
    
    int main() {
        cin >> n;
        ans = n;
        for (int i = 0; i < n; i++) cin >> a[i];
        dfs(1, 0, 0, 0);
    
        cout << ans << '\n';
        return 0;
    }
    

    顺序2:固定元素

    搜索顺序:对于当前元素,看其能否放入已有的组中,或者开辟新组存下该元素

    剪枝

    1. 可行性剪枝:

      如果当前元素能存放在该组中, 那么存下该元素继续搜索

    2. 最优化剪枝:

      如果当前答案大于最优答案,那么无需继续向下搜索

      如果当前元素能放入一个已经存在组内,那么就没有必要去搜索放入其他已经存在的组

    #include <iostream>
    #include <vector>
    using namespace std;
    using VI = vector<int>;
    
    constexpr int N = 11;
    int a[N], w[N], n, res;
    VI g[N];
    bool st[N];
    
    int gcd(int x, int y) { return y ? gcd(y, x % y) : x; }
    
    inline bool check(VI g, int x) {
        for (int i : g)
            if (gcd(i, x) > 1)
                return 0;
        return 1;
    }
    
    void dfs(int u, int num) {
        // 最优化剪枝
        if (u >= res) return;
        if (num >= n) {
            res = u;
            return;
        }
    
        int c = a[num];
        for (int i = 1; i <= u; i++) {
            // 可行性剪枝
            if (check(g[i], c)) {
                /* 如果一个元素能放入一个组内,那么直接放进去,
                 * 不需要考虑能不能放进其他的组
                 */
                g[i].push_back(c);
                dfs(u, num + 1);
                g[i].pop_back();
    
                break;
            }
        }
    
        g[u + 1].push_back(c);
        dfs(u + 1, num + 1);
        g[u + 1].pop_back();
    }
    
    int main() {
        scanf("%d", &n);
        // 一个小小的最优化剪枝
        res = n;
        for (int i = 0; i < n; i++) scanf("%d", &a[i]);
        dfs(1, 0);
    
        cout << res << '\n';
    
        return 0;
    }
    

    本题小结

    虽然这两种搜索顺序的剪枝策略差不太多,但是顺序2的速度是顺序1的\(30\)多倍。也由此可见,搜索顺序的选择对DFS速度的影响也极其重要

    数独

    题目链接

    本题不光需要剪枝,还需要二进制状态表示等小技巧

    搜索顺序

    选取九宫格中的一个格子,将其填满,之后再选另一个格子填满……

    剪枝

    1. 优化搜索顺序:

    每次选填过的最多的格子,因为选过的格子越多,那么往后枚举的可能也就越少

    1. 可行性剪枝:

    当前位置的列、行和所在的九宫格都没有填过数字\(x\),那么这个数字是可填的

    其他的优化

    1. 对于每一列、行、和九宫格填过数字的状态,我们可以用二进制来表示,1代表每天过,0代表填过

    2. 对于求一个九宫格中有多少个没有填过的数,我们可以提前预处理出所有二进制的状态,统计每个状态中有多少个1

    3. 判断当前格子能否填数,我们可以去这个格子所在的行、列和九宫格的状态的&,得到二进制中只有一个1的数字。找到1出现的位置加上1,得到的结果就是可以填的数字。同样的,这个状态中1出现的位置也可提前预处理出来

    4. 可以用lowbit去快速计算一个状态中可以填的数字的位置

    #include <bits/stdc++.h>
    using namespace std;
    
    constexpr int N = 9, M = 1 << N;
    
    int row[N], col[N], f[3][3];
    char s[N * N + 5];
    int loc[M], Count[M];
    
    inline int lowbit(int x) { return x & -x; }
    
    inline void init() {
      for (int i = 0; i < N; i++) col[i] = row[i] = (1 << N) - 1;
      for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++) f[i][j] = (1 << N) - 1;
    }
    
    int get(int x, int y) { return row[x] & col[y] & f[x / 3][y / 3]; }
    
    inline void update(int x, int y, int v, bool type) {
      if (type) {
        s[x * N + y] = '1' + v;
      } else s[x * N + y] = '.';
    
      int t = 1 << v;
      if (!type) t = -t;
    
      row[x] -= t;
      col[y] -= t;
      f[x / 3][y / 3] -= t;
    }
    
    bool dfs(int cnt) {
      if (!cnt) return 1;
      int x, y, maxv = 12;
      for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) {
          if (s[i * N + j] != '.') continue;
          int n = Count[get(i, j)];
          if (n < maxv) maxv = n, x = i, y = j;
        }
    
      for (int i = get(x, y); i; i -= lowbit(i)) {
        int c = loc[lowbit(i)];
        update(x, y, c, 1);
        if (dfs(cnt - 1)) return 1;
        update(x, y, c, 0);
      }
    
      return 0;
    }
    
    int main() {
      for (int i = 0; i < N; i++) loc[1 << i] = i;
      for (int i = 0; i < 1 << N; i++)
        for (int j = i; j; j -= lowbit(j)) Count[i]++;
    
      while(cin >> s && s[0] != 'e') {
        init();
        int cnt = 0;
        for (int i = 0; i < N; i++)
          for (int j = 0; j < N; j++) if (s[i * N + j] != '.') {
            update(i, j, s[i * N + j] - '1', 1);
          } else cnt++;
        dfs(cnt);
    
        puts(s);
      }
    
      return 0;
    }
    

    木棒

    题目链接

    这里称短的为木棒,组成后长的为木棍

    搜索顺序

    首先,搜索的木棍的长度一定是所有木棒长度的因数,那么我们可以枚举木棒总长度的因数作为木棍的长度去搜索。问题就转化为了让不同的木棒组称几个长度相同的组

    我们可以依次枚举所有的木棒,如果当前的木棒能放入当前木棍的组中,那就放入组中,继续搜索下一个木棒;否则就放到下一个组中,从头去搜索没有放入的木棍。

    搜索顺序

    1. 优化搜索顺序:

    搜索时按照木棒长度从大到小开始搜索,那么之后木棒的可用的空间就就少了,对应的搜索的状态也随之减少

    1. 排除等效冗余:

    按照木棒的下标从小到大进行搜索,这样就能避免等效冗余(组合的方式枚举)

    1. 可行性剪枝:

    2. 设枚举木棒的长度为\(len\),木棒的组数为\(n\),如果有\(n\times len>sum\),那么该状态一定不合法

    3. 如果某个长度的木棒无法放入,那么后面相同长度的木棍也肯定无法放入

    4. 如果第一个木棒的长度大于木棍的长度,那么该方案一定不合法

    5. 如果最后一个木棒\(x\)摆放失败了,那么该方案也一定不合法(反证法:如果存在其他组能让\(x\)摆放成功的话,那么调换木棒的摆放顺序,让\(x\)成为最后摆放的木棍,那么就与先前的结论矛盾了)

    #include <iostream>
    #include <algorithm>
    using namespace std;
    constexpr int N = 210;
    
    int n, a[N], sum, len;
    bool st[N];
    
    bool dfs(int u, int start, int size) {
        if (len * u == sum) return 1;
        if (size == len) return dfs(u + 1, 0, 0);
    
        for (int i = start; i < n; i++) {
            // 可行性剪枝
            if (st[i] || a[i] + size > len) continue;
            st[i] = 1;
            if (dfs(u, start + 1, size + a[i])) return 1;
            st[i] = 0;
            if (!size || size + a[i] == len) return 0;
            int j = i;
            while (j < n && a[i] == a[j]) j++;
            i = j - 1;
        }
        return 0;
    }
    
    int main() {
        while (scanf("%d", &n) != EOF && n) {
            // init
            fill(st, st + n + 1, 0);
            sum = 0, len = 0;
            for (int i = 0; i < n; i++) cin >> a[i], sum += a[i];
            // 优化搜索顺序
            sort(a, a + n, greater<int>());
    
            while (++len <= sum) {
                if (sum % len == 0 && dfs(1, 0, 0)) {
                    printf("%d\n", len);
                    break;
                }
            }
        }
    
        return 0;
    }
    

    生日蛋糕

    原题链接

    题目分析

    蛋糕的搜索顺序有两种:自上而下,自底向上。由于给定的体积是固定的,而且体积是自底向上减少的,所以我们应该选择第二个搜索顺序,减少枚举的状态,从而实现对搜索顺序的优化

    我们首先需要对蛋糕进行一下分析,看看根据题目中的信息能获取哪些信息:

    1. 根据题目中的信息,我们可以写出蛋糕的面积和体积的公式

    \(n=\sum^m_{i=1}{R_i^2H_i}\)\(Ans=\sum_{i=1}^m2R_iH_i+R_m^2\)

    1. 对于第\(u\)层蛋糕,我们可以确定半径和高的取值范围:\(u\le R_u< R_{u+1}-1\)\(u\le H_u< H_{u+1}-1\)。同理,我们也可以推测出\(R\)\(H\)都去最小值时前\(u\)层的体积和表面积

    2. 对于第\(u\)层,若上一层的体积是\(v\),那么有\(n-v\ge \sum R_i^2H_i\ge R_i^2\),可以得到\(R_i\le \sqrt{n-v}\)\(H_i\le \frac{n-v}{R_i^2}\)。所以每一层的面积的取值有\(u\le R_u< \min({R_{u+1}-1, \sqrt{n-v}})\),体积的取值有\(u\le H_u< \min(H_{u+1}-1,\frac{n-v}{R_i^2})\)

    3. 寻找体积和面积的关系:

    4. 首先对面积进行缩放:

    \[ Ans = R^2_m+2\sum_{i=1}^uR_iH_i\\ =R_m^2+\frac{2}{R_{u+1}}R_iR_uH_i\\ \because \frac{2}{R_{u+1}}R_iR_uH_i<\frac{2}{R_{u+1}}R_i^2H_i =\frac{2}{R_{u+1}}n-v\\ \therefore Ans<\frac{2(n-v)}{R_{u+1}} \]

    所以我们可得到剪枝的方案:

    1. 优化搜索顺序:自底向上进行搜索

    2. 可行性剪枝:

    3. 如果当前体积(面积)加上前\(u\)层最小体积(面积)大于\(n\)(\(Ans\)),那么该方案不可行

    4. 每一层的面积的取值有\(u\le R_u< \min({R_{u+1}-1, \sqrt{n-v}})\),体积的取值有\(u\le H_u< \min(H_{u+1}-1,\frac{n-v}{R_i^2})\)

    5. Ans要满足\(Ans<\frac{2(n-v)}{R_{u+1}}\)

    #include <bits/stdc++.h>
    #define io ios::sync_with_stdio(false); cin.tie(0); cout.tie(0)
    using namespace std;
    
    constexpr int N = 22, INF = 0x3f3f3f3f;
    
    int n, m, ans = INF;
    int mins[N], minv[N], R[N], H[N];
    
    void dfs(int u, int s, int v) {
      if (v + minv[u] > n) return;
      if (s + mins[u] >= ans) return;
      if (s + 2 * (n - v) / R[u + 1] >= ans) return;
      if (!u) {
        if (v == n) ans = s;
        return;
      }
    
      for (int r = min(R[u + 1] - 1, (int)sqrt(n - v)); >= u; r--)
        for (int h = min(H[u + 1] - 1, (n - v) / r / r); h >= u; h--) {
          int t = 0;
          if (u == m) t = r * r;
          R[u] = r, H[u] = h;
          dfs(u - 1, s + t + 2 * r * h, v + r * r * h);
        }
    }
    
    int main() {
      io;
      cin >> n >> m;
      for (int i = 1; i <= m; i++) {
        mins[i] = mins[i - 1] + 2 * i * i;
        minv[i] = minv[i - 1] + i * i * i;
      }
    
      R[m + 1] = H[m + 1] = INF;
    
      dfs(m, 0, 0);
      
      if (ans == INF) ans = 0;
      cout << ans << '\n';
    
      return 0;
    }
    

    可见本题的剪枝策略主要来自对于公式的推导

  • 相关阅读:
    AFNetworking使用总结
    使用Attiny 85开发板制作BadUSB
    C# 按指定数量从前面或者后面删除字符串
    C# 获取打印机列表
    【解决】该任务映像已损坏或已篡改。(异常来自HRESULT:0x80041321)
    PowerShell 解锁使用浏览器下载的文件
    C#使用HttpHelper万能框架,重启路由器
    【解决】应用程序无法正常启动(0xc000007b)。请单击“确定”关闭应用程序。
    Windows 7 IE11 F12 不能正常使用
    HTML5 图片上传预览
  • 原文地址:https://www.cnblogs.com/FrankOu/p/15642483.html
Copyright © 2020-2023  润新知