当算法遇上语言特性,理解才会真正深刻。
开篇:一道经典面试题引发的思考
"如何用栈来模拟队列?"
这是每个后端开发者都绕不开的经典面试题,也是我最近一次算法课的核心内容。但这次课程的不同之处在于,老师并没有止步于算法本身,而是顺着代码实现一路深入,带我们走进了 JavaScript 面向对象的底层机制——原型链、new 操作符、prototype 与 __proto__ 的三角关系。
一、栈与队列:两种"相反"的数据结构
在动手写代码之前,我们先快速回顾一下这两种数据结构。
栈(Stack) 是一种 后进先出(FILO, First In Last Out) 的线性数据结构。你可以把它想象成叠盘子——你总是把新盘子放在最上面,取盘子时也只能从最上面取。在 JavaScript 中,数组的 push() 和 pop() 天然就是栈的行为。
队列(Queue) 则是 先进先出(FIFO, First In First Out)。它更像排队买奶茶——先来的人先买到,后来的人排在队尾。队列需要支持四种核心操作:
push(x)— 将元素放入队尾pop()— 从队头移除并返回元素peek()— 只查看队头元素,不移除empty()— 判断队列是否为空
核心矛盾在于:栈只能在"顶部"操作,而队列要求"头部出、尾部进"。这两个操作方向刚好相反——这就是问题有趣的地方。
二、两个栈模拟队列:思路与实现
2.1 核心思想
既然一个栈不能满足需求,那我们就用 两个栈 来配合工作:
- stack1(输入栈):专门负责接收
push,新元素直接压入栈顶 - stack2(输出栈):专门负责处理
pop和peek
关键操作在于 倒数据:当需要出队且 stack2 为空时,把 stack1 中的所有元素逐个弹出并压入 stack2。由于栈的 FILO 特性,倒一次之后,最早进入 stack1 的元素就跑到了 stack2 的栈顶,此时直接 pop stack2 就实现了 FIFO。
2.2 代码实现
const MyQueue = function () {
this.stack1 = [] // 输入栈,负责 push
this.stack2 = [] // 输出栈,负责 pop/peek
}
MyQueue.prototype.push = function (x) {
this.stack1.push(x)
}
MyQueue.prototype.pop = function () {
// 如果 stack2 为空,就把 stack1 全部倒入 stack2
if (this.stack2.length === 0) {
while (this.stack1.length > 0) {
this.stack2.push(this.stack1.pop())
}
}
return this.stack2.pop()
}
MyQueue.prototype.peek = function () {
if (this.stack2.length === 0) {
while (this.stack1.length > 0) {
this.stack2.push(this.stack1.pop())
}
}
return this.stack2[this.stack2.length - 1]
}
MyQueue.prototype.empty = function () {
return this.stack1.length === 0 && this.stack2.length === 0
}
2.3 时间复杂度分析
这个设计最巧妙的地方在于 均摊分析:
push:永远 O(1),直接压入 stack1pop和peek:均摊 O(1)。虽然最坏情况下需要把 stack1 全部倒入 stack2,但每个元素在整个生命周期中最多被"倒"两次(进 stack1 一次、进 stack2 一次)。所以 n 次操作的总复杂度是 O(n),均摊到每次操作就是 O(1)
这也是为什么这道题能成为面试高频题——它同时考察了数据结构理解、算法设计思维和复杂度分析能力。
2.4 一步步走一遍
为了让你对整个过程有直观感受,我们来模拟一次完整的操作流程:
初始化:stack1 = [] stack2 = []
push(1):stack1 = [1] stack2 = []
push(2):stack1 = [1,2] stack2 = []
push(3):stack1 = [1,2,3] stack2 = []
pop():
→ stack2 为空,开始倒数据
→ stack1 弹出 3 压入 stack2:stack1=[1,2] stack2=[3]
→ stack1 弹出 2 压入 stack2:stack1=[1] stack2=[3,2]
→ stack1 弹出 1 压入 stack2:stack1=[] stack2=[3,2,1]
→ stack2.pop() = 1 ✅ 先进先出!
push(4):stack1 = [4] stack2 = [3,2]
pop():
→ stack2 不为空,直接 pop
→ stack2.pop() = 2 ✅ 顺序完全正确!
注意看最后一步:push(4) 之后再次 pop(),此时 stack2 里还有元素,所以我们并不需要再次倒数据——直接弹出 stack2 栈顶即可。这就是 "不为空就不倒" 策略的精髓,也是均摊 O(1) 得以成立的关键。
三、JavaScript 的面向对象:不走寻常路
写完上面这段代码,你可能会注意到:我用的是 function + prototype,而不是 class。
这并不是怀旧,而是这节课的另一个核心主题:JavaScript 的面向对象设计哲学与众不同。
3.1 函数是一等公民
在 JavaScript 中,函数就是普通的对象,你可以给一个函数添加属性:
function greeting() {
console.log('hello world')
}
greeting.a = '1'
console.log(greeting.a) // '1'
greeting() // 'hello world'
这种灵活性在其他主流语言中几乎见不到。正因为函数是对象,它才能同时扮演"构造函数"的角色。
3.2 原型式的面向对象
Java 和 C++ 的面向对象建立在 类(Class) 之上——类是抽象的模板,对象是类的实例。
JavaScript 走了另一条路。它的设计哲学是:一切皆对象,没有真正的类。所谓"面向对象",靠的是 原型(prototype) 机制。
在 JS 里:
MyQueue是一个函数对象,同时也是构造函数MyQueue.prototype也是一个对象,用来存放所有实例共享的方法new MyQueue()创建的实例,通过内部链接__proto__指向MyQueue.prototype,从而"继承"原型上的方法
把类比作"人"这个抽象概念,prototype 就像孔子——他是一个具体的人(对象),但后人可以通过学习他而获得知识和行为规范。这就是 原型式继承 的哲学内核。
3.3 class 语法糖 vs 原型语法
ES6 引入了 class 关键字,让从 Java/C++ 转过来的开发者更容易上手:
class MyQueue {
constructor() {
this.stack1 = []
this.stack2 = []
}
push(x) {
this.stack1.push(x)
}
}
看起来舒服了很多——但请注意,这仅仅是语法糖。底层依然是构造函数 + prototype,typeof MyQueue 的结果仍然是 "function",MyQueue.prototype.push 依然存在。理解了原型机制,你才能真正调试 class 写出来的代码。
3.4 为什么 JS 要这样设计?
1995 年,Brendan Eich 在 Netscape 用 10 天创造了 JavaScript。当时公司管理层希望这门语言像 Java,但 Eich 受到 Scheme 函数式编程和 Self 语言原型继承的启发,选择了一条不同的路:用原型链代替类继承,用函数代替类定义。这种设计让 JS 既轻量又灵活——你可以随时给 prototype 添加方法,所有实例立即获得更新,无需重新编译。
四、深入 new 操作符:从空对象到完整实例
写 const queue = new MyQueue() 时,底层到底发生了什么?这可能是这节课最"开脑洞"的部分。
new 操作符的执行可以分为四步:
- 创建空对象:创建一个全新的空对象
{},this指向它 - 绑定原型:将新对象的
__proto__设置为构造函数的prototype,这样实例就能访问原型上的方法 - 执行构造函数:执行函数体,在
this上添加属性(比如this.stack1 = []) - 返回实例:返回
this(除非构造函数显式return了一个对象)
用代码模拟 new 的过程:
function myNew(constructor, ...args) {
// 1. 创建空对象
const obj = {}
// 2. 绑定原型链
Object.setPrototypeOf(obj, constructor.prototype)
// 3. 执行构造函数
const result = constructor.apply(obj, args)
// 4. 返回实例
return result instanceof Object ? result : obj
}
理解了这四步,你就会发现 new 并不是什么魔法——它只是 创建对象 → 绑定原型 → 初始化属性 → 返回实例 这套流程的语法糖。
五、原型链:JavaScript 继承的真正面貌
当你在实例上访问一个属性时,JavaScript 会:
- 先在实例自身查找
- 如果找不到,沿着
__proto__去原型对象上找 - 如果还找不到,继续沿着
__proto__.__proto__往上找 - 一直找到
null为止
这条路就叫 原型链。
5.1 三角关系:构造函数 · 原型 · 实例
这三个角色的关系是理解 JS 面向对象的基石:
- 构造函数 有一个
prototype属性,指向原型对象 - 原型对象 有一个
constructor属性,指回构造函数 - 实例 有一个
__proto__属性(隐式原型),指向原型对象
所以当我们写:
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.say = function () {
console.log(`我叫${this.name},很高兴认识你!`)
}
const zfj = new Person('zfj', 18)
zfj.say() // 我叫zfj,很高兴认识你!
zfj 本身没有 say 方法,但 JavaScript 沿着 zfj.__proto__(即 Person.prototype)找到了它。
5.2 核心法则总结
记住下面这几条法则,你就真正掌握了 JS 原型体系:
| 法则 | 说明 |
|---|---|
实例.__proto__ === 构造函数.prototype | 实例的原型指向构造函数的原型对象 |
原型对象.constructor === 构造函数 | 原型对象的 constructor 指回构造函数 |
函数.__proto__ === Function.prototype | 函数也是对象,由 Function 构造 |
| 原型链终点 | Object.prototype.__proto__ === null |
| 属性查找规则 | 先自身 → 再沿 __proto__ 逐级向上 → 直到 null |
任何对象的原型链,在到达 null 之前,一定会经过 Object.prototype。这就是 JS 世界"万物皆对象"的技术基础。
5.3 在浏览器里亲手验证
打开 Chrome DevTools,复制以下代码到 Console:
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.poem = '仁义礼智信'
Person.prototype.say = function () {
console.log(`我叫${this.name},很高兴认识你!`)
}
const zfj = new Person('zfj', 18)
console.log(zfj.__proto__ === Person.prototype) // true
console.log(Person.prototype.constructor === Person) // true
console.log(Person.__proto__ === Function.prototype) // true
console.log(Person.prototype.__proto__ === Object.prototype) // true
console.log(Object.prototype.__proto__) // null
// 属性查找演示
console.log(zfj.hasOwnProperty('name')) // true — 自身属性
console.log(zfj.hasOwnProperty('say')) // false — 来自原型
console.log('say' in zfj) // true — in 会沿原型链查找
console.log(zfj.toString()) // 可以! — 来自 Object.prototype
亲手跑一遍这些验证代码,比读十篇文章都管用。特别是最后一个 zfj.toString()——zfj 本身没有 toString,Person.prototype 上也没定义,但 JavaScript 一路沿着原型链找到了 Object.prototype.toString。这便是原型链在日常开发中最真实的体现。
六、总结:算法思维与语言理解相辅相成
这堂课的独特之处在于:它不是孤立地讲算法,也不是孤立地讲语言特性,而是让 算法的实现过程成为理解语言特性的最佳入口。
用栈模拟队列告诉我们:
- 数据结构是工具,组合使用可以突破单个结构的限制
- 均摊分析是评价算法性能的重要视角
而顺着实现代码深入下去,JavaScript 的原型体系告诉我们:
- JS 没有传统意义上的"类",但通过 prototype 机制实现了更灵活的面向对象
new不是魔法,是四步操作的组合- 原型链是 JS 对象继承和属性查找的底层机制
如果你正在准备面试,我的建议是:不要只背答案。试着像这堂课一样,把算法问题和语言特性连接起来——当你理解了 new 的内部原理,回头再写 const queue = new MyQueue() 这行代码时,你看到的是一个完整的对象创建和继承过程,而不只是一个语法形式。
这才是真正"学会"的感觉。