1. 程式人生 > >【Algorithms公開課學習筆記6】 排序演算法part4——堆排序

【Algorithms公開課學習筆記6】 排序演算法part4——堆排序

HeapSort 堆排序

0. 前言

本文繼續分析另一個很重要的高效排序演算法——堆排序。不過,在此之前,需要先引入優先佇列的概念,這是堆排序的基礎。

1.優先佇列(Priority Queue)

基本概念

顧名思義,優先佇列是由佇列演變而來的,包含最基本的插入和刪除操作。優先佇列的插入規則與佇列一致,是插入(新增)到佇列的末尾;刪除規則比較特殊,是刪除(在指定規則中)優先順序最高的項。

(最大項)優先佇列API如下:

public class MaxPQ<Key extends Comparable<Key>>{
    MaxPQ() //建構函式
    MaxPQ
(Key[] a) //建構函式 void insert(Key v) //插入項、新增項 Key delMax() //刪除最大值 boolean isEmpty() //判空 Key max() //查詢最大值 int size() //查詢長度 }

簡單應用

想象一個場景:從總數為N的列表中個找出最大的M個項。 方法一:將N列表排序(使用快速排序或者歸併排序,從大到小排),然後選擇前M項即可。 方法二:使用優先佇列,當佇列大小達到M時,開始將最小值出隊。

方法二的程式碼如下:

//MinPQ是alg4提供的最小優先佇列API
MinPQ<Transaction>
pq = new MinPQ<Transaction>(); while (StdIn.hasNextLine()){ String line = StdIn.readLine(); Transaction item = new Transaction(line); pq.insert(item); //當佇列長度達到M時,每讀取一個數字入隊後,就將最小值刪除 if (pq.size() > M) pq.delMin(); }

方法一與方法二的對比

實現方法

優先佇列可以使用有序陣列或者無序陣列來實現。使用有序陣列時,每次插入都要與陣列中原來的項比較,然後插入適當的位置;每次刪除只需刪除陣列最後一項。使用無序陣列時,每次插入時插入到陣列的最後一項;每次刪除時要查找出陣列中優先度最高的項,然後與最後一項交換,刪除最後一項。

以下是無序陣列的實現方式

public class UnorderedMaxPQ<Key extends Comparable<Key>>{

    private Key[] pq; // 定義無序陣列
    private int N; // 定義陣列長度
    public UnorderedMaxPQ(int capacity){
        pq = (Key[]) new Comparable[capacity];
    }

    public boolean isEmpty(){ return N == 0; }
    //插入到陣列最後一項
    public void insert(Key x){ pq[N++] = x; }
    //刪除優先度最大的項
    public Key delMax(){
        int max = 0;
        for (int i = 1; i < N; i++)
            if (less(max, i))
                //查詢到最大項
                max = i;
        //與最後一項交換
        exch(max, N-1);
        return pq[--N];
    }
}

優先佇列是有序陣列和無序陣列的實現方法的效能比較

2.二叉堆

上一小節的最後一張圖表明使用有序陣列或者無序陣列來實現優先佇列均不是最優的方案,因此本節介紹二叉堆這個結構來實現優先佇列,可以將各項操作的時間效能降低到lgN。

基本概念

先上概念,二叉堆本質上就是一種堆有序的完全二叉樹。 所謂的完全二叉樹就是除了最低層外完全平衡的二叉樹,如下圖所示

完全二叉樹具備以下性質:

  • N個節點的完全二叉樹的高度是lgN
  • 只有節點數量達到2的數量級時才增加樹高

所謂的堆有序的二叉樹是指每個節點均代表一個值,父節點的值不小於其子節點的值,下圖就是一個堆有序的二叉樹

二叉堆最簡單的表示形式就是使用陣列表示:從第1位開始,下標的關係標誌樹層的關係,無需指標練連線。

如何使用下標表示樹層呢?

  • 節點K的父節點一定是k/2

  • 節點k的子節點分別是2k 和2k+1

  • 注意一定要從下標1開始記錄(不能從下標0開始)

下標表示樹層的示意圖

基本操作

使用二叉堆實現優先佇列,最基本的操作包括:插入和刪除。 根據二叉堆的特性(陣列表示法)不難發現,隨機插入一個項、刪除優先項都很有可能打亂二叉堆。

場景一

問題:子節點的值大於父節點 解決方法:交換子節點與父節點的位置,直到堆有序 示意圖

程式碼實現

//將節點K浮上去
private void swim(int k){
    while (k > 1 && less(k/2, k)){
        //如果k/2(父)小於k(子),交換位置
        exch(k, k/2);
        k = k/2;
    }
}

時間效能:至多lgN+1次比較操作(樹高)

因此,插入時就是將值插入陣列最後一項,然後呼叫swim()將其上浮到合適位置,保證堆有序。

public void insert(Key x){
    pq[++N] = x;
    swim(N);
}

場景二

問題:父節點的值小於其子節點(一個或者兩個) 解決方法:交換父節點與較大的子節點,直到堆有序 示意圖

程式碼實現

//將節點K沉上去
private void sink(int k){
    while (2*k <= N){
        int j = 2*k;
        if (j < N && less(j, j+1))
            //選擇值較大的子節點
            j++;
        if (!less(k, j)) break;
        //如果k(父)小於j(子),交換位置
        exch(k, j);
        k = j;
    }
}

時間效能:至多需要2lgN次比較。

因此,刪除的時候就是交換root(下標1)與陣列最後一項,刪除最後一項後,對root呼叫sink(),將其下沉到適合的位置,保證堆有序。

public Key delMax(){
    Key max = pq[1];
    exch(1, N--);
    sink(1);
    pq[N+1] = null;
    return max;
}

程式碼實現

//定義了MaxPQ這個最大項優先佇列
public class MaxPQ<Key extends Comparable<Key>>{
    private Key[] pq;
    private int N;

    public MaxPQ(int capacity)
    { pq = (Key[]) new Comparable[capacity+1]; }
    //判空
    public boolean isEmpty()
    { return N == 0; }
    //插入
    public void insert(Key x){
        pq[++N] = x;
        swim(N);
    }
    //刪除
    public Key delMax(){
        Key max = pq[1];
        exch(1, N--);
        sink(1);
        pq[N+1] = null;
        return max; 
    }
    //上浮
    private void swim(int k){
        while (k > 1 && less(k/2, k)){
            //如果k/2(父)小於k(子),交換位置
            exch(k, k/2);
            k = k/2;
        }
    }
    //下沉
    private void sink(int k){
        while (2*k <= N){
        int j = 2*k;
        if (j < N && less(j, j+1))
            //選擇值較大的子節點
            j++;
        if (!less(k, j)) break;
        //如果k(父)小於j(子),交換位置
        exch(k, j);
        k = j;
        }
    }
    //輔助函式:比較和交換
    private boolean less(int i, int j){
        return pq[i].compareTo(pq[j]) < 0;
    }
    private void exch(int i, int j){
        Key t = pq[i];
        pq[i] = pq[j];
        pq[j] = t;
    }
}

有序陣列、無序陣列和二叉堆的實現方法的效能比較

另外,需要考慮一種情況——二叉堆的大小超過了陣列的長度,此時就要使用可調整大小的陣列(resizing array)

3.堆排序

原理

講完以上概念之後,下面正式分析堆排序演算法。堆排序就是先將無序序列構造成二叉堆,然後逐此將root節點與無序序列中的最後一個值交換,重複直到序列有序。 這裡使用了兩次迴圈,第一次迴圈是自底向上檢查,構造二叉堆;第二次迴圈是重複交換root與無序序列中最後一個節點,遍歷完所有節點。

第二次迴圈的示意圖

  • 1.E 與X (ROOT)交換,同時E 下沉到其適合位置。此時X 有序,其他無序;

  • 2.E 與T (ROOT)交換,同時E 下沉到期適合位置。此時X T 有序,其他無序;

  • 3.以此類推。

程式碼實現

public class Heap{
    public static void sort(Comparable[] a){
        int N = a.length;
        //第一次迴圈
        for (int k = N/2; k >= 1; k--)
            sink(a, k, N);
        //第二次迴圈
        while (N > 1){
            exch(a, 1, N);
            sink(a, 1, --N);
        }
    }
    private static void sink(Comparable[] a, int k, int N)
    { /* as before */ }
    private static boolean less(Comparable[] a, int i, int j)
    { /* as before */ }
    private static void exch(Comparable[] a, int i, int j)
    { /* as before */ }
}

對比

作為高效的排序演算法,快速排序、歸併排序、堆排序對比如下:

空間使用率

  • 歸併排序:線性級的額外空間N

  • 快速排序:常數級額外空間in place

  • 堆排序:常數級的二外空間in place

時間效能

對排序最多使用了2NlgN次比較和交換

4.第4周作業答案