11-14|排序

如何衡量一個排序算法?

  • 執行效率
  1. 最好情況、最壞情況、平均情況時間複雜度。對於要排序的數據,有的接近有序,有的完全無序。有序度不同的數據,對於排序的執行時間肯定是有影響的,我們要知道排序算法在不同數據下的性能表現。
  2.  時間複雜度的係數、常數 、低階。實際的軟件開發中,我們排序的可能是 10 個、100 個、1000 個這樣規模很小的數據,所以,在對同一階時間複雜度的排序算法性能對比的時候,我們就要把係數、常數、低階也考慮進來。
  3. 比較次數和交換(或移動)次數。基於比較的排序算法的執行過程,會涉及兩種操作,一種是元素比較大小,另一種是元素交換或移動
  • 內存消耗
  • 穩定性。如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變。很多數據結構和算法課程,在講排序的時候,都是用整數來舉例,但在真正軟件開發中,我們要排序的往往不是單純的整數,而是一組對象,我們需要按照對象的某個 key 來排序。比如c++中的pair。

對於穩定性,舉例子:我們現在要給電商交易系統中的“訂單”排序。訂單有兩個屬性,一個是下單時間,另一個是訂單金額。如果我們現在有 10 萬條訂單數據,我們希望按照金額從小到大對訂單數據排序。對於金額相同的訂單,我們希望按照下單時間從早到晚有序。

解決思路是這樣的:我們先按照下單時間給訂單排序,注意是按照下單時間,不是金額。排序完成之後,我們用穩定排序算法,按照訂單金額重新排序。

冒泡排序(Bubble Sort)——帶標誌位

void bubble_sort_flag(int *L, int len) {
	int flag = 1;
	for (int i = 0; i < len && flag; i++) {
		flag = 0;
		for (int j = len - 1; j > i; j--) {
			if (L[j] < L[j - 1]) {
				swap(L[j], L[j - 1]);
				flag = 1;
			}
		}
	}
}

選擇排序(Selection Sort)

核心:分已排序區間和未排序區間。選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。

void select_sort(int *arr, int len) {
    int index = -1;
	for (int i = 0; i < len; i++) {
		index = i;
		for (int j = i + 1; j < len; j++) {
			if (arr[index] > arr[j])
				index = j;
		}
		if (i != index)    swap(arr[index], arr[i]);
	}
}

選擇排序是一種不穩定的排序算法。比如 5,8,5,2,9 這樣一組數據,使用選擇排序算法來排序的話,第一次找到最小元素 2,與第一個 5 交換位置,那第一個 5 和中間的 5 順序就變了,所以就不穩定了。

 

插入排序(Insertion Sort)

void insert_sort(int *L, int len) {
	for (int i = 1; i < len; i++) {
		if (L[i] < L[i - 1]) {
			int j = i - 1;//j表示待插入的位置,j是有序表的最後一個元素的位置
			int temp = L[i];//i表示無序表
			while (j >= 0 && temp < L[j]) {
				L[j + 1] = L[j];
				j--;
			}
            //表示找到了arr[j],新的數temp比它大,可以插在它後面。
            //就像撲克牌一樣,找到了滿足條件的數,應該插在後面。
			L[j + 1] = temp;
		}
	}
}

這三種時間複雜度爲 O(n2) 的排序算法中,冒泡排序、選擇排序,可能就純粹停留在理論的層面了,學習的目的也只是爲了開拓思維,實際開發中應用並不多,但是插入排序還是挺有用的。有些編程語言中的排序函數的實現原理會用到插入排序算法。

 

課後思考:

如果數據存儲在鏈表中,這三種排序算法還能工作嗎?如果能,那相應的時間、空間複雜度又是多少呢?

 

超越了O(n^2)的新排序算法

希爾排序(shell sort),平均時間複雜度O(nlogn~n^2),最好時間複雜度是O(n^1.3),最壞時間複雜度O(n^2)。

希爾排序,遞減增量排序算法,是插入排序的一種更高效的改進版本,是非穩定排序算法。

希爾排序是基於插入排序的以下兩點性質而提出改進方法的:

  • 插入排序在對幾乎已經排好序的數據操作時,效率高,即可以達到線性排序的效率。
  • 插入排序一般來說是低效的,因爲插入排序每次只能將數據移動一位。

基本有序:小的關鍵字基本在前面,大的基本在後面,不大不小的基本在中間
跳躍分割的策略:將相距某個“增量”的記錄組成一個子序列,這樣才能保證在子序列內分別進行直接插入排序後得到的結果是基本有序而不是局部有序
希爾排序的關鍵並不是隨便分組各自排序,而是將相隔某個“增量”的記錄組成一個子序列,實現跳躍式的移動,使得排序效率更高。

