十張動圖帶你搞懂排序演算法(go實現版本)

Sunshine-鬆發表於2020-11-29

排序演算法

簡介:排序演算法在我們日常開發中、面試中都會使用到,所以就打算弄一個合集,把常用的排序演算法用Go實現一下。如果你還不會這些那就說不過去了哦~~~。

程式碼已經收錄到我的github,需要的自取:https://github.com/asong2020/go-algorithm/tree/master/sort

演算法分類

我們常見的排序演算法可以分為兩大類:

  • 比較類排序:通過比較來決定元素間的相對次序,由於其時間複雜度不能突破O(nlogn),因此也稱為非線性時間比較類排序。
  • 非比較類排序:不通過比較來決定元素間的相對次序,它可以突破基於比較排序的時間下界,以線性時間執行,因此也稱為線性時間非比較類排序。

在這裡插入圖片描述

演算法複雜度概覽

排序演算法時間複雜度(平均)時間複雜度(最壞)時間複雜度(最優)空間複雜度穩定性
氣泡排序O(?2)O(?2)O(?)O(1)穩定
快速排序O(nlogn)O(?2)O(nlogn)O(nlogn)~O(n)不穩定
插入排序O(?2)O(?2)O(?)O(1)穩定
希爾排序O(nlogn)~O(?2)O(?2)O(?1.3)O(1)不穩定
選擇排序O(?2)O(?2)O(?2)O(1)穩定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不穩定
歸併排序O(nlogn)O(nlogn)O(nlogn)O(n)穩定
計數排序O(n+k)O(n+k)O(n+k)O(k)穩定
桶排序O(n+k)O(?2)O(?2)O(n+k)穩定
基數排序O(n*k)O(n*k)O(n*k)O(n+k)穩定

時間、空間複雜度

在這裡也簡單解釋一下什麼是時間、空間複雜度吧。具體計算方法就不在這篇文章講解了。

1. 時間複雜度

時間複雜度是指執行這個演算法所需要的計算工作量,其複雜度反映了程式執行時間**「隨輸入規模增長而增長的量級」,在很大程度上能很好地反映出演算法的優劣與否。一個演算法花費的時間與演算法中語句的「執行次數成正比」,執行次數越多,花費的時間就越多。一個演算法中的執行次數稱為語句頻度或時間頻度,記為T(n),其中n稱為問題的規模,當n不斷變化時,它所呈現出來的規律,我們稱之為時間複雜度。比如:imgimg,雖然演算法的時間頻度不一樣,但他們的時間複雜度卻是一樣的,「時間複雜度只關注最高數量級,且與之係數也沒有關係」**。通常一個演算法由控制結構(順序,分支,迴圈三種)和原操作(固有資料型別的操作)構成,而演算法時間取決於兩者的綜合效率。

2. 空間複雜度

空間複雜度是對一個演算法在執行過程中臨時佔用儲存空間大小的量度,所謂的臨時佔用儲存空間指的就是程式碼中**「輔助變數所佔用的空間」,它包括為參數列中「形參變數」分配的儲存空間和為在函式體中定義的「區域性變數」**分配的儲存空間兩個部分。我們用 S(n)=O(f(n))來定義,其中n為問題的規模(或大小)。通常來說,只要演算法不涉及到動態分配的空間,以及遞迴、棧所需的空間,空間複雜度通常為0(1)。一個一維陣列a[n],空間複雜度O(n),二維陣列為O(n^2)。

3. 大O表示方法

大O符號是由德國數論學家保羅·巴赫曼(Paul Bachmann)在其1892年的著作《解析數論》(Analytische Zahlentheorie)首先引入的。演算法的複雜度通常用大O符號表述,定義為T(n) = O(f(n))。稱函式T(n)以f(n)為界或者稱T(n)受限於f(n)。 如果一個問題的規模是n,解這一問題的某一演算法所需要的時間為T(n)。T(n)稱為這一演算法的“時間複雜度”。當輸入量n逐漸加大時,時間複雜度的極限情形稱為演算法的“漸近時間複雜度”。空間複雜度同理。舉個例子,令f(n) = 2n^2 + 3n + 5,O(f(n)) = O(2 n^2 + 3n + 5) = O(n^2)

好啦,演算法的知識就簡單介紹一下概念吧,因為這並不是本文的重點,下面我們我們一起來看看這幾種排序。

1. 氣泡排序

1.1 演算法步驟

  • 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。
  • 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
  • 針對所有的元素重複以上的步驟,除了最後一個。
  • 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。

1.2 動畫演示

1.3 程式碼示例(go實現)

package main

import (
	"fmt"
)

type uint64Slice []uint64

func main()  {

	numbers := []uint64{5,4,2,3,8}
	sortBubble(numbers)
	fmt.Println(numbers)
}

func sortBubble(numbers uint64Slice)  {
	length := len(numbers)
	if length == 0{
		return
	}
	flag := true

	for i:=0;i<length && flag;i++{
		flag = false
		for j:=length-1;j>i;j--{
			if numbers[j-1] > numbers[j] {
				numbers.swap(j-1,j)
				flag = true // 有資料才交換
			}
		}
	}
}

// 交換方法
func (numbers uint64Slice)swap(i,j int)  {
	numbers[i],numbers[j] = numbers[j],numbers[i]
}

1.4 複雜度分析

分析一下他的時間複雜度吧。當最好的情況下,也就是要排序的表本身就是有序的,那麼我們比較次數,根據我們的程式碼可以推斷出來就是n-1次的比較,沒有資料交換,時間複雜度為O(n)。當最壞情況下,即待排序表是逆序的,那麼我們可以列出一個公式如下: , 因此可以計算出氣泡排序的時間複雜度為O(n2)。因為我們的程式碼在執行時執行過程中臨時佔用儲存空間大小的量度沒有變化,所以空間複雜度仍為O(1)

2. 快速排序

2.1 演算法步驟

  • 從數列中挑出一個元素,稱為 “基準”(pivot);
  • 重新排序數列,所有元素比基準值小的擺放在基準前面,所有元素比基準值大的擺在基準的後面(相同的數可以到任一邊)。在這個分割槽退出之後,該基準就處於數列的中間位置。這個稱為分割槽(partition)操作;
  • 遞迴地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序;

2.2 動畫演示

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-RV7HzuNs-1606657384212)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5b56d02d73d145ca9be2c0431f1f6d68~tplv-k3u1fbpfcp-watermark.image)]

2.3 程式碼示例

package main

import (
	"fmt"
)

type uint64Slice []uint64


func main()  {
	numbers := []uint64{5,4,20,3,8,2,8}
	quickSort(numbers,0,len(numbers)-1)
	fmt.Println(numbers)
}

func quickSort(numbers uint64Slice,start,end int)  {
	var middle int
	tempStart := start
	tempEnd := end

	if tempStart >= tempEnd{
		return
	}
	pivot := numbers[start]
	for start < end{
		for start < end && numbers[end] > pivot{
			end--
		}
		if start<end{
			numbers.swap(start,end)
			start++
		}
		for start < end && numbers[start] < pivot{
			start++
		}
		if start<end{
			numbers.swap(start,end)
			end--
		}
	}
	numbers[start] = pivot
	middle = start

	quickSort(numbers,tempStart,middle-1)
	quickSort(numbers,middle+1,tempEnd)

}


// 交換方法
func (numbers uint64Slice)swap(i,j int)  {
	numbers[i],numbers[j] = numbers[j],numbers[i]
}

2.4 複雜度分析

快速排序涉及到遞迴呼叫,所以該演算法的時間複雜度還需要從遞迴演算法的複雜度開始說起;
遞迴演算法的時間複雜度公式:T[n] = aT[n/b] + f(n) ;對於遞迴演算法的時間複雜度這裡就不展開來說了。

  • 最優情況

快速排序最優的情況就是每一次取到的元素都剛好平分整個陣列(很顯然我上面的不是);

此時的時間複雜度公式則為:T[n] = 2T[n/2] + f(n);T[n/2]為平分後的子陣列的時間複雜度,f[n] 為平分這個陣列時所花的時間;

​ 下面來推算下,在最優的情況下快速排序時間複雜度的計算(用迭代法):

T[n] =  2T[n/2] + n              ----------------第一次遞迴
令:n = n/2        =  2 { 2 T[n/4] + (n/2) }  + n     ----------------第二次遞迴
                  =  2^2 T[ n/ (2^2) ] + 2n

令:n = n/(2^2)   =  2^2  {  2 T[n/ (2^3) ]  + n/(2^2)}  +  2n    ----------------第三次遞迴  
                 =  2^3 T[  n/ (2^3) ]  + 3n
......................................................................................                        
令:n = n/(  2^(m-1) )    =  2^m T[1]  + mn   ----------------第m次遞迴(m次後結束)

當最後平分的不能再平分時,也就是說把公式一直往下跌倒,到最後得到T[1]時,說明這個公式已經迭代完了(T[1]是常量了)。
得到:T[n/ (2^m) ]  =  T[1]    ===>>   n = 2^m   ====>> m = logn;
T[n] = 2^m T[1] + mn ;其中m = logn;
T[n] = 2^(logn) T[1] + nlogn  =  n T[1] + nlogn  =  n + nlogn  ;其中n為元素個數
又因為當n >=  2時:nlogn  >=  n  (也就是logn > 1),所以取後面的 nlogn;
綜上所述:快速排序最優的情況下時間複雜度為:O( nlogn )
  • 最差情況

最差的情況就是每一次取到的元素就是陣列中最小/最大的,這種情況其實就是氣泡排序了(每一次都排好一個元素的順序)
這種情況時間複雜度就好計算了,就是氣泡排序的時間複雜度:T[n] = n * (n-1) = n^2 + n;
綜上所述:快速排序最差的情況下時間複雜度為:O( n^2 )

  • 空間複雜度

首先就地快速排序使用的空間是O(1)的,也就是個常數級;而真正消耗空間的就是遞迴呼叫了,因為每次遞迴就要保持一些資料;
最優的情況下空間複雜度為:O(logn) ;每一次都平分陣列的情況。
最差的情況下空間複雜度為:O( n ) ;退化為氣泡排序的情況。

3. 插入排序

3.1 演算法步驟

  • 將第一待排序序列第一個元素看做一個有序序列,把第二個元素到最後一個元素當成是未排序序列。
  • 從頭到尾依次掃描未排序序列,將掃描到的每個元素插入有序序列的適當位置。(如果待插入的元素與有序序列中的某個元素相等,則將待插入元素插入到相等元素的後面。)

3.2 動畫演示

3.3 程式碼示例

package main

import (
	"fmt"
)

type uint64Slice []uint64

func main()  {
	numbers := []uint64{5,4,20,3,8,2,9}
	insertSort(numbers)
	fmt.Println(numbers)
}

func insertSort(numbers uint64Slice)  {
	for i:=1; i < len(numbers); i++{
		tmp := numbers[i]
		// 從待排序序列開始比較,找到比其小的數
		j:=i
		for j>0 && tmp<numbers[j-1] {
			numbers[j] = numbers[j-1]
			j--
		}
		// 存在比其小的數插入
		if j!=i{
			numbers[j] = tmp
		}
	}
}


// 交換方法
func (numbers uint64Slice)swap(i,j int)  {
	numbers[i],numbers[j] = numbers[j],numbers[i]
}

3.4 複雜度分析

我們來分析一下這個演算法,從空間上來看,它只需要一個記錄的輔助空間,因此關鍵是看它的時間複雜度。在最好的情況,我們要排序的表本身就是有序的,那我們的比較次數就是上面程式碼tmp<numbers[j-1]的比較,因此沒有移動記錄,時間複雜度為O(n)。當最壞情況,即待排序表是逆序的情況,此時需要比較 ,而記錄的移動次數也達到最大值 次。如果排序記錄是隨機的,那麼根據概率相同的原則,平均比較和移動次數約為?2/4次。因此,我們得出直接插入排序法的時間複雜度為O(?2)。從這裡也可以看出,同樣的O(?2)時間複雜度,直接插入排序比氣泡排序效能要好一些。

4 希爾排序

4.1 演算法步驟

  • 選擇一個增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
  • 按增量序列個數 k,對序列進行 k 趟排序;
  • 每趟排序,根據對應的增量 ti,將待排序列分割成若干長度為 m 的子序列,分別對各子表進行直接插入排序。僅增量因子為 1 時,整個序列作為一個表來處理,表長度即為整個序列的長度。

4.2 動畫演示

