アルゴリズム設計と解析のためのバックトラッキング手法

1. バックトラッキングの概要

バックトラッキングはヒューリスティックとも呼ばれ、最適なソリューションを見つけるための総当り検索手法です。暴力のため、バックトラッキング法の時間計算量は高いため、最短経路問題などの大きな数を伴う一部の問題を比較すると、一般に実行時間は長くなります。バックトラッキング手法では、**DFS (Depth First Search)** が非常に重要なツールです。

1.1 DFSの基本的な考え方

(1) ある可能性のある状況を前方に探索し、子ノードを生成します。

(2) プロセス中に、元の選択が要件を満たしていないことが判明すると、親ノードに戻り、別の方向を再選択し、子ノードを再度生成して、引き続き探索を続けます。

(3) 最適解が得られるまでこれを繰り返します。

1.2 バックトラッキング手法の基本的な考え方

(1) 特定の問題について、問題の解決空間を定義します。

(2)検索しやすい解空間構造を決定する(データ構造の選択)。

(3) 一般に、解空間はDFSの形式で検索されます。

(4) 検索プロセス中に、枝刈り機能を使用してアルゴリズムを最適化できます。(枝刈り機能:制約​​関数制限関数を用いて、最適解が得られない部分木を切り捨てる機能を総称して枝刈り関数といいます。)

ソリューション スペース: 名前が示すように、問題に対するすべてのソリューションのセットです。(しかし、これは私たちが求める最適な解決策にはまだ程遠いです。)

制約: 効果的な解決策の要件、つまりタイトルの要件。

制約関数:制約を満たさない部分木を減算する関数。

バウンディング関数:最適解が得られないノードを削除する関数。

拡張ノード: 現在子ノードを生成しているノードを拡張ノードと呼びます。

バックトラッキング法で処理される解空間の種類は、主に以下の 2 種類に分けられます。

  • サブセット ツリー:与えられた問題が、集合から特定の特性を満たすサブセットを見つけることである場合、対応する解空間ツリーはサブセット ツリーと呼ばれます。

  • 順列ツリー:指定された問題が集合から特定の特性を満たすと判断された場合対応する解空間ツリーは順列ツリーと呼ばれます。

1.3 バックトラッキング方式と DFS の違い

DFS は、検索グラフやツリーなどのデータ構造を走査するためのアルゴリズムであり、ツールに近いものです。

バックトラッキング法は、最適な解が見つかるまで、問題を解決するためにいくつかの解を生成し、放棄し続けることです (問題を探索する過程で解空間を動的に生成することは、バックトラッキング法の重要な特徴です)。指針となるアイデアとして、DFS はソリューション空間での包括的な検索を実行するために使用されます。

1.4 剪定

枝刈りとは、探索過程において全く考慮する必要のない(最適解が得られないと判断された)探索経路をフィルタ条件を用いて切り出し、不要な探索を回避し、アルゴリズムを最適化することです。解決の速度を向上させ、結果の正確性を保証することも必要です。

バックトラッキング アルゴリズムを適用すると、現在のパスが結果セットを生成できるかどうかを事前に判断でき、生成できない場合は事前にバックトラッキングできます。これは、実現可能性枝刈りとも呼ばれます。

さらに、別の種類の最適枝刈りもあり、現在の最適値を毎回記録し、現在のノードが現在の最適解よりも優れた解を生成できない場合は、事前にバックトラックできます。

ただし、枝刈りのフィルタ条件を見つけるのは難しいため、枝刈りの最適化によってアルゴリズムの効率を向上させたい場合は、結果の正確性と枝刈りの精度を確保する必要があります。

2.01 ナップサック問題: サブセットツリー

2.1 問題の導入

01 ナップザック問題は、サブセット ツリーによって解決される古典的な問題です。質問は次のとおりです。

シャオミンはクラスメートを訪問する予定で、プレゼントとしてチョコレートの入ったバックパックを持っていく予定です。彼は、詰められたチョコレートの合計価値が最高になることを望んでいます (この方が良いかもしれません)。ただし、シャオミンの体力には限界があるため、チョコレートの袋は重すぎてはならず、わずか8kgです。取り扱いチョコレートは以下の通りです。

シリアルナンバー ブランド 体重/kg 価値
1 フェレーロ 4 45
2 良い時間 5 57
3 2 22
4 クーディ (スペイン) 1 11
5 自作 6 67

2.2 解決策

ここに画像の説明を挿入

