Unverified Commit a0551764 authored by Angel_Kitty's avatar Angel_Kitty Committed by GitHub
Browse files

Merge pull request #1866 from ouuan/search

chore(search): update index.md & move contents
parents e0ac51b6 445073a8
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -123,6 +123,7 @@ int rev_g(int g) {
## 习题

-    [CSP S2 2019 D1T1](https://www.luogu.org/problem/P5657) Difficulty: easy

-    [SGU #249 Matrix](http://codeforces.com/problemsets/acmsguru/problem/99999/249) Difficulty: medium

-   2019 CSP-S D1T1
+80 −1
Original line number Diff line number Diff line
author: FFjet, ChungZH, frank-xjh, hsfzLZH1, Xarfa, AndrewWayne

### 双向同时搜索

从状态图上起点和终点同时开始进行宽度/深度优先搜索,如果发现相遇了,那么可以认为是获得了可行解。

双向广搜的步骤
双向广搜的步骤

```text
开始结点 和 目标结点 入队列 q
@@ -21,3 +25,78 @@ while(队列q不为空)
    那么 将这个s个结点标记为2 并且入队q
}
```

### 折半搜索

也称 meet in the middle,主要思想是将整个搜索过程分成两半,分别搜索,最后将两半的结果合并。由于搜索的复杂度往往是指数级的,而折半搜索可以使指数减半,也就能使复杂度开方。

???+note "例题 [「USACO09NOV」灯 Lights](https://www.luogu.org/problemnew/show/P2962)"

    有 $n$ 盏灯,每盏灯与若干盏灯相连,每盏灯上都有一个开关,如果按下一盏灯上的开关,这盏灯以及与之相连的所有灯的开关状态都会改变。一开始所有灯都是关着的,你需要将所有灯打开,求最小的按开关次数。

    $1\le n\le 35$。

如果这道题暴力 DFS 找开关灯的状态,时间复杂度就是 $O(2^{n})$ , 显然超时。不过,如果我们用 **meet-in-middle** 的话,时间复杂度可以优化至 $O(n2^{n/2})$ 。 **meet-in-middle** 就是让我们先找一半的状态,也就是找出只使用编号为 $1$ 到 $\mathrm{mid}$ 的开关能够到达的状态,再找出只使用另一半开关能到达的状态。如果前半段和后半段开启的灯互补,将这两段合并起来就得到了一种将所有灯打开的方案。具体实现时,可以把前半段的状态以及达到每种状态的最少按开关次数存储在 `map` 里面,搜索后半段时,每搜出一种方案,就把它与互补的第一段方案合并来更新答案。

??? note "参考代码"
    ```cpp
    #include <algorithm>
    #include <cstdio>
    #include <iostream>
    #include <map>
    
    using namespace std;
    
    typedef long long ll;
    
    int n, m, ans = 0x7fffffff;
    map<ll, int> f;
    ll a[40];
    
    int main() {
      cin >> n >> m;
    
      for (int i = 0; i < n; ++i) a[i] = (1ll << i);
    
      for (int i = 1; i <= m; ++i) {
        int u, v;
        cin >> u >> v;
        --u;
        --v;
        a[u] |= (1ll << v);
        a[v] |= (1ll << u);
      }
    
      for (int i = 0; i < (1 << (n / 2)); ++i) {
        ll t = 0;
        int cnt = 0;
        for (int j = 0; j < n / 2; ++j) {
          if ((i >> j) & 1) {
            t ^= a[j];
            ++cnt;
          }
        }
        if (!f.count(t))
          f[t] = cnt;
        else
          f[t] = min(f[t], cnt);
      }
    
      for (int i = 0; i < (1 << (n - n / 2)); ++i) {
        ll t = 0;
        int cnt = 0;
        for (int j = 0; j < (n - n / 2); ++j) {
          if ((i >> j) & 1) {
            t ^= a[n / 2 + j];
            ++cnt;
          }
        }
        if (f.count(((1ll << n) - 1) ^ t))
          ans = min(ans, cnt + f[((1ll << n) - 1) ^ t]);
      }
    
      cout << ans;
    
      return 0;
    }
    ```
+3 −49
Original line number Diff line number Diff line
搜索的思想按照在状态空间中尝试的顺序分为多种。搜索看起来简单,但是往往是很多复杂的题目中必不可少的模块
搜索,也就是对状态空间进行枚举。通过穷尽所有的可能,来找到最优解,或者统计合法解的个数

## 深度优先搜索 (DFS)
搜索有很多优化方式,如减小状态空间,更改搜索顺序,剪枝等。

主条目: [DFS(搜索)](./dfs.md) 

## 宽度优先搜索 (BFS)

主条目: [BFS(搜索)](./bfs.md) 

### 双向宽度优先搜索

主条目: [双向广搜](./bidirectional.md) 

从状态图上起点和终点同时开始进行宽度优先搜索,如果发现相遇了,那么可以认为是获得了可行解。

## A\*搜索

主条目: [A\*](./astar.md) 

## IDA\*搜索

主条目: [IDA\*](./idastar.md) 

## 剪枝

搜索往往是在庞大的解空间中尝试获得最优解,这时候剪枝就显得十分必要了。剪枝顾名思义,是运用已有的信息,尽早地确定一种方案是否可行,如果已经知道无法获得最优解就及时退回。这样的操作对于搜索树来说,就相当于是在搜索树上剪掉一些枝杈。

剪枝思路有很多种,大多需要对于具体问题来分析,在此简要介绍几种常见的剪枝思路。

### 极端法

考虑极端情况,如果最极端(最理想)的情况都无法满足,那么肯定实际情况搜出来的结果不会更优了。

### 调整法

通过对子树的比较剪掉重复子树和明显不是最有“前途”的子树。

### 数学方法

比如在图论中借助连通分量,数论中借助模方程的分析,借助不等式的放缩来估计下界等等。

## meet-in-middle

也称折半搜索,主要思想是分治,通过将枚举量减少到原来的一半和特殊的合并技巧以使情况数减少到原来的 sqrt,复杂度也就开了个方,折半搜索也是一个很好的优化,往往能在 OI 竞赛中获得出人意料的效果(尤其在面对数据水的时候)

所谓 **meet-in-middle** , 就是让 DFS 的状态在中间的时候碰面。我们知道,如果一个暴力 dfs 有 $K$ 个转移,那么它的时间复杂度(大多数情况)是 $O(K^N)$ 的。那我们就想,当 $N$ 到达一定程度时,TLE 会变成必然。

例题 [「USACO09NOV」灯 Lights](https://www.luogu.org/problemnew/show/P2962) 

我们正常想,如果这道题暴力 DFS 找开关灯的状态,时间复杂度就是 $O(2^{n})$ , 显然超时。不过,如果我们用 **meet-in-middle** 的话,时间复杂度将会变为 $O(2^{n/2} \times 2)$ 而已。 **meet-in-middle** 就是让我们先找一半的状态,也就是 $1$ 到 $\mathrm{mid}$ 的状态,再找剩下的状态就可以了。我们把前半段的状态全部存储在 `map` 里面,然后在找后半段的状态的时候,先判断后半段是不是都合法,就可以判断上半段有没有配对的上半段使得整段合法。
搜索是一些高级算法的基础,在 OI 中,纯粹的搜索往往也是得到部分分的手段,但可以通过纯粹的搜索拿到满分的题目非常少。

## 经典题目

+55 −48
Original line number Diff line number Diff line
@@ -23,7 +23,7 @@ void dfs(传入数值) {

其中的 ans 可以是解的记录,那么从当前解与已有解中选最优就变成了输出解。

## 优化与剪枝
## 剪枝方法

最常用的剪枝有三种,记忆化搜索、最优性剪枝、可行性剪枝。

@@ -92,7 +92,17 @@ void dfs(传入数值) {
}
```

#### 经典例题
## 剪枝思路

剪枝思路有很多种,大多需要对于具体问题来分析,在此简要介绍几种常见的剪枝思路。

-   极端法:考虑极端情况,如果最极端(最理想)的情况都无法满足,那么肯定实际情况搜出来的结果不会更优了。

-   调整法:通过对子树的比较剪掉重复子树和明显不是最有“前途”的子树。

-   数学方法:比如在图论中借助连通分量,数论中借助模方程的分析,借助不等式的放缩来估计下界等等。

## 例题

???+note "工作分配问题"
     **题目描述** 
@@ -130,17 +140,14 @@ void dfs(传入数值) {
    5
    ```

 **分析** 

由于每个人都必须分配到工作,在这里可以建一个二维数组 `time[i][j]` ,用以表示 $i$ 个人完成 $j$ 号工作所花费的时间。给定一个循环,从第 1 个人开始循环分配工作,直到所有人都分配到。为第 $i$ 个人分配工作时,再循环检查每个工作是否已被分配,没有则分配给 $i$ 个人,否则检查下一个工作。可以用一个一维数组 `is_working[j]` 来表示第 $j$ 号工作是否已被分配,未分配则 `is_working[j]=0` ,否则 `is_working[j]=1` 。利用回溯思想,在工人循环结束后回到上一工人,取消此次分配的工作,而去分配下一工作直到可以分配为止。这样,一直回溯到第 1 个工人后,就能得到所有的可行解。

检查工作分配,其实就是判断取得可行解时的二维数组的第一维下标各不相同并且第二维下标各不相同。而我们是要得到完成这 $n$ 份工作的最小时间总和,即可行解中时间总和最小的一个,故需要再定义一个全局变量 `cost_time_total_min` 表示目前找到的解中最小的时间总和,初始 `cost_time_total_min``time[i][i]` 之和,即对角线工作时间相加之和。在所有人分配完工作时,比较 `count``cost_time_total_min` 的大小,如果 `count` 小于 `cost_time_total_min` ,说明找到了一个最优解,此时就把 `count` 赋给 `cost_time_total_min`

但考虑到算法的效率,这里还有一个剪枝优化的工作可以做。就是在每次计算局部费用变量 `count​` 的值时,如果判断 `count` 已经大于 `cost_time_total_min` ,就没必要再往下分配了,因为这时得到的解必然不是最优解。

#### 经典例题代码

```c++
??? note "参考代码"
    ```C++
    #include <cstdio>
    #define N 16
    int is_working[N] = {0};  // 某项工作是否被分配