数独谜题的生成是整个游戏的核心技术之一。一个好的生成算法不仅要能产生有效的谜题,还要保证谜题有唯一解,并且能够根据难度级别控制谜题的复杂程度。今天我们来详细讲解数独谜题生成的算法原理和实现。
请添加图片描述

在开始编码之前,我们需要理解数独谜题生成的基本思路。整个过程分为两个阶段:首先生成一个完整的数独解答,然后从这个解答中挖去一定数量的数字形成谜题。挖去的数字越多,谜题越难。这种方法保证了生成的谜题一定有解,而且解就是我们最初生成的那个完整解答。

让我们从创建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

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