十四. 算法基础
1. 算法的特性
算法是对特定问题求解步骤的描述,它是指令的有限序列,其中每一条指令表示一个或多个操作。
- 有穷性:执行有穷步之后结束,且每一步都可在有穷时间内完成。
- 确定性:算法中每一条指令必须有确切的含义,无二义性。
- 可行性(有效性):算法的每个步骤都能有效执行并能在执行有限次后得到确定的结果。例如 a=0,b/a 就无效。
- 输入:一个算法有零个或多个输入。
- 输出:一个算法有一个或多个输出。
2. 时间复杂度与空间复杂度
时间复杂度是指程序运行从开始到结束所需要的时间。
通常分析时间复杂度的方法是从算法中选取一种对于所研究的问题来说是基本运算的操作,以该操作重复执行的次数作为算法的时间度量。一般来说,算法中原操作重复执行的次数是规模 n 的某个函数 T(n)。由于许多情况下要精确计算 T(n) 是困难的,因此引入了渐进时间复杂度在数量上估计一个算法的执行时间。其定义如下:
如果存在两个常数 c 和 m,对于所有的 n,当 n≥m 时有 f(n)≤cg(n),则有 f(n) = O(g(n))。也就是说,随着 n 的增大,f(n) 渐进地不大于 g(n)。例如,一个程序的实际执行时间为 ,则 T(n) = O()。也可表示为 。
常见的对算法执行所需时间的度量:
O(1)<O()<O(n)<O()<O()<O()<O()
空间复杂度是指对一个算法在运行过程中临时占用存储空间大小的度量。
一个算法的空间复杂度只考虑在运行过程为为局部变量分配的存储空间的大小。
时间复杂度总结:
① 常数级时间复杂度 O(1)
单个语句
如:k = 0;
整个程序都没有循环语句,或复杂函数的调用
void main(){
char*x = "ABCADAB";
int m = strlen(x);
printf("len:%d\n",m);
}
② 时间复杂度 O(n)
单层循环
void main(){
int i,j;
k = 0;
for(i=0; i<n; i++){
b[i]=0;
}
}
③ 时间复杂度 O()
双层循环(嵌套)
void main(){
int i, s=0, n=1000;
for(i=1; i<n; i++)
for(j=1; j<n; j++)
s+=j;
printf("结果为:"%d,s)
}
④ 时间复杂度 O()
三层嵌套循环
如果循环不嵌套,则时间复杂度仍为 O(n)。
⑤ 时间复杂度 O()
// 二分查找
int search(int array[], int n, int v)
{
int left,right,middle;
left = 0,right = n-1;
while(left <= right)
{
middle = (left+right)/2;
if(array[middle]>v)
{
right = middle-1;
}
else if(array[middle]<v)
{
left = middle+1;
}
else
{
return middle;
}
}
return =1;
}
如果再加一个 for 循环,那么时间复杂度就会变成 O()。
⑥ 时间复杂度 O()
典型代表:堆排序,每次重建堆的时间复杂度是 ,n 个元素就是 。
⑦ 时间复杂度 O()
典型代表:LCS最长公共子序列、钢管切割问题,动态规划法自顶向下,时间复杂度为 O()。
例题1:
根据渐进分析,表达式序列:,lgn,,1000n,,n!从低到高排序为()。
A.lgn,1000n,,,n!,
B.,1000n,lgn,,n!,
C.lgn,1000n,,,,n!
D.lgn,,1000n,,,n!
解析1:
可以理解成对时间复杂度的比较,根据 O(1)<O()<O(n)<O()<O()<O()<O()可得,lgn < < 1000n < < ,只有 D 项满足要求,因此选 D。
例题2:
已知算法 A 的运行时间函数为 T(n)=8T()+,其中 n 表示问题的规模,则该算法的时间复杂度为()。另已知算法 B 的运行时间函数为 T(n)=XT()+,其中 n 表示问题的规模。对充分大的 n,若要算法 B 比算法 A 快,则 X 的最大值为()。
A.Θ(n) B.Θ(nlgn) C.Θ() D.Θ()
A.15 B.17 C.63 D.65
解析2:
根据主定理
f(n) = ,a = 8,b = 2,则 = 3。显然满足第一条,f(n)<,即 <,且 = 1,所以 T(n) = Θ() = Θ(),第一个空选 D。第二个空求算法 B 比 A 快时 X 需要满足的条件,算法 B 中,f(n) = ,a = X,b = 4,则 = 。若满足第一条,则 T(n) = Θ(),因为要比算法 A 快,所以 T(n) 需要小于 ,n 越小才代表越快,因此 < 3,所以 X < 64,X 可以为 63。如果满足第二条,则 T(n) = Θ(logn),那么 至少需要小于 2,T(n) 才会小于 ,所以 X < 16,X 也可以为 15。同理,如果满足第三条,那么 T(n) = ,且 > ,能够求得 a 是小于 15 的。综合来看,X 的最大值是 63,也可以取到 15。因此选 C。
例题3:
求解两个长度为 n 的序列 X 和 Y 的一个最长公共子序列(如序列 ABCBDAB 和 BDCABA 的一个最长公共子序列为 BCBA)可以采用多种计算方法。如可以采用蛮力法,对 X 的每一个子序列,判断其是否也是 Y 的子序列,最后求出最长的即可,该方法的时间复杂度为()。经分析发现该问题具有最优子结构,可以定义序列长度分别为 i 和 j 的两个序列 X 和 Y 的最长公共子序列的长度为 C[i,j],如下所示。采用自底向上的方法实现该算法,则时间复杂度为()。
A.O() B.O(lgn) C.O() D.O(n)
A.O() B.O(lgn) C.O() D.O(n)
解析3:
若采用蛮力法,将 X 的每个子序列与 Y 进行比较,X 的子序列有 个,因为每个字符都有存在在序列中和不存在两种情况,长度为 n,所以共有 个子序列。和 Y 进行对比,Y 的长度也为 n,相当于两层嵌套过程,一层判断 X 的子序列,一层判断提取的子序列与 Y 是否相同,因此时间复杂度为 O(n)。对于优化的结构,采用自底向上的方式实现,即从 0 开始判断,式子中给出的是二维数组 C[i,j],因此至少需要两层嵌套循环,因此时间复杂度为 O()。因此选择 DA。
3. 常见算法策略
(1)算法策略概述
常见算法特征总结
- 分治法(主要是二分)
特征:把一个问题拆分成多个小规模的相同子问题,一般可用递归解决。
经典问题:斐波那契数列、归并排序、快速排序、二分搜索、矩阵乘法、大整数乘法等
- 贪心法(一般用于求满意解)
特征:局部最优,但整体不见得最优。每步有明确的、既定的策略。
经典问题:背包问题(如装箱)、多机调度、找零钱问题
- 动态规划法(用于求最优解)
特征:划分子问题,并把子问题结果适用数组存储,利用查询子问题结果构造最终问题结果。(一般自顶向下时间复杂度为 O(),自底向上时间复杂度为 O(),后者效率更高)
经典问题:斐波那契数列、矩阵乘法、背包问题、LCS最长公共子序列
- 回溯法
特征:系统搜索一个问题的所有解或任一解。
经典问题:N皇后问题、迷宫、背包问题
算法名称 | 关键点 | 特征 | 典型问题 |
分治法 | 递归技术 | 把一个问题拆分成多个小规模的相同子问题,一般可用递归解决。 | 归并排序、快速排序、二分搜索 |
贪心法 | 一般用于求满意解,特殊情况可求最优解(部分背包) | 局部最优,但整体不一定最优。每步有明确的、既定的策略。 | 背包问题(如装箱)、多机调度、找零钱问题 |
动态规划法 | 最优子结构和递归式 | 划分子问题(最优子结构),并把子问题结果使用数组存储,利用查询子问题结果构造最终问题结果。 | 矩阵乘法、背包问题、LCS最长公共子序列 |
回溯法 | 探索和回退 | 系统搜索一个问题的所有解或任一解。有试探和回退的过程。 | N皇后问题、迷宫、背包问题 |
算法策略判断:
- 回溯:有尝试探索和回退的过程。
- 分治:分治和动态规划比较难区分。分治不好解决问题,从而记录中间解解决问题。分治主要采用二分的思想,二分以外都用动态规划法解决了。二分的时间复杂度与 O(nlog2n) 相关,需注意有无外层嵌套循环,如果有,则需要再乘 n。(结合归并排序、快速排序的过程,也是二分的)
- 动态规划法:有递归式,自底向上实现时,中间解基本上查表可得,时间复杂度一般是 O(),具体 a 的值取决于 for 循环的嵌套层数。如果循环变量从 0 或 1 开始,到 n 结束,这种情况就是从小规模到大规模,自底向上。如果自顶向下,时间复杂度为 O(),和分治的实现就差不多了,查表的意义可以忽略不记,循环变量一般由 n 开始,向 1 缩小,是从大规模到小规模。
- 贪心法:有时也会出现最优子结构的描述,但没有递归式。考虑的是当前最优,求得的是满意解。
(2)分治法
对于一个规模为 n 的问题,若该问题可以容易地解决(比如说规模 n 较小)则直接解决;否则将其分解为 k 个规模较小的子问题,这些子问题相互独立且与原问题形式相同,递归地解决这些子问题,然后将子问题的解合并得到原问题的解。
- 该问题的规模缩小到一定的程度就可以容易地解决(分解)
- 该问题可以分解为若干个规模较小的相同问题(解决)
- 利用该问题分解出的子问题的解可以合并为该问题的解(合并)
- 该问题所分解出的各个子问题是相互独立的
递归就是在运行过程中调用自己。
斐波那契数列
int F(int n)
{
if(n==0) return 1;
if(n==1) return 1;
if(n>1) retrun F(n-1)+F(n-2);
}
自顶向下:n -> n-1 -> n-2 -> … -> 2 -> 1
自底向上:1 -> 2 -> 3 -> … -> n-1 -> n
二分查找
function Binary_Search(L,a,b,x){
if(a>b) return -1;
else{
m=(a+b)/2;
if(x==L[m]) return(m);
else if(x>L[m])
return(Binary_Search(L,m+1,b,x)); // x 在中间值右侧
else
return(Binary_Search(L,a,m-1,x)); // x 在中间值左侧
}
}
(3)贪心法
总是做出在当前来说是最好的选择,而并不从整体上加以考虑,它所做的每步选择只是当前步骤的局部最优选择,但从整体来说不一定是最优的选择。由于它不必为了寻找最优解而穷尽所有可能解,因此其耗费时间少,一般可以快速得到满意的解,但可能得不到最优解。(贪心法解决部分背包问题可得最优解)
在贪心思想中,优先放置单位价值大的物品,即用小空间放大价值物品,物品 1 2 3 的单位价值分别是 7 6 5,因此在 0-1背包问题中,由于物品无法分割,因此最优解是放置价值 380 的物品。部分背包问题,物品可以分割,因此可以将物品 1 2 放置后,再放置单位价值最低的物品 3 的一部分,得到价值 420 的物品,此时也是最优解,所以贪心法解决部分背包问题可以得到最优解。
例题1:
采用贪心算法保证能求得最优解的问题是()。
A.0-1背包 B.矩阵链乘 C.最长公共子序列 D.部分(分数)背包
解析1:
贪心算法解决部分背包问题可以得到最优解,所以选 D。
例题2:
现需要申请一些场地举办一批活动,每个活动有开始时间和结束时间。在同一个场地,如果一个活动结束之前,另一个活动开始,即两个活动冲突。若活动 A 从 1 时间开始,5 时间结束,活动 B 从 5 时间开始,8 时间结束,则活动 AB 不冲突。现需计算 n 个活动需要的最少场地数。
求解该问题的基本思路如下(假设需要场地数为 m,活动数为 n,场地集合为 P1,P2,…,Pm),初始条件 Pi 均无活动安排:
- 采用快速排序算法对 n 个活动的开始时间从小到大排序,得到活动 a1,a2,…,an。对每个活动 ai,i 从 1 到 n,重复步骤 2、3、4;
- 从 P1 开始,判断 ai 与 P1 的最后一个活动是否冲突,若冲突,考虑下一个场地 P2, …;
- 一旦发现 ai 与某个 Pj 的最后一个活动不冲突,则将 ai 安排到 Pj,考虑下一个活动;
- 若 ai 与所有已安排活动的 Pj 的最后一个活动均冲突,则将 ai 安排到一个新的场地,考虑下一个活动;
- 将 n 减去没有安排活动的场地数即可得到所用的最少场地数。
该问题首先采用了快速排序算法进行排序,其算法设计策略是();后面步骤采用的算法设计策略是()。整个算法的时间复杂度是()。下表给出了 n=11 的活动集合,根据上述算法,得到最少的场地数为()。
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
开始时间 | 0 | 1 | 2 | 3 | 3 | 5 | 5 | 6 | 8 | 8 | 12 |
结束时间 | 6 | 4 | 13 | 5 | 8 | 7 | 9 | 10 | 11 | 12 | 14 |
A.分治 B.动态规划 C.贪心 D.回溯
A.分治 B.动态规划 C.贪心 D.回溯
A.Θ(lgn) B.Θ(n) C.Θ(nlgn) D.Θ()
A.4 B.5 C.6 D.7
解析2:
快速排序的算法设计策略是分治的思想;该问题在第 2、3、4 步采用的是贪心的策略,即将当前的活动放到与场地中最后一个活动不冲突的场地中,并且后面的算法中没有涉及到递归式,因此不会是动态规划,没有二分的思想,也不会是分治,也没有回退与探索,也不是回溯;在整个算法中,涉及到对活动 ai 的遍历以及对场地 Pj 的遍历,所以需要两层嵌套循环,因此时间复杂度是 ;在实例中,n=11,可以得到如下计算过程:活动1 [0,6],时间 6 结束,放到场地 1 中;活动2 [1,4],时间 4 结束,与场地 1 中的最后一个活动 1 的结束时间冲突,因此活动2 放到新的场地 2 中;同理,活动3 [2,13] 的开始时间与场地 1、2 中的活动均冲突,因此活动3 放到新的场地 3 中;同理,活动4 [3,5] 放到场地 4 中;活动5 [3,8] 放到场地 5 中;活动6 [5,7] 的开始时间是 5,此时活动2 在时间 4 时结束,因此活动6 可以放到场地 2 中;同理,活动7 [5,9] 的开始时间是 5,此时活动4 在时间 5 时结束,因此活动7 可以放到场地 4 中;同理,活动8 可以放到场地 1 中;活动9 可以放到场地 2 中;活动10 可以放到场地 5 中;活动11 可以放到场地 1 中。因此 5 个场地即可放置这 11 个活动,最小场地数为 5。因此整题选 ACDB。
(4)动态规划法
在求解问题中,对于每一步决策,列出各种可能的局部解,再依据某种判定条件,舍弃那些肯定不能得到最优解的局部解,在每一步都经过筛选,以每一步都是最优解来保证全局是最优解。(问题中如果出现 "最优子结构" 这类描述,并且结果用递归式表示,一般为动态规划法)
例题:
已知矩阵 和 相乘的时间复杂度为 O(mnp)。矩阵相乘满足结合律,如三个矩阵 A、B、C 相乘的顺序可以是(A*B)*C,也可以是 A*(B*C)。不同的相乘顺序所需进行的乘法次数可能有很大的差别。因此确定 n 个矩阵相乘的最优计算顺序是一个非常重要的问题。已知确定 n 个矩阵 相乘的计算顺序具有最优子结构,即 的最优计算顺序包含其子问题 和 的最优计算顺序。可以列出其递归式为:
其中, 的维度为 ,m[i,j] 表示 最优计算顺序的相乘次数。
先采用自底向上的方法求 n 个矩阵相乘的最优计算顺序。则求解该问题的算法设计策略为()。算法的时间复杂度为(),空间复杂度为()。给定一个实例,()=(20,15,4,10,20,25),最优计算顺序为()。
A.分治法 B.动态规划法 C.贪心法 D.回溯法
A.O() B.O(lgn) C.O() D.O()
A.O() B.O(lgn) C.O() D.O()
A.(((×)×)×)× B.×(×(×(×)
C.((×)×)×(×) D.(×)×((×)×)
解析:
题干中强调了最优子结构以及递归式,典型的动态规划法设计策略;问题中涉及到了三个循环变量 i、j、k,因此需要三层嵌套循环,时间复杂度为 ;空间复杂度是指对一个算法在运行过程中临时占用存储空间大小的度量,该问题中采用 m[i,j] 二维数组存储矩阵最优计算顺序的相乘次数,所以空间复杂度为 。
给定实例的最优计算顺序是求取所需最少的乘法次数,题干开头给出两个矩阵相乘的时间复杂度为 O(mnp),即 m*n*p。 的维度为 , 的维度为 ,的维度为 ,的维度为 , 的维度为 。在矩阵乘法中, * = ,因此, * = ,((×)×) = ,依次类推。A 项中, * 的乘法次数或时间复杂度为 20*15*4 = 1200,((×)×) 的乘法次数为 20*4*10,再乘以 为 20*10*20,再乘以 为 20*20*25,相加得 16000 次,同理,计算 BCD,分别为 15000、12000和 6000,因此最优计算顺序为 D。
(5)回溯法
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当搜索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择。这种走不通就退回再走的方法就是回溯法。
回溯法分为两部分:
- 试探部分:满足除规模之外的所有条件,则扩大规模。
- 回溯部分:当前规模解不是合法解时回溯(不满足约束条件);求完一个解,要求下一个解时,也要回溯。缩小规模。
4. 查找算法
(1)顺序查找
将待查找的关键字为 key 的元素从头到尾与表中元素进行比较,如果中间存在关键字为 key 的元素,则返回成功;否则,查找失败。
查找成功时,顺序查找的平均查找长度为(等概率):
顺序查找的方法对于顺序存储方式和链式存储方式的查找表都适用。
(2)二分查找
二分查找也称为折半查找,其基本思想是:
设 R[low,…,high] 是当前的查找区。
- 确定该区间的中点位置:mid = [(low+high)/2];
- 将待查的 k 值与 R[mid].key 比较,若相等,则查找成功并返回此位置,否则需确定新的查找区间,继续二分查找,具体方法为:
- 若 R[mid].key > k,则由表的有序性可知,R[mid,…,n].key 均大于 k,因此若表中存在关键字等于 k 的结点,则该结点必定是在位置 mid 左边的子表 R[low,…,mid-1] 中。因此,新的查找区间是左子表 R[low,…,mid-1];
- 若 R[mid].key < k,则要查找的 k 必在 mid 的右子表 R[mid+1,…,high] 中,新的查找区间是右子表 R[mid+1,…,high];
- 若 R[mid].key = k,则查找成功,算法结束。
3. 下一次查找是针对新的查找区间进行的,重复步骤 1 2;
4. 在查找过程中,low 逐步增加,high 逐步减少。如果 low > high,则溢界,查找失败,算法 结束。
因此二分查找的前提是表是顺序存储且元素有序排列,并且中间元素的寻找向下取整。
例如,在含有 12 个元素的有序表 {1,4,10,16,17,18,23,29,33,40,50,51} 中二分查找关键字 17。
步骤为:
- 求取有序表的中间元素,即 (1+12)/2 = 6(向下取整),即元素 18;
- 17<18,因此 17 位于中间元素的左半边,且中间元素 18 已经比较过,因此新的查找区间为 R[1,5];
- 继续二分查找,中间元素为 (1+5)/2 = 3,即元素 10;
- 17>10,因此 17 位于中间元素的右半边,新的查找区间为 R[4,5];
- 继续二分查找,中间元素为 (4+5)/2 = 4(向下取整),即元素 16;
- 17>16,因此 17 位于中间元素的右半边,新的查找区间为 R[5,5];
- 即第五个元素就是要查找的元素 17,算法结束。
二分查找在查找成功时关键字的比较次数最多为 次;时间复杂度为 O()。
例题:
在 55 个互异元素构成的有序表 A[1…55] 中进行折半查找。若需要找的元素等于 A[19],则在查找过程中参与比较的元素依次为()、A[19]。
A.A[28]、A[30]、A[15]、A[20]
B.A[28]、A[14]、A[21]、A[17]
C.A[28]、A[15]、A[22]、A[18]
D.A[28]、A[18]、A[22]、A[20]
解析:
第一次查找比较的元素为 (1+55)/2 = 28;19<28,第二次查找比较的元素为 (1+27)/2 = 14;19>14,第三次查找比较的元素为 (15+27)/2 = 21;19<21,第四次查找比较的元素为 (15+20)/2 = 17;19>17,第五次查找比较的元素为 (18+20)/2 = 19。因此选 B。
(3)哈希散列表
散列表查找的基本思想是:已知关键字集合 U,最大关键字为 m,设计一个函数 Hash,它以关键字为自变量,关键字的存储地址为因变量,将关键字映射到一个有限的、地址连续的区间 T[0…n-1](n<<m)中,这个区间就称为散列表,散列表查找中使用的转换函数称为散列函数。
例如,记录关键码为(3,8,12,17,9),取 m = 10(存储空间为10),p = 5,散列函数 h = key%p。
3 % 5 = 3;8 % 5 = 3;12 % 5 = 2;17 % 5 = 2;9 % 5 = 4。
所以它们存放的位置分别为 3、3、2、2、4,显然 3 和 2 处均存放了两个元素,存在冲突。开放定址法是指当构造散列表发生冲突时,使用某种探测手段,产生一个探测的散列地址序列,并且逐个查找此地址中是否存储了数据元素,如果没有,则称该散列地址开放,并将关键字存入,否则继续查找下一个地址。只要散列表足够大,总能找到空的散列地址将数据元素存入。如元素 8 和 3 冲突,元素 8 则存入下一个散列地址,即 4,元素 17 与 12 冲突,且 3 、4 位置均有元素,因此只能存入 5,元素 9 存入位置 6。这种方法也称为线性探测法。
例题1:
用哈希表存储元素时,需要进行冲突(碰撞)处理,冲突是指()。
A.关键字被依次映射到地址编号连续的存储位置
B.关键字不同的元素被映射到相同的存储位置
C.关键字相同的元素被映射到不同的存储位置
D.关键字被映射到哈希表之外的位置
解析1:
根据冲突的定义可知,关键字不同的元素被映射到相同的存储位置被称为冲突。因此选 B。
例题2:
设散列函数为 H(key)=key%11,对于关键码序列 (23,40,91,17,19,10,31,65,26),用线性探测法解决冲突构造的哈希表为()。
解析2:
首先分别求得各元素的散列值,分别为 1、 7、3、6、8、10、9、10、4,如果存在冲突则放入下一个位置,直至成功放入,因此只有 B 项正确。
5. 排序算法
(1)排序的基本概念
假设含 n 个记录的文件内容为 {},相应的关键字为 {}。经过排序确定一种排列 {},使得它们的关键字满足以下递增(或递减)关系:(或 )。
若在待排序的一个序列中, 和 的关键字相同,即 ,且在排序前 领先于 ,那么在排序后,如果它们的相对次序保持不变, 仍领先于 ,则称此类排序方法是稳定的。若在排序后的序列中有可能出现 领先于 的情形,则称此类排序是不稳定的。
内部排序指待排序记录全部存放在内存中进行排序的过程。外部排序指待排序记录的数量很大,以至于内存不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程。
排序方法分类
- 插入类排序:直接插入排序、希尔排序
- 交换类排序:冒泡排序、快速排序
- 选择类排序:直接选择排序、堆排序
- 归并排序
- 基数排序
(2)插入类排序
直接插入排序
当插入第 i 个记录时, 均已排好序,因此,将第 i 个记录 依次与 进行比较,找到合适的位置插入。
直接插入排序简单明了,速度较慢。它是一种稳定的排序方法,时间复杂度为 O(),在排序过程中仅需要一个元素的辅助空间,空间复杂度为 O(1)。适用于基本有序的情况,此时时间复杂度近乎线性,即 O(n)。
希尔排序
先取一个小于 n 的整数 作为第一个增量,把文件的全部记录分成 个组。所有距离为 的倍数的记录放在同一个组中。先在各组进行直接插入排序;然后,取第二个增量 重复上述的分组和排序,直至所取的增量 (),即所有记录放在同一组中进行直接插入排序为止。该方法实质上是一种分组插入方法。
希尔排序是一种不稳定的排序方法,据统计分析其时间复杂度为 O()。在排序过程中仅需要一个元素的辅助空间用于数组元素的交换,空间复杂度为 O(1)。
例题1:
现需对一个基本有序的数组进行排序,此时最适宜采用的算法为()排序算法,时间复杂度为()。
A.插入 B.快速 C.归并 D.堆
A.O(n) B.O(nlgn) C.O() D.(lgn)
解析1:
对基本有序的数组进行排序适合采用插入排序算法;时间复杂度为 O(n),几乎呈线性。因此选 AA。
例题2:
若某应用中,需要先排序一组大规模的记录,其关键字为整数。若这组记录的关键字基本上有序,则适宜采用()排序算法。若这组记录的关键字的取值均在 0 到 9 之间(含),则适宜采用()排序算法。
A.插入 B.快速 C.归并 D.堆
A.插入 B.快速 C.归并 D.计数
解析2:
对有序的记录排序适合采用插入排序算法;计数排序是统计待排序数组中每个元素出现的次数,然后根据这些计数来确定每个元素在最终排序数组中的位置,适用于整数或可以映射到小范围内整数的元素。因此选 AD。
(3)选择类排序
直接选择排序
首先在所有记录中选出排序码最小的记录,把它与第 1 个记录交换,然后在其余的记录内选出排序码最小的记录,与第 2 个记录交换,依此类推,直到所有记录排完为止。
直接选择排序是一种不稳定的排序方法,其时间复杂度为 O()。排序过程中仅需要一个元素的辅助空间用于数组元素的交换,空间复杂度为 O(1)。
堆排序
设有 n 个元素的序列 {},当且仅当满足下述关系之一时,称之为堆。
- ≤ 且 ≤ (小顶堆)
- ≥ 且 ≥ (大顶堆)
堆排序的基本思想为:先将序列建立堆,然后输出堆顶元素,再将剩下的序列建立堆,然后再输出堆顶元素,依此类推,直到所有元素均输出为止,此时元素输出的序列就是一个有序序列。对于大量的记录来说,堆排序是很有效的。
堆排序的时间复杂度为 O(),空间复杂度为 O(1)。之所以时间复杂度为 O(),是因堆排序的过程为:初建堆 — 取出堆顶元素 — 重建堆 — 完成排序,每次重建堆的时间复杂度均为 O(),即建立一个完全二叉树,而每次取出元素都需要重建堆一次,共取出 n 次,因此为 。
例题:
对于 n 个元素的关键字序列 {},当且仅当满足关系 ≤ 且 ≤ (i = 1,2,…,)时称其为小顶堆(小根堆)。以下序列中,()不是小顶堆。
A.16,25,40,55,30,50,45
B.16,40,25,50,45,30,55
C.16,25,39,41,45,43,50
D.16,40,25,53,39,55,45
解析:
根据各选项建立堆判定是否为小顶堆即可,只有 D 项不是,它形成的堆为:
(4)交换类排序
冒泡排序
通过相邻元素之间的比较和交换,将排序码较小的元素逐渐从底部移向顶部。整个排序过程就像水底下的气泡一样逐渐向上冒。
冒泡排序是一种稳定的排序方法,其时间复杂度为 O()。排序过程中仅需要一个元素的辅助空间用于数组元素的交换,空间复杂度为 O(1)。
快速排序
快速排序采用分治法,将原问题分解成若干个规模更小但结构与原问题相似的子问题。通过递归地解决这些子问题,然后将这些子问题的解组合成原问题的解。
在 O() 时间复杂度量级中,快速排序的平均性能最好。
快速排序通常包括两个步骤:
- 在待排序的 n 个记录中任取一个记录,以该记录的排序码为准,将所有记录都分成两组,第 1 组都小于该数,第 2 组都大于该数;
- 采用相同的方法对左右两组分别进行排序,直到所有记录都排到相应的位置为止。
快速排序中基准元素的选择可以是第一个元素也可以是中位数。
快速排序是一种不稳定的排序方法,平均和最优情况下时间复杂度为 O()。最差情况下(基本有序),若以第一个元素为基准元素,此时时间复杂度为 O();若以中位数为基准元素,时间复杂度为 O()。
其空间复杂度,若需要辅助空间存储左侧数组和右侧数组,则空间复杂度为 O(n);若需要记录所有基准元素,则空间复杂度为 O()。
例题:
对数组 A=(2,8,7,1,3,5,6,4) 用快速排序算法的划分方法进行一趟划分后得到的数组 A 为()(非递减排序,以最后一个元素为基准元素)。进行一趟划分的计算时间为()。
A.(1,2,8,7,3,5,6,4) B.(1,2,3,4,8,7,5,6) C.(2,3,1,4,7,5,6,8) D.(2,1,3,4,8,7,5,6)
A.O(1) B.O(lgn) C.O(n) D.O(nlgn)
解析:
以 4 为基准元素,先与 2 比较,2<4,无需交换;然后再与 8 比较,4<8,交换位置,变成 (2,4,7,1,3,5,6,8);然后再与 6 比较,无需交换;与 5 比较,无需交换;与 3 比较,交换位置,变成(2,3,7,1,4,5,6,8);再与 7 比较,交换位置,变成 (2,3,4,1,7,5,6,8);最后再与 1 比较,交换位置,变成 (2,3,1,4,7,5,6,8),4 左侧元素均小于 4,右侧均大于 4,所以划分得到的数组 A 为(2,3,1,4,7,5,6,8);计算时间为基准元素 4 与其它所有元素都进行了一次比较,比较了 n-1 次,所以时间复杂度为 O(n)。因此选择 CC。
(5)归并排序
归并也称为合并,是将两个或两个以上的有序子表合并成一个新的有序表。若将两个有序表合并成一个有序表,则称为二路合并。合并的过程是:比较 A[i] 和 A[j] 的排序码大小,若 A[i] 的排序码小于等于 A[j] 的排序码,则将第一个有序表中的元素 A[i] 复制到 R[k] 中,并令 i 和 k 分别加 1;如此循环下去,直到其中一个有序表比较和复制完,然后再将另一个有序表的剩余元素复制到 R 中。
归并排序是一种稳定的排序方法,时间复杂度为 O()。空间复杂度为 O(n)。
归并排序是 O() 时间复杂度量级中唯一稳定的一个排序方法。
例题1:
对 n 个数排序,最坏情况下时间复杂度最低的算法是()排序算法。
A.插入 B.冒泡 C.归并 D.快速
解析1:
插入排序与冒泡排序最坏情况下时间复杂度都是 O(),插入排序在有序情况下时间复杂度为 O(n);快速排序在最坏情况下(基本有序)且以第一个元素为基准元素,时间复杂度也为 O();归并排序时间复杂度为 O(),由于 < 。所以最坏情况下时间复杂度最低的算法是归并排序,选 C。
例题2:
两个递增序列 A 和 B 的长度分别为 m 和 n(m<n且m与n接近),将二者归并为一个长度为 m+n 的递增序列。当关系为()时,归并过程中元素的比较次数最少。
A.a1<a2<…<am-1<am<b1<b2<…<bn-1<bn
B.b1<b2<…<bn-1<bn<a1<a2<…<am-1<am
C.a1<b1<a2<b2<…<am-1<bm-1<am<bm<bm+1<…<bn-1<bn
D.b1<b2<…<bm-1<bm<a1<a2<…<am-1<am<bm+1<…<bn-1<bn
解析2:
看两个序列比较的交界处。A 项中,序列 A 的最后一个元素比序列 B 的第一个元素小,因此只需 b1 分别比较 A 的各个元素,比较了 m 次;同理 B 项,a1 分别比较 B 的各个元素,比较了 n 次;CD 两项是交叉比较,C 项中直接复制到新序列中的元素为 bm<bm+1<…<bn-1<bn 这一部分,因此比较的元素为前面的 a 比较 m 次,b 比较 m-1 次,共比较了 2m-1 次;同理 D 项,b 比较 m 次,a 比较 m 次,直接复制的部分为 bm+1<…<bn-1<bn,共比较了 2m次,因此比较次数最少的为 m 次,即 A 项。
(6)基数排序
基数排序是一种借助多关键字排序思想对单逻辑关键字进行排序的方法。基数排序不是基于关键字比较的排序方法,它适合于元素很多而关键字较少的序列。基数的选择和关键字的分解是根据关键字的类型来决定的,例如关键字是十进制数,则按个位、十位来分解。
基数排序是一种稳定的排序方法,时间复杂度为 O(d(n+rd))。空间复杂度为 O(rd)。
(7)排序算法对比
类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | |
平均情况 | 特殊情况 | 辅助存储 | |||
插入排序 | 直接插入 | O() | 基本有序最优O(n) | O(1) | 稳定 |
希尔排序 | O() | - | O(1) | 不稳定 | |
选择排序 | 直接选择 | O() | - | O(1) | 不稳定 |
堆排序 | O() | - | O(1) | 不稳定 | |
交换排序 | 冒泡排序 | O() | - | O(1) | 稳定 |
快速排序 | O() | 基本有序最差O() | O() | 不稳定 | |
归并排序 | O() | - | O(n) | 稳定 | |
基数排序 | O(d(n+rd)) | - | O(rd) | 稳定 |
排序算法的选择
- 若待排序列的记录数目 n 较小,可采用直接插入排序和直接选择排序。由于直接插入排序所需的记录移动操作较直接选择排序多,因而当记录本身信息量大时,用直接选择排序好。
- 若待排记录按关键字基本有序,适合采用直接插入排序或冒泡排序。
- 当 n 很大且关键字位数较少时,适合采用基数排序。
- 若 n 很大,则应采用时间复杂度相对较小的 O() 的排序方法,如快速排序、堆排序或归并排序:
- 快速排序目前被认为是内部排序中最好的方法,当待排序的关键字为随机分布时,快速排序的平均运行时间最短;
- 堆排序只需要一个辅助空间,并且不会出现在快速排序中可能出现的最差情况;
- 快速排序和堆排序都是不稳定的排序方法,若要求排序稳定,可选择归并排序。
算法基础部分的内容至此结束,后续如果有补充或修改会直接添加。