每日算法 — 使用java实现填字游戏:交叉约束传播与字典树前缀匹配

引言

填字游戏(Crossword Puzzle)是一种广受欢迎的文字益智游戏,玩家需要根据线索提示,在纵横交错的网格中填入合适的单词。从算法角度看,填字游戏是一个典型的约束满足问题:横向单词与纵向单词在交叉位置必须共享相同的字母。本文用Java实现一个填字游戏自动求解器,核心算法包括字典树(Trie)高效前缀匹配与约束传播技术,让程序自动完成复杂的填字推理。

算法原理

交叉约束建模

将填字游戏网格中的每个连续空白序列视为一个变量(横向或纵向单词槽),每个槽需要填入一个长度匹配的单词。交叉位置形成约束:若横向槽的第 $i$ 个格子与纵向槽的第 $j$ 个格子重叠,则两单词在对应位置必须字母相同。

字典树前缀匹配

Trie树是一种高效的前缀匹配数据结构。在求解过程中,当我们确定某个槽的部分字母后(如通过交叉约束),可以迅速从Trie中筛选出所有匹配该前缀的候选单词,大幅缩减搜索空间。

约束传播(MRV启发式)

采用Minimum Remaining Values(MRV)策略:每次都选择候选单词最少的槽进行赋值。这样可以在搜索早期就发现矛盾,减少回溯次数。同时,每次赋值后立即向相邻槽传播约束,更新它们的候选集合。

Java实现

import java.util.*;

/**
 * 填字游戏自动求解器:Trie前缀匹配 + 约束传播 + MRV回溯
 */
public class CrosswordSolver {

    static class TrieNode {
        TrieNode[] children = new TrieNode[26];
        boolean isWord;
        // 记录以该节点为前缀的所有单词,用于快速候选筛选
        List<String> wordsWithPrefix = new ArrayList<>();
    }

    static class Trie {
        TrieNode root = new TrieNode();

        void insert(String word) {
            TrieNode node = root;
            for (char c : word.toCharArray()) {
                int idx = c - 'a';
                if (node.children[idx] == null) {
                    node.children[idx] = new TrieNode();
                }
                node = node.children[idx];
                node.wordsWithPrefix.add(word);
            }
            node.isWord = true;
        }

        // 获取匹配给定模式(如 "a__le")的所有单词
        List<String> matchPattern(String pattern) {
            List<String> result = new ArrayList<>();
            collectMatch(root, pattern, 0, new StringBuilder(), result);
            return result;
        }

        void collectMatch(TrieNode node, String pattern, int pos,
                          StringBuilder sb, List<String> result) {
            if (node == null) return;
            if (pos == pattern.length()) {
                if (node.isWord) result.add(sb.toString());
                return;
            }
            char c = pattern.charAt(pos);
            if (c == '_') {
                for (int i = 0; i < 26; i++) {
                    if (node.children[i] != null) {
                        sb.append((char) ('a' + i));
                        collectMatch(node.children[i], pattern, pos + 1, sb, result);
                        sb.deleteCharAt(sb.length() - 1);
                    }
                }
            } else {
                int idx = c - 'a';
                if (node.children[idx] != null) {
                    sb.append(c);
                    collectMatch(node.children[idx], pattern, pos + 1, sb, result);
                    sb.deleteCharAt(sb.length() - 1);
                }
            }
        }
    }

    static class Slot {
        int id, row, col, length;
        boolean horizontal; // true=横向, false=纵向
        // 交叉点:位置 -> 另一个slot的id及对应位置
        Map<Integer, int[]> crosses = new HashMap<>();
        String assigned = null;

        Slot(int id, int r, int c, int len, boolean h) {
            this.id = id; this.row = r; this.col = c;
            this.length = len; this.horizontal = h;
        }

