C++高級數據結構算法 | 回溯算法(解決整數選擇、01揹包、整數求和、八皇后問題)


回溯法有“通用的解題法”之稱。用它可以系統地搜索一個問題的所有解或任一解。回溯法是一個既帶有系統性又帶有跳躍性的搜索算法。它在問題的解空間樹中,按深度優先策略,從根結點出發搜索解空間樹。算法搜索至解空間樹的任一結點時,先判斷該結點是否包含問題的解。如果肯定不包含,則跳過對以該結點爲根的子樹的搜索,逐層向其祖先結點回溯。否則,進入該子樹,繼續按深度優先策略搜索。回溯法求問題的所有解時,要回溯到根,且根結點的所有子樹都已被搜索遍才結束。回溯法求問題的一個解時,只要搜索到問題的一個解就可結束。這種以深度優先方式系統搜索問題解的算法稱爲回溯法,它適用於解組合數較大的問題。


回溯法的算法解析

問題的解空間

用回溯法解問題時,應明確定義問題的解空間。問題的解空間至少應包含問題的一個(最優)解。例如,對於有nn種可選擇物品的揹包問題,其解空間由長度爲 nn010-1向量組成。該解空間包含對變量的所有可能的賦值。當n=3n=3時,其解空間是(0,0,0),(0,1,0),(0,0,1),(1,0,0),(0,1,1),(1,0,1),(1,1,0),(1,1,1){(0,0,0),(0,1,0),(0,0,1),(1,0,0),(0,1,1),(1,0,1),(1,1,0),(1,1,1)}

定義了問題的解空間後,還應將解空間很好地組織起來,使得能用回溯法方便地搜索整個解空間。通常將解空間組織成樹或圖的形式。

例如,對於n=3n=3時的揹包問題,可用一棵完全二叉樹表示其解空間,如下圖所示。

解空間樹的第 ii 層到第 i+1i + 1 層邊上的標號給出了變量的值。從樹根到葉的任一路徑表示解空間中的一個元素。例如,從根結點到結點HH的路徑相應於解空間中的元素1,1,1(1,1,1)


回溯法的基本思想

確定瞭解空間的組織結構後,回溯法從開始結點(根結點)出發,以深度優先方式搜索整個解空間。這個開始結點成爲活結點,同時也成爲當前的擴展結點。在當前的擴展結點處,搜索向縱深方向移至一個新結點。這個新結點就成爲新的活結點,併成爲當前擴展結點。如果在當前的擴展結點處不能再向縱深方向移動,則當前擴展結點就成爲死結點。此時,應往回移動(回溯)至最近的一個活結點處,並使這個活結點成爲當前的擴展結點回溯法以這種工作方式遞歸地在解空間中搜索,直至找到所要求的解或解空間中已無活結點時爲止

回溯法搜索解空間樹時,通常採用兩種策略避免無效搜索,提高回溯法的搜索效率。其一是用約束函數在擴展結點處剪去不滿足約束的子樹;其二是用限界函數剪去得不到最優解的子樹。這兩類函數統稱爲剪枝函數

例如,解0-1揹包問題的回溯法用剪枝函數剪去導致不可行解的子樹。在解旅行商問題的回溯法中,如果從根結點到當前擴展結點處的部分周遊路線的費用已超過當前找到的最好的周遊路線費用,則可以斷定以該結點爲根的子樹中不含最優解,因此可將該子樹剪去。

綜上所述,用回溯法解題通常包含以下3個步驟:

  • 針對所給問題,定義問題的解空間
  • 確定易於搜索的解空間結構
  • 以深度優先方式搜索解空間,並在搜索過程中用剪枝函數避免無效搜索

用回溯法解題的一個顯著特徵是在搜索過程中動態產生問題的解空間在任何時刻,算法只保存從根結點到當前擴展結點的路徑。如果解空間樹中從根結點到葉結點的最長路徑的長度爲 h(n)h(n),則回溯法所需的計算空間通常爲O(h(n))O(h(n))。而顯式地存儲整個解空間則需要 O(2h(n))O(2^{h(n)})O(!h(n))O(!h(n)) 內存空間。


遞歸回溯

回溯法對解空間作深度優先搜索,因此在一般情況下可用遞歸函數來實現回溯法如下(僞代碼描述):

