排序算法的稳定性
定义:同样值的个体之间,如果不会因为排序而改变相对次序,则认为这个排序是稳定的。
例:现在对下面的数组继续进行排序:
若排序之后,值为1的数的相对次序仍然不变(1、2),值为2的数的相对次序也不改变(a、b、c、d),值为n的也不改变,即可以认为该排序是稳定的。
针对基础类型数组的时候,稳定性用处不大;但是在非基础类型的数组中,稳定性影响大!例:
对于学生结构体(包含class、age两个属性),先按照年龄升序排序,再按照班级升序排序:
得到的结果是:班级内部的学生也是按年龄排序的!
原因:排序算法的稳定性能保留相对次序!
针对时间复杂度 的排序
选择排序:不可 —— 交换过程破坏稳定性
反例:
冒泡排序:可 —— 要求:相等时两数不交换
例:
第一轮排序
注意,相等时不进行交换!
第二轮排序
等等。
插入排序:可 —— 要求:相等时两数不交换
例:
0 ~ 0有序,想要 0 ~ 1 有序:
仍然可以保持稳定性。
针对时间复杂度 的排序
归并排序:可, 要求:merge过程中,两数相等时先拷贝左侧的数
快速排序:做不到 —— partition过程破坏了稳定性!
例:假设以5作为划分值,运行后发现需要把3和6进行交换,此时就已经破坏了6的稳定性!
堆排序:做不到 —— 其根据完全二叉树二叉树进行运算,很轻易地破坏了稳定性!
---->
计数排序和基础排序:能做到 —— 可以控制出桶的顺序!(思路上就与比较无关)
排序算法大总结
排序方法 \ 评价指标 | 时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|
选择排序 | × | ||
冒泡排序 | √ | ||
插入排序 | √ | ||
归并排序 | √ | ||
快速排序(随机) | × | ||
堆排序 | × |
注意:(1)基于比较的排序,时间复杂度指标最低为。
(2)在时间复杂度 的情况下,空间复杂度不可能小于 且稳定。
启示:根据需要进行取舍 —— 运行速度最快:快排(实际期望值最低);空间要求为:堆排序;需要稳定性:归并排序,且必须一个 的空间。
常见的坑:
- 归并排序的额外空间复杂度可以变成 ,代价是其不再稳定,且难以实现(内部缓存法),为何不用堆排序实现?【归并排序情况一】
- “原地归并排序”的帖子是垃圾:可以变成 ,但是时间复杂度变为,为何不采用插入排序?又简单又满足要求。【归并排序情况二】
- 快速排序可以做到稳定性,但是空间复杂度会变成 水平(01 stable sort),为何不直接使用归并排序?
- 目前没有找到时间复杂度、外空间复杂度 、又稳定的排序!
- 面试大坑题:对一个整型数组,要求排序后奇数在左偶数在右,且稳定,要求时间复杂度、空间复杂度,问能否做到?
答:奇偶问题对应0-1标准,经典快排的partition也是0-1标准、是同一种调整策略,但是做不到稳定性。理论上您的要求在快排的基础上修改可以实现,但是这是一个论文级别的算法,我不会做,请面试官指教。
工程上对于排序的改进
(1)充分利用 和 排序各自的优势! ---> “综合排序”
例如:在快速排序中,若样本量较小,eg:L > R - 60 时,直接使用插入排序。
即:取长补短,在大样本量的调度上,利用了快排的调度优势;在小样本量的时候,利用插入排序常数项小的优势(当 n 较小,NlogN 与 N^2 差别不大,此时其常数项对于复杂度影响更大)。
(2)稳定性方面:在语言自己封装的排序中,若发现传入基础类型,则使用快排;若发现传入非基础类型,考虑到可能需要利用稳定性的特质,会选择归并排序。
在 c 或 java 等的底层,其实使用了复杂的排序策略,以求达到排序效率的最大化。
哈希表
可理解成集合结构。
--HashSet(UnOrderedSet):只有 Key
--HashMap(UnOrderedMap):Key -> Value
增(put)删(remove)改(put)查(get)可认为时间复杂度为 ,但是常数时间比较大。
对于放入哈希表的内容,
若是基础类型,则按值传递(产生拷贝),内存占用即是所存东西的大小;
若是非基础类型,内部按引用传递,内存占用为这个东西占地址的大小。
(1)基础类型按值传递!如果 String 很长,则存储消耗很多空间!
(2)非基础类型按照引用传递,无论值如何,只保存地址,只要地址不同,则认为是不同的。
而其中的 Node 是自定义类型,按引用传递!
有序表
也可理解成集合结构,和哈希表的区别在于:有序表会把 Key 按照顺序组织起来。
--TreeSet(OrderedSet):只有 Key
--TressMap(OrderedMap):Key -> Value
相应地,有序表因为按 Key 组织,其也会提供对应的 api:
红黑树、AVL树、size-balance-tree 和跳表都属于有序表结构,只是底层实现不同。
对于放入有序表的内容,
若是基础类型,则按值传递(产生拷贝),内存占用即是所存东西的大小;
若是非基础类型,必须提供比较器,否则有序表不知道如何组织,内部按引用传递,内存占用为这个东西占地址的大小。
示例:
单链表和双链表
定义
如果在逆序链表题中涉及换头操作,形式应该类似:head = f (head);
链表题的解题方法论
(1)笔试:做出来即可,最多考虑时间复杂度
(2)面试:时间复杂度优先考虑,但是一定找到空间复杂度最省的方法!!
重要技巧:
1)额外数据结构记录(哈希表等)
2)快慢指针 ----> 一定要自己练习!
需要根据具体情况“定制,同时注意边界条件:
例题1
不考虑空间复杂度,直接倒入栈。
但是应该使得额外空间复杂度达到 O(1),利用修改链表实现,修改后从两侧往中间走,一步步判断,直到不一样 / 某个指针走到空,注意返回时先还原链表:
**注意**,单链表题目准备的时候区分笔试和面试来准备。
核心code:
逆序后半部分链表:
两侧同时往中间比较:
恢复链表并返回:
这部分实在是太复杂了......我还是抽时间自己做一遍吧...
例题2
解:
code
把所有原链表按 value 划分成三个小链表:
合并操作(经过多次化简):
总结:链表题目难点——厘清边界条件的问题!!
若使用额外空间,则解法简单,使用哈希表(map)即可:
code
按要求不使用额外空间
重点:修改节点的存储位置为原节点的下一个,省去了哈希表!
code
下一步就只需要关心复制后的节点的 rand 指针怎么设置:
注意:此时原节点的 rand 位置的 next 指向复制节点的 rand 位置!(结合上方链表图理解)
最后分离出结果链表:使用 res 记录 head.next 即第一个复制节点,随后,如果原链表有下一个节点(next = cur.next.next 且 next != null), 则把新链表的 next 设置为 原节点的下一个位置(next.next)即对应的复制节点;否则返回 null 。