1.归并排序
1.1递推公式
归并排序分解图
1.2代码实现
/**
* 归并排序
*
* @param value
*/
public static void mergeSort(int[] value) {
mergeSort(value, 0, value.length - 1);
}
//将两个排好序的合并到一起
public static void mergeSort(int[] value, int m, int n) {
//终止条件
if (m >= n) {
return;
}
mergeSort(value, m, (m + n) / 2);
mergeSort(value, (m + n) / 2 + 1, n);
//将两个子排序合并到一起 用临时数组
merge(value, m, n);
}
/**
* 合并函数
*
* @param value
* @param m
* @param n
*/
private static void merge(int[] value, int m, int n) {
int[] tem = new int[n - m + 1];
int i = m;
int j = (m + n) / 2 + 1;
for(int x = 0; x <= n - m; x++) {
if (j <= n && value[j] < value[i] || i > (m + n) / 2) {
tem[x] = value[j];
j++;
} else {
tem[x] = value[i];
i++;
}
}
for (x--; x >= 0; x--, n--) {
value[n] = tem[x];
}
}
如何简化merge函数
/**
* 简化merge函数
* todo 如何用哨兵简化代码
*
* @param value
* @param m
* @param n
*/
private static void merge1(int[] value, int m, int n) {
int[] tem = new int[n - m + 1];
int x = 0;
int i = m;
int j = (m + n) / 2 + 1;
while (x <= n - m) {
if (j <= n && value[j] < value[i] || i > (m + n) / 2) {
tem[x++] = value[j++];
} else {
tem[x++] = value[i++];
}
}
while (--x >= 0) {
value[x + m] = tem[x];
}
}
1.3算法分析
1.3.1稳定性
归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。
在合并的过程中,如果 A[p…q]和 A[q+1…r]之间有值相同的元素,那我们可以像伪代码中那样,先把 A[p…q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
1.3.2时间复杂度
不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
我们假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......
通过这样一步一步分解推导,我们可以得到 T(n) = 2k * T(n/2k) + k*n。当 T(n/2k)=T(1) 时,也就是 n/2k=1,我们得到 k=log2n 。
我们将 k 值代入上面的公式,得到 T(n)=C*n+n*log2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。
1.3.3空间复杂度
归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。
如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?
实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
2.快速排序
2.1递推公式
2.2代码实现
/**
* @param value
* @param m
* @param n
*/
public static void quickSort(int[] value, int m, int n) {
//终止条件
if (m >= n) {
return;
}
//找到分区点, 并分区
int pivot = partition(value, m, n);
quickSort(value, m, pivot - 1);
quickSort(value, pivot + 1, n);
}
/**
* 分区函数, 找到分区点, 并分区(移动分区点)
* @param value
* @param m
* @param n
* @return
*/
private static int partition(int[] value, int m, int n) {
int pivot = m;
for (int i = m + 1; i <= n; i++) {
//把比分区点小的放到分区点左边, 分区点右移一位, 分区点右边>=分区点的不动
// (数组中分区点右边的数换到比分区点小的数字位置)
if (value[i] < value[pivot]) {
int tem = value[i];
value[i] = value[pivot + 1];
value[pivot + 1] = value[pivot];
value[pivot++] = tem;
}
}
return pivot;
}
2.3算法分析
2.3.1稳定性
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。
2.3.2时间复杂度
快排也是用递归来实现的。对于递归代码的时间复杂度,我前面总结的公式,这里也还是适用的。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是 O(nlogn)。
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1
但是,公式成立的前提是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。我举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2), 即最坏情况时间复杂度。
那快排的平均情况时间复杂度是多少呢?
实际上,递归的时间复杂度的求解方法除了递推公式之外,还有递归树,在树那一节我再讲,这里暂时不说。我这里直接给你结论:T(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)。而且,我们也有很多方法将这个概率降到很低
2.3.3空间复杂度
通过元素交换的写法,快排的空间复杂度是O(1),属于原地排序算法。