终于从树堆里爬出来了——堆排序(基于二叉树)基本思想、步骤、复杂度及python代码,欢迎交流

欢迎关注,敬请点赞!

一、动图演示

堆排序动图

二、思路分析

返回顶部

1. 相关概念

堆是具有以下性质的完全二叉树:

  • 每个结点的值都大于或等于其左右孩子结点的值,称为【大顶堆】;

  • 每个结点的值都小于或等于其左右孩子结点的值,称为【小顶堆】。

    如下图:
    堆定义
    同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中,就是下面这个样子。
    数组
    该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:

    大顶堆: a r r [ i ] > = a r r [ 2 i + 1 ]   & &   a r r [ i ] > = a r r [ 2 i + 2 ] arr[i] >= arr[2i + 1] \space\&\&\space arr[i] >= arr[2i + 2]

    小顶堆: a r r [ i ] < = a r r [ 2 i + 1 ]   & &   a r r [ i ] < = a r r [ 2 i + 2 ] arr[i] <= arr[2i + 1] \space \&\& \space arr[i] <= arr[2i + 2]

2. 基本思想

返回顶部
【1】 将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根结点。

【2】 将其与末尾元素进行交换,此时末尾就为最大值。

【3】 然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。

【4】 如此反复执行,便能得到一个有序序列了。

3. 步骤

【步骤一】 构造初始堆

将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。

​ a. 假设给定无序序列结构如下
初始
​ b. 此时我们从最后一个非叶子结点开始,【从右至左,从下至上】进行调整。

​ (叶结点自然不用调整,第一个非叶子结点 l e n ( a r r ) / 2 1 = 5 / 2 1 = 1 len(arr)/2 - 1 = 5/2 - 1 = 1 ,也就是下面的6结点)
建堆第一步
【局部代码对应演示一】

start = 1
end = 4
root = start
while True:
	child = 2 * root + 1  # 第一轮循环:child = 3;第二轮循环:child = 9
	if child > end: break  # 第一轮循环:不执行;第二轮循环:执行,【退出循环】
	if child + 1 <= end and arr[child] < arr[child + 1]:  # 第一轮循环:成立,执行
		child += 1  # 【让子元素中较大值去与根元素比较】,第一轮循环:child = 4
	if lt[root] < lt[child]:  # 第一轮循环:成立,执行
		lt[root], lt[child] = lt[child], lt[root]  # 第一轮循环:6和9换位
		root = child  # 第一轮循环:root = 4
	else: break  # 【不需要换位,退出循环】,第一轮循环:不执行

​ c. 找到第二个非叶结点4,由于[4, 9, 8]中9元素最大,4和9交换。【见下面代码第一轮循环】
建堆第二步
【局部代码对应演示二】

start = 0
end = 4
root = start
while True:
	child = 2 * root + 1  # 第一轮循环:child = 1;第二轮循环:child = 3;第三轮循环:child = 9
	if child > end: break  # 第一轮循环:不执行;第二轮循环:不执行;第三轮循环:执行,【退出循环】
	if child + 1 <= end and arr[child] < arr[child + 1]:  # 第一轮循环:不执行;第二轮循环:执行
		child += 1  # 【让子元素中较大值去与根元素比较】,第一轮循环:child = 1;第二轮循环:child = 4
	if lt[root] < lt[child]:  # 第一轮循环:成立,执行;第二轮循环:执行
		lt[root], lt[child] = lt[child], lt[root]  # 第一轮循环:4和9换位;第二轮循环:4和6换位
		root = child  # 【从上至下,调整交换导致的子根混乱】,第一轮循环:root = 1;第二轮循环:root = 4
	else: break  # 【不需要换位,退出循环】,第一轮循环:不执行;第二轮循环:不执行

​ d. 这时,交换导致了子根[4, 5, 6]结构混乱,继续调整,[4, 5, 6]中6最大,交换4和6。【见上面代码第二轮循环】
初始堆完成
​ 此时,我们就将一个无序序列构造成了一个大顶堆。

​ 【说明】:高度 h = l o g 2 l e n ( a r r ) = l o g 2 5 = 2 h = log_2len(arr) = log_2 5 = 2 ,调整次数最多 s u m = 2 ( h + 1 ) 2 h = 2 3 2 2 = 4 sum = 2^{(h + 1)} - 2- h = 2^3 - 2 - 2 = 4 , 实际换了3次【确定最高(2层)的0号元素,用了2次;接下来,确定第一个有子结点**(1层)的1号元素,用1次**】。

