LeetCode 101
本文档是对LeetCode101中题目的详细解释和扩展。
文章目录
第一章 题目分类
打开 LeetCode 网站,如果我们按照题目类型数量分类,最多的几个题型有数组、动态规划、 数学、字符串、树、哈希表、深度优先搜索、二分查找、贪心算法、广度优先搜索、双指针等等。 本书将包括上述题型以及网站上绝大多数流行的题型,并且按照难易程度和类型进行分类。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W76O4oIC-1657023302337)(pictureforMD/image-20220521093822599.png)]
第一个大分类是算法。本书先从最简单的贪心算法讲起,然后逐渐进阶到二分查找、排序算法和搜索算法,最后是难度比较高的动态规划和分治算法。
第二个大分类是数学,包括偏向纯数学的数学问题,和偏向计算机知识的位运算问题。这类问题通常用来测试你是否聪敏,在实际工作中并不常用,笔者建议可以优先把精力放在其它大类上。
第三个大分类是数据结构,包括 C++ STL内包含的常见数据结构、字符串处理、链表、树和 图。其中,链表、树、和图都是用指针表示的数据结构,且前者是后者的子集。最后我们也将介绍一些更加复杂的数据结构,比如经典的并查集和 LRU。
第二章 最易懂的贪心算法
2.1 算法解释
顾名思义,贪心算法或贪心思想采用贪心的策略,保证每次操作都是局部最优的,从而使最后得到的结果是全局最优的。 举一个最简单的例子:小明和小王喜欢吃苹果,小明可以吃五个,小王可以吃三个。已知苹果园里有吃不完的苹果,求小明和小王一共最多吃多少个苹果。在这个例子中,我们可以选用的贪心策略为,每个人吃自己能吃的最多数量的苹果,这在每个人身上都是局部最优的。又因为全局结果是局部结果的简单求和,且局部结果互不相干,因此局部最优的策略也同样是全局最优的策略。
2.2 分配问题
455. 分发饼干
题目描述:
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
示例 1:
输入: g = [1,2,3], s = [1,1]
输出: 1
解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。
示例 2:
输入: g = [1,2], s = [1,2,3]
输出: 2
解释:
你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出2.
提示:
1 <= g.length <= 3 * 104
0 <= s.length <= 3 * 104
1 <= g[i], s[j] <= 231 - 1
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/assign-cookies
思路
本题有两种思路:
1.饥饿度最小的孩子最容易吃饱,所以我们先考虑这个孩子。为了尽量使剩下的饼干可以满足更多的孩子,我们应该选取满足这个孩子且大小最小的饼干给这个孩子。满足这个孩子之后,我们再采用同样的策略去考虑剩下孩子里饥饿度最小的孩子,直到没有满足条件的饼干存在。
2.饥饿度高的孩子吃大尺寸的饼干不容易造成浪费,小尺寸的饼干可以满足饥饿度小的孩子,所以我们先考虑饥饿度较高的孩子。为了尽量使剩下的饼干可以满足更多的孩子,我们应该选取满足这个孩子且大小最小的饼干。满足这个孩子之后,我们再采取同样的策略去考虑剩下的孩子里饥饿度较高的孩子,直到没有满足条件的饼干存在。
具体实现要将饥饿度和饼干大小从小到大排序,这样就可以从饥饿度最小的孩子和大小最小的饼干(或饥饿度最大的孩子和大小最大的饼干)出发,计算有多少个可以满足条件的饼干孩子对。
题解
//先满足饥饿度小的孩子
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int child = 0, cookie = 0;
while (child < g.size() && cookie < s.size()) {
if (g[child] <= s[cookie]) ++child;
++cookie;
}
return child;//此时child指向最后可以满足的孩子的下一个孩子,下标等于已满足孩子的数量
}
};
//先满足饥饿度大的孩子
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int index = s.size() - 1;
int result = 0;
for (int i = g.size() - 1; i >= 0; i--) {
if (index >= 0 && g[i] <= s[index]) {//index>=0 一定要放在前面,当不符合此条件时不进行后续判断
--index;
++result;
}
}
return result;
}
};
小结:对数组或字符串排序是常见的操作,方便之后的大小比较。在之后的讲解中,若我们谈论的是对连续空间的变量进行操作,我们并不会明确区分数组和字符串,因为他们本质上都是在连续空间上的有序变量集合。一个字符串“abc”可以被看作一个数组 [‘a’,‘b’,‘c’]。
135. 分发糖果
题目描述:
n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
每个孩子至少分配到 1 个糖果。
相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的最少糖果数目 。
示例 1:
输入:ratings = [1,0,2]
输出:5
解释:你可以分别给第一个、第二个、第三个孩子分发 2、1、2 颗糖果。
示例 2:
输入:ratings = [1,2,2]
输出:4
解释:你可以分别给第一个、第二个、第三个孩子分发 1、2、1 颗糖果。 第三个孩子只得到 1 颗糖果,这满足题面中的两个条件。
提示:
n == ratings.length
1 <= n <= 2 * 104
0 <= ratings[i] <= 2 * 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/candy
思路
做完了题目 455,你会不会认为存在比较关系的贪心策略一定需要排序或是选择?虽然这一 道题也是运用贪心策略,但我们只需要简单的两次遍历即可:把所有孩子的糖果数初始化为 1; 先从左往右遍历一遍,如果右边孩子的评分比左边的高,则右边孩子的糖果数更新为左边孩子的 糖果数加 1;再从右往左遍历一遍,如果左边孩子的评分比右边的高,且左边孩子当前的糖果数不大于右边孩子的糖果数,则左边孩子的糖果数更新为右边孩子的糖果数加 1,若左边孩子当前的糖果大于右边孩子的糖果数,则不用改变。通过这两次遍历, 分配的糖果就可以满足题目要求了。这里的贪心策略即为,在每次遍历中,只考虑并更新相邻一侧的大小关系。
题解
class Solution {
public:
int candy(vector<int>& ratings) {
vector<int> candy(ratings.size(), 1);
for (int i = 1; i < ratings.size(); i++) {
if (ratings[i] > ratings[i - 1]) candy[i] = candy[i - 1] + 1;
}
for (int j = ratings.size() - 1; j > 0; j--) {
if (ratings[j - 1] > ratings[j]) candy[j - 1] = max(candy[j - 1], candy[j] + 1);
}
return accumulate(candy.begin(), candy.end(), 0);
}
};
关于std::accumulate的用法:https://cloud.tencent.com/developer/section/1009763
2.3 区间问题
455. 无重叠区间
题目描述
给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回需要移除区间的最小数量,使剩余区间互不重叠 。
示例 1:
输入: intervals = [[1,2],[2,3],[3,4],[1,3]]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。
示例 2:
输入: intervals = [ [1,2], [1,2], [1,2] ]
输出: 2
解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。
示例 3:
输入: intervals = [ [1,2], [2,3] ]
输出: 0
解释: 你不需要移除任何区间,因为它们已经是无重叠的了。
提示:
1 <= intervals.length <= 105
intervals[i].length == 2
-5 * 104 <= starti < endi <= 5 * 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/non-overlapping-intervals
思路
人类在处理这种问题时潜意识往往运用贪心算法。示例1中,我们会选取前三个区间,删除最后一个区间。为什么呢?因为我们潜意识里默认要选取结尾最小的区间,这样才能留下位置给其他区间。总结来说,本题的贪心策略即是:优先选取结尾数值小且不相交的区间。
具体实现方法是:既然我们要优先选取结尾数值小的,我们就要先对区间按结尾大小进行增序排序,每次选取结尾最小且和前一个选择的区间不重复的区间。我们使用C++的Lambda表达式,结合std::sort()函数进行自定义排序。
题解
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.size() == 0) return 0;
sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});
int result = 0, pre = intervals[0][1];//第一个区间的结尾值
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] < pre) result++;//重叠,需要删除此区间
else pre = intervals[i][1]; //不重叠,不需要删除,将pre后移
}
return result;
}
};
本题Lambda表达式的参数需要加个引用,否则会超时。关于Lambda表达式如果有些遗忘可以参考:
https://en.cppreference.com/w/cpp/language/lambda
2.4 练习
605. 种花问题
题目描述
假设有一个很长的花坛,一部分地块种植了花,另一部分却没有。可是,花不能种植在相邻的地块上,它们会争夺水源,两者都会死去。
给你一个整数数组 flowerbed 表示花坛,由若干 0 和 1 组成,其中 0 表示没种植花,1 表示种植了花。另有一个数 n ,能否在不打破种植规则的情况下种入 n 朵花?能则返回 true ,不能则返回 false。
示例 1:
输入:flowerbed = [1,0,0,0,1], n = 1
输出:true
示例 2:
输入:flowerbed = [1,0,0,0,1], n = 2
输出:false
提示:
1 <= flowerbed.length <= 2 * 104
flowerbed[i] 为 0 或 1
flowerbed 中不存在相邻的两朵花
0 <= n <= flowerbed.length
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/can-place-flowers
思路
贪心算法,如果某个位置前后都没有种花,则可以种。全局来看,会种最多的花。但是此题要判断边界条件:首位置如果后面没种花,则可以种,尾位置如果前面没种花,则可以种。为避免分类讨论,我们可以在首尾添加一个0,以表示上述这两种情形。
题解
class Solution {
public:
bool canPlaceFlowers(vector<int>& flowerbed, int n) {
int count = 0;
flowerbed.insert(flowerbed.begin(), 0);
flowerbed.push_back(0);
int size = flowerbed.size();
for (int i = 1; i < flowerbed.size() - 1; i++) {
if (flowerbed[i - 1] == 0 && flowerbed[i + 1] == 0 && flowerbed[i] == 0) {
count++;
flowerbed[i] = 1;
}
}
if (count - n >= 0) return true;
return false;
}
};
在首尾添加0的做法,体现了防御式编程思想,对数组长度只有1的样例也可以通过。
452. 用最少数量的箭引爆气球
题目描述
有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstart 和 xend之间的气球。你不知道气球的确切 y 坐标。
一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被引爆 。可以射出的弓箭的数量没有限制。 弓箭一旦被射出之后,可以无限地前进。
给你一个数组 points ,返回引爆所有气球所必须射出的最小弓箭数 。
示例 1:
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:气球可以用2支箭来爆破:
在x = 6处射出箭,击破气球[2,8]和[1,6]。
在x = 11处发射箭,击破气球[10,16]和[7,12]。
示例 2:
输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4
解释:每个气球需要射出一支箭,总共需要4支箭。
示例 3:
输入:points = [[1,2],[2,3],[3,4],[4,5]]
输出:2
解释:气球可以用2支箭来爆破:
在x = 2处发射箭,击破气球[1,2]和[2,3]。
在x = 4处射出箭,击破气球[3,4]和[4,5]。
提示:
1 <= points.length <= 105
points[i].length == 2
-231 <= xstart < xend <= 231 - 1
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/minimum-number-of-arrows-to-burst-balloons
思路
从直觉来看,只要让弓箭从气球重叠最多的位置射出,就可以引爆最多数量的气球。每次发射弓箭都引爆最多数量的弓箭,那么最终就用了最少数量的弓箭来引爆所有气球。具体来说,为了让气球尽可能重叠,我们应该对气球的左区间进行从小到大增序排序(Lambda),然后遍历一遍气球,当左区间大于上一个重叠气球区间最小右边界,那么就需要多一只箭来引爆此气球;否则还可以用上一只箭来引爆这组气球,并更新重叠气球区间最小右边界。
题解
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
sort(points.begin(), points.end(), [](vector<int>& a, vector<int>& b){
return a[0] < b[0];
});
int count = 1;
int last = points[0][1];
for (int i = 1; i < points.size(); i++) {
if (points[i][0] <= last) last = min(last, points[i][1]);
else {
count++;
last = points[i][1];
}
}
return count;
}
};
763. 划分字母区间
题目描述
字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
示例:
输入:S = “ababcbacadefegdehijhklij”
输出:[9,7,8]
解释:
划分结果为 “ababcbaca”, “defegde”, “hijhklij”。
每个字母最多出现在一个片段中。
像 “ababcbacadefegde”, “hijhklij” 的划分是错误的,因为划分的片段数较少。
提示:
S的长度在[1, 500]之间。
S只包含小写字母 ‘a’ 到 ‘z’ 。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/partition-labels
思路
人类在思考这道题时会扫描整个字符串,然后凭直觉进行划分,怎么划分的呢?仔细想想,既然要让同一个字符只能出现在一个区间内,那么这个字符的第一次出现的位置和最后一次出现的位置一定都是在这个区间内的。而这个区间是需要一直维护的,我们维护区间的左边界start和右边界end,end要根据当前遍历的字符最后一次出现的位置下标进行更新。当我们遍历到end时,就说明我们之前遍历的所有字符其最后一次出现位置都在end之前(如果不是则end还会更新)。此时我们要记录这个区间的长度为end-start+1,然后进入下一个区间start=end-1,end=start,重复这个过程,直到遍历到最后一个字符。具体来讲,我们要先遍历一遍字符串,将每个字符的最后出现位置下标存入哈希表,然后再次按上述步骤遍历字符串。
题解
class Solution {
public:
vector<int> partitionLabels(string s) {
int last[26] = {0};
for (int i = 0; i < s.size(); i++) {
last[s[i] - 'a'] = i;//不断更新字符的最后出现位置
}
int start = 0, end = 0;
vector<int> partition;//用来存储各个划分区间的长度
for (int i = 0; i < s.size(); i++) {
end = max(end, last[s[i] - 'a']); //一定放在前面,先更新end再判断是不是遍历到了end
if (i == end) {
partition.push_back(end - start + 1);
start = end + 1;
end = start;
}
}
return partition;
}
}
为了满足你的贪心策略,是否需要一些预处理?注意在处理数组前,统计一遍信息(如频率、个数、第一次出现位置、最后一次出现位置等)可以使题目难度大幅降低。
122. 买卖股票的最佳时机Ⅱ
题目描述
给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
示例 1:
输入:prices = [7,1,5,3,6,4]
输出:7
解释:在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6 - 3 = 3 。
总利润为 4 + 3 = 7 。
示例 2:
输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
总利润为 4 。
示例 3:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 交易无法获得正利润,所以不参与交易可以获得最大利润,最大利润为 0 。
提示:
1 <= prices.length <= 3 * 104
0 <= prices[i] <= 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii
思路
首先要清楚两点:1.只有一只股票;2.当前只有买卖股票这两种操作;3.要想获得利润至少以两天为一个交易单元。
有的同学可以想到,不就是选一个价格低的日期买入,再选一个价格高的日期卖出,如此循环。如果想到最终利润是可以分解的,那么这题就好解决了。假如我们第一天买入,第三天卖出,那么利润可以表示为price[3]-price[0],相当于(price[3]-price[2]+price[2]-price[1]+price[1]-price[0],此时我们就把利润分解为了以天为单位的数组。将所有正数相加就是最大利润。只收集正利润就是贪心算法贪的地方。局部最优:收集每天的正利润;全局最优:求得最大利润。另外,有的同学可能会陷入“第一天怎么没有利润,第一天到底算不算利润?”的困惑中。第一天当然没有利润,至少到第二天才有利润,所以利润序列比股票序列少一天。
题解
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 1; i < prices.size(); i++) {
if ((prices[i] - prices[i - 1]) > 0) result += prices[i] - prices[i - 1];
}
return result;
}
};
406. 根据身高重建队列
题目描述
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki个身高大于或等于 hi 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
示例 1:
输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
解释:
编号为 0 的人身高为 5 ,没有身高更高或者相同的人排在他前面。
编号为 1 的人身高为 7 ,没有身高更高或者相同的人排在他前面。
编号为 2 的人身高为 5 ,有 2 个身高更高或者相同的人排在他前面,即编号为 0 和 1 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0、1、2、3 的人。
编号为 5 的人身高为 7 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
因此 [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] 是重新构造后的队列。
示例 2:
输入:people = [[6,0],[5,0],[4,0],[3,2],[2,2],[1,4]]
输出:[[4,0],[5,0],[2,2],[3,2],[1,4],[6,0]]
提示:
1 <= people.length <= 2000
0 <= hi <= 106
0 <= ki < people.length
题目数据确保队列可以被重建
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/queue-reconstruction-by-height
思路
本题有两个维度,h和k,看到这种题目一定要想如何确定一个维度,然后在按照另一个维度重新排列。其实如果大家认真做了135. 分发糖果,就会发现和此题有点点的像。在135. 分发糖果我就强调过一次,遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度。如果两个维度一起考虑一定会顾此失彼。
是先确定k还是先确定h呢,也就是究竟先按h排序呢,还先按照k排序呢?如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。那么按照身高h来排序呢,身高一定是从大到小排(身高相同的话则k小的站前面),这个顺序就是我们依次插入结果集的顺序。如果不这样的话,后续插入身高相同的但k较小的,会使之前插入的相同身高的人的k不符合要求,从输出结果我们也可以看出,如果两个人身高相同,则一定是k值小的在前面。
此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高!那么只需要按照k为下标重新插入队列就可以了,为什么呢?因为我们已经根据身高从高到低排序了,优先按身高高的people的k来插入,后续插入节点也不会影响前面已经插入的节点(就算后续节点(低身高的插入到了高身高的前面,也不会影响,因为k是代表其前面身高大于等于他的人数),最终按照k的规则完成了队列。
题解
class Solution {
public:
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort (people.begin(), people.end(), [](vector<int>& a, vector<int>& b){
return a[0] == b[0] ? a[1] < b[1] : a[0] > b[0];
});
vector<vector<int>> result;
for (int i = 0; i < people.size(); i++) result.insert(result.begin() + people[i][1], people[i]);
return result;
}
};
另外我们知道,在vector中插入元素是很耗时的,所以我们可以尝试用list来存储结果,最后再转化成vector。
class Solution {
public:
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort (people.begin(), people.end(), [](vector<int>& a, vector<int>& b){
return a[0] == b[0] ? a[1] < b[1] : a[0] > b[0];
});
list<vector<int>> result;
for (int i = 0; i < people.size(); i++) {
std::list<vector<int>>::iterator it = result.begin();
int position = people[i][1];
while (position--) it++;//寻找插入位置
result.insert(it, people[i]);
}
return vector<vector<int>>(result.begin(), result.end());
}
};
665. 非递减数列
题目描述
给你一个长度为 n 的整数数组 nums ,请你判断在 最多 改变 1 个元素的情况下,该数组能否变成一个非递减数列。
我们是这样定义一个非递减数列的: 对于数组中任意的 i (0 <= i <= n-2),总满足 nums[i] <= nums[i + 1]。
示例 1:
输入: nums = [4,2,3]
输出: true
解释: 你可以通过把第一个 4 变成 1 来使得它成为一个非递减数列。
示例 2:
输入: nums = [4,2,1]
输出: false
解释: 你不能在只改变一个元素的情况下将其变为非递减数列。
提示:
n == nums.length
1 <= n <= 104
-105 <= nums[i] <= 105
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/non-decreasing-array
思路
本题难就在于需要考虑两个贪心情况,当nums[i] > nums[i+1]时,此时需要更改元素值,要么将nums[i]缩小,要么将nums[i+1]放大。
- 如果将nums[i]缩得太小,则有可能导致之前判断无误的nums[0,i]之内重新出现递减情况(j=[0,i],nums[i]<nums[j]),所以此时应该使用贪心策略:缩小nums[i]时,尽可能使nums[i]大,于是num[i]缩小到nums[i+1]。
- 如果将nums[i+1]放的太大,则有可能导致数列之后出现递减情况,所以此时应该使用贪心策略:放大nums[i+1]时,尽可能使nums[i+1]小,于是nums[i+1]放大到nums[i]。
具体来讲,我们遍历数组,如果遇到nums[i]>nums[i+1]时(递减情况),如果还能修改则判断应该按上述哪种方案修改:
-
当nums[i+1] >= nums[i-1]时,例如[2,3,2,2],此时应该缩小nums[i]至nums[i+1],如果放大nums[i+1],则可能导致后面的数组出现递减情况。
-
当nums[i+1] < nums[i-1]时,例如[3,3,2],此时应该放大nums[i+1]至nums[i],如果缩小nums[i],则可能导致前面的数组出现递减情况。
如果不能再修改就直接返回false。
题解
class Solution {
public:
bool checkPossibility(vector<int>& nums) {
if (nums.size() == 1) return true;
int count = nums[0] <= nums[1] ? 1 : 0;//前两个元素是否需要修改,count为剩余修改次数
for (int i = 1; i < nums.size() - 1; i++) {
if (nums[i] > nums[i + 1]) {
if (count) {//还有剩余修改机会
//判断应该修改nums[i]还是nums[i+1]
if (nums[i + 1] >= nums[i - 1]) nums[i] = nums[i + 1];
else nums[i + 1] = nums[i];
count--;
}
else return false;//还要修改但无修改机会,返回false
}
}
return true;//想要修改的次数小于等于1,返回true
}
};
第三章 玩转双指针
3.1 算法解释
双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多 个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的 区域即为当前的窗口),经常用于区间搜索。
若两个指针指向同一数组,但是遍历方向相反,则可以用来搜索,待搜索的数组往往是排好序的。
对于 C++ 语言,指针还可以玩出很多新的花样。一些常见的关于指针的操作如下。
指针与常量
int x;
int* p1 = &x;//指针可以被修改,值也可以被修改
const int* p2 = &x;//指针可以被修改,但值不可以被修改(const int)
int* const p2 = &x;//指针不可以被修改,但值可以被修改(* const)
const int* const p2 = &x;//指针不可以被修改,值也不可以被修改
//区别方法:左定值,右定向(const在*左边,底层const,指针所指的对象是常量;const在*右边,顶层const,指针本身是常量)
指针函数与函数指针
//addition是指针函数,(返回类型是指针的函数)
int* addition(int a, int b) {
int* sum = new int(a + b);
return sum;
}
int* m = addition(1,2);
int subtraction(int a, int b) {
return a - b;
}
//minus是函数指针,(指向函数的指针)
int (*minus)(int, int) = subtraction;
int opeartion(int x, int y, int (*func)(int, int)) {//第三个形参类型为函数指针
return (*func)(x, y);
}
int n = operation(3, *m, minus);//第一个实参类型为int,第一个实参类型为int型指针的值即int,第三个实参类型为函数指针
3.2 两数之和
167. 两数之和Ⅱ - 输入有序数组
题目描述
给你一个下标从 1 开始的整数数组 numbers ,该数组已按非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1] 和 numbers[index2] ,则 1 <= index1 < index2 <= numbers.length 。
以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 和 index2。
你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。
你所设计的解决方案必须只使用常量级的额外空间。
示例 1:
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。
示例 2:
输入:numbers = [2,3,4], target = 6
输出:[1,3]
解释:2 与 4 之和等于目标数 6 。因此 index1 = 1, index2 = 3 。返回 [1, 3] 。
示例 3:
输入:numbers = [-1,0], target = -1
输出:[1,2]
解释:-1 与 0 之和等于目标数 -1 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。
提示:
2 <= numbers.length <= 3 * 104
-1000 <= numbers[i] <= 1000
numbers 按 非递减顺序 排列
-1000 <= target <= 1000
仅存在一个有效答案
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/two-sum-ii-input-array-is-sorted
思路
本题其实很简单,因为数组都是按非递减排好序的,只需用两个遍历方向相反的指针,一个从左往右,一个从右往左遍历。当双指针指向的元素之和大于target时,说明应该减小总和,于是右边的指针要向左前进一位;当双指针指向的元素之和小于target时,说明应该增大总和,于是左边的指针要向右前进一位;当双指针指向的元素之和等于target时,说明这两个元素就是我们想要的值,返回双指针下标即可。
题解
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int left = 0, right = numbers.size() - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) break;
else if (sum < target) left++;
else right--;
}
return vector<int>{left + 1, right + 1};
}
};
3.3 归并两个有序数组
88. 合并两个有序数组
题目描述
给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。
请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。
注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。
示例 1:
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3] 和 [2,5,6] 。
合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。
示例 2:
输入:nums1 = [1], m = 1, nums2 = [], n = 0
输出:[1]
解释:需要合并 [1] 和 [] 。
合并结果是 [1] 。
示例 3:
输入:nums1 = [0], m = 0, nums2 = [1], n = 1
输出:[1]
解释:需要合并的数组是 [] 和 [1] 。
合并结果是 [1] 。
注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。
提示:
nums1.length == m + n
nums2.length == n
0 <= m, n <= 200
1 <= m + n <= 200
-109 <= nums1[i], nums2[j] <= 109
进阶:你可以设计实现一个时间复杂度为 O(m + n) 的算法解决此问题吗?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/merge-sorted-array
思路
这道题题意是让把所有元素按非递减顺序放入nums1数组,那么应该先从小往大放,还是从大往小放呢?应该从大往小放。因为这样避免了nums1数组中元素的移动,如果从小往大放,先往nums1数组的左侧加入小元素则会导致nums1数组的其他元素往后移动。正因为nums1数组后面已经预留了位置,所以我们应该从右往前比较两个数组最大的元素,选取一个最大的放入nums1数组的结尾(n+m-1)。
具体来讲:我们设置两个指针初始值为m-1和n-1,分别指向nums1和nums2的末尾,即nums1的m-1位和nums2的n-1位。另外设置一个指针pos,指向要加入nums1数组的位置,初始为m+n-1。每次将两指针指向的元素最大值复制到pos指向的nums1数组的位置,同时将有最大值的那个数组指针向左移动一位,pos指针也要向左移动一位。最后应该注意,当nums1元素被复制完,不要忘了将nums2的元素继续复制;如果nums2的元素已经复制完,剩余nums1元素则不需要改变,因为初始就是有序的。
题解
class Solution {
public:
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
int pos = m-- + n-- - 1;//pos初始值为m+n-1
//指向两数组的指针初始值为m-1和n-1
while (m >= 0 && n >= 0) {//两数组都没复制完
nums1[pos--] = nums1[m] > nums2[n] ? nums1[m--] : nums2[n--];
}
while (n >= 0) {//nums1数组复制完
nums1[pos--] = nums2[n--];
}
//nums2数组复制完则不用再操作了,因为nums1数组本来就有序
}
};
3.4 快慢指针
142. 环形链表Ⅱ
题目描述
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改 链表。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AYnBzvFj-1657023302339)(pictureforMD/circularlinkedlist.png)]
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CG3sXq3v-1657023302340)(pictureforMD/circularlinkedlist_test2.png)]
输入:head = [1,2], pos = 0
输出:返回索引为 0 的链表节点
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wO0HctMY-1657023302340)(pictureforMD/circularlinkedlist_test3.png)]
输入:head = [1], pos = -1
输出:返回 null
解释:链表中没有环。
提示:
链表中节点的数目范围在范围 [0, 104] 内
-10^5 <= Node.val <= 105
pos 的值为 -1 或者链表中的一个有效索引
进阶:你是否可以使用 O(1) 空间解决此题?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/linked-list-cycle-ii
思路
对于链表找环路的问题,有一个通用的解法——快慢指针(Floyd 判圈法)。给定两个指针, 分别命名为 slow 和 fast,起始位置在链表的开头。每次 fast 前进两步,slow 前进一步。如果 fast 可以走到尽头,那么说明没有环路;如果 fast 可以无限走下去,那么说明一定有环路,且一定存 在一个时刻 slow 和 fast 相遇。当 slow 和 fast 第一次相遇时,我们将 fast 重新移动到链表开头,并 让 slow 和 fast 每次都前进一步。当 slow 和 fast 第二次相遇时,相遇的节点即为环路的开始点。具体证明,参考代码随想录相应题目:
408考研真题曾经涉及到链表是否有环。可以使用快慢指针法,分别定义fast和slow指针,从头节点出发,快指针每次移动两个结点,慢指针每次移动一个节点。如果快慢指针在途中相遇,则说明这个链表有环。这很容易理解,我们知道快指针一定先比慢指针先进入环,而且快慢指针一定是在环中相遇。(快指针会不断在环里转圈等待慢指针进入环,然后相遇)换一种理解方式,因为快指针每次移动两个节点,慢指针每次移动一个节点,相对于慢指针而言,快指针是一个节点一个节点地靠近慢指针的,所以快慢指针一定可以相遇。
寻找环的入口需要一定的数学推导。假设从头节点到环的入口节点的节点数为x,环的入口结点到快慢节点相遇节点的节点数为y,从相遇节点再到环的入口节点的节点数为z,则环一圈的节点数为y+z。当快慢指针相遇时,慢指针移动的节点数为x+y,快指针移动的节点数为x+y+n(y+z),n为快指针在环内转了n圈才与慢指针相遇。由此我们可以列出等式:(x+y)×2=x+y+n(y+z),化简得:x+y=n(y+z)。因为我们要寻找的是环的入口,所以要计算的是x。进一步变形得:x=n(y+z)-y,提取一个y+z出来:x=(n-1)(y+z)+z。**这里的n一定是大于或等于1的,因为快指针要至少在环内移动一圈才能遇到慢指针。**先令n=1,这说明快指针在环内移动一整圈之后又移动了y个节点后与慢指针相遇。等式就变为x=z。这意味着,如果定义一个指针index2在头节点,定义另一个指针index1在相遇节点,这两个指针每次只移动一个节点,这两个指针相遇的节点就是环的入口节点。如果n>1呢?其实判断方法是一样的。只不过index1指针会在环内多移动n-1圈,最后在环的入口与index2相遇。仔细揣摩上面的公式:x=(n-1)(y+z)+z。
题解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head;
ListNode* slow = head;
//判断有无环
do{
if (!fast || !fast->next) return nullptr;
fast = fast->next->next;
slow = slow->next;
} while (fast != slow);
//有环则判断环起始位置
fast = head;
while (fast != slow) {
slow = slow->next;
fast = fast->next;
}
return fast;
}
};
3.5 滑动窗口
76. 最小覆盖子串
题目描述
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。
注意:
对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。
示例 1:
输入:s = “ADOBECODEBANC”, t = “ABC”
输出:“BANC”
示例 2:
输入:s = “a”, t = “a”
输出:“a”
示例 3:
输入: s = “a”, t = “aa”
输出: “”
解释: t 中两个字符 ‘a’ 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
提示:
1 <= s.length, t.length <= 105
s 和 t 由英文字母组成
进阶:你能设计一个在 o(n) 时间内解决此问题的算法吗?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/minimum-window-substring
思路
本题使用滑动窗口求解,即两个指针 l 和 r 都是从最左端向最右端移动,且 l 的位置一定在 r 的左边或重合。注意本题虽然在 for 循环里出现了一个 while 循环,但是因为 while 循环负责移 动 l 指针,且 l 只会从左到右移动一次,因此总时间复杂度仍然是 O(n)。本题使用了长度为 128 的数组来映射字符,也可以用哈希表替代;其中 chars 表示目前滑动窗口内每个字符缺少的数量,flag 表示每个字符是否在 T 中存在。
具体来讲:我们先遍历一遍字符串T,将字符串T中的字符记录在flag和chars中。此时flag中记录了每个字符在T中是否出现过,chars记录了每个字符在T中出现的次数。定义变量count用来记录滑动窗口中出现T中字符的数量,定义变量min_l用来记录l的最小位置,定义变量min_size用来记录滑动窗口最小大小。将l和r指针初始化为0,for循环向右移动r指针,当r指针指向的字符在T中出现过且此时滑动窗口内这个字符缺少的数量大于等于1,说明这个字符应该放入滑动窗口,此时++count,–chars[S[r]]。当count等于字符串T的长度时(说明滑动窗口内包含了所有T中的字符),此时尝试将l右移,以求最小的子串。更新min_l和min_size,如果l指针指向的字符在字符串T中出现过且chars[S[l]]>=0(说明l指向的字符是滑动窗口内应该包含的字符),–count,++chars[S[l]];指针l向右移动。当r指针指向字符串S的最后一个字符时,此时min_l就是要求的子串的左边界,子串大小为min_size。
题解
class Solution {
public:
string minWindow(string s, string t) {
vector<int> chars(128, 0);
vector<bool> flag(128, false);
for (int i = 0; i < t.size(); ++i) {
flag[t[i]] = true;
++chars[t[i]];
}
int count = 0, l = 0, min_l = 0, min_size = s.size() + 1;
for (int r = 0; r < s.size(); ++r) {
if (flag[s[r]]) {
if (--chars[s[r]] >= 0) ++count;
while (count == t.size()) {
if (r - l + 1 < min_size) {
min_l = l;
min_size = r - l + 1;
}
if (flag[s[l]] && ++chars[s[l]] > 0) --count;
++l;
}
}
}
return min_size > s.size() ? "" : s.substr(min_l, min_size);
}
};
思路中讲解的可能比较乱,一句话总结本题的思想就是:当滑动窗口内未完全包含字符串T内所有字符,则右边界右移,否则左边界右移,以求最小子串。
3.6 练习
633. 平方数之和
题目描述
给定一个非负整数 c ,你要判断是否存在两个整数 a 和 b,使得 a2 + b2 = c 。
示例 1:
输入:c = 5
输出:true
解释:1 * 1 + 2 * 2 = 5
示例 2:
输入:c = 3
输出:false
提示:
0 <= c <= 231 - 1
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/sum-of-square-numbers
思路
本题很容易想到用双指针法:一个指针i指向1,一个指针j指向c-1,当i<=j时候计算两指针指向元素的平方和,如果等于c就返回true;如果大于c就–j;如果小于c就++i。但是我们没必要令j指针指向c-1,只要指向c1/2就行了。另外我们需要注意,本题的c的取值范围,因此在计算平方和的时候要避免int型溢出的情况,需要使用long型避免溢出。
题解
class Solution {
public:
bool judgeSquareSum(int c) {
long i = 0, j = static_cast<int>(sqrt(c));
while (i <= j) {
long sum = i * i + j * j;
if (sum == c) return true;
else if (sum < c) ++i;
else --j;
}
return false;
}
};
680. 验证回文字符串Ⅱ
题目描述
给定一个非空字符串 s,最多删除一个字符。判断是否能成为回文字符串。
示例 1:
输入: s = “aba”
输出: true
示例 2:
输入: s = “abca”
输出: true
解释: 你可以删除c字符。
示例 3:
输入: s = “abc”
输出: false
提示:
1 <= s.length <= 105
s 由小写英文字母组成
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/valid-palindrome-ii
思路
遇到回文字符串的题目我们都会想到用双指针法,本题也一样,当i和j指针指向不同的字符时候,我们应该删除一个字符,删除i和j哪一个呢?都应该试试。如果都不行就返回false,如果有一个行则返回true。
具体来讲,我们实现一个函数,其功能是判断回文数,主函数用双指针遍历字符,当第一次两指针指向不相等的元素时就调用实现的函数判断删除哪一个字符可以变成回文字符串,如果有一个可以就返回true。
这里我有个疑问,如果第一次两指针指向字符不相等时不删除,而之后再删除会不会也会成为回文字符串?不会的,因为我们是使用双指针,两个指针同时向中心前进,如果第一次出现不相等字符时不处理,之后遇到再处理的话,则之前的没处理的字符会导致整个字符串不是回文字符串。
题解
class Solution {
public:
bool validPalindrome(string s) {
int l = 0, r = s.size() - 1;
while(l <= r) {
if (s[l] != s[r]) return Istrue(s, l + 1, r) || Istrue(s, l, r - 1);
++l;
--r;
}
return true;//都满足s[l]==s[r]则不用删除字符就是回文字符串
}
private:
bool Istrue(string s, int l, int r) {//判断是否是回文字符串
while (l < r)
if (s[l++] != s[r--]) return false;
return true;
}
};
524. 通过删除字符匹配到字典里最长的单词
题目描述
给你一个字符串 s 和一个字符串数组 dictionary ,找出并返回 dictionary 中最长的字符串,该字符串可以通过删除 s 中的某些字符得到。
如果答案不止一个,返回长度最长且字母序最小的字符串。如果答案不存在,则返回空字符串。
示例 1:
输入:s = “abpcplea”, dictionary = [“ale”,“apple”,“monkey”,“plea”]
输出:“apple”
示例 2:
输入:s = “abpcplea”, dictionary = [“a”,“b”,“c”]
输出:“a”
提示:
1 <= s.length <= 1000
1 <= dictionary.length <= 1000
1 <= dictionary[i].length <= 1000
s 和 dictionary[i] 仅由小写英文字母组成
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/longest-word-in-dictionary-through-deleting
思路
本题要求将字符串s删除某些字符匹配到dictionary中最长的字符串,这样的描述是求子序列(可以不连续),因为本题只要求是否可以匹配到,所以我们并不用真的删除s中的字符,直接用求子序列的函数求dictionary中的字符串是否是s的子序列即可。另外题目说如果答案不止一个,返回长度最长且字母序最小的字符串,这一点我们可以通过事先对dictionary中的字符串排序保证。
具体来讲,我们实现一个函数,其功能是求t是否是s的子序列,如果t的长度大于s的长度,那么t一定不是s的子序列,之后遍历字符串t的每一个字符,看看是否能与s中某些字符全部匹配上,如果可以则说明t是s的子序列。我们定义了两个指针分别指向s和t字符串,所以也是双指针法的题目。
题解
class Solution {
private:
bool IsSubstr(string s, string t) {
int n = s.size(), m = t.size();
if (m > n) return false;//如果t长度大于s则t一定不是s的子序列
int i = 0;//记录字符串s遍历到哪里
for (char &c : t) {
while (i < n && s[i] != c) ++i;//s[i]不匹配t的字符c,则匹配s的下一个字符
if (i >= n) return false;//字符串s遍历完但字符串t还没遍历完,说明t不是s的子序列
//此时s[i]已经匹配t的字符c,则要继续匹配s的下一个字符和t的下一个字符
++i;
}
return true;//t的所有字符均与s中的某些字符匹配上,说明t是s的子串
}
public:
string findLongestWord(string s, vector<string>& dictionary) {
sort(dictionary.begin(), dictionary.end(), [&](string &a, string &b) {
if (a.size() == b.size()) return a < b;//长度相等则按字典序
return a.size() > b.size();//按长度从大到小排序
});
for (string &str : dictionary) {
if (IsSubstr(s, str)) return str;
}
return "";//未找到子序列
}
};
340. 至多包含k个不同字符的最长子串
题目描述
给定一个字符串 s ,找出 至多 包含 k 个不同字符的最长子串 T。
示例 1:
输入: s = “eceba”, k = 2
输出: 3
解释: 则 T 为 “ece”,所以长度为 3。
示例 2:
输入: s = “aa”, k = 1
输出: 2
解释: 则 T 为 “aa”,所以长度为 2。
提示:
1 <= s.length <= 5 * 104
0 <= k <= 50
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/longest-substring-with-at-most-k-distinct-characters
思路
本题使用滑动窗口,一句话解释就是:当窗口内的字符种类大于2时,则左边界右移,否则右边界右移。 为了统计窗口内的字符种类和每个字符的个数,我们需要定义一个int型变量count和map<char, int>。
具体来讲,我们定义滑动窗口,初始化左右边界为字符串首位置,右边界不断向后移动,当窗口内不含右边界字符时(count()函数),加入map,++count,当窗口内字符种类大于k时,开始移动左边界,首先将左边界字符在map中的数值减一,如果此字符数量减为0,则将这个字符从map中删除,同时–count。最后结果为R-L+1与0的最大值。
当然我们也可以不用定义count,因为unordered_map默认使不允许重复关键字的,所以我们可以用size()函数来统计字符种类。我们也不用count()函数来确定map中是否有这个元素,直接++map[s[r]]即可,如果有则该字符值加一,如果没有,则添加这个元素并value初始化为1。
本题与3.5节76. 最小覆盖子串十分类似,注意总结滑动窗口模板。
题解
class Solution {
public:
int lengthOfLongestSubstringKDistinct(string s, int k) {
unordered_map<char, int> char_count;
int l = 0;
int max_size = 0;
for (int r = 0; r < s.size(); ++r) {
++char_count[s[r]];
while (char_count.size() > k) {
--char_count[s[l]];
if (char_count[s[l]] == 0) char_count.erase(s[l]);
++l;
}
max_size = max(max_size, r - l + 1);
}
return max_size;
}
};
第四章 二分查找
4.1 算法解释
二分查找也常被称为二分法或者折半查找,每次查找时通过将待查找区间分成两部分并只取 一部分继续查找,将查找的复杂度大大减少。对于一个长度为 O(n) 的数组,二分查找的时间复 杂度为 O(log n)。
举例来说,给定一个排好序的数组 {3,4,5,6,7},我们希望查找 4 在不在这个数组内。第一次 折半时考虑中位数 5,因为 5 大于 4, 所以如果 4 存在于这个数组,那么其必定存在于 5 左边这一 半。于是我们的查找区间变成了 {3,4,5}。(注意,根据具体情况和您的刷题习惯,这里的 5 可以 保留也可以不保留,并不影响时间复杂度的级别。)第二次折半时考虑新的中位数 4,正好是我们 需要查找的数字。于是我们发现,对于一个长度为 5 的数组,我们只进行了 2 次查找。如果是遍 历数组,最坏的情况则需要查找 5 次。
我们也可以用更加数学的方式定义二分查找。给定一个在 [a, b] 区间内的单调函数 f (x),若 f (a) 和 f (b) 正负性相反,那么必定存在一个解 c,使得 f © = 0。(零点存在性定理)在上个例子中,f (x) 是离散函数 f (x) = x +2,查找 4 是否存在等价于求 f (x) −4 = 0 是否有离散解。因为 f (1) −4 = 3−4 = −1 < 0、 f (5) − 4 = 7 − 4 = 3 > 0,且函数在区间内单调递增,因此我们可以利用二分查找求解。如果最后 二分到了不能再分的情况,如只剩一个数字,且剩余区间里不存在满足条件的解,则说明不存在 离散解,即 4 不在这个数组内。 具体到代码上,二分查找时区间的左右端取开区间还是闭区间在绝大多数时候都可以,因此 有些初学者会容易搞不清楚如何定义区间开闭性。这里我提供两个小诀窍,第一是尝试熟练使用 一种写法,比如左闭右开(满足 C++、Python 等语言的习惯)或左闭右闭(便于处理边界条件), 尽量只保持这一种写法;第二是在刷题时思考如果最后区间只剩下一个数或者两个数,自己的写 法是否会陷入死循环,如果某种写法无法跳出死循环,则考虑尝试另一种写法。 二分查找也可以看作双指针的一种特殊情况,但我们一般会将二者区分。双指针类型的题, 指针通常是一步一步移动的,而在二分查找里,指针每次移动半个区间长度。
4.2 求开方
69. x的平方根
题目描述
给你一个非负整数 x ,计算并返回 x 的 算术平方根 。
由于返回类型是整数,结果只保留 整数部分 ,小数部分将被 舍去 。
注意:不允许使用任何内置指数函数和算符,例如 pow(x, 0.5) 或者 x ** 0.5 。
示例 1:
输入:x = 4
输出:2
示例 2:
输入:x = 8
输出:2
解释:8 的算术平方根是 2.82842…, 由于返回类型是整数,小数部分将被舍去。
提示:
0 <= x <= 231 - 1
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/sqrtx
思路
本题采用类似二分法的策略查找[1, x]区间符合题目要求的x的算数平方根(只保留整数)。我们既可以使用result=middle^2(middle和result都要定义成long),也可以使用result=x/middle。关键是采取哪种二分查找写法,是左闭右开还是左闭右闭。左闭右闭代码很好写,按之前的模板写就行最后返回right(此时left>right取小的),但是左闭右开代码最后应该返回right-1(此时left=right),另外还需要特殊考虑x=1的情况,综上所述我们采用左闭右闭、result=x/middle的写法。
具体来讲:首先对x=0进行特殊处理,然后套用模板,注意这时的target(result)是时刻变化的,result=x/middle。不符合while循环条件后再返回right。
题解
class Solution {
public:
int mySqrt(int x) {
int left = 1, right = x;//左闭右开区间二分查找写法
while (left <= right) {
int middle = left + (right - left) / 2;
int result = x / middle;
if (result == middle) return middle;
else if (result < middle) right = middle - 1;
else if (result > middle) left = middle + 1;
}
return right;
}
};
另外,本科的数值计算方法里学过牛顿迭代法,这其实是最快的算法,公式为: x n + 1 = x n − f ( x n ) / f ′ ( x n ) x_{n+1} = x_n - f(x_n)/f'(x_n) xn+1=xn−f(xn)/f′(xn)
这里我们要求解的解是 f ( x ) = x 2 − a = 0 f(x) = x^2 - a = 0 f(x)=x2−a=0 ,迭代公式为 x n + 1 = ( x n + a / x n ) / 2 x_{n+1} = (x_n + a/x_n)/2 xn+1=(xn+a/xn)/2 ,代码如下:
题解
class Solution {
public:
int mySqrt(int x) {
long result = static_cast<long>(x);
while (result * result > x ) result = (result + x / result) / 2;
return result;
}
};
4.3 查找区间
34. 在排序数组中查找元素的第一个和最后一个位置
题目描述
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
进阶:
你可以设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?
示例 1:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
示例 2:
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
示例 3:
输入:nums = [], target = 0
输出:[-1,-1]
提示:
0 <= nums.length <= 105
-109<= nums[i] <= 109
nums 是一个非递减数组
-109 <= target <= 109
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array
思路
此题可以看作是自己实现C++里的lower_bound和upper_bound函数。我们采用左闭右开的写法,注意我们之前的二分法模板改变右区间边界的条件是(nums[mid] > target),这其实是求target最后出现位置的条件(默认target出现一次),我们要求第一次出现位置则要改变成(nums[mid] >= target)。
题解
class Solution {
private:
int lower_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(), mid;
while (left < right) {
mid = (left + right) / 2;
if (nums[mid] >= target) right = mid;
else left = mid + 1;
}
return left;
}
int upper_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(), mid;
while (left < right) {
mid = (left + right) / 2;
if (nums[mid] > target) right = mid;
else left = mid + 1;
}
return left;
}
public:
vector<int> searchRange(vector<int>& nums, int target) {
if (nums.size() == 0) return {-1, -1};
int lower = lower_bound(nums, target);
int upper = upper_bound(nums, target) - 1;//左闭右开
if (lower == nums.size() || nums[lower] != target) return {-1, -1};
return vector<int> {lower, upper};
}
};
4.4 旋转数组查找数字
33. 搜索旋转排序数组
题目描述
整数数组 nums 按升序排列,数组中的值 互不相同 。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], …, nums[n-1], nums[0], nums[1], …, nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。
给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。
示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
示例 2:
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
示例 3:
输入:nums = [1], target = 0
输出:-1
提示:
1 <= nums.length <= 5000
-104 <= nums[i] <= 104
nums 中的每个值都 独一无二
题目数据保证 nums 在预先未知的某个下标上进行了旋转
-104 <= target <= 104
进阶:你可以设计一个时间复杂度为 O(log n) 的解决方案吗?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/search-in-rotated-sorted-array
思路
即使数组被旋转过,我们仍然可以利用这个数组的递增性,使用二分查找。将数组一分为二,其中一定有一个是有序的,另一个可能是有序,也可能是部分有序。 此时有序部分用二分法查找。无序部分再一分为二,其中一个一定有序,另一个可能有序,可能无序,一直就这样循环下去。对于当前的中点, 如果它指向的值小于等于右端,那么说明右区间是排好序的;反之,那么说明左区间是排好序的。 如果目标值位于排好序的区间内,我们可以对这个区间继续二分查找;反之,我们对于另一半区 间继续二分查找。
题解
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1, mid;
while (left <= right) {
mid = (left + right) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] <= nums[right]) {//右区间是非递减有序的
if (target > nums[mid] && target <= nums[right]) left = mid + 1;//target在有序右区间内,将left移动到右区间左边界,对此区间进行二分查找
else right = mid - 1;//target在不一定有序的左区间内,此时将right移动到左区间右边界,继续对此区间一分为二
}
else {//左区间是非递减的
if (target < num[mid] && target >= nums[left]) right = mid - 1;//target在有序左区间内,将right移动到左区间右边界,对此区间进行二分查找
else left = mid + 1;//target在不一定有序的右区间内,此时将left移动到右区间左边界,继续对此区间一分为二
}
}
return -1;
}
};
81. 搜索旋转排序数组Ⅱ
题目描述
已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], …, nums[n-1], nums[0], nums[1], …, nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4] 。
给你 旋转后 的数组 nums 和一个整数 target ,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false 。
你必须尽可能减少整个操作步骤。
示例 1:
输入:nums = [2,5,6,0,0,1,2], target = 0
输出:true
示例 2:
输入:nums = [2,5,6,0,0,1,2], target = 3
输出:false
提示:
1 <= nums.length <= 5000
-104 <= nums[i] <= 104
题目数据保证 nums 在预先未知的某个下标上进行了旋转
-104 <= target <= 104
进阶:
这是 搜索旋转排序数组 的延伸题目,本题中的 nums 可能包含重复元素。
这会影响到程序的时间复杂度吗?会有怎样的影响,为什么?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/search-in-rotated-sorted-array-ii
思路
与上一题一样,唯一不同的是本题存在相同元素。 注意,因为数组存在重复数字,如果中点和左端的数字相同,我们并不能确定是左区间全部 相同,还是右区间完全相同。在这种情况下,我们可以简单地将左端点右移一位,然后继续进行二分查找。
题解
class Solution {
public:
bool search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1, mid;
while (left <= right) {
mid = (left + right) / 2;
if (nums[mid] == target) return true;
if (nums[left] == nums[mid]) ++left;//此情况无法判断哪个区间是非递减的
else if (nums[mid] <= nums[right]) {//右区间非递减
if (target > nums[mid] && target <= nums[right]) left = mid + 1;//且target在右区间,可以二分查找
else right = mid - 1;
}
else {//左区间非递减
if (target >= nums[left] && target < nums[mid]) right = mid - 1;//且target在左区间,可以二分查找
else left = mid + 1;
}
}
return false;
}
};
4.5 练习
153. 寻找旋转排序数组的最小值
题目描述
已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:
若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]
若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], …, a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], …, a[n-2]] 。
给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。
示例 1:
输入:nums = [3,4,5,1,2]
输出:1
解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。
示例 2:
输入:nums = [4,5,6,7,0,1,2]
输出:0
解释:原数组为 [0,1,2,4,5,6,7] ,旋转 4 次得到输入数组。
示例 3:
输入:nums = [11,13,15,17]
输出:11
解释:原数组为 [11,13,15,17] ,旋转 4 次得到输入数组。
提示:
n == nums.length
1 <= n <= 5000
-5000 <= nums[i] <= 5000
nums 中的所有整数 互不相同
nums 原来是一个升序排序的数组,并进行了 1 至 n 次旋转
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array
思路
此题和33.搜索旋转排序数组十分相似,题目花里胡哨其实只是将数组反转了一次。我们根据33题的思路可以想到此题还是使用二分法来搜索最小值,这里重新写一下宏观的思路:将数组一分为二,一半必定有序,一半不一定有序,如果不有序说明最小值在此区间,则应该对此区间再一分为二,循环下去。
不过对于二分法如果边界条件写不对很容易超时或者输出错误答案,这里我们针对代码用例子来说明各个边界条件的正确写法。具体来讲:首先我们使用左闭右闭区间,left和right分别初始化为0和nums.size()-1。
while循环中应该是(left < right),而不是(left <= right),假设nums=[3,10,1],当left=1,right=2,mid=1时,根据上述思路left=mid+1=2,此时left就指向最小元素,如果是(left < right)此时就可以跳出循环了,如果是(left <= right)则要多进行一次循环,此时left=right=mid,所以nums[mid]=nums[right],会执行else语句right=mid= 1,无限循环下去。
if语句应该是left = mid + 1,而不是left = mid,这样做万一nums[mid]就是最小值不就错过了吗?不会,因为如果nums[mid]是最小值,那nums[mid]一定小于nums[right],不会执行if语句。假设nums=[3,10,1],如果left=mid,当left=1,right=2,mid=1时,nums[mid]>nums[right],left=mid,则会无限循环下去。
else语句应该是right=mid,而不是right=mid-1,假设nums=[3,1,2],当left=1,right=2,mid=1时,nums[mid]>nums[right],如果right=mid-1,则会丢失最小值。
二分法套路很简单,难的是边界条件的判断。目前我还没找到什么好办法可以一次性判断边界条件,有待后续学习探索。
题解
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1, mid;
while (left < right) {
mid = (left + right) / 2;
if (nums[mid] > nums[right]) {//右区间更小
left = mid + 1;
}
else right = mid;
}
return nums[left];
}
};
154. 寻找旋转排序数组的最小值Ⅱ
题目描述
思路
已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,4,4,5,6,7] 在变化后可能得到:
若旋转 4 次,则可以得到 [4,5,6,7,0,1,4]
若旋转 7 次,则可以得到 [0,1,4,4,5,6,7]
注意,数组 [a[0], a[1], a[2], …, a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], …, a[n-2]] 。
给你一个可能存在 重复 元素值的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
你必须尽可能减少整个过程的操作步骤。
示例 1:
输入:nums = [1,3,5]
输出:1
示例 2:
输入:nums = [2,2,2,0,1]
输出:0
提示:
n == nums.length
1 <= n <= 5000
-5000 <= nums[i] <= 5000
nums 原来是一个升序排序的数组,并进行了 1 至 n 次旋转
进阶:这道题与 寻找旋转排序数组中的最小值 类似,但 nums 可能包含重复元素。允许重复会影响算法的时间复杂度吗?会如何影响,为什么?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array-ii
思路
这道题大体和153题类似,唯一不同的是有重复元素。有重复元素的问题我们在81.题搜索旋转排序数组Ⅱ中讲解过:这种情况下,我们可以简单地将左端点右移一位,然后继续进行二分查找。类似的,本题也是,由于重复元素的存在,例如nums=[1,1,1,0,1]和[1,0,1,1,1],我们并不能确定nums[mid]在最小值的左侧还是右侧,因此我们不能莽撞地忽略某一部分的元素,唯一可以知道的是,由于它们的值相同,所以无论是nums[right]是不是最小值,都有一个它的替代品nums[mid],因此我们可以忽略右端点,right–。
题解
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1, mid;
while (left < right) {
mid = (left + right) / 2;
if (nums[mid] > nums[right]) {//最小值在右区间
left = mid + 1;
}
else if (nums[mid] < nums[right]) right = mid;
else right--;
}
return nums[left];
}
};
540. 有序数组中的单一元素
题目描述
给你一个仅由整数组成的有序数组,其中每个元素都会出现两次,唯有一个数只会出现一次。
请你找出并返回只出现一次的那个数。
你设计的解决方案必须满足 O(log n) 时间复杂度和 O(1) 空间复杂度。
示例 1:
输入: nums = [1,1,2,3,3,4,4,8,8]
输出: 2
示例 2:
输入: nums = [3,3,7,7,10,11,11]
输出: 10
提示:
1 <= nums.length <= 105
0 <= nums[i] <= 105
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/single-element-in-a-sorted-array
思路
先说结论:假设只出现一次的元素下标为x,对于下标x左边的任意下标y,如果有nums[y] = nums[y+1],则y一定是偶数;对于下标x右边的任意下标z,如果有nums[z] = nums[z+1],则z一定是奇数。我们把y、z称为相同元素开始下标,则有上可知,x是相同元素开始下标奇偶性的分界(x之前相同元素开始下标都为偶数,x之后相同元素开始下标都为奇数,因此我们可以使用二分查找的方法来寻找下标x。
这个结论怎么得到的呢?在没有出现单一元素时,因为每个元素都连续出现两次,所以相同元素开始下标皆是偶数(0、2、4。。。结束下标皆为奇数1、3、5。。。)。在单一元素出现后,这一性质发生变化,因为这个单一元素占据了一个偶数位,使得之后出现的成对元素开始元素只能位于奇数位,所以之后相同元素开始下标就都为奇数了。
具体来讲,二分查找的左边界位0,右边界位nums.size() - 1,每次取左右边界中间值mid作为待判断的下标,根据mid的奇偶性来决定是和左边还是右边的相邻元素进行比较(此结论等价于上述的结论)
- 如果mid是奇数,则比较其左边的元素是否与它相等,即比较nums[mid-1]和nums[mid]是否相等。
- 如果mid是偶数,则比较器右边的元素是否与它相等,即比较nums[mid]和nums[mid+1]是否相等。
如果上述比较相等,则说明还未出现单一元素,即mid<x,则应该调整左边界,在右区间继续寻找;如果上述比较不相等,则已经出现了单一元素,即mid>=x,应该调整右边界,在左区间继续寻找,直到确定下标x的值。
注意在实际写代码时,我们不需要对mid进行判断奇偶,而是用将mid与1按位异或来得到mid-1或mid+1,因为如果mid是奇数时,化为二进制时最后一位是1,与1异或之后变为0,实现了-1的操作;如果mid是偶数时,化为二进制时最后一位是0,与1异或之后变为1,实现了+1的操作。
题解
class Solution {
public:
int singleNonDuplicate(vector<int>& nums) {
int left = 0, right = nums.size() - 1, mid;
while (left < right) {
mid = left + (right - left) / 2;
if (nums[mid] == nums[mid ^ 1]) left = mid + 1;
else right = mid;
}
return nums[left];
}
};
我们来讨论一下边界条件,首先left和right初始化为0和nums.size()-1:
- 当nums[mid]==nums[mid ^ 1]时,left应该等于mid+1,而不是mid,假设nums=[3,3,10],当left= 0,right=2时,mid=1(奇数),nums[mid] == nums[mid-1]=3,此时如果left=mid=1,则下一次循环,mid计算后还等于(1+2)/2=1,陷入了无限循环。
- while循环条件应该是left < right,而不是left <= right ,假设nums[1,1,2,3,3],执行到left=right=2时,如果是left<=right则还会继续循环,mid=2(偶数),nums[mid] !=nums[mid+1],执行else语句,right=mid=2,无限循环下去。
- else语句应该是right=mid,而不是mid-1,还是上面的例子,当left=0,right=4时,mid=2,nums[mid]!=nums[mid+1],执行else语句,如果right=mid-1,则会跳过mid这个只出现一次的元素。
4. 寻找两个正序数组的中位值
题目描述
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
算法的时间复杂度应该为 O(log (m+n)) 。
示例 1:
输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2
示例 2:
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
提示:
nums1.length == m
nums2.length == n
0 <= m <= 1000
0 <= n <= 1000
1 <= m + n <= 2000
-106 <= nums1[i], nums2[i] <= 106
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/median-of-two-sorted-arrays
思路
这道题如果不规定时间复杂度为O(log(m+n))的话还是很简单的,将两数组合并然后找到下标为(m+n)/2的元素(m+n为奇数)或者下标为(m+n)/2的元素+下标为(m+n)/2+1的元素/2.0(m+n为偶数)即可。从上面的思路我们得知,找中位数其实就是找下标为k的数,k=(m+n)/ 2或(m+n)/ 2 + 1,于是问题转化为求两个有序数组下标为k的元素(或称第k+1个元素)。
一般看到题目中要求时间复杂度为O(log(m+n)),就是要用二分法。假设两数组分别为A、B,要找到下标为k的元素,我们可以比较A[k/2 - 1]和B[k/2 - 1],下标为k/2-1说明其前面有k/2-1个元素,那么对于A[k/2-1]与B[k/2-1]中的最小值在合并之后的数组中其前面最多有k-2个元素(假设A[k/2-1]与B[k/2-1]中的最小值为A[k/2-1],当A[k/2-1] > B[k/2-2]时对应此情况),于是A[k/2-1]应该是下标为k-1的元素,而不是下标为k的元素,所以A[0]-A[k/2-1]都不是下标为k的元素,应该把这个区间的元素全部排除。同理,假设A[k/2-1]与B[k/2-1]中的最小值为B[k/2-1],则B[0]-B[k/2-1]都不是下标为k的元素,应该把这个区间的元素全部排除。当A[k/2-1]==B[k/2-1]时归到第一种情况。归纳一下:
- 如果A[k/2-1] < B[k/2-1],应该排除A[0]-A[k/2-1]的全部元素
- 如果B[k/2-1] < A[k/2-1],应该排除B[0]-B[k/2-1]的全部元素
- 如果A[k/2]-1 == B[k/2-1],可以归入第一种情况处理
我们可以看到,比较A[k/2-1]和B[k/2-1]之后我们可以排除掉k/2个元素,缩小了搜索范围,在排除后的新数组上继续进行二分查找,并且根据我们排除数的个数,减少k的值,这是因为我们排除的数都不大于下标为k的数。
对于减少k的值有三种特殊情况需要考虑:
- 如果A[k/2-1]或B[k/2-1]越界,那么我们可以选取对应数组中的最后一个元素。在这种情况下,我们必须根据排除数的个数来减少k的值,而不是像一般情况那样之间令k-k/2。
- 如果一个数组为空,说明该数组中的所有元素都被排除,我们可以直接返回另一个数组中下标为k的元素
- 如果k=1,我们只要返回两个数组首元素的最小值即可。
我们用一个例子来说明上述算法:假设A=[1,3,4,9],B=[1,2,3,4,5,6,7,8,9],两个数组的长度分别为4和9,长度之和为13,中位数是下标为k=13/2=6的元素,比较有序数组中下标为k/2-1=2的数即A[2]和B[2]:A[2]>B[2],
A: 1 3 4 9
↑
B: 1 2 3 4 5 6 7 8 9
↑
所以排除B[0]-B[2]的所有元素,B数组下标偏移变为3同时更新k的值为k=k-k/2=4。
下一步寻找,比较两个有序数组中下标为k/2-1=1的数,即A[1]和B[4],如下所示,方括号代表以及排除的数组。
A: 1 3 4 9
↑
B: [1 2 3] 4 5 6 7 8 9
↑
由于A[1]<B[4],因此排除A[0]-A[1]的全部元素,A数组下标偏移变为2,同时更新k的值为k=k-k/2=2。
下一步寻找,比较两个有序数组下标为k/2-1=0的数,即A[2]和B[3],如下所示。
A: [1 3] 4 9
↑
B: [1 2 3] 4 5 6 7 8 9
↑
由于A[2]=B[3],归为第一种情况,排除A[2]元素,A数组下标偏移变为3,同时更新k的值为k=k-k/2=1。这时由于k=1,所以应该比较两数组剩下元素的首元素,取最小的数,A[3]>B[3],因此第k个数是B[3] = 4。
题解
class Solution {
public:
int getKthElement(const vector<int>& nums1, const vector<int>& nums2, int k) {
int m = nums1.size();
int n = nums2.size();
int index1 = 0, index2 = 0;
while (true) {
// 边界情况
if (index1 == m) {
return nums2[index2 + k];
}
if (index2 == n) {
return nums1[index1 + k];
}
if (k == 0) {
return min(nums1[index1], nums2[index2]);
}
// 正常情况
int newIndex1 = min(index1 + (k+1) / 2 - 1, m - 1);
int newIndex2 = min(index2 + (k+1) / 2 - 1, n - 1);
int pivot1 = nums1[newIndex1];
int pivot2 = nums2[newIndex2];
if (pivot1 <= pivot2) {
k -= newIndex1 - index1 + 1;
index1 = newIndex1 + 1;
}
else {
k -= newIndex2 - index2 + 1;
index2 = newIndex2 + 1;
}
}
}
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int totalLength = nums1.size() + nums2.size();
if (totalLength % 2 == 1) {
return getKthElement(nums1, nums2, totalLength / 2);
}
else {
return (getKthElement(nums1, nums2, totalLength / 2) + getKthElement(nums1, nums2, totalLength / 2 - 1)) / 2.0;
}
}
};
第五章 千奇百怪的排序算法
5.1 常用排序算法
以下是一些最基本的排序算法,虽然在C++中可以使用sort()函数进行快速排序,而且在做题时很少需要自己手动实现排序算法,但是我们练习各种排序算法不仅可以加深对算法的理解,还可以解答由这些排序算法引申处理的题目。
快速排序(Quicksort)
快速排序的时间复杂度:最坏情况下,时间复杂度是O(n2); 最优情况下,时间复杂度是O(nlog2n);平均时间复杂度是O(nlog2n)。
快速排序空间复杂度:O(log2n)。
是否稳定:不稳定。
//左闭右闭
void quick_sort(vector<int>& nums, int left, int right) {
if (right - left <= 1) return;//
int first = left, last = right - 1, key = nums[first];//基准
while (first < last) {
while (first < last && nums[last] >= key) --last;
nums[first] = nums[last];
while (first < last && nums[first] <= key) ++first;
nums[last] = nums[first];
}
nums[first] = key;//完成划分
//递归划分
quick_sort(nums, l, first);//划分左区间
quick_sort(nums, first + 1, r); //划分右区间
}
归并排序(Merge Sort)
归并排序的时间复杂度:最坏情况下,时间复杂度是O(nlog2n); 最优情况下,时间复杂度是O(nlog2n);平均时间复杂度是O(nlog2n)。
归并排序空间复杂度:O(n)。
是否稳定:稳定。
//左闭右开
void merge_sort(vector<int>& nums, int left, int right, vector<int>& temp) {
if (right - left <= 1) return;
//分
int mid = left + (right - left) / 2;
merge_sort(nums, left, mid, temp);
merge_sort(nums,mid, right, temp);
//治
int p = left, q = mid, i = left;
while (p < mid || q < right) {//归并[left,mid)和[mid,right)
if (q >= right || (p < mid && nums[p] <= nums[q])) {//后半段归并完成或nums[p]<=nums[q],q >= right一定放前面
temp[i++] = nums[p++];
}
else {
temp[i++] = nums[q++];//前半段归并完成或nums[p]>nums[q]
}
}
for (i = left; i < right; ++i) nums[i] = temp[i];//将排序好的数组拷贝至原数组memcpy()
}
插入排序(Insertion Sort)
插入排序的时间复杂度:最坏情况下,时间复杂度是O(n2); 最优情况下,时间复杂度是O(n);平均时间复杂度是O(n2)。
插入排序空间复杂度:O(1)。
是否稳定:稳定。
void insert_sort(vector<int>& nums, int n) {
for (int i = 0; i < n; ++i) {
for (j = i; j > 0 && nums[j] < nums[j - 1]; --j) {
swap(nums[j], nums[j - 1]);
}
}
}
冒泡排序(Bubble Sort)
冒泡排序的时间复杂度:最坏情况下,时间复杂度是O(n2); 最优情况下,时间复杂度是O(n);平均时间复杂度是O(n2)。
插入排序空间复杂度:O(1)。
是否稳定:稳定。
void bubble_sort(vector<int>& nums, int n) {
bool swapped;
for (int i = 1; i < n; ++i) {
swapped = false;
for (int j = 1; j < n - i + 1; ++j) {
if (nums[j] < nums[j - 1]) {
swap[nums[j], nums[j - 1]];
swapped = true;
}
}
if (!swapped) break;//本轮无交换,说明排序完成
}
}
选择排序(Selection Sort)
冒泡排序的时间复杂度:最坏情况下,时间复杂度是O(n2); 最优情况下,时间复杂度是O(n2);平均时间复杂度是O(n2)。
插入排序空间复杂度:O(1)。
是否稳定:不稳定。
void selection_sort(vector<int>& nums, int n) {
int mid;
for (int i = 0; i < n - 1; ++i) {
mid = i;
for (int j = i + 1; j < n; ++j) {
if (nums[j] < nums[mid]) mid = j;//寻找最小元素
}
swap(nums[mid], nums[i]);//每遍历一轮就将本轮最小元素放置在起始位置
}
}
堆排序(Heap Sort)
堆排序的时间复杂度:最坏情况下,时间复杂度是O(nlog2n); 最优情况下,时间复杂度是O(nlog2n);平均时间复杂度是O(nlog2n)。
堆排序空间复杂度:O(1)。
是否稳定:不稳定
//下标从0开始
class Solution {
private:
void BuildMaxHeap(vector<int>& nums, int len) {
int last_node = len - 1;
int parent = (last_node - 1) / 2;//最后一个非叶节点下标
for (int i = parent; i >= 0; i--) {
HeapAdjust(nums, i, len);//从最后一个非叶子节点开始建堆
}
}
void HeapAdjust(vector<int>& nums, int i, int len) {
if (i >= len) return;//已经建成堆,递归终止
int lchild = i * 2 + 1;//左孩子下标
int rchild = i * 2 + 2;//右孩子下标
int max = i;//默认数值最大节点为该非叶子节点的值
if (lchild < len && nums[lchild] > nums[max]) max = lchild;
if (rchild < len && nums[rchild] > nums[max]) max = rchild;
if (max != i) {
swap(nums[max], nums[i]);//交换非叶子节点和其最大叶子节点
HeapAdjust(nums, max, len);//递归,继续向下修正
}
}
public:
void Heap_sort(vector<int>& nums, int len) {
BuildMaxHeap(nums, len);
for (int i = len - 1; i >= 0; --i) {
swap(nums[i], nums[0]);//输出大根堆堆顶元素(与栈底元素交换)
HeapAdjust(nums, 0, i);
}
}
};
上述代码调用方法为:
void sort() {
vector<int> nums = {1,3,5,7,2,6,4,8,9,2,8,7,6,0,3,5,9,4,1,0};
vector<int> temp(nums.size());
sort(nums.begin(), nums.end());//C++内置函数(快排)
quick_sort(nums, 0, nums.size());
merge_sort(nums, 0, nums.size(), temp);
insertion_sort(nums, nums.size());
bubble_sort(nums, nums.size());
selection_sort(nums, nums.size());
}
5.2 快速排序
215. 数组中的第K个最大元素
题目描述
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4
提示:
1 <= k <= nums.length <= 104
-104 <= nums[i] <= 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/kth-largest-element-in-an-array
流氓解法
看到本题直接
sort(nums.begin(), nums.end());
return nums[nums.size() - k];
冒泡排序
思路
啪一下很快昂。这就丧失了本题的意义。本题要找数组中的第K个最大元素,我们很容易想到冒泡排序和堆排序每次都能产生当前的最大值或最小值。
题解
//冒泡排序
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
bool swapped;
for (int i = 1; i <= k; ++i) {
swapped = false;
for (int j = 1; j < nums.size() - i + 1; ++j) {
if (nums[j] < nums[j - 1]) {
swap(nums[j], nums[j - 1]);
swapped = true;
}
}
}
return nums[nums.size() - k];
}
};
堆排序
题解
//堆排序
class Solution {
private:
void BuildMaxHeap(vector<int>& nums, int len) {
int last_node = len - 1;
int parent = (last_node - 1) / 2;//最后一个非叶节点下标
for (int i = parent; i >= 0; --i) {
HeapAdjust(nums, i, len);//从最后一个非叶子节点开始建堆
}
}
void HeapAdjust(vector<int>& nums, int i, int len) {
if (i >= len) return;//已经建成堆,递归终止
int lchild = i * 2 + 1;//左孩子下标
int rchild = i * 2 + 2;//右孩子下标
int max = i;//默认数值最大节点为该非叶子节点的值
if (lchild < len && nums[lchild] > nums[max]) max = lchild;
if (rchild < len && nums[rchild] > nums[max]) max = rchild;
if (max != i) {
swap(nums[max], nums[i]);//交换非叶子节点和其最大叶子节点
HeapAdjust(nums, max, len);//递归,继续向下修正
}
}
public:
int findKthLargest(vector<int>& nums, int k) {
int heapSize = nums.size();
BuildMaxHeap(nums, heapSize);
for (int i = nums.size() - 1; i >= nums.size() - k + 1; --i) {
swap(nums[0], nums[i]);
--heapSize;
HeapAdjust(nums, 0, heapSize);
}
return nums[0];
}
};
//时间复杂度O(nlogn),建堆时间代价为O(n),删除的总代价为O(klogn),k<n,所以渐进时间复杂度为O(n+klogn) = O(nlogn)
//空间复杂度O(logn),即递归使用栈空间的空间代价
快速选择(快排)
思路
我们可以用快速排序来解决这个问题,先对原数组排序,再返回倒数第k个位置,这样平均时间复杂度是O(nlog2n),其实我们可以做得更快。
回顾一下快速排序,快速排序每次划分操作后,我们一定可以确定一个元素的最终位置,即x的最终位置为q,所以只要某次划分的q为倒数第k个下标时,我们就已经找到了答案。至于nums[0…q-1]和nums[q+1…nums.size()-1]是否有序,我们并不需要关心。
快速选择一般用于求解k-th Element问题,可以在O(n)时间复杂度,O(1)空间复杂度完成求解工作。快速选择的实现和快速排序相似,不过只需找到第k大的枢(pivot)即可,不需要对其左右再进行排序。与快速排序一样,快速选择一般需要先打乱数组,否则最坏情况下时间复杂度为O(n2),我们这里为了方便省略掉了打乱的步骤。
具体来讲,我们基于快速排序实现一个辅助函数,其功能是根据nums左边界元素值来确定此元素在数组中的最终位置(pivot默认为左边界),并返回其下标。主函数中利用二分查找法查找target时,mid就是第k个最大元素的下标,返回其值;当mid<target时,说明这个已经确定最终位置的元素小于第k个最大元素,应该在右区间继续寻找;当mid>target时,说明应该在左区间寻找。
题解
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
int l = 0, r = nums.size() - 1, target = nums.size() - k;
while (l < r) {
int mid = quickSelection(nums, l, r);
if (mid == target) return nums[mid];
if (mid < target) l = mid + 1;
if (mid > target) r = mid - 1;
}
return nums[l];
}
private:
int quickSelection(vector<int>& nums, int l, int r) {//左闭右闭
int i = l + 1, j = r;//l为pivot
while (true) {
while (i < r && nums[i] <= nums[l]) ++i;
while (j > l && nums[j] >= nums[l]) --j;
if (i >= j) break;
swap(nums[i], nums[j]);//nums[i]>nums[l],nums[j]<nums[l],交换
}
swap(nums[l], nums[j]);//将pivot放入最终位置
return j;//返回最终位置下标
}
};
此解法因为默认pivot为左边界,并不能达到最好情况下时间复杂度,所以我们稍加修改,调用rand()函数来选取pivot,代码如下:
题解
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
srand(time(0));
int l = 0, r = nums.size() - 1, target = nums.size() - k;
while (l < r) {
int mid = quickSelection(nums, l, r);
if (mid == target) return nums[mid];
if (mid < target) l = mid + 1;
if (mid > target) r = mid - 1;
}
return nums[l];
}
private:
int quickSelection(vector<int>& nums, int l, int r) {//左闭右闭
int k = rand() % (r - l + 1) + l;
swap(nums[l], nums[k]);
int i = l + 1, j = r;//l为pivot
while (true) {
while (i < r && nums[i] <= nums[l]) ++i;
while (j > l && nums[j] >= nums[l]) --j;
if (i >= j) break;
swap(nums[i], nums[j]);//nums[i]>nums[l],nums[j]<nums[l],交换
}
swap(nums[l], nums[j]);//将pivot放入最终位置
return j;//返回最终位置下标
}
};
//时间复杂度O(n)
//空间复杂度O(1)
此代码可以做到和堆排序以及sort()函数近似的执行耗时。
5.3 桶排序
347. 前K个高频元素
题目描述
给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
示例 1:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
示例 2:
输入: nums = [1], k = 1
输出: [1]
提示:
1 <= nums.length <= 105
k 的取值范围是 [1, 数组中不相同的元素的个数]
题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的
进阶:你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n 是数组大小。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/top-k-frequent-elements
基于优先级队列实现小顶堆
思路
本题与215. 数组中的第k个最大元素类似,都可以使用堆排序和快速选择来解答。我们使用一个哈希表unordered_map::count来统计元素数组nums中元素出现次数,此时就可以把输出nums数组中前k个高频元素转化为输出count中前k个最大元素。为什么不直接对出现次数数组调用sort()函数呢?因为有可能每个元素只出现一次,从而时间复杂度达到O(nlogn),不满足题目要求。
对于使用堆排序算法,具体来讲,我们可以用priority_queue来实现一个小顶堆pri_que,将count中的元素不断push进pri_que,还要保证堆的大小始终小于等于k(通过.pop()函数将较小的元素弹出)。当count数组中的所有元素都push到pri_que中后,此时pri_que中只剩k个最大元素,只需将其元素倒序输出即可。
题解
class Solution {
public:
struct myComparsion {
bool operator() (const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;//大于号为小顶堆,
}
};
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> count;
for (int i = 0; i < nums.size(); ++i) ++count[nums[i]];
priority_queue<pair<int, int>, vector<pair<int, int>>, myComparsion> pri_que;
for (unordered_map<int, int>:: iterator it = count.begin(); it != count.end(); ++it) {
pri_que.push(*it);
if (pri_que.size() > k) pri_que.pop();
}
vector<int> result(k);
for (int i = k - 1; i >= 0; --i) {
result[i] = pri_que.top().first;
pri_que.pop();
}
return result;
}
};
//时间复杂度O(nlogk);遍历nums存入count_map时间复杂度O(n),遍历count_map中的元素并加入优先级队列(大小为k)调整时间复杂度为O(n*logk),总时间复杂度为O(nlogk)
//空间复杂度O(n),count_map大小O(n),优先级队列大小O(k),总空间复杂度为O(n)
手写堆
思路
我们也可以自己手写堆,这里需要注意的是,unordered_map的底层实现是哈希表hash table,不能在上直接建堆(比较时会报错),所以我们需另外定义一个容器来存储unordered_map中的内容,vector<pair<int, int>> count_vec。
#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>;
using namespace std;
class Solution {
private:
void BuildMaxHeap(vector<pair<int, int>>& count_vec, int len) {//建立大顶堆
int last_node = len - 1;
int parent = (last_node - 1) / 2;//最后一个非叶节点下标
for (int i = parent; i >= 0; --i) HeapAdjust(count_vec, i, len);
}
void HeapAdjust(vector<pair<int, int>>& count_vec, int i, int len) {//调整
if (i >= len) return;//递归终止条件
int lchild = i * 2 + 1;
int rchild = i * 2 + 2;
int max = i;//默认数值最小节点为该非叶子节点的值
if (lchild < len && count_vec[lchild].second > count_vec[max].second) max = lchild;
if (rchild < len && count_vec[rchild].second > count_vec[max].second) max = rchild;
if (max != i) {
swap(count_vec[max], count_vec[i]);
HeapAdjust(count_vec, max, len);
}
}
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> count_map;//存储每个元素出现次数
for (int i = 0; i < nums.size(); i++) ++count_map[nums[i]];
vector<pair<int, int>> count_vec;//存到vector方便建堆
for (unordered_map<int, int>:: iterator it = count_map.begin(); it != count_map.end(); ++it) {
count_vec.push_back(make_pair(it -> first, it -> second));
}
int heapsize = count_vec.size();
BuildMaxHeap(count_vec, heapsize);
vector<int> result;
for (int i = count_vec.size() - 1; i >= static_cast<int>(count_vec.size() - k); --i) {//将前k个高频元素加入result,
//i是int型整数,-1转化成unsigned int型则是一个很大的正数,循环条件会一直满足,可以将size_t类型转化成int
//也可将条件改成result.size() < k,建议改成这个,运行时间会快一点
result.push_back(count_vec[0].first);
swap(count_vec[0], count_vec[i]);
--heapsize;
HeapAdjust(count_vec, 0, heapsize);
}
return result;
}
};
//时间复杂度O(nlogn),建堆时间代价为O(n),删除调整的总代价为O(klogn),k<n,所以渐进时间复杂度为O(n+klogn) = O(nlogn)
//空间复杂度O(logn),即递归使用栈空间的空间代价
快速选择(快排)
思路
我们也可以使用快速选择算法来求频率前k高的元素,因为题目说明可以按任意顺序返回答案,所以当我们找到count_vec第k大元素的最终位置时,此元素之后的所有元素就是我们要求的结果。和上个题解一样,我们也不能直接在unordered_map上建堆,需要另外定义一个容器count_vec。
题解
class Solution {
private:
int quickSelection(vector<pair<int, int>>& count_vec, int l, int r) {
int k = rand() % (r - l + 1) + l;//随机选择pivot
swap(count_vec[l], count_vec[k]);
int i = l + 1, j = r;//l为pivot
while (true) {
while (i < r && count_vec[i].second <= count_vec[l].second) ++i;
while (j > l && count_vec[j].second >= count_vec[l].second) --j;
if (i >= j) break;
swap(count_vec[i], count_vec[j]);
}
swap(count_vec[l], count_vec[j]);
return j;//返回最终位置下标
}
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> count_map;
for (int i = 0; i < nums.size(); i++) ++count_map[nums[i]];
vector<pair<int, int>> count_vec;
for (unordered_map<int, int>:: iterator it = count_map.begin(); it != count_map.end(); ++it) {
count_vec.push_back(make_pair(it -> first, it -> second));
}
srand(time(0));
int l = 0, r = count_vec.size() - 1, target = count_vec.size() - k;
vector<int> result;
while (l < r) {
int mid = quickSelection(count_vec, l, r);
if (mid == target) {
for (int i = target; i < count_vec.size(); ++i) {
result.push_back(count_vec[i].first);
}
return result;
}
if (mid > target) r = mid - 1;
if (mid < target) l = mid + 1;
}
for (int i = l; i < count_vec.size(); ++i) {
result.push_back(count_vec[i].first);
}
return result;
}
};
桶排序
思路
这里我们再尝试一个新的解法:桶排序。顾名思义,桶排序的意思是为每个值设立一个桶,桶内记录这个值出现的次数(或其他属性),然后对桶进行排序。针对nums=[1,1,1,1,2,2,3,4],k=2来说,我们先通过桶排序得到四个桶[1,2,3,4],它们的值分别为[4,2,1,1],表示每个数值出现的次数。紧接着,我们对桶的频次进行排序,前k大个桶就是前k个频繁的数。这里我们可以使用各种排序算法,甚至可以再进行一次桶排序,把每个旧桶根据频次放入不同的新桶中。针对上述样例,因为目前最大的频次是4,我们建立[1,2,3,4]四个新桶,他们分别放入的旧桶为[[3,4],[2],[],[1]],表示不同数字出现的频率。最后我们从后往前遍历,直到找到k个旧桶。
题解
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> count_map;
int max_count = 0;//统计最大频次
for (const int& num: nums) {
max_count = max(max_count, ++count_map[num]);
}
vector<vector<int>> buckets(max_count + 1);//建立新桶[[],[],...[]]
for (const auto& p : count_map) {
buckets[p.second].push_back(p.first);//在新桶内放入旧桶(在对应频次内存入该频次对应的数值)
}
vector<int> result;//保存结果
for (int i = max_count; i >= 0 && result.size() < k; --i) {//从后往前遍历新桶
for (const int& num : buckets[i]) {//遍历每个桶内的元素值
result.push_back(num);
if (result.size() == k) break;
}
}
return result;
}
};
5.4 练习
451. 根据字符出现频率排序
题目描述
给定一个字符串 s ,根据字符出现的 频率 对其进行 降序排序 。一个字符出现的 频率 是它出现在字符串中的次数。
返回 已排序的字符串 。如果有多个答案,返回其中任何一个。
示例 1:
输入: s = “tree”
输出: “eert”
解释: 'e’出现两次,'r’和’t’都只出现一次。
因此’e’必须出现在’r’和’t’之前。此外,"eetr"也是一个有效的答案。
示例 2:
输入: s = “cccaaa”
输出: “cccaaa”
解释: 'c’和’a’都出现三次。此外,"aaaccc"也是有效的答案。
注意"cacaca"是不正确的,因为相同的字母必须放在一起。
示例 3:
输入: s = “Aabb”
输出: “bbAa”
解释: 此外,"bbaA"也是一个有效的答案,但"Aabb"是不正确的。
注意’A’和’a’被认为是两种不同的字符。
提示:
1 <= s.length <= 5 * 105
s 由大小写英文字母和数字组成
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/sort-characters-by-frequency
桶排序
思路
此题思路与上一题桶排序思路基本一致,需要注意的是,我们建立的每个新桶中存放的元素是不重复的,但题目要求输出降频次排序的完整字符串,所以我们要根据该元素的频次将该元素多次加入到result中。
题解
class Solution {
public:
string frequencySort(string s) {
unordered_map<char, int> count_map;
int max_count = 0;//记录最大频次,用于后续建立新桶
for (const char& c : s) {
max_count = max(max_count, ++count_map[c]);
}
vector<vector<char>> buckets(max_count + 1);//建立新桶
for (const auto& p : count_map) {
buckets[p.second].push_back(p.first);//在新桶内放入旧桶,(在对应频次内存入该频次对应数值
}
string result;
for (int i = max_count; i >= 1; --i) {
vector<char>& bucket = buckets[i];
for (const char& c : bucket)
for (int j = 0; j < i; j++)
result.push_back(c);
}
return result;
}
};
//时间复杂度O(n+k),n为字符串长度,k为字符串s包含不同字符的个数。
//统计每个字符出现的频次O(n)时间,创建桶并将不同字符加入桶需要O(k)时间,生成排序后的字符串需要O(k)的时间遍历桶,将字符拼接成字符串需要O(n)
//空间复杂度O(n+k),生成桶O(k),生成排序后字符串O(n)
优先级队列(大顶堆)
思路
和347.前k个高频元素类似,我们将每个字符的频率存储在unordered_map类型的容器count_map中,再将这个容器的所有容器加入优先级队列(频率高,优先级高)之中,最后输出
题解
class Solution {
public:
struct myComparsion {
bool operator() (const pair<char, int>& lhs, const pair<char, int>& rhs) {
return lhs.second < rhs.second;//大顶堆是小于号
}
};
string frequencySort(string s) {
unordered_map<char, int> count_map;
for (const char& c : s) ++count_map[c];//统计字符频次
priority_queue<pair<char, int>, vector<pair<char, int>>, myComparsion> pri_que;//定义优先级队列
for (unordered_map<char, int> :: iterator it = count_map.begin(); it != count_map.end(); ++it) {
pri_que.push(*it);//加入优先级队列中(second大即频次大的优先级高)
}
string result;
for (int i = 0; !pri_que.empty(); ++i) {//将优先级队列全部元素.first从头到尾以频次输出
for (int j = 0; j < count_map[pri_que.top().first]; ++j) {
result.push_back(pri_que.top().first);
}
pri_que.pop();
}
return result;
}
};
//时间复杂度O(nlogn);遍历nums存入count_map时间复杂度O(n),遍历count_map中的元素并加入优先级队列(大小为<n)调整时间复杂度为O(n*logn),总时间复杂度为O(nlogn)
//空间复杂度O(n),count_map大小O(n),优先级队列大小O(n),总空间复杂度为O(n)
手写大顶堆
思路
题解
class Solution {
private:
void BuildMaxHeap(vector<pair<char, int>>& count_vec, int len) {//建立大顶堆
int last_node = len - 1;
int parent = (last_node - 1) / 2;//堆顶下标为0,要先最后元素下标-1之后/2求的才是最后一个非叶节点下标
for (int i = parent; i >= 0; --i) HeapAdjust(count_vec, i, len);//调整
}
void HeapAdjust(vector<pair<char, int>>& count_vec, int i, int len) {
if (i >= len) return;//递归出口
int lchild = i * 2 + 1;
int rchild = i * 2 + 2;
int max = i;//默认数值最大节点为该非叶子节点的值
if (lchild < len && count_vec[lchild].second > count_vec[max].second) max = lchild;
if (rchild < len && count_vec[rchild].second > count_vec[max].second) max = rchild;
if (max != i) {
swap(count_vec[max], count_vec[i]);
HeapAdjust(count_vec, max, len);//递归继续向下调整
}
}
public:
string frequencySort(string s) {
unordered_map<char, int> count_map;
for (const char& c : s) ++count_map[c];
vector<pair<char, int>> count_vec;
for (const pair<char, int>& pair : count_map) count_vec.push_back(pair);
int heapsize = count_vec.size();
BuildMaxHeap(count_vec, heapsize);
string result;
for (int i = count_vec.size() - 1; i >= 0; --i) {
for (int j = 0; j < count_map[count_vec[0].first]; ++j) {
result.push_back(count_vec[0].first);
}
swap(count_vec[0], count_vec[i]);
--heapsize;
HeapAdjust(count_vec, 0, heapsize);
}
return result;
}
};
根据频次大小进行排序
思路
有这样一种解法,直接调用sort()函数来对字符串中的字符进行排序,sort()函数根据什么来排序呢?根据unordered_map中存放的各字符频次进行排序。当然这种解法速度很慢,因为sort()函数是基于快排的,时间复杂度为O(nlogn)。
class Solution {
public:
string frequencySort(string s) {
unordered_map<char, int> count_map;
for(const char& c : s) count_map[c]++;
sort(s.begin(),s.end(),[&](const char &a, const char &b){//根据频次由高到低排序
return count_map[a] == count_map[b] ? a > b : count_map[a] > count_map[b];
});
return s;
}
};
//时间复杂度O(nlogn),快排O(nlogn)
//空间复杂度O(logn),快排需要递归,递归工作栈深度logn
75. 颜色分类
题目描述
给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。必须在不使用库的sort函数的情况下解决这个问题。
示例 1:
输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]
示例 2:
输入:nums = [2,0,1]
输出:[0,1,2]
提示:
n == nums.length
1 <= n <= 300
nums[i] 为 0、1 或 2
进阶:
你可以不使用代码库中的排序函数来解决这道题吗?
你能想出一个仅使用常数空间的一趟扫描算法吗?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/sort-colors
根据频次修改原数组
思路
本题是经典的荷兰国旗问题,将数组的元素分为三块。因为数组中只包含0、1、2三个元素,因此我们可以对数组执行各种排序算法:
- 如果使用快排、归并排序等算法能做是能做,但都会达不到本题的进阶要求:一趟扫描和空间复杂度O(n),题解略去不写。
- 因为本题数组只有0、1、2,我们可以申请大小为3的数组来使用计数排序算法,然后根据统计出来的频次修改原nums数组。
题解
class Solution {
public:
void sortColors(vector<int>& nums) {
vector<int> count(3, 0);
for(auto i : nums){
++count[i];
}
for(int i = 0; i < nums.size(); ++i){
if(i < count[0]) nums[i] = 0;
else if(i < count[0] + count[1]) nums[i] = 1;
else nums[i] = 2;
}
}
};
划分区域
思路
但这种解法并不是将数组排序,而是修改数组。再仔细读一读本题,将问题抽象:将数组划分为三块,前面都是0,中间都是1,后面都是2,也就是说划分之后每块中间其实是不要求有序的。这样就很自然的想到我们之前就介绍过的一种算法:快速选择,它通常用来求解第K个问题,其实也就是将数组划分为三块:第k个元素之前、第k个元素、第k个元素之后。
针对于本题具体来讲,我们需要定义两个边界:zero_right,表示[0,zero_right)={0},two_left,表示[two_left, size) = {2},此外我们需要定义一个指针i,指向遍历的元素。排序完成后[zero_right, i) = {1}。
首先初始化zero_right = 0,two_left = nums.size(),以确保0,2区域为空,之后初始化指针i为0,开始遍历元素(while循环条件为i < two_left)。
- 当指针指向的元素值为0时,说明此元素属于0区域,应将它与0区域右边界交换(加入0区域),之后将0区域右边界后移,同时指针i后移
- 当指针指向的元素值为1时,说明此元素属于1区域,又因为指针i始终在0区域右边界之后(看代码),所以指针i所指元素所在的区域就是1区域,不用操作,指针i后移即可.
- 当指针指向的元素值为2时,说明此元素属于2区域,应先将2区域左边界前移动(左闭右开),再将它与2区域左边界交换(加入2区域),之后,因为交换过来的元素还为遍历,不知道是什么元素,还要再访问此元素,所以指针不移动。
题解
class Solution {
public:
void sortColors(vector<int> &nums) {
int size = nums.size();
if (size < 2) return;
int zero_right = 0;
int two_left = size;
int i = 0;
while (i < two_left) {
if (nums[i] == 0) {
swap(nums[zero_right], nums[i]);
++zero_right;
++i;
}
else if (nums[i] == 1) ++i;
else if (nums[i] == 2) {
--two_left;
swap(nums[i], nums[two_left]);
}
}
}
};
第六章 一切皆可搜索
6.1 算法解释
深度优先搜索和广度优先搜索是两种最常见的优先搜索方法,它们被广泛地运用在图和树等 结构中进行搜索。
6.2 深度优先搜索
深度优先搜索(depth-first search,DFS)在搜索到一个新节点时,立即对该新节点进行遍历,而不是处理,为保存还未处理的节点,我们需要用先进后出的栈来实现,也可以通过与栈等价的递归来实现。对于树形结构来说,由于总是对新节点调用遍历,因此看起来是向深处前进。
考虑如下一颗简单的树。我们从 1 号节点开始遍历,假如遍历顺序是从左子节点到右子节点, 那么按照优先向着“深”的方向前进的策略,假如我们使用递归实现,我们的遍历过程为 1(起 始节点)->2(遍历更深一层的左子节点)->4(遍历更深一层的左子节点)->2(无子节点,返回 父结点)->1(子节点均已完成遍历,返回父结点)->3(遍历更深一层的右子节点)->1(无子节 点,返回父结点)-> 结束程序(子节点均已完成遍历)。如果我们使用栈实现,我们的栈顶元素 的变化过程为 1->2->4->3。
1
/ \
2 3
/
4
深度优先搜索也可以用来检测环路:记录每个遍历过的节点的父节点,若一个节点被再次遍 历且父节点不同,则说明有环。我们也可以用之后会讲到的拓扑排序判断是否有环路,若最后存 在入度不为零的点,则说明有环。
有时我们可能会需要对已经搜索过的节点进行标记,以防止在遍历时重复搜索某个节点,这 种做法叫做状态记录或记忆化(memoization)。
695. 岛屿的最大面积
题目描述
给你一个大小为 m x n 的二进制矩阵 grid 。
岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在 水平或者竖直的四个方向上 相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。
岛屿的面积是岛上值为 1 的单元格的数目。
计算并返回 grid 中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。
示例 1:
输入:
grid =
[[0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,1,1,0,1,0,0,0,0,0,0,0,0],
[0,1,0,0,1,1,0,0,1,0,1,0,0],
[0,1,0,0,1,1,0,0,1,1,1,0,0],
[0,0,0,0,0,0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,0,0,0,0,0,0,1,1,0,0,0,0]]
输出:6
解释:答案不应该是 11 ,因为岛屿只能包含水平或垂直这四个方向上的 1 。
示例 2:
输入:grid = [[0,0,0,0,0,0,0,0]]
输出:0
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 50
grid[i][j] 为 0 或 1
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/max-area-of-island
深度优先搜索(栈)
思路
此题是十分标准的搜索题,我们可以拿来练手深度优先搜索。当然,我们也可以使用栈 (stack)实现深度优先搜索,但因为栈与递归的调用原理相同,而递归相对便于实现,因此刷题时笔者推荐使用递归式写法,同时也方便进行回溯(见下节)。不过在实际工程上,直接使用栈可 能才是最好的选择,一是因为便于理解,二是更不易出现递归栈满的情况。我们先展示使用栈的写法。
用栈模拟递归的解法具体来讲,我们遍历矩阵的每个元素,如果值等于1(是土地)则初始化当前岛屿面积为1并将该元素下标压入栈island中,同时要将元素值修改为0以防多次遍历,当栈island不空时,弹出栈顶元素进行处理(深度优先),通过辅助数组direction={-1,0,1,0,-1},我们可以检查当前元素下右上左四个方向上是否有土地。如果有土地,则先将元素值修改为0,再将当前岛屿面积+1,最后将该元素下标压入island栈中。每次遍历一个土地单元格我们都更新最大岛屿面积。
通俗来讲,就是遍历每个单元格,当单元格是土地时开始深度优先搜索,将相邻为土地的单元格都压入栈中(属于一个岛屿),直到该岛屿不能再扩大(四周靠水)。每遍历一个土地单元格我们就更新一下最大岛屿面积,最后就能得到最大岛屿面积。
题解
class Solution {
public:
int maxAreaOfIsland(vector<vector<int>>& grid) {
vector<int> direction{-1, 0, 1, 0, -1};
int m = grid.size(), n = m ? grid[0].size() : 0, current_island, max_island = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j]) {
current_island = 1;
stack<pair<int, int>> island;
island.push({i, j});
grid[i][j] = 0;
while (!island.empty()) {
auto [r, c] = island.top();
island.pop();
for (int k = 0; k < direction.size() - 1; ++k) {
int x = r + direction[k], y = c + direction[k + 1];
if (x >= 0 && x < m && y >= 0 && y < n && grid[x][y] == 1) {
++current_island;
island.push({x, y});
grid[x][y] = 0;
}
}
}
max_island = max(max_island, current_island);
}
}
}
return max_island;
}
};
深度优先搜索(递归)
思路
一般来说,深度优先搜索类型的题可以分为主函数和辅函数,主函数用于遍历所有的搜索位置,判断是否可以开始搜索,如果可以即在辅函数进行搜索。辅函数则负责深度优先搜索的递归调用。因此刷题时笔者推荐使用递归式写法,同时也方便进行回溯(见下节)
在辅函数里,一个一定要注意的点是辅函数内递归搜索时,边界条件的判定。边界判定一般有两种写法:
- 一种是先判定是否越界,只有在合法的情况下才进行下一步搜索(即判断放在调用递归函数前)。
递归解法一具体来讲,和栈解法一样,遍历每个单元格(在主函数里),当当前单元格为土地时(先判断再调用递归函数),就要进行调用辅函数进行dfs搜索了。辅函数中递归出口是当前单元格为水,当当前单元格为土地时则对其四周为土地的单元格进行递归调用,累加当前岛屿面积。
题解
class Solution {
public:
vector<int> direction{-1, 0, 1, 0, -1};
int maxAreaOfIsland(vector<vector<int>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
int max_island = 0;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[0].size(); ++j) {
if (grid[i][j]) max_island = max(max_island, dfs(grid, i, j));//调用递归函数
}
}
return max_island;
}
private:
int dfs(vector<vector<int>>& grid, int r, int c) {
if (!grid[r][c]) return 0;//当前单元格为水,岛屿面积为0
grid[r][c] = 0;
int current_island = 1;
for (int i = 0; i < direction.size() - 1; ++i) {
int x = direction[i] + r, y = direction[i + 1] + c;
if (x >= 0 && x < grid.size() && y >= 0 && y < grid[0].size()) {
current_island += dfs(grid, x, y);//递归调用
}
}
return current_island;//返回当前岛屿面积
}
};
- 另一种是不管三七二十一先进行下一步搜索,待下一步搜索开始时再判断是否合法(即判断放在辅函数第一行)。我们这里分别展示这两种写法。
解法二具体来讲,就是主函数内不进行判断,辅函数内将单元格是水作为递归出口。辅助函数返回单元格为土地时,其四周的面积。
class Solution {
public:
int maxAreaOfIsland(vector<vector<int>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
int max_island = 0;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[0].size(); ++j) {
max_island = max(max_island, dfs(grid, i, j));//调用递归函数
}
}
return max_island;
}
private:
int dfs(vector<vector<int>>& grid, int r, int c) {
if (r < 0 || r >= grid.size() || c < 0 || c >= grid[0].size() || grid[r][c] == 0) return 0;
grid[r][c] = 0;
return 1 + dfs(grid, r + 1, c) + dfs(grid, r - 1, c) + dfs(grid, r, c + 1) + dfs(grid, r, c - 1);
}
};
这种方法用时和内存消耗都很优秀,但不好理解,我们面试时优先写出第一种解法,之后再优化。
547. 省份数量
题目描述
有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。
省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1
表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0
表示二者不直接相连。
返回矩阵中 省份 的数量。
示例 1:
输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]]
输出:2
示例 2:
输入:isConnected = [[1,0,0],[0,1,0],[0,0,1]]
输出:3
提示:
1 <= n <= 200
n == isConnected.length
n == isConnected[i].length
isConnected[i][j]
为 1 或 0
isConnected[i][i] == 1
isConnected[i][j] == isConnected[j][i]
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/number-of-provinces
思路
对于题目 695.岛屿的最大面积,图的表示方法是,每个位置代表一个节点,每个节点与上下左右四个节点相邻。而在这一道题里面,每一行(列)表示一个节点,它的每列(行)表示是否存在一个相邻节点。因此题目 695 拥有 m × n 个节点,每个节点有 4 条边;而本题拥有 n 个节点,每个节点最多 有 n 条边,表示和所有节点都直接相邻,最少可以有 1 条边,表示自己与自己相邻。当清楚了图的表 示方法后,这道题与题目 695 本质上是同一道题:搜索省份(岛屿)的个数(最大面积)。
深度优先搜索(递归)
思路
我们这里先采用递归解法一,具体来讲,主函数首先定义int型变量count用来统计省份的个数,之后定义数组visited用来记录节点是否访问过。遍历n个节点中的每一个,如果当前节点未访问过,则调用辅函数(递归函数)深度搜索此节点,同时count+1(因为每遍历一个未访问过的节点就说明此节点属于一个新的省份)。辅助函数首先将此节点标记为已访问,之后遍历所有节点,当与其他节点相连且这个其他节点未访问过,则说明他俩属于一个省份,继续递归。
题解
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size(), count = 0;
vector<bool> visited(n, false);
for (int i = 0; i < n; ++i) {
if (!visited[i]) {
++count;
dfs(isConnected, i, visited);
}
}
return count;
}
private:
void dfs(vector<vector<int>>& isConnected, int i, vector<bool>& visited) {
visited[i] = true;
for (int j = 0; j < isConnected.size(); ++j) {
if (isConnected[i][j] == 1 && !visited[j]) {//i与j相连,且j节点未访问,说明属于一个省份
dfs(isConnected, j, visited); //继续递归调用,直到没有与这个省份相连的城市
}
}
}
};
解法二因为没有在调用递归函数之前判断是否合法,所以不容易统计count。
深度优先搜索(栈)
思路
我们定义一个栈来保存未处理的节点,当栈不空时弹出节点进行处理。注意每访问一个节点都要将该节点设置为已经访问,避免重复访问节点。for循环遍历没访问过的节点,如果没访问过,就说明未与访问过的节点组成省份,需要将count+1,说明这是新的省份。
题解
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size(), count = 0;
vector<bool> visited(n, false);
for (int i = 0; i < n; ++i) {
if (!visited[i]) {
++count;
visited[i] = true;
stack<int> city;
city.push(i);
while (!city.empty()) {
int j = city.top();
city.pop();
for (int k = ; k < n; ++k) {
if (!visited[k] && isConnected[j][k]) {
visited[k] = true;
city.push(k);
}
}
}
}
}
return count;
}
};
417. 太平洋大西洋水流问题
题目描述
有一个 m × n 的矩形岛屿,与 太平洋 和 大西洋 相邻。 “太平洋” 处于大陆的左边界和上边界,而 “大西洋” 处于大陆的右边界和下边界。
这个岛被分割成一个由若干方形单元格组成的网格。给定一个 m x n 的整数矩阵 heights , heights[r][c]
表示坐标 (r, c) 上单元格 高于海平面的高度 。
岛上雨水较多,如果相邻单元格的高度 小于或等于 当前单元格的高度,雨水可以直接向北、南、东、西流向相邻单元格。水可以从海洋附近的任何单元格流入海洋。
返回网格坐标 result 的 2D 列表 ,其中 result[i] = [ri, ci] 表示雨水从单元格 (ri, ci) 流动 既可流向太平洋也可流向大西洋 。
示例 1:
输入: heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]
输出: [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]
示例 2:
输入: heights = [[2,1],[1,2]]
输出: [[0,0],[0,1],[1,0],[1,1]]
提示:
m == heights.length
n == heights[r].length
1 <= m, n <= 200
0 <= heights[r][c]
<= 105
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/pacific-atlantic-water-flow
思路
我们很容易想到遍历每一个单元格,对其进行搜索,判断其雨水是否可以向下流入两大洋的位置。但是这种解法在不剪枝的情况下复杂度会很高(经常重复搜索一个单元格)。因此我们需要动用逆向思维,考虑”水往高处流“,从两大洋开始向海拔高的单元格流,这样只需对矩阵四条边进行搜索了。搜索完成,只需遍历一遍矩阵,满足条件的位置即为两大洋向上流都能到达的位置(这个位置向下流可以流入两大洋)
具体来讲,我们定义两个二维bool型数组can_reach_p和can_reach_a,代表能够流向太平洋和大西洋。首先将矩阵的四条边上的每一个单元格遍历搜索一遍,左边和上边结果的存入can_reach_p,右边和下边的结果存入can_reach_a,此时can_reach_p和can_reach_p保存的就是各个单元格的水是否能流入太平洋或大西洋。之后再遍历整个矩阵,如果单元格既可以流入太平洋也可以流入大西洋,则加入结果集。
辅助函数的递归出口是can_reach[r][c] = true
,这个单元格如果已经可以流入某个海洋(或者说已经被处理过)则返回(不再重复处理),将can_reach[r][c]置为true,之后遍历其四周单元格,如果四周单元格{x,y}海拔比它高,说明水可以流向{x,y}(水往高处流),即{x,y}的水可以流向某个海洋,对{x,y}进行递归搜索,直到水不能再流向任何单元格。
题解
class Solution {
public:
vector<int> direction{-1, 0, 1, 0, -1};
vector<vector<int>> pacificAtlantic(vector<vector<int>>& heights) {
if (heights.empty() || heights[0].empty()) return {};
vector<vector<int>> result;
int m = heights.size(), n = heights[0].size();
vector<vector<bool>> can_reach_p(m, vector<bool>(n, false));
vector<vector<bool>> can_reach_a(m, vector<bool>(n, false));
for (int i = 0; i < m; ++i) {
dfs(heights, can_reach_p, i, 0);//左
dfs(heights, can_reach_a, i, n - 1);//右
}
for (int j = 0; j < n; ++j) {
dfs(heights, can_reach_p, 0, j);//上
dfs(heights, can_reach_a, m - 1, j);//下
}
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (can_reach_p[i][j] && can_reach_a[i][j]) {
result.push_back(vector<int> {i, j});
}
}
}
return result;
}
private:
void dfs(const vector<vector<int>>& heights, vector<vector<bool>>& can_reach, int r, int c) {
if (can_reach[r][c]) return;
can_reach[r][c] = true;
for (int i = 0; i < direction.size() - 1; ++i) {
int x = r + direction[i], y = c + direction[i + 1];
if (x >= 0 && x < heights.size() && y >= 0 && y < heights[0].size() && heights[r][c] <= heights[x][y]) {
dfs(heights, can_reach, x, y);
}
}
}
};
6.3 回溯法
回溯法(backtracking)是优先搜索的一种特殊情况,又称为试探法,常用于需要记录节点状 态的深度优先搜索。通常来说,排列、组合、选择类问题使用回溯法比较方便。 顾名思义,回溯法的核心是回溯。**在搜索到某一节点的时候,如果我们发现目前的节点(及其子节点)并不是需求目标时,我们回退到原来的节点继续搜索,并且把在目前节点修改的状态还原。**这样的好处是我们可以始终只对图的总状态进行修改,而非每次遍历时新建一个图来储存状态。在具体的写法上,它与普通的深度优先搜索一样,都有 [修改当前节点状态]→[递归子节点] 的步骤,只是多了回溯的步骤,变成了 [修改当前节点状态]→[递归子节点]→[回改当前节点状态]。 没有接触过回溯法的读者可能会不明白我在讲什么,这也完全正常,希望以下几道题可以让 您理解回溯法。如果还是不明白,可以记住两个小诀窍,一是按引用传状态,二是所有的状态修改在递归完成后回改。 回溯法修改一般有两种情况,一种是修改最后一位输出,比如排列组合;一种是修改访问标记,比如矩阵里搜字符串。
46. 全排列
题目描述
给定一个不含重复数字的数组 nums ,返回其所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/permutations
回溯模板解法
思路
我们之前在代码随想录中总结过回溯模板,所以我们首先使用模板来解答这道题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SQywCAIY-1657023302342)(pictureforMD/image-20220617111227286.png)]
1.确定递归函数的参数和返回值。
返回值为void,首先排列是有序的,也就是说[1,2]和[2,1]是两个集合,这是与之前分析的子集及组合所不同的地方。可以看出元素1在[1,2]中使用过了,但在[2,1]还要再使用一次1,所以处理排列问题就不能使用startIndex了。排列问题还需要一个used数组,用于标记已经选择的元素。
2.确定递归终止条件
可以从图中看出搜索到叶子节点就是收集结果的地方。当path数组的长度等于nums数组长度时,就说明找到了一个全排列,也表示到达了叶子节点。
3.确定单层搜索的逻辑
这里和组合问题,分割问题,子集问题最大的不同就是for循环里不用startIndex了。每次for循环要从0开始从头搜索,可以看出元素1在[1,2]中使用过了,但在[2,1]还要再使用一次1。而used数组用来记录此时path中都有哪些元素被使用过,一个排列中的元素只能使用一次。
题解
class Solution {
private:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums, vector<bool>& used) {
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (used[i]) continue;//说明此元素这条分支之前已经使用过,不再重复选取
used[i] = true;//选取nums[i]元素
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
基于交换的回溯法
思路
怎样输出所有的排列方式呢?对于每一个当前位置 i,我们可以将其于之后的任意位置交换, 然后继续处理位置 i+1,直到处理到最后一位。为了防止我们每此遍历时都要新建一个子数组储 存位置 i 之前已经交换好的数字,我们可以利用回溯法,只对原数组进行修改,在递归完成后再 修改回来。 我们以样例[1,2,3]为例,按照这种方法,我们输出的数组顺序为[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]],可以看到所有的排列在这个算法中都被考虑到了。
具体来讲,在递归函数中,递归出口是当交换的起点level为nums最后一个元素时,说明后面已经没有元素可以交换了,则将nums(交换过的)加入结果集。在递归函数中for循环将交换起点level和其后面的元素进行交换(对应二叉树的横向),之后递归调用backtracking函数将交换起点+1,将交换起点后移(对应二叉树的纵向)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0pNB2pAk-1657023302343)(pictureforMD/46全排列.drawio.png)]
题解
class Solution {
private:
void backtracking(vector<int>& nums, int level, vector<vector<int>>& result) {
if (level == nums.size() - 1) {
result.push_back(nums);
return;
}
for (int i = level; i < nums.size(); ++i) {
swap(nums[i], nums[level]);
backtracking(nums, level + 1, result);
swap(nums[i], nums[level]);//回溯
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> result;
backtracking(nums, 0, result);
return result;
}
};
此方法由于没有使用path来存储当前结果,所以内存消耗较少。
77. 组合
题目描述
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
提示:
1 <= n <= 20
1 <= k <= n
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/combinations
回溯模板解法
思路
1.确定递归函数返回值和参数
返回值为空,参数为n,k(否则不能获得n,k)和startIndex为了记录本轮递归遍历的起点(组合是防止重复选取)。
2.确定递归函数的终止条件
当path大小等于k时,说明找到了大小为k的组合,收集结果结束本层递归。
3.确定单层搜索的过程
回溯法的过程就是一个树形结构的遍历过程。for循环每次从startIndex开始遍历,然后用path数组保存获取节点i。可以看出递归函数通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到叶子节点就返回。递归函数后序的操作就是回溯,即撤销本次处理的结果,处理同层的右分支。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3n0OC4UM-1657023302343)(pictureforMD/image-20220617191957160.png)]
题解
class Solution {
private:
vector<int> path;
vector<vector<int>> result;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n; ++i) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();//回溯
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
回溯法虽然是暴力搜索,但有时候也可以通过剪枝来优化。在上面代码中for循环是可以优化的。举个例子,如果n=4,k=4,那么在第一个for循环中,从元素2开始的遍历都没有意义。在第二层的for循环中,从元素3开始的遍历都没有意义。所以,可以剪枝的地方就在递归过程中每一层的for循环所选择的起始位置。如果for循环选择的起始位置(包括起始位置)之后的元素个数已经少于我们需要的元素个数,那么 就没有必要搜索了。剩余元素个数n-i+1 要大于等于 k - path.size()。
class Solution {
private:
vector<int> path;
vector<vector<int>> result;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();//回溯
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
79. 单词搜索
题目描述
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-njCmwCKn-1657023302343)(pictureforMD/word2.jpg)]
输入:board = [[“A”,“B”,“C”,“E”],[“S”,“F”,“C”,“S”],[“A”,“D”,“E”,“E”]], word = “ABCCED”
输出:true
示例 2:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FluqCSzB-1657023302343)(pictureforMD/word-1.jpg)]
输入:board = [[“A”,“B”,“C”,“E”],[“S”,“F”,“C”,“S”],[“A”,“D”,“E”,“E”]], word = “SEE”
输出:true
示例 3:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vEVWVptM-1657023302344)(pictureforMD/word3.jpg)]
输入:board = [[“A”,“B”,“C”,“E”],[“S”,“F”,“C”,“S”],[“A”,“D”,“E”,“E”]], word = “ABCB”
输出:false
提示:
m == board.length
n = board[i].length
1 <= m, n <= 6
1 <= word.length <= 15
board 和 word 仅由大小写英文字母组成
进阶:你可以使用搜索剪枝的技术来优化解决方案,使其在 board 更大的情况下可以更快解决问题?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/word-search
思路
不同于排列组合问题,本题采用的并不是修改输出方式,而是修改访问标记。在我们对任意 位置进行深度优先搜索时,我们先标记当前位置为已访问,以避免重复遍历(如防止向右搜索后 又向左返回);在所有的可能都搜索完成后,再回改当前位置为未访问,防止干扰其它位置搜索 到当前位置。使用回溯法,我们可以只对一个二维的访问矩阵进行修改,而不用把每次的搜索状 态作为一个新对象传入递归函数中。
具体来讲,主函数内,我们设置一个二维bool类型数组visited来记录单元格是否被访问,设置一个bool类型变量find,记录是否找到对应的元素。之后遍历每一个单元格进行深度优先遍历。
辅助函数内,我们首先对传入的单元格位置进行判断,判断是否超出board的范围,如果超出则终止递归。之后如果单元格被访问过、已经找到整个string、单元格元素不等于要找的元素,这些条件有一个成立则终止递归。最后如果pos已经等于word.size()-1(即现在找到word最后一个元素)也终止递归。将单元格位置{i,j}访问位置为true,对其上下左右进行深度优先遍历(回溯),最后将访问位改回,防止干扰其他位置搜索到此位置。
class Solution {
public:
bool exist(vector<vector<char>>& board, string word) {
if (board.size() == 0) return false;
int m = board.size(), n = board[0].size();
vector<vector<bool>> visited(m, vector<bool>(n, false));//单元格访问位
bool result = false;//标记是否找到word
for (int i = 0; i < m; ++i) {//对每一个单元格进行深度优先遍历(回溯)
for (int j = 0; j < n; ++j) {
backtracking(i, j, board, word, result, visited, 0);//最后一位是标记该找word的哪一位
}
}
return result;
}
private:
void backtracking(int i, int j, vector<vector<char>>& board, string& word, bool& result, vector<vector<bool>>& visited, int pos) {
//参数都应该传引用,board,word,visited,是为了传地址防止拷贝构造耗时,而result必须传引用是因为递归函数要修改result,不传引用修改的是副本
if (i < 0 || i >= board.size() || j < 0 || j >= board[0].size()) return;//检查范围
if (visited[i][j] || result || board[i][j] != word[pos]) return;//检查元素
if (pos == word.size() - 1) {//现在已经找到word
result = true;
return;
}
visited[i][j] = true;//设置为已访问
backtracking(i + 1, j, board, word, result, visited, pos + 1);//上
backtracking(i - 1, j, board, word, result, visited, pos + 1);//下
backtracking(i, j - 1, board, word, result, visited, pos + 1);//左
backtracking(i, j + 1, board, word, result, visited, pos + 1);//右
visited[i][j] = false;//回溯,防止干扰其他位置搜索到此位置时的判断
}
};
51. N-皇后
题目描述
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jytKoJxf-1657023302344)(pictureforMD/queens.jpg)]
输入:n = 4
输出:[[“.Q…”,“…Q”,“Q…”,“…Q.”],[“…Q.”,“Q…”,“…Q”,“.Q…”]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
输入:n = 1
输出:[[“Q”]]
提示:
1 <= n <= 9
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/n-queens
回溯模板解法
思路
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6IBY83Ua-1657023302344)(pictureforMD/image-20220618101206791.png)] 从图中我们可以看出,二维矩阵的高就是树形结构的高,矩阵的宽就是树形结构的宽度。我们用约束条件来回溯搜索这棵树,只要搜索到了叶子节点,就说明找到了皇后的合理位置了。
1.确定递归函数的参数和返回值
返回值为void,参数为棋盘的大小n,记录当前遍历到几层的遍历row,和string数组chessboard来存储棋盘的结果。因为每次for循环都要从0开始遍历(从每一行的第一列开始遍历),所以不用startIndex来确定每次递归开始位置。
2.确定递归函数的终止条件
当遍历到棋盘最后一行(也就是叶节点时)就要收集结果并返回了。
3.确定单层搜索的逻辑
递归的深度也就是row控制棋盘的行数,每一层中for循环col用于控制棋盘的列,一行一列就确定了放皇后的位置。每次都要从新的一行的起始位置开始搜索,所以都是从0开始搜索。
最后是验证合法性的代码,对col列的前row行进行遍历,如果出现Q则return false;对左斜线进行验证,对右边斜线进行验证。
题解
class Solution {
private:
vector<vector<string>> result;
void backtracking(int n, vector<string>& chessboard, int row) {
if (row == n) {//递归到第n+1层,找到一种解法
result.push_back(chessboard);
return;
}
for (int col = 0; col < n; ++col) {
if (isValid(row, col, chessboard, n)) {//在{row,col}位置放置Q合法
chessboard[row][col] = 'Q';
backtracking(n, chessboard, row + 1);//向下递归
chessboard[row][col] = '.';//回溯
}
}
}
bool isValid(int row, int col, vector<string>& chessboard, int n) {
for (int i = 0; i < row; ++i) {
if (chessboard[i][col] == 'Q') return false;
}
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; --i, --j) {
if (chessboard[i][j] == 'Q') return false;
}
for (int i = row - 1, j = col + 1; i >= 0 && j < n; --i, ++j) {
if (chessboard[i][j] == 'Q') return false;
}
return true;
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<string> chessboard(n, string(n, '.'));
backtracking(n, chessboard, 0);
return result;
}
};
利用状态矩阵进行回溯
思路
上面的回溯模板法使用isValid()函数来判断此位置插入皇后是否合法,我们也可以使用类似在矩阵中寻找字符串采用的状态矩阵来进行回溯。不同的是,我们需要 对每一行、列、左斜、右斜建立访问数组,来记录它们是否存在皇后。 本题有一个隐藏的条件,即满足条件的结果中每一行或列有且仅有一个皇后。这是因为我们 一共只有 n 行和 n 列。所以如果我们通过对每一行遍历来插入皇后,我们就不需要对行建立访问数组了。所以我们需要三个状态数组colmn(列)、ldiag(左斜)、rdiag(右斜)。
此解法具体来讲,在主函数中定义初始化好所需辅助数据结构,在辅函数中递归终止条件是row等于n也就是找到了一种解。之后我们遍历每一列,对row行的第i列、row行i列的左斜、右斜我们都检验其是否存在皇后,如果有一个存在,则说明此位置不能放置皇后。假设都没有皇后存在,说明可以放置,修改当前单元格为皇后,将三个访问位置为true,向row+1行递归。
题解
class Solution {
private:
vector<vector<string>> result;
void backtracking(vector<vector<string>>& result, vector<string>& chessboard, vector<bool>& column, vector<bool>& ldiag, vector<bool>& rdiag, int row, int n) {
if (row == n) {
result.push_back(chessboard);
return;
}
for (int col = 0; col < n; ++col) {
//对{row,col}所在列(col列)检查 || 对{row,col}所在左斜(n-row+col-1)检查 || 对{row,col}所在右斜(row+i+1)检查
if (column[col] || ldiag[n - row + col - 1] || rdiag[row + col + 1]) continue;//若有皇后,则此位置不能放置皇后
chessboard[row][col] = 'Q';
column[col] = ldiag[n - row + col - 1] = rdiag[row + col + 1] = true;
backtracking(result, chessboard, column, ldiag, rdiag, row + 1, n);
chessboard[row][col] = '.';
column[col] = ldiag[n - row + col - 1] = rdiag[row + col + 1] = false;
}
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<string> chessboard(n, string(n, '.'));
vector<bool> column(n,false);//列访问数组
vector<bool> ldiag(2 * n - 1, false);//左斜访问数组,有2*n-1条左斜
vector<bool> rdiag(2 * n - 1, false);//右斜访问数组,有2*n-1条右斜
backtracking(result, chessboard, column, ldiag, rdiag, 0, n);
return result;
}
};
6.4 广度优先搜索
广度优先搜索(breadth-first search,BFS)不同与深度优先搜索,它是一层层进行遍历的,因此需要用先入先出的队列而非先入后出的栈进行遍历。由于是按层次进行遍历,广度优先搜索时 按照“广”的方向进行遍历的,也常常用来处理最短路径等问题。 考虑如下一颗简单的树。我们从 1 号节点开始遍历,假如遍历顺序是从左子节点到右子节点, 那么按照优先向着“广”的方向前进的策略,队列顶端的元素变化过程为 [1]->[2->3]->[4],其中 方括号代表每一层的元素。
1
/ \
2 3
/
4
这里要注意,深度优先搜索和广度优先搜索都可以处理可达性问题,即从一个节点开始是否能达到另一个节点。因为深度优先搜索可以利用递归快速实现,很多人会习惯使用深度优先搜索刷此类题目。实际软件工程中,笔者很少见到递归的写法,因为一方面难以理解,另一方面可能 产生栈溢出的情况;而用栈实现的深度优先搜索和用队列实现的广度优先搜索在写法上并没有太大差异,因此使用哪一种搜索方式需要根据实际的功能需求来判断。
934. 最短的桥
题目描述
在给定的二维二进制数组 A 中,存在两座岛。(岛是由四面相连的 1 形成的一个最大组。)现在,我们可以将 0 变为 1,以使两座岛连接起来,变成一座岛。返回必须翻转的 0 的最小数目。(可以保证答案至少是 1 。)
示例 1:
输入:A = [[0,1],[1,0]]
输出:1
示例 2:
输入:A = [[0,1,0],[0,0,0],[0,0,1]]
输出:2
示例 3:
输入:A = [[1,1,1,1,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,0,0,1],[1,1,1,1,1]]
输出:1
提示:
2 <= A.length == A[0].length <= 100
A[i][j]
== 0 或 A[i][j]
== 1
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/shortest-bridge
思路
换一种角度思考,为使两座岛连起来成为一个岛所必须翻转0的最小数目是不是就是两座岛的最短距离?因此我们可以先深度优先搜索寻找一个岛屿,然后利用广度优先搜索查找其与另一个岛屿的最短距离。
具体来讲,主函数内定义一个队列,起初用来存储第一个岛屿周边的水域坐标。之后对所有单元格进行遍历,当单元格为陆地时,调用dfs函数对其进行深度优先搜索,搜索所属岛屿的所有陆地单元格。当遍历完第一个岛屿所有陆地单元格后结束循环。
在dfs辅函数中,递归出口是下标越界和当前单元格已经遍历过(2),当当前单元格为水域时,压入队列中,如果上述条件都不满足,说明此单元格值为1(陆地且未遍历过),将访问位置为2,对其上下左右进行递归调用dfs函数。
当遍历完第一个岛屿的所有陆地单元格后,我们利用广度优先搜索,从队列中弹出元素(第一个岛屿周边的水域)进行搜索。当单元格值为2时,说明是第一个岛屿的陆地,继续循环;当单元格为1时,说明搜索到了第二个岛屿,此时应该返回距离level;当单元格值为0时,说明仍是水域,加入队列,以供后续搜索。
题解
class Solution {
private:
vector<int> direction{-1, 0, 1, 0, -1};
void dfs(vector<vector<int>>& grid, queue<pair<int, int>>& points, int m , int n, int i, int j) {
if (i < 0 || j < 0 || i >= m || j >= n || grid[i][j] == 2) return;
if (grid[i][j] == 0) {
points.push({i, j});
return;
}
//gird[i][j]==1
grid[i][j] = 2;
dfs(grid, points, m, n, i - 1, j);
dfs(grid, points, m, n, i + 1, j);
dfs(grid, points, m, n, i, j - 1);
dfs(grid, points, m, n, i, j + 1); //上下左右
//不用回溯,目的就是要标记已经访问过的第一个岛屿土地单元格
}
public:
int shortestBridge(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
queue<pair<int, int>> points;//队列,用于存储第一个岛屿周围水域坐标
bool flag = false;
for (int i = 0; i < m; ++i) {//dfs搜索第一个岛屿所有陆地单元格,并将周围水域存储在points中
if (flag) break;
for (int j = 0; j < n; ++j) {
if (grid[i][j] == 1) {
dfs(grid, points, m, n, i, j);
flag = true;//第一个岛屿访问完成
break;
}
}
}
// bfs寻找第二个岛屿,并把过程中经过的0赋值为2
int level = 0;
while (!points.empty()) {
++level;//最短距离加1,不断向外围搜索
int pointssize = points.size();
while (pointssize--) {
auto [r, c] = points.front();//队首
points.pop();//队首出队
for (int k = 0; k < direction.size() - 1; ++k) {
int x = r + direction[k], y = c + direction[k + 1];
if (x >= 0 && y >= 0 && x < m && y < n) {
if (grid[x][y] == 2) continue;
if (grid[x][y] == 1) return level;
//gird[x][y] == 0
points.push({x, y});
grid[x][y] = 2; //已经访问过
}
}
}
}
return 0;
}
};
126. 单词接龙Ⅱ
题目描述
按字典 wordList 完成从单词 beginWord 到单词 endWord 转化,一个表示此过程的 转换序列 是形式上像 beginWord -> s1 -> s2 -> … -> sk 这样的单词序列,并满足:
- 每对相邻的单词之间仅有单个字母不同。
- 转换过程中的每个单词 si(1 <= i <= k)必须是字典 wordList 中的单词。注意,beginWord 不必是字典 wordList 中的单词。
- sk == endWord
给你两个单词 beginWord 和 endWord ,以及一个字典 wordList 。请你找出并返回所有从 beginWord 到 endWord 的 最短转换序列 ,如果不存在这样的转换序列,返回一个空列表。每个序列都应该以单词列表 [beginWord, s1, s2, …, sk] 的形式返回。
示例 1:
输入:beginWord = “hit”, endWord = “cog”, wordList = [“hot”,“dot”,“dog”,“lot”,“log”,“cog”]
输出:[[“hit”,“hot”,“dot”,“dog”,“cog”],[“hit”,“hot”,“lot”,“log”,“cog”]]
解释:存在 2 种最短的转换序列:
“hit” -> “hot” -> “dot” -> “dog” -> “cog”
“hit” -> “hot” -> “lot” -> “log” -> “cog”
示例 2:
输入:beginWord = “hit”, endWord = “cog”, wordList = [“hot”,“dot”,“dog”,“lot”,“log”]
输出:[]
解释:endWord “cog” 不在字典 wordList 中,所以不存在符合要求的转换序列。
提示:
1 <= beginWord.length <= 5
endWord.length == beginWord.length
1 <= wordList.length <= 500
wordList[i].length == beginWord.length
beginWord、endWord 和 wordList[i] 由小写英文字母组成
beginWord != endWord
wordList 中的所有单词 互不相同
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/word-ladder-ii
思路
我们可以把起始字符串、终止字符串、以及单词表里所有的字符串想象成节点。若两个字符串只有一个字符不同,那么它们相连。因为题目需要输出修改次数最少(最短路径)的所有修改方式,因此我们可以使用广度优先搜索,求得起始节点到终止节点的最短距离。 我们同时还使用了一个小技巧:我们并不是直接从起始节点进行广度优先搜索,直到找到终止节点为止;**而是从起始节点和终止节点分别进行广度优先搜索,每次只延展当前层节点数最少的那一端,这样我们可以减少搜索的总结点数。**举例来说,假设最短距离为 4,如果我们只从一端搜索 4 层,总遍历节点数最多是 1 + 2 + 4 + 8 + 16 = 31;而如果我们从两端各搜索两层,总遍历节点数最多只有 2 × (1 + 2 + 4) = 14。 在搜索结束后,我们还需要通过回溯法来重建所有可能的路径。
具体来讲,在主函数中首先定义两个数据结构vector<vector>类型变量ans存储结果,unordered_set类型变量dict存储除beginword和endword以外的中间节点。之后再定义两种数据结构1.unordered_set q1{beginWord},代表从beginWord开始正向搜索,unordered_set q2{endWord},代表从endWord开始反向搜索。2.unordered_map<string, vector> next,用来存储各个节点之后的节点(图)。之后定义两个标志位reversed表示是否反向搜索,found表示是否找到答案(不一定是endWord,因为我们是从两边都搜索)。
之后开始从q1开始搜索(当q1不空时),定义数据结构unordered_set q用来存放搜索到的中间节点,对q1中的每一个字符串,替换每位字符,如果在q2中找到(目标节点),则根据reversed位,加入next,置found为true;如果在dict中找到(中间节点),则根据reversed位加入next,同时将中间节点加入q。在遍历q1的每个字符串的一轮循环中,如果found为true(找到目标节点)则退出循环(while),同时将找到的中间节点从dict中删除,防止反复搜索同一节点。之后判断q(中间节点个数)和q2的大小(目标节点的个数),如果q.size() <= q2.size(),则q1=q(中间节点作为开始节点);否则进行反向搜索,将reversed位反转,q1=q2 ,q2 = q。搜索结束后,用回溯法重建所有可能的路径。
辅助函数backtracking()首先对src进行判断是否等于dst,如果是的话将path加入ans并返回(递归出口)。之后根据next[src](这是一个string数组)的元素来递归加入path。
题解
class Solution {
private:
void backtracking(const string& src, const string& dst, unordered_map<string, vector<string>>& next, vector<string>& path, vector<vector<string>>& ans) {
if (src == dst) {//当前节点等于目标节点,将路径加入结果集
ans.push_back(path);
return;
}
for (const string& s : next[src]) {//遍历路径的所有字符串
path.push_back(s);
backtracking(s, dst, next, path, ans);//递归
path.pop_back();
}
}
public:
vector<vector<string>> findLadders(string beginWord, string endWord, vector<string>& wordList) {
vector<vector<string>> ans;//结果
unordered_set<string> dict;//存储中间节点
for (const string& w : wordList) dict.insert(w);
if (!dict.count(endWord)) return ans;
dict.erase(beginWord);
dict.erase(endWord);//除去begin和end
unordered_set<string> q1{beginWord}, q2{endWord};//正向反向搜索或开始节点和目标节点
unordered_map<string, vector<string>> next; //存储每个节点后续的路径<节点,路径>
bool reversed = false, found = false;//反转位,找到位
while (!q1.empty()) {//当正向搜索节点集合不空时
unordered_set<string> q;//存储已经找到的中间节点
for (const string& w : q1) {
string s = w;
for (size_t i = 0; i < s.size(); ++i) {
char ch = s[i];
for (int j = 0; j < 26; ++j) {
s[i] = j + 'a';//修改
if (q2.count(s)) {//在目标节点中找到
reversed ? next[s].push_back(w) : next[w].push_back(s);//根据reversed存入next
found = true;
}
if (dict.count(s)) {//在中间节点中找到
reversed ? next[s].push_back(w) : next[w].push_back(s);
q.insert(s);//将中间节点加入q
}
}
s[i] = ch;//恢复修改
}
}
if (found) break;//找到目标节点
for (const string& w : q) dict.erase(w);//将dict中已经找到的中间节点删除,防止重复寻找
if (q.size() <= q2.size()) q1 = q;//每次只延展当前层节点数最少的那一端
else {
reversed = !reversed;
q1 = q2;//更换开始节点和目标节点集合
q2 = q;
}
}
if (found) {
vector<string> path = {beginWord};
backtracking(beginWord, endWord, next, path, ans);
}
return ans;
}
};
127. 单词接龙
题目描述
字典 wordList 中从单词 beginWord 和 endWord 的 转换序列 是一个按下述规格形成的序列 beginWord -> s1 -> s2 -> … -> sk:
每一对相邻的单词只差一个字母。
对于 1 <= i <= k 时,每个 si 都在 wordList 中。注意, beginWord 不需要在 wordList 中。
sk == endWord
给你两个单词 beginWord 和 endWord 和一个字典 wordList ,返回 从 beginWord 到 endWord 的 最短转换序列 中的 单词数目 。如果不存在这样的转换序列,返回 0 。
示例 1:
输入:beginWord = “hit”, endWord = “cog”, wordList = [“hot”,“dot”,“dog”,“lot”,“log”,“cog”]
输出:5
解释:一个最短转换序列是 “hit” -> “hot” -> “dot” -> “dog” -> “cog”, 返回它的长度 5。
示例 2:
输入:beginWord = “hit”, endWord = “cog”, wordList = [“hot”,“dot”,“dog”,“lot”,“log”]
输出:0
解释:endWord “cog” 不在字典中,所以无法进行转换。
提示:
1 <= beginWord.length <= 10
endWord.length == beginWord.length
1 <= wordList.length <= 5000
wordList[i].length == beginWord.length
beginWord、endWord 和 wordList[i] 由小写英文字母组成
beginWord != endWord
wordList 中的所有字符串 互不相同
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/word-ladder
思路
相信上一题如果理解明白了,这道题就很简单了。上一题我们已经用ans存储了最短路径,我们只需返回最短路径的长度即可。
题解
class Solution {
private:
void backtracking(const string& src, const string& dst, unordered_map<string, vector<string>>& next, vector<string>& path, vector<vector<string>>& ans) {
if (src == dst) {//当前节点等于目标节点,将路径加入结果集
ans.push_back(path);
return;
}
for (const string& s : next[src]) {//遍历路径的所有字符串
path.push_back(s);
backtracking(s, dst, next, path, ans);//递归
path.pop_back();
}
}
public:
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
vector<vector<string>> ans;//结果
unordered_set<string> dict;//存储中间节点
for (const string& w : wordList) dict.insert(w);
if (!dict.count(endWord)) return 0;
dict.erase(beginWord);
dict.erase(endWord);//除去begin和end
unordered_set<string> q1{beginWord}, q2{endWord};//正向反向搜索或开始节点和目标节点
unordered_map<string, vector<string>> next; //存储每个节点后续的路径<节点,路径>
bool reversed = false, found = false;//反转位,找到位
while (!q1.empty()) {//当正向搜索节点集合不空时
unordered_set<string> q;//存储已经找到的中间节点
for (const string& w : q1) {
string s = w;
for (size_t i = 0; i < s.size(); ++i) {
char ch = s[i];
for (int j = 0; j < 26; ++j) {
s[i] = j + 'a';//修改
if (q2.count(s)) {//在目标节点中找到
reversed ? next[s].push_back(w) : next[w].push_back(s);//根据reversed存入next
found = true;
}
if (dict.count(s)) {//在中间节点中找到
reversed ? next[s].push_back(w) : next[w].push_back(s);
q.insert(s);//将中间节点加入q
}
}
s[i] = ch;//恢复修改
}
}
if (found) break;//找到目标节点
for (const string& w : q) dict.erase(w);//将dict中已经找到的中间节点删除,防止重复寻找
if (q.size() <= q2.size()) q1 = q;//每次只延展当前层节点数最少的那一端
else {
reversed = !reversed;
q1 = q2;//更换开始节点和目标节点集合
q2 = q;
}
}
if (found) {
vector<string> path = {beginWord};
backtracking(beginWord, endWord, next, path, ans);
}
if (ans.empty()) return 0;
else return ans[0].size();
}
};
6.5 练习
130. 被围绕的区域
题目描述
给你一个 m x n
的矩阵 board
,由若干字符 'X'
和 'O'
,找到所有被 'X'
围绕的区域,并将这些区域里所有的 'O'
用 'X'
填充。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VxMh4Ebe-1657023302344)(pictureforMD/xogrid.jpg)]
输入:board = [[“X”,“X”,“X”,“X”],[“X”,“O”,“O”,“X”],[“X”,“X”,“O”,“X”],[“X”,“O”,“X”,“X”]]
输出:[[“X”,“X”,“X”,“X”],[“X”,“X”,“X”,“X”],[“X”,“X”,“X”,“X”],[“X”,“O”,“X”,“X”]]
解释:被围绕的区间不会存在于边界上,换句话说,任何边界上的 ‘O’ 都不会被填充为 ‘X’。 任何不在边界上,或不与边界上的 ‘O’ 相连的 ‘O’ 最终都会被填充为 ‘X’。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。
示例 2:
输入:board = [[“X”]]
输出:[[“X”]]
提示:
m == board.length
n == board[i].length
1 <= m, n <= 200
board[i][j] 为 ‘X’ 或 ‘O’
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/surrounded-regions
思路
我们很难判断哪些‘O’是被‘X’包围的,哪些不是被’X’包围的。注意题目给出的这句话:任何边界上的 ‘O’ 都不会被填充为 ‘X’。 任何不在边界上,或不与边界上的 ‘O’ 相连的 ‘O’ 最终都会被填充为 ‘X’。也就是说,任何不被包围的‘O’,它一定是直接或间接与边界上的’O’相连。这就为我们解题提供了方向:从每一个边界上的‘O’出发,搜索与它直接或间接相连的’O’,将它标记。最后再遍历整个矩阵,如果该单元格被标记过,则说明它是与边界上的‘O’相连的(即不被’X’包围的’O’),应该将它还原为’O’;如果该单元格未被标记过,则说明它是未与边界上的’O’相连的或本来就是’X’,我们应该将它修改为’X’。
具体来讲,我们对边界上的单元格调用dfs()函数。dfs()函数首先对边界和此单元格的值进行判断,只有不越界且等于’O’的单元格才将此单元格标记,并递归调用dfs()函数搜索其上下左右的单元格(递归出口为越界或不等于’O’)。之后再遍历整个矩阵所有单元格,对标记过的单元格修改其值为’O’,未被标记过的单元格则修改为’X’。
题解
class Solution {
private:
void dfs(vector<vector<char>>& board, int r, int c) {
if (r < 0 || r >= board.size() || c < 0 || c >= board[0].size() || board[r][c] != 'O') return;//递归出口
board[r][c] = 'V';
//递归搜索上下左右
dfs(board, r - 1, c);
dfs(board, r + 1, c);
dfs(board, r, c - 1);
dfs(board, r, c + 1);
}
public:
void solve(vector<vector<char>>& board) {
int m = board.size();
if (m == 0) return;
int n = board[0].size();
for (int i = 0; i < m; ++i) {//深度搜索左右边界上的O
if (board[i][0] == 'O') dfs(board, i, 0);
if (board[i][n - 1] == 'O') dfs(board, i, n - 1);
}
for (int j = 1; j < n - 1; ++j) {//深度搜索上下边界上的O
if (board[0][j] == 'O') dfs(board, 0, j);
if (board[m - 1][j] == 'O') dfs(board, m - 1, j);
}
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (board[i][j] == 'V') board[i][j] = 'O';//被标记的都是于边界O相连的,不被X包围
else if (board[i][j] == 'O') board[i][j] = 'X';
}
}
}
};
当然本题也可以使用队列实现的广度优先搜索,但是力扣官方题解版本有一些小问题,会出现重复入队的问题。所以目前我们还是考虑最容易实现最容易理解最容易在面试写出的递归深度优先搜索版本。
257. 二叉树的所有路径
题目描述
给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。
叶子节点 是指没有子节点的节点。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fCO7xYFP-1657023302345)(pictureforMD/paths-tree.jpg)]
输入:root = [1,2,3,null,5]
输出:[“1->2->5”,“1->3”]
示例 2:
输入:root = [1]
输出:[“1”]
提示:
树中节点的数目在范围 [1, 100] 内
-100 <= Node.val <= 100
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/binary-tree-paths
深度优先搜索(递归)
思路
此题是经典的二叉树搜索问题,可以想到的方式有深度优先搜索(递归和栈迭代)和广度优先搜索(队列)。在此我只实现深度优先搜索的递归版本和广度优先遍历(队列)版本。栈迭代版本已在力扣上提交通过。
对于深度优先搜索递归版本来讲,我们先从根节点出发进行搜索,调用traversal()函数进行深度优先搜索,对于搜索到的每一个节点,我们都将它加入path中。当搜索到叶子节点时(递归出口),我们就已经找到了二叉树的一条路径,将它降入result中即可;当搜索到非叶子节点时,我们需要对其左右子树进行判断,如果不空,则递归搜索。
题解
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
private:
void traversal(TreeNode* cur, string path, vector<string>& result) {//path不能传引用,我们应该利用整个副本来实现回溯效果
path += to_string(cur->val);//加入path
if (cur->left == NULL && cur->right == NULL) {//叶子节点
result.push_back(path);
return;
}
if (cur->left) traversal(cur->left, path + "->", result);
if (cur->right) traversal(cur->right, path + "->", result);
}
public:
vector<string> binaryTreePaths(TreeNode* root) {
string path;
vector<string> result;
if (root == NULL) return result;
traversal(root, path, result);
return result;
}
};
广度优先搜索(队列)
我们也可以用广度优先搜索来实现。我们维护两个队列,存储节点以及根到该节点的路径。一开始这个队列里只有根节点。在每一步迭代中,我们取出队列中的首节点,如果它是叶子节点,则将它对应的路径加入到答案中。如果它不是叶子节点,则将它的所有孩子节点加入到队列的末尾。当队列为空时广度优先搜索结束,我们即能得到答案。
具体来讲,我们维护两个队列,当节点队列不空时,我们弹出节点队列的队首元素,弹出路径队列的队首元素。当弹出的节点为叶子节点时,则路径队列弹出的元素即是根节点到此节点的路径,将它加入result中;当不为叶子节点时,则将该节点的左右孩子加入节点队列,同时将根节点到左右孩子的路径也存入路径队列。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<string> binaryTreePaths(TreeNode* root) {
vector<string> result;
if (root == NULL) return result;
queue<TreeNode*> node_queue;
queue<string> path_queue;
node_queue.push(root);
path_queue.push(to_string(root->val));
while (!node_queue.empty()) {
TreeNode* node = node_queue.front();
node_queue.pop();
string path = path_queue.front();
path_queue.pop();
if (node->left == NULL && node->right == NULL) {//叶子节点
result.push_back(path);//此时path存储的是根节点到node的路径加入result即可
}
if (node->left) {
node_queue.push(node->left);
path_queue.push(path + "->" + to_string(node->left->val));
}
if (node->right) {
node_queue.push(node->right);
path_queue.push(path + "->" + to_string(node->right->val));
}
}
return result;
}
};
47. 全排列Ⅱ
题目描述
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/permutations-ii
思路
此题我们已经在代码随想录中详细讲解过了,简单概括一下就是要去重,去重首先就是要对nums进行排序,这样才能通过相邻节点来判断元素是否重复使用了。另外本题既可以使用树枝去重也可以使用树层去重,只不过树层去重效率更高(直接减去一大分支)。在此我只写效率最高的解法。
题解
class Solution {
private:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums, vector<bool>& used) {
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {//同层使用过元素nums[i-1]
continue;
}
if (used[i]) continue;
//if (used[i] == false) {
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
//}
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
40. 组合总和Ⅱ
题目描述
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
示例 2:
输入: candidates = [2,5,2,1,2], target = 5,
输出:
[
[1,2,2],
[5]
]
提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/combination-sum-ii
思路
组合问题的关键是理清题意:
- 题目中要求数组中的每个数字在组合中只能使用一次,也就是说我们不能重复选取同一位置的元素,需要用startIndex来实现此要求,每次递归从下一个位置开始遍历。
- 题目中还要求解集中不能包含重复的组合,这就要求值相同的元素也不能重复选取,例如{1,1,2},target = 3,会出现{1,2}、{1,2}。所以需要used数组对值相同的元素进行去重(先对数组排序)。(当然我们也可以用candidates[i] == candidates[i - 1]进行去重,但我们为了统一(有的题目需要树枝去重),使用used数组进行去重)
另外,有一些小细节需要注意:
- 我们不用维护一个sum来存储当前path中的总和,只需要对target-candidates[i]就可以实现对总和的判断。
- 剪枝优化。因为数组是从小到大排序过的,所以当target-candidates[i]<0时,说明后面的元素都不能使总和等于target,所以我们在for循环中就设置条件,只当target-candidates[i]>=0时才执行循环,避免了无效的遍历,优化了执行用时。
此解法是最优的。
题解
class Solution {
private:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& candidates, vector<bool>& used, int target, int startIndex) {
if (target == 0) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && target - candidates[i] >= 0; ++i) {
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) continue;
used[i] = true;
path.push_back(candidates[i]);
backtracking(candidates, used, target - candidates[i], i + 1);
path.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
vector<bool> used(candidates.size(), false);
backtracking(candidates, used, target, 0);
return result;
}
};
37. 解数独
题目描述
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 ‘.’ 表示。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VDFurgsM-1657023302345)(pictureforMD/250px-sudoku-by-l2g-20050714svg.png)]
输入:board = [[“5”,“3”,“.”,“.”,“7”,“.”,“.”,“.”,“.”],[“6”,“.”,“.”,“1”,“9”,“5”,“.”,“.”,“.”],[“.”,“9”,“8”,“.”,“.”,“.”,“.”,“6”,“.”],[“8”,“.”,“.”,“.”,“6”,“.”,“.”,“.”,“3”],[“4”,“.”,“.”,“8”,“.”,“3”,“.”,“.”,“1”],[“7”,“.”,“.”,“.”,“2”,“.”,“.”,“.”,“6”],[“.”,“6”,“.”,“.”,“.”,“.”,“2”,“8”,“.”],[“.”,“.”,“.”,“4”,“1”,“9”,“.”,“.”,“5”],[“.”,“.”,“.”,“.”,“8”,“.”,“.”,“7”,“9”]]
输出:[[“5”,“3”,“4”,“6”,“7”,“8”,“9”,“1”,“2”],[“6”,“7”,“2”,“1”,“9”,“5”,“3”,“4”,“8”],[“1”,“9”,“8”,“3”,“4”,“2”,“5”,“6”,“7”],[“8”,“5”,“9”,“7”,“6”,“1”,“4”,“2”,“3”],[“4”,“2”,“6”,“8”,“5”,“3”,“7”,“9”,“1”],[“7”,“1”,“3”,“9”,“2”,“4”,“8”,“5”,“6”],[“9”,“6”,“1”,“5”,“3”,“7”,“2”,“8”,“4”],[“2”,“8”,“7”,“4”,“1”,“9”,“6”,“3”,“5”],[“3”,“4”,“5”,“2”,“8”,“6”,“1”,“7”,“9”]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bDfoBG3B-1657023302345)(pictureforMD/250px-sudoku-by-l2g-20050714_solutionsvg.png)]
提示:
board.length == 9
board[i].length == 9
board[i][j] 是一位数字或者 ‘.’
题目数据 保证 输入数独仅有一个解
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/sudoku-solver
思路
很明显我们要对整个矩阵的每个位置进行递归回溯,所以在递归函数中使用两层for循环来遍历整个矩阵。当遍历到的单元格未填入数字时,我们就试探性的放入1-9进行递归,当下一层递归的结果返回true时,说明已经找到一组解,本层递归也要返回true。当此单元格填入1-9都不能满足条件,则说明上一层递归填写有误,需要返回false,返回上一层重新填写(回溯)。可以看出回溯函数是需要返回值的,用来标记本层递归是否找到解。 当找到正确结果时返回true,找不到结果说明之前填入有错,需要回溯,即返回false来让if语句不执行,进行回溯,尝试填入其他数字。
另外对当前填入的数字是否合法的判断函数也有一些细节需要注意,详细见代码。
题解
class Solution {
private:
bool isValid(int row, int col, char val, vector<vector<char>>& board) {
for (int i = 0; i < 9; i++) {//判断同一行中是否有重复数字
if (board[row][i] == val) return false;
}
for (int j = 0; j < 9; j++) {//判断同一列中是否有重复数字
if (board[j][col] == val) return false;
}
int startRow = (row / 3) * 3;//确定当前是哪个九宫格[startRow,startCol]是当前小九宫格的左上角元素
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++) {
for (int j = startCol; j < startCol + 3; j++) {
if (board[i][j] == val) return false;
}
}
return true;
}
bool backtracking(vector<vector<char>>& board) {
for (int i = 0; i < board.size(); i++) {
for (int j = 0; j < board[0].size(); j++) {
if (board[i][j] != '.') continue;//已经放过元素就进入下次循环
for (char k = '1'; k <= '9'; k++) {
if (isValid(i, j, k, board)) {
board[i][j] = k;
if (backtracking(board)) return true;//如果找到合适的一组结果,立刻返回
board[i][j] = '.';
}
}
return false;//如果9个数都试完了都不行就返回false
}
}
return true;//如果遍历完一直没返回就说明找到了合适的棋盘位置
}
public:
void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
//return board;
}
};
可以说这种解法是最容易理解的,力扣中已有的题解虽然执行用时少,但都不易理解,很难在面试环境中写出,所以我们要追求能够得到的答案,那些技巧图个乐就行了,毕竟我们的目标是拿offer而不是得奖牌。
310. 最小高度树
题目描述
树是一个无向图,其中任何两个顶点只通过一条路径连接。 换句话说,一个任何没有简单环路的连通图都是一棵树。
给你一棵包含 n 个节点的树,标记为 0 到 n - 1 。给定数字 n 和一个有 n - 1 条无向边的 edges 列表(每一个边都是一对标签),其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间存在一条无向边。
可选择树中任何一个节点作为根。当选择节点 x 作为根节点时,设结果树的高度为 h 。在所有可能的树中,具有最小高度的树(即,min(h))被称为 最小高度树 。
请你找到所有的 最小高度树 并按 任意顺序 返回它们的根节点标签列表。
树的 高度 是指根节点和叶子节点之间最长向下路径上边的数量。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jl66COWG-1657023302345)(pictureforMD/e1.jpg)]
输入:n = 4, edges = [[1,0],[1,2],[1,3]]
输出:[1]
解释:如图所示,当根是标签为 1 的节点时,树的高度是 1 ,这是唯一的最小高度树。
示例 2:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9xXpS06Y-1657023302346)(pictureforMD/e2.jpg)]
输入:n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]
输出:[3,4]
提示:
1 <= n <= 2 * 104
edges.length == n - 1
0 <= ai, bi < n
ai != bi
所有 (ai, bi) 互不相同
给定的输入 保证 是一棵树,并且 不会有重复的边
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/minimum-height-trees
思路
暴力的解法是:枚举以每个节点为根节点的树,求其高度,所有树中最小高度即为答案,需要的时间复杂度为O(n2)
广度优先搜索或深度优先搜索的思路需要用到一个结论:设树中距离最长的两个节点为(x, y),它们之间的距离表示为maxdist=dist[x][y]
,则任意节点构成的树的最小高度一定为minheight = ⌈maxdist/2⌉,且最小高度树的根节点一定在节点x和节点y的路径上。
此结论需要证明三个子结论,详细见力扣官方题解:
- 证明树的高度一定为minheight = ⌈maxdist/2⌉,可以用反证法证明,假设存在以节点z为根的树的高度h < minheight,需要考虑两种情况,1.节点z存在于x到y的路径上;2节点z不存在于x到y的路径上。
- 其次证明最小高度树的根节点 一定存在于x到y的路径上。
综合上述推理,设两个叶子节点的最大距离为maxdist,可以得到最小高度树的高度为⌈maxdist/2⌉,且最小高度树的根节点一定存在其最长路径上。假设最长路径由m个节点组成,依次为p1->p2->p3->p4->……pm,可以得到以下结论:
- 如果m为奇数,此时最小高度树的根节点为p(m+1)/2,且此时最小高度为(m-1)/ 2
- 如果m为偶数,此时最小高度树的根节点为pm/2或pm/2+1,且此时最小高度为m/2
因此我们只需求出路径最长的两个叶子节点即可,并求出其路径的最中间节点即为最小高度树的根节点。我们可以利用以下算法找到图中距离最远的两个节点与它们之间的路径。(找图中最长路径的算法)
- 以任意节点p出发,利用dfs或bfs找到以p为起点的最长路径的终点x
- 以节点x出发,找到以x为起点的最长路径的终点y
- x到y之间的路径即为图中的最长路径,找到路径的中间节点即为根节点
广度优先搜索(队列)
使用广度优先搜索算法具体来讲,我们定义一个二维数组adj用来存储节点i与哪些节点相连(数组adj[i]中存放的是与节点i相连的节点)。之后定义一个数组parent初始化为-1,用来存储一条路径上节点的父节点(parent[i]=j,表示节点i的父节点是j)。调用findLongestNode()函数找到距离节点0最远的节点x,再调用findLongestNode()函数找到距离节点x最远的节点y。最后定义一个数组path用来存储y到x的路径,通过parent数组中存放的数据加入path。之后通过结论找到最小高度树的根节点。
在findLongestNode()函数中,我们定义一个bool型数组visit来记录节点是否被访问过,再定义一个队列,用于广度优先搜索。最后定义一个int型变量node来记录与u距离最远的节点。当队列不空时,队首出队并访问,将与该节点相连的未访问过的节点进队,并把访问位置为true,将parent修改。当广度优先搜索结束后,node就存储的与u距离最远的节点。
题解
class Solution {
private:
int findLongestNode(int begin, vector<int>& parent, vector<vector<int>>& adj) {
int n = adj.size();
vector<bool> visited(n, false);//访问数组
visited[begin] = true;//起点置为已访问
queue<int> que;//用于广度优先搜索的队列
que.emplace(begin);//起点入队
int node = -1;//存储终点
while (!que.empty()) {
int cur = que.front();
que.pop();
node = cur;
for (const int v : adj[cur]) {
if (!visited[v]) {
visited[v] = true;
que.emplace(v);
parent[v] = cur;
}
}
}
return node;//通过广度优先搜索,node存储最后搜索的节点就是距离begin最远的节点
}
public:
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
if (n == 1) return {0};//只有一个节点高度为0
vector<vector<int>> adj(n);//adj[i]存放的是与i节点相连的节点
for (const vector<int>& edge : edges) {
adj[edge[0]].emplace_back(edge[1]);
//emplace_back()代替push_back()
//好处是不用先构造临时对象,再将临时对象拷贝至容器末尾,而是直接在容器末尾构造对象,省去了拷贝的过程
adj[edge[1]].emplace_back(edge[0]);
}
vector<int> parent(n, -1);
int x = findLongestNode(0, parent, adj);//找到与节点0距离最远的节点x
int y = findLongestNode(x, parent, adj);//找到与节点x距离最远的节点y
vector<int> path;//存储y到x的路径
parent[x] = -1;//标记终点
while (y != -1) {
path.emplace_back(y);
y = parent[y];
}
int maxDist = path.size();
if (maxDist % 2 == 0) {//偶数
return {path[maxDist / 2 - 1], path[maxDist / 2]};
}
else {
return {path[maxDist / 2]};
}
}
};
深度优先搜索(递归)
思路
我们还可以使用深度优先搜索来实现本题。思路都是一样的。不同的是深度优先搜索findLongestNode()函数中,我们定义int型数组dist来存储节点距离起点的距离,通过调用dfs函数来将所有节点距离起点begin的距离存入数组dist中。另外在dfs()函数中,我们将节点的父节点信息存入parent数组(parent[i]=j,表示i节点的父节点为j)。
题解
class Solution {
private:
void dfs(int begin, vector<int>& dist, vector<int>& parent, const vector<vector<int>>& adj) {
for (const int& v : adj[begin]) {//遍历与起点相连的所有节点
if (dist[v] < 0) {//未访问过
dist[v] = dist[begin] + 1;
parent[v] = begin;
dfs(v, dist, parent, adj);//递归调用dfs,向深度搜索
}
}
}
int findLongestNode(int begin, vector<int>& parent, const vector<vector<int>>& adj) {
int n = adj.size();
vector<int> dist(n, -1);//存储每个节点距离begin的距离
dist[begin] = 0;
dfs(begin, dist, parent, adj);
int maxdist = 0;
int node = -1;
for (int i = 0; i < n; ++i) {
if (maxdist < dist[i]) {
node = i;
maxdist = dist[i];
}
}
return node;
}
public:
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
if (n == 1) return {0};
vector<vector<int>> adj(n);
for (const vector<int>& edge : edges) {
adj[edge[0]].emplace_back(edge[1]);
adj[edge[1]].emplace_back(edge[0]);
}
vector<int> parent(n, -1);
int x = findLongestNode(0, parent, adj);
int y = findLongestNode(x, parent, adj);
vector<int> path;
parent[x] = -1;
while (y != -1) {
path.emplace_back(y);
y = parent[y];
}
int m = path.size();
if (m % 2 == 0) return {path[m / 2 - 1], path[m / 2]};
else return {path[m / 2]};
}
};
第七章 深入浅出动态规划
7.1 算法解释
这里我们引用一下维基百科的描述:“动态规划(Dynamic Programming, DP)在查找有很多重叠子问题的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间 · · · · · ·
动态规划只能应用于有最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,问题能够分解成子问题来解决。”
通俗一点来讲,动态规划和其它遍历算法(如深/广度优先搜索)都是将原问题拆成多个子问题然后求解,他们之间最本质的区别是,动态规划保存子问题的解,避免重复计算。解决动态规划问题的关键是找到状态转移方程,这样我们可以通过计算和储存子问题的解来求解最终问题。 同时,我们也可以对动态规划进行空间压缩,起到节省空间消耗的效果。这一技巧笔者将在之后的题目中介绍。 **在一些情况下,动态规划可以看成是带有状态记录(memoization)的优先搜索。**状态记录的意思为,如果一个子问题在优先搜索时已经计算过一次,我们可以把它的结果储存下来,之后遍历到该子问题的时候可以直接返回储存的结果。动态规划是自下而上的,即先解决子问题,再解决父问题;而用带有状态记录的优先搜索是自上而下的,即从父问题搜索到子问题,若重复搜索到同一个子问题则进行状态记录,防止重复计算。如果题目需求的是最终状态,那么使用动态搜索比较方便;如果题目需要输出所有的路径,那么使用带有状态记录的优先搜索会比较方便。
7.2 基本动态规划: 一维
70. 爬楼梯
题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1 阶 + 1 阶
2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1 阶 + 1 阶 + 1 阶
1 阶 + 2 阶
2 阶 + 1 阶
提示:
1 <= n <= 45
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/climbing-stairs
思路
这是十分经典的斐波那契数列题。定义一个数组 dp,dp[i] 表示走到第 i 阶的方法数。因为我们每次可以走一步或者两步,所以第 i 阶可以从第 i-1 或 i-2 阶到达。换句话说,走到第 i 阶的 方法数即为走到第 i-1 阶的方法数加上走到第 i-2 阶的方法数。这样我们就得到了状态转移方程 dp[i] = dp[i-1] + dp[i-2]。注意边界条件的处理。
题解
class Solution {
public:
int climbStairs(int n) {
if (n <= 2) return n;
vector<int> dp(n + 1);//有n个台阶,所以数组末尾下标应该是n,所以数组大小为n+1
dp[1] = 1, dp[2] = 2;//初始化dp数组
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 2] + dp[i - 1];
}
return dp[n];
}
};
我们可以看出,dp[i]只由dp[i-1]和dp[i-2]推出,所以我们可以只用大小为2的数组来存储dp[i-1]和dp[i-2],使得空间复杂度由O(n)优化至O(1)。
class Solution {
public:
int climbStairs(int n) {
if (n <= 2) return n;
vector<int> dp(2);//只用两个变量来存储dp[i-1]、dp[i-2]
dp[0] = 1, dp[1] = 2;
for (int i = 3; i <= n; ++i) {
int temp = dp[1];
dp[1] = dp[1] + dp[0];
dp[0] = temp;
}
return dp[1];
}
};
198. 打家劫舍
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 400
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/house-robber
思路
我们定义dp数组,dp[i]表示抢劫到下标为i的房子时(不一定抢下标为i的房子),可以抢劫到最大金额。到打算抢劫下标为i的房子时,也就是考虑dp[i]时,有两种情况:
- 选择抢劫下标为i的房子,那么下标为i-1的房子就不能偷,dp[i] = dp[i-2] + nums[i];
- 选择不抢劫这个房子,那么dp[i]应该保持不变,dp[i] = dp[i-1];
我们应该选取这两种情况中,金额最大的情况,从而在抢劫结束时获得最高金额。所以我们的状态转移方程为:dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
题解
class Solution {
public:
int rob(vector<int>& nums) {
int size = nums.size();
if (size == 0) return 0;
if (size == 1) return nums[0];
vector<int> dp(size, 0);
dp[0] = nums[0], dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < size; ++i) {
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[size - 1];
}
};
同样,从状态转移方程我们可以看出,dp[i]是由dp[i-1]和dp[i-2]推导出来的,所以我们也可以只用大小为2的数组来保存dp[i-1]和dp[i-2]。
class Solution {
public:
int rob(vector<int>& nums) {
int size = nums.size();
if (size == 0) return 0;
if (size == 1) return nums[0];
vector<int> dp(2, 0);
dp[0] = nums[0], dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < size; ++i) {
int temp = dp[1];
dp[1] = max(temp, dp[0] + nums[i]);
dp[0] = temp;
}
return dp[1];
}
};
413. 等差数列划分
题目描述
如果一个数列至少有三个元素 ,并且任意两个相邻元素之差相同,则称该数列为等差数列。
例如,[1,3,5,7,9]、[7,7,7,7] 和 [3,-1,-5,-9] 都是等差数列。
给你一个整数数组 nums ,返回数组 nums 中所有为等差数组的 子数组 个数。
子数组 是数组中的一个连续序列。
示例 1:
输入:nums = [1,2,3,4]
输出:3
解释:nums 中有三个子等差数组:[1, 2, 3]、[2, 3, 4] 和 [1,2,3,4] 自身。
示例 2:
输入:nums = [1]
输出:0
提示:
1 <= nums.length <= 5000
-1000 <= nums[i] <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/arithmetic-slices
思路
求等差数列我们可以很容易的想到如果num[i]-num[i-1] == nums[i-1] - nums[i-2],则说明{i-2, i-1, i}构成一个等差数列。我们定义一个dp数组,dp[i]表示以下标为i的元素为结尾能够组成的等差数列的个数。举一个例子,当nums={1,2,3,4}时,nums[2]-nums[1] == nums[1]-nums[0]==1,说明{1,2,3}(以元素3为结尾的)数列构成一个等差数列;nums[3] -nums[2] == nums[2] -nums[1]=1时,说明{2,3,4}数列构成一个等差数列。但1,2还与3构成等差数列,需要加上这一个结果才是以元素4为结尾的数列构成的等差数列的数量。
由此我们可以推理出,当num[i]-num[i-1] == nums[i-1] - nums[i-2]时,以下标为i的元素为结尾的数列构成等差数列的数量等于以下标为i-1的元素为结尾的数量构成的等差数列的数量+1。即dp[i] = dp[i-1] + 1;
题解
class Solution {
public:
int numberOfArithmeticSlices(vector<int>& nums) {
int size = nums.size();
if (size < 3) return 0;
vector<int> dp(size, 0);//dp数组
int count = 0;//计算总和
for (int i = 2; i < size; ++i) {
if (nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2]) {
dp[i] = dp[i - 1] + 1;
count += dp[i];
}
}
return count;
}
};
由状态转移公式可知,dp[i]的状态只由dp[i-1]推导而来,所以我们只需维护一个变量就可以记录dp[i-1]。
class Solution {
public:
int numberOfArithmeticSlices(vector<int>& nums) {
int size = nums.size();
if (size < 3) return 0;
int dp = 0;
int count = 0;//计算总和
for (int i = 2; i < size; ++i) {
if (nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2]) {
dp += 1;
count += dp;
}
else dp = 0;
}
return count;
}
};
7.3 基本动态规划: 二维
64. 最小路径和
题目描述
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WcdckZa8-1657023302346)(pictureforMD/minpath.jpg)]
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
示例 2:
输入:grid = [[1,2,3],[4,5,6]]
输出:12
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 200
0 <= grid[i][j] <= 100
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/minimum-path-sum
思路
对于任意一个非左边界和上边界的单元格,从grid[0][0]
到grid[i][j]
的最小路径和dp[i][j]
都由其上方或左方单元格的最小路径和dp[i-1][j]
或dp[i][j-1]
之中的最小值+grid[i][j]
确定。也就是说该题的状态转移公式为dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + gird[i][j];
对于左边界和上边界的单元格,由于其没有左方单元格和上方单元格,其最小路径和只由其上方或左方单元格的最小路径和确定。于是我们将左边界和上边界单元格进行dp数组的初始化,左边界单元格初始化其上方单元格元素值累加和,上边界单元格初始化器左方单元格元素值累加和。
题解
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
int sum = 0;
for (int i = 0; i < m; ++i) {
sum += grid[i][0];
dp[i][0] = sum;
}
sum = 0;
for (int j = 0; j < n; ++j) {
sum += grid[0][j];
dp[0][j] = sum;
}
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
};
我们也可以不用预先初始化左边界和上边界的dp数组值,在统一计算dp数组值时对左边界和右边界进行特殊处理即可。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (i == 0 && j == 0) dp[i][j] = grid[i][j];//左上角元素
else if (i == 0) dp[i][j] = dp[i][j - 1] + grid[i][j];//上边界
else if (j == 0) dp[i][j] = dp[i - 1][j] + grid[i][j];//左边界
else dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];//其余
}
}
return dp[m - 1][n - 1];
}
};
//好像效果还不如直接初始化
由于状态转移方程计算dp[i][j]
时只由dp[i - 1][j]
或dp[i][j - 1]
推导而来,对应二维dp数组为上方和左方。又因为我们计算dp数组的顺序是由上至下,由左至右,所以计算dp[i][j]
时只需用到上一层的数据或本层已经计算好的数据,因此我们可以使用一维dp数组(滚动数组)来存储计算结果。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<int> dp(n, 0);
dp[0] = grid[0][0];
for (int j = 1; j < n; ++j) {
dp[j] = dp[j - 1] + grid[0][j];
}
for (int i = 1; i < m; ++i) {
dp[0] = dp[0] + grid[i][0];//每计算一层都将起始位置更新
for (int j = 1; j < n; ++j) {
dp[j] = min(dp[j - 1], dp[j]) + grid[i][j];
}
}
return dp[n - 1];
}
};
事实上,由于grid数组前面的数据之后不会再用,可以直接在原grid上建立dp数组,覆盖掉原本的数据,这样会进一步减少内存消耗。
542. 01矩阵
题目描述
给定一个由 0 和 1 组成的矩阵 mat ,请输出一个大小相同的矩阵,其中每一个格子是 mat 中对应位置元素到最近的 0 的距离。
两个相邻元素间的距离为 1 。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hnQDFCSq-1657023302346)(pictureforMD/1626667201-NCWmuP-image.png)]
输入:mat = [[0,0,0],[0,1,0],[0,0,0]]
输出:[[0,0,0],[0,1,0],[0,0,0]]
示例 2:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qa1ImOcB-1657023302347)(pictureforMD/1626667205-xFxIeK-image.png)]
输入:mat = [[0,0,0],[0,1,0],[1,1,1]]
输出:[[0,0,0],[0,1,0],[1,2,1]]
提示:
m == mat.length
n == mat[i].length
1 <= m, n <= 104
1 <= m * n <= 104
mat[i][j] is either 0 or 1.
mat 中至少有一个 0
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/01-matrix
思路
从题目描述中我们可以推理出,**对于每个非边界单元格,其到最近的0的距离取决于其上下左右四个方向上的单元格到最近的0的距离的最小值+1。**因为这道题涉及到四个方向上的最近搜索,所以很多人的第一反应可能会是广度优先搜索。但是对于一个大小 O(mn) 的二维数组,对每个位置进行四向搜索,最坏情况的时间复杂度(即全是 1)会达到恐怖的 O(m2n2)。一种办法是使用一个 dp 数组做 memoization,使得广 度优先搜索不会重复遍历相同位置;另一种方法是,我们分别从左上、左下、右上和右下进行一次动态搜索,最后计算出的dp数组就是通过四个方向计算而来的。以左上为例,我们从左至右,从上到下遍历每个单元格,对于每个单元格,我们仅仅计算其左方向和上方向的数值(这些方向我们已经计算过),其他情况类似。另外我们在初始化dp数值时,不能初始化为0,这很容易理解,因为我们是求最小值。但也不能初始化为最大值,因为我们会涉及到两个方向上求最小值并+1的操作,如果两个方向上都是最大值,+1之后会溢出,所以我们将dp数值初始化为INT_MAX-1。
题解
class Solution {
public:
vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();//题目中说明了1<=m,n<=10000
vector<vector<int>> dp(m, vector<int>(n, INT_MAX - 1));
for (int i = 0; i < m; ++i) {//单元格为0,则到最近的0距离为0(初始化)
for (int j = 0; j < n; ++j) {
if (mat[i][j] == 0) dp[i][j] = 0;
}
}
//从上到下,从左到右遍历矩阵,根据每个单元格其左方向和上方向来计算dp数组
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (i > 0) {//i>0,有上方向单元格
dp[i][j] = min(dp[i][j], dp[i - 1][j] + 1);
}
if (j > 0) {//j>0,有左方向单元格
dp[i][j] = min(dp[i][j], dp[i][j - 1] + 1);
}
}
}
//从下到上,从左到右遍历矩阵,根据每个单元格其左方向和下方向来计算dp数组
for (int i = m - 1; i >= 0; --i) {
for (int j = 0; j < n; ++j) {
if (i < m - 1) {//有下方向单元格
dp[i][j] = min(dp[i][j], dp[i + 1][j] + 1);
}
if (j > 0) {//有左方向单元格呢
dp[i][j] = min(dp[i][j], dp[i][j - 1] + 1);
}
}
}
//从上到下,从右到左遍历矩阵,根据每个单元格其右方向和上方向来计算dp数组
for (int i = 0; i < m; ++i) {
for (int j = n - 1; j >= 0; --j) {
if (i > 0) {//有上方向单元格
dp[i][j] = min(dp[i][j], dp[i - 1][j] + 1);
}
if (j < n - 1) {//有右方向单元格
dp[i][j] = min(dp[i][j], dp[i][j + 1] + 1);
}
}
}
//从下到上,从右到左遍历矩阵,根据每个单元格其右方向和上方向来计算dp数组
for (int i = m - 1; i >= 0; --i) {
for (int j = n - 1; j >= 0; --j) {
if (i < m - 1) {//有下方向单元格
dp[i][j] = min(dp[i][j], dp[i + 1][j] + 1);
}
if (j < n - 1) {//有右方向单元格
dp[i][j] = min(dp[i][j], dp[i][j + 1] + 1);
}
}
}
return dp;
}
};
实际上,我们并不需要四个方向上来计算dp数组,只需计算从上到下,从左到右遍历(根据左方向和上方向计算)和从下到上,从右到左遍历(根据右方向和下方向计算)就可算出结果。证明如下:
o为正确值,x为错误值,?为未判断
初始矩阵:
? ? ? ? ? ? ? ? ?
? ? ? ? ? ? ? ? ?
? ? ? ? ? ? ? ? ?
? ? ? ? 0 ? ? ? ?
? ? ? ? ? ? ? ? ?
? ? ? ? ? ? ? ? ?
首先从上到下从左到右遍历,以左上方元素为基准判断最小值后,得到:
x x x x x x x x x
x x x x x x x x x
x x x x x x x x x
x x x x 0 o o o o
x x x x o o o o o
x x x x o o o o o
矩阵×为计算错误数据(并不一定真的错,只是依据不足,因为只根据了左方向和上方向元素计算)。而o为计算正确数据,因为根据中间的零就可以正确计算出之后的dp数组值。
然后从下到上从右到左方向遍历,以右下方元素为基准判断最小值后,得到正确答案:
o o o o o o o o o
o o o o o o o o o
o o o o o o o o o
o o o o 0 o o o o
o o o o o o o o o
o o o o o o o o o
所以我们可以只通过两个方向的遍历就可以计算出正确答案。另外根据mat[i][j]==0
dp数组初始化为0,这部操作可以放入遍历中。
题解
class Solution {
public:
vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();//题目中说明了1<=m,n<=10000
vector<vector<int>> dp(m, vector<int>(n, INT_MAX - 1));
//从上到下,从左到右遍历矩阵,根据每个单元格其左方向和上方向来计算dp数组
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (mat[i][j] == 0) dp[i][j] = 0;//在遍历过程中初始化
if (i > 0) {//i>0,有上方向单元格
dp[i][j] = min(dp[i][j], dp[i - 1][j] + 1);
}
if (j > 0) {//j>0,有左方向单元格
dp[i][j] = min(dp[i][j], dp[i][j - 1] + 1);
}
}
}
//从下到上,从右到左遍历矩阵,根据每个单元格其右方向和上方向来计算dp数组
for (int i = m - 1; i >= 0; --i) {
for (int j = n - 1; j >= 0; --j) {
if (i < m - 1) {//有下方向单元格
dp[i][j] = min(dp[i][j], dp[i + 1][j] + 1);
}
if (j < n - 1) {//有右方向单元格
dp[i][j] = min(dp[i][j], dp[i][j + 1] + 1);
}
}
}
return dp;
}
};
221. 最大正方形
题目描述
在一个由 ‘0’ 和 ‘1’ 组成的二维矩阵内,找到只包含 ‘1’ 的最大正方形,并返回其面积。
示例 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jLbvtcLr-1657023302347)(pictureforMD/max1grid.jpg)]
输入:matrix = [[“1”,“0”,“1”,“0”,“0”],[“1”,“0”,“1”,“1”,“1”],[“1”,“1”,“1”,“1”,“1”],[“1”,“0”,“0”,“1”,“0”]]
输出:4
示例 2:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bUqxl1jw-1657023302347)(pictureforMD/max2grid.jpg)]
输入:matrix = [[“0”,“1”],[“1”,“0”]]
输出:1
示例 3:
输入:matrix = [[“0”]]
输出:0
提示:
m == matrix.length
n == matrix[i].length
1 <= m, n <= 300
matrix[i][j] 为 ‘0’ 或 ‘1’
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/maximal-square
思路
对于在矩阵内搜索正方形或长方形的题型,一种常见的做法是定义一个二维 dp 数组,其中 dp[i][j]
表示满足题目条件的、以 (i, j) 为右下角的正方形或者长方形的属性。
对于本题,则表示 以 (i, j) 为右下角的全由 1 构成的最大正方形面积。
- 如果当前位置是 0,那么
dp[i][j]
即为 0; - 如果当前位置是 1,则
dp[i][j] = min(dp[i-1][j-1],dp[i][j-1],dp[i-1][j]) + 1;
证明如下:
我们通过固定dp[i][j]
的值,判断其相邻位置与其关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n9PAvOT6-1657023302347)(pictureforMD/14aa58be2ea5c9b36a722db76d2e843c4c909e312223a8461a3d2d93bc734b42-1277-1.png)]
如上图所示,若对于(i,j)位置有dp[i][j]=4
,我们将以(i,j)为右下角、边长为4的正方形涂上色,可以发现其左侧位置、上方位置和左上方位置均可以作为一个边长为4-1=3的正方形的右下角。也就是说,这些位置的dp值至少为3(>=3),即:
dp[i][j - 1] >= dp[i][j] - 1
dp[i - 1][j] >= dp[i][j] - 1
dp[i - 1][j - 1] >= dp[i][j] - 1
将上三式联立可得:min(dp[i][j-1],dp[i-1][j],dp[i-1][j-1]) >= dp[i][j] - 1
同理,我们也可以固定dp[i][j]
相邻位置的dp值,来得到另外的限制条件:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2CSCCxcS-1657023302347)(pictureforMD/dfcaa5390732f1b9d1d9c71f696957cbc91904c52e23705a061bae727228126d-1277-2.png)]
如上图所示,假设dp[i][j-1]
、dp[i-1][j]
、dp[i-1][j-1]
中的最小值为3,也就是说以(i,j-1)、(i-1,j)和(i-1,j-1)为右下角均可以作为一个边长为3的正方形的右下角。我们将它们涂上色,可以发现,如果(i,j)的元素为1,那么它可以作为一个边长为4的正方形的右下角,dp值至少为4,即:
dp[i[j] >= min(dp[i][j-1],dp[i-1][j],dp[i-1][j-1]) + 1
与上一个不等式联立我们可以得到:
dp[i[j] = min(dp[i][j-1],dp[i-1][j],dp[i-1][j-1]) + 1
这样我们就到了非边界dp[i][j]
的递推式。此外我们还要考虑边界(i=0 || j=0)的情况,此时由于边界存在这些dp值最大只能等于1,即dp[i][j](i=0 || j=0) = 1
题解
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
int m = matrix.size(), n =matrix[0].size();//1 <= m,n <= 300
vector<vector<int>> dp(m, vector<int>(n, 0));//dp数组全部初始化0
int maxsize = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
//dp数组初始化为0,就不用单独考虑matrix[i][j]=0的情况了
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) dp[i][j] = 1;//边界处最大边长只能为1
else {
dp[i][j] = min(dp[i - 1][j], min(dp[i - 1][j - 1], dp[i][j - 1])) + 1;
}
maxsize = max(maxsize, dp[i][j]);//更新最大正方形边长
}
}
}
return maxsize * maxsize;//输出最大面积
}
};
上述题解我们对边界进行了特殊处理,假如边界上的matrix[i][j]=1
,则以它为右下角的最大正方形边长只能是1。但由于dp[i][j]
是由其左、左上和上方向的dp值推导而来,所以我们可以将dp数组的大小设置为m+1和n+1,且初始化为0,这样我们就可以利用多出来的左边界和上边界避免判断原matrix的边界情况。
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
int m = matrix.size(), n =matrix[0].size();//1 <= m,n <= 300
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));//dp数组全部初始化0
int maxsize = 0;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
//dp数组初始化为0,就不用单独考虑matrix[i][j]=0的情况了
if (matrix[i - 1][j - 1] == '1') {
dp[i][j] = min(dp[i - 1][j], min(dp[i - 1][j - 1], dp[i][j - 1])) + 1;
maxsize = max(maxsize, dp[i][j]);//更新最大正方形边长
}
}
}
return maxsize * maxsize;//输出最大面积
}
};
7.4 分割类型问题
279. 完全平方数
题目描述
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
示例 1:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
示例 2:
输入:n = 13
输出:2
解释:13 = 4 + 9
提示:
1 <= n <= 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/perfect-squares
思路
对于分割类型题,动态规划的状态转移方程通常并不依赖相邻的位置,而是依赖于满足分割条件的位置。我们定义一个一维矩阵 dp,其中 dp[i] 表示数字 i 最少可以由几个完全平方数相加构成。在本题中,位置 i 只依赖 i - k2 的位置,如 i - 1、i - 4、i - 9 等等,才能满足完全平方分割 的条件。因此 dp[i] 可以取的最小值即为 1 + min(dp[i-1], dp[i-4], dp[i-9] · · · )。
题解
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);//求min所以设置为最大值才不会被覆盖
dp[0] = 0;
for (int j = 1; j < n + 1; j++) {//先遍历背包
for (int i = 1; i * i <= j; i++) {//后遍历物品
dp[j] = min(dp[j], dp[j - i * i] + 1);
//1就是i*i这个完全平方数,即j是有j-i*i + i*i得到的,而j-i*i最少由几个完全平方数组成我们已经求过了,是dp[j-i*i]
}
}
return dp[n];
}
};
实质上,本题属于完全背包问题,n就是背包,完全平方数就是物品(并且物品可以多次使用)。因为本题只要求计算最小数量,没有对组合或排列做要求,所以我们先遍历物品和先遍历背包是一样的。上述题解给出的是先遍历背包,先遍历 物品的写法如下:
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int j = 1; j * j <= n; ++j) {//先遍历物品
for (int i = 1; i <= n; ++i) {//后遍历背包
if (i - j * j >= 0) {//可以装进背包
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
}
}
return dp[n];
}
};
91. 解码方法
题目描述
一条包含字母 A-Z 的消息通过以下映射进行了 编码 :
‘A’ -> “1”
‘B’ -> “2”
…
‘Z’ -> “26”
要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,“11106” 可以映射为:
“AAJF” ,将消息分组为 (1 1 10 6)
“KJF” ,将消息分组为 (11 10 6)
注意,消息不能分组为 (1 11 06) ,因为 “06” 不能映射为 “F” ,这是由于 “6” 和 “06” 在映射中并不等价。
给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数 。
题目数据保证答案肯定是一个 32 位 的整数。
示例 1:
输入:s = “12”
输出:2
解释:它可以解码为 “AB”(1 2)或者 “L”(12)。
示例 2:
输入:s = “226”
输出:3
解释:它可以解码为 “BZ” (2 26), “VF” (22 6), 或者 “BBF” (2 2 6) 。
示例 3:
输入:s = “0”
输出:0
解释:没有字符映射到以 0 开头的数字。
含有 0 的有效映射是 ‘J’ -> “10” 和 ‘T’-> “20” 。
由于没有字符,因此没有有效的方法对此进行解码,因为所有数字都需要映射。
提示:
1 <= s.length <= 100
s 只包含数字,并且可能包含前导零。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/decode-ways
思路
这是一道很经典的动态规划题,难度不大但是十分考验耐心。这是因为只有 1-26 可以表示 字母,因此对于一些特殊情况,比如数字 0 或者当相邻两数字大于 26 时,需要有不同的状态转移方程。
具体来讲,我们从下标2(因为需要用到dp[i-2])开始遍历字符串直到结尾,开始时pre指向下标为0的元素,cur指向下标i-1的元素。由于i起始为2,为统一字符串大小为2的字符串,dp[i]表示下标0~i-1有几种方法,最后整个字符串的解码方法数存到dp[n]中。
- 初始阶段pre=s[0] - ‘0’,当pre=0时,说明此字符串无法被解码(不存在起始位0为0的字符)return 0;
- 进入循环后,当pre=0或pre > 2,且cur=0时,有两种情况,当pre=0时一定不能解码;当pre>2且cur=0时不存在类似30对应的字符,也不存在0对应的字符,return 0;
- 当pre=1 或 pre=2且cur<7时,若cur=0,此时可以解码,只不过只能将pre和cur算做一个整体,dp[i] = dp[i-1];若cur!=0,此时也可以解码,可以看成两部分,1是cur单独解码,2是pre和cur组合解码,dp[i] = dp[i-1] + dp[i-2]
- 其他情况,也就是pre>2且cur=1~9或pre=0且cur=1-9,此时只能cur单独解码,dp[i] = dp[i-1]。
题解
class Solution {
public:
int numDecodings(string s) {
int size = s.size();
int pre = s[0] - '0';
if (pre == 0) return 0;
if (size == 1) return 1;
vector<int> dp(size + 1, 1);//dp[i]表示0-i-1有多少种解码方法,dp[0]=1,dp[1]=1
for (int i = 2; i <= size; ++i) {
int cur = s[i - 1] - '0';
if ((pre == 0 || pre > 2) && cur == 0) return 0;
if (pre == 1 || (pre == 2 && cur < 7)) {
if (!cur) dp[i] = dp[i - 2];
else dp[i] = dp[i - 1] + dp[i - 2];
}
else dp[i] = dp[i - 1];
pre = cur;
}
return dp[size];
}
};
139. 单词拆分
题目描述
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 1:
输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以由 “leet” 和 “code” 拼接成。
示例 2:
输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回 true 因为 “applepenapple” 可以由 “apple” “pen” “apple” 拼接成。
注意,你可以重复使用字典中的单词。
示例 3:
输入: s = “catsandog”, wordDict = [“cats”, “dog”, “sand”, “and”, “cat”]
输出: false
提示:
1 <= s.length <= 300
1 <= wordDict.length <= 1000
1 <= wordDict[i].length <= 20
s 和 wordDict[i] 仅有小写英文字母组成
wordDict 中的所有字符串 互不相同
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/word-break
思路
本题类似于完全平方数分割问题,这道题的分割条件由集合内的字符串决定,因此在考虑每个分割位置时,需要遍历字符串集合,以确定当前位置是否可以成功分割
具体来讲,我们定义一个大小为字符串长度+1的dp数组,dp[i]表示字符串长度为i时,是否可以切割为字典中的字符串,当字符串[i-len,i]是字典中的一个字符串时,dp[i]= dp[i] || dp[i-len]。(||是因为只要可以切割为字典中的一个字符串时,dp[i]就为true,不用管之后是否可以切割成)dp数组初始化为false。注意对于位置 0,需要初始化值为真。因为这是递推的起点(字符串[0,i]正好对应字典中的一个字符串,此时需要dp[0],如果dp[0]为false,之后的递推就都是false了)。
题解
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int size = s.size();
vector<bool> dp(size + 1, false);
dp[0] = true;
for (int i = 1; i <= size; ++i) {
for (const string& word : wordDict) {
int len = word.size();
if (i >= len && s.substr(i - len, len) == word) {
dp[i] = dp[i] || dp[i - len];
}
}
}
return dp[size];
}
};
上述解法是将字符串s进行切割,对于位置i,我们遍历字典,查询是否有字符串等于s[i-len,i]。我们也可以将此题看作是完全背包问题。
具体来讲,我们外循环遍历背包容量(切割字符串的尾),内循环遍历物品(切割字符串的首),当这个切割出来的字符串s[i,j]在字典中且dp[i]=true时,dp[j] = true。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size() + 1, false);
dp[0] = true;
for (int j = 0; j <= s.size(); j++) {//遍历背包
for (int i = 0; i < j; i++) {//遍历物品
string word = s.substr(i, j - i);//从i起始,截取j-i个字符即[i,j]子串 //复杂度为O(n)
if (wordSet.find(word) != wordSet.end() && dp[i]) dp[j] = true;//找到[i,j]这个子串,且dp[i]=true
}
}
return dp[s.size()];
}
};
7.5 子序列问题
300. 最长递增子序列
题目描述
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
提示:
1 <= nums.length <= 2500
-10^4 ^<= nums[i] <= 104
进阶:
你能将算法的时间复杂度降低到 O(n log(n)) 吗?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/longest-increasing-subsequence
思路
首先明确dp[i]含义:nums[0,i]最长严格递增子序列的长度。之后确定递推关系,当nums[i]>nums[j](j<i)时说明子序列的长度可以+1,dp[i]=dp[j] +1。但j有多种取值,所以我们要找j各个值时dp[i]的最小值,所以有状态转移公式dp[i] = max(dp[i], dp[j] + 1).
题解
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int size = nums.size();
if (size == 1) return 1;
vector<int> dp(size, 1);
int maxlength = 0;
for (int i = 0; i < size; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
//此时dp[i]存的是nums[0,i]中最大递增子序列长度
maxlength = max(maxlength, dp[i]);
}
return maxlength;
}
};
题目中要求最好可以使用O(nlogn)时间复杂度的解法,于是我们考虑一个简单的贪心,如果我们要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。
基于上面的贪心思路,我们维护一个数组 d[i]。d[i] :表示长度为 i的最长上升子序列的末尾元素的最小值,用len 记录目前最长上升子序列的长度,起始时 len 为 1,d[1]=nums[0]。
同时我们可以注意到 d[i] 是关于 i 单调递增的。因为如果d[j]≥d[i] 且 j < i,我们考虑从长度为 i 的最长上升子序列的末尾删除 i−j 个元素,那么这个序列长度变为 j ,且第 j 个元素 x(末尾元素)必然小于 d[i],也就小于 d[j]。那么我们就找到了一个长度为 j的最长上升子序列,并且末尾元素比 d[j] 小,从而产生了矛盾。因此数组 d 的单调性得证。
整个算法流程为:
设当前已求出的最长上升子序列的长度为len(初始时为 1),从前往后遍历数组ums,在遍历到 nums[i] 时:
- 如果 nums[i]>d[len] ,则直接加入到 d 数组末尾,并更新len=len+1;
- 如果nums[i] <= d[len],在 d 数组中二分查找,找到第一个比 nums[i] 小的数d[k] ,并更新d[k+1]=nums[i];
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int len = 1, n = nums.size();
if (n == 0) {
return 0;
}
vector<int> d(n + 1, 0);
d[len] = nums[0];
for (int i = 1; i < n; ++i) {
if (nums[i] > d[len]) {
d[++len] = nums[i];
} else {
int l = 1, r = len, pos = 0; // 如果找不到说明所有的数都比 nums[i] 大,此时要更新 d[1],所以这里将 pos 设为 0
while (l <= r) {//左闭右闭
int mid = (l + r) >> 1;
if (d[mid] < nums[i]) {
pos = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
//d[pos]是第一个比nums[i]小的元素,d[pos+1]是第一个比nums[i]大的元素,将它的值改为nums[i],就可以让子序列尽可能的增长慢
d[pos + 1] = nums[i];
}
}
return len;
}
};
上面这个解法执行用时很低,但是内存消耗相对较高,这是因为我们预先定义了大小为n+1的dp数组,其实并不需要那么大的数组,于是我们可以动态增长dp数组,dp数组的长度即是给定数组的最长递增子序列长度,也省去了定义变量len来记录长度。另外我们也可以不用手写二分法,调用lower_bound()函数也可以找到第一个不小于nums[i]的位置。
我们定义一个 dp 数组,其中 dp[k] 存储长度为 k+1 的最长递增子序列的最后一个数字。我们遍历每一个位置 i,如果其对应的数字 大于 dp 数组中所有数字的值,那么我们把它放在 dp 数组尾部,表示最长递增子序列长度加 1; 如果我们发现这个数字在 dp 数组中比数字 a 大、比数字 b 小,则我们将 b 更新为此数字,使得 之后构成递增序列的可能性增大。以这种方式维护的 dp 数组永远是递增的,因此可以用二分查 找加速搜索。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int size = nums.size();
if (size == 1) return 1;
vector<int> dp;
dp.push_back(nums[0]);
for (int i = 1 ;i < size; ++i) {
if (dp.back() < nums[i]) {//nums[i]大于dp中所有元素(dp递增,最后一个元素最大)
dp.push_back(nums[i]);
}
else {
vector<int> :: iterator iter = lower_bound(dp.begin(), dp.end(), nums[i]);
*iter = nums[i];
}
}
return dp.size();
}
};
1143. 最长公共子序列
题目描述
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例 1:
输入:text1 = “abcde”, text2 = “ace”
输出:3
解释:最长公共子序列是 “ace” ,它的长度为 3 。
示例 2:
输入:text1 = “abc”, text2 = “abc”
输出:3
解释:最长公共子序列是 “abc” ,它的长度为 3 。
示例 3:
输入:text1 = “abc”, text2 = “def”
输出:0
解释:两个字符串没有公共子序列,返回 0 。
提示:
1 <= text1.length, text2.length <= 1000
text1 和 text2 仅由小写英文字符组成。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/longest-common-subsequence
思路
dp[i][j]
:表示下标为[0, i - 1]的字符串text1与⻓度为[0, j - 1]的字符串text2的最⻓公共⼦序列长度。有同学会问:为什么要定义⻓度为[0, i - 1]的字符串text1,定义下标为[0, i]的字符串text1不⾹么?这样定义是为了后⾯代码实现⽅便,如果⾮要定义为下标[0, i]的字符串text1也可以,⼤家可以试⼀试!
如果text1[i - 1] 与 text2[j - 1]相同,那么找到了⼀个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1;
如果不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最⻓公共⼦序列 和text1[0, i - 1]与text2[0, j - 2]的最⻓公共⼦序列,取最⼤的。即dp[i][j] = max(dp[i - 1][j], dp[i][j-1]);
dp[i][0]
表示text1[0,i-1]与空串的最长公共子序列长度,肯定是0,dp[0][j]
表示空串与text2[0, j -1]的最长公共子串,肯定也为0。其他下标都是根据这些初值递推出来的(覆盖掉),所以初始化多少都可,为方便统一初始化为0。
在二维dp数组中,我们可以看出,dp[i][j]
由左方向、左上方向、上方向推导而来(即dp[i][j]
由dp[i][j]
、dp[i-1][j-1]
和dp[i-1][j]
推导而来)。当我们在计算dp[i][j]
时,一定要保证这三个值是确定的,所以我们从上到下,从左到右依次计算dp数组的值。
题解
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));//初始化为0(主要是初始化左边界和上边界)
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (text1[i - 1] == text2[j - 1]) {//找到一个公共元素,
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m][n];
}
};
让我们试试可不可以使用滚动数组,dp[i][j]由左上方,上方和左方来推导:
对于左方和上方的数据都可以直接用滚动数组,但是左上方的不行,因为左上方的是上一轮的外循环的dp[j-1](dp[i-1][j-1]
),要是我们直接用temp=dp[j-1],则表示的是左方的dp[i][j-1]
。怎么才能记录上一轮外循环的dp[j-1]呢?用两个临时变量temp1和temp2,在第一层循环内进行初始化,都为dp[0](每一层第i层都重置为上一轮已经处理好的dp[0]即dp[i-1][0]
。temp2记录上一轮内循环未处理的dp[j](处理之后就是dp[i][j-1]
了)用于下轮(j+1)(即dp[i-1][j-1]
)。temp1赋值为temp2,获得上一轮的dp[j-1]。记住这种方法,编辑距离题还会用到。代码如下:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
vector<int> dp(text2.size() + 1, 0);
for (int i = 1; i < text1.size() + 1; i++) {
int temp1 = dp[0], temp2 = dp[0]; //用于记录上一轮的dp[i-1][j-1]
for (int j = 1; j < text2.size() + 1; j++) {
temp1 = temp2;//temp2存储上一轮循环的dp[j],之后j++,即temp2 =dp[i-1][j-1] 赋给temp1
temp2 = dp[j];//将temp2更新为未计算的dp[j],即上一轮循环的dp[j],又因为本轮循环后j++,所以相当于存储了dp[i-1][j-1]
if (text1[i - 1] == text2[j - 1]) dp[j] = temp1 + 1;
else dp[j] = max(dp[j], dp[j - 1]);
}
}
return dp[text2.size()];
}
};
对于子序列问题,第二种动态规划方法是,定义一个 dp 数组,其中 dp[i] 表示到位置 i 为止 的子序列的性质,并不以 i 结尾。这样 dp 数组(n+1)的最后一位结果即为题目所求,不需要再对每 个位置进行统计。 在本题中,我们可以建立一个二维数组 dp,其中 dp[i][j]
表示到第一个字符串位置 i 为止、到 第二个字符串位置 j 为止、最长的公共子序列长度。这样一来我们就可以很方便地分情况讨论这 两个位置对应的字母相同与不同的情况了。
7.6 背包问题
背包问题是一种组合优化的 NP 完全问题:有 N 个物品和容量为 W 的背包,每个物品都有 自己的体积 w 和价值 v,求拿哪些物品可以使得背包所装下物品的总价值最大。如果限定每种物 品只能选择 0 个或 1 个,则问题称为 0-1 背包问题;如果不限定每种物品的数量,则问题称为无界背包问题或完全背包问题。
0-1 背包
我们可以用动态规划来解决背包问题。以 0-1 背包问题为例。
一种写法是:我们可以定义一个二维数组 dp 存储最大价值,**其中 dp[i][j]
表示取前 i (i=1-N)件在物品体积不超过 j 的情况下能达到的最大价值。**在我们遍历到第 i 件物品时,在当前背包总容量为 j 的情况下,如果我们不将物品 i 放入背包,那么 dp[i][j] = dp[i-1][j]
,即前 i 个物品的最大价值等于只取前 i-1 个物品时的最大价值;如果我们将物品 i 放 入背包,假设第 i 件物品体积为 w,价值为 v,那么我们得到 dp[i][j] = dp[i-1][j-w] + v
。我们只需在遍历过程中对这两种情况取最大值即可,总时间复杂度和空间复杂度都为 O(NW)。
这种写法是计算第i个物品而不是下标为i的物品,而题目中往往给出物品体积和价值数组,所以对应下标应该-1,dp数组应该定义N+1行,W+1行。由于i=1时实际上遍历的是下标为0的物品,计算dp[1][j]
需要dp[0][j]
,而dp[0]这一行的数据我们初始化为0,相当于用来辅助计算dp[1]这一行的。所以这种写法的优点是不用刻意的初始化第一个物品这一行dp数组。缺点是需要搞清第i个物品其下标是i-1
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<vector<int>> dp(weight.size() + 1, vector<int>(begweight + 1, 0));//dp数组初始化为0
for (int i = 1; i < weight.size() + 1; ++i) {
int w = weight[i - 1], v = value[i - 1];//第i个物品
for (int j = 1; j < begweight + 1; ++j) {
if (j >= w) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w] + v);
else dp[i][j] = dp[i - 1][j];
}
}
cout << dp[weight.size()][begweight] << endl;
}
另一种写法是定义dp数组,dp[i][j]
的含义是:从下标为[0-i]的物品⾥任意取,放进容量为j的背包,价值总和最⼤是多少。因为dp[0]这一行就是装入下标为0的物品对应的最大价值,而计算dp[0]需要上一行的数据,不能统一计算,所以我们先将dp数组第一行(背包放入下标为0的物品)初始化。这种写法的优点是遍历下标即是物品下标,逻辑不混乱。缺点就是需要单独初始化dp数组第一行
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<vector<int>> dp(weight.size() + 1, vector<int>(begweight + 1, 0));//dp数组初始化为0
for (int j = weight[0]; j <= begweight; j++) dp[0][j] = value[0];//dp数组第一行初始化,背包放入物品0的价值,容量小于物理0重量的则仍保持0(装不下)
for (int i = 1; i < weight.size(); i++) {
for (int j = 1; j < begweight + 1; j++) {
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][begweight] << endl;
}
我们可以进一步对 0-1 背包进行空间优化,将空间复杂度降低为 O(W)。
先按第一种写法,假设我 们目前考虑物品 i = 2,且其体积为 w = 2,价值为 v = 3;对于背包容量 j,我们可以得到 dp[2][j] = max(dp[1][j], dp[1][j-2] + 3)
。这里可以发现我们永远只依赖于上一排 i = 1 的信息,之前算过的 其他物品都不需要再使用。因此我们可以去掉 dp 矩阵的第一个维度,在考虑物品 i 时变成 dp[j] = max(dp[j], dp[j-w] + v)。这里要注意我们在遍历每一行的时候必须逆向遍历,这样才能够调用 上一行物品 i-1 时 dp[j-w] 的值;若按照从左往右的顺序进行正向遍历,则 dp[j-w] 的值在遍历到 j 之前就已经被更新成物品 i 的值了。
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<int> dp(begweight + 1);//dp数组初始化为0
for (int i = 1; i < weight.size() + 1; ++i) {
int w = weight[i - 1], v = value[i - 1];//第i个物品
for (int j = begweight; j >= w; --j) {//倒序遍历
dp[j] = max(dp[j], dp[j - w] + v);
}
}
cout << dp[begweight] << endl;
}
之后考虑第二种写法:
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<int> dp(begweight + 1);
for (int i = 0; i < weight.size(); i++) {
for (int j = begweight; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[begweight] << endl;//打印最终结果
}
完全背包
在完全背包问题中,一个物品可以拿多次。,假设我们遍历到物品 i = 2, 且其体积为 w = 2,价值为 v = 3;对于背包容量 j = 5,最多只能装下 2 个该物品。那么我们的状态转移方程就变成了 dp[2][5] = max(dp[1][5], dp[1][3] + 3, dp[1][1] + 6)
。如果采用这种方法,假设 背包容量无穷大而物体的体积无穷小,我们这里的比较次数也会趋近于无穷大,远超 O(NW) 的 时间复杂度。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0LFXOGyv-1657023302348)(pictureforMD/image-20220701111737540.png)]
怎么解决这个问题呢?我们发现在 dp[2][3]
的时候我们其实已经考虑了 dp[1][3]
和 dp[2][1]
的情况,而在 dp[2][1]
时也已经考虑了 dp[1][1]
的情况。因此,如图下半部分所示,对于拿多个物品的情况,我们只需考虑 dp[2][3]
即可,即 dp[2][5] = max(dp[1][5], dp[2][3] + 3)
。这样,我们 就得到了完全背包问题的状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i][j-w] + v)
,其与 0-1 背包问题的差别仅仅是把状态转移方程中的第二个 i-1 变成了 i。
上述讲解的是二维dp数组的状态转移方程,类似0-1背包,我们也有两种写法,第一种:
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<vector<int>> dp(weight.size() + 1, vector<int>(begweight + 1, 0));//dp数组初始化为0
for (int i = 1; i < weight.size() + 1; ++i) {
int w = weight[i - 1], v = value[i - 1];//第i个物品
for (int j = 1; j < begweight + 1; ++j) {
if (j >= w) dp[i][j] = max(dp[i - 1][j], dp[i][j - w] + v);//注意相对于0-1背包状态转移公式这里第二个i-1改为i
else dp[i][j] = dp[i - 1][j];
}
}
cout << dp[weight.size()][begweight] << endl;
}
第二种写法:
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<vector<int>> dp(weight.size() + 1, vector<int>(begweight + 1, 0));//dp数组初始化为0
for (int j = weight[0]; j <= begweight; j++) dp[0][j] = value[0];//dp数组第一行初始化,背包放入物品0的价值,容量小于物理0重量的则仍保持0(装不下)
for (int i = 1; i < weight.size(); i++) {
for (int j = 1; j < begweight + 1; j++) {
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]); //注意相对于0-1背包状态转移公式这里第二个i-1改为i
}
}
cout << dp[weight.size() - 1][begweight] << endl;
}
同样的,我们也可以利用空间压缩将时间复杂度降低为 O(W)。这里要注意我们在遍历每一行的时候必须正向遍历,因为我们需要利用当前物品在第 j-w 列的信息。如果是倒序遍历就成了背包只装一种物品的一个,就不是完全背包了。
第一种写法:
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<int> dp(begweight + 1);//dp数组初始化为0
for (int i = 1; i < weight.size() + 1; ++i) {
int w = weight[i - 1], v = value[i - 1];//第i个物品
for (int j = w; j <= begweight; ++j) {//正序遍历
dp[j] = max(dp[j], dp[j - w] + v);
}
}
cout << dp[begweight] << endl;
}
第二种写法:
void test_wei_beg_problem(vector<int>& weight, vector<int>& value, int begweight) {
vector<int> dp(begweight + 1);
for (int i = 0; i < weight.size(); i++) {
for (int j = 0; j < begweight + 1; j++) {
if (j >= weight[i]) {//装得下
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
//装不下则dp[j]保持不变
}
}
cout << dp[begweight] << endl;//打印最终结果
}
压缩空间时到底需要正向还是逆向遍历呢?物品和体积哪个放在外层,哪个放在内层呢? 这取决于状态转移方程的依赖关系。在思考空间压缩前,不妨将状态转移矩阵画出来,方便思考 如何进行空间压缩。这些问题需要我们仔细思考并理解。
总结一下:
0-1背包二维dp数组两个for循环顺序可以颠倒,完全背包二维dp数组两个for循环顺序也可以颠倒。虽然两个for循环遍历的次序不同,但是dp[i][j]
所需要的数据dp[i-1][j]
和dp[i (- 1)][j - weight[i]]
就是左上⻆和正上方向,根本不影响dp[i][j]
公式的推导!
0-1背包一维dp数组只能是先遍历物品后遍历背包容量且背包容量要倒序遍历。(先物品后背包保证可以装多种物品,倒序保证每种物品只装一个)
为什么0-1背包不能先遍历背包呢?如果先遍历背包容量后遍历物品且背包容量倒序遍历,求的就是背包只装一种物品的一个,其最大价值是多少(不伦不类)。如果先遍历背包容量后遍历物品且背包容量正序遍历,求的就是背包只装一种物品(可以多个),其最大价值是多少(完全背包)。
完全背包一维dp数组两个for循环顺序可以颠倒,且遍历背包要正序遍历。如果是倒序遍历就成了背包只装一种物品的一个,就不是完全背包了。
416. 分割等和子集
题目描述
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 100
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/partition-equal-subset-sum
思路
在动态规划前,我们首先需要进行一些判断:
- 若数组的长度size<2,则不可能直接将数组分割成元素和相等的两个子集,因此直接返回false。
- 若数组总和sum为奇数则不可能将数组分割成总和相等的两个子集。
- 若数组总和sum为偶数,且数组最大元素maxNum大于target=sum/2,则不可能将数组分割成总和相等的两个子集。
首先本题数组中的元素只能使用一次,所以本题等价于 0-1 背包问题,设所有数字和为 sum,我们的目标是选取一部分物品,使得它们 的总和为target= sum/2。这道题可以不需要考虑价值,因此我们只需要通过一个布尔值矩阵来表示状态转移 矩阵。注意边界条件的处理。
题解
不用初始化第一行的写法,dp数组需要多定义一行,其中dp[i][j]
表示选取下标为0-i-1的数时(可以不选),是否存在一种方案使得被选取的数之和等于j。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int size = nums.size();
if (size == 1) return false;//长度为1 不能分割
int sum = accumulate(nums.begin(), nums.end(), 0);//返回第一个最大值迭代器
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) return false;//int型变量sum二进制形式若最后一位是1则是奇数(与运算)
int target = sum / 2;
if (maxNum > target) return false;//最大值大于target,分割不成
vector<vector<bool>> dp(size + 1, vector<bool>(target + 1, false));
for (int i = 0; i <= size; ++i) {//初始化第一列(target为0时,可以什么数都不选,总和为0)
dp[i][0] = true;
}
//不用初始化第一行
for (int i = 1; i <= size; ++i) {
for (int j = 1; j <= target; ++j) {
if (j >= nums[i - 1]) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];//j>=nums[i-1]装得下,计算dp[i][j]
}
else {//j<nums[i-1]装不下,则dp[i][j]保持上一行的值
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[size][target];
}
};
初始化第一行的写法。其中dp[i][j]
表示选取下标为0-i的数时(可以不选),是否存在一种方案使得被选取的数之和等于j。:
class Solution {
public:
bool canPartition(vector<int>& nums) {
int size = nums.size();
if (size == 1) return false;//长度为1 不能分割
int sum = accumulate(nums.begin(), nums.end(), 0);//返回第一个最大值迭代器
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) return false;//int型变量sum二进制形式若最后一位是1则是奇数(与运算)
int target = sum / 2;
if (maxNum > target) return false;//最大值大于target,分割不成
vector<vector<bool>> dp(size, vector<bool>(target + 1, false));
for (int i = 0; i < size; ++i) {//初始化第一列(target为0时,可以什么数都不选,总和为0)
dp[i][0] = true;
}
dp[0][nums[0]] = true;//初始化第一行首个true
for (int i = 1; i < size; ++i) {
for (int j = 1; j <= target; ++j) {
if (j >= nums[i]) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];//j>=nums[i-1]装得下,计算dp[i][j]
}
else {//j<nums[i-1]装不下,则dp[i][j]保持上一行的值
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[size - 1][target];
}
};
我们可以使用滚动数组,对空间复杂度进行优化,注意这是0-1背包,为使数组中的每一个数只使用一次,对背包的遍历需要倒序。
第一种写法:
class Solution {
public:
bool canPartition(vector<int>& nums) {
int size = nums.size();
if (size == 1) return false;//长度为1 不能分割
int sum = accumulate(nums.begin(), nums.end(), 0);//返回第一个最大值迭代器
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) return false;//int型变量sum二进制形式若最后一位是1则是奇数(与运算)
int target = sum / 2;
if (maxNum > target) return false;//最大值大于target,分割不成
vector<bool> dp(target + 1, false);
dp[0] = true;//初始化第一行首个true
for (int i = 1; i <= size; ++i) {
for (int j = target; j >= nums[i - 1]; --j) {
dp[j] = dp[j] || dp[j - nums[i - 1]];//j>=nums[i-1]装得下,计算dp[j]
}
}
return dp[target];
}
};
第二种写法:
class Solution {
public:
bool canPartition(vector<int>& nums) {
int size = nums.size();
if (size == 1) return false;//长度为1 不能分割
int sum = accumulate(nums.begin(), nums.end(), 0);//返回第一个最大值迭代器
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) return false;//int型变量sum二进制形式若最后一位是1则是奇数(与运算)
int target = sum / 2;
if (maxNum > target) return false;//最大值大于target,分割不成
vector<bool> dp(target + 1, false);
dp[0] = true;//初始化第一行首0个true
for (int i = 0; i < size; ++i) {
for (int j = target; j >= nums[i]; --j) {
dp[j] = dp[j] || dp[j - nums[i]];//j>=nums[i]装得下,计算dp[j]
}
}
return dp[target];
}
};
我们也可以换一种dp数组定义方式:dp[j]表示:子集和容量为j,最大可以凑成j的子集总和。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
class Solution {
public:
bool canPartition(vector<int>& nums) {
int size = nums.size();
if (size == 1) return false;//长度为1 不能分割
int sum = accumulate(nums.begin(), nums.end(), 0);//返回第一个最大值迭代器
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) return false;//int型变量sum二进制形式若最后一位是1则是奇数(与运算)
int target = sum / 2;
if (maxNum > target) return false;//最大值大于target,分割不成
vector<int> dp(target + 1);
for (int i = 0; i < nums.size(); i++) {
for (int j = target; j >= nums[i]; j--) {
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
if (dp[target] == target) return true;
return false;
}
};
//时间复杂度:O(n^2)
//空间复杂度:O(n)
可以看出,⼀维dp 的01背包,要⽐⼆维简洁的多! 初始化 和 遍历顺序相对简单了。所以我倾向于使⽤⼀维dp数组的写法,⽐较直观简洁,⽽且空间复杂度还降了⼀个数量级!在后⾯背包问题的讲解中,我都直接使⽤⼀维dp数组来进⾏推导。
474. 一和零(二维0-1背包)
题目描述
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
示例 1:
输入:strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {“10”,“0001”,“1”,“0”} ,因此答案是 4 。
其他满足题意但较小的子集包括 {“0001”,“1”} 和 {“10”,“1”,“0”} 。{“111001”} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。
示例 2:
输入:strs = [“10”, “0”, “1”], m = 1, n = 1
输出:2
解释:最大的子集是 {“0”, “1”} ,所以答案是 2 。
提示:
1 <= strs.length <= 600
1 <= strs[i].length <= 100
strs[i] 仅由 ‘0’ 和 ‘1’ 组成
1 <= m, n <= 100
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/ones-and-zeroes
思路
这是一个多维费用的 0-1 背包问题,而不是多重背包,只不过有两个背包大小维度,0 的数量和 1 的数量。题目中要求的是满足条件的最大子集长度,所以我们将dp数组定义为,dp[i][j]
:最多包含i个0和j个1的strs的最大子集长度,也就是在不超过两个维度背包容量的前提下其物品的最大数量。当我们遍历一个物品(字符串)时,有两种情况:1当前物品不加入背包dp[i][j] = dp[i][j]
;2当前物品加入背包dp[i][j] = dp[i - zeroNum][j - zeroNum] + 1
;这二者是要取最大值的,状态转移公式为:dp[i][j]=max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1)
。dp数组应该初始化为0,因为我们是求最大值,不会被覆盖。遍历顺序应该是倒序遍历二维背包容量,因为是0-1背包。
题解
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));//初始化为0
for (const string& str : strs) {
int zeroNum = count(str.begin(), str.end(), '0');
int oneNum = count(str.begin(), str.end(), '1');
for (int i = m; i >= zeroNum; --i) {
for (int j = n; j >= oneNum; --j) {
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
return dp[m][n];
}
};
不少同学刷过这道题,可能没有总结这究竟是什么背包。这道题的本质是有两个维度的01背包,如果⼤家认识到这⼀点,对这道题的理解就⽐较深⼊了。
322. 零钱兑换
题目描述
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有.任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/coin-change
思路
由于每种硬币的数量是无限的,所以本题是完全背包问题。dp[j]表示凑够j所需的最小硬币数。当遍历的到一种硬币时,有两种情况:1.不加入背包dp[j] = dp[j];2.加入背包,dp[j] = dp[j - coins[i]] + 1;因为我们是求最小硬币数,所以二者要取最小值,状态转移公式为dp[j]=min(dp[j], dp[j - coins[i] + 1])。因为我们是求最小数量为保证数据不被覆盖,所以dp数组应该初始化为最大值INT_MAX,dp[0]是所有递推的起点,所以应该初始化为0,表示凑够0需要0个硬币。另外由于是完全背包,所以内层遍历背包时,我们应该正序遍历,才能保证一种物品被取多次。
题解
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i < coins.size(); ++i) {
for (int j = coins[i]; j <= amount; ++j) {
if (dp[j - coins[i] != INT_MAX) {//防止dp[j - coins[i]] + 1越界
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
我们也可以把 dp 数组初始化为 amount + 2 ,为什么取这个值,是因为 j 最大可 以取 amount + 1,而最多的组成方式是只用 1 元硬币,因此 amount + 2 一定大于凑够j所需的硬币数,取最小值时一定不会是它。在动态规划完成后,若结果仍然是此值,则说明不存在满足条 件的组合方法,返回-1。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 2);
dp[0] = 0;
for (int i = 0; i < coins.size(); ++i) {
for (int j = coins[i]; j <= amount; ++j) {
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
return dp[amount] == amount + 2 ? -1 : dp[amount];
}
};
7.7 字符串编辑问题
72. 编辑距离
题目描述
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
示例 2:
输入:word1 = “intention”, word2 = “execution”
输出:5
解释:
intention -> inention (删除 ‘t’)
inention -> enention (将 ‘i’ 替换为 ‘e’)
enention -> exention (将 ‘n’ 替换为 ‘x’)
exention -> exection (将 ‘n’ 替换为 ‘c’)
exection -> execution (插入 ‘u’)
提示:
0 <= word1.length, word2.length <= 500
word1 和 word2 由小写英文字母组成
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/edit-distance
思路
我们使用一个二维数组 dp[i][j]
,表示将第一个字符串到位置 i 为止(以i-1为结尾),和第 二个字符串到位置 j 为止(以j-1为结尾),最多需要几步编辑。当第 i 位和第 j 位对应的字符相同时,dp[i][j]
等 于 dp[i-1][j-1]
;当二者对应的字符不同时,替换的消耗是 dp[i-1][j-1]+1
,插入 i 位置(相当于删除下标j-1元素) 的消耗是 dp[i][j-1] + 1
,删除 下标i-1元素的消耗是 dp[i-1][j] + 1
。
从递推公式我们可以看出,dp[i][j]
由dp[i-1][j-1]
、dp[i][j-1]
、dp[i-1][j]
推导出来,所以必须要初始化的是dp[i][0]
和dp[0][j]
,dp[i][0]
表示以word1[i-1]为结尾的字符串word1和空字符串word2的最近编辑距离,一定是i(删除i个元素)。同理dp[0][j]
一定是j。
题解
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
for (int i = 0; i <= word1.size(); ++i) {
for (int j = 0; j <= word2.size(); ++j) {
if (i == 0) dp[i][j] = j;//初始化
else if (j == 0) dp[i][j] = i;//初始化
else {
dp[i][j] = min(dp[i - 1][j - 1] + (word1[i - 1] == word2[j - 1] ? 0 : 1),
min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
}
}
}
return dp[word1.size()][word2.size()];
}
};
我们也可以使用滚动数组,和之前讲过的一样,temp2记录本轮的未计算的dp[i][j]
即dp[i-1][j]
,在j+1后赋给temp1,此时temp1就保存了dp[i-1][j-1]
。而在每遍历一行时,temp2都要更新为上一行的dp[0]即i-1。
class Solution {
public:
int minDistance(string word1, string word2) {
vector<int> dp(word2.size() + 1, 0);
for (int i = 0; i <= word1.size(); ++i) {
int temp1, temp2 = i - 1;
for (int j = 0; j <= word2.size(); ++j) {
if (i == 0) dp[j] = j;//初始化
else if (j == 0) dp[j] = i;//初始化
else {//i>0 || j > 0
temp1 = temp2;
temp2 = dp[j];
dp[j] = min(temp1 + (word1[i - 1] == word2[j - 1] ? 0 : 1),
min(dp[j] + 1, dp[j - 1] + 1));
}
}
}
return dp[word2.size()];
}
};
650. 只有两个键的键盘
题目描述
最初记事本上只有一个字符 ‘A’ 。你每次可以对这个记事本进行两种操作:
Copy All(复制全部):复制这个记事本中的所有字符(不允许仅复制部分字符)。
Paste(粘贴):粘贴 上一次 复制的字符。
给你一个数字 n ,你需要使用最少的操作次数,在记事本上输出 恰好 n 个 ‘A’ 。返回能够打印出 n 个 ‘A’ 的最少操作次数。
示例 1:
输入:3
输出:3
解释:
最初, 只有一个字符 ‘A’。
第 1 步, 使用 Copy All 操作。
第 2 步, 使用 Paste 操作来获得 ‘AA’。
第 3 步, 使用 Paste 操作来获得 ‘AAA’。
示例 2:
输入:n = 1
输出:0
提示:1 <= n <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/2-keys-keyboard
思路
不同于以往通过加减实现的动态规划,这里需要乘除法来计算位置,因为粘贴操作是倍数增加的。我们使用一个一维数组 dp,其中dp[j]表示延展到长度j的最少操作次数。对于每个位置 i,如果 i可以被 j整除,那么长度 j 就可以由长度 i经过操作得到,其操作次数等价于把一个长度为 1 的 A 延展到长度为 j/i(长度i和j同除以i)。因此我们可以得到递推公式 dp[j] = dp[i] + dp[j/i]。
具体来讲,我们在第一层循环遍历j时,将dp[j]初始化为j,主要是为了初始化j为质数的情况,因为若j为质数,则它只能通过复制一个A然后一次一次粘贴得到。之后我们在内循环遍历i,当j%i==0(可以整除)时,dp[j]=dp[i]+dp[j/i],**此时我们要退出内层循环,停止寻找可以被j整除的i,因为之后找到的符合条件的i不过是另一种组合,本质上j还是被分解成立质数之积。**例如dp[12]一开始找到i=2,dp[12]=dp[2]+dp[6](=dp[2]+dp[2]+dp[3])。之后找到i=3,dp[12] = dp[3]+dp[4](=dp[3]+dp[2]+dp[2]),本质上都是2,2,3这些质数之积。
题解
class Solution {
public:
int minSteps(int n) {
vector<int> dp(n + 1);
//int primelimit = sqrt(n);//找质因数的边界
for (int j = 2; j <= n; ++j) {
dp[j] = j;//目的是将j为质数的dp[j]初始化为j
for (int i = 2; i * i <= n; ++i) {//i<=primelimit
if (j % i == 0) {
dp[j] = dp[i] + dp[j / i];
break;//找到一个组合就可以停止寻找了
}
}
}
return dp[n];
}
};
//时间复杂度O(n根号n)
//空间复杂度O(n)
从上面的思路我们可以得知dp[j]的值本质上等于dp[i]之和,i为j的所有质因数,而dp[i](i为j的质因数)本身就等于i,所以dp[j]=所有j的质因数之和。
于是本题转化成立求n的所有质因数之和了。
class Solution {
public:
int minSteps(int n) {
int result = 0;
for (int i = 2; i * i <= n; ++i) {//遍历所有可能的质因数
while (n % i == 0) {//是n的质因数
n /= i;
result += i;
}
}
//遍历结束后,n若大于等于2,则说明还剩一个质因数等于n
if (n > 1) result += n;
return result;
}
};
//时间复杂度O(根号n)
//空间复杂度O(1)
10. 正则表达式匹配(不懂)
题目描述
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。
‘.’ 匹配任意单个字符
‘*’ 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。
示例 1:
输入:s = “aa”, p = “a”
输出:false
解释:“a” 无法匹配 “aa” 整个字符串。
示例 2:
输入:s = “aa”, p = “a*
”
输出:true
解释:因为 ‘*’ 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 ‘a’。因此,字符串 “aa” 可被视为 ‘a’ 重复了一次。
示例 3:
输入:s = “ab”, p = “."
输出:true
解释:".” 表示可匹配零个或多个(‘*’)任意字符(‘.’)。
提示:
1 <= s.length <= 20
1 <= p.length <= 30
s 只包含从 a-z 的小写字母。
p 只包含从 a-z 的小写字母,以及字符 . 和 *。
保证每次出现字符 * 时,前面都匹配到有效的字符
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/regular-expression-matching
思路
题目中的匹配是一个「逐步匹配」的过程:我们每次从字符串 p 中取出一个字符或者「字符 + 星号」的组合,并在 s 中进行匹配。对于 p 中一个字符而言,它只能在 s 中匹配一个字符,匹配的方法具有唯一性;而对于 p 中字符 + 星号的组合而言,它可以在 s 中匹配任意自然数个字符,并不具有唯一性。因此我们可以考虑使用动态规划,对匹配的方案进行枚举。
我们可以使用一个二维数组 dp,其中 dp[i][j]
表示以下标i -1的字符为结尾的字符串是否可以被以下标j-1的字符为结尾的正则表达式匹配。根据正则表达式的不同情况,即字符、星号,点号,我们可以分情况讨论来更 新 dp 数组。
具体来说,dp[0][0]
应初始化为true,所有dp值都是由它推导而来,其含义代表两个空字符串可以互相匹配。我们还需以dp[0][0]
为起点初始化dp数组第一行dp[0][j]
,因为下行的dp值需要用到第一行的dp值。当p[j-1]='*'
时,dp[0][j]=dp[0][j-2]
,因为p[j-1]='*'
时p[j-2]和p[j-1]构成一个组合,可以匹配零次或多次p[j-2]的字符,在匹配零次的情况下,有dp[i][j] = dp[i][j-2]
,也就是我们浪费了字符+'*'的组合,没有匹配s中任何字符。针对于dp数组第一行,即dp[0][j]=dp[0][j-2]
- 当
p[j-1] == '.'
时,这个’.'可以匹配任何字符,于是有dp[i][j] = dp[i-1][j-1]
- 当
p[j-1] !='*'
时,即p[j-1]为一个字符时,dp[i][j]
的值取决于p[j-1]是否与s[i-1]匹配且dp[i-1][j-1]
是否为true。于是有dp[i][j] = p[j-1]==s[i-1] && dp[i-1][j-1]
- 当p[j-2] != s[i-1]且 p[j-2] != ’.’时,即
p[j-1]='*'
且p[j-2]!='.‘且p[j-2]不匹配s[i-1]时,即p[j-2]匹配零次,浪费了字符+’*'的组合,没有匹配s中任何字符,于是有dp[i][j] = dp[i-1][j-1]
- 剩下的情况便是
p[j-1]='*'
且{p[j-2]=s[i-1]或p[j-2]=‘.’)。当p[j-1]='*
'且p[j-1]=s[i-1]时,在字母加’*‘组合匹配的过程中,本质上只会有两种情况:匹配s末尾的一个字符,将该字符扔掉,该组合还可以继续匹配,对应dp[i][j]=dp[i-1][j]
;不匹配字符,将该组合扔掉,不再进行匹配,对应dp[i][j]=dp[i][j-2]
。当p[j-1]='*
‘且p[j-2]=’.'时,有dp[i][j]=dp[i][j-1]
,即不算这个星号。
题解
class Solution {
public:
bool isMatch(string s, string p) {
int m = s.size(), n = p.size();
vector<vector<bool>> dp(m + 1, vector<bool>(n + 1, false));
dp[0][0] = true;
for (int i = 1; i < n + 1; ++i) {
if (p[i-1] == '*') {
dp[0][i] = dp[0][i-2];
}
}
for (int i = 1; i < m + 1; ++i) {
for (int j = 1; j < n + 1; ++j) {
if (p[j-1] == '.') {
dp[i][j] = dp[i-1][j-1];
}
else if (p[j-1] != '*') {
dp[i][j] = dp[i-1][j-1] && p[j-1] == s[i-1];
}
else if (p[j-2] != s[i-1] && p[j-2] != '.') {
dp[i][j] = dp[i][j-2];
}
else {
dp[i][j] = dp[i][j-1] || dp[i-1][j] || dp[i][j-2];
}
}
}
return dp[m][n];
}
};
附上别人的讲解辅助理解
https://leetcode.cn/problems/regular-expression-matching/solution/dong-tai-gui-hua-zen-yao-cong-0kai-shi-si-kao-da-b/
7.8 股票交易问题
股票交易类问题通常可以用动态规划来解决。对于稍微复杂一些的股票交易类问题,比如需 要冷却时间或者交易费用,则可以用通过动态规划实现的状态机来解决。
121. 买卖股票的最佳时机
题目描述
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
示例 2:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
提示:
1 <= prices.length <= 105
0 <= prices[i] <= 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock
思路
本题很容易想到贪心解法:因为股票只买卖一次,为了让差值最大,我们需要找到左边最小值和右边最大值。左边最小值通过low=min(low, prices[i])来保持,右边最大值通过result=max(result, prices[i]-low)间接来保持。
注意,我们不能直接统计最大值,因为最大值有可能在low的左侧,这并不能算是最大利润,而是应该用prices[i]-low来统计最大利润,low代表prices[i]之前的最小值。
题解
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
int low = INT_MAX;
for (int i = 0; i < prices.size(); i++) {
low = min(low, prices[i]);
result = max(result, prices[i] - low);
}
return result;
}
};
思路
动态规划解法的一般操作是,定义一个二维dp数组,dp[i][j]
代表第i[0,prices.size()]天状态j下所得的最大现金数。对应本题,只有两种状态,0:持有股票时;1:不持有股票时。
若第i天持有股票即dp[i][0]
,则有两种情况:1.第i-1天就持有股票,则dp[i][0]=dp[i-1][0]
;2.第i天才持有(第i天买入),则dp[i][0] = - prices[i]
。很明显,二者应该取最大值即dp[i][0]=max(dp[i-1][0],- prices[i])
若第i天不持有股票即dp[i][1]
,也有两种情况:1.第i-1天就不持有股票,则dp[i][1]=dp[i-1][1]
;2.第i天才不持有(第i天卖出),则dp[i][1]=dp[i-1][0]+prices[i]
。很明显,二者也应该取最大值即dp[i][1] =max(dp[i-1][1],dp[i-1][0]+prices[i])
由状态转移公式可以看出,第i行dp值都是由第i-1行推导而来,所以应该初始化第0行即dp[0][0]
和dp[0][1]
,第0天持有股票所得最大现金为-prices[0],第0天不持有股票所得最大现金为0。
上面我们提到,第i行dp值都是由第i-1行推导而来,所以我们可以使用滚动数组,递推公式为dp[0]=max(dp[0],-prices[i]);dp[1]=max(dp[1],dp[0]+prices[i])
题解
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<int> dp(2, 0);
dp[0] = -prices[0], dp[1] = 0;
for (int i = 0; i < prices.size(); ++i) {
for (int j = 0; j < 2; ++j) {
dp[0] = max(dp[0], -prices[i]);
dp[1] = max(dp[1], dp[0] + prices[i]);
}
}
return dp[1];//所持最大现金时一定是不持有股票时
}
};
188. 买卖股票的最佳时机Ⅳ
题目描述
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入:k = 2, prices = [2,4,1]
输出:2
解释:在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。
示例 2:
输入:k = 2, prices = [3,2,6,5,0,3]
输出:7
解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
提示:
0 <= k <= 100
0 <= prices.length <= 1000
0 <= prices[i] <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iv
思路
买卖股票的最佳时机Ⅲ中要求最多可以买卖两次股票,也就是说可以买卖一次,剩下不操作,所以要有不操作的状态。
本题要求最多可以买卖k次,也要有不操作的状态,一共有1+2k个状态。j=0代表不操作状态,j=2x-1代表第x次持有股票状态,j=2x代表第x次不持有状态。
状态转移公式可以模仿买卖股票的最佳时机Ⅲ,与买卖股票的最佳时机不同的是计算持有股票状态时的第i天才买入情况,不再是-prices[i],而是dp[i-1][j]-prices[i]
,因为那道题买入一定是第一次买入,买入之前现金为0,而这道题第i天买入股票时,可能会持有现金,所持现金是第i-1天上次不持有股票时的现金,即dp[i-1][j]
:
dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]);//j+1为奇数买入
dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]);//j+2为偶数卖出
由状态转移公式可以看出,第i行dp值都是由第i-1行推导而来,所以应该初始化第0行,持有状态时应该初始化为prices[i],不持有状态时应该初始化为0。最后我们应该返回最后一次不持有股票状态所得最大现金数,这一定是最大的。
另外这道题比较狗,还有prices.size()==0的样例,输出0即可。
题解
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
if (prices.size() == 0) return 0;
vector<int> dp(2 * k + 1, 0);
for (int j = 0; j < 2 * k; ++j) {
dp[j + 1] = -prices[0];
//dp[0][j + 2] = 0;可以省略
}
for (int i = 0; i < prices.size(); ++i) {
for (int j = 0; j < 2 * k - 1; j += 2) {
dp[j + 1] = max(dp[j + 1], dp[j] - prices[i]);
dp[j + 2] = max(dp[j + 2], dp[j + 1] + prices[i]);
}
}
return dp[2 * k];
}
};
309. 最佳买卖股票时机含冷冻期
题目描述
给定一个整数数组prices,其中第 prices[i] 表示第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: prices = [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
示例 2:
输入: prices = [1]
输出: 0
提示:
1 <= prices.length <= 5000
0 <= prices[i] <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-cooldown
思路
这道题是买卖股票最佳时机Ⅱ的引申,那道题不限制买卖次数,这道题也不限制次数,但有冷冻期。在那道题中,有两个状态,持有股票和不持有股票。而这道题因为由于冷冻期的存在,不持有股票状态被分为了三个子状态:
- 状态0:买入股票状态(今天买或者之前就已经买入)
- 状态1:两天前就卖出了股票(度过了冷冻期,今天没有操作,保持卖出股票的状态)
- 状态2:今天卖出的股票
- 状态3:今天为冷冻期,但明天就不是
计算dp[i][0]
有两种方向:
- 前一天就是持有股票状态,所以今天不用操作即
dp[i][0]=dp[i-1][0]
; - 今天才买入股票,有两种情况:1.前一天是冷冻期,冷冻期过后立马买,即
dp[i][0]=dp[i-1][3]-prices[i];
2.前一天不是冷冻期,是保持卖出股票状态,今天买入,即dp[i][0]=dp[i-1][1]-prices[i]
;这个方向的两种情况需要取二者最大值即max(dp[i - 1][3] - prices[i], dp[i - 1][1] - prices[i]);
- 这两种方向也要取最大值,综上所述:
dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3] - prices[i], dp[i - 1][1] - prices[i]);
计算dp[i][1]
有两种方向:
- 前一天是冷冻期即
dp[i][1]=dp[i-1][3]
; - 前一天就是状态1(两天前卖出股票,已过冷冻期)即
dp[i][1]=dp[i-1][1]
; - 这两种方向要取最大值,
dp[i][1] = max(dp[i - 1][3], dp[i - 1][1]);
计算dp[i][2]
只有一种方向:
- 只能是昨天已经是买入股票的状态1,今天卖出即
dp[i][2] = dp[i - 1][0] + prices[i];
计算dp[i][3]
也只有一种方向:
- 昨天卖出状态2即
dp[i][3] = dp[i - 1][2];
由于所有dp值都是由第一行计算而来,所以我们应该初始化第一行即。dp[0][0]=-prices[0]
,第一天持有股票状态。dp[0][1]= 0
,第一条不持有股票且非冷冻期状态。dp[0][2]=0
,第一天卖出股票状态,最少收益就是0。dp[0][3]=0
,不持有股票且冷冻期状态,0。
另外我们知道,最后一次不持有股票的状态一定是所持现金最多的,但是本题我们将不持有股票状态划分成了三个子状态,所以最大值应该在这三个dp值里取最大值。
本题也可以使用滚动数组,但是需要注意,这道题不能直接用滚动数组,因为dp[i][0]
的改变会影响到dp[i][2]
也就是说dp[0]在本轮计算完成之和,在dp[2]的计算中还会用到,它需要的是上一轮的dp[0],而不是本轮已经计算好的dp[0],dp[i][2]
的改变会影响到dp[i][3]
…我们可以使用二维dp数组,保存前一天的状态,也可以使用局部变量来保存这些值。
题解
class Solution {
public:
int maxProfit(vector<int>& prices) {
int size = prices.size();
if (size == 0) return 0;
vector<int> dp(4, 0);
dp[0] = -prices[0];
for (int i = 1; i < size; i++) {
int dp0 = dp[0], dp1 = dp[1], dp2 = dp[2], dp3 = dp[3];
dp[0] = max(dp0, max(dp3, dp1) - prices[i]);
dp[1] = max(dp1, dp3);
dp[2] = dp0 + prices[i];
dp[3] = dp2;
}
return max(dp[3], max(dp[2], dp[1]));
}
};
7.9 练习
213. 打家劫舍Ⅱ
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 3:
输入:nums = [1,2,3]
输出:3
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/house-robber-ii
思路
这道题和打家劫舍不同的就是要考虑首尾房间,不能同时偷,所以有三种情况:1.不考虑首尾房间;2.只考虑首房间;3.只考虑尾房间;注意我这里是用考虑这个词,考虑意味着在我们的计算中会计算到这种情况,但最终的结果并不一定是这种情况。 所以我们的情况二、三就都包含了情况一。分析到这,本题就比较简单了,我们把上一题的代码逻辑抽离出来,使其接受一个数组nums和一对起始位置,并返回在这对起始位置上按规则进行盗窃,所能得到的最大金额。类似打家劫舍,我们也可以只使用大小为2的dp数组,整体代码如下:
题解
class Solution {
private:
int robRange(vector<int>& nums, int start, int end) {
if (start == end) return nums[start];//只有一个元素
vector<int> dp(nums.size(), 0);
dp[start] = nums[start];
dp[start + 1] = max(nums[start], nums[start + 1]);
for (int i = start + 2; i <= end; ++i) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[end];
}
public:
int rob(vector<int>& nums) {
//if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
int result1 = robRange(nums, 0, nums.size() - 2);//去尾
int result2 = robRange(nums, 1, nums.size() - 1);//去头
return max(result1, result2);
}
};
class Solution {
private:
int robRange(vector<int>& nums, int start, int end) {
if (start == end) return nums[start];//只有一个房屋
vector<int> dp(2, 0);
dp[0] = nums[start];
dp[1] = max(nums[start], nums[start + 1]);
for (int i = start + 2; i <= end; ++i) {
int temp = dp[0];
dp[0] = dp[1];
dp[1] = max(temp + nums[i], dp[1]);
//int temp = dp[1];
//dp[1] = max(dp[0] + nums[i], temp);
//dp[0] = temp;
}
return dp[1];
}
public:
int rob(vector<int>& nums) {
if (nums.size() == 1) return nums[0];
int result1 = robRange(nums, 1, nums.size() - 1);//不考虑第一个房屋
int result2 = robRange(nums, 0, nums.size() - 2);//不考虑最后一个房屋
return max(result1, result2);
}
};
53. 最大子数组和
题目描述
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
示例 2:
输入:nums = [1]
输出:1
示例 3:
输入:nums = [5,4,-1,7,8]
输出:23
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
进阶:如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的 分治法 求解。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/maximum-subarray
思路
我们在代码随想录Pro中已经讲解过此题的贪心解法:局部最优:当前连续和为负数时立刻放弃计算,从下一个元素重新计算连续和。全局最优:选取了最大的子序和。大概的思路是:遍历nums,从头开始用count累积,如果count加上nums[i]变为负数,则应该从nums[i+1]重新计算count,count恢复0。这相当于在暴力解法中不断调整最大子序列和区间的起始位置。
可以看出,从i=1开始,每遍历一个nums[i],都有两种选择:1.将它加入到连续子数组中(当前连续和为正数);2.将它作为连续子数组的首个元素(当前连续和为负数)。于是我们定义dp[i]表示nums[0,i]中以下标i元素为结尾的子数组的最大子数组和,自然而然有dp[i] = max(dp[i-1] + nums[i], nums[i]);由于dp数组推导基础是dp[0],所以dp[0]应该初始化为nums[0]。
另外我们还需要一个result来存储最大子数组和,不是dp[i]存储的就是最大子数和吗?并不是,dp[nums.size()-1]表示以下标nums.size()-1元素为结尾的子数组的最大子数组和,而题目中求得是整个数组的最大子数组和,不一定以最后一个元素为结尾。
题解
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.size() == 0) return 0;
vector<int> dp(nums.size(), 0);
dp[0] = nums[0];
int result = dp[0];//不能初始化为0,因为如果遍历结束后,最大子序和不超过第一个元素值,则应该返回第一个元素值
//例如[1,-1,-1,-1]
for (int i = 1; i < nums.size(); ++i) {
dp[i] = max(dp[i - 1] + nums[i], nums[i]);
if (result < dp[i]) result = dp[i];
}
return result;
}
};
空间复杂度优化:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.size() == 0) return 0;
vector<int> dp(1, 0);
dp[0] = nums[0];
int result = dp[0];//不能初始化为0,因为如果遍历结束后,最大子序和不超过第一个元素值,则应该返回第一个元素值
//例如[1,-1,-1,-1]
for (int i = 1; i < nums.size(); ++i) {
dp[0] = max(dp[0] + nums[i], nums[i]);
if (result < dp[0]) result = dp[0];
}
return result;
}
};
此题还有分治解法,时间复杂度也是O(n),但空间复杂度为O(logn)。那么这种方法存在的意义是什么呢?对于这道题而言,确实是如此的。但是仔细观察「方法二」,它不仅可以解决区间 [0, n-1],还可以用于解决任意的子区间 [l,r] 的问题。如果我们把 [0, n-1]分治下去出现的所有子区间的信息都用堆式存储的方式记忆化下来,即建成一颗真正的树之后,我们就可以在 O(logn) 的时间内求到任意区间内的答案,我们甚至可以修改序列中的值,做一些简单的维护,之后仍然可以在O(logn) 的时间内求到任意区间内的答案,对于大规模查询的情况下,这种方法的优势便体现了出来。这棵树就是上文提及的一种神奇的数据结构——线段树。
class Solution {
public:
struct Status {
int lSum, rSum, mSum, iSum;
};
Status pushUp(Status l, Status r) {
int iSum = l.iSum + r.iSum;
int lSum = max(l.lSum, l.iSum + r.lSum);
int rSum = max(r.rSum, r.iSum + l.rSum);
int mSum = max(max(l.mSum, r.mSum), l.rSum + r.lSum);
return (Status) {lSum, rSum, mSum, iSum};
};
Status get(vector<int> &a, int l, int r) {
if (l == r) {
return (Status) {a[l], a[l], a[l], a[l]};
}
int m = (l + r) >> 1;
Status lSub = get(a, l, m);
Status rSub = get(a, m + 1, r);
return pushUp(lSub, rSub);
}
int maxSubArray(vector<int>& nums) {
return get(nums, 0, nums.size() - 1).mSum;
}
};
343. 整数拆分
题目描述
给定一个正整数 n ,将其拆分为 k 个 正整数 的和( k >= 2 ),并使这些整数的乘积最大化。返回 你可以获得的最大乘积 。
示例 1:
输入: n = 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
输入: n = 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
提示:
2 <= n <= 58
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/integer-break
思路
我们在7.1节算法解释中就提到,动态规划擅长解决存在子问题的问题,本题很明显的存在子问题。整数乘积的每个乘数可以看作是由几个数相乘得来,每个数又可以看作是几个数的乘积做得。。。
于是,我们定义dp[i],表示分解正整数i可以获得的最大乘积。dp[i]可由两个方向推导而来:1.j*(i-j)
,仅仅将正整数i分解为两个正整数;2.dp[i-j]*j
,将正整数i分解为一系列正整数和j,一系列正整数的乘积为dp[i-j];因为存在不同的j,所以dp[i]要取最大值,于是有状态转移公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
另外,因为正整数i的取值从2开始,而递推的起点就是dp[2],所以我们应该将dp[2]初始化为1(1*1=1)。
题解
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(n + 1);
dp[2] = 1;
for (int i = 3; i <= n; ++i) {
for (int j = 1; j < i; ++j) {//将i拆解为i-j和j,j从1~i-2,为什么不到i-1呢?j到i-1也可以提交也可以通过
//因为j=i-1时,要求dp[1]*i-1,dp[1]是没有定义的(0)0*任何数都是0
dp[i] = max(dp[i],max(dp[i - j] * j, (i - j) * j));
}
}
return dp[n];
}
};
本题也可以使用贪心算法,前提是了解相关数学知识:1.任何数都可拆解为若干个3和一个2或若干个3和一个1。2.根据乘积不等式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jghS4nmM-1657023302348)(pictureforMD/image-20220704132238088.png)]
当且仅当a1=a2=a3…=an时等号成立,乘积取到最大值。根据1可知如果我们拆出来k个3和一个1,则应该取k-1个3和一个4。这样能保证a1…an尽量相等,取最大值。
class Solution {
public:
int integerBreak(int n) {
if (n == 2) return 1;
if (n == 3) return 2;
if (n == 4) return 4;
int result = 1;
while (n > 4) {
result *= 3;
n -= 3;
}
//此时n<=4,n=4就直接乘,不再拆成3和1;另外n不会等于1,因为n要等于1,得从4再减3
return result *= n;//
}
};
583. 两个字符串的删除操作
题目描述
给定两个单词 word1 和 word2 ,返回使得 word1 和 word2 相同所需的最小步数。
每步 可以删除任意一个字符串中的一个字符。
示例 1:
输入: word1 = “sea”, word2 = “eat”
输出: 2
解释: 第一步将 “sea” 变为 “ea” ,第二步将 "eat "变为 “ea”
示例 2:
输入:word1 = “leetcode”, word2 = “etco”
输出:4
提示:
1 <= word1.length, word2.length <= 500
word1 和 word2 只包含小写英文字母
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/delete-operation-for-two-strings
思路
在编辑距离那题中我们定义dp[i][j]
为以下标i-1为结尾的字符串word1和以j-1为结尾的字符串word2,要想让这两个字符串达到相等,所需删除元素的最小次数。
和编辑距离一样,这种题一般都要考虑两种情况:1.word1[i-1]=word2[j-1];2word1[i-1]!=word2[j-1]
1.当word1[i-1]=word2[j-1]时,此时只有一种情况。dp[i][j]=dp[i-1][j-1]
此时不用删除word1[i-1]或word2[j-1]因为这两个元素相同,不能删除,否则我们求的就不是最小步骤了,(最后相同的字符串一定是尽可能长)
2.当word1[i-1]!=word2[j-1],此时由三种子情况:
- 删除word1[i-1],此时的
dp[i][j]
就应该等于dp[i-1][j] + 1
(+1是因为删除word1[i-1]) - 删除word2[j-1],此时的
dp[i][j]
就应该等于dp[i][j-1]+1
(+1是因为删除word2[j-1]) - 两个元素都删除,此时的
dp[i][j]
就等于dp[i-1][j-1]+2
(+2是因为删除了word1[i-1]、word2[j-1]) - 当然,这三种情况要取最小值,因为我们求的是最小步骤。所以
dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1})
从递推公式我们可以看出,dp[i][j]
由其左方向,上方向和左上方向推导而来,所以我们应该初始化第一行和第一列。dp[0][j]
表示word2[0,j-1]最少需要删除几次才能成为空串,很明显是j次。同理dp[i][0]=i
题解
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
for (int i = 1; i <= word1.size(); ++i) dp[i][0] = i;
for (int j = 1; j <= word2.size(); ++j) dp[0][j] = j;
for (int i = 1; i <= word1.size(); ++i) {
for (int j = 1; j <= word2.size(); ++j) {
if (word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 2});
}
}
return dp[word1.size()][word2.size()];
}
};
我们可以试试使用滚动数组,由于dp[i][j]
由三个方向推导而来,所以我们还是使用temp1和temp2来实现。
class Solution {
public:
int minDistance(string word1, string word2) {
vector<int> dp(word2.size() + 1, 0);
for (int j = 0; j <= word2.size(); ++j) dp[j] = j;
for (int i = 1; i <= word1.size(); ++i) {
int temp1, temp2 = i - 1;//上一轮dp[0]
dp[0] = i;//初始化本轮dp[0]
for (int j = 1; j <= word2.size(); ++j) {
temp1 = temp2;//dp[i-1][j-1]
temp2 = dp[j];//记录本轮未处理的dp[j](dp[i-1][j]),供下一轮(++j)使用,在下一轮中即是dp[i-1][j-1]
if (word1[i - 1] == word2[j - 1]) dp[j] = temp1;
else dp[j] = min({dp[j] + 1, dp[j - 1] + 1, temp2 + 2});
}
}
return dp[word2.size()];
}
};
646. 最长数对链
题目描述
给出 n 个数对。 在每一个数对中,第一个数字总是比第二个数字小。
现在,我们定义一种跟随关系,当且仅当 b < c 时,数对(c, d) 才可以跟在 (a, b) 后面。我们用这种形式来构造一个数对链。
给定一个数对集合,找出能够形成的最长数对链的长度。你不需要用到所有的数对,你可以以任何顺序选择其中的一些数对来构造。
示例:
输入:[[1,2], [2,3], [3,4]]
输出:2
解释:最长的数对链是 [1,2] -> [3,4]
提示:
给出数对的个数在 [1, 1000] 范围内。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/maximum-length-of-pair-chain
思路
题目中说到,我们可以以任意顺序选择其中的数对来构造链,所以为使链尽可能长,我们对每个数对的第一个值进行排序。
定义dp[i]表示以下标i为结尾的数对链的最大长度。dp[i]从dp[j](j<i)推导而来,当pairs[i][0]>pairs[j]][1]
时,说明下标i数对可以连在下标j数对之后,链长度+1,dp[i]=dp[j]+1;但j是从0遍历到i-1的,所以我们要取最大值,求得以下标i为结尾的数对链的最大长度。状态转移公式为:dp[i]=max(dp[i],dp[j]+1);从状态转移公式我们可以看出,所有dp值的推导都是基于dp[0]的,所以我们将dp[0]初始化为1。
因为我们定义的dp[i]是以下标i数对为结尾的数对链的最大长度,以最后一个数对为结尾的数对链不一定是整个pairs中存在的最长数对链,所以我们还需要result来保持最大长度。
题解
class Solution {
public:
int findLongestChain(vector<vector<int>>& pairs) {
sort(pairs.begin(), pairs.end(), [](vector<int>& a, vector<int>& b) {
if (a[0] == b[0]) return a[1] < b[1];//return (a[0]==b[0]&&a[1]<b[1])||(a[0]<b[0])
else return a[0] < b[0];
});
vector<int> dp(pairs.size(), 0);
dp[0] = 1;
int result = 1;
for (int i = 1; i < pairs.size(); ++i) {
dp[i] = 1;//每遍历一个i都初始化为1,代表以它为结尾的数对链长度初始为1
for (int j = 0; j < i; ++j) {
if (pairs[i][0] > pairs[j][1]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
result = max(result, dp[i]);
}
return result;
}
};
我们还可以考虑一种贪心算法:为保证数对链尽可能长,则应该让数对增长尽可能缓慢,为使数对增长尽可能缓慢,则应该在所有可作为下一个数对的集合中选择第二个数最小的数对添加到数对链,因为如果再增加数对,则要比上一个数对的第二个数大。
根据思路中的描述,按照数对第二个数的升序序列遍历所有数对,如果当前数对可以加入链,则加入。
class Solution {
public:
int findLongestChain(vector<vector<int>>& pairs) {
sort(pairs.begin(), pairs.end(), [](vector<int>& a, vector<int>& b) {
return a[1] < b[1];
});
int result = 1;
int compare = pairs[0][1];
for (int i = 1; i < pairs.size(); ++i) {
if (pairs[i][0] > compare) {
++result;
compare = pairs[i][1];
}
}
return result;
}
};
376. 摆动序列
题目描述
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
例如, [1, 7, 4, 9, 2, 5] 是一个 摆动序列 ,因为差值 (6, -3, 5, -7, 3) 是正负交替出现的。
相反,[1, 4, 7, 2, 5] 和 [1, 7, 4, 5, 5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。
给你一个整数数组 nums ,返回 nums 中作为 摆动序列 的 最长子序列的长度 。
示例 1:
输入:nums = [1,7,4,9,2,5]
输出:6
解释:整个序列均为摆动序列,各元素之间的差值为 (6, -3, 5, -7, 3) 。
示例 2:
输入:nums = [1,17,5,10,13,15,10,5,16,8]
输出:7
解释:这个序列包含几个长度为 7 摆动序列。
其中一个是 [1, 17, 10, 13, 10, 16, 8] ,各元素之间的差值为 (16, -7, 3, -3, 6, -8) 。
示例 3:
输入:nums = [1,2,3,4,5,6,7,8,9]
输出:2
提示:
1 <= nums.length <= 1000
0 <= nums[i] <= 1000
进阶:你能否用 O(n) 时间复杂度完成此题?
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/wiggle-subsequence
思路
之前我们在代表随想录中讲过贪心解法,在这里我们只讲解动态规划方法。首先规定一些某个序列被称为「上升摆动序列」,当且仅当该序列是摆动序列,且最后一个元素呈上升趋势。如序列 [1,3,2,4] 即为「上升摆动序列」。某个序列被称为「下降摆动序列」,当且仅当该序列是摆动序列,且最后一个元素呈下降趋势。如序列[4,2,3,1] 即为「下降摆动序列」。
每当我们选择一个元素作为摆动序列的一部分时,这个元素要么是上升的,要么是下降的,这取决于前一个元素的大小。那么列出状态表达式为:
- up[i] 表示以下标[0,i]中的某一个为结尾的最长的「上升摆动序列」的长度。
- down[i] 表示以下标[0,i]中的某一个为结尾的最长的「下降摆动序列」的长度
以up[i]为例,说明状态转移规则:
- 当 nums[i] < nums[i-1]时,
up[i]=up[i-1]
。我们无法选出更长的「上升摆动序列」的方案,因为要想让上升摆动序列变长,就得加入nums[i],而nums[i] <= nums[i-1],任何以nums[i]为结尾的上升摆动序列都可以替换为以nums[i-1]为结尾的摆动序列(替换之后等于nums[i]并没有加入上升摆动序列,另外nums[i-1]更大就更保险) - 当nums[i] > nums[i-1]时,
up[i] = max(up[i-1],down[i-1]+1)
。首先我们考虑从up[i-1]转移:因为要想让上升摆动序列变长,就得加入nums[i]。假设上升序列以nums[i-1]结尾,任何以nums[i-1]为结尾的上升摆动序列都可以替换为以nums[i]为结尾的上升摆动序列,不会变长。假设上升序列不以nums[i-1]为结尾,若nums[i]比这个结尾大,上升摆动序列不会更长(替换结尾),若比这个结尾小,加入nums[i]就构不成上升摆动序列。之后我们考虑从down[i-1]+1转移,如果down[i-1]的最长摆动序列的结尾是nums[j],若nums[j]>=nums[i-1],则可以用nums[i-1]替换nums[j],之后加上nums[i],上升摆动序列长度+1;若nums[j]<nums[i-1]<nums[i],则可以在nums[j]后直接加入nums[i],上升摆动序列+1。 - 当nums[i] = nums[i-1]时,
up[i] = up[i-1]
,上升摆动序列不会因为加入一个nums[i-1]相等的元素而发生变化。
down[i]的状态转移规则与up[i]对称。最终的答案即为up[n-1]和down[n-1]中较大值。
题解
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int n = nums.size();
if (n < 2) return n;
vector<int> up(n,0), down(n, 0);
up[0] = down[0] = 1;
for (int i = 1; i < n; i++) {
if (nums[i] < nums[i - 1]) {
up[i] = up[i - 1];
down[i] = max(up[i - 1] + 1, down[i - 1]);
} else if (nums[i] > nums[i - 1]) {
up[i] = max(up[i - 1], down[i - 1] + 1);
down[i] = down[i - 1];
} else {
up[i] = up[i - 1];
down[i] = down[i - 1];
}
}
return max(up[n - 1], down[n - 1]);
}
};
从转移公式我们可以看出,都只与前一个状态为基础,所以我们可以只维护两个变量。
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int n = nums.size();
if (n < 2) {
return n;
}
int up = 1, down = 1;
for (int i = 1; i < n; i++) {
if (nums[i] < nums[i - 1]) {
down = max(up + 1, down);
} else if (nums[i] < nums[i - 1]) {
up = max(up, down + 1);
}
}
return max(up, down);
}
};
494. 目标和
题目描述
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
示例 1:
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3
示例 2:
输入:nums = [1], target = 1
输出:1
提示:
1 <= nums.length <= 20
0 <= nums[i] <= 1000
0 <= sum(nums[i]) <= 1000
-1000 <= target <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/target-sum
思路
本题的回溯解法很容易想到,但是时间很慢,在此不再讲解,详细情况代码随想录。我们专注讲解动态规划解法。
本题实际上可以看作0-1背包问题,因为每个数我们最多只可使用一次,与之前的0-1背包不同的是,本题并不是求装满容量为j的背包其最大价值为多少,而是装满容量j的背包有几种方法。其实这就是一个组合问题了,选取若干元素凑够目标和,而这些元素的顺序并不做要求。
假设加法的总和为x,那么减法对应的总和就是sum - x。所以我们要求的是 x - (sum - x) = target,x = (target + sum) / 2,这个x就是我们的背包容量,我们要求的就是选取若干个物品装满容量为x的背包有多少种方法。
我们定义一个dp数组,大小为bagSize+1,又因为有一个测试用例为nums= [100],target = -200。则begSize = (sum + target) / 2,我们在初始化dp数组时候,vector<int> dp(begSize + 1);
,则会出错,超过了vector的max_size。所以我们还要加一个判断,当begSize + 1 < 0时候,此时无解。
用二维dp数组来求解就是dp[i][j]
:使用下标为[0,i]的nums[i]能够装满容量为j的背包有多少种方法。一维dp[j]表示:填满j(包括j)容量的背包,有多少方法。dp[i][j]
可以由dp[i-1][j-nums[i]]
推导而来,在不选择nums[i]的情况下,装满j容量的背包有dp[i-1][j]
种方法;在选择nums[i]的情况下,方法数是则等于之前从下标[0,i-1]中选取元素凑够j-nums[i]大小的背包的方法数。于是dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i]]
,一维dp数组:dp[j]+=dp[j-nums[i]]
所有求组合类问题的公式,都是类似这种。这个公式在背包解决排列组合问题的时候还会⽤到!
从公式可以看出,二维dp数组应该初始化第一行,而一维dp数组应该初始化dp[0]为1,因为这是所有dp值的基础,若它为0,则所有都会为0。(装满容量为0的背包,有几种方法?一种,什么都不选)dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j -nums[i]]推导出来。
题解
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int num : nums) sum += num;
if (sum < target) return 0;
if ((sum + target) % 2 == 1) return 0;
int begSize = (sum + target) / 2;
if (begSize + 1 < 0) return 0;
vector<int> dp(begSize + 1);
dp[0] = 1;
for (int i = 0; i < nums.size(); i++) {
for (int j = begSize; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[begSize];
}
};
//时间复杂度O(n * m),n为正数个数,m为背包容量
//空间复杂度:O(m) m为背包容量
714. 买卖股票的最佳时机含手续费
题目描述
给定一个整数数组 prices,其中 prices[i]表示第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
示例 1:
输入:prices = [1, 3, 2, 8, 4, 9], fee = 2
输出:8
解释:能够达到的最大利润:
在此处买入 prices[0] = 1
在此处卖出 prices[3] = 8
在此处买入 prices[4] = 4
在此处卖出 prices[5] = 9
总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8
示例 2:
输入:prices = [1,3,7,5,10,3], fee = 3
输出:6
提示:
1 <= prices.length <= 5 * 104
1 <= prices[i] < 5 * 104
0 <= fee < 5 * 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-transaction-fee
思路
本题类似买卖股票的最佳时机Ⅱ,都是可以无限制次数的买卖股票,但不同的是本题需要每笔交易支付手续费。
本题同样分为两个状态:j=0,持有股票状态;j=1,不持有股票状态。所以我们定义dp[i][0]
为第i天持有股票所有最大现金数;dp[i][1]
为第i天不持有股票所有的最大现金数。
dp[i][0]
可以由两个方向推导而来:1.第i-1天就是持有股票状态,dp[i][0]=dp[i-1][0]
;2.第i天刚刚买入股票,dp[i][0]=dp[i-1][1]-prices[i]
;二者要取最大值,所以dp[i][0]=max(dp[i-1][0],dp[i-1][1]-prices[i])
dp[i][1]
可以由两个方向推导而来:1.第i-1天就是不持有股票状态,dp[i][1]=dp[i-1][1]
;2.第i天刚刚卖出股票,dp[i][1] = dp[i][0] + prices[i]-free
;二者要取最大值,所以dp[i][1] = max(dp[i-1][1],dp[i-1][0] + prices[i]-free)
对应的一维dp数组状态转移公式为:
dp[0] = max(dp[0],dp[1]-prices[i])
dp[1] = max(dp[1],dp[0] + prices[i] - free)
从状态转移公式可以看出,二维dp数组需要初始化第一行,dp[0][0] = -prices[0];d[0][1] = 0
,一维dp数组只需初始化dp[0]和dp[1],dp[0]=-prices[0],dp[1]=0;
题解
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
if (n == 0) return 0;
vector<int> dp(2, 0);
dp[0] = -prices[0];
for (int i = 1; i < n; i++) {
dp[0] = max(dp[0], dp[1] - prices[i]);
dp[1] = max(dp[1], dp[0] + prices[i] - fee);
}
return dp[1];//卖出股票一定比持有股票现金多
}
};
第八章 化繁为简的分治法
8.1 算法解释
顾名思义,分治问题由“分”(divide)和“治”(conquer)两部分组成,通过把原问题分为子 问题,再将子问题进行处理合并,从而实现对原问题的求解。我们在排序章节展示的归并排序就 是典型的分治问题,其中“分”即为把大数组平均分成两个小数组,通过递归实现,最终我们会 得到多个长度为 1 的子数组;“治”即为把已经排好序的两个小数组合成为一个排好序的大数组, 从长度为 1 的子数组开始,最终合成一个大数组。
我们也使用数学表达式来表示这个过程。定义 T(n) 表示处理一个长度为 n 的数组的时间复 杂度,则归并排序的时间复杂度递推公式为 T(n) = 2T(n/2) + O(n)。其中 2T(n/2) 表示我们分成了 两个长度减半的子问题,O(n) 则为合并两个长度为 n/2 数组的时间复杂度。
那么怎么利用这个递推公式得到最终的时间复杂度呢?这里我们可以利用著名的主定理 (Master theorem)求解:
考虑 T(n) = aT(n/b) + f (n),定义 k = logba
-
- 如果 f (n) = O(np ) 且 p < k,那么 T(n) = O(nK )
-
- 如果存在 c ≥ 0 满足 f (n) = O(nk logcn),那么 T(n) = O(nklogc+1n)
-
- 如果 f (n) = O(np ) 且 p > k,那么 T(n) = O( f (n))
通过主定理我们可以知道,归并排序属于第二种情况(c=0,k=1),且时间复杂度为 O(n log n)。其他的分治问题也可以通过主定理求得时间复杂度。
另外,自上而下的分治可以和 memoization 结合,避免重复遍历相同的子问题。如果方便推导,也可以换用自下而上的动态规划方法求解。
8.2 表达式问题
241. 为运算表达式设计优先级
题目描述
给你一个由数字和运算符组成的字符串 expression ,按不同优先级组合数字和运算符,计算并返回所有可能组合的结果。你可以 按任意顺序 返回答案。
生成的测试用例满足其对应输出值符合 32 位整数范围,不同结果的数量不超过 104 。
示例 1:
输入:expression = “2-1-1”
输出:[0,2]
解释:
((2-1)-1) = 0
(2-(1-1)) = 2
示例 2:
输入:expression = “2 * 3-4 * 5”
输出:[-34,-14,-10,-10,10]
解释:
(2 * (3-(4 * 5))) = -34
((2 * 3)-(4 * 5)) = -14
((2 * (3-4)) * 5) = -10
(2 * ((3-4) * 5)) = -10
(((2 * 3)-4) * 5) = 10
提示:
1 <= expression.length <= 20
expression 由数字和算符 ‘+’、‘-’ 和 ‘*’ 组成。
输入表达式中的所有整数值在范围 [0, 99]
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/different-ways-to-add-parentheses
思路
本题的含义其实是给定一个只包含加减乘运算的数学表达式,求通过加括号可以得到多少种不同的结果。
利用分治思想,我们可以把加括号转化为,对于每个运算符号,先执行处理两侧的数学表达式,再处理此运算符号。注意边界情况,即字符串内无运算符号,只有数字。
具体来讲,我们先定义一个result,用来存储所有可能组合的结果。之后遍历字符串,对于每一个字符,如果它是运算符,则以它为分界,对左右两个子串进行递归(结果为左右子串所有可能的结果),结果分别存储在left和right中。之后我们使用两层for循环根据运算符对左右子串的结果进行组合,将结果加入result中。当遍历完字符串之后,所有可能组合的结果就都被存入result中了。若遍历完字符串之后result为空,此情况对应子串只有一个字符,应该直接将它加入result。
题解
class Solution {
public:
vector<int> diffWaysToCompute(string expression) {
vector<int> result;
for (int i = 0; i < expression.size(); ++i) {
int c = expression[i];
if (c == '+' || c == '-' || c == '*') {//c为运算符
vector<int> left = diffWaysToCompute(expression.substr(0, i));//运算符左边子串
vector<int> right = diffWaysToCompute(expression.substr(i + 1));//运算符右边子串
for (const int& l : left) {
for (const int& r : right) {//对left和right中的结果根据c进行组合
if (c == '+') result.push_back(l + r);
if (c == '-') result.push_back(l - r);
if (c == '*') result.push_back(l * r);
}
}
}
}
if (result.empty()) result.push_back(stoi(expression));//最终都会被分解为单个字符(数字)
return result;
}
};
我们发现,某些被 divide 的子字符串可能重复出现多次,因此我们可以用 memoization 来去重。或者与其我们从上到下用分治处理 +memoization,不如直接从下到上用动态规划处理。
具体来讲,我们定义两个数组:data和ops分别存放字符串中的数字和运算符(后面加一个+)。之后我们定义一个三维数组作为dp数组存储所有可能的解。dp[j][i]
里存放的数组表示以下标i为左边界、下标j为右边界的字符串所有可能的计算结果。dp[j][i]
应该初始化(初始化步骤并到遍历之中),当i=j时,初始化为下标为i或j的单个数字。在第二层循环内部,我们对[j,i)之间的所有k进行遍历,根据下标k所代表的运算符将dp[j][k]
(左子串)和dp[k+1][i]
(右子串)进行组合,将结果加入dp[j][i]
中,最后返回dp[0][n-1]
class Solution {
public:
vector<int> diffWaysToCompute(string input) {
vector<int> data;
vector<char> ops;
int num = 0;
char op = ' ';
istringstream ss(input + "+");//最后添加一个加号
while (ss >> num && ss >> op) {
data.push_back(num);
ops.push_back(op);
}
int n = data.size();
vector<vector<vector<int>>> dp(n, vector<vector<int>>(n, vector<int>()));
for (int i = 0; i < n; ++i) {
for (int j = i; j >= 0; --j) {
if (i == j) {
dp[j][i].push_back(data[i]);
}
else {
for (int k = j; k < i; k += 1) {
for (auto left : dp[j][k]) {
for (auto right : dp[k+1][i]) {
int val = 0;
if (ops[k] == '+') val = left + right;
else if (ops[k] == '-') val = left - right;
else if (ops[k] == '*') val = left * right;
dp[j][i].push_back(val);
}
}
}
}
}
}
return dp[0][n-1];
}
};
8.3 练习
932. 漂亮数组
题目描述
对于某些固定的 N,如果数组 A 是整数 1, 2, …, N 组成的排列,使得:
对于每个 i < j,都不存在 k 满足 i < k < j 使得 A[k] * 2 = A[i] + A[j]。
那么数组 A 是漂亮数组。
给定 N,返回任意漂亮数组 A(保证存在一个)。
示例 1:
输入:4
输出:[2,1,4,3]
示例 2:
输入:5
输出:[3,1,2,5,4]
提示:
1 <= N <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/beautiful-array
思路
题解
312. 戳气球
题目描述
有 n 个气球,编号为0 到 n - 1,每个气球上都标有一个数字,这些数字存在数组 nums 中。
现在要求你戳破所有的气球。戳破第 i 个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1] 枚硬币。 这里的 i - 1 和 i + 1 代表和 i 相邻的两个气球的序号。如果 i - 1或 i + 1 超出了数组的边界,那么就当它是一个数字为 1 的气球。
求所能获得硬币的最大数量。
示例 1:
输入:nums = [3,1,5,8]
输出:167
解释:
nums = [3,1,5,8] --> [3,5,8] --> [3,8] --> [8] --> []
coins = 315 + 358 + 138 + 181 = 167
示例 2:
输入:nums = [1,5]
输出:10
提示:
n == nums.length
1 <= n <= 300
0 <= nums[i] <= 100
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/burst-balloons
思路
题解
组:dp[j]+=dp[j-nums[i]]
所有求组合类问题的公式,都是类似这种。这个公式在背包解决排列组合问题的时候还会⽤到!
从公式可以看出,二维dp数组应该初始化第一行,而一维dp数组应该初始化dp[0]为1,因为这是所有dp值的基础,若它为0,则所有都会为0。(装满容量为0的背包,有几种方法?一种,什么都不选)dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j -nums[i]]推导出来。
题解
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int num : nums) sum += num;
if (sum < target) return 0;
if ((sum + target) % 2 == 1) return 0;
int begSize = (sum + target) / 2;
if (begSize + 1 < 0) return 0;
vector<int> dp(begSize + 1);
dp[0] = 1;
for (int i = 0; i < nums.size(); i++) {
for (int j = begSize; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[begSize];
}
};
//时间复杂度O(n * m),n为正数个数,m为背包容量
//空间复杂度:O(m) m为背包容量
714. 买卖股票的最佳时机含手续费
题目描述
给定一个整数数组 prices,其中 prices[i]表示第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
示例 1:
输入:prices = [1, 3, 2, 8, 4, 9], fee = 2
输出:8
解释:能够达到的最大利润:
在此处买入 prices[0] = 1
在此处卖出 prices[3] = 8
在此处买入 prices[4] = 4
在此处卖出 prices[5] = 9
总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8
示例 2:
输入:prices = [1,3,7,5,10,3], fee = 3
输出:6
提示:
1 <= prices.length <= 5 * 104
1 <= prices[i] < 5 * 104
0 <= fee < 5 * 104
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-transaction-fee
思路
本题类似买卖股票的最佳时机Ⅱ,都是可以无限制次数的买卖股票,但不同的是本题需要每笔交易支付手续费。
本题同样分为两个状态:j=0,持有股票状态;j=1,不持有股票状态。所以我们定义dp[i][0]
为第i天持有股票所有最大现金数;dp[i][1]
为第i天不持有股票所有的最大现金数。
dp[i][0]
可以由两个方向推导而来:1.第i-1天就是持有股票状态,dp[i][0]=dp[i-1][0]
;2.第i天刚刚买入股票,dp[i][0]=dp[i-1][1]-prices[i]
;二者要取最大值,所以dp[i][0]=max(dp[i-1][0],dp[i-1][1]-prices[i])
dp[i][1]
可以由两个方向推导而来:1.第i-1天就是不持有股票状态,dp[i][1]=dp[i-1][1]
;2.第i天刚刚卖出股票,dp[i][1] = dp[i][0] + prices[i]-free
;二者要取最大值,所以dp[i][1] = max(dp[i-1][1],dp[i-1][0] + prices[i]-free)
对应的一维dp数组状态转移公式为:
dp[0] = max(dp[0],dp[1]-prices[i])
dp[1] = max(dp[1],dp[0] + prices[i] - free)
从状态转移公式可以看出,二维dp数组需要初始化第一行,dp[0][0] = -prices[0];d[0][1] = 0
,一维dp数组只需初始化dp[0]和dp[1],dp[0]=-prices[0],dp[1]=0;
题解
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
if (n == 0) return 0;
vector<int> dp(2, 0);
dp[0] = -prices[0];
for (int i = 1; i < n; i++) {
dp[0] = max(dp[0], dp[1] - prices[i]);
dp[1] = max(dp[1], dp[0] + prices[i] - fee);
}
return dp[1];//卖出股票一定比持有股票现金多
}
};
第八章 化繁为简的分治法
8.1 算法解释
顾名思义,分治问题由“分”(divide)和“治”(conquer)两部分组成,通过把原问题分为子 问题,再将子问题进行处理合并,从而实现对原问题的求解。我们在排序章节展示的归并排序就 是典型的分治问题,其中“分”即为把大数组平均分成两个小数组,通过递归实现,最终我们会 得到多个长度为 1 的子数组;“治”即为把已经排好序的两个小数组合成为一个排好序的大数组, 从长度为 1 的子数组开始,最终合成一个大数组。
我们也使用数学表达式来表示这个过程。定义 T(n) 表示处理一个长度为 n 的数组的时间复 杂度,则归并排序的时间复杂度递推公式为 T(n) = 2T(n/2) + O(n)。其中 2T(n/2) 表示我们分成了 两个长度减半的子问题,O(n) 则为合并两个长度为 n/2 数组的时间复杂度。
那么怎么利用这个递推公式得到最终的时间复杂度呢?这里我们可以利用著名的主定理 (Master theorem)求解:
考虑 T(n) = aT(n/b) + f (n),定义 k = logba
-
- 如果 f (n) = O(np ) 且 p < k,那么 T(n) = O(nK )
-
- 如果存在 c ≥ 0 满足 f (n) = O(nk logcn),那么 T(n) = O(nklogc+1n)
-
- 如果 f (n) = O(np ) 且 p > k,那么 T(n) = O( f (n))
通过主定理我们可以知道,归并排序属于第二种情况(c=0,k=1),且时间复杂度为 O(n log n)。其他的分治问题也可以通过主定理求得时间复杂度。
另外,自上而下的分治可以和 memoization 结合,避免重复遍历相同的子问题。如果方便推导,也可以换用自下而上的动态规划方法求解。
8.2 表达式问题
241. 为运算表达式设计优先级
题目描述
给你一个由数字和运算符组成的字符串 expression ,按不同优先级组合数字和运算符,计算并返回所有可能组合的结果。你可以 按任意顺序 返回答案。
生成的测试用例满足其对应输出值符合 32 位整数范围,不同结果的数量不超过 104 。
示例 1:
输入:expression = “2-1-1”
输出:[0,2]
解释:
((2-1)-1) = 0
(2-(1-1)) = 2
示例 2:
输入:expression = “2 * 3-4 * 5”
输出:[-34,-14,-10,-10,10]
解释:
(2 * (3-(4 * 5))) = -34
((2 * 3)-(4 * 5)) = -14
((2 * (3-4)) * 5) = -10
(2 * ((3-4) * 5)) = -10
(((2 * 3)-4) * 5) = 10
提示:
1 <= expression.length <= 20
expression 由数字和算符 ‘+’、‘-’ 和 ‘*’ 组成。
输入表达式中的所有整数值在范围 [0, 99]
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/different-ways-to-add-parentheses
思路
本题的含义其实是给定一个只包含加减乘运算的数学表达式,求通过加括号可以得到多少种不同的结果。
利用分治思想,我们可以把加括号转化为,对于每个运算符号,先执行处理两侧的数学表达式,再处理此运算符号。注意边界情况,即字符串内无运算符号,只有数字。
具体来讲,我们先定义一个result,用来存储所有可能组合的结果。之后遍历字符串,对于每一个字符,如果它是运算符,则以它为分界,对左右两个子串进行递归(结果为左右子串所有可能的结果),结果分别存储在left和right中。之后我们使用两层for循环根据运算符对左右子串的结果进行组合,将结果加入result中。当遍历完字符串之后,所有可能组合的结果就都被存入result中了。若遍历完字符串之后result为空,此情况对应子串只有一个字符,应该直接将它加入result。
题解
class Solution {
public:
vector<int> diffWaysToCompute(string expression) {
vector<int> result;
for (int i = 0; i < expression.size(); ++i) {
int c = expression[i];
if (c == '+' || c == '-' || c == '*') {//c为运算符
vector<int> left = diffWaysToCompute(expression.substr(0, i));//运算符左边子串
vector<int> right = diffWaysToCompute(expression.substr(i + 1));//运算符右边子串
for (const int& l : left) {
for (const int& r : right) {//对left和right中的结果根据c进行组合
if (c == '+') result.push_back(l + r);
if (c == '-') result.push_back(l - r);
if (c == '*') result.push_back(l * r);
}
}
}
}
if (result.empty()) result.push_back(stoi(expression));//最终都会被分解为单个字符(数字)
return result;
}
};
我们发现,某些被 divide 的子字符串可能重复出现多次,因此我们可以用 memoization 来去重。或者与其我们从上到下用分治处理 +memoization,不如直接从下到上用动态规划处理。
具体来讲,我们定义两个数组:data和ops分别存放字符串中的数字和运算符(后面加一个+)。之后我们定义一个三维数组作为dp数组存储所有可能的解。dp[j][i]
里存放的数组表示以下标i为左边界、下标j为右边界的字符串所有可能的计算结果。dp[j][i]
应该初始化(初始化步骤并到遍历之中),当i=j时,初始化为下标为i或j的单个数字。在第二层循环内部,我们对[j,i)之间的所有k进行遍历,根据下标k所代表的运算符将dp[j][k]
(左子串)和dp[k+1][i]
(右子串)进行组合,将结果加入dp[j][i]
中,最后返回dp[0][n-1]
class Solution {
public:
vector<int> diffWaysToCompute(string input) {
vector<int> data;
vector<char> ops;
int num = 0;
char op = ' ';
istringstream ss(input + "+");//最后添加一个加号
while (ss >> num && ss >> op) {
data.push_back(num);
ops.push_back(op);
}
int n = data.size();
vector<vector<vector<int>>> dp(n, vector<vector<int>>(n, vector<int>()));
for (int i = 0; i < n; ++i) {
for (int j = i; j >= 0; --j) {
if (i == j) {
dp[j][i].push_back(data[i]);
}
else {
for (int k = j; k < i; k += 1) {
for (auto left : dp[j][k]) {
for (auto right : dp[k+1][i]) {
int val = 0;
if (ops[k] == '+') val = left + right;
else if (ops[k] == '-') val = left - right;
else if (ops[k] == '*') val = left * right;
dp[j][i].push_back(val);
}
}
}
}
}
}
return dp[0][n-1];
}
};
8.3 练习
932. 漂亮数组
题目描述
对于某些固定的 N,如果数组 A 是整数 1, 2, …, N 组成的排列,使得:
对于每个 i < j,都不存在 k 满足 i < k < j 使得 A[k] * 2 = A[i] + A[j]。
那么数组 A 是漂亮数组。
给定 N,返回任意漂亮数组 A(保证存在一个)。
示例 1:
输入:4
输出:[2,1,4,3]
示例 2:
输入:5
输出:[3,1,2,5,4]
提示:
1 <= N <= 1000
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/beautiful-array
思路
题解
312. 戳气球
题目描述
有 n 个气球,编号为0 到 n - 1,每个气球上都标有一个数字,这些数字存在数组 nums 中。
现在要求你戳破所有的气球。戳破第 i 个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1] 枚硬币。 这里的 i - 1 和 i + 1 代表和 i 相邻的两个气球的序号。如果 i - 1或 i + 1 超出了数组的边界,那么就当它是一个数字为 1 的气球。
求所能获得硬币的最大数量。
示例 1:
输入:nums = [3,1,5,8]
输出:167
解释:
nums = [3,1,5,8] --> [3,5,8] --> [3,8] --> [8] --> []
coins = 315 + 358 + 138 + 181 = 167
示例 2:
输入:nums = [1,5]
输出:10
提示:
n == nums.length
1 <= n <= 300
0 <= nums[i] <= 100
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/burst-balloons
思路
题解