问题描述
假设我们给定一组字符或者数字,如何求出它们的全排列或者给定个数的排列组合呢?这个问题本身看起来很简单。从以前学过的数学知识来看的话也很好表述。只是因为这个问题里牵涉到的各种条件和变化比较多,逐个讨论下来的篇幅也很大,所以打算拆成几篇文章来讨论。在这篇文章里,我们主要讨论基本的全排列和它的生成算法。
分析
这个问题我们可以采用两种不同的方式来分析和处理,一种是递归调用的思路,还有一种是字典序的思路。在一些问题的变体里,还会对于排列的输出顺序有一定要求。我们在这里结合具体情况一并讨论。
递归
递归的思路其实还是比较好理解的,比如说我们有一组共n个元素。我们要生成它们的排列时,可以按照一个这样的顺序。首先从数组的第一个元素开始,比如说它可以放置的元素有n个,有n种可选项。而在第一个位置确定之后,后面的元素接着再放,它只有n - 1种可能了。实际上,我们后面每一个位置的元素都和前面的元素有一定关系,比如位置为k的元素必须取和前面元素不一样的。所以我们每次在一个位置上添加元素的时候还要判断一下,是否前面已经有这个元素了。如果有的话则不能添加。所以说,我们可以采用这样的思路,针对每个位置去遍历我们n个元素,尝试将元素赋值到该位置,然后根据前面元素是否被取用来过滤一些被占用的情况。
所以,根据前面的讨论,我们需要的参数应该包括有如下几个:
1. 已经确定的填充序列,比如说当我们填充到位置i的时候,前面i - 1个元素是已经确定了的。
2. 剩下的需要进行全排列的元素集合s,我们要从这里挑选。
根据这些讨论,我们这个算法的实现伪码如下:
void permutation(int[] a, 集合s,当前位置cur) { if(s为空) 输出序列a. else 按照顺序考虑s的每个元素{ if(s中的元素i 不在a中) permutation(a, s - i, cur +1); } }
我们再来结合具体的实现来考虑一下。假设我们考虑的就是一串数字从1到n。那么这里的参数集合s可以直接用一个数字n来表示。我们可以根据cur == n来判断集合是否为空。因为按照前面递归的关系,每次cur递归一次就要加一,等它遍历完整个集合,也就是集合剩下的元素为0了。所以我们对应的实现如下:
public static void permu(int[] a, int len, int cur) { if(a == null || len < 1) return; if(cur == len) { //判断集合s是否为空 printList(a); } else { for(int i = 1; i <= len; i++) { if(!contains(a, i, cur)) { a[cur] = i; permu(a, len, cur + 1); } } } }
这部分代码基本上是前面伪码的详细翻译。其中参数len表示数组的长度。因为cur表示当前放置元素所在的下标,一般是从0到len - 1。 这里有一个开始有点让人费解的地方就是为什么判断集合s为空的时候是if(cur == len)。照理说说cur最多也只能到len - 1啊。其实因为我们要将里面所有的元素都放置满。而放置的最后一个元素就是当cur == len - 1的情况。所以按照前面的递归关系它是要继续到cur + 1的情况。而这个时候正好应该是我们程序的一个返回点了。所以正好就是判断cur是否等于len。一个小细节,容易出问题的地方。我们调用这个方法的时候一般是传入对应的数组,数组长度和开始位置,这个开始位置设置为0。
另外,里面的contains方法用来判断在给定的cur范围内,是否存在元素i。它的实现如下:
private static boolean contains(int[] a, int i, int pos) { for(int j = 0; j < pos; j++) if(a[j] == i) return true; return false; }
而里面的printList()方法纯粹是为了输出列表里的元素省事,它的实现如下:
private static void printList(int[] a) { for(int i = 0; i < a.length; i++) System.out.print(a[i] + " "); System.out.println(); }
这部分很简单,不解释。
字典序生成排列
还有一种常用的非递归算法叫做字典序生成法。它实际上遵循的是一种数字和排列的一一对应关系。而且我们知道对于两个串来说,我们要比较它们的字典序是按照这样的规则来的:从前面的字符开始比较,一直比较到不同的两个元素为止。如果不同的元素中,某一个序列的元素大,则这个序列整体大于另外一个序列。 比如说我们有两个字符串序列,分别为"abcde", "abcfd"。在这两个串里,前面三个字符是相同的,但是第四个元素一个是d一个是f。因为f > d,所以第二个串大于第一个串。对于数字的串来说就更加明显了。
按照我们描述的字典序,对于一个串来说,它的元素排列之间就构成了一个顺序关系。比如以我们的数字串1234为例。这4个数字能构成的排列里,最小的是1234, 最大的是4321。我们要生成它们所有的排列,无非就是从最小的1234开始,然后按照一个逐步递增的方式,使得它们生成一个严格递增的序列一直到最大。这样我们既按照原来的要求生成了给定序列的全排列,同时还保证了生成的排列是严格按照递增的顺序生成的。那么,这个详细的生成思路,该怎么来推导呢?
我们以给定的数字1234为例来考虑。比它大的且与它最接近的数字是1243。通过这一步的观察,作为最低两位的数字,如果它们是保持递增关系的,我们只要调换一下它们的顺序就可以了。我们再往下一步观察,下一个最接近它且大于它的数字是1324。因为此时1243里2后面的数字已经到了所能取的最大数字了,要再增加的话只能增加2。而这里最接近的就是将2换成3。那么后面可以选择的数字是2和4。而很显然,1324是最接近的,而不是1342。那么通过这一步,我们能找到什么规律呢?
首先一个,当我们取到1243的时候,怎么会考虑到要换的是2而不是其他位置的数字呢?正如前面所说的那样,因为这个时候2后面的数字已经到最大了,也就是说它已经是一个连续严格递减的序列了。比如这里的是43。那么,我们要找到需要换的位置2, 首先就必须要找到一个最后面的连续递减序列。换一种说法,我们也可以说要找到一个最后面的递增序列。比如当1243的时候,我们要保证有这么一个连续递增的序列。我们能找到的最后的那个就是24。所以说这里就确定了2的位置。这一步的过程如下图所示:
在知道了这个要换的元素之后,我们后面要做的应该就是找一个后面和它最接近但比它大的元素,然后他们之间换个位置。如下图:
但是很明显,我们只是这一步还是不够的。因为比1243大且最接近它的是1324而不是1342。所以我们肯定还必须要调整。结合我们前面考虑的结果。当我们取到的数字2的时候,它后面肯定是一个递减的序列,或者极端的情况就是它后面只有一个唯一的比它大的元素。既然2后面的这个数字是后面这个递减序列里最大的数字,而处于要让后面数字最接近前面的那个数字,必然必须要取一个尽量小的数字。所以必须要这个最大的数字尽量放到最后面。怎么来让后面这一截数字组成一个最小的数字呢?我们先来看一个这样的规律。
在前面的序列里,假定每个元素都是唯一的。比如有a[i], a[i+1], a[i+2]...a[i+k]...a[n]这么一个满足a[i] < a[i+1]且从a[i+1]到后面的所有元素都是递减的。那么假设那个大于a[i]且和a[i]最接近的是a[i+k],那么我们将a[i]和a[i+k]的位置替换之后,a[i+1]到a[n]这一串里还保持有原来的递减特性吗?仔细一想,这个特性是当然满足的。因为我们在前面的步骤里要找这个大于a[i]且最接近a[i]的元素。这里的a[i+k]满足的话,则a[i+k+1]则必然小于a[i]了。不然我们肯定选择的是a[i+k+1]。所以说,这一特性是满足的。而结合我们前面讨论的,要构成一个最小的数列,则只要构造一个严格从小到大的序列就可以了。而既然前面已经是一个严格递减的序列了,我们把它倒一下不就完了吗?
以我们前面的示例来看,它在交换了2和3之后,要做的就是将2后面的所有元素都倒置一下,如下图:
这样,最终的排列结果如下:
尼玛,搞半天原来是这样啊!求一个排列的下一个字典序的过程可以概括成如下的步骤:
1. 求里面最后面的连续递增元素点。比如说,在前面的示例1243里,肯定最靠后的就是24, 所以找到元素位置2。
2. 从位置2的这个点,也就是满足最后连续递增的点后面起去找一个比这个元素大,但是最接近这个元素的点。比如说这里找到的是3 。
3. 交换两个元素的位置。
4. 再对最后连续递增点后面所有的元素顺序逆转一下。
有了这个之后,我们要求所有的排列也就简单了,就是一直不停的找下一个字典序序列,直到找不到这个序列为止。我们知道当元素处于一个严格递减的序列情况时,就找不到递增的位置了。这个时候终止程序。在前面的实例里就是达到了4321这个序列。
好了,到这个份上,就没什么好说的。代码伺候:
1. 找最后的连续递增序列点:
private static int findJ(int[] a) { for(int i = a.length - 2; i >= 0; i--) { if(a[i] < a[i + 1]) return i + 1; } return -1; }
从后往前找,找到就直接返回,省事。
2. 根据找到的点后面找最接近的大于它的元素:
private static int findK(int[] a, int n, int i) { for(int j = n; j > i; j--) { if(a[j] > a[i]) return j; } return -1; }
我们看到这部分代码的时候可能会有点疑惑。因为是要找到最接近它的元素,这里直接碰到一个比它大的就返回,这样找到的真的就是那个元素吗?其实从前面的推导里已经看到了,后面的元素要么就是纯递减的,要么就一个光杆元素。所以从后往前去找到,第一个符合条件的就是。
3. 倒置后面序列里的元素:
private static void reverse(int[] a, int l, int r) { while(l < r) { swap(a, l, r); l++; r--; } }
里面swap方法的实现如下:
public static void swap(int[] a, int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; }
前面提到的交换元素的步骤放在所有步骤整合的代码里。整合代码的实现如下:
public static void permu(int[] a, int l) { while(true) { printList(a); int j = findJ(a); if(j == -1) return; int k = findK(a, l, j - 1); if(k == -1) return; swap(a, j - 1, k); reverse(a, j, l); } }
方法里参数l表示数组的长度减一,表示数组里最后一个元素的索引。这里只是实现的一个选择,我们也可以选择数组的长度,只是里面用到的地方要对应的减一。
对比
现在,我们已经实现了递归和非递归的两个版本。我们来比较一下它们的差别。采用递归方法实现的代码总体比较简单。它的基本思路是尝试不断的往每个位置去设定给定的值,然后过滤不符合条件的元素。所以这里的递归的层次和深度是一个值得考虑的地方。它的实现虽然简单,但是在数字比较多的情况下,容易导致栈溢出。而非递归的过程推导占据了很大一部分的篇幅。因为有了这么一个思路,我们不需要使用额外的空间来保存。相对来说空间的复杂度要低一些。
可重复集合的排列
在一些特殊的情况下,我们需要考虑可重复集合里元素的排列。比如说,我们要考虑一个数列1123。对于这个序列,因为有重复元素的加入,整个过程就显得略微复杂一些。因为我们每次将两个1中间的某个1放到一个位置的时候,另外一个1可能放的位置可能会形成一个重复。比如说我们构成的数列2311中,里面两个1都可能交换位置,但是实际上我们要求生成的排列不能重复。也就是说有重复元素的集合生成不重复的排列。所以对于这种有重复元素的排列要去除这些重复。
为了解决这个问题,我们需要引入一个额外的数组,它专门保存我们需要去读取的数字集合。比如我们前面的数列1123。对于这个数组,因为我们要保存一系列重复的元素,让里面的元素按照顺序排列好有特殊的意义。意义在哪里结合后面的代码会进一步说明。我们再用另外一个数组来保存我们最终放置的元素。
现在我们来针对两种解决方法进行讨论。
递归
我们在这里先回顾一下前面解决不重复元素全排列的思路。首先遍历源数据集合,尝试从当前的位置设置一个值,然后检查这个值是否已经被前面的元素给占了,如果没有,则尝试下一个位置。
那么对于我们这里有重复元素的情况,不能简单的判断前面是否已经占用了这个元素,而应该来判断一下,前面如果占用了相同的元素,我们需要看后面是否可以放同样的元素。所以需要判断我们后面可以放的元素个数和已经占用了元素的个数。我们怎么来计算后面可以使用的元素呢?我们可以这样来看,对于前面已经占用的元素,我们可以通过遍历一遍统计出来。而到底可以放多少个同样的元素我们可以去遍历源数据数组,找到比如说这里1的个数有两个,假设前面已经取了一个1的情况下,我们后面还可以取1个。而如果两个1都取了的情况则不能再放置这个元素了。
按照前面讨论的思路,我们可以写出一个大致过程的伪码:
void repPermu(源数据集合a, 目的数据集合s, int cur) { if(目的数据集合满) //输出排列; print(集合s); else 按照源数据集合a中的元素i { if(i在a中间出现的次数 > i在s中出现的次数) { p[i] = a[i]; repPermu(a, s, cur+1); } } }
现在,我们根据这部分伪码进一步细化来实现真正的代码。源数据集合和目的数据集合a, s可以分别表示为两个数组。和前面递归实现类似,我们可以用数组长度作为判断是否返回的条件。所以需要一个参数int len。
我们判断两个集合里给定元素的个数是否相等的实现就只是一个循环遍历,没什么好说的。所以详细的实现如下:
public static void repPermu(int[] a, int[] p, int len, int cur) { if(cur == len) { printList(p); } else { for(int i = 0; i < len; i++) { if(i == 0 || a[i] != a[i - 1]) { int c1 = 0, c2 = 0; for(int j = 0; j < cur; j++) if(p[j] == a[i]) c1++; for(int j = 0; j < len; j++) if(a[j] == a[i]) c2++; if(c1 < c2) { p[cur] = a[i]; repPermu(a, p, len, cur + 1); } } } } }
这里有一个实现的细节值得非常注意。就是为什么我们在for循环的里面要加一个if(i == 0 || a[i] != a[i-1])这样的条件判断呢?这是因为我们在循环里遍历源数组a的时候,如果读到的是连续相同的元素,则它们必然满足a[i] == a[i-1]这样的条件。而i == 0是为了避开当i为0的时候,我们判断i和它前面元素是否相同的这个极端情况。对于前面连续的元素,实际上我们知道,它们同样的情况只应该出现一次,所以要避免重复就体现在这里。而且,在前面我们提到过,这里用a[i] == a[i-1]判断是否有效基于的就是我这个数组a它本身是已经排序了的,所以如果相同的元素肯定就连在一块了。用这个方法来判断就显得很简单。这个部分的理解稍微有点困难。值得细细揣摩。
在讨论完可重复元素的全排列之后,我们可能也想过是否有非递归的实现呢?实际上,对于非递归的实现,我们用前面现成的那个实现居然也可以。为什么呢?结合我前面讨论的那个选择下一个字典序的排列思路我们就可以推测出来。大家可以去试试,这里就不再赘述了。
总结
这个话题在草稿箱里已经躺了一年多了。当初碰到这样的问题时,总觉得因为都是固定的套路,去刻意的记住它没多大的意义。不过在一次很重要的面试中被别人给问到,结果被一招给放倒了。当时的心情简直是无比郁闷。其实对于排列,以及全排列来说,它的实现方法有很多。除了我们列举的递归和字典序方法,还有一些其他的方法,比如递增进位数制法,递减进位数制法,邻位交换法等。这些方法虽然都有固定的套路,但是他们用来解决这些问题的思路还是很值得借鉴的。后面会结合具体情况进行进一步的讨论。另外,本文主要是讨论了全排列的生成,而对于一些部分排列的情形没有详细说明。在后续文章里也会进一步阐述。
参考材料
http://www.cnblogs.com/devymex/archive/2010/08/17/1801122.html
http://blog.csdn.net/zmazon/article/details/8351611
相关推荐
排列组合问题的算法设计是指如何高效地生成所有可能的排列或组合。今天,我们将讨论基于C语言的排列组合算法实现。 一、全排列算法 全排列算法是指生成所有可能的排列的算法。常见的全排列算法有递归算法、分治...
本资源主要讨论排列组合问题,特别是具有重复元素的排列组合问题,并使用回溯算法来解决。下面是对该资源的详细解释和知识点总结。 一、排列组合的定义 在数学和计算科学中,排列组合是指从一个集合中选择一些元素...
在编程领域,排列组合算法是解决许多问题的关键,特别是在数据处理、数据分析以及各种优化问题中。PHP作为一种流行的服务器端脚本语言,虽然不是为高性能计算而设计,但其丰富的库和简洁的语法使得实现这些算法变得...
排列组合计算器是一款专门针对这些问题设计的软件工具,如标题所示,这里我们讨论的是“排列组合计算器 v2.0”。 首先,我们需要理解排列和组合的基本定义。在给定的一组元素中: 1. **排列(Permutation)**:是...
总的来说,排列生成算法是组合优化和算法设计中的基础工具,对理解和掌握计算机科学中的问题求解技巧至关重要。通过深入学习和实践这两种方法,不仅可以提升编程能力,还能培养解决问题的逻辑思维。
通过递归方法,我们可以轻松地生成给定集合的所有排列组合。这种技术在解决许多实际问题时非常有用,例如在搜索最优解、模拟随机事件等方面。理解排列组合的基本原理及其实现方法对于学习高级算法和数据结构非常重要...
通过学习和理解这种VBA组合生成算法,你可以提升Excel的自动化能力,解决实际问题,比如在市场调查中分析潜在客户群组,或者在项目管理中研究不同资源分配方案的效果。 总结来说,这个VBA项目提供了一个实用工具,...
排列组合是离散数学的一部分,与算法设计、数据结构和优化问题息息相关。本教学内容主要介绍了排列和组合的基本概念,旨在帮助小学生通过实际操作和活动理解这些抽象的数学概念。 排列是指从给定的元素集合中取出一...
此外,对于树的组合生成,Knuth可能会介绍组合数学的概念,如排列、组合和递推关系,这些都是生成所有可能树的关键。读者还将学习如何利用这些数学工具来优化算法,减少计算复杂性。 最后,书中可能包含大量实例和...
在编程领域,排列组合是解决问题时经常会遇到的一种数学...同时,这个问题的讨论也关联到了八皇后问题,展示了排列组合在解决实际问题中的应用。理解和掌握这些知识,对于提升编程能力和解决复杂问题有着极大的帮助。
在EXCEL中,排列组合是一个常见的问题,今天我们来讨论如何使用EXCEL来生成排列组合。排列组合是指从一个集合中选取若干元素,并将他们排列成不同的顺序。例如,从数字1到4中选取8个数字,并将他们排列成不同的顺序...
下面将详细讨论排列组合的基本概念,以及在JAVA中实现这些算法的关键点。 排列是指从n个不同元素中取出m(m≤n)个元素,按照一定的顺序排成一列,这样的方法数目称为排列数,用P(n,m)表示。组合则是指不考虑顺序,...
本话题关注的是“33选6”和“36选7”的全组合速度试验,这是一个涉及排列组合计算和性能测试的议题。我们将深入探讨这些概念,并结合RtlMoveMemory函数在实际编程中的应用。 首先,让我们解释一下“33选6”和“36选...
本问题的核心是生成一个集合的所有可能排列,即使该集合中的某些元素可能完全相同。以下是这个问题的详细解析。 首先,我们需要理解“排列”这一概念。排列是指从一个有限的、非空的元素集合中,取出一部分或全部...
《计算机程序设计与艺术》是计算机科学领域的一部经典之作,由Donald E....通过阅读这本书,你可以了解到如何有效地生成和处理元组和排列,这对解决各种实际问题,如组合搜索、数据建模和优化,都将大有裨益。
总的来说,这个项目结合了MATLAB的图像处理能力、随机数生成、排列组合计算以及脚本编程,是一个很好的学习和实践MATLAB技术的例子。通过研究这些脚本,我们可以深入了解如何在MATLAB中实现这些功能。
- 使用递归方法来生成所有可能的排列组合。 - 在递归过程中,对于每个位置,尝试与之后的所有元素交换位置,并检查是否会产生重复排列。 - 如果发现会产生重复排列,则跳过此次交换。 - 对于每一种有效的排列,...
组合数学是离散数学的一个重要分支,主要研究有限集合中元素的不同排列组合方式以及与之相关的性质和计数问题。这个学科在计算机科学、信息论、编码理论、统计学、博弈论等多个领域都有广泛的应用。这份名为“组合...
第7章可能涉及生成函数,这是一种将组合问题转化为代数问题的技巧。通过解代数方程,可以找到组合数的递推关系。习题可能要求构造并求解特定类型的生成函数。 第8章可能涵盖了概率和统计的基本概念,这些在组合数学...
这个问题可以通过组合数学中的排列组合原理来解决。我们将7个人中的夫妇看作一个整体,然后在剩下的5个独立个体中进行排列,最后考虑夫妇内部的排列方式。类似的,若7个人中有3对夫妇,从中取出6个人的夫妇均不相邻...