第 17 关 | 经典刷题思想之贪心 : 2.白银挑战—贪心高频问题
贪心的思想不一定要理解的很透彻,但是贪心的问题我们要掌握的很好,这里我们就来研究几道考察频率非常高的问题。
关卡名 | 理解与贪心有关的高频问题 | 我会了✔️ | |
---|---|---|---|
内容 | 1. 理解区间问题如何解决 | ✔️ | |
2. 理解字符串分割问题 | ✔️ | ||
3. 理解加油站问题如何解决 | ✔️ |
1. 区间问题
区间问题也是面试中经常遇到的情况,这类面试题目还挺讨巧的,很容易考察出应聘者到底会不会写代码。我们来看几个例子。两个区间所有可能的关系如下所示,对于所有区间的问题,我们都在大脑里装着这张图,然后再看题,会容易很多。
1.1. 判断区间是否重叠
LeetCode252.给定一个会议时间安排的数组 intervals ,每个会议时间都会包括开始和结束的时间 intervals[i] = [starti, endi] ,请你判断一个人是否能够参加这里面的全部会议。
示例 1::
输入: intervals = [[0,30],[15,20],[5,10]]
解释: 存在重叠区间,一个人在同一时刻只能参加一个会议。
因为一个人在同一时刻只能参加一个会议,因此题目实质是判断是否存在重叠区间,将区间按照会议开始时间进行排序,然后遍历一遍判断后面的会议开始的时候是否前面的还没有结束即可。也就是上图中如果出现重叠的部分就直接返回false即可。
public boolean canAttendMeetings(int[][] intervals) {
// 将区间按照会议开始实现升序排序
Arrays.sort(intervals, (v1, v2) -> v1[0] - v2[0]);
// 遍历会议,如果下一个会议在前一个会议结束之前就开始了,返回 false。
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
def canAttendMeetings(self, intervals):
# 根据会议开始时间排序
intervals.sort(key=lambda x: x[0])
return all(intervals[i - 1][1] <= intervals[i][0] for i in range(1, len(intervals)))
bool canAttendMeetings(int[][] intervals) {
// 将区间按照会议开始实现升序排序
std::sort(intervals, intervals + intervals.length, [](int v1, int v2) {
return v1[0] < v2[0];
});
// 遍历会议,如果下一个会议在前一个会议结束之前就开始了,返回 false。
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
1.2. 合并区间
LeetCode56.以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
class Solution {
public int[][] merge(int[][] intervals) {
Arrays.sort(intervals,(v1,v2) -> v1[0] - v2[0]);
// 遍历区间
int[][] res = new int[intervals.length][2];
int index = -1;
for(int[] interval:intervals){
// 如果结果数组是空的,或者当前区间的起始位置 > 结果数组中最后区间的终止位置,
// 则不合并,直接将当前区间加入结果数组。
if(index == -1 || interval[0] > res[index][1]){
res[++index] = interval;
}else{
res[index][1] = Math.max(res[index][1],interval[1]);
}
}
return Arrays.copyOf(res,index+1);
}
}
为了方便理解,我们将上述序列画成序列图:
和上一题一样,首先对区间按照起始端点进行升序排序,然后逐个判断当前区间是否与前一个区间重叠,如果不重叠的话将当前区间直接加入结果集,反之如果重叠的话,就将当前区间与前一个区间进行合并。
public int[][] merge(int[][] intervals) {
// 先按照区间起始位置排序
Arrays.sort(intervals, (v1, v2) -> v1[0] - v2[0]);
// 遍历区间
int[][] res = new int[intervals.length][2];
int idx = -1;
for (int[] interval: intervals) {
// 如果结果数组是空的,或者当前区间的起始位置 > 结果数组中最后区间的终止位置,说明不重叠。
// 则不合并,直接将当前区间加入结果数组。
if (idx == -1 || interval[0] > res[idx][1]) {
res[++idx] = interval;
} else {
// 反之说明重叠,则将当前区间合并至结果数组的最后区间
res[idx][1] = Math.max(res[idx][1], interval[1]);
}
}
return Arrays.copyOf(res, idx + 1);
}
def merge(self, intervals):
intervals.sort(key=lambda x: x[0])
merged = []
for interval in intervals:
# 如果列表为空,或者当前区间与上一区间不重合,直接添加
if not merged or merged[-1][1] < interval[0]:
merged.append(interval)
else:
# 否则的话,我们就可以与上一区间进行合并
merged[-1][1] = max(merged[-1][1], interval[1])
return merged
int[][] merge(int[][] intervals) {
// 先按照区间起始位置排序
sort(intervals, [](const int& v1, const int& v2) { return v1[0] - v2[0]; });
// 遍历区间
int[][] res = new int[intervals.length][2];
int idx = -1;
for (int[] interval : intervals) {
// 如果结果数组是空的,或者当前区间的起始位置 > 结果数组中最后区间的终止位置,说明不重叠。
// 则不合并,直接将当前区间加入结果数组。
if (idx == -1 || interval[0] > res[idx][1]) {
res[++idx] = interval;
} else {
// 反之说明重叠,则将当前区间合并至结果数组的最后区间
res[idx][1] = max(res[idx][1], interval[1]);
}
}
return res;
}
1.3. 插入区间
LeetCode57,给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
示例 1::
输入: intervals = [[1,3],[6,9]], newInterval = [2,5]
输出: [[1,5],[6,9]]
解释: 新区间[2,5] 与 [1,3]重叠,因此合并成为 [1,5]。
示例 2::
输入: intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]
输出: [[1,2],[3,10],[12,16]]
解释: 新区间 [4,8] 与 [3,5],[6,7],[8,10] 重叠,因此合并成为 [3,10]。
本题就是上一题的再拓展,本题中的区间已经按照起始端点升序排列,因此我们直接遍历区间列表,寻找新区间的插入位置即可。具体步骤如下:
- 首先将新区间左边且相离的区间加入结果集(遍历时,如果当前区间的结束位置小于新区间的开始位置,说明当前区间在新区间的左边且相离)
- 接着判断当前区间是否与新区间重叠,重叠的话就进行合并,直到遍历到当前区间在新区间的右边且相离,将最终合并后的新区间加入结果集;
- 最后将新区间右边且相离的区间加入结果集。
public int[][] insert(int[][] intervals, int[] newInterval) {
int[][] res = new int[intervals.length + 1][2];
int idx = 0;
// 遍历区间列表:
// 首先将新区间左边且相离的区间加入结果集
int i = 0;
while (i < intervals.length && intervals[i][1] < newInterval[0]) {
res[idx++] = intervals[i++];
}
// 判断当前区间是否与新区间重叠,重叠的话就进行合并,直到遍历到当前区间在新区间的右边且相离,
// 将最终合并后的新区间加入结果集
while (i < intervals.length && intervals[i][0] <= newInterval[1]) {
newInterval[0] = Math.min(intervals[i][0], newInterval[0]);
newInterval[1] = Math.max(intervals[i][1], newInterval[1]);
i++;
}
res[idx++] = newInterval;
// 最后将新区间右边且相离的区间加入结果集
while (i < intervals.length) {
res[idx++] = intervals[i++];
}
return Arrays.copyOf(res, idx);
}
def insert(intervals, new_interval):
# 创建一个新的一维数组,长度为intervals的长度+1,并初始化结果集
res = [new_interval]
idx = 1
i = 0
while i < len(intervals):
if intervals[i][1] < new_interval[0]:
res.append(intervals[i])
i += 1
elif intervals[i][0] <= new_interval[1]:
new_interval[0] = min(new_interval[0], intervals[i][0])
new_interval[1] = max(new_interval[1], intervals[i][1])
i += 1
else:
res.append(new_interval)
new_interval = intervals[i]
res.extend(intervals[i:])
return res[:idx]
2. 字符串分割
这个题第一次做会感觉特别难, 无从下手,或者想的很复杂,但简单的方法就能解决。 LeetCode763 . 字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
输入:S = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca", "defegde", "hijhklij"。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 的划分是错误的,因为划分的片段数较少。
我们在回溯部分会讲到,分割问题是回溯算法的典型应用,但是这个题如果用回溯将非常复杂。题目要求同一字母最多出现在一个片段中,那么如何把同一个字母的都圈在同一个区间里呢? 该遍历过程相当要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。所以可以这么做:
- 首先,统计每一个字符最后出现的位置
- 然后,从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点。
public List<Integer> partitionLabels(String S) {
List<Integer> list = new LinkedList<>();
int[] edge = new int[26];
char[] chars = S.toCharArray();
for (int i = 0; i < chars.length; i++) {
edge[chars[i] - 'a'] = i;
}
int idx = 0;
int last = -1;
for (int i = 0; i < chars.length; i++) {
idx = Math.max(idx,edge[chars[i] - 'a']);
if (i == idx) {
list.add(i - last);
last = i;
}
}
return list;
}
def partitionLabels(self, s):
last = [0] * 26
for i, ch in enumerate(s):
last[ord(ch) - ord("a")] = i
partition = list()
start = end = 0
for i, ch in enumerate(s):
end = max(end, last[ord(ch) - ord("a")])
if i == end:
partition.append(end - start + 1)
start = end + 1
return partition
vector<int> canCompleteCircuit(string s) {
int last[26];
int length = s.size();
for (int i = 0; i < length; i++) {
last[s[i] - 'a'] = i;
}
vector<int> partition;
int start = 0, end = 0;
for (int i = 0; i < length; i++) {
end = max(end, last[s[i] - 'a']);
if (i == end) {
partition.push_back(end - start + 1);
start = end + 1;
}
}
return partition;
}
3. 加油站问题
加油站问题也是贪心的热门问题之一。LeetCode134.题目要求:在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。
示例1
输入:
gas = [1,2,3,4,5]
cost = [3,4,5,1,2]
输出: 3
解释:
从 3 号加油站(索引为 3 处)出发,可获得 4 升汽油。此时油箱有 = 0 + 4 = 4 升汽油
开往 4 号加油站,此时油箱有 4 - 1 + 5 = 8 升汽油
开往 0 号加油站,此时油箱有 8 - 2 + 1 = 7 升汽油
开往 1 号加油站,此时油箱有 7 - 3 + 2 = 6 升汽油
开往 2 号加油站,此时油箱有 6 - 4 + 3 = 5 升汽油
开往 3 号加油站,你需要消耗 5 升汽油,正好足够你返回到 3 号加油站。
因此,3 可为起始索引。
我们说做贪心题目时,不用管啥是贪心,直接做题就行。我们先理解一下这个题是什么意思。示例给的两个数组只是代表从当前站可以提供多少油料,以及从前一个位置过来需要消耗多少油料,因为是环。所以这里gas[0]=1和cost[0]=3就表示第一个站有1升汽油,从第一个站到第二个站需要消耗3升汽油。
很显然在第一个站只能加1升汽油,但是从第一到第二个站需要消耗3升汽油,因此不能从第一个站开始走。为此我们可以从第二个,第三个、第四个开始,因此我们想到了暴力解法,如下图所示:
我们一直向后找,只要能找到就行。 这里很明显的问题就是需要大量的重复计算,我们来优化一下。 首先,如果总油量减去总消耗大于等于零,那么应该能跑完一圈,具体到每一段就是各个加油站的剩油量rest[i]相加一定是大于等于零的。 每个加油站的剩余量rest[i]为gas[i] - cost[i]。i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,起始位置必须从i+1开始重新算,只有这样才能保证我们有可能完成。
这个操作能让搜索从O(n^2)降低到O(n),代码如下:
public boolean canAttendMeetings(int[][] intervals) {
// 将区间按照会议开始实现升序排序
Arrays.sort(intervals, (v1, v2) -> v1[0] - v2[0]);
// 遍历会议,如果下一个会议在前一个会议结束之前就开始了,返回 false。
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
def canAttendMeetings(self, intervals):
# 根据会议开始时间排序
intervals.sort(key=lambda x: x[0])
return all(intervals[i - 1][1] <= intervals[i][0] for i in range(1, len(intervals)))
bool canAttendMeetings(int[][] intervals) {
// 将区间按照会议开始实现升序排序
std::sort(intervals, intervals + intervals.length, [](int v1, int v2) {
return v1[0] < v2[0];
});
// 遍历会议,如果下一个会议在前一个会议结束之前就开始了,返回 false。
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
4. 通关文牒
本文的重点是能够解决上面的热门问题,特别是区间问题,将上面的内容理解清楚即可。
转载自:https://juejin.cn/post/7293356574218977343