一道 Top K 面試題引發的思考

引言

堆是前端進階必不可少的知識,也是面試的重難點,例如內存堆與垃圾回收、Top K 問題等,這篇文章將從基礎開始梳理整個堆體系,按以下步驟來講:

  • 什麼是堆

  • 怎樣建堆

  • 堆排序

  • 內存堆與垃圾回收

  • Top K 問題

  • 中位數問題

  • 最後來一道leetcode題目,加深理解

下面開始吧????

一、堆

滿足下面兩個條件的就是堆:

  • 堆是一個完全二叉樹

  • 堆上的任意節點值都必須大於等於(大頂堆)或小於等於(小頂堆)其左右子節點值

如果堆上的任意節點都大於等於子節點值,則稱爲 大頂堆

如果堆上的任意節點都小於等於子節點值,則稱爲 小頂堆

也就是說,在大頂堆中,根節點是堆中最大的元素;

在小頂堆中,根節點是堆中最小的元素;

上圖我們可以看出:堆其實可以用一個數組表示,給定一個節點的下標 i ,那麼它的父節點一定爲 A[i/2] ,左子節點爲 A[2i] ,右子節點爲 A[2i+1]

二、怎樣創建一個大(小)頂堆

我們在上一節說過,完全二叉樹適用於數組存儲法(前端進階算法7:小白都可以看懂的樹與二叉樹),而堆又是一個完全二叉樹,所以它可以直接使用數組存儲法存儲:

function Heap() {
    let items = [,]
}

那麼怎樣去創建一個大頂堆(小頂堆)喃?

常用的方式有兩種:

  • 插入式創建:每次插入一個節點,實現一個大頂堆(或小頂堆)

  • 原地創建:又稱堆化,給定一組節點,實現一個大頂堆(或小頂堆)

三、插入式建堆

插入節點:

  • 將節點插入到隊尾

  • 自下往上堆化: 將插入節點與其父節點比較,如果插入節點大於父節點(大頂堆)或插入節點小於父節點(小頂堆),則插入節點與父節點調整位置

  • 一直重複上一步,直到不需要交換或交換到根節點,此時插入完成。

代碼實現:

function insert(key) {
    items.push(key)
    // 獲取存儲位置
    let i = items.length-1 
    while (i/2 > 0 && items[i] > items[i/2]) {  
        swap(items, i, i/2); // 交換 
        i = i/2; 
    }
}  
function swap(items, i, j) {
    let temp = items[i]
    items[i] = items[j]
    items[j] = temp
}

時間複雜度: O(logn),爲樹的高度

四、原地建堆(堆化)

假設一組序列:

let arr = [,1, 9, 2, 8, 3, 7, 4, 6, 5]

原地建堆的方法有兩種:一種是承襲上面插入的思想,即從前往後、自下而上式堆化建堆;與之對應的另一種是,從後往前、自上往下式堆化建堆。其中

  • 自下而上式堆化 :將節點與其父節點比較,如果節點大於父節點(大頂堆)或節點小於父節點(小頂堆),則節點與父節點調整位置

  • 自上往下式堆化 :將節點與其左右子節點比較,如果存在左右子節點大於該節點(大頂堆)或小於該節點(小頂堆),則將子節點的最大值(大頂堆)或最小值(小頂堆)與之交換

所以,自下而上式堆是調整節點與父節點(往上走),自上往下式堆化是調整節點與其左右子節點(往下走)。

1. 從前往後、自下而上式堆化建堆

這裏以小頂堆爲例,

代碼實現:

// 初始有效序列長度爲 1,上圖中用 k 表示
var heapSize = 1
// 原地建堆
function buildHeap(items) {
    while(heapSize < items.length - 1) {
        heapSize ++
        heapify(items, heapSize)
    }
}

function heapify(items, i) {
    // 自下而上式堆化
    while (Math.floor(i/2) > 0 && items[i] < items[Math.floor(i/2)]) {  
        swap(items, i, Math.floor(i/2)); // 交換 
        i = Math.floor(i/2); 
    }
}  