/* 形參t表示遞歸深度,即當前擴展結點在解空間樹的深度 */
void Backtrack(int t)
{
	/* 葉子節點,輸出結果,x是可行解 */
	if(t > n) Output(x);
	else
		/* 當前節點的所有子節點 */
		for i = 1 to k 
		{
			/* 每個子節點的值賦值給x */
			x[t] = value[i]; 
			/* 滿足約束條件和限界條件 */ 
			if(Constrain(t) && Bound(t))
				Backtrace(t + 1); /* 遞歸下一層 */
		}
			
}

迭代回溯

採用樹的非遞歸深度優先遍歷算法,也可將回溯法表示成爲一個非遞歸的迭代過程如下(僞代碼描述):

void Backtrace()
{
	int t = 1;
	while(t > 0)
	{
		/* 當前節點的存在子節點 */
		if (ExistSubNode(t))
		{
			/* 遍歷當前節點的所有子節點 */
			for i = 0 to k
			{
				/* 每個子節點的值賦值給x */
				x[t] = value[i];
				/* 滿足約束條件和限界條件 */
				if(Constrain(t) && Bound(t)) // 
				{
				 	/* solution表示在當前擴展結點處是否已得到問題的可行解 */
					if(Solution(t))
						Output(t);
				}
				else
					t++; /* 沒有得到解,繼續向下搜索 */
			}	
		}
		else
			t--; /* 不存在子節點,返回上一層 */
	}
}

子集樹與排列樹

當所給的問題是從 nn 個元素的集合 SS 中找出滿足某種性質的子集時,相應的解空間樹稱爲子集樹。例如 nn 個物品的 0101 揹包問題所相應的解空間樹就是一棵子集樹。這類子集樹通常有 2n2^n 個葉結點,其結點總個數爲2n12^n - 1。遍歷子集樹的任何算法均需 Ω(2n)Ω(2^n) 的計算時間。

用回溯法搜索子集樹的一般算法可描述如下:

void Backtrace(int t)
{
	if(t > n) Output(x);
	else
		for(int i = 0; i <= 1; i++)
			x[t] = i;
			if(Constrain(t) && Bound(t))
				Backtrace(t + 1); 
}

當所給的問題是確定 nn 個元素滿足某種性質的排列時,相應的解空間樹稱爲排列樹。排列樹通常有 !n!n 個葉結點。因此遍歷排列樹需要 Ω(!n)Ω(!n) 的計算時間。旅行商問題的解空間樹就是一棵排列樹,如下圖所示:

用回溯法搜索排列樹的一般算法可描述如下:

void Backtrace(int t)
{
	if(t > n) Output(x);
	else
		for(int i = t; i <= n; ++i)
		{
			Swap(x[t], x[i]);
			if(Constrain(t) && Bound(t))
				Backtrace(t + 1); 
			Swap(x[t], x[i]);
		}
}

經典問題分析

子集樹算法框架

/* 子集樹 */

#include<iostream>
using namespace std;

void func(int arr[], int i, int length, int brr[])
{
	if (i == length)
	{
		for (int j = 0; j < length; j++)
		{
			if (brr[j] == 1)
			{
				cout << arr[j] << " ";
			}
		}
		cout << endl;
	}
	else
	{
		/* 左子樹:元素標誌爲1 */
		brr[i] = 1;
		func(arr, i + 1, length, brr);
		
		/* 右子樹:元素標誌爲0 */
		brr[i] = 0;
		func(arr, i + 1, length, brr);

	}
}

int main()
{
	int arr[4] = { 1,2,3,4 };
	int brr[4] = { 0 };
	func(arr, 0, 4, brr);
}

整數選擇問題

問題描述:

一組整數序列,選擇其中的一部分整數,讓選擇的整數和序列中剩下的整數的和的差值最小。

#include<iostream>
using namespace std;

#define N 10

int arr[N] = { 12, 3, 45, 6, 78, 9, 43, 21, 62, 31 };

// 輔助數組
int brr[N] = { 0 };

// 存儲標誌位,標誌最終的結果集
int res[N] = { 0 };

// 序列中剩餘數字的和
int arrSum = 0;

// 當前選擇序列的和
//int sum = 0;

// 存儲當前的最小差值
unsigned int min = 0xFFFFFFFF;

