如何使用选择单子(Select monad)来解决 N 皇后问题?

12
我正在试图理解Select单子的工作原理。显然,它是Cont的近亲,可用于回溯搜索。 我有一个基于列表的解决n皇后问题的方案:
-- All the ways of extracting an element from a list.
oneOf :: [Int] -> [(Int,[Int])] 
oneOf [] = [] 
oneOf (x:xs) = (x,xs) : map (\(y,ys) -> (y,x:ys)) (oneOf xs)

-- Adding a new queen at col x, is it threathened diagonally by any of the
-- existing queens?
safeDiag :: Int -> [Int] -> Bool
safeDiag x xs = all (\(y,i) -> abs (x-y) /= i) (zip xs [1..])

nqueens :: Int -> [[Int]]
nqueens queenCount = go [] [1..queenCount]
  where
    -- cps = columsn of already positioned queens. 
    -- fps = columns that are still available
    go :: [Int] -> [Int] -> [[Int]]
    go cps [] = [cps]
    go cps fps = [ps | (p,nfps) <- oneOf fps, ps <- go (p:cps) nfps, safeDiag p cps]

我在努力调整这个解决方案以使用Select,但不太顺利。

看起来Select可以让你抽象出用于比较答案的“评估函数”。该函数被传递给runSelect。我有一种感觉,我的解决方案中的safeDiag可能可以作为评估函数,但如何结构化Select计算本身呢?

此外,仅使用Select单子是否足够,还是需要在列表上使用转换器版本?


你确定要使用 Select 单子类型吗?我的理解是,Select 尝试证明可能解决方案的存在(作为证明的见证)。Select 的典型示例是 SAT 求解器。你可能可以通过在列表单子类型上使用 SelectT 强制执行某些操作,但我更确信你真正需要使用 Select 单子类型。 - Alec
@Alec,我读到Select在回溯搜索中很好用,而n皇后问题是这种类型的原型问题,所以我认为它是monad的一个很好的使用案例。 - danidiaz
区别可能在于回溯以找到所有解决方案和回溯直到找到一个解决方案之间。不过,我之前只玩过一次“选择”,所以不要认真对待我说的任何话。 - Alec
2
不是 Select 单子,而是这个项目:queenslogic 使用 Logic 单子通过回溯法解决 N 皇后问题。 - Dave Compton
相关链接:https://julesh.com/2021/03/30/selection-functions-and-lenses/ - danidiaz
3个回答

9
我知道这个问题已经有答案并且大约4年了,但是为了未来可能会遇到这个问题的人着想,我想补充一些额外的信息。具体来说,我想试着回答以下两个问题:
  • 如何将返回单个值的多个Select组合以创建返回值序列的单个Select?
  • 当解决方案路径注定失败时,是否有可能提前返回?

链接Selects

Select在transformers库中被实现为一个单子变换器(monad transformer),但我们还是先看看如何只为Select实现>>=

(>>=) :: Select r a -> (a -> Select r b) -> Select r b
Select g >>= f = Select $ \k ->
  let choose x = runSelect (f x) k
  in  choose $ g (k . choose)

我们首先定义一个新的Select,它接收类型为a -> r的输入参数k(回忆一下,Select包装了类型为(a -> r) -> a的函数)。您可以将k视为一个函数,它返回给定a的类型为r的“分数”,这个分数可以用来确定Select函数应该返回哪个a
在我们的新Select内部,我们定义了一个名为choose的函数。此函数将一些x传递给函数f,它是单子绑定的(monadic binding)部分a -> m b:它将m a计算的结果转换为新的计算m b。因此,f将使用x返回一个新的Select,然后choose会使用我们的得分函数k运行它。您可以将choose视为一个函数,它询问“如果我选择x并将其向下传递,最终结果会是什么?”
在第二行中,我们返回了choose $ g (k . choose)。函数k . choosechoose和我们最初的得分函数k的组合:它接收一个值,计算选择该值的下游结果,并返回该下游结果的得分。换句话说,我们创建了一种“预知”得分函数:它不返回给定值的得分,而是返回我们选择该值后将获得的最终结果的得分。通过将我们的“预知”得分函数传递给g(我们要绑定到的原始Select),我们能够选择导致我们正在寻找的最终结果的中间值。一旦我们有了中间值,我们只需将其传回choose并返回结果即可。
这就是我们如何能够将单个值的Select连接在一起并传入作用于值数组的得分函数的方法:每个Select都对选择值的假设最终结果进行评分,而不一定是值本身。应用实例遵循相同的策略,唯一的区别是如何计算下游的Select(它不是将候选值传递到a -> m b函数中,而是将候选函数映射到第二个Select上。)

提前返回

那么,我们如何在提前返回的情况下使用Select?我们需要一种方法来在构造Select的代码范围内访问得分函数。一种方法是在另一个Select内部构造每个Select,就像这样:
sequenceSelect :: Eq a => [a] -> Select Bool [a]
sequenceSelect [] = return []
sequenceSelect domain@(x:xs) = select $ \k ->
  if k [] then runSelect s k else []
  where
    s = do
      choice <- elementSelect (x:|xs)
      fmap (choice:) $ sequenceSelect (filter (/= choice) domain)

这样可以让我们测试正在进行的序列,并在失败时短路递归。(我们可以通过调用k []来测试该序列,因为评分函数包括我们递归排列的所有前缀。)

