动态规划思想的延伸:计数模式再升级——巧妙捕捉「优美子数组」
哈喽,各位,我是前端L。
在之前的探险中,“前缀和 + 哈希表”这对黄金搭档,已经帮助我们攻克了“和为K”(LC 560)以及“和是K的倍数”(LC 523)这两大类子数组计数难题。它们的威力在于,能将 O(n²) 的暴力枚举,优雅地降维到 O(n)。
今天,我们将要面对一个它的“变装”版本——“优美子数组”。标准不再是“和”,而是子数组中奇数的个数。但你将惊奇地发现,问题的核心结构,几乎没有改变!同时,我们还将学习一种全新的、基于滑动窗口的计数技巧,进一步拓宽我们的解题思路。
力扣 1248. 統計「优美子数组」
https://leetcode.cn/problems/count-number-of-nice-subarrays/

题目分析: 给定一个整数数组 nums 和一个整数 k,找到连续子数组的个数,使得子数组中恰好包含 k 个奇数。
-
核心:只关心奇偶性,不关心具体数值。
-
目标:计数,有多少个这样的子数组。
思路一:“前缀和 + 哈希表”的完美复刻 (O(n) 时间, O(n) 空间)
这道题和“和为K的子数组”(LC 560)简直是“异父异母的亲兄弟”!
1. 思想的“平移”:
-
在 LC 560 中,我们关心的是元素和。我们用了前缀和
preSum。 -
在本题中,我们关心的是奇数个数。我们只需要用前缀奇数个数
preOddCount来替换!
2. DP状态定义 (隐式): preOddCount[i] 表示:nums 数组的前 i 个元素 (nums[0...i-1]) 中,奇数的总个数。
3. 核心公式推导: 子数组 nums[l...r] 中奇数的个数 = preOddCount[r+1] - preOddCount[l]。 我们要找的是,有多少对 (l, r) 使得 preOddCount[r+1] - preOddCount[l] == k。 移项得: preOddCount[l] == preOddCount[r+1] - k
4. 算法流程 (与LC 560几乎一致):
-
初始化哈希表
oddCountFreq = { {0, 1} },存储{前缀奇数个数 -> 出现次数}。{0, 1}处理从头开始的子数组。 -
初始化
count = 0,currentOddCount = 0。 -
遍历数组
nums(下标r): a. 如果nums[r]是奇数,currentOddCount++。 b. 计算目标前缀奇数个数target = currentOddCount - k。 c. 查找哈希表:如果target存在于oddCountFreq中,count += oddCountFreq[target]。 d. 更新哈希表:oddCountFreq[currentOddCount]++。 -
返回
count。
代码实现 (前缀奇数计数 + 哈希表):
#include <vector>
#include <unordered_map>class Solution {
public:int numberOfSubarrays(vector<int>& nums, int k) {int count = 0;int currentOddCount = 0;// 存储 {前缀奇数个数 -> 出现次数}unordered_map<int, int> oddCountFreq;// 初始化:奇数个数为0的前缀出现了1次(空前缀)oddCountFreq[0] = 1;for (int num : nums) {// 更新当前前缀奇数个数if (num % 2 != 0) { // is oddcurrentOddCount++;}// 寻找 target = currentOddCount - kint target = currentOddCount - k;// 如果 target 存在于 map 中,累加其出现次数if (oddCountFreq.count(target)) {count += oddCountFreq[target];}// 将当前的前缀奇数个数加入 mapoddCountFreq[currentOddCount]++;}return count;}
};
思路二:“差分”的智慧——滑动窗口解“恰好K” (O(n) 时间, O(1) 空间)
我们知道,滑动窗口特别擅长处理“最多K”或“最少K”的问题,但直接处理“恰好K”比较棘手。但是,我们可以运用一个极其巧妙的数学思想: 恰好 k 个 = 最多 k 个 - 最多 k-1 个
count(exactly k) = count(atMost k) - count(atMost k-1)
现在,问题转化为了:如何用滑动窗口,计算一个数组中有多少个子数组,其奇数个数最多为 k?
atMostK(nums, k) 函数的实现:
-
初始化
left = 0,oddCountInWindow = 0,result = 0。 -
用
right指针遍历数组(扩张窗口): a. 如果nums[right]是奇数,oddCountInWindow++。 b. 收缩窗口:只要oddCountInWindow > k,就不断收缩左边界: i. 如果nums[left]是奇数,oddCountInWindow--。 ii.left++。 c. 计数 (核心!):当窗口[left, right]满足oddCountInWindow <= k后,所有以right结尾,且起点i满足left <= i <= right的子数组,都满足条件! * 这些子数组的个数是right - left + 1。 * 将这些新的有效子数组计入结果:result += (right - left + 1)。 d.right++。 -
返回
result。
最终答案: 调用 atMostK(nums, k) 和 atMostK(nums, k - 1),然后相减。
代码实现 (滑动窗口 + 差分):
class Solution {
public:int numberOfSubarrays(vector<int>& nums, int k) {// 恰好 k = 最多 k - 最多 k-1return atMostK(nums, k) - atMostK(nums, k - 1);}private:// 计算 nums 中奇数个数 最多 为 k 的子数组个数int atMostK(vector<int>& nums, int k) {int n = nums.size();int left = 0, oddCount = 0, result = 0;for (int right = 0; right < n; ++right) {if (nums[right] % 2 != 0) {oddCount++;}// 当窗口内奇数个数超过 k 时,收缩左边界while (oddCount > k) {if (nums[left] % 2 != 0) {oddCount--;}left++;}// 此时窗口 [left, right] 满足条件// 以 right 结尾的有效子数组个数为 right - left + 1result += (right - left + 1);}return result;}
};
总结:算法模型的“泛化”与“特化”
今天这道题,再次印证了算法世界一个深刻的道理:
许多看似不同的问题,其底层可能共享着同一个数学或逻辑模型。
-
前缀和 + 哈希表:是解决“子数组和/计数等于(或模等)K”问题的泛化利器。本题只是将“求和”变成了“计数奇数”,模型完美适用。
-
滑动窗口 + 差分:是解决“恰好K”问题的另一种特化技巧,尤其在空间复杂度上更胜一筹 (O(1))。
掌握识别这些底层模型,并根据问题的细微差别(等于K vs K的倍数 vs 恰好K个奇数),灵活选择或调整武器,是你从“解题”走向“算法设计”的关键一步。
咱们下期见~
