Go语言学习笔记【15】 排序算法之堆排序、桶排序、基数排序

这篇博客详细介绍了三种排序算法:堆排序、桶排序和基数排序。堆排序通过构建小顶堆进行排序,时间复杂度为O(Nlog2N),非稳定排序。桶排序利用元素分布到有限数量的桶内,再对每个桶排序,时间复杂度取决于桶内排序算法。基数排序则通过对数字逐位排序,保持稳定性,时间复杂度为O(N*logRN)。

【声明】

非完全原创,部分内容来自于学习其他人的理论。如果有侵权,请联系我,可以立即删除掉。

一、堆排序

1、方法和复杂度

1.1、核心思想

  • 利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点
  • 根据这个特点,先将待排序的序列构造成一个小顶堆,则堆顶就是序列中最小的元素
  • 取出堆顶元素,用最后一个元素填充堆顶,然后重新构建小顶堆
  • 重复上面过程,取出来的元素就是一个从小到大的有序序列

1.2、主要方法

1.2.1、筛选法

(1)构建小顶堆:按照完全二叉树的规则,将元素依次填充到树上的结点;然后根据下往上,从右往左得顺序,根据小顶堆的规则比较子结点和父结点进行调整,直到整棵树都满足小顶堆的要求
(2)取出堆顶元素,剩余元素重新构建小顶堆
(3)重复(2),直到序列只剩下一个元素,则取出来的序列就是有序序列

1.2.2、插入法

主要区别在于初始创建堆的过程。筛选法是先用元素依次填满二叉树,然后根据规则进行调整;而插入法:

  • 假设二叉树是空的,先将第一个元素作为根结点
  • 从后面取一个待插入的结点,将被插入元素与其父节点进行比较,若大于父节点,则与父节点交换交换后继续向上检查,直到根结点
  • 重复上一个步骤,直到所有元素插入到完全二叉树中并调整成小顶堆

1.3、特点及构建堆的分析

(1)特点:
  • 对于小顶堆有:arr[i] <= arr[2i + 1] && arr[i] <= arr[2i + 2]
  • 对于大顶堆有:arr[i] >= arr[2i + 1] && arr[i] >= arr[2i + 2]
(2)分析

注意:数组最后一个元素是一个叶子结点,因此以此为突破口,可以查找其父结点。
当完全二叉树除了叶子结点之外,其他结点均有左右子结点时,树的结点总个数为奇数,以此分析:

  • 若数组长度为偶数,则最后元素是左叶子结点,得:2*dad + 1 == len(arr)-1 ==> dad = len(arr)/2 -1
  • 若数组长度为奇数,则最后元素是右叶子结点,得:2*dad + 2 == len(arr)-1 ==> dad = [len(arr)-1] /2 - 1

因此,最后一个元素的父结点索引是len(arr)/2-1,而该结点也是最后一个非叶子结点

1.3、稳定性

该算法不能保证相等元素的相对顺序,属于非稳定排序。原因是,父结点和子节点进行交换时,可能会将后面的元素交换到前面去,从而破坏稳定性

举例:假如数组{5, 7, 6, 4, 3, 4}。则小顶堆构建时,第一次交换就破坏了稳定性。
构建小顶堆

1.4、时间复杂度

具体分析见:堆排序算法描述及时间复杂度分析

1.4.1、初始化堆的时间复杂度

最好情况:当按照顺序排列出的完全二叉树完全满足堆的规则时,父结点只需要和左右子结点比较大小,不需要进行元素交换,其时间复杂度为 O ( N ) O(N) O(N)
最坏情况:当每次调整堆进行检查时,父节点每次都需要和子结点交换位置,且子节点也需要不停往下检查换位,直到叶子结点。设当前结点的深度为k,则该层的结点最多有 2 k − 1 2^{k-1} 2k1。设堆的高度为 h = l o g 2 N h = log_2N h=log2N,则当前结点最坏情况需要移动到最后一层,则移动的次数为h - k。而每一层需要和左、右节点比较,因此每层需要比较2次。那么最坏情况下的时间复杂度为:

T [ N ] = T[N] = T[N]= ∑ k = 1 h − 1 2 k − 1 ∗ 2 ∗ ( h − k ) \sum_{k=1}^{h-1}2^{k-1}*2*(h-k) k=1h12k12(hk)= ∑ k = 1 h − 1 2 k ( h − k ) \sum_{k=1}^{h-1}2^k(h-k) k=1h12k(hk)
= ( h − 1 ) 2 1 + ( h − 2 ) 2 2 + ( h − 3 ) 2 3 + . . + 2 h − 1 ∗ 1 = (h-1)2^1+(h-2)2^2+(h-3)2^3+..+2^{h-1}*1 =(h1)21+(h2)22+(h3)23+..+2h11
由于 2 T [ N ] = ( h − 1 ) 2 2 + ( h − 2 ) 2 3 + ( h − 3 ) 2 4 + . . + 2 h ∗ 1 2T[N]=(h-1)2^2+(h-2)2^3+(h-3)2^4+..+2^h*1 2T[N]=(h1)22+(h2)23+(h3)24+..+2h1, 因此
T [ N ] = 2 0 + 2 1 + 2 2 + 2 3 + . . + 2 h − 2 ∗ h − 1 T[N]=2^0+2^1+2^2+2^3+..+2^h-2*h-1 T[N]=20+21+22+23+..+2h2h1
= 2 h + 1 − 2 h − 2 =2^{h+1}-2h-2 =2h+12h2
= 2 N − 2 l o g 2 N − 2 ∈ O ( N ) =2N-2log_2N-2\in O(N) =2N2log2N2O(N)

综上来看,初始化堆的时间复杂度为 O ( N ) O(N) O(N)

1.4.2、取元素堆重新排序的时间复杂度

初始化堆之后,每次取元素之后,堆中的元素都较少1个,且需要重新调整。而每次调整堆时,最坏情况下需要从根元素一直调整到最后一个元素,其时间复杂度为 O ( l o g 2 ( h − k ) ) O(log_2(h-k)) O(log2(hk))。整个排序过程中,一共需要调整N-1次,总时间复杂度为:
T [ N ] = l o g 2 2 + l o g 2 3 + l o g 2 4 + . . . + l o g 2 N = l o g 2 ( N ! ) ∈ O ( N l o g 2 N ) T[N]=log_22+log_23+log_24+...+log_2N=log_2(N!)\in O(Nlog_2N) T[N]=log22+log23+log24+...+log2N=log2(N!)O(Nlog2N)

1.4.3、总时间复杂度

T [ N ] ∈ O ( N ) + O ( N l o g 2 N ) ∈ O ( N l o g 2 N ) T[N] \in O(N)+O(Nlog_2N) \in O(Nlog_2N) T[N]O(N)+O(Nlog2N)O(Nlog2N)

1.5、空间复杂度

无论是初始化堆还是取元素堆重新排序的过程,都只有常数个辅助变量,且函数执行完成之后就释放内存了,因此总的空间复杂度为 O ( 1 ) O(1) O(1)

2、排序的过程

3、算法实现

package main

import "fmt"

const size = 10

var (
	cnt     = 0
	swapcnt = 0
)

func AdjustHeap(arr []int, root int, is_min bool) {
	left, right := 2*root+1, 2*root+2
	//小顶堆时,搜索父结点、左右子节点中  值最小的索引
	index := root
	if is_min {
		if left < len(arr) && arr[left] < arr[index] {
			index = left
		}
		if right < len(arr) && arr[right] < arr[index] {
			index = right
		}
	} else {
		if left < len(arr) && arr[left] > arr[index] {
			index = left
		}
		if right < len(arr) && arr[right] > arr[index] {
			index = right
		}
	}
	//如果发生了交换,则必须递归调整  以被交换的子节点为根节点 的堆
	if index != root {
		arr[root], arr[index] = arr[index], arr[root]
		swapcnt++ //记录元素交换的次数
		AdjustHeap(arr, index, is_min)
	}
	//每次调整有3次比较
	cnt += 3
}

func BuildHeap(arr []int, is_min bool) {
	for i := len(arr)/2 - 1; i >= 0; i-- {
		AdjustHeap(arr, i, is_min)
	}
	cnt += len(arr) / 2
}

