Hello, 小伙伴们, 算法系列又更新了, 在上篇(入门篇)文章中介绍了两种排序算法, 分别是冒泡排序和插入排序,但是这两种排序算法的时间复杂度都是平方阶的,那在这篇文章中会对冒泡排序进行优化,并且会引入第三种排序算法—— 快速排序。
声明:算法核心思想都是一样的, 不分编程语言, 但是在本系列文章中主要会用 python 或者 JavaScript 语言来实现相关代码, 特此说明一下。
想查看更多的文章请关注公众号:IT巡游屋
在上篇文章中我们分析了冒泡排序算法,不管是什么情况,冒泡排序算法的时间复杂度始终是平方阶,但是比方说一个数组本身就是排好序的数组,或者在执行一些步骤时就已经排好序了,那么理想的状态就应该停止执行。
下面先复习下之前的冒泡排序算法:
现在对序列 A 进行冒泡排序, 步骤如下: 1. 对比第 1 个元素(记为 A[1]) 和 第 2 个元素(记为 A[2]), 如果A[1] > A[2], 就交换这两个元素; 2. 再次对比 A[2] 和 A[3], 如果 A[2] > A[3], 就交换这两个元素; 3. 重复上述步骤, 直到对比 A[n-1] 和 A[n], 如果 A[n-1] > A[n], 就交换这两个元素, 此时 A[n] 一定是最大的那个元素; 4. 再次对 A[1...n-1] 循环执行第 1~3 的步骤, 直到 对比 A[n-2] 和 A[n-1], 如果 A[n-2] > A[n-1], 就交换这两个元素; 5. 重复执行步骤 4, 直到排序完成.核心思想: 在原来的基础上设置一个 标记变量, 初始值为 true, 如果在一次循环中并没有进行任何交换, 就停止排序算法.
为了便于对上述算法更简单的理解, 我们用一个具体的序列 A = [ 8 , 3 , 1 , 5 , 4 ] {A = [8, 3, 1, 5, 4]} A=[8,3,1,5,4] 演绎一下冒泡排序的执行步骤.
第 1 次循环 —— j = 1 {j=1} j=1, i {i} i 循环 4 次
A = [8, 3, 1, 5, 4] 1. 首先比较 A[1]=8 与 A[2]=3, 此时 8 > 3, 将 A[1] 和 A[2] 交换位置, 此时 A = [3, 8, 1, 5, 4]; 2. 然后比较 A[2]=8 与 A[3]=1, 此时 8 > 1, 将 A[2] 和 A[3] 交换位置, 此时 A = [3, 1, 8, 5, 4]; 3. 然后比较 A[3]=8 与 A[4]=5, 此时 8 > 5, 将 A[3] 和 A[4] 交换位置, 此时 A = [3, 1, 5, 8, 4]; 4. 然后比较 A[4]=8 与 A[5]=4, 此时 8 > 4, 将 A[4] 和 A[5] 交换位置, 此时 A = [3, 1, 5, 4, 8]; 此时可以看到 A[5] = 8 就是最大值;图形演示如下:
第 2 次循环 —— j = 2 {j=2} j=2, i {i} i 循环 3 次
此时 A = [3, 1, 5, 4, 8] 1. 首先比较 A[1]=3 与 A[2]=1, 此时 3 > 1, 将 A[1] 和 A[2] 交换位置, 此时 A = [1, 3, 5, 4, 8]; 2. 然后比较 A[2]=3 与 A[3]=5, 此时 3 < 5, 进入下一轮循环; 3. 然后比较 A[3]=5 与 A[4]=4, 此时 5 > 4, 将 A[3] 和 A[4] 交换位置, 此时 A = [1, 3, 4, 5, 8];图形演示如下:
第 3 次循环 —— j = 3 {j=3} j=3, i {i} i 循环 2 次
此时 A = [1, 3, 4, 5, 8] 1. 首先比较 A[1]=1 与 A[2]=3, 此时 1 < 3, 进入下一轮循环; 2. 然后比较 A[2]=3 与 A[3]=4, 此时 3 < 4, 不发生交换; 3. 可知在此轮循环中未发生交换, 此时退出冒泡排序.可知, 如果用原来的冒泡排序算法, 需要经历 4 个步骤(循环), 但是 经过优化之后只需 3 个步骤 就完成排序了.
为了便于计算时间复杂度, 对于单行代码执行的时间复杂度记为 1。最坏的情况此时就不分析了, 因为和上一次是一致的, 也就是平方阶 O ( n 2 ) {O(n^2)} O(n2) 。
但是最好的情况, 比如本身就是已经排好序的数组, 那么只需执行一轮循环就可以了,此时冒泡排序的时间复杂度就是线性阶 O ( n ) {O(n)} O(n) 了,可见此时时间复杂度就明显降低了。
核心思想: 此优化方法是在优化一的基础上的进一步优化, 主要是为了减少二层循环的次数, 记录上一轮未经过排序的元素的最大下标, 那么下一次循环的最大次数只需到达这个位置就可以, 无需再往后进行循环了.
为了便于对上述算法更简单的理解, 我们用一个具体的序列 A = [ 8 , 3 , 1 , 4 , 5 ] {A = [8, 3, 1, 4, 5]} A=[8,3,1,4,5] 演绎一下冒泡排序(优化二)的执行步骤.
第 1 次循环 —— j = 1 {j=1} j=1, i {i} i 循环 4 次
A = [8, 3, 1, 4, 5] 1. 首先比较 A[1]=8 与 A[2]=3, 此时 8 > 3, 将 A[1] 和 A[2] 交换位置, 此时 A = [3, 8, 1, 4, 5]; 2. 然后比较 A[2]=8 与 A[3]=1, 此时 8 > 1, 将 A[2] 和 A[3] 交换位置, 此时 A = [3, 1, 8, 4, 5]; 3. 然后比较 A[3]=8 与 A[4]=4, 此时 8 > 4, 将 A[3] 和 A[4] 交换位置, 此时 A = [3, 1, 4, 8, 5]; 4. 然后比较 A[4]=8 与 A[5]=5, 此时 8 > 5, 将 A[4] 和 A[5] 交换位置, 此时 A = [3, 1, 4, 5, 8]; 此时可以看到 A[5] = 8 就是最大值, 同时未经过排序的元素的最大下标就是 4, 指的是 A[4] = 5 这个元素;图形演示如下:
第 2 次循环 —— j = 2 {j=2} j=2, i {i} i 循环 3 次
此时 A = [3, 1, 4, 5, 8] 1. 首先比较 A[1]=3 与 A[2]=1, 此时 3 > 1, 将 A[1] 和 A[2] 交换位置, 此时 A = [1, 3, 4, 5, 8]; 2. 然后比较 A[2]=3 与 A[3]=4, 此时 3 < 4, 进入下一轮循环; 3. 然后比较 A[3]=4 与 A[4]=5, 此时 4 < 5, 进入下一轮循环; 未经过排序的元素的最大下标是 1, 指的是 A[1] = 1 这个元素.图形演示如下:
第 3 次循环 —— j = 3 {j=3} j=3, i {i} i 循环 0 次
此时 A = [1, 3, 4, 5, 8] 此时第二层循环直接退出, 不再进行, 也就是没有发生交换, 退出冒泡排序.可知, 如果用原来的冒泡排序算法, 需要经历 4 个步骤(循环), 但是 经过优化之后也只需 3 个步骤 就完成排序了.
为了便于计算时间复杂度, 对于单行代码执行的时间复杂度记为 1。最坏的情况此时就不分析了, 因为和上一次是一致的, 也就是平方阶 O ( n 2 ) {O(n^2)} O(n2) 。
但是最好的情况, 就是本身就是已经排好序的数组, 那么也是只需执行一轮循环就可以了,此时冒泡排序的时间复杂度就是线性阶 O ( n ) {O(n)} O(n) 了,可见此时时间复杂度就明显降低了,对于某些情况,此优化方式比优化一的执行次数更少, 算法更优。
从以上分析我们可以发现, 不管如何优化冒泡排序算法, 它的时间复杂度始终都是平方阶的, 插入排序也同样, 那有没有一种排序算法的时间复杂度不是平方阶的呢? 答案是有的, 那就是将要介绍的 快速排序 算法。
声明: 之前在介绍算法时, 数组的第一个元素都是从 1 开始的, 考虑到大部分读者都是有一定的编程基础的, 那从这里开始, 数组的第一个元素就从 0 开始.
现在对序列 A 进行快速排序, 假设 end = A.length -1, 步骤如下:
首先取出数组中的任意一个元素比如 A[0] = pivotKey(主元), 然后将 元素pivotKey 与数组中的所有元素依次对比, 如果元素比 pivotKey 小, 就交换这两个元素的位置, 最终可以保证 元素pivotKey 的左侧都是比它小的元素, 右侧都是比它大的元素, 假设此时 元素pivotKey 的位置是 pivot;
然后再对 A[0, pivot - 1] 和 A[pivot + 1, end] 执行 1 的步骤.
从上面可以看出最关键的就是步骤 1 , 它将数组分为了 三个部分, 分别是 A[0, pivot - 1], A[pivot], A[pivot + 1, end].
快速排序算法理解起来稍微有些困难, 为了便于更简单的理解, 我们用一个具体的序列 A = [ 4 , 1 , 5 , 8 , 3 ] {A = [4, 1, 5, 8, 3]} A=[4,1,5,8,3] 演绎一下快速排序算法的执行步骤.
对 A[0, 4]= [4, 1, 8, 5, 3] 执行快速排序算法
此时参数 A = [4, 1, 5, 8, 3], start=0, end=4 由于 start < end, 进入算法 1. 首先执行 Partition(A, 0, 4), 选取第一个元素 A[0]=4 为主元, 即 pivotKey=4, 并将此元素和数组中的所有元素对比, 初始 sep=0: (1) 当 j=1 时, 比较 pivotKey=4 与 A[1] = 1, 知 4 > 1, 此时 sep++ 也变为 1, 将 A[1] 与 A[1] 位置交换, A 不变; (2) 当 j=2 时, 比较 pivotKey=4 与 A[2]=5, 知 4 < 5, A 不变; (3) 当 j=3 时, 比较 pivotKey=4 与 A[3]=8, 知 4 < 8, A 不变, A = [4, 1, 5, 8, 3]; (4) 当 j=4 时, 比较 pivotKey=4 与 A[4]=3, 知 4 > 3, 此时 sep++ 变为 2, 接着将 A[2] 与 A[4] 交换位置, 此时 A=[4, 1, 3, 8, 5], 退出循环; (5) 此时 sep=2, 将 A[0] 与 A[2] 交换位置, 此时 A = [3, 1, 4, 8, 5]; (6) 最终返回 sep=2, 此时可以看出 A[2] = 4 前面的元素都比 4 小, 后面的元素都比 4 大; 2. 对 A[0, 1] = [3, 1] 和 A[3, 4] = [8, 5] 进行快速排序, 重复上述步骤.图形演示如下:
说明:
选取第一个元素为主元 p i v o t K e y = A [ s t a r t ] {pivotKey= A[start]} pivotKey=A[start];
浅阴影部分的数组元素都比 p i v o t {pivot} pivot 小, 深阴影部分的数字组元素都比 p i v o t {pivot} pivot 大, 无阴影部分是还未进行划分的元素(除去第一个元素);
在 第(6)步中, 将 A [ p i v o t ] {A[pivot]} A[pivot] 与 A [ s e p ] {A[sep]} A[sep] 交换, 此时 A [ s e p ] {A[sep]} A[sep] 左侧的元素都比它小, 右侧的元素都比它大。
对A[0, 1] = [3, 1] 执行快速排序算法
此时参数 A = [3, 1, 4, 8, 5], start=0, end=1 由于 start < end, 进入算法 1. 首先执行 Partition(A, 0, 1), 选取第一个元素 A[0]=3 为主元, 即 pivotKey=3, 并将此元素和数组中的所有元素对比, 初始 sep = 0: (1) 当 j=1 时, 比较 pivotKey=3 与 A[1]=1, 知 3 > 1, 此时 sep++ 也为 1, 接着将 A[1] 与 A[1] 位置交换, A 不变, 退出循环; (2) 此时 sep=1, 将 A[0] 与 A[1] 交换位置, 此时 A = [1, 3, 4, 8, 5]; (3) 最终返回 sep=1; 2. 对 A[0, 0] = [1] 再次进行快速排序, 实际上会直接退出, 不会进入算法.这一步比较简单, 所以就不在提供图形演示了, 只要理解了上面图形演示, 拿这一步就很简单了。
对A[3, 4] = [8, 5] 执行快速排序算法
此时参数 A = [1, 3, 4, 8, 5], start=3, end=4 由于 start < end, 进入算法 1. 首先执行 Partition(A, 3, 4), 选取第一个元素 A[3]=8 为主元, 即 pivotKey=8, 并将此元素和数组中的所有元素对比, 初始 sep=3: (1) 当 j=4 时, 比较 pivotKey=8 与 A[4]=5, 知 8 > 5, 此时 sep++ 也是 4, 接着 将 A[4] 与 A[4] 位置交换, A 不变, 退出循环; (3) 此时 sep=4, 将 A[3] 与 A[4] 交换位置, 此时 A = [1, 3, 4, 5, 8]; (4) 最终返回 sep = 4; 2. 对 A[3, 3] = [5] 再次进行快速排序, 实际上会直接退出, 不会进入算法, 排序结束.我们今天用 JavaScript 实现以下快速排序的算法
// QuickSort(A, start, end) 快速排序, 参数A表示一个数组, start 表示开始索引, end 表示结束索引, 也就是对子数组 A[start, end] 进行快速排序算法 function QuickSort(A, start, end) { if (start >= end) return A; pivot = Partition(A, start, end); QuickSort(A, start, pivot - 1); QuickSort(A, pivot + 1, end); return A; } function Partition(A, start, end) { var pivotKey = A[start]; var sep = start; for (var j = start + 1; j <= end; j++) { if (A[j] < pivotKey) { sep++; var tmp = A[sep]; A[sep] = A[j]; A[j] = tmp; } } var tmp = A[start]; A[start] = A[sep]; A[sep] = tmp; return sep; } // 算法测试, 通过调试可以清楚的看到上面的算法演绎的过程 A = [4, 1, 5, 8, 3] // 调用方式 console.log(QuickSort(A, 0, A.length-1));由于快速排序的时间复杂度分析设计到专业的数学知识, 以后可能会专门出一篇文章进行详细的分析, 在这里不过多赘述。其实快速排序算法的时间复杂度主要取决于每次执行 Partition 时数组如何划分:
**最坏情况划分: ** 当划分产生的两部分分别包含 n-1 个元素 和 0 个元素时, 就是最坏情况, 那在执行划分过程时, 需要循环 n-1 次, 假设每次都是这种情况, 那共需要循环 1 + 2 + . . . + ( n − 1 ) = n ∗ ( n − 1 ) / 2 1 + 2 + ... + (n-1) = n*(n-1)/2 1+2+...+(n−1)=n∗(n−1)/2
此时时间复杂度还是 O ( n 2 ) {O(n^2)} O(n2) 。
**最好情况划分: ** 最理想的情况, 就是每次划分都是平均的, 也就是一半的元素小于主元, 一半的元素大于主元, 此时时间复杂度就是 O ( n l g n ) {O(nlgn)} O(nlgn)。
**平均情况: ** 快速排序的平均情况更接近于最好情况, 所以只要每次划分的两部分是常数比例, 算法的时间复杂度就还是 O ( n l g n ) {O(nlgn)} O(nlgn)。
综上所述, 快速排序的平均时间复杂度是 O ( n l g n ) {O(nlgn)} O(nlgn), 这个时间复杂度远小于 O ( n 2 ) {O(n^2)} O(n2), 也就是说 快速排序 在大多数情况下都是优于 冒泡排序 或者 插入排序 的, 这也是为什么很多家公司面试时必问快速排序算法的原因。在本系列的下一篇文章中, 我们将介绍其他常见的排序算法 —— 归并排序算法并分析其时间复杂度。
