天天看點

個人項目-數獨

項目位址

https://github.com/si1entic/sudoku

需求分析

  1. 生成終局

    格式:sudoku.exe -c n

    1)不重複

    2)1<=n<=1000000,能處理非法參數

    3)左上角數字固定為(4+4)% 9 + 1 = 9

    4)n在1000以内時,要求程式在 60 s 内給出結果

    5)輸出生成的數獨終盤至sudoku.txt

  2. 求解數獨

    格式:sudoku.exe -s path

    1)從指定檔案讀取,每讀81個數字視作一道數獨題,忽略其他字元

    2)要求檔案内的數獨題目是合法的

    3)檔案内數獨個數在1000以内時,要求程式在 60 s 内給出結果

    4)輸出已讀入數獨的答案至sudoku.txt。若存在未滿81個的數字,在已解出的答案後輸出“存在錯誤格式!”

解題思路

1.數獨生成算法

  首先想到的是暴力解決,第一行進行1~9的全排列,之後的行依次循壞填充數字再驗證,不滿足就回退。但這樣有個明顯的問題——效率極低。于是求助度娘,找到了一些算法,但要麼效率不夠,要麼很難滿足首數字固定的要求。最終,在《程式設計之美》P95裡找到了比較适合的算法,加以改進就可以解決該題目了。

  下面簡單地一下該算法思路:首先将9x9的表格視作3x3的九宮格,随機生成正中的九宮格(稱其為種子),再通過行列變化填滿其他九宮格,得到了一個合法的數獨終盤。下面來算算通過對這個終盤進行行列變換能得到多少個不同的終盤。

  不難發現,在同一九宮格内進行行列變換(藍框列或粉框行互換),所得新數獨仍是合法的。再考慮到固定左上角數字的要求,我們不動第一行第一列,于是有2!×3!×3!×2!×3!×3!=5184種,這就是一個種子變換出的終盤個數。而種子有8!=40320個,顯然不同種子經行列變換得到的終盤是不重複的,故該算法可生成5184×40320=209018880個不重複終盤,滿足1000000的要求。

注:該算法由本人與室友 @李金奇 結合書中内容讨論所得,但可承諾代碼實作均是各自獨立完成。

2.求解數獨算法

  查到求解數獨一般采取高效率的DLX算法,于是去這個部落格了解了一下。算法原理比較容易了解,Dancing Links實際上是一種資料結構(交叉十字循環雙向鍊)。由于解決精确覆寫問題的X算法中需要頻繁用到移除、恢複操作,而在這種結構下,進行這兩種操作的效率極高。

  接下來就該考慮如何将數獨求解問題轉換為01精确覆寫問題了,這篇部落格對我幫助很大。

行為狀态,列為條件
  • 9x9x9行(狀态)

    X行:表示數獨i行j列填入數字k(根據X=81*i+9*j+k-1求出)

  • 1+9x9x4列 (條件)

    第0列為Head元素所占

    Y列:

    當0<Y≤81,表示數獨i行j列是否已填入數字(根據Y=9*i+j+1求出)

    當81<Y≤81*2,表示數獨i行是否已填入數字k(根據Y=81+9*i+k求出)

    當81*2<Y≤81*3,表示數獨j列是否已填入數字k(根據Y=81*2+9*j+k求出)

    當81*3<Y≤81*4,表示數獨b塊是否已填入數字k(根據Y=81*3+9*b+k求出)

  轉化之後采用DLX算法就行了,關鍵步驟是深度優先周遊-移除-無法滿足則恢複,詳見下面的代碼說明。

注:該算法參考上述部落格較多,尤其是關于使用數組來建構交叉十字循環雙向鍊的部分,但代碼都是我自己實作的,從注釋也看得出來。

設計實作

輸入處理類:根據參數調用下列函數進行相應處理(包括參數合法性判斷)

終盤生成類:種子生成函數、交換組合函數、行列交換函數、轉換輸出函數

數獨求解類:讀入轉換函數、連結建構函數、結點插入函數、移除函數、恢複函數、深度優先周遊函數

輸入處理類中,根據讀取參數選擇一個執行,調用終盤生成類或數獨求解類完成相應功能,并負責輸出到檔案。

終盤生成類中,種子生成函數調用交換組合函數生成各種變換方式,再調用行列交換函數實作交換,最後通過轉換輸出函數傳回字元串結果。

