题目链接:Minimum Window Substring - LeetCode[page:2]
题目要求到底是什么?
题目描述(英文关键部分):[page:2]
Given two strings s and t, return the minimum window substring of s such that every character in t (including duplicates) is included in the window.
If there is no such substring, return the empty string "".
The testcases will be generated such that the answer is unique.
翻译成白话:
- 给定字符串
s和t。 - 在
s里找一个子串 ,这个子串要包含t中的所有字符和对应次数。 - 在所有「满足条件的子串」里,返回长度最短的那一个。
- 如果不存在这样的子串,返回空串
""。 - 题目保证:最短的这个答案是唯一的(不会有两个不同位置但同样短的最优解)。[page:1]
注意这两点:
- 要的是 minimum window substring ------ 最短的那一段,而不是"随便一个包含 t 的子串就行"。
- "答案唯一"是说「最短答案不会有并列」,不是说"第一个找到的合法窗口就是答案"。
因此,不能在第一次 找到一个包含 t 的窗口就 break,而是要在所有合法窗口中找到长度最小的。
思维抽象:把问题想成"装满购物清单"
可以这样类比:
t是一张购物清单 :需要哪些字符,各需要多少个。- 例如
t = "AABC",清单上是:A × 2, B × 1, C × 1。
- 例如
s是一排货架 :"ADOBECODEBANC"。- 我们用两个指针
left、right表示一个在货架上的「窗口」[left, right],这个窗口里面的字符就是我们当前"购物车"里的东西。
目标:
- 找到一个最短的窗口
[left, right],能刚好把清单上的东西都装进去(至少满足需求次数)。 - 同时,这样满足条件的窗口可能有很多,我们要从中选出最短的那一个。
对应到算法上,就是典型的滑动窗口 + 频次统计。
官方推荐思路:滑动窗口 + 两个计数
1. 准备:统计 t 里面的需求
- 用
t_map统计t中每个字符需要的次数(类似 need)。 - 同时统计一共有多少种不同字符是"需要的",记为
t_count(类似 required)。
伪代码(逻辑上):
pseudo
t_map = {}
t_count = 0
for each char c in t:
if t_map[c] 之前是 0:
t_count++ // 多了一种需要的字符
t_map[c]++
2. 窗口中的统计
- 用
s_map统计当前窗口中每个字符出现的次数(类似 window)。 - 用
s_count记录「当前窗口中,有多少种字符的数量已经达到需求」。
含义类似:
t_count= 需要满足的字符种类数(required)。s_count= 当前窗口中已经满足需求的字符种类数(formed)。
当 s_count == t_count 时,说明当前窗口已经覆盖了 t 的所有字符与次数,是一个合法窗口。
滑动窗口流程
步骤一:右指针扩展窗口
- 初始化:
left = 0,right = 0。 - 用
right从左到右遍历s:
伪代码:
pseudo
for right from 0 to s_len - 1:
c = s[right]
// 如果当前字符根本不是需要的字符,跳过,不影响 s_count
if t_map[c] == 0:
continue
// 把 c 加入窗口
s_map[c]++
// 如果这个字符刚好达到需求次数,多了一种满足的字符
if s_map[c] == t_map[c]:
s_count++
步骤二:当窗口已经满足需求时,尽量缩小
一旦 s_count == t_count,说明当前窗口 [left, right] 已经包含了 t 需要的所有字符,我们可以试着从左边缩小窗口,找到更短的合法子串。
伪代码:
pseudo
while s_count == t_count:
// 尝试更新全局最优答案
if (right - left + 1) < best_len:
best_len = right - left + 1
best_left = left
// 移除左边的字符
lc = s[left]
if t_map[lc] > 0: // 如果是需要的字符才会影响计数
if s_map[lc] == t_map[lc]: // 原来刚好满足需求
s_count-- // 移除后不再满足
s_map[lc]--
left++
关键点:
- 只有当窗口已经满足需求 时(
s_count == t_count),才会尝试缩小,并顺带更新最短答案。 - 缩小过程中,一旦有某个字符的数量不再满足
t_map的需求,就让s_count--,窗口变成不合法,循环结束,回到外层继续移动right。
步骤三:结束返回结果
- 整个
s扫完后,如果best_len还是初始值(比如INT_MAX),说明不存在合法窗口,返回空串。 - 否则返回
s[best_left .. best_left + best_len - 1]。
这一整套流程保证了:
- 每个字符最多被
right访问一次、被left移出一次。 - 整体时间复杂度是 (O(m + n))(符合题目 Follow up 要求)。[page:2]
C 语言实现(使用 uthash)
在 C 里直接开大数组做频次数组是最简单的,但如果使用 uthash 这样的哈希表,也完全可以。下面是一个使用 uthash 的完整实现,它正是前面思路的代码版本。
c
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include "uthash.h"
struct char_node {
char ch;
int count;
UT_hash_handle hh;
};
static void map_inc(struct char_node **head, char ch) {
struct char_node *node;
HASH_FIND(hh, *head, &ch, sizeof(char), node);
if (node != NULL) {
node->count++;
return;
} else {
node = (struct char_node *)malloc(sizeof(struct char_node));
node->ch = ch;
node->count = 1;
HASH_ADD(hh, *head, ch, sizeof(char), node);
}
}
static void map_dec(struct char_node **head, char ch) {
struct char_node *node;
HASH_FIND(hh, *head, &ch, sizeof(char), node);
if (node != NULL && node->count > 0) {
node->count--;
}
}
static int map_get(struct char_node *head, char ch) {
struct char_node *node;
HASH_FIND(hh, head, &ch, sizeof(char), node);
return node != NULL ? node->count : 0;
}
static void map_free(struct char_node **head) {
struct char_node *node, *tmp;
HASH_ITER(hh, *head, node, tmp) {
HASH_DEL(*head, node);
free(node);
}
}
char* minWindow(char* s, char* t) {
int s_len = strlen(s);
int t_len = strlen(t);
int best_ans_len = INT_MAX;
char *ans = NULL;
if (s_len == 0 || t_len == 0) {
return strdup("");
}
int left = 0, best_left = 0;
int right = 0;
int t_count = 0; // 需要的字符种类数
int s_count = 0; // 当前已满足需求的字符种类数
char c;
struct char_node *s_map = NULL;
struct char_node *t_map = NULL;
// 统计 t_map 和 t_count
for (int i = 0; i < t_len; i++) {
if (map_get(t_map, t[i]) == 0) {
t_count++;
}
map_inc(&t_map, t[i]);
}
// 滑动窗口
for (right = 0; right < s_len; right++) {
c = s[right];
// 不在 t_map 中的字符可以直接忽略
if (map_get(t_map, c) == 0) {
continue;
}
// 加入窗口
map_inc(&s_map, c);
if (map_get(s_map, c) == map_get(t_map, c)) {
s_count++;
}
// 当窗口已经满足所有需要的字符种类
while (s_count == t_count) {
if (right - left + 1 < best_ans_len) {
best_ans_len = right - left + 1;
best_left = left;
}
char lc = s[left];
if (map_get(t_map, lc) > 0) {
if (map_get(s_map, lc) == map_get(t_map, lc))
s_count--;
map_dec(&s_map, lc);
}
left++;
}
}
// 没有合法窗口
if (best_ans_len == INT_MAX) {
map_free(&s_map);
map_free(&t_map);
return strdup("");
}
// 拷贝最短窗口
ans = (char *)malloc(best_ans_len + 1);
memset(ans, 0, best_ans_len + 1);
strncpy(ans, s + best_left, best_ans_len);
map_free(&s_map);
map_free(&t_map);
return ans;
}
这段代码已经在 LeetCode 上通过全部测试用例,只是因为使用哈希表而不是数组,性能上略慢一点,但在面试和刷题上完全可以接受。[page:2]
常见疑问:为什么不能"找到第一个就返回"?
很多人一开始会有这样的误区:
题目说答案是唯一的,那我从左往右找到第一个包含 t 的子串,直接返回不就行了?
问题在于:
- "唯一"指的是:在所有最短合法子串里,只有一个位置。
- 不是说:"你从左往右扫到的第一个合法窗口就是那个最短的"。
示例:
s = "AAABBC"t = "ABC"
合法的子串有很多:
"AAABBC"(长度 6)"AABBC"(长度 5)"ABBC"(长度 4)
最短答案是 "ABBC",不是你从左边开始扫到的第一个合法窗口。所以:
- 每次窗口刚好覆盖 t 时,只能说明"这是一个合法窗口",并不保证是最短的。
- 我们必须在「合法」的前提下继续缩小窗口,并在整个过程中维护全局最短答案。
这也正是滑动窗口中「右指针只增不减,左指针在合法时尽量往右缩」的核心思想。
小结
- 这题的关键是理解:
- 目标是「最短且包含 t 所有字符(含重复)」的窗口。
- "唯一答案"指的是"最短答案没有并列",不是"随便一个合法窗口"。
- 官方推荐解法是:
- 对
t统计需求(t_map+t_count)。 - 用滑动窗口
[left, right]和s_map+s_count记录当前窗口是否满足需求。 - 当窗口满足需求时,通过移动左指针缩小窗口并更新最短答案。
- 对
- 整体时间复杂度 (O(m + n)),非常适合面试中的经典滑动窗口模板题。[page:2]
如果你已经写出了上面的 C 版本,那么你不仅拿下了这题,也算真正把「滑动窗口 + 频次统计」这一类题的核心套路吃透了。