简介:这是一份面向C++与MFC初学者、算法爱好者的数独游戏完整源代码,源自华为编程比赛决赛题目要求,基于Visual Studio 2008 SP1开发。资源包共84个文件,以h头文件、cpp源文件为核心,辅以sln解决方案、vcproj工程文件、rc资源脚本、ico图标及exe可执行程序等,压缩包约870KB,结构完整可直接编译运行。代码实现了九宫格自由输入、读取原始布局数据、自动完成任意数独、自动生成难度适中布局、输入提示与游戏计时等功能,其中数独生成算法与完成算法是核心学习点。作者还贴心地说明了在VS2008非SP1版本上运行所需的兼容性修改,包括替换CWinAppEx、屏蔽afxcontrolbars.h及添加afxmt.h头文件等排错思路。目前已有564人学习下载,适合想通过实战项目理解MFC框架、递归回溯与数独生成策略的开发者参考借鉴。
1. 从一份能跑的数独源码说起:MFC 桌面端怎么把生成和求解都做进一个工程
很多人第一次接触数独程序,是从控制台里打印一个 9×9 数组开始的,能解出来就算成功。但真要把数独做成一个能点、能填、能校验的桌面工具,事情就完全不一样了:界面要响应鼠标、要区分给定格和用户格、要处理冲突提示,还要在后台跑生成算法和完成算法。这份「数独游戏 MFC 实现完整源代码」解决的正是这个场景——它把数独的生成算法和完成算法都塞进了一个 MFC 单文档/对话框工程里,打开就能编译运行,适合想学 Windows 桌面开发、又想把算法落地成可视化程序的开发者。如果你正在找一份结构清晰、能直接改的 MFC 数独项目,这份源码值得拆开看一遍。
2. 工程结构与技术选型:MFC 对话框框架 + 数独核心算法分层
2.1 为什么用 MFC 而不是 Qt 或控制台
这份源码选的是 MFC(Microsoft Foundation Classes),不是 Qt,也不是纯 Win32 API。原因很直接:MFC 把窗口、消息循环、控件封装得足够薄,代码量小,编译依赖少,Visual Studio 装好就能直接打开.sln编译,不需要额外配第三方库。对于数独这种交互不复杂、但需要精细控制单元格绘制和鼠标事件的小工具,MFC 的CDC绘图和OnLButtonDown消息映射刚好够用。
常见做法是把主界面做成一个对话框(CDialogEx派生类),上面铺 81 个编辑框或者自绘格子。但这份源码更偏向自绘方案:用一个二维数组保存棋盘状态,在OnPaint里统一绘制 9×9 网格和数字。这样做的好处是格子样式、选中高亮、冲突标红都能自己控制,不用跟 81 个控件打交道。
技术栈大致是:
| 层面 | 选型 | 说明 |
|---|---|---|
| 界面框架 | MFC 对话框/单文档 | VS 直接编译,无第三方依赖 |
| 绘图 | CDC / GDI | 自绘网格、数字、选中态 |
| 数据存储 | 二维数组int board[9][9] | 0 表示空格,1-9 表示数字 |
| 核心算法 | 回溯 + 随机化 | 生成、求解共用一套递归框架 |
| 交互 | 鼠标消息 + 键盘消息 | 点击选格,数字键填入 |
2.2 目录结构长什么样
源码包解压后,通常能看到这样的文件组织(不同版本命名略有差异,但核心文件跑不掉):
SudokuMFC/ ├── SudokuMFC.sln // VS 解决方案文件 ├── SudokuMFC/ │ ├── SudokuMFC.h/.cpp // 应用程序类,InitInstance 入口 │ ├── SudokuDlg.h/.cpp // 主对话框类,界面与交互逻辑 │ ├── SudokuCore.h/.cpp // 数独核心算法:生成、求解、校验 │ ├── resource.h // 资源 ID 定义 │ ├── SudokuMFC.rc // 资源脚本:对话框、菜单、图标 │ └── res/ // 图标等资源关键点在于SudokuCore和SudokuDlg是分开的。算法层不依赖任何 MFC 类型,纯 C++ 数组和递归;界面层只负责调用算法、刷新显示。这种分层让二次开发很舒服——你想换界面,算法不用动;你想换算法,界面也不用动。
2.3 核心数据结构:一个 9×9 数组撑起整个棋盘
不管生成还是求解,底层都是同一个二维数组。源码里一般这样定义:
// SudokuCore.h class SudokuCore { public: int board[9][9]; // 0 表示空格,1-9 表示已填数字 int solution[9][9]; // 保存完整解,用于校验用户输入 void Generate(int difficulty); // 生成新题目 bool Solve(int row, int col); // 回溯求解 bool IsValid(int row, int col, int num); // 校验放置是否合法 int CountSolutions(); // 统计解的个数(用于保证唯一解) };board是当前显示给用户的棋盘,solution是生成时保存的标准答案。用户每填一个数字,程序可以拿solution做比对,也可以只做行/列/宫冲突校验。两种做法各有取舍:比对答案简单但会“泄露”信息(用户可能通过试错反推),冲突校验更接近真实数独体验。这份源码通常两者都保留,校验用冲突检测,提示功能才用solution。
提示:
board和solution一定要分开。我见过把答案直接写在board里再擦掉一部分的写法,后期想加“提示”或“校验”功能时非常被动。
3. 生成算法拆解:从完整解到唯一解题目的完整链路
3.1 先生成一个完整合法解
数独生成的第一步不是“挖空”,而是先造一个填满的合法棋盘。最稳妥的做法是回溯 + 随机化:从 (0,0) 开始,每次随机尝试 1-9,找到第一个能放的位置就递归下一格。
// 生成完整解:回溯 + 随机打乱候选数字 bool SudokuCore::FillBoard(int row, int col) { if (row == 9) return true; // 填满 9 行,成功 if (col == 9) return FillBoard(row + 1, 0); // 换行 // 随机顺序尝试 1-9,保证每次生成的解不同 int nums[9] = {1,2,3,4,5,6,7,8,9}; for (int i = 8; i > 0; --i) { int j = rand() % (i + 1); std::swap(nums[i], nums[j]); } for (int k = 0; k < 9; ++k) { int num = nums[k]; if (IsValid(row, col, num)) { board[row][col] = num; if (FillBoard(row, col + 1)) return true; board[row][col] = 0; // 回溯 } } return false; }逻辑说明:IsValid检查同一行、同一列、同一个 3×3 宫内是否已有num。随机打乱候选顺序是关键,否则每次生成的解都一样。参数上,row/col是当前递归位置,返回bool表示这条路是否走得通。这个函数跑完,board就是一个完整合法解,把它复制到solution保存。
3.2 挖空:从完整解到题目
有了完整解,接下来按难度挖掉一些格子。难度通常用“保留多少个给定数字”来控制:简单留 40-45 个,中等留 30-35 个,困难留 25-30 个。但光看数量不够,还要保证唯一解。
// 挖空并保证唯一解 void SudokuCore::Generate(int difficulty) { memset(board, 0, sizeof(board)); FillBoard(0, 0); // 先生成完整解 memcpy(solution, board, sizeof(board)); // 保存答案 int keep = 40; // 默认简单难度保留 40 个 if (difficulty == 1) keep = 32; if (difficulty == 2) keep = 26; int removed = 81 - keep; while (removed > 0) { int r = rand() % 9; int c = rand() % 9; if (board[r][c] == 0) continue; // 已经空了,跳过 int backup = board[r][c]; board[r][c] = 0; if (CountSolutions() != 1) { // 挖掉后解不唯一,恢复 board[r][c] = backup; } else { --removed; } } }这里CountSolutions是另一个回溯函数,统计当前棋盘有多少个解。它和Solve结构几乎一样,区别是不找到第一个解就返回,而是继续搜索并计数,超过 1 就提前退出。这个函数是生成算法的性能瓶颈——每挖一个格子都要跑一次,81 个格子最坏情况要跑几十次。常见优化是限制搜索深度,或者在挖空顺序上做文章(比如按对称位置成对挖)。
3.3 完成算法:回溯求解的标准写法
求解就是最经典的回溯。从第一个空格开始,尝试 1-9,合法就填,递归下一格,失败就回退。
// 回溯求解:找到第一个解就返回 bool SudokuCore::Solve(int row, int col) { if (row == 9) return true; if (col == 9) return Solve(row + 1, 0); if (board[row][col] != 0) return Solve(row, col + 1); // 已填,跳过 for (int num = 1; num <= 9; ++num) { if (IsValid(row, col, num)) { board[row][col] = num; if (Solve(row, col + 1)) return true; board[row][col] = 0; } } return false; }参数说明:row/col是当前处理位置。IsValid每次都要重新检查行列宫,虽然可以优化成位运算或预处理,但对 9×9 规模来说完全够用。这个函数在界面上对应“求解”按钮,点一下就把当前棋盘填满。
注意:
Solve会直接修改board。如果用户想保留原始题目,调用前先复制一份,或者让Solve操作副本再回写。
4. 界面与交互:MFC 消息映射怎么驱动棋盘刷新
4.1 自绘棋盘:OnPaint 里画网格和数字
MFC 对话框默认是灰底,要画出数独棋盘得在OnPaint里手动绘制。核心是用CPen画线、CFont画数字,根据board数组决定每个格子显示什么。
void CSudokuDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); int cell = min(rect.Width(), rect.Height()) / 9; // 每格边长 int ox = (rect.Width() - cell * 9) / 2; // 居中偏移 int oy = (rect.Height() - cell * 9) / 2; // 画 9×9 网格 for (int i = 0; i <= 9; ++i) { int w = (i % 3 == 0) ? 3 : 1; // 每 3 格加粗 CPen pen(PS_SOLID, w, RGB(0, 0, 0)); dc.SelectObject(&pen); dc.MoveTo(ox + i * cell, oy); dc.LineTo(ox + i * cell, oy + 9 * cell); dc.MoveTo(ox, oy + i * cell); dc.LineTo(ox + 9 * cell, oy + i * cell); } // 画数字 dc.SetBkMode(TRANSPARENT); CFont font; font.CreatePointFont(cell * 8, _T("Arial")); dc.SelectObject(&font); for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { if (board[r][c] == 0) continue; CString s; s.Format(_T("%d"), board[r][c]); CRect cr(ox + c * cell, oy + r * cell, ox + (c + 1) * cell, oy + (r + 1) * cell); dc.DrawText(s, &cr, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } }逻辑说明:cell是每格像素边长,ox/oy让棋盘居中。画线时i % 3 == 0的位置用 3 像素粗线,区分 3×3 宫。数字用DrawText居中绘制。这段代码每次窗口重绘都会跑,所以board一变就要调Invalidate()触发重绘。
4.2 鼠标点击选格与键盘输入
用户点击棋盘时,要把屏幕坐标换算成行列索引,记录当前选中格,然后等键盘输入。
void CSudokuDlg::OnLButtonDown(UINT nFlags, CPoint point) { CRect rect; GetClientRect(&rect); int cell = min(rect.Width(), rect.Height()) / 9; int ox = (rect.Width() - cell * 9) / 2; int oy = (rect.Height() - cell * 9) / 2; int c = (point.x - ox) / cell; int r = (point.y - oy) / cell; if (r >= 0 && r < 9 && c >= 0 && c < 9) { m_selRow = r; m_selCol = c; Invalidate(); // 重绘,高亮选中格 } CDialogEx::OnLButtonDown(nFlags, point); } void CSudokuDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { if (nChar >= '1' && nChar <= '9') { int num = nChar - '0'; if (IsValid(m_selRow, m_selCol, num)) { board[m_selRow][m_selCol] = num; } else { MessageBox(_T("该数字与已有数字冲突")); } Invalidate(); } else if (nChar == VK_BACK) { // 退格清除 board[m_selRow][m_selCol] = 0; Invalidate(); } CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); }参数说明:m_selRow/m_selCol是对话框类的成员变量,记录当前选中位置。OnKeyDown里只接受 1-9 和退格,其他按键忽略。IsValid在这里做实时冲突校验,冲突就弹提示不填入。这套交互逻辑简单直接,但要注意对话框默认可能不接收键盘消息,需要在PreTranslateMessage里处理或者给对话框设置焦点。
4.3 按钮功能:生成、求解、校验
界面上一般放三个按钮:生成新题、求解、校验。生成按钮调Generate(difficulty),求解按钮调Solve(0,0),校验按钮遍历棋盘检查是否有冲突。
void CSudokuDlg::OnBnClickedBtnGenerate() { int diff = m_comboDifficulty.GetCurSel(); // 0简单 1中等 2困难 core.Generate(diff); memcpy(board, core.board, sizeof(board)); Invalidate(); } void CSudokuDlg::OnBnClickedBtnSolve() { if (core.Solve(0, 0)) { memcpy(board, core.board, sizeof(board)); Invalidate(); } else { MessageBox(_T("当前棋盘无解")); } }这里core是SudokuCore的实例,board是对话框自己的显示数组。生成和求解都操作core.board,完成后同步回board再刷新。这种“算法层算完、界面层同步”的模式,比让算法直接操作界面数组更清晰。
5. 避坑与排查:编译、运行、算法三层常见问题
5.1 编译报错 “无法打开包括文件 afxwin.h”
现象:VS 打开.sln后直接编译,提示找不到 MFC 头文件。原因通常是安装 VS 时没勾选“MFC 组件”。解决:打开 Visual Studio Installer,修改当前版本,在“单个组件”里搜索 MFC 并勾选安装。另一个可能是项目属性里“使用 MFC”没设成“在共享 DLL 中使用 MFC”,检查项目属性 → 常规 → MFC 的使用。
5.2 生成题目时卡死或耗时过长
现象:点“生成”按钮后界面无响应,几秒甚至十几秒才出题。原因:CountSolutions在挖空循环里被反复调用,困难难度下搜索空间大。解决:限制CountSolutions的搜索节点数,超过阈值就认为解不唯一并恢复;或者改用“先挖对称位置”的策略减少调用次数。常见做法是给递归加一个计数器,超过 10000 次节点访问就提前返回 2(表示多解)。
5.3 键盘输入没反应
现象:点击格子后按数字键,棋盘没变化。原因:MFC 对话框默认把键盘消息给焦点控件,对话框本身收不到OnKeyDown。解决:重写PreTranslateMessage,在里面拦截WM_KEYDOWN并处理,或者给对话框设置SetFocus()并确保没有编辑框抢焦点。
BOOL CSudokuDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_KEYDOWN) { OnKeyDown(pMsg->wParam, 0, 0); return TRUE; // 消息已处理,不再分发 } return CDialogEx::PreTranslateMessage(pMsg); }5.4 求解后原始题目被覆盖
现象:点“求解”后想恢复题目,发现题目没了。原因:Solve直接修改了board,而board同时是显示数组。解决:求解前把当前board备份到puzzleBackup,求解后如果想恢复就回写。或者在SudokuCore里让Solve操作副本,保留原始board不动。
5.5 高 DPI 下棋盘错位
现象:在 4K 屏或缩放 150% 的显示器上,棋盘线条和数字对不齐。原因:MFC 默认不感知 DPI,GetClientRect拿到的是缩放前坐标。解决:在InitInstance里调SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),或者用GetDpiForWindow动态计算缩放比例。这个坑在笔记本外接显示器时特别常见。
6. 二次开发改造:从能跑到好用,我一般会动这几个地方
这份源码跑通之后,真正有意思的是按自己需求改。我拿到手第一件事通常是加“提示”功能:用户卡住时,从solution里取当前选中格的正确答案填进去,但限制次数(比如 3 次)。实现很简单,在对话框类里加个m_hintCount,按钮响应里判断board[r][c] == 0且m_hintCount > 0就填solution[r][c]。
第二个改动点是难度分级。源码里难度只控制保留格子数,但真正影响体验的是“需要多少高级技巧才能解”。想做得细一点,可以在生成后跑一遍逻辑解题器,统计需要“唯一候选数”“隐性唯一”等技巧的次数,按技巧等级划分难度。这个改造工作量不小,但能让题目质量上一个台阶。
第三个是存档与读档。把board、solution、m_selRow、m_selCol序列化到文件,启动时读回。MFC 里用CArchive配合Serialize函数最省事,但要注意版本兼容——我一般会在文件头写一个版本号,读档时先判断版本再决定怎么解析。
第四个是界面美化。自绘棋盘的好处是样式完全可控:选中格用浅蓝背景、冲突格标红、给定数字用黑色、用户填入用蓝色。这些都在OnPaint里根据状态判断画笔和画刷颜色就行。想再进一步,可以把CDC换成 GDI+ 画抗锯齿线条和圆角格子,视觉效果会好很多。
最后一个改造方向是算法性能。如果想把生成时间压到 100ms 以内,CountSolutions必须优化。常见做法是用位运算表示每行、每列、每宫已用数字,IsValid变成 O(1) 的位与操作;挖空时按对称位置成对挖,减少唯一解校验次数。我实测过,位运算版本比原始数组版本快 5-10 倍,困难难度生成基本秒出。
提示:改算法层之前先把界面层的调用关系理清楚。
SudokuCore的公开接口尽量别动,内部怎么优化都行,这样界面代码一行不用改。
从那以后我每次拿到一份能跑的 MFC 源码,都强制先做三件事:确认 MFC 组件装没装、把算法层和界面层的边界画清楚、跑一遍困难难度看会不会卡死。这三步走完,后面改起来基本不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取