题目回顾
LeetCode 2 两数相加:逆序存储数字的两条单链表,逐位相加,进位向后传递,返回新链表。
一、先逐行解析这段 Java 代码的链表操作逻辑
java
// 虚拟头指针head(最终返回结果),res工作游标,不断向后追加节点
ListNode head=null,res=null;
int add=0; // 进位
while(l1!=null||l2!=null){
// 空链表位补0
int n1 = l1!=null?l1.val:0;
int n2 = l2!=null?l2.val:0;
int sum=n1+n2+add;
// 第一次创建头节点
if(head==null)
head=res=new ListNode(sum%10);
else {
// 当前游标res的next挂新节点,游标后移
res.next=new ListNode(sum %10);
res=res.next;
}
add=sum/10; // 更新进位
// 两条输入链表游标后移
if(l1!=null) l1=l1.next;
if(l2!=null) l2=l2.next;
}
// 最高位进位单独补节点
if(add>0){
res.next=new ListNode(add);
}
return head;
核心链表操作点
head:结果链表头引用,全程固定指向第一个节点,用于最后返回整条链表;res:工作游标引用,始终指向当前链表最后一个节点,专门用来追加新节点;res.next = new ListNode():在尾部挂新节点;res = res.next:游标后移;- 所有
ListNode对象全部存堆内存 ,局部变量head/res/l1/l2只是栈上的引用地址,类似 C 的指针。
二、Java 链表 vs C 语言链表
1. 存储结构完全一致
C 结构体链表:
struct ListNode {
int val;
struct ListNode *next;
};
Java 类链表:
java
public class ListNode {
int val;
ListNode next;
}
结构一一对应:
val:数据域,普通整型;next:指针 / 引用域,存下一个节点的内存地址; 逻辑操作完全互通:遍历、尾插、头插、删除、游标移动思路一模一样。
2. 游标遍历逻辑完全相同
C 写法等价逻辑:
c
struct ListNode *head = NULL, *res = NULL;
int add = 0;
while(l1 || l2){
int n1 = l1 ? l1->val : 0;
int n2 = l2 ? l2->val : 0;
int sum = n1 + n2 + add;
struct ListNode *newNode = (struct ListNode*)malloc(sizeof(struct ListNode));
newNode->val = sum % 10;
newNode->next = NULL;
if(head == NULL){
head = res = newNode;
}else{
res->next = newNode;
res = res->next;
}
add = sum / 10;
if(l1) l1 = l1->next;
if(l2) l2 = l2->next;
}
if(add > 0){
struct ListNode *newNode = malloc(...);
newNode->val = add;
res->next = newNode;
}
return head;
遍历、尾插、双游标、进位处理思路完全一样,算法逻辑无差别。
3. 空判断逻辑一致
C:l1 == NULL Java:l1 == null 语义完全等价:当前游标没有指向任何节点。
三、Java 链表让人觉得 "奇怪" 的核心差异
差异 1:C 是裸指针,Java 是安全引用,无手动内存管理
C 语言痛点
- 节点必须手动
malloc堆分配; - 使用完必须
free释放,否则内存泄漏; - 野指针风险:指针指向已释放内存,程序直接崩溃;
- 可以指针强转、任意地址读写,不安全。
Java 设计
new ListNode()自动在堆创建对象,GC 自动回收,不用手动释放;- 不存在野指针:引用只能指向合法对象或
null,访问null.val只会抛NullPointerException,不会程序崩溃; - 禁止直接操作内存地址,屏蔽指针运算,只能
.访问成员。
你代码里 new ListNode(sum%10) 等价 C 的malloc,但不用关心释放,这是最直观的区别。
差异 2:变量本质不同:C 指针变量 / Java 引用变量
C 语言
c
struct ListNode *res; // 栈上存内存地址数值
res->next = newNode; // 解引用,修改堆节点内部next字段
res = res->next; // 修改栈内指针变量存的地址值
Java
java
ListNode res; // 栈上存对象引用(逻辑地址,不暴露真实内存)
res.next = new ListNode(); // 通过引用找到堆对象,修改对象内部next属性
res = res.next; // 把栈中res引用指向新的堆对象
行为完全一致,但底层实现不同: C 直接操作物理内存地址;Java 引用是 JVM 封装后的句柄,屏蔽底层内存。
差异 3:C 结构体是值 / 指针两套逻辑,Java 类永远引用传递
- C 如果直接写
struct ListNode a;是栈上实体对象,赋值是值拷贝; 链表必须全程用struct ListNode*指针,否则链表断裂; - Java 中
ListNode是引用类型,不存在栈上实体节点,所有节点一律在堆,所有变量都是引用,不存在值拷贝混淆问题。
举例容易踩坑的对比:
c
// C 错误写法,值拷贝,链表断
struct ListNode res = *head;
res.next = newNode;
// Java 不存在这种写法,ListNode变量只能存引用,不会拷贝整个节点
ListNode res = head; // 仅拷贝引用地址,共享同一个堆节点
这是很多 C 转 Java 写链表最容易疑惑的点:Java 永远不会拷贝整个节点对象,只有引用传递。
差异 4:构造器封装节点创建,C 靠 malloc 手动赋值
C 创建节点需要三步:malloc、赋值 val、置空 next; Java ListNode 提供三个构造函数,一行new ListNode(val, next)直接构建完整节点,封装简化代码。
java
// 自带构造,不用手动赋值next
new ListNode(sum % 10, null);
差异 5:内存回收机制不同
- C:忘记 free → 内存泄漏;重复 free → 程序崩溃;
- Java:无手动释放,对象无任何引用指向时,GC 自动标记清除; 本题执行完毕后
head方法结束失效,整条链表自动被回收。
四、这段代码的 Java 链表细节深挖
1. head 和 res 两个引用,为什么能维持整条链表?
java
if(head==null)
head=res=new ListNode(sum%10);
赋值逻辑: new 创建堆节点 A; res = A、head = A,两个栈引用同时指向同一个堆节点 A ; 后续循环: res.next = 新节点B → 节点 A 的 next 字段存 B 的引用; res = res.next → res 引用改为指向 B,head 依旧固定指向 A(链表头部)。
核心:head 永远保留链表起点,res 只做移动游标,二者互不干扰 。 C 语言中两个ListNode*指针也是完全相同逻辑。
2. 为什么不用虚拟头节点(dummy)?
标准优化写法会创建哑节点 ListNode dummy = new ListNode(0); ListNode cur = dummy;,最后返回dummy.next,消除if(head==null)判断。 你的代码不用 dummy,通过head==null区分首次创建节点,逻辑更省一个节点,但多一层判断,两种写法都是工业标准。
3. l1 = l1.next 到底做了什么?
l1 是输入链表的游标引用,l1 = l1.next 只是修改栈局部变量 l1 的指向 ,不会修改原链表任何堆内节点数据。 Java 引用只是 "地址别名",重新赋值引用不会改动原有对象,和 C 指针l1 = l1->next行为一致。
4. 循环结束后单独处理进位 add>0
链表遍历完两条输入,但最高位还有进位,需要尾部追加新节点; 此时res正好停在链表最后一个节点,res.next = new ListNode(add)直接尾插,利用游标特性,和 C 逻辑无区别。
五、Java 链表容易产生 "奇怪感" 的总结
- 习惯 C 手动 malloc/free,Java 自动 GC,看不到内存操作,感觉 "黑盒";
- C 区分结构体值和指针,Java 只有引用类型,无值拷贝混淆;
- C 能打印指针地址、指针运算,Java 屏蔽底层内存,只能用
.访问属性; - C 空指针直接崩溃,Java 空引用抛 NPE,异常可控;
- Java 构造函数封装节点创建,C 需要手动分配 + 赋值,步骤繁琐。
六、通用链表核心思想
- 单链表本质:节点 = 数据 + 下一个节点地址;
- 双游标思想:一个头指针固定起点,一个游标遍历 / 尾插;
- 遍历终止条件:游标 == null(C NULL);
- 尾插标准流程:
cur.next = 新节点; cur = cur.next;; - 所有链表算法(反转、合并、快慢指针、两数相加)算法逻辑不分语言,仅内存操作语法不同。
七、补充优化版
java
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
int carry = 0;
while(l1 != null || l2 != null || carry > 0){
int n1 = l1 == null ? 0 : l1.val;
int n2 = l2 == null ? 0 : l2.val;
int sum = n1 + n2 + carry;
cur.next = new ListNode(sum % 10);
cur = cur.next;
carry = sum / 10;
if(l1 != null) l1 = l1.next;
if(l2 != null) l2 = l2.next;
}
return dummy.next;
}
}
哑节点统一所有插入逻辑,不用判断头节点是否为空,是 Java 链表刷题最常用写法。