排序算法过程动态图:https://visualgo.net/zh/sorting
一、排序算法基础理论
原地排序:特指空间复杂度是 O ( 1 ) O(1) O(1)的排序算法
排序算法的稳定性:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后排序不变,这种排序算法称为稳定的排序算法
二、冒泡排序
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置(最大的元素移动到最后的位置),重复n次,就完成了n个数据的排序工作
对一组数据4、5、6、3、2、1,从小到大进行排序。第一次冒泡操作的详细过程如下:
经过一次冒泡操作之后,6这个元素已经存储在正确的位置上。要想完成所有数据的排序,只要进行6次这样的冒泡操作就行了
冒泡排序的优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。下图的例子里面有6个元素排序,只需要4次冒泡操作就可以了
代码实现:
public void bubbleSort(int[] nums, int n) {
for (int i = 0; i < n; ++i) {
boolean flag = false;//提前退出冒泡循环的标志位
for (int j = 0; j < n - i - 1; ++j) {
if (nums[j + 1] < nums[j]) {//交换
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
flag = true;//表示有数据交换
}
}
if (!flag) break;//没有数据交换,提前退出
}
}
算法分析:
冒泡排序包含两个操作:比较和交换,平均时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)(原地排序算法),在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法
三、插入排序
将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束
如图所示,要排序的数据是4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间
插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当需要将一个数组a插入到已排序区间时,需要拿a与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素a插入
代码实现:
public void insertionSort(int[] nums, int n) {
for (int i = 1; i < n; ++i) {
int value = nums[i];
int j = i - 1;
//查找插入的位置
for (; j >= 0; --j) {
if (nums[j] > value) {
nums[j + 1] = nums[j];//数据移动
} else {
break;
}
}
nums[j + 1] = value;//插入数据
}
}
算法分析:
插入排序包含两个操作:元素的比较和移动,平均时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)(原地排序算法),在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法
四、选择排序
选择排序算法的实现思路有点类似于插入排序,也分为已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的尾部
代码实现:
public void selectionSort(int[] nums, int n) {
for (int i = 0; i < n - 1; ++i) {
//查找最小值
int minIndex = i;
for (int j = i + 1; j < n; ++j) {
if (nums[j] < nums[minIndex]) {
minIndex = j;
}
}
//交换
int tmp = nums[i];
nums[i] = nums[minIndex];
nums[minIndex] = tmp;
}
}
算法分析:
插入排序包含两个操作:元素的比较和移动,平均时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)(原地排序算法),选择排序是一种不稳定的排序算法。比如5,8,5,2,9这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素2,与第一个5交换位置,那第一个5和中间的5顺序就变了,所以就不稳定了
五、归并排序
归并排序的核心思想:如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了(分治思想)
递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:
p >= r 不用再继续分解
如何将A[p…q]和A[q+1…r]合并成一个有序的数组:
如下图所示,申请一个临时数组tmp,大小与A[p…r]相同。用两个游标i和j,分别指向A[p…q]和A[q+1…r]的第一个元素。比较这两个元素A[i]和A[j],如果A[i]<=A[j],就把A[i]放入到临时数组tmp,并且i后移一位,否则将A[j]放入到数组tmp,j后移一位
继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到原数组A[p…r]中
代码实现:
//nums是需要进行排序的数组,n表示数组大小
public void mergeSort(int[] nums, int n) {
mergeSortInternally(nums, 0, n - 1);
}
//递归调用函数
private void mergeSortInternally(int[] nums, int p, int r) {
//递归终止条件
if (p >= r) return;
//取p到r之间的中间位置q
int q = p + ((r - p) >> 1);
//分治递归
mergeSortInternally(nums, p, q);
mergeSortInternally(nums, q + 1, r);
//将A[p...q]和A[q+1...r]合并为A[p...r]
merge(nums, p, q, r);
}
private void merge(int[] nums, int p, int q, int r) {
int i = p;
int j = q + 1;
int k = 0;//初始化变量i,j,k
int[] tmp = new int[r - p + 1];//申请一个大小跟a[p...r]一样的临时数组
while (i <= q && j <= r) {
if (nums[i] <= nums[j]) {
tmp[k++] = nums[i++];
} else {
tmp[k++] = nums[j++];
}
}
//判断哪个子数组中有剩余的数据
int start = i;
int end = q;
if (j <= r) {
start = j;
end = r;
}
//将剩余的数据拷贝到临时数组tmp
while (start <= end) {
tmp[k++] = nums[start++];
}
//将tmp中的数组拷贝回nums[p...r]
for (i = 0; i < tmp.length; ++i) {
nums[p + i] = tmp[i];
}
}
算法分析:
归并排序合并的过程中,如果A[p…q]和A[q+1…r]之间有值相同的元素,可以先把A[p…q]中的元素放入tmp数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法
归并排序时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn)
归并排序不是原地排序算法,空间复杂度是 O ( n ) O(n) O(n)
六、快速排序
快排的核心思想:如果要排序数组中下标从p到r之间的一组数据,选择p到r之间的任意一个数据作为pivot(分区点)。遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的
根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
终止条件:
p >= r
通过游标i把A[p…r-1]分成两部分。A[p…i-1]的元素都是小于pivot的,暂且叫它已处理区间,A[i…r-1]是未处理区间。每次都从未处理的区间A[i…r-1]中取一个元素A[j],与pivot对比,如果小于pivot,则将其加入到已处理区间的尾部,也就是A[i]的位置。只需要将A[i]与A[j]交换,就可以在
O
(
1
)
O(1)
O(1)时间复杂度内将A[j]放到下标为i的位置
代码实现:
//nums是需要进行排序的数组,n表示数组的大小
public void quickSort(int[] nums, int n) {
quickSortInternally(nums, 0, n - 1);
}
private void quickSortInternally(int[] nums, int p, int r) {
if (p >= r) return;
int q = partition(nums, p, r);//获取分区点
quickSortInternally(nums, p, q - 1);
quickSortInternally(nums, q + 1, r);
}
private int partition(int[] nums, int p, int r) {
int pivot = nums[r];
int i = p;
for (int j = p; j < r; ++j) {
if (nums[j] < pivot) {
if (i == j) {
++i;
} else {
int tmp = nums[i];
nums[i++] = nums[j];
nums[j] = tmp;
}
}
}
int tmp = nums[i];
nums[i] = nums[r];
nums[r] = tmp;
return i;
}
算法分析:
快速排序分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个6的相对先后顺序就会改变。所以,快速排序不是一个稳定的排序算法
极端情况的例子,如果数组中的数据原来已经是有序的了,比如1,3,5,6,8。如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的。需要进行大约n次分区操作,才能完成快排的整个过程。每次分区平均要扫描大约 n / 2 n/2 n/2个元素,这种情况下,快排的时间复杂度就从 O ( n l o g n ) O(nlogn) O(nlogn)退化到 O ( n 2 ) O(n^2) O(n2)快速排序在大部分情况下的时间复杂度都可以做到 O ( n l o g n ) O(nlogn) O(nlogn),只有在极端情况下,才会退化到 O ( n 2 ) O(n^2) O(n2)
快速排序是一个原地排序算法,空间复杂度为 O ( 1 ) O(1) O(1),解决了归并排序占用太多内存的问题,所以快速排序的应用更加广泛
快速排序的优化:
快速排序最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。如果直接选择第一个或者最后一个分区点,不考虑数据的特点,在某些情况下就会出现 O ( n 2 ) O(n^2) O(n2)的时间复杂度,可以采用三数取中法或者随机法来选取分区点
三数取中法:从区间的首、尾、中间,分别取出一个数,然后对比大小,取这3个数的中间值作为分区点
随机法:每次从要排序的区间中,随机选择一个元素作为分区点
总结
Java中Arrays.sort的实现:
- 若数组元素个数总数小于47,使用插入排序
- 若数据元素个数总数在47~286之间,使用快速排序。采用三数取中法进行优化
- 若大于286,使用归并排序