分享

遗传算法Python实战 007.骑士问题

 godxiasad 2023-04-21 发布于北京

写在前面的话

骑士问题与第四节八皇后问题一样,是国际象棋里面的著名猜想之一,也是很多算法课的必讲内容,当然,自然也是很多大厂的经典笔试算法题之一。

问题是这样的:

国际象棋里面的骑士(Knights),是一种曲线攻击的兵种,类似于中国象棋里面的马(走日字,但是国际象棋里面的马没有蹩马腿一说):

从上图可以看见,一个骑士能够攻击到的位置一共是8个,所以中国象棋里面常有“马有八面威风”一说,套用到国际象棋里面,也是一样的。

整个棋盘一共有8*8 = 64个位置,问最少需要多少个骑士,且如何摆放,才能够攻击到整个棋盘所有位置?

看起来,似乎是一个很简单的计算题——共计64个格子,一个骑士控制8个格子,那不就是8个骑士就可以攻击到所有的位置了么?

答案当然是否定的,因为骑士的特性,越往棋盘边上靠,控制的区域就会越少,所以无论如何,8个骑士都是无法控制全盘的。比如下面是就一种的摆放方式:

总共用了14个骑士,才控制住了全场,我们随便挑选任意位置,都最少有一个骑士可以攻击得到:

类似于八皇后问题,现在我们需要通过算法,来计算一下,是否还有其他的摆放方法?

算法实现部分

首先,定义个骑士当前所在的位置的类,就两个属性,X和Y,三个内部函数,分别用于打印、比较和hash。

什么是hash,请看第五节图着色问题,或者自行上网搜索。

class Position:
def __init__(self, x, y):
self.X = x
self.Y = y

def __str__(self):
return "{},{}".format(self.X, self.Y)

def __eq__(self, other):
return self.X == other.X and self.Y == other.Y

def __hash__(self):
return self.X * 1000 + self.Y

然后定义一个棋盘类:通过输入棋盘的高低,来构造成一个棋盘,然后通过输入的上面的Position(骑士位置)类,来构造骑士在棋盘上的位置。print函数用于输出。

class Board:
def __init__(self, positions, width, height):
board = [['.'] * width for _ in range(height)]

for index in range(len(positions)):
knightPosition = positions[index]
board[knightPosition.Y][knightPosition.X] = 'N'
self._board = board
self._width = width
self._height = height

def print(self):
# 0,0 prints in bottom left corner
for i in reversed(range(self._height)):
print(i, "\t", ' '.join(self._board[i]))
print(" \t", ' '.join(map(str, range(self._width))))

然后是计算当前能够攻击到该点的骑士的位置方法:

def get_attacks(location, boardWidth, boardHeight):
return [i for i in set(
Position(x + location.X, y + location.Y)
for x in [-2, -1, 1, 2] if 0 <= x + location.X < boardWidth
for y in [-2, -1, 1, 2] if 0 <= y + location.Y < boardHeight
and abs(y) != abs(x))]

我们可以测试一下这个攻击方法:试试要攻击最左下角这个点,骑士需要摆放在哪个位置:

n = get_attacks(Position(0,0),8,8)
Board(n,8,8).print()

运行结果:

7 	 . . . . . . . .
6 . . . . . . . .
5 . . . . . . . .
4 . . . . . . . .
3 . . . . . . . .
2 . N . . . . . .
1 . . N . . . . .
0 . . . . . . . .
0 1 2 3 4 5 6 7

可以看见,在0,0这个点,只有两个位置上的骑士可以攻击得到

又或者是我们看看棋盘中间的点,比如第三列,第四行:

n = get_attacks(Position(3,4),8,8)
Board(n,8,8).print()

运行结果有8个位置的骑士,都可以攻击到这个点。

7 	 . . . . . . . .
6 . . N . N . . .
5 . N . . . N . .
4 . . . . . . . .
3 . N . . . N . .
2 . . N . N . . .
1 . . . . . . . .
0 . . . . . . . .
0 1 2 3 4 5 6 7

接下去就是核心的健壮性检验函数了:该函数的设计很简单,直接用随机构造出来的骑士的位置,来计算棋盘上面有多少个点被攻击到,如果是重复的,则不计数。可以看到,如果被攻击的位置等于64,则表示覆盖了整个棋盘。

