Skip to content

🎮 Теория на игрите: Стратегии и Анализ

🎯 Въведение

Теорията на игрите изследва стратегическото взаимодействие между рационални играчи. В състезателното програмиране разглеждаме предимно комбинаторни игри - игри с пълна информация, детерминистични ходове, без случайност и с два играча, които играят на ред.

Основният въпрос е: Кой играч има печеливша стратегия при оптимална игра? "Оптимална игра" означава, че и двамата играчи играят перфектно - винаги правят най-добрия възможен ход.


♟️ Класификация на игрите

В състезателното програмиране разглеждаме безпристрастни игри (impartial games) и партизански игри (partizan games). - Безпристрастни: Допустимите ходове зависят само от състоянието, не от това кой е на ход (напр. Ним). - Партизански: Ходовете зависят от играча (напр. Шах - белите местят бели фигури).


🔙 Ретрограден анализ (Retrograde Analysis)

Това е техника за определяне на печеливши/губещи позиции чрез връщане от края към началото. Използва се за игри, представени като граф на състоянията.

  1. Терминални състояния: Всички състояния, от които няма ходове, са Губещи (L).
  2. Ако от състояние \(U\) има поне един ход към Губещо състояние \(V\), то \(U\) е Печелившо (W).
  3. Ако от състояние \(U\) всички ходове водят към Печеливши състояния, то \(U\) е Губещо (L).

Алгоритъм (BFS-подобен): - Инициализираме degree[u] = брой изходящи ребра. - Вкарваме всички терминални състояния в опашка. - Когато обработваме състояние \(V\) (чийто статус вече знаем): - За всяко \(U\), водещо към \(V\): - Ако \(V\) е губещо $ o$ \(U\) става печелившо веднага. - Ако \(V\) е печелившо $ o$ намаляваме degree[U]. Ако degree[U] стане 0, значи всички ходове от \(U\) водят към печеливши $ o$ \(U\) става губещо.


⚖️ Играта Ним и XOR суми (Разширено)

Основна теория на Ним

Класическата игра Ним се състои от няколко купчини с камъчета. На ход, играч взима произволен брой камъчета от ЕДНА купчина (поне един). Губи този, който не може да направи ход (всички купчини са празни).

Теорема на Bouton: Позицията е губеща точно когато XOR-ът на всички купчини е 0.

bool isLosingPosition(vector<int>& piles) {
    int xorSum = 0;
    for (int pile : piles) {
        xorSum ^= pile;
    }
    return xorSum == 0;  // true = губеща, false = печеливша
}

Защо XOR?

Всяко неотрицателно цяло число \(n\) има уникално разлагане на сума от степени на 2. Играта Ним е еквивалентна на сума от няколко игри с по една купчина. Операцията, която съответства на "събиране на игри без взаимодействие", е побитовото събиране без пренос (XOR).

Когато комбинираме няколко независими игри, печелившостта на комбинираната игра се определя от XOR на Грънди числата на отделните игри.

Намиране на печеливш ход

Ако XOR-ът не е 0, има печеливш ход. Как да го намерим?

int findWinningMove(vector<int>& piles) {
    int xorSum = 0;
    for (int pile : piles) {
        xorSum ^= pile;
    }

    if (xorSum == 0) {
        return -1;  // Няма печеливш ход (губеща позиция)
    }

    // Намираме купчина, от която можем да вземем
    for (int i = 0; i < piles.size(); i++) {
        int target = piles[i] ^ xorSum;
        if (target < piles[i]) {
            // Взимаме от купчина i до target
            cout << "Вземи от купчина " << i 
                 << " до " << target << " камъчета" << endl;
            return i;
        }
    }
    return -1;
}

Вариации на Ним:

  1. Misere Nim: Печели този, който е принуден да вземе последния предмет (губи този, който вземе последния).
  2. Стратегия: Същата като нормалната игра, освен ако всички купчини са с размер 1. Тогава печелившият ход е да оставим нечетен брой купчини.

  3. Nim с ограничение: Може да се взимат най-много \(K\) камъчета.

  4. Еквивалентно на купчина с размер \(N \bmod (K+1)\).

  5. Staircase Nim: Монети на стъпала. Местенето от стъпало \(i\) на \(i-1\) е еквивалентно на махане.

  6. Отчитат се само стъпалата на нечетни позиции (1, 3, 5...).
// Misere Nim
bool misereNim(vector<int>& piles) {
    int xorSum = 0;
    bool allOnes = true;

    for (int pile : piles) {
        xorSum ^= pile;
        if (pile > 1) allOnes = false;
    }

    if (allOnes) {
        // Всички купчини са с размер 1
        return piles.size() % 2 == 1;  // Нечетен брой = печеливша
    }

    return xorSum != 0;  // Като нормалния Ним
}

// Staircase Nim
bool staircaseNim(vector<int>& stairs) {
    int xorSum = 0;
    for (int i = 0; i < stairs.size(); i++) {
        if (i % 2 == 1) {  // Само нечетните стъпала
            xorSum ^= stairs[i];
        }
    }
    return xorSum != 0;
}

