简介:这是一份面向C#初学者与算法入门者的数独游戏源码,基于9x9网格与3x3小九宫格规则实现,支持随机出题与自动解题两大核心功能,代码结构简洁,适合用来练习面向对象编程与基础算法。资源包共47个文件,以12个cs源码文件为主体,配合resx与resources资源文件、config配置、sln与csproj工程文件,以及exe、dll、pdb等编译产物,整体约95KB,体量轻便,便于直接打开工程阅读与调试。目前已有956人学习下载,说明其在入门实践场景中具有一定参考价值。读者可从中了解数独网格类与单元格类的划分方式、随机出题时生成完整解再挖空的思路,以及深度优先搜索配合候选数字列表、回溯与剪枝的解题流程,还可借鉴LINQ对行列与九宫格进行规则校验的写法,进而尝试优化搜索策略,提升编程与问题解决能力。
1. 一份能跑起来的 C# 数独源码:随机出题与自动解题到底怎么落地
很多人第一次接触数独程序,是在刷算法题的时候——LeetCode 上那道「解数独」写完回溯就扔了,真到要做一个能随机出题、能自动求解、还能在 WinForm 上点着玩的完整小工具时,反而不知道从哪下手。这份 C# 数独源码就是冲着这个场景来的:它把「生成合法终盘 → 随机挖洞出题 → 回溯求解 → 界面渲染」这条链路完整串了一遍,代码量不大,结构清晰,适合拿来当 C# 入门到进阶之间的一个综合练习。它解决的不是「数独算法有多难」的问题,而是「一个能跑、能改、能看懂的小项目长什么样」的问题。适合刚学完 C# 基础语法、想找一个有实际逻辑的练手项目的人,也适合想快速拿到一份可运行源码、在此基础上改造成自己版本的人。下面我按「先看懂结构、再动手跑、最后避坑」的顺序拆一遍。
2. 源码结构与核心类拆解:从 Sudoku.sln 到 SudokuPanel.cs
拿到压缩包解压后,第一件事不是急着双击 .sln,而是先看清楚目录里有什么。这份源码的目录结构是典型的 Visual Studio WinForm 工程布局,没有多余的第三方依赖,这意味着你不需要配 NuGet 源、不需要装额外 SDK,只要本机有对应版本的 .NET 开发环境就能编译。
2.1 工程文件清单与职责划分
先把根目录下的文件按职责分个类,心里有数再动手:
| 文件/目录 | 类型 | 职责 |
|---|---|---|
| Sudoku.sln | 解决方案文件 | VS 入口,双击打开整个工程 |
| Sudoku/Sudoku.csproj | 项目文件 | 定义目标框架、编译项、资源引用 |
| Sudoku/Program.cs | 入口 | Main 方法,启动主窗体 |
| Sudoku/FrmSudoku.cs | 窗体逻辑 | 按钮事件、出题/解题触发、状态更新 |
| Sudoku/FrmSudoku.Designer.cs | 设计器生成 | 控件布局、初始化代码,一般不改 |
| Sudoku/FrmSudoku.resx | 资源 | 窗体资源,含图标等 |
| Sudoku/SudokuPanel.cs | 自定义控件 | 9x9 网格的绘制与交互核心 |
| Sudoku/Sudoku.cs | 算法类 | 出题、求解、校验逻辑 |
| Sudoku/App.config | 配置 | 运行时配置,通常保持默认 |
| Sudoku/Properties/ | 属性 | AssemblyInfo 等元数据 |
| Sudoku/bin、obj | 输出 | 编译产物,可删可重建 |
| 9.ico | 图标 | 窗体图标资源 |
这个划分里最关键的是三个文件:Sudoku.cs负责算法,SudokuPanel.cs负责把二维数组画成能点的格子,FrmSudoku.cs负责把两者粘起来。很多人看源码容易一头扎进 Designer 文件里,那里面全是自动生成的坐标和属性,看了也没用,直接跳过。
2.2 二维数组与单元格状态的设计
数独盘面在代码里最自然的表示就是一个 9x9 的二维整型数组,0 表示空格,1 到 9 表示已填数字。但光有数字不够,界面要区分「题目给定的数字」和「玩家填的数字」,否则解题后你没法知道哪些是原始线索。常见做法是再加一个布尔二维数组isFixed,或者用一个枚举标记每个格子的来源。
// Sudoku.cs 中盘面数据的典型定义 private int[,] board = new int[9, 9]; // 0 表示空格 private bool[,] isFixed = new bool[9, 9]; // true 表示题目给定,不可修改 // 判断在 (row, col) 填入 num 是否合法 private bool IsValid(int row, int col, int num) { for (int i = 0; i < 9; i++) { // 检查行和列 if (board[row, i] == num || board[i, col] == num) return false; } // 检查所在 3x3 小九宫格 int startRow = row / 3 * 3; int startCol = col / 3 * 3; for (int r = startRow; r < startRow + 3; r++) for (int c = startCol; c < startCol + 3; c++) if (board[r, c] == num) return false; return true; }这段IsValid是整个程序的校验基石,出题时的挖洞验证、解题时的候选判断、玩家手动填数时的即时反馈,全都调它。参数含义很直白:row、col是零基索引,num是待填入的 1 到 9。注意小九宫格起点用row / 3 * 3这种整数除法来算,这是数独代码里最常见的写法,比查表更省事。如果你要改成 16x16 的数独,这里的分母和循环上界都要跟着改,不是简单改个常量就完事。
2.3 随机出题的生成与挖洞策略
随机出题分两步:先生成一个完整合法终盘,再随机挖掉若干数字形成题目。生成终盘最稳的办法还是回溯——从左上角开始逐格尝试填 1 到 9,配合随机打乱候选顺序,这样每次生成的终盘都不一样。
// 生成一个完整合法终盘 private bool GenerateFullBoard(int row, int col) { if (row == 9) return true; // 填满即成功 int nextRow = col == 8 ? row + 1 : row; int nextCol = col == 8 ? 0 : col + 1; int[] candidates = Enumerable.Range(1, 9).OrderBy(x => Guid.NewGuid()).ToArray(); foreach (int num in candidates) { if (IsValid(row, col, num)) { board[row, col] = num; if (GenerateFullBoard(nextRow, nextCol)) return true; board[row, col] = 0; // 回溯 } } return false; }这里用Guid.NewGuid()排序来做洗牌,是 C# 里比较省事的随机打乱写法,比手写 Fisher-Yates 短,但性能略差,对 9x9 完全够用。挖洞阶段则是在终盘基础上随机选格子置零,每挖一个就用求解器验证是否仍有唯一解——这一步是很多简易实现偷懒的地方,如果不验证唯一解,玩家可能遇到多解题目,体验直接翻车。
3. 自动解题的回溯实现:DFS、剪枝与候选数优化
自动解题是这份源码里最值得细看的部分。摘要里提到用深度优先搜索配合候选数字列表,实际代码里通常就是回溯法的标准写法,但写得好不好,差别在剪枝和候选顺序上。
3.1 基础回溯求解器
最朴素的做法是从第一个空格开始,依次尝试 1 到 9,合法就填、递归、失败就回退。这个版本代码最短,但遇到极端盘面会慢。
// 基础回溯求解 public bool Solve() { for (int row = 0; row < 9; row++) { for (int col = 0; col < 9; col++) { if (board[row, col] == 0) // 找到第一个空格 { for (int num = 1; num <= 9; num++) { if (IsValid(row, col, num)) { board[row, col] = num; if (Solve()) return true; // 递归求解 board[row, col] = 0; // 回溯 } } return false; // 1-9 都试过,无解 } } } return true; // 没有空格,求解完成 }逻辑说明:外层双重循环负责定位空格,内层循环负责试数。IsValid保证当前填入不冲突,递归返回 true 说明后续全部填完,直接向上传递成功信号;返回 false 则把当前格清空,继续试下一个数。参数上没有任何可调项,它的效率完全取决于盘面难度。对于普通难度的数独,这个版本毫秒级出结果;对于接近空盘的极端情况,可能会卡顿。
3.2 候选数列表与最少候选优先剪枝
要让求解器稳一点,常见做法是每次不选「第一个空格」,而是选「候选数最少的空格」来填。这能大幅减少无效分支,是数独求解里性价比最高的优化。
// 找出候选数最少的空格,返回其候选列表 private (int row, int col, List<int> candidates) FindBestCell() { int bestRow = -1, bestCol = -1; List<int> bestCandidates = null; for (int row = 0; row < 9; row++) { for (int col = 0; col < 9; col++) { if (board[row, col] != 0) continue; var candidates = new List<int>(); for (int num = 1; num <= 9; num++) if (IsValid(row, col, num)) candidates.Add(num); if (bestCandidates == null || candidates.Count < bestCandidates.Count) { bestRow = row; bestCol = col; bestCandidates = candidates; if (candidates.Count == 1) return (bestRow, bestCol, bestCandidates); } } } return (bestRow, bestCol, bestCandidates); }这个函数返回候选数最少的空格及其候选列表,求解器改成先调它、再遍历候选。参数上candidates.Count == 1时直接返回,是因为唯一候选无需再比较,属于提前退出。把Solve里的「找第一个空格」换成「找最优空格」,代码改动不大,但求解速度在难题上能差出几倍。这也是从「能解」到「解得快」的关键一步。
3.3 出题唯一性验证的调用方式
挖洞出题时,每挖一个格子都要验证剩余盘面是否唯一解。做法是先复制当前盘面,调求解器数解的个数,超过 1 就说明挖多了,把格子填回去。
// 统计解的个数,用于唯一性验证 private int CountSolutions(int[,] grid, int limit = 2) { // 找到第一个空格 for (int row = 0; row < 9; row++) for (int col = 0; col < 9; col++) if (grid[row, col] == 0) { int count = 0; for (int num = 1; num <= 9; num++) { if (IsValidOn(grid, row, col, num)) { grid[row, col] = num; count += CountSolutions(grid, limit - count); grid[row, col] = 0; if (count >= limit) return count; // 剪枝:够 limit 就停 } } return count; } return 1; // 没有空格,说明找到一个解 }参数limit是提前终止阈值,默认 2 表示只要发现第二个解就立刻返回,不用数完所有解。这个剪枝在出题阶段非常重要,否则每挖一个洞都完整数一遍解,出题会慢到没法用。注意这里用的是独立的IsValidOn和传入的grid,不能直接复用成员变量上的board,否则会污染当前盘面。
4. 编译运行与界面交互:从 .sln 到可点击的 9x9 网格
算法看懂了,接下来要让它真正跑起来。这一章讲环境准备、编译步骤,以及SudokuPanel是怎么把二维数组变成可交互网格的。
4.1 开发环境准备与编译步骤
这份工程是 WinForm 项目,只能在 Windows 上跑(用 Wine 或跨平台方案不在本文讨论范围)。环境准备按下面几步走:
- 安装 Visual Studio,勾选「.NET 桌面开发」工作负载,这会带上 WinForm 所需的全部组件。
- 确认目标框架。打开
Sudoku.csproj,看<TargetFramework>节点,常见是net48或net6.0-windows这类。如果本机没装对应框架,VS 会提示你改目标或装运行时。 - 双击
Sudoku.sln,等待解决方案加载完成。 - 在解决方案资源管理器里右键
Sudoku项目,选择「设为启动项目」。 - 按 F5 或点「启动」按钮编译运行。
如果编译报错集中在FrmSudoku.Designer.cs,多半是设计器版本差异导致的,可以先尝试「重新生成解决方案」;仍然报错就检查.csproj里的目标框架是否和本机 SDK 匹配。bin和obj目录可以直接删掉重新生成,不影响源码。
4.2 SudokuPanel 的绘制与鼠标交互
SudokuPanel.cs是一个继承自Panel或Control的自定义控件,核心工作是重写OnPaint画格子和数字,重写鼠标事件处理点击。
// 绘制 9x9 网格与数字(简化示意) protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; int cellSize = this.Width / 9; // 假设控件为正方形 for (int row = 0; row < 9; row++) { for (int col = 0; col < 9; col++) { Rectangle rect = new Rectangle(col * cellSize, row * cellSize, cellSize, cellSize); g.DrawRectangle(Pens.Black, rect); // 画格子边框 if (board[row, col] != 0) { // 题目给定用黑色,玩家填入用蓝色 Brush brush = isFixed[row, col] ? Brushes.Black : Brushes.Blue; g.DrawString(board[row, col].ToString(), this.Font, brush, rect, new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }); } } } // 加粗 3x3 小九宫格的分隔线 for (int i = 0; i <= 9; i += 3) { g.DrawLine(new Pen(Color.Black, 2), i * cellSize, 0, i * cellSize, 9 * cellSize); g.DrawLine(new Pen(Color.Black, 2), 0, i * cellSize, 9 * cellSize, i * cellSize); } }逻辑说明:cellSize由控件宽度除以 9 得到,所以控件本身要保持正方形,否则格子会变形。数字用DrawString居中绘制,StringFormat负责水平和垂直居中。最后那个循环每隔 3 格画一条粗线,把 9x9 分成 9 个 3x3 区域,这是数独界面的视觉刚需,少了它玩家很难看清宫格边界。鼠标点击则通过MouseDown事件把坐标换算成行列索引,再更新选中状态并触发重绘。
4.3 出题与解题按钮的事件串联
FrmSudoku.cs里的按钮事件是算法和界面的粘合层,逻辑不复杂但顺序要对:
// 「出题」按钮 private void btnGenerate_Click(object sender, EventArgs e) { sudoku.Generate(); // 生成终盘并挖洞 sudokuPanel.Bind(sudoku.Board, sudoku.IsFixed); // 绑定数据 sudokuPanel.Invalidate(); // 触发重绘 } // 「解题」按钮 private void btnSolve_Click(object sender, EventArgs e) { if (sudoku.Solve()) { sudokuPanel.Invalidate(); // 求解成功,刷新界面 } else { MessageBox.Show("当前盘面无解,请检查输入。"); } }参数和调用顺序说明:Generate内部完成终盘生成和挖洞,Bind把盘面数据和固定标记传给控件,Invalidate通知系统重绘。解题按钮先调Solve,成功才刷新,失败给提示。这里有个容易忽略的点——如果玩家手动改过格子,Solve会直接在当前盘面上继续解,所以「解题」前最好确认盘面没有冲突数字,否则会得到无解提示。
5. 避坑与常见问题排查:那些让程序跑不起来的细节
这份源码整体不复杂,但实际动手时踩坑的地方还挺集中。下面几条是我自己复现和帮人排查时遇到最多的,按「现象 → 原因 → 解决」列出来。
现象一:编译通过但运行时报「未将对象引用设置到对象的实例」。原因通常是SudokuPanel在窗体初始化时还没绑定盘面数据,OnPaint里直接访问了空的board数组。解决方式是在控件构造函数里先初始化一个全零的 9x9 数组,或者在OnPaint开头加空值判断,数据没绑定时只画网格不画数字。
现象二:出题按钮点下去界面卡住几秒。原因是挖洞阶段每个格子都完整数了一遍解,没有加提前终止。解决方式是给CountSolutions加limit参数,发现第二个解就立刻返回,同时挖洞数量设一个上限,不要无限制挖到接近空盘。
现象三:解题结果和预期不符,或者提示无解。多半是盘面里存在冲突数字——玩家手动填了一个和同行/同列/同宫冲突的数,求解器从冲突盘面出发自然无解。解决方式是在解题前先跑一遍全盘校验,发现冲突就高亮提示,而不是直接丢给求解器。
现象四:格子点击后数字显示位置偏移。原因是cellSize用整数除法算的,控件宽度不能被 9 整除时会有余数,累积到右边和下边就偏了。解决方式是让控件宽度固定为 9 的倍数,或者用浮点计算格子边界再取整,绘制时统一用同一套坐标。
现象五:换台机器打开 .sln 提示目标框架不受支持。原因是.csproj里写的目标框架本机没装。解决方式是右键项目 → 属性 → 目标框架,改成你本机已安装的版本;或者去装对应版本的 .NET 运行时/SDK。改完记得清理bin、obj再重新生成。
6. 进阶改造与验证:把这份源码变成你自己的数独工具
源码能跑通之后,真正有意思的是按自己的需求改。这里给几个可落地的改造方向和验证方法,都是在这份代码基础上小步改动就能见效的。
第一个方向是难度分级。现在的挖洞数量是固定的或随机的,你可以按挖洞数分三档:简单留 40 个左右已知数,中等留 30 个,困难留 25 个以下。改法是在Generate里加一个难度参数,控制挖洞循环的终止条件。验证方法是连续出 20 题,用求解器反解一遍,确认每题都是唯一解且难度符合预期。
第二个方向是加计时和错误计数。在FrmSudoku里放一个Timer控件,出题时启动、解题成功时停止,界面上显示用时;玩家每填一个和正确答案不符的数就累加错误次数。这里要注意,判断「和正确答案不符」需要你在出题时把完整终盘存一份副本,不能只存挖洞后的题目。
第三个方向是把求解器换成更高效的版本。前面讲的「最少候选优先」已经能应付绝大多数盘面,如果你想再进一步,可以研究舞蹈链(Dancing Links)算法,它在处理极端数独时优势明显。不过对 9x9 标准数独来说,优化过的回溯已经足够,上舞蹈链更多是学习价值。
验证改造是否成功,我一般会走一套固定流程:先跑 100 次随机出题,统计平均耗时和唯一解比例;再拿几道公认的困难题手动输入,看求解器能否秒解;最后把界面在不同 DPI 缩放下过一遍,确认格子不变形、数字不重叠。这套流程走下来,基本能覆盖大部分回归问题。
说个我自己的习惯:每次改完算法相关代码,我都会先写一个临时的控制台测试入口,把出题和求解各跑一千次,看有没有异常和性能退化,确认没问题再接回 WinForm 界面。这个习惯帮我省过好几次「界面点着没事、批量跑就崩」的后悔药。希望这份拆解能帮你把这份 C# 数独源码真正用起来,改出属于自己的版本。
本文还有配套的精品资源,点击获取