关于全排列算法的思考

来源:互联网 发布:好玩的gba游戏 知乎 编辑:程序博客网 时间:2024/06/06 18:59

http://blog.sina.com.cn/s/blog_6fb300a30100mvzp.html


已经是求职过程中第三次碰到这个问题了,笔试两次,又面了一次,觉得这个问题确实值得深思啊。我还是尽量总结下吧,说不定以后还会碰到。

问题:已知输入为一个字符串,求其全排列的输出。比如输入为abc,那么输出有以下几种:

abc

acb

bac

bca

cab

cba

即如果输入字符串的长度为N的话,会输出N!个结果。

方法一:递归

思路是这样的:我们维护两个序列,一个序列是要进行全排列的序列,我们暂称之为源序列,另一个序列是全排列之后的结果序列,我们称其为结果序列。过程如下:

1)初始时源序列为输入的字符串序列,结果序列为空

2)如果源序列中的元素个数大于1,则对源序列中的每一个元素,进行如下操作:

   I. 以结果序列+该元素生成新的结果序列

   II. 将该元素从源序列中剔除并保持其他元素顺序不变生成新的源序列

   然后以I产生的结果序列和II产生的源序列为基础递归2)过程

3)如果源序列中元素个数不大于1,则打印结果序列+源序列

下面给出了该思路的java实现,参考[1]

public class MainClass {
  public static void main(String args[]) {
    permuteString("", "String");
  }

  public static voidpermuteString(String beginningString, String endingString) {
    if (endingString.length() <= 1)
      System.out.println(beginningString +endingString);
    else
      for (int i = 0; i <endingString.length(); i++) {
        try {
          String newString =endingString.substring(0, i) + endingString.substring(i + 1);

          permuteString(beginningString+ endingString.charAt(i), newString);
        } catch (StringIndexOutOfBoundsExceptionexception) {
          exception.printStackTrace();
        }
      }
  }
}


这个实现中endingString对应叙述中的源序列,beginningString对应结果序列,为了方便理解,我以abc为示例画了个调用图:

这个方法有两个问题:

1)每次递归时都要产生新源序列和结果序列,作为递归参数传进去,上面的实现使用java语言写的,要是用C++的话就得用传值的方式生成std::string,不断地产生和释放新串,当输入串只有几个十几个字符时当然不是什么问题了,如果字符串长度一长,估计就扛不住了。

2)同样,当字符串长度一长,递归深度成线性增加,这个也可能照成性能上的影响。

 

这个其实在百度招聘的笔试试卷上见过一次,我当时也没多想,直接给出了本文第二种方法的实现代码,结果面试时面试官说了一种比较容易懂的方法就是这种方法。我跟面试官解释了我的代码半天,好像也没解释清楚。Sigh!

 

方法二:递归+置换

我先把实现代码给出来吧:

void Swap(int *a, int *b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int Permutation(int A[], int start, intend)
{
    int n = 0;
    if (start >= end)   //结束条件
    {
        for (int i = 0; i <= end;++i)
        {
            printf("%d", A[i]);
        }
        printf("\n");
        n++;
    }
    else
    {
        for (int i = start; i <=end; ++i)
        {
            Swap(&A[start],&A[i]);
            n +=Permutation(A, start + 1, end);
            Swap(&A[start],&A[i]);
        }
    }
    return n;
}

int main(int argc, char *argv[])
{
    int A[] = {1, 2, 3, 4};

    int num =Permutation(A, 0, 3);

    return 0;
}


有点区别的是我这里用的是整数数组,不过我想和字符串数组应该是一个道理。那么怎么解释这种方法呢?两次面试都是拿着代码找解释,那个尴尬啊,这也更加坚定了我要彻底搞清楚这个问题的决心。

其核心是:将每个元素放到n个元素组成的队列最前方,然后对剩余元素进行全排列,依次递归下去。[2]这样解释起来好像还是有点不明白,我还是以abc为例画个程序图吧

这个方法的一个巧妙之处在于,每次和子序列的首元素交换,然后调用递归并返回后,重新再进行一次交换,这样就能保证在递归的某一层中,每轮循环开始第一次Swap前序列顺序与子递归返回第二次Swap后的顺序保持一致,即图中的竖直方向。这样就使得子调用递归后不会影响下一轮循环的调用。

    这种方法不需要额外创建字符串数组来保存中间信息,字符串序列也是通过引用的方式传入,因此不存在上一种方法中的第一个问题,但是由于还是使用了递归,所以当字符串过长时,还是会出现递归层次过深的问题。

    关于全排列这个问题,还有很类似的有趣的问题,就是部分排列,比如今年大摩就有这么一道笔试题:0,2,3,。。。9这十个数,从中选出4个数进行排列,打印所有可能的排列。比如:

1 2 3 4

1 2 3 5

。。。

9 8 7 6

其实就是全排列的变种,全排列是求Ann,而大摩的那道题是要求部分排列Anm,m < n,其实上面两种方法都有对应的解法,第一种只要把源序列长度结束判断条件改为endingString.length() <= (n – m) 即可;第二种,将结束条件改为start +(n - m) >= end即可。

    笔试完大摩,我同学说这个题好简单,写四个循环然后加些不等的比较判断就搞定了。嗯,确实,如果仅仅就那个题来说,确实可以,但是如果不是选4个数,而是选6个,8个呢?一旦n和m变大,这种直接写循环的方法就行不通了,因此我们必须考虑如何去写这个循环,使得代码不依赖m和n,这也是EMC面试官给我出的一个难题,很遗憾,我没有当场做出来,只能回来考虑了。

方法三:非递归循环

    估计这个方法就是面试官想要的方法了,还记得他一直在问,如何动态控制循环层数,如何判断循环结束等等问题,我也是模模糊糊地回答了下,其实根本就不知道,呵呵。回来考虑了很久,还是没头绪,直到看了[3],才明白到底是怎么回事,哎,我的资质确实不怎么样。还是先看看别人的算法流程吧:

1、用一个数组 repeat_cnt[] 来保存每层的循环变量:repeat_cnt[0]保存第 0 层循环变量的位置,repeat_cnt[1] 保存第 1 层循环变量的位置......repeat_cnt[n-1] 保存第 n-1 层循环变量的位置
2、标记当前正在第几层循环:layer
3、集合长度已知 :n = strlen(s)
4、临时数组:tmp_seq[],长度为 n,用于存储一个可能全排列的序列
3、算法描述如下:
循环体(layer == 0 且 repeat_cnt[0]== size  则退出循环)
{
      如果(前 n-1 层)
      {
            取出该层循环变量:pos=repeat_cnt[layer]
            如果 (pos 到达该层末尾,即pos==size)
            {
                  repeat[layer]= 0//该层循环变量归零
                  layer--//回到上一层
                  continue
            }
            否则
            {
                  tmp_seq[layer]= s[pos]
                  repeat_count[layer]++//该层循环变量增加1
                  layer++//层数增加 1
                  continue
            }
      否则(在最内层)
      {
            不停改变 tmp_seq 最后一个元素,每改变一次,就得到一种新的组合,该层循环变量增加1
            当最内层也到达 s 末尾:将该层循环变量归零,回到上层
      }
}

下面是将原作者用Python编写的 permutation 函数改成了C++版本的,对于C/C++程序员来说肯能更直接一些,该函数接受两个参数
第一个参数:要全排列的字符串

第二个参数:选几个数排列,默认全排序
第三个参数:是否允许重复,默认不允许

 

void permutation(char* s, int m = 0, boolduplicate = false)
{
    int n = strlen(s);
    if(n < 1 || m < 0 || m > n)
        return;

    if(m == 0) //默认全排列

        m =n;

    int *repeat_cnt =new int[n](); //循环计数器,一层一个,共 n  个,都初始化为 0
    char *tmp_seq = new char[n]();  //存放排列结果序列的空间
    int layer =0;                  //当前正在循环层

    intpos;                        //本循环层的层计数器

    while(true)
    {
        if(layer == 0 &&repeat_cnt[0] >= n) //当前在第 0 层,且第 0 层计数器达到末尾时退出循环
            break;
        if(layer < m -1)           //小于 n-1 层
        {
            pos =repeat_cnt[layer];
            if(pos<n)             
            {

                //层计数器 <n,保存层计数器指向的元素,计数器前进一位,增加一层
                if(!duplicate)
                {

                    //不允许重复

                    boolfound = false;
                    for(inti = 0; i < n; ++i)
                    {
                        if(pos+ 1 == repeat_cnt[i])
                        {
                            found= true;
                            break;
                        }
                    }
                    if(found)
                    {

                        //检查到重复,计数器前进一位,继续
                        repeat_cnt[layer]= repeat_cnt[layer] + 1;
                        continue;
                    }
                }
                tmp_seq[layer]= s[pos];
                repeat_cnt[layer]= repeat_cnt[layer] + 1;
                layer++;
            }
            else
            {

                //否则,层计数器归零,向上一层
                repeat_cnt[layer]= 0;
                layer--;
            }
        }
        else
        {

            //第 m - 1 层:计数器每移动一个位置,都是一种组合
            pos =repeat_cnt[layer];
            if(pos< n)
            {
                if(!duplicate)
                {

                    //不允许重复
                    boolfound = false;
                    for(inti = 0; i < n; ++i)
                    {
                        if(pos+ 1 == repeat_cnt[i])
                        {
                            found= true;
                            break;
                        }
                    }
                    if(found)
                    {

                        //检查到重复,计数器前进一位,继续
                        repeat_cnt[layer]= repeat_cnt[layer] + 1;
                        continue;
                    }
                }

                tmp_seq[layer]= s[pos];

                //找到一种组合,将其打印出来

                for(inti = 0; i < n; ++i)
                    printf("%c",tmp_seq[i]);
                printf("\n");

                //层计数器前进 1 位               

                repeat_cnt[layer]= repeat_cnt[layer] + 1;
            }
            else
            {

                //n-1层计数器到达末尾,将层计数器归零,返回上层
                repeat_cnt[layer]= 0;
                layer--;
            }
        }
    }

    delete[]repeat_cnt;
    delete[] tmp_seq;
}

这种方法和前面两种方法进行比较,不难发现,首先,它不需要额外分配太多的空间,仅仅在堆中分配O(n)的辅助空间,然后一直重用直到调用结束,因此空间利用率很高,其次,它没有递归调用,也不需要函数调用栈开销,当字符串长度很长时,也不会出现方法一中的两个问题,所以这种方法的优势还是很明显的,唯一一个缺点就是这种方法可能不是那么容易理解。关于循环层数的动态控制,《编程之美》上有一道类似的题[4],手机上每个数字对应0个或多个字母,给定一个数字数组,打印所有可能的字符串组合。比如下面的字符串数组就描述了这种对应关系,0和1没有对应的字母,2~9每个数字可能对应3~4个不等字母中的一个。

char *c[10] = {
    "",
    "",
    "ABC",
    "DEF",
    "GHI",
    "JKL",
    "MNO",
    "PQRS",
    "TUV",
    "WXYZ"
};

我们假设输入的数字数组不可能有0或1这两个数字(因为即使有这两个数字,对应的字符也为空),比如我们给一个例子,输入为数字串为42,那么4可能对应GHI中的一个,2可能对应ABC中的一个,那么输出就为:

GA

GB

GC

HA

HB

HC

IA

IB

IC

这个例子很简单,直接使用二重循环就能写出来,但是当输入数字串的长度增加时,如何控制循环层数呢?

我先给个递归的解法吧:

char s[20]; //s用于保存结果字符串

//返回值是所有可能组合的数目

//第一个参数是输入数字串首地址

//第二个参数是输入数字串长度,也就是对应最后结果字符串的长度

//第三个参数表示当前正在处理串中第几个数字

int recursive_search(int number[], int N,int k)
{
    int cnt = 0;
    if (k  == N) //已经处理完最后一个数字,打印结果然后返回一个可能的组合
    {
        s[N] = '\0';
        printf("%s\n", s);
        cnt++;
        return cnt;
    }

    char *p =c[number[k]]; //处理数字串第k个数字

    while(*p)  //遍历该数字可能对应的字母
    {
        s[k] = *p; //逐个字母赋值
        cnt += cal_str(number, N, k+1);//递归处理第k+1个数字
        p++;
    }

    return cnt;
}

 

下面是微软的人给的一种非递归方法:

int nonrecursive_search(int number[], intN)
{
    int total[10] = {0, 0, 3, 3, 3, 3, 3, 4, 3, 4};
    int answer[100] = {0}; //这个就类似于层循环计数器,我们假设输入数字串长度不超过100
    int cnt = 0;  //统计有多少个可能组合,其实可以直接算出来的,不需要累加,这里就是为了方便理解

    while (true)
    {
        for (int i = 0; i < N; ++i)
            printf("%c",c[number[i]][answer[i]]);
        printf("\n");
        cnt++;

        intk = N - 1;
        while (k >= 0)
        {
            if(answer[k] < total[number[k]] - 1)
            {
                answer[k]++;
                break;
            }
            else
            {
                answer[k]= 0;
                k--;
            }
        }

        if(k < 0)
            break;
    }

    return cnt;
}

我第一次看这段代码时,也没怎么看明白,后来发现这当中包含了一种“进位”的思想,一开始循环计数器变量数组answer全部都是0,这是可以看成值最小的一种组合,然后从最后一个循环计数器开始,进行递增,如果发现该位到达上限,那么就把该位置零,然后转到前面一位(高位)进行递增。每次都从最低位开始递增循环计数器,且保证每次的计数器数组都是在上一次的基础上增一,有点像数数那样。直到最后,进位溢出,最后answer数组中的各计数器又都变为0。

    我们开可以将这段代码按照前面的非递归循环permutation的思路改写如下:

int cal_str_nonrecursive2(int number[], intN)
{
    int total[10] = {0, 0, 3, 3, 3, 3, 3, 4, 3, 4};
    int repeat_cnt[100] = {0}; //这个就类似于层循环计数器,我们假设输入数字串长度不超过100
    int cnt = 0;  //统计有多少个可能组合,其实可以直接算出来的,不需要累加,这里就是为了方便理解
    int layer = 0;
    char s[100];  //用来存放可能的结果字符串
    int pos;

    while (true)
    {
        if(layer == 0 &&repeat_cnt[0] >= total[number[0]])
            break;

        if(layer< N - 1)
        {
            pos =repeat_cnt[layer];
            if(pos< total[number[layer]])
            {
                s[layer]= c[number[layer]][pos];
                repeat_cnt[layer]= repeat_cnt[layer] + 1;
                layer++;
            }
            else
            {
                repeat_cnt[layer]= 0;
                layer--;
            }
        }
        else
        {
            pos =repeat_cnt[layer];
            if(pos< total[number[layer]])
            {
                s[layer]= c[number[layer]][pos];

                repeat_cnt[layer]= repeat_cnt[layer] + 1;

                for(inti = 0; i < N; ++i)
                    printf("%c",s[i]);
                printf("\n");               
                cnt++;

            }
            else
            {
                repeat_cnt[layer]= 0;
                layer--;
            }
        }

    }

    return cnt;
}

这种写法比微软的那个写法多用了一个用来存放结果字符串的数组s[],这个数组是不是必须得要呢?嗯,需要考虑下。其实不需要,可以删掉上述代码中和s相关的代码行,然后将打印语句修改为:

printf("%c",c[number[i]][repeat_cnt[i] - 1]);

有兴趣的可以稍微想想。你甚至可以想出前面permutation函数中,不需要tmp_seq[]数组的方法。

方法四:字典序法 

上面的三种方法都是基于位置变换的方法,也就是说,不管字符串中的字符的具体值到底是什么,因此,如果输入的字符串中有重复的数值时,输出将会重复。考虑输入为aab的字符串,如果按照前面的三种方法,将会输出六个全排列,单实际上,如果要求结果不重复时,只有一下三个全排列:

aab

aba

baa

    那么怎么去掉重复的排列呢,最简单的方法就是维护一个集合,然后实时更新并判断排列是否在其中,但这麽做还是有些不好的,既耗空间也耗时间。如果使用字典序法进行全排列的话,就能够很好地解决这个问题。

    字典序的思路是这个样子的,以abcde为例,对于输入的字符串,我们先对其进行排序,假设按升序排列成abcde,我们把这个定义为最小的一个排列,然后下一个递增的全排列是abced,再下一个是abdce,然后是abdec。。。这个递增的过程可以如下描述,参考[2]:

设P是1~n的一个全排列:p = p1p2...pn = p1p2...pj-1pjpj+1......pk-1pkpk+1...pn

1)从排列的右端开始,找出第一个比右边数字小的数字的序号j(j从左端开始计算),即j = max{i | pi < pi+1}

2)在pj的右边的数字中,找出所有比pj大的数中最小的数字pk,即 k = max{i| pi < pi+1}

(右边的数从右至左是递增的,因此k是所有大于pj的数字中序号最大者)

3)对换pj,pk

4)再将pj+1......pk-1pkpk+1...pn倒转得到排列p' = p1p2...pj-1pjpn......pk+1pkpk-1...pj+1,

这就是排列p的下一个排列。

例如839647521是数字1~9的一个排列。从它生成下一个排列的步骤如下:

自右至左找出排列中第一个比右边数字小的数字4;

在该数字后的数字中找出比4大的数中最小的一个5;

将5与4交换得到839657421;

将7421倒转得到839651247;

所以839647521的下一个排列是839651247。

附下原作者写的代码:

#include <iostream>
#include <algorithm>
#include <string>

using namespace std;

template<class Iter>
bool my_next_permutation( Iter start, Iter end )
{
     Iter j = end; //STL中end指向最后一个元素的后面
     j—; //现在i指向最后一个元素

     for( ; ; )
     {
         Iter j1 = j; //从最后一个元素开始向前搜素
         --j; //j指向倒数第二个元素
        //find the first changable pair
        if( *j < *j1 )
        {

            //找到了第一个满足条件的i
            //findbiggest on the right of ii
            Iter k= end;
            while (!( *j < *--k ) )
            {}
            //IMPORTANT!
            //notswap i and ii, but i and j
            iter_swap(j, k );
            //IMPORTANT!
            reverse(j1, end );
            returntrue;
         }

         if(j == start )
         {
             reverse(start,end);
             returnfalse;
         }
     }
}

int main()
{
    string str = "abcde";
    do
     {
         cout<<str<<endl;
     } while( my_next_permutation( str.begin(),str.end() ) );
}

以aab为输入分析,输出为

aab

aba

baa

确实能去掉重复的排列。关于重复的排列,我还想到了这样一个问题:要从n个数中取出m个数进行排列(m<n),打印出所有不同的排列,其中n个数中可能有若干个数相同。想到了一个最一般化的方法:先从n个数中取出m个数进行组合,并保证每个组合的唯一性,然后对每一个组合进行按字典序全排列。如何从n个数中选m个数进行全排列并保持其唯一性,有空我再研究下吧。

 

后记: 

    求全排列的方法真的有很多种,而且有些方法确实很巧妙,这也是一个很锻炼思维的题目,所以很多公司把它作为笔试或面试的题目之一,来考察候选者思考问题的过程,有的面试官甚至要求使用不同的方法。关于这个题的难度,我想大概和之前提到的编程之美上的那个电话号码对应英语单词的题目难度差不多,大概是三个星,三颗星的难度又是多少,用编程之美序上的话说“三颗星:需要查阅一些资料,在60分钟内完成。”。所以,如果面试官要你说出思路并写出实现代码,如果你之前没有接触过类似的问题,是很难回答上来的。如果真的能回答上来并给出巧妙的解法,那真的就是人才了。

 

 


0 0
原创粉丝点击