カードカウンティングが実際にどのように機能するかを理解するためにプログラミングを使用しました

私は若い頃、映画21が大好きでした。素晴らしいストーリー、演技のスキル、そして明らかに、巨大な勝利を収めてカジノを打ち負かすというこの内なる夢。カードカウンティングを学んだことはなく、実際にブラックジャックをプレイしたこともありません。しかし、私はいつも、このカードカウンティングが本物なのか、それとも大金と大きな夢のおかげでカジノのおとりがインターネットに飛び散ったのかを確認したかったのです。

今日はプログラマーです。ワークショップの準備からプロジェクトの開発までに少し時間があったので、ようやく真実を明らかにすることにしました。そこで、カードカウンティングでゲームプレイをシミュレートする最小限のプログラムを作成しました。

どのようにそれをしましたか、そして結果はどうでしたか?どれどれ。

モデル

これは最小限の実装であると思われます。カードの概念すら紹介していないほど最小限です。カードは、評価するポイントの数で表されます。たとえば、エースは11または1です。

デッキは整数のリストであり、以下のように生成できます。「4つの10、2から9までの数と1つの11、すべて4回」と読みます。

fun generateDeck(): List = (List(4) { 10 } + (2..9) + 11) * 4

次の関数を定義して、次の内容を乗算しますList

private operator fun  List.times(num: Int) = (1..num).flatMap { this }

ディーラーのデッキは、シャッフルされた6つのデッキに他なりません—ほとんどのカジノでは:

fun generateDealerDeck() = (generateDeck() * 6).shuffled() 

カードカウンティング

さまざまなカードカウンティング技術は、カードを数えるさまざまな方法を示唆しています。最も人気のあるものを使用します。これは、カードが7より小さい場合は1、10とエースの場合は-1、それ以外の場合は0と評価されます。

これは、これらのルールのKotlin実装です。

fun cardValue(card: Int) = when (card) { in 2..6 -> 1 10, 11 -> -1 else -> 0 }

使用済みのカードをすべて数える必要があります。ほとんどのカジノでは、使用されたすべてのカードを見ることができます。

