排序算法學習總結

排序算法名詞解釋

名詞 解釋
穩定性 當數組經過算法排序後,相等的數字的前後順序沒有發生變動。
不穩定性 當數組經過算法排序後,相等的數字的前後順序發生變動。
原地排序算法 指空間複雜度爲O(1)。在整段代碼中沒有額外的申請空間。
時間複雜度 代碼執行時間隨數據規模增長的變化趨勢,也叫作漸進時間複雜度。
空間複雜度 全稱就是漸進空間複雜度,表示算法的存儲空間與數據規模之間的增長關係。

這裏在學習極客時間的數據結構與算法之美的老師,提出了一個關於穩定性有用的例子

我們現在要給電商交易系統中的“訂單”排序。訂單有兩個屬性,一個是下單時間,另一個是訂單金額。如果我們現在有 10 萬條訂單數據,我們希望按照金額從小到大對訂單數據排序。對於金額相同的訂單,我們希望按照下單時間從早到晚有序。對於這樣一個排序需求,我們怎麼來做呢?

最先想到的方法是:我們先按照金額對訂單數據進行排序,然後,再遍歷排序之後的訂單數據,對於每個金額相同的小區間再按照下單時間排序。這種排序思路理解起來不難,但是實現起來會很複雜。藉助穩定排序算法,這個問題可以非常簡潔地解決。

解決思路是這樣的:我們先按照下單時間給訂單排序,注意是按照下單時間,不是金額。排序完成之後,我們用穩定排序算法,按照訂單金額重新排序。兩遍排序之後,我們得到的訂單數據就是按照金額從小到大排序,金額相同的訂單按照下單時間從早到晚排序的。爲什麼呢?

穩定排序算法可以保持金額相同的兩個對象,在排序之後的前後順序不變。第一次排序之後,所有的訂單按照下單時間從早到晚有序了。在第二次排序中,我們用的是穩定的排序算法,所以經過第二次排序之後,相同金額的訂單仍然保持下單時間從早到晚有序。

算法的屬性總結

排序算法 平均複雜度 最好時間複雜度 最壞情況時間複雜度 空間複雜度(原地) 是否基於比較 穩定性
冒泡排序 O(n2) O(n) O(n2) O(1) 穩定
插入排序 O(n2) O(n) O(n2) O(1) 穩定
選擇排序 O(n2) O(n2) O(n2) O(1) 不穩定
快速排序 O(nlogn) O(nlogn) O(n2) O(1) 不穩定
歸併排序 O(nlogn) O(nlogn) O(nlogn) O(n) 穩定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不穩定
桶排序 O(n) O(n) O(nlogn) O(n + m) 不是 穩定
計數排序 O(n) O(n) O(n) O(m) 不是 穩定
基數排序 O(n) O(n) O(n) O(n + m) 不是 穩定

n: 數據規模
m: 桶的個數

冒泡排序

基本的思想,從數組的頭部開始,每兩個元素比較大小並進行交換;然後把這一輪當中的最大值或最小值放置在數組的尾部;然後一直持續上述的過程。

示例代碼

public  void Bubble(int[] array) {
    
		for (int i = 0; i < array.length; i++) {
			boolean flag = false; //保存一個標記
			for (int j = 0; j < array.length - 1 - i; j++) {
				if (array[j] > array[j + 1]) {
					int temp = array[j];
					array[j] = array[j + 1];
					array[j + 1] = temp;
					flag = true;
				}
			}
			if(!flag) break; // 如果此次未發生交換,則表示已經排序完畢,提前終止
		}
	}

分析

時間複雜度:O(n2)

  • 最好情況下,數據是排序好的,只需要進行一次冒泡操作就結束也就是O(n)。
  • 最壞情況是,要排序的數據剛好是倒序的,每個數據都要冒泡,也就是 n 次,所以最壞是 O(n2)

插入排序

插入排序通過在分成兩個區間,一個爲已排序區間,而另外一個爲未排序區間。通過從未排序區間取出一個值,然後在已排序好的區間中找到位置插入。

示例代碼

public void quicklysort(int[] array,int length){
    if(length <= 1) return ;
    for(int i = 1;i < length;i++){
        //先保存待排序的數
        int value = array[i];
        //尋找插入位置
        int j = i - 1;
        for(; j >=0; j--){
            if(value <array[j]){
                array[j+1] = array[j];//數據的向後移動
            }else{
                break;
            }
        }
        array[j] = value;//插入數據
    }
}

分析

時間複雜度 O(n2)

  • 最好的情況是已經排序好的數字,那麼只需要每個數比較一次,複雜度爲 O(n)
  • 最壞的情況在倒序的時候,每次都要插入在第一位,意味着在往後移動數據的時候要移動的個數越來越多,時間複雜度爲 O(n2)

空間複雜度 O(1)
從代碼的過程,都不需要額外的存儲空間,所以空間複雜度爲 O(1)。

選擇排序

選擇也是通過區分已排序區間和未排序區間,通過在未排序區間找到最小的元素,添加到已排序區間的末尾。

示例代碼

public void selectSort(int[] array,int length){
    if(length<=1) return;
    for(int i = 0;i < length; i++){
        int min = i; //假定一個最小值
        for(int j = i+1;j < length;j++){
            if(array[min] > array[j]){
                min = j; // 保存最小值索引下標
            }
        }
        if(min != i){ // 最小值下標發生改變,交換元素
            int temp = array[min];
            array[min] = array[i];
            array[i] = temp;
        }
    }
}

分析

時間複雜度 O(2)
最好和最壞的情況,發現無論是尋找最小值,已經跟最小值的比較(它每個都要比較一次),都跟原本數組的前後順序無關。

空間複雜度
不需要額外存儲空間,所以空間複雜度爲 O(1) 。

不是穩定排序算法
因爲算法的原理就是,在未排序的區間裏面尋找最小值,如果是像相同的值,則靠後的最小值,會被優先插到前面來。交換了位置,破壞了穩定性。

選擇 冒泡 插入的比較

冒泡會比選擇高效些,因爲冒泡可以提前終止循環(在沒有發生交換元素的情況下)。但是插入會比冒泡高效些,在交換元素的部分,冒泡所要執行的語句比較多,需要第三個變量來輔助交換,而插入則並不需要。

歸併排序

核心思想,就是把要排序的數組,從中間分成前後兩部分,然後對前後兩個部分分別排序,再將排序好的兩部分合併在一起,這樣整個數組就有序了。

示例代碼

//n 爲數組長度
public void marge_sort(int[] array,int n) {
		marge_sort_c(array,0,n-1);
}
//s 數組開始左邊,e 數組結束座標
public void marge_sort_c(int[] array, int s, int e) {
		if (s >= e)
			return;
		int center = (e + s) / 2;
		marge_sort_c(array, s, center);
		marge_sort_c(array, center+1, e);
		marge1(array, s, center, e);
	}
//s 開始座標,e 結束座標,c爲中間點
public void marge(int[] array,int s,int c,int e){
    int i = s;
    int j = c+1;
    int k = 0;//記錄臨時數組的當前位置
    int[] temp = new int[e-s+1];//申請一個臨時數組
    //將兩個區間中,小的值先插入到數組中
    while (i <= c && j <= e) {
		if (array[i] < array[j]) {
			temp[k++] = array[i++];
		}else {
			temp[k++] = array[j++];
		}
	}
    //判斷哪個區間的數插入完畢
    int start = j;
	int end = e;
	if(i <= c) {
		start = i;
		end = c;
	}
    //剩餘的區間直接插入到臨時數組中
    while(start<=end) {
		temp[k++] = array[start++];
	}
    //將臨時數組(temp)拷貝到原始數組(array)
    for(i = 0;i <= e-s;i++) {
    	array[s + i] = temp[i];
	}
    
}

另一種方法,設置哨兵,在兩個區間的邊緣設置一個最大值作爲哨兵,當其中一個區間遇到哨兵的時候,另外一個區間的數不會在比這個哨兵大,就會全部放入原數組中。

/* 前面是一樣的 */
//s 開始座標,e 結束座標,c爲中間點
public void marge1(int[] array,int s,int c,int e) {
	int n1 = c - s + 1;
	int n2 = e - c;
	//申請兩個臨時數組用來保存兩個分區,多出一個位置來設置哨兵
	int[] left = new int[n1 + 1];
	int[] right = new int[n2 + 1];
	
	for (int i = 0; i < n1; i++) {
		left[i] = array[s + i];
	}
	//設置哨兵
	left[n1] = Integer.MAX_VALUE;
	for (int i = 0; i < n2; i++) {
		right[i] = array[c + i + 1];
	}
	//設置哨兵
	right[n2] = Integer.MAX_VALUE;
	int j = 0;
	int k = 0;
	for(int i = s; i <= e; i ++) {
	    //當遇到哨兵的時候,就把剩下的直接放到原數組
		if(left[j]<=right[k]) {
			array[i] = left[j++];
		}else {
			array[i] = right[k++];
		}
	}
}

複雜度分析

時間複雜度 O(nlogn)
無論最好情況,最壞情況,平均情況都是 O(nlogn)

空間複雜度 O(n)
主要的操作在 merge() 函數階段,儘管每次的合併都需要申請額外的存儲空間給臨時數組,但是這些空間在合併完之後就釋放了。我們知道 cpu 只會在一個函數執行,也就是當前就一個空間在被使用,這個內存空間最大不會超過 array 數組大小,所以空間複雜度爲 O(n)

穩定排序算法
歸併排序的比較發生在合併那個,在 merge() 中,如果兩個相同的數,一個數在
s-c 中,另外一個在 (c+1) - e 中,那麼前面那個相同的會被先放到 temp 數組中,這也就保證了先後順序不變,是個穩定排序算法。

快速排序

快排就是,把要排序的數組 p 到 r ,我們選擇其中任意一個數據作爲 pivot (分區點),把比這個 pivot 小的放在左邊,比 pivot 大的放到右邊,將 pivot 放中間。這樣數組分成了三份。然後就這樣分治和遞歸下去,當區間縮小到爲 1 的時候,那麼數據就有序了。

示例代碼

//n 爲數組長度
public void quick_sort(int[] array ,int n) {
	quick_sort_c(array,0,n-1);
}
public void quick_sort_c(int[] array,int p,int r) {
	if(p >= r) return;
	//q 爲 pivot 得位置	
	int q = partition(array,p,r);
	quick_sort_c(array,p,q-1);
	quick_sort_c(array,q+1,r);
}
private int partition(int[] array, int p, int r) {
    //默認取數組最後一個爲 pivot值
	int pivot = array[r];
	int i = p;//用來記錄比 pivot 大的位置
	//這裏將比 pivot 小得值放到左側
	for(int j = p; j < r; j ++) {
	    
		if(array[j]<pivot) {
			int temp = array[j];
			array[j] = array[i];
			array[i] = temp;
				i++;
		}
	}
	//在循環結束後,把最後一個比 pivot 大的值放到右邊
	array[r] = array[i];
	array[i] = pivot;
	return i;
}

這裏的思想很巧妙,因爲最初的想法是借用兩個額外的數組來保存分別比 pivot 大和小的值,之後進行合併。但是會消耗更多額外的空間。

這裏借用比較,利用 i 將數組分爲兩部分, p~i-1爲小於 pivot 的,i~r-1 爲大於 pivot 的,每次我們都從 i~r-1 取一個數,比 pivot 小的就插入到 p~i-1 尾部,也就是和 array[i] 交換,因爲 i 對應的值是比 pivot 大的,就把小的值移出了 i ~ r-1的空間。

最後 i 在與 pivot 進行交換。我們知道,交換數組的操作,是可以在 O(1) 的時間複雜度內完成,這樣就節省了空間和時間。

分析

時間複雜度 O(nlogn)
在普通情況下複雜度 O(n) ,但是在極端情況下,比如數組中的數據原來已經是有序的了,比如 1,3,5,6,8。如果我們每次選擇最後一個元素作爲 pivot,那每次分區得到的兩個區間都是不均等的。我們需要進行大約 n 次分區操作,才能完成快排的整個過程。每次分區我們平均要掃描大約 n/2 個元素,這種情況下,快排的時間複雜度就從 O(nlogn) 退化成了 O(n2)。

