【戀上數據結構】遞歸(函數調用過程、斐波那契數列、上樓梯、漢諾塔、遞歸轉非遞歸、尾調用)

什麼是遞歸?

遞歸:函數(方法)直接或間接調用自身,是一種常用的編程技巧。

方法直接調用自身:

int sum(int n) {
	if (n <= 1) return n;
	return n + sum(n - 1);
}

方法間接調用自身:

/**
 * 沒有遞歸出口, 最終會 StackOverflow
 */
static void a(int v) {
	b(--v);
}
static void b(int v) {
	a(--v);;
}

在這裏插入圖片描述

函數的調用過程(棧空間)

棧空間會將調用的函數依次入棧,一般來說最先入棧的是 main,下圖中的 test1 雖然被調用了,但是沒有執行操作,編譯器會忽略它test2 調用了 test3,所以 test2test3 依次入棧。
在這裏插入圖片描述

函數的遞歸調用過程

下圖中 main 先入棧,sum(4)sum(3)sum(2)sum(1) 由於遞歸調用依次入棧,可見空間複雜度爲 O(n)
在這裏插入圖片描述
在這裏插入圖片描述

遞歸實例分析(1 + 2 + 3 + … + 100 的和)

求 1 + 2 + 3 + … + (n - 1) + n 的和(n>0)


遞歸做法:

int sum(int n) {
	if(n <= 1) return n;
	return sum(n - 1) + n; 
}

總消耗時間 T(n) = T(n − 1) + O(1),因此,時間複雜度:O(n)、空間複雜度:O(n)


循環做法:

int sum(int n) {
int result = 0;
	for (int i = 0; i <= n; i++) {
		result += i;
	}
	return result;
}

時間複雜度:O(n),空間複雜度:O(1)


求和公式:

int sum(int n) {
	if (n <= 1) return n;
	return (1 + n) * n >> 1;
}

時間複雜度:O(1),空間複雜度:O(1)


  • 注意:使用遞歸不是爲了求得最優解,是爲了簡化解決問題的思路,代碼會更加簡潔
  • 遞歸求出來的很有可能不是最優解,也有可能是最優解

遞歸的基本思想、使用套路

基本思想:拆解問題,大化小
在這裏插入圖片描述
使用套路:明確功能、關係、邊界條件
在這裏插入圖片描述

斐波那契數列

斐波那契數列:1、1、2、3、5、8、13、21、34、……

  • F(1)=1F(2)=1F(n)=F(n1)+F(n2)n3F(1) = 1,F(2) = 1,F(n) = F(n - 1) + F(n - 2)(n ≥ 3)

編寫一個函數求第 n 項斐波那契數:

int fib(int n) {
	if (n <= 2) return 1;
	return fib(n - 1) + fib(n - 2);
}
  • 根據遞推式 T(n) = T(n − 1) + T(n − 2) + O(1),可知:
  • 時間複雜度:O(2n)
  • 空間複雜度:O(n)

遞歸調用的空間複雜度 = 遞歸深度 * 每次調用所需的輔助空間

fib函數的調用過程

在這裏插入圖片描述

fib優化1 — 記憶化

用數組存放計算過的結果,避免重複計算

時間複雜度:O(n),空間複雜度:O(n)

/*
 * 用數組存放計算過的結果,避免重複計算
 */
int fib(int n) {
	if(n <= 2) return 1;
	int[] array = new int[n + 1];
	array[2] = array[1] = 1;
	return fib(array, n);
}
int fib(int[] array, int n) {
	if (array[n] == 0) {
		array[n] = fib(array, n - 1) + fib(array, n - 2);
	}
	return array[n];
}

在這裏插入圖片描述

fib優化2 — 去除遞歸調用

這是一種 “自底向上” 的計算過程

時間複雜度:O(n),空間複雜度:O(n)

int fib(int n) {
	if(n <= 2) return 1;
	int[] array = new int[n + 1];
	array[2] = array[1] = 1;
	for (int i = 3; i <= n; i++) {
		array[i] = array[i - 1] + array[i - 2];
	}
	return array[n];
}

