前置知识:无

堆结构

堆结构是一颗完全二叉树,堆结构可以实现优先级队列

完全二叉树底层存储结构可以用数组

完全二叉树 和数组前缀范围来对应,大小用单独的变量 size 来控制

完全二叉树节点关系与数组索引对应关系:

  • i 的父亲节点:(i - 1) >> 1,根节点(索引为 0)的父节点索引是负数,代表不存在
  • i 的左孩子:i << 1 + 1
  • i 的右孩子:i << 1 + 2,左右孩子的索引要与 size 比较,确定是否在二叉树上(位置有效)

堆的定义

大根堆(小根堆),任何一颗子树都满足父节点的值大于(小于)等于子节点的值,可以清楚整个堆中最大(小)值在堆顶、完全二叉树的根节点、数组的 0 索引处

本节课讲解按照大根堆来讲解,小根堆是同理的

堆的调整

  • 向上调整:heapInsertup
  • 向下调整:heapifydown

都很简单易懂,具体代码可以看 这里

heapInsertheapify 方法的单次调用,时间复杂度 O(logn),完全二叉树的结构决定的(节点个数为 ,则高度为

Go标准库中的堆

堆排序

建堆

  • 从顶到底建堆,时间复杂度
  • 从底到顶建堆,时间复杂度
    • 总代价就是简单的等比数列关系:

为啥会有差异?简单图解一下

复杂度分析

建好堆之后的调整(排序)阶段,从最大值到最小值依次归位,时间复杂度

不管以什么方式 建堆,调整阶段的时间复杂度都是 ,所以整体复杂度也是

额外空间复杂度是 ,因为堆直接建立在了要排序的数组上,所以没有什么额外空间

代码

测试链接:

package main
 
import (
	"bufio"
	"os"
	"strconv"
)
 
const (
	MAXN = 501
)
 
var (
	n        int
	arr      = [MAXN]int{}
	heapSize int
)
 
func main() {
	input := bufio.NewScanner(os.Stdin)
	input.Split(bufio.ScanWords)
	out := bufio.NewWriterSize(os.Stdout, 4096)
	for input.Scan() {
		n, _ = strconv.Atoi(input.Text())
		if n == 0 {
			continue
		}
		for i := 0; i < n; i++ {
			input.Scan()
			arr[i], _ = strconv.Atoi(input.Text())
		}
		// heapSort1()
		heapSort2()
		out.WriteString(strconv.Itoa(arr[0]))
		for i := 1; i < n; i++ {
			out.WriteByte(' ')
			out.WriteString(strconv.Itoa(arr[i]))
		}
		out.WriteByte('\n')
		out.Flush()
	}
}
 
func heapSort1() {
	heapSize = n
	// 从顶到底建大根堆,O(n*logn)
	for i := 0; i < n; i++ {
		up(i)
	}
	// 依次弹出堆内最大值并排好序,O(n*logn)
	for heapSize > 1 {
		arr[0], arr[heapSize-1] = arr[heapSize-1], arr[0]
		heapSize--
		down(0)
	}
}
 
func heapSort2() {
	heapSize = n
	// 从底到顶建立大根堆,O(n)
	for i := n>>1 - 1; i >= 0; i-- { // n>>1 - 1: 第一个非叶子节点
		down(i)
	}
	// 依次弹出堆内最大值并排好序,O(n*logn)
	for heapSize > 1 {
		arr[0], arr[heapSize-1] = arr[heapSize-1], arr[0]
		heapSize--
		down(0)
	}
}
 
func up(idx int) {
	for {
		parent := (idx - 1) >> 1
		if parent < 0 {
			break
		}
		if arr[parent] >= arr[idx] {
			break
		}
		arr[idx], arr[parent] = arr[parent], arr[idx]
		idx = parent
	}
}
 
func down(idx int) {
	for {
		left := idx<<1 + 1
		maxIdx := idx
		if left < heapSize && arr[left] > arr[maxIdx] {
			maxIdx = left
		}
		if left+1 < heapSize && arr[left+1] > arr[maxIdx] {
			maxIdx = left + 1 // right
		}
		if maxIdx == idx {
			break
		}
		arr[idx], arr[maxIdx] = arr[maxIdx], arr[idx]
		idx = maxIdx
	}
}

常量增倍分析法

数据量为 ,复杂度是 ,如何“化解”这个式子?

用常量增倍分析法!

扩大到 ,即: 是它的上限

常量增倍分析法:若数据量为 时,是否以 为下限?

数据量为 时,前 个数的高度最高是 ,那么后 个数的高度一定都是大于 的,所以 是它的下限

复杂度是忽略常数项的,所以数据量是 还是 ,其复杂度是是一样的

一个算法的复杂度上限和下限都是 ,那么这个算法的复杂度就是

再来个例子

计算一个矩阵(n 行 m 列)中有几个子矩阵,其时间复杂度是什么规模的?

选两个点即可确定一个矩形,第一个点: 种可能,第二个点: 种可能,但是这是有有限种重复情况的:

所以其时间复杂度上限是

常量增倍分析法:若矩形为 时,是否以 为下限?

是的,因为当第一个点在 A 区域,第二个点在 D 区域时,复杂度为 ,但第一个点不仅仅能在 A 区域,第二个点也不仅仅能在 D 区域,所以 是其下限

一个算法的复杂度上限和下限都是 ,那么这个算法的复杂度就是

备注

注意:堆结构比堆排序有用的多,尤其是和比较器结合之后,后面几节课会重点讲述