1. 程式人生 > >資料結構與演算法隨筆之------堆與優先佇列

資料結構與演算法隨筆之------堆與優先佇列

 堆是什麼?是一種特殊的完全二叉樹,就像下面這棵樹一樣。

        有沒有發現這棵二叉樹有一個特點,就是所有父結點都比子結點要小(注意:圓圈裡面的數是值,圓圈上面的數是這個結點的編號,此規定僅適用於本節)。符合這樣特點的完全二叉樹我們稱為最小堆。反之,如果所有父結點都比子結點要大,這樣的完全二叉樹稱為最大堆。那這一特性究竟有什麼用呢?

        假如有14個數分別是99、5、36、7、22、17、46、12、2、19、25、28、1和92。請找出這14個數中最小的數,請問怎麼辦呢?最簡單的方法就是將這14個數從頭到尾依次掃一遍,用一個迴圈就可以解決。這種方法的時間複雜度是O(14)也就是O(N)。

1

2

3

4

for(i=1;i<=14;i++)

{

if(a[ i]<min)    min=a[ i];

}

        現在我們需要刪除其中最小的數,並增加一個新數23,再次求這14個數中最小的一個數。請問該怎麼辦呢?只能重新掃描所有的數,才能找到新的最小的數,這個時間複雜度也是O(N)。假如現在有14次這樣的操作(刪除最小的數後並新增一個新數)。那麼整個時間複雜度就是O(142)即O(N2)。那有沒有更好的方法呢?堆這個特殊的結構恰好能夠很好地解決這個問題。

        首先我們先把這個14個數按照最小堆的要求(就是所有父結點都比子結點要小)放入一棵完全二叉樹,就像下面這棵樹一樣。

        很顯然最小的數就在堆頂,假設儲存這個堆的陣列叫做h的話,最小數就是h[ 1]。接下來,我們將堆頂的數刪除,並將新增加的數23放到堆頂。顯然加了新數後已經不符合最小堆的特性,我們需要將新增加的數調整到合適的位置。那如何調整呢?

        向下調整!我們需要將這個數與它的兩個兒子2和5比較,並選擇較小一個與它交換,交換之後如下。

        我們發現此時還是不符合最小堆的特性,因此還需要繼續向下調整。於是繼續將23與它的兩個兒子12和7比較,並選擇較小一個交換,交換之後如下。

        到此,還是不符合最小堆的特性,仍需要繼續向下調整直到符合最小堆的特性為止。

        我們發現現在已經符合最小堆的特性了。綜上所述,當新增加一個數被放置到堆頂時,如果此時不符合最小堆的特性,則將需要將這個數向下調整,直到找到合適的位置為止,使其重新符合最小堆的特性。

        向下調整的程式碼如下。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

void siftdown(int i) //傳入一個需要向下調整的結點編號i,這裡傳入1,即從堆的頂點開始向下調整 

{

int t,flag=0;//flag用來標記是否需要繼續向下調整 

//當i結點有兒子的時候(其實是至少有左兒子的情況下)並且有需要繼續調整的時候迴圈窒執行

while( i*2<=n && flag==0 )

{        

//首先判斷他和他左兒子的關係,並用t記錄值較小的結點編號 

if( h[ i] > h[ i*2] )

t=i*2;

else

t=i; 

//如果他有右兒子的情況下,再對右兒子進行討論 

if(i*2+1 <= n)

{

//如果右兒子的值更小,更新較小的結點編號  

if(h[ t] > h[ i*2+1])

t=i*2+1;

}

//如果發現最小的結點編號不是自己,說明子結點中有比父結點更小的  

if(t!=i)

{

swap(t,i);//交換它們,注意swap函式需要自己來寫

i=t;//更新i為剛才與它交換的兒子結點的編號,便於接下來繼續向下調整 

}

else

flag=1;//則否說明當前的父結點已經比兩個子結點都要小了,不需要在進行調整了 

}

}

        我們剛才在對23進行調整的時候,竟然只進行了3次比較,就重新恢復了最小堆的特性。現在最小的數依然在堆頂為2。之前那種從頭到尾掃描的方法需要14次比較,現在只需要3次就夠了。現在每次刪除最小的數並新增一個數,並求當前最小數的時間複雜度是O(3),這恰好是O(log214)即O(log2N)簡寫為O(logN)。假如現在有1億個數(即N=1億),進行1億次刪除最小數並新增一個數的操作,使用原來掃描的方法計算機需要執行大約1億的平方次,而現在只需要1億*log1億次,即27億次。假設計算機每秒鐘可以執行10億次,那原來則需要一千萬秒大約115天!而現在只要2.7秒。是不是很神奇,再次感受到演算法的偉大了吧。

        說到這裡,如果只是想新增一個值,而不是刪除最小值又該如何操作呢?即如何在原有的堆上直接插入一個新元素呢?只需要直接將新元素插入到末尾,再根據情況判斷新元素是否需要上移,直到滿足堆的特性為止。如果堆的大小為N(即有N個元素),那麼插入一個新元素所需要的時間也是O(logN)。例如我們現在要新增一個數3。

        先將3與它的父結點25比較,發現比父結點小,為了維護最小堆的特性,需要與父結點的值進行交換。交換之後發現還是要比它此時的父結點5小,因此需要再次與父結點交換。至此又重新滿足了最小堆的特性。向上調整完畢後如下。

        向上調整的程式碼如下。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