算法步驟:

  1. 分割子序列
  2. 將相距某個增量“increment”的記錄組成一個子序列
  3. 在每個子序列插入排序
void shell_sort(int *arr, int len) {
	for (int increment = len / 2; increment > 0; increment /= 2) {
		for (int i = increment; i < len; i++) { 
			/*多個子序列和插入排序的單個子序列不同
			單個子序列,分爲已排序和待排序,所以從待排序取出一個,需要去插入到已排序序列
			當分割成多個子序列,如果後面一個分割序列大於前面,不用插入
			[1,3,6,9]和[12,14]此時12不用再插入到前面一個子序列中*/
			if (arr[i] < arr[i - increment]) {
				int temp = arr[i];
				int j = i - increment;
				while (j >= 0 && temp < arr[j]) {
					arr[j + increment] = arr[j];
					j -= increment;
				}
				arr[j + increment] = temp;
			}
		}
	}
}

堆排序
堆:完全二叉樹,假設下標從0開始。下標i的結點左孩子是2i+1,右孩子2i+2
說起堆排序,我一直以爲需要構建二叉樹,因爲每次學習原理的時候都是用二叉樹來表示,後來發現,其實只是利用二叉樹的形,實則是利用數組下標的連接關係來實現。
    
堆排序的步驟(思想):

  1. 將待排序序列構造最大堆
  2. 將最大值的根節點和末尾交換,然後再調整將剩餘的n-1個序列重新構造成最大堆

時間複雜度:構建堆的時間複雜度是O(n),重建堆的時間複雜度O(nlogn),所以堆排序的時間複雜度爲O(nlogn)。

void heap_adjust(int *arr, int s, int m /*數組長度*/) {
	int temp = arr[s];//s表示傳入的節點,先把節點的值保存
	for (int i = 2 * s+1; i< m; i = i*2+1) {
		//i = 2*s+1表示左孩子,i<m因爲左孩子下標最大是數組最後一位m-1,i = 2*i+1找它的左孩子
		if (i+1 < m &&arr[i] < arr[i + 1])//i和i+1表示父節點s的左右孩子
			++i;
		if (temp >= arr[i])//如果根節點已經比孩子大了沒有必要再交換
			break;
		arr[s] = arr[i];//把孩子結點值賦值給根節點,注意此時兩者值都成了孩子結點。
		s = i;//對這個孩子結點再去比較
	}
	arr[s] = temp;//把暫時保存根節點的值temp給孩子結點
}

void heap_sort(int *arr, int len) {
	//將現在的待排序序列構建成一個大頂堆,從下而上的調整
	for (int i = len / 2; i >= 0; i--) {
		//爲什麼要從i = len/2開始,因爲觀察一個堆可以發現,節點 :len/2以下的纔會是父節點
		heap_adjust(arr, i, len);
	}
	//逐步將每個最大值的根節點與末尾元素交換,並再調整其成爲大頂堆。
	for (int i = len - 1; i > 0; i--) {
		swap(arr[0], arr[i]);//將堆頂記錄和最後一個記錄交換
		heap_adjust(arr, 0, i-1);
	}
}

冒泡排序、插入排序、選擇排序這三種排序算法,它們的時間複雜度都是 O(n^2),比較高,適合小規模數據的排序。歸併排序和快速排序兩種時間複雜度爲 O(nlogn) 的排序算法。這兩種排序算法適合大規模的數據排序。

歸併排序(Merge Sort)

思想:如果要排序一個數組,我們先把數組從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個數組就都有序了。

歸併排序使用的就是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。分治是一種解決問題的處理思想,遞歸是一種編程技巧。

穩定性分析:歸併函數中有if(a[i] < a[j])語句,說明它需要兩兩比較,不存在跳躍。因此就是一種穩定的排序算法。

歸併算法時間複雜度和空間複雜度:

一趟歸併需要把相鄰first~last的記錄掃描一遍放到arr中,需要把待排序序列中所有記錄掃描一遍,因此耗費O(n)時間,由完全二叉樹的深度可知,整個歸併排序需要進行[log2n]次,因此總的時間複雜度O(nlogn);由於開闢了n個元素的額外數組,所以空間複雜度爲O(n)。

