在所有排序算法中,平均时间复杂度为O(nlogn)的算法一共有三种:快速排序、归并排序、堆排序。
排序算法
最好情况时间复杂度
平均情况时间复杂度
最坏情况时间复杂度
空间复杂度
稳定性
选择排序
O(n²)
O(n²)
O(n²)
O(1)
不稳定
插入排序
O(n)
O(n²)
O(n²)
O(1)
稳定
冒泡排序
O(n)
O(n²)
O(n²)
O(1)
稳定
归并排序
O(n log n)
O(n log n)
O(n log n)
O(n)
稳定
快速排序
O(n log n)
O(n log n)
O(n²)
O(log n)
不稳定
堆排序
O(n log n)
O(n log n)
O(n log n)
O(1)
不稳定
希尔排序
O(n)
O(n log n)
O(n log n)
O(1)
不稳定
基数排序
O(nk)
O(nk)
O(nk)
O(n + d)
稳定
注:
稳定性 :如果两个元素相等,排序后它们的相对顺序不变,则称该排序算法是稳定的。
希尔排序的时间复杂度 和增量选择有关,如果增量序列是1,2,4,8,16,32,64,128,256,512,1024,则时间复杂度为O(n)。
基数排序的时间复杂度 为O(nk),其中k为元素的位数。
出于好奇我打算自己实现这三种排序,并对比它们的性能。
一、快速排序 步骤详解:
选择基准值:一般选择第一个元素作为基准值。
分区操作:将数组分成两个部分,左边的元素都小于基准值,右边的元素都大于基准值。
递归排序:对基准值左右子数组进行递归执行以上两步,直到左右子数组长度为0,数组便有序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func partition (arr []int , left, right int ) int { pivot := arr[right] i := left - 1 for j := left; j < right; j++ { if arr[j] < pivot { i++ arr[i], arr[j] = arr[j], arr[i] } } i++ arr[right], arr[i] = arr[i], arr[right] return i } func QuickSort (arr []int , left, right int ) { if left < right { mid := partition(arr, left, right) QuickSort(arr, left, mid-1 ) QuickSort(arr, mid+1 , right) } }
二、归并排序 步骤详解:
递归分割:如果数组长度大于1,则递归地将数组分成两个子数组。
排序:直到子数组长度为1,然后将两个子数组进行排序成一个新的有序数组。
合并:将两个已经有序的子数组合并成一个新的有序数组并返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func merge (left, right []int ) []int { result := make ([]int , 0 , len (left)+len (right)) i, j := 0 , 0 for i < len (left) && j < len (right) { if left[i] <= right[j] { result = append (result, left[i]) i++ } else { result = append (result, right[j]) j++ } } result = append (result, left[i:]...) result = append (result, right[j:]...) return result } func MergeSort (arr []int ) []int { if len (arr) <= 1 { return arr } mid := len (arr) / 2 left := MergeSort(arr[:mid]) right := MergeSort(arr[mid:]) return merge(left, right) }
三、堆排序 堆的概念:
堆(heap)是一种满足特定条件的完全二叉树,一般使用数组作为存储方式,对于数组中每个下标 i(从0开始),左孩子为 2i+1,右孩子为 2 i+2,堆分为两种:
小顶堆(min heap):任意节点的值 ≤ 其子节点的值,堆顶(根节点)的元素最小。
大顶堆(max heap):任意节点的值 ≥ 其子节点的值,堆顶(根节点)的元素最小。
步骤详解:
构建堆:从最后一个非叶子节点(下标为 n/2-1)开始,从下往上,依次将每个节点的值赋值为其左右子节点值中的最大值,直到根节点,使整个数组形成一个最大堆。
从数组中最后一个元素开始,和根节点交换位置,此时该元素已经处于排好序的位置,堆长度减1,重新对堆顶调整,使剩余的数组仍为最大堆。
重复步骤2,直到堆长度为1。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func HeapSort (arr []int ) { n := len (arr) for i := n/2 - 1 ; i >= 0 ; i-- { heapify(arr, n, i) } for i := n - 1 ; i >= 0 ; i-- { arr[0 ], arr[i] = arr[i], arr[0 ] heapify(arr, i, 0 ) } } func heapify (arr []int , n, i int ) { largest := i left := i*2 + 1 right := i*2 + 2 if left < n && arr[left] > arr[largest] { largest = left } if right < n && arr[right] > arr[largest] { largest = right } if largest != i { arr[i], arr[largest] = arr[largest], arr[i] heapify(arr, n, largest) } }
四、性能测试对比 在下文中通过生成一个长度为 200000 的随机数数组,对我们自己实现的三种排序算法进行性能测试,并对比它们的性能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 快速排序: goos: windows goarch: amd64 pkg: demo2/algorithms cpu: AMD Ryzen 7 5800H with Radeon Graphics BenchmarkQuickSort-16 90 13047283 ns/op 1605665 B/op 1 allocs/op BenchmarkQuickSort-16 80 12976745 ns/op 1605635 B/op 1 allocs/op BenchmarkQuickSort-16 100 13071114 ns/op 1605633 B/op 1 allocs/op PASS 归并排序: goos: windows goarch: amd64 pkg: demo2/algorithms cpu: AMD Ryzen 7 5800H with Radeon Graphics BenchmarkMergeSort-16 45 28383313 ns/op 31327008 B/op 200002 allocs/op BenchmarkMergeSort-16 49 30430869 ns/op 31326802 B/op 200000 allocs/op BenchmarkMergeSort-16 39 27267669 ns/op 31326759 B/op 200000 allocs/op PASS 堆排序: goos: windows goarch: amd64 pkg: demo2/algorithms cpu: AMD Ryzen 7 5800H with Radeon Graphics BenchmarkHeapSort-16 49 23543824 ns/op 1605634 B/op 1 allocs/op BenchmarkHeapSort-16 50 23547334 ns/op 1605633 B/op 1 allocs/op BenchmarkHeapSort-16 50 23595582 ns/op 1605687 B/op 1 allocs/op PASS
从结果可以看出,快速排序的内存开销和时间开销都比归并排序和堆排序要小
但是! 在上文中我使用的堆排序函数中,堆调整的方式是递归的 每次递归都是一个函数调用,这意味着需要分配新的栈帧来存储局部变量、参数和其他状态信息。此外还需要进行参数传递、返回地址的保存以及函数体的执行,这增加了额外的时间开销。因此我们将该函数改为迭代方式,使其在一个for循环内完成,以提高性能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func heapify (arr []int , n, i int ) { largest := i left := i*2 + 1 right := i*2 + 2 if left < n && arr[left] > arr[largest] { largest = left } if right < n && arr[right] > arr[largest] { largest = right } if largest != i { arr[i], arr[largest] = arr[largest], arr[i] heapify(arr, n, largest) } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func heapify (arr []int , n, i int ) { for { largest := i left := i*2 + 1 right := i*2 + 2 if left < n && arr[left] > arr[largest] { largest = left } if right < n && arr[right] > arr[largest] { largest = right } if largest != i { arr[i], arr[largest] = arr[largest], arr[i] i = largest } else { break } } }
性能对比
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 递归堆调整: goos: windows goarch: amd64 pkg: demo2/algorithms cpu: AMD Ryzen 7 5800H with Radeon Graphics BenchmarkHeapSort-16 49 23543824 ns/op 1605634 B/op 1 allocs/op BenchmarkHeapSort-16 50 23547334 ns/op 1605633 B/op 1 allocs/op BenchmarkHeapSort-16 50 23595582 ns/op 1605687 B/op 1 allocs/op PASS 迭代堆调整: goos: windows goarch: amd64 pkg: demo2/algorithms cpu: AMD Ryzen 7 5800H with Radeon Graphics BenchmarkHeapSort-16 58 20431231 ns/op 1605637 B/op 1 allocs/op BenchmarkHeapSort-16 58 20289929 ns/op 1605632 B/op 1 allocs/op BenchmarkHeapSort-16 58 20366748 ns/op 1605633 B/op 1 allocs/op PASS
可以看出,迭代堆调整的性能比递归堆调整的性能要好一些。