【问题标题】:Quicksort - which sub-part should be sorted first?快速排序 - 应该首先对哪个子部分进行排序?
【发布时间】:2012-10-17 15:39:11
【问题描述】:

我正在阅读一些关于两个递归快速排序调用的排序的文本:

...首先调用较小的子问题很重要,这与尾递归一起确保堆栈深度为log n。

我完全不确定这意味着什么,我为什么要先在较小的子数组上调用 Quicksort?

【问题讨论】:

  • "为什么我要先在较小的子数组上调用 Quicksort?" - 因为“结合尾递归确保堆栈深度为 log n”
  • 你从哪里读到这篇文章的?

标签: algorithm sorting quicksort


【解决方案1】:

将快速排序视为隐式二叉树。枢轴是根,左右子树是你创建的分区。

现在考虑对这棵树进行深度优先搜索。递归调用实际上对应于对上述隐式树进行深度优先搜索。还假设树总是有较小的子树作为左孩子,所以建议实际上是对这棵树进行预排序。

现在假设您使用堆栈实现预排序,您只推送左子项(但将父项保留在堆栈中),以及何时推送右子项(假设您保持一个状态,您知道是否有节点是否已探索其左子节点),您替换堆栈顶部,而不是推动右子节点(这对应于尾递归部分)。

最大堆栈深度是最大“左深度”:即,如果您将前往左孩子的每条边标记为 1,将前往右孩子的每条边标记为 0,那么您正在查看具有最大边总和的路径(基本上你不计算右边缘)。

现在由于左子树的元素不超过一半,因此每次向左(即遍历和标记为 1 的边)时,您将至少要探索的节点数减少一半。

因此,您看到的标记为 1 的最大边数不超过 log n。

因此堆栈使用量不超过 log n,如果您总是选择较小的分区,并使用尾递归。

【讨论】:

  • 问题为什么被关闭了?
  • 因为可悲的是,如今 SO 上的许多人都喜欢结束完全合理、有趣且具有高质量答案的问题。 FWIW,这是一个很好的答案——不要气馁。
  • @j_random_hacker:嘿,谢谢!不用担心,我曾经是这里的常客,但我的帐户被删除了(大量时间浪费!),但有时会在各种非注册帐户下做出贡献(害怕注册:-))。我实际上对这种恋物癖的关闭免疫。只想知道 SO 的范围是否发生了巨大变化......
  • @j_random_hacker 确实如此。你为什么会想,当一个不称职的人不在和同事喝咖啡的时候,你可以简单地通过做他的工作来获得积分。
【解决方案2】:

有些语言有尾递归。这意味着如果你写 f(x) { ... ... .. ... .. g(x)} 那么最终的调用,对 g(x) 的调用,根本就不是用函数调用来实现的,但是有一个跳转,所以最后的调用不使用任何堆栈空间。

快速排序将要排序的数据分成两部分。如果您总是先处理较短的部分,那么每个消耗堆栈空间的调用都有一个要排序的数据部分,该部分最多是调用它的递归调用大小的一半。因此,如果您从 10 个元素开始排序,则堆栈最深处将调用排序这 10 个元素,然后调用排序最多 5 个元素,然后调用排序最多 2 个元素,然后调用排序最多 1 个元素 - 然后,对于 10 个元素,堆栈不能再深 - 堆栈大小受数据大小日志的限制。

如果您不担心这一点,您最终可能会在堆栈中保存一个对 1​​0 个元素进行排序的调用,然后是对 9 个元素进行排序的调用,然后是对 8 个元素进行排序的调用,以此类推,这样堆栈与要排序的元素的数量一样深。但是,如果您首先对短部分进行排序,尾递归就不会发生这种情况,因为虽然您可以将 10 个元素拆分为 1 个元素和 9 个元素,但调用排序 9 个元素是最后完成的,并作为跳转实现,这不会t 使用更多的堆栈空间 - 它重用其调用者之前使用的堆栈空间,无论如何它都即将返回。

【讨论】:

  • 什么是“跳跃”?你是说goto吗?
【解决方案3】:

理想情况下,列表分为两个大小大致相似的子列表。您首先处理哪个子列表并不重要。

但是,如果在糟糕的日子里,列表以最不平衡的方式划分,一个包含两个或三个项目的子列表,可能是四个,并且一个子列表几乎和原来的一样长。这可能是由于分区值的错误选择或恶意设计的数据造成的。想象一下,如果你先处理更大的子列表会发生什么。快速排序的第一次调用是在其堆栈帧中保存短列表的指针/索引,同时递归地为长列表调用快速排序。这也严重划分为一个很短的列表和一个很长的列表,我们先做较长的子列表,重复......

最终,在最糟糕的糟糕日子里,我们将建立与原始列表长度成正比的堆栈帧。这是快速排序最坏的情况,递归调用的 O(n) 深度。 (注意我们说的是快速排序的递归深度,而不是性能。)

首先做较短的子列表可以很快地摆脱它。我们仍然处理大量的小列表,与原始列表长度成比例,但现在每个列表都由浅的一两个递归调用处理。我们仍然进行 O(n) 次调用(性能),但每次调用的深度都是 O(1)。

【讨论】:

  • 极度不平衡的分区也是快速排序最差的表现。
【解决方案4】:

令人惊讶的是,即使快速排序没有遇到严重不平衡的分区,甚至当实际使用 introsort 时,这也很重要。

当正在排序的容器中的值非常大时,就会出现问题(在 C++ 中)。我的意思并不是说它们指向非常大的物体,而是它们本身非常大。在这种情况下,一些(可能很多)编译器也会使递归堆栈帧变得非常大,因为它至少需要一个临时值才能进行交换。 Swap 在分区内部调用,它本身不是递归的,因此您会认为快速排序递归驱动程序不需要怪物堆栈帧;不幸的是,partition 通常最终会被内联,因为它又好又短,并且不会从其他任何地方调用。

通常,20 和 40 个堆栈帧之间的差异可以忽略不计,但如果值的重量为 8kb,那么 20 和 40 个堆栈帧之间的差异可能意味着工作和堆栈溢出之间的差异,如果堆栈已经缩小尺寸以允许多个线程。

如果使用“总是递归到较小的分区”算法,堆栈不能每超过 log2 N 帧,其中 N 是向量中的元素数。此外,N 不能超过可用内存量除以元素的大小。所以在 32 位机器上,一个向量中只能有 219 个 8kb 元素,并且快速排序调用深度不能超过 19。

简而言之,正确编写快速排序使其堆栈使用可预测(只要您可以预测堆栈帧的大小)。即使在非病态情况下,不去优化优化(以保存单个比较!)也很容易导致堆栈深度加倍,并且在病态情况下可能会变得更糟。

【讨论】:

  • AFAICT OP 想知道的是 why 如果您总是递归到较小的分区,“堆栈不能每个超过 log_2 N 帧”。这是一件好事,尤其是当物体很大时,这一事实没有争议!
  • @j_random_hacker,您对 OP 的看法可能是正确的,我应该更好地总结原因。但是,没有争议是不正确的;我知道这个问题的唯一原因是它的发生是因为标准的 introsort 实现,遵循原始的 introsort 论文,未能优化堆栈深度,大概是因为每个循环一次额外比较的成本。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2018-10-04
  • 2013-02-10
  • 2015-06-28
  • 1970-01-01
  • 1970-01-01
  • 2014-10-18
  • 1970-01-01
相关资源
最近更新 更多