彻底理解JS链表push:this.tail对象引用详解
2025-04-27 22:51:16
搞懂 JavaScript 链表 push
操作:this.tail
引用到底怎么回事?
咱们在用 JavaScript 写单向链表(Singly Linked List, SLL)的时候,特别是在实现 push
方法(在链表末尾添加节点)时,可能会遇到一个关于 this.tail
引用的疑惑。具体来说,就是搞不清楚为什么在更新 this.tail
指向新节点之前,它好像还能正确地连接到前一个节点上。
直接来看代码,问题通常出现在类似这样的实现里:
问题代码示例
// 节点定义
class Node {
constructor(val) {
this.val = val;
this.next = null;
}
}
// 单向链表定义
class SLL {
constructor() {
this.head = null; // 头指针
this.tail = null; // 尾指针
this.length = 0; // 链表长度
}
push(val) {
let newNode = new Node(val); // 1. 创建新节点
if (!this.head) {
// 2. 如果链表是空的
this.head = newNode; // 头指针指向新节点
this.tail = this.head; // 尾指针也指向这个节点 (注意这里!)
} else {
// 3. 如果链表不为空
// 这一步是关键: 把当前尾节点的 next 指向新节点
this.tail.next = newNode;
// 然后才把 tail 指针本身移动到新节点上
this.tail = newNode;
}
this.length++; // 链表长度加一
// push 方法一般不需要 return,或者可以 return this 以支持链式调用
}
}
// 使用示例
let list = new SLL();
list.push('Hi');
list.push('Hello'); // 第二次 push
list.push('How'); // 第三次 push,疑惑点常在这里出现
list.push('Are');
// ...
核心困惑点:
在第二次 push('Hello')
后,else
分支里的 this.tail = newNode;
语句执行了,this.tail
确实指向了 'Hello' 这个新节点。那为什么在第三次 push('How')
时,执行 this.tail.next = newNode;
这一行时,this.tail
(也就是 'Hello' 节点) 还能找到它前面的 'Hi' 节点,并把自己的 next
正确地指向新的 'How' 节点呢?看起来好像 this.tail
更新后,之前的连接关系还在。
很多同学在这里会有点懵,觉得 this.tail
被重新赋值了,是不是就跟前面的节点“断开”了?
刨根问底:JavaScript 的对象引用机制
这个问题的关键,在于理解 JavaScript 中对象是如何通过引用来操作的,而不是像基本类型(如数字、字符串)那样通过值传递。
-
变量存的是地址(引用): 当你创建一个对象(比如
new Node('Hi')
),JavaScript 会在内存里分配一块空间来存储这个对象的数据(val
和next
属性)。然后,像this.head
、this.tail
或newNode
这样的变量,它们存储的并不是对象本身,而是指向这块内存空间的地址,我们通常叫它“引用”。 -
赋值是复制引用: 执行
this.tail = this.head;
或者let o = l;
这样的操作时,并不是把整个对象复制一份,而是把变量this.head
(或l
)里面存的那个内存地址(引用),复制给了this.tail
(或o
)。现在,this.tail
和this.head
指向了内存中的 同一个 对象。 -
通过引用修改对象: 当你通过一个引用去修改对象的属性,比如
this.tail.next = newNode;
,你实际上是找到了this.tail
指向的那个内存地址,然后修改了那个内存块里next
属性的值。因为可能有多个变量(比如之前的this.head
和现在的this.tail
可能在某个时刻指向同一个节点)都指向同一个对象,所以通过任何一个引用修改了对象的属性,通过其他引用访问时也能看到这个变化。 -
重新赋值变量是改变指向: 当你执行
this.tail = newNode;
时,你改变的是this.tail
这个 变量本身 的值。它不再存储原来那个节点的地址了,而是开始存储newNode
指向的那个新节点的地址。这并不会影响原来那个节点(除非它是最后一个节点,那么没有其他变量再引用它的话,它可能最终被垃圾回收)。重要的是,这也不会改变 其他 仍然指向原来那个节点的变量(比如某个节点的next
属性可能还指着它)。
分步拆解 push
过程
让我们一步步看 push
的执行过程,彻底搞清楚引用是怎么传递和修改的:
第 1 次 push('Hi')
:
newNode
指向{ val: 'Hi', next: null }
这个新节点对象。- 链表为空 (
!this.head
为true
)。 this.head = newNode;
:this.head
现在指向 'Hi' 节点。this.tail = this.head;
:this.tail
变量被赋值为this.head
变量当前的值(也就是 'Hi' 节点的内存地址)。现在this.head
和this.tail
都指向同一个 'Hi' 节点对象。- 链表状态:
head -> Node('Hi') <- tail
,length: 1
第 2 次 push('Hello')
:
newNode
指向{ val: 'Hello', next: null }
这个新节点对象。- 链表不为空 (
!this.head
为false
),进入else
分支。 this.tail.next = newNode;
: 这是关键的第一步!- 此时
this.tail
指向的是 'Hi' 节点。 - 我们访问
this.tail
指向的那个对象('Hi' 节点),把它内部的next
属性,设置为指向newNode
(也就是 'Hello' 节点)。 - 执行完这句,'Hi' 节点变成了
{ val: 'Hi', next: <reference_to_Hello_Node> }
。链表的连接在这里已经建立了!
- 此时
this.tail = newNode;
: 这是关键的第二步!- 现在,我们改变
this.tail
这个变量本身,让它不再指向 'Hi' 节点,而是指向newNode
('Hello' 节点)。 this.head
仍然指向 'Hi' 节点。this.tail
现在指向 'Hello' 节点。
- 现在,我们改变
- 链表状态:
head -> Node('Hi') -> Node('Hello') <- tail
,length: 2
第 3 次 push('How')
(解开疑惑的地方):
newNode
指向{ val: 'How', next: null }
这个新节点对象。- 链表不为空,进入
else
分支。 console.log(this.tail);
会输出 'Hello' 节点对象的信息。console.log(this.tail.next);
会输出null
,因为此刻this.tail
指向的 'Hello' 节点的next
属性还没有被修改,它创建时默认就是null
。this.tail.next = newNode;
:- 此时
this.tail
指向 'Hello' 节点。 - 我们访问
this.tail
指向的 'Hello' 节点对象,修改它内部的next
属性,让它指向newNode
('How' 节点)。 - 执行完这句,'Hello' 节点变成了
{ val: 'Hello', next: <reference_to_How_Node> }
。'Hello' 和 'How' 的连接在这里建立! 'Hi' 节点的next
指向 'Hello' 的关系不受影响。
- 此时
this.tail = newNode;
:- 更新
this.tail
变量,让它指向新的尾节点 'How'。 this.head
指向 'Hi', 'Hi' 的next
指向 'Hello', 'Hello' 的next
指向 'How'。
- 更新
- 链表状态:
head -> Node('Hi') -> Node('Hello') -> Node('How') <- tail
,length: 3
看明白了吗?this.tail.next = newNode;
是在 修改当前尾节点内部的 next
指针,把链条接长。而 this.tail = newNode;
是在 移动 this.tail
这个“尾巴”标记本身,让它指向新的末端节点。这两个操作顺序不能反,必须先接上链条,再移动尾巴标记。
对比理解:简单对象赋值示例
提问者还提供了一个简单的对象赋值例子,我们来分析一下,这有助于加深理解:
let l = { a : 1}; // l 指向对象 { a: 1 }
let o;
let p;
o = l; // o 也指向 { a: 1 }
p = o; // p 也指向 { a: 1 }。现在 l, o, p 都指向同一个对象
console.log("--- Before p.a modification ---");
console.log("l", l); // 输出: { a: 1 }
console.log("o", o); // 输出: { a: 1 }
console.log("p", p); // 输出: { a: 1 }
p.a = 2; // 通过 p 修改了那个共享对象的 a 属性
console.log("--- After p.a modification ---");
console.log("l", l); // 输出: { a: 2 } (看到变化了)
console.log("o", o); // 输出: { a: 2 } (看到变化了)
console.log("p", p); // 输出: { a: 2 }
p = { a: 10 }; // !! 关键:重新给 p 赋值,让 p 指向一个全新的对象
console.log("--- After p reassignment ---");
console.log("l", l); // 输出: { a: 2 } (没变,还是指向原来的对象)
console.log("o", o); // 输出: { a: 2 } (没变,还是指向原来的对象)
console.log("p", p); // 输出: { a: 10 } (指向新对象了)
这个例子的关键点在于最后一步 p = { a: 10 };
:
- 它创建了一个全新的对象
{ a: 10 }
。 - 它让变量
p
指向了这个新对象。 - 它 没有 影响
l
和o
,它们仍然指向之前那个(现在是{ a: 2 }
)的对象。 - 它断开的是
p
和原来那个{ a: 2 }
对象之间的 引用关系,而不是l
和o
之间的关系,也不是l
、o
与{ a: 2 }
对象的关系。
这和链表中的 this.tail = newNode;
非常类似。这个赋值操作改变的是 this.tail
这个变量的指向,让它指向新节点。它并没有影响到上一个节点(比如 'Hello' 节点),那个节点的 next
已经在前一步 (this.tail.next = newNode
) 被正确设置,指向了 'How' 节点。
核心要点总结
- 变量存引用: JavaScript 中,对象类型的变量存储的是对象的内存地址(引用),不是对象本身。
- 赋值传引用: 将一个对象变量赋给另一个变量,是复制引用,两者指向同一个对象。
- 改属性影响所有引用: 通过任何一个引用修改了对象的属性,所有指向该对象的引用都能观察到变化。
- 改变量只影响自己: 对一个变量重新赋值(让它指向一个新对象或
null
),只改变该变量自身的指向,不影响其他指向原对象的变量,也不影响原对象(除非没有引用指向它了)。 - 链表
push
的顺序是关键:this.tail.next = newNode
(连接节点)必须在this.tail = newNode
(移动尾指针)之前执行,这样才能保证链表的连续性。this.tail.next = ...
操作的是this.tail
当前指向的那个 节点对象本身 的next
属性;而this.tail = ...
操作的是this.tail
这个 变量 的指向。
希望通过这个拆解分析,能够彻底搞清楚 JavaScript 链表 push
操作中关于 this.tail
引用的工作原理。关键就在于区分“修改对象属性”和“修改变量指向”这两件事。