1.五子棋对弈python解法——2024年省赛蓝桥杯真题

原题传送门: 1.五子棋对弈 – 蓝桥云课

问题描述

“在五子棋的对弈中,友谊的小船说翻就翻?” 不!对小蓝和小桥来说,五子棋不仅是棋盘上的较量,更是心与心之间的沟通。这两位挚友秉承着”友谊第一,比赛第二”的宗旨,决定在一块 5×5 的棋盘上,用黑白两色的棋子来决出胜负。但他们又都不忍心让对方失落,于是决定用一场和棋(平局)作为彼此友谊的见证。

比赛遵循以下规则:

棋盘规模:比赛在一个 5×5 的方格棋盘上进行,共有 25 个格子供下棋使用

棋子类型:两种棋子,黑棋与白棋,代表双方。小蓝持白棋,小桥持黑棋

先手规则:白棋(小蓝)具有先手优势,即在棋盘空白时率先落子(下棋)

轮流落子:玩家们交替在棋盘上放置各自的棋子,每次仅放置一枚

胜利条件:率先在横线、竖线或斜线上形成连续的五个同色棋子的一方获胜

平局条件:当所有 25 个棋盘格都被下满棋子,而未决出胜负时,游戏以平局告终

在这一设定下,小蓝和小桥想知道,有多少种不同的棋局情况,既确保棋盘下满又保证比赛结果为平局。

答案提交
这是一道结果填空题,你只需要算出结果后提交即可。本题的结果为一个整数,在提交答案时只填写这个整数,填写多余的内容将无法得分。

问题分析

说实话在赛场看到这样的题目,说不慌张都是骗人的,尤其是其还放在填空题中,这对于我们这种水平偏中下的同学来说更是一个较为严峻的挑战。

本题在官方的难度分类中为中等题,但是在小玉看来,其难度就算在中等,那也是中等中偏难的那种,所以没有解题成功的同学并不需要慌张,也不要气馁,你能够在编程软件上敲下你的想法,就已经超过了赛场上的很多人!(这是真的!很多时候我们更应该做的是相信自己 (ง๑ •̀_•́)ง )

言归正传,万事开头难,我们先来简单分析一下这个题目!

这里的关键是统计所有可能的满盘棋局,并确保没有出现五子连珠的情况。由于棋盘规模是 5×5,共有 25!  种不同的落子顺序,直接遍历是不可行的,需要更高效的方法。

我们可以采用回溯 + 剪枝的方法进行搜索:

  • 回溯搜索:逐步填充棋盘,每次轮流落子(白棋先手)。

  • 剪枝:在填充的过程中,如果某个状态下已经形成五子连珠,则立即剪枝,不再继续。

  • 终止条件:当棋盘填满时,若没有五子连珠的情况,则计数。

这个方法考虑了所有可能的棋局排列,并检查是否有五子连珠的情况。但由于 25!  过于庞大,直接使用排列组合的方法会导致计算量过大,优化点在于:

  • 使用位运算或更紧凑的数据结构 以减少存储开销

  • 提前剪枝 在确定某个局面已经形成五子连珠时尽早结束

  • 如果需要进一步优化,可以考虑更复杂的数据结构如Zobrist哈希来避免重复计算。

上面是我一开始看到这个题目时脑子里所有冒出来的想法,先不管对不对,有想法就要坚持去实践!万一实现了呢 [狗头先欠着]

下面是我开始解题时编写的python代码:

当然这个代码是错误的,因为只考虑到了逻辑上的可行性,并没有考虑到实际运算的可行性,有兴趣的同学可以将我错误示例与自己的相对比,在错误中学习

def generate_permutations(elements):
    """
    递归生成列表元素的全排列(手工实现 permutations 生成器)

    参数:
        elements: list,需要生成排列的元素列表

    Yields:
        list: 一个排列组合
    """
    # 基本情况:空列表只有一种排列(空列表)
    if len(elements) == 0:
        yield []
    else:
        # 遍历每个元素作为排列的第一个元素
        for i in range(len(elements)):
            # 排除当前元素后的剩余元素列表
            rest = elements[:i] + elements[i+1:]
            # 递归生成剩余元素的所有排列
            for p in generate_permutations(rest):
                # 将当前元素与子排列组合
                yield [elements[i]] + p

def check_five_in_a_row(board):
    """
    检查五子棋棋盘是否存在五连珠情况

    参数:
        board: list[list[int]],5x5的二维数组,0表示空,1/2表示玩家

    返回:
        bool: 是否存在五连珠
    """
    def check_line(x, y, dx, dy):
        """
        检查从(x,y)出发,沿(dx,dy)方向是否存在五连珠

        参数:
            x, y: 起始坐标
            dx, dy: 方向步长(取值应为0或±1)

        返回:
            bool: 是否五连
        """
        color = board[x][y]
        # 空位置直接返回否
        if color == 0:
            return False
        # 检查连续五个位置
        for i in range(1, 5):  # 只需要检查后续四个位置(共五个)
            nx, ny = x + i*dx, y + i*dy
            # 确保不会越界(根据调用方式其实不需要,但保留更安全)
            if nx >= 5 or ny >= 5 or nx < 0 or ny < 0:
                return False
            if board[nx][ny] != color:
                return False
        return True

    # 检查所有可能的五连珠路线
    for i in range(5):
        # 横向检查(每行的起始位置)
        if check_line(i, 0, 0, 1):
            return True
        # 纵向检查(每列的起始位置)
        if check_line(0, i, 1, 0):
            return True

    # 对角线检查
    return check_line(0, 0, 1, 1) or check_line(0, 4, 1, -1)

def count_draw_games():
    """
    计算所有可能的平局棋局数量(无五连珠的完整棋盘)

    注意:此实现因时间复杂度为O(25!)而无法实际运行,仅作为逻辑演示

    返回:
        int: 平局数量(理论值)
    """
    # 生成所有棋盘位置坐标
    positions = [(i, j) for i in range(5) for j in range(5)]
    draw_count = 0

    # 遍历所有可能的落子顺序排列
    for perm in generate_permutations(positions):
        # 初始化空棋盘
        board = [[0] * 5 for _ in range(5)]

        # 模拟落子过程
        for turn, (x, y) in enumerate(perm):
            # 交替玩家落子(玩家1先手)
            board[x][y] = 1 if turn % 2 == 0 else 2
            # 每次落子后立即检查五连珠
            if check_five_in_a_row(board):
                break  # 出现五连则终止当前棋局
        else:
            # 完整25步且无五连珠的情况计数
            draw_count += 1

    return draw_count

# 注意:实际运行时本代码无法完成计算
print(count_draw_games())

好!现在我们来真正地说一说本题的正确打开方式

首先,如果你想要顺利的解出本题,那么你需要了解一个至关重要的知识点:

将线性位置转换为二维坐标


这个思路可以用于解决所有和本题相似的题目中,如果你不清楚这个知识点,请你将下面得到两个式子记住,如果你想了解这个式子是如何推导得到的,欢迎访问我的个人网站(之前的服务器过期,本站为正在建设中的新站)南徽玉的个人博客,在“算法与竞赛->必备知识数学篇”可以找到相关的推导,好消息是,同时你可以参考以下链接中的解析:希望可以为你解惑
关于坐标压缩式子的解压缩式子-CSDN博客

    x = (position - 1) // line_size + 1  # 行坐标
    y = (position - 1) % line_size + 1    # 列坐标

假设现在有一个坐标轴,那么其中的position为坐标轴上的刻度,也就是线性坐标X,line_size为你希望将其转换到二维坐标系下的坐标系长度(二维平面XOY,我们假设这个平面就像棋盘一样,每一边都有其边长,其中line_size就是这个边长),此后我们便可以通过这个式子计算转换之后的每个点对应的二维坐标!在本题中,我们还应该注意到:两条对角线索引计算的差异

其次,我们需要使用四个数组分别跟踪行、列、对角线的棋子差值(白棋+1,黑棋-1),由于棋盘上总共能放下 5×5 共25个棋子,且 白棋先手,轮流落子,因此我们可以认为:

        初始白子:13颗

        初始黑子:12颗

而防止有一方连成5子赢得比赛,我们应该有对于二者棋子数量的   绝对值的限制(<4),以确保同一颜色不会出现5连。

这点也可以成为我们后续简化代码的关键剪枝条件

代码描述:

当分析到这里,对于知识点掌握比较扎实的同学应该可以猜到小玉要使用什么算法来解决本题了,没错!就是你想的那种!就是——记忆化搜索+启发式剪枝+并行计算(劝退)

我们大概来介绍一下这几个小方向:

  • 1,记忆化搜索
    • 缓存重复出现的棋盘状态
  • 2,并行计算
    • 将搜索树分解为多个子树并行处理
  • 3,启发式剪枝
    • 提前识别无效路径(如双方都无法形成连线)