void siftup(int i) //傳入一個需要向上調整的結點編號i

{

int flag=0; //用來標記是否需要繼續向上調整

if(i==1)  return//如果是堆頂,就返回,不需要調整了    

//不在堆頂 並且 當前結點i的值比父結點小的時候繼續向上調整 

while(i!=1 && flag==0)

{

//判斷是否比父結點的小 

if(h[ i]<h[ i/2])

swap(i,i/2);//交換他和他爸爸的位置 

else

flag=1;//表示已經不需要調整了,當前結點的值比父結點的值要大 

i=i/2; //這句話很重要,更新編號i為它父結點的編號,從而便於下一次繼續向上調整 

}

}

接著上一Pa說。就是如何建立這個堆呢。可以從空的堆開始,然後依次往堆中插入每一個元素,直到所有數都被插入(轉移到堆中為止)。因為插入第i個元素的所用的時間是O(log i),所以插入所有元素的整體時間複雜度是O(NlogN),程式碼如下。

1

2

3

4

5

6

7

n=0;

for(i=1;i<=m;i++)

{

n++;

h[ n]=a[ i];  //或者寫成scanf("%d",&h[ n]);

siftup();

}

        其實我們還有更快得方法來建立堆。它是這樣的。

        直接把99、5、36、7、22、17、46、12、2、19、25、28、1和92這14個數放入一個完全二叉樹中(這裡我們還是用一個一維陣列來儲存完全二叉樹)。

        在這個棵完全二叉樹中,我們從最後一個結點開始依次判斷以這個結點為根的子樹是否符合最小堆的特性。如果所有的子樹都符合最小堆的特性,那麼整棵樹就是最小堆了。如果這句話沒有理解不要著急,繼續往下看。

        首先我們從葉結點開始。因為葉結點沒有兒子,所以所有以葉結點為根結點的子樹(其實這個子樹只有一個結點)都符合最小堆的特性(即父結點的值比子結點的值小)。這些葉結點壓根就沒有子節點,當然符合這個特性。因此所有葉結點都不需要處理,直接跳過。從第n/2個結點(n為完全二叉樹的結點總數,這裡即7號結點)開始處理這棵完全二叉樹。注意完全二叉樹有一個性質:最後一個非葉結點是第n/2個結點。

        以7號結點為根的子樹不符合最小堆的特性,因此要向下調整。

        同理以6號、5號和4結點為根的子樹也不符合最小對的特性,都需要往下調整。

        下面是已經對7號、6號、5號和4結點為根結點的子樹調整完畢之後的狀態。

        當然目前這棵樹仍然不符合最小堆的特性,我們需要繼續調整以3號結點為根的子樹,即將3號結點向下調整。

        同理繼續調整以2號結點為根的子樹,最後調整以1號結點為根的子樹。調整完畢之後,整棵樹就符合最小堆的特性啦。

        小結一下這個建立堆的演算法。把n個元素建立一個堆,首先我可以將這n個結點以自頂向下、從左到右的方式從1到n編碼。這樣就可以把這n個結點轉換成為一棵完全二叉樹。緊接著從最後一個非葉結點(結點編號為n/2)開始到根結點(結點編號為1),逐個掃描所有的結點,根據需要將當前結點向下調整,直到以當前結點為根結點的子樹符合堆的特性。雖然講起來起來很複雜,但是實現起來卻很簡單,只有兩行程式碼如下:

