深度优先搜索

深度优先搜索

一.什么是深度优先搜索

深度优先搜索,Depth First Search, 简称DFS。它从初始节点出发,按预定的顺序扩展到下一个节点,然后从下一节点出发继续扩展新的节点,不断递归执行这个过程,直到某个节点不能再扩展下一个节点为止。此时,则返回上一个节点重新寻找一个新的扩展节点。如此搜索下去,直到找到目标节点,或者搜索完所有节点为止。

如果从位置开始深度优先搜索,则搜索的顺序为:

1 → 2 → 3 → 5 → 9 → 10 → 6 → 11 → 12 → 7 → 4 → 8 1 \to 2 \to 3 \to 5 \to 9 \to 10 \to 6 \to 11 \to 12 \to 7 \to 4 \to 8 123591061112748

例1 骑士遍历问题

问题描述

设有一个 n ∗ n n*n nn​的棋盘 ( n < = 10 ) (n<=10) (n<=10)​,在棋盘上的任一点 A ( x , y ) A(x,y) A(x,y)​​有一个中国象棋的马,按马走日字的规则,试找出一条路径,使马不重复地走遍棋盘上的每一个点.
输入

第一行 n n n​​,第二行两个数,表示马的起始位置。

输出

n ∗ n n*n nn​​的矩阵

样例输入

 5
 1  1

样例输出

1 14 19 8 25
6 9 2 13 18
15 20 7 24 3
10 5 22 17 12
21 16 11 4 23

分析

将起始位置 ( x , y ) (x,y) (x,y)​​​​作为根结点,从根结点出发一步能到达的所有位置即为其子结点,依此类推,直到找到一组解或搜索完整棵搜索树为止。
本题的搜索树为一棵八叉树。

const int MAXN = 21;
const int d[8][2] = { {1, 2},{2, 1}, {1, -2}, {2, -1},{-1, 2},{-2, 1}, {-1, -2}, {-2,-1} };
struct pos {int x, y;};
pos s[MAXN*MAXN];      //保存当前搜索路径
int a[MAXN][MAXN];      //保存骑士每一步的走法
int n;   
void dfs(int i){
    pos cp, np;
    int j;
    if (i == n * n + 1)         //边界
    {
        print();
        exit(0);
    }
    cp = s[i-1];                //取出上一阶段的骑士位置cp
    for(j = 0; j < 8; j++)
    {
        np.x = cp.x + d[j][0];  //计算骑士下一步的X坐标
        np.y = cp.y + d[j][1];  //计算骑士下一步的Y坐标
        if( inarea(np)&& a[np.x][np.y] == 0)
        {
            s[i] = np;
            a[np.x][np.y] = i;
            dfs(i+1);
            a[np.x][np.y] = 0;   //恢复现场
        }
    }
}

程序中用二维数组 a a a表示棋盘, 其元素记录马经过该位置的步骤号, 初始时除起点外所有位置的值为 0 0 0​​​​​​​,​​​表示马未到达过该点。
为了方便表示马的八种走法,利用数组d记录其下一步的坐标的变化量。

小结:dfs的要点
  1. 结点定义:
    即如何描述问题的状态以及状态之间的关系.在骑士遍历问题中,结点用棋盘数组 a a a表示,状态之间的关系用方向数组d表示.

  2. 边界条件
    即在什么情况下程序不再递归下去.在骑士遍历问题中,边界条件为 i = n ∗ n + 1 i = n*n + 1 i=nn+1

  3. 搜索范围:
    设定 f o r for for语句中循环变量的初值和终值;在骑士遍历问题中,马有8种跳法,对应循环从1到8.

  4. 约束条件:

    状态扩展出一个子结点后,子结点应满足什么条件方可继续递归下去.在骑士遍历问题中,要求新生成的状态的 x , y x,y x,y坐标在 [ 1 , n ] [1,n] [1,n]之间, 并且 a [ x , y ] a[x,y] a[x,y]未走到过.

  5. 恢复递归前状态:
    优先搜索过程中的变量,如果在递归调用前改变了它的值, 而在下一次递归调用时又要用到它原来的值,那么就需要在每一次调用返回时,恢复成它的原值.在骑士遍历问题中,用 a [ x , y ] = 0 a[x,y] = 0 a[x,y]=0​ 来恢复现场.

二、深度优先算法的优化

在骑士遍历问题中,棋盘大小n稍微大一点,搜索时间就急剧上升,因为其时间复杂性是O(8n2),因此需要对算法进行优化(n=8时就快要算不出来了)。
深度优先搜索算法优化的三种方法:

  • **缩小搜索范围 **

  • 改变搜索次序

  • 剪枝

1.缩小搜索范围

缩小搜索范围一般可从二个方面考虑优化:

(1). 在递归前对尚待搜索的信息进行预处理

(2). 增加约束条件

例2 因式分解
题目描述

将大于1的自然数N进行因式分解,满足 N = a 1 ∗ a 2 ∗ . . . . . . ∗ a m N=a1*a2*......*am N=a1a2......am 编一个程序,对任意的自然数N,求N的所有形式不同的因式分解方案总数。例如,N=12,共有8种分解方案,分别是:

12=12 12=6*2 12=4*3 12=3*4 12=3*2*2 12=2*6 12=2*3*2 12=2*2*3
输入

第1行:1个正整数N(N<=10^9)

输出

第1行:一个整数,表示N的因式分解方案总数

样例输入

12

样例输出

8

分析:

f ( x ) f(x) f(x)​​表示 x x x​​的分解方案数,则KaTeX parse error: Undefined control sequence: \and at position 18: …x)=\sum_{(j|i) \̲a̲n̲d̲ ̲(j<i)}f(j)​​​.

问题的关键在于如何枚举因子 j j j.

如果在[1,N-1]范围内枚举 j j j​​,显然太慢。因为 n n n​​的所有的因子基本上是成对出现的,一半分布在 [ 1 , n ] [1,\sqrt{n}] [1,n ]​​​,一般分布在 [ n , n ] [\sqrt{n},n] [n ,n]

所以可以枚举前 n \sqrt{n} n

void dfs(int n){
  for(int i = 2; i * i <= n; i++){
     if(n % i == 0) 
       if(i != n / i) f[n] += dfs(i) + dfs(n / i);
    	 else f[n] += dfs(i);
  }
  f[n] += 1;
}

但这样仍然会超时,究其原因,许多状态被反复计算,而实际上计算一次就可以了。

所以,我们可以使用记忆化搜索。将上述代码修改一下:

void dfs(int n){
  if(f[n] > 0) return f[n];
  for(int i = 2; i * i <= n; i++){
     if(n % i == 0) 
       if(i != n / i) f[n] += dfs(i) + dfs(n / i);
    	 else f[n] += dfs(i);
  }
  f[n] += 1;
  return f[n];
}

使用记忆化搜索以后,效率提升了很多。然而,如果 x = 2 ∗ 1 0 9 x = 2*10^9 x=2109,则数组无法开那么大。怎么办?

可以使用折中的办法,定义数组的大小为 2 ∗ 1 0 7 + 5 2*10^7+5 2107+5​​,如果 x < 2 ∗ 1 0 7 x<2*10^7 x<2107,采用记忆化搜索;否则,直接搜索,因为此时 x x x很大,它被计算的次数不会很多。

当然也有其他的办法,比如不直接用使用因子作为下标,可以先处理出所有的因子,存放在一个数组中,然后用该因子的位置来作为 f f f数组的下标。比如因子 x x x,它在因子表中的下标为 i d id id,则可以用 f [ i d ] f[id] f[id]表示 x x x​的分解方案数,这样数组就开得下了。

例3 虫食算
题目描述

所谓虫食算,就是原先的算式中有一部分被虫子啃掉了,需要我们根据剩下的数字来判定被啃掉的字母。来看一个简单的例子:

     43#98650#45
+      8468#6633
---------------------------------    
     44445506978

其中#号代表被虫子啃掉的数字。
根据算式,我们很容易判断:第一行的两个数字分别是5和3,第二行的数字是5。
现在,我们对问题做两个限制:
首先,我们只考虑加法的虫食算。这里的加法是N进制加法,算式中三个数都有N位,允许有前导的0。
其次,虫子把所有的数都啃光了,我们只知道哪些数字是相同的,我们将相同的数字用相同的字母表示,不同的数字用不同的字母表示。
如果这个算式是N进制的,我们就取英文字母表午的前N个大写字母来表示这个算式中的0到N-1这N个不同的数字:但是这N个字母并不一定顺序地代表0到N-1)。
输入数据保证N个字母分别至少出现一次。

   BADC
 + CBDA
--------------------------
   DCCC

上面的算式是一个4进制的算式。很显然,我们只要让ABCD分别代表0123,便可以让这个式子成立了。
你的任务是,对于给定的N进制加法算式,求出N个不同的字母分别代表的数字,使得该加法算式成立。输入数据保证有且仅有一组解。

分析:

如果枚举n个字母的所有可能的值,再来检测算式是否合法,因为n<=26, 情况数为 n ! n! n!,肯定超时。
我们可以一边枚举字母的值,一边来检测是否合法。
按照什么顺序来枚举呢?
显然,按照加式中从右到左字母出现的顺序来枚举,这样枚举了部分字母后,就可以检测它们是否合法了。这个检测只是部分检测,是比较宽松的。但即使这个比较宽松的检测,也能排除掉许多不合法的情况,优化搜索效率,达到快速出解的效果。

