这个修改版的二十一点游戏的最佳获胜策略是什么?

9

问题

有没有一个最好的点数可以停留,使我赢得可能的最大比例?如果有,那是多少?

编辑:是否存在一个确切的胜率概率,可以独立于对手所做的任何事情计算出给定限制的胜率?(我自大学以来就没有学过概率与统计学)。我很想看到它作为答案,以便与我的模拟结果进行对比。

编辑:修复了算法中的错误,更新了结果表格。

背景

我一直在玩一个修改过的二十一点游戏,其中有一些非常恼人的规则调整不同于标准规则。我已经用斜体标出与标准二十一点规则不同的规则,并包括对于不熟悉规则的人的二十一点规则。

修改后的二十一点规则

  1. 正好两个人类玩家(庄家无关紧要)
  2. 每个玩家都会被发两张脸朝下的牌
    • 没有一个玩家知道任何对手牌的价值
    • 除非两个玩家都完成了这手牌,否则没有一个玩家知道对手的手牌价值
  3. 目标是尽可能接近21分。结果:
    • 如果玩家A和B的得分相同,则游戏为平局
    • 如果玩家A和B都有超过21分的得分(爆牌),则游戏为平局
    • 如果玩家A的得分小于等于21且玩家B已经爆牌,则玩家A获胜
    • 如果玩家A的得分大于玩家B的得分,并且两者都没有爆牌,则玩家A获胜
    • 否则,玩家A输(B赢)。
  4. 卡牌价值如下:
    • 2到10的牌面点数与其相应的点数相同
    • J、Q、K的牌面点数为10点
    • Ace牌为1或11点
  5. 每个玩家可以一次请求一张附加卡牌,直到:
    • 玩家不想再要了(停留)
    • 玩家的得分,将任何Ace计为1,超过21(爆牌)
    • 任何时候,没有一个玩家知道对手使用了多少张牌
  6. 一旦两个玩家都停留或爆牌,根据规则3确定赢家。
  7. 每次发完牌后整个牌堆都会重新洗牌,所有52张牌都会再次使用

什么是一副牌?

一副牌由52张牌组成,每种牌面点数有四张牌:

"2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A"。没有其他卡牌属性是相关的。这个Ruby表示是:
CARDS = ((2..11).to_a+[10]*3)*4

算法

我的方法如下:

  • 如果我的得分在2到11之间,我总是希望要牌,因为不可能爆牌
  • 对于每个得分从12到21,我将模拟N次与对手的比赛
    • 对于这N次比赛,我的得分将是我的“限制”。一旦达到或超过限制,我将停止要牌
    • 我的对手将按照完全相同的策略行动
    • 我将为(12..21)和(12..21)的每个组合模拟N次比赛
  • 打印每个组合的胜负差以及净胜负差

这里是用Ruby实现的算法:

#!/usr/bin/env ruby
class Array
  def shuffle
    sort_by { rand }
  end

  def shuffle!
    self.replace shuffle
  end

  def score
    sort.each_with_index.inject(0){|s,(c,i)|
      s+c > 21 - (size - (i + 1)) && c==11 ? s+1 : s+c
    }
  end
end

N=(ARGV[0]||100_000).to_i
NDECKS = (ARGV[1]||1).to_i

CARDS = ((2..11).to_a+[10]*3)*4*NDECKS
CARDS.shuffle

my_limits = (12..21).to_a
opp_limits = my_limits.dup

puts " " * 55 + "opponent_limit"
printf "my_limit |"
opp_limits.each do |result|
  printf "%10s", result.to_s
end
printf "%10s", "net"
puts

printf "-" * 8 + " |"
print "  " + "-" * 8
opp_limits.each do |result|
  print "  " + "-" * 8
end
puts

win_totals = Array.new(10)
win_totals.map! { Array.new(10) }

