猜数字的一种解法
本文将给出解答猜数字游戏(guess number)的
一种
算法,最多 6
步就可猜出结果。这个
的解法需要用到部分信息论的知识
。
猜数字问题表述如下: 答案是 0 至 9 十个数的四位排列(没有重复元素)。每猜一次,游戏比较答案和输入这两组排列,反馈 aAbB 。 aA 表示有 a 个数字数值正确位置正确, bB 表示 b 个数字数值正确位置错误。
考虑问题的互动方式,这道题目属于输入反馈的问题。 每次输入,从反馈中获取部分信息,当获得系统的全部信息时,就能猜出答案。所以解这个问题的关键是寻找合适的输入使反馈的信息量最大。
这道题的解法思路 比较简单,由于是有穷状态,而且数量不大,可用穷举法解答。比较所有可能的输入,找出最佳输入(某个标准下)。 问题变成了如何衡量输入的好坏。输入越好则反馈的信息越大,山农给出的信息量的定义是衡量输入好坏的标准。信息量是不确定性的大小,如果某个事件的概率为 P ,则它 含有的信息量为 log(1/P) 。具体的理论可以参考一本关于信息论的书。
具体到猜数字这个题目,答案有多种可能。得到反馈前,每一个输入的反馈有不同的可能。得到反馈后,反馈是确定的,不确定性变为 0。这样 问题的信息量变小了。前后的信息量之差就是这个输入能够得到的信息量。
//
把输入和所有可能的答案比较,得到所以不同反馈可能出现的次数
//
输入参数:input输入
//
输出参数:feedbacks反馈可能出现的次数
void GetPsbFeedbacksNum(VectorInt
&
feedbacks,
const
VectorInt
&
input)
{
//
和所有可能的答案比较
for
(
int
j
=
0
; j
<
ALL_ANS_NUM; j
++
)
{
//
答案是否已经被排除
if
(g_AnsState[j] !
=
0
)
continue;
feedbacks[GetFeedback(input, g_AllAns[j])]
++
;
}
}
计算这个输入能够获得的信息量
//
计算信息量
//
输入参数:不同反馈出现的次数
//
返回值:信息量
double
CalcEntropy(
const
VectorInt
&
feedbacks)
{
int
sum
=
0
;
double
entropy
=
0
.;
for
(
int
j
=
0
; j
<
feedbacks.size(); j
++
)
sum
+=
feedbacks[j];

for
(
int
j
=
0
; j
<
feedbacks.size(); j
++
)
{
if
(feedbacks[j]
==
0
)
continue;
float tmp
=
(float)feedbacks[j]
/
sum;
entropy
+=
-
1
*
tmp
*
log
(tmp);
}
return entropy;
}
这样就得到每个输入的信息量。下面讨论如何枚举所有可能的输入。
输入的种类有10*9*8*7种,把每一种输入和所有可能答案比较。这方法比较直观,但运算次数太多。
//
得到最佳的输入,不分类直接比较的方法
//
最佳输入存放在g_AnsGuess中
void GetBestInputNoSort()
{
double
max_entropy
=
0
.;
//
存放最大的信息量
VectorInt feedbacks((ANS_SIZE
+
1
)
*
(ANS_SIZE
+
1
),
0
);
//
反馈的结果分类

//
比较所有输入的信息量
for
(
int
i
=
0
; i
<
ALL_ANS_NUM; i
++
)
{
//
这个输入的信息量是否是0
if
(g_InputState[i]
==
1
)
continue;

//
是否只在可能的答案中搜索最佳输入
//
if
(g_AnsState[i] !
=
0
)
//
continue;

//
得到所有可能的反馈
feedbacks.assign((ANS_SIZE
+
1
)
*
(ANS_SIZE
+
1
),
0
);
GetPsbFeedbacksNum(feedbacks, g_AllAns[i]);

//
计算信息量
double
entropy
=
CalcEntropy(feedbacks);

//
排除信息量为0的输入
if
(entropy
<
0.00001
)
{
g_InputState[i]
=
1
;
continue;
}

//
求最大的信息量
if
(max_entropy
<
entropy)
{
max_entropy
=
entropy;
g_AnsGuess
=
g_AllAns[i];
}
}
}
由于第一次输入,各种输入能够得到的信息量是一样的,因此第一次可以不比较,任给一组输出。改进后的代码如下:
//
得到最佳的输入,不分类直接比较的方法,但优化过第一次输入。
//
最佳输入存放在g_AnsGuess中
void GetBestInputIpvFst()
{
//
如果是第一次,所有输入的信息量是一样的,任选一个
if
(g_isFirstTime)
{
g_isFirstTime
=
0
;
g_AnsGuess
=
g_AllAns[
0
];
}
else
GetBestInputNoSort();
}
第一次输入时10个元素之间是没有分别的。沿着这个思路考虑,第二次输入时,6个没有参与输入的元素,也没有分别。举例来说,第一次输入{0,1,2,3},则4,5,6,7,8,9六个元素属同一类元素,在第二次输入时可以相互替换。如{0,1,2,4}和{0,1,2,5}两组输入能够得到的信息量应该相同。把元素分类枚举则可以优化算法。
分类方法如下,参入输出的元素单独成一类,没有参与的元素是一类。第一次输入时,只有一类元素,这类元素个数为10。第二次输入时,有五类元素,有四类元素个数为1,一类元素个数为6。依次类推。关于如何枚举见 《从集合中枚举子集》。
//
得到最佳的输入,元素分类的方法
//
最佳输入存放在g_AnsGuess中
void GetBestInputSort()
{
double
max_entropy
=
0
.;
//
存放最大的信息量
VectorInt feedbacks((ANS_SIZE
+
1
)
*
(ANS_SIZE
+
1
),
0
);
//
反馈的结果分类

//
初始化枚举
CSetIterAgent
<
int
>
iter(g_AnsEle);
iter.Init(ANS_SIZE, CSetIter::MULT_ORDERED_IN);

//
比较所有输入的信息量
VectorInt setsub(ANS_SIZE,
0
);
while
(iter.GetNextSubset(setsub, g_AnsEle))
{
//
把
-
1元素具体化,
-
1表示该元素没有输入过
AssembleInput(setsub);

//
//
这个输入的信息量是否是0
//
if
(g_InputState[GetSetPosition(setsub)]
==
1
)
//
continue;

//
是否只在可能的答案中搜索最佳输入
//
if
(g_AnsState[GetSetPosition(setsub)] !
=
0
)
//
continue;

//
得到所有可能反馈的数目
feedbacks.assign((ANS_SIZE
+
1
)
*
(ANS_SIZE
+
1
),
0
);
GetPsbFeedbacksNum(feedbacks, setsub);

//
计算信息量
double
entropy
=
CalcEntropy(feedbacks);

//
//
排除信息量为0的输入
//
if
(entropy
<
0.00001
)
//
{
//
g_InputState[GetSetPosition(setsub)]
=
1
;
//
continue;
//
}

//
求最大的信息量
if
(max_entropy
<
entropy)
{
max_entropy
=
entropy;
g_AnsGuess
=
setsub;
}
}
//
元素分类
SortEle();
}
分类的方法可以大大提升运算速度,下面的测试会证明这一点。但当参与输入元素慢慢增加时,分类就不那么重要,而且调用CSetIterAgent也是一笔不小的开销。将第一种算法和分类法结合:
//
不分类法和分类法的混合法
//
输入参数:num表示当输入元素个数等于或超过num时用直接法
void GetBestInputBlend(
int
num)
{
//
计算没有输入过的元素个数
int
sum
=
0
;
for
(
int
i
=
0
; i
<
g_AnsEle.size(); i
++
)
{
if
(g_AnsEle[i]
==
-
1
)
sum
++
;
}
sum
=
g_AnsEle.size()
-
sum;

if
(sum
>=
num)
GetBestInputNoSort();
else
GetBestInputSort();
}
下面给出这些算法的测试结果。测试方法是将10*9*8*7种答案依次用这些算法求解,统计猜出每个答案需要多少次数。一共花费了多少时间。
1次 1
2次 12
3次 261
4次 2082
5次 2500
6次 184
平均次数:4.51190
N次是指N次输入反馈后,就可以确定答案,并不指第N次输入的反馈是4A0B。
花费的时间:
GetBestInputNoSort: 8h也没有算完
GetBestInputIpvFst: 9385s
GetBestInputSort: 1596s
GetBestInputBlend7: 2151s
GetBestInputBlend10: 1515s
这些方法在本质上是一样的,即在所有的输入中寻找反馈信息量最大的输入。下面考虑其他方法,比较这些方法的差别。
如果缩小寻找范围,不考虑不是答案的输入。代码如下:
取消GetBestInputNoSort函数中
//
是否只在可能的答案中搜索最佳输入
if
(g_AnsState[i] !
=
0
)
continue;
和GetBestInputSort函数中
//
是否只在可能的答案中搜索最佳输入
if
(g_AnsState[GetSetPosition(setsub)] !
=
0
)
continue;
这两行注释。
测试结果如下:
1次 1
2次 16
3次 228
4次 1555
5次 2688
6次 542
7次 10
平均次数:4.70218
运行时间:
GetBestInputIpvFst:1375s
GetBestInputSort: 97s
缩小范围后,速度有很多提升,但平均次数增加了。
再考虑另外一种方法,不从信息量的角度考虑。用第一个可能为答案的排列作为输入。
//
得到第一个可能的答案
void GetFstPsbInput()
{
for
(
int
i
=
0
; i
<
ALL_ANS_NUM; i
++
)
{
if
(g_AnsState[i]
==
0
)
{
g_AnsGuess
=
g_AllAns[i];
break;
}
}
}
测试结果:
1次 1
2次 15
3次 211
4次 1240
5次 2108
6次 1203
7次 252
8次 10
平均次数:5.00515
花费的时间:7s
这个方法令人吃惊的是它的速度,如此简单却又不错的效果。平均次数要比上面两个方法差些。
上面的测试结果是在vc2005的release下编译测试。
本文用信息量大小作为判断最佳输入的标准,相比其他算法平均步数较少,但花费的时间较多。如何缩短计算时间将在以后的文章中继续探讨。此外从测试中看出,方法2,3在两次内猜中答案的次数比方法1多。在这个意义下方法2,3比方法1要优。以后还会在不同的意义下比较各类算法。大家有什么好的想法可以联系我,大家一起讨论。我的邮箱 [email protected]
代码下载
猜数字问题表述如下: 答案是 0 至 9 十个数的四位排列(没有重复元素)。每猜一次,游戏比较答案和输入这两组排列,反馈 aAbB 。 aA 表示有 a 个数字数值正确位置正确, bB 表示 b 个数字数值正确位置错误。
考虑问题的互动方式,这道题目属于输入反馈的问题。 每次输入,从反馈中获取部分信息,当获得系统的全部信息时,就能猜出答案。所以解这个问题的关键是寻找合适的输入使反馈的信息量最大。
这道题的解法思路 比较简单,由于是有穷状态,而且数量不大,可用穷举法解答。比较所有可能的输入,找出最佳输入(某个标准下)。 问题变成了如何衡量输入的好坏。输入越好则反馈的信息越大,山农给出的信息量的定义是衡量输入好坏的标准。信息量是不确定性的大小,如果某个事件的概率为 P ,则它 含有的信息量为 log(1/P) 。具体的理论可以参考一本关于信息论的书。
具体到猜数字这个题目,答案有多种可能。得到反馈前,每一个输入的反馈有不同的可能。得到反馈后,反馈是确定的,不确定性变为 0。这样 问题的信息量变小了。前后的信息量之差就是这个输入能够得到的信息量。
统计反馈前这个输入可能得到的反馈,求出各种反馈的概率,可由下面公式计算出反馈前的信息量:
H=P1*log(1/P1)+P2*log(1/P2)+...。P1,P2等分别为不同反馈的概率。
反馈后,这个输入的反馈是确定的,信息量为0。比较所有输入能够得到信息量,就能找出最佳的输入。
统计反馈前这个输入可能得到的反馈:














计算这个输入能够获得的信息量



















这样就得到每个输入的信息量。下面讨论如何枚举所有可能的输入。
输入的种类有10*9*8*7种,把每一种输入和所有可能答案比较。这方法比较直观,但运算次数太多。








































由于第一次输入,各种输入能够得到的信息量是一样的,因此第一次可以不比较,任给一组输出。改进后的代码如下:













第一次输入时10个元素之间是没有分别的。沿着这个思路考虑,第二次输入时,6个没有参与输入的元素,也没有分别。举例来说,第一次输入{0,1,2,3},则4,5,6,7,8,9六个元素属同一类元素,在第二次输入时可以相互替换。如{0,1,2,4}和{0,1,2,5}两组输入能够得到的信息量应该相同。把元素分类枚举则可以优化算法。
分类方法如下,参入输出的元素单独成一类,没有参与的元素是一类。第一次输入时,只有一类元素,这类元素个数为10。第二次输入时,有五类元素,有四类元素个数为1,一类元素个数为6。依次类推。关于如何枚举见 《从集合中枚举子集》。


















































分类的方法可以大大提升运算速度,下面的测试会证明这一点。但当参与输入元素慢慢增加时,分类就不那么重要,而且调用CSetIterAgent也是一笔不小的开销。将第一种算法和分类法结合:


















