Я использовал программирование, чтобы понять, как на самом деле работает подсчет карт.

1656574814 ya ispolzoval programmirovanie chtoby ponyat kak na samom dele rabotaet

Мартин Москало

0*sTtAxozPICvu9A05

Когда я был младше, я любил фильм 21. Великолепная история, актерские способности и, очевидно, эта внутренняя мечта выиграть огромный и победить казино. Я никогда не научился считать карты и никогда не играл в блэкджек. Но я всегда хотел проверить, был ли этот подсчет карт реальным или просто приманка казино выплеснула в Интернет благодаря большим деньгам и большим мечтам.

Сегодня я программист. Поскольку у меня было дополнительное время между подготовкой семинара и разработкой проекта, я решил, наконец, раскрыть правду. Поэтому я написал минимальную программу, имитирующую игровой процесс с подсчетом карт.

Как я это сделал и каковы были результаты? Давайте посмотрим.

0*DZD00vHuupPY5kQo

Модель

Это должно быть минимальная реализация. Настолько минимален, что я даже не ввел понятие карты. Карточки представлены количеством баллов, которые они оценивают. К примеру, туз равен 11 или 1.

Бревно представляет собой список целых чисел, и мы можем сгенерировать его, как показано ниже. Прочтите это как «четыре 10, число от 2 до 9 и одно 11, все 4 раза»:

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

Мы определяем следующую функцию, которая позволит нам умножить содержимое List:

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

Бревно дилера – это не что иное, как 6 перетасованных бревен – в большинстве казино:

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

Подсчет карт

Различные техники подсчета карт предлагают разные способы подсчета карт. Мы будем использовать самый популярный, который оценивает карту как 1, если меньше 7, -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) } or -sumBy(::cardValue). Это сумма баллов за все использованные карты.

Нас интересует так называемый «Истинный подсчет», который представляет собой количество подсчитанных очков, разделенное на количество оставшихся бревен. Обычно игроку необходимо оценить это число.

В нашей реализации мы можем использовать гораздо более точное число и вычислить trueCount сюда:

fun List<Int>.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)
}

Что делать дальше?

Есть одно окончательное решение для нашего игрока. В каждой игре нужно выполнить определенные действия. Чтобы принять решение, игроку необходимо принять решение на основе информации о своей руке и видимой карте дилера.

Нам нужно как-то представлять руки игроку и дилеру. С математической точки зрения, рука – это не что иное, как список карт. С точки зрения игрока это представлено очками, количеством неиспользованных тузов, если его можно разделить, и если это блэкджек. С точки зрения оптимизации, я предпочитаю вычислять все эти свойства один раз и повторно использовать значение, поскольку они проверяются снова и снова.

Поэтому я представил руку так:

class Hand private constructor(val cards: List<Int>) {
    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
}

тузы

У этой функции есть один недостаток: что, если мы пройдем 21, а у нас останется неиспользованный туз? Нам нужно сменить туза с 11 на 1, по возможности. Но где это делать? Это можно сделать в конструкторе, но было бы очень обманчиво, если бы кто-то установил руку с карт 11 и 11 на карты 11 и 1.

Такое поведение следует делать заводским способом. После некоторых раздумий я реализовал это вот так (также реализовано оператор плюс):

class Hand private constructor(val cards: List<Int>) {
    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<Int>): Hand {
            var hand = Hand(cards)
            while (hand.unusedAces >= 1 && hand.points > 21) {
                hand = Hand(hand.cards - 11 + 1)
            }
            return hand
        }
    }
}

Возможные решения представлены в виде перечня (enum):

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

Пора реализовать функцию принятия решения игроком. Для этого существует множество стратегий.

Я решил воспользоваться этим:

0*Be3OphVR7yf6dzkx

Я реализовал это посредством следующей функции. Я предположил, что фолд запрещен казино:

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 <= 7 -> SPLIT
    firstTurn && hand.canSplit && hand.cards[0] == 6 && casinoCard <= 6 -> SPLIT
    firstTurn && hand.canSplit && hand.cards[0] == 4 && casinoCard in 5..6 -> SPLIT
    firstTurn && hand.canSplit && hand.cards[0] in 2..3 && casinoCard <= 7 -> SPLIT
    hand.unusedAces >= 1 && hand.points >= 19 -> STAND
    hand.unusedAces >= 1 && hand.points == 18 && casinoCard < 9 -> STAND
    hand.points > 16 -> STAND
    hand.points > 12 && casinoCard < 4 -> 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 < 10 -> if (firstTurn) DOUBLE else HIT
    hand.points == 9 && casinoCard in 3..6 -> if (firstTurn) DOUBLE else HIT
    else -> HIT
}
0*z7LiE9T-DyO9W2NH

