每日算法 — 使用java实现推箱子:BFS与死锁检测

一、游戏介绍与问题建模

推箱子(Sokoban)是一款经典的益智游戏,最早由日本游戏设计师 Thinking Rabbit 于 1982 年发布。游戏的核心玩法非常简单:玩家控制一个工人,在仓库中将所有箱子推到指定的目标位置上。

1.1 游戏规则

推箱子的基本规则:

  • 工人(Player):可以上下左右移动,每次移动一格
  • 箱子(Box):工人可以推动箱子,但不能拉动箱子
  • 墙壁(Wall):不可穿越的障碍物
  • 目标点(Goal):箱子需要被推到的位置
  • 胜利条件:所有箱子都位于目标点上

几个关键约束:
1. 工人一次只能推一个箱子(不能同时推两个相邻的箱子)
2. 箱子不能推入墙壁或另一个箱子的位置
3. 工人不能穿过箱子或墙壁

1.2 问题建模

将推箱子问题抽象为图搜索问题

  • 状态(State):工人位置 + 所有箱子的位置集合
  • 动作(Action):工人向上/下/左/右移动一格
  • 如果移动方向上有箱子且箱子前方为空,则推动箱子
  • 如果移动方向上为空,则工人单纯移动
  • 状态转移:通过合法动作从一个状态转移到另一个状态
  • 初始状态:给定的关卡布局
  • 目标状态:所有箱子都在目标点上

这是一个典型的最短路径问题,因为每步移动代价相同,我们需要找到从初始状态到目标状态的最少步数。

1.3 状态空间规模

推箱子的状态空间非常庞大。对于一个典型的 10×10 关卡,假设有 5 个箱子,状态空间的理论上限约为:

工人位置数 × 箱子位置组合数 ≈ 100 × C(100, 5) ≈ 7.5亿

实际可达状态数远小于理论值,但仍然是一个巨大的搜索空间。这也是为什么需要死锁检测等剪枝技术的原因——许多状态虽然合法,但已经不可能到达目标状态,可以提前排除。


二、状态表示与编码

高效的状态表示是算法性能的关键。推箱子的状态由工人位置和箱子位置共同决定,我们需要一种紧凑且便于哈希比较的编码方式。

2.1 关卡表示

使用二维字符数组表示关卡地图:

'#' = 墙壁 (Wall)
' ' = 空地 (Floor)
'.' = 目标点 (Goal)
'$' = 箱子 (Box)
'*' = 箱子在目标点上 (Box on Goal)
'@' = 工人 (Player)
'+' = 工人在目标点上 (Player on Goal)

2.2 状态编码设计

推箱子状态由两部分组成:工人位置 + 箱子位置集合。由于箱子是不可区分的(交换两个箱子的位置不产生新状态),我们需要对箱子位置进行排序后再编码。

/**
 * 状态类:封装工人位置和箱子位置集合
 * 实现 equals 和 hashCode 用于 HashSet 去重
 */
class SokobanState {
    int playerX, playerY;       // 工人位置
    int[] boxPositions;         // 箱子位置(排序后的一维编码)

    public SokobanState(int playerX, int playerY, int[] boxPositions) {
        this.playerX = playerX;
        this.playerY = playerY;
        this.boxPositions = boxPositions.clone();
        Arrays.sort(this.boxPositions); // 排序确保相同箱子集合的编码一致
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof SokobanState)) return false;
        SokobanState other = (SokobanState) o;
        return playerX == other.playerX 
            && playerY == other.playerY 
            && Arrays.equals(boxPositions, other.boxPositions);
    }

    @Override
    public int hashCode() {
        int result = Objects.hash(playerX, playerY);
        result = 31 * result + Arrays.hashCode(boxPositions);
        return result;
    }
}

2.3 位置编码优化

为了提高效率,我们将二维坐标 (x, y) 编码为一维整数:

/**
 * 将二维坐标编码为一维整数
 * 假设地图宽度不超过 256,y 占高 16 位,x 占低 16 位
 */
public static int encodePos(int x, int y) {
    return (y << 16) | x;
}

/**
 * 从一维编码解码 x 坐标
 */
public static int decodeX(int code) {
    return code & 0xFFFF;
}

/**
 * 从一维编码解码 y 坐标
 */
public static int decodeY(int code) {
    return code >>> 16;
}

2.4 静态地图预处理

将墙壁、目标点等静态信息预处理为独立的数据结构,避免在每次状态比较时重复存储:

/**
 * 关卡静态信息(所有状态共享)
 */
class Level {
    int width, height;
    boolean[][] walls;          // 墙壁位置
    boolean[][] goals;          // 目标点位置
    int goalCount;              // 目标点数量

    public Level(String[] map) {
        height = map.length;
        width = map[0].length();
        walls = new boolean[height][width];
        goals = new boolean[height][width];

        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                char c = map[y].charAt(x);
                walls[y][x] = (c == '#');
                goals[y][x] = (c == '.' || c == '*' || c == '+');
                if (goals[y][x]) goalCount++;
            }
        }
    }

    public boolean isWall(int x, int y) {
        return x < 0 || x >= width || y < 0 || y >= height || walls[y][x];
    }

    public boolean isGoal(int x, int y) {
        return x >= 0 && x < width && y >= 0 && y < height && goals[y][x];
    }
}

三、BFS求解原理与Java实现

广度优先搜索(BFS)是求解推箱子最短路径问题的基础算法。由于推箱子状态空间巨大,单纯的 BFS 效率很低,需要配合死锁检测等剪枝手段。

3.1 BFS 算法原理

BFS 从初始状态出发,逐层扩展搜索:
1. 将初始状态加入队列
2. 每次从队列头部取出一个状态
3. 生成该状态所有可能的下一状态(四个方向移动)
4. 对新状态进行死锁检测,排除不可解状态
5. 将未访问过的合法状态加入队列尾部
6. 重复直到找到目标状态

3.2 完整Java实现

import java.util.*;

/**
 * 推箱子BFS求解器
 */
public class SokobanBFS {

    // 四个方向:上、下、左、右
    private static final int[][] DIRS = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}};

    private Level level;
    private Set<SokobanState> visited;
    private Map<SokobanState, SokobanState> parent;
    private Map<SokobanState, Integer> steps;

    public SokobanBFS(Level level) {
        this.level = level;
        this.visited = new HashSet<>();
        this.parent = new HashMap<>();
        this.steps = new HashMap<>();
    }

    /**
     * BFS求解
     * @param initialState 初始状态
     * @return 最少步数,无解返回-1
     */
    public int solve(SokobanState initialState) {
        Queue<SokobanState> queue = new LinkedList<>();

        queue.offer(initialState);
        visited.add(initialState);
        steps.put(initialState, 0);
        parent.put(initialState, null);

        int statesExplored = 0;

        while (!queue.isEmpty()) {
            SokobanState current = queue.poll();
            statesExplored++;

            // 检查是否到达目标状态
            if (isGoal(current)) {
                System.out.println("找到解!步数:" + steps.get(current));
                System.out.println("探索状态数:" + statesExplored);
                printSolution(current);
                return steps.get(current);
            }

            // 生成所有可能的下一状态
            List<SokobanState> nextStates = generateNextStates(current);

            for (SokobanState next : nextStates) {
                if (!visited.contains(next)) {
                    // 死锁检测:如果是死锁状态则跳过
                    if (isDeadlock(next)) {
                        continue;
                    }

                    visited.add(next);
                    steps.put(next, steps.get(current) + 1);
                    parent.put(next, current);
                    queue.offer(next);
                }
            }
        }

        System.out.println("无解!探索状态数:" + statesExplored);
        return -1;
    }

    /**
     * 检查是否为目标状态:所有箱子都在目标点上
     */
    private boolean isGoal(SokobanState state) {
        for (int boxPos : state.boxPositions) {
            int bx = decodeX(boxPos);
            int by = decodeY(boxPos);
            if (!level.isGoal(bx, by)) {
                return false;
            }
        }
        return true;
    }

    /**
     * 生成当前状态的所有合法下一状态
     */
    private List<SokobanState> generateNextStates(SokobanState state) {
        List<SokobanState> nextStates = new ArrayList<>();
        int px = state.playerX;
        int py = state.playerY;

        // 将箱子位置数组转为Set便于查找
        Set<Integer> boxSet = new HashSet<>();
        for (int pos : state.boxPositions) {
            boxSet.add(pos);
        }

        for (int[] dir : DIRS) {
            int dx = dir[0];
            int dy = dir[1];
            int newPx = px + dx;
            int newPy = py + dy;

            // 检查新位置是否是墙
            if (level.isWall(newPx, newPy)) {
                continue;
            }

            int newPlayerPos = encodePos(newPx, newPy);

            // 检查新位置是否有箱子
            if (boxSet.contains(newPlayerPos)) {
                // 尝试推箱子:检查箱子前方是否为空
                int newBx = newPx + dx;
                int newBy = newPy + dy;

                if (level.isWall(newBx, newBy)) {
                    continue; // 箱子前方是墙,不能推
                }

                int newBoxPos = encodePos(newBx, newBy);
                if (boxSet.contains(newBoxPos)) {
                    continue; // 箱子前方是另一个箱子,不能推
                }

                // 推动箱子:创建新状态
                int[] newBoxPositions = state.boxPositions.clone();
                for (int i = 0; i < newBoxPositions.length; i++) {
                    if (newBoxPositions[i] == newPlayerPos) {
                        newBoxPositions[i] = newBoxPos;
                        break;
                    }
                }

                SokobanState nextState = new SokobanState(newPx, newPy, newBoxPositions);
                nextStates.add(nextState);

            } else {
                // 工人单纯移动
                SokobanState nextState = new SokobanState(newPx, newPy, state.boxPositions);
                nextStates.add(nextState);
            }
        }

        return nextStates;
    }

    /**
     * 死锁检测(简化版,详见下一节)
     */
    private boolean isDeadlock(SokobanState state) {
        // 简单死锁检测:检查是否有箱子在角落且不是目标点
        for (int boxPos : state.boxPositions) {
            int bx = decodeX(boxPos);
            int by = decodeY(boxPos);

            if (level.isGoal(bx, by)) {
                continue; // 在目标点上,不是死锁
            }

            // 检查是否在角落(两面都是墙)
            boolean upWall = level.isWall(bx, by - 1);
            boolean downWall = level.isWall(bx, by + 1);
            boolean leftWall = level.isWall(bx - 1, by);
            boolean rightWall = level.isWall(bx + 1, by);

            // 左上角死锁
            if (upWall && leftWall) return true;
            // 右上角死锁
            if (upWall && rightWall) return true;
            // 左下角死锁
            if (downWall && leftWall) return true;
            // 右下角死锁
            if (downWall && rightWall) return true;
        }
        return false;
    }

    // ... encodePos/decodeX/decodeY 方法同上

    /**
     * 打印解决方案路径
     */
    private void printSolution(SokobanState goalState) {
        List<SokobanState> path = new ArrayList<>();
        SokobanState current = goalState;
        while (current != null) {
            path.add(current);
            current = parent.get(current);
        }
        Collections.reverse(path);

        System.out.println("共 " + (path.size() - 1) + " 步:");
        for (int i = 0; i < path.size(); i++) {
            System.out.println("--- 第 " + i + " 步 ---");
            printState(path.get(i));
        }
    }

    private void printState(SokobanState state) {
        char[][] map = new char[level.height][level.width];

        // 初始化墙壁和地板
        for (int y = 0; y < level.height; y++) {
            for (int x = 0; x < level.width; x++) {
                if (level.walls[y][x]) {
                    map[y][x] = '#';
                } else if (level.goals[y][x]) {
                    map[y][x] = '.';
                } else {
                    map[y][x] = ' ';
                }
            }
        }

        // 放置箱子
        for (int boxPos : state.boxPositions) {
            int bx = decodeX(boxPos);
            int by = decodeY(boxPos);
            map[by][bx] = (level.goals[by][bx]) ? '*' : '$';
        }

        // 放置工人
        int px = state.playerX;
        int py = state.playerY;
        map[py][px] = (level.goals[py][px]) ? '+' : '@';

        // 打印
        for (int y = 0; y < level.height; y++) {
            System.out.println(new String(map[y]));
        }
    }
}

四、死锁检测算法

死锁检测是推箱子求解器中最重要的优化手段。所谓死锁,是指虽然当前状态合法,但已经不可能通过任何操作到达目标状态的情况。及时检测并剪枝死锁状态,可以大幅减少搜索空间。

