轉 賴勇浩:從一道筆試題談算法優化(上)

因爲受到經濟危機的影響,我在 bokee.com 的博客可能隨時出現無法訪問的情況;因此將2005年到2006年間在 bokee.com 撰寫的博客文章全部遷移到 csdn 博客中來,本文正是其中一篇遷移的文章。

 

聲明:本文最初發表於《電腦編程技巧與維護》 2006 年第 5 期,版本所有,如蒙轉載,敬請連此聲明一起轉載,否則追究侵權責任。

從一道筆試題談算法優化(上)

作者:賴勇浩( http://blog.csdn.net/lanphaday

引子

每年十一月各大 IT 公司都不約而同、爭後恐後地到各大高校進行全國巡迴招聘。與此同時,網上也開始出現大量筆試面試題;網上流傳的題目往往都很精巧,既能讓考查基礎知識,又在平淡中隱含了廣闊的天地供優秀學生馳騁。

這兩天在網上淘到一道筆試題目(注 1 ),雖然真假未知,但的確是道好題,題目如下:

       10 億個浮點數中找出最大的 1 萬個。

這是一道似易實難的題目,一般同學最容易中的陷阱就是沒有重視這個“億”字。因爲有 10 億個單精度浮點數元素的數組在 32 位平臺上已經達到 3.7GB 之巨,在常見計算機平臺(如 Win32 )上聲明一個這樣的數組將導致堆棧溢出。正確的解決方法是分治法,比如每次處理 100 萬個數,然後再綜合起來。不過這不是本文要討論的主旨,所以本文把上題的 10 億改爲 1 億,把浮點數改爲整數,這樣可以直接地完成這個問題,有利於清晰地討論相關算法的優化(注 2 )。

不假思索

拿到這道題,馬上就會想到的方法是建立一個數組把 1 億個數裝起來,然後用 for 循環遍歷這個數組,找出最大的 1 萬個數來。原因很簡單,因爲如果要找出最大的那個數,就是這樣解決的;而找最大的 1 萬個數,只是重複 1 萬遍而已。

template< class T >

void solution_1( T BigArr[], T ResArr[] )

{

       for( int i = 0; i < RES_ARR_SIZE; ++i )

       {

              int idx = i;

              for( int j = i+1; j < BIG_ARR_SIZE; ++j )

              {

                     if( BigArr[j] > BigArr[idx] )

                            idx = j;

              }

              ResArr[i] = BigArr[idx];

              std::swap( BigArr[idx], BigArr[i] );

       }

}

BIG_ARR_SIZE 1 億, RES_ARR_SIZE = 1 萬,運行以上算法已經超過 40 分鐘(注 3 ),遠遠超過我們的可接受範圍。

稍作思考

從上面的代碼可以看出跟 SelectSort 算法的核心代碼是一樣的。因爲 SelectSort 是一個 O(n^2) 的算法( solution_1 的時間複雜度爲 O(n*m) ,因爲 solution_1 沒有將整個大數組全部排序),而我們又知道排序算法可以優化到 O(nlogn) ,那們是否可以從這方面入手使用更快的排序算法如 MergeSor QuickSort 呢?但這些算法都不具備從大至小選擇最大的 N 個數的功能,因此只有將 1 億個數按從大到小用 QuickSort 排序,然後提取最前面的 1 萬個。

template< class T, class I >

void solution_2( T BigArr[], T ResArr[] )

{

       std::sort( BigArr, BigArr + BIG_ARR_SIZE, std::greater_equal() );

       memcpy( ResArr, BigArr, sizeof(T) * RES_ARR_SIZE );

}

因爲 STL 裏的 sort 算法使用的是 QuickSort ,在這裏直接拿來用了,是因爲不想寫一個寫一個衆人皆知的 QuickSort 代碼來佔篇幅(而且 STL sort 高度優化、速度快)。

solution_2 進行測試,運行時間是 32 秒,約爲 solution_1 1.5% 的時間,已經取得了幾何數量級的進展。

深入思考

壓抑住興奮回頭再仔細看看 solution_2 ,你將發現一個大問題,那就是在 solution_2 裏所有的元素都排序了!而事實上只需找出最大的 1 萬個即可,我們不是做了很多無用功嗎?應該怎麼樣來消除這些無用功?

如果你一時沒有頭緒,那就讓我慢慢引導你。首先,發掘一個事實:如果這個大數組本身已經按從大到小有序,那麼數組的前 1 萬個元素就是結果;然後,可以假設這個大數組已經從大到小有序,並將前 1 萬個元素放到結果數組;再次,事實上這結果數組裏放的未必是最大的一萬個,因此需要將前 1 萬個數字後續的元素跟結果數組的最小的元素比較,如果所有後續的元素都比結果數組的最小元素還小,那結果數組就是想要的結果,如果某一後續的元素比結果數組的最小元素大,那就用它替換結果數組裏最小的數字;最後,遍歷完大數組,得到的結果數組就是想要的結果了。

template< class T >

void solution_3( T BigArr[], T ResArr[] )

{

       // 取最前面的一萬個

       memcpy( ResArr, BigArr, sizeof(T) * RES_ARR_SIZE );

       // 標記是否發生過交換

       bool bExchanged = true;

       // 遍歷後續的元素

       for( int i = RES_ARR_SIZE; i < BIG_ARR_SIZE; ++i )

       {

              int idx;

              // 如果上一輪發生過交換

              if( bExchanged )

              {

                     // 找出 ResArr 中最小的元素

                     int j;

                      for( idx = 0, j = 1; j < RES_ARR_SIZE; ++j )

                     {

                            if( ResArr[idx] > ResArr[j] )

                                   idx = j;

                     }

              }

              // 這個後續元素比 ResArr 中最小的元素大,則替換。

              if( BigArr[i] > ResArr[idx] )

              {

                     bExchanged = true;

                     ResArr[idx] = BigArr[i];

              }

              else

                     bExchanged = false;

       }

}

上面的代碼使用了一個布爾變量 bExchanged 標記是否發生過交換,這是一個前文沒有談到的優化手段——用以標記元素交換的狀態,可以大大減少查找 ResArr 中最小元素的次數。也對 solution_3 進行測試一下,結果用時 2.0 秒左右(不使用 bExchanged 則高達 32 分鐘),遠小於 solution_2 的用時。

深思熟慮

在進入下一步優化之前,分析一下 solution_3 的成功之處。第一、 solution_3 的算法只遍歷大數組一次,即它是一個 O(n) 的算法,而 solution_1 O(n*m) 的算法, solution_2 O(nlogn) 的算法,可見它在本質上有着天然的優越性;第二、在 solution_3 中引入了 bExchanged 這一標誌變量,從測試數據可見引入 bExchanged 減少了約 99.99% 的時間,這是一個非常大的成功。

上面這段話絕非僅僅說明了 solution_3 的優點,更重要的是把 solution_3 的主要矛盾擺上了桌面——爲什麼一個 O(n) 的算法效率會跟 O(n*m) 的算法差不多(不使用 bExchanged )?爲什麼使用了 bExchanged 能夠減少 99.99% 的時間?帶着這兩個問題再次審視 solution_3 的代碼,發現 bExchanged 的引入實際上減少了如下代碼段的執行次數:

for( idx = 0, j = 1; j < RES_ARR_SIZE; ++j )

{

       if( ResArr[idx] > ResArr[j] )

              idx = j;

}

上面的代碼段即是查找 ResArr 中最小元素的算法,分析它可知這是一個 O(n) 的算法,到此時就水落石出了!原來雖然 solution_3 是一個 O(n) 的算法,但因爲內部使用的查找最小元素的算法也是 O(n) 的算法,所以就退化爲 O(n*m) 的算法了。難怪不使用 bExchanged 使用的時間跟 solution_1 差不多;這也從反面證明了 solution_3 被上面的這一代碼段導致性能退化。使用了 bExchanged 之後因爲減少了很多查找最小元素的代碼段執行,所以能夠節省 99.99% 的時間!

至此可知元兇就是查找最小元素的代碼段,但查找最小元素是必不可少的操作,在這個兩難的情況下該怎麼去優化呢?答案就是保持結果數組(即 ResArr )有序,那樣的話最小的元素總是最後一個,從而省去查找最小元素的時間,解決上面的問題。但這也引入了一個新的問題:保持數組有序的插入算法的時間複雜度是 O(n) 的,雖然在這個問題裏插入的數次比例較小,但因爲基數太大( 1 億),這一開銷仍然會令本方案得不償失。

難道就沒有辦法了嗎?記得小學解應用題時老師教導過我們如果解題沒有思路,那就多讀幾遍題目。再次審題,注意到題目並沒有要求找到的最大的 1 萬個數要有序(注 4 ),這意味着可以通過如下算法來解決:

1)            BigArr 的前 1 萬個元素複製到 ResArr 並用 QuickSort 使 ResArr 有序,並定義變量 MinElemIdx 保存最小元素的索引,並定義變量 ZoneBeginIdx 保存可能發生交換的區域的最小索引;

2)            遍歷 BigArr 其它的元素,如果某一元素比 ResArr 最小元素小,則將 ResArr MinElemIdx 指向的元素替換,如果 ZoneBeginIdx == MinElemIdx 則擴展 ZoneBeginIdx

3)            重新在 ZoneBeginIdx RES_ARR_SIZE 元素段中尋找最小元素,並用 MinElemIdx 保存其它索引;

4)            重複 2) 直至遍歷完所有 BigArr 的元素。

依上算法,寫代碼如下:

template< class T, class I >

void solution_4( T BigArr[], T ResArr[] )

{

       // 取最前面的一萬個

       memcpy( ResArr, BigArr, sizeof(T) * RES_ARR_SIZE );

       // 排序

       std::sort( ResArr, ResArr + RES_ARR_SIZE, std::greater_equal() );

       // 最小元素索引

       unsigned int MinElemIdx = RES_ARR_SIZE - 1;

       // 可能產生交換的區域的最小索引

       unsigned int ZoneBeginIdx = MinElemIdx;

       // 遍歷後續的元素

       for( unsigned int i = RES_ARR_SIZE; i < BIG_ARR_SIZE; ++i )

       {    

              // 這個後續元素比 ResArr 中最小的元素大,則替換。

              if( BigArr[i] > ResArr[MinElemIdx] )

              {

                     ResArr[MinElemIdx] = BigArr[i];

                     if( MinElemIdx == ZoneBeginIdx )

                            --ZoneBeginIdx;

                     // 查找最小元素

                     unsigned int idx = ZoneBeginIdx;

                     unsigned int j = idx + 1;

                     for( ; j < RES_ARR_SIZE; ++j )

                     {

                            if( ResArr[idx] > ResArr[j] )

                                   idx = j;

                     }

                     MinElemIdx = idx;

              }

       }

}

經過測試,同樣情況下 solution_4 用時約 1.8 秒,較 solution_3 效率略高,總算不負一番努力。

 

待續……

發佈了8 篇原創文章 · 獲贊 4 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章