统计完全子数组的数目 图解题解
这道题到底在问什么
- 输入
- nums = [1,3,1,2,2]
- 输出
- 4
- 输入
- nums = [5,5,5,5]
- 输出
- 10
先想最直接的笨办法
记牢这套「先求 k,再枚举起点、右端往右扩,集合凑够 k 就开始逐个计数」,下面每一帧都在套它。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 2799 统计完全子数组的数目:先定整个数组的不同元素数 k 当标尺,枚举起点、右端往右扩,集合凑齐 k 后每个更长子数组都计数,时间 O(n²)、空间 O(n)。
什么样的连续子数组,才配叫「完全」
子数组是数组里连续的一段。先数出整个数组有多少种不同元素、记成 k;一段连续子数组只要也凑齐这 k 种,就叫完全子数组,要统计的是这种子数组的总个数。题面 nums=[1,3,1,2,2] 不同元素是 1、3、2 共 3 种,答案 4;nums=[5,5,5,5] 只有一种元素、k 为 1,答案 10。
每段都从头数一遍不同元素,账为什么划不来
最直白的做法是把所有子数组都摆出来:起点和终点各挑一个,约 n² 段,每段再从头扫一遍数它有几种不同元素、和 k 比。可每段单独数就要走一趟,三层叠起来是 O(n³),n 到几千就慢。慢在重复:终点只往右挪一格,前面数过的元素其实一个没变。
集合只增不减,凑齐 k 之后凭什么右边全算
把起点钉住,让右端从它一格格往右伸、元素挨个塞进同一个集合。集合只添不删,不同元素数便只增不减、始终朝一个方向往上爬、绝不回头。
于是集合大小头一回等于 k 时,k 种就全凑齐了。再往右多吞几格,新来的要么是老面孔、要么没有新种类可添,不同元素数只会卡在 k、绝不掉回去。所以从这个终点起、往右每伸一格的更长子数组都照样含齐 k 种、都是完全子数组,一个不漏。窗口一旦完整,右边全算,内层压到一趟扫完。
先把 k 定死,再枚举起点让右端加进集合
先用集合扫全数组求出 k = len(set(nums)),标尺全程不变。外层枚举起点,每换一个起点就把工作集合清空;内层让右端从当前起点往右走、逐个加进集合,大小等于 k 就给答案 ans 加一,之后继续往右扩、每格再判一次。所有起点走完,ans 就是总数。清空是关键:不清则上一轮的元素赖在集合里,大小虚高、把不该算的也算进。
[1,3,1,2,2] 里,这 4 个完全子数组怎么数出来
先求 k:不同元素 1、3、2,共 3 种。起点 0 清空集合,右端依次吞 1→大小 1、3→大小 2、1→大小仍 2、2→{1,3,2} 大小 3 第一次等于 k、ans 记 1;再吞末尾的 2→大小还是 3、ans 到 2。起点 0 数出 [0,3]、[0,4] 两段。
起点 1 清空重来,吞 3→1 种、1→2 种、2→3 种够 k、ans 到 3,再吞 2→仍 3 种、ans 到 4,数出 [1,3]、[1,4]。起点 2 只剩 1、2、2,最多 2 种、凑不齐 3;起点 3、4 元素更少,各数出 0 段。四段相加得 4,和题面答案 4 一致。
拿子数组自己的种类数当标尺,会把每段都误判成完全
最隐蔽的错是搞混标尺数谁的:k 必须是整个数组的不同元素数、算好锁死,若拿每段自己的种类数去比,它永远等于自己,所有子数组都被误判成完全。另两处:换起点忘清空集合,大小会带上一轮的元素虚高;大小刚到 k 就收手不再往右数,会漏掉更长却同样完整的子数组。
复杂度:外层 n 个起点、内层各走一趟、集合加查平均 O(1),合起来 O(n²),空间 O(n)。边界:全相同如 [5,5,5,5],k 为 1,每段都完全、答案就是子数组总数 4×5÷2=10;元素全不同只有整个数组凑得齐 k、答案 1;单元素数组答案也是 1。
▶ 动画逐步走查(共 27 步)——想跟着动画一帧帧对照就展开
- 3记牢这套「先求 k,再枚举起点、右端往右扩,集合凑够 k 就开始逐个计数」,下面每一帧都在套它。
- 4先扫全数组,不同元素是 1、3、2,一共 k = 3 种。之后每个子数组都要拿它的不同元素个数去和这个 3 比。
- 5起点固定在 0,右端扩到第 0 个 1。这是窗口里的新面孔,不同元素数涨到 1。
- 6窗口不同元素才 1 种,离 k = 3 还差 2 种,这段不算完全子数组,继续把右端往右扩。
- 7起点固定在 0,右端扩到第 1 个 3。这是窗口里的新面孔,不同元素数涨到 2。
- 8窗口不同元素才 2 种,离 k = 3 还差 1 种,这段不算完全子数组,继续把右端往右扩。
- 9起点固定在 0,右端扩到第 2 个 1。它之前已经在窗口里出现过,集合不变,不同元素数还是 2。
- 10窗口不同元素才 2 种,离 k = 3 还差 1 种,这段不算完全子数组,继续把右端往右扩。
- 11起点固定在 0,右端扩到第 3 个 2。这是窗口里的新面孔,不同元素数涨到 3。
- 12窗口不同元素 3 恰好等于 k = 3,[0, 3] 凑齐了全部 3 种,是一个完全子数组,ans 变成 1。
- 13起点固定在 0,右端扩到第 4 个 2。它之前已经在窗口里出现过,集合不变,不同元素数还是 3。
- 14窗口不同元素 3 恰好等于 k = 3,[0, 4] 凑齐了全部 3 种,是一个完全子数组,ans 变成 2。
- 15起点固定在 1,右端扩到第 1 个 3。这是窗口里的新面孔,不同元素数涨到 1。
- 16窗口不同元素才 1 种,离 k = 3 还差 2 种,这段不算完全子数组,继续把右端往右扩。
- 17起点固定在 1,右端扩到第 2 个 1。这是窗口里的新面孔,不同元素数涨到 2。
- 18窗口不同元素才 2 种,离 k = 3 还差 1 种,这段不算完全子数组,继续把右端往右扩。
- 19起点固定在 1,右端扩到第 3 个 2。这是窗口里的新面孔,不同元素数涨到 3。
- 20窗口不同元素 3 恰好等于 k = 3,[1, 3] 凑齐了全部 3 种,是一个完全子数组,ans 变成 3。
- 21起点固定在 1,右端扩到第 4 个 2。它之前已经在窗口里出现过,集合不变,不同元素数还是 3。
- 22窗口不同元素 3 恰好等于 k = 3,[1, 4] 凑齐了全部 3 种,是一个完全子数组,ans 变成 4。
- 23起点 2 从第 2 个数到第 2 个,不同元素最多才 1 种,凑不齐 3 种。起点 2 往后再怎么扩也只有这些元素,这一轮数不出完全子数组。
- 24起点 2 从第 2 个数到第 3 个,不同元素最多才 2 种,凑不齐 3 种。起点 2 往后再怎么扩也只有这些元素,这一轮数不出完全子数组。
- 25起点 2 从第 2 个数到第 4 个,不同元素最多才 2 种,凑不齐 3 种。起点 2 往后再怎么扩也只有这些元素,这一轮数不出完全子数组。
- 26起点 3 从第 3 个数到第 3 个,不同元素最多才 1 种,凑不齐 3 种。起点 3 往后再怎么扩也只有这些元素,这一轮数不出完全子数组。
- 27起点 3 从第 3 个数到第 4 个,不同元素最多才 1 种,凑不齐 3 种。起点 3 往后再怎么扩也只有这些元素,这一轮数不出完全子数组。
- 28起点 4 从第 4 个数到第 4 个,不同元素最多才 1 种,凑不齐 3 种。起点 4 往后再怎么扩也只有这些元素,这一轮数不出完全子数组。
- 29五个起点都枚举完了。起点 0 数到 [0,3]、[0,4] 两个,起点 1 数到 [1,3]、[1,4] 两个,后面三个起点凑不齐 3 种。加起来完全子数组共 4 个。
⚠️ 容易写错的地方
✗ 错:拿子数组自己的不同元素数和自己比
✓ 对:先固定「整个数组」的不同元素数 k 作为标尺
子数组和自己比永远相等,会把所有子数组都误判成完全
✗ 错:换起点时忘了清空集合
✓ 对:每次换起点 i 前把集合清空重来
不清空会把上一个起点的元素带进来,size 虚高
✗ 错:size 到达 k 后就停止计数
✓ 对:size 等于 k 后继续往右扩,每个更长子数组也都计数
已含全部 k 种,再加元素 size 不会变小,仍然完全
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def countCompleteSubarrays(self, nums: List[int]) -> int:
cnt = len(set(nums))
ans, n = 0, len(nums)
for i in range(n):
s = set()
for x in nums[i:]:
s.add(x)
if len(s) == cnt:
ans += 1
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int countCompleteSubarrays(vector<int>& nums) {
unordered_set<int> s(nums.begin(), nums.end());
int cnt = s.size();
int ans = 0, n = nums.size();
for (int i = 0; i < n; ++i) {
s.clear();
for (int j = i; j < n; ++j) {
s.insert(nums[j]);
if (s.size() == cnt) {
++ans;
}
}
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int countCompleteSubarrays(int[] nums) {
Set<Integer> s = new HashSet<>();
for (int x : nums) {
s.add(x);
}
int cnt = s.size();
int ans = 0, n = nums.length;
for (int i = 0; i < n; ++i) {
s.clear();
for (int j = i; j < n; ++j) {
s.add(nums[j]);
if (s.size() == cnt) {
++ans;
}
}
}
return ans;
}
}复杂度
时间
O(n²)
外层枚举起点、内层枚举右端,集合增删查平均 O(1)
空间
O(n)
集合最多装下 n 个不同元素;Python 切片 nums[i:] 另有 O(n) 拷贝但不改量级
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 统计完全子数组的数目 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这个 O(n²) 还能再快吗,能压到线性吗?+
能,换成滑动窗口做到 O(n)。反过来固定右端 right、往右加元素,同时维护一个最靠左的 left,让 [left, right] 恰好还含齐 k 种——再去掉 nums[left] 就会缺一种。这时以 right 结尾的完全子数组,左端可以取 0 到 left 之间任意位置,一共 left+1 个,直接累加。因为右端右移时 left 只会往右不会退,两个指针各自单向走一遍,整体线性。本题数据量下 O(n²) 已够用,O(n) 是进阶。
它和「至多 K 个不同整数的子数组」是一类题吗?+
是同一族滑动窗口计数题。那类题固定右端、收缩左端来数满足条件的子数组个数;本题把条件从「至多 K 种」换成「不同元素数正好等于全局 k」,套路完全相通。认出「固定右端、维护左端边界、按左端可选个数累加」这个母题,不同整数个数、连续子数组计数这类题都能顺下来。
为什么凑齐 k 之后还要接着往右数,不是到齐就停?+
因为更长的那些也都是完全子数组,停了就漏。集合大小到 k 说明 k 种已全在窗口里,往右再加元素,新来的要么是重复的老元素、要么没有新种类,不同元素数只会停在 k、不可能掉到 k 以下。所以从第一次凑齐的终点起,往右每伸一格都仍含齐 k 种、都得单独计一次数。真按到齐就停来写,[0,3] 算了、[0,4] 就被漏掉,本题答案会从 4 少数成 2。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 统计完全子数组的数目 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。