1. 程式人生 > 其它 >資料結構與演算法(十五)

資料結構與演算法(十五)

堆排序

基本介紹

堆排序是利用 這種 資料結構 而設計的一種排序演算法,它是一種選擇排序,最壞 、最好、平均時間複雜度均為 O(nlogn),它是不穩定排序。

堆是具有以下性質的完全二叉樹:

  • 大頂堆:每個節點的值都 大於或等於 其左右孩子節點的值

    注:沒有要求左右值的大小關係

  • 小頂堆:每個節點的值都 小於或等於 其左右孩子節點的值

舉例說明:

大頂堆舉例

對堆中的節點按層進行編號,對映到陣列中如下圖

大頂堆特點:arr[i] >= arr[2*i+1] && arr[i] >= arr[2*i+2],i 對應第幾個節點,i 從 0 開始編號

小頂堆舉例

小頂堆特點:arr[i] <= arr[2*i+1] && arr[i] <= arr[2*i+2],i 對應第幾個節點,i 從 0 開始

  • 升序:一般採用大頂堆
  • 降序:一般採用小頂堆

基本思想

  1. 將待排序序列構造成一個大頂堆

    注意:這裡使用的是陣列,而不是一顆二叉樹

  2. 此時:整個序列的 最大值就是堆頂的根節點

  3. 將其 與末尾元素進行交換,此時末尾就是最大值

  4. 然後將剩餘 n-1 個元素重新構造成一個堆,這樣 就會得到 n 個元素的次小值。如此反覆,便能的得到一個有序序列。

堆排序步驟圖解

  1. 給定無序序列結構 如下:注意這裡的操作用陣列,樹結構只是參考理解

將給定無序序列構造成一個大頂堆。

  1. 此時從最後一個非葉子節點開始調整,從左到右,從上到下進行調整。

    葉節點不用調整,第一個非葉子節點 arr.length/2-1 = 5/2-1 = 1,也就是 元素為 6 的節點。

  2. 找到第二個非葉子節點 4,由於 [4,9,8] 中,9 元素最大,則 4 和 9 進行交換

  3. 此時,交換導致了子根 [4,5,6] 結構混亂,將其繼續調整。[4,5,6] 中 6 最大,將 4 與 6 進行調整。

此時,就將一個無序序列構造成了一個大頂堆。

5. 將堆頂元素與末尾元素進行交換,**使其末尾元素最大**。然後繼續調整,再將堆頂元素與末尾元素交換,得到第二大元素。如此反覆進行交換、重建、交換。

1. 將堆頂元素 9 和末尾元素 4 進行交換
  1. 將堆頂元素 9 和末尾元素 4 進行交換

  2. 再將堆頂元素 8 與末尾元素 5 進行交換,得到第二大元素 8

  3. 再將堆頂元素 8 與末尾元素 5 進行交換,得到第二大元素 8

程式碼實現

//堆排序
public static void heapSort(int[] arr){
    int temp;
    //將無序列表構建成一個大頂推
    for(int i = arr.length/2-1;i >= 0; i--){
        adjustHeap(arr,i,arr.length);
    }
    for(int k = arr.length-1; k > 0; k--){
        //交換
        temp = arr[0];
        arr[0] = arr[k];
        arr[k] = temp;
        adjustHeap(arr,0,k);
    }
}

    /**
     * 將以i為節點的數調整成一個大頂堆
     * @param arr 待調整的陣列
     * @param i 要調整的非葉子節點
     * @param length 陣列長度
     */
public static void adjustHeap(int[] arr,int i,int length){
    int temp = arr[i];

    for(int k = 2*i+1; k < length; k = 2*k+1){
        if(k+1 < length && arr[k+1] > arr[k]){
            //右子節點比左子節點大
            k++;
        }
        //比較是否調整位置
        if(arr[k] > temp){
            arr[i] = arr[k];//交換位置
            i = k;//重置i的位置
        }else{
            break;
        }
    }
    arr[i] = temp;
}

效能測試

排序800萬資料,平均時間在2秒左右

常用的十種演算法

二分查詢(非遞迴)

