挖掘算法中的數據結構(五):排序算法總結 和 索引堆及優化(堆結構)

前四篇博文介紹的O(n^2)或O(n*logn)排序算法及堆排序結束,意味着有關排序算法已講解完畢,此篇博文將對這些排序算法進行比較總結,並且學習另一個經典的堆結構,處於二叉堆優化之上的索引堆,最後拓展瞭解由堆衍生的一些問題。

此篇涉及的知識點有:

  • 排序算法總結
  • 索引堆及其優化
  • 堆結構衍生的問題

挖掘算法中的數據結構(一):選擇、插入、冒泡、希爾排序 及 O(n^2)排序算法思考
挖掘算法中的數據結構(二):O(n*logn)排序算法之 歸併排序(自頂向下、自底向上) 及 算法優化
挖掘算法中的數據結構(三):O(n*logn)排序算法之 快速排序(隨機化、二路、三路排序) 及衍生算法


一. 排序算法總結

前三篇博文介紹的排序算法及以上講解完的堆排序完成,意味着有關排序算法已講解完畢,下面對這些排序算法進行簡單總結:

這裏寫圖片描述

(1)均時間複雜度

注意,表格中強調的是“平均”時間複雜度,比如說快速排序,待排序數組已經是近乎有序,那麼其時間複雜度會退化到O(n^2),所以使用了隨機算法優化使其概率降低到0。總體而言,快速排序的性能較優,也就是說在O(n*logn)這3種算法而言有常數性的差異,但快速排序較優,所以一般系統級別的排序採用快速排序,而對於含有大量重複元素的數組可採用優化的三路快速排序。

(2)原地排序

插入排序、快速排序和堆排序可以直接在待排序數組上交換元素完成排序過程,而歸併排序無法完成,它必須開闢額外的空間來輔助完成。正因如此,若一個系統對空間使用比較敏感,並不會採用歸併排序。

(3)額外空間

  • 對於插入排序和堆排序而言,使用的額外空間就是數組上交換元素,所以所耗空間爲O(1)級別,即常數級別。
  • 而歸併排序需要O(n)級別空間,即數組同等長度空間來輔助完成歸併過程。
  • 快速排序所需O(logn)額外空間,因爲它採用遞歸方式來進行排序,遞歸有logn層,所以需要O(logn)空間來保證每一層的臨時變量以供遞歸返回時繼續使用。

(4)穩定排序

穩定排序:對於相等的元素,在排序後,原來靠前的元素依然靠前,即相等元素的相對位置沒有發生改變,此算法纔是穩定的。

這裏寫圖片描述

例如上圖數組中有3個相同元素3,在排序後,這分開的3個3肯定會排列在一起,但重點依舊按照原來的“紅綠藍”位置排列,這纔是穩定排序。

例如實際應用中,學生名單按照名字字典序排列,現在需要按照成績重新排列,最後幾個同分的同學之間依然還是按照字典序排列。

  • 穩定排序
    • 插入排序:算法中有後面元素與前面元素相比較,若小於則前移,否則不動。所以相同元素之間位置不會發生改變。
    • 歸併排序:在歸併過程中,左右子數組已經有序,需要歸併到一起,其核心也是判斷當後面元素小於前面元素才前移,否則不動。所以相同元素之間位置不會發生改變。
  • 不穩定排序
    • 快速排序:算法核心中會隨機選擇一個標誌點來進行大於、小於判斷排序,所以很有可能使得後面相等元素到前面來。所以相同元素之間位置會發生改變。
    • 堆排序:將整個數組整理成堆的過程中會破壞掉穩定性。所以相同元素之間位置會發生改變。



二. 索引堆(Index Heap)及優化

下面依然將重點放到“堆”這個數據結構,以下將介紹一個相比普通的堆更加高級的數據結構——索引堆。

1. 引出問題

首先來分析一下普通的堆有什麼問題,纔會進而衍生出索引堆:

這裏寫圖片描述

重點查看以上舉例證明一個數組實現堆後元素的變換,可是在構建堆的過程中有侷限性:

  • 如果元素是非常複雜的結構,例如字符串(一篇十萬字的文章)等等,這樣交換的代價是十分大的。不過這可以通過一些基本手段解決,
  • 更加致命的是元素在數組中的位置發生改變,使得在堆中很難索引到它!例如元素下標是任務ID,元素值是優先級別。當將數組構建成堆後,下標發生改變,則意味着兩者無法產生聯繫!在原來數組中尋找任務只需O(1),但是構建成堆後元素位置發生改變後需要遍歷數組!所以纔會引入“索引堆”這個概念。

2. 結構思想

  • 當將此數組構建成堆之前:對於索引堆來說將數據和索引兩部分內容分開存儲,而真正表示堆的數組是由索引構建成的,如下圖,每一個節點旁標記的是索引1,2,3……

  • 當將此數組構建成堆之後:****data部分並未發生改變,真正改變的是索引index,index數組發生改變形成堆。index爲10,即真正元素值去找索引10代表的data值62,這樣去對應。

這裏寫圖片描述

使用“索引堆”有以下兩個好處:

  • 將數組構建成堆之後,只是索引index值發生改變,int型數字之間的交換而不會涉及到data數據類型,提供交換效率。
  • 重要的一點,如果想對堆中數據進行操作,例如對index爲7的數據進行修改,找到對應數據值爲28更改,在修改之後需要繼續維護“堆”的性質,這時只需對data數組進行維護即可。

其實“索引堆”和之前堆的思路類似,只是在做元素值比較時是比較data數組,而做元素交換時修改的是索引值。


3. 代碼實現

(1)基本更改

在原先MaxHeap基礎上修改即可,首先改名爲IndexMaxHeap(完整代碼請查看github上源碼,這裏只提供重點部分):

  • 在成員變量中需要添加一個數組來存儲索引值
  • 在構造函數中需要開闢索引數組空間
  • 在析構桉樹中也要相信釋放掉該空間

(2)插入和刪除函數

重點需要修改插入和刪除函數:

  • 插入函數:在調用插入函數時不僅要傳數據還要傳遞索引值。注意:這裏獲取data數組的索引值是根據index數組來確定的!在shiftUp函數中交換的是index索引值,並非是data數組。
    // 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
    void shiftUp( int k ){
        while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){
            swap( indexes[k/2] , indexes[k] );
            k /= 2;
        }
    }

    // 向最大索引堆中插入一個新的元素, 新元素的索引爲i, 元素爲item
    // 傳入的i對用戶而言,是從0索引的
    void insert(int i, Item item){
        assert( count + 1 <= capacity );
        assert( i + 1 >= 1 && i + 1 <= capacity );

        i += 1;
        data[i] = item;
        indexes[count+1] = i;
        count++;

        shiftUp(count);
    }
  • 刪除函數:
    // 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
    void shiftDown( int k ){

        while( 2*k <= count ){
            int j = 2*k;
            if( j + 1 <= count && data[indexes[j+1]] > data[indexes[j]] )
                j += 1;

            if( data[indexes[k]] >= data[indexes[j]] )
                break;

            swap( indexes[k] , indexes[j] );
            k = j;
        }
    }

    // 從最大索引堆中取出堆頂元素, 即索引堆中所存儲的最大數據
    Item extractMax(){
        assert( count > 0 );

        //注意:這裏獲取data數組的索引值是根據index數組來確定的
        Item ret = data[indexes[1]];
        swap( indexes[1] , indexes[count] );
        count--;
        shiftDown(1);
        return ret;
    }

(3)更改函數

在第一點“引出問題”中提到一個問題,就是二叉堆中的某個節點的元素值可能會被改變,(實際應用中:OS中任務的優先級會動態改變)所以提供一個方法僅供修改元素值。

注意:修改元素值後必然還要維護索引堆的特性,所以該元素的值位置可能會有所改變,具體操作也簡單,只需分別調用shiftUpshiftDown方法即可找到合適位置。

   // 將最大索引堆中索引爲i的元素修改爲newItem
    void change( int i , Item newItem ){

        i += 1;
        data[i] = newItem;

        // 找到indexes[j] = i, j表示data[i]在堆中的位置
        // 之後shiftUp(j), 再shiftDown(j)
        for( int j = 1 ; j <= count ; j ++ )
            if( indexes[j] == i ){
                shiftUp(j);
                shiftDown(j);
                return;
            }
    }

4. 反向查找優化 —— 更改元素值

(1)引出問題

這裏寫圖片描述

舉個例子,如上圖,若用戶要更改下標4所指向元素的數據,將此數據更愛以後需要維護index數組,此數組本質上是一個堆,其中存儲的元素對應着上一層索引。

所以需要做的是在index數組中找到4的位置,在下標9指向的位置,上一點的實現方法是順序遍歷查找下標9的位置,4指向的data是13,然後調用shiftUpshiftDown方法維護二叉堆特徵,這樣過程的時間複雜度爲O(n)級別。

(2)思想

其實對於以上更改元素值思想還有可以優化的地方,此種思想非常經典,被稱爲“反向查找”,查看下圖:

這裏寫圖片描述

可以看到,多了一行數組rev,rev[i]代表i這個索引在堆中的位置。舉個例子,將下標4的data13修改了,接着需要維護索引4在堆中的位置,即維護index數組,怎麼找到下標4在堆中的位置?

查看rev數組,rev數組中對應的是9,所以在index數組中第9個位置存儲的索引4。

rev數組相關性質

這樣一來只需維護rev數組,在進行元素更新時所耗時間複雜度爲O(1),來了解rev數組相關性質:

這裏寫圖片描述

(3)代碼實現

如此一來引入了rev數組,就需要在insertshiftUpextractMaxshiftDown函數中進行維護,代碼如下:

(具體代碼見github源碼,以下只粘貼重點部分)

    int *reverse;   // 最大索引堆中的反向索引, reverse[i] = x 表示索引i在x的位置

    // 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
    void shiftUp( int k ){

        while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){
            swap( indexes[k/2] , indexes[k] );
            reverse[indexes[k/2]] = k/2;
            reverse[indexes[k]] = k;
            k /= 2;
        }
    }

    // 索引堆中, 數據之間的比較根據data的大小進行比較, 但實際操作的是索引
    void shiftDown( int k ){

        while( 2*k <= count ){
            int j = 2*k;
            if( j + 1 <= count && data[indexes[j+1]] > data[indexes[j]] )
                j += 1;

            if( data[indexes[k]] >= data[indexes[j]] )
                break;

            swap( indexes[k] , indexes[j] );
            reverse[indexes[k]] = k;
            reverse[indexes[j]] = j;
            k = j;
        }
    }

   // 向最大索引堆中插入一個新的元素, 新元素的索引爲i, 元素爲item
    // 傳入的i對用戶而言,是從0索引的
    void insert(int i, Item item){
        assert( count + 1 <= capacity );
        assert( i + 1 >= 1 && i + 1 <= capacity );

        // 再插入一個新元素前,還需要保證索引i所在的位置是沒有元素的。
        assert( !contain(i) );

        i += 1;
        data[i] = item;
        indexes[count+1] = i;
        reverse[i] = count+1;
        count++;

        shiftUp(count);
    }

    // 從最大索引堆中取出堆頂元素, 即索引堆中所存儲的最大數據
    Item extractMax(){
        assert( count > 0 );

        Item ret = data[indexes[1]];
        swap( indexes[1] , indexes[count] );
        reverse[indexes[count]] = 0;
        reverse[indexes[1]] = 1;
        count--;
        shiftDown(1);
        return ret;
    }

    // 將最大索引堆中索引爲i的元素修改爲newItem
    void change( int i , Item newItem ){

        assert( contain(i) );
        i += 1;
        data[i] = newItem;
        // 有了 reverse 之後,
        // 我們可以非常簡單的通過reverse直接定位索引i在indexes中的位置
        shiftUp( reverse[i] );
        shiftDown( reverse[i] );
    }



五. 堆衍生的問題

1. 使用堆實現優先隊列

(1)OS系統執行任務

可以使用堆來作爲優先隊列,對於OS而言,每次使用堆可找到優先級最高的任務執行,就算此時有新的任務添加進行,插入堆中即可,動態修改任務的優先級也可滿足。實現一個堆後,以上需求易簡單。

這裏寫圖片描述

(2)在N個元素中選出前M個元素

例如在1,000,000個元素中選出前100名,也就是“在N個元素中選出前M個元素”。

按照之前學習的一些排序算法,性能最優可達到O(n*logn )但是使用了優先隊列,可將時間複雜度從O(n*logn )降低爲O(n *logM)!(若N是百萬級別數字,其實這優化的不少)使用一個最小堆,使長度維護在100,將前100個元素放入最小堆之後再插入新的元素,此時只會將堆中最小元素移出去,堆的長度不變,將這1,000,000個元素遍歷完後,最小堆中存放的100個元素就是前100名,因爲比其小的元素全都請了出去。


2. 多路歸併排序

可以使用堆來完成多路歸併排序,首先思考歸併排序思想,是將數組一分爲二,兩個子數組分別排序後進行歸併,每次歸併的過程只有兩個子數組元素比較,如下圖:

這裏寫圖片描述

其實在歸併排序過程中可以一次分成多個(大於2,這裏是4)子數組,再進行歸併,每次比較4個元素的大小關係,理所當然想到逐個元素比較,但是可以將這4個元素放入堆中,再逐個取出來,取出來的元素屬於哪個子數組,再添加這個子數組的下一個元素進入堆中,來維護這個堆。


3. d叉堆

此部分主要講解的是二叉堆,即每個節點最多有兩個孩子,其實還有一種思想——d叉堆,下圖是三叉堆,依然滿足堆的特徵。其中d越大,層數越少,同樣在Shift Up、Shift Down時比較的次數增多,所以對於這個d的數量,也是性能之間的衡量。(二叉堆是最經典的)

這裏寫圖片描述


4. 堆的實現細節優化

這裏這位讀者提供對細節優化的思路,切身去體會算法的“優化”,堆的實現細節優化還有:

  • ShiftUp 和 ShiftDown 中使用賦值操作替換swap操作
  • 表示堆的數組從0開始索引
  • 沒有capacity的限制,動態的調整堆中數組的大小

5. 其它

此篇博文主要講解的是最大堆 和 最大索引堆,與之相對應的還有最小堆、 最小索引堆,可自行查看源碼實現。這其實也對應着最大、最小優先隊列,這樣的優先隊列可輕易找到隊列中最大或最小元素,那麼是否可以設計一個“最大最小類”,能同時找到最大和最小數據?

這裏僅提供思路,其實可以在這個類中同時放一個最大堆和最小堆,兩者維護同一組數據。

最後此篇文章重點講解二叉堆和索引堆,但其實有關於堆還衍生出了二項堆和斐波那契堆,有興趣可自行研究。



所有以上解決算法詳細代碼請查看liuyubo老師的github:
https://github.com/liuyubobobo/Play-with-Algorithms


以上就是有關於堆的所有內容,堆其實就是一棵“數”,包括之前學習的有關O(n*logn)排序算法,雖然是在數組上進行排序,但是通過其算法使用的遞歸可畫出“樹”結構,可見“樹”的重要性,下一篇將開始講解

若有錯誤,虛心指教~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章