用STL實現DFS/BFS演算法——基於策略的類設計
來源:互聯網
上載者:User
用STL實現DFS/BFS演算法
——基於策略的類設計在引入boost.Multi_index容器之前,我想有必要先整理一下DFS/BFS的代碼。修改是出於以下幾方面原因,其中之一是:近期拜讀了Andrei Alexandrescu的《Modern C++ Design》,深受啟發,書中的第一章講述了基於策略的類設計,因此我也想參照這種設計方法來重寫一次代碼。另外的一個原因是:原來的代碼中使用了queue容器和stack容器,它們會把已搜尋過的結點刪除掉,如果我們想要在容器內進行查重的話,就必須保留所有結點,所以不能再使用這兩種容器。我想先選用一種接近於boost.Multi_index的STL容器來改寫,然後再逐步過渡到真正的Multi_index容器。我們先說換用什麼容器,一開始我也想過使用vector容器,不過由於我們最終的目標是Multi_index容器,它不能象vector那樣進行隨機訪問,它只能實現list容器的功能,為了便於向Multi_index過渡,最終我還是選擇了list。不能使用vector的另一個原因是,DFS要求在容器的中間插入元素(我們很快就會看到這一點),vector不適合做這樣的操作。沒有了queue和stack,我們就要用一個iterator來區分list中那些已搜尋與未搜尋的結點。我把已搜尋和未搜尋的結點分別放在list的兩端,而這個iterator指向它們的分界線,即iterator之前的結點為已搜尋結點,之後的結點為未搜尋結點。當我們需要取出下一個結點來處理時,只要把iterator後移一下就可以了;對當前結點調用nextStep()所得到的下一層結點,則要視DFS或BFS來決定如何插入list;如果是DFS,就把新得到的結點插入到iterator的後面;如果是BFS,則把新的結點插入到list的後端;當iterator到達list的末端時,則表示搜尋結束。用一個圖來表示可能會更直觀一些: 假如我們有初始問題狀態A,從A可以產生下一層(或者說下一步)的狀態B、C、D,同樣從B可以產生下一層的狀態E、F、G。對於DFS,我們的搜尋順序應該是A、B、E…,而這些狀態的產生次序應該是A、B、C、D、E、F、G…,當我們用list來作為存放狀態樹時,我們要用一個迭代器(圖中的箭頭)指向當前處理的狀態。我們把初始狀態A放入list容器,箭頭指向它,然後按以下方法進行迭代:讀出箭頭指向的元素(一個狀態結點),根據該狀態產生下一層結點(所有可能的下一步狀態),逐一插入到箭頭所指位置的後面 (同一層的結點插入的順序不太要緊,為了易於表述,我們假設以D、C、B的順序入棧);然後箭頭後移一格;如此類推,讀出B,產生E、F、G插入箭頭的後面。這樣這個list以及箭頭的行為就象棧的作用一樣了,箭頭左邊的元素為已處理完的狀態結點,右邊的元素為待處理的狀態結點。我們再來看看BFS,正確的搜尋順序應該是A、B、C、D、E…,這個順序與狀態的產生順序完全相同,我們仍舊用list來作為存放狀態樹的容器了,不過插入新結點的位置有所不同。我們把初始狀態A放入list容器,箭頭指向它,然後按以下方法進行迭代:讀出箭頭指向的元素(一個狀態結點),根據該狀態產生下一層結點(所有可能的下一步狀態),逐一插入到list的末端;然後箭頭後移一格;如此類推,讀出B,產生E、F、G插入到list的末端。這樣這個list以及箭頭的行為就象隊列一樣了,與DFS相同,箭頭左邊的元素為已處理完的狀態結點,右邊的元素為待處理的狀態結點。現在我們來看看如何對DFS/BFS搜尋演算法進行基於策略的設計。首先我們設計的應該是一個類模板,而不是象早前的函數模板,該類模板提供一個成員函數讓使用者調用以完成搜尋的工作。我給這個類模板起的名字是StateSpaceTreeSearch(狀態空間樹搜尋),如下:template < class State, template <class> class SearchInserter, template <class> class CheckDup >class StateSpaceTreeSearch{ …}它被設計為接受三個模板參數,每一個分別代表解決具體問題時需採取的一種策略。第一個模板參數State是為具體問題所編寫的問題狀態類(如我們以前見過的SudokuState、QueenState、SokoState),它的資料成員應該用於儲存表示問題狀態所需的資料(如SudokuState用一個二維整數數組來表示數獨遊戲的狀態,而QueenState則用一個一維整數數組來表示棋盤的狀態等等),它的成員函數中應至少包含nextStep()和isTarget(),前者用於從一個狀態推算出下一步的各種可能狀態,後者用於判斷目前狀態是否符合解答的要求。當然為了方便起見或其它要求(如查重演算法的要求),問題狀態類還可能需要提供其它成員函數(如operator<<、operator>>、operator==、operator<、hash()等等)。第二個模板參數SearchInserter是一個模板模板參數,它用於指定使用BFS還是DFS。正如我們前面分析的那樣,如果採用list來存放狀態結點,那麼BFS和DFS的差別僅在於新結點的插入方法。我們需要針對BFS和DFS分別提供一種插入方法(這就是策略了),當使用者用不同的插入方法來執行個體化StateSpaceTreeSearch時,就等於選擇了BFS或DFS。第三個模板參數CheckDup也是一個模板模板參數,不錯,它就是用於指定對狀態進行查重的策略。我們在上一版本中已經提供了四種查重策略(不知道你還記不記得,它們分別是NoCheckDup、SequenceCheckDup、OrderCheckDup和HashCheckDup),這裡我們同樣使用這四種策略。不過,有一點小小的變化,是關於HashCheckDup的。在上一個版本中,CheckDup的使用方法是由調用者選用一種合適的查重方法,執行個體化出一個函數對象,然後把該函數對象作為函數調用參數傳遞給DFS/BFS演算法的函數模板。CheckDup對象的執行個體化代碼由使用者負責,這樣代碼可以比較靈活,但增加了使用者的負擔(使用者有可能寫出錯誤的代碼)。在重新進行基於策略的設計後,使用者只需要指定查重的策略即可,不需要再負責執行個體化出CheckDup對象。CheckDup對象的執行個體化工作由StateSpaceTreeSearch負責,這樣就要求各種CheckDup策略必須要使用相同的格式來執行個體化。當我回過頭來查看原來的四種查重策略的代碼時,我發現,NoCheckDup、SequenceCheckDup和OrderCheckDup三個都是只有一個模板參數的,而HashCheckDup則有兩個模板參數,雖然它的第二個模板參數有預設值,但是這還是會引起某些編譯器的編譯錯誤。所以,我們要讓HashCheckDup也只帶一個模板參數,要做到這一點並不太難,大家看看以下代碼就會明白了。// 仿函式,用於不檢查狀態結點是否重複template <class T>struct NoCheckDup : std::unary_function<T, bool>{ bool operator() (const T&) const { return false; }}; // 仿函式,用vector容器檢查狀態結點是否重複,線性複雜度// 要求狀態類提供operator==template <class T>class SequenceCheckDup : std::unary_function<T, bool>{ typedef vector<T> Cont; Cont states_;public: bool operator() (const T& s) { typename Cont::iterator i = find(states_.begin(), states_.end(), s); if (i != states_.end()) // 狀態已存在,重複 { return true; } states_.push_back(s); // 狀態未重複,記錄該狀態 return false; }}; // 仿函式,用set容器檢查狀態結點是否重複,對數複雜度// 要求狀態類提供operator<template <class T>class OrderCheckDup : std::unary_function<T, bool>{ typedef set<T> Cont; Cont states_;public: bool operator() (const T& s) { typename Cont::iterator i = states_.find(s); if (i != states_.end()) // 狀態已存在,重複 { return true; } states_.insert(i, s); // 狀態未重複,記錄該狀態 return false; }}; // 仿函式,用hash_set容器檢查狀態結點是否重複// 要求狀態類提供operator==以及hash()成員函數template <class T>class HashCheckDup : std::unary_function<T, bool>{ struct HashFcn { size_t operator()(const T& s) const { return s.hash(); } }; typedef hash_set<T, HashFcn> Cont; Cont states_;public: HashCheckDup() : states_(100, HashFcn()) {} bool operator() (const T& s) { if (states_.find(s) != states_.end()) // 狀態已存在,重複 { return true; } states_.insert(s); // 狀態未重複,記錄該狀態 return false; }};前三個CheckDup都與舊版本一樣,而最後一個HashCheckDup則有了小小的變化。新的介面是,要求狀態類提供operator==以及hash()成員函數,後者是用於計算hash值的。HashCheckDup中定義了一個嵌套函數對象類HashFcn,它通過調用狀態類所提供的hash()成員函數來計算hash值並返回給hash_set容器。其它部分都與舊版本一樣,可見改變是很小的。現在,我們的四個查重策略都具有相同的模板參數格式了。我們再回過頭來看看BFS和DFS分別所對應的SearchInserter,它們的代碼如下:// BFS演算法對應的新結點插入策略template <class Cont>class BFSInserter{public: BFSInserter(Cont& c) : c_(c) {} typedef typename Cont::iterator iterator; typedef typename Cont::value_type value_type; void operator()(iterator it, const value_type& v) { c_.push_back(v); //新結點插入到列表的末端,即未搜尋的結點後 }private: Cont& c_;}; // DFS演算法對應的新結點插入策略template <class Cont>class DFSInserter{public: DFSInserter(Cont& c) : c_(c) {} typedef typename Cont::iterator iterator; typedef typename Cont::value_type value_type; void operator()(iterator it, const value_type& v) { c_.insert(++it, v); //新結點插入到未搜尋的結點前 }private: Cont& c_;};BFSInserter和DFSInserter都是函數對象類模板,它們以儲存狀態空間樹的容器為模板參數,提供一個operator()操作符,該操作符函數接受一個指向容器內的迭代器和一個要插入到容器裡的值,它負責執行插入的動作。BFS和DFS分別對應於不同的插入策略。可選的SearchInserter和CheckDup策略都已準備好了,在進入到StateSpaceTreeSearch的實現部分之前,我們再討論一個小問題,就是關於SearchInserter和CheckDup這兩個模板參數的順序和預設值的問題。Andrei Alexandrescu在《Modern C++ Design》中說到,我們應該把最可能被使用者顯式指定的策略放在前面,同時把使用者最可能使用的某種策略作為該類策略的預設值。那麼,第一個問題:SearchInserter和CheckDup分別應該使用什麼預設值?我覺得,很多實際問題會要求給出最少步數的解法,這時就應該使用BFS而不是DFS,所以我選擇了BFSInserter作為SearchInserter的預設值。至於CheckDup,我就選擇了對效能影響最少的NoCheckDup作為預設值。第二個問題:對於SearchInserter和CheckDup,哪一個會更多地被顯式指定?我覺得好象差不多,所以就比較隨意地安排了現在這個次序。下面準備進入StateSpaceTreeSearch。我們再來重溫一次,通常解決一個具體的搜尋問題,除了前面已經提到的State、SearchInserter和CheckDup以外,我們還需要什嗎?對了,就是一個初始狀態(它應該是一個State對象)和一個關於找到解答後的回呼函數(也可以看成是某種策略)。初始狀態無疑應該作為函數調用的參數被傳入,但是回呼函數呢?它應不應該也成為StateSpaceTreeSearch的一類策略呢?我的看法是,State、SearchInserter和CheckDup三部分已經組成了一個具體問題的解法,使用者應該用這三個組成部分來執行個體化出一種具體問題(如推箱子)的解法,如下:StateSpaceTreeSearch<SokoState, BFSInserter, OrderCheckDup> sokoSearch;而當你需要對這種問題的某個特定題目(如一道具體的推箱子題目)進行解答時,你應該執行這個解法sokoSearch,並把題目傳入,等待sokoSearch返回答案,如:sokoSearch(initState);那麼,找到解答後所執行的回呼函數應該屬於問題解法的範疇還是屬於特定題目解答的範疇呢?我認為,把它歸入到後者會更為靈活些。例如,對於同一種問題(如推箱子),我們可以用一個sokoSearch對象來解答多條特定題目,並且每條題目可以選擇不同的回呼函數。如:sokoSearch(initState1, printAnswerAndContinue);sokoSearch(initState2, printAnswerAndStop);所以,我把StateSpaceTreeSearch設計為一個函數對象類模板,即它提供一個operator(),該操作符函數接受兩個參數:一個是問題的初始狀態,另一個是找到解答後的回呼函數,函數的傳回值為整數,表示搜尋結束時找到的解答數量。StateSpaceTreeSearch的代碼如下:// 狀態空間樹搜尋模板// State:問題狀態類,提供nextStep()和isTarget()成員函數// SearchInserter:可選擇BFS或DFS// CheckDup:狀態查重演算法,可選擇NoCheckDup,HashCheckDup,OrderCheckDup等template < class State, template <class> class SearchInserter = BFSInserter, template <class> class CheckDup = NoCheckDup >class StateSpaceTreeSearch{public: typedef list<State> Cont; typedef typename Cont::iterator iterator; template <class Func> int operator()(const State& initState, Func afterFindSolution) const // initState : 初始化狀態,類State應提供成員函數nextStep()和isTarget(), // nextStep()用vector<State>返回下一步可能的所有狀態, // isTarget()用於判斷目前狀態是否符合要求的答案; // afterFindSolution : 仿函式,在找到一個有效答案後調用之,它接受一個 // const State&,並返回一個bool值,true表示停止搜尋, // false表示繼續搜尋 // return : 找到的答案數量 { CheckDup<State> checkDup; Cont states; SearchInserter<Cont> inserter(states); states.push_back(initState); iterator head = states.begin(); //指向下個搜尋的結點 vector<State> nextStates; int n = 0; //記錄找到的解答數量 bool stop = false; while (!stop && head != states.end()) { State s = *head; //搜尋一個結點 nextStates.clear(); s.nextStep(nextStates); //從搜尋點產生下一層結點 for (typename vector<State>::iterator i = nextStates.begin(); i != nextStates.end(); ++i) { if (i->isTarget()) { // 找到一個目標狀態 ++n; if (stop = afterFindSolution(*i)) //處理結果並決定是否停止 { break; } } else { // 不是目標狀態,判斷是否放入搜尋隊列中 if (!checkDup(*i)) // 只將不重複的狀態放入搜尋隊列 { inserter(head, *i); } } } ++head; //指標移到下一個元素 } return n; }};我想,代碼中的注釋已經解釋了許多,我只在這裡簡單的補充說明一下。StateSpaceTreeSearch使用list<State>作為儲存狀態空間樹的容器,不過這個容器並不是StateSpaceTreeSearch的資料成員,而是operator()操作符函數裡的局部變數。也就是說,該容器在每次執行搜尋時產生,搜尋結束後銷毀,不會在兩次搜尋間保留。這樣,執行個體化出一個StateSpaceTreeSearch類就可以多次執行搜尋。具體的搜尋由operator()操作符函數執行,指定策略的CheckDup和SearchInserter都在其中進行執行個體化和使用。operator()中的代碼按照本文開始時對DFS和BFS的分析進行編寫。以推箱子遊戲為例,我們將這樣使用新版的DFS/BFS演算法:StateSpaceTreeSearch<SokoState, BFSInserter, OrderCheckDup> sokoSearch;int n = sokoSearch(initState, printAnswer);作為對比,我把舊版的代碼也貼出來:OrderCheckDup<SokoState> checkDup;int n = BreadthFirstSearch(initState, printAnswer, checkDup);如果你是使用者,你會覺得哪一種用法更方便呢?