🧮 Грънди числа (Sprague-Grundy Theorem)

За произволна безпристрастна игра можем да дефинираме Грънди число (или nimber) за всяко състояние.

Дефиниция: Грънди числото на състояние е минималното неотрицателно цяло число (MEX - Minimal EXcludant), което не е Грънди число на никое от състоянията, до които можем да стигнем с един ход.

\[g(position) = \text{mex}(\{g(next) : next \text{ е достижимо от } position\})\]

MEX функция:

int mex(set<int>& s) {
    int mex = 0;
    while (s.count(mex)) {
        mex++;
    }
    return mex;
}

Пресмятане на Грънди числа

map<State, int> grundy;

int computeGrundy(State state) {
    if (isTerminal(state)) return 0;  // Няма ходове

    if (grundy.count(state)) return grundy[state];

    set<int> reachable;
    for (State next : getNextStates(state)) {
        reachable.insert(computeGrundy(next));
    }

    return grundy[state] = mex(reachable);
}

Теорема на Sprague-Grundy

Комбинацията от няколко независими игри има Грънди число равно на XOR на Грънди числата на отделните игри.

// Комбинация от игри
int combinedGrundy(vector<int>& individualGrundies) {
    int result = 0;
    for (int g : individualGrundies) {
        result ^= g;
    }
    return result;
}

// Позицията е печеливша ако Грънди числото != 0
bool isWinning(int grundy) {
    return grundy != 0;
}

Пример: Игра на премахване

Имаме купчина от \(N\) камъчета. На ход може да се вземат 1, 2 или 3 камъчета. Какво е Грънди числото?

int grundy[MAXN];

void computeGrundyForGame() {
    grundy[0] = 0;  // Терминална позиция

    for (int n = 1; n < MAXN; n++) {
        set<int> reachable;

        // Можем да вземем 1, 2 или 3 камъчета
        if (n >= 1) reachable.insert(grundy[n-1]);
        if (n >= 2) reachable.insert(grundy[n-2]);
        if (n >= 3) reachable.insert(grundy[n-3]);

        grundy[n] = mex(reachable);
    }
}

За тази конкретна игра Грънди числата са: \(0, 1, 2, 3, 0, 1, 2, 3, \ldots\) (периодични с период 4).


🏆 Минимакс с Мемоизация

За игри, които не са безпристрастни (нямат Грънди числа), използваме рекурсия с памет.

Основна идея

Всяко състояние може да бъде: - Печеливша (W): Текущият играч може да спечели - Губеща (L): Текущият играч неизбежно губи при оптимална игра - Равенство (D): При оптимална игра резултатът е равенство (рядко в комбинаторни игри)

map<State, int> memo;
// 1: Печели, -1: Губи, 0: Неизвестно

int solve(State state) {
    if (is_terminal(state)) return -1; // Няма ходове -> губим
    if (memo.count(state)) return memo[state];

    int res = -1; // Предполагаме, че губим
    for (State next_state : get_moves(state)) {
        if (solve(next_state) == -1) { // Ако противникът губи от следващото състояние
            res = 1; // Ние печелим
            break;
        }
    }
    return memo[state] = res;
}

Пример: Игра върху граф

Двама играчи местят фигура по граф. Губи този, който не може да направи ход.

vector<int> graph[MAXN];
int dp[MAXN];  // 1 = печеливша, -1 = губеща, 0 = непресметната

int solveGraph(int node, int depth) {
    if (graph[node].empty()) return -1;  // Няма ходове
    if (dp[node] != 0) return dp[node];

    for (int next : graph[node]) {
        if (solveGraph(next, depth + 1) == -1) {
            return dp[node] = 1;  // Намерихме губещ ход за противника
        }
    }

    return dp[node] = -1;  // Всички ходове водят до печеливши за противника
}

Минимакс с оценка (за сложни игри)

За игри като шах или го, където не можем да изчислим всички позиции, използваме евристична оценка.

const int INF = 1e9;

// Максимизираме за нас, минимизираме за противника
int minimax(State state, int depth, bool maximizing) {
    if (depth == 0 || isTerminal(state)) {
        return evaluate(state);  // Евристична функция
    }

    if (maximizing) {
        int maxEval = -INF;
        for (State next : getMoves(state)) {
            int eval = minimax(next, depth - 1, false);
            maxEval = max(maxEval, eval);
        }
        return maxEval;
    } else {
        int minEval = INF;
        for (State next : getMoves(state)) {
            int eval = minimax(next, depth - 1, true);
            minEval = min(minEval, eval);
        }
        return minEval;
    }
}

Alpha-Beta Pruning (оптимизация)

Окастряме клонове, които не могат да повлияят на резултата.

