双指针与滑动窗口:从入门到实战
大家好,欢迎来到《C语言算法专题精讲》。今天我们来聊聊双指针和滑动窗口。这两个技巧,说白了就是「用两个指针来优化遍历过程」。我刚开始学的时候也觉得没什么,直到在项目中处理一个上百万条数据的日志文件,才发现暴力解法根本跑不动。嗯,从那以后,我对这两个技巧就格外上心了。
一、双指针技巧:快慢指针与左右指针
双指针的核心思想很简单:用两个指针协同工作,代替单指针的暴力遍历。根据指针的移动方式,我习惯把它们分成两类。
1. 快慢指针
快慢指针,顾名思义,一个走得快,一个走得慢。最常见的场景就是「检测链表是否有环」。快指针每次走两步,慢指针每次走一步。如果有环,它们一定会相遇。
我在项目中遇到过一个问题:需要从一个有序数组中删除重复项。用快慢指针,一次遍历就能搞定。
int removeDuplicates(int* nums, int numsSize) {
if (numsSize == 0) return 0;
int slow = 0; // 慢指针指向「已处理区域的最后一个」
for (int fast = 1; fast < numsSize; fast++) {
if (nums[fast] != nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}
你看,快指针负责「探索」,慢指针负责「确认」。这就是快慢指针的精髓。
2. 左右指针
左右指针,通常用在有序数组或字符串中。左指针从左边开始,右指针从右边开始,向中间靠拢。
最经典的例子就是「两数之和 II - 输入有序数组」。给定一个升序数组和一个目标值,找到两个数之和等于目标值。
int* twoSum(int* numbers, int numbersSize, int target, int* returnSize) {
int left = 0, right = numbersSize - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) {
int* result = (int*)malloc(2 * sizeof(int));
result[0] = left + 1;
result[1] = right + 1;
*returnSize = 2;
return result;
} else if (sum < target) {
left++; // 和太小,左指针右移
} else {
right--; // 和太大,右指针左移
}
}
*returnSize = 0;
return NULL;
}
为什么会这样?因为数组是有序的。左指针右移,和变大;右指针左移,和变小。你想想看,这比暴力枚举快了多少倍?
二、滑动窗口框架
滑动窗口,其实是双指针的一种特殊形式。两个指针维护一个「窗口」,窗口在数组或字符串上滑动。我个人觉得,滑动窗口最核心的是「什么时候扩大窗口,什么时候缩小窗口」。
我总结了一个通用框架,你可以直接套用:
/* 滑动窗口算法框架 */
void slidingWindow(char* s) {
int left = 0, right = 0;
// 用于记录窗口内的情况,比如字符出现次数
int window[256] = {0};
while (right < strlen(s)) {
// 扩大窗口:将 right 指向的字符加入窗口
char c = s[right];
window[c]++;
right++;
// 判断是否需要缩小窗口
while (/* 窗口需要缩小的条件 */) {
// 缩小窗口:将 left 指向的字符移出窗口
char d = s[left];
window[d]--;
left++;
}
// 此时窗口满足条件,可以更新答案
}
}
这个框架,说白了就是「先扩大,再缩小,中间更新答案」。我建议你把这个框架背下来,遇到滑动窗口的题,直接往上套。
三、最长无重复子串
这是滑动窗口的经典题目。给定一个字符串,找出其中不含有重复字符的最长子串的长度。
举个例子:"abcabcbb",最长无重复子串是 "abc",长度为 3。
用滑动窗口怎么解?
int lengthOfLongestSubstring(char* s) {
int window[256] = {0}; // 记录字符出现次数
int left = 0, right = 0;
int maxLen = 0;
int len = strlen(s);
while (right < len) {
// 扩大窗口
char c = s[right];
window[c]++;
right++;
// 如果出现重复字符,缩小窗口
while (window[c] > 1) {
char d = s[left];
window[d]--;
left++;
}
// 更新最大长度
if (right - left > maxLen) {
maxLen = right - left;
}
}
return maxLen;
}
你想想看,这个算法的时间复杂度是 O(n),空间复杂度是 O(1)(因为 window 数组大小固定为 256)。比暴力解法的 O(n²) 不知道高到哪里去了。
四、最小覆盖子串
这道题比上一道难一些。给定一个字符串 S 和一个字符串 T,在 S 中找出包含 T 所有字符的最短子串。
举个例子:S = "ADOBECODEBANC",T = "ABC",最短覆盖子串是 "BANC"。
这道题我当年面试时遇到过,当时没写出来……嗯,后来我认真总结了一下,其实还是滑动窗口框架。
char* minWindow(char* s, char* t) {
int need[256] = {0}; // 记录 T 中每个字符需要的次数
int window[256] = {0}; // 记录窗口中每个字符出现的次数
int needCount = 0; // 记录 T 中不同字符的个数
// 初始化 need
for (int i = 0; t[i] != '\0'; i++) {
if (need[t[i]] == 0) needCount++;
need[t[i]]++;
}
int left = 0, right = 0;
int valid = 0; // 记录窗口中已经满足条件的字符个数
int start = 0, minLen = INT_MAX;
int len = strlen(s);
while (right < len) {
// 扩大窗口
char c = s[right];
right++;
if (need[c] != 0) {
window[c]++;
if (window[c] == need[c]) {
valid++;
}
}
// 当所有字符都满足条件时,尝试缩小窗口
while (valid == needCount) {
// 更新最小子串
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((minLen + 1) * sizeof(char));
strncpy(result, s + start, minLen);
result[minLen] = '\0';
return result;
}
这里有个关键点:valid 变量记录的是「已经满足条件的字符种类数」。当 valid == needCount 时,说明窗口已经覆盖了 T 中的所有字符。这时候我们尝试缩小窗口,找到最短的覆盖子串。
window[c] >= need[c] 来判断是否满足条件,结果发现重复字符会干扰判断。后来改用 valid 变量来计数,才解决了问题。细节决定成败啊。
五、知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
六、总结与避坑指南
好了,我们来总结一下今天的内容:
- 双指针:快慢指针用于「探索+确认」,左右指针用于「有序数组中的对撞」。
- 滑动窗口:核心框架是「扩大窗口 → 缩小窗口 → 更新答案」,适用于子串/子数组问题。
- 最长无重复子串:当窗口内出现重复字符时,缩小左边界。
- 最小覆盖子串:用
valid变量记录满足条件的字符种类数,当全部满足时尝试缩小窗口。
- 我曾经在滑动窗口的 while 循环里忘了更新
valid变量,导致死循环。记住:缩小窗口时也要更新状态。 - 我曾经在左右指针中,把
left < right写成了left <= right,导致数组越界。记住:边界条件要仔细检查。 - 我曾经在快慢指针中,忘了处理空链表的情况。记住:边界条件永远要优先考虑。
双指针和滑动窗口,说白了就是「用空间换时间」的典型代表。它们不需要额外的数据结构,只需要两个指针和一点点思考。我建议你多练几道题,把框架刻在脑子里。下次遇到子串、子数组的问题,先想想能不能用滑动窗口解决。
好,今天就到这里。希望你能把这些技巧用到实际项目中,写出更高效的代码。
公众号:蓝海资料掘金营,微信deep3321