使用Prim算法实现随机生成的迷宫

19

我正在尝试使用Prim算法实现一个随机生成的迷宫。

我希望我的迷宫看起来像这样: enter image description here

然而,我从程序生成的迷宫看起来像这样:

enter image description here

我目前卡在了正确实现粗体步骤上:

  1. 开始时,将网格填满墙壁。
  2. 选择一个单元格,将其标记为迷宫的一部分。将该单元格的墙壁添加到墙壁列表中。
  3. 当列表中有墙壁时:
    • **1. 从列表中随机选择一堵墙。如果对面的单元格还没有在迷宫中:
        1. 将墙壁改为通道,并将对面的单元格标记为迷宫的一部分。
        1. 将该单元格的相邻墙壁添加到墙壁列表中。
      1. 从列表中删除该墙壁。

来自 此迷宫生成算法文章。

如何确定一个单元格是否是墙列表的有效候选项?我想改变算法以便生成正确的迷宫。任何能帮助我解决问题的想法都将不胜感激。


这取决于您编写程序的方式。如果您有一个m,n数组并使用它来标记已使用的单元格,则不难实现。从您的迷宫图片中可以看出,我认为错误在于您在实现中删除了多个墙壁。 “好的迷宫”始终只有一条开放路径(已删除的墙壁)。例如,在起点处,您的迷宫有两条开放路径。嗯,有点像。好的迷宫永远不会“连接”已经存在的路径... - BitTickler
我有一个二维数组来存储每个单元格的值。在while循环的每次迭代中,我只移除一堵墙。我认为问题在于我的函数将边缘单元格添加到墙列表中。 - donth77
你们有没有注意到在你发布的文章的动画中,第22秒有什么有趣的地方吗? - kpie
8个回答

21

维基百科文章中的描述确实需要改进。

文章的第一个令人困惑的部分是,随机 Prim 算法的描述没有详细说明算法所使用的假设数据结构。因此,“相反的单元格”等短语变得令人困惑。

基本上,“迷宫生成程序员”可以选择两种主要方法:

  1. 单元格有四个相邻的墙或通道。关于墙/通道的信息被存储和操作。
  2. 单元格可以是阻塞(墙)或通道,而不存储任何额外的连通性信息。

根据读者在阅读算法描述时心中的模型(1)或(2),他们要么理解要么不理解。

就我个人而言,我更喜欢将单元格用作墙或通道,而不是操纵专门的通道/墙信息。

然后,“边缘”块离通道的距离为2(而不是1)。从边缘块列表中随机选择一个边缘块,并通过使边缘块和相邻通道之间的单元格也变成通道来将其连接到随机相邻通道(距离为2)。

这是我的 F# 实现:

let rng = new System.Random()
type Cell = | Blocked | Passage
type Maze = 
    { 
        Grid : Cell[,]
        Width : int
        Height : int
    }

let initMaze dx dy = 
    let six,siy = (1,1)
    let eix,eiy = (dx-2,dy-2)
    { 
        Grid = Array2D.init dx dy 
            (fun _ _ -> Blocked
            ) 
        Width = dx
        Height = dy
    }

let generate (maze : Maze) : Maze =
    let isLegal (x,y) =
        x>0 && x < maze.Width-1 && y>0 && y<maze.Height-1
    let frontier (x,y) =
        [x-2,y;x+2,y; x,y-2; x, y+2]
        |> List.filter (fun (x,y) -> isLegal (x,y) && maze.Grid.[x,y] = Blocked)
    let neighbor (x,y) =
        [x-2,y;x+2,y; x,y-2; x, y+2]
        |> List.filter (fun (x,y) -> isLegal (x,y) && maze.Grid.[x,y] = Passage)
    let randomCell () = rng.Next(maze.Width),rng.Next(maze.Height)
    let removeAt index (lst : (int * int) list) : (int * int) list =
        let x,y = lst.[index]
        lst |> List.filter (fun (a,b) -> not (a = x && b = y) )
    let between p1 p2 =
        let x = 
            match (fst p2 - fst p1) with
            | 0 -> fst p1
            | 2 -> 1 + fst p1
            | -2 -> -1 + fst p1
            | _ -> failwith "Invalid arguments for between()"
        let y = 
            match (snd p2 - snd p1) with
            | 0 -> snd p1
            | 2 -> 1 + snd p1
            | -2 -> -1 + snd p1
            | _ -> failwith "Invalid arguments for between()"
        (x,y)
    let connectRandomNeighbor (x,y) =
        let neighbors = neighbor (x,y)
        let pickedIndex = rng.Next(neighbors.Length)
        let xn,yn = neighbors.[pickedIndex]
        let xb,yb = between (x,y) (xn,yn)
        maze.Grid.[xb,yb] <- Passage
        ()
    let rec extend front =
        match front with
        | [] -> ()
        | _ ->
            let pickedIndex = rng.Next(front.Length)
            let xf,yf = front.[pickedIndex]
            maze.Grid.[xf,yf] <- Passage
            connectRandomNeighbor (xf,yf)
            extend ((front |> removeAt pickedIndex) @ frontier (xf,yf))

    let x,y = randomCell()
    maze.Grid.[x,y] <- Passage
    extend (frontier (x,y))

    maze


let show maze =
    printfn "%A" maze
    maze.Grid |> Array2D.iteri 
        (fun y x cell ->
            if x = 0 && y > 0 then 
                printfn "|"
            let c = 
                match cell with
                | Blocked -> "X"
                | Passage -> " "
            printf "%s" c
        )
    maze

let render maze =
    let cellWidth = 10;
    let cellHeight = 10;
    let pw = maze.Width * cellWidth
    let ph = maze.Height * cellHeight
    let passageBrush = System.Drawing.Brushes.White
    let wallBrush = System.Drawing.Brushes.Black
    let bmp = new System.Drawing.Bitmap(pw,ph)
    let g = System.Drawing.Graphics.FromImage(bmp);
    maze.Grid
    |> Array2D.iteri 
        (fun y x cell ->
            let brush = 
                match cell with
                | Passage -> passageBrush
                | Blocked -> wallBrush
            g.FillRectangle(brush,x*cellWidth,y*cellHeight,cellWidth,cellHeight)
        )
    g.Flush()
    bmp.Save("""E:\temp\maze.bmp""")

initMaze 50 50 |> generate |> show |> render

生成的迷宫可能如下所示:

enter image description here

以下是我尝试用维基百科“算法”风格描述我的解决方案的方法:

  1. 网格由二维单元格数组组成。
  2. 单元格有两种状态:阻塞或通道。
  3. 从一个充满阻塞单元格的网格开始。
  4. 选择一个随机单元格,将其设置为通道状态并计算其前沿单元格。 前沿单元格是距离2个阻塞单元格以内且在网格内的单元格。
  5. 当前沿单元格列表不为空时:
    1. 从前沿单元格列表中随机选择一个前沿单元格。
    2. 让neighbors(frontierCell) = 距离2个通道单元格的所有单元格。 随机选择一个邻居,并通过将中间的单元格设置为通道状态将前沿单元格与邻居相连。 计算所选前沿单元格的前沿单元格并将它们添加到前沿列表中。 从前沿单元格列表中删除所选前沿单元格。

1
我仍在努力理解你的做法。你能详细说明一下为什么前沿单元格与通道之间的距离为2吗? - donth77
1
因为在我的数据模型中,一个单元可以是墙壁或通道。其他人的数据模型中,一个单元有四面墙可以变成通道。使用这个模型的人需要检查距离1的相邻单元,因为这些单元包含墙壁信息。在我的情况下,一个单元可以是墙壁或通道,所以和另一个模型一样,下一个单元在“穿过墙壁”后面。 - BitTickler
1
非常感谢。我刚刚成功让我的程序生成了一个合适的迷宫。 - donth77
1
@BitTickler,这是否意味着迷宫的最小尺寸必须为5x5,因为您的算法试图找到距当前单元格2个单位的前沿单元格? - Kurt Mueller
在第5步中,当我们随机选择一个边界单元格时,我们是将其设置为通道,还是仅将其之间的单元格设置为通道? - Questions123
显示剩余2条评论

7
一个简单的Java实现Prim算法:
import java.util.LinkedList;
import java.util.Random;

public class Maze {
    public static final char PASSAGE_CHAR = ' ';
    public static final char WALL_CHAR = '▓';
    public static final boolean WALL    = false;
    public static final boolean PASSAGE = !WALL;

    private final boolean map[][];
    private final int width;
    private final int height;

    public Maze( final int width, final int height ){
        this.width = width;
        this.height = height;
        this.map = new boolean[width][height];

        final LinkedList<int[]> frontiers = new LinkedList<>();
        final Random random = new Random();
        int x = random.nextInt(width);
        int y = random.nextInt(height);
        frontiers.add(new int[]{x,y,x,y});

        while ( !frontiers.isEmpty() ){
            final int[] f = frontiers.remove( random.nextInt( frontiers.size() ) );
            x = f[2];
            y = f[3];
            if ( map[x][y] == WALL )
            {
                map[f[0]][f[1]] = map[x][y] = PASSAGE;
                if ( x >= 2 && map[x-2][y] == WALL )
                    frontiers.add( new int[]{x-1,y,x-2,y} );
                if ( y >= 2 && map[x][y-2] == WALL )
                    frontiers.add( new int[]{x,y-1,x,y-2} );
                if ( x < width-2 && map[x+2][y] == WALL )
                    frontiers.add( new int[]{x+1,y,x+2,y} );
                if ( y < height-2 && map[x][y+2] == WALL )
                    frontiers.add( new int[]{x,y+1,x,y+2} );
            }
        }
    }

    @Override
    public String toString(){
        final StringBuffer b = new StringBuffer();
        for ( int x = 0; x < width + 2; x++ )
            b.append( WALL_CHAR );
        b.append( '\n' );
        for ( int y = 0; y < height; y++ ){
            b.append( WALL_CHAR );
            for ( int x = 0; x < width; x++ )
                b.append( map[x][y] == WALL ? WALL_CHAR : PASSAGE_CHAR );
            b.append( WALL_CHAR );
            b.append( '\n' );
        }
        for ( int x = 0; x < width + 2; x++ )
            b.append( WALL_CHAR );
        b.append( '\n' );
        return b.toString();
    }
}
< p > new Maze(20,20).toString() 的示例输出如下:

▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓   ▓     ▓       ▓ ▓▓
▓ ▓▓▓ ▓▓▓▓▓▓▓▓▓ ▓▓▓ ▓▓
▓     ▓ ▓ ▓ ▓   ▓ ▓ ▓▓
▓ ▓▓▓▓▓ ▓ ▓ ▓▓▓ ▓ ▓ ▓▓
▓   ▓ ▓ ▓   ▓       ▓▓
▓ ▓ ▓ ▓ ▓ ▓▓▓▓▓▓▓ ▓ ▓▓
▓ ▓ ▓ ▓ ▓   ▓ ▓   ▓ ▓▓
▓ ▓▓▓ ▓ ▓▓▓ ▓ ▓ ▓▓▓▓▓▓
▓   ▓     ▓ ▓ ▓   ▓ ▓▓
▓ ▓▓▓▓▓ ▓▓▓ ▓ ▓ ▓▓▓ ▓▓
▓   ▓   ▓           ▓▓
▓ ▓ ▓ ▓ ▓▓▓ ▓▓▓▓▓▓▓▓▓▓
▓ ▓   ▓   ▓       ▓ ▓▓
▓ ▓▓▓▓▓▓▓ ▓ ▓▓▓▓▓ ▓ ▓▓
▓ ▓     ▓   ▓   ▓ ▓ ▓▓
▓▓▓ ▓▓▓ ▓▓▓ ▓ ▓▓▓▓▓ ▓▓
▓   ▓               ▓▓
▓▓▓ ▓ ▓▓▓ ▓▓▓ ▓▓▓ ▓ ▓▓
▓   ▓ ▓   ▓     ▓ ▓ ▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓

4
下面是一份基于被接受的答案的Java注释实现:
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;

/**
 * Generate a maze using Prime's algorithm
 * Based on: https://dev59.com/QF0a5IYBdhLWcg3w-87a#29758926
 *
 * @author c0der
 * 25 Jun 2020
 *
 */
public class PrimeMazeGenerator implements Runnable {

