本文描述的算法虽然比较简单,执行速度也相对慢一些,但仍然值得学习。因为这些简单排序算法除了比较容易理解之外,某些情况下比那些复杂的算法还要好一些。比如,对于小规模的文件以及基本有序的文件,插入排序算法能比快速算法更为有效。实际上插入排序也常作为快速排序算法实现的一部分。 |
---|
一、冒泡排序
冒泡算法运行起来非常慢,但是规则简单,因此冒泡排序算法在刚开始研究排序技术时是一个非常好的算法。
规则:
- 比较相邻的元素,如果前一个比后一个大,就把它们两个调换位置
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数
- 针对所有的元素重复以上的步骤,除了最后一个
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较
特性:
- 不变性:末端已排定的最大元素都是有序的
- 稳定性:稳定
示例程序:
public void bubbleSort(int... elements) {
for (int out = elements.length - 1; out > 0; out--) {
for (int in = 0; in < out; in++) {
if (elements[in] > elements[in + 1]) {
int temp = elements[in];
elements[in] = elements[in + 1];
elements[in + 1] = temp;
}
}
}
}
效率分析:
比较次数:T1 = (N-1) + (N-2) + (N-3) +…+ 1 = N*(N-1)/2
最多交换次数:T2 = T1 = N*(N-1)/2
平均交换次数:T3 = T2/2 = N*(N-1)/4
时间复杂度:T = T1 + T3 = 3*N*(N-1)/4 = O(N2)
冒泡排序的改进:
鸡尾酒排序,也叫定向冒泡排序,是冒泡排序的一种改进。此算法与冒泡排序的不同处在于从低到高然后从高到低,而冒泡排序则仅从低到高去比较序列里的每个元素。他可以得到比冒泡排序稍微好一点的效能。
示例程序:
public int[] cocktailSort(int... elements) {
int left = 0; // 初始化边界
int right = elements.length - 1;
while (left < right) {
for (int i = left; i < right; i++) // 前半轮,将最大元素放到后面
{
if (elements[i] > elements[i + 1]) {
swap(elements, i, i + 1);
}
}
right--;
for (int i = right; i > left; i--) // 后半轮,将最小元素放到前面
{
if (elements[i - 1] > elements[i]) {
swap(elements, i - 1, i);
}
}
left++;
}
return elements;
}
void swap(int arr[], int i, int j) {
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
}
在乱数序列的状态下,鸡尾酒排序与冒泡排序的效率都很差劲。
二、选择排序
选择排序改进了冒泡排序,将必要的交换次数从O(N2)减少到O(N)。不幸的是比较次数仍保持为O(N2)。然而,选择排序仍然为大记录量的排序提出了非常重要的改进,因为这些大量的记录需要在内存中移动,这就使交换的时间和比较的时间相比,交换时间更为重要。(在Java语言中并不是这样,Java中只是改变了引用位置,而实际对象并没有发生改变)
规则:
- 初始时在序列中找到最小(大)元素,放到序列的起始位置作为已排序序列
- 再从剩余未排序元素中继续寻找最小(大)元素,放到已排序序列的末尾
- 以此类推,直到所有元素均排序完毕
特性:
- 不变性:已排定的起始位置的最小元素是有序的
- 稳定性:不稳定
示例程序:
public void selectionSort(int... elements) {
for (int out = 0; out < elements.length; out++) {
int min = out;
for (int in = out + 1; in < elements.length; in++) {
if (elements[in] < elements[min]) {
min = in;
}
}
if (min != out) {
int temp = elements[min];
elements[min] = elements[out];
elements[out] = temp;
}
}
}
}
效率分析:
比较次数:T1 = (N-1) + (N-2) + (N-3) +…+ 1 = N*(N-1)/2
交换次数:T2 = N
时间复杂度:T = T1 + T2 = O(N2)
选择排序和冒泡排序执行了相同次数的比较,但是交换次数却是冒泡排序的1/N。所以虽然选择排序和冒泡排序的时间复杂度都是O(N2),但是选择排序无疑更快,因为它进行的交换少得多。
三、直接插入排序
在大多数情况下,插入排序算法是本文中描述的基本排序算法中最好的一种。虽然插入算法仍然需要O(N)的时间,但是在一般情况下,它要比冒泡排序快一倍,比选择排序还要快一点。尽管它比冒泡排序算法和选择排序算法都更麻烦一些,但它也不很复杂。
规则:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后
- 重复步骤2~5
特性:
不变性:有序组数据的有序性
稳定性:稳定
示例程序:
public void insertionSort(int... elements) {
for (int i = 1; i < elements.length; i++) {
int j, insertion = elements[i];
for (j = i; j > 0 && insertion < elements[j - 1]; j--) {
elements[j] = elements[j - 1];
}
elements[j] = insertion;
}
}
效率分析:
最多比较次数:T1 = (N-1) + (N-2) + (N-3) +…+ 1 = N*(N-1)/2
平均比较次数:T2 = T1/2 = N*(N-1)/4
最少比较次数:T3 = N-1
最多移动次数:T4 = (N-1) + (N-2) + (N-3) +…+ 1 = N*(N-1)/2
平均移动次数:T5 = T4/2 = N*(N-1)/4
最少移动次数:T6 = 0
平均时间复杂度:T7 = T2 + T5 = O(N2)
最差情况时间复杂度:T8 = T1 + T4 = Ω(N2)
最好情况时间复杂度:T9 = T3 + T6 = Φ(N)
平均复制次数大致等于平均比较次数。然而一次复制与一次交换的时间耗费有所不同,所以对于随机数据,这个算法比冒泡排序快一倍,比选择排序略快。
对于已经有序或基本有序的数据来说,插入排序要好的多。然而对于逆序排列的数据,每次比较和移动都会执行,所以插入排序不比冒泡排序快。
排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | ||||
直接插入排序 | O(N2) | Ω(N2) | Φ(N) | O(N) | 稳定 | 简单 |
直接插入算法的改进:
1.折半插入排序
又称二分插入排序,寻找插入位置的方法采用二分法查找(也称折半查找)
2.希尔排序:
通过交换相邻元素进行排序的任何算法,每次只减少一个逆序,平均都需要Ω(N2)时间,这个下界告诉我们,为了使一个排序算法以亚二次或O(N2)时间运行,必须执行一些比较,特别是要对相距较远的元素进行交换。一个排序算法通过删除逆序得以向前进行,而为了有效地进行,它必须使每次交换删除不止一个逆序。
希尔排序通过比较相距一定间隔的元素来工作;各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。由于这个原因,希尔排序也叫作缩减增量排序。采用Habbard增量序列时,最坏运行时间为Ω(N3/2)。
四、有关简单排序的面试问题:
- 为什么冒泡排序和选择排序比,时间复杂度相同,但是选择排序的效率却高于冒泡排序
- 冒泡排序和选择排序中,还有什么更高效的交换方法(提示:通过加减法运算或位运算交换)
- 插入排序在什么情况下,效率比冒泡排序高,什么情况下比冒泡排序效率低
- 插入排序有什么改进方法
希望对大家有帮助,谢谢!
参考资料:
《Java数据结构和算法(第二版)》 Robert Lafore著
《数据结构与算法分析》 MAW著