        // 根据当前交叉约束生成匹配模式
        String getPattern(char[][] grid) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < length; i++) {
                int r = horizontal ? row : row + i;
                int c = horizontal ? col + i : col;
                if (grid[r][c] == '_') sb.append('_');
                else sb.append(grid[r][c]);
            }
            return sb.toString();
        }
    }

    Trie trie;
    List<Slot> slots;
    char[][] grid;
    int rows, cols;

    public CrosswordSolver(Set<String> dictionary, int rows, int cols) {
        this.trie = new Trie();
        for (String w : dictionary) trie.insert(w.toLowerCase());
        this.rows = rows; this.cols = cols;
        this.grid = new char[rows][cols];
        for (char[] row : grid) Arrays.fill(row, '#');
        this.slots = new ArrayList<>();
    }

    /**
     * 从网格中提取所有单词槽(横向和纵向连续空白)
     */
    void extractSlots(boolean[][] open) {
        int id = 0;
        // 横向槽
        for (int r = 0; r < rows; r++) {
            int c = 0;
            while (c < cols) {
                while (c < cols && !open[r][c]) c++;
                int start = c;
                while (c < cols && open[r][c]) c++;
                if (c - start >= 2) {
                    slots.add(new Slot(id++, r, start, c - start, true));
                }
            }
        }
        // 纵向槽
        for (int c = 0; c < cols; c++) {
            int r = 0;
            while (r < rows) {
                while (r < rows && !open[r][c]) r++;
                int start = r;
                while (r < rows && open[r][c]) r++;
                if (r - start >= 2) {
                    slots.add(new Slot(id++, start, c, r - start, false));
                }
            }
        }
        // 建立交叉关系
        for (Slot s1 : slots) {
            for (Slot s2 : slots) {
                if (s1.id >= s2.id) continue;
                if (s1.horizontal == s2.horizontal) continue;
                // 检查是否交叉
                Slot h = s1.horizontal ? s1 : s2;
                Slot v = s1.horizontal ? s2 : s1;
                if (v.col >= h.col && v.col < h.col + h.length &&
                    h.row >= v.row && h.row < v.row + v.length) {
                    int hPos = v.col - h.col;
                    int vPos = h.row - v.row;
                    h.crosses.put(hPos, new int[]{v.id, vPos});
                    v.crosses.put(vPos, new int[]{h.id, hPos});
                }
            }
        }
    }

    /**
     * 使用MRV回溯 + 约束传播求解填字游戏
     */
    public boolean solve() {
        return backtrack(0);
    }

    boolean backtrack(int filled) {
        if (filled == slots.size()) return true;

        // MRV:选择候选最少的未填充槽
        Slot bestSlot = null;
        List<String> bestCandidates = null;
        int minCandidates = Integer.MAX_VALUE;

        for (Slot s : slots) {
            if (s.assigned != null) continue;
            String pattern = s.getPattern(grid);
            List<String> candidates = trie.matchPattern(pattern);
            // 额外过滤:确保与已赋值交叉槽一致
            candidates.removeIf(word -> !consistent(s, word));
            if (candidates.isEmpty()) return false; // 死路
            if (candidates.size() < minCandidates) {
                minCandidates = candidates.size();
                bestSlot = s;
                bestCandidates = candidates;
                if (minCandidates == 1) break; // 无法更优
            }
        }

        if (bestSlot == null) return false;

        for (String word : bestCandidates) {
            // 赋值
            bestSlot.assigned = word;
            fillGrid(bestSlot, word);
            if (backtrack(filled + 1)) return true;
            // 回溯
            bestSlot.assigned = null;
            unfillGrid(bestSlot);
        }
        return false;
    }

    boolean consistent(Slot s, String word) {
        for (Map.Entry<Integer, int[]> entry : s.crosses.entrySet()) {
            int pos = entry.getKey();
            int[] other = entry.getValue();
            Slot otherSlot = slots.get(other[0]);
            int otherPos = other[1];
            if (otherSlot.assigned != null) {
                if (word.charAt(pos) != otherSlot.assigned.charAt(otherPos))
                    return false;
            }
        }
        return true;
    }

    void fillGrid(Slot s, String word) {
        for (int i = 0; i < s.length; i++) {
            int r = s.horizontal ? s.row : s.row + i;
            int c = s.horizontal ? s.col + i : s.col;
            grid[r][c] = word.charAt(i);
        }
    }

    void unfillGrid(Slot s) {
        for (int i = 0; i < s.length; i++) {
            int r = s.horizontal ? s.row : s.row + i;
            int c = s.horizontal ? s.col + i : s.col;
            // 只有不被其他已赋值槽覆盖的格子才清空
            boolean covered = false;
            for (Slot other : slots) {
                if (other.id == s.id || other.assigned == null) continue;
                if (other.horizontal) {
                    if (r == other.row && c >= other.col && c < other.col + other.length)
                        covered = true;
                } else {
                    if (c == other.col && r >= other.row && r < other.row + other.length)
                        covered = true;
                }
            }
            if (!covered) grid[r][c] = '_';
        }
    }

    void printGrid() {
        for (char[] row : grid) {
            for (char c : row) {
                System.out.print(c == '#' ? "# " : c + " ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        Set<String> dict = new HashSet<>(Arrays.asList(
            "apple", "grape", "peach", "berry", "mango",
            "april", "great", "peace", "beach", "magic",
            "agent", "prize", "bread", "cream", "dream",
            "eagle", "flame", "globe", "house", "image"
        ));

        // 5x5网格,开放格子标记为true
        boolean[][] open = {
            {true,  true,  true,  true,  true},
            {true,  false, true,  false, true},
            {true,  true,  true,  true,  true},
            {true,  false, true,  false, true},
            {true,  true,  true,  true,  true}
        };

        CrosswordSolver solver = new CrosswordSolver(dict, 5, 5);
        solver.extractSlots(open);
        // 初始化开放格子为'_'
        for (int r = 0; r < 5; r++)
            for (int c = 0; c < 5; c++)
                if (open[r][c]) solver.grid[r][c] = '_';

        if (solver.solve()) {
            System.out.println("填字游戏求解成功:");
            solver.printGrid();
        } else {
            System.out.println("无解");
        }
    }
}

复杂度分析

  • Trie构建:$O(W \times L)$,$W$ 为词典单词数,$L$ 为平均长度。
  • 回溯搜索:最坏 $O(S! \times C^S)$,$S$ 为槽数量,$C$ 为平均候选数。MRV启发式和约束传播大幅剪枝,实际效率接近多项式级别。
  • 模式匹配:单次 $O(26^K)$,$K$ 为未知字母数,但由于Trie剪枝通常远小于此。

总结

本文实现了填字游戏的自动求解器,核心创新在于将Trie前缀匹配与约束传播结合:Trie快速筛选候选单词,MRV策略决定搜索顺序,约束传播确保交叉位置的一致性。该框架可扩展至更大规模网格和更丰富的词典,甚至支持多解枚举与难度评估。