Java單連結串列反轉圖文詳解

1發表於2021-04-02

Java單連結串列反轉圖文詳解

最近在回顧連結串列反轉問題中,突然有一些新的發現和收穫,特此整理一下,與大家分享 ?

背景回顧

單連結串列的儲存結構如圖:
資料域存放資料元素,指標域存放後繼結點地址

我們以一條 N1 -> N2 -> N3 -> N4 指向的單連結串列為例:

反轉後的連結串列指向如圖:

我們在程式碼中定義如下結點類以方便執行測試:

    /**
     * 結點類
     * (因為後續在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;
        }
    }

執行結果如圖:

可以看到,原先指向為 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

執行效果截圖:

最終的程式執行結果與我的設想大相徑庭!

那麼,問題出在哪裡呢?

遞迴方式反轉連結串列問題排查與延伸

問題定位

既然程式執行效果與預期效果不符,那我們就在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);
    }

執行結果:

從上面?的執行結果看,在遞迴函式執行期間,head引用指向的物件確實發生了變化

注意 呼叫前 / 呼叫返回前 / 呼叫後 這三個地方head引用指向物件的變化:

可以發現,雖然遞迴函式執行期間確實改變了head引用指向的物件,但實際上是變了個寂寞!?

函式呼叫返回後,head引用指向的物件還是呼叫前的那個!

在debug模式下,我們再繼續深入看看遞迴函式呼叫前跟呼叫後的head物件是不是完全一樣的:

從上面兩張圖可以發現,雖然遞迴呼叫前跟呼叫後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的部分,如有不對歡迎指正 ?)

如圖,main方法和recursionNode方法執行時實際是作為不同的棧幀入棧到當前執行緒的虛擬機器棧中

main方法中的 head 引用實際儲存的是一個地址,通過這個地址可以找到堆(heap)中的一個Node物件

recursionNode方法中的 headNode 引用實際儲存的也是一個地址,通過這個地址可以找到堆中的一個Node物件

那麼在main方法中呼叫recursionNode方法,實參 head 到形參 headNode 的傳遞過程中,到底傳遞的是什麼呢?

很明顯,傳遞的就是那個能定址到堆中某個Node物件的 地址(劃重點,要考!)

由此,實參 head 物件引用和形參 headNode 物件引用具有了相同的地址值,指向堆中的同一個Node物件

通過這兩個引用中的任何一個,都可以改變堆中對應的那個物件的屬性和狀態

遞迴方法呼叫後發生了什麼

理解了物件引用傳遞的實質後,再回過頭來看上面遞迴方法呼叫後實際結果與預期結果不一致的問題,一切就迎刃而解了

如圖,遞迴呼叫結束後,雖然遞迴方法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;
        }
    }

執行結果截圖:

可以看到,經過改善的新遞迴方法實現了預期的效果!?

相關文章