以下是整个解决方案:

import Data.List
import Data.List.NonEmpty (NonEmpty(..))
import Control.Monad.Trans.Select

validBoard :: [Int] -> Bool
validBoard qs = all verify (tails qs)
  where
    verify [] = True
    verify (x:xs) = and $ zipWith (\i y -> x /= y && abs (x - y) /= i) [1..] xs

nqueens :: Int -> [Int]
nqueens boardSize = runSelect (sequenceSelect [1..boardSize]) validBoard

sequenceSelect :: Eq a => [a] -> Select Bool [a]
sequenceSelect [] = return []
sequenceSelect domain@(x:xs) = select $ \k ->
  if k [] then runSelect s k else []
  where
    s = do
      choice <- elementSelect (x:|xs)
      fmap (choice:) $ sequenceSelect (filter (/= choice) domain)

elementSelect :: NonEmpty a -> Select Bool a
elementSelect domain = select $ \p -> epsilon p domain

-- like find, but will always return something
epsilon :: (a -> Bool) -> NonEmpty a -> a
epsilon _ (x:|[]) = x
epsilon p (x:|y:ys) = if p x then x else epsilon p (y:|ys)

简而言之:我们通过递归构建一个选择器,在使用元素时从域中移除它们,并在域被耗尽或我们走错路线时终止递归。
另一个补充是基于希尔伯特的epsilon运算符的epsilon函数。对于大小为N的域,它最多会检查N-1个项目...这可能听起来不像是巨大的节省,但正如您从上面的解释中所知道的那样,p通常会启动整个计算的其余部分,因此最好尽可能减少谓词调用。
关于sequenceSelect的好处在于它的通用性:它可以用于创建任何Select Bool [a],其中:
我们在有限的不同元素域内进行搜索 我们想要创建一个包含每个元素恰好一次的序列(即该域的排列) 我们想要测试局部序列并在其未能满足谓词时放弃它们
希望这有助于澄清问题!
附注:这是一个Observable笔记本的链接,在其中我使用Javascript实现了Select单子和n皇后求解器的演示:https://observablehq.com/@mattdiamond/the-select-monad

1
不错的回答。对措辞有一点疑问:shift似乎没有以“剩余计算”的意义来捕获连续性。正如你所写的那样,它只是明确地掌握了评分函数。 - danidiaz
这个问题的所有解决方案有多容易返回? - is7s
@danidiaz 很好的观点!我实际上正在考虑删除 shift,因为它只是一个方便的函数,没有做太多事情(而且名称本身可能会误导)。 - Matt Diamond
@is7s 这是一个有趣的问题... 我认为没有简单的方法可以做到,但我会考虑一下。 - Matt Diamond
2
请注意,Select包装了一个类型为(a -> r) -> a的函数...如果它返回所有解决方案,那么类型将是([a] -> Bool) -> [[a]],更像是(a -> r) -> m a。然而,使用SelectT转换器可能可以使其工作,该转换器包装了(a -> m r) -> m a - Matt Diamond

4

Select可以被视为在“紧凑”空间中搜索的抽象,由某个谓词引导。您在评论中提到了SAT,是否尝试将问题建模为SAT实例,并将其传递给基于Select的求解器(类似于这篇论文)?您可以专门定制搜索,将N皇后特定约束硬连到您的phi中,并将SAT求解器转换为N皇后求解器。


3

在参考了jd823592的答案并查看了论文中的SAT示例后,我编写了以下代码:

import Data.List 
import Control.Monad.Trans.Select

validBoard :: [Int] -> Bool
validBoard qs = all verify (tails qs)
  where
    verify [] = True
    verify (x : xs) = and $ zipWith (\i y -> x /= y && abs (x-y) /= i) [1..] xs

nqueens :: Int -> [Int]
nqueens boardSize = runSelect (traverse selectColumn columns) validBoard
  where
  columns = replicate boardSize [1..boardSize]
  selectColumn candidates = select $ \s -> head $ filter s candidates ++ candidates

看起来(虽然缓慢),已经得出了一个有效的解决方案:

ghci> nqueens 8
[1,5,8,6,3,7,2,4]

我不是很理解它,特别是在Selectsequence的工作方式将一个适用于整个棋盘的函数(validBoard)转化为接受单个列索引的函数,似乎有些神奇。
基于sequence的解决方案的缺陷在于,在某一列中放置皇后并不排除选择同一列放置后续皇后的可能性;我们最终会探索注定失败的分支。
如果我们希望前面的决策影响到列的选择,我们需要超越Applicative并利用Monad的能力:
nqueens :: Int -> [Int]
nqueens boardSize = fst $ runSelect (go ([],[1..boardSize])) (validBoard . fst)
  where
  go (cps,[]) = return (cps,[])
  go (cps,fps) = (select $ \s ->
    let candidates = map (\(z,zs) -> (z:cps,zs)) (oneOf fps)
    in  head $ filter s candidates ++ candidates) >>= go

单子版本仍然存在问题,因为它只检查已完成的棋盘,而原始的基于列表的解决方案在发现部分完成的棋盘有冲突时立即进行回溯。我不知道如何使用 Select 来实现这一点。


“特别是,Select中的sequence的工作方式似乎非常神奇”-- 是的,这个应用实例绝对令人难以置信。 - duplode

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