文章目录
参考资料:
一、前言
上一节我讲了冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 O(n2),比较高,适合小规模数据的排序。今天,我讲两种时间复杂度为 O(nlogn) 的排序算法,归并排序和快速排序。这两种排序算法适合大规模的数据排序,比上一节讲的那三种排序算法要更常用。
二、归并排序的原理
2.1、分治思想
归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧。
2.2、如何用递归代码来实现归并排序?
import java.util.Arrays;
public class MergeSort {
public static void main(String[] args) {
// int arr[] = SortTestHelper.getRandomArray(15, -1, 1);
int arr[] = {12,5,6,10,2};
System.out.println("归并排序前:"+Arrays.toString(arr));
mergeSort(arr, 0, arr.length-1);
System.out.println("归并排序后:"+Arrays.toString(arr));
}
/**
* 递归使用归并排序,对arr[l...r]的范围进行排序(前闭区间,后闭区间)
* @param arr 待排序数组
* @param left 数组左
* @param right
*/
private static void mergeSort(int[] arr, int left, int right) {
//对于递归,要处理递归到底的判断,这里就是left>=right。
if( left >= right)//left = 0 right = 4 不管原数组的个数是奇数还是偶数,最大索引传进,进行多次递归最终都是left = right =0
//left = 0 right(mid) = 2
//left = 0 right(mid) = 1
//left = 0 right(mid) = 1/2 0
return;//终止了当前方法
int mid = (left+right)/2;
mergeSort(arr, left, mid);
mergeSort(arr, mid+1, right);//mid = 0 right = 1
merge(arr, left, mid, right); //将左右两部分,利用临时数组进行归并 left = 0 , mid = 0 ,right = 1
}
/**
* 将arr[l...mid]和arr[mid+1...r]两部分进行归并
* @param arr
* @param left
* @param mid
* @param right
* i:临时数组左边比较的元素下标;j:临时数组右边比较的元素的下标;k:原数组将要放置的元素下标
*/
private static void merge(int[] arr, int left, int mid, int right) {// left = 0 , mid = 0, right = 1
int[] aux = new int[right-left+1]; //临时辅助数组 right 1 - left 0 + 1 = [2]
for(int i=left; i<=right; i++)
//将以左边索引开始,右边索引结束的值
// {0,1,2,3,4,5}
// 第一次是将0位置的值赋值给0位置,第二次是将1位置的值赋值到1位置的值。
// 相当于将原数组赋值了一份值到aux[]临时数组
aux[i-left] = arr[i]; /*减去的left是原数组相对于临时数组的偏移量*/
int i=left, j=mid+1;
for(int k=left; k<=right; k++) {
if(i > mid) { //检查左下标是否越界
arr[k] = aux[j-left];
j++;
} else if(j > right) { //检查右下标是否越界
arr[k] = aux[i-left];
i++;
} else if(aux[i-left] <= aux[j-left]) {
arr[k] = aux[i-left];
i++;
} else {
arr[k] = aux[j-left];
j++;
}
}
}
}
2.3、自己实现的代码
import java.util.Arrays;
public class SortTest {
public static void main(String[] args) {
//主要分为两种情况,一种是偶数,一种是奇数。
// 1、问题一,如果是奇数,那么中间这个数应该是放在前面的一组里,还是后面的一组里。
//马士兵的说是排在前面一组
//2、问题二,如果是偶数的情况下,排序是有问题的
//3、问题三,要考虑稳定排序
int[] arr = {3, 4, 7, 1, 2, 6};//7 0 + 6
// 0, 1, 2, 3, 4, 5, 7
// merge_sort(arr, 7);
// merge(arr, 3, 3, 3);
// merge4(arr, 1, 3, 3);
sort(arr, 0, arr.length - 1);
}
public static void sort(int[] arr, int left, int right) {
if (left == right) return;//这里应该是>=,不知是什么原理?
//分成两半,中间值:如果是奇数,取值是前半段+中间值。这个公式适用于索引不管是0开头还是1开头。
// 注意:该公式特点是如果有小数,是直接省去的
//分成两半
int mid = (left + right) / 2;
//左边排序
sort(arr, left, mid);
//右边排序
sort(arr, mid + 1, right);//这有两个功能,一是当递归到终止条件时(剩余两个数),可以跳过这一步。二是可以递归分解后半段
//每次合并的时候,它的前一次合并的数组和下一次合并的数组是怎么连接?
merge4(arr, left, mid + 1, right);
}
public static void merge3(int[] arr, int leftPoint, int rightPoint, int boundPoint) {
int min = arr.length / 2;
//前半段的开头索引 int[] arr = {3, 4, 5, 1, 2,6};
int head1 = 0;
//后半段的开头索引
int head2 = min + 1;
//新数组的开始索引
int a = 0;
int[] arr3 = new int[arr.length];
//自己的实现方式一
//假设两个数组(前后两部分)都没有遍历完的情况时,执行具体的操作
while (head1 <= min && head2 < arr.length) {
arr3[a++] = arr[head1] <= arr[head2] ? arr[head1++] : arr[head2++];
}
//将上面的if()代码优化,可以写在下面。
//如果head2遍历完,head1没有遍历完的情况。执行以下操作
while (head1 <= min) arr3[a++] = arr[head1++];
//同理把下一个if()代码优化
//如果head1遍历完,head2没有遍历完的情况。执行以下操作
while (head2 < arr.length) arr3[a++] = arr[head2++];
System.out.println(Arrays.toString(arr3));
}
/**
* 以上方法不够灵活,可以指定前段开始指针、后段开始指针、最后指针。
* 这样就灵活多了。
* leftPoint 前段开始指针
* rightPoint 后段开始指针
* boundPoint 最后指针
* 需要考虑边界问题:1、leftPoint == rightPoint 。2、leftPoint < rightPoint 时,不执行。
* 此代码现在有问题:问题二
*/
public static void merge4(int[] arr, int leftPoint, int rightPoint, int boundPoint) {
int min = rightPoint - 1;
//前半段的开头索引
int head1 = leftPoint;
//后半段的开头索引
int head2 = rightPoint;
//新数组的开始索引
int a = 0;
int[] arr3 = new int[rightPoint - leftPoint + 1];
//自己的实现方式一
//假设两个数组(前后两部分)都没有遍历完的情况时,执行具体的操作
while (head1 <= min && head2 <= boundPoint) {
arr3[a++] = arr[head1] <= arr[head2] ? arr[head1++] : arr[head2++];
}
//将上面的if()代码优化,可以写在下面。
//如果head2遍历完,head1没有遍历完的情况。执行以下操作
while (head1 <= min) arr3[a++] = arr[head1++];
//同理把下一个if()代码优化
//如果head1遍历完,head2没有遍历完的情况。执行以下操作
while (head2 <= boundPoint) arr3[a++] = arr[head2++];
for (int i = 0; i < arr3.length; i++) {
arr[leftPoint + i] = arr3[i];
}
}
//归并排序
public class TestMergesort {
public static void main(String[] args) {
int[] arr = new int[]{23, 10, 9, 29, 1, 20, 2, 2};
mergesort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
/**
* 1、时间复杂度:(递归的时间复杂度求法:是根据递推公式和终止条件进行的求的)
* 最好时间复杂度:nlogn
* 最坏时间复杂度:nlogn
* 平均时间复杂度:nlogn 因为执行效率和数据的有序度是没有关系的,所以都是一样的。nlogn
* 说明:合并两个子数组的执行效率是O(n)
*
* 2、空间消耗:每次合并的时候,会申请一个数组(大小小于原数组)。因为每次申请完就会释放,最大申请就是原数组的大小。
* 所以最后空间复杂度是:O(n)
*
* 3、是否是稳定排序:主要看合并的时候,如果相同的数据(前半段和后半段),先将前半段放在了前面。
* 所以,是稳定的排序。
*
* @param arr
* @param start
* @param end
*/
public static void mergesort(int[] arr, int start, int end) {
if (start >= end) return;
int mid = (start + end) / 2;
mergesort(arr, start, mid);
mergesort(arr, mid + 1, end);
merge(arr, start, mid, end);
}
public static void merge(int[] arr, int start, int mid, int end) {
int[] ints = new int[end - start + 1];
for (int i = start; i <= end; i++) {
ints[i - start] = arr[i];//注意这里的start mid end 可能是某一段的索引
}
int i = start;//arr 中前半段第一个数
int j = mid + 1;//arr 中后半段第一个数
for (int a = start; a <= end; a++) {
if (i > mid) {
arr[a] = ints[j - start];
j++;
} else if (j > end) {
arr[a] = ints[i - start];
i++;
} else if (ints[i - start] > ints[j - start]) {
arr[a] = ints[j - start];
j++;
} else {
arr[a] = ints[i - start];
i++;
}
}
}
}
2.4、第一,归并排序是稳定的排序算法吗?
主要看merge()方法里,如果前半段和后半段的数相等时,是将前一个数进行放在前面。所以是稳定排序。
2.5、第二,归并排序的时间复杂度是多少?(即递归代码的时间复杂度分析)
求解 a问题,可以分为求解 b问题的时间和 c问题的时间。k为 b问题和 c问题合并所需的时间
T(a)= T(b)+ T(c)+ K
归并排序的时间复杂度的计算公式就是:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1
通过下面代码直观的看一下,n是数据的总量(总个数)
T(n) = 2T(n/2) + n
= 2(2T(n/4) + n/2) + n = 4T(n/4) + 2n
= 4(2T(n/8) + n/4) + 2n = 8T(n/8) + 3n
= 8*(2T(n/16) + n/8) + 3n = 16T(n/16) + 4n
…
= 2^k * T(n/2^k) + k * n
…
当 T(n/2 ^ k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。(因为n/2 ^k=1是最后一次分解,也就是终止条件。)
得到 T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。
归并排序的执行效率与要排序的**原始数组的有序程度无关,**所以其时间复杂度是非常稳定的。最好时间复杂度、最坏时间复杂度、平均时间复杂度都是 O(nlogn)。
说明:log 省略底数,在计算机中默认底数是2
2.6、第三,归并排序的空间复杂度是多少?
实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
三、快速排序的原理
3.1、快排的思想
如果要排序数组中下标从 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,就说明所有的数据都有序了。
程序执行时的问题:将左半部分递归完成后,代码是怎么跳到右半部分的?(方法压栈后,退栈的条件是什么)
3.2、快排的代码
import java.util.Arrays;
public class QuilkeySortTest {
public static void main(String[] args) {
int[] arr = {6, 11, 3, 7, 8};
// int[] arr = {9, 7, 8, 3, 2, 1};
quick_sort(arr, arr.length);
System.out.println(Arrays.toString(arr));
}
// 快速排序,A是数组,n表示数组的大小
public static void quick_sort(int[] arr, int n) {
quick_sort_c(arr, 0, n - 1);
}
// 快速排序递归函数,p,r为下标
public static void quick_sort_c(int[] arr, int p, int r) {
if (p >= r) return;
int q = partition(arr, p, r);// 获取分区点
quick_sort_c(arr, p, q - 1);
quick_sort_c(arr, q + 1, r);
}
//分区函数
public static int partition(int[] arr, int p, int r) {
int pivot = arr[r];//该数组最后一个值
int i = p;//第一个索引
//循环条件:当 j 遍历到最后一个值(pivot)时,跳出循环。
for (int j = p; j <= r - 1; j++) {
if (arr[j] < pivot) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
i += 1;
}
}
//当 j 指针指向最后一个数时,跳出上面的循环,执行最后一次交换
int temp2 = arr[i];
arr[i] = arr[r];//这里不能用 pivot的原因是:最后temp2赋值给arr[r]才赋值到数组里,不然是赋值不到的。
arr[r] = temp2;
return i;
}
}
自己的实现
//快排
public class TestQuicksort {
public static void main(String[] args) {
int[] arr = new int[]{23, 10, 9, 29, 1, 20, 2, 2};
// int[] arr = {1, 4, 3, 6, 8, 2, 5, 7};
quick(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
public static void quick(int[] arr, int start, int end) {
if (start >= end) return;
int q = quicksort(arr, start, end);
quick(arr, start, q - 1);
quick(arr, q + 1, end);
}
/**
* 1、时间复杂度:
* 最坏时间复杂度: n2
* 最好时间复杂度: nlogn
* 平均时间复杂度:
* 2、空间消耗:只用到了常量级的空间,所以是原地排序
*
* 3、是否是稳定排序:不是。例如:9、11、11、8、7、10
*
* @param arr
* @param start
* @param end
* @return
*/
public static int quicksort(int[] arr, int start, int end) {
int i = start;
for (int j = start; j < end; j++) {
if (arr[j] < arr[end]) {
swap(arr, i, j);
i++;
}
}
swap(arr, i, end);
return i;
}
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
3.3、快排代码的自己理解的图解
3.5、第一,快速排序是稳定的排序算法吗?
快速排序并不是一个稳定的排序算法。
3.6、第二,快速排序的时间复杂度是多少?(即递归代码的时间复杂度分析)
最好情况时间复杂度(分区极其均衡):O(nlogn)
最坏情况时间复杂度(分区极其不均衡):O(n2)
那快排的平均情况时间复杂度是多少呢?
求解递归的时间复杂度两种方式:
1、递推公式
2、递归树
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = T(n/10) + T(9*n/10) + n; n>1
3.7、第三,快速排序的空间复杂度是多少?
快速排序并是一个原地排序算法。
3.4、问题收集
1、执行partition函数的时候,指针 i 和 指针 j 指向第一个值时,是否可以省去他们交换的执行?影响性能大吗?因为交换前后都是一样的。
2、分区的时候,取pivot 值(随机取其一个值)是否合理?会不会造成前半段个数一直很多或者后半段个数一直很多?这样是否有影响?
四、解答开篇问题
在一个数组中,找第K大元素(类似排名名次),比如:4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。
用快排排好序后,根据索引来确定第K大元素。
public class STest {
public static void main(String[] args) {
int[]arr={8,6,5,7};
kthSmallest(arr,4);
}
public static int kthSmallest(int[] arr, int k) {
if (arr == null || arr.length < k) {
return -1;
}
int partition = partition(arr, 0, arr.length - 1);
while (partition + 1 != k) { //这一步
if (partition + 1 < k) {
partition = partition(arr, partition + 1, arr.length - 1);
} else {
partition = partition(arr, 0, partition - 1);
}
}
return arr[partition];
}
private static int partition(int[] arr, int p, int r) {
int pivot = arr[r];
int i = p;
for (int j = p; j < r; j++) {
// 这里要是 <= ,不然会出现死循环,比如查找数组 [1,1,2] 的第二小的元素
if (arr[j] <= pivot) {
swap(arr, i, j);
i++;
}
}
swap(arr, i, r);
return i;
}
private static void swap(int[] arr, int i, int j) {
if (i == j) {
return;
}
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}