1. 程式人生 > >為什麼我要放棄javaScript資料結構與演算法(第十章)—— 排序和搜尋演算法

為什麼我要放棄javaScript資料結構與演算法(第十章)—— 排序和搜尋演算法

本章將會學習最常見的排序和搜尋演算法,如氣泡排序、選擇排序、插入排序、歸併排序、快速排序和堆排序,以及順序排序和二叉搜尋演算法。

第十章 排序和搜尋演算法

排序演算法

我們會從一個最慢的開始,接著是一些效能好一些的方法

先建立一個數組(列表)來表示待排序和搜尋的資料結構。

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

ArrayList 是一個簡單的資料結構,它將項儲存在陣列。我們只需要一個插入方法來向資料結構中新增元素。使用js原生的push方法即可,而改寫toString函式運用了js的join方法是來拼接陣列中的所有元素至一個單一的字串。

氣泡排序

氣泡排序是所有排序演算法中最簡單,然後從執行時間看,它也是最差的一個、

氣泡排序比較兩個相鄰的項,如果第一個大於第二個,則交換它們。元素項向上移動至正確的順序,就好像氣泡升至表面一樣,氣泡排序因此得名。

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

首先,宣告一個 名為length的變數,用來儲存陣列的長度。接著外迴圈從陣列的第一位迭代到最後一位,它控制了在陣列中經過多次輪排序,然後內迴圈將從第一位迭代到倒數第二位,內迴圈實際上進行當前項和下一項的比較。如果這兩項順序不對,則交換它們,意思就是位置為 j+1 的會被換到位置 j 處。

宣告 swap函式

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

交換時,我們用一箇中間值來儲存某一交換項的值。其他排序法也會用到這個方法,因此我們宣告一個方法放置這段交換程式碼以便重用。

還可以簡化成

[array[index1],array[index2]] = [array[index2],array[index1]]

下面這個示意圖展示了氣泡排序的工作過程

氣泡排序

上面的圖每一小段表示外迴圈的一輪,而相鄰兩項的比較是在內迴圈中進行的。

用下面的程式碼來測試氣泡排序演算法

function createNonSortedArray(size){
    var array = new ArrayList();
    for(var i = size; i > 0; i--){
        array.insert(i);
    }
    return array;
}

var array = createNonSortedArray(5);
console.log(array.toString()); // 5,4,3,2,1
array.bubbleSort();
console.log(array.toString());  // 1,2,3,4,5

為了輔助本章將要學習的排序演算法,我們將建立一個函式來自動地建立一個未排序的陣列,陣列的長度由函式的引數指定。如果傳遞5為引數,該函式就會建立如下陣列

[5,4,3,2,1]

呼叫這個函式並將返回值儲存在一個變數中,該變數將包含這個以某些數字來初始化的 ArrayList 類例項。

注意當演算法執行外迴圈的第二輪的時候,數字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++){
            if(array[j] > array[j+1]){                          
                this.swap(j,j+1);
            }
        }
    }
}

下圖展示了改進後的氣泡排序演算法是如何執行的:

優化氣泡排序

可以通過檢驗知道減少了10次迴圈,優化了演算法的效能。

即使做了這樣子的改變,還是不推薦該演算法,該演算法的複雜度是O(n2 )

後面的章節會介紹大O表達法。

選擇排序

選擇排序演算法是一種原址比較排序演算法。選擇排序大致的思路是找到資料結構中最小值並將其放置在第一位,接著找到第二小的值並將其放在第二位。以此類推。

實現:

// 選擇排序
this.selectionSort = function(){
    var length = array.length,
    indexMin;
    for(var i = 0; i < length; i++){
        indexMin = i;
        for(var j = i; j < length; j++){
            if(array[indexMin] > array[j]){
                indexMin = j;
            }
        }
        if(i !== indexMin){
            this.swap(i,indexMin);
        }
    }
}

首先宣告一些將在演算法內使用的變數。接著,外迴圈迭代陣列,並控制迭代一次(陣列的第n個值——下一個最小值)。我們假設本迭代一次的第一個值為陣列的最小值。然後,當前 i 的值開始至陣列結束,我們比較是否位置 j的值比當前的最小值小。如果是則改變最小值為新的最小值。當內迴圈結束,將得出陣列的第n小的值。最後,如果該最小值和原最小值不一樣,則互動其值。

測試:

var array = createNonSortedArray(5);
console.log(array.toString()); // 1,2,3,4,5
array.selectionSort();
console.log(array.toString());  // 1,2,3,4,5

下圖的示意圖展示了選擇排序演算法,此例基於之前的程式碼中所用的陣列。

選擇排序

陣列底部的箭頭指示出了當前迭代尋找最小值的陣列範圍,示意圖中的每一步則表示外部迴圈。

選擇排序同樣是一個複雜度也是 O(n2)的演算法。和氣泡排序一樣,它包含有巢狀的兩個迴圈,這導致了二次方的複雜度。然後,接下來要學的插入排序比選擇排序的效能要好。

插入排序

插入排序每次排一個數組項,以此方式構建最後的排序陣列。假設第一項已經排序,接著,它和第二項進行比較,第二項是應該待在原位還是插入到第一項之前呢?這樣,頭兩項就已經正確排序,接著和第三項比較(它是該插入到第一、第二還是第三的位置呢?),以此類推

實現:

// 插入排序
this.insertionSort = function(){
    var length = array.length,
    j,
    temp;
    for(var i = 0; i <length; i++){
        j = i;
        temp = array[i];
        while(j > 0 && array[j - 1] > temp){
            array[j] = array[j - 1]
            j--;
        }
        array[j] = temp;
    }
}

先宣告程式碼中使用的變數,接著,迭代陣列來給第i項找到正確的位置。注意,演算法是從第二個位置而不是從0位置開始的。然後用i值來初始化一個輔助變數並將其保存於一臨時變數中,便於之後將其插入到正確的位置上。下一步是找到正確的位置來插入專案。只要變數j比0大並且陣列中前面的值比待比較的值大,我們就把這個值移到當前位置上並減小j。最終,該專案能插入到正確的位置上。

下面的示意圖展示了一個插入排序的例項:
插入排序

排序小型陣列時,此演算法比選擇排序和氣泡排序效能都要好。

歸併排序

歸併排序是第一個可以被實際使用的排序演算法。歸併排序效能複雜度為 O(nlog(n))

歸併演算法是一種分治演算法。其思想是將原始陣列切成較小的陣列,直到每個小陣列只有一個位置,接著將小陣列歸併成較大的陣列,直到最後一個排序完畢的大陣列。

由於是分治法,歸併排序也是遞迴的。

// 歸併排序
this.mergeSort = function(){
    array = this.mergeSortRec(array);
}

mergeSortRec 是遞迴函式

this.mergeSortRec = function(array){
    var length = array.length;
    if(length === 1){
        return array;
    }
    var mid = Math.floor(length/2),
    left = array.slice(0,mid),
    right = array.slice(mid,length);
    return merge(arguments.callee(left),arguments.callee(right));
}

歸併排序將一個大陣列轉化為一個小陣列直到只有一個項。由於演算法是遞迴的,我們需要一個停止條件,在這裡條件是判斷陣列的長度是否為1.如果是,則直接返回這個長度為1的陣列,因為它已經排序了。

如果陣列長度比1大,那麼我們得將其分成小陣列。為此,首先要找到陣列的中間行,找到後我們將陣列分成兩個小陣列,分別叫做left 和 right 。 left 陣列由索引0至中間索引的元素組成,而 right 陣列由中間索引至原始陣列最後一個位置的元素組成。

下面的步驟就是呼叫merge 函式,它負責合併和排序小陣列來產生大陣列,直到回到原始陣列已排序完成。為了不斷將原始陣列分成小陣列,我們得再次對left 陣列和right 陣列遞迴呼叫 mergeSortRec ,並同時作為引數傳遞給 merge 函式

function merge(left,right){
    var result = [],
    il = 0,
    ir = 0;
    while(il < left.length && ir < right.length){
        if(left[il] < right[ir]){
            result.push(left[il++]);
        }else{
            result.push(right[ir++]);
        }
    }
    while(il < left.length){
        result.push(left[il++]);
    }
    while(ir < right.length){
        result.push(right[ir++]);
    }
    return result;          
}

merge 函式接受兩個陣列作為引數,並將它們歸併至一個大陣列。排序發生在歸併過程中。首先,需要宣告歸併過程要建立的新陣列已經用來迭代兩個陣列(left和right陣列)所需要的兩個變數。迭代兩個陣列的過程中,我們來自left 陣列的項是否比來自right的陣列的項小。如果是,將該項從left陣列新增至歸併結果陣列,並遞增迭代陣列的控制變數;否則,從right陣列新增項並遞增相應的迭代陣列的控制變數。

接下來,將left陣列或right陣列所有剩下的項新增到歸併陣列中。最後,將歸併陣列作為結果返回。

下圖是具體的執行過程

歸併排序

可以看到,演算法首先將原始陣列分割成只有一個元素的子陣列,然後開始排序。歸併過程也會完成排序,知道原始陣列完全合併並完成排序。

快速排序

快速排序也許是最常用的排序演算法了。它的複雜度為O(nlogn),且它的效能通常比其他的複雜度為O(nlog(n))的排序演算法要好。和歸併演算法一樣,快速排序也是用分治 的方法,將原始演算法分成了較小的陣列(但它沒有像歸併排序那樣將它們分割開)

快速排序比目前之前的排序演算法要負責一些。

  1. 首先,從陣列中選擇中間一項作為主元
  2. 建立兩個指標,左邊一個指向陣列的第一項,右邊一個指向陣列最後一項。移動左指標知道我們找到一個比主元大的元素,接著,移動右指標直到找到一個比主元小的元素,然後交換它們,重複這個過程,直到左指標超過了右指標。這個過程將使得比主元小的值都排在主元之前,而比主元大的值都排在主元之後。這一步叫做劃分操作。
  3. 接著,演算法對劃分後的小陣列(較主元小的值組成的子陣列,以及較主元大的值組成的子陣列)重複之前的兩個步驟,直到陣列已完全排序。

實現:

// 快速排序
this.quickSort = function(){
    this.quick(array,0,array.length-1);
}

像歸併演算法那樣,開始我們宣告一個主方法來呼叫遞迴函式,傳遞待排序陣列,已經索引0及其最末的位置作為引數。

this.quick = function(array,left,right){
    var index;
    if(array.length > 1){
        index = partition(array,left,right);
        if(left < index - 1 ){
            arguments.callee(array,left,index-1);
        }
        if(index < right){
            arguments.callee(array,index,right);
        }               
    }
}

首先宣告 index ,該變數能幫助我們將子陣列分離成較小值陣列和較大值陣列,這樣,我們就能再次遞迴的呼叫quick函數了。partition 函式返回值將賦值給 index.

如果陣列的長度比1大,我們就對給定子陣列執行 partition 操作以得到 index 。如果子陣列存在較小值的元素,則對該陣列重複這個過程。同理,對存在較大值的子陣列也是如此。

劃分過程

第一件要做的事情就是選中主元(pivot),有好幾種方式,最簡單的一種就是選中陣列的第一項(最左項)。然而,研究表明對於幾乎已排序的陣列,這不是一個好的選擇,它將導致該演算法的最差表現。另外一種方式是隨機選擇一個數組或是選擇中間項。

function partition(array,left,right){
    var pivot = array[Math.floor((left+right) / 2)],
    i = left,
    j = right;
    while(i <= j){
        while(array[i] < pivot){
            i++
        }
        while(array[j] > pivot){
            j--
        }
        if(i <= j){
            [array[i],array[j]] = [array[j],array[i]];
            i++;
            j--
        }
    }
    return i;
}

上面的實現中,我們選中中間項作為主元。我們初始化兩個指標:left,初始化為陣列第一個元素,right,初始化為陣列最後一個元素。

只要left和right指標沒有相互交錯,就執行劃分操作。首先,先移動left指標直到找到一個元素比主元大。對於right指標,我們做同樣的事情,移動right指標直到我們找到一個元素比主元小。

當左指標指向的元素比主元大且右指標指向的元素比主元小,並且此時左指標索引沒有右指標索引大,意思是左項比右項大。我們交換它們,然後移動兩個指標,並重復這個過程。

在劃分操作結束後,返回左指標的索引,用來在建立子陣列。

快速排序實戰

看一個快速排序的實際例子

快速排序

給定陣列([3,5,1,6,4,7,2]),前面的示意圖展示了劃分操作的第一次執行。

下面的示意圖展示了對有較小值的子陣列執行的劃分(注意7和6不包含在子陣列之內)

快速排序2

接著,我們繼續建立子陣列,但是這次操作是針對上圖中有較大值的子陣列(有1那個較小陣列不用再劃分了,因為它僅含有一個項)

快速排序3

子陣列([2,3,5,4])中的較小陣列([2,3])繼續劃分。

快速排序4

然後子陣列([2,3,5,4])中較大陣列([5,4])也繼續進行劃分,示意圖如下

快速排序5

最終,較大子陣列([6,7])也會進行繼續劃分操作,快速排序演算法的操作執行完成。

計數排序、桶排序和基數排序(分散式排序)

目前為止,已經學習瞭如何不借助任何輔助資料結構的情況下對陣列進行排序。還有一類被稱為分散式排序的演算法,原始陣列中的資料會分發到多箇中間結構(桶),再合起來放回原始陣列。

最著名的分散式演算法有計數排序、桶排序和基數排序。這裡不做展開,有興趣的請自行百度。

排序的相關程式碼

function createNonSortedArray(size){
    var array = new ArrayList();
    for(var i = size; i > 0; i--){
        array.insert(i);
    }
    return array;
}
function merge(left,right){
    var result = [],
    il = 0,
    ir = 0;
    while(il < left.length && ir < right.length){
        if(left[il] < right[ir]){
            result.push(left[il++]);
        }else{
            result.push(right[ir++]);
        }
    }
    while(il < left.length){
        result.push(left[il++]);
    }
    while(ir < right.length){
        result.push(right[ir++]);
    }
    return result;          
}
function partition(array,left,right){
    var pivot = array[Math.floor((left+right) / 2)],
    i = left,
    j = right;
    while(i <= j){
        while(array[i] < pivot){
            i++
        }
        while(array[j] > pivot){
            j--
        }
        if(i <= j){
            [array[i],array[j]] = [array[j],array[i]];
            i++;
            j--
        }
    }
    return i;
}
function ArrayList(){
    var array = [];
    this.insert = function(item){
        array.push(item);
    }
    this.toString = function(){
        return array.join();
    }
    this.swap = function(index1,index2){    
        var aux = array[index1];
        array[index1] = array[index2];
        array[index2] = aux;
    // [array[index1],array[index2]] = [array[index2],array[index1]]
    }           
    // 氣泡排序
    this.bubbleSort = function(){
        var length = array.length;
        for(var i = 0; i <length; i++){
            for(var j = 0; j < length -1; j++){
                if(array[j] > array[j+1]){                              
                    this.swap(j,j+1);
                }
            }
        }
    }
    // 改進後的氣泡排序
    this.modifiedBubbleSort = function(){
        var length = array.length;
        for(var i = 0; i <length; i++){
            for(var j = 0; j < length-1-i; j++){
                if(array[j] > array[j+1]){                          
                    this.swap(j,j+1);
                }
            }
        }
    }
    // 選擇排序
    this.selectionSort = function(){
        var length = array.length,
        indexMin;
        for(var i = 0; i < length; i++){
            indexMin = i;
            for(var j = i; j < length; j++){
                if(array[indexMin] > array[j]){
                    indexMin = j;
                }
            }
            if(i !== indexMin){
                this.swap(i,indexMin);
            }
        }
    }
    // 插入排序
    this.insertionSort = function(){
        var length = array.length,
        j,
        temp;
        for(var i = 0; i <length; i++){
            j = i; 
            temp = array[i]; 
            while(j > 0 && array[j - 1] > temp){
                array[j] = array[j - 1]
                j--;
            }
            array[j] = temp;
        }
    }
    // 歸併排序
    this.mergeSort = function(){
        array = this.mergeSortRec(array);
    }
    this.mergeSortRec = function(array){
        var length = array.length;
        if(length === 1){
            return array;
        }
        var mid = Math.floor(length/2),
        left = array.slice(0,mid),
        right = array.slice(mid,length);
        return merge(arguments.callee(left),arguments.callee(right));
    }
    // 快速排序
    this.quickSort = function(){
        this.quick(array,0,array.length-1);
    }
    this.quick = function(array,left,right){
        console.log(left+'  '+right);
        var index;
        if(array.length > 1){
            index = partition(array,left,right);
            if(left < index - 1 ){
                arguments.callee(array,left,index-1);
            }
            if(index < right){                  
                arguments.callee(array,index,right);
            }               
        }
    }
}

// var array = createNonSortedArray(5);
// console.log(array.toString());
// array.quickSort();
// console.log(array.toString());   
const quickSortArray = new ArrayList();
quickSortArray.insert(3);
quickSortArray.insert(5);
quickSortArray.insert(1);
quickSortArray.insert(6);
quickSortArray.insert(4);
quickSortArray.insert(7);
quickSortArray.insert(2);
console.log(quickSortArray.toString());
quickSortArray.quickSort();
console.log(quickSortArray.toString()); 

搜尋演算法

回顧一下之前學過的演算法,我們會發現BinarySearch Tree 類的search以及LinkedList類的indexOf 方法等都是搜尋演算法。當然,它們都是根據各自的資料結構來實現的。所以我們其實已經熟悉兩個搜尋演算法了,只是還不知道它們的正式名稱而已。

順序搜尋

順序或者線性搜尋是基本的搜尋演算法。它的機制是,將每一個數據結構中的元素和我們要找的元素做比較。順序搜尋是最低效的一種搜尋演算法。

// 順序搜尋
this.sequentialSearch = function(item){
    for(var i = 0; i < array.length; i++){
        if(item === array[i]){
            return i;
        }
    }
    return -1;
}

順序搜尋迭代整個陣列,並將每個陣列元素和搜尋項作比較。如果搜尋到了,演算法將返回值來標示搜尋成功。返回值可以是該搜尋項本身,或是true,又或是搜尋項的索引。如果沒有找到該項,則返回-1,表示該索引不存在,也可以考慮返回false或者null。

假定有陣列([5,4,3,2,1])和待搜尋值3,下圖展示了順序搜尋的示意圖

順序搜尋

二分搜尋

二分搜尋演算法的原理和菜數字遊戲類似。我們每回應一個數字。那個人就會說這個數字是高了還是低了或者是對了。

這個演算法要求被搜尋的資料結構已經排序。以下是該演算法遵循的步驟

  1. 選擇陣列的中間值
  2. 如果選中值是待搜尋值,那麼演算法執行完畢
  3. 如果待搜尋值比選中的小,則返回步驟1並在選中值的左邊的子陣列中尋找
  4. 如果待搜尋值比選中的大,則返回步驟1並在選中值的右邊的子陣列中尋找

實現:

// 二分搜尋
this.binarySearch = function(item){
    this.quickSort();
    var low = 0,
    high = array.length - 1,
    mid,element;
    while(low <= high){
        mid = Math.floor((low + high)/2);
        element = array[mid];
        if(element < item){
            low = mid + 1;
        }else if(element > item){
            high = mid - 1;
        }else{
            return mid;
        }
    }
    return -1;
}

開始前需要先排序陣列,我們這這裡選擇了快速排序。在陣列排序之後,我們設定low和high指標(它們是邊界)

當low比high小時,我們計算得到中間項索引並取得中間項的值,此處如果low比high大,則意思是該搜尋值不存在並返回-1.接著,我們比較選中項的值和搜尋值。如果小了,則選擇陣列低半邊並重新開始。如果選中項的值比搜尋值大了,則選擇陣列高半邊並重新開始。若兩者都不是,則意味著選中項的值和搜尋值相等,因此,直接返回該索引。

給定下圖所示陣列,試試搜尋2.這是演算法將會執行的步驟:

順序搜尋

小結

本章介紹了排序和搜尋演算法,包括冒泡、選擇、插入、歸併和快速排序,還有順序搜尋和二分搜尋。下一章學習一些高階演算法技巧。

書籍連結: 學習JavaScript資料結構與演算法