1. 程式人生 > 其它 >《挑戰程式設計競賽——世界一流程式設計高手的經驗總結》閱讀筆記(第二章 初出茅廬——初級篇)

《挑戰程式設計競賽——世界一流程式設計高手的經驗總結》閱讀筆記(第二章 初出茅廬——初級篇)

第二章 初出茅廬——初級篇

從第二章開始內容就多了起來,由於以後可能會有很多東西想寫,所以儘可能壓縮篇幅,這本書還是以每一章總結一篇文章,瀑布流可以讓內容更加內聚,不會太過分散;而且這本書是每個章節對應一個練習題集,整體來看題目數量是比較少的,所以一章一篇總結是合適的

最基礎的“窮竭收縮”

搜尋這裡大概脈絡是:

  1. 介紹了遞迴函式:自己呼叫自己
  2. 通過斐波那契數列問題,引出記憶化搜尋儲存重複運算的結果的思想
  3. 通過“部分和問題”和“園子積水問題”(本質是圖搜尋),說明DFS
  4. 通過“迷宮最短路徑”問題,說明BFS
  5. 介紹了C++中求 \(n!\) 的函式next_permutation,介紹了剪枝的思想

下面對其中一部分有趣的地方做一些說明

遞迴函式和斐波那契數列

斐波那契數裡的定義如下:
\(\begin{cases} a_0 = 0 \\ a_1 = 1 \\ a_n = a_{n-1} + a_{n-2} \end{cases}\)

所以使用遞迴可以很方便的求解\(a_n\)

int fib(int n) {
	if(n <= 1) return n;
	return fib(n - 1) + fib(n - 2);
}

但是展開以後可以發現fib這個函式呼叫次數是指數級的,拿fib(10)來說,fib(10) = fib(9) + fib(8),fib(9) = fib(8) + fib(7),也就是計算(或者說呼叫)一遍fib(10)需要計算兩遍fib(8);而計算一個fib(8)則需要計算兩遍fib(6);對於奇數情況我們從fib(9)展開,可以得到同樣的結果

即:計算一個fib(n)需要計算2遍fib(n-2),展開以後會發現,計算一個fib(n)需要計算\(2^k\)遍fib(n-2*k),因此演算法是指數級的

深度優先搜尋

最開始學深搜的時候,是大學做了八皇后那道題目,對於第一次接觸遞迴的同學來說dfs還是比較難理解的,需要在腦海中不停反覆模擬搜尋過程,習慣了以後才能應用自如

