算法+数据结构总结系列(三)


本次为大家带来非常重要的 Niu B 三人组中的排序方法。这三种方法无论从思路,还是代码上来说都不太容易,所以篇幅也会较长。其中 快速排序、归并排序涉及递归方法堆排序涉及二叉树这一数据结构。有追求的童鞋要 反复看,反复琢磨,本篇中由于篇幅忽略的细节欢迎大家 质疑、评价、讨论

快速排序(quick sort)

快速排序:快!
快速排序思路:

  1. 取一个元素p(可任意,通常取规定列表的第一个元素),使元素p归位
  2. 列表被p分成两部分,左边的都比p小,右边的都比p大;
  3. 递归即可完成排序。

注意:第一步“元素p归位”指的是在这一列表已经有序的情况下,该元素在列表中所属的列表指标。这一步是快速排序的重中之重直接决定了接下来的递归范围(实际上递归在代码中是简单的,步骤也是定死的,要点在于我们如何归纳地降低问题规模并找到结束条件,回顾系列(一))。


栗子:

5, 7, 4, 6, 3, 1, 2, 9, 8

  1. 选取元素p为5归位:-------2, 1, 4, 3, 5, 6, 7, 9, 8

  2. 归位指标分割列表:-------2, 1, 4, 3,| 5 |,6, 7, 9, 8

目标:--------------------------- 1, 2, 3, 4, 5, 6, 7, 8, 9

  1. 递归(以左边小列表为例):
    选取元素p为2归位:--------1,| 2 |, 4, 3,| 5 |, 6, 7, 9, 8
    左边元素1无需归位,达到递归终止;右边列表[4,3]:
    选取元素p为4归位:------1,| 2 |, 3, | 4 |,| 5 |, 6, 7, 9, 8
    左边元素3无需归位,达到递归终止;右边无元素,达到递归终止:
    -----------------------------------1,| 2 |, 3, | 4 |,| 5 |, 6, 7, 9, 8

栗子总结:

  1. 可以看到每次归位,归位元素都会将原列表劈成两半,即存在循环减半过程,时间复杂度中必会出现logn项,这就是快速排序的“快”。
  2. 递归终止条件即为列表被劈到只剩一个元素或无元素

问题:若 “目标” 是递归实现,那么 “p归位” 怎么实现?
回答:现在我们可以为“p元素归位”这个操作提炼一个函数,这个函数:

  1. 输入:列表,左指标,右指标(两个指标限定截取的列表片段,同时可选取左指标对应的列表元素为“元素p”)
  2. 输出:“元素p” 归位后的列表指标(此列表指标将列表一分为二,左右 “小列表” 分别递归)
  3. 此函数我们命名为partition(li, left, right),承担 “分割” 列表,推动递归的作用。

此时可以给出快速排序代码框架

def quick_sort(li, left, right)
	if left < right:       # 列表元素至少两个
		mid = partition(li, left, right)    # 分割
		quick_sort(li, left, mid - 1)       # 左递归
		quick_sort(li, mid + 1, right)      # 右递归

下面着重实现partition(li, left, right)

分割

之前的栗子:


5, 7, 4, 6, 3, 1, 2, 9, 8
选取元素p为5,进行归位:

目标:把比元素p小的都放元素p左边,比元素p大的都放元素p右边

策略

记录元素p,那么列表空缺一位;

此时空缺元素p“左”边元素,从最右边开始找比p小的,直到找到后移动元素补全;

此时空缺元素p“右”边元素,从最左边开始找比p大的,直到找到后移动元素补全;

反复左右横跳直到左右碰到,此时就是元素p的位置

操作

左边空缺: _, 7, 4, 6, 3, 1, 2, 9, 8
从右寻找: 8 > 5 ----> 9 >5 ----> 2
移动补全: 2, 7, 4, 6, 3, 1, _, 9, 8
从左寻找: 7
移动补全: 2, _, 4, 6, 3, 1, 7, 9, 8
从右寻找: 1
移动补全: 2, 1, 4, 6, 3, _, 7, 9, 8
从左寻找: 4 < 5 ----> 6
移动补全: 2, 1, 4, _, 3,67, 9, 8
从右寻找: 3
移动补全: 2, 1, 4, 3, _,67, 9, 8
左右碰到: 2, 1, 4, 3, 567, 9, 8

栗子总结

  1. 假设列表左右指标为left, right, 那么left向右移动,right向左移动
  2. 先空缺左边,从而right先开始移动,直到碰到比p小的,碰到后补全左边,left开始移动,直到碰到比p大的,碰到后补全右边,right开始移动… 反复左右横跳
  3. left与right相等时即为终止,也为p归位

def partition(li, left, right):
	tag = li[left] # 锁定目标
	while left < right:
		while left < right and li[right] >= tag: # 从右边找比tag小的数并避免一直走到最左边
			right -= 1   # 往左走一步
		li[left] = li[right]  # 把右边值写到左边空位
		while left < right and li[left] <= tag: # 从左边找比tag大的数,其余与右边完全对称
			left += 1
		li[right] = li[left]
	li[left] = tag  # 目标归位
	return left # 返回归位下标,即分割值

以上便是partition(li, left, right) 的代码实现,留意列表左右反复横跳操作,在代码由上到下中体现的强烈对称性 😃

排序

到这里可以很容易写出快速排序的代码(就是之前的代码框架):


def partition(li, left, right):
	tag = li[left] # 锁定目标
	while left < right:
		while left < right and li[right] >= tag: # 从右边找比tag小的数并避免一直走到最左边
			right -= 1   # 往左走一步
		li[left] = li[right]  # 把右边值写到左边空位
		while left < right and li[left] <= tag: # 从左边找比tag大的数,其余与右边完全对称
			left += 1
		li[right] = li[left]
	li[left] = tag  # 目标归位
	return left # 返回归位下标,即分割值


def quick_sort(li, left, right):
	if left < right: # 至少两个元素
		mid = partition(li, left, right)
		quick_sort(li, left, mid - 1)
		quick_sort(li, mid + 1, right)

时间复杂度

这里时间复杂度部分为方便理解,仅作大致的数学推导。

快速排序的效率:O(nlogn)

  1. 对于partition(li, left, right) 这一函数,实质上是对一个列表同时从左从右遍历了一遍(反复横跳遍历):
while left < right:
		while left < right and li[right] >= tag: 
			right -= 1   # 往左走一步
		li[left] = li[right]  # 把右边值写到左边空位
		while left < right and li[left] <= tag: 
			left += 1    # 往右走一步
		li[right] = li[left]  # 把左边值写到右边空位

这里虽然有三个while循环,这实际上只是改变了列表遍历的方式不再是顺序或倒序等常规遍历,而是左右横跳遍历, 其余比较、赋值等操作都是O(1) 的,从而partition(li, left, right) 的复杂度是O(n)

  1. 递归的时间复杂度涉及递归的层数,以及每一层的操作。由于列表数量每次折半,从而一共有logn 层(回顾系列(一)),每一层的操作:
	if left < right: # 至少两个元素
		mid = partition(li, left, right)
		quick_sort(li, left, mid - 1)
		quick_sort(li, mid + 1, right)

事实上是若干个partition(li, left, right) 函数操作,再精确一点:第k层大致是2kpartition(li, left, right) 操作,每个函数操作的是原列表长度1/2k的小列表,所以时间复杂度为O(n*(1/2k))×2k = O(n), 从而层数×每一层操作 得到快速排序的时间复杂度为 O(nlogn)

快速排序的缺点

  1. 最坏情况:快速排序有最坏情况出现,此时时间复杂度达到了O(n2)

栗子:

[9, 8, 7, 6, 5, 4, 3, 2, 1]

这个例子的归位、排序过程:(读者可做个练习)

