DFSとBFS
記事ディレクトリ
序文
DFS と BFS は 2 つの基本的な検索方法です。検索は、考えられるすべての状況をリストし、それらを 1 つずつチェックして答えを見つける総当たりアルゴリズムを具体的に実現したものです。
DFS と BFS の同じポイント: どちらも出口を見つけることができ、両方ともすべての交差点と道路を徹底的に探索する必要があります。
違い: BFS を使用すると最短パスを簡単に見つけることができますが、DFS を使用するとより困難になります。DFS を使用すると入口から出口までのすべてのパスを検索できますが、BFS を使用すると検索できません。DFS プログラミングは BFS よりも簡単です。
DFS
基本的な考え方
DFS プログラミングは通常、再帰を使用して実装され、再帰コードを記述する場合は、最適化のためにメモリ検索を使用する必要があります。
再帰的アルゴリズムの考え方は、同じ種類の最小の問題になるまで大きな問題を徐々に減らし、最小値に達したら、最終的な問題が解決されるまで、より大きな問題に 1 つずつ答えていくことです。したがって、再帰には 2 つのプロセスがあります。再帰的順方向と再帰的戻り (バックトラック) です。
コードテンプレート
ans; //答案,用全局变量表示
void dfs(层数,其他参数){
if(出局判断){ //到达最底层,或者满足条件退出
更新答案; //答案一般用全局变量表示
return; //返回到上一层
}
(剪枝) //在进一步DFS之前剪枝
for(枚举下一层可能的情况) //对每一种情况继续DFS
if(used[i]==0){ //如果状态i没有用过,就可以进入下一层
used[i]=1; //标记状态i,表示已经用过,在更底层不能再使用
dfs(层数+1,其他参数); //下一层
used[i]=0; //恢复状态,回溯时,不影响上一层对这个状态的使用
}
return; //返回到上一层
}
DFSを実行する場合、「シーンの保存」と「シーンの復元」は非常に重要であり、これは検索されたパスが正しいか、繰り返されるかに関係します。
-
「現場の保存」の役割は再利用を禁止することです。始点から終点までの経路を探索する場合、この経路上を通過する点を繰り返し通過することができず、ぐるぐる回ってしまうため、経路上の点を「シーン保存」する必要がありますが、そして再度追い越しを禁止します。通過していないポイント、または壁にぶつかった後に戻ってきたポイントはシーンを保存できず、これらのポイントは後で現在のパスに入る可能性があります。
-
「リカバリサイト」の役割は再利用を可能にすることです。新しい経路を再探索する場合は、終点(または衝突点)から元の経路に沿って一歩後退する方法で、点が後退するたびにその点が「復元」され、新しい経路が可能になります。再びこの地点を通過します。
DFSアプリケーション
順列を生成する
- 配置
一部のシナリオでは、システムの置換関数はnext_permutation()
適用できないため、置換アルゴリズムを独自に記述する必要があります。
次のコードは、a[20] 内の数値が小さい値から大きい値の順に並べられている場合、小さい値から大きい値の順序を出力できます。それ以外の場合は、最初に並べ替える必要があります。
コードでは、b[] を使用して新しい完全な配置を記録します。初めて dfs() を入力するとき、b[0] は n 個の数値の中から数値を選択します。2 回目に入力するとき、b[1] は次のいずれかを選択します。残りの n 個の番号 - 1 個の番号から番号を選択...vis[] を使用して、特定の番号が選択されたかどうかを記録し、選択された番号は後で選択することはできません。
#include<bits/stdc++.h>
using namespace std;
int a[20]={1,2,3,4,5,6,7,8,9,10,11,12,13};
bool vis[20]; //记录第i个数是否用过
int b[20]; //生成的一个全排列
void dfs(int s,int t){
if(s==t){ //递归结束,产生一个全排列
for(int i=0;i<t;++i)cout<<b[i]<<" "; //输出一个排列
cout<<"; ";
return;
}
for(int i=0;i<t;i++)
if(!vis[i]){
vis[i]=true;
b[s]=a[i];
dfs(s+1,t);
vis[i]=false;
}
}
int main(){
int n=3
dfs(0,n); //前n个数的全排列
return 0;
}
実行後の出力:1 2 3; 1 3 2; 2 1 3; 2 3 1; 3 1 2; 3 2 1;
たとえば、n 個の数値のうち任意の m 個の数値の配置を出力する必要がある場合、4 つの数値のうち任意の 3 つの数値を取り出し、上記のコードの 21 行目を n=4 に変更してから、dfs() の 7 行目と 8 行目を変更します。 3に変更可能です。
- 組み合わせ
DFS 出力と組み合わせます。DFSを実行する場合、k番目の番号を選択するか選択しないかによって、さまざまな組み合わせが実現できます。
例として 3 つの数字 {1,2,3} を取り上げます。
#include<bits/stdc++.h>
using namespace std;
int a[]={1,2,3,4,5,6,7,8,9,10};
int vis[10];
void dfs(int k){
if(k==3){
for(int i=0;i<3;i++)
if(vis[i])cout<<a[i];
cout<<"-";
}
else {
vis[k]=0; //不选中第k个数
dfs(k+1); //继续搜下一个数
vis[k]=1; //选这个数
dfs(k+1); //继续搜下一个数
}
}
int main(){
dfs(0); //从第一个数开始
return 0;
}
出力結果:-3-2-23-1-13-12-123-
迷路問題
まず例を見てみましょう。
この質問のある時点にいる人は、標識に沿って歩くか、最後には外に出られるか、あるいは出られないか、このような「最後までずっと」という歩き方は典型的な DFS です。
この問題を DFS で直接解決すると、複雑さは O(n^4) となり、n が大きい場合、コードは深刻なタイムアウトになります。
実際には、点ごとに dfs を実行する必要はなく、たとえば、ある点から開始して道を歩き、最後に迷路から抜け出すと、このパス上のすべての点から開始して、迷路から抜け出すことができます。 ; このパスを周回する場合、このパス上のすべてのポイントを通過しても迷路から抜け出すことはできません。したがって、重要なのは、計算量を大幅に削減するために、パス全体をマークする方法です。最適化されたコードはsolve[][]
で実装されます。
...
char mp[n+2][n+2];
bool vis[n+2][n+2];
int solve[n+2][n+2]; //solve[i][j]=1表示这个点能走出去,solve[i][j]=2表示这个点走不出去
int ans=0;
int cnt=0;
bool dfs(int i,int j){
if(i<0||i>n-1||j<0||j>n-1)return true;
if(solve[i][j]==1) return true; //点(i,j)已经算过,能走出去
if(solve[i][j]==2) return false;//点(i,j)已经算过,走不出去
if(vis[i][j])return false;
cnt++; //统计DFS了多少次
vis[i][j]=true;
if(mp[i][j]=='L'){
if(dfs(i,j-1)){solve[i][j]=1;return true;} //回退,记录整条路径都能走出去
else {solve[i][j]=2;return false;} //回退,记录整条路径都走不出去
}
if(mp[i][j]=='R'){
if(dfs(i,j+1)){solve[i][j]=1;return true;}
else {solve[i][j]=2;return false;}
}
if(mp[i][j]=='U'){
if(dfs(i-1,j)){solve[i][j]=1;return true;}
else {solve[i][j]=2;return false;}
}
if(mp[i][j]=='D'){
if(dfs(i+1,j)){solve[i][j]=1;return true;}
else {solve[i][j]=2;return false;}
}
}
...
迷路内の各点に 1 回値を割り当てるだけで答えが得られるためsolve[][]
、複雑さは O(n²) になります。
定期的な問題
正規表現とも呼ばれる正規表現は、通常、特定のパターン (ルール) に一致するテキストを取得および置換するために使用されます。
単純な正規表現を考えてみましょう。
x ( ) | のみで構成される正規表現。
Xiao Ming は、この正規表現が受け入れることができる最長の文字列の長さを調べたいと考えています。
例: ((xx|xxx)x|(x|xx)) xx が受け入れることができる最長の文字列は xxxxxx で、長さは 6 です。
このトピックでは、括弧 "()" が最も優先されるか、操作 "()" が 2 番目に優先されます。括弧内は全体で、長い方を演算の両側に置きます。
今回のテーマは古典的なスタック アプリケーションであるブラケット マッチングですが、DFS (再帰) プログラミングも同時に使用できるため、回答が簡単になります。
#include<bits/stdc++.h>
using namespace std;
string s;
int pos=0; //当前的位置
int dfs(){
int tmp=0,ans=0;
int len=s.size();
while(pos<len){
if(s[pos]=='()') {pos++;tmp+=dfs();} //左括号,继续递归,相当于入栈
else if(s[pos]==')') {pos++;break;} //右括号,递归返回,相当于出栈
else if(s[pos]=='|') {pos++;ans=max(ans,tmp);tmp=0;} //检查或操作
else if(s[pos]=='x') {pos++;tmp++;} //检查x,并统计x个数
}
ans=max(ans,tmp);
return ans;
}
int main(){
cin>>s;
cout<<dfs();
return 0;
}
BFS
基本的な考え方
BFS の原理は、開始点から開始して層ごとに探索する「層ごとの拡散」です。プログラミング時に、BFS はキューを使用して実装されます。BFS の特徴は、レイヤーごとに検索することであり、最初に検索されたレイヤーが開始点に近いため、BFS は一般に最短経路問題を解決するために使用されます。
- 初期状態 S から開始して、ルールを使用して次の層の状態が生成されます。
- 次の層のすべての状態を順番にチェックして、目標状態 G が存在するかどうかを確認します。それ以外の場合は、レイヤーのすべての状態ノードにルールを使用します。次の層の状態ノードを生成します。
- 上記の考え方に従って次の層のすべての状態ノードを生成し続けることで、目的の状態が表示されるまで層が拡張されます。
検索ツリーは階層順にたどられます。
コードテンプレート
通常はキューを使用して実装されます (先入れ先出し、先入れ先出し)
初始化队列Q.
Q={起点s};标记s为已访问;
while(Q非空){
取Q队首元素u;
u出队;
if(u==目标状态){...}
所有与u相邻且未被访问的点进入队列;
标记u为已访问;
}
BFSアプリケーション
最短の道を見つける
以下は、BFS を使用して最短パスを見つけるプロセスの例です。
キューの変更プロセスは次のとおりです。
BFS は最短パスを見つけるための優れたアルゴリズムですが、それが適しているのは 1 つの状況のみです。つまり、隣接する 2 つの点間の距離が等しく、この距離は通常 1 とみなされます。この場合、始点から終点までの最短距離を求めるには、BFS が最適なアルゴリズムです。1 回の歩行の距離が 1 でない場合、歩行数が多いパスの方が、歩行数が少ないパスよりも短くなる可能性があります。このとき、BFS は使用できませんが、ダイクストラ、SPFA、フロイドなどのアルゴリズムが必要です。
BFS アルゴリズムを使用して重み付けされていないグラフの最短パスを解くコードを示します。
//定义图的数据结构
vector<int> adj[N]; //存储图中每个节点的相邻节点
//定义队列及访问标记
queue<int> q;
bool visited[N];
//源点s和目标节点t
int s, t;
//BFS求解最短路径
int bfs(int s, int t) {
memset(visited, false, sizeof(visited)); //初始化所有节点均未访问过
q.push(s); //将源点s入队
visited[s] = true; //标记源点s已访问过
int step = 0; //记录当前的层数,即走过的步数
while (!q.empty()) {
int size = q.size(); //当前层的节点数
for (int i = 0; i < size; i++) {
int cur = q.front(); //取出队首节点
q.pop();
if (cur == t) return step; //如果找到目标节点t,返回最短路径长度
for (int j = 0; j < adj[cur].size(); j++) {
int neighbor = adj[cur][j]; //取出相邻节点
if (!visited[neighbor]) { //如果该节点未被访问过
visited[neighbor] = true; //标记已访问
q.push(neighbor); //将该节点入队
}
}
}
step++; //步数加1
}
return -1; //未找到最短路径,返回-1
}
このうち、N はグラフ内のノードの総数を表し、adj はグラフ内の各ノードの隣接ノードを格納するデータ構造 (通常は隣接リストを使用します) を表し、s はソース ポイントを表し、t はターゲット ノードを表します。このアルゴリズムでは、キューを使用して現在の層のすべてのノードを保存します。現在の層を走査した後、ステップ数 step が 1 ずつ増加し、ターゲット ノードが見つかるかすべてのノードが見つかるまで次の層が走査されます。横断しました。
最短パスを計算する場合、次の問題が発生します。
- 最短パスの長さ: 最短パスの長さは一意であることに注意してください。
- 最短経路が通過するポイント。最短パスが複数存在する可能性があるため、通常、タイトルには出力パスは必要ありませんが、出力が必要な場合は、辞書順が最も小さいパスが出力されるのが一般的です。
接続性判定
接続性の判断はグラフ理論における単純な問題であり、点と点を接続する辺で構成されるグラフが与えられた場合、グラフ内の相互接続部分を見つける必要があります。
接続の問題は、BFS と DFS の両方で解決できます。ただし、N が大きい場合、DFS を使用すると再帰の深さが大きすぎるためにエラーが発生する可能性があるため、現時点では BFS を使用する必要があります。
BFS 接続の判断手順:
- グラフ上の任意の点 u からトラバースを開始し、それをキューに入れます。
- キュー u の先頭をポップアップし、点 u が検索済みであることをマークし、点 u の近傍点、つまり点 u に接続されている点を検索してキューに入れます。
- キューの先頭を取り出し、検索済みとしてマークし、それに接続されている近隣を検索してキューに入れます。
キューが空になるまで上記の手順を続けます。この時点で、接続されたブロックが見つかります。訪問されていない他のポイントは、別の接続されたブロックに属します。これらのポイントを上記の手順に従って処理し続けます。最終的に、すべてのポイントが検索され、すべての接続されたブロックが見つかります。
例えば以下の例では、N*M 個の長方形領域と各領域の状態(油の有無)が与えられ、油が付着している領域が 2 つ隣接(横、縦、斜め)していれば、それに属するものとみなされます。同じオイルポケットです。
この長方形の領域にオイルポケットがいくつあるかを求めます。
アイデア:
各油領域について、同じオイル ポケットに属するすべての油領域を見つけて、最終的に合計でいくつの油ポケットがあるかを計算します。
同じオイル ポケットに属するすべてのオイル ポケットを見つけるにはどうすればよいですか?
BFS: 開始点を見つけます。この点から開始し、周囲のエリアを列挙して油っぽいエリアを見つけます。見つかった新しいエリアから順番に開始し、新しいエリアが追加されなくなるまで上記のプロセスを繰り返します。
同じオイルポケットに属する油っぽい領域にマークを付けるにはどうすればよいですか?
この領域が含まれているかどうかを示すアクセス フラグを設定すると、BFS への呼び出し数 = オイル ポケットの数になります。
コードのフレームワークは次のとおりです。
Void BFS(int i,int j)
{
初始化队列Q;
while(Q不为空)
{
取出队首元素u;
枚举元素u的相邻区域, if (此区域有油)
{
入队;访问标记;
}
}
}
int main()
{
…
枚举所有区域,if (此区域有油&&没有被访问过)
BFS(…,…)
}
剪定
BFS と DFS は暴力法を直接実現したものであり、あらゆる状態を探索することができます。しかし、不必要な計算に多くの時間が無駄になる可能性があります。
剪定は比喩であり、答えをもたらさない枝や不要な枝を切り落とすことです。重要なのは、どの枝を切るか、どこを切るかという判断です。枝刈りは検索に一般的に使用される最適化方法であり、多くの場合、指数関数的な複雑さを最適化して多項式の複雑さを近似できます。
BFS の主な枝刈り技術は重みを判断することで、ある層に繰り返し状態がある場合に枝刈りを行います。
DFS プルーニング手法は数多くありますが、一般的な考え方は「検索状態を減らす」ことです。
たとえば、前の迷路の問題(迷路の各点に 1 人がいます。何人出られるかを尋ねます)
枝刈り: メモリ検索を使用できます。ポイントが検索されている場合、再度検索する必要はありません。
グリッド分割の問題もあります (6×6 グリッドを 2 つの同じ部分に分割し、分割数がいくつあるかを尋ねます)。
枝刈り: 中心点から分割を開始します。中心点に関して対称な 2 つの点を同時に分割することはできないことに注意してください。実現可能性枝刈りが使用されます。
同様に、判断も枝刈りであり、要するに検索数を最小限に抑えることです。
要約する
DFS アルゴリズムは通常、スタックまたは再帰を使用して実装されます。DFS アルゴリズムは、検索プロセス中に現在のパスを記録するだけでよいため、空間計算量には優れていますが、極端な場合には無限ループに陥り、検索を終了できなくなる可能性があります。さらに、DFS アルゴリズムは最初のソリューションのみを検索するため、通常、見つかったソリューションが最適なソリューションであることを保証する方法がありません。
BFS アルゴリズムは通常、キューを使用して実装されます。BFS アルゴリズムは最短パスを見つけるためにすべてのノードを横断する必要があるため、空間計算量と時間計算量の点で DFS アルゴリズムよりも高くなります。ただし、BFS アルゴリズムは、見つかった解が最短であることを保証できますが、DFS アルゴリズムでは保証できません。
したがって、アルゴリズムを選択するときは、特定の問題の特性と要件に従って、時間計算量と空間計算量の関係を比較検討し、適切なアルゴリズムを選択する必要があります。同時に、アルゴリズムを実装する際には、より良い結果を得るために境界条件やアルゴリズムの最適化などの問題にも注意を払う必要があります。
現在、DFS および BFS アルゴリズムはコンピューター サイエンスで最も重要なアルゴリズムの 1 つとなり、広く使用されています。たとえば、深層学習のバックプロパゲーション アルゴリズムは DFS ベースのアルゴリズムとみなすことができ、コンピューター ネットワークでは、ルーティング アルゴリズムの実装でも多数の DFS および BFS アルゴリズムが使用されます。
さらに、DFS および BFS アルゴリズムは、計算時間が短く、実装が簡単であるという特徴があるため、画像処理における接続性分析アルゴリズムや人工知能における検索アルゴリズムなど、多くのアルゴリズムの基礎にもなっています。DFS と BFS を十分に理解することは、他の分野をより良く学ぶのに役立ちます。