← 返回博客
2026-09-07 17:54:00

复杂度入门:O(1)、O(log n) 是怎么算出来的——时间与空间复杂度基础

复杂度入门:O(1)、O(log n) 是怎么算出来的——时间复杂度与空间复杂度基础

背结论很容易,二分查找是 O(log n)、HashMap 的 get 是 O(1)、双层循环是 O(n²),可一旦自己写一段循环,想判断它是 O 几,很多人就卡住了。这篇不做那些字面题,只讲一件事:拿到一段代码,怎么把它的时间复杂度和空间复杂度一步一步推出来。懂了怎么推,那些背过的结论才算你自己的。

一、复杂度在说“随规模怎么涨”,不是执行秒数

同一个程序,在这台机器上跑 1 毫秒,换个老机器可能跑 10 毫秒。所以直接用秒数衡量算法不靠谱,得找一个和机器无关的尺子。

这个尺子就是:输入规模 n 变大时,程序要做的事变多快。程序做的事,可以数成“基本操作执行了多少次”。

看个最简单的例子:

int sum = a + b;

不管 a、b 是 1 还是一个亿,都只有一次加法,操作次数是固定的 1,跟输入无关。这种操作次数不随 n 变的,记作 O(1),叫常数时间。O(1) 不代表快,它只代表“操作次数恒定,不随规模长”。

再想一层:如果代码里有几个这样的固定操作,比如 3 步、5 步,都还是 O(1)。因为复杂度只看“增长的级别”,不看具体几步——这一步以后会反复用到,叫去掉常数

所以复杂度回答的问题永远是同一句:n 翻一倍,你要做的事大概翻几倍? 懂了这句,下面每个例子都顺了。

二、线性:单层循环是 O(n)

最常见的循环长相:

for (int i = 0; i < n; i++) {
    sum += arr[i];      // 每次循环做 1 次加法
}

循环体执行了几次?从 i = 0 到 i = n - 1,一共 n 次。每次做 1 次操作,总共 n 次操作。n 翻一倍,操作次数也翻一倍,这种“跟着 n 一起涨”的记作 O(n),叫线性时间。

数组里顺序找一个值就是典型的 O(n):最坏情况要找的那个数在最后,得把 n 个元素从头到尾看一遍。n 越大,最多要看越多个,是正比关系。

三、平方:双层循环是 O(n²)

循环里套循环,次数就开始乘起来了:

for (int i = 0; i < n; i++) {        // 外层 n 次
    for (int j = 0; j < n; j++) {    // 内层每次都跑 n 次
        // 一次操作
    }
}

外层 i 每取一个值,内层就要完整跑一遍 n 次;外层一共 n 个值,所以总次数是 n × n = n²。记作 O(n²),叫平方时间。n 翻一倍,操作次数翻成 4 倍(2n × 2n = 4n²)。

内层不总是从 0 到 n 呢?比如内层上限是 i:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < i; j++) {    // 内层次数跟着外层变
        // 一次操作
    }
}

数一下总次数:i = 0 时内层 0 次,i = 1 时 1 次,i = 2 时 2 次……一直加到 i = n - 1 时 n - 1 次。总和是:

0 + 1 + 2 + … + (n - 1) = n(n - 1) / 2

把它展开就是 (n² - n) / 2。n 很大的时候,n² 这一项比 n 项大得多,n 项可以忽略,只留下 n² / 2。而前面说过常数要丢掉,所以最终记作 O(n²)

这里藏着复杂度计算的第一个要点:求和之后,只留增长最快的那一项,再把它的系数变成 1。n(n-1)/2 里的 -n 和 /2 都被这一条规则处理掉了。

四、对数:每次减半或翻倍是 O(log n)

前面三种都直观,唯独对数最反直觉,但它恰恰是“省时间”的关键,值得单独说。

看这段:

while (n > 1) {
    n = n / 2;   // 每次都砍掉一半
}

n 从 8 开始,会走 8 → 4 → 2 → 1,走了 3 步;从 16 开始,走 16 → 8 → 4 → 2 → 1,走了 4 步。步数不是 n 条,而是“n 能除以几次 2 才到 1”,答案就是 log₂n

算一下验证直觉。下面每行是“n 是 2 的几次方”:

n分解砍半到 1 的步数
88 = 2³3
1616 = 2⁴4
10241024 = 2¹⁰10
约 100 万1048576 = 2²⁰20

注意最后一行:n 从 8 涨到 100 万,涨了十几万倍,可步数只从 3 涨到 20,连十倍都不到。这就是对数“涨得极慢”的含义,也是二分查找能在大数组里飞快的原因——每看一次,剩下的范围就砍半。

循环还有一种“翻倍”长相,性质一样:

int k = 1;
while (k < n) {
    k = k * 2;   // 每次翻倍
}

从 1 翻到 n 需要翻几次 2,同样约 log₂n 次,所以也是 O(log n)

还有一点不用纠结:log 的底数写 2 还是写 10,最后都统一叫 O(log n)。因为换底只是乘一个常数,而常数在复杂度里会被丢掉。

五、常见复杂度排个队,找找感觉

把最常见的阶从慢到快排一遍(复杂度越小越好):

O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)

拿 n = 100 万(一百万)代入,感受差距有多大:

复杂度n = 1,000,000 时大概的操作次数生活化类比
O(1)1瞥一眼就知道
O(log n)约 20每次二分,只要翻二十次
O(n)1,000,000挨个看一遍
O(n log n)约 2000 万快排、归并那种档位
O(n²)10¹²每秒 10 亿次也要算上千秒
O(2ⁿ)天文数字n = 30 已是约 10 亿次,基本跑不动

每个阶对应的典型代表,背这个就够用:

六、空间复杂度:看额外占了多大地方

时间看“步数”,空间看“内存”。空间复杂度衡量的是:随着 n 变大,程序额外占用的内存涨多快。注意是“额外”——输入数据本身占的那份不算,只看你为了干活又开了多少。

判断方法和时间一模一样:数出额外空间跟 n 的关系,再去掉常数。

只用固定几个变量,不管 n 多大都不额外开数组的,是 O(1) 空间:

public int findMax(int[] arr) {
    int max = arr[0];          // 就这一个变量
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) max = arr[i];
    }
    return max;
}

这里无论 arr 多长,额外只占一个 max 变量,所以空间是 O(1)。

但如果复制了一份和输入一样长的数组,额外空间就是 O(n)

public int[] copyArr(int[] arr) {
    int[] copy = new int[arr.length];   // 和输入一样长
    for (int i = 0; i < arr.length; i++) {
        copy[i] = arr[i];
    }
    return copy;
}

最容易漏的是递归占的栈空间。每次递归调用,都要在调用栈上留一层记录,递归多少层就占多大地方:

public int sum(int n) {
    if (n == 0) return 0;
    return n + sum(n - 1);   // 一路递归到 0,深度是 n
}

sum(n) 会先一直调下去,n → n-1 → … → 0,调用栈里叠了 n 层,所以空间是 O(n)。光看代码你会觉得“不就是个循环嘛”,但它递归版本的空间确实随 n 线性涨。

对比一下:前面那个砍半的 while 循环,全程只用几个变量、没有递归,所以时间是 O(log n),空间却是 O(1)。递归版的二分查找就不一样了——每层递归砍半,栈深度约 log₂n,空间会变成 O(log n)。同一个算法,写成循环还是递归,空间复杂度常常不同,这也是面试爱问的点。

七、一张表收尾:常用操作背这张就够

不用记一堆,常用结构就这么几条,多数在前面单元都验证过:

结构 / 操作时间复杂度一句话原因
数组按下标读O(1)知道地址直接取
数组中间插删O(n)后面的元素要挪
链表头尾操作O(1)只改指针
链表按下标找O(n)得从头走
HashMap get / putO(1) 平均哈希定位,冲突多会退化
TreeMap 查 / 插 / 删O(log n)红黑树,树高是 log
堆 offer / pollO(log n)上浮下沉走树高
快速排序(平均)O(n log n)分治
归并排序O(n log n)分治,还要 O(n) 空间

最后

一句话收束:时间复杂度和空间复杂度,本质都在回答“n 变大时,操作次数 / 额外内存涨多快”,算的时候只留增长最快的项、把系数丢掉。

下一步建议:拿一段你最近写过的、带循环或递归的代码,照着第二节到第六节的方法,数出它的时间复杂度和空间复杂度,标在注释里。能独立推出来一遍,比背十张表都管用。