topk问题的一些整理

1. 针对一堆杂乱无章的数,如何找中位数?

  找中位数是不需要排序的; 找中位数本质就是找 topk 的问题,就按照 topk 问题的思路来解决就好了。
  所以这个问题,直接来求解就是 求这堆数的第K个最大元素,K =? 。

已知nums
n = len(nums)
if n % 2 == 1:
	k = (n + 1) // 2
else:
	# 偶数的情况需要求第k-1和k大的元素,之后求平均值才是中位数
	k = (n + 2) // 2

还有一道被考过的力扣题目:4.两个正序数组的中位数

2. 求最小的k个数(topk 问题)的两个解法及优劣比较

Top K 的两种经典解法(堆/快排变形)与优劣比较

  方法一:堆

    堆的性质是每次可以找出最大或最小的元素。
    在Python3中优先队列用堆(heapq)来表示,是一个小顶堆,(如果要每次找最大的值,可以把元素加负号,输出的值再去掉负号就是最大值)
    如果要求最小的k个数,那么就可以维护一个大小为k的大顶堆。将数组中的元素依次入堆,当堆的大小超过k时,如果每次要放入堆中的值比堆顶元素小,就把堆顶元素弹出,并放入该值。最后堆中剩下的k个元素就是最小的k个数

class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        '''
        利用大小为k的堆
        '''
        if k == 0: return []
        heap = [-x for x in arr[:k]]
        # 也可以在遍历数组的时候,判断索引值小于k的加入堆中,否则判断大小选择是否出入堆
        heapq.heapify(heap)
        # print(heap)  # [-3, -2]
        for i in range(k, len(arr)):
            if -heap[0] > arr[i]:
                heapq.heappop(heap)
                heapq.heappush(heap, -arr[i])
        res = [-y for y in heap]
        return res

if __name__ == '__main__':
    s = Solution()
    arr, k = [3,2,1], 2
    res = s.getLeastNumbers(arr, k)
    print(res)  # [2, 1]

    时间复杂度: O ( n l o g k ) O(nlogk) O(nlogk)。其中n是数组的长度,k是堆的大小。每次入堆出堆的操作都是 O ( l o g k ) O(logk) O(logk),最坏情况下数组中n个数都会插入。
    **空间复杂度: O ( k ) ∗ ∗ O(k)** O(k)

  方法二:快排变形

    首先,快速排序采用的是分治的思想,其中很重要的操作就是partition(划分),从数组中随机选取一个元素作为分区点 pivot,然后原地移动数组中的元素,使得比 pivot 小的元素在 pivot 的左边,比 pivot 大的元素在 pivot 的右边。partition操作需要 O ( n ) O(n) O(n)的时间接下来,快速排序会递归地排序左右两侧的数组
    而快速选择算法,在partition操作之后,只需要递归地选择一侧的数组。快速选择算法想当于一个“不完全”的快速排序,因为我们只需要知道最小的 k 个数是哪些,并不需要知道它们的顺序。【写这道题的时候增加了随机选择一个数的方法,运行速度加快了一点,但是速度还是不理想】
    假设经过一次 partition 操作,枢纽元素位于下标 m,也就是说,左侧的数组有 m 个元素,是原数组中最小的 m 个数。那么有三种情况
    ①如果 k = m k=m k=m ,则最小的k个数就是左侧的数组
    ②如果 m > k m>k m>k ,则最小的k个数一定在左侧的数组中,只需要对左侧的数组递归的partition即可。
    ③如果 m < k m<k m<k ,则左侧的数组都属于最小的k个数,还需要在右侧数组中寻找最小的 k − m k-m km个数,对右侧数组递归的partition即可。
    要注意的是k为0或者是数组长度的时候,要特判。

class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        '''
        快速选择
        需要单独考虑 k为0 和 k超过数组长度的情况
        '''
        if k == 0: return []
        elif len(arr) <= k: return arr
        self.partitionArray(arr, 0, len(arr) - 1, k)
        return arr[:k]

    def partitionArray(self, arr, l, r, k):
        '''
        一次partition后,根据不同的情况递归的选择一侧的数组
        '''
        m = self.randomized_partition(arr, l, r)
        if k == m:
            return
        elif k < m:
            self.partitionArray(arr, l, m - 1, k)
        else:
        	# 这里传入的数都是k,虽然右侧要找的是最小的k-m个数,
        	# 但对整个数组而言,这是最小的第k个数。
        	# 这里的partition是对数组arr中的一部分(l, r)进行划分,索引是针对整个数组的。
            self.partitionArray(arr, m + 1, r, k)

    def randomized_partition(self, nums, l, r):  # nums
        '''
        partition中引入随机选择一个数的功能
        做法是随机选择一个索引,数组中对应的数和最后一个元素进行交换
        '''
        i = random.randint(l, r)
        nums[i], nums[r] = nums[r], nums[i]
        return self.partition(nums, l, r)

    def partition(self, nums, l, r):
        '''
        选择最后一个元素作为分区点
        最后一个元素此时已经是随机选择好的了
        '''
        pivot = nums[r]
        i = l
        # Python中取最后一个元素的好处是:range中的取值不用加一减一了
        for j in range(l, r):  
            if nums[j] < pivot:
            	nums[i], nums[j] = nums[j], nums[i]
                i += 1
        nums[i], nums[r] = nums[r], nums[i]
        return i

if __name__ == '__main__':
    s = Solution()
    arr, k = [3,2,1], 2
    res = s.getLeastNumbers(arr, k)
    print(res)  # [1, 2]

    时间复杂度:期望为 O ( n ) O(n) O(n)最坏情况下,时间复杂度为 O ( n 2 ) O(n^2) O(n2)。最坏的情况是指每次划分点都是最大值或者最小值,一共需要划分 n − 1 n-1 n1次,而一次划分需要线性的时间复杂度。
    空间复杂度 O ( 1 ) O(1) O(1)
    事实上期望为 O ( l o g n ) O(logn) O(logn)。递归调用的期望深度为 O ( l o g n ) O(logn) O(logn),也就是期望partition操作每次能正好把要分区间划分成一半一半的形式,每层需要的空间为 O ( 1 ) O(1) O(1),只有常数个变量。 最坏情况下的空间复杂度为 O ( n ) O(n) O(n)。最坏情况下需要划分 n 次,即 partitionArray 函数递归调用最深 n − 1 n-1 n1 层,而每层由于需要 O ( 1 ) O(1) O(1) 的空间,所以一共需要 O ( n ) O(n) O(n) 的空间复杂度。

  优劣比较
  虽然分治的快速选择算法在时间和空间复杂度上,是优于堆这种方法的,但是有一定的局限性。
    1.快速选择需要原地修改数组,如果原数组不能修改,就需要拷贝一份数组,空间复杂度就上升了。
    2.快速选择算法需要保存所有的数据,当数据量非常大的时候比较麻烦。如果把数据看成输入流,堆方法来一个数据处理一个,只需要保存k个元素就可以了,比较方便。

3. topk问题整理

前端进阶算法10:别再说你不懂topk问题了
经典的 Top K 问题有:最大(小) K 个数、第 K 个最大(小)元素【第k大 == 第n-k小】、前 K 个高频元素
  拿数组中的第K个最大元素举例
  ①全局排序,取从大到小排序的第k个元素就可以了。时间和空间复杂度分别是 O ( n l o g n ) O(nlogn) O(nlogn) O ( l o g n ) O(logn) O(logn)

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        nums.sort()
        length = len(nums)
        return nums[length - k]

  ②局部排序,冒泡。每次将最大的数在右边冒泡出来,只冒泡k次即可。最好时间复杂度是O(n) ,平均时间复杂度是O(n*k) ,空间复杂度是O(1)。
  这里有两个优化策略:一是外层优化,如果在某一趟排序中发现没有进行元素交换,则终止循环,可以用flag来判断;二是内层优化,每次记录最后一次变化的位置,下一次循环遍历到该位置。

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        n = len(nums)
        for i in range(k):
            for j in range(n - i - 1):
                if nums[j] > nums[j + 1]:
                    nums[j], nums[j + 1] = nums[j + 1], nums[j]
        return nums[n - k]
class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        # 外层优化,
        n = len(nums)
        for i in range(k):
            flag = True
            for j in range(n - i - 1):
                if nums[j] > nums[j + 1]:
                    nums[j], nums[j + 1] = nums[j + 1], nums[j]
                    flag = False
            if flag:
                break
        return nums[n - k]
