力扣hot100-子串(C++)

今天在LeetCode上主要研究了两道子串相关的题目:一道是关于统计和为特定值的子数组个数,另一道是关于滑动窗口的最大值。这两道题看起来都属于数组操作范畴,但在解法上却各有千秋,让我对不同类型的子串问题有了更深入的理解。

一、和为K的子数组

1.1思路解析

最初看到这道题时,我的第一反应是使用双指针滑动窗口。毕竟"子数组"这个概念天然就带有连续性的特征,而滑动窗口在处理连续区间问题上通常是个不错的选择。窗口内的元素和如果小于K,就扩大窗口;如果大于K,就缩小窗口;当和恰好等于K时,就记录结果。这个思路在纸上模拟小例子时看起来挺完美。

但当我尝试用代码实现时,很快就发现了问题。如果数组中的元素并不全是正数------这意味着即使当前窗口的和大于K,缩小窗口也不一定能减小总和,因为可能会减去一个负数,反而使总和增大。同样,当窗口和小于K时,扩大窗口也不一定能增加总和。这种不确定性让滑动窗口的单调性假设失效了。

比如数组[1, -1, 0]中寻找和为0的子数组,正确结果应该是3个([1, -1][-1, 0][0]),但滑动窗口算法只能找到2个,因为它无法正确处理当窗口扩展到包含负数后的收缩逻辑。

这让我意识到,不是所有看起来适合滑动窗口的问题都真的适合滑动窗口。滑动窗口的核心前提是"窗口的扩大或缩小能单调地改变某个指标",而在这道题中,由于负数的存在,窗口的和不再随窗口大小变化而单调变化。

那么回溯呢?毕竟回溯能探索所有可能性。但我很快放弃了这个想法。回溯算法适合解决组合、排列、子集这类需要枚举所有可能性的问题,而这里的子数组要求是连续的。如果用回溯来生成所有可能的子数组,就需要维护额外的状态来确保连续性,这会让算法变得异常复杂。更重要的是,回溯的时间复杂度非常高,相当于是翻版的暴力解题。

此题的突破点事子数组nums[i..j]的和其实就是前缀和prefix[j]减去前缀和prefix[i-1]。那么问题就转化为:有多少对(i, j)使得prefix[j] - prefix[i-1] = K,对于每个位置j,我需要知道在前面有多少个位置i满足prefix[i] = prefix[j] - K

这个转化把原问题从一个二维的区间搜索问题,降维成了一个一维的查找问题。只需要一次遍历数组,计算当前位置的前缀和,然后检查之前有多少个前缀和等于当前前缀和减去K。为了快速查找,可以用哈希表来记录每个前缀和出现的次数。

1.2完整代码

cpp 复制代码
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        unordered_map<int, int> mp; // 前缀和 -> 出现次数
        mp[0] = 1; // 前缀和为0出现1次(重要!)
        
        int sum = 0, count = 0;
        for (int num : nums) {
            sum += num; // 当前前缀和
            
            // 如果存在前缀和 sum-k,说明存在子数组和为k
            if (mp.find(sum - k) != mp.end()) {
                count += mp[sum - k];
            }
            
            // 记录当前前缀和出现的次数
            mp[sum]++;
        }
        
        return count;
    }
};

1.3特别注意

当子数组从数组开头开始时,前缀和prefix[i-1]中的i-1会是-1,这意味着需要考虑前缀和为0的情况。所以在初始化哈希表时,需要加入{0: 1},表示前缀和为0已经出现了1次。

二、滑动窗口最大值

2.1思路解析

第二道题要求滑动窗口中的最大值,这看起来比前一道题更直观地适合滑动窗口算法。但当我尝试用简单的双指针暴力求解时,很快就意识到问题所在:对于每个窗口,我都要重新扫描窗口内的所有元素来找到最大值,这样太慢了会超时。

cpp 复制代码
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        if (nums.empty() || k == 0) return {};
        
        vector<int> result;
        int n = nums.size();
        
        for (int i = 0; i <= n - k; i++) {
            int maxVal = nums[i];
            for (int j = i; j < i + k; j++) {
                maxVal = max(maxVal, nums[j]);
            }
            result.push_back(maxVal);
        }
        
        return result;
    }
};