部分集合を見つけることを考慮しているため、各項目には選択と非選択の 2 つの状態しかなく、そのため解空間は二分木になります。このツリーでは、各レベルのエッジは項目が選択されているかどうかを表します。上の図に示すように、最初のレイヤーのポイント 0 と左側のポイント 1 の間のエッジを選択します。これは項目 1 を選択することを意味します。つまり、下に進む左側のサブツリーを選択します。項目 1 を選択しない場合は、入力します。バッグを右のサブツリーに入り、右側のポイント 1 を選択します。次に、合計 n 個のアイテムがある場合、n 層のエッジと n+1 層の点が存在します。最後の層の各リーフ ノードは選択方法を表しており、合計 2 n 個のリーフ ノード、つまり解空間には2 n個の解があり、これらのリーフ ノードの中から最適なノードを選択する必要があります。

まず、バックトラッキング手法を使用してサブツリー セットを検索するための疑似コード フレームワークを提供します。

void search(层数)
{
    
    
	if(搜索到最底层)
		打印出结果解;
	else 
		for(遍历当前层解)
		{
    
    
			if(合适解)
				继续搜索;
			撤消当前状态的影响; //回溯
		}
}

バックトラッキング方式では「暴力性」に注目します。暴力の観点から考えると、バックパックを満たすすべてのコロケーションをできるだけ見つけたい場合は、各方法 (各解決策) の最大値をマークして、最適な解決策を見つける必要があります。最初の種類のチョコレートから始めて、次の種類のチョコレートを見つけ、ロードできるかどうかを判断し、再帰して境界に到達し、比較し、より良い解決策を記録し、後戻りし、下を見続ける...ループを繰り返します。サブセット ツリーの観点からは、左側のサブツリー、つまりパッケージに移動することを好みます。リーフ ノードに移動するか、制約の重み条件を満たさない場合は、親ノードに戻ります。 、右のノードに入り、最後にツリー全体を走査します。

ロードできるかどうかを判断した後、book 配列を使用してパックするかどうかをマークできます。

2.3 アルゴリズムの実装

上記の考え方に基づいて 01 ナップザック問題を記述するアルゴリズムは次のとおりです。

//01背包问题-回溯法-子集树 
#include <iostream>

int n, bag_v, bag_w;
int bag[100], x[100], w[100], val[100];

//search递归函数,当前节点背包的价值为cur_v(current value),重量为cur_w(current weight)
void search(int cur, int cur_v, int cur_w)
{
    
    
    if(cur > n) //判断子集树的边界   
    {
    
    
        if(cur_v > bag_v) //子集树对应的背包价值 是否超过了 最大价值
        {
    
    
            bag_v = cur_v; //得到最大价值
            for(int i = 1; i <= n; i++)      
                bag[i] = x[i]; //x表示当前子集树各物品是否被选中,将选中的物品存入bag中 
        }
    }
    else 
        for(int j = 0; j <= 1; j++) //遍历当前解层:j 代表是否选择该物品
        {
    
    
            x[cur] = j;      
            if(cur_w + x[cur]*w[cur] <= bag_w) //满足重量约束,继续向前寻找配对 
            {
    
    
                cur_w += w[cur]*x[cur];
                cur_v += val[cur]*x[cur];
                search(cur + 1, cur_v, cur_w); //递归,下一层物品 
                //清除痕迹,回溯上一层 
                cur_w -= w[cur]*x[cur];   
                cur_v -= val[cur]*x[cur];
                x[cur] = 0;
            }
        }
}

int main()
{
    
    
    int i;
    bag_v = 0; //初始化背包最大价值
    
    //输入数据 
    std::cout << "请输入背包最大容量:" << std::endl;
    std::cin >> bag_w;
    std::cout << "请输入物品个数:" << std::endl;
    std::cin >> n;
    std::cout << "请依次输入物品的重量:" << std::endl;
    for(i = 1; i <= n; i++) 
        std::cin >> w[i];
    std::cout << "请依次输入物品的价值:" << std::endl;
    for(i = 1; i <= n; i++) 
        std::cin >> val[i]; 
    search(1, 0, 0);
    
    std::cout << "最大价值为:" << std::endl;
    std::cout << bag_v << std::endl;
    std::cout << "物品的编号依次为:" << std::endl;

    for(i = 1; i <= n; i++)
        if(bag[i] == 1) 
            std::cout << i << " ";
    std::cout << std::endl;
    
    return 0;
}

出力は次のとおりです。

PS E:\Code\VSCode\Learning\build> .\main.exe
请输入背包最大容量:
8
请输入物品个数:
5
请依次输入物品的重量:
4 5 2 1 6
请依次输入物品的价值:
45 57 22 11 67
最大价值为:
90
物品的编号依次为:
2 3 4

2.4 最適化の方法

上限関数bound(): 現在値 + 残りの容量で対応できる最大値を使用して、バックパックの現在の最大値 (つまり、最適解) と比較できます。 ) が小さい場合、検索は続行されません。つまり、左側のサブツリーが切り取られ、現在の項目を選択せず​​に、右側のサブツリーに入ります。

1 つの項目に対して選択するか選択しないかの決定は 2 つだけであり、合計 n 個の項目があるため、時間計算量は O(2 n ) です。再帰的スタックは最大 n 層に達することができ、すべての項目の情報を格納するには一定の 1 次元配列のみが必要であるため、最終的な空間複雑さは O(n) になります。

では、この「残容量が保持できる最大値」はどのように計算するのでしょうか?まず、単位重量の値が大きいものから小さいものまでアイテムを分類し、各アイテムを順番に検討します。コードは以下のように表示されます:

if(cur_w+w[cur]<=bag_w) //将物品cur放入背包,搜索左子树,即选择当前物品 
{
    
    
	cur_w+=w[cur]; //同步更新当前背包的重量
	cur_v+=val[cur]; //同步更新当前背包的总价值
	put[cur]=1;
	search(cur+1,cur_v,cur_w); //深度搜索进入下一层
	cur_w-=w[cur]; //回溯复原
	cur_v-=val[cur]; //回溯复原
}
if(bound(cur+1,cur_v,cur_w)>bag_v) //如若符合条件则搜索右子树,即不选择当前物品 
{
    
    
	put[cur]=0;
	search(cur+1,cur_v,cur_w);
}
  • i<=n で重みが制限を超える場合、leftw は負になり、この時点で最後に入力された項目の値は比較的高いが、完全に詰め込むことはできないため、得られる値は達成不可能な理想的な最大値になります。ランドセルなので、余分な部分を削除し、オブジェクトの一部だけをカバンに入れます。もちろん、これはできません。したがって、計算された値は達成不可能な理想値です。

  • i>n の場合、重みは制限を超えず、それは達成可能な最大値です。

これは、この上限関数の最適化を説明します。これは、現在のノードがより良いソリューションを生成する可能性があるかどうかを判断するための最適な枝刈りの最適化であることがわかります。

最適化されたアルゴリズムは次のとおりです。

#include <iostream>

int n, bag_v, bag_w;
int bag[100], put[100], w[100], val[100], order[100];
double perp[100]; 

//按照单位重量价值排序,这里用冒泡 
void bubblesort()
{
    
    
    int i,j;
    int temporder = 0;
    double temp = 0.0;
 
    for(i = 1;i <= n; i++)
        perp[i] = val[i] / w[i]; //计算单位价值(单位重量的物品价值)
    for(i = 1; i <= n - 1; i++)
    {
    
    
        for(j = i + 1; j <= n; j++)
            if(perp[i] < perp[j]) //冒泡排序perp[], order[], sortv[], sortw[]
        {
    
    
            temp = perp[i];  //冒泡对perp[]排序交换 
            perp[i] = perp[i];
            perp[j] = temp;
 
            temporder = order[i]; //冒泡对order[]交换 
            order[i] = order[j];
            order[j] = temporder;
 
            temp = val[i]; //冒泡对val[]交换 
            val[i] = val[j];
            val[j] = temp;
 
            temp = w[i]; //冒泡对w[]交换 
            w[i] = w[j];
            w[j] = temp;
        }
    }
}

//计算上界函数,功能为剪枝
double bound(int i, int cur_v, int cur_w)
{
    
       //判断当前背包的总价值cur_v + 剩余容量可容纳的最大价值 <= 当前最优价值
    double leftw = bag_w - cur_w; //剩余背包容量
    double b = cur_v; //记录当前背包的总价值cur_v,最后求上界
    //以物品单位重量价值递减次序装入物品
    while(i <= n && w[i] <= leftw)
    {
    
    
        leftw -= w[i];
        b += val[i];
        i++;
    }
    //装满背包
    if(i <= n)
        b += val[i] / w[i] * leftw;
    return b; //返回计算出的上界
}

