目录

题目描述

面试题 17.18. 最短超串

题意分析

在数组 big 中找一个最短的连续子数组,使它包含 small 中的所有元素。要返回的是这个子数组的左右端点下标,而不是长度也不是内容;若有多个同样短的答案,返回左端点最小的那个;若根本不存在这样的区间,返回空数组。

两个细节把题目框死了:一是「连续」,所以候选一定是区间而不是任意子集,这排除了排序、哈希去重之类会破坏顺序的做法;二是 small元素互不相同,所以每个目标只需要在窗口里出现至少一次,不存在「需要两个 3」这种多重集需求,判定条件因此可以简化成计数命中的种类数。

最关键的算法信号是单调性:如果区间 [l, r] 已经覆盖了全部目标,那么把右端点继续右移得到的 [l, r']r' > r)必然也覆盖——覆盖性对区间的扩张是单调的。这意味着对每个固定的右端点,存在唯一的「最靠右的合法左端点」,且这个左端点随右端点右移只增不减。有了这条,两个指针就都只需要单向走一遍,不必回退。

规模上 big 可达十万量级,$O(n^2)$ 地枚举所有区间会超时,上面的单调性正好把它降到线性。

边界上要考虑:big 中可能根本不含某个目标元素,此时答案是空数组;big 里可能有大量重复的目标元素,收缩时不能一见到目标就停;small 长度可能为 1,此时答案是任意一个该元素所在的单点区间。

解法:滑动窗口维护有效区间

核心思路

暴力做法是枚举所有 $O(n^2)$ 个区间,对每个区间检查是否覆盖 small。即便用哈希把检查降到 $O(1)$ 摊销,枚举本身就已经超时了。瓶颈在于:左端点每次都从头重来,而前一个左端点已经得出的「覆盖到哪里才够」的信息被完全丢弃。

由前面的单调性观察可以重新组织搜索:固定右端点 r,我们只关心那个使 [l, r] 恰好仍然合法的最大 l——比它更小的左端点只会让区间更长,不可能是答案;比它更大的左端点则已经不合法。而当 r 增加时,这个最优 l 绝不会变小(原来不合法的左端点,加入更多右侧元素后仍不合法与否只可能变好,但已经被更优解淘汰的位置不必回头)。于是 l 与 r 都只需单调右移,总移动次数被 $2n$ 限制住。

剩下的问题是如何 $O(1)$ 地判断「当前窗口是否覆盖全部目标」。这里维护两层结构构成不变量:need 记录每个目标元素的需求量(本题因元素互异恒为 1),window 记录窗口内每个目标元素的实际出现次数,match 记录「已经达到需求量的目标种类数」;任意时刻窗口合法当且仅当 match == need.size()match 的存在把一次全表比对压成了一个整数比较,这是滑动窗口模板的核心。

match 的更新必须严格踩在「跨过阈值的那一刻」:加入元素后计数恰好等于需求量时 match++,移除元素后计数刚刚小于需求量时 match--。用「恰好等于」和「刚刚小于」而不是「大于等于」和「小于等于」,是为了保证同一个目标不会被重复计数——窗口里有三个 1 时,match 里 1 这个目标只能算一次。

解题步骤

  • 先把 small 装进 need,每个元素的需求量置 1。题目已保证 small 内元素互不相同,所以不需要累加计数;needSize = need.size() 就是要凑齐的目标种类数,这个值全程不变,先算好避免在循环里反复调用。
  • 右端点从左到右遍历 big,只有当元素在 need 中时才更新窗口。非目标元素对合法性毫无影响,把它们记进 window 只会浪费空间,还会让「窗口内目标种类数」这个语义变模糊。
  • 加入元素后立刻判断 cnt == need.get(val) 决定是否 match++。写成 >= 会让同一个目标的第二次、第三次出现都触发自增,match 虚高,窗口尚未真正覆盖就被误判为合法。
  • 合法之后用 while 而不是 if 持续收缩左端点。一次右移可能让窗口连续通过多个可收缩的位置(左边堆了一串非目标元素或重复目标),只收一次会留下大量冗余前缀,得到的区间不是最短的。
  • 在收缩循环的开头、移除元素之前更新答案。此刻的 [left, right] 是合法的,长度可以参与竞争;一旦移除了左端元素,窗口可能已经不合法,再记录就是错的。用严格小于 right - left + 1 < bestLen 做比较,保证同长度时保留先出现的、也就是左端点更小的那个答案,正好符合题目要求。
  • 移除左端元素时同步递减计数,并在 cnt < need.get(drop)match--。这是加入操作的精确逆运算;漏掉这一步,match 会永远停在满值,左指针一路滑到底,答案退化成一个不合法的短区间。
  • 循环结束后用 bestL == -1 判断是否找到过合法窗口。用哨兵而不是「bestLen 是否还是初值」来判断更直观,也避免了长度比较里的溢出隐患。