func MinHeap(arr []int) {
	//先构建堆
	BuildHeap(arr, true)
	fmt.Printf("第[%02d]趟构建堆:  %v\n", 1, arr)
	//取堆顶交换最后一个元素,重构堆(此时待构建堆的数组排除最后一个元素)
	for i := len(arr) - 1; i > 0; i-- {
		arr[i], arr[0] = arr[0], arr[i]
		BuildHeap(arr[:i], true)
		fmt.Printf("第[%02d]趟构建堆:  %v\n", len(arr)-i+1, arr)
		swapcnt++
	}
	cnt += len(arr) - 1
	fmt.Printf("【MinHeap】排序元素比较的次数: %d, 发生元素交换的次数: %d\n", cnt, swapcnt)
}

func MaxHeap(arr []int) {
	//先构建堆
	BuildHeap(arr, false)
	fmt.Printf("第[%02d]趟构建堆:  %v\n", 1, arr)
	//取堆顶交换最后一个元素,重构堆(此时待构建堆的数组排除最后一个元素)
	for i := len(arr) - 1; i > 0; i-- {
		arr[i], arr[0] = arr[0], arr[i]
		BuildHeap(arr[:i], false)
		fmt.Printf("第[%02d]趟构建堆:  %v\n", len(arr)-i+1, arr)
		swapcnt++
	}
	cnt += len(arr) - 1
	fmt.Printf("【MaxHeap】排序元素比较的次数: %d, 发生元素交换的次数: %d\n", cnt, swapcnt)
}

func main() {
	arr := [size]int{5, 8, 9, 7, 0, 2, 3, 6, 7, 6}
	var arr_tmp [size]int
	fmt.Println("原数组序列:  ", arr)

	arr_tmp = arr
	MaxHeap(arr_tmp[0:])
	fmt.Println()

	cnt = 0
	swapcnt = 0
	arr_tmp = arr
	MinHeap(arr_tmp[0:])
}

4、运行结果

原数组序列:   [5 8 9 7 0 2 3 6 7 6][01]趟构建堆:  [9 8 5 7 6 2 3 6 7 0][02]趟构建堆:  [8 7 5 7 6 2 3 6 0 9][03]趟构建堆:  [7 7 5 6 6 2 3 0 8 9][04]趟构建堆:  [7 6 5 0 6 2 3 7 8 9][05]趟构建堆:  [6 6 5 0 3 2 7 7 8 9][06]趟构建堆:  [6 3 5 0 2 6 7 7 8 9][07]趟构建堆:  [5 3 2 0 6 6 7 7 8 9][08]趟构建堆:  [3 0 2 5 6 6 7 7 8 9][09]趟构建堆:  [2 0 3 5 6 6 7 7 8 9][10]趟构建堆:  [0 2 3 5 6 6 7 7 8 9]
【MaxHeap】排序元素比较的次数: 157, 发生元素交换的次数: 25[01]趟构建堆:  [0 5 2 6 6 9 3 7 7 8][02]趟构建堆:  [2 5 3 6 6 9 8 7 7 0][03]趟构建堆:  [3 5 7 6 6 9 8 7 2 0][04]趟构建堆:  [5 6 7 7 6 9 8 3 2 0][05]趟构建堆:  [6 6 7 7 8 9 5 3 2 0][06]趟构建堆:  [6 7 7 9 8 6 5 3 2 0][07]趟构建堆:  [7 8 7 9 6 6 5 3 2 0][08]趟构建堆:  [7 8 9 7 6 6 5 3 2 0][09]趟构建堆:  [8 9 7 7 6 6 5 3 2 0][10]趟构建堆:  [9 8 7 7 6 6 5 3 2 0]
【MinHeap】排序元素比较的次数: 160, 发生元素交换的次数: 26

二、桶排序

1、方法和复杂度

1.1、核心思想

  • 数组分到有限数量的桶里。每个桶再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后依次把各个桶中的记录列出来记得到有序序列
  • 主要思路是将大问题化小。它是是鸽巢排序的一种归纳结果
  • 它不是一种基于比较的排序

1.2、主要方法