void search(int cur, int cur_v, int cur_w)
{
    
       //search递归函数,当前current节点的价值为current value,重量为current weight 
    if(cur > n) //判断边界   
    {
    
    
        if(cur_v > bag_v) //是否超过了最大价值
        {
    
    
            bag_v = cur_v; //得到最大价值
            for(int i = 1; i <= n; i++)      
                bag[order[i]] = put[i]; //put表示当前是否被选中,将选中的物品存入bag中 
        }
    }
    //如若左子节点可行,则直接搜索左子树
    //对于右子树,先计算上界函数,以判断是否将其减去
    if(cur_w + w[cur] <= bag_w) //将物品cur放入背包,搜索左子树,即选择当前物品 
    {
    
    
        cur_w += w[cur]; //同步更新当前背包的重量
        cur_v += val[cur]; //同步更新当前背包的总价值
        put[cur] = 1;
        search(cur + 1, cur_v, cur_w); //深度搜索进入下一层
        cur_w -= w[cur]; //回溯复原
        cur_v -= val[cur]; //回溯复原
    }
    if(bound(cur + 1, cur_v, cur_w) > bag_v) //如若符合条件则搜索右子树,即不选择当前物品 
    {
    
    
        put[cur] = 0;
        search(cur + 1, cur_v, cur_w);
    }
}

int main()
{
    
    
    int i;
    bag_v = 0; //初始化背包最大价值
    //输入数据 
    std::cout << "请输入背包最大容量:" << std::endl;;
    std::cin >> bag_w;
    std::cout << "请输入物品个数:" << std::endl;
    std::cin >> n;
    std::cout << "请依次输入物品的重量:" << std::endl;
    for(i = 1; i <= n; i++) 
        std::cin >> w[i];
    std::cout << "请依次输入物品的价值:" << std::endl;
    for(i = 1; i <= n; i++) 
        std::cin >> val[i];
    for(i = 1; i <= n; i++) //新增的order数组,存储初始编号 
        order[i] = i;
    search(1, 0, 0);
    
    std::cout << "最大价值为:" << std::endl;
    std::cout << bag_v << std::endl;
    std::cout << "物品的编号依次为:" << std::endl;

    for(i = 1; i <= n; i++)
        if(bag[i] == 1) 
            std::cout << i << " ";
    std::cout << std::endl;
    
    return 0;
}

3. 巡回セールスマン問題 TSP: ソート ツリー

3.1 問題の導入

シャオミンはクラスメートに行く前にそれについて考え、さまざまな大学の高校のクラスメートを訪問する計画を立てました。彼は自分の学校からスタートし、高校の同級生がいるいくつかの大学を経て、最後に自分の学校に戻るつもりだった。シャオミンは怠け者で最短の道だけを選びたいと思っていますが、同時に学校とはメインターゲットではないので二度目の対戦はしたくありません。旅行計画を立てるにはどうすればよいですか?

一見すると、このトピックは最短経路問題に似ていますか? しかし、残念なことに、最短経路はすべてのポイントを通過する必要はなく、それでも異なります。

3.2 解決策

順列ツリーとサブセット ツリーの最大の違いは、サブセット ツリーの解は順序付けされていないサブセットであるのに対し、順列ツリーの解には集合全体のすべての要素が含まれていることです。要素がすべて揃っています

ここに画像の説明を挿入

{ } の外側の数字はソート済みであることを示し、{ } 内の数字はまだソートされていないことを示します。

並べ替えツリーでは、各層が数値を選択し、それをキューの最後に置きます。したがって、n 個の要素のコレクションの場合、ツリーの最初の層には n 個の子ノードがあり、最初の層で n 個の数値を選択できることを示します。キューの最初の位置、フォークは前のフォークに比べて 1 つ減ります (位置の要素が決定されているため)、ツリーは合計 n+1になります(図では最後の層は省略しています) 、n 回の選択を示します。リーフ ノードの合計は **n!* * で、組み合わせの数を表します A、すべての順列に n! 個のケースがあります (したがって、時間計算量も n!)。

この問題では、解決空間はすべての都市の完全な配置、つまり各都市を歩く順序であるため、この問題を検討するためにソート ツリーを使用できます。

3.3 アルゴリズムの枠組み

void backtrack(int t)
{
    
    
    if(t > n)
        output(x);
    else
    {
    
    
        for(int i = t; i <= n; i++)
        {
    
    
            swap(x[t], x[i]);
            if(constraint(t) && bound(t))
                backtrack(t+1);
            swap(x[i],x[t]);
        }
    }
}

ここでの交換は交換機能であり、取り決めの場合、任意の 2 つの番号を交換すれば、新しい取り決めになります。constraint() とbound()) は、それぞれ制約関数制限関数(枝刈り最適化用) です。