big = [1, 2, 3, 2, 1, 4, 5]small = [1, 4] 走一遍。need = {1:1, 4:1}needSize = 2window = {}match = 0left = 0bestLen = +∞

right = 0,值 1,是目标,window = {1:1},计数恰好等于需求,match = 1。不合法,不收缩。

right = 1,值 2,不是目标,跳过。right = 2,值 3,跳过。right = 3,值 2,跳过。

right = 4,值 1,是目标,window = {1:2},计数 2 不等于需求 1,match 仍为 1。不合法。这里正是「用 == 而非 >=」发挥作用的地方:目标 1 已经算过一次,不能再算。

right = 5,值 4,是目标,window = {1:2, 4:1},计数恰好等于需求,match = 2,窗口合法,进入收缩循环。

收缩第 1 轮:[0, 5] 长度 6 < ∞,记 bestL = 0bestR = 5bestLen = 6。移除 big[0] = 1window[1] 降为 1,仍不小于需求 1,match 保持 2,left = 1

收缩第 2 轮:[1, 5] 长度 5 < 6,更新 bestL = 1bestR = 5bestLen = 5。移除 big[1] = 2,非目标,left = 2

收缩第 3 轮:[2, 5] 长度 4,更新为 bestL = 2bestR = 5。移除 big[2] = 3,非目标,left = 3

收缩第 4 轮:[3, 5] 长度 3,更新为 bestL = 3bestR = 5。移除 big[3] = 2,非目标,left = 4

收缩第 5 轮:[4, 5] 长度 2,更新为 bestL = 4bestR = 5bestLen = 2。移除 big[4] = 1window[1] 降为 0,小于需求 1,match 减到 1,left = 5,循环退出。

right = 6,值 5,不是目标,跳过。遍历结束。

bestL = 4 不等于 -1,返回 [4, 5]。手工核对:big[4..5] = [1, 4] 恰好包含目标 1 和 4,长度 2;数组里 1 出现在下标 0 和 4,4 只出现在下标 5,任何合法区间都必须含下标 5,配上最近的 1 就是下标 4,长度 2 已是下界,答案正确。

代码实现

// 用哈希表统计窗口内每个目标元素出现次数,并维护已满足的目标数 match。
class Solution {
    public int[] shortestSeq(int[] big, int[] small) {
        Map<Integer, Integer> need = new HashMap<>();
        for (int num : small) {
            need.put(num, 1);
        }

        Map<Integer, Integer> window = new HashMap<>();
        int match = 0;
        int needSize = need.size();

        int left = 0;
        int bestL = -1;
        int bestR = -1;
        int bestLen = Integer.MAX_VALUE;

        for (int right = 0; right < big.length; right++) {
            int val = big[right];
            if (need.containsKey(val)) {
                int cnt = window.getOrDefault(val, 0) + 1;
                window.put(val, cnt);
                if (cnt == need.get(val)) {
                    match++;
                }
            }

            while (match == needSize && left <= right) {
                if (right - left + 1 < bestLen) {
                    bestLen = right - left + 1;
                    bestL = left;
                    bestR = right;
                }

                int drop = big[left];
                if (need.containsKey(drop)) {
                    int cnt = window.get(drop) - 1;
                    window.put(drop, cnt);
                    if (cnt < need.get(drop)) {
                        match--;
                    }
                }
                left++;
            }
        }

        if (bestL == -1) {
            return new int[0];
        }

        return new int[]{bestL, bestR};
    }
}
// 用哈希表统计窗口内每个目标元素出现次数,并维护已满足的目标数 match。
func shortestSeq(big []int, small []int) []int {
    need := map[int]int{}
    for _, num := range small {
        need[num] = 1
    }

    window := map[int]int{}
    match := 0
    needSize := len(need)

    left := 0
    bestL := -1
    bestR := -1
    bestLen := int(^uint(0) >> 1)

    for right, val := range big {
        if _, ok := need[val]; ok {
            window[val]++
            if window[val] == need[val] {
                match++
            }
        }

        for match == needSize && left <= right {
            if right-left+1 < bestLen {
                bestLen = right - left + 1
                bestL = left
                bestR = right
            }

            drop := big[left]
            if _, ok := need[drop]; ok {
                window[drop]--
                if window[drop] < need[drop] {
                    match--
                }
            }
            left++
        }
    }

    if bestL == -1 {
        return []int{}
    }

    return []int{bestL, bestR}
}

