日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程资源 > 编程问答 >内容正文

编程问答

超全十大经典排序算法及其分析

發(fā)布時(shí)間:2023/12/19 编程问答 27 豆豆
生活随笔 收集整理的這篇文章主要介紹了 超全十大经典排序算法及其分析 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

文章目錄

  • 0.算法概述
    • 0.1 算法分類
    • 0.2 算法復(fù)雜度
    • 0.3 相關(guān)概念
  • 1. 冒泡排序(Bubble Sort)
    • 1.1 算法描述:
    • 1.2 圖解演示
    • 1.3 代碼實(shí)現(xiàn)
    • 1.4 優(yōu)化過(guò)程
    • 1.5 性能分析
  • 2. 選擇排序(Selection Sort)
    • 2.1 算法描述:
    • 2.2 圖解演示
    • 2.3 代碼實(shí)現(xiàn)
    • 2.4 優(yōu)化過(guò)程
    • 2.5 性能分析
    • 2.6 拓展
  • 3. 插入排序(Insertion Sort)
    • 3.1 算法描述
    • 3.2 圖解演示
    • 3.3 代碼演示:
    • 3.4 優(yōu)化過(guò)程
    • 3.5 性能分析
    • 3.6 應(yīng)用分析
  • 4.快速排序(Quick Sort)
    • 4.1算法描述
    • 4.2 圖解演示
    • 4.3 代碼實(shí)現(xiàn)
      • ①快速排序遞歸框架
      • ②退出遞歸的邊界條件
      • ③基數(shù)的選擇
      • ④分區(qū)算法實(shí)現(xiàn)
      • ⑤最簡(jiǎn)單的分區(qū)算法
      • ⑥雙指針?lè)謪^(qū)算法
    • 4.4 性能分析
    • 4.5快速排序的優(yōu)化思路
  • 5.希爾排序(Shell Sort)
    • 5.1 算法描述
    • 5.2 圖解演示
    • 5.3 代碼實(shí)現(xiàn)
    • 5.4 優(yōu)化過(guò)程
    • 5.5增量序列
    • 5.6 性能分析
    • 5.7 希爾排序與 O(n^2)級(jí)排序算法的本質(zhì)區(qū)別
  • 6.歸并排序(Merge Sort)
    • 6.1算法描述
    • 6.2 算法圖解
    • 6.3 代碼實(shí)現(xiàn)
    • 6.4 性能分析
    • 6.5 應(yīng)用分析
  • 7.堆排序(Heap Sort)
    • 7.1算法描述
    • 7.2圖解演示
    • 7.3 代碼實(shí)現(xiàn)
    • 7.4 性能分析
  • 8.計(jì)數(shù)排序(Counting Sort)
    • 8.1算法描述
    • 8.2 圖解演示
    • 8.3代碼實(shí)現(xiàn)
    • 8.4 性能分析
    • 8.5 拓展
  • 9.基數(shù)排序(Radix Sort)
    • 9.1 算法描述
    • 9.2 圖解演示
    • 9.3 代碼實(shí)現(xiàn)
    • 9.4 對(duì)包含負(fù)數(shù)的數(shù)組進(jìn)行基數(shù)排序
    • 9.5 LSD VS MSD
    • 9.6 性能分析
  • 10.桶排序(Bucket Sort)
    • 10.1 算法描述
    • 10.2 圖解演示
    • 10.3 代碼實(shí)現(xiàn)
    • 10.4 性能分析
    • 10.5 拓展
  • 11.非比較類排序算法總結(jié)

0.算法概述

0.1 算法分類

1、時(shí)間復(fù)雜度O(n^2)級(jí)排序算法:

  • 選擇排序
  • 插入排序
  • 冒泡排序

2、時(shí)間復(fù)雜度O(nlogn)級(jí)排序算法:

  • 快速排序
  • 希爾排序
  • 歸并排序
  • 堆排序

3、時(shí)間復(fù)雜度O(n)級(jí)排序算法:

  • 桶排序
  • 基數(shù)排序
  • 計(jì)數(shù)排序

4、比較類排序

  • 交換排序
    • 冒泡排序 Bubble Sort
    • 快速排序 Quick Sort
  • 插入排序
    • 簡(jiǎn)單插入排序 Insertion Sort
    • 希爾排序 Shell Sort
  • 選擇排序
    • 簡(jiǎn)單選擇排序 Selection Sort
    • 堆排序 Heap Sort
  • 歸并排序
    • 二路歸并排序 Merge Sort
    • 多路歸并排序 Merge Sort
  • 非比較排序
    • 計(jì)數(shù)排序 Counting Sort
    • 基數(shù)排序 Radix Sort
    • 桶排序 Bucket Sort

0.2 算法復(fù)雜度

0.3 相關(guān)概念

時(shí)空復(fù)雜度:

時(shí)間復(fù)雜度:時(shí)間隨著問(wèn)題(數(shù)據(jù))規(guī)模的擴(kuò)大而如何變化的:一般講時(shí)間復(fù)雜度都是講”最壞“的;

  • 不考慮必須要做的操作:循環(huán)、賦初值、程序初始化:----->O(1)
  • 不考慮常數(shù)項(xiàng):2n -----> n
  • 不考慮低次項(xiàng):n^(2+n)-----> n^2

空間復(fù)雜度:算法需要用到的額外空間,不包括該問(wèn)題必須分配的空間;

分類:

  • 非線性時(shí)間比較類排序:通過(guò)比較來(lái)決定元素間的相對(duì)次序,由于其時(shí)間復(fù)雜度不能突破O(nlogn),因此稱為非線性時(shí)間比較類排序;

  • 線性時(shí)間非比較類排序:不通過(guò)比較來(lái)決定元素間的相對(duì)次序,它可以突破基于比較排序的時(shí)間下界,以線性時(shí)間運(yùn)行,因此稱為線性時(shí)間非比較類排序;

  • 內(nèi)排序:所有排序操作都在內(nèi)存中完成;

  • 外排序:由于數(shù)據(jù)太大,因此把數(shù)據(jù)放在磁盤中,而排序通過(guò)磁盤和內(nèi)存的數(shù)據(jù)傳輸才能進(jìn)行;

穩(wěn)定性:

  • 假定在待排序的記錄序列中,存在多個(gè)具有相同的關(guān)鍵字的記錄,若經(jīng)過(guò)排序,這些記錄的相對(duì)次序保持不變,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,則稱這種排序算法是穩(wěn)定的;否則稱為不穩(wěn)定的。

推薦的標(biāo)準(zhǔn)來(lái)源于面試筆試的考察情況:

冒泡排序 ■■□□□
快速排序 ■■■■■
插入排序 ■■■□□
希爾排序 ■□□□□
歸并排序 ■■■■■
選擇排序 ■■□□□
堆排序 ■■■■□
計(jì)數(shù)排序 ■■□□□
基數(shù)排序 ■□□□□
桶排序 ■□□□□
根據(jù)上面的情況,相信你會(huì)有所側(cè)重地學(xué)習(xí)排序算法。

1. 冒泡排序(Bubble Sort)

依次比較兩個(gè)相鄰的元素,如果順序(如從大到小、首字母從Z到A)錯(cuò)誤就把他們交換過(guò)來(lái)。走訪元素的工作是重復(fù)地進(jìn)行直到?jīng)]有相鄰元素需要交換,也就是說(shuō)該元素列已經(jīng)排序完成。

1.1 算法描述:

  • 比較相鄰的元素。如果第一個(gè)比第二個(gè)大,就交換他們兩個(gè);
  • 對(duì)每一對(duì)相鄰元素做同樣的工作,從開(kāi)始第一對(duì)到結(jié)尾的最后一對(duì)。在這一點(diǎn),最后的元素應(yīng)該會(huì)是最大的數(shù);
  • 針對(duì)所有的元素重復(fù)以上的步驟,除了最后一個(gè);
  • 持續(xù)每次對(duì)越來(lái)越少的元素重復(fù)上面的步驟,直到?jīng)]有任何一對(duì)數(shù)字需要比較;

1.2 圖解演示

1.3 代碼實(shí)現(xiàn)

冒泡排序第一種寫法:

代碼如下:

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]) {// 如果左邊的數(shù)大于右邊的數(shù),則交換,保證右邊的數(shù)字最大swap(arr, j, j + 1);}}} } // 交換元素 private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp; }

1.4 優(yōu)化過(guò)程

  • 一邊比較一邊向后兩兩交換,將最大值 / 最小值冒泡到最后一位;

  • 經(jīng)過(guò)優(yōu)化的寫法:使用一個(gè)變量記錄當(dāng)前輪次的比較是否發(fā)生過(guò)交換,如果沒(méi)有發(fā)生交換表示已經(jīng)有序,不再繼續(xù)排序;

  • 進(jìn)一步優(yōu)化的寫法:除了使用變量記錄當(dāng)前輪次是否發(fā)生交換外,再使用一個(gè)變量記錄上次發(fā)生交換的位置,下一輪排序時(shí)到達(dá)上次交換的位置就停止比較;

    最外層的 for 循環(huán)每經(jīng)過(guò)一輪,剩余數(shù)字中的最大值就會(huì)被移動(dòng)到當(dāng)前輪次的最后一位,中途也會(huì)有一些相鄰的數(shù)字經(jīng)過(guò)交換變得有序。總共比較次數(shù)是 (n-1)+(n-2)+(n-3)+…+1(n?1)+(n?2)+(n?3)+…+1。

冒泡排序第二種寫法:

第二種寫法是在第一種寫法的改良基礎(chǔ)下而來(lái),代碼如下:

public static void bubbleSort(int[] arr) {// 初始時(shí) swapped 為 true,否則排序過(guò)程無(wú)法啟動(dòng)boolean swapped = true;for (int i = 0; i < arr.length - 1; i++) {// 如果沒(méi)有發(fā)生過(guò)交換,說(shuō)明剩余部分已經(jīng)有序,排序完成if (!swapped) break;// 設(shè)置 swapped 為 false,如果發(fā)生交換,則將其置為 trueswapped = false;for (int j = 0; j < arr.length - 1 - i; j++) {if (arr[j] > arr[j + 1]) {// 如果左邊的數(shù)大于右邊的數(shù),則交換,保證右邊的數(shù)字最大swap(arr, j, j + 1);// 表示發(fā)生了交換swapped = true;}}} }

最外層的 for 循環(huán)每經(jīng)過(guò)一輪,剩余數(shù)字中的最大值仍然是被移動(dòng)到當(dāng)前輪次的最后一位。這種寫法相對(duì)于第一種寫法的優(yōu)點(diǎn)是:如果一輪比較中沒(méi)有發(fā)生過(guò)交換,則立即停止排序,因?yàn)榇藭r(shí)剩余數(shù)字一定已經(jīng)有序了。

看下動(dòng)圖演示:

圖中可以看出:

第一輪排序?qū)?shù)字 66 移動(dòng)到最右邊;
第二輪排序?qū)?shù)字 55 移動(dòng)到最右邊,同時(shí)中途將 11 和 22 排了序;
第三輪排序時(shí),沒(méi)有發(fā)生交換,表明排序已經(jīng)完成,不再繼續(xù)比較。

冒泡排序的第三種寫法:

第三種寫法比較少見(jiàn),它是在第二種寫法的基礎(chǔ)上進(jìn)一步優(yōu)化:

經(jīng)過(guò)再一次的優(yōu)化,代碼看起來(lái)就稍微有點(diǎn)復(fù)雜了。最外層的 while 循環(huán)每經(jīng)過(guò)一輪,剩余數(shù)字中的最大值仍然是被移動(dòng)到當(dāng)前輪次的最后一位。

在下一輪比較時(shí),只需比較到上一輪比較中,最后一次發(fā)生交換的位置即可。因?yàn)楹竺娴乃性囟紱](méi)有發(fā)生過(guò)交換,必然已經(jīng)有序了。

最好情況:在數(shù)組已經(jīng)有序的情況下,只需遍歷一次,由于沒(méi)有發(fā)生交換,排序結(jié)束。

最差情況:數(shù)組順序?yàn)槟嫘?#xff0c;每次比較都會(huì)發(fā)生交換。

代碼如下:

