别再被面试官问倒!保姆级算法复杂度(大 O 表示法)通关指南

30 阅读5分钟

嘿,未来的架构师们!去大厂面试时,你是不是也遇到过这种尴尬:辛辛苦苦写出的算法,面试官只扫一眼就问:“小同学,你这个算法的时间复杂度是多少?”

如果你心里咯噔一下,只能含糊其辞说“挺快的”,那这篇文章就是为你量身定做的。今天我们不聊虚头巴脑的理论,直接从最底层的逻辑出发,带你拆解什么是算法的好坏


一、 算法也分“好坏”吗?

想象一下,你要去 10 公里外的地方。

  • 方法 A:步行(耗时长,但不费钱)。
  • 方法 B:打车(耗时短,但费钱)。

算法也是一样。评价一个算法的优劣,我们主要看两个维度:

  1. 执行时间:这就是时间复杂度(Time Complexity)。
  2. 占用内存:这就是空间复杂度(Space Complexity)。

优秀的算法就像“瞬移且免费”,而平庸的算法则是“走路还收费”。


二、 核心秘籍:抓大放小的“大 O 表示法”

很多新手在算时间复杂度时,会纠结于“这行代码跑了 0.001 秒还是 0.002 秒”。注意:这完全不重要!

由于硬件性能不同(你的 MacBook Pro 和我的小霸王学习机跑同样的代码速度肯定不一样),我们评估的是执行次数 T(n)T(n) 随着输入规模 nn 增大的增长趋势

黄金法则:抓主要矛盾

在计算中,我们只保留最高阶项,并去掉系数。

  • T(n)=3n+3O(n)T(n) = 3n + 3 \Rightarrow O(n)
  • T(n)=3n2+5n+1O(n2)T(n) = 3n^2 + 5n + 1 \Rightarrow O(n^2)

三、 时间复杂度:到底执行了多少次?

我们通过你提供的几个经典例题,手把手教你如何从代码逻辑推导出 O(n)O(n)

1. 简单线性:O(n)O(n)

看看这段代码:

JavaScript

// T(n) 线性阶
function traverse(arr) {
    var len = arr.length; // T(1) - 只执行 1 次
    for(var i = 0; i < len; i++) { 
        // 这里的循环判定包括:初始化 i=0 (1次)、i < len (n+1次)、i++ (n次)
        // 合计大约 T(1) + T(n+1) + T(n)
        console.log(arr[i]); // T(n) - 循环体内,执行 n 次
    }
}

逻辑拆解:

通过数学加法,我们得到 T(n)=1+1+(n+1)+n+n=3n+3T(n) = 1 + 1 + (n + 1) + n + n = 3n + 3

但当 nn 变成 1 亿时,那个常数 +3 和系数 3 就不重要了。增长趋势是线性的,所以我们说它的复杂度是 O(n)O(n)

2. 嵌套循环:O(n2)O(n^2)

当循环里嵌套循环时,情况就开始变得“慢”了起来:

JavaScript

function traverse(arr) {
    var outlen = arr.length; // 1 次
    for(var i = 0; i < outlen; i++){ // 外部循环 n 次
        var inlen = arr[i].length; // n 次
        for(var j = 0; j < inlen; j++){ // 内部循环在每次外部循环时都要跑 n 次
            console.log(arr[i][j]); // 总共跑了 n * n 次
        }
    }
}

逻辑拆解:

这段代码的执行总次数约为 3n2+5n+13n^2 + 5n + 1

  • 最高阶项n2n^2
  • 所以它的时间复杂度是 O(n2)O(n^2)
  • 面试小技巧:看到双层嵌套且都与 nn 相关,第一反应就是 O(n2)O(n^2)

3. 神奇的对数:O(logN)O(\log N)

这是很多初学者容易卡壳的地方,请仔细看:

JavaScript

for(var i = 1; i < length; i = i * 2) { 
    // 注意:这里的 i 每次是乘 2,不是加 1
    console.log(arr[i]);
}

逻辑拆解:

假设循环跑了 xx 次,那么 ii 的值变化就是:1,2,4,8,,2x1, 2, 4, 8, \dots, 2^x

2xn2^x \ge n 时,循环停止。

根据数学公式, x=log2nx = \log_2 n

在算法领域,我们简写为 O(logn)O(\log n)。这是一种非常高效的算法,比如二分查找。


四、 空间复杂度:内存的“临时开销”

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。

重点误区:参数本身占用的空间(如传入的 arr)通常不计入空间复杂度。我们只关心算法为了解决问题而额外新开辟的空间。

1. 稳如泰山:O(1)O(1)

如果你只是定义了几个变量,不随 nn 的增大而增多:

JavaScript

function swap(a, b) {
    var temp = a; // 额外开辟 1 个空间
    a = b;
    b = temp;
}
// 空间复杂度 O(1)

2. 随风而长:O(n)O(n)

看看你提供的这个初始化例子:

JavaScript

function init(n) {
    var arr = []; // 这是一个新开辟的空间,它是关键!
    for(var i = 0; i < n; i++){
        arr[i] = i; // 随着 n 的增大,arr 占用的内存也在同步变大
    }
    return arr;
}

逻辑拆解:

因为 arr 这个数组的长度直接取决于输入 nn,它在内存里实打实地占用了 nn 个单位的空间。所以,空间复杂度是 O(n)O(n)


五、 时间与空间的权衡(Trade-off)

在实际面试或开发中,我们经常会面临选择:

  • 空间换时间:比如使用 缓存(Hash Table) 。你多占用了内存,但查询速度从 O(n)O(n) 变成了 O(1)O(1)
  • 时间换空间:为了极端的内存节省,宁愿让 CPU 多跑几次循环。

复杂度对比表(快 \to 慢)

复杂度名称类比
O(1)O(1)常数阶无论多少人,只查一次名单。
O(logn)O(\log n)对数阶翻开字典,每次砍掉一半。
O(n)O(n)线性阶挨个点名,一个不落。
O(nlogn)O(n \log n)线性对数阶进阶排序算法(快排、归并)。
O(n2)O(n^2)平方阶每个人都要跟其他所有人握手。
O(2n)O(2^n)指数阶恐怖的递归增长。

六、 总结:面试官想听到什么?

当你被问到复杂度时,最专业的回答方式应该是:

  1. 先给结论:“这个算法的时间复杂度是 O(n)O(n),空间复杂度是 O(1)O(1)。”
  2. 再析过程:“因为代码里有一个单层循环遍历,且没有申请额外的动态空间。”
  3. 最后谈优化:“如果数据量极大,我们可以考虑用空间换时间的方式,将复杂度降到 O(logn)O(\log n)。”

掌握了这些,你就不再是那个只会写代码的“码农”,而是一个懂得权衡性能的“工程师”了。