算法AK說 又是遞歸?! 這樣講全排列算法,應該明白了!

寫在前面:大家好K。首先爲你點進這篇有趣的文章點贊👍!文章在撰寫過程中難免有疏漏和錯誤,歡迎你在下方留言指出文章的不足之處;如果覺得這篇文章對你有用,也歡迎你點贊和留下你的評論。更多內容請點進👉我的博客K。👈閱覽。

代碼運行結果圖

問題:求1、2、3三個數的全排列

1. 分析遞歸問題

遞歸圖

老師:同學們好。今天我們來學習一道全排列的例題,首先請問同學們,遞歸函數有那兩個部分?

同學:老師好。遞歸函數包含 遞歸出口遞歸體

老師:沒錯,那麼全排列問題的遞歸出口是什麼?

同學:遞歸出口是最簡單的情況,不用計算過程,計算機能秒出答案。全排列中最簡單的情況,就是分解成只有“1個數”的情況:比如,“1”的全排列就是“1”,“2”的全排列就是“3”……

老師:說得對。但是把一個大規模問題的遞歸過程全展開來學習代碼,是十分複雜的,通常我們只選擇最簡單的過程來分析(當然,只有一個數的過程是最簡單的過程,但是這是能直接出答案的過程,對分析問題來說沒有意義,這裏語境中已經排除掉遞歸出口的情形),那麼,全排列問題中,最簡單的過程是什麼呢?

同學:最簡單的過程是隻有兩個數的過程,比如只有兩個數1、2,我們很容易想出來答案是12和21。通過簡單例子來學習遞歸代碼,遞歸層數不會很深,會更有助於理解代碼。老師,您先用兩個數的例子來給我們講解吧。

老師:好。這位同學對於遞歸的基礎已經掌握得很好了,那現在我們通過這道例題來學習一下,看起來很複雜的遞歸代碼,該如何來理解。學會後,對以後學習快速排序、合併排序、動態規劃等經常用到遞歸來解決的問題會很有幫助。現在,我們開始講解吧!

2. 通過分析兩個數的簡單情況寫代碼

無Bug
老師:我們現在假設有2個數:1、2,它倆的全排列經歷了三步,你們來說一說過程呢?

同學:有下面三個步驟:

----------------------------------------------------------------
|①步,對程序來說,原先的大數組有倆元素:1、2;                        |
|②步,第一次交換結果僅僅是恰好與初始狀態相同,但也是交換了的。           |
|③步,拆分成兩個小數組:只有一個元素“1”的子數組,只有一個元素“2”的子數組;|
----------------------------------------------------------------
 1) 求第一個結果       |       2) 求第二個結果
 ①初始倆數:12;      |       ①初始倆數:12;
 ②交換順序:12;      |       ②交換順序:21;
 ③拆分得:1、2;      |        ③拆分得:2、1;
 ④組合輸出結果:12;   |        ④組合輸出結果:21;

老師:沒錯,不管有多少個數,都有三個大步驟:拆分大數組----交換順序----組合輸出結果。那麼來試一下寫出倆數全排列的代碼吧。

同學:老師,我發現組合輸出結果的過程,是最簡單的。我們先寫這部分代碼:

// 數組名爲list
// 首下標是k,尾下標是m

// 當首下標就是尾下標時,說明這是隻有一個數的數組
// 換句話說,原大數組已經被拆分到最小(②③步),可以直接輸出結果了
if (k==m) {
	// 循環輸出結果
	for (int i = 0; i <= m; i++) {
      cout << list[i] << " ";
    }
    cout << endl;
}

老師:沒錯,這是1個數時,即最簡單的情況下要做的事情,這也是遞歸函數的出口。

同學:老師,交換兩數的代碼可以這樣寫,對嗎?

swap(list[k], list[m]);

老師:這個代碼在只有兩個數時是沒問題的,但是如果數多了以後,比如,給一組數:1、2、3。k是首下標,m是尾下標。如果僅僅交換這一頭一尾的值,交換是不徹底的,永遠是123、321,“2”沒有做過第一位。所以要通過引入第三個變量,來使數組中所有數都做過第一位

同學:老師,我們可以通過for循環的控制變量i,來遍歷所有情況:

// k永遠都是數組的第一個下標,以後拆分成小數組後,k是各小數組的第一位
// i值從k開始,直到和m相等,意味着所有數都做過第一位
for (int i = k; i <= m; i++) {
	swap(list[k], list[i]);
}

老師:這就對了!現在我們已經把交換順序和組合輸出結果的代碼都寫出來了:

void perm(int list[], int k, int m) {
	// 組合輸出結果
	if (k == m) {
		for (int i = 0; i <= m; i++) {
			cout << list[i] << " ";
		}
		cout << endl;
	} 
	// 交換順序
	else {
		for (int i = k; i <= m; i++) {
			swap(list[k], list[i]);
		}
	}
}

