通过深入思考与分析获得对问题本质的透彻理解,按照长期积淀而成的框架与模式设计出合乎问题内在规律的算法,选用、改进或定制足以支撑算法高效实现的数据结构,并在真实的应用环境中充分测试和改进。
算法是指基于特定的计算模型,旨在解决某一信息处理问题而设计的一个指令序列。
输入 输出 |
对所求解问题特定实例的描述。 经计算和处理之后得到的信息,即针对输入问题实例的答案。 |
基本操作 确定性 可行性 |
算法可描述为由若干语义明确的基本操作组成的指令序列, 每一基本操作对应的计算模型中均可兑现。 |
有穷性 正确性 |
任意算法都应当在执行有限次基本操作之后终止并给出输出。 算法不应迟早会终止,而且所给出的输出还应符号由问题本身在实现确定的条件。 |
退化与鲁棒性 |
各种极端的输入实例属于退化情况。算法鲁棒性就是要求能够尽可能充分地应对此类情况。 |
重用性 |
算法模式可推广并适用于不同类型的基本元素。 |
表 算法的基本要素
鲁棒也是健壮的意思,是在异常和危险情况下系统生存的能力。
从保守估计的角度出发,在规模为n的所有输入中选择执行时间最长者作为T(n),并以T(n)度量该算法的时间复杂度。
在处理更大规模的问题呢时,效率差异都将对实际执行效果产生巨大的影响。这种着眼长远、更为注重时间复杂度的总体变化趋势和增长速度的策略与方法称为渐进分析。
是用于描述函数渐近行为的数学符号。更确切地说,它是用另一个函数来描述一个函数数量级的渐近上界。
无穷大渐近
假设解决一个规模为n的问题所花费的时间为T(n) = 4n^2 - 2n + 2。当n增大时,n^2项将开始占主导地位,其他项可忽略。进一步看,n^2的系数也可忽略。这样,大O符号记作 T(n)≦ O(n^2)。 并且我们就说该算法具有2阶的复杂度。
符号 |
名称 |
符号 |
名称 |
O(1) |
常熟(阶,下同) |
O(n^2) |
平方 |
O(log n) |
对数 |
O(n^c) |
多项式 |
O[(log n) ^ c] |
多对数 |
O(c^n) |
指数 |
O(n) |
线性 |
O(n!) |
阶乘 |
O(n log n) |
线性对数 |
表 常用的函数阶
与大O符号恰好相反,大Ω记号是对算法执行效率的乐观估计。算法的运行时间都不低于Ω(g(n))。
例如:f(n) = 2n +3 = Ω(n);
是对算法复杂度的准确估计——对于规模为n的任何输入,算法的运行时间T(n)都与Θ(h(n))同阶。
例如:f(n) = n^2 + 3n + 8 = Θ(n^2)。
算法所需存储空间的多少。
实际上根据定义,每次基本操作所涉及的存储空间,都不会超过常数规模;纵然每次基本操作所占用或访问的存储空间都是新开辟的,整个算法所需的空间用量,也不过与基本操作的次数同阶。从这个意义上说,时间复杂度本身就是空间复杂度的一个天然上界。
大Ο记号是最基本的,也是最常用的。大Ο记号将各算法的复杂度由低到高划分为若干层次级别,以下依次介绍若干典型的复杂度级别。
需求:从n>3个互异整数中,找出除最大、最小者外,任一元素。
public static int findNotMaxNumber(int[] arr) {
int max,min;
if (arr[0] > arr[1]) {
max = arr[0];
min = arr[1];
} else {
max = arr[1];
min = arr[0];
}
if (arr[2] > max) {
return max;
}
if (arr[2] < min) return min;
return arr[2];
}
上述代码中 T(n) = O(1) + O(2) + O(1) + O(1) + O(1) = O(6) = O(1) = Ω(1) + Ω(2) + Ω(1) + Ω(1) = Ω(5) = Ω(1);
运行时间可表示和度量为T(n)=O(1)的这一类算法,统称作“常熟时间复杂度算法”。
需求:对于任意非负整数,统计其二进制中1的总数。
public static int statisticalBinary(int n) {
int num = 0;
while (n > 0) {
num += n & 1;
n >>= 1;
}
return num;
}
f(n) = 1 + 2 * + 1 = O()。在用函数 界定渐进复杂度时,常底数r的具体取值无所谓,故通常不予专门标出而笼统地记作logn。此类算法称作具有”对数时间复杂度”。
需求:计算给定n个整数的总和。
public static int sumOfArr(int[] arr) {
int sum = 0; // O(1)
for(int i = 0; i < arr.length; i++)
sum += arr[i]; //O(1)
return sum; //O(1)
}
f(n) = 1 + n + 1 = O(n);凡运行时间可以表示和度量为T(n) = O(n)形式的这一类算法,均统称作“线性时间复杂度算法”
若运行时间可以表示和度量为T(n) = O(f(n))的形式,且f(x)为多项式,则对应的算法称作“多项式时间复杂度算法”。上述的三种算法都属于多项式算法。
通常认为,指数复杂度算法无法真正应用于实际问题中,它们不是有效算法,甚至不能称作算法。相应的,不存在多项式复杂度算法的问题,也称作难解的问题。
函数是一个非空集合到另一个非空集合的映射。给定一个非空数集A,假设其中的元素为x,对A中的元素x施加对应法则f,记作f(x),得到另一非空数集B,假设B中的元素为y。则y=f(x)。
而这里的A集合在程序上成为参数,B集合为返回值。有参有返回值的才是函数,否则就是过程。
需求: 计算给定n个整数的总和。
思路:n个整数和 = 前n-1项整数和 + 第n-1项。当n为1时,整数和=前0项整数和 + 第0项。这种情况也为一般情况。 若n为0,则0个整数和必为0,这为最终的平凡情况。
public static int sumOfArr(int[] arr) {
int sum = 0; // O(1)
for(int i = 0; i < arr.length; i++)
sum += arr[i]; //O(1)
return sum; //O(1)
}
保证递归算法有穷性的基本技巧:首先判断并处理n=0之类的平凡情况(递归基),平凡情况可能有多种,但至少要有一种,迟早必然会出现。
线性递归即普通递归,单向递归,最后一步操作不是递归操作。
图 线性递归示意图
线性递归的模式,往往对应于减而治之的算法策略:递归每深入一层,待求解问题的规模都缩减一个常数,直至最终蜕化为平凡的小问题。
在线性递归算法中,若递归调用在递归实例中恰好以最后一步操作的形式出现,则称作尾递归。
需求:将数组中各元素的次序前后翻转。
思路:将第一个元素与最后一个元素翻转转,然后处理除首尾两个元素外的数组翻转。平凡情况为第一个元素的pos大等于最后一个元素的pos。
public static int sumOfArr(int[] arr) {
int sum = 0; // O(1)
for(int i = 0; i < arr.length; i++)
sum += arr[i]; //O(1)
return sum; //O(1)
}
上述操作属于典型的尾递归。
属于尾递归形式的算法,均可简捷地转换为等效的迭代版本。
将上述尾递归算法修改成迭代版本的思路:首先在起始位置插入一个跳转标志next,然后将尾递归语句调用替换为一条指向next标志的跳转语句。
public static void iterationReverse(Integer[] arr, int start, int end) {
next:
while (start < end) {
int temp = arr[end];
arr[end] = arr[start];
arr[start] = temp;
start++; end--;
continue next;
}
}
goto语句有悖与结构化程序设计的原则,开发中尽量回避。将上述代码改版为:
public static void iterationReverse(Integer[] arr, int start, int end) {
while (start < end) {
int temp = arr[end];
arr[end] = arr[start];
arr[start] = temp;
start++; end--;
}
}
严格来说,只有当该算法(除递归基外)任一实例都终止于这一递归调用,才属于尾递归。而不仅仅是递归语句出现在代码体的最后一行。上面代码求n个整数之后的递归算法不是尾递归。
将问题分解为若干规模更小的子问题,再通过递归机制分别求解,直到子问题规模缩减至平凡情况,
保证子问题与原问题在接口形式上的一致。既然每一递归实例都可能做多次递归,故称作“多路递归”。通常将问题一分为二,故称作“二分递归”。无论是分解为两个还是更大常数个子问题,对算法总体的渐进复杂度并无实质影响。
需求:计算第n项斐波那契数列的值。 斐波那契数列:1,1,2,3,5,8,13,21,34...
思路:F(0) = 1; F(1) = 1; F(n) = F(n-1) + f(n-2) (n ≥2);
public static int fib(int n) {
if (n <= 1) return 1;
return fib(n - 1) + fib(n-2);
}
分析:该算法计算过程中的递归实例,重复度极高。为了消除这些重复的递归实例,借助一定量的辅助空间,在各子问题求解之后,及时记录下其对应的解答。
观察数列,可以发现在这个数列中,第n项等于将这个数列每一项都向前移一位的新数列的第n-1项。
移动位数 |
to |
t1 |
t2 |
t3 |
t4 |
t5 |
t6 |
L0 |
1 |
1 |
2 |
3 |
5 |
8 |
13 |
L1 |
1 |
2 |
3 |
5 |
8 |
13 |
|
L2 |
2 |
3 |
5 |
8 |
13 |
||
L3 |
3 |
5 |
8 |
13 |
|||
L4 |
5 |
8 |
13 |
||||
L5 |
8 |
13 |
|||||
L6 |
13 |
观察上述数列可得到,L1T1 = L0T2 = L0T0 + L0T1;L1T0 = L0T1;
平凡情况为n=1时值为1 及n=0时值为1;
public static int fibSeq(int n,int t0, int t1) {
if (n == 0) return t0; //除非在n的初始值设为0,否则n永远不可能为0
if (n == 1) return t1;
return fibSeq(n-1,t1,t0 + t1);
}