|
12球问题表述如下:有12个球,有一个球的重量与其它球的重量不一致。用一台没有刻度的天平称3次把这个球找出。当然12球问题可以推广至N个球。 首先考虑这道题目的模式,它属于输入反馈的问题。每一次输入(即称一次)便会有一个反馈信息,分析反馈信息再次输入,直到把问题解决。通过一次次输入反馈、这类问题解法思路较简单,由于是有穷状态,状态数目不大,可以用穷举法解答。比较所有的输入,找出最佳输入(某个标准意义下)。问题是如何衡量输入的好坏。直观上,输入越好,反馈的信息就越大。问题变成了对信息的量化。山农给出了信息量的定义是解决问题的关键。信息量是不确定性的大小。如果某个事件的概率为P,则它含有的信息量为log(1/p)。具体理论请参考关于信息论方面的书籍。(这个问题和猜数字很像,大家参考此文《猜数字的一种解法》)。
具体到这个12球问题。12个球从0-11编号。0号球为较轻的球可能性为1/24。这个事件的信息量log(24)。同理0号球为重球这个事件的信息量为log(24)。这样的事件有24个,每个事件出现的概率1/24,所以整个系统的信息量24*(1/24*log(24)),即log(24)。 下面我们考虑一次具体的输入: 第一次输入,天平左端放0号球,右端放1号球。反馈信息可能为平衡,左倾,右倾。 平衡:这时0,1号球是标准球。根据上面的计算过程,同样可以计算出此时系统含有的信息量20*(1/20*log(20)),即log(20)。这次输入获得的信息log(24)-log(20)。 左倾:可能的情况,1号球为轻球,2号球为重球。此时系统的信息量log(2)。获得的信息log(24)-log(2)。 右倾:同左倾,此时系统的信息量log(2)。获得的信息log(24)-log(2)。 这次输入,出现平衡的概率20/24,出现左倾的概率2/24,出现右倾的概率2/24。这可以计算这次输入能够获得的平均信息量为20/24*(log(24)-log(20))+2/24*(log(24)-log(2))+2/24*(log(24)-log(2)),即20/24*log(24/20)+2/24*log(24/2)+2/24*log(24/2)。
下面就要枚举所有可能的输入,找出平均信息量最大的输入。枚举需要些技巧,需要把球分类,例如第一次输入时每个球可以看成相同,左右天平放的球数为1,2,3,4,5,6,一共六情况。如果把每个球区别看待枚举的次数将会很多。下面介绍分类方法。 根据球的状态,分成4类,一类:标准球即排出不标准的可能性,二类:只可能为轻球,三类:只可能为重球,四类:可能为轻球也可能为重球。
// 把球分类 // 输出参数:type 表示对应球的类型,0为标准球,1可能为轻球,2可能为重球,3都有可能 void CGuessBall::SortBalls(VectorInt& type) { for (int i=0; i<m_iNumOfBalls; i++) { if (m_vPsbAns[i*2] == 0) type[i]++; if (m_vPsbAns[i*2+1] == 0) type[i] += 2; } }
// 返回四个数组,分别表示四类球 void CGuessBall::SortBalls(VectorInt& type0, VectorInt& type1, VectorInt& type2, VectorInt& type3) { for (int i=0; i<m_iNumOfBalls; i++) { if (m_vPsbAns[i*2] == 0 && m_vPsbAns[i*2+1] == 0) type3.push_back(i); else if (m_vPsbAns[i*2+1] == 0) type2.push_back(i); else if (m_vPsbAns[i*2] == 0) type1.push_back(i); else type0.push_back(i); } }
下面函数说明如何枚举,首先找出2*i个球,然后从中找出i个球。计算这次输入的信息量,需要我以前写的一篇文章《从集合中枚举子集 》。
// 使用分类法得到最佳输入 // 输出参数:left,right左右天平球的标号 void CGuessBall::GetBestInputSort(VectorInt& left, VectorInt& right ) { double max_entropy = 0.0; // 把球分类 VectorInt types(m_iNumOfBalls, 0); SortBalls(types); VectorInt t[4]; SortBalls(t[0], t[1], t[2], t[3]); // 枚举所有可能的输入组合 // 首先找出i个球,左右天平各方i/2个球。 CSetIterAgent<int> iter(types); for (int i=2; i<=m_iNumOfBalls; i+=2) { iter.Init(i, CSetIter::MULT_DISORDER_IN); VectorInt subset(i, 0); while (iter.GetNextSubset(subset, types)) { // 再从i个球中找出i/2个球 CSetIterAgent<int> iter_i(subset); iter_i.Init(i/2, CSetIter::MULT_DISORDER_IN); VectorInt subset_l(i/2, 0); VectorInt subset_r(i/2, 0); while (iter_i.GetNextSubset(subset_l, subset)) { // subset - subset_l得到subset_r int idx_r=0, idx_l=0, idx=0; while (idx < subset.size()) { if (idx_l >= subset_l.size() || subset_l[idx_l] != subset[idx]) { subset_r[idx_r++] = subset[idx++]; } else { idx_l++, idx++; } } // 把球的类型转化成具体的球号 int idxs[4] = {0, 0, 0, 0}; left.assign(i/2, -1); for (int j=0; j<subset_l.size(); j++) { left[j] = t[subset_l[j]][idxs[subset_l[j]]++]; } right.assign(i/2, -1); for (int j=0; j<subset_r.size(); j++) { right[j] = t[subset_r[j]][idxs[subset_r[j]]++]; } // 得到所有可能的反馈 VectorInt outputs(3, 0); for (int j=0; j<m_vPsbAns.size(); j++) { if (m_vPsbAns[j] == 1) continue; outputs[CheckFeedback(right, left, j)+1]++; } // 检查是否是理论最优,提前返回 if (IsTheoryBestInput(outputs)) { m_vLeft = left; m_vRight = right; return; } // 比较 double entropy = CalcEntropy(outputs); // 求最大的信息量 if (max_entropy < entropy) { max_entropy = entropy; m_vLeft = left; m_vRight = right; } } } } left = m_vLeft; right = m_vRight; }
要使得到的信息量最大,就要使天平左倾,右倾,平衡的出现的概率尽可能相等。四类球,只有三类影响平衡,如果它们的个数能被3整除,左右天平各放三分之一份球。当然也有不能被3整除的情况。三类球,每一类均可能有余数0,1,2。组合起来一共有27种情况。考虑这27种情况配合标准球如何分配。得到以下的算法,先将三类球三等分,然后再考虑27种余数情况,得到第二种算法。
// 直接法得到最佳输入 void CGuessBall::GetBestInputDirect(VectorInt& left, VectorInt& right) { // 第一次输入,没有标准球。 if (m_iTimes == 0) { int num = (m_iNumOfBalls+1) / 3; left.assign(num, -1); right.assign(num, -1); for (int i=0; i<num; i++) { left[i] = i; right[i] = i + num; } } else { // 有标准球 VectorInt t0, t1, t2, t3; SortBalls(t0, t1, t2, t3); int tmp1 = t1.size() / 3; for (int i=0; i<tmp1; i++) { left.push_back(t1[i]); right.push_back(t1[i+tmp1]); } int tmp2 = t2.size() / 3; for (int i=0; i<tmp2; i++) { left.push_back(t2[i]); right.push_back(t2[i+tmp2]); } int tmp3 = t3.size() / 3; for (int i=0; i<tmp3; i++) { left.push_back(t3[i]); right.push_back(t3[i+tmp3]); } switch ((t3.size()%3)*9+(t2.size()%3)*3+(t1.size()%3)) { case 0*3*3 + 0*3 + 0: case 0*3*3 + 0*3 + 1: case 0*3*3 + 1*3 + 0: break; case 0*3*3 + 0*3 + 2: case 0*3*3 + 1*3 + 2: case 0*3*3 + 2*3 + 2: // case 1*3*3 + 0*3 + 2: left.push_back(t1[tmp1*2]); right.push_back(t1[tmp1*2+1]); break; case 0*3*3 + 1*3 + 1: left.push_back(t1[tmp1*2]); right.push_back(t0[0]); break; case 0*3*3 + 2*3 + 0: case 0*3*3 + 2*3 + 1: // case 1*3*3 + 2*3 + 0: left.push_back(t2[tmp2*2]); right.push_back(t2[tmp2*2+1]); break; case 1*3*3 + 0*3 + 0: // case 1*3*3 + 0*3 + 1: // case 1*3*3 + 1*3 + 0: case 2*3*3 + 0*3 + 0: left.push_back(t3[tmp3*2]); right.push_back(t0[0]); break; // case 1*3*3 + 1*3 + 1: // case 1*3*3 + 1*3 + 2: // case 1*3*3 + 2*3 + 1: // left.push_back(t1[tmp1*2]); // right.push_back(t3[tmp3*2]); // break; // case 1*3*3 + 2*3 + 2: // left.push_back(t1[tmp1*2]); // right.push_back(t1[tmp1*2+1]); // left.push_back(t2[tmp2*2]); // right.push_back(t2[tmp2*2+1]); // break; // case 2*3*3 + 0*3 + 1: // case 2*3*3 + 0*3 + 2: // case 2*3*3 + 1*3 + 0: // case 2*3*3 + 1*3 + 1: // case 2*3*3 + 2*3 + 0: // case 2*3*3 + 1*3 + 2: // case 2*3*3 + 2*3 + 1: // left.push_back(t3[tmp3*2]); // right.push_back(t3[tmp3*2+1]); // break; // case 2*3*3 + 2*3 + 2: // left.push_back(t1[tmp1*2]); // right.push_back(t1[tmp1*2+1]); // left.push_back(t3[tmp3*2]); // right.push_back(t3[tmp3*2+1]); // break; default:; } } m_vLeft = left; m_vRight = right; }
这里要注意,如果有标准球则能达到最佳的输入。所以第一次没有标准球要区别对待。此外其实没有27种情况,因为第二,三类不可能和第四类同时出现。
这是一个测试程序 ,解答12球的问题,它输出左右天平放的球的编号,等待用户判断左倾,右倾还是平衡,直到输出结果。
#ifdef _TEST_ int main() { int size = 12; CGuessBall guess(size); while (1) { VectorInt left, right; int fd; guess.GetBestInput(left, right); // 输出左右天平的编号 for (int i=0; i<left.size(); i++) printf("%d, ", left[i]); printf("\n"); for (int i=0; i<right.size(); i++) printf("%d, ", right[i]); printf("\n"); // 等待反馈 printf("左倾,平衡,右倾(-1,0,1):"); scanf("%d", &fd); if (fd <= -1) fd = -1; else if (fd >= 1) fd = 1; else fd = 0; int rz = guess.RemoveImpsAns(fd); if (rz == 1) { int ans = guess.GetTheFirstImpsAns(); printf("猜出结果:%d球为%s球", ans/2, ans%2?"重":"轻"); break; } else if (rz < 1) { printf("输入错误"); break; } else continue; } } #endif
从程序运行情况看,很多球也很少几次就可以找出不规则球。下面讨论N个球要多少次才能确定不规则球。最佳输入能够使天平的三种情况出现的次数均匀。如果是最佳输入,系统的信息量只剩下原来的三分之一。由于对N个球,如果N不能被3整除,第一次就找不到最佳输入。对N个球可以在[log3(2*N)]+1次内找出不规则球。
第二个程序是测试上面两种算法的性能
#ifdef _TEST_TIME_ #include <windows.h> int main() { const int inc = 20; DWORD time; CGuessBall guess; for (int i=0; i<2; i++) { for (int j=12; j< 12+inc; j++) { time = 0; for (int t=0; t<j*2; t++) { guess.Init(j, i); DWORD start = GetTickCount(); while (1) { VectorInt left, right; guess.GetBestInput(left, right); int fd = guess.CheckFeedback(left, right, t); int rz = guess.RemoveImpsAns(fd); if (rz == 1) break; } time += GetTickCount()-start; } time /= j*2; printf("方法:%d\t球数:%d\t平均时间:%d\n", i, j, time); } } return 0; } #endif
这是测试结果: 方法:0 球数:12 平均时间:0 方法:0 球数:13 平均时间:0 方法:0 球数:14 平均时间:0 方法:0 球数:15 平均时间:0 方法:0 球数:16 平均时间:0 方法:0 球数:17 平均时间:1 方法:0 球数:18 平均时间:1 方法:0 球数:19 平均时间:1 方法:0 球数:20 平均时间:5 方法:0 球数:21 平均时间:5 方法:0 球数:22 平均时间:5 方法:0 球数:23 平均时间:6 方法:0 球数:24 平均时间:6 方法:0 球数:25 平均时间:6 方法:0 球数:26 平均时间:27 方法:0 球数:27 平均时间:26 方法:0 球数:28 平均时间:25 方法:0 球数:29 平均时间:167 方法:0 球数:30 平均时间:162 方法:0 球数:31 平均时间:157 方法:0 球数:32 平均时间:171 方法:0 球数:33 平均时间:165 方法:0 球数:34 平均时间:163 方法:1 球数:12 平均时间:0 方法:1 球数:13 平均时间:0 方法:1 球数:14 平均时间:0 方法:1 球数:15 平均时间:0 方法:1 球数:16 平均时间:0 方法:1 球数:17 平均时间:0 方法:1 球数:18 平均时间:0 方法:1 球数:19 平均时间:0 方法:1 球数:20 平均时间:0 方法:1 球数:21 平均时间:0 方法:1 球数:22 平均时间:0 方法:1 球数:23 平均时间:0 方法:1 球数:24 平均时间:0 方法:1 球数:25 平均时间:0 方法:1 球数:26 平均时间:0 方法:1 球数:27 平均时间:0 方法:1 球数:28 平均时间:0 方法:1 球数:29 平均时间:0 方法:1 球数:30 平均时间:0 方法:1 球数:31 平均时间:0 方法:1 球数:32 平均时间:0 方法:1 球数:33 平均时间:0 方法:1 球数:34 平均时间:0 这里是 全部代码
|