那麼拆分的代碼怎麼寫呢?
在這裏插入圖片描述
同學將大數組拆分成小數組,是重複的過程,並且每次範圍都要變小(遞歸,代碼如下)。其實變量i值從k開始循環的過程,就已經隱含了拆分的步驟。小數組從大數組中來,編程中實際一直是用一個數組來存儲原數組,但是人爲通過k值的變化(遞歸函數調用時k+1),將大數組拆分成想象的小數組。因此循環並不是每次都從原數組的首下標開始。每次循環的首下標都是新的小數組的首下標(即是從i=k開始而不是0開始)。

void perm(int list[], int k, int m) {
	// 組合輸出結果
	if (k == m) {
		for (int i = 0; i <= m; i++) {
			cout << list[i] << " ";
		}
		cout << endl;
	} 
	// 交換順序
	else {
		for (int i = k; i <= m; i++) {
			swap(list[k], list[i]);
			// 這裏是遞歸,函數自己調用自己
			// 每次調用該函數時,list(數組名)、m(尾下標)倆值不變
			// k值每次都往m靠攏1位,到最後k會與m相等,到達遞歸出口
			perm(list, k + 1, m);
		}
	}
}

3. 消滅多個數的“特殊”情況

全排列圖
老師:做到這一步已經很棒了。但是運行一下會發現,只有兩個數的時候結果是正確,到三個數以上就不行了。這是哪兒出問題了呢?我們看一下同學們以兩個數爲例描述的步驟:

1) 求第一個結果        |       2) 求第二個結果
 ①初始倆數:12;      |       ①初始倆數:12;
 ②交換順序:12;      |       ②交換順序:21;
 ③拆分得:1、2;      |        ③拆分得:2、1;
 ④組合輸出結果:12;   |        ④組合輸出結果:21;

我們可以看到,1)的初始數是12,2)的初始數也是12,說明每次開始處理的初始狀態是相同的,但是爲啥兩個數的時候運行正確呢?原因是,2個數的情況太簡潔了,沒有體現出這種錯誤。1)的②步交換順序的結果,恰好是與初始狀態相同的,當2)又用到原數組時,自然拿到的數組的順序貌似就沒有變過。

老師:如果是123三個數的話(如下圖描述),1)大步①②交換順序時,同樣與初始狀態相同的,這一步正確✅,③步拆分得到1和23,還沒完,23還需要拆分,然後23被拆分再組合成23和32(就是倆數全排列的情形)✅,即1)大步執行結果就是123、132。但是我們發現了,原數組123經過這幾個交換組合後,變成了132。再拿去給2)大步執行,就錯了

1) 分而治之
①初始仨數:123;
②交換順序:123;
③拆分得:1、23;
④“1”已最簡,“23”需再拆分
******************************
*注:對計算機來說,到這裏數組是123*
******************************
2) 求第一個結果           |       3) 求第二個結果
 ①初始倆數:23;         |       ①初始倆數:23;
 ②交換順序:23;         |       ②交換順序:32;
 ③拆分得:2、3;         |       ③拆分得:3、2;
 ④組合輸出結果:123;     |       ④組合輸出結果:132;
******************************
*注:對計算機來說,到這裏數組是123*
******************************
                                ******************************
                                *注:對計算機來說,到這裏數組是132*
                                ******************************

同學:老師,我明白了,3)大步之後,如果再不對數組順序進行恢復,之後的結果就會出錯。事實上,1)2)大步也是需要對數組恢復原順序的,只是剛好交換完成的順序與初始狀態相同,僥倖逃過了錯誤。所以我們應該加一行代碼,讓順序再換回來:

void perm(int list[], int k, int m) {
	// 組合輸出結果
	if (k == m) {
		for (int i = 0; i <= m; i++) {
			cout << list[i] << " ";
		}
		cout << endl;
	} 
	// 交換順序
	else {
		for (int i = k; i <= m; i++) {
			swap(list[k], list[i]);
			perm(list, k + 1, m);
			// 上面交換後這裏再交換,順序恢復如初
			swap(list[k], list[i]);
		}
	}
}

老師:沒錯,同學們真聰明!

4. 完整代碼

在這裏插入圖片描述
同學:這就是我們的全部代碼:

#include <iostream>

using namespace std;

void swap(int &a, int &b) {
  int tmp = a;
  a = b;
  b = tmp;
}

void perm(int list[], int k, int m) {
  if (k == m) {
    for (int i = 0; i <= m; i++) {
      cout << list[i] << " ";
    }
    cout << endl;
  } else {
    for (int i = k; i <= m; i++) {
      swap(list[k], list[i]);
      perm(list, k + 1, m);
      swap(list[k], list[i]);
    }
  }
}

int main() {
  int list[] = {1, 2, 3, 4};
  perm(list, 0, 3);
  return 0;
}

代碼運行結果:
代碼運行結果圖

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章