    private static final int[][] DIRECTIONS = { //distance of 2 to each side
            { 0 ,-2}, // north
            { 0 , 2}, // south
            { 2 , 0}, // east
            {-2 , 0}, // west
    };

    private long delay = 0;
    private final CellModel[][] cells;
    private final Random random;

    public PrimeMazeGenerator(CellModel[][] cells) {
        this.cells = cells;
        random = new Random();
    }

    @Override
    public void run() {
        primMazeGeneration();
    }

    public void execute() {
        new Thread(this).start();
    }

    void primMazeGeneration() {

        //Start with a grid full of cellModelViews in state wall (not a path).
        for(int i = 0; i < cells.length; i++){
            for(int j = 0; j < cells[0].length ; j++){
                cells[i][j].setWall(true);
            }
        }

        //Pick a random cell
        int x = random.nextInt(cells.length);
        int y = random.nextInt(cells[0].length);

        cells[x][y].setWall(false); //set cell to path
        //Compute cell frontier and add it to a frontier collection
        Set<CellModel> frontierCells = new HashSet<>(frontierCellsOf(cells[x][y]));

        while (!frontierCells.isEmpty()){

            //Pick a random cell from the frontier collection
            CellModel frontierCell = frontierCells.stream().skip(random.nextInt(frontierCells.size())).findFirst().orElse(null);

            //Get its neighbors: cells in distance 2 in state path (no wall)
            List<CellModel> frontierNeighbors =  passageCellsOf(frontierCell);

            if(!frontierNeighbors.isEmpty()) {
                //Pick a random neighbor
                CellModel neighbor = frontierNeighbors.get(random.nextInt(frontierNeighbors.size()));
                //Connect the frontier cell with the neighbor
                connect(frontierCell, neighbor);
            }

            //Compute the frontier cells of the chosen frontier cell and add them to the frontier collection
            frontierCells.addAll(frontierCellsOf(frontierCell));
            //Remove frontier cell from the frontier collection
            frontierCells.remove( frontierCell);
            try {
                Thread.sleep(delay);
            } catch (InterruptedException ex) { ex.printStackTrace();}
        }
    }

    //Frontier cells: wall cells in a distance of 2
    private List<CellModel> frontierCellsOf(CellModel cell) {

        return cellsAround(cell, true);
    }

    //Frontier cells: passage (no wall) cells in a distance of 2
    private List<CellModel> passageCellsOf(CellModel cell) {

        return cellsAround(cell, false);
    }

    private List<CellModel> cellsAround(CellModel cell, boolean isWall) {

        List<CellModel> frontier = new ArrayList<>();
        for(int[] direction : DIRECTIONS){
            int newRow = cell.getRow() + direction[0];
            int newCol = cell.getColumn() + direction[1];
            if(isValidPosition(newRow, newCol) && cells[newRow][newCol].isWall() == isWall){
                frontier.add(cells[newRow][newCol]);
            }
        }

        return frontier;
    }

    //connects cells which are distance 2 apart
    private void connect( CellModel frontierCellModelView, CellModel neighbour) {

        int inBetweenRow = (neighbour.getRow() + frontierCellModelView.getRow())/2;
        int inBetweenCol = (neighbour.getColumn() + frontierCellModelView.getColumn())/2;
        frontierCellModelView.setWall(false);
        cells[inBetweenRow][inBetweenCol].setWall(false);
        neighbour.setWall(false);
    }

    private boolean isValidPosition(int row, int col) {
        return row >= 0 && row < cells.length
                    && col >= 0 && col < cells[0].length;
    }

    public PrimeMazeGenerator setDelay(long delay) {
        this.delay = delay;
        return this;
    }
}

CellModel.java:

/**
 * Maze cell representation
 *
 * @author c0der
 * 25 Jun 2020
 *
 */
public class CellModel{

    private final int row, column;
    private boolean isWall;
    //support to fire property change events
    private PropertyChangeSupport pcs;

    public CellModel(int row, int column)  {
       this(row, column, false);
    }

    public CellModel(int row, int column, boolean isWall) {
        this.row = row;
        this.column = column;
        this.isWall = isWall;
    }

    @Override
    public boolean equals(Object obj) {
        if(!(obj instanceof CellModel)) return false;
        CellModel other = (CellModel)obj;
        return row == other.getRow() && column == other.getColumn();
    }

