在杨修的那些颇具传奇色彩的小故事里面,有一个是最能体现他是如何的聪明,却又没有用到正地方的,说的是杨修身为曹操主簿,却又不肯老老实实坐在办公室里,老想溜出去玩,可是又怕曹操有问题要问,于是每当外出时,都要事先揣度曹操的心思,写出答案,按次序写好,并吩咐侍从,如果丞相有令传出,就按这个次序一一作答,结果每次都严丝合缝,没出过一点差错。可是后来有一次吹来一阵风,把纸张的顺序弄乱了。侍从按乱了的次序作答,自然文不对题,结果露了馅。
不过我怎么都不觉得这是小聪明。要我说,这简直就是奇迹。即使是曹操问一题、杨修答一题的模式,能够全部回答出来就已经算得上是很聪明、很有才了。但是,杨修所做的事情却是,他不但可以预先判断出问题的内容,就连问题的数量和顺序也能丝毫不差地预先判断出来,这可就有些神了。如果杨老师给高考押题的话,那就不叫押题,那看上去根本就是泄题,估计也是难逃一死。
算法设计的一个常用思路
但是身为程序员,却必须时不时地扮演一下杨修。不信?准备接招:实现一个函数“int[] Sort(int[] s)”;输入:一个长度不确定、元素不确定、顺序不确定的数组;输出:按从小到大的顺序排列的数组。
例如:输入:{ 3, 1 }; 输出:{ 1, 3 }。
输入:{ 5, 2, 1, 4, 6, 7, 3 }; 输出:{ 1, 2, 3, 4, 5, 6, 7 }。
我们圈里人都知道,计算机里面其实并没有一个小妖精手忙脚乱地替我们工作。如果说计算机知道怎么工作,那是因为我们程序员预先判断出了所有可能的输入情况,并且告诉计算机在每种情况下应该怎么处理。但是,我们看到,输入的元素个数是不确定的,并且是无序的,这两个特点将导致输入的状态是无限多的,所以,我们不可能像杨修那样写程序:
int[] Sort(int[] s) { if(IsArrayEquals(s, new int[] { 2, 1, 3 })) return new int[] { 1, 2, 3 } else if(IsArrayEquals(s, new int[] { 3, 1, 2 })) return new int[] { 1, 2, 3 } else if(IsArrayEquals(s, new int[] { 7, 6, 5, 9 })) return new int[] { 5, 6, 7, 9 } ... }
那么,我们如何把具有无限多个状态的无序的集合(输入)转化为有序的集合(输出)呢?(注:这里的“有序”可以不仅仅指“有顺序”,而是可以更宽泛一点,指“有一定规律”)。一个常见的思路是:
1)首先处理输入的一个子集。由于这个子集的元素个数是有限的,它的状态必然是有限的,所以我们就有可能找到一个方法把这个子集处理成有序的。
2)向这个有序的子集加入更多的元素,并保持它的有序性,直到子集扩展到整个输入。既然子集会扩展成整个输入,那么子集的元素个数必然也会是不确定(无限多)的,所以很关键的一点是,如果我们想在扩展它的同时保持它的有序性,就必须找到一个方法可以把一个元素个数不确定但是有序的集合抽象成只有有限个状态的系统。
下面让我们来看一个实例。
插入排序
插入排序算法就像我们一边抓扑克牌,一边排列扑克牌的顺序那样。
假设输入是:{ 4, 2, 5, 10, 7 }
在这里,我们定义“有序”的集合是一个“元素按从小到大的顺序排列”的集合。
输入自然是无序的。
第一步,我们要把输入分成2个子集,一个是有序的(我们给它个名字,叫L好了),一个是无序的(我们叫它R吧)。初始时,我们不妨让L={ 4 }, R = { 2, 5, 10, 7 },这样的话由于L里面只有一个元素,本身就是有序的,我们都不用再做什么处理了。
接下来,我们每次从R里面拿出一个元素放到L里面,这可能会导致L变成无序的,所以我们还要再次把L处理成有序的。现在到了关键的地方:我们怎么把L抽象成只有有限个状态的系统?(因为只有这样,我们才能在向L里放入一个新元素之后,使用有限的语句再次把L处理成有序的)。在本例中很简单,当我们把一个新元素b放入L的末尾时,我们可以把L里面小于等于b的、紧挨着的元素称为a,把大于b的、紧挨着的元素称为c,因为L是有序的,所以,此时L的状态只可能有3种:{ a, b }、{ a, c, b }、{ c, b }。对于第1种情况,L已经是有序的,不需要再做处理;对于第2、第3种情况,我们只需把b跟c交换一下位置就可以使L再次变为有序的了。
让我们把整个过程写一遍:
初始时:L = { 4 }, R = { 2, 5, 10, 7 }。
第1次迭代:L = { c, b }, 其中 c = { 4 } b={ 2 }, R = { 5, 10, 7 }。我们把b和c交换,L = { 2, 4 }。
第2次迭代:L = { a, b }, 其中 a = { 2, 4 } b={ 5 }, R = { 10, 7 }。L已经是有序的,不需要处理,L = { 2, 4, 5 }。
第3次迭代:L = { a, b }, 其中 a = { 2, 4, 5 } b={ 10 }, R = { 7 }。L已经是有序的,不需要处理,L = { 2, 4, 5, 10 }。
第4次迭代:L = { a, c, b }, 其中 a = { 2, 4, 5 } c={ 10 } b={ 7 }, R = { }。我们把b和c交换,L = { 2, 4, 5, 7, 10 }。
程序结束。
下面是插入排序的C#实现:
static int[] InsertionSort(int[] s) { for (int i = 0; i < s.Length-1; i++) // s[0..i] 是每次迭代前的 L { int b = s[i + 1]; // s[0..i+1] 是迭代后的 L int j = i; while (j >= 0 && s[j] > b) { s[j + 1] = s[j]; j--; } s[j + 1] = b; } return s; }
为方便大家调试,这里还有一个可运行的控制台版本(为使代码更简洁,使用了Lambda表达式,所以需要.NetFrameWork3.5以上环境)。
class Program
{
static void Main(string[] args)
{
Console.WriteLine(InsertionSort(new int[] { }).Montage(p => p.ToString(), ","));
Console.WriteLine(InsertionSort(new int[] { 1 }).Montage(p => p.ToString(), ","));
Console.WriteLine(InsertionSort(new int[] { 1, 2 }).Montage(p => p.ToString(), ","));
Console.WriteLine(InsertionSort(new int[] { 2, 1 }).Montage(p => p.ToString(), ","));
Console.WriteLine(InsertionSort(new int[] { 4, 2, 5, 10, 7 }).Montage(p => p.ToString(), ","));
}
static int[] InsertionSort(int[] s)
{
for (int i = 0; i < s.Length-1; i++) // s[0..i] 是每次迭代前的 L
{
int b = s[i + 1]; // s[0..i+1] 是迭代后的 L
int j = i;
while (j >= 0 && s[j] > b)
{
s[j + 1] = s[j];
j--;
}
s[j + 1] = b;
}
return s;
}
}
public static class EnumerableExtension
{
/// <summary>
/// 将列表元素拼接成由splitter分隔的字符串
/// </summary>
/// <example>
/// 拼接字符串:
/// <c>new List<string> { "aa", "bb", "cc" }.Montage(p => p, ","); // 返回:"aa,bb,cc"</c>
/// 拼接对象属性:
/// <c>new List<string> { "aa", "bbb", "c" }.Montage(p => p.Length.ToString(), ","); // 返回:"2,3,1"</c>
/// 拼接枚举值:
/// <c>new List<DomainType> { DomainType.GuanHao, DomainType.YaoJiKe }.Montage(p => ((int)p).ToString(), ","); // 返回:"1,2"</c>
/// 拼接枚举名:
/// <c>new List<DomainType> { DomainType.GuanHao, DomainType.YaoJiKe }.Montage(p => p.ToString(), ","); // 返回:"GuanHao,YaoJiKe"</c>
/// </example>
/// <typeparam name="T"></typeparam>
/// <param name="source"></param>
/// <param name="toString">将列表元素转换为字符串的委托</param>
/// <param name="splitter">分隔符(可为空)</param>
/// <returns></returns>
public static string Montage<T>(this IEnumerable<T> source, Func<T, string> toString, string splitter)
{
StringBuilder result = new StringBuilder();
splitter = splitter ?? string.Empty;
foreach (T item in source)
{
result.Append(toString(item));
result.Append(splitter);
}
string resultStr = result.ToString();
if (resultStr.EndsWith(splitter))
resultStr = resultStr.Remove(resultStr.Length - splitter.Length, splitter.Length);
return resultStr;
}
}
更快的排序算法?
插入排序的最坏情况(譬如输入是{5,4,3,2,1})时间代价为Θ(n2)。是否还有更快的排序算法呢?从直觉上,我们可以发现插入排序有2个地方有些浪费性能。
1)每次迭代时,我们需要根据b把L分成2个子集:a和c。这里a和c的定义分别是“所有元素小于等于b”和“所有元素大于b”,而并不要求a和c本身一定是有序的,所以我们一开始的时候就把a和c处理成有序的是不是有些浪费呢?
2)每次迭代时,我们需要根据b把L分成2个子集:a和c。插入排序在划分a和c时使用的方法是逐一比较L里面每个元素和b的大小,是否有更快的划分a和c的方法呢?
接下来的几篇,我们将介绍一些更快的排序算法和理解这些算法的思路。
注:开始的几篇我们会先将重点放到理解算法的思路上面,而不会介绍如何证明算法的正确性和分析算法性能。但是事实上它们是同样基础、重要和有用的知识。想想看,如果万一有一天我们发明了一种新的排序算法,如果没有这些知识,我们如何证明新算法在理论上是正确的,又如何评估新算法是世界上最快的还是第二快的算法呢?而且学习这些内容也有助于理解为什么某些算法性能更好。所以以后有机会一定会和大家一起认真地学习一下这部分内容。