class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        # 内层优化,
        n = len(nums)
        for i in range(k):
            pos = i
            for j in range(n - pos - 1):
                if nums[j] > nums[j + 1]:
                    nums[j], nums[j + 1] = nums[j + 1], nums[j]
                pos = j
        return nums[n - k]

  ③构造前k个最大元素的小顶堆,取堆顶。从数组中取出k个元素构造一个小顶堆,将其余元素与小顶堆对比,如果大于堆顶则替换堆顶,再进行堆化,所有元素遍历完之后,堆顶元素就是第k个最大元素。时间复杂度是O(nlogk)[遍历数组需要O(n)的时间复杂度,一次堆化需要O(logk)], 空间复杂度O(k)。
  堆的优点是,如果所给的是动态数组,不需要重新排序,直接每次比较堆顶元素就可以了。

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        heap = [x for x in nums[:k]]
        heapq.heapify(heap)
        for i in nums[k:]:
            if i > heap[0]:
                heapq.heappop(heap)
                heapq.heappush(heap, i)
        return heap[0]

  ④快速选择,不需要把整个数组都排好序,只需要在分区的时候判断基准位置m是否在 n − k n-k nk位置上就可以了。
  如果 m = = n − k m == n-k m==nk,则第k个最大元素就是基准值。
  如果 m > n − k m > n-k m>nk,则在左侧继续递归找。
  如果 m < n − k m < n-k m<nk,则在右侧继续递归找。
  时间复杂度是O(n),最坏情况下,分区点每次都是选择的最大值或者最小值,则每次分区O(n)—要进行O(n)次分区,为O(n^2); 空间复杂度O(1)。

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        n = len(nums)
        self.partitionArray(nums, 0, n - 1, k)
        return nums[n - k]

    def partitionArray(self, nums, l, r, k):
        n = len(nums)
        m = self.random_partition(nums, l, r)
        if n - k == m: return
        elif n - k < m:
            self.partitionArray(nums, l, m - 1, k)
        else:
            self.partitionArray(nums, m + 1, r, k)

    def random_partition(self, arr, l, r):  # arr
        pivot = random.randint(l, r)
        arr[pivot], arr[r] = arr[r], arr[pivot]
        return self.partition(arr, l, r)

    def partition(self, arr, l, r):
        p = arr[r]
        i = l
        for j in range(l, r):
            if arr[j] < p:
                arr[i], arr[j] = arr[j], arr[i]
                i += 1
        arr[i], arr[r] = arr[r], arr[i]
        return i

  ⑤中位数的中位数算法(BFPRT)。最坏时间复杂度为 O(n) 。思想是修改快速选择算法的主元选取方法,提高算法在最坏情况下的时间复杂度。
  在 BFPTR 算法中,每次选择 五分中位数的中位数 作为基准元(也称为主元pivot),这样做的目的就是使得划分比较合理,从而避免了最坏情况的发生。
选取主元:
将 n 个元素按顺序分为 n/5 个组,每组 5 个元素,若有剩余,舍去
对于这 n/5 个组中的每一组使用插入排序找到它们各自的中位数
对于上一步中找到的所有中位数,调用 BFPRT 算法求出它们的中位数,作为主元;

4. 这一堆数是数据流的形式给你的,而且后续可能还有新的数来加入,这个怎么做?

703. 数据流中的第 K 大元素

class KthLargest:

    def __init__(self, k: int, nums: List[int]):
        self.k = k
        self.heap = nums
        heapq.heapify(self.heap)

    def add(self, val: int) -> int:
        heapq.heappush(self.heap, val)
        while len(self.heap) > self.k:
            heapq.heappop(self.heap)
        return self.heap[0]


# Your KthLargest object will be instantiated and called as such:
# obj = KthLargest(k, nums)
# param_1 = obj.add(val)

295. 数据流的中位数

class MedianFinder:

    def __init__(self):
        """
        initialize your data structure here.
        """
        self.minHeap = []
        self.maxHeap = []

    def addNum(self, num: int) -> None:
        minHeap = self.minHeap
        maxHeap = self.maxHeap
        if len(maxHeap) == len(minHeap):
            heapq.heappush(maxHeap, -num)
            maxs = -heapq.heappop(maxHeap)
            heapq.heappush(minHeap, maxs)
        else:
            heapq.heappush(minHeap, num)
            mins = heapq.heappop(minHeap)
            heapq.heappush(maxHeap, -mins)

    def findMedian(self) -> float:
        minHeap = self.minHeap
        maxHeap = self.maxHeap
        if len(minHeap) == len(maxHeap):
            return (minHeap[0] - maxHeap[0]) * 0.5
        else:
            return minHeap[0]


