一.扫描线
什么是扫描线?
拿数飞机举例
思路一:暴力扫描
遍历每个时刻,检测每个时刻有多少个飞机
思路二:扫描线
不需要检测每一时刻,只需要检测起点或者终点的位置(交点变化的位置只有起点或者终点)
扫描线一般运用在图形上面,它和它的字面意思十分相似,就是一条线在整个图上扫来扫去,它一般被用来解决图形面积,周长等问题
总结
一般两种解法:使用 pq(heap) 或者将 start/end 分开进行扫描线
考点:sort常用comparator的写法,判断interval merge的边界条件
Arrays.sort(intervals,(a,b) -> a[0] - b[0]);
Arrays.sort(intervals,new Comparator<Interval>() {
@override
public int compare(Interval a,Interval b) {
return a.start - b.start;
}
}
PriorityQueue<int[]> heap = new PriorityQueue<>((a,b) -> a[1] - b[1]);
PriorityQueue<Interval> heap = new PriorityQueue<Interval> (intervals.length,new Comparator<Interval>() {
@override
public int compare(Interval a,Interval b) {
return a.end - b.end;
}
}
二.滑动窗口
什么是滑动窗口?
滑动窗口算法可以用以解决数组/字符串的子元素问题,它可以将嵌套的循环问题,转换为单循环问题,降低时间复杂度
如何识别滑动窗口?
1.连续的元素,比如string,subarray,LinkedList
2.min,max,longest,shortest,key word
滑动窗口基本类型
1.Easy,size fixed
窗口长度确定,比如max sum of size = k
2.Median,size可变,单限制条件
比如找到subarray sum比目标值大一点点
3.Median,size可变,双限制条件
比如longest substring with distinct character
4.Hard,size fixed,单限制条件
比如sliding window maximum,考察单调队列
套路
滑动窗口几乎都长得一样:给你一个数组,要求找到一个连续的子数组,满足某个条件
设左右两个指针为L和R,题目求的是最长,那么每一步都是:求L不动的情况下,R能移动到多远,满足条件越远越好,实在不行我们试试下一个L
每道题不一样的地方基本只在于怎么维护是否满足条件,可以分为3类
①维护子数组的和
②维护set / map / bool 数组
③维护一个有序数组 / 堆
模板
//本质仍然是two pointer,左边left,右边iterator(i)
public in lengthOfLongestSubstringKDistinct(String s,int k) {
Map<Character,Integer> map = new HashMap<>();
int left = 0,res = 0;
for (int i = 0; i < s.length(); i++) {
char cur = s.charAt(i);
map.put(cur,map.getOrDefault(cur,0) + 1); //当前遍历的 i 进入窗口
while (map.size() > k) {
char c = s.charAt(left);
map.put(c,map.get(c) - 1);
if (map.get(c) == 0) map.remove(c);
left++; //当窗口不符合条件时 left 持续退出窗口
}
res = Math.max(res,i - left + 1); //现在窗口valid了,我们计算结果
}
return res;
}
总结
1.滑动窗口套路模板时间复杂度一般为O(n)
2.一般string使用map作为window,如果说明了只有小写字母也可以用int[26]
3.多重限制条件的压轴题需要考虑是否为单调队列
4.字母类还可以暴力尝试26个字母,比如1个unique,2个unique,然后内部模板
5.Exact(k)可以转换为atMost(k) - atMost(k - 1)
三.前缀和
前缀和是算法题中比较实用的一种技巧,当算法题的背景是整数型数组且出现 “ 子数组和 ” 或者 “ 连续的子数组 ” 既可以考虑使用前缀和来求解会得到不错的效果
Prifix sum相关考题从频率最高的17题可以发现常考的知识点有:
1.2sum系列
2.rangeSum
3.sliding window
4.monotonic queue
5.少量扫描线
6.rolling hash内部也使用了prefix sum
2sum系列
2sum系列在整个leetcode中大概有50多道,基本思路是求两数字之和等于target,两数之差,两数的余数相同,或者两数和为0(如果只有1,-1则为二者数量相同)
public int[] twoSum(int[] nums, int target) {
Map<Integer,Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int diff = target - nums[i];
if (map.containsKey(diff)) return new int[]{map.get(diff),i};
map.put(nums[i], i);
}
return null;
}
总结
1. 2sum系列
2. rangeSum
3. sliding window
4. monotonic queue
考察最多的prefix还是two sum (和,差,余数,0),如果是range sum的话,大部分情况会提升到2维。对于sliding window还是单调队列,取决于是否有负数。滑动窗口的左缩进是不论subarray sum大小直接缩进,对于全正数是ok的,因为缩进一定会让sum减小,有负数的情况就不可以这样,需要根据subarray sum减小来改变左缩进,也就是单调队列保持最小起点,因为差值sum[i] - sum[queue.peekFirst()] 就是subarray的和,左缩进不一定是一步一步走的,是根据总window sum的减小来走的,会走到下一个最小的起点
很多题目包括greedy比如gas station类似的题目,也使用了prefix sum的思路来对一路上的gas求和,这里的prefix sum就比较广义了,题目过多不再详述
四.二分查找法
二分查找法主要是解决在“一堆数中找出指定的数”这类问题
想要用二分查找法,这“一堆数”必须有以下特征:
存储在数组中
有序排列
所以如果是链表存储的,就无法在其上应用二分查找法了
模板
start = 0;end = len - 1;
while(start + 1 < end){
mid = start + (end - start)/2;
if(nums[mid]<target)
start = mid;
else
end = mid;
}
start = 0;end = len;
while(start < end){
mid = start + (end - start)/2;
if(nums[mid]<target)
start = mid + 1;
else
end = mid;
}
start = 0;end = len - 1;
while(start <= end){
mid = start + (end - start)/2;
if(nums[mid] < target)
start = mid + 1;
else
end = mid - 1;
}
五.分治法
设计思想:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之
分治策略:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解
1.把大问题分成2个或者2个以上的小问题
2.解决小问题并且把结果合并
3.hybrid algorithm 实际代码中我们不会等base case小到1或者0,当relative small的时候我们就会去计算,比如quick sort,size < 10我们就会去用insertion sort做而不是继续divide
4.redundant call需要避免,Fibnacci就是一个例子,虽然是divide and conquer但是有很多的重复call,所以这里memorization
步骤
1.分解原问题为若干子问题,这些子问题是原问题的规模最小的实例
2.解决这些子问题,递归地求解这些子问题,当子问题的规模足够小,就可以直接求解
3.合并这些子问题的解成原问题的解
优势在可以很效率地解决一些困难问题,一般分解为2或4个问题,最后的时间复杂度和logn有关
总结
这是一个很万能的解法,但是很多时候并不一定好用(比如有pq的情况下,面试可能pq就给过了,dc的话知识follow up要小心第一时间把自己给绕进去,给一个workable的solution是最重要的)。另外常常用于nlogn的优化,一般给avg(N)并且可以根据pivot优化,quickSelect
六.贪心算法
基本概念
所谓贪心算法是指,在对问题求解时,总是做出在当前看来最好的选择。也就是说,不从整体最优加以考虑,他所做出的仅是在某种意义上的局部最优解
贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,至于当前状态有关
所以对所采用的贪心策略一定要仔细分析其是否满足无后效性(无后效性是指如果在某个阶段上过程的状态已知,则从此阶段以后过程的发展变化仅与此阶段的状态有关,而与过程在此阶段以前的阶段所经历过的状态无关。利用动态规划方法求解多阶段决策过程问题,过程的状态必须具备无后效性)
基本思路
1.建立数学模型来描述问题
2.把求解的问题分成若干个子问题
3.对每一子问题求解,得到子问题的局部最优解
4.把子问题的解局部最优解合成原来解问题的一个解
适用的问题
贪心策略适用的前提是:局部最优策略能导致产生全局最优解
实际上,贪心算法适用的情况很少。一般,对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析,就可做出判断
与动态规划对比
1.基本思想
贪心算法:并不从整体最优上加以考虑,它所做的选择只是在某种意义上的局部最优解
动态规划:将待求解的问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解
2.基本要素
贪心算法:最优子结构性质和贪心选择性质
动态规划:最优子结构性质和重叠子问题性质
3.区别
共同点:两者都具有最优子结构的性质
不同点:动态规划算法中,每步所做的选择往往依赖于相关子问题的解,因而只有在解出相关子问题时才能做出选择。而贪心算法,仅在当前状态下做出最好选择,即局部最优选择,然后再去解做出这个选择后产生的相应的子问题
七.回溯算法
result = []
def backtrack(路径,选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径,选择列表)
撤销选择
八.双指针
所谓双指针,指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个相同方向或者相反方向的指针进行扫描,从而达到相应的目的
换言之,双指针法充分使用了数组有序这一特征,从而在某些情况下能够简化一些运算
注:这里的指针,并非专指c中指针的概念,而是指索引,游标或指针,可迭代对象等
给定一个有序递增数组,在数组中找到满足条件的两个数,使得这两个数的和为某一给定的值。如果有多对数,只输出一对即可
常见的题型有哪些?
①快慢指针(两个指针步长不同)
②左右端点指针(两个指针分别指向头尾,并往中间移动,步长不确定)
③固定间距指针(两个指针间距相同,步长相同)
双指针一直是程序员面试中的一个必须准备的主题,面试中双指针出现的次数比较多,主要由于在工作中指针经常用到,指针问题能够直接反映面试者的基础知识,代码能力和思维逻辑,因此双指针的问题必须掌握
解决双指针问题的三种常用思想:
①左右指针:需要两个指针,一个指向开头,一个指向末尾,然后向中间遍历,直到满足条件或者两个指针相遇
②快慢指针:需要两个指针,开始都指向开头,根据条件不同,快指针走得快,慢指针走的慢,直到满足条件或者快指针走到结尾
③后序指针:常规指针操作是从前向后遍历,对于合并和替换类型题,防止之前的数据被覆盖,双指针需从后向前遍历
记忆口诀:左右指针中间夹,快慢指针走到头,后序指针往回走