1. 程式人生 > >演算法第八記-堆排序

演算法第八記-堆排序

今天來總結一下堆排序的思路以及使用到堆排序思路的演算法面試題:
首先堆是一棵完全二叉樹,它的左子樹與右子樹同時也都是堆(至於是最大堆還是最小堆。隨便),如果你的堆是最大堆的話,那麼它的性質每個結點都大於其左右孩子結點的值。如果是最小堆的話,每個結點都小於其左右孩子結點的值。而且對於堆來說每次刪除操作都是在堆頂操作的。而插入操作則沒有這個限制。我們一般是用陣列來儲存資料(樹的儲存形式可以是陣列),然後通過將陣列進行操作使其滿足堆的性質 。首先我們來講講一個關鍵操作Max_heapify:

我們發現歸併排序、快速排序都有一個關鍵操作,而堆排序也不例外。那麼這個Max_heapify究竟做了什麼呢?對於一個堆來講,當我們從堆頂刪除一個元素或者從尾部插入一個元素時都會可能使得堆的性質發生了改變,所以我們在插入刪除後需要繼續維持這個性質。首先當我們刪除堆頂元素時我們是這樣操作的:通過將陣列尾部的元素與當前堆頂進行交換,然後堆的大小減1。由於換到堆頂的元素不一定滿足堆的性質,所以我們要去檢測如果滿足就啥也不做,如果不滿足的話就需要調整堆,那麼如何調整堆呢?下溯!什麼意思呢?我們已經將陣列末尾的元素換到了堆頂,此時我們從堆頂開始與其左右孩子進行判斷大小,如果比左右孩子都大,則max_heapfiy直接結束,如果比左右孩子中更大的一方小的話,就讓這個孩子的值移動到堆頂,然後繼續從這個孩子的位置繼續往下重複操作,直到出現當前結點比左右孩子都大的情況。那麼這裡需要注意的是,由於我們是要和左右孩子中更大的一方進行比較,所以自然免不了要有一次左右孩子之間的比較(程式碼中會體現),同時還有一種情況就是 只有左孩子,沒有右孩子的情況,那我們就不需要這次左右孩子的比較操作,所以自然而然我們就可以發現這兩個條件是&&的關係,只有當前節點有左孩子也有右孩子才進行後面的操作:child<length&&arr[child]<arr[child+1] 。

void Max_Heapify(int arr[], int i,int length)
{
	int child=2*i+1;//因為陣列儲存是從0開始的,所以左孩子應該為2*i+1;
	int val = arr[i];
	int x = i;
	for (; child< length; x = child,child=2*x+1)
	{
		if (child+1< length&&arr[child] < arr[child + 1])
			child = child+1;
		if (arr[child] < val)
			break;
		arr[x] = arr[child];
	}
	arr[x] = val;
}

講完了max_heapify之後,接下來我要講講建堆操作:

如何建堆呢? 直觀的思路通常是,將一個一個元素插入堆,然後不斷呼叫max_heapify,最終建堆完成。那麼這樣的效率是多少呢?O(nlogn)!。有沒有其他更高效率的建堆方法呢?弗洛伊德建堆法!這種方法是從底往上不斷調整,最終的時間複雜度為O(n),關於建堆的時間複雜度分析,我找到了兩篇文章挺棒的https://blog.csdn.net/wangqing_199054/article/details/20461877

https://www.cnblogs.com/shytong/p/5364470.html。可以去參考參考。

下面貼上建堆程式碼:

void build_MaxHeap(int arr[], int length)
{
	for (int i = length / 2-1; i >= 0; i--)//如果是從下標0開始的話 i應該再減1
		Max_Heapify(arr, i, length);
}

最後就是堆排序的程式碼,其實這是最簡單的一部分,我們每次堆調整之後,堆頂都是最大值。我們可以把堆頂元素與尾部進行交換。重新堆調整時,將這個尾部元素排除。這樣我們就能最終將序列排好序。

void HeapSort(int arr[], int length)
{
	if (!arr || length <= 0)
		return;
	build_MaxHeap(arr, length);
	for (int i = length-1; i > 0; i--)
	{
		swap(arr[i], arr[0]);
	    Max_Heapify(arr, 0,i);
	}
}

關於堆排序的效率分析:首先它沒有用到額外的空間複雜度,所以是一個原地排序,空間複雜度為O(1)。堆排序是不穩定的,為什麼呢?我們知道堆的結構是節點i的孩子為2 * i和2 * i + 1節點,大頂堆要求父節點大於等於其2個子節點,小頂堆要求父節點小於等於其2個子節點。在一個長為n 的序列,堆排序的過程是從第n / 2開始和其子節點共3個值選擇最大(大頂堆)或者最小(小頂堆),這3個元素之間的選擇當然不會破壞穩定性。但當為n / 2 - 1, n / 2 - 2, ... 1這些個父節點選擇元素時,就會破壞穩定性。有可能第n / 2個父節點交換把後面一個元素交換過去了,而第n / 2 - 1個父節點把後面一個相同的元素沒 有交換,那麼這2個相同的元素之間的穩定性就被破壞了。所以,堆排序不是穩定的排序演算法。

堆排序的時間複雜度為O(nlogn),最好最壞情況都是如此。

1.最小或者最大的ķ個數

答:我們可以建立一個大小為ķ的最小堆,然後遍歷剩餘的nk個個數如果當前比堆頂大的話就將堆頂元素刪除,插入新元素如果比堆頂小的話就跳過繼續遍歷。這樣我們就會不斷提高堆內元素的下界,直到最終堆裡將會是最大的ķ個數。同理最小的ķ個數,我們建立一個大小為ķ的最大堆,然後遍歷剩餘的NK個數,如果比堆頂元素大的話就跳過,比堆頂元素小的話就刪除堆頂元素,並插入新元素。這樣我們會不斷降低堆內元素的上界,使得最終堆裡儲存的是最小的ķ個數。


      2.使用堆進行多路歸併外排序(假設記憶體放不下的情況)

答:如果我們的記憶體放不下我們待排序的資料的話,我們通常會考慮把資料儲存在外存中比如一共ķ個檔案每個檔案內的資料都可以放到記憶體進行快速排序排好序,然後建立k個IO流物件繫結這八個檔案。先選取每個檔案的第一個數進行建最小堆。這裡我的想法是暫時放在記憶體中的資料以一個結構體型別儲存,一個數據域另一個用來標識來自哪個檔案。由於放在記憶體中的資料較少,所以這樣一點空間損耗是無關緊要的。然後每次從堆頂出堆一個數。再從這個出堆元素所屬的檔案,再加入一個新的數然後對進行最小堆調整再繼續操作。所以最終的時間複雜度排除掉那些io讀寫的效率的影響。應該是(nk)logk + O(k)(建堆時間) ,最終是O(nlogk)。

      3.使用優先順序佇列模擬佇列和棧


      4.使用不斷插入的方法進行建堆與弗洛伊德建堆法的效率分析

 答:使用不斷插入的方法建堆的效率分析如下,第一次入堆調整log1,第二次入堆調整log2,第三次入堆調整log3,最終的效率和是s = log1 + log2 + log3 + LOG4 ...... logn.s因此也可以等於日誌(1 * 2 * 3 * 4 * ... * N),...,這個序列的上界很明顯可以是的log(n的ñ次方),也就是等於nlogn,所以最終的建堆效率為O(nlogn)。

   使用弗洛伊德建堆法我們來分析一下建堆效率:每次呼叫max_heapify的效率是O(logn)時間,Build_MAx_HEAP需要Ñ次這樣的呼叫因此總的時間複雜度為O(nlogn)這個上。界雖然正確,但是並不漸進緊確。我們還可以進一步得到一個更加緊確的界。可以觀察到不同高度的結點呼叫max_heapify的效率是不同的。而且我們知道對於完全二叉樹而言,差不多一半以上的結點都在最底部,所以那些結點呼叫max_heapfiy的效率是很高的因此利用這個性質我們可以得到一個更緊確的界:

    5.為什麼建堆要從尺寸/ 2-1減到0而不是從0加到尺寸/ 2-1?

  答:因為我們使用max_heapify的前提是左右子樹都是最大堆所以如果左右子樹無法保證這個前提的話那麼第一次假設我們此時調整堆頂從前往後開始,而此時的左子樹比此時的堆頂元素大但卻不是左子樹中最大的那個。那麼我們此時將會這個值放在了堆頂。而堆調整是從前往後的,後面就再也不會從堆頂元素這個位置開始調整。這樣會導致我們最終建堆完畢後,堆頂元素不是最大值。從而建堆失敗。