【算法】遞歸三步走


遞歸

遞歸實現的原理:對於遞歸的問題,我們一般都是從上往下遞歸的,直到遞歸到最底,再一層一層着把值返回。
一個遞歸函數的調用過程類似於多個函數的嵌套的調用,只不過調用函數和被調用函數是同一個函數。為了保證遞歸函數的正確執行,系統需設立一個工作棧。具體地說,遞歸調用的內部執行過程如下:

  1. 運動開始時,首先為遞歸調用建立一個工作棧,其結構包括值參局部變量返回地址
  2. 每次執行遞歸調用之前,把遞歸函數的值參局部變量的當前值以及調用后的返回地址壓棧;
  3. 每次遞歸調用結束后,將棧頂元素出棧,使相應的值參和局部變量恢復為調用前的值,然后轉向返回地址指定的位置繼續執行。

在我們了解了遞歸的基本思想及其數學模型之后,我們如何才能寫出一個漂亮的遞歸程序呢?我認為主要是把握好如下三個方面:

  1. 明確遞歸函數的作用;
  2. 明確遞歸終止條件與對應的解決辦法;
  3. 找出函數的等價關系式,提取重復的邏輯縮小問題規模。

適用場景

  1. 對於一開始就可以寫一個遞歸出口,以免空指針報錯的,比如一開始就寫if (root == null),我們可以考慮一下使用遞歸,找找遞推關系式。
  2. 對於一步一步推出來的,遞推出來的,即 上一個答案與下一個有關的,都可以考慮一下遞歸
  3. 回溯法,要退回去的,也可以考慮遞歸

回溯法一般使用在問題可以樹形化表示時的場景。

這樣說明的話可能有點抽象,那么我們來換個方法說明。
當你發現,你的問題需要用到多重循環,具體幾重循環你又沒辦法確定,那么就可以使用我們的回溯算法來將循環一層一層的進行嵌套。

就像這樣:

void f(int count) {
      if (count == max) {
            return;
      }

      for (...) {
            f(count+1);
      }
}

這樣套起來的話,無論多少重循環我們都可以滿足。

遞歸三步走

1. 明確函數功能

1.明確函數功能:要清楚你寫這個函數是想要做什么?它的參數是什么?它的全局變量是什么?
遞歸的時候要按照題目的要求來設置函數功能,再根據函數功能來設置函數的參數

其實我們按照題目要求來設置函數功能,最后總是莫名其妙就把問題給解決了,可能很多人都覺得這也太奇妙了吧。
理解:其實遞歸的理論基礎其實就是制定查找規則按照這個規則找答案,一定能找到答案。

例如,這個題:面試題 04.06. 后繼者,做出來之后覺得莫名其妙就找到答案了。

問題:可是如果完全按照題目要求來設置函數功能的話,那根據先序遍歷和中序遍歷來確定后序遍歷函數的方法參數真的能返回一個數組嗎?

技巧:

  • 全局變量和方法參數:(當前節點狀態)這個方法的參數最好由遞歸時的當前階段的狀態決定!最好這個方法的參數能夠記錄我們當前階段的狀態

    比如說,如果我們這個方法需要實現階乘,那么我們的方法參數需要記錄當前階乘的數字(即 當前階段的狀態)

  • 返回數據:返回數據應該是我們遇到遞歸出口之后,需要告訴給前一步遞歸的信息數據!
    比如,計算階乘我們就需要在遇到遞歸出口之后,告訴我們前一步遞歸我們現在的結果數據,方便整合。

    注意:遞歸函數的返回值最好設置為單個元素,比如說一個節點或者一個數值,告訴前一步遞歸我們現在的結果數據即可。
    如果返回值是數組的話,我們將無法從中提取到任何有效信息來進行操作;
    如果結果需要數組的話,我們可以將數組作為公共變量返回值為void,我們在方法體里面操作數組即可。

2. 尋找遞歸出口

2.尋找遞歸出口:我們需要思考,什么時候我們該結束遞歸了。遞歸一定要有結束條件,不然會永遠遞歸下去,禁止套娃

