玩转f#的一个实例——拼图游戏


起因

起因是这样的:前一阵逛抖音,买了个预售的拼图游戏。据说是国内团队开发的,非常有趣。大概长这样:

拼图设计上故意空出三个空位,拼图的格子上印有1-12月的月份,1-31号的日期,以及周一到周日的星期。空出的三个空位,可以正好用来匹配月份-日期-星期的组合。这个可以使得你每天都可以有一道新的谜题可玩,好几年都不带重样的。

可是呢,这是个预售商品,大概研发团队找的生产厂商生产力不足吧。下单后要一个半月才发货。实属令人抓腮。

正好最近在折腾F#,干脆我来验证下是不是每种情况都有解吧。

于是,开工。

思路

暂时不需要太复杂的算法,就用最简单的DFS就可以了。

我们从左到右,从上到下搜索棋盘格,只要这个格子应放但未放入积木,我们就尝试放一个形状进去,然后继续搜索。如果无法放入我们就继续试其他形状。如果所有积木的所有形状都试过了我们就回溯。这样可以保证了搜索到某个点时,它之前的所有行、它同一行左侧的所有点都已经填满了积木。只需要搜完整个地图,说明全部积木都放进去了。

因为是函数式编程,上述的过程也被转换成了递归。

具体步骤:

  1. 将积木保存为大小仅为长*宽的点阵,有木头的为1,空白的为0。并把积木编上从0到10的号。
  2. 针对每个积木,通过旋转、翻转,产生出它的所有的变形形状,并且剔除重复的形状,作为该积木应该尝试的所有形状列表。每个形状记录左上角那块积木的位置(相对于当前积木区域的坐标),上比左优先。后面的搜索要用到。
  3. 地图抽象为7行6列的矩阵。不可达的区域定义为-100,需要空出来的月份、日期、星期三个格子定义为100,未放置任何积木的定义为-1,放置了积木的格子记录为这个积木的编号(0-10)
  4. 针对棋盘开始搜索,从(0,0)处依次向右、向下搜索。注意这里的坐标采取(x,y)形式,也就是列在前,行在后。
    1. 遇到已经放入积木、不可达区域、需要空出来的格子则直接跳过,递归搜索下一个点。
    2. 遇到空白格子(为-1的格子)则针对该点遍历所有积木的所有形状。将该形状的左上角点放置于此格子,并判断是否能放进该形状。如果形状能放入则放入,并继续递归,在尝试下一个形状之前删除该形状。如果无法放入,则不进行递归。
    3. 当搜索到达(3,6)格子时,意味着找到了一个解。此时可以选择继续搜索找出所有解,或者退出搜索完成任务。(两种我都试了,解法真的很多很多,本文只讲取一种解的情况)

那么,开工

准备工作

定义形状、积木

这里采用记录:

//积木变形形状
type Shape = 
    {
        X: int //最大x坐标,而非length
        Y: int //最大y坐标,而非length
        TopLeft: int * int
        Map: int[,]
    }

//积木单元
type Piece =
    {
        Shapes: list
    }

生成积木

做一个积木的形状生成器,这样可以方便积木形状的生成:

  1. 单个积木根据有木头的点坐标自动生成积木的基本形状的map。
  2. 进行旋转获取4个形状,然后翻转后再旋转获取4个形状
  3. 对8种形状做去重
  4. 针对所有积木执行上述步骤,生成所有积木

