1510. Stone Game IV
題目 / Problem
中文:
Alice 和 Bob 輪流玩遊戲,Alice 先手。一開始有 n 顆石頭。每一回合,當前玩家必須從石堆中拿走 任意一個非零的完全平方數 顆石頭(例如 1、4、9、16…)。如果輪到某位玩家時他無法行動(沒有石頭可拿),他就輸了。給定正整數 n,假設雙方都採取最佳策略,若 Alice 必勝就回傳 true,否則回傳 false。
English:
Alice and Bob take turns, Alice first. There are n stones. On each turn a player must remove a non-zero perfect-square number of stones (1, 4, 9, 16, …). A player who cannot move loses. Given n, return true if Alice wins with optimal play, otherwise false.
Constraints / 限制:
- 1 <= n <= 10^5
Example / 範例:
- n = 2 → false。Alice 只能拿 1 顆(2 → 1),Bob 再拿走最後 1 顆(1 → 0),輪到 Alice 時沒有石頭,Alice 輸。
- n = 4 → true。4 本身是完全平方數,Alice 一次拿走 4 顆(4 → 0),Bob 無法行動,Alice 贏。
名詞解釋 / Glossary
- 完全平方數 / Perfect square:某個整數的平方,例如
1=1²、4=2²、9=3²、16=4²。本題每次只能拿走這種數量的石頭。 A number that equals an integer squared. - 動態規劃 / Dynamic Programming (DP):把大問題拆成小問題,先算小的、把答案存起來,之後直接查表,避免重複計算。 Break a problem into smaller subproblems, solve each once, store the answer, reuse it.
- 狀態 / State:這裡的「狀態」就是「輪到某人時,石堆裡還剩幾顆石頭」。我們用
dp[i]表示「面對i顆石頭、且輪到你行動」時你是否會贏。 The situation we track:istones left and it is your move. - 必勝態 / 必敗態 (Winning / Losing state):
dp[i] = true代表當前要行動的人能贏(必勝態);dp[i] = false代表當前要行動的人會輸(必敗態)。 Whether the player to move wins. - 最佳策略 / Optimal play:雙方都不會犯錯,永遠選能讓自己贏的走法。 Both players always pick the best possible move.
思路
我們先想暴力法:從 n 開始,Alice 嘗試每一個可拿的完全平方數,然後 Bob 面對剩下的石頭也嘗試每一種走法……這樣一路遞迴下去。問題是同一個「剩餘石頭數」會被重複計算非常多次,時間會爆炸。這正是動態規劃的用武之地——因為「輸贏只取決於現在剩幾顆石頭,跟之前怎麼拿的無關」。
我們定義 dp[i] = 「當輪到你、石堆裡有 i 顆時,你是否必勝」。基底情況是 dp[0] = false:沒有石頭可拿,輪到你就直接輸。關鍵的遞推想法是:從 i 顆出發,你可以拿走某個完全平方數 k*k(只要 k*k <= i),這樣就把 i - k*k 顆石頭「丟給對手」。如果存在某個走法,使得對手面對的狀態 dp[i - k*k] 是 必敗態(false),那你就能靠這一步逼對手輸,所以 dp[i] = true。只要有一個這樣的走法就夠了。反之,如果你試過所有平方數,對手每一次都落在必勝態,那你怎麼走都會輸,dp[i] = false。我們從 1 算到 n,每個狀態只算一次並存起來,最後回傳 dp[n]。這裡的核心不變量是:dp[i] 一旦算好就固定不變,且只依賴比它更小的狀態,所以由小到大填表一定正確。
Start with brute force: from n, Alice tries every removable square, then Bob does the same on what remains, recursing forever. The trouble is the same "number of stones remaining" gets recomputed a huge number of times. DP fixes this because the winner depends only on how many stones remain right now, not on the history of moves. Define dp[i] = "with i stones and it is your move, do you win?" The base case is dp[0] = false: no stones to take means you lose immediately. The recurrence: from i, you may remove a square k*k (as long as k*k <= i), handing your opponent the state i - k*k. If any such move leaves the opponent in a losing state (dp[i - k*k] == false), then you can force their loss, so dp[i] = true. Just one good move is enough. If every square leaves the opponent winning, then you lose no matter what, so dp[i] = false. We fill the table from 1 up to n, computing each state once, and return dp[n]. The invariant that makes bottom-up filling correct: dp[i] depends only on strictly smaller states, which are all already computed.
逐步走查 / Walkthrough
以 n = 4 為例,逐步建表 / Trace for n = 4, filling dp[0..4]:
| i | 可拿的平方數 k*k / Squares to try | 檢查對手狀態 dp[i - k*k] / Opponent states | dp[i] | 說明 / Meaning |
|---|---|---|---|---|
| 0 | 無 / none | — | false | 無法行動,輸 / can't move, lose |
| 1 | 1 | dp[0] = false |
true | 拿 1 顆,對手面對 0 必敗 / take 1, opponent gets losing 0 |
| 2 | 1 | dp[1] = true |
false | 只能拿 1,對手面對 1 必勝 / only move leaves opponent winning |
| 3 | 1 | dp[2] = false |
true | 拿 1 顆,對手面對 2 必敗 / take 1, opponent gets losing 2 |
| 4 | 1, 4 | dp[3]=true;dp[0]=false |
true | 拿 4 顆,對手面對 0 必敗 / take 4, opponent gets losing 0 |
最終 dp[4] = true,所以 Alice 必勝,回傳 true。/ Final dp[4] = true, so Alice wins.
Solution — C
// 演算法 / Algorithm:
// dp[i] = 面對 i 顆石頭且輪到你時是否必勝 / whether the player to move with i stones wins.
// dp[0]=false(不能動就輸)。對每個 i,試每個平方數 k*k;若某個 dp[i-k*k] 為 false,
// 代表能把必敗態丟給對手,故 dp[i]=true。/ If any move hands the opponent a losing state, you win.
#include <stdbool.h> // 讓 C 能使用 bool / true / false / enables bool, true, false
#include <stdlib.h> // 提供 calloc / free / for calloc and free
bool winnerSquareGame(int n) {
// 配置 n+1 個 bool,索引 0..n;calloc 會把每格初始化為 0(即 false)
// Allocate n+1 bools, indices 0..n; calloc zero-initializes every cell to false.
bool *dp = (bool *)calloc(n + 1, sizeof(bool));
// dp[0] 已是 false,代表 0 顆石頭時輪到你就輸 / dp[0] stays false: 0 stones = you lose.
for (int i = 1; i <= n; i++) { // 由小到大填表 / fill states from small to large
// 試每個非零平方數 k*k,只要不超過 i / try each square k*k that fits in i
for (int k = 1; k * k <= i; k++) { // k*k 是這一步要拿走的石頭數 / stones removed this move
// 拿走 k*k 後,對手面對 dp[i - k*k];若它是必敗態(false),我方就贏
// After removing k*k, opponent faces dp[i - k*k]; if that is losing, we win.
if (!dp[i - k * k]) { // '!' 是邏輯反:!false == true / '!' negates the bool
dp[i] = true; // 找到必勝走法,記為必勝態 / found a winning move
break; // 一個就夠,跳出內層迴圈 / one is enough, stop looking
}
}
// 若迴圈走完都沒 break,dp[i] 仍是 calloc 給的 false(必敗態)
// If no break happened, dp[i] remains false: a losing state.
}
bool ans = dp[n]; // 答案就是 n 顆石頭時先手(Alice)是否必勝 / answer for the starting state
free(dp); // 釋放先前 calloc 的記憶體,避免記憶體洩漏 / release the allocated memory
return ans; // 回傳結果 / return the result
}
Solution — C++
// 演算法 / Algorithm:
// dp[i] = 面對 i 顆石頭且輪到你時是否必勝 / whether the mover with i stones wins.
// 從 i 出發試每個平方數 k*k;若某個 dp[i-k*k]==false,就能把必敗態丟給對手,故 dp[i]=true。
// If any move leaves the opponent in a losing state, the current player wins.
#include <vector> // 提供 std::vector(可自動管理記憶體的動態陣列)/ dynamic array container
class Solution {
public:
bool winnerSquareGame(int n) {
// vector<bool> 建立長度 n+1 的表,全部初始化為 false(必敗態的預設值)
// Build a length-(n+1) table, all initialized to false (default losing state).
std::vector<bool> dp(n + 1, false);
for (int i = 1; i <= n; ++i) { // 由小到大填每個狀態 / fill each state bottom-up
for (int k = 1; k * k <= i; ++k) { // 枚舉可拿的平方數 k*k / enumerate squares that fit
// 若對手面對的 dp[i - k*k] 是必敗態,這步就讓我方必勝
// If the opponent's resulting state is losing, this move wins for us.
if (!dp[i - k * k]) { // '!' 取反:找到 false 就代表對手必敗 / found a losing target
dp[i] = true; // 標記為必勝態 / mark current state as winning
break; // 找到一個必勝走法即可停 / one winning move suffices
}
}
// 沒進 if 的話,dp[i] 保持 false,表示所有走法都讓對手必勝
// Otherwise dp[i] stays false: every move hands the opponent a win.
}
return dp[n]; // n 顆石頭時 Alice(先手)是否必勝 / does the first player win with n stones
}
};
複雜度 / Complexity
- Time: O(n · √n) — 外層迴圈跑
n次;對每個i,內層只枚舉到k*k <= i,也就是最多約√i ≤ √n個平方數。兩者相乘即n√n。 The outer loop runsntimes; for eachithe inner loop tries only about√isquares, so total work isn · √n. - Space: O(n) — 需要一個長度
n+1的dp陣列來保存每個狀態的輸贏;除此之外只用了常數個變數。 Onedparray of sizen+1stores every state's result; everything else is constant.
Pitfalls & Edge Cases
- 必敗態才是「好目標」/ You want to hand the opponent a
false:新手容易寫成「若dp[i-k*k]為 true 就贏」,方向剛好相反。你贏是因為對手接到的是 必敗態,所以條件是!dp[i - k*k]。 The winning condition checks that the opponent's state is losing, not winning. - 迴圈邊界用
k*k <= i而非k <= i/ Loop bound:一定要用k * k <= i,否則會多枚舉、甚至讓i - k*k變負而越界。用k*k也自然避免了對i取平方根的浮點誤差。 Usingk*k <= iavoids negative indices and floating-point sqrt issues. k*k的整數溢位不是問題 / No overflow here:因為n <= 10^5,k最多約316,k*k遠在int範圍內;但若題目放大,需注意k*k可能溢位。 Withn <= 10^5,k*kstays well withinint.dp[0]必須是 false / Base case must be false:這是整個遞推的錨點——0 顆石頭代表輪到的人不能動而輸。calloc/vector(...,false)已幫我們設好,別手動改成 true。 The base casedp[0]=falseanchors the recurrence; leave it false.- C 版記得
free/ Free the memory in C:calloc配置的記憶體要free,否則造成記憶體洩漏;C++ 的vector會自動釋放,不需手動處理。 The C version mustfreethe array; the C++vectorcleans up itself.