排序算法
排序
我们通常所说的排序算法往往指的是内部排序算法,即数据记录在内存中进行排序。 排序算法大体可分为两种: 一种是比较排序,时间复杂度O(nlogn) ~ O(n^2),主要有:冒泡排序,选择排序,插入排序,归并排序,堆排序,快速排序等。 另一种是非比较排序,时间复杂度可以达到O(n),主要有:计数排序,基数排序,桶排序等。
稳定性 稳定性的简单形式化定义为:如果Ai = Aj,排序前Ai在Aj之前,排序后Ai还在Aj之前,则称这种排序算法是稳定的。通俗地讲就是保证排序前后两个相等的数的相对顺序不变。 需要注意的是,排序算法是否为稳定的是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。 例如,对于冒泡排序,原本是稳定的排序算法,如果将记录交换的条件改成A[i] >= A[i + 1],则两个相等的记录就会交换位置,从而变成不稳定的排序算法。
冒泡排序
它重复地走访过要排序的元素,依次比较相邻两个元素,如果他们的顺序错误就把他们调换过来,直到没有元素再需要交换,排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端。 冒泡排序算法的运作如下:
- 比较相邻的元素,如果前一个比后一个大,就把它们两个调换位置。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- 如果能在内部循环第一次运行时,使用一个旗标来表示有无需要交换的可能,可以把最优时间复杂度降低到O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
func bubbleSort(arr []int) {
for i := 0; i < len(arr); i++ {
for j := 0; j < len(arr)-1-i; j++ {
if arr[j] > arr[j+1] {
arr[j], arr[j+1] = arr[j+1], arr[j]
}
}
}
}
冒泡改进(鸡尾酒排序)
鸡尾酒排序,也叫定向冒泡排序,是冒泡排序的一种改进。此算法与冒泡排序的不同处在于从低到高然后从高到低,而冒泡排序则仅从低到高去比较序列里的每个元素。他可以得到比冒泡排序稍微好一点的效能。
func bubbleSort2(arr []int) {
l, r := 0, len(arr)-1
for ; l < r; {
for i := l; i < r; i++ {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}
r--
for i := r; i > l; i-- {
if arr[i] < arr[i-1] {
arr[i], arr[i-1] = arr[i-1], arr[i]
}
}
l++
}
}
快速选择排序
它的工作原理很容易理解:初始时在序列中找到最小(大)元素,放到序列的起始位置作为已排序序列;然后,再从剩余未排序元素中继续寻找最小(大)元素,放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- O(n^2)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
func selectionSort(arr []int) {
for i := 0; i < len(arr); i++ {
minValeIndex := i
for j := i + 1; j < len(arr); j++ {
if arr[j] < arr[minValeIndex] {
minValeIndex = j
}
}
arr[i], arr[minValeIndex] = arr[minValeIndex], arr[i]
}
}
选择排序是不稳定的排序算法,不稳定发生在最小元素与A[i]交换的时刻。
比如序列:{ 5, 8, 5, 2, 9 },一次选择的最小元素是2,然后把2和第一个5进行交换,从而改变了两个元素5的相对次序。
插入排序
插入排序是一种简单直观的排序算法。它的工作原理非常类似于我们抓扑克牌。 对于未排序数据(右手抓到的牌),在已排序序列(左手已经排好序的手牌)中从后向前扫描,找到相应位置并插入。 插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。 具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后
- 重复步骤2~5
// 分类 ------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 最坏情况为输入序列是降序排列的,此时时间复杂度O(n^2)
// 最优时间复杂度 ---- 最好情况为输入序列是升序排列的,此时时间复杂度O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
func insertSort(arr []int) {
for i := 1; i < len(arr); i++ {
toInsertValue := arr[i]
var j int
for j = i - 1; j >= 0; j-- {
if arr[j] <= toInsertValue {
break
}
arr[j+1] = arr[j]
}
arr[j+1] = toInsertValue
}
}
插入排序升级(二分查找插入)
对于插入排序,如果比较操作的代价比交换操作大的话,可以采用二分查找法来减少比较操作的次数,我们称为二分插入排序,换句话说:原来从右往左老老实实的找,现在通过二分查找来找到要插入的位置。
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
func insertSort2(arr []int) {
for i := 1; i < len(arr); i++ {
toInsertValue := arr[i]
l, r := 0, i-1
for ; l <= r; {
mid := l + (r-l)/2
if arr[mid] < toInsertValue {
l = mid + 1
} else {
r = mid - 1
}
}
var j int
for j = i - 1; j >= l; j-- {
arr[j+1] = arr[j]
}
arr[j+1] = toInsertValue
}
}
插入排序升级(希尔排序)
回想一下直接插入排序过程,排序过程中,我们可以设置一条线,左边是排好序的,右边则是一个一个等待排序, 如果最小的那个值在最右边,那么排这个最小值的时候,需要将所有元素向右边移动一位。 是否能够减少这样的移位呢? 我们不希望它是一步一步的移动,而是大步大步的移动。刚开始的时候步长比较大,逐渐缩小至1(这时就是普通的插入排序了)。最后还是插入排序,那快在哪里?意义何在?其实最后一遍已经基本有序了,几乎就是O(n),别忘了,插入排序最适合的场景就是排基本有序的数据。
- 分组,步长incre初始化为len/2(当然也可以设为其他)
- 从incre处开始依次进行插入
- 和普通的插入排序一样,把插入的值一一和左边已经排序好的进行比较,只不过步长不再为1,而是incre
- 一遍排完,缩小步长incre=incre/2,循环进行步骤1-3,知道incre=1
func insertSort3(arr []int) {
for incre := len(arr) / 2; incre >= 1; incre = incre / 2 {
for i := incre; i < len(arr); i++ {
toInsertValue := arr[i]
var j int
for j = i - incre; j >= 0; j -= incre {
if arr[j] <= toInsertValue {
break
}
arr[j+incre] = arr[j]
}
arr[j+incre] = toInsertValue
}
}
}
归并排序
使用分而治之的思想,先把数组分成两个子数组,把两个子数组分别排序后再整合成一个有序的数组。 把两个有序的数组合成一个有序的数组:
- 两个指针了l,r分别指向两个有序的子数组头
- 分别对比指针所对应的值,小的那个存到新数组,指针向后移一位,直到把任意一个子数组比空
- 把剩余的那个子数组元素追加到合成的新数组后面 我们可以利用递归,先把两个子数组排序,再合并
func guibingSort(arr []int, l, r int) {
if l >= r {
return
}
mid := l + (r-l)/2
guibingSort(arr, l, mid)
guibingSort(arr, mid+1, r)
merge(arr, l, r)
}
func merge(arr []int, l, r int) {
if l >= r {
return
}
len := r - l + 1
newArr := make([]int, len)
mid := l + (r-l)/2
i, j := l, mid+1
index := 0
for ; i <= mid && j <= r; index++ {
if arr[i] < arr[j] {
newArr[index] = arr[i]
i++
} else {
newArr[index] = arr[j]
j++
}
}
if i <= mid {//这里注意是<=
for ; i <= mid; i++ {
newArr[index] = arr[i]
index++
}
}
if j <= r {//这里注意是<=
for ; j <= r; j++ {
newArr[index] = arr[j]
index++
}
}
for index := 0; l <= r; l++ {
arr[l] = newArr[index]
index++
}
}
快速排序
快速排序使用分治策略(Divide and Conquer),是冒泡排序的升级版,步骤为:
- 从序列中挑出一个元素,作为"基准"(pivot).
- 把所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准的后面(相同的数可以到任一边),这个称为分区(partition)操作。
- 对每个分区递归地进行步骤1~2,递归的结束条件是序列的大小是0或1,这时整体已经被排好序了。
这里实例采用挖坑法,把第一个元素拿出来作为基准保存起来,那么第一位置就是坑了,从右边开始找到比k小的,放入坑中,那么右边的位置就成了新的坑,再从左边开始找比k大的元素,放入右边的坑中,一遍下来,把k放到最后的坑中,那么就达到了左边比k都笑,右边的都比k大。
//先进行一遍排序,第一个做基准,左边的比基准小,右边的比基准大;进行一遍排序后对剩余的进行分组再进行重复
func QuickSort(arr []int, start, end int) {
if start >= end {
return
}
l, r := start, end //[l,r]为待排序的,l左边和r右边的都是已经排好的
k := arr[l] //基准,那么左边第一个就是坑,任何比k小的都可以放进去
for ; r > l; {
//从右边往左,找到比基准小的,填到坑里,那么右边的就变成了新的坑,任何比k大的都可以放进去
for ; r > l && arr[r] >= k; r-- {
}
arr[l] = arr[r]
//从左往右,找到比基准大的
for ; r > l && arr[l] <= k; l++ {
}
arr[r] = arr[l]
}
//当for循环结束,说明l和r相遇了,那么l=r处就是最后的坑,直接把k放进去即可,这样一遍下来,k的左边比k小,右边比k大
arr[l]=k
//剩余的分两组,进行重复操作
QuickSort(arr, start, l-1)
QuickSort(arr, l+1, end)
}
快速排序是不稳定的排序算法,不稳定发生在基准元素与A[tail+1]交换的时刻。 比如序列:{ 1, 3, 4, 2, 8, 9, 8, 7, 5 },基准元素是5,一次划分操作后5要和第一个8进行交换,从而改变了两个元素8的相对次序。
三路快排
三路快排是快速排序的升级版,解决了在一般快速排序中,重复的元素会放到下个分区间中再进行排序。适用于有大量重复元素的排序。 这里我们通过一次排序分为左边小于k的,中间等于k的,右边大于k的。然后再对两头的两个小区间再进行排序即可。 通过维持三个指针来控制[left, lt )小于主元(pivot),[lt, i)等于主元,[i, gt]未知,(gt, right]大于主元。 一开始,lt指向主元的位置left,gt指向right,而i从left右边接下来的第一个索引开始遍历,每当遇到一个数,就判断它与主元之间的大小关系,有三种情况:
- 小于主元就把这个数与lt指向的数交换,然后lt,i都自增1,然后继续遍历
- 大于主元就把这个数与gt指向的数交换,gt自减1,此时i还得不能自增,因为它不知道gt用一个什么样的元素跟它交换,所以留到下一次循环判断交换过来的这个元素的去留
- 等于主元就不用跟谁进行交换,直接自增1就可以
func QuickSort3(arr []int, start, end int) {
if start >= end {
return
}
l, r := start, end //[start,l)为小于基准,(r,end]大于基准。都是已经排好的
i := l + 1 //[l,i)等于基准,[i,r]等待排序
k := arr[l]
for ; r > l && i <= end; {
if arr[i]<k{
arr[l],arr[i]=arr[i],arr[l]
l++
i++
}else if arr[i]>k{
arr[r],arr[i]=arr[i],arr[r]
r--
}else {
i++
}
}
QuickSort3(arr, start, l-1)
QuickSort3(arr, r+1, end)
}
堆排序
堆排序是指利用堆这种数据结构所设计的一种选择排序算法。堆是一种近似完全二叉树的结构(通常堆是通过一维数组来实现的),并满足性质:以最大堆(也叫大根堆、大顶堆)为例,其中父结点的值总是大于它的孩子节点。
计数排序
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。 比如:一个数组中公共3种值:1,2,3。我们只要统计出1个个数,2的个数,3的个数然后反向填充进数组。 算法描述
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
桶排序
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。 算法描述
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来
算法选择
各种排序方法比较: 简单排序中直接插入最好,快速排序最快。 当文件为正序时,直接插入和冒泡均最佳。 若要求排序稳定,则可选用归并排序。 当记录的规模较大时,为避免耗费大量的时间去移动记录,可以用链表作为存储结构。譬如插入排序、归并排序、基数排序都易于在链表上实现,使之减少记录的移动次数。但有的排序方法,如快速排序和堆排序,在链表上却难于实现
标准库排序所选用算法
java
Java系统提供的Arrays.sort函数。对于基础类型,底层使用快速排序。对于非基础类型,底层使用归并排序。请问是为什么?
答:这是考虑到排序算法的稳定性。对于基础类型,相同值是无差别的,排序前后相同值的相对位置并不重要,所以选择更为高效的快速排序,尽管它是不稳定的排序算法;而对于非基础类型,排序前后相等实例的相对位置不宜改变,所以选择稳定的归并排序。
go
go语言中对基本类型排序是sort.Sort(data Interface)方法,自定义类型用的是sort.Slice方法,他们是“快排”、“堆排序”和“希尔排序的组合使用。是不安全的,具体如下: 总体是快排把数组不断的分为子数组,但是子数组内使用的排序就可能会变了:当子数组是元素<=12时用希尔排序。当元素个数大于12时又要通过n获取一个阈值(2*ceil(lg(n+1))),如果阈值==0则使用堆排序,否则继续使用快排。
要想对自定义的类型稳定的排序可以用sort.SliceStable方法。它用的是插入排序。