過年,新冠狀病毒肺炎,封城、封村、春節假期延長、節後第一週在家辦公。沒事幹……
溫故而知新,可以爲師矣。
排序(上)——爲什麼插入排序比冒泡排序更受歡迎?
https://blog.csdn.net/every__day/article/details/83419170
https://blog.csdn.net/qq_30816517/article/details/86296446
算法評價標準
百度百科上見:穩定性是一個特別重要的評估標準。穩定的算法在排序的過程中不會改變元素彼此的位置的相對次序,反之不穩定的排序算法經常會改變這個次序,這是我們不願意看到的。我們在使用排序算法或者選擇排序算法時,更希望這個次序不會改變,更加穩定,所以排序算法的穩定性,是一個特別重要的參數衡量指標依據。就如同空間複雜度和時間複雜度一樣,有時候甚至比時間複雜度、空間複雜度更重要一些。所以往往評價一個排序算法的好壞往往可以從下邊幾個方面入手:
穩定性
穩定性是不管考慮時間和空間必須要考慮的問題,往往也是非常重要的影響選擇的因素。
時間複雜度
即從序列的初始狀態到經過排序算法的變換移位等操作變到最終排序好的結果狀態的過程所花費的時間度量。
空間複雜度
就是從序列的初始狀態經過排序移位變換的過程一直到最終的狀態所花費的空間開銷。
實際場景中的常用算法
排序算法有很多,不同種類的排序算法適合不同種類的情景,可能有時候需要節省空間對時間要求沒那麼多,反之,有時候則是希望多考慮一些時間,對空間要求沒那麼高,總之一般都會必須從某一方面做出抉擇。
實際代碼中,比如Java的JDK中排序都是TimSort,這是一個“插入排序”和“歸併排序”的組合排序算法,是穩定的排序算法。和大家常見的冒泡排序、快速排序並不相同。原因就在於,整體上考慮了穩定性、時間複雜度、空間複雜度。
所以,雖然學習過程中,冒泡排序、快速排序是常見的,但是實際應用中,真正有用的還是插入排序和歸併排序。
附錄——時間、空間複雜度
附錄——性能測試
算法時間複雜度和空間複雜度一樣的情況下,真的就一樣嗎?比如冒泡排序和插入排序?
完全隨機的情況下,插入排序比冒泡排序快多了。
幾個常見排序算法
import java.util.Arrays;
/**
* 常見算法:插入排序(穩定)、冒泡排序(穩定)、歸併排序(穩定、兩種寫法)、快速排序(不穩定)、堆排序(不穩定)
*
* @author ObiteAaron
* @since 2020/1/29 12:01
*/
public class TestSorts {
/**
* 插入排序
*/
static class InsertSort {
/**
* 僅向後移動,算法本身
*/
public static void sort(int[] nums) {
// 一開始排序好的數量是1個
for (int sorted = 1; sorted < nums.length; sorted++) {
// 第0個和第1個
int current = nums[sorted];
int j = sorted - 1;
for (; j >= 0; j--) {
if (nums[j] > current) {
nums[j + 1] = nums[j];
} else {
break;
}
}
nums[j + 1] = current;
}
}
}
/**
* 冒泡排序
*/
static class BubbleSort {
public static void sort(int[] nums) {
// 一開始排序好的數量是0個
for (int sorted = 1; sorted < nums.length; sorted++) {
// 開關,當某次內層循環,沒有數據交換時,已排好順序,直接跳出循環。
boolean flag = false;
// 第0個和第1個
for (int j = 0; j < nums.length - sorted; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
flag = true;
}
}
if (!flag) {
break;
}
}
}
public static void swap(int[] numbers, int one, int tow) {
int temp = numbers[one];
numbers[one] = numbers[tow];
numbers[tow] = temp;
}
}
/**
* 歸併排序,按算法寫來的,推薦
* <p/>
* 一次歸併操作的工作原理如下:
* 第一步:申請空間,使其大小爲兩個已經排序序列之和,該空間用來存放合併後的序列
* 第二步:設定兩個指針,最初位置分別爲兩個已經排序序列的起始位置
* 第三步:比較兩個指針所指向的元素,選擇相對小的元素放入到合併空間,並移動指針到下一位置
* 重複步驟3直到某一指針超出序列尾
* 將另一序列剩下的所有元素直接複製到合併序列尾
* <p/>
* 歸併排序把數組分爲只包含一個數字的組,然後一次次歸併成最終結果。
* <p/>
* 這個是遞歸版本的,更容易理解一些。
*/
static class MergeSort {
/**
* @param origin 原始數組,也是最終排序正確排序的數組
*/
public static int[] sort(int[] origin) {
int[] result = new int[origin.length];
sort(origin, result, 0, origin.length - 1);
return origin;
}
/**
* @param origin 原始數組,也是最終的結果集。
* @param result 輔助數組,也是最終的結果集。
* @param start 歸併排序開始位置
* @param end 歸併排序結束位置
*/
public static void sort(int[] origin, int[] result, int start, int end) {
//浪費了我幾乎昨天一個下午,再加上今天上午的時間的一個算法。歸併排序,原因只是因爲我少寫了一個括號。
//運算符的優先級:+ - 爲4,* / % 爲3,<< >> 爲5,所以寫成 int half = start + (end - start) >>> 1;是錯的。
int half = start + ((end - start) >>> 1);
if (start >= end) {
return;
}
// 遞歸排序子序列1和子序列2
sort(origin, result, start, half);
sort(origin, result, half + 1, end);
// 歸併每次排序後的兩個子串
merge(origin, result, start, half + 1, end);
}
/**
* @param origin 原始數組,每次合併後會把b的結果複製到a中
* @param result 輔助作用,也是最終的結果集。
* @param start 是第一個待合併子數組的起始位置。
* @param half 是第二個子數組的結束位置。
* @param end 第二個子數組的起始位置,也是當前已經排序到的位置
*/
public static void merge(int[] origin, int[] result, int start, int half, int end) {
// 歸併兩個排序結果
int i = start, j = half, k = start;
while (i < half && j <= end) {
result[k++] = origin[i] <= origin[j] ? origin[i++] : origin[j++];
}
// 剩下的元素,直接寫入(只會有一個數組會有剩下的元素)
while (i < half) {
result[k++] = origin[i++];
}
while (j <= end) {
result[k++] = origin[j++];
}
// 這裏把新合併的結果寫入到原始數組中,必須寫入
for (int x = start; x < k; x++) {
origin[x] = result[x];
}
// display(origin, result, start, k);
}
/**
* 展示結果,好理解,並無其他用處
*/
public static void display(int[] origin, int[] result, int start, int end) {
System.out.println(String.format("{%s},{%s},{%s}",
displayArrayToString(Arrays.copyOfRange(origin, 0, start)),// 先前已排序
displayArrayToString(Arrays.copyOfRange(result, start, end)),// 當前排序
displayArrayToString(Arrays.copyOfRange(origin, end, origin.length))));// 未排序
}
public static String displayArrayToString(int[] array) {
String s = Arrays.toString(array);
return s.substring(1, s.length() - 1);
}
}
/**
* 歸併排序,每次創建新對象,不推薦。
* 這個寫法很好理解,但會浪費比較多空間,產生了很多int[]數組。
*/
static class MergeSort2 {
public static int[] sort(int[] nums) {
return sort(nums, 0, nums.length - 1);
}
public static int[] sort(int[] nums, int l, int h) {
if (l == h)
return new int[]{nums[l]};
int mid = l + (h - l) / 2;
int[] leftArr = sort(nums, l, mid); //左有序數組
int[] rightArr = sort(nums, mid + 1, h); //右有序數組
int[] newNum = new int[leftArr.length + rightArr.length]; //新有序數組
// 歸併兩個排序結果
int m = 0, i = 0, j = 0;
while (i < leftArr.length && j < rightArr.length) {
newNum[m++] = leftArr[i] < rightArr[j] ? leftArr[i++] : rightArr[j++];
}
// 剩下的元素,直接寫入(只會有一個數組會有剩下的元素)
while (i < leftArr.length)
newNum[m++] = leftArr[i++];
while (j < rightArr.length)
newNum[m++] = rightArr[j++];
return newNum;
}
}
/**
* 快速排序(並不快)
*/
static class QuickSort {
static void sort(int[] numbers) {
sort(numbers, 0, numbers.length - 1);
}
static void sort(int[] numbers, int start, int end) {
if (start >= end) {
return;
}
int key = numbers[start];
int i = start;
int j = end;
while (i < j) {
for (; i < j; j--) {
if (numbers[j] < key) {
break;
}
}
numbers[i] = numbers[j];
for (; i < j; i++) {
if (numbers[i] > key) {
break;
}
}
numbers[j] = numbers[i];
}
// 中間位置
// assert i == j;
numbers[i] = key;
// 遞歸兩邊
sort(numbers, start, i - 1);
sort(numbers, j + 1, end);
}
}
/**
* 堆排序
* <p/>
* 此排序過程:
* 1. 建大根堆
* 2. 把第一個數字和最後一個交換。且已排序好的個數加1。即,大根堆建好後,第一個是最大的,把它放到最後即可。
* 3. 對於除最後排序好的個數外的數組進行建堆(重複1),交換(重複2),直到結束。
* <p/>
* 這不是完全按照原始算法寫的代碼,所以可能效率不很高。
*/
static class HeapSort {
/**
* 建堆,建大根堆。
* 一個過程:
* 1. 每一次開始時,i=n/2,i的兩個子節點分別是 2*i 和 2*i+1。
* 2. 如果兩個子結點的序號在數組內,分別對其進行比較,把最大的放在父結點位置。
* 3. i-- 直到退出。
* 4. 重複1,2,3 直到沒有交換,則完成。
*
* @param a 待排序數組
* @param sorted 已經排序好的個數
*/
public static void heap(int[] a, int sorted) {
while (true) {
int flag = 0;//交換次數,未交換表明此數組已經符合大根堆條件
for (int i = (a.length - sorted) / 2; i >= 0; i--) {
int x = i * 2;
int y = i * 2 + 1;
System.out.println(i + " " + x + " " + y);
if (y <= (a.length - sorted - 1)) {
if (a[i] < a[y]) {
swap(a, i, y);
flag++;
}
}
if (x <= (a.length - sorted - 1)) {
if (a[i] < a[x]) {
swap(a, i, x);
flag++;
}
}
}
if (flag == 0) {
break;
}
}
}
/**
* 排序
* <p/>
* 1. 大根堆的第一個是最大的,移動到最後,把最後一個放在第一個位置(交換)。
* 2. 把剩餘的數調整爲大根堆。
* 3. 重複1,2,直到數組排序完成。
*
* @param a
*/
public static void sort(int[] a) {
heap(a, 0);
for (int i = 0; i < a.length; i++) {
swap(a, 0, a.length - 1 - i);
heap(a, i + 1);
}
}
public static void swap(int[] numbers, int one, int tow) {
int temp = numbers[one];
numbers[one] = numbers[tow];
numbers[tow] = temp;
}
}
}
幾個常見排序算法性能測試
import org.apache.commons.lang3.RandomUtils;
import java.text.MessageFormat;
import java.util.stream.IntStream;
/**
* 測試插入排序,冒泡排序,歸併排序,快速排序性能
*
* @author ObiteAaron
* @since 2020/1/29 12:37
*/
public class TestSortsPerformance {
public static final String INSERT = "INSERT";
public static final String QUICK = "QUICK";
public static final String BUBBLE = "BUBBLE";
public static final String MERGE = "MERGE";
public static final String MERGE2 = "MERGE2";
/**
* 排序算法執行順序
*/
public static final String[] ALGORITHM = new String[]{INSERT, BUBBLE, MERGE, QUICK,};
public static void main(String[] args) {
// 10個數字排序,循環10萬次
doTest(100000, 10);
// 20個數字排序,循環10萬次
doTest(100000, 20);
// 30個數字排序,循環10萬次
doTest(100000, 30);
// 50個數字排序,循環10萬次
doTest(100000, 50);
// 100個數字排序,循環10萬次
doTest(100000, 100);
// 1000個數字排序,循環1000次
doTest(1000, 1000);
// 1萬個數字排序,循環10次
doTest(10, 10000);
// 10萬個數字排序,循環1次
doTest(1, 100000);
}
/**
* 生產完全隨機int數組
*
* @param rows 行數
* @param perRowCount 每行數量
*/
public static int[][] randomArray(int rows, int perRowCount) {
int[][] nums = new int[rows][perRowCount];
for (int i = 0; i < rows; i++) {
nums[i] = IntStream.range(0, perRowCount).map(item -> RandomUtils.nextInt(0, Integer.MAX_VALUE)).toArray();
}
return nums;
}
public static void doTest(int rows, int perRowCount) {
System.out.println("DoTest start");
// 造數據
int[][] nums = randomArray(rows, perRowCount);
for (String algorithm : ALGORITHM) {
// 測試多次取平均
int times = 3;
long elapsedTime = 0;
for (int i = 0; i < 10; i++) {
// copy,都用同一份數據進行排序
int[][] nums2 = new int[rows][perRowCount];
for (int i1 = 0; i1 < nums.length; i1++) {
System.arraycopy(nums[i1], 0, nums2[i1], 0, nums[i1].length);
}
long startTime = System.nanoTime();
doSort(algorithm, nums2);
long endTime = System.nanoTime();
elapsedTime += (endTime - startTime);
}
System.out.println(MessageFormat.format("Algorithm:[{0}], rows[{1}], perRowCount[{2}], average elapsed time: {3}ms",
algorithm,
rows,
perRowCount,
(elapsedTime / times / 1000000)));
}
System.out.println("DoTest end");
}
public static void doSort(String algorithm, int[][] nums) {
switch (algorithm) {
case INSERT:
for (int[] num : nums) {
TestSorts.InsertSort.sort(num);
}
break;
case QUICK:
for (int[] num : nums) {
TestSorts.QuickSort.sort(num);
}
break;
case BUBBLE:
for (int[] num : nums) {
TestSorts.BubbleSort.sort(num);
}
break;
case MERGE:
for (int[] num : nums) {
TestSorts.MergeSort.sort(num);
}
break;
case MERGE2:
for (int[] num : nums) {
TestSorts.MergeSort2.sort(num);
}
break;
default:
throw new UnsupportedOperationException();
}
}
}
測試結果
每次排序數量(x=10)
- 插入排序 > 冒泡排序 > 快速排序 > 歸併排序
每次排序數量(x=20,30,50)
- 插入排序 > 快速排序 > 歸併排序 > 冒泡排序
每次排序數量(x=100)
- 插入排序 ≫ 快速排序 > 歸併排序 ≫ 冒泡排序
每次排序數量(x=1000)
- 快速排序 > 歸併排序 > 插入排序 ≫ 冒泡排序
每次排序數量(x=10000)
- 快速排序 ≈ 歸併排序 ≫ 插入排序 ≫ 冒泡排序
每次排序數量(x=100000)
- 快速排序 ≈ 歸併排序 ≫ 插入排序 ≫ 冒泡排序
結論
完整結論
- 歸併排序(穩定):多個已排序列表的合併很實用,所以歸併排序很實用,適用於大量數據排序。(JDK內爲TimSort,高度優化的歸併排序)
- 插入排序(穩定):少量數據插入排序快,所以對數據進行分塊後,配合歸併排序一起使用很高效。
- 快速排序(不穩定):大量數據下快速,少量數據下也表現不錯,但由於不穩定,因此給對象排序的場景下不適用。但給基本類型排序時,比如給數字排序,由於數字只有數字本身的含義,排序算法是否穩定並不產生副作用,因此基本類型排序依然可以採用快速排序(JDK實現爲雙軸快速排序DualPivotQuicksort)。