此题需要一种能快速获取当前窗口最大值的方法,同时还要能在窗口滑动时高效地更新这个信息。这就非常适合用单调队列。

单调队列的核心思想是维护一个队列,队列中的元素保持单调性(这里是单调递减),这样队列的头部总是当前窗口的最大值。但维护这个队列需要一些技巧:当窗口滑动时,需要从队列中移除已经离开窗口的元素;当新元素加入窗口时,需要从队列尾部移除所有比新元素小的元素,因为这些元素不再可能成为后续窗口的最大值。

每个元素最多进入队列一次、离开队列一次,所以整体的时间复杂度是O(n)。这种用空间换时间、用数据结构来维护状态的思想,在很多算法问题中都有应用。

2.2完整代码

cpp 复制代码
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> result;
        deque<int> dq; // 双端队列,存储索引
        
        for (int i = 0; i < nums.size(); i++) {
            // 1. 移除窗口外的元素
            if (!dq.empty() && dq.front() == i - k) {
                dq.pop_front();
            }
            
            // 2. 维护单调递减队列
            while (!dq.empty() && nums[dq.back()] < nums[i]) {
                dq.pop_back();
            }
            
            // 3. 加入当前元素索引
            dq.push_back(i);
            
            // 4. 当窗口形成时,记录最大值
            if (i >= k - 1) {
                result.push_back(nums[dq.front()]);
            }
        }
        
        return result;
    }
};

2.3特别注意

  • 队列中存储的是元素的索引而不是元素值,这样才能判断一个元素是否还在当前窗口中
  • 当窗口还没有形成时(即遍历的前k-1个元素),只构建队列而不记录结果
  • 每次窗口滑动时,都要先检查队列头部的元素是否还在窗口中

三、两类问题的对比与思考

这两道题虽然都涉及子串或子数组,但在解法上却走向了两个不同的方向。

和为K的子数组问题:不能仅凭问题的表面特征就选择算法。滑动窗口看起来很合适,但由于负数的存在,它实际上不适用。这提醒我在选择算法时要深入分析问题的本质特征,而不仅仅是表面形式。前缀和+哈希表的解法展示了如何通过数学转化将问题重新表述,从而找到更高效的解决方案。

滑动窗口最大值问题:展示了如何针对问题的特定需求设计专用的数据结构。单调队列虽然不是标准的数据结构,但它完美契合了这个问题的需求:既要快速获取最大值,又要高效地更新状态。

两道题都让我深刻体会到,在算法学习中,理解问题的本质比记住解法更重要。每个问题都有其独特的结构和约束,而优秀的算法正是能够充分利用这些特征的设计。下一次遇到类似问题时,我会更加仔细地分析:这个问题的数据有什么特性?哪些操作需要高效?现有的数据结构能否直接应用,还是需要组合或改造?

写在最后

从暴力解法开始思考是一个好习惯,它帮助我理解问题的难点在哪里;然后尝试优化,这让我看到不同解法之间的本质差异;最后实现和调试,这让我注意到各种边界情况和细节。这样的学习过程虽然比直接看答案要慢,但收获却大得多。

相关推荐
jiaguangqingpanda3 小时前
Day29-20260125
java·数据结构·算法
POLITE33 小时前
Leetcode 437. 路径总和 III (Day 16)JavaScript
javascript·算法·leetcode
June`4 小时前
FloodFill算法:图像处理与游戏开发利器
算法·深度优先·floodfill
wWYy.4 小时前
算法:四数相加||
算法
●VON4 小时前
从系统亮度监听到 UI 重绘:Flutter for OpenHarmony TodoList 深色模式的端到端响应式实现
学习·flutter·ui·openharmony·布局·von
新-code4 小时前
ros学习
学习·机器人
新能源BMS佬大4 小时前
【仿真到实战】STM32落地EKF算法实现锂电池SOC高精度估算(含硬件驱动与源码)
stm32·嵌入式硬件·算法·电池soc估计·bms电池管理系统·扩展卡尔曼滤波估计soc·野火开发板
wen__xvn4 小时前
模拟题刷题2
算法
AI 菌4 小时前
DeepSeek-OCR 解读
人工智能·算法·计算机视觉·大模型·ocr