1. 前言
排序(Sorting)是计算机程序设计中的一种重要操作,它的功能是将一个数据元素(或记录)的任意序列,重新排列成一个按关键字有序的序列。
排序方法主要分为两大类:一类是内部排序,指的是待排序记录存放在计算机随机储存器中进行的排序过程;另一类是外部排序,指的是待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程。本篇文章讨论内部排序算法。
内部排序的方法主要有:插入排序、交换排序、选择排序和归并排序等。本篇文章针对每种排序方法给出具体的实现算法,并介绍适用场景。
1.1 测试数据
样本A:50条-200到700的随机整数;
样本B:10,000条-200到700的随机整数;
性能测试方法:使用某算法独立排序样本数据A和B各4次选择其中的三次较短的时间求平均值,平均花费时间记为TA和TB,单位毫秒(ms)。
2. 插入排序
2.1 直接插入排序(Straight Insertion Sort)
它是一种最简单的排序方法,基本操作是将一个记录插入到已排好的有序表中,从而得到一个新的、记录数增1的有序表。时间复杂度为O(n^2),空间复杂度为O(1),比较次数和移动次数约为(n^2)/4。
适用场景:记录数n不大的排序情况。
性能测试:TA=1.3ms,TB=48.3ms
/**
* 直接插入排序:稳定的排序方法,时间复杂度为O(n^2),空间复杂度为O(1),比较次数和移动次数约为(n^2)/4
* @author 忘川
*
*/
public class StraightInsertionSort
{
public static void sort(int[] data)
{
// 记录插入的序列
int index = 0;
// 记录当前要插入的值
int value = 0;
// 将第一个数字当作已有的序列,依次将后面的序列插入到已有的序列中
for (int i = 1; i < data.length; i++)
{
// 记录当前要插入的数字
value = data[i];
// 从已有的序列中从后往前查找合适的位子,比较大小的同时并移动数据
for (index = i; index > 0; index--)
{
if (value < data[index - 1])
data[index] = data[index - 1];
else
break;
}
// 插入数据
data[index] = value;
}
}
}
2.2 折半插入排序(Binary Insertion Sort)
直接插入排序,有“比较”和“移动”两个基本操作。折半插入排序采用折半查找的方式来减少比较次数,而记录的移动次数不变。折半插入排序的时间复杂度为O(n^2)
适用场景:记录数n不大的排序情况,记录数较大时性能较直接插入排序好。
性能测试:TA=1.6ms,TB=25.9ms
/**
* 折半插入排序:时间复杂度为O(n^2)
* 说明:折半插入排序仅减少了关键字的比较次数,而记录的移动次数不变
* @author 忘川
*
*/
public class BinaryInsertionSort
{
public static void sort(int[] data)
{
// 记录要插入的元素
int value = 0;
// 用于折半查找
int low = 0, high = 0, m = 0;
for (int i = 1; i < data.length; i++)
{
// 初始化有序序列的首指针和尾指针
low = 0;
high = i - 1;
// 折半查找合适的插入位置
while (low <= high)
{
m = (low + high) / 2;
// 中间值后一半查找
if (data[i] > data[m])
low = m + 1;
else
high = m - 1;
}
// 暂时存储要插入的元素
value = data[i];
// 向后移动元素
for (int j = i; j > low; j--)
data[j] = data[j - 1];
// 插入元素
data[low] = value;
}
}
}
2.3 希尔排序(Shell’s Sort)
先将整个待排序记录序列分割成若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。当增量序列为dlta[k]=2^(t-k+1)-1 (1 <= k <= t <= log2(n+1))时,时间复杂度为O(n^1.5)。
适用场景:希尔排序的时间复杂度较直接插入排序低,因此希尔排序的性能比直接插入排序优越得多。记录数n不管大小,希尔排序都有不错的表现。
性能测试:TA=1.4ms,TB=8.8ms
/**
* 希尔排序:当增量序列为dlta[k]=2^(t-k+1)-1 (1 <= k <= t <= log2(n+1))时,O(n^1.5)
* @author 忘川
*
*/
public class ShellSort
{
public static void sort(int[] data)
{
// shell排序的趟数:t<=log2(n+1)(向下取整)
int t = (int) (Math.log(data.length + 1) / Math.log(2));
// 保存Shell排序的增量序列
int[] dlta = new int[t];
// 计算增量序列
for (int i = 0; i < t; i++)
dlta[i] = (int) (Math.pow(2, t - i) - 1);
// 进行t趟增量插入排序
for (int i = 0; i < t; i++)
shellInsert(data, dlta[i]);
}
private static void shellInsert(int[] d, int dk)
{
int temp = 0;
// 把每一组第一个序列看成是已经有序的
for (int i = dk; i < d.length; i++)
{
// 后面的序列比前面的小
if (d[i] < d[i - dk])
{
// 暂时存储需要插入的数据
temp = d[i];
// 向前寻找合适的位置插入,每隔dk个序列比较一次
int j = i - dk;
for (; j >= 0 && d[j] > temp; j = j - dk)
d[j + dk] = d[j];
// 将暂存的数据插入
d[j + dk] = temp;
}
}
}
}
3. 交换排序
3.1 冒泡排序(Bubble Sort)
若初始序列为“正序”序列,只需要进行一趟排序,在排序过程中进行n-1次关键字间的比较,且不移动记录;反之,若初始序列为“逆序”序列,则需要进行n-1趟排序,需要进行n(n-1)/2次比较,且做等数量级。因此,总的时间复杂度为O(n^2)。
适用场景:记录数n不大的排序情况。
性能测试:TA=1.1ms,TB=187.2ms
/**
* 冒泡排序:稳定的排序算法,总的时间复杂度为O(n^2)
* @author 忘川
*
*/
public class BubbleSort
{
public static void sort(int[] data)
{
// 记录某一趟排序是否有元素交换
boolean flag = false;
// 进行n-1趟排序
for (int i = 0; i < data.length - 1; i++)
{
flag = false;
for (int j = 0; j < data.length - i - 1; j++)
{
// 当前一个元素比后面的一个大的时候,则交换两个元素的位置
if (data[j] > data[j + 1])
{
int temp = data[j];
data[j] = data[j + 1];
data[j + 1] = temp;
flag = true;
}
}
// 当某一趟没有交换,则说明整个序列已经有序,就终止排序
if (!flag)
break;
}
}
}
3.2 快速排序(Quick Sort)
通过一趟排序将待排序记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序。平均时间复杂度为O(nlogn),最坏情况下的时间复杂度为O(n^2),空间复杂度为O(logn)。快速排序是目前被认为是最好的一种内部排序方法。
适用场景:性能优异,记录数n较大和较小的情况均适合。
性能测试:TA=1.2ms,TB=5.2ms
/**
* 快速排序:不稳定的排序算法,平均时间复杂度为O(nlogn),最坏情况下的时间复杂度为O(n^2),空间复杂度为O(logn)
* 说明:快速排序是目前被认为是最好的一种内部排序方法
* @author 忘川
*
*/
public class QuickSort
{
public static void sort(int[] data)
{
quickSort(data, 0, data.length - 1);
}
private static void quickSort(int[] data, int first, int end)
{
if (first < end)
{
// 进行划分,index是轴值在数组中的下标位置
int index = partition(data, first, end);
// 对轴值左边的序列进行快速排序
quickSort(data, first, index - 1);
// 对轴值右边的序列进行快速排序
quickSort(data, index + 1, end);
}
}
/**
* 将数列划分
*
* @param data 数组
* @param first 起始下标
* @param end 终止下标
* @return
*/
private static int partition(int[] data, int first, int end)
{
// 记录轴值,防止数据移动
int pivot = data[first];
while (first < end)
{
// 从后往前扫描,小于轴值的数替换到前面去
while (end > first && data[end] >= pivot)
end--;
data[first] = data[end];
// 从前往后扫描,大于轴值的数替换到后面去
while (first < end && data[first] <= pivot)
first++;
data[end] = data[first];
}
// 一次划分完成时,first和end相等
data[first] = pivot;
return first;
}
}
4. 选择排序
4.1 简单选择排序(Simple Selection Sort)
将整个记录分为有序区和无序区,初始条件下有序区为空。每一趟从无序区中选出一个最小的记录和无序区的第一个记录交换,使有序区增加一个记录而无序区减少一个记录,经过n-1趟选择,整个记录已经有序。简单选择排序的时间复杂度为O(n^2)。
适用场景:记录数n不大的排序情况。
性能测试:TA=1.3ms,TB=71.3ms
/**
* 简单选择排序:时间复杂度为O(n^2)
* @author 忘川
*
*/
public class SimpleSelectionSort
{
public static void sort(int[] data)
{
// 记录每一趟选择中最小记录的下标
int minIndex = 0;
// n个记录,进行n-1趟选择
for (int i = 0; i < data.length - 1; i++)
{
minIndex = i;
// 选择余下待排序记录中的最小记录的小标
for (int j = i + 1; j < data.length; j++)
if (data[j] < data[minIndex])
minIndex = j;
// 当前位置与余下待排序记录中的最小记录交换
if (minIndex != i)
{
int temp = data[i];
data[i] = data[minIndex];
data[minIndex] = temp;
}
}
}
}
4.2 堆排序(Heap Sort)
堆排序运行时间主要耗费在建初始堆和调整建新堆时进行的反复“筛选”上,堆排序在最坏的情况下,其时间复杂度为O(nlogn),相对于快速排序来说,这是堆排序最大的优点。堆排序的空间复杂度为O(1)。
适用场景:堆排序方法对记录数较少的文件并不值得提倡,但对n较大的文件还是很有效的。
性能测试:TA=1.3ms,TB=4.5ms
/**
* 堆排序:时间复杂度为O(log2(n))
* 说明:堆排序对n值较大的序列排序很有效。空间复杂度为O(1),最坏情况时间复杂度为O(nlogn),
* 这是相当于快速排序最大的优点
* @author 忘川
*
*/
public class HeapSort
{
public static void sort(int[] data)
{
// 将一个乱序序列调整成堆,参与调整的序列为数组的长度
for (int i = (data.length - 1) / 2; i >= 0; i--)
siftHeap(data, i, data.length);
// 进行n-1趟选择
for (int i = 1; i < data.length; i++)
{
// 将二叉树的root和乱序区的最后一个节点交换
int temp = data[0];
data[0] = data[data.length - i];
data[data.length - i] = temp;
// 重新将乱序区的序列调整成堆
siftHeap(data, 0, data.length - i);
}
}
/**
* 将乱序序列调整成堆
* @param data 乱序序列
* @param k 要调整的元素的下标
* @param count 乱序区元素的长度
*/
private static void siftHeap(int[] data, int k, int count)
{
// 初始值为做孩子的下标
int max = 2 * (k + 1) - 1;
// 当该节点不是叶子节点时(已经排序好的节点认为已经从二叉树上减掉了),进行筛选
// 当右孩子的序号小于等于乱序区元素的个数时,就可以进行筛选
while (max + 1 <= count)
{
// 选取左右孩子较大元素的下标值,当没有右孩子时,左孩子就是最大的
if (max < count - 1 && data[max] < data[max + 1])
max++;
// 如果当前节点大于左右孩子节点,则筛选完毕。因为其左右孩子节点本身就是堆,
// 而其兄弟本身就是堆并且并没有参与交换依然是堆
if (data[k] >= data[max])
break;
else
{
// 将该节点与左右孩子中较大孩子交换
int temp = data[k];
data[k] = data[max];
data[max] = temp;
// 筛选目标变为左右孩子中参与交换的那个孩子,另一个孩子本身是堆不用继续筛选
k = max;
max = 2 * (k + 1) - 1;
}
}
}
}
5. 归并排序(Merging Sort)
“归并”的含义就是将两个或两个以上的有序表组合成一个新的有序表。实现归并排序需要和待排序记录等数量的辅助空间,其空间复杂度为O(n)。归并排序递归形式的算法的形式上较为简洁,但使用性很差(例子给出了递归实现和非递归实现)。归并排序与快速排序和堆排序相比,它最大的特点就是:归并排序是一种稳定的排序方法。归并排序的时间复杂度为O(nlogn)。
适用场景:由于空间复杂度为O(n),不适合记录数n值较大的情况。
性能测试:TA=1.3ms,TB=6.2ms
/**
* 归并排序:时间复杂度为O(nlogn),空间复杂度为O(n),它需要等长的序列存放归并之后的序列
* @author 忘川
*
*/
public class MergingSort
{
/**
* 用归并排序算法对序列进行排序
* @param data 待排序序列
* @param flag 0采用递归算法,非0则采用非递归算法
*/
public static void sort(int[] data, int flag)
{
if (flag == 0)
sortByRecursion(data, new int[data.length], 0, data.length - 1);
else
sortNotRecursion(data);
}
/**
* 归并排序算法的非递归实现
* @param data 待排序的序列
*/
private static void sortNotRecursion(int[] data)
{
//一趟归并排序扫描每次处理的数据的个数
//例如h=2,则表示将两个长度为1的序列归并为一个长度为2的序列
int h = 2;
//辅助数组,用于两个序列合并为一个序列用
int[] t = new int[data.length];
//当每次处理的数据大于等于待排序数组的长度时,说明已经排序完成
for (int c = 1; (h = (int) Math.pow(2, c)) < data.length; c++)
{
int i = 0;
//将前n*h个分组的数据分别处理(每h个数据一分为二两两合并),n为整数,最后尾部可能余下不足h个数据
for (i = 0; i + h <= data.length; i = i + h)
merge(data, t, i, i + h / 2 - 1, i + h - 1);
//将尾部余下的不足h个数据与第n个分组(有h个)的合并
if (i != data.length)
merge(data, t, i - h, i - 1, data.length - 1);
}
}
/**
* 归并排序算法的递归实现
* @param d 待排序序列
* @param t 辅助数组
* @param start 待排序序列的起始下标
* @param end 待排序序列的结束下标
*/
private static void sortByRecursion(int[] d, int[] t, int start, int end)
{
// 当只有一个记录时,已经有序
if (start == end)
return;
else
{
int mid = (start + end) / 2;
// 对前一半进行递归归并排序,排序结果通过辅助数组存放在d[start]……d[mid]中
sortByRecursion(d, t, start, mid);
// 对后一般进行递归归并排序,排序结果存放在d[mid+1]……d[end]中
sortByRecursion(d, t, mid + 1, end);
// 将d[start]……d[mid]和d[mid+1]……d[end]两部分有序的序列合并,结果存放在t[start]……t[end]中
merge(d, t, start, mid, end);
}
}
/**
* 将d[start]……d[mid]和d[mid+1]……d[end]两部分有序的序列合并,
* 并借助辅助数组放数据将合并之后的数据存放到d[start]……d[end]中
* @param d 序列
* @param t 辅助数组
* @param start 第一部分有序序列开始下标
* @param mid 第一部分有序序列结束下标
* @param end 第二部分有序序列结束下标
*/
private static void merge(int[] d, int[] t, int start, int mid, int end)
{
// 将d[start]……d[mid]和d[mid+1]……d[end]两部分有序的序列合并,结果存放在t[start]……t[end]中
int index1 = start, index2 = mid + 1;
int tIndex = start;
while (index1 <= mid && index2 <= end)
{
// 如果前者小,则t中放入前者,前者指示器增加一
if (d[index1] < d[index2])
t[tIndex++] = d[index1++];
else
t[tIndex++] = d[index2++];
}
// 将其中一个没有处理完的序列进行收尾处理
if (index1 > mid)
while (index2 <= end)
t[tIndex++] = d[index2++];
if (index2 > end)
while (index1 <= mid)
t[tIndex++] = d[index1++];
// 将辅助数组中暂时存放的数据传回原数组,达到两个序列合并为一个序列的目的
for (int i = start; i <= end; i++)
d[i] = t[i];
}
}
6. 总结
(1)从平均时间性能而言,快速排序最佳,其所需时间最省,但快速排序在最坏情况下的时间性能不如堆排序和归并排序。而后两者相比较的结果是,在n较大时,归并排序所需的时间较堆排序省,但它所需的辅助存储量最多(性能测试中堆排序性能非常好的原因和n值不够大以及样本数据有较多相等有关)。
(2)简单的排序方法包括除希尔排序之外的所有插入排序、冒泡排序和简单选择排序,其中直接插入排序最简单,当序列中的记录“基本有序”或n值较小时,它是最佳的排序方法。
(3)从方法的稳定性来比较,所有的时间复杂度为O(n^2)的简单排序方法是稳定的,而快速排序、堆排序和希尔排序等时间性能较好的排序方法都是不稳定的。
综上所述,本节讨论的所有排序方法中,没有哪一种是绝对最优的。有的适合于n较大的情况,有的适合于n较小的情况,因此,在实用时需根据不同情况适当选择,甚至可将多种方法结合起来使用。
7. 附录
算法及测试代码:http://download.csdn.net/detail/mytroy/8572277