一道Google top coder的850分例題及解答
來源:互聯網
上載者:User
一道Google top coder的850分例題及解答 原題: 假設有這樣一種字串,它們的長度不大於 26 ,而且若一個這樣的字串其長度為 m ,則這個字串必定由 a, b, c ... z 中的前 m 個字母構成,同時我們保證每個字母出現且僅出現一次。比方說某個字串長度為 5 ,那麼它一定是由 a, b, c, d, e 這 5 個字母構成,不會多一個也不會少一個。嗯嗯,這樣一來,一旦長度確定,這個字串中有哪些字母也就確定了,唯一的區別就是這些字母的前後順序而已。 現在我們用一個由大寫字母 A 和 B 構成的序列來描述這類字串裡各個字母的前後順序: l 如果字母 b 在字母 a 的後面,那麼序列的第一個字母就是 A (After),否則序列的第一個字母就是 B (Before);l 如果字母 c 在字母 b 的後面,那麼序列的第二個字母就是 A ,否則就是 B;l 如果字母 d 在字母 c 的後面,那麼 …… 不用多說了吧?直到這個字串的結束。 這規則甚是簡單,不過有個問題就是同一個 AB 序列,可能有多個字串都與之相符,比方說序列"ABA",就有"acdb"、"cadb"等等好幾種可能性。說的專業一點,這一個序列實際上對應了一個字串集合。那麼現在問題來了:給你一個這樣的AB 序列,問你究竟有多少個不同的字串能夠與之相符?或者說這個序列對應的字串集合有多大?注意,只要求個數,不要求枚舉所有的字串。 註:如果結果大於10億就返回-1。 我的最終解答(沒有考慮溢出的情況): // CODE 1// the best way// O(N^2)int countABbest(const string& AB){ assert(AB.find_first_not_of("AB") == string::npos); vector<int> current, next; // should we reserve these vectors? current.push_back(1); for (string::const_iterator letter = AB.begin(); letter != AB.end(); ++letter) { next.resize(current.size()+1); // or next.insert(next.end(), 2, 0); next[0] = 0; // in fact, we could set the entire vector to zero if (*letter == 'A') { partial_sum(current.begin(), current.end(), next.begin()+1); } else { partial_sum(current.rbegin(), current.rend(), next.begin()+1); reverse(next.begin(), next.end()); } swap(current, next); } return accumulate(current.begin(), current.end(), 0);} int main(){ const char* AB = "ABBAAB"; printf("'%s' : %d/n", AB, countABbest(AB));} 下面談一談我在解決這個問題時的思路。 第一步 初步分析 以下“字串”特指題目中提到的由小寫字母a、b、c等等組成的字串,每個字母出現且僅出現一次。顯然題目要求我們寫一個函數f,f的輸入是一個長度為v 的AB序列w,w代表了一個字串集合s(集合中的元素都是長度為m(m=v+1)的字串),f的傳回值是這個集合的元素個數|s|,即|s|=f(w)。用高中學過的一點排列組合知識,可分析知:1. 長度為m的字串有m! 個(’!’ 表示階乘)因為這相當於m個不同字母的全排列;2. 長度為v的AB序列有2^v個(’^’ 表示指數)因為每個位置有2種可能,一共有v個位置;3. 由於2^v <= m! (m=v+1),所以AB序列的數目不大於字串的數目。4. 每個字串剛好有一個AB序列與之對應。比如對於字串”abdec”,我們很容易得知b在a後,c在b後,d在c前,e在d後,因此它對應的AB序列為”AABA”。可見拿到一個字串,立刻就能求出它對應的那一個AB序列。5. 每個AB序列至少對應一個字串(當然也對應多個,因為字串數目遠遠大於AB序列數目)。比如任取一個AB序列”ABA”,很容易構造出與它對應的字串: i. b在a後,得”ab”; ii. c在b前,得”acb”或”cab”; iii. d在c後,拿”acb”來說,可得”acdb”和”acbd”;拿”cab”來說,可得 ”cdab”、”cadb”和”cabd”;這樣一共構造了5個與”ABA”對應的字串,而且不會再有別的字串了(why?)。其實我們已經找到了蠻力解決問題的辦法。6. 根據4、5,得知如果窮舉出長度為v的AB序列(共2^v個),並計算每個序列對應的字串數目,那麼把所有這些數目加起來,應該等於(v+1)!,這可以用作我們演算法的一個檢驗。7. 其實這可以看作集合的劃分,把一個有 m! 個元素的集合U劃分為2^v個不相交的子集s_0, s_1, s_{2^v–1},每個子集s_i是一個類別,每個字串都屬於一個類別,問題轉變為求給定類別中有多少個元素。 第二步 蠻力解決 在想到前面的分析之前,我先用一種蠻力辦法部分地解決了這個問題,思路是拿到一個長度為v的AB序列,窮舉所有長度為v+1的字串,遇到匹配的就記錄下來。這樣得到第一個程式,這個程式雖然效率極低,但可以用來檢驗後面程式的正確性,是個標竿。 // CODE 2bool match(const string& AB, const string& str){ // many ways to improve this function, but we won’t bother it. for (size_t i = 0; i < AB.length(); ++i) { size_t first = str.find('a'+i); size_t second = str.find('a'+i+1); assert(first != string::npos && second != string::npos); if (AB[i] == 'A' && first > second) { return false; } else if (AB[i] == 'B' && first < second) { return false; } } return true;} // the stupid way// O(N! * N^2)int countAB(const string& AB){ assert(AB.find_first_not_of("AB") == string::npos); string str; int count = 0; int m = (int)AB.length() + 1; // construct the initial string for (int i = 0; i < m; ++i) { str.push_back('a'+i); } do { if (match(AB, str)) { printf("%s, ", str.c_str()); count++; } } while (next_permutation(str.begin(), str.end())); return count;} 上面這個程式是以AB序列為中心,想辦法找到與它匹配的字串。為了看它能否通過第6點分析的檢驗,我寫了一個enumAB(int v)函數,用來窮舉長度為v的所有AB序列,並做檢驗(檢驗基本靠眼)。 // CODE 3void enumAB(int v){ assert(0 <= v && v < 26); int nAB = 1 << v; int total = 0; for (int i = 0; i < nAB; ++i) { string AB; for (int bit = v-1; bit >= 0; --bit) { if (i & (1 << bit)) { AB.push_back('B'); } else { AB.push_back('A'); } } int count = countAB(AB); total += count; printf("%s : %d/n", AB.c_str(), count); } printf("/nTotal strings: %d/n", total);} 以下是enumAB(4)的運行結果(5!=120,初步檢驗通過): AAAA : 1AAAB : 4AABA : 9AABB : 6ABAA : 9ABAB : 16ABBA : 11ABBB : 4BAAA : 4BAAB : 11BABA : 16BABB : 9BBAA : 6BBAB : 9BBBA : 4BBBB : 1 Total strings: 120 如果想窮舉所有AB序列和它們對應的字串,還可以用一種效率稍高的蠻力演算法,以字串為中心,窮舉所有長度為m的字串,把它歸入相應的AB序列名下。代碼如下。 // CODE 4string getAB(const string& str){ const char* alphabet = "abcdefghijklmnopqrstuvwxyz"; assert(str.find_first_not_of(alphabet, 0, str.length()) == string::npos); int pos[26] = {0}; char AB[26] = {0}; int m = (int)str.length(); for (int i = 0; i < m; ++i) { pos[str[i]-'a'] = i; } for (int i = 0; i < m-1; ++i) { AB[i] = pos[i] < pos[i+1] ? 'A' : 'B'; } return AB; // we are not return the local char array, but a string object.} void enumStr(int m){ string str; int nAB = 0; for (int i = 0; i < m; ++i) { str.push_back(char('a'+i)); } map<string, vector<string> > AB2strs; do { string AB = getAB(str); //printf("%s is of %s/n", str.c_str(), AB.c_str()); AB2strs[AB].push_back(str); } while (next_permutation(str.begin(), str.end())); for (map<string, vector<string> >::iterator it = AB2strs.begin(); it != AB2strs.end(); ++it) { ++nAB; printf("%s (%d): ", it->first.c_str(), it->second.size()); for (vector<string>::iterator str = it->second.begin(); str != it->second.end(); ++str) { printf("%s, ", str->c_str()); } printf("/n"); } printf("/nTotal ABs : %d/n", nAB);} 以下是enumStr(4)的運行結果(2^3=8,初步檢驗通過): AAA (1): abcd,AAB (3): abdc, adbc, dabc,ABA (5): acbd, acdb, cabd, cadb, cdab,ABB (3): adcb, dacb, dcab,BAA (3): bacd, bcad, bcda,BAB (5): badc, bdac, bdca, dbac, dbca,BBA (3): cbad, cbda, cdba,BBB (1): dcba, Total ABs : 8 第三步 進階分析 我們也可以根據前面第5點分析,做出一個更高效的蠻力演算法,不過蠻力畢竟是蠻力,還是讓我們動動腦筋,做個真正高效的演算法吧。我第一次拿到這個問題時,先用蠻力演算法列印出前面的結果,試圖分析其規律,沒成功。便又在紙上演算了了一陣,發現其實可以遞推解決(當然也可以遞迴解決),以下內容最好在紙上演算。比如對於序列”AAA”,字母d只可能在第3號位置出現一次(abcd);遞推一下,對於序列”AAAB”,e在d前,那麼e可以在第0、1、2、3號位置各出現一次(eabcd、aebcd、abecd、abced)。又比如根據以前面第5點分析,如果我們知道對於序列”AB”,字母c可能在第0號位置出現一次(cab)、在第1號位置出現一次(acb);那麼對於序列”ABA”,字母d會在第1、2、3號位置分別出現1、2、2次,因此”ABA”對應的字串共有5個;同理對於序列”ABB”,字母d會在第0、1號位置分別出現2、1次,因此”ABB”對應的字串共有3個。繼續遞推,對於序列”ABBA”,e在d後,那麼e可以在第1、2、3、4號位置分別出現2、3、3、3次(具體說來,對於d在第0號位置出現2次,那麼e可以在第1、2、3、4號位置各出現2次;d在第1號位置出現1次,那麼e可以在第2、3、4號位置各出現1次,對位加起來就得到前面“2、3、3、3”的結果),因此”ABBA”對應的字串共有11個。到這裡,我們已經發現遞推的規律了:對於AB序列w,用二維數組occurs[][]表示第letter個字母在位置pos出現的次數occurs[letter][pos](這個說法不太嚴格,應該說是w的前面長度為letter的子序列對應的字串中,最大那個字母出現的位置和次數,呵呵,還是比較繞口)。如果字母p在位置q1出現n1次,而AB序列的當前元素為’A’,那麼字母p+1會在位置q1+1, q1+2, . . . , p各出現n1次;如果AB序列的當前元素為’B’,那麼字母p+1會在位置0, 1, . . . , q1各出現n1次;如果字母p還在q2位置出現了n2次,那麼對於’A’ 情況,字母p+1還會在位置q2+1, q2+2, . . . , p各出現n2次;那麼對於’B’ 情況,字母p+1還會在位置0, 1, . . . , q2各出現n2次。需要把這些情況都累加起來。對於序列”ABBAA”,遞推表如下:1, 0, 0, 0, 0, 0 字母a在位置0出現1次0, 1, 0, 0, 0, 0 字母b在位置1出現1次1, 1, 0, 0, 0, 0 字母c在位置0、1分別出現1次2, 1, 0, 0, 0, 0 字母d在位置0、1分別出現2、1次0, 2, 3, 3, 3, 0 字母e在位置1、2、3、4分別出現2、3、3、3次0, 0, 2, 5, 8, 11 字母f 在位置2、3、4、5分別出現2、5、8、11次可知對應的字串有26個。如果細心,已經能發現遞推中的部分和(partial sum)關係。 第四步 解決 既然遞推關係有了,很容易就能寫出代碼。這個演算法的複雜度是O(N^3)。 // CODE 5// the better way// O(N^3)int countABbetter(const string& AB){ assert(AB.find_first_not_of("AB") == string::npos); int v = (int)AB.length(); int m = v + 1; // 'letter' at 'pos' occurs 'occurs[letter][pos]' times. vector<vector<int> > occurs(m, vector<int>(m, 0)); // letter 'a' at pos 0, 1 time occurs[0][0] = 1; for (int letter = 1; letter < m; ++letter) { for (int pos = 0; pos < letter; ++pos) { int first_pos = 0; int last_pos = 0; if (AB[letter-1] == 'A') { // after current pos first_pos = pos + 1; last_pos = letter; } else { assert(AB[letter-1] == 'B'); // before (and at) current pos first_pos = 0; last_pos = pos; } int occur = occurs[letter-1][pos]; for (int t = first_pos; t <= last_pos; ++t) { occurs[letter][t] += occur; assert(occurs[letter][t] >= 0); } } } return accumulate(occurs[m-1].begin(), occurs[m-1].end(), 0);} 第五步 最佳化 前面提過一句,在遞推的過程中其實隱藏了一個“部分和”的關係,利用這一性質,可以很容易地將複雜度降為O(N^2),而且遞推只是根據當前字母的出現位置退出下一字母的出現位置,因此可以省去2維數組,改用兩個vector就行了。最後的代碼就是前面一開始列出的 CODE 1。 第六步 展望 我猜測演算法的複雜度能進一步降到 O(N log N),不過自己已經沒有能力實現了。另外,為了附庸風雅一把,我發現整個遞推演算法的過程如果用矩陣來描述,會變得相當清楚。比如對於序列”ABAAB”,很容易構造矩陣A1、B2、A3、A4、B5(每個矩陣都是6階方陣),初始向量x=[1 0 0 0 0 0]
T,產生向量y=B5*A4*A3*B2*A1*x,那麼對應的字串有sum(y)個(sum表示y的各分量之和)。註:也可以定義初始向量x=[1],矩陣A1是2x1、矩陣B2是3x2、矩陣A3是4x3、……、矩陣B5是6x5,一樣可以計算出向量y。例如:(這些矩陣中的元素都是0或1,排列起來像三角形(因為是求部分和),很有規律的。)A1 = [0; 1]B2 = [1 1; 0 1; 0 0]A3 = [0 0 0; 1 0 0; 1 1 0; 1 1 1]A4 = [0 0 0 0; 1 0 0 0; 1 1 0 0; 1 1 1 0; 1 1 1 1]B5 = [1 1 1 1 1; 0 1 1 1 1; 0 0 1 1 1; 0 0 0 1 1; 0 0 0 0 1; 0 0 0 0 0]算出y = B5*A4*A3*B2*A1 = [9 9 9 8 5 0]
Tsum(y) = 40,與前面程式的結果相同。 . 完 .