public static void bubbleSort(int[] arr) {boolean swapped = true;// 最后一個(gè)沒(méi)有經(jīng)過(guò)排序的元素的下標(biāo)int indexOfLastUnsortedElement = arr.length - 1;// 上次發(fā)生交換的位置int swappedIndex = -1;while (swapped) {swapped = false;for (int i = 0; i < indexOfLastUnsortedElement; i++) {if (arr[i] > arr[i + 1]) {// 如果左邊的數(shù)大于右邊的數(shù),則交換,保證右邊的數(shù)字最大swap(arr, i, i + 1);// 表示發(fā)生了交換swapped = true;// 更新交換的位置swappedIndex = i;}}// 最后一個(gè)沒(méi)有經(jīng)過(guò)排序的元素的下標(biāo)就是最后一次發(fā)生交換的位置indexOfLastUnsortedElement = swappedIndex;} }

1.5 性能分析

時(shí)間復(fù)雜度、空間復(fù)雜度:

  • ? 第一種寫法的比較次數(shù)為 (n-1)+(n-2)+(n-3)+…+1,總比較次數(shù)為 (n^2)/2,所以時(shí)間復(fù)雜度為 O(n^2),使用空間只有 i、j 兩個(gè)變量,所以空間復(fù)雜度為 O(1);

  • ? 第二種寫法在數(shù)組已經(jīng)有序的情況下比較次數(shù)為 n-1,只需比較一輪即可完成排序,此時(shí)時(shí)間復(fù)雜度為 O(n),最壞的情況和第一種寫法一樣,平均時(shí)間復(fù)雜度仍是 O(n^2)使用的空間最多 i、j、swapped、temp 四個(gè)變量,最好的情況下只有 i、j、swapped 三個(gè)變量,所以空間復(fù)雜度為 O(1);

  • ? 第三種寫法時(shí)間復(fù)雜度和第二種寫法一樣,平均時(shí)間復(fù)雜度是 O(n^2)只是實(shí)際運(yùn)行效率比第二種寫法好一些;使用的空間最多 swapped、indexOfLastUnsortedElement、swappedIndex、i、temp 五個(gè)變量,最好的情況下沒(méi)有 temp 變量,所以空間復(fù)雜度為 O(1)。

穩(wěn)定性:

? 冒泡排序就是把小的元素往前調(diào)或者把大的元素往后調(diào)。比較是相鄰的兩個(gè)元素比較,交換也發(fā)生在這兩個(gè)元素之間。所以,如果兩個(gè)元素相等,是不會(huì)再交換的;如果兩個(gè)相等的元素沒(méi)有相鄰,那么即使通過(guò)前面的兩兩交換把兩個(gè)相鄰起來(lái),這時(shí)候也不會(huì)交換,所以相同元素的前后順序并沒(méi)有改變,所以冒泡排序是一種穩(wěn)定排序算法。

2. 選擇排序(Selection Sort)

選擇排序(Selectionsort)是一種簡(jiǎn)單直觀的排序算法。它的工作原理是:第一次從待排序的數(shù)據(jù)元素中選出最小(或最大)的一個(gè)元素,存放在序列的起始位置,然后再?gòu)氖S嗟奈磁判蛟刂袑ふ业阶钚?#xff08;大)元素,然后放到已排序的序列的末尾。以此類推,直到全部待排序的數(shù)據(jù)元素的個(gè)數(shù)為零。

2.1 算法描述:

n個(gè)記錄的直接選擇排序可經(jīng)過(guò)n-1趟直接選擇排序得到有序結(jié)果。具體算法描述如下:

  • 初始狀態(tài):無(wú)序區(qū)為N[1…n],有序區(qū)為空;
  • 第i趟排序(i=1,2,3…n-1)開(kāi)始時(shí),當(dāng)前有序區(qū)和無(wú)序區(qū)分別為N[1…i-1]和N(i…n)。該趟排序從當(dāng)前無(wú)序區(qū)中選出關(guān)鍵字最小的記錄 N[m],將它與無(wú)序區(qū)的第1個(gè)記錄N交換,使N[1…i]和N[i+1…n)分別變?yōu)橛涗泜€(gè)數(shù)增加1個(gè)的新有序區(qū)和記錄個(gè)數(shù)減少1個(gè)的新無(wú)序區(qū);
  • n-1趟結(jié)束,數(shù)組有序化了。

2.2 圖解演示

2.3 代碼實(shí)現(xiàn)

public static void selectionSort(int[] arr) {int minIndex;for (int i = 0; i < arr.length - 1; i++) {minIndex = i;for (int j = i + 1; j < arr.length; j++) {if (arr[minIndex] > arr[j]) {// 記錄最小值的下標(biāo)minIndex = j;}}// 將最小元素交換至首位int temp = arr[i];arr[i] = arr[minIndex];arr[minIndex] = temp;} }

2.4 優(yōu)化過(guò)程

二元選擇排序:

選擇排序算法也是可以優(yōu)化的,既然每輪遍歷時(shí)找出了最小值,何不把最大值也順便找出來(lái)呢?這就是二元選擇排序的思想。

使用二元選擇排序,每輪選擇時(shí)記錄最小值和最大值,可以把數(shù)組需要遍歷的范圍縮小一倍。

public static void selectionSort2(int[] arr) {int minIndex, maxIndex;// i 只需要遍歷一半for (int i = 0; i < arr.length / 2; i++) {minIndex = i;maxIndex = i;for (int j = i + 1; j < arr.length - i; j++) {if (arr[minIndex] > arr[j]) {// 記錄最小值的下標(biāo)minIndex = j;}if (arr[maxIndex] < arr[j]) {// 記錄最大值的下標(biāo)maxIndex = j;}} // 如果 minIndex 和 maxIndex 都相等,那么他們必定都等于 i,且后面的所有數(shù)字都與 arr[i] 相等,此時(shí)已經(jīng)排序完成if (minIndex == maxIndex) break;// 將最小元素交換至首位int temp = arr[i];arr[i] = arr[minIndex];arr[minIndex] = temp; // 如果最大值的下標(biāo)剛好是 i,由于 arr[i] 和 arr[minIndex] 已經(jīng)交換了,所以這里要更新 maxIndex 的值。if (maxIndex == i) maxIndex = minIndex;// 將最大元素交換至末尾int lastIndex = arr.length - 1 - i;temp = arr[lastIndex];arr[lastIndex] = arr[maxIndex];arr[maxIndex] = temp;} }

我們使用 minIndex 記錄最小值的下標(biāo),maxIndex 記錄最大值的下標(biāo)。每次遍歷后,將最小值交換到首位,最大值交換到末尾,就完成了排序。

由于每一輪遍歷可以排好兩個(gè)數(shù)字,所以最外層的遍歷只需遍歷一半即可。

二元選擇排序中有一句很重要的代碼,它位于交換最小值和交換最大值的代碼中間:

if (maxIndex == i) maxIndex = minIndex;

這行代碼的作用處理了一種特殊情況:如果最大值的下標(biāo)等于 i,也就是說(shuō) arr[i] 就是最大值,由于 arr[i] 是當(dāng)前遍歷輪次的首位,它已經(jīng)和 arr[minIndex] 交換了,所以最大值的下標(biāo)需要跟蹤到 arr[i] 最新的下標(biāo) minIndex。

二元選擇排序的效率:
在二元選擇排序算法中,數(shù)組需要遍歷的范圍縮小了一倍。那么這樣可以使選擇排序的效率提升一倍嗎?

從代碼可以看出,雖然二元選擇排序最外層的遍歷范圍縮小了,但 for 循環(huán)內(nèi)做的事情翻了一倍。也就是說(shuō)二元選擇排序無(wú)法將選擇排序的效率提升一倍。但實(shí)測(cè)會(huì)發(fā)現(xiàn)二元選擇排序的速度確實(shí)比選擇排序的速度快一點(diǎn)點(diǎn),它的速度提升主要是因?yàn)閮牲c(diǎn):

  • 在選擇排序的外層 for 循環(huán)中,i 需要加到 arr.length - 1 ,二元選擇排序中 i 只需要加到 arr.length / 2
  • 在選擇排序的內(nèi)層 for 循環(huán)中,j 需要加到 arr.length ,二元選擇排序中 j 只需要加到 arr.length - i

二元選擇排序雖然比選擇排序要快,但治標(biāo)不治本,二元選擇排序中做的優(yōu)化無(wú)法改變其時(shí)間復(fù)雜度,二元選擇排序的時(shí)間復(fù)雜度仍然是 O(n^2);只使用有限個(gè)變量,空間復(fù)雜度 O(1)。

2.5 性能分析

時(shí)間復(fù)雜度:

? 擇排序的交換操作介于 0 和 (n - 1)次之間。選擇排序的比較操作為 n (n - 1) / 2 次之間。選擇排序的賦值操作介于 0 和 3 (n - 1) 次之間。比較次數(shù)O(n^2)

比較次數(shù)與關(guān)鍵字的初始狀態(tài)無(wú)關(guān),總的比較次數(shù)N=(n-1)+(n-2)+…+1=n*(n-1)/2。交換次數(shù)O(n),最好情況是,已經(jīng)有序,交換0次;最壞情況交換n-1次,逆序交換n/2次,由于選擇排序在進(jìn)行排序時(shí)無(wú)論數(shù)組是非有序都需要進(jìn)行相同次數(shù)的尋找和比較,所以最好和最差情況下的時(shí)間復(fù)雜度都是O(n^2)

空間復(fù)雜度:

? 分配變量時(shí)不考慮,臨時(shí)變量分配完在一次for循環(huán)后就消失,沒(méi)有用到額外的空間,所以空間復(fù)雜度為O(1)。

穩(wěn)定性:

? 選擇排序是給每個(gè)位置選擇當(dāng)前元素最小的,比如給第一個(gè)位置選擇最小的,在剩余元素里面給第二個(gè)元素選擇第二小的,依次類推,直到第n-1個(gè)元素,第n個(gè)元素不用選擇了,因?yàn)橹皇O滤粋€(gè)最大的元素了。那么,在一趟選擇,如果一個(gè)元素比當(dāng)前元素小,而該小的元素又出現(xiàn)在一個(gè)和當(dāng)前元素相等的元素后面,那么交換后穩(wěn)定性就被破壞了。舉個(gè)例子,序列{5,8,5,2,9},我們知道第一遍選擇第1個(gè)元素5會(huì)和2交換,那么原序列中兩個(gè)5的相對(duì)前后順序就被破壞了,所以選擇排序是一個(gè)不穩(wěn)定的排序算法。

2.6 拓展

現(xiàn)在讓我們思考一下,冒泡排序和選擇排序有什么異同?

  • 相同點(diǎn):
    • 都是兩層循環(huán),時(shí)間復(fù)雜度都為 O(n^2);都只使用有限個(gè)變量,空間復(fù)雜度 O(1)。
  • 不同點(diǎn):
    • 冒泡排序在比較過(guò)程中就不斷交換,而選擇排序增加了一個(gè)變量保存最小值 / 最大值的下標(biāo),遍歷完成后才交換,減少了交換次數(shù)。
  • 事實(shí)上還有一個(gè)非常重要的不同點(diǎn),那就是:
    • 冒泡排序法是穩(wěn)定的,選擇排序法是不穩(wěn)定的。

那么排序算法的穩(wěn)定性有什么意義呢?

? 其實(shí)它只在一種情況下有意義:當(dāng)要排序的內(nèi)容是一個(gè)對(duì)象的多個(gè)屬性,且其原本的順序存在意義時(shí),如果我們需要在二次排序后保持原有排序的意義,就需要使用到穩(wěn)定性的算法。排序算法的穩(wěn)定性與效率、可靠性都無(wú)關(guān)。

舉個(gè)例子,如果我們要對(duì)一組商品排序,商品存在兩個(gè)屬性:價(jià)格和銷量。當(dāng)我們按照價(jià)格從高到低排序后,要再按照銷量對(duì)其排序,這時(shí),如果要保證銷量相同的商品仍保持價(jià)格從高到低的順序,就必須使用穩(wěn)定性算法。

? 當(dāng)然,算法的穩(wěn)定性與具體的實(shí)現(xiàn)有關(guān)。在修改比較的條件后,穩(wěn)定性排序算法可能會(huì)變成不穩(wěn)定的。如冒泡算法中,如果將「左邊的數(shù)大于右邊的數(shù),則交換」這個(gè)條件修改為「左邊的數(shù)大于或等于右邊的數(shù),則交換」,冒泡算法就變得不穩(wěn)定了。同樣地,不穩(wěn)定排序算法也可以經(jīng)過(guò)修改,達(dá)到穩(wěn)定的效果。

思考一下,選擇排序算法如何實(shí)現(xiàn)穩(wěn)定排序呢?

? 實(shí)現(xiàn)的方式有很多種,這里給出一種最簡(jiǎn)單的思路:新開(kāi)一個(gè)數(shù)組,將每輪找出的最小值依次添加到新數(shù)組中,選擇排序算法就變成穩(wěn)定的了。

? 但如果將尋找最小值的比較條件由arr[minIndex] > arr[j]修改為arr[minIndex] >= arr[j],即使新開(kāi)一個(gè)數(shù)組,選擇排序算法依舊是不穩(wěn)定的。所以分析算法的穩(wěn)定性時(shí),需要結(jié)合具體的實(shí)現(xiàn)邏輯才能得出結(jié)論,我們通常所說(shuō)的算法穩(wěn)定性是基于一般實(shí)現(xiàn)而言的。

3. 插入排序(Insertion Sort)

插入排序的思想非常簡(jiǎn)單,生活中有一個(gè)很常見(jiàn)的場(chǎng)景:在打撲克牌時(shí),我們一邊抓牌一邊給撲克牌排序,每次摸一張牌,就將它插入手上已有的牌中合適的位置,逐漸完成整個(gè)排序。

3.1 算法描述

插入排序有兩種寫法:

  • 交換法:在新數(shù)字插入過(guò)程中,不斷與前面的數(shù)字交換,直到找到自己合適的位置。

  • 移動(dòng)法:在新數(shù)字插入過(guò)程中,與前面的數(shù)字不斷比較,前面的數(shù)字不斷向后挪出位置,當(dāng)新數(shù)字找到自己的位置后,插入一次即可。

  • 具體算法描述如下:

  • 從第一個(gè)元素開(kāi)始,該元素可以認(rèn)為已經(jīng)被排序;

  • 取出下一個(gè)元素,在已經(jīng)排序的元素序列中從后向前掃描;

  • 如果該元素(已排序)大于新元素,將該元素移到下一位置;

  • 重復(fù)步驟3,直到找到已排序的元素小于或者等于新元素的位置;

  • 將新元素插入到該位置后;

  • 重復(fù)步驟2~5。

3.2 圖解演示

3.3 代碼演示:

public static void insertSort(int[] arr) {// 從第二個(gè)數(shù)開(kāi)始,往前插入數(shù)字for (int i = 1; i < arr.length; i++) {// j 記錄當(dāng)前數(shù)字下標(biāo)int j = i;// 當(dāng)前數(shù)字比前一個(gè)數(shù)字小,則將當(dāng)前數(shù)字與前一個(gè)數(shù)字交換while (j >= 1 && arr[j] < arr[j - 1]) {swap(arr, j, j - 1);// 更新當(dāng)前數(shù)字下標(biāo)j--;}} } private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp; }

3.4 優(yōu)化過(guò)程

移動(dòng)法插入排序:

? 我們發(fā)現(xiàn),在交換法插入排序中,每次交換數(shù)字時(shí),swap 函數(shù)都會(huì)進(jìn)行三次賦值操作。但實(shí)際 上,新插入的這個(gè)數(shù)字并不一定適合與它交換的數(shù)字所在的位置。也就是說(shuō),它剛換到新的位置上不久,下一次比較后,如果又需要交換,它馬上又會(huì)被換到前一個(gè)數(shù)字的位置。

? 我們可以想到一種優(yōu)化方案:讓新插入的數(shù)字先進(jìn)行比較,前面比它大的數(shù)字不斷向后移動(dòng),直到找到適合這個(gè)新數(shù)字的位置后,新數(shù)字只做一次插入操作即可。整個(gè)過(guò)程就像是已經(jīng)有一些數(shù)字坐成了一排,這時(shí)一個(gè)新的數(shù)字要加入,所以這一排數(shù)字不斷地向后騰出位置,當(dāng)新的數(shù)字找到自己合適的位置后,就可以直接坐下了。重復(fù)此過(guò)程,直到排序結(jié)束。

動(dòng)圖演示:

這種方案我們需要把新插入的數(shù)字暫存起來(lái),代碼如下:

public static void insertSort(int[] arr) {// 從第二個(gè)數(shù)開(kāi)始,往前插入數(shù)字for (int i = 1; i < arr.length; i++) {int currentNumber = arr[i];int j = i - 1;// 尋找插入位置的過(guò)程中,不斷地將比 currentNumber 大的數(shù)字向后挪while (j >= 0 && currentNumber < arr[j]) {arr[j + 1] = arr[j];j--;}// 兩種情況會(huì)跳出循環(huán):1. 遇到一個(gè)小于或等于 currentNumber 的數(shù)字,跳出循環(huán),currentNumber 就坐到它后面。// 2. 已經(jīng)走到數(shù)列頭部,仍然沒(méi)有遇到小于或等于 currentNumber 的數(shù)字,也會(huì)跳出循環(huán),此時(shí) j 等于 -1,currentNumber 就坐到數(shù)列頭部。arr[j + 1] = currentNumber;} }

3.5 性能分析

時(shí)間復(fù)雜度:

? 在插入排序中,當(dāng)待排序數(shù)組是有序時(shí),是最優(yōu)的情況,只需當(dāng)前數(shù)跟前一個(gè)數(shù)比較一下就可以了,這時(shí)一共需要比較N- 1次,時(shí)間復(fù)雜度為O(n)。

? 最壞的情況是待排序數(shù)組是逆序的,此時(shí)需要比較次數(shù)最多,總次數(shù)記為:1+2+3+…+N-1,所以,插入排序最壞情況下的時(shí)間復(fù)雜度為O(n^2)。

? 平均來(lái)說(shuō),A[1…j-1]中的一半元素小于A[j],一半元素大于A[j],插入排序在平均情況運(yùn)行時(shí)間與最壞情況運(yùn)行時(shí)間一樣,是輸入規(guī)模的二次函數(shù),時(shí)間復(fù)雜度為O(n^2) 。

空間復(fù)雜度:

? 由于插入排序是就地排序的,沒(méi)有用到單獨(dú)的空間,插入排序的空間復(fù)雜度為常數(shù)階:O(1)。

穩(wěn)定性:

? 如果待排序的序列中存在兩個(gè)或兩個(gè)以上具有相同關(guān)鍵詞的數(shù)據(jù),排序后這些數(shù)據(jù)的相對(duì)次序保持不變,即它們的位置保持不變,通俗地講,就是兩個(gè)相同的數(shù)的相對(duì)順序不會(huì)發(fā)生改變,則該算法是穩(wěn)定的;如果排序后,數(shù)據(jù)的相對(duì)次序發(fā)生了變化,則該算法是不穩(wěn)定的。關(guān)鍵詞相同的數(shù)據(jù)元素將保持原有位置不變,所以該算法是穩(wěn)定的 。

3.6 應(yīng)用分析

  • 插入排序適用于已經(jīng)有部分?jǐn)?shù)據(jù)已經(jīng)排好,并且排好的部分越大越好。一般在輸入規(guī)模大于1000的場(chǎng)合下不建議使用插入排序。
  • 比冒泡排序的效率高,冒泡排序需要兩兩比較兩兩交換,時(shí)間基本快一倍。
  • 比選擇排序快一點(diǎn),因?yàn)槿绻懊娴臄?shù)組基本有序,平均上比較一半就找到位置,選擇排序總要從頭到尾找一遍。

4.快速排序(Quick Sort)

它的時(shí)間復(fù)雜度也是 O(nlogn)O(nlogn),但它在時(shí)間復(fù)雜度為 O(nlogn)O(nlogn) 級(jí)的幾種排序算法中,大多數(shù)情況下效率更高,所以快速排序的應(yīng)用非常廣泛。再加上快速排序所采用的分治思想非常實(shí)用,使得快速排序深受面試官的青睞,所以掌握快速排序的思想尤為重要。

4.1算法描述

快速排序算法的基本思想是:

  • 從數(shù)組中取出一個(gè)數(shù),稱之為基數(shù)(pivot)
  • 遍歷數(shù)組,將比基數(shù)大的數(shù)字放到它的右邊,比基數(shù)小的數(shù)字放到它的左邊。遍歷完成后,數(shù)組被分成了左右兩個(gè)區(qū)域
  • 將左右兩個(gè)區(qū)域視為兩個(gè)數(shù)組,重復(fù)前兩個(gè)步驟,直到排序完成

4.2 圖解演示

4.3 代碼實(shí)現(xiàn)

①快速排序遞歸框架

根據(jù)我們分析出的思路,先搭出快速排序的架子:

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1); } public static void quickSort(int[] arr, int start, int end) {// 將數(shù)組分區(qū),并獲得中間值的下標(biāo)int middle = partition(arr, start, end);// 對(duì)左邊區(qū)域快速排序quickSort(arr, start, middle - 1);// 對(duì)右邊區(qū)域快速排序quickSort(arr, middle + 1, end); } public static int partition(int[] arr, int start, int end) {// TODO: 將 arr 從 start 到 end 分區(qū),左邊區(qū)域比基數(shù)小,右邊區(qū)域比基數(shù)大,然后返回中間值的下標(biāo) }

? partition 意為“劃分”,我們期望 partition 函數(shù)做的事情是:將 arr 從 start 到 end 這一區(qū)間的值分成兩個(gè)區(qū)域,左邊區(qū)域的每個(gè)數(shù)都比基數(shù)小,右邊區(qū)域的每個(gè)數(shù)都比基數(shù)大,然后返回中間值的下標(biāo)。

? 只要有了這個(gè)函數(shù),我們就能寫出快速排序的遞歸函數(shù)框架。首先調(diào)用 partition 函數(shù)得到中間值的下標(biāo) middle,然后對(duì)左邊區(qū)域執(zhí)行快速排序,也就是遞歸調(diào)用 quickSort(arr, start, middle - 1),再對(duì)右邊區(qū)域執(zhí)行快速排序,也就是遞歸調(diào)用 quickSort(arr, middle + 1, end)。

現(xiàn)在還有一個(gè)問(wèn)題,何時(shí)退出這個(gè)遞歸函數(shù)呢?

②退出遞歸的邊界條件

? 很容易想到,當(dāng)某個(gè)區(qū)域只剩下一個(gè)數(shù)字的時(shí)候,自然不需要排序了,此時(shí)退出遞歸函數(shù)。實(shí)際上還有一種情況,就是某個(gè)區(qū)域只剩下 0 個(gè)數(shù)字時(shí),也需要退出遞歸函數(shù)。當(dāng) middle 等于 start 或者 end 時(shí),就會(huì)出現(xiàn)某個(gè)區(qū)域剩余數(shù)字為 0。

? 在遞歸之前,先判斷此區(qū)域剩余數(shù)字是否為 0 個(gè)或者 1 個(gè),當(dāng)數(shù)字至少為 2 個(gè)時(shí),才執(zhí)行這個(gè)區(qū)域的快速排序。因?yàn)槲覀冎?middle >= start && middle <= end 必然成立,所以判斷剩余區(qū)域的數(shù)字為 0 個(gè)或者 1 個(gè)也就是指 start 或 end 與 middle 相等或相差 1。

我們來(lái)分析一下這四個(gè)判斷條件:

  • 當(dāng) start == middle 時(shí),相當(dāng)于 quickSort(arr, start, middle - 1) 中的 start == end + 1

  • 當(dāng) start == middle - 1 時(shí),相當(dāng)于 quickSort(arr, start, middle - 1) 中的 start == end

  • 當(dāng) middle == end 時(shí),相當(dāng)于 quickSort(arr, middle + 1, end) 中的 start == end + 1

  • 當(dāng) middle == end -1 時(shí),相當(dāng)于 quickSort(arr, middle + 1, end) 中的 start == end

    由上文所說(shuō)的 middle >= start && middle <= end 可以推出,除了start == end || start == end + 1這兩個(gè)條件之外,其他的情況下 start 都小于 end。我們需要知道,這里的 start >= end 實(shí)際上只有兩種情況:

  • start == end: 表明區(qū)域內(nèi)只有一個(gè)數(shù)字

  • start == end + 1: 表明區(qū)域內(nèi)一個(gè)數(shù)字也沒(méi)有
    不會(huì)存在 start 比 end 大 2 或者大 3 之類的。

所以最終我們可以得出判斷條件為:

public static void quickSort(int[] arr, int start, int end) {// 如果區(qū)域內(nèi)的數(shù)字少于 2 個(gè),退出遞歸if (start >= end) return;// 將數(shù)組分區(qū),并獲得中間值的下標(biāo)int middle = partition(arr, start, end);// 對(duì)左邊區(qū)域快速排序quickSort(arr, start, middle - 1);// 對(duì)右邊區(qū)域快速排序quickSort(arr, middle + 1, end); }

③基數(shù)的選擇

基數(shù)的選擇沒(méi)有固定標(biāo)準(zhǔn),隨意選擇區(qū)間內(nèi)任何一個(gè)數(shù)字做基數(shù)都可以。通常來(lái)講有三種選擇方式:

  • 選擇第一個(gè)元素作為基數(shù)
  • 選擇最后一個(gè)元素作為基數(shù)
  • 選擇區(qū)間內(nèi)一個(gè)隨機(jī)元素作為基數(shù)

選擇的基數(shù)不同,算法的實(shí)現(xiàn)也不同。實(shí)際上第三種選擇方式的平均時(shí)間復(fù)雜度是最優(yōu)的。

? 為什么說(shuō)隨機(jī)選擇剩余數(shù)組中的一個(gè)元素作為基數(shù)的方案平均復(fù)雜度是最優(yōu)的呢?要理清這個(gè)問(wèn)題,我們先來(lái)看一下什么情況下快速排序算法的時(shí)間復(fù)雜度最高,一共有兩種情況:數(shù)組為正序、數(shù)組為逆序,理想中的快速排序在第 k 輪遍歷中,可以排好 2^{k-1}個(gè)基數(shù)。當(dāng)數(shù)組原本為正序或逆序時(shí),我們將第一個(gè)數(shù)作為基數(shù)的話,每輪分區(qū)后,都有一個(gè)區(qū)域是空的,也就是說(shuō)數(shù)組中剩下的數(shù)字都被分到了同一個(gè)區(qū)域!這就導(dǎo)致了每一輪遍歷只能排好一個(gè)基數(shù)。所以總的比較次數(shù)為 (n - 1) + (n - 2) + (n - 3) + … + 1 次,由等差數(shù)列求和公式可以計(jì)算出總的比較次數(shù)為 n(n - 1)/2 次,此時(shí)快速排序的時(shí)間復(fù)雜度達(dá)到了 O(n^2)級(jí)。

選擇第一個(gè)元素作為基數(shù):

// 將 arr 從 start 到 end 分區(qū),左邊區(qū)域比基數(shù)小,右邊區(qū)域比基數(shù)大,然后返回中間值的下標(biāo) public static int partition(int[] arr, int start, int end) {// 取第一個(gè)數(shù)為基數(shù)int pivot = arr[start];// 從第二個(gè)數(shù)開(kāi)始分區(qū)int left = start + 1;// 右邊界int right = end; }

④分區(qū)算法實(shí)現(xiàn)

? 快速排序中最重要的便是分區(qū)算法,也就是 partition 函數(shù)。partition 函數(shù)需要做的事情就是將 arr 從 start 到 end 分區(qū),左邊區(qū)域比基數(shù)小,右邊區(qū)域比基數(shù)大,然后返回中間值的下標(biāo)。那么首先我們要做的事情就是選擇一個(gè)基數(shù),基數(shù)我們一般稱之為 pivot,意為“軸”。整個(gè)數(shù)組就像圍繞這個(gè)軸進(jìn)行旋轉(zhuǎn),小于軸的數(shù)字旋轉(zhuǎn)到左邊,大于軸的數(shù)字旋轉(zhuǎn)到右邊。

⑤最簡(jiǎn)單的分區(qū)算法

? 分區(qū)的方式也有很多種,最簡(jiǎn)單的思路是:從 left 開(kāi)始,遇到比基數(shù)大的數(shù),就交換到數(shù)組最后,并將 right 減一,直到 left 和 right 相遇,此時(shí)數(shù)組就被分成了左右兩個(gè)區(qū)域。再將基數(shù)和中間的數(shù)交換,返回中間值的下標(biāo)即可。

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1); } public static void quickSort(int[] arr, int start, int end) {// 如果區(qū)域內(nèi)的數(shù)字少于 2 個(gè),退出遞歸if (start >= end) return;// 將數(shù)組分區(qū),并獲得中間值的下標(biāo)int middle = partition(arr, start, end);// 對(duì)左邊區(qū)域快速排序quickSort(arr, start, middle - 1);// 對(duì)右邊區(qū)域快速排序quickSort(arr, middle + 1, end); } // 將 arr 從 start 到 end 分區(qū),左邊區(qū)域比基數(shù)小,右邊區(qū)域比基數(shù)大,然后返回中間值的下標(biāo) public static int partition(int[] arr, int start, int end) {// 取第一個(gè)數(shù)為基數(shù)int pivot = arr[start];// 從第二個(gè)數(shù)開(kāi)始分區(qū)int left = start + 1;// 右邊界int right = end;// left、right 相遇時(shí)退出循環(huán)while (left < right) {// 找到第一個(gè)大于基數(shù)的位置while (left < right && arr[left] <= pivot) left++;// 交換這兩個(gè)數(shù),使得左邊分區(qū)都小于或等于基數(shù),右邊分區(qū)大于或等于基數(shù)if (left != right) {exchange(arr, left, right);right--;}}// 如果 left 和 right 相等,單獨(dú)比較 arr[right] 和 pivotif (left == right && arr[right] > pivot) right--;// 將基數(shù)和中間數(shù)交換if (right != start) exchange(arr, start, right);// 返回中間值的下標(biāo)return right; } private static void exchange(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp; }

? 因?yàn)槲覀冞x擇了數(shù)組的第一個(gè)元素作為基數(shù),并且分完區(qū)后,會(huì)執(zhí)行將基數(shù)和中間值交換的操作,這就意味著交換后的中間值會(huì)被分到左邊區(qū)域。所以我們需要保證中間值的下標(biāo)是分區(qū)完成后,最后一個(gè)比基數(shù)小的值,這里我們用 right 來(lái)記錄這個(gè)值。