复杂度分析

  • 时间复杂度:$O(n + m)$,其中 n 为 big 的长度、m 为 small 的长度。凭什么?建 need 是 $O(m)$;主循环里右指针恰好走 n 步,左指针全程只增不减、最多也走 n 步,两者合计 $2n$ 次移动,每次移动只做常数次哈希读写。虽然内层是 while,但它不会让任何一个下标被左指针访问两次,所以整体仍是线性。
  • 空间复杂度:$O(m)$。凭什么?need 恰好存 small 的 m 个元素;window 只登记出现过的目标元素,键集是 need 的子集,同样不超过 m;其余是常数个下标与计数变量。与 big 的长度无关,即使 big 有十万个不同的非目标元素,也不会进入任何哈希表。

关键点总结

  • 覆盖性的单调性是滑动窗口成立的前提:只有当「区间变长不会让合法变不合法」时,左右指针才能都只走一遍。遇到新题先验证这条,若某个约束在扩张时可能被破坏(例如同时要求「不含某元素」和「含某元素」),窗口法就要改造甚至换算法。
  • 用一个整数 match 代替整表比对:把「所有目标都满足」这个 $O(m)$ 的判断压缩成一次比较,是模板从 $O(nm)$ 降到 $O(n)$ 的真正原因。关键在于 match 只在跨过阈值的瞬间加减,而不是每次改动都重算。
  • 加入与移除必须是严格互逆的一对操作:加入时用 cnt == need,移除时就必须用 cnt < need;两边的判据不对称,match 就会漂移。写完模板后用「加一个再删一个应回到原状态」自检一遍是很有效的验证手段。
  • 答案在窗口合法时记录,而不是在收缩之后:这决定了 if 更新语句必须写在移除操作之前。顺序错了不会报错,只会静默返回一个偏移一位的区间。
  • 「多解取左端点最小」靠严格小于自然实现:比较时用 < 而非 <=,先出现的同长度答案就不会被后来者覆盖。凡是题目对多解有额外偏好的,都要检查一遍比较符的严格性。
  • 面试视角:这题是 76 最小覆盖子串的数组版,代码几乎可以照搬,考的是模板的熟练度与边界处理。答完后主动补两句会加分——一是「因为 small 元素互异,need 其实可以退化成 Setwindow 里的计数仍要保留因为 big 中会重复」,二是「若 small 允许重复,需求量就要累加,其余代码一行不用改」,这说明你理解的是模板的语义而不是形状。

