詳細分析鏈表中的遞歸性質(Java 實現)

鏈表中的遞歸性質

前言

在前面的 鏈表的數據結構的實現 中,已經對鏈表數據結構的實現過程有了充分的瞭解了。可是對於鏈表而言,其實它還和遞歸相關聯。雖然通常來講遞歸在樹的數據結構中使用較多,由於在樹這個結構中使用遞歸是很是方便的。在鏈表這個數據結構中也是可使用遞歸的,由於鏈表自己具備自然的遞歸性質,只不過鏈表是一種線性結構,一般使用非遞歸的方式也能夠很容易地實現它,因此大多數狀況下都是使用循環的方式來實現鏈表。不過若是在鏈表中使用遞歸,能夠幫助打好遞歸的基礎以在後面能夠更加深刻地理解樹這種數據結構和一些遞歸算法,這是很是具備好處的。因此在這裏能夠藉助 LeetCode 上的一道關於鏈表的問題,使用遞歸的方式去解決它,以此達到理解鏈表中的遞歸性質的目的。html

LeetCode 上關於鏈表的一道問題

203 號題目 移除鏈表中的元素

題目描述:java

刪除鏈表中等於給定值 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 能夠得到刪除元素後的鏈表。編碼

以上思路實現爲代碼以下:code

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 上的一道題目的例子作了相應的過程分析以後已經有了充分的瞭解,也發現了使用遞歸來書寫邏輯是很是簡單易讀的,相比以前使用非遞歸方式實現的題解其中的代碼,遞歸方式的代碼只有短短几行。可是相對應的,遞歸也是有必定的侷限性的,在使用的過程當中須要注意系統棧空間的佔有,若是數據量太大極可能會撐爆系統棧空間,因此這一方面須要額外注意。


若有寫的不足的,請見諒,請你們多多指教。

相關文章
相關標籤/搜索