在刷 LeetCode 时,我们不仅要写出正确的代码,还需要分析算法的时间复杂度和空间复杂度。
时间复杂度衡量算法的执行工作量如何随输入规模增长,空间复杂度衡量算法所需的额外空间如何随输入规模增长。
本文以 LeetCode 128「最长连续序列」为例,学习如何分析这两种复杂度,以及一个容易误解的知识点:循环嵌套不一定意味着时间复杂度是 O(n²)。
一、先看我们的解法
dart
class Solution {
public int longestConsecutive(int[] nums) {
HashSet<Integer> set = new HashSet<>();
// 去重,方便后续查找
for (int num : nums) {
set.add(num);
}
int maxLength = 0;
// 从连续序列的起点开始查找
for (int num : set) {
if (!set.contains(num - 1)) {
int endNum = num;
while (set.contains(endNum + 1)) {
endNum++;
}
int newLength = endNum - num + 1;
maxLength = Math.max(maxLength, newLength);
}
}
return maxLength;
}
}
设输入数组的长度为 (n),去重后 HashSet 中的元素数量为 (m),显然 (m \le n)。
在 Java HashSet 正常哈希性能的假设下,这份代码的复杂度为:
- 时间复杂度:平均 O(n)
- 额外空间复杂度:O(n)
下面分析为什么。
二、什么是时间复杂度?怎么判断?
时间复杂度关注的是:当输入规模不断增大时,算法的执行工作量如何增长。
它并不是计算代码运行了多少秒,而是分析执行操作的增长趋势。
例如:
- O(1):执行工作量基本不随输入规模增长。
- O(n):工作量与输入规模成正比。
- O(n²):工作量通常与输入规模的平方成正比。
1. 分析第一段循环
dart
for (int num : nums) {
set.add(num);
}
假设数组有 100 个元素,这个循环执行 100 次;如果有 10000 个元素,就执行 10000 次。
因此,遍历次数与 (n) 成正比。
HashSet 的 add() 在平均情况下是 O(1),所以这段代码的平均时间复杂度是:
O(n \\times 1)=O(n)
2. 分析第二段循环
dart
for (int num : set) {
if (!set.contains(num - 1)) {
// ...
}
}
HashSet 最多包含 (m) 个不同的元素,因此外层循环执行 (m) 次。
每次 contains() 平均为 O(1),所以单看起点判断,时间复杂度为 O(m)。
但真正需要注意的是里面的 while。
3. 为什么循环嵌套不一定是 O(n²)?
很多人看到 for 里面还有 while,就会直接判断为 O(n²)。
这是一个常见误区。
判断复杂度不能只看循环写了几层,还要看内层循环累计执行了多少次。
我们的代码是:
dart
if (!set.contains(num - 1)) {
int endNum = num;
while (set.contains(endNum + 1)) {
endNum++;
}
}
关键在于起点判断:
dart
!set.contains(num - 1)
只有当前数字的前一个数字不存在时,才会进入 while。
例如,Set 中有:
{1, 2, 3, 4, 100, 101, 102}
当遍历到 1 时,它是连续序列的起点,while 会依次找到 2、3、4。
当遍历到 2、3、4 时,由于它们的前一个数字都存在,不会再次扫描这个连续序列。
同理,100 会负责扫描 101、102,而 101、102 不会重复扫描。
因此,每个连续序列只会从起点开始扫描一次。所有 while 循环累计处理的元素数量为 O(m),而不是每次外层循环都重新扫描整个集合。
整体时间复杂度为:
T(n)=O(n)+O(m)+O(m)
由于 (m < n),最终得到:
{T(n)=O(n)}
这里需要注意:如果没有起点判断,让每个数字都执行向后扫描,那么对于连续的 (n) 个数字,累计工作量可能达到 O(n²)。
所以,循环嵌套不一定是 O(n²)。真正决定复杂度的是所有操作的累计工作量。
三、什么是空间复杂度?怎么判断?
空间复杂度关注的是:随着输入规模增长,算法需要多少额外空间。
分析时,通常不把题目传入的原数组本身算作额外空间。
回到我们的代码:
ini
HashSet<Integer> set = new HashSet<>();
这个 Set 最多保存 (n) 个不同的数字。
如果数组中的数字全部不同,Set 就需要存储 (n) 个元素,因此需要 O(n) 的额外空间。
其他变量,例如:
ini
int maxLength = 0;
int endNum = num;
int newLength = endNum - num + 1;
它们都只占用固定数量的空间,属于 O(1)。
因此:
S(n)=O(n)+O(1)=O(n)
最终,算法的额外空间复杂度为 O(n)。
一个容易混淆的地方
循环次数多,不代表空间复杂度就高。
例如:
ini
int sum = 0;
for (int num : nums) {
sum += num;
}
这个循环执行 (n) 次,时间复杂度是 O(n),但它只使用固定数量的变量,额外空间复杂度仍然是 O(1)。
反过来,即使只遍历一次数组,如果在循环中将所有元素保存到新的集合中,空间复杂度仍然可能是 O(n)。
时间看执行了多少工作,空间看额外存储了多少数据。
四、判断复杂度时,还要注意什么?
1. 多段代码的复杂度通常相加
例如:
scss
for (...) {
// O(n)
}
for (...) {
// O(n)
}
两段循环的总工作量为:
O(n)+O(n)=O(2n)=O(n)
大 O 表示法忽略常数倍,因此不是 O(2n)。
2. 连续嵌套循环通常需要相乘,但不能机械判断
例如:
ini
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 固定时间操作
}
}
内层循环对每次外层循环都执行 (n) 次,总操作次数为 (n \times n),因此时间复杂度是 O(n²)。
但如果内层循环的总工作量受到条件限制,或者多个循环共同处理同一批元素,就需要进一步分析,不能仅凭代码缩进层数判断。
3. HashSet 的操作通常是平均 O(1)
本题使用 HashSet,依赖 add() 和 contains() 的平均常数时间性能。
如果发生严重哈希冲突,单次操作可能变慢。因此,通常将本题的时间复杂度表述为平均 O(n),而不是声称任何情况下都绝对是 O(n)。
五、总结
分析算法复杂度时,可以记住以下思路:
- 时间复杂度: 统计操作随着输入规模增长的趋势。
- 空间复杂度: 找出额外创建的数据结构,判断它们最多保存多少数据。
- 循环嵌套: 不要只看层数,要分析内层循环的累计工作量。
- 简化表达式: 忽略常数倍和低阶项,例如 O(2n + 1) 简化为 O(n)。
以 LeetCode 128 为例,第一段循环构建 HashSet,耗时 O(n);第二段循环虽然包含 while,但起点判断避免了重复扫描,所有扫描工作累计为 O(n)。同时,HashSet 最多保存 (n) 个元素,所以额外空间为 O(n)。
最终结论是:
- 时间复杂度:平均 O(n)
- 额外空间复杂度:O(n)
理解复杂度的关键,不是数循环有几层,而是弄清楚:算法总共做了多少工作,以及额外使用了多少空间。