文章目录
-
- [1、209 长度最小的子数组](#1、209 长度最小的子数组)
-
- [1. 观察切入点:为什么要用滑动窗口?](#1. 观察切入点:为什么要用滑动窗口?)
- [2. 核心原理:滑动窗口(毛毛虫算法)](#2. 核心原理:滑动窗口(毛毛虫算法))
- [3. 思路推演:逻辑如何一步步清晰?](#3. 思路推演:逻辑如何一步步清晰?)
- [4. 避坑指南:这里的陷阱在哪里?](#4. 避坑指南:这里的陷阱在哪里?)
- [2、3 无重复字符的最长子串](#2、3 无重复字符的最长子串)
- [3、 713 乘积小于K的子数组](#3、 713 乘积小于K的子数组)
-
- [1. 核心原理:乘法与加法的"起点"不同](#1. 核心原理:乘法与加法的“起点”不同)
- [2. 观察切入点:这行代码的"计数逻辑"](#2. 观察切入点:这行代码的“计数逻辑”)
- [3. 思路深度拆解:为什么要有 `if(k <= 1) return 0;`?](#3. 思路深度拆解:为什么要有
if(k <= 1) return 0;?) - [4. 避坑指南:数据溢出](#4. 避坑指南:数据溢出)
- 总结
- [4 904 水果成蓝](#4 904 水果成蓝)
-
- [1. 观察切入点:为什么要用哈希表?](#1. 观察切入点:为什么要用哈希表?)
- [2. 核心原理:结构体与"句柄"](#2. 核心原理:结构体与“句柄”)
- [3. 推演过程:代码逐段解析](#3. 推演过程:代码逐段解析)
-
- [A. 查找与入队(右窗口扩张)](#A. 查找与入队(右窗口扩张))
- [B. 满足条件的极限(左窗口收缩)](#B. 满足条件的极限(左窗口收缩))
- [C. 善后处理(内存回收)](#C. 善后处理(内存回收))
- [4. 避坑指南:哈希表的"暗号"](#4. 避坑指南:哈希表的“暗号”)
- [5. 总结:滑动窗口 + 哈希表的万能公式](#5. 总结:滑动窗口 + 哈希表的万能公式)
- [5、76 最小覆盖子串](#5、76 最小覆盖子串)
-
- [1. 观察切入点:为什么要用滑动窗口?](#1. 观察切入点:为什么要用滑动窗口?)
- [2. 核心原理:两个哈希表的对比](#2. 核心原理:两个哈希表的对比)
- [3. 思路推演:逻辑四部曲](#3. 思路推演:逻辑四部曲)
-
- [第一步:右移 `right`(扩张)](#第一步:右移
right(扩张)) - 第二步:判断收缩(找到可行解)
- [第三步:左移 `left`(优化)](#第三步:左移
left(优化)) - 第四步:重复上述步骤
- [第一步:右移 `right`(扩张)](#第一步:右移
- [4. 代码实现(C语言+数组哈希版)](#4. 代码实现(C语言+数组哈希版))
- [5. 避坑指南](#5. 避坑指南)
- 总结:滑动窗口的终极模板
- 杂记
-
- [1. 观察切入点:作为"从未找到"的信号灯](#1. 观察切入点:作为“从未找到”的信号灯)
- [2. 逻辑推演:为了第一次"成功记录"](#2. 逻辑推演:为了第一次“成功记录”)
- [3. 思路总结:状态机思维](#3. 思路总结:状态机思维)
- [4. 避坑指南:为什么不用 0?](#4. 避坑指南:为什么不用 0?)
1、209 长度最小的子数组
题目

代码:
c
int minSubArrayLen(int target, int* nums, int numsSize) {
int left = 0,right = 0;
int sum = 0;
int min_len = INT_MAX;
for(right = 0;right < numsSize;right++){
sum += nums[right];
while(sum >= target){
min_len = fmin(min_len,right-left+1);
sum -= nums[left];
left++;
}
}
return (min_len == INT_MAX) ?0:min_len;
}
时间复杂度:O(n)
空间复杂度:O(1)
1. 观察切入点:为什么要用滑动窗口?
-
关键词:连续子数组。题目要求找到"连续"的段,这意味着我们不能破坏原数组的顺序(不能排序)。
-
关键词:正整数。这是一个极其重要的信号。因为数组里全是正整数,所以:
-
当你在一个区间里多加一个数 ,和(Sum)一定会变大。
-
当你在一个区间里少减一个数 ,和(Sum)一定会变小。
-
结论:这种"单调性"使得我们可以通过移动区间的左右边界来控制"和"的大小,而不需要暴力枚举所有可能的起点和终点()。
2. 核心原理:滑动窗口(毛毛虫算法)
滑动窗口本质上是双指针 的一种进阶应用。你可以把它想象成一条在数组上爬行的毛毛虫:
- **右边界
right**:负责寻找"可行解"。它不断向右伸展,把数字吃进来,直到窗口内的和满足≥ target。 - **左边界
left**:负责寻找"最优解"。一旦窗口满足条件,它就尝试向右收缩,看看在减掉左边的数字后,剩下的部分是否依然满足条件且长度更短。
3. 思路推演:逻辑如何一步步清晰?
我们要解决的核心矛盾是:如何在保证和不低于 target 的前提下,让区间尽可能短?
- 初始化 :
left = 0,sum = 0,minLen = 无限大。 - 移动右边界 :遍历数组,将
nums[right]加入sum。 - 判断与收缩(关键步骤):
- **如果
sum >= target**: - 当前找到了一个满足条件的区间,计算长度
right - left + 1。 - 尝试更新
minLen。 - 最重要的一步 :将
nums[left]从sum中减去,并将left右移。 - 重复执行 :这是一个
while循环,因为可能减去一个数后,剩下的和依然>= target,我们要一直缩到不能再缩为止。
- 返回结果 :如果
minLen没被动过,说明不存在满足条件的子数组,返回 0。
4. 避坑指南:这里的陷阱在哪里?
- 陷阱一:用
if代替while。
很多初学者在判断sum >= target时用了if。这会导致漏掉最优解。例如target=7, nums=[1, 1, 1, 1, 7]。当右指针指到7时,左指针可能需要连续右移很多次才能找到最小长度1。 - 陷阱二:初始化
minLen。
如果你把minLen初始化为0,那么比较currentLen < minLen永远不会成立。应该初始化为一个比数组长度还要大的数(如INT_MAX)。 - 陷阱三:复杂度理解 。
虽然代码里有一个while嵌套在for循环里,但每个元素最多被right扫过一次,被left扫过一次。每个元素进出窗口各一次,所以时间复杂度是 O ( n ) O(n) O(n),而不是 O ( n 2 ) O(n^2) O(n2)。
2、3 无重复字符的最长子串
题目:

代码:
c
int lengthOfLongestSubstring(char* s) {
int left = 0,right = 0;
int cnt[128] = {};//数组,也可以用哈希,因为说了s由英文字母、数字、符号和空格组成
int count = 0;
for(right = 0;s[right] != '\0';right++){
int c = s[right];
cnt[c]++;
while(cnt[c] > 1){//窗口内有重复字符
cnt[s[left]]--;//移除窗口左端点字母
left++;//缩小窗口
}
count = fmax(count,right-left+1);//更新窗口长度最大值
}
return count;
}
时间复杂度:O(n)
空间复杂度:O(1)或者O(128)
3、 713 乘积小于K的子数组
题目:

代码:
c
int numSubarrayProductLessThanK(int* nums, int numsSize, int k) {
if(k <= 1) return 0;
int left = 0,right = 0;
int count = 0;
int prod=1;
for(right = 0;right < numsSize;right++){
prod *= nums[right];
while(prod >= k){
prod /= nums[left];
left++;
}
count += (right - left + 1);
}
return count;
}
时间复杂度:O(n)
空间复杂度:O(1)
为什么这里prod初始化为1而加法中count一般初始化为0?
这是一个关于代数恒等元(Identity Element)和逻辑初始化的经典问题。
1. 核心原理:乘法与加法的"起点"不同
在数学中,每一个运算都有一个"不改变结果"的初始值:
- 加法的恒等元是 0:任何数加上 0,结果不变()。
- 乘法的恒等元是 1:任何数乘以 1,结果不变()。
在你的代码中:
你正在计算乘积(prod)。
- 如果初始化为
prod = 0:那么无论你后面乘以什么数(0 * nums[right]),结果永远都是 0。这会导致你的循环逻辑完全失效。 - 如果初始化为
prod = 1:当你乘以第一个数nums[0]时,1 * nums[0]得到了数组的第一个数,这才是正确的累乘起点。
2. 观察切入点:这行代码的"计数逻辑"
这道题最精妙的地方在于 count += (right - left + 1);。我们要思考:为什么是加这个长度?
- 推演过程 :
假设当前的有效窗口是[left, right],比如[2, 5, 6]。
当我们把right指向的新元素包含进来时,新增加的满足条件的连续子数组有:
[6](只有新元素自己)[5, 6](新元素和它前一个)[2, 5, 6](新元素和它前两个)
正好是 3 个,也就是right - left + 1的长度。
3. 思路深度拆解:为什么要有 if(k <= 1) return 0;?
这也是你代码中非常鲁棒(Robust)的一点。
-
切入点 :题目要求乘积严格小于
k。 -
逻辑推导:
-
如果
k = 1:因为数组里全是正整数(),任何子数组的乘积至少是 1。不可能有乘积比 1 还小的子数组。 -
如果
k = 0:同理,正整数乘积不可能小于 0。 -
结论 :如果没有这个判断,当
k=1时,while(prod >= k)会陷入死循环(因为1 >= 1永远成立,left会一直加到越界)。这行代码不仅是优化,更是防线。
4. 避坑指南:数据溢出
虽然这道题你写对了,但在实际应用中要小心:
- 风险 :
prod是一直累乘的。如果数组里有很多大数,prod可能会超过int能表示的最大范围(约 )。 - 对策 :在某些题目中,可能需要将
prod定义为long long。
总结
- 乘法初始化用 1,加法初始化用 0。
- 滑动窗口计数 :
right - left + 1是统计"以right结尾的所有合法子数组数量"的常用公式。
你现在已经掌握了滑动窗口的:
- 求最小长度(209题)
- 求最大长度(3题)
- 求子数组个数(713题)
这三种模式涵盖了滑动窗口 80% 的考点!
4 904 水果成蓝
题目

滑动窗口的套路其实就是:
"右指针无脑进,左指针按需缩,中间过程存结果"
代码
c
int totalFruit(int* fruits, int fruitsSize) {
int left = 0,right = 0;
int count = 0;
int n = fruitsSize;
//int cnt[n+1] = {};
int* cnt = (int*)calloc(sizeof(int) , (fruitsSize+1));
if(cnt == NULL) return 0;//增加鲁棒性
int kinds = 0;
for(right = 0;right < n;right++){
int a = fruits[right];
if(cnt[a] == 0){
kinds++;
}
cnt[a]++;
while(kinds > 2){
cnt[fruits[left]]--;
if(cnt[fruits[left]] == 0){
kinds--;
}
left++;
}
count = fmax(right - left + 1,count);
}
return count;
}
时间复杂度:O(n)
空间复杂度:O(n)或者O(fruitsSize+1)
使用哈希表版
c
//1.定义哈希表结构
struct HashTable{
int id;//键(水果种类)
int count;//值(该种类在窗口内的数量)
UT_hash_handle hh;//必选句柄
};
int totalFruit(int* fruits, int fruitsSize) {
struct HashTable* map = NULL;//初始化哈希表头
int left = 0,right = 0,maxLen = 0,kinds = 0;
for(right = 0;right < fruitsSize;right++){
int r_fruit = fruits[right];
struct HashTable* item;
//---窗口右扩:添加哈希项---
HASH_FIND_INT(map,&r_fruit,item);
if(item == NULL){
item = (struct HashTable*)malloc(sizeof(struct HashTable));
item->id = r_fruit;
item->count = 1;
HASH_ADD_INT(map,id,item);
kinds++;//发现新种类
}else{
item->count++;
}
//---窗口左缩:种类超过2 时执行---
while(kinds > 2){
int l_fruit = fruits[left];
struct HashTable* del_item;
HASH_FIND_INT(map,&l_fruit,del_item);
if(del_item){
del_item->count--;
if(del_item->count == 0){
//核心:计数归零必须从表里删除,kinds才会变
HASH_DEL(map,del_item);
free(del_item);
kinds--;
}
}
left++;
}
//更新结果
int currentLen = right - left + 1;
if(currentLen > maxLen){
maxLen = currentLen;
}
}
//---善后:释放哈希表内存
struct HashTable *curr,*tmp;
HASH_ITER(hh,map,curr,tmp){
HASH_DEL(map,curr);
free(curr);
}
return maxLen;
}
不懂哈希表的友友可以看看下面gimini老师的解释:
在 C 语言中,uthash 是一个非常流行的宏库,它允许你像使用高级语言(如 Python 的字典或 Java 的 HashMap)一样处理数据。
由于 C 语言本身没有内置哈希表,这段代码利用了 uthash 的宏 (如 HASH_FIND_INT)来实现**"快速查找"**。
1. 观察切入点:为什么要用哈希表?
在这道题里,我们要记录窗口内每种水果出现的次数。
- 数组法:只能处理水果编号是连续小整数的情况。
- 哈希表法 :水果编号可以是任何数字(比如
1和999999)。哈希表通过一个"哈希函数",把这些杂乱的编号映射到表里的位置,让你能在 时间内找到这个水果采了多少个。
2. 核心原理:结构体与"句柄"
c
struct HashTable {
int id; // 键 (Key):水果的编号(唯一的)
int count; // 值 (Value):这个水果在当前窗口里有多少个
UT_hash_handle hh; // 句柄:这是 uthash 的内部结构,不用管它,但必须写
};
把哈希表想象成一个记事本 ,每一页(struct)记录了一种水果的信息。hh 就像是装订线,把所有页面连在一起。
3. 推演过程:代码逐段解析
A. 查找与入队(右窗口扩张)
c
HASH_FIND_INT(map, &r_fruit, item); // 1. 在记事本 map 里找编号为 r_fruit 的页
if (item == NULL) { // 2. 没找到,说明是新种类
item = malloc(...); // 3. 拿一张新纸
item->id = r_fruit; // 4. 写上水果编号
item->count = 1; // 5. 初始数量为 1
HASH_ADD_INT(map, id, item); // 6. 把这张纸装订进记事本
kinds++; // 7. 种类加 1
} else {
item->count++; // 8. 找到了,直接在数量上 +1
}
B. 满足条件的极限(左窗口收缩)
当 kinds > 2 时,我们需要丢掉最左边的水果(left 指针移动)。
c
del_item->count--; // 1. 数量减 1
if (del_item->count == 0) { // 2. 如果减到 0,说明这种水果在窗口里消失了
HASH_DEL(map, del_item); // 3. 从记事本里撕掉这一页
free(del_item); // 4. 毁掉这张纸(释放内存)
kinds--; // 5. 种类减 1
}
注意 :在哈希表中,
count == 0的项必须删除。如果不撕掉这一页,即使数量是 0,程序依然会认为这种水果"存在",kinds就不会减少。
C. 善后处理(内存回收)
C 语言不会自动回收 malloc 的空间。
c
HASH_ITER(hh, map, curr, tmp) { // 遍历记事本的每一页
HASH_DEL(map, curr); // 撕掉
free(curr); // 销毁
}
如果你在力扣上不写这段,可能会报"内存泄漏"警告,虽然通常能过,但在实际开发中这是导致程序崩溃的头号元凶。
4. 避坑指南:哈希表的"暗号"
使用 uthash 有几个必须遵守的暗号:
- 初始化必为 NULL :
struct HashTable* map = NULL;必须赋值为NULL,否则宏会报错。 - 查找要传地址 :
HASH_FIND_INT的第二个参数必须是地址&r_fruit。 - Key 必须唯一 :哈希表里不能有两页的
id是一样的。
5. 总结:滑动窗口 + 哈希表的万能公式
这道题的逻辑其实可以套用到很多题:
- 进 :
right进场,哈希表记录(count++),如果是新 Key 则kinds++。 - 出 :当
kinds不满足条件,left出场,哈希表更新(count--),如果count == 0则彻底删除 并kinds--。 - 算 :每次记录窗口大小
right - left + 1。
你理解了为什么要特意执行 HASH_DEL 吗? 它是为了让 kinds 能正确地反映"当前窗口内真实存在的水果种类"。
5、76 最小覆盖子串
题目

代码
hard还是太难了~
c
bool is_covered(int cnt_s[],int cnt_t[]){
for(int i = 'A';i <= 'Z';i++){
if(cnt_s[i] < cnt_t[i]){
return false;
}
}
for(int i = 'a';i <= 'z';i++){
if(cnt_s[i] < cnt_t[i]){
return false;
}
}
return true;
}
char* minWindow(char* s, char* t) {
int cnt_s[128] = {};//s子串字母的出现次数
int cnt_t[128] = {};//t子串字母的出现次数
for(int i = 0;t[i];i++){
cnt_t[t[i]]++;
}
int ans_left = -1,ans_right = INT_MAX / 2;
int left = 0;
for(int right = 0;s[right];right++){//移动子串右端点
cnt_s[s[right]]++;//右端点字母移入子串
while(is_covered(cnt_s,cnt_t)){//涵盖
if(right - left < ans_right - ans_left){//找到更短的子串
ans_left = left;//记录此时的左右端点
ans_right = right;
}
cnt_s[s[left]]--;//左端点字母移出子串
left++;
}
}
if(ans_left < 0){
return "";
}
s[ans_right + 1] = '\0';
return s + ans_left;
}
时间复杂度:O(n)
空间复杂度:O(128)
灵神优化版
c
char* minWindow(char* s, char* t) {
int cnt[128] = {};
int less = 0;
for(int i = 0;t[i];i++){
if(cnt[t[i]] == 0){
less++;
}
cnt[t[i]]++;
}
int ans_left = -1;
int left = 0;
int ans_right = INT_MAX / 2;
for(int right = 0;s[right];right++){
int c = s[right];
cnt[c]--;
if(cnt[c] == 0){
less--;
}
while(less == 0){
if((right - left) < (ans_right - ans_left)){
ans_left = left;
ans_right = right;
}
if(cnt[s[left]] == 0){
less++;
}
cnt[s[left]]++;
left++;
}
}
if(ans_left < 0){
return "";
}
s[ans_right + 1] = '\0';
return s + ans_left;
}
力扣第 76 题 "最小覆盖子串" (Minimum Window Substring) 是滑动窗口题目中的"天花板"。它几乎融合了我们之前学过的所有技巧:双指针、哈希表计数、以及窗口的动态伸缩。
这道题的难度在于条件的复杂性 :你不仅要找包含 t 中所有字符的窗口,还要处理重复字符,并保证它是最短的。
1. 观察切入点:为什么要用滑动窗口?
- 目标 :寻找一段连续的区间(子串),使其满足某种特定条件(包含
t的所有字符)。 - 单调性:当你增加窗口右边界时,满足条件的可能性在增加;当你收缩左边界时,如果依然满足条件,窗口就变短了。
- 暴力代价:如果枚举所有子串,复杂度是 。使用滑动窗口可以将复杂度降为 。
2. 核心原理:两个哈希表的对比
我们需要两个"记事本"(哈希表):
need表 :记录字符串t中每个字符出现的次数(目标)。window表:记录当前滑动窗口中每个字符出现的次数(现状)。
**关键变量 valid**:
为了不每次都遍历整个哈希表来检查是否"覆盖",我们定义 valid 变量来记录:窗口内有多少个字符已经达到了 need 表中的要求数量。
3. 思路推演:逻辑四部曲
第一步:右移 right(扩张)
不断增加 right 指针扩大窗口。每进一个字符:
- 如果它是
t中的字符,更新window表。 - 如果
window中该字符的数量刚好等于need中的要求,valid++。
第二步:判断收缩(找到可行解)
当 valid == need中的字符种类数 时,说明窗口已经包含了 t 的所有字符。
第三步:左移 left(优化)
此时我们要找"最小"子串,所以尝试把 left 往右挪。
- 每挪动一次,更新
minLen。 - 每挪动一次,都要把
left处的字符从window中踢出。 - 如果踢出后,该字符的数量小于
need中的要求,则valid--。此时窗口不再满足条件,停止收缩。
第四步:重复上述步骤
直到 right 到达字符串末尾。
4. 代码实现(C语言+数组哈希版)
在 C 语言中,处理 ASCII 字符,直接用 int[128] 数组模拟哈希表效率最高。
c
char* minWindow(char* s, char* t) {
int need[128] = {0}, window[128] = {0};
int t_len = 0;
// 统计 t 中字符频率
for (int i = 0; t[i]; i++) {
if (need[t[i]] == 0) t_len++; // 统计有多少种不同的字符
need[t[i]]++;
}
int left = 0, right = 0;
int valid = 0; // 已达标的字符种类数
int start = 0, minLen = INT_MAX; // 记录最小窗口的起始位置和长度
while (s[right]) {
char c = s[right];
right++; // 窗口右移
// 更新窗口数据
if (need[c] > 0) {
window[c]++;
if (window[c] == need[c]) valid++;
}
// 判断窗口是否收缩
while (valid == t_len) {
// 更新最小覆盖子串
if (right - left < minLen) {
start = left;
minLen = right - left;
}
char d = s[left];
left++; // 窗口左移
// 更新窗口数据
if (need[d] > 0) {
if (window[d] == need[d]) valid--;
window[d]--;
}
}
}
if (minLen == INT_MAX) return "";
// 返回截取的子串
char* result = (char*)malloc(sizeof(char) * (minLen + 1));
strncpy(result, s + start, minLen);
result[minLen] = '\0';
return result;
}
5. 避坑指南
valid的更新逻辑 :一定要是window[c] == need[c]时才valid++。如果写成window[c] >= need[c],会导致valid多次增加。start指针的作用 :不要在循环里反复malloc或strncpy。只记录start和minLen,最后统一生成结果字符串。- 空字符串处理 :注意
minLen是否被更新过的逻辑判断。
总结:滑动窗口的终极模板
通过这道题,你可以总结出一个万能模板:
- 右指针进场,更新窗口数据。
- 收缩条件触发 (本题是
valid == t_len,209 题是sum >= target)。 - 左指针进场,尝试缩小窗口并寻找最优解。
- 循环往复。
至此,你已经完成了滑动窗口专题的全部"通关"!
杂记
在第五题中int ans_left = -1;为什么要令这个等于-1?
在算法编程中,将 ans_left 初始化为 -1 是一个非常经典且严谨的**"标记位"**设计。它的作用主要体现在以下两个方面:
1. 观察切入点:作为"从未找到"的信号灯
滑动窗口的目标是寻找满足条件的子串。但在实际运行中,可能会出现整个字符串 s 都不包含 t 的所有字符的情况。
- **初始化为
-1**:这是一个无效的数组索引。 - 逻辑判断 :在代码最后,我们检查
if (ans_left < 0)。 - 如果
ans_left依然是-1,说明整个循环跑完都没有一次进过while里的if判断(即没找到可行解)。 - 这时我们直接返回空字符串
""。
如果没有这个初始化:
如果你令 ans_left = 0,而在寻找过程中一次都没找到满足条件的子串,代码最后可能会错误地返回从索引 0 开始的一段字符串,导致结果错误。
2. 逻辑推演:为了第一次"成功记录"
在你的更新逻辑中:
c
if ((right - left) < (ans_right - ans_left)) {
ans_right = right;
ans_left = left;
}
- 第一次找到可行解时 :
由于ans_right初始化为一个极大的值(INT_MAX/2),而ans_left是-1,这个减法结果ans_right - (-1)会是一个巨大的正数。 - 结果 :这保证了只要找到第一个满足条件的子串,
if条件一定成立 ,从而把第一个真实有效的left和right存入这两个变量中,覆盖掉初始的-1。
3. 思路总结:状态机思维
在 C 语言处理这类搜索问题时,通常遵循这个套路:
- 初始态 :设置一个不可能的标志(如
-1或NULL)。 - 寻找态:如果找到,更新标志为真实值。
- 结果态:通过判断标志是否被修改过,来决定是返回结果还是返回"未找到"。
4. 避坑指南:为什么不用 0?
在数组操作中,0 是一个合法索引 (代表第一个元素)。
如果你用 0 作为初始值,你就分不清:
- "结果是真的从索引
0开始" - "还是根本就没找到结果,只是沿用了初始值"
这就是为什么程序员喜欢用 -1 来代表**"不存在"**。
至此,滑动窗口所有的"潜规则"你都掌握了! 这种对初始值的敏感度是成为高级程序员的必备素质。