【翻译】深入算法

321 阅读5分钟

第十一章 深入算法

书籍出处: 《Learning JavaScript Data Structures and Algorithm》

作者: 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 动态规划和分治法(用于并归算法)是不一样的。尽管分治法会把问题分解成互相独立的子问题,动态规划则是把成互相依赖的子问题。

另一个例子是斐波那契数列。我们会把斐波那契问题拆解成更小的子问题。

在使用动态规划技巧来解决问题时,有三个重要的步骤:

  1. 定义子问题。
  2. 实现解决子问题的重复(在此步骤中,我们需要按照上一节中讨论的递归步骤进行递归操作)
  3. 识别并解决基准情况。

这些问题,可以通过动态规划来解决:

  • 背包问题
  • 最长公共子序列
  • 矩阵链相乘
  • 硬币找零
  • 全匹配最短路径

最小找零问题

最小找零问题是找零问题的变种。找零问题用于在给定货币种类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》