4.3 程式碼示例

package main

import (
	"fmt"
	"math"
)

type uint64Slice []uint64

func main()  {
	numbers := []uint64{8,9,1,7,2,3,5,4,6,0}
	shellSort(numbers)
	fmt.Println(numbers)
}

func shellSort(numbers uint64Slice)  {
	gap := 1
	for gap < len(numbers){
		gap = gap * 3 + 1
	}
	for gap > 0{
		for i:= gap; i < len(numbers); i++{
			tmp := numbers[i]
			j := i - gap
			for j>=0 && numbers[j] > tmp{
				numbers[j+gap] = numbers[j]
				j -= gap
			}
			numbers[j+gap] = tmp
		}
		gap = int(math.Floor(float64(gap / 3)))
	}
}

4.4 複雜度分析

通過上面的程式碼我們可以分析,希爾排序的關鍵並不是隨便分組後各自排序,而是將相隔某個"增量"的記錄組成一個子序列,實現跳躍式的移動,使得排序的效率提高。這裡的"增量"選取就非常關鍵了。我們是用gap = gap * 3 + 1的方式選取增量,可究竟應該選取什麼樣的增量才是最好的呢?目前還是數學難題,迄今為止還沒有人找到一種最好的增量序列。不過大量研究表明,當增量序列為時,可以獲得不錯的效率,其時間複雜度為O(n3/2),要好於直接排序的O(n2)。需要注意的是,增量最後一個增量值必須等於1才行。因為記錄是跳躍式移動,希爾排序並不是一種穩定的排序演算法。

5. 選擇排序

5.1 演算法步驟

  • 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
  • 再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。
  • 重複第二步,直到所有元素均排序完畢。

5.2 動畫演示

5.3 程式碼示例

package main

import (
	"fmt"
)

type uint64Slice []uint64

func main()  {
	numbers := []uint64{5,23,1,6,7,9,2}
	selectSort(numbers)
	fmt.Println(numbers)
}

func selectSort(numbers uint64Slice)  {
	for i := 0; i < len(numbers) - 1; i++{
		// 記錄最小值位置
		min := i

		for j:= i+1; j<len(numbers);j++{
			if numbers[j] < numbers[min]{
				min = j
			}
		}
		if i != min{
			numbers.swap(i,min)
		}
	}
}

// 交換方法
func (numbers uint64Slice)swap(i,j int)  {
	numbers[i],numbers[j] = numbers[j],numbers[i]
}

5.4 複雜度分析

從簡單選擇排序的過程來看,他最大的特點就是交換移動資料次數相當少,這樣也就節約了相應的時間。分析它的時間複雜度發現,無論最好最差的情況,其比較次數都是一樣的多,第i趟排序需要進行n-i次關鍵字的比較,此時需要比較。而對於交換次數而言,當最好的時候,交換為0次,最差的時候,也就初始降序時,交換次數為n-1次,基於最終的排序時間是比較與交換的次數總和,因此,總的時間複雜度依然為O(n2)。雖然與氣泡排序同為O(n2),但選擇排序的效能上還是要略優於氣泡排序的。

6. 堆排序

6.1 演算法步驟

堆排序(Heapsort)是指利用堆這種資料結構所設計的一種排序演算法。堆積是一個近似完全二叉樹的結構,並同時滿足堆積的性質:即子結點的鍵值或索引總是小於(或者大於)它的父節點。

  • 將初始待排序關鍵字序列(R1,R2….Rn)構建成大頂堆,此堆為初始的無序區;
  • 將堆頂元素R[1]與最後一個元素R[n]交換,此時得到新的無序區(R1,R2,……Rn-1)和新的有序區(Rn),且滿足R[1,2…n-1]<=R[n];
  • 由於交換後新的堆頂R[1]可能違反堆的性質,因此需要對當前無序區(R1,R2,……Rn-1)調整為新堆,然後再次將R[1]與無序區最後一個元素交換,得到新的無序區(R1,R2….Rn-2)和新的有序區(Rn-1,Rn)。不斷重複此過程直到有序區的元素個數為n-1,則整個排序過程完成。

6.2 動畫演示

6.3 程式碼示例

package main

import (
	"fmt"
)

type uint64Slice []uint64

func main()  {
	numbers := []uint64{5,2,7,3,6,1,4}
	sortHeap(numbers)
	fmt.Println(numbers)
}

func sortHeap(numbers uint64Slice)  {
	length := len(numbers)
	buildMaxHeap(numbers,length)
	for i := length-1;i>0;i--{
		numbers.swap(0,i)
		length -=1
		heapify(numbers,0,length)
	}
}

// 構造大頂堆
func buildMaxHeap(numbers uint64Slice,length int)  {
	for i := length / 2; i >= 0; i-- {
		heapify(numbers, i, length)
	}
}

func heapify(numbers uint64Slice, i, length int) {
	left := 2*i + 1
	right := 2*i + 2
	largest := i
	if left < length && numbers[left] > numbers[largest] {
		largest = left
	}
	if right < length && numbers[right] > numbers[largest] {
		largest = right
	}
	if largest != i {
		numbers.swap(i, largest)
		heapify(numbers, largest, length)
	}
}

// 交換方法
func (numbers uint64Slice)swap(i,j int)  {
	numbers[i],numbers[j] = numbers[j],numbers[i]
}

6.4 複雜度分析

堆排序的執行時間主要消耗在初始構建堆和在重建堆時的反覆篩選上。在構建堆的過程中,因為我們是完全二叉樹從最下層最右邊的非終端節點開始構建,將它與其孩子進行比較,若有必要的交換,對於每個非終端節點來說,其實最多進行兩次比較和呼喚操作,因此整個構建堆的時間複雜度為O(n)

在正式排序時,第i次取堆頂記錄重建堆需要用O(logi)的時間(完全二叉樹的某個節點到根結點的距離為|logi|+1),並且需要取n-1次堆頂記錄,因此,重建堆的時間複雜度為O(nlogn)

所以總體來說,堆排序的時間複雜度為O(nlogn)。由於堆排序對原始記錄的排序狀態並不敏感,因此他無論是最好、最壞和平均時間複雜度均為O(nlogn)。這在效能上顯然要遠遠好過於冒泡、簡單選擇、直接插入的O(n2)的時間複雜度了。

空間複雜度上,他只有一個用來交換的暫存單元,也非常的不錯。不過由於記錄的比較與交換是跳躍式進行的,因此堆排序也是一種不穩定的排序方法。注意:由於初始構建堆所需的比較次數較多,因此,他並不適合待排序序列個數較少的情況。

7. 歸併排序

7.1 演算法步驟

  • 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列;
  • 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置;
  • 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置;
  • 重複步驟 3 直到某一指標達到序列尾;
  • 將另一序列剩下的所有元素直接複製到合併序列尾。

7.2 動畫演示

7.3 程式碼示例

package main

import (
	"fmt"
)

type uint64Slice []uint64

func main()  {
	numbers := []uint64{3,44,38,5,47,15,36,26,27,2,46,4,19,50,48}
	res := mergeSort(numbers)
	fmt.Println(res)
}

func mergeSort(numbers uint64Slice) uint64Slice {
	length := len(numbers)
	if length < 2{
		return numbers
	}
	middle := length/2
	left := numbers[0:middle]
	right := numbers[middle:]
	return merge(mergeSort(left),mergeSort(right))
}

func merge(left uint64Slice,right uint64Slice) uint64Slice {
	result := make(uint64Slice,0)
	for len(left) != 0 && len(right) != 0 {
		if left[0] <= right[0] {
			result = append(result, left[0])
			left = left[1:]
		} else {
			result = append(result, right[0])
			right = right[1:]
		}
	}

	for len(left) != 0 {
		result = append(result, left[0])
		left = left[1:]
	}

	for len(right) != 0 {
		result = append(result, right[0])
		right = right[1:]
	}

	return result
}

// 交換方法
func (numbers uint64Slice)swap(i,j int)  {
	numbers[i],numbers[j] = numbers[j],numbers[i]
}

7.4 複雜度分析

可以說合並排序是比較複雜的排序,特別是對於不瞭解分治法基本思想的同學來說可能難以理解。總時間=分解時間+解決問題時間+合併時間。分解時間就是把一個待排序序列分解成兩序列,時間為一常數,時間複雜度o(1).解決問題時間是兩個遞迴式,把一個規模為n的問題分成兩個規模分別為n/2的子問題,時間為2T(n/2).合併時間複雜度為o(n)。總時間T(n)=2T(n/2)+o(n).這個遞迴式可以用遞迴樹來解,其解是o(nlogn).此外在最壞、最佳、平均情況下歸併排序時間複雜度均為o(nlogn).從合併過程中可以看出合併排序穩定。

用遞迴樹的方法解遞迴式T(n)=2T(n/2)+o(n):假設解決最後的子問題用時為常數c,則對於n個待排序記錄來說整個問題的規模為cn。

8. 計數排序

8.1 演算法步驟

  • 花O(n)的時間掃描一下整個序列 A,獲取最小值 min 和最大值 max
  • 開闢一塊新的空間建立新的陣列 B,長度為 ( max - min + 1)
  • 陣列 B 中 index 的元素記錄的值是 A 中某元素出現的次數
  • 最後輸出目標整數序列,具體的邏輯是遍歷陣列 B,輸出相應元素以及對應的個數

8.2 動畫演示

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-i2Y68TqV-1606657384218)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/91c6fadc729f4acb986e4a9deca26072~tplv-k3u1fbpfcp-watermark.image)]

8.3 程式碼示例

package main

import (
	"fmt"
)

type uint64Slice []uint64

func main()  {
	numbers := []uint64{2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2}

	countSort(numbers,getMaxValue(numbers))
	fmt.Println(numbers)
}

func countSort(numbers uint64Slice,maxValue uint64) {
	bucketLen := maxValue + 1
	bucket := make(uint64Slice,bucketLen) // 初始都是0的陣列
	sortedIndex := 0

	for _,v:= range numbers{
		bucket[v] +=1
	}
	var j uint64
	for j=0;j<bucketLen;j++{
		for bucket[j]>0{
			numbers[sortedIndex] = j
			sortedIndex +=1
			bucket[j] -= 1
		}
	}
}


func getMaxValue(numbers uint64Slice) uint64{
   maxValue := numbers[0]
   for _,v:=range numbers {
	   if maxValue < v {
		   maxValue = v
	   }
   }
   	return maxValue
}

8.4 複雜度分析

這個演算法不是基於比較的排序演算法,因此它的下界可以優於Ω(nlgn),甚至這個演算法都沒有出現比較元素的操作。這個演算法很明顯是穩定的,也就是說具有相同值得元素在輸出陣列中的相對次序和他們在輸入陣列中的相對次序相同。演算法中的迴圈時間代價都是線性的,還有一個常數k,因此時間複雜度是Θ(n+k)。當k=O(n)時,我們採用計數排序就很好,總的時間複雜度為Θ(n)。

計數排序是複雜度為O(n+k)的穩定的排序演算法,k是待排序列最大值,適用在對最大值不是很大的整型元素序列進行排序的情況下(整型元素可以有負數,我們可以把待排序列整體加上一個整數,使得待排序列的最小元素為0,然後執行計數排序,完成之後再變回來。這個操作是線性的,所以計數這樣做計數排序的複雜度仍然是O(n+k))。本質上是一種空間換時間的演算法,如果k比較小,計數排序的效率優勢是很明顯的,當k變得很大的時候,這個演算法可能就不如其他優秀的排序演算法。

9. 桶排序

9.1 演算法步驟

桶排序是計數排序的升級版。這個是利用了函式的對映關係,是否高效就在於這個對映函式的確定。所以為了使桶排序更加高效,我們要保證做到以下兩點:

1. 在額外空間充足的情況下,儘量增大桶的數量
2. 使用的對映函式能夠將輸入的 N 個資料均勻的分配到 K 個桶中
  • 設定固定數量的空桶。
  • 把資料放到對應的桶中。
  • 對每個不為空的桶中資料進行排序。
  • 拼接不為空的桶中資料,得到結果

最後,對於桶中元素的排序,選擇何種比較排序演算法對於效能的影響至關重要。

9.2 動畫演示

9.3 程式碼示例

package main

import (
	"fmt"
)

func main()  {
	numbers := []uint64{5,3,4,7,4,3,4,7}
	sortBucket(numbers)
	fmt.Println(numbers)
}