//1.合併兩個有序序列
void merge_array(int *arr, int first, int mid, int last, int* temp_arry) {
	int i = first, j = mid + 1;
	int k = 0;
	while (i <= mid && j <= last) {

		if (arr[i] <= arr[j]) {
			temp_arry[k++] = arr[i++];
		}
		else {
			temp_arry[k++] = arr[j++];
		}
	}
	while (i <= mid)	temp_arry[k++] = arr[i++];
	while (j <= last)	temp_arry[k++] = arr[j++];
	for (int count = 0; count < k; count++) {
		arr[first + count] = temp_arry[count];
	}
}
//2.歸併排序——遞歸實現
void merge_sort(int *arr, int left, int right, int *temp_arry) {
	if (left >= right)	return;
	int mid = left + (right - left) / 2;
	merge_sort(arr, left, mid, temp_arry);
	merge_sort(arr, mid + 1, right, temp_arry);
	merge_array(arr, left, mid, right, temp_arry);
}
void Merge_Sort(int *arr, int len) {
	int* p_arry = new int[len];
	merge_sort(arr, 0, len - 1, p_arry);
	delete[] p_arry;//刪除數組指針要特別說明
}

快速排序:

如果要排序數組中下標從 p 到 r 之間的一組數據,我們選擇 p 到 r 之間的任意一個數據作爲 pivot(分區點)。我們遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,數組 p 到 r 之間的數據就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。

下圖中選取的pivot是從最右邊開始的,不過代碼中我們選取的pivot是最左邊。

 

partition函數:選取的temp不斷交換,比它小的放左邊,比它大的放右邊,然後temp也在不斷變換。直到兩部分完全分開。
快排算法爲什麼一定要從右邊開始的原因:https://blog.csdn.net/w282529350/article/details/50982650
    
時間複雜度和空間複雜度:

平均時間複雜度O(n*logn),最好時間複雜度O(n*logn),最壞時間複雜度O(n^2)

(當排序數組時有序的情況,逆序的情況,快速排序算法時間複雜度退化到 O(n2) 的概率非常小,我們可以通過合理地選擇 pivot 來避免這種情況)

爲什麼數組有序的情況下時間複雜度最壞呢?

因爲有序你選取的pivot就是最大或者最小的,在partition函數就浪費大量比較時間。而且這也說明了爲什麼改進的快排要用三數取中,因爲我們每次選取的pivot都更希望是數組的中位數,這樣就能很好把數組分開。

快排的空間複雜度,本身是O(1)的,但是隨着你調用遞歸函數,不斷開闢臨時變量temp,所以遞歸帶來的額外空間複雜度O(logn)。

int partition(int *arr, int low, int high) {
	int temp = arr[low];
	while (low < high) {
		while (low < high && temp <= arr[high]) {
			--high;
		}
		swap(arr[low], arr[high]);
		while (low < high && temp >= arr[low]) {
			++low;
		}
		swap(arr[low], arr[high]);
	}
	return low;
}
//三數取中法,改進partition函數,要保證pivot不是最大或者最小的。
int partition_modified(int *arr, int low, int high) {
	int temp;
	int mid = low + (high - low) / 2;
	if (arr[low] > arr[high])	swap(arr[low], arr[high]);
	if (arr[mid] > arr[high])	swap(arr[mid], arr[high]);
	if (arr[mid] > arr[low])	swap(arr[low], arr[mid]);
	//交換完成之後,low數位上是整個序列左中右關鍵字的中間值
	temp = arr[low];
	while (low < high) {
		while (low < high && temp <= arr[high]) {
			--high;
		}
		swap(arr[low], arr[high]);
		while (low < high && temp >= arr[low]) {
			++low;
		}
		swap(arr[low], arr[high]);
	}
	return low;
}
void quicksort(int *arr, int low, int high) {
	if (low >= high)	return;
	int t = partition_modified(arr, low, high);
	//t的左邊都是小於,右邊都是比它大,像二分一樣,所以不用在把arr[t]排序
	quicksort(arr, low, t - 1);
	quicksort(arr, t + 1, high);
}

題目:O(n) 時間複雜度內求無序數組中的第 K 大元素。比如,4, 2, 5, 12, 3 這樣一組數據,第 3 大元素就是 4。

利用partiation函數,當 m = k - 1時,m:=partiation函數返回的下標,k := 求得第K大元素。

 

三種時間複雜度是 O(n) 的排序算法:桶排序、計數排序、基數排序。這三個算法是非基於比較的排序算法,都不涉及元素之間的比較操作。

桶排序(Bucket sort)

核心思想是將要排序的數據均勻分到幾個有序的桶裏,每個桶裏的數據再單獨進行排序。桶內排完序之後,再把每個桶裏的數據按照順序依次取出,組成的序列就是有序的了。

桶排序的時間複雜度分析:如果要排序的數據有 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)。

桶排序的侷限性:注意在覈心思想提到的“均勻分到”,其實是一個很苛刻的假設,如果有些桶裏的數據非常多,有些非常少,很不平均,那桶內數據排序的時間複雜度就不是常量級了。在極端情況下,如果數據都被劃分到一個桶裏,那就退化爲 O(nlogn) 的排序算法了。

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

應用場景:

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

我們可以先掃描一遍文件,看訂單金額所處的數據範圍。假設經過掃描之後我們得到,訂單金額最小是 1 元,最大是 10 萬元。我們將所有訂單根據金額劃分到 100 個桶裏,第一個桶我們存儲金額在 1 元到 1000 元之內的訂單,第二桶存儲金額在 1001 元到 2000 元之內的訂單,以此類推。每一個桶對應一個文件,並且按照金額範圍的大小順序編號命名(00,01,02…99)。

理想的情況下,如果訂單金額在 1 到 10 萬之間均勻分佈,那訂單會被均勻劃分到 100 個文件中,每個小文件中存儲大約 100MB 的訂單數據,我們就可以將這 100 個小文件依次放到內存中,用快排來排序。等所有文件都排好序之後,我們只需要按照文件編號,從小到大依次讀取每個小文件中的訂單數據,並將其寫入到一個文件中,那這個文件中存儲的就是按照金額從小到大排序的訂單數據了。

不過,你可能也發現了,訂單按照金額在 1 元到 10 萬元之間並不一定是均勻分佈的 ,所以 10GB 訂單數據是無法均勻地被劃分到 100 個文件中的。有可能某個金額區間的數據特別多,劃分之後對應的文件就會很大,沒法一次性讀入內存。這又該怎麼辦呢?

針對這些劃分之後還是比較大的文件,我們可以繼續劃分,比如,訂單金額在 1 元到 1000 元之間的比較多,我們就將這個區間繼續劃分爲 10 個小區間,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果劃分之後,101 元到 200 元之間的訂單還是太多,無法一次性讀入內存,那就繼續再劃分,直到所有的文件都能讀入內存爲止。

 

計數排序(Counting sort)

當要排序的 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] 數組順序求和,C[6] 數組存儲的數據就變成了下面這樣子。C[k] 裏存儲小於等於分數 k 的考生個數

三個數組:

  •     待排序數組:arr
  •     最大值減去最小值再加1大小的計數數組:count
  •     排好序的數組:psort

計數排序的基本思想爲一組數在排序之前先統計這組數中其他數小於等於這個數的個數,則可以確定這個數的位置。

算法的步驟如下:

  1. 找出待排序的數組中最大和最小的元素
  2. 統計數組中每個值爲i的元素出現的次數,存入數組count的第i項
  3. 對所有的計數累加(從count中的位置爲1的元素開始,每一項和前一項相加)
  4. 反向填充目標數組:將每個元素i放在新數組的第count(i)項,每放一個元素就將count(i)減去1(反向填充爲了保持穩定性)

思考一個問題:一開始已經在count數組中統計了每個元素出現的次數,根據這個不就可以直接輸出排序好的數組嗎?
       如果數據跨度特別大,本來是n個數字,count數組卻是2n或者更多?如果你某個元素出現了k次,那麼你在時間複雜度上不就又變成了O(k*2*n)?所以不能直接利用hash的性質來排序。
總結:計數排序做題要畫出三個數組,根據count數組保存的是比它小的元素個數來寫就很簡單。

void CountSort(int *arr, int len) {
	if (arr == NULL)	return;
	int max = arr[0], min = arr[0];
	for (int i = 1; i < len; i++) {
		if (arr[i] > max)	max = arr[i];
		if (arr[i] < min)	min = arr[i];
	}
	int size = max - min + 1;//計數排序的缺點就是數據跨度特別大要開闢的額外空間很大。
	int *count = (int*)malloc(sizeof(int)*size);
	memset(count, 0, sizeof(int)*size);
	for (int i = 0; i < len; i++) count[arr[i] - min]++;//確定每個元素出現次數
	for (int i = 1; i < size; i++)	count[i] += count[i - 1];
	//比i小的元素出現次數
	int* psort = (int*)malloc(sizeof(int)*len);
	memset(psort, 0, sizeof(int)*len);
	for (int i = len - 1; i >= 0; i--) {
		int pos = count[arr[i] - min]- 1;
		psort[pos] = arr[i];
		//如果有count[pos] = 3,說明有3個比它小,直接在psort[3]插入就可以了,因爲前面還有0 1 2
		count[arr[i] - min]--;
	}
	for (int i = 0; i < len; i++) {
		arr[i] = psort[i];
	}
	free(count);
	free(psort);
	count = NULL;
	psort = NULL;
}

