字符串全排列问题分析

来源:互联网 发布:男生喜欢手办恶心知乎 编辑:程序博客网 时间:2024/05/16 17:49

今天晚上和一个同学讨论了下字符串全排列的问题,发现了一些自己没有发现过的点,感觉这个问题虽然简单,但是其实要写的漂亮,其实还是有一些难度,要解决整个问题需要经历的分析过程其实还是挺漫长的。所以就做一个小结,把一些其他的知识点也串上,讲讲我是如何去分析这个问题的。


问题描述

字符串全排列问题
输入:一个非空字符串,例如“abc”,“a”,“abcdefg”。
输出:这个字符串所有不重复的排列,例如对于“abc”,就有“abc”,“acb”,“bac”,“bca”,“cab”,“cba”,这六个。

问题分析

首先看我们给出的求解思路:

状态与解的描述

这里我们先说明一下状态,状态可以简单理解成我们的“解”,但是,需要注意的是,这个解可能是一个完全解,也可以是一个部分解。这东西的定义对我们的求解分析有什么作用?我们来一点点分析。

对于我们的全排列问题,我们使用下划线来表示进行全排列的字符串的范围,例如,“abcdef”表示需要对整个字符串进行排序,而“abcdef”则表示需要对后4个字符进行全排序,前2个字符不需要排序。
这种带有下划线的字符串就是我们的一个状态。

那为什么这种表示可以说它是一个“解”,我们看下图,对于字符串“abc”的全排列,我们可以是看成是,分别以a,b,c开头的三个排列问题,即“abc”,“bac”,“cba”,这样不断的分解下去,我们可以发现整个树的最底层其实就是我们所希望的解。

将整个树的非叶子节点看成不分解,叶子节点看成是完全解,两者的并集就是我们所描述的所有状态。



注:我自己也不知道这样描述状态是不是准确,因为状态这玩意玄之又玄,怎么说都感觉不在着力点上。

如何求解

知道了上述的这个图,那求解的思路就很自然了,只要遍历这棵树即可,这里需要注意的两点就是:
这里必须遍历这棵树,因为完全解处在所有叶子节点上,没有走完所有叶子节点,无法获取到所有的解。
这里也不可能使用递推的思路去求解这个问题,因为我们的起点只可能在树的根部,无法直接跳跃到叶子,当然,反过来思考,如果我们可以字节获得叶子节点,何必需要这个树呢(当然,有些强词夺理了)。

状态转换

有了上述的分析,我们接下来面对的问题就是,如何去遍历这棵树了。
遍历树,能够想到的无非就是所谓的什么先序遍历、中序遍历、后续遍历这些。这些技术说白了都是基于递归技术实现,所以我们第一反应就是使用递归去遍历这棵树。
但是问题在于我们事先是没有构建好这棵树的,所以这个现在问题的关键点就变成了如何从一个节点跳转到另外一个节点,也就是不同的状态间是如何转换的。
这里的解决方法可以看下图,我们通过一个swap操作,就可以做到了,图中只画了其中的一层,其他层次之间的转移操作也是一样的。
所以,我们这里的状态转移开销=交换两个元素所带来的开销



如何用代码描述状态

认真观察上面的图,我们很容易发现,每个状态我们可以分解成两个部分”不带下划线的字母串+带有下划线的字母串“,两者之间可谓泾渭分明,所以我们可以只使用一个下标值表示这两者的分界线即可。
所以设定i为首个下划线字母在整个字符串中的下标值。所以我们的函数就可以写成:

void wholeArrangement(char a[], int i);

对于这个函数,我们可以这样理解,我们需要对字符串a,从第i位开始的子串进行全排列,输出的结果就是以字符串a中第0~i-1个字符为前缀,以第i~n字符所表示的所有全排列。
这样,当i设置为0时,输出的就是真个字符串a的全排列。

基本代码模型

通过上述的分析,只要是对递归模板有所了解,我们不难得到如下的代码:
#include <algorithm>#include <iostream>using namespace std;void wholeArrangement(char a[], int idx) {    // 剩余的子字符串已经没有,即到达了树叶子节点处    if (a[idx] == '\0') {        cout << a << endl;        return;    }    for (int i = idx; a[i] != '\0'; i++) {        swap(a[idx], a[i]); // 从父状态到子状态转换        wholeArrangement(a, idx+1); // 递归求解        swap(a[idx], a[i]); // 从子状态到父状态转换    }}int main() {    char c[] = "abc";    wholeArrangement(c, 0);}

代码调优

如果不考虑要排除重复的字符串,则上述的代码已经接近我们问题的尾声了,剩下的最后一步就是将递归代码转换成非递归代码,实现最后一步的调优工作。但是问题还是没有这么简单,接下来我们就要开始使用各种手段去分析这个问题中哪些问题我们没有考虑到。

重叠子问题(1)

我们来观察使用上述代码求解这样的一个字符串的全排列得到的结果输出:
输入:”aa
输出:”aa“,”aa“
明显,我们发现我们得到了一个重复的子串,通过画出上述字符串的状态转移图(下图),我们就很容易看到原因了。



接下来的问题,对于这种问题,我们需要做的事情就是对这棵树可能出现重复解的分支进行裁剪,即所谓的”剪枝“,那接下来的问题就是,我们如何这样需要进行裁剪的一个分支。我们加入一个下标值,用于区分相同的字符。
例如:对于字符串”aba“,我们描述成”a1b1a2“。这样对于这个字符,我们画出的状态转移图如下,我们就可以发现,当交换两个相同的a的时候,其实获得的子状态是一样的,这也就意味着它们的子树也是一样的,得到的解也是一样的。所以对于这种分支,我们可以将其进行裁剪。
这样,我们就得到了对于这类重叠子问题的一个简单描述:当两个字符相同时,通过交换其位置获得的子状态其实与不交换情况下得到的状态是一样的,即解是一样的。


所以,通过剪枝操作,我们修改后的代码就变成了:
#include <algorithm>#include <iostream>using namespace std;void wholeArrangement(char a[], int idx) {    if (a[idx] == '\0') {        cout << a << endl;        return;    }    for (int i = idx; a[i] != '\0'; i++) {        // 跳过重叠子问题,达到剪枝的目的        if (i != idx && a[i] == a[idx]) {            continue;        }        swap(a[idx], a[i]);        wholeArrangement(a, idx+1);        swap(a[idx], a[i]);    }}int main() {    char c[] = "aba";    wholeArrangement(c, 0);}

重叠子问题(2)

这样,我们问题考虑完全了么?还没有哦,还有一个问题没有得到考虑,我们再看下面的这组输入:
输入:”abb“
输出:”abb“,"bab","bba","bba","bab"
我们很明显,看到了两组重复的输出,我们使用之前的分析手段,重新对字符进行标记,然后再观察一下状态转移图,如下:
我们很容易发现,中间和右边两个状态虽然描述不同,但是如果进一步分解下去,其实他们获取到的解是一样的,造成这个的原因就是:
一个字符串的全排列与初始问题是字符的排列顺序无关。


解决的办法还是对其进行剪枝,不过在转换成代码的时候遇到了一个问题,我们如何才能判断两种并不相同的状态的解是一样的呢?
通过观察,这里具有相同解的两个子树的根状态中具有的”前缀“是一样的,即他们必然具备相同的父节点。这个点很重要,因为这是一个很强的约束条件,不满足这个约束条件的两个状态是不需要考虑的。如果想不明白,最好多画几个图看看,盯着看就明白了。
所以,我们的的问题就变成了,一个状态的所有派生子状态中,如何确定两个状态的”后缀“具有相同的字符串,这个问题可以单独成为一个算法题进行求解,不过我们这里不进行设计,因为已经超出我们要讨论的问题的范畴,我们先使用一种最为暴力的方式进行解决,所以写出来的代码就是:
#include <algorithm>#include <iostream>#include <set>#include <string>using namespace std;void wholeArrangement(char a[], int idx) {    if (a[idx] == '\0') {        cout << a << endl;        return;    }    set<string> st;    for (int i = idx; a[i] != '\0'; i++) {        if (i != idx && a[i] == a[idx]) {            continue;        }        // 向下状态转换        swap(a[idx], a[i]);        if (i != idx) {            // 使用一个s保存后缀            string s(a+idx+1);            // 排序使得后缀的顺序不会影响检查            sort(s.begin(), s.end());            if (st.find(s) != st.end()) {                // 如果发现已经保存了相同的后缀,则恢复现场并返回父状态                swap(a[idx], a[i]);                continue;            } else {                // 保存没有出现过的后缀                st.insert(s);            }        }        wholeArrangement(a, idx+1);        swap(a[idx], a[i]);    }}int main() {    char c[] = "abb";    wholeArrangement(c, 0);}

减少状态数量,进一步优化

到目前为止,我所发现的重叠子问题已经得到解决了,还有没有暂时没发现,那到这我们就结束了么?并没有哦,其实我们还有很多可以优化的细节,这里谈到的第一个很有用的细节,我们观察下图,
很容易就发现,倒数第二层和倒数第一层的状态其实是一样的,那在这样的两个状态间进行状态的切换其实是没有必要的,如果我们可以省略这样的一次转换操作,我们就可以减少等于2倍叶子节点数量次数的状态转移开销。
要知道我们的搜索树(好吧,我承认,这其实算一个搜索问题了)的叶子数量其实是非常庞大的,能够减少一次状态的转移开销,对性能的提高都是值得的,不过需要注意,我们必须估算减少状态数量而带来的额外开销是否值得,如果为了减少状态数量而减少的状态转移开销比所增加的额外开销少,那我们的修改是得不偿失的。不过幸运的是,在我们这个代码里面是值得的(当然,我没有实际测试过,不过估算是正确的,要严格证明还是有些困难),因为我们仅仅通过增加1个简单的附加探测判断(见下面代码)


要减少这样的开销其实相对容易,修改后的代码如下:

#include <algorithm>#include <iostream>#include <set>#include <string>using namespace std;void wholeArrangement(char a[], int idx) {    // 通过提前探测下一层是否为叶子节点而减少状态数量    if (a[idx] == '\0' || a[idx+1] == '\0') {        cout << a << endl;        return;    }    set<string> st;    for (int i = idx; a[i] != '\0'; i++) {        if (i != idx && a[i] == a[idx]) {            continue;        }        swap(a[idx], a[i]);        if (i != idx) {            string s(a+idx+1);            sort(s.begin(), s.end());            if (st.find(s) != st.end()) {                swap(a[idx], a[i]);                continue;            } else {                st.insert(s);            }        }        wholeArrangement(a, idx+1);        swap(a[idx], a[i]);    }}int main() {    char c[] = "hello";    wholeArrangement(c, 0);}

减少状态转移开销,进一步优化

我们还有优化的空间么?是的,还有,搜索算法的优化手段很多,其中一种就是通过减少状态转移开销来获取性能上的提升。接下来我们就来观察一下我们如何才能减少状态转移开销?
我们观察下图,对于每个非叶子状态,其进入其最左子树的状态的时候,所做的交换是将“后缀”字符串中的第一个字符与自己进行交换。这里的开销可以减少多少?减少的开销=2倍的非叶子节点的转移开销。
这个开销其实还是比较可观的。