选取9: _, 8, 7, 6, 5, 4, 3, 2, 1;
归位9: 1, 8, 7, 6, 5, 4, 3, 2,| 9 |
左边列表: 1, 8, 7, 6, 5, 4, 3, 2
右边列表: 无,递归完成

选取1: _, 8, 7, 6, 5, 4, 3, 2,| 9 |;
归位1:| 1 |,8, 7, 6, 5, 4, 3, 2,| 9 |;
左边列表: 无,递归完成
右边列表: 8, 7, 6, 5, 4, 3, 2

选取8:又回到了 ”选取9“时类似的情况;
…………

栗子总结:
在这种情况下,我们会发现:每次归位后,总有一边列表为空,另一边列表比原列表元素少1。这种极端情况就是,每次归位不再把列表劈成一半,而是只变成比原列表少1的列表,这样递归层数就变成了n层,从而算法整体时间复杂度变成了O(n2)


问题:这种最坏情况是否可以避免呢?
回答:可以, 这种情况出现在我们把”元素p“每次都恰好选成了当前列表中的最大或最小值,所以只需每次谨慎地选取”元素p“。这需要引入”随机化版本“的快速排序,这种排序方法即是每次随机地选取”元素p“,以此来避免上面的情况。

  • 在代码上,可以添加一步随机选取操作,选出列表中的随机元素后与第一个元素交换,再执行后续操作。虽然还是有可能在倒八辈子霉的情况下出现最坏情况,但概率很小很小了,一般都没有问题
  • 从这里栗子我们也看出了一个算法可能有多个时间复杂度:最优时间复杂度,一般时间复杂度,最坏时间复杂度,这可以帮助我们更好的认识算法。
  1. 递归

递归写法对于python来说有最大递归深度的问题, 递归会很客观的消耗系统资源,这个缺点只需了解,这里不展开讨论了。

堆排序(heap sort)

堆排序的目标仍然是列表排序,但是此时我们会把列表以一种更加逻辑的方式看待——堆堆是一种数据结构,是特殊的完全二叉树,完全二叉树又是特殊的树结构,因此我们从介绍一点点数据结构来开始本次的算法。

堆排序前传——树与二叉树

什么是树?
tree

  • 树是一种数据结构, 比如 目录结构。
  • 进一步,树是一种可以递归定义的数据结构。
  • 更具体, 树是由n个节点组成的集合:
  1. 如果n=0, 那这是一棵空树;
  2. 如果n>0, 那存在1个节点作为树的根节点, 其他节点可以分为m个集合,每个集合本身又是一颗树。
  • 列表的元素、树的节点本质上是相同的,只是两者作为数据的组织方式,即结构不同。前者是线性的,后者是非线性的。

树的基本概念(图片为例):

  • 根节点(A):只有向下分叉的节点;
  • 叶节点(EFGHIJ):只有向上连接,没有再分叉的节点;
  • 树的深度/高度(3):最多分叉了几层,从纵向来看树;
  • 树的广度/度(3):整个树的节点中有最多向下分了几叉,从横向来看树;
  • 孩子节点(BCDEFGHIJ):有向上连接的节点;
  • 父节点(ABCD):有向下分叉的节点;
  • 子树:树中构成树的子集合。

二叉树:广度不超过2的树

  • 每个节点最多只有两个孩子节点;
  • 两个孩子节点被区分为左孩子节点和右孩子节点。

满二叉树:一个二叉树,如果每一层的节点数都达到最大值,则这个二叉树就是满二叉树。

