讓遊戲記住你哪裡不熟:FSRS 間隔重複的簡單實作
你有 80 個日文單字要學,每個單字有 5 種變化形。隨機出題的話,你已經會的「食べます」和你老是搞混的「食べさせられる」出現的機率一樣。你的時間有一半浪費在已經會的東西上。
FSRS(Free Spaced Repetition Scheduler)解決的就是這個問題:讓你不熟的東西多出現,熟的東西少出現。我在 ConjuGate 裡面實作了一個簡化版的 FSRS,這篇用最簡單的方式解釋它怎麼運作。
先從一張卡片開始想
假設你正在學「飲む」的 ます形。你第一次看到它,答對了。下一次什麼時候該再出現?
如果太快再出,浪費時間。如果太晚再出,你已經忘了。FSRS 的核心就是找到這個「快要忘記但還沒忘」的甜蜜點。
做法很直覺:每張卡片有一個 interval(間隔),代表「隔多久再出一次」。答對了,interval 變長。答錯了,interval 縮短。
mutating func recordCorrect(turn: Int) {
correctCount += 1
lastSeen = turn
interval = interval * easeFactor // 間隔拉長
easeFactor = min(3.0, easeFactor + 0.1)
}
mutating func recordWrong(turn: Int) {
wrongCount += 1
lastSeen = turn
interval = max(1.0, interval * 0.5) // 間隔砍半
easeFactor = max(1.3, easeFactor - 0.2)
}
easeFactor:每張卡片的「難度記憶」
interval 拉長的幅度不是固定的,由 easeFactor 決定。easeFactor 從 2.5 開始,答對一次加 0.1,答錯一次減 0.2。範圍鎖在 1.3 到 3.0 之間。
你一直答對的卡片,easeFactor 會慢慢爬到 3.0,interval 每次乘以 3,間隔越拉越長。你常答錯的卡片,easeFactor 掉到 1.3,interval 每次只乘 1.3,幾乎沒在拉長。
換句話說,系統會自動區分「你覺得簡單的卡片」和「你覺得難的卡片」,不用你手動標記。
怎麼決定下一題出什麼
有了 interval 和 easeFactor,下一步是決定 80 個單字、幾百種 word+form 組合裡面,下一題該出哪一個。
ConjuGate 用 weighted random 來選。每個 word+form 組合有一個 weight,weight 越高越容易被選到:
var weight: Double {
let wrongBoost = Double(wrongCount) * 0.5
let recency = 1.0 / (interval + 1.0)
return max(0.1, 1.0 + wrongBoost + recency)
}
weight 由兩個因素組成:
- wrongBoost:答錯越多次,weight 越高。答錯 4 次的卡片 weight 比從沒錯過的高 2.0。
- recency:interval 越短(代表還不熟),weight 越高。interval 是 1 的卡片 recency 是 0.5,interval 是 99 的卡片 recency 趨近 0。
選的時候還有一個 due boost:如果你距離上次看到這張卡片的回合數已經超過 interval,代表它「到期了」,weight 再乘 1.5。
let turnsSinceSeen = Double(turnCount - (record?.lastSeen ?? 0))
let dueBoost = turnsSinceSeen >= interval ? 1.5 : 1.0
return base * dueBoost
Round Pool:避免短期重複
光靠 weight 還不夠。如果某張卡片 weight 很高,它可能連續出現三次,玩家會煩。
ConjuGate 用 round pool 解決:每一輪把所有 word+form 組合丟進池子裡,選過的就撈出來。等池子空了,重新裝滿,開始下一輪。
func nextQuestion() -> Question {
turnCount += 1
if currentRoundPool.isEmpty {
rebuildPool()
}
let picked = pickWeighted()
currentRoundPool.removeAll { $0 == picked }
usedThisRound.insert(picked)
// ...
}
這保證每個 word+form 在同一輪裡最多出現一次。weight 高的卡片會更早被選到,但不會霸占整輪。
跨 Session 記憶:SwiftData
FSRS 的學習記錄如果每次關掉 app 就消失,等於沒有。玩家上週已經把「飲む」的 ます形練到 interval 50 了,今天打開 app 又從頭開始,那 spaced repetition 就沒意義。
ConjuGate 用 SwiftData 把每個 word+form 的 ReviewRecord 存進本地資料庫:
@Model
final class ReviewRecordEntity {
@Attribute(.unique) var key: String // "wordID::formRawValue"
var correctCount: Int = 0
var wrongCount: Int = 0
var interval: Double = 1.0
var easeFactor: Double = 2.5
var lastReviewDate: Date?
}
每次答題,QuizScheduler 立刻把結果寫回 DataStore。下次打開 app,loadRecords() 把所有記錄讀回來,FSRS 從上次的狀態繼續。
func recordResult(wordID: String, form: FormID, correct: Bool) {
let key = WordFormKey(wordID: wordID, form: form)
var record = records[key] ?? ReviewRecord()
if correct {
record.recordCorrect(turn: turnCount)
} else {
record.recordWrong(turn: turnCount)
}
records[key] = record
dataStore?.saveRecord(key: key, record: record)
}
跟隨機出題比,差在哪
| 隨機出題 | FSRS | |
|---|---|---|
| 已經會的單字 | 跟不會的出現頻率一樣 | interval 拉長,自動淡出 |
| 一直答錯的單字 | 跟其他的出現頻率一樣 | weight 提高,更頻繁出現 |
| 關掉 app 再打開 | 一切重來 | 從上次的學習狀態繼續 |
| 80 個單字 × 5 種變化 | 400 種組合平均分配時間 | 時間集中在你不熟的組合上 |
實作上的取捨
ConjuGate 的 FSRS 是簡化版。完整的 FSRS(像 Anki 用的)有更複雜的記憶模型,會根據答題的反應時間、連續答對次數、遺忘曲線斜率來調整參數。
我選擇簡化版有兩個原因:
- ConjuGate 是遊戲,不是單字卡 app。玩家在閃避敵人的同時回答問題,反應時間沒有參考價值。
- QuizScheduler 整個檔案 150 行。加上 DataStore 的持久化,總共不到 350 行。複雜度和效果之間,這個平衡點夠用。
如果你在做學習類的 app,不需要一開始就搬完整的 FSRS。interval × easeFactor 這個核心公式,加上 wrong weight 和 due boost,已經比隨機出題好太多了。先用這個跑,等有了真實的用戶數據再決定要不要升級。
ConjuGate 是一個用 SpriteKit 寫的 iOS 遊戲,在打怪的過程中學日文動詞變化。這篇是「費曼的實習生」系列,把學到的東西用最簡單的方式講出來。