? 這段代碼有一個(gè)細(xì)節(jié)。首先,在交換 left 和 right 之前,我們判斷了 left != right,這是因?yàn)槿绻S嗟臄?shù)組都比基數(shù)小,則 left 會(huì)加到 right 才停止,這時(shí)不應(yīng)該發(fā)生交換。因?yàn)?right 已經(jīng)指向了最后一個(gè)比基數(shù)小的值。

? 但這里的攔截可能會(huì)攔截到一種錯(cuò)誤情況,如果剩余的數(shù)組只有最后一個(gè)數(shù)比基數(shù)大,left 仍然加到 right 停止了,但我們并沒(méi)有發(fā)生交換。所以我們?cè)谕顺鲅h(huán)后,單獨(dú)比較了 arr[right] 和 pivot。

實(shí)際上,這行單獨(dú)比較的代碼非常巧妙,一共處理了三種情況:

  • 一是剛才提到的剩余數(shù)組中只有最后一個(gè)數(shù)比基數(shù)大的情況
  • 二是 left 和 right 區(qū)間內(nèi)只有一個(gè)值,則初始狀態(tài)下, left == right,所以 while (left < right) 根本不會(huì)進(jìn)入,所以此時(shí)我們單獨(dú)比較這個(gè)值和基數(shù)的大小關(guān)系
  • 三是剩余數(shù)組中每個(gè)數(shù)都比基數(shù)大,此時(shí) right 會(huì)持續(xù)減小,直到和 left 相等退出循環(huán),此時(shí) left 所在位置的值還沒(méi)有和 pivot 進(jìn)行比較,所以我們單獨(dú)比較 left 所在位置的值和基數(shù)的大小關(guān)系

⑥雙指針?lè)謪^(qū)算法

? 除了上述的分區(qū)算法外,還有一種雙指針的分區(qū)算法更為常用:從 left 開(kāi)始,遇到比基數(shù)大的數(shù),記錄其下標(biāo);再?gòu)?right 往前遍歷,找到第一個(gè)比基數(shù)小的數(shù),記錄其下標(biāo);然后交換這兩個(gè)數(shù)。繼續(xù)遍歷,直到 left 和 right 相遇。然后就和剛才的算法一樣了,交換基數(shù)和中間值,并返回中間值的下標(biāo)。

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1); } public static void quickSort(int[] arr, int start, int end) {// 如果區(qū)域內(nèi)的數(shù)字少于 2 個(gè),退出遞歸if (start >= end) return;// 將數(shù)組分區(qū),并獲得中間值的下標(biāo)int middle = partition(arr, start, end);// 對(duì)左邊區(qū)域快速排序quickSort(arr, start, middle - 1);// 對(duì)右邊區(qū)域快速排序quickSort(arr, middle + 1, end); } // 將 arr 從 start 到 end 分區(qū),左邊區(qū)域比基數(shù)小,右邊區(qū)域比基數(shù)大,然后返回中間值的下標(biāo) public static int partition(int[] arr, int start, int end) {// 取第一個(gè)數(shù)為基數(shù)int pivot = arr[start];// 從第二個(gè)數(shù)開(kāi)始分區(qū)int left = start + 1;// 右邊界int right = end;while (left < right) {// 找到第一個(gè)大于基數(shù)的位置while (left < right && arr[left] <= pivot) left++;// 找到第一個(gè)小于基數(shù)的位置while (left < right && arr[right] >= pivot) right--;// 交換這兩個(gè)數(shù),使得左邊分區(qū)都小于或等于基數(shù),右邊分區(qū)大于或等于基數(shù)if (left < right) {exchange(arr, left, right);left++;right--;}}// 如果 left 和 right 相等,單獨(dú)比較 arr[right] 和 pivotif (left == right && arr[right] > pivot) right--;// 將基數(shù)和軸交換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; }

同樣地,我們需要在退出循環(huán)后,單獨(dú)比較 left 和 right 的值。

4.4 性能分析

時(shí)間復(fù)雜度:

快速排序的每一次遍歷,都將基數(shù)擺到了最終位置上。第一輪遍歷排好 1 個(gè)基數(shù),第二輪遍歷排好 2 個(gè)基數(shù)(每個(gè)區(qū)域一個(gè)基數(shù),但如果某個(gè)區(qū)域?yàn)榭?#xff0c;則此輪只能排好一個(gè)基數(shù)),第三輪遍歷排好 4 個(gè)基數(shù)(同理,最差的情況下,只能排好一個(gè)基數(shù)),以此類推。總遍歷次數(shù)為 logn~n 次,每輪遍歷的時(shí)間復(fù)雜度為 O(n),所以很容易分析出快速排序的時(shí)間復(fù)雜度為 O(nlogn)~ O(n^2),平均時(shí)間復(fù)雜度為 O(nlogn),最壞的時(shí)間復(fù)雜度為 O(n^2),

空間復(fù)雜度:

? 空間復(fù)雜度與遞歸的層數(shù)有關(guān),每層遞歸會(huì)生成一些臨時(shí)變量,所以空間復(fù)雜度為 O(logn) ~ O(n),平均空間復(fù)雜度為 O(logn)。

穩(wěn)定性:

? 從代碼實(shí)現(xiàn)中可以分析出,快速排序是一種不穩(wěn)定的排序算法,在分區(qū)過(guò)程中,相同數(shù)字的相對(duì)順序可能會(huì)被修改。

4.5快速排序的優(yōu)化思路

如何解決這樣的問(wèn)題呢?其實(shí)思路也很簡(jiǎn)單,只要我們每輪選擇的基數(shù)不是剩余數(shù)組中最大或最小的值就可以了。具體方案有很多種,其中較常用的有三種:

  • 第一種就是我們?cè)谇拔闹刑岬降?#xff0c;每輪選擇基數(shù)時(shí),從剩余的數(shù)組中隨機(jī)選擇一個(gè)數(shù)字作為基數(shù)。這樣每輪都選到最大或最小值的概率就會(huì)變得很低了。所以我們才說(shuō)用這種方式選擇基數(shù),其平均時(shí)間復(fù)雜度是最優(yōu)的

  • 第二種解決方案是在排序之前,先用洗牌算法將數(shù)組的原有順序打亂,以防止原數(shù)組正序或逆序。

  • 第三種就是既然數(shù)組重復(fù)排序的情況如此常見(jiàn),那么我們可以在快速排序之前先對(duì)數(shù)組做個(gè)判斷,如果已經(jīng)有序則直接返回,如果是逆序則直接倒序即可。在 Java 內(nèi)部封裝的 Arrays.sort() 的源碼中就采用了此解決方案。

5.希爾排序(Shell Sort)

希爾排序和冒泡、選擇、插入等排序算法一樣,逐漸被快速排序所淘汰,但作為承上啟下的算法,不可否認(rèn)的是,希爾排序身上始終閃耀著算法之美。希爾排序本質(zhì)上是對(duì)插入排序的一種優(yōu)化,它利用了插入排序的簡(jiǎn)單,又克服了插入排序每次只交換相鄰兩個(gè)元素的缺點(diǎn)。

5.1 算法描述

它的基本思想是:

  • 將待排序數(shù)組按照一定的間隔分為多個(gè)子數(shù)組,每組分別進(jìn)行插入排序。這里按照間隔分組指的不是取連續(xù)的一段數(shù)組,而是每跳躍一定間隔取一個(gè)值組成一組
  • 逐漸縮小間隔進(jìn)行下一輪排序
  • 最后一輪時(shí),取間隔為 11,也就相當(dāng)于直接使用插入排序。但這時(shí)經(jīng)過(guò)前面的「宏觀調(diào)控」,數(shù)組已經(jīng)基本有序了,所以此時(shí)的插入排序只需進(jìn)行少量交換便可完成

每一遍排序的間隔在希爾排序中被稱之為增量,所有的增量組成的序列稱之為增量序列,也就是本例中的。增量依次遞減,最后一個(gè)增量必須為 1,所以希爾排序又被稱之為「縮小增量排序」。要是以專業(yè)術(shù)語(yǔ)來(lái)描述希爾排序,可以分為以下兩個(gè)步驟:

  • 定義增量序列 D_m > D_{m-1} > D_{m-2} > … > D_1 = 1
  • 對(duì)每個(gè)D_k進(jìn)行「D_k間隔排序」

有一條非常重要的性質(zhì)保證了希爾排序的效率:

「D_{k+1}間隔」有序的序列,在經(jīng)過(guò)「D_k間隔排序」排序后,仍然是有序的增量序列的選擇會(huì)極大地影響希爾排序的效率。

5.2 圖解演示

5.3 代碼實(shí)現(xiàn)

代碼實(shí)現(xiàn)如下:

public static void shellSort(int[] arr) {// 間隔序列,在希爾排序中我們稱之為增量序列for (int gap = arr.length / 2; gap > 0; gap /= 2) {// 分組for (int groupStartIndex = 0; groupStartIndex < gap; groupStartIndex++) {// 插入排序for (int currentIndex = groupStartIndex + gap; currentIndex < arr.length; currentIndex += gap) {// currentNumber 站起來(lái),開(kāi)始找位置int currentNumber = arr[currentIndex];int preIndex = currentIndex - gap;while (preIndex >= groupStartIndex && currentNumber < arr[preIndex]) {// 向后挪位置arr[preIndex + gap] = arr[preIndex];preIndex -= gap;}// currentNumber 找到了自己的位置,坐下arr[preIndex + gap] = currentNumber;}}} }

? 這份代碼與我們上文中提到的思路是一模一樣的,先分組,再對(duì)每組進(jìn)行插入排序。同樣地,這里的插入排序也可以采用交換元素的方式。

5.4 優(yōu)化過(guò)程

? 實(shí)際上,這段代碼可以優(yōu)化一下。我們現(xiàn)在的處理方式是:處理完一組間隔序列后,再回來(lái)處理下一組間隔序列,這非常符合人類思維。但對(duì)于計(jì)算機(jī)來(lái)說(shuō),它更喜歡從第 gap 個(gè)元素開(kāi)始,按照順序?qū)⒚總€(gè)元素依次向前插入自己所在的組這種方式。雖然這個(gè)過(guò)程看起來(lái)是在不同的間隔序列中不斷跳躍,但站在計(jì)算機(jī)的角度,它是在訪問(wèn)一段連續(xù)數(shù)組。

代碼實(shí)現(xiàn)如下:

public static void shellSort(int[] arr) {// 間隔序列,在希爾排序中我們稱之為增量序列for (int gap = arr.length / 2; gap > 0; gap /= 2) {// 從 gap 開(kāi)始,按照順序?qū)⒚總€(gè)元素依次向前插入自己所在的組for (int i = gap; i < arr.length; i++) {// currentNumber 站起來(lái),開(kāi)始找位置int currentNumber = arr[i];// 該組前一個(gè)數(shù)字的索引int preIndex = i - gap;while (preIndex >= 0 && currentNumber < arr[preIndex]) {// 向后挪位置arr[preIndex + gap] = arr[preIndex];preIndex -= gap;}// currentNumber 找到了自己的位置,坐下arr[preIndex + gap] = currentNumber;}} }

5.5增量序列

? 增量序列的選擇會(huì)極大地影響希爾排序的效率。增量序列如果選得不好,希爾排序的效率可能比插入排序效率還要低,舉個(gè)例子:

[外鏈圖片轉(zhuǎn)存失敗,源站可能有防盜鏈機(jī)制,建議將圖片保存下來(lái)直接上傳(img-VhSU36io-1620379072803)(C:\Users\86178\AppData\Roaming\Typora\typora-user-images\image-20210504124048637.png)]

在這個(gè)例子中,我們發(fā)現(xiàn),原數(shù)組 8 間隔、4 間隔、2 間隔都已經(jīng)有序了,使用希爾排序時(shí),真正起作用的只有最后一輪 1 間隔排序,也就是直接插入排序。希爾排序反而比直接使用插入排序多執(zhí)行了許多無(wú)用的邏輯。于是人們發(fā)現(xiàn):增量元素不互質(zhì),則小增量可能根本不起作用。

事實(shí)上,希爾排序的增量序列如何選擇是一個(gè)數(shù)學(xué)界的難題,但它也是希爾排序算法的核心優(yōu)化點(diǎn)。Knuth 增量序列:D_1 = 1; D_{k+1} = 3 * D_k + 1也就是1,4,13,40,…,數(shù)學(xué)界猜想它的平均時(shí)間復(fù)雜度為 O(n^{3/2});

以 Knuth 增量序列為例,使用 Knuth 序列進(jìn)行希爾排序的代碼如下:

public static void shellSortByKnuth(int[] arr) {// 找到當(dāng)前數(shù)組需要用到的 Knuth 序列中的最大值int maxKnuthNumber = 1;while (maxKnuthNumber <= arr.length / 3) {maxKnuthNumber = maxKnuthNumber * 3 + 1;}// 增量按照 Knuth 序列規(guī)則依次遞減for (int gap = maxKnuthNumber; gap > 0; gap = (gap - 1) / 3) {// 從 gap 開(kāi)始,按照順序?qū)⒚總€(gè)元素依次向前插入自己所在的組for (int i = gap; i < arr.length; i++) {// currentNumber 站起來(lái),開(kāi)始找位置int currentNumber = arr[i];// 該組前一個(gè)數(shù)字的索引int preIndex = i - gap;while (preIndex >= 0 && currentNumber < arr[preIndex]) {// 向后挪位置arr[preIndex + gap] = arr[preIndex];preIndex -= gap;}// currentNumber 找到了自己的位置,坐下arr[preIndex + gap] = currentNumber;}} }

5.6 性能分析

時(shí)間復(fù)雜度:

? 增量序列的選擇,Shell排序的執(zhí)行時(shí)間依賴于增量序列。

好的增量序列的共同特征:

  • 最后一個(gè)增量必須為1;

  • 應(yīng)該盡量避免序列中的值(尤其是相鄰的值)互為倍數(shù)的情況。

    有人通過(guò)大量的實(shí)驗(yàn),給出了較好的結(jié)果:當(dāng)n較大時(shí),比較和移動(dòng)的次數(shù)約在n1.25到(1.6n)1.25之間。

    事實(shí)上,希爾排序時(shí)間復(fù)雜度非常難以分析,它的平均復(fù)雜度界于 O(n) 到 O(n^2) 之間,普遍認(rèn)為它最好的時(shí)間復(fù)雜度為 O(n^{1.3}),

空間復(fù)雜度:

? 希爾排序的空間復(fù)雜度為 O(1),只需要常數(shù)級(jí)的臨時(shí)變量。

穩(wěn)定性:

? 雖然插入排序是穩(wěn)定的排序算法,但希爾排序是不穩(wěn)定的。在增量較大時(shí),排序過(guò)程可能會(huì)破壞原有數(shù)組中相同關(guān)鍵字的相對(duì)次序。

5.7 希爾排序與 O(n^2)級(jí)排序算法的本質(zhì)區(qū)別

希爾排序憑什么可以打破時(shí)間復(fù)雜度 O(n^2)的魔咒呢?它和之前介紹的 O(n^2)級(jí)排序算法的本質(zhì)區(qū)別是什么?

只要理解了這一點(diǎn),我們就能知道為什么希爾排序能夠承上啟下,啟發(fā)出之后的一系列 O(n^2)級(jí)以下的排序算法,這個(gè)問(wèn)題我們可以用逆序?qū)?lái)理解。

當(dāng)我們從小到大排序時(shí),在數(shù)組中的兩個(gè)數(shù)字,如果前面一個(gè)數(shù)字大于后面的數(shù)字,則這兩個(gè)數(shù)字組成一個(gè)逆序?qū)Α?/p>

排序算法本質(zhì)上就是一個(gè)消除逆序?qū)Φ倪^(guò)程。

對(duì)于隨機(jī)數(shù)組,逆序?qū)Φ臄?shù)量是 O(n^2)級(jí)的,如果采用「交換相鄰元素」的辦法來(lái)消除逆序?qū)?#xff0c;每次最多只能消除一組逆序?qū)?#xff0c;因此必須執(zhí)行 O(n^2)級(jí)的交換次數(shù),這就是為什么冒泡、插入、選擇算法只能到 O(n^2)級(jí)的原因。反過(guò)來(lái)說(shuō),基于交換元素的排序算法要想突破 O(n^2)級(jí),必須通過(guò)一些比較,交換間隔比較遠(yuǎn)的元素,使得一次交換能消除一個(gè)以上的逆序?qū)Α?/p>

希爾排序算法就是通過(guò)這種方式,打破了在空間復(fù)雜度為 O(1)的情況下,時(shí)間復(fù)雜度為 O(n^2)的魔咒,此后的快排、堆排等等算法也都是基于這樣的思路實(shí)現(xiàn)的。

6.歸并排序(Merge Sort)

? 歸并排序是建立在歸并操作上的一種有效的排序算法。該算法是采用分治法(Divide and Conquer)的一個(gè)非常典型的應(yīng)用。將已有序的子序列合并,得到完全有序的序列;即先使每個(gè)子序列有序,再使子序列段間有序。若將兩個(gè)有序表合并成一個(gè)有序表,稱為2-路歸并。

6.1算法描述

  • 把長(zhǎng)度為n的輸入序列分成兩個(gè)長(zhǎng)度為n/2的子序列;
  • 對(duì)這兩個(gè)子序列分別采用歸并排序;
  • 將兩個(gè)排序好的子序列合并成一個(gè)最終的排序序列。

6.2 算法圖解

6.3 代碼實(shí)現(xiàn)

6.3.1如何將兩個(gè)有序的列表合并成一個(gè)有序的列表?

? 在第二個(gè)列表向第一個(gè)列表逐個(gè)插入的過(guò)程中,由于第二個(gè)列表已經(jīng)有序,所以后續(xù)插入的元素一定不會(huì)在前面插入的元素之前。在逐個(gè)插入的過(guò)程中,每次插入時(shí),只需要從上次插入的位置開(kāi)始,繼續(xù)向后尋找插入位置即可。這樣一來(lái),我們最多只需要將兩個(gè)有序數(shù)組遍歷一次就可以完成合并。在向數(shù)組中不斷插入新數(shù)字時(shí),原數(shù)組需要不斷騰出位置,這是一個(gè)比較復(fù)雜的過(guò)程,而且這個(gè)過(guò)程必然導(dǎo)致增加一輪遍歷。有一個(gè)替代方案:只要開(kāi)辟一個(gè)長(zhǎng)度等同于兩個(gè)數(shù)組長(zhǎng)度之和的新數(shù)組,并使用兩個(gè)指針來(lái)遍歷原有的兩個(gè)數(shù)組,不斷將較小的數(shù)字添加到新數(shù)組中,并移動(dòng)對(duì)應(yīng)的指針即可。

代碼實(shí)現(xiàn)如下:

// 將兩個(gè)有序數(shù)組合并為一個(gè)有序數(shù)組 private static int[] merge(int[] arr1, int[] arr2) {int[] result = new int[arr1.length + arr2.length];int index1 = 0, index2 = 0;while (index1 < arr1.length && index2 < arr2.length) {if (arr1[index1] <= arr2[index2]) {result[index1 + index2] = arr1[index1++];//index1++;} else {result[index1 + index2] = arr2[index2++];//index2++;}}// 將剩余數(shù)字補(bǔ)到結(jié)果數(shù)組之后while (index1 < arr1.length) {result[index1 + index2] = arr1[index1++];//index1++;}while (index2 < arr2.length) {result[index1 + index2] = arr2[index2++];//index2++;}return result; }

6.3.2將數(shù)組拆分成有序數(shù)組

拆分過(guò)程使用了二分的思想,這是一個(gè)遞歸的過(guò)程,歸并排序使用的遞歸框架如下:

6.3.3歸并排序的優(yōu)化:減少臨時(shí)空間的開(kāi)辟

為了減少在遞歸過(guò)程中不斷開(kāi)辟空間的問(wèn)題,我們可以在歸并排序之前,先開(kāi)辟出一個(gè)臨時(shí)空間,在遞歸過(guò)程中統(tǒng)一使用此空間進(jìn)行歸并即可。

public static void mergeSort(int[] arr) {if (arr.length == 0) return;int[] result = new int[arr.length];mergeSort(arr, 0, arr.length - 1, result); }// 對(duì) arr 的 [start, end] 區(qū)間歸并排序 private static void mergeSort(int[] arr, int start, int end, int[] result) {// 只剩下一個(gè)數(shù)字,停止拆分if (start == end) return;int middle = (start + end) / 2;// 拆分左邊區(qū)域,并將歸并排序的結(jié)果保存到 result 的 [start, middle] 區(qū)間mergeSort(arr, start, middle, result);// 拆分右邊區(qū)域,并將歸并排序的結(jié)果保存到 result 的 [middle + 1, end] 區(qū)間mergeSort(arr, middle + 1, end, result);// 合并左右區(qū)域到 result 的 [start, end] 區(qū)間merge(arr, start, end, result); }// 將 result 的 [start, middle] 和 [middle + 1, end] 區(qū)間合并 private static void merge(int[] arr, int start, int end, int[] result) {int end1 = (start + end) / 2;int start2 = end1 + 1;// 用來(lái)遍歷數(shù)組的指針int index1 = start;int index2 = start2;while (index1 <= end1 && index2 <= end) {if (arr[index1] <= arr[index2]) {result[index1 + index2 - start2] = arr[index1++];} else {result[index1 + index2 - start2] = arr[index2++];}}// 將剩余數(shù)字補(bǔ)到結(jié)果數(shù)組之后while (index1 <= end1) {result[index1 + index2 - start2] = arr[index1++];}while (index2 <= end) {result[index1 + index2 - start2] = arr[index2++];}// 將 result 操作區(qū)間的數(shù)字拷貝到 arr 數(shù)組中,以便下次比較while (start <= end) {arr[start] = result[start++];} }

? 其中, mergeSort(int[] arr) 函數(shù)是對(duì)外暴露的公共方法,內(nèi)部調(diào)用了私有的mergeSort(int[] arr, int start, int end) 函數(shù),這個(gè)函數(shù)用于對(duì) arr 的 [start, end] 區(qū)間進(jìn)行歸并排序。

? 可以看到,我們?cè)谶@個(gè)函數(shù)中,將原有數(shù)組不斷地二分,直到只剩下最后一個(gè)數(shù)字。此時(shí)嵌套的遞歸開(kāi)始返回,一層層地調(diào)用merge(int[] arr1, int[] arr2)函數(shù),也就是我們剛才寫的將兩個(gè)有序數(shù)組合并為一個(gè)有序數(shù)組的函數(shù)。

? 這就是最經(jīng)典的歸并排序,只需要一個(gè)二分拆數(shù)組的遞歸函數(shù)和一個(gè)合并兩個(gè)有序列表的函數(shù)即可。

? 我們統(tǒng)一使用 result 數(shù)組作為遞歸過(guò)程中的臨時(shí)數(shù)組,所以merge 函數(shù)接收的參數(shù)不再是兩個(gè)數(shù)組,而是 result 數(shù)組中需要合并的兩個(gè)數(shù)組的首尾下標(biāo)。根據(jù)首尾下標(biāo)可以分別計(jì)算出兩個(gè)有序數(shù)組的首尾下標(biāo) start1、end1、start2、end2,之后的過(guò)程就和之前合并兩個(gè)有序數(shù)組的代碼類似了。

6.3.4原地歸并排序?

? 現(xiàn)在的歸并排序看起來(lái)仍"美中不足",那就是仍然需要開(kāi)辟額外的空間,能不能實(shí)現(xiàn)不開(kāi)辟額外空間的歸并排序呢?好像是可以做到的。在一些文章中,將這樣的歸并排序稱之為 In-Place Merge Sort,直譯為原地歸并排序。

public static void mergeSort(int[] arr) {if (arr.length == 0) return;mergeSort(arr, 0, arr.length - 1); }// 對(duì) arr 的 [start, end] 區(qū)間歸并排序 private static void mergeSort(int[] arr, int start, int end) {// 只剩下一個(gè)數(shù)字,停止拆分if (start == end) return;int middle = (start + end) / 2;// 拆分左邊區(qū)域mergeSort(arr, start, middle);// 拆分右邊區(qū)域mergeSort(arr, middle + 1, end);// 合并左右區(qū)域merge(arr, start, end); }// 將 arr 的 [start, middle] 和 [middle + 1, end] 區(qū)間合并 private static void merge(int[] arr, int start, int end) {int end1 = (start + end) / 2;int start2 = end1 + 1;// 用來(lái)遍歷數(shù)組的指針int index1 = start;int index2 = start2;while (index1 <= end1 && index2 <= end) {if (arr[index1] <= arr[index2]) {index1++;} else {// 右邊區(qū)域的這個(gè)數(shù)字比左邊區(qū)域的數(shù)字小,于是它站了起來(lái)int value = arr[index2];int index = index2;// 前面的數(shù)字不斷地后移while (index > index1) {arr[index] = arr[index - 1];index--;}// 這個(gè)數(shù)字坐到 index1 所在的位置上arr[index] = value;// 更新所有下標(biāo),使其前進(jìn)一格index1++;index2++;end1++;}} } /* 這段代碼在合并 arr 的 [start, middle] 區(qū)間和 [middle + 1, end] 區(qū)間時(shí),將兩個(gè)區(qū)間較小的數(shù)字移動(dòng)到 index1 的位置,并且將左邊區(qū)域不斷后移,目的是給新插入的數(shù)字騰出位置。最后更新兩個(gè)區(qū)間的下標(biāo),繼續(xù)合并更新后的區(qū)間。 */

