黑马程序员--暴力求解法

---------------------- ASP.Net+Android+IOS开发、.Net培训、期待与您交流! ----------------------

 

本案例中所有的例子全部采用C语言或C++编程语言。

  暴力求解法

引   言

暴力法也称为穷举法、蛮力法,它要求调设计者找出所有可能的方法,然后选择其中的一种方法,若该方法不可行则试探下一种可能的方法。

暴力法也是一种直接解决问题的方法,常常直接基于问题的描述和所涉及的概念定义。

暴力法不是一个最好的算法,但当我们想不出更好的办法时,它也是一种有效的解决问题的方法。

暴力法的优点是逻辑清晰,编写程序简洁。在程序设计竞赛时,时间紧张,相对于高效的、巧妙的算法,暴力法编写的程序简单,能更快地解决问题。同时蛮力法也是很多算法的基础,可以在蛮力法的基础上加以优化,得到更高效的算法。

而且,某些情况下,算法规模不大,使用优化的算法没有必要,而且某些优化算法本身较复杂,在规模不在时可能因为复杂的算法浪费时间,反而不如简单的暴力搜索。

使用暴力法常用如下几种情况:

(1)搜索所有的解空间;

(2)搜索所有的路径;

(3)直接计算;

(4)模拟和仿真。

  一、简单的枚举

  在枚举复杂对象之前,先尝试着枚举一些相对简单的东西,如整数、子串等。暴力枚举对问题进行一定的分析往往会让算法更加简洁、高效。

1、 除法

输入正整数n,按从小到大的顺序输出所有形如abcde/fghij=n的表达式,其中a~j恰好为数字0~9的一个排列,2≤n≤79。

样例输入:

62

样例输出:

79546/01283=62

94736/01528=62

 

【分析】

只需要枚举fghij就可以计算出abcde,然后判断是否所有数字都不相同即可。不仅程序简单,而枚举量也从10!=3628800降低至不到1万。由此可见,即使采用暴力枚举,也是需要认真分析问题。

完整的程序如下:

#include 

using namespace std;

 

bool test(int i,int j){  //用数组t存放i,j的各位数字

   int t[10]={0};  //初始化数组t,使得各位数字为0,好处是使得fghij<10000时f位置为0

   int ia = 0;

 while(i) {  //取i中各位数字存放在数组t中

      t[ia++] = i % 10;

      i = i / 10;

   }

while(j) {  //取j中各位数字存放在数组t中

      t[ia++] = j % 10;

       j = j / 10;

   }

//判断a~j是否恰好为数字的0~9的一个排列

   for(int m = 0; m < 10; ++m)

      for(int n = m+1; n < 10; ++n)

         if(t[n] == t[m])   return false;

  return true;

}

 

int main(){

   int n;

   int k;

   while(cin >> n && n >=2 && n <= 79) {

      k = 1234;

      while(k <= 98765) {

        int j = k * n;

        if(j < 100000) { //若fghij<10000,满足题目的条件,f位置输出0

           if(test(j,k)) {

              cout << j << "/" ;

              if(k < 10000)  cout <<"0";

              cout << k << "=" << n <

           }

        }

        ++k;

      }

   }

   return 0;

}

2、双基回文数

如果一个正整数n至少有两个不同的进位制b1和b2下都是回文数(2≤b1,b2≤10),则称n是双基回文数(注意,回文数不以包含前导零)。输入正整数S<106,输出比S大的最小双基回文数。

样例输入:1600000

样例输出:1632995

【分析】

最自然的想法是:从n+1开始依次判断每个数是否为双基回文数,而在判断时要枚举所有可能的基数(2~10)。意外的是:对于S<106的“小规模数据”来说是足够快的——双基回文数太多太密。

完整的程序如下:

#include 

using namespace std;

 

bool huiwen(int n){

   int i,j,a[30],s;

   int total = 0; //存放数s是回文数的基数个数

   for(int base = 2; base <= 10; base++) {

      i = 1;

      s = n;

      while(s) {

         a[i] = s % base;

         s = s / base;

         i++;

      }

      i--;

      for(j = 1; j <= i/2; j++)   //判断数s在基base下是否是回文数

         if(a[j] != a[i-j+1])    break;

         if(j > i/2)    total++;  //数s在基base下是回文数,则total++

         if(total >= 2)  return true;

   }

 return false;

}

 

int main(){

   int s;

   while(scanf("%d",&s) == 1) {

      for(s = s+1; ; s++) {

         if(huiwen(s)) {

cout << s << endl;     break;

    }

  }

  return 0;

}

 

二、枚举排列

枚  举  排  列

输入整数n,按字典序从大到小的顺序输出前n个数的所有排列。两个序列的字典序大小关系等价于从头开始第一个不相同位置处的大小关系。例如,(1,3,2)<(2,1,3),字典序最小的排列是(1,2,3,4,…,n),最大的排列是(n,n-1,n-2,…,1)。n=3时,所有排列的排序结果是:(1,2,3)、(1,3,2)、(2,1,3)、(2,3,1)、(3,1,2)、(3,2,1)

1 、 生成1~n的排列

对此问题用递归的思想解决:先输出所有以1开头的排列(递归调用),然后输出以2开头的排列(递归调用),接着以3开头的排列,…,最后才是以n开头的排列。

以1开头的排列的特点是:第一位是1,后面是按字典序的2~9的排列。所以在设计递归函数需要以下参数:

(1)已经确定的“前缀”序列,以便输出;

(2)需要进行全排列的元素集合,以便依次选做第一个元素。

这样,写出以下的伪代码:

void print_permutation(序列A,集合S)

{

    if(S为空)    输出序列A;

    else 按照从小到大顺序依次考虑S的每个元素v

    {

       print_permutation(在A的末尾填加v后得到的新序列,S-{v});

}

}

上面的递归函数中递归边界是S为空的情形,可以直接输出序列A;若S不为空,则按从小到大的顺序考虑S中的每个元素,每次递归调用以A开头。

下面考虑程序的实现。用数组表示序列A,集合S可以由序列A完全确定——A中没有出现的元素都可以选。C语言中的函数在接受数组参数时无法得到数组的元素个数,所以需要传一个已经填好的位置个数,或者当前需要确定的元素位置cur。声明一个足够大的数组A,然后调用print_permutation(n,A,0),即可按字典序输出1~n的所有排列。

完整的程序如下:

#include

int A[100];

 

// 输出1~n的全排列的递归函数

void print_permutation(int n, int* A, int cur) {

  int i, j;

  if(cur == n) {    // 递归边界

    for(i = 0; i < n; i++) printf("%d ", A[i]);

    printf("\n");

  } 

else for(i = 1; i <= n; i++) { // 尝试在A[cur]中填各种整数i

    int ok = 1;

    for(j = 0; j < cur; j++)

      if(A[j] == i) ok = 0; // 如果i已经在A[0]~A[cur-1]出现过,则不能再选

    if(ok) {

         A[cur] = i;

         print_permutation(n, A, cur+1); // 递归调用

    }

  }

}

 

int main() {

  print_permutation(4, A, 0); 

  return 0;

}

在递归函数print_permutation中循环变量i是当前考虑的A[cur]。为了检查元素i是否已经用过,还用到了一个标志变量ok,初始值为1(真),如果发现有某个A[j]==i时,则改为0(假)。如果最终ok仍为1,则说明i没有在序列中出现过,把它添加到序列末尾(A[cur]=i)后递归调用。

2、 生成可重集的排列

如果把问题改成:输入数组A,并按字典序输出数组A各元素的所有全排列,则需要对上述递归函数print_permutation修改——把P加到print_permutation的参数列表中,然后把代码中的if(A[j]==i)和A[cur]=i分别改成if(A[j]==P[i])和A[cur]=P[i]。只有把P的所有元素按从小到大的顺序排序,然后调用print_permutation(n,P,A,0)即可。

但是上述递归函数print_permutation中,禁止A数组中出现重复,而在P中可能就有重复元素时,所以输出数组A时就会出现问题。

解决方法是统计A[0]~A[cur-1]中P[i]的出现次数c1,以及P数组中P[i]的出现次数c2。只要c1

枚举的下标i应不重复、不遗漏地取遍所有P[i]值。由于P数组已经排过序,所以只需检查P的第一个元素和所有“与前一个元素不相同”的元素,即只需在for(i=0;i

完整的程序如下:

#include

int P[100], A[100];

 

// 输出数组P中元素的全排列。数组P中可能有重复元素

void print_permutation(int n, int* P, int* A, int cur) {

  int i, j;

  if(cur == n) {

    for(i = 0; i < n; i++) printf("%d ", A[i]);

    printf("\n");

  } 

else for(i = 0; i < n; i++)

 if(!i || P[i] != P[i-1]) {

             int c1 = 0, c2 = 0;

              for(j = 0; j < cur; j++)  if(A[j] == P[i]) c1++;

              for(j = 0; j < n; j++)    if(P[i] == P[j]) c2++;

              if(c1 < c2) {

                 A[cur] = P[i];

                 print_permutation(n, P, A, cur+1);

              }

         }

}

 

int main() {

  int i, n;

  scanf("%d", &n);

  for(i = 0; i < n; i++)

      scanf("%d", &P[i]);

  print_permutation(n, P, A, 0);

  return 0;

}

 

 

三、回  溯  法

无论是排列生成还是子集枚举,两种思路:递归构造和直接遍历。直接遍历的优点是思路和程序都很简单,缺点在于无法简便地减少枚举量——必须生成(generate)所有可能的解,然后一一检查(test)。

另一方面,在递归构造中,生成和检查过程可以有机结合起来,从而减少不必要的枚举,这就是本节的主题——回溯法(backtracking)。

回溯法是一种系统的搜索问题的解的方法。它的基本思想是:从一条路前行,能进则进,不能进则退回来,换一条路再试。回溯法是一种通用的解题方法。

应用回溯法的时候,首先明确定义问题的解空间。解空间至少应该包含问题的一个解。确定了解空间后,回溯法从开始结点出发,以深度优先的方法搜索整个解空间。

对于回溯法一般可以采用递归方式来实现。

1  八皇后问题

在棋盘上放置8个皇后,使得它们互不攻击,此时每个皇后的攻击范围为同行同列和对角线,要求找出所有解,如图所示。

黑马程序员--暴力求解法_第1张图片

 

 

【分析】

思路一:把问题转化为“从64个格子中选一个子集”,使得“子集中恰好有8个格子,且任意两个选出的格子都不在同一行、同一列或同一个对角线上”。这是子集枚举问题,不是一个好的模型。

思路二:把问题转化为“从64个格子中选8个格子”,这是组合生成问题。比思路一好,但是仍然不是很好。

思路三:由分析可得,恰好每行每列各放置一个皇后。如果用C[x]表示第x行皇后的列编号,则问题变成了全排列生成问题。

当把问题分成若干步骤并递归求解时,如果当前步骤没有合法选择,则函数将返回上一级递归调用,这种现象称为回溯。正是因为这个原因,递归枚举算法常称为回溯法,它的应用十分普遍。

在主程序中读入n,并为tot清0,然后调用search(0),即可得到解的个数tot。当n=8,则tot=92,状态空间结点数nc=2057。完整的程序如下:

#include 

int C[50], tot = 0, n, nc = 0;  

void search(int cur) {

  int i, j;

  nc++;      //nc状态空间结点数

  if(cur == n)   tot++;  //递归边界。只要走到了这里,所有皇后必然不冲突

  else for(i = 0; i < n; i++) {

    int ok = 1;

    C[cur] = i;               //尝试把cur行的皇后放在第i列

    for(j = 0; j < cur; j++)  //检查是否和前面的皇后冲突

      if(C[cur] == C[j] || cur-C[cur] == j-C[j] || cur+C[cur] == j+C[j]) {

        ok = 0;

        break;

      }

    if(ok)   search(cur+1);    //如果合法,则继续递归

  }

}

 

int main() {

  scanf("%d",&n);

  search(0);

  printf("%d\n", tot);

  printf("%d\n", nc);

  return 0;

}

注意:既然是逐行放置的,则皇后肯定不会横向攻击,因此只需检查是否纵向和斜向攻击即可。条件cur-C[cur] == j-C[j] || cur+C[cur] == j+C[j]用来判断皇后(cur,C[cur])和(j,C[j])是否在同一条对角线上。其原理如下图

黑马程序员--暴力求解法_第2张图片

上面的程序还可改进:利用二维数组vist[2][]直接判断当前尝试的皇后所在的列和两个对角线是否已有其他皇后。注意到主对角线标识y-x可能为负,存取时要加上n。完整的程序如下:

Include 

int C[50], vis[3][50], tot = 0, n, nc = 0;

 

void search(int cur) {

  int i, j;

  nc++;

  if(cur == n)   tot++;

else for(i = 0; i < n; i++) {

    if(!vis[0][i] && !vis[1][cur+i] && !vis[2][cur-i+n]) {

//利用二维数组直接判断

      C[cur] = i;  //如果不打印解,整个C数组都可以省略

      vis[0][i] = vis[1][cur+i] = vis[2][cur-i+n] = 1;  //修改全局变量

      search(cur+1);

      vis[0][i] = vis[1][cur+i] = vis[2][cur-i+n] = 0; //切记!一定要改回来

    }

  }

}

 

int main() {

  scanf("%d",&n);

  memset(vis, 0, sizeof(vis)); 

  search(0);

  printf("%d\n", tot);

  printf("%d\n", nc);

  return 0;

}

上面的程序关键的地方是vis数组的使用。vis数组表示已经放置的皇后占据了哪些列、主对角线和副对角线。将来放置的皇后不应该修改这些值。一般地,如果要回溯法中修改了辅助的全局变量,则一定要及进把它们恢复原状(除非故意保留修改)。另外,千万不要忘记在调试之前把vis数组清空。

 

本次发表的只有一小部分,那些后面再写吧!

---------------------- ASP.Net+Android+IOS开发、.Net培训、期待与您交流! ----------------------

转载于:https://www.cnblogs.com/zxh930508/p/3544337.html

你可能感兴趣的:(黑马程序员--暴力求解法)