Conway's Game of Life
Conway's Game of Life is a famous simulation game in the history of computer science. Generally speaking, the rules of the game is quite simple, not to mention that in an interview you will usually meet a simplier version of it. But as to perfectly give the answer, more consideration should be put on it. The space and time analysis is also very important.
In fact, Conway's Game of Life usually appears in an interview as a measure of the knowledge of the matrices' reprensentation. In different situation, different types of representation of matrix or 2D array have differenet performance in time and space cost, which should be well grasped.
Here we talk about a simplified version of Conway's Game of Life. The rules of game is:
- Any live cells with fewer than two neighbours dies in the next generation
- Any live or empty cell with more or equal to two neighbours become live cell in the next generation.
We will disscuss this problem step by step.
Stage 1: small board
If the board is small, we can iterate through every possible position of cell and count the neighbours of a possition to decide if there should be a live cell there. At first we can give a version of program that initiate a new board at the begining of every generation and copy the result back to the original.
class GameOfLife {
void nextGeneration(int[][] board) {
int m = board.length;
int n = board[0].length;
int[][] newboard = new int[m][n];
for(int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
int neighbours = countNeighbours(board, i, j);
if (neighbours >= 2) newboard[i][j] = 1;
}
}
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
board[i][j] = newboard[i][j];
}
int countNeighbours(int[][] board, int i, int j) {
int count = 0;
int m = board.length;
int n = board[0].length;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
if (x == 0 && y == 0) continue;
int px = i + x;
int py = j + y;
if (px >= 0 && px < m && py >= 0 && py < n) {
if (board[px][py] == 1) count++;
}
}
}
return count;
}
The solution above is not fast, nor saving in the space cost. One way to speed up
the algorithm is to expand the for loop in countNeighbours
into eight conditional expression,
but this won't help a a lot. This elementary version is not acceptable for an interview.
Both the time and space cost is , but it will actually spend a lot of time to allocation
memery for the newboard
in every generation. And when the board is very large, it is not even feasible.
Step 2: in place generation
We can come up an in place algorithm by modifying the cell by plus 2 to avoid the repeadly allocation. Thus we can distinguish the original positions of live cell and the positions should be live cell next generation. The program may looks like:
class GameOfLife {
void nextGeneration(int[][] board) {
int m = board.length;
int n = board[0].length;
for(int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
int neighbours = countNeighbours(board, i, j);
if (neighbours >= 2) board[i][j] += 2;
}
}
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (board[i][j] >= 2) board[i][j] = 1;
else board[i][j] = 0;
}
int countNeighbours(int[][] board, int i, int j) {
int count = 0;
int m = board.length;
int n = board[0].length;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
if (x == 0 && y == 0) continue;
int px = i + x;
int py = j + y;
if (px >= 0 && px < m && py >= 0 && py < n) {
if (board[px][py] == 1 || board[px][py] == 3) count++;
}
}
}
return count;
}
Note that we will count the neighbours when the value is 1
or 3
, which means there is a life
cell originally at that position. A position has value 2
means it originally empty but will
has a live cell the next generation. At last, both 2
and 3
means that position will has a
live cell, so at last we traverse around all positions and marks these positions as 1
.
This solution is enough for the first version of answer. But it should be better, especially when the board is very large and even infinite.
For this solution especially, if the live cells are sparse on the board. We will have a way
to optimize under some programming languanges. These languanges has condition branch prediction
and when check the count of neighbours of a position, it will usually assume the check result to be true
.
But on a sparse board, the check usually returns false
. So change the code to be like below might
improves the performance on some machines with some languages:
if (neighbours < 2) board[i][j] = 0;
else board[i][j] = 1;
Step 3: large or infinite board
If we have very large or infinite board, traverse very position will be unacceptable. The program above is also not good enough if the live cells on the board is very sparse. A better way is to change the representation of board.
Data structures decide everything!
Just remember the motto above. Many times the interviewers is not examining your algorithm, they are examining your knowledge and understanding of data structure!
And DO NOT limited your mind in a specified languange and the data structures it provides.
For example, Java does not have anything like a Pair<L, R>
in C++ or tuple
in Python,
but you can always use a similar data structure as long as you can explain what it does.
In this example, we will use something like the Pair
or tuple
in Java. The data structure
may looks like:
class Pair<L, R> {
L left;
R right;
Pair(L l, R r) {
left = l;
right = r;
}
int hashCode() {
return left.hashCode() * right.hashCode();
}
bool equals(Pair other) {
return left == other.left && right == other.right;
}
}
The hashCode
and equals
function make sure the class can be correctly handled by HashSet
and HashMap
.
You are not necessary to write these code down, just tell the interviewee that there exists a similar
data structure can act properly.
Other than check every position in this solution and find those with more than one live neighbours, we iterate over current live cells and marks every cell it affects. Then we find all affects positions and put live cells there.
An implementation for the situation of infinite board may be like:
void nextGeneration(HashSet<Pair> liveCells) {
HashMap<Pair, Integer> map = new HashMap<Pair, Integer>();
for (Pair p : liveCells) {
for (int i = -1; i <= 1; i++)
for (int j = -1; j <= 1; j++) {
if (i == 0 && j == 0) continue;
Pair newp = new Pair(p.left + i, p.right + j);
if (map.containsKey(newp)) {
map.put(newp, map.get(newp) + 1);
} else {
map.put(newp, 1);
}
}
}
liveCells.clear();
for (Pair p : map.keySet()) {
if (map.get(p) >= 2) liveCells.add(p);
}
}
The new solution is in fact more clear than the old one, neither to say it is faster and more space efficient
especially when the live cells are sparse on the board. The time cost depends on the implementation of Set
and Map
is used. In our case, we assume the HashSet
and HashMap
can provide const time cost to insert
and find an element, then the total time cost for a single generation is where is the number
of live cells on the board currently. The space cost is too.
The last solution is usually the best one if you can use self-defined data structures. It is also good to mention the first two solutions as references.