6

这将是一个很长的帖子,只是为了好玩,所以如果你没有太多时间最好去帮助人们解决更重要的问题:)

最近在 Xbox 上发布了一款名为“Tower Bloxx”的游戏。游戏的一部分是以最佳方式在场地上放置不同颜色的塔,以最大限度地增加最有价值的塔的数量。我写了一个算法,可以确定最有效的塔放置,但它不是很有效,几乎只是暴力强制所有可能的组合。对于具有 4 种塔型的 4x4 场,它在大约 1 小时内解决,5 种塔型大约需要 40 小时,这太多了。

规则如下: 场地上可以放置 5 种类型的塔。有几种类型的字段,最简单的一种只是 4x4 矩阵,其他字段有一些您无法构建的“空白”。您的目标是在场地上放置尽可能多的最有价值的塔,以最大化场地上的总塔价值(假设所有塔是一次建造的,没有转弯)。

塔类型(按从低到高的顺序排列):

  • 蓝色 - 可以放在任何地方,值 = 10
  • 红色 - 只能放置在蓝色之外,值 = 20
  • 绿色 - 放置在红色和蓝色之外,值 = 30
  • 黄色 - 除了绿色、红色和蓝色,值 = 40
  • 白色 - 除了黄色、绿色、红色和蓝色之外,值 = 100

这意味着例如绿色塔应该在北、南、西或东相邻单元格中至少有 1 个红色和 1 个蓝色塔(对角线不计算在内)。白塔应该被所有其他颜色包围。

这是我在 4x4 场上的 4 个塔的算法:

  1. 组合总数 = 4^16
  2. 循环 [1..4^16] 并将每个数字转换为 base4 字符串以编码塔的位置,因此 4^16 = "3333 3333 3333 3333" 表示我们的塔类型(0=蓝色,..., 3=黄色)
  3. 将塔放置字符串转换为矩阵。
  4. 对于矩阵中的每个塔,检查其邻居,如果任何要求失败,则整个组合失败。
  5. 将所有正确的组合放入一个数组中,然后将该数组按字典顺序排序为字符串,以找到可能的最佳组合(首先需要对字符串中的字符进行排序)。

我想出的唯一优化是跳过不包含任何最有价值的塔的组合。它跳过了一些处理,但我仍然循环遍历所有 4^16 个组合。

有没有想过如何改进?如果在 java 或 php 中,代码示例会很有帮助。

- - - -更新 - - - -

添加更多非法状态后(黄色不能建在角落,白色不能建在角落和边缘,场应至少包含每种类型的塔),意识到在4x4场上只能建造1个白塔并优化 Java 代码,总时间从 40 小时减少到约 16 小时。也许线程会将其缩短到 10 小时,但这可能是蛮力限制。

4

7 回答 7

5

我发现这个问题很有趣,因为我正在自学 Haskell,所以我决定尝试用这种语言实现一个解决方案。

我考虑了分支定界,但想不出一个很好的方法来约束解决方案,所以我只是通过丢弃违反规则的板来进行一些修剪。

我的算法从“空”板开始工作。它将塔的每种可能颜色放置在第一个空槽中,然后在每种情况下(每种颜色)递归调用自身。递归调用尝试第二个插槽中的每种颜色,再次递归,直到板已满。

在放置每个塔时,我会检查刚刚放置的塔及其所有邻居,以验证它们是否遵守规则,将任何空邻居视为外卡。所以如果一个白塔有四个空的邻居,我认为它是有效的。如果某个展示位置无效,我不会在该展示位置上递归,从而有效地修剪其下的整个可能性树。

代码的编写方式,我生成了所有可能解决方案的列表,然后查看列表以找到最佳解决方案。实际上,由于 Haskell 的惰性求值,列表元素是在搜索功能需要它们时生成的,并且由于它们不再被引用,它们立即可用于垃圾收集,因此即使对于 5x5 板内存使用量也相当小(2 MB)。

性能相当不错。在我的 2.1 GHz 笔记本电脑上,程序的编译版本使用一个内核在约 50 秒内解决了 4x4 的情况。我现在正在运行一个 5x5 示例,看看需要多长时间。由于函数式代码很容易并行化,因此我还将尝试并行处理。有一个并行化的 Haskell 编译器,它不仅可以将工作分布在多个内核上,还可以分布在多台机器上,这是一个非常可并行化的问题。

到目前为止,这是我的代码。我意识到您指定了 Java 或 PHP,而 Haskell 则完全不同。如果你想玩它,你可以修改底部正上方的变量“bnd”的定义来设置棋盘大小。只需将其设置为 ((1,1),(x, y)),其中 x 和 y 分别是列数和行数。

import Array
import Data.List

-- Enumeration of Tower types.  "Empty" isn't really a tower color,
-- but it allows boards to have empty cells
data Tower = Empty | Blue | Red | Green | Yellow | White
             deriving(Eq, Ord, Enum, Show)