my_limits.each do |my_limit|
  printf "%8s |", my_limit
  $stdout.flush
  opp_limits.each do |opp_limit|

    if my_limit == opp_limit # will be a tie, skip
      win_totals[my_limit-12][opp_limit-12] = 0
      print "        --"
      $stdout.flush
      next
    elsif win_totals[my_limit-12][opp_limit-12] # if previously calculated, print
      printf "%10d", win_totals[my_limit-12][opp_limit-12]
      $stdout.flush
      next
    end

    win = 0
    lose = 0
    draw = 0

    N.times {
      cards = CARDS.dup.shuffle
      my_hand = [cards.pop, cards.pop]
      opp_hand = [cards.pop, cards.pop]

      # hit until I hit limit
      while my_hand.score < my_limit
        my_hand << cards.pop
      end

      # hit until opponent hits limit
      while opp_hand.score < opp_limit
        opp_hand << cards.pop
      end

      my_score = my_hand.score
      opp_score = opp_hand.score
      my_score = 0 if my_score > 21 
      opp_score = 0 if opp_score > 21

      if my_hand.score == opp_hand.score
        draw += 1
      elsif my_score > opp_score
        win += 1
      else
        lose += 1
      end
    }

    win_totals[my_limit-12][opp_limit-12] = win-lose
    win_totals[opp_limit-12][my_limit-12] = lose-win # shortcut for the inverse

    printf "%10d", win-lose
    $stdout.flush
  end
  printf "%10d", win_totals[my_limit-12].inject(:+)
  puts
end

Usage

ruby blackjack.rb [num_iterations] [num_decks]

这个脚本默认执行100,000次迭代和4个堆。在快速的MacBook Pro上,100,000次迭代需要大约5分钟。

输出结果(N = 100,000)

                                                       opponent_limit
my_limit |        12        13        14        15        16        17        18        19        20        21       net
-------- |  --------  --------  --------  --------  --------  --------  --------  --------  --------  --------  --------
      12 |        --     -7666    -13315    -15799    -15586    -10445     -2299     12176     30365     65631     43062
      13 |      7666        --     -6962    -11015    -11350     -8925      -975     10111     27924     60037     66511
      14 |     13315      6962        --     -6505     -9210     -7364     -2541      8862     23909     54596     82024
      15 |     15799     11015      6505        --     -5666     -6849     -4281      4899     17798     45773     84993
      16 |     15586     11350      9210      5666        --     -6149     -5207       546     11294     35196     77492
      17 |     10445      8925      7364      6849      6149        --     -7790     -5317      2576     23443     52644
      18 |      2299       975      2541      4281      5207      7790        --    -11848     -7123      8238     12360
      19 |    -12176    -10111     -8862     -4899      -546      5317     11848        --    -18848     -8413    -46690
      20 |    -30365    -27924    -23909    -17798    -11294     -2576      7123     18848        --    -28631   -116526
      21 |    -65631    -60037    -54596    -45773    -35196    -23443     -8238      8413     28631        --   -255870

翻译

这是我困惑的地方。我不太确定如何解释这些数据。乍一看,似乎总是停留在16或17是最好的选择,但我不确定是否那么简单。我认为实际的人类对手不太可能停留在12、13和可能的14,所以我应该排除这些opponent_limit值吗?另外,我该如何修改这个模型来考虑真实人类对手的可变性?例如,一个真正的人类对手可能会基于“感觉”停留在15上,也可能会基于“感觉”在18上击中。


1
这是一个编程问题吗?你似乎并没有遇到程序的问题,只是在解释结果方面有困难。 - T.J. Crowder
3个回答

4
我对你的结果有所怀疑。例如,如果对手的目标是19,你的数据显示打到20是打败他的最佳方式。这在基本的实践中是不可行的。你确定没有错误吗?如果我的对手争取得分达到19或更高,我的策略将是不惜一切代价避免爆牌:保持任何13或更高(甚至可能是12?)。去争取20一定是错误的——而且不仅仅是一个小差距,而是很大的差距。
我怎么知道你的数据有问题?因为你玩的二十一点游戏并不特别。这是大多数赌场里庄家的玩法:庄家会一直要牌直到达到一个目标,然后停止,而不管其他玩家手中拥有什么牌。那个目标是什么?对于硬17点必须停牌,而对于软17点必须继续叫牌。当你修复脚本中的错误后,它应该证实赌场知道自己的生意。
当我对你的代码进行以下替换时:
# Replace scoring method.
def score
  s = inject(0) { |sum, c| sum + c }
  return s if s < 21
  n_aces = find_all { |c| c == 11 }.size
  while s > 21 and n_aces > 0
      s -= 10
      n_aces -= 1
  end
  return s
end

# Replace section of code determining hand outcome.
my_score  = my_hand.score
opp_score = opp_hand.score
my_score  = 0 if my_score  > 21
opp_score = 0 if opp_score > 21
if my_score == opp_score
  draw += 1
elsif my_score > opp_score
  win += 1
else
  lose += 1
end

这个结果与赌场荷官的行为相符:17是最佳目标

n=10000
                                                       opponent_limit
my_limit |        12        13        14        15        16        17        18        19        20        21       net
-------- |  --------  --------  --------  --------  --------  --------  --------  --------  --------  --------  --------
      12 |        --      -843     -1271     -1380     -1503     -1148      -137      1234      3113      6572
      13 |       843        --      -642     -1041     -1141      -770       -93      1137      2933      6324
      14 |      1271       642        --      -498      -784      -662        93      1097      2977      5945
      15 |      1380      1041       498        --      -454      -242      -100       898      2573      5424
      16 |      1503      1141       784       454        --      -174        69       928      2146      4895
      17 |      1148       770       662       242       174        --        38       631      1920      4404
      18 |       137        93       -93       100       -69       -38        --       489      1344      3650
      19 |     -1234     -1137     -1097      -898      -928      -631      -489        --       735      2560
      20 |     -3113     -2933     -2977     -2573     -2146     -1920     -1344      -735        --      1443
      21 |     -6572     -6324     -5945     -5424     -4895     -4404     -3650     -2560     -1443        --

一些杂项评论:

当前的设计不够灵活。只需要进行少量的重构,你就可以在游戏运行(发牌、洗牌、保持统计数据)和玩家决策之间实现清晰的分离。这将使你能够测试各种策略之间的差异。目前,你的策略都嵌入在循环中,并且与游戏操作代码纠缠在一起。如果有一个允许你创建新玩家并随意设置其策略的设计,你的实验效果将会更好。


@FM:“特别注意,21 + 21 = 42,而不是44。”是的。如果两手牌总和为44,则都爆了(平局)。如果它们都是21,则也是平局。 - hobodave
@hobodave 必须跑一趟... 关于这个问题:"在这些数据中,你没有看到的是如果两个玩家都一直要牌直到19和20,由于双方都爆牌,所以平局的数量非常高。" 这进一步证明了你发布的结果存在很大偏差。如果两个使用高目标(19或20)的玩家爆牌率很高,这表明其中一个可以通过采用不同的策略获得巨大的回报。但是你的数据表明,对于一个瞄准19的对手,12和20的目标大致同样好。这不符合逻辑。 - FMc
@FM:你为什么改了评分方式?你把它搞砸了。[10,11,11]的.score方法,按照你的方式显示得分为22。在我的例子中,Array.score方法适用于所有情况,并且不需要更改。 - hobodave
@hobodave 噢!现在修好了。总的来说,结果是一样的:17才是正确的选择。 - FMc
@hobodave,在你最近的错误修复中,你在手动将爆牌设置为零之后使用了if my_hand.score == opp_hand.score。你需要改用这个:if my_score == opp_score。这就是为什么你的数据仍然不正确。 - FMc
显示剩余6条评论

2
两个评论:
1. 看起来没有一种基于“击中限制”的主导策略: - 如果你选择16,你的对手可以选择17 - 如果你选择17,你的对手可以选择18 - 如果你选择18,你的对手可以选择19 - 如果你选择19,你的对手可以选择20 - 如果你选择20,你的对手可以选择12 - 如果你选择12,你的对手可以选择16。
2. 你没有提及玩家是否能看到他们的对手抽了多少张牌(我猜应该可以)。我希望这个信息能被纳入到“最佳”策略中。(已回答)
没有其他玩家决策的信息,游戏变得更简单了。但由于明显不存在优势"纯"策略,最优策略将是一种 "混合" 策略。也就是说:对于每个从12到21的分数,你是否应该停止或再抽一张牌,需要设定一组概率(注:对于有A和没有A的情况,需要使用不同的概率)。执行这个策略需要在每次新抽牌后随机选择(根据概率)是停止还是继续。然后你可以找到游戏的 Nash equilibrium
当然,如果你只是问一个更简单的问题:对于次优玩家(例如那些总是在16、17、18或19点停止的玩家),什么是最优获胜策略,那么你正在问一个完全不同的问题,你必须详细说明其他玩家与你相比有哪些限制。

