每日算法 — 使用java实现纵横字谜:回溯搜索与约束满足

引言

纵横字谜(Crossword Puzzle)是报纸和杂志上最常见的益智游戏之一。玩家需要根据提示,在一个由黑白格组成的网格中填入单词,使得横向和纵向的单词都符合对应的线索。对于程序而言,这本质上是一个约束满足问题(Constraint Satisfaction Problem, CSP)。

本文将用Java实现一个完整的纵横字谜自动求解器,通过回溯搜索结合约束传播技术,高效地填充字谜网格。算法核心借鉴了人工智能领域中经典的CSP求解框架,包括变量排序、值排序、前向检查等优化策略。

问题建模为CSP

变量(Variables)

字谜网格中的每个空白序列(横向或纵向的连续白格)就是一个变量。每个变量有:
– 位置:起始坐标和方向(横/纵)
– 长度:空白格的数量
– 域(Domain):所有长度匹配且符合当前约束的候选单词

约束(Constraints)

  1. 交叉约束:两个变量相交的格子必须填入相同的字母
  2. 唯一性约束(可选):同一个单词不能重复使用
  3. 线索约束:某些位置可能有预设的字母(如固定提示)

核心算法设计

1. 网格解析

将二维字符网格解析为变量集合。例如:

# # # # #
# _ _ _ #
# _ # _ #
# _ _ _ #
# # # # #

解析出:
– 横向变量:(1,1)向右长度3,(3,1)向右长度3
– 纵向变量:(1,1)向下长度3,(1,2)向下长度1,(1,3)向下长度3

2. 回溯搜索

function Backtrack(assignment):
    if assignment is complete: return assignment
    var = SelectUnassignedVariable()
    for value in OrderDomainValues(var):
        if value is consistent with assignment:
            add {var = value} to assignment
            inferences = ForwardCheck(var, value)
            if inferences != failure:
                add inferences to assignment
                result = Backtrack(assignment)
                if result != failure: return result
                remove inferences from assignment
            remove {var = value} from assignment
    return failure

3. 约束传播:前向检查

每当一个变量被赋值后,立即检查所有与之相邻的未赋值变量,从它们的域中移除因交叉约束而变得不可能的取值。如果某个变量的域为空,则立即回溯。

Java实现

import java.util.*;

/**
 * 纵横字谜自动求解器
 * 使用回溯搜索 + 约束传播(前向检查)求解CSP问题
 */
public class CrosswordSolver {

    // 网格中黑格用'#'表示,白格用'_'表示,已填字母直接写
    private char[][] grid;
    private final int rows;
    private final int cols;

    // 候选词库:按长度分组存储
    private final Map<Integer, List<String>> wordDict;

    // 解析出的所有变量(空白序列)
    private List<Variable> variables;

    // 变量之间的交叉约束关系
    private Map<Variable, List<Constraint>> constraints;

    /**
     * 表示字谜中的一个变量(横向或纵向的连续白格序列)
     */
    static class Variable {
        final int row;          // 起始行
        final int col;          // 起始列
        final int length;       // 长度
        final boolean horizontal; // true=横向, false=纵向
        final String id;        // 唯一标识

        List<String> domain;    // 候选单词域
        String assignedValue;   // 当前赋值(null表示未赋值)

        public Variable(int row, int col, int length, boolean horizontal) {
            this.row = row;
            this.col = col;
            this.length = length;
            this.horizontal = horizontal;
            this.id = (horizontal ? "H" : "V") + "_" + row + "_" + col;
            this.domain = new ArrayList<>();
            this.assignedValue = null;
        }

        /**
         * 获取该变量第idx个格子的全局坐标
         */
        public int[] getCellPosition(int idx) {
            if (horizontal) {
                return new int[]{row, col + idx};
            } else {
                return new int[]{row + idx, col};
            }
        }

        @Override
        public String toString() {
            return id + "(len=" + length + ")";
        }
    }

    /**
     * 表示两个变量之间的交叉约束
     */
    static class Constraint {
        final Variable var1;
        final Variable var2;
        final int pos1;  // var1中交叉的位置
        final int pos2;  // var2中交叉的位置

        public Constraint(Variable var1, int pos1, Variable var2, int pos2) {
            this.var1 = var1;
            this.pos1 = pos1;
            this.var2 = var2;
            this.pos2 = pos2;
        }

        /**
         * 检查两个赋值是否满足交叉约束(对应位置字母相同)
         */
        public boolean isSatisfied(String val1, String val2) {
            if (val1 == null || val2 == null) return true;
            return val1.charAt(pos1) == val2.charAt(pos2);
        }
    }

