《iOS面試之道》演算法基礎學習(下)

Tioks0發表於2018-09-14

前言

上一篇文章裡,筆者已經對連結串列、佇列和二叉樹的基本資料結構做了簡單的介紹,附上前文連結:《iOS面試之道》演算法基礎學習(上) 。在這篇文章裡,筆者繼續把剩下的部分嘗試著去解讀,儘量會細緻到每一行程式碼。另外本篇文章也只是筆者自己的理解,如果有理解錯誤的地方也希望大家進行指正。

關於演算法部分後面的內容,主要是圍繞一些通用演算法進行了講解。個人認為是本書含金量最高的部分,也是面試中比較常見的演算法題目。

排序

關於排序的介紹,常見的主要有7種。氣泡排序、插入排序、選擇排序、堆排序、歸併排序、快速排序、桶排序。

在介紹每一種排序方法之前,先來看一些概念的的東西:

時間複雜度:在電腦科學中,演算法的時間複雜度是一個函式,它定性描述了該演算法的執行時間,通常用大O符號表示。

關於這7種排序演算法的時間複雜度依次為:氣泡排序 = 插入排序 = 選擇排序 > 堆排序 = 歸併排序 = 快速排序 > 桶排序

排序演算法穩定性:假定在待排序的記錄序列中,存在多個具有相同的關鍵字的記錄,若經過排序,這些記錄的相對次序保持不變,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序後的序列中,r[i]仍在r[j]之前,則稱這種排序演算法是穩定的;否則稱為不穩定的。

關於排序演算法穩定性真正的意義筆者認為可以分為2個方面: 1:在無意義資料面前,穩定性起到了避免無意義交換的作用,減少了元素交換的次數。 2:在有意義資料面前,每一個資料元素對應的可能是一個模型,若對模型中的某一屬性進行排序,不穩定的排序方法改變了元素位置,其可能就影響了整個模型的排序。

所以時間複雜度和排序演算法穩定性都是反映排序演算法好壞的重要指標。接下來筆者會對每一種排序演算法進行詳細的介紹,並用Swift程式碼進行實現。

1.氣泡排序(穩定)

氣泡排序(Bubble Sort)顧名思義就像魚兒在水底吐出的氣泡,從底部會慢慢的漂浮到水面逐漸變大。程式碼層面表示就是序列中的元素,一步步的向著序列的一側進行移動,最後達到我們希望的排序效果。直接來看程式碼:

func bubbleSort( _ array: inout [Int]) -> [Int]? {
   //i表示陣列中依次比較的總次數
    for i in 0..<array.count {
         for j in 0..<array.count - i - 1) {
           //j表示下標,判斷第j位和j+1位元素大小,進行排序
            if array[j] > array[j+1] {
                //元素左右進行交換
                let tmp = array[j]
                array[j] = array[j+1]
                array[j+1] = tmp
            }                
        }
    }
    return array
}
複製程式碼

註解:

1:每一次外層i的遍歷,都會選出未排序陣列中的最大值,放到右側並固定位置。

2:每一次j的遍歷,會根據左右元素進行大小比較,若左邊資料較大則進行交換。

但是這樣的寫法並不優秀,如果我的陣列部分有序,但通過上面的寫法還是會對已經有序的元素進行不必要的判斷,造成了時間複雜度的陡增。關於這個問題筆者會在之後的文章裡介紹(連結:iOS冒泡演算法優化)。

2.插入排序(穩定)

插入排序(Insert Sort)是對已經有序的序列插入新的元素,使其序列仍然有序。打個比方,就好像班級需要以高矮排成一隊,排好隊後發現有個同學來晚了,那這個時候就需要以這個同學的身高來和其他同學比較,插入這個隊伍中。知道了概念,來看下程式碼。

func insertSort(_ array: inout [Int]) -> [Int]? {       
    //從1開始遍歷,預設不清楚陣列是否部分有序
    for i in 1..<array.count {
        //取出第i位元素 賦給臨時變數 做後續比較
        let tmp = array[i]
        var j = i - 1
        //當比到最左邊或臨時變數tmp小於比較的元素時
        while j>=0 && tmp < array[j] {
            //比較的元素array[j],向右移動一位
            array[j + 1] = array[j]
            //j-1,並繼續和左邊元素比較
            j -= 1
        }
        //臨時變數tmp放到已排序好的陣列的下一位
        array[j + 1] = tmp            
    }
    return array
}
複製程式碼

註解: 1:外層i遍歷,表示取出陣列的第i位元素。例如:原陣列[1,2,5,4]取出第i=1位的元素2,此時這個元素2可以理解為被取出來,使原陣列變成[1,_,5,4]。 2:內層j迴圈,因為i的左邊已經有序,所以根據第i的元素tmp和i左邊的元素j進行判斷,並根據返回結果插入合適的位置。

雖然說插入排序經歷了2層遍歷在時間複雜度上和氣泡排序是一樣的,但是在比較次數上第一次是1,第二次是2,依次類推總共只需要比較1+2+...+N-1=N*(N-1)/2。並且元素一旦發現左邊元素小就立即停止判斷,效率上大大提高。

3.選擇排序(不穩定)

選擇排序(Selection Sort)是在一個待排序的序列中取出最小(或最大)的元素,然後放在已排好序的序列最後。示例程式碼按照從小到大進行排序,看下程式碼

func selectSort(_ array: inout [Int]) -> [Int]? {
    
    for i in 0..<array.count {
        //記錄最小值的下標
        var k = i
        for j in (i + 1)..<array.count {
            //判斷當前無序陣列中的最小值
            if array[j] < array[k] {
                //k指向最小值下標
                k = j
            }
        }
        //若當前元素為其本身,不做交換
        if k != i {
            let tmp = array[i] 
            array[i] = array[k]
            array[k] = tmp
        }
    }
    return array
}
複製程式碼

註解:

1:外層i迴圈首先會記錄下標並賦值給k。

2:內層j迴圈通過遍歷i的元素,並判斷其大小,取出最小值下標,最後交換其位置,完成排序。

選擇排序在時間複雜度上和冒泡、插入排序一樣都是O(n^2),但是其交換次數少於冒泡演算法。

4:堆排序(不穩定)

堆排序(Heap Sort)是利用堆這種資料結構而設計的一種排序演算法,是選擇排序的一種。通過調整堆結構,將堆的頂部元素(即陣列第0位元素)與末尾的元素進行交換,調整後將末尾元素固定,然後繼續調整新的堆結構,每次調整固定一個元素,遍歷結束後從而達到排序的效果。關於堆排序詳細的介紹可以參考這篇文章:堆排序介紹

下面的示例程式碼採取的是構建大頂堆,即升序排列,看程式碼

func heapSort(_ array: inout [Int]) -> [Int]? {
    
    //構建大頂堆 從最後一個非葉子結點倒序遍歷
    for i in (0...(array.count/2-1)).reversed() {
        //從第一個非葉子結點從下至上,從右至左調整結構
        adjustHeap(&array, i: i, length: array.count)
    }     
    //上面已將輸入陣列調整成堆結構
    for j in (1...(array.count - 1)).reversed() {
        //堆頂元素與末尾元素進行交換
        array.swapAt(0, j)
        adjustHeap(&array, i:0, length:j)
    }
    return array
}

func adjustHeap(_ array: inout [Int], i: Int, length: Int) {
    var j = i
    //取出當前元素i
    let tmp = array[j]
    var k = 2*i+1
    while k < length {
        //左子節點小於右子節點
        if(k+1 < length && array[k] < array[k+1]) {
            //取到右子節點下標
            k+=1
        }
        if(array[k] > tmp){
            //如果子節點大於父節點,將子節點值賦給父節點(不用進行交換)
            array[j] = array[k]
            j = k
        } else {
            break
        }
        k = k*2 + 1
    }
    //將tmp值放到最終的位置
    array[j] = tmp
}
複製程式碼

註解:

1:在i迴圈方法中,首先將原始入引數組呼叫adjustHeap方法進行大頂堆的構建,目的是方便下面迴圈中堆頂與末尾元素進行交換操作。進行該遍歷操作時需要注意該遍歷採用的是倒序遍歷的方式,即從最後一個非葉子結點開始。

2:j迴圈中,對已構建好大堆頂的陣列進行堆頂元素與末尾元素的交換,並固定新的末尾元素,遍歷結束即得到排序好的陣列。

3: adjustHeap方法,是取根節點的下標i元素與其左右子節點對應元素進行大小比較,將最大值賦給根節點。

堆排序的基本思想是:將待排序序列構造成一個大頂堆,此時,整個序列的最大值就是堆頂的根節點。將其與末尾元素進行交換,此時末尾就為最大值。然後將剩餘n-1個元素重新構造成一個堆,這樣會得到n個元素的次小值。如此反覆執行,便能得到一個有序序列了。

5:歸併排序(穩定)

歸併操作(Merge Sort)是建立在歸併操作的排序演算法,是將需要排序的序列進行拆分,拆分成每一個單一元素。這時再按每個元素進行比較排序,兩兩合併,生成新的有序序列。再對新的有序序列進行兩兩合併操作,直到整個序列有序。 歸併排序在效能上會明顯優於前面幾種排序方法,時間複雜度為O(nlogn), 看下書中給出的程式碼:

func mergeSort(_ array: [Int]) -> [Int] {
    //初始化輔助陣列 預設資料0
    var helper = Array(repeating: 0, count: array.count)
    var array = array
    mergeSort(&array, &helper, 0, array.count - 1)
    return array
    
}

func mergeSort(_ array: inout [Int], _ helper: inout [Int], _ low: Int, _ high: Int) {
    //判斷是否拆分至單一元素
    guard low < high else {
        return
    }
    //使用二分法對入引數組進行拆分
    let middle = (high - low)/2 + low
    mergeSort(&array, &helper, low, middle)
    mergeSort(&array, &helper, middle + 1, high)
    //合併陣列
    merge(&array, &helper, low, middle, high)
}

func merge(_ array: inout [Int], _ helper: inout [Int], _ low: Int, _ middle: Int, _ high: Int) {
    //輔助陣列接收 分割範圍內的元素
    for i in low...high {
        helper[i] = array[i]
    }
    //helperLeft為分割部分左邊的元素下標,helperRight為分割部分右邊的元素下標,初始化時預設是第0位元素下標。
    var helperLeft = low, helperRight = middle + 1
    //合併後新序列的下標
    var current = low
    //判斷子序列是否越界
    while helperLeft <= middle && helperRight <= high {
        //判斷分割成的子序列元素大小
        if helper[helperLeft] <= helper[helperRight] {
            //對入參array陣列進行賦值
            array[current] = helper[helperLeft]
            //判斷左序列的下一位元素
            helperLeft += 1
            
        } else {
            //同上
            array[current] = helper[helperRight]
            //判斷右序列的下一位元素
            helperRight += 1
        }
        //新序列下標向右移動
        current += 1
    }
    //不滿足middle>=helperLeft return
    guard middle - helperLeft >= 0 else {
        return
    }
    
    for i in 0...middle - helperLeft {
        //將元素補齊
        array[current + i] = helper[helperLeft + i]
    }        
}
複製程式碼

程式碼上面的註釋都是筆者自己根據理解加上的,可能會有錯誤,讀者可以根據程式碼來自行理解。

註解:

1:首先初始化輔助陣列helper,和需要排序的陣列一起傳入**mergeSort(::::)**方法。

2:**mergeSort(::::)**通過遞迴,將入引數組進行拆分,直至為單一元素。

3: merge方法根據所傳下標引數,對陣列array進行拆分。並藉助輔助helper陣列完成array陣列的排序操作。

歸併排序理解起來會比前幾種排序方法更難一些,學習時理解了歸併排序的核心思想:拆分->排序->合併,再結合程式碼,就能事半功倍。

6:快速排序(不穩定)

快速排序(Quick Sort)是一種高效的排序方法,它利用了歸併排序的思想,將需要排列的陣列拆分成每一小部分,然後讓每一小部分進行排序、合併,最後得到完整的排序序列。直接看程式碼:

func quickSort(_ array: [Int]) -> [Int] {
    //判斷是否為單一元素
    guard array.count > 1 else {
        return array
    }
    //取出陣列中間下標元素
    let pivot = array[array.count/2]
    //用到了函式式方法filter,過濾元素
    let left = array.filter{$0 < pivot}
    let middle = array.filter{$0 == pivot}
    let right = array.filter{$0 > pivot}
    //遞迴 對新陣列進行合併
    return quickSort(_:left) + middle + quickSort(_:right)        
}
複製程式碼

這部分程式碼比較簡單,就不贅述了。

7:桶排序(穩定)

桶排序 (Bucket Sort)的工作原理是將陣列分到n個相同的大小的子區間,每個子區間就是一個桶,然後將輸入陣列中的元素一一對應,放入對應子區間內的桶中。最後遍歷每個桶,依次輸入每個桶中對應裝的資料即可。因為桶的順序是有序的,所以只要從第一個桶開始遍歷,得到的結果也就是有序的陣列。看下程式碼:

