今天天气晴朗,你打算去树木园探险。土地爷爷跟你开了个玩笑:
“咱们玩个游戏吧!我心中想一个1~100之间的数字,你的任务是猜出它。
如果你猜小了,我会告诉你猜小了。
如果你猜大了,我会告诉你猜大了。但是,一旦你猜大了一次后,那么下一次你再猜的时候,我只会告诉你猜对了或者猜错了,而不会告诉你大小。
你猜对了,我便会告诉你。”
你在想:这老头,必定是太久没人过来看他,闷得慌。那我就大发慈悲地陪你玩玩。
于是你说:“请问我有多少次机会呢?”
“无限次。”
“好!”
你心想:为了谨慎起见,一开始不能猜太大。既然不能二分,那就四分。说不定四分就是最优解。
于是你说:“25。” “猜小了。”
“37。” “猜小了。”
“42。” “猜小了。”
“66。” “猜大了。”
你心想:这下糟了。剩下的数字只能随机猜了。早知道不猜那么大了。
“49。” “猜错了。”
“63。” “猜错了。”
你心想:我的幸运数字到底是多少呢?来个质数。
“43。” “恭喜你,猜对了。”
最终你获得了树木园探险之旅的入场券。
离开后,你越想越不甘心:我要找出最优解!回去写个brute force(暴力破解)的代码。
思考过程:
假设你第一次猜25。如果猜大了,剩下的1~24只能随机猜了。那么,在运气最差的情况下,你需要猜24次。如果猜小了,剩下的26~100又该如何去猜呢?
灵机一动。若能知道猜26~100的最少次数,就能知道,在第一次猜25的前提下,猜1~100的最少次数了。
定义 “猜a~b的最少次数”:对于土地爷爷心中想的a~b之间的那个数字(包括a和b),你猜出它所需的最少次数。
换句话说,无论土地爷爷心中所想的是哪一个数,都能保证在“最少次数”内猜对。
咦——这难道是dynamicprogramming(动态规划)?
因此,在第一次猜25的前提下,猜1~100的最少次数=max(24,猜26~100的最少次数)+1,其中max是取两者中的大的那个。+1是因为要加上第一次猜的那个25。
为什么是max呢?如果猜对了,当然最好啦,下一次就不用再猜了嘛。而猜大了和猜小了是两种不一样的情况,需要分开讨论。
于是乎,你便有了以下的思路:
猜1~100的最少次数=
在第一次猜1的前提下,=猜2~100的最少次数+1(因为不可能猜大了);
在第一次猜2的前提下,=max(1,猜3~100的最少次数)+1;
在第一次猜3的前提下,=max(2,猜4~100的最少次数)+1;
。。。。。。
在第一次猜24的前提下,=max(23,猜25~100的最少次数)+1;
在第一次猜25的前提下,=max(24,猜26~100的最少次数)+1;
在第一次猜26的前提下,=max(25,猜27~100的最少次数)+1;
。。。。。。
在第一次猜99的前提下,=max(98,猜100~100的最少次数)+1;
在第一次猜100的前提下,=99+1=100(因为不可能猜小了);
综上可得,取上述100个值的最小者,因为不知道第一次要猜哪个数好。
假设第一次猜25是最优策略,那么猜26~100的最少次数可以同理递归地算下去。
用一个二维数组Guess来保存中间状态,也就是说Guess[a][b]保存猜a~b的最少次数(1<=a<=b<=100)。这么一来,时间复杂度和空间复杂度都是O(n^2)。并没有什么优惠。
转念一想,猜1~20、15~34、71~90的最少次数必定是一样的。最优策略的第一次猜的数的相对位置是一样的。比如,猜1~20,假设第一次应该猜7:那么猜15~34时,第一次应该猜21;猜71~90时,第一次应该猜77。
这样一来,Guess数组只需保存不同区间长度对应的最少次数。也就是说,Guess[n]保存猜1~n的最少次数。空间复杂度降到了O(n)。
为了知道完整的猜数过程,记录猜的最少次数的同时,还需记录猜的该范围的第几个数。所以Guess[n]要保存猜1~n的最少次数和第一次要猜的数的相对位置(下标从1开始)。
边界情况:猜1~n(n=1)的最少次数和第一次要猜的数的相对位置都是1啦,因为只有一个数可以猜,就是它自己。
此刻,小精灵在你耳边细语:“动态规划:先解决更小的子问题,再解决更大的子问题。(Introduction to Algorithms, 3rd ed. P424)尽管思想是递归的,但是实现可以是迭代的。”
(代码和程序完整输出在文章末尾。)
程序关于猜数过程的输出:
猜1~100的最少次数是14
猜数过程:
9-->22-->34-->45-->55-->64-->72-->79-->85-->90-->94-->97-->99
所以,正确的猜数过程就是上面打印的顺序。若在某一次猜大了,剩下的范围随机猜就行了。
其实,上述序列不唯一。换一种思路,既然有14次机会,那么
第1次,1~100,猜14。剩下13次机会:若猜大了,用于随机猜1~13;若猜小了,用于猜15~100。
第2次,15~100,猜15+13-1=27。剩下12次机会:若猜大了,用于随机猜15~26;若猜小了,用于猜28~100。
第3次,28~100,猜28+12-1=39。剩下11次机会:若猜大了,用于随机猜28~38;若猜小了,用于猜40~100。
第4次,40~100,猜40+11-1=50。剩下10次机会:若猜大了,用于随机猜40~49;若猜小了,用于猜51~100。
第5次,51~100,猜51+10-1=60。剩下9次机会:若猜大了,用于随机猜51~59;若猜小了,用于猜61~100。
第6次,61~100,猜61+9-1=69。剩下8次机会:若猜大了,用于随机猜61~68;若猜小了,用于猜70~100。
第7次,70~100,猜70+8-1=77。剩下7次机会:若猜大了,用于随机猜70~76;若猜小了,用于猜78~100。
第8次,78~100,猜78+7-1=84。剩下6次机会:若猜大了,用于随机猜78~83;若猜小了,用于猜85~100。
第9次,85~100,猜85+6-1=90。剩下5次机会:若猜大了,用于随机猜85~89;若猜小了,用于猜91~100。
第10次,91~100,猜91+5-1=95。剩下4次机会:若猜大了,用于随机猜91~94;若猜小了,用于猜96~100。
第11次,96~100,猜96+4-1=99。剩下3次机会:若猜大了,用于随机猜96~98;若猜小了,用于猜100~100。
第12次,100~100,猜100。
你发现了规律。设x为猜1~100的最少次数,那么x满足
x+(x-1)+(x-2)+…+2+1>=100
解得x>=14。
最优策略就是,无论猜大了或是猜小了,接下来仍需猜的次数应尽可能接近,最好相等。
此刻,你接收到了小精灵的心灵感应:“贪心算法:每一步都是当前最优(局部最优),走到最后便是全局最优。(Introduction to Algorithms, 3rd ed. P424)”
你发现了秘密:第一次猜9~14的任意一个数都无所谓。因为14次机会,不仅可以猜1~100,而且可以猜1~105。
下次去树木园就好玩了!每次都猜同一串数字,那该多无趣啊!
#include<cstdio>
#include<algorithm>
using namespace std;
const int ultimate_interval_length=100;
struct {
int number;
int minimal_times;
}Guess[ultimate_interval_length+1];
void print_Guess_array(void)
{
printf("Guess数组:\n");
printf("i:猜的数字 猜的次数\n");
for(int interval_length=0;interval_length<=ultimate_interval_length;interval_length++){
printf("%-3d: %3d %3d\n",interval_length,Guess[interval_length].number,Guess[interval_length].minimal_times);
}
}
void print_guessing_process(int a,int b)
{
printf("猜%d~%d的最少次数是%d\n",a,b,Guess[b-a+1].minimal_times);
printf("猜数过程:\n");
int isfirst=1;
while(a<b){
if(isfirst) isfirst=0;
else printf("-->");
printf("%d",Guess[b-a+1].number+a-1);
a+=Guess[b-a+1].number;
}
putchar('\n');
}
int main(void)
{
Guess[1].number=1;
Guess[1].minimal_times=1;
for(int interval_length=2;interval_length<=ultimate_interval_length;interval_length++){
//猜第1个数,猜小了
Guess[interval_length].number=1;
int minimal_times=Guess[interval_length-1].minimal_times+1;
//猜中间(除去首尾2个)的数
for(int guessing_number=2;guessing_number<=interval_length-1;guessing_number++){
int minimal_times_for_guessing_number=max(guessing_number-1,Guess[interval_length-guessing_number].minimal_times)+1;//max(猜大了,猜小了)
if(minimal_times_for_guessing_number<minimal_times){
minimal_times=minimal_times_for_guessing_number;
Guess[interval_length].number=guessing_number;
}
}
//猜最后一个数,猜大了
if(interval_length<minimal_times){
minimal_times=interval_length;
Guess[interval_length].number=interval_length;
}
Guess[interval_length].minimal_times=minimal_times;
}
print_Guess_array();
print_guessing_process(1,ultimate_interval_length);
return 0;
}
程序完整输出:
Guess数组:
i:猜的数字 猜的次数
0 : 0 0
1 : 1 1
2 : 1 2
3 : 2 2
4 : 1 3
5 : 2 3
6 : 3 3
7 : 1 4
8 : 2 4
9 : 3 4
10 : 4 4
11 : 1 5
12 : 2 5
13 : 3 5
14 : 4 5
15 : 5 5
16 : 1 6
17 : 2 6
18 : 3 6
19 : 4 6
20 : 5 6
21 : 6 6
22 : 1 7
23 : 2 7
24 : 3 7
25 : 4 7
26 : 5 7
27 : 6 7
28 : 7 7
29 : 1 8
30 : 2 8
31 : 3 8
32 : 4 8
33 : 5 8
34 : 6 8
35 : 7 8
36 : 8 8
37 : 1 9
38 : 2 9
39 : 3 9
40 : 4 9
41 : 5 9
42 : 6 9
43 : 7 9
44 : 8 9
45 : 9 9
46 : 1 10
47 : 2 10
48 : 3 10
49 : 4 10
50 : 5 10
51 : 6 10
52 : 7 10
53 : 8 10
54 : 9 10
55 : 10 10
56 : 1 11
57 : 2 11
58 : 3 11
59 : 4 11
60 : 5 11
61 : 6 11
62 : 7 11
63 : 8 11
64 : 9 11
65 : 10 11
66 : 11 11
67 : 1 12
68 : 2 12
69 : 3 12
70 : 4 12
71 : 5 12
72 : 6 12
73 : 7 12
74 : 8 12
75 : 9 12
76 : 10 12
77 : 11 12
78 : 12 12
79 : 1 13
80 : 2 13
81 : 3 13
82 : 4 13
83 : 5 13
84 : 6 13
85 : 7 13
86 : 8 13
87 : 9 13
88 : 10 13
89 : 11 13
90 : 12 13
91 : 13 13
92 : 1 14
93 : 2 14
94 : 3 14
95 : 4 14
96 : 5 14
97 : 6 14
98 : 7 14
99 : 8 14
100: 9 14
猜1~100的最少次数是14
猜数过程:
9-->22-->34-->45-->55-->64-->72-->79-->85-->90-->94-->97-->99