#include <bits/stdc++.h>
using namespace std;
#define MAXN 30
char as[MAXN], bs[MAXN],cs[MAXN], ss[MAXN];
int tot, ans[MAXN], jinwei[MAXN];
bool haveans, vis[MAXN], used[MAXN]; 
int n;
bool check(){
    memset(jinwei, -1 ,sizeof jinwei);
    jinwei[n] = 0;
    for(int i = n - 1; i >= 0; i--){
        int a = ans[as[i] - 'A'], b = ans[bs[i] - 'A'], c = ans[cs[i] - 'A']; //第i位的三个值
        if(a != -1 && b != -1 && c != -1){ //如果第i为的三个值都确定
            if(jinwei[i + 1] != -1){ //如果进位也确定
                if((a + b + jinwei[i + 1]) %n != c){ //不相等,则不合法,返回0
                    return 0;
                }
                else jinwei[i] = (a + b + jinwei[i + 1] >= n);
            }
            else { //如果进位不确定,只有两种可能:进位为0,进位为1.
                if((a + b) % n != c && (a + b + 1) % n != c)return 0; //两种进位都不能相等,则不合法。
                else if(a + b == n - 1) jinwei[i] = -1;
                else jinwei[i] = ((a + b) > n - 1);
            }
        }
    }
    return 1; //其他情况都暂且认为合法,返回1.
}
void dfs(int k){
    if(k == n){ // 找到解,将haveans标记为1,并输出该解。
        haveans = 1; 
        for(int i = 0; i < n; i++) 
        printf("%d ",ans[i]);
        printf("\n");
        return;
    }
    for(int i = 0; i < n; i++){
        if(used[i] == 0){
            ans[ss[k] - 'A'] = i;
            used[i] = 1;
            if(check() == 1) //检测当前合法,再进行下一步搜索。这就是增加约束条件
            dfs(k + 1);
            if(haveans == 1) break;
            used[i] = 0;
            ans[ss[k] - 'A'] = -1;
        }
    }
}
int main(){
    memset(ans, -1, sizeof ans);
    scanf("%d %s %s %s", &n, as, bs, cs);
    for(int i = n - 1; i >= 0; i--){ //按照加式中从右到左首次出现的顺序来把字母存放到ss数组中。
        if(vis[as[i] - 'A'] == 0){
            ss[tot++] = as[i];
            vis[as[i] - 'A'] = 1;
        }
        if(vis[bs[i] - 'A'] == 0){
            ss[tot++] = bs[i];
            vis[bs[i] - 'A'] = 1;
        }
        if(vis[cs[i] - 'A'] == 0){
            ss[tot++] = cs[i];
            vis[cs[i] - 'A'] = 1;
        }
    }
    dfs(0);
}

2. 改变搜索次序

搜索次序的改变是指枚举当前层变量的值时,有时按从小到大枚举,有时按从大到小枚举,而有时按一个启发函数值的优劣进行启发式搜索。
这样做为什么会提高速度呢?因为在完整的搜索树上,解结点的位置会随问题有所不同,有时可能会偏左一些,有时会偏右一些。搜索变量不同的扩展顺序,实际上是从不同的方向上去遍历树,显然有的顺序遇到解结点的时间会少一些。

3.剪枝

搜索的过程可以看作是从树根出发,遍历一棵倒置的树——搜索树的过程。所谓剪枝,就是通过某种判断,避免一些不必要的遍历过程。形象地说,就是剪去了搜索树中的某些“枝条”,故称剪枝。

说明:图中,在叶子节点已经找到了一个值为30的最短路径,这时,在搜索到G(50)、H(35)、J(30)时,其路径长度已经大于或等于了当前最优值,因此搜索下去毫无含义,其下的节点都可以剪除。

例4. 生日蛋糕
题目描述

7月17日是Mr.W的生日,ACM-THU为此要制作一个体积为 N π N\pi Nπ M M M层生日蛋糕,每层都是一个圆柱体。 设从下往上数第 i ( 1 ≤ i ≤ M ) i(1 \leq i\leq M) i(1iM)层蛋糕是半径为Ri, 高度为Hi的圆柱。当 i < M i<M iM时,要求 R i > R i + 1 Ri>R_{i+1} RiRi+1 H i > H i + 1 Hi>H_{i+1} HiHi+1。 由于要在蛋糕上抹奶油,为尽可能节约经费,我们希望蛋糕外表面(最下一层的下底面除外)的面积 Q Q Q最小。 令 Q = S ∗ π Q = S*\pi Q=Sπ,请编程对给出的N和M,找出蛋糕的制作方案(适当的Ri和Hi的值),使 S S S最小。(除 Q Q Q外,以上所有数据皆为正整数).

输入格式

有两行,第一行为N(N<=10000),表示待制作的蛋糕的体积为Nπ;
第二行为M(M<=20),表示蛋糕的层数为M。

输出格式

仅一行,是一个正整数S(若无解则S=0)。

分析

本题是个搜索好题,可以不断的挖掘题目的性质,用到各种剪枝技巧,使得搜索效率不断提升。

首先确定搜索的状态 (int level, int s, int v, int r, int h)
搜索时从下往上,依次确定每一层的半径r 和高度h。level表示当前是第几层。注意,最底层为第m层,最上层为第1层。s表示当前已经得到的面积,其中上表面已经提前在最底层统计好,侧面积每确定一层则计算一层,v表示还剩多少体积未用。

