「程序首先是写给人看的,只是顺便让机器能执行。」——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 里常数和低次项扔掉,只留谁说了算)。
怎么「数圈」(时间)
- 看最里面那层:循环变量从 0 走到 n-1,通常就是 O(n)。
- 循环套循环:外层 n、内层 n → O(n²)。
- 循环里调方法:若
arr.includes(x)/arr.find(...)本身要扫一遍数组,相当于藏了一层 n——这是前端踩坑高发区。 - 二分:每次问题规模减半 → 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 上就是委托。若回调里又对整表扫一遍,那是另一笔时间复杂度,别和「注册次数」混为一谈。
小练习
- 下面函数时间复杂度大约多少?
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²)。 - 打开你项目里一层
for里套find/includes的代码,标出两层 n,改成Set或预处理,看能不能降到一遍扫描。
面试一句
「时间复杂度看循环层数和隐藏扫描;空间复杂度看额外结构和递归深度;前端常考的是循环里再 includes 这种隐性 O(n²)。」
下篇预告
第 3 篇 · 数组:一排带编号的格子——随机访问、尾部爽插中间哭;继续 纯 JS。