fib優化3 — 滾動數組

由於每次運算只需要用到數組中的 2 個元素,所以可以使用滾動數組來優化

時間複雜度:O(n),空間複雜度:O(1)

int fib(int n) {
	if (n <= 2) return 1; 
	int[] array = new int[2];
	array[0] = array[1] = 1;
	for (int i = 3; i <= n; i++) {
		array[i % 2] = array[(i - 1) % 2] + array[(i - 2) % 2];
	}
	return array[n % 2];
}

乘、除、模運算效率較低,建議用其他方式(位運算)取代

int i = 100;
// (i % 2) == (i & 1);
System.out.println(
	(i % 2) == (i & 1) // true
);
 // (i * 2) == (i << 1);
System.out.println(
	(i * 2) == (i << 1) // true
);
// (i / 2) == (i >> 1);
System.out.println(
	(i / 2) == (i >> 1) // true
);

位運算優化後的滾動數組:

int fib(int n) {
	if ( n <= 2) return 1;
	int[] array = new int[2];
	array[0] = array[1] = 1;
	for (int i = 3; i <= n; i++) {
		array[i & 1] = array[(i - 1) & 1] + array[(i - 2) & 1];
	}
	return array[n & 1];
}

fib優化4 — 去除數組

只有兩個元素,直接通過2個變量即可,不需要創建數組。

時間複雜度:O(n),空間複雜度:O(1)

int fib(int n) {
	if (n <= 2) return 1;
	int first = 1;
	int second = 1;
	for (int i = 3; i <= n; i++) {
		second = first + second;
		first = second - first;
	}
	return second;
}

fib優化5 — 數學公式

在這裏插入圖片描述
時間複雜度、空間複雜度取決於 pow 函數(至少可以低至 O(logn) )

int fib(int n) {
	double c = Math.sqrt(5);
	return (int)((Math.pow((1 + c) / 2, n) - Math.pow((1 - c) / 2, n)) / c);
}

上樓梯(跳臺階)

在這裏插入圖片描述
1 階臺階只有1種走法,所以:f(1)=1f(1) = 1
2 階臺階有2種走法(11、2),所以:f(2)=2f(2) = 2
3 階臺階有3種走法(111、21、12),所以:f(3)=3f(3) = 3

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

跟斐波那契數列幾乎一樣,因此優化思路也是一致的:

int climbStairs(int n) {
	if (n <= 2) return n;
	int first = 1;
	int second = 2;
	for(int i = 3; i <= n; i++) {
		second = first + second;
		first = second - first;
	}
	return second;
}

漢諾塔(Hanoi)

在這裏插入圖片描述

1個盤子、2個盤子、3個盤子圖示

1個盤子的情況:
在這裏插入圖片描述
2個盤子的情況:
在這裏插入圖片描述
3個盤子的情況:
在這裏插入圖片描述
在這裏插入圖片描述

漢諾塔 — 思路

分 2 種情況討論即可:

  • 當 n == 1時,直接將盤子從 A 移動到 C

  • 當 n > 1時,可以拆分成3大步驟

    • ① 將 n – 1 個盤子從 A 移動到 B :hanoi(n - 1, p1, p3, p2);
      在這裏插入圖片描述
    • ② 將編號爲 n 的盤子從 A 移動到 C:move(n, p1, p3);
      在這裏插入圖片描述
    • ③ 將 n – 1 個盤子從 B 移動到 C:hanoi(n - 1, p2, p1, p3);
      在這裏插入圖片描述

    步驟 ① ③ 明顯是個遞歸調用

漢諾塔 — 實現

T(n) = 2 ∗ T(n - 1) + O(1),時間複雜度是:O(2n),空間複雜度:O(n)