完全二叉树:可以从满二叉树最后依次拿掉几个节点,即叶节点只能出现在最下层和次下层,并且最下面一层的节点都集中在该层最左边的若干位置的二叉树。(这种二叉树能和列表一对一互相转换

在这里插入图片描述
二叉树的存储方式(表示方式):

  • 链式存储方式(后系列 数据结构 部分讲解)
  • 顺序存储方式(本篇采用)

顺序存储方式:用列表来存储二叉树;

栗子:
二叉树

  1. 将二叉树从上到下(层数),从左到右(节点)依次放入列表;
  2. 完全二叉树保证这种对应是一一的,可复原的
  3. 这种对应可以使节点间关系与元素的列表指标联系起来
  • 父节点和左孩子节点的编号下标有什么关系?
    0-1,1-3,2-5,3-7,4-9;
    关系:i -----> 2i + 1,即:
    父亲找左孩子: i -----> 2i + 1;
    左孩子找父亲:n -----> (n-1) // 2。

  • 父节点和右孩子节点的编号下标有什么关系?
    0-2,1-4,2-6,3-8,4-10;
    关系:i ------> 2i + 2,即:
    父亲找右孩子:i ------> 2i + 2;
    右孩子找父亲:n -----> (n-2) // 2。

  • 上面的关系都可以用数学归纳法证明,此处忽略,读者可做练习。

堆排序前传——堆和堆的向下调整

堆:堆是可以被看做一棵树的列表对象,满足以下性质:

  • 堆总是一棵完全二叉树;
  • 堆中某个节点的值总是不大于或不小于(不能混合)其父节点的值。

大根堆:一棵完全二叉树,满足任意节点都比其孩子节点大。

小根堆:一棵完全二叉树,满足任意节点都比其孩子节点小。
堆

  • 下面用到的堆以大根堆为例,这样排出的列表是增序的,逻辑与小根堆完全对称。

堆的向下调整:

  • 假设根节点的左右子树都是堆,但根节点不满足堆的性质;
  • 可以通过一次向下调整来将其变成一个堆;
  • 这一基本操作具有递归潜力建堆 和 排序 都以 向下调整作为基本操作单位

栗子:

  • 以Mermaid流程图来大致给演示;
  • 假设:节点的左右子树都是堆,但自身不是堆。
0
1
2
3
4
5
6
7
8
9
10
11

摘掉2,需要补充,11 vs 10,11上;

0
1
3
4
5
6
7
8
9
10
11
*

摘掉11,2<8,2<5,需要补充,8 vs 5,8上;

0
1
3
4
5
6
7
8
9
10
11
*

摘掉8,2<6,2<4,需要补充,6 vs 4,6上;

0
1
3
4
5
6
7
8
9
10
11
*

摘掉6,2补充;

0
1
2
3
4
5
6
7
8
9
10
11

栗子总结:

  • 当根节点的左右子树都是堆时,可以通过一次向下调整来将其变成一个堆。
  • 摘掉根节点后调整过程即是:谁强谁上

栗子过程:

  1. 摘掉根节点后由下一层补充,但接下来的目标是将原先根节点放入合适的位置成堆
  2. 比较大小后,若可以放入,则向下调整完成
  3. 若无法放入,由下一层补充,继续比较大小;
  4. 一直无法放入,原先根节点就会成为叶节点

这一操作在后续“建堆”、“排序”中是最最重要的基本操作,有必要单列函数来实现堆的向下调整,我们命名sift(li, low, high) 来实现这一操作。

  • 函数中的三个参数 li为列表, low、high为列表上下标,用来截取列表片段。这个函数的目标是:将列表视作堆的情况下,对列表的任意完全二叉树片段做堆的向下调整

  • 函数从摘掉 根节点(li[low])开始, 开始循环,每次循环本质上是 li[low]、左孩子、右孩子三个数的比较

  • 若右孩子不越界(j < high)且 li[low] 比某个孩子小则选较大的孩子上,循环继续;否则 li[low] 一定比俩孩子都大,则 li[low] 上,循环结束。

  • 若右孩子越界,则 li[low] 作为叶结点,循环结束。

def sift(li, low, high):
	i = low   # 指向根节点
	j = 2*i + 1   # 指向根节点左孩子
	while j < high and (li[low] < li[j] or li[low] < li[j+1]):
		if li[j] > li[j + 1]:
			li[i] = li[j]
			i = j  # 往下一层
		else:
			li[i] = li[j+1]
			i = j + 1   # 往下一层
		j = 2*i + 1
	else:
		li[i] = li[low]

堆排序传——堆排序过程

堆排序过程:

  1. 建立堆(大根堆);
  2. 得到堆顶元素, 为最大元素;
  3. 去掉堆顶元素,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序;
  4. 堆顶元素为第二大元素;
  5. 重复步骤3,直到堆变空。

这个过程我们分两个大部分:建立堆(步骤1)、排序(步骤2-5)来分别说明。

  • 建立堆
    建立堆是指从任意一个完全二叉树开始,将其调整为一个大根堆(或小根堆)。主要步骤是:
  1. 从最后一个叶节点所在的两层子树开始调整;
  2. 从右向左沿着较上一层逐渐移动;
  3. 直到根节点的左右子树都变成堆;
  4. 做一次堆的向下调整。

栗子:
建堆

  1. 从最后一个叶节点所在的两层子树开始调整:

建堆1
调整为:

建堆2

  1. 从右向左沿着较上一层逐渐移动(移动轨迹:3—>9):

建堆3
这一子树无需调整,从右向左沿着较上一层移动(移动轨迹3—>9—>1):
建堆4
调整为:
建堆5
从右向左沿着较上一层移动(移动轨迹3—>9—>1—>8):
建堆6
注意:虽然选中整个子树,但由于下两层已经调整,事实上每次只涉及调整两层,整个子树就调整ok
如上调整的两层即是:

5
8
9

调整为:
建堆7

  1. 直到根节点的左右子树都变成堆(移动轨迹3—>9—>1—>8—>6);
    建堆8

  2. 做一次堆的向下调整:
    建堆9
    栗子总结:

  • 从最后一个非叶结点(列表视角:(n-2) // 2)及其左孩子(列表视角:(n-1) 开始,向较上层不断移动调整;
  • 每次仅涉及两层,逐渐由小农村包围整个城市

def build_heap(li):
	n = len(li)
	for i in range((n-2) // 2, -1, -1):  # i表示建堆调整时的上标
		sift(li, i, n-1)    # 逐次调整,堆建立完成

留给读者:为什么循环中sift(li, low, high) 的 high 一直使用(n-1)就可以?

堆排序传——堆排序实现

  • 排序(步骤2-5)

栗子:
排序1
2. 得到堆顶元素, 为最大元素;
得到元素9:
排序2

  1. 去掉堆顶元素,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序;
    要保持原地排序,即9和3交换:
    排序3
    并做一次向下调整:
    排序4
  2. 堆顶元素为第二大元素;
    排序5
    得到元素8,8和3交换:
    排序6
    并做一次向下调整:
    排序7
  3. 重复步骤3,直到堆变空。

那么堆排序实现呼之欲出了~


def sift(li, low, high):
	i = low   # 指向根节点
	j = 2*i + 1   # 指向根节点左孩子
	while j < high and (li[low] < li[j] or li[low] < li[j+1]):
		if li[j] > li[j + 1]:
			li[i] = li[j]
			i = j  # 往下一层
		else:
			li[i] = li[j+1]
			i = j + 1   # 往下一层
		j = 2*i + 1
	else:
		li[i] = li[low]

def heap_sort(li)
	n = len(li)
	for i in range((n-2) // 2, -1, -1):
	# i表示建堆调整时的上标
		sift(li, i, n-1)
	# 堆建立完成
	for i in range(n-1, -1, -1):
	# i会用来表示当前堆调整的下标
		li[0], li[i] = li[i], li[0]
		sift(li, 0, i-1)
		# 一次调整完成,摘除最大数并保持堆
	

堆排序的时间复杂度:

  1. 函数sift(li, low, high) 本质是遍历了列表作为堆的深度(树的深度),回忆同 快速排序 时的相同现象,while只是控制了层数遍历的方式,而二叉树是个随层数循环减半的数据结构,函数sift(li, low, high) 的时间复杂度无疑是O(logn)
  2. 建堆是 n/2 次sift(li, low, high) 向下调整;
  3. 排序是 n次sift(li, low, high) 向下调整。

综上,堆排序的时间复杂度为O(nlogn)

注意虽然时间复杂度相同,快排实际上比堆排还要快一些

堆排序后传——堆的内置模块

  • python 内置模块 —— heapq;
import heapq
  • 常用函数:
  1. heapify(li) :将列表 li 堆化(建了小根堆)
  2. heappop(heap, item):往外弹出小根堆的根节点(当前列表最小元素)
  3. heappush(heap, item):往堆中加入元素并保持堆
import heapq
import random

li = list(range(100))
random.shuffle(li)

heapq.heapify(li)  # 建堆
for i in range(len(li) - 1):  # 逐个弹出最小元素
	print(heapq.heappop(li), end='')

堆排序后传——topk问题

  • topk问题:现在有n个数,设计算法得到前k大的数 (k < n)。
  • 解决思路:
  1. 排序后切片,各种排序方法都可。
    那么基于前两种介绍的排序方法,时间复杂度大致为 O(nlogn) + O(k) ≈ O(nlogn)
  2. Low B 三人组中,如冒泡排序,仅排序k趟,每趟操作n次,后续趟数不管,时间复杂度大致为O(nk)
  3. 堆排序思路:
  • 取列表前k个元素建立一个小根堆,堆顶就是目前第k大的数;
    依次向后遍历原列表,对于列表中的元素,如果小于根顶,则忽略该元素;
    如果大于堆顶,则将堆顶更换为该元素,并且对堆进行一次调整;
    遍历列表所有元素后,倒序弹出堆顶。
  • 建堆的时间复杂度为O(klogk)遍历列表元素并调整的操作次数为nlogk,从而时间复杂度为O(nlogk)

对于以上三种思路,时间复杂度分别为O(nlogn)、O(nk)、O(nlogk),前两种需要比较下lognk的大小,而第三种无疑是最快的。

topk问题实现一下~

# 小根堆版本的sift:
def sift(li, low, high):
	i = low   # 指向根节点
	j = 2*i + 1   # 指向根节点左孩子
	while j < high and (li[low] > li[j] or li[low] > li[j+1]):
		if li[j] < li[j + 1]:
			li[i] = li[j]
			i = j  # 往下一层
		else:
			li[i] = li[j+1]
			i = j + 1   # 往下一层
		j = 2*i + 1
	else:
		li[i] = li[low]

def topk(li, k)
	for i in range((k - 2)//2, -1, -1):
		sift(li[0:k], i, k-1)
	# 前k个元素建堆
	for i in range(k, len(li)):
		if li[i] > li[0]
			li[0] = li[i]
			sift(li[0:k], 0, k-1)
	# 遍历列表并调整
	topk = li[0:k]
	return topk
	# 出数

归并排序(merge sort)

归并

  • 假设现在的列表分两段有序,如何将其合成为一个有序列表;
  • 这种操作成为一次归并。

栗子:

归并1

2 > 1,从右一直寻找比2小的并放入新列表;
归并2
2 < 3, 终止,从左一直寻找比3小的并放入新列表;
归并3
5 > 3,从右一直寻找比5小的放入新列表;
归并4
继续寻找,4 < 5,放入新列表;
归并5
5 < 6 , 从左一直寻找比6小的,放入新列表;
归并6

7 > 6,从右一直寻找比7小的,放入新列表;
归并7
右边穷尽,将左边值依次放入新列表;

归并8
栗子总结:

  • 两个指针,分别指向两段列表开始;
  • 开头小的列表开始,依次寻找“小片段”,左右横跳,放入新列表;
  • 直到两段都耗尽
  • 由于每次寻找都可能找到一个片段,原地移动困难,只能使用新列表排序;
  • 注意与 快速排序 的左右横跳操作对比(每次只找到一个值)。

单独实现归并操作:merge(li, low, mid, high) ,li 是列表,[low:high]截取列表片段,[low:mid],[mid+1:high]是分别有序的片段。

def merge(li, low, mid, high):
	li_new = []
	while mid > low or mid + 1 < high:
		while li[low] < li[mid+1]:
			li_new.append(li[low])
			low += 1
		while li[mid+1] < li[low]:
			li_new.append(li[mid+1])
			mid += 1
	li[low, high+1] = li_new

注意merge(li, low, mid, high) 注定归并排序不是原地排序。

排序

归并排序—— 使用归并:

  • 分解:将列表越分越小,直到分成一个元素;
  • 终止条件:一个元素是有序的;
  • 合并:将两个有序列表归并,列表越来越大。

栗子:

归并
栗子总结(回顾快排、递归):

  • 将列表劈成两半(降低问题规模);
  • 只要列表元素多于1,左边列表归并排序,右边列表归并排序(终止条件);
  • 左右列表归并。

归并排序实现一下~

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)

时间复杂度

  1. 归并merge(li, low, mid, high) 的时间复杂度为O(n),其中的三个while循环:
while mid > low or mid + 1 < high:
		while li[low] < li[mid+1]:
			li_new.append(li[low])
			low += 1
		while li[mid+1] < li[low]:
			li_new.append(li[mid+1])
			mid += 1

同样是变相地遍历了一遍列表,从而时间复杂度为O(n),但是此函数开辟了新列表,故具有空间复杂度O(n)

  1. 归并排序的递归操作中,递归深度为列表循环减半的次数,时间复杂度为O(logn)第k层递归中,merge(li, low, mid, high) 操作的是2k个小列表,每个列表长度为原列表的1/2k,从而时间复杂度为O((1/2k)n)×2k = O(n),对比快速排序,从而时间复杂度为O(nlogn)

Niu B 三人组小结

  • 三种排序算法时间复杂度都是O(nlogn)
  • 一般情况下,就运行时间而言:
    快速排序 < 归并排序 < 堆排序(具有常数级别差异)
  • 三种排序算法的缺点:
  1. 快速排序:极端情况下排序效率低;
  2. 归并排序:需要额外的内存开销;
  3. 堆排序:在快的排序算法中相对较慢。

比较

说明:

  • 冒泡排序的“最好情况”:改进后的冒泡排序可以通过监测是否交换来控制趟数,若此时列表已经有序,那么我们只进行一趟,时间复杂度即为O(n)
  • 快速排序的“空间复杂度”:快速排序采用了递归方法,需要占用系统资源来临时存放每层递归内容。最好的情况是循环减半,递归logn层;最差情况就是倒八辈子霉那种,递归n层,所以有平均空间复杂度为O(logn),最坏空间复杂度为O(n)
  • 归并排序的“空间复杂度”:归并排序同样是递归方法,其空间复杂度为:递归系统占用 + 每层开辟新列表 ≈ O(n)
  • 稳定性:Niu B 三人组中排序都是进行比较、移动等操作,然鹅当两个值相同时,排序前后是否能保证其相对位置不变,即为排序的稳定性

栗子:

{'name':'a', 'age': 18 }
{'name':'b', 'age': 20 }
{'name':'a', 'age': 25 }

若我们对上面这一字典按"name"排序,是否能保证同名元素的"age"的相对大小

# 能的情况:
{'name':'a', 'age': 18 }
{'name':'a', 'age': 25 }
{'name':'b', 'age': 20 }
# 不能的情况:
{'name':'a', 'age': 25 }
{'name':'a', 'age': 18 }
{'name':'b', 'age': 20 }

Niu B 三人组稳定性判断依据: 挨个交换的都稳定,跳着换的都不稳定

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值