要点
-
对数器打表找规律的使用场景: 输入参数是简单类型,返回值也是简单类型。
-
对数器打表找规律的过程:
-
可以用最暴力的实现求入参不大情况下的答案,往往只需要最基本的递归能力
-
打印入参不大情况下的答案,然后观察规律
-
把规律变成代码,就是最优解了
小明用袋子买苹果
我们通过这个 【小明用袋子买苹果的问题】 来简单了解一下对数器打表找规律是怎样工作的:
小明去商店买苹果,发现只有【能装8个苹果的袋子】和【能装6个苹果的袋子】,节俭的小明只有当自己手上的袋子全部被装满时才会买苹果,现在我想知道小明买苹果需要用掉多少个袋子。
给定n个苹果,如果刚好能装满所有袋子,则返回至少要多少个袋子;如果不存在每个袋子都装满的情况,则返回-1。
示例:
1.买10个苹果 需要装满8苹果的袋子,但是还需要一个不能装满6苹果的袋子 返回-1。
2.买14个苹果 需要一个装满8苹果的袋子和一个装满6苹果的袋子 返回2。
我们先来看看常规解法(暴力递归解法):
首先想到的一定是动态规划解法,给出问题框架如下:
//输入参数为苹果数x
int AppleMinBags(int x){
int result ;
.....
return result;
}
从后往前考虑,不就是 AppleMinBags(x) = min(AppleMinBags(x-8),AppleMinBags(x-6)) + 1
嘛。递归表达式已经出来了,现在我们该考虑边界条件了,这道题的边界条件是什么呢?
很简单,就是 x小于6且不等于0 对应的AppleMinBags(x)
无效,那对于这种无效情况我们应该返回什么呢?
既然是求需要袋子的最小个数,那我们就让无效的AppleMinBags(x) = INT_MAX
,但是我们想想,如果递归栈顶的结果是INT_MAX,那我下一层还能+1吗?显然不能,所以我们要改一下递归表达式:
如果递归栈顶的结果是INT_MAX,那我们只能+0,如果递归栈顶的结果不是INT_MAX,那我们正常+1即可。
【由于我们有6容量的袋子,所以最终的苹果数就是6的余数 0,1,2,3,4,5。0则返回0,1,...,5则继续-6或-8,返回INT_MAX】
常规解法代码如下:
//输入参数为苹果数x
int AppleMinBags(int x){
return dp(x) == INT_MAX ? -1, dp(x);
}
int dp(int x){
//边界条件
if(x == 0) return 0;
if(x < 0) return INT_MAX; //无效解
int p1 = dp(x-8); //用8容量袋子装
int p2 = dp(x-6); //用6容量袋子装
p1 += (p1 == INT_MAX ? 0, 1); //如果栈顶无效,则新栈顶继续维持无效(+0)
p2 += (p2 == INT_MAX ? 0, 1);
return std::min(p1, p2);
}
对数器打表找规律:
当我们遇到的问题较为复杂时,常规解法可能不能立刻得出来,这时候我们可以先打印出前几个值来找出大概规律。然后把这个大概规律用代码描述出来,这样可以得到比常用递归暴力解法时间复杂度低很多的解法。
由于篇幅有限,读者可自行打印出前100个数据进行观察规律,我这里只给出前四十个数据的表格。
打印代码:
//打印前一百个数观察规律
int main(){
for(int i = 1; i <= 100; i++){
cout << i << " : " << AppleMinBags(i) << endl;
}
return 0;
}
下面是前四十个数据的表格:
1 : -1 | 11 : -1 | 21 : -1 | 31 : -1 |
---|---|---|---|
2 : -1 | 12 : 2 | 22 : 3 | 32 : 4 |
3 : -1 | 13 : -1 | 23 : -1 | 33 : -1 |
4 : -1 | 14 : 2 | 24 : 3 | 34 : 5 |
5 : -1 | 15 : -1 | 25 : -1 | 35 : -1 |
6 : 1 | 16 : 2 | 26 : 4 | 36 : 5 |
7 : -1 | 17 : -1 | 27 : -1 | 37 : -1 |
8 : 1 | 18 : 3 | 28 : 4 | 38 : 5 |
9 : -1 | 19 : -1 | 29 : -1 | 39 : -1 |
10 : -1 | 20 : 3 | 30 : 4 | 40 : 5 |
我们发现,前十八个元素中,6、8对应1个袋子,12、14、16对应两个袋子;而18之后,18、20、22、24对应3个袋子,26、28、30、32对应
4个袋子,34、36、37、40对应5个袋子,...。你观察你打印的100个数据能够更加确信这个规律。
现在我们用代码表示这个规律:
int AppleMinBags(int n){
//由于 6 + 8 只能为偶数,所以奇数只能返回-1
if(n % 2 != 0) return -1;
if(x == 6 || x == 8) return 1;
if(x == 14 || x == 16 || x == 12) return 2;
if(x >= 18) return (x-18)/8 + 3;
}
将这段代码代入运行,会得出和暴力递归解法一样的结果。
AB轮流吃草最终谁会赢
简单介绍一下这个例子,草场上有n份草,小牛【小薪】和小牛【小楠】起了争执,谁把最后一份草吃完让对方无
草可吃谁就赢,两只牛每次吃草只能吃4的某次方份草(也就是4^x份草,1,4,16,64...)。在一轮回合中,【小薪】先
吃草,【小薪】和【小楠】都在竭尽全力让自己赢,也就是说决策双方都是在做理智最优决策【理智最优决策可以
自行搜索博弈论的基本策略】,请问最终谁会赢?
还是先按照常规递归思路来分析题目:
当草场上没有草,即有0份草时,A先遇到无草可吃的情况,B赢;有1份草时,A选择吃1份草,轮到B时就只剩0份草,A赢;有2份草时,A只能选择吃1份草,轮到B时B也选择吃1份草,进入下一回合时A先无草可吃,B赢;有3份草时,......,A赢;有4份草时,A吃4份草,B就面临0份草的情况了,......。
从这前5个数据可以看出简单规律,设cur
为当前选手,enemy
为另一选手,草数少于5时,当草数为0或2时,enemy
赢,否则cur
赢。
草数大于4时能将情况转化为小于4的情况吗?由于这个问题是一个博弈论的问题,【小薪】和【小楠】都有诸多选择,并且他们都会选择对自己最有益的选择。举个例子:
假设轮到【小薪】吃草,并且还剩下100份草,【小薪】有以下几种选择:
吃 1份草 --> 剩下99份,【小楠】吃
吃 4份草 --> 剩下96份,【小楠】吃
吃16份草 --> 剩下84份,【小楠】吃
吃64份草 --> 剩下36份,【小楠】吃
如果剩下rest份,【小楠】吃的结果是【小薪】赢,那么小薪就会选择当前方案。如果所有选择的结果都是【小薪】输,那么直接返回【小楠】赢就可以了。
以下是常规解法框架:
string Win(int x){
string result;
result = dp(x , "A");
return result;
}
string dp(int rest, string cur){
string enemy = (cur == "A" ? "B" : "A");
if(rest < 5){
if(rest == 0 || rest == 2) return enemy;
return cur;
}
int pick = 1;
while(pick <= rest){ //进入循环说明当前草数可以做出此决定
if(dp((rest - pick), enemy) == cur){
return cur;
}
pick *= 4;
}
//cur做出任何选择都赢不了,那么最终赢家肯定是enemy
return enemy;
}
现在我们把数据打印出来看规律:
打印代码:
int main(){
for(int i = 0; i < 44; i++){
cout << i << " : " << Win(i) << endl;
}
return 0;
}
以下是前44个数据的表格:
0 : B | 11 : A | 22 : B | 33 : A |
---|---|---|---|
1 : A | 12 : B | 23 : A | 34 : A |
2 : B | 13 : A | 24 : A | 35 : B |
3 : A | 14 : A | 25 : B | 36 : A |
4 : A | 15 : B | 26 : A | 37 : B |
5 : B | 16 : A | 27 : B | 38 : A |
6 : A | 17 : B | 28 : A | 39 : A |
7 : B | 18 : A | 29 : A | 40 : B |
8 : A | 19 : A | 30 : B | 41 : A |
9 : A | 20 : B | 31 : A | 42 : B |
10 : B | 21 : A | 32 : B | 43 : A |
看到这些数据你会惊讶的发现,结果竟然是BABAA,BABAA,......,BABAA
的方式出现,也就是以BABAA
为周期呈现结果。我们根据打表出来的结果直接给出代码:
string Win(int n){
if(n % 5 == 0 || n % 5 == 2){
return "B";
}
return "A";
}
找出这种简单规律,能够将时间复杂度大大降低,打表也只是一种对递归解法的优化手段。
判断一个数是否为若干数量(数量 > 1)的连续正整数的和
这个问题说的是任取一个正数,判断它是否为连续正整数的和。比如 12 = 3 + 4 + 5,是3个连续正整数的和,而8 = 8,只能由一个正整数8构成,所以它不是连续正整数的和。
和之前一样,我们先看看常规解法:
//采用最简单的遍历方法
bool IsNum(int n){
for(int i = 1;i < n;i++){
//从i开始,i,i+1,i+2,....,.....累加之后能到n
int rest = n;
for(int j = i;rest > 0;j++){
rest -= j;
if(rest == 0) return true;
}
}
return false;
}
从这段常规解法代码很容易看出来,判断这个数是不是连续正整数的和就是从左往右一个一个试,时间复杂度极大,为O(n^2)。
前四十个正整数数据:
1 : false | 11 : true | 21 : true | 31 : true |
---|---|---|---|
2 : false | 12 : true | 22 : true | 32 : false |
3 : true | 13 : true | 23 : true | 33 : true |
4 : false | 14 : true | 24 : true | 34 : true |
5 : true | 15 : true | 25 : true | 35 : true |
6 : true | 16 : false | 26 : true | 36 : true |
7 : true | 17 : true | 27 : true | 37 : true |
8 : false | 18 : true | 28 : true | 38 : true |
9 : true | 19 : true | 29 : true | 39 : true |
10 : true | 20 : true | 30 : true | 40 : true |
从这四十个数据就能看出来,2^x(x=0,1,2,...)的数不是连续的正整数和,其他数都是连续的正整数和。
现在给出打表找出的代码:
bool IsNum(int n){
if(n & (n-1) == 0) return false;
return true;
}
要求一个长度>=2的回文子串,求所有长度为n的red字符串中好串的数量
简要介绍一下”好串“的概念,”好串“指的是,如果字符串内部有且只有一个长度>=2的回文子串,那么这个串就叫子串。”回文子串“指的是正反序列一致的字符串。
示例:
-
”aba”
有长度为3的回文子串“aba”
,所以“aba”
是好串。 -
“ababc”
有长度为3的回文子串“bab”
和“aba”
,所以“aba”
不是好串。 -
同理,
“1221”
不是好串 ,“12321”
是好串。
这个题目的要求就是,字符串中的字符只能从 ‘r’ ,’e’ ,’d’ 三个字符中选,用这三个字符拼出长度为n的字符串,求长度为n的好串的个数。
先看暴力解法:
我们先用最简单的方法得出答案,虽然这种方法提交时会超时,不过我们只对前一小部分的数据进行打印,不会出现运行很久才出结果的情况。
要想知道n长度的所有字符串中好串有多少个,很简单,把长度为n的所有字符串都列出来,一个一个验证其长度>=2的回文子串数量是否为1即可。至于如何验证回文子串,利用双指针(首尾指针)即可。
暴力解法代码如下:
int f(vector<char>& path, int i, int n);
bool is(vector<char>& path, int left, int right);
int numOfGoodString(int n) {
vector<char> path(n);
return f(path, 0, n);
}
int f(vector<char>& path, int i, int n) {
// 递归结束条件
if (i == n) {
int cnt = 0;
// 枚举所有子串
for (int j = 0; j < n; j++) {
for (int k = j + 1; k < n; k++) {
if (is(path, j, k)) {
cnt++;
}
if (cnt > 1) {
return 0;
}
}
}
return cnt == 1 ? 1 : 0;
}
// DFS
int ans = 0;
path[i] = 'r';
ans += f(path, i + 1, n);
path[i] = 'e';
ans += f(path, i + 1, n);
path[i] = 'd';
ans += f(path, i + 1, n);
return ans;
}
// 判断子串是否是回文子串
bool is(vector<char>& path, int left, int right) {
while (left <= right) {
// 结束条件
if (path[left] != path[right]) return false;
left++;
right--;
}
return true;
}
同学们看这段代码不难看出,暴力解法的解法流程如下:
先从第0层(没有元素)节点开始,进行DFS
遍历,通过三个分支进入下一层;如此递归下去,当递归到第n层时(此时字符数组中刚
好有n个元素,对应长度为n的字符串),开始判断此串是否为“好串”。判断此串是否为“好串”是用到了暴力枚举,对所有长度>=2的子串
进行暴力枚举并用计数器记录【初始为0,如果有一个子串是长度>=2的回文子串,则计数器+1】,如果计数器计数为1,则此串为“好
串”。
值得一提的是,我们之前使用DFS
算法进行递归的时候,都是自身带数据进节点内部,考虑进入此节点拿到结果后回溯退出的情况比较少,在这段代码中,我们就是将所有子节点的结果全部回溯到父节点中,用ans
记录此分支的“好串”数量。这样按步就班地回溯每个分支,就能在最初的f(path, 0 , n)
处拿到长度为n的所有字符串中好串数量。
前19个数据表格:
1 : 0 | 11 : 72 |
---|---|
2 : 3 | 12 : 78 |
3 : 18 | 13 : 84 |
4 : 30 | 14 : 90 |
5 : 36 | 15 : 96 |
6 : 42 | 16 : 102 |
7 : 48 | 17 : 108 |
8 : 54 | 18 : 114 |
9 : 60 | 19 : 120 |
10 : 66 |
这个简单规律我就不赘述了哈,偷点懒(手动狗头)。
直接给出代码:
int numOfGoodString(int n){
if(n == 1) return 0;
if(n == 2) return 3;
if(n == 3) return 18;
if(n >= 4){
return 30+6*(n-4);
}
}
以上就是我对于对数器打表找规律这一知识点做的详细笔记了,如果同学们想要通过看视频方式学习【对数器打表找规律】,可以b站搜索左程云。