🐟前言
对于很多程序员而言,第一次接触的算法就是排序。他无处不在,很多语言都会提供内置排序的函数,在平常的项目中我们也希望能针对特定的数据顺序编写一个排序函数。排序非常重要,所以我们可以多花点时间来学习一下一些经典的排序算法。
我会将今天讲的排序算法们分为三种,当然是以时间复杂度去区分它们,分别是O(n²),O(nlogn),O(n)
首先我们来讲时间复杂度为O(n²)的几种排序算法,在讲解排序之前,我们需要知道怎么去分析一个排序算法,学会如何写一个排序算法固然重要,但是更重要的是学会如何去评价和分析!
评估排序算法
评估参数可以分为三类 分别是执行效率、内存消耗、稳定性
执行效率
1. 最好情况、最坏情况、平均情况时间复杂度
- 最好情况时间复杂度:指的是在特定输入数据下,算法所能达到的最快执行速度。例如,冒泡排序在数据已经有序的情况下,只需要进行一趟扫描即可确认数据已排序,此时时间复杂度为O(n)。
- 最坏情况时间复杂度:反映了算法在面对最不利输入数据时的性能。冒泡排序在数据完全逆序的情况下,需要进行n(n-1)/2次比较和交换,时间复杂度为O(n^2)。
- 平均情况时间复杂度:考虑了所有可能输入数据的概率分布,给出了算法在一般情况下的执行速度。对于某些排序算法,如快速排序,其平均时间复杂度为O(n log n),但在最坏情况下会退化到O(n^2)(尽管通过随机化或三数取中可以降低这种可能性)。
区分这三种时间复杂度有助于我们更全面地了解算法在不同情况下的性能表现,从而选择合适的算法。
2. 时间复杂度的系数、常数、低阶
虽然时间复杂度主要关注数据规模n的增长趋势,但在实际开发中,特别是当数据规模较小时,系数、常数和低阶项也可能对算法性能产生显著影响。例如,插入排序在n较小时可能比快速排序更快,因为其具有较小的常数和低阶项。因此,在对比同一阶时间复杂度的排序算法时,我们需要考虑这些因素。
3. 比较次数和交换(或移动)次数
对于基于比较的排序算法,比较次数和交换(或移动)次数是衡量其执行效率的重要指标。比较次数反映了算法在判断元素顺序时所需的操作次数,而交换(或移动)次数则反映了算法在调整元素位置时所需的操作次数。
- 比较次数:通常与算法的时间复杂度密切相关。例如,快速排序在每次划分过程中都需要进行多次比较以确定元素的归属。
- 交换(或移动)次数:在某些情况下,即使比较次数相同,交换(或移动)次数的差异也可能导致算法性能的不同。例如,冒泡排序在每次发现逆序对时都需要进行交换,而插入排序则通过移动元素来构建有序序列。
内存消耗与原地排序
-
内存消耗:
- 排序算法的内存消耗可以通过空间复杂度来衡量。空间复杂度描述了算法在运行过程中临时占用存储空间的大小。
-
原地排序:
- 原地排序(Sorted in place)是指空间复杂度为 O(1) 的排序算法。这意味着算法在运行过程中只需要常数级别的额外存储空间,而不随输入数据的规模增大而增加。
- 常见的原地排序算法包括冒泡排序、选择排序、插入排序和快速排序(在某些实现中)。
排序的稳定性
-
稳定性定义:
- 排序的稳定性是指,在待排序的序列中存在值相等的元素时,经过排序之后,相等元素之间原有的先后顺序不变。
- 稳定性在排序算法中是一个重要的特性,因为它在某些应用场景下非常有用。
-
稳定性示例:
- 假设有一组数据
[2, 9, 3, 4, 8, 3],按照大小排序之后是[2, 3, 3, 4, 8, 9]。如果两个3的前后顺序没有改变,则这种排序算法是稳定的。
- 假设有一组数据
现在开始我们的正题
冒泡排序
相信冒泡排序许多人都曾有耳闻,他的名字和他的实现过程很相似,你可以想象,把数据从上到下排列,然后最下层的数据往上层漂浮,像泡泡一样。如果比上一层大就交换,比他小,那上一层就成为泡泡替换他去漂浮,直到最顶层!
每冒泡一次就可以确认一个元素的最终位置!所以要想完成所有元素的位置,我们只需要重复六次,就可以完成冒泡排序
我们会发现上面的冒泡排序其实在第五次之后就已经没有进行数据交换了(说明已经有序了),所以我们可以加入一个标志位,去判断是否需要提前退出循环。
这里是优化之后的冒泡排序实现:
function bubbleSort(a) {
const n = a.length;
if (n <= 1) return;
for (let i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
let flag = false;
for (let j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j + 1]) {
// 交换
const tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
现在我们根据上面的三个方面去分析一下冒泡排序
分析冒泡排序
时间复杂度
- 它的最高时间复杂度是 O(n),就是它本身就是有序的数据,我们只需要遍历一次这个数据,发现它是有序的,我们的标志位就会帮我们退出循环,所以时间复杂度是O(n)。
- 最坏时间复杂度是 O(n²),当然是倒序的数据,每次都需要进行遍历数据比较大小然后交换,直到最后一个元素落到该在的位置
- 要想分析平均时间复杂度,我们需要引入这几个概念,有序度和逆序度,以及满序度
有序度和逆序度,以及满序度
有序度是数组中具有有序关系的元素对的个数。
同理,对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。我们把这种完全有序的数组的有序度叫作满有序度。
逆序度的定义与有序度恰恰相反,关于这三个概念,有一个数学等式关系: 逆序度 = 满有序度 - 有序度。我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。 拿刚刚的例子举例就是:
对于包含 n 个数据的数组进行冒泡排序,最坏情况下初始有序度为 0,即数组完全逆序,需要进行
n*(n-1)/2 次交换;最好情况下初始有序度为 n*(n-1)/2,即数组已经完全有序,不需要进行任何交换。在平均情况下,初始有序度既不高也不低,可以假设数组中的元素是随机分布的,此时交换次数约为 n(n-1)/4*。这一估计值反映了冒泡排序在处理一般情况下的平均性能,帮助我们更好地理解算法在不同初始条件下的表现。
换句话说,平均情况下,冒泡排序需要进行 n*(n-1)/4 次交换操作。比较操作的次数更多,总次数为n*(n-1)/2。由于交换和比较操作的总复杂度都是 O(n^2),因此平均情况下的时间复杂度仍然是 O(n^2) 。
插入排序
插入排序是一种简单直观的排序算法,其核心思想是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。这种方法类似于我们在日常生活中整理扑克牌的过程。
首先我们需要讲需要排序的数组分为两个区间,一个是已排序,另一个是未排序,然后是我们需要初始化已排序的区间只有一个元素,便是数组的第一个元素。
插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
如图所示,要排序的数据是 4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间。
插入排序包含两种操作:元素的比较和元素的移动。当需要将一个数据 a 插入到已排序区间时,需要将 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点后,将插入点之后的元素顺序往后移动一位,腾出位置给 a 插入。
对于不同的查找插入点方法(从头到尾或从尾到头),元素的比较次数会有所不同。但对于一个给定的初始序列,移动操作的次数总是固定的,等于逆序度。
例如,对于数组 [4, 5, 6, 1, 3, 2],满有序度为 n×(n−1)/2=15,初始序列的有序度为 5,因此逆序度为 10。在插入排序过程中,数据移动的总次数也等于 10,具体为 3 + 3 + 4。这表明每次插入操作中,移动的次数正好对应了逆序对的数量。
代码实现如下
function insertionSort(a) {
var n = a.length;
if (n <= 1) return;
for (var i = 1; i < n; ++i) {
var value = a[i];
var j = i - 1;
// 查找插入的位置
for (; j >= 0; --j) {
if (a[j] > value) {
a[j + 1] = a[j]; // 数据移动
} else {
break;
}
}
a[j + 1] = value; // 插入数据
}
}
然后还是老三样问题,需要你去分析一下插入算法。
分析插入算法
时间复杂度
-
如果要排序的数据已经是有序的,插入排序算法并不需要搬移任何数据。在这种情况下,从尾到头查找插入位置时,每次只需比较一个数据即可确定插入位置,因此最佳时间复杂度为 O(n)。
-
然而,如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新数据,需要移动大量数据,导致最坏情况时间复杂度为 O(n2)。
-
数组中的元素是随机分布的。对于每个元素,平均需要进行 i−1/2 次比较和 i−1/2 次移动。这样算平均时间复杂度就为
内存消耗与原地排序
插入排序是原地排序,它的运行过程中并不依赖什么额外的存储空间,所以是O(1)。
排序的稳定性
这里如果是值相同的元素,我们可以把后出现的元素放在前面出现元素的后面,这样就是一个稳定的排序算法了
选择排序
选择排序人如其名,我们可以理解为每次都选择最小的元素出来去放到以排序区间的末尾,他也和插入排序一样,把数组分为两个区间,已排序区间和未排序区间。
这里我就不给大家代码的具体实现了,大家可以自己去code一下,看能不能试着写出来,我给大家基本的思路。
-
初始化:
- 将数组的第一个元素视为已排序部分的开始,其余部分视为未排序部分。
-
迭代过程:
- 遍历未排序部分,找到最小(或最大)的元素。
- 将找到的最小元素与未排序部分的第一个元素交换位置。
-
重复步骤:
- 将已排序部分的范围扩大一个元素。
- 继续对剩余的未排序部分进行同样的操作,直到整个数组排序完成。
分析选择排序算法
时间复杂度
选择排序的实现很特殊,他每次都需要找到最小的元素,需要遍历数组大小的次数,然后会执行n遍这样的操作。所以无论你是否本来就是有序的,还是逆序的,都需要执行 O(n²)次
内存消耗与原地排序
它的空间复杂度为O(1)
排序的稳定性
它是他们三兄弟中唯一不稳定的排序算法,比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。
END
读完全文你会发现你之后会对排序算法有了新的认识,不仅仅是记忆它的解法,而是理解它的灵魂,通过对排序算法的深入理解,我们可以更好地选择和应用它们,甚至在特定场景下进行优化。这样就可以真正的扩展和运用算法!
本文参考《数据结构与算法之美》,有什么问题欢迎大家在评论区沟通交流!