如果你用C语言刷过LeetCode,大概率听过这句话:别用C刷题,容器都没有,纯属给自己找不痛快。说实话,我一开始也是这么想的。直到我把HOT 100里的滑动窗口题全部用C语言写完,才发现这个说法至少对滑动窗口这类题完全站不住脚——滑动窗口的核心难点从来不是有没有现成的哈希表和队列,而是边界条件的控制和窗口状态的维护,而这两件事恰恰是C语言最能让你思考透彻的地方。
这篇日志记录的是我用C语言刷完LeetCode HOT 100中滑动窗口相关题目后的全部沉淀,包括一套可以直接套用的C语言模板、三道必刷题的手写拆解、七个我反复踩过的坑,以及最后从算法卷到工程实践的延伸思考。适合准备算法面试的C语言使用者,也适合刚入门滑动窗口但不想只停留在“看懂了”这个层面的读者。
1. 写在前面:C语言刷滑动窗口,到底亏不亏?
先说结论:不亏,而且刷完之后你对滑动窗口的理解会比用Python刷的同学深一截。
原因很简单。滑动窗口类题目本质上考察的是两个能力:一是能不能把问题抽象成“一个连续区间内的状态维护”,二是能不能把这个状态在窗口滑动时高效地更新。第一个能力靠的是思维模型,第二个能力在C语言里最见功力。Python里一行collections.Counter就能搞定的事,在C语言里你得自己设计计数数组、自己处理窗口收缩的时机、自己管理内存,这反而逼着你去理解每一行代码背后的数据流动。
HOT 100里面,滑动窗口相关的题目数量不算多,但每一道都是典型。以我刷下来的经验看,基本可以分为三类:
| 类型 | 代表题目 | 核心考点 |
|---|---|---|
| 可变窗口求极值 | 无重复字符的最长子串(3) | 收缩时机与状态更新 |
| 可变窗口匹配目标 | 最小覆盖子串(76) | 匹配计数的设计 |
| 固定窗口求最值 | 滑动窗口最大值(239) | 单调队列与双端结构 |
你会发现一个很有意思的现象:这三类题目,用常规循环也能解,但时间复杂度动不动就是O(nk)甚至O(n²)。滑动窗口能把它们压到O(n),靠的就是“右指针扩张、左指针收缩”这套范式,以及一个关键思维——窗口在移动时,不是重新计算整个窗口的内容,而是增量更新。
我在刷题过程中的体会是:滑动窗口的“为什么有效”其实和TCP里流量控制的滑动窗口是一脉相承的。网络里窗口滑动的目的是在有限的缓冲区里高效管理数据流,算法里窗口滑动的目的是在有限的区间里高效维护状态。这个观念通了之后,你再看那些题目,会发现它们本质上是在同一个问题上换了不同的包装。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一招吃遍HOT 100:滑动窗口的C语言通用骨架
我一开始刷的时候,每道题都从头想,写出来的代码七零八落。后来发现,滑动窗口题有一个几乎万能的结构,只要照着这个骨架走,思路不会乱。
2.1 可变窗口的通用模板
所谓可变窗口,就是窗口长度不固定,右指针一直往右扩张,左指针根据条件收缩。这类题解题框架是这个样子的:
c复制int slidingWindow(int* nums, int numsSize) {
int left = 0, right = 0; // 窗口为左闭右开 [left, right)
int result = 0;
// 初始化窗口状态,比如计数数组、当前和等等
while (right < numsSize) {
// 1. 扩张:将 nums[right] 纳入窗口
// 更新窗口状态
// 2. 收缩:当窗口不满足题目条件时
while (窗口不满足条件) {
// 将 nums[left] 移出窗口
// 更新窗口状态
left++;
}
// 3. 收缩完成后,窗口处于合法状态,更新答案
result = 更新结果(result, right - left + 1);
// 4. 右指针扩张
right++;
}
return result;
}
注意我用的是左闭右开区间[left, right),也就是right指向下一个要加入窗口的位置,窗口内实际包含的是nums[left]到nums[right - 1]。这个区间定义方式有个好处:当left == right时窗口为空,不会有“窗口内有一个元素但实际是空的”这种边界混乱。
收缩条件是整个框架的核心。什么时候收缩、收缩到什么程度,决定了这个代码能不能AC。比如求无重复字符的最长子串,收缩条件是“窗口内出现了重复字符”;求包含某集合所有字符的最短子串,收缩条件是“当前窗口已经包含了目标集合的全部字符”。
2.2 固定窗口的通用模板
固定窗口的题更简单,窗口长度k不变,两针同步移动:
c复制int fixedWindow(int* nums, int numsSize, int k) {
int left = 0, right = 0;
int result = 0;
// 窗口状态初始化
while (right < numsSize) {
// 1. 加入 nums[right]
// 2. 当窗口长度达到 k 时
if (right - left + 1 == k) {
// 更新答案
// 3. 将 nums[left] 移出窗口
// left++ 准备下一个窗口
}
right++;
}
return result;
}
固定窗口的代码看起来更短,但实际上有一个隐藏的陷阱:移出窗口和加入窗口的顺序不能乱。实际上滑窗的维护顺序很重要。先加入,再判断长度,满了就移除left并移动left,这样的顺序避免了重复代码。
这套骨架我刷了大概十道题之后,发现基本可以无脑套用。真正需要动脑的地方在于:每道题的“窗口状态”是什么、怎么更新、收缩条件是什么。下面就拿三道最典型的题目来拆解。
3. HOT 100滑动窗口题组实战拆解
3.1 无重复字符的最长子串(LeetCode 3):收缩时机的艺术
这道题是滑动窗口的入门必刷题,也是在面试中出现频率极高的一道。题目很简单:给定一个字符串,找出其中不含有重复字符的最长子串的长度。
暴力解法是枚举所有子串,逐个检查是否含重复字符,时间复杂度O(n²)。滑动窗口的做法是:维护一个窗口,右指针不断右移,把新字符纳入窗口;一旦发现窗口内出现重复字符,就把左指针右移,直到窗口重新合法。
这里有一个关键优化:左指针的移动不需要一步步来。因为重复字符的位置是确定的,我们可以直接记录每个字符上一次出现的位置,遇到重复时,把左指针跳到“重复字符上一次出现位置的下一位”。
c复制int lengthOfLongestSubstring(char* s) {
int last[128];
for (int i = 0; i < 128; i++) {
last[i] = -1; // 初始化:所有字符都没出现过
}
int left = 0; // 窗口左边界
int maxLen = 0;
for (int right = 0; s[right] != '\0'; right++) {
char c = s[right];
// 如果 c 在窗口内出现过,直接收缩左边界到上次出现位置的下一位
if (last[c] >= left) {
left = last[c] + 1;
}
last[c] = right; // 更新 c 最后一次出现的位置
int len = right - left + 1;
if (len > maxLen) {
maxLen = len;
}
}
return maxLen;
}
last[c] >= left这个判断很关键。它检查的是“这个字符上一次出现的位置是否还在当前窗口内”。如果last[c] < left,说明它上次出现的位置已经被移出了窗口,这次出现不产生重复,不需要收缩。
这里用数组代替哈希表,是因为字符范围是有限且已知的——ASCII字符一共128个。如果题目限定只有小写字母,也可以缩成26。C语言里没有内置哈希表,但很多字符串类滑窗题用定长数组就够了,这是一条非常实用的经验。
复杂度:O(n),每个字符最多被处理两次(一次进窗口、一次出窗口)。空间复杂度O(128),也就是O(1)。
3.2 最小覆盖子串(LeetCode 76):匹配计数的巧妙设计
这道题比上一道难了一个级别。题目是:给你一个字符串s、一个字符串t,返回s中涵盖t所有字符的最小子串。注意t中可能包含重复字符,比如t = "AABC",那么子串必须包含两个A、一个B、一个C。
我一开始想到的思路是用两个哈希表:一个记录t中每个字符的需求量,一个记录窗口内每个字符的拥有量,然后比较是否满足。但是C语言没有一个现成的“比较两个哈希表”的函数,每次都要O(128)遍历,总复杂度就变成O(128n),虽然也能过,但不优雅。
后来我学到一个技巧:不需要每次比较两个表,只需要维护一个计数器matched,表示“已经满足需求的字符种类数”。
c复制char* minWindow(char* s, char* t) {
int need[128] = {0};
int needCnt = 0; // t 中不同字符的种类数
for (int i = 0; t[i] != '\0'; i++) {
need[t[i]]++;
}
for (int i = 0; i < 128; i++) {
if (need[i] > 0) {
needCnt++;
}
}
int left = 0, right = 0;
int matched = 0; // 当前窗口中已经满足需求的字符种类数
int minLen = INT_MAX;
int start = 0;
while (s[right] != '\0') {
// 1. 扩展右边界
char c = s[right];
need[c]--; // 消费一个 c 的“配额”
if (need[c] == 0) {
matched++; // 说明 c 的需求已经满足
}
// 2. 当所有字符的需求都被满足时,开始收缩
while (matched == needCnt) {
int len = right - left + 1;
if (len < minLen) {
minLen = len;
start = left;
}
// 尝试收缩左边界
char lc = s[left];
if (need[lc] == 0) {
matched--; // 移出一个必需字符,需求不再满足
}
need[lc]++;
left++;
}
right++;
}
if (minLen == INT_MAX) {
return "";
}
char* result = (char*)malloc((minLen + 1) * sizeof(char));
strncpy(result, s + start, minLen);
result[minLen] = '\0';
return result;
}
这个need数组的用法有点反直觉,我第一次看到的时候想了很久。关键在于:need[c]的初始值是t中c的需求量,然后动态变化表示“当前窗口还有多少缺口”。遍历s时执行need[c]--,含义是“当前窗口中的这个c抵消了一份需求”。当某个字符的需求量从1变成0时,说明这个字符的需求刚好被填满了,matched加一。如果变成负数,说明当前窗口里这个字符已经过剩了,但不影响matched。
收缩的时候反过来,need[lc]++,含义是“把一个c移出窗口后,c的缺口又增加了一份”。如果need[lc]从0变成正数,说明移出的这个字符恰好是多余的配额被耗尽的那个,也就是必需字符,所以matched减一。
用这种计数法,匹配判断从O(128)降到了O(1),整体的复杂度是O(n)。这道题给我的启发是:滑窗的状态维护可以做到极其细粒度,不一定非要保存一份“完整快照”,而是可以只保存一个“偏移量”,通过正负和零来判断状态。
3.3 滑动窗口最大值(LeetCode 239):手写双端队列
这道题是HOT 100滑动窗口题组的天花板,也是面试时能拉开区分度的一道题。题目:给定数组nums和窗口大小k,返回每个窗口中的最大值。
暴力解法是每个窗口内扫一遍找最大值,总复杂度O(nk)。优化思路是用一个单调递减的双端队列,队首永远是当前窗口的最大值。但问题来了:C语言标准库没有双端队列,你只能自己写或者用数组模拟。
用数组模拟双端队列其实不复杂。这个队列里存的是元素的下标,而不是值,因为我们要判断队首元素是否已经滑出窗口。
c复制int* maxSlidingWindow(int* nums, int numsSize, int k, int* returnSize) {
if (numsSize == 0 || k == 0) {
*returnSize = 0;
return NULL;
}
int* deque = (int*)malloc(numsSize * sizeof(int));
int front = 0, rear = 0; // 左闭右开
int* result = (int*)malloc((numsSize - k + 1) * sizeof(int));
int idx = 0;
for (int i = 0; i < numsSize; i++) {
// 1. 移除队首已经滑出窗口的下标
while (front < rear && deque[front] <= i - k) {
front++;
}
// 2. 移除队尾比当前元素小的下标
while (front < rear && nums[deque[rear - 1]] <= nums[i]) {
rear--;
}
// 3. 当前下标入队
deque[rear++] = i;
// 4. 窗口形成后,队首就是最大值
if (i >= k - 1) {
result[idx++] = nums[deque[front]];
}
}
*returnSize = idx;
free(deque);
return result;
}
队列里维护的是一个“单调递减”序列。队尾弹出比当前元素小的下标,是因为:如果有两个下标a和b(a < b),且nums[a] <= nums[b],那么在窗口还包含a和b的时候,a永远不可能是最大值(b比它大而且比它新),所以a可以直接丢掉。
为什么要存下标而不是存值?因为存值无法判断队首元素是否已经不在窗口里了。下标是唯一的,deque[front] <= i - k这个判断很优雅地解决了“过期”问题。
这里有一个细节值得留意:数组模拟双端队列时,front和rear都一直在增加,即使front被推到rear的位置,队列为空,也没有真正的“内存复用”。因为队列总长度不会超过numsSize,所以一次性分配numsSize个int是安全的,不用担心数组越界。
4. C语言实现滑动窗口的七个经典坑(比IDE报错更隐蔽)
这三道题刷完之后,我本来以为自己对滑动窗口已经掌握得很扎实了。结果在后来做变体题的时候,频频因为一些C语言特有的细节翻车。总结下来,这些坑比IDE能抓到的语法错误隐蔽得多。
坑1:char当数组下标,可能踩到负数
C语言的char到底是signed char还是unsigned char,取决于编译器和平台。如果是有符号的,那么char c = 0x80的时候,c的值是-128,直接用s[c]访问数组会越界。
刷字符串滑窗题时,我习惯用int c = s[right]而不是char c = s[right],这样c自动被提升为int,不会存在符号问题。或者强制转换成unsigned char。
坑2:memset在很多情况下不可靠
用memset(window, 0, sizeof(window))在main函数里没问题,但一旦窗口数组定义在函数内部作为参数传递,sizeof(window)就变成指针大小了,数组长度信息丢失,只清了一小部分。
我在实现最小覆盖子串时犯过这个错:
c复制void init(int hash[128]) {
memset(hash, 0, sizeof(hash)); // 错误!sizeof(hash)是8,只清了两个元素
}
正确写法是memset(hash, 0, 128 * sizeof(int)),或者在函数外面用int hash[128] = {0};初始化。
坑3:搞混了INT_MAX的包含头文件
最小覆盖子串那题的代码里用到了INT_MAX,我第一次写的时候忘了加#include <limits.h>,编译直接报错。这种小细节浪费了我五分钟,后来想了个办法:把所有可能用到的头文件一次性写全:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
坑4:LeetCode的returnSize和returnColumnSizes忘记赋值
C语言在LeetCode上的函数接口大多会带一个*returnSize参数,用来告诉调用方返回数组的长度。这是一个输出参数,很多从Python转过来的朋友会忽略。一旦忘记赋值,调用方读到的长度就是一个随机值,结果要么越界读,要么测试用例直接报错。
我的习惯是:在函数入口处先给*returnSize = 0,然后在所有能return的地方都提前赋值。这样即使某个分支忘了处理,也不会留一个未定义的随机值在外面。
坑5:窗口收缩时用了if而不是while
无重复字符的最长子串那道题,收缩条件出现重复字符,你可能以为用if就够了——因为重复字符出现了,只需要把左指针跳一下,确实不用while。但最小覆盖子串那道题,窗口满足条件后,要一直收缩直到不再满足条件,这时必须用while。
我总结的规律是:如果收缩一步之后,窗口可能仍然不合法,就要用while;如果收缩一步之后窗口必然合法,用if。这个判断要在写代码前想清楚。
可变窗口模板里我默认用while,是因为while更通用,只是有的题不需要它循环多次,但不影响正确性。
坑6:窗口长度和数组下标的差一错误
这个坑严格来说不是C语言特有的,但C语言没有越界检查,一旦写错就是段错误或悄悄读到脏数据。
滑动窗口中最常出现的差一错误是:窗口长度为right - left还是right - left + 1?我自己的办法是固定一种区间表示法,全程使用。我习惯用左闭右闭[left, right],长度就是right - left + 1,但要注意right指向的是窗口内的最后一个元素;用左闭右开[left, right),长度是right - left,right指向的是窗口外的下一个元素。这两种表示法都能写对,最怕的是混着用。
坑7:malloc出来的内存没有初始化
C语言刷题时,malloc分配出来的空间是“脏”的,不保证全零。如果你分配了一个数组想当计数数组用,忘了初始化就直接累加,结果就是莫名其妙的大数。
和这个相关的还有calloc和free。calloc自带清零,但速度比malloc慢;LeetCode上可以用,因为数据量不大。free的时机也要注意:返回给LeetCode的数组不能free,内部临时用的数组用完就该释放,否则内存泄漏在长时间运行的服务里会积累成灾难。
5. 跳出LeetCode:TCP滑动窗口与信号滤波的同一性
刷了那么多滑动窗口的题之后,我有一种感觉是:LeetCode里的滑动窗口,本质上和计算机网络里的TCP滑动窗口、信号处理里的滑动窗口滤波,是同一个思想在不同领域的投影。
5.1 TCP里的滑动窗口
TCP协议为了保证可靠传输和控制流量,用了一个滑动窗口机制。发送方维护一个发送窗口,窗口内的数据包是已发送但未确认的,窗口外的数据包要么还没发送,要么已经确认。收到一个ACK就滑一下窗口,这个机制保证了发送方不会一次性把数据全砸出去,也保证了对端缓冲区的安全。
你仔细看这个结构和算法题里的固定窗口滑动,是不是异曲同工?LeetCode的滑动窗口最大值是在一个数组上移动固定长度为k的窗口,TCP是在一个字节流上移动一个动态可调的窗口。窗口在TCP里起到的作用是“一次只关注一小段数据”,算法里也是一样,一次只维护一段区间内的状态。
5.2 信号处理里的滑动窗口滤波
再看滑动窗口滤波,它是对时序信号取最近N个样本的平均值来消除噪声。窗口里始终保存着最近N个数据,新数据进来,最老的数据出去,然后重新计算平均值。
这个场景和算法题的相似之处在于“增量更新”的思想。滑动窗口滤波的更新公式是:新平均值 = 旧平均值 + (新数据 - 老数据) / N。这跟滑动窗口最大值里面维护单调队列的更新逻辑本质一样——都是通过增量更新代替全量重算。很多初学者想不通为什么滑动窗口能把O(nk)降到O(n),其实就是这个道理:你不需要重新算一遍整个窗口,只需要处理好新进来和刚出去的这两个数据就行。
滤波器还有个延迟参数,决定窗口大小N,N越大越平滑但延迟越大。这跟算法题里调整窗口大小的取舍也是类似的——窗口太大,能获得更多信息但空间复杂度上升,窗口太小,状态变化太快,可能错过最优解。
5.3 统一的启示
刷完滑动窗口这一组题,再回头看这些工程应用,最大的体会是:算法题不是孤立的脑筋急转弯,它们背后都有真实的工程需求。TCP滑动窗口要解决的是网络中的流量问题,滑动窗口滤波要解决的是信号去噪问题,而LeetCode题把它们抽象成了纯粹的数据结构和算法问题。当你用C语言把这些问题一个个实现出来的时候,你不只是在刷题,你是在用最低的抽象层次理解这些思想是如何从理论变成代码的。
这也解释了一个现象:为什么有些题用Python刷完很快就忘了,用C刷完却记得特别牢?因为用C语言实现队列、哈希表、计数数组,每一个操作都是自己写的,你对这个数据结构的数据流动有完整的掌控感。这种掌控感,本质上就是你把这套思想的原理掌握了。
