灵茶山艾府 - 基础算法精讲 高频面试题
下一篇: 【灵茶山艾府题单】基础算法精讲 高频面试题(9~16 二叉树 回溯)
01 相向双指针
167.两数之和|| - 输入有序数组
题目 题解
初始双指针指向数组首尾,如果当前和大于target,右指针左移;小于target,左指针右移;等于target,则返回。
15.三数之和
题目 题解
for循环遍历i,j和k参照上面两数之和的方法做。难点在于在取得一组正确的三元组后,怎么移动i、j和k能够保证不重复:右移j直到nums[j]≠nums[j-1];k也同理。
对于这题,灵神还给出了两个优化,即计算nums[i]与i之后最前或最末两个数的和,与target比较。
02 相向双指针
11.盛最多的水
题目 题解
木桶效应,能盛多少水取决于短边。因此每次应该移动最短边,计算移动过程中的最大水量即可。
42.接雨水
题目 题解
【方法一:前后缀分解】
把每一根柱子看作一个木桶,木桶的左壁最大高度称为前缀,木桶的右壁最大高度称为后缀。一个木桶中的水面高度如果超过前缀,水就会从左侧漏出;如果超过后缀,就会从右侧漏出。木桶的盛水量就是前缀与后缀中较小的一个,减去height。首先正向、逆向分别遍历,记录pre_max和suf_max,然后对每个桶计算水量即可。
【方法二:相向双指针】
对于计算每一个桶的容量,实际上并不需要确切地知道它前缀和后缀的值,只需要知道其前后缀中较小的一个的值即可。那么,前缀从height[0]开始,后缀从height[n-1],此时前缀和后缀都是最小的情况了。如果此时前缀小于后缀,说明木桶0必然由前缀决定蓄水量,我们可以直接计算出木桶0的水量,并右移前缀的下标;后缀小于前缀时同理。知道前缀与后缀相遇为止。
03 滑动窗口
常见于子数组子串问题。常见结构为for(右指针){while{移动左指针}}。随着滑动窗口的增大or减小,某个性质(如窗口内数字的和)应该存在单调性,这类题目适用滑动窗口。使用滑动窗口,可以让时间复杂度从O(n^2)下降到O(n)。
209.长度最小的子数组
题目 题解
用一个滑动窗口来维护满足和小于target的最大子数组,滑动窗口的右边界由for遍历得到,左边界通过计算和来确定。右边界每右移一位,就判断当前和是否大于等于target,若大于等于target,则不断右移左边界直到和小于target,并在移动过程中记录最小的子数组长度。
713.乘积小于K的子数组
题目 题解
同样是固定右端点移动左端点,滑动窗口维护满足成绩小于K的最长子数组[left,right]。对于每一个right所形成的[left,right]滑动窗口,[left,left+1…right]、[left+1…right]…[right-1,right]、[right]都是满足要求的子数组,共有right-left+1个。累加每个right对应的满足要求的子数组,得到最终答案。
3.无重复字符的最长子串
固定右端点移动左端点,滑动窗口维护满足无重复字符的的最长子串。使用哈希表来判断是否有重复字符。当右端点新增加的字符为重复字符时,右移左端点直到原窗口中的重复字符消失。记录最长的窗口长度即可。
04 二分查找
利用数组有序的特点,时间复杂度由O(n)下降到O(logn)。
【左右指针的初始化】
不管是[left,right]、[left, right)还是(left,right),核心是要让所有没有判断过的数落在区间内。所以,对于[left,right],left=0,right=nums.size()-1;对于[left, right),left=0,right=nums.size();对于(left,right),left=-1,right=nums.size()。
【while循环的条件】
当区间内没有数时,终止循环;还有数时,继续循环。所以,对于[left,right],while(left<=right);对于[left, right),while(left<right);对于(left,right),while(left+1<right)
【循环内if-else的判断条件】
按照二分查找的目标进行设置即可。如想找到第一个大于等于target的数,那么就应该写if(nums[mid]>=target){} else{}
【左右指针的移动】
和初始化一样,核心是要让移动后的区间只包含没有判断过的数。在每轮循环的if-else中,都判断了mid和target的关系,需要根据left、right区间的开闭来决定更新后的区间是否包含mid
【返回值】
根据if-else条件确定返回值即可。比如if(nums[mid]>=target) right = mid-1; else{left = mid+1}。那么我们可以知道循环中的不变量为nums[right+1]>=target,因此应该return right+1。如果不存在满足条件的数,return的值会等于nums.size()
【二分查找条件的相互转化】
不通过转化也可以直接写出各类查找条件下的二分查找代码,但是如果一题中出现了多个条件,可以使用转化来节省时间。
- 第一个大于等于x的下标: lower_bound(x)
- 第一个大于x的下标:可以转换为
第一个大于等于 x+1 的下标
,lower_bound(x+1) - 最后一个一个小于x的下标:可以转换为
第一个大于等于 x 的下标
的左边位置
, lower_bound(x) - 1; - 最后一个小于等于x的下标:可以转换为
第一个大于等于 x+1 的下标
的左边位置
, lower_bound(x+1) - 1;
34.在排序数组中查找元素的第一个和最后一个位置
题目 题解
写辅助函数lower_bound,查找第一个大于等于x的下标,参考上述方法实现二分查找方法。
int start = lower_bound(nums,target);
int end = lower_bound(nums,target+1)-1;
if(start==nums.size() || nums[start]!=target) return {-1,-1};
return {start,end};
05 二分查找
二分查找不仅可以处理有序数组,通过设置比较条件,也可以处理一些“无序”数组的查找问题。
162.寻找峰值
题目 题解
虽然这道题的数组不是有序的,一般难以想到用二分。但是由于我们只需要找到其中一个峰值,而在这个峰值附近的一小段区域里,数字满足左边递增、右边递减的规律,所以我们仍然可以使用二分,只不过条件从与常数target比较,变成了nums[mid]与nums[mid+1]比较。
153.寻找旋转排序数组的最小值
题目 题解
旋转后的数组分成两段递增数列,后半段的最大值也比前半段的最小值还小。第二段的开头也就是数组中第一个<=nums.back()的值,因此只要以nums[mid]<=nums.back()为条件二分即可。
33.搜索旋转排序数组
题目 题解
主要的难点在于根据target和mid在哪一段,以及target和mid之间的关系进行分类讨论。比较好的方式是先根据mid在哪一段进行分类,再根据target在mid的左侧还是右侧进行分类。
06 反转链表
while(cur!=nullptr){
nex=cur->next;
cur->next=pre;
pre=cur;
cur=nex;
}
206.反转链表
题目 题解
三个指针pre、cur、nex,注意应在每轮循环中操作cur之前更新nex,这样可以避免nex的越界问题。
92.反转链表II
题目 题解
一般的思路是先把需要反转的部分断开,进行反转后在接上原来的首尾结点。这种方法思路简单,但是需要标记的结点比较多,注意细节就能正确写出。灵神的方法是不断开直接进行反转,代码过程简单多了,不愧是灵神!另外,为了避免left=1时成为特例,需要在头结点前增加一个假头结点。
25.K个一组翻转链表
题目 题解
92题中灵神方法的优势在这一题中得到了充分体现。首先统计整个链表的长度,以k为步长循环:在每一轮循环里翻转K个结点,翻转的方式参考92题。除了这种方法外,也可以使用递归的解法,每次只处理k个结点。
07 快慢指针
ListNode* slow=head;
ListNode* fast=head;
while(fast && fast->next){
slow=slow->next;
fast=fast->next->next;
}
876.链表的中间结点
题目 题解
快慢指针均从链表的头开始,慢指针一次走一步,快指针一次走两步。那么长度为奇数时,快指针走到最后一个结点时慢指针走到中间;长度为偶数时,快指针走到结尾nullptr时慢指针走到第二个中间结点。设置循环条件为fast && fast->next,慢指针即为答案。
141.环形链表
题目 题解
如果有环,快慢指针肯定可以相遇,因为快指针相对慢指针的速度是1,不会出现快指针错过慢指针的情况;如果没有环,快指针走到链表结尾,不会与慢指针相遇。
142.环形链表II
题目 题解
首先如141题的方法一样,判断链表中有没有环,如果有环,快慢指针在环中的某一个结点处会相遇。相遇时,慢指针在环内所走过的长度必然是小于环长的(因为快指针相对慢指针的速度是1,假设环长为n,慢指针最多走n-1步就可以和快指针相遇)。假设环外长度为a,相遇时慢指针在环内走过长度为b,剩余环长为c,如图所示:
那么相遇时,慢指针走了(a+b)步,快指针走了a+k(b+c)步。
根据快指针走的距离是慢指针的两倍,可以得到:(a+b) = 2a+2k(b+c) ==> a = c + (k - 1)(b + c)
a = c + (k - 1)(b + c) 这一式子说明:从相遇点到入口的距离加上 k-1 圈的环长,恰好等于从链表头部到入口的距离。也就是说,如果有一个指针head从链表头起始,和慢指针一起一次向前走一步,那么当head走到入口(共a步)时,慢指针也走到了入口处。
因此当如141题一样找到相遇点后,使用head指针和slow一起一次向前一步,直到两者相遇即可。
143.重排链表
题目 题解
题中想要的操作类似于,倒着把链表后半段的结点一个个插入链表前半段中,比如把L_n插入L_0和L_1中,L_n-1插入L_1和L_2中……我们可以给前后两段链表各分配一个指针,每一轮插入完就移动到下一个结点。但是,链表的单向性让我们易于从L_0移动到L_1,却难以从L_n移动到L_n-1。因此,我们可以反转后半段链表,这样就能从L_n移动到L_n-1了。
要反转后半段链表,首先需要找到原链表的中点。参考876题,这使用快慢指针法即可。
参考206题反转后半段链表,也非常简单。
然后,把后半段链表的结点依次插入前半段链表中:注意,无论原链表的长度是奇是偶,后半段链表的尾结点(也就是原链表的中间结点),不需要插入就已经在正确的位置上了。因此循环条件应设置为while(second->next)
08 前后指针
237.删除链表中的节点
题目 题解
一般的删除,需要知道要删除链表的上一个结点。而这道题因为无法访问head,所以也不可能通过遍历的方式找到上一个节点。于是不得不使用一些奇技淫巧:把当前结点的值改为下一个结点的值,然后删掉下一个结点。
19.删除链表的倒数第n个结点
题目 题解
首先,由于删除的可能是头结点,所以需要增加一个假头结点dummyNode。其次,要删除倒数第n个结点,我们需要找到倒数第n+1个结点。而要实现一轮扫描,可以使用前后指针的方法:让前指针先走n步后,后指针和前指针一起一次走一步,当后指针到达链表尾结点的时候,前指针到达倒数第n+1个结点。
83.删除排序链表中的重复元素
题目 题解
头结点不管有没有重复,都可以保留,所以不需要使用假头结点。cur指针从头结点起始,只在需要保留的结点上移动。如果cur的下一个结点的值与cur相同,就删除下一个结点;否则移动cur指针。
82.删除排序链表中的重复元素II
题目 题解
这题与上一题的区别在于,有重复的结点要全部删除,而不是保留一个。所以头结点也可能被删,因此需要使用假头结点。cur指针从假头结点起始,只在需要保留的结点上移动。如果cur->next和cur->next->next的值相同,说明有重复,就应该删除所有重复:记录下目前要删的这批重复结点的值,只要当前cur的下一个结点的值仍然是该值,就删除下一个结点;否则移动cur指针。这题逻辑有点复杂,循环条件也要避免写错。