1. 程式人生 > >Priority Queue(Heaps)--優先佇列(堆)

Priority Queue(Heaps)--優先佇列(堆)

0)引論

前面已經有了連結串列,堆疊,佇列,樹等資料結構,尤其是樹,是一個很強大的資料結構,能做很多事情,那麼為什麼還要引進一個優先佇列的東東呢?它和佇列有什麼本質的不同呢?看一個例子,有一個印表機,但是有很多的檔案需要列印,那麼這些任務必然要排隊等候印表機逐步的處理。這裡就產生了一個問題。原則上說先來的先處理,但是有一個檔案100頁,它排在另一個1頁的檔案的前面,那麼可能我們要先列印這個1頁的檔案比較合理。因此為了解決這一類的問題,提出了優先佇列的模型。

優先佇列是一個至少能夠提供插入(Insert)和刪除最小(DeleteMin)這兩種操作的資料結構。對應於佇列的操作,Insert相當於Enqueue,DeleteMin相當於Dequeue。

連結串列,二叉查詢樹,都可以提供插入(Insert)和刪除最小(DeleteMin)這兩種操作,但是為什麼不用它們而引入了新的資料結構的。原因在於應用前兩者需要較高的時間複雜度。對於連結串列的實現,插入需要O(1),刪除最小需要遍歷連結串列,故需要O(N)。對於二叉查詢樹,這兩種操作都需要O(logN);而且隨著不停的DeleteMin的操作,二叉查詢樹會變得非常不平衡;同時使用二叉查詢樹有些浪費,因此很多操作根本不需要。

因此這裡引入一種新的資料結構,它能夠使插入(Insert)和刪除最小(DeleteMin)這兩種操作的最壞時間複雜度為O(N),而插入的平均時間複雜度為常數時間,即O(1)。同時不需要引入指標。

1) 二插堆(Binary Heap)

Heap有兩個性質:結構性質(structure property),堆的順序性(heap order property)。看英文應該比較好理解。

(1)structure property

Heap(堆)是一個除了底層節點外的完全填滿的二叉樹,底層可以不完全,左到右填充節點。(a heap is a binary tree that completely filled, with the exception of bottom level, which is filled from left to right.)這樣的樹叫做完全二叉樹。

一個高度為h 的完全二叉樹應該有以下的性質:

a) 有2^h到2^h-1個節點

b) 完全二叉樹的高度為[logN](向下取整)

鑑於完全二叉樹是一個很整齊的結構,因此可以不用指標而只用陣列來表示一顆完全二叉樹。 對於處於位置i 的元素,

a)他的左子節點在2*i,右子節點在(2*i+1)

b) 它的父節點在【i/2】(向下取整)

下圖顯示了完全二叉樹與陣列的對應關係:


(2)heap order property

堆的順序性質是指最小的結點應該是根節點,鑑於我們希望子樹也是堆,那麼每個子樹的根節點也應該是最小的,這樣就可以立刻找到最小值,然後可以對其進行刪除操作。下圖是一個堆的例子。

其實從這裡可以看出,堆的兩條性質:(a)完全二叉樹;(b)父節點小於後繼子節點

(3)堆的宣告

typedef struct HeapStruct;
typedef struct HeapStruct *Heap;

struct HeapStruct
{
	int Capacity;
	int Size;
	ElementType *Element;
}
堆元素存放在陣列中Element,Capacity是指堆的容量,SIze是指堆的實際元素個數。

(4)堆的初始化

Heap Initialize(int MaxNum)
{
	if(MaxNum<MiniHeapSize)
		error("The Heap Size is too small");
	Heap H;
	H = malloc(sizeof(struct HeapStruct));
	if(H==NULL)
		Error("Out of Space!!!");
	H->Element = malloc(sizeof(ElementType)*(MaxNum+1));
	if(H->Element == NULL)
		Error("Out of Space!!!");
	H->Capacity = MaxNum;
	H->Size = 0;
	H->Element[0] = MinData;
	return H;
}
堆的陣列的位置0的值是一個遊標哨兵,這個會用到,對元素是從1開始存放的。

