破陣子·春景
燕子來時新社,梨花落后清明。
池上碧苔三四點,葉底黃鸝一兩聲。日長飛絮輕。
巧笑同桌伙伴,上學徑里逢迎。
疑怪昨宵春夢好,元是今朝Offer拿。笑從雙臉生。
排序算法——最基礎的算法,互聯網面試必備技能。春來來了,排序的季節來了!
本文使用Java語言優雅地實現常用排序算法,希望對大家有幫助,早日拿到Offer!
冒泡排序
最暴力、最無腦、最簡單的排序算法。名字的由來是因為越大的元素會經由交換慢慢“浮”到數組的頂端,就如同碳酸飲料中二氧化碳的氣泡最終會上浮到頂端一樣,故名“冒泡排序”。
冒泡排序的基本思想是:每次比較相鄰的元素,如果它們的順序和理想順序不一致,就把它們進行交換。不多叨叨了,直接看代碼。
public static void bubbleSort(int[] arr) {
int n = arr.length;
if (n <= 1) {
return;
}
//冒泡排序,遇到亂序不管三七二十一直接交換完事
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
if (arr[i] > arr[j]) {
swap(arr, i, j);
}
}
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
選擇排序
選擇排序,這樣記憶,選擇最小的元素與未進行排序的首元素進行交換。
選擇排序具體過程:
- 找到數組中最小的元素,將它與數組的第一個元素交換位置;
- 在剩下的元素中尋找最小的元素,將它和數組第二個元素交換位置;
- 往復執行,直到將整個數組排序完成。
選擇排序特點:
- 運行時間和輸入無關;選擇排序為了找到最小的元素需要每次都掃描一遍整個輸入數組,這也是它的平均時間復雜度、最好情況、最壞情況都是O(n^2)。
- 數據移動最少;每次交換都會改變兩個數組元素的值,交換次數和要排序的數組大小呈線性關系。
public static void selectSort(int[] arr) {
int n = arr.length;
if (n <= 1) {
return;
}
//選擇排序,每次選擇最小的元素與未進行排序的首元素進行交換
for (int i = 0; i < n; i++) {
int minIndex = i;
for (int j = i + 1; j < n; j++) {
if (arr[minIndex] > arr[j]) {
minIndex = j;
}
}
swap(arr, i, minIndex);
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
插入排序
插入排序,這樣記憶,將一個元素插入到已經排好序的有序數組中。
插入排序的基本思想是:每步將一個待排序的元素,插入前面已經排序的數組中適當位置上,直到全部插入完為止。
在程序的實現中,為了給要插入的元素騰出空間,需要將其余所有元素在插入之前都向右移動一位。
插入排序所需的時間取決於輸入元素的初始順序,對數據量比較大且基本有序的數組進行排序要比對隨機順序或者逆序數組排序要快的多。
public static void insertSort(int[] arr) {
int n = arr.length;
if (n <= 1) {
return;
}
//插入排序:找到位置,將其余所有元素在插入之前都向右移動一位
for (int i = 1; i < n; i++) {
for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
swap(arr, j, j - 1);
}
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
希爾排序
希爾排序是1959年Shell發明,是第一個突破O(n^2)的排序算法,是簡單插入排序的改進版。與插入排序的不同之處在於,它會優先比較距離較遠的元素。
希爾排序是把記錄按下標的一定增量分組,對每組使用直接插入排序算法排序;隨着增量逐漸減少,每組包含的關鍵詞越來越多,當增量減至1時,整個文件恰被分成一組,算法便終止。
希爾排序的核心在於間隔序列的設定。既可以提前設定好間隔序列,也可以動態的定義間隔序列。動態定義間隔序列的算法是《算法(第4版)》的合著者Robert Sedgewick提出的。
關於希爾排序的時間復雜度,有人在大量的實驗之后得出結論:當n在某個特定的范圍后希爾排序的比較和移動次數減少至n^1.3 ,關於數學論證,這就很困難了。這種科學難題我們就不用太糾結了。
public static void shellSort(int[] arr) {
int n = arr.length;
int h = 1;
while (h < n / 3) {
h = 3 * h + 1;//1,4,13,40,121,364,1093, ...
}
while (h >= 1) {
//將數組變為h有序
for (int i = h; i < n; i++) {
//將arr[i]插入到arr[i-h],arrr[i-2*h],arr[i-3*h]...中
for (int j = i; j >= h && arr[j] < arr[j - h]; j -= h) {
swap(arr, j, j - h);
}
}
h = h / 3;
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
快速排序
重要!重要!重要!>在現場筆試和面試中遇到好多次了(阿里巴巴、字節跳動、騰訊、百度等)。
與冒泡排序相比,快速排序每次交換是跳躍式的,這也是快速排序速度較快的原因。每次排序的時候選擇一個基准點,將小於基准點的全部放到基准點左邊,將大於基准點的都放到基准點右邊。這樣每次交換的時候就不會想冒泡排序一樣只交換相鄰位置的元素,交換距離變大,交換次數變小,從而提高速度。當然在最壞情況下,仍可能是相鄰兩個數進行了交換。因此快速排序的最差時間復雜度和冒泡排序是一樣的,都是O(n^2)。快速排序的平均時間復雜度為O(nlogn)。而且,快速排序是原地排序(只需要一個很小的輔助棧),時間和空間復雜度都很優秀。用《算法(第四版)》的話來說就是:
快速排序是最快的通用排序算法。
程序怎么寫:
- 定義一個基准數(初始化值設置為左邊第一個元素)和兩個左右指針(分別為i和j);
- 當i和j沒有相遇的時候,在循環中進行尋找i和j,讓j先從右往左尋找比基准數小的,i從左往右尋找比基准數大的,當然需要滿足條件
i<j
;找到了的時候,進行交換。為什么要右邊的指針先走呢?當從左邊開始時,那么 i 所停留的那個位置肯定是大於基數base的,為了滿足i<j
的條件,j也會停下。那么如果在此時進行交換,會發現交換以后並不滿足基准數左邊都比基准數小,右邊都比基准數大。 - 當i和j相遇的時候,說明i右邊已經沒有比基准數base小的元素了,左邊沒有比基准數大的元素了,此時交換i位置上的元素arr[i]和基准數,基准數的位置就定好了。
- 基准數歸位
- 繼續快速排序處理i的左半部分和右半部分。
如果理解了,自己能寫出來最好。如果還沒有完全理解,需要進行面試,那我覺得還是背下來吧。對,沒有看錯,就是背下來,現場筆試的時候直接默寫!!!
public static void quickSort(int[] arr, int left, int right){
if(left > right){
return;
}
int base = arr[0];//基准數
int i = left;
int j = right;
//i和j沒有相遇,在循環中進行檢索
while(i != j){
//先由j從右往左檢索比基准數小的,找到就停下
while(arr[j] >= base && i < j){
j--;
}
//i從左往右檢索比基准數大的,找到就停下
while(arr[i] <= base && i < j){
i++;
}
//此時,找到了i和j,進行交換
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
//基准數歸位
arr[left] = arr[i];//相遇位置的元素賦值給基准位置的元素
arr[i] = base;//基准數賦值給相遇位置的元素
//此時,i左邊的都比i小,右邊的都比i大;再進行快速排序
quickSort(arr, left, i-1);
quickSort(arr, i+1, right);
}
歸並排序
上文提到,快速排序是最快的通用排序算法。的確,在大多數情況下,快速排序是最佳選擇。但是,有一個明顯的例外:如果穩定性很重要且空間又不是問題,歸並排序可能是最好的。
歸並排序是分治思想(divide-and-conquer)的典型應用。將待排序的數組,可以先(遞歸地)將它分成兩半分別排序,然后將結果歸並起來。
歸並排序的優點是能夠保證將任意長度為n的數組排序所需的時間與nlogn成正比,時間復雜度為O(nlogn);缺點也很明顯,所需的額外空間與n成正比,空間復雜度O(n)。
/**
*
* @param arr
* 待歸並的數組
* @param l
* 左邊界
* @param mid
* 中
* @param r
* 右邊界
*/
public static void merge(int[] arr, int l, int mid, int r) {
int[] aux = Arrays.copyOfRange(arr, 0, arr.length);//復制數組
//將[l,mid]和[mid+1,r]歸並
int i = l, j = mid + 1;
for (int k = l; k <= r; k++) {
if (i > mid)
arr[k] = aux[j++];
else if (j > r)
arr[k] = aux[i++];
else if (aux[j] < aux[i]) {
arr[k] = aux[j++];
} else {
arr[k] = aux[i++];
}
}
}
public static void sort(int[] arr, int l, int r) {
if (l >= r)
return;
int mid = (l + r) / 2;
sort(arr, l, mid);//左邊歸並排序
sort(arr, mid + 1, r);//右邊歸並排序
merge(arr, l, mid, r);//將兩個有序子數組合並
}
public static void sort(int[] arr) {
sort(arr, 0, arr.length - 1);
}
堆排序
堆排序,首要問題是要知道什么是堆?
通俗來說,堆是一種特殊的完全二叉樹。如果這課二叉樹所有父節點都要比子節點大,就叫大頂堆;如果所有父節點都比子節點小,就叫小頂堆。
《算法(第四版)》是這么說的:
當一棵二叉樹的每個節點都大於等於它的兩個節點時,它被稱為堆有序。
二叉堆是一組能夠用堆有序的完全二叉樹排序的元素,並在數組中按照層序存儲。
也就是說:對於n個元素的待排序數組arr[0,...,n-1],當且僅當滿足下列要求(0 <= i <= (n-1)/2
):
array[i] >= array[2*i + 1]
且 array[i] >= array[2*i + 2]
; 稱為大根堆;
array[i] <= array[2*i + 1]
且 array[i] <= array[2*i + 2]
; 稱為小根堆;
堆排序的基本思想(大頂堆為例):將待排序數組構造成一個大頂堆,此時,整個數組的最大值就是堆頂元素。將其與末尾元素進行交換,此時末尾就為最大值。然后將剩余n-1個元素重新構造成一個堆,這樣會得到n個元素的次小值。如此反復執行,就可以得到一個有序數組。
具體過程:
- 建堆;
- 將堆頂元素與堆底元素進行交換;
- 堆頂元素向下調整使其繼續保持大根堆的性質;
- 重復過程2,3,直到堆中只剩下堆頂元素未交換,此時也無法交換了,排序完成。
其中建堆的時間復雜度為O(n);
由於堆的高度為logn,所以將堆頂元素與堆底元素進行交換並進行排序的時間復雜度為O(logn);
所以整體的時間復雜度為O(nlogn)。
堆排序過程中只有交換的時候借助了輔助空間,空間復雜度為O(1)。
/**
*
* @param arr
* 要進行堆排序的數組
* @param n
* 數組元素個數
* @param i
* 對節點i進行heapify操作
*/
public static void heapify(int[] arr, int n, int i) {
if (i >= n) {
return;
}
int c1 = 2 * i + 1;
int c2 = 2 * i + 2;
int max = i;//假設最大的為arr[i]
//取左右孩子中較大者的進行交換
if (c1 < n && arr[c1] > arr[max]) {
max = c1;
}
if (c2 < n && arr[c2] > arr[max]) {
max = c2;
}
if (max != i) {
swap(arr, max, i);
heapify(arr, n, max);
}
}
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void buildHeap(int arr[], int n) {
int lastNode = n - 1;
int parent = (lastNode - 1) / 2;
//從最后一個節點的父節點開始,直到根節點0,反復調整堆heapify
for (int i = parent; i >= 0; i--) {
heapify(arr, n, i);
}
}
public static void heapSort(int[] arr, int n) {
buildHeap(arr, n);
for (int i = n - 1; i >= 0; i--) {
swap(arr, i, 0);
heapify(arr, i, 0);
}
}
總結
以上的排序算法都是基於比較的排序算法。通過比較來決定元素之間的相對次序,其時間復雜度不能突破O(nlogn)的界限。
關於穩定性,如果一個排序算法能夠保留數組中重復元素的相對位置,就是穩定的。怎么記憶呢?不穩定的排序算法可以用”快些選對“諧音來記:快速排序、希爾排序、選擇排序、堆排序。
用一張表格來作為小結:
排序方法 | 平均情況 | 最好情況 | 最壞情況 | 空間復雜度 | 穩定性 |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n) | O(n^2) | O(1) | 穩定 |
選擇排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不穩定 |
插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 穩定 |
希爾排序 | O(nlogn) ~ O(n^2) | O(n1.3) | O(n^2) | O(1) | 不穩定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不穩定 |
歸並排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 穩定 |
快速排序 | O(nlogn) | O(nlogn) | O(n^2) | O(logn)~O(n) | 不穩定 |
高曉松老師曾說:生活不只是眼前的苟且,還有詩和遠方。而我希望遠方不遠,有處可尋,祝大家早日拿到Offer。