【算法】深度優先搜索(dfs)

忽然發現機房裏有不少人不會暴搜(dfs),因此寫一篇他們能聽得懂的博客(大概?)ios

PS:萬能 yuechi ———— 大法師怎麼能不會呢?!

如有錯誤,請 dalao 指出。c++

前置

我知道即便不少人都知道 dfs 是用遞歸來實現的,但免不了仍是叨叨幾句:git

  • 要有邊界(否則你要遞歸到猴年馬月……)算法

  • 剪枝(即便是暴搜也不至於從頭莽到尾)數組

  • 別犯 sb 錯誤(debug 到心累,最後發現邊界寫錯了 = =)函數

大概流程

嚴格來講,dfs 其實也是有一套固定的流程,畢竟優化

萬物皆可板(bushi)spa

  1. 定義如今的狀態(即搜索到了哪個位置)debug

  2. 枚舉可能的狀況(如一個數多是 \([0,9]\)code

  3. 標記枚舉到的狀況已被用了(如一個數已是偶數了,那下一個數就不能是偶數(這個視狀況而定))

  4. 判斷有無到達邊界(若是到達就輸出,沒到就繼續搜(用遞歸))

  5. 回溯(難點,下面舉例來說)

放幾個例題來說解一下

例題一

不少算法都是創建在 dfs 上的,先放一個裸題。

題目描述

一個的 \(n \times n\) 的跳棋棋盤,有 \(n\) 個棋子被放置在棋盤上,使得每行、每列有且只有一個,每條對角線(包括兩條主對角線的全部平行線)上至多有一個棋子。

數據範圍:\(n \in [6, 13]\)

分析

八皇后的題目我相信你們也不陌生,積護全部 dfs 入門的人都作過,但我仍是來分析一下吧。

看過數據範圍,就能確認眼神:一道 dfs 能作的題。

首先,很容易就能知道: \(n\) 個棋子必定是在不一樣行,不一樣列的,這是能夠構成限制的。

要求每條對角線上只能有一個棋子,這不只是限制,也是該題的難點所在,若是要優化能夠從這裏入手。

既然是搜(暴搜),那麼就能夠從第一行開始,到達最後一行結束(邊界)。

代碼實現

從第一行開始枚舉行數,同時也枚舉列數,而且記錄下棋子放下的位置致使出現的限制。

變量 意義
a 存儲答案
b1 判斷一個位置是否能放棋子
b2 判斷這個數有無被用(貌似沒用)
t 搜到的當前的行數
函數 意義
fread 快讀
bj 標記位置不能用
hy 標記位置能用
print 搜完輸出答案
search dfs
/**
*
author:Eiffel_A
*/
#include <iostream>
#include <iomanip>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <map>
#include <queue>
#define MAXN 100001
#define Mod 998244353
//-------------定義變量-------------
int n, s = 0;
int a[14], b1[14][14], b2[14];
//------------定義結構體------------

//-------------定義函數-------------
int fread() {
	int x = 0, f = 0; char ch = getchar();
	while (!isdigit(ch)) f |= (ch == '-'), ch = getchar();
	while (isdigit(ch)) x = x * 10 + (ch ^ 48), ch = getchar();
	return f ? -x : x;
}
void bj(int x, int y) { // 一個棋子放下後將對角線標記爲不可用
	for (int j = 1; j <= n; ++j) {
		if (x + j >= 1 && x + j <= n && y + j >= 1 && y + j <= n && b1[x + j][y + j] == 0)
			b1[x + j][y + j] = x;
		if (x + j >= 1 && x + j <= n && y - j >= 1 && y - j <= n && b1[x + j][y - j] == 0)
			b1[x + j][y - j] = x;
	}
}
void hy(int x, int y) { // 將棋子回溯到未放下時將對角線標記爲可用
	for (int j = 1; j <= n; ++j) {
		if (x + j >= 1 && x + j <= n && y + j >= 1 && y + j <= n && b1[x + j][y + j] == x)
			b1[x + j][y + j] = 0;
		if (x + j >= 1 && x + j <= n && y - j >= 1 && y - j <= n && b1[x + j][y - j] == x)
			b1[x + j][y - j] = 0;
	}
}
void print() { // 到達邊界後輸出
	s++;
	if (s <= 3) {
		for (int i = 1; i <= n; ++i)
			printf("%d ",a[i]);
		printf("\n");
	}
}
int search(int t) { // dfs
	for (int i = 1; i <= n; ++i) // 枚舉列數
		if (!b2[i] && !b1[t][i]) { // 若是這一列尚未棋子且不在任何一條已放棋子的對角線上
			a[t] = i; b2[i] = t; // 記錄棋子位置,標記這列已用
			bj(t, i); // 標記對角線已用
			if (t == n) print(); // 若是到了最後一行,就輸出
			else search(t + 1); // 不然繼續搜下一行
			b2[i] = 0; // 回溯,這列還沒用
			hy(t, i); // 回溯,這個對角線還沒用
		}
}
//--------------主函數--------------
int main() {
	n = fread();
	search(1);
	printf("%d", s);
	return 0;
}

請不要在乎我難看的馬蜂和奇怪的變量名……

解釋回溯

讓咱們想象一下:

當判斷是否搜到邊界時,

若是到了,則輸出,而後回到 dfs 函數裏;

但這時候僅僅只是找到了一種可行的擺放方法,還有許多方法還沒開始搜,

因此咱們要僞裝這個位置沒有放過棋子,即退回放這個棋子以前,這樣才能將這一列空出來,以便在其餘行在這一列放棋子,找到更多的狀況。

若沒到邊界,則又會進入新的一行,一直到到達邊界爲止,剩下的就與上一種狀況一致了。

若是到如今仍是沒懂的話,那我舉個栗子:

假如你正在走迷宮:emmmm 這個(我手畫的……)

你走到了終點:這樣(橡皮開路)

可是你的要求是找出全部能到達終點的路,僅僅只有一條是不夠的,

因此你得退回去:


(固然也能夠退到其餘地方)

這樣你就能夠找另外一條道路:

因此回溯大概就是這麼一個過程~~

dfs (\(t\)) 每一層 dfs 能夠用變量 \(t\) 來標記,能夠把 \(t\) 看作是下標(反正我這麼理解)

若是 \(t == 1\) 就說明這一層 dfs 是在 \(1\) 這個點的,以此類推。

這樣回溯就會很好理解啦~~

優化

這個代碼是我剛剛學 dfs 時寫的,只不過又被我扒了出來改了改馬蜂罷了……

若是你像我這份代碼這樣判斷一條對角線有無佔用,那麼當你把代碼交上去後,你就會驚喜地發現:

你 T 啦~~

大概是反覆調用標記和回溯函數的問題……

因此要優化的說~~

而後通過我深(cha)思(kan)熟(ti)慮(jie)後發現了一個好方法:

咱們能夠再開一個 \(c\) 數組和一個 \(d\) 數組,而後把 \(b1\)\(b2\) 數組去掉,改爲 \(b\) 數組 。

衆所周知,若是一個點的座標是 \((x,y)\) 且獨一無二,那麼 \(x + y\)\(x - y + n\)\(n\) 是總行數)就是獨一無二的。

