排序、查找
912-数组排序(快速排序算法)
归并排序:原始数组 [4, 1, 3, 2]
;先排序左半边、再排序右半边,得到 [1, 4, 2, 3]
;最后合并成 [1, 2, 3, 4]
。
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
N = len(nums)
aux = [0] * N # auxiliary 辅助数组,归并排序需要额外 N 的存储空间
self.sort(nums, aux, 0, N-1)
return nums
def sort(self, nums, aux, lo, hi):
if lo >= hi: return
mid = lo + (hi-lo)//2
self.sort(nums, aux, lo, mid)
self.sort(nums, aux, mid+1, hi)
self.merge(nums, aux, lo, mid, hi)
def merge(self, nums, aux, lo, mid, hi):
# copy to aux
for i in range(lo, hi+1):
aux[i] = nums[i]
# merge back to nums
i = lo
j = mid + 1
for k in range(lo, hi+1):
if i > mid: # 左半边的数取完了,取右半边的
nums[k] = aux[j]
j += 1
elif j > hi: # 右半边的数取完了,取左半边的
nums[k] = aux[i]
i += 1
elif aux[i] <= aux[j]:
nums[k] = aux[i]
i += 1
else:
nums[k] = aux[j]
j += 1
快速排序 :
class Solution {
public:
int partition(vector<int>& nums, int lo, int hi) {
int i = lo + 1;
int j = hi;
while (true) {
while (i <= j && nums[i] <= nums[lo]) ++i;
while (i <= j && nums[j] >= nums[lo]) --j;
if (i >= j) break;
swap(nums[i], nums[j]);
}
swap(nums[lo], nums[j]);
return j;
}
void sort(vector<int>& nums, int lo, int hi) {
if (lo >= hi) return;
int pa = partition(nums, lo, hi);
sort(nums, lo, pa - 1);
sort(nums, pa + 1, hi);
}
vector<int> sortArray(vector<int>& nums) {
sort(nums, 0, nums.size() - 1);
return nums;
}
};
快速排序算法在平均状况下有着不错的表现,但是对于基准值的选择十分敏感,最坏情况下的算法复杂度是 O(n^2)。C++ std sort 的实现选择了首部、中部、尾部三个元素的中值作为 pivot。
现实中应用的排序,往往根据数据集的特征,采用多种排序算法的混合。比如 C++ std sort 的实现被称为 Introspective Sorting(内省式排序),使用快速排序算法、分段排序;当分段的元素个数小于 16 时,采用插入排序,插入排序对“大部分有序”的数据集效率非常好;当递归层次过深、分割行为有恶化倾向时,采用堆排序,堆排序最坏时间复杂度也能保证 O(NlogN)。参考 std::sort 源码剖析。
215-第 k 大的数
找到数组中第 k 大的数,最容易想到的,用 O(NlogN) 排序,再用 O(1) 取第 k-1 个元素。
方案一:快速选择算法
与快速排序一样,都是由计算机科学家托尼·霍尔发明的。
快速排序中,有一个子过程称为分区,可以在线性时间里将一个列表分为两部分,分别是小于基准和大于等于基准的元素。
与快速排序一样,快速选择算法对于基准值的选择非常敏感,可以在切分函数的一开始,随机交换第一个元素与它后面的任意一个元素的位置。
它的时间代价的期望是 O(n),证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。
class Solution {
public:
int partition(vector<int>& nums, int lo, int hi) {
int i = lo + 1;
int j = hi;
while (true) {
while (i <= j && nums[i] <= nums[lo]) ++i;
while (i <= j && nums[j] >= nums[lo]) --j;
if (i >= j) break;
swap(nums[i], nums[j]);
}
swap(nums[lo], nums[j]);
return j;
}
int findKthLargest(vector<int>& nums, int k) {
int N = nums.size();
k = N - k; // to find kth largest, means nums[N-k]
int lo = 0;
int hi = N - 1;
while (lo <= hi) {
int pa = partition(nums, lo, hi);
if (pa == k) {
return nums[k];
} else if (pa < k) {
lo = pa + 1;
} else {
hi = pa - 1;
}
}
return nums[k];
}
};
方案二:优先队列
优先队列天然就是解决 TopK 这种问题的。
考虑从 10 亿个数中找到最大/最小的 100 个数。首先空间上,样本数据如果特别大(例如 10 亿这种级别),并不适合一次性将所有数据读到内存中进行处理;第二时间上,可以将数据集分拆,充分利用多核 CPU 并行处理,提高效率。
以找最大的 100 个数为例(找最大则构建最小堆,找最小则构建最大堆),将样本集分成 1,000,000,000 / 100 = 1,000,000 份,每份找到最大的 100 个数,最终整体的最大 100 个数必定在这中间产生。对每一份,用前 100 个数构建最小堆,再遍历剩余的数,如果小于堆顶则直接跳过;如果大于堆顶则将它放到堆里,同时调整堆。这样遍历完之后,这个最小堆就是这一份样本中最大的 100 个数。
- 当父节点的键值总是大于或等 于任何一个子节点的键值时为最大堆。
- 当父节点的键值总是小于或等于任何一个子节点的键值时为最小堆。
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> topK;
for (int i : nums) {
if (topK.size() < k) {
topK.push(i);
} else if (i > topK.top()) {
topK.push(i);
topK.pop();
}
}
return topK.top();
}
};
347-Top K 高频元素
给定一个数组,返回其中出现频率前 k 高的元素。
优先队列天然就是解决 TopK 这种问题的。
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> um;
for (int i = 0; i < nums.size(); ++i) {
um[nums[i]] += 1;
}
auto cmp = [&um](int lhs, int rhs) { return um[lhs] > um[rhs]; };
priority_queue<int, vector<int>, decltype(cmp)> topK(cmp);
for (const auto &kv : um) {
if (topK.size() < k) {
topK.push(kv.first);
} else if (kv.second > um[topK.top()]) {
topK.pop();
topK.push(kv.first);
}
}
vector<int> res;
while (!topK.empty()) {
res.push_back(topK.top());
topK.pop();
}
return res;
}
};