# Your MedianFinder object will be instantiated and called as such:
# obj = MedianFinder()
# obj.addNum(num)
# param_2 = obj.findMedian()

  C++:数据流中位数[priority_queue, push. pop, top]

class MedianFinder {
public:
    /** initialize your data structure here. */
    priority_queue<int, vector<int>, less<int>> minHeap;
    priority_queue<int, vector<int>, greater<int>> maxHeap;
    MedianFinder() {

    }
    
    void addNum(int num) {
        // 要保证奇数的时候,小顶堆的元素个数 比 大顶堆 多一个
        // 当元素个数相等,再加入元素时,个数会变成奇数,因此要先放入大顶堆里,找到大顶堆中最大的元素放置到‘小顶堆’中
        if (minHeap.size() == maxHeap.size()) {
            maxHeap.push(num);
            minHeap.push(maxHeap.top());
            maxHeap.pop();
        }
        // 若元素个数不等,则加入元素时,个数会变成偶数,因此要先放入小顶堆中,找到小顶堆中最小的元素放置到‘大顶堆’中。
        else {
            minHeap.push(num);
            maxHeap.push(minHeap.top());
            minHeap.pop();
        }
    }
    
    double findMedian() {
        return (minHeap.size() == maxHeap.size()) ? ((minHeap.top() + maxHeap.top()) * 0.5) : minHeap.top();
    }
};

一篇公众号中的topk回答
分治法,大问题分解为小问题,小问题都要递归各个分支,例如:快速排序

减治法,大问题分解为小问题,小问题只要递归一个分支,例如:二分查找,随机选择

关于普通中位数的问题(top-k)问题:

  1. 排序 、局部排序
  2. 快速选择(快排的partition)
  3. BFPRT 算法
    注意时间、空间复杂度。

代码的时间复杂度和算法该如何选择

借用acwing中y总的一篇算法选择文章:

一般ACM或者笔试题的时间限制是1秒或2秒。
在这种情况下,C++代码中的操作次数控制在 1 0 7 ∼ 1 0 8 10^7 \sim 10^8 107108 为最佳。

下面给出在不同数据范围下,代码的时间复杂度和算法该如何选择:

n ≤ 30 n \le 30 n30, 指数级别, dfs+剪枝,状态压缩dp
n ≤ 100 n \le 100 n100 => O ( n 3 ) O(n^3) O(n3),floyd,dp,高斯消元
n ≤ 1000 n \le 1000 n1000 => O ( n 2 ) O(n^2) O(n2) O ( n 2 l o g n ) O(n^2logn) O(n2logn),dp,二分,朴素版Dijkstra、朴素版Prim、Bellman-Ford
n ≤ 10000 n \le 10000 n10000 => O ( n ∗ n ) O(n * \sqrt n) O(nn ),块状链表、分块、莫队
n ≤ 100000 n \le 100000 n100000 => O ( n l o g n ) O(nlogn) O(nlogn) => 各种sort,线段树、树状数组、set/map、heap、拓扑排序、dijkstra+heap、prim+heap、spfa、求凸包、求半平面交、二分、CDQ分治、整体二分
n ≤ 1000000 n \le 1000000 n1000000 => O ( n ) O(n) O(n), 以及常数较小的 O ( n l o g n ) O(nlogn) O(nlogn) 算法 => 单调队列、 hash、双指针扫描、并查集,kmp、AC自动机,常数比较小的 O ( n l o g n ) O(nlogn) O(nlogn) 的做法:sort、树状数组、heap、dijkstra、spfa
n ≤ 10000000 n \le 10000000 n10000000 => O ( n ) O(n) O(n),双指针扫描、kmp、AC自动机、线性筛素数
n ≤ 1 0 9 n \le 10^9 n109 => O ( n ) O(\sqrt n) O(n ),判断质数
n ≤ 1 0 18 n \le 10^{18} n1018 => O ( l o g n ) O(logn) O(logn),最大公约数,快速幂
n ≤ 1 0 1000 n \le 10^{1000} n101000 => O ( ( l o g n ) 2 ) O((logn)^2) O((logn)2),高精度加减乘除
n ≤ 1 0 100000 n \le 10^{100000} n10100000 => O ( l o g k × l o g l o g k ) , k 表 示 位 数 O(logk \times loglogk),k表示位数 O(logk×loglogk)k,高精度加减、FFT/NTT

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值