快速选择算法的 Partition 的实质:
快速选择/快速排序中的 partition 是 可左可右 的partition,也就是说,对于nums[i] == pivot 时,这个数字既可以放在左边,也可以放在右边。
为什么这样划分数组呢?
原因是为了避免出现类似 [1,1,1,1,1,1] 的数组中的元素,全部被分到一边的情况。我们让 nums[i] == pivot 的情况既不属于左边也不属于右边,这样就能够让 partition 之后的结果稍微平衡一些。
如果 quick select / quick sort 写成了nums[i] < pivot 在左侧,nums[i] >= pivot 在右侧这种形式,就会导致划分不平均,从而导致错误或者超时。
为什么问题《partition array》不能使用同样的代码?
对于问题《partition array》来说,题目的要求是将数组划分为两个部分,一部分满足一个条件,另外一部分不满足这个条件,所以可以严格的把 nums[i] < pivot 放在左侧,把 nums[i] >= pivot 放在右侧,这样子做完一次 partition 之后,就能够将这两部分分开。
总结
简单的说就是,quick select 和 quick sort 的 partition 目标不是将数组 严格的按照 nums[i] < pivot 和nums[i] >= pivot 去拆分开,而是只要能够让左半部分 <= 右半部分即可。这样子 nums[i] == pivot 放在哪儿都无所谓,两边都可以放。
题目
- 第k大元素
- 颜色分类🌈
算法中,常见的时间复杂度有:
复杂度 | 可能对应的语法 | 备注 |
---|---|---|
O(1) | 位运算 | 常数级复杂度,一般面试中不会有 |
O(logn) | 二分法,倍增法,快速幂算法,辗转相除法 | |
O(n) | 枚举法,双指针算法,单调栈算法,KMP算法,Rabin Karp,Manacher's Algorithm | 又称作线性时间复杂度 |
O(nlogn) | 快速排序,归并排序,堆排序 | |
O(n^2) | 枚举法,动态规划,Dijkstra | |
O(n^3) | 枚举法,动态规划,Floyd | |
O(2^n) | 与组合有关的搜索问题 | |
O(n!) | 与排列有关的搜索问题 |
在面试中,经常会涉及到时间复杂度的计算。当你在对于一个问题给出一种解法之后,面试官常会进一步询问,是否有更优的方法。此时就是在问你是否有时间复杂度更小的方法(有的时候也要考虑空间复杂度更小的方法),这个时候需要你对常用的数据结构操作和算法的时间复杂度有清晰的认识,从而分析出可优化的部分,给出更优的算法。
例如,给定一个已经排序的数组,现在有多次询问,每次询问一个数字是否在这个数组中,返回True or False.
- 方法1: 每次扫描一遍数组,查看是否存在。
这个方法,每次查询的时间复杂度是: O(n)。
-
方法2:由于已经有序,可以使用二分查找的方法。
这个方法,每次查询的时间复杂度是: O(logn)。 -
方法3:将数组中的数存入Hashset。
这个方法,每次查询的时间复杂度是: O(1)。
可以看到,上述的三种方法是递进的,时间复杂度越来越小。
在面试中还有很多常见常用的方法,他们的时间复杂度并不是固定的,都需要掌握其时间复杂度的分析,要能够根据算法过程自己推算出时间复杂度。
太深的递归会内存溢出
首先,函数本身也是在内存中占空间的,主要用于存储传递的参数,以及调用代码的返回地址。
函数的调用,会在内存的栈空间中开辟新空间,来存放子函数。递归函数更是会不断占用栈空间,例如该阶乘函数,展开到最后n=1
时,内存中会存在factorial(100), factorial(99), factorial(98) ... factorial(1)
这些函数,它们从栈底向栈顶方向不断扩展。
当递归过深时,栈空间会被耗尽,这时就无法开辟新的函数,会报出stack overflow
这样的错误。
所以,在考虑空间复杂度时,递归函数的深度也是要考虑进去的。
Follow up:
尾递归:若递归函数中,递归调用是整个函数体中最后的语句,且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。(上例factorial函数满足前者,但不满足后者,故不是尾递归函数)
尾递归函数的特点是:在递归展开后该函数不再做任何操作,这意味着该函数可以不等子函数执行完,自己直接销毁,这样就不再占用内存。一个递归深度O(n)的尾递归函数,可以做到只占用O(1)空间。这极大的优化了栈空间的利用。
但要注意,这种内存优化是由编译器决定是否要采取的,不过大多数现代的编译器会利用这种特点自动生成优化的代码。在实际工作当中,尽量写尾递归函数,是很好的习惯。
而在算法题当中,计算空间复杂度时,建议还是老老实实地算空间复杂度了,尾递归这种优化提一下也是可以,但别太在意。
二分法的四重境界
二分法模板
特点:要求数组有序,不一定是升序或者降序
时间复杂度:O(n)
空间复杂度:O(1)
public class Solution {
/**
* @param A an integer array sorted in ascending order
* @param target an integer
* @return an integer
*/
public int findPosition(int[] nums, int target) {
if (nums == null || nums.length == 0) {
return -1;
}
int start = 0, end = nums.length - 1;
// 要点1: start + 1 < end ;
// 避免在 last target 满足某条件的最后一个数的问题上死循环
while (start + 1 < end) {
// 要点2:start + (end - start) / 2
int mid = start + (end - start) / 2;
// 要点3:=, <, > 分开讨论,mid 不+1也不-1
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
start = mid;
// or start = mid + 1;
} else {
end = mid;
// or end = mid -1;
}
}
// 要点4: 循环结束后,单独处理start和end
if (nums[start] == target) {
return start;
}
if (nums[end] == target) {
return end;
}
return -1;
}
}
如果你之前写过二分的题目,你会发现在二分问题中,最常见的错误就是死循环。而这个模版一定不会出现死循环。为什么呢?
因为我们这边使用了start + 1 < end, 而不是start < end 或者 start <= end
二分法的模板中,整个程序架构分为两个部分:
通过 while 循环,将区间范围从 n 缩小到 2 (只有 start 和 end 两个点)。
在 start 和 end 中判断是否有解。
而普通的start < end 或者 start <= end 在寻找目标最后一次出现的位置的时候,可能出现死循环。
有同学可能会问为什么明明可以 start = mid + 1 偏偏要写成 start = mid?
大部分时候,mid 是可以 +1 和 -1 的。在一些特殊情况下,比如寻找目标的最后一次出现的位置时,当 target 与 nums[mid] 相等的时候,是不能够使用 mid + 1 或者 mid - 1 的。因为会导致漏掉解。那么为了节省脑力,统一写成 start = mid / end = mid 并不会造成任何解的丢失,并且也不会损失效率——log(n) 和 log(n+1) 没有区别。
通过时间复杂度判断算法。我们知道二分答案的时间复杂度是O(logn)的。所以当你发现有一个问题,用O(N)的时间复杂度非常好做,或者面试官说:“你给我优化一下这个算法。”那么你优化的思路就是优化到O(logn),也就是尝试使用二分去做,这就是二分算法的判断条件
题目:
457. 经典二分查找问题
458. 目标最后位置
459.排序数组中最接近元素
585. 山脉序列中的最大值
宽度优先搜索
SFPA算法 = BFS + Heap; 可以考
正确答案:A B E F
先序遍历通常使用递归方式来实现,即使使用非递归方式,也是借助栈来实现的,所以并不适合BFS,而层次遍历因为是一层一层的遍历,所以是BFS十分擅长的;边长一致的图是简单图,所以可以用BFS,因此B可以,因为BFS只适用于简单图,所以C不可以;矩阵连通块也是BFS可以处理的问题,求出最大块只需要维护一个最大值即可;选项F属于求所有方案问题,因此可以用BFS来处理,但是并不是唯一的解决方式。
万能解法:深度优先搜索,深搜的目的在于寻找所有路径