第 2 篇|代码的时间与空间复杂度

3 阅读7分钟

「程序首先是写给人看的,只是顺便让机器能执行。」——Donald Knuth(高德纳)
大 O 干的事差不多:先把「规模变大时,成本怎么涨」说清楚给人听,机器其实不太 care 你咋念。


本篇干啥

上篇说写页面要算账;这篇给你一把记账单位时间复杂度(跑起来要干多少活)和空间复杂度(额外要占多少内存)。不用背公式推导,只要会数圈、会量级

n 在前端心里就两件事:列表有多长节点/用户有多少。下文里的 n 都当这个使。


大 O:不说「几秒」,说「涨得多快」

同样一段代码,电脑快慢会变;数据量也会变。大 O 描述的是:当 n 很大时,工作量跟着 n 怎么变——叫渐近上界,你把它理解成量级账就行。

常见几张脸(从快到慢,记个序就够用):

写法直觉(别背定义)记号人话
固定几步,跟 n 无关O(1)取数组第一个、对象按 key 取、Map 查一下
扫一遍O(n)一个 for 把数组走完
有序数组里「折半猜」O(log n)二分:每次砍掉一半搜索范围
嵌套两层都跟 n 有关O(n²)外层 n、内层又 n——全班握手
再嵌一层O(n³)少见,见到了先怀疑人生是不是写劈了

全班握手:n 个人,每人要和其余 n-1 人握手,大约 n×n 量级(严谨是 n(n-1)/2,大 O 里常数和低次项扔掉,只留谁说了算)。


怎么「数圈」(时间)

  1. 看最里面那层:循环变量从 0 走到 n-1,通常就是 O(n)
  2. 循环套循环:外层 n、内层 n → O(n²)
  3. 循环里调方法:若 arr.includes(x) / arr.find(...) 本身要扫一遍数组,相当于藏了一层 n——这是前端踩坑高发区
  4. 二分:每次问题规模减半 → O(log n),下章数组篇会再碰。

最好 / 最坏 / 平均:同一函数,有时一上来就命中,有时扫到底。面试爱问,工程里先盯最坏——用户可不会按你的平均情况点页面

均摊:像动态数组 push,偶尔扩容拷贝一下,摊到很多次操作上,单次可以当 O(1) 想——知道有这回事即可,别和「每次都拷贝」搞混。


空间复杂度:多占了哪些「额外」内存

问的是:为了算这道题,多开了多少东西——不算输入本身,算你新搞出来的数组、对象、Map、递归栈

  • 只弄了几个变量:O(1)
  • 复制了一份和输入一样长的数组:O(n)
  • 递归深度最深到 n 层(比如不当心写的深递归):栈空间 O(n)

空间换时间经典梗:多花点内存(Set / Map)换少跑几圈循环——和上篇「重名」例子是同一类账


小例子:找人——无序扫一遍 vs 有序「折半猜」

无序名单里找有没有「李四」:只能从头扫到尾,最坏 O(n)

已按姓名排好序的名单:看中间,偏小去左半、偏大去右半,每次砍掉一半——O(log n)。(本篇只预告写法,数组篇可细聊。)

// 无序:线性扫描 —— 时间 O(n),额外空间 O(1)
function findLinear(arr, target) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === target) return true;
  }
  return false;
}

// 有序:二分(名字前提:已排序)—— 时间 O(log n),额外空间 O(1)
function findBinary(sorted, target) {
  let lo = 0;
  let hi = sorted.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    const v = sorted[mid];
    if (v === target) return true;
    if (v < target) lo = mid + 1;
    else hi = mid - 1;
  }
  return false;
}

const messy = ['王五', '张三', '李四'];
const sorted = ['张三', '李四', '王五'];
console.log(findLinear(messy, '李四')); // true
console.log(findBinary(sorted, '李四')); // true

人话不是二分一定更快——排序本身有成本;维护有序 + 高频查找时才划算。这里只为让你看到:同样是「找」,量级可以差一档


从复杂度看交互:坏写法 vs 好写法

场景很常见:列表里有 n 行,每行点一下都要做点什么。很多人不假思索就「一行绑一个监听」——这在工作量上等价于:监听注册次数跟 n 走,列表一长,初始化、销毁、改 DOM 都更沉。

要是 n 到了几千? 就意味着要挂上几千个监听函数,常见弊端有:

  • 首屏 / 切换路由更慢:初始化阶段要在循环里 addEventListener 几千次,纯纯多出来的 O(n) 固定开销,列表一刷新就得再来一遍。
  • 内存更肥:每个监听背后都是一份函数(还常常闭包住行数据),份数跟 n 成正比;移动端、老机器上更容易顶。
  • 改列表心累:虚拟列表、分页、重渲染时,旧 li 卸了若忘了 removeEventListener,容易泄漏;新 li 来了又要再绑一遍。委托时往往只维护父节点那一个,子节点增删不必跟着改绑定次数
  • 排查问题费眼:DevTools 里事件监听堆成山,到底是谁触发的更难一眼看清。