假如,数组中的元素分布在[0, 40)之间,那么可以将数组统一为同样的数位长度,数位较短的数前面补零。根据这个规则,可以将数组中的元素分在最高位数字为0~34个桶里。其过程如下:

  • 将数组的元素按照最高位依次放入到不同的桶内
  • 桶内按照大小排序
  • 桶内元素依次输入到数组中
    在这里插入图片描述

1.3、稳定性

由上面的排序过程可以得知,相等的元素会被划分到同一个桶内,而桶内排序选择的算法将直接影响桶排序的稳定性

一般来说,排序算法采用比较快速的算法。如果是快速排序算法,则不稳定;如果是归并排序则稳定

1.4、时间复杂度

桶排序的时间复杂度主要分为三个部分:数组放入桶内的时间O(N)、桶内排序的时间、桶内取出数据到数组的时间O(N)

桶内排序的时间取决于排序算法的时间
(1)假如排序算法的时间复杂度是 O ( N ∗ l o g 2 N ) O(N*log_2N) O(Nlog2N)

  • 在最好情况下,桶的元素是均匀分布的,桶的个数为M,则每个桶内的元素个数为N/M,那么所有桶内排序的总时间为 M ∗ O ( N M l o g 2 N M ) = O ( N ∗ l o g 2 N M ) M*O(\frac {N}{M}log_2{\frac {N}{M}}) = O(N*log_2{\frac {N}{M}}) MO(MNlog2MN)=O(Nlog2MN)。当桶的个数M等于数组的总个数N,即每个桶只有一个数据时,桶排序的总时间等于 O ( N ) O(N) O(N)
  • 在最坏情况下,元素被分到了一个桶里面,所有桶进行排序的总时间为 O ( N ∗ l o g 2 N ) O(N*log_2N) O(Nlog2N),桶排序的总时间复杂度为 O ( N ∗ l o g 2 N ) O(N*log_2N) O(Nlog2N)

(2)假如排序算法的时间复杂度是 O ( N 2 ) O(N^2) O(N2),那么桶排序的总时间复杂度为 O ( N 2 ) O(N^2) O(N2)

1.5、空间复杂度

桶的个数为M、数组元素个数为N,空间复杂度为 O ( M + N ) O(M+N) O(M+N)。当桶的个数越多时,额外消耗的空间就越大

1.6、优缺点

  • 优点:
    桶的元素是均匀分布时,桶排序的时间复杂度为 O ( N ∗ l o g 2 N M ) O(N*log_2{\frac {N}{M}}) O(Nlog2MN)。当桶的个数无限接近于数组元素个数时,其时间复杂度接近于 O ( N ) O(N) O(N)
  • 缺点:
    (1)桶排序算法是以空间换时间的算法,当时间复杂度低的时候,耗费空间较大
    (2)桶排序是基于原始数据的某种规律拟定的算法,当数据需要扩容且不符合原有规律时,则算法需要重写
    (3)桶排序的复杂度是值域相关的。当值域很大并且分布不均匀时,桶排序需要增加轮数,效率随之降低。
    (4)桶排序是非比较排序,有些情况无法使用。比如数组中元素是小数时

2、排序的过程

将数组统一为同样的数位长度,数位较短的数前面补零。数组变为[23, 39, 02, 17, 09, 21]。根据这个规则,可以将数组中的元素分在最高位数字为0~34个桶里,如下图
排序的过程

3、算法实现

package main

import (
	"fmt"
	"sort"
)

const size = 10

func Bucket(arr []int) {
	//将数组装入桶中
	bucket := make(map[int][]int)
	for _, val := range arr {
		bucket[val/10] = append(bucket[val/10], val)
	}
	//各个桶内进行从小到大排序
	for _, val := range bucket {
		sort.Ints(val)
	}
	fmt.Println("桶内排序后: ", bucket)
	//由于map无序,因此需要获取map的key的有序集合
	var keys []int
	for key := range bucket {
		keys = append(keys, key)
	}
	sort.Ints(keys)
	fmt.Println("桶的key排序后: ", keys)
	//桶内元素转入数组中
	index := 0
	for _, key := range keys {
		for _, val := range bucket[key] {
			arr[index] = val
			index++
		}
	}
	fmt.Printf("【Bucket】排序桶的个数为: %d, 最后结果: %v\n", len(bucket), arr)
}

