---
title: '核心技巧 - 回朔 & DFS'
disqus: kyleAlien
---
核心技巧 - 回朔 & DFS
===
## OverView of Content
[TOC]
## 回朔 & DFS
DFS 是一種 **概念**,是深度為優先的概念
回朔是實現 DFS 的一種手段
### 回朔 - 概述
* 回朔的重點在於 **==列舉==**,這是不是跟 [**動態規劃**](https://hackmd.io/ty1Hyx-kQ5S3vg30Z6uqpQ?view) 有點像? 但它 **比動態規劃更簡單,可以當作動態規劃的特例版本**
:::success
- 複習動態規劃,列出 **轉移方程** 需要以下準備
- base case
- 題目狀態
- 每個狀態可做的選擇
- dptable 優化(可選)
> ![](https://i.imgur.com/TDZOMKA.png)
:::
### 回朔 - 重點
* 其實回朔就是解決一個 多元樹問題,更精確得來說是一個 **==決策樹的尋訪==**,**複雜度高,Big O(n!)**
> ![](https://i.imgur.com/22Yx5jM.png)
:::info
* 經典問題:`全排列`、`N 皇后`,這些問題都是需要 **列舉出全部答案**
:::
* 回朔需要思考三個問題 (要點)
1. **路徑紀錄**:已做出的選擇
2. **選擇清單**:目前還可以做的選
3. **結束條件**:達到底層,無法再做選擇
> ![](https://i.imgur.com/SaalAdh.png)
### DFS & 回朔 - 框架
* 回朔 框架如下:**使用遞歸**,重點有 `路徑紀錄`、`結束條件`、`選擇清單`,其核心在於 **++在遞歸之前做選擇++、++遞歸後移除選擇++**
```java=
// 路徑紀錄
List<路徑> pathRecord = new ArrayList<>();
int backtrack(路徑, 選擇清單) {
if(結束條件) { // 結束條件
return;
}
for(選擇:選擇清單) { // 遍歷選擇清單
if(可選擇列表判斷) { // 已走過路徑不再走
continue;
}
TODO: 做選擇,紀錄到 pathRecord // 做選擇!
backtrack(路徑, 選擇清單); // 更新選擇清單
TODO: 撤銷選擇,從 pathRecord 移除 // 移除選擇!
}
}
```
## 全排列問題
排列組合問題,n 個不重複的全排列共有幾組組合? `n!` 個
> 5! = 5 * 4 * 3 * 2 * 1
以下全排列問題也不包含重複數字
### [1 , 2 , 3] 全排列
* `[1 , 2 , 3]` 最簡單的就是按照順序:將每種可能繪出,繪出後就會發現它就是一個 **多元樹**,這棵樹又可以稱為回朔演算法的 **==決策樹==**
> 以下框框內是可選擇 (每有可選擇的項目,就是其中一個解答),路徑則是已選擇
>
> ![](https://i.imgur.com/vAVbgo2.png)
:::warning
* 啥是 **決策樹** ?
每個節點 (Tree Node) 都是決策,所以是決策樹
:::
* 前面框架鎖定義的 `backtrace` 函數就像是一個指標,**透過 ==遞迴== (for、while...)** 在多元樹上移走,當走到底層沒有選擇時(結束條件),就是全排列的一個答案
> ![](https://i.imgur.com/EGfQCkr.png)
### 樹 - 回朔尋訪
* 其實從 **回朔模組** 來看,它就是一個多元樹的尋訪,而回朔就是在尋訪的過程中,在 `前序`、`後序` 做處理
```java=
// 多元樹模組
public class BaseModule {
private static final class TreeNode {
TreeNode[] children;
int value;
}
public void recursive(TreeNode node) {
for(TreeNode item : children) {
// 前序
recursive(item);
// 後序
}
}
}
```
* 選擇在 `前序`、`後序` 做處理的原因是因為,**要 ++維護節點的屬性++**
- 前序:在進入節點之前進行處理
- 後續:在退出節點時處理
> ![](https://i.imgur.com/ivDKMVZ.png)
### 全排列 - 遞迴 + 迭代實做
* 這邊我們假設有一組 **不重複** 的數字 Array (這裡使用 `[1 , 2 , 3]`),要針對它進行排序
1. 紀錄列表:使用 `LinkedList` 紀錄走過得位置
2. 結束條件:`紀錄列表長度` = `提供的 Array`;這代表已走到底端,Array 被消耗完畢
3. 選擇清單:去除當前不可選的選項(從紀錄列表判斷),**在前序時進行選擇、後序時移除選擇**
```java=
public List<List<Integer>> getCompleteRange(int[] array) {
List<List<Integer>> result = new ArrayList<>();
// 協助函數
_getCompleteRange(array, new Stack<>(), result);
return result;
}
private void _getCompleteRange(int[] array,
Stack<Integer> memo, // 紀錄已走過路徑
List<List<Integer>> result) {
// base case 結束遞迴的條件
if(array.length == memo.size()) { // 已走過的路 == 數組長度
// 已全排列完成
List<Integer> tmp = new ArrayList<>(memo);
result.add(tmp);
return;
}
// status choose
// 遍歷 Array 的所有路徑
for(int item : array) {
// 判斷避免重複
if(memo.contains(item)) { // 排除已走過的路徑,判斷(維護)節點的選
continue;
}
// 前序選擇
memo.push(item);
// 修改 memo (條整狀態),遞迴呼叫
_getCompleteRange(array, memo, result);
// 後續移除選擇
memo.pop();
}
}
```
:::info
這裡使用 **`Stack` 數據結構 紀錄路徑** 來達成 `O(1)` 的效能
:::
* 測試全排列 DFS 回朔
```java=
public static void main(String[] args) {
CompleteRange completeRange = new CompleteRange();
int[] test = {
1, 2, 3
};
List<List<Integer>> result = completeRange.getCompleteRange(test);
for(List<Integer> subResult: result) {
System.out.println(subResult);
}
}
```
> ![](https://i.imgur.com/5mcASfN.png)
<!--
### 全排列 - 全迭代實做
* 除了使用遞迴實作以外,我們也可透使用迭代 (while、for...) 來替代遞迴的功能
-->
### DFS 效能、特性
* DFS 的特性是列舉出所有可能,但 **DFS 無法保證探索路徑的長度 ! (無法找出最短路徑)**
* 回朔的 Big O 時間複雜度,這裡我們可以跟動態規劃比對,**==兩者都是必須列舉==**
| 演算法 | 時間複雜度 | 可否優化 | 優化後的複雜度 |
| -------- | -------- | -------- | - |
| 動態規劃 | O(分支^n^) | 可,使用 dptable、memo,空間換時間 | 解決子結構重疊 O(n) |
| 回朔 | O(n!) | 不可 | - |
:::warning
從這裡我們可以知道,**回朔演算法一般 ++複雜度都很高++**
:::
## N 皇后
:::success
西洋棋的皇后(棋子),可以攻擊同一行、列、左上、左下、右上、右下(就是米字形的攻擊方式)
- 現在要在 N\*N 的棋盤上放置 N 個皇后,並且 **要讓它們不能相互攻擊**,計算全部結果
:::
### N 皇后 - 解法步驟
Rows/Columns 定義
![](https://i.imgur.com/ibwbBem.png)
1. **Debug 訊息 & 初始化方法**:以下方法用來 `初始化`、`查看` Java 二維陣列
```java=
// 初始化
private void debugOrInit(Character[][] array, Supplier<Character> supplier) {
for (Character[] characters : array) {
for (int i = 0; i < array.length; i++) {
if(supplier == null) {
System.out.print("[" + characters[i] + "]");
continue;
}
characters[i] = supplier.get();
}
System.out.println();
}
System.out.println("------------------");
}
// debug 方法
private void debug(Character[][] array) {
debugOrInit(array, null);
}
// 複製 棋盤
private Character[][] copy(Character[][] array) {
int size = array.length;
Character[][] result = new Character[size][size];
for (int i = 0; i < array.length; i++) {
System.arraycopy(array[i], 0, result[i], 0, array.length);
}
return result;
}
```
2. **套用回朔(backtrace)框架**:套用框架寫出 N 皇后的程式主幹
* **結束回朔的條件**:判斷 `Row` 是否到達棋盤的尾端,到達尾端就結束 `backtrace` 遞迴
:::info
* 使用遞迴,如同 動態拓展,須思考進入下次的遞迴時 **==可變狀態是誰==**?
> 已當前案例,進入遞迴改變者為 `row`,這樣才能對應到結束條件
:::
* **紀錄路徑**:^1.^ 進入下一個回朔前(前序)加入 `Q` 到棋盤中、^2.^ 結束回朔時(後續)移除 `Q` 的位置改為 `.`(也就是未經過的路)
* **進行選擇**:這邊使用 迭代 `iterate` 棋盤的 `Colume`,並在迭代中進入遞迴傳入 `Row + 1` 來改變狀態
:::info
* N 皇后使用的遞迴概念如下
```java=
// 先 colume
for(int colume = 0; colume < N; colume++) {
// 在每個 colume 條件下,迭代 row
for(int row = 0; row < N; row++) { // 遞歸使用 function 代替
// TODO: 條件判斷
}
}
```
:::
```java=
// N 皇后實做
public LinkedList<Character[][]> backtrace_nQueens(int n) {
// 創建 N 皇后的棋盤 !
Character[][] board = new Character[n][n];
// 初始化二維陣列
debugOrInit(board, () -> '.');
// debug(board);
// 儲存結果
LinkedList<Character[][]> result = new LinkedList<>();
// 進入回朔方法
backtrace(0, board, result);
return result;
}
private void backtrace(int row,
Character[][] board,
LinkedList<Character[][]> result) {
// 1. 結束回朔(遞迴)的條件
if(row == board.length - 1) {
result.addLast(copy(board));
return;
}
// 2. 進行選擇
for(int col = 0; col < board.length; col++) {
if(!isValid(board, row, col)) { // isValid 等等講
continue;
}
// 放入 Queen
board[row][col] = 'Q';
// 這裡的可變狀態為 row,所以 row + 1 再進入遞迴
backtrace(row + 1, board, result);
board[row][col] = '.';
}
}
```
3. **判斷是否進入遞迴的條件**:在這裡就可以判斷目前的點,是否可以符合 皇后的條件(條件是不可與皇后攻擊的道路相同)
:::info
皇后攻擊的路線是 **米字型**
:::
```java=
// 判斷是否進入遞迴的條件
private boolean isValid(Character[][] board, int row, int col) {
// 1. row 從 0 判斷橫向
for(int i = 0; i < row; i++) {
if(board[i][col] == 'Q') {
return false;
}
}
// 2. 斜方左下判斷
int boardLimit = board.length;
for(int i = row - 1, j = col + 1;
i >= 0 && j < boardLimit; i--, j++) {
if(board[i][j] == 'Q') {
return false;
}
}
// 3. 斜方左上判斷
for(int i = row - 1, j = col - 1;
i >= 0 && j >= 0; i--, j--) {
if(board[i][j] == 'Q') {
return false;
}
}
return true;
}
```
概念圖,可正常加入:則如下 (預想注入點是傳入的 row、col 參數)
> ![](https://i.imgur.com/vHD0UKu.png)
概念圖,不可正常加入:如下左上斜方衝突
> ![](https://i.imgur.com/JtkP03e.png)
### 回朔 - 結論
* 回朔演算法就是 **多元樹尋訪**(同動態規劃)
1. 回朔在樹中是使用到 **`前序`、`後序`**
2. 回朔另一個重點是 **維護路徑**,在 `前序` 把路經添加進去,`後序` 把路徑移除
```java=
boolean backtrace(...狀態) {
for(選擇) {
做選擇
backtrace(改變狀態)
移除選擇
}
}
```
* 回朔 & 動態規劃的關係
| 回朔 | 動態規劃 |
| -------- | -------- |
| 結束遞迴條件 | base case |
| 迭代選擇清單 | 選擇 |
| 路徑紀錄 | 改變狀態 |
## Appendix & FAQ
:::info
:::
###### tags: `資料結構`