Loading

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;
}

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

posted @ 2021-12-04 16:42  Frank_Ou  阅读(133)  评论(0编辑  收藏  举报