目录

题目描述

340. 至多包含 K 个不同字符的最长子串

image-20250418173337399

题意分析

给一个字符串 s 和一个整数 k,要求返回一个长度最大的子串的长度,这个子串里出现的不同字符种类数不超过 k。注意求的是长度这个数字,不是子串本身。

「子串」这个词是第一个关键信号:它指的是连续的一段,不是可以跳着挑的子序列。连续意味着一段区间由左右两个端点唯一确定,也意味着「在已有区间上往右扩一位」这个操作是有意义的。

第二个关键信号是「至多 $k$ 种字符」这个条件的性质:它是一个判非法的条件,而且是单调的——区间越长,包含的字符种类只会不减;一旦某个区间已经有超过 $k$ 种字符,任何包含它的更长区间必然也超过 $k$ 种。换句话说,条件被破坏之后,靠继续往右扩是永远救不回来的,只能从左边缩。这正是可变长度窗口能成立的前提:右端负责试探、左端负责在越界时把窗口拉回合法。

边界要留意几处:k 可能等于 0,此时任何非空子串都不合法,答案是 0;k 大到超过字符串里的字符种类数时,整个字符串都合法,答案就是 s 的长度;s 可能为空。另外题目并没有限定字符集是小写字母,写代码时不要想当然地开一个长度 26 的数组。

解法:滑动窗口维护字符种类

核心思路

枚举所有子串至少需要 $O(n^2)$。本题的约束具有单调性:窗口一旦包含超过 $k$ 种字符,继续右扩不可能恢复合法,只能移动左端点,因此适合可变长度滑动窗口。

右指针负责加入字符;不同字符数超限时,左指针不断移出字符直到窗口重新合法。左右指针都只向右移动,不会重复枚举同一段区间。

哈希表记录窗口内每个字符的出现次数,并且只保留正计数;计数降为 0 时必须删除键,这样表的大小才等于当前字符种类数。

不变量是:收缩结束后 [left, right] 至多包含 $k$ 种字符,并且是以 right 结尾的最长合法窗口。因为再向左多保留一个字符时窗口仍处于超限状态,所以此时才能用窗口长度更新答案。

解题步骤

  • k == 0 时直接返回 0;初始化计数表、左端点和答案。
  • 右端点逐字符扩张,并将新字符计数加一。
  • 当表中字符种类超过 $k$ 时,循环移动左端点;移出字符的计数降为 0 就删除该键。
  • 窗口恢复合法后,用 right - left + 1 更新最大长度。

例如 s = "eceba"k = 2:窗口先扩到 "ece",答案更新为 3;加入 b 后种类数变成 3,连续移出 ec 才恢复合法。之后加入 a 也需要收缩,最终答案仍为 3。

代码实现

import java.util.HashMap;
import java.util.Map;

class Solution {
    public int lengthOfLongestSubstringKDistinct(String s, int k) {
        if (k == 0) {
            return 0;
        }

        Map<Character, Integer> count = new HashMap<>();
        int left = 0;
        int ans = 0;
        for (int right = 0; right < s.length(); right++) {
            char in = s.charAt(right);
            count.put(in, count.getOrDefault(in, 0) + 1);

            while (count.size() > k) {
                char out = s.charAt(left++);
                count.put(out, count.get(out) - 1);
                if (count.get(out) == 0) {
                    count.remove(out);
                }
            }
            // 窗口合法时才能更新最长长度。
            ans = Math.max(ans, right - left + 1);
        }
        return ans;
    }
}
func lengthOfLongestSubstringKDistinct(s string, k int) int {
    if k == 0 {
        return 0
    }

    count := make(map[byte]int)
    left := 0
    ans := 0
    for right := 0; right < len(s); right++ {
        count[s[right]]++
        for len(count) > k {
            out := s[left]
            left++
            count[out]--
            if count[out] == 0 {
                delete(count, out)
            }
        }
        // 哈希表大小就是当前窗口不同字符数量。
        if right-left+1 > ans {
            ans = right - left + 1
        }
    }
    return ans
}

复杂度分析

  • 时间复杂度:$O(n)$。左右指针各最多移动 $n$ 次,哈希表操作均摊为 $O(1)$。
  • 空间复杂度:$O(k)$。哈希表在扩张瞬间最多保存 $k + 1$ 种字符,收缩结束后不超过 $k$ 种。

关键点总结

  • 可变窗口的成立条件是「非法性单调」:区间越长字符种类越多,越界之后只能靠缩左端恢复,这决定了右端点无需回退。
  • 计数减到 0 必须删键,这是 count.size() 能代表不同字符数的前提,也是本题最容易漏的一行。
  • 更新答案的时机必须在收缩之后,保证参与取最大值的一定是合法窗口。
  • 面试时要能解释两点:两层循环仍是 $O(n)$,因为左指针总共只移动 $n$ 次;若统计「恰好 $k$ 种」子数组,可用两次「至多」计数相减。

易错点总结

  • 错误写法:收缩时只做 count.put(out, count.get(out) - 1),却不在计数归零后 count.remove(out)。用 s = "eceba"k = 2 走:right = 3 时表里有 ecb 三个键,收缩过程中它们的计数陆续减到 0 但键一直留着,count.size() 永远是 3,退出条件永远不满足,left 一路越过 right,最终对还没进过窗口的字符做减一(Java 里 count.get 返回 null 触发空指针异常,Go 里 left 继续增长直到 s[left] 下标越界)。
  • 错误写法:把 ans = Math.max(ans, right - left + 1) 放在收缩循环之前。用 s = "eceba"k = 2 走:right = 3 时窗口 [0, 3]ecb 三种字符,却被当成合法窗口刷出长度 4,输出 4,而正确答案是 3。
  • 错误写法:收缩条件写成 while (count.size() >= k)。用 s = "eceba"k = 2 走:窗口被压到最多只剩 1 种字符,而这个串里没有相邻重复字符,输出 1,正确答案是 3。
  • 错误写法:假设字符集是小写字母,用 int[] count = new int[26]s.charAt(i) - 'a' 索引。题目没有限定字符集,输入 s = "Aa"'A' - 'a' 为负数,数组下标越界异常。
  • 错误写法:每次移动端点后重新截取子串并统计字符种类。虽然结果正确,但整体退化为 $O(n^2)$。
  • 错误写法:把「子串」当成「子序列」,统计全局出现最多的 $k$ 种字符。该做法忽略连续性,例如 "abaccc"k = 2 的正确答案是 "accc" 的长度 4。

相似题目

题目 难度 考察点
3. 无重复字符的最长子串 中等 窗口内字符互不重复的特例
159. 至多包含两个不同字符的最长子串 中等 本题 k 固定为 2 的版本
904. 水果成篮 中等 同一模型换成数组与题面包装
992. K 个不同整数的子数组 困难 「恰好 k 种」拆成两次「至多」相减
76. 最小覆盖子串 困难 求最短合法窗口,收缩时机相反
424. 替换后的最长重复字符 中等 判非法条件依赖窗口内最高频字符