第一章
用謎語解開算法世界
從前,有個小島只住着和尚。有些和尚的眼睛是紅色的,而另一些是褐色的。紅色眼睛的和尚受到詛咒,如果得知自己的眼睛是紅色的,那么當晚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后面的元素開始即可。這是利用排序大大提高算法性能的典型案例。高德納教授將執行這種算法的一般速度稱為。
和
分別代表排序集合 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 算法等諸多方法。
各種搜索算法的學習核心可以歸納為 “ 效率”。如果說可讀性是算法的形式,那么效率就是算法的內容。這些優化的問題中派生出了一個很深奧的主題——動態規划法。