每一层的蛋糕如何确定半径 r i r_i ri呢?
根据题意: r i < r i + 1 r_i \lt r_{i+1} ri<ri+1
这个上界还不够好,我们可以让上界更小一点。
当前还剩体积v,现在在第level层,那当前的的高度至少是level,如果把所有的体积都拿来作为当前这一层,r最大为 v / l e v e l \sqrt{v/level} v/level .
所以,可以确定r的上界为:
r i ≤ m i n ( r i + 1 − 1 , v / l e v e l r_i \leq min(r_{i+1} - 1, \sqrt{v/level} rimin(ri+11,v/level

r i r_i ri的下界为 l e v e l level level

再看 h i h_i hi的上界,类似的分析,可知 h i ≤ m i n ( h i + 1 − 1 , v / ( l e v e l 2 ) ) h_i \leq min(h_{i+1} - 1, v /(level^2)) himin(hi+11,v/(level2))

h i h_i hi的下界也为level.
再看最优化剪枝:
如果当前得到的面积为s,之前得到的最小面积为smin,当前层的半径为r,现在还剩余的蛋糕为v,则这些剩余的蛋糕未来能产生的面积最小为多少呢?答案是 2 ∗ v / r 2*v/r 2v/r.
那如果$s+2*v/r > smin $,则意味未来不管怎样,都没有办法得到更小的面积,则可以进行剪枝了。
为什么体积为v的蛋糕在当前层半径为r,未来能增加的最小面积是 2 ∗ v / r 2*v/r 2v/r呢?
我们首先看:
如果蛋糕只做一层蛋糕,体积为v,侧面积s和半径r有什么关系?
因为单位为 π \pi π,所以下面式子中都没有包含 π \pi π.
设半径为r,高度为h,可知 v = r 2 ∗ h v = r^2 * h v=r2h,侧面积 s = 2 ∗ r ∗ h s = 2*r*h s=2rh.
所以, s = 2 ∗ v / r s = 2* v /r s=2v/r
则在体积 v v v固定的情况下,半径 r r r越大,侧面积 s s s越小。
而显然,如果做成多层蛋糕,则侧面积会更大。因为上层的蛋糕半径会变小,可以看做有部分体积产生了更大的侧面积。
于是可知,如果蛋糕体积为v,最大半径为r,则最小的侧面积为 2 ∗ v / r 2*v/r 2v/r

再来看可行性剪枝。
先预处理出来最小的 i ( 1 ≤ i ≤ m ) i (1 \leq i \leq m) i(1im)层蛋糕的体积minv和侧面积mins,用于后续剪枝。
如果当前剩余蛋糕的体积为v,未来还要做若干层,而那若干层的最小体积都会超过v,则说明当前的蛋糕体积已经不可行,可以剪枝.

最优化剪枝二:

如果当前的侧面积为s,未来还要做若干层,那若干层的最小侧面积为mins,而之前已经得到的面积的最优解为ans,如果 s + m i n s > a n s s+mins > ans s+mins>ans,则当前也可以剪枝了。

#include <bits/stdc++.h>
using namespace std;
#define MAXN 10005
#define MAXM 21
int r[MAXM], h[MAXM];
int n, m, smin;
int sumv[MAXM], sums[MAXM];
void pre() {
    for (int i = 1; i <= m; i++) sumv[i] = sumv[i - 1] + i * i * i, sums[i] = sums[i - 1] + 2 * i * i;
}
void dfs(int level, int s, int v, int r, int h) {
    if (level == 0) {
        if (v == 0 && s < smin)
            smin = s;
        return;
    }
    if (s + 2 * v / r > smin)
        return;
    for (int ri = level; ri <= min(r, (int)sqrt(1.0 * v / level)); ri++) {
        for (int hi = level; hi <= min(h, v / (level * level)); hi++) {
            if (s + 2 * ri * hi + sums[level - 1] > smin || v - ri * ri * hi < sumv[level - 1])
                continue;
            int tmp = 0;
            for (int i = level, j = 0; i > 0; i--, j++) {
                tmp += (ri - j) * (ri - j) * (hi - j);
            }
            if (v > tmp)
                continue;
            if (level == m)
                s += ri * ri;

            dfs(level - 1, s + 2 * ri * hi, v - ri * ri * hi, ri - 1, hi - 1);
            if (level == m)
                s -= ri * ri;
        }
    }
}
int main() {
    scanf("%d%d", &n, &m);
    smin = 999999999;
    pre();
    dfs(m, 0, n, (int)sqrt(1.0 * n / m), n / (m * m));
    printf("%d\n", smin);
    return 0;
}
剪枝的原则:
  • 原则一:正确性
    剪枝方法之所以能够优化程序的执行效率,是因为它能够“剪去”搜索树中的一些“无用枝条”。然而,如果在剪枝的时候,将“长有”我们所需要的解的枝条也剪掉了,那么,再好的剪枝都将失去意义。所以,在进行剪枝时,必须保证不剪掉包含解或最优解的枝条,这是剪枝优化的前提。
    为了满足这个原则,我们就应当利用“必要条件”来进行剪枝判断。也就是说,通过解所必须具备的特征、必须满足的条件等方面来考察待判断的枝条能否被剪枝。这样,就可以保证所剪掉的枝条一定不是正解所在的枝条。当然,由必要条件的定义,我们知道,没有被剪枝不意味着一定可以得到正解,否则,也就不必搜索了。

  • 原则二:高效性
    一般说来,设计好剪枝判断方法之后,我们对搜索树的每个枝条都要执行一次判断操作。然而,由于是利用有解的“必要条件”进行判断,所以,必然有很多不含正解的枝条没有被剪枝。这些情况下的剪枝判断操作,对于程序的效率的提高无疑是具有副作用的。为了尽量减少剪枝判断的副作用,我们除了要下功夫改善判断的准确性外,经常还需要提高判断操作本身的时间效率。
    然而这就带来了一个矛盾:我们为了加强优化的效果,就必须提高剪枝判断的准确性,因此,常常不得不提高判断操作的复杂度,也就同时降低了剪枝判断的时间效率;但是,如果剪枝判断的时间消耗过多,就有可能减小、甚至完全抵消提高判断准确性所能带来的优化效果,这恐怕也是得不偿失。很多情况下,能否较好的解决这个矛盾,往往成为搜索算法优化的关键。

综上所述,我们可以把剪枝优化的主要原则归结为四个字:正确、高效

剪枝的技巧

在利用最优化剪枝方面,有很多技巧:

  • 用贪心法获得一个局部最优解
  • 在搜索中使用一些贪心策略,使解尽快地朝最优解靠拢

可行性剪枝的技巧:
可行性剪枝条件一般有以下两种情况:(假设当前求最大解)
估计未来原料消耗的最大值,如果都无法用完当前的原料,显然可以剪掉此枝。这是原料用不完的情形。
估计未来原料消耗的最小值,如果都大于当前剩余的原料,显然可以剪掉此枝。这是原料不够用的情形。

这两条剪枝是算法效率的关键,切不可小视。

不同的题目一般有不同的剪枝,这有赖于做题者对题目的认真的分析,没有一般的方法与公式。

例5 邮票面值设计
题目描述

给定一个信封,最多只允许粘贴 N N N张邮票,计算在给定 K ( N + k ≤ 40 ) K(N+k \leq 40) K(N+k40)种邮票的情况下(假定所有的邮票数量都足够),如何设计邮票的面值,能得到最大 m a x max max ,使得 1 - m a x 1-max 1max之间的每一个邮资值都能得到。
例如, N = 3 , K = 2 N=3,K=2 N=3K2,如果面值分别为 1 1 1分、 4 4 4分,则在 1 1 1分- 6 6 6分之间的每一个邮资值都能得到(当然还有 8 8 8分、 9 9 9分和 12 12 12分):如果面值分别为 1 1 1分、 3 3 3分,则在 1 1 1分- 7 7 7分之间的每一个邮资值都能得到。可以验证当 N = 3 , K = 2 N=3,K=2 N=3K2时, 7 7 7分就是可以得到连续的邮资最大值,所以 M A X = 7 MAX=7 MAX=7,面值分别为 1 1 1分、 3 3 3分。

输入样例

N=3 k=2

输出样例

1 3
MAX=7

分析:

邮票面值设计这道题中 M A X MAX MAX N 、 K N、K NK难以用数学方法确定他们之间的关系,只能通过搜索来求得。所以本题的重点就放在搜索优化上,来提高搜索的效率。
搜索的过程实质上是枚举K种邮票的面值,然后计算这K种邮票对应的MAX值。设递增地搜索K种邮票的面值,面值保存在数组A中。注意思考第m张邮票的搜索范围。于是可以得到如下算法:

 void dfs(int i){ //搜索第i种邮票的面值
  if (i == K+1){   
    if (当前方案更优)  保存当前方案;
    return;
  }
   for (j=A[i-1]+1; j <= N*A[i-1]+1; j++) 
   {    //N*A[i-1]+1肯定不能由前i-1张邮票构成
      A[i] = j;
      dfs(i+1);
   }
}

然而,这个算法的效率是比较低的。因为 j j j的上界太大了。我们完全可以降低 j j j的上界。
前面的 i − 1 i-1 i1张邮票,可以做一个背包,如果它们在不超过 n n n张的前提下,能达到的连续面值的最大值为 j ′ j' j,则可知第 i i i张邮票的面值不能超过 j ′ + 1 j'+1 j+1,否则 j ’ + 1 j’+1 j+1这个值就没法达到了。

改进过后的代码如下:

#include <bits/stdc++.h>
using namespace std;
#define MAXN 1000
int n, k, ans;
int stamp_tmp[41], stamp[41];
int flg[41][MAXN];
void init(){
    memset(flg, 0x3f, sizeof flg);
    flg[0][0] = 0;
}
void dfs(int id, int lastmax){ 
    if(id > k){
        if(lastmax > ans){
            ans = lastmax;
            for(int i = 1; i < id; i++){
                stamp[i] = stamp_tmp[i];
            }
        }
        return;
    }
    lastmax++;
    for(int i = stamp_tmp[id - 1] + 1; i <= lastmax; i++){
        int tmpmax = 0;
        stamp_tmp[id] = i;
        tmpmax =lastmax;
        memcpy(flg[id], flg[id - 1],sizeof flg[id]); 
        for(int j = 0; j < MAXN; j++){
            if(j >= i && flg[id][j - i] < n) flg[id][j] = min(flg[id][j - i] + 1, flg[id][j]); 
        }
        for(int j = 0; ; j++){
            if(flg[id][j] > n) {
                tmpmax = j - 1;
                break;
            }
        }
        dfs(id + 1, tmpmax);
    }
}
int main(){
    scanf("%d %d", &n, &k);
    init();
    dfs(1, n);
    for(int i = 1; i <= k; i++)printf("%d ", stamp[i]);
    printf("\nMAX=%d\n", ans);
    return 0;
}
例6 分数分解
题目描述

近来IOI专家们正在进行一项有关整数方程的研究,研究涉及到整数方程解集的统计问题,问题是这样的: 对任意的正整数N,我们有整数方程: 1 x 1 + 1 x 2 + ⋯ + 1 x n = 1 \frac{1}{x_1}+\frac{1}{x_2}+\dots+\frac{1}{x_n}=1 x11+x21++xn1=1,该整数方程的一个解集 { x 1 , x 2 , … , x n } \{x_1,x_2,\dots,x_n\} {x1,x2,,xn}是使整数方程成立的一组正整数. 例如 { n , n , n , … , n } \{n,n,n,…,n\} {n,n,n,n}就是一个解集,在统计解集时,IOI专家把数据值相同但顺序不一样的解集认为是同一个解集,例如:当 n = 3 n=3 n=3时,我们把 { 2 , 3 , 6 } \{2,3,6\} {2,3,6} { 3 , 6 , 2 } \{3,6,2\} {3,6,2}认为是同一个解集。 现在的任务是:对于一个给定的m ,在最多只允许1个 x i x_i xi大于 m m m时,求出整数方程不同解集的个数。

输入格式

两个整数 n , m n,m n,m

输出格式

一个整数,表示方案数

分析:

dfs,逐项枚举当前项的分母,最后一项时不用再枚举,直接看剩余的值是不是能得到 1 x \frac{1}{x} x1的形式,且分母大于等于前一项的分母。

在枚举当前的项的分母时,尽量缩小搜索范围。我是直接算出来循环变量i的上界和下界。结果交上去总是超时。

而课件的代码,是把循环变量的上下界放在了下一层递归的前面,当做了剪枝。

按理来说,我的更快。然而诡异的是我的跑出来800多ms,它是100多ms。

一点点地改,改成了和它的一样,还是发现没有它快。

后来才发现,是gcd的缘故,我把它放在了递归函数的最前面,每次递归都执行gcd。应该放在边界判断后。这才是跑得慢的根源。
事实证明,在循环变量那里直接缩小范围,比在下一层递归函数值中剪枝,还是要快些的。
调了太久。

另外本题中的计算的中间变量会超过long long int。所以,要用double来进行计算,计算结果再换成整数。这个技巧很实用。

最快的代码:124ms

#include <bits/stdc++.h>
using namespace std;
#define LL long long int
#define INF 2000000000
#define min(a, b) (a < b ? (a) : (b))
#define max(a, b) (a > b ? (a) : (b))
int n, m, ans;
LL fac[40];
LL gcd(LL a, LL b){
    LL t;
    while(b){t = b, b =  a % b, a = t;}
    return a;
}
void dfs(int k, LL la, LL lb){
    if(k <= n && la == 0)return;
    if(k == n && lb % la == 0 && lb / la >= fac[k - 1]) {
        ans++;
        return;
    }
    if(k >= n)return;
    if(lb > INF ){
    LL gd = gcd(la, lb);
    la /= gd, lb /= gd;
    }
    //if(1.0 * la / lb<= 1.0 * (n - k) / m ) return;
    //if(1.0 * la / lb > 1.0 * (n - k + 1)/ fac[k - 1]) return;
    double tmp =  (1.0 * la / lb - 1.0 * (n - k - 1) / m);
    tmp = 1.0 / tmp;
    LL minz = (int) tmp;
    tmp = (1.0 * lb / la * (n - k + 1));
    LL maxz =(LL) tmp;
    for(int i = max(fac[k - 1] ,minz); i <= min(m, maxz); i++){
        fac[k] = i;
        dfs(k + 1, la * i - lb, lb * i);
    }
}
int main(){
    fac[0] = 2;
    scanf("%d %d", &n, &m);
    dfs(1, 1,  1);
    printf("%d\n", ans);
    return 0;
}

课件的代码,184ms

#include <bits/stdc++.h>
using namespace std;
const long long LIM = 2000000000;
const int MAXN = 201;
long long a[MAXN];
long long n, m, ans;
bool check(long long t1, long long t2)
{
  bool f = false;
  if (t1<t2 && t2 % (t2-t1)==0 && t2 / (t2-t1) >= a[n-1])
  {
    f = true;
    a[n] = t2 / (t2-t1);
  }
  return f;
}
void reduce(long long x, long long y)
{
  long long r, i, j;
  i = x;
  j = y;
  while (j != 0)
  {
     r = i % j;
     i = j;
     j = r;
  }
  x = x / i;
  y = y / i;
}
  
void dfs(long long i,long long t1, long long t2)
{
  long long j, k;
  bool flag;
  if (i == n)
  {
    flag = check(t1,t2);
    if (flag)
    {
        ans++;
        /*
        for(k = 1; k <= n; k++)
          cout << a[k] << ' ';
        cout << endl;
        */
    }
    return;
  }
  reduce(t1,t2);  //分子太大时,约分
  if ((n-i+1)/(double)a[i-1]+t1/(double)t2 < 1) return;  //剪枝1
  if ((n-i)/(double)m + t1/(double)t2 > 1) return;       //剪枝2
  for (j = a[i-1]; j <= m; j++)
  {
    a[i] = j;
    dfs(i+1,t2+t1*j,t2*j);
  }
}
int main()
{
  cin >>n >>m;
  a[0] = 2;
  dfs(1,0,1);
  cout << ans << endl;
  return 0;
}

如果把第一份代码的gcd放在边界条件之前,则马上变成800多毫秒。

而如果把第二份代码的gcd放在边界条件之前,则超时。

例7 斗地主
题目描述

nodgd在斗地主,他想快速出完手上的牌,他想知道他手上的牌最少多少次可以出完。
众所周知,斗地主时手上有17张牌。我们用一个长度为17的字符串来描述手上的牌,其中只包含’3’,‘4’,‘5’,‘6’,‘7’,‘8’,‘9’,‘0’,‘J’,‘Q’,‘K’,‘A’,‘2’,’’,’#‘这些字符,其中’0’表示点数为10的牌,’‘表示小王,’#‘大王,其他的相信大家都懂的。
出牌规则如下:
(1)单张。任意一张牌可以当做一个单张出牌。例如"8",“K”。
(2)对子。任意两张点数相同的牌可以当做一个对子出牌。特别的,一张小王和一张大王也可以当做一个对子出牌。例如"55",“AA”,"#"。
(3)三不带。任意三张点数相同的牌可以当做一个三不带出牌。例如"777",“QQQ”。
(4)三带一。任意三张点数相同的牌和另外一张牌可以当做一个三带一出牌。例如"3555",“2333”,“222#”,“7777”。
(5)三带对。任意三张点数相同的牌和另外两张点数相同的牌可以当做一个三带对出牌。例如"55566",“88KKK”。特别的,大王小王不能当做“另外两张”,即"444
#"是不合法的出牌方案。
(6)连单张。大于等于’3’,小于等于’A’的连续至少五个单张可以当做一个连单张出牌,即连单张中不能包含’2’,’‘或,’#’。例如"34567",“890JQKA”,“34567890JQKA”。
(7)连对子。大于等于’3’,小于等于’A’的连续至少三个对子可以当做一个连对子出牌,即连对子中同样不能包含’2’,’
‘或’#’。例如"445566",“77889900JJ”,“JJQQKKAA”。
(8)连三不带。大于等于’3’,小于等于’A’的连续至少两个三不带可以当做一个连三不带出牌,即连三不带中同样不能包含’2’,’‘或’#’。例如"555666777888",“QQQKKK”。
(9)连三带一。大于等于’3’,小于等于’A’的连续至少两个三带一可以当做一个连三带一出牌。例如"33344467",“3888999Q”,“557JJJQQQKKK”,“0JJJQQQQKKKA”,“4444555566667777”。连三带一中可以包含’2’,’
‘或’#’,但这些只能当做每个三带一中的“另外一张牌”,例如"QQQKKKAAA222"是一个合法的连三带一,但只能看做"QQQ2"+“KKK2”+“AAA2”。
注意1:没有“连三带对”的说法。
注意2:由于本题只涉及到出牌次数,不涉及玩家与玩家之间的交互,所以没有定义日常中所说的“炸弹”,因为一个“炸弹”也可以看做一个三带一。

输入格式

输入包含多组测试数据(不超过5组),
每组数据占一行,一个长度为17的字符串,描述nodgd手上的牌。字符串中只包’3’,‘4’,‘5’,‘6’,‘7’,‘8’,‘9’,‘0’,‘J’,‘Q’,‘K’,‘A’,‘2’,’*’,’#'这些字符。
保证手上的牌合法,即相同点数的牌不超过4张,小王大王分别不超过一张。

输出格式

一个整数,表示nodgd经过最少多少次可以出完手中的牌。

分析:采用dfs搜索更好写。bfs对于状态表示比较复杂的,写起来很繁琐。
首先将所有的牌重新编码,按牌的大小顺序,分别对应到0~14.
其中3对应的0,4对应的1,……,小王对应到13,大王对应到14,用一个桶记录每种牌的次数。
搜索时,枚举每一种出牌的方法,出掉的牌,则在桶中减掉。
dfs有四个参数:带出了多少对子pairs(只考虑三带对带出的),当前出到第几种牌id(桶的下标), 还剩多少张牌leave,出牌的次数step。

注意几点:
1.递归时,只从当前id开始考虑。之前的id在上层或者更上层的函数中已经考虑过了。
2.最优化剪枝:当前的step已经大于了之前求出的ans了,则可以返回。
3.对于所有的连子(连单张,连队、连三),这些连子的起点是id,终点需要去枚举,因为可能有多个终点。
4.最难的地方在于三带对和三带一,连三带一等,这些带出的牌怎么表示。如果带出的牌也要在桶中减掉,则需要去枚举带出了哪些牌,这很麻烦,状态数会增加很多,程序会跑得很慢。解决办法是不需要在桶中减掉它们。我们只记录带出的对子的数量,而带出的单牌连数量都不需要记录。因为还有一个变量leave在记录目前还剩多少张牌,带出了单牌,在leave中会相应的多减1,带出了对子,则相应的多减2. 当leave == 0时,记录一下桶中的牌还有多少对子,(这些牌就是我们之前带出的牌,但之前没有减掉它们)。如果其中的对子数大于等于我们带出的pairs,则是合法的。表示有足够的对子让我们带出。

具体看代码:

#include <bits/stdc++.h>
using namespace std;
#define MAXN 18
char s[MAXN];
int ans, pai[17];
void dfs(int pairs, int id, int leave, int step){//p表示三带对带的对数,id表示当前考虑第几种牌,leave表示剩下的牌数,step表示当前的步数
    if(id >= 15 || step > ans) return;
    int duizi = 0;
        for(int i = 0; i < 13; i++)if(pai[i] == 2 ||pai[i] == 4) duizi += pai[i] / 2;
    if(leave == 0 && pairs <= duizi) { 
        if(step < ans) ans = step; 
        return;
    }
    int tmp3 = 0;
    for(int i = id; i < 15; i++){ 
        if(i < 13 && pai[i] > 2){
            int k = i;
            while(k < 12 && pai[k] > 2 ) k++;
            if(k > i + 1) {  
                for(int x = i + 2; x <= k; x++){
                for(int j = i; j < x; j++)pai[j] -= 3;
                dfs(pairs, i, leave - (x - i) * 3, step + 1); //连三不带
                if(leave >= (x - i) * 4)dfs(pairs, i, leave - (x - i) * 4, step + 1); //连三带一
                for(int j = i; j < x; j++)pai[j] +=3;
                }
            }
            pai[i] -= 3;  
            dfs(pairs, i, leave - 3, step + 1);//三不带
            dfs(pairs, i, leave - 4, step + 1); //三带1
            dfs(pairs + 1, i, leave - 5, step + 1); //三带对
            pai[i] +=3;
        }
        if(pai[i] > 1){
            int k = i;
            while(k < 12 && pai[k] > 1) k++;
            if(k > i + 2) { //连对子必须三个。
                for(int x = i + 3; x <= k; x++){
                for(int j = i; j < x; j++) pai[j] -= 2;
                dfs(pairs, i, leave - (x - i) * 2, step + 1);
                for(int j = i; j < x; j++) pai[j] += 2;
                }
            }
            pai[i] -= 2; //对子
            dfs(pairs , i, leave - 2, step + 1);
            pai[i] += 2;
        }
        if(pai[i] > 0){
            int k = i;
            while(k < 12 && pai[k] > 0) k++;
            if(k > i + 4){ //连单张
                for(int x = i + 5; x <= k; x++){
                for(int j = i; j < x; j++) pai[j]--;
                dfs(pairs, i, leave - (x - i), step + 1);
                for(int j = i; j < x; j++) pai[j]++;
                }
            }
            if(i == 13 && pai[14] == 1){
                pai[i]--;
                pai[i + 1]--;
                dfs(pairs, i, leave - 2, step + 1);
                pai[i]++;
                pai[i + 1]++;
            }
            pai[i]--; // 单张
            dfs(pairs, i, leave - 1, step + 1);
            pai[i]++;
        }
    }
}
int main(){
    while(scanf("%s", s) != -1){
        memset(pai, 0, sizeof pai);
        ans = 100;
        int len = strlen(s);
        for(int i = 0; i < len; i++){
            if(s[i] == '0') pai[7]++;
            else if(s[i] == 'J') pai[8]++;
            else if(s[i] == 'Q') pai[9]++;
            else if(s[i] == 'K') pai[10]++;
            else if(s[i] == 'A') pai[11]++;
            else if(s[i] == '2') pai[12]++;
            else if(s[i] == '*') pai[13]++;
            else if(s[i] == '#') pai[14]++;
            else pai[s[i] - '3']++;
        }
        dfs(0, 0, 17, 0);
        printf("%d\n", ans);
    }
    return 0;
}
  • 6
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值