排序算法
目录
一、概念
- 排序:是计算机程序设计中的一项重要操作,其功能是指一个数据元素集合或序列重新排列成一个按数据元素某个数据项值有序的序列。
- 排序码(关键码):排序依据的数据项。
- 稳定排序:排序前与排序后相同关键码元素间的位置关系,保持一致的排序方法。
- 不稳定排序:排序前与排序后相同关键码元素间的相对位置发生改变的排序方法
- 排序分为两类:
- 内排序:指待排序列完全存放在内存中所进行的排序。内排序大致可分为五类:插入排序、交换排序、选择排序、归并排序和分配排序。
- 外排序:指排序过程中还需访问外存储器的排序。
- 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
- **空间复杂度:**是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
为了以后讨论方便,我们直接将排序码写成一个一维数组的形式,并且在没有声明的情形下,所有排序都按排序码的值递增排列。
二、插入排序
- 基本思想:每次将一个待排序的元素,按其关键字的大小插入到前面已经排好序的子文件的适当位置,直到全部记录插入完成为止。
1、直接插入排序
-
直接插入排序的基本思想是:把n个待排序的元素看成为一个有序表和一个无序表,开始时有序表中只包含一个元素,无序表中包含有n-1个元素,排序过程中每次从无序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。
-
图解
-
代码示例
public static void insertSort(int arr[]) {
/*从数组的第二个元素开始进行相应的排序*/
for (int i = 1; i < arr.length; i++) {
/*i控制第i次插入,最多进行n-1次插入*/
if (arr[i] < arr[i - 1]) {
/*小于时,需将arr[i]插入有序表相应位置*/
int temp = arr[i]; /*使用临时变量存储arr[i]*/
int j = i - 1;
while (j >= 0 && arr[j] > temp) {
arr[j + 1] = arr[j]; /*元素后移*/
j--; /*从后往前依次比较*/
}
arr[j + 1] = temp; /*将临时变量的值插入到有序表中*/
}
}
}
- 效率分析
- 首先从空间来看,它只需要一个元素的辅助空间,用于元素的位置交换。从时间分析,首先外层循环要进行n-1次插入,每次插入最少比较一次(正序),移动0次;最多比较i次(包括同监视哨temp的比较),移动i+1次(逆序)(i=2,3,…,n)。因此,直接插入排序的时间复杂度为O(n2)。
- 直接插入算法的元素移动是顺序的,该方法是稳定的。
2、希尔排序
-
希尔排序的基本思想:先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上有较大提高。希尔排序又叫缩小增量排序。
-
图解
-
代码示例
public static void shellSort(int[] arr) {
int len = arr.length; //获取数组长度
//将长度除2获取增量(gap),增量(gap)每次变化为原来的一半(向下取整)
for(int gap = len / 2; gap > 0; gap = gap / 2) {
//将分组后的子序列做 直接插入排序
for(int i = gap; i < len; i++) {
int j = i;
int temp = arr[i]; //将arr[i]保存到临时变量
while(j - gap >= 0 && temp < arr[j - gap]) {
arr[j] = arr[j - gap]; //元素后移
//将j定位到子序列的前一个元素,每个元素之间相差gap
j = j - gap;
}
arr[j] = temp; //将临时变量插入到有序表中
}
}
}
- 效率分析
- 虽然我们给出的算法是三层循环,最外层循环为log2n数量级,中间的for循环是n数量级的,内循环远远低于n数量级,因为当分组较多时,组内元素较少;此循环次数少;当分组较少时,组内元素增多,但已接近有序,循环次数并不增加。因此,希尔排序的时间复杂性在O ( nlog2n )和O ( n2 )之间,大致为O(n1.3)。
- 由于希尔排序对每个子序列单独比较,在比较时进行元素移动,有可能改变相同排序码元素的原始顺序,因此希尔排序是不稳定的。
三、交换排序
- 主要是通过排序表中两个记录关键码的比较,若与排序要求相逆(不符合升序或降序),则将两者交换。
1、冒泡排序
-
冒泡排序的基本思想:通过对待排序序列从前向后,依次比较相邻元素的排序码,若发现逆序则交换,使排序码较大的元素逐渐从前部移向后部。因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,因此要在排序过程中设置一个标志flag判断元素是否进行过交换。从而减少不必要的比较。
-
图解
-
代码示例
public static void bubbleSort(int[] arr) {
//i表示趟数,最大不超过n-1趟,n为数组长度
for (int i = 0; i < arr.length - 1; i++) {
//定义标识,判断排序是否完成
boolean flag = true;
//从第一个开始比较,将大的值往后移,j表示要比较值所在的位置
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
//交换两个位置的值
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = false; //若发生交换,则设置未false
}
}
//设置标识,如果一回合内未发生过交换,则表示排序完成
if (flag) {
break; //若排序完成,则直接退出循环
}
}
}
- 效率分析
- 从冒泡排序的算法可以看出,若待排序的元素为正序,则只需进行一趟排序,比较次数为(n-1)次,移动元素次数为0;若待排序的元素为逆序,则需进行n-1趟排序,比较次数为**(n2-n)/2**,移动次数为3(n2-n)/2,因此冒泡排序算法的时间复杂度为O(n2) 。由于其中的元素移动较多,所以属于内排序中速度较慢的一种。
- 因为冒泡排序算法只进行元素间的顺序移动,所以是一个稳定的算法。
2、快速排序
-
快速排序(Quick Sorting)是迄今为止所有内排序算法中速度最快的一种。它的基本思想是:任取待排序序列中的某个元素作为标准(也称为支点、界点,一般取第一个元素),通过一次划分,将待排元素分为左右两个子序列,左子序列元素的排序码均小于基准元素的排序码,右子序列的排序码则大于或等于基准元素的排序码,然后分别对两个子序列继续进行划分,直至每一个序列只有个元素为止。最后得到的序列便是有序序列。
-
图解
-
此处基准值为最后一个元素 31
-
代码示例
public static void quickSort(int[] arr, int leftIndex, int rightIndex) {
if (leftIndex >= rightIndex) {
return;
}
int left = leftIndex;
int right = rightIndex;
//待排序的第一个元素作为基准值
int key = arr[left];
//从左右两边交替扫描,直到left = right
while (left < right) {
while (right > left && arr[right] >= key) {
//从右往左扫描,找到第一个比基准值小的元素
right--;
}
//找到这种元素将arr[right]放入arr[left]中
arr[left] = arr[right];
while (left < right && arr[left] <= key) {
//从左往右扫描,找到第一个比基准值大的元素
left++;
}
//找到这种元素将arr[left]放入arr[right]中
arr[right] = arr[left];
}
//基准值归位,此时left=right
arr[left] = key;
//对基准值左边的元素进行递归排序
quickSort(arr, leftIndex, left - 1);
//对基准值右边的元素进行递归排序。
quickSort(arr, right + 1, rightIndex);
}
-
递归树
-
快速排序的递归过程可用一棵二叉树形象地给出。下图为待排序列49、38、65、97、76、13、27、49’所对应的快速排序递归调用过程的二叉树(简称为快速排序递归树)。
-
快速排序是递归的,需要有一个栈存放每层递归调用时的指针和参数。最大递归调用层次数与递归树的高度一致。
-
-
效率分析
如果每次划分对一个对象定位后,该对象的左子序列与右序列的长度相同,则下一步将是对两个长度减半的子序列进行排序,这是最理想的情况。
假设n是2的幂,n=2k,(k=log2n),假设支点位置位于序列中间,这样划分的子区间大小基本相等。 n+2(n/2)+4(n/4)+…+n(n/n)=n+n+…+n=nk=nlog2n
因此,快速排序的最好时间复杂度为O(nlog2n) 。而且在理论上已经证明,快速排序的平均时间复杂度也为O(nlog2n)。实验结果表明:就平均计算时间而言,快速排序是所有内排序方法中最好的一个。
在最坏的情况,即待排序对象序列已经按其排序码从小到大排好序的情况下,其递归树成为单支树,每次划分只得到一个比上一次少一个对象的子序列(蜕化为冒泡排序)。必须经过n-1趟才能把所有对象定位,而且第i趟需要经过n-i次排序码比较才能找到第i个对象的安放位置,总的排序码比较次数将达到
∑ i = 1 n − 1 ( n − i ) = 1 2 n ( n − 1 ) ≈ n 2 2 \sum_{i=1}^{n-1}(n-i)=\frac{1}{2}n(n-1)\approx \frac{n^2}{2} i=1∑n−1(n−i)=21n(n−1)≈2n2
因此,快速排序的最坏时间复杂度为O(n2)
-
空间复杂度及稳定性
- 快速排序最好的空间复杂度为O(nlog2n),最坏的空间复杂度为O(n2)。
- 快速排序是一种不稳定的排序方法。可用3,2,2’序列来验证。
四、选择排序
- 基本原理:将待排序的元素分为已排序(初始为空)和未排序两组,依次将未排序的元素中值最小的元素放入已排序的组中。
1、简单选择排序
- 在一组元素R[i]到R[n]中选择具有最小关键码的元素
- 若它不是这组元素中的第一个元素,则将它与这组元素中的第一个元素对调。
- 除去具有最小关键字的元素,在剩下的元素中重复第(1)、(2)步,直到剩余元素只有一个为止。
-
图解
-
代码示例
public static void selectSort(int[] arr) {
int min; //定义最小值的下标
for (int i = 0; i < arr.length; i++) {
min = i; //初始最小值默认为第 i 个
//定位到最小值的下标
for (int j = i+1; j < arr.length; j++) {
if(arr[min] > arr[j]) {
min = j;
}
}
if(min != i) { //将最小值交换到下标为 i 的位置
int temp = arr[i];
arr[i] = arr[min];
arr[min] = temp;
}
}
}
-
无论初始状态如何,在第i趟排序中选择最小关键码的元素,需做n-i次比较,因此总的比较次数为:
∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 = O ( n 2 ) ( 即 时 间 复 杂 度 ) \sum_{i=1}^{n-1}(n-i)=\frac{n(n-1)}{2}=O(n^2)(即时间复杂度) i=1∑n−1(n−i)=2n(n−1)=O(n2)(即时间复杂度)
-
最好情况:序列为正序时,移动次数为0,最坏情况:序列为反序时,每趟排序均要执行交换操作,总的移动次数取最大值3(n-1)。
-
由于在直接选择排序中存在着不相邻元素之间的互换,因此,直接选择排序是一种不稳定的排序方法。例如,给定排序码为3,7,3’,2,1,排序后的结果为1,2,3’,3,7。
五、归并排序
1、2-路归并排序
-
二路归并排序的基本思想:将两个有序表合并成一个有序表。
-
基本做法:首先将初始序列的n个记录看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到n/2向上取整(n为奇数时,最后一个序列的长度为1)的有序序列。在此基础上,再对长度为2的有序子序列进行两两归并,得到若干个长度为4的有序子序列。以此类推,直到得到一个长度为n的有序序列为止。
-
原理:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
- 设定两个变量,初始值分别为两个已经排序序列的起始位置;
- 比较以两个变量为下标的元素,选择相对较小的元素放入到合并空间,并使其下标值为下一位置下标;
- 重复步骤三直到子序列的左下标超过右下标;
- 将另一序列剩下的所有元素直接复制到合并序列尾;
-
图解
-
代码示例
// 归并排序 时间复杂度O(nlogn) 空间复杂度O(n) 稳定性:稳定排序。
public static void mergeSort(int[] arr, int left, int right) {
//左边与右边相同就直接返回
if (left >= right) {
return;
}
//取分割的下标
int mid = (right + left) / 2;
//分割的左边部分
mergeSort(arr, left, mid);
//分割的右边部分
mergeSort(arr, mid + 1, right);
//分割完分别排序之后合并
merge(arr, left, mid, right);
}
//合并
public static void merge(int[] arr, int left, int mid, int right) {
//创建一个临时数组
int[] tempArr = new int[right - left + 1];
//记录分割两个数组以及临时数组下标位置
int leftIndex = left, rightIndex = mid + 1, index = 0;
//循环直到临时数组填满,排序
while (index < tempArr.length) {
//若左边的数组下标未到结尾且右边的数组下标到了结尾
// 或左边数组下标未到结尾且左边的数组下标对应数据大于右边的数组下标对应数据,就填写到临时数组中
while (leftIndex <= mid && (rightIndex > right || arr[leftIndex] < arr[rightIndex])) {
tempArr[index++] = arr[leftIndex++];
}
//若右边的数组下标未到结尾且左边的数组下标到了结尾
// 或右边数组下标未到结尾且右边的数组下标对应数据大于左边的数组下标对应数据,就填写到临时数组中
while (rightIndex <= right && (leftIndex > mid || arr[leftIndex] > arr[rightIndex])) {
tempArr[index++] = arr[rightIndex++];
}
}
//此时临时数组已经将原数组left至right位置的数据排序完成并存放
//只需将临时数组的数据覆盖到原数组对应位置即可
for (int i = 0; i < tempArr.length; i++) {
arr[left + i] = tempArr[i];
}
}
- 效率分析
- 2-路归并排序的时间复杂度等于归并趟数与每一趟时间复杂度的乘积。对n个元素的表,将这n个元素看作叶结点,若将两两归并生成的子表看作它们的父结点,则归并过程对应由叶向根生成一棵二叉树的过程。所以归并趟数等于二叉数的高度减1,即 log2n 。每一趟归并需移动n个元素,即每一趟归并的时间复杂度为O(n)。因此,2-路归并排序的时间复杂度为O(nlog2n)。
- 利用二路归并排序时,需要利用与待排序数组相同的辅助数组作临时单元,故该排序方法的空间复杂度为0(n),比前面介绍的其它排序方法占用的空间大。
- 由于二路归并排序中,每两个有序表合并成一个有序表时,若分别在两个有序表中出现有相同排序码,则会使前一个有序表中相同排序码先复制,后一有序表中相同排序码后复制,从而保持它们的相对次序不会改变。所以,2-路归并排序是一种稳定的排序方法。
六、各种排序方法的比较
1、性能比较
- 从平均时间而言:快速排序最佳。但在最坏情况下时间性能不如堆排序和归并排序。
- 从算法简单性看:由于直接选择排序、直接插入排序和冒泡排序的算法比较简单,将其认为是简单算法,都包含在上图中的“简单排序”中。对于希尔排序、堆排序、快速排序和归并排序算法,其算法比较复杂,认为是复杂排序。
- 从稳定性看:直接插入排序、冒泡排序和归并排序是稳定的;而希尔排序、直接选择排序、快速排序和堆排序是不稳定排序。
- 从待排序的记录数n的大小看,n较小时,宜采用简单排序;而n较大时宜采用改进排序。
2、选择排序的方法
- 当待排序记录数n较大时,若要求排序稳定,则采用归并排序。
- 当待排序记录数n较大,关键字分布随机,而且不要求稳定时,可采用快速排序;
- 当待排序记录数n较大,关键字会出现正、逆序情形,可采用堆排序(或归并排序)。
- 当待排序记录数n较小,记录已接近有序或随机分布时,又要求排序稳定,可采用直接插入排序。
- 当待排序记录数n较小,且对稳定性不作要求时,可采用直接选择排序。