排序是计算机程序设计中的一种重要操作。如果数据能够根据某种规则排序,就能大大挺高数据处理的算法效率。
排序就是整理文件中的记录,使之按关键字递增(或递减)的次序排列起来。
排序的对象是文件,它由一组记录组成。每条记录则由一个或若干个数据项(或域)组成。
关键字项就是可用来标识一个记录的一个或多个组合的数据项。该数据项的值称为关键字(key)。需要注意的是,在不易产生混淆时,可将关键字项简称为关键字。
用来作为排序运算的关键字,可以是数字类型,也可以是字符类型。关键字的选取应根据问题的要求而定。
当待排序记录的关键字均不相同时,排序结果是惟一的,否则排序结果不惟一。
在待排序的文件中,若存在多个关键字相同的记录,经过排序后这些具有相同关键字的记录之间的相对次序保持不变,该排序方法是稳定的;若具有相同关键字的记录之间的相对次序发生变化,则称这种排序方法是不稳定的。
根据排序时待排序的数据元素数量的不同,使得排序过程中涉及的存储器不同,可以将排序方法分为两类。 一类是整个排序过程在内存储器中进行,称为内部排序,简称内排序。
另一类是由于待排序元素数量太大,以至于内存储器无法容纳全部数据,排序需要借助外部存储设备才能完成,这类排序称为外部排序。
一般情况下,内排序适宜在记录个数不多的小文件中使用,外排序则适用于记录个数太多,不能一次将其全部记录放入内存的大文件。
对于外排序,可进一步分为两种方法:
对于内排序,按策略进行划分,可以分为:
分析排序算法时,应该考虑比较的次数和数据移动的次数。
具体需要考虑以下3中情况的比较和移动次数:
很多排序方法在这3种情况下的性能是迥然不同的,所以可以根据实际条件选择使用哪一种算法。
插入排序的思想:
每次将一个待排序的记录按其关键字大小插入到前面已经排好序的子文件的适当位置,直到全部记录插入完成为止。
直接插入排序是一种最简单的排序方法,它的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的有序表。
假设待排序的记录存放在数组R[1……n]中,排序过程中,R被分为两个子区间R[1……i]和R[i+1……n],其中R[1……i]是已经排好序的有序区;R[i+1……n]是当前未排序的部分。将当前无序区的第一个记录R[i+1]插入到有序区R[1……i]的适当位置,使R[1……i+1]变为新的有序区,每次插入一个数据,知道所有的数据有序为止。
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
// 插入排序
public static int[] insertionSort(int[] arr) {
int preIndex, temp;
//比较的论数
for (int i = 1; i < arr.length; i++) {
//当前下标的前一位下标
preIndex = i - 1;
//当前位置的元素
temp = arr[i];
//前一位下标不越界并且前一位的元素值大于当前位置的元素值
while (preIndex >= 0 && arr[preIndex] > temp) {
//移动元素位置
arr[preIndex + 1] = arr[preIndex];
//下标前移继续比较
preIndex--;
}
//插入相应位置
arr[preIndex + 1] = temp;
}
return arr;
}
对于具有n个记录的文件,要进行n-1趟排序。 各种状态下的时间复杂度如下表:
算法所需的辅助空间是一个监视哨,辅助空间负责度S(n)=O(1),是一个就地排序。
直接插入排序是稳定的排序方法。
希尔排序是插入排序的一种,因D.L.Shell于1959年提出而得名。希尔排序也称作缩减增量排序(diminishing increment sort)
先取定一个小于n的整数d1作为第一个增量,把文件的全部记录分成d1个组,所有距离为d1的倍数的记录放在同一个组中,在各组内进行插入排序;然后,取第二个增量d2<d1,重复上述的分组和排序,直至所取的增量dt=1(dt<……<d2<d1),即所有记录放在同一组中进行直接插入排序为止。
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
// 希尔排序
public static void shellSort2(int[] arr) {
int j;
// 初始值为数组长度的一半,然后按照一半的一半递减至1
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
// 初始取值为数组长度的一半,然后加一递增,不能超过数组长度
for (int i = gap; i < arr.length; i++) {
int tmp = arr[i];// 取出数组的一半位置的元素。
// 比较步长两端的元素,如果后端小于前端
for (j = i; j >= gap && tmp - arr[j - gap] < 0; j -= gap) {
arr[j] = arr[j - gap];// 将前端的值赋予后端
}
// 此时j减去了gap则为前端的位置,将后端的值赋予前端。完成不同位置的元素交换。
arr[j] = tmp;
}
}
}
Shell排序的执行时间依赖于增量序列。
好的在增量序列的共同特征为:
希尔排序的时间性能优于直接插入排序,原因如下:
希尔排序是一种不稳定的排序方法。
交换排序的基本思想:
两两比较待排序记录的关键字,发现两个记录的次序相反时,即进行交换,直到没有反序的记录为止。 应用交换排序基本思想的主要排序方法有冒泡和快速排序。
设想被排序的记录关键字保存在数组R[1……n]中,将每个记录R[i]看做是重量为R[i].key的气泡。根据轻气泡不能在重气泡之下的原则,从下往上扫描数组R;凡扫描到违反本原则的轻气泡,就使其向上“漂浮”。如此反复进行,直到最后任何两个气泡都轻者在上,重者在下为止。
// 冒泡排序
public static void bubbleSort(int[] arr) {
// 比较的轮数
for (int i = 0; i < arr.length - 1; i++) {
// 每轮比较的次数
for (int j = 0; j < arr.length - 1 - i; j++) {
// 从小到大排序
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
若文件的初始状态是正序的,一趟扫描即可完成排序。所需的关键字比较次数C和记录移动次数M均达到最小值:
Cmin=n-1;Mmin=0
冒泡最好的时间复杂度为O(n)。
若成绩是文件是反序的,需要进行n-1趟排序。每趟排序要进行n-i此关键字的比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:
Cmax=n(n-1)/2=O(n^2);Mmax=3n(n-1)/2=O(n^2)
冒泡的最坏时间复杂度为O(n^2)。
冒泡算法的平均时间复杂度为O(n^2)。
冒泡排序是就地排序,且它是稳定的。
快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序。它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod)。
将原有问题分解为若干个规模更小但结构与原问题相似的子问题,递归地解这些子问题。然后将这些子问题的解组合为原问题的解。然后将这些子问题的解组合为原问题的解。
快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
// 快速排序
public static void fastSort(int[] arr, int left, int right) {
// 最小位置
int minIndex = left;
// 最大位置
int maxIndex = right;
// 三方变量
int temp = 0;
// 最大和最小位置不相等时
if (minIndex < maxIndex) {
// 三方变量从最小位置开始取值比较
temp = arr[minIndex];
// 当最大和最小位置不重叠时
while (minIndex != maxIndex) {
// 小位置没有越过大位置,并且大位置的值大于等于temp的取值
while (maxIndex > minIndex && arr[maxIndex] >= temp) {
// 大位置向左移
maxIndex--;
}
// 否则,小位置的元素取大位置的元素
arr[minIndex] = arr[maxIndex];
// 小位置没有越过大位置,并且小位置的元素值小于等于三方变量
while (minIndex < maxIndex && arr[minIndex] <= temp) {
// 小位置右移
minIndex++;
}
// 否则,大位置的元素取小位置的元素
arr[maxIndex] = arr[minIndex];
}
// 停止处的小位置去三方变量的值。
arr[maxIndex] = temp;
// 左半数组重复上面的操作
fastSort(arr, left, minIndex - 1);
// 右半数组重复上面的操作
fastSort(arr, maxIndex + 1, right);
}
}
快速排序的时间主要耗费在划分操作上,对长度为k的区间进行划分,共需k-1次关键字的比较。
最坏情况是每次划分宣州区的基准都是当前无序区中关键字最小(或最大)的记录,划分的结果是基准左边的子区间为空(或右边的子区间为空),而划分所得的另一个非空的子区间中记录数目仅仅比划分前的无序区中记录个数减少一个。
因此,快速排序必须做n-1此划分,第i此划分开始区间长度为n-i+1,所需的比较次数为n-i(1≤i≤n-1),故总的比较次数达到最大值:
Cmax=n(n-1)/2=O(n^2)
最好情况下,每次划分所取的基准都是当前无序区的“中值”记录,划分的结果是基准的左、右两个无序子区间的长度大致相等。总的关键字比较次数为:O(nlgn)。 平均时间复杂度为:O(nlgn)。
快速排序在系统内部需要一个栈来实现递归。若每次划分较为均匀,则其递归树的高度为O(lgn),故递归后需要栈空间为O(lgn)。最坏情况下,递归树的高度为O(n),所需的栈空间为O(n)。
快速排序是非稳定的。
选择排序的基本思想:
每一趟从待排序的记录中选出关键字最小的记录,顺序放在已排好序的子文件的最后,直到全部记录排序完毕。
选择排序方法主要有一下两种:直接选择排序(或称简单选择排序)和堆排序。
第i趟排序开始时,当前有序区和无序区分别为R[1……i-1]和R[i……n](1≤i≤n-1),该趟排序则是从当前无序区中选出关键字最小的记录R[k],将它与无序区的第一个记录R[i]交换,是R[1……i]和R[i+1……n]分别变为新的有序区和新的无序区。因为每趟排序均使有序区中增加了一个记录,且有序区中的记录关键字均不大于无序区无序区轰炸过记录的关键字,即第i趟排序之后R[1……i].keys≤R[i+1……n].keys,所以进行n-1趟排序之后有R[1……n-1].keys≤R[n].key,即经过n-1趟排序之后,整个文件R[1……n]递增有序。
n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:
//直接选择排序
public static void SelectionSort(int[] arr) {
int minIndex,temp;
for(int i = 0;i<arr.length;i++) {
minIndex=i;
for(int j=i+1;j<arr.length;j++) {
if(arr[j]<arr[minIndex]) {
minIndex = j;
}
}
temp = arr[i];
arr[i] =arr[minIndex];
arr[minIndex]=temp;
}
}
直接选择排序的平均时间复杂度为O(n^2)。
直接选择排序是一个就地排序,并且是不稳定的。
堆排序是利用完全二叉树进行排序的方法。
堆有大根堆(根结点的关键字值最大的堆)和小根堆(根结点关键字值最小)之分。
堆排序利用了大根堆(或小根堆)堆顶记录的关键字最大(或最小)这一特征,使得在当前无序区中选取最大(或最小)关键字的记录变得简单。
大根堆排序思想:
首先将初始文件R[1……n]建成一个大根堆,此堆的初始的无序区;将关键字最大的记录R[1](即堆顶)堆顶和无序区的最后一个记录R[n]交换,由此得到新的无序区R[1……n-1]和有序区R[n],且满足R[1……n-1].keys≤R[n].keys,由于交换后新的根R[1]可能违反堆性质,故应将当前无序区R[1……n-1]调整为堆;然后再次将R[1……n-1]中关键字最大的记录R[1]和该区间的最后一个记录R[n-1]交换,由此得到新的无序区R[1……n-2]和有序区R[n-1……n],且仍满足关系R[1……n-2].keys≤R[n-1……n].keys。同样要将R[1……n-2]调整为堆。重复以上步骤,直至按关键字有序。
//交换值
private static void swap(int[] arr,int biggerIndex,int rootIndex) {
int temp=arr[rootIndex];
arr[rootIndex]=arr[biggerIndex];
arr[biggerIndex]=temp;
}
//调整大根堆
private static void adjustHeap(int[] arr,int rootIndex,int lastIndex) {
//从根结点开始往下调整
int biggerIndex = rootIndex;
int leftChildIndex=rootIndex * 2+1;
int rightChildIndex=rootIndex*2+2;
if(rightChildIndex<=lastIndex) {//如果右结点存在,则左结点一定存在
if(arr[rightChildIndex]> arr[rootIndex]||arr[leftChildIndex]>arr[rootIndex]) {
//将子节点更大的元素下标赋值给biggerIndex
biggerIndex = arr[rightChildIndex]>arr[leftChildIndex]?rightChildIndex:leftChildIndex;
}
}else if(leftChildIndex<=lastIndex) {//保证左结点存在,且不越界
if(arr[leftChildIndex]>arr[rootIndex]) {
biggerIndex = leftChildIndex;
}
}
if(biggerIndex!=rootIndex) {
swap(arr, biggerIndex, rootIndex);
adjustHeap(arr, biggerIndex, lastIndex);
}
}
//构建大根堆
private static void buildMaxHeap(int[] arr,int lastIndex) {
//从最后一个元素的父结点开始进行调整,一直调整到根结点结束
int j=(lastIndex-1)/2;
while(j>=0) {
int rootIndex = j;
adjustHeap(arr, rootIndex, lastIndex);
j--;
}
}
//堆排序
public static void heapSort(int[] arr,int len) {
int lastIndex = len -1;
//构建最大堆
buildMaxHeap(arr, lastIndex);
while(lastIndex>0) {
swap(arr, 0, lastIndex);
if(--lastIndex==0) {//剩余元素个数为1,排序结束,跳出循环
break;
}
adjustHeap(arr, 0, lastIndex);
}
}
堆排序的时间主要由建立初始堆和反复重建堆这两部分的时间开销构成,他们均是通过调用heapSort实现的,
堆排序的最坏时间复杂度为O(nlgn)。堆排序的平均性能较接近于最坏性能。
由于建初始堆岁序的比较次数较多,所欲堆排序不适宜与记录数较少的文件。
堆排序是就地排序,辅助空间为O(1)
堆排序是一种不稳定的排序方法。
归并排序是将两个或两个以上的有序表组合成一个新的有序表。
先将N个数据看成N个长度为1的表,将相邻的表成对合并,得到长度为2的N/2个有序表,进一步将相邻的合并,得到长度为4的N/4个有序表,一次类推,知道所有数据均合并成一个长度为N的有序表为止。
// 合并数据
private static void merge(int[] arr,int left,int center,int right) {
int[] newArr = new int[arr.length];
int mid = center+1;
int third = left;
int tmp = left;
while(left <=center&& mid<=right) {
if(arr[left]<=arr[mid]) {
newArr[third++]=arr[left++];
}else {
newArr[third++]=arr[mid++];
}
}
while(mid<=right) {
newArr[third++]=arr[mid++];
}
while(left<=center) {
newArr[third++]=arr[left++];
}
while(tmp<=right) {
arr[tmp]=newArr[tmp++];
}
}
//排序
private static void sort(int[] arr,int left,int right) {
if(left>=right) {
return;
}
int center = (left+right)/2;
sort(arr, left, center);
sort(arr, center+1, right);
merge(arr, left, center, right);
}
//归并排序
public static void mergeSort(int[] arr) {
sort(arr, 0, arr.length-1);
}
归并排序的时间复杂度无论是在最好情况下,还是在最坏情况下均是O(nlgn)。
需要一个辅助向量来暂存两个有序子文件归并的结果,故其辅助空间复杂度为O(n)。
归并排序是一种稳定的排序。
算法和数据结构的实现可以基于住存储器,也可以基于辅助存储器,但这会影响算法和数据结构的设计。
主存储器和辅助存储器的差别主要与存储介质中的访问速度、数据的存储量和数据的永久性有关。
访问辅助存储器比访问主存储器要慢很多,而外部排序的过程需要进行多次的主存储器和辅助存储器之间的交换。
调整内部排序算法使之应用于外部排序,这种思路的更普遍的问题是这样做不可能比设计一个新的尽量减少磁盘存取的算法更有效。
进行外部排序的一个更好的方法源于归并排序。
首先,按可用内存大小,将外存上喊n个记录的文件分成若干长度为l的子文件或段(segment),依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存。通常称这些有序子文件为归并段或顺串;然后对这些归并段进行逐趟归并,使归并段组件由小到大,直至整个有序文件为止。
置换选择实际上是堆排序算法的一个微小变种。
多路归并与二路归并类似。如果有B个顺串需要归并,从每个顺串中取出一个块放在主存中使用,那么B路归并算法仅仅查看B个值,并且选择最小的一个输出。把这个值从它的顺串中移出,然后重复这个过程。当任何顺串的当前块用完时,就从磁盘中独处这个顺串的下一个块。
一般来说,简历大的初始顺串可以把运行时间减少到标准归并排序的四分之一,使用多路归并可以进一步把时间减半。
本博文,部分参考十大经典排序算法(动图演示),所涉及的动图,均出自此博文!