    public CrosswordSolver(char[][] grid, List<String> words) {
        this.grid = new char[grid.length][];
        for (int i = 0; i < grid.length; i++) {
            this.grid[i] = grid[i].clone();
        }
        this.rows = grid.length;
        this.cols = grid[0].length;

        // 按长度组织词库
        this.wordDict = new HashMap<>();
        for (String word : words) {
            wordDict.computeIfAbsent(word.length(), k -> new ArrayList<>()).add(word);
        }

        // 解析变量和约束
        parseVariables();
        buildConstraints();
        initializeDomains();
    }

    /**
     * 解析网格中的所有变量(连续白格序列)
     */
    private void parseVariables() {
        variables = new ArrayList<>();

        // 解析横向变量
        for (int r = 0; r < rows; r++) {
            int c = 0;
            while (c < cols) {
                // 跳过黑格
                if (grid[r][c] == '#') {
                    c++;
                    continue;
                }
                // 找到连续白格的起始
                int start = c;
                while (c < cols && grid[r][c] != '#') {
                    c++;
                }
                int length = c - start;
                if (length >= 2) {  // 至少2格才构成有效单词位置
                    variables.add(new Variable(r, start, length, true));
                }
            }
        }

        // 解析纵向变量
        for (int c = 0; c < cols; c++) {
            int r = 0;
            while (r < rows) {
                if (grid[r][c] == '#') {
                    r++;
                    continue;
                }
                int start = r;
                while (r < rows && grid[r][c] != '#') {
                    r++;
                }
                int length = r - start;
                if (length >= 2) {
                    variables.add(new Variable(start, c, length, false));
                }
            }
        }

        System.out.println("解析出 " + variables.size() + " 个变量");
    }

    /**
     * 构建变量之间的交叉约束
     */
    private void buildConstraints() {
        constraints = new HashMap<>();
        for (Variable v : variables) {
            constraints.put(v, new ArrayList<>());
        }

        // 检查每对变量是否有交叉
        for (int i = 0; i < variables.size(); i++) {
            for (int j = i + 1; j < variables.size(); j++) {
                Variable v1 = variables.get(i);
                Variable v2 = variables.get(j);

                // 同方向不可能交叉
                if (v1.horizontal == v2.horizontal) continue;

                // 找到交叉点
                Variable hVar = v1.horizontal ? v1 : v2;
                Variable vVar = v1.horizontal ? v2 : v1;

                // 横向变量的行必须在纵向变量的列范围内
                // 纵向变量的列必须在横向变量的行范围内
                if (hVar.row >= vVar.row && hVar.row < vVar.row + vVar.length
                    && vVar.col >= hVar.col && vVar.col < hVar.col + hVar.length) {

                    int hPos = vVar.col - hVar.col;
                    int vPos = hVar.row - vVar.row;

                    Constraint c1 = new Constraint(hVar, hPos, vVar, vPos);
                    Constraint c2 = new Constraint(vVar, vPos, hVar, hPos);
                    constraints.get(hVar).add(c1);
                    constraints.get(vVar).add(c2);
                }
            }
        }
    }

    /**
     * 初始化每个变量的域(候选单词)
     * 同时考虑网格中已有的预设字母
     */
    private void initializeDomains() {
        for (Variable v : variables) {
            List<String> candidates = wordDict.getOrDefault(v.length, new ArrayList<>());
            v.domain = new ArrayList<>();

            for (String word : candidates) {
                if (matchesGridConstraints(v, word)) {
                    v.domain.add(word);
                }
            }
        }
    }

    /**
     * 检查单词是否符合网格中的预设字母约束
     */
    private boolean matchesGridConstraints(Variable v, String word) {
        for (int i = 0; i < v.length; i++) {
            int[] pos = v.getCellPosition(i);
            char gridChar = grid[pos[0]][pos[1]];
            if (gridChar != '_' && gridChar != word.charAt(i)) {
                return false;
            }
        }
        return true;
    }

    /**
     * MRV启发式:选择域最小的未赋值变量
     * (Minimum Remaining Values)
     */
    private Variable selectMRVVariable() {
        Variable best = null;
        int minDomainSize = Integer.MAX_VALUE;

        for (Variable v : variables) {
            if (v.assignedValue == null && v.domain.size() < minDomainSize) {
                minDomainSize = v.domain.size();
                best = v;
            }
        }
        return best;
    }

