大家好,我是梁唐。
今天是周一,我们老规矩来聊聊昨天LeetCode周赛的第285场,文章首发于公众号:Coder梁,欢迎关注。
这次的比赛是贝壳赞助的,奖品好坏不谈,居然只有1-10名才能获得贝壳的内推码……有点蚌埠住。
废话不多说,我们来看题。
统计数组中峰和谷的数量
给你一个下标从 0 开始的整数数组 nums
。如果两侧距 i
最近的不相等邻居的值均小于 nums[i]
,则下标 i
是 nums
中,某个峰的一部分。类似地,如果两侧距 i
最近的不相等邻居的值均大于 nums[i]
,则下标 i
是 nums
中某个谷的一部分。对于相邻下标 i
和 j
,如果 nums[i] == nums[j]
, 则认为这两下标属于 同一个 峰或谷。
注意,要使某个下标所做峰或谷的一部分,那么它左右两侧必须 都 存在不相等邻居。
返回 nums
中峰和谷的数量。
解法
数据范围很小, 最多只有100个数,那么基本上就随便玩都行。
我们直接暴力求解,首先遍历每一个元素nums[i]
,然后往前和往后分别找到第一个和nums[i]
不等的元素,然后判断一下,nums[i]
是否能够构成峰或谷即可。
class Solution {
public:
int countHillValley(vector<int>& nums) {
int ret = 0;
int n = nums.size();
for (int i = 0; i < n; i++) {
if (i > 0 && nums[i] == nums[i-1]) continue;
int l = i - 1;
while (l > -1 && nums[l] == nums[i]) l--;
int r = i+1;
while (r < n && nums[r] == nums[i]) r++;
if (l < 0 || r >= n) continue;
if (nums[l] < nums[i] && nums[r] < nums[i]) ret++;
if (nums[l] > nums[i] && nums[r] > nums[i]) ret++;
}
return ret;
}
};
复制代码
统计道路上的碰撞次数
在一条无限长的公路上有 n
辆汽车正在行驶。汽车按从左到右的顺序按从 0
到 n - 1
编号,每辆车都在一个 独特的 位置。
给你一个下标从 0 开始的字符串 directions
,长度为 n
。directions[i]
可以是 'L'
、'R'
或 'S'
分别表示第 i
辆车是向 左 、向 右 或者 停留 在当前位置。每辆车移动时 速度相同 。
碰撞次数可以按下述方式计算:
- 当两辆移动方向 相反 的车相撞时,碰撞次数加
2
。 - 当一辆移动的车和一辆静止的车相撞时,碰撞次数加
1
。
碰撞发生后,涉及的车辆将无法继续移动并停留在碰撞位置。除此之外,汽车不能改变它们的状态或移动方向。
返回在这条道路上发生的 碰撞总次数 。
解法
这题乍一看有点唬人,首先题目的范围不小,最多有1e5辆车,整个情况会非常复杂。
但如果我们冷静一点,仔细分析一下题意,会发现没有那么难。
我们可以分析一个通用情况,对于下标为i的车辆来说,它一共有三种可能,分别是向左向右或停止。我们先来看看向左,如果i车向左,那么如果i车的左侧存在向右的车辆,一定会发生碰撞。但会碰撞几次呢?这其实关系到有几辆车。
如果i的左侧有x辆向右移动的车,那么第一辆会和i碰撞,这会带来两次碰撞。之后,两车停止,剩余的x-1辆车与静止的车辆发生碰撞,带来x-1次碰撞。那么一共就是发生x+1次碰撞。
第二种情况,i车停止,这既会影响i左侧的车也会影响i右侧的车。如果i左侧存在向右的车,那么这些车都会依次与i发生碰撞。同理,如果i的右侧存在向左的车也是一样,它的左右有几辆朝它行驶的车,就会发生几次碰撞。
最后一种情况是i车向右,这种情况无法判断是否会产生碰撞,需要根据i车右侧的情况来确定。
稍微归纳一下可以发现我们从左到右判断碰撞的过程当中需要维护两个值,第一个值是左侧是否有停止的车辆或者是因碰撞而停止的车辆。第二个值是左侧存在多少辆向右行驶的车,只要维护这两个数据,我们可以很容易得到答案。
class Solution {
public:
int countCollisions(string dir) {
int ret = 0;
int r = 0;
bool has_stop = false;
int n = dir.size();
for (int i = 0; i < n; i++) {
if (dir[i] == 'S') {
// i车停止,i左侧向右的车全部会碰撞
has_stop = true;
ret += r;
r = 0;
continue;
}
if (dir[i] == 'L') {
// i车向左,如果左侧存在x辆向右的车,则出现x+1次碰撞
if (r) {
ret += r+1;
has_stop = true;
r = 0;
continue;
}
// 如果左侧存在停止的车,出现1次碰撞
if (has_stop) {
ret++;
}
}
if (dir[i] == 'R') r++;
}
return ret;
}
};
复制代码
射箭比赛中的最大比分
Alice 和 Bob 是一场射箭比赛中的对手。比赛规则如下:
- Alice 先射
numArrows
支箭,然后 Bob 也射numArrows
支箭。 - 分数按下述规则计算:
- 箭靶有若干整数计分区域,范围从
0
到11
(含0
和11
)。 - 箭靶上每个区域都对应一个得分
k
(范围是0
到11
),Alice 和 Bob 分别在得分k
区域射中ak
和bk
支箭。如果ak >= bk
,那么 Alice 得k
分。如果ak < bk
,则 Bob 得k
分 - 如果
ak == bk == 0
,那么无人得到k
分。
- 箭靶有若干整数计分区域,范围从
- 例如,Alice 和 Bob 都向计分为
11
的区域射2
支箭,那么 Alice 得11
分。如果 Alice 向计分为11
的区域射0
支箭,但 Bob 向同一个区域射2
支箭,那么 Bob 得11
分。
给你整数 numArrows
和一个长度为 12
的整数数组 aliceArrows
,该数组表示 Alice 射中 0
到 11
每个计分区域的箭数量。现在,Bob 想要尽可能 最大化 他所能获得的总分。
返回数组 bobArrows
,该数组表示 Bob 射中 0
到 11
每个 计分区域的箭数量。且 bobArrows
的总和应当等于 numArrows
。
如果存在多种方法都可以使 Bob 获得最大总分,返回其中 任意一种 即可。
解法
观察一下数据范围,会发现本题的范围不小,numArrows
最大有1e5。
其次很多同学估计拿到手就会忘贪心方向想,但不难发现贪心很容易出现反例。无论我们按照性价比贪心,还是简单地按照单个步骤收益最大贪心都没办法解决反例的问题。
发现贪心有反例只有,如果头脑灵光的话,已经可以反应过来了,凡是贪心不能解决的问题,十有八九是动态规划问题,本题也不例外。
熟悉背包问题的话,会发现本题就是一个赤裸裸的01背包问题。如果Bob射中的数量比Alice多,那么Bob获得相应的积分。考虑最优情况,当然是Bob射中的次数刚好比Alice多一个最优。我们可以把区域i看成是体积alice[i]+1
,价值i
的商品,由于每个区域最多赢一次,相当于每件商品最多拿一件,这样就转换成了经典的01背包问题。
不过01背包只求一个最值,而本题当中需要还原背包构建的过程。
一种简单的做法是我们单独用一个数组把每一个状态转移时采取的策略记录下来:
typedef pair<int, int> pii;
class Solution {
public:
vector<int> maximumBobPoints(int num, vector<int>& ali) {
// stg 记录策略
vector<vector<int>> dp(13, vector<int>(num+2, 0)), stg(13, vector<int>(num+2, 0));
for (int i = 1; i < 12; i++) {
int v = ali[i]+1;
for (int j = 0; j <= num; j++) {
if (j < v) {
dp[i][j] = dp[i-1][j];
continue;
}
if (dp[i-1][j-v] + i > dp[i-1][j]) {
dp[i][j] = dp[i-1][j-v] + i;
stg[i][j] = i;
}else dp[i][j] = dp[i-1][j];
}
}
vector<int> ret(12);
int tot = num;
for (int i = 11; i > 0; i--) {
// 如果策略大于0,还原相关策略
if (stg[i][tot] > 0) {
ret[i] = ali[stg[i][tot]] + 1;
tot -= ret[i];
}
}
ret[0] = tot;
return ret;
}
};
复制代码
但还原策略还有一个更巧妙的方法,我们不需要再去记录策略了,而是直接从dp
数组当中判断。
我们判断dp[i][j]
和dp[i-1][j]
的大小关系,这里的i表示策略,j表示状态,即背包的体积。如果dp[i][j] > dp[i-1][j]
,这说明了什么?
说明了对于同样的状态j,使用了策略i之后获得了更优的结果,那么必然是因为采用了i策略,因为dp[i][j]
和dp[i-1][j]
的策略差异只有i而已。那么我们只需要根据这个性质倒推,也一样可以还原出策略。
class Solution {
public:
vector<int> maximumBobPoints(int num, vector<int>& ali) {
vector<vector<int>> dp(13, vector<int>(num+2, 0));
for (int i = 1; i < 12; i++) {
int v = ali[i]+1;
for (int j = 0; j <= num; j++) {
if (j < v) {
dp[i][j] = dp[i-1][j];
continue;
}
dp[i][j] = max(dp[i-1][j-v] + i, dp[i-1][j]);
}
}
vector<int> ret(12);
int tot = num;
for (int i = 11; i > 0; i--) {
// 判断是否采取了策略i
if (dp[i][tot] > dp[i-1][tot]) {
ret[i] = ali[i]+1;
tot -= (ali[i]+1);
}
}
ret[0] = tot;
return ret;
}
};
复制代码
我当时在比赛的时候,想出了背包求解的方法,但由于我是采用的压缩之后的写法,没有使用二维数组,所以无法回溯策略。
情急之下我注意到了本题策略的范围很小只有12,所以可以暴力枚举所有状态,找到满足最优解的状态。进而又发现,既然已经暴力枚举了状态,那么就不必再使用dp了,我们直接从暴力出的结果里找最优即可。
typedef pair<int, int> pii;
class Solution {
public:
vector<int> maximumBobPoints(int num, vector<int>& ali) {
vector<int> ret(12, 0);
int maxi = 0;
// 二进制枚举状态
for (int s = 0; s < (1 << 12); s++) {
int used = 0, score = 0;
for (int i = 1; i < 12; i++) {
if (s & (1 << i)) {
used += ali[i] + 1;
// 如果消费大于num,说明不成立,直接跳出循环
if (used > num) break;
score += i;
}
}
// 维护最大得分
if (score > maxi) {
for (int i = 1; i < 12; i++) {
if (s & (1 << i)) {
ret[i] = ali[i] + 1;
}else ret[i] = 0;
}
maxi = score;
ret[0] = num - used;
}
}
return ret;
}
};
复制代码
所以这一题有很多种写法,很明显最后一种不仅编码简单而且效率也最高。这题质量真的不错,值得反复挖掘,最好能把每一种解法都尝试一遍,对于提升编码能力很有帮助。
由单个字符重复的最长字符串
给你一个下标从 0 开始的字符串 s
。另给你一个下标从 0 开始、长度为 k
的字符串 queryCharacters
,一个下标从 0
开始、长度也是 k
的整数 下标 数组 queryIndices
,这两个都用来描述 k
个查询。
第 i
个查询会将 s
中位于下标 queryIndices[i]
的字符更新为 queryCharacters[i]
。
返回一个长度为 k
的数组 lengths
,其中 lengths[i]
是在执行第 i
个查询 之后 s
中仅由 单个字符重复 组成的 最长子字符串 的 长度 。
解法
这题观察一下数据范围之后可以发现非常棘手,首先,字符串的长度很长,有1e5,并且修改次数也很多,也有1e5,每一次修改都伴随着查询,查询的量级也是1e5。即使对于每一次查询我们都能以 的复杂度获得结果,也依然会超时。
如果你在比赛的时候看到这里一点头绪和思路都没有,那么恭喜你,你可以提前去吃午饭了。因为这样的问题十有八九都需要用到一些算法或者数据结构,不知道算法或数据结构就不可能做出来。
对于这道题而言,我们需要能够用尽量低的复杂度对于一个区间(字符串)中的内容进行快速更新、快速查询。这样的数据结构有好几种,比如线段树、树状数组等,结合本题的要求,不难发现可以使用线段树来解决。
之前写过介绍线段树的文章,想要了解原理的同学可以点击链接:线段树链接
我们使用线段树维护一个区间内的三个值,分别是从左侧开始连续相同的子串长度,从右侧开始连续相同的子串长度,以及中间位置的最大连续子串长度。我们分别用fwd(foward), bkd(backward)和meg(merge)三个变量来代表这三个值。
假设我们要求[l, r)
区间的这三个值,怎么办呢?我们可以首先一分为二,将区间拆分成两个部分,分别是[l, m)
和[m, r)
。这里的m是l和r的中点,注意这里的区间都是左闭右开区间,当然也可以用闭区间看个人习惯。
我们可以采用分治的思想,先求解出左右两个部分fwd,bkd和meg的值,之后就可以分情况讨论了。其实要讨论的情况不多,只有一种情况,就是左右两个区间可以衔接在一起,即s[m-1] == s[m]
时,这里的s就是原字符串。当满足这个条件时,说明左右两侧区间中间可以连接。
如果不满足条件,很简单,左区间的fwd就是全区间的fwd,右区间的bkd就是全区间的bkd,左右区间的meg的最大值就是全区间meg的最大值。
如果满足条件,依然有几种情况,首先对于meg来说,它多了一个取值:L.bkd + R.fwd
,因为左右区间可以连接,所以左区间的bkd和右区间的fwd可以连接在一起,连接之后的长度就是两者之和。
其次要考虑一下左区间完全一样或者是右区间完全一样的情况,如果左区间完全一样,那么左区间的fwd就可以连接上右区间的fwd,同理,如果右区间完全一样,那么右区间的bkd就可以连接上左区间的bkd。
如此一来,利用递归和分治,在每次查询时我们只需要判断几种情况就能得到答案,而不再需要遍历字符串获取答案,因此查询的复杂度就是树上递归的复杂度即 ,如此一来,即能AC。
完整代码如下:
int n;
// 结构体,分别存储fwd、bkd和meg
struct P {
int fwd, bkd, meg;
P(){}
P(int f, int b, int m): fwd(f), bkd(b), meg(m) {}
};
P tree[300050];
void push_up(string &s, int lef, int rig, int l, int r, int m, int k) {
tree[k].fwd = tree[lef].fwd;
tree[k].bkd = tree[rig].bkd;
tree[k].meg = max(tree[lef].meg, tree[rig].meg);
// 判断中间是否可以衔接
if (s[m-1] == s[m]) {
if (tree[rig].bkd >= r - m) tree[k].bkd += tree[lef].bkd;
if (tree[lef].fwd >= m - l) tree[k].fwd += tree[rig].fwd;
tree[k].meg = max(tree[k].meg, tree[lef].bkd + tree[rig].fwd);
}
}
void build(string &s, int l, int r, int k) {
// 建树操作
if (r <= l+1) {
tree[k] = {1, 1, 1};
return ;
}
int m = (l + r) >> 1;
int lef = k << 1, rig = k << 1 | 1;
// 分左右递归
build(s, l, m, lef);
build(s, m, r, rig);
// 通过左右区间求中间
push_up(s, lef, rig, l, r, m, k);
}
void update(string &s, int l, int r, int k, int idx, char m) {
if (r - l == 1 && l == idx) {
s[l] = m;
return ;
}
int mid = (l + r) >> 1;
// 根据更新为止判断更新左侧或右侧
if (idx < mid) update(s, l, mid, (k << 1), idx, m);
else update(s, mid, r, (k << 1 | 1), idx, m);
// 更新中间
int lef = k << 1, rig = k << 1 | 1;
push_up(s, lef, rig, l, r, mid, k);
}
class Solution {
public:
vector<int> longestRepeating(string s, string query, vector<int>& queryIdx) {
n = s.length();
build(s, 0, n, 1);
vector<int> ret;
int m = query.size();
for (int i = 0; i < m; i++) {
update(s, 0, n, 1, queryIdx[i], query[i]);
ret.push_back(tree[1].meg);
}
return ret;
}
};
复制代码
虽然本题是线段树的简单应用,但在比赛当中想要做出来还是非常难的,我虽然想到了线段树,但由于太过生疏也没能在时限内debug完。另外除了acm选手之外,很少有人能够自学会线段树并且能够手打通过的。
赛后看了一下大佬的题解,发现本题还有另外一种取巧的数据结构可以使用,由于篇幅所限就不在此赘述了。在我研究明白之后,我会再另外开一篇文章单独讲讲的。
好了,关于本次的比赛就先聊到这里,感谢大家的阅读。