二分查詢法只適用於從 有序 的數列中查詢(比如數字和字母等),將數列 **排序後 **再進行查詢。

二分查詢法的執行時間為對數時間 O(log2n) ,即查詢到目標位置最多隻需要 log2n 步,假設從 0~99 的佇列(100 個數,即 n = 100),中旬到目標數 30,則需要查詢的步數為 log2100,即最多需要查詢 7 次(26 < 100 < 27,100 介於 2 的 6、7 次方之間,次方則是尋找的步數)

程式碼實現

/**
     * 二分查詢非遞迴
     * @param arr 有序陣列
     * @param target 查詢的目標
     * @return 返回找到到的索引,沒有找到返回-1
     */
public static int binarySearch(int[] arr,int target){
    int left = 0;
    int right = arr.length-1;
    while(left <= right){
        int mid = (right + left) / 2;
        if(arr[mid] == target){
            return mid;
        }else if(arr[mid] > target){
            right = mid-1;
        }else{
            left = mid+1;
        }
    }
    return -1;
}

分治演算法

分治法 是一種很重要的演算法。字面上的解釋是 分而治之,把一個複雜的問題 分成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題.... 直到最後子問題可以簡單的直接求解,原問題的解即 子問題的解的合併

這個技巧是很多高效演算法的基礎,比如 排序演算法:快速排序、歸併排序,傅立葉變換、快速傅立葉變換

分治演算法可以 求解的一些經典問題 如:

  • 二分搜尋
  • 大整數乘法
  • 棋盤覆蓋
  • 快速排序
  • 歸併排序
  • 線性時間選擇
  • 最接近點對問題
  • 迴圈賽日程表
  • 漢諾塔

剛剛看了下之前學過的快速排序和歸併排序,他們不同也是難點在於如何把一個大問題 分解 成一個小問題進行 解決,然後再 合併 小問題的結果。

基本步驟

分治法在每一層遞迴上都有三個步驟:

  • 分解:將原問題分解為若干個規模較小、相互獨立、與原問題形式相同的子問題
  • 解決:若子問題規模較小而容易被解決則直接解決,否則遞迴的解各個子問題
  • 合併:將各個子問題的解合併為原問題的解

分治演算法的設計模式

if |P| ≤ n0
  then return (ADHOC(P))
// 將 P 分解為較小的子問題 P1,P2...Pk
for i ← to k
do yi ← Divide-and-Conquer(Pi) // 遞迴解決 pi
T ← MERGE(y1,y2,..yk)          // 合併子問題
return(T)
  • |P|:表示問題 P 的規模

  • n0:為閥值,表示當問題 P 的規模不超過 n0 時,問題已容易直接解出,不必再繼續分解

  • ADHOC(P) :該分治法中的基本子演算法,用於直接解小規模的問題 P

    因此,當 P 的規模不超過 n0 時,直接用 ADHOC(P) 求解。

  • MERGE(y1,y2,..yk):該分治法中的合併子演算法,用於將 P 的子問題 P1、P2...Pk 的相應的解 y1、y2...yk 合併為 P 的解

實踐-漢諾塔

漢諾塔(又稱河內塔)問題是源於印度一個古老傳說的益智玩具。大梵天創造世界的時候做了三根金剛石柱子,在一根柱子上從下往上按照大小順序摞著 64 片黃金圓盤。大梵天命令婆羅門把圓盤從下面開始按大小順序重新擺放在另一根柱子上。並且規定,在小圓盤上不能放大圓盤,在三根柱子之間一次只能移動一個圓盤。

假如每秒鐘一次,共需多長時間呢?移完這些金片需要 5845.54 億年以上,太陽系的預期壽命據說也就是數百億年。真的過了 5845.54 億年,地球上的一切生命,連同梵塔、廟宇等,都早已經灰飛煙滅。

/**
     * 分治演算法解決漢諾塔
     * @param num 要移動的盤子個數
     * @param a A柱
     * @param b B柱
     * @param c C柱
     */