    /**
     * 度启发式:选择约束最多的未赋值变量(MRV相同时的打破平局策略)
     */
    private Variable selectDegreeVariable() {
        Variable best = null;
        int maxDegree = -1;

        for (Variable v : variables) {
            if (v.assignedValue == null) {
                int degree = constraints.get(v).size();
                if (degree > maxDegree) {
                    maxDegree = degree;
                    best = v;
                }
            }
        }
        return best;
    }

    /**
     * 综合选择策略:优先MRV,平局时选度最大的
     */
    private Variable selectVariable() {
        Variable mrv = selectMRVVariable();
        if (mrv == null) return null;

        // 检查是否有多个变量具有相同的最小域大小
        int minSize = mrv.domain.size();
        List<Variable> ties = new ArrayList<>();
        for (Variable v : variables) {
            if (v.assignedValue == null && v.domain.size() == minSize) {
                ties.add(v);
            }
        }

        if (ties.size() == 1) return mrv;

        // 平局时用度启发式
        Variable best = ties.get(0);
        int maxDegree = constraints.get(best).size();
        for (int i = 1; i < ties.size(); i++) {
            int degree = constraints.get(ties.get(i)).size();
            if (degree > maxDegree) {
                maxDegree = degree;
                best = ties.get(i);
            }
        }
        return best;
    }

    /**
     * 前向检查:赋值后更新相关变量的域
     * @return 如果某个变量域为空则返回null(失败)
     */
    private Map<Variable, List<String>> forwardCheck(Variable var, String value) {
        Map<Variable, List<String>> removed = new HashMap<>();

        for (Constraint c : constraints.get(var)) {
            Variable other = (c.var1 == var) ? c.var2 : c.var1;
            if (other.assignedValue != null) continue;

            List<String> toRemove = new ArrayList<>();
            for (String candidate : other.domain) {
                if (!c.isSatisfied(value, candidate)) {
                    toRemove.add(candidate);
                }
            }

            if (!toRemove.isEmpty()) {
                other.domain.removeAll(toRemove);
                removed.put(other, toRemove);

                // 如果域为空,约束传播失败
                if (other.domain.isEmpty()) {
                    // 恢复已移除的值
                    for (Map.Entry<Variable, List<String>> entry : removed.entrySet()) {
                        entry.getKey().domain.addAll(entry.getValue());
                    }
                    return null;
                }
            }
        }

        return removed;
    }

    /**
     * 恢复前向检查移除的域值
     */
    private void undoForwardCheck(Map<Variable, List<String>> removed) {
        if (removed == null) return;
        for (Map.Entry<Variable, List<String>> entry : removed.entrySet()) {
            entry.getKey().domain.addAll(entry.getValue());
            // 去重(安全处理)
            Set<String> unique = new HashSet<>(entry.getKey().domain);
            entry.getKey().domain.clear();
            entry.getKey().domain.addAll(unique);
        }
    }

    /**
     * 检查当前赋值是否满足所有约束
     */
    private boolean isConsistent() {
        for (Variable v : variables) {
            if (v.assignedValue == null) continue;
            for (Constraint c : constraints.get(v)) {
                Variable other = (c.var1 == v) ? c.var2 : c.var1;
                if (other.assignedValue != null) {
                    if (!c.isSatisfied(v.assignedValue, other.assignedValue)) {
                        return false;
                    }
                }
            }
        }
        return true;
    }

    /**
     * 核心回溯搜索算法
     */
    public boolean solve() {
        return backtrack();
    }

    private boolean backtrack() {
        // 检查是否所有变量都已赋值
        boolean allAssigned = true;
        for (Variable v : variables) {
            if (v.assignedValue == null) {
                allAssigned = false;
                break;
            }
        }
        if (allAssigned) return isConsistent();

        // 选择下一个变量(MRV + Degree启发式)
        Variable var = selectVariable();
        if (var == null) return true;

        // 按最少约束值(LCV)排序候选值
        List<String> orderedValues = orderByLCV(var);

        for (String value : orderedValues) {
            // 赋值
            var.assignedValue = value;

            // 前向检查
            Map<Variable, List<String>> removed = forwardCheck(var, value);

            if (removed != null) {
                // 递归搜索
                if (backtrack()) {
                    return true;
                }

                // 回溯:恢复域
                undoForwardCheck(removed);
            }

            // 撤销赋值
            var.assignedValue = null;
        }

        return false;
    }

