前言
排序演算法是老生常談的了,但是在面試中也有會被問到,例如有時候,在考察演算法能力的時候,不讓你寫演算法,就讓你描述一下,某個排序演算法的思想以及時間複雜度或空間複雜度。我就遇到過,直接問快排的,所以這次我就總結梳理一下經典的十大排序演算法以及它們的模板程式碼。
演算法分析
一個排序演算法的好壞,一般是通過下面幾個關鍵資訊來分析的,下面先介紹一下這幾個關鍵資訊,然後再將常見的排序演算法的這些關鍵資訊統計出來。
名詞介紹
- 時間複雜度:指對資料操作的次數(或是簡單的理解為某段程式碼的執行次數)。舉例:O(1):常數時間複雜度;O(log n):對數時間複雜度;O(n):線性時間複雜度。
- 空間複雜度:某段程式碼每次執行時需要開闢的記憶體大小。
- 內部排序:不依賴外部的空間,直接在資料內部進行排序;
- 外部排序:資料的排序,不能通過內部空間來完成,需要依賴外部空間。
- 穩定排序:若兩個元素相等:a=b,排序前a排在b前面,排序後a仍然在b後面,稱為穩定排序。
- 不穩定排序:若兩個元素相等:a=b,排序前a排在b前面,排序後a有可能出現在b後面,稱為不穩定排序。
常見的排序演算法的這幾個關鍵資訊如下:
氣泡排序
氣泡排序是一種簡單直觀的排序演算法,它需要多次遍歷資料;
主要有這麼幾步:
- 將相鄰的兩個元素進行比較,如果前一個元素比後一個元素大那麼就交換兩個元素的位置,經過這樣一次遍歷後,最後一個元素就是最大的元素了;
- 然後再將除最後一個元素的剩下的元素,重複執行上面相鄰兩元素比較的步驟。
- 每次對越來越少的元素重複上面的步驟,直到就剩一個數字需要比較。
這樣最終達到整體資料的一個有序性了。
動圖演示
程式碼模板
/**
* 氣泡排序
* @param array
*/
public static void bubbleSort(int[] array){
if(array.length == 0){
return;
}
for(int i=0;i<array.length;i++){
// 每一次都減少i個元素
for(int j=0;j<array.length-1-i;j++){
// 若相鄰的兩個元素比較後,前一個元素大於後一個元素,則交換位置。
if(array[j]>array[j+1]){
int temp = array[j];
array[j] = array[j+1];
array[j+1] = temp;
}
}
}
}
氣泡排序總結
當陣列中的元素已經是正序時,執行效率最高。
當陣列中的元素是一個倒序時,執行效率最低,相鄰的元素每次比較都需要交換位置。
而且氣泡排序是完全在資料內部進行的,不需要額外的空間,所以空間複雜度是O(1)。
選擇排序
選擇排序是一種簡單粗暴的排序方式,每次都從資料中選出最大或最小的元素,最終選完了,那麼選出來的資料就是排好序的了。
主要步驟:
- 先從全部資料中選出最小的元素,放到第一個元素的位置(選出最小元素和第一位位置交換位置);
- 然後再從除了第一個元素的剩餘元素中再選出最小的元素,然後放到陣列的第二個位置上。
- 迴圈重複上面的步驟,最終選出來的資料都放前面了,資料就排好序了。
動圖演示
程式碼模板
public static void selectSort(int[] array){
for(int i=0;i<array.length;i++){
// 先以i為最小值的下標
int min = i;
// 然後從後面的資料中找出比array[min] 還小的值,就替換min為當前下標。
for(int j=i+1;j<array.length;j++){
if(array[j]<array[min]){
min = j;
}
}
// 最終如果最小值的下標不等於i了,那麼將最小值與i位置的資料替換,即將最小值放到陣列前面來,然後迴圈整個操作。
if(min != i){
int temp = array[i];
array[i] = array[min];
array[min] = temp;
}
}
}
選擇排序總結
所有的資料經過選擇排序,時間複雜度都是O(n^2);所以需要排序的資料量越小選擇排序的效率越高。
插入排序
插入排序也是一種比較直觀和容易理解的排序演算法,通過構建有序序列,將未排序中的資料插入到已排序中序列,最終未排序全部插入到有序序列,達到排序效果。
主要步驟:
- 將原始資料的第一個元素當成已排序序列,然後將除了第一個元素的後面元素當成未排序序列。
- 從後面未排序元素中從前到後掃描,挨個取出元素,在已排序的序列中從後往前掃描,將從未排序序列中取出的元素插入到已排序序列的指定位置。
- 當未排序元素數量為0時,則排序完成。
動圖演示
程式碼模板
public static void insertSort(int[] array){
// 第一個元素被認為預設有序,所以遍歷無序元素從i1開始。
for(int i=1;i<array.length;i++){
int sortItem = array[i];
int j = i;
// 將當前元素插入到前面的有序元素裡,將當前元素與前面有序元素從後往前挨個對比,然後將元素插入到指定位置。
while (j>0 && sortItem < array[j-1]){
array[j] = array[j-1];
j--;
}
// 若當前元素在前面已排序裡面不是最大的,則將它插入到前面已經確定了位置裡。
if(j !=i){
array[j] = sortItem;
}
}
}
插入排序總結
插入排序也是採用的內部排序,所以空間複雜度是O(1),但是時間複雜度就是O(n^2),因為基本上每個元素都要處理多次,需要反覆將已排序元素移動,然後將資料插入到指定的位置。
希爾排序
希爾排序是插入排序的一個升級版,它主要是將原先的資料分成若干個子序列,然後將每個子序列進行插入排序,然後每次拆得子序列數量逐次遞減,直到拆的子序列的長度等於原資料長度。然後再將資料整體來依次插入排序。
主要步驟:
選擇一個增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
按增量序列個數 k,對序列進行 k 趟排序;
每趟排序,根據對應的增量 ti,將待排序列分割成若干長度為 m 的子序列,分別對各子表進行直接插入排序。僅增量因子為 1 時,整個序列作為一個表來處理,表長度即為整個序列的長度。
過程演示
原始未排序的資料。
經過初始增量gap=array.length/2=5
分組後,將原資料分為了5組,[12,1]、[29,30]、[5,45]、[16,26]、[15,32]。
將分組後的資料,每一組資料都直接執行插入排序,這樣資料已經慢慢有序起來了,然後再縮小增量gap=5/2=2
,將資料分為2組:[1,5,15,30,26]、[29,16,12,45,32]。
對上面已經分好的兩組進行插入排序,整個資料就更加趨向有序了,然後再縮小增量gap=2/2=1
,整個資料成為了1組,整個序列作為了表來處理,然後再執行一次插入排序,資料最終達到了有序。
程式碼模板
/**
* 希爾排序
* @param array
*/
public static void shellSort(int[] array){
int len = array.length;
int temp, gap = len / 2;
while (gap > 0) {
for (int i = gap; i < len; i++) {
temp = array[i];
int preIndex = i - gap;
while (preIndex >= 0 && array[preIndex] > temp) {
array[preIndex + gap] = array[preIndex];
preIndex -= gap;
}
array[preIndex + gap] = temp;
}
gap /= 2;
}
}
歸併排序
歸併排序是採用的分而治之的遞迴方式來完成資料排序的,主要是將已有序的兩個子序列,合併成一個新的有序子序列。先將子序列分段有序,然後再將分段後的子序列合併成,最終完成資料的排序。
主要步驟:
- 將資料的長度從中間一分為二,分成兩個子序列,執行遞迴操作,直到每個子序列就剩兩個元素。
- 然後分別對這些拆好的子序列進行歸併排序。
- 將排序好的子序列再兩兩合併,最終合併成一個完整的排序序列。
動圖演示
程式碼模板
/**
* 歸併排序
* @param array 陣列
* @param left 0
* @param right array.length-1
*/
public static void mergeSort(int[] array,int left,int right){
if (right <= left){
return;
}
// 一分為二
int mid = (left + right)/2;
// 對前半部分執行歸併排序
mergeSort(array, left, mid);
// 對後半部分執行歸併排序
mergeSort(array, mid + 1, right);
// 將分好的每個子序列,執行排序加合併操作
merge(array, left, mid, right);
}
/**
* 合併加排序
* @param array
* @param left
* @param middle
* @param right
*/
public static void merge(int[] array,int left,int middle,int right){
// 中間陣列
int[] temp = new int[right - left + 1];
int i = left, j = middle + 1, k = 0;
while (i <= middle && j <= right) {
// 若前面陣列的元素小,就將前面元素的資料放到中間陣列中
if(array[i] <= array[j]){
temp[k++] = array[i++];
}else {
// 若後面陣列的元素小,就將後面陣列的元素放到中間陣列中
temp[k++] = array[j++];
}
}
// 若經過上面的比較合併後,前半部分的陣列還有資料,則直接插入中間陣列後面
while (i <= middle){
temp[k++] = array[i++];
}
// 若經過上面的比較合併後,後半部分的陣列還有資料,則直接插入中間陣列後面
while (j <= right){
temp[k++] = array[j++];
}
// 將資料從中間陣列中複製回原陣列
for (int p = 0; p < temp.length; p++) {
array[left + p] = temp[p];
}
}
歸併排序總結
歸併排序效率很高,時間複雜度能達到O(nlogn)
,但是依賴額外的記憶體空間,而且這種分而治之的思想很值得借鑑,很多場景都是通過簡單的功能,組成了複雜的邏輯,所以只要找到可拆分的最小單元,就可以進行分而治之了。
快速排序
快速排序,和二分查詢的思想很像,都是先將資料一份為二然後再逐個處理。快速排序也是最常見的排序演算法的一種,面試被問到的概率還是比較大的。
主要步驟:
- 從資料中挑選出一個元素,稱為 "基準"(pivot),一般選第一個元素或最後一個元素。
- 然後將資料中,所有比基準元素小的都放到基準元素左邊,所有比基準元素大的都放到基準元素右邊。
- 然後再將基準元素前面的資料集合和後面的資料集合重複執行前面兩步驟。
動圖演示
程式碼模板
/**
* 快速排序
* @param array 陣列
* @param begin 0
* @param end array.length-1
*/
public static void quickSort(int[] array, int begin, int end) {
if (end <= begin) return;
int pivot = partition(array, begin, end);
quickSort(array, begin, pivot - 1);
quickSort(array, pivot + 1, end);
}
/**
* 分割槽
* @param array
* @param begin
* @param end
* @return
*/
public static int partition(int[] array, int begin, int end) {
// pivot: 標杆位置,counter: 小於pivot的元素的個數
int pivot = end, counter = begin;
for (int i = begin; i < end; i++) {
if (array[i] < array[pivot]) {
// 替換,將小於標杆位置的資料放到開始位置算起小於標杆資料的第counter位
int temp = array[counter];
array[counter] = array[i];
array[i] = temp;
counter++;
}
}
// 將標杆位置的資料移動到小於標杆位置資料的下一個位。
int temp = array[pivot];
array[pivot] = array[counter];
array[counter] = temp;
return counter;
}
快速排序總結
我找的快速排序的模板程式碼,是比較巧妙的,選擇了最後一個元素作為了基準元素,然後小於基準元素的數量,就是基準元素應該在的位置。這樣看起來是有點不好懂,但是看明白之後,就會覺得這個模板寫的還是比較有意思的。
堆排序
堆排序其實是採用的堆這種資料結構來完成的排序,一般堆排序的方式都是採用的一種近似完全二叉樹來實現的堆的方式完成排序,但是堆的實現方式其實不止有用二叉樹的方式,其實還有斐波那契堆。
而根據排序的方向又分為大頂堆和小頂堆:
- 大頂堆:每個節點值都大於或等於子節點的值,在堆排序中用做升序排序。
- 小頂堆:每個節點值都小於或等於子節點的值,在堆排序中用做降序排序。
像Java中的PriorityQueue
就是小頂堆。
主要步驟:
- 建立一個二叉堆,引數就是無序序列[0~n];
- 把堆頂元素和堆尾元素互換;
- 調整後的堆頂元素,可能不是最大或最小的值,所以還需要調整此時堆頂元素的到正確的位置,這個調整位置的過程,主要是和二叉樹的子元素的值對比後找到正確的位置。
- 重複步驟2、步驟3,直至整個序列的元素都在二叉堆的正確位置上了。
動圖演示
模板程式碼
/**
* 堆排序
* @param array
*/
public static int[] heapSort(int[] array){
int size = array.length;
// 先將資料放入堆中
for (int i = (int) Math.floor(size / 2); i >= 0; i--) {
heapTopMove(array, i, size);
}
// 堆頂位置調整
for(int i = size - 1; i > 0; i--) {
swapNum(array, 0, i);
size--;
heapTopMove(array, 0,size);
}
return array;
}
/**
* 堆頂位置維護
* @param array
* @param i
* @param size
*/
public static void heapTopMove(int[] array,int i,int size){
int left = 2 * i + 1;
int right = 2 * i + 2;
int largest = i;
if (left < size && array[left] > array[largest]) {
largest = left;
}
if (right < size && array[right] > array[largest]) {
largest = right;
}
if (largest != i) {
swapNum(array, i, largest);
heapTopMove(array, largest, size);
}
}
/**
* 比較交換
* @param array
* @param left
* @param right
*/
public static void swapNum(int[] array,int left,int right){
int temp = array[left];
array[left] = array[right];
array[right] = temp;
}
堆排序總結
堆排序的時間複雜度也是O(nlogn)
,這個也是有一定的概率在面試中被考察到,其實如果真實在面試中遇到後,可以在實現上不用自己去維護一個堆,而是用Java中的PriorityQueue
來實現,可以將無序資料集合放入到PriorityQueue
中,然後再依次取出堆頂資料,取出堆頂資料時要從堆中移除取出的這個元素,這樣每次取出的就都是現有資料中最小的元素了。
計數排序
計數排序是一種線性時間複雜度的排序演算法,它主要的邏輯時將資料轉化為鍵儲存在額外的陣列空間裡。計數排序有一定的侷限性,它要求輸入的資料,必須是有確定範圍的整數序列。
主要步驟:
- 找出待排序的陣列中最大和最小的元素;
- 統計陣列中每個值為i的元素出現的次數,存入陣列C的第i項;
- 對所有的計數累加(從C中的第一個元素開始,每一項和前一項相加);
- 反向填充目標陣列:將每個元素i放在新陣列的第C(i)項,每放一個元素就將C(i)減去1。
動圖演示
程式碼模板
/**
* 計數排序
* @param array
*/
public static void countSort(int[] array){
int bucketLen = getMaxValue(array) + 1;
int[] bucket = new int[bucketLen];
// 統計每個值出現的次數
for (int value : array) {
bucket[value]++;
}
// 反向填充陣列
int sortedIndex = 0;
for (int j = 0; j < bucketLen; j++) {
while (bucket[j] > 0) {
array[sortedIndex++] = j;
bucket[j]--;
}
}
}
/**
* 獲取最大值
* @param arr
* @return
*/
private static int getMaxValue(int[] arr) {
int maxValue = arr[0];
for (int value : arr) {
if (maxValue < value) {
maxValue = value;
}
}
return maxValue;
}
桶排序
桶排序算是計數排序的一個加強版,它利用特定函式的對映關係,將屬於一定範圍內的資料,放到一個桶裡,然後對每個桶中的資料進行排序,最後再將排序好的資料拼接起來。
主要步驟:
- 設定一個合適長度的陣列作為空桶;
- 遍歷資料,將資料都放到指定的桶中,分佈的越均勻越好;
- 對每個非空的桶裡的資料進行排序;
- 將每個桶中排序好的資料拼接在一起。
動圖演示
程式碼模板
/**
* 桶排序
* @param arr
* @param bucketSize
* @return
*/
private static int[] bucketSort(int[] arr, int bucketSize){
if (arr.length == 0) {
return arr;
}
int minValue = arr[0];
int maxValue = arr[0];
// 計算出最大值和最小值
for (int value : arr) {
if (value < minValue) {
minValue = value;
} else if (value > maxValue) {
maxValue = value;
}
}
// 根據桶的長度以及資料的最大值和最小值,計算出桶的數量
int bucketCount = (int) Math.floor((maxValue - minValue) / bucketSize) + 1;
int[][] buckets = new int[bucketCount][0];
// 利用對映函式將資料分配到各個桶中
for (int i = 0; i < arr.length; i++) {
int index = (int) Math.floor((arr[i] - minValue) / bucketSize);
// 將資料填充到指定的桶中
buckets[index] = appendBucket(buckets[index], arr[i]);
}
int arrIndex = 0;
for (int[] bucket : buckets) {
if (bucket.length <= 0) {
continue;
}
// 對每個桶進行排序,這裡使用了插入排序
InsertSort.insertSort(bucket);
for (int value : bucket) {
arr[arrIndex++] = value;
}
}
return arr;
}
/**
* 擴容,並追加資料
*
* @param array
* @param value
*/
private static int[] appendBucket(int[] array, int value) {
array = Arrays.copyOf(array, array.length + 1);
array[array.length - 1] = value;
return array;
}
基數排序
基數排序是一種非比較型排序,主要邏輯時將整數按位拆分成不同的數字,然後再按照位數排序,先按低位排序,進行收集,再按高位排序,再進行收集,直到最高位。
主要步驟:
- 獲取原始資料中的最大值以及最高位;
- 在原始陣列中,從最低位開始取每個位組成基數陣列;
- 對基數陣列進行計數排序(利用計數排序適用於小範圍數的特點);
動圖演示
程式碼模板
/**
* 基數排序
* @param array
*/
public static void radixSort(int[] array){
// 獲取最高位
int maxDigit = getMaxDigit(array);
int mod = 10;
int dev = 1;
for (int i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
// 考慮負數的情況,這裡擴充套件一倍佇列數,其中 [0-9]對應負數,[10-19]對應正數 (bucket + 10)
int[][] counter = new int[mod * 2][0];
// 計數排序
for (int j = 0; j < array.length; j++) {
int bucket = ((array[j] % mod) / dev) + mod;
counter[bucket] = appendBucket(counter[bucket], array[j]);
}
// 反向填充陣列
int pos = 0;
for (int[] bucket : counter) {
for (int value : bucket) {
array[pos++] = value;
}
}
}
}
/**
* 獲取最高位數
*/
private static int getMaxDigit(int[] arr) {
int maxValue = getMaxValue(arr);
return getNumLength(maxValue);
}
/**
* 獲取最大值
* @param arr
* @return
*/
private static int getMaxValue(int[] arr) {
int maxValue = arr[0];
for (int value : arr) {
if (maxValue < value) {
maxValue = value;
}
}
return maxValue;
}
/**
* 獲取整數的位數
* @param num
* @return
*/
protected static int getNumLength(long num) {
if (num == 0) {
return 1;
}
int lenght = 0;
for (long temp = num; temp != 0; temp /= 10) {
lenght++;
}
return lenght;
}
/**
* 擴容,並追加資料
*
* @param array
* @param value
*/
private static int[] appendBucket(int[] array, int value) {
array = Arrays.copyOf(array, array.length + 1);
array[array.length - 1] = value;
return array;
}
基數排序總結
計數排序、桶排序、基數排序這三種排序演算法都利用了桶的概念,但對桶的使用方法上有明顯差異:
- 基數排序:根據鍵值的每位數字來分配桶;
- 計數排序:每個桶只儲存單一鍵值;
- 桶排序:每個桶儲存一定範圍的數值;
總結
這次總結了10個經典的排序演算法,也算是給自己早年偷的懶補一個補丁吧。一些常用的演算法在面試中也算是一個考察方向,但是一般考察都是時間複雜度低的那幾個即時間複雜度為O(nlogn)
的:快速排序、堆排序、希爾排序。所以這幾個要熟練掌握,起碼要知道是怎樣的實現邏輯(畢竟面試也有口述演算法的時候)。
畫圖:AlgorithmMan