数据结构和算法(Golang实现)(24)排序算法-优先队列及堆排序
优先队列及堆排序
堆排序(Heap Sort
)由威尔士-加拿大计算机科学家J. W. J. Williams
在1964
年发明,它利用了二叉堆(A binary heap)
的性质实现了排序,并证明了二叉堆数据结构的可用性。同年,美国籍计算机科学家R. W. Floyd
在其树排序研究的基础上,发布了一个改进的更好的原地排序的堆排序版本。
堆排序属于选择类排序算法。
一、优先队列
优先队列是一种能完成以下任务的队列:插入一个数值,取出最小或最大的数值(获取数值,并且删除)。
优先队列可以用二叉树来实现,我们称这种结构为二叉堆。
最小堆和最大堆是二叉堆的一种,是一颗完全二叉树(一种平衡树)。
最小堆的性质:
- 父节点的值都小于左右儿子节点。
- 这是一个递归的性质。
最大堆的性质:
- 父节点的值都大于左右儿子节点。
- 这是一个递归的性质。
最大堆和最小堆实现方式一样,只不过根节点一个是最大的,一个是最小的。
1.1. 最大堆特征
最大堆实现细节(两个操作):
- push:向堆中插入数据时,首先在堆的末尾插入数据,如果该数据比父亲节点还大,那么交换,然后不断向上提升,直到没有大小颠倒为止。
- pop:从堆中删除最大值时,首先把最后一个值复制到根节点上,并且删除最后一个数值,然后和儿子节点比较,如果值小于儿子,与儿子节点交换,然后不断向下交换, 直到没有大小颠倒为止。在向下交换过程中,如果有两个子儿子都大于自己,就选择较大的。
最大堆有两个核心操作,一个是上浮,一个是下沉,分别对应push
和pop
。
这是一个最大堆:
用数组表示为:[11 5 8 3 4]
1.2. 上浮操作
我们要往堆里push
一个元素15
,我们先把X = 15
放到树最尾部,然后进行上浮操作。
因为15
大于其父亲节点8
,所以与父亲替换:
这时15
还是大于其父亲节点11
,继续替换:
操作一次push
的最好时间复杂度为:O(1)
,因为第一次上浮时如果不大于父亲,那么就结束了。最坏的时间复杂度为:O(logn)
,相当于每次都大于父亲,会一直往上浮到根节点,翻转次数等于树的高度,而树的高度等于元素个数的对数:log(n)
。
1.3. 下沉操作
我们现在要将堆顶的元素pop
出。如图我们要移除最大的元素11
:
我们先将根节点移除,然后将最尾部的节点4
放在根节点上:
接着对根节点4
进行下沉操作,与其两个儿子节点比较,发现较大的儿子节点8
比4
大,那么根节点4
与其儿子节点8
交换位置,向下翻转:
这样一直向下翻转就维持了最大堆的特征。
操作一次pop
最好的时间复杂度也是:O(1)
,因为第一次比较时根节点就是最大的。最坏时间复杂度仍然是树的高度:O(logn)
。
1.4. 时间复杂度分析
构建一个最大堆,从空堆开始,每次添加元素到尾部后,需要向上翻转,最坏翻转次数是:
第一次添加元素翻转次数:log1
第二次添加元素翻转次数:log2
第三次添加元素翻转次数:不大于log3的最大整数
第四次添加元素翻转次数:log4
第五次添加元素翻转次数:不大于log5的最大整数
...
第N次添加元素翻转次数:不大于logn的最大整数
近似 = log(1)+log(2)+log(3)+...+log(n) = log(n!)
从一个最大堆,逐一移除堆顶元素,然后将堆尾元素置于堆顶后,向下翻转恢复堆特征,最坏翻转次数是:
第一次移除元素恢复堆时间复杂度:logn
第二次移除元素恢复堆时间复杂度:不大于log(n-1)的最大整数
第三次移除元素恢复堆时间复杂度:不大于log(n-2)的最大整数
...
第N次移除元素恢复堆时间复杂度:log1
近似 = log(1)+log(2)+log(3)+...+log(n) = log(n!)
根据斯特林公式:
可以进行证明log(n!)
和nlog(n)
是同阶的:
所以构建一个最大堆的最坏时间复杂度是:O(nlogn)
。
从堆顶一个个移除元素,直到移完,整个过程最坏时间复杂度也是:O(nlogn)
。
从构建堆到移除堆,总的最坏复杂度是:O(nlogn)+O(nlogn)
,我们可以认为是:O(nlogn)
。
如果所有的元素都一样的情况下,建堆和移除堆的每一步都不需要翻转,最好时间复杂度为:O(n)
,复杂度主要在于遍历元素。
如果元素不全一样,即使在建堆的时候不需要翻转,但在移除堆的过程中一定会破坏堆的特征,导致恢复堆时需要翻转。比如一个n
个元素的已排好的序的数列,建堆时每次都满足堆的特征,不需要上浮翻转,但在移除堆的过程中最尾部元素需要放在根节点,这个时候导致不满足堆的特征,需要下沉翻转。因此,在最好情况下,时间复杂度仍然是:O(nlog)
。
因此,最大堆从构建到移除,总的平均时间复杂度是:O(nlogn)
。
1.5. 最大堆实现
// 一个最大堆,一颗完全二叉树
// 最大堆要求节点元素都不小于其左右孩子
type Heap struct {
// 堆的大小
Size int
// 使用内部的数组来模拟树
// 一个节点下标为 i,那么父亲节点的下标为 (i-1)/2
// 一个节点下标为 i,那么左儿子的下标为 2i+1,右儿子下标为 2i+2
Array []int
}
// 初始化一个堆
func NewHeap(array []int) *Heap {
h := new(Heap)
h.Array = array
return h
}
// 最大堆插入元素
func (h *Heap) Push(x int) {
// 堆没有元素时,使元素成为顶点后退出
if h.Size == 0 {
h.Array[0] = x
h.Size++
return
}
// i 是要插入节点的下标
i := h.Size
// 如果下标存在
// 将小的值 x 一直上浮
for i > 0 {
// parent为该元素父亲节点的下标
parent := (i - 1) / 2
// 如果插入的值小于等于父亲节点,那么可以直接退出循环,因为父亲仍然是最大的
if x <= h.Array[parent] {
break
}
// 否则将父亲节点与该节点互换,然后向上翻转,将最大的元素一直往上推
h.Array[i] = h.Array[parent]
i = parent
}
// 将该值 x 放在不会再翻转的位置
h.Array[i] = x
// 堆数量加一
h.Size++
}
// 最大堆移除根节点元素,也就是最大的元素
func (h *Heap) Pop() int {
// 没有元素,返回-1
if h.Size == 0 {
return -1
}
// 取出根节点
ret := h.Array[0]
// 因为根节点要被删除了,将最后一个节点放到根节点的位置上
h.Size--
x := h.Array[h.Size] // 将最后一个元素的值先拿出来
h.Array[h.Size] = ret // 将移除的元素放在最后一个元素的位置上
// 对根节点进行向下翻转,小的值 x 一直下沉,维持最大堆的特征
i := 0
for {
// a,b为下标 i 左右两个子节点的下标
a := 2*i + 1
b := 2*i + 2
// 左儿子下标超出了,表示没有左子树,那么右子树也没有,直接返回
if a >= h.Size {
break
}
// 有右子树,拿到两个子节点中较大节点的下标
if b < h.Size && h.Array[b] > h.Array[a] {
a = b
}
// 父亲节点的值都大于或等于两个儿子较大的那个,不需要向下继续翻转了,返回
if x >= h.Array[a] {
break
}
// 将较大的儿子与父亲交换,维持这个最大堆的特征
h.Array[i] = h.Array[a]
// 继续往下操作
i = a
}
// 将最后一个元素的值 x 放在不会再翻转的位置
h.Array[i] = x
return ret
}
以上为最大堆的实现。
三、普通堆排序
根据最大堆,堆顶元素一直是最大的元素特征,可以实现堆排序。
先构建一个最小堆,然后依次把根节点元素pop
出即可:
func main() {
list := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
// 构建最大堆
h := NewHeap(list)
for _, v := range list {
h.Push(v)
}
// 将堆元素移除
for range list {
h.Pop()
}
// 打印排序后的值
fmt.Println(list)
}
输出:
1 3 4 5 6 6 6 8 9 14 25 49
根据以上最大堆的时间复杂度分析,从堆构建到移除最坏和最好的时间复杂度:O(nlogn)
,这也是堆排序的最好和最坏的时间复杂度。
这样实现的堆排序是普通的堆排序,性能不是最优的。
因为一开始会认为堆是空的,每次添加元素都需要添加到尾部,然后向上翻转,需要用Heap.Size
来记录堆的大小增长,这种堆构建,可以认为是非原地的构建,影响了效率。
美国籍计算机科学家R. W. Floyd
改进的原地自底向上的堆排序,不会从空堆开始,而是把待排序的数列当成一个混乱的最大堆,从底层逐层开始,对元素进行下沉操作,一直恢复最大堆的特征,直到根节点。
将构建堆的时间复杂度从O(nlogn)
降为O(n)
,总的堆排序时间复杂度从O(2nlogn)
改进到O(n+nlogn)
。
三、自底向上堆排序
自底向上堆排序,仅仅将构建堆的时间复杂度从O(nlogn)
改进到O(n)
,其他保持不变。
这种堆排序,不再每次都将元素添加到尾部,然后上浮翻转,而是在混乱堆的基础上,从底部向上逐层进行下沉操作,下沉操作比较的次数会减少。步骤如下:
- 先对最底部的所有非叶子节点进行下沉,即这些非叶子节点与它们的儿子节点比较,较大的儿子和父亲交换位置。
- 接着从次二层开始的非叶子节点重复这个操作,直到到达根节点最大堆就构建好了。
从底部开始,向上推进,所以这种堆排序又叫自底向上的堆排序。
为什么自底向上构建堆的时间复杂度是:O(n)
。证明如下:
第k
层的非叶子节点的数量为n/2^k
,每一个非叶子节点下沉的最大次数为其子孙的层数:k
,而树的层数为logn
层,那么总的翻转次数计算如下:
因为如下的公式是成立的:
所以翻转的次数计算结果为:2n
次。也就是构建堆的时间复杂度为:O(n)
。
我们用非递归的形式来实现,非递归相对容易理解:
package main
import "fmt"
// 先自底向上构建最大堆,再移除堆元素实现堆排序
func HeapSort(array []int) {
// 堆的元素数量
count := len(array)
// 最底层的叶子节点下标,该节点位置不定,但是该叶子节点右边的节点都是叶子节点
start := count/2 + 1
// 最后的元素下标
end := count - 1
// 从最底层开始,逐一对节点进行下沉
for start >= 0 {
sift(array, start, count)
start-- // 表示左偏移一个节点,如果该层没有节点了,那么表示到了上一层的最右边
}
// 下沉结束了,现在要来排序了
// 元素大于2个的最大堆才可以移除
for end > 0 {
// 将堆顶元素与堆尾元素互换,表示移除最大堆元素
array[end], array[0] = array[0], array[end]
// 对堆顶进行下沉操作
sift(array, 0, end)
// 一直移除堆顶元素
end--
}
}
// 下沉操作,需要下沉的元素时 array[start],参数 count 只要用来判断是否到底堆底,使得下沉结束
func sift(array []int, start, count int) {
// 父亲节点
root := start
// 左儿子
child := root*2 + 1
// 如果有下一代
for child < count {
// 右儿子比左儿子大,那么要翻转的儿子改为右儿子
if count-child > 1 && array[child] < array[child+1] {
child++
}
// 父亲节点比儿子小,那么将父亲和儿子位置交换
if array[root] < array[child] {
array[root], array[child] = array[child], array[root]
// 继续往下沉
root = child
child = root*2 + 1
} else {
return
}
}
}
func main() {
list := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
HeapSort(list)
// 打印排序后的值
fmt.Println(list)
}
输出:
[1 3 4 5 6 6 6 8 9 14 25 49]
系列文章入口
我是陈星星,欢迎阅读我亲自写的 数据结构和算法(Golang实现),文章首发于 阅读更友好的GitBook。
- 数据结构和算法(Golang实现)(1)简单入门Golang-前言
- 数据结构和算法(Golang实现)(2)简单入门Golang-包、变量和函数
- 数据结构和算法(Golang实现)(3)简单入门Golang-流程控制语句
- 数据结构和算法(Golang实现)(4)简单入门Golang-结构体和方法
- 数据结构和算法(Golang实现)(5)简单入门Golang-接口
- 数据结构和算法(Golang实现)(6)简单入门Golang-并发、协程和信道
- 数据结构和算法(Golang实现)(7)简单入门Golang-标准库
- 数据结构和算法(Golang实现)(8.1)基础知识-前言
- 数据结构和算法(Golang实现)(8.2)基础知识-分治法和递归
- 数据结构和算法(Golang实现)(9)基础知识-算法复杂度及渐进符号
- 数据结构和算法(Golang实现)(10)基础知识-算法复杂度主方法
- 数据结构和算法(Golang实现)(11)常见数据结构-前言
- 数据结构和算法(Golang实现)(12)常见数据结构-链表
- 数据结构和算法(Golang实现)(13)常见数据结构-可变长数组
- 数据结构和算法(Golang实现)(14)常见数据结构-栈和队列
- 数据结构和算法(Golang实现)(15)常见数据结构-列表
- 数据结构和算法(Golang实现)(16)常见数据结构-字典
- 数据结构和算法(Golang实现)(17)常见数据结构-树
- 数据结构和算法(Golang实现)(18)排序算法-前言
- 数据结构和算法(Golang实现)(19)排序算法-冒泡排序
- 数据结构和算法(Golang实现)(20)排序算法-选择排序
- 数据结构和算法(Golang实现)(21)排序算法-插入排序
- 数据结构和算法(Golang实现)(22)排序算法-希尔排序
- 数据结构和算法(Golang实现)(23)排序算法-归并排序
- 数据结构和算法(Golang实现)(24)排序算法-优先队列及堆排序
- 数据结构和算法(Golang实现)(25)排序算法-快速排序
- 数据结构和算法(Golang实现)(26)查找算法-哈希表
- 数据结构和算法(Golang实现)(27)查找算法-二叉查找树
- 数据结构和算法(Golang实现)(28)查找算法-AVL树
- 数据结构和算法(Golang实现)(29)查找算法-2-3树和左倾红黑树
- 数据结构和算法(Golang实现)(30)查找算法-2-3-4树和普通红黑树
数据结构和算法(Golang实现)(24)排序算法-优先队列及堆排序的更多相关文章
- 数据结构和算法(Golang实现)(25)排序算法-快速排序
快速排序 快速排序是一种分治策略的排序算法,是由英国计算机科学家Tony Hoare发明的, 该算法被发布在1961年的Communications of the ACM 国际计算机学会月刊. 注:A ...
- 数据结构和算法(Golang实现)(18)排序算法-前言
排序算法 人类的发展中,我们学会了计数,比如知道小明今天打猎的兔子的数量是多少.另外一方面,我们也需要判断,今天哪个人打猎打得多,我们需要比较. 所以,排序这个很自然的需求就出来了.比如小明打了5只兔 ...
- 数据结构和算法(Golang实现)(19)排序算法-冒泡排序
冒泡排序 冒泡排序是大多数人学的第一种排序算法,在面试中,也是问的最多的一种,有时候还要求手写排序代码,因为比较简单. 冒泡排序属于交换类的排序算法. 一.算法介绍 现在有一堆乱序的数,比如:5 9 ...
- 数据结构和算法(Golang实现)(20)排序算法-选择排序
选择排序 选择排序,一般我们指的是简单选择排序,也可以叫直接选择排序,它不像冒泡排序一样相邻地交换元素,而是通过选择最小的元素,每轮迭代只需交换一次.虽然交换次数比冒泡少很多,但效率和冒泡排序一样的糟 ...
- 数据结构和算法(Golang实现)(21)排序算法-插入排序
插入排序 插入排序,一般我们指的是简单插入排序,也可以叫直接插入排序.就是说,每次把一个数插到已经排好序的数列里面形成新的排好序的数列,以此反复. 插入排序属于插入类排序算法. 除了我以外,有些人打扑 ...
- 数据结构和算法(Golang实现)(22)排序算法-希尔排序
希尔排序 1959 年一个叫Donald L. Shell (March 1, 1924 – November 2, 2015)的美国人在Communications of the ACM 国际计算机 ...
- 数据结构和算法(Golang实现)(23)排序算法-归并排序
归并排序 归并排序是一种分治策略的排序算法.它是一种比较特殊的排序算法,通过递归地先使每个子序列有序,再将两个有序的序列进行合并成一个有序的序列. 归并排序首先由著名的现代计算机之父John_von_ ...
- 数据结构和算法(Golang实现)(26)查找算法-哈希表
哈希表:散列查找 一.线性查找 我们要通过一个键key来查找相应的值value.有一种最简单的方式,就是将键值对存放在链表里,然后遍历链表来查找是否存在key,存在则更新键对应的值,不存在则将键值对链 ...
- 数据结构和算法(Golang实现)(27)查找算法-二叉查找树
二叉查找树 二叉查找树,又叫二叉排序树,二叉搜索树,是一种有特定规则的二叉树,定义如下: 它是一颗二叉树,或者是空树. 左子树所有节点的值都小于它的根节点,右子树所有节点的值都大于它的根节点. 左右子 ...
随机推荐
- 查看oracle是否锁表以及解决方法
Oracle数据库操作中,我们有时会用到锁表查询以及解锁和kill进程等操作,那么这些操作是怎么实现的呢?本文我们主要就介绍一下这部分内容.(1)锁表查询的代码有以下的形式: select count ...
- Java循环和数组练习题:打印素数升级版
前两天一个小伙伴问了我一个数组的练习题,我一看思路很清晰,就是打印素数加了个数组.没想到写的时候啪啪打脸,终究还是没逃过眼高手低.本来不打算发出来了,因为发出来愈发显得我很菜,最后还是打算分享出来,一 ...
- spring容器概述
这篇博客写一下对spring和springmvc父子容器的理解. 一.首先明确: (1)spring是一个大的父容器,springmvc是其中的一个子容器.父容器不能访问子容器对象,但是子容器可以访问 ...
- [Unity] Unity 2019.1.14f 在Terrain中使用Paint Texture的方法
1.点击Terrain中的Paint Texture按钮2.将按钮下面的下拉菜单选择paint texture3.点击Edit Terrain Layers按钮T4.点击弹出菜单的Create Lay ...
- Qt 的信号与槽(纯干货)
接触Qt断断续续有些时间了,总看了一堆的文章说信号槽的概念,心里就想骂人,做为一个初学者,最重要的就是怎么写代码,写代码写多了,再去看理论,有时水到渠成的就明白那些理论了.但所有讲信号槽的都把一堆信号 ...
- Kaggle大牛小姐姐自述:我是怎么成为竞赛中Top 0.3%的 | 干货攻略
天天跟数据打交道的研究人员,都有一个成为Kaggle顶级大师(Grandmaster)的梦想. 但每年的Kaggle参赛团队众多,通常一个项目都有数千人至上万人报名,如何在其中脱颖而出? 最近,自动化 ...
- arcgis发布服务后显示证书不安全,将https改为http
arcgis server发布服务后显示证书不安全,无法浏览. 要通过https://localhost:port/arcgis/admin作如下修改 update之后地图服务会重启,耐心等待一会儿就 ...
- AngularJS插件使用---angular-cookies.js
首先在项目中引入angular-cookies.js angular模块中添加依赖:‘ngCookies’ 在控制器中依赖注入$cookies,使用$cookies操作cookie $cookiesP ...
- JS 剑指Offer(五) 二叉树的重建
题目:输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树.假设输入的前序遍历和中序遍历的结果中都不含重复的数字. 题目分析:已知二叉树的前序和中序遍历,根据前序遍历和中序遍历的规则,前序遍历的第一 ...
- 学编程这么久,还傻傻分不清什么是方法(method),什么是函数(function)?
在编程语言中有两个很基础的概念,即方法(method)和函数(function).如果达到了编程初级/入门级水平,那么你肯定在心中已有了初步的答案. 也许在你心中已有答案了 除去入参.返回值.匿名函数 ...