易错点总结

  • 错误写法:加入元素后用 cnt >= need.get(val) 判断是否 match++。用例 big = [1, 1, 4], small = [1, 4] → 下标 1 的第二个 1 也会触发自增,match 变成 2,窗口 [0, 1] 被误判为合法并记为答案 [1, 1];正确答案是 [1, 2]。必须用 == 只在恰好达标那一刻计数。
  • 错误写法:收缩用 if 而不是 while。用例 big = [1, 2, 3, 2, 1, 4, 5], small = [1, 4] → 右端点走到 5 时先记下 [0, 5] 长度 6,然后只收缩一次就退出,后续再也没能把长度压下去,最终返回 [0, 5];正确答案是 [4, 5] 长度 2。一次扩张可能允许连续多次收缩。
  • 错误写法:把更新答案的语句写在移除左端元素之后。同样用例 big = [1, 2, 3, 2, 1, 4, 5], small = [1, 4] → 最后一轮先把下标 4 的 1 移出、left 变成 5,再记录 [5, 5] 长度 1;这个区间只含元素 4,根本不覆盖目标 1。必须在窗口仍合法时抢先记录。
  • 错误写法:移除元素时忘记 match--。用例 big = [1, 4, 9, 9, 9], small = [1, 4]match 一旦到 2 就再也降不下来,左指针在收缩循环里一路滑到 left > right 才停,途中把 [1, 1] 这个只含元素 4 的单点区间当成更短的答案记了下来,最终返回 [1, 1];正确答案是 [0, 1]。加入与移除必须成对更新。
  • 错误写法:移除时的判据写成 cnt <= need.get(drop)。用例 big = [1, 1, 4], small = [1, 4] → 窗口 {1:2, 4:1} 移除一个 1 后计数降为 1,1 <= 1 成立触发 match--,窗口被误判为不合法而提前停止收缩,返回 [0, 2];正确答案是 [1, 2]。只有掉到需求量以下才算失去一个目标。
  • 错误写法:长度比较用 <= 而不是 <。用例 big = [1, 4, 9, 1, 4], small = [1, 4] → 两个长度均为 2 的合法区间 [0, 1][3, 4] 中,后者会覆盖前者返回 [3, 4];题目要求多解时取左端点最小,正确答案是 [0, 1]
  • 错误写法:把非目标元素也记进 window。用例 big 含十万个互不相同的非目标数 → window 膨胀到 $O(n)$,空间从 $O(m)$ 退化成 $O(n)$;更糟的是若同时把 match 的判定改成比较 window.size() == needSize,第一个非目标元素就会让判定永远失真,返回任意错误区间。
  • 错误写法:不存在合法窗口时返回 new int[]{-1, -1}null。用例 big = [1, 2, 3], small = [4] → 4 从未出现,match 始终为 0,若返回 [-1, -1] 会被判错,返回 null 则可能在判题侧抛异常;题目明确要求返回空数组。
  • 错误写法:bestLen 初值设成 big.length 而不是极大值。用例 big = [1, 4], small = [1, 4] → 唯一的合法区间长度恰好等于 2,2 < 2 不成立,答案永远不被记录,最终返回空数组;正确答案是 [0, 1]。初值必须严格大于任何可能的合法长度。
  • 错误写法:Go 里用 if window[val] > 0 判断元素是否是目标。用例 big = [9, 1, 4], small = [1, 4]map 的零值读取不会报错但也不区分「不存在」与「存在且为 0」,把非目标的 9 误判后 window 被污染,match 的语义随之失效。判断是否为目标必须查 need 并用 _, ok := 的两值形式。
  • 错误写法:左指针的循环条件漏掉 left <= right。用例 small 为空数组时 needSize = 0match == needSize 从一开始就成立 → 左指针会在右指针还停在 0 时越过它,right - left + 1 变成 0 甚至负数,记录出非法区间。加上边界条件可以让窗口永远保持非空。

相似题目

题目 难度 考察点
76. 最小覆盖子串 困难 目标串允许重复字符,需求量要累加而非恒为 1,且返回的是子串内容不是下标
567. 字符串的排列 中等 窗口长度固定,收缩退化为定长右移,且要求「恰好相等」而非「至少覆盖」
438. 找到字符串中所有字母异位词 中等 定长窗口且要收集全部起点,答案是列表而非单个最优解
209. 长度最小的子数组 中等 合法性由数值和的阈值决定,靠一个累加变量即可,完全不需要哈希表
632. 最小区间 困难 覆盖对象是 k 个有序列表各出一个数,窗口开在归并后的序列上并按值域而非下标度量
30. 串联所有单词的子串 困难 元素是等长单词,需按单词长度分组做多条独立窗口,命中后还要整词回退