データを新しい配列に入れたり他の操作をしたりするのではなく、交換にスワップを使用するのはなぜでしょうか? これは、最初にデータを格納していた配列 x を交換するときに、ソートされた要素を配列の先頭に置き、データをソートしないままにするためです。このようにして、for ループを実行するときに t から開始することができ、同時にソートされた数値に繰り返し遭遇することを回避し、書籍レコードなどの冗長なコードを必要としません。

3.4 アルゴリズムの実装

//旅行商问题-回溯法-排序树 
#include <iostream>
 
int n, t;
int dis[100][100], x[100], bestroad[100]; 
int cur_dis, bestdis;
const int INF=99999;

void swap(int& a, int& b)  //swap函数,交换 
{
    
    
	int temp;
	temp = a;
	a = b;
	b = temp;
}
 
void backtrack(int t)   
{
    
    
	if (t == n)
	{
    
     	//判断边界。很长的判断,不能到自己或到不了,要比当前最优解短 
		if (dis[x[n - 1]][x[n]] != 0 && dis[x[n]][1] != 0 &&(cur_dis + dis[x[n - 1]][x[n]] + dis[x[n]][1] < bestdis || bestdis == 0)) 
		{
    
      	//记录最优路径,最优距离 
			for (int j = 1; j <= n; j++)
				bestroad[j] = x[j];
			bestdis = cur_dis + dis[x[n-1]][x[n]] + dis[x[n]][1];
			return;
		}
	}
	else
	{
    
    
		for (int j=t;j<= n; j++)
		{
    
    
			if(dis[x[t]][x[j]]!=0&& (cur_dis + dis[x[t - 1]][x[t]] + dis[x[t]][1] < bestdis || bestdis == 0))
			{
    
    
				swap(x[t], x[j]);
				cur_dis += dis[x[t]][x[t-1]];
				backtrack(t+1);
				//回溯 
				cur_dis -= dis[x[t]][x[t-1]];
				swap(x[t], x[j]);
			}
		}
	}
 }
 
int main()
{
    
    
	int i, j, m, a, b, c;

	std::cout << "输入城市数:" << std::endl;
	std::cin >> n; 
	std::cout << "输入路径数:" << std::endl; 
	std::cin >> m;
	//初始化邻接矩阵
	for(i = 1; i <= n; i++)
		for(j = 1; j <= n; j++)
			dis[i][j] = 0;  
	std::cout << "输入路径与距离:" << std::endl;

	//读入城市之间的距离
	for(i = 1; i <= m; i++)
	{
    
     
		std::cin >> a >> b >> c;
		dis[a][b] = dis[b][a] = c; //无向图,两边都记录 
	}
	for(i = 1; i <= n; i++)
		x[i] = i;
		
	backtrack(2);      
	std::cout << "最佳路径为:";
	for (i = 1; i <= n; i++)
			std::cout << bestroad[i] << " --> ";
	std::cout << "1" << std::endl;
	std::cout << "最短距离为:" << bestdis;

	return 0;
 }
 

出力は次のとおりです。

PS E:\Code\VSCode\Learning\build> ."E:/Code/VSCode/Learning/build/main.exe"
输入城市数:
4
输入路径数:
6
输入路径与距离:
1 2 30
1 3 6
1 4 4
2 3 5
2 4 10
3 4 20
最佳路径为:1 --> 4 --> 2 --> 3 --> 1
最短距离为:25

知らせ:

  • 最短パスとは異なり、ここではスワップの必要がないため、**INF (つまり、パス接続なし) と 0 (つまり、それ自体) ** を一緒に扱います。

  • テーブルの下の配列が範囲外になるのを防ぐために、 t>=n の代わりに t==n を使用します

4. まとめ

  • 非常に暴力的な検索方法であるバックトラッキング方法の時間計算量は非常に高く、サブセット ツリーは約 2 n、ソート ツリーは約 n! , そのため、大きな問題に対処するのはあまり強力ではありません。しかし、その代わりに、真の最適なソリューションを提供することができます。

  • バックトラッキング法のサブセット ツリーとソート ツリーは、最適なサブセットを見つけることと最適なソートを見つけるという 2 つのタイプの問題に対処できます。

  • 枝刈り関数を使用して最適化することは非常に困難です。

参考記事:Program Ape Voice:【アルゴリズム学習】バックトラッキング手法について話しましょう

おすすめ

転載: blog.csdn.net/crossoverpptx/article/details/131419565