下面给出这些算法的测试结果。测试方法是将10*9*8*7种答案依次用这些算法求解,统计猜出每个答案需要多少次数。一共花费了多少时间。
1次 1
2次 12
3次 261
4次 2082
5次 2500
6次 184
平均次数:4.51190
N次是指N次输入反馈后,就可以确定答案,并不指第N次输入的反馈是4A0B。
花费的时间:
GetBestInputNoSort: 8h也没有算完
GetBestInputIpvFst: 9385s
GetBestInputSort: 1596s
GetBestInputBlend7: 2151s
GetBestInputBlend10: 1515s
这些方法在本质上是一样的,即在所有的输入中寻找反馈信息量最大的输入。下面考虑其他方法,比较这些方法的差别。
如果缩小寻找范围,不考虑不是答案的输入。代码如下:
取消GetBestInputNoSort函数中






测试结果如下:
1次 1
2次 16
3次 228
4次 1555
5次 2688
6次 542
7次 10
平均次数:4.70218
运行时间:
GetBestInputIpvFst:1375s
GetBestInputSort: 97s
缩小范围后,速度有很多提升,但平均次数增加了。
再考虑另外一种方法,不从信息量的角度考虑。用第一个可能为答案的排列作为输入。












1次 1
2次 15
3次 211
4次 1240
5次 2108
6次 1203
7次 252
8次 10
平均次数:5.00515
花费的时间:7s
这个方法令人吃惊的是它的速度,如此简单却又不错的效果。平均次数要比上面两个方法差些。
上面的测试结果是在vc2005的release下编译测试。
本文用信息量大小作为判断最佳输入的标准,相比其他算法平均步数较少,但花费的时间较多。如何缩短计算时间将在以后的文章中继续探讨。此外从测试中看出,方法2,3在两次内猜中答案的次数比方法1多。在这个意义下方法2,3比方法1要优。以后还会在不同的意义下比较各类算法。大家有什么好的想法可以联系我,大家一起讨论。我的邮箱 [email protected]
代码下载