第一章
用谜语解开算法世界
从前,有个小岛只住着和尚。有些和尚的眼睛是红色的,而另一些是褐色的。红色眼睛的和尚受到诅咒,如果得知自己的眼睛是红色的,那么当晚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 算法等诸多方法。
各种搜索算法的学习核心可以归纳为 “ 效率”。如果说可读性是算法的形式,那么效率就是算法的内容。这些优化的问题中派生出了一个很深奥的主题——动态规划法。