Flutter for OpenHarmony数独游戏App实战:数独谜题生成算法
本文详细介绍了数独谜题生成的算法原理和实现方法。核心流程分为两个阶段:首先生成完整的数独解答,然后挖去部分数字形成谜题。生成解答采用递归回溯算法,随机打乱数字顺序增加多样性。挖空阶段根据难度级别控制挖去数字数量,简单难度保留43个数字,专家级仅保留23个。文章还实现了每日挑战功能,通过日期种子确保同一天生成相同谜题。算法通过行列和宫格检查确保数独规则,使用深拷贝避免修改原数组,为不同难度提供合理挑
数独谜题的生成是整个游戏的核心技术之一。一个好的生成算法不仅要能产生有效的谜题,还要保证谜题有唯一解,并且能够根据难度级别控制谜题的复杂程度。今天我们来详细讲解数独谜题生成的算法原理和实现。
在开始编码之前,我们需要理解数独谜题生成的基本思路。整个过程分为两个阶段:首先生成一个完整的数独解答,然后从这个解答中挖去一定数量的数字形成谜题。挖去的数字越多,谜题越难。这种方法保证了生成的谜题一定有解,而且解就是我们最初生成的那个完整解答。
让我们从创建PuzzleGenerator类开始。
import 'dart:math';
class PuzzleGenerator {
final Random _random = Random();
我们导入dart:math库来使用Random类生成随机数。_random是一个私有的Random实例,用于在算法中产生随机选择。使用类级别的Random实例而不是每次都创建新的,可以获得更好的随机性分布。
生成完整解答的入口方法。
List<List<int>> generateSolution() {
List<List<int>> board = List.generate(9, (_) => List.filled(9, 0));
_fillBoard(board);
return board;
}
generateSolution方法创建一个9x9的二维数组,初始值全为0,然后调用_fillBoard方法填充数字。List.generate创建外层列表,List.filled创建内层列表并填充0。这种初始化方式简洁高效,是Dart中创建二维数组的常用模式。
核心的填充算法使用回溯法。
bool _fillBoard(List<List<int>> board) {
for (int row = 0; row < 9; row++) {
for (int col = 0; col < 9; col++) {
if (board[row][col] == 0) {
List<int> numbers = List.generate(9, (i) => i + 1)..shuffle(_random);
for (int num in numbers) {
if (_isValid(board, row, col, num)) {
board[row][col] = num;
if (_fillBoard(board)) return true;
board[row][col] = 0;
}
}
return false;
}
}
}
return true;
}
_fillBoard使用递归回溯算法填充棋盘。它遍历每个单元格,找到第一个空格后,尝试填入1-9中的某个数字。为了增加随机性,我们先将1-9打乱顺序再尝试。如果某个数字在当前位置有效,就填入并递归处理下一个空格。如果递归成功返回true,否则回溯,将当前格子重置为0并尝试下一个数字。当所有格子都填满时返回true。
验证数字是否有效的方法。
bool _isValid(List<List<int>> board, int row, int col, int num) {
for (int i = 0; i < 9; i++) {
if (board[row][i] == num) return false;
}
for (int i = 0; i < 9; i++) {
if (board[i][col] == num) return false;
}
_isValid检查在指定位置填入某个数字是否违反数独规则。首先检查同一行是否已经有这个数字,遍历该行的所有列。然后检查同一列是否已经有这个数字,遍历该列的所有行。如果发现重复,立即返回false。
检查3x3宫格的冲突。
int boxRow = (row ~/ 3) * 3;
int boxCol = (col ~/ 3) * 3;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (board[boxRow + i][boxCol + j] == num) return false;
}
}
return true;
}
计算当前单元格所在3x3宫格的左上角坐标,然后遍历该宫格内的所有9个单元格检查是否有重复。整除运算row ~/ 3得到宫格的行索引(0、1或2),乘以3得到宫格左上角的行坐标。如果三项检查都通过,返回true表示这个数字可以填入。
根据难度创建谜题的方法。
List<List<int>> createPuzzle(List<List<int>> solution, String difficulty) {
List<List<int>> puzzle = solution.map((row) => List<int>.from(row)).toList();
int cellsToRemove = _getCellsToRemove(difficulty);
List<List<int>> positions = [];
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
positions.add([i, j]);
}
}
positions.shuffle(_random);
createPuzzle接收完整解答和难度级别,返回挖去部分数字后的谜题。首先深拷贝solution避免修改原数组,使用map和List.from确保创建的是新的列表对象。然后根据难度计算需要挖去的数字数量。创建一个包含所有81个位置的列表并打乱顺序,这样挖去的位置是随机的。
执行挖空操作。
int removed = 0;
for (var pos in positions) {
if (removed >= cellsToRemove) break;
puzzle[pos[0]][pos[1]] = 0;
removed++;
}
return puzzle;
}
遍历打乱后的位置列表,依次将对应单元格设为0,直到达到需要挖去的数量。这种简单的挖空策略可能产生有多个解的谜题,但对于休闲游戏来说是可以接受的。如果需要保证唯一解,需要在挖空后验证,这会增加算法复杂度。
根据难度确定挖空数量。
int _getCellsToRemove(String difficulty) {
switch (difficulty) {
case 'Easy': return 81 - 43;
case 'Medium': return 81 - 33;
case 'Hard': return 81 - 28;
case 'Expert': return 81 - 23;
default: return 81 - 43;
}
}
不同难度对应不同的初始数字数量。简单级别保留43个数字,挖去38个;中等保留33个;困难保留28个;专家保留23个。这些数值是经过测试的,能够产生合适难度的谜题。数字越少,玩家需要推理的步骤越多,谜题越难。
每日挑战的生成方法。
List<List<int>> generateDailyChallenge(DateTime date) {
final seed = date.year * 10000 + date.month * 100 + date.day;
final dailyRandom = Random(seed);
List<List<int>> board = List.generate(9, (_) => List.filled(9, 0));
_fillBoardWithRandom(board, dailyRandom);
每日挑战使用日期作为随机种子,这样同一天生成的谜题是相同的。种子的计算方式是年份乘以10000加月份乘以100加日期,比如2024年3月15日的种子是20240315。使用固定种子的Random可以产生可重复的随机序列,保证所有玩家在同一天看到相同的谜题。
使用指定Random的填充方法。
bool _fillBoardWithRandom(List<List<int>> board, Random random) {
for (int row = 0; row < 9; row++) {
for (int col = 0; col < 9; col++) {
if (board[row][col] == 0) {
List<int> numbers = List.generate(9, (i) => i + 1);
for (int i = numbers.length - 1; i > 0; i--) {
int j = random.nextInt(i + 1);
var temp = numbers[i];
numbers[i] = numbers[j];
numbers[j] = temp;
}
_fillBoardWithRandom与_fillBoard类似,但使用传入的Random实例而不是类级别的_random。打乱数组使用Fisher-Yates洗牌算法手动实现,而不是调用shuffle方法,因为我们需要使用指定的Random实例。这确保了使用相同种子时产生相同的打乱结果。
继续填充逻辑。
for (int num in numbers) {
if (_isValid(board, row, col, num)) {
board[row][col] = num;
if (_fillBoardWithRandom(board, random)) return true;
board[row][col] = 0;
}
}
return false;
}
}
}
return true;
}
这部分与_fillBoard完全相同,使用回溯法尝试填充每个空格。递归调用时传递同一个Random实例,保证整个填充过程使用一致的随机源。这种设计让每日挑战的生成是确定性的,同一天的谜题永远相同。
每日挑战的挖空处理。
List<List<int>> puzzle = board.map((row) => List<int>.from(row)).toList();
int cellsToRemove = 81 - 30;
List<List<int>> positions = [];
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
positions.add([i, j]);
}
}
for (int i = positions.length - 1; i > 0; i--) {
int j = dailyRandom.nextInt(i + 1);
var temp = positions[i];
positions[i] = positions[j];
positions[j] = temp;
}
每日挑战固定保留30个数字,难度介于中等和困难之间。位置列表的打乱同样使用dailyRandom,确保挖空位置也是确定性的。手动实现洗牌算法而不是使用shuffle,因为shuffle使用的是全局Random。
完成挖空并返回。
for (int i = 0; i < cellsToRemove; i++) {
puzzle[positions[i][0]][positions[i][1]] = 0;
}
return puzzle;
}
}
按照打乱后的顺序挖去指定数量的数字,返回最终的谜题。整个每日挑战的生成过程完全由日期决定,不依赖任何外部状态,这是实现"每日挑战"功能的关键。
让我们深入理解回溯算法的工作原理。回溯法是一种系统性的搜索方法,它尝试所有可能的解,当发现当前路径无法得到有效解时,回退到上一步尝试其他选择。
// 回溯算法的伪代码表示
bool solve(board, position) {
if (position == 81) {
return true; // 所有位置都填完了
}
row = position / 9;
col = position % 9;
if (board[row][col] != 0) {
return solve(board, position + 1); // 跳过已填的格子
}
for (num = 1; num <= 9; num++) {
if (isValid(board, row, col, num)) {
board[row][col] = num;
if (solve(board, position + 1)) {
return true;
}
board[row][col] = 0; // 回溯
}
}
return false; // 所有数字都试过了,无解
}
这个伪代码展示了回溯算法的核心逻辑。从位置0开始,依次尝试填入1-9。如果某个数字有效,就填入并递归处理下一个位置。如果递归返回false,说明这条路走不通,需要回溯:将当前格子重置为0,尝试下一个数字。如果所有数字都试过了还是无解,返回false让上一层继续回溯。
算法的时间复杂度分析。
// 最坏情况下的时间复杂度是 O(9^81)
// 但实际上由于约束剪枝,平均复杂度远低于此
// 对于有效的数独,通常在毫秒级别完成
// 空间复杂度是 O(81) = O(1)
// 递归深度最多81层,每层使用常数空间
理论上回溯算法的时间复杂度是指数级的,但由于数独的约束条件很强,大部分分支在早期就被剪掉了。实际测试中,生成一个完整解答通常只需要几毫秒。空间复杂度是常数级的,因为棋盘大小固定为9x9。
优化:使用位运算加速验证。
class OptimizedPuzzleGenerator {
final Random _random = Random();
// 使用位掩码记录每行、每列、每宫格已使用的数字
List<int> rowMask = List.filled(9, 0);
List<int> colMask = List.filled(9, 0);
List<int> boxMask = List.filled(9, 0);
bool isValidOptimized(int row, int col, int num) {
int bit = 1 << num;
int boxIndex = (row ~/ 3) * 3 + col ~/ 3;
return (rowMask[row] & bit) == 0 &&
(colMask[col] & bit) == 0 &&
(boxMask[boxIndex] & bit) == 0;
}
使用位掩码可以将验证操作从O(27)优化到O(1)。每个掩码是一个整数,第i位为1表示数字i已被使用。验证时只需要检查对应位是否为0,使用位与运算即可。这种优化在需要大量验证的场景下效果显著。
更新位掩码的方法。
void setNumber(int row, int col, int num) {
int bit = 1 << num;
int boxIndex = (row ~/ 3) * 3 + col ~/ 3;
rowMask[row] |= bit;
colMask[col] |= bit;
boxMask[boxIndex] |= bit;
}
void clearNumber(int row, int col, int num) {
int bit = ~(1 << num);
int boxIndex = (row ~/ 3) * 3 + col ~/ 3;
rowMask[row] &= bit;
colMask[col] &= bit;
boxMask[boxIndex] &= bit;
}
setNumber在填入数字时更新掩码,使用位或运算设置对应位。clearNumber在回溯时清除掩码,使用位与运算和取反清除对应位。这两个操作都是O(1)的,配合isValidOptimized可以大幅提升算法性能。
保证唯一解的挖空策略。
List<List<int>> createUniquePuzzle(List<List<int>> solution, String difficulty) {
List<List<int>> puzzle = solution.map((row) => List<int>.from(row)).toList();
int targetRemove = _getCellsToRemove(difficulty);
List<List<int>> positions = [];
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
positions.add([i, j]);
}
}
positions.shuffle(_random);
int removed = 0;
for (var pos in positions) {
if (removed >= targetRemove) break;
int row = pos[0];
int col = pos[1];
int backup = puzzle[row][col];
puzzle[row][col] = 0;
if (_hasUniqueSolution(puzzle)) {
removed++;
} else {
puzzle[row][col] = backup; // 恢复,这个位置不能挖
}
}
return puzzle;
}
这个方法在挖空每个位置后检查谜题是否仍有唯一解。如果挖空后有多个解,就恢复这个位置的数字,尝试下一个位置。这种策略保证了生成的谜题一定有唯一解,但代价是生成时间会显著增加。
检查唯一解的方法。
bool _hasUniqueSolution(List<List<int>> puzzle) {
int solutionCount = 0;
_countSolutions(puzzle, 0, (count) {
solutionCount = count;
});
return solutionCount == 1;
}
void _countSolutions(List<List<int>> board, int count, Function(int) callback) {
// 使用回溯法计数解的数量
// 当找到第二个解时立即停止
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(board, row, col, num)) {
board[row][col] = num;
_countSolutions(board, count, callback);
board[row][col] = 0;
}
}
return;
}
}
}
count++;
callback(count);
if (count >= 2) return; // 找到两个解就够了
}
_countSolutions使用回溯法计算解的数量,但当找到第二个解时立即停止,因为我们只需要知道是否有唯一解。这个优化避免了枚举所有解的开销。对于大多数有效的数独谜题,这个检查很快就能完成。
难度评估算法。
int evaluateDifficulty(List<List<int>> puzzle) {
int score = 0;
// 空格数量
int emptyCount = 0;
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (puzzle[i][j] == 0) emptyCount++;
}
}
score += emptyCount * 10;
// 需要的推理技巧
score += _countNakedSingles(puzzle) * 1;
score += _countHiddenSingles(puzzle) * 2;
score += _countNakedPairs(puzzle) * 5;
// ... 更多技巧的评估
return score;
}
难度评估不仅考虑空格数量,还考虑解题需要的技巧。裸单(Naked Single)是最简单的技巧,隐藏单(Hidden Single)稍难,裸对(Naked Pair)更难。通过分析谜题需要哪些技巧才能解出,可以更准确地评估难度。
总结一下数独谜题生成算法的关键要点。首先是使用回溯法生成完整解答,通过随机打乱尝试顺序增加多样性。其次是根据难度控制挖空数量,数字越少谜题越难。然后是每日挑战使用日期作为随机种子,保证同一天的谜题相同。最后是可选的唯一解验证,确保谜题质量。
数独谜题生成是一个经典的算法问题,涉及回溯搜索、约束满足、随机化等多个概念。理解这些算法不仅有助于实现数独游戏,也能提升解决其他组合优化问题的能力。
在实际应用中,我们还需要考虑谜题的对称性。对称的谜题在视觉上更加美观。
List<List<int>> createSymmetricPuzzle(List<List<int>> solution, String difficulty) {
List<List<int>> puzzle = solution.map((row) => List<int>.from(row)).toList();
int pairsToRemove = _getCellsToRemove(difficulty) ~/ 2;
List<List<int>> positions = [];
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (i < 5 || (i == 4 && j < 5)) {
positions.add([i, j]);
}
}
}
positions.shuffle(_random);
createSymmetricPuzzle生成中心对称的谜题。只遍历棋盘的一半位置,每次挖空时同时挖去对称位置。这样生成的谜题具有180度旋转对称性,看起来更加规整美观。
对称挖空的实现。
int removed = 0;
for (var pos in positions) {
if (removed >= pairsToRemove) break;
int row = pos[0];
int col = pos[1];
int symRow = 8 - row;
int symCol = 8 - col;
puzzle[row][col] = 0;
puzzle[symRow][symCol] = 0;
removed++;
}
if (_getCellsToRemove(difficulty) % 2 == 1) {
puzzle[4][4] = 0;
}
return puzzle;
}
对于每个位置,计算其关于中心点(4,4)的对称位置,同时将两个位置设为0。如果需要挖去奇数个数字,最后单独处理中心点。这种对称设计让谜题在视觉上更加平衡。
谜题的难度验证也是一个重要功能。
class DifficultyAnalyzer {
int analyzeDifficulty(List<List<int>> puzzle) {
int score = 0;
int emptyCount = 0;
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (puzzle[i][j] == 0) emptyCount++;
}
}
score += emptyCount * 10;
int nakedSingles = _countNakedSingles(puzzle);
int hiddenSingles = _countHiddenSingles(puzzle);
score += nakedSingles * 1;
score += hiddenSingles * 3;
return score;
}
DifficultyAnalyzer分析谜题的实际难度。不仅考虑空格数量,还考虑解题需要的技巧。裸单是最简单的技巧,权重为1;隐藏单稍难,权重为3。这种分析可以更准确地评估谜题难度。
裸单计数的实现。
int _countNakedSingles(List<List<int>> puzzle) {
int count = 0;
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (puzzle[i][j] == 0) {
Set<int> candidates = _getCandidates(puzzle, i, j);
if (candidates.length == 1) count++;
}
}
}
return count;
}
Set<int> _getCandidates(List<List<int>> puzzle, int row, int col) {
Set<int> candidates = {1, 2, 3, 4, 5, 6, 7, 8, 9};
for (int i = 0; i < 9; i++) {
candidates.remove(puzzle[row][i]);
candidates.remove(puzzle[i][col]);
}
裸单是指某个空格只有一个可能的候选数。_getCandidates计算指定位置的所有候选数,如果只有一个候选数,就是裸单。裸单越多,谜题越容易,因为玩家可以直接填入确定的数字。
候选数计算的完整实现。
int boxRow = (row ~/ 3) * 3;
int boxCol = (col ~/ 3) * 3;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
candidates.remove(puzzle[boxRow + i][boxCol + j]);
}
}
return candidates;
}
}
从候选集合中移除同行、同列、同宫格中已存在的数字。剩下的就是该位置的有效候选数。这个方法是数独解题算法的基础,也是难度分析的核心。
谜题的变换可以从一个谜题生成多个等价谜题。
class PuzzleTransformer {
final Random _random = Random();
List<List<int>> transform(List<List<int>> puzzle) {
List<List<int>> result = puzzle.map((row) => List<int>.from(row)).toList();
int transformType = _random.nextInt(4);
switch (transformType) {
case 0:
result = _swapRows(result);
break;
case 1:
result = _swapCols(result);
break;
case 2:
result = _swapBandRows(result);
break;
case 3:
result = _swapStackCols(result);
break;
}
return result;
}
PuzzleTransformer通过行列交换生成等价谜题。数独有一些保持有效性的变换:同一宫格内的行可以交换,同一宫格内的列可以交换,整个行带可以交换,整个列带可以交换。这些变换不改变谜题的本质,但可以产生看起来不同的谜题。
行交换的实现。
List<List<int>> _swapRows(List<List<int>> puzzle) {
int band = _random.nextInt(3);
int row1 = band * 3 + _random.nextInt(3);
int row2 = band * 3 + _random.nextInt(3);
if (row1 != row2) {
var temp = puzzle[row1];
puzzle[row1] = puzzle[row2];
puzzle[row2] = temp;
}
return puzzle;
}
_swapRows在同一个行带内随机选择两行进行交换。band确定是哪个行带(0、1或2),然后在该行带内随机选择两行。只有同一行带内的行才能交换,否则会破坏数独的有效性。
数字替换也是一种有效的变换。
List<List<int>> _substituteNumbers(List<List<int>> puzzle) {
List<int> mapping = List.generate(10, (i) => i);
List<int> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
numbers.shuffle(_random);
for (int i = 0; i < 9; i++) {
mapping[i + 1] = numbers[i];
}
return puzzle.map((row) {
return row.map((cell) {
return cell == 0 ? 0 : mapping[cell];
}).toList();
}).toList();
}
}
_substituteNumbers将数字1-9随机映射到另一组1-9。比如原来的1变成5,2变成3等。这种变换不改变谜题的逻辑结构,但数字完全不同,看起来是一个全新的谜题。
谜题的序列化和反序列化便于存储和传输。
class PuzzleSerializer {
String serialize(List<List<int>> puzzle) {
StringBuffer buffer = StringBuffer();
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
buffer.write(puzzle[i][j] == 0 ? '.' : puzzle[i][j].toString());
}
}
return buffer.toString();
}
List<List<int>> deserialize(String str) {
if (str.length != 81) {
throw ArgumentError('Invalid puzzle string length');
}
List<List<int>> puzzle = List.generate(9, (_) => List.filled(9, 0));
for (int i = 0; i < 81; i++) {
int row = i ~/ 9;
int col = i % 9;
String char = str[i];
puzzle[row][col] = char == '.' ? 0 : int.parse(char);
}
return puzzle;
}
}
serialize将9x9的谜题压缩为81字符的字符串,空格用点号表示。deserialize将字符串还原为二维数组。这种格式紧凑且易于处理,是数独谜题的标准表示方式。
谜题的验证确保生成的谜题是有效的。
class PuzzleValidator {
bool isValidPuzzle(List<List<int>> puzzle) {
for (int i = 0; i < 9; i++) {
if (!_isValidGroup(_getRow(puzzle, i))) return false;
if (!_isValidGroup(_getCol(puzzle, i))) return false;
if (!_isValidGroup(_getBox(puzzle, i))) return false;
}
return true;
}
bool _isValidGroup(List<int> group) {
Set<int> seen = {};
for (int num in group) {
if (num == 0) continue;
if (num < 1 || num > 9) return false;
if (seen.contains(num)) return false;
seen.add(num);
}
return true;
}
isValidPuzzle检查谜题是否满足数独规则。遍历所有行、列、宫格,检查是否有重复数字。_isValidGroup检查一组9个数字是否有效,忽略空格,检查是否有重复。
获取行、列、宫格的辅助方法。
List<int> _getRow(List<List<int>> puzzle, int row) {
return puzzle[row];
}
List<int> _getCol(List<List<int>> puzzle, int col) {
return List.generate(9, (i) => puzzle[i][col]);
}
List<int> _getBox(List<List<int>> puzzle, int box) {
int startRow = (box ~/ 3) * 3;
int startCol = (box % 3) * 3;
List<int> result = [];
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
result.add(puzzle[startRow + i][startCol + j]);
}
}
return result;
}
}
_getRow直接返回指定行。_getCol通过遍历每行的指定列构建列数组。_getBox根据宫格索引计算起始位置,然后收集该宫格内的所有数字。这些辅助方法让验证逻辑更加清晰。
总结一下数独谜题生成算法的完整技术体系。核心是回溯法生成完整解答,然后通过挖空创建谜题。在此基础上,我们可以添加对称性控制、难度分析、谜题变换、序列化等功能。这些技术的组合让我们能够生成高质量、多样化的数独谜题,满足不同玩家的需求。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)