數獨求解類中,先将字元串輸入轉化為二維整型數組,再建構交叉十字循環雙向鍊,接着在dfs遞歸中進行移除、恢複操作,直到擷取可行解。

DLX算法流程圖如下

個人項目-數獨

單元測試

TEST_METHOD(TestMethod1){
	FianlMaker fm;
	int a[9][9] = {
		{ 9,8,7,6,5,4,3,2,1 },
		{ 1,2,3,4,5,6,7,8,9 }};
	fm.rowExchange(a, 0, 1);
	for (int i = 0; i < 9; i++){
		Assert::AreEqual(i+1, a[0][i]);
		Assert::AreEqual(9-i, a[1][i]);
	}
}
TEST_METHOD(TestMethod2){
	FianlMaker fm;
	int a[9][9] = {
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 },
		{ 1,2,3,4,5,6,7,8,9 } };
	fm.colExchange(a, 0, 1);
	for (int i = 0; i < 9; i++){
		Assert::AreEqual(2, a[i][0]);
		Assert::AreEqual(1, a[i][1]);
	}
}
           

覆寫率分析

可以看到兩個功能函數的覆寫率都達到了100%(助教給的這個插件似乎無法對單元測試的覆寫率進行統計,隻能統計EXE實際跑起來執行了哪些代碼?)

優化改進

  1. 多次輸出改為單次一起輸出
  2. dfs中,發現優先移除元素最少的列,求解效率更高。(單次求解1.2s→0.3s)
  3. str+=to_string(table[i][j])改為str+=char(table[i][j]+'0'),快了不少 之前使用string儲存所有的輸出字元,性能分析發現string的+=操作占用了程式大部分時間,于是改為char數組儲存,速度提高了很多(39s→8s)。
  4. 将輸出由ofstream的<<改為FILE*的fputs,速度略微提高。
  5. 之前使用swap标準函數來交換二維數組,改為自己用temp實作變快了。(6s→3s)

最終,生成100萬個終盤,開啟O2優化後的release編譯性能分析圖如下

個人項目-數獨

消耗最大的函數是make,負責調用其它函數生成終盤。

解1000次芬蘭數學家提出的“最難數獨”

耗時

個人項目-數獨

代碼說明

void FianlMaker::make(int n) {
	num = n;
	count = 0;
	int a[9] = { 1,2,3,4,5,6,7,8,9 };
	while (1) {
		table[0][4] = table[1][1] = table[2][7] = table[3][3] = table[4][0] = table[5][6] = table[6][5] = table[7][2] = table[8][8] = a[0];
		table[0][5] = table[1][2] = table[2][8] = table[3][4] = table[4][1] = table[5][7] = table[6][3] = table[7][0] = table[8][6] = a[1];
		table[0][3] = table[1][0] = table[2][6] = table[3][5] = table[4][2] = table[5][8] = table[6][4] = table[7][1] = table[8][7] = a[2];
		table[0][7] = table[1][4] = table[2][1] = table[3][6] = table[4][3] = table[5][0] = table[6][8] = table[7][5] = table[8][2] = a[3];
		table[0][8] = table[1][5] = table[2][2] = table[3][7] = table[4][4] = table[5][1] = table[6][6] = table[7][3] = table[8][0] = a[4];
		table[0][6] = table[1][3] = table[2][0] = table[3][8] = table[4][5] = table[5][2] = table[6][7] = table[7][4] = table[8][1] = a[5];
		table[0][1] = table[1][7] = table[2][4] = table[3][0] = table[4][6] = table[5][3] = table[6][2] = table[7][8] = table[8][5] = a[6];
		table[0][2] = table[1][8] = table[2][5] = table[3][1] = table[4][7] = table[5][4] = table[6][0] = table[7][6] = table[8][3] = a[7];
		table[0][0] = table[1][6] = table[2][3] = table[3][2] = table[4][8] = table[5][5] = table[6][1] = table[7][7] = table[8][4] = 9;
		memcpy(temp, table, sizeof(table));
		for (int c1 = 0; c1 < 2 ; c1++)
			for (int c2 = 0; c2 < 6 ; c2++)
				for (int c3 = 0; c3 < 6 ; c3++)
					for (int r1 = 0; r1 < 2 ; r1++)
						for (int r2 = 0; r2 < 6 ; r2++)
							for (int r3 = 0; r3 < 6; r3++) {
								combina(c1, c2, c3, r1, r2, r3);
								if (count == num) {
									ofstream out;
									out.open("sudoku.txt", ios::out | ios::trunc);	// 寫入前先清空檔案
									out << str;
									out.close();
									return;
								}
							}
		next_permutation(a, a + 8);	 // 按升序進行全排列一次,隻排列前8個元素
	}

}
           