不穩定排序算法
如果是相同的值,比如數組 4,5,6,5 來說,取最後一位 5 爲 pivot ,當對比到數組第二位 5 的時候循環中交換就把會發生,知道最後 pivot 與 i 發生交換,那麼前面順序就變了。

快排 歸併 的比較

  • 快排是從上到下依次縮小區間下來的(先分區,在處理子問題),而歸併則是從下往上排序區間上來的(先處理子問題,再合併區間)。
  • 快排的了原地排序,解決了歸併帶來的空間消耗問題

桶排序

和桶類似,就是每個桶對應數據的範圍,對應着桶的容量似的,把對應的數字放入到對應的桶中,然後在對桶中的數據在進行排序。

image

桶排序爲什麼時間複雜度 爲O(n) ?

如果要排序的數據有 n 個,我們把它們均勻地劃分到 m 個桶內,每個桶裏就有 k=n/m 個元素。每個桶內部使用快速排序,時間複雜度爲 O(k * logk)。m 個桶排序的時間複雜度就是 O(m * k * logk),因爲 k=n/m,所以整個桶排序的時間複雜度就是 O(n*log(n/m))。當桶的個數 m 接近數據個數 n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的時間複雜度接近 O(n)。

示例代碼

     /**
     * 桶排序
     *
     * @param arr 數組
     * @param bucketSize 桶容量
     */
    public static void bucketSort(int[] arr, int bucketSize) {
        if (arr.length < 2) {
            return;
        }

        // 數組最小值
        int minValue = arr[0];
        // 數組最大值
        int maxValue = arr[1];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] < minValue) {
                minValue = arr[i];
            } else if (arr[i] > maxValue) {
                maxValue = arr[i];
            }
        }

        // 桶數量
        int bucketCount = (maxValue - minValue) / bucketSize + 1;
        int[][] buckets = new int[bucketCount][bucketSize]; 
        int[] indexArr = new int[bucketCount]; // 用來記錄每個桶已經保存的數量

        // 將數組中值分配到各個桶裏
        for (int i = 0; i < arr.length; i++) {
            int bucketIndex = (arr[i] - minValue) / bucketSize;
            if (indexArr[bucketIndex] == buckets[bucketIndex].length) {
                ensureCapacity(buckets, bucketIndex);
            }
            buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];
        }

        // 對每個桶進行排序,這裏使用了快速排序
        int k = 0;
        for (int i = 0; i < buckets.length; i++) {
            if (indexArr[i] == 0) {
                continue;
            }
            quickSortC(buckets[i], 0, indexArr[i] - 1);
            for (int j = 0; j < indexArr[i]; j++) {
                arr[k++] = buckets[i][j];
            }
        }
    }
    
    /**
     * 數組擴容
     *
     * @param buckets
     * @param bucketIndex
     */
    private static void ensureCapacity(int[][] buckets, int bucketIndex) {
        int[] tempArr = buckets[bucketIndex];
        int[] newArr = new int[tempArr.length * 2];
        for (int j = 0; j < tempArr.length; j++) {
            newArr[j] = tempArr[j];
        }
        buckets[bucketIndex] = newArr;
    }

條件

  1. 數據需要很容易就能劃分成 m 個桶
  2. 桶之間有着天然的大小順序
  3. 桶中的數據是均勻分佈的
    1. 如果所有數據都在一個桶中,將會退化成 O(nlogn)

試用的場景

適用於在外部磁盤中的排序。所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。

比如說我們有 10GB 的訂單數據,我們希望按訂單金額(假設金額都是正整數)進行排序,但是我們的內存有限,只有幾百 MB,沒辦法一次性把 10GB 的數據都加載到內存中。這個時候該怎麼辦呢?

可以先查看桶中的數據範圍,如果是 1 到 100 萬,那麼我們可以分 100 個桶,桶中的範圍分別是 1 ~ 1000元範圍內,第二個桶 1001~2000範圍內… 依此類推。最後對每個桶的對應的文件按照順序進行編號。

那麼就會有 100 個小文件,每次都導入一個文件來進行內部排序即可。

計數排序

和桶排序類似,這次如果排序的 n 個數,最大值爲 k ,那麼我們就安排 k 個桶,這樣就省去了我們要對桶內進行排序的時間,每個桶對應着一個數字,桶中則記錄着該數字出現的次數。

排序原理

假設只有 8 個考生,分數在 0 到 5 分之間。這 8 個考生的成績我們放在一個數組 A[8]中,它們分別是:2,5,3,0,2,3,0,3。

考生的成績從 0 到 5 分,我們使用大小爲 6 的數組 C[6]表示桶,其中下標對應分數。不過,C[6]內存儲的並不是考生,而是對應的考生個數。像我剛剛舉的那個例子,我們只需要遍歷一遍考生分數,就可以得到 C[6]的值。

image
從圖中可以看出,分數爲 3 分的考生有 3 個,小於 3 分的考生有 4 個,所以,成績爲 3 分的考生在排序之後的有序數組 R[8]中,會保存下標 4,5,6 的位置。
image

那我們如何快速計算出,每個分數的考生在有序數組中對應的存儲位置呢?
我們先對數組 C 進行順序求和,C[k] 保存的是小於等於 k 的人數有多少個。如下圖
image
如 C[3] 就是小於等於 3 的一共有 7 個。

我們假設一個數組 R 用來保存排序好的數組,然後我們從後往前遍歷數組 A,當取出 3 時,從數組 c 中獲得 7,也就是小於等於 3 這個分數的人有 7 個,所以這個 3 放在 R[6] 中,之後 C[3] - 1。接下來 取出 0,從數組 c 中獲得 2,也就是小於等於 0 有兩個人,所以把這個 0 放在 R[1],然後 c[0] - 1,之後依此類推。就是一個排序後的數組了

代碼示例


// 計數排序,a是數組,n是數組大小。假設數組中存儲的都是非負整數。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查找數組中數據的範圍
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申請一個計數數組c,下標大小[0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 計算每個元素的個數,放入c中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 臨時數組r,存儲排序之後的結果
  int[] r = new int[n];
  // 計算排序的關鍵步驟,有點難理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 將結果拷貝給a數組
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

條件

計數排序只能用在數據範圍不大的場景中,如果數據範圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化爲非負整數。

基數排序

這裏舉個例子,比如我們要個十萬個手機號進行從小到大排序,們之前講的快排,時間複雜度可以做到 O(nlogn),還有更高效的排序算法嗎?桶排序、計數排序能派上用場嗎?手機號碼有 11 位,範圍太大,顯然不適合用這兩種排序算法。針對這個排序問題,有沒有時間複雜度是 O(n) 的算法呢

我們知道對於手機號的前幾位,如果前面的幾位已經對比出大小了,那麼就不用再對比後面的幾位了。

這裏我們利用穩定性的優勢來排序,我們知道穩定排序就是相同的數字,他們在經過排序之後,前後的順序不會改變。我們從後往前進行排序,先按照最後一位數字進行排序,然後倒數第二位,依此類推。每一位數字在進行桶排序或者計數排序。那麼每個數字都是的排序的時間複雜度就是 O(n) ,一共有 K 位,那總的時間複雜度就是 K*O(n) ,如果 k 足夠小,比如我們這裏是 11 ,那 k 就可以近乎忽略,總的時間複雜度位 O(n)。

這裏要注意,因爲如果是非穩定排序算法,那最後一次排序只會考慮最高位的大小順序,完全不管其他位的大小關係,那麼低位的排序就完全沒有意義了。

示例代碼

這裏利用了計數排序

public class RadixSort {

    /**
     * 基數排序
     *
     * @param arr
     */
    public static void radixSort(int[] arr) {
        int max = arr[0];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
        }

        // 從個位開始,對數組arr按"指數"進行排序
        for (int exp = 1; max / exp > 0; exp *= 10) {
            countingSort(arr, exp);
        }
    }

    /**
     * 計數排序-對數組按照"某個位數"進行排序
     *
     * @param arr
     * @param exp 指數
     */
    public static void countingSort(int[] arr, int exp) {
        if (arr.length <= 1) {
            return;
        }

        // 計算每個元素的個數
        int[] c = new int[10];
        for (int i = 0; i < arr.length; i++) {
            c[(arr[i] / exp) % 10]++;
        }

        // 計算排序後的位置
        for (int i = 1; i < c.length; i++) {
            c[i] += c[i - 1];
        }

        // 臨時數組r,存儲排序之後的結果
        int[] r = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            r[c[(arr[i] / exp) % 10] - 1] = arr[i];
            c[(arr[i] / exp) % 10]--;
        }

        for (int i = 0; i < arr.length; i++) {
            arr[i] = r[i];
        }
    }
}

條件

基數排序的使用也是很嚴格的,因爲基數排序要求,其中的數字要求要能夠分割成 ”位“,並且每個”位“之間有着明顯的遞進關係。像是比如排序手機號碼,只要知道前面的幾位比後面的幾位來得大,就不需要進行比較了。

桶排序 計數排序 計數排序 的總結

三個都用到了桶的概念

  • 桶排序:每個桶存儲一定的範圍,然後對內部使用其他高效的排序算法
  • 計數排序:是每個桶存儲同一個鍵值,之後取出來合併就行
  • 基數排序:則是根據每個鍵值的每位數子來劃分桶

堆排序

什麼是堆

堆是一種二叉樹,只是這二叉樹對於數字的擺放是有要求的。

  • 首先是一個完全二叉樹
  • 每個節點的值必須大於等於(小於等於)它的子樹包括子樹每個節點的值。

大頂堆就是它的每個節點的值必須是大於等於它的子樹的節點的值。小頂堆就是它的每個節點的值必須是小於等於它的子樹的節點的值。

如圖所示:
image
其中 1 和 2 是大頂堆,3 是小頂堆,4 不是堆。

存儲堆的方式

利用數組存儲完全二叉樹是非常合適的,因爲利用數組的特性,我們可以單詞的利用數組下標來找到一個節點的左右子節點和父節點。

image

從圖中看到,例如下標爲 i 的節點的左子節點,就是下標爲 i * 2 的節點,右子節點就是下標爲 i * 2 + 1 的節點,父節點就是下標爲 i/2 的節點。

往堆中插入一個元素

堆化

就是插入一個元素後,重新進行調整,讓其重新滿足堆的特性,這個過程就是堆化。

堆化的兩種形式,一種從下往上和從上往下堆化,以下用大頂堆示例。

從下往上堆化

先將數據插到數組的最後,我們讓新插入的節點與父節點對比大小。如果不滿足子節點小於父節點的關係,就交換節點,一直重複這個過程,直到父子節點之間都滿足關係。
image
代碼示例:

public class Heap {
	// 注意我這裏的數組第一位不存儲數據,只爲讓後面計算父節點方便
	private int[] heap;//數組,從下標 1 開始存儲數據
	private int n; //堆的最大容量
	private int count;// 當前堆中已經存儲的堆的數據個數
	
	public Heap(int n) {
		heap = new int[n + 1];//多賦值一個,下標 0 的不需要賦值
		this.n = n;
		count = 0;
	}
	public void insert(int data) {
		if(count >= n) return; // 如果堆中已滿則不插入
		heap[++count] = data; // 從 下標 1 開車存儲數據
		int i = count;
		// 對於當前插入的元素堆化,從下往上堆化
		while(i / 2 >0 && heap[i/2] <= heap[i]) {
			int temp = heap[i/2];
			heap[i/2] = heap[i];
			heap[i] = temp;
			i = i / 2;
		}
	}
}

刪除堆頂元素

如果建造的是大頂堆,那麼堆頂元素就是最大值。將堆頂元素刪除後,我們把最後一個元素拿到第一位,然後通過對比大小關係,然後互換位置,實現從上而下的堆化。

// 注意我這裏的數組第一位不存儲數據,只爲讓後面計算父節點方便
public int removeMax() {
		if(count==0) return -1;
		int data = heap[1];
		heap[1] = heap[count--];
		heapify(heap,count,1);
		return data;
	
}
// n 爲需要排序的堆長度 , i 爲當前需要堆化的下標,從上往下堆化
private void heapify(int[] heap,int n, int i) {
	while(true) {
		int maxPos = i;
		if(i * 2 <= n && heap[i * 2] > heap[i]) {
			maxPos = i * 2;
		}
		if(i * 2 +1 <= n && heap[ i * 2 + 1] > heap[maxPos]) {
			maxPos = i * 2 + 1;
		}
		if(maxPos == i) break;
		int temp = heap[i];
		heap[i] = heap[maxPos];
		heap[maxPos] = temp;
		i = maxPos;
	}
}
	

怎麼實現堆排序

建堆

第一種

利用插入的方式將數據逐個插入到堆中,組成了堆。

第二種

從下往上,我們知道葉子節點往下堆化只會跟自己比較,所以從非葉子節點開始進行依次堆化比較。我這裏採用第二種

// 注意我這裏的數組第一位不存儲數據,只爲讓後面計算父節點方便
// arr 中的數據需要是在 1~n 位置,n 爲數據個數
public static void buildHeap(int[] arr,int n) {
		for(int i = n/2; i >= 1; i --) {
			heapify(arr,n, i);
		}
}
// n 爲需要排序的堆長度 , i 爲當前需要堆化的下標,從上往下堆化
private static void heapify(int[] heap,int n, int i) {
	while(true) {
		int maxPos = i;
		if(i * 2 <= n && heap[i * 2] > heap[i]) {
			maxPos = i * 2;
		}
		if(i * 2 +1 <= n && heap[ i * 2 + 1] > heap[maxPos]) {
			maxPos = i * 2 + 1;
		}
		if(maxPos == i) break;
		int temp = heap[i];
		heap[i] = heap[maxPos];
		heap[maxPos] = temp;
		i = maxPos;
	}
}

排序

建完堆之後,就是排序問題了,我們知道大頂堆的堆頂就是最大值了,那麼我們把它跟數組最後一個元素交換,那麼最大值就被放到數組末尾了。

交換之後,排除掉最後一個元素,只剩下 n-1 個元素,此時在對這 n - 1 個元素進行堆化。直到只剩下一個元素,排序就完成了。

// 注意我這裏的數組第一位不存儲數據,只爲讓後面計算父節點方便
// 數組中的數據需要是在 1~n 位置, n 爲數據個數
public static  void sort(int[] arr,int n) {
		buildHeap(n);
		int k = n;
		while(k > 1) {
			swap(arr,k,1);
			--k;
			heapify(k, 1);
		}
}
public static void swap(int[] arr,int i,int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
}

分析

不是穩定排序算法
排序過程存在堆頂與最後一個交換,順序就可能發生了改變。

建堆的時間複雜度 O(n)
因爲我們的每個節點都要進行依次堆化,但是這是從非葉子節點,所以是 n/2 + 1 個節點,而每個節點的堆化時間爲 O(logn),因爲每次堆化過程
,順着節點的路徑進行比較,跟樹的高度有關係,樹的高度又不會超過 log2n,所以對話的時間就是 O(logn)。那麼建堆理論總的時間複雜度爲 O(nlogn)。

因爲需要堆化的節點從倒數第二層開始,每個節點的堆化過程,需要比較和交換節點個數,跟這個節點的高度成正比,我們把每一層對應的節點的高度求和,最後的時間複雜度就爲 O(n)。

排序時間複雜度O(nlogn)
因爲每個節點的堆化爲 O(logn) ,所以 n 個節點就是 O(nlogn)

總的時間複雜度爲 O(nlogn)

對比快排

  • 堆排序的數據訪問方式沒有快排友好
    • 因爲堆排序是對數組下標跳着訪問,不想快排局部有序的訪問
  • 對於同樣的數據,排序過程堆排序的交換次數要比快排多
    • 堆排序每次都要建堆,那麼會把原來的數組打亂依次,這樣可能導致比較次數增多

以上的部分圖片來自於極客時間的 “數據結構與算法之美” ,該篇是學習總結。

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