【問題描述】(本文代碼以在面試題40. 最小的k個數中可提交)
在無序數組 nums[] 中,找出最小(或最大)的 k 個數。例如,輸入[4, 5, 1, 6, 2, 7, 3, 8]這8個數字,則最小的4個數字是1、2、3、4。
思路1:直接排序
直接將數組進行排序,然后取出前 k 個元素即可。這是最容易想到的。
代碼略。
直接排序需要對整個數組 n 個元素都進行排序(全局操作),時間復雜度至少是 O(n*logn)
,而我們只需要找出前 k 個元素即可(只需要局部元素),顯然是小題大做了。我們能不能只進行局部排序,拿到我們想要的 k 個元素就及時停止呢?
思路2:冒泡排序
冒泡排序雖然平時很少用到,但我們知道它是一個全局排序,也就是說,每執行一次,就會有一個元素確定其最終位置。因此,我們可以通過冒泡排序,執行 k 次便可以確定最終結果,時間復雜度是 O(n*k)
。當 k << n
時,O(n*k)
的性能會比O(n*logn)
好很多。
class Solution {
public int[] getLeastnumbers(int[] nums, int k) {
if(nums.length == 0 || k == 0) return new int[0];
for(int i = 0; i < k; i++) {
for(int j = nums.length - 1; j > i; j--) {
if(nums[j] < nums[j-1]) {
int t = nums[j];
nums[j] = nums[j-1];
nums[j-1] = t;
}
}
}
int[] res = new int[k];
for(int i = 0; i < k; i++) {
res[i] = nums[i];
}
return res;
}
}
在面試題40. 最小的k個數中,使用冒泡也是能通過的,只不過效率很低,這是因為題目中並未說明k << n
,而可能k == n
。這里僅作為一種思路。Anyway,就假定k << n
吧,我們已經將全局排序優化成局部排序了,但是!通過冒泡排序我們拿到的 k 個元素仍然是有序的,題目只要求我們取出最小的 k 個元素,並未要求這 k 個數有序,因此,我們還有進一步優化的空間。
思路3:堆結構的應用
對於求最小的 k 個元素,我們建立一個大頂堆,保證堆中的元素不超過 k 個。大頂堆中存放的元素是當前數組中前 k 個小的數。當要往大頂堆中插入元素時,先跟堆頂元素(也就是當前的最大值)進行比較,如果待插入的元素比堆頂元素要小,那么堆頂元素不可能是前 k 個小的數了。於是替換掉堆頂元素,並調整堆,以保證堆內的 k 個元素,總是當前最小的 k 個元素。當遍歷完數組,大頂堆中存留下來的 k 個元素就是所求結果。
時間復雜度為O(n*logk)
,其中O(n)
是因為要變遍歷一趟數組,O(logk)
是每次堆結構調整所需要的時間。
class Solution {
public int[] getLeastnumbers(int[] nums, int k) {
// PriorityQueue<Integer> maxHeap = new PriorityQueue<>(); // 默認為小頂堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((x, y) -> y - x); // 大頂堆
for(int num : nums) {
if(maxHeap.size() < k) {
maxHeap.add(num);
}else if(!maxHeap.isEmpty() && num < maxHeap.peek()) {
maxHeap.poll();
maxHeap.add(num);
}
}
int[] res = new int[maxHeap.size()];
int i = 0;
while(!maxHeap.isEmpty()) {
res[i++] = maxHeap.poll();
}
return res;
}
}
思路4:隨機選擇
隨機選擇算在是《算法導論》中一個經典的算法,其時間復雜度為O(n),是一個線性復雜度的方法。為了說明隨機選擇算法,需要先了解快速排序算法。
快速排序算法的偽代碼實現如下:
void QuickSort(int[] nums, int left, int right) {
if (left >= right) return;
// 選取主元
int pivot = selectPivot(nums, left, right);
// 根據主元進行划分
int i = partition(nums, left, right, pivot);
// 遞歸處理左右子集
QuickSort(nums, left, i-1);
QuickSort(nums, i+1, right);
}
其核心思想是分治法。
【擴展】
分治法(Divide&Conquer):把一個大的問題,轉化為若干個子問題(Divide),每個子問題「都」解決,大的問題便隨之解決(Conquer)。這里的關鍵詞是「都」。從偽代碼里可以看到,快速排序遞歸時,先通過partition把數組分隔為兩個部分,兩個部分「都」要再次遞歸。
分治法有一個特例,叫減治法。
減治法(Reduce&Conquer):把一個大的問題,轉化為若干個子問題(Reduce),這些子問題中「只」解決一個,大的問題便隨之解決(Conquer)。這里的關鍵詞是「只」。
二分查找(Binary Search)就是一個典型的運用減治法的一種算法。其偽代碼如下:
int binarySearch(int[] nums, int target, int left, int right) {
if(left > right) return -1;
int mid = (left + right) / 2;
if(nums[mid] > target) {
return binarySearch(nums, target, left, mid-1);
}else if(nums[mid] < target) {
return binarySearch(nums, target, mid+1, right);
}else {
return mid;
}
}
可以看到,每次查詢時,通過mid把原數組分為左右兩個子分區,根據和target的比較,只需要進入其中一個分區就可解決問題。這是和快速排序的最大不同。
通過上述說明,我們可以知道,減治法一般要比分治法的復雜度更低。
-
分治法:O(n*logn)
-
減治法:O(logn)
回到本題,解決Topk問題可以從排序算法中借鑒什么思想呢?排序算法的核心是划分操作,即partition。它的作用是根據主元pivot調整數組,把小於pivot的元素移到左側,把大於pivot的元素移到右側,從而確定pivot的最終位置。假設pivot的位置為k,也就是說,可以確定pivot是該數組第k小的元素(這里先假設下標從1開始)——這不就是Topk問題要解決的問題嗎?
我們設法找到數組中第k大的元素,那么在該元素之前的所有元素,就是我們要求的Topk了。
代碼實現如下:
class Solution {
public int[] getLeastnumbers(int[] nums, int k) {
if(nums.length == 0 || k == 0) return new int[0];
return quickSort(nums, 0, nums.length-1, k);
}
public int[] quickSort(int[] nums, int left, int right, int k) {
int pivotIndex = partition(nums, left, right);
if(pivotIndex == k - 1) {
return Arrays.copyOfRange(nums, 0, k);
}else if(pivotIndex > k - 1) {
return quickSort(nums, left, pivotIndex - 1, k);
}else {
return quickSort(nums, pivotIndex + 1, right, k);
}
}
// 划分操作,返回主元索引
public int partition(int[] nums, int left, int right) {
if(right - left == 0) return left;
// 以數組的首個元素作為主元
int pivot = nums[left];
int i = left, j = right;
while(i <= j) {
while(i <= j && nums[i] <= pivot) i++;
while(i <= j && nums[j] > pivot) j--;
if(i < j) swap(nums, i, j);
else break;
}
swap(nums, left, j);
return j;
}
public void swap(int[] nums, int i, int j) {
int t = nums[i];
nums[i] = nums[j];
nums[j] = t;
}
}
根據之前的分析,這是一個典型的減治算法,遞歸的兩個分支,每次只會執行其中一個。
時間復雜度分析: 因為我們是要找下標為k的元素,第一次切分的時候需要遍歷整個數組 (0 ~ n) 找到了下標是 j 的元素,假如 k 比 j 小的話,那么我們下次切分只要遍歷數組 (0~k-1)的元素就行了,反之如果 k 比 j 大的話,那下次切分只要遍歷數組 (k+1~n) 的元素,總之可以看作每次調用 partition 遍歷的元素數目都是上一次遍歷的 1/2,因此時間復雜度是 n + n/2 + n/4 + ... + n/n = 2n,因此時間復雜度是 O(n)。
思路5:計數排序
當元素值域限定在一定范圍內時,可以直接使用計數排序。比如,在面試題40. 最小的k個數中,限定了元素的大小在[0, 10000]之間,那么,可以直接使用計數排序(也稱桶排序)來解決。
class Solution {
public int[] getLeastnumbers(int[] nums, int k) {
if(nums.length == 0 || k == 0) return new int[0];
int[] count = new int[10010];
for(int num : nums) {
count[num]++;
}
int[] res = new int[k];
int index = 0;
for(int val = 0; val < count.length; val++) {
while (count[val] > 0 && index < k) {
res[index] = val;
count[val]--;
index++;
}
}
return res;
}
}
使用計數排序的時間復雜度是O(n)
,也是很好的解法,只不過不是處理Topk問題的通用解法,該解法參考了這篇題解。
總結
處理Topk問題,我們的思路演化過程是這樣的:
- 全局排序,O(n*logn),直覺做法,不推薦
- 局部排序,只排序Topk個元素,O(n*k),只提供一種思路,不推薦
- 堆的應用,Topk 個元素也不排序了,O(n*logk),Topk問題的經典做法!
- 分治思想,如何利用partition操作找出Topk元素,O(n),Topk問題的經典做法!
- 計數排序(或稱桶排序),當元素的值域限定在一定范圍時,也可以使用這種方法,也是O(n)的時間復雜度,但不是通用解法。
同類型題目集:
參考: