小男孩‘自慰网亚洲一区二区,亚洲一级在线播放毛片,亚洲中文字幕av每天更新,黄aⅴ永久免费无码,91成人午夜在线精品,色网站免费在线观看,亚洲欧洲wwwww在线观看

分享

拜托,別再問(wèn)我什么是堆了!

 taotao_2016 2020-05-05

前言

堆是生產(chǎn)中非常重要也很實(shí)用的一種數(shù)據(jù)結(jié)構(gòu),也是面試中比如求 Top K 等問(wèn)題的非常熱門的考點(diǎn),本文旨在全面介紹堆的基本操作與其在生產(chǎn)中的主要應(yīng)用,相信大家看了肯定收獲滿滿!
本文將會(huì)從以下幾個(gè)方面來(lái)講述堆:
  • 生產(chǎn)中的常見(jiàn)問(wèn)題
  • 堆的定義
  • 堆的基本操作
  • 堆排序
  • 堆在生產(chǎn)中應(yīng)用

生產(chǎn)中的常見(jiàn)問(wèn)題

我們?cè)谏a(chǎn)中經(jīng)常碰到以下常見(jiàn)的問(wèn)題:
  1. 優(yōu)先級(jí)隊(duì)列的應(yīng)用場(chǎng)景很廣,它是如何實(shí)現(xiàn)的呢
  2. 如何求 Top K 問(wèn)題
  3. TP99 是生產(chǎn)中的一個(gè)非常重要的指標(biāo),如何快速計(jì)算
可能你已經(jīng)猜到了,以上生產(chǎn)上的高頻問(wèn)題都可以用堆來(lái)實(shí)現(xiàn),所以理解堆及掌握其基本操作十分重要!接下來(lái)我們就來(lái)一步步地來(lái)了解堆及其相關(guān)操作,掌握了堆,上面三個(gè)生產(chǎn)上的高頻問(wèn)題將不是問(wèn)題。

堆的定義

堆有以下兩個(gè)特點(diǎn)
  1. 堆是一顆完全二叉樹(shù),這樣實(shí)現(xiàn)的堆也被稱為二叉堆
  2. 堆中節(jié)點(diǎn)的值都大于等于(或小于等于)其子節(jié)點(diǎn)的值,堆中如果節(jié)點(diǎn)的值都大于等于其子節(jié)點(diǎn)的值,我們把它稱為大頂堆,如果都小于等于其子節(jié)點(diǎn)的值,我們將其稱為小頂堆。
簡(jiǎn)單回顧一下什么是完全二叉樹(shù),它的葉子節(jié)點(diǎn)都在最后一層,并且這些葉子節(jié)點(diǎn)都是靠左排序的。
從堆的特點(diǎn)可知,下圖中,1,2 是大頂堆,3 是小頂堆, 4 不是堆(不是完全二叉樹(shù))
從圖中也可以看到,一組數(shù)據(jù)如果表示成大頂堆或小頂堆,可以有不同的表示方式,因?yàn)樗灰蠊?jié)點(diǎn)值大于等于(或小于等于)子節(jié)點(diǎn)值,未規(guī)定左右子節(jié)點(diǎn)的排列方式。
堆的底層是如何表示的呢,從以上堆的介紹中我們知道堆是一顆完全二叉樹(shù),而完全二叉樹(shù)可以用數(shù)組表示
如圖示:給完全二叉樹(shù)按從上到下從左到右編號(hào),則對(duì)于任意一個(gè)節(jié)點(diǎn)來(lái)說(shuō),很容易得知如果它在數(shù)組中的位置為 i,則它的左右子節(jié)點(diǎn)在數(shù)組中的位置為 2i,2i + 1,通過(guò)這種方式可以定位到樹(shù)中的每一個(gè)節(jié)點(diǎn),從而串起整顆樹(shù)。
一般對(duì)于二叉樹(shù)來(lái)說(shuō)每個(gè)節(jié)點(diǎn)是要存儲(chǔ)左右子節(jié)點(diǎn)的指針,而由于完全二叉樹(shù)的特點(diǎn)(葉子節(jié)點(diǎn)都在最后一層,并且這些葉子節(jié)點(diǎn)都是靠左排序的),用數(shù)組來(lái)表示它再合適不過(guò),用數(shù)組來(lái)存儲(chǔ)有啥好處呢,由于不需要存指向左右節(jié)點(diǎn)的指針,在這顆樹(shù)很大的情況下能省下很多空間!

堆的基本操作

堆有兩個(gè)基本的操作,構(gòu)建堆(往堆中插入元素)與刪除堆頂元素,我們分別來(lái)看看這兩個(gè)操作
  • 往堆中插入元素
往堆中插入元素后(如下圖示),我們需要繼續(xù)滿足堆的特性,所以需要不斷調(diào)整元素的位置直到滿足堆的特點(diǎn)為止(堆中節(jié)點(diǎn)的值都大于等于(或小于等于)其子節(jié)點(diǎn)的值),我們把這種調(diào)整元素以讓其滿足堆特點(diǎn)的過(guò)程稱為堆化(heapify)
由于上圖中的堆是個(gè)大頂堆,所以我們需要調(diào)整節(jié)點(diǎn)以讓其符合大頂堆的特點(diǎn)。怎么調(diào)整?不斷比較子節(jié)點(diǎn)與父節(jié)點(diǎn),如果子節(jié)點(diǎn)大于父節(jié)點(diǎn),則交換,不斷重復(fù)此過(guò)程,直到子節(jié)點(diǎn)小于其父節(jié)點(diǎn)。來(lái)看下上圖插入節(jié)點(diǎn) 11 后的堆化過(guò)程
這種調(diào)整方式是先把元素插到堆的最后,然后自下而上不斷比較子節(jié)點(diǎn)與父節(jié)點(diǎn)的值,我們稱之為由下而上的堆化。有了以上示意圖,不難寫(xiě)出插入元素進(jìn)行堆化的代碼:
public class Heap {
    private int[] arr;       // 堆是完全二叉樹(shù),底層用數(shù)組存儲(chǔ)
    private int capacity;    // 堆中能存儲(chǔ)的最大元素?cái)?shù)量
    private int n;          // 當(dāng)前堆中元素?cái)?shù)量

    public Heap(int count) {
        capacity = count;
        arr = new int[capacity+1];
        n = 0;
    }

    public void insert(int value) {
        if (n >= capacity) {
            // 超過(guò)堆大小了,不能再插入元素
            return;
        }
        n++;
        // 先將元素插入到隊(duì)尾中
        arr[n] = value;

        int i = n;
        // 由于我們構(gòu)建的是一個(gè)大頂堆,所以需要不斷調(diào)整以讓其滿足大頂堆的條件
        while (i/2 > 0 && arr[i] > arr[i/2]) {
            swap(arr, i, i/2);
            i = i / 2;
        }
    }
}
時(shí)間復(fù)雜度就是樹(shù)的高度,所以為 O(logn)。
  • 刪除堆頂元素
由于堆的特點(diǎn)(節(jié)點(diǎn)的值都大于等于(或小于等于)其子節(jié)點(diǎn)的值),所以其根節(jié)點(diǎn)(堆項(xiàng))要么是所有節(jié)點(diǎn)中最大,要么是所有節(jié)點(diǎn)中最小的,當(dāng)刪除堆頂元素后,也需要調(diào)整子節(jié)點(diǎn),以讓其滿足堆(大頂堆或小頂堆)的條件。
假設(shè)我們要操作的堆是大頂堆,則刪除堆頂元素后,要找到原堆中第二大的元素以填補(bǔ)堆頂元素,而第二大的元素?zé)o疑是在根節(jié)點(diǎn)的左右子節(jié)點(diǎn)上,假設(shè)是左節(jié)點(diǎn),則用左節(jié)點(diǎn)填補(bǔ)堆頂元素之后,左節(jié)點(diǎn)空了,此時(shí)需要從左節(jié)點(diǎn)的左右節(jié)點(diǎn)中找到兩者的較大值填補(bǔ)左節(jié)點(diǎn)...,不斷迭代此過(guò)程,直到調(diào)整完畢,調(diào)整過(guò)程如下圖示:
但是這么調(diào)整后,問(wèn)題來(lái)了,如上圖所示,在最終調(diào)整后的堆中,出現(xiàn)了數(shù)組空洞,對(duì)應(yīng)的數(shù)組如下
怎么解決?我們可以用最后一個(gè)元素覆蓋堆頂元素,然后再自上而下地調(diào)整堆,讓其滿足大頂堆的要求,這樣即可解決數(shù)組空洞的問(wèn)題。
看了以上示意圖,代碼實(shí)現(xiàn)應(yīng)該比較簡(jiǎn)單,如下:
/**
 * 移除堆頂元素
 */