有算法思维的人会先问一句:这 n 份响应,能不能合并成「一处处理、再认出是谁」? 在浏览器里,这叫事件委托:子节点被点到时,事件会往上冒泡,父元素只挂 1 个监听,用 event.target(再配合 closest)判断到底是哪一行——注册次数与 n 脱钩,新增行也不用再绑。

下面用一页完整的 HTML(结构 + 脚本)对照两种写法。把下面整个代码框复制到本地,保存为任意文件名、扩展名 .html,用浏览器打开即可;本专栏不在仓库里再挂单独的 html 文件,这一篇里自包含就够了。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>监听次数:坏写法 vs 好写法</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 36rem; margin: 1rem auto; padding: 0 1rem; }
    section { border: 1px solid #ccc; border-radius: 8px; padding: 1rem; margin: 1rem 0; }
    ul { max-height: 6rem; overflow: auto; }
    li { cursor: pointer; margin: 0.2rem 0; }
    .meta { font-size: 0.875rem; background: #f5f5f5; padding: 0.5rem; border-radius: 6px; }
    #msg { color: #060; min-height: 1.25rem; margin-top: 0.5rem; }
  </style>
</head>
<body>
  <h1>列表点击:两种绑定方式</h1>
  <p>下方各 <code id="nShow"></code> 行,点一行会在最下显示点了谁。看灰色框里的「注册次数」对比。</p>

  <section>
    <h2>写法 A(坏):每个 <code>li</code><code>addEventListener</code> 一次</h2>
    <p class="meta" id="metaA"></p>
    <ul id="listA"></ul>
  </section>

  <section>
    <h2>写法 B(好):只在父 <code>ul</code> 上绑一次(委托 + <code>closest</code></h2>
    <p class="meta" id="metaB"></p>
    <ul id="listB"></ul>
  </section>

  <p id="msg"></p>

  <script>
    const N = 24;
    document.getElementById('nShow').textContent = String(N);

    // —— 写法 A:注册次数 = N —— //
    let regA = 0;
    const ulA = document.getElementById('listA');
    for (let i = 0; i < N; i++) {
      const li = document.createElement('li');
      li.textContent = '列表 A · 第 ' + i + ' 行';
      li.dataset.index = String(i);
      li.addEventListener('click', function () {
        document.getElementById('msg').textContent =
          '[A] 点了第 ' + li.dataset.index + ' 行(本行单独注册的监听)';
      });
      regA++;
      ulA.appendChild(li);
    }
    document.getElementById('metaA').textContent =
      'addEventListener 调用次数:' + regA + '(随 n 变多)';

    // —— 写法 B:注册次数 = 1 —— //
    let regB = 0;
    const ulB = document.getElementById('listB');
    for (let i = 0; i < N; i++) {
      const li = document.createElement('li');
      li.textContent = '列表 B · 第 ' + i + ' 行';
      li.dataset.index = String(i);
      ulB.appendChild(li);
    }
    ulB.addEventListener('click', function (e) {
      const li = e.target.closest('li');
      if (!li || !ulB.contains(li)) return;
      document.getElementById('msg').textContent =
        '[B] 点了第 ' + li.dataset.index + ' 行(ul 上唯一监听处理)';
    });
    regB = 1;
    document.getElementById('metaB').textContent =
      'addEventListener 调用次数:' + regB + '(与 n 无关)';
  </script>
</body>
</html>

小结:坏写法不是「语法写错」,而是没先想规模:默认「有几个就绑几次」。好写法来自知道在算什么账——合并同类项、一次处理,这正是算法里常练的「别重复劳动」;落到 DOM 上就是委托。若回调里又对整表扫一遍,那是另一笔时间复杂度,别和「注册次数」混为一谈。


小练习

  1. 下面函数时间复杂度大约多少?
    function countSame(arr) { let c = 0; for (let i = 0; i < arr.length; i++) { for (let j = 0; j < arr.length; j++) { if (arr[i] === arr[j]) c++; } } return c; }
    答:外层 n、内层 n,O(n²)
  2. 打开你项目里一层 for 里套 find / includes 的代码,标出两层 n,改成 Set 或预处理,看能不能降到一遍扫描

面试一句

「时间复杂度看循环层数和隐藏扫描;空间复杂度看额外结构和递归深度;前端常考的是循环里再 includes 这种隐性 O(n²)。」


下篇预告

第 3 篇 · 数组:一排带编号的格子——随机访问、尾部爽插中间哭;继续 纯 JS