24點游戲題解
一、問題描述
80年代全世界流行一種數字游戲,在中國我們把這種游戲稱為“24點”。現在我們把這個有趣的游戲推廣一下:您作為游戲者將得到6個不同的自然數作為操作數,
以及另外一個自然數作為理想目標數,而您的任務是對這6個操作數進行適當的算術運算,要求運算結果小於或等於理想目標數,並且我們希望所得結果是最優的,
即結果要最接近理想目標數。
您可以使用的運算只有:+,-,*,/,您還可以使用()來改變運算順序。注意:
所有的中間結果必須是整數,所以一些除法運算是不允許的(例如,(2*2)/4是合法的,2*(2/4)是不合法的)
下面我們給出一個游戲的具體例子:
若給出的6個操作數是:1,2,3,4,7和25,理想目標數是573;
則最優結果是573:(((4*25-1)*2)-7)*3。
輸入:
輸入文件名為game.in。輸入文件僅一行,包含7個整數,前6個整數Mi,
1<=Mi<=100,表示操作數,最后一個整數T, 1<=T<=1000,表示理想目標數。
輸出:
輸出文件名為game.out。輸出文件有兩行,第一行僅一個整數,表示您的程序計算
得到的最優結果;第二行是一個表達式,即您得到的最優結果的運算方案。
輸入輸出示例:
輸入文件
1 2 3 4 7 25 573
輸出文件
573
((4*25-1)*2)-7)*3
二、算法分析
首先我們要對這個問題進行數學抽象。
定義1:對於有理數組成的多重集合S ,f(S) 定義如下:
如果 S 是空集或只包含一個元素,則 f(S)=S ;
否則 f(S)=∪ f( ( S-{r1, r2}) ∪ {r} ) ,對於每一個 r=r1+r2 , r1-r2 , r1×r2,r1÷r2(r2≠0),且r1, r2取遍 S 中所有元素的組成的二元組。
定義1說明:要計算集合S中的元素通過四則混合運算所能得到的所有值,我們只需要任取 S 中的兩個元素 r1 , r2 ,分別計算 r1 , r2 的加減乘除運算,然后用
所得的結果與 S 中剩下的其他數字進行四則混合運算。只要取遍所有的 r1 ,r2 ,最后得到的所有結果的並集就是 S 中的元素通過四則混合運算所能得到的所
有值的集合。
根據上述定義,在本問題中,集合 S 就是由輸入中給定的6個正整數組成的集合,
題目所求就是找出 f(S) 中小於或等於目標數的最大數。
定義2:給定兩個多重集合 S1 , S2,定義
comb( S1, S2 ) = ∪ { r1+r2 , r1-r2, r1×r2, r1÷r2(r2≠0) } (1.1)
其中 ( r1 , r2 ) ∈ S1 × S2。
定義2實際上定義了兩個集合中的元素兩兩進行加減乘除運算所能得到的結果集合。
定理1:對於有理數組成的多重集合 S ,如果 S 至少有兩個元素,則
f(S)=∪ comb( f(S1), f(S - S1) ) (1.2)
其中 S1 取遍 S 的所有非空真子集。
定理1的含義是:要計算 S 中的元素通過四則混合運算所能得到的所有值,可以先將 S 分解為兩個子集 S1 和 S- S1 ,分別計算 S1 和 S-S1 中的元素進行四則混
合運算所能得到的結果集合,即 f(S1) 和 f(S-S1) ,然后對這兩個集合中的元素進行加減乘除運算,即 comb( f(S1), f(S-S1) ) ,最后得到的所有集合的並集就
是 f(S) 。限於篇幅,定理1的正確性易用數學歸納法證明。
定義1和定理1實際上分別給出了計算f(S)的兩種不同的方法。根據定義1,可以遞歸地計算f(S) ,其算法偽代碼如下:
算法1:
function f(S)
begin
1. if |S| < 2
2. then return S
3. else begin
4. T ← Φ
5. for each (r1, r2) in S do
6. begin
7. r ← r1 + r2;
8. T ← T + f(S – {r1, r2} + {r});
9. r ← r1 - r2;
10. T ← T + f(S – {r1, r2} + {r});
11. r ← r1 * r2;
12. T ← T + f(S – {r1, r2} + {r});
13. if (r2 <> 0) and (r1 mod r2 = 0) then
14. begin
15. r ← r1 / r2;
16. T ← T + f(S – {r1, r2} + {r});
17. end
18. end
19. return T;
20. end
end
上述偽代碼中使用了+, - 來分別表示集合的並和差運算。算法1每次選擇兩個數字進行某種運算,然后將結果與剩下的數字遞歸地進行運算,最后求得所有數字進行
四則混合運算的結果。當然,在具體實現該算法的過程中有很多可以優化的地方,比如根據加法交換律, a+b+c=a+c+b ,因此我們可以規定:如果上一層遞歸作了
加法運算,這一層僅當滿足當前的操作數大於上一層的兩個操作數的時候才進行加法運算,以確保 a+b+c 這樣的式子中的操作數總是從小到大排列,這樣就可以避
免重復進行等價的加法計算。類似地我們可以對乘法也作此規定。在進行減法的時候,我們可以規定只能計算大數減小數,因為最后所需計算得到的目標數是一個正
數,如果計算過程中出現負數,肯定有另外一個較大的正數與其作加法或者有另外一個負數與其做乘除法以消除負號。因此我們總可以調整運算次序使得四則混合運
算的每一步的中間結果都是正數。在作除法的時候,因為題目規定中間結果只能是整數,所以也只需要用大數除小數,且僅當能除盡的時候才進行除法。對於本題而
言,初始的集合 S 中一共有6個操作數,每次遞歸都可以合並兩個操作數,所以遞歸到第5層的時候集合 S 中只剩下一個數,這個數就是原先的6個操作數進行四則
混合運算所能得到的結果。本題只要求最接近目標值的結果,所以實現上述算法的時候可以只記錄當前最優的結果。對於本題也可以利用遞歸回溯構造出所有的四則
混合運算的語法樹,但本質上與算法1是沒有區別的。
定理1則給出了另一種計算f(S)的方法。我們當然也可以根據(1.2)式直接地遞歸計算f(S),但那樣的話會有很多冗余計算。
例如對於S={1,2,3,4},
f(S) = comb( f({ 1 }), f({ 2,3,4}) )∪ ... ∪ comb( f({ 1,2 }), f({3,4 }) ) ∪ ...;
計算f(S)的時候需要計算 f({ 2,3,4 })和f({ 3,4 }) ,又因為 f({2,3,4}) = comb(f({ 2 }), f({3,4})) ∪ ...;
在計算 f({ 2,3,4}) 的時候又要重復地計算 f({ 3,4 }) ,這就產生了冗余的計算。這種情況下直接地遞歸就不適用。必須按照一定的順序,遞推地進行計算。這
種將遞歸改為遞推,以解決冗余的算法設計策略,就叫做動態規划。
下面我們具體闡述一下該算法的步驟。設初始時集合 S 中的 n 個數字分別為x[0], x[1],...,x[n-1] ,我們可以用一個二進制數k來表示S 的子集 S[k] ,
x[i] ∈ S[k] 當且僅當二進制數k的第i位為1。於是我們用一個數組 F[0..2^n-1]就可以保存函數f對於S的所有子集的函數值(注意,函數f的函數值是一個集合)
,且 F[2^n-1]=f(S) 就是所求。
算法2 :
1. for i ← 0 to 2^n-1
2. do F[i]←Φ;
3. for i ← 0 to n-1
4. do F[2^i]← {x[i]};
5. for x ← 1 to 2^n-1 do
6. begin
7. for i ← 1to x-1 do
8. begin
9. if x∧i=i then
10. begin
11. j ← x – i;
12. if i < j
13. then F[x] ← F[x] + comp(F[i],F[j]);
14. end;
15. end;
16. end;
17. return F[ 2 n ?1] ;
上述偽代碼中使用了+表示集合的並運算。算法2的第1~2行將F中所有的集合初始化為空;第3~4行中 2^i 即表示只包含元素 x[i]的子集(因為 2^i 只有第 i 位
上是1),根據定義1我們知道當集合中只有一個元素的時候函數 f 的函數值就是那唯一的元素組成的集合,所以3~4行計算出了函數 f 對於所有只有一個元素的
子集的函數值;第5~17行按照一定的順序計算函數 f 對於 S 的所有子集的函數值。對於 S 的兩個子集 S[i] 和 S[x] , S[i]真包含於S[x]的充要條件是 x∧
i=i ,這里 ∧ 是按位進行與操作,而 x∧i=i 的必要條件是 i<x 。因而第7~15行的循環將S[x]拆成兩個子集S[i]和S[j],並在第13行根據(1.2)式計算所有的
comp( f(S[i]),f(S[j]) ) 的並。第12行的判斷語句是為了優化算法的效率,因為將 S[x]拆成兩個子集 S[i]和 S[j]的過程是對稱的,所以我們對於 comp(
f(S[i]),f(S[j]) ) 和 comp( f(S[j]),f(S[i]) ) 兩者只取一個進行計算。下面是函數comp的偽代碼:
算法3 :
function comp(S1, S2)
1. T ← Φ ;
2. for each x in S1 do
3. begin
4. for each y in S2 do
5. begin
6. T ← T + {(x + y)};
7. T ← T + {(x * y)};
8. if x > y then
9. begin
10. T ← T + {(x – y)};
11. if (y <> 0) and (x mod y = 0)
12. then T ← T + {(x / y)};
13. end
14. else begin
15. T ← T + {(y – x)};
16. if (x <> 0) and (y mod x = 0)
17. then T ← T + {(y / x)};
18. end;
19. end;
20. end;
21. return T;
comp在進行計算的時候不考慮參數集合S1和S2的順序,進行減法的時候始終用大數減小數,這樣保證運算過程中不出現負數(這樣做的理由前文已經闡明)。
因為我們只關心最后的f(S)中最接近目標值的數字,並且題目只要求求出任何一組最優解,所以算法2中的集合不需要是多重集合,只要是一般的集合即可。換句話
說,集合F[i]中所有的元素互不相同,重復出現元素的我們只保留其中一個。這樣可以大大減少計算中的冗余。做了這樣的處理后,算法2的效率至少不會比算法1差
,因為算法1中所能采用的主要剪枝手段是排除等價的表達式,但因為等價的兩個表達式計算出的結果也一定相同,而算法2排除了所有結果相同的表達式,所以算
法2的效率至少不會比算法1差,算法2中所進行的計算基本上都是得到最優解所必需的計算。
在實現算法2的過程中,集合可以用一個鏈表加上一個哈希表來實現。鏈表中保存每個表達式及其值,哈希表用來記錄該集合中是否存在某個特定值的表達式。當向
集合中插入一個新的表達式的時候,首先檢查哈希表,看看該集合是否已經有和新表達式值相同的表達式,如果有的話就不插入,否則將新的表達式追加到鏈表末尾
。采用這種數據結構,可以在常數時間內完成集合的插入和刪除操作。利用鏈表,集合的並操作也很容易高效地實現。
在實現算法2的過程中,可以不必保存表達式的字符串,只需要記錄下當前的值是 由哪兩個集合中的元素通過哪種運算得到的,最后再根據最優解遞歸地計算出最優 解的表達式。
這樣只在最后構造最優解的表達式時才進行字符串操作,程序運行效率能提高7~8倍左右。另外,在comb函數中進行乘法運算的時候要注意考慮運算結果超出整數范圍的情況經
過以上優化,利用算法2實現的程序對於100個隨機生成的測試數據總共只需要5秒左右就可以出解,平均每個數據只需要50毫秒即可出解(測試用的CPU為賽揚1GB)。
這樣的效率已經非常令人滿意了。
三、附錄:
1、根據算法1計算24點的代碼 :
#include <iostream>
#include <string>
#include <cmath>using namespace std;
const double PRECISION = 1E-6;
const int COUNT_OF_NUMBER = 4;
const int NUMBER_TO_CAL = 24;double number[COUNT_OF_NUMBER];
string expression[COUNT_OF_NUMBER];bool Search(int n)
{
if (n == 1) {
if ( fabs(number[0] - NUMBER_TO_CAL) < PRECISION ) {
cout << expression[0] << endl;
return true;
} else {
return false;
}
}for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
double a, b;
string expa, expb;a = number[i];
b = number[j];
number[j] = number[n - 1];expa = expression[i];
expb = expression[j];
expression[j] = expression[n - 1];expression[i] = '(' + expa + '+' + expb + ')';
number[i] = a + b;
if ( Search(n - 1) ) return true;expression[i] = '(' + expa + '-' + expb + ')';
number[i] = a - b;
if ( Search(n - 1) ) return true;expression[i] = '(' + expb + '-' + expa + ')';
number[i] = b - a;
if ( Search(n - 1) ) return true;
expression[i] = '(' + expa + '*' + expb + ')';
number[i] = a * b;
if ( Search(n - 1) ) return true;if (b != 0) {
expression[i] = '(' + expa + '/' + expb + ')';
number[i] = a / b;
if ( Search(n - 1) ) return true;
}
if (a != 0) {
expression[i] = '(' + expb + '/' + expa + ')';
number[i] = b / a;
if ( Search(n - 1) ) return true;
}number[i] = a;
number[j] = b;
expression[i] = expa;
expression[j] = expb;
}
}
return false;
}int main()
{
for (int i = 0; i < COUNT_OF_NUMBER; i++) {
char buffer[20];
int x;
cin >> x;
number[i] = x;
itoa(x, buffer, 10);
expression[i] = buffer;
}if ( Search(COUNT_OF_NUMBER) ) {
cout << "Success." << endl;
} else {
cout << "Fail." << endl;
}
}
2、根據算法2計算解決題目的程序代碼:
#include <fstream>
#include <algorithm>
#include <string>
#include <sstream>
#include <list>
#include <cmath>
#include <climits>
#include <bitset>
using namespace std;const char* INPUT_FILE = "game.in";
const char* OUTPUT_FILE = "game.out";
const int NUMBER_COUNT = 7;
const int STATE_COUNT = (1 << NUMBER_COUNT);
const int MAX_NUMBER = 100;
const int MAX_EXPECTION = 1000;
const int MAX_VALUE = MAX_EXPECTION * MAX_NUMBER;struct Node {
int value;
int left, right;
int leftvalue, rightvalue;
char opr;
};typedef list<Node> NodeList;
struct State {
bitset<MAX_VALUE+10> exist;
NodeList nodelist;
};int number[NUMBER_COUNT], expection;
State state[STATE_COUNT];void ReadData()
{
ifstream fin(INPUT_FILE);for (int i = 0; i < NUMBER_COUNT; i++) {
fin >> number[i];
}
fin >> expection;
}void Init()
{
Node node ;
for (int i = 0; i < NUMBER_COUNT; i++) {
node.value = number[i];
node.left = node.right = -1;
state[(1 << i)].nodelist.push_back(node);
state[(1 << i)].exist[node.value] = true;
}
}void Merge(int a, int b, int x)
{
Node node;
NodeList::const_iterator i, j;for (i = state[a].nodelist.begin(); i != state[a].nodelist.end(); i++){
for (j = state[b].nodelist.begin(); j != state[b].nodelist.end(); j++){
node.value = (*i).value + (*j).value;
node.left = a;
node.right = b;
node.leftvalue = (*i).value;
node.rightvalue = (*j).value;
node.opr = '+';
if ( (node.value <= MAX_VALUE) && (!state[x].exist[node.value]) ) {
state[x].nodelist.push_back(node);
state[x].exist[node.value] = true;
}/////////////////////////////////////////////////////
double tmp = double((*i).value) * double((*j).value);
if (tmp < INT_MAX) {
node.value = (*i).value * (*j).value;
node.left = a;
node.right = b;
node.leftvalue = (*i).value;
node.rightvalue = (*j).value;
node.opr = '*';
if ( (node.value <= MAX_VALUE) && (!state[x].exist[node.value]) ){
state[x].nodelist.push_back(node);
state[x].exist[node.value] = true;
}
}/////////////////////////////////////////////////////
if ((*i).value >= (*j).value) {
node.value = (*i).value - (*j).value;
node.left = a;
node.right = b;
node.leftvalue = (*i).value;
node.rightvalue = (*j).value;
node.opr = '-';
} else {
node.value = (*j).value - (*i).value;
node.left = b;
node.right = a;
node.leftvalue = (*j).value;
node.rightvalue = (*i).value;
node.opr = '-';
}if ( (node.value <= MAX_VALUE) && (!state[x].exist[node.value]) ) {
state[x].nodelist.push_back(node);
state[x].exist[node.value] = true;
}/////////////////////////////////////////////////////
if ( ((*j).value != 0) && ((*i).value >= (*j).value) && ((*i).value % (*j).value == 0) )
{
node.value = (*i).value / (*j).value;
node.left = a;
node.right = b;
node.leftvalue = (*i).value;
node.rightvalue = (*j).value;
node.opr = '/';
} else if ( ((*i).value != 0) && ((*j).value >= (*i).value) && ((*j).value % (*i).value == 0) )
{
node.value = (*j).value / (*i).value;
node.left = b;
node.right = a;
node.leftvalue = (*j).value;
node.rightvalue = (*i).value;
node.opr = '/';
}if ( (node.value <= MAX_VALUE) && (!state[x].exist[node.value]) ){
state[x].nodelist.push_back(node);
state[x].exist[node.value] = true;
}
/////////////////////////////////////////////////////}
}
}void Solve()
{
Init();for (int x = 2; x < STATE_COUNT; x++) {
for (int i = 1; i < x; i++) {
if ( (x & i) == i ) {
int j = x - i;
if (i <= j) {
Merge(i, j, x);
}
}
}
}
}void PrintExpression(ostream& out, Node node)
{
if (node.left == -1) {
out << node.value;
} else {
NodeList::const_iterator iter;out << "(";
for (iter = state[node.left].nodelist.begin();
iter != state[node.left].nodelist.end();
iter++)
{
if ((*iter).value == node.leftvalue) {
PrintExpression(out, *iter);
break;
}
}out << node.opr;
for (iter = state[node.right].nodelist.begin();
iter != state[node.right].nodelist.end();
iter++)
{
if ((*iter).value == node.rightvalue) {
PrintExpression(out, *iter);
break;
}
}out << ")";
}
}void Output()
{
ofstream fout(OUTPUT_FILE);int bestValue = -INT_MAX;
NodeList::const_iterator iter, bestIter;NodeList& nodelist = state[STATE_COUNT-1].nodelist;
for (iter = nodelist.begin(); iter != nodelist.end(); iter++)
{
if ( ((*iter).value <= expection) && (bestValue < (*iter).value) ) {
bestValue = (*iter).value;
bestIter = iter;
}
}
fout << bestValue << endl;
PrintExpression(fout, *bestIter );
fout << endl;
}int main()
{
ReadData();
Solve();
Output();
system("PAUSE");
return 0;
}
可以參考<編程之美>