一、排序算法总结
排序算法的稳定性
同样值的个体之间,如果不因为排序而改变相对次序,就是这个排序是有稳定性的;否则就没有。
- 不具备稳定性的排序:
选择排序、快速排序、堆排序 - 具备稳定性的排序:
冒泡排序、插入排序、归并排序、一切桶排序思想下的排序
基于比较的排序算法
排序算法 | 时间复杂度 | 空间复杂度 | 是否具有稳定性 |
---|---|---|---|
冒泡排序 | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 是 |
选择排序 | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 否 |
插入排序 | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 是 |
归并排序 | O ( N ∗ l o g N ) O(N*logN) O(N∗logN) | O ( N ) O(N) O(N) | 是 |
快速排序 | O ( N ∗ l o g N ) O(N*logN) O(N∗logN) | O ( l o g N ) O(logN) O(logN) | 否 |
堆排序 | O ( N ∗ l o g N ) O(N*logN) O(N∗logN) | O ( l o g N ) O(logN) O(logN) | 否 |
- 一般会选择快速排序,因为虽然归并排序、快速排序、堆排序的时间复杂度都是 O ( N ∗ l o g N ) O(N*logN) O(N∗logN),但实际实验指出快速排序是最快的。
- 当需要保持稳定性时,选择归并排序,其劣势是空间复杂度高
基于比较的排序,目前没有找到时间复杂度 O ( N ∗ l o g N ) O (N* logN) O(N∗logN),额外空间复杂度 O ( 1 ) O(1) O(1),又稳定的排序。
常见的坑
- 归并排序的额外空间复杂度可以变成O(1),但是非常难,不需要掌握,有兴趣可以搜 “归并排序内部缓存法”
- “原地归并排序” 的帖子都是垃圾,会让归并排序的时间复杂度变成O(N^2)
- 快速排序可以做到稳定性问题,但是非常难,不需要掌握,可以搜 “01 .stable sort’
- 所有的改进都不重要,因为目前没有找到时间复杂度0 (N*logN),额外空间复杂度0(1),又稳定的排序。
- 有一道题目,是奇数放在数组左边,偶数放在数组右边,还要求原始的相对次序不变、时间复杂度
O
(
N
∗
l
o
g
N
)
O(N*logN)
O(N∗logN)以下、空间复杂度
O
(
l
o
g
N
)
O(logN)
O(logN)以下,碰到这个问题,可以怼面试官。
- 经典快排的partition操作无法保证稳定性,但是经典快排的partition是0/1标准,和奇偶问题是同一种调整策略,所以经典快排无法解决该问题,面试官教教我😆
工程上对排序的改进
- 充分利用
O
(
N
∗
l
o
g
N
)
O(N*logN)
O(N∗logN)和
O
(
N
2
)
O(N^2)
O(N2)排序各自的优势
- 根据数据的规模选择不同的排序,比如插入排序在规模比较低的情况下,排序效率是比快排及归并排序高不少的。
- 高级语言底层库中实现的排序(比如java的
Array.sort()
)代码量通常是非常大的,因为它把基本的排序算法都实现了,根据数据的不同,选择不同的排序策略。
- 稳定性的考虑
Array.sort()
,底层对于基础类型会选择时间复杂度更好一点的排序比如快排,但对于自定义类型的排序,java不知道要不要保持稳定性,所以一般会选择稳定的排序,比如归并排序。
二、哈希表与有序表
2.1 哈希表的简单介绍
- 哈希表在使用层面上可以理解为一种集合结构
- 如果只有key,没有伴随数据value,可以使用HashSet结构 (C++中叫UnOrderedSet)
- 如果既有key,又有伴随数据value,可以使用HashMap结构 (C++中叫Un0r der edMap)
- 有无伴随数据,是HashMap和HashSet唯一的区别, 底层的实际结构是一致的
- 使用哈希表增(put)、删(remove)、改(put)和查(get)的操作,可以认为时间复杂度为 O ( 1 ) O(1) O(1),但是常数时间比较大
- 放入哈希表的东西,如果是基础类型,内部按值传递,内存占用就是这个东西本身的大小
- 放入哈希表的东西,如果不是基础类型,内部按引用传递,内存占用是这个东西内存地址的大小
2.2 有序表的简单介绍
- 有序表在使用层面上可以理解为一种集合结构
- 如果只有key,没有伴随数据value,可以使用TreeSet结构 (C++中叫OrderedSet)
- 如果既有key,又有伴随数据value,可以使用TreeMap结构 (C++中叫OrderedMap)
- 有无伴随数据,是TreeSet和TreeMap唯一的区别,底层的实际结构是一回事
- 有序表和哈希表的区别是,有序表把key按照顺序组织起来,而哈希表完全不组织
- 红黑树、AVL树、size-balance-tree和跳表等都属于有序表结构,只是底层具体实现不同
- 放入哈希表的东西,如果是基础类型,内部按值传递,内存占用就是这个东西的大小.
- 放入哈希表的东西,如果不是基础类型,必须提供比较器,内部按引用传递,内存占用是这个东西内存地址的大小
- 不管是什么底层具体实现,只要是有序表,都有以下固定的基本功能和固定的时间复杂度
有序表的固定操作
void put(K key, V value)
:将一个 (key, value) 记录加入到表中,或者将 key 的记录
更新成 value。V get(K key)
:根据给定的 key,查询 value 并返回。void remove(K key)
:移除 key 的记录。.boolean containsKey(K key)
:询问是否有关于 key 的记录。K firstKey()
:返回所有键值的排序结果中,最左 (最小) 的那个。K lastKey()
:返回所有键值的排序结果中,最右 (最大) 的那个。K floorKey(K key)
:如果表中存入过key,返回key;否则返回所有键值的排序结果中,key的前一个。K ceilingKey (K key)
:如果表中存入过key,返回key;否则返回所有键值的排序结果中,key的后一个。
以上所有操作时间复杂度都是 O ( l o g N ) O(logN) O(logN), N N N为有序表含有的记录数
三、回文链表判断
【题目】给定一个单链表的头节点head,请判断该链表是否为回文结构。
【例子】 1->2->1, 返回true; 1->2->2->1, 返回true; 15->6->15,返回true;1->2->3, 返回false。
【例子】如果链表长度为N,时间复杂度达到0 (N),额外空间复杂度达到0(1)。
方法一、栈
利用栈来判断,需要 N N N额外空间
public static boolean isPalindrome1(Node head) {
Stack<Node> stack = new Stack<>();
Node cur = head;
while (cur != null) {
stack.push(cur);
cur = cur.next;
}
cur = head;
while (cur != null){
if (cur.value != stack.pop().value) {
return false;
}
cur = cur.next;
}
return true;
}
方法二、快慢指针寻找中点
思路:利用快慢指针寻找链表中点,然后将链表的右半部分依次存储到栈中,通过双指针比较栈和链表左半部分元素是否相同。
需要 N / 2 N/2 N/2额外空间
public static boolean isPalindrome2(Node head) {
if (head == null || head.next == null) {
return true;
}
Node right = head.next;
Node cur = head;
// right将会指向中间节点
while (cur.next != null && cur.next.next != null) {
right = right.next;
cur = cur.next.next;
}
Stack<Node> stack = new Stack<>();
while (right != null) {
stack.push(right);
right = right.next;
}
while (!stack.isEmpty()) {
if (head.value != stack.pop().value) {
return false;
}
head = head.next;
}
return true;
}
方法三、快慢指针,双向比较链表
思路:利用快慢指针寻找链表中点,然后将链表右半部分依次反转,中间节点指向null
,然后头部和尾部指针依次比较,比较结束后,需要将链表还原。
需要 O ( 1 ) O(1) O(1)额外空间
public static boolean isPalindrome3(Node head) {
if (head == null || head.next == null) {
return true;
}
Node n1 = head;
Node n2 = head;
// 寻找中点, n1->mid, n2->end
while (n2.next != null && n1.next.next != null) {
n1 = n1.next;
n2 = n2.next.next;
}
// n2 -> 右半部分第一个点
n2 = n1.next;
// mid.next = null
n1.next = null;
Node n3 = null;
// 右部分反转
while (n2 != null) {
// n3 -> save next node
n3 = n2.next;
n2.next = n1;
n1 = n2;
n2 = n3;
}
// 保存尾节点
n3 = n1;
// 头节点
n2 = head;
boolean res = true;
while (n1 != null && n2 != null) {
if (n1.value != n2.value) {
res = false;
break;
}
n1 = n1.next;
n2 = n2.next;
}
// 还原链表
n1 = n3.next;
n3.next = null;
while (n1 != null) {
n2 = n1.next;
n1.next = n3;
n3 = n1;
n1 = n2;
}
return res;
}
注意点
利用快慢指针找到的中点可能不是真正的中点
-
对于大小为奇数的链表遍历结束后慢指针指向的就是中点
-
对于大小为偶数的链表遍历结束后慢指针指向的是左中点(该链表没有真正的中点)
-
要熟练掌握获取不同中点的方法(左中点、中点、右中点)