public void removeTopElement() {
    if (n == 0) {
        // 堆中如果沒(méi)有元素,也就是不存在移除堆頂元素的情況了
        return;
    }
    int count = n;
    arr[1] = arr[count];
    --count;
    heapify(1, count);
}

/**
 * 自上而下堆化以滿足大頂堆的條件
 */

public void heapify(int index, int n) {

    while (true) {
        int maxValueIndex = index;
        if (2 * index <= n && arr[index] < arr[2 * index]) {
            // 左節(jié)點(diǎn)比其父節(jié)點(diǎn)大
            maxValueIndex = 2 * index;
        }

        if (2 * index + 1 <= n && arr[maxValueIndex] < arr[2 * index + 1]) {
            // 右節(jié)點(diǎn)比左節(jié)點(diǎn)或父節(jié)點(diǎn)大
            maxValueIndex = 2 * index + 1;
        }

        if (maxValueIndex == index) {
            // 說(shuō)明當(dāng)前節(jié)點(diǎn)值為最大值,無(wú)需再往下迭代了
            break;
        }
        swap(arr, index, maxValueIndex);
        index = maxValueIndex;
    }
}

/**
 * 交換數(shù)組第 i 和第 j 個(gè)元素
 */

public static void swap(int[] arr, int i, int j)
{
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

時(shí)間復(fù)雜度和插入堆中元素一樣,也是樹(shù)的高度,所以為 O(logn)。

堆排序

用堆怎么實(shí)現(xiàn)排序?我們知道在大頂堆中,根節(jié)點(diǎn)是所有節(jié)點(diǎn)中最大的,于是我們有如下思路:
假設(shè)待排序元素個(gè)數(shù)為 n(假設(shè)其存在數(shù)組中),對(duì)這組數(shù)據(jù)構(gòu)建一個(gè)大頂堆,刪除大頂堆的元素(將其與數(shù)組的最后一個(gè)元素進(jìn)行交換),再對(duì)剩余的 n-1 個(gè)元素構(gòu)建大頂堆,再將堆頂元素刪除(將其與數(shù)組的倒數(shù)第二個(gè)元素交換),再對(duì)剩余的 n-2 個(gè)元素構(gòu)建大頂堆...,不斷重復(fù)此過(guò)程,這樣最終得到的排序一定是從小到大排列的,堆排序過(guò)程如下圖所示:
從以上的步驟中可以看到,重要的步驟就兩步,建堆(堆化,構(gòu)建大頂堆)與排序。
先看下怎么建堆,其實(shí)在上一節(jié)中我們已經(jīng)埋下了伏筆,上一節(jié)我們簡(jiǎn)單介紹了堆的基本操作,插入和刪除,所以我們可以新建一個(gè)數(shù)組,遍歷待排序的元素,每遍歷一個(gè)元素,就調(diào)用上一節(jié)我們定義的 insert(int value) 方法,這個(gè)方法在插入元素到堆的同時(shí)也會(huì)堆化調(diào)整堆為大頂堆,遍歷完元素后,最終生成的堆一定是大頂堆。

用這種方式生成的大頂堆空間復(fù)雜度是多少呢,由于我們新建了一個(gè)數(shù)組,所以空間復(fù)雜度是 O(n),但其實(shí)堆排序是原地排序的(不需要任何額外空間),所以我們重點(diǎn)看下如何在不需要額外空間的情況下生成大頂堆。

其實(shí)思路很簡(jiǎn)單,對(duì)于所有非葉子節(jié)點(diǎn),自上而下不斷調(diào)整使其滿足大頂堆的條件(每個(gè)節(jié)點(diǎn)值都大于等于其左右節(jié)點(diǎn)的值)即可,遍歷到最后得到的堆一定是大頂堆!同時(shí)調(diào)整堆的過(guò)程中只是不斷交換數(shù)組里的元素,沒(méi)有用到額外的存儲(chǔ)空間。
那么非葉子節(jié)點(diǎn)的范圍是多少呢,假設(shè)數(shù)組元素為 n,則數(shù)組下標(biāo)為 1 到 n / 2 的元素是非葉子節(jié)點(diǎn)。下標(biāo) n / 2 + 1 到 n 的元素是葉子節(jié)點(diǎn)。
畫(huà)外音:假設(shè)下標(biāo) n/2+1 的節(jié)點(diǎn)不是葉子節(jié)點(diǎn),則其左子節(jié)點(diǎn)下標(biāo)為 (n/2 + 1) * 2 = n + 2,超過(guò)了數(shù)組元素 n,顯然不符合邏輯,由此可以證明  n / 2 + 1 開(kāi)始的元素一定是葉子節(jié)點(diǎn)
示意圖如下:
如圖示:對(duì)每個(gè)非葉子節(jié)點(diǎn)自上而下調(diào)整后,最終得到大頂堆。
有了以上思路,不難寫(xiě)出如下代碼:
/**
* 對(duì) 1 妻 n/2 的非葉子節(jié)點(diǎn)自上而下進(jìn)行堆化,以構(gòu)建大頂堆
 */

public void buildHeap() {
    for (int i = n/2; i > 0; i--) {
        heapify(i);
    }
}
這樣建堆的時(shí)間復(fù)雜度是多少呢,我們知道對(duì)每個(gè)元素進(jìn)行堆化時(shí)間復(fù)雜度是 O(log n),那么對(duì) 1 到 n/2 個(gè)元素進(jìn)行堆化,則總的時(shí)間復(fù)雜度顯然是 O(n log n)(實(shí)際上如果詳細(xì)推導(dǎo),時(shí)間復(fù)雜度是 O(n),這里不作展開(kāi),有興趣的同學(xué)建議查一下資料看下 O(n) 是怎么來(lái)的)。
知道怎么建堆,接下來(lái)排序就簡(jiǎn)單了,對(duì) n 個(gè)元素來(lái)說(shuō),只要移除堆頂元素(將其與最后一個(gè)元素交換),再對(duì)之前的 n-1 個(gè)元素堆化,再移除堆頂元素(將其與倒數(shù)第二個(gè)元素交換)...,不斷重復(fù)此過(guò)程即可,代碼如下:
/**
 * 堆排序
 */

public void heapsort() {
    // 建堆
    buildHeap();
    int i = n;
    while (true) {
        if (i <= 1) {
            break;
        }

        // 將堆頂元素放到第 i 個(gè)位置
        swap(arr, 1, i);
        i--;
        // 重新對(duì) 1 到 i 的元素進(jìn)行堆化,以讓其符合大頂堆的條件
        heapify(1, i);
    }
}
時(shí)間復(fù)雜度上文已經(jīng)分析過(guò)了,就是 O(n log n),居然和快排一樣快!但堆排序?qū)嶋H在生產(chǎn)中用得并不是很多,Java 默認(rèn)的數(shù)組排序(Arrays.sort())底層也是用的快排,時(shí)間復(fù)雜度和快排一樣快,為啥堆排序卻并不受待見(jiàn)呢。主要有以下兩個(gè)原因
1、 快排在遞歸排序的過(guò)程中,都是拿 pivot 與相鄰的元素比較,會(huì)用到計(jì)算機(jī)中一個(gè)非常重要的定理:局部性原理,啥叫局部性原理,可以簡(jiǎn)單理解為當(dāng) CPU 讀取到某個(gè)數(shù)據(jù)的時(shí)候,它認(rèn)為這個(gè)數(shù)據(jù)附近相鄰的數(shù)據(jù)也有很大的概率會(huì)被用到,所以干脆把被讀取到數(shù)據(jù)的附近的數(shù)據(jù)也一起加載到 Cache 中,這樣下次還需要再讀取數(shù)據(jù)進(jìn)行操作時(shí),就直接從 Cache 里拿數(shù)據(jù)即可(無(wú)需再?gòu)膬?nèi)存里拿了),數(shù)據(jù)量大的話,極大地提升了性能。堆排序無(wú)法利用局部性原理,為啥呢,我們知道在堆化的過(guò)程中,需要不斷比較節(jié)點(diǎn)與其左右子節(jié)點(diǎn)的大小,左右子節(jié)點(diǎn)也需要比較其左右節(jié)點(diǎn)。。。
如圖示:在對(duì)節(jié)點(diǎn) 2 自上而下的堆化中,其要遍歷數(shù)組中 4,5,9,10... 中的元素,這些元素并不是相鄰元素,無(wú)法利用到局部性原理來(lái)提升性能
2、我們知道堆排序的一個(gè)重要步驟是把堆頂元素移除,重新進(jìn)行堆化,每次堆化都會(huì)導(dǎo)致大量的元素比較,這也是堆排序性能較差的一個(gè)原因。
3、堆排序不是穩(wěn)定排序,因?yàn)槲覀冎涝诙鸦_(kāi)始前要先把首位和末位元素進(jìn)行交換,如果這兩元素值一樣,就可能改變他們?cè)瓉?lái)在數(shù)組中的相對(duì)順序,而快排雖然也是不穩(wěn)定排序,不過(guò)可以改進(jìn)成穩(wěn)定排序,這一點(diǎn)也是快排優(yōu)于堆排序的一個(gè)重要的點(diǎn)。

堆在生產(chǎn)中應(yīng)用

堆排序雖然不常用,但堆在生產(chǎn)中的應(yīng)用還是很多的,這里我們?cè)敿?xì)來(lái)看堆在生產(chǎn)中的幾個(gè)重要應(yīng)用
1、 優(yōu)先級(jí)隊(duì)列
我們知道隊(duì)列都是先進(jìn)先出的,而在優(yōu)先級(jí)隊(duì)列中,元素被賦予了權(quán)重的概念,權(quán)重高的元素優(yōu)先執(zhí)行,執(zhí)行完之后下次再執(zhí)行權(quán)重第二高的元素...,顯然用堆來(lái)實(shí)現(xiàn)優(yōu)先級(jí)隊(duì)列再合適不過(guò)了,只要用一個(gè)大頂堆來(lái)實(shí)現(xiàn)優(yōu)先級(jí)隊(duì)列即可,當(dāng)權(quán)重最高的隊(duì)列執(zhí)行完畢,將其移除(相當(dāng)于刪除堆頂),再選出優(yōu)先級(jí)第二高的元素(堆化讓其符合大頂堆 的條件),很方便,實(shí)際上我們查看源碼就知道, Java 中優(yōu)先級(jí)隊(duì)列  PriorityQueue 就是用堆來(lái)實(shí)現(xiàn)的。
2、 求 TopK 問(wèn)題
怎樣求出 n 個(gè)元素中前 K 個(gè)最大/最小的元素呢。假設(shè)我們要求前 K 個(gè)最大的元素,我們可以按如下步驟來(lái)做
  1. 取 n 個(gè)元素的前 K 個(gè)元素構(gòu)建一個(gè)小頂堆
  2. 遍歷第 K + 1 到  n 之間的元素,每一個(gè)元素都與小頂堆的堆頂元素進(jìn)行比較,如果小于堆頂元素,不做任何操作,如果大于堆頂元素,則將堆頂元素替換成當(dāng)前遍歷的元素,再堆化以讓其滿足小頂?shù)囊螅@樣遍歷完成后此小頂堆的所有元素就是我們要求的 TopK。
每個(gè)元素堆化的時(shí)間復(fù)雜度是 O(logK),n 個(gè)元素時(shí)間復(fù)雜度是 O(nlogK),還是相當(dāng)給力的!
3、 TP99 是生產(chǎn)中的一個(gè)非常重要的指標(biāo),如何快速計(jì)算
先來(lái)解釋下什么是 TP99,它指的是在一個(gè)時(shí)間段內(nèi)(如5分鐘),統(tǒng)計(jì)某個(gè)接口(或方法)每次調(diào)用所消耗的時(shí)間,并將這些時(shí)間按從小到大的順序進(jìn)行排序,取第99%的那個(gè)值作為 TP99 值,舉個(gè)例子, 假設(shè)這個(gè)方法在 5 分鐘內(nèi)調(diào)用消耗時(shí)間為從 1 s 到 100 s 共 100 個(gè)數(shù),則其 TP99 為 99,這個(gè)值為啥重要呢,對(duì)于某個(gè)接口來(lái)說(shuō),這個(gè)值越低,代表 99% 的請(qǐng)求都是非??斓模f(shuō)明這個(gè)接口性能很好,反之,就說(shuō)明這個(gè)接口需要改進(jìn),那怎么去求這個(gè)值呢?
思路如下:
  1. 創(chuàng)建一個(gè)大頂堆和一個(gè)小頂堆,大頂堆的堆頂元素比小頂堆的堆頂元素更小,大頂堆維護(hù) 99% 的請(qǐng)求時(shí)間,小頂堆維護(hù) 1% 的請(qǐng)求時(shí)間
  2. 每產(chǎn)生一個(gè)元素(請(qǐng)求時(shí)間),如果它比大頂堆的堆頂元素小,則將其放入到大頂堆中,如果它比小頂堆的堆頂元素大,則將其插入到小頂堆中,插入后當(dāng)然要堆化以讓其符合大小頂堆的要求。
  3. 上一步在插入的過(guò)程中需要注意一下,可能會(huì)導(dǎo)致大頂堆和小頂堆中元素的比例不為 99:1,此時(shí)就要做相應(yīng)的調(diào)整,如果在將元素插入大頂堆之后,發(fā)現(xiàn)比例大于 99:1,將需將大頂堆的堆頂元素移到小頂堆中,再對(duì)兩個(gè)堆堆化以讓其符合大小頂堆的要求,同理,如果發(fā)現(xiàn)比例小于 99: 1,則需要將小頂堆的堆頂元素移到大頂堆來(lái),再對(duì)兩者進(jìn)行堆化。
以上的大小頂堆調(diào)整后,則大頂堆的堆頂元素值就是所要求的 TP99 值。
有人可能會(huì)說(shuō)以上的這些應(yīng)用貌似用快排或其他排序也能實(shí)現(xiàn),沒(méi)錯(cuò),確實(shí)能實(shí)現(xiàn),但是我們需要注意到,在靜態(tài)數(shù)據(jù)下用快排確實(shí)沒(méi)問(wèn)題,但在動(dòng)態(tài)數(shù)據(jù)上,如果每插入/刪除一個(gè)元素對(duì)所有的元素進(jìn)行快排,其實(shí)效率不是很高,由于要快排要全量排序,時(shí)間復(fù)雜度是 O(nlog n),而堆排序就非常適合這種對(duì)于動(dòng)態(tài)數(shù)據(jù)的排序,對(duì)于每個(gè)新添加的動(dòng)態(tài)數(shù)據(jù),將其插入到堆中,然后進(jìn)行堆化,時(shí)間復(fù)雜度只有 O(logK)

總結(jié)

堆是一種非常重要的數(shù)據(jù)結(jié)構(gòu),在對(duì)動(dòng)態(tài)數(shù)據(jù)進(jìn)行排序時(shí)性能很高,優(yōu)先級(jí)隊(duì)列底層也是普遍采用堆來(lái)管理,所以掌握堆的基本操作十分重要。另外我們也知道了 Java 的優(yōu)先級(jí)隊(duì)列(PriorityQueue)也是用堆來(lái)實(shí)現(xiàn)的,所以再次說(shuō)明了掌握基本的數(shù)據(jù)結(jié)構(gòu)非常重要,對(duì)于理解上層應(yīng)用的底層實(shí)現(xiàn)十分有幫助!
最后,歡迎大家關(guān)注公號(hào)哦。之后將會(huì)講解大量算法解題思路,希望我們一起攻克算法難題!

    本站是提供個(gè)人知識(shí)管理的網(wǎng)絡(luò)存儲(chǔ)空間,所有內(nèi)容均由用戶發(fā)布,不代表本站觀點(diǎn)。請(qǐng)注意甄別內(nèi)容中的聯(lián)系方式、誘導(dǎo)購(gòu)買等信息,謹(jǐn)防詐騙。如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請(qǐng)點(diǎn)擊一鍵舉報(bào)。
    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評(píng)論

    發(fā)表

    請(qǐng)遵守用戶 評(píng)論公約

    類似文章 更多