這裡生成種子時,采用了next_permutation這個标準庫裡的函數,其作用是将所給範圍内的元素進行升序排列一次,能升序則改變數組并傳回true,否則傳回false。由于1000000的上限決定了不可能用完所有種子,是以無須判斷。然和根據種子生成的終盤,進行行列變換。

void FianlMaker::combina(const int& c1, const int& c2, const int& c3, const int& r1, const int& r2, const int& r3) {
	memcpy(table, temp, sizeof(temp));
	if (c1 == 1)
		colExchange(1, 2);
	switch (c2) {
	case 1:
		colExchange(4, 5);
		break;
	case 2:
		colExchange(3, 4);
		break;
	case 3:
		colExchange(3, 4);
		colExchange(4, 5);
		break;
	case 4:
		colExchange(3, 5);
		colExchange(4, 5);
		break;
	case 5:
		colExchange(3, 5);
		break;
	}
	...
	tableToString();
}
           

這裡根據6個參數選擇行列變換,确定具體變換方式,然後将該終盤數組轉化為字元串

const int maxrow = 9 * 9 * 9;
const int maxcol = 1 + 9 * 9 * 4;
const int num = maxcol + maxrow * 4;	// 總元素個數,  第一個為Head元素,接着9*9*4個為列标元素,最後9*9*9*4個為“1”元素
int Left[num], Right[num], Up[num], Down[num];	// 每個元素的4個方向分量(相當于連結清單中的箭頭)
int Col[num];		// 記錄每個元素的列标元素
int Row[num];		// 記錄每個元素所在的01矩陣行數
int Size[maxcol];	// 記錄每列的“1”元素個數
int Head[maxrow];	// 記錄每行第一個“1”元素
int table[9][9];	// 數獨
int no;				// 元素編号
           

DLX算法用到的交叉十字循環雙向鍊,用數組來實作這一結構

void SudokuSolver::link() {
	/* 連結列标元素 */
	for (size_t i = 0; i < maxcol; i++) {
		Left[i] = i - 1;
		Right[i] = i + 1;
		Up[i] = Down[i] = i;
		Row[i] = 0;
		Col[i] = i;
		Size[i] = 0;
	}
	/* 連結Head元素 */
	Left[0] = maxcol - 1;
	Right[maxcol - 1] = 0;
	no = maxcol;
	/* 連結“1”元素 */
	for (size_t i = 0; i < 9; i++) {
		for (size_t j = 0; j < 9; j++) {	// 周遊9x9數獨
			int k = table[i][j];
			if (k) {
				for (size_t t = 1; t <= 4; t++) {	// 每個非0數字會在01矩陣中産生4個“1”元素
					Left[no + t] = no + t - 1;
					Right[no + t] = no + t + 1;
					Row[no + t] = i * 81 + j * 9 + k - 1;
				}
				Left[no + 1] = no + 4;
				Right[no + 4] = no + 1;
				Head[Row[no + 1]] = no + 1;
				/* 将4個元素插入列鍊中 */
				insertNode(i * 9 + j + 1, no + 1);
				insertNode(81 + i * 9 + k, no + 2);
				insertNode(81 * 2 + j * 9 + k, no + 3);
				insertNode(81 * 3 + (i / 3 * 3 + j / 3) * 9 + k, no + 4);
				no += 4;
			}
			else {	// 該位置為0,即待填
				for (size_t k = 1; k <= 9; k++) {	// 構造9種填法
					for (size_t t = 1; t <= 4; t++) {	// 生成并連結它們的元素
						Left[no + t] = no + t - 1;
						Right[no + t] = no + t + 1;
						Row[no + t] = i * 81 + j * 9 + k - 1;
					}
					Left[no + 1] = no + 4;
					Right[no + 4] = no + 1;
					Head[Row[no + 1]] = no + 1;
					insertNode(i * 9 + j + 1, no + 1);
					insertNode(81 + i * 9 + k, no + 2);
					insertNode(81 * 2 + j * 9 + k, no + 3);
					insertNode(81 * 3 + (i / 3 * 3 + j / 3) * 9 + k, no + 4);
					no += 4;
				}
			}
		}
	}
}
           