其实这三个内容在上面的思路分析部分已经多多少少提到过了,有没有课代表愿意画画重点呢?(要考的!)那么本题的具体代码如下,让我们跟着思路一步一步实现:

  • 1,初始化棋盘尺寸和平局计数器,其中:
    • board_size 设置棋盘的大小为5。
    • draw_count 用来记录平局的次数。
board_size 设置棋盘的大小为5。
draw_count 用来记录平局的次数。
 
board_size = 5 # 棋盘边长,本题为正方形棋盘
draw_count = 0 # 记录平局的次数
  • 2,创建棋盘状态跟踪系统,其中:
    • 使用四个数组来分别跟踪行、列、主对角线和副对角线上的棋子差值。白棋记为+1,黑棋记为-1。
    • row_count:行差值统计数组,长度为 board_size + 1,索引从1开始。
    • col_count:列差值统计数组,长度和索引同上。
    • main_diag_count:主对角线差值统计数组,长度为 2 * board_size + 1,索引对应于对角线上的位置。
    • anti_diag_count:副对角线差值统计数组,长度同上,索引对应于副对角线上的位置。
# 统计数组,分别记录行、列、主对角线、副对角线的棋子数
row_count = [0] * (board_size + 1)
col_count = [0] * (board_size + 1)
main_diag_count = [0] * (2 * board_size + 1)
anti_diag_count = [0] * (2 * board_size + 1)
  • 3,定义递归函数 count_draw_games,其中:
    • 参数 position 表示当前放置棋子的位置(从1开始)。
    • 参数 white_pieces 和 black_pieces 分别表示剩余可以放置的白棋和黑棋的数量。
    • 从线性坐标轴上看,当 position 达到棋盘大小的平方加一时,表示所有位置已填满,此时增加 draw_count,即达到和棋条件
    • 将线性位置转换为二维坐标(行 x 和列 y)。
def count_draw_games(position, white_pieces, black_pieces):
    global draw_count

    # 终止条件:所有位置已填满
    if position == board_size * board_size + 1:
        draw_count += 1
        return

    # 将线性位置转换为二维坐标(1-based)
    x = (position - 1) // board_size + 1  # 行坐标(1-5)
    y = (position - 1) % board_size + 1    # 列坐标(1-5)

    # 尝试放置白棋(先手方需要多一个棋子)
    if white_pieces > 0:
        # 剪枝条件:保证所有方向上白棋不超过4个
        if (row_count[x] < board_size - 1 and           # 行方向还能放白棋
            col_count[y] < board_size - 1 and           # 列方向还能放白棋
            main_diag_count[x + y] < board_size - 1 and # 主对角线方向
            anti_diag_count[x - y + board_size] < board_size - 1): # 副对角线方向

            # 更新所有跟踪数组
            row_count[x] += 1
            col_count[y] += 1
            main_diag_count[x + y] += 1
            anti_diag_count[x - y + board_size] += 1

            # 递归处理下一个位置
            count_draw_games(position + 1, white_pieces - 1, black_pieces)

            # 回溯:恢复状态
            row_count[x] -= 1
            col_count[y] -= 1
            main_diag_count[x + y] -= 1
            anti_diag_count[x - y + board_size] -= 1

    # 尝试放置黑棋
    if black_pieces > 0:
        # 剪枝条件:保证所有方向上黑棋不超过4个
        # 注意这里的比较方向是反的(因为黑棋用负数表示)
        if (row_count[x] > 1 - board_size and           # 行方向还能放黑棋
            col_count[y] > 1 - board_size and           # 列方向还能放黑棋
            main_diag_count[x + y] > 1 - board_size and # 主对角线方向
            anti_diag_count[x - y + board_size] > 1 - board_size):

            # 更新所有跟踪数组(黑棋用减法)
            row_count[x] -= 1
            col_count[y] -= 1
            main_diag_count[x + y] -= 1
            anti_diag_count[x - y + board_size] -= 1

            # 递归处理下一个位置
            count_draw_games(position + 1, white_pieces, black_pieces - 1)

            # 回溯:恢复状态
            row_count[x] += 1
            col_count[y] += 1
            main_diag_count[x + y] += 1
            anti_diag_count[x - y + board_size] += 1

那么以上便是本题的所有小部分了,以下是完整的代码

# 棋盘尺寸
board_size = 5
# 平局计数器
draw_count = 0

"""
创新性的棋盘状态跟踪系统:
使用四个数组分别跟踪行、列、对角线的棋子差值(白棋+1,黑棋-1)
这种设计使得我们可以在 O(1) 时间内判断是否允许落子
"""
# 行差值统计(索引1-5)
row_count = [0] * (board_size + 1)
# 列差值统计(索引1-5)
col_count = [0] * (board_size + 1)
# 主对角线(左上到右下)差值统计(x+y范围:2-10)
main_diag_count = [0] * (2 * board_size + 1)
# 副对角线(右上到左下)差值统计(x-y+5范围:1-9)
anti_diag_count = [0] * (2 * board_size + 1)

def count_draw_games(position, white_pieces, black_pieces):
    global draw_count

    # 终止条件:所有位置已填满
    if position == board_size * board_size + 1:
        draw_count += 1
        return

    # 将线性位置转换为二维坐标(1-based)
    x = (position - 1) // board_size + 1  # 行坐标(1-5)
    y = (position - 1) % board_size + 1    # 列坐标(1-5)

    # 尝试放置白棋(先手方需要多一个棋子)
    if white_pieces > 0:
        # 剪枝条件:保证所有方向上白棋不超过4个
        if (row_count[x] < board_size - 1 and           # 行方向还能放白棋
            col_count[y] < board_size - 1 and           # 列方向还能放白棋
            main_diag_count[x + y] < board_size - 1 and # 主对角线方向
            anti_diag_count[x - y + board_size] < board_size - 1): # 副对角线方向

            # 更新所有跟踪数组
            row_count[x] += 1
            col_count[y] += 1
            main_diag_count[x + y] += 1
            anti_diag_count[x - y + board_size] += 1

            # 递归处理下一个位置
            count_draw_games(position + 1, white_pieces - 1, black_pieces)

            # 回溯:恢复状态
            row_count[x] -= 1
            col_count[y] -= 1
            main_diag_count[x + y] -= 1
            anti_diag_count[x - y + board_size] -= 1

    # 尝试放置黑棋
    if black_pieces > 0:
        # 剪枝条件:保证所有方向上黑棋不超过4个
        # 注意这里的比较方向是反的(因为黑棋用负数表示)
        if (row_count[x] > 1 - board_size and           # 行方向还能放黑棋
            col_count[y] > 1 - board_size and           # 列方向还能放黑棋
            main_diag_count[x + y] > 1 - board_size and # 主对角线方向
            anti_diag_count[x - y + board_size] > 1 - board_size):

            # 更新所有跟踪数组(黑棋用减法)
            row_count[x] -= 1
            col_count[y] -= 1
            main_diag_count[x + y] -= 1
            anti_diag_count[x - y + board_size] -= 1

            # 递归处理下一个位置
            count_draw_games(position + 1, white_pieces, black_pieces - 1)

            # 回溯:恢复状态
            row_count[x] += 1
            col_count[y] += 1
            main_diag_count[x + y] += 1
            anti_diag_count[x - y + board_size] += 1

# 初始化递归(白棋13个,黑棋12个)
count_draw_games(1, (board_size * board_size + 1) // 2, board_size * board_size // 2)

print(draw_count)

结果提交

上述代码的运行结果:

将上述代码提交到蓝桥杯官网

写在后面

本题的成功解决也告诉我们,不应该随便忽略题设中的任何一个条件和字眼,也就是:审题是务必全面仔细!!!实际赛场中,如果你在未完全使用题设条件的前提下编写出了代码,那其在实际测试时的样例通过率可能会非常感人😭😭😭

有小伙伴私信提到实际运行时间上的问题,我想说的是,实际赛场上的测试软件中,对于python这个编程语言还是比较宽容的,所以其实并不用太过于焦虑担心运行用时这块的问题。

且本题只是一个填空题,并不需要多高超的解题方法,在分析实际代码的时间复杂度允许的情况下,我们完全可以选择舍弃一点时间来换取答案的准确性。

那么对于上述代码,其实还可以在某些地方再次进行简化哦,亲爱的小伙伴,请问你看出来了吗?

如果您在阅读本文的过程中有所收获,或者有任何宝贵的建议和想法,欢迎通过邮箱或是微信等方式给我留言交流,您的每一次建议都将是我前进的动力!在此,博主斗胆向您提出一个小小的请求,如果您觉得本文给您带来了一丝启发,不妨动动手指,给予一点点鼓励。万水千山总是情,您的打赏,哪怕只是 0.1 元,也是对博主莫大的支持!(悄悄告诉您,博主正在为服务器众筹中 (×﹏×),您的每一份心意都将助力博主走得更远!)感谢您的慷慨,愿我们的缘分如同这网络世界,绵长不断。

上一篇
下一篇