始于一个很简单的问题:生成{0,1,2,3,...,n-1}的n!种排列,即全排列问题。下面介绍几种全排列的实现,以及探讨一下其解题思路。
基于枚举/递归的方法
思路:
基于枚举的方法,也可以说是基于递归的方法,此方法的思路是先将全排列问题的约束进行放松,形成一个较容易解决的新问题,解新问题,再对新问题进行约束,解出当前问题。以上全排列问题是生成{0,1,2,...,n-1}的n!个排列,隐含的一个约束是这个n个位置上的数必须是给出的集合中的数,不能重复使用。当我们将此约束放松的时候,问题就变成了n个位置每个位置上有0~n-1种可能出现的数字,列出所有nn种数列,即在每一位上枚举所有的可能。新问题的算法非常简单:
private Integer[] perm; private void permut(int pos, int n) { if (pos == n) { for (int i = 0; i < perm.length; i++) { System.out.print(perm[i]); } System.out.println(); return; } for (int i = 0; i < n; i++) { perm[pos] = i; permut(pos+1, n); } }
而我们实际的问题只要保证每一位上的数字在其他位置上没有使用过就行了。
private boolean[] used; private Integer[] perm; private void permut(int pos, int n) { if (pos == n) { for (int i = 0; i < perm.length; i++) { System.out.print(perm[i]); } System.out.println(); return; } //针对perm的第pos个位置,究竟使用0~n-1中的哪一个进行循环 for (int i = 0; i < n; i++) { if (used[i] == false) { perm[pos] = i; used[i] = true; //i已经被使用了,所以把标志位设置为True permut(pos+1, n); used[i] = false; //使用完之后要把标志复位 } } }
或者完全按递归是思想,对{0,1,2,...,n-1}进行排列,分别将每个位置交换到最前面位,之后全排列剩下的位:
private static void PermutationList(int fromIndex, int endIndex) { if (fromIndex == endIndex) Output(); else { for (int index = fromIndex; index <= endIndex; ++index) { // 此处排序主要是为了生成字典序全排列,否则递归会打乱字典序 Sort(fromIndex, endIndex); Swap(fromIndex, index); PermutationList(fromIndex + 1, endIndex); Swap(fromIndex, index); } } }
基于字典序的方法
基于字典序的方法,生成给定全排列的下一个排列,所谓一个的下一个就是这一个与下一个之间没有其他的。这就要求这一个与下一个有尽可能长的共同前缀,也即变化限制在尽可能短的后缀上。计算下一个排列的算法内容如下:
一般而言,设P是[1,n]的一个全排列。 P = P1P2…Pn = P1P2 … Pj-1PjPj+1 … Pk-1PkPk+1 … Pn find: j = max{i|Pi<Pi+1} k = max{i|Pi>Pj} 1, 对换Pj,Pk, 2, 将Pj+1 … Pk-1PjPk+1 … Pn 翻转 P’= P1P2 … Pj-1PkPn … Pk+1PjPk-1 … Pj+1 即P的下一个
按照算法可以实现:
public class Permutation2 { public static String nextPerm(String aStr) { int index_j = -1; int index_k = -1; int length = aStr.length(); StringBuffer buffer = new StringBuffer(aStr); for (int i = length-1; i > 0; i--) { if (aStr.charAt(i) > aStr.charAt(i-1)) { index_j = i-1; break; } } if (index_j != -1) { for (int i = length-1; i > index_j; i--) { if (aStr.charAt(i) > aStr.charAt(index_j)) { index_k = i; break; } } }else { return null; } char tmp = buffer.charAt(index_j); buffer.setCharAt(index_j, buffer.charAt(index_k)); buffer.setCharAt(index_k, tmp); StringBuffer subBuffer1 = new StringBuffer(buffer.subSequence(index_j+1, length)); String subBuffer2 = buffer.substring(0, index_j+1); subBuffer1.reverse(); return subBuffer2 + subBuffer1; } public static void main(String[] args) { String aNum = "123"; while ((aNum = Permutation2.nextPerm(aNum)) != null) { System.out.println(aNum); } } }
原理:
根据如上算法为什么能得到已知排列的下一个排列?我们来分析一下。
假设我们对已知排列 P1P2…Pn 求其下一个排列,默认为按字典序递增,P1P2…Pn 可能是一串数字,为了便于计算,统统将其看作一个字符串。首先我们需要清楚的一点是下一个刚好比 P1P2…Pn 大的排列应当和原排列有尽可能长的相同前缀(高位保持一致,尽可能在低位上发生变化),剩下变化的部分称为后缀,假设为 PjPj+1 ... Pn ,我们的所有变化都在这个子串上进行。
对于上述子串 PjPj+1 ... Pn ,隐含如下信息:
- 对于下一个排列的生成过程,实际上就是对子串 PjPj+1 ... Pn 某些位置上的数值进行交换。
- Pj 位置上的数值必然会与之后某个位置上的数值进行交换,因为 Pj 是后缀上的第一个位置,故必须发生变化,而且必须与后缀其他位置上的数字交换。
- 交换之后Pj位置上的数值必然比原数值大,因为我们按默认字典序递增计算下一个排列,也就是说,在 Pj 之后的某个位置上必须存在比 Pj 大的数值。
根据以上三点,我们就能确定 Pj 的位置了。为了保证尽可能长的前缀,我们需要从尾部向前检查,检查的条件是满足 Pi<Pi+1 。一旦满足这个条件,就保证了在后缀上至少有一个数值大于 Pi ,即 Pi+1 ,如果不满足这个条件,从后向前是一个递增的序列,在后缀上不会存在大于Pi的数值,即不满足以上第三点,继续向前检查,第一个满足 Pi<Pi+1 的 Pi 就是我们要寻找的 Pj (理由是尽可能高位保持一致)。这时候在后缀上至少存在 Pi+1 是大于 Pi (即 Pj )的,但同时也可能后缀存在多个大于 Pj 的数值,我们应该选取哪一个与Pj交换呢?当然是刚好比 Pj 大的那个,即比Pj大的数值中最小的那个(设为 Pk ),原因很简单,如果选择了一个不是最小的数值 Pc 与 Pj 交换,那生成的排列与原排列之间必然还有其他排列(这个排列就是后缀中任何一个比 Pc 小且比 Pj 大的数值与 Pj 交换产生的排列),那就不是我们需要的下一个排列了。因此:
PjPj+1 ... PkPk+1 ... Pn(Pk为后缀中刚好比Pj大的数值)
交换之后:
PkPj+1 ... PjPk+1 ... Pn
此时 Pk (原 Pj )处已经确定下来了,那后面的排列怎么排呢?我们既然是要产生刚好比原排列大的下一个排列,当然是在满足情况的前提下使新排列尽可能的小,而此时 Pk (原 Pj )位置比原此位置上的数值大,因此后面无论怎么排,新生成的排列都比原排列大,因此在只要 Pk 之后的排列找到一个最小的就行了。而在 Pj 与 Pk 交换之前这段序列是从后向前递增有序的,那交换以后呢?
因为 Pj < Pk , Pj > Pk+1, Pk < Pk-1
所以 Pk-1 > Pj > Pk+1
所以交换之后仍然是从后向前递增有序,因此只需要把后面的序列逆置一下就行了,最后生成的新排列就是我们所有的下一个排列。