2

我问的是关于 Top K 算法的问题。我认为 O(n + k log n) 应该更快,因为好吧..例如,如果您尝试插入 k = 300 和 n = 100000000,我们可以看到 O(n + k log n)更小。

但是,当我使用 C++ 进行基准测试时,它显示 O (n log k) 的速度要快 2 倍以上。这是完整的基准测试程序:

#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>
#include <ctime>
#include <cstdlib>
using namespace std;

int RandomNumber () { return rand(); }
vector<int> find_topk(int arr[], int k, int n)
{
   make_heap(arr, arr + n, greater<int>());

   vector<int> result(k);

   for (int i = 0; i < k; ++i)
   {
      result[i] = arr[0];
      pop_heap(arr, arr + n - i, greater<int>());
   }

   return result;
}

vector<int> find_topk2(int arr[], int k, int n)
{
   make_heap(arr, arr + k, less<int>());

   for (int i = k; i < n; ++i)
   {
      if (arr[i] < arr[0])
      {
     pop_heap(arr, arr + k, less<int>());
     arr[k - 1] = arr[i];
     push_heap(arr, arr + k, less<int>());
      }
   }

   vector<int> result(arr, arr + k);

   return result;
}


int main()
{
   const int n = 220000000;
   const int k = 300;

   srand (time(0));
   int* arr = new int[n];

   generate(arr, arr + n, RandomNumber);

   // replace with topk or topk2
   vector<int> result = find_topk2(arr, k, n);

   copy(result.begin(), result.end(), ostream_iterator<int>(cout, "\n"));


   return 0;
}

find_topk 的方法是在 O(n) 中构建一个大小为 n 的完整堆,然后将堆的顶部元素移除 k 次 O(log n)。find_topk2 的做法是构建一个大小为 k (O(k)) 的堆,使得 max 元素在顶部,然后从 k 到 n,比较看是否有任何元素小于顶部元素,如果是则 pop顶部元素,并推送新元素,这意味着 n 次 O(log k)。两种方法的编写方式都非常相似,因此我不相信任何实现细节(如创建临时对象等)会导致除了算法和数据集(随机)之外的差异。

我实际上可以分析基准测试的结果,并且可以看到 find_topk 实际上调用比较运算符的次数比 find_topk2 多得多。但我对理论复杂性的推理更感兴趣......所以两个问题。

  1. 忽略实现或基准,我期望 O(n + k log n) 应该比 O(n log k) 更好是错误的吗?如果我错了,请解释为什么以及如何推理以使我可以看到 O(n log k) 实际上更好。
  2. 如果我期望没有 1 没有错。那么为什么我的基准测试显示不是这样呢?
4

3 回答 3

4

多个变量中的大 O 很复杂,因为您需要假设变量如何相互缩放,因此您可以明确地将极限设为无穷大。

如果例如。k ~ n^(1/2),则 O(n log k) 变为 O(n log n) 并且 O(n + k log n) 变为 O(n + n^(1/2) log n) = O (n),哪个更好。

如果k ~ log n,那么O(n log k) = O(n log log n)和O(n + k log n) = O(n),这样更好。请注意,log log 2^1024 = 10,因此对于任何实际 n,O(n) 中隐藏的常数可能大于 log log n。

如果 k = 常数,则 O(n log k) = O(n) 和 O(n + k log n) = O(n),这是相同的。

但是常量起着很大的作用:例如,构建一个堆可能涉及读取数组 3 次,而构建一个长度为 k 的优先级队列只需要一次通过数组,并且一个小的常量乘以 log k 用于抬头。

因此,尚不清楚哪个“更好”,尽管我的快速分析倾向于表明 O(n + k log n) 在对 k 的温和假设下表现更好。

例如,如果 k 是一个非常小的常数(例如 k = 3),那么我可以打赌,该make_heap方法在现实世界数据上的性能比优先级队列一差。

明智地使用渐近分析,最重要的是,在得出结论之前分析您的代码。

于 2011-07-12T19:45:28.433 回答
1

您正在比较两个最坏情况的上限。对于第一种方法,最坏情况几乎等于平均情况。对于第二种情况,如果输入是随机的,当您将多个项目传递到堆中时,立即丢弃新值的机会是因为它不会替换任何前 K相当高,因此对此的最坏情况估计是悲观的。

如果您正在比较挂钟时间而不是比较,您可能会发现基于堆的算法往往不会赢得很多比赛,因为它们具有可怕的存储位置 - 现代微处理器上的常数因素在很大程度上受到您结束的内存级别的影响开始工作 - 发现你的数据在真正的内存芯片中(或者更糟的是,在磁盘上),而不是某种级别的缓存会减慢你的速度 - 这是一种耻辱,因为我真的很喜欢堆排序。

于 2011-07-12T19:50:08.993 回答
0

请记住,您现在可以使用 std::nth_element 而不必使用堆并自己做事。由于默认的比较器运算符是 std::less<>(),你可以这样说:

std::nth_element(myList.begin(), myList.begin() + k, myList.end());

现在,从位置 0 到 k 的 myList 将是最小的 k 个元素。

于 2015-10-09T02:17:36.373 回答