type Location = (Int, Int)
type Board = Array Location Tower

-- towerScore omputes the score of a single tower
towerScore :: Tower -> Int
towerScore White = 100
towerScore t     = (fromEnum t) * 10

-- towerUpper computes the upper bound for a single tower
towerUpper :: Tower -> Int
towerUpper Empty = 100
towerUpper t = towerScore t

-- boardScore computes the score of a board
boardScore :: Board -> Int
boardScore b = sum [ towerScore (b!loc) | loc <- range (bounds b) ]

-- boardUpper computes the upper bound of the score of a board
boardUpper :: Board -> Int
boardUpper b = sum [ bestScore loc | loc <- range (bounds b) ]
    where
      bestScore l | tower == Empty = 
                      towerScore (head [ t | t <- colors, canPlace b l t ])
                  | otherwise = towerScore tower
                  where 
                    tower = b!l
                    colors = reverse (enumFromTo Empty White)

-- Compute the neighbor locations of the specified location
neighborLoc :: ((Int,Int),(Int,Int)) -> (Int,Int) -> [(Int,Int)]
neighborLoc bounds (col, row) = filter valid neighborLoc'
    where
      valid loc = inRange bounds loc
      neighborLoc' = [(col-1,row),(col+1,row),(col,row-1),(col,row+1)]

-- Array to store all of the neighbors of each location, so we don't
-- have to recalculate them repeatedly.
neighborArr = array bnd [(loc, neighborLoc bnd loc) | loc <- range bnd]

-- Get the contents of neighboring cells
neighborTowers :: Board -> Location -> [Tower]
neighborTowers board loc = [ board!l | l <- (neighborArr!loc) ]

-- The tower placement rule.  Yields a list of tower colors that must
-- be adjacent to a tower of the specified color.
requiredTowers :: Tower -> [Tower]
requiredTowers Empty  = []
requiredTowers Blue   = []
requiredTowers Red    = [Blue]
requiredTowers Green  = [Red, Blue]
requiredTowers Yellow = [Green, Red, Blue]
requiredTowers White  = [Yellow, Green, Red, Blue]

-- cellValid determines if a cell satisfies the rule.
cellValid :: Board -> Location -> Bool
cellValid board loc = null required ||
                      null needed   ||
                      (length needed <= length empties)
    where
      neighbors = neighborTowers board loc
      required  = requiredTowers (board!loc)
      needed    = required \\ neighbors
      empties   = filter (==Empty) neighbors

-- canPlace determines if 'tower' can be placed in 'cell' without
-- violating the rule.
canPlace :: Board -> Location -> Tower -> Bool
canPlace board loc tower =
    let b' = board // [(loc,tower)]
    in cellValid b' loc && and [ cellValid b' l | l <- neighborArr!loc ]

-- Generate a board full of empty cells       
cleanBoard :: Array Location Tower
cleanBoard = listArray bnd (replicate 80 Empty)

-- The heart of the algorithm, this function takes a partial board
-- (and a list of empty locations, just to avoid having to search for
-- them) and a score and returns the best board obtainable by filling
-- in the partial board
solutions :: Board -> [Location] -> Int -> Board
solutions b empties best | null empties = b
solutions b empties best = 
    fst (foldl' f (cleanBoard, best) [ b // [(l,t)] | t <- colors, canPlace b l t ])
    where
      f :: (Board, Int) -> Board -> (Board, Int)
      f (b1, best) b2  | boardUpper b2 <= best = (b1, best)
                       | otherwise = if newScore > lstScore
                                     then (new, max newScore best)
                                     else (b1, best)
                       where
                         lstScore = boardScore b1
                         new      = solutions b2 e' best
                         newScore = boardScore new
      l = head empties
      e' = tail empties

colors = reverse (enumFromTo Blue White)

-- showBoard converts a board to a printable string representation
showBoard :: Board -> String
showBoard board = unlines [ printRow row | row <- [minrow..maxrow] ]
    where
      ((mincol, minrow), (maxcol, maxrow)) = bounds board
      printRow row = unwords [ printCell col row | col <- [mincol..maxcol] ]
      printCell col row = take 1 (show (board!(col,row)))

-- Set 'bnd' to the size of the desired board.                          
bnd = ((1,1),(4,4))

-- Main function generates the solutions, finds the best and prints
-- it out, along with its score
main = do putStrLn (showBoard best); putStrLn (show (boardScore best))
       where
         s = solutions cleanBoard (range (bounds cleanBoard)) 0
         best = s

另外,请记住这是我的第一个重要的 Haskell 程序。我相信它可以做得更加优雅和简洁。

更新:由于用 5 种颜色制作 5x5 仍然非常耗时(我等了 12 个小时但还没有完成),我又看看如何使用边界修剪更多的搜索树。

我的第一个方法是通过假设每个空单元都被一个白色的塔填充来估计部分填充板的上限。然后我修改了“解决方案”功能以跟踪看到的最佳分数并忽略任何上限低于该最佳分数的棋盘。

这帮助了一些人,将 4x4x5 板从 23 秒减少到 15 秒。为了进一步改进它,我修改了上限函数以假设每个 Empty 都装满了可能的最佳塔,与现有的非空单元格内容一致。这帮助很大,将 4x4x5 时间减少到 2 秒。

在 5x5x5 上运行它需要 2600 秒,得到以下电路板:

G B G R B
R B W Y G
Y G R B R
B W Y G Y
G R B R B

730 分。

我可能会再做一次修改,让它找到所有的最高得分板,而不仅仅是一个。

于 2009-10-29T07:27:15.023 回答
4

如果您不想做 A*,请使用分支定界方法。这个问题应该相对容易编码,因为您的价值函数定义明确。我想您应该能够相对轻松地修剪掉搜索空间的大部分。但是,由于您的搜索空间很大,因此可能仍需要一些时间。只有一种方法可以找出:)

wiki 文章不是世界上最好的。Google 可以为您找到大量很好的示例和树以及其他东西来进一步说明该方法。

于 2009-10-26T18:28:29.020 回答
3

改进蛮力方法的一种简单方法是仅探索合法状态。例如,如果您正在尝试所有可能的状态,您将测试右上角是白塔的许多状态。所有这些状态都是非法的。生成和测试所有这些状态是没有意义的。因此,您希望一次生成一个状态,并且仅在您实际处于潜在有效状态时才更深入地了解树。这将使您的搜索树减少许多数量级。

您可能还可以做更多花哨的事情,但这是对您当前解决方案的一个易于理解(希望)的改进。

于 2009-10-26T18:32:28.793 回答
3

我认为您会想要使用分支定界算法,因为我认为为 A* 实现提出一个好的启发式算法将很难(但这只是我的直觉)。

分支定界实现的伪代码是:

board = initial board with nothing on it, probably a 2D array

bestBoard = {}

function findBest(board)
  if no more pieces can be added to board then
     if score(board) > score(bestBoard) then
       bestBoard = board
     return
  else
    for each piece P we can legally add to board
      newBoard = board with piece P added
      //loose upper bound, could be improved
      if score(newBoard) + 100*number of blanks in newBoard > score(bestBoard)
        findBestHelper(newBoard)

我们的想法是按顺序搜索所有可能的板,但我们会跟踪迄今为止找到的最好的板(这是界限)。然后,如果我们找到一个我们知道永远不会比目前最好的部分板更好的部分板,那么我们停止在该部分板上工作:我们修剪搜索树的那个分支。

在上面的代码中,我通过假设所有空白都将被白色块填充来进行检查,因为我们不能做得比这更好。我敢肯定,只要稍加思考,您就可以想出比这更紧密的界限。

您可以尝试优化的另一个地方是 for-each 循环的顺序。您想按正确的顺序试件。也就是说,最理想的情况是,您希望找到的第一个解决方案是最好的解决方案,或者至少是一个得分非常高的解决方案。

于 2009-10-26T19:18:37.657 回答
1

似乎一个好的方法是从一个白色的塔开始,然后根据要求在它周围建造一组塔,试图找到可以作为联锁瓷砖的最小可能的彩色形状集。

于 2009-10-26T18:06:16.773 回答
1

我想提倡整数未知数的线性规划,但事实证明即使在二进制情况下它也是 NP 难的。但是,您仍然可以在优化像您这样的问题方面取得巨大成功,其中有许多有效的解决方案,您只需要最好的解决方案。

线性规划,对于这类问题,本质上等于有很多变量(例如,单元格(M,N)中存在的红色塔的数量)和变量之间的关系(例如,单元格中的白色塔的数量)单元格 (M, N) 必须小于或等于其所有邻居中具有最小此类数量的非白色塔的数量)。编写线性程序有点痛苦,但如果你想要一个在几秒钟内运行的解决方案,这可能是你最好的选择。

于 2009-10-26T18:28:06.853 回答
1

你已经收到了很多关于算法方面的好建议,所以我没有太多要补充的。但是,假设 Java 作为语言,这里有一些相当明显的性能改进建议。

  • 确保您没有在 4^16 循环内实例化任何对象。JVM 重新初始化现有对象比创建新对象要便宜得多。使用基元数组甚至更便宜。:)
  • 如果你能帮上忙,请远离集合类。它们会增加很多您可能不需要的开销。
  • 确保您没有连接任何字符串。使用字符串生成器。
  • 最后,考虑用 C 重写整个东西。
于 2009-10-26T21:14:06.487 回答