上篇文章寫了關於 Java 內部類的基本知識,感興趣的朋友能夠去看一下:搞懂 JAVA 內部類;本文寫的內容是最近學習的算法相關知識中的基本排序算法,排序算法也算是面試中的常客了,實際上也是算法中最基本的知識。因爲 Android 開發中用到的地方並很少,因此也很容易遺忘,可是爲了進階高級工程師鞏固基本算法和數據結構也是必修課程之一。html
基本排序算法按難易程度來講能夠分爲:冒泡排序,選擇排序,插入排序,歸併排序,選擇排序。本文也將從這五種排序算法來說解各自的中心思想,和 Java 實現方式。java
冒泡排序恐怕是咱們計算機專業課程上以第一個接觸到的排序算法,也算是一種入門級的排序算法。git
冒泡排序雖然簡單可是對於 n 數量級很大的時候,實際上是很低效率的。因此實際生產中不多使用這種排序算法。下面咱們看下這種算法的具體實現思路:github
一次比較過程如圖所示(圖片 Google 來的侵刪)面試
/** * @param arr 待排序數組 * @param n 數組長度 arr.length */ private static void BubbleSort(int[] arr, int n) { for (int i = 0; i < n - 1; i++) { for (int j = 1; j < n - i; j++) { if (arr[j - 1] > arr[j]) { //交換兩個元素 int temp = arr[j]; arr[j] = arr[j - 1]; arr[j - 1] = temp; } } } } 複製代碼
對於長度爲 n 的數組,冒泡排序須要通過 n(n-1)/2 次比較,最壞的狀況下,即數組自己是倒序的狀況下,須要通過 n(n-1)/2 次交換,因此其算法
冒泡排序的算法時間平均複雜度爲O(n²)。空間複雜度爲 O(1)。數組
能夠想象一下:若是兩個相鄰的元素相等是不會進行交換操做的,也就是兩個相等元素的前後順序是不會改變的。若是兩個相等的元素沒有相鄰,那麼即便經過前面的兩兩交換把兩個元素相鄰起來,最終也不會交換它倆的位置,因此相同元素通過排序後順序並無改變。數據結構
因此冒泡排序是一種穩定排序算法。因此冒泡排序是穩定排序。這也正是算法穩定性的定義:dom
排序算法的穩定性:通俗地講就是能保證排序前兩個相等的數據其在序列中的前後位置順序與排序後它們兩個前後位置順序相同。post
冒泡排序總結:
選擇排序是另外一種簡單的排序算法。選擇排序之因此叫選擇排序就是在一次遍歷過程當中找到最小元素的角標位置,而後把它放到數組的首端。咱們排序過程都是在尋找剩餘數組中的最小元素,因此就叫作選擇排序。
選擇排序的思想也很簡單:
示意圖:
public static void sort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { int minIndex = i; // for 循環 i 以後全部的數字 找到剩餘數組中最小值得索引 for (int j = i + 1; j < n; j++) { if (arr[j]< arr[minIndex]) { minIndex = j; } } swap(arr, i, minIndex); } } /** * 角標的形式 交換元素 */ private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } 複製代碼
上述 java 代碼能夠看出咱們除了交換元素並未開闢額外的空間,因此額外的空間複雜度爲O(1)。
對於時間複雜度而言,選擇排序序冒泡排序同樣都須要遍歷 n(n-1)/2 次,可是相對於冒泡排序來講每次遍歷只須要交換一次元素,這對於計算機執行來講有必定的優化。可是選擇排序也是名副其實的慢性子,即便是有序數組,也須要進行 n(n-1)/2 次比較,因此其時間複雜度爲O(n²)。
即使不管如何也要進行n(n-1)/2 次比較,選擇排序還是不穩定的排序算法,咱們舉一個例子如:序列5 8 5 2 9, 咱們知道第一趟選擇第1個元素5會與2進行交換,那麼原序列中兩個5的相對前後順序也就被破壞了。
選擇排序總結:
對於插入排序,大部分資料都是使用撲克牌整理做爲例子來引入的,咱們打牌都是一張一張摸牌的,沒摸到一張牌就會跟手裏全部的牌比較來選擇合適的位置插入這張牌,這也就是直接插入排序的中心思想,咱們先來看下動圖:
相信你們看完動圖之後大概知道了插入排序的實現思路了。那麼咱們就來講下插入排序的思想。
下面先看下最基本的實現:
public static void sort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { //內層循環比較 i 與前邊全部元素值,若是 j 索引所指的值小於 j- 1 則交換二者的位置 for(int j = i; j > 0 && arr[j-1] > arr[j]; j--){ swap(arr,j-1,j); } } } 複製代碼
在上述算法實現中咱們每次尋找 i 應該處在數組中哪一個爲位置的時候,都是以交換當前元素與上一個元素爲代價的,咱們知道交換操做是要比賦值操做要費時的,由於每次交換都須要通過三次賦值操做,咱們想一下咱們玩撲克的時候沒有拿起一張牌一個個向前挪知道放到其該放的位置的吧,都是拿出這張牌,找到位置就插進去(忽然邪惡),實際上咱們是將這個位置之後的牌一次向後挪了一個位置,那麼用Java 代碼是否能實現呢?答案確定是能夠的:
public static void sort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { //拎出來當前未排序的這樣牌 int e = arr[i]; //尋找其該放的位置 for(int j = i; j > 0 && arr[j-1] > arr[j]; j--){ arr[j]= arr[j-1]; } //循環結束後 arr[j] >= arr[j-1] 那麼 j 角標就是e 應該在的位置。 arr[j] = e; } } 複製代碼
對於插入的時間複雜度和空間複雜度,經過代碼就能夠看出跟選擇和冒泡來講沒什麼區別同屬於 O(n²) 級別的時間複雜度算法 ,只是遍歷方式有原來的 n n-1 n-2 ... 1,變成了 1 2 3 ... n 了。最終獲得時間複雜度都是 n(n-1)/2。
對於穩定性來講,插入排序和冒泡同樣,並不會改變原有的元素之間的順序,若是碰見一個與插入元素相等的,那麼把待插入的元素放在相等元素的後面。因此,相等元素的先後順序沒有改變,從原無序序列出去的順序還是排好序後的順序,因此插入排序是穩定的。
對於插入排序這裏說一個很是重要的一點就是:因爲這個算法能夠提早終止內層比較( arr[j-1] > arr[j])因此這個排序算法頗有用!所以對於一些 NlogN 級別的算法,後邊的歸併和快速都屬於這個級別的,算法來講對於 n 小於必定級別的時候(Array.sort 中使用的是47)均可以用插入算法來優化,另外對於近乎有序的數組來講這個提早終止的方式就顯得更加又有優點了。
插入排序總結:
接下來咱們看一個 NlogN 級別的排序算法,歸併算法。 歸併算法正如其名字同樣採用歸併的方法進行排序:
咱們老是能夠將一個數組一分爲二,而後二分爲四直到,每一組只有兩個元素,這能夠理解爲個遞歸的過程,而後將兩個元素進行排序,以後再將兩個元素爲一組進行排序。直到全部的元素都排序完成。一樣咱們來看下邊這個動圖。
歸併算法其實能夠分爲遞歸法和迭代法(自低向上歸併),兩種實現對於最小集合的歸併操做思想是同樣的區別在於如何劃分數組,咱們先介紹下算法最基本的操做:
假設咱們如今在對一個數組的 arr[l...r]
部分進行歸併,按照上述歸併思想咱們可將數組分爲兩部分 假設爲 arr[l...mid] 和 arr[mid+1...r]
兩部分,注意這兩部分可能長度並不相同,由於基數個數的數組劃分的時候老是能獲得一個 長度爲1 和長度爲2 的部分進行歸併.
那麼咱們按照上述思路進行代碼編寫:
/** * arr[l,mid] 和 arr[mid+1,r] 兩部分進行歸併 */ private static void merge(int[] arr, int l, int mid, int r) { // 複製等待歸併數組 用來進行比較操做,最將原來的 arr 每一個角標賦值爲正確的元素 int[] aux = new int[r - l + 1]; for (int i = l; i <= r; i++) { aux[i - l] = arr[i]; } int i = l; int j = mid + 1; for (int k = l; k <= r; k++) { if (i > mid) { //說明左邊部分已經全都放進數組了 arr[k] = aux[j - l]; j++; } else if (j > r) { //說明左邊部分已經全都放進數組了 arr[k] = aux[i - l]; i++; } else if (aux[i - l] < aux[j - l]) { //當左半個數組的元素值小於右邊數組元素值得時候 賦值爲左邊的元素值 arr[k] = aux[i - l]; i++; } else { //當左半個數組的元素值大於等於右邊數組元素值得時候 賦值爲左邊的元素值 這樣也保證了排序的穩定性 arr[k] = aux[j - l]; j++; } } } 複製代碼
相信你們配合剛纔的動圖和上述算法實現已經理解了歸併算法了,若是感到迷糊的話能夠試着拿個一個數組在紙上演算一下歸併的過程,相信你們必定能夠理解。上述只是實現了算法核心部分,那麼咱們應該怎麼對整個數組來進行排序呢?上邊也提到了有兩種方法,一種是遞歸劃分法,一種是迭代遍歷法(自低向上)那麼咱們先來開來看遞歸實現:
/** * * @param arr 待排序數組 * @param l 其實元素角標 0 * @param r 最後一個元素角標 n -1 */ private static void mergeSort(int[] arr, int l, int r) { if (l >= r) { return; } //開始歸併排序 向下取整 int mid = (l + r) / 2; //遞歸劃分數組 mergeSort(arr, l, mid); mergeSort(arr, mid + 1, r); //檢查是否上一步歸併完的數組是否有序,若是有序則直接進行下一次歸併 if (arr[mid] <= arr[mid + 1]) { return; } //將兩邊的元素歸併排序 merge(arr, l, mid, r); } 複製代碼
若是對遞歸過程不理解能夠配合下邊這個圖來理解(圖片來自網上,侵刪):
固然咱們merge先對左半部分進行的也就是先進行到Level3的左邊最底層 8 | 6 ,而後歸併完成後進行右邊遞歸到底 最終是 8 6 2 3 | 1 5 7 4 進行歸併。
對於迭代實現歸併其實和遞歸實現有所不一樣,迭代的時候咱們是將數組分爲 一個一個的元素,而後每兩個歸併一次,第二次咱們將數組每兩個分一組,兩個兩個的歸併,知道分組大小等於待歸併數組長度爲止,即先局部排序,逐步擴大到全局排序
/** * 自低向上的歸併排序 * * @param n 爲數組長度 * @param arr 數組 */ private static void mergeSortBU(Integer[] arr, int n) { //外層遍歷從歸併區間長度爲1 開始 每次遞增一倍的空間 1 2 4 8 sz 須要遍歷到數組長度那麼大 //sz = 1 : [0] [1]... //sz = 2 : [0,1] [2.3] ... //sz = 4 : [0..3] [4...7] ... for (int sz = 1; sz <= n; sz += sz) { //內層遍歷要比較 arr[i,i+sz-1] arr[i+sz,i+sz+sz-1] 兩個區間的大小 也就是每次對 sz - 1 大小的數組空間進行歸併 // 注意每次 i 遞增 兩個 sz 的長度 ,由於每次 merge 的時候已經歸併了兩個 sz 長度 部分的數組 for (int i = 0; i + sz < n; i += sz + sz) { merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, n - 1)); } } } 複製代碼
好比咱們看第一次是 sz = 1 個長度的歸併即 i = 0 i = 1 的元素歸併 下次歸併應該爲 i= 2 i = 3 一次類推 因此內層循環 i 每次應該遞增 兩個 sz 那麼大 爲了不角標越界且保證歸併的右半部分存在 因此 i + sz < n ,又考慮到數組長度爲奇數的狀況,因此右半邊的右邊爲 Math.min(i + sz + sz - 1, n - 1);能夠參考下邊的圖片:
其實對於歸併排序的時間複雜對有一個遞歸公式來推斷出時間複雜度,但簡單來說假設數組長度爲 N ,那麼咱們就有 logN 次劃分區間,而最終會劃分爲常數 級別的歸併,將全部層的歸併時間加起來獲得了一個 NlogN,想要了解歸併排序時間複雜度講解的同窗能夠左轉 歸併排序及其時間複雜度分析,這裏再也不過多講解。
對於空間複雜度,咱們經過算法實現能夠看出咱們歸併過程申請了 長度爲 N 的臨時數組,來進行歸併因此空間複雜度爲 O(n);
又因爲咱們在排序過程當中對於 aux[i - l] = aux[j - l] 並無進行位置交換直接取得靠前的元素先賦值,因此算法是穩定的。
** 歸併排序總結:**
快速排序爲應用最多的排序算法,由於快速二字而聞名。快速排序和歸併排序同樣,採用的都是分治思想。分治法的基本思想是:將原問題分解爲若干個規模更小但結構與原問題類似的子問題。遞歸地解這些子問題,而後將這些子問題的解組合爲原問題的解。咱們只需關注最小問題該如何求解,和如何去遞歸既能夠獲得正確的算法實現。快速排序能夠分爲:單路快速排序,雙路快速排序,三路快速排序,他們區別在於選取幾個指針來對數組進行遍歷下面咱們依次來說解。
首先咱們選取數組中的一個數,將其放在合適的位置,這個位置左邊的數所有小於該數值,這個位置右邊的數所有大於該數值 。
假設數組爲 arr[l...r]
假設指定數值爲數組第一個元素 int v = arr[l]
,假設 j 標記爲比 v 小的最後一個元素, 即 arr[j+1] > v
。當前考察的元素爲 i 則有arr[l + 1 ... j] < v , arr[j+1,i) >= v
如上圖所示。
假設正在考察的元素值爲 e ,e >= v
的時候咱們只需交將不動,直接 i++ 去考察下一個元素,
當e < v
由上述假設咱們須要將 e 放在<v 的部分 ,此時咱們只需將 arr[j]
和 arr[i]
交換一下位置便可。
最後一個元素考察完成之後,咱們再講 arr[l]
和 arr[j]
調換一下位置就能夠了。
上述遍歷完成之後 arr[l + 1 ... j] < v , arr[j+1,i) >= v
就知足了,接下來咱們只須要遞歸的去考察 arr[l + 1 ... j] 和 arr[j+1,r] 便可。
private static void quickSort(int[] arr, int l, int r) { if (l >= r) { return; } // p 爲 第一次 排序完成後 v 應該在的位置,即分治的劃分點 int p = partition(arr, l, r); quickSort(arr, l, p - 1); quickSort(arr, p + 1, r); } private static int partition(Integer[] arr, int l, int r) { // 爲了提升效率,減小形成快速排序的遞歸樹不均勻的機率, // 對於一個數組,每次隨機選擇的數爲當前 partition 操做中最小最大元素的可能性爲 1/n int randomNum = (int) (Math.random() * (r - l + 1) + l); swap(arr, l, randomNum); int v = arr[l]; int j = l; for (int i = l + 1; i <= r; i++) { if (arr[i] < v) { swap(arr, j + 1, i); j++; } } swap(arr, l, j); return j; } private static void swap( int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } 複製代碼
對於上述算法中爲何選取了當前排序數組中隨機一個元素進行比較,假設咱們在考察的數組已經爲已經排序好的數組,那麼咱們遞歸樹就會向右側延伸 N 的深度,這種狀況使咱們不想要看到的,若是咱們每次 partition 都隨機從數組中取一個數,那麼這個數是當前排序數組中最小元素可能性爲 1/n 那麼每次都取到最小的數的可能性就很低了。
跟單路同樣,雙路快速排序,一樣選擇數組的第一個元素當作標誌位(通過隨機選擇後的)
雙路快速排序要求有兩個指針,指針 i j 分別指向 l+1 和 r 的位置而後二者同時向數組中間遍歷 在遍歷過程當中要保證arr[l+1 ... i) <= v, arr(j....r] >= v
所以咱們能夠初始化 i = l+1 以保證左側區間初始爲空,j = r 保證右側空間爲空
遍歷過程當中要 i <= r 且 arr[i] <= v 的時候 i ++ 就能夠了 當 arr[i] > v 時表示遇到了 i 的值大於 v 數值 此刻能等待 j 角標的值,從右向左遍歷數組 當 arr[i] < v 表示遇到了 j 的值小於 v 的元素,它不應在這個位置呆着,
獲得了 i j 的角標後 先要判斷是否到了循環結束的時候了,即 i 是否已經 大於 j 了。
不然 應該講 i 位置的元素和 j 位置的元素交換位置,而後 i++ j-- 繼續循環
遍歷結束的條件是 i>j 此時 arr[j]爲最後一個小於 v 的元素 arr[i] 爲第一個大於 v 的元素 所以 j 這個位置 就應該是 v 所應該在數組中的位置 所以遍歷結束後須要交換 arr[l] 與 arr[j]
private static void quickSort(int[] arr, int l, int r) { if (l >= r) { return; } // 這裏 p 爲 小於 v 的最後一個元素,=v 的第一個元素 int p = partition(arr, l, r); quickSort(arr, l, p - 1); quickSort(arr, p + 1, r); } private static int partition(int[] arr, int l, int r) { // 爲了提升效率,減小形成快速排序的遞歸樹不均勻的機率, // 對於一個數組,每次隨機選擇的數爲當前 partition 操做中最小最大元素的可能性下降 int randomNum = (int) (Math.random() * (r - l + 1) + l); swap(arr, l, randomNum); int v = arr[l]; int i = l + 1; int j = r; while (true) { while (i <= r && arr[i] <= v) i++; while (j >= l + 1 && arr[j] >= v) j--; if (i > j) break; swap(arr, i, j); i++; j--; } //j 最後角標停留在 i > j 即爲 比 v 小的最後一個一元素位置 swap(arr, l, j); return j; } 複製代碼
雙路快速排序爲最常用的快速排序實現,java 中對基本數據類型的排序 Arrays.sort() Collections.sort()
內部原理就是經過這種快速排序實現.
上述兩種算法咱們發現對於與標誌位相同的值得處理老是,作了多餘的交換處理,若是咱們可以將數組分爲> = <
三部分的話效率可能會有所提升。 以下圖所示:
咱們將數組劃分爲 arr[l+1...lt] <v arr[lt+1..i) =v arr[gt...r] > v
三部分 其中 lt 指向 < v 的最後一個元素前一個元素,gt 指向>v的第一個元素的前一個元素,i 爲當前考察元素
定義初始值得時候依舊能夠保證這初始的時候這三部分都爲空 int lt = l; int gt = r + 1; int i = l + 1;
當 e > v
的時候咱們須要將 arr[i] 與 arr[gt-1]
交換位置,並將 > v
的部分擴大一個元素 即 gt--
可是此時 i 指針並不須要操做,由於換過過來的數尚未被考察。
當 e = v
的時候 i ++ 繼續考察下一個
當 e < v
的時候咱們須要將 arr[i] 與 arr[lt+1]
交換位置
當循環結束的時候 lt 位於小於 v 的最後一個元素位置因此最後咱們須要將arr[l] 與 arr[lt] 交換一下位置。
最後再遞歸的對 arr[l...lt-1] 和 arr[gt...r] 進行排序就能獲得正確結果了。
以下圖2所示
private static void quickSort3(int[] num, int length) { quickSort(num, 0, length - 1); } private static void quickSort(int[] arr, int l, int r) { if (l >= r) { return; } // 爲了提升效率,減小形成快速排序的遞歸樹不均勻的機率, // 對於一個數組,每次隨機選擇的數爲當前 partition 操做中最小最大元素的可能性 下降 1/n! int randomNum = (int) (Math.random() * (r - l + 1) + l); swap(arr, l, randomNum); int v = arr[l]; // 三路快速排序即把數組劃分爲大於 小於 等於 三部分 //arr[l+1...lt] <v arr[lt+1..i) =v arr[gt...r] > v 三部分 // 定義初始值得時候依舊能夠保證這初始的時候這三部分都爲空 int lt = l; int gt = r + 1; int i = l + 1; while (i < gt) { if (arr[i] < v) { swap(arr, i, lt + 1); i++; lt++; } else if (arr[i] == v) { i++; } else { swap(arr, i, gt - 1); gt--; //i++ 注意這裏 i 不須要加1 由於此次交換後 i 的值仍不等於 v 可能小於 v 也可能等於 v 因此交換完成後 i 的角標不變 } } //循環結束的後 lt 所處的位置爲 <v 的最後一個元素 i 確定與 gt 重合 //可是 最終v 要放的位置並非 i 所指的位置 由於此時 i 爲大於 v 的第一個元素 v //而 v 應該處的位置爲 lt 位置 並非 i-1 所處的位置(arr[i-1] = arr[l]) swap(arr, l, lt); quickSort(arr,l,lt-1); quickSort(arr,gt,r); } 複製代碼
因爲咱們最常使用的是雙路快排所以咱們以此來分析:咱們爲了方便分析咱們假定元素不是隨機選取的而是取得數組第一個元素,在選取的標準元素和 partition 獲得位置交換的時候,頗有可能把前面的元素的穩定性打亂,
好比序列爲 5 3 3 4 3 8 9 10 11
如今基準元素5和3(第5個元素,下標從1開始計)交換就會把元素3的穩定性打亂。因此快速排序是一個不穩定的排序算法,不穩定發生在基準元素和a[partition]交換的時刻。
對於快速排序的時間度取決於其遞歸的深度,若是遞歸深度又決定於每次關鍵值得取值因此在最好的狀況下每次都取到數組中間值,那麼此時算法時間複雜度最優爲 O(nlogn)。固然最壞狀況就是以前咱們分析的有序數組,那麼每次都須要進行 n 次比較則 時間複雜度爲 O(n²),可是在平均狀況 時間複雜度爲 O(nlogn),一樣若想看詳細的推到這裏推薦一個連接 快速排序最好,最壞,平均複雜度分析
快速排序的空間複雜度主要取決於表示爲選擇的時候的臨時空間,因此跟時間複雜度掛鉤,因此平均的空間複雜度也是 O(nlogn)。
本文總結了常見的排序算法的實現,經過研究這些算法的思想,也有助於算法題的解題思路。對於這幾種算法都是須要咱們熟練掌握的,可是 Android 工做平時不會接觸太多的數據處理,所以咱們須要刻意的去常常複習,本文的圖片大部分來自於網上,若是有問題的話能夠私信我刪掉。若是文章所說的內容有技術問題也歡迎聯繫我。