☰
MFC数独源码解析:回溯求解与挖洞生成算法实战
2026/10/15 21:00:23 网站建设 项目流程

简介:这是一份面向C++与MFC初学者、算法爱好者的数独游戏完整源代码,源自华为编程比赛决赛题目要求,基于Visual Studio 2008 SP1开发,可帮助读者理解数独生成与求解算法的工程化落地。资源包共84个文件,以h头文件、cpp源文件为核心,辅以sln解决方案、vcproj工程文件、rc资源脚本、ico图标及若干测试数据文件,压缩包约870KB,结构完整可直接编译运行。功能上支持九宫格自由输入、读取原始布局、自动完成任意数独、自动生成难度适中的题目,并提供输入提示与游戏计时。代码中同时包含完成算法与生成算法两条主线,适合对照学习回溯求解与挖洞出题思路;作者还附带了VS2008非SP1版本的移植说明,涉及CWinAppEx替换、afxcontrolbars.h屏蔽及afxmt.h头文件补充等排错细节。目前已有564人学习下载,适合作为课程设计、竞赛练手与MFC界面开发的参考范例。

1. 从一份 MFC 数独源码说起:它到底能跑出什么

前阵子帮一个做桌面开发的朋友看毕设,他扔过来一份“数独游戏MFC实现完整源代码”,说跑不起来,老师催得紧。我打开工程一看,核心就两块:一个基于回溯的求解器,一个挖洞式的题目生成器,界面用 MFC 的对话框框架搭的,画格子、响应鼠标、刷新数字全在里面。这份代码最值钱的地方不是界面多漂亮,而是它把“数独怎么解”和“数独怎么出”这两件事用 C++ 写清楚了,而且能直接编译运行。如果你正在找一份能改、能交作业、能当 MFC 练手项目的源码,这份资源基本够用。它适合三类人:刚学完 C++ 想找个完整工程练手的、需要交课程设计但不想从零画对话框的、以及想搞明白数独生成算法到底怎么控制难度的。下面我按“先跑起来、再看算法、最后调难度”的顺序,把这份源码拆一遍。

2. 把工程跑起来:MFC 环境配置与第一个可执行窗口

2.1 确认你的 Visual Studio 装了 MFC 组件

很多人拿到源码第一步就翻车,不是代码有问题,是 VS 根本没装 MFC。打开 Visual Studio Installer,点“修改”,在“单个组件”里搜“MFC”,把“适用于最新 v143 生成工具的 C++ MFC”勾上。如果你用的是 VS2019,对应的是 v142。装完大概多出 1GB 左右空间。验证方法很简单:新建项目时能看到“MFC 应用”模板,就说明装好了。

提示:不要用 VS Code 打开这个工程,MFC 的对话框资源和消息映射必须靠 Visual Studio 的 IDE 来解析,VS Code 会报一堆找不到 afxwin.h 的错误。

2.2 用 .sln 打开并解决字符集不匹配

源码包里通常有一个.sln文件和一个.vcxproj文件。双击.sln,VS 会加载整个解决方案。这时候最常见的报错是:

error C2664: “void ATL::CStringT<...>::Format(const wchar_t *,...)”: 无法将参数 1 从“const char [10]”转换为“const wchar_t *”

这是字符集问题。右键项目 → 属性 → 配置属性 → 高级 → 字符集,把“使用 Unicode 字符集”改成“使用多字节字符集”。因为这份源码里大量用了CString和sprintf混搭,原作者大概率是在多字节环境下写的。改完重新生成,一般就能过。

// 典型的多字节写法,在 Unicode 下会报错 CString str; str.Format("当前空格数:%d", emptyCount); // 多字节下正常,Unicode 下需要 L"%d"

逻辑说明:Format在 Unicode 下要求宽字符串,而源码里写的是窄字符串。改成多字节字符集是最省事的做法,不用逐行加L前缀。参数说明:emptyCount是当前盘面剩余空格数,后面讲生成算法时会反复用到这个变量。

2.3 资源视图里检查对话框 ID 是否冲突

