堆和堆的應用:堆排序和優先隊列

堆和堆的應用:堆排序和優先隊列

1.堆

堆(Heap)是一種重要的數據結構,是實現優先隊列(Priority Queues)html

首選的數據結構。因爲堆有不少種變體,包括二項式堆、斐波那契堆等,可是這裏只考慮最多見的就是二叉堆(如下簡稱堆)。java

堆是一棵知足必定性質的二叉樹,具體的講堆具備以下性質:父節點的鍵值老是不大於它的孩子節點的鍵值(小頂堆), 堆能夠分爲小頂堆大頂堆,這裏以小頂堆爲例,其主要包含的操做有:
- insert()
- extractMin
- peek(findMin)
- delete(i)node

因爲堆是一棵形態規則的二叉樹,所以堆的父節點和孩子節點存在以下關係:web

設父節點的編號爲 i, 則其左孩子節點的編號爲2*i+1, 右孩子節點的編號爲2*i+2
設孩子節點的編號爲i, 則其父節點的編號爲(i-1)/2算法

因爲二叉樹良好的形態已經包含了父節點和孩子節點的關係信息,所以就能夠不使用鏈表而簡單的使用數組來存儲堆。編程

要實現堆的基本操做,涉及到的兩個關鍵的函數
- siftUp(i, x) : 將位置i的元素x向上調整,以知足堆得性質,經常是用於insert後,用於調整堆;
- siftDown(i, x):同理,經常是用於delete(i)後,用於調整堆;swift

具體的操做以下:api

private void siftUp(int i) {
    int key = nums[i];
    for (; i > 0;) {
        int p = (i - 1) >>> 1;
        if (nums[p] <= key)
            break;
        nums[i] = nums[p];
        i = p;
    }
    nums[i] = key;
}
private void siftDown(int i) {
        int key = nums[i];
        for (;i < nums.length / 2;) {
            int child = (i << 1) + 1;
            if (child + 1 < nums.length && nums[child] > nums[child+1])
                child++;
            if (key <= nums[child])
                break;
            nums[i] = nums[child];
            i = child;
        }
        nums[i] = key;
  }

能夠看到siftUpsiftDown不停的在父節點和子節點之間比較、交換;在不超過logn的時間複雜度就能夠完成一次操做。數組

有了這兩個基本的函數,就能夠實現上述說起的堆的基本操做。數據結構

首先是如何建堆,實現建堆操做有兩個思路:

  • 一個是不斷地insertinsert後調用的是siftUp
  • 另外一個將原始數組當成一個須要調整的堆,而後自底向上地
    在每一個位置i調用siftDown(i),完成後咱們就能夠獲得一個知足堆性質的堆。這裏考慮後一種思路:

一般堆的insert操做是將元素插入到堆尾,因爲新元素的插入可能違反堆的性質,所以須要調用siftUp操做自底向上調整堆;堆移除堆頂元素操做是將堆頂元素刪除,而後將堆最後一個元素放置在堆頂,接着執行siftDown操做,同理替換堆頂元素也是相同的操做。

建堆

// 創建小頂堆
private void buildMinHeap(int[] nums) {
    int size = nums.length;
    for (int j = size / 2 - 1; j >= 0; j--)
        siftDown(nums, j, size);
}

那麼建堆操做的時間複雜度是多少呢?答案是O(n)。雖然siftDown的操做時間是logn,可是因爲高度在遞減的同時,每一層的節點數量也在成倍減小,最後經過數列錯位相減能夠獲得時間複雜度是O(n)

extractMin
因爲堆的固有性質,堆的根即是最小的元素,所以peek操做就是返回根nums[0]元素便可;
若要將nums[0]刪除,能夠將末尾的元素nums[n-1]覆蓋nums[0],而後將堆得size = size-1,調用siftDown(0)調整堆。時間複雜度爲logn

peek
同上

delete(i)

刪除堆中位置爲i的節點,涉及到兩個函數siftUpsiftDown,時間複雜度爲logn,具體步驟是,
- 將元素last覆蓋元素i,而後siftDown
- 檢查是否須要siftUp

注意到堆的刪除操做,若是是刪除堆的根節點,則不用考慮執行siftUp的操做;若刪除的是堆的非根節點,則要視狀況決定是siftDown仍是siftUp操做,兩個操做是互斥的。

