References:
值得收藏的十大經典排序算法
漫畫:什么是桶排序?
漫畫:什么是計數排序?
leetbook:排序算法全解析
visualgo:數據結構與算法可視化
概述
排序算法是一類非常經典的算法,說來簡單,說難也難。剛學編程時大家都愛用冒泡排序,隨后接觸到選擇排序、插入排序等,歷史上還有曇花一現的希爾排序,公司面試時也經常會問到快速排序等等。小小的排序算法,融入了無數程序大牛的心血。
排序算法在生活中的應用非常廣泛,比如:
在學校時,每位學生的考試成績會按照降序排出名次
在電商領域,需要按照出單量排序,快速找出銷量領先的商品
在游戲清算時,根據用戶的表現分評選出 MVP
在不同領域,排序算法的實現各有千秋。總體來看,排序算法大致可分為十類:
- 時間復雜度為 O(n^2)的排序算法
選泡插:選擇排序、冒泡排序、插入排序 - 時間復雜度為 O(nlog n) 的排序算法
快歸希堆:快速排序、歸并排序、希爾排序、堆排序
希爾排序比較特殊,它的性能略優于 O(n^2),但又比不上O(nlog n) - 時間復雜度為線性的排序算法
桶計基:桶排序、計數排序、基數排序
非線性時間比較類排序:通過比較來決定元素間的相對次序,由于其時間復雜度不能突破O(nlogn),因此稱為非線性時間比較類排序。
線性時間非比較類排序:不通過比較來決定元素間的相對次序,它可以突破基于比較排序的時間下界,以線性時間運行,因此稱為線性時間非比較類排序。
說明:
穩定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面;
不穩定:如果a原本在b的前面,而a=b,排序之后a可能會出現在b的后面;
內排序:所有排序操作都在內存中完成;
外排序:由于數據太大,因此把數據放在磁盤中,而排序通過磁盤和內存的數據傳輸才能進行;
當然,以上列舉的只是最主流的排序算法,在算法界還存在著更多五花八門的排序,它們有些基于傳統排序變形而來;有些則是腦洞大開,如雞尾酒排序、猴子排序、睡眠排序等。
此外,排序算法還可以根據其穩定性,劃分為穩定排序和不穩定排序。
雖然工作中很少需要我們手打排序算法,只需要調用基礎庫中的 Arrays.sort() 便可解決排序問題。但你可曾靜下心來,閱讀 Arrays.sort() 背后的原理,它是采用了哪種排序算法呢?
事實上,Arrays.sort() 函數并沒有采用單一的排序算法。Java 中的 Arrays.sort() 函數是由 Java 語言的幾位創始人編寫的,這個小小的函數邏輯嚴密,并且每個步驟都被精心設計,為了最大化性能做了一層又一層的優化,根據數據的概況采用雙軸快排、歸并或二分插入算法完成排序,堪稱工業級排序算法的典范,理清之后其樂無窮。
并且,排序算法深受面試官的喜愛,在人才招聘時,總是將排序算法作為程序員的基本功來考察。對排序算法的理解深度在一定程度上反映了程序員邏輯思維的嚴謹度。攻克排序算法的難關是每位程序大牛的必經之路。
如牛頓所言,正是站在巨人的肩膀上,我們才能望得更遠。本系列文章我們就來一起梳理一下排序算法的前世今生。
O(n^2):選擇排序、插入排序、冒泡排序
選擇排序 Select Sort
原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再從剩余未排序元素中繼續尋找最小(大)元素,然后放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。
public static int[] selectionSort(int[] array) {
if (array.length == 0) return array;
for (int i = 0; i < array.length; i++) {
int minIndex = i;
for (int j = i; j < array.length; j++) {
if (array[j] < array[minIndex]) //找到最小的數
minIndex = j; //將最小數的索引保存
}
int temp = array[minIndex];
array[minIndex] = array[i];
array[i] = temp;
}
return array;
}
插入排序 Insertion Sort
原理:在要排序的一組數中,假定前n-1個數已經排好序,現在將第n個數插到前面的有序數列中,使得這n個數也是排好順序的。如此反復循環,直到全部排好順序。
public static void insertSort(int[] arr) {
// 從第二個數開始,往前插入數字
for (int i = 1; i < arr.length; i++) {
int currentNumber = arr[i];
int j = i - 1;
// 尋找插入位置的過程中,不斷地將比 currentNumber 大的數字向后挪
while (j >= 0 && currentNumber < arr[j]) {
arr[j + 1] = arr[j];
j--;
}
// 兩種情況會跳出循環:1. 遇到一個小于或等于 currentNumber 的數字,跳出循環,currentNumber 就坐到它后面。
// 2. 已經走到數列頭部,仍然沒有遇到小于或等于 currentNumber 的數字,也會跳出循環,此時 j 等于 -1,currentNumber 就坐到數列頭部。
arr[j + 1] = currentNumber;
}
}
冒泡排序 Bubble Sort
原理:比較兩個相鄰的元素,將值大的元素交換到右邊。如果遇到相等的值不進行交換,那這種排序方式是穩定的排序方式。
通常來說,冒泡排序有三種寫法:
- 一邊比較一邊向后兩兩交換,將最大值 / 最小值冒泡到最后一位;
- 經過優化的寫法:使用一個變量記錄當前輪次的比較是否發生過交換,如果沒有發生交換表示已經有序,不再繼續排序;
- 進一步優化的寫法:除了使用變量記錄當前輪次是否發生交換外,再使用一個變量記錄上次發生交換的位置,下一輪排序時到達上次交換的位置就停止比較。
寫法一
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 如果左邊的數大于右邊的數,則交換,保證右邊的數字最大
arr[j + 1] = arr[j + 1] + arr[j];
arr[j] = arr[j + 1] - arr[j];
arr[j + 1] = arr[j + 1] - arr[j];
}
}
}
}
i
代表起泡的次數,j
代表起泡的位置。最外層的 for 循環每經過一輪,剩余數字中的最大值就會被移動到當前輪次的最后一位,中途也會有一些相鄰的數字經過交換變得有序。總共比較次數是(n-1)+(n-2)+(n-3)+…+1
。整個過程看起來就像一個個氣泡不斷上浮,這也是“冒泡排序法”名字的由來。
其中,我們在交換兩個數字時使用了一個小魔術:沒有引入第三個中間變量就完成了兩個數字的交換。這個交換問題曾經出現在大廠面試題中,感興趣的讀者可以細品一下。除了這種先加后減的寫法,還有一種先減后加的寫法:
arr[j + 1] = arr[j] - arr[j + 1];
arr[j] = arr[j] - arr[j + 1];
arr[j + 1] = arr[j + 1] + arr[j];
寫法二
第二種寫法是在第一種寫法的基礎上改良而來的:
public static void bubbleSort(int[] arr) {
// 初始時 swapped 為 true,否則排序過程無法啟動
boolean swapped = true;
for (int i = 0; i < arr.length - 1; i++) {
// 如果沒有發生過交換,說明剩余部分已經有序,排序完成
if (!swapped) break;
// 設置 swapped 為 false,如果發生交換,則將其置為 true
swapped = false;
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 如果左邊的數大于右邊的數,則交換,保證右邊的數字最大
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
// 表示發生了交換
swapped = true;
}
}
}
}
最外層的 for 循環每經過一輪,剩余數字中的最大值仍然是被移動到當前輪次的最后一位。這種寫法相對于第一種寫法的優點是:如果一輪比較中沒有發生過交換,則立即停止排序,因為此時剩余數字一定已經有序了。
寫法三
第三種寫法比較少見,它是在第二種寫法的基礎上進一步優化:
public static void bubbleSort(int[] arr) {
boolean swapped = true;
// 最后一個沒有經過排序的元素的下標
int indexOfLastUnsortedElement = arr.length - 1;
// 上次發生交換的位置
int swappedIndex = -1;
while (swapped) {
swapped = false;
for (int i = 0; i < indexOfLastUnsortedElement; i++) {
if (arr[i] > arr[i + 1]) {
// 如果左邊的數大于右邊的數,則交換,保證右邊的數字最大
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
// 表示發生了交換
swapped = true;
// 更新交換的位置
swappedIndex = i;
}
}
// 最后一個沒有經過排序的元素的下標就是最后一次發生交換的位置
indexOfLastUnsortedElement = swappedIndex;
}
}
經過再一次的優化,代碼看起來就稍微有點復雜了。最外層的 while 循環每經過一輪,剩余數字中的最大值仍然是被移動到當前輪次的最后一位。
在下一輪比較時,只需比較到上一輪比較中,最后一次發生交換的位置即可。因為后面的所有元素都沒有發生過交換,必然已經有序了。
當一輪比較中從頭到尾都沒有發生過交換,則表示整個列表已經有序,排序完成。
測試:
public void test() {
int[] arr = new int[]{6, 2, 1, 3, 5, 4};
bubbleSort(arr);
// 輸出: [1, 2, 3, 4, 5, 6]
System.out.println(Arrays.toString(arr));
}
復雜度
復雜度:時間復雜度O(n^2),空間復雜度O(1)。
最好情況:在數組已經有序的情況下,只需遍歷一次,由于沒有發生交換,排序結束。
最差情況:數組順序為逆序,每次比較都會發生交換。
但優化后的冒泡排序平均時間復雜度仍然是 O(n^2),所以這些優化對算法的性能并沒有質的提升。正如 Donald E. Knuth(1974 年圖靈獎獲得者)所言:“冒泡排序法除了它迷人的名字和導致了某些有趣的理論問題這一事實外,似乎沒有什么值得推薦的。”
不管怎么說,冒泡排序法是所有排序算法的老祖宗,如同程序界經典的 “Hello, world” 一般經久不衰,總是出現在各類算法書刊的首個章節。但面試時如果你說你只會冒泡排序可就太掉價了,下一節我們就來認識一下他的繼承者們。
題目
O(nlogn):快速排序、歸并排序、希爾排序、堆排序
快速排序
快速排序的框架
給你一個整數數組 nums,請你將該數組升序排列。
輸入:nums = [5,2,3,1]
輸出:[1,2,3,5]
partition函數
- 在
arr[left, right]
選擇中樞點arr[mid]
,雙指針相向移動,進行交換排序,直到left > right
。 - 左指針移動到
>=
中樞點的位置,而不是>
中樞點。 - 不能在
left == right
時就停止,因為該處的值還沒處理。 - 當
left > right
時停止,此時有兩種可能:-
arr = [1, 2, 3, 4, 5], pivot = 3
,left
和right
都指向3,那么移動后left
指向4,right
指向2。兩個指針隔一個數,且中間那個數就是pivot
。 -
arr = [1, 3, 3', 5], pivot = 3
,left
和right
分別指向3和3',那么swap后arr = [1, 3', 3, 5]
,left
指向3,right
指向3'。兩個指針相鄰。
-
- 所以,令
index1 = right
,index2 = left
,返回。當然,也可以只返回一個索引,然后再判斷。
quickSort函數
-
arr[left, index1]
都是小于等于pivot
,arr[index2, right]
都是大于等于pivot
。在這兩個區間分別quickSort。
注意
- 如果只返回一個index也可以,如果left == right-1,pivot可能是兩者之一,如果left == right-2,pivot是left+1。
- 兩個函數可以合并寫
- 也可以選擇
arr[left]
或arr[right]
為pivot
,就有另一種寫法,似乎是同向雙指針,沒仔細研究。
時間復雜度
畫出遞歸樹
n 1 * n
/ \
n/2 n/2 2 * n/2
/ \ / \
n/4 n/4 n/4 n/4 4 * n/4
1 * n + 2 * n/2 + ... + 2^i * n/2^i = n * i = nlog(n)
代碼
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
# 面試中可能不允許修改原數組,所以把nums拷貝成arr
arr = [0] * len(nums)
for i in range(len(nums)):
arr[i] = nums[i]
# 進行快速排序
self.quickSort(arr, 0, len(nums) - 1)
return arr
# 歸并排序函數:將nums[left...right]排序
def quickSort(self, arr: List[int], left: int, right: int):
if left >= right:
return
# 找到兩個中樞點
index1, index2 = self.partition(arr, left, right)
# 左邊排序
self.quickSort(arr, left, index1)
# 右邊排序
self.quickSort(arr, index2, right)
def partition(self, arr: List[int], left: int, right: int):
pivot = arr[left + (right- left) // 2]
# 當left == right時也要做處理
while left <= right:
# 找到左邊第一個大于等于pivot的數
while left <= right and arr[left] < pivot:
left += 1
# 找到右邊第一個小于等于pivot的數
while left <= right and arr[right] > pivot:
right -= 1
# 交換
if left <= right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
# [..., right]都是小于等于pivot的
# [left, ...]都是大于等于pivot的
# 有兩種可能:right + 1 == left or right + 2 == left
return right, left
另一種寫法
如果選第一個數作為參考數,那么先排好后面的直到i==j,然后交換0和i-
# 定義 pivot 函數,他會以數組第一個數作為參考數,并會按上述規則調整數組,并返回參考數的下標
def pivot(a):
# 首先我們分析一下邊界情況,首先如果只有一個數,這種情況下數組已經是有序的了,我們返回 -1 代表不需要再繼續后面的過程。那如果是兩個數的話,我們可以直接比較大小然后給出正確排序,也不需要 pivot 過程了。我們仍然返回 -1。
if len(a) == 1:return -1
if len(a) == 2:
if a[0] > a[1]:
a[0],a[1] = a[1],a[0]
return -1
# 那么接下來我們就得進行我們的算法了,首先按我們剛才說的,我們假設參考數是第一個值。同時我們定義兩個指針,i 指向 1,j 指向末尾。
pivot = a[0]
i = 1; j = len(a)-1
# 如果 i 和 j 還沒重疊的話
while i < j:
# 我們比較 a[i] 和 pivot 的大小關系,直到碰到第一個 a[i] 大于 pivot,或者 i 等于 j 就退出
while a[i] < pivot and i < j:
i += 1
# 對 a[j] 進行類似操作
while a[j] > pivot and i < j:
j -= 1
# 如果 i, j 重合,就可以退出了
if i == j:break
# 交換 a[i], a[j] 繼續算法
a[i],a[j] = a[j],a[i]
# 最后交換 pivot
if a[i] > a[0]:
a[0],a[i-1] = a[i-1],a[0]; i -=1
else:
a[0],a[i] = a[i],a[0]
return i
public static void quickSort(int[] arr) {
quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {
// 如果區域內的數字少于 2 個,退出遞歸
if (start >= end) return;
// 將數組分區,并獲得中間值的下標
int middle = partition(arr, start, end);
// 對左邊區域快速排序
quickSort(arr, start, middle - 1);
// 對右邊區域快速排序
quickSort(arr, middle + 1, end);
}
// 將 arr 從 start 到 end 分區,左邊區域比基數小,右邊區域比基數大,然后返回中間值的下標
public static int partition(int[] arr, int start, int end) {
// 取第一個數為基數
int pivot = arr[start];
// 從第二個數開始分區
int left = start + 1;
// 右邊界
int right = end;
while (left < right) {
// 找到第一個大于基數的位置
while (left < right && arr[left] <= pivot) left++;
// 找到第一個小于基數的位置
while (left < right && arr[right] >= pivot) right--;
// 交換這兩個數,使得左邊分區都小于或等于基數,右邊分區大于或等于基數
if (left < right) {
exchange(arr, left, right);
left++;
right--;
}
}
// 如果 left 和 right 相等,單獨比較 arr[right] 和 pivot
if (left == right && arr[right] > pivot) right--;
// 將基數和軸交換
exchange(arr, start, right);
return right;
}
private static void exchange(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快速排序的優化思路
第一種就是我們在前文中提到的,每輪選擇基數時,從剩余的數組中隨機選擇一個數字作為基數。這樣每輪都選到最大或最小值的概率就會變得很低了。所以我們才說用這種方式選擇基數,其平均時間復雜度是最優的
第二種解決方案是在排序之前,先用洗牌算法將數組的原有順序打亂,以防止原數組正序或逆序。Java 已經將洗牌算法封裝到了集合類中,即 Collections.shuffle() 函數。洗牌算法由 Ronald A.Fisher 和 Frank Yates 于 1938 年發明,思路是每次從未處理的數據中隨機取出一個數字,然后把該數字放在數組中所有未處理數據的尾部。
還有一種解決方案,既然數組重復排序的情況如此常見,那么我們可以在快速排序之前先對數組做個判斷,如果已經有序則直接返回,如果是逆序則直接倒序即可。在 Java 內部封裝的 Arrays.sort() 的源碼中就采用了此解決方案。
快速選擇的框架
在未排序的數組中找到第 k 個最大的元素。請注意,你需要找的是數組排序后的第 k 個最大的元素,而不是第 k 個不同的元素。
輸入: [3,2,1,5,6,4] 和 k = 2
輸出: 5
partition函數
和快排一樣。
quickSelect函數
- 快排是:1. 找中樞點 2. 快排左和右
- 快選是:1. 找中樞點 2. 快選左或右
時間復雜度
- 平均: n + n/2 + n/4 +... = O(n)。期望為 O(n),具體證明可以參考《算法導論》第 9 章第 2 小節。
- 最差: n + n-1 + n-2 + ... + 1 = O(n^2)。情況最差時,每次的劃分點都是最大值或最小值,一共需要劃分 n - 1次,而一次劃分需要線性的時間復雜度。
空間復雜度
- 平均:O(logn)。遞歸調用的期望深度為O(logn),每層需要的空間為 O(1),只有常數個變量。
- 最差:O(n)。最壞情況下需要劃分 n 次,即 quickSelect 函數遞歸調用最深 n - 1層,而每層由于需要 O(1) 的空間,所以一共需要 O(n)的空間復雜度。
代碼
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
# 面試中可能不允許修改原數組,所以把nums拷貝成arr
arr = [0] * len(nums)
for i in range(len(nums)):
arr[i] = nums[i]
# 進行快速排序
self.quickSelect(arr, 0, len(nums) - 1, len(nums) - k)
return arr[len(nums) - k]
# 歸并排序函數:將nums[left...right]排序
def quickSelect(self, arr: List[int], left: int, right: int, k: int):
# 也可以寫 if left == right
if left >= right:
return
# 找到兩個中樞點
index1, index2 = self.partition(arr, left, right)
# 左邊排序
if k <= index1:
self.quickSelect(arr, left, index1, k)
# 右邊排序
elif k >= index2:
self.quickSelect(arr, index2, right, k)
else:
return
def partition(self, arr: List[int], left: int, right: int):
pivot = arr[left + (right- left) // 2]
# 當left == right時也要做處理
while left <= right:
# 找到左邊第一個大于等于pivot的數
while left <= right and arr[left] < pivot:
left += 1
# 找到右邊第一個小于等于pivot的數
while left <= right and arr[right] > pivot:
right -= 1
# 交換
if left <= right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
# [..., right]都是小于等于pivot的
# [left, ...]都是大于等于pivot的
# 有兩種可能:right + 1 == left or right + 2 == left
return right, left
O(n):桶排序、計數排序、基數排序
計數排序 Counting Sort
計數排序是一個非基于比較的排序算法。計數排序需要根據原始數列的取值范圍,創建一個統計數組,用來統計原始數列中每一個可能的整數值所出現的次數。
原始數列中的整數值,和統計數組的下標是一一對應的,以數列的最小值作為偏移量。比如原始數列的最小值是90, 那么整數95對應的統計數組下標就是 95-90 = 5。
基于比較的排序的時間復雜度在理論上的下限是O(n*log(n)), 如歸并排序,堆排序。
計數排序的優勢在于在對一定范圍內的整數排序時,它的復雜度為Ο(n+r)(其中r是整數的范圍),快于任何比較排序算法。當然這是一種犧牲空間換取時間的做法,而且當O(r)>O(nlog(n))的時候其效率反而不如基于比較的排序。
def counting_sort(array):
largest = max(array); smallest = min(array) # 獲取最大,最小值
counter = [0 for i in range(largest-smallest+1)] # 用于統計個數的空數組
idx = 0 # 桶內索引值
#step1:put
for i in range(len(array)):
counter[array[i]-smallest] += 1 # 統計每個元素出現的次數
#step2:retrieve
for j in range(len(counter)):
while counter[j] > 0:
array[idx] = j + smallest # 取出元素
idx += 1
counter[j] -= 1
return array
優化版的計數排序
下面的講解會有一些燒腦,請大家扶穩坐好。我們仍然以剛才的學生成績表為例,把之前的統計數組變形成下面的樣子:
這是如何變形的呢?統計數組從第二個元素開始,每一個元素都加上前面所有元素之和。這樣相加的目的,是讓統計數組存儲的元素值,等于相應整數的最終排序位置。比如下標是9的元素值為5,代表原始數列的整數9,最終的排序是在第5位。
接下來,我們創建輸出數組sortedArray,長度和輸入數列一致。然后從后向前遍歷輸入數列:
第一步,我們遍歷成績表最后一行的小綠:
小綠是95分,我們找到countArray下標是5的元素,值是4,代表小綠的成績排名位置在第4位。
同時,我們給countArray下標是5的元素值減1,從4變成3,,代表著下次再遇到95分的成績時,最終排名是第3。
第二步,我們遍歷成績表倒數第二行的小白:
小白是94分,我們找到countArray下標是4的元素,值是2,代表小白的成績排名位置在第2位。
同時,我們給countArray下標是4的元素值減1,從2變成1,,代表著下次再遇到94分的成績時(實際上已經遇不到了),最終排名是第1。
第三步,我們遍歷成績表倒數第三行的小紅:
小紅是95分,我們找到countArray下標是5的元素,值是3(最初是4,減1變成了3),代表小紅的成績排名位置在第3位。
同時,我們給countArray下標是5的元素值減1,從3變成2,,代表著下次再遇到95分的成績時(實際上已經遇不到了),最終排名是第2。
這樣一來,同樣是95分的小紅和小綠就能夠清楚地排出順序了,也正因此,優化版本的計數排序屬于穩定排序。
public static int[] countSort(int[] array) {
//1.得到數列的最大值和最小值,并算出差值d
int max = array[0];
int min = array[0];
for(int i=1; i<array.length; i++) {
if(array[i] > max) {
max = array[i];
}
if(array[i] < min) {
min = array[i];
}
}
int d = max - min;
//2.創建統計數組并統計對應元素個數
int[] countArray = new int[d+1];
for(int i=0; i<array.length; i++) {
countArray[array[i]-min]++;
}
//3.統計數組做變形,后面的元素等于前面的元素之和
int sum = 0;
for(int i=0;i<countArray.length;i++) {
sum += countArray[i];
countArray[i] = sum;
}
//4.倒序遍歷原始數列,從統計數組找到正確位置,輸出到結果數組
int[] sortedArray = new int[array.length];
for(int i=array.length-1;i>=0;i--) {
sortedArray[countArray[array[i]-min]-1]=array[i];
countArray[array[i]-min]--;
}
return sortedArray;
}
public static void main(String[] args) {
int[] array = new int[] {95,94,91,98,99,90,99,93,91,92};
int[] sortedArray = countSort(array);
System.out.println(Arrays.toString(sortedArray));
}
復雜度
- 時間復雜度:O(n+r)
- 空間復雜度:O(r)
局限性
1.當數列最大最小值差距過大時,并不適用計數排序。
比如給定20個隨機整數,范圍在0到1億之間,這時候如果使用計數排序,需要創建長度1億的數組。不但嚴重浪費空間,而且時間復雜度也隨之升高。
2.當數列元素不是整數,并不適用計數排序。
如果數列中的元素都是小數,比如25.213,或是0.00000001這樣子,則無法創建對應的統計數組。這樣顯然無法進行計數排序。
桶排序 Bucket Sort
思想
當數據范圍過大,或者不是整數時,計數排序就行不通了。這時候我們可以用桶排序。
它利用了函數的映射關系,高效與否的關鍵就在于這個映射函數的確定。桶排序 (Bucket sort)的工作的原理:假設輸入數據服從均勻分布,將數據分到有限數量的桶里,每個桶再分別排序(有可能再使用別的排序算法或是以遞歸方式繼續使用桶排序進行排)。
每一個桶(bucket)代表一個區間范圍,里面可以承載一個或多個元素。桶排序的第一步,就是創建這些桶,確定每一個桶的區間范圍:
具體建立多少個桶,如何確定桶的區間范圍,有很多不同的方式。我們這里創建的桶數量等于原始數列的元素數量,除了最后一個桶只包含數列最大值,前面各個桶的區間按照比例確定。
區間跨度 = (最大值-最小值)/ (桶的數量 - 1)
第二步,遍歷原始數列,把元素對號入座放入各個桶中:
第三步,每個桶內部的元素分別排序(顯然,只有第一個桶需要排序):
第四步,遍歷所有的桶,輸出所有元素:0.5,0.84,2.18,3.25,4.5
代碼
public static double[] bucketSort(double[] array){
//1.得到數列的最大值和最小值,并算出差值d
double max = array[0];
double min = array[0];
for(int i=1; i<array.length; i++) {
if(array[i] > max) {
max = array[i];
}
if(array[i] < min) {
min = array[i];
}
}
double d = max - min;
//2.初始化桶
int bucketNum = array.length;
ArrayList<LinkedList<Double>> bucketList = new ArrayList<LinkedList<Double>>(bucketNum);
for(int i = 0; i < bucketNum; i++){
bucketList.add(new LinkedList<Double>());
}
//3.遍歷原始數組,將每個元素放入桶中
for(int i = 0; i < array.length; i++){
int num = (int)((array[i] - min) * (bucketNum-1) / d);
bucketList.get(num).add(array[i]);
}
//4.對每個桶內部進行排序
for(int i = 0; i < bucketList.size(); i++){
//JDK底層采用了歸并排序或歸并的優化版本
Collections.sort(bucketList.get(i));
}
//5.輸出全部元素
double[] sortedArray = new double[array.length];
int index = 0;
for(LinkedList<Double> list : bucketList){
for(double element : list){
sortedArray[index] = element;
index++;
}
}
return sortedArray;
}
public static void main(String[] args) {
double[] array = new double[] {4.12,6.421,0.0023,3.0,2.123,8.122,4.12, 10.09};
double[] sortedArray = bucketSort(array);
System.out.println(Arrays.toString(sortedArray));
}
代碼中,所有的桶保存在ArrayList集合當中,每一個桶被定義成一個鏈表(LinkedList<Double>),這樣便于在尾部插入元素。
復雜度
下面我們來逐步分析算法復雜度:
第一步求數列最大最小值,運算量為n。
第二步創建空桶,運算量為m。
第三步遍歷原始數列,運算量為n。
第四步在每個桶內部做排序,由于使用了O(nlogn)的排序算法,所以運算量為 n/m * log(n/m ) * m。
第五步輸出排序數列,運算量為n。
加起來,總的運算量為 3n+m+ n/m * log(n/m ) * m = 3n+m+n(logn-logm) 去掉系數,時間復雜度為:O(n+m+n(logn-logm))
最好情況,桶中的元素分布均勻,當m=n時,時間復雜度O(n)
最差情況,O(nlogn),白白創建了許多空桶。
至于空間復雜度就很明顯了:空桶占用的空間 + 數列在桶中占用的空間 = O(m+n)。
基數排序 Radix Sort
桶排序是基數排序的一個特例。
基數排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次類推,直到最高位。有時候有些屬性是有優先級順序的,先按低優先級排序,再按高優先級排序。最后的次序就是高優先級高的在前,高優先級相同的低優先級高的在前。
乍一看會感覺很神奇,為什么明明只考慮了單個位置的大小,最后的數據卻整體有序了。我們這樣來分析每一步:
- 首先按個位桶排序,那么現在所有的數據都保持個位上有序。
- 然后按十位排序,數據在十位上是有序的。單獨看每個桶內,由于 桶內數據保持之前順序,因此桶內數據在個位上也是有序的。總結一下,桶內數據在后兩位有序。
- 同樣,按百位排序時,桶內數據 在后三位是有序的。
- 最后,所有位數排完時,桶內數據是有序排列,加上桶本身按最高位順序排序,因此所有數據都有序了。
由于我們排序每一位都需要遍歷一遍數據,所以整體時間復雜度為 O(n*m),其中 m 為數字的最高位數
復雜度:快排vs桶排
快速排序的時間復雜度是O(nlogn),而基數排序的時間復雜度是 O(n),是不是說基數排序一定優于快速排序呢?實際上并不是。
從運行速度角度看,基數排序的理論時間復雜度是更低的,但是其依賴于排序的數據的位數,而數據 x 的位數等價于 log(x),因此基數排序的常數 log(x)和快速排序多出來的復雜度部分log(n) 實際上是一個量級,并不能得到一個絕對的速度優勢。
從空間消耗看,快速排序遞歸調用系統棧,空間復雜度為 O(logn),而基數排序需要一個額外的O(n)桶空間。
排序題目
題目 | 方法 | 時間 |
---|---|---|
劍指 Offer 40. 最小的k個數 | 3x堆排序+冒泡+計數+快排 | 2021/3/9 |
347. 前 K 個高頻元素 | 堆排序 桶排序 | 2021/3/9 |
147. 對鏈表進行插入排序 | 插入排序,跟數組的有區別,注意避坑 | 2021/3/9 |