具體到這本書中的內容,深搜涉及到兩個典型應用:

  1. 列舉:對應的問題是“部分和問題”,n個數字中,能否選取若干數使其和為k,其中n <= 20
    考慮每個數加或不加,列舉所有的方案,對每個方案計算和是否等於k即可。方案總數有\(2^n\)個,每個方案對應dfs最終的一個搜尋節點,因此時間複雜度也是\(O(2^n)\)


    這裡可以擴充套件一下,如果n <= 40,原本的做法會超時,這時候可以使用“中途相遇法(meet-in-the-middle)”來解:將前n/2個數和後n/2個數分為兩波,各自列舉方案,並將各自方案的sum儲存在陣列中,對其中一個數組a排序,遍歷其中一個數組b,並且用二分在排好序的陣列a中查詢k-sum。這樣的複雜度為:\(O(2^{n/2}log2^{n/2})\),“列舉+排序”和“列舉+查詢”均為這個複雜度
    另外還有一種做法,就是將a和b陣列均排序,從小到達列舉a陣列,對當前的\(a_i\),從大到小列舉b陣列中的\(b_j\),保持\(a_i + b_j >= k\),可以發現下標i單調遞增,下標j單調遞減,整個過程的複雜度是\(O(n)\)的(儘管“列舉+排序”的複雜度仍是\(O(2^{n/2}log2^{n/2})\)),理論依據是,兩個數的和(基本)不變,一個數增加,另一個數要減小,這種方法叫做“尺取法”,也叫做“two pointers”

  2. 搜圖:對應“園子積水”問題,更普遍的叫法是“連通塊”問題,給出一個M*N的矩陣,其中'W'代表水,'.'代表陸地,每個單元和周圍8個相鄰單元連通(也有些題目規定4個共邊單元),問矩陣中一共有多少個連通塊
    書中的做法是使用dfs以某個'W'單元為入口進行搜尋,進入該單元后,會將該單元設定成'.',這樣後面的單元就不會重複訪問這個單元了,隨後向周圍8個方向搜尋,由於'W'越來越少,最終一定會結束。每次這樣的搜尋過程都伴隨著一個連通塊的消失,因此搜尋次數就是連通塊數
    之前學過的搜圖過程,都是打vis標記來“判重”,而這次直接把到達的單元“刪掉了”,看似合理且符合直覺,但是不禁要問一聲:WHY?如何證明這樣的過程是對的呢?(下面的證明過程比較毒瘤,“算感”強的意識流選手可以直接跳過)
    當思考打vis標記來判重的過程時,彷彿也碰到了同樣的問題,所以我們需要證明:dfs可以到達連通塊中的每個元素,儘管它看上去顯然成立,是屬於數學中“這也要證?!!”的型別
    這裡的矩陣實際上屬於圖(ghaph)的範疇,一個'W'單元就是一個節點,'W'和周圍8個相鄰的'W'之間存在無向邊,所以一個連通塊對應一個無向連通圖
    那麼dfs可以到達無向連通圖中的每個點嗎?考慮dfs進入一個node的過程:1). 先打vis標記; 2). 對周圍能到達且沒打vis標記的節點進行dfs;
    所以是否存在一個無向圖,經過dfs後,其中某些節點沒有到達?顯然,不可能所有節點都沒有到達,因為我們從某個入口進去了;因為圖是連通的,所以我們一定能找到一個沒有到達的節點v與某個到達過的節點u相連,在節點u退棧之前,根據上述dfs的搜尋步驟,一定會進入節點v
    對於“刪點”的做法也有同樣的證明方法,因此一個連通塊不可能存在沒被刪掉的點

這塊的證明看似又繞又沒有必要,但是很好的回答了一個“這也能證?!!”的問題。

寬度優先搜尋

寬搜比深搜更加直觀,列舉了一個“迷宮最短路徑”問題;從“源點S”到“終點G”,一層一層的將新的節點加入到佇列中去,這個過程就像一顆石頭砸到平靜的水面上
寬搜和深搜如果需要保證複雜度為\(O(n)\)就必須保證每個點只“訪問”一次,對寬搜而言,“訪問”對應著從佇列中取出來的那部分操作
跟深搜一樣,寬搜我們要選一個入口,這個點進入佇列後要標記它,防止對它重複訪問,“入隊時標記”的原因是,有可能同一層次的多個節點會到達下個層次的同一個節點
“所有第k層的點能一步到達的未被訪問的節點為第k+1層的節點”,這樣一層一層的擴充套件順序總能保證到每個點的距離最短,就像高低不同的臺階

一切其他需要注意的地方

  1. 有些特殊狀態的列舉有其專門的方法:例如\(n!\)可以使用next_permutation列舉、二進位制在\(O(3^n)\)列舉集合子集、據說位運算還能列舉\(C_n^k\)
  2. 書中只是介紹了剪枝,是說需要經過n步的決策生成方案,決策到第k步時發現剩下的n-k步無論作出什麼決策方案都是非法的,那就可以不繼續向下了;對應到“狀態搜尋樹”中就是剪掉了一個子樹。需要注意的是,按照什麼策略去剪枝,是可以很靈活很深入的內容,劉汝佳的書裡提到的一些UVa題目中很考研剪枝的策略選取,只是不確定這類題目如今是否流行
  3. 需要計算某些狀態的最小解的時候,經常將狀態初始化為INF,而在dp的刷表法中,經常會遇到d[i][j] = min(d[i][j], d[i][k] + x)這樣的寫法,如果INF取得太大,可能會溢位導致WA,所以INF如何取到既大於所有合法解,又不會在運算中導致溢位,也需要探索(可能沒有最好的辦法,只能引入標記或者-1特判解決)
  4. 怎麼將遞迴寫成迴圈形式,也是一個沒有掌握的專題

(這部分內容才是2.1而已,持續更新中,如果將來有人問我為什麼你寫的比書本上的內容還多,回答是我解釋了作者沒有解釋的,並且擴充了作者沒有提到的)