Давай играть!

Все что нам сейчас нужно, это игровой симулятор. Что происходит в игре? Сначала берутся и перемешиваются карты.

Давайте представим их в виде изменяемого списка:

val cards = generateDealerDeck().toMutableList()

Нам понадобится pop функции для него:

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

Нам также нужно знать, сколько у нас денег:

var bankroll = initialMoney

Затем мы играем итерационно до… пока? По данным этого форума, обычно это происходит до тех пор, пока не будет использовано 75% карточек. Затем карты перемешиваются, поэтому мы в основном начинаем сначала.

Итак, мы можем реализовать это так:

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

Игра начинается. Казино принимает одну карту:

val casinoCard = cards.pop()

Другие игроки также принимают карты. Это сожженные карты, но мы запишем их позже, чтобы позволить игроку включить их при подсчете очков (воспаление их сейчас даст игроку информацию, которая действительно недоступна на данный момент).

Мы также берем карту и принимаем решение. Проблема в том, что мы начинаем как один игрок, но мы можем разделить карты и принять участие как 2 игрока.

Поэтому лучше представить игровой процесс как рекурсивный процесс:

fun playFrom(playerHand: Hand, bet: Double, firstTurn: Boolean): List<Pair<Double, Hand>> =
        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()
        }

Если мы не поделимся, возвращается значение – это всегда одна ставка и последняя раздача.

Если мы разделимся, будет возвращен список по двум ставкам и рукам. Если мы сворачиваем, возвращается пустой список.

Вот как мы должны запустить эту функцию:

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

После этого дилеру казино необходимо сыграть в свою игру. Это гораздо проще, потому что они получают новую карточку только тогда, когда имеют меньше 17 очков. Иначе он держится.

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

Тогда нам следует сравнить наши результаты.

Нам нужно сделать это для каждой руки в отдельности:

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 < playerHand.points -> bankroll += bet
        else -> bankroll -= bet
    }
}

Наконец-то мы можем сжечь некоторые карты, которыми пользуются другие игроки. Скажем, мы играем с двумя другими людьми, и они используют в среднем по 3 карты:

cards.pop(6)

Это! Таким образом, симуляция воспроизведет всю колоду дилера, а затем остановится.

Сейчас мы можем проверить, есть ли у нас больше или меньше денег, чем раньше:

val differenceInBankroll = bankroll - initialMoney
return differenceInBankroll

Симуляция очень стремительна. Вы можете сделать тысячи моделирования в считанные секунды. Таким образом можно легко вычислить средний результат:

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

Начните с этого алгоритма и получайте удовольствие. Здесь вы можете играть с кодом онлайн:

Блэкджек
Kotlin прямо в браузере.try.kotlinlang.org

Результаты

К сожалению, мой имитированный плеер все еще теряет деньги. Еще меньше, чем у обычного игрока, но этот подсчет помог недостаточно. Может быть, я что-то пропустил. Это не моя дисциплина.

Поправьте меня, если я ошибаюсь 😉 Пока весь этот подсчет карт выглядит как огромная афера. Может быть, этот сайт просто представляет нехороший метод. Хотя это самый популярный алгоритм, который я нашел!

Эти результаты могут объяснить, почему, несмотря на то, что методы подсчета карт были известны годами – и все эти фильмы были сняты (например, 21) – казино во всем мире все еще так счастливо предлагают блэкджек.

Я считаю, что они знают (возможно, это даже доказано математически), что единственный способ выиграть в казино – вообще не играть. Как и почти в любой другой игре на опасность.

0*wJKLFeo872PKNVnj

Об авторе

Марцин Москала (@marcinmoskala) – тренер и консультант, который сейчас сосредотачивается на разработке Kotlin в Android и расширенных семинарах Kotlin (контактная форма, чтобы подать заявку на вашу команду). Он также спикер, автор статей и книги о разработке Android в Kotlin.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *