[算法] 时间复杂度O (nlogn)级排序算法

173 阅读4分钟

希尔排序

希尔排序,又称缩小增量排序。本质上是对插入排序的一种优化,他利用了插入排序的简单,又解决了插入排序每次只交换相邻两个元素的缺点。他的基本思想是:

  • 将待排序数组按照一定的增量分为多个子数组,每组分别进行插入排序。这里按照增量分组指的不是取连续的一段数组,而是每跳跃一定增量取一个值组成一组,一般的初次取序列的一半为增量,以后每次减半,直到增量为1。

  • 逐渐缩小增量进行下一轮排序

  • 最后一轮时,取增量为 1,也就相当于直接使用插入排序。但这时经过前面的「宏观调控」,数组已经基本有序了,所以此时的插入排序只需进行少量交换便可完成

举个例子,对数组[84, 83, 88, 87, 61, 50, 70, 60, 80, 99]进行希尔排序的过程如下:

  • 第一遍(5 增量排序):按照增量 5 分割子数组,共分成五组,分别是 [84,50],[83,70],[88,60],[87,80],[61,99]。对它们进行插入排序,排序后它们分别变成: [50, 84], [70, 83], [60, 88], [80, 87], [61, 99],此时整个数组变成 [50,70,60,80,61,84,83,88,87,99]

  • 第二遍(2 增量排序):按照增量 2 分割子数组,共分成两组,分别是 [50, 60, 61, 83, 87], [70, 80, 84, 88, 99]。对他们进行插入排序,排序后它们分别变成: [50, 60, 61, 83, 87], [70, 80, 84, 88, 99],此时整个数组变成 [50,70,60,80,61,84,83,88,87,99]。这里有一个非常重要的性质:当我们完成 2 增量排序后,这个数组仍然是保持 5 增量有序的。也就是说,更小增量的排序没有把上一步的结果变坏

  • 第三遍(1 增量排序,等于直接插入排序):按照增量 1 分割子数组,分成一组,也就是整个数组。对其进行插入排序,经过前两遍排序,数组已经基本有序了,所以这一步只需经过少量交换即可完成排序。排序后数组变成 [50,60,61,70,80,83,84,87,88,99],整个排序完成。

此时,增量序列为[5,2,1]

动图演示

blob (1).gif

func ShellSort(nums []int) []int {
   //外层步长控制
   for step := len(nums) / 2; step > 0; step /= 2 {
      //从step开始,按照顺序将每个元素一次向前插入自己所在的组,开始插入排序
      for i := step; i < len(nums); i += step {
         //满足条件则插入
         for j := i - step; j >= 0 && nums[j+step] < nums[j]; j -= step {
            nums[j], nums[j+step] = nums[j+step], nums[j]
         }
      }
   }
   return nums
}

快速排序

快速排序算法由 C. A. R. Hoare 在 1960 年提出。它的时间复杂度也是 O(nlogn),但它在时间复杂度为 O(nlogn) 级的几种排序算法中,大多数情况下效率更高,所以快速排序的应用非常广泛。再加上快速排序所采用的分治思想非常实用,使得快速排序深受面试官的青睐

快速排序算法的基本思想是:

  • 从数组中取出一个数,称之为基数(pivot)
  • 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个区域
  • 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成

快速排序我在单独的文章[算法] Golang 快速排序 - 掘金 (juejin.cn)中有详细的讲解,有兴趣的可以去看看,这里我就不多赘述

归并排序

理解归并排序最好的例子就是将两个有序的数组合并成一个有序的数组,我们需要怎么做呢?只要开辟一个长度等于两个数组长度之和的新数组,并使用两个指针来遍历原来的两个数组,不断将较小的数字添加到新数组中,并移动相应的指针即可

//将两个有序数组合并成一个有序数组
func MergeSort(arr1, arr2 []int) []int {
   result := make([]int, len(arr1)+len(arr2))
   index1, index2 := 0, 0
   for index1<len(arr1) && index2<len(arr2) {
      if arr1[index1] <= arr2[index2] {
         result[index1+index2] = arr1[index1]
         index1++
      } else {
         result[index1+index2] = arr2[index2]
         index2++
      }
   }
   //将剩余数字补到结果数组后面
   for index1 < len(arr1) {
      result[index1+index2] = arr1[index1] 
      index1++
   }
   for index2 < len(arr2) {
      result[index1+index2] = arr2[index2]
      index2++
   }
   return result
}

合并有序数组的问题解决了,但我们排序时用的都是无序数组,那么上哪里去找这两个有序的数组呢?

答案是 —— 自己拆分,我们可以把数组不断地拆成两份,直到只剩下一个数字时,这一个数字组成的数组我们就可以认为它是有序的。

然后通过上述合并有序列表的思路,将 1 个数字组成的有序数组合并成一个包含 2 个数字的有序数组,再将 2 个数字组成的有序数组合并成包含 4 个数字的有序数组...直到整个数组排序完成,这就是归并排序(Merge Sort)的思想。

func MergeSort(nums []int) []int {
   if len(nums) < 2 {
      // 分治,两两拆分,一直拆到基础元素才向上递归。
      return nums
   }
   i := len(nums) / 2
   left := MergeSort(nums[0:i])
   // 左侧数据递归拆分
   right := MergeSort(nums[i:])
   // 右侧数据递归拆分
   result := merge(left, right)
   // 排序 & 合并
   return result
}

func merge(left, right []int) []int {
   result := make([]int, 0)
   i, j := 0, 0
   l, r := len(left), len(right)
   for i < l && j < r {
      if left[i] > right[j] {
         result = append(result, right[j])
         j++
      } else {
         result = append(result, left[i])
         i++
      }
   }
   result = append(result, right[j:]...)
   result = append(result, left[i:]...)
   return result
}

堆排序

在学习堆排序之前,我们要先清楚什么是堆? 堆:符合以下两个条件之一的完全二叉树

  • 根节点的值>=子节点的值,这样的堆被称之为最大堆或大顶堆
  • 根节点的值<=子节点的值,这样的堆被称之为最小堆或小顶堆

堆排序过程如下:

  • 用数列构建出一个大顶堆,取出堆顶的数字;
  • 调整剩余的数字,构建出新的大顶堆,再次取出堆顶的数字;
  • 循环往复,完成整个排序。

在介绍堆排序具体实现之前,我们先要了解完全二叉树的几个性质。将根节点的下标视为 0,则完全二叉树有如下性质:

  • 对于完全二叉树中的第 i 个数,它的左子节点下标:left = 2i + 1
  • 对于完全二叉树中的第 i 个数,它的右子节点下标:right = left + 1
  • 对于有 n 个元素的完全二叉树(n≥2)(n≥2),它的最后一个非叶子结点的下标:n/2 - 1
func HeapSort(arr []int) {
   buildHeap(arr)
   for i := len(arr) - 1; i > 0; i-- {
      //将最大值交换到数组最后
      swap(arr, 0, i)
      //调整剩余数组,使其满足大顶堆
      maxHeapify(arr, 0, i)
   }
}

//构建大顶堆
func buildHeap(arr []int) {
   //从最后一个非叶子节点开始调整大顶堆,最后一个非叶子节点的下标就是 len(arr)/2-1
   for i := len(arr)/2 - 1; i >= 0; i-- {
      maxHeapify(arr, i, len(arr))
   }
}

//调整大顶堆
func maxHeapify(arr []int, i int, heapSize int) {
   //左子节点下标
   left := 2*i + 1
   //右子节点下标
   right := left + 1
   //记录根节点,左子树节点,右子树几点最大值下标
   largest := i
   //与左子树比较
   if left < heapSize && arr[left] > arr[largest] {
      largest = left
   }
   if right < heapSize && arr[right] > arr[largest] {
      largest = right
   }
   if largest != i {
      //将最大值交换为根节点
      swap(arr, i, largest)
      maxHeapify(arr, largest, heapSize)
   }
}

func swap(arr []int, i, j int) {
   arr[i], arr[j] = arr[j], arr[i]
}

总结

相同点

  • 平均时间复杂度都在 O(n)到 O(n^2) 之间。

不同点

  • 希尔排序、堆排序、快速排序是不稳定的,归并排序是稳定的。
  • 希尔排序的平均复杂度界于 O(n) 到 O(n^2) 之间,普遍认为它最好的时间复杂度为 O(n^{1.3}),希尔排序的空间复杂度为 O(1);
  • 堆排序的时间复杂度为 O(n\log n),空间复杂度为 O(1),
  • 快速排序的平均时间复杂度为 O(nlog⁡n),平均空间复杂度为O(log n);
  • 归并排序的时间复杂度是 O(n log⁡n),空间复杂度是 O(n)。