从"用栈实现队列"说起:深入理解 JavaScript 原型式面向对象
一、引言
在算法面试中,**"用栈实现队列"**是一道经典的高频题。这道题不仅考察对基本数据结构的理解,更考验思维的灵活转换能力。而当我们用 JavaScript 来实现它时,又会自然地牵出另一条主线:JavaScript 独特的原型式面向对象机制。
本文将以这道算法题为主线,结合 1.js / 2.js / 3.js / 4.html 四个代码示例,串联起数据结构基础 、算法设计思路 、JS 原型链机制 以及new 运算符原理四个维度的知识,帮助你构建一张完整的知识网络。
二、数据结构基础:栈与队列
在动手编码之前,我们首先需要精准理解两个核心数据结构的定义。
2.1 栈(Stack)
栈是一种线性数据结构 ,遵循 LIFO(Last In First Out,后进先出) 原则。可以把栈想象成一摞盘子------你只能从最上面取盘子,也只能把新盘子放在最上面。
栈的核心操作:
push(x)--- 将元素压入栈顶pop()--- 弹出栈顶元素并返回peek()--- 查看栈顶元素但不弹出empty()--- 判断栈是否为空
在 JavaScript 中,数组自带的 push() 和 pop() 方法天然适配栈的行为:
js
const stack = [];
stack.push(1); // 入栈
stack.push(2);
stack.pop(); // 出栈 → 2(后进先出)
2.2 队列(Queue)
队列同样是一种线性数据结构 ,但遵循 FIFO(First In First Out,先进先出) 原则。队列就像排队买奶茶------先来的人先被服务,后来的人排在队尾。
队列的核心操作:
push(x)--- 将元素放入队列尾部pop()--- 从队列首部移除元素peek()--- 返回队列首部的元素empty()--- 返回队列是否为空
关键矛盾点:栈是"后进先出",队列要求"先进先出"------这恰好是相反的顺序。那么,如何用两个"后进先出"的结构,拼出一个"先进先出"的行为?这就是本题的核心挑战。
三、核心算法:用两个栈实现队列
3.1 算法思路
既然一个栈只能倒序输出,那两个栈串联就能实现"负负得正"的效果:
- 入队时 :直接将元素压入
stack1(入队栈) - 出队时 :
- 如果
stack2(出队栈)为空,则将stack1中的所有元素逐一弹出并压入stack2。这个过程会将元素顺序反转,使得最早进入stack1的元素现在位于stack2的栈顶 - 然后从
stack2弹出栈顶元素
- 如果
makefile
入队序列: 1 → 2 → 3
入队阶段(全部进 stack1):
stack1: [1, 2, 3] (栈顶在右)
出队准备(stack1 → stack2,顺序反转):
stack2: [3, 2, 1] (栈顶在右)
弹出 stack2 栈顶 → 1(先进先出!)
3.2 代码实现分析(1.js)
js
const MyQueue = function() {
// 构造函数:初始化两个栈
this.stack = []; // 入队栈 --- 负责接收 push 进来的元素
this.stack2 = []; // 出队栈 --- 负责提供 pop 出去的元素
}
代码解析 :
MyQueue作为构造函数,使用this在实例上挂载两个数组属性。stack专用于入队操作,stack2专用于出队操作。两个栈各司其职,通过惰性转移(出队时才搬运)来分摊时间复杂度。
js
MyQueue.prototype.push = function(x) {
this.stack.push(x);
}
代码解析 :
push方法被定义在MyQueue.prototype上,意味着所有MyQueue实例将共享 同一个函数引用,而不是每个实例都复制一份。入队操作非常简单------直接往stack中压入元素即可,时间复杂度 O(1)。
js
const queue = new MyQueue();
console.log(queue, queue.push());
代码解析 :
new运算符触发了构造函数执行,创建了一个新的MyQueue实例。queue.push()调用时,JavaScript 引擎先在实例自身属性上查找push,未找到;随后沿着__proto__链向上查找,在MyQueue.prototype上找到并执行。这便是原型链查找机制的完整运作过程。
3.3 复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
push |
O(1) | 直接压入 stack1 |
pop |
均摊 O(1) | 每个元素最多被搬运一次(从 stack1 到 stack2),均摊下来仍是常数时间 |
peek |
均摊 O(1) | 与 pop 逻辑相同,只是不弹出 |
empty |
O(1) | 判断两个栈是否都为空 |
四、JavaScript 原型式面向对象基础
上面的 MyQueue 实现中,我们用到了 prototype、new、this 等机制。这些是 JavaScript 面向对象编程的基石,但它们的运作原理与传统的类式语言(如 Java、C++)截然不同。
4.1 函数是一等公民(2.js)
js
function greeting() {
console.log('hello world');
}
greeting.a = '1';
console.log(greeting.a); // '1'
greeting(); // 'hello world'
代码解析 :这段代码揭示了一个看似矛盾的现象------
greeting明明是一个函数,却可以像普通对象一样被动态添加属性a。这在 Java 或 C++ 中是不可思议的。原因在于:JavaScript 中的函数本质上也是对象 (Function的实例),函数名只是一个指向该函数对象的引用。正因为函数是对象,它才能拥有自己的属性,也才能被当作参数传递、从函数中返回------这正是"函数是一等公民"的底层原理。
4.2 构造函数与原型(3.js)
js
function Greeting(name) {
this.name = name;
}
代码解析 :按照约定,构造函数首字母大写(
Greeting而非greeting),以区别于普通函数。当通过new调用时,函数内部的this不再指向全局对象,而是指向新创建的那个实例对象。this.name = name这行代码实际上在说:"给正在被创建的这个新对象,挂上一个name属性。"
js
Greeting.prototype.say = function() {
console.log(`我叫${this.name}, 很高兴认识你`);
}
Greeting.prototype.work = function() {
console.log(`我叫${this.name}, 我正在工作`);
}
代码解析 :
say和work方法没有直接定义在构造函数内部,而是挂载到了Greeting.prototype(原型对象)上。这样做有两大好处:
- 内存节省 :无论创建多少个
Greeting实例,say和work方法在内存中只有一份拷贝,所有实例通过原型链共享- 动态扩展 :即使在实例创建之后,给
prototype添加新方法,已有实例也能立即访问到(因为查找是动态沿原型链进行的)
js
const zzh = new Greeting('张志和');
console.log(zzh.name); // '张志和'
zzh.say(); // '我叫张志和, 很高兴认识你'
zzh.work(); // '我叫张志和, 我正在工作'
代码解析 :
zzh实例本身只有name这一个自有属性。当调用zzh.say()时,JS 引擎先在zzh自身属性中查找say→ 未找到 → 沿着zzh.__proto__(即Greeting.prototype)向上查找 → 找到并执行。这个"自身 → 原型 → 原型的原型 → ... → null"的查找链路,就是原型链(Prototype Chain)。
4.3 完整原型链实战(4.html)
前面的例子只展示了"一级"原型链查找(实例 → 构造函数原型),现在让我们通过 4.html 来观察一个完整的多级原型链运作过程:
js
function Person(name, age) {
// 构造实例的,实例私有的
this.name = name;
this.age = age;
}
代码解析 :与
3.js中的Greeting类似,Person构造函数负责为每个实例创建私有属性name和age。这些属性直接挂载在实例自身,每个实例各有一份,互不影响。
js
// 原型对象上的方法是公用的
Person.prototype.poem = '仁义礼智信'
Person.prototype.say = function() {
console.log(`我叫${this.name}, 很高兴认识你`);
}
Person.prototype.timeMF = function() {
console.log(`时间管理魔法`);
}
代码解析 :这里有一个容易被忽略的细节------
prototype上不仅可以挂方法 (say、timeMF),还可以挂属性值 (poem)。poem: '仁义礼智信'是一个共享属性,所有Person实例访问zwy.poem时得到的都是同一个值。但要注意:如果某个实例通过zwy.poem = '自定义'修改,实际上是在实例自身创建了一个同名属性覆盖原型上的值,而不会影响其他实例------这正是原型链查找的"写时遮蔽"特性。
js
const zwy = new Person('邹伟洢', 18);
console.log(zwy.toString());
代码解析 :这是整段代码中最关键的一行。
toString()方法在Person构造函数中没定义,在Person.prototype上也没定义,但zwy.toString()却成功执行并输出了[object Object]------这个方法从哪来的?答案藏在原型链的尽头 :当 JS 引擎在
zwy自身和Person.prototype上都找不到toString时,查找并不会结束,而是继续沿着Person.prototype.__proto__向上追溯,最终在Object.prototype上找到了toString方法。Object.prototype是 JavaScript 中几乎所有对象的"终极祖先",它内置了toString()、valueOf()、hasOwnProperty()等一系列基础方法。完整的查找路径如下:
javascriptzwy.toString() → zwy 自身属性? ❌ 没有 → zwy.__proto__ (Person.prototype)? ❌ 没有 → Person.prototype.__proto__ (Object.prototype)? ✅ 找到了!这正是笔记中那句"任何对象,要不原型直接是 Object.prototype,要不终点前一定是 Object.prototype"的生动验证。
五、深入理解 new 运算符
当我们写下 const queue = new MyQueue() 时,JavaScript 在幕后执行了以下步骤:
5.1 new 的完整执行流程
kotlin
┌─────────────────────────────────────────┐
│ 1. 创建一个空对象 {} │
│ ↓ │
│ 2. 将 this 指向这个新创建的对象 │
│ ↓ │
│ 3. 执行构造函数,在 this 上添加属性 │
│ ↓ │
│ 4. 将实例的 __proto__ 指向构造函数的 │
│ prototype 对象 │
│ ↓ │
│ 5. 返回这个新对象(如果构造函数没有 │
│ 显式返回其他对象) │
└─────────────────────────────────────────┘
第 4 步尤为关键:queue.__proto__ === MyQueue.prototype。正是这一赋值操作,打通了实例与原型对象之间的查找通道------实例从此"继承"了原型上定义的所有方法。
5.2 一张图总结原型关系(完整链路)
让我们以 4.html 中的 Person 为例,绘制一条从实例追溯到 null 的完整原型链:
javascript
zwy (实例)
├── name: '邹伟洢' ← 自有属性(构造函数中通过 this 挂载)
├── age: 18 ← 自有属性
└── __proto__ ──────→ Person.prototype ← 一级原型对象
├── poem: '仁义礼智信' ← 共享属性
├── say: function ← 共享方法
├── timeMF: function ← 共享方法
└── __proto__ ──────→ Object.prototype ← 终极原型
├── toString: function
├── valueOf: function
├── hasOwnProperty: function
└── __proto__ ──────→ null ← 终点
当执行
zwy.toString()时,引擎遍历了这条链上的两个__proto__跳转 才找到目标方法。这也揭示了 JavaScript 继承的本质:不是父子类之间的拷贝,而是对象之间通过__proto__指针形成的查找链。
五条核心法则:
| # | 法则 | 说明 |
|---|---|---|
| ① | 实例的 __proto__ 指向构造函数的 prototype |
zwy.__proto__ === Person.prototype |
| ② | 任何函数都有 prototype 属性,指向原型对象 |
原型对象负责为所有实例提供共享方法 |
| ③ | 原型对象的 constructor 指回构造函数 |
Person.prototype.constructor === Person |
| ④ | 属性查找沿 __proto__ 链向上追溯,终点是 null |
自身 → Person.prototype → Object.prototype → null |
| ⑤ | 任何对象的原型链终点前一定是 Object.prototype |
Object.prototype 是所有对象的"根原型",toString() 就来自这里 |
六、JavaScript 设计哲学
6.1 一切皆对象,没有真正的"类"
JavaScript 的面向对象与 Java、C++ 有本质区别。Java 是先有"类"这个抽象模版,再用 new 根据模版"制造"实例------这是类式继承 。而 JavaScript 的世界里只有对象:Object 是顶层对象 ,Function、Array、Date、RegExp 这些"构造函数"本质上也都是函数对象,它们的 __proto__ 最终都指向 Function.prototype,再往上追溯到 Object.prototype。
vbnet
let obj = {} // 等价于 new Object()
let arr = [1, 2, 3] // 等价于 new Array()
function fn() {} // 等价于 new Function(...)
以 4.html 中的 zwy 实例为例,其原型链为:zwy → Person.prototype → Object.prototype → null。而 Person 构造函数本身作为函数对象,其原型链为:Person → Function.prototype → Object.prototype → null。殊途同归,万链归一 ------无论从哪个方向出发,最终都会在 Object.prototype 汇合,然后止于 null。
6.2 原型式面向对象的优势
在 ES6 class 语法出现之前,JavaScript 开发者用构造函数 + prototype 的模式完成面向对象设计。这种模式有几个独特优势:
- 灵活性极高 :运行时可以随时向
prototype添加或修改方法,所有实例即时生效 - 内存高效:方法共享而非复制,即使有成千上万个实例,方法也只占一份内存
- 动态语言本色:不需要预定义类结构,可以根据运行时的条件动态构建对象行为
ES6 引入的 class 关键字本质上是语法糖 ------底层仍然是基于原型的机制,class 只是让代码写起来更符合传统 OOP 程序员的习惯。
七、总结
本文从一道"用栈实现队列"的算法题出发,串联了以下核心知识点:
| 知识维度 | 核心要点 |
|---|---|
| 数据结构 | 栈(LIFO)、队列(FIFO),两者的本质区别在于元素移除顺序 |
| 算法设计 | 双栈协作实现"负负得正",每个元素均摊 O(1) 的入队和出队 |
| 原型机制 | JS 继承的本质是原型链查找,__proto__ 串联起整个查找路径 |
| new 运算符 | 创建空对象 → 绑定 this → 执行构造函数 → 链接原型 → 返回实例 |
| 设计哲学 | 一切皆对象,没有类,只有原型;ES6 class 只是语法糖 |
理解这些底层原理,不仅能让你在面试中从容应对,更能帮助你在日常开发中写出更高效、更符合 JavaScript 语言特性的代码。
如果这篇文章对你有帮助,欢迎点赞、收藏和关注!如有疑问,也欢迎在评论区交流讨论。