CUJ:標準庫:標準庫中的搜尋演算法 (轉)

worldblog發表於2007-12-14
CUJ:標準庫:標準庫中的搜尋演算法 (轉)[@more@]

The Standard Librarian: Searching in the Standard Library:namespace prefix = o ns = "urn:schemas--com::office" />

Matthew Austern

http://www.cuj.com/experts/1911/austern.htm?topic=experts

 

The genius as well as the oversights in the design of the Standard C++ library surface in a simple discussion of its linear and binary search algorithms.

--------------------------------------------------------------------------------

  如果你儲存著一系列的元素,或許原因理由之一是因此你能夠在它裡面找到些。在一個集合中找到一個特別的條目是個很重要的問題;所有的書都會寫到它。不奇怪,標準C++執行庫提供了許多不同的搜尋技術。

  在 C++執行庫中,指明一個集合的常用方法是透過iterator指示出區間。區間可以被寫為[first, last),此處,*first是區間內的第一個元素而last則指向最後一個元素的下一個。本文展示了我們如何考慮一個通用問題:給定一個區間和一個準則,找到指向第一個滿足準則的元素的iterator。因為我們將區間表示為不對稱的形式,於是避開了一個特殊情況:搜尋失敗可以返回last,沒有任何元素的區間可以寫為[i, i)。

線性搜尋和它的變種

  最簡單的搜尋是線性搜尋,或,如Knuth所稱呼的,順序搜尋:依次檢視每個元素,檢查它是否為我們正在搜尋的那個。如果區間中有N個元素,最壞的情況就需要N次比較。

  標準執行庫提供了線性搜尋的一些版本;兩個最重要的版本是find() (它接受一個區間和值x,並查詢等價於x的元素)和find_if()(接受一個區間和判決條件p,並查詢滿足p的元素)。線性搜尋的其它版本是find_first_of()(接受兩個區間,[first1,last1)和[first2,last2) ,並在[first1, last1)中查詢第一個等價於[first2, last2)中任何一個元素的元素]和adjacent_find()(接受單一區間,並查詢第一個等價於其後繼元素的元素)。

  舉例來說,假如,v是一個int的vector。你能用下面的程式碼查詢第一個0:

vector::iterator i =

  find(v.begin(), v.end(), 0);

  你也能這樣查詢第一個非0值:

vector::iterator i =

  find_if(v.begin(), v.end(),

  not1(bind2nd(equal_to(),

  0)));

  你能這樣查詢第一個小質數:

A[4] = { 2, 3, 5, 7 };

vector::iterator i =

  find_first_of(v.begin(), v.end(),

  A+0, A+4);

  你能這樣找到第一個重複對:

vector::iterator i =

  adjacent_find(v.begin(), v.end());

  沒有任何獨立版本以對區間進行逆向搜尋,因為你不需要:你能用一個簡單的iterator adaptor來達到相同的效果。比如,在v中找到最後一個0,可以這麼寫:

vector::reverse_iterator i =

  find(v.rbegin(), v.rend(), 0);

  線性搜尋是一個簡單的演算法,它的實現看起來沒什麼可討論的。許多書(包括我的)展示了std::find()的一個簡單的實現:

template

InIter find(InIter first, InIter last,

  const T& val)

{

  while (first != last && !

  (*first == val))

  ++first;

  return first;

}

  這的確是線性搜尋演算法的一個忠實的實現,滿足C++標準的需求;第一個模板引數的名字,InIter,意味著實參只需要是非常弱的Input Iterator[注1]。它看起來可能是如此的簡單,以致於還不如在程式碼中直接手寫出來。雖然如此,還是有一個令人懊惱的問題:這個實現沒有達到它應該的。迴圈條件很複雜,需要為取得的每個元素作兩個測試。有條件的分支昂貴的,並且複雜的迴圈條件不會得到與簡單的迴圈條件同樣程度的。

  問題的答案之一,並是被某些標準執行庫的實作所採用[注2],是“解開”迴圈,每次檢查4個元素。這是比較複雜的解決方法,因為find()然後必須處理殘餘元素(區間不會總是4的倍數!),以及它還需要find()基於Iterator的種類進行分解--“解開”只能工作於Ran Access Iterator指示的區間,對一般情況還是需要使用老的實現。但是,“解開”是有效果的:它將對每個元素的測試的數目從2降到 1.25。它是標準庫的實現人員不需要改動任何介面就能採用的技術。

  你將會在講述演算法的常見書籍中看到一個不同的答案。需要對每個元素作兩次測試的原因是,如果到達區間結束還沒有找到所要找的元素,我們必須承認已經失敗。但是如果我們碰巧所要查詢的元素總是存在,搜尋絕不會失敗時,會怎麼樣?在那種情況下,為區間結束作的測試是多餘的;會沒有任何的理由認為搜尋演算法應該首先掌握區間結束的資訊(there wouldn’t be any reason for the search algorithm to keep track of the end of the range in the first place)。取代std::find(),我們可以如下實現線性搜尋演算法:

template

InIter

unguarded_find(InIter first,

  const T& val)

{

  while (!(*first==val))

  ++first;

}

  Knuth的線性搜尋版本[注3]更接近unguarded_find()而不是std::find()。注意,unguarded_find()不是C++標準的一部分。它比find()危險,通用性上也稍差。你只能在確保有一個元素等價於val時使用它--這通常意味著你自己已經將那個元素放在裡面了,並作為區間結束的哨兵。使用哨兵並不總是成立。(如果你正在搜尋的是一個只讀區間怎麼辦?)但當它可用時,unguarded_find()比標準庫中的所有東西都更快,更簡單。

二分搜尋

  線性搜尋很簡單,並且,對於小區間,它是最好的方法。然而,如果區間越來越長,它就不再是合理的解決方案了。在最近使用T的時候,我想起這個問題。我的XSLT指令碼包括了一個類似於這樣的一行:

  我用來跑這個的XSLT引擎肯定是使用的線性搜尋。我在一個list中搜尋,並對list中的每個條目了這個搜尋。我的指令碼是O(N2)的,它執行需要花幾分鐘。

  如果你正在搜尋一個完全一般的區間,不能比線性搜尋做得更好了。你必須檢查每一個元素,否則你漏掉的可能就是你正在尋找的。但如果你要求這個區間是以某種方式組織的,你就可以做得更好了。

  比如,你可以要求區間是已排序的。如果有一個已序區間,就可以使用線性搜尋的一個改良版本(當你到達一個比所尋找的元素更大的元素時,不需要繼續到區間結束就可以知道搜尋已經失敗了),但更好的方法是使用二分搜尋。透過檢視區間中央的元素,你就可以說出所搜尋的元素在前半部分還是後半部分;重複這個分解過程,你不需要遍歷所有元素就能找到要找的元素。線性搜尋需要O(N)的比較,而二分搜尋只需要O(log N)。

  標準執行庫包含二分搜尋的四個不同版本:lower_bound(),upper_bound(),equal_range()和binary_search()。他們全部都有著相同的形式:接受一個區間、一個試圖查詢的元素,和可選的比較。區間必須是根據此比較函式進行過排序的;如果不提供比較函式,它必須是根據通常的“作為比較函式。

  在四個二分搜尋函式中,最沒用的一個是名字最一目瞭然的那個:binary_search()。它所返回是簡單的yes或no:存在於區間中時返回true,否則為false。但光這麼一個資訊沒什麼用;我從未遇到什麼場合來使用binary_search()。如果你想搜尋的元素存在,你可能想知道它的位置;如果不存在,你可能想知道如果它存在,這個位置是哪裡。

  關於元素的位置,你可以想問幾個不同的問題,而這正是二分搜尋的幾個不同版本存在的原因。當相同的元素存在好幾個複製時,它們的區別就很重要了。舉例來說,假如你有一個int的陣列,然後使用lower_bound()和upper_bound()都找尋同一個值:

int A[10] =

  { 1, 2, 3, 5, 5, 5, 5, 7, 8, 9 };

int* first =

  std::lower_bound(A+0, A+10, 5);

int* last  =

  std::upper_bound(A+0, A+10, 5);

  名字first和last暗示了區別:lower_bound()返回第一個你正在尋找的數值(對本例,是&A[3]),而upper_bound()返回最後一個你正尋找的值的下一個的iterator(對本例,是&A[7])。如果你搜尋的值不存在,你將得到如果它存在的話,應該位於的位置。和前面一樣,我們可以寫:

int* first =

  std::lower_bound(A+0, A+10, 6);

int* last  =

  std::upper_bound(A+0, A+10, 6);

  first和last都將等於&A[7],因為這是6在不違背排序時可以插入的唯一位置。

  實踐中,你看不到lower_bound()的後面立即跟一個upper_bound()。如果你同時需要這兩個資訊,那正是引入最後一個二分搜尋演算法的原因:equal_range()返回一個pair,第一個元素是lower_bound()將要返回的值,第二個元素是upper_bound()的返回值。

  直到此時,我在討論中故意比較粗略:我說了lower_bound()和upper_bound()找一個值,但沒有正確說明它的含義。如果你寫

iterator i =

  std::lower_bound(first, last, x);