私たちの実装では、デッキに残っているカードからポイントを数え、この数を0から引く方が簡単です。したがって、実装は0 — this.sumBy { card -> cardValue(card)}であり、これはof -this.sumBy { cardValue(it)} ue)と同等です。これは、使用済みのすべてのカードのポイントの合計です。or -sumBy(::cardVal

私たちが興味を持っているのは、いわゆる「トゥルーカウント」です。これは、カウントされたポイントの数を残りのデッキの数で割ったものです。通常、プレイヤーはこの数を見積もる必要があります。

私たちの実装では、はるかに正確な数値を使用して、trueCount次のように計算できます。

fun List.trueCount(): Int = -sumBy(::cardValue) * 52 / size 

ベッティング戦略

プレーヤーは、ゲームの前に、賭ける金額を常に決定する必要があります。この記事に基づいて、プレーヤーが賭けの単位を計算するルールを使用することにしました。これは、残りのお金の1/1000に相当します。次に、賭けの単位に真のカウントから1を引いたものとして賭けを計算します。また、賭けは25から1000の間である必要があることもわかりました。

関数は次のとおりです。

fun getBetSize(trueCount: Int, bankroll: Double): Double { val bettingUnit = bankroll / 1000 return (bettingUnit * (trueCount - 1)).coerceIn(25.0, 1000.0) }

次はどうする?

プレイヤーにとって最終的な決定が1つあります。すべてのゲームで、プレイヤーはいくつかのアクションを実行する必要があります。決定を下すには、プレーヤーは自分の手とディーラーの目に見えるカードに関する情報に基づいて決定する必要があります。

どういうわけかプレイヤーとディーラーの手を代表する必要があります。数学的な観点から、手はカードのリストに他なりません。プレーヤーの観点からは、ポイント、分割できる場合は未使用のエースの数、ブラックジャックの場合はポイントで表されます。最適化の観点から、私はこれらすべてのプロパティを一度計算し、値を再利用することを好みます。なぜなら、それらは何度もチェックされるからです。

だから私はこのように手を表現しました:

class Hand private constructor(val cards: List) { val points = cards.sum() val unusedAces = cards.count { it == 11 } val canSplit = cards.size == 2 && cards[0] == cards[1] val blackjack get() = cards.size == 2 && points == 21 }

エース

この関数には1つの欠陥があります。21を通過しても未使用のエースが残っている場合はどうなりますか?可能な限り、エースを11から1に変更する必要があります。しかし、これはどこで行う必要がありますか?コンストラクターで行うこともできますが、誰かがカード11と11から手をセットして、カード11と1を持っていると、非常に誤解を招く可能性があります。

この動作は、ファクトリメソッドで実行する必要があります。いくつか検討した後、これは私がそれを実装した方法です(プラス演算子も実装されています):

class Hand private constructor(val cards: List) { val points = cards.sum() val unusedAces = cards.count { it == 11 } val canSplit = cards.size == 2 && cards[0] == cards[1] val blackjack get() = cards.size == 2 && points == 21 operator fun plus(card: Int) = Hand.fromCards(cards + card) companion object { fun fromCards(cards: List): Hand { var hand = Hand(cards) while (hand.unusedAces >= 1 && hand.points > 21) { hand = Hand(hand.cards - 11 + 1) } return hand } } }

可能な決定は列挙(列挙)として表されます:

enum class Decision { STAND, DOUBLE, HIT, SPLIT, SURRENDER } 

プレイヤーの決定機能を実装する時間です。そのための多くの戦略があります。

私はこれを使用することにしました:

以下の関数を使用して実装しました。カジノでは折りたたみは許可されていないと思いました。

fun decide(hand: Hand, casinoCard: Int, firstTurn: Boolean): Decision = when { firstTurn && hand.canSplit && hand.cards[0] == 11 -> SPLIT firstTurn && hand.canSplit && hand.cards[0] == 9 && casinoCard !in listOf(7, 10, 11) -> SPLIT firstTurn && hand.canSplit && hand.cards[0] == 8 -> SPLIT firstTurn && hand.canSplit && hand.cards[0] == 7 && casinoCard  SPLIT firstTurn && hand.canSplit && hand.cards[0] == 6 && casinoCard  SPLIT firstTurn && hand.canSplit && hand.cards[0] == 4 && casinoCard in 5..6 -> SPLIT firstTurn && hand.canSplit && hand.cards[0] in 2..3 && casinoCard  SPLIT hand.unusedAces >= 1 && hand.points >= 19 -> STAND hand.unusedAces >= 1 && hand.points == 18 && casinoCard  STAND hand.points > 16 -> STAND hand.points > 12 && casinoCard  STAND hand.points > 11 && casinoCard in 4..6 -> STAND hand.unusedAces >= 1 && casinoCard in 2..6 && hand.points >= 18 -> if (firstTurn) DOUBLE else STAND hand.unusedAces >= 1 && casinoCard == 3 && hand.points >= 17 -> if (firstTurn) DOUBLE else HIT hand.unusedAces >= 1 && casinoCard == 4 && hand.points >= 15 -> if (firstTurn) DOUBLE else HIT hand.unusedAces >= 1 && casinoCard in 5..6 -> if (firstTurn) DOUBLE else HIT hand.points == 11 -> if (firstTurn) DOUBLE else HIT hand.points == 10 && casinoCard  if (firstTurn) DOUBLE else HIT hand.points == 9 && casinoCard in 3..6 -> if (firstTurn) DOUBLE else HIT else -> HIT }

遊ぼう!

今必要なのはゲームシミュレーションだけです。ゲームではどうなりますか?まず、カードを取り、シャッフルします。

それらを可変リストとして表現しましょう。

val cards = generateDealerDeck().toMutableList() 

そのためのpop関数が必要になります:

fun  MutableList.pop(): T = removeAt(lastIndex) fun  MutableList.pop(num: Int): List = (1..num).map { pop() }

また、どれだけのお金があるかを知る必要があります。

var bankroll = initialMoney

それから私たちは…いつまで?このフォーラムによると、通常はカードの75%が使用されるまでです。その後、カードがシャッフルされるので、基本的に最初から始めます。

So we can implement it like that:

val shufflePoint = cards.size * 0.25 while (cards.size > shufflePoint) {

The game starts. The casino takes single card:

val casinoCard = cards.pop()

Other players take cards as well. These are burned cards, but we will burn them later to let the player now include them during the points calculation (burning them now would give player information that is not really accessible at this point).

We also take a card and we make decisions. The problem is that we start as a single player, but we can split cards and attend as 2 players.

Therefore, it is better to represent gameplay as a recursive process:

fun playFrom(playerHand: Hand, bet: Double, firstTurn: Boolean): List = when (decide(playerHand, casinoCard, firstTurn)) { STAND -> listOf(bet to playerHand) DOUBLE -> playFrom(playerHand + cards.pop(), bet * 2, false) HIT -> playFrom(playerHand + cards.pop(), bet, false) SPLIT -> playerHand.cards.flatMap { val newCards = listOf(it, cards.pop()) val newHand = Hand.fromCards(newCards) playFrom(newHand, bet, false) } SURRENDER -> emptyList() }

If we don’t split, the returned value is always a single bet and a final hand.

If we split, the list of two bets and hands will be returned. If we fold, then an empty list is returned.

This is how we should start this function:

val betsAndHands = playFrom( playerHand = Hand.fromCards(cards.pop(2)), bet = getBetSize(cards.trueCount(), bankroll), firstTurn = true )

After that, the casino dealer needs to play their game. It is much simpler, because they only get a new card when they have less then 17 points. Otherwise he holds.

var casinoHand = Hand.fromCards(listOf(casinoCard, cards.pop())) while (casinoHand.points < 17) { casinoHand += cards.pop() }

Then we need to compare our results.

We need to do it for every hand separately:

for ((bet, playerHand) in betsAndHands) { when { playerHand.blackjack -> bankroll += bet * if (casinoHand.blackjack) 1.0 else 1.5 playerHand.points > 21 -> bankroll -= bet casinoHand.points > 21 -> bankroll += bet casinoHand.points > playerHand.points -> bankroll -= bet casinoHand.points  bankroll += bet else -> bankroll -= bet } }

We can finally burn some cards used by other players. Let’s say that we play with two other people and they use 3 cards on average each:

cards.pop(6)

That’s it! This way the simulation will play the whole dealer’s deck and then it will stop.

At this moment, we can check out if we have more or less money then before:

val differenceInBankroll = bankroll - initialMoney return differenceInBankroll

The simulation is very fast. You can make thousands of simulations in seconds. This way you can easily calculate the average result:

(1..10000).map { simulate() }.average().let(::print)

Start with this algorithm and have fun. Here you can play with the code online:

Blackjack

Kotlin right in the browser.try.kotlinlang.org

Results

Sadly my simulated player still loses money. Much less than a standard player, but this counting didn’t help enough. Maybe I missed something. This is not my discipline.

Correct me if I am wrong ;) For now, this whole card-counting looks like a huge scam. Maybe this website just presents a bad algorithm. Although this is the most popular algorithm I found!

These results might explain why even though there have been known card-counting techniques for years — and all these movies were produced (like 21) — casinos around the world still offer Blackjack so happily.

I believe that they know (maybe it is even mathematically proven) that the only way to win with a casino is to not play at all. Like in nearly every other hazard game.

About the author

Marcin Moskała (@marcinmoskala) is a trainer and consultant, currently concentrating on giving Kotlin in Android and advanced Kotlin workshops (contact form to apply for your team). He is also a speaker, author of articles and a book about Android development in Kotlin.