白话算法(7) 生成全排列的几种思路(一)
我在黄昏时坐在地球上
我这样说并不表明晚上
我就不在地球上
——海子 《明天醒来我会在哪一只鞋子里》思路1:加大搜索空间,使用评估函数找到解
“如果在可能的地方找不到,就去不可能的地方找。”这是一句废话,也是一句挺有道理的话,甚至有时候,它还是一句有用的话。因为生活中有时真的会有神奇的事情发生。元宵节的前一天晚上,我和一位同事加班到10点半,正准备回家的时候,突然发现大事不妙——办公室唯一的一把钥匙找不到了——没有钥匙就没办法锁门,我们回不去家了。办公室是客户借给我们的,备份钥匙只有客户有,但是这么晚了实在不好意思打电话求救。钥匙是我几个小时前放在同事的桌子上的,却不知被同事随手扔到哪里去了。桌子上,抽屉里,所有的包包和衣服兜全找过了,就是不见钥匙的踪影。现在我们有3种选择:1)放弃。在办公室里睡到天亮或者不锁门就回家(但是那一屋子的电脑还有服务器实在让人放心不下);2)再找一遍。不过已经找了2遍了,再原样重复一遍结果也不会有什么改变。3)去不可能的地方找。我决定碰碰运气。结果,5分钟之后,我居然在桌子上一个装着汤圆的袋子里找到了那把已经冻得冰凉的钥匙。
如果没办法直接取得沙子里的金子,就把沙子和金子一同捞起来,再用水慢慢淘去沙子,留下砂金。如果没有办法直接求解,可以先找到一个虽然大一些但是比较容易取得的包含所有解的搜索空间,再使用评估函数判断搜索空间里每一个潜在解的质量。
现在我们想求得["a","b","c"]的全排列,也就是想要得到解(为了看着舒服省略了引号):
[[a,b,c],
[a,c,b],
[b,a,c],
[b,c,a],
[c,a,b],
[c,b,a]]
数组的下标的排列是:
0 1 2
0 2 1
1 0 2
1 2 0
2 0 1
2 1 0
有没有什么方法可以由“0 1 2”经过某种计算得到下一个下标的排列“0 2 1”呢?如果把 012 看作是一个3进制的数字,那么把 012 加 1 就得到了 021。但是,把 021 加 1 得到的是 022 而 不是 102。所以,用这种方法比较容易得到可重复的排列,从000开始,每次增加1可以得到:
0 0 0
0 0 1
0 0 2
0 1 0
0 1 1
0 1 2
0 2 0
0 2 1
0 2 2
1 0 0
1 0 1
1 0 2
1 1 0
1 1 1
1 1 2
1 2 0
1 2 1
1 2 2
2 0 0
2 0 1
2 0 2
2 1 0
2 1 1
2 1 2
2 2 0
2 2 1
2 2 2
其中绿颜色表示的是我们想要的结果,我们只要把它们挑出来就好。
如果把 currentIndexList 看作是一个 currentIndexList.Count 进制的数字,GetNextIndexList() 函数的作用是把这个数字增加1:view sourceprint?// 获取 currentIndexList 的下一个排列。例如 currentIndexList = [0,1,1],调用此函数之后,
// currentIndexList 将变为 [0,1,2];[0,2,2] 变为 [1,0,0]
static void GetNextIndexList(IList<int> currentIndexList)
{
int count = currentIndexList.Count;
for (int i = count-1; i >= 0; i--)
{
if (currentIndexList[i] < count - 1) // 不需要进位
{
currentIndexList[i]++; // 当前位加1
return;
}
else
{
currentIndexList[i] = 0; // 进位前把当前位置0
}
}
}
接着,我们只要调用 GetNextIndexList() 函数 nn 次(n=source.Count),就可以遍历整个搜索空间,然后再判断是否所有下标都不重复就可以知道是不是想要的解了:view sourceprint?// 生成 source 的全排列。
static IEnumerable<IList<string>> Permutation1(IList<string> source)
{
// 初始化 indexList,使它具有 source.Count 个值为0的元素。
int count = source.Count;
IList<int> indexList = new List<int>();
for (int i = 0; i < count; i++)
{
indexList.Add(0);
}
for (int i = 0; i < Math.Pow(count, count); i++)
{
GetNextIndexList(indexList);
if (indexList.Distinct().Count() == count) // 不重复
{
IList<string> result = new List<string>();
foreach (int j in indexList)
{
result.Add(source[j]);
}
yield return result;
}
}
}
测试一下:view sourceprint?IList<string> s = new List<string> { "a", "b", "c" };
foreach (IList<string> item in Permutation1(s))
{
Console.WriteLine(item.Montage(t => t, " "));
}
注:Montage()是一个方便把列表拼接成字符串的扩展方法。这种方法的缺点是效率比较差。要生成n个元素的全排列需要遍历 nn 次才能得到 n! 个解(看上面那个绿色和黑色的下标的比例也可以有一个直观的感觉)。
由于这种方法可以根据当前下标计算出下一个下标,所以可以使用 yield return “按需生成”所需要的全排列,如果只需要得到前面几个而不是所有的全排列,会很划算。思路2:一致,一致,一致!
第一种方法虽然简单,但是效率太差。能不能每次不是加1,而是加n,一下子就得到下一个排列呢?你可以亲自尝试一下,要想知道每次应该加多少真的很难。我们只好换个思路,能否通过交换数组元素得到下一个排列呢?譬如有数组 s = ["A", "B", "C", "D"],只要交换 s[2] 和 s[3] 就可得到下一个排列 A B D C。但是要得到下一个排列 A C B D 需要先交换 s[1] 和 s[3],再交换 s[2] 和 s[3]……有时只需交换一次,有时需要交换两次,有时甚至需要交换3次。看图找规律神马的最讨厌了。但是如果不能找出一个一致的处理方法,就没法编写程序。
从哪里着手呢?当问题难以直接求解时,如果可以先得到一个间接结果,一个可跟踪的线索,问题往往会变得简单很多。
所以我们可以先试试能否找出一个一致的方法来确定第一次要交换的是哪两个元素。我们先使用常规办法:观察一个样本(例如ABCD的所有排列),看看是否能找到什么规律。但是这一次恐怕要失望了:第一次要交换的两个元素有可能是相邻的,也有可能不相邻;有可能是最大(或最小)的元素,也可能不是;有可能是第一个(或最后一个)元素,也有可能不是……除了前一个元素一定比后一个小之外,似乎难以发现其他规律。
好像要卡住了。我们换个思路——反着想:“什么情况下无法通过交换两个元素来得到下一个排列?”为什么这个问题有意义呢?让我们回想一下目标:得到下一个(字典序的)排列。“下一个(字典序的)排列”的定义是:只比前一个排列大一点点的那个排列。第一个排列(例如 A B C D)是最小的那个排列,它一定是最小的元素在最高位、最大的元素在最低位。当我们想得到 A B C D 的下一个排列时,一定是优先交换它的最低位的那个元素(也就是 C 和 D),这样才能保证得到的是仅比当前排列大一点点的那个排列。只有无法交换最低2位来得到下一个排列时,才会考虑交换更高位的元素(例如想得到 A B D C 的下一个排列时,就不能交换 D 和 C)。
那么,什么情况下无法通过交换两个元素来得到下一个排列呢?答案是,当子数组已经是最后一个排列时。例如,当前排列是 A B D C 时,因为 “D C” 这个排列是子数组 [C,D] 的最后一个排列,所以交换 D C 的任意两个元
补充:综合编程 , 其他综合 ,