(5)堆的插入

堆的插入是按照順序插入到底層的結點上,然後與他的父節點比較,如果小於父節點,那麼此結點與父節點交換位置,否則,這個位置就是應該插入的位置,依次迴圈,如圖所示。因此也可以理解堆的插入的平均時間複雜度為O(1),即常數時間,原因就在於只要在最後插入就可,最多是做幾個遷移比較,而最壞的時間複雜度為O(logN)是指這個插入節點是最小的結點,要遷移到root。


void Insert(ElementType X, Heap H)
{
	int i;
	if(IsFull(H))
	{
		Error("Heap is Full");
	}
	for(i=++H->Size;H->Element[i/2]>X;i/=2)
		H->Element[i] = H->Element[i/2];
	H->Element[i] = X;
}
插入就是一個比較節點和父節點的過程,而對於堆
H->Element[i/2]
就是父節點。

(6)堆的刪除最小操作

找到最小很easy,就是root。但是最關鍵的是刪除了以後的問題。這個可以用插入的思想把一步一步的向上滲透。先選取根節點的最小子節點,然後把這個這點遷移到根節點。然後遞迴操作。

對於刪除最小操作,可與預見的是他的最壞時間複雜度為O(logN),因為刪除節點後的滲透是沿著子樹走的,類似於二叉查詢樹的操作,故為O(logN)。


ElementType DeleteMin(Heap H)
{
	if(IsEmpty(H))
	{
		Error("Heap is Empty!!");
		return H->Element[0];
	}
	MiniElement = H->Element[1];
	LastElement = H->Element[H->Size--];

	int i;
	for(i=1;i*2<=H->Size;i=Child)
	{
		Child = i*2;
		// some node may have only one child or no child ,so put Child!=H->Size firstly 
		// to protect.
		if(Child!=H->Size && H->Element[Child+1]< H->Element[Child])
			Child++;
		if(LastElement>H->Element[Child])
			H->Element[i] = H->Element[Child];
		else
			break;
	}
	H->Element[i] = LastElement;
	return MiniElement;
}
程式設計需要注意的地方,一個是注意有些可能只有一個子節點或沒有子節點,二是如果最後一個節點小於當前正在滲透的節點(這兩個必然不在一個子樹上),那麼就直接用最後一個節點替換就可以了,然後結束。

(6)BuildHeap操作

建立一個堆我們可以首先用前面的程式碼初始化,然後利用Insert程式碼一個一個的把結點插入到堆中完成建立堆的工作。但是這樣有一個問題,我們知道插入的時間複雜的為:平均O(1), 最壞O(logN)。也就是說當需要插入N個點的時候,平均時間複雜度為O(N),最壞時間複雜度為O(NlogN)。那麼能不能線上性時間裡O(N)完成插入操作呢? BuildHeap就是在完成這樣的事情。

BuildHeap的思想很簡單,首先把需要插入的N個數據隨機插入到陣列中,然後逐級滲透以使其滿足Heap Order Property。


下面來對這個問題分析一下:虛線表示的操作是比較兩個子節點並把最小的子節點與父節點交換,這需要兩步。那麼對這個整個完全二叉樹來說,可能存在的是N/2個這樣的虛線,因此BuildHeap演算法的時間複雜度為O(N)。

(7)堆的應用

(a)尋找第k個最小值

可以先對陣列用BuildHeap操作,然後執行k次DeleteMin。

(8)d--堆

前面見到的都是二叉堆,當然還有其他形式的堆,d-堆就是其中之一。


d-堆也是完全樹。d-堆的優點在於它的插入時間複雜度為:


(9)總結:

優先佇列(堆)的主要作用就是兩個操作:插入,刪除最小。之所以要重新引入這個概念,就是為了能夠利用較小的時間複雜度來完成。