? 分析代碼可以看出,這樣實(shí)現(xiàn)的歸并本質(zhì)上是插入排序!前文已經(jīng)說(shuō)到,在插入排序中,騰出位置是一個(gè)比較復(fù)雜的過(guò)程,而且這個(gè)過(guò)程必然導(dǎo)致增加一輪遍歷。在這兩份代碼中,每一次合并數(shù)組時(shí),都使用了兩層循環(huán),目的就是不斷騰挪位置以插入新數(shù)字,可以看出這里合并的效率是非常低的。這兩種排序算法的時(shí)間復(fù)雜度都達(dá)到了 O(n^2)級(jí),不能稱之為歸并排序。它們只是借用了歸并排序的遞歸框架而已。

? 也就是說(shuō),所謂的原地歸并排序事實(shí)上并不存在,許多算法書籍中都沒(méi)有收錄這種算法。它打著歸并排序的幌子,賣的是插入排序的思想,實(shí)際排序效率比歸并排序低得多。

6.4 性能分析

時(shí)間復(fù)雜度:

歸并排序的復(fù)雜度比較容易分析,拆分?jǐn)?shù)組的過(guò)程中,會(huì)將數(shù)組拆分 logn 次,每層執(zhí)行的比較次數(shù)都約等于 n 次,所以時(shí)間復(fù)雜度是 O(nlogn)。

空間復(fù)雜度:

空間復(fù)雜度是 O(n),主要占用空間的就是我們?cè)谂判蚯皠?chuàng)建的長(zhǎng)度為n的result數(shù)組。

穩(wěn)定性:

? 分析歸并的過(guò)程可知,歸并排序是一種穩(wěn)定的排序算法。其中,對(duì)算法穩(wěn)定性非常重要的一行代碼是:

if (arr[index1] <= arr[index2]) {
result[index1 + index2 - start2] = arr[index1++];
}
在這里我們通過(guò)arr[index1] <= arr[index2]來(lái)合并兩個(gè)有序數(shù)組,保證了原數(shù)組中,相同的元素相對(duì)順序不會(huì)變化,如果這里的比較條件寫成了arr[index1] < arr[index2],則歸并排序?qū)⒆兊貌环€(wěn)定。

6.5 應(yīng)用分析

? 總結(jié)起來(lái),歸并排序分成兩步,一是拆分?jǐn)?shù)組,二是合并數(shù)組,它是分治思想的典型應(yīng)用。分治的意思是“分而治之”,分的時(shí)候體現(xiàn)了二分的思想,治是一個(gè)滾雪球的過(guò)程,將 1 個(gè)數(shù)字組成的有序數(shù)組合并成一個(gè)包含 2 個(gè)數(shù)字的有序數(shù)組,再將 2 個(gè)數(shù)字組成的有序數(shù)組合并成包含 4 個(gè)數(shù)字的有序數(shù)組…

? 由于性能較好,且排序穩(wěn)定,歸并排序應(yīng)用非常廣泛,Arrays.sort() 源碼中的 TimSort就是歸并排序的優(yōu)化版。

7.堆排序(Heap Sort)

? 數(shù)組、鏈表都是一維的數(shù)據(jù)結(jié)構(gòu),相對(duì)來(lái)說(shuō)比較容易理解,而堆是二維的數(shù)據(jù)結(jié)構(gòu),對(duì)抽象思維的要求更高,所以許多程序員「談堆色變」。但堆又是數(shù)據(jù)結(jié)構(gòu)進(jìn)階必經(jīng)的一步,我們不妨靜下心來(lái),將其梳理清楚。

堆:符合以下兩個(gè)條件之一的完全二叉樹(shù):

根節(jié)點(diǎn)的值 ≥ 子節(jié)點(diǎn)的值,這樣的堆被稱之為最大堆,或大頂堆;

根節(jié)點(diǎn)的值 ≤ 子節(jié)點(diǎn)的值,這樣的堆被稱之為最小堆,或小頂堆。

7.1算法描述

堆排序過(guò)程如下:

  • 用數(shù)列構(gòu)建出一個(gè)大頂堆,取出堆頂?shù)臄?shù)字;
  • 調(diào)整剩余的數(shù)字,構(gòu)建出新的大頂堆,再次取出堆頂?shù)臄?shù)字;
  • 循環(huán)往復(fù),完成整個(gè)排序。

整體的思路就是這么簡(jiǎn)單,我們需要解決的問(wèn)題有兩個(gè):

  • 如何用數(shù)列構(gòu)建出一個(gè)大頂堆;

  • 取出堆頂?shù)臄?shù)字后,如何將剩余的數(shù)字調(diào)整成新的大頂堆。

    構(gòu)建大頂堆 & 調(diào)整堆
    構(gòu)建大頂堆有兩種方式:

    方案一:從 0 開(kāi)始,將每個(gè)數(shù)字依次插入堆中,一邊插入,一邊調(diào)整堆的結(jié)構(gòu),使其滿足大頂堆的要求;
    方案二:將整個(gè)數(shù)列的初始狀態(tài)視作一棵完全二叉樹(shù),自底向上調(diào)整樹(shù)的結(jié)構(gòu),使其滿足大頂堆的要求。
    方案二更為常用,動(dòng)圖演示如下:

    7.2圖解演示

在介紹堆排序具體實(shí)現(xiàn)之前,我們先要了解完全二叉樹(shù)的幾個(gè)性質(zhì)。將根節(jié)點(diǎn)的下標(biāo)視為 0,則完全二叉樹(shù)有如下性質(zhì):

  • 對(duì)于完全二叉樹(shù)中的第 i 個(gè)數(shù),它的左子節(jié)點(diǎn)下標(biāo):left = 2i + 1
  • 對(duì)于完全二叉樹(shù)中的第 i 個(gè)數(shù),它的右子節(jié)點(diǎn)下標(biāo):right = left + 1
  • 對(duì)于有 n 個(gè)元素的完全二叉樹(shù)(n≥2),它的最后一個(gè)非葉子結(jié)點(diǎn)的下標(biāo):n/2 - 1

7.3 代碼實(shí)現(xiàn)

堆排序代碼如下:

public static void heapSort(int[] arr) {// 構(gòu)建初始大頂堆buildMaxHeap(arr);for (int i = arr.length - 1; i > 0; i--) {// 將最大值交換到數(shù)組最后swap(arr, 0, i);// 調(diào)整剩余數(shù)組,使其滿足大頂堆maxHeapify(arr, 0, i);} } // 構(gòu)建初始大頂堆 private static void buildMaxHeap(int[] arr) {// 從最后一個(gè)非葉子結(jié)點(diǎn)開(kāi)始調(diào)整大頂堆,最后一個(gè)非葉子結(jié)點(diǎn)的下標(biāo)就是 arr.length / 2-1for (int i = arr.length / 2 - 1; i >= 0; i--) {maxHeapify(arr, i, arr.length);} } // 調(diào)整大頂堆,第三個(gè)參數(shù)表示剩余未排序的數(shù)字的數(shù)量,也就是剩余堆的大小 private static void maxHeapify(int[] arr, int i, int heapSize) {// 左子結(jié)點(diǎn)下標(biāo)int l = 2 * i + 1;// 右子結(jié)點(diǎn)下標(biāo)int r = l + 1;// 記錄根結(jié)點(diǎn)、左子樹(shù)結(jié)點(diǎn)、右子樹(shù)結(jié)點(diǎn)三者中的最大值下標(biāo)int largest = i;// 與左子樹(shù)結(jié)點(diǎn)比較if (l < heapSize && arr[l] > arr[largest]) {largest = l;}// 與右子樹(shù)結(jié)點(diǎn)比較if (r < heapSize && arr[r] > arr[largest]) {largest = r;}if (largest != i) {// 將最大值交換為根結(jié)點(diǎn)swap(arr, i, largest);// 再次調(diào)整交換數(shù)字后的大頂堆maxHeapify(arr, largest, heapSize);} } private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp; }

? 堆排序的第一步就是構(gòu)建大頂堆,對(duì)應(yīng)代碼中的 buildMaxHeap 函數(shù)。我們將數(shù)組視作一顆完全二叉樹(shù),從它的最后一個(gè)非葉子結(jié)點(diǎn)開(kāi)始,調(diào)整此結(jié)點(diǎn)和其左右子樹(shù),使這三個(gè)數(shù)字構(gòu)成一個(gè)大頂堆。

? 調(diào)整過(guò)程由 maxHeapify 函數(shù)處理, maxHeapify 函數(shù)記錄了最大值的下標(biāo),根結(jié)點(diǎn)和其左右子樹(shù)結(jié)點(diǎn)在經(jīng)過(guò)比較之后,將最大值交換到根結(jié)點(diǎn)位置。這樣,這三個(gè)數(shù)字就構(gòu)成了一個(gè)大頂堆。

? 需要注意的是,如果根結(jié)點(diǎn)和左右子樹(shù)結(jié)點(diǎn)任何一個(gè)數(shù)字發(fā)生了交換,則還需要保證調(diào)整后的子樹(shù)仍然是大頂堆,所以子樹(shù)會(huì)執(zhí)行一個(gè)遞歸的調(diào)整過(guò)程。

? 這里的遞歸比較難理解,我們打個(gè)比方:構(gòu)建大頂堆的過(guò)程就是一堆數(shù)字比賽誰(shuí)更大。比賽過(guò)程分為初賽、復(fù)賽、決賽,每場(chǎng)比賽都是三人參加。但不是所有人都會(huì)參加初賽,只有葉子結(jié)點(diǎn)和第一批非葉子結(jié)點(diǎn)會(huì)進(jìn)行三人組初賽。初賽的冠軍站到三人組的根結(jié)點(diǎn)位置,然后繼續(xù)參加后面的復(fù)賽。

? 而有的人生來(lái)就在上層,比如李小胖,它出生在數(shù)列的第一個(gè)位置上,是二叉樹(shù)的根結(jié)點(diǎn),當(dāng)其他結(jié)點(diǎn)進(jìn)行初賽、復(fù)賽時(shí),它就靜靜躺在根結(jié)點(diǎn)的位置等一場(chǎng)決賽。

? 當(dāng)王大強(qiáng)和張壯壯,經(jīng)歷了重重比拼來(lái)到了李小胖的左右子樹(shù)結(jié)點(diǎn)位置。他們?nèi)齻€(gè)人開(kāi)始決賽。王大強(qiáng)和張壯壯是靠實(shí)打?qū)嵉膶?shí)力打上來(lái)的,他們已經(jīng)確認(rèn)過(guò)自己是小組最強(qiáng)。而李小胖之前一直躺在這里等決賽。如果李小胖贏了他們兩個(gè),說(shuō)明李小胖是所有小組里最強(qiáng)的,毋庸置疑,他可以繼續(xù)坐在冠軍寶座。

? 但李小胖如果輸給了其中任何一個(gè)人,比如輸給了王大強(qiáng)。王大強(qiáng)會(huì)和張壯壯對(duì)決,選出本次構(gòu)建大頂堆的冠軍。但李小胖能夠坐享其成獲得第三名嗎?生活中或許會(huì)有這樣的黑幕,但程序不會(huì)欺騙我們。李小胖跌落神壇之后,就要從王大強(qiáng)的打拼路線回去,繼續(xù)向下比較,找到自己真正實(shí)力所在的真實(shí)位置。這就是 maxHeapify 中會(huì)繼續(xù)遞歸調(diào)用 maxHeapify 的原因。

? 當(dāng)構(gòu)建出大頂堆之后,就要把冠軍交換到數(shù)列最后,深藏功與名。來(lái)到冠軍寶座的新人又要和李小胖一樣,開(kāi)始向下比較,找到自己的真實(shí)位置,使得剩下的 n - 1n?1 個(gè)數(shù)字構(gòu)建成新的大頂堆。這就是 heapSort 方法的 for 循環(huán)中,調(diào)用 maxHeapify 的原因。

? 變量 heapSize 用來(lái)記錄還剩下多少個(gè)數(shù)字沒(méi)有排序完成,每當(dāng)交換了一個(gè)堆頂?shù)臄?shù)字,heapSize 就會(huì)減 11。在 maxHeapify 方法中,使用 heapSize 來(lái)限制剩下的選手,不要和已經(jīng)躺在數(shù)組最后,當(dāng)過(guò)冠軍的人比較,免得被暴揍。

? 這就是堆排序的思想。學(xué)習(xí)時(shí)我們采用的是最簡(jiǎn)單的代碼實(shí)現(xiàn),在熟練掌握了之后我們就可以加一些小技巧以獲得更高的效率。比如我們知道計(jì)算機(jī)采用二進(jìn)制來(lái)存儲(chǔ)數(shù)據(jù),數(shù)字左移一位表示乘以 22,右移一位表示除以 22。所以堆排序代碼中的arr.length / 2 - 1 可以修改為 (arr.length >> 1) - 1,左子結(jié)點(diǎn)下標(biāo)2 * i + 1可以修改為(i << 1) + 1。需要注意的是,位運(yùn)算符的優(yōu)先級(jí)比加減運(yùn)算的優(yōu)先級(jí)低,所以必須給位運(yùn)算過(guò)程加上括號(hào)。

