ThoughtWorks代碼挑戰——FizzBuzzWhizz游戲 通用高速版(C/C++ & C#)


  最早看到這個題目是從@ 程序媛想事兒(Alexia)最難面試的IT公司之ThoughtWorks代碼挑戰——FizzBuzzWhizz游戲 開始的,然后這幾天陸陸續續有N個小伙伴發表了自己的文章和代碼,本來不想做些什么,但是看了這么多代碼,總有點想寫點什么的欲望。

  我說說我對這個題目的看法,當初看 Alexia 的文章時,也沒有看得很仔細,甚至沒有看這個題目的原出處,一邊在玩英雄聯盟,一邊看了一下題目,Alexia 並沒有貼出相應的代碼要求(我是后來看了大家的文章才看到,偶對什么拉勾網不怎么感冒),不過就我的尿性來講,也不太會往設計模式方向走。就我一貫的思路和作風,還是會往算法和代碼優化的方向走(這個題目的確沒什么算法深度可言)。所以現在在我看來,這個題目可以有兩個方向,一個是走算法以及優化的路線,另一個是設計模式的路線。用設計模式實現必然會損失一些性能。題目里的要求是最好有單元測試,也是很不錯的 idea。

  還有一點,看了這么多園子里的代碼,沒有一個實現可擴展的代碼,即 Fizz,Buzz,Whizz 可擴展,特殊數(3,5,7)可擴展(很多人沒實現擴展,但實現了改變特殊數的值),最大數(默認為100)可擴展(實現這個難度不大,但基本沒人考慮,如果考慮最大數比較大的時候,則還是有一定技巧的,比如一些位操作,只不過看你追求的是什么,在規模比較小的時候,意義不大)。

  我們的思路是:實現高可擴展性盡量簡化邏輯避免使用\(除法)、%(取余)(當前各式主流CPU整數除法都是比較慢的指令),避免使用高等函數(浮點),能夠使用加法絕不使用乘法用加法代替乘法(其實這個思想跟篩法求素數是一個道理,實現篩法的時候你會用乘法嗎?)。唯一思路跟我比較接近的是 @黑耗子FizzBuzzWhizz游戲的高效解法 ,其評論里的代碼更是跟我的想法幾乎一致。

  后記:后來我發現,編譯器在處理被除數是常數的除法上是有優化的,比如/10, /3, /5等,可以轉化成一次乘法和一次位移,所有除以固定數或對固定數取余還是可以接受的,但如果是除以一個變量或對一個變量取余,是不可取的。除以10的商可以由 Value / 10 ~= [ (Value * 0x66666667) / 2^32 ] / 2^2 ] 獲得,可以參考 http://bbs.csdn.net/topics/320096074http://bbs.emath.ac.cn/thread-521-3-1.html (有原理解釋)。

  下面談談我的代碼的優勢,或者說特點:高擴展性,我盡量考慮了擴展的可能性,不過由於內置數據類型的限制,目前最多只能支持64個特殊數,如果還想擴展,也不存在難度。幾乎沒有使用乘法,全部都是加減法,或者查表法代替,用空間換時間,沒有使用高等(數學)函數,只有 left_start_num = special_num * integer_base10[digital]; 這一個地方使用了乘法,當然把數字變成字符串時,fast_itoa_radix_10()函數里還是使用了/10,但是這個是所有方法不可避免的,而且我嘗試用減法代替,效率很低。其實現代CPU的整數乘法已經很快了,還是可以值得考慮的。如果你的目標CPU乘法很慢的話,我的方法更有優勢。

ThoughtWorks 挑戰題之 “FizzBuzzWhizz游戲” 題目如下:

你是一名體育老師,在某次課距離下課還有五分鍾時,你決定搞一個游戲。此時有 100 名學生在上課。游戲的規則是:

1. 你首先說出三個不同的特殊數,要求必須是個位數,比如:3、5、7。
2. 讓所有學生拍成一隊,然后按順序報數。
3. 學生報數時,如果所報數字是第一個特殊數(3)的倍數,那么不能說該數字,而要說 Fizz;如果所報數字是第二個特殊數(5)的倍數,那么要說 Buzz;如果所報數字是第三個特殊數(7)的倍數,那么要說 Whizz。
4. 學生報數時,如果所報數字同時是兩個特殊數的倍數情況下,也要特殊處理,比如第一個特殊數和第二個特殊數的倍數,那么不能說該數字,而是要說 FizzBuzz, 以此類推。如果同時是三個特殊數的倍數,那么要說 FizzBuzzWhizz。
5. 學生報數時,如果所報數字包含了第一個特殊數,那么也不能說該數字,而是要說相應的單詞,比如本例中第一個特殊數是 3,那么要報 13 的同學應該說 Fizz。如果數字中包含了第一個特殊數,那么忽略 規則3 和 規則4,比如要報 35 的同學只報 Fizz,不報 BuzzWhizz。

現在,我們需要你完成一個程序來模擬這個游戲,它首先接受 3 個特殊數,然后輸出 100 名學生應該報數的數或單詞。

比如, 輸入 3,5,7
輸出(片段) 1 2 Fizz 4 Buzz Fizz Whizz 8 Fizz Buzz 11 Fizz Fizz Whizz FizzBuzz 16 17 Fizz 19 Buzz ......
一直到 100

 

  好了,下面來談談我的思路。

  其實很簡單,100個學生報數,從1報到100,根據規則3、4,遇到3個特殊數的倍數的時候需報出該特殊數對應的字符(串),可疊加。那么這些報出來的字符串是這樣的(跟黑耗子的很類似,不過后面還是有點區別的):

  1. Fizz
  2. Buzz
  3. FizzBuzz
  4. Whizz
  5. FizzWhizz
  6. BuzzWhizz
  7. FizzBuzzWhizz

  我們在上面的列表最前面插入一個第 0 位 “0. [空]“,並把第 0 位的 [空] 看成 000,

  把 Fizz 看成二進制的第一位(從右邊數),把 Buzz 看成是二進制的第二位(同前),把 Whizz 看成是二進制的第三位(同前),則該列表可表示為:

  • 0. 000 [空] 
  • 1. 001
  • 2. 010
  • 3. 011
  • 4. 100
  • 5. 101
  • 6. 110
  • 7. 111

  這不是剛好是三位的二進制嗎,如果特殊數有 N 個,則這里列表的長度為 2^N,對於本題默認參數,即為 2 ^ 3 = 8。所以所有的報數除了這 8 種(其中 000 這種是不會被報的)中的7種,再加上不符合規則 3、4、5 的報自己的順序的數字本身(這個我們認為是同一種規則),歸納起來共 8 種不同的規則,我們對每個要報的數,給它一個這個規則的索引,即完成FizzBuzzWhizz游戲的處理(因0. 000 [空] 這個規則用不到,故我們把它用在表示報數字本身這一規則),當要具體報數的時候,我們再去這 8 種規則里取具體要報的字符串或數字即可。

  我們為什么不直接輸出字符串,而輸出索引值?這個嘛,是因為我們這樣做,可以更好的利用二進制的特性,再者可以實現先預計算,到顯示的時候,你要哪個我給你顯示哪個(雖然題目本身沒有要求我們這么做),因為如果列表所有元素都轉換成字符串,還是要花不少時間的。雖然也許理論上是多了一個從索引轉換為字符串的步驟,但邏輯更清晰,所以很多人也是這么干的。而且這么做還會增加一定量的內存消耗,如果最大數很大的話,也是要值得考慮的問題。

  構造這個索引字符串列表很簡單,把二進制和特殊數列表一一對應即可。具體值,參考以上兩個列表。我們需要的是兩個參數,特殊數類型的最大種類 max_word_type,即可構造這個 2 ^ max_word_type 的列表。

  然后,我們構造(設置)索引值列表,根據規則 3、4、5,假設 3 個特殊數分別為 a、b、c,得到下列優先級:

  1. 優先級1:Fizz      (數字里含有第一個特殊數a的);
  2. 優先級2:FizzBuzzWhizz  (同時是a,b,c倍數的數);
  3. 優先級3:FizzBuzz      (同時是a,b的倍數的數);
  4. 優先級4:FizzWhizz    (同時是a,c的倍數的數);
  5. 優先級5:BuzzWhizz   (同時是b,c的倍數的數);
  6. 優先級6:Fizz             (是a的倍數的數);
  7. 優先級7:Buzz            (是b的倍數的數);
  8. 優先級8:Whizz          (是c的倍數的數);
  9. 優先級9:報自己的數字

   跟 @黑耗子 的方法相反,由於我使用了二進制合並(也可以用加法來實現),所以我的執行順序是從下而上的(跟黑耗子文章后面的評論里網友 殘蛹 的方法類似,如果你不能理解我說的,可以去看看那個代碼),即優先處理優先級 9,最后處理優先級 1(后來我用代碼測試了一下,一開始我也認為黑耗子的從上而下的順序可能更好一點,但后來的實踐表明,可能從下而上還稍快一點點,雖然后者重復寫入的次數更多,前者每次寫入都要檢查一遍是否為空,不為空才寫入,而后者完全是覆蓋式的,不用先查詢再寫入)。由於我們用二進制合並的機制,所以優先級  2 - 8 可能算得上是同一個步驟處理的,不分先后,而且互相不受優先級影響,具體實現請看下面代碼:

    // 所有 sayword_index_list 的默認值均為 NORMAL_NUM_INDEX (0), 即默認是報自己的數字
    for (num = 0; num <= max_number; ++num) {
        sayword_index_list[num] = NORMAL_NUM_INDEX;
    }

    // 規則3, 4: 計算(合並)和設置所有特殊數的 mask 值
    for (index = 0; index < max_word_type; ++index) {
        // 取一個特殊數, 從后往前讀
        special_num = special_num_list[index];
        // 如果特殊數不在 [1, 9] 范圍內, 則認為是無效特殊數, 跳過
        if (special_num >= 1 && special_num <= 9) {
            // 該特殊數的 mask 值
            mask = 1 << index;
            for (num = special_num; num <= max_number; num += special_num) {
                sayword_index_list[num] |= (index_mask_t)mask;
            }
        }
        else {
            special_num_list[index] = INVALID_SPECIAL_NUM;
        }
    }

  下面是優先級1的處理,這里可能是稍微復雜那么一點的地方,其實也很簡單。規則 5:學生報數時,如果所報數字包含了第一個特殊數,那么也不能說該數字,而是要說相應的單詞,比如本例中第一個特殊數是 3,那么要報 13 的同學應該說Fizz。 如果數字中包含了第一個特殊數,那么忽略規則 3 和規則 4,比如要報 35 的同學只報 Fizz,不報 BuzzWhizz。也就是說,只要你的數字里面包含第一個特殊數3,那么就只報 Fizz,Fizz 在索引列表里的索引永遠都是 001,因為它永遠都是第一個特殊數對應的字符串。下面就是怎么把所有含有第一個特殊數3的數都找出來,小伙伴們的代碼里都有,而且很簡潔,但不是通用的方法,我們來研究一下通用的算法:

  首先,我們先假設要報的最大數 max_number 有 N 位數字,則這 N 位數字里,可能包含個位,十位,百位,千位,萬位,…… 等,我們以所有十位數包含 3 的數為例,從十位數 3 的右邊來看,分別有 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 共 10 個數字,從十位數 3 的左邊來看,分別有 130, 131, 132, 133, , , 230, 231, 232, 233 , ...... , 330, 331, 332, 333,  ...... 等 。我們看到,十位數 3 的右邊一共只有 30 到 39 共 10 個數字,它是比十位數低的位數,從 0 開始循環,一直循環到 9,然后跟 3 組合而成;然后,十位數 3 的左邊,則是在右邊循環(低位循環)所得到的結果 30 - 39 的基礎上,在左邊加上 “1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,  ...... ”。如此循環,把兩者拼接而成,讓組合起來的數小於等於 max_number 即可,依此類推到百位,千位,萬位 ...... 等等。

我們總結出下面兩條規律:

1、對於某個要報的數的第 M 位數字是 D(即 D 為從右邊數的第 M 位數字),先搜索某位數上(例如個位,十位,百位等)的右邊,去掉該位數以后,從 0 開始循環,一直循環到 (10 ^ M - 1) 為止,再與該第 M 位數字 D 組合起來,記做 “D + 右邊循環”(低位循環)。

2、在每一位 “D + 右邊循環” 數字的基礎上(即前面例子中的 “30, 31, 32, 33, 34, 35, 36, 37, 38, 39”),再從左邊循環。左邊循環一樣簡單,從 1 開始循環,把左邊循環的數跟 “D + 右邊循環” 組合起來,我們得到:“左邊循環 + D + 右邊循環”,稱為 “高位循環”,讓這個組合起來的數不超過 max_number 即可,超過的話,則左邊循環(高位循環)結束。

 

具體實現,請看下面代碼:

    // 規則5: 根據此規則, 設置所有所報數字包含了第一個特殊數 first 的數,
    // 先篩選所有個位數包含 first 的數, 再篩選所有十位數包含 first 的數,
    // 依此類推, 百位, 千位..., 直接達到 max_number
    // FIRST_SPECIAL_NUM_FIXED_INDEX 的值固定為 1, 因為第一個特殊數(僅第一個)的 mask 就是 1

    // 第一個特殊數
    special_num = special_num_list[0];

    // 檢查特殊數是否在 [1, 9] 范圍內
    if (special_num >= 1 && special_num <= 9) {
        // 篩選所有個,十,百,千,萬,十萬,百萬位數等包含 first_special_num 的數
        for (digital = 0; digital < max_digital; ++digital) {
            right_start_num = special_num * integer_base10[digital];
            right_max_num = MIN(right_start_num + integer_base10[digital] - 1, max_number);
            // 右邊的步長恆為 1
            right_num_step = 1;
            // 這里 right_start_num 雖然已經是 first_special_num 的倍數,
            // 但是因為還要進行左邊(高位)的循環, 所以不能省略
            // 右邊循環(該個,十,百,千,萬位的右邊, 即低位循環)
            for (right_num = right_start_num; right_num <= right_max_num; right_num += right_num_step) {
                sayword_index_list[right_num] = FIRST_SPECIAL_NUM_FIXED_INDEX;

                if (digital < integer_base10_length) {
                    left_num_step = integer_base10[digital + 1];
                    left_start_num = right_num + left_num_step;
                    // 左邊循環(該個,十,百,千,萬位的左邊, 即高位循環)
                    for (left_num = left_start_num; left_num <= max_number; left_num += left_num_step) {
                        sayword_index_list[left_num] = FIRST_SPECIAL_NUM_FIXED_INDEX;
                    }
                }
            }
        }
    }

 后記

  后來,我改進了一下這個代碼,“if (digital < integer_base10_length) { 這一句其實是可以拿出來單獨處理的,因為大多數情況下是不會出現這種情況的,即當前位數超過 int32 范圍內最大的 10 的 9 冪次方數(1000000000),此時左邊是不用循環的。雖然這種情況出現的可能性非常低,但考慮完整性,還是保留了這個判斷。而且分開處理后(拿到外層循環只判斷一次,具體可以看 GitHub 的代碼,為什么不貼在這里,因為我覺得這里貼的應該盡量簡潔,能說明問題即可)也只需要一次if判斷即可,而不必像原來那樣在每次循環時都判斷一次,耗時比原來略微減少了一點。

  再后來,我重新研究了一下這段代碼,你可以發現右邊循環的數都是連續的,左邊循環的數的間隔至少是 10 或 10 以上,所有我們何不先循環左邊循環,內部再嵌套右邊循環,這樣寫入時,右邊循環的地址是連續地址,對寫緩存比較有利;而且對於個位數來說,它是沒有右邊循環的,可以特別處理。遺憾的是,測試結果好像還比先右邊循環再循環左邊稍慢一點,由於我們測試的僅僅是 max_number=100 的情況,也許 max_number 更大的時候,先左后右的方法才能體現出優勢。不過對於為什么會出現這種情況,我也是不太能夠理解,也許還是跟緩存或編譯器代碼優化有關(編譯器不是萬能的,有時候可能編譯出來的代碼不一定是最優的),也可能是因為 max_number=100 (太小)的原因,因為即使先循環左邊,間隔也不過才 10 而已,由於我們是重復循環 10000 次,數據基本上已經在緩存里了,所有緩存優化已經變得沒有差別了,主要差別還是在代碼的執行過程上。

具體的代碼請看 /FizzBuzzWhizz_vc2008/src/FizzBuzzWhizz/,里面有三種不同的方法。

GitHub 地址:https://github.com/shines77/FizzBuzzWhizz

代碼分別有 C/C++ 版本和 C# 版本,C# 版本是和 @黑耗子 的版本一起測試的。

程序運行截圖:

下面是各個 C# 版本的運行結果(注:C# 最快的版本比 C++ 最快的版本要慢許多):

那個 “失業青年” 是我寫的 C# 版本,解釋一下為什么要比 “屌絲青年” 的代碼要慢,他們都是固定寫死的版本,我寫的是通用版本,效率自然會低一些(因為做的工作要多一些),而且這是我比較早的版本寫成 C# 的,后來的邏輯改動也不是很大,所以也就懶得改了。如果都寫成通用版本,兩者估計會更接近(他的代碼求最大公約數的時候使用了除法),從邏輯上兩者差別不算很大,所以也不會有很大出入。

下面是 C\C++ 版本(第三種方法 FizzBuzzWhizz_fast() 是所有代碼里最快的)

下面介紹一下 C\C++ 的三個版本:

1. FizzBuzzWhizz_stl():STL版本,使用 std::string 和 std::vector<>,一開始沒有優化的時候,更是用時 500 多 ms(毫秒),比 C# 版本還要慢!后來還做了一些改進,但效率依然不是特別理想;

2. FizzBuzzWhizz_sys():這個版本使用自己分配內存處理字符串數組,並且調用的系統自帶的 strcat(), strcpy(), itoa() 函數,效率依然不夠高;

3. FizzBuzzWhizz_fast():這個版本重寫了 strcat(), strcpy() 以及 itoa() 函數,對這些函數做了一些優化,速度得到一定提升,是目前所有版本里最快的。

 

代碼里有用到 _aligned_malloc() 和 _aligned_free(),這個是微軟自己寫的對齊內存分配函數(其實自己實現也很簡單,我有寫過,懶得拿出來用了),如果你的系統不支持,可以在 common.h 找到 _aligned_malloc 的宏,默認的處理方式是,如果不是微軟的編譯器則會重定義到 malloc 和 free 。本來早期是想讓內存地址對齊到 16 字節的,因為我考慮可能會用 sse2 來優化,后來發現對齊到 4 字節就夠了(如果不用 SSE 的話),而 malloc 默認分配的的地址本來就是 8 字節對齊的(默認情況下,不另外設置),所以 _aligned_malloc 其實是多余的,不過如果是 Visual Studio,默認應該是支持的。如果不支持,也可以修改 common.h 里的代碼,讓宏生效。

從這個程序得到一個啟示,就是不管是 C++ 還是 C#,很有可能在默認的情況,會有一些性能瓶頸,而你不知情,比如以上的 STL 就是,沒想到效率差這么多。C# 也沒有那么慢,雖然歸根結底是要比 C++ 慢的,托管的代碼有這樣的效率還可以接受,對於這個算法,我沒對 Java 做同樣的測試。不過之前我做過一些測試,Java 的 HotSpot 大多數情況下要比 C# 優秀,甚至一個簡單的遞歸版 fibonacci() 比 C++ 還要快,如果你想知道詳情,可以私下交流。

另外,我還發現一個問題,就是每個算法單獨運行比和其他方法一起運行的時候,耗時要多一些。后來想想,可能是 CPU 沒有預熱的原因,因為這種現象我在 C++ 和 C# 里都發現了,現代的 CPU 和主板都具備在空閑的時間自己把 CPU 頻率降下來,以節省能源,而我們的測試代碼雖然循環了 10000 次,但耗時還是很短的,所以一開始執行代碼的時候 CPU 並沒有全速運行,所以我們寫個無用的代碼,讓 CPU 喚醒一下,后面的測試就准確了,從此腿也不酸了,腰也不疼了。CPU 預熱我只在 C++ 版本里寫了,C# 版本沒弄,如果你有興趣,可以自己加上去。

 

關於怎么實現可擴展,為了方便跟黑耗子的代碼做比較,默認只測試了跟他一樣的數據,也沒有寫數據輸入部分,暫時不在這上面浪費時間。

那怎么實現擴展性?請看如下代碼:

void FizzBuzzWhizz_Test_Wrapper_4(const int max_number, bool display_to_screen)
{
    int index, max_num_list;
    const string say_word_list[] = { "Fizz", "Buzz", "Whizz", "Zoozz" };
    int special_num_list[][4] = {
        { 3, 5, 7, 9 },
        { 2, 4, 8, 9 },
        { 3, 6, 8, 9 },
        { 2, 2, 2, 2 },
    };   

    // 最大say_word類型
    int max_word_type = _countof(say_word_list);

    // say_word組合后的最大字符長度
    int max_word_length = 1;
    for (index = 0; index < max_word_type; ++index)
        max_word_length += say_word_list[index].length();

    // 對齊到STRING_ADDR_ALIGNMENT(4字節)
    max_word_length = (max_word_length + STRING_ADDR_MASK) & (~STRING_ADDR_MASK);

    // 測試參數總組數
    max_num_list = sizeof(special_num_list) / sizeof(special_num_list[0]);

    if (display_to_screen)
        max_num_list = 1;

    for (index = 0; index < max_num_list; ++index) {
        display_special_num_list(max_word_type, special_num_list[index]);

        // 使用stl的std::string, 速度慢
        FizzBuzzWhizz_stl_Test(max_number, max_word_type, max_word_length, say_word_list, special_num_list[index], display_to_screen);

        // 使用自定義的string array, 采用系統自帶的字符串處理函數, 中等速度
        FizzBuzzWhizz_sys_Test(max_number, max_word_type, max_word_length, say_word_list, special_num_list[index], display_to_screen);

        // 使用自己編寫的字符串處理函數, 較快
        FizzBuzzWhizz_fast_Test(max_number, max_word_type, max_word_length, say_word_list, special_num_list[index], display_to_screen);
    }
}

可以自行修改 say_word_list 和 special_num_list,但要保證 special_num_list 每一行的長度必需跟 say_word_list 的個數相同。當然,say_word_list 的長度在當前的代碼里不是無限的,最多支持 32 個。如果需要,可以擴展為 64 個(通過修改 common.h 里的 index_mask_t 類型即可實現),如果超過 64 個,則需要寫新的代碼以實現 mask 的位操作。

 

此外,我還想了幾條擴展的規則,如下:

(下面是 3 條規則筆者自己加的擴展規則,原題是沒有的,這些規則可以視為題目的難度擴展,其實也只是增加了一些處理規則和邏輯而已,解決方案基本不變。)

6. 如果所報數字包含了第二個特殊數,也忽略規則 3 和規則 4,直接報 Buzz,比如要報 15 的同學只報 Buzz,不報 FizzBuzz;
7. 如果所報數字包含了第三個特殊數,也忽略規則 3 和規則 4,直接報 Whizz,以此類推。
8. 特殊數越在前面的優先級越高,即,如果滿足了規則 5 中的第一個特殊數的條件,則忽略規則 6 中的第二個特殊數的規則,如果滿足了規則 6 中的第二個特殊數的條件,則忽略規則 7 中的第三個特殊數的規則,依次類推。

有興趣的朋友,可以試試。

 

.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM