返回

彻底理解JS链表push:this.tail对象引用详解

javascript

搞懂 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 中对象是如何通过引用来操作的,而不是像基本类型(如数字、字符串)那样通过值传递。

  1. 变量存的是地址(引用): 当你创建一个对象(比如 new Node('Hi')),JavaScript 会在内存里分配一块空间来存储这个对象的数据(valnext 属性)。然后,像 this.headthis.tailnewNode 这样的变量,它们存储的并不是对象本身,而是指向这块内存空间的地址,我们通常叫它“引用”。

  2. 赋值是复制引用: 执行 this.tail = this.head; 或者 let o = l; 这样的操作时,并不是把整个对象复制一份,而是把变量 this.head(或 l)里面存的那个内存地址(引用),复制给了 this.tail(或 o)。现在,this.tailthis.head 指向了内存中的 同一个 对象。

  3. 通过引用修改对象: 当你通过一个引用去修改对象的属性,比如 this.tail.next = newNode;,你实际上是找到了 this.tail 指向的那个内存地址,然后修改了那个内存块里 next 属性的值。因为可能有多个变量(比如之前的 this.head 和现在的 this.tail 可能在某个时刻指向同一个节点)都指向同一个对象,所以通过任何一个引用修改了对象的属性,通过其他引用访问时也能看到这个变化。

  4. 重新赋值变量是改变指向: 当你执行 this.tail = newNode; 时,你改变的是 this.tail 这个 变量本身 的值。它不再存储原来那个节点的地址了,而是开始存储 newNode 指向的那个新节点的地址。这并不会影响原来那个节点(除非它是最后一个节点,那么没有其他变量再引用它的话,它可能最终被垃圾回收)。重要的是,这也不会改变 其他 仍然指向原来那个节点的变量(比如某个节点的 next 属性可能还指着它)。

分步拆解 push 过程

让我们一步步看 push 的执行过程,彻底搞清楚引用是怎么传递和修改的:

第 1 次 push('Hi'):

  1. newNode 指向 { val: 'Hi', next: null } 这个新节点对象。
  2. 链表为空 (!this.headtrue)。
  3. this.head = newNode;: this.head 现在指向 'Hi' 节点。
  4. this.tail = this.head;: this.tail 变量被赋值为 this.head 变量当前的值(也就是 'Hi' 节点的内存地址)。现在 this.headthis.tail 都指向同一个 'Hi' 节点对象。
  5. 链表状态:head -> Node('Hi') <- tail, length: 1

第 2 次 push('Hello'):

  1. newNode 指向 { val: 'Hello', next: null } 这个新节点对象。
  2. 链表不为空 (!this.headfalse),进入 else 分支。
  3. this.tail.next = newNode; : 这是关键的第一步!
    • 此时 this.tail 指向的是 'Hi' 节点。
    • 我们访问 this.tail 指向的那个对象('Hi' 节点),把它内部的 next 属性,设置为指向 newNode (也就是 'Hello' 节点)。
    • 执行完这句,'Hi' 节点变成了 { val: 'Hi', next: <reference_to_Hello_Node> }链表的连接在这里已经建立了!
  4. this.tail = newNode; : 这是关键的第二步!
    • 现在,我们改变 this.tail 这个变量本身,让它不再指向 'Hi' 节点,而是指向 newNode ('Hello' 节点)。
    • this.head 仍然指向 'Hi' 节点。this.tail 现在指向 'Hello' 节点。
  5. 链表状态:head -> Node('Hi') -> Node('Hello') <- tail, length: 2

第 3 次 push('How') (解开疑惑的地方):

  1. newNode 指向 { val: 'How', next: null } 这个新节点对象。
  2. 链表不为空,进入 else 分支。
  3. console.log(this.tail); 会输出 'Hello' 节点对象的信息。
  4. console.log(this.tail.next); 会输出 null,因为此刻 this.tail 指向的 'Hello' 节点的 next 属性还没有被修改,它创建时默认就是 null
  5. 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' 的关系不受影响。
  6. this.tail = newNode; :
    • 更新 this.tail 变量,让它指向新的尾节点 'How'。
    • this.head 指向 'Hi', 'Hi' 的 next 指向 'Hello', 'Hello' 的 next 指向 'How'。
  7. 链表状态: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 指向了这个新对象。
  • 没有 影响 lo,它们仍然指向之前那个(现在是 { a: 2 })的对象。
  • 它断开的是 p 和原来那个 { a: 2 } 对象之间的 引用关系,而不是 lo 之间的关系,也不是 lo{ a: 2 } 对象的关系。

这和链表中的 this.tail = newNode; 非常类似。这个赋值操作改变的是 this.tail 这个变量的指向,让它指向新节点。它并没有影响到上一个节点(比如 'Hello' 节点),那个节点的 next 已经在前一步 (this.tail.next = newNode) 被正确设置,指向了 'How' 节点。

核心要点总结

  1. 变量存引用: JavaScript 中,对象类型的变量存储的是对象的内存地址(引用),不是对象本身。
  2. 赋值传引用: 将一个对象变量赋给另一个变量,是复制引用,两者指向同一个对象。
  3. 改属性影响所有引用: 通过任何一个引用修改了对象的属性,所有指向该对象的引用都能观察到变化。
  4. 改变量只影响自己: 对一个变量重新赋值(让它指向一个新对象或 null),只改变该变量自身的指向,不影响其他指向原对象的变量,也不影响原对象(除非没有引用指向它了)。
  5. 链表 push 的顺序是关键: this.tail.next = newNode(连接节点)必须在 this.tail = newNode(移动尾指针)之前执行,这样才能保证链表的连续性。this.tail.next = ... 操作的是 this.tail 当前指向的那个 节点对象本身next 属性;而 this.tail = ... 操作的是 this.tail 这个 变量 的指向。

希望通过这个拆解分析,能够彻底搞清楚 JavaScript 链表 push 操作中关于 this.tail 引用的工作原理。关键就在于区分“修改对象属性”和“修改变量指向”这两件事。