数据结构基础深度解析
前端面试必考的四种基础数据结构:栈、队列、链表、哈希表,各自解决什么问题、复杂度边界在哪、什么时候该用哪个。
一句话概括
数据结构本质是”数据怎么存、怎么取”。前端天天在用,但多数人停留在”会调 API”的层面——直到面试被问到”为什么 Array.shift 很慢”“LRU 怎么实现”“链表怎么判环”才卡壳。
本文聚焦四种最常被考、也最实用的基础结构:栈(LIFO)、队列(FIFO)、链表(非连续)、哈希表(键值映射)。把它们的操作复杂度和典型场景吃透,你就能在”这个需求该用什么结构”的问题上做对选择,而不是永远一把梭 Array。
核心知识点
1. 栈(Stack):后进先出
栈只允许在一端(栈顶)进出,核心操作 push / pop / peek 都是 O(1)。它解决的是”后进的先处理”——函数调用栈、浏览器前进后退、撤销操作全是它的影子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Stack {
#items = [];
push(x) { this.#items.push(x); return this.size; } // O(1)
pop() { if (this.isEmpty()) throw new Error('栈空'); return this.#items.pop(); } // O(1)
peek() { return this.#items[this.#items.length - 1] ?? null; } // O(1)
get size() { return this.#items.length; }
isEmpty() { return this.#items.length === 0; }
}
// 经典应用:括号匹配(编译器解析代码的底层逻辑)
function isBalanced(s) {
const stack = new Stack();
const pair = { ')': '(', ']': '[', '}': '{' };
for (const ch of s) {
if ('([{'.includes(ch)) stack.push(ch);
else if (pair[ch] !== stack.pop()) return false; // 右括号对不上栈顶左括号
}
return stack.isEmpty();
}
console.log(isBalanced('({[]})')); // true
2. 队列(Queue):先进先出
队列在一端入队、另一端出队。朴素用数组 push + shift 实现出队是 O(n)(因为后面元素要整体前移),这是面试常考的性能坑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ❌ 用 shift 出队:每次都要搬移后续元素,O(n)
function badDequeue(arr) { return arr.shift(); }
// ✅ 循环数组(头尾指针)把出队降到 O(1)
class Queue {
#buf = new Array(8);
#head = 0; #tail = 0; #size = 0;
enqueue(x) {
this.#buf[this.#tail] = x;
this.#tail = (this.#tail + 1) % this.#buf.length;
if (++this.#size === this.#buf.length) this.#resize();
}
dequeue() {
if (this.isEmpty()) throw new Error('队列空');
const x = this.#buf[this.#head];
this.#head = (this.#head + 1) % this.#buf.length;
this.#size--;
return x;
}
#resize() {
const next = new Array(this.#buf.length * 2);
for (let i = 0; i < this.#size; i++)
next[i] = this.#buf[(this.#head + i) % this.#buf.length];
this.#buf = next; this.#head = 0; this.#tail = this.#size;
}
isEmpty() { return this.#size === 0; }
}
队列是 BFS、事件循环任务队列、请求排队的底层结构。
3. 链表(Linked List):非连续存储
链表每个节点存数据和指向下一个节点的指针,所以插入 / 删除是 O(1),随机访问是 O(n)——和数组正好相反。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ListNode {
constructor(val, next = null) { this.val = val; this.next = next; }
}
// 反转链表(面试最高频):三指针,一次遍历,O(n) 时间 O(1) 空间
function reverse(head) {
let prev = null, cur = head;
while (cur) { const next = cur.next; cur.next = prev; prev = cur; cur = next; }
return prev;
}
// 快慢指针判环(Floyd):快指针走两步、慢指针走一步,相遇即有环
function hasCycle(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next; fast = fast.next.next;
if (slow === fast) return true;
}
return false;
}
对比记忆(这是面试必背的一张表):
| 操作 | 数组 | 链表 |
|---|---|---|
| 头部插入 / 删除 | O(n) | O(1) |
| 尾部插入 | O(1) | O(1) |
| 按下标访问 | O(1) | O(n) |
| 中间插入(已知前驱) | O(n) | O(1) |
4. 哈希表(Hash Table):键值映射
哈希表用哈希函数把 key 映射成数组下标,平均 get / set / delete 都是 O(1)。冲突时常见两种策略:链地址法(同桶挂链表,JS 的 Map / Object 即用此法)和开放地址法(线性探测找下一个空位)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class HashTable {
#buckets = Array.from({ length: 8 }, () => []);
#size = 0;
#hash(key) {
let h = 0;
for (const ch of String(key)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return h % this.#buckets.length;
}
set(key, val) {
const b = this.#buckets[this.#hash(key)];
const entry = b.find(e => e.key === key);
if (entry) entry.val = val;
else { b.push({ key, val }); this.#size++; }
}
get(key) {
const e = this.#buckets[this.#hash(key)].find(e => e.key === key);
return e ? e.val : undefined;
}
}
LRU 缓存是哈希表 + 双向链表的经典组合(Vue keep-alive 内部就是它):哈希表 O(1) 定位节点,双向链表 O(1) 把节点移到”最近使用”头部、淘汰最久未用的尾部。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class LRUCache {
#capacity; #map = new Map(); // Map 本身保持插入顺序,天然适合 LRU
constructor(capacity) { this.#capacity = capacity; }
get(key) {
if (!this.#map.has(key)) return -1;
const val = this.#map.get(key);
this.#map.delete(key); this.#map.set(key, val); // 访问后移到末尾
return val;
}
put(key, val) {
if (this.#map.has(key)) this.#map.delete(key);
this.#map.set(key, val);
if (this.#map.size > this.#capacity) {
this.#map.delete(this.#map.keys().next().value); // 删最久未用(队首)
}
}
}
其实你每天都在用
- 浏览器的前进 / 后退:导航历史就是栈,后退 = 出栈,前进 = 把页面再压回栈
- 事件循环的任务队列:微任务、宏任务本质是队列,先注册先执行(FIFO)
- Vue / React 虚拟 DOM diff:Fiber 用链表实现可中断的”深度优先 + 兄弟”遍历
- 对象的属性读取:
obj.x底层就是哈希表查表,O(1) 取属性 keep-alive缓存组件:按访问时间淘汰最久没用的,就是 LRU(哈希表 + 双向链表)Array.shift/unshift:在数组头部增删,底层要搬移后面所有元素 → 隐含的 O(n) 性能坑
常见误解(FAQ)
❌ 误区一:”数组能当栈也能当队列,为什么还要单独学?”
能,但在”头部频繁增删”时数组会暴露 O(n) 的 shift / unshift 成本。队列该用循环数组或链表,让出队也保持 O(1)。分不清这点,你写的”队列”在大数据量下会悄悄变慢。
❌ 误区二:”哈希表查找永远 O(1)”
平均是 O(1),但哈希函数设计差、或负载因子过高时,大量 key 挤进同一个桶,退化成链表的 O(n)。JS 的 Map 用随机化哈希种子防”哈希碰撞 DoS”,但你自定义的哈希函数未必防得住。
❌ 误区三:”链表比数组好,因为增删快”
链表是用 O(n) 的随机访问换 O(1) 的插入删除。如果你需要频繁”按下标取第 k 个”,链表反而更慢。选结构看访问模式,不是看哪个”更高级”。
❌ 误区四:”反转链表要开新数组存值”
不需要。三指针原地反转,O(n) 时间 O(1) 空间。面试写反转还去 new Array 拷贝的,基本会被追问”能不能 O(1) 空间”。
一句话总结
数据结构不是背出来的,而是”在正确的场景选对容器”的直觉——栈管先后、队列管公平、链表管灵活增删、哈希表管极速查找,理解了它们的复杂度边界,你写出的代码才既跑得对、也跑得快。