重读算法导论之算法基础
插入排序
对于少量数据的一种有效算法。原理:
- 整个过程中将数组中的元素分为两部分,已排序部分A和未排序部分B
- 插入过程中,从未排序部分B取一个值插入已排序的部分A
- 插入的过程采用的方式为: 依次从A中下标最大的元素开始和B中取出的元素进行对比,如果此时该元素与B中取出来的元素大小关系与期望不符,则将A中元素依次向右移动
具体代码如下:
public static void insertionSort(int[] arr) {
// 数组为空或者只有一个元素的时候不需要排序
if (arr == null || arr.length <= 1) {
return;
}
// 开始插入排序,先假设元素组第一个元素属于已经排好序的A部分,依次从B部分取出元素,进行比较插入
for (int j = 1; j < arr.length; j++) {
int key = arr[j];
int i = j - 1;
for (; i >= 0; i--) {
if (arr[i] > key) {
arr[i + 1] = arr[i];
} else {
break;
}
}
arr[i+1] = key;
}
}
易错点为,最后应该是设置arr[i + 1] = key。 可以设想假设A中所有元素都比B中选出来的数小的时候,此时key所在的下标应该不变,此时i + 1 = j 。所以有arr[i + 1] = key。
循环不变式
循环不变式主要用来帮助我们理解算法的正确性。要证明一个算法是循环不变式,必须证明该算法满足三条性质:
- 初始化:循环的第一次迭代之前,它为真
- 保持:如果循环的某次迭代之前它为真,那么进行完当前迭代,下次迭代之前仍然为真
- 终止:在循环终止时,不变式为我们提供了一个有用的性质,该性质有助于证明算法是正确的
循环不变式类似于数学上的归纳法。只不过在归纳法中,归纳步是无限地使用的,而这里存在循环终止,停止归纳。
用循环不变式验证插入排序
初始化: 从上面的代码可以看到。在循环之前,我们假设排好序的部分A只包含一个元素,此时A当然是满足排好序的。即初始化A满足循环不变式
保持:下面分析每一个循环的过程。从未排序的B中取出key,如果A中的arr[i] > key, 说明此时key所在的位置并不是合适的位置,此时需要将A中的arr[i]往右移动到arr[i+1](arr[i + 1] = arr[i])。直到找到arr[i] <= key, 说明此时arr[i+1]就应该是key的正确位置,arr[i + 1] = key。完成该轮迭代之后,因为A原来就是排好序的,所以只是将A中不符合条件的元素右移,不会影响其排好序的特性。而key插入到正确位置之后,也保证了A+key之后的新的A满足循环不变式
-
终止:代码中的j表示未排好序的B部分的最左元素下标。可以看到循环的最终条件是j=arr.length。即此时A包含数组的所有元素。因此终止条件也满足循环不变式
快速排序的整个流程图如下:
分析算法
主要涉及两个重要的概念
- 输入规模: 最佳概念依赖于研究的问题。对于许多问题,比如排序或者计算离散傅里叶变换,最自然的度量是输入中的项数。对于其他许多问题,比如两数相乘,输入规模的最佳度量则是用通常的二进制几号表示输入所需的总位数。有时候,输入规模可能需要用多个数来表示。比如某个算法输入的是一个图,则输入规模可能用该图中的顶点数和边数来描述更加合适。
- 运行时间: 指算法执行的基本操作数或步数。之所以定义成步的概念,是为了独立于机器。
对于每一行代码所执行的耗时,我们可以用c~i~ 来表示。每一行执行的次数我们可以用t~j~ 来表示。假设输入数组的规模为n。下面我们针对快排的代码来进行时间分析:
代码 | 执行耗时 | 执行次数 |
---|---|---|
for (int j = 1; j < arr.length; j++) { | c~1~ | n |
int key = arr[j]; | c~2~ | n - 1 |
int i = j - 1; | c~3~ | n - 1 |
for (; i >= 0; i--) { if (arr[i] > key) { | c~4~ | t~j~ 之和 ( 2=< j <=n ) |
arr[i + 1] = arr[i]; | c~5~ | (t~j~ - 1)之和 ( 2=< j <=n ) |
arr[i+1] = key; | c~6~ | n - 1 |
注意:上面for循环的语句执行次数比循环内部多一次,因为for循环最后会多执行一次第三个递增语句。
通过上面的表格分析可以发现,影响算法效率的关键在第4行和第5行。最好的情况下,数组本身就是排好序的,那么t~j~ 就都是1,此时总的耗时即为n的倍数。最坏的情况下,数组刚好是逆序排好的,则此时第4行和第5行要执行的步数与j有关,此时和为(2 + 3 + 4 + ... + n ), 其结果与\(n^2\)有关。
最坏情况与平均情况分析
一个算法的最佳情况往往象征着我们的美好愿望,没有什么实际的研究意义。我们更关注于一个算法的最坏情况和平均情况。因为最坏情况代表着程序运行的上界,他可以让我们对最糟糕的情况有所准备。而平均情况,可以给出我们不刻意构造输入的时候最可能运行的时间消耗,可以认为是最接近自然的时间消耗。当然大部分情况下平均情况都与最坏情况一致。比如插入排序,可以认为第4/5行代码的平均运行次数为j的一半,最终相加之后发现其结果也是\(n^2\) 相关的。
增长量级的概念
我们真正感兴趣的不在于具体运行时间表达多项式的值为多少。而是运行时间的增长率/增长量级。对于时间表达的多项式而言,我们只关注多项式的最高次数的项即可。这里我们引入Theta符号: \[\Theta\] 。
我们将插入排序的最坏运行时间记为: \(\Theta\)(\(n^2\)) 。 如果一个算法比另一个算法具有更低的增量级,我们通常可以认为具有较低增量级的算法更有效。
设计算法之分治算法
有时候一个问题如果作为一个整体来解决会显得比较棘手,此时可以考虑将一个大问题分为多个规模较小的问题。如果小问题的规模足够小的时候就可以直接求解,最后再将每个小问题的求解合并,就完成了对整个问题的求解。本人认为其实就像现在的流水作业,细化分工,本质上也是一种分治算法。一个公司的业务涉及方方面面,我们可以把每一个方面看做一个小规模的问题,公司的正常发展只要人人各司其职,解决好自己那一方面的问题就行了。至于老板,则需要整体问题的把握能力,负责整合各方面的"解"。
好了,不扯远了。直接来看下分治算法求解的三个步骤:
- 分解:分解原问题为若干子问题,这些子问题就是原问题规模较小的实例
- 解决:递归地求解各个子问题
- 合并:合并子问题的解成原问题的解
算法上的分治一种最常见的表现就是递归调用。递归调用就是一个方法不停地对自己进行调用,每次调用的问题规模都会缩小,直至达到方法返回的临界值。归并排序就是分治算法思想的一个典型应用。下面直接看归并排序的代码:
public static void mergeSort(int[] arr, int startIndex, int endIndex) {
// 这里endIndex表示不可达的下标,所以如果元素个数小于等于1则不用再排序的情况是 endIndex <= startIndex + 1
if (endIndex <= startIndex + 1) {
return;
}
int midIndex = (endIndex + startIndex) / 2;
// 递归调用求解子问题,每个子问题规模为原来的1/2
mergeSort(arr, startIndex, midIndex);
mergeSort(arr, midIndex, endIndex);
// 合并子问题的解:此时满足 [startIndex, midIndex)和[midIndex, endIndex)已经排好序
merge(arr, startIndex, midIndex, endIndex);
}
private static void merge(int[] arr, int startIndex, int midIndex, int endIndex) {
/**
* 合并策略:
* 1. 新建两个数组,分别存取左半部分排好序的数组和右半部分排好序的数组
* 2. 分别从左右两个数组最开始下标开始遍历,选取较小的依次放入原数组对应位置
* 3. 最终如果左右数组中有一个已经遍历完成,另一个数组所剩的元素直接放入元素组后面部分即可
*/
// STEP1
int[] leftArr = new int[midIndex - startIndex];
int[] rightArr = new int[endIndex - midIndex];
System.arraycopy(arr, startIndex, leftArr, 0, leftArr.length);
System.arraycopy(arr, midIndex, rightArr, 0, rightArr.length);
// STEP2
int k = startIndex; // 存储原数组中的下标
int i = 0; // 存储左边数组的下标
int j = 0; // 存储右边数组的下标
while (i < leftArr.length && j < rightArr.length) {
// 将较小的元素复制到元素组对应下标k,并且移动较小元素所在数组下标
if (leftArr[i] < rightArr[j]) {
arr[k++] = leftArr[i++];
} else {
arr[k++] = rightArr[j++];
}
}
// STEP3
if (i < leftArr.length) {
System.arraycopy(leftArr, i, arr, k, leftArr.length - i);
} else if (j <= rightArr.length) {
System.arraycopy(rightArr, j, arr, k, rightArr.length - j);
}
}
以上代码有以下三点需要注意:
- mergeSort递归的结束条件
- midIndex的计算方式
- 数组拷贝的时候我们利用了
System.arraycopy
方法,该方法是native方法,具有更好的效率。Arrays.copyOf
相关方法最终也是调用了该native方法,这点可以直接在jdk源码中看到
归并排序流程图
流程图大致如下,其中原数组中阴影部分表示还未排好序的部分。左右数组中阴影部分表示已经处理过的部分。在《算法导论》中使用了一个哨兵元素来判断是否已经到左右元素末尾,在上面的源码中我们直接根据下标来进行判断:
当然这整个流程也可以用树表示如下:
归并排序算法分析
根据归并排序的算法步骤,我们来逐步分析最坏情况下的时间复杂度:
- 分解:每一步分解相当于只是求解了待排序部分的中间位置下标,需要的是常量时间,因此:D(n) = \(\Theta\)(1)
- 解决:解决的过程是递归地解决n/2规模的问题,将需要2T(n/2)运行时间
- 合并:每一次合并其实就是遍历左右数组的元素,可以认为: C(n) = \(\Theta\)(n)
我们为了分析总的运行时间,则需要将D(n)与C(n)进行相加。这时候其实就是相当于\(\Theta\)(n)与\(\Theta\)(1)相加。相加的和仍然是一个n的线性函数。所以我们可以仍然表示成\(\theta\)(n)。再将其与步骤2中的表达式相加,得到归并排序最坏情况运行时间的T(n)递归式:
我们将时间常量用c表示,将上式简化为:
为了求解递归式我们需要借助递归树。为了方便起见我们假设问题的规模正好为2的幂(这样的话正好是一个完全二叉树)。利用递归树分析如下图:
其实不难发现每一层运行的时间代价都是cn。所以整个算法的复杂度的关键在于求究竟有多少层。我们知道最上层问题的规模是n,最下层问题的规模是1。然后每次问题的规模缩减为原来的一半。不难得出这颗递归树的总层数为:\(\log\)~2~n + 1,简单记为:lgn + 1。所以递归表达式的总时间消耗为: cnlgn + cn。忽略低阶项和常量项,得到最坏运行时间的表达式为: \(\Theta\)(nlgn)。
算法基础相关练习与思考
递归实现二分查找法
其实也是一种分治的思想,逐步的减小问题的规模,直至找到对应的元素。只不过该算法最终不需要将每个小规模的求解合并。
private static int binarySearch(int[] arr, int lowIndex, int upIndex, int searchValue) {
if (upIndex < lowIndex + 1) {
return -1;
}
int midIndex = (lowIndex + upIndex) / 2;
if (arr[midIndex] == searchValue) {
return midIndex; // 相等则返回当前下标
} else if (arr[midIndex] < searchValue) {
// 小于要查找的值则查找右半部分大值区间
return binarySearch(arr, midIndex + 1, upIndex, searchValue);
} else {
// 大于要查找的则查找左半部分小值区间
return binarySearch(arr, lowIndex, midIndex, searchValue);
}
}
该算法的时间复杂度就在于分解成子问题的次数,因为每个子问题都是直接取中间元素与当前元素对比,可以认为时间复杂度为\(\Theta\)(1)。所以整个二分查找法的最坏时间复杂度为:\(\Theta\)(lgn)。
二分查找法优化插入排序效率
由上面对插入排序的最坏时间分析可知。插入排序的最坏时间出现在输入数组正好与希望的排序结果倒序排列。对于下标为i的元素,此时仍需要比较i次。用二分查找虽然可以加快得出元素应该插入的位置,但如果输入数组还是逆序的话,移动次数不会改变,所以无法正真优化插入排序的最坏时间。
冒泡排序
冒泡排序的得名来自于其算法的过程类似于冒泡:以从小到大的顺序来说,每次交换相邻的两个元素,直至最小的元素冒泡到未排序的部分最左边。其java实现代码如下:
private static void bubbleSort(int[] arr) {
// i可以看做是未排序数组的最左端元素下标,每次循环最左端冒泡出最小的元素,i依次递增
for (int i = 0; i < arr.length - 1; i++) {
// j可以看做未做排序的部分元素下标集合,依次对比冒泡
for (int j = arr.length - 1; j > i; j--) {
if (arr[j] < arr[j - 1]) {
int temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
}
}
}
}
其实冒泡排序和选择排序的过程基本类似,只不过选择排序是每次遍历未排序部分选择最小元素,冒泡排序时对未排序部分依次两两对比。最坏时间复杂度都是: \(\Theta\)(\(n^2\))。
归并排序中对小数组使用插入排序优化
虽然归并排序的最坏情况运行时间为Θ(nlgn),而插入排序的最坏情况运行时间为Θ(n2),但是插入排序中的常量因子可能使得它在n较小时,在许多机器上实际运行得更快。因此,在归并排序中当子问题变得足够小时,采用插入排序来使递归的叶变粗是有意义的。考虑对归并排序的一种修改,其中使用插入排序来排序长度为k的n/k个子表,然后使用标准的合并机制来合并这些子表,这里k是一个待定的值。
- 证明:插入排序最坏情况可以在\(\Theta\)(nk)时间内排序每个长度为k的n/k个子表。
- 表明在最坏情况下如何在\(\Theta\)(nlg(n/k))时间内合并这些子表。
- 假定修改后的算法的最坏情况运行时间为Θ(nk+nlg(n/k)),要使修改后的算法与标准的归并排序具有相同的运行时间,作为n的一个函数,借助Θ记号,k的最大值是什么?
- 在实践中,我们应该如何选择k?
- 由前面得插入排序的最坏时间复杂度为: \(\Theta\)(n/k * \(k^2\)) = \(\Theta\)(nk)
- 因为最终采用分治算法分到最底层每组元素为k。那么这个分组实际上一共经过了: lg(n/k) + 1次。又每一层合并所需时间为:cn。所以最坏时间复杂度为: \(\Theta\)(nlg(n/k))
- 如果修改后的归并排序时间与原来时间一致,则有: \(\Theta\)(nlg(n)) = \(\Theta\)(nk + nlg(n/k)) \(\Rightarrow\) Θ(k+lg(n/k)) = Θ(lgn) \(\Rightarrow\) k的最大值应该为lgn
- 实践中,k的值应该选为使得插入排序比合并排序快的最大的数组长度。很容易理解,假设k=1,那么退化为标准合并排序,那么要提高效率需放大k,k放大到使得array[k]使用插入排序比合并排序快,而array[k+1]的插入排序效率不如或等于合并排序