    /**
     * LCV启发式:优先选择对其他变量约束最少的值
     * (Least Constraining Value)
     */
    private List<String> orderByLCV(Variable var) {
        List<Map.Entry<String, Integer>> scored = new ArrayList<>();

        for (String value : var.domain) {
            int constraintsCount = 0;
            for (Constraint c : constraints.get(var)) {
                Variable other = (c.var1 == var) ? c.var2 : c.var1;
                if (other.assignedValue != null) continue;

                for (String otherVal : other.domain) {
                    if (!c.isSatisfied(value, otherVal)) {
                        constraintsCount++;
                    }
                }
            }
            scored.add(new AbstractMap.SimpleEntry<>(value, constraintsCount));
        }

        scored.sort(Comparator.comparingInt(Map.Entry::getValue));
        List<String> result = new ArrayList<>();
        for (Map.Entry<String, Integer> e : scored) {
            result.add(e.getKey());
        }
        return result;
    }

    /**
     * 将求解结果填入网格并打印
     */
    public void printSolution() {
        char[][] result = new char[rows][cols];
        for (int r = 0; r < rows; r++) {
            result[r] = grid[r].clone();
        }

        for (Variable v : variables) {
            if (v.assignedValue != null) {
                for (int i = 0; i < v.length; i++) {
                    int[] pos = v.getCellPosition(i);
                    result[pos[0]][pos[1]] = v.assignedValue.charAt(i);
                }
            }
        }

        System.out.println("\n=== 字谜解答 ===");
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                System.out.print(result[r][c] + " ");
            }
            System.out.println();
        }
    }

    /**
     * 主程序:演示求解过程
     */
    public static void main(String[] args) {
        // 简单的5x5字谜网格
        char[][] grid = {
            {'#', '#', '#', '#', '#'},
            {'#', '_', '_', '_', '#'},
            {'#', '_', '#', '_', '#'},
            {'#', '_', '_', '_', '#'},
            {'#', '#', '#', '#', '#'}
        };

        // 候选词库(横向/纵向单词)
        List<String> words = Arrays.asList(
            "CAT", "BAT", "RAT", "HAT", "MAT",
            "CAR", "BAR", "ART", "ACT", "CAT",
            "CAB", "TAB", "TAR", "RAT"
        );

        CrosswordSolver solver = new CrosswordSolver(grid, words);

        System.out.println("开始求解纵横字谜...");
        boolean success = solver.solve();

        if (success) {
            System.out.println("求解成功!");
            solver.printSolution();

            // 打印每个变量的赋值
            System.out.println("\n变量赋值详情:");
            for (Variable v : solver.variables) {
                System.out.println(v + " = " + v.assignedValue);
            }
        } else {
            System.out.println("无解!请检查网格和词库。");
        }
    }
}

复杂度分析

  • 变量数量:设网格为 R × C,变量数量约为 O(R × C)(每个格子可能属于两个变量)。
  • 域大小:每个变量的候选词数量,取决于词库大小,记为 D。
  • 搜索空间最坏情况:O(D^V),其中 V 是变量数量。
  • MRV + 前向检查的实际效果:通过约束传播大幅剪枝,实际运行时间通常远低于理论最坏情况。对于典型报纸字谜(10×10网格,变量约30个),可在秒级求解。
  • 空间复杂度:O(V × D) 存储变量域和约束关系。

算法优化策略

  1. MRV(最小剩余值):优先处理选择最少的变量,能最快发现矛盾并回溯。
  2. 度启发式:当MRV平局时,选择约束最多的变量,因为它对未来分支的影响最大。
  3. LCV(最少约束值):优先尝试对其他变量影响最小的候选值,保留更多后续选择空间。
  4. AC-3(弧一致性):比前向检查更强的约束传播,可以进一步缩减搜索空间。
  5. 字典树(Trie)优化:将词库存储为Trie结构,在交叉约束检查时可快速过滤不满足前缀约束的单词。

现实应用扩展

纵横字谜求解器的核心CSP框架广泛应用于:
– 数独求解:9×9网格,每行/列/宫为约束
– 课程表排课:教师、教室、时间为变量,冲突为约束
– 电路板布局:元件位置为变量,间距为约束
– 密码破解:字符位置为变量,字典和模式为约束

总结

纵横字谜自动求解是约束满足问题的教科书级案例。通过将游戏规则精确建模为CSP,并运用MRV、度启发式、LCV和前向检查等经典优化策略,我们能够在可接受的时间内求解复杂的字谜问题。

这个算法的核心启示是:约束即信息。每填一个字母,就传播到所有相交的单词,逐步缩小搜索空间。在现实生活中,无论是排课、调度还是资源分配,善于发现和利用约束,往往能让看似无解的问题迎刃而解。