func main() {
	arr := [size]int{23, 56, 98, 73, 28, 51, 90, 47, 9, 1}
	fmt.Println("原数组序列:  ", arr)
	Bucket(arr[0:])
}

4、运行结果

原数组序列:   [23 56 98 73 28 51 90 47 9 1]
桶内排序后:  map[0:[1 9] 2:[23 28] 4:[47] 5:[51 56] 7:[73] 9:[90 98]]
桶的key排序后:  [0 2 4 5 7 9]
【Bucket】排序桶的个数为: 6, 最后结果: [1 9 23 28 47 51 56 73 90 98]

三、基数排序

1、方法和复杂度

1.1、核心思想

透过键值的部份资讯,将要排序的元素分配至某些“桶”中,藉以达到排序的作用。在某些时候,基数排序法的效率高于其它的稳定性排序法

基数排序是对传统桶排序的扩展,速度很快

1.2、主要方法

将所有待比较元素统一为同样的数位长度,数位较短的数前面补零。

  • 桶的key值取元素的个位数,将元素按照队列的方式依次放入不同的桶里面
  • 元素按照队列的方式依次出桶,放入到数组中
  • 桶的key值取元素的十位数,重复上面的两个步骤。直到所有元素的最高位为0
    示例

1.3、稳定性

由于桶中元素一直遵循先进先出的规则,因此相等元素会进入到一个桶中且始终保持其相对的顺序,所以基数排序是稳定的

1.4、时间复杂度

基数排序中,每一轮排序入桶和出桶的时间复杂度均为 O ( N ) O(N) O(N),总共有 l o g R N log_RN logRN轮(R表示基数排序的基数,如上面的示例中基数为10),因此总时间复杂度为 O ( N ∗ l o g R N ) O(N*log_RN) O(NlogRN)

1.5、空间复杂度

某一个确定的时间点上,基数排序只会使用M个桶(队列),最坏情况下每个桶只有1个元素,有N个桶,因此空间复杂度为 O ( N ) O(N) O(N)

2、算法实现

package main

import (
	"fmt"
	"sort"
)

const size = 10

var max_num_bucket int = 0

func Radix(arr []int) {
	var queue map[int][]int
	var radix int = 0 //记录轮数
	var base int = 1  //记录基数值
	var key int       //记录个/十/百位上的数字
	var keys []int    //记录桶的key值的集合
	var index int     //记录数组的下标
	//求基数排序的轮数
	max := arr[0]
	for _, val := range arr {
		if val > max {
			max = val
		}
	}
	for max != 0 {
		radix++
		max /= 10
	}

	//循环进行基数排序
	for i := 0; i < radix; i++ {
		queue = make(map[int][]int)
		keys = nil
		index = 0
		//将数组装入桶中
		for _, val := range arr {
			key = val % (base * 10) / base
			queue[key] = append(queue[key], val)
		}
		fmt.Printf("第[%02d]趟桶中元素: %v\n", i+1, queue)
		if len(queue) > max_num_bucket {
			max_num_bucket = len(queue)
		}
		//桶需要按照key排序
		for key := range queue {
			keys = append(keys, key)
		}
		sort.Ints(keys)
		//桶内元素装入数组
		for _, key := range keys {
			for _, val := range queue[key] {
				arr[index] = val
				index++
			}
		}
		base *= 10
	}
	fmt.Printf("【Bucket】排序桶的最多个数为: %d, 最后结果: %v\n", max_num_bucket, arr)
}

func main() {
	arr := [size]int{23, 56, 98, 73, 28, 51, 90, 47, 9, 1}
	fmt.Println("原数组序列:  ", arr)
	Radix(arr[0:])
}

3、运行结果

原数组序列:   [23 56 98 73 28 51 90 47 9 1][01]趟桶中元素: map[0:[90] 1:[51 1] 3:[23 73] 6:[56] 7:[47] 8:[98 28] 9:[9]][02]趟桶中元素: map[0:[1 9] 2:[23 28] 4:[47] 5:[51 56] 7:[73] 9:[90 98]]
【Bucket】排序桶的最多个数为: 7, 最后结果: [1 9 23 28 47 51 56 73 90 98]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值