排序
本次为大家带来非常重要的 Niu B 三人组中的排序方法。这三种方法无论从思路,还是代码上来说都不太容易,所以篇幅也会较长。其中 快速排序、归并排序涉及递归方法, 堆排序涉及二叉树这一数据结构。有追求的童鞋要 反复看,反复琢磨,本篇中由于篇幅忽略的细节欢迎大家 质疑、评价、讨论。
快速排序(quick sort)
快速排序:快!
快速排序思路:
- 取一个元素p(可任意,通常取规定列表的第一个元素),使元素p归位;
- 列表被p分成两部分,左边的都比p小,右边的都比p大;
- 递归即可完成排序。
注意:第一步“元素p归位”指的是在这一列表已经有序的情况下,该元素在列表中所属的列表指标。这一步是快速排序的重中之重,直接决定了接下来的递归范围(实际上递归在代码中是简单的,步骤也是定死的,要点在于我们如何归纳地降低问题规模并找到结束条件,回顾系列(一))。
栗子:
5, 7, 4, 6, 3, 1, 2, 9, 8
-
选取元素p为5归位:-------2, 1, 4, 3, 5, 6, 7, 9, 8
-
归位指标分割列表:-------2, 1, 4, 3,| 5 |,6, 7, 9, 8
(目标:--------------------------- 1, 2, 3, 4, 5, 6, 7, 8, 9)
- 递归(以左边小列表为例):
选取元素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
栗子总结:
- 可以看到每次归位,归位元素都会将原列表劈成两半,即存在循环减半过程,时间复杂度中必会出现logn项,这就是快速排序的“快”。
- 递归终止条件即为列表被劈到只剩一个元素或无元素。
问题:若 “目标” 是递归实现,那么 “p归位” 怎么实现?
回答:现在我们可以为“p元素归位”这个操作提炼一个函数,这个函数:
- 输入:列表,左指标,右指标(两个指标限定截取的列表片段,同时可选取左指标对应的列表元素为“元素p”)
- 输出:“元素p” 归位后的列表指标(此列表指标将列表一分为二,左右 “小列表” 分别递归)
- 此函数我们命名为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,6, 7, 9, 8
从右寻找: 3
移动补全: 2, 1, 4, 3, _,6, 7, 9, 8
左右碰到: 2, 1, 4, 3, 5,6, 7, 9, 8
栗子总结:
- 假设列表左右指标为left, right, 那么left向右移动,right向左移动;
- 先空缺左边,从而right先开始移动,直到碰到比p小的,碰到后补全左边,left开始移动,直到碰到比p大的,碰到后补全右边,right开始移动… 反复左右横跳。
- 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)
- 对于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) 。
- 递归的时间复杂度涉及递归的层数,以及每一层的操作。由于列表数量每次折半,从而一共有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层大致是2k个partition(li, left, right) 操作,每个函数操作的是原列表长度1/2k的小列表,所以时间复杂度为O(n*(1/2k))×2k = O(n), 从而层数×每一层操作 得到快速排序的时间复杂度为 O(nlogn)。
快速排序的缺点:
- 最坏情况:快速排序有最坏情况出现,此时时间复杂度达到了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“,以此来避免上面的情况。
- 在代码上,可以添加一步随机选取操作,选出列表中的随机元素后与第一个元素交换,再执行后续操作。虽然还是有可能在倒八辈子霉的情况下出现最坏情况,但概率很小很小了,一般都没有问题。
- 从这里栗子我们也看出了一个算法可能有多个时间复杂度:最优时间复杂度,一般时间复杂度,最坏时间复杂度,这可以帮助我们更好的认识算法。
- 递归
递归写法对于python来说有最大递归深度的问题, 递归会很客观的消耗系统资源,这个缺点只需了解,这里不展开讨论了。
堆排序(heap sort)
堆排序的目标仍然是列表排序,但是此时我们会把列表以一种更加逻辑的方式看待——堆。堆是一种数据结构,是特殊的完全二叉树,完全二叉树又是特殊的树结构,因此我们从介绍一点点数据结构来开始本次的算法。
堆排序前传——树与二叉树
什么是树?
- 树是一种数据结构, 比如 目录结构。
- 进一步,树是一种可以递归定义的数据结构。
- 更具体, 树是由n个节点组成的集合:
- 如果n=0, 那这是一棵空树;
- 如果n>0, 那存在1个节点作为树的根节点, 其他节点可以分为m个集合,每个集合本身又是一颗树。
- 列表的元素、树的节点本质上是相同的,只是两者作为数据的组织方式,即结构不同。前者是线性的,后者是非线性的。
树的基本概念(图片为例):
- 根节点(A):只有向下分叉的节点;
- 叶节点(EFGHIJ):只有向上连接,没有再分叉的节点;
- 树的深度/高度(3):最多分叉了几层,从纵向来看树;
- 树的广度/度(3):整个树的节点中有最多向下分了几叉,从横向来看树;
- 孩子节点(BCDEFGHIJ):有向上连接的节点;
- 父节点(ABCD):有向下分叉的节点;
- 子树:树中构成树的子集合。
二叉树:广度不超过2的树
- 每个节点最多只有两个孩子节点;
- 两个孩子节点被区分为左孩子节点和右孩子节点。
满二叉树:一个二叉树,如果每一层的节点数都达到最大值,则这个二叉树就是满二叉树。
完全二叉树:可以从满二叉树最后依次拿掉几个节点,即叶节点只能出现在最下层和次下层,并且最下面一层的节点都集中在该层最左边的若干位置的二叉树。(这种二叉树能和列表一对一互相转换)
二叉树的存储方式(表示方式):
- 链式存储方式(后系列 数据结构 部分讲解)
- 顺序存储方式(本篇采用)
顺序存储方式:用列表来存储二叉树;
栗子:
- 将二叉树从上到下(层数),从左到右(节点)依次放入列表;
- 完全二叉树保证这种对应是一一的,可复原的;
- 这种对应可以使节点间关系与元素的列表指标联系起来:
-
父节点和左孩子节点的编号下标有什么关系?
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流程图来大致给演示;
- 假设:节点的左右子树都是堆,但自身不是堆。
摘掉2,需要补充,11 vs 10,11上;
摘掉11,2<8,2<5,需要补充,8 vs 5,8上;
摘掉8,2<6,2<4,需要补充,6 vs 4,6上;
摘掉6,2补充;
栗子总结:
- 当根节点的左右子树都是堆时,可以通过一次向下调整来将其变成一个堆。
- 摘掉根节点后调整过程即是:谁强谁上。
栗子过程:
- 摘掉根节点后由下一层补充,但接下来的目标是将原先根节点放入合适的位置成堆;
- 比较大小后,若可以放入,则向下调整完成;
- 若无法放入,由下一层补充,继续比较大小;
- 若一直无法放入,原先根节点就会成为叶节点。
这一操作在后续“建堆”、“排序”中是最最重要的基本操作,有必要单列函数来实现堆的向下调整,我们命名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]
堆排序传——堆排序过程
堆排序过程:
- 建立堆(大根堆);
- 得到堆顶元素, 为最大元素;
- 去掉堆顶元素,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序;
- 堆顶元素为第二大元素;
- 重复步骤3,直到堆变空。
这个过程我们分两个大部分:建立堆(步骤1)、排序(步骤2-5)来分别说明。
- 建立堆
建立堆是指从任意一个完全二叉树开始,将其调整为一个大根堆(或小根堆)。主要步骤是:
- 从最后一个叶节点所在的两层子树开始调整;
- 从右向左沿着较上一层逐渐移动;
- 直到根节点的左右子树都变成堆;
- 做一次堆的向下调整。
栗子:
- 从最后一个叶节点所在的两层子树开始调整:
调整为:
- 从右向左沿着较上一层逐渐移动(移动轨迹:3—>9):
这一子树无需调整,从右向左沿着较上一层移动(移动轨迹3—>9—>1):
调整为:
从右向左沿着较上一层移动(移动轨迹3—>9—>1—>8):
注意:虽然选中整个子树,但由于下两层已经调整,事实上每次只涉及调整两层,整个子树就调整ok。
如上调整的两层即是:
调整为:
-
直到根节点的左右子树都变成堆(移动轨迹3—>9—>1—>8—>6);
-
做一次堆的向下调整:
栗子总结:
- 从最后一个非叶结点(列表视角:(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)
栗子:
2. 得到堆顶元素, 为最大元素;
得到元素9:
- 去掉堆顶元素,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序;
要保持原地排序,即9和3交换:
并做一次向下调整:
- 堆顶元素为第二大元素;
得到元素8,8和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)
# 一次调整完成,摘除最大数并保持堆
堆排序的时间复杂度:
- 函数sift(li, low, high) 本质是遍历了列表作为堆的深度(树的深度),回忆同 快速排序 时的相同现象,while只是控制了层数遍历的方式,而二叉树是个随层数循环减半的数据结构,函数sift(li, low, high) 的时间复杂度无疑是O(logn);
- 建堆是 n/2 次sift(li, low, high) 向下调整;
- 排序是 n次sift(li, low, high) 向下调整。
综上,堆排序的时间复杂度为O(nlogn)。
注意:虽然时间复杂度相同,快排实际上比堆排还要快一些。
堆排序后传——堆的内置模块
- python 内置模块 —— heapq;
import heapq
- 常用函数:
- heapify(li) :将列表 li 堆化(建了小根堆)
- heappop(heap, item):往外弹出小根堆的根节点(当前列表最小元素)
- 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)。
- 解决思路:
- 排序后切片,各种排序方法都可。
那么基于前两种介绍的排序方法,时间复杂度大致为 O(nlogn) + O(k) ≈ O(nlogn)。 - Low B 三人组中,如冒泡排序,仅排序k趟,每趟操作n次,后续趟数不管,时间复杂度大致为O(nk)。
- 堆排序思路:
- 取列表前k个元素建立一个小根堆,堆顶就是目前第k大的数;
依次向后遍历原列表,对于列表中的元素,如果小于根顶,则忽略该元素;
如果大于堆顶,则将堆顶更换为该元素,并且对堆进行一次调整;
遍历列表所有元素后,倒序弹出堆顶。 - 建堆的时间复杂度为O(klogk), 遍历列表元素并调整的操作次数为nlogk,从而时间复杂度为O(nlogk)。
对于以上三种思路,时间复杂度分别为O(nlogn)、O(nk)、O(nlogk),前两种需要比较下logn与k的大小,而第三种无疑是最快的。
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)
归并
- 假设现在的列表分两段有序,如何将其合成为一个有序列表;
- 这种操作成为一次归并。
栗子:
2 > 1,从右一直寻找比2小的并放入新列表;
2 < 3, 终止,从左一直寻找比3小的并放入新列表;
5 > 3,从右一直寻找比5小的放入新列表;
继续寻找,4 < 5,放入新列表;
5 < 6 , 从左一直寻找比6小的,放入新列表;
7 > 6,从右一直寻找比7小的,放入新列表;
右边穷尽,将左边值依次放入新列表;
栗子总结:
- 两个指针,分别指向两段列表开始;
- 从开头小的列表开始,依次寻找“小片段”,左右横跳,放入新列表;
- 直到两段都耗尽;
- 由于每次寻找都可能找到一个片段,原地移动困难,只能使用新列表排序;
- 注意与 快速排序 的左右横跳操作对比(每次只找到一个值)。
单独实现归并操作: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)
时间复杂度
- 归并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)。
- 归并排序的递归操作中,递归深度为列表循环减半的次数,时间复杂度为O(logn)。第k层递归中,merge(li, low, mid, high) 操作的是2k个小列表,每个列表长度为原列表的1/2k,从而时间复杂度为O((1/2k)n)×2k = O(n),对比快速排序,从而时间复杂度为O(nlogn)。
Niu B 三人组小结
- 三种排序算法时间复杂度都是O(nlogn)。
- 一般情况下,就运行时间而言:
快速排序 < 归并排序 < 堆排序(具有常数级别差异) - 三种排序算法的缺点:
- 快速排序:极端情况下排序效率低;
- 归并排序:需要额外的内存开销;
- 堆排序:在快的排序算法中相对较慢。
说明:
- 冒泡排序的“最好情况”:改进后的冒泡排序可以通过监测是否交换来控制趟数,若此时列表已经有序,那么我们只进行一趟,时间复杂度即为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 三人组稳定性判断依据: 挨个交换的都稳定,跳着换的都不稳定。