Java单链表反转图文详解

最近在回顾链表反转问题中,突然有一些新的发现和收获,特此整理一下,与大家分享 😁

背景回顾

单链表的存储结构如图:
数据域存放数据元素,指针域存放后继结点地址

Java单链表反转图文详解-LMLPHP

我们以一条 N1 -> N2 -> N3 -> N4 指向的单链表为例:

Java单链表反转图文详解-LMLPHP

反转后的链表指向如图:

Java单链表反转图文详解-LMLPHP

我们在代码中定义如下结点类以方便运行测试:

    /**
     * 结点类
     * (因为后续在main方法中运行,为了方便定义为static内部类)
     */
    static class Node {
        int val; // 数据域
        Node next; // 指针域,指向下一个结点

        Node(int x, Node nextNode) {
            val = x;
            next = nextNode;
        }
    }

通过循环遍历方式实现链表反转

实现思路:从链表头结点出发,依次循环遍历每一个结点,并更改结点对应的指针域,使其指向前一个结点

代码如下:

    /**
     * 循环遍历方式实现链表反转
     *
     * @param head 链表的头结点
     * @return
     */
    public static Node cycleNode(Node head) {

        Node prev = null; // 保存前一个结点的信息

        // 循环遍历链表中的结点
        while (head.next != null) {
            // 1. 先保存当前结点的下一个结点的信息到tempNext
            Node tempNext = head.next;
            // 2. 修改当前结点指针域,使其指向上一个结点(如果是第一次进入循环的头结点,则其上一个结点为null)
            head.next = prev;
            // 3. 将当前结点信息保存到prev中(以作为下一次循环中第二步使用到的"上一个结点")
            prev = head;
            // 4. 当前结点在之前的123步中指针域已经修改完毕,此时让head重新指向待处理的下一个结点
            head = tempNext;
        }

        // 上面的循环完成后,实际只修改了原先链表中的头结点到倒数第二个结点间的结点指向,倒数第一个结点(尾结点)并未处理
        // 此时prev指向原先链表中的倒数第二个结点,head指向尾结点
        // 处理尾结点的指针域,使其指向前一个结点
        head.next = prev;

        // 返回尾结点,此时的尾结点既是原先链表中的尾结点,又是反转后的新链表中的头结点
        return head;
    }

测试效果:

    public static void main(String[] args) {
        // 构造测试用例,链表指向为 N1 -> N2 -> N3 -> N4
        Node n4 = new Node(4, null);
        Node n3 = new Node(3, n4);
        Node n2 = new Node(2, n3);
        Node n1 = new Node(1, n2);
        Node head = n1;
        // 输出测试用例
        System.out.println("原始链表指向为:");
        printNode(head);

        // 普通方式反转链表
        System.out.println("循环方式反转链表指向为:");
        head = cycleNode(head);
        printNode(head);
    }

    /**
     * 循环打印链表数据域
     * @param head
     */
    public static void printNode(Node head) {
        while (head != null) {
            System.out.println(head.val);
            head = head.next;
        }
    }

运行结果如图:

Java单链表反转图文详解-LMLPHP

可以看到,原先指向为 N1 -> N2 -> N3 -> N4 的链表,运行反转方法后,其指向已变为 N4 -> N3 -> N2 -> N1

通过递归方式实现链表反转

实现思路:从链表头结点出发,依次递归遍历每一个结点,并更改结点对应的指针域,使其指向前一个结点(没错,实际每一次递归里的处理过程跟上面的循环里是一样的)

代码实现:

    /**
     * 递归实现链表反转
     * 递归方法执行完成后,head指向就从原链表顺序:头结点->尾结点 中的第一个结点(头结点) 变成了反转后的链表顺序:尾结点->头结点 中的第一个结点(尾结点)
     *
     * @param head 头结点
     * @param prev 存储上一个结点
     */
    public static void recursionNode(Node head, Node prev) {

        if (null == head.next) {
            // 设定递归终止条件
            // 当head.next为空时,表明已经递归到了原链表中的尾结点,此时单独处理尾结点指针域,然后结束递归
            head.next = prev;
            return;
        }

        // 1. 先保存当前结点的下一个结点的信息到tempNext
        Node tempNext = head.next;
        // 2. 修改当前结点指针域,使其指向上一个结点(如果是第一次进入递归的头结点,则其上一个结点为null)
        head.next = prev;
        // 3. 将当前结点信息保存到prev中(以作为下一次递归中第二步使用到的"上一个结点")
        prev = head;
        // 4. 当前结点在之前的123步中指针域修改已经修改完毕,此时让head重新指向待处理的下一个结点
        head = tempNext;

        // 递归处理下一个结点
        recursionNode(head, prev);
    }