自己与自己交换,明显就是一个浪费的操作,所以我们完全可以减少这样的一次转移开销,当然,进入其他节点的时候并不能减少,所以我们并不能够通过修改转移函数(即交换操作)来进行,而是通过调整代码的语序来进行修改。
所以修改后的代码就变成下面这样:
#include <algorithm>#include <iostream>#include <set>#include <string>using namespace std;void wholeArrangement(char a[], int idx) {    if (a[idx] == '\0' || a[idx+1] == '\0') {        cout << a << endl;        return;    }    // 通过提前求解最左子树,减少进入该子树的开销    wholeArrangement(a, idx+1);    set<string> st;    // 这里就可以从第二个子状态开始求解    for (int i = idx+1; a[i] != '\0'; i++) {        // 这里也减少了一个判断语句        if (/*i != idx && */a[i] == a[idx]) {            continue;        }        swap(a[idx], a[i]);        //if (i != idx) { 这里的判断开销也可以省略        string s(a+idx+1);        sort(s.begin(), s.end());        if (st.find(s) != st.end()) {            swap(a[idx], a[i]);            continue;        } else {            st.insert(s);        }        //}        wholeArrangement(a, idx+1);        swap(a[idx], a[i]);    }}int main() {    char c[] = "ello";    wholeArrangement(c, 0);    cout << endl;}

构造返回解

最后,我们来看看,如果不希望仅仅是打印解,而是希望将所有的结果保存,那又要怎么做,毕竟这是我们平时写代码最常见的方式,很多时候需要返回结果并不是一件容易的事情。不过,在这个问题中,还是相对容易解决的
修改后的代码如下:
#include <algorithm>#include <iostream>#include <set>#include <string>#include <vector>using namespace std;void wholeArrangement(char a[], int idx, vector<string> &ret) {    if (a[idx] == '\0' || a[idx+1] == '\0') {        ret.push_back(a);        return;    }    wholeArrangement(a, idx+1, ret);    set<string> st;    for (int i = idx+1; a[i] != '\0'; i++) {        if (a[i] == a[idx]) {            continue;        }        swap(a[idx], a[i]);        string s(a+idx+1);        sort(s.begin(), s.end());        if (st.find(s) != st.end()) {            swap(a[idx], a[i]);            continue;        } else {            st.insert(s);        }        wholeArrangement(a, idx+1, ret);        swap(a[idx], a[i]);    }}int main() {    char c[] = "ello";    vector<string> ret;    wholeArrangement(c, 0, ret);    vector<string>::iterator itr = ret.begin();    for (; itr != ret.end(); itr++) {        cout << *itr << endl;    }}

最后的优化(?)我们能不能转换成非递归实现

在之前的所有分析过程中,我们一直在忽略一个状态的转移开销,这个开销和算法本身并没有关系,而纯粹是因为使用递归函数造成的,这也是所有使用递归函数实现算法时候必须面对的一个问题:
在使用递归函数实现的时候,我们必须正视递归过程产生的开销,这个开销的数量=整棵树的大小-1(即边的数量)。

因为我们实现的这个算法明显是一棵树形递归,很容易理解,因为我们画出来的状态转移图就是一棵树,所以肯定是树形递归。

所以我们可以使用树形递归转非递归的实现技巧(其实就是用栈进行辅助)。

不过我想了很久,这个非递归代码,我无论怎么想,结果写出来的代码开销都要比递归实现的开销要大。

这是因为,使用栈进行辅助的时候,栈中需要保存输入的问题及其边界,对于很对递归问题,问题输入并不发生改变,改变的仅仅是其边界条件。

而在我们这个代码实现中,我们不仅边界发生了改变,而且每次输入的问题也发生了改变。

这样我们的栈不仅需要缓存所有的边界,而且还需要缓存对应的问题。而每次拷贝这个问题,会另开销反而比递归实现时更大,所以最后反而没有得到优化。


尾声

获取一个字符串的全排列或许是一个很简单的问题,不过感觉要做的好也不容易,而且到最后甚至还出现了另外的一个算法问题。

整个过程,我用上了几乎我所知道的与搜索相关的优化技巧,对于如何描述这个过程,我也尽量一步步分析,说明我写每行代码的依据,尽量不让一行代码的出现显得有些突兀。






原创粉丝点击