應用場景:

我們都經歷過高考,高考查分數系統你還記得嗎?我們查分數的時候,系統會顯示我們的成績以及所在省的排名。如果你所在的省有 50 萬考生,如何通過成績快速排序得出名次呢?

考生的滿分是 900 分,最小是 0 分,這個數據的範圍很小,所以我們可以分成 901 個桶,對應分數從 0 分到 900 分。根據考生的成績,我們將這 50 萬考生劃分到這 901 個桶裏。桶內的數據都是分數相同的考生,所以並不需要再進行排序。我們只需要依次掃描每個桶,將桶內的考生依次輸出到一個數組中,就實現了 50 萬考生的排序。因爲只涉及掃描遍歷操作,所以時間複雜度是 O(n)。

 

基數排序(Radix sort)

算法步驟:(以排序爲整數非負整數舉例,也可以是字母)

  1. 將所有待排序整數(注意,必須是非負整數)統一爲位數相同的整數,位數較少的前面補零。一般用10進制,也可以用16進制甚至2進制。所以前提是能夠找到最大值,得到最長的位數,設 k 進制下最長爲位數爲 d 。
  2. 從最低位開始,依次進行一次穩定排序。這樣從最低位一直到最高位排序完成以後,整個序列就變成了一個有序序列。

舉個例子,有一個整數序列,0, 123, 45, 386, 106,下面是排序過程:

第一次排序,個位,000 123 045 386 106,無任何變化
第二次排序,十位,000 106 123 045 386
第三次排序,百位,000 045 106 123 386
最終結果,0, 45, 106, 123, 386, 排序完成。

應用場景1:假設我們有 10 萬個手機號碼,希望將這 10 萬個手機號碼從小到大排序,你有什麼比較快速的排序方法呢?

手機號碼有 11 位,範圍太大,顯然不是和計數排序和桶排序。我們可以按照以前講的“穩定性”問題提到的訂單問題。一個訂單有兩個屬性,時間戳和金額。首先按照時間戳排序依次,再按照金額排序依次。所以這裏也可以用相同的思路。先按照最後一位來排序手機號碼,然後,再按照倒數第二位重新排序,以此類推,最後按照第一位重新排序。經過 11 次排序之後,手機號碼就都有序了。(注意一定要是穩定的排序算法,所以我們可以用桶排序或者計數排序,時間複雜度爲O(n))。

應用場景2:比如我們排序牛津字典中的 20 萬個英文單詞,最短的只有 1 個字母,最長的我特意去查了下,有 45 個字母,中文翻譯是塵肺病。對於這種不等長的數據,基數排序還適用嗎?實際上,我們可以把所有的單詞補齊到相同長度,位數不夠的可以在後面補“0”,因爲根據ASCII 值,所有字母都大於“0”,所以補“0”不會影響到原有的大小順序。這樣就可以繼續用基數排序了。

要使用基數排序的數據,其中的某一位必須有明確的遞進關係,比如數字0~9,字母a~b。其次,每一位數據範圍不能太大。否則就不是一個O(n)算法。

 

如何實現一個通用的,高性能排序算法?

從時間複雜度考慮放棄O(n^2)的,選擇O(n^2)的,再從空間複雜度考慮,不會使用歸併,即使他平均,最壞時間複雜度都是O(nlogn),因爲額外的O(n)空間複雜度。所以一般選取快排,那麼如何避免快排的最壞時間複雜度O(n^2)的情況?在於pivot的選取。常見有三數取中法:首,中,尾取三個數,選取其平均值作爲pivot。

 

舉例分析C中排序函數qsort()

對於小數據量的排序,qsort()優先使用歸併排序;排序數據量比較大的時候,會改爲qsort()來排序,並且使用三數取中來選取pivo;當數據量小於等於4的時候,qsort()又會退化爲插入排序。在快排調用時一定要注意合理的pivot選擇,避免遞歸太深等等。

tips:在小數據量面前,O(n^2)的時間複雜度並不一定比O(nlogn)的算法執行時間長。在大O複雜度表示法中,我們會省略低階、係數和常數,也就是說,O(nlogn) 在沒有省略低階、係數、常數之前可能是 O(knlogn + c),而且 k 和 c 有可能還是一個比較大的數。假設 k=1000,c=200,當我們對小規模數據(比如 n=100)排序時,n2的值實際上比 knlogn+c 還要小。

knlogn+c = 1000 * 100 * log100 + 200 遠大於10000

n^2 = 100*100 = 10000

課後思考:

C++中sort()是怎麼實現的呢?用了哪些優化技巧?

https://blog.csdn.net/qq_34269988/article/details/103224335

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