1

2

for(i=n/2;i>=1;i--)

siftdown(i);

        用這種方法來建立一個堆的時間複雜度是O(N),如果你感興趣可以嘗試自己證明一下,嘿嘿。

        堆還有一個作用就是堆排序,與快速排序一樣堆排序的時間複雜度也是O(NlogN)。堆排序的實現很簡單,比如我們現在要進行從小到大排序,可以先建立最小堆,然後每次刪除頂部元素並將頂部元素輸出或者放入一個新的陣列中,直到堆為空為止。最終輸出的或者存放在新陣列中數就已經是排序好的了。

1

2

3

4

5

6

7

8

9

10

//刪除最大的元素

int deletemax()

{

int t;

t=h[ 1];//用一個臨時變數記錄堆頂點的值

h[ 1]=h[ n];//將堆得最後一個點賦值到堆頂

n--;//堆的元素減少1

siftdown(1);//向下調整

return t;//返回之前記錄的堆得頂點的最大值

}

        建堆以及堆排序的完整程式碼如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

#include <stdio.h>

int h[ 101];//用來存放堆的陣列

int n;//用來儲存堆中元素的個數,也就是堆的大小

//交換函式,用來交換堆中的兩個元素的值

void swap(int x,int y)

{

int t;

t=h[ x];

h[ x]=h[ y];

h[ y]=t;

}

//向下調整函式

void siftdown(int i) //傳入一個需要向下調整的結點編號i,這裡傳入1,即從堆的頂點開始向下調整

{

int t,flag=0;//flag用來標記是否需要繼續向下調整

//當i結點有兒子的時候(其實是至少有左兒子的情況下)並且有需要繼續調整的時候迴圈窒執行

while( i*2<=n && flag==0 )

{        

//首先判斷他和他左兒子的關係,並用t記錄值較小的結點編號

if( h[ i] > h[ i*2] )

t=i*2;

else

t=i;

//如果他有右兒子的情況下,再對右兒子進行討論

if(i*2+1 <= n)

{

//如果右兒子的值更小,更新較小的結點編號  

if(h[ t] > h[ i*2+1])

t=i*2+1;

}

//如果發現最小的結點編號不是自己,說明子結點中有比父結點更小的  

if(t!=i)

{

swap(t,i);//交換它們,注意swap函式需要自己來寫

i=t;//更新i為剛才與它交換的兒子結點的編號,便於接下來繼續向下調整

}

else

flag=1;//則否說明當前的父結點已經比兩個子結點都要小了,不需要在進行調整了

}

}

//建立堆的函式

void creat()

{

int i;

//從最後一個非葉結點到第1個結點依次進行向上調整

for(i=n/2;i>=1;i--)

{

siftdown(i);

}  

}

//刪除最大的元素

int deletemax()

{

int t;

t=h[ 1];//用一個臨時變數記錄堆頂點的值

h[ 1]=h[ n];//將堆得最後一個點賦值到堆頂

n--;//堆的元素減少1

siftdown(1);//向下調整

return t;//返回之前記錄的堆得頂點的最大值

}

int main()

{

int i,num;

//讀入數的個數

scanf("%d",&num);

for(i=1;i<=num;i++)

scanf("%d",&h[ i]);

n=num;   

//建堆

creat();

//刪除頂部元素,連續刪除n次,其實夜就是從大到小把數輸出來

for(i=1;i<=num;i++)

printf("%d ",deletemax());

getchar();

getchar();

return 0;

}

        可以輸入以下資料進行驗證

        14

        99 5 36 7 22 17 46 12 2 19 25 28 1 92

        執行結果是

        1 2 5 7 12 17 19 22 25 28 36 46 92 99

        當然堆排序還有一種更好的方法。從小到大排序的時候不建立最小堆而建立最大堆。最大堆建立好後,最大的元素在h[ 1]。因為我們的需求是從小到大排序,希望最大的放在最後。因此我們將h[ 1]和h[ n]交換,此時h[ n]就是陣列中的最大的元素。請注意,交換後還需將h[ 1]向下調整以保持堆的特性。OK現在最大的元素已經歸位,需要將堆的大小減1即n--,然後再將h[ 1]和h[ n]交換,並將h[ 1]向下調整。如此反覆,直到堆的大小變成1為止。此時陣列h中的數就已經是排序好的了。程式碼如下:

1

2

3

4

5

6

7

8

9

10

//堆排序

void heapsort()

{

while(n>1)

{

swap(1,n);

n--;

siftdown(1);

}

}

完整的堆排序的程式碼如下,注意使用這種方法來進行從小到大排序需要建立最大堆。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

#include <stdio.h>

int h[ 101];//用來存放堆的陣列

int n;//用來儲存堆中元素的個數,也就是堆的大小

//交換函式,用來交換堆中的兩個元素的值

void swap(int x,int y)

{

int t;

t=h[ x];

h[ x]=h[ y];

h[ y]=t;

}

//向下調整函式

void siftdown(int i) //傳入一個需要向下調整的結點編號i,這裡傳入1,即從堆的頂點開始向下調整

{

int t,flag=0;//flag用來標記是否需要繼續向下調整

//當i結點有兒子的時候(其實是至少有左兒子的情況下)並且有需要繼續調整的時候迴圈窒執行

while( i*2<=n && flag==0 )

{        

//首先判斷他和他左兒子的關係,並用t記錄值較大的結點編號

if( h[ i] < h[ i*2] )

t=i*2;

else

t=i;

//如果他有右兒子的情況下,再對右兒子進行討論

if(i*2+1 <= n)

{

//如果右兒子的值更大,更新較小的結點編號  

if(h[ t] < h[ i*2+1])

t=i*2+1;

}

//如果發現最大的結點編號不是自己,說明子結點中有比父結點更大的  

if(t!=i)

{

swap(t,i);//交換它們,注意swap函式需要自己來寫

i=t;//更新i為剛才與它交換的兒子結點的編號,便於接下來繼續向下調整

}

else

flag=1;//則否說明當前的父結點已經比兩個子結點都要大了,不需要在進行調整了

}

}

//建立堆的函式

void creat()

{

int i;

//從最後一個非葉結點到第1個結點依次進行向上調整

for(i=n/2;i>=1;i--)

{

siftdown(i);

}  

}

//堆排序

void heapsort()

{

while(n>1)

{

swap(1,n);

n--;

siftdown(1);

}

}

int main()

{

int i,num;

//讀入n個數

scanf("%d",&num);

for(i=1;i<=num;i++)

scanf("%d",&h[ i]);

n=num;   

//建堆

creat();

//堆排序

heapsort();

//輸出

for(i=1;i<=num;i++)

printf("%d ",h[ i]);

getchar();

getchar();

return 0;

}

        可以輸入以下資料進行驗證

        14

        99 5 36 7 22 17 46 12 2 19 25 28 1 92

        執行結果是

        1 2 5 7 12 17 19 22 25 28 36 46 92 99

        OK,最後還是要總結一下。像這樣支援插入元素和尋找最大(小)值元素的資料結構稱之為優先佇列。如果使用普通佇列來實現這個兩個功能,那麼尋找最大元素需要列舉整個佇列,這樣的時間複雜度比較高。如果已排序好的陣列,那麼插入一個元素則需要移動很多元素,時間複雜度依舊很高。而堆就是一種優先佇列的實現,可以很好的解決這兩種操作。

        另外Dijkstra演算法中每次找離源點最近的一個頂點也可以用堆來優化,使演算法的時間複雜度降到O((M+N)logN)。堆還經常被用來求一個數列中第K大的數。只需要建立一個大小為K的最小堆,堆頂就是第K大的數。如果求一個數列中第K小的數,只最需要建立一個大小為K的最大堆,堆頂就是第K小的數,這種方法的時間複雜度是O(NlogK)。當然你也可以用堆來求前K大的數和前K小的數。你還能想出更快的演算法嗎?有興趣的同學可以去閱讀《程式設計之美》第二章第五節。

        堆排序演算法是由J.W.J. Williams在1964年發明,他同時描述瞭如何使用堆來實現一個優先佇列。同年,由Robert W.Floyd提出了建立堆的線性時間演算法。