7.4 性能分析

時(shí)間復(fù)雜度:

? 堆排序分為兩個(gè)階段:初始化建堆(buildMaxHeap)和重建堆(maxHeapify,直譯為大頂堆化)。所以時(shí)間復(fù)雜度要從這兩個(gè)方面分析。

? 根據(jù)數(shù)學(xué)運(yùn)算可以推導(dǎo)出初始化建堆的時(shí)間復(fù)雜度為 O(n),重建堆的時(shí)間復(fù)雜度為 O(n\log n)O,所以堆排序總的時(shí)間復(fù)雜度為 O(n\log n)。推導(dǎo)過(guò)程較為復(fù)雜,故不再給出證明過(guò)程。

空間復(fù)雜度:

堆排序的空間復(fù)雜度為 O(1),只需要常數(shù)級(jí)的臨時(shí)變量。堆排序是一個(gè)優(yōu)秀的排序算法,但是在實(shí)際應(yīng)用中,快速排序的性能一般會(huì)優(yōu)于堆排序。

穩(wěn)定性:

? 堆排序是不穩(wěn)定的

? 比如:9 18 27 18

? 如果堆頂9先輸出,則第三層的18(最后一個(gè)18)跑到堆頂,然后堆穩(wěn)定,繼續(xù)輸出堆頂,是剛才那個(gè)18,這樣說(shuō)明后面的18先于第二個(gè)位置的18輸出,所以不穩(wěn)定。

8.計(jì)數(shù)排序(Counting Sort)

計(jì)數(shù)排序是一個(gè)非基于比較的排序算法,它的優(yōu)勢(shì)在于在對(duì)一定范圍內(nèi)的整數(shù)排序時(shí),它的復(fù)雜度為Ο(n+k)(其中k是整數(shù)的范圍),快于任何比較排序算法。當(dāng)然這是一種犧牲空間換取時(shí)間的做法,而且當(dāng)O(k)>O(nlog(n))的時(shí)候其效率反而不如基于比較的排序(基于比較的排序的時(shí)間復(fù)雜度在理論上的下限是O(n*log(n)), 如歸并排序,堆排序)

8.1算法描述

算法思想:

  • 找出待排序的數(shù)組中最大和最小的元素;
  • 統(tǒng)計(jì)數(shù)組中每個(gè)值為i的元素出現(xiàn)的次數(shù),存入數(shù)組C的第i項(xiàng);
  • 對(duì)所有的計(jì)數(shù)累加(從C中的第一個(gè)元素開(kāi)始,每一項(xiàng)和前一項(xiàng)相加);
  • 反向填充目標(biāo)數(shù)組:將每個(gè)元素i放在新數(shù)組的第C(i)項(xiàng),每放一個(gè)元素就將C(i)減去1。

8.2 圖解演示

8.3代碼實(shí)現(xiàn)

代碼實(shí)現(xiàn)如下:

//針對(duì)c數(shù)組的大小,優(yōu)化過(guò)的計(jì)數(shù)排序 public class CountSort{publicstaticvoidmain(String[]args){//排序的數(shù)組int a[]={100,93,97,92,96,99,92,89,93,97,90,94,92,95};int b[]=countSort(a);for(inti:b){System.out.print(i+"");}System.out.println();}public static int[] countSort(int[]a){int b[] = new int[a.length];int max = a[0],min = a[0];for(int i:a){if(i>max){max=i;}if(i<min){min=i;}}//這里k的大小是要排序的數(shù)組中,元素大小的極值差+1int k=max-min+1;int c[]=new int[k];for(int i=0;i<a.length;++i){c[a[i]-min]+=1;//優(yōu)化過(guò)的地方,減小了數(shù)組c的大小}for(int i=1;i<c.length;++i){c[i]=c[i]+c[i-1];}for(int i=a.length-1;i>=0;--i){b[--c[a[i]-min]]=a[i];//按存取的方式取出c的元素}return b;} }

8.4 性能分析

時(shí)間復(fù)雜度:

? 原數(shù)組、count數(shù)組、累加數(shù)組、目標(biāo)數(shù)組;先對(duì)原數(shù)組過(guò)濾一遍生成count數(shù)組,對(duì)count數(shù)組過(guò)濾一遍生成累加數(shù)組,最后再把原數(shù)組從后往前生成目標(biāo)數(shù)組,原數(shù)組過(guò)濾兩遍,count數(shù)組過(guò)濾兩遍,原數(shù)組是長(zhǎng)度是n,count數(shù)組長(zhǎng)度是k,即2(n+k);

? 從計(jì)數(shù)排序的實(shí)現(xiàn)代碼中,可以看到,每次遍歷都是進(jìn)行 n 次或者 k 次,所以計(jì)數(shù)排序的時(shí)間復(fù)雜度為 O(n + k),k 表示數(shù)據(jù)的范圍大小。用到的空間主要是長(zhǎng)度為 k 的計(jì)數(shù)數(shù)組和長(zhǎng)度為 n 的結(jié)果數(shù)組,所以空間復(fù)雜度也是 O(n + k)。

空間復(fù)雜度:

? 用了額外的數(shù)組長(zhǎng)度為n和k,所以空間復(fù)雜度為O(n+k)。

穩(wěn)定性:

? 經(jīng)計(jì)數(shù)排序,輸出序列中值相同的元素之間的相對(duì)次序與他們?cè)谳斎胄蛄兄械南鄬?duì)次序相同,換句話說(shuō),計(jì)數(shù)排序算法是一個(gè)穩(wěn)定的排序算法。

8.5 拓展

需要注意的是,一般我們分析時(shí)間復(fù)雜度和空間復(fù)雜度時(shí),常數(shù)項(xiàng)都是忽略不計(jì)的。但計(jì)數(shù)排序的常數(shù)項(xiàng)可能非常大,以至于我們無(wú)法忽略。不知你是否注意到計(jì)數(shù)排序的一個(gè)非常大的隱患,比如我們想要對(duì)這個(gè)數(shù)組排序:

int[] arr = new int[]{1, Integer.MAX_VALUE};

盡管它只包含兩個(gè)元素,但數(shù)據(jù)范圍是 [1, 2^{31}],我們知道java中int 占4個(gè)字節(jié),一個(gè)長(zhǎng)度為 2^{31}次方的 int 數(shù)組大約會(huì)占8G的空間。如果使用計(jì)數(shù)排序,僅僅排序這兩個(gè)元素,聲明計(jì)數(shù)數(shù)組就會(huì)占用超大的內(nèi)存,甚至導(dǎo)致 OutOfMemory 異常。

使用于特定問(wèn)題,對(duì)源數(shù)據(jù)有要求,適合量比較大,數(shù)值范圍比較小,例如企業(yè)員工年齡排序,快速查詢高考名次,如果需要排序的數(shù)字中存在一位小數(shù),可以將所有數(shù)字乘以 10,再去計(jì)算最終的下標(biāo)位置。當(dāng)k不是很大并且序列比較集中時(shí),計(jì)數(shù)排序是一個(gè)很有效的排序算法。

9.基數(shù)排序(Radix Sort)

基數(shù)排序有兩種實(shí)現(xiàn)方式。本例屬于「最高位優(yōu)先法」,簡(jiǎn)稱 MSD (Most significant digital),思路是從最高位開(kāi)始,依次對(duì)基數(shù)進(jìn)行排序。與之對(duì)應(yīng)的是「最低位優(yōu)先法」,簡(jiǎn)稱 LSD (Least significant digital)。思路是從最低位開(kāi)始,依次對(duì)基數(shù)進(jìn)行排序。使用 LSD 必須保證對(duì)基數(shù)進(jìn)行排序的過(guò)程是穩(wěn)定的。

通常來(lái)講,LSD 比 MSD 更常用。因?yàn)槭褂玫氖?MSD,例如在第二步比較兩個(gè)以 99 開(kāi)頭的數(shù)字時(shí),其他基數(shù)開(kāi)頭的數(shù)字不得不放到一邊。體現(xiàn)在計(jì)算機(jī)中,這里會(huì)產(chǎn)生很多臨時(shí)變量。

但在采用 LSD 進(jìn)行基數(shù)排序時(shí),每一輪遍歷都可以將所有數(shù)字一視同仁,統(tǒng)一處理。所以 LSD 的基數(shù)排序更符合計(jì)算機(jī)的操作習(xí)慣。

9.1 算法描述

基數(shù)排序可以分為以下三個(gè)步驟:

  • 找出數(shù)組中最大的數(shù)字的位數(shù) maxDigitLength
  • 獲取數(shù)組中每個(gè)數(shù)字的基數(shù)
  • 遍歷 maxDigitLength 輪數(shù)組,每輪按照基數(shù)對(duì)其進(jìn)行排序

9.2 圖解演示

9.3 代碼實(shí)現(xiàn)

9.3.1找出數(shù)組中最大的數(shù)字的位數(shù)

首先找到數(shù)組中的最大值:

public static void radixSort(int[] arr) {if (arr == null) return;int max = 0;for (int value : arr) {if (value > max) {max = value;}}// ... }

通過(guò)遍歷一次數(shù)組,找到了數(shù)組中的最大值 max,然后我們計(jì)算這個(gè)最大值的位數(shù):

int maxDigitLength = 0; while (max != 0) {maxDigitLength++;max /= 10; }

將 maxDigitLength 初始化為 00,然后不斷地除以 10,每除一次,maxDigitLength 就加一,直到 max 為 00。

如果 max 初始值就是 00 呢?嚴(yán)格來(lái)講,00 在數(shù)學(xué)上屬于 11 位數(shù)。但實(shí)際上,基數(shù)排序時(shí)我們無(wú)需考慮 max 為 00 的場(chǎng)景,因?yàn)?max 為 00 只有一種可能,那就是數(shù)組中所有的數(shù)字都為 00,此時(shí)數(shù)組已經(jīng)有序,我們無(wú)需再進(jìn)行后續(xù)的排序過(guò)程。

9.3.2獲取基數(shù):

