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

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

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

写在最后

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

相关推荐
你撅嘴真丑5 小时前
第九章-数字三角形
算法
在路上看风景5 小时前
19. 成员初始化列表和初始化对象
c++
uesowys5 小时前
Apache Spark算法开发指导-One-vs-Rest classifier
人工智能·算法·spark
zmzb01036 小时前
C++课后习题训练记录Day98
开发语言·c++
ValhallaCoder6 小时前
hot100-二叉树I
数据结构·python·算法·二叉树
执笔论英雄6 小时前
【大模型学习cuda】入们第一个例子-向量和
学习
董董灿是个攻城狮6 小时前
AI 视觉连载1:像素
算法
wdfk_prog6 小时前
[Linux]学习笔记系列 -- [drivers][input]input
linux·笔记·学习
念风零壹6 小时前
C++ 内存避坑指南:如何用移动语义和智能指针解决“深拷贝”与“内存泄漏”
c++
智驱力人工智能6 小时前
小区高空抛物AI实时预警方案 筑牢社区头顶安全的实践 高空抛物检测 高空抛物监控安装教程 高空抛物误报率优化方案 高空抛物监控案例分享
人工智能·深度学习·opencv·算法·安全·yolo·边缘计算