【问题标题】:How is nth_element Implemented?nth_element 是如何实现的?
【发布时间】:2015-05-22 14:56:00
【问题描述】:

StackOverflow 和其他地方有很多声称 nth_elementO(n) 并且通常使用 Introselect 实现:http://en.cppreference.com/w/cpp/algorithm/nth_element

我想知道如何实现这一点。我看着Wikipedia's explanation of Introselect,这让我更加困惑。算法如何在 QSort 和 Median-of-Medians 之间切换?

我在这里找到了 Introsort 论文:http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.5196&rep=rep1&type=pdf 但上面写着:

在本文中,我们专注于排序问题,并在后面的部分中仅简要介绍选择问题。

我试图通读 STL 本身以了解 nth_element 是如何实现的,但这很快就会变得很麻烦。

有人可以向我展示如何实现 Introselect 的伪代码吗?甚至更好,当然是 STL 以外的实际 C++ 代码:)

【问题讨论】:

  • 您是否注意到,根据 cppreference,在平均情况下,算法应该只是 O(n)。它没有说明最坏的情况。这意味着快速选择将是可行的,因为它平均有 O(n)
  • @Nobody cppreference 还指出可以使用除 Introselect 之外的其他东西,这可能不是最坏的情况 O(n)。 Wikipedia 和 Introselect 论文声称 Introselect 在 worst 情况下是 O(n)
  • 正如你所说的 introselect 是快速选择和中位数的组合。快速选择在平均和最佳情况下非常快,但具有二次最坏情况,而中位数的中位数保证是一种较慢的始终线性时间算法。 Introselect 尝试通过 quickselect 来提高速度,如果不能,那么它会退回到较慢但有保证的线性时间算法,从而在它变得比线性更差之前限制其最坏情况的运行时间。也许您应该稍微澄清一下您的问题,因为您的问题似乎有些混乱:STL、Introsort、复杂性?
  • @Nobody 重读我的问题,我明白你在说什么。我有点列出了我不明白的事情的清单。我真正想知道的是 Introsort 是如何工作的。不过,我现在犹豫要不要编辑我的问题,因为它会对以前的答案产生影响。希望仅仅说您的回答解决了我的问题就足够了。但是,如果在阅读完本文后您仍然觉得问题需要编辑,那么您修改它是没有问题的。
  • 哦,只是你应该使用另一个字母来表示列表的长度,因为在 nth 元素中已经有一个 n 了。我想每个人都明白你的意思,但最好是准确的,例如。 “列表长度为 m 的第 n 个元素”。文档说算法是 O(m) 这与 O(n) 不同

标签: c++ algorithm stl selection nth-element


【解决方案1】:

你问了两个问题,名义上的一个

nth_element是如何实现的?

你已经回答了:

在 StackOverflow 和其他地方有很多声称 nth_element 是 O(n) 并且通常使用 Introselect 实现。

我也可以通过查看我的 stdlib 实现来确认这一点。 (稍后会详细介绍。)

还有一个你不明白的答案:

算法如何在 QSort 和 Median-of-Medians 之间切换?

让我们看看我从 stdlib 中提取的伪代码:

nth_element(first, nth, last)
{ 
  if (first == last || nth == last)
    return;

  introselect(first, nth, last, log2(last - first) * 2);
}

introselect(first, nth, last, depth_limit)
{
  while (last - first > 3)
  {
      if (depth_limit == 0)
      {
          // [NOTE by editor] This should be median-of-medians instead.
          // [NOTE by editor] See Azmisov's comment below
          heap_select(first, nth + 1, last);
          // Place the nth largest element in its final position.
          iter_swap(first, nth);
          return;
      }
      --depth_limit;
      cut = unguarded_partition_pivot(first, last);
      if (cut <= nth)
        first = cut;
      else
        last = cut;
  }
  insertion_sort(first, last);
}

无需详细了解引用的函数 heap_selectunguarded_partition_pivot,我们可以清楚地看到,nth_element 提供了 introselect 2 * log2(size) 细分步骤(在最佳情况下是快速选择所需的两倍)直到 @987654328 @ 启动并永久解决问题。

【讨论】:

  • 请注意,提到的 libstdc++ 实现实际上并不是 introselect,尽管代码如此命名。 Introselect 回退到 O(n) 中位数方法,而此实现回退到基于 O(nlogn) 堆的解决方案。 (有关更多信息,请参阅此错误报告:gcc.gnu.org/bugzilla/show_bug.cgi?id=35968
  • @Azmisov:这有点涉及my old comment。无论如何,感谢您指出这一点。尽管它在代码中写得很清楚,但毫无戒心的读者可能会掩盖heap_selectO(n log n) 的事实
【解决方案2】:

STL(我认为是 3.3 版)的代码是这样的:

template <class _RandomAccessIter, class _Tp>
void __nth_element(_RandomAccessIter __first, _RandomAccessIter __nth,
                   _RandomAccessIter __last, _Tp*) {
  while (__last - __first > 3) {
    _RandomAccessIter __cut =
      __unguarded_partition(__first, __last,
                            _Tp(__median(*__first,
                                         *(__first + (__last - __first)/2),
                                         *(__last - 1))));
    if (__cut <= __nth)
      __first = __cut;
    else 
      __last = __cut;
  }
  __insertion_sort(__first, __last);
}

让我们稍微简化一下:

template <class Iter, class T>
void nth_element(Iter first, Iter nth, Iter last) {
  while (last - first > 3) {
    Iter cut =
      unguarded_partition(first, last,
                          T(median(*first,
                                   *(first + (last - first)/2),
                                   *(last - 1))));
    if (cut <= nth)
      first = cut;
    else 
      last = cut;
  }
  insertion_sort(first, last);
}

我在这里所做的是删除双下划线和 _Uppercase 内容,这只是为了保护代码免受用户可以合法定义为宏的内容的影响。我还删除了最后一个参数,该参数仅用于模板类型推导,并为简洁起见重命名了迭代器类型。

正如您现在应该看到的,它重复划分范围,直到剩余范围内剩余的元素少于四个,然后对其进行简单排序。

现在,为什么是 O(n)?首先,最多三个元素的最终排序是 O(1),因为最多三个元素。现在,剩下的就是重复的分区。分区本身就是 O(n)。但是,在这里,每一步都会将下一步需要触及的元素数量减半,因此您有 O(n) + O(n/2) + O(n/4) + O(n/8) 即如果你总结一下,小于 O(2n)。由于 O(2n) = O(n),因此平均具有线性复杂度。

【讨论】:

  • 请注意,这似乎是一个纯粹的快速排序实现,没有进行算法切换,所以它不是 introselect。
  • 我不明白你想说什么,@Nobody。
  • 你回答了标题但没有回答问题Could someone show me pseudo-code for how Introselect is implemented? Or even better, actual C++ code other than the STL of course :)。除此之外,我认为我并不清楚每个分区的大小减半。我不确定unguarded_partition,但所选的枢轴只是当前范围的第一个、中间和最后一个元素的中值,这只能保证枢轴的每一侧至少有一个元素,因此需要线性多个分区步骤。
  • 关于最后一部分,我刚刚注意到您(和标准)在谈论平均情况,所以这没关系,但最好补充一下最坏的情况可能(并且将会)是这个算法更糟。
  • 我的主要观点(来自第一条评论)是 OP 似乎对 introselect 中的算法切换比nth_element 的实际实现更感兴趣。我也通过从我安装的 stdlib 实现中创建伪代码来作弊,但我试图解决这些问题。
【解决方案3】:

免责声明:我不知道std::nth_element 在任何标准库中是如何实现的。

如果您知道快速排序的工作原理,您可以轻松地对其进行修改以执行该算法所需的操作。快速排序的基本思想是,在每一步中,将数组分成两部分,使得所有小于枢轴的元素都在左子数组中,所有等于或大于枢轴的元素都在右子数组中. (快速排序的修改称为三元快速排序创建第三个子数组,其中所有元素都等于枢轴。然后右子数组只包含严格大于枢轴的条目。)然后快速排序通过递归排序左子和右子继续-数组。

如果您只想将第 n 个元素移动到位,而不是递归到 both 子数组中,您可以在每一步中告诉您是否需要下降到左或右子数组。 (您知道这一点,因为已排序数组中的第 n 个元素具有索引 n,因此它变成了比较索引的问题。)所以——除非你的快速排序遇到最坏的情况退化——你在每一步中将剩余数组的大小大致减半。 (您永远不会再查看另一个子数组。)因此,平均而言,您在每个步骤中处理的数组长度如下:

  1. Θ(N)
  2. Θ(N / 2)
  3. Θ(N / 4)

每一步在它所处理的数组长度上都是线性的。 (你遍历它一次,然后根据它与枢轴的比较来决定每个元素应该进入哪个子数组。)

可以看到,经过 Θ(log(N)) 个步骤,我们最终会到达一个单例数组并完成。如果你总结 N (1 + 1/2 + 1/4 + ...),你会得到 2 N。或者,在平均情况下,因为我们不能希望枢轴总是正好是中位数,所以大约是 Θ(N)。

【讨论】:

  • 我假设您知道 Quicksort 是 O(nlogn)en.wikipedia.org/wiki/Quicksort这希望如何达到比 O(nlogn) 更好的性能?
  • @JonathanMee:排序需要对分区的两侧进行排序,而选择只需要在被选元素所在的一侧进行。因此,排序必须对所有n 元素log n 次起作用,而选择只需要处理缩小的子集(如上面的答案中所述)。
  • @Nobody 有趣,我研究了这个,这实际上是 Introsort 之前的实现。然而,gcc 和 Visual Studio 现在都已经进入了 Introsort。大概是因为一个糟糕的枢轴选择让 Quicksort 很快就被带到了一个糟糕的地方。
  • @Jonathan:大概是因为一个糟糕的枢轴选择让 Quicksort 很快就进入了一个糟糕的地方。不,因为 ISO C++ 2011 标准现在要求 std::sort 平均不是 O(n * log n),但它要求所有发行版的 O(n * log n)。这要求 Introsort() 满足这个附加要求。遗憾的是,std::nth_element 的复杂性也没有收紧(这意味着 QuickSelect 不够好,需要 IntraSelect)。
猜你喜欢
  • 2013-12-07
  • 2012-06-19
  • 2021-11-12
  • 2012-05-08
  • 2020-08-29
  • 1970-01-01
  • 2016-12-09
  • 2011-10-24
  • 2011-04-26
相关资源
最近更新 更多