遞歸出口:一般為某深度,或葉子節點,或非葉子節點(包括根節點)、所有節點等。決定遞歸出去時要執行的操作。

特別注意:每次提交數組的集合(即 List<List<>>的時候,都要記得創建一個新的數組來存放結果數組元素(即 new List<>(list)),不然后面操作的都是加入集合后的那個數組。

這里需要注意的是,由於我們的節點狀態可能需要多個參數來表示,所以我們的遞歸出口可能也並不唯一,我們有可能需要為每個狀態參數來安排一個遞歸出口,確保我們的遞歸能夠確實有效的出去。

例如:
我們需要注意這里的遞歸出口:

  1. 當我們操作一棵樹root的時候,我們的遞歸出口可能是if (root == null)
  2. 而我們在操作兩顆樹t1,t2的時候,我們的遞歸出口應該包括這兩棵樹所有為null的情況,如 if (t1 == null && t2 == null)if (t1 == null || t2 == null)這樣才能概況完所有為null的出口情況。

實例:
面試題 08.09. 括號
面試題 04.10. 檢查子樹

並且值得注意的是,我們的遞歸出口並不一定都是在最開頭的位置,我們一般在最開頭設置遞歸出口是希望遞歸能以最快的速度出去;
但是有時候我們在對當前節點進行一些相關處理操作之后我們就希望判斷一下能不能遞歸出口,所以遞歸出口有可能是在代碼中間的,大家需要靈活應用。

在這一步,我們需要思考題目需要的解在哪里?是在某一具體的深度、還是在葉子結點、還是在非葉子結點(包括根節點)、還是在每個節點、還是在從跟結點到葉子結點的路徑

  • 在某一具體的深度:if (depth >= n)
    很常見,大部分就是

  • 在每個節點:if (true)
    面試題 08.04. 冪集

3. 找出遞推關系

3.找出遞推關系:開始實現遞歸,一步一步遞推出最終結果。

一般是前后數據有所關聯,遞推。

三步走實例

1. 明確函數功能

第一步,明確這個函數的功能是什么,它要完成什么樣的一件事。
而這個功能,是完全由你自己來定義的。也就是說,我們先不管函數里面的代碼是什么、怎么寫,而首先要明白,你這個函數是要用來干什么的。

明確函數功能其實就是明確題目的目的、遞歸的目的。比如說一棵樹,你就可以對它左子樹操作、右子樹操作,然后再寫對根節點的操作,這樣就能完成對整個樹的遞歸。

例如:求解任意一個數的階乘
要做出這個題,
第一步,要明確即將要寫出的這個函數的功能為:算n的階乘。

//算n的階乘(假設n不為0)
int f(int n) {
    
}

2. 尋找遞歸出口(初始條件)

遞歸就是在函數實現的內部代碼中,調用這個函數本身。所以,我們必須要找出遞歸的結束條件,不然的話,會一直調用自己,一直套娃,直到內存充滿。

  • 必須有一個明確的結束條件。因為遞歸就是有“遞”“歸”,所以必須又有一個明確的點,到了這個點,就不用“遞下去”,而是開始“歸來”。

第二步,我們需要找出當參數為何值時,遞歸結束,之后直接把結果返回。
一般為初始條件,然后從初始條件一步一步擴充到最終結果

注意:這個時候我們必須能根據這個參數的值,能夠直接知道函數的結果是什么。

讓我們繼續完善上面那個階乘函數。
第二步,尋找遞歸出口:我們需要思考,什么時候我們該結束遞歸了。
當n=1時,我們能夠直接知道f(1)=1,此時我們的結果已知,可以結束遞歸了;
那么遞歸出口就是n=1時函數返回1。
如下:

//算n的階乘(假設n不為0)
int f(int n) {
    if(n == 1) {
        return 1;
    }
}

當然,當n=2時,我們也是知道f(2)等於多少的,n=2也可以作為遞歸出口。遞歸出口可能並不唯一的。


這里需要注意的是,由於我們的節點狀態可能需要多個參數來表示,所以我們的遞歸出口可能也並不唯一,我們有可能需要為每個狀態參數來安排一個遞歸出口,確保我們的遞歸能夠確實有效的出去。

我們需要注意這里的遞歸出口:

  1. 當我們操作一棵樹root的時候,我們的遞歸出口可能是if (root == null)
  2. 而我們在操作兩顆樹t1,t2的時候,我們的遞歸出口應該包括這兩棵樹所有為null的情況,如 if (t1 == null && t2 == null)if (t1 == null || t2 == null)這樣才能概況完所有為null的出口情況。

3. 找出遞推關系

第三步,我們要從初始條件一步一步遞推到最終結果。

類比:數學歸納法,多米諾骨牌

  • 初始條件:f(1) = 1
  • 遞推關系式:f(n) = f(n-1)*n

遞歸:

  • 遞:f(n) = n * f(n-1),將f(n)→f(n-1)了。這樣,問題就由n縮小為了n-1,並且為了原函數f(n)不變,我們需要讓f(n-1)乘以n。就這樣慢慢從f(n),f(n-1)“遞”到f(1)。
  • 歸:這樣就可以從n=1,一步一步“歸”到n=2,n=3...
// 算n的階乘(假設n不為0)
int f(int n) {
    if(n = 1) {
        return n;
    }
    // 把f(n)的遞推關系寫進去
    return f(n-1) * n;
}

到這里,遞歸三步走就完成了,那么這個遞歸函數的功能我們也就實現了。
可能初學的讀者會感覺很奇妙,這就能算出階乘了?
那么,我們來一步一步推一下。
f(1)=1
f(2)=f(1)*2=2
f(3)=f(2)*3=2*3=6
...
你看看是不是解決了,n都能遞推出來!


這里的遞推關系也可以為boolean,比如判斷二叉平衡樹

    public boolean isBalanced(TreeNode root) {

        if (root == null) {
            return true;
        }
        return Math.abs(h(root.left) - h(root.right)) <= 1 && isBalanced(root.left) && isBalanced(root.right);
    }

當滿足所有條件時,才返回true;否則,返回false。

這是一種技巧,大家可以留意一下。

優化思路

這里的遞歸優化思路和回溯算法中的優化思路基本一致,兩者可以互通,大家可以把我的兩篇文章都看看作對比。

重復計算

其實遞歸當中有很多子問題被重復計算。

對於斐波那契數列,f(n) = f(n-1)+f(n-2)。
遞歸調用的狀態圖如下:

其中,遞歸計算時f(6)、f(5)...都被重復了很多次,這是極大的浪費,當n越大,因重復計算浪費的就越多,所以我們必須要進行優化。

  • 優化思路:
    • 建立一個數組,將子問題的計算結果保存起來。
    • 判斷之前是否計算過:
      • 計算過,取出來用
      • 沒有計算過,再遞歸計算
  • 實例:
    • 把n作為數組下標,f(n)作為值。
      例如arr[n] = f(n)。
    • f(n)還沒有計算過的時候,我們讓arr[n]等於一個特殊值。
      例如arr[n] = -1。
    • 當我們要判斷的時候,
      • 如果 arr[n] = -1,則證明f(n)沒有計算過;
      • 否則,f(n)就已經計算過了,且f(n) = arr[n]。
        直接把值取出來用就行了。

代碼如下:

// 我們實現假定 arr 數組已經初始化好的了。
int f(int n) {
    if(n <= 1) {
        return n;
    }
    //先判斷有沒計算過
    if(arr[n] != -1) {
        //計算過,直接返回
        return arr[n];
    }else {
        // 沒有計算過,遞歸計算,並且把結果保存到 arr數組里
        arr[n] = f(n-1) + f(n-1);
        reutrn arr[n];
    }
}

剪枝

剪枝:就是在算法優化中,通過某種判斷,避免一些不必要的遍歷過程
形象的說,就是剪去了搜索樹中的某些“枝條”,故稱剪枝
應用剪枝優化的核心問題是設計剪枝判斷方法,即 確定哪些枝條應當舍棄,哪些枝條應當保留的方法。

類比:這個剪枝其實就像人生,一個人因很多種選擇,而到達不同的結局,然而有些選擇我們一開始就可以判斷是bad end了,那么我們在一開始就不會選擇那樣的道路,而不是到達結局才發現是bad end,撞了南牆才知道疼。
未剪枝——不撞南牆不死心,到最后結局才能判斷是否是自己想要的結果
剪枝——及時止損

剪枝可分為:

  • 可行性剪枝
  • 最優性剪枝

可行性剪枝

可行性剪枝:該方法判斷繼續搜索能否得出答案,如果不能直接回溯。

最優性剪枝

最優性剪枝:又稱為上下界剪枝,是一種重要的搜索剪枝策略。
它記錄當前得到的最優值,如果當前結點已經無法產生比當前最優解更優的解時,可以提前回溯。

回溯算法可以設置標志位flag代表找到了,找到了就直接返回,避免繼續回溯浪費時間;或者說找到了方法就返回true。。等方法都行。

可以看我寫的回溯算法中的例題《劍指 Offer 12. 矩陣中的路徑》

自底向上

上面說了那么多,都是自頂向下(把問題逐步變小)的遞歸。
(但是我比較習慣按自頂向下做,按自底向上思考遞歸,因為比較符合數學歸納法,順着推)

除了自頂向下,其實自底向上也是可以完成任務的!
例如,上面的斐波那契數列
自頂向下:把問題逐漸減小

int Fibonacci(int n) {
    if(n == 0)
        return 0;
    if(n == 1)
        return 1;
    return Fibonacci(n-1) + Fibonacci(n-2);
}

自底向上:用底部的小問題答案,組裝成最后的大問題答案

int Fibonacci(int n) {
    int array[n] = {0};
    array[1] = 1;
    for(int i = 2; i < n; i++)
        array[i] = array[i-1] + array[i-2];
}

尾遞歸

遞歸 中的 尾遞歸,其實就好比 動態規划 中的 滾動數組
理解:尾遞歸的本質,其實是將遞歸方法中的需要的“所有狀態”通過方法的參數傳入下一次調用中。

遞歸與尾遞歸
關於遞歸操作,相信大家都已經不陌生。簡單地說,一個函數直接或間接地調用自身,是為直接或間接遞歸。例如,我們可以使用遞歸來計算一個單向鏈表的長度:

public class Node {
    public Node(int value, Node next) {
        this.Value = value;
        this.Next = next;
    }

    public int Value { get; private set; }

    public Node Next { get; private set; }
}

編寫一個遞歸的GetLength方法:

public static int getLengthRecursively(Node head) {
    if (head == null) return 0;
    return getLengthRecursively(head.Next) + 1;
}

在調用時,getLengthRecursively()方法會不斷調用自身,直至滿足遞歸出口。對遞歸有些了解的朋友一定猜得到,如果單項鏈表十分長,那么上面這個方法就可能會遇到棧溢出,也就是拋出StackOverflowException。這是由於每個線程在執行代碼時,都會分配一定尺寸的棧空間(Windows系統中為1M),每次方法調用時都會在棧里儲存一定信息(如參數、局部變量、返回地址等等),這些信息再少也會占用一定空間,成千上萬個此類空間累積起來,自然就超過線程的棧空間了。

不過這個問題並非無解,我們只需把遞歸改成如下形式即可(在這篇文章里我們不考慮非遞歸的解法):

public static int getLengthTailRecursively(Node head, int acc) {
    if (head == null) return acc;
    return getLengthTailRecursively(head.Next, acc + 1);
}

getLengthTailRecursively()方法多了一個acc參數,acc的為accumulator(累加器)的縮寫,它的功能是在遞歸調用時“積累”之前調用的結果,並將其傳入下一次遞歸調用中。

這就是getLengthTailRecursively()方法與getLengthRecursively()方法相比在遞歸方式上最大的區別:getLengthRecursive()方法在遞歸調用后還需要進行一次“+1”,而getLengthTailRecursively()的遞歸調用屬於方法的最后一個操作。

這就是所謂的“尾遞歸”。與普通遞歸相比,由於尾遞歸的調用處於方法的最后,因此方法之前所積累下的各種狀態對於遞歸調用結果已經沒有任何意義,因此完全可以把本次方法中留在堆棧中的數據完全清除,把空間讓給最后的遞歸調用。這樣的優化便使得遞歸不會在調用堆棧上產生堆積,意味着即時是“無限”遞歸也不會讓堆棧溢出。這便是尾遞歸的優勢。

有些朋友可能已經想到了,尾遞歸的本質,其實是將遞歸方法中的需要的“所有狀態”通過方法的參數傳入下一次調用中。對於getLengthTailRecursively方法,我們在調用時需要給出acc參數的初始值:

getLengthTailRecursively(head, 0)

為了進一步熟悉尾遞歸的使用方式,我們再用著名的“菲波納鍥”數列作為一個例子。傳統的遞歸方式如下:

public static int fibonacciRecursively(int n) {
    if (n < 2) return n;
    return fibonacciRecursively(n - 1) + fibonacciRecursively(n - 2);
}

而改造成尾遞歸,我們則需要提供兩個累加器:

// 運算位 + 運算位 -> 結果位
// acc1 + acc2 -> (acc1 + acc2)
// acc2 + (acc1 + acc2) -> 結果位
public static int fibonacciTailRecursively(int n, int acc1, int acc2) {
    if (n == 0) return acc1;
    return fibonacciTailRecursively(n - 1, acc2, acc1 + acc2);
}

於是在調用時,需要提供兩個累加器的初始值:

fibonacciTailRecursively(10, 0, 1)

1、遞歸

前言:今天上網看帖子的時候,看到關於尾遞歸的應用(http://bbs.csdn.net/topics/390215312),大腦中感覺這個詞好像在哪里見過,但是又想不起來具體是怎么回事。如是乎,在網上搜了一下,頓時豁然開朗,知道尾遞歸是怎么回事了。下面就遞歸與尾遞歸進行總結,以方便日后在工作中使用。


關於遞歸的概念,我們都不陌生。簡單的來說遞歸就是一個函數直接或間接地調用自身,是為直接或間接遞歸。一般來說,遞歸需要有邊界條件、遞歸前進段和遞歸返回段。當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回。

用遞歸需要注意以下兩點:

  1. 遞歸就是在過程或函數里調用自身。
  2. 在使用遞歸策略時,必須有一個明確的遞歸結束條件,稱為遞歸出口。

遞歸一般用於解決三類問題:

  1. 數據的定義是按遞歸定義的。(Fibonacci函數,n的階乘)
  2. 問題解法按遞歸實現。(回溯)
  3. 數據的結構形式是按遞歸定義的。(二叉樹的遍歷,圖的搜索)

遞歸的缺點:
遞歸解題相對常用的算法如普通循環等,運行效率較低。因此,應該盡量避免使用遞歸,除非沒有更好的算法或者某種特定情況,遞歸更為適合的時候。在遞歸調用的過程當中系統為每一層的返回點、局部量等開辟了棧來存儲,因此遞歸次數過多容易造成棧溢出。

用線性遞歸實現Fibonacci函數,程序如下所示:

int FibonacciRecursive(int n) {
    if (n < 2)
        return n;
    return (FibonacciRecursive(n-1)+FibonacciRecursive(n-2));
}

遞歸寫的代碼非常容易懂,完全是根據函數的條件進行選擇計算機步驟。例如現在要計算n=5時的值,遞歸調用過程如下圖所示:

2、尾遞歸

顧名思義,尾遞歸就是從最后開始計算, 每遞歸一次就算出相應的結果, 也就是說, 函數調用出現在調用者函數的尾部, 因為是尾部, 所以根本沒有必要去保存任何局部變量. 直接讓被調用的函數返回時越過調用者, 返回到調用者的調用者去。尾遞歸就是把當前的運算結果(或路徑)放在參數里傳給下層函數,深層函數所面對的不是越來越簡單的問題,而是越來越復雜的問題,因為參數里帶有前面若干步的運算路徑。

尾遞歸是極其重要的,不用尾遞歸,函數的堆棧耗用難以估量,需要保存很多中間函數的堆棧。比如f(n, sum) = f(n-1) + value(n) + sum; 會保存n個函數調用堆棧,而使用尾遞歸f(n, sum) = f(n-1, sum+value(n)); 這樣則只保留后一個函數堆棧即可,之前的可優化刪去。

采用尾遞歸實現Fibonacci函數,程序如下所示:

int FibonacciTailRecursive(int n,int ret1,int ret2) {
   if(n==0)
      return ret1; 
    return FibonacciTailRecursive(n-1,ret2,ret1+ret2);
}
int FibonacciTailRecursive(int n,int ret1,int ret2) {
   if(n==0)
      return ret1;
    // 這種感覺就是動態規划的優化——滾動數組,運算位前進,只不過是借用遞歸來實現不確定的循環層數
    int a = ret2;
    int b = ret1 + ret2;
    return FibonacciTailRecursive(n-1,a,b);
}

例如現在要計算n=5時的值,尾遞歸調用過程如下圖所示:

從圖可以看出,為遞歸不需要向上返回了,但是需要引入而外的兩個空間來保持當前的結果。

為了更好的理解尾遞歸的應用,寫個程序進行練習。采用直接遞歸和尾遞歸的方法求解單鏈表的長度,C語言實現程序如下所示:

#include <stdio.h>
#include <stdlib.h>

typedef struct node {
  int data;
  struct node* next;
}node,*linklist;

void InitLinklist(linklist* head) {
     if(*head != NULL)
        free(*head);
     *head = (node*)malloc(sizeof(node));
     (*head)->next = NULL;
}

void InsertNode(linklist* head,int d) {
     node* newNode = (node*)malloc(sizeof(node));
     newNode->data = d;
     newNode->next = (*head)->next;
     (*head)->next = newNode;
}

//直接遞歸求鏈表的長度
int GetLengthRecursive(linklist head) {
    if(head->next == NULL)
       return 0;
    return (GetLengthRecursive(head->next) + 1);
}
//采用尾遞歸求鏈表的長度,借助變量acc保存當前鏈表的長度,不斷的累加
int GetLengthTailRecursive(linklist head,int *acc) {
    if(head->next == NULL)
      return *acc;
    *acc = *acc+1;
    return GetLengthTailRecursive(head->next,acc);
}

void PrintLinklist(linklist head) {
     node* pnode = head->next;
     while(pnode) {
        printf("%d->",pnode->data);
        pnode = pnode->next;
     }
     printf("->NULL\n");
}

int main() {
    linklist head = NULL;
    int len = 0;
    InitLinklist(&head);
    InsertNode(&head,10);
    InsertNode(&head,21);
    InsertNode(&head,14);
    InsertNode(&head,19);
    InsertNode(&head,132);
    InsertNode(&head,192);
    PrintLinklist(head);
    printf("The length of linklist is: %d\n",GetLengthRecursive(head));
    GetLengthTailRecursive(head,&len);
    printf("The length of linklist is: %d\n",len);
    system("pause");
}

程序測試結果如下圖所示:

實例

斐波那契數列

斐波那契數列的是這樣一個數列:1、1、2、3、5、8、13、21、34....,即第一項 f(1) = 1,第二項 f(2) = 1.....,第 n 項目為 f(n) = f(n-1) + f(n-2)。求第 n 項的值是多少。

  • 明確函數功能:f(n)為求第n項的值

    // 1.f(n)為求第n項的值
    int f(int n) {
    
    }
  • 尋找遞歸出口:f(1)=1,f(2)=1

    // 1.f(n)為求第n項的值
    int f(int n) {
        // 2.遞歸出口
        if(n <= 2) {
            return 1;
        }
    }
  • 找出遞推關系:f(n) = f(n-1) + f(n-2)

    // 1.f(n)為求第n項的值
    int f(int n) {
        // 2.遞歸出口
        if(n <= 2) {
            return 1;
        }
        // 3.遞推關系
        return f(n-1) + f(n-2);
    }

小青蛙跳台階

一只青蛙一次可以跳上1級台階,也可以跳上2級。求該青蛙跳上一個n級的台階總共有多少種跳法。

  • 明確函數功能:f(n)為青蛙跳上一個n級的台階總共有多少種跳法

    int f(int n) {
    
    }
  • 尋找遞歸出口:f(0)=0,f(1)=1

    int f(int n) {
        // 遞歸出口
        if(n <= 1) {
            return n;
        }
    }
  • 找出遞推關系:f(n) = f(n-1)+f(n-2)

    int f(int n) {
        // 遞歸出口
        if(n <= 2) {
            return 1;
        }
        // 遞推關系
        return f(n-1) + f(n-2);
    }

劍指 Offer 24. 反轉鏈表

定義一個函數,輸入一個鏈表的頭節點,反轉該鏈表並輸出反轉后鏈表的頭節點。

示例:
輸入: 1->2->3->4->5->NULL
輸出: 5->4->3->2->1->NULL

答案

初始

反轉head.next

接下來只需要把節點 2 的 next 指向 1,然后把 1 的 next 指向 null,不就行了?,即通過改變 newList 鏈表之后的結果如下:

// 試試遞歸
class Solution {
    public ListNode reverseList(ListNode head) {
        if (head == null || head.next == null) {
            return head;
        }
        ListNode newHead = reverseList(head.next);  // 反轉head.next,也就是說,現在的head.next變為尾結點了

        ListNode tail = head.next;  // 尾結點
        tail.next = head;   // 尾結點加上還沒有反轉的頭節點
        head.next = null;   // 頭節點的下一個置為null,作為新的尾結點
        return newHead;
    }
}

約瑟夫環問題 劍指 Offer 62. 圓圈中最后剩下的數字

0,1,···,n-1這n個數字排成一個圓圈,從數字0開始,每次從這個圓圈里刪除第m個數字(刪除后從下一個數字開始計數)。求出這個圓圈里剩下的最后一個數字。

例如,0、1、2、3、4這5個數字組成一個圓圈,從數字0開始每次刪除第3個數字,則刪除的前4個數字依次是2、0、4、1,因此最后剩下的數字是3。

示例 1:

輸入: n = 5, m = 3
輸出: 3

示例 2:

輸入: n = 10, m = 17
輸出: 2

答案

class Solution {
    public int lastRemaining(int n, int m) {

        // 約瑟夫環問題
        if (n == 1) {
            return 0;
        }

        // 1. 我們首先刪除第 (m % n) 個元素
        // 2. 刪除之后從下一個元素開始計數,即 第 (m % n) 個元素變成了我們的首元素。
        // (這是一個循環鏈表噢,所以剩下的鏈表為 以第(m % n)個元素開頭的長度為n - 1的鏈表)
        // 3. 我們只需要遞歸返回獲取我們這長度為 n - 1 的鏈表以 m 為跨度刪除,會剩下第幾個元素
        // 4. 假設這個元素為第 x 個元素,那么我們鏈表長度為 n 時,剩下的元素應該是從第一次刪除的第(m % n)個元素為開頭算起往后的第 x 個元素。


        // 舉個例子:我們鏈表長度為 n - 1 時刪除,會剩下第 1 個元素
        // 那么我們鏈表長度為 n 時第一次刪除,會刪除第(m % n)個元素,
        // 然后再剩下的以第(m % n)個元素開頭的,長度為 n - 1的鏈表中刪除,最終會剩下開頭的第 1 個元素,只不過這個第 1 個元素現在的序號是從 (m % n) 開始往后算的(當長度為 n 時,不包括第(m % n)個元素,它已經被刪除了)
        // 注意:應該從第一次刪除的第(m % n)個元素為開頭算起(長度為n - 1的鏈表不包含這個元素)往后的第 1 個元素。

        int x = lastRemaining(n - 1, m);
        
        return (m % n + x) % n;
        // return (m + x) % n;  // 等價於上面的式子
    }
}


// class Solution {
//     public int lastRemaining(int n, int m) {

//         // 約瑟夫環問題
//         // 我們循環遍歷數組,將要刪除的數字置為n,每次遍歷都要跳過n去執行m次,一共要刪除n-1輪,留下一個

//         int cur = 0;
//         for (int i = n; i > 1; i++) {

//             int times = m;
//             while (times != 0) {
//                 if (cur == n) {
//                     cur = (cur + 1) % i;    // 前進
//                     continue;
//                 }
//                 cur = (cur + 1) % i;    // 前進
//                 times--;
//             }
//             cur = n;    // 做到這里發現不對勁了,因為它不是遍歷數組,我們的改變無法持久化存儲!!!!
//         }
//     }
// }

劍指 Offer 16. 數值的整數次方

實現 pow(x, n) ,即計算 x 的 n 次冪函數(即,xn)。不得使用庫函數,同時不需要考慮大數問題。

示例 1:

輸入:x = 2.00000, n = 10
輸出:1024.00000

示例 2:

輸入:x = 2.10000, n = 3
輸出:9.26100

示例 3:

輸入:x = 2.00000, n = -2
輸出:0.25000
解釋:2-2 = 1/22 = 1/4 = 0.25

超時答案

class Solution {
    public double myPow(double x, int n) {

        return n >= 0 ? f(x, n) : 1.0 / f(x,n);
    }

    public double f(double x, int n) {

        if (n == 0) {
            return 1;
        }
        if (n > 0) {
            return x * f(x, n - 1);
        }
        if (n < 0) {
            return x * f(x, n + 1);
        }

        return 1;
    }
}

快速冪

「快速冪算法」的本質是分治算法。舉個例子,如果我們要計算 \(x^{64}\) ,我們可以按照:
\[x \to x^2 \to x^4 \to x^8 \to x^{16} \to x^{32} \to x^{64}\]
的順序,從 x 開始,每次直接把上一次的結果進行平方,計算 6 次就可以得到 \(x^{64}\) 的值,而不需要對 x 乘 63 次 x。

再舉一個例子,如果我們要計算 \(x^{77}\),我們可以按照:
\[x \to x^2 \to x^4 \to x^9 \to x^{19} \to x^{38} \to x^{77}\]
的順序,在 \(x \to x^2\)\(x^2 \to x^4\)\(x^{19} \to x^{38}\) 這些步驟中,我們直接把上一次的結果進行平方,而在 \(x^4 \to x^9\)\(x^9 \to x^{19}\)\(x^{38} \to x^{77}\) 這些步驟中,我們把上一次的結果進行平方后,還要額外乘一個 x。

直接從左到右進行推導看上去很困難,因為在每一步中,我們不知道在將上一次的結果平方之后,還需不需要額外乘 x。但如果我們從右往左看,分治的思想就十分明顯了:

  • 當我們要計算 \(x^n\) 時,我們可以先遞歸地計算出 \(y = x^{\lfloor n/2 \rfloor}\),其中 \(\lfloor a \rfloor\) 表示對 a 進行下取整;
  • 根據遞歸計算的結果,如果 n 為偶數,那么 \(x^n = y^2\);如果 n 為奇數,那么 \(x^n = y^2 \times x\)
  • 遞歸的邊界為 \(n = 0\),任意數的 0 次方均為 1。

由於每次遞歸都會使得指數減少一半,因此遞歸的層數為 \(O(\log n)\),算法可以在很快的時間內得到結果。

// 很遺憾,上面的遞歸遇到n為2147483647的時候爆棧了,只能換種方法了。。
// 試試快速冪
class Solution {
    public double myPow(double x, int n) {
        if(n == 0){
            return 1;
        }else if(n < 0){    // 如果是負數,那就把它變成正數來求解
            return 1 / (x * myPow(x, - n - 1));
        }else if(n % 2 == 1){   // 正數,冪為奇數我們可以把它變成偶數
            return x * myPow(x, n - 1);
        }else{  // 正數,冪為偶數我們可以使用快速冪
            // 快速冪:x → x2 → x4 → x8 → x16 → x32 → x64
            return myPow(x * x, n / 2);
        }     
    }
}

復雜度分析

  • 時間復雜度:\(O(\log n)\),即為遞歸的層數。

  • 空間復雜度:\(O(\log n)\),即為遞歸的層數。這是由於遞歸的函數調用會使用棧空間。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM