做了这道题后发现有几个值得沉淀的小技巧,于是想写个小结梳理下。
先看题。
1124. 表现良好的最长时间段
给你一份工作时间表 hours(1 <= hours.length <= 、0 <= hours[i] <= 16),上面记录着某一位员工每天的工作小时数。
我们认为当员工一天中的工作小时数大于 8 小时的时候,那么这一天就是「劳累的一天」。
所谓「表现良好的时间段」,意味在这段时间内,「劳累的天数」是严格 大于「不劳累的天数」。
请你返回「表现良好时间段」的最大长度。
示例 1: 输入: hours = [9,9,6,0,6,6,9] 输出: 3 解释: 最长的表现良好时间段是 [9,9,6]。 示例 2: 输入:hours = [6,6,6] 输出:0
概括下题目,它先给出了一个定义,
一段连续的数字,如果满足条件「大于8的数字个数 > 小于8的数字个数」,则可将其定义为“表现良好的时间段”。
然后提供一个数组,求解,在这个数组中找出一个满足了上述定义的连续子数组,使之长度最大,并返回这个最大的长度值。
初步思考分析
首先,我们并不在乎每个数字具体值是多少,只在乎其是否大于8。同义转换下,意思是每个数字就变成了是大于8或不是大于8的标记状态,我们将>8记为1,<=8记为-1,以示例1的hours数组为例,[9, 9, 6, 0, 6, 6, 9]就转变成了[1, 1, -1, -1, -1, -1, 1]。求解的问题则转变成了 找出一个子数组,满足它们各项和>0,那这个数组长度最大是多少。
完成转换后,接下来就是思考如何计算符合条件的子数组长度。
找子数组就是找左右边界。初次能想到的方法就是利用2个for循环来计算边界,大循环固定左边界,小循环判断、调整右边界。从头开始逐次累加,第一轮以下标0的值为初始sum,内循环逐次累加到sum上,内循环的过程中如果sum>0就记下右边界下标,最后,根据左右边界下标差能计算出这轮的最大长度。第二轮从下标1开始向后累加计算。以此类推直到最后一个数字。
代码如下,
var longestWPI = function (hours) {
/**
STEP1: 转换数组
*/
const status = hours.map(t => t > 8 ? 1 : -1); // 将小时数转为>8、<=8的状态值
let max = 0;
/**
STEP2: 计算数组左右边界的距离并更新max值
*/
for (let i = 0; i < status.length && (status.length - i > max); i++) { // 如果剩余数字个数小于max值直接退出循环
let left = right = i; // 逐次缩小左边界
let sum = status[i];
for (let j = i + 1; j < status.length; j++) {
sum += status[j];
// 累加和 > 0,说明子数组中大于8的数字个数 > 小于8的数字个数,则更新右边界的下标
if (sum > 0) right = j;
}
const length =
right === left ?
// 2个下标相同时可能存在status[i]为负数的情况,则此轮不存在符合题意的子数组
status[i] > 0 ? 1 : -1
: (right - left + 1); // 计算子数组长度
length > max && (max = length);
}
return max;
}
上面的代码主要就做了2件事:同义转换数组、计算数组中符合题意的左右边界距离并更新max变量。
在步骤2中,存在几个优化空间:
- 内循环里,为了更新右边界,数组中靠后位置的一些值被多次重复的做了累加动作,导致效率下降;
- 存在多余的边界计算。(数组示意图:
__l1__l2____r2__r1___) 假设左边界l1和右边界r1是一个符合题意的子数组两端,则此时符合题意的子数组l2-r2就是无效的计算,它长度小于l1-r1的长度;
优化
对于第一个优化点,常规操作可以借助前缀和技巧来减少重复的累加操作。
前缀和
我理解的定义:遍历一次原数组后,生成一个新数组,这个新数组中的每项都记录了原数组中从首项到当前下标项之间的各项和。
0 1 2 3 4 5 6 hours数组下标索引
1 1 -1 -1 -1 -1 1 hours数组>8、<=8的状态值(原数组)
0 1 2 3 4 5 6 7 前缀和数组「preSum」下标索引
0 1 2 1 0 -1 -2 -1 前缀和数组「preSum」
便于计算前缀和数组,preSum[0]被记录为初始值0,我们可以理解为原数组中前0项的和;
前缀和下标1记录了原数组中前1项的和,即下标0的值,即1;
preSum[2]记录了原数组中前2项的和(下标1到下标0之间的各项和),等价为 前缀和下标1的值preSum[1] + 原数组下标1的值hours[1],即 1 + 1 = 2;
同理,前缀和下标3的值,记录了原数组中前3项的和,即 2 + (-1) = 1;
前缀和数组,使得我们能够以O(1)的时间复杂度计算出一个区间的各项和。比如想算hours[5]到hours[2]之间(闭区间)的各项和,就相当于求 原数组前6项和 - 原数组前2项和,对应到前缀和数组中就是preSum[6] - preSum[2] = -4;
接着就是借助前缀和数组来计算符合题意的子数组左右边界。如果不借助其他优化的话,就和最开始那段代码里的for循环类似,用俩层for循环进行控制变量法,逐一比较来确认左右边界,这里不再赘述。这样硬比较就会存在很多多余的计算和判断。如何优化它们?
我们先再回顾下如何计算一个子数组的长度:右边界下标 - 左边界下标 + 1;为了找到最宽的子数组,我们会期望尽可能使右边界更右、左边界更左,即向两边延展。而在他们当中构成的符合题意的子数组都不是最长的。
根据子数组的长度计算公式,寻找最宽子数组时从右端(末端)开始向前遍历更便于计算(因为是右边界下标去减别的东西)。不过还是会用到控制变量法,每次一个大循环会固定一个右边界,然后不断延伸左边界。这里能优化的点是如何减少左侧边界的比较次数。这里会使用到一个叫单调栈的技巧。
单调栈
我的粗浅理解:单调栈就是个成员值有序(依次递增或依次递减)的栈结构。
引入这个技巧是为了在确认左边界时跳过一些完全不用考虑的节点。什么叫完全不用考虑?观察上面这张“前缀和数组图表”,x轴表示下标索引,y轴表示前缀和的值,假设(我说假设)现在下标4是个满足条件的左边界,那么与它值相同的下标0肯定也是满足条件的左边界,并且比下标4更左,那么(0,4]之间的节点其实就都没有被比较的必要,可以直接跳过它们来提升效率。
操作上,需要先计算出一个前缀和递减的单调栈。因为最终是用下标来计算子数组长度,所以栈里存的是数组下标。我们将最左端下标(数组起始位置)作为初始的栈顶,下一个要推入栈中的下标得保证它对应的前缀和值小于当前栈顶下标所对应的前缀和值,即if(preSum[i] < preSum[stack[stack.length - 1]]) stack.push(i);。当前示例中,遍历后得到的递减单调栈结果是[0, 5, 6].
如此一来,我们就只需要从这3个节点中找出最左端的边界点了。
完整代码如下,
/**
* @param {number[]} hours
* @return {number}
*/
var longestWPI = function (hours) {
let max = 0;
/**
STEP1: 计算前缀和数组
*/
const preSum = [0];
// 大于8记为1,否则记为-1;得到前缀和数组,便于计算原数组的区间和;
for (let i = 1; i <= hours.length; i++) {
preSum[i] = preSum[i - 1] + (hours[i - 1] > 8 ? 1 : -1);
}
/**
STEP2: 计算单调递减栈
*/
// 此时题目变为在preSum中求解a到b的距离,使得a<b且preSum[b] - preSum[a] > 0;
const idxStack = [0]; // 维护一个preSum值单调递减的下标栈;因为preSum中每个值之间都是连续性的,所以可以借助单调栈来优化比较效率。
for (let i = 0; i < preSum.length; i++) {
if (preSum[i] < preSum[idxStack[idxStack.length - 1]]) idxStack.push(i);
}
/**
STEP3: 确认右、左边界并计算max值
*/
for (
let i = preSum.length - 1;
i > max; // 如果i小于max,就没有再比较的必要,后续结果肯定无法得到大于当前max的值;
i--
) {
// 当满足右边界preSum值大于栈顶对应preSum值时,(弹出栈顶元素)继续比较以扩大左边界;
while (preSum[i] > preSum[idxStack[idxStack.length - 1]]) {
max = Math.max(max, i - idxStack.pop()); // 更新满足题意的最长区间
}
}
return max;
};
粗浅的小结
本题主要用到了2个通用技巧来解决问题:前缀和、单调栈。
前缀和的话,感觉比较容易想到应用场景,通常是为了能更快计算出一个子数组的各项和。先遍历一次数组求得前缀和数组,而后都能以O(1)时间复杂度计算出一个子数组的各项和。
关于单调栈,老实说,初次练习做这题时我根本想不到能利用一个叫单调递减栈的东西来加快对左边界的确认。包括现在回头看这个问题——“哪些场景下可以利用单调栈来优化”,我也有点总结不清楚:
题目里得涉及到求解2个节点之间的关系?涉及到求解离这个节点最近或最远的节点?……
可能还是我题做的太少。希望有思路清晰的jy(掘友)能具体分享下单调栈场景应用的总结。
然后下面我粗略整理了一些我已知的几道涉及单调栈技巧的题供大家找规律总结。
就先小结到这儿吧,继续努力。