这是一个很好的观点,尽管抽取的牌数可能并不能告诉你太多信息,特别是每手牌都会洗牌。 两张牌的得分可以从3分到21分不等。 话虽如此,我理解你的观点 - 如果玩家只有两张牌,他们拥有更高的牌面的可能性较小,但也不能排除。 把这个因素考虑进去将会很有趣! - Damovisa

1

以下是关于您收集的数据的一些想法:

  • 它对告诉您应该设定什么“命中限制”有些用处,但前提是您知道您的对手正在遵循类似的“命中限制”策略。
  • 即使如此,只有当您知道对手的“命中限制”是多少或可能是多少时,它才真正有用。您可以选择一个比他们赢得更多的限制。
  • 您可以或多或少地忽略表格中的实际值。重要的是它们是正数还是负数。

为了以另一种方式展示您的数据,第一个数字是您的对手的限制,第二组数字是您可以选择并获胜的限制。带星号的是“最赢”的选择:

12:   13, 14, 15, 16*, 17, 18
13:   14, 15, 16*, 17, 18, 19
14:   15, 16, 17*, 18, 19
15:   16, 17*, 18, 19
16:   17, 18*, 19
17:   18*, 19
18:   19*, 20
19:   12, 20*
20:   12*, 13, 14, 15, 16, 17
21:   12*, 13, 14, 15, 16, 17, 18, 19, 20

从这个结果可以看出,如果对手遵循随机的“击中限制”选择策略,那么17或18的命中限制是最安全的选择,因为17和18将打败7/10的对手“命中限制”。

当然,如果你的对手是人类,你不能指望他们自我强制实施低于18或高于19的“命中限制”,所以这完全否定了之前的计算。不过,我仍然认为这些数字是有用的:


我同意,对于任何一手牌,你可以相当有信心地认为你的对手会有一个极限,超过这个极限他们就会停止要牌。如果你能猜测出这个极限,你就可以根据这个估计来选择自己的极限。

如果你认为他们很乐观或者愿意冒险,那么选择一个20的极限 - 只要他们的极限在17以上,你就能在长期内战胜他们。如果你非常有信心,选择一个12的极限 - 如果他们的极限在18以上,这里有更频繁的赢钱机会。

如果你认为他们很保守或者风险规避,那么选择一个18的极限。只要他们自己的极限低于18,你就能获胜。

对于中立的情况,也许考虑一下没有任何外部影响时你的极限是多少。你通常会在16还是17时要牌呢?

简而言之,你只能猜测你对手的极限是多少,但如果你猜得好,你就可以用这些统计数据在长期内战胜他们。


@Damovisa:你似乎得出了和我一样的结论。(我的网络专栏或多或少表明与你的表格相同)。我的本能反应是总是要击中16,有时要击中17,而永远不要击中18。 - hobodave
我猜测的是,如果一个玩家不遵循硬性击中限制的相同策略,他们将始终停留在19、20和21,并且始终在12到15之间击中。因此,对于每一手牌,他们可能会有16、17、18或19的击中限制,具体取决于情况。这使我认为19可能是最好的击中限制,因为从长远来看,它将倾向于打败所有那些击中限制,与19并列。唯一明显的打败方法是对手采用这种策略,并始终使用12或20的击中限制。 - hobodave
所以,基本上在大约400,000手牌中,如果他们随机决定留在16到19之间的任何一个位置,那么这并不重要。如果他们每个位置都一样,那么我将比他们分别多赢大约2867、3314、2080和0场比赛。这有缺陷吗? - hobodave
不,我认为那很准确。你假设他们没有选择“糟糕”的选项;16-19岁。如果你选择19作为你的限制,你将在长期内击败他们。 - Damovisa

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