尝试模型
什么是尝试?尝试是方法,是解题的步骤,也可以说尝试就是暴力递归,但是所有的动态规划都来自于暴力尝试,所有说尝试是解题的关键。本篇介绍第一个模型。
从左往右的尝试模型
子串:要求字符之间是连续的
子序列:不要求连续
例如“abc”的子串有“a”,“ab”,“abc”,“b”,“bc”,"c"这六个
而子序列:abc,ab,ac,a,bc,b,c, 空串。ac表示子串,是子序列。
- 如何打印子串?
这个简单都不需要递归,三层循环就好了,外层循环确定子串开始位置,内层循环确定子串结束位置,最里面的一层保存字串
string strs = "abc";
vector<string>re;
string paths = "";
for (int i = 0; i < strs.size(); i++) {
for (int j = i; j < strs.size(); j++) {
for (int t = i; t <= j; t++) {//存放从开始i到结束j的字符
paths += strs[t];
}
re.push_back(paths);
paths = "";
}
}
for (int i = 0; i < re.size(); i++) {
cout << re[i] << ",";
}
- 如何打印子序列?
从左往右尝试,对于字符串每个字符都有两种选择,要或者不要,可以把它想象成一个二叉树,边表示选择,如下图所示,结点上的数字就是index
然后就该写递归了
- 确定参数:strs 固定参数,index,vector< string>re//存放子序列的集合,string paths//存放当前的路径
返回值:由于要遍历整棵树,所以不需要返回值- 终止条件:到达叶子结点就结束,其实没必要真得建一个树,由图可以知道当index等于字符串的长度,就相当于到了叶子结点
- 本层递归:前面也提到了,对于当前位置的字符,有两种选择,要不选它,要么不选它
#include<iostream>
#include<vector>
#include<string>
using namespace std;
vector<string> re;//保存子序列的集合
//要打印str的子序列,当前位置index,当前路径是paths
void printSubsequence(string& str, int index,string paths) {
if (index == str.size()) {
re.push_back(paths);
return;
}
printSubsequence(str, index + 1, paths + str[index]);//要当前位置的字符
printSubsequence(str, index + 1, paths);//不要当前位置的字符
}
int main() {
string strs = "abc";
string paths = "";
printSubsequence(strs, 0, paths);
for (int i = 0; i < re.size(); i++) {
cout << re[i] << "," ;
}
return 0;
}
- 打印所有的子序列,保证不出现重复的子序列
什么意思呢?就是加入一个字符串是“aaa”,选择第一个位置第二个位置和选择第一个位置第三个位置这两个选择得到的答案是一样的,所以要求不重复。
很简单,去重优先想到set天生的去重机制,所以直接把vector变为set就行了。
本来想对string按字符串长度排序来着,但是相同长度的字符串它会默认为是相同的字符串,所以不能重复添加相同长度的字符串,目前还没想到解决方案。
#include<iostream>
#include<set>
#include<string>
#include<algorithm>
using namespace std;
//要打印str的子序列,当前位置index,当前路径是paths
void printSubsequence(string& str, int index, string paths,set<string>& re) {
if (index == str.size()) {
re.insert(paths);
return;
}
printSubsequence(str, index + 1, paths + str[index],re);//要当前位置的字符
printSubsequence(str, index + 1, paths,re);//不要当前位置的字符
}
int main() {
string strs = "abc";
string paths = "";
set<string> re;//保存子序列的集合
printSubsequence(strs, 0, paths, re);
for (set<string>::iterator it = re.begin(); it != re.end(); it++) {
cout << *it << ",";
}
return 0;
}
- 打印字符串的全排列
举个例子,还是字符串“abc”,第一个位置有3种选择,第二个位置有2种选择,第三个位置1种,相乘就是一共的种数,所以全排列一共6种(321)。
同样也可以用树来表示,但不是二叉树,而是多叉树。如下图
每一条边代表一种选择,第一层(深度为1的结点)表示确定第一个位置,有3种选择:从0位置到最后位置都可以选
第二层(深度为2的结点)表示确定第二个位置,只有2种选择,因为第一个位置已经确定了一个字符,从1位置到最后位置可以选
第三层(深度为3的结点)表示确定第三个位置,只要1种选择,只要最后位置可以选。
选择的过程其实就是交换位置的过程,比如第一层的c -> 0表示2位置的c与0位置的a交换
- 参数:strs,index(可以选择的开始位置,初始值肯定是0),re(存放结果的集合)
返回值:由于遍历整棵树同样不需要返回值 - 终止条件,当index等于strs的长度时表示到了叶子结点
- 本层递归:从index开始的字符依次与index位置的字符交换,然后找index+1的全排列,具体请看代码
#include<iostream>
#include<vector>
#include<string>
using namespace std;
void fullArrangement(string strs,int index,vector<string>&re) {
if (index == strs.length()) {
re.push_back(strs);
return;
}
for (int i = index; i < strs.size(); i++) {//从index开始字符依次与index位置交换
swap(strs[i], strs[index]);
fullArrangement(strs, index + 1, re);//找第index+1开始的全排列
swap(strs[i], strs[index]);//一定要回溯到交换之前的状态
}
}
int main() {
string strs = "abc";
vector<string>re;
fullArrangement(strs, 0, re);
for (string s : re) {
cout << s << ",";
}
return 0;
}
练手见46. 全排列
- 打印字符的全排序,并且要求不重复
也可以用刚才那种方法,找到所有的排列然后set去重,这里介绍分支限界。也是先举个例子
比如字符串“aaa”,index=0时,0位置的a与0位置的a交换,然后1位置的a与0位置的a交换与刚才的选择是一样的,所以这个分支剪掉,同理2位置的a与0位置的a交换也是一样的选择,也剪掉,这就是分支限界。
具体如何实现呢?
在跑递归前应该先做一个判断,如果这个字符在本层使用过了,就不再考虑了,判断字符有没有使用过可以用哈希表,也可以用个数组(前提是字符种类有限),这里我用哈希表。
- 参数:string& strs, int index, vector< string>& re,同样也不需要返回值
- 终止条件:index等于字符串长度
- 本层递归:来个哈希表,初始值都为false,表示都没有使用过,在做交换前先判断有没有使用过,没有使用的话才交换跑递归
#include<iostream>
#include<set>
#include<vector>
#include<string>
#include<unordered_map>
using namespace std;
void fullArrangement(string& strs, int index, vector<string>& re) {
if (index == strs.length()) {
re.push_back(strs);
return;
}
unordered_map<char, bool> umap;//默认value都为false
for (int i = index; i < strs.size(); i++) {//从index开始字符依次与index位置交换
if (!umap[strs[i]]) {//如果strs[i]这个字符没有用过就跑递归
umap[strs[i]] = true;//标记使用过
swap(strs[i], strs[index]);
fullArrangement(strs, index + 1,re);
swap(strs[i], strs[index]);//同样也需要回溯
}
}
}
int main() {
string strs = "aabb";
vector<string>re;
fullArrangement(strs, 0, re);
for (string s : re) {
cout << s << ",";
}
return 0;
}
练练手吧47. 全排列 II
- 题目描述:规定1和A对应、2和B对应、3和C对应……,那么一个数字字符串比如“111”,就可以转化为“AAA”、“KA”和“AK”。
给定一个只有数字字符组成的字符串,返回有多少种转化结果。
也是先举个例子,就是“111”吧
从左往右尝试,只有1到26的数字才可以转化,如下图
第一层(深度为1的结点):有两种转化选择,一种是"1"->A,然后去转化“11”,另一种是“11”->K,然后去转化“1”
第二层的“11”,也有两种选择,一种是“1”->A,然后去转化“1”,另一种是“11”->K,然后去转化“空串”。“1”同理。
第三次的空串当然就转化结束了。
可以写递归了
- 参数:strs//固定参数,i//开始转化的下标
返回值:题目要求返回种数,这里就int - 终止条件:前面也提到了,空串就是转化结束,从图中可以看出,应该返回1,说明当前支路找到一种转化。也可以理解为空串就一种转化,这种转化就是不用转化。还有一个终止条件,也可以把它理解为剪枝,如果开始位置的字符是0,是不是就无法转化了呢,此时直接返回0,这条支路无法转化了。
- 本层递归:从i开始往后截取字符串,如果截取的字符串对应的整数值在1到26范围内才能转化,如果大于了26直接结束就好了。
#include<iostream>
#include<string>
using namespace std;
int f(string str, int i) {
if (i == str.size()) {
return 1;
}
if (str[i] == '0') {//开始位置为0的转化失败
return 0;
}
int count = 0;
for (int j = i; j < str.size(); j++) {
string sub = str.substr(i, j - i + 1);//特别注意substr参数的含义,第一个参数;开始截取的位置,第二个参数:截取的字符的个数
int val = stoi(sub);//string转整型
if (val <= 26) {//等于0的已经排除了,所以只要判断是否小于26就好了
count += f(str, j + 1);//注意找j往后的转化
}
else {
break;//当前就大于26,往后就更大了
}
}
return count;
}
int main() {
string strs = "10";
cout << f(strs, 0);
return 0;
}
还有一种写法,既不需要字符串整型转化,也不需要循环。不是就只要26个字母嘛,这棵树它最多就两个分支,要么i作为一个整体转化,然后去转化i+1开始的字符串,要么i和i+1作为整体转化,然后去转化i+2开始字符串。
这里分3种情况:
- 如果i位置的字符是大于3就只有一个分支:i作为一个整体转化,然后去转化i+1开始的字符串
- 如果i位置的字符是2:可能有一个分支,也可能有两个分支,如果i+1的字符在0到6之内就有两个分支,一个是i作为整体去转化i+1开始的字符串,另一个是i i+1作为一个整体去转化i+2开始的字符串,两者相加就是转化的种数;否则就只有一个分支,只能是i作为整体,去转化i+1开始的字符串。
- 如果i位置的字符是1:放心好了,一定是有两个分支。
#include<iostream>
#include<string>
using namespace std;
int f(string str, int i) {
if (i == str.size()) {
return 1;
}
if (str[i] == '0') {//开始位置为0的转化失败
return 0;
}
int count = 0;
if (str[i] == '1') {
count += f(str, i + 1);//i作为一个整体
if (i + 1 < str.size()) {//保证i+1不越界的情况下才考虑i i+1作为整体
count += f(str, i + 2);
}
return count;
}
else if (str[i] == '2') {
count += f(str, i + 1);//i作为一个整体
if (i + 1 < str.size() && (str[i + 1] >= '0' && str[i + 1] <= '6')) {//除了保证i+1不越界,还要确保在20-26之内
count += f(str, i + 2);
}
return count;
}
else {//只剩下大于等于3小于等于9的情况了
count += f(str, i + 1);//i作为一个整体
return count;
}
}
int main() {
string strs = "220";
cout << f(strs, 0);
return 0;
}
接下来做一下力扣的题吧
和刚才的差不多,区别在于这个是0-25才是有效转化,还有就是给出的是整型,可以把它变为字符串就做,也可以直接整型做,但是我觉得应该要先计算整型的长度好做截取,所以我就索性转成字符串吧。
- 参数:strs//固定参数,i//开始翻译的下标
返回值:题目要求返回种数,这里就int- 这里终止条件就是空串,返回1,这里人家0可是有效的转化了
- 本层递归:注意特殊的0,当前位置是0的话,只能是0->a,然后去转化后面的字符。其余的和上一题一样。
补充:字符串转int:stoi(string s)
int转字符串:to_string(int i)
class Solution {
public:
int f(string strs,int i){//strs表示数字字符串,i表示转化的开始位置
if(i==strs.size()){//可以理解为空串就1种转化方法就是不用转化,也可以理解为已经转化为了,你把这一种返回吧
return 1;
}
if(strs[i]=='0'){//这能是0->a,后面的继续翻译
return f(strs,i+1);
}
int count=0;//翻译的种数
for(int j=i;j<strs.size();j++){//从i位置开始截取
string sub=strs.substr(i,j-i+1);//截取i到j的字符串
int a=stoi(sub);//string转整型
if(a<=25){
count+=f(strs,j+1);//sub可以翻译成有效字符串,找从j+1开始翻译的种数
}
else{//当前已经不符合了 后面的一定也不符合了,没有必要往后找了
break;
}
}
return count;
}
int translateNum(int num) {
string strs=to_string(num);
return f(strs,0);
}
};
这里也写一下简便的代码
class Solution {
public:
int f(string strs, int i) {//strs表示数字字符串,i表示转化的开始位置
if (i == strs.size()) {//可以理解为空串就1种转化方法就是不用转化,也可以理解为已经转化为了,你把这一种返回吧
return 1;
}
if (strs[i] == '0') {//只能是0->a,后面的继续翻译,这个可以和else放一起,都是只有一个分支
return f(strs, i + 1);
}
int count = 0;//翻译的种数
if (strs[i] == '1') {
count += f(strs, i + 1);//i作为一个整体
if (i + 1 < strs.size()) {//保证i+1不越界的情况下才考虑i i+1作为整体
count += f(strs, i + 2);
}
return count;
}
else if (strs[i] == '2') {
count += f(strs, i + 1);//i作为一个整体
if (i + 1 < strs.size() && (strs[i + 1] >= '0' && strs[i + 1] <= '5')) {//除了保证i+1不越界,还要确保在20-25之内
count += f(strs, i + 2);
}
return count;
}
else {//只剩下大于等于3小于等于9的情况了
count += f(strs, i + 1);//i作为一个整体
return count;
}
return count;
}
int translateNum(int num) {
string strs=to_string(num);
return f(strs,0);
}
};
经典的背包问题也是这个模型
- 题目描述:给定两个长度都为N的数组weight和values,weight[i]和values[i]分别表示i号物品的重量和价值。
给定一个正数bag,表示载重bag的袋子,你装的物品不能超过这个重量,返回你能装下最大的价值。
1085 背包问题
- 参数:weight values数组这都是固定参数,index:从index位置开始选择,rest:还剩rest的空间可以放物品
返回值:最大价值- 终止条件:如果rest<0:这个方案是无效的,返回-1.
如果rest等于0,不能再放物品了,返回0
如果index等于背包的大小,没有物品可以选了,也返回0
3.本层递归:no接住不选当前位置的物品的最大价值,yes接住选当前位置的最大价值,返回max(no,value[index]+yes)。
由于当前是暴力递归,所以这道题肯定不能完全通过的
#include<iostream>
#include<string>
#include<vector>
using namespace std;
int maxValue(vector<int>& weight, vector<int>& value, int index, int rest) {//index表示开始选择的位置,rest表示还剩多少空间
if (rest < 0) {
return -1;//无效方案
}
if (rest == 0) {//没有空间可以放了
return 0;
}
//rest大于0
if (index == weight.size()) {//没有货物可以选了
return 0;
}
int no = maxValue(weight, value, index + 1, rest);//不选index的物品,返回的一定是大于等于0的
int yes = maxValue(weight, value, index + 1, rest - weight[index]);//选择index的物品,返回有可能是-1
if (yes != -1) {
return max(no, yes + value[index]);
}
//如果yes==-1(no不可能是-1因为rest>=0)
return no;
}
int main() {
int n, w;
cin >> n >> w;
vector<int>weight(n);
vector<int>value(n);
for (int i = 0; i < n; i++) {
cin >> weight[i] >> value[i];
}
cout << maxValue(weight, value, 0, w);
return 0;
}
确定参数和返回值:一共有N个位置,开始位置是M,终点是P,要求走K步,所以参数是N,M,P,K,返回值是方案数
考虑终止条件:当K等于0时,如果M等于P,方案数为1,否则为0.
本层递归:有三种情况:
如果M等于1,只能往右走
如果M等于N,只能往左走
如果1<M<N,左右都可以走
int f1(int N,int P,int K,int M){//暴力递归
if(K==0){
return M==P?1:0;
}
if(M==1){
return f1(N,P,K-1,2);
}
if(M==N){
return f1(N,P,K-1,N-1);
}
return f1(N,P,K-1,M-1)+f1(N,P,K-1,M+1);
}
题目描述:给定一个正数数组(有重复值),找出组成k的最小个数。
例如:
输入 1 2 3 3 ,k=6
输出 2个(3+3)
- 暴力递归
- 确定参数和返回值:参数:数组,k,index(从当前位置开始往后找)。返回值:最小个数
- 终止条件:如果k<0,由于是正数数组,所以不可能组成负数面值,因此返回-1(表示找不到)
当k==0,表示找组成0面值的最小个数,因此返回0. 当index
==`数组的长度 且k>0,因为数组是从0开始的,当index等于数组的长度且k>0,所以已经没有其他数可以选了,因此也返回-1 - 本层递归:
剩下的情况就是0<=index<数组的长度,k>0
此时有两种选择,选当前位置的数:1+f(coins,index+1,rest-coins[index])
不选当前位置的数:f(coin,index+1,rest)
注意-1的处理
int f(vector<int>& coins,int index,int rest){//从index开始往后选,凑成rest面值所需的最小个数
if(rest<0 || index==coins.size()){
return -1;
}
if(rest==0){
return 0;
}
int selected=f(coins,index+1,rest-coins[index]);
int noSelected=f(coins,index+1,rest);
if(selected==-1 && noSelected==-1){//两种选择都找不到
return -1;
}
else if(selected==-1 && noSelected!=-1){
return noSelected;
}
else if(selected!=-1 &&noSelected==-1){
return selected+1;//index位置的数加index后面的数
}
else{
return min(1+selected,noSelected);
}
}
- 暴力递归
题目要求连续子数组的最大和,我们先求以某一个元素开始的最大值。
从左往右尝试,以某一个元素开始的最大值等于max(本元素,本元素+以后一个元素开始的最大值)
同时设一个变量,最大的最大值。-
很遗憾此解法会超时,毕竟暴力递归嘛!
class Solution {
public:
int maxVal;
int f(vector<int>& nums,int index){//以index开始的最大值
if(index==nums.size()-1){//只有一个元素可以选择
maxVal=maxVal>nums[index]?maxVal:nums[index];
return nums[index];
}
int lateMax=f(nums,index+1);//后一个元素开始的最大值
int curMax=max(nums[index],nums[index]+f(nums,index+1));//当前元素开始的最大值
maxVal=maxVal>curMax?maxVal:curMax;
return curMax;
}
int maxSubArray(vector<int>& nums) {
maxVal=nums[0];
f(nums,0);
return maxVal;
}
};
- 暴力递归:
参数:index:从index开始往后选择硬币,rest:要组成rest大小的金额
返回值:方法数
终止条件:当rest<0,返回0.当index等于数组大小,如果rest等于0,返回1,否则返回0
本层递归:从使用0张index位置面值的硬币开始递归,直到张数*index面值的硬币>rest为止,这里就是不确定个数的枚举行为。
暴力递归提交超时了
class Solution {
public:
int f(vector<int>&coins,int index,int rest){
if(rest<0){//可以删去
return 0;
}
if(index==coins.size()){
return rest==0?1:0;
}
int way=0;
for(int i=0;i*coins[index]<=rest;i++){//从使用0张index位置面值的开始枚举,在for循环中保证了i*coins[i]<=rest,所以包含出现rest-i*coins[i]<0的情况,所以rest<0这个终止条件可以删去
way+=f(coins,index+1,rest-i*coins[index]);
}
return way;
}
int change(int amount, vector<int>& coins) {
return f(coins,0,amount);
}
};
分析:可以尝试一行一行的摆,即每一行只摆一个皇后,那么在摆的过程中皇后一定不共行了,只需要判断共不共列、是否共斜线(两个方向的斜线)。
也是通过递归来解决:
- 参数:int record[n]:记录皇后的摆放位置,int i:该摆第i行(从0开始)的皇后了,int n:摆n个皇后
返回值:在0~i-1已经摆好的情况下,有多少种方案。- 终止条件:i等于n时,没有皇后可以摆了,此时返回1.
- 本层递归:现在该摆第i行的皇后,从i行0列开始试,如果当前位置与之前的皇后不冲突就在这里摆皇后,记录i行皇后的位置,然后去找下一行摆放的方案。如果冲突了就试下一列,一直试到n-1。
需要注意的是如果第j列摆放是合法的且第j+1列摆放也是合法的,当计算完第j列摆放的种数,此时record[i]里记录的是j,你可以把它清0,也可以不用清零,因为当第j+1列摆放时也会重新写入j+1(会覆盖原来的值)
再介绍一下判断共斜线的简便方法:假设两个点的坐标(a,b),(c,d),如果|a-c|等于|b-d|,那么这两个点共斜线。
class Solution {
public:
bool isValid(vector<int>&record,int i,int j){//判断摆放是否合法
for(int t=0;t<i;t++){//record[t]:第t行皇后摆放在了(t,record[t])
if(j==record[t] || abs(i-t)==abs(j-record[t])){//j==record[t]:共列,abs(i-t)==abs(j-record[t]):共斜线
return false;
}
}
return true;
}
int f(vector<int>&record,int i,int n){
if(i==n){//所有的皇后已经摆好了
return 1;
}
//还有皇后要摆
int count=0;
for(int j=0;j<n;j++){//计算0~n-1列摆放的方案数
if(isValid(record,i,j)){
record[i]=j;
count+=f(record,i+1,n);
//record[i]=0,这个其实是没有必要的,但写上符合回溯的结构
}
}
return count;
}
int totalNQueens(int n) {
vector<int>record(n);
return f(record,0,n);
}
};
下面介绍位运算的解法,应该是最快的方案了,时间复杂度O(nn),仅仅加速了常数项的时间
如果是8皇后问题,就用一个整型的二进制位的右8位表示皇后可以摆放的位置,对于每一层的递归都只需要维持4个变量,limit :固定量,限制问题的规模(是几皇后问题),clolim:列限制,只有为0的位上才可以放皇后,leftlim:左斜线限制,只有为0的位上才可以放皇后,rightlim:右斜线限制,只有为0的位上才可以放皇后。
因为位运算太抽象了没法用语言描述清楚直接看代码吧,代码注释应该写的够详细了!
补充二进制知识:取出二进制位最右边的1:pos & (~pos +1)
,
设置右边n位全为1:(1<<n)-1
class Solution {
public:
//如果是8皇后问题,limit最右边是8个1,其余位是0
//collim中的0表示可以放皇后,1表示这个位置其他行已经放过皇后了
//leftlim中的0表示可以放皇后
//rightlim中的0表示可以放皇后
int f(int limit,int collim,int leftlim,int rightlim){
//collim | leftlim | rightlim表示总限制,为0的位置才可以放皇后
if(collim==limit){//所有的列都摆上了皇后,也就意味着摆完了
return 1;
}
int pos=limit & (~(collim | leftlim | rightlim));//pos中的1表示可以放皇后,做这个运算的目的一是方便取每个可以放皇后的位置,二是防止越界,例如8皇后问题,如果第9位为1是无效的,通过这个运算可以消除无效的1
int count=0;
while(pos!=0){//找pos最右边的1,pos中1的个数就是尝试的方案数
int mostRightOne=pos & (~pos +1);//得到最右边的1,这个位置就可以放皇后,假设pos为01110000,mostRightOne就是00010000
pos=pos-mostRightOne;//消去这个位置的1,当然可以用异或pos^mostRightOne代替
count+=f(limit,collim | mostRightOne,
(leftlim | mostRightOne)<<1,
(rightlim | mostRightOne)>>1
);//limit | mostRightOne:新的列限制,(leftlim | mostRightOne)<<1:新的左斜线限制,(rightlim | mostRightOne)>>1:新的右斜线限制
}
return count;
}
int totalNQueens(int n) {
int limit=n==32?-1:(1<<n)-1;//limit右边n位全为都为1
return f(limit,0,0,0);//从第一行开始存放,所以第一行的列限制 左斜线限制 右斜线限制都为0,可以随便放
}
};
上一个真正会了这个就不成问题了,这个特别要注意回溯了
class Solution {
public:
vector<vector<string>>re;
bool isValid(vector<int>record,int i,int j){
for(int t=0;t<i;t++){
if(j==record[t] || abs(i-t)==abs(j-record[t])){
return false;
}
}
return true;
}
void f(vector<int>record,vector<string>v,int i,int n,string& path){//n和path都是固定参数
if(i==n){
re.push_back(v);
return ;
}
for(int j=0;j<n;j++){
if(isValid(record,i,j)){
record[i]=j;
string s=path;
s[j]='Q';//j位置摆上皇后
v.push_back(s);
f(record,v,i+1,n,path);
v.pop_back();//回溯
}
}
}
vector<vector<string>> solveNQueens(int n) {
vector<string>v;
string path;
for(int i=0;i<n;i++){//默认什么也不放
path.push_back('.');
}
vector<int> record(n);
f(record,v,0,n,path);
return re;
}
};
总结
从左往右的尝试模型一般都是从0出现,逐渐缩小所需问题的规模,关键是搞清楚本层递归返回值的含义以及本层递归所做的选择。