MFC 工程靠resource.h里的 ID 宏来绑定对话框和控件。如果编译时报“未定义标识符 IDD_SUDOKU_DIALOG”,就去资源视图 → Dialog 里看那个对话框的 ID 是不是被改了。常见情况是源码从别人机器拷过来,resource.h和.rc文件不同步。解决办法:在资源视图里右键对话框 → 属性 → 把 ID 改回IDD_SUDOKU_DIALOG,然后重新生成。

// 在 SudokuDlg.cpp 的 OnInitDialog 里 BOOL CSudokuDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 初始化 9x9 按钮数组,每个格子对应一个 CButton for (int i = 0; i < 9; i++) for (int j = 0; j < 9; j++) m_cell[i][j].Create(_T(""), WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, CRect(20 + j * 40, 20 + i * 40, 60 + j * 40, 60 + i * 40), this, IDC_CELL_BASE + i * 9 + j); return TRUE; }

逻辑说明:这段代码在对话框初始化时动态创建 81 个按钮,每个按钮代表一个数独格子。IDC_CELL_BASE是资源里定义的起始 ID,后面每个按钮的 ID 依次递增。参数说明:CRect的四个值分别是左、上、右、下坐标,每个格子 40x40 像素,间距 20 像素。如果你想让格子大一点,改40这个值就行,但要注意窗口总尺寸也要跟着调。

3. 求解算法拆解:回溯法在 9x9 盘面上的具体实现

3.1 回溯法的三个核心判断

数独求解的本质就是“试错 + 回退”。这份源码里用一个Solve()函数递归完成,核心判断只有三个:行冲突、列冲突、宫冲突。先看代码:

bool CSudokuSolver::IsValid(int row, int col, int num) { // 检查行 for (int j = 0; j < 9; j++) if (m_board[row][j] == num) return false; // 检查列 for (int i = 0; i < 9; i++) if (m_board[i][col] == num) return false; // 检查 3x3 宫 int startRow = (row / 3) * 3; int startCol = (col / 3) * 3; for (int i = 0; i < 3; i++) for (int j = 0; j < 3; j++) if (m_board[startRow + i][startCol + j] == num) return false; return true; }

逻辑说明:IsValid在尝试填入num之前调用,只要行、列、宫里任意一处已经有num,就返回false,表示这个数字不能放。参数说明:row和col是当前尝试的格子坐标,范围 0 到 8;num是待填入的数字,范围 1 到 9。startRow和startCol用整除 3 再乘 3 的方式定位到当前格子所属的 3x3 宫左上角,这是数独里最常用的坐标变换技巧。

3.2 递归求解的主循环与回退点

有了IsValid,求解函数就是一个标准的深度优先搜索:

bool CSudokuSolver::Solve() { for (int row = 0; row < 9; row++) { for (int col = 0; col < 9; col++) { if (m_board[row][col] == 0) // 找到空格 { for (int num = 1; num <= 9; num++) { if (IsValid(row, col, num)) { m_board[row][col] = num; // 尝试填入 if (Solve()) return true; // 递归 m_board[row][col] = 0; // 回退 } } return false; // 1-9 都试过了,无解 } } } return true; // 没有空格,求解完成 }

逻辑说明:外层双重循环找到第一个空格,然后从 1 到 9 逐个尝试。如果某个数字合法,就填入并递归调用Solve()。如果递归返回true,说明后续格子都能填完,直接一路返回true。如果递归返回false,说明当前数字导致后面无解,把格子恢复为 0,继续试下一个数字。参数说明:m_board是 9x9 的整型数组,0 表示空格,1 到 9 表示已填数字。这个算法在最坏情况下复杂度是指数级,但标准数独的盘面通常能在毫秒级出结果。

3.3 求解器与界面的数据同步

求解完成后,需要把m_board里的数字刷到界面上。源码里用一个UpdateBoard()函数遍历 81 个按钮:

void CSudokuDlg::UpdateBoard() { for (int i = 0; i < 9; i++) { for (int j = 0; j < 9; j++) { CString str; if (m_solver.m_board[i][j] != 0) str.Format(_T("%d"), m_solver.m_board[i][j]); m_cell[i][j].SetWindowText(str); } } }

逻辑说明:遍历所有格子,如果m_board里对应位置不是 0,就把数字转成字符串设到按钮上;如果是 0,就设空字符串。参数说明:m_solver是求解器对象,m_board是它的公开成员。SetWindowText是 MFC 按钮控件设置显示文字的标准方法。注意这里用了_T()宏,在多字节和 Unicode 下都能编译。

4. 生成算法拆解:挖洞法怎么控制题目难度

4.1 先生成完整解,再随机挖洞

这份源码的生成思路很直接:先用回溯法生成一个完整合法的 9x9 盘面,然后随机挖掉一些格子。生成完整解的时候,不是从空盘开始试,而是先按顺序填 1 到 9,再随机打乱:

void CSudokuGenerator::GenerateFullBoard() { // 先按顺序填一个合法盘面 for (int i = 0; i < 9; i++) for (int j = 0; j < 9; j++) m_board[i][j] = (i * 3 + i / 3 + j) % 9 + 1; // 随机交换数字,打乱盘面 int map[10]; for (int i = 1; i <= 9; i++) map[i] = i; for (int i = 9; i >= 1; i--) { int j = rand() % i + 1; int tmp = map[i]; map[i] = map[j]; map[j] = tmp; } for (int i = 0; i < 9; i++) for (int j = 0; j < 9; j++) m_board[i][j] = map[m_board[i][j]]; }

逻辑说明:(i * 3 + i / 3 + j) % 9 + 1是一个经典的数独基盘公式,能直接生成一个合法完整解。然后通过随机交换数字 1 到 9 的映射关系,得到不同外观的盘面。参数说明:map数组是数字映射表,map[1]到map[9]是 1 到 9 的一个随机排列。rand()需要先调用srand((unsigned)time(NULL))播种,否则每次运行生成的盘面一样。

4.2 挖洞数量与难度等级的对应关系

挖洞就是随机把一些格子的数字置 0。源码里用difficulty变量控制挖洞数量:

难度等级挖洞数量剩余提示数适合人群
简单30-3546-51新手熟悉规则
中等40-4536-41有一定经验
困难50-5526-31熟练玩家
专家56-6021-25追求极限
void CSudokuGenerator::DigHoles(int holes) { int dug = 0; while (dug < holes) { int row = rand() % 9; int col = rand() % 9; if (m_board[row][col] != 0) { m_board[row][col] = 0; dug++; } } }

逻辑说明:DigHoles随机选格子,如果当前格子不是 0 就挖掉,直到挖够holes个。参数说明:holes是目标挖洞数,对应上表的难度等级。注意这个简单挖洞法不保证唯一解,后面避坑章节会讲怎么处理。

4.3 保证唯一解的两种常见做法

上面那种随机挖洞有个问题:挖完可能有多解。常见做法有两种。第一种是每挖一个洞就用求解器验证一次,如果解的数量大于 1 就恢复这个格子:

bool CSudokuGenerator::HasUniqueSolution() { CSudokuSolver solver; memcpy(solver.m_board, m_board, sizeof(m_board)); int count = 0; solver.CountSolutions(count); // 计数到 2 就停 return count == 1; }

第二种是改变挖洞顺序,从中心向四周挖,因为中心格子的约束更强,挖掉后更容易保持唯一解。我一般会两种结合:先按中心优先顺序挖,每挖一个验证一次,这样生成的题目质量最稳。参数说明:CountSolutions是求解器的一个变体,递归时不返回true而是累加解的数量,累加到 2 就可以提前终止,避免浪费时间。

5. 避坑与排查:这份源码最容易翻车的五个地方

5.1 编译报错“无法打开 afxwin.h”

现象:双击.sln后生成,提示找不到afxwin.h。原因:VS 没装 MFC 组件,或者项目属性里的“使用 MFC”被设成了“不使用”。解决:先确认 VS Installer 里勾了 MFC,然后右键项目 → 属性 → 配置属性 → 常规 → 使用 MFC,改成“在共享 DLL 中使用 MFC”。

5.2 运行后界面一片空白,按钮没显示

现象:程序能跑,但对话框上只有背景,没有 9x9 格子。原因:OnInitDialog里创建按钮的代码没执行,或者按钮坐标算错了跑到窗口外面。解决:在OnInitDialog里加断点,看循环有没有进去;检查CRect的坐标是不是超出了对话框客户区大小。常见情况是对话框资源本身设得太小,按钮画到边界外了。

5.3 求解结果不对,同一行出现两个相同数字

现象:点“求解”后,盘面上某一行有两个 5。原因:IsValid里的行检查写成了m_board[j][col],行列写反了。解决:仔细核对IsValid里三个循环的索引,行检查固定row变j,列检查固定col变i,宫检查用startRow + i和startCol + j。

5.4 生成题目时程序卡死

现象:点“生成”后界面无响应,要等好几秒。原因:挖洞时没有限制重试次数,如果盘面剩余空格已经很少,随机选格子可能反复选到已挖的格子,陷入死循环。解决:在while循环里加一个重试计数器,超过 1000 次就跳出,或者改成先收集所有非空格子的坐标,再从中随机抽取。

5.5 每次生成的题目一模一样

现象:反复点“生成”,出来的盘面完全相同。原因:忘了调srand((unsigned)time(NULL)),或者只在程序启动时调了一次但生成函数里又调了srand固定种子。解决:在InitInstance或对话框初始化时调一次srand,生成函数里不要再调。

6. 进阶技巧:把这份源码改成能自动出题并验证的版本

如果你想把这份源码用到实际项目里,比如做一个自动出题的小工具,我建议加一个“生成-验证”循环。具体做法是:写一个GenerateValidPuzzle(int targetHoles)函数,内部先GenerateFullBoard(),再DigHoles(targetHoles),然后调HasUniqueSolution()验证。如果不唯一,就重新生成完整盘面再挖,直到唯一为止。这个循环通常跑 3 到 5 次就能成功,因为随机挖洞出唯一解的概率并不低。

bool CSudokuGenerator::GenerateValidPuzzle(int targetHoles) { for (int attempt = 0; attempt < 10; attempt++) { GenerateFullBoard(); DigHoles(targetHoles); if (HasUniqueSolution()) return true; } return false; // 10 次都失败,降低难度重试 }

逻辑说明:外层最多尝试 10 次,每次重新生成完整盘面并挖洞,验证唯一解。如果 10 次都失败,说明目标挖洞数太高,可以适当减少targetHoles再试。参数说明:targetHoles是目标挖洞数,对应第 4 章表格里的难度等级。attempt是尝试计数器,10 次是个经验值,再大就明显影响响应速度了。

另一个实用技巧是加一个“提示”按钮:当玩家卡住时,从当前盘面调一次求解器,把解的第一个空格填上。这个功能在CSudokuDlg里加一个按钮响应就行,底层直接复用Solve(),不需要额外算法。

void CSudokuDlg::OnBnClickedHint() { CSudokuSolver solver; memcpy(solver.m_board, m_solver.m_board, sizeof(m_board)); if (solver.Solve()) { // 找到第一个空格并填入 for (int i = 0; i < 9; i++) for (int j = 0; j < 9; j++) if (m_solver.m_board[i][j] == 0) { m_solver.m_board[i][j] = solver.m_board[i][j]; UpdateBoard(); return; } } }

逻辑说明:复制当前盘面到临时求解器,求解后找到原盘面第一个空格,把解的值填进去,然后刷新界面。参数说明:memcpy用来复制整个m_board数组,避免修改原盘面。这个提示功能每次只填一个格子,不会直接给出完整答案,适合做游戏辅助。

从那以后我每次拿到这类源码,都先跑通编译、再验证求解、最后才动生成逻辑,顺序反了就容易在环境问题上浪费一整天。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询