前言
程序 = 數據結構 + 算法。
設計優良程序的兩個要點:選用正確的存儲結構、採用合理的解決方案。
算法就是“解決方案”。而排序,是一種非常常見的業務場景,如:“前100名下單的打八折”、“成績前三的獲一等獎”、“績效最差的扣工資”等等。但排序的實現有很多不同的解決方案,不同的解決方案需要的運算時間和存儲空間不同。
這裏說的八大排序算法,都是內部排序(不需要額外的空間存儲數據,所有運算時存儲的數據都是放在內存中)。他們分別是:冒泡排序、堆排序、直接插入排序、歸併排序、快速排序、基數排序、簡單選擇排序、希爾排序。
接下來,我們來學習並歸納這八種排序的思想以及Java實現。
具體的Java實現以及測試代碼詳見於我的Github。
由於這些排序裏面有一些公用的方法,預先新建工具類如下:
/**
* 整數數組相關的工具類
* @author zhenye 2019/1/24
*/
public class IntArrayUtil {
/**
* 初始化一個正整數數組
* @param length 數組長度
* @param maxNum 數組中元素的最大值
* @return 正整數數組
*/
public static int[] initIntArrays (int length, int maxNum) {
if (length <= 0 || maxNum <= 0) {
throw new RuntimeException("參數非法");
}
int[] nums = new int[length];
Random random = new Random();
for(int i = 0; i < length; i++) {
nums[i] = random.nextInt(maxNum);
}
return nums;
}
/** 交換數組中下標i與下標j的值
* @param intArrays 目標數組
* @param i 數組下標i
* @param j 數組下標j
*/
public static void exchangeValue(int[] intArrays, int i, int j) {
// 如果i==j,通過這種方法交換,會將當前位置的值重置爲0
if(i == j) {
return;
}
intArrays[i] = intArrays[i] ^ intArrays[j];
intArrays[j] = intArrays[i] ^ intArrays[j];
intArrays[i] = intArrays[i] ^ intArrays[j];
}
}
冒泡排序
基本思想:從左往右比較相鄰的兩個元素,如果左邊元素比右邊元素大,交換左右元素。即每輪比較完後,最大值都能到最右邊。當所有的輪次比較完後,就是從小到大的順序。
圖解冒泡排序如下圖:
冒泡排序的java實現如下:
/**
* 冒泡排序
* @author zhenye 2019/1/24
*/
public class BubbleSort {
public static void sort(int[] nums) {
// 比較的輪次數
for(int i = 0;i < nums.length;i++) {
// 當前輪次需要比較的次數
for(int j = 0; j < nums.length - i - 1;j++) {
if(nums[j] > nums[j+1]) {
IntArrayUtil.exchangeValue(nums, j, j+1);
}
}
}
}
}
冒泡排序的執行結果如下圖所示:
堆排序
基本思想:堆是一顆順序存儲的完全二叉樹。每個節點的值都不大於其子節點的值,這樣的稱之爲“小根堆”,反之則稱之爲“大根堆”。因此“大根堆”的堆頂元素就是這個堆的最大值。利用將無序序列初始化爲“大根堆”,取出其堆頂元素作爲最大值,然後將剩餘的元素看做無序序列,並再次變爲“大根堆”並取出其堆頂元素。重複迭代直到無序序列只有一個元素即排序完畢。
圖解堆排序如下圖:
-
如果將一個無序序列,初始化爲一個“大根堆”,如下圖:
-
將堆頂元素與最後一個元素交換,將剩餘的n-1個元素的無序序列再次初始化爲
“大根堆”,並重復。
堆排序的java實現如下:
/**
* 堆排序
* @author zhenye 2019/1/24
*/
public class HeapSort {
public static void sort(int[] nums) {
// 預先初始化爲大根堆
initBigHeap(nums);
for(int i = nums.length; i >= 1; i--) {
// 堆頂元素爲最大值,交換到其應該在的位置
IntArrayUtil.exchangeValue(nums, 0, i - 1);
// 重新調整,保證數組的前i-1個元素符合大根堆
adjustBigHeap(nums, 0, i - 1);
}
}
/**
* 將數組初始化爲大根堆
* @param nums 待排數組
*/
private static void initBigHeap(int[] nums) {
// 從最後一個有子節點的節點往前遍歷,遍歷過程中保證當前節點的左右子節點的值均不大於該節點的值
// 則遍歷到根節點(堆頂元素)時也保證其左右子節點的值均不大於該節點的值,則初始化成功。
// 即從第i個元素的父節點(第i個元素的下標爲i - 1,其父節點的下標爲i/2 - 1)開始往前遍歷:
for(int i = nums.length / 2 - 1;i >= 0; i--) {
adjustBigHeap(nums,i,nums.length);
}
}
/**
* 保證數組nums的前count個元素中,下標爲parent的節點及其子孫節點符合大根堆要求
* @param nums 待排數組
* @param parentIndex 目標節點
* @param count 待排元素個數(nums數組的前count個元素)
*/
private static void adjustBigHeap(int[] nums, int parentIndex, int count) {
int leftChildIndex = 2 * parentIndex + 1;
int rightChildIndex = 2 * parentIndex + 2;
while(leftChildIndex < count) {
int toBeComparedIndex;
// 如果j節點有右子節點,且右子節點比左子節點的值大,則j節點跟右子節點比較,否則跟左子節點比較
if (rightChildIndex < count && nums[rightChildIndex] > nums[leftChildIndex]) {
toBeComparedIndex = rightChildIndex;
} else {
toBeComparedIndex = leftChildIndex;
}
if (nums[toBeComparedIndex] > nums[parentIndex]) {
IntArrayUtil.exchangeValue(nums, parentIndex, toBeComparedIndex);
// 交換後,然後還要保證交換後的節點也符合大根堆要求
adjustBigHeap(nums, toBeComparedIndex, count);
} else {
// 當父節點是最大時,停止遞歸
break;
}
}
}
}
堆排序的執行結果如下圖所示:
直接插入排序
基本思想:將n個待排序的元素分成一個有序表和一個無序表。開始時有序表只包含第一個元素,無序表包含後面的n-1個元素。排序過程中每次從無序表中取出第一個元素,並直接插入有序表中的合適位置。重複n-1次即排序完畢。
圖解直接插入排序如下圖:
直接插入排序的java實現如下:
/**
* 直接插入排序
* @author zhenye 2019/1/24
*/
public class InsertSort {
public static void sort(int[] nums) {
// i爲無序表中的元素下標,temp是待插入元素。
int i,temp;
for(i = 1;i < nums.length;i++) {
temp = nums[i];
// k記錄的是待插入元素應該在數組中位置的下標
int k;
// 比待插入元素大的元素右移,否則就是找到待插入元素的正確位置k
for(k = i;k > 0 && nums[k - 1] > temp;k--) {
nums[k] = nums[k - 1];
}
nums[k] = temp;
}
}
}
直接插入排序的執行結果如下圖所示:
![
歸併排序
基本思想:採用分治策略將待排序列分成若干個子序列,先將每個子序列進行排序後,再把子序列進行合併即得到排好序的序列。
圖解歸併排序如下圖:
具體歸併操作的實現細節圖解如下圖:
歸併排序的java實現:
/**
* 歸併排序
* @author zhenye 2019/1/24
*/
public class MergeSort {
/**
* 提供給開發者使用的歸併排序
* @param nums 待排序數組
*/
public static void sort(int[] nums) {
mergeSort(nums, 0, nums.length - 1, new int[nums.length]);
}
/**
* 歸併算法的具體實現
* @param nums 待排序列
* @param start 數組起始下標
* @param end 數組截止下標
* @param temp 臨時數組
*/
private static void mergeSort(int[] nums, int start, int end, int[] temp) {
// 當[start ... end]數組可以拆分(不只一個元素)時,需要再次拆分
if(start < end) {
// 拆分成[start ... mid],[mid + 1 ... end]兩個更小的數組)
int mid = (start + end) / 2;
mergeSort(nums, start, mid, temp);
mergeSort(nums, mid + 1, end, temp);
// 將兩個小的有序數組,合併爲一個大的有序數組
merge(nums, start, mid, end, temp);
}
}
/**
* 將兩個小的有序數組,合併爲一個大的有序數組
*/
private static void merge(int[] nums, int start, int mid, int end, int[] temp) {
// 定義兩個旗幟i,j(旗幟i在數組[start ... mid]移動,旗幟j在數組[mid + 1 ... end]移動)
int i = start;
int j = mid + 1;
int k = 0;
while (i <= mid && j <= end) {
if(nums[i] > nums[j]) {
temp[k++] = nums[j++];
} else {
temp[k++] = nums[i++];
}
}
while (i <= mid) {
temp[k++] = nums[i++];
}
while (j <= end) {
temp[k++] = nums[j++];
}
// 將臨時數組copy到原數組的相應位置
for(k = 0;start <= end; start++, k++) {
nums[start] = temp[k];
}
}
}
歸併排序的執行結果如下圖所示:
快速排序
基本思想:首先在待排序列中隨機選取一個基準數(代碼實現時默認選取的是第一個元素),然後把待排序列中比這個基準數小的都放在其左邊,把待排序列中比這個基準數大的都放在其右邊(即找出並將該基準數放到待排序列的正確位置)。然後把該基準數的左右兩邊看做兩個待排序列重複上述操作,直至所有的基準數都找到並放到了正確的位置,則排序完畢。
圖解快排排序如下圖:
快速排序的java實現如下:
/**
* 快速排序
* @author zhenye 2019/1/24
*/
public class QuickSort {
/**
* 提供給開發者使用的快速排序
* @param nums 待排序數組
*/
public static void sort(int[] nums) {
quickSort(nums, 0 , nums.length - 1);
}
/**
* 對下標從start到end的正整數數組intArrays進行快速排序
* @param intArrays 將要排序的數組intArrays
* @param start 數組起始下標位置
* @param end 數組截止下標位置
*/
private static void quickSort(int[] intArrays, int start, int end) {
if(start > end) {
return;
}
// 將數組起始下標位置的元素,作爲基準數
int base = intArrays[start];
// 將i看作從左往右搜索的哨兵,j看作從右往左搜索的哨兵
int left = start;
int right = end;
while(left != right) {
// !!!因爲選取的基準數是左哨兵的起始位置,這裏一定要右邊的哨兵先走。
/*
這裏循環結束的條件有一個是左右哨兵相遇。
如果左哨兵先行,出現由於左哨兵找到了右哨兵(相遇)導致循環結束的情況時,
左右哨兵的當前元素是基於右哨兵上次循環結束的位置。
即此時左右哨兵相遇位置的當前元素是比基準數大的!!!將該元素與基準數交換是不對的。
*/
// 找到右邊比基準數小的元素下標(或哨兵相遇)
while(left < right && intArrays[right] >= base) {
right--;
}
// 找到左邊比基準數大的元素下標(或哨兵相遇)
while (left < right && intArrays[left] <= base) {
left++;
}
if (left < right) {
// 交換,保證哨兵i左邊的元素都比基準數小,哨兵j右邊的元素都比基準數大
IntArrayUtil.exchangeValue(intArrays, left, right);
}
}
// 交換,保證基準數在正確的位置
IntArrayUtil.exchangeValue(intArrays, start, right);
// 下一次,迭代(兩個子序列分別進行快速排序)
quickSort(intArrays, start, right - 1);
quickSort(intArrays, right + 1, end);
}
}
快速排序實現需要注意的地方:當選擇的基準數是左哨兵的起始位置時,尋找基準數的最終位置應該是右哨兵先移動。
快速排序的執行結果如下圖所示:
基數排序
基本思想:又稱“桶排序”。依次從低位到高位分解待排序列的元素並排序,當按最高位分解並排序了待排序列後即排序完畢。
圖解基數排序如下圖:
基數排序的java實現:
/**
* 基數排序
* @author zhenye 2019/1/24
*/
public class RadixSort {
/**
* 提供給開發人員使用的基數排序
* @param nums 待排序列
*/
public static void sort(int[] nums) {
int maxBit = getMaxBit(nums);
// temp臨時二維數組,列是餘數(0-9),行是0或者待排序列中元素的值
int[][] temp = new int[10][nums.length];
// order對應下標,即待排序列元素對應餘數的個數
int[] order = new int[10];
int n = 1;
int m = 1;
int k = 0;
while(m <= maxBit) {
for (int num : nums) {
int reminder = (num / n) % 10;
// 將餘數爲reminder的元素歸到二維數組temp的第reminder列,並記錄此時第reminder列元素的個數order[reminder]
temp[reminder][order[reminder]] = num;
order[reminder]++;
}
// 重排待排序列
for(int i = 0; i < order.length;i++) {
if (order[i] != 0) {
for(int j = 0;j < order[i];j++) {
nums[k] = temp[i][j];
k++;
}
}
order[i] = 0;
}
// 重置相關變量
n *= 10;
k = 0;
m++;
}
}
/**
* 找到數組中最大值的位數(如:{32,43,101,2}的最大值是101,位數是3)
* @param nums 給定數組
* @return 數組中最大值位數
*/
private static int getMaxBit(int[] nums) {
int max = nums[0];
for(int i = 1;i < nums.length;i++) {
if(nums[i] > max){
max = nums[i];
}
}
int maxBit;
for(maxBit = 1; max >= 10; maxBit++){
max = max / 10;
}
return maxBit;
}
}
基數排序有其侷限性,它要求排序的過濾條件能夠分解。
基數排序的執行結果如下圖所示:
簡單選擇排序
基本思想:從待排序列中找出值最小的元素,如果最小元素不是第一個元素,則將其餘第一個元素互換。然後從餘下n-1個元素找出最小元素,重複上述操作直至排序結束。
圖解簡單選擇排序如下圖:
簡單選擇排序的Java實現如下:
/**
* 簡單選擇排序
* @author zhenye 2019/1/24
*/
public class SelectSort {
/**
* 提供給開發者使用的簡單選擇排序
* @param nums 待排序列
*/
public static void sort(int[] nums) {
int i,j;
for(i = 0; i < nums.length; i++) {
int minValueIndex = i;
for (j = i;j < nums.length; j++) {
if (nums[minValueIndex] > nums[j]) {
minValueIndex = j;
}
}
IntArrayUtil.exchangeValue(nums, i, minValueIndex);
}
}
}
簡單選擇排序的執行結果如下圖所示:
希爾排序
基本思想:先將整個待排序列(按相隔某個“增量”的下標)分割成若干個子序列,並對這些子序列進行直接插入排序。然後依次縮減這個“增量”,分割成子序列並再次進行直接插入排序。重複上述過程到“增量”爲1,即對整個待排序列進行了一次直接插入排序。
圖解希爾排序如下圖:
希爾排序的Java實現如下:
/**
* 希爾排序
* @author zhenye 2019/1/24
*/
public class ShellSort {
/**
* 提供給開發者使用的希爾排序
* @param nums 待排序列
*/
public static void sort(int[] nums) {
// i爲無序表中的元素下標,j爲有序表中的元素下標,temp是待插入元素,increment爲增量。
int i, j, temp, increment;
for(increment = nums.length / 2;increment >= 1; increment = increment / 2){
for(i = increment; i < nums.length; i++) {
temp = nums[i];
j = i - increment;
for(;j >= 0 && temp < nums[j];j -= increment) {
nums[j + increment] = nums[j];
}
nums[j + increment] = temp;
}
}
}
}
希爾排序的執行結果如下圖所示:
八種排序算法的性能測試
八種排序算法的性能測試代碼如下:
/**
* 排序測試類
*/
public class SortTest {
/**
* 各排序算法的性能測試
*/
@Test
public void speedTest() {
int[] initIntArrays = IntArrayUtil.initIntArrays(100000,10000);
System.out.println("排序前,數組順序爲:" + Arrays.toString(initIntArrays));
int[] toBubbleSort = initIntArrays.clone();
Long toBubbleSortStart = System.currentTimeMillis();
BubbleSort.sort(toBubbleSort);
Long toBubbleSortEnd = System.currentTimeMillis();
System.out.println("冒泡排序耗時:" + (toBubbleSortEnd - toBubbleSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toHeapSort = initIntArrays.clone();
Long toHeapSortStart = System.currentTimeMillis();
HeapSort.sort(toHeapSort);
Long toHeapSortEnd = System.currentTimeMillis();
System.out.println("堆排序耗時:" + (toHeapSortEnd - toHeapSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toInsertSort = initIntArrays.clone();
Long toInsertSortStart = System.currentTimeMillis();
InsertSort.sort(toInsertSort);
Long toInsertSortEnd = System.currentTimeMillis();
System.out.println("直接插入排序耗時:" + (toInsertSortEnd - toInsertSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toMergeSort = initIntArrays.clone();
Long toMergeSortStart = System.currentTimeMillis();
MergeSort.sort(toMergeSort);
Long toMergeSortEnd = System.currentTimeMillis();
System.out.println("歸併排序耗時:" + (toMergeSortEnd - toMergeSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toQuickSort = initIntArrays.clone();
Long toQuickSortStart = System.currentTimeMillis();
QuickSort.sort(toQuickSort);
Long toQuickSortEnd = System.currentTimeMillis();
System.out.println("快速排序耗時:" + (toQuickSortEnd - toQuickSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toRadixSort = initIntArrays.clone();
Long toRadixSortStart = System.currentTimeMillis();
RadixSort.sort(toRadixSort);
Long toRadixSortEnd = System.currentTimeMillis();
System.out.println("基數排序耗時:" + (toRadixSortEnd - toRadixSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toSelectSort = initIntArrays.clone();
Long toSelectSortStart = System.currentTimeMillis();
SelectSort.sort(toSelectSort);
Long toSelectSortEnd = System.currentTimeMillis();
System.out.println("簡單選擇排序耗時:" + (toSelectSortEnd - toSelectSortStart) + "毫秒。");
System.out.println("----------------------------------------------------");
int[] toShellSort = initIntArrays.clone();
Long toShellSortStart = System.currentTimeMillis();
ShellSort.sort(toShellSort);
Long toShellSortEnd = System.currentTimeMillis();
System.out.println("希爾排序耗時:" + (toShellSortEnd - toShellSortStart) + "毫秒。");
}
}
我在本地電腦測試的效果圖如下:
各排序算法的複雜度如下:
彙總說明:
- 當業務場景簡單,數據量較小時,由於計算機運算能力強,各排序算法之間性能差別不大,推薦直接採用穩定易實現的冒泡排序或插入排序。
- 當業務場景複雜,數據量較大時,考慮到要注重性能的優化,推薦使用快速排序、堆排序或歸併排序。