void func(int i)
{
	if (i == N)
	{
		int sum = 0;
		for (int j = 0; j < N; ++j)
		{
			if (brr[j] == 1)
			{
				// 求當前選擇的序列的和
				sum += arr[j];
			}
		}

		//int diff = abs(sum - (arrSum - sum));
		int diff = abs(sum - arrSum);
		
		/* 當前的差值比記錄的最小差值還要小,進行更新 */
		if (diff < min)
		{
			min = diff;
			for (int k = 0; k < N; k++)
			{
				res[k] = brr[k];
			}
		}
	}
	else
	{
		/* 左子樹中剩餘的元素和,arrSum減去選擇的元素 */
		arrSum -= arr[i];
		brr[i] = 1;
		func(i + 1);
		
		/* 右子樹中的元素不被選擇,arrSum加上該元素 */
		arrSum += arr[i];

		brr[i] = 0;
		func(i + 1);
	}
}

int main()
{
	for (int i = 0; i < N; i++)
	{
		arrSum += arr[i];
	}

	func(0);

	for (int i = 0; i < N; i++)
	{
		if (res[i] == 1)
		{
			cout << arr[i] << " ";

		}
	}
	cout << endl;
	cout << "min:" << min << endl;

	return 0;
}

題目變形:

一組2n個整數序列,選擇其中n個整數,和序列中剩下的n個整數的和的差值最小

#include<iostream>
using namespace std;

#define N 14
int arr[N] = { 12, 3, 45, 6, 78, 9, 43, 21, 62, 31,23,34,12,56 };

// 輔助數組
int brr[N] = { 0 };

// 存儲標誌的結果集
int res[N] = { 0 };

// 數組中剩餘序列的和
int arrSum = 0;

// 已選擇序列的和
int sum = 0;

// 當前已經選擇的數字個數
int curNum = 0;

// 存儲當前的最小差值
unsigned int min = 0xFFFFFFFF;

void func(int i)
{
	if (i == N)
	{
		/**
		 * 當前選擇的數字個樹不是n,無需判斷,此序列已經不滿足
		 * 題意,直接返回即可。
		 */
		if (curNum != N / 2)
		{
			return;
		}

		unsigned int diff = abs(sum - arrSum);

		if (diff < min)
		{
			min = diff;
			for (int k = 0; k < N; k++)
			{
				res[k] = brr[k];
			}
		}
	}
	else
	{
		// 剪枝操作
		if (curNum < N / 2)
		{
			curNum++;
			sum += arr[i];
			arrSum -= arr[i];
			brr[i] = 1;
			func(i + 1);

			curNum--;
			sum -= arr[i];
			arrSum += arr[i];
			brr[i] = 0;
			func(i + 1);
		}
	}
}

int main()
{
	for (int i = 0; i < N; i++)
	{
		arrSum += arr[i];
	}

	func(0);

	for (int i = 0; i < N; i++)
	{
		if (res[i] == 1)
		{
			cout << arr[i] << " ";

		}
	}
	cout << endl;
	cout << "min:" << min << endl;

	return 0;
}

0-1揹包問題

問題描述:

假設有n個物品,它們的重量分別是W1, W2, W3… Wn,它們的價值分別是V1, V2, V3… Vn,有一個揹包,其容量限制是C,那麼怎麼樣裝入物品,能使揹包的價值最大化。

#include<iostream>
using namespace std;

// 物品的價值
int v[5] = { 12, 4, 60, 8, 13 };

// 物品的重量
int w[5] = { 8, 6, 9, 4, 7 };

// 揹包容量
int c = 25;

// 輔助數組
int brr[5] = { 0 };

// 標誌結果集
int res[5] = { 0 };

// 當前選擇的價值總和
int choiceV = 0;

// 當前選擇的重量總和
int choiceW = 0;

// 當前最優價值
int bestv = INT_MIN;

// 剩餘物品的價值總和
int arrSum = 0;

int lenV = sizeof(v) / sizeof(v[0]);
int lenW = sizeof(w) / sizeof(w[0]);

void func(int i)
{
	if (i == lenV)
	{
		cout << "choice v = " << choiceV << endl;
		if (choiceV > bestv)
		{
			bestv = choiceV;
			for (int j = 0; j < lenV; j++)
			{
				res[j] = brr[j];
			}
		}
	}
	else
	{
		arrSum -= v[i];
		// 判斷左孩子結點是不是可行結點
		if (choiceW + w[i] <= c)
		{
			choiceW += w[i];
			choiceV += v[i];
			brr[i] = 1;
			func(i + 1);
			choiceW -= w[i];
			choiceV -= v[i];
		}

		/**
		 * 剪枝操作:當choiceV + arrSum <= bestv時,
		 * 需要繼續搜索進行"增加",否則進行剪枝
		 */
		if (choiceV + arrSum > bestv)
		{
			brr[i] = 0;
			func(i + 1);
		}
		arrSum += v[i];
	}
}

int main()
{
	for (size_t i = 0; i < lenV; i++)
	{
		arrSum += v[i];
	}

	func(0);
	cout << bestv << endl;
	int len = sizeof(res) / sizeof(res[0]);
	for (int i = 0; i < len; i++)
	{
		if (res[i] == 1)
		{
			cout << v[i] << " ";
		}

	}
	cout << endl;

	return 0;
}

整數求和問題

題目描述:

從一組整數數組中選擇n個元素,讓其和等於指定的值

#include<iostream>
using namespace std;

int arr[9] = { 12,45,8,91,36,79,83,52,31 };

// 輔助數組
int brr[4] = { 0 };
int res[4] = { 0 };

int length = sizeof(arr) / sizeof(arr[0]);

// 數組中剩餘的元素和
int arrSum = 0;

// 已選擇的數字的和
int choiceSum = 0;

// 指定的和
int sum = 136;

void func(int i)
{
	if (i == length)
	{
		if (choiceSum == sum)
		{
			for (int j = 0; j < length; j++)
			{
				if (brr[j] == 1)
				{
					cout << arr[j] << " ";
				}
			}
			cout << endl;
		}		
	}
	else
	{
		arrSum -= arr[i];
		if (choiceSum + arr[i] <= sum)
		{
			choiceSum += arr[i];
			brr[i] = 1;
			func(i + 1);
			choiceSum -= arr[i];
		}
		
		// 剪枝函數 - 已選擇的與剩餘的之和小於sum,繼續"增加",否則進行剪枝
		if (choiceSum + arrSum >= sum)
		{
			brr[i] = 0;
			func(i + 1);
		}
		arrSum += arr[i];
	}
}


int main()
{
	for (int i = 0; i < length; i++)
	{
		arrSum += arr[i];
	}

	func(0);
}

排列樹算法框架

/* 排列樹 */
void swap(int* arr, int i, int j)
{
	int tmp = arr[i];
	arr[i] = arr[j];
	arr[j] = tmp;
}

void backtrace(int* arr, int i, int length)
{
	if (i == length)
	{
		for (int j = 0; j < length; j++)
		{
			cout << arr[j] << " ";
		}
		cout << endl;
	}
	else
	{
		for (int j = i; j < length; j++)
		{
			swap(arr, i, j);
			backtrace(arr, i + 1, length);
			swap(arr, i, j);
		}
	}
}

int main()
{
	int arr[3] = { 1, 2, 3 };
	int length = sizeof(arr) / sizeof(arr[0]);
	backtrace(arr, 0, length);
}

八皇后問題

問題描述:

按照國際象棋規則,皇后可以攻擊與之處在同一行,或者同一列,或者同一斜線上的其它棋子。現在有n個皇后放置在nxn格的棋盤上,如何擺放n個皇后而使它們都不能互相吃子?有多少種擺法?

/* 八皇后問題 */

/* 記錄排列總數 */
int countNum = 0;

void swap(int* arr, int i, int j)
{
	int tmp = arr[i];
	arr[i] = arr[j];
	arr[j] = tmp;
}

/**
 * 判斷是否滿足限制條件
 * 我們用i表示行數、arr[i]表示相應的列數
 * 檢查從第0行開始到第i行,不出現同行(i==j),同列(arr[i] == arr[j])
 * 同斜線的棋子
 */
bool isLine(int* arr, int i)
{
	for (int j = 0; j < i; j++)
	{
		if (abs(arr[i] - arr[j]) == abs(i - j))
		{
			return false;
		}
	}
	return true;
}


void backtrace(int* arr, int i, int length)
{
	if (i == length)
	{
		countNum++;
		for (int j = 0; j < length; j++)
		{
			cout << arr[j] << " ";
		}
		cout << endl;
	}
	else
	{
		for (int j = i; j < length; j++)
		{
			swap(arr, i, j);
			if (isLine(arr, i))
			{
				backtrace(arr, i + 1, length);
			}
			
			swap(arr, i, j);
		}
	}
}

int main()
{
	int arr[8] = { 1, 2, 3, 4, 5 ,6, 7, 8 };
	int length = sizeof(arr) / sizeof(arr[0]);
	backtrace(arr, 0, length);
	cout << "countNum = " << countNum;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章