测试效果:

    public static void main(String[] args) {
        // 构造测试用例,链表指向为 N1 -> N2 -> N3 -> N4
        Node n4 = new Node(4, null);
        Node n3 = new Node(3, n4);
        Node n2 = new Node(2, n3);
        Node n1 = new Node(1, n2);
        Node head = n1;
        // 输出测试用例
        System.out.println("原始链表指向为:");
        printNode(head);

        // 递归方式反转链表
        System.out.println("递归方式反转链表指向为:");
        recursionNode(head, null);
        printNode(head);
    }

    /**
     * 循环打印链表数据域
     * @param head
     */
    public static void printNode(Node head) {
        while (head != null) {
            System.out.println(head.val);
            head = head.next;
        }
    }

注意:在上面👆的测试代码中,在调用递归函数时传递了Node类的实例head作为参数

根据Java中 方法调用传参中,基本类型是值传递,对象类型是引用传递 可得 =>

因为在调用递归函数时传递了head对象的引用,且在递归函数运行过程中,我们已经数次改变了head引用指向的对象

那么当递归函数执行完毕时,head引用指向的对象此时理论上已经是原链表中的尾结点N4了,且链表顺序也已经变成了 N4 -> N3 -> N2 -> N1

运行效果截图:

Java单链表反转图文详解-LMLPHP

最终的程序运行结果与我的设想大相径庭!

那么,问题出在哪里呢?

递归方式反转链表问题排查与延伸

问题定位

既然程序运行效果与预期效果不符,那我们就在head对象引用可能发生变化的地方加入注释打印一下对象地址,看看能不能发现问题在哪:

加入注释后的代码如下:

    public static void main(String[] args) {
        // 构造测试用例,链表指向为 N1 -> N2 -> N3 -> N4
        Node n4 = new Node(4, null);
        Node n3 = new Node(3, n4);
        Node n2 = new Node(2, n3);
        Node n1 = new Node(1, n2);
        Node head = n1;
        // 输出测试用例
        System.out.println("原始链表指向为:");
        printNode(head);


        // 递归方式反转链表
        System.out.println("递归方式反转链表指向为:");
        System.out.println("递归调用前 head 引用指向对象: " + head.toString());
        recursionNode(head, null);
        System.out.println("递归调用后 head 引用指向对象: " + head.toString());
        printNode(head);
    }

    /**
     * 循环打印链表数据域
     * @param head
     */
    public static void printNode(Node head) {
        while (head != null) {
            System.out.println(head.val);
            head = head.next;
        }
    }

    /**
     * 递归实现链表反转
     * 递归方法执行完成后,head指向就从原链表顺序:头结点->尾结点 中的第一个结点(头结点) 变成了反转后的链表顺序:尾结点->头结点 中的第一个结点(尾结点)
     *
     * @param head 头结点
     * @param prev 存储上一个结点
     */
    public static void recursionNode(Node head, Node prev) {
        System.out.println("递归调用中 head引用指向对象: " + head.toString());
        if (null == head.next) {
            // 设定递归终止条件
            // 当head.next为空时,表名已经递归到了原链表中的尾结点,此时单独处理尾结点指针域,然后结束递归
            head.next = prev;
            System.out.println("递归调用返回前 head引用指向对象: " + head.toString());
            return;
        }

        // 1. 先保存当前结点的下一个结点的信息到tempNext
        Node tempNext = head.next;
        // 2. 修改当前结点指针域,使其指向上一个结点(如果是第一次进入循环的头结点,则其上一个结点为null)
        head.next = prev;
        // 3. 将当前结点信息保存到prev中(以作为下一次递归中第二步使用到的"上一个结点")
        prev = head;
        // 4. 当前结点在之前的123步中指针域修改已经修改完毕,此时让head重新指向待处理的下一个结点
        head = tempNext;

        // 递归处理下一个结点
        recursionNode(head, prev);
    }

运行结果:

Java单链表反转图文详解-LMLPHP

从上面👆的运行结果看,在递归函数执行期间,head引用指向的对象确实发生了变化

注意 调用前 / 调用返回前 / 调用后 这三个地方head引用指向对象的变化:

Java单链表反转图文详解-LMLPHP

可以发现,虽然递归函数执行期间确实改变了head引用指向的对象,但实际上是变了个寂寞!😶

函数调用返回后,head引用指向的对象还是调用前的那个!

在debug模式下,我们再继续深入看看递归函数调用前跟调用后的head对象是不是完全一样的:

Java单链表反转图文详解-LMLPHP

Java单链表反转图文详解-LMLPHP

从上面两张图可以发现,虽然递归调用前跟调用后head引用指向的对象都是同一个,但这个对象本身的属性(指针域)已经发生了变化!