public static void dac(int num,char a,char b,char c){
    if(num == 1){
        System.out.println("將第1個盤從" + a + "移動到" + c);
    }else{
        //將除最後一個以外的上面所有的盤移動到B柱,藉助C盤
        dac(num-1,a,c,b);
        //將最後一個從A柱移動到C柱
        System.out.println("將第" + num + "個盤從" + a + "移動到" + c);
        //將除最後一個以外的上面所有的盤移動到C柱藉助A柱
        dac(num-1,b,a,c);
    }
}

驗證結果正確性:https://zhangxiaoleiwk.gitee.io/h.html

動態規劃演算法

應用場景:揹包問題

有一個揹包,容量為 4 磅,現有物品如下:

物品 重量 價格
吉他(G) 1 1500
音響(S) 4 3000
電腦(L) 3 2000

要求:

  1. 達到的目標為裝入的揹包的總價值最大,並且重量不超出
  2. 裝入的物品不能重複

介紹

動態規劃(Dynamic Programming) 演算法的核心思想是:將大問題劃分為小問題進行解決,從而一步步獲取最優解的處理演算法

動態規劃演算法 與分治演算法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然後從這些子問題的解得到原問題的解。

與分治法不同的是,適合於用動態規劃求解的問題,經分解得到子問題往往不是互相獨立的。 ( 即 下一個子階段的求解是建立在上一個子階段的解的基礎上,進行進一步的求解 )

動態規劃可以通過 填表的方式 來逐步推進,得到最優解.

思路和圖解

揹包問題主要是指一個給定容量的揹包、若干具有一定價值和重量的物品,如何選擇物品放入揹包使物品的價值最大。其中又分:

  • 01 揹包:放入物品不能重複

  • 無限揹包:放入物品可重複

    無限揹包可以轉化成 01 揹包。

想要解決一個問題,你首先得有思想,然後轉化成公式或規律,最後轉化成你的程式。

演算法的主要思想:利用動態規劃來解決。每次遍歷到的第 i 個物品,根據 w[i]v[i] 來確定是否需要將該物品放入揹包中。即設:

  • n : 給定了 n 個物品

  • w[i]:第 i 個商品的重量

  • val[i]:第 i 個商品的價值

  • c:為揹包的容量

  • v[i][j]:表示在前 i 個物品中能夠裝入容量為 j 的揹包中的最大價值

    假設當前已經放了 i 個物品在揹包中,那麼當前揹包的容量為 j,能夠放進去的容量用 v[i][j] 表示

則有下面的結果:

  1. v[i][0] = v[0][j]=0
  2. w[i] > j時:v[i][j] = v[i - 1][j]
  3. j ≥ w[i] 時:v[i][j] = max{v[i - 1][j] , v[i - 1][j - w[i]] + val[i]}

以上思路和公式,現在肯定看不懂,下面使用填表法來逐步推匯出這個規律和公式。

給定的商品如下:

物品 重量 價格
吉他(G) 1 1500
音響(S) 4 3000
電腦(L) 3 2000

初始表格為:

物品 0 磅 1 磅 2 磅 3 磅 4 磅
沒有物品 0 0 0 0 0
吉他(G) 0
音響(S) 0
電腦(L) 0
  • 第 1 行:是沒有任何物品:那麼它在任何揹包容量下,都是 0 磅
  • 第 1 列:是當揹包容量為 0 時,那麼它是無法裝入任何物品的,所以都是 0
  1. 現在假如只有吉他可以放:
物品 0 磅 1 磅 2 磅 3 磅 4 磅
0 0 0 0 0
吉他(G) 0 1500(G) 1500(G) 1500(G) 1500(G)
音響(S) 0
電腦(L) 0

現在只有一把吉他可以放,所以不管揹包的容量有多大,它都只能放一把吉他進去(01 揹包),所以 4 個容量都為 1500(G)

  1. 上面已經放了一把吉他,現在開始嘗試放音響:
物品 0 磅 1 磅 2 磅 3 磅 4 磅
0 0 0 0 0
吉他(G) 0 1500(G) 1500(G) 1500(G) 1500(G)
音響(S) 0 1500(G) 1500(G) 1500(G) 3000(S)
電腦(L) 0
  • 當揹包容量只有 1 磅時:音響重 4 磅,放不進去

    那麼從上一個單元格複製物品下來,也就是 1500(G) 吉他

  • 類似的:當揹包只有 2、3 磅時,也是放不下音響的

    那麼從上一個單元格複製物品下來,也就是 1500(G) 吉他

  • 當揹包容量有 4 磅時:可以放下音響了

    需要考慮當前音響放進去的價值,是否大於上一個單元格(子問題的解依賴於上一個子問題的解)

    這裡是 3000 > 1500,那麼此時 4 磅的格子中就放入了 3000(S) 音響

  1. 現在開始嘗試放電腦:
物品 0 磅 1 磅 2 磅 3 磅 4 磅
0 0 0 0 0
吉他(G) 0 1500(G) 1500(G) 1500(G) 1500(G)
音響(S) 0 1500(G) 1500(G) 1500(G) 3000(S)
電腦(L) 0 1500(G) 1500(G) 2000(L) 2000(L)+ 1500(G)
  • 當揹包容量只有 1、2 磅時:電腦重量 3 磅,放不進去

    那麼從上一個單元格複製物品下來,也就是 1500(G) 吉他

  • 當揹包容量只有 3 磅時:可以放下電腦了

    需要考慮當前電腦放進去的價值,是否大於上一個單元格。

    2000 > 1500,那麼就放入 2000(L) 電腦。

  • 當揹包容量只有 4 磅時:此時如果不考慮程式實現,人為填表的話

    可以放 2000(L)+ 1500(G) 的電腦和吉他

程式碼實現

//動態規劃解決揹包問題
public class KnapsackProblem {
    public static void main(String[] args) {
        int[] w = {1,4,3};//用於表示對應的商品重量,比如,第一個商品重量為1,第二個為4
        int[] val = {1500,3000,2000};//用於表示對應商品的價值,比如第一個商品價值為1500
        int M = 4;//表示揹包的容量
        int n = w.length;//商品的個數
        int[][] v = new int[n+1][M+1];//用於v[i][j]表示在有i個商品種類的時候,揹包容量為j時候最大的揹包價值
        int[][] path = new int[n+1][M+1];//記錄放入商品的順序

        //當商品種類為0的時候,不管揹包容量為多少,揹包價值的都是0
        for(int i = 0; i < v[0].length;i++){
            v[0][i] = 0;
        }
        //當揹包容量為0的時候,不管有幾種商品,揹包最大的價值都是0
        for(int i = 0; i < v.length; i++){
            v[i][0] = 0;
        }
        //開始動態規劃
        for(int i = 1; i <= n; i++){//i表示商品的種類,i=1,表示第一個商品
            for(int j = 0; j < v[i].length; j++){//j表示揹包的容量,j=0表示揹包容量為0
                if(w[i-1] > j){//噹噹前商品的重量大於當前揹包的容量的時候,就參考上一次該容量的時候揹包的價值
                    v[i][j] = v[i-1][j];

                }else{
                    //當揹包容量大於或者等於當前要新增商品容量的時候,將上一次該容量的最大價值和
                    //將當前商品放入後的價值和剩餘空間還能放的最大價值之和比較,取兩個值的最大值
                    if(v[i-1][j] < val[i-1] + v[i-1][j-w[i-1]]){
                        v[i][j] = val[i-1] + v[i-1][j-w[i-1]];
                        path[i][j] = 1;
                    }else{
                        v[i][j] = v[i-1][j];
                    }

                }
            }
        }
        System.out.println("動態規劃計算出的各容量的揹包的最大價值:");
        //輸出動態規劃之和揹包的價值表
        for(int i = 0; i < v.length; i++){
            for(int j = 0; j < v[i].length; j++){
                System.out.printf("%4d\t",v[i][j]);
            }
            System.out.println();
        }
        //從最後一個商品最大容量開始逆向輸出
        int i = path.length-1;
        int j = path[0].length-1;
        while(i > 0 && j > 0){
            if(path[i][j] == 1){
                System.out.printf("第%d個商品放入揹包\n",i);
                j -= w[i-1];//放入當前商品後,剩餘容量
            }
            i--;//下一個可以放入的商品
        }

    }
}