int alphaBeta(State state, int depth, int alpha, int beta, bool maximizing) {
    if (depth == 0 || isTerminal(state)) {
        return evaluate(state);
    }

    if (maximizing) {
        int maxEval = -INF;
        for (State next : getMoves(state)) {
            int eval = alphaBeta(next, depth - 1, alpha, beta, false);
            maxEval = max(maxEval, eval);
            alpha = max(alpha, eval);
            if (beta <= alpha) break;  // Beta cut-off
        }
        return maxEval;
    } else {
        int minEval = INF;
        for (State next : getMoves(state)) {
            int eval = alphaBeta(next, depth - 1, alpha, beta, true);
            minEval = min(minEval, eval);
            beta = min(beta, eval);
            if (beta <= alpha) break;  // Alpha cut-off
        }
        return minEval;
    }
}

📊 Практически съвети и трикове

1. Симетрични игри

В симетрични игри (еднаква дъска за двамата играчи), вторият играч често може да копира ходовете на първия.

Пример: На дъска с централна симетрия, вторият играч играе симетрично на хода на първия.

2. Паритет

Много игри зависят от паритета на състоянието.

Пример: Игра, където можем да вземаме монети от два края на ред. Ако общият брой е нечетен, първият играч винаги може да вземе по-голямата монета.

3. Инварианти

Търсете величини, които се запазват или променят предвидимо.

Пример: В играта на Ним инвариантът е XOR-ът на купчините.

4. Моделиране като граф

Често е полезно да представим играта като граф на състоянията.


🎯 Примерни задачи

Задача 1: Башки

Имаме число \(N\). На ход можем да извадим 1, 2 или 3. Губи който стигне до 0.

bool canWin(int n) {
    // Грънди числата са периодични: 0,1,2,3,0,1,2,3,...
    return (n % 4) != 0;
}

Задача 2: Игра на делене

Имаме число \(N\). На ход можем да го разделим на произволен негов делител (различен от 1 и \(N\)). Губи който не може да направи ход.

map<int, bool> memo;

bool canWin(int n) {
    if (isPrime(n)) return false;  // Прости числа са губещи
    if (memo.count(n)) return memo[n];

    for (int d = 2; d * d <= n; d++) {
        if (n % d == 0) {
            if (!canWin(d) || !canWin(n / d)) {
                return memo[n] = true;
            }
        }
    }

    return memo[n] = false;
}

Задача 3: Комбинирани игри

Имаме три купчини: (3, 4, 5). Можем да играем Ним на всяка купчина.

int result = 3 ^ 4 ^ 5;  // XOR на Грънди числата
if (result != 0) {
    cout << "Първият играч печели" << endl;
} else {
    cout << "Вторият играч печели" << endl;
}

🏁 Заключение

Ключът към задачите от тип "Игра" е да определите: 1. Има ли играта Грънди числа? (Безпристрастна ли е?) \(\to\) Използвайте XOR/mex. 2. Има ли малко състояния? \(\to\) Използвайте Минимакс/Ретрограден анализ. 3. Има ли симетрия? \(\to\) Търсете огледални стратегии.

Обобщение на техниките:

Тип игра Подход Сложност Приложение
Ним и вариации XOR на купчините \(O(N)\) Класически Ним, Misere Nim, Staircase Nim
Безпристрастни игри Грънди числа + XOR \(O(States \cdot Moves)\) Композиция на игри
Партизански игри Минимакс \(O(States)\) Игри с различни ходове за играчите
Сложни игри Alpha-Beta \(O(b^{d/2})\) Шах, Шашки (с евристика)
Граф игри ДП/BFS \(O(V + E)\) Игри върху графи

Стъпки при решаване:

  1. Определете типа на играта:
  2. Безпристрастна или партизанска?
  3. Крайна или безкрайна?
  4. Има ли циклични състояния?

  5. Намерете базовите случаи:

  6. Кои позиции са губещи (няма ходове)?
  7. Кои позиции са печеливши директно?

  8. Пресметнете Грънди числа или минимакс:

  9. За безпристрастни игри: използвайте mex
  10. За партизански игри: използвайте минимакс

  11. Комбинирайте подигри (ако има):

  12. XOR на Грънди числата

  13. Оптимизирайте:

  14. Мемоизация
  15. Alpha-beta pruning
  16. Търсене на модел/период

Често срещани грешки:

  • Забравяне, че и двамата играят оптимално
  • Обърква не на печеливши/губещи позиции
  • Неправилно пресмятане на mex
  • Пропускане на специални случаи (празно състояние, цикли)

Полезни задачи за практика:

  1. CSES - Nim Game I: Класически Ним
  2. CSES - Nim Game II: Вариация на Ним
  3. Codeforces 768C: Jon Snow and his Favourite Number
  4. CodeChef - GAMENEW: Грънди числа
  5. SPOJ - MCOINS: Игра с монети
  6. UVa 10165: Stone Game (Ним вариация)
  7. Codeforces 768B: Code For 1 (Грънди на дърво)

Теорията на игрите изисква практика и интуиция. Започнете с прости задачи и постепенно увеличавайте сложността. Разбирането на Ним е основата - след това всичко друго е приложение и разширение на същите принципи.