На этот раз я немного отойду от темы блога и опубликую алгоритмическую задачу.
Как и в предыдущей задаче, Штирлиц и Мюллер по очереди стреляют по очереди. На этот раз в очереди N человек. Нужно написать программу, которая для каждого из чисел 0 <= N < 1000 вычислит, кто выиграет при оптимальной игре обоих игроков.
Сразу скажу, что решить эту задачу прямолинейным поиском в глубину не получится. Мой вариант на Java тратил больше 10 минут уже при сравнительно небольших N в пределах 40.
Решение
Для решения этой задачи нам пригодится функция Шпрага-Гранди. Строгое доказательство её свойств можно посмотреть здесь, я же ограничусь только формулировками.
Пусть $x$ - некоторая позиция в нашей игре, а $F(x)$ - множество всех позиций, в которые можно перейти из $x$ за один ход. Тогда по определению$$G(x) = min\{n \geq 0: n \neq G(y), y \in F(x)\}$$Другими словами, $G(x)$ это наименьшее целое неотрицательное число, которое не встречается среди значений Шпрага-Гранди для позиций, достижимых из $x$ за один ход.
Для проигрышных позиций значение Шпрага-Гранди равно нулю. Соответственно, выигрышная стратегия заключается в том, чтобы после своего хода оставлять противнику позицию с $G(x)=0$.
Если у нас есть $n$ игр $Г_1,...,Г_n$, то можно рассмотреть игру-сумму, в которой игровое поле состоит из совокупности игровых полей. За один ход игрок выбирает некоторое $i$ и делает ход на поле игры $Г_i$. Проигрывает тот, кто не может сделать хода ни на одном из полей. В этом случае значение Шпрага-Гранди для игры-суммы может быть вычислено как$$G(x) = G(x_i) \oplus G(x_2) \oplus ... \oplus G(x_n)$$Здесь $x$ - объединённая позиция, $x_i$ - позиции на каждом из полей, $\oplus$ - побитовое исключающее "или".
Теперь вернёмся к нашей задаче о расстреле очереди. Если один из игроков убивает человека в середине очереди, то очередь распадается на две части. При этом эти части никак не зависят друг от друга, и мы можем рассматривать возникшую позицию как совокупность двух игр.
Это наблюдение позволяет нам значительно сократить объём перебора. Если очередь распалась на два меньших куска, то нам достаточно будет посчитать значение Шпрага-Гранди для каждого из них по отдельности. Это значительно проще, чем пытаться анализировать совокупную позицию.
Теперь, собственно, код на Java:
Как и в предыдущей задаче, Штирлиц и Мюллер по очереди стреляют по очереди. На этот раз в очереди N человек. Нужно написать программу, которая для каждого из чисел 0 <= N < 1000 вычислит, кто выиграет при оптимальной игре обоих игроков.
Сразу скажу, что решить эту задачу прямолинейным поиском в глубину не получится. Мой вариант на Java тратил больше 10 минут уже при сравнительно небольших N в пределах 40.
Решение
Для решения этой задачи нам пригодится функция Шпрага-Гранди. Строгое доказательство её свойств можно посмотреть здесь, я же ограничусь только формулировками.
Пусть $x$ - некоторая позиция в нашей игре, а $F(x)$ - множество всех позиций, в которые можно перейти из $x$ за один ход. Тогда по определению$$G(x) = min\{n \geq 0: n \neq G(y), y \in F(x)\}$$Другими словами, $G(x)$ это наименьшее целое неотрицательное число, которое не встречается среди значений Шпрага-Гранди для позиций, достижимых из $x$ за один ход.
Для проигрышных позиций значение Шпрага-Гранди равно нулю. Соответственно, выигрышная стратегия заключается в том, чтобы после своего хода оставлять противнику позицию с $G(x)=0$.
Если у нас есть $n$ игр $Г_1,...,Г_n$, то можно рассмотреть игру-сумму, в которой игровое поле состоит из совокупности игровых полей. За один ход игрок выбирает некоторое $i$ и делает ход на поле игры $Г_i$. Проигрывает тот, кто не может сделать хода ни на одном из полей. В этом случае значение Шпрага-Гранди для игры-суммы может быть вычислено как$$G(x) = G(x_i) \oplus G(x_2) \oplus ... \oplus G(x_n)$$Здесь $x$ - объединённая позиция, $x_i$ - позиции на каждом из полей, $\oplus$ - побитовое исключающее "или".
Теперь вернёмся к нашей задаче о расстреле очереди. Если один из игроков убивает человека в середине очереди, то очередь распадается на две части. При этом эти части никак не зависят друг от друга, и мы можем рассматривать возникшую позицию как совокупность двух игр.
Это наблюдение позволяет нам значительно сократить объём перебора. Если очередь распалась на два меньших куска, то нам достаточно будет посчитать значение Шпрага-Гранди для каждого из них по отдельности. Это значительно проще, чем пытаться анализировать совокупную позицию.
Теперь, собственно, код на Java:
import java.util.*; public class Game { private static Map<Integer, Integer> cache = new HashMap<Integer, Integer>(); public static void main(String[] args) { for (int i=0; i<1000; i++) { int g = G(i); if (g > 0) { System.out.println(i + ": First player wins"); } else { System.out.println(i + ": Second player wins"); } } } private static int G(int n) { if (!cache.containsKey(n)) { int g = calculateG(n); cache.put(n, g); } return cache.get(n); } // Calculates G when there are exactly n persons in the queue private static int calculateG(int n) { if (n == 0) { return 0; } Set<Integer> subValues = new HashSet<Integer>(); for (int i=0; i<n; i++) { // i is the number of the victim we are going to kill int leftPart = i; int rightPart = Math.max(n-i-1, 0); if (leftPart == 1) { leftPart = 0; // The leftmost guy runs away } if (rightPart == 1) { rightPart = 0; // The rightmost guy runs away } int g = G(leftPart) ^ G(rightPart); subValues.add(g); } int i=0; while (subValues.contains(i)) { i++; } return i; } }Несколько первых результатов:
0: Second player wins 1: First player wins 2: First player wins 3: First player wins 4: Second player wins 5: First player wins 6: First player wins 7: First player wins 8: First player wins 9: First player wins 10: First player wins 11: First player wins 12: Second player wins
> Мой вариант на Java тратил больше 10 минут уже при сравнительно небольших N в пределах 40.
ОтветитьУдалитьЯ предыдущую задачу про очередь не мог решить и написал программку на C++, которая делает тупой перебор (про Шпрага-Гранди раньше не слышал, спасибо за ссылку). Очередь размером 40 обсчитывается за 0.02 секунды, 100 -- за 5 минут, а дальше уже плохо.
Код на C++ - в студию!
ОтветитьУдалитьВот код. Изначально я его не стал постить, т.к. не знаю, как сохранить форматирование.
ОтветитьУдалить// Number of people in a group -> total number of such groups.
typedef map<int, int> Position;
typedef uint128 Fprint;
void Insert(Position& pos, int people) {
if (people > 1)
++pos[people];
}
// Shoot a person number 'idx' in a group pointed to by 'where'.
Position Shoot(const Position& pos, Position::const_iterator where, int idx) {
Position res;
copy(pos.begin(), where, inserter(res, res.end()));
copy(boost::next(where), pos.end(), inserter(res, res.end()));
if (where->second > 1)
res.insert(make_pair(where->first, where->second - 1));
Insert(res, idx);
Insert(res, where->first - idx - 1);
return res;
}
// Collision-free hash function.
Fprint Hash(const Position& pos) {
Fprint res = 0;
for (Position::const_iterator i = pos.begin(); i != pos.end(); ++i)
res = Hash128WithSeed(&*i, sizeof(*i), res);
return res;
}
typedef hash_map<Fprint, bool> Cache;
bool FirstWins(const Position& pos, Cache& cache) {
if (pos.empty())
return false;
Fprint hash = Hash(pos);
Cache::const_iterator i = cache.find(hash);
if (i != cache.end())
return i->second;
for (Position::const_iterator i = pos.begin(); i != pos.end(); ++i)
for (int j = 0; j != (i->first + 1) / 2; ++j)
if (!FirstWins(Shoot(pos, i, j), cache))
return cache[hash] = true;
return cache[hash] = false;
}
int main() {
Cache cache;
for (int i = 0; i != 100; ++i) {
Position pos;
pos[i] = 1;
cout << i << " " << (FirstWins(pos, cache) ? "First" : "Second");
}
}