关于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 问题)的两个解法及优劣比较
方法一:堆
堆的性质是每次可以找出最大或最小的元素。
在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
k−m个数,对右侧数组递归的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
n−1次,而一次划分需要线性的时间复杂度。
空间复杂度:
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
n−1 层,而每层由于需要
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
n−k位置上就可以了。
如果
m
=
=
n
−
k
m == n-k
m==n−k,则第k个最大元素就是基准值。
如果
m
>
n
−
k
m > n-k
m>n−k,则在左侧继续递归找。
如果
m
<
n
−
k
m < n-k
m<n−k,则在右侧继续递归找。
时间复杂度是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. 这一堆数是数据流的形式给你的,而且后续可能还有新的数来加入,这个怎么做?
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)
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)问题:
- 排序 、局部排序
- 堆
- 快速选择(快排的partition)
- BFPRT 算法
注意时间、空间复杂度。
代码的时间复杂度和算法该如何选择
借用acwing中y总的一篇算法选择文章:
一般ACM或者笔试题的时间限制是1秒或2秒。
在这种情况下,C++代码中的操作次数控制在
1
0
7
∼
1
0
8
10^7 \sim 10^8
107∼108 为最佳。
下面给出在不同数据范围下,代码的时间复杂度和算法该如何选择:
n
≤
30
n \le 30
n≤30, 指数级别, dfs+剪枝,状态压缩dp
n
≤
100
n \le 100
n≤100 =>
O
(
n
3
)
O(n^3)
O(n3),floyd,dp,高斯消元
n
≤
1000
n \le 1000
n≤1000 =>
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
n≤10000 =>
O
(
n
∗
n
)
O(n * \sqrt n)
O(n∗n),块状链表、分块、莫队
n
≤
100000
n \le 100000
n≤100000 =>
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
n≤1000000 =>
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
n≤10000000 =>
O
(
n
)
O(n)
O(n),双指针扫描、kmp、AC自动机、线性筛素数
n
≤
1
0
9
n \le 10^9
n≤109 =>
O
(
n
)
O(\sqrt n)
O(n),判断质数
n
≤
1
0
18
n \le 10^{18}
n≤1018 =>
O
(
l
o
g
n
)
O(logn)
O(logn),最大公约数,快速幂
n
≤
1
0
1000
n \le 10^{1000}
n≤101000 =>
O
(
(
l
o
g
n
)
2
)
O((logn)^2)
O((logn)2),高精度加减乘除
n
≤
1
0
100000
n \le 10^{100000}
n≤10100000 =>
O
(
l
o
g
k
×
l
o
g
l
o
g
k
)
,
k
表
示
位
数
O(logk \times loglogk),k表示位数
O(logk×loglogk),k表示位数,高精度加减、FFT/NTT