而且搜尋成功,你保證*i和x相等嗎?不一定!lower_bound()和upper_bound()從不對等價性進行測試(WQ注:邏輯相等,使用operator==())。它們使用你提供的比較函式:operator(WQ注,即等值性,數學相等)。

  這個區別看起來象吹毛求疵,但它不是。假如你一些具有很多欄位的複雜記錄,你使用其中的一個欄位作為排序的key值(比如,人的姓)。兩個記錄可能有相同的key值,於是,即使所有其它子段都是不同的,它們哪一個也不小於另外一個。

  一旦開始想到記錄和key值,二分搜尋的另外一個問題就變得很自然了:你能用二分搜尋根據key來搜尋記錄嗎?更具體些,假設我們定義了一個struct X:

struct X {

  int id;

  ... // other fields

};

再假設有一個vector,根據元素的id進行過排序。你如何使用二分搜尋來找到一個指定id(比如148)的X?

  一個方法是建立一個有著指定的id啞X物件,並在二分搜尋中使用它:

X dummy;

dummy.id = 148;

vector::iterator

  = lower_bound(v.begin(), v.end(),

  dummy,

  X_compare);

  目前而言,這是最可靠的方法。如果你關心最大程度的可移植性,它是你所應該使用的方法。另一方面,它不是非常優雅。你必須建立一個完整的X物件,雖然你需要的只是其中一個欄位;其它欄位不得不被初始化為預設值或隨機值。那個初始化可能是不方便的,昂貴的,或有時甚至不可能的。

  可能直接將id傳給lower_bound()嗎?也許,透過傳入一個異質比較函式,它接受一個X和一個id?這個問題沒有一個簡單的答案。C++標準沒有完全說清楚是否允許這樣的異質比較函式;依我之見,對標準的最自然的讀解是不允許。在現今的實踐中,異質比較函式在一些實作上可行,而在另外一些上不行。另一方面,C++標準化委員會認為這是一個缺陷,並且在未來版本的標準將明確是否允許異質比較函式[注4]。

總結

C++執行庫還提供了其它一些形式的搜尋演算法。使用find()和lower_bound(),搜尋只限於單個元素,但標準執行庫還提供了serach(),它尋找整個子區間。比如,你可以在一個字串中搜尋一個單詞:

std::string the = "the";

std::string::iterator i

  = std::search(s.begin(), s.end(),

  the.begin(), the.end());

返回值,i,將指向“the”在s中第一次出現的開始處--或,和往常一樣,如果“the”不存在將返回s.end()。還有一個變種以從尾部開始搜尋:

std::find_end(s.begin(), s.end(),

  the.begin(), the.end());

  它返回一個iterator,指向“the”最後出現處的開始,而不是第一個。(如果你認為這很奇怪,search的逆向變種叫find_end()而不是search_end(),那麼你並不孤獨。)

  搜尋可以被封裝入資料結構。最明顯地,標準執行庫的關聯容器,set、multiset、map和multimap,被特別設計為根據key進行搜尋將很高效[注5]。執行庫的string類也提供了許多搜尋用的成員函式:find()、rfind()、find_first_of()、find_last_of()、find_first_not_of()和find_last_not_of()。我建議避免使用它們。我發現這些特殊的成員函式難以記憶,因為它們擁有如此多的形式,並且介面形式與執行庫的其它部分不同;無論如何,他們不會提供任何不能從find()、find_if()、search()得到的功能。

  但是,如果你仍然認為看到了一些重要的省略,你是正確的!我沒有提到hash表,因為標準執行庫中沒有hash表。我提到了search()的子區間匹配,但那當然只是匹配的一個特例--標準執行庫中沒有正則搜尋或任何類似的東西。

  C++標準化委員會剛剛開始考慮對標準執行庫擴充,而hash表和正規表示式是未來版本的標準的優先候選者。如果你認為標準執行庫缺少了什麼,並且你想提交一份提議,那麼現在是你應該開始準備時候了。 

[1] See Table 72 in the C++ Standard. Some of the other search algorithms, which I discuss later, rely on the stronger Forward Iterator requirements.

[2] See, for example, <>.

[3] See “Algorithm Q,” in §6.1 of D. E. Knuth, The Art of Computer Programming, vol. 2, Sorting and Searching, Second Edition (Addison-Wesley, 1998).

[4] See <>. Dave Abrahams had the insight that enabled the proposed resolution to this issue. He pointed out that it’s possible to think of binary searches not in terms of sorting and comparisons, but in terms of partitioning: we’re given a range with the property that all elements before a certain point satiy a condition and all elements after it fail to satisfy the condition, and we’re looking for the transition point.

[5] But these containers aren’t the most efficient choice as often as one might think. See my earlier column “Why You Shouldn’t Use set — and What You Should Use Instead,” C++ Report, April 2000.

 


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-993202/,如需轉載,請註明出處,否則將追究法律責任。

相關文章