function swap(items, i, j) {
    let temp = items[i]
    items[i] = items[j]
    items[j] = temp
}

// 測試
var items = [,5, 2, 3, 4, 1]
buildHeap(items)
console.log(items)
// [empty, 1, 2, 3, 5, 4]

測試成功

2. 從後往前、自上而下式堆化建堆

這裏以小頂堆爲例

注意:從後往前並不是從序列的最後一個元素開始,而是從最後一個非葉子節點開始,這是因爲,葉子節點沒有子節點,不需要自上而下式堆化。

最後一個子節點的父節點爲 n/2 ,所以從 n/2 位置節點開始堆化:

代碼實現

// 原地建堆
// items: 原始序列
// heapSize: 有效序列長度,上圖用 k 表示
function buildHeap(items, heapSize) {
    // 從最後一個非葉子節點開始,自上而下式堆化
    for (let i = Math.floor(heapSize/2); i >= 1; --i) {    
        heapify(items, heapSize, i);  
    }
}
function heapify(items, heapSize, i) {
    // 自上而下式堆化
    while (true) {
        var maxIndex = i;
        if(2*i <= heapSize && items[i] > items[i*2] ) {
            maxIndex = i*2;
        }
        if(2*i+1 <= heapSize && items[maxIndex] > items[i*2+1] ) {
            maxIndex = i*2+1;
        }
        if (maxIndex === i) break;
        swap(items, i, maxIndex); // 交換 
        i = maxIndex; 
    }
}  
function swap(items, i, j) {
    let temp = items[i]
    items[i] = items[j]
    items[j] = temp
}

// 測試
var items = [,5, 2, 3, 4, 1]
// 因爲 items[0] 不存儲數據
// 所以:heapSize = items.length - 1
buildHeap(items, items.length - 1)
console.log(items)
// [empty, 1, 2, 3, 4, 5]

測試成功

五、排序算法:堆排序

1. 原理

堆是一棵完全二叉樹,它可以使用數組存儲,並且大頂堆的最大值存儲在根節點(i=1),所以我們可以每次取大頂堆的根結點與堆的最後一個節點交換,此時最大值放入了有效序列的最後一位,並且有效序列減1,有效堆依然保持完全二叉樹的結構,然後堆化,成爲新的大頂堆,重複此操作,知道有效堆的長度爲 0,排序完成。

完整步驟爲:

  • 將原序列(n個)轉化成一個大頂堆

  • 設置堆的有效序列長度爲 n

  • 將堆頂元素(第一個有效序列)與最後一個子元素(最後一個有效序列)交換,並有效序列長度減1

  • 堆化有效序列,使有效序列重新稱爲一個大頂堆

  • 重複以上2步,直到有效序列的長度爲 1,排序完成

2. 動圖演示

3. 代碼實現

function heapSort(items) {
    // 構建大頂堆
    buildHeap(items, items.length-1)
    // 設置堆的初始有效序列長度爲 items.length - 1
    let heapSize = items.length - 1
    for (var i = items.length - 1; i > 1; i--) {
        // 交換堆頂元素與最後一個有效子元素
        swap(items, 1, i);
        // 有效序列長度減 1
        heapSize --;
        // 堆化有效序列(有效序列長度爲 currentHeapSize,拋除了最後一個元素)
        heapify(items, heapSize, 1);
    }
    return items;
}

// 原地建堆
// items: 原始序列
// heapSize: 有效序列長度
function buildHeap(items, heapSize) {
    // 從最後一個非葉子節點開始,自上而下式堆化
    for (let i = Math.floor(heapSize/2); i >= 1; --i) {    
        heapify(items, heapSize, i);  
    }
}
function heapify(items, heapSize, i) {
    // 自上而下式堆化
    while (true) {
        var maxIndex = i;
        if(2*i <= heapSize && items[i] < items[i*2] ) {
            maxIndex = i*2;
        }
        if(2*i+1 <= heapSize && items[maxIndex] < items[i*2+1] ) {
            maxIndex = i*2+1;
        }
        if (maxIndex === i) break;
        swap(items, i, maxIndex); // 交換 
        i = maxIndex; 
    }
}  
function swap(items, i, j) {
    let temp = items[i]
    items[i] = items[j]
    items[j] = temp
}

// 測試
var items = [,1, 9, 2, 8, 3, 7, 4, 6, 5]
heapSort(items)
// [empty, 1, 2, 3, 4, 5, 6, 7, 8, 9]

測試成功

4. 複雜度分析

時間複雜度: 建堆過程的時間複雜度是 O(n) ,排序過程的時間複雜度是 O(nlogn) ,整體時間複雜度是 O(nlogn)

空間複雜度: O(1)

六、內存堆與垃圾回收

前端面試高頻考察點,瓶子君已經在 棧 章節中介紹過,點擊前往前端進階算法5:吊打面試官之數據結構棧(+leetcode刷題)

七、堆的經典應用:Top K 問題(常見於騰訊、字節等面試中)

什麼是 Top K 問題?簡單來說就是在一組數據裏面找到頻率出現最高的前 K 個數,或前 K 大(當然也可以是前 K 小)的數。

這種問題我們該怎麼處理喃?我們以從數組中取前 K 大的數據爲例,可以按以下步驟來:

  • 從數組中取前 K 個數,構造一個小頂堆

  • K+1 位開始遍歷數組,每一個數據都和小頂堆的堆頂元素進行比較,如果小於堆頂元素,則不做任何處理,繼續遍歷下一元素;如果大於堆頂元素,則將這個元素替換掉堆頂元素,然後再堆化成一個小頂堆。

  • 遍歷完成後,堆中的數據就是前 K 大的數據

遍歷數組需要 O(N) 的時間複雜度,一次堆化需要 O(logK) 時間複雜度,所以利用堆求 Top K 問題的時間複雜度爲 O(NlogK)。

利用堆求 Top K 問題的優勢

也許很多人會認爲,這種求 Top K 問題可以使用排序呀,沒必要使用堆呀

其實是可以使用排序來做的,將數組進行排序(可以是最簡單的快排),去前 K 個數就可以了,so easy

但當我們需要在一個動態數組中求 Top K 元素怎麼辦喃,動態數組可能會插入或刪除元素,難道我們每次求 Top K 問題的時候都需要對數組進行重新排序嗎?那每次的時間複雜度都爲 O(NlogN)

這裏就可以使用堆,我們可以維護一個 K 大小的小頂堆,當有數據被添加到數組中時,就將它與堆頂元素比較,如果比堆頂元素大,則將這個元素替換掉堆頂元素,然後再堆化成一個小頂堆;如果比堆頂元素小,則不做處理。這樣,每次求 Top K 問題的時間複雜度僅爲 O(logK)

八、堆的經典應用:中位數問題

除了 Top K 問題,堆還有一個經典的應用場景就是求中位數問題

中位數,就是處於中間的那個數:

[1, 2, 3, 4, 5]    的中位數是 3

[1, 2, 3, 4, 5, 6]   的中位數是 3, 4

即:

當 n % 2 !== 0 時,中位數爲:arr[(n-1)/2]

當 n % 2 === 0 時,中位數爲:arr[n/2],  arr[n/2 + 1]

如何利用堆來求解中位數問題喃?

這裏需要維護兩個堆:

  • 大頂堆:用來存取前 n/2 個小元素,如果 n 爲奇數,則用來存取前 Math.floor(n/2) + 1 個元素

  • 小頂堆:用來存取後 n/2 個小元素

那麼,中位數就爲:

  • n 爲奇數:中位數是大頂堆的堆頂元素

  • n 爲偶數:中位數是大頂堆的堆頂元素與小頂堆的堆頂元素

當數組爲動態數組時,每當數組中插入一個元素時,都需要如何調整堆喃?

如果插入元素比大頂堆的堆頂要大,則將該元素插入到小頂堆中;如果要小,則插入到大頂堆中。

當出入完後後,如果大頂堆、小頂堆中元素的個數不滿足我們已上的要求,我們就需要不斷的將大頂堆的堆頂元素或小頂堆的堆頂元素移動到另一個堆中,知道滿足要求

由於插入元素到堆、移動堆頂元素都需要堆化,所以,插入的時間複雜度爲 O(logN) ,每次插入完成後求中位數僅僅需要返回堆頂元素即可,時間複雜度爲 O(1)

中位數的變形:TP 99 問題

TP 99 問題:指在一個時間段內(如5分鐘),統計某個方法(或接口)每次調用所消耗的時間,並將這些時間按從小到大的順序進行排序,取第 99% 的那個值作爲 TP99 值;

例如某個接口在 5 分鐘內被調用了100次,每次耗時從 1ms 到 100ms之間不等數據,將請求耗時從小到大排列,TP99 就是取第 100*0.99 = 99 次請求耗時 ,類似地 TP50、TP90,TP99越小,說明這個接口的性能越好

所以,針對 TP99 問題,我們同樣也可以維護兩個堆,一個大頂堆,一個小頂堆。大頂堆中保存前 99% 個數據,小頂堆中保存後 1% 個數據。大頂堆堆頂的數據就是我們要找的 99% 響應時間。

本小節參考極客時間的:數據結構與算法之美

九、總結

堆是一個完全二叉樹,並且堆上的任意節點值都必須大於等於(大頂堆)或小於等於(小頂堆)其左右子節點值,推可以採用數組存儲法存儲,可以通過插入式建堆或原地建堆,堆的重要應用有:

  • 堆排序

  • Top K 問題:堆化,取前 K 個元素

  • 中位數問題:維護兩個堆,一大(前50%)一小(後50%),奇數元素取大頂堆的堆頂,偶數取取大、小頂堆的堆頂

JavaScript 的存儲機制分爲代碼空間、棧空間以及堆空間,代碼空間用於存放可執行代碼,棧空間用於存放基本類型數據和引用類型地址,堆空間用於存放引用類型數據,當調用棧中執行完成一個執行上下文時,需要進行垃圾回收該上下文以及相關數據空間,存放在棧空間上的數據通過 ESP 指針來回收,存放在堆空間的數據通過副垃圾回收器(新生代)與主垃圾回收器(老生代)來回收。詳情可查看前端進階算法5:吊打面試官之數據結構棧(+leetcode刷題)

十、leetcode刷題:最小的k個數

話不多說,來一道題目加深一下理解吧:

輸入整數數組 arr ,找出其中最小的 k 個數。例如,輸入4、5、1、6、2、7、3、8這8個數字,則最小的4個數字是1、2、3、4。

示例 1:

輸入:arr = [3,2,1], k = 2
輸出:[1,2] 或者 [2,1]

示例 2:

輸入:arr = [0,1,2,1], k = 1
輸出:[0]

限制:

  • 0 <= k <= arr.length <= 10000

  • 0 <= arr[i] <= 10000

題目詳情已提交到 https://github.com/sisterAn/JavaScript-Algorithms/issues/59 ,歡迎解答,歡迎star

感謝閱讀❤️

歡迎關注「前端瓶子君」,回覆「交流」加入前端交流羣!

歡迎關注「前端瓶子君」,回覆「算法」自動加入,從0到1構建完整的數據結構與算法體系!

在這裏,瓶子君不僅介紹算法,還將算法與前端各個領域進行結合,包括瀏覽器、HTTP、V8、React、Vue源碼等。

在這裏,你可以每天學習一道大廠算法題(阿里、騰訊、百度、字節等等)或 leetcode,瓶子君都會在第二天解答喲!

》》面試官也在看的算法資料《《

“在看和轉發”就是最大的支持

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