def get_fitness(genes, boardWidth, boardHeight):
attacked = set(pos
for kn in genes
for pos in get_attacks(kn, boardWidth, boardHeight))
return len(attacked)

打印函数,主要是输出棋盘和骑士所在的位置

def display(candidate, startTime, boardWidth, boardHeight):
timeDiff = datetime.datetime.now() - startTime
board = Board(candidate.Genes, boardWidth, boardHeight)
board.print()

print("{}\n\t{}\t{}".format(
' '.join(map(str, candidate.Genes)),
candidate.Fitness,
timeDiff))

关键函数:进化变异函数 这应该是到目前以来,最复杂的进化变异函数了,具体的代码说明见里面注释

def mutate(genes, boardWidth, boardHeight, allPositions, nonEdgePositions):
#随机交换8-10组基因,这个值也可以修改,一般来说,别太多,否则慢。
for i in range(random.randint(8,10)):
#构建一个由棋盘上所有的点所组成的字典
positionToKnightIndexes = dict((p, []) for p in allPositions)
#从种群的基因库中查看骑士的位置
# 然后把这些骑士能够攻击到的点,都加入到上面那个字典里面
# 结构是{1:[骑士1的索引,骑士2的索引]……}
for i, knight in enumerate(genes):
for position in get_attacks(knight, boardWidth, boardHeight):
positionToKnightIndexes[position].append(i)
knightIndexes = set(i for i in range(len(genes)))
print(knightIndexes)
unattacked = []
#下面对每个点进行进行迭代
# 如果某个点没有任何一个骑士可以攻击到的话,把这个点记录下面
# 如果某个点,只有一个骑士可以攻击到,那个这个骑士的位置就太浪费
# 会把这个骑士先移除掉,设定为需要重新放位的
for kvp in positionToKnightIndexes.items():
if len(kvp[1]) > 1:
continue
if len(kvp[1]) == 0:
unattacked.append(kvp[0])
continue
for p in kvp[1]: # len == 1
if p in knightIndexes:
knightIndexes.remove(p)

# 对于完全没有被攻击到的点,进行一次计算,计算出可以攻击到他们的位置
# 也就是潜在的要被攻击的点。
# 而且这些潜在的点,需要是非边界上的点。
# 如果未被攻击到的点小于等于0了,那么需要计算的潜在点,就是非边界点。
potentialKnightPositions = \
[p for positions in
map(lambda x: get_attacks(x, boardWidth, boardHeight),
unattacked)
for p in positions if p in nonEdgePositions] \
if len(unattacked) > 0 else nonEdgePositions

#选择需要替换的基因库里面的基因
geneIndex = random.randrange(0, len(genes)) \
if len(knightIndexes) == 0 \
else random.choice([i for i in knightIndexes])
# 在前致电里面,选择一个位置,设置为新的骑士的位置
position = random.choice(potentialKnightPositions)
genes[geneIndex] = position
return Chromosome(genes, get_fitness(genes,boardWidth, boardHeight))

下面的两个方法,是用来创建初始化骑士位置的……当然,你可以可以简单的用个随机函数来生成:

遗传算法的一个特点就是,不会在乎起始的情况是怎么样的。

def fnGetRandomPosition():
return random.choice(nonEdgePositions)

def create(fnGetRandomPosition, expectedKnights):
genes = [fnGetRandomPosition() for _ in range(expectedKnights)]
return genes

传统方法,种群类,用来记录种群基因和健壮性。

class Chromosome:
def __init__(self, genes, fitness):
self.Genes = genes
self.Fitness = fitness

设置棋盘大小和骑士的数量

这里默认的骑士数量是14个,这是能够全面控制棋盘的最少的骑士数量

初始化了两个位置集合,一个是全棋盘64个点的位置集合,一个是非边框的位置集合。

boardHeight = 8
boardWidth = 8
expectedKnights = 14
allPositions = [Position(x, y)
for y in range(boardHeight)
for x in range(boardWidth)]
nonEdgePositions = [i for i in allPositions
if 0 < i.X < boardWidth - 1 and
0 < i.Y < boardHeight - 1]

