在 1 亿个数中取出最大的 1000 个数?

起因

上个月的一场面试中,面试官问了我这个问题,当时没能很好的回答出来。后来认真的思考了下这个问题,用此文记录下来。

排序算法

符合第一直觉的回答应该是快速排序,在系统的学过排序算法后,我们都知道快排是一种很快的排序方法。我们先对数据进行排序,然后再取出其中前 1000 个数。不过你要是真的在面试中只那么回答的话,那么基本也无缘后面的面试了。这种思路在输入的数据规模很小时,不失为一种便捷的方法,但是当数据规模扩大时(题目中的 1 亿),其对运行的内存要求也将增大。在快排中,其时间复杂度为 O(n log n),要同时对 1 亿整数排序,需要将其读入内存中,以每个 int 类型占 4 字节为例,1 亿整数则需要占用 400MB 的内存。虽然现代的计算机可用内存远远大于400MB,但当数据规模再次扩大到 10 亿甚至是 100 亿时,其对内存的要求则上升到了 4GB 和 40GB,算法的可用性 (availability) 和扩展性 (scalability) 将受到严重的影响。同时,在这种情况下还需要做全排序的话,就只能借助外部排序位图排序基数排序桶排序 等算法来解决内存不足的问题了。同时,题目中只要求取 top 1000 的数据,对所有元素排序很明显是不必要的。

我写一个简单的演示程序,代码如下:

#include <iostream>  
#include <algorithm>  
#include <random>  
#include <chrono>  
  
using namespace std;  
  
#define MAX INT32_MAX  
#define MIN INT32_MIN  
#define SIZE 100000000 // 100 million  
#define K 1000  
  
int main() {   
    auto start = chrono::high_resolution_clock::now();  

	  // Create a random number generator 
    random_device rd;  
    mt19937 gen(rd());  
    uniform_int_distribution<> dis(MIN, MAX);  
  
    // Create an array of random numbers  
    int *arr = new int[SIZE];  
    for (int i = 0; i < SIZE; i++) {  
        arr[i] = dis(gen);  
    }  
  
    auto end = chrono::high_resolution_clock::now();  
    chrono::duration<double> diff = end - start;  
    cout << "Time taken to create array: " << diff.count() << " s" << endl;  
  

    start = chrono::high_resolution_clock::now();  
	// sorting via std::sort() - optimized quicksort
    sort(arr, arr + SIZE);  
  
    end = chrono::high_resolution_clock::now();  
    diff = end - start;  
    // the arr[0]~arr[k-1] is the top k elements in arr.
    cout << "Time taken to get top K elements: " << diff.count() << " s" << endl;
  
    free(arr);  
    return 0;  
}
# CPU: i7-8700k
# Memory: 16 GB
# OS: Ubuntu 22.10 Kinetic
Time taken to create array: 2.13481 s
Time taken to get top K elements: 22.3849 s

局部淘汰法

为了避免上面的算法中的不必要排序,我们可以选取出待排序数组中的前 k (= 1000) 个元素构建一个最小堆。然后从 k + 1 开始与容器中的最小元素 m 相比,如果大于 m,则替换 m 并重新调整最小堆。

其算法如下:

  1. 选取前K个元素,构建一个最小堆。
  2. 从第 K+1 个元素开始,与堆顶元素比较,如果比堆顶元素大,则替换堆顶元素,并调整堆。
  3. 重复步骤 2,直到数组遍历完毕。
  4. 堆中的元素即为前K个最大元素。

其代码如下:

#include <iostream>  
#include <algorithm>  
#include <random>  
#include <chrono>  
  
using namespace std;  
  
#define MAX INT32_MAX  
#define MIN INT32_MIN  
#define SIZE 100000000 // 100 million  
#define K 1000  
  
int main() {  
    // Create a random number generator  
    auto start = chrono::high_resolution_clock::now();  
  
    random_device rd;  
    mt19937 gen(rd());  
    uniform_int_distribution<> dis(MIN, MAX);  
  
    // Create an array of random numbers  
    int *arr = new int[SIZE];  
    for (int i = 0; i < SIZE; i++) {  
        arr[i] = dis(gen);  
    }  
  
    auto end = chrono::high_resolution_clock::now();  
    chrono::duration<double> diff = end - start;  
    cout << "Time taken to create array: " << diff.count() << " s" << endl;  
  
    // clock start  
    start = chrono::high_resolution_clock::now();  

	// generate a min-heap with first k elements
    int *heap = new int[K];  
    for (int i = 0; i < K; i++) {  
        heap[i] = arr[i];  
    }
    make_heap(heap, heap + K, greater<>());  
    for (int i = K; i < SIZE; i++) {  
        if (arr[i] > heap[0]) {  
            pop_heap(heap, heap + K, greater<>());  
            heap[K - 1] = arr[i];  
            push_heap(heap, heap + K, greater<>());  
        }  
    }  
  
    // clock end  
    end = chrono::high_resolution_clock::now();  
    diff = end - start;  
    cout << "Time taken to get top K elements: " << diff.count() << " s" << endl;  
  
    free(arr);  
    free(heap);  
    return 0;  
}
Time taken to create array: 2.18854 s
Time taken to get top K elements: 0.178133 s

C++ STL set

我们知道,标准库中的 set 底层实现是一棵红黑树(平衡二叉树),在这里我想到了是不是可以借助 set 容器来取 top k 元素呢。如果允许重复元素的话,则使用 multiset 来代替 set.

代码如下:

#include <iostream>  
#include <random>  
#include <chrono>  
#include <set>  
  
using namespace std;  
  
#define MAX INT32_MAX  
#define MIN INT32_MIN  
#define SIZE 100000000 // 100 million  
#define K 1000  
  
int main() {  
    // Create a random number generator  
    auto start = chrono::high_resolution_clock::now();  
  
    random_device rd;  
    mt19937 gen(rd());  
    uniform_int_distribution<> dis(MIN, MAX);  
  
    // Create an array of random numbers  
    int *arr = new int[SIZE];  
    for (int i = 0; i < SIZE; i++) {  
        arr[i] = dis(gen);  
    }  
  
    auto end = chrono::high_resolution_clock::now();  
    chrono::duration<double> diff = end - start;  
    cout << "Time taken to create array: " << diff.count() << " s" << endl;  
  
    // clock start  
    start = chrono::high_resolution_clock::now();  
  
    // Create a set of K the largest numbers  
    set<int> s;  
    for (int i = 0; i < SIZE; i++) {  
        if (s.size() < K) {  
            s.insert(arr[i]);  
        } else {  
            if (arr[i] > *s.begin()) {  
                s.erase(s.begin());  
                s.insert(arr[i]);  
            }  
        }  
    }  
  
    // clock end  
    end = chrono::high_resolution_clock::now();  
    diff = end - start;  
    cout << "Time taken to get top K elements: " << diff.count() << " s" << endl;  
  
    // Print the set in reverse order  
//    for (auto it = s.rbegin(); it != s.rend(); it++) {  
//        cout << *it << endl;  
//    }
  
    free(arr);  
    return 0;  
}
Time taken to create array: 2.16181 s
Time taken to get top K elements: 1.68677 s

总结

通过上面的实验,我们可以看到,使用局部淘汰的思想和最小堆的数据结构来取 top k 元素的效率是最高的,其次是使用 set 容器,最后是使用排序算法。

参考

1 亿个数中找出最大的100个数 (top K问题) - CSDN


最后修改于 2023-02-03


感谢您的支持 :D