一:归并排序
要排序一个数组,我们先把数组分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就有序了。
归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。分治思想一般是通过递归来实现的。
代码实现:
/**
* 递归切分待排
*
* @param nums 待切分数组
* @param left 待切分最后第一个元素的索引
* @param right 待切分数组最后一个元素的索引
*/
public static void mergeSort(int[] nums, int left, int right) {
if (left < right) {
// 找出中间索引
int center = (left + right) / 2;
// 对左边数组进行递归
mergeSort(nums, left, center);
// 对右边数组进行递归
mergeSort(nums, center + 1, right);
// 合并
merge(nums, left, center, right);
}
}
/**
* 两两归并排好序的数组(2路归并)
* @param nums 带排序数组对象
* @param left 左边数组的第一个索引
* @param center 左数组的最后一个索引,center + 1右数组的第一个索引
* @param right 右数组的最后一个索引
*/
private static void merge(int[] nums, int left, int center, int right) {
int[] tmpArray = new int[right - left + 1];
int leftIndex = left; //左数组第一个元素的索引
int rightIndex = center + 1; //右数组第一个元素索引
int tmpIndex = 0; //临时数组索引
// 把较小的数先移到新数组中
while (leftIndex <= center && rightIndex <= right) {
if (nums[leftIndex] <= nums[rightIndex]) {
tmpArray[tmpIndex++] = nums[leftIndex++];
} else {
tmpArray[tmpIndex++] = nums[rightIndex++];
}
}
// 把左边剩余的数移入数组
while (leftIndex <= center) {
tmpArray[tmpIndex++] = nums[leftIndex++];
}
// 把右边边剩余的数移入数组
while (rightIndex <= right) {
tmpArray[tmpIndex++] = nums[rightIndex++];
}
//可以优化成下面的写法
System.arraycopy(tmpArray, 0, nums, left, tmpArray.length);
System.out.println("nums: " + Arrays.toString(nums));
}
运行结果:
时间复杂度:归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管最好情况、最坏情况、平均情况,时间复杂度都是O(nlogn)
空间复杂度:归并排序有一个致命弱点,不是原地排序算法。在归并排序的合并两个有序数组的函数中,需要借助额外的存储空间。但是在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小,所以空间复杂度是O(n)
二:快速排序
如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。
我们遍历p到r之间的数据,将小于pivot的数据放到左边,大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据分成了三部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。
归并排序有个合并merge函数,这里有个分区partition函数,就是随机选择一个元素(可以选择p到r区间的最后一个元素 ),然后对A[p....r]分区,函数返回pivot的下标。如果不考虑空间消耗的话,partition分区函数可以写的非常简单,申请两个临时数组X和Y,遍历A[p....r],将小于pivot的元素都拷贝到临时数组X,将大于pivot的元素都拷贝到临时数组Y,最后再将数组X和数组Y数据顺序拷贝到A[p...r]
但是这样partition就需要申请很多额外的内存空间,所以快排就不是原地排序算法了。如果希望是原地排序算法,空间复杂度得是O(1),那partition函数就不能占用太多额外的内存空间,我们就需要在A[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] 的位置。 数组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。当时我们也讲了一种处理技巧,就是交换,在 O(1) 的时间复杂度内完成插入操作。这里我们也借助 这个思想,只需要将 A[i] 与 A[j] 交换,就可以在 O(1) 时间复杂度内将 A[j] 放到下标为 i的位置。
代码:
/**
* 快速排序递归函数
*
* @param arr
* @param p
* @param r
*/
public static void quickSort(int[] arr, int p, int r) {
if (p >= r) {
return;
}
int q = partition(arr, p, r);
quickSort(arr, p, q - 1);
quickSort(arr, q + 1, r);
}
/**
* 分区函数
*
* @param arr
* @param p
* @param r
* @return 分区点位置
*/
private static int partition(int[] arr, int p, int r) {
int pivot = arr[r];
int i = p;
for (int j = p; j < r; j++) {
if (arr[j] <= pivot) {
swap(arr, i, j);
i++;
}
}
swap(arr, i, r);
System.out.println("arr: " + Arrays.toString(arr));
System.out.println();
return i;
}
/**
* 交换
*
* @param arr
* @param i
* @param j
*/
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;
}
运行结果:
归并和快排的区别:
归并和快排用得都是分治思想,那他们的区别在哪里呢?可以发现归并排序的处理过程是由下到上,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的。时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法,主要是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
快排性能分析:
如果每次分区操作,都能把数组分成大小接近相等的两个区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以这种情况下时间复杂度为O(nlogn)。但是公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等的一分为二,实际上这种情况是很难实现的。
举一个比较极端的例子,如果数组中的数据原来就是有序的,比如:1,3,6,8,10,15,如果每次选择最后的元素作为pivot,那每次分区得到的两个区间是不均等,需要进行N次分区,才能完成整个的快排动作。每次分区大约要扫描n/2个元素,这种情况下,快排的时间复杂度就从O(nlogn)退化成了O(n2),但我们可以通过合理的选择pivot来避免这种情况。