Χρησιμοποίησα προγραμματισμό για να καταλάβω πώς λειτουργεί πραγματικά η καταμέτρηση καρτών

Όταν ήμουν νεότερος, μου άρεσε η ταινία 21. Υπέροχη ιστορία, δεξιότητες δράσης και προφανώς αυτό το εσωτερικό όνειρο για να κερδίσω τεράστια και να νικήσω το καζίνο. Δεν έμαθα ποτέ να μετράω φύλλα και δεν έχω παίξει ποτέ Μπλάκτζακ. Αλλά πάντα ήθελα να ελέγξω αν αυτή η καταμέτρηση καρτών ήταν πραγματική, ή απλώς έφτασε στο διαδίκτυο ένα παιχνίδι του καζίνο χάρη σε μεγάλα χρήματα και μεγάλα όνειρα.

Σήμερα είμαι προγραμματιστής. Εφόσον είχα έναν επιπλέον χρόνο μεταξύ των προετοιμασιών εργαστηρίου και της ανάπτυξης έργων, αποφάσισα να αποκαλύψω επιτέλους την αλήθεια. Έτσι έγραψα ένα ελάχιστο πρόγραμμα που προσομοιώνει το παιχνίδι με την καταμέτρηση καρτών.

Πώς το έκανα και ποια ήταν τα αποτελέσματα; Ας δούμε.

Μοντέλο

Αυτό υποτίθεται ότι είναι μια ελάχιστη εφαρμογή. Τόσο ελάχιστο που δεν έχω εισαγάγει καν την έννοια της κάρτας. Οι κάρτες αντιπροσωπεύονται από τον αριθμό των βαθμών στους οποίους αξιολογούνται. Για παράδειγμα, ένας άσος είναι 11 ή 1.

Το κατάστρωμα είναι μια λίστα με ακέραιους αριθμούς και μπορούμε να το δημιουργήσουμε όπως φαίνεται παρακάτω. Διαβάστε το ως "τέσσερα 10, αριθμός από 2 έως 9 και single 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() 

Καταμέτρηση καρτών

Διαφορετικές τεχνικές καταμέτρησης καρτών προτείνουν διαφορετικούς τρόπους μέτρησης καρτών. Θα χρησιμοποιήσουμε το πιο δημοφιλές, το οποίο αξιολογεί μια κάρτα ως 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)} ue). Αυτό είναι το άθροισμα των πόντων για όλες τις χρησιμοποιημένες κάρτες.or -sumBy(::cardVal

Αυτό που μας ενδιαφέρει είναι το λεγόμενο "True Count", που είναι ο αριθμός των μετρημένων πόντων διαιρούμενος με τον αριθμό των καταστρωμάτων που απομένουν. Κανονικά ο παίκτης πρέπει να εκτιμήσει αυτόν τον αριθμό.

Στην εφαρμογή μας, μπορούμε να χρησιμοποιήσουμε έναν πολύ πιο ακριβή αριθμό και να υπολογίσουμε 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) }

Τι να κάνω μετά?

Υπάρχει μια τελική απόφαση για τον παίκτη μας. Σε κάθε παιχνίδι, ο παίκτης πρέπει να κάνει κάποιες ενέργειες. Για να λάβει αποφάσεις, ο παίκτης πρέπει να αποφασίσει με βάση τις πληροφορίες σχετικά με το χέρι του και την ορατή κάρτα του ντίλερ.

Πρέπει να εκπροσωπήσουμε τα χέρια παικτών και ντίλερ κάπως. Από μαθηματική άποψη, το χέρι δεν είναι τίποτα άλλο από μια λίστα καρτών. Από την άποψη του παίκτη, αντιπροσωπεύεται από σημεία, τον αριθμό των αχρησιμοποίητων άσων εάν μπορεί να χωριστεί και αν είναι μπλάκτζακ. Από την άποψη βελτιστοποίησης, προτιμώ να υπολογίσω όλες αυτές τις ιδιότητες μία φορά και να επαναχρησιμοποιήσω τις τιμές, καθώς ελέγχονται ξανά και ξανά.

Έτσι εκπροσώπησα το χέρι με αυτόν τον τρόπο:

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 }

Άσοι

Υπάρχει ένα ελάττωμα σε αυτήν τη λειτουργία: τι γίνεται αν περάσουμε το 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):

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) είναι εκπαιδευτής και σύμβουλος, επί του παρόντος επικεντρώνεται στην παροχή Kotlin στο Android και σε προχωρημένα εργαστήρια Kotlin (φόρμα επικοινωνίας για να υποβάλει αίτηση για την ομάδα σας). Είναι επίσης ομιλητής, συγγραφέας άρθρων και ένα βιβλίο για την ανάπτυξη Android στο Kotlin.