前端算法实战:用JS解决力扣【2348. 全0子数组的数目】------从暴力到最优的思考过程
引言
今天我们来聊一道在前端面试中可能让你眼前一亮的力扣题目------2348. 全0子数组的数目。这道题看似简单,实则蕴含着动态规划和数学归纳的巧妙结合,掌握它不仅能帮你应对面试,更能让你对数组问题有更深层次的理解。今天,我将带大家从前端视角出发,一步步拆解这道题,从最直观的暴力解法,到最终的数学优化,让你彻底掌握这类问题的解题精髓。
题目分析
- 原题链接 :力扣 2348. 全0子数组的数目

-
题目大意:
给定一个整数数组
nums
,我们需要找出其中所有由连续的0
组成的子数组的数目。子数组必须是非空的。 -
输入输出:
- 输入 :一个整数数组
nums
。 - 输出 :一个整数,表示全部为
0
的子数组的数目。
- 输入 :一个整数数组
-
约束条件:
1 <= nums.length <= 10^5
-10^9 <= nums[i] <= 10^9
-
示例演示:
示例 1: 输入:
nums = [1,3,0,0,2,0,0,4]
输出:6
解释:- 子数组
[0]
出现了 4 次。 - 子数组
[0,0]
出现了 2 次。 - 不存在长度大于 2 的全
0
子数组,所以我们返回6
。
示例 2: 输入:
nums = [0,0,0,2,0,0]
输出:9
解释:- 子数组
[0]
出现了 5 次。 - 子数组
[0,0]
出现了 3 次。 - 子数组
[0,0,0]
出现了 1 次。 - 不存在长度大于 3 的全
0
子数组,所以我们返回9
。
示例 3: 输入:
nums = [2,10,2019]
输出:0
解释: 没有全0
子数组,所以我们返回0
。分步计算示例2: 对于
nums = [0,0,0,2,0,0]
:-
连续的
0
序列有两段:[0,0,0]
和[0,0]
。 -
对于
[0,0,0]
这段:- 长度为 1 的
[0]
子数组有 3 个。 - 长度为 2 的
[0,0]
子数组有 2 个。 - 长度为 3 的
[0,0,0]
子数组有 1 个。 - 这一段总共有
3 + 2 + 1 = 6
个全0
子数组。
- 长度为 1 的
-
对于
[0,0]
这段:- 长度为 1 的
[0]
子数组有 2 个。 - 长度为 2 的
[0,0]
子数组有 1 个。 - 这一段总共有
2 + 1 = 3
个全0
子数组。
- 长度为 1 的
-
总计:
6 + 3 = 9
个全0
子数组。
- 子数组
思路推导
这道题的核心在于如何高效地统计连续的 0
序列所能形成的全 0
子数组的数目。我们来一步步推导。
笨方法尝试:暴力枚举(不可行)
最直观的想法是暴力枚举所有的子数组,然后判断每个子数组是否全部由 0
组成。如果一个数组的长度为 n
,那么它的子数组数量大约是 n^2
个。对于每个子数组,我们还需要遍历一遍来判断是否全为 0
,这又是一个 O(n)
的操作。因此,总的时间复杂度将达到 O(n^3)
。考虑到题目中 n
的最大值是 10^5
,10^5
的三次方是 10^15
,这在力扣上是绝对会超时的。所以,暴力枚举是不可行的。
优化方向:减少重复计算
既然暴力枚举不行,我们就需要寻找更高效的方法。仔细观察题目,我们发现全 0
子数组的形成,只与连续的 0
有关。当遇到非 0
元素时,连续的 0
序列就会中断。这提示我们可以将问题分解为处理一段段连续的 0
。
最优思路选择:数学归纳法
假设我们发现了一段连续的 k
个 0
。这段 0
可以形成多少个全 0
子数组呢?
- 长度为 1 的全
0
子数组有k
个(每个0
自身)。 - 长度为 2 的全
0
子数组有k-1
个(例如[0,0]
)。 - 长度为 3 的全
0
子数组有k-2
个(例如[0,0,0]
)。 - ...
- 长度为
k
的全0
子数组有 1 个(整个k
个0
组成的子数组)。
将这些数目加起来,就是一个等差数列的和:1 + 2 + ... + k = k * (k + 1) / 2
。
所以,我们的思路就清晰了:
- 遍历数组
nums
。 - 维护一个计数器
count
,记录当前连续0
的个数。 - 当遇到
0
时,count
加 1。 - 当遇到非
0
元素时,或者遍历到数组末尾时,说明一段连续的0
结束了。此时,我们将count * (count + 1) / 2
加到总结果中,并将count
重置为0
。
思路具象化:小例子辅助推演
我们以 nums = [0,0,0,2,0,0]
为例进行推演:
-
初始化
total_subarrays = 0
,count = 0
。 -
遍历
nums[0] = 0
:count
变为 1。 -
遍历
nums[1] = 0
:count
变为 2。 -
遍历
nums[2] = 0
:count
变为 3。 -
遍历
nums[3] = 2
(非0
):- 连续
0
序列结束,count = 3
。 total_subarrays += 3 * (3 + 1) / 2 = 3 * 4 / 2 = 6
。count
重置为 0。
- 连续
-
遍历
nums[4] = 0
:count
变为 1。 -
遍历
nums[5] = 0
:count
变为 2。 -
遍历结束(数组末尾):
- 连续
0
序列结束,count = 2
。 total_subarrays += 2 * (2 + 1) / 2 = 2 * 3 / 2 = 3
。count
重置为 0。
- 连续
-
最终
total_subarrays = 6 + 3 = 9
。
这与示例 2 的结果完全一致。这种方法只需要一次遍历,时间复杂度为 O(n)
,空间复杂度为 O(1)
,非常高效。
代码实现
根据上述思路,我们可以用 JavaScript 实现如下代码。为了符合前端最佳实践,我们使用 let/const
和箭头函数,并附上逐行注释。
ini
/**
* @param {number[]} nums
* @return {number}
*/
var zeroFilledSubarray = function(nums) {
let totalSubarrays = 0; // 用于存储最终的全0子数组总数
let currentZeroCount = 0; // 用于记录当前连续0的个数
// 遍历整个数组
for (let i = 0; i < nums.length; i++) {
if (nums[i] === 0) {
// 如果当前元素是0,连续0的计数器加1
currentZeroCount++;
} else {
// 如果当前元素不是0,说明连续0的序列中断了
// 根据数学归纳法,将当前连续0的个数所能形成的全0子数组数量累加到总数中
// 公式:k * (k + 1) / 2
totalSubarrays += (currentZeroCount * (currentZeroCount + 1)) / 2;
// 重置连续0的计数器
currentZeroCount = 0;
}
}
// 循环结束后,如果数组是以0结尾,或者整个数组都是0,需要将最后一段连续0的子数组数量累加
totalSubarrays += (currentZeroCount * (currentZeroCount + 1)) / 2;
return totalSubarrays;
};
代码说明:
-
初始化 :
totalSubarrays
用于累加所有全0
子数组的数量,currentZeroCount
用于统计当前连续0
的个数,两者都初始化为0
。 -
遍历逻辑 :我们遍历
nums
数组的每一个元素。- 如果
nums[i]
是0
,说明连续0
的序列还在继续,currentZeroCount
简单地加1
。 - 如果
nums[i]
不是0
,这意味着当前的连续0
序列已经结束了。此时,我们利用前面推导出的公式k * (k + 1) / 2
(其中k
就是currentZeroCount
)计算出这段连续0
所能形成的全0
子数组数量,并将其累加到totalSubarrays
中。然后,将currentZeroCount
重置为0
,为下一段连续0
的统计做准备。
- 如果
-
边界处理 :循环结束后,需要特别注意。如果数组的最后一个元素是
0
,或者整个数组都是0
,那么在循环结束时,currentZeroCount
可能不为0
。这意味着还有一段连续的0
序列没有被计算。因此,在循环结束后,我们还需要再执行一次累加操作,确保所有连续0
序列都被正确计算。
优化提升
原代码分析:
- 时间复杂度 :我们只对数组进行了一次遍历,每次操作都是常数时间。因此,时间复杂度为
O(n)
,其中n
是数组nums
的长度。这对于n
达到10^5
的情况来说,是非常高效的。 - 空间复杂度 :我们只使用了
totalSubarrays
和currentZeroCount
两个变量来存储中间结果,没有使用额外的数组或复杂的数据结构。因此,空间复杂度为O(1)
。这也是最优的空间复杂度。
优化点:
这道题的解法已经非常高效,在时间和空间上都达到了最优。如果题目要求「返回全 0
子数组的具体内容」(而不是数量),那么我们需要在遍历过程中记录每个连续 0
序列的起始和结束索引,并根据公式生成对应的子数组。但这会增加空间复杂度,因为需要存储这些子数组。
拓展:
这类问题可以有很多变种,例如:
- 如果要求「全
X
子数组的数目」怎么办? 思路是完全一样的,只需要将判断条件nums[i] === 0
改为nums[i] === X
即可。 - 如果要求「全
0
子数组的最大长度」怎么办? 只需要在遍历过程中,维护一个最大连续0
的长度即可。 - 如果数组是二维的(比如「最大全
0
子矩阵」) :这类问题通常会复杂很多,可能需要将二维问题转化为一维问题来解决,或者使用更复杂的动态规划。
面试总结
考点提炼:
这道题的核心考点在于对连续子数组问题 的理解和数学归纳法的应用。面试官可能希望看到你:
- 问题分解能力 :能否将复杂问题分解为处理独立的连续
0
序列。 - 数学思维 :能否发现连续
k
个0
形成k * (k + 1) / 2
个全0
子数组的规律。 - 代码实现能力:能否将思路清晰地转化为高效、简洁的 JavaScript 代码。
- 边界条件处理 :能否考虑到数组末尾的连续
0
序列的计算。
技巧总结:
- 遇到涉及连续子数组 的问题,尤其是需要统计或计算其属性时,可以优先考虑滑动窗口 、动态规划 或数学归纳法 。本题通过数学归纳法将问题简化为对连续
0
序列的计数,避免了复杂的动态规划状态转移。 - 在面试中,如果能清晰地阐述从暴力解法到最优解法的思考过程 ,并用小例子进行推演,会给面试官留下深刻印象。
类似题目:
- 力扣 413. 等差数列划分:这道题也是统计连续子数组(等差数列)的数目,思路与本题有异曲同工之妙,都可以通过数学归纳法来解决。
- 力扣 670. 最大交换:虽然题目类型不同,但都涉及到对数字序列的分析和操作,可以锻炼对数组和数字的敏感度。
结尾互动
你们在做这道题的时候有没有遇到什么坑?比如在处理连续 0
序列的边界条件时?或者有没有想到其他更巧妙的解法?欢迎在评论区告诉我!
如果想练习更多类似的算法题目,或者对前端算法面试有任何疑问,欢迎关注我,后续会更新更多前端算法实战内容,助你轻松通关算法面试!