本节介绍两种时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的排序算法,归并排序和快速排序。这两种排序都是利用了分治思想,使用递归方式来实现的。通过分治思想,将待排序数组进行分区,大事化小来降低时间复杂度,这种思想可以会在编程中经常使用到。
归并排序的核心思想:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
过程如下图所示:
递推公式:merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:p >= r 不用再继续分解
merge_sort伪代码如下:
// 归并排序算法, A 是数组,n 表示数组大小
merge_sort(A, n) {
merge_sort_c(A, 0, n-1)
}
// 递归调用函数
merge_sort_c(A, p, r) {
// 递归终止条件
if p >= r then return
// 取 p 到 r 之间的中间位置 q
q = (p+r) / 2
// 分治递归
merge_sort_c(A, p, q)
merge_sort_c(A, q+1, r)
// 将 A[p...q] 和 A[q+1...r] 合并为 A[p...r]
merge(A[p...r], A[p...q], A[q+1...r])
}
merge函数伪代码如下:
merge(A[p...r], A[p...q], A[q+1...r]) {
var i := p,j := q+1,k := 0 // 初始化变量 i, j, k
var tmp := new array[0...r-p] // 申请一个大小跟 A[p...r] 一样的临时数组
while i<=q AND j<=r do {
if A[i] <= A[j] {
tmp[k++] = A[i++] // i++ 等于 i:=i+1
} else {
tmp[k++] = A[j++]
}
}
// 判断哪个子数组中有剩余的数据
var start := i,end := q
if j<=r then start := j, end:=r
// 将剩余的数据拷贝到临时数组 tmp
while start <= end do {
tmp[k++] = A[start++]
}
// 将 tmp 中的数组拷贝回 A[p...r]
for i:=0 to r-p do {
A[p+i] = tmp[i]
}
}
归并排序稳不稳定关键要看merge()函数,也就是两个子数组合并成一个有序数组的那部分代码。在合并的过程中,如果 A[p…q] 和 A[q+1…r] 之间有值相同的元素,那我们就可以像伪代码中那样,先把 A[p…q] 中的元素放入tmp数组,这样 就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一种稳定排序算法。
如何分析递归代码的时间复杂度公式?
递归的适用场景是一个问题a可以分解为子问题b、c,若定义求解问题a的时间是 T ( a ) T(a) T(a),则求解问题b、c的时间分别是 T ( b ) T(b) T(b)和 T ( c ) T(c) T(c),那就可以得到这样的递推公式:
T ( a ) = T ( b ) + T ( c ) + K T(a) = T(b) + T(c) + K T(a)=T(b)+T(c)+K
其中,K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。
套用这个公式,那么归并排序的时间复杂度就可以表示为:
T ( n ) = 2 ∗ T ( n / 2 ) + n T(n) = 2*T(n/2) + n T(n)=2∗T(n/2)+n
= 2 ∗ ( 2 ∗ T ( n / 4 ) + n / 2 ) + n = 4 ∗ T ( n / 4 ) + 2 ∗ n = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 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 = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n =4∗(2∗T(n/8)+n/4)+2∗n=8∗T(n/8)+3∗n
. . . . . . ...... ......
= 2 k ∗ T ( n / 2 k ) + k ∗ n = 2^k * T(n/2^k) + k * n =2k∗T(n/2k)+k∗n
. . . . . . ...... ......
当$T(n/2^k)=T(1) $时,也就是 n / 2 k = 1 n/2^k=1 n/2k=1,得到 k = l o g 2 n k=log2n k=log2n。将k带入上面的公式就得到
T ( n ) = C n + n l o g 2 n T(n)=Cn+nlog2n T(n)=Cn+nlog2n
如用大O表示法, T ( n ) T(n) T(n)就等于 O ( n l o g n ) O(nlogn) O(nlogn),所以归并排序的是复杂度时间复杂度就是 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
quick_sort()伪代码:
// 快速排序,A 是数组,n 表示数组的大小
quick_sort(A, n) {
quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r 为下标
quick_sort_c(A, p, r) {
if p >= r then return
q = partition(A, p, r) // 获取分区点
quick_sort_c(A, p, q-1)
quick_sort_c(A, q+1, r)
}
partition()伪代码:
partition函数用来完成上述分区过程,但是如果简单采用临时数组方式保存小于pivot和大于pivot数据,然后合并入数组,这样虽然简单,但是会占用额外内存空间。为了保证快速排序为原地排序算法,采用分区函数方式如下:
partition(A, p, r) {
pivot := A[r]
i := p
for j := p to r-1 do {
if A[j] < pivot {
swap A[i] with A[j]
i := i+1
}
}
swap A[i] with A[r]
return i
这里的处理有点类似选择排序,通过游标 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 ) O(1) O(1)的时间完成插入操作。
过程如下图:
因为分区过程中涉及交换操作,如果数组中有两个8,其中一个是pivot,经过分区处理后顺序就颠倒了,所以快速排序是不稳定的排序算法。比如数组[1,2,3,9,8,11,8],取后面的8作为pivot,那么分区后就会将后面的8与9进行交换。
最好时间复杂度: 如果每次分区操作都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并的相同。因此时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。
最坏时间复杂度: 如果分区极不均衡,每次pivot都是最大或者最小值,则导致算法退化为时间复杂度 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 ( n l o g n ) O(nlogn) O(nlogn)。
由于partition()函数的特殊分区思路,快排是一种原地排序算法,空间复杂度是O(1)。
归并和快排都是分治思想,递推公式和递归代码也非常相似,区别在于:
O(n)时间复杂度内求无序数组中第K大元素? 比如[4,2,5,12,3],第3大元素是4。
step1:选择数组区间A[0…n-1]的最后一个元素作为pivot,对数组A[0…n-1]进行原地分区,分成了3部分,A[0…p-1]、A[p]、A[p+1…n-1]。
step2: 如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1…n-1]区间。如果K
step3:递归上述操作,直到找到第k大元素。
第一次分区查找,需要对大小为n的数组进行分区操作,需要遍历n个元素。
第二次分区查找,我们需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。
依次类推,分区遍历元素的个数分别为n、n/2、n/4、n/8、n/16…直到区间缩小为1。
如果把每次分区遍历的元素个数累加起来,就是等比数列求和,结果为2n-1。所以时间复杂度为O(n)。
有10个访问日志文件,每个日志文件大小约为300MB,每个文件里的日志都是按照时间戳从小到大排序的。现在需要将这10个较小的日志文件合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述任务的机器内存只有1GB,你有什么好的解决思路能快速地将这10个日志文件合并?
解题思路:先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据。
以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存。