时间复杂度为O(nlogn)的排序

排序(一)中讲了冒泡排序,插入排序,选择排序。其中插入排序比冒泡排序更常使用的原因是,插入排序只有1次赋值操作,而冒泡排序有3次。它们3种排序的时间复杂度都是O(n²)。接下来会介绍两种时间复杂度为O(nlogn)的排序算法,即归并排序和快速排序。因为时间复杂度的关系,这两种排序算法被广泛用于大规模的数据排序。

归并排序(Merge Sort)

归并排序就是将一个无序数组从中间分成前后两个部分,然后对这两个部分分别进行排序,再将排序好的两个部分合并在一起,这样整个数组就排序完了。下面是排序的过程:

归并排序使用了分治的思想,即分而治之。归并排序使用了递归的代码,要想写出递归,首先先要分析递归的公式,然后找到终止条件。

// 递归公式
merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r))
  
// 终止条件
p >= r 不用再继续分解

解释一下上面的公式,就是假设将p到r的数组进行排序,找到中间值q,分别排序p到q,q+1到r,然后再将结果合并在一起。转换成伪代码如下所示:

// 归并算法,A是需要排序的数组,n表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 递归调用
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r { return }
  
  // 取p到r的中间位置q
  q = (p+r)/2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 将结果合并
  merge(A[p...r], A[p...q], A[q+1...r])
}

我们如何将A[p…q]和A[q+1…r]合并呢?

我们申请一个临时数组 tmp,大小与 A[p…r]相同。我们用两个游标 i 和 j,分别指向 A[p…q]和 A[q+1…r]的第一个元素。比较这两个元素 A[i]和 A[j],如果 A[i]<=A[j],我们就把 A[i]放入到临时数组 tmp,并且 i 后移一位,否则将 A[j]放入到数组 tmp,j 后移一位。继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p…r]中。过程如图所示:

转换成伪代码如下:

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 && j <=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++]
    } else {
      tmp[k++] = A[j++]
    }
  }
  // 判断哪个子数组当中有剩余的数据
  var start := i, end := q
  if j <= r {
    start := j
    end := r
  }
  
  // 将剩余的数据拷贝到tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
  
  // 将tmp拷贝回A[p...r]中
  for i:=0; i <= r-p; i ++ {
    a[p+i] = tmp[i]
  }
}

归并排序的时间复杂度为什么是O(nlogn)?

假设我们对n个元素进行归并排序的时间为T(n),那么分成两个子数组就是T(n/2),merge合并也为n,所以可以写成:

T(1) = C  // n=1时为常量
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(1) = T(n/2^k)
n/2^k = 1
k = log2n
// 将k代入上面的公式
T(n) = Cn + nlog2n
// 用大O标记法
T(n) = nlogn

归并排序之所以没有快速排序应用广泛,是因为归并排序不是原地排序。它所使用了一个tmp空间存储数据。

归并排序的golang实现:

package main

import "fmt"

// 合并 [l,r] 两部分数据,mid 左半部分的终点,mid + 1 是右半部分的起点
func merge(arr []int, l int, mid int, r int) {
   // 因为需要直接修改 arr 数据,这里首先复制 [l,r] 的数据到新的数组中,用于赋值操作 
    temp := make([]int, r-l+1)
    for i := l; i <= r; i++ {
        temp[i-l] = arr[i]
    }
    
   // 指向两部分起点
    left := l
    right := mid + 1

    for i := l; i <= r; i++ {
       // 左边的点超过中点,说明只剩右边的数据
        if left > mid {
            arr[i] = temp[right-l]
            right++
        // 右边的数据超过终点,说明只剩左边的数据
        } else if right > r {
            arr[i] = temp[left-l]
            left++
       // 左边的数据大于右边的数据,选小的数字
        } else if temp[left - l] > temp[right - l] {
            arr[i] = temp[right - l]
            right++
        } else {
            arr[i] = temp[left - l]
            left++
        }
    }
}

func MergeSort(arr []int, l int, r int) {
    if l >= r {
        return
    }
  
    // 递归向下
    mid := (r + l) / 2
    MergeSort(arr, l, mid)
    MergeSort(arr, mid+1, r)
    // 归并向上
    merge(arr, l, mid, r)
}

func main() {
    arr := []int{3, 1, 2, 5, 6, 43, 4}
    MergeSort(arr, 0, len(arr)-1)

    fmt.Println(arr)
}

快速排序(Quick Sort)

乍一看起来,快排和归并是很像的。假如我们还是排序A[p…r],我们选择p到r之间的任意一个数为分区点(pivot)。遍历p到r的数据将小于pivot的数放在pivot左边,大于pivot的数放在pivot右边,将pivot放在中间,这样数组p到r就被分成了3部分。即p到q-1小于pivot, 中间是pivot, 右边是q到r大于pivot。

同样,我们使用递归对左右两边进行排序,直到区间缩小为1,就说明排序完成。递归公式如下:

// 递归公式
quick_sort(p...r) = quick_sort(p...q-1) + quick_sort(q...r)
  
//终止条件
p >= r

换成伪代码就是:

// 快速排序,A是数组,n是数组大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}

quick_sort(A, p, r) {
  if p >= r { return }
  q = partition(A, p, r) // 获得pivot(一般情况下,可以获得p到r之间的最后一个元素)
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q, r)
}

我们申请两个临时数组 X 和 Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r]。

但是这样和归并一样,申请了新的空间去存储数据,并不是原地排序算法,如果要使partition函数不能占用额外的空间,我们就要这样:

partition(A, p ,r) {
  pivot := A[r]
  i := p
    
  for j := p; j <= r-1; j ++ {
    if A[j] < pivot {
      swap(A[i], A[j])
      i := i+1
    }
  }
  
  swap(A[i], A[r])
  retuen 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]的位置。

下面是整个过程:

快速排序的golang实现如下:

package main

import "fmt"

func quickSort(source []int, l, u int) {
    if l < u {
        m := partition(source, l, u)
        quickSort(source, l, m-1)
        quickSort(source, m, u)
    }
}

func partition(source []int, l, u int) int { //划分
    var (
        pivot = source[l]
        left = l
        right = l+1
    )
    for ;right<u; right++ {
        if source[right] <= pivot {
            left++
            source[left], source[right] = source[right], source[left]
        }
    }
    source[l], source[left] = source[left], source[l]
    return left+1
}

func main() {
    s := []int{10, 6, 7, 4, 2, 5}
    quickSort(s, 0, len(s))
    fmt.Println(s)
}

可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。