public class Hanoi {
	public static void main(String[] args) {
		new Hanoi().hanoi(4, "A", "B", "C");
	}
	/**
	 * 將第 i 號盤子從 from 移到 to
	 */
	void move(int i, String from, String to) {
		System.out.println(i + "號盤子: " + from + "->" + to);
	}
	
	/**
	 * 將 n 個盤子從 p1 移動到 p3
	 */
	void hanoi(int n, String p1, String p2, String p3) {
		if (n <= 1) {
			move(n, p1, p3);
			return;
		}
		hanoi(n - 1, p1, p3, p2); 	//  將 n – 1 個盤子從 p1 移動到 p2 
		move(n, p1, p3); 			// 將編號爲 n 的盤子從 p1 移動到 p3
		hanoi(n - 1, p2, p1, p3); 	// 將 n – 1 個盤子從 p2 移動到 p3 
	}
	
}
1號盤子: A->C
2號盤子: A->B
1號盤子: C->B
3號盤子: A->C
1號盤子: B->A
2號盤子: B->C
1號盤子: A->C

漢諾塔的代碼是沒有規律的,不像斐波那契數列,因此沒有優化的空間

遞歸轉非遞歸(用棧模擬100%可以轉)

記住一句話:遞歸100%可以轉成非遞歸
在這裏插入圖片描述

遞歸轉非遞歸的萬能方法

  • 自己維護一個棧,來保存參數、局部變量
  • 但是空間複雜度依然沒有得到優化

例如針對下面這段遞歸代碼:

public static void main(String[] args) {
	log(5);
}

static void log(int n) {
	if(n < 1) return;
	log(n - 1);
	int v = n + 10;
	System.out.println(v);
}

我們嘗試將遞歸轉爲非遞歸

首先創建一個棧幀類:

public class Frame {
	int n;
	int v;
	public Frame(int n, int v) {
		super();
		this.n = n;
		this.v = v;
	}
}

然後我們手動模擬函數調用後入棧的過程,從而將遞歸轉爲非遞歸

static void log(int n) {
	Stack<Frame> frames = new Stack<>();;
	while (n > 0) {
		frames.push(new Frame(n, n + 10));
		n--;
	}
	while (!frames.isEmpty()) {
		Frame frame = frames.pop();
		System.out.println(frame.v);
	}
}

某些時候其實有更精妙的做法,可以重複使用一組相同的變量來保存每個棧幀的內容

static void log(int n) {
	for(int i = 0; i < n; i++) {
		System.out.println(i + 10);
	}
}

這裏重複使用變量 i 保存原來棧幀中的參數,使得空間複雜度從 O(n) 降到了 O(1)。

尾調用(Tail Call)

在這裏插入圖片描述

下面這段代碼不是尾調用:因爲它最後一個動作是乘法,沒有調用自身。

int factorial(int n) {
	if (n <= 1) return n;
	return n * factorial(n - 1);
}

尾調用優化(Tail Call Optimization)

在這裏插入圖片描述

尾調用優化前後的彙編代碼(C++)

針對這麼一段尾調用代碼:

void test(int n) {
	if (n < 0) return;
	printf("test - %d\n", n);
	test(n - 1);
}

尾調用優化的彙編代碼:
在這裏插入圖片描述
尾調用優化的彙編代碼:
在這裏插入圖片描述

尾遞歸示例

階乘

求 n 的階乘 1 * 2 * 3 * … * (n - 1) * n (n>0)

普通遞歸:

int factorial(int n ) {
	if (n <= 1) return n;
	return n * factorial(n - 1);
}

轉化爲尾遞歸

int factorial(int n ) {
	return factorial(n, 1);
}
/**
 * @param result 從大到小累乘的結果
 */
int factorial(int n, int result) {
	if(n <= 1) return result;
	return factorial(n - 1, n * result);
}

斐波那契數列

普通遞歸:

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

轉化爲尾遞歸

int fib(int n) {
	return fib(n, 1, 1);
}

int fib(int n, int first, int second) {
	if (n <= 1) return 1;
	return fib(n - 1, second, first + second);
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章