简介这是一份面向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 或控制台这份源码选的是 MFCMicrosoft 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 默认不感知 DPIGetClientRect拿到的是缩放前坐标。解决在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 组件装没装、把算法层和界面层的边界画清楚、跑一遍困难难度看会不会卡死。这三步走完后面改起来基本不会翻车。希望帮到你。本文还有配套的精品资源点击获取