执行方法

def get_best():
startTime = datetime.datetime.now()

optimalFitness = boardWidth * boardHeight
geneset = create(fnGetRandomPosition,expectedKnights)
best = Chromosome(geneset, get_fitness(geneset,boardHeight,boardWidth))
if best.Fitness == optimalFitness:
return best
num = 0
while True:
child = mutate(best.Genes, boardWidth, boardHeight,
allPositions, nonEdgePositions)
num +=1
if best.Fitness > child.Fitness:
continue
#display(child,startTime, boardWidth, boardHeight)
if not child.Fitness > best.Fitness:
best = child
if child.Fitness == optimalFitness:
break
best = child
return (best,num)

执行过程如下:

7 	 . . . . . . . .
6 . . . . . N N .
5 . . N . N . . .
4 . . . N . . . .
3 . . . N . N . .
2 . . N . N . N .
1 . N N . N . . .
0 . . . . . . . .
0 1 2 3 4 5 6 7
3,4 1,1 4,5 6,6 4,2 1,1 2,2 4,1 6,2 2,5 3,3 5,6 2,1 5,3
51 0:00:00.000998

# ……中间过程略

7 . . . . . . . .
6 . . N . N . . .
5 . . N . N . N .
4 . . N . . . N .
3 . . N . . . N .
2 . . N . N . N .
1 . . N . N . . .
0 . . . . . . . .
0 1 2 3 4 5 6 7
4,6 4,1 2,2 4,2 2,1 6,5 6,2 2,5 2,6 6,4 4,5 2,3 6,3 2,4
64 0:00:00.191378

初始化的时候,14个骑士,只能控制51个位置,迭代到230次之后,变成了最终的样子,总共控制了64个位置,进化结束。

进行100次迭代:

本次迭代100次,平均549次进化,完成骑士问题布局。

当然,我们也可以修改棋盘大小和骑士的数量,来增加算法的难度,比如当棋盘扩大为10 * 10的时候,最少需要22个骑士才能控制全场:代码略,运行结果如下:

9 	 . . . . . . . . . .
8 . N . . N N N . . .
7 . N . N . . N . . .
6 . . . . . N . N . .
5 . . . . . . N . . .
4 . . . N N . . . . .
3 . . . . . N N . . .
2 . . . N . . N N . .
1 . . N . . . . . N .
0 . . . . . . . . . .
0 1 2 3 4 5 6 7 8 9
7,6 6,7 4,8 7,2 1,7 3,2 6,5 5,8 3,7 6,7 7,2 1,8 8,1 4,4 5,6 5,6 2,1 3,4 6,8 6,2 6,3 5,3
78 0:00:00.003988
# …… 中间过程略

9 . . . . . . . . . .
8 . . . . . . N . . .
7 . N N N N . . N N .
6 . . . . . . . N N .
5 . N N . . . N . . .
4 . . . . . . . . . .
3 . . . N N N N . . .
2 . . N N . . N N . .
1 . . N . . . . N . .
0 . . . . . . . . . .
0 1 2 3 4 5 6 7 8 9
6,5 2,1 5,3 8,6 6,2 4,3 3,2 2,7 7,7 2,5 2,2 4,7 3,7 8,7 7,6 6,8 1,7 7,2 3,3 1,5 6,3 7,1
100 0:00:01.400890

100个位置全部被控制,迭代完成。

结语

骑士算法的解法,与扑克牌问题的解法一样,定义了一个比较复杂的进化算法,根据定义,也是一种模因进化算法。

模因作为一种广义的基因(把进化论思维扩展到了文化领域),让我们在进化的时候有更多是选择,而不仅仅是简单的通过随机算法模拟基因突变来进行进化。

通过在进化算法里面,控制基因的突变(主要是在一次突变中,就突变数次,这个次数可以固定,也可以通过随机数来控制,大家可以通过修改进化算法里面的第一行,for循环中的循环次数来测试),来加速进化,是模因算法的一个特点。

不过一般来说,不建议把求解的完整过程倚赖与进化方法,而应该结合健壮性检验函数和进化算法来联合实现……用遗传算法的专业说法就是:我们应该更多的发挥基因引擎的作用。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多