转载文章,原文章来源于算法设计与分析基础系列--生成排列与洗牌算法(一) (欢迎关注微信公众号,会定期更新内容)
============================================================
前言
本文内容基于书籍"算法设计与分析基础"(Introduction to The Design and Analysis of Algorithms,作者Anany Levitin),主要学习和讨论其中的生成排列与洗牌算法。
问题描述
生成排列问题主要是给定从1到n的n个正整数,生成所有的排列结果,一共有n!种序列。而在此基础上,我们接着讨论洗牌算法。洗牌问题的一般描述是给定n张不同的牌,设计一个函数随机均匀地生成其中一种排列,即以概率1/n!生成其中一种排列结果,有时也会以面试题的形式出现。
解答
我们先来讨论生成排列问题。以n=3为例,我们需要生成所有3!=3*2*1=6种排列结果,包括(1,2,3), (1,3,2), (2,1,3), (2,3,1), (3,1,2), (3,2,1)。
我们可以这么来思考。首先,我们需要确定第一个位置放哪个数字,它可能是1,2或者3,有3种选择。当第一个位置确定后,我们需要继续确定第二个位置放哪个数字,它也可能是1,2或者3,但是必须要保证不能和第一个位置放置的数字重复,因此有2种选择,以此类推处理接下来的每个位置(对于n=3,只有最后一个位置了,它有1种选择),直到所有位置都确定了放置的数字。我们会发现,将每个位置(一共3个位置或者说3个步骤)的方案数乘起来,恰好就是3*2*1=6种结果,即覆盖了所有可能的排列。而我们的排列生成算法,就是基于枚举"每个步骤"的所有可能来实现的。
下面我们给出详细的过程。
Result A. 首先确定第一个位置,我们可以放置1,2或者3。假设我们本次决定放置1(步骤1)。接着我们确定第二个位置,它可以放置2或者3,假设我们本次决定放置2(步骤2)。接着我们确定第三个位置,它只能放置3了。此时我们已处理完所有位置,可以输出结果1,2,3。
Result B. 现在我们回看Result A的步骤2,在步骤2的时候,本来我们是可以选择2或者3的,而Result A只是"本次决定放置2"的结果,我们还要尝试"本次决定放置3"这个方案,如果在Result A的步骤2我们选择放置3,那么在第三个位置只能选择2了,此时我们也处理完了所有结果,输出1,3,2。
接着,我们再回看Result A和Result B的步骤1,这两个输出结果中,在步骤1的时候,我们都是"本次决定放置1",但是在步骤1(第一个位置),我们还可以尝试放置2或者3,那么如果我们在步骤1"本次决定放置2"的话会怎样呢?
Result C. 如果步骤1"本次决定放置2",那么在位置2,我们有两种选择1或者3,类似地,在步骤2我们"本次决定放置"1,那么第三个位置就只能放置3了,此时输出结果为2,1,3
Result D. 接着在Result C的步骤2中,我们"本次决定放置"3,那么第三个位置只能放置1,此时的结果为2,3,1
现在我们再次回到Result A和B之后的步骤1那里,在Result A和B中我们"本次决定放置1",在Result C和D中,我们"本次决定放置2",现在我们考虑"本次决定放置3"
Result E. 在步骤1中我们"本次决定放置"3,那么在步骤2中我们可以选择1或者2,假设我们"本次决定放置"1,那么第三个位置只能放置2了,此时结果为3,1,2
Result F. 在步骤1中我们"本次决定放置"3,那么在步骤2中假设此时我们"本次决定放置"2,那么第三个位置只能放置1了,此时结果为3,2,1
我们可以发现,上面的6种结果,恰好对应了所有的排列情况。而得到这些结果的过程可以描述如下。
我们首先确定第一个位置放哪个数字,会发现有多种可能,我们选择其中一种并进入第二个位置的处理步骤中,接着对于第二个位置我们继续从多种可能中选择其中一种,并进入下一个位置的处理中,直到我们处理完所有位置,输出结果。然后,我们"回溯"到最近的那个有多种选择的位置,做出另一个选择,接着再次进入到下一个位置继续处理,直到"每个位置的多种选择都被尝试了一次"停止。
如果大家还记得八皇后问题的话,会发现这个问题和八皇后非常相似,采用的都是"递归+回溯"的思想,可以参考本公众号下的这篇文章,“"剑指offer"中的经典算法面试题--八皇后问题”。
现在我们直接给出代码,代码中我们是从0开始
int a[n]; // 数组a包含数字从0到n-1 即a[i]=i
void solve(int pos, int n){ // pos表示当前处理的位置 n表示数组长度 即数字个数
if(pos == n){ // 已经处理完所有位置 可以输出答案
for(int i = 0; i < n; i++){ // 此时数组a装填的就是0到n-1的一种排列
cout<<a[i]<<" ";
}
cout<<endl;
return;
}
for(int i = pos; i < n; i++){ // 当前处理的位置是pos 由于不能和之前已经选择的数字重复 我们只能考虑后面的数字
int tmp1 = a[pos];
a[pos] = a[i];
a[i] = tmp1; // 这3行代码是交换a[pos]和a[i] 表示在pos这个位置 我们"本次决定放置a[i]"
solve(pos + 1, n); // 递归调用解决下一个位置pos+1
int tmp2 = a[pos];
a[pos] = a[i];
a[i] = tmp2; // 这3行代码是再次交换a[pos]和a[i] 从而将"状态"恢复到"本次决定放置a[i]"之前的样子 在下一次for循环中我们即将"尝试"该位置pos的其他可以放置的数字
}
}
int main(){
int n = 5; // 初始化长度n
for(int i = 0; i < n; i++){
a[i] = i; // 初始化数组a[i]=i
}
solve(0, n); // 调用函数 先决定位置pos=0放置哪个数字
}
我们需要再次强调,在这类"递归+回溯"的算法中,"回溯"部分非常重要(有时也容易遗漏),在一些更复杂的问题中,"回溯"的处理也会更难处理一些。
关于洗牌算法,我们将会在下一篇文章中进行讨论和分析。
总结
1,排列生成算法是很多其他问题的一个基础,比如对于输入规模不大的旅行商问题,我们是可以利用排列生成直接暴力枚举最优解的。
2,递归+回溯,是一个非常重要且经典的思想,往往是很多复杂问题中的关键一步。
欢迎大家多多转载并关注后续更新,如果有其他算法相关的问题也欢迎留言讨论~