从"用栈实现队列"说起:深入理解 JavaScript 原型式面向对象

从"用栈实现队列"说起:深入理解 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 算法思路

既然一个栈只能倒序输出,那两个栈串联就能实现"负负得正"的效果:

  1. 入队时 :直接将元素压入 stack1(入队栈)
  2. 出队时
    • 如果 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 实现中,我们用到了 prototypenewthis 等机制。这些是 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}, 我正在工作`);
}

代码解析saywork 方法没有直接定义在构造函数内部,而是挂载到了 Greeting.prototype(原型对象)上。这样做有两大好处:

  1. 内存节省 :无论创建多少个 Greeting 实例,saywork 方法在内存中只有一份拷贝,所有实例通过原型链共享
  2. 动态扩展 :即使在实例创建之后,给 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 构造函数负责为每个实例创建私有属性 nameage。这些属性直接挂载在实例自身,每个实例各有一份,互不影响。

js 复制代码
// 原型对象上的方法是公用的
Person.prototype.poem = '仁义礼智信'
Person.prototype.say = function() {
  console.log(`我叫${this.name}, 很高兴认识你`);
}
Person.prototype.timeMF = function() {
  console.log(`时间管理魔法`);
}

代码解析 :这里有一个容易被忽略的细节------prototype 上不仅可以挂方法saytimeMF),还可以挂属性值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() 等一系列基础方法。

完整的查找路径如下:

javascript 复制代码
zwy.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 是顶层对象FunctionArrayDateRegExp 这些"构造函数"本质上也都是函数对象,它们的 __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 语言特性的代码。


如果这篇文章对你有帮助,欢迎点赞、收藏和关注!如有疑问,也欢迎在评论区交流讨论。

相关推荐
lantian3 小时前
TypeScript 三斜线指令完全指南:从入门到理解为什么不再需要它
前端·javascript·vue.js
ZengLiangYi3 小时前
AI 编程工具的数据格式为什么不能统一
javascript·后端·架构
陈_杨3 小时前
鸿蒙APP开发-带你走进旧物集的时间线与收藏管理
前端·javascript
尼斯湖皮皮怪3 小时前
iceCoder双模详解
javascript
小雨下雨的雨3 小时前
月相分析工具鸿蒙PC Electron框架技术实现详解
前端·javascript·华为·electron
布依前端3 小时前
基于 Vue 3 的 Tiptap 富文本编辑器实践:tiptap-editor-vue3 项目介绍
前端·javascript·vue.js
奥利奥夹心脆芙4 小时前
OTel / Logstash / Fluentd 全维对比,及统一日志与指标管道的 AWS ECS 落地
javascript
zithern_juejin4 小时前
手写函数组合compose
javascript
rime_neko4 小时前
js学习笔记
开发语言·前端·javascript