TopK問題詳解


【問題描述】(本文代碼以在面試題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問題,我們的思路演化過程是這樣的:

  1. 全局排序,O(n*logn),直覺做法,不推薦
  2. 局部排序,只排序Topk個元素,O(n*k),只提供一種思路,不推薦
  3. 堆的應用,Topk 個元素也不排序了,O(n*logk),Topk問題的經典做法!
  4. 分治思想,如何利用partition操作找出Topk元素,O(n),Topk問題的經典做法!
  5. 計數排序(或稱桶排序),當元素的值域限定在一定范圍時,也可以使用這種方法,也是O(n)的時間復雜度,但不是通用解法。

同類型題目集:

  1. 面試題40. 最小的k個數

參考:

  1. https://blog.csdn.net/z50L2O08e2u4afToR9A/article/details/82837278
  2. https://leetcode-cn.com/problems/zui-xiao-de-kge-shu-lcof/solution/3chong-jie-fa-miao-sha-topkkuai-pai-dui-er-cha-sou/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM