题目
Alice 和 Bob 用几堆石子在做游戏:一共有偶数堆石子,排成一行;每堆都有正整数颗石子,数目为 piles[i] 。游戏以谁手中的石子最多来决出胜负,石子的总数是奇数 ,所以没有平局。
Alice 和 Bob 轮流进行,Alice 先开始 。 每个回合,玩家从行的开始或结束处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中石子最多的玩家获胜。
假设 Alice 和 Bob 都发挥出最佳水平,当 Alice 赢得比赛时返回 true ,当 Bob 赢得比赛时返回 false 。
示例 1:
输入:piles = [5,3,4,5]
输出:true
解释:Alice 先开始,只能拿前 5 颗或后 5 颗石子 。
假设他取了前 5 颗,这一行就变成了 [3,4,5] 。
如果 Bob 拿走前 3 颗,那么剩下的是 [4,5],Alice 拿走后 5 颗赢得 10 分。
如果 Bob 拿走后 5 颗,那么剩下的是 [3,4],Alice 拿走后 4 颗赢得 9 分。
这表明,取前 5 颗石子对 Alice 来说是一个胜利的策略,所以返回 true 。
示例 2:
输入:piles = [3,7,2,3]
输出:true
暴力法
在石子游戏这类策略游戏中应用暴力法,通常意味着递归地尝试所有可能的取石子序列,直到游戏结束,然后根据这些尝试的结果判断先手玩家是否能赢得比赛。由于暴力法的指数级时间复杂度,在实际应用中,这种解法只适用于非常小的输入规模。使用暴力法求解本题的主要步骤如下。
1、定义递归函数。接收当前石子堆数组、Alice和Bob当前的得分、以及当前轮到的玩家作为参数。
2、递归尝试。对于当前可选的每堆石子,分别尝试从两端取走,更新双方得分,并递归调用函数检查后续步骤。
3、回溯判断。汇总所有尝试的结果,只要有任意一条路径能让Alice最终得分高于Bob,则返回True。否则,返回False。
根据上面的算法步骤,我们可以得出下面的示例代码。
def stone_game_brute_force(piles, aliceScore=0, bobScore=0, isAlice=True):# 没有更多石子堆,判断Alice是否赢得比赛if not piles:return aliceScore > bobScore# 选择取走最左边还是最右边的石子堆for i in (0, -1):nextPile = piles[i]# 移除已取的石子堆nextPiles = piles[:i] + piles[i+1:]# 根据当前轮到的玩家更新分数nextAliceScore = 0nextBobScore = 0if isAlice:nextAliceScore = aliceScore + nextPileelse:nextBobScore = bobScore + nextPile# 递归尝试下一步if stone_game_brute_force(nextPiles, nextAliceScore, bobScore, not isAlice):return Trueif stone_game_brute_force(nextPiles, aliceScore, nextBobScore, not isAlice):return True# 如果所有尝试都无法让Alice赢得比赛,则返回Falsereturn Falsepiles = [5, 3, 4, 5]
# 输出: True
print(stone_game_brute_force(piles))
动态规划法
考虑到游戏的最优策略具有重叠子问题和最优子结构的特点,故可以使用动态规划法来解决。我们可以定义一个二维数组dp,其中dp[i][j]表示在石子堆从第i堆到第j堆之间,当前玩家与对手玩家之间的最大得分差。由于每次操作后剩下的石子堆序列也会形成一个新的子问题,因此可以通过比较两端石子堆大小来决定先手玩家的最佳选择,并以此更新dp数组。使用动态规划法求解本题的主要步骤如下。
1、状态定义。定义dp[i][j]为在石子堆区间[i, j]内,按照最优策略进行游戏,先手相对于后手所能多获得的石子数量。这里的i和j代表石子堆的索引,i ≤ j。
2、状态转移。状态转移的关键在于:考虑先手玩家的最优选择。对于区间[i, j],先手有以下两种选择。
(1)取走左侧的石子堆,此时后手面对的是区间[i+1, j],先手优势变为piles[i] - dp[i+1][j]。
(2)取走右侧的石子堆,此时后手面对的是区间[i, j-1],先手优势变为piles[j] - dp[i][j-1]。
3、边界条件。当区间长度为1,即只有一个石子堆时,dp[i][i] = piles[i]。因为此时先手直接拿走全部石子,相对于后手(无石子可拿)优势就是这堆石子的全部数量。
根据上面的算法步骤,我们可以得出下面的示例代码。首先,我们创建一个大小为n x n的二维数组dp,并初始化对角线上的元素,即单个石子堆的情况。然后,按照区间长度从小到大(从长度为2到n)遍历所有可能的区间,并应用状态转移方程更新dp数组中的值。最后,dp[0][n-1]即表示整个石子序列按照最优策略进行游戏时,先手相对于后手的石子优势。如果dp[0][n-1] > 0,则表示Alice能赢得比赛。
def stone_game_dp(piles):n = len(piles)# 初始化dp数组,大小为n*n,用于存储区间[i, j]内先手的优势dp = [[0]*n for _ in range(n)]# 边界条件:单个石子堆,先手直接拿走全部,优势为该堆石子的数量for i in range(n):dp[i][i] = piles[i]# 构建状态转移方程,从长度为2的区间开始遍历到整个序列for length in range(2, n + 1):# 遍历区间起始位置for i in range(n - length + 1):# 计算区间结束位置j = i + length - 1# 应用状态转移方程,选择使先手优势最大的操作dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1])# 如果dp[0][n-1] > 0,说明按照最优策略,先手能赢得比赛return dp[0][n-1] > 0piles = [5, 3, 4, 5]
# 输出: True
print(stone_game_dp(piles))
总结
暴力法通过递归尝试所有可能的取石子序列来决定胜负,其时间复杂度是指数级别的。具体来说,对于每一步决策,都有两种选择(取左侧或右侧的石子堆),故总的时间复杂度大约为O(2^n),其中n是石子堆的数量。在递归过程中,每一层递归调用都会消耗一定的栈空间来存储函数调用信息。最深的递归深度同样与石子堆的数量n有关,因此空间复杂度也是O(n)。暴力法虽然直观易懂,但对于较大的n值来说,其执行时间将迅速增长至不可接受的程度。
动态规划法通过构建一个二维数组来避免重复计算,时间复杂度主要来自于填充这个数组的过程。对于长度为n的石子堆序列,需要填充一个n×n的表格,每个状态的计算基于之前较小状态的计算结果,因此总体的时间复杂度为O(n^2)。其空间复杂度同样为O(n^2),因为需要一个n×n的二维数组来存储每个子问题的解。动态规划法通过预计算和存储子问题的解,避免了重复计算,极大地提高了效率。对于石子游戏这类具有重叠子问题和最优子结构的问题,动态规划是十分有效的。