David Berlinkshi说:有两种思想,象珠宝商放在天鹅绒上的宝石一样熠熠发光,一是微积分,另一个就是算法。如果说微积分及在其基础上建立的数学分析体系造就了现代科学,而算法则造就了现代世界。
算法是计算机科学的灵魂,更是每个程序员和软件工程师必需具有的核心知识。区分一个好的软件工程师和一个代码磨工(coder)的关键就在于看其是否能够分析并设计出高效率的算法。正如爱因斯坦所说的,并非所有能计算的东西都有价值。能计算的算法,也不一定是有价值的。除正确性外,算法的效率对一个程序而言至关重要。很多时候O(2n)和O(n)的差别不是时间上的快慢,而是整个算法的可行与不可行。
算法有很长久的历史,无数古代经典的算法,如求最大公约数的欧拉算法依然广泛使用。尽管不同的算法在逻辑结构、数据结构上有着很大的差别。但其解决问题的思想是一致的。下面简单介绍一下各种算法的思想,当然不可能仅通过任何一遍文章来掌握或理解算法的思想,这篇文章的目的是希望给出一个鸟瞰,希望还没接触算法的你尽快走进这个有趣而具有挑战性的领域。如果想详细学习推荐一下两本书。其中第二本是主要讲数据结构的,但它的每一段代码的编写都蕴含着算法分析的思想。并且算法与数据结构是紧紧联系在一起的,数据结构作为算法的副产品和最终产品而存在。(程序=算法+数据结构)
Introduction to The Design and Analysis of Algorithms
Data Structures and Algorithms with Object-Oriented Design Patterns in C++
* 如果你找到《Introduction to The Design and Analysis of Algorithms》的web book 和 pdf ,请与我联系。
蛮干法
蛮干法是直接基于问题的描述和所涉及的概念定义的解决问题的方法。虽然蛮干法和少产生高效的算法,但也多时候,它可能是解决问题的唯一方法。
与蛮干法相关的算法很多,如顺序查找(SequentialSearch),它直接按顺序遍历整个线性表(一般为数组),直至找到匹配或者无法找到才停止。它是线性的因此的时间效率为O(n),与数组中的容量成正比。
另外一个复杂点的蛮干算法是冒泡排序(BobbleSort)。它比较数组中的相邻元素,如果它们是逆序的话,就交换它们的位置。通过多轮的重复,每轮都把最大的元素沉淀到数组的末端。下面以c#为例实现该算法。(为了增加其一般性,使用泛型。)
public void BubbleSort<T>(ref T[] a) where T : IComparable<T> //泛型方法声明,类型参数T必须实现IComparable接口 { for (int j = a.Length - 1; j > 0; j--) for (int i = 0; i < j; i++) if (a[i].CompareTo(a[i + 1]) > 0) Swap(ref a[i], ref a[i + 1]); //交换a[i]与a[i+1] }
分治(Divide-and-Conquer)
分治法是把问题切分为若干个小问题来解决的方法。其一般步骤为:
1.将问题的实例划分为同一个问题的几个较小规模的实例。子问题最好拥有相同的规模。
2.对足够小的子问题实例求解。(一般使用递归方法)
3.将子问题的解合并,得到原问题的结。
二路归并排序是分治技术的一个完美例子
它把一个需要排序的数组A[0,..n-1]一分为二:A[0,..n/2-1]和A[n/2,..n-1],并对每个子数组进行递归排序,然后把这两个排序好的子数组组合为一个有序数组。下面以c#为例实现该算法。
public void Mergesort<T>(ref T[] a) where T : IComparable<T> { int n=a.Length; if (n > 1) { T[] b1 = new T[n / 2]; T[] b2 = new T[n - n / 2]; int j=0; for (int i=0; i < n / 2; i++,j++) //将 a[0..n/2-1]复制到 b1[] b1[i] = a[j]; for (int i = 0; j < n; i++, j++) //将 a[n-2..n-1]复制到 b2[] b2[i] = a[j]; Mergesort(ref b1); //递归调用,将b1[]排序 Mergesort(ref b2); //递归调用,将b2[]排序 Merge(b1, b2, ref a); //合并b1[] ,b2[] 为 a[] } } private void Merge<T>(T[] b1, T[] b2, ref T[] a) where T : IComparable<T> { int i=0, j=0,k=0; while (i < b1.Length && j < b2.Length) { if (b1[i].CompareTo(b2[j]) < 0) { a[k] = b1[i]; i++; } else { a[k] = b2[j]; j++; } k++; } if (i == b1.Length) for (; k < a.Length; k++, j++) a[k] = b2[j]; else for (; k < a.Length; k++, i++) a[k] = b1[i]; }
减治
减治技术通过建立原问题实例的解和同样问题较小实例的解的关系,并利用这种关系,从顶而下(递归地)或从底而上(非递归地)解决问题。一般情况下,每次算法迭代,都消去一个常量和一个常数因子。
利用减治法的一个典型算法就是折半查找(BinarySearch)。它搜索一个排序好的数组,将查找目标与数组的中间位置的元素相比,比它大则递归查找数组的左边,反之亦然。这个每次迭代都将问题减小为原来的1/2。下面是折半查找的一个非递归实现。
public int BinarySearch>T>(T[] a,T key) where T : IComparable>T> { int left = 0, right = a.Length-1,middle; while (left <= right) { middle = (left + right) / 2; if (a[middle].CompareTo(key) == 0) return middle; else if (a[middle].CompareTo(key) > 0) right = middle - 1; else left = middle + 1; } return -1; }
折半查找每次都消去一个常数因子2,因此其时间效率为O(logn)。另一个减治算法的例子是插入排序,它运用的是减一技术。
该方法遵循的思路是,假设对较小数组 A[0..n-2]排序问题已经解决了,得到一个大小为n-1的有序数组。然后将要排序的第n个元素,插入到数组的适合位置上,得到大小为n的有序数组 A[0..n-1]。下面用C#实现该算法。
public void InsertionSort(ref T[] a) where T : IComparable { for (int i = 1; i < a.Length; i++) for (int j = i - 1; j >= 0 && a[j + 1].CompareTo(a[j]) < 0; j--) Swap(ref a[j + 1],ref a[j]); }
变治
变治法是一组基于变换思想的技术,用来把问题变换成一种更容易解决的类型。实例简化,改变表现和问题化简是变治技术主要类型。
实例化简是把问题的实例转换成相同问题的另一个实例,新的实例有特殊的属性,使其更容易被解决。
预排序是实例化简的一中应用,例如要判断一个数组元素的唯一性,先对其进行排序,然后然后只检查它的连续元素是否重复。
public bool PresortElementUniqueness(T[] a) where T : IComparable { Sort(ref a); //对数组排序 for (int i = 0; i < a.Length - 1; i++) if (a[i].CompareTo(a[i + 1]) == 0) return false; return true; }
改变表现是将一个问题的实例转换为同样实例的不同表现。它的一个经典例子是求多项式的霍纳法则。
例如要计算x=12时,f(x)=2x4-x3+3x2+x-5的值,如果直接算的话要进行多次乘法,效率很低。(一般计算机算乘法的效率比加减法低)。
如果运用霍纳法则,f(x)=2x4-x3+3x2+x-5=x(x(x(2x-1)+3)+1)-5
只需进行4次乘法和4次加法运算便可以得出结果。尽管开始你会郁闷要怎么求出该式子的各常数,但很快你会发现它们便是多项式中的对应系数。因此霍纳法则的代码实现非常简单。
public double Horner(int[] p,double x)// p[]为多项式的各项系数,按次数升序排列。 x为 要求的 f(x)的x { double result=0; for (int i = p.Length - 1; i>= 0; i--) result = x * result + p[i]; return result; }
最后问题化简是把一个给定的问题变换为可用已知算法求解的问题。如求最小公倍数lcm(m,n),我们想到我们已经有算最大公约数gcd(m,n)的欧拉算法。于是只需找出它们的关系即可。很快我们想到了 lcm(m,n)=m*n/gcb(m,n),于是问题便解决了。
时空转换
顾名思义,时空转换是通过降低或提高算法的空间效率来提高或降低时间效率的以解决问题的方法。一般硬件状况下,我们希望以额外的空间为代价,提高其时间效率。这种思想广泛运用在实现字典和散列中。
另外一个典型的例子是计数排列。它的时间效率是比所有基于比较的排序法都要高。但是它只能应用在对整数的排序,需要知道待排序数组的最大值和最小值,而且是以额外存贮空间为代价的。计数排列建立一个用于记录元素频道的表(数组),然后遍历待排序数组,将各个元素出现的频率记入频率表。然后在根据频率表还原出排列好的数组。它在处理元素跨度小或元素重复性高的数组上,效率是极高的。下面是其代码实现。
public void DistributionCounting(ref int[] a,int l,int u)// l元素最小值 u元素最大值 { int[] result = new int[a.Length]; int[] d = new int[u - l+1]; //建立d[]作为频率表 for (int i = 0; i < a.Length; i++) d[a[i] - l]++; //统计频率 for (int i = 0; i < u-l; i++) d[i+1] = d[i] + d[i+1]; //为分布作准备 for (int i = a.Length - 1; i >= 0; i--) { int j = a[i] - l; result[d[j] - 1] = a[i]; d[j]--; } a = result; }
动态规划(Dynamic Programming)
动态规划方法是一种对具有交叠子问题的问题进行求解的技术。一般来说,这样的子问题出现在求解给定问题的递推关系中,这个递推关系中包含了相同类型的更小子问题。动态规划并不对子问题一次又一次的求解,而是只解一次并把结果记录在表中。
下面用判断有向图是否连通的Warshall算法来作例子(下面的例子都和图论有关系,如果你对图论不熟悉的话,请先阅读相关教材。)
Warshall算法输入一个有向图的邻接矩阵,输出它的传递闭包。传递闭包用来表示有向图中的点是否连通。它通过多次构造n阶布尔矩阵,每次都找出在原矩阵中以某一个节点为中间节点的连通点对。
public bool[,] Warshall(bool [,] a) { int n = a.GetLength(0); for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) for (int k = 0; k < n; k++) a[j, k] = a[j, k] || a[j, i] && a[i, k]; return a; }
动态规划中,经常会用到一种叫记忆功能的技术,它试图把自顶而下和自底而上的优势结合起来,对具有交叠子问题的问题求解。它在把子问题计入表时,只计算必须求解的子问题。在这就不举出其例子了。
贪婪技术
最后说到的算法思想是贪婪技术,它建立在通过一系列的步骤构造问题的解,每一部对目前构造的部分解都是最优的,直至获得问题完整的解为止。贪婪技术的核心是每一步选择都必须满足可行、局部最优和不可取消。
这里以求图的最小生成树的Prim算法为例子。详细的解释在很多图论上的书能找到,下面只给出其伪代码。
// <-表示赋值 Q表示空集 U表示集合并 Prim() //输入:加权连通图G=<V,E> //输出:Et,组成G的最小生成树的边的集合。 Vt<-{v0} //可以用任意顶点来初始化树的顶点集合。 Et<-Q for i<-1 to |V|-1 do 在所有的边(v,u)中,求权重最小的边 e*=(v*,u*) 使得v在Vt中,而u在V-Vt中 Vt<-Vt U {u*} Et<-Et U {e*}
后记
我在标题写道:算法无极限。当然,每个类型的算法都是存在其极限的。算法专家们早已证明了许多算法的极限,如基于比较的排序法,其下限是 nlogn。然而,尽管如此。数学家和程序员们依然致力于发明新的算法,在寻找问题的更优算法的道路上,是从来不会有极限的。