由此说明递归函数的执行并不是在做无用功,而是切切实实改变了原链表的各结点指向顺序!

只是因为递归函数执行完成后,并没有成功让传入的head对象引用指向反转后的新链表的头结点N4,

此时head对象引用仍然跟调用前一样指向了N1,而N1在反转后的新链表中变成了尾结点,至此,我们已经完美的丢失了反转后的新链表 😭,光靠指向尾结点的head根本无法遍历到新链表的其他结点。。。

问题延伸:探究Java方法调用中的参数传递实质

由上面的问题定位可知,问题出在我对Java方法调用中的参数传递理解有偏差,那么接下来就来详细探究一下Java方法调用中的参数传递过程吧!

形参与实参

测试示例代码:

public static void recursionNode(Node headNode, Node prevNode) {
		// do something...
}

public static void main(String[] args) {
		// init head...
		recursionNode(head, null);   // 调用方法
}

在上面的示例代码中,我们定义了recursionNode()方法,并在main()方法中调用它

方法定义中的 headNode prevNode形式参数,调用时传入的 head null实际参数

值传递

方法定义中的形式参数类型如果是基本数据类型(byte、short、int、long、float、double、boolean、char),则调用方法时,实参到形参的传递实际是值传递,传递的是实际参数值的副本(拷贝)

因此,在方法体中任意修改形参的值,并不会影响到方法体外的实参的值

引用传递

方法定义中的形式参数类型如果是对象类型(含基本数据类型的数组),则调用方法时,实参到形参的传递实际也是值传递,传递的是实参对象的引用地址

如何理解这个 实参对象的引用地址 的概念呢?让我们来看看示例代码运行时的内存模型图(简单抽象了stack和heap的部分,如有不对欢迎指正 😆)

Java单链表反转图文详解-LMLPHP

如图,main方法和recursionNode方法执行时实际是作为不同的栈帧入栈到当前线程的虚拟机栈中

main方法中的 head 引用实际保存的是一个地址,通过这个地址可以找到堆(heap)中的一个Node对象

recursionNode方法中的 headNode 引用实际保存的也是一个地址,通过这个地址可以找到堆中的一个Node对象

那么在main方法中调用recursionNode方法,实参 head 到形参 headNode 的传递过程中,到底传递的是什么呢?

很明显,传递的就是那个能寻址到堆中某个Node对象的 地址(划重点,要考!)

由此,实参 head 对象引用和形参 headNode 对象引用具有了相同的地址值,指向堆中的同一个Node对象

通过这两个引用中的任何一个,都可以改变堆中对应的那个对象的属性和状态

递归方法调用后发生了什么

理解了对象引用传递的实质后,再回过头来看上面递归方法调用后实际结果与预期结果不一致的问题,一切就迎刃而解了

Java单链表反转图文详解-LMLPHP

如图,递归调用结束后,虽然递归方法recursionNode()方法体内的 headNode 引用确实已经变成了指向新的Node对象N4,但是main方法中,head 引用指向的仍然是递归方法调用前的Node对象N1(随着递归方法的执行,N1对象内部的指针域已经产生了变化)

正确的递归方式实现链表反转

    /**
     * 递归实现链表反转,递归方法执行完成后,head就从 头结点->尾结点 中的起始点(头结点)变成了 尾结点->头结点 中的起始点(尾结点)
     *
     * @param head 头结点
     * @param prev 存储上一个结点
     */
    public static Node recursionNode2(Node head, Node prev) {
        if (null == head.next) {
            // 设定递归终止条件
            head.next = prev;
            return head;
        }
        Node tempNext = head.next;
        head.next = prev;
        prev = head;
        head = tempNext;
        Node result = recursionNode2(head, prev);
        return result;
    }

测试结果:

    public static void main(String[] args) {
        // 构造测试用例,链表指向为 N1 -> N2 -> N3 -> N4
        Node n4 = new Node(4, null);
        Node n3 = new Node(3, n4);
        Node n2 = new Node(2, n3);
        Node n1 = new Node(1, n2);
        Node head = n1;
        // 输出测试用例
        System.out.println("原始链表指向为:");
        printNode(head);

        // 新递归方式反转链表
        System.out.println("新递归方式反转链表指向为:");
        head = recursionNode2(head, null);
        printNode(head);
    }

    /**
     * 循环打印链表数据域
     * @param head
     */
    public static void printNode(Node head) {
        while (head != null) {
            System.out.println(head.val);
            head = head.next;
        }
    }

运行结果截图:

Java单链表反转图文详解-LMLPHP

可以看到,经过改善的新递归方法实现了预期的效果!😁

04-02 14:09