    public void setPropertChangeSupport(PropertyChangeSupport pcs) {
        this.pcs = pcs;
    }

    private void firePropertyChange(String name, Object oldValue, Object newValue) {
        if(pcs != null) {
            pcs.firePropertyChange(name, oldValue, newValue);
        }
    }

    /**
    * Get {@link #isWall}
    */
    public boolean isWall() {
        return isWall;
    }

    /**
    * Set {@link #isWall}
    */
    public void setWall(boolean isWall) {
        Object old = this.isWall;
        this.isWall = isWall;
        firePropertyChange("Wall", old, isWall);
    }

    /**
    * Get {@link #row}
    */
    public int getRow() {
        return row;
    }

    /**
    * Get {@link #column}
    */
    public int getColumn() {
        return column;
    }

    @Override
    public String toString() {
        return  "["+ (isWall ? "Wall " : "Path " ) +  row + "-" + column + "]";
    }

    /* (non-Javadoc)
     * @see java.lang.Object#hashCode()
     */
    @Override
    public int hashCode() {
        return 17*row + 31*column;
    }
}

CellModel[][] cells 可以从MazeModel中获取:

/**
 * Maze representation
 *
 * @author c0der
 * 25 Jun 2020
 *
 */
public class MazeModel {

    /**
     * Collection to represent an entire maze
     */
    private final CellModel[][] cellModels;

    public MazeModel(int rows, int columns) {

        cellModels = new CellModel[rows][columns];
        for(int row=0; row <cellModels.length; row++) {
            for(int col=0; col<cellModels[row].length; col++) {
                CellModel cellModel = new CellModel(row, col);
                cellModels[row][col] = cellModel;
            }
        }
    }

    /**
    * Get {@link #cellModels}
    */
    public CellModel[][] getCellModels() {
        return cellModels;
    }
}

这个代码库包含完整的可运行代码,其中包括 SwingJavaFx gui。你可以在这里找到。


enter image description here


我尝试在C#中实现这个,但它不能正确生成迷宫。我不明白你的算法如何避免生成“孤立”的墙壁,也就是被8个“通道”单元包围的墙壁单元。 - Jez

3
你的解决方案看起来并没有什么问题。特别地,它是一个迷宫,如果你不能对角行走,那么每个(开放的)位置到每个其他(开放的)位置都有一条唯一的路径。它唯一的问题似乎在于风格。
如果你考虑你发布的“正确”迷宫(不包括外边框),并将左上角的单元格视为 (0,0),你会发现通道和墙壁在某种意义上交替出现。每个坐标都是偶数的单元格必须是通道,每个坐标都是奇数的单元格必须是墙壁。因此,你只有在一个坐标是偶数,另一个坐标是奇数的单元格中才有选择的余地。
给定一个位于场地中央、两个坐标都是偶数的单元格 (x,y),这个单元格必须是通道。单元格 (x-1,y)(x+1,y)(x,y-1)(x,y+1) 是潜在的围绕它的墙壁,而单元格 (x-2,y)(x+2,y)(x,y-2)(x,y+2) 是相应墙壁对面的方块。
有了这些信息,你只需实现你的算法,并额外要求在第二步中选择两个坐标都是偶数的单元格。

3

在过程的最开始,尝试使用唯一随机权重加权墙壁。该权重列表将永远不会改变。当您从可用墙壁列表中选择下一个墙壁时,请选择具有最小权重的墙壁。


我不确定那样做会产生不同的结果,但我可以尝试你的建议。 - donth77
@donth77 它能正常工作是因为它将Prim算法转换成了更好的Kruskal算法。 :-) - Edward Doolittle
1
我开始理解这个问题了。维基百科文章写得很差。根据人们如何看待这个问题,他们要么在单元格之间维护一个墙列表,要么尝试将单元格本身用作墙壁或通道。问题所涉及的文章没有具体说明。如果将单元格视为可能的墙壁,则会出现“相反是什么”的问题。 - BitTickler

2
您的问题的简单答案是,在添加边缘时,您需要检查该边缘是否意味着删除任何其相邻墙壁块的最后一个邻居墙壁。这将防止任何墙壁仅通过角连接。

