基础的排序算法
人类对排序算法的使用,最早可以追溯到美索不达米亚地区的古巴比伦文明,它是世界上最早的文明之一。古巴比伦地处肥沃的新月地带,主要种植小麦、大麦、棉花,农业非常发达。古巴比伦也是贸易中心,古巴比伦人通过陆路和水路,与其它地区进行贸易,包括粮食、牲畜、纺织品、金属等。为了管理大量的商品,商人需要对数量和价格的记录排好顺序。
古巴比伦人在数学和天文学方面也有卓越的成就。他们的天文学观测非常精确,能够预测日食和月食。使用的六十进制(sexagesimal)系统,至今仍然用于测量时间和角度。古巴比伦文明留下了许多数学表格,比如乘法表和平方表,其中乘法表(“plimpton 322”泥板)包含了一系列排序好的勾股数。这些数列表明古巴比伦人对排序和数列有深入的理解,能够利用这些排序好的数列进行复杂的数学计算。
尽管我们无法确定古巴比伦人是否使用了系统的排序算法,但为了提高效率,他们确实在日常生活和科学研究中使用了排序技术。古巴比伦人对排序的应用展示了人类在早期文明中对数据管理和组织的智慧。
排序算法是打开计算机科学之门的一把钥匙。在学习更有效率的排序算法之前,先来看几个已经在生活中存在的排序算法,尽管它们效率很低,在实际应用中极少使用,但是它们的历史和发展见证了计算机科学早期算法研究的进步和演变,在计算机科学史上的地位依然不可忽视。作为基础的排序算法,它们的思想和实现方式都很简单,对于初学者学习算法和排序问题也是很好的切入点。
冒泡排序
冒泡排序(Bubble Sort)是一种简单的排序算法,它的起源并不清楚,由于它简单易懂的特性,在20世纪50年代已经被人们广泛使用和研究。在1956年的一篇论文中,数学家兼精算师 Edward Harry Friend 对冒泡排序做了描述,不过论文并未引起注意。几年后,许多计算机科学家重新发现了冒泡排序,Kenneth E. Iverson 为这种算法起名冒泡排序。
冒泡排序算法的原理比较简单,如果要将数列从小到大排列,先用数列的第一个元素跟第二个元素比较,视情况交换位置,让它满足第一个元素小于第二个元素。接着比较第二个元素和第三个元素,让第二个元素小于第三个元素,依此类推,直至最后一个元素,现在数列中最大的元素就排在了数列的最后一项,第一轮比较结束。接着继续第二轮比较,不过这一轮只需要比较到倒数第二个元素,比较结束后,整个数列第二大的元素位于倒数第二的位置。不断重复这个过程,整个数列就排好序了。
它通过相邻元素两两比较,将高位元素冒泡到右边(或大到小排列将低位元素冒泡到左边),重复这个过程,直至比较完所有元素。
举个例子,假设要排序数组: [3,42,1,5,34,20,9]
。
第一轮
将3
与42
比较,42
大于3
,不需要交换位置;将42
与1
比较,交换它们的位置;依次重复这个过程,直至比较到最后一个元素,这一轮结束后,最大的元素处在数组最后一项:[3,1,5,34,20,9,42]
。第二轮
将3
与1
比较,3
大于1
,交换它们的位置;将3
与5
比较,不需要交换位置;接着用5
和下一个元素比较...;依次重复这个过程,直至比较到倒数第二个元素,第二大的元素位于数组倒数第二个位置:[1,3,5,20,9,34,42]
。
经过六轮排序后,最终会变成有序数组:[1,3,5,9,20,34,42]
。
fn bubble_sort<T: Ord>(list: &mut [T]) {
for i in 0..list.len() {
for j in 0..(list.len() - i - 1) { // 注意边界
if list.get(j) > list.get(j+1) {
list.swap(j, j+1);
}
}
}
}
冒泡排序是在原地的排序,在原数组就能排好序,不需要返回。交换元素需要一个额外空间存储临时元素,因此冒泡排序的空间复杂度是 $O(1)$。冒泡排序是一种稳定的排序算法,也就是说两个相同元素的相对位置不会被改变。
第一轮需要比较 $n-1$ 次,第二轮需要比较 $n-2$ 次…… 总共有 $n$ 个元素,需要比较 $n-1$ 轮。不难算出总共需要比较的次数:$(n-1) + (n-2) + (n-3) + … + 1 = (n-1) \cdot \frac {(n-1) + 1}{2}$,冒泡排序的时间复杂度是 $O(n^2)$。
冒泡排序过程中存在很多重复的比较,有一些办法可以做一些优化,少做些比较。比如设置一个标识,记录上一次排序两个元素是否交换了位置,如果没有交换位置,表示上一轮从开始到中间(本轮结束位置)的元素是有序的,因此不需要交换位置,可以提前结束本轮比较进入下一轮。
冒泡排序是一种蛮力排序算法,即便有多种优化办法,但毕竟无法消除大部分重复的比较,不管最好情况还是最差情况,它的时间复杂度都是 $O(n^2)$。
选择排序
选择排序(Selection Sort)算法的原理简单且直观,具体的起源和发明人不详,但是在20世纪50年代已经被广泛用于教学和研究,在计算机科学早期的发展中也得到了应用。
选择排序算法的原理很简单,对于要排序的数组,从第一个元素开始到最后一个元素,不断比较找出最小的元素,然后和第一个元素交换位置,现在第一个元素就是最小的。接下来从第二个元素开始,找出剩余数组部分最小的元素,跟第二个元素交换位置。依此类推,直至最后一个元素。
算法从逻辑上将数组分成两个部分:[已排序部分|未排序部分]
,随着多轮选择最小元素,未排序元素不断减少,已排序部分不断增多,最终整个数组就被排好了顺序。
举个例子,假设要排序数组:[8, 3, 9, 6, 7]
。
第一轮
选取第一个元素8
假设是最小的元素,跟下一个元素比较后发现3
才是最小的,接着3
继续往后比较直到末尾,这一轮确定最小的值就是3
,它在数组第二个位置,跟首个元素交换位置后变成:[3, 8, 9, 6, 7]
。第二轮 选取第二个元素
8
假设是最小的元素,跟剩余元素逐个比较,最后发现最小元素是6
,将第二个元素的8
与6
交换位置后得到:[3, 6, 9, 8, 7]
。
经过四轮比较和交换位置之后,最终得到有序数组:[3, 6, 7, 8, 9]
。
fn selection_sort<T: Ord>(list: &mut [T]) {
for i in 0..list.len() {
let mut lowest = i;
for j in (i+1)..list.len() {
if list.get(j) < list.get(lowest) {
lowest = j;
}
}
list.swap(i, lowest);
}
}
在选择最小元素过程中,如果遇上相同的元素,会选择最后面那一个,将它放到有序部分的末尾。下一次选择最小元素时,也是选择最末尾的一个,然后插入到有序部分的末尾。在这个过程里,两个相等的元素交换了相对位置。因此说选择排序算法是不稳定的排序算法。
选择排序算法是一种原地排序,交换元素需要一个额外的空间存储临时元素,因此选择排序的空间复杂度是 $O(1)$。
要找出最小元素,第一轮需要比较 $n-1$ 次,第二轮比较 $n-2$ 次,最后一轮比较 $1$ 次,总共需要的比较次数:$(n-1) + (n-2) + (n-3) + … + 1 = \frac{(n-1)((n-1) + 1)}{2} = \frac {n^2 - n}{2}$,因此,选择排序的时间复杂度是 $O(n^2)$
相比较冒泡排序,选择排序真正需要交换数据的操作很少,在数组顺序刚好相反的极端情况下,最多需要交换 $n-1$ 次。在同等情况下,冒泡排序排序则需要交换 $\frac {n^2}{2}$ 次,在这一方面,选择排序算法略优于冒泡排序算法。但是选择排序需要重复扫描数据序列,只为找出这一次的最小值(或最大值),给人感觉非常笨拙,这个过程中存在着大量重复工作,跟冒泡排序并没有本质上的区别。
插入排序
插入排序(Insertion Sort)算法原理也很简单,它的起源和发明人也是未知,计算机科学形成之前,它很可能已经存在于各种手工排序方法中。这种算法思想在我们生活中很常见,我们玩扑克牌的时候,我们会将新摸到的牌,按某种顺序插入现有的牌中。
对于要排序的数组,选取第二个元素和第一个比较,如果小于第一个,就将第二个元素插入第一个元素的位置,原来的元素就往后挪。然后选择第三个元素,跟前面的元素从头开始比较,找到合适的位置插入,原来的元素需要往后挪动。依此类推,直至最后一个元素。
插入排序算法在逻辑上也将数组分成两个部分:[已排序部分|未排序部分]
,每一次从“未排序部分”选择第一个元素,插入“已排序部分”的正确位置。找到适合插入元素的正确位置后,需要将之后的所有元素往后挪动。之所以选择“未排序部分”的第一个元素,是因为这样做需要挪动的元素最少。
举个例子,假设要排序数组:[8, 3, 7, 6, 5]
。
第一轮 从第二个元素
3
开始,和前面已排序部分比较,发现3
要放在8
前面,数组变成这样:[3, 8, 7, 6, 5]
。第二轮 将第三个元素
7
与前面已排序的部分进行逐个比较,发现7
要放在3
的后面,8
的前面,这一轮排序结束后数组是这样的:[3, 7, 8, 6, 5]
。
经过四轮比较和插入值之后,最终得到有序数组:[3, 5, 6, 7, 8]
。
fn insertion_sort<T: Ord>(list: &mut Vec<T>) {
for i in 1..list.len() {
for j in 0..i { // 已排好序的部分
if list.get(i) < list.get(j) {
let e = list.swap_remove(i);
list.insert(j, e);
break;
}
}
}
}
如果遇上相同的元素,插入排序算法会继续跟后面的元素比较,也就是说插入过程不会改变相同元素的相对位置,它是稳定的排序算法。
插入排序算法是一种原地排序,交换元素需要一个额外的空间存储临时元素,因此插入排序的空间复杂度是 $O(1)$。
插入排序的时间复杂度有两个层面,一个是将元素与已排序部分做比较,另一个是插入元素过程需要将元素后挪。
先看比较过程,第一轮需要比较 $1$ 次,第二轮比较 $2$ 次…,总共需要比较 $n-1$ 轮,总的比较次数是:$1 + 2 + … + (n-1) = \frac {n^2 - n}{2}$,这一点跟选择排序一致,都是 $O(n^2)$。不过这一步可以使用二分查找提高性能,如果这样的话就是 $O(n \ log \ n)$。
如果数组是完全有序的,插入过程不需要挪动任何元素。如果数组恰好倒序,第一次需要挪动 $n-1$ 次元素,第二次挪动 $n-2$ 次… 总共需要的挪动次数:$(n-1) + (n-2) + (n-3) + … + 1 = \frac {n^2 - n}{2}$,因此,这一步的时间复杂度是 $O(n^2)$。
总结
上面讨论的三个排序算法,是最简单的排序算法,比较直观容易理解,不需要太多计算机科学知识就能想到。但它们都有着 $O(n^2)$ 时间复杂度,存在很多重复比较,做了很多无用功。
在数据很小的时候,它们可能不至于太慢,但是如今我们面对的数据集通常不会太小,$O(n^2)$ 指数级别的时间复杂度,如果不小心将它们引入生产环境,会带来灾难的后果,如果你不知道自己在做什么,就应避免使用在生产环境这三种排序算法。