算法 ----- 排序NB二人组 堆排序 归并排序
参考博客:基于python的七种经典排序算法 常用排序算法总结(一)
堆排序
堆排序前传 - 树与二叉树
树是一种很常见的非线性的数据结构,称为树形结构,简称树。所谓数据结构就是一组数据的集合连同它们的储存关系和对它们的操作方法。树形结构就像自然界的一颗树的构造一样,有一个根和若干个树枝和树叶。根或主干是第一层的,从主干长出的分枝是第二层的,一层一层直到最后,末端的没有分支的结点叫做叶子,所以树形结构是一个层次结构。在《数据结构》中,则用人类的血统关系来命名,一个结点的分枝叫做该结点的“孩子”,而它的上层结点叫做该结点的父亲。根结点没有父亲,叶子结点没有孩子。在《数据结构》中,用递归的方法给出了树的严格数学定义。
树的标准定义:
树(tree)是包含n(n>0)个节点的有穷集合,其中:
(1)每个元素称为节点(node);
(2)有一个特定的节点被称为根节点或树根(root)。
(3)除根节点之外的其余数据元素被分为m(m≥0)个互不相交的结合T1,T2,……Tm-1,其中每一个集合Ti(1<=i<=m)本身也是一棵树,被称作原树的子树(subtree)。
树具有以下特点:
(1)每个节点有零个或多个子节点。
(2)每个子节点只有一个父节点。
(3)没有父节点的节点称为根节点。
关于树的一些术语
节点的度:一个节点含有的子树的个数称为该节点的度;
叶节点或终端节点:度为零的节点称为叶节点;
非终端节点或分支节点:度不为零的节点;
双亲节点或父节点:若一个结点含有子节点,则这个节点称为其子节点的父节点;
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点;
兄弟节点:具有相同父节点的节点互称为兄弟节点;
树的高度或深度:定义一棵树的根结点层次为1,其他节点的层次是其父结点层次加1。一棵树中所有结点的层次的最大值称为这棵树的深度。
节点的层次:从根开始定义起,根为第1层,根的子结点为第2层,以此类推;
树的度:一棵树中,最大的节点的度称为树的度;
节点的祖先:从根到该节点所经分支上的所有节点;
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。
森林:由m(m>=0)棵互不相交的树的集合称为森林;
总结如下:
1、树是一种数据结构;
2、树是一种可以递归定义的数据结构;
3、树是由n个节点组成的集合:
如果n=0空集,那这是一棵空树;
如果n>0,那存在1个节点作为树的根节点,其他节点可以分为m个集合,每个集合本身又是一棵树。
4、树是由叶子构成的单个结点的树。除根外,每一个结点都有唯一的路径连到根上(否则有环)。这条路径由根开始,而末端就在该结点上。这条路径的长度叫做该结点的深度。所有结点深度的最大者叫做树的高度或树的深度。路径上一个结点的前驱就是它的父结点,而子结点则就是这个结点的后继。因为前驱是唯一的,也就是非根结点的入度都是1。所以我们只需要关心出度。结点的出度就是节点的子节点数,称为该结点的度。
特殊且常用的树——二叉树
二叉树是由n(n≥0)个结点组成的有限集合、每个结点最多有两个子树的有序树。它或者是空集,或者是由一个根和称为左、右子树的两个不相交的二叉树组成。
特点:
(1)二叉树是有序树,即使只有一个子树,也必须区分左、右子树;
(2)二叉树的每个结点的度不能大于2,只能取0、1、2三者之一;
(3)二叉树中所有结点的形态有5种:空结点、无左右子树的结点、只有左子树的结点、只有右子树的结点和具有左右子树的结点。
总结:二叉树是每个结点最多有两个孩子,且其子树有左右之分的有序树;二叉树,度不超过2的树(节点最多有两个叉)
二叉树的递归定义:
二叉树是一棵有序树,它的任一结点至多只有两个孩子,分别叫做左孩子和右孩子。根的左孩子L和右孩子R也是二叉树,称为根的左子树和右子树。
两种特殊二叉树
⑴满二叉树:
如果一棵深度为K的二叉树,共有2K-1个结点,即任意第I层有2I-1的结点,称为满二叉树。
⑵完全二叉树:
如果一棵二叉树最多只有最下层但不是叶结点的度数可以小于2,并且最下层的结点如果只有一个孩子,它必须是左孩子,则称此二叉树为完全二叉树)
换句话说,满二叉树就是儿女双全的,每个生育了的结点都有两个孩子。完全二叉树就是虽然不满,但生育了就先生男孩(左),没有只养女儿的。
二叉树的存储方式
完全二叉树可以用列表【顺序存储方式】来存储,通过规律可以从父亲找到孩子或从孩子找到父亲。父亲节点以 i 表示
父节点与左孩子节点 下标关系:i = 2i+1
父节点与右孩子节点 下标关系:i = 2i+2
堆排序 Heap sort
这里的堆(二叉堆),指得不是堆栈的那个堆,而是一种数据结构。堆可以视为一棵完全的二叉树,完全二叉树的一个“优秀”的性质是,除了最底层之外,每一层都是满的。这使得堆可以利用数组来表示,每一个结点对应数组中的一个元素。
二叉堆一般分为两种:大根堆(大顶堆)和小根堆(小顶堆)。
大根堆:一棵完全二叉树,满足任一节点都比其孩子节点大;
特点:最大元素出现在根节点上,处于最大堆的根节点的元素一定是这个堆中的最大值。
小根堆:一棵完全二叉树,满足任一节点都比其孩子节点小;
特点:最小元素出现在根节点上
注意:当根节点的左右子树都是堆时,但自身不是堆,可以通过一次向下的调整来将其变换成一个堆。
堆排序(Heap Sort)就是利用大根堆或小根堆的性质进行排序的方法。堆排序的总体时间复杂度为O(nlogn)。以大根堆为例:我们的堆排序算法就是抓住了堆的这一特点,每次都取堆顶的元素,将其放在序列最后面,然后将剩余的元素重新调整为最大堆,依次类推,最终得到排序的序列。
核心思想:
将待排序的序列构造成一个大顶堆;此时,整个序列的最大值就是堆的根节点。将它与堆数组的末尾元素交换,然后将剩余的n-1个序列重新构造成一个大顶堆。剩余部分调整为大顶堆后,再次将堆顶的最大数取出,再将剩余部分调整为大顶堆。反复执行前面的操作,这个过程持续到剩余数只有一个时结束,最后获得一个有序序列。
堆排序过程:
1、建立堆,挨个出数
2、得到堆顶元素,为最大元素
3、去掉堆顶,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序。
4、堆顶元素为第二大元素。
5、重复步骤3,直到堆变空。
构造堆:
对一个无序的数列,每次都从最后一个子树位置开始,往上依次比较,同层排列结束,再去判断上一层!
堆挨个出数:
找最后的一个数作为棋子,然后取堆顶的值,放在最后;依次执行取出的数放在上一次取出的数前。
堆排序动态演示:
算法实现:
def cal_time(func): def wrapper(*args, **kwargs): t1 = time.time() x = func(*args, **kwargs) t2 = time.time() print("%s running time %s secs." % (func.__name__, t2 - t1)) return x return wrapper def sift(data, low, high): """ 调整函数 data: 列表 low:待调整的子树的根位置 high:待调整的子树的最后一个节点的位置 """ i = low #子树的根 j = 2 * i + 1 #根的左孩子 tmp = data[i] #根的值 # i指向空位置 while j<=high: #j一定要在范围之内,此时领导已经撸到底了 if j != high and data[j] < data[j+1]: j += 1 #j指向数值大的孩子 if tmp < data[j]: #如果小领导比撸下来的大领导能力值大 data[i] = data[j] #把大值放在高位 i = j # 把当前的j赋给i,指向新的空位 根 j = 2*i+1 #生成新的j,执向新的左孩子 #带着新的i,j重新循环 else: break #撸下来的领导比候选的领导能力值大 #循环结束,tmp是最小的值 写在大根堆最后 data[i] = tmp #查找已经到底了,把数写到最后的位置上。 @cal_time def heap_sort(data): """ 堆排序 """ n = len(data) # 建堆 从最后一个非叶子节点的子树【最后一个叶子节点与其父节点比较】 开始构建 # n//2-1 代表最后一个非叶子节点的索引位置,从这个节点开始,一直往上比较 for i in range(n//2-1, -1, -1): sift(data, i, n - 1) #为了方便,我们把最后子树的high设成堆的high。及列表最后一个元素的索引 # 挨个出数 出数的过程中,high值变化而low值不变 for high in range(n - 1, -1, -1): data[0], data[high] = data[high], data[0] #把堆顶和堆尾位置交换(省空间,仅在当前列表操作) sift(data, 0, high - 1) #因为每次都会出一个数,所以真正的high位置是high-1 return data li = list(range(100000)) rd.shuffle(li) heap_sort(li)
堆排序的运行时间主要消耗在初始构建堆和重建堆的反复筛选上。
其初始构建堆时间复杂度为O(n)。
正式排序时,重建堆的时间复杂度为O(nlogn)。
所以堆排序的总体时间复杂度为O(nlogn)。
堆排序对原始记录的排序状态不敏感,因此它无论最好、最坏和平均时间复杂度都是O(nlogn)。在性能上要好于冒泡、简单选择和直接插入算法。空间复杂度上,只需要一个用于交换的暂存单元。但是由于记录的比较和交换是跳跃式的,因此,堆排序也是一种不稳定的排序方法。此外,由于初始构建堆的比较次数较多,堆排序不适合序列个数较少的排序工作。
nlargest 现在有n个数(n>10000),设计算法,按大小顺序得到前10小的数。 应用场景:榜单TOP 10 解决思路: 取列表前10个元素建立一个小根堆。堆顶就是目前第10大的数。 依次向后遍历原列表,对于列表中的元素,如果小于堆顶,则忽略该元素;如果大于堆顶,则将堆顶更换为该元素,并且对堆进行一次调整; 遍历列表所有元素后,倒序弹出堆顶。 时间复杂度:O(nlogm) def topn(li, n): heap = li[0:n] # 建堆 for i in range(n // 2 - 1, -1, -1): sift(heap, i, n - 1) # 遍历 for i in range(n, len(li)): if li[i] > heap[0]: heap[0] = li[i] sift(heap, 0, n - 1) # 出数 for i in range(n - 1, -1, -1): heap[0], heap[i] = heap[i], heap[0] sift(heap, 0, i - 1)
Python内置模块——heapq 利用heapq模块实现堆排序 #每次把一个数加入堆中,向上调整(建立的是小根堆) def heapsort(li): h = [] for value in li: heappush(h, value) return [heappop(h) for i in range(len(h))] 利用heapq模块实现取top-k heapq.nlargest(10, li) #取前10大的数
归并排序 Merge sort
原理:
把原始数组分成若干子列表,对每一个子列表进行排序,继续把子列表与子列表合并,合并后仍然有序,直到全部合并完,形成有序的列表。
分解:将列表越分越小,直至分成一个元素。
一个元素是有序的。
合并:将两个有序列表归并,列表越来越大。
归并排序动态演示:
算法实现:
#!/usr/bin/env python # _*_ coding:utf-8 _*_ # 一次归并排序代码 def merge(li, low, mid, high): """ 归并排序,取之间值,对左右两个临时的列表进行排序,排序完成之后再把两个有序列表归并 """ i = low j = mid + 1 ltmp = [] # low ~ high 这一小块的列表 while i <= mid and j <= high: # 中间数左右还有数 if li[i] <= li[j]: ltmp.append(li[i]) i += 1 else: # li[i]>li[j] ltmp.append(li[j]) j += 1 # 判断左边一直有 while i <= mid: ltmp.append(li[i]) i += 1 # 判断右边一直有 while j <= high: ltmp.append(li[j]) j += 1 li[low:high + 1] = ltmp#写回原列表 def merge_sort(li, low, high): """ 利用递归分解【二分】 和归并 """ if low < high: mid = (low + high)//2 # 正当中的值 merge_sort(li, low, mid) # 递归调用左半部分 merge_sort(li, mid+1, high) # 递归调用右半部分 merge(li, low, mid, high) # 归并 return li li = [10, 4, 6, 3, 8, 2, 5, 7] high = len(li)-1 print(merge_sort(li,0,high))
归并排序对原始序列元素分布情况不敏感,其时间复杂度为O(nlogn)。
归并排序在计算过程中需要使用一定的辅助空间【空列表】,用于递归和存放结果,因此其空间复杂度为O(n)。
归并排序中不存在跳跃,只有两两比较,因此是一种稳定排序。
总之,归并排序是一种比较占用内存,但效率高,并且稳定的算法。
快速排序、堆排序、归并排序-小结
三种排序算法的时间复杂度都是O(nlogn)
一般情况下,就运行时间而言:
快速排序 < 归并排序 < 堆排序
三种排序算法的缺点:
快速排序:极端情况下排序效率低
归并排序:需要额外的内存开销
堆排序:在快的排序算法中相对较慢