前言:
算法训练系列是做《代码随想录》一刷,个人的学习笔记和详细的解题思路,总共会有60篇博客来记录,计划用60天的时间刷完。
内容包括了面试常见的10类题目,分别是:数组,链表,哈希表,字符串,栈与队列,二叉树,回溯算法,贪心算法,动态规划,单调栈。
博客记录结构上分为 思路,代码实现,复杂度分析,思考和收获,四个方面。
如果这个系列的博客可以帮助到读者,就是我最大的开心啦,一起LeetCode一起进步呀;)
目录
LeetCode435. 无重叠区间
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
LeetCode763. 划分字母区间
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
Leetcode 56. 合并区间
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
LeetCode435. 无重叠区间
链接:435. 无重叠区间 - 力扣(LeetCode)
1. 思路
**相信很多同学看到这道题目都冥冥之中感觉要排序,但是究竟是按照右边界排序,还是按照左边界排序呢?**这其实是一个难点!
排序和遍历顺序?
- 按照右边界排序,就要从左向右遍历,因为右边界越小越好,只要右边界越小,留给下一个区间的空间就越大,所以从左向右遍历,优先选右边界小的;
- 按照左边界排序,就要从右向左遍历,因为左边界数值越大越好(越靠右),这样就给前一个区间的空间就越大,所以可以从右向左遍历;
- 如果按照左边界排序,还从左向右遍历的话,其实也可以,逻辑会有所不同;
题意就是求非交叉区间的最大个数!
一些同学做这道题目可能真的去模拟去重复区间的行为,这是比较麻烦的,还要去删除区间。题目只是要求移除区间的个数,没有必要去真实的模拟删除区间!
按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了。此时问题就是要求非交叉区间的最大个数。
贪心算法的思想
右边界排序之后:
- 局部最优:优先选右边界小的区间,所以从左向右遍历,留给下一个区间的空间大一些,从而尽量避免交叉;
- 全局最优:选取最多的非交叉区间。
- 局部最优推出全局最优,试试贪心!
这里记录非交叉区间的个数还是有技巧的,如图:
区间,1,2,3,4,5,6都按照右边界排好序。
每次取非交叉区间的时候,都是可右边界最小的来做分割点(这样留给下一个区间的空间就越大),所以第一条分割线就是区间1结束的位置;
接下来就是找大于区间1结束位置的区间,是从区间4开始。那有同学问了为什么不从区间5开始?别忘已经是按照右边界排序的了;
区间4结束之后,在找到区间6,所以一共记录非交叉区间的个数是三个;总共区间个数为6,减去非交叉区间的个数3。移除区间的最小数量就是3。
2. 代码实现
# 贪心算法
# time:O(NlogN);space:O(n)
class Solution(object):
def eraseOverlapIntervals(self, intervals):
"""
:type intervals: List[List[int]]
:rtype: int
"""
# 细节:当x[1]相同的时候,随便排序都可以,不影响结果
intervals.sort(key = lambda x: x[1])
# 记录非交叉区间的个数
count = 1
# 记录区间分割点
rightLine = intervals[0][1]
for i in range(1,len(intervals)):
if intervals[i][0] >= rightLine:
count += 1
rightLine = intervals[i][1]
return len(intervals)-count
3. 复杂度分析
- 时间复杂度:O(nlog n) ,有一个快排;
- 空间复杂度:O(n),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间;
4. 思考与收获
-
本题难度级别可以算是hard级别的!
总结如下难点:
- 难点一:一看题就有感觉需要排序,但究竟怎么排序,按左边界排还是右边界排。
- 难点二:排完序之后如何遍历,如果没有分析好遍历顺序,那么排序就没有意义了。
- 难点三:直接求重复的区间是复杂的,转而求最大非重复区间个数。
- 难点四:求最大非重复区间个数时,需要一个分割点来做标记。
这四个难点都不好想,但任何一个没想到位,这道题就解不了;
-
贪心就是这样,代码有时候很简单(不是指代码短,而是逻辑简单),但想法是真的难!这和动态规划还不一样,动规的代码有个递推公式,可能就看不懂了,而贪心往往是直白的代码,但想法读不懂,哈哈;
-
本题其实和**452.用最少数量的箭引爆气球 (opens new window)** 非常像,弓箭的数量就相当于是非交叉区间的数量,只要把弓箭那道题目代码里射爆气球的判断条件加个等号(认为[0,1][1,2]不是相邻区间),然后用总区间数减去弓箭数量 就是要移除的区间数量了。
Reference:代码随想录 (programmercarl.com)
本题学习时间:40分钟。
LeetCode763. 划分字母区间
链接:763. 划分字母区间 - 力扣(LeetCode)
1. 思路
一想到分割字符串就想到了回溯,但本题其实不用回溯去暴力搜索;题目要求同一字母最多出现在一个片段中,那么如何把同一个字母的都圈在同一个区间里呢?
如果没有接触过这种题目的话,还挺有难度的。
在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。
可以分为如下两步:
- 统计每一个字符最后出现的位置
- 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点
2. 代码实现
# 贪心算法
# time:O(N);space:O(1)
class Solution(object):
def partitionLabels(self, s):
"""
:type s: str
:rtype: List[int]
"""
# i为字符,hash[i]为字符出现的最后位置
record = [0]*26
result = []
left = 0
right = 0
# 统计每一个字符最后出现的位置
for i in range(len(s)):
record[ord(s[i])-ord("a")] = i
for i in range(len(s)):
# 找到字符出现的最远边界
right = max(right,record[ord(s[i])-ord("a")])
if i == right:
result.append(right-left+1)
left = i+1
return result
3. 复杂度分析
-
时间复杂度:O(N)
其中N为字符串长度的大小,需要遍历字符串两遍;
-
空间复杂度:O(1)
使用的hash数组的大小是固定的;
4. 思考与收获
-
这道题目leetcode标记为贪心算法,说实话,我没有感受到贪心,找不出局部最优推出全局最优的过程。就是用最远出现距离模拟了圈字符的行为,但这道题目的思路是很巧妙的!
-
(二刷再看)这里提供一种与**452.用最少数量的箭引爆气球 (opens new window)、435.无重叠区间 (opens new window)相同的思路。统计字符串中所有字符的起始和结束位置,记录这些区间(实际上也就是435.无重叠区间 (opens new window)**题目里的输入),将区间按左边界从小到大排序,找到边界将区间划分成组,互不重叠。找到的边界就是答案。
class Solution { public: static bool cmp(vector<int> &a, vector<int> &b) { return a[0] < b[0]; } // 记录每个字母出现的区间 vector<vector<int>> countLabels(string s) { vector<vector<int>> hash(26, vector<int>(2, INT_MIN)); vector<vector<int>> hash_filter; for (int i = 0; i < s.size(); ++i) { if (hash[s[i] - 'a'][0] == INT_MIN) { hash[s[i] - 'a'][0] = i; } hash[s[i] - 'a'][1] = i; } // 去除字符串中未出现的字母所占用区间 for (int i = 0; i < hash.size(); ++i) { if (hash[i][0] != INT_MIN) { hash_filter.push_back(hash[i]); } } return hash_filter; } vector<int> partitionLabels(string s) { vector<int> res; // 这一步得到的 hash 即为无重叠区间题意中的输入样例格式:区间列表 // 只不过现在我们要求的是区间分割点 vector<vector<int>> hash = countLabels(s); // 按照左边界从小到大排序 sort(hash.begin(), hash.end(), cmp); // 记录最大右边界 int rightBoard = hash[0][1]; int leftBoard = 0; for (int i = 1; i < hash.size(); ++i) { // 由于字符串一定能分割,因此, // 一旦下一区间左边界大于当前右边界,即可认为出现分割点 if (hash[i][0] > rightBoard) { res.push_back(rightBoard - leftBoard + 1); leftBoard = hash[i][0]; } rightBoard = max(rightBoard, hash[i][1]); } // 最右端 res.push_back(rightBoard - leftBoard + 1); return res; } };
Reference:代码随想录 (programmercarl.com)
本题学习时间:40分钟。
Leetcode 56. 合并区间
链接:56. 合并区间 - 力扣(LeetCode)
1. 思路
大家应该都感觉到了,此题一定要排序,那么按照左边界排序,还是右边界排序呢?都可以!
那么我按照左边界排序:
- 排序之后局部最优:每次合并都取最大的右边界,这样就可以合并更多的区间了;
- 整体最优:合并所有重叠的区间。
- 局部最优可以推出全局最优,找不出反例,试试贪心。
如何判断重复?
那有同学问了,本来不就应该合并最大右边界么,这和贪心有啥关系?有时候贪心就是常识!哈哈
按照左边界从小到大排序之后,如果 intervals[i][0] < intervals[i - 1][1]
即intervals[i]左边界 < intervals[i - 1]右边界,则一定有重复,因为intervals[i]的左边界一定是大于等于intervals[i - 1]的左边界。
即:intervals[i]的左边界在intervals[i - 1]左边界和右边界的范围内,那么一定有重复!
这么说有点抽象,看图:(注意图中区间都是按照左边界排序之后了)
如何模拟合并区间呢?
其实就是用合并区间后左边界和右边界,作为一个新的区间,加入到result数组里就可以了。如果没有合并就把原区间加入到result数组。
2. 代码实现
# 贪心算法
# time:O(NlogN);space:O(N)
class Solution(object):
def merge(self, intervals):
"""
:type intervals: List[List[int]]
:rtype: List[List[int]]
"""
# 按照区间左边界从小到大排序
intervals.sort(key=lambda x: x[0])
# 先初始化result的第一个元素为第一个intervals里面的区间
result= [intervals[0]]
# 从第二个区间开始遍历intervals
for i in range(1,len(intervals)):
# 如果当前interval的左边界小于或者等于
# result最后一个元素的右边界,说明有重叠区间
if intervals[i][0] <= result[-1][1]:
# 合并区间,左边界因为排序了,所以不变
# 更新右边界,为以前值和现在的值的最大值
result[-1][1] = max(result[-1][1],intervals[i][1])
else:
# 如果最后一个区间没有合并,将其加入result
result.append(intervals[i])
return result
3. 复杂度分析
-
时间复杂度:O(nlog n)
其中N为intervals数组的长度,有一个快排;
-
空间复杂度:O(n)
有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间;
4. 思考与收获
-
对于贪心算法,很多同学都是:如果能凭常识直接做出来,就会感觉不到自己用了贪心, 一旦第一直觉想不出来, 可能就一直想不出来了。跟着「代码随想录」刷题的录友应该感受过,贪心难起来,真的难。那应该怎么办呢?
正如我贪心系列开篇词**关于贪心算法,你该了解这些! (opens new window)**中讲解的一样,贪心本来就没有套路,也没有框架,所以各种常规解法需要多接触多练习,自然而然才会想到。「代码随想录」会把贪心常见的经典题目覆盖到,大家只要认真学习打卡就可以了。
Reference:代码随想录 (programmercarl.com)
本题学习时间:40分钟。
本篇学习时间约为2小时,总结字数为5000+;本篇是贪心算法的重叠区间专题,再加上Day35的最后一道题:用最少数量的箭引爆气球,这四道题是重叠区间的经典题目,都属于那种看起来好复杂,但一看贪心解法,惊呼:这么巧妙! 做过了也就会了,没做过就很难想出来。(求推荐!)