詳細分析連結串列中的遞迴性質(Java 實現)

踏雪彡尋梅發表於2020-09-05

連結串列中的遞迴性質

前言

在前面的 連結串列的資料結構的實現 中,已經對連結串列資料結構的實現過程有了充分的瞭解了。但是對於連結串列而言,其實它還和遞迴相關聯。雖然一般來說遞迴在樹的資料結構中使用較多,因為在樹這個結構中使用遞迴是非常方便的。在連結串列這個資料結構中也是可以使用遞迴的,因為連結串列本身具有天然的遞迴性質,只不過連結串列是一種線性結構,通常使用非遞迴的方式也可以很容易地實現它,所以大多數情況下都是使用迴圈的方式來實現連結串列。不過如果在連結串列中使用遞迴,可以幫助打好遞迴的基礎以在後面可以更加深入地理解樹這種資料結構和一些遞迴演算法,這是非常具有好處的。所以在這裡可以藉助 LeetCode 上的一道關於連結串列的問題,使用遞迴的方式去解決它,以此達到理解連結串列中的遞迴性質的目的。

LeetCode 上關於連結串列的一道問題

203 號題目 移除連結串列中的元素

題目描述:

刪除連結串列中等於給定值 val 的所有節點。

示例:

輸入: 1->2->6->3->4->5->6, val = 6
輸出: 1->2->3->4->5

來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/remove-linked-list-elements
著作權歸領釦網路所有。商業轉載請聯絡官方授權,非商業轉載請註明出處。

題目提供的連結串列結點類:

/**
 * Definition for singly-linked list.
 */
public class ListNode {
    int val;
    ListNode next;
    ListNode(int x) { 
        val = x; 
    }
}

題目提供的解題模板:

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode removeElements(ListNode head, int val) {
        
    }
}

-對於此題,可以先嚐試使用非遞迴的方式然後使用虛擬頭節點和不使用虛擬頭節點分別實現來回顧一下連結串列的刪除邏輯。

非遞迴方式及不使用虛擬頭節點題解思路:

  1. 如果不使用虛擬頭結點,那麼首先可以直接判斷 head 是否不為 null 以及它的值是否是要刪除的元素,如果是則刪除當前頭節點。此處需要注意的是,很可能會存在多個要刪除的元素都堆在連結串列頭部或者整個連結串列都是要刪除的元素,所以這裡可以使用 while 迴圈來判斷依次刪除連結串列的當前頭節點。

  2. 處理完頭部部分後,就處理中間部分需要刪除的元素,此時回顧一下連結串列的刪除邏輯,需要先找到待刪除節點的前置節點,所以以連結串列此時的頭節點 head 開始,將其作為第一個前置節點 prev(因為此時頭部已經處理完畢,沒有要刪除的元素了)。再通過 while 迴圈依次判斷 prev 的下一個節點是否需要刪除直到刪除完所有要刪除的元素為止。

  3. 最後返回頭節點 head 即可,此時通過 head 可以獲得刪除元素後的連結串列。

以上思路實現為程式碼如下:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 非遞迴不使用虛擬頭結點的解決方案
        // 把連結串列開始部分需要刪除的元素刪除
        while (head != null && head.val == val) {
            ListNode delNode = head;
            head = head.next;
            delNode.next = null;
        }

        // 如果此時 head == null,說明連結串列中所有元素都需要刪除,此時返回 head 或 null
        if (head == null) {
            return null;
        }

        // 處理連結串列中間需要刪除的元素
        ListNode prev = head;
        // 每次看 prev 的下一個元素是否需要被刪除
        while (prev.next != null) {
            if (prev.next.val == val) {
                ListNode delNode = prev.next;
                prev.next = delNode.next;
                delNode.next = null;
            } else {
                prev = prev.next;
            }
        }

        return head;
    }
}

提交結果:

提交結果-1

接下來就使用虛擬頭結點的方式來實現此題,思路如下:

  1. 建立一個虛擬頭節點,並指向連結串列的頭節點 head。

  2. 此時整個連結串列的所有元素都有一個前置節點,就可以統一使用通過前置節點的方式來刪除待刪除元素,此時以虛擬頭節點開始,將其作為第一個前置節點 prev。再通過 while 迴圈依次判斷 prev 的下一個節點是否需要刪除直到刪除完所有要刪除的元素為止。

  3. 最後返回虛擬頭節點的下一個節點即可,即返回 head。

以上思路實現為程式碼如下:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 非遞迴使用虛擬頭結點的解決方案
        // 建立虛擬頭節點
        ListNode dummyHead = new ListNode(-999);
        dummyHead.next = head;

        // 處理連結串列中需要刪除的元素
        ListNode prev = dummyHead;
        // 每次看 prev 的下一個元素是否需要被刪除
        while (prev.next != null) {
            if (prev.next.val == val) {
                ListNode delNode = prev.next;
                prev.next = delNode.next;
                delNode.next = null;
            } else {
                prev = prev.next;
            }
        }

        // 返回連結串列頭節點
        return dummyHead.next;
    }
}

提交結果:

提交結果-2

此時,兩種方案都正確的執行了。對於連結串列的刪除邏輯在使用虛擬頭節點和不使用虛擬頭節點的情況都實現了一遍,這也是在之前的連結串列的資料結構的實現中涉及到的部分,這裡再次回顧一遍加深印象,也方便後面使用遞迴方式實現該題目後對比兩種不同方式的異同。

遞迴的基本概念與示例

對於遞迴,本質上,就是將原來的問題,轉化為更小的同一問題,直到轉化為基本問題並解決基本問題後,再一步步的將結果返回達到求解原問題的目的。

舉個例子:陣列求和。

陣列求和遞迴示例

從圖中可以看出,其實遞迴也就是將原問題的規模一步步地縮小,一直縮小到基本問題出現然後解出基本問題的解再往上依次返回根據這個基本解依次求出各個規模的解直到求出原問題的解。

以上過程編碼實現如下:

/**
 * 陣列求和遞迴示例
 *
 * @author 踏雪彡尋梅
 * @date 2020/2/8 - 10:30
 */
public class Sum {
    /**
     * 對 array 求和
     *
     * @param array 求和的陣列
     * @return 返回求和結果
     */
    public static int sum(int[] array) {
        // 計算 array[0...n) 區間內所有數字的和
        return sum(array, 0);
    }

    /**
     * 計算 array[l...n) 這個區間內所有數字的和
     *
     * @param array 求和的陣列
     * @param l 左邊界
     * @return 返回求和的結果
     */
    private static int sum(int[] array, int l) {
        // 基本問題: 陣列為空時返回 0
        if (l == array.length) {
            return 0;
        }
        // 把原問題轉換為小問題解決
        return array[l] + sum(array, l + 1);
    }

    /**
     * 測試陣列求和
     */
    public static void main(String[] args) {
        int[] nums = {1, 2, 3, 4, 5, 6, 7, 8};
        System.out.println(sum(nums));
    }
}

執行結果:

求和結果

對於以上例子,可以這樣理解:在使用遞迴時,可以注意遞迴函式的“巨集觀”語意。在上面的例子中,“巨集觀”語意就是計算 array[l...n) 區間內所有數字的和。這樣子理解遞迴函式再去觀看函式中的將原問題轉換成小問題時,會更好地理解這個函式要做的事情,簡單來說遞迴函式就是一個完成一個功能的函式,只不過是自己呼叫自己,每一次轉換成小問題時完成的功能都是陣列的某個數加上剩餘數的和,直到無數可加為止。這個陣列求和的遞迴過程如下圖所示:

陣列求和遞迴過程-1

也可以使用下圖表示,下圖中的程式碼是進行拆分後的程式碼,為了更方便地展示過程:

陣列求和遞迴過程-2

至此,已經大致瞭解了遞迴的基本概念和基本流程了,接下來就看看連結串列所具有的天然的遞迴性質。

連結串列天然的遞迴性

對於連結串列而言,本質上就是將一個個節點掛接起來組成的。也就是下圖的這個樣子:

連結串列的基本結構

而其實對於連結串列,也可以應用遞迴理解成是由一個頭節點後面掛接著一個更短的連結串列組成的。也就是下圖的這個樣子:

連結串列的遞迴結構

對於上圖中的一個更短的連結串列,其中也是由一個頭節點掛接著一個更短的連結串列形成的,依次類推,直到最後為 NULL 時,NULL 其實也就是一個連結串列了,此時就是遞迴方式的連結串列的基本問題。

所以此時再看回之前的 203 號題目:移除連結串列中的元素。就可以將題目提供的連結串列看成上圖所示的結構,然後使用遞迴解決更小的連結串列中要刪除的元素得到這個小問題的解,之後再看頭節點是否需要刪除,如果要刪除就返回小問題的解,此時也就是原問題的解了;不刪除的話就將頭節點和小問題的解組合起來返回回去得到原問題的解。這個過程用圖來表示為以下圖示:

遞迴方式題解思路

用程式碼實現後如下所示:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 使用遞迴解決連結串列中移除元素
        // 構建基本問題,連結串列為空時返回 null
        if (head == null) {
            return null;
        }

        // 構建小問題: 得到頭節點後掛接著的更小的連結串列的解
        ListNode result = removeElements(head.next, val);
        // 判斷頭節點是否需要刪除,和小問題的解組合得到原問題的解
        if (head.val == val) {
            // 頭節點需要刪除
            return result;
        } else {
            // 頭節點不需要刪除,和小問題的解組合得到原問題的解
            head.next = result;
            return head;
        }
    }
}

提交結果:

提交結果-3

從提交結果可以驗證實現的邏輯是沒有錯誤的。此時程式碼還可以進行簡化如下:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 使用遞迴解決連結串列中移除元素
        // 構建基本問題,連結串列為空時返回 null
        if (head == null) {
            return null;
        }

        // 構建小問題: 得到頭節點後掛接著的更小的連結串列的解,然後掛接在頭節點後面
        head.next = removeElements(head.next, val);
        // 判斷頭節點是否需要刪除,和小問題的解組合得到原問題的解
        return head.val == val ? head.next : head;
    }
}

提交結果:

提交結果-4

此時對比前面的非遞迴方式實現的題解,可以發現使用遞迴方式實現是非常優雅的,程式碼十分簡潔易讀。接下來就分析一下該遞迴執行的機制。遞迴執行過程如下圖所示:

題解遞迴過程

至此,這個題目的遞迴流程就走完了,對於以上過程,就是子過程的一步步呼叫,呼叫完畢之後,子過程計算出結果,再一步步地返回結果給上層呼叫,最終得到了結果。節點的刪除發生在第 6 行語句上,這行語句也就是解決了更小規模的問題後得到解後組織當前呼叫構成了當前問題的解。

與此同時,需要注意的是遞迴呼叫是有代價的,代價則是函式的呼叫和使用系統棧空間這兩方面。在函式呼叫時是需要一些時間開銷的,其中包括需要記錄當前函式執行到哪個位置、函式中的區域性變數是處於怎樣的等等,然後將這個狀態給壓入系統棧。然後在遞迴呼叫的過程中,是需要消耗系統棧的空間的,所以對於遞迴函式,如果不處理基本問題的話,遞迴函式將一直執行下去,直到將系統棧的空間使用完。同時如果使用遞迴處理資料量巨大的情況的時候,也有可能會使用完系統棧空間,比如上面的陣列求和如果求和百萬級別、千萬級別的資料系統棧空間是不夠用的,在連結串列中刪除元素也是如此,如果連結串列過長系統棧空間也是不夠用的。所以在這一點需要有所注意。

總而言之,使用遞迴來書寫程式邏輯其實是比較簡單的,這個特點在非線性結構中,比如樹、圖這些資料結構,這個特點會體現地十分明顯。

小結

此時,對於遞迴和連結串列中的遞迴性質在使用了一個陣列求和的例子和 LeetCode 上的一道題目的例子做了相應的過程分析之後已經有了充分的瞭解,也發現了使用遞迴來書寫邏輯是非常簡單易讀的,相比之前使用非遞迴方式實現的題解其中的程式碼,遞迴方式的程式碼只有短短几行。但是相對應的,遞迴也是有一定的侷限性的,在使用的過程中需要注意系統棧空間的佔有,如果資料量太大很可能會撐爆系統棧空間,所以這一方面需要額外注意。


如有寫的不足的,請見諒,請大家多多指教。

相關文章