Hi 大家好,我是张小猪。欢迎来到『宝宝也能看懂』系列之 leetcode 周赛题解。
这里是第 168 期的第 4 题,也是题目列表中的第 1298 题 -- 『你能从盒子里获得的最大糖果数』
给你 n
个盒子,每个盒子的格式为 [status, candies, keys, containedBoxes]
,其中:
- 状态字
status[i]
:整数,如果box[i]
是开的,那么是1
,否则是0
。 - 糖果数
candies[i]
: 整数,表示box[i]
中糖果的数目。 - 钥匙
keys[i]
:数组,表示你打开box[i]
后,可以得到一些盒子的钥匙,每个元素分别为该钥匙对应盒子的下标。 - 内含的盒子
containedBoxes[i]
:整数,表示放在box[i]
里的盒子所对应的下标。
给你一个 initialBoxes
数组,表示你现在得到的盒子,你可以获得里面的糖果,也可以用盒子里的钥匙打开新的盒子,还可以继续探索从这个盒子里找到的其他盒子。
请你按照上述规则,返回可以获得糖果的 最大数目。
示例 1:
输入:status = [1,0,1,0], candies = [7,5,4,100], keys = [[],[],[1],[]], containedBoxes = [[1,2],[3],[],[]], initialBoxes = [0]
输出:16
解释:
一开始你有盒子 0 。你将获得它里面的 7 个糖果和盒子 1 和 2。
盒子 1 目前状态是关闭的,而且你还没有对应它的钥匙。所以你将会打开盒子 2 ,并得到里面的 4 个糖果和盒子 1 的钥匙。
在盒子 1 中,你会获得 5 个糖果和盒子 3 ,但是你没法获得盒子 3 的钥匙所以盒子 3 会保持关闭状态。
你总共可以获得的糖果数目 = 7 + 4 + 5 = 16 个。
示例 2:
输入:status = [1,0,0,0,0,0], candies = [1,1,1,1,1,1], keys = [[1,2,3,4,5],[],[],[],[],[]], containedBoxes = [[1,2,3,4,5],[],[],[],[],[]], initialBoxes = [0]
输出:6
解释:
你一开始拥有盒子 0 。打开它你可以找到盒子 1,2,3,4,5 和它们对应的钥匙。
打开这些盒子,你将获得所有盒子的糖果,所以总糖果数为 6 个。
示例 3:
输入:status = [1,1,1], candies = [100,1,100], keys = [[],[0,2],[]], containedBoxes = [[],[],[]], initialBoxes = [1]
输出:1
示例 4:
输入:status = [1], candies = [100], keys = [[]], containedBoxes = [[]], initialBoxes = []
输出:0
示例 5:
输入:status = [1,1,1], candies = [2,3,2], keys = [[],[],[]], containedBoxes = [[],[],[]], initialBoxes = [2,1,0]
输出:7
提示:
1 <= status.length <= 1000
status.length == candies.length == keys.length == containedBoxes.length == n
status[i]
要么是 0 要么是 11 <= candies[i] <= 1000
0 <= keys[i].length <= status.length
0 <= keys[i][j] < status.length
keys[i]
中的值都是互不相同的。0 <= containedBoxes[i].length <= status.length
0 <= containedBoxes[i][j] < status.length
containedBoxes[i]
中的值都是互不相同的。- 每个盒子最多被一个盒子包含。
0 <= initialBoxes.length <= status.length
0 <= initialBoxes[i] < status.length
HARD
又是一个参数挺多的题,连约束条件都这么长。总的来说是一个并不复杂的套娃小游戏。(套娃真的都这么流行了么 _)
游戏的过程就是,有一天,圣诞老人拿给了你几个盒子,其中每个盒子打开之后可能会有 3 种东西:
- 一定数量的糖果
- 一定数量的钥匙
- 一定数量的盒子
糖果,当然很开心啦,虽然是从烟囱里进来的;
钥匙,当然不是别人家的啦,是用来打开上锁的盒子;
盒子,也就是套娃,有可能被锁住了,需要用钥匙来打开。
至于打开之后的话...『有一天,圣诞老人拿给了你几个盒子...』...直到我们无盒可开。最终返回我们能拿到的糖糖的数量即可。
看完题目内容,我其实有点惊异。居然不是对于初始盒子和开盒情况加一定的限制条件,然后让我们给出能够得到最多糖的初始盒子方案。不是很喜欢出这样的题么 >.<
那么对于现在的题目,其实我们不难发现,整个过程是没有变数的,因为所有的参数和条件都已经提供给我们了。我们所需要做的仅仅是根据游戏玩法推演出过程,并最终统计出能够获取的糖的数量。那么基于这道题,我就写多一点一步一步的优化过程。
由于上面已经罗列了游戏的玩法和基本思路,那么我们这里就直接基于玩法来实现相关的代码:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const queue = initialBoxes;
const closedBoxes = new Uint8Array(1000);
let unusedKeys = [];
let ret = 0;
for (let i = 0; i < queue.length; ++i) {
const cur = queue[i];
ret += candies[cur];
for (const box of containedBoxes[cur]) {
status[box] === 1 ? queue.push(box) : (closedBoxes[box] = 1);
}
unusedKeys = unusedKeys.concat(keys[cur]).filter(key => {
if (closedBoxes[key] === 0) return true;
closedBoxes[key] = 0;
queue.push(key);
});
}
return ret;
};
这是我最开始写的比较无脑的直接过程。如果拿这段代码去提交,就会发现什么是擦边低空飘过。时间整整花了 2200ms 多。好吧,我知道为什么这道没有变数的题被放进 HARD 了,可能和测试数据有关。
于是马上改写了一点 concat
和 filter
函数的调用部分。当然,这肯定不会有质变,只是想顺便看一下影响会有多大。代码如下:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const queue = initialBoxes;
const closedBoxes = new Uint8Array(1000);
let unusedKeys = [];
let ret = 0;
for (let i = 0; i < queue.length; ++i) {
const cur = queue[i];
ret += candies[cur];
for (const box of containedBoxes[cur]) {
status[box] === 1 ? queue.push(box) : (closedBoxes[box] = 1);
}
const leftKeys = [];
unusedKeys.push(...keys[cur]);
for (const key of unusedKeys) {
if (closedBoxes[key] === 0) {
leftKeys.push(key);
} else {
closedBoxes[key] = 0;
queue.push(key);
}
}
unusedKeys = leftKeys;
}
return ret;
};
提交后时间来到了 1600ms 多。影响比我预期的明显。不过具体工作中的 production 代码,大家还是根据具体情况酌情考虑吧。接下来开始正式的优化。
回看上面的代码,我们会发现对于等待被打开的盒子们,每次打开一个盒子,我们都会去完整的遍历未使用的钥匙。这样的效率其实是很低的,因为每次增量的锁住盒子其实是很少的。那么最简单的做法就是,我们可以把同一批的盒子都打开,然后再统一做处理和校验。代码如下:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const closedBoxes = new Uint8Array(1000);
let queue = initialBoxes;
let unusedKeys = [];
let ret = 0;
while (queue.length) {
const next = [];
for (const cur of queue) {
ret += candies[cur];
for (const box of containedBoxes[cur]) {
status[box] === 1 ? next.push(box) : (closedBoxes[box] = 1);
}
unusedKeys.push(...keys[cur]);
}
const leftKeys = [];
for (const key of unusedKeys) {
closedBoxes[key] === 0 ? leftKeys.push(key) : (closedBoxes[key] = 0, next.push(key));
}
unusedKeys = leftKeys;
queue = next;
}
return ret;
};
这下时间有了数量级的变化,成了 160ms。
当然这里还可以有一点小优化,我们对于从当前一批盒子中拿到的钥匙,每次都全部放进了未使用的钥匙的数组,然后再做处理。其实可以分开,从而节省数据转移的时间。代码如下:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const closedBoxes = new Uint8Array(1000);
let queue = initialBoxes;
let unusedKeys = [];
let ret = 0;
while (queue.length) {
const next = [];
for (const cur of queue) {
ret += candies[cur];
for (const box of containedBoxes[cur]) {
status[box] === 1 ? next.push(box) : (closedBoxes[box] = 1);
}
}
const leftKeys = [];
for (const key of unusedKeys) {
closedBoxes[key] === 0 ? leftKeys.push(key) : ((closedBoxes[key] = 0), next.push(key));
}
for (const cur of queue) {
for (const key of keys[cur]) {
closedBoxes[key] === 0 ? leftKeys.push(key) : ((closedBoxes[key] = 0), next.push(key));
}
}
unusedKeys = leftKeys;
queue = next;
}
return ret;
};
时间稍微缩短到了 130ms 左右。
重新整理思路。之前的想法其实比较的规整,即『打开->归类->处理』。但是其实题目没有做任何限制,也就是说我们完全可以在归类的时候就完成处理这个操作。与此同时,处理的数据量也会小很多。因为我们只需要处理当前打开盒子中的内容,而不是之前那样再遍历未使用的东西列表。
基于这个思路,我们来列一下过程:
- 打开一个可打开的盒子
- 拿出内部的糖果做计数
- 拿出内部的盒子
- 如果是没有锁的盒子,直接进入可打开队列
- 如果有锁,检查一下之前的未使用的钥匙
- 如果有钥匙,盒子进入可打开队列,钥匙去掉
- 如果没钥匙,记录该盒子被锁住
- 拿出内部的钥匙
- 检查被锁住的盒子中是否有可以被打开的
对比一下可以发现,这个过程中循环的内容明显少了很多。其中需要被检查的内容多了一个,但是我们可以通过 Map 做到 O(1),所以完全不是问题。并且由于不存在对于历史内容的遍历,所以我们也就不用再去合并一批一批的操作,直接单个操作即可。
基于以上分析,我们可以写出类似这样的代码:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const closedBoxes = new Uint8Array(1000);
const unusedKeys = new Uint8Array(1000);
const queue = initialBoxes;
let ret = 0;
for (let i = 0; i < queue.length; ++i) {
const cur = queue[i];
ret += candies[cur];
for (const box of containedBoxes[cur]) {
if (status[box] === 1) {
queue.push(box);
} else if (unusedKeys[box] === 1) {
queue.push(box);
unusedKeys[box] = 0;
} else {
closedBoxes[box] = 1;
}
}
for (const key of keys[cur]) {
if (closedBoxes[key] === 0) {
unusedKeys[key] = 1;
} else {
closedBoxes[key] = 0;
queue.push(key);
}
}
}
return ret;
};
时间缩短到了 90ms 多。初见成效。
鲁迅可能说过,“所有的初见成效之后,一定还有更多内容”。于是我们当然不会就此满足。
回看上面的代码,其中有一个 unusedKeys
看起来十分不爽。它的作用就是单纯的为了记录没有使用过的钥匙。那么我们是否有什么方法把它去掉呢?
我们可以注意到,在处理新的盒子的时候,我们用到了 status[box] === 1
这个判断,也用到了 unusedKeys[box] === 1
这个判断。本质是因为前者只是最初的状况,并不够完整,所以我们需要后者来补充。说到这里,相信大家已经注意到了这一点,那就是为什么我们不继续更新 status
的状态呢?
基于此我们可以得到类似下面的代码:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const closedBoxes = new Uint8Array(1000);
const queue = initialBoxes;
let ret = 0;
for (let i = 0; i < queue.length; ++i) {
const cur = queue[i];
ret += candies[cur];
for (const box of containedBoxes[cur]) {
status[box] === 1 ? queue.push(box) : (closedBoxes[box] = 1);
}
for (const key of keys[cur]) {
if (closedBoxes[key] === 0) {
status[key] = 1;
} else {
closedBoxes[key] = 0;
queue.push(key);
}
}
}
return ret;
};
那么,我们继续。这个 closedBoxes
是不是也看着很不爽?它的作用就是单纯的为了记录锁住的还没找到钥匙的盒子。那么我们来试试把它也去掉吧。
我们可以注意到,对于这个数组的使用,是作为 status
状态更新的依据。而我们的 status
状态目前只有两个,0
表示锁住了,1
表示可以打开。那么问题的本质其实是,我们目前没有办法表示『我们已经拿到了这个盒子,但是还没有钥匙』的这个状态。那么为什么我们不添加这么一个状态呢?反正 status
的状态更新已经由我们自己维护了。
基于此我们可以得到类似下面的代码:
const maxCandies = (status, candies, keys, containedBoxes, initialBoxes) => {
const queue = initialBoxes;
let ret = 0;
for (let i = 0; i < queue.length; ++i) {
ret += candies[queue[i]];
for (const box of containedBoxes[queue[i]]) {
status[box] === 1 ? queue.push(box) : (status[box] = -1);
}
for (const key of keys[queue[i]]) {
status[key] === -1 ? (status[key] = 0, queue.push(key)) : (status[key] = 1);
}
}
return ret;
};
这一段代码我跑到了 68ms,暂时 beats 100%。
这道题的优化过程写的相对比较详细,目的就是借助这个 HARD 题目来尝试给出一些优化中的思路方向。以及当我们在一个方向上卡住的时候,也许可以尝试重新梳理目的和过程,这时候由于我们已经做了一些方案和优化,可能能比最开始解决的时候得到更好的思路。