直接把结论放前面:用JavaScript学数据结构,不是把C语言教材里的代码翻译成JS,而是要重新理解“数据结构在不同语言里的存在形态”。很多人学到一半就卡住,不是因为算法难,而是因为JS太“方便”——数组像什么都干了,对象像什么都存得下,结果反而掩盖了底层结构的存在感。这一篇“数据结构(二)(JavaScript)”要解决的就是这个问题。
系列第一篇我们已经走完了线性表的底层逻辑,也手写了栈和队列。到了第二篇,该啃硬骨头了:链表、树、图、哈希表、堆,以及排序算法里那些“数据结构味儿”最浓的部分。我会全部用JavaScript来实现,并且从实际工程的角度讲清楚每一类结构解决什么问题、为什么这么设计、在浏览器和Node环境里又会踩哪些坑。适合正在学数据结构准备面试的同学,也适合写了一阵子前端、觉得自己业务代码都能写但一到算法题就发懵的开发者。读完这一篇,你至少应该能回答这几个问题:JS数组头尾插删到底慢在哪?递归遍历树为什么可能会爆栈?用Map而不是Object存图数据好在哪?为什么要手写堆,直接用数组排序不香吗?
1. 用JavaScript学数据结构,为什么总卡在“不上不下”的位置
1.1 这个语言的特性,让数据结构的“本来面目”被遮住了
先说我自己的体会。当年学数据结构用的是C语言版的经典教材,里面每一段代码都要自己管内存、管指针、管数组长度,写一个链表要小心翼翼地把next指来指去。这种“麻烦”反而让人把结构本身记得很牢。后来用JavaScript再去看这些结构,第一反应是:为什么还要自己写链表?Array.prototype.splice不就能随便插入删除吗?对象取属性不也是O(1)吗?
但问题恰好出在这里。JS的数组是动态的、可以变长的,还自带一堆push、pop、shift、unshift、splice方法,它更像是一个“复合工具箱”,而不是传统数据结构课程里那个固定容量的连续内存块。JS的对象又太像“万能字典”,任何键值都想塞进去,导致你很难意识到它底层就是一张哈希表。
所以用JS学数据结构,难点不是“写不出”,而是“不知道自己在用什么”。我会在这一篇里反复做一件事:把JS里那些司空见惯的语法和能力拆开,让你看到底下真正运转的是哪一种结构。理解了这一层,看源码、答面试题、写复杂业务,都会顺畅很多。
1.2 从“会调用方法”到“敢自己实现”的过渡
很多人问:前端业务里都是操作数据、渲染视图,数据结构和算法到底用在哪?我的回答是:你用Array.prototype.filter的时候,就是在遍历数组,复杂度是O(n);你频繁用unshift往数组头部加数据,系统背后要搬移所有元素,复杂度也是O(n);你写了一个递归渲染树形菜单,那棵树就是一棵多叉树;你做一个“稍后自动重试”的调度器,背后很可能就是一个最小堆。
这些场景每天都在发生,只是多数时候轮子已经被别人造好了。学习数据结构的意义,就是让你在轮子出问题、需要自建工具、或者面试被问底层原理的时候,能低头看清结构。这一篇的每一章都按这个思路来:先讲这个结构解决什么问题,再用JS从头实现,最后给一个真实场景串起来。系列(一)讲完的栈、队列是线性结构的入口,现在往更复杂的方向走:不连续存储、非线性关系、更巧妙的时间复杂度优化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链表:在JS里被“内置数组”惯坏之后,反而最能拉开差距的结构
2.1 数组那么方便,为什么还要自己写链表
数组的push和pop在大多数浏览器引擎里都做了很好的优化,尾插尾删基本是O(1),但头插头删不是。unshift之所以慢,是因为它要把后面所有元素都往后挪一位。如果你在一个列表里高频地在头部插入新数据,比如任务队列、消息流,直接用数组会看到明显的卡顿。
链表的价值在于,它不要求物理内存连续,每个节点只需要记住“下一个节点在哪”。插入和删除只要改相邻节点的指针,不需要搬移任何元素。代价是访问某个节点要从头往后走,随机访问变成了O(n)。但JS里恰恰有很多场景是“顺序遍历多、随机下标访问少”的,这时候链表比数组更自然。
2.2 从零实现一个带迭代器的双向链表
最常用的是双向链表,因为单向链表在删除某个节点时要找到它的前驱,必须从头扫一遍,很尴尬。双向链表每个节点有prev和next两个指针,删除时直接操作前后节点就能完成。我直接给一个可跑的完整实现:
javascript复制class Node {
constructor(data) {
this.data = data;
this.prev = null;
this.next = null;
}
}
class DoublyLinkedList {
constructor() {
this.head = null;
this.tail = null;
this.length = 0;
}
append(data) {
const node = new Node(data);
if (!this.head) {
this.head = node;
this.tail = node;
} else {
node.prev = this.tail;
this.tail.next = node;
this.tail = node;
}
this.length++;
return node;
}
insertAfter(node, data) {
if (!node) return null;
const newNode = new Node(data);
newNode.prev = node;
newNode.next = node.next;
if (node.next) {
node.next.prev = newNode;
} else {
this.tail = newNode;
}
node.next = newNode;
this.length++;
return newNode;
}
remove(node) {
if (!node) return;
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
this.length--;
}
*[Symbol.iterator]() {
let current = this.head;
while (current) {
yield current.data;
current = current.next;
}
}
}
这段代码里有几个细节要特别注意:insertAfter和remove看起来简单,但最容易错的是边界条件。如果node已经是尾节点,node.next是null,直接读node.next.prev就会报错,所以要先判断。remove里如果删的是头节点,要更新head;如果删的是尾节点,要更新tail。别小看这几行,写链表题时很多崩溃都来自这种边界。
有了迭代器之后,for...of可以直接遍历链表,这点比C语言版方便得多。调试的时候可以写一个toArray()方法:把链表按顺序转回数组,用console.log(linkedList.toArray())去看每一步操作结果,比自己内心模拟指针靠谱一万倍。
2.3 链表问题的经典场景:LRU淘汰策略
链表和哈希表组合起来,能实现一个非常有名的结构:LRU缓存(Least Recently Used,最近最少使用)。在浏览器缓存、后端缓存、操作系统的页面置换里,都是核心策略。思路简单说:用一个哈希表快速判断数据是否存在,用一个双向链表维护访问顺序。
每次访问一个key,如果命中,就把对应节点从链表当前位置摘下来,移动到头部;如果没命中,就判断缓存是否满了,满了就把尾节点删掉,再在头部插入新节点。为什么不只用数组?因为数组里“把某个节点移到头部”的代价是O(n),而链表只需要O(1)改几个指针。这个例子完美说明:哈希表负责“快速找”,链表负责“保持顺序”,两者缺一不可。
我建议你亲自动手把这个LRU类写一遍,这是前端面试里频率极高的手写题。写完你会发现,理解双向链表比背面试题答案有用得多。
3. 树:从递归到迭代,从二叉树到DOM
3.1 二叉树三种遍历,递归和迭代到底差在哪里
树这种结构在JS里太常见了,小到操作系统的文件目录,大到页面里的组件树。讨论树,核心就是遍历。二叉树的先序、中序、后序,用递归写非常简洁:
javascript复制function preorder(root) {
if (!root) return;
console.log(root.val);
preorder(root.left);
preorder(root.right);
}
这段代码的逻辑量很少,但问题在于递归有深度限制。当树的深度达到几千层(比如把A标签恶意嵌套的DOM,或者某些极端数据),V8的调用栈会溢出,直接报Maximum call stack size exceeded。这时候就需要把递归改成显式的栈迭代。
先序迭代比较简单,先把右子树入栈,再把左子树入栈,这样出栈顺序就是中-左-右。但中序迭代就卡人很多:你要不断往左走,走到头输出,再转向右子树。我见过不少面试者在这道题上翻车,核心原因是没有想清楚“出栈的时候,左子树一定已经处理完”。
javascript复制function inorder(root) {
const stack = [];
const result = [];
let current = root;
while (current || stack.length) {
while (current) {
stack.push(current);
current = current.left;
}
current = stack.pop();
result.push(current.val);
current = current.right;
}
return result;
}
看明白没?内层循环一直往左压栈,直到没有左孩子,弹出当前节点,再处理它的右子树。这就是“把系统栈换成手动栈”。层序遍历则换一种思路:用队列,先入先出,逐层处理,每一层的节点都放出来、把孩子再放回去。这些遍历方式,是后续一切树形算法的基础。
3.2 用JS对象手写一棵二叉搜索树
二叉搜索树的特点是左子树所有节点值小于根节点,右子树所有节点值大于根节点。查找、插入、删除的平均复杂度是O(log n)。用JS写这类树,不需要像C语言那样为了处理指针而额外费心思,对象引用天然就是“指针”,写起来反而更贴近直觉。
javascript复制class BSTNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
class BST {
constructor() {
this.root = null;
}
insert(val) {
if (!this.root) {
this.root = new BSTNode(val);
return;
}
let current = this.root;
while (true) {
if (val < current.val) {
if (!current.left) {
current.left = new BSTNode(val);
return;
}
current = current.left;
} else {
if (!current.right) {
current.right = new BSTNode(val);
return;
}
current = current.right;
}
}
}
search(val) {
let current = this.root;
while (current) {
if (val === current.val) return current;
current = val < current.val ? current.left : current.right;
}
return null;
}
}
插入和查找都比较直接,麻烦的是删除,因为要处理三种情况:删除叶子节点、删除只有一个子节点的节点、删除有两个子节点的节点。第三种情况最常见的做法是,找到右子树里最小的节点(中序后继),把它复制到当前节点,然后删除那个最小的节点。这个操作你不亲手写一遍,面试时很容易漏条件。我建议写完后再用大量随机数据做验证——插入100个数字,全部搜索一遍,再随机删除,每删一次都检查它是否符合二叉搜索树的性质。
3.3 把树的遍历思想用到真实DOM上
前端工程师最熟悉的树就是DOM树。你可以完全不依赖document.querySelectorAll,自己遍历整个页面:
javascript复制function walkDOM(root) {
const result = [];
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (node.nodeType === Node.ELEMENT_NODE) {
result.push(node);
}
const children = node.children;
for (let i = children.length - 1; i >= 0; i--) {
stack.push(children[i]);
}
}
return result;
}
这就是一个非递归的先序遍历。把子节点逆序压入栈,是为了保持和DOM树从左到右的顺序一致。做性能分析、统计页面里所有标签数量、定位深层节点时,这种手动遍历往往比querySelectorAll更灵活,因为你可以在遍历过程中加各种判断条件。理解了树的遍历,看React的Fiber架构、看虚拟DOM的diff策略,都会感觉亲切很多——大家都是靠遍历树来解决问题的。
4. 图:邻接表、BFS/DFS,以及前端地图里的可达性
4.1 为什么JS最适合用邻接表表示图
图比树更灵活,因为每个节点可以和任意多个节点相连。表示一个图,常见有两种方式:邻接矩阵和邻接表。
邻接矩阵用二维数组graph[i][j] = 1表示i到j有边,优点是查询任意两点是否相连是O(1),缺点是空间浪费严重,在社交网络、页面依赖这种“大部分节点不相连”的场景,存一堆0毫无意义。邻接表则对每个节点维护一个邻居列表,只存真实存在的边,空间开销小很多。
JS里面用对象或Map来表示邻接表,非常自然。Map比普通对象更好,因为键可以是任意类型,而且遍历顺序稳定。我一般这样写:
javascript复制class Graph {
constructor() {
this.adjList = new Map();
}
addVertex(vertex) {
if (!this.adjList.has(vertex)) {
this.adjList.set(vertex, []);
}
}
addEdge(vertex1, vertex2) {
this.adjList.get(vertex1).push(vertex2);
this.adjList.get(vertex2).push(vertex1);
}
}
4.2 BFS求最短路径,DFS做连通性检测
图上最基础的两个搜索算法是BFS(广度优先搜索)和DFS(深度优先搜索)。BFS用队列,逐层向外扩展。在无权图中,BFS第一次到达某个节点的层数,就是到它的最短路径长度,因为它保证了“先到先得”。
javascript复制function bfsShortestPath(graph, start, target) {
const queue = [[start, 0]];
const visited = new Set([start]);
while (queue.length) {
const [node, distance] = queue.shift();
if (node === target) return distance;
for (const neighbor of graph.adjList.get(node)) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push([neighbor, distance + 1]);
}
}
}
return -1;
}
这里有一个性能陷阱必须提醒:用数组当队列,shift的时间复杂度是O(n),因为要搬移元素。数据量小无所谓,数据量大时建议用一个带头指针的循环数组或链表实现队列。这恰好又印证了前两章的价值:写BFS时,队列的底层实现直接影响性能。
DFS则用栈或递归。它的典型用途是判断连通性、检测图中是否存在环、寻找某条可行路径。比如做一个简单的城市公交网络,想知道两个站台之间有没有通路、最少换乘几次,BFS直接搞定。前端场景里,构建组件依赖关系、遍历表单字段引用链,也是同样的思路。
4.3 图的工程落地:依赖关系与状态传送
图不只在面试中出现。前端构建工具里有一个非常核心的问题:模块依赖分析。入口文件依赖了哪些文件,这些文件又依赖了哪些文件,这就是以入口为根的一张有向图,构建工具要遍历这张图,分析哪些模块会被用到、哪些可以被打包进去。
再比如SPA应用里的状态流转,你把页面的每个状态当作节点,用户能触发的跳转行为当作边,这就是一个典型的有向图。BFS可以用来找从一个状态到另一个状态的最短触发路径,DFS可以用来排查是否存在“死循环状态”,即从一个状态出发能不能绕回自己。写权限系统和表单流程引擎时,这些算法特别顺手。
我常用的一个技巧:如果图数据量不大,直接用JSON对象来存邻接表:
javascript复制const graph = {
'home': ['login', 'dashboard'],
'login': ['dashboard', 'reset-password'],
'dashboard': ['settings'],
'settings': ['home'],
};
然后写一个通用的BFS函数接收这个对象。这样做的好处是数据本身就是可读的,调式时打印出来一目了然。
5. 哈希表与堆:散列冲突、Map/Set,以及用数组手写最小堆
5.1 Object、Map、Set的选择,背后其实是哈希表原理
哈希表可能是你用得最多却最没感觉的数据结构。JS里访问对象属性、Map取值、Set去重,底层都是哈希表。哈希表的核心是用一个哈希函数,把键映射到数组下标,通过下标直接定位,所以查找是O(1)级别。
哈希函数不可能对每个输入都生成唯一下标,所以必然存在散列冲突。常见解决办法是链地址法:把碰撞的元素放到同一条链表里。这也是为什么有些情况下哈希表会退化成链表,最坏查找复杂度变成O(n)。当然,现代引擎在冲突超过阈值时会自动扩容重哈希,日常业务里基本遇不到退化情况,但理解这个机制会有助于解释“为什么对象键数量特别多时性能会下降”。
那日常到底用Object还是Map?我建议:需要频繁增删键值对、键类型不一定是字符串、或者需要清楚知道遍历顺序时,用Map。普通对象更适合表示“固定结构”的数据,比如配置项。表格对比一下:
| 特性 | Object | Map | Set |
|---|---|---|---|
| 键类型 | 字符串或Symbol | 任意类型 | 任意值 |
| 插入顺序 | 整数键会单独升序排列 | 完全按插入顺序 | 按插入顺序 |
| 大小获取 | Object.keys().length | map.size | set.size |
| 适合场景 | 固定结构、配置项 | 动态增删的哈希表 | 去重、集合运算 |
5.2 用数组实现最小堆,解决Top K问题
堆是一种特殊的完全二叉树,但在JavaScript里实现时不需要显式建树,直接用数组就可以。父节点索引是i,左子节点是2*i+1,右子节点是2*i+2。数组天然就是一棵完全二叉树的层序遍历结果。
最小堆的关键操作有两个:siftUp(上浮)和siftDown(下沉)。插入新节点时,先放到数组末尾,然后不断和父节点比较,如果更小就交换。取最小值时,把根节点和最后一个节点交换,弹出最后一个节点,再从根节点下沉调整。代码如下:
javascript复制class MinHeap {
constructor() {
this.heap = [];
}
size() {
return this.heap.length;
}
peek() {
return this.heap[0];
}
insert(val) {
this.heap.push(val);
this.siftUp(this.heap.length - 1);
}
extractMin() {
if (!this.heap.length) return null;
const min = this.heap[0];
const last = this.heap.pop();
if (this.heap.length) {
this.heap[0] = last;
this.siftDown(0);
}
return min;
}
siftUp(index) {
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (this.heap[parent] <= this.heap[index]) break;
[this.heap[parent], this.heap[index]] = [this.heap[index], this.heap[parent]];
index = parent;
}
}
siftDown(index) {
const n = this.heap.length;
while (true) {
let smallest = index;
const left = 2 * index + 1;
const right = 2 * index + 2;
if (left < n && this.heap[left] < this.heap[smallest]) smallest = left;
if (right < n && this.heap[right] < this.heap[smallest]) smallest = right;
if (smallest === index) break;
[this.heap[index], this.heap[smallest]] = [this.heap[smallest], this.heap[index]];
index = smallest;
}
}
}
Top K问题——比如从海量日志里找出点击量最高的100个页面——是堆最经典的应用场景。思路是维护一个大小为K的最小堆,新元素比堆顶大就替换,这样堆里始终是最大的K个。为什么不用数组排序?因为排序要O(n log n),而堆方案是O(n log K),数据量大时差距非常可观。浏览器内部的JS引擎、React的调度器、数据库的索引维护,到处都是堆在发挥作用。
5.3 数据结构“八股”与JS实际应用之间的桥梁
很多人刷“八股文”时背了一堆结论,比如“哈希表查找O(1)”“堆排序O(n log n)”,但一遇到实际的JS题目就露馅,因为他们不知道这些结论在JS里对应什么。我建议的复习方式不是背,而是每个结构都写一个实际场景验证一遍:
- 数组:高频头插头删,改用链表或倒序存储。
- 哈希表:去重交给Set;需要记录出现次数用Map。
- 堆:优先队列、Top K、定时器小根堆。
- 链表:LRU缓存、撤销重做、分页里的事件流。
写代码时多问一句“这里为什么不用数组?”、“这里为什么不用对象?”。问得多了,数据结构就不是死知识,而是你解决问题时的工具箱。
6. 排序算法里的“数据结构味儿”,以及几个提高复习效率的建议
6.1 快排/归并排序在JS中的实现差异
排序算法是数据结构课程的重头戏,但在JS工程里,很少需要手写排序,因为V8的Array.prototype.sort已经用了足够成熟的排序引擎。手写排序的意义在于理解算法的复杂度来源和稳定性。快速排序是典型的“用递归拆分数组”的算法,平均O(n log n),但递归栈深度在最坏情况下会变成O(n)。归并排序则是“先分后合”,需要额外的数组空间,空间复杂度O(n),但它是稳定排序。
用JS写快排,最大的坑是原地分区。很多人图省事直接用filter创建新数组,这样写很优雅但额外占用大量内存,面试时容易被追问“空间复杂度到底是多少”。真正能体现功底的是原地分区:
javascript复制function quickSort(arr, left = 0, right = arr.length - 1) {
if (left >= right) return;
let pivot = arr[right];
let storeIndex = left;
for (let i = left; i < right; i++) {
if (arr[i] < pivot) {
[arr[i], arr[storeIndex]] = [arr[storeIndex], arr[i]];
storeIndex++;
}
}
[arr[right], arr[storeIndex]] = [arr[storeIndex], arr[right]];
quickSort(arr, left, storeIndex - 1);
quickSort(arr, storeIndex + 1, right);
return arr;
}
这里storeIndex的作用是把小于基准值的元素交换到左边,最后再把基准值放到正确位置。写完之后遍历一遍数组验证有序性,不要只看结果直接跳过。
6.2 时间复杂度与空间复杂度的JS验证
学习算法时最怕只看理论,不看实际。我建议写一个简单的计时器,对同一份随机数组分别跑数组原生sort、手写快排、手写归并排序,比较不同数据规模(100、1000、10000、100000)下的耗时。这个实验会让你直观感受到O(n log n)和O(n²)的区别,特别是当你特意构造一个接近有序的数组时,快排如果基准值选得不好会退化得很明显。
空间复杂度同样可以“感受”。开一个很大的数组,用递归归并排序,观察浏览器内存占用;再换成原地快排,感受一下差异。记忆这些东西会比背书里的复杂度表格牢固得多。
6.3 我的个人复习路线:从入门到能讲给别人听
走到这一步,知识点已经不少了。我自己的经验是:看完一个结构,别急着看下一个,先尝试用这个结构解决两三个经典问题,然后试着把它的实现思路讲给别人听。讲不明白的地方,就是自己没学透的地方。
可以用“费曼式”的方法写一个自查清单:链表插入节点分几步?树的中序迭代为什么先一路向左?哈希表冲突怎么解决?堆的siftDown为什么是从数组末尾往前处理?BFS和DFS的遍历顺序差别是什么?这些问题用JS代码写一遍,再用语言说一遍,比刷十道网上同类型题都管用。
如果还要结合经典教材,比如严蔚敏老师的教材或者各类考研辅导资料,我的建议是:看它们理解定义和算法推导,但动手实现时一定要用JS写。语言只是工具,关键是那些结构背后的逻辑——描述存储方式、维护数据关系、平衡时间与空间的取舍——这些在任何语言里都是相通的。
最后再说一句实在的:我刚开始学这些东西的时候,也觉得很多结构可能一辈子都用不上。但后来发现,当你真的面对一个复杂的前端项目时,那种“复杂度敏感”会潜移默化地影响你每次选择数据的方式。它是慢功夫,但确实值得下。这一篇的数据结构部分到这里就收尾了,下一篇如果继续写,我会往更进阶的“平衡二叉树、并查集、字符串匹配”方向走,有兴趣的可以继续跟。
