堆的排列算法
Posted
技术标签:
【中文标题】堆的排列算法【英文标题】:Heap's algorithm for permutations 【发布时间】:2015-10-04 04:56:56 【问题描述】:我正在准备面试,我正在努力记住 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
这个算法是一个非常有名的生成排列的算法。它简洁快速,与代码一起生成组合。
问题是:我不喜欢死记硬背,我总是尽量保留概念以便以后“推断”算法。
这个算法真的很不直观,我无法找到一种方法来向自己解释它是如何工作的。
谁能告诉我为什么和如何这个算法在生成排列时按预期工作?
【问题讨论】:
我知道这是旧的,但我在他的网站上找到了 Ruslan Ledesma-Garza 的一个很好的解释:ruslanledesma.com/2016/06/17/why-does-heap-work.html 【参考方案1】:Heap 的算法可能不是任何合理面试问题的答案。有一种更直观的算法可以按字典顺序产生排列;虽然它是摊销 O(1)(每排列)而不是 O(1),但在实践中并没有明显变慢,而且更容易动态推导。
字典顺序算法描述起来非常简单。给定一些排列,通过以下方式找到下一个:
找到比它右边的元素更小的最右边的元素。
将该元素与其右侧比它大的最小元素交换。
将排列的部分反转到该元素所在的右侧。
步骤 (1) 和 (3) 都是最坏情况 O(n),但很容易证明这些步骤的平均时间是 O(1)。
Heap 的算法有多么棘手(在细节上)的一个迹象是,您对它的表达略有错误,因为它进行了一次额外的交换;如果 n 为偶数,则额外的交换是无操作的,但会显着改变 n 为奇数时生成的排列顺序。无论哪种情况,它都会做不必要的工作。请参阅https://en.wikipedia.org/wiki/Heap%27s_algorithm 了解正确的算法(至少,今天是正确的)或查看Heap's algorithm permutation generator 的讨论
要了解堆算法的工作原理,您需要查看循环的完整迭代对向量的作用,包括偶数和奇数情况。给定一个偶数长度的向量,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:cs.princeton.edu/~rs/talks/perms.pdf @StephenFriedrich:我在对链接问题***.com/questions/29042819/… 的回答中提到了这张幻灯片。这张幻灯片是不正确的(显然是这样),但它也与 Sedgewick 工作中对算法的其他讨论不对应。在演示文稿中很容易出错(即使你是 Robert Sedgewick);我在该答案中引用的论文更可靠。很遗憾,这个特定的演示文稿没有得到更正。 @connor:很好。谢谢。【参考方案2】:我在这里找到了一篇文章试图解释它:Why does Heap's algorithm work?
但是,我认为它很难理解,所以想出了一个希望更容易理解的解释:
请暂时假设这些陈述是正确的(我稍后会展示):
每次调用“生成”函数
(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 的生成)。 所以我们可以忽略这些电话。 在此调用中执行的交换导致右循环,请参阅 (III)。
如果 n 为 3: 递归调用“generate(n - 1, A)”导致右旋转。 所以这个调用的总步数等于 (IV) => 顺序不变。
重复 n = 4, 5, 6, ...
【讨论】:
Swap: a[0] <-> a[n]
显然不正确,因为没有 a[n]
。如果将其更改为将 a[0]
与 a[n-1]
交换,则会引入额外的交换,从而使排列序列不是格雷码。 (这在未更正的***页面中很明显。)虽然它不是格雷码,但它仍然是所有排列的序列,因此很容易错过错误。
感谢@rici 捕捉到这个错误。已更正。是的,代码正在执行一些不必要的交换操作。我真的不明白这有多重要,因为目标是生成所有排列,它确实 - 不像***关于堆算法的文章中的当前代码,它只是被破坏了。 Heap的算法有什么“权威”的描述吗?我无法破译***上链接的原始文章中的结构图:comjnl.oxfordjournals.org/content/6/3/293.full.pdf
人们不断地破坏***的代码,尤其是通过使用错误的 prezzy 以及误读代码。但我最后一次看它时,它工作得很好。 sedgewick 的原始论文和 1977 年的论文都是正确的,在我对链接问题的回答中,有一份 sedgewick 1977 年的代码副本。
这里是***代码到 C++ 的快速翻译,以及 n==3 coliru.stacked-crooked.com/a/0c239cfc7b7f4d46 和 n==4 coliru.stacked-crooked.com/a/0c239cfc7b7f4d46 的正确输出也许你会好心证实你的声称它“刚刚损坏”或解释我的翻译与***伪代码有何不同。否则,你有一些事情要做。
好的,谢谢你的代码。我正式撤回我之前的声明!当我自己翻译伪代码时,我使用了 kotlin 并错误地将 for 语句“for(i in 0..(n - 1)) ”而不是“for(i in 0..(n - 2)) ”。但是,我希望有一种语言结构可以使“循环中间返回”更加优雅(在循环之后重复循环的部分内容与在循环中使用“if”和“break”一样不雅)中间(真))。【参考方案3】:
堆的算法构造所有排列的原因是它将每个元素与其余元素的每个排列相邻。当您执行堆算法时,对偶数长度输入的递归调用将元素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。
【讨论】:
以上是关于堆的排列算法的主要内容,如果未能解决你的问题,请参考以下文章