暴力求解法
本文介绍的暴力法包括以下内容:
直接枚举
例如,类似“1~n的整数中有多少个满足……”,“输入一个长度为n的序列,有多少个连续子序列满足……”的问题都可以用直接枚举法。枚举法可以解决问题,但是效率不一定足够高。
枚举子集和排列
n个元素的子集有n2个,可以用递归的方法枚举(前面介绍的增量法和位向量法都属于递归枚举),也可以用二进制的方法枚举。递归法的优点在于效率高,方便剪枝,缺点在于代码比较长。一般来说,当n很小(如n≤15)时,会使用二进制的方式枚举。n个不同元素的全排列有n!个。除了用递归的方法枚举之外,还可以用STL的next_permutation来枚举,它也适用于有重复元素的情形。
回溯法
简单地说,回溯法几乎就是递归枚举,只是多了一条:违反题目要求时及时终止当前递归过程,即回溯(backtracking)。回溯法最经典的题目就是八皇后问题,这个问题也常常被作为“判断有没有学过回溯法”的依据。本节的几个例题非常经典,覆盖了回溯法的几个常见话题:搜索对象的选取(天平难题)、最优性剪枝(带宽),以及减少无用功(困难的串)。
题单
[P1036 [NOIP2002 普及组] 选数 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P1036) |
[P1157 组合的输出 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P1157) |
[P1706 全排列问题 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P1706) |
[P1219 [USACO1.5]八皇后 Checker Challenge - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/P1219) |
[UVA524 素数环 Prime Ring Problem - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/UVA524) |
[UVA129 困难的串 Krypton Factor - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/UVA129) |
[UVA140 带宽 Bandwidth - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/UVA140) |
[UVA1354 天平难题 Mobile Computing - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)](https://www.luogu.com.cn/problem/UVA1354) |
一、组合问题
[P1036 NOIP2002 普及组] 选数 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
#include<cstdio>
#include<iostream>
using namespace std;
int a[25];
int n, k;
long long ans=0;
bool isprime(int m){
for (int i = 2; i*i <= m; i++){
if (m%i == 0)return false;
}
return true;
}
void dfs(int num,int sum,int startx){
if (num == k){
if (isprime(sum))
ans++;
return;
}
for (int i = startx; i < n; i++){
dfs(num + 1, sum + a[i], i + 1);
}
return;
}
int main()
{
cin >> n >> k;
for (int i = 0; i<n; i++){
cin >> a[i];
}
dfs(0, 0, 0);
cout << ans;
return 0;
}
P1157 组合的输出 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
本题与上题的解答树和思路基本相同,要开一个容器存放数字组合
这道题有个很坑的点,就是“场宽”—每个元素占三个字符的位置,切不可写成两个空格,可以写成%3d或者
#include<iomanip> //要包含头文件
cout << setw(3) << st[j];
#include<cstdio>
#include<iostream>
using namespace std;
int a[25];
int n, k;
int st[25];// 存放组合的栈
void dfs(int num,int startx){
if (num == k){
for(int j = 0; j < k; j++){
printf("%3d",st[j]);
}
printf("\n");
return;
}
for (int i = startx; i <= n; i++){
st[num] = i;
dfs(num + 1, i + 1);
}
return;
}
int main()
{
dfs(0, 1);
return 0;
}
比较妙的解法—利用排列next_permutation()来算组合,它的性质是按字典序从小到大的方向调整
#include<bits/stdc++.h>
using namespace std;//因为要按照字典序进行输出,而排列的时候要按照从小到大的顺序进行全排列,因此0代表选,而1代表不选
int x[30]={0};
int main(){
int n,r;
cin>>n>>r;
for(int i=r+1;i<=n;i++){
x[i]=1;
}
do{
for(int i=1;i<=n;i++){//需要全部遍历,找到选的点
if(x[i]==0){
printf("%3d",i);
}
}
printf("\n");
}while(next_permutation(x+1,x+1+n));//代表数组从i=1开始是算数的
return 0;
}
二、排列问题
P1706 全排列问题 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
#include<cstdio>
#include<iostream>
#include<iomanip>
#include<algorithm>
using namespace std;
int n,a[10];
int main()
{
cin >> n;
for(int i = 0; i < n; i++){
a[i] = i + 1;
}
sort(a,a+n);
do{
for(int i = 0; i < n; i++) printf("%5d",a[i]);
printf("\n");
}while(next_permutation(a,a+n));
system("pause");
return 0;
}
三、子集问题
除了经典的位向量法(参见紫书P188),二进制法,还可以使用next_permutation()
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
int n,a[10];
int b[10]={0};
int main()
{
cin >> n;
for(int i = 0; i < n; i++) a[i] = i + 1;
for(int i = 1; i <= n; i++){
memset(b,0,sizeof(b));
for(int k = i; k < n; k++) b[k] = 1;
//sort(b,b+n);
do{
for(int j = 0; j < n; j++){
if(b[j] == 0) printf("%5d",a[j]);
}
printf("\n");
}while(next_permutation(b,b+n));
}
return 0;
}
四、回溯法
回溯的定义:当把问题分成若干步骤并递归求解时,如果当前步骤没有合法选择,则函数将返回上一级递归调用,这种现象称为回溯。正是因为这个原因,递归枚举算法常被称为回溯法,应用十分普遍。
如果在回溯法中使用了辅助的全局变量,则一定要及时把它们恢复原状。特别地,若函数有多个出口,则需在每个出口处恢复被修改的值。
[P1219 USACO1.5]八皇后 Checker Challenge - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
非常经典的题目
从普通的组合与排列角度考虑是不太现实的,因为种类太多了。正确的解法是按行枚举,保证每行一个,再按列循环,满足条件的列进行递归,回溯。
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
int visi[3][30],n,num = 0;
int ans[20];
void search(int cur) //cur 当前行
{
if(cur == n){
if(++num<=3){
for(int i = 0; i < n; i++) printf("%d ",ans[i]);
putchar('\n');
}
}
else{
for(int i = 0;i < n; i++){ //搜索列
if(!visi[0][i] && !visi[1][cur-i+n] && !visi[2][cur+i]){
visi[0][i] = visi[1][cur-i+n] = visi[2][cur+i] = 1;
ans[cur] = i+1;
search(cur+1); //继续搜索下一行
visi[0][i] = visi[1][cur-i+n] = visi[2][cur+i] = 0; //回溯,勿忘!
}
}
}
}
int main()
{
cin >> n;
search(0);
printf("%d",num);
return 0;
}
五、回溯法的其他应用
UVA524 素数环 Prime Ring Problem - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
回溯为什么快速呢?因为迭代的次数少,”函数将返回上一级递归调用“,使结果在之前正确结果的基础上生成。
注意输出格式,UVA题目不允许有任何的多余空行和空格
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cmath>
using namespace std;
int n,isp[40],size = 0,A[20],visi[20];
bool is_prime(int num){
for(int i = 2; i < int(sqrt(num) + 1); i++){
if(num % i == 0) return false;
}
return true;
}
void dfs(int cur)
{
if(cur == n){
if(isp[A[0]+A[n-1]]){
for(int i = 0; i < n-1; i++) printf("%d ",A[i]);
printf("%d\n",A[n-1]); //一定要这样,否则多一个空格
}
return;
}
else for(int i = 2; i <= n; i++){
if(!visi[i] && isp[i + A[cur-1]]){
visi[i] = 1;
A[cur] = i;
dfs(cur+1);
visi[i] = 0;
}
}
}
int main()
{
for(int i = 2; i <= 40; i++){
isp[i] = is_prime(i);
}
A[0] = 1;
int cnt = 0;
while(cin >> n){
if(cnt) printf("\n"); //否则多一个空行
printf("Case %d:\n",++cnt);
dfs(1);
}
return 0;
}
UVA129 困难的串 Krypton Factor - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
本题采用回溯法,自顶向下实现,首先实现bool dfs(cur)
输出格式是真的烦…
bool dfs(int cur){
if(cnt++ == n){
for(int j = 0; j < cur; j++){
putchar(ch[j] + 'A');
if(j % 64 == 63 && j!=cur-1) printf("\n");
else if(j % 4 == 3 && j!=cur-1) printf(" ");
}
putchar('\n');
printf("%d\n",cur);
return true;
}
//按照字典序生成序列
for(int i = 0; i < l; i++){
ch[cur] = i;
if(is_hard(cur)){
if(dfs(cur+1)) return true;
}
}
return false;
}
再实现 bool is_hard(),判断包含最后一个元素的字串是否与前面的字串相同
bool is_hard(int cur)
{
// i代表长度,j代表比较的循环变量
for(int i = 1; i*2 <= cur+1; i++){
bool flag = true;
for(int j = 0; j < i; j++){
if(ch[cur-i-j] != ch[cur-j]){ flag = false; break;}
}
if(flag) return false; //存在相等相邻字串
}
return true;
}
完整代码:
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cmath>
using namespace std;
int n,l,ch[100],cnt = 0;
bool is_hard(int cur)
{
// i代表长度,j代表比较的循环变量
for(int i = 1; i*2 <= cur+1; i++){
bool flag = true;
for(int j = 0; j < i; j++){
if(ch[cur-i-j] != ch[cur-j]){ flag = false; break;}
}
if(flag) return false; //存在相等相邻字串
}
return true;
}
bool dfs(int cur){
if(cnt++ == n){
for(int j = 0; j < cur; j++){
putchar(ch[j] + 'A');
if(j % 64 == 63 && j!=cur-1) printf("\n");
else if(j % 4 == 3 && j!=cur-1) printf(" ");
}
putchar('\n');
printf("%d\n",cur);
return true;
}
//按照字典序生成序列
for(int i = 0; i < l; i++){
ch[cur] = i;
if(is_hard(cur)){
if(dfs(cur+1)) return true;
}
}
return false;
}
int main()
{
while(cin >> n >> l && n && l){
cnt = 0;
dfs(0);
}
return 0;
}
六、剪枝策略
Bandwidth - UVA 140 - Virtual Judge (vjudge.net)
如果不考虑效率,本题可以递归枚举全排列,分别计算带宽,然后选取最小的一种方案。能否优化呢?和八皇后问题不同的是:八皇后问题有很多可行性约束(feasibility constraint),可以在得到完整解之前避免扩展那些不可行的结点,但本题并没有可行性约束——任何排列都是合法的。难道只能扩展所有结点吗?当然不是。
可以记录下目前已经找到的最小带宽k。如果发现已经有某两个结点的距离大于或等于k,再怎么扩展也不可能比当前解更优,应当强制把它“剪”掉,就像园丁在花园里为树修剪枝叶一样,也可以为解答树“剪枝(prune)”。
除此之外,还可以剪掉更多的枝叶。如果在搜索到结点u时,u结点还有m个相邻点没有确定位置,那么对于结点u来说,最理想的情况就是这m个结点紧跟在u后面,这样的结点带宽为m,而其他任何“非理想情况”的带宽至少为m+1。这样,如果m≥k,即“在最理想的情况下都不能得到比当前最优解更好的方案”,则应当剪枝。
本题输入是个问题,希望能够进一步优化
#include<iostream>
#include<cstdio>
#include<string>
#include<algorithm>
#include<cstring>
#include<sstream>
#include<map>
using namespace std;
const int N = 8;
int G[N][N],n = 0;
int min_band = 8;
char sq[10],temp[10];
map<int, char> mp_1;
map<char, int> mp_2;
bool visi[8];
//返回当前节点之前的距离最大值
int pre_dis(int cur, int i){
for (int j = 0; j < cur; j++){
if (G[temp[j]][i])
return cur - j;
}
return 0;
}
//返回当前节点之后的邻接点个数
int post_dis(int i){
int n_cnt = 0; //邻接点的个数
for (int j = 0; j < n; j++){ //枚举所有的点,若该点未被访问(不在temp[]中),并且存在点i到该点的边
if (visi[j] == 0 && G[i][j]) n_cnt++; //visi[j] = 0表示在当前节点之后
}
return n_cnt;
}
void dfs(int cur, int cur_max){ //temp_max:当前距离最大值
if (cur == n){ //保存并更新
if (min_band > cur_max){
memcpy(sq, temp, sizeof(sq));
min_band = cur_max;
}
return;
}
//i代表字母序号
for (int i = 0; i < n; i++){
if (!visi[i]){
if (post_dis(i) >= min_band) continue; //剪枝1:当前节点邻接节点数很多,已经超过min_band,剪掉
cur_max = max(cur_max, pre_dis(cur, i));
if (cur_max < min_band){ //剪枝2:当前最大距离已经超过min_band,剪掉
visi[i] = true;
temp[cur] = i;
dfs(cur + 1, cur_max);
visi[i] = false; //回溯
}
}
}
}
int main()
{
string line,neighbor;
char ch;
while (cin >> line && line != "#")
{
memset(G, 0, sizeof(G));
memset(visi, 0, sizeof(visi));
mp_1.clear(), mp_2.clear();
n = 0, min_band = 8;
for (auto &e : line){
if (!isalpha(e)) e = ' ';
}
for (char i = 'A'; i <= 'Z'; i++){
if (line.find(i)!=-1){ //注意写法
mp_1[n] = i;
mp_2[i] = n;
n++;
}
}
stringstream ss(line);
while (ss >> ch >> neighbor){
for (auto e : neighbor){
G[mp_2[ch]][mp_2[e]]++;
G[mp_2[e]][mp_2[ch]]++;
}
}
dfs(0, 1);
for (int i = 0; i < n; i++){
cout << mp_1[sq[i]] << " ";
}
cout << "-> " << min_band << endl;
}
return 0;
}
【特别注意】在求最优解的问题中,应尽量考虑最优性剪枝。这往往需要记录下当前最优解,并且想办法“预测”一下从当前结点出发是否可以扩展到更好的方案。具体来说,先计算一下最理想情况可以得到怎样的解,如果连理想情况都无法得到比当前最优解更好的方案,则剪枝。
教训:
1、先写好不剪枝的结构,再进行剪枝操作,原始的dfs如下
void dfs(int cur, int cur_max){ //temp_max:当前距离最大值
if (cur == n){ //保存并更新
if (min_band > cur_max) {
memcpy(sq, temp, sizeof(sq));
min_band = cur_max;
}
return;
}
//i代表字母序号
for (int i = 0; i < n; i++){
if (!visi[i]){
cur_max = max(cur_max, pre_dis(cur, i));
visi[i] = true;
temp[cur] = i;
dfs(cur + 1, cur_max);
visi[i] = false; //回溯
}
}
}
2、索引统一:1—n或者0— n - 1 。特殊的表示(例如用整数来表示字母)写一下注释提醒一下自己
3、多次输入一定要把所有的全局变量(包括各种的数组和常数)初始化。有一些例如:dfs中用于保存临时序列的数组等不必初始化,若不确定,则都初始化
参考
《算法竞赛入门经典》 刘汝佳 清华大学出版社