相似题目:

  1. 【常考排序算法】堆排序.
  2. 【LeetCode】347. 前 K 个高频元素 (全手写).
  3. 【LeetCode】215. 数组中的第K个最大元素.

1.1 直接 sort()函数

但是面试这样写肯定不行。

补充 sort 与 sorted 区别:

  1. sort 是应用在 list 上的方法,sorted 可以对所有可迭代的对象进行排序操作。
  2. list 的 sort 方法返回的是对已经存在的列表进行操作,无返回值,而内建函数 sorted 方法返回的是一个新的 list,而不是在原来的基础上进行的操作。
class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        arr.sort()
        return arr[:k]

1.2 快速排序(排序完再取前 k 个) (时间: O ( N l o g N ) O (NlogN) O(NlogN))

直接对数组排序,排序后前k个数就是答案,排序一般较快的是 O ( N l o g N ) O(NlogN) O(NlogN),显然这并不是时间复杂度最优解。

1.3 最大堆(排序完再取前 k 个) (时间: O ( N l o g N ) O (NlogN) O(NlogN))

class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        n = len(arr)
        self.buildHeap(arr, n)
        for i in range(n-1, -1, -1):
            self.swap(arr, i, 0)
            self.heapify(arr, i, 0)
        return arr[:k]
    
    def swap(self, arr, i, j):
        arr[i], arr[j] = arr[j], arr[i]
    def buildHeap(self, arr, n):
        for i in range((n-2)//2, -1, -1):
            self.heapify(arr, n, i)
    def heapify(self, arr, n, i):
        c1 = 2 * i + 1
        c2 = 2 * i + 2
        max = i
        if c1 < n and arr[c1] > arr[max]:
            max = c1
        if c2 < n  and arr[c2] > arr[max]:
            max = c2
        if max != i:
            self.swap(arr, max, i)
            self.heapify(arr, n, max)

1.4 最大堆 (时间: O ( N l o g k ) O (Nlogk) O(Nlogk); 空间: O ( N ) O(N) O(N) )

【求最大 top k 问题用最小堆,求最小 top k 问题用最大堆】

思路:用一个大顶堆实时维护数组的前 k k k 小值。

  1. 首先 build 一个长度为 k k k 的大顶堆
  2. 随后从第 k + 1 k+1 k+1 个数开始遍历,如果当前遍历到的数比大顶堆的堆顶的数要小,就把堆顶的数弹出,再插入当前遍历到的数。
  3. 最后将大根堆里的数存入数组返回即可。(由于 C++ 语言中的堆(即优先队列)为大根堆,我们可以直接操作)。而 Python 语言中库中自带的为小顶堆,因此我们要对数组中所有的数取其相反数,才能使用小顶堆维护前 k 小值。

优点: 适合海量数据求k个最小。因为k个数的堆,空间是固定的,当数组超级大,那么全存入内存都变得不可行的时候,就需要从外存中慢慢读取数字,然后和这个堆进行比较。

而先排序再取前 k 个数的方法就必须吧整个数组放入内存中,才能运行,所以不适合海量数据。

法一:利用函数库 (推荐!!!)

import heapq
class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        if k == 0:
            return list()

        hp = [-x for x in arr[:k]]
        heapq.heapify(hp)
        for i in range(k, len(arr)):
            if -hp[0] > arr[i]:
                heapq.heappop(hp)
                heapq.heappush(hp, -arr[i])
        ans = [-x for x in hp]
        return ans

法二:手写大顶堆

class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        if k==0 or len(arr) == 0:
            return []
        n = len(arr)
        nums = arr[:k]          # 需要额外的 O(k) 的空间
        self.buildHeap(nums, k) # 1. 建立长度为 k 的大顶堆
        
        for i in range(k, n):   # 2. 从 k+1 开始遍历,如果小于堆顶元素,则插入堆顶
            if nums[0] > arr[i]:
                nums[0] = arr[i] # 插入堆顶
                self.heapify(nums,k, 0) # 插完以后,恢复大顶堆结构
        return nums              # 3. 返回这个长度为 k 的最大堆
    
    def swap(self, arr, i, j):
        arr[i], arr[j] = arr[j], arr[i]

    def buildHeap(self, arr, k):
        for i in range((k-2) // 2, -1, -1):
            self.heapify(arr, k, i)
    
    def heapify(self, arr, k, i):
        c1 = 2 * i + 1
        c2 = 2 * i + 2
        max = i
        if c1 < k and arr[c1] > arr[max]:
            max = c1
        if c2 < k and arr[c2] > arr[max]:
            max = c2
        if max != i:
            self.swap(arr, max, i)
            self.heapify(arr, k, max)

如果不做

nums = arr[:k] 

这一步操作的话,而是直接将 arr[:k] 带入 buildHeap()函数中,那么buildHeap()需要返回值,否则,arr[:k] 并没有被改变,具体看以下代码

法三:

class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        if k==0 or len(arr) == 0:
            return []
        n = len(arr)
        # nums = arr[:k] # 需要额外的 O(k) 的空间
        # self.buildHeap(nums, k) # 1. 建立长度为 k 的大顶堆
        arr[:k] = self.buildHeap(arr[:k], k)
        for i in range(k, n):   # 2. 从 k+1 开始遍历,如果小于堆顶元素,则插入堆顶
            if arr[0] > arr[i]:
                arr[0] = arr[i] # 插入堆顶
                self.heapify(arr,k, 0) # 插完以后,恢复大顶堆结构
        return arr[:k]            # 3. 返回这个长度为 k 的最大堆
    
    def swap(self, arr, i, j):
        arr[i], arr[j] = arr[j], arr[i]

    def buildHeap(self, arr, k):
        for i in range((k-2) // 2, -1, -1):
            self.heapify(arr, k, i)
        return arr
    
    def heapify(self, arr, k, i):
        c1 = 2 * i + 1
        c2 = 2 * i + 2
        max = i
        if c1 < k and arr[c1] > arr[max]:
            max = c1
        if c2 < k and arr[c2] > arr[max]:
            max = c2
        if max != i:
            self.swap(arr, max, i)
            self.heapify(arr, k, max)

上述提到的问题,下面用简单代码说明:

def fun_1(arr):
    arr[0], arr[1] = arr[1], arr[0]

def fun_2(arr):
    arr[0], arr[1] = arr[1], arr[0]
    return arr

a = [1,2,3,4]
fun_1(a[:2])    # a[:2] 没有被真正操作,是生成了一个副本进行操作
print('a:',a)

b = [5,6,7,8]
b[:2] = fun_2(b[:2]) # 函数返回值,再赋给 b[:2]
print('b:',b)

输出:

a: [1, 2, 3, 4]
b: [6, 5, 7, 8]

法四:嵌套函数(推荐)
采用嵌套函数,可以免去考虑上面提到的 arr[:k] 传参过程中的问题


class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        def heapify(k, i):
            c1 = 2 * i + 1
            c2 = 2 * i + 2
            max = i
            if c1 < k and arr[c1] > arr[max]:
                max = c1
            if c2 < k and arr[c2] > arr[max]:
                max = c2
            if max != i:
                arr[i], arr[max] = arr[max], arr[i]
                heapify(k, max)

        if k==0 or len(arr) == 0:
            return []
        n = len(arr)
        
        # 建立最大堆
        for i in range((k-2) // 2, -1, -1):
            heapify(k, i)

        for i in range(k, n):   # 2. 从 k+1 开始遍历,如果小于堆顶元素,则插入堆顶
            if arr[0] > arr[i]:
                arr[0] = arr[i] # 插入堆顶
                heapify(k, 0) # 插完以后,恢复大顶堆结构
        return arr[:k]            # 3. 返回这个长度为 k 的最大堆

复杂度:

  1. 时间复杂度 O ( k l o g k + ( N − k ) l o g k ) = O ( N l o g k ) O(k logk + (N-k) logk) = O (Nlogk) O(klogk+(Nk)logk)=O(Nlogk)

(如果是排序后再选的话,复杂度最低达到 O ( N l o g N ) O(NlogN) O(NlogN),当 k < < N k<<N k<<N 的时候,用大顶堆算法的优势就出来了)

1.5 类似快速排序的算法 (期望时间: O ( N ) O(N) O(N),最坏 O ( N 2 ) O(N^2) O(N2)

class Solution:
    def partition(self, nums, l, r):
        pivot = nums[r]
        i = l - 1
        for j in range(l, r):
            if nums[j] <= pivot:
                i += 1
                nums[i], nums[j] = nums[j], nums[i]
        nums[i + 1], nums[r] = nums[r], nums[i + 1]
        return i + 1

    def randomized_partition(self, nums, l, r):
        i = random.randint(l, r)
        nums[r], nums[i] = nums[i], nums[r]
        return self.partition(nums, l, r)

    def randomized_selected(self, arr, l, r, k):
        pos = self.randomized_partition(arr, l, r)
        num = pos - l + 1
        if k < num:
            self.randomized_selected(arr, l, pos - 1, k)
        elif k > num:
            self.randomized_selected(arr, pos + 1, r, k - num)

    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        if k == 0:
            return list()
        self.randomized_selected(arr, 0, len(arr) - 1, k)
        return arr[:k]

复杂度分析

  1. 时间复杂度期望为 O(n) ,由于证明过程很繁琐,所以不再这里展开讲。具体证明可以参考《算法导论》第 9 章第 2 小节。
    最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。情况最差时,每次的划分点都是最大值或最小值,一共需要划分 n−1 次,而一次划分需要线性的时间复杂度,所以最坏情况下时间复杂度为 O ( n 2 ) O(n^2) O(n2)

  2. 空间复杂度:O(logn),递归调用的期望深度为 O(logn),每层需要的空间为 O(1),只有常数个变量。
    最坏情况下的空间复杂度为 O(n)。最坏情况下需要划分 n 次,即 randomized_selected 函数递归调用最深 n−1 层,而每层由于需要 O(1) 的空间,所以一共需要 O(n) 的空间复杂度。

参考: LeetCode官方题解

大顶堆和这种方法的优劣性比较

在面试中,另一个常常问的问题就是这两种方法有何优劣。看起来分治法的快速选择算法的时间、空间复杂度都优于使用堆的方法,但是要注意到快速选择算法的几点局限性:

第一,算法需要修改原数组,如果原数组不能修改的话,还需要拷贝一份数组,空间复杂度就上去了。

第二,算法需要保存所有的数据。如果把数据看成输入流的话,使用堆的方法是来一个处理一个,不需要保存数据,只需要保存 k 个元素的最大堆。而快速选择的方法需要先保存下来所有的数据,再运行算法。当数据量非常大的时候,甚至内存都放不下的时候,就麻烦了。所以当数据量大的时候还是用基于堆的方法比较好。

参考:

  1. 【LeetCode】215. 数组中的第K个最大元素.
  2. 【常考排序算法】快速排序.
  3. 【常考排序算法】堆排序.