現(xiàn)與復(fù)雜度分析)
1. 項(xiàng)目概述為什么堆排序值得你投入時(shí)間如果你剛開始接觸算法或者已經(jīng)刷過一些排序題對冒泡、選擇、插入排序感到“也就那樣”但一遇到數(shù)據(jù)量稍大就力不從心那么堆排序就是你算法能力進(jìn)階路上必須攻克的一個(gè)堡壘。它不像快速排序那樣依賴運(yùn)氣也不像歸并排序那樣需要額外的內(nèi)存空間堆排序以其穩(wěn)定且優(yōu)秀的O(n log n)時(shí)間復(fù)雜度在理論研究和實(shí)際工程中比如優(yōu)先級隊(duì)列、Top K問題都占據(jù)著核心地位。很多人覺得堆排序概念抽象實(shí)現(xiàn)起來繞但我想說一旦你理解了“堆”這種數(shù)據(jù)結(jié)構(gòu)的本質(zhì)并親手用Python實(shí)現(xiàn)一遍你會(huì)發(fā)現(xiàn)它的邏輯異常優(yōu)美和強(qiáng)大。今天我們就來徹底拆解堆排序從零開始用一個(gè)“0基礎(chǔ)強(qiáng)化版”的視角不僅讓你看懂更要讓你能寫出來、用起來。2. 堆排序核心思想與數(shù)據(jù)結(jié)構(gòu)基礎(chǔ)2.1 什么是“堆”從完全二叉樹到數(shù)組映射堆排序的核心是“堆”Heap但這并不是我們編程中說的那個(gè)內(nèi)存堆。它是一種特殊的完全二叉樹并且滿足堆屬性對于最大堆每個(gè)節(jié)點(diǎn)的值都大于或等于其子節(jié)點(diǎn)的值對于最小堆每個(gè)節(jié)點(diǎn)的值都小于或等于其子節(jié)點(diǎn)的值。堆排序通常使用最大堆。為什么用完全二叉樹因?yàn)樗慕Y(jié)構(gòu)非常規(guī)整可以用一個(gè)數(shù)組來完美表示從而避免指針操作效率極高。這個(gè)映射關(guān)系是理解堆操作的關(guān)鍵給定一個(gè)節(jié)點(diǎn)在數(shù)組中的索引i通常從0開始它的父節(jié)點(diǎn)索引是(i - 1) // 2。它的左子節(jié)點(diǎn)索引是2 * i 1。它的右子節(jié)點(diǎn)索引是2 * i 2。例如數(shù)組[50, 30, 20, 15, 10, 8, 16]可以表示成以下的最大堆50 / \ 30 20 / \ / \ 15 10 8 16你可以驗(yàn)證每個(gè)父節(jié)點(diǎn)都大于其子節(jié)點(diǎn)并且數(shù)組索引的映射關(guān)系成立。注意這里的“完全二叉樹”意味著除了最后一層其他層都是滿的并且最后一層的節(jié)點(diǎn)都盡可能靠左排列。正是這個(gè)性質(zhì)保證了數(shù)組表示的緊湊性和無空洞。2.2 堆排序的兩大階段建堆與排序堆排序的整個(gè)過程可以清晰地分為兩個(gè)階段理解了這兩個(gè)階段整個(gè)算法就清晰了建堆Heapify將一個(gè)無序的數(shù)組通過一系列調(diào)整使其滿足堆的性質(zhì)這里我們建最大堆。這個(gè)過程是自底向上進(jìn)行的。排序基于最大堆的性質(zhì)堆頂元素即數(shù)組第一個(gè)元素就是當(dāng)前最大值。我們將堆頂元素與堆的最后一個(gè)元素交換這樣最大值就放到了數(shù)組末尾的正確位置。然后我們將堆的大小減1排除已排序的末尾元素并對新的堆頂元素進(jìn)行“下沉”操作以恢復(fù)最大堆的性質(zhì)。重復(fù)這個(gè)過程直到堆中只剩下一個(gè)元素。簡單來說就是不斷地從堆頂取出最大值放到序列末尾并重新調(diào)整堆。這個(gè)思路和選擇排序不斷選擇剩余元素中的最大值類似但堆結(jié)構(gòu)讓我們能在O(log n)的時(shí)間內(nèi)找到最大值而不是O(n)從而將整體復(fù)雜度從O(n2)提升到O(n log n)。3. 核心操作詳解下沉與建堆3.1 “下沉”操作維護(hù)堆性質(zhì)的關(guān)鍵“下沉”Sink, 或稱為 Heapify-down是堆操作中最核心的子過程。它的作用是當(dāng)一個(gè)節(jié)點(diǎn)的值可能小于其某個(gè)子節(jié)點(diǎn)時(shí)對于最大堆將它向下移動(dòng)直到它大于等于其子節(jié)點(diǎn)或者成為葉子節(jié)點(diǎn)從而重新滿足堆性質(zhì)。操作步驟針對索引為i的節(jié)點(diǎn)計(jì)算節(jié)點(diǎn)i的左孩子left和右孩子right的索引。找出節(jié)點(diǎn)i、left、right三者中值最大的那個(gè)索引記為largest。如果largest不等于i說明子節(jié)點(diǎn)中有比當(dāng)前節(jié)點(diǎn)更大的值違反了最大堆性質(zhì)。那么交換array[i]和array[largest]的值。此時(shí)原來在largest位置的節(jié)點(diǎn)現(xiàn)在是較小的值可能又破壞了堆性質(zhì)。所以我們需要以largest為新的起點(diǎn)遞歸地或迭代地繼續(xù)執(zhí)行“下沉”操作。Python實(shí)現(xiàn)示例def heapify_down(arr, n, i): 在大小為n的堆a(bǔ)rr中對索引i位置的元素進(jìn)行下沉操作。 largest i # 初始化最大元素為當(dāng)前節(jié)點(diǎn) left 2 * i 1 right 2 * i 2 # 如果左子節(jié)點(diǎn)存在且大于當(dāng)前最大節(jié)點(diǎn) if left n and arr[left] arr[largest]: largest left # 如果右子節(jié)點(diǎn)存在且大于當(dāng)前最大節(jié)點(diǎn) if right n and arr[right] arr[largest]: largest right # 如果最大元素不是當(dāng)前節(jié)點(diǎn)則交換并繼續(xù)下沉 if largest ! i: arr[i], arr[largest] arr[largest], arr[i] # 遞歸地對交換后的子節(jié)點(diǎn)進(jìn)行下沉 heapify_down(arr, n, largest)這是一個(gè)遞歸版本清晰易懂。你也可以用循環(huán)來實(shí)現(xiàn)迭代版本效率稍高且避免遞歸深度問題。3.2 “建堆”從無序數(shù)組到最大堆有了“下沉”操作建堆就很簡單了。我們不需要從葉子節(jié)點(diǎn)開始因?yàn)槿~子節(jié)點(diǎn)本身可以看作只有一個(gè)元素的堆已經(jīng)滿足堆性質(zhì)。我們只需要從最后一個(gè)非葉子節(jié)點(diǎn)開始向前遍歷對每個(gè)節(jié)點(diǎn)依次執(zhí)行“下沉”操作即可。最后一個(gè)非葉子節(jié)點(diǎn)的索引是n // 2 - 1n是數(shù)組長度。你可以這樣理解最后一個(gè)節(jié)點(diǎn)的父節(jié)點(diǎn)就是最后一個(gè)非葉子節(jié)點(diǎn)。建堆過程Python實(shí)現(xiàn)def build_max_heap(arr): n len(arr) # 從最后一個(gè)非葉子節(jié)點(diǎn)開始向前遍歷到根節(jié)點(diǎn) for i in range(n // 2 - 1, -1, -1): heapify_down(arr, n, i)這個(gè)循環(huán)的時(shí)間復(fù)雜度是O(n)這是一個(gè)非常有趣且重要的結(jié)論看似每個(gè)節(jié)點(diǎn)下沉O(log n)但經(jīng)過攤還分析整體是O(n)。這意味著將無序數(shù)組初始化為堆的成本是線性的非常高效。實(shí)操心得很多初學(xué)者會(huì)試圖從根節(jié)點(diǎn)開始“上浮”來建堆這雖然也能建成但時(shí)間復(fù)雜度是O(n log n)。記住這個(gè)“自底向上、從后向前下沉”的方法是標(biāo)準(zhǔn)且最優(yōu)的建堆方式。4. 堆排序的完整Python實(shí)現(xiàn)與逐行解析現(xiàn)在我們將建堆和排序過程組合起來得到完整的堆排序算法。def heap_sort(arr): 堆排序主函數(shù) n len(arr) # 1. 構(gòu)建初始最大堆 build_max_heap(arr) # 調(diào)用前面定義的函數(shù) print(f建堆后的數(shù)組{arr}) # 2. 逐個(gè)提取元素 for i in range(n - 1, 0, -1): # 將當(dāng)前堆頂最大值arr[0] 與堆的最后一個(gè)元素 arr[i] 交換 arr[0], arr[i] arr[i], arr[0] print(f第{n-i}次交換后將最大值{arr[i]}放到末尾{i}當(dāng)前數(shù)組{arr}) # 堆的大小減1排除已排序的末尾元素對新的堆頂arr[0]進(jìn)行下沉恢復(fù)堆性質(zhì) heapify_down(arr, i, 0) # 注意這里堆的大小是i不是n print(f 下沉調(diào)整后數(shù)組{arr}) # 輔助函數(shù)定義放在heap_sort之前或之后 def build_max_heap(arr): n len(arr) for i in range(n // 2 - 1, -1, -1): heapify_down(arr, n, i) def heapify_down(arr, n, i): largest i left 2 * i 1 right 2 * i 2 if left n and arr[left] arr[largest]: largest left if right n and arr[right] arr[largest]: largest right if largest ! i: arr[i], arr[largest] arr[largest], arr[i] heapify_down(arr, n, largest) # 測試代碼 if __name__ __main__: data [4, 10, 3, 5, 1, 7, 9, 2, 6, 8] print(f原始數(shù)組{data}) heap_sort(data) print(f排序后數(shù)組{data})逐行解析與關(guān)鍵點(diǎn)n len(arr): 獲取數(shù)組長度。build_max_heap(arr): 第一階段將輸入數(shù)組原地改造成一個(gè)最大堆。此時(shí)arr[0]是最大值。for i in range(n - 1, 0, -1): 這是排序循環(huán)。i從最后一個(gè)索引n-1開始遞減到1。i在這里有兩個(gè)含義一是它指向當(dāng)前堆的“最后一個(gè)元素”位置二是交換后arr[i]就是已經(jīng)就位的最大值。arr[0], arr[i] arr[i], arr[0]: 將堆頂最大值arr[0]與當(dāng)前堆的末尾arr[i]交換。交換后最大值就歸位到了數(shù)組末尾。heapify_down(arr, i, 0):這是最易錯(cuò)的一步注意此時(shí)堆的有效大小已經(jīng)減少了因?yàn)樗饕齣及之后的元素都是已排序好的最大值。所以我們調(diào)用heapify_down時(shí)傳入的堆大小是i而不是n表示只對前i個(gè)元素進(jìn)行堆調(diào)整。調(diào)整的對象是新的堆頂arr[0]它是剛才交換上來的一個(gè)較小值目的是讓這個(gè)值“下沉”到合適位置恢復(fù)前i個(gè)元素的最大堆性質(zhì)。運(yùn)行測試代碼觀察打印的中間過程你能清晰地看到最大值如何被一步步交換到末尾以及堆如何被重新調(diào)整。5. 算法深度剖析時(shí)間復(fù)雜度、空間復(fù)雜度與穩(wěn)定性5.1 時(shí)間復(fù)雜度分析為什么是 O(n log n)建堆階段build_max_heap函數(shù)的時(shí)間復(fù)雜度是O(n)。這是一個(gè)經(jīng)過仔細(xì)推導(dǎo)的結(jié)論雖然它內(nèi)部調(diào)用了O(log n)的heapify_down但由于大部分節(jié)點(diǎn)的高度都很小攤還后的成本是線性的。排序階段循環(huán)執(zhí)行n-1次每次循環(huán)主要操作是交換O(1)和一次heapify_downO(log n)。因此排序階段的時(shí)間復(fù)雜度是O(n log n)。綜合兩個(gè)階段堆排序的總時(shí)間復(fù)雜度為 O(n log n)。并且這個(gè)復(fù)雜度是最壞、平均、最好情況下的時(shí)間復(fù)雜度它非常穩(wěn)定不像快速排序在最壞情況下會(huì)退化到O(n2)。5.2 空間復(fù)雜度原地排序的典范堆排序的整個(gè)操作都是在輸入數(shù)組上進(jìn)行的只使用了常數(shù)級別的額外空間如幾個(gè)循環(huán)變量。因此它的空間復(fù)雜度是 O(1)是一種原地排序算法。這對于內(nèi)存受限的場景如嵌入式系統(tǒng)或處理海量數(shù)據(jù)時(shí)非常重要。5.3 穩(wěn)定性堆排序是不穩(wěn)定排序穩(wěn)定性是指如果兩個(gè)相等的元素在排序前后的相對位置不變則排序算法是穩(wěn)定的。堆排序在heapify_down的交換過程中可能會(huì)將位于后面的相等元素交換到前面去。例如對[5a, 5b, 3]用a,b區(qū)分相同值建最大堆并排序5a和5b的相對順序可能改變。因此堆排序是不穩(wěn)定的排序算法。如果需要穩(wěn)定性可以考慮歸并排序。6. 堆排序的優(yōu)缺點(diǎn)與適用場景6.1 優(yōu)勢時(shí)間復(fù)雜度優(yōu)且穩(wěn)定最壞情況下也能保證O(n log n)在需要對性能有嚴(yán)格保證的場景下很可靠??臻g效率高原地排序空間復(fù)雜度O(1)節(jié)省內(nèi)存。適用于海量數(shù)據(jù)由于空間復(fù)雜度低在處理無法一次性裝入內(nèi)存的大數(shù)據(jù)時(shí)外排序堆排序或堆結(jié)構(gòu)是核心組件。例如從1TB數(shù)據(jù)中找出最大的10個(gè)數(shù)可以用一個(gè)大小為10的最小堆在單次遍歷中完成。6.2 劣勢緩存不友好堆排序?qū)?shù)組的訪問是跳躍式的訪問父節(jié)點(diǎn)和子節(jié)點(diǎn)這破壞了數(shù)據(jù)的局部性原理導(dǎo)致CPU緩存命中率較低。在現(xiàn)代計(jì)算機(jī)體系結(jié)構(gòu)下這可能會(huì)使其實(shí)際運(yùn)行速度慢于同樣O(n log n)但緩存友好的排序如歸并排序、經(jīng)過優(yōu)化的快速排序。不穩(wěn)定如上所述不適用于需要保持相等元素原始順序的場景。常數(shù)因子較大由于涉及大量的比較和交換其O(n log n)前面的常數(shù)因子通常比快速排序大。6.3 典型應(yīng)用場景實(shí)現(xiàn)優(yōu)先級隊(duì)列這是堆數(shù)據(jù)結(jié)構(gòu)最直接的應(yīng)用。Python的heapq模塊就是基于最小堆實(shí)現(xiàn)的。Top K 問題求數(shù)據(jù)流中最大或最小的K個(gè)元素。維護(hù)一個(gè)大小為K的堆時(shí)間復(fù)雜度為O(n log K)。定時(shí)任務(wù)調(diào)度操作系統(tǒng)或任務(wù)調(diào)度器中經(jīng)常需要根據(jù)優(yōu)先級或執(zhí)行時(shí)間來調(diào)度任務(wù)堆是高效的數(shù)據(jù)結(jié)構(gòu)。作為某些復(fù)雜算法的子過程如圖算法中的Dijkstra最短路徑算法、Prim最小生成樹算法都需要優(yōu)先級隊(duì)列的支持。7. 常見問題、調(diào)試技巧與優(yōu)化方向7.1 常見錯(cuò)誤與排查索引越界在heapify_down中訪問left和right子節(jié)點(diǎn)前務(wù)必檢查left n和right n。這是邊界條件容易遺漏。排序循環(huán)中堆大小傳錯(cuò)在heapify_down(arr, i, 0)中第二個(gè)參數(shù)必須是i代表當(dāng)前未排序的堆大小。如果錯(cuò)誤地傳入n會(huì)導(dǎo)致算法錯(cuò)誤地調(diào)整已排序好的元素。建堆起始點(diǎn)錯(cuò)誤建堆時(shí)循環(huán)應(yīng)從n // 2 - 1開始。如果從n-1開始即從葉子節(jié)點(diǎn)是無效操作如果從0開始從上往下則不是最優(yōu)建堆方式。遞歸深度問題對于極大的數(shù)組遞歸版本的heapify_down可能導(dǎo)致遞歸深度超過Python默認(rèn)限制??梢暂p松改為迭代版本def heapify_down_iterative(arr, n, i): current i while True: largest current left 2 * current 1 right 2 * current 2 if left n and arr[left] arr[largest]: largest left if right n and arr[right] arr[largest]: largest right if largest current: break arr[current], arr[largest] arr[largest], arr[current] current largest7.2 性能優(yōu)化與小技巧使用迭代代替遞歸如上所述迭代版本的heapify_down可以避免遞歸開銷和深度限制是工業(yè)級實(shí)現(xiàn)的首選。內(nèi)聯(lián)交換操作在非常注重性能的底層實(shí)現(xiàn)中可能會(huì)用臨時(shí)變量手動(dòng)進(jìn)行交換而不是Python的元組解包但現(xiàn)代Python解釋器對此優(yōu)化得很好差異不大。理解“為什么從 n//2-1 開始”畫一個(gè)包含6個(gè)或7個(gè)節(jié)點(diǎn)的完全二叉樹手動(dòng)標(biāo)出數(shù)組索引和父子關(guān)系這個(gè)結(jié)論會(huì)變得非常直觀。理解它比死記硬背更重要。利用heapq模塊Python標(biāo)準(zhǔn)庫的heapq提供的是最小堆。如果你想用現(xiàn)成的堆來實(shí)現(xiàn)堆排序可以先將所有元素heapq.heappush進(jìn)堆再逐個(gè)heapq.heappop出來但這樣會(huì)使用額外O(n)空間。heapq模塊也提供了heapify函數(shù)O(n)時(shí)間來原地建堆。7.3 從堆排序到優(yōu)先級隊(duì)列堆排序的算法本身就是一個(gè)動(dòng)態(tài)維護(hù)最大值的過程。稍作封裝你就可以實(shí)現(xiàn)一個(gè)優(yōu)先級隊(duì)列class MaxPriorityQueue: def __init__(self): self.heap [] def push(self, val): # 上浮操作 self.heap.append(val) i len(self.heap) - 1 parent (i - 1) // 2 while i 0 and self.heap[i] self.heap[parent]: self.heap[i], self.heap[parent] self.heap[parent], self.heap[i] i parent parent (i - 1) // 2 def pop(self): if not self.heap: return None if len(self.heap) 1: return self.heap.pop() root self.heap[0] # 將末尾元素移到堆頂并下沉 self.heap[0] self.heap.pop() self._heapify_down(0) return root def _heapify_down(self, i): # 類似之前的heapify_down但操作在self.heap上 n len(self.heap) largest i left 2 * i 1 right 2 * i 2 if left n and self.heap[left] self.heap[largest]: largest left if right n and self.heap[right] self.heap[largest]: largest right if largest ! i: self.heap[i], self.heap[largest] self.heap[largest], self.heap[i] self._heapify_down(largest)這個(gè)簡單的類展示了如何用堆的思想實(shí)現(xiàn)插入push O(log n)和彈出最大值pop O(log n)的操作。這正是許多高級算法的基礎(chǔ)。掌握堆排序絕不僅僅是學(xué)會(huì)了一種排序方法。它真正讓你入門了“堆”這一極其重要的數(shù)據(jù)結(jié)構(gòu)打開了解決一大類高效算法問題的大門。我建議你在理解上述代碼后關(guān)閉文章自己從頭到尾默寫一遍并嘗試用迭代方式實(shí)現(xiàn)heapify_down。然后去LeetCode上找?guī)椎狸P(guān)于“堆”或“Top K”的題目練練手感受一下它的威力。當(dāng)你下次需要在一個(gè)數(shù)據(jù)流中實(shí)時(shí)維護(hù)最大或最小的幾個(gè)元素時(shí)你第一個(gè)想到的就會(huì)是堆。