//根据提供的占位坐标点序列,生成所有积木单元,并计算全部不重复的形状
let genPieces (piecesDesc: seq>) =
    //生成一个积木单元
    let genPiece (arr: seq) =

        //根据基础形状,生成所有不重复形状
        let genAvailabeShapes shape =

            //变形,f为目的地元素函数
            let transShape shape f =
                let cur = Array2D.create (shape.Y + 1) (shape.X + 1) 0 //(x,y)格式,先列后行
                let mutable minX = -1
                let mutable minY = -1
                for y in 0 .. shape.X do  //行列互换
                    for x in 0 .. shape.Y do
                        cur[x, y] <- f shape x y
                        if minX = -1 && cur[x, y] = 1 then minX <- x; minY <- y 
                {
                    Map = cur
                    X = shape.Y
                    Y = shape.X
                    TopLeft = (minX, minY)
                }
    
            //旋转变形(顺时针)
            let rotateShape shape =
                transShape shape (fun shape x y -> shape.Map[shape.X - y, x])
    
            //翻转变形(左上、右下对角线)
            let turnOverShape shape =
                transShape shape (fun shape x y -> shape.Map[y, x])
    
            //重复旋转获取四种情况(含未旋转)
            let getRotateShapes4 shape = 
                let rec rotateLoop shape n = //4、3、2、1,尾递归
                    match n with
                    | 0 -> []
                    | _ -> shape :: rotateLoop (rotateShape shape) (n - 1)
                rotateLoop shape 4

            //旋转、翻转获取变形列表
            let rotatedShapes = getRotateShapes4 shape  //旋转的4个形状
            let turnedOverShape = turnOverShape shape  //翻转形状
            let turnedOverRotatedShapes = getRotateShapes4 turnedOverShape //基于翻转旋转的4个形状
            
            let allShapes = List.append rotatedShapes turnedOverRotatedShapes  //拼接全部8个形状

            //筛选有效形状(不重复)
            let searchAvailableShapes (allshapes: List) =
                //判断形状相同,用于剔除旋转后重复的形状
                let shapeEqual shape1 shape2 =
                    if shape1.X <> shape2.X then false
                    elif shape1.Y <> shape2.Y then false
                    else
                        seq { 0 .. shape1.Y }
                        |> Seq.exists (fun y ->
                            seq { 0 .. shape1.X }
                            |> Seq.exists (fun x ->
                                shape1.Map[x, y] <> shape2.Map[x, y]
                            ))
                        |> not

                //递归筛选有效形状(不重复)
                let rec searchAvailableShapesLoop (allshapes: List) n = //此处不是序号,是数量,8到1
                    match n with
                    | 0 -> []
                    | _ ->
                        let next = searchAvailableShapesLoop allshapes (n - 1)
                        let hasEqual =
                            seq { 0 .. next.Length - 1 }
                            |> Seq.exists(fun i -> shapeEqual next.[i] allshapes.[n - 1])
                        if hasEqual then next else allshapes[n - 1] :: next

                searchAvailableShapesLoop allshapes allshapes.Length

            //生成最终积木作为结果
            searchAvailableShapes allShapes

        //初始化
        let maxX = Seq.map (fun (x, _) -> x) arr |> Seq.max
        let maxY = Seq.map (fun (_, y) -> y) arr |> Seq.max
        let cur = Array2D.create (maxX + 1) (maxY + 1) 0 //(x,y)格式,先列后行

        //根据参数填充格子
        Seq.iter (fun (x, y) -> cur[x, y] <- 1) arr  

        //取左上点,上优先
        let mutable minX = -1
        let mutable minY = -1
        seq { 0 .. maxY }
        |> Seq.exists (fun y ->
            seq { 0 .. maxX }
            |> Seq.exists (fun x ->
                if minX = -1 && cur[x, y] = 1 then 
                    minX <- x; minY <- y; true
                else false
            ))
        |> ignore
                
        //基础形状
        let shape = 
            {
                X = maxX
                Y = maxY
                TopLeft = (minX, minY)  //用于判断
                Map = cur
            }

        //计算本积木的所有可用变形并返回为一个Piece
        {
            Shapes = genAvailabeShapes shape
        }

    //根据参数生成所有积木
    Seq.map (fun pieceDesc -> genPiece pieceDesc) piecesDesc |> Array.ofSeq

然后利用生成器生成全部积木:

//根据实际情况,生成积木
let pieces = genPieces [
    [ (0, 0); (0, 1); (1, 1); (2, 1); (2, 0) ]
    [ (0, 0); (0, 1); (1, 1) ]
    [ (0, 0); (0, 1); (1, 0); (2, 0) ]
    [ (0, 0); (0, 1); (0, 2); (1, 0); (1, 1) ]
    [ (0, 0); (0, 1); (1, 0); (1, 1) ]
    [ (0, 0); (0, 1); (0, 2); (0, 3); (1, 1) ]
    [ (0, 0); (0, 1); (0, 2); (0, 3); ]
    [ (0, 0); (0, 1); (0, 2); (0, 3); (1, 0) ]
    [ (0, 0); (0, 1); (0, 2); (1, 0); (2, 0) ]
    [ (0, 0); (1, 0); (1, 1); (2, 1) ]
    [ (0, 0); (1, 0); (2, 0); (1, 1) ]
]

月份、日期、星期

写个函数,用于将月份、日期、星期转换成坐标:

let blankPos (month, day, weekday) =

    //计算星期坐标
    let weekdayPos weekdayZB =
        if weekdayZB < 3 then (1 + weekdayZB, 0)
        else weekdayZB - 3, 1
        
    //计算月份坐标
    let monthPos monthZB =
        (4 + monthZB % 4, monthZB / 4)

    //计算日期坐标
    let dayPos dayZB =
        match dayZB with
        | 0 | 1 | 2 | 3 -> (dayZB, 2)
        | _ -> ((dayZB - 4) % 8, 3 + (dayZB - 4) / 8)

    (monthPos (month - 1), dayPos (day - 1), weekdayPos (weekday - 1)) 

求解

求解函数

按照之前的算法,写出求解函数

let solve m d w =
    //搜索时用的map
    let map = Array2D.create 8 7 -1 //(x,y)格式,先列后行
    //赋初值
    //map[0, 0] <- -100  //这里不对……
    seq { 3 .. 7 } |> Seq.iter (fun i -> map[i, 6] <- -100)  //-100为地图黑域
    map[fst m, snd m] <- 100  //100为空出位置
    map[fst d, snd d] <- 100
    map[fst w, snd w] <- 100
    //搜索时记录积木是否已经使用的map
    let pieceUsed = Array.create pieces.Length false

    //打印解
    let printMap () =
        let showChar d =
            match d with
            | -100 -> ' '
            | -1 -> '_'
            | 100 -> ' '
            | d -> "*#+&@$%08oD"[d]
        printfn ""
        for y in 0 .. 6 do
            for x in 0 .. 7 do
                printf "%c " (showChar map.[x, y])
            printfn ""
        printfn ""

    //尝试填充shape,若失败撤销填充,若成功标记used为true
    let tryShape i shape x y =
        let xOffset, yOffset = shape.TopLeft
        //排除出界情况
        if x - xOffset < 0 then false
        elif x - xOffset + shape.X > 7 then false
        elif y - yOffset < 0 then false
        elif y - yOffset + shape.Y > 6 then false
        else
            let rec tryShapeRec i shape xLoop yLoop =
                match (xLoop, yLoop) with
                | (_, -1) -> true
                | (-1, _) -> tryShapeRec i shape shape.X (yLoop - 1)
                | _ -> 
                    //x为当前测试map位置,它减去offset是当前图形起始位置, loopx为循环遍历当前图形小坐标。 y同理
                    let xIndex, yIndex = (x - xOffset + xLoop, y + yOffset + yLoop)  
                    if shape.Map[xLoop, yLoop] <> 1 then tryShapeRec i shape (xLoop - 1) yLoop
                    elif map[xIndex, yIndex] <> -1 then false
                    else
                        map[xIndex, yIndex] <- i
                        let rsl = tryShapeRec i shape (xLoop - 1) yLoop
                        if not rsl then map[xIndex, yIndex] <- -1
                        rsl

            let rsl = tryShapeRec i shape shape.X shape.Y
            if rsl then pieceUsed[i] <- true
            rsl
            
    //回滚填充的shape
    let eraseShape i shape x y =
        let xOffset, yOffset = shape.TopLeft
        for yLoop in 0 .. shape.Y do
            for xLoop in 0 .. shape.X do
                if shape.Map[xLoop, yLoop] = 1 then
                    map[x - xOffset + xLoop, y - yOffset + yLoop] <- -1
        pieceUsed[i] <- false

    //主过程,逐级递归
    let rec solveLoop x y = 
        match (x, y) with
        | (3, 6) -> 
            printMap (); 
            true
        | _ -> 
            if map[x, y] <> -1 then //已经填充
                solveLoop ((x + 1) % 8) (y + ((x + 1) / 8))
            else  //尝试填充
                seq {0 .. 10}
                |> Seq.exists(fun i ->
                    if not pieceUsed[i] then
                        pieces[i].Shapes
                        |> List.exists (fun shape ->
                            if tryShape i shape x y then
                                //printMap () //测试
                                let nextRsl = solveLoop ((x + 1) % 8) (y + ((x + 1) / 8))
                                if nextRsl then true
                                else eraseShape i shape x y; false
                            else false)
                    else false)

    //printMap () //测试用,打印map

    //调用
    solveLoop 0 0

执行

考虑两种模式:

  1. 根据用户输入的月份、日期、星期求出这种情况的解。
  2. 自动计算所有月份、日期、星期组合的所有情况,验证每种情况的解。

以上两种我也都做了。但是下面代码只演示了第一种。

首先我们Nuget安装一个包,用于scanf

Install-Package FSharp.Scanf

然后open它

open FSharp.Scanf

然后根据输入转换成坐标:

let m, d, w = scanfn "%d %d %d"
let (month, day, weekday) = blankPos (m, d, w)

接下来就是根据输入求解了:

printfn "=== %2d.%2d-%d ===" m d w

//解决问题
let solved = solve month day weekday

if not solved then printfn "%2d.%2d-%d 此题无解" m d w

printfn "==============="

代码码完了。

随便测试一个:

  • 月份:8
  • 日期:16
  • 星期:三

再测试一个:

  • 月份:12
  • 日期:31
  • 星期:日

随后测试了下速度,在编译为Release的情况下:

  • 针对特定月份、日期、星期,算出所有解的数量,大约需要3秒
  • 针对所有的月份、日期、星期的组合,验证每一种至少有一个解的情况,总用时大约需要3秒。

发现效率还可以,就不考虑继续优化算法了。

OK。等玩具到了,想不出来解的时候,至少有个工具能用了。