暴力求解法(枚举,搜索,回溯,剪枝策略)

暴力求解法


本文介绍的暴力法包括以下内容:

直接枚举

例如,类似“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)

image.png

#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中用于保存临时序列的数组等不必初始化,若不确定,则都初始化

参考

《算法竞赛入门经典》 刘汝佳 清华大学出版社

运动员最佳匹配问题是指在给定的n个男运动员和n个女运动员中,找到最佳的n对组合,使得这n对组合的分数和最大。每个男运动员都有一个评分表,记录他对所有女运动员的评分,同样,每个女运动员也有一个评分表,记录她对所有男运动员的评分。匹配的分数是男女之间的互相评分之和。 这个问题可以通过排列树进行求解。排列树是一种搜索树,其中每个节点表示了一个待定的选项,而每个分支代表一个选项的选择。在这个问题中,树的深度为n,每个节点表示了一对男女的匹配情况,分支代表了下一对待匹配的男女。对于每个节点,可以通过计算当前已匹配的男女对的分数和,加上剩余男女之间的最大分数和来估计当前的分数上限。如果当前的分数上限已经小于已知的最大分数和,则可以剪枝掉这个节点,因为它不可能包含最优解。 以下是伪代码实现: ``` best_score = 0 current_score = 0 matched_pairs = [] def permute(men, women): global best_score, current_score, matched_pairs # 如果已经匹配了n对,更新最优解 if len(matched_pairs) == len(men): best_score = max(best_score, current_score) return # 计算剩余男女之间的最大分数和 max_possible_score = 0 for man in men: if man not in matched_pairs: max_possible_score += max(man.ratings.values()) for woman in women: if woman not in matched_pairs: max_possible_score += max(woman.ratings.values()) # 如果当前分数加上剩余分数的最大值小于已知最优解,剪枝 if current_score + max_possible_score < best_score: return # 选择下一对男女进行匹配 for man in men: if man not in matched_pairs: for woman in women: if woman not in matched_pairs: matched_pairs.append((man, woman)) current_score += man.ratings[woman] current_score += woman.ratings[man] permute(men, women) current_score -= man.ratings[woman] current_score -= woman.ratings[man] matched_pairs.remove((man, woman)) ``` 在这个伪代码中,`men`和`women`分别是男女运动员的列表,每个运动员都有一个`ratings`属性,记录他们对其他所有运动员的评分。`best_score`记录了已知的最大分数和,`current_score`记录了当前已匹配的男女对的分数和,`matched_pairs`记录了当前已匹配的男女对。`permute`函数是排列树的递归函数,它会枚举下一对待匹配的男女,计算当前分数上限,判断是否需要剪枝,然后递归地进行搜索。在递归过程中,会更新当前分数和已匹配的男女对,并且在回溯时撤销这些修改。 这个算的时间复杂度是O(n!),因为它需要枚举n!种可能的匹配组合。但是,由于有剪枝优化,实际运行时间通常会比这个时间复杂度低得多。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值