public int delete(int i) {
    int key = nums[i];
    //將last元素移動過來,先siftDown; 再視狀況考慮是否siftUp
    int last = nums[i] = nums[size-1];
    size--;
    siftDown(i);
    //check #i的node的鍵值是否確實發生改變(是否siftDown操做生效),若發生改變,則ok,不然爲確保堆性質,則須要siftUp 
    if (i < size && nums[i] == last) {
        System.out.println("delete siftUp");
        siftUp(i);
    }   
     return key;
}

case 1 :

刪除中間節點i21,將最後一個節點複製過來;

這裏寫圖片描述

因爲沒有進行siftDown操做,節點i的值仍然爲6,所以爲確保堆的性質,執行siftUp操做;

這裏寫圖片描述

case 2

刪除中間節點i,將值爲11的節點複製過來,執行siftDown操做;
這裏寫圖片描述

因爲執行siftDown操做後,節點i的值再也不是11,所以就不用再執行siftUp操做了,由於堆的性質在siftDown操做生效後已經獲得了保持。

這裏寫圖片描述


能夠看出,堆的基本操做都依賴於兩個核心的函數siftUpsiftDown;較爲完整的Heap代碼以下:

class Heap {
    private final static int N = 100; //default size
    private int[] nums;
    private int size;

    public Heap(int[] nums) {
        this.nums = nums;
        this.size = nums.length;
        heapify(this.nums);
    }

    public Heap() {
        this.nums = new int[N];
    }

    /** * heapify an array, O(n) * @param nums An array to be heapified. */
    private void heapify(int[] nums) {
        for (int j = (size - 1) >> 1; j >= 0; j--)
            siftDown(j);
    }

    /** * append x to heap * O(logn) * @param x * @return */
    public int insert(int x) {
        if (size >= this.nums.length)
            expandSpace();
        size += 1;
        nums[size-1] = x;
        siftUp(size-1);
        return x;
    }

    /** * delete an element located in i position. * O(logn) * @param i * @return */
    public int delete(int i) {
        rangeCheck(i);
        int key = nums[i];
        //將last元素覆蓋過來,先siftDown; 再視狀況考慮是否siftUp;
        int last = nums[i] = nums[size-1];
        size--;
        siftDown(i);
        //check #i的node的鍵值是否確實發生改變,若發生改變,則ok,不然爲確保堆性質,則須要siftUp; 
        if (i < size && nums[i] == last) 
            siftUp(i);
        return key;
    }

    /** * remove the root of heap, return it's value, and adjust heap to maintain the heap's property. * O(logn) * @return */
    public int extractMin() {
        rangeCheck(0);
        int key = nums[0], last = nums[size-1];
        nums[0] = last;
        size--;
        siftDown(0);
        return key;
    }
    /** * return an element's index, if not exists, return -1; * O(n) * @param x * @return */
    public int search(int x) {
        for (int i = 0; i < size; i++)
            if (nums[i] == x)
                return i;
        return -1;
    }
    /** * return but does not remove the root of heap. * O(1) * @return */
    public int peek() {
        rangeCheck(0);
        return nums[0];
    }

    private void siftUp(int i) {
        int key = nums[i];
        for (; i > 0;) {
            int p = (i - 1) >>> 1;
            if (nums[p] <= key)
                break;
            nums[i] = nums[p];
            i = p;
        }
        nums[i] = key;
    }

    private void siftDown(int i) {
        int key = nums[i];
        for (;i < size / 2;) {
            int child = (i << 1) + 1;
            if (child + 1 < size && nums[child] > nums[child+1])
                child++;
            if (key <= nums[child])
                break;
            nums[i] = nums[child];
            i = child;
        }
        nums[i] = key;
    }

    private void rangeCheck(int i) {
        if (!(0 <= i && i < size))
            throw new RuntimeException("Index is out of boundary"); 
    }

    private void expandSpace() {
        this.nums = Arrays.copyOf(this.nums, size * 2);
    }

    @Override
    public String toString() {
        // TODO Auto-generated method stub
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < size; i++)
            sb.append(String.format((i != 0 ? ", " : "") + "%d", nums[i]));
        sb.append("]\n");
        return sb.toString();
    }
}

2.堆的應用:堆排序

運用堆的性質,咱們能夠獲得一種經常使用的、穩定的、高效的排序算法————堆排序。堆排序的時間複雜度爲O(n*log(n)),空間複雜度爲O(1),堆排序的思想是:
對於含有n個元素的無序數組nums, 構建一個堆(這裏是小頂堆)heap,而後執行extractMin獲得最小的元素,這樣執行n次獲得序列就是排序好的序列。
若是是降序排列則是小頂堆;不然利用大頂堆。

Trick

因爲extractMin執行完畢後,最後一個元素last已經被移動到了root,所以能夠將extractMin返回的元素放置於最後,這樣能夠獲得sort in place的堆排序算法。

具體操做以下:

int[] n = new int[] {1,9,5,6,8,3,1,2,5,9,86};
Heap h = new Heap(n);
for (int i = 0; i < n.length; i++)
    n[n.length-1-i] = h.extractMin();

固然,若是不使用前面定義的heap,則能夠手動寫堆排序,因爲堆排序設計到建堆extractMin, 兩個操做都公共依賴於siftDown函數,所以咱們只須要實現siftDown便可。(trick:因爲建堆操做能夠採用siftUp或者siftDown,而extractMin是須要siftDown操做,所以取公共部分,則採用siftDown建堆)。

這裏便於和前面統一,採用小頂堆數組進行降序排列。

public void heapSort(int[] nums) {
        int size = nums.length;
        buildMinHeap(nums);
        while (size != 0) {
            // 交換堆頂和最後一個元素
            int tmp = nums[0];
            nums[0] = nums[size - 1];
            nums[size - 1] = tmp;
            size--;
            siftDown(nums, 0, size);
        }
    }

    // 創建小頂堆
    private void buildMinHeap(int[] nums) {
        int size = nums.length;
        for (int j = size / 2 - 1; j >= 0; j--)
            siftDown(nums, j, size);
    }

    private void siftDown(int[] nums, int i, int newSize) {
        int key = nums[i];
        while (i < newSize >>> 1) {
            int leftChild = (i << 1) + 1;
            int rightChild = leftChild + 1;
            // 最小的孩子,比最小的孩子還小
            int min = (rightChild >= newSize || nums[leftChild] < nums[rightChild]) ? leftChild : rightChild;
            if (key <= nums[min])
                break;
            nums[i] = nums[min];
            i = min;
        }
        nums[i] = key;
    }

3.堆的應用:優先隊列

優先隊列是一種抽象的數據類型,它和堆的關係相似於,List和數組、鏈表的關係同樣;咱們經常使用堆來實現優先隊列,所以不少時候堆和優先隊列都很類似,它們只是概念上的區分。
優先隊列的應用場景十分的普遍:
常見的應用有:
- Dijkstra’s algorithm(單源最短路問題中須要在鄰接表中找到某一點的最短鄰接邊,這能夠將複雜度下降。)
- Huffman coding(貪心算法的一個典型例子,採用優先隊列構建最優的前綴編碼樹(prefixEncodeTree))
- Prim’s algorithm for minimum spanning tree
- Best-first search algorithms

這裏簡單介紹上述應用之一:Huffman coding

Huffman編碼是一種變長的編碼方案,對於每個字符,所對應的二進制位串的長度是不一致的,可是遵照以下原則:
- 出現頻率高的字符的二進制位串的長度小
- 不存在一個字符c的二進制位串s是除c外任意字符的二進制位串的前綴

遵照這樣原則的Huffman編碼屬於變長編碼,能夠無損的壓縮數據,壓縮後一般能夠節省20%-90%的空間,具體壓縮率依賴於數據的固有結構。

Huffman編碼的實現就是要找到知足這兩種原則的 字符-二進制位串 對照關係,即找到最優前綴碼的編碼方案(前綴碼:沒有任何字符編碼後的二進制位串是其餘字符編碼後位串的前綴)。
這裏咱們須要用到二叉樹來表達最優前綴碼,該樹稱爲最優前綴碼樹
一棵最優前綴碼樹看起來像這樣:

這裏寫圖片描述

算法思想:用一個屬性爲freqeunce關鍵字的最小優先隊列Q,將當前最小的兩個元素x,y合併獲得一個新元素z(z.frequence = x.freqeunce + y.frequence),
而後插入到優先隊列中Q中,這樣執行n-1次合併後,獲得一棵最優前綴碼樹(這裏不討論算法的證實)。

一個常見的構建流程以下:

這裏寫圖片描述

樹中指向某個節點左孩子的邊上表示位0,指向右孩子的邊上的表示位1,這樣遍歷一棵最優前綴碼樹就能夠獲得對照表。

import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.PriorityQueue;

/** * * root * / \ * --------- ---------- * |c:freq | | c:freq | * --------- ---------- * * */
public class HuffmanEncodeDemo {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Node[] n = new Node[6];
        float[] freq = new float[] { 9, 5, 45, 13, 16, 12 };
        char[] chs = new char[] { 'e', 'f', 'a', 'b', 'd', 'c' };
        HuffmanEncodeDemo demo = new HuffmanEncodeDemo();
        Node root = demo.buildPrefixEncodeTree(n, freq, chs);
        Map<Character, String> collector = new HashMap<>();
        StringBuilder sb = new StringBuilder();
        demo.tranversalPrefixEncodeTree(root, collector, sb);
        System.out.println(collector);
        String s = "abcabcefefefeabcdbebfbebfbabc";
        StringBuilder sb1 = new StringBuilder();
        for (char c : s.toCharArray()) {
            sb1.append(collector.get(c));
        }
        System.out.println(sb1.toString());
    }

    public Node buildPrefixEncodeTree(Node[] n, float[] freq, char[] chs) {
        PriorityQueue<Node> pQ = new PriorityQueue<>(new Comparator<Node>() {
            public int compare(Node o1, Node o2) {
                return o1.item.freq > o2.item.freq ? 1 : o1.item.freq == o2.item.freq ? 0 : -1;
            };
        });
        Node e = null;
        for (int i = 0; i < chs.length; i++) {
            n[i] = e = new Node(null, null, new Item(chs[i], freq[i]));
            pQ.add(e);
        }

        for (int i = 0; i < n.length - 1; i++) {
            Node x = pQ.poll(), y = pQ.poll();
            Node z = new Node(x, y, new Item('$', x.item.freq + y.item.freq));
            pQ.add(z);
        }
        return pQ.poll();
    }

    /** * tranversal * @param root * @param collector * @param sb */
    public void tranversalPrefixEncodeTree(Node root, Map<Character, String> collector, StringBuilder sb) {
        // leaf node
        if (root.left == null && root.right == null) {
            collector.put(root.item.c, sb.toString());
            return;
        }
        Node left = root.left, right = root.right;
        tranversalPrefixEncodeTree(left, collector, sb.append(0));
        sb.delete(sb.length() - 1, sb.length());
        tranversalPrefixEncodeTree(right, collector, sb.append(1));
        sb.delete(sb.length() - 1, sb.length());
    }

}

class Node {
    public Node left, right;
    public Item item;

    public Node(Node left, Node right, Item item) {
        super();
        this.left = left;
        this.right = right;
        this.item = item;
    }

}

class Item {
    public char c;
    public float freq;

    public Item(char c, float freq) {
        super();
        this.c = c;
        this.freq = freq;
    }
}

輸出以下:

{a=0, b=101, c=100, d=111, e=1101, f=1100} 010110001011001101110011011100110111001101010110011110111011011100101110110111001010101100

4 堆的應用:海量實數中(一億級別以上)找到TopK(一萬級別如下)的數集合。

  • A:一般遇到找一個集合中的TopK問題,想到的即是排序,由於常見的排序算法例如快排算是比較快了,而後再取出K個TopK數,時間複雜度爲O(nlogn),當n很大的時候這個時間複雜度仍是很大的;

  • B:另外一種思路就是打擂臺的方式,每一個元素與K個待選元素比較一次,時間複雜度很高:O(k*n),此方案明顯遜色於前者。

對於一億數據來講,A方案大約是26.575424*n

  • C:因爲咱們只須要TopK,所以不須要對全部數據進行排序,能夠利用堆得思想,維護一個大小爲K的小頂堆,而後依次遍歷每一個元素e, 若元素e大於堆頂元素root,則刪除root,將e放在堆頂,而後調整,時間複雜度爲logK;若小於或等於,則考察下一個元素。這樣遍歷一遍後,最小堆裏面保留的數就是咱們要找的topK,總體時間複雜度爲O(k+n*logk)約等於O(n*logk),大約是13.287712*n(因爲k與n數量級差太多),這樣時間複雜度降低了約一半。

A、B、C三個方案中,C一般是優於B的,由於logK一般是小於k的,當Kn的數量級相差越大,這種方式越有效。

如下爲具體操做:

import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.Scanner;
import java.util.Set;
import java.util.TreeSet;
public class TopKNumbersInMassiveNumbersDemo {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        int[] topK = new int[]{50001,50002,50003,50004,50005};
        genData(1000 * 1000 * 1000, 500, topK);
        long t = System.currentTimeMillis();
        findTopK(topK.length);
        System.out.println(String.format("cost:%fs", (System.currentTimeMillis() - t) * 1.0 / 1000));
    }

    public static void genData(int N, int maxRandomNumer, int[] topK) {
        File f = new File("data.txt");
        int k = topK.length;
        Set<Integer> index = new TreeSet<>();
        for (;;) {
            index.add((int)(Math.random() * N));
            if (index.size() == k)
                break;
        }
        System.out.println(index);
        int j = 0;
        try {
            PrintWriter pW = new PrintWriter(f, "UTF-8");
            for (int i = 0; i < N; i++)
                if(!index.contains(i))
                    pW.println((int)(Math.random() * maxRandomNumer));
                else
                    pW.println(topK[j++]);
            pW.flush();
        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (UnsupportedEncodingException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }

    public static void findTopK(int k) {
        int[] nums = new int[k];
        //read
        File f = new File("data.txt");
        try {
            Scanner scanner = new Scanner(f);
            for (int j = 0;j < k; j++)
                nums[j] = scanner.nextInt();
            heapify(nums);
            //core
            while (scanner.hasNextInt()) {
                int a = scanner.nextInt();
                if (a <= nums[0])
                    continue;
                else {
                    nums[0] = a;
                    siftDown(0, k, nums);
                }
            }
            System.out.println(Arrays.toString(nums));
        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

    }

    //O(n), minimal heap
    public static void heapify(int[] nums) {
        int size = nums.length;
        for (int j = (size - 1) >> 1; j >= 0; j--)
            siftDown(j, size, nums);
    }

    private static void siftDown(int i, int n, int[] nums) {
        int key = nums[i];
        for (;i < (n >>> 1);) {
            int child = (i << 1) + 1;
            if (child + 1 < n && nums[child] > nums[child+1])
                child++;
            if (key <= nums[child])
                break;
            nums[i] = nums[child];
            i = child;
        }
        nums[i] = key;
    }
}

ps:大體測試了一下,10億個數中找到top5須要140秒左右,應該是很快了。

5 總結

  • 堆是基於樹的知足必定約束的重要數據結構,存在許多變體例如二叉堆、二項式堆、斐波那契堆(很高效)等。
  • 堆的幾個基本操做都依賴於兩個重要的函數siftUpsiftDown,堆的insert一般是在堆尾插入新元素並siftUp調整堆,而extractMin是在
    刪除堆頂元素,而後將最後一個元素放置堆頂並調用siftDown調整堆。
  • 二叉堆是經常使用的一種堆,其是一棵二叉樹;因爲二叉樹良好的性質,所以經常採用數組來存儲堆。
    堆得基本操做的時間複雜度以下表所示:
heapify insert peek extractMin delete(i)
O(n) O(logn) O(1) O(logn) O(logn)

- 二叉堆一般被用來實現堆排序算法,堆排序能夠sort in place,堆排序的時間複雜度的上界是O(nlogn),是一種很優秀的排序算法。因爲存在相同鍵值的兩個元素處於兩棵子樹中,而兩個元素的順序可能會在後續的堆調整中發生改變,所以堆排序不是穩定的。降序排序須要創建小頂堆,升序排序須要創建大頂堆。

  • 堆是實現抽象數據類型優先隊列的一種方式,優先隊列有很普遍的應用,例如Huffman編碼中使用優先隊列利用貪心算法構建最優前綴編碼樹。

  • 堆的另外一個應用就是在海量數據中找到TopK個數,思想是維護一個大小爲K的二叉堆,而後不斷地比較堆頂元素,判斷是否須要執行替換對頂元素的操做,採用
    此方法的時間複雜度爲n*logk,當kn的數量級差距很大的時候,這種方式是頗有效的方法。

6 references

[1] https://en.wikipedia.org/wiki/Heap_(data_structure)

[2] https://en.wikipedia.org/wiki/Heapsort

[3] https://en.wikipedia.org/wiki/Priority_queue

[4] https://www.cnblogs.com/swiftma/p/6006395.html

[5] Thomas H.Cormen, Charles E.Leiserson, Ronald L.Rivest, Clifford Stein.算法導論[M].北京:機械工業出版社,2015:245-249

[6] Jon Bentley.編程珠璣[M].北京:人民郵電出版社,2015:161-174