解题记录
11 月总结
本月有价值的题目:
- 11-12 Range 模块:平衡二叉搜索树的模板
- 11-13 区域和检索 - 数组可修改 :线段树的模板
- 11-14 阈值距离内邻居最少的城市:Dijkstra 的模板
- 11-17 最大和查询:有难度的题目,单调栈
- 11-19 三个无重叠子数组的最大和:滑动窗口,动态规划
- 11-23 HTML 实体解析器:字符串替换
- 11-26 统计子串中的唯一字符 :切入点的转变
- 11-27 子数组的最小值之和:和 11-26 的题目很像,都是转变切入点,这个题还用了单调栈优化
- 11-20 确定两个字符串是否接近:解题思路
2023-11-11
765 情侣牵手
n
对情侣坐在连续排列的2n
个座位上,想要牵到对方的手。人和座位由一个整数数组
row
表示,其中row[i]
是坐在第i
个座位上的人的 ID。情侣们按顺序编号,第一对是(0, 1)
,第二对是(2, 3)
,以此类推,最后一对是(2n-2, 2n-1)
。返回 最少交换座位的次数,以便每对情侣可以并肩坐在一起。 每次交换可选择任意两人,让他们站起来交换座位。
简单的模拟题
首先有两个观察:对于最终的理想位次,满足:
- 偶数位置上的人,其下一个位置为其情侣
- ID 为偶数的人,其情侣 ID 为其 ID + 1;反之,ID 为奇数的人,其情侣 ID 为其 ID - 1
那么,只需要从前往后遍历所有偶数位次,检查其下一个位置是否为其情侣,不是的话就将其情侣交换过来。显然,这样也是交换次数最少的方案。
代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
int minSwapsCouples(vector<int>& row) {
int n = row.size();
int ans = 0;
vector<int> index(n);
for (int i = 0; i < n; i++) {
index[row[i]] = i;
}
for (int i = 0; i < n; i += 2) { // 遍历每个偶数位置
int pos = find(row[i], index, row);
if (pos == -1) continue;
else {
int temp = row[i + 1];
row[i + 1] = row[pos];
row[pos] = temp;
index[row[i + 1]] = i + 1;
index[row[pos]] = pos;
ans++;
}
}
return ans;
}
int find(int x, vector<int>& index, vector<int>& row)
{
// 位置在偶数位,那么检测其下一位
if (x % 2 == 0) { // ID 为偶数,其情侣 ID 应为 x + 1
if (row[index[x] + 1] == x + 1) return -1;
else return index[x + 1];
}
else {
if (row[index[x] + 1] == x - 1) return -1;
else return index[x - 1];
}
}
};
2023-11-12
715 Range 模块
设计一个数据结构来跟踪表示为 半开区间 的范围并查询它们。
半开区间
[left, right)
实现增删查
平衡二叉搜索树的模板题
在 C++ STL 中为 set
树上每个节点代表一个区间,以区间的左端点 left 作为 key,按照 left 从小到大排序
注意到:由于会引入区间合并操作,所以树上的节点不会有重叠
在代码过程中有两个细节需要注意:
ranges.lower_bound
:返回第一个 key 大于等于 left 的节点,这里要额外处理等于的情况需要处理左端点小于 left,但是右端点大于 left 的重叠情况,所以上一步返回的迭代器需要向前看一个:
1
if(it != ranges.begin() && (--it)->second < left) it++;
另外学到了:
1
it = ranges.erase(it);
这样去迭代。
以及引用 vector 中的元素:
1
it = ranges.erase(it);
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class RangeModule {
public:
RangeModule() {}
void addRange(int left, int right) {
auto it = ranges.lower_bound({ left, right });
if (it != ranges.begin() && (--it)->second < left) it++;
while (it != ranges.end() && it->first <= right) {
left = min(left, it->first);
right = max(right, it->second);
it = ranges.erase(it);
}
ranges.insert({ left, right });
}
bool queryRange(int left, int right) {
auto it = ranges.lower_bound({ left, right });
if (it->first == left) {
return it->second >= right;
}
else {
if (it == ranges.begin()) return false;
it--;
return it->second >= right;
}
}
void removeRange(int left, int right) {
auto it = ranges.lower_bound({ left, right });
if (it != ranges.begin() && (--it)->second < left) it++;
vector<pair<int, int>> tmp;
while (it != ranges.end() && it->first < right) {
if (it->first < left) tmp.push_back({ it->first, left });
if (it->second > right) tmp.push_back({ right, it->second });
it = ranges.erase(it);
}
for (const auto& t : tmp) ranges.insert(t);
}
private:
set<pair<int, int>> ranges;
};
2023-11-13
307 区域和检索 - 数组可修改
给你一个数组
nums
,请你完成两类查询。
- 其中一类查询要求 更新 数组
nums
下标对应的值- 另一类查询要求返回数组
nums
中索引left
和索引right
之间( 包含 )的nums元素的 和 ,其中left <= right
基础版线段树的模板题
只涉及到加法,而且修改是单点修改,所以比较简单的模板。虽然好久没写已经忘了
值得关注的是求区间和,有一些细节需要注意。
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class NumArray {
public:
NumArray(vector<int>& nums) {
if(nums.empty()) return;
n = nums.size();
tree.resize(2 * n);
buildTree(nums);
}
void update(int index, int val) {
index += n;
tree[index] = val;
while(index > 0){
int left = index;
int right = index;
if(index % 2 == 0){
right = index + 1;
}
else{
left = index - 1;
}
tree[index / 2] = tree[left] + tree[right];
index /= 2;
}
}
int sumRange(int left, int right) {
left += n;
right += n;
int sum = 0;
while(left <= right){
if(left % 2 == 1){
sum += tree[left];
left++;
}
if(right % 2 == 0){
sum += tree[right];
right--;
}
left /= 2;
right /= 2;
}
return sum;
}
private:
vector<int> tree;
int n;
void buildTree(vector<int>& nums){
for(int i = n; i < 2 * n; i++){
tree[i] = nums[i - n];
}
for(int i = n - 1; i > 0; i--){
tree[i] = tree[i * 2] + tree[i * 2 + 1];
}
}
};
2023-11-14
1334 阈值距离内邻居最少的城市
有
n
个城市,按从0
到n-1
编号。给你一个边数组edges
,其中edges[i] = [fromi, toi, weighti]
代表fromi
和toi
两个城市之间的双向加权边,距离阈值是一个整数distanceThreshold
。返回能通过某些路径到达其他城市数目最少、且路径距离 最大 为
distanceThreshold
的城市。如果有多个这样的城市,则返回编号最大的城市。n <= 100
Dijkstra 模板题
只需要以每个城市为起点,Dijkstra,记录到所有城市的最短距离,统计有多少在 distanceThreshold
以内,最后比较。
可以剪枝:在 Dijkstra 入队时,如果已经超过了 distanceThreshold
,那么不入队。
时间复杂度:$O(n^2log(n))$
具体代码细节上,学到了:
vector 的
push_bach
和emplace_back
的不同,前者是将已经创建好的对象直接插入,后者是提供参数,在 vector 内创建。后者效率要高一些。vector 初始化:
1
vector<int> dist(graph.size(), INT_MAX);
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution {
public:
int findTheCity(int n, vector<vector<int>>& edges, int distanceThreshold) {
graph.resize(n);
for(auto& edge: edges){
graph[edge[0]].emplace_back(edge[1], edge[2]);
graph[edge[1]].emplace_back(edge[0], edge[2]);
}
for(int i = 0; i < n; i++){
vector<int> dist = dijkstra(i, distanceThreshold);
int count = 0;
for(int d : dist){
if(d <= distanceThreshold){
count++;
}
}
if(count <= minCount){
minCount = count;
minCity = i;
}
}
return minCity;
}
vector<int> dijkstra(int start, int threshold){
vector<int> dist(graph.size(), INT_MAX);
dist[start] = 0;
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
pq.emplace(0, start);
while(!pq.empty()){
auto [d, u] = pq.top();
pq.pop();
if(d > dist[u]) continue;
for(auto& [v, w] : graph[u]){
if(dist[v] > dist[u] + w){
dist[v] = dist[u] + w;
if(dist[v] <= threshold){
pq.emplace(dist[v], v);
}
}
}
}
return dist;
}
private:
vector<vector<pair<int, int>>> graph; // graph[i] = {j, w} 表示 i 到 j 的距离为 w
int minCount = INT_MAX;
int minCity = -1;
};
2023-11-15
2656 K 个元素的最大和
比较简单,不做记录。
2023-11-16
2760 最长奇偶子数组
也比较简单,假如数据范围大点必须要用 DP,那么还有记录的必要。
2023-11-17
2736 最大和查询
给你两个长度为
n
、下标从 0 开始的整数数组nums1
和nums2
,另给你一个下标从 1 开始的二维数组queries
,其中queries[i] = [xi, yi]
。对于第
i
个查询,在所有满足nums1[j] >= xi
且nums2[j] >= yi
的下标j
(0 <= j < n)
中,找出nums1[j] + nums2[j]
的 最大值 ,如果不存在满足条件的j
则返回 -1 。返回数组
answer
,其中answer[i]
是第i
个查询的答案
nums1.length == nums2.length
n == nums1.length
1 <= n <= 10^5
1 <= queries.length <= 10^5
首先,根据数据范围,直接暴力两重循环会超时。
最早考虑将 nums1 排序,对每个查询,找到符合条件的下标(二分查找),然后只遍历符合条件的下标,但是时间复杂度并没有下降很多,极端情况下,仍然需要遍历所有 nums1。所以仍然会超时。
所以只能看题解了
首先一个关键点是:
找出
nums1[j] + nums2[j]
的 最大值
即,每次求和时,nums1 和 nums1 的下标需要一致。
两个技巧:
将 nums1 和 nums2 合并到一个 pair 数组,保证了求和时下标一致,然后将 nums 以及查询数组按照 x 降序排序
这样在访问到某个查询时,能方便地保留所有 x 满足条件的 nums
维护一个 nums 的单调栈,按照 x + y 从底到顶从大到小
这样对于某个查询,单调栈里的 nums 的 x 一定是满足的;那就二分查找第一个满足 y 条件的即为此次查询的 ans
那么每次访问到 nums,如果 x + y 比栈顶大,那么弹栈,如果当前 nums 的 y 比栈顶的大,那么入栈。
关键:如果当前访问到的 y 比之前的都要小,那么 x + y 一定小于栈中元素。所以能入栈的,一定是 y 比较大的。所以栈中元素不仅 x + y 从大到小,x 从大到小,而且 y 是从小到大。
概括来说:
- nums 按照 x 从大到小排序,单调栈从底到顶按 x + y 从大到小排序,那么访问 nums 的顺序保证了:栈中 x 从大到小,y 从小到大。
- 直观理解是,保留了要么 x 有优势,要么 y 有优势的 nums
核心总结起来,就是一句话:
如果一个 num 的 x 和 y 都比另一个的大,那么另一个一定不会是 ans
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution {
public:
vector<int> maximumSumQueries(vector<int>& nums1, vector<int>& nums2, vector<vector<int>>& queries) {
int n = nums1.size();
int m = queries.size();
vector<pair<int, int>> sortedNums;
vector<tuple<int, int, int>> sortedQueries;
for (int i = 0; i < n; i++) {
sortedNums.emplace_back(nums1[i], nums2[i]);
}
sort(sortedNums.begin(), sortedNums.end(), greater<pair<int, int>>());
for (int i = 0; i < m; i++) {
sortedQueries.emplace_back(queries[i][0], queries[i][1], i);
}
sort(sortedQueries.begin(), sortedQueries.end(), greater<tuple<int, int, int>>());
vector<pair<int, int>> stack;
vector<int> answer(m, -1);
int j = 0;
for (auto [x, y, i] : sortedQueries) {
while (j < n && sortedNums[j].first >= x) {
auto [num1, num2] = sortedNums[j];
while (!stack.empty() && stack.back().second <= num1 + num2) {
stack.pop_back();
}
if (stack.empty() || stack.back().first < num2) {
stack.emplace_back(num2, num1 + num2);
}
j++;
}
int k = binary_search(stack, y);
if (k < stack.size()) {
answer[i] = stack[k].second;
}
}
return answer;
}
int binary_search(vector<pair<int, int>>& stack, int y) {
int l = 0, r = stack.size();
while (l < r) {
int mid = (l + r) / 2;
if (stack[mid].first >= y) {
r = mid;
}
else {
l = mid + 1;
}
}
return l;
}
};
值得关注的是,二分查找,r = stack.size()
,而不是 stack.size() - 1
,以处理边界情况。
2023-11-18
2342 数位和相等数对的最大和
给你一个下标从 0 开始的数组
nums
,数组中的元素都是正整数。请你选出两个下标i
和j
(i != j
),且nums[i]
的数位和 与nums[j]
的数位和相等。请你找出所有满足条件的下标
i
和j
,找出并返回nums[i] + nums[j]
可以得到的最大值。数位和,即各位的和
比较简单,可以使用 unordered_map
实现:
1
unordered_map<int, pair<int, int>> digitSums;
维护一个数位和对应的最大值和次最大值,值得注意的一个细节是不存在满足条件的数对,返回 -1
2023-11-19
689 三个无重叠子数组的最大和
给你一个整数数组
nums
和一个整数k
,找出三个长度为k
、互不重叠、且全部数字和(3 * k
项)最大的子数组,并返回这三个子数组。以下标的数组形式返回结果,数组中的每一项分别指示每个子数组的起始位置(下标从 0 开始)。如果有多个结果,返回字典序最小的一个。
一开始想用 DP(其实是看到题目标签里有),但是想了想,觉得状态转移方程不是很好写
滑动窗口
维护三个滑动窗口,长度均为 k,并且同步向右移。win1, win2, win3
维护三个当前最大值,分别是一个子数组、两个子数组、三个子数组。max1, max2, max3
关键:每次移动,win1 比较然后更新 max1,max1 + win2 比较然后更新 max2,max2 + win3 比较然后更新 max3
记录好下标,这里别想当然,每个 max 的多个下标需要分别记录
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public:
vector<int> maxSumOfThreeSubarrays(vector<int>& nums, int k) {
int win1 = 0, win2 = 0, win3 = 0;
int max1 = 0, max2 = 0, max3 = 0;
int index1 = 0;
int index2_1 = 0, index2_2 = 0;
int index3_1 = 0, index3_2 = 0, index3_3 = 0;
int n = nums.size();
for(int i = 2 * k; i < n; i++){
win1 += nums[i - 2 * k]; win2 += nums[i - k]; win3 += nums[i];
if(i < 3 * k - 1) continue;
if(win1 > max1){
max1 = win1;
index1 = i - 3 * k + 1;
}
if(max1 + win2 > max2){
max2 = max1 + win2;
index2_1 = index1; index2_2 = i - 2 * k + 1;
}
if(max2 + win3 > max3){
max3 = max2 + win3;
index3_1 = index2_1; index3_2 = index2_2; index3_3 = i - k + 1;
}
win1 -= nums[i - 3 * k + 1]; win2 -= nums[i - 2 * k + 1]; win3 -= nums[i - k + 1];
}
vector<int> ans;
ans.push_back(index3_1); ans.push_back(index3_2); ans.push_back(index3_3);
return ans;
}
};
2023-11-20
53 最大子数组和
比较简单的 DP,不做记录
2023-11-21
2261 美化数组的最少删除数
模拟题,或者说我没想到其他有技巧的解法,所以不做记录
2023-11-22
2304 网格中的最小路径代价
简单的 DP,需要注意的是题目中关于数组的描述:
moveCost[i][j]
是从值为i
的单元格移动到下一行第j
列单元格的代价
不是位置,而是值
其他的不需要记录
2023-11-23
1410 HTML 实体解析器
字符串替换题目,比较经典,所以记录一下。代码值得记住。
另外有两个小细节:
&
会替换为&
,&
又是 HTML 特殊字符的开头,所以应该最后替换- 即使最后替换
&
,每次替换也应该从上次替换的下一个开始,要不然也会有被替换出的&
与后续字符拼接而被误替换的情况
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
string entityParser(string text) {
vector<string> str = {""", "'", ">", "<", "⁄", "&"};
vector<string> rep = {"\"", "\'", ">", "<", "/", "&"};
for(int i = 0; i < str.size(); i++){
int pos = text.find(str[i]);
while(pos != string::npos){
text.replace(pos, str[i].size(), rep[i]);
pos = text.find(str[i], pos + rep[i].size());
}
}
return text;
}
2023-11-24
今天的每日一题 2824 统计和小于目标的下标对数目 比较简单,不做记录。
不过今天做的另外一道题 2750 将数组划分成若干好子数组的方式 值得记录一下:
数组 nums,只包含 0 和 1 两种元素
切分为若干子数组,每个子数组有且只有一个 1,问切分方法数
首先,子数组应该是不改变原数组元素的位置的
关键的想法:
以数组 [0,1,0,0,1]
为例,应该是在两个 1 之间切分,共 3 个位置,那么就有 3 种方法数,当有更多 1 时,结果相乘。
这个想明白以后,代码就比较简单了。
2023-11-25
今天的每日一题 1457 二叉树中的伪回文路径 比较简答的 DFS,不做记录。
今天做的另一道题,1383 最大的团队表现值 值得记录一下:
给定两个整数
n
和k
,以及两个长度为n
的整数数组speed
和efficiency
。现有n
名工程师,编号从1
到n
。其中speed[i]
和efficiency[i]
分别代表第i
位工程师的速度和效率。从这
n
名工程师中最多选择k
名不同的工程师,使其组成的团队具有最大的团队表现值。团队表现值 的定义为:一个团队中「所有工程师速度的和」乘以他们「效率值中的最小值」。
请你返回该团队的最大团队表现值,由于答案可能很大,请你返回结果对
10^9 + 7
取余后的结果。
这个题与 11 月 17 日做的 2736 最大和查询 有些相似,但是比那个简单。
核心思想就是将工程师按照效率从大到小排序,逐个考虑效率,维护一个当前选择的效率最小值。维护一个优先队列,存储效率大于当前维护的最小效率的工程师的速度。优先队列容量最大为 k,并且维护一个当前单调栈中速度的和。
两个点需要注意:
- 一个是题目描述中的「最多选择
k
名不同的工程师」,也就是说可以选择少于 k 人 - 另一个是,答案虽然需要取 MOD,但是在中途比较的过程中不可以取 MOD
代码比较简单,不做记录。
2023-11-26
828 统计子串中的唯一字符
给一个字符串,仅由大写字母组成
求该字符串的「所有字串的唯一字符」的个数之和
1 <= s.length <= 105
一开始一直在 DP 的方向思考(主要是题目标签里有动态规划),想的是类似线段树的思想构造一个数,维护长度为 2 的幂的区间的各个字符出现的次数。
问题是创建这棵树时间为 $O(nlogn)$,但是查询仍然是 $O(n^2logn)$,而且常数比较大,至少为 26(大写字母的个数),会超时。
然后就去看题解了
核心在于转变思考切入点,之前一直在思考怎么逐个区间求,但是转变到从每个字母入手,求其对答案的贡献,那就很简单了。
对于一个字符 c,当前下标为 index,其左边最近的 c 的位置为 l,右边最近的 c 的位置为 r,那么该字符 c 对答案的贡献为 (index - l) * (r - index)
维护每个字母出现的所有位置,用一个二维数组即可,注意处理好边界。
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int uniqueLetterString(string s) {
int n = s.size();
vector<vector<int>> f(26);
for (int i = 0; i < n; i++) {
f[s[i] - 'A'].push_back(i);
}
int ans = 0;
for(int i = 0; i < 26; i++){
int len = f[i].size();
for(int j = 0; j < len; j++){
int l = (j == 0 ? -1 : f[i][j - 1]);
int r = (j == len - 1 ? n : f[i][j + 1]);
ans += (f[i][j] - l) * (r - f[i][j]);
}
}
return ans;
}
};
2023-11-27
907 子数组的最小值之和
给定一个整数数组
arr
,找到min(b)
的总和,其中b
的范围为arr
的每个(连续)子数组
1 <= arr.length <= 3 * 10^4
1 <= arr[i] <= 3 * 10^4
这个题和 11 月 26 日的每日一题解题思想基本一摸一样,统计每个数字对 answer 的贡献。
为了避免重复数字的重复统计,得要求一个方向是 >=,一个方向是 >.
但是直接写会超时
其实有点怪,直接写的时间复杂度为 $O(n^2)$,按理说不应该超时啊
使用单调栈优化,单调栈的精髓是,如果我想左找小于当前数字的下标,如果左边第 j 个数字大于当前数字,那么再往左连续的大于第 j 个数字的元素都可以跳过。
完整代码为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int sumSubarrayMins(vector<int>& arr) {
int n = arr.size();
long long ans = 0;
vector<int> l(n), r(n);
vector<int> stack;
for(int i = 0; i < n; i++){
while(!stack.empty() && arr[stack.back()] > arr[i]){
stack.pop_back();
}
l[i] = stack.empty() ? -1 : stack.back();
stack.push_back(i);
}
stack.clear();
for(int i = n - 1; i >= 0; i--){
while(!stack.empty() && arr[stack.back()] >= arr[i]){
stack.pop_back();
}
r[i] = stack.empty() ? n : stack.back();
stack.push_back(i);
}
for(int i = 0; i < n; i++){
ans += (long long)arr[i] * (long long)(i - l[i]) * (long long)(r[i] - i);
ans %= MOD;
}
return (int)ans;
}
};
2023-11-28
1670 设计前中后队列
支持在队列前、正中间、尾 push 和 pop 操作
最简单的时间就是使用链表
不过因为内部弹出只涉及到正中间,所以可以使用两个双端队列,时间性能上更优
此外今天做的另一道题目 1605 给定行和列的和求可行矩阵 也值得记录一下
给出行和和列和,求一个可行的非负矩阵
贪心构造,对每个位置,取为 min(rowSum[i], colSum[j])
,然后更新 rowSum 和 colSum
2023-11-29
2336 无限集中的最小数字
比较简单,不做记录
2023-11-30
1657 确定两个字符串是否接近
如果可以使用以下操作从一个字符串得到另一个字符串,则认为两个字符串 接近 :
- 操作 1:交换任意两个现有字符。
- 例如,
abcde -> aecdb
- 操作 2:将一个现有字符的每次出现转换为另一个现有字符,并对另一个字符执行相同的操作。
- 例如,
aacabb -> bbcbaa
(所有a
转化为b
,而所有的b
转换为a
)你可以根据需要对任意一个字符串多次使用这两种操作。
给你两个字符串,
word1
和word2
。如果word1
和word2
接近 ,就返回true
;否则,返回false
。
关键在于这两个操作可以使用任意多次,所以提取这两个操作的特征:
- 操作一保证了顺序不影响结果
- 操作二使得可以对换任意两个字符
所以直接按出现次数排序,默认将前面的字符通过「操作二」调成出现次数最少的
还有一个点需要注意的是,能够执行「操作二」,前提是这个字符得存在,如果 word1 或 word2 拥有另一方不存在的字符,那么直接 false.
思想比较关键,代码就比较简单了。