這是形成交叉十字循環雙向鍊的函數,負責将1個Head元素+9*9*4個列标元素+9*9*9*4個“1”元素連結起來,即DLX算法中的DancingLink部分。

bool SudokuSolver::dfs(int select){
	if (select > 81)	// 已選夠
		return true;
	/* 周遊列标元素,選一個元素最少的列(回溯率低) */
	int c, minnum = INT_MAX;
	for (size_t i = Right[0]; i != 0; i = Right[i]) {	
		if (Size[i] == 0)
			return false;
		if (Size[i] < minnum){
			minnum = Size[i];
			c = i;
		}
	}
	remove(c);
	/* 周遊該列各“1”元素 */
	for (int i = Down[c]; i != c; i = Down[i]){
		int row = Row[i];
		table[row / 81][row % 81 / 9] = row % 9 + 1;	// 根據該元素的行填入數獨
		for (size_t j = Right[i]; j != i; j = Right[j])	// 移除與該元素同行元素的列
			remove(Col[j]);
		if (dfs(select + 1))	// 已選行數+1,遞歸調用
			return true;
		for (size_t j = Left[i]; j != i; j = Left[j])	// 遞歸傳回false,說明後續無法滿足,故恢複與該元素同行元素的列,循壞進入本列下一進制素
			restore(Col[j]);
	}
	restore(c);
	return false;
}
           

這是求解精确覆寫問題的X算法中的核心部分,即按深度優先周遊遞歸求解

PSP表格

PSP2.1 Personal Software Process Stages 預估耗時(分鐘) 實際耗時(分鐘)
Planning 計劃
· Estimate · 估計這個任務需要多少時間 10 20
Development 開發
· Analysis · 需求分析 (包括學習新技術) 120 180
· Design Spec · 生成設計文檔 30
· Design Review · 設計複審 (和同僚稽核設計文檔) 5
· Coding Standard · 代碼規範 (為目前的開發制定合适的規範)
· Design · 具體設計 60
· Coding · 具體編碼 500 600
· Code Review · 代碼複審
· Test · 測試(自我測試,修改代碼,送出修改) 300
Reporting 報告
· Test Report · 測試報告
· Size Measurement · 計算工作量
· Postmortem & Process Improvement Plan · 事後總結, 并提出過程改進計劃
Total 總計 1090 1480

一些感想

  草草估計了下,花在這個項目上的時間已經24h了(實際可能更多)。開學這第一周可以說也沒幹别的事,成天腦子裡想的都是學C++、找算法、看教材、寫部落格、組隊……寫煩的時候會想“這真的是兩學分的選修課?花的時間和計組差不多了!”又加上經曆了前後加入的兩個組 組長都帶着半組人退課了這種事,有那麼一刻真的想我也退了算了。理由可以找很多:我沒有C++基礎上來就寫大項目太難了、編譯+資料庫+安卓 課業已經很重了……但其實心知肚明,都是想偷懶的借口。上這課隻是為了學分嗎?畢業想參加工作的我肯定不能隻為混學分。課業雖重,但咬咬牙總能過去。That which does not kill us makes us stronger.

  扯遠了,說回這個項目。花了一下午找算法并看懂,花了一天完成初版。然後本來打算做附加題的,但這幾天的時間都花在性能優化上了,再加上一直沒怎麼寫過GUI也不熟悉Qt,就不了了之。 優化的結果還是比較滿意的,從初版的一分多鐘到現在的1.5秒,其中走了很多繞路。舉個例子,之前因為友善都是用string的+=拼接字元串,可沒想到比直接操作char*慢這麼多。(在此之前,還查了+=、append、stringstream、sprintf的效率比較)類似的還有各種檔案IO的處理。也仍有一些不懂的地方,比如VS的編譯優化是怎樣做到的?還有調試時出現的一些link錯誤也沒真正弄懂背後的原理。以前寫代碼時幾乎沒怎麼想效率問題,現在算是有些明白老師講的“軟工作業寫個隻存了五六本書的圖書管理系統有什麼用”了。

  雖然現在想想後面的結對、團隊項目仍有點頭皮發麻,但也明白“輕輕松松就完成的作業相當于沒做”。路在前方,唯行而已。