题目
根据 百度百科 ,生命游戏,简称为生命,是英国数学家约翰·何顿·康威在 1970 年发明的细胞自动机。
给定一个包含 m × n 个格子的面板,每一个格子都可以看成是一个细胞。每个细胞都具有一个初始状态:1 即为活细胞(live),或 0 即为死细胞(dead)。每个细胞与其八个相邻位置(水平,垂直,对角线)的细胞都遵循以下四条生存定律:
如果活细胞周围八个位置的活细胞数少于两个,则该位置活细胞死亡;
如果活细胞周围八个位置有两个或三个活细胞,则该位置活细胞仍然存活;
如果活细胞周围八个位置有超过三个活细胞,则该位置活细胞死亡;
如果死细胞周围正好有三个活细胞,则该位置死细胞复活;
根据当前状态,写一个函数来计算面板上所有细胞的下一个(一次更新后的)状态。下一个状态是通过将上述规则同时应用于当前状态下的每个细胞所形成的,其中细胞的出生和死亡是同时发生的。
示例:
输入:
[
[0,1,0],
[0,0,1],
[1,1,1],
[0,0,0]
]
输出:
[
[0,0,0],
[1,0,1],
[0,1,1],
[0,1,0]
]
进阶:
你可以使用原地算法解决本题吗?请注意,面板上所有格子需要同时被更新:你不能先更新某些格子,然后使用它们的更新后的值再更新其他格子。
本题中,我们使用二维数组来表示面板。原则上,面板是无限的,但当活细胞侵占了面板边界时会造成问题。你将如何解决这些问题?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/game-of-life
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
思路分析
审题后,我们会有一个初步的想法💡,那就是将这个板子复制一份,这样就可以防止面板上的格子更新后影响其他未更新的格子的判断。这个想法很好,这样完成复制任务后,就循环面板,从头到尾挨个检查每格细胞周围一圈8个位置的活细胞数,根据规则进行判断更新即可。
有了解法一之后,我们来进阶一下。题目中的进阶提示使用原地算法。什么是原地算法呢?像我们的思路是复制个板子就相当于开辟了新地图,原地算法是不需要复制面板,只利用当前的面板就能完成更新。这里会有很精彩的解法,一种是使用额外的状态来表示,还有一种是位运算。具体分析详见解法二和解法三。再次推荐leetcode的官方题解和评论区,本文得益于各路大神的闪光思路。
解法一复制大法好
时间复杂度:O(mn),其中m和n分别为board的行数和列数
空间复杂度:O(mn)
Go版代码
func gameOfLife(board [][]int) {
neighbors := []int{0,1,-1}
n := len(board)
m := len(board[0])
var copy [][]int
//复制板子
for row:=0;row<n;row++ {
var item []int
for col:=0;col<m;col++ {
item = append(item,board[row][col])
}
copy = append(copy,item)
}
for i:= 0;i<n;i++ {
for j:=0;j<m;j++ {
count:=0
for row:=0;row<3;row++ {
for col:=0;col<3;col++ {
if !(neighbors[row]==0 && neighbors[col]==0) {
r := i+neighbors[row]
c := j+neighbors[col]
if r<n&&r>=0 && c<m&&c>=0 &©[r][c]==1 {
count++
}
}
}
}
//规则1和3,以及2
if copy[i][j]==1 && (count<2||count>3) {
board[i][j] = 0
}
//规则4
if copy[i][j]==0 && count==3 {
board[i][j]=1
}
}
}
}
解法二额外状态
由于我们想要使用原地算法来解题,所以我们需要在状态处理上想想办法。这里有个巧妙的思路:
如果细胞过去是活的,现在死了,将值改为-1,这样可以利用绝对值获取之前活的状态1
如果细胞过去是死的,现在活了,将值改为2,这样区分它的状态
其他的细胞状态仍旧保持原来的值
这样我们使用额外状态将板子上的细胞值修改好后,需要再次遍历板子,将上面的值改为题目中要求的0和1即可。
时间复杂度:O(mn)
空间复杂度:O(1)
func gameOfLife(board [][]int) {
neighbors := []int{0,1,-1}
rows := len(board)
cols := len(board[0])
for row:=0;row<rows;row++ {
for col:=0;col<cols;col++ {
var count int
for i:=0;i<3;i++ {
for j:=0;j<3;j++ {
if !(neighbors[i]==0&&neighbors[j]==0) {
r := row+neighbors[i]
c := col+neighbors[j]
if r<rows&&r>=0 && c<cols&&c>=0 && Abs(board[r][c])==1 {
count++
}
}
}
}
//活细胞死了,值为-1
if Abs(board[row][col])==1 && (count<2||count>3) {
board[row][col] = -1
}
//死细胞活了,值为2
if board[row][col]==0 && count==3 {
board[row][col]=2
}
}
}
for i:=0;i<rows;i++ {
for j:=0;j<cols;j++ {
//-1细胞当前是死细胞,改为0
if board[i][j] == -1 {
board[i][j] = 0
}
//2细胞当前是活细胞,改为1
if board[i][j] == 2 {
board[i][j] = 1
}
}
}
}
//golang自带的math.Abs比较坑,需要float64类型,涉及转换,所以自己实现了
func Abs(num int) int{
if num<0 {
return 0-num
} else {
return num
}
}
解法三位运算
原地算法除了上面可以使用额外状态来存储外,我们还有别的方式来同时存储之前的状态和更新后的状态吗?
铛!铛!铛!位运算。
go语言中int是带符号整数类型,大小至少为32位,而8位是1字节,int可以存储4字节。所以我们使用最低位存储当前状态,使用倒数第二位存储下一个状态。最后整体右移一位就好了。
小知识:代码中0b11就是十进制的3,0b10是十进制的2,二进制以0b开头,十六进制是0x,八进制以0开头。
时间复杂度:O(mn)
空间复杂度:O(1)
func gameOfLife(board [][]int) {
neighbors := []int{0,1,-1}
rows := len(board)
cols := len(board[0])
for row:=0;row<rows;row++ {
for col:=0;col<cols;col++ {
var count int
for i:=0;i<3;i++ {
for j:=0;j<3;j++ {
if !(neighbors[i]==0&&neighbors[j]==0) {
r := row+neighbors[i]
c := col+neighbors[j]
if r>=rows||r<0 || c>=cols||c<0 {
continue
}
count += board[r][c]&1 //由于倒数第二位可能有值,所以不能直接+board[r][c]
}
}
}
if (board[row][col]&1)>0 {
if count == 2 || count == 3 {
board[row][col] = 0b11 //活细胞还是存活状态
}
} else if count==3 {
board[row][col] = 0b10 //死细胞变活
}
}
}
for i:=0;i<rows;i++ {
for j:=0;j<cols;j++ {
board[i][j] >>= 1 //位运算,右移一位
}
}
}
总结
- 三种解法大有八仙过海,各显神通的风范。一题多解能让我们学得更深入,不必求快,要精。
- 位运算还是很巧妙啊,评论区有人提到了如果额外开辟数组,这就是c++中的双缓冲技术。所以得把算法学好,这样才能深入底层。
- 生命游戏还有平衡的意味在里面,4个规则分别对应:人口过少会死,人口适中存活,人口过多会死,最后一个是生命的繁殖。