【翻译】排序与搜索算法

404 阅读13分钟

第十章 排序与搜索算法

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

作者: Loiane Groner

假设我们有一个未排序的通讯录。当我们要添加一个新的联系人时,我们仅仅是在空的一行写下了新的号码。假设你现在要寻找特定的联系人,但是因为这个通讯录是没有排序的,以致于你得从头一个一个地查找。这样的通讯录是非常糟糕的。想象一下你要在乱序的黄页上寻找一个联系人,那会是多么的费事儿。

为此,我们需要对大量的信息进行处理。排序算法和搜索算法是广泛的应用于这类问题的。在这一章,你将会学到最常用的排序算法和搜索算法。

排序算法

学完本章,你应该知道第一步是对现有信息进行排序,第二部才是进行搜索。在这一节,我们会讲到计算机科学中最常用对搜索算法。我们会先讲解速度最慢对算法,之后会讲解速度更快对算法。

在我们讲解算法之前,我们需要生成一个数组来作为搜索和查询对对象:

function ArrayList() {
	var array = []; // {1}
    
    this.insert = funciton(item){ // {2}
    	array.push(item);
    };
    
    this.toString = function(){	// {3}
    	return array.join();
    }
}

如你所见,ArrayList是一个用于储存数组的简单数据结构(行 {1})。ArrayList只有insert方法来添加成员,而insert方法用的是数组原生的push方法(行 {2})。此外,ArrayList还有一个toString方法来把数组所有成员拼成一个字符串,所以我们可以在浏览器的控制台便捷的得到数组的输出结果。

Note

join方法会把数组的所有成员拼成一个字符串并返回。

注意,ArrayList类并没有其他方法来添加或者删来除成员。我们想让ArrayList尽可能的简单,这样我们可以更好地专注在排序和搜索算法上。我们将会为ArrayList添加排序和搜索算法。

现在,我们开始把!

冒泡排序

当我们学习排序算法时,首先要学习的是排序算法,因为它是所有算法中最简单的。然而,这也是运行效率最低的排序算法。

排序算法会比较每两个相邻的成员,如果第一个比第二个大,那么就会交换他们的位置。它之所以叫排序算法,是因为它的成员会按照从小到大的顺序,像泡泡一样往上排列。

现在让我们来实现这个算法:

this.bubbleSort = function(){
	var length = array.length;		// {1}
        for(var i =0; i<length; i++){		// {2}
            for(var j =0; j<length-1;j++){	 //{3}
                if(array[j]>array[j+1]){	 //{4}
                    swap(j,j+1);		 //{5}
                }
            }
        }
}

首先,我们要生成一个用于存储数组大小的变量length(行 {1})。这一步可以帮助我们在 行 {2}和 行 {3}时获取数组的length。当然,这一行也是可以没有的。之后,我们需要一个外层循环(行 {2})来将数组进行从头到尾的迭代,****。之后,我们需要一个内层循环(行 {3}),对数组进行自第一个成员到本轮倒数第二个成员的遍历,然后在内层循环内进行前项后项后项的比较(行 {4})。如果前项比后项大,我们则对两者的值进行交换(行 {5})。

之后,我们需要声明一个私有函数swap:

var swap = function(index1,index2){
	var aux = array[index1];
    	array[index1] = array[index2];
    	array[index2] = aux;
}

为了实现交换,我们需要一个临时变量来储存数组成员的值。我们还会在其他的排序中使用这个方法,这也是为什么我们要把它写在一个函数内。这样我们就可以更好的复用这些代码了。

下图展示了排序搜索的执行过程:

上图中的每一节(形如'[ '的线链接的部分),代表的是外层循环(行 {2});而每一节里面的前后项比较与互换,则是由内层循环实现的(行 {3})。

我们可以使用以下代码来测试以下我们的冒泡算法:

function createNonSortedArray(size){	// {6}
	for(var i=size; i>0 ; i--){
    	array.insert(i);
    }
    return array;
}
var array = createNonSortedArray(5);	// {7}
console.log(array.toString());		// {8}
array.bubbleSort();			// {9}
console.log(array.toString());		// {10}	

为了更好的测试本章的排序算法,我们需要用一个函数来帮助我们生成未排序的数组(行 {6})。如果我们这个函数传入参数5,就会生成一个这样的数组[5,4,3,2,1]。之后,我们只要调用这个函数,并由array变量来接收该函数的返回结果即可(行 {7})。我们可以通过在控制台打印该数组来查看这个未排序的数组(行 {8}),之后调用冒泡排序(行 {9}),并在控制台查看排序后的结果(行 {10})。

Note

在机器进入第二轮的外层循环时(图中的第二部分),数字4和5已经被排好顺序了。然而,在接下来的循环中,我们还是会对已经排好序的部分进行比较。为了避免这些不必要的操作,我们可以给冒泡排序进行小小的改进。

改进后的冒泡排序

this.modifiedBubbleSort = function(){
	var length = array.length;		
        for(var i =0; i<length; i++){		
            for(var j =0; j<length-1-i ;j++){	// {1}
                if(array[j]>array[j+1]){	
                    swap(j,j+1);		
                }
            }
        }
}

下图展示了改进后排序算法的执行过程:

Note

这样,我们就不用再对已经排序的结果进行比较了。但是即便如此,这个算法还是有着O(n2)的复杂度。

我们会在下一章讨论程序的复杂度。

选择排序

选择排序的思想是,从数组中选出最小的放在第一个位置,第二小的放在第二位,并依次类推。

以下为选择排序的代码:

this.selectionSort = function(){
    var length = array.length,
    	indexMin;				// {1}
        
    for (var i = 0; i<length-1;i++){		// {2}
    	indexMin = i;				// {3}
        for (var j = i; j<length; j++){		// {4}
        	if(array[indexMin] > array[j]){ // {2}
            	indexMin = j;				// {6}
            }
        }
        if ( i !== indexMin){				// {7}
        	swap( i, indexMin);
        }
    }
}

首先,我们要声明本次算法要用的变量(行 {1})。之后,我们用外层循环来遍历数组(行 {2})。我们假设本轮循环中第一个成员是最小的(行 {3})。之后,从当前的i至数组的最后一位(行 {4}),我们会比较第j个成员是否比indexMin小(行 {5});如果成立,则让第j个成员为最小的成员(行 {6})。当我们推出内部循环(行 {4})后,我们会有本轮的最小值。之后,如果这个最小值和初始的最小值不同,我们将这两个值进行交换(行 {7})。

为了测试选择排序,我们可以使用如下代码:

var array = createNonSortedArray(5);	
console.log(array.toString());		
array.selectionSort();			
console.log(array.toString());		

以下为选择排序算法的执行过程:

选择排序的复杂度也是O(n2)。和冒泡排序一样,他有可能会带来性能消耗的两层循环。然而,选择排序的性能比接下来要学的插入排序性能差一些。

插入排序

插入排序的特点是,它每执行一次就会返回一个排好序的子数组。它假设数组的第一个成员是已经排好序的。之后,它会将第二个成员与第一个成员进行比较——如果第二个比第一个大,则留在原位;反之则插入到第一个的前面。这样就完成了前两个成员的排序,之后就是由第三个成员与排好序的子数组进行比较,之后依次类推。

以下为插入排序的代码:

this.insertionSort = function(){
	var length = array.length,		// {1}
    	     j, temp;
    for (var i =1; i< length; i++){		// {2}
    	j=i;					// {3}
        temp = array[i];			// {4}
        while (j>0 && array[j-1] > temp){	// {5}
        	array[j] = array[j-1];		// {6}
            	j--;
        }
        array[j] = temp; 			// {7}
    }
}

和之前一样,行 {1}用于声明本次算法要用到的变量。之后,我们从指定位置进行数组遍历(行 {2})。需要注意的是,我们首先会遍历第二个成员(下标为1),而非第一个成员(下标为0),因为我们假设第一个成员已经被排好序了。之后,给辅助变量j赋值,让它与i相等(行 {3}),并把i位置对应的成员存在temp中(行 {4}),以便于我们把i对应的值插入到正确的位置。下一步是找到插入成员的正确位置。只要变量j>0(因为数组的第一个索引为0),且前项大于当前的temp变量(行 {5}),我们将前项的值赋值给当前项(行 {6}),并降低j的值。最后,我们会把值插入到正确的位置。

下图展示了插入排序的工作过程:

以上图为例,我们要对数组[3,5,1,4,2]进行排序。这个数组会被按照下列步骤进行排序:

  • 1.3已经被排好序了,所以我们开始对第二个成员,也就是5进行排序。因为3小于5,所以他们保持原来的位置不变。这样,3和5就已经排好序了。
  • 2.下一个要插入的成员是1(位于数组的第三个位置)。因为5大于1,所以把5移到数组第三位。我们需要分析1是否应该被插入到第二个位置——1是否大于3?1并不大于3,所以1应该被移到数组的第二个位置。接下来,我们要确认1是否应该被插入到数组的第一个位置。因为这个位置的下标为0,且数组没有负数的下标,所以1应该被插入到数组的第一个位置。此时,1、3和5已经被排序了。
  • 3.下一个要插入的是4。4是应该留在现在的位置,还是被插入到前面的位置?因为4小于5,所以4应该左移。而4又大于3,所以4应该被插入到数组的第三位。
  • 4.下一个要插入的是2。5比2大,所以2要左移。因为4比2大,所以2要左移。因为3比2大,所以2要左移。而1小于2,所以2应该被插入到数组的第二个位置。至此,数组已经被排好顺序了。

在处理小数组时,这个算法的表现要优于冒泡排序和选择排序。

并归排序

并归排序算法是第一个可以应用在工业场景的算法。之前学的三个算法其表现并不算好。而并归排序的表现要优于前三者,其复杂度为O(n log n)。

Note

JavaScript的Array类定义了一个用于给数组排序的sort函数(Array.prototype.sort)。ECMAscript并没有排序函数要使用哪一种排序算法,所以不同的浏览器会采用自己选择的排序算法。比如说,火狐浏览器里,Array.prototype.sort使用的是并归排序,而谷歌浏览器里Array.prototype.sort使用的是变异版的快速排序。

并归排序是一种分治算法。其背后的思想是,把数组分割成更小的数组,直至分割为只有一个成员的数组。之后再合并这些小数组成为更大的数组,直至合并出一个分好类的数组。

因为并归排序是分治算法,所以要用到递归。

this.mergeSort = function() {
	array = mergeSortRec(array);
}

和前面几章一样,我们在使用递归时,需要定义一个私有的帮手函数来实现递归。对于并归算法,我们也需要实现一个帮手函数。我们将生成mergeSort函数,而mergeSort函数又会调用递归函数mergerSortRec:

var mergeSortRec = function(array){
	var length = array.length;
    if(length === 1){			 // {1}
    	return array;			 // {2}
    }
    var mid = Math.floor(length/2),	 // {3}
    	left = array.slice(0,mid),	 // {4}
        right = array.slice(mid,length); // {5}
    
    return merge(mergeSortRec(left),mergeSortRec(right));// {6}
};

并归排序算法会把一个数组拆分成更小的数组,直至拆分为只有一个成员的数组。这个算法是通过递归实现的,所以我们为它添加一个断点,即数组大小为1时(行 {1}).如果此时大小为1,则返回大小为1的数组,因为此时数组已经完成排序了。

如果数组大于1,我们会把它拆分成更小的数组。为此,我们要将数组分成左右两个部分(行 {3})。找到数组的中点后,我们会把数组分成左(行 {4})右(行 {5})两个部分。左部分由下标0到下标middle的成员组成,右部分由下标middle到数组剩余的部分组成。

接下来,我们就要调用merge函数(行 {6})。merge函数用于将小数组排序与合并为更大的数组,直至完成整个数组的排序。为了一直拆分数组,我们将对mergeSortRec进行递归式调用,将左边的数组和右边的数组以参数的形式传递进去。

var merge = function(left,right){
	var result = [],			// {7}
    	il = 0,
        ir = 0;
        
    while(il<left.length && ir<right.length){	// {8}
    	if(left[il] < right[ir]){
        	result.push(left[il++]);	// {9}
        }else{
        	result.push(left[ir++]);	// {10}
        }
    }  
    while (il < left.length){		// {11}
          result.push(left[il++]);
      }
      while (ir < left.length){		// {12}
          result.push(left[ir++]);
      }
    
    return result;	// {13}
};

merge函数会接收两个数组,并把它们合并成一个更大的数组。而在合并时,也会对数组进行排序。首先,我们要声明一个数组来用于接收并归结果,再声明两个变量(行 {7})用于遍历左数组和右数组。在遍历这两个数组时,我们需要确认左数组对成员是否小于右数组的成员。如果小于,我们会把左数组的成员加入到result数组中,并给il加1(行 {9})。否则,我们会把右数组的成员加入到result数组中,并给ir加1(行 {10})。接下来,我们会把左数组剩余的成员加到result数组中(行 {11}),再把右数组剩余的成员加到result数组中(行 {12})。最后,我们会返回一result数组作为并归的结果(行 {13})。

如果我们执行了mergeSort函数,代码会如下执行:

快速排序

快速排序,是最常用的排序算法。它的复杂度为O(nlogn),且它的表现比其他复杂度为O(nlogn)的算法要好。和并归排序一样,快速排序也使用了分治法——把数组分成更小的数组并排序。

快速排序比其他排序算法要复杂一些。让我们按照下面的步骤,来学习快速排序:

  • 1.首先,我们要选择数组中间的成员作为支点。
  • 2.我们要生成两个指针——左指针会指向数组的第一个成员,而又指针会指向数组的最后一个成员。我们会右移左指针,直到我们发现有成员比支点大,然后左移又指针,直到我们发现有成员比支点小,之后互换比支点大的值和比支点小的值。我们会重复这个过程,直到左指针经过又指针。这个过程,使得比支点大的点,都移到了支点的右边;比支点小的值,都移到了支点的左边。这整个过程叫做分割操作。

现在,让我们来实现快速排序:

this.quickSort = function() {
	quick(array, 0, array.length-1);
};

和并归排序一样,我们也要在函数内部调用递归函数:

var quick = function(array, left, right){

	var index;					// {1}
    
    if (array.length > 1){				// {2}
    	index = partiton(array,left,right);		// {3}
        
        if (left < index -1 ){				// {4}
        	quick(array, left , index-1);		// {5}
        }
        if (index < right){				// {6}
        	quick(array, index, right);		// {7}
        }
    }
}

首先,我们要声明index变量(行 {1}),而index变量是用来帮我们把数组分成小子值数组和大值子数组的,而这样分割有助于我们递归式调用quick函数。我们会以partition函数的返回值作为index的值(行 {3})。

如果数组的长度大于1(因为只有一个成员的数组是已经排好序的数组-行 {2}),我们将会对给定的子数组执行partition函数(第一次执行partition时,以整个数组作为参数),以获取index(行 {3})。如果子数组有比index小的成员(行 {4}),我们会对该子数组重复这个操作(行 {5})。之后,我们会对大值子数组做一样的事情。只要子数组有比index大的成员(行 {6}),我们也会重复这个过程(行 {7})。

分割过程

现在,让我们看看分割函数:

var partition = function(array, left, right){

	var pivot = array[Math.floor((right + left) / 2)],	// {8}
    	i = left,						// {9}
        j = right						// {10}
        
    while ( i <= j) {						// {11}
    	while (array[i] < pivot ){				// {12}
        	i++;
        }
        while (array[j] > pivot ){				// {13}
        	j--;
        } 
        if ( i <= j ){						// {14}
        	swapQuickStort(array, i, j);			// {15}
            i++;
            j--;
        }
        return i;						// {16}
    }
}

首先,我们要选择一个支点。找支点有很多种方法。最简单的方法就是以数组的第一个成员作为支点(位于最左边的成员)。然而,相关研究表明这样的方式对提升算法的性能不利。另外的方法是,从数组随机选一个,或者选择数组中间的成员。本书中选择的是最后一个方法,即以数组中间的成员作为支点(行 {8})。之后,我们需要对两个指针进行初始化:左指针指向数组的第一个成员(行 {9}),右指针指向数组的最后一个成员(行 {10})。

只要左指针和右指针没有相交,我们就会继续执行分割操作。在分割过程中,当发现一个成员大于支点时,我们会右移左指针(行 {12})。同理,当我们发现一个值比支点小时,我们会左移右指针(行 {13})。

当左指针比支点大,且右指针小于支点时,我们会比较左指针的下标是否大于右指针的下(行 {14})。之后,我们会将两个指针的值进交换,移动两个指针,并重复这个过程(从行 {11}开始)。

在分割操作快结束时,我们会返回左指针的下标,用以产生下一个子数组(行 {3})。

swapQuickSort函数和swap函数非常相似。两者唯一的不同是,swapQuickSort会把array作为参数接收:

var swapQuickSort = function(array,index1,index2){
	var aux = array[index1];
    array[index1] = array[index2];
    array[index2]= aux;
}

速排序的执行过程

让我们一步一步地研究快速排序的执行过程: 给定数组[3,5,1,6,4,7,2],上图表示的是第一次执行分割操作的过程。

下图展示了对第一个子数组的分割操作,其中这个子数组的值都比较小(7和6不在子数组中):

如下图所示,我们继续生成子数组。但这一次的子数组的值比上图的值要更大:

来自子数组[2,3,4,5]的子数组[2,3]将继续被分割(行 {5}):

之后,来自子数组[2,3,4,5]的子数组[5,4]将继续被分割(行 {7}),如下图所示:

最后,值更大的子数组[6,7]将会被分割函数处理,并完成整个快速排序。

搜索算法

接下来,我们要学习的是搜索算法。如果我们回顾一下我们之前学过的章节的算法,如二分搜索树的search方法,链表的indexOf方法,其实都是搜索算法。实际上,这些搜索算法都是因应不同的数据结构而实现的。所以,我们已经知道了两种搜索算法,只是我们还不知道他们的正式名称而已。

顺序搜索

顺序搜索,或者说是线性搜索是最常用的搜索算法。它的原理是每个成员和要查找的值进行比较。当然,这是效率最低的一个搜索算法。

让我们看看它是如何实现的:

this.sequentialSearch = function(item){
	for (var i = 0; i <array.length; i++){	// {1}
    	if (item === array[i]){			// {2}
        	return i;			// {3}
        }
    }
    return -1;					// {4}
};

顺序搜索算法会遍历整个数组(行 {1}),并比将每一个成员与要查找的值进行比较(行 {2})。如果找到了,我们会返回相应成员的索引。(行 {3})。如果我们找不到要查找值,我们会返回-1(行 {4}),告诉人们数组中不存在这个值;当然也可返回falsenull

假设我们有一个数组[5,4,3,2,1],现在我们要寻找3。下图展示了顺序搜索的工作过程:

二分搜索

二分搜索算法的原理与猜数字游戏很相似。在玩猜数字游戏时,A会说“我在思考1到100间的一个数字”,B负责猜数字。而A会回答对B猜的数字是比他思考的数字大了、小了,或者正确。

为了让该算法能够运行,相关数据需要被排好顺序。以下为二分搜索算法的工作步骤:

  • 1.选定数组的中间值。
  • 2.如果中间值与要查找的值相等,那么工作就完成了。
  • 3.如果我们选的成员比要查找的值大,那么我们会左进一位,并重新执行步骤1
  • 4.如果我们选的成员比要查找的值小,那么我们会右进一位,并重新执行步骤1

现在,让我们看看其代码实现:

this.binarySearch = function(item){
	this.quickSort();		// {1}
    
    var low = 0,			// {2}
    	high = array.length - 1,	// {3}
        mid, element;
        
    while (low <= high){		// {4}
    	mid = Math.floor((low+hight)/2);// {5}
        element = array[mid];		// {6}
        if (element < item){		// {7}
        	low = mid +1;		// {8}
        }else if (element > item){	// {9}
        	high = mid -1;		// {10}
        }else {
        	return mid;		// {11}
        }
    }
    return -1;				// {12}
};

在开始排序前,我们要先对数组进行排序。理论上来说,我们可以使用任何排序方法。针对本算法,我们使用快速排序(行 {1})。数组排好序后,我们要设置set指针和high指针(这两者用于指明边界)。

如果变量low大于变量high,我们会返回-1(行 {12}),这意味着我们没有搜索到相应的index。反之,当变量low小于变量high时(行 {4}),我们会进入while循环,并找到中间下标(行 {5}),并获取中间下标的对应值(行 {6}).之后,我们便开始对变量element与变量item进行比较,如果变量element比item小(行 {7}),则增加变量low的值(行 {8})。如果变量element比item大(行 {9}),则增减小量high的值(行 {10})。如果以上两个条件都不成立,则说明变量element等于变量item,因此我们可以返回相应的下标(行 {11})。

下图为二分搜索算法在寻找值2的过程:

Note

我们在第八章实现的树也有search方法。树的search方法其实和二分搜索是一样的,只是树的search用在树结构的特殊二分搜索。

小结

在这一章,我们学习了排序和搜索算法。我们已经学习了常用地冒泡排序、选择排序、并归排序和快速排序。我们也学习了连续搜索和二分搜索(这一搜索要求数据事先被排好序)。

我们可以把本章学到的逻辑应用到任何数据结构中,只要调整一下本书的源代码即可。

在下一章,我们将学习高级的算法技巧,已经更多关于大O标记的知识点。

注:本文翻译自Loiane Groner的《Learning JavaScript Data Structures and Algorithm》