2
在我了解该议题之前,我自己想出了一种完全不同的方法。看看你是否认为它是一个有用的方法。
很久以前,当我看到IBM PC字符图形(Code Page中的一部分)时,我就想用这种方式创建迷宫。我的方法分为两个阶段:
1. 在整数数组中生成迷宫,使用位编码值1-15表示迷宫每个单元格中开放的方向。 2. 将其呈现为可见形式。因此,在显示迷宫之前,墙壁并不是需要考虑的因素。
每个单元格最开始都是0(未选择),然后可以将4个位(1=向右,2=向下,4=向左,8=向上)中的任何一个打开。天真地说,您可以在每个单元格中随机选择1-15的数字,除了以下五件事:
1. 从绘制围绕整个数组的“围墙”(包括走廊和角落),并在两个点处留下通道开始。这是处理边界条件最简单的方法。 2. 加权选择,使死胡同不常见,直线或拐角过道常见,交叉口不经常出现。 3. 将每个单元格与其周围已设置的单元格匹配:如果相邻单元格打开相应的位(左侧单元格中的1位等),则强制在此单元格中打开该位,如果关闭,则强制关闭此单元格中的位。 4. 找到一种方法确保起点和终点连接(需要进一步研究)。 5. 管理填充所有单元格而不创建空洞(需要更多研究)。
以下是以字符图形形式显示的“原始”数组:
    ┌│───────┐  
    │└─┐┌┐│  │  
    ││┌┴┤│├─┐│  
    │├┴─┘│└┐││  
    │└─┐──┐│││  
    │┌┬┴─┌┘│││  
    ││├┐│└┬┘││  
    │└┤│└┬┴─┤│  
    │─┴┴─┘──┤│  
    └───────│┘  

当渲染结果时,我使用一个3x4的字符图形网格来显示每个单元格。以下是一个示例:
╔═══╡  ╞═══════════════════════════════╗
║░░░│  │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
║░░╔╡  ╞════════════════════════════╗░░║
║░░║│  └───────┐┌──────┐┌──┐        ║░░║
║░░║│          ││      ││  │        ║░░║
║░░║└───────┐  ││  ┌┐  ││  │        ║░░║
║░░║┌──┐┌───┘  └┘  ││  ││  └───────┐║░░║
║░░║│  ││          ││  ││          │║░░║
║░░║│  ││  ┌────┐  ││  ││  ┌────┐  │║░░║
║░░║│  └┘  └────┘  ││  ││  └───┐│  │║░░║
║░░║│              ││  ││      ││  │║░░║
║░░║│  ┌───────────┘└──┘└───┐  ││  │║░░║
║░░║│  └───────┐┌──────────┐│  ││  │║░░║
║░░║│          ││          ││  ││  │║░░║
║░░║└───────┐  │└───────┐  │└──┘│  │║░░║
║░░║┌───────┘  └───┐┌───┘  │┌──┐│  │║░░║
║░░║│              ││      ││  ││  │║░░║
║░░║│  ┌┐  ┌───────┘│  ┌───┘│  ││  │║░░║
║░░║│  ││  └───┐┌──┐│  └────┘  ││  │║░░║
║░░║│  ││      ││  ││          ││  │║░░║
║░░║│  ││  ┌┐  ││  │└───┐  ┌───┘│  │║░░║
║░░║│  └┘  ││  ││  └────┘  └────┘  │║░░║
║░░║│      ││  ││                  │║░░║
║░░║└───┐  ││  │└───┐  ┌────────┐  │║░░║
║░░║┌───┘  └┘  └────┘  │┌───────┘  │║░░║
║░░║│                  ││          │║░░║
║░░║└──────────────────┘└───────┐  │║░░║
║░░╚════════════════════════════╡  ╞╝░░║
║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│  │░░░║
╚═══════════════════════════════╡  ╞═══╝

看看您可以用这种方法做什么。(不同的字体选择使它看起来比这里更好,所有行都无缝连接-当然必须是等宽字体)。


0

我认为你的代码没有按预期工作,因为你忘记在前沿单元格列表中删除两个相同的前沿单元格。请注意,两个通道单元格可以共享同一个前沿单元格。


这感觉更像是一条注释,而不是答案 :) - mikus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接