int dev = 1; for (int i = 0; i < maxDigitLength; i++) {for (int value : arr) {int radix = value / dev % 10;// 對(duì)基數(shù)進(jìn)行排序}dev *= 10; }

9.3.3對(duì)基數(shù)進(jìn)行排序:

因?yàn)槊恳粋€(gè)基數(shù)都在 [0, 9] 之間,并且計(jì)數(shù)排序是一種穩(wěn)定的算法。

LSD 方式的基數(shù)排序代碼如下:

public class RadixSort {public static void radixSort(int[] arr) {if (arr == null) return;// 找出最大值int max = 0;for (int value : arr) {if (value > max) {max = value;}}// 計(jì)算最大數(shù)字的長(zhǎng)度int maxDigitLength = 0;while (max != 0) {maxDigitLength++;max /= 10;}// 使用計(jì)數(shù)排序算法對(duì)基數(shù)進(jìn)行排序int[] counting = new int[10];int[] result = new int[arr.length];int dev = 1;for (int i = 0; i < maxDigitLength; i++) {for (int value : arr) {int radix = value / dev % 10;counting[radix]++;}for (int j = 1; j < counting.length; j++) {counting[j] += counting[j - 1];}// 使用倒序遍歷的方式完成計(jì)數(shù)排序for (int j = arr.length - 1; j >= 0; j--) {int radix = arr[j] / dev % 10;result[--counting[radix]] = arr[j];}// 計(jì)數(shù)排序完成后,將結(jié)果拷貝回 arr 數(shù)組System.arraycopy(result, 0, arr, 0, arr.length);// 將計(jì)數(shù)數(shù)組重置為 0Arrays.fill(counting, 0);dev *= 10;}} }

當(dāng)每一輪對(duì)基數(shù)完成排序后,我們將 result 數(shù)組的值拷貝回 arr 數(shù)組,并且將 counting 數(shù)組中的元素都置為 00,以便在下一輪中復(fù)用。

9.4 對(duì)包含負(fù)數(shù)的數(shù)組進(jìn)行基數(shù)排序

如果數(shù)組中包含負(fù)數(shù),如何進(jìn)行基數(shù)排序呢?

? 我們很容易想到一種思路:將數(shù)組中的每個(gè)元素都加上一個(gè)合適的正整數(shù),使其全部變成非負(fù)整數(shù),等到排序完成后,再減去之前加的這個(gè)數(shù)就可以了。

但這種方案有一個(gè)缺點(diǎn):加法運(yùn)算可能導(dǎo)致數(shù)字越界,所以必須單獨(dú)處理數(shù)字越界的情況。

? 事實(shí)上,有一種更好的方案解決負(fù)數(shù)的基數(shù)排序。那就是在對(duì)基數(shù)進(jìn)行計(jì)數(shù)排序時(shí),申請(qǐng)長(zhǎng)度為19的計(jì)數(shù)數(shù)組,用來(lái)存儲(chǔ) [-9, 9]這個(gè)區(qū)間內(nèi)的所有整數(shù)。在把每一位基數(shù)計(jì)算出來(lái)后,加上99,就能對(duì)應(yīng)上 counting 數(shù)組的下標(biāo)了。也就是說(shuō),counting數(shù)組的下標(biāo)[0, 18]對(duì)應(yīng)基數(shù) [-9, 9]。

代碼實(shí)現(xiàn)如下:

public class RadixSort {public static void radixSort(int[] arr) {if (arr == null) return;// 找出最長(zhǎng)的數(shù)int max = 0;for (int value : arr) {if (Math.abs(value) > max) {max = Math.abs(value);}}// 計(jì)算最長(zhǎng)數(shù)字的長(zhǎng)度int maxDigitLength = 0;while (max != 0) {maxDigitLength++;max /= 10;}// 使用計(jì)數(shù)排序算法對(duì)基數(shù)進(jìn)行排序,下標(biāo) [0, 18] 對(duì)應(yīng)基數(shù) [-9, 9]int[] counting = new int[19];int[] result = new int[arr.length];int dev = 1;for (int i = 0; i < maxDigitLength; i++) {for (int value : arr) {// 下標(biāo)調(diào)整int radix = value / dev % 10 + 9;counting[radix]++;}for (int j = 1; j < counting.length; j++) {counting[j] += counting[j - 1];}// 使用倒序遍歷的方式完成計(jì)數(shù)排序for (int j = arr.length - 1; j >= 0; j--) {// 下標(biāo)調(diào)整int radix = arr[j] / dev % 10 + 9;result[--counting[radix]] = arr[j];}// 計(jì)數(shù)排序完成后,將結(jié)果拷貝回 arr 數(shù)組System.arraycopy(result, 0, arr, 0, arr.length);// 將計(jì)數(shù)數(shù)組重置為 0Arrays.fill(counting, 0);dev *= 10;}} }

代碼中主要做了兩處修改:

  • 當(dāng)數(shù)組中存在負(fù)數(shù)時(shí),我們就不能簡(jiǎn)單的計(jì)算數(shù)組的最大值了,而是要計(jì)算數(shù)組中絕對(duì)值最大的數(shù),也就是數(shù)組中最長(zhǎng)的數(shù)
  • 在獲取基數(shù)的步驟,將計(jì)算出的基數(shù)加上9,使其與 counting 數(shù)組下標(biāo)一一對(duì)應(yīng)

9.5 LSD VS MSD

前文介紹的基數(shù)排序都屬于 LSD,接下來(lái)我們看一下基數(shù)排序的 MSD 實(shí)現(xiàn):

public class RadixSort {public static void radixSort(int[] arr) {if (arr == null) return;// 找到最大值int max = 0;for (int value : arr) {if (Math.abs(value) > max) {max = Math.abs(value);}}// 計(jì)算最大長(zhǎng)度int maxDigitLength = 0;while (max != 0) {maxDigitLength++;max /= 10;}radixSort(arr, 0, arr.length - 1, maxDigitLength);}// 對(duì) arr 數(shù)組中的 [start, end] 區(qū)間進(jìn)行基數(shù)排序private static void radixSort(int[] arr, int start, int end, int position) {if (start == end || position == 0) return;// 使用計(jì)數(shù)排序?qū)鶖?shù)進(jìn)行排序int[] counting = new int[19];int[] result = new int[end - start + 1];int dev = (int) Math.pow(10, position - 1);for (int i = start; i <= end; i++) {// MSD, 從最高位開(kāi)始int radix = arr[i] / dev % 10 + 9;counting[radix]++;}for (int j = 1; j < counting.length; j++) {counting[j] += counting[j - 1];}// 拷貝 counting,用于待會(huì)的遞歸int[] countingCopy = new int[counting.length];System.arraycopy(counting, 0, countingCopy, 0, counting.length);for (int i = end; i >= start; i--) {int radix = arr[i] / dev % 10 + 9;result[--counting[radix]] = arr[i];}// 計(jì)數(shù)排序完成后,將結(jié)果拷貝回 arr 數(shù)組System.arraycopy(result, 0, arr, start, result.length);// 對(duì) [start, end] 區(qū)間內(nèi)的每一位基數(shù)進(jìn)行遞歸排序for (int i = 0; i < counting.length; i++) {radixSort(arr, i == 0 ? start : start + countingCopy[i - 1], start + countingCopy[i] - 1, position - 1);}}}

使用 MSD 時(shí),下一輪排序只應(yīng)該發(fā)生在當(dāng)前輪次基數(shù)相等的數(shù)字之間,對(duì)每一位基數(shù)進(jìn)行遞歸排序的過(guò)程中會(huì)產(chǎn)生許多臨時(shí)變量。

相比 LSD,MSD 的基數(shù)排序顯得較為復(fù)雜。因?yàn)槲覀兠看螌?duì)基數(shù)進(jìn)行排序后,無(wú)法將所有的結(jié)果一視同仁地進(jìn)行下一輪排序,否則下一輪排序會(huì)破壞本次排序的結(jié)果。

9.6 性能分析

時(shí)間復(fù)雜度:

? 無(wú)論 LSD 還是 MSD,基數(shù)排序時(shí)都需要經(jīng)歷 maxDigitLength 輪遍歷,每輪遍歷的時(shí)間復(fù)雜度為 O(n + k) ,其中 k 表示每個(gè)基數(shù)可能的取值范圍大小。如果是對(duì)非負(fù)整數(shù)排序,則 k = 10,如果是對(duì)包含負(fù)數(shù)的數(shù)組排序,則 k = 19。所以基數(shù)排序的時(shí)間復(fù)雜度為 O(d(n + k)) (d 表示最長(zhǎng)數(shù)字的位數(shù),k 表示每個(gè)基數(shù)可能的取值范圍大小)。

? 每次都復(fù)制一遍,一遍的時(shí)間復(fù)雜度為O(n),所以時(shí)間復(fù)雜度為O(n*k)。

空間復(fù)雜度:

? 使用的空間和計(jì)數(shù)排序是一樣的,空間復(fù)雜度為 O(n + k)(k 表示每個(gè)基數(shù)可能的取值范圍大小)。

穩(wěn)定性:

? 在基數(shù)排序過(guò)程中,每一次裝桶都是將當(dāng)前位數(shù)上相同數(shù)值的元素進(jìn)行裝桶,并不需要交換位置;所以基數(shù)排序是穩(wěn)定的算法。

如果負(fù)數(shù)可以使用正負(fù)數(shù)桶,負(fù)數(shù)的排負(fù)數(shù),正數(shù)的排正數(shù),然后就可以達(dá)到要求。

10.桶排序(Bucket Sort)

桶排序利用了函數(shù)的映射關(guān)系,高效與否的關(guān)鍵就在于這個(gè)映射函數(shù)的確定;桶排序 (Bucket sort)的工作的原理:假設(shè)輸入數(shù)據(jù)服從均勻分布,將數(shù)據(jù)分到有限數(shù)量的桶里,每個(gè)桶再分別排序(有可能再使用別的排序算法或是以遞歸方式繼續(xù)使用桶排序進(jìn)行排);桶排序的思想近乎徹底的分治思想;桶排序假設(shè)待排序的一組數(shù)均勻獨(dú)立的分布在一個(gè)范圍中,并將這一范圍劃分成幾個(gè)子范圍(桶)。

10.1 算法描述

  • 設(shè)置一個(gè)定量的數(shù)組當(dāng)作空桶;
  • 遍歷輸入數(shù)據(jù),并且把數(shù)據(jù)一個(gè)一個(gè)放到對(duì)應(yīng)的桶里去;
  • 對(duì)每個(gè)不是空的桶進(jìn)行排序;
  • 從不是空的桶里把排好序的數(shù)據(jù)拼接起來(lái),得到結(jié)果。

10.2 圖解演示

[外鏈圖片轉(zhuǎn)存失敗,源站可能有防盜鏈機(jī)制,建議將圖片保存下來(lái)直接上傳(img-vBv608ur-1620379072809)(C:\Users\86178\AppData\Roaming\Typora\typora-user-images\image-20210506215159979.png)]

10.3 代碼實(shí)現(xiàn)

代碼實(shí)現(xiàn):

public static double[] bucketSort(double[] array){//得到數(shù)列的最大值和最小值,并計(jì)算出差值ddouble 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;//初始化桶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>());}//遍歷原始數(shù)組將每個(gè)元素放入桶中for (int i=0;i<array.length;i++){int num=(int)((array[i]-min)*(bucketNum-1)/d);bucketList.get(num).add(array[i]);}//對(duì)每個(gè)桶內(nèi)部進(jìn)行排序for(int i=0;i<bucketList.size();i++){// 使用Collections.sort,其底層實(shí)現(xiàn)基于歸并排序或歸并排序的優(yōu)化版本Collections.sort(bucketList.get(i));}//輸出全部元素double[] sortedArray=new double[array.length];int index=0;for (LinkedList<Double> list:bucketList) {for (double element:list){sortedArray[index]=element;index++;}}return sortedArray;}

10.4 性能分析

時(shí)間復(fù)雜度(這部分內(nèi)容不太重要,增加學(xué)習(xí)負(fù)擔(dān)):

? 桶排序利用函數(shù)的映射關(guān)系,減少了幾乎所有的比較工作。實(shí)際上,桶排序的f(k)值的計(jì)算,其作用就相當(dāng)于快排中劃分,已經(jīng)把大量數(shù)據(jù)分割成了基本有序的數(shù)據(jù)塊(桶)。然后只需要對(duì)桶中的少量數(shù)據(jù)做先進(jìn)的比較排序即可。

? 對(duì)N個(gè)關(guān)鍵字進(jìn)行桶排序的時(shí)間復(fù)雜度分為兩個(gè)部分:

  • 循環(huán)計(jì)算每個(gè)關(guān)鍵字的桶映射函數(shù),這個(gè)時(shí)間復(fù)雜度是O(n)。

  • 利用先進(jìn)的比較排序算法對(duì)每個(gè)桶內(nèi)的所有數(shù)據(jù)進(jìn)行排序,其時(shí)間復(fù)雜度為 ∑ O(Ni*logNi) 。其中Ni 為第i個(gè)桶的數(shù)據(jù)量。

    很顯然,第二部分是桶排序性能好壞的決定因素。盡量減少桶內(nèi)數(shù)據(jù)的數(shù)量是提高效率的唯一辦法(因?yàn)榛诒容^排序的最好平均時(shí)間復(fù)雜度只能達(dá)到O(n*logn)了)。因此,我們需要盡量做到下面兩點(diǎn):

  • 映射函數(shù)f(k)能夠?qū)個(gè)數(shù)據(jù)平均的分配到m個(gè)桶中,這樣每個(gè)桶就有[n/m]個(gè)數(shù)據(jù)量。

  • 在額外空間充足的情況下,盡量增大桶的數(shù)量,使用的映射函數(shù)能夠?qū)⑤斎氲?n個(gè)數(shù)據(jù)均勻的分配到 m個(gè)桶中。極限情況下每個(gè)桶只能得到一個(gè)數(shù)據(jù),這樣就完全避開(kāi)了桶內(nèi)數(shù)據(jù)的“比較”排序操作。 當(dāng)然,做到這一點(diǎn)很不容易,數(shù)據(jù)量巨大的情況下,f(k)函數(shù)會(huì)使得桶集合的數(shù)量巨大,空間浪費(fèi)嚴(yán)重。這就是一個(gè)時(shí)間代價(jià)和空間代價(jià)的權(quán)衡問(wèn)題了。

? 對(duì)于n個(gè)待排數(shù)據(jù),m個(gè)桶,平均每個(gè)桶[n/m]個(gè)數(shù)據(jù)的桶排序平均時(shí)間復(fù)雜度為: O(n)+O(m(n/m)log(n/m))=O(n+n(logn-logm))=O(n+nlogn-nlogm)

  • 當(dāng)n=m時(shí),即極限情況下每個(gè)桶只有一個(gè)數(shù)據(jù)時(shí)。桶排序的最好效率能夠達(dá)到O(n)。
  • 最糟糕的情況下,即所有的數(shù)據(jù)都放入了一個(gè)桶內(nèi),桶內(nèi)自排序算法為插入排序,那么其時(shí)間復(fù)雜度就為 O(n^2) 了。

空間復(fù)雜度:

? 當(dāng)然桶排序的空間復(fù)雜度 為O(N+M),如果輸入數(shù)據(jù)非常龐大,而桶的數(shù)量也非常多,則空間代價(jià)無(wú)疑是昂貴的。

穩(wěn)定性:

? 桶排序是穩(wěn)定的。

10.5 拓展

? 其次,我們可以發(fā)現(xiàn),區(qū)間劃分的越細(xì),即桶的數(shù)量越多,理論上分到每個(gè)桶中的元素就越少,桶內(nèi)數(shù)據(jù)的排序就越簡(jiǎn)單,其時(shí)間復(fù)雜度就越接近于線性。

? 極端情況下,就是區(qū)間小到只有1,即桶內(nèi)只存放一種元素,桶內(nèi)的元素不再需要排序,因?yàn)樗鼈兌际窍嗤脑?#xff0c;這時(shí)桶排序差不多就和計(jì)數(shù)排序一樣了。

? 總結(jié): 桶排序的平均時(shí)間復(fù)雜度為線性的O(n+C),其中C=n*(logn-logm)。如果相對(duì)于同樣的n,桶數(shù)量m越大,其效率越高,最好的時(shí)間復(fù)雜度達(dá)到O(n)。 桶排序的空間復(fù)雜度 為O(n+m),如果輸入數(shù)據(jù)非常龐大,而桶的數(shù)量也非常多,則空間代價(jià)無(wú)疑是昂貴的。此外,桶排序是穩(wěn)定的。

11.非比較類排序算法總結(jié)

這三種排序算法都利用了桶的概念,但對(duì)桶的使用方法上有明顯差異:

  • 基數(shù)排序:根據(jù)鍵值的每位數(shù)字來(lái)分配桶;
  • 計(jì)數(shù)排序:每個(gè)桶只存儲(chǔ)單一鍵值;
  • 桶排序:每個(gè)桶存儲(chǔ)一定范圍的數(shù)值;

部分動(dòng)圖參考:https://www.cnblogs.com/onepixel/articles/7674659.html
部分優(yōu)化參考:https://leetcode-cn.com/leetbook/detail/sort-algorithms/


總結(jié)

以上是生活随笔為你收集整理的超全十大经典排序算法及其分析的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。