文章目录
前言
搜索技术是基本的编程技术,在算法竞赛学习中是基础的基础。搜索使用的算法是BFS和DFS,BFS用队列,DFS用递归来具体实现。在BFS和DFS的基础上可以扩展出A算法,双向广搜算法,迭代加深搜索,IDA等技术。第三阶段都将详细介绍这些知识点
基本概念
搜索技术是“暴力法”算法思想的具体实现。
人们常说:“要利用计算机强大的计算能力。”如果答案在一大堆的数字里面,让计算机一个个去试,符合条件的不就是答案了吗?
没错最基本的算法思想"暴力法"就是这样。例如,银行卡密码是6位数字,共100万个,对于计算机来说,尝试100万次只需要一瞬间。不过计算机也不是无敌的。为了应对计算机强大的计算能力,可以对密码进行强化设计。例如网络账号密码,大部分网站都要求长度在8位以上,并且混合数字,字母,标点等。从40多个符号中选8个组成密码,数量有40x39x38x37x36x35x34x33>3万亿,即使使用计算机也不能很快算出来。
暴力法(Brute force,又称为蛮力法):把所有可能的情况都罗列出来,然后逐一检查,从中找到答案。这种方法简单,直接,不玩花样,利用了计算机强大的计算能力。
虽然暴力法常常是低效的代名词,但是它仍然很有用,原因如下:
- 很多问题只能用暴力法解决,例如猜密码。
- 对于小规模的问题,暴力法完全够用,而且避免了高级算法需要的复杂编码,在竞赛中可以加快解题速度。在竞赛中也可以用暴力法来构造测试数据,以验证高级算法的正确性。
- 把暴力法当作参照(benchmark)。既然暴力法是“最差”的,那么可以把它当作一个比较衡量另外的算法有多“好”。不过在具体编程的时候通常需要对暴力法进行优化,以减少搜索空间,提高效率。例如利用剪枝技术跳过不符合要求的情况,从而减少复杂度。
虽然暴力搜索的思路简单,但是操作起来并不简单。一般有以下操作:
- 找到所有可能的数据,并且用数据结构表示和存储
- 剪枝。尽量多的排除不符合条件的数据,以减少搜索的空间
- 用某个算法快速检索这些数据。
其中第一步就可能很不容易。例如迷宫问题,如和列举从起点到终点的所有可能的路径?再如图论中的“最短路径问题”,在地图上任取两个点,它们之间所有可行的路径可能是一个天文数字,以至于根本无法一一列举出来。所以计算最短路径的Dijkstra算法使用贪心法,进行从局部扩散到全局的搜索,不能列举所有可能的路径。
暴力法的主要操作是搜索,搜索的主要技术是BFS和DFS。掌握搜索技术是学习算法的基础。在搜索时,具体问题会有相应的数据结构,例如队列,栈,图,树等。读者应该能熟练的在这些数据结构上进行搜索的操作。
本阶段主要讲解BFS和DFS,以及基于它们的优化技术,并以一些经典的搜索问题为例讲解算法思想,例如排列组合,生成子集,八皇后,八数码,图遍历等。
递归和排列
排列和组合问题是在暴力枚举的时候经遇到的,一般有3中常见情况。
本篇博客用递归程序来实现问题1和2,问题3留在下一篇博客.
在计算机编程教材中都会提到递归的概念和应用,一般会用数学中的递推方程来讲解递归的概念,例如f(n)=f(n-1)+f(n-2)。在计算机系统中,递归是通过嵌套来实现的,设计指针,地址,栈的使用。
从算法的思想上看,递归是把大问题逐步缩小,直到变成最小的同类问题的过程。例如n->n-1->n-2->…->1,最后的小问题的解是已知的,一般是给定的初始初始条件。在递归的过程中,由于大问题和小问题的解决方法完全一样,那么大家自然可以想到,大问题的程序和小问题的可以写成一样。一个递归函数直接调用自己,就实现了程序的复用。
递归和分治法的思路非常相似,分治是把一个大问题分解为多个类型相同的子问题,事实上,一些涉及分治法的问题可以用递归来编程,典型的有快速排序,归并排序等。
对于编程初学者来说,递归是一个难以理解的编程概念,很容易绕晕。为了帮助理解,可以一步步打印出递归函数的输出,看它从大到小解决问题的过程
编程竞赛中的暴力法常常需要考虑所有可能的情况,用递归编程可以轻松,方便的实现对搜索空间所有状态的遍历
案例1:采用STL库中的next_permutation()函数实现全排列
题目
打印n个数的全排列,一共n!个。
在用递归解决这个问题之前,先给出STL的实现方法
思路
如果用全排列的场景比较简单,可以直接用C++STL的库函数next_permutation(),按字典序输出下一个排列。在使用之前,先用sort()给数据排序,得到最小排列,然后每调用next_permutation()一次,就得到一个大一点的排列
next_permutation()优点是能按从小到大的顺序输出排列。
源码
#include<iostream>
#include<algorithm> //包含sort和next_permutation()函数的头文件
using namespace std;
int main() {
int data[4] = { 5,2,4,1 };
sort(data, data + 4);//默认从小到大,得到最小排列
do {
for (int i = 0; i < 4; i++) {//输出每个排列的值
cout << data[i] << " ";
}
cout << endl;
} while (next_permutation(data, data + 4));//把下一个排列放在data中
return 0;
}
运行结果
分析
案例1:采用递归求全排列
题目
打印n个数的全排列,一共n!个。
思路
下面用递归求全排列,代码很短,但是理解起来不太容易。认真理解
在递归调用之前,为了对比,先给出一个简单,粗暴的方法:以10个数的全排列为例,用排列组合的思路写一个10级的for循环,在每一个for中选一个和前面的for用过的都不同的数。当n=10时,一共有10!=362800个排列
验证
运行结果
显然这个程序很笨,运行需要的时间很长,下面我们用递归来写,能感受到明显的差别。
递归思路
假定数字是{1,2,3,4,5,…,n};
- 让第一个数不同得到n个数列。其办法是把第1个和后面的每个数交换
1,2,3,4,5,…,n
2,1,3,4,5,…,n
3,1,2,4,5,…,n
.
.
.
n,2,3,4,5,…,1
以上n个数列,只要第一个数不同,不管后面的n-1个数是怎样排列的,这n个数列都不同。
这是递归的第一层。 - 继续:在上面的每个数列中去掉第一个数,对后面n-1个数进行同样的方法进行排列。例如从上面的第二行的{2,1,3,4,5,…,n}进入第二层(去掉首位2);
1,3,4,5,…,n
3,1,4,5,…,n
.
.
.
n,3,4,5,…,1
以上n-1个数列,只要第一个数不同,不管后面的n-2个数是怎么排列的,这n-1个数列都不同
这是递归的第二层 - 重复上面的代码,直到用完所有的数字。
因此上面的结果就是n!个
源码
#include<iostream>
#include<algorithm>
using namespace std;
void Swap(int &a,int &b){//也可以用STL库中的swap函数,只是速度慢些
int temp = a;
a = b;
b = temp;
}
int Data[] = { 1,2,3,4,5,6,7,8,9,11,12 };//只会用到前10个
int num = 0;
void Perm(int begin, int end) {
int i;
if (begin == end) {
num++;//表示一个全排列,到最底层,递归结束
}
else {
for (i = begin; i <= end; i++) {
//将第一个位置的数和后面每一个数进行交换,那么后面的每个数都出现在了第一个位置,加上自己,就有n个不同的数列
Swap(Data[begin], Data[i]);
Perm(begin + 1, end); //得到的这个数列,继续递归,进入第二层
Swap(Data[begin], Data[i]); //将位置换回来,交换后面的那个数。
}
}
}
int main() {
Perm(0, 9);
cout << "num:" << num << endl;
return 0;
}
运行结果
刚好就是10的阶乘。
如果你每次都打印了结果的话,这个结果就很慢,因为cout打印这屏幕上也是需要时间的。
分析
案例2,打印n个数中任意m个数全排列
就把上面的Perm函数改一下就行,
总结
相信经过上面的例题你已经明白了递归的思想,那我们就可以继续学习下面的内容了
子集生成和组合问题,BFS和队列
加油,每天进步一点点!一步一个jio印。