這樣就能夠表達出對角線啦~~~

int search(int t) {
	for (int i = 1; i <= n; ++i)
		if (!b[i] && !c[t + i] && !d[t - i + n]) {
			a[t] = i; b[i] = 1;
			c[t + i] = 1; d[t - i + n] = 1;
			if (t == n) print();
			else search(t + 1);
			b[i] = 0;
			c[t + i] = 0; d[t - i + n] = 0;
		}	
}

例題二

題目描述

將整數 \(n\) 分紅 \(k\) 份,且每份不能爲空,任意兩個方案不相同(不考慮順序)。

例如:\(n=7\)\(k=3\),下面三種分法被認爲是相同的。

\(1,1,5\)\(1,5,1\)\(5,1,1\)

問有多少種不一樣的分法。

數據範圍:\(n\in (6,200]\)\(k\in [2,6]\)

分析

幾乎與上一題同樣,無非只是把條件和枚舉的東西變了一下而已 = =

PS:下面的代碼是錯的,並且還刪了幾個頭文件(貌似 pd 函數寫錯了,不過這不重要)

代碼實現

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
//------------定義結構體------------ 

//-------------定義變量-------------
int n, k, s, v = 0, w = 0;
int a[10], b[10]; 
map<int,int> hh;
//-------------定義函數------------- 
bool pd() { // 錯的
	w = 0; memcpy(b, a, sizeof(a));
	sort(b, b + 10);
	for (int j = 9; j >= 10 - k; --j)
		w *= 10, w += b[j];
	if (!hh[w]) v++, hh[w] = 1;
}
int search(int t) {
	if (t == k && s) a[t] = s, pd(); // 判斷到邊界後是否知足條件,若知足,則輸出
	else {
		for (int i = a[t - 1]; i <= n; ++i) { // 保證下一個數大於等於上一個數,防止重複
			if (!i) continue; // 若是 i 爲零,則不算入答案
			if (i < s) { // 保證各數字之和不大於 n
				a[t] = i; // 記錄 i
				s -= a[t]; // 減去加數
				search(t + 1); // 繼續搜
				s += a[t]; // 回溯,僞裝沒用過這個加數
			}
		}
	}
}
//--------------主函數-------------- 
int main() {
	cin >> n >> k;
	s = n;
	search(1);
	printf("%d", v);
	return 0;
}

依舊是好久之前寫的代碼,被我扒拉出來改改馬蜂貼了上來……

Q:爲何把錯的代碼放了上來?
A:是由於我 懶得改 只想讓大家瞭解思路就好了

優化

經查實,若是你按照這個思路(即 pd 函數寫對)交了上去

你會驚喜得發現:

你又 T 啦~~

這時候就又須要優化剪枝了,咱們能夠這樣想:

  • 既然是求 \(k\) 個數,又知道這 \(k\) 個數的和,那麼只須要求 \(k - 1\) 個數,最後一個數減出來就好辣。

  • 直接減出來了數,就不用判斷全部數加起來是否等於 \(n\)

  • 只須要判斷減出來的數是否大於以前的數(判重)。

這下正解代碼就出來啦~~

int search(int t) {
	if (t == k && s >= a[t - 1]) ++v;	
	if (t != k)
		for (int i = a[t - 1]; i <= n; ++i) {
			if (!i) continue;
			if (i < s) {
				a[t] = i;
				s -= a[t];
				search(t + 1);
				s += a[t];
			}
		}
}

後言

我相信兩道例題已經足夠講明白了,就不舉第三個例子了 (其實只是我不想寫了而已)

祝全部人 noip2020 rp++

練習題

  1. 洛谷 八皇后 Checker Challenge(例題一)

  2. 洛谷 數的劃分(例題二)

  3. 一堆 慢慢刷

相關文章
相關標籤/搜索