第十一章 深入算法
作者: Loiane Groner
目前为止,我们已经学习了常用的数据结构和排序搜索算法。编写算法的过程是非常有趣的。在这一章,我们会进一步深入算法的世界。
在这一章,我们会学习到递归(在第八章介绍过)。我们还会介绍动态规划、贪婪算法和big O标记法。
递归
递归是一种先把问题拆分为相同的小问题,先解决相同的小问题、再解决大问题的方法。它通常会涉及到函数自己调用自己。
如果一个函数或者方法自己调用自己,那么它就是使用了递归的方法:
var recursiveFunction = function(someParam){
recursiveFunction(someParam);
}
当然,递归函数也可这样调用:
var recursiveFunction1 = function(someParam){
recursiveFunction2(someParam);
}
var recursiveFunction2 = function(someParam){
recursiveFunction1(someParam);
}
如果我们要执行recursiveFunction,那结果会说如何呢。事实上,递归函数会被无限调用。为此,每一个递归函数都需要有一个让递归停止自我调用的基准情况。
JavaScript调用栈的大小
如果我们忘记给递归函数添加一个基准情况,那么会发生什么事情呢?它并不会被无限执行;浏览器会报错,告诉我们错误类型为调用栈溢出。
每个浏览器的调用栈都有其大小限制,我们可以使用以下代码来测试:
var i = 0;
function recursiveFn () {
i++;
recursiveFn(); //被标亮的那行
}
try {
recursiveFn();
} catch (ex) {
alert('i' + i + 'error: ' + ex);
}
在第37版的谷歌浏览器中,该函数被执行了20,955次,之后浏览器弹出RangeError: Maximum call stack size exceeded。在第27版的火狐浏览器中,该函数被执行了343,429次,之后浏览器弹出InternalError: too much recursion
ECMAScript6进行了尾调用优化。如果一个函数是另一个函数里最后调用的(在这个例子中,就是被标亮的那一行),它会交由“jump”处理,而非“子程序调用”处理。这意味着我们的代码可以在ECMAScript6永远执行,这也是为什么递归函数需要有一个基准情况。
斐波那契数列
现在让我们回到在第十章讲过的斐波那契问题。斐波那契数列可以被这样定义:
- 斐波那契数列的第一项或者第二项为1
- 斐波那契数列的第n项(n>2)为斐波那契数列的第n-1项和斐波那契数列的第n-2项的和
那么,我们开始实现一个斐波那契函数吧:
function fibonacci(num){
if (num ===21 || num === 2){ // {1}
return 1;
}
}
该斐波那契函数的基准情况是斐波那契数列的第一项或者第二项为1。所以,我们进一步完善这个函数吧:
function fibonacci(num){
if (num ===21 || num === 2){ // {1}
return 1;
}
return fibonacci(n-1) + fibonacci(n-2);
}
斐波那契数列的第n项(n>2)为斐波那契数列的第n-1项和斐波那契数列的第n-2项的和。
现在我们已经实现了斐波那契函数。如果我们想知道6的斐波那契数列,下图为执行递归时所调用的函数:
当然,我们也可以通过非递归的方式实现斐波那契函数:
function fib(num){
var n1 = 1,
n2 = 1,
n = 1;
for (var i =3; i<= num ; i++){
n = n1 + n2;
n1 = n2;
n2 = n;
}
return n;
}
为什么要使用递归呢?是因为这样更快吗?其实用递归并不会更快,反而会慢一些。但是递归会更加好理解,并且代码行数会更少。
note 在ECMA6Script中,因为进行了尾调用优化,所以使用递归并不会变慢。但在其他语言中,有递归的代码运行速度会慢一些。
因此,我们通常会使用递归,因为这样可以更好地解决问题。
动态规划
动态规划(DP)是一种通过把问题拆分成更小的子问题,用来解决复杂问题的优化技巧。
我们在之前已经讲过一些动态规划的技巧。其中一个用到了动态规划的问题就是第九章的深度优先搜索。
note 动态规划和分治法(用于并归算法)是不一样的。尽管分治法会把问题分解成互相独立的子问题,动态规划则是把成互相依赖的子问题。
另一个例子是斐波那契数列。我们会把斐波那契问题拆解成更小的子问题。
在使用动态规划技巧来解决问题时,有三个重要的步骤:
- 定义子问题。
- 实现解决子问题的重复(在此步骤中,我们需要按照上一节中讨论的递归步骤进行递归操作)
- 识别并解决基准情况。
这些问题,可以通过动态规划来解决:
- 背包问题
- 最长公共子序列
- 矩阵链相乘
- 硬币找零
- 全匹配最短路径
最小找零问题
最小找零问题是找零问题的变种。找零问题用于在给定货币种类d1…dn的情况下,可以组合出多少种特定零钱的方案。而最小找零问题,是在此基础上,用最好的数量的货币,找出相应的零钱。
比如说,美国发行了如下的货币:d1=1;d2=2;d3=10;d4=25。
如果我们要找36分的零钱,我们可以用一个夸特(25分),一个十分钱,和一便士(一分)。
那么,我们该如何把它转换成一个算法呢?
最小找零问题致力于找到使用硬币数量n最小的方案。但为了达到这一目的,我们要先发现每一个使x<n的解法。然后,我们从较小的值的解决方案中构建解决方案。
现在,让我们看看这个算法:
function MinCoinChange(coins){
var coins = coins; //{1}
var cache = {}; //{2}
this.makeChange = function(amount) {
var me = this;
if (!amount) { //{3}
return [];
}
if (cache[amount]) { //{4}
return cache[amount];
}
var min = [], newMin, newAmount;
for (var i=0; i<coins.length; i++){ //{5}
var coin = coins[i];
newAmount = amount - coin; //{6}
if (newAmount >= 0){
newMin = me.makeChange(newAmount); //{7}
}
if (
newAmount >= 0 && //{8}
(newMin.length < min.length-1 || !min.length)//{9}
&& (newMin.length || !newAmount) //{10}
){
min = [coin].concat(newMin); //{11}
console.log('new Min ' + min + ' for ' + amount);
}
}
return (cache[amount] = min); //{12}
};
}
为了让代码更有条理,我们生成了一个依据已给定的货币来解决最小找零问题的类。让我们一步一步地理解这个算法把。
我们的MinCoinChange类接收代表货币种类的conins参数(行 {1})。在美国的货币体系中,coin参数应该为[1, 5, 10, 25]。我们可以传入任何我们喜欢的货币参数。当然,为了提升算法性能、减少重复运算,我们还要生成cache变量(行 {2})。
之后,我们就进入用于递归解决该问题的makeChange方法。首先,如果amount不是正数,将会返回一个空数组(行 {3});在这个方法的最后,我们会返回一个用于实现最小找零的数组。之后,我们会检查cache变量。如果结果已经被缓存了(行 {4}),我们会返回这个结果;否则我们会执行后面的算法。
我们会基于conis参数来解决这个问题。因此,对于每一个货币(行 {5}),我们会进行newAmount(行 {6})。我们会一直通过减去硬币面值的方式,计算newAmount的值,直到我们计算出这些货币可以找出的最小零钱的值(记住,这个算法会一直计算newAmount的值以致于 x < amount)。如果newAmount是合法值(为正数),我们也会计算其结果(行 {7})。
最后,我们会验证newAmount是否合法,验证minValue(最小数量的货币)是最好的结果,验证minValue和newAmount为合法值(行 {10})。如果所有的这些验证都是正确的,这意味着这个结果比之前的方案要好(行 {11}-比如说,如果要找5分钱,我们可以给五个一分钱,或者一个五分钱)。最后,我们会返回最终结果(行 {12})。
让我们测试一下这个算法:
var minCoinChange = new MinCoinChange([1, 5, 10, 25]);
console.log(minCoinChange.makeChange(36));
注意,如果我们检查cache变量,我们可以到所有1到36分的结果。上述代码的结果为[1, 10, 25]。
如果我们用货币[1, 3, 4]找零6元,会得到以下输出:
new Min 1 for 1
new Min 1,1 for 2
new Min 1,1,1 for 3
new Min 3 for 3
new Min 1,3 for 4
new Min 4 for 4
new Min 1,4 for 5
new Min 1,1,4 for 6
new Min 3,3 for 6
[3, 3]
所以,找零6元的最优解是两个3元的硬币。
贪婪算法
贪婪算法遵循在每一阶段实现该阶段最优的问题解决式探索法,以寻求全局最优解法。它不像动态规划算法,会关注全局。
我们现在用贪婪算法来实现最小找零问题,并观察它与动态规划算法的不同。
最小找零问题
最小找零问题可以由贪婪算法来实现。大多数时候,其得出的结果都是最优的,但对于某些货币,其得出的结果不一定是最优的。
大O标记法
在第十章,我们引入了大O标记法的概念。那么,它到底是什么意思呢。它用于描述算法的表现或者复杂度。
在分析算法时,这些复杂度类型的函数是最常见的:
理解大O标记法
我们该如何衡量一个算法的效率呢?我们通常会使用CPU资源、记忆资源,碟资源和网络资源。在讨论大O标记法时,我们通常是在讨论CPU资源。
我们一起来理解一下大O标记法是如何使用的。
现在,让我们看看这个算法:
function MinCoinChange(coins){
var coins = coins; //{1}
this.makeChange = function(amount) {
var change = [],
total = 0;
for (var i=coins.length; i>=0; i--){ //{2}
var coin = coins[i];
while (total + coin <= amount) { //{3}
change.push(coin); //{4}
total += coin; //{5}
return change;
};
}
用贪婪算法来解决这一问题,比用动态规划来解决要容易太多了。与动态规划相似,我们在初始化MinCoinChange时,要把货币一参数的形式传入(行 {1})。
对于每一个货币(行 {2}-从面值最大的开始遍历,直至面值最小的),我们会把他们的值加到total变量中,而且total需要比amount小(行 {3})。我们会把每次遍历的coin加入到result中(行 {4})和total中(行 {5})。
如你所见,这个方法就简单多了。我们从面值最大的硬币开始遍历,并把符合条件的硬币加入到change数组中。我们不能把同样面值的硬币第二次加入change数组中,我们只能把面值第二小的硬币加入到change数组中,并一次类推。
为了测试代码,我们会使用动态规划中相同的算法:
var minCoinChange = new MinCoinChange([1, 5, 10, 25]);
console.log(minCoinChange.makeChange(36));
其输出结果为 [25,10,1],和用动态规划实现的一样。下图展示了该算法的执行过程:
然而,如果货币为 [1,3,4],并执行贪婪算法,其结果为 [4,1,1]。如果使用的是动态规划,我们得到的是最优结果[3,3]。
虽然贪婪算法比动态规划快且简单,但如我们所见,它不能永远给出最优解。但就平均情况而言,它在相同但执行时间可以给出一个可接受的方案。
O(1)
让我们看看下面这个函数:
function increment(num) {
return ++num;
}
如果我们执行increment函数,该函数的执行时间为X。如果我们传入不同的参数(比如说2)来执行这个函数,其执行时间还是X。在这个过程中,不论参数为何,函数的执行时间是一样的。为此,我们可以说上面代码的复杂为O(1)(常数级别)。
O(n)
现在,我们来看看第十章的序列搜索:
function sequentialSearch(array, item){
for (var i =0 ; i <array.length; i++){
if (item === array[i]){ // {1}
return i;
}
}
return -1;
}
如果我们传入的参数有10个([1, ... , 10])成员,而我们要寻找的是1,而在第一次比较时我们就找到了该值。假设我们每执行一次行 {1},该算法的cost为1。
现在,假设我们要寻找的是11。那么行 {1}会被执行10次(我们遍历数组每一个成员,最后发现没有该值并返回-1).如果行 {1}的cost为1,那么执行10次的cost为10(这比第一次执行的cost要大太多了)。
现在,假设数组有1000个成员([1, ... , 1000]),要寻找1001。此时行 {1}会被执行1000次(并返回-1)。
sequentailSearch函数的cost取决于数组的大小和要寻找的值。如果要寻找的值存在于数组中,行 {1}会执行多少次呢?如果我们寻找的值并不存在于数组中,数组有多大行 {1}就执行多少次,而这就是最坏场景分析。
回到sequentailSearch函数的最坏场景分析,如果数组的大小为10,则其cost为10。如果数组的大小为1000,则其cost为1000。所以我们可以推论sequentailSearch函数的复杂度为O(n),而n为数组的大小。
为了更好的理解刚刚的推论,我们可以修改一下算法来看看:
function sequentialSearch(array, item){
var cost = 0;
for (var i =0 ; i <array.length; i++){
cost++;
if (item === array[i]){ // {1}
return i;
}
}
console.log('cost for sequentialSearch with input size ' + array.length + ' is ' + cost);
return -1;
}
通过输入不同大小的数组来执行sequentailSearch函数函数,我们就可以看的不同的输出了。
O(n²)
我们以冒泡排序为例,讲解O(n²)的复杂度:
function swap(array, index1, index2){
var aux = array[index1];
array[index1] = array[index2];
array[index2] = aux;
}
function bubbleSort(array){
var length = array.length;
for (var i=0; i<length; i++){ //{1}
for (var j=0; j<length-1; j++ ){ //{2}
if (array[j] > array[j+1]){
swap(array, j, j+1);
}
}
}
}
假设行 {1}和行 {2}的cost都为1。我们在此基础上修改该算法:
function bubbleSort(array){
var length = array.length;
var cost = 0;
for (var i=0; i<length; i++){ //{1}
cost++;
for (var j=0; j<length-1; j++ ){ //{2}
cost++;
if (array[j] > array[j+1]){
swap(array, j, j+1);
}
}
}
console.log('cost for bubbleSort with input size ' + length + ' is ' + cost);
}
我们如果对一个长度为10的数组执行冒泡排序,则其cost为100(10²)。如果我们如果对一个长度为100的数组执行冒泡排序,则其cost为10000(100²)。
note 一层循环的复杂度为O(n);两层嵌套循环的复杂度为O(n²);如果一个算法有三层嵌套循环,则其复杂度可能为0(n³)。
比较不同的复杂度
下图展示了不同复杂度所对应的操作数量:
学习算法的网站
我们学习算法,不仅仅是因为学校的要求或者是工作的需要。通过提升我们解决问题的技能、把学习到的算法用于解决实际问题,我们可以成为更专业的开发者。
强化我们知识最好的方法就是把知识用于解决实际问题。而实际并不意味着无聊。在这一节,我们会介绍一些可以愉快地学习算法的网站。
小结
在这一章,我们进一步学习了递归,并了解它可以如何用在动态规划中。此外,我们还学习了贪婪算法。
在讲解大O标记法时,我们还讲解了如何使用大O标记法来计算复杂度。
我们还介绍了一些可以免费学习算法的网站,让我们更好的学习和应用学习算法。
以下为学习算法的网站(有一些网站并不支持提交JavaScript代码,即便如此,我们可以把从本书学习到的逻辑用于其他编程语言中):
这些网站的好处在于,他们会展示生活中真实的问题,以至于我们需要探寻用于解决这一问题的算法。这也告诉我们,从本书所学的算法不仅仅有教育上的意义,同时还可以用于解决实际生活中的问题。
如果你已经开始了你的技术事业,我非常推荐你在GitHub上注册一个账号,这样你就可以在这里提交你用于解决这些网站上各种问题的代码了。如果你还缺乏技术相关的经验,GitHub也可以帮你建立个人的作品集,并帮助你找到第一份技术工作。
注:本文翻译自Loiane Groner的《Learning JavaScript Data Structures and Algorithm》