Heap算法用于排列组合

25

我正在准备面试,试图记忆 Heap 算法:

procedure generate(n : integer, A : array of any):
    if n = 1 then
          output(A)
    else
        for i := 0; i < n; i += 1 do
            generate(n - 1, A)
            if n is even then
                swap(A[i], A[n-1])
            else
                swap(A[0], A[n-1])
            end if
        end for
    end if

这个算法是生成排列的一个著名算法。它简洁快速,并且与生成组合的代码配合使用。

问题在于:我不喜欢死记硬背,总是试图保留概念以“推导”算法。

这个算法真的不直观,我找不到一种方法来解释它如何工作。

请问有人可以告诉我为什么和如何在生成排列时使该算法按预期工作吗?


4
我知道这已经比较老了,但在Ruslan Ledesma-Garza的网站上我找到了一份好的解释:http://ruslanledesma.com/2016/06/17/why-does-heap-work.html。 - orionrush
6个回答

13

Heap算法可能不是任何合理面试题的答案。有一个更直观的算法可以按照字典序产生排列;虽然它的摊销时间是O(1)(每个排列),而不是O(1),但在实践中并没有明显变慢,而且更容易推导。

字典序算法非常简单: 给定某些排列, 按照以下方式找到下一个排列:

  1. 找到最右边比右侧元素小的元素。
  2. 将该元素与右侧最小的大于它的元素交换。
  3. 反转该元素右侧的部分。

步骤(1)和(3)都是最坏情况O(n),但很容易证明它们的平均时间是O(1)。


Heap算法有多棘手(在细节上)可以通过你对其表达式的略微错误来体现,因为它执行了一次额外的交换操作;如果n是偶数,则这个额外的交换操作是无用的,但当n是奇数时,它会显着改变生成的排列顺序。在任一情况下,它都做了不必要的工作。请参阅https://en.wikipedia.org/wiki/Heap%27s_algorithm以获取正确算法(至少今天是正确的),或在Heap's algorithm permutation generator中查看讨论。

要了解Heap算法的工作原理,您需要查看循环的完整迭代在偶数和奇数情况下对向量进行的操作。给定一个长度为偶数的向量,Heap算法的一个完整迭代将按照规则重新排列元素。

[1,...n] → [(n-2),(n-1),2,3,...,(n-3),n,1]

而如果向量的长度为奇数,则仅交换第一个和最后一个元素:

[1,...n] → [n,2,3,4,...,(n-2),(n-1),1]
你可以使用归纳法证明这两个事实都是真的,尽管它并不能解释为什么这是真的。查看维基百科页面上的图表可能会有所帮助。

原帖作者提供的代码实际上是正确的。它与Sedgewick在这里演示的第13张幻灯片上给出的代码完全相同:http://www.cs.princeton.edu/~rs/talks/perms.pdf - eekboom
6
@StephenFriedrich 在我的回答中提到了这张幻灯片,链接是 https://dev59.com/_V4b5IYBdhLWcg3wfxxI#29044942 。这张幻灯片是错误的(可以证明),而且也不符合 Sedgewick 的其他关于该算法的讨论。在演示中犯错误是很容易的(即使你是 Robert Sedgewick);我在回答中引用的论文更加可靠。遗憾的是,这个特定的演示并没有被纠正。 - rici
@connor:发现得好。谢谢。 - rici
@rici,您能否解释一下为什么词典排序算法通过这些步骤生成所有排列?我不擅长数学,想知道为什么执行这3个步骤就可以直观地得到每个排列...谢谢。 - ima_prog
1
@ima_prog:字典序算法使用这三个步骤来找到下一个更大的排列(其中更大是按字典顺序定义的)。字典序排序只是单词在字典中排列的方式,许多人根本不认为这是数学;如果您不理解这些步骤如何找到下一个更大的排列,最简单的方法就是用铅笔和一张纸自己执行算法。(使用较小的向量,否则您将需要非常大的纸张 :-) 具有许多重复值的向量具有较少的排列。) - rici
显示剩余2条评论

2
我找到了一篇文章,试图在这里解释它:Heap算法为什么有效? 然而,我认为这篇文章很难理解,所以想出了一个希望更容易理解的解释:
请暂时假设这些陈述是正确的(稍后我会证明):
每次“generate”函数的调用 (I) 当n为奇数时,在结束时元素的顺序保持不变。 (II) 当n为偶数时,将元素向右旋转,例如ABCD变成DABC。
因此,在“for i”的循环中, 当n为偶数时,“generate(n - 1, A)”的递归调用不会改变顺序。因此,for循环可以迭代地交换i = 0..(n-1)处的元素与(n - 1)处的元素,并且每次都会调用“generate(n - 1, A)”并且缺少另一个元素。 当n为奇数时,“generate(n - 1, A)”的递归调用已将元素向右旋转。因此,索引0处的元素将始终自动成为另一个元素。只需在每次迭代中交换0和(n-1)处的元素即可生成唯一的元素集。
最后,让我们看看为什么初始陈述是正确的:
旋转到右侧 (III) 这些交换的系列导致向右旋转一个位置:
A[0] <-> A[n - 1]
A[1] <-> A[n - 1]
A[2] <-> A[n - 1]
...
A[n - 2] <-> A[n - 1]

例如,尝试使用序列ABCD:
A[0] <-> A[3]: DBCA
A[1] <-> A[3]: DACB
A[2] <-> A[3]: DABC

无操作

(IV) 这一系列步骤使得序列的顺序与之前完全相同:

Repeat n times:
    Rotate the sub-sequence a[0...(n-2)] to the right
    Swap: a[0] <-> a[n - 1]

直观上讲,这是正确的:

如果您有一个长度为5的序列,然后将其旋转5次,它最终保持不变。

在旋转之前取出0号元素,然后在旋转后将其与新的0号元素交换,不会改变结果(如果旋转n次)。

归纳法

现在我们可以看到为什么(I)和(II)是正确的:

如果n为1: 很明显,在调用函数后,排序不会改变。

如果n为2: 递归调用“generate(n - 1, A)”不会改变排序(因为它调用第一个参数为1的generate)。 所以我们可以忽略那些调用。 在此调用中执行的交换导致右旋,参见(III)。

如果n为3: 递归调用“generate(n - 1, A)”导致右旋。 因此,此调用中的总步骤等于(IV) => 序列未改变。

重复n = 4、5、6、...的操作。


1
“Swap: a[0] <-> a[n]” 明显是不正确的,因为没有 a[n]。如果你将其更改为交换 a[0]a[n-1],则会引入一个额外的交换,使排列序列不再是格雷码(这在未经校正的维基百科页面中很明显)。虽然它不是格雷码,但仍然可以通过所有排列序列,因此错误很容易被忽略。 - rici
感谢@rici指出那个差一的错误。已经更正。是的,代码执行了几次不必要的交换操作。我并不认为这有什么关系,因为目标是生成所有排列,它做到了——与维基百科文章中当前代码完全不同,后者根本就是错误的。是否有任何“权威”的Heap算法描述?我无法解密维基百科链接的原始文章中的结构图:http://comjnl.oxfordjournals.org/content/6/3/293.full.pdf - eekboom
人们一直在破坏维基百科的代码,特别是通过使用错误的prezzy,还有误读代码。但上次我查看时,它运行良好。原始论文和Sedgewick 1977年的论文都是正确的,并且在我回答链接问题时附有来自Sedgewick 1977年的代码副本。 - rici
相比之下,这里是原问题的错误代码,你在评论中声称它是正确的。http://coliru.stacked-crooked.com/a/72c850885d09d4cb 它确实生成了所有24个排列,但它在第7行从 cbaddbca,这不是一个格雷码,因为它不是两个元素的交换。 - rici
1
好的,谢谢你提供代码。我正式撤回之前的声明!当我自己翻译伪代码时,我使用了Kotlin,并错误地将for语句写成了"for(i in 0..(n - 1)) {"而不是"for(i in 0..(n - 2)) {"。但我希望有一种语言结构可以使“在循环中返回”更加优雅(在循环后重复循环的部分与在while(true)中使用“if”和“break”一样不优雅)。 - eekboom
显示剩余3条评论

1
Heap算法构建所有排列的原因是将每个元素与其余元素的每个排列相邻。当您执行Heap算法时,对于偶数长度的输入递归调用将元素n,(n-1),2,3,4,...,(n-2),1放置在最后一个位置,对于奇数长度的输入递归调用将元素n,(n-3),(n-4),(n-5),...,2,(n-2),(n-1),1放置在最后一个位置。因此,在任一情况下,所有元素都与n-1元素的所有排列相邻。
如果您想要更详细和图形化的解释,请查看this article

0
function* permute<T>(array: T[], n = array.length): Generator<T[]> {
if (n > 1) {
    for (let ix = 1; ix < n; ix += 1) {
        for (let _arr of permute(array, n - 1)) yield _arr
        let j = n % 2 ? 0 : ix - 1
        ;[array[j], array[n - 1]] = [array[n - 1], array[j]]
    }
    for (let _arr of permute(array, n - 1)) yield _arr
} else yield array

}

使用示例:

for (let arr of permute([1, 2, 3])) console.log(arr)

0

对我来说最难理解的部分是递归表达式,因为我还在学习它。

for i := 0; i < n; i += 1 do
   generate(n - 1, A)
  • 我理解为对每个 i 到 n 进行评估
  • 以 n = 1 作为终止条件
  • 在执行时,无论 n 是奇数还是偶数都会返回

由于它递归地调用和返回每个 i,因此每当传回 n + 1 时可以实现最小更改。


这种递归方法会非常慢,特别是对于更大的数字而言,因此请使用迭代方法。 - Shaggy

0

只是一个小提示。堆算法将生成n!个组合。 例如,如果您将n = [1,2,3]作为输入传递,则结果将是n!,即


这更适合作为评论而不是答案发布。您可以在此处找到有关如何编写良好答案的更多信息:https://stackoverflow.com/help/how-to-answer - CoderBlue

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接