func sortBucket(numbers []uint64) {
	num := len(numbers) // 桶數量
	max := getMaxValue(numbers)
	buckets := make([][]uint64,num)
	var index uint64
	for _,v := range numbers{
		// 分配桶 index = value * (n-1)/k
		index = v * uint64(num-1) / max

		buckets[index] = append(buckets[index],v)
	}

	// 桶內排序
	tmpPos := 0
	for k:=0; k < num; k++ {
		bucketLen := len(buckets[k])
		if bucketLen>0{
			sortUseInsert(buckets[k])
			copy(numbers[tmpPos:],buckets[k])
			tmpPos +=bucketLen
		}
	}
}

func sortUseInsert(bucket []uint64)  {
	length := len(bucket)
	if length == 1 {return}
	for i := 1; i < length; i++ {
		backup := bucket[i]
		j := i -1
		for  j >= 0 && backup < bucket[j] {
			bucket[j+1] = bucket[j]
			j --
		}
		bucket[j + 1] = backup
	}
}

//獲取陣列最大值
func getMaxValue(numbers []uint64) uint64{
	max := numbers[0]
	for i := 1; i < len(numbers); i++ {
		if numbers[i] > max{ max = numbers[i]}
	}
	return max
}

9.4 複雜度分析

1. 時間複雜度

因為時間複雜度度考慮的是最壞的情況,所以桶排序的時間複雜度可以這樣去看(只看主要耗時部分,而且常熟部分K一般都省去)

  • N次迴圈,每一個資料裝入桶
  • 然後M次迴圈,每一個桶中的資料進行排序(每一個桶中有N/M個資料),假設為使用比較先進的排序演算法進行排序

一般較為先進的排序演算法時間複雜度是O(N*logN),實際的桶排序執行過程中,桶中資料是以連結串列形式插入的,那麼整個桶排序的時間複雜度為:

O(N)+O(M*(N/M)*log(N/M))=O(N*(log(N/M)+1))

所以,理論上來說(N個數都符合均勻分佈),當M=N時,有一個最小值為O(N)

PS:這裡有人提到最後還有M個桶的合併,其實首先M一般遠小於N,其次再效率最高時是M=N,這是就算把這個算進去,也是O(N(1+log(N/M)+M/N)),極小值還是O(2N)=O(N)

求M的極小值,具體計算為:(其中N可以看作一個很大的常數)
F(M) = log(N/M)+M/N) = LogN-LogM+M/N
它的導函式
F'(M) = -1/M + 1/N
因為導函式大於0代表函式遞增,小於0代表函式遞減
所以F(M)在(0,N) 上遞減
在(N,+∞)上遞增
所以當M=N時取到極小值

2. 空間複雜度

空間複雜度一般指演算法執行過程中需要的額外儲存空間

桶排序中,需要建立M個桶的額外空間,以及N個元素的額外空間

所以桶排序的空間複雜度為 O(N+M)

3. 穩定性·

穩定性是指,比如a在b前面,a=b,排序後,a仍然應該在b前面,這樣就算穩定的。

桶排序中,假如升序排列,a已經在桶中,b插進來是永遠都會a右邊的(因為一般是從右到左,如果不小於當前元素,則插入改元素的右側)

所以桶排序是穩定的

PS:當然了,如果採用元素插入後再分別進行桶內排序,並且桶內排序演算法採用快速排序,那麼就不是穩定的

用排序主要適用於均勻分佈的數字陣列,在這種情況下能夠達到最大效率

10. 基數排序

10.1 演算法步驟

基數排序與桶排序、計數排序都用到了桶的概念,但對桶的使用方法上有明顯差異:

  • 基數排序:根據鍵值的每位數字來分配桶;
  • 計數排序:每個桶只儲存單一鍵值;
  • 桶排序:每個桶儲存一定範圍的數值;

基數排序按取數方向分為兩種:從左取每個數列上的數,為最高位優先(Most Significant Digit first, MSD);從右取每個數列上的數,為最低位優先(Least Significant Digit first, LSD)
下列以LSD為例。

基數排序步驟:

  • 將所有待比較數值(正整數)統一為同樣的數位長度,數位較短的數前面補零
  • 從最低位開始,依次進行一次排序
  • 從最低位排序一直到最高位排序完成以後, 數列就變成一個有序序列

10.2 動畫演示

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-HDo6G2Fg-1606657384218)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/db965b0d583e4001a6078ee7a796b65a~tplv-k3u1fbpfcp-watermark.image)]

10.3 程式碼示例

package main

import (
	"fmt"
)

func main()  {
	numbers := []uint64{3221, 1, 10, 9680, 577, 9420, 7, 5622, 4793, 2030, 3138, 82, 2599, 743, 4127}
	radixSort(numbers)
	fmt.Println(numbers)
}

func radixSort(numbers []uint64)  {
	key := maxDigits(numbers)
	tmp := make([]uint64,len(numbers),len(numbers))
	count := new([10]uint64)
	length := uint64(len(numbers))
	var radix uint64 =  1
	var i, j, k uint64
	for i = 0; i < key; i++ { //進行key次排序
		for j = 0; j < 10; j++ {
			count[j] = 0
		}
		for j = 0; j < length; j++ {
			k = (numbers[j] / radix) % 10
			count[k]++
		}
		for j = 1; j < 10; j++ { //將tmp中的為準依次分配給每個桶
			count[j] = count[j-1] + count[j]
		}
		for j = length-1; j > 0; j-- {
			k = (numbers[j] / radix) % 10
			tmp[count[k]-1] = numbers[j]
			count[k]--
		}
		for j = 0; j < length; j++ {
			numbers[j] = tmp[j]
		}
		radix = radix * 10
	}
}


//獲取陣列的最大值的位數
func maxDigits(arr []uint64) (ret uint64) {
	ret = 1
	var key uint64 = 10
	for i := 0; i < len(arr); i++ {
		for arr[i] >= key {
			key = key * 10
			ret++
		}
	}
	return
}

10.4 複雜度分析

**如果使用桶排序或者計數排序(必需是穩定排序演算法),時間複雜度可以做到 O(n)。**如果要排序的資料有 k 位,那我們就需要 k 次桶排序或者計數排序,總的時間複雜度是 O(kn)。當 k 不大的時候,比如手機號碼排序的例子,基數排序的時間複雜度就近似於 O(n)。

基數排序對要排序的資料要求如下:

  1. 需要分割出獨立的"位"來比較,而且位之間可以進行比較。
  2. 每一位的資料範圍不能太大,要可以用線性排序演算法來排序,否則,基數排序的時間複雜度就無法做到 O(n)。
  3. 如果排序的元素位數不一樣,位數不夠的可以在後面補位。

總結

這篇文章總結到這裡就結束了,花費了好長時間耶(動畫太難弄了)~~。這排序演算法長時間不寫都快忘光了,這一次又重新整理了一遍,收穫很大。雖然這些演算法是很簡單的演算法,但是卻很重要,日常開發都會用到,所以大家一定要學好。希望這篇文章對你們有用。如果覺得不錯,給個三連吧(點贊、看一看,分享),這就對筆者的最大鼓勵,感謝啦~~~。

程式碼已經收錄到我的github,需要的自取:https://github.com/asong2020/go-algorithm/tree/master/sort

好啦,這一篇文章到這就結束了,我們下期見~~。希望對你們有用,又不對的地方歡迎指出,可新增我的golang交流群,我們一起學習交流。

結尾給大家發一個小福利吧,最近我在看[微服務架構設計模式]這一本書,講的很好,自己也收集了一本PDF,有需要的小夥可以到自行下載。獲取方式:關注公眾號:[Golang夢工廠],後臺回覆:[微服務],即可獲取。

最近被吐槽說我寫的程式碼太醜陋了,所以最近也在看clean code這本書,有需要的小夥伴公眾號自取哈。獲取方式:關注公眾號:[Golang夢工廠],後臺回覆:[code],即可獲取

我翻譯了一份GIN中文文件,會定期進行維護,有需要的小夥伴後臺回覆[gin]即可下載。

翻譯了一份Machinery中文文件,會定期進行維護,有需要的小夥伴們後臺回覆[machinery]即可獲取。

我是asong,一名普普通通的程式猿,讓gi我一起慢慢變強吧。我自己建了一個golang交流群,有需要的小夥伴加我vx,我拉你入群。歡迎各位的關注,我們下期見~~~

推薦往期文章:

相關文章