本节我们基于对数组的理解和掌握,围剿线性数据结构(栈、队列和链表)。
# 栈和队列
30 秒速记
- 栈 = 后进先出
LIFO,只动一头;队列 = 先进先出FIFO,尾进头出 - 栈直接
push+pop;队列别用shift扛大数据,维护head下标,出队只挪指针 - 栈的场景:括号匹配、撤销、函数调用、
DFS、单调栈;队列:BFS/ 层序遍历、任务调度 - 带
head的队列,长度是items.length - head,别直接看数组长度 - 真做任务队列还要定容量、满了怎么办(拒绝 / 等待 / 丢弃)、失败怎么重试
栈是后进先出,像一摞盘子只从顶上拿;队列是先进先出,像排队买饭先到先得。 在 JS 里两者都用数组模拟,栈直接 push 加 pop 就完了。队列按教科书写是 push 加 shift,但 shift 每次要把后面所有元素往前挪一位,BFS 跑一万个结点就是上千万次搬移。我一般给队列维护一个 head 下标,出队只把 head 加一,有效区间是 [head, items.length),攒到一定量再批量 slice 一次,均摊下来还是 O(1)。
下面给出一个不会因连续 shift() 退化的队列。入队和出队的均摊时间都是 O(1);当已消费空间超过一半时才批量压缩一次,避免底层数组无限增长。
class Queue {
#items = []
#head = 0
enqueue(value) {
this.#items.push(value)
}
dequeue() {
if (this.size === 0) return undefined
const value = this.#items[this.#head]
this.#head += 1
if (this.#head > 1024 && this.#head * 2 > this.#items.length) {
this.#items = this.#items.slice(this.#head)
this.#head = 0
}
return value
}
get size() {
return this.#items.length - this.#head
}
}
队列的不变量是有效区间始终为 [head, items.length);dequeue() 只移动 head,不移动剩余元素。批量 slice() 单次是 O(n),但不会每次出队都发生,所以连续操作的均摊成本仍是常数级。若队列必须限制内存,应额外设置容量,并让 enqueue() 在满时拒绝、等待或丢弃,三种策略必须由业务决定。
在 JavaScript 中,栈和队列的实现一般都要依赖于数组,大家完全可以把栈和队列都看作是“特别的数组”。
(注:实际上,栈和队列作为两种运算受限的线性表,用链表来实现也是没问题的。只是从前端面试做题的角度来说,基于链表来实现栈和队列约等于脱裤子放屁(链表实现起来会比数组麻烦得多,做不到开箱即用),基本没人会这么干。这里大家按照数组的思路往下走就行了)
两者的区别在于,它们各自对数组的增删操作有着不一样的限制。因此,在学习栈与队列之前,我们需要先来明确一下数组中的增删操作具有什么样的特性、对应的方法有哪些:
面试官追问
追问 1BFS 用 queue.shift() 出队,结点一多就慢,为什么?
shift 删掉第一个后,后面所有元素下标都要减一,单次 O(n),整趟下来接近 O(n²)。改成 let head = 0; const node = queue[head++],出队只挪指针。
追问 2用 head 指针的队列,判空写成 queue.length === 0 对吗?
不对,出过队的元素还留在数组里,length 不会变小。判空要写 head === queue.length,或者 size = length - head。
追问 3浏览器的「后退」和「前进」,你会用什么结构实现?
两个栈。访问新页面压进后退栈并清空前进栈;点后退就从后退栈 pop 出来压进前进栈,点前进反过来。
追问 4head 指针一直往后走,数组越来越大,内存会不会一直涨?
会,所以要定期压缩。比如 head 超过一半长度时 items = items.slice(head); head = 0,单次 O(n),但不会每次都发生,均摊仍是 O(1)。
追问 5评论审核按提交顺序处理,你用栈还是队列?
队列。用栈的话最新提交的先审,早提交的可能一直排在底下审不到,用户就投诉了。
# 灵活增删的数组
30 秒速记
- 加:
unshift头 /push尾 /splice(i, 0, x)任意位置 - 删:
shift头 /pop尾 /splice(i, 1)任意位置 splice(start, deleteCount, ...items):第二个参数是删几个,不是结束下标- 尾部增删均摊
O(1),头部和中间要挪后面的元素 →O(n) - 这些方法都原地改数组;不想改原数组用
ES2023的toSpliced
数组头、尾、中间都能增删:头部用 unshift / shift,尾部用 push / pop,任意位置用 splice。 splice 最容易记错,三个参数分别是起始下标、删几个、要插入的元素,[1, 2].splice(1, 0, 3) 是在下标 1 删零个、插一个 3,结果 [1, 3, 2],原来的 2 被挤到后面。复杂度上,动尾巴不影响别人,算 O(1);动头部或中间,后面的元素都得挪位置,算 O(n)。另外这几个方法都是原地修改,React 里直接对 state 数组 splice 是不会触发更新的。
数组的增删操作可以说是没有任何限制的,允许我们在任何位置执行想要的操作。
数组中增加元素的三种方法
// unshift 方法-添加元素到数组的头部
const arr = [1,2]
arr.unshift(0) // [0,1,2]
// push 方法-添加元素到数组的尾部
const arr = [1,2]
arr.push(3) // [1,2,3]
// splice 方法-添加元素到数组的任何位置
const arr = [1,2]
arr.splice(1,0,3) // [1,3,2]
这里重点讲一下这个 splice 方法。很多同学对传入两个以上的参数这种用法可能比较陌生。大家相对熟悉的应该还是 splice 用于删除的操作:
arr.splice(1,1)
第一个入参是起始的索引值,第二个入参表示从起始索引开始需要删除的元素个数。这里我们指明从索引为1的元素开始,删掉1个元素,也就相当于把 arr[1] 给删掉了。这就是数组中删除任意位置元素的方法。
至于传入两个以上参数这种用法,是用于在删除的同时完成数组元素的新增。而从第三个位置开始的入参,都代表着需要添加到数组里的元素的值:
arr.splice(1,0,3) // [1,3,2]
在这个例子里,我们就指明了从 arr[1] 开始,删掉 0 个元素,并且在索引为1的地方新增了值为3的元素。
因为删掉的元素是0个,所以说 arr[1] 中原有的元素值“2”仍然会被保留下来;同时因为我们后面又指定了 arr[1] 处需要新增一个元素3,那么这个3就会把原来arr[1]这个地方的元素给“挤到后面去”。这样我们就做到了在数组中任意位置进行元素的新增。这个过程如下图:

数组中删除元素的三种方法
// shift 方法-删除数组头部的元素
const arr = [1,2,3]
arr.shift() // [2,3]
// pop 方法-删除数组尾部的元素
const arr = [1,2,3]
arr.pop() // [1,2]
splice方法-删除数组任意位置的元素splice方法我们上文已经详细讲过,此处不再赘述。
面试官追问
追问 1[1, 2].splice(1, 0, 3) 之后,原来的 arr[1] 被删了吗?
没有。第二个参数 0 表示删零个,结果是 [1, 3, 2],2 只是被挤到后面。写成 splice(1, 1, 3) 才是把 2 换成 3。
追问 2删第 10 个组件写成 arr.splice(10, arr.length),后面的全没了,为什么?
第二个参数是删除个数,不是结束下标,这样写等于从 10 删到末尾。删一个就 arr.splice(10, 1),另外确认一下产品说的「第十个」是不是下标 9。
追问 3删导航项用 delete arr[i] 行不行?
不行。delete 只删属性,长度不变,留下一个空槽,渲染时会多出一个空位。用 splice(i, 1) 或者 filter 返回新数组。
追问 4React 里 list.splice(i, 1); setList(list),页面为什么没更新?
splice 原地改,list 还是原来那个引用,React 比较引用相同就跳过渲染。改成 setList(list.filter((_, idx) => idx !== i)),或者 list.toSpliced(i, 1)。