4.1 简单死锁(角落死锁)

最简单的死锁:箱子被推到了墙角,且该墙角不是目标点。由于箱子只能推不能拉,一旦进入角落就再也无法移动。

#####    #####    #####    #####
#$..    #..$    #..#    #..#
#..#    #..#    #$..    #..$
#####    #####    #####    #####
  左上死锁  右上死锁  左下死锁  右下死锁

检测方法:检查每个不在目标点上的箱子,判断其水平和垂直方向是否各有至少一面墙。

/**
 * 简单死锁检测:角落死锁
 * @return true 表示是死锁状态
 */
private boolean checkCornerDeadlock(int bx, int by) {
    if (level.isGoal(bx, by)) return false;

    boolean upWall = level.isWall(bx, by - 1);
    boolean downWall = level.isWall(bx, by + 1);
    boolean leftWall = level.isWall(bx - 1, by);
    boolean rightWall = level.isWall(bx + 1, by);

    // 四个角落中的任意一个
    return (upWall && leftWall) || (upWall && rightWall) 
        || (downWall && leftWall) || (downWall && rightWall);
}

4.2 冻结死锁(Freeze Deadlock)

冻结死锁比角落死锁更复杂:一个箱子虽然不在角落,但由于被其他箱子和墙壁共同阻挡,已经无法再移动。

########
#  $.  #
# $    #
########

上图中,上面的箱子被左边的墙和下面的箱子挡住,下面的箱子被左边的墙和上面的箱子挡住——两个箱子都无法移动,形成冻结死锁。

检测方法:从一个不在目标点的箱子出发,递归检查其相邻的箱子和墙壁,判断是否形成了无法移动的封闭结构。

/**
 * 冻结死锁检测
 * 检查某个箱子是否被"冻结"(无法向任何方向移动)
 */
private boolean checkFreezeDeadlock(SokobanState state, int boxIndex, 
                                     Set<Integer> frozenBoxes) {
    int boxPos = state.boxPositions[boxIndex];
    int bx = decodeX(boxPos);
    int by = decodeY(boxPos);

    if (level.isGoal(bx, by)) {
        return false; // 在目标点上的箱子不参与冻结死锁判断
    }

    if (frozenBoxes.contains(boxPos)) {
        return true; // 已经判定为冻结
    }

    // 检查水平方向是否可推
    boolean canPushHorizontal = false;

    // 检查向左推
    if (!level.isWall(bx - 1, by) && !hasBoxAt(state, bx - 1, by)) {
        // 左边是空的,检查右边是否有空间让工人进入推箱子
        if (!level.isWall(bx + 1, by) && !hasBoxAt(state, bx + 1, by)) {
            canPushHorizontal = true;
        }
    }
    // 检查向右推
    if (!level.isWall(bx + 1, by) && !hasBoxAt(state, bx + 1, by)) {
        if (!level.isWall(bx - 1, by) && !hasBoxAt(state, bx - 1, by)) {
            canPushHorizontal = true;
        }
    }

    // 检查垂直方向是否可推
    boolean canPushVertical = false;

    // 检查向上推
    if (!level.isWall(bx, by - 1) && !hasBoxAt(state, bx, by - 1)) {
        if (!level.isWall(bx, by + 1) && !hasBoxAt(state, bx, by + 1)) {
            canPushVertical = true;
        }
    }
    // 检查向下推
    if (!level.isWall(bx, by + 1) && !hasBoxAt(state, bx, by + 1)) {
        if (!level.isWall(bx, by - 1) && !hasBoxAt(state, bx, by - 1)) {
            canPushVertical = true;
        }
    }

    // 如果两个方向都不能推,则是冻结死锁
    if (!canPushHorizontal && !canPushVertical) {
        frozenBoxes.add(boxPos);
        return true;
    }

    return false;
}

/**
 * 检查指定位置是否有箱子
 */
private boolean hasBoxAt(SokobanState state, int x, int y) {
    int pos = encodePos(x, y);
    for (int boxPos : state.boxPositions) {
        if (boxPos == pos) return true;
    }
    return false;
}

4.3 模式死锁(Pattern Deadlock)

模式死锁是更复杂的死锁形式,指箱子被推到了某些特定的位置模式中,虽然单个箱子都可以移动,但整体上已经无法将所有箱子都推到目标点。

常见的模式死锁包括:

1. 墙壁死锁:箱子沿着墙壁排成一列,且墙壁一侧没有目标点

#########    #########
#$ $ $.#    #.  $  $#  ← 错误示范
#########    #########

2. 瓶颈死锁:两个箱子堵住了唯一通道,导致无法绕到另一侧

#####
# $ #
# $ #
#   #
#####

模式死锁的检测通常需要预计算死锁位置表(Deadlock Square Table):通过从目标点反向搜索,找出所有”不可能将箱子推到任何目标点”的位置。

/**
 * 预计算死锁位置表(反向搜索法)
 * 从所有目标点出发,反向模拟箱子的移动,标记所有可达位置
 * 不可达的位置就是死锁位置
 */
public boolean[][] computeDeadlockTable(Level level) {
    int w = level.width;
    int h = level.height;
    boolean[][] reachable = new boolean[h][w];
    Queue<int[]> queue = new LinkedList<>();

    // 从所有目标点开始反向搜索
    for (int y = 0; y < h; y++) {
        for (int x = 0; x < w; x++) {
            if (level.isGoal(x, y)) {
                reachable[y][x] = true;
                queue.offer(new int[]{x, y});
            }
        }
    }

    // 反向BFS:箱子从目标点"拉"回来
    while (!queue.isEmpty()) {
        int[] pos = queue.poll();
        int bx = pos[0];
        int by = pos[1];

        for (int[] dir : DIRS) {
            int dx = dir[0];
            int dy = dir[1];

            // 反向:箱子从(bx+dx, by+dy)被推到(bx, by)
            // 工人需要在(bx-dx, by-dy)的位置推
            int prevBx = bx + dx;
            int prevBy = by + dy;
            int playerX = bx - dx;
            int playerY = by - dy;

            // 检查箱子之前的位置和工人位置是否都不是墙
            if (!level.isWall(prevBx, prevBy) && !level.isWall(playerX, playerY)) {
                if (!reachable[prevBy][prevBx]) {
                    reachable[prevBy][prevBx] = true;
                    queue.offer(new int[]{prevBx, prevBy});
                }
            }
        }
    }

    // 死锁位置 = 不是墙 且 不可达
    boolean[][] deadlock = new boolean[h][w];
    for (int y = 0; y < h; y++) {
        for (int x = 0; x < w; x++) {
            if (!level.isWall(x, y) && !reachable[y][x]) {
                deadlock[y][x] = true;
            }
        }
    }

    return deadlock;
}

4.4 综合死锁检测

将以上几种死锁检测组合起来,形成完整的死锁判断:

/**
 * 综合死锁检测
 */
private boolean isDeadlock(SokobanState state) {
    // 1. 简单死锁检测 + 死锁位置表检测
    for (int boxPos : state.boxPositions) {
        int bx = decodeX(boxPos);
        int by = decodeY(boxPos);

        if (level.isGoal(bx, by)) continue;

        // 死锁位置表检测(预计算)
        if (deadlockTable[by][bx]) {
            return true;
        }
    }

    // 2. 冻结死锁检测
    Set<Integer> frozenBoxes = new HashSet<>();
    for (int i = 0; i < state.boxPositions.length; i++) {
        if (checkFreezeDeadlock(state, i, frozenBoxes)) {
            return true;
        }
    }

    return false;
}

五、复杂度分析与优化策略

5.1 时间复杂度分析

算法 时间复杂度 说明
朴素 BFS O(b^d) b 为分支因子(约 2-4),d 为最优解深度
BFS + 死锁检测 远小于 O(b^d) 死锁剪枝可排除 50%-90% 的无效状态
BFS + 死锁 + 可达性优化 更优 进一步减少状态空间

推箱子的分支因子(每个状态的合法后继状态数)通常在 2-4 之间,但由于死锁检测的存在,实际有效分支因子会更低。

5.2 空间复杂度分析

数据结构 空间复杂度 说明
已访问状态集合 O(N) N 为探索的状态数
队列 O(b^d) 最坏情况下与状态数同阶
死锁位置表 O(W×H) 与地图大小成正比,可忽略

5.3 优化策略

优化1:工人可达性优化

在推箱子中,工人的位置只有在推动箱子时才有意义。如果两个状态中箱子位置完全相同,且工人可以在不推动任何箱子的情况下从一个位置走到另一个位置,那么这两个状态是等价的。

/**
 * 优化状态表示:只记录箱子位置 + 工人所在的可达区域
 * 工人在同一连通区域内的不同位置视为同一状态
 */

这种优化可以将状态空间减少数倍,因为工人的许多位置都是等价的。

优化2:优先队列(A* 搜索)

与华容道类似,推箱子也可以使用 A* 算法加速搜索。常用的启发函数:

  1. 曼哈顿距离和:每个箱子到最近目标点的曼哈顿距离之和
  2. 最小权匹配:使用匈牙利算法计算箱子到目标点的最优匹配距离
/**
 * 启发函数:每个箱子到最近目标点的曼哈顿距离之和
 * 可采纳:每个箱子至少需要移动这么多步才能到达目标点
 */
private int heuristic(SokobanState state) {
    int total = 0;
    for (int boxPos : state.boxPositions) {
        int bx = decodeX(boxPos);
        int by = decodeY(boxPos);

        if (level.isGoal(bx, by)) continue;

        int minDist = Integer.MAX_VALUE;
        // 找到最近的目标点
        for (int gy = 0; gy < level.height; gy++) {
            for (int gx = 0; gx < level.width; gx++) {
                if (level.isGoal(gx, gy)) {
                    int dist = Math.abs(bx - gx) + Math.abs(by - gy);
                    minDist = Math.min(minDist, dist);
                }
            }
        }
        total += minDist;
    }
    return total;
}

优化3:IDA(迭代加深 A

推箱子的状态空间很大,BFS 和 A 都可能因为内存不足而失败。IDA 使用深度优先搜索的方式,每次限制 f 值上限,空间复杂度仅为 O(d)。

优化4:双向搜索

从初始状态正向搜索,同时从目标状态反向搜索(箱子从目标点被”拉”回来),当两边相遇时得到解。


六、适用场景与扩展思路

6.1 适用场景

推箱子求解算法的思路可以推广到以下场景:

  1. 仓储物流:仓库中货物的搬运路径规划、货架排布优化
  2. 机器人路径规划:多机器人协同搬运物体的场景
  3. 游戏 AI:益智游戏的关卡设计与难度评估、自动解题功能
  4. 规划问题:许多 AI 规划问题(Planning)可以转化为状态空间搜索
  5. 组合优化:某些约束满足问题可以用搜索 + 剪枝的方法求解

6.2 扩展思路

1. 多箱子协作

当关卡中有多个箱子时,如何高效地规划推箱顺序?这涉及到子目标排序和任务分解:

思路:将问题分解为"将第i个箱子推到第j个目标点"的子问题
使用分治或动态规划的思想,逐步求解

2. 关卡生成器

利用求解器自动生成推箱子关卡:

  • 随机生成关卡布局
  • 使用求解器验证是否有解
  • 根据求解难度(步数、搜索状态数)评估关卡难度
  • 筛选出符合要求的关卡

3. 人机对战

将推箱子 AI 应用于游戏中:

  • 提示功能:AI 给出下一步最优走法
  • 对战模式:玩家与 AI 比赛谁先完成关卡
  • 教学功能:AI 分析玩家的走法,给出改进建议

4. 三维推箱子

将推箱子扩展到三维空间,增加更多的策略性和复杂度。此时需要重新设计状态表示和死锁检测算法。

6.3 总结

推箱子虽然是一款经典小游戏,但其中蕴含的搜索算法、剪枝策略、状态空间优化等思想,是人工智能和算法设计领域的宝贵财富。

从简单的 BFS 到复杂的死锁检测,从盲目搜索到启发式搜索,推箱子求解器的优化过程完美展现了算法设计的核心思路:深入理解问题本质,找到有效的剪枝手段,用尽可能少的计算获得答案

在实际工程中,我们经常会遇到类似的状态空间搜索问题。掌握这些经典算法,并能根据问题特点设计合适的剪枝策略和启发函数,是每个算法工程师的必备技能。

思考练习:如果推箱子游戏中增加了”可以拉箱子”的功能,算法需要做哪些修改?死锁检测还适用吗?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注