func bucketSort(_ array: inout [Int]) -> [Int]? {
    
    //求出桶的數量
    let max = array.max()
    let min = array.min()
    let bucketCount = max! - min!
    
    //建立桶陣列,桶個數=最大值-最小值+1,預設初值都為0。
    var bucket = Array(repeating: 0, count: bucketCount + 1)
    //遍歷入引數組元素,並給桶做上標記
    for num in array {
        //陣列元素減去最小值,指向桶陣列下標
        let index = num - min!
        //將元素出現次數打上標記,每次+1
        bucket[index] += 1
    }
    //記錄新陣列下標
    var arrayIndex = 0
    
    //對桶內元素進行遍歷
    for i in 0..<bucket.count {
        //取出每個桶的標記值
        var j = bucket[i]
        //當桶內有值,根據標記的次數依次減1,新增到陣列中
        while j > 0 {
            array[arrayIndex] = i + min!
            j-=1
            arrayIndex+=1
        }
    }
    return array
}
複製程式碼

註解:

1:輸入為整數陣列,所以先求出陣列中的最大值和最小值,求出桶的數量,然後建立預設值為0的桶陣列。

2:遍歷入引數組array中的元素,並放入對應區間的桶中。

3:遍歷桶陣列,取出每個桶中的標記值。將桶中大於0的標記值所對應的區間值加入到陣列中,返回陣列。

桶排序的複雜度為O(n),也是上面所有排序方法中效能最好的,邏輯也比較簡單。另外關於桶排序中有一點需要注意的是,建立的桶個數儘量應從最小值開始建立,如果只以最大值建立桶的數量,會造成不必要的記憶體開銷。

上面的部分就是筆者根據常見的排序方法,進行的總結。因為書中只給出了歸併排序和快速排序的寫法,其他幾種排序方法的程式碼實現都是筆者根據自己的理解結合網上資料進行的實現,所以可能會有表述錯誤的地方,還請大家指正。

搜尋

最簡單也是最直接的搜尋就是遍歷集合,然後找到滿足條件的元素,但是當資料量巨大時,這樣搜尋的效率就顯得非常的低下。關於搜尋這塊,主要介紹了二分搜尋,這是一種複雜度更低O(logn),效率更高的搜尋方法。

定義:

有序陣列中,查詢某一個特定元素的搜尋,它從中間的元素開始搜尋,若中間元素是要找的元素,則返回;若中間元素小於要找的元素,則要找的元素一定在大於中間元素的那一部分,所以只需要搜尋此部分即可,反正亦然。

直接看程式碼:

func binarySearch(_ nums: [Int], _ target: Int) -> Bool {
    //初始化
    var left = 0, mid = 0, right = nums.count - 1
    //邊界條件判斷
    while left <= right {
        //求出有序陣列下標
        mid = (right - left)/2 + left
        //判斷當前中間元素與目標元素關係
        if nums[mid] == target {
            //等於mid
            return true
        } else if nums[mid] < target{
            //在mid右邊
            left = mid + 1
        } else {
            //在mid左邊
            right = mid - 1
        }
    }
    return false
}
複製程式碼

程式碼比較簡單,通過不斷取有序陣列的中間值,和目標數值進行比較,不斷遍歷最後等到結果。關於二分演算法,書中最少還介紹了通過遞迴來完成二分搜尋,就是把上述while進行了整合,其原理也是相同的。

**實戰例題:**有一個產品釋出了多個版本,他遵循以下規則,若其中一個版本崩潰了,則其後面的所有版本都會崩潰。

關於這樣的題目,可以將產品的所有版本號看做一個陣列,我們可以通過二分法的操作,來判斷陣列的中間值是否崩潰,若崩潰則更最早出現崩潰的版本在陣列的左邊,若不崩潰則出現在右邊。這樣通過不斷縮小範圍,確定第一個崩潰的版本。 具體的實現,需要注意的是當在陣列左邊時,我們的right就不能做-1的操作了,因為此時也不清楚該mid版本是否是我們需要求的第一個版本,其他的程式碼與上面原理程式碼類似,就不再贅述了。

總結

這篇文章裡面筆者主要把書中後續的排序和搜尋的定義與實現進行了介紹,希望能真正幫助到剛接觸演算法的開發者們。最後關於這部分內容,筆者認為是面試中最常考到的知識點,只要稍微花上幾個小時,在運用和麵試中都能遊刃有餘。

相關文章