演算法枕邊書 精華整理 —— 不持續更新
第一章
用謎語解開演算法世界
從前,有個小島只住著和尚。有些和尚的眼睛是紅色的,而另一些是褐色的。紅色眼睛的和尚受到詛咒,如果得知自己的眼睛是紅色的,那麼當晚12點必須自行了斷。
和尚們之間有一條不成文的規定,彼此不能提及對方眼睛的顏色。小島上也沒有鏡子,也沒有可以反射自己容貌的物體。因此,任何人都無從得知自己的眼睛的顏色。出於這些原因,每個和尚都過著幸福的日子,也沒有一個和尚自我了斷。
有一天,島上來了一個旅客,她對這個詛咒毫不知情,因而,這位遊客對和尚們說:
“你們當中至少有一個位的眼睛是紅色的”。
無心遊客離去,和尚們卻惴惴不安,那麼會出現什麼最壞的情況?
答案:若小島上共有 n 個紅眼遊客,那麼第 n 個晚上將有 n 個 和尚同時自我了斷。
設計精妙演算法
有一個能夠儲存99個數值的陣列 item[0], item[1], item[2],..., item[98]。從擁有1~100 元素的集合 {1,2,3,4,5,...,100}中,隨機抽取99個元素儲存到陣列中,集合共有100個元素,而陣列只能儲存99個元素,所以集合一定會留下一個元素,問集合中剩下的一個元素是什麼。
const total = 5050; for(var i = 0; i< 100; i++){ total = total - item[i]; } console.log(` 剩下的數值是 ${total}`);
迴文世界
無論正著讀還是倒著讀全都相同的單詞或短語稱為“迴文”(palindrome )。編寫函式,判斷輸入的字串是否為迴文,是為true,否則為false
function isPalindrome(palindrome){ if (!palindrome) return false; // null或undefined palindrome += ""; for(var i = 0;i < palindrome.length/2; i++){ if(palindrome[i] !== palindrome[palindrome.length-i-1] ){ return false; } return true; } }
上面這種方式是傳統的採取比較字串的第一位與最後一位並前後逐個比較的方法,當字串比較短的時候,可以採用這種方法。可以明顯注意到,每次執行迴圈的時候,都會執行一次 palinedrome.length-i-1
。如果可以把它放在 for 迴圈的外面執行,就可以提高效率。
下面這種方法是利用 javaScript 自帶的一些方法實現的。
function isPalindrome(palindrome){ if (!palindrome) return false; // null或undefined palindrome += ""; return palindrome === palindrome.split('').reverse().join(''); }
這種方法很方便,但效率不高,字串分割,倒轉,聚合都需要很多額外的操作。
另外有 一則數學觀察報道與迴文相關,非常有趣。1984年,電腦科學家在一篇雜誌上,發表了一篇文章。提出了一個有趣的演算法。
- 選擇任意數值;
- 翻轉此數值(例如,13 -> 31),並將原數值和翻轉的數字相加(13 + 31)
- 相加的結果若不是迴文數,則返回2反覆執行,若是迴文則終止演算法。
大部分數值會有迴文數,但也不能證明所有數值會有對應的迴文數。有些數值妨礙了演算法的通用性,其中最小的數就是 196 。
這個數值被稱為 “196數值” 或 “196問題”。
康威的末日演算法
丟擲一個簡單的問題,2199年7月2日是星期幾?
在解決這個問題之前,我們先來了解一下。“年”代表地球圍繞太陽公轉一週所耗的時間,“月”代表從一個滿月到下一個滿月所耗的時間,“日‘代表地球自轉一週所耗的時間,這些都是需要準確掌握季節變化的的農耕文化為中心發展的”刻度“。但是令人可惱的是,無論如何精確製作這種刻度,都不能與太陽、地球、月球三者的運動100%吻合。
例如,兩個滿月之間的實際平均時間為 29.5 日。若將所有月份都定義為29.5日,那麼一年應該是364日。如果製作一年為354日的日曆,那麼隨著時間的流逝,會發生月份和季節不相符的現象。為了彌補這個缺陷。埃及天文學家最早設計了我們今天所用的 365 天、每 4 年 增加 1天的 ”演算法“。雖然這種月曆使用了相當長的時間,但還是會有微小的誤差。微小的誤差累計到1582年時,月曆與季節相差了6日。最終,當初的教皇格雷戈裡十三世宣佈,一個新世紀開始的年份(即能被100整除的年份)若不能被400整除,則不是閏年。
上述規則總結為:
- 如果年份能夠被 4 整除,那麼該年份是2月份需要新增 1 日的 “閏年”。因閏年多出 1 日,所以當年為 366 日。
- 如果年份能被 100 整除(即新世紀開始的年份)但不能被 400 整除,那麼該年不是閏年。
康威教授的末日演算法執行原理非常簡單。為了判斷不同日期的星期,演算法中首先設立一個必要的 “基準” 。然後根據星期以7位迴圈的原則和對閏年的考慮,計算日期對應的星期。其中,充當 “ 基準”的日期就是 “末日“。
平年時,2 月 28 日設定為 “末日”,到了閏年,將 2 月 29 日設為 “末日”。只要知道特殊年份(例如 1900年)“末日”的星期,那麼根據康威演算法 即可判斷 其他日期的星期。
例如 2003 年的 “末日” (即 2 月 28 日)是星期五,那麼當年聖誕節(12 月 25 日)是星期幾呢?
星期是以 7 為迴圈(mod7),所以與 “末日” 以 7 倍數為間隔的日期和 “末日”具有相同的星期。利用這個原理,先記住每個月中總是與 “末日”星期相同的一個日期,即可以快速地算出末日演算法。
下面是2003年每個月中總是與 “末日” 星期相同的一個日期。
04月04號 06月06號 08月08號 10月10號 12月12號 09月05號 05月09號 07月11號 11月07號 03月07號
這些日期與“末日”的日期差都是 7 的整數倍。因為2003年的末日是 “星期五”,所以12月12日也是星期五。
$12+7*2 = 26$
所以2003年12月26日是星期五,那麼12月25日就是星期四。
解決這個問題之後,我們可能會考慮,如果是跨年的聖誕節又要怎麼計算。這種情況下,要記住“末日”的星期每跨一年都會 加1,若遇到閏年就會加2。例如,1900年的末日是星期三,那麼1901年的末日是星期四,1902年是星期五,1903年是星期六,而1904年(閏年)是星期一。
對於這個規律,康威演算法提供瞭如下的列表。
6, 11.5, 17, 23, 28, 34, 39.5, 45, 51, 56, 62, 67.5, 73, 79, 84, 90, 90.5
根據列表,假如 1900年的“末日”是星期三,那麼1906年、1907年、1923年也都是星期三。可以注意到列表中有小數位的數字,例如11.5 代表的意思是1911年是星期二,而1913年是星期四。這要記住這個列表就可以生成所有20世紀年份的末日基準,不需要複雜計算出各年份的“末日”。既然說是“世紀”,那麼就意味著當年份跨世紀時,康威列表就會失去作用。對於不同世紀的年份,沒有什麼特別的方法能夠猜出“末日”的星期。只能將被 100 整除的年份表示為日曆形式時,得到一些規律而已。
日 | 一 | 二 | 三 | 四 | 五 | 六 |
---|---|---|---|---|---|---|
1599 | 1600 | 1601 | 1602 | |||
1700 | 1701 | 1702 | 1703 | 1704 | 1705 | |
1796 | 1797 | 1798 | 1799 | 1800 | 1801 | |
1897 | 1898 | 1899 | 1900 | 1901 | 1902 | 1903 |
1999 | 2000 | 2001 | 2002 | 2003 | ||
2100 | 2101 | 2102 | 2103 | 2104 | 2105 | |
2196 | 2197 | 2198 | 2199 | 2200 | 2201 | |
2297 | 2298 | 2299 | 2300 | 2301 | 2403 | |
2399 | 2400 | 2401 | 2402 | 2403 | ||
2500 | 2501 | 2502 | 2503 | 2504 | 2505 |
從上面的日曆中,可以看出2199的”末日“是星期四,那麼回到一開始問的問題,2199年的7月2日是星期幾,可以輕易地算出來,答案是星期二。
原文中,作者留下了一道作業題目,是以末日演算法為基礎程式設計編寫程式,輸入以“年月日”形式組成的日期,能夠輸出相對應的星期。經過思考與查詢,除了末日演算法以外,還有一個 基姆拉爾森計算公式
好像更可以解決這個問題,因為末日演算法的缺陷是跨世紀存在問題,並且需要知道一個末日的基準。
基姆拉爾森計算公式
W= ( d + 2*m + 3*(m+1)/5 + y + y/4 - y/100 + y/400 )%7 //C++計算公式
C++ 中的 /
符號是整除的意思。在公式中 d
代表日期中的日數, m
代表日期中的月份數, y
代表年份數。注意:公式中,把1、2月看成了上一年的十三和是十四月,例如:2004-1-10則換成2003-13-10來代入公式計算。根據這些原理,用javaScript 實現程式碼如下:
function getWeek(y, m, d){ if(m == 1 || m == 2){ m += 12; y--; } return (d + 2*m + Math.floor(3*(m+1)/5) + y + Math.floor(y/4) - Math.floor(y/100) + Math.floor(y/400))%7; } function getWeekName(y, m, d){ const Weeks = ['星期一','星期二','星期三','星期四','星期五','星期六','星期日']; return Weeks[getWeek(y, m, d)]; } console.log(getWeekName(2018,9,17)); // 星期一
Math.floor
返回小於或等於一個給定數字的最大整數(向下取整)
當然,如果嫌麻煩的話,其實js 的 Date
物件其實也有類似方法
new Date().getDay(); // 1 new Date('2018/9/17').getDay(); // 1 // [0~6]代表星期日到星期六
第二章
排序演算法
排序演算法雖然是基礎理論,但包含了非常豐富的內容,從某種意義上講,程式設計中的所有演算法歸根到底都是排序演算法。排序演算法不僅包含分治法或遞迴演算法等核心方法,還包含演算法的優化、記憶體使用分析等具體事項。因此,排序演算法雖然基礎,但絕不簡單。
在快速排序、氣泡排序、選擇排序、插入排序、歸併排序、基礎排序等排序演算法中最廣為人知的就是快速排序。以遞迴為基礎演算法而成的。
下面簡單介紹快速排序演算法,虛擬碼。
quicksort(list){ if(length(list) < 2){ return list } x = pickPivot(list) list1 = { y in list where y < x} list2 = { x } list3 = { y in list where y > x} quicksort(list1) quicksort(list3) return concatentate(list1, list2, list3) }
上面虛擬碼的含義
x x x
x
取最小值或者是最大值的情況是最壞的條件,因為這意味著 邊側的列表有一邊可能為0,而另一邊是原來的列表長度。根據 x
的不同宣發會有很多的變形,演算法的效能也會有所不一樣的地方,這種變形並不只存在於快速排序法中。學習排序演算法不要死記硬背某種演算法的程式碼,而是理解並學會質疑實現演算法的核心程式碼,這種方法真的是最佳的,只有這樣才可以吃透演算法。
下面有一個例子,給出存在有整數的陣列 array
,編寫函式實現以下的功能:若 array
中的元素已經排序則返回 1,否則返回 0 。函式特徵如下:、
int isSorted(int* array, int length)
下面是答案
int isSorted(int* array, int length){ int index; for(index = 0; index < length; index++){ if(array[index] > array[index - 1]){ return 0; } } return 1; }
快速排序的 javaScript 實現
function quickSort(arr){ if(arr.length <= 1) { return arr; } // 找出基準並從原陣列中刪除 const pivotIndex = Math.floor(arr.length/2); const pivot = arr.splice(pivotIndex,1)[0]; // 定義左右陣列 let left = []; let right = []; // 比基準小的放在 left,否則在right for(let i = 0;i < arr.length;i++){ if(arr[i] <= pivot){ left.push(arr[i]) }else{ right.push(arr[i]) } } // 遞迴 return quickSort(left).concat([pivot],quickSort(right)) } const a = [12,4,543,234,534,534,32,562,563,3,23,53,1,5]; console.log(quickSort(a)); // (14) [1, 3, 4, 5, 12, 23, 32, 53, 234, 534, 534, 543, 562, 563]
搜尋演算法與優化問題
排序和搜尋常伴相隨,高德納教授舉例如下。
假設有如下兩個集合。
A = { ,,..., } B = { ,,..., }
設計演算法判斷集合 A 是否為 集合 B 的子集。即
很多人可能第一個想法是“暴力破解法”,就是遍歷兩個集合,取出裡面的元素進行比較,如果有相同的則 break
跳出迴圈,而最外面返回 true
,如果有迴圈中沒有相同的則直接返回 false
,主要用的是巢狀 for
迴圈。
這種演算法在功能符合以上要求,但是當要比較的兩個集合的長度非常大時,效能就會急速下降。
巢狀 for
迴圈的演算法,執行速度與兩個迴圈的最大迴圈次數之積成反比,演算法的整體執行速度會是 ,
是考慮到迴圈內部消耗的時間而設定的常數。
下面第二個演算法效率會更高,若集合 A 和集合 B 已按照先相同順序排序,那麼判斷 A 是否為 B 子集的過程會非常簡單。
首先對兩個集合進行排序,當迴圈中,A集合的a元素對應B集合的b元素,那麼在B集合中查詢A集合的下一個元素aa的時候,就不用從頭開始查詢,而是直接從b後面的元素開始即可。這是利用排序大大提高演算法效能的典型案例。高德納教授將執行這種演算法的一般速度稱為 Line"/> 。
和
分別代表排序集合 A 和 集合 B 的所用的速度,而常數
表示上述步驟進行比較時耗費的時間。(公式並不是經過嚴密的數學原理推匯出來的,而是學習電腦科學的人們通過先約定的規則推匯出來的)。
搜尋演算法會不斷提問,對資料結構中儲存的數值以最快、最高效的方法找出特定值。
下面丟擲一個問題,有一棟大樓,未知層數,有一個分割獎勵與懲罰的特定層,如果選擇了懲罰則結束遊戲,但是有五次重新開始的機會,在這五次中,要怎麼樣找到特頂層。(原文是用生死來分割,我覺得不怎麼好聽就改成獎勵與懲罰了)。
若用暴力法解決這道題目的話,可以從一樓一直往上,假如分割層在64樓的話,那麼就逃嘗試63次才可以找到。暴力法無法在 5 次機會內找到特定層。
而利用“二分法檢索”就可以規定次數內找到特定層。檢索過程中,為了檢索(二叉樹內)按順序儲存的資料,首先選擇中間位置(或二叉樹根節點)的一個值。若查詢的數值比選擇的數值大,就移向右側(更大的一側),若查詢的數值比選擇的小,則移向左側(值更小的一側)。
為了得到答案,假設特定層是第 17 層,那麼選擇 17 層以上的會收到懲罰,而從16層以下的則不會。64層,相當於根節點的中間樓層是64除以2的第32層,下面是演算法的執行過程。
- 選擇 32 層,受到懲罰,特定層在32以下,重新開始,選擇 16(32/2) 層
- 選擇 16 層,不會收到懲罰,特定層在16以上,選擇24(32與16的中間值)層
- 選擇 24 層,受到懲罰,特定層在24以下,重新開始,選擇18(24與16的中間值)層
- 選擇 18 層,受到懲罰,特定層在18以下,重新開始,由2知道,特定層在16以上,那麼特定層就是17.
- 選擇17 層,找到分割層,在 5 次機會內成功。
如果特定層是 2 的倍數,那麼能更快地求解。
排序演算法中最簡單的是快速排序法,搜尋演算法中,最簡單的“二叉樹搜尋”。利用數的搜尋演算法時,不僅可以利用二叉樹,還可以利用 B 樹、B- 樹、B+樹或雜湊。不僅如此, 從字串中搜索特定字串模式的“字串匹配”演算法也包含 KMP 演算法、BM 演算法、 Rabin-Karp 演算法等諸多方法。
各種搜尋演算法的學習核心可以歸納為 “ 效率”。如果說可讀性是演算法的形式,那麼效率就是演算法的內容。這些優化的問題中派生出了一個很深奧的主題——動態規劃法。