O(nlogn)
归并排序(Merge Sort)
如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
用递归实现归并排序:
- 递推公式:
merge_sort(start…end) = merge(merge_sort(start…mid), merge_sort(mid+1…end))
- 下标 mid 等于 start 和 end 的中间位置,也就是 (start+end)/2
- 终止条件:
start >= end 不用再继续分解
|
|
归并排序的空间复杂度是多少?
- 递归代码的空间复杂度并不能像时间复杂度那样累加。尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。
- 在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
归并排序的时间复杂度是多少?
- 如果我们定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那我们就可以得到这样的递推关系式:
T(a) = T(b) + T(c) + K
- 其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。
- 不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
- 归并排序的时间复杂度的计算公式是:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; n>1 = 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) = 2^kT(n/2^k)+kn
,当T(n/2^k)=T(1)
时,也就是n/2^k=1
,我们得到 $k=log_2 n$- 我们将 k 值代入上面的公式,得到 $T(n)=Cn+nlog_2 n$ 。
- 如果我们用大 O 标记法来表示的话,T(n) 就等于 $O(nlogn)$
快排
如果要排序数组中下标从 start 到 end 之间的一组数据,我们选择 start 到 end 之间的任意一个数据作为 pivot(分区点)。
我们遍历 start 到 end 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。
经过这一步骤之后,数组 start 到 end 之间的数据就被分成了三个部分:
- 前面 start 到 i-1 之间都是小于 pivot 的,
- 中间是 pivot,
- 后面的 i+1 到 end 之间是大于 pivot 的。
用递归实现快排:
- 递推公式:
quick_sort(start…end) = quick_sort(start…i-1) + quick_sort(i+1…end)
- 终止条件:
start >= end 不用再继续分解
|
|
快排是稳定的排序算法吗?
- 因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。
- 所以,快速排序并不是一个稳定的排序算法。
快排的时间复杂度是多少?
- 快排的时间复杂度的计算公式是:
- 假设,每次选择的 pivot 都很合适,正好能将大区间对等地一分为二,那么它的递归公式就和归并排序是一样的:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; n>1
- 假设每次分区操作都将区间分成大小为 9:1 的两个小区间:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = T(n/10) + T(9*n/10) + n; n>1
- 这个公式的递推求解的过程非常复杂,虽然可以求解,但我不推荐用这种方法。
- 实际上,递归的时间复杂度的求解方法除了递推公式之外,还有递归树
- 如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。
- 如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。
- 我们需要进行大约 n 次分区操作,才能完成快排的整个过程。
- 每次分区我们平均要扫描大约 n/2 个元素。
- 这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。
- 假设,每次选择的 pivot 都很合适,正好能将大区间对等地一分为二,那么它的递归公式就和归并排序是一样的:
- 结论:
T(n) = 2^kT(n/2^k)+kn
,当T(n/2^k)=T(1)
时,也就是n/2^k=1
,我们得到 $k=log_2 n$- 我们将 k 值代入上面的公式,得到 $T(n)=Cn+nlog_2 n$ 。
- 如果我们用大 O 标记法来表示的话,T(n) 就等于 $O(nlogn)$
如何用快排思想在O(n)内查找第K大元素?
选择数组区间 A[0…n-1]的最后一个元素 A[n-1]作为 pivot,对数组 A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
- 如果
p+1=K
,那 A[p]就是要求解的元素; - 如果
K>p+1
, 说明第 K 大元素出现在 A[p+1…n-1]区间,我们再按照上面的思路递归地在 A[p+1…n-1]这个区间内查找 - 如果
K<p+1
,那我们就在 A[0…p-1]区间查找。
为什么上述解决思路的时间复杂度是 O(n)?
- 第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。
- 第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。
- 依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。
- 如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。
借助递归树来求解递归算法的时间复杂度
归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。
如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作递归树。
- 依次计算每一层递归操作涉及到的数据规模之和,一般都是n
- 那么所有层需要遍历的数据总和为 $hn$,也就是时间复杂度为 $O(hn)$
- 用递归树来求解归并排序的时间复杂度
归并排序递归树
m(n)
/ \ -------------------------------n
m(n/2) m(n/2)
/ \ / \ -------------------n/2+n/2=n
m(n/4) m(n/4) m(n/4) m(n/4)
/ \ / \ / \ / \ ----n/4+n/4+n/4+n/4=n
m(n/8) m(n/8) m(n/8)m(n/8) m(n/8) m(n/8) m(n/8) m(n/8)
- 每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1
- 归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组
- 归并操作消耗的时间与数据规模相关
- 从图中可以看出,每层归并操作,涉及的数据规模累计和都是n。
- 我们只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 $O(n∗h)$。
- 从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树。
- 满二叉树的高度大约是 log2n,所以,归并排序递归实现的时间复杂度就是 O(nlogn)。
- 用递归树来求解快速排序的时间复杂度
快排递归树
q(n)
/ \ --------------n
q(n/10) q(9n/10)
/ \ / \ -------n/10+9n/10=n
q(n/10^2) q(9n/10^2) q(9n/10^2) q(9^2n/10^2)
/ \ / \ / \ / \ -----n/10^2+9n/10^2+9n/10^2+9^2n/10^2=n
q(n/10^3) q(9n/10^3) q(9n/10^3) q(9^2n/10^3) q(9n/10^3) q(9^2n/10^3) q(9^2n/10^3) q(9^3n/10^3)
- 快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。
- 我们现在只要求出递归树的高度 h,这个快排过程遍历的数据个数就是 $h∗n$ ,也就是说,时间复杂度就是 $O(h∗n)$。
- 快速排序结束的条件就是待排序的小区间,大小为 1,也就是说叶子节点里的数据规模是 1
- 从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 101,最长的一个路径每次都乘以 109。
- 从根节点到叶子节点的最短路径是 $log_{10} n$,最长的路径是 $log_{9\over10} n$。
- 遍历数据的个数总和就介于 nlog10n 和 nlog910n 之间。根据复杂度的大 O 表示法,对数复杂度的底数不管是多少,我们统一写成 logn,所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 O(nlogn)。