這道題給出了輸入數組裏每個元素的值的範圍 -50000 <= A[i] <= 50000,爲此寫一個「非穩定」的「計數排序」就能得到一個不錯的評分。
這裏和大家分享一下我學習的「基礎排序算法」的知識點。
我從零基礎到真正入門算法,就是從學習排序算法開始的,所以「排序算法」是我的初戀,差不多 3 年了。排序算法作爲一項需求,它足夠簡單,是學習基礎算法思想(例如:分治算法、減治思想、遞歸寫法)的很好的學習材料。 如果覺得算法難,無法入手,不妨從寫好一個排序算法開始。
如果是面試遇到寫排序算法,一般還是先問清楚數據的特點,有的時候可能還會給具體的業務場景,在面試官肯定採用的算法之後再編碼,不要一上來就手撕快排。
先說乾貨:
1、學習算法的可視化網站:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
大家點到 Sorting 這一章節,會看到我這篇題解介紹到的 10 大排序算法,而且還是交互式的,強烈推薦大家去點一下。
建議:先了解算法的思路,再去理解代碼是怎麼寫的。如果看書,看我後面總結的不太清楚的地方,大家自己點一下這個網站,就會非常清楚了,還挺好玩的。
2、《算法 4》、《算法導論》、《阿里巴巴 Java 開發手冊》下載
鏈接:https://pan.baidu.com/s/1hIQM4y_OTlbZnJLYpmLoiw
密碼:91fj
以下介紹的內容來自《算法 4》和《算法導論》,它們介紹的算法思想足夠經典,但不是最新研究結果,也並非最快。如果想研究最新排序算法的結論,可以參考最新的學術論文,或者是在互聯網上搜索相關資料,或者是查看您當前所使用語言關於排序部分的源代碼。
(依然是囉嗦兩句:《算法 4》和《算法導論》不是面向筆試和麪試的書籍,對於新接觸算法的朋友,可以把它們作爲在「力扣」刷題的參考書,遇到什麼知識點不會了,再去查,除非是專業的研究人員,看這兩本書的時候建議忽略其中的數學證明和公式,只挑對自己有用的部分來看)。
1、選擇排序(瞭解)
思路:每一輪選取未排定的部分中最小的部分交換到未排定部分的最開頭,經過若干個步驟,就能排定整個數組。即:先選出最小的,再選出第 2 小的,以此類推。
參考代碼 1:
Java
import java.util.Arrays;
public class Solution {
// 選擇排序:每一輪選擇最小元素交換到未排定部分的開頭
public int[] sortArray(int[] nums) {
int len = nums.length;
// 循環不變量:[0, i) 有序,且該區間裏所有元素就是最終排定的樣子
for (int i = 0; i < len - 1; i++) {
// 選擇區間 [i, len - 1] 裏最小的元素的索引,交換到下標 i
int minIndex = i;
for (int j = i + 1; j < len; j++) {
if (nums[j] < nums[minIndex]) {
minIndex = j;
}
}
swap(nums, i, minIndex);
}
return nums;
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
public static void main(String[] args) {
int[] nums = {5, 2, 3, 1};
Solution solution = new Solution();
int[] res = solution.sortArray(nums);
System.out.println(Arrays.toString(res));
}
}
總結:
算法思想 1:貪心算法:每一次決策只看當前,當前最優,則全局最優。注意:這種思想不是任何時候都適用。
算法思想 2:減治思想:外層循環每一次都能排定一個元素,問題的規模逐漸減少,直到全部解決,即「大而化小,小而化了」。運用「減治思想」很典型的算法就是大名鼎鼎的「二分查找」。
優點:交換次數最少。
「選擇排序」看起來好像最沒有用,但是如果在交換成本較高的排序任務中,就可以使用「選擇排序」(《算法 4》相關章節課後練習題)。
依然是建議大家不要對算法帶有個人色彩,在面試回答問題的時候和看待一個人和事物的時候,可以參考的回答模式是「具體問題具體分析,在什麼什麼情況下,用什麼什麼算法」。
複雜度分析:
時間複雜度:O(N^2)O(N
2
),這裏 NN 是數組的長度;
空間複雜度:O(1)O(1),使用到常數個臨時變量。
2、插入排序(熟悉)
思路:每次將一個數字插入一個有序的數組裏,成爲一個長度更長的有序數組,有限次操作以後,數組整體有序。
圖片來自「力扣」第 147 題:對鏈表進行插入排序。
參考代碼 2:
Java
public class Solution {
// 插入排序:穩定排序,在接近有序的情況下,表現優異
public int[] sortArray(int[] nums) {
int len = nums.length;
// 循環不變量:將 nums[i] 插入到區間 [0, i) 使之成爲有序數組
for (int i = 1; i < len; i++) {
// 先暫存這個元素,然後之前元素逐個後移,留出空位
int temp = nums[i];
int j = i;
// 注意邊界 j > 0
while (j > 0 && nums[j - 1] > temp) {
nums[j] = nums[j - 1];
j--;
}
nums[j] = temp;
}
return nums;
}
}
優化:「將一個數字插入一個有序的數組」這一步,可以不使用逐步交換,使用先賦值給「臨時變量」,然後「適當的元素」後移,空出一個位置,最後把「臨時變量」賦值給這個空位的策略(就是上面那張圖的意思)。編碼的時候如果不小心,可能會把數組的值修改,建議多調試;
特點:「插入排序」可以提前終止內層循環(體現在 nums[j - 1] > temp 不滿足時),在數組「幾乎有序」的前提下,「插入排序」的時間複雜度可以達到 O(N)O(N);
由於「插入排序」在「幾乎有序」的數組上表現良好,特別地,在「短數組」上的表現也很好。因爲「短數組」的特點是:每個元素離它最終排定的位置都不會太遠。爲此,在小區間內執行排序任務的時候,可以轉向使用「插入排序」。
複雜度分析:
時間複雜度:O(N^2)O(N
2
),這裏 NN 是數組的長度;
空間複雜度:O(1)O(1),使用到常數個臨時變量。
3、歸併排序(重點)
基本思路:藉助額外空間,合併兩個有序數組,得到更長的有序數組。例如:「力扣」第 88 題:合併兩個有序數組。
算法思想:分而治之(分治思想)。「分而治之」思想的形象理解是「曹衝稱象」、MapReduce,在一定情況下可以並行化。
個人建議:「歸併排序」是理解「遞歸思想」的非常好的學習材料,大家可以通過理解:遞歸完成以後,合併兩個有序數組的這一步驟,想清楚程序的執行流程。即「遞歸函數執行完成以後,我們還可以做點事情」。因此,「歸併排序」我個人覺得非常重要,一定要掌握。
參考代碼 3:
Java
public class Solution {
// 歸併排序
/**
* 列表大小等於或小於該大小,將優先於 mergeSort 使用插入排序
*/
private static final int INSERTION_SORT_THRESHOLD = 7;
public int[] sortArray(int[] nums) {
int len = nums.length;
int[] temp = new int[len];
mergeSort(nums, 0, len - 1, temp);
return nums;
}
/**
* 對數組 nums 的子區間 [left, right] 進行歸併排序
*
* @param nums
* @param left
* @param right
* @param temp 用於合併兩個有序數組的輔助數組,全局使用一份,避免多次創建和銷燬
*/
private void mergeSort(int[] nums, int left, int right, int[] temp) {
// 小區間使用插入排序
if (right - left <= INSERTION_SORT_THRESHOLD) {
insertionSort(nums, left, right);
return;
}
int mid = left + (right - left) / 2;
// Java 裏有更優的寫法,在 left 和 right 都是大整數時,即使溢出,結論依然正確
// int mid = (left + right) >>> 1;
mergeSort(nums, left, mid, temp);
mergeSort(nums, mid + 1, right, temp);
// 如果數組的這個子區間本身有序,無需合併
if (nums[mid] <= nums[mid + 1]) {
return;
}
mergeOfTwoSortedArray(nums, left, mid, right, temp);
}
/**
* 對數組 arr 的子區間 [left, right] 使用插入排序
*
* @param arr 給定數組
* @param left 左邊界,能取到
* @param right 右邊界,能取到
*/
private void insertionSort(int[] arr, int left, int right) {
for (int i = left + 1; i <= right; i++) {
int temp = arr[i];
int j = i;
while (j > left && arr[j - 1] > temp) {
arr[j] = arr[j - 1];
j--;
}
arr[j] = temp;
}
}
/**
* 合併兩個有序數組:先把值複製到臨時數組,再合並回去
*
* @param nums
* @param left
* @param mid [left, mid] 有序,[mid + 1, right] 有序
* @param right
* @param temp 全局使用的臨時數組
*/
private void mergeOfTwoSortedArray(int[] nums, int left, int mid, int right, int[] temp) {
System.arraycopy(nums, left, temp, left, right + 1 - left);
int i = left;
int j = mid + 1;
for (int k = left; k <= right; k++) {
if (i == mid + 1) {
nums[k] = temp[j];
j++;
} else if (j == right + 1) {
nums[k] = temp[i];
i++;
} else if (temp[i] <= temp[j]) {
// 注意寫成 < 就丟失了穩定性(相同元素原來靠前的排序以後依然靠前)
nums[k] = temp[i];
i++;
} else {
// temp[i] > temp[j]
nums[k] = temp[j];
j++;
}
}
}
}
優化 1:在「小區間」裏轉向使用「插入排序」,Java 源碼裏面也有類似這種操作,「小區間」的長度是個超參數,需要測試決定,我這裏參考了 JDK 源碼;
優化 2: 在「兩個數組」本身就是有序的情況下,無需合併;
優化 3:全程使用一份臨時數組進行「合併兩個有序數組」的操作,避免創建臨時數組和銷燬的消耗,避免計算下標偏移量。
注意:實現歸併排序的時候,要特別注意,不要把這個算法實現成非穩定排序,區別就在 <= 和 < ,已在代碼中註明。
「歸併排序」比「快速排序」好的一點是,它藉助了額外空間,可以實現「穩定排序」,Java 裏對於「對象數組」的排序任務,就是使用歸併排序(的升級版 TimSort,在這裏就不多做介紹)。
複雜度分析:
時間複雜度:O(N \log N)O(NlogN),這裏 NN 是數組的長度;
空間複雜度:O(N)O(N),輔助數組與輸入數組規模相當。
「歸併排序」也有「原地歸併排序」和「不使用遞歸」的歸併排序,但是我個人覺得不常用,編碼、調試都有一定難度。遞歸、分治處理問題的思想在基礎算法領域是非常常見的,建議多練習編寫「歸併排序」學習遞歸思想,瞭解遞歸的細節,熟悉分治的思想。
經典問題:
《劍指 Offer》第 51 題:數組中的逆序對,照着歸併排序的思路就能寫出來。
「力扣」第 315 題:計算右側小於當前元素的個數,它們是一個問題。
4、快速排序(重點)
基本思路:快速排序每一次都排定一個元素(這個元素呆在了它最終應該呆的位置),然後遞歸地去排它左邊的部分和右邊的部分,依次進行下去,直到數組有序;
算法思想:分而治之(分治思想),與「歸併排序」不同,「快速排序」在「分」這件事情上不想「歸併排序」無腦地一分爲二,而是採用了 partition 的方法(書上,和網上都有介紹,就不展開了),因此就沒有「合」的過程。
實現細節(注意事項):(針對特殊測試用例:順序數組或者逆序數組)一定要隨機化選擇切分元素(pivot),否則在輸入數組是有序數組或者是逆序數組的時候,快速排序會變得非常慢(等同於冒泡排序或者「選擇排序」);
以下是針對特殊測試用例(有很多重複元素的輸入數組)有 3 種版本的快排:
版本 1:基本快排:把等於切分元素的所有元素分到了數組的同一側,可能會造成遞歸樹傾斜;
版本 2:雙指針快排:把等於切分元素的所有元素等概率地分到了數組的兩側,避免了遞歸樹傾斜,遞歸樹相對平衡;
版本 3:三指針快排:把等於切分元素的所有元素擠到了數組的中間,在有很多元素和切分元素相等的情況下,遞歸區間大大減少。
這裏有一個經驗的總結:之所以快排有這些優化,起因都是來自「遞歸樹」的高度。關於「樹」的算法的優化,絕大部分都是在和樹的「高度」較勁。類似的通過減少樹高度、使得樹更平衡的數據結構還有「二叉搜索樹」優化成「AVL 樹」或者「紅黑樹」、「並查集」的「按秩合併」與「路徑壓縮」。
寫對「快速排序」的技巧:保持「循環不變量」,即定義的變量在循環開始前、循環過程中、循環結束以後,都保持不變的性質,這個性質是人爲根據問題特點定義的。
「循環不變量」的內容在《算法導論》這本書裏有介紹。我個人覺得非常有用。「循環不變量」是證明算法有效性的基礎,更是寫對代碼的保證,遵守循環不變量,是不是該寫等於號,先交換還是先 ++ ,就會特別清楚,絕對不會寫錯,我在編碼的時候,會將遵守的「循環不變量」作爲註釋寫在代碼中。
快速排序丟失了穩定性,如果需要穩定的快速排序,需要具體定義比較函數,這個過程叫「穩定化」,在這裏就不展開了。
使用「快速排序」解決的經典問題(非常重要):
TopK 問題:「力扣」第 215 題:數組中的第 K 個最大元素;
荷蘭國旗問題:「力扣」第 75 題:顏色分類。
不好意思,我又來囉嗦了:《算法 4》這本書裏面的代碼風格是極其不推薦的。代碼是寫給人看的,應該儘量避免代碼個人風格化,採用統一規範的寫法,保證易讀性,可擴展性。
參考代碼 4:(下面提供了快排的三個版本,供參考)
說明:
lt 是 less than 的縮寫,表示(嚴格)小於;
gt 是 greater than 的縮寫,表示(嚴格)大於;
le 是 less than or equal 的縮寫,表示小於等於(本代碼沒有用到);
ge 是 greater than or equal 的縮寫,表示大於等於(本代碼沒有用到)。
JavaJavaJava
import java.util.Random;
public class Solution {
// 快速排序 1:基本快速排序
/**
* 列表大小等於或小於該大小,將優先於 quickSort 使用插入排序
*/
private static final int INSERTION_SORT_THRESHOLD = 7;
private static final Random RANDOM = new Random();
public int[] sortArray(int[] nums) {
int len = nums.length;
quickSort(nums, 0, len - 1);
return nums;
}
private void quickSort(int[] nums, int left, int right) {
// 小區間使用插入排序
if (right - left <= INSERTION_SORT_THRESHOLD) {
insertionSort(nums, left, right);
return;
}
int pIndex = partition(nums, left, right);
quickSort(nums, left, pIndex - 1);
quickSort(nums, pIndex + 1, right);
}
/**
* 對數組 nums 的子區間 [left, right] 使用插入排序
*
* @param nums 給定數組
* @param left 左邊界,能取到
* @param right 右邊界,能取到
*/
private void insertionSort(int[] nums, int left, int right) {
for (int i = left + 1; i <= right; i++) {
int temp = nums[i];
int j = i;
while (j > left && nums[j - 1] > temp) {
nums[j] = nums[j - 1];
j--;
}
nums[j] = temp;
}
}
private int partition(int[] nums, int left, int right) {
int randomIndex = RANDOM.nextInt(right - left + 1) + left;
swap(nums, left, randomIndex);
// 基準值
int pivot = nums[left];
int lt = left;
// 循環不變量:
// all in [left + 1, lt] < pivot
// all in [lt + 1, i) >= pivot
for (int i = left + 1; i <= right; i++) {
if (nums[i] < pivot) {
lt++;
swap(nums, i, lt);
}
}
swap(nums, left, lt);
return lt;
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}
複雜度分析:
時間複雜度:O(N \log N)O(NlogN),這裏 NN 是數組的長度;
空間複雜度:O(\log N)O(logN),這裏佔用的空間主要來自遞歸函數的棧空間。
5、堆排序(堆很重要,堆排序根據個人情況掌握)
堆講的最好的資料就是《算法 4》,堆的內容比較多,我在這裏就不多展開了,建議大家直接看書獲得相關知識。
堆排序是選擇排序的優化,選擇排序需要在未排定的部分裏通過「打擂臺」的方式選出最大的元素(複雜度 O(N)O(N)),而「堆排序」就把未排定的部分構建成一個「堆」,這樣就能以 O(\log N)O(logN) 的方式選出最大元素;
堆是一種相當有意思的數據結構,它在很多語言裏也被命名爲「優先隊列」。它是建立在數組上的「樹」結構,類似的數據結構還有「並查集」「線段樹」等。
我個人是這樣看待這些定義的:「優先隊列」是一種特殊的隊列,按照優先級順序出隊,從這一點上說,與「普通隊列」無差別。「優先隊列」可以用數組實現,也可以用有序數組實現,但只要是線性結構,複雜度就會高,因此,「樹」結構就有優勢,「優先隊列」的最好實現就是「堆」。
「堆」還有很多擴展的知識:「索引堆」、「多叉堆」,已經不在我能介紹的範圍了,我個人覺得一般的面試問題也不會涉及。但是基礎的堆的相關知識是有必要掌握的,要知道堆的底層是數組,可能涉及擴容的問題,上浮和下沉操作。
「力扣」上有很多使用「優先隊列」完成的問題,感興趣的朋友不妨做一下。
至於現在筆試考不考「手寫一個堆」,我個人覺得意義不大。如果真的考到了,能寫儘量寫,不能一次寫對就和面試官說明自己對於「堆」所掌握的知識我感覺就可以了。面試的時候,本來精神就比平常緊張。我們都不是「堆」的發明人,瞭解和熟悉「堆」的原理和使用場景,自己學習的時候,手寫過堆,通過了測試用例就可以了。
參考代碼 5:
Java
public class Solution {
public int[] sortArray(int[] nums) {
int len = nums.length;
// 將數組整理成堆
heapify(nums);
// 循環不變量:區間 [0, i] 堆有序
for (int i = len - 1; i >= 1; ) {
// 把堆頂元素(當前最大)交換到數組末尾
swap(nums, 0, i);
// 逐步減少堆有序的部分
i--;
// 下標 0 位置下沉操作,使得區間 [0, i] 堆有序
siftDown(nums, 0, i);
}
return nums;
}
/**
* 將數組整理成堆(堆有序)
*
* @param nums
*/
private void heapify(int[] nums) {
int len = nums.length;
// 只需要從 i = (len - 1) / 2 這個位置開始逐層下移
for (int i = (len - 1) / 2; i >= 0; i--) {
siftDown(nums, i, len - 1);
}
}
/**
* @param nums
* @param k 當前下沉元素的下標
* @param end [0, end] 是 nums 的有效部分
*/
private void siftDown(int[] nums, int k, int end) {
while (2 * k + 1 <= end) {
int j = 2 * k + 1;
if (j + 1 <= end && nums[j + 1] > nums[j]) {
j++;
}
if (nums[j] > nums[k]) {
swap(nums, j, k);
} else {
break;
}
k = j;
}
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}
複雜度分析:
時間複雜度:O(N \log N)O(NlogN),這裏 NN 是數組的長度;
空間複雜度:O(1)O(1)。
6、希爾排序(不建議多花時間瞭解)
希爾排序的參考資料是《算法 4》。
思想來源:插入排序的優化。在插入排序裏,如果靠後的數字較小,它來到前面就得交換多次。「希爾排序」改進了這種做法。帶間隔地使用插入排序,直到最後「間隔」爲 11 的時候,就是標準的「插入排序」,此時數組裏的元素已經「幾乎有序」了;
希爾排序的「間隔序列」其實是一個超參數,這方面有一些研究成果,有興趣的朋友可以瞭解一下,但是如果這是面向筆試面試,就不用瞭解了。
參考代碼 6:
Java
public class Solution {
// 希爾排序
public int[] sortArray(int[] nums) {
int len = nums.length;
int h = 1;
// 使用 Knuth 增量序列
// 找增量的最大值
while (3 * h + 1 < len) {
h = 3 * h + 1;
}
while (h >= 1) {
// insertion sort
for (int i = h; i < len; i++) {
insertionForDelta(nums, h, i);
}
h = h / 3;
}
return nums;
}
/**
* 將 nums[i] 插入到對應分組的正確位置上,其實就是將原來 1 的部分改成 gap
*
* @param nums
* @param gap
* @param i
*/
private void insertionForDelta(int[] nums, int gap, int i) {
int temp = nums[i];
int j = i;
// 注意:這裏 j >= deta 的原因
while (j >= gap && nums[j - gap] > temp) {
nums[j] = nums[j - gap];
j -= gap;
}
nums[j] = temp;
}
}
希爾排序的時間複雜度至今還沒有明確的結論,只有一個範圍,已經不在我能介紹的範圍了。
7、冒泡排序(瞭解)
基本思想:外層循環每一次經過兩兩比較,把每一輪未排定部分最大的元素放到了數組的末尾;
「冒泡排序」有個特點:在遍歷的過程中,提前檢測到數組是有序的,從而結束排序,而不像「選擇排序」那樣,即使輸入數據是有序的,「選擇排序」依然需要「傻乎乎」地走完所有的流程。
參考代碼 7:
以下代碼提交以後會出現超時,超時數據是規模較大的數據,一般情況下說明算法是正確的,但不高效。
Java
public class Solution {
// 冒泡排序:超時
public int[] sortArray(int[] nums) {
int len = nums.length;
for (int i = len - 1; i >= 0; i--) {
// 先默認數組是有序的,只要發生一次交換,就必須進行下一輪比較,
// 如果在內層循環中,都沒有執行一次交換操作,說明此時數組已經是升序數組
boolean sorted = true;
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
sorted = false;
}
}
if (sorted) {
break;
}
}
return nums;
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}
複雜度分析:
時間複雜度:O(N^2)O(N
2
),這裏 NN 是數組的長度;
空間複雜度:O(1)O(1),使用到常數個臨時變量。
3 種「非比較」的排序算法(瞭解,如果是面向筆試,不要花時間去研究)
特別說明:這部分算法不建議花太多去仔細研究它們的細節。如果是面向面試,瞭解思想即可,用到了再學。
直接放棄我個人覺得完全可以。
學習資料是《算法導論》。下面是我根據《算法導論》上介紹的內容整理出來的。
這三種排序的區別與上面的排序的特點是:一個數該放在哪裏,是由這個數本身的大小決定的,它不需要經過比較。也可以認爲是哈希的思想:由數值映射地址。
因此這三種算法一定需要額外的空間才能完成排序任務,時間複雜度可以提升到 O(N)O(N),但適用場景不多,主要是因爲使用這三種排序一定要保證輸入數組的每個元素都在一個合理的範圍內(例如本題)。
這三種算法還有一個特點是:都可以實現成穩定排序,無需穩定化。
我在這裏只是給出了可以通過測評的代碼,沒有具體展開介紹了。具體想知道細節的朋友可以參考《算法導論》。
8、計數排序(瞭解)
「計數排序」是這三種排序算法裏最好理解的,從名字就可以看出。把每個出現的數值都做一個計數,然後根據計數從小到大輸出得到有序數組。
這種做法丟失了穩定性,如果是本題這種基本數據類型的話沒有關係。如果是對象類型,就不能這麼做了。
保持穩定性的做法是:先對計數數組做前綴和,在第 2 步往回賦值的時候,根據原始輸入數組的數據從後向前賦值,前綴和數組保存了每個元素存放的下標信息(這裏沒有說得太細,本來這一點就不重要,也不難理解)。
參考代碼 8:
Java
public class Solution {
// 計數排序
private static final int OFFSET = 50000;
public int[] sortArray(int[] nums) {
int len = nums.length;
// 由於 -50000 <= A[i] <= 50000
// 因此"桶" 的大小爲 50000 - (-50000) = 10_0000
// 並且設置偏移 OFFSET = 50000,目的是讓每一個數都能夠大於等於 0
// 這樣就可以作爲 count 數組的下標,查詢這個數的計數
int size = 10_0000;
// 計數數組
int[] count = new int[size];
// 計算計數數組
for (int num : nums) {
count[num + OFFSET]++;
}
// 把 count 數組變成前綴和數組
for (int i = 1; i < size; i++) {
count[i] += count[i - 1];
}
// 先把原始數組賦值到一個臨時數組裏,然後回寫數據
int[] temp = new int[len];
System.arraycopy(nums, 0, temp, 0, len);
// 爲了保證穩定性,從後向前賦值
for (int i = len - 1; i >= 0; i--) {
int index = count[temp[i] + OFFSET] - 1;
nums[index] = temp[i];
count[temp[i] + OFFSET]--;
}
return nums;
}
}
複雜度分析:(略,這部分內容不太重要,增加學習負擔)
9、基數排序(瞭解)
基本思路:也稱爲基於關鍵字的排序,例如針對數值排序,個位、十位、百位就是關鍵字。針對日期數據的排序:年、月、日、時、分、秒就是關鍵字。
「基數排序」用到了「計數排序」。
參考代碼 9:
Java
public class Solution {
// 基數排序:低位優先
private static final int OFFSET = 50000;
public int[] sortArray(int[] nums) {
int len = nums.length;
// 預處理,讓所有的數都大於等於 0,這樣纔可以使用基數排序
for (int i = 0; i < len; i++) {
nums[i] += OFFSET;
}
// 第 1 步:找出最大的數字
int max = nums[0];
for (int num : nums) {
if (num > max) {
max = num;
}
}
// 第 2 步:計算出最大的數字有幾位,這個數值決定了我們要將整個數組看幾遍
int maxLen = getMaxLen(max);
// 計數排序需要使用的計數數組和臨時數組
int[] count = new int[10];
int[] temp = new int[len];
// 表徵關鍵字的量:除數
// 1 表示按照個位關鍵字排序
// 10 表示按照十位關鍵字排序
// 100 表示按照百位關鍵字排序
// 1000 表示按照千位關鍵字排序
int divisor = 1;
// 有幾位數,外層循環就得執行幾次
for (int i = 0; i < maxLen; i++) {
// 每一步都使用計數排序,保證排序結果是穩定的
// 這一步需要額外空間保存結果集,因此把結果保存在 temp 中
countingSort(nums, temp, divisor, len, count);
// 交換 nums 和 temp 的引用,下一輪還是按照 nums 做計數排序
int[] t = nums;
nums = temp;
temp = t;
// divisor 自增,表示採用低位優先的基數排序
divisor *= 10;
}
int[] res = new int[len];
for (int i = 0; i < len; i++) {
res[i] = nums[i] - OFFSET;
}
return res;
}
private void countingSort(int[] nums, int[] res, int divisor, int len, int[] count) {
// 1、計算計數數組
for (int i = 0; i < len; i++) {
// 計算數位上的數是幾,先取個位,再十位、百位
int remainder = (nums[i] / divisor) % 10;
count[remainder]++;
}
// 2、變成前綴和數組
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 3、從後向前賦值
for (int i = len - 1; i >= 0; i--) {
int remainder = (nums[i] / divisor) % 10;
int index = count[remainder] - 1;
res[index] = nums[i];
count[remainder]--;
}
// 4、count 數組需要設置爲 0 ,以免干擾下一次排序使用
for (int i = 0; i < 10; i++) {
count[i] = 0;
}
}
/**
* 獲取一個整數的最大位數
*
* @param num
* @return
*/
private int getMaxLen(int num) {
int maxLen = 0;
while (num > 0) {
num /= 10;
maxLen++;
}
return maxLen;
}
}
複雜度分析:(略,這部分內容不太重要,增加學習負擔)
10、桶排序(瞭解)
基本思路:一個坑一個蘿蔔,也可以一個坑多個蘿蔔,對每個坑排序,再拿出來,整體就有序。
參考代碼 10:
Java
public class Solution {
// 桶排序
// 1 <= A.length <= 10000
// -50000 <= A[i] <= 50000
// 10_0000
private static final int OFFSET = 50000;
public int[] sortArray(int[] nums) {
int len = nums.length;
// 第 1 步:將數據轉換爲 [0, 10_0000] 區間裏的數
for (int i = 0; i < len; i++) {
nums[i] += OFFSET;
}
// 第 2 步:觀察數據,設置桶的個數
// 步長:步長如果設置成 10 會超出內存限制
int step = 1000;
// 桶的個數
int bucketLen = 10_0000 / step;
int[][] temp = new int[bucketLen + 1][len];
int[] next = new int[bucketLen + 1];
// 第 3 步:分桶
for (int num : nums) {
int bucketIndex = num / step;
temp[bucketIndex][next[bucketIndex]] = num;
next[bucketIndex]++;
}
// 第 4 步:對於每個桶執行插入排序
for (int i = 0; i < bucketLen + 1; i++) {
insertionSort(temp[i], next[i] - 1);
}
// 第 5 步:從桶裏依次取出來
int[] res = new int[len];
int index = 0;
for (int i = 0; i < bucketLen + 1; i++) {
int curLen = next[i];
for (int j = 0; j < curLen; j++) {
res[index] = temp[i][j] - OFFSET;
index++;
}
}
return res;
}
private void insertionSort(int[] arr, int endIndex) {
for (int i = 1; i <= endIndex; i++) {
int temp = arr[i];
int j = i;
while (j > 0 && arr[j - 1] > temp) {
arr[j] = arr[j - 1];
j--;
}
arr[j] = temp;
}
}
}
複雜度分析:(略,這部分內容不太重要,增加學習負擔)