【步骤二】 将堆顶元素与末尾元素进行交换,使末尾元素最大。

返回顶部
将堆顶元素9和末尾元素4进行交换
第一次交换

【步骤三】 继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。

​ a. 重新调整结构,使其继续满足堆定义

​ 【此时采用的是从上至下,选择最大分支8结点,其它分支已经满足堆定义】
第一次重建
【局部代码对应演示三】

start = 0
end = 3
root = start
while True:
	child = 2 * root + 1  # 第一轮循环:child = 1;第二轮循环:child = 5
	if child > end: break  # 第一轮循环:不执行;第二轮循环:执行,【退出循环】
	if child + 1 <= end and arr[child] < arr[child + 1]:  # 第一轮循环:成立,执行
		child += 1  # 【让子元素中较大值去与根元素比较】,第一轮循环:child = 2
	if lt[root] < lt[child]:  # 第一轮循环:成立,执行
		lt[root], lt[child] = lt[child], lt[root]  # 第一轮循环:4和8换位
		root = child  # 【从上至下,调整交换导致的子根混乱】,第一轮循环:root = 2
	else: break  # 【不需要换位,退出循环】,第一轮循环:不执行

【说明】:重建堆时,只需要进行最多 l o g 2 ( n 1 ) = l o g 2 4 = 2 log_2(n - 1) = log_2 4 = 2 次交换,实际进行1次
​ b.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.
第二次交换

【步骤四】 继续进行调整,交换,如此反复进行

返回顶部
【局部代码对应演示四】

start = 0
end = 2
root = start
while True:
	child = 2 * root + 1  # 第一轮循环:child = 1;第二轮循环:child = 3
	if child > end: break  # 第一轮循环:不执行;第二轮循环:执行,【退出循环】
	if child + 1 <= end and arr[child] < arr[child + 1]:  # 第一轮循环:不执行
		child += 1  # 【让子元素中较大值去与根元素比较】,第一轮循环:child = 1
	if lt[root] < lt[child]:  # 第一轮循环:成立,执行
		lt[root], lt[child] = lt[child], lt[root]  # 第一轮循环:5和6换位
		root = child  # 【从上至下,调整交换导致的子根混乱】,第一轮循环:root = 1
	else: break  # 【不需要换位,退出循环】,第一轮循环:不执行

​ 最终使得整个序列有序。
最终结果

三、python代码实现

返回顶部
【代码参考自暗焰之珩的博客《Python实现堆排序》】

def big_endian(lt, start, end):
    root = start
    while True:
        child = root * 2 + 1
        if child > end:
            break
        if child + 1 <= end and lt[child] < lt[child + 1]:  # 有两个子元素,且后一个子元素更大【反例是只有一个子元素,或第一个子元素更大】
            child += 1  # 【让子元素中较大值去与根元素比较】
        if lt[root] < lt[child]:
            lt[root], lt[child] = lt[child], lt[root]
            root = child  # 从上至下,调整交换导致的子根混乱
        else:  # 不需要换位,退出循环
            break

def heap_sort(lt):
    first=len(lt) // 2 - 1
    for start in range (first, -1, -1):  # 构建初始堆【范围包括最顶端元素】
        big_endian(lt, start, len(lt)-1)
        
    for end in range (len(lt) - 1, 0, -1):  # 【范围从最后一个元素到第二个元素,不包括第一个元素,共交换n - 1次】
        lt[0], lt[end] = lt[end], lt[0]  # 最大元素与末尾元素互换
        big_endian(lt, 0, end - 1)  # 只剩下end - 1个待排序元素

def main():
    lt = [9, 6, 8, 5, 4]
    print ('堆排序前:',lt)
    heap_sort(lt)
    print ('堆排序后',lt)

if __name__ == "__main__":
    main()

四、复杂度分析

返回顶部

【时间复杂度】:

构建初始堆的时间复杂度

【推导过程】

  1. 建立堆时,我们先将n个数据顺序读入到数组中,接着从下向上进行调整。

    1. 从第一个有子结点的结点开始考虑。由于这个结点最多只有左右两个子结点,因此,这两个子结点可以分别看成是两个最大堆。
    2. 将根结点从根调整到应该在的位置,最多需要进行【1次交换】位置。这样就形成了一个比之前的子结点堆高度高一(高度为2)的新堆。
    3. 同样的,与这个结点在同一层上,并在这个结点之前的结点,也最多进行【1次交换】位置。
  2. 第一层调整完之后,进行上一层的调整。

    1. 此时,对新一层的每一个结点,其子结点都是堆。
    2. 同样对这个结点向下调整。最多进行【2次交换】位置。
  3. 递推得到,对于高度为i的结点,最多进行【i次交换】位置。
    【分析】
    设堆的高度为h,因堆是完全二叉树,其高度 h = l o g 2 n h = log_2 n
    【确定上层元素后,交换可能导致子根混乱,可以这样看:】
    【最顶端(只有 2 ( h h ) = 2 0 = 1 2^{(h - h)} = 2^0 = 1 个元素) 进行 h 次交换确定位置】
    【其子根(有 2 [ h ( h 1 ) ] = 2 1 = 2 2^{[h - (h - 1)]} = 2^1 = 2 个元素) 进行 h-1 次交换确定位置】
    【……】
    【第一个有子结点的层(高度为1,有 2 ( h 1 ) 2^{(h - 1)} 个元素) 进行 1 次交换确定位置】

    【计算】
    ​ 显然,建立堆时一共的交换次数为所有结点的交换次数之和。高度为i的结点数为 2 h i 2^{h-i} , 因此堆的调整次数为: s u m = i = 1 h i 2 h i sum =\displaystyle \sum_{i=1}^h i*2^{h-i} ,sum乘2,错位相减:
    s u m = 2 s u m s u m = ( i = 1 h 2 i ) h = ( 2 ( h + 1 ) 2 ) h sum = 2 * sum - sum = \displaystyle (\sum_{i=1}^h 2^i) - h = (2^{(h+1)} - 2) - h
    ​ 可得 s u m = 2 2 h 2 h = 2 n 2 l o g 2 n sum = 2 * 2^h - 2 - h = 2n - 2 - log_2n 。所以,建立堆的时间复杂度(最坏情况时)为O(n)。

堆排序的时间复杂度

​ 堆排序是一种选择排序,整体主要由【构建初始堆】+【交换堆顶元素和末尾元素,并重建堆】两部分组成。

  • 构建初始堆的复杂度为O(n);
  • 在交换并重建堆的过程中,需交换n-1次【确定 n-1个元素后,最后一个元素自动确定】;
  • 而重建堆的过程中【每次重建最多比较互换 l o g 2 n log_2n 次,比构建初始堆简单】,根据完全二叉树的性质, [ l o g 2 ( n 1 ) ,   l o g 2 ( n 2 ) ,   . . . ,   1 ] [log_2(n-1), \space log_2(n-2), \space ...,\space 1] 逐步递减,近似为 ( n 1 ) l o g 2 n (n-1)log_2n
  • O ( n ) + O ( ( n 1 ) l o g 2 n ) O ( n l o g 2 n ) O(n) + O((n - 1)log_2n) \approx O(nlog_2n) ,所以堆排序时间复杂度最好和最坏情况下都是 O ( n l o g 2 n ) O(nlog_2n) 级。

【空间复杂度】:

堆排序不要任何辅助数组,只需要一个辅助变量,所占空间是常数,与n无关,所以空间复杂度为O(1)

堆排序库应用示例:

返回顶部
100w个数中找出最大的100个数。

import heapq    # 引入堆模块
import random   # 产生随机数
test_list = []  # 测试列表
for i in range(1000000):                # 产生100w个数,每个数在【0, 1000w】之间
	test_list.append(random.random() * 100000000)
heapq.nlargest(100, test_list)          # 求100w个数最大的100个数

总结

堆排序的基本思路:

  • 将无序序列构建成一个堆,根据升序(或降序)需求选择大顶堆(或小顶堆);
  • 将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
  • 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素;
  • 反复执行“调整+交换”步骤,直到整个序列有序。

堆排序复杂度:

  • 时间复杂度: O ( n l o g 2 n ) O(nlog_2 n)
  • 空间复杂度:O(1)

欢迎关注,敬请点赞!
返回顶部

原创文章 43 获赞 14 访问量 2839

猜你喜欢

转载自blog.csdn.net/weixin_45221012/article/details/105717562