雲端 Mac 上的 iOS 百分比分配驗收:最大餘數法
iOS 統計頁將三個相同權重的項目各自顯示為 33%,合計卻只有 99%。若把差額全補到最後一項,又可能讓權重為零的項目也分到百分比。在雲端 Mac 的建置流程中,這類問題不必啟動模擬器就能驗證:將分配演算法抽成純 Swift 函式,即可檢查總和、排序與異常輸入,再讓介面使用同一份結果。
先定義顯示單位與輸入契約
這裡處理的是非負整數計數,例如三個分類各自完成的任務數,而非金額、浮點量測值或已經四捨五入的百分比。將顯示總單位數設為 100,每個單位代表一個百分點;若需要顯示到小數點後一位,就改為 1000,每個單位代表零點一個百分點。
契約包含四項:輸出長度與輸入相同;輸出皆為非負數,且合計等於總單位數;零權重分配到零單位;相同輸入始終回傳相同結果。遇到空陣列、負權重或總權重為零時,直接回報失敗,不自行產生平均分配。
「沒有資料」與「每項占比相同」是兩種不同的業務狀態。輸入全為零時,頁面應顯示空白狀態,不能透過分配演算法將它偽裝成有效統計。
餘數相同時,以輸入索引決定先後,因此呼叫端必須先依穩定的分類識別碼排列資料。不要把字典的走訪順序當成產品規則,否則重新整理頁面後,獲得補足單位的項目可能會改變。
用整數最大餘數法補足差額
先將各項權重乘以總單位數,再除以總權重。商數就是基礎配額,餘數則用來決定補足的順序。基礎配額合計不足的部分,依餘數由大到小,逐項增加一個單位。
由於每一項都無條件捨去小數,待補足的單位數必定小於項目數。不需要反覆迴圈直到數值收斂,也不需要設定浮點容許誤差。將以下程式碼儲存為 percentage-check.swift,其中同時包含實作與最小檢查。
enum AllocationError: Error {
case invalidInput
case overflow
}
func allocate(_ weights: [Int], units: Int = 100) throws -> [Int] {
guard !weights.isEmpty,
(1...10_000).contains(units),
weights.allSatisfy({ $0 >= 0 }) else {
throw AllocationError.invalidInput
}
var total = 0
for weight in weights {
let sum = total.addingReportingOverflow(weight)
guard !sum.overflow else {
throw AllocationError.overflow
}
total = sum.partialValue
}
guard total > 0 else {
throw AllocationError.invalidInput
}
var result = [Int]()
var remainders = [Int]()
for weight in weights {
let product = weight.multipliedReportingOverflow(by: units)
guard !product.overflow else {
throw AllocationError.overflow
}
result.append(product.partialValue / total)
remainders.append(product.partialValue % total)
}
let remaining = units - result.reduce(0, +)
let order = weights.indices.sorted {
if remainders[$0] == remainders[$1] {
return $0 < $1
}
return remainders[$0] > remainders[$1]
}
for index in order.prefix(remaining) {
result[index] += 1
}
return result
}
func expectFailure(_ weights: [Int], units: Int = 100) {
do {
_ = try allocate(weights, units: units)
} catch {
return
}
preconditionFailure("Expected allocation to fail")
}
let equal = try allocate([1, 1, 1])
precondition(equal == [34, 33, 33])
let withZero = try allocate([0, 1, 1])
precondition(withZero == [0, 50, 50])
let tenths = try allocate([1, 1, 1], units: 1000)
precondition(tenths == [334, 333, 333])
for a in 0...12 {
for b in 0...12 {
for c in 0...12 {
let weights = [a, b, c]
let total = weights.reduce(0, +)
if total == 0 { continue }
let output = try allocate(weights)
precondition(output.count == weights.count)
precondition(output.reduce(0, +) == 100)
precondition(output.allSatisfy { $0 >= 0 })
for index in weights.indices {
let floorQuota = weights[index] * 100 / total
precondition(
output[index] == floorQuota ||
output[index] == floorQuota + 1
)
if weights[index] == 0 {
precondition(output[index] == 0)
}
}
}
}
}
expectFailure([])
expectFailure([0, 0])
expectFailure([-1, 2])
expectFailure([1], units: 0)
expectFailure([Int.max, 1])
expectFailure([Int.max])
程式碼刻意在加總與乘法兩處檢查溢位。即使最後的百分比很小,計算過程中的權重乘積仍可能超出整數範圍。這裡選擇拒絕輸入,不以轉換成浮點數的方式掩蓋精度變化;若要接受更大的計數,應另外設計更寬位元的整數運算。
在雲端 Mac 執行獨立驗收
先以 xcrun swift --version 確認目前的工具鏈,再執行以下命令。檢查成功時不會有任何輸出;斷言失敗或未處理的錯誤都會使程序失敗,建置流程必須保留該結束狀態。
set -eu
xcrun swift percentage-check.swift
作為正式合併門檻時,可以先用 xcrun swiftc -Onone 編譯再執行,但不能使用會停用這些斷言的最佳化模式。更穩妥的長期做法,是將函式放進業務模組,把斷言移入測試目標,再透過既有的測試命令執行,避免指令碼與產品各自維護一份演算法。
這裡列舉的是三個權重在小範圍內的組合,並非效能評測。涵蓋的情境包括接近全零、單一項目占全部權重、餘數相同,以及小權重被捨去為零等結構。新增測試時應擴充輸入型態,而非單純重複同一個均勻樣本。
區分捨入誤差與業務規則
最容易忽略的問題,是排序後遺失與原始項目的對應關係。這份實作只排序索引,最終陣列仍對應原始項目;介面不能直接將餘數排名當成分類的顯示順序。
其次,不要要求「每個正權重都至少顯示一個單位」。當分類數超過顯示總單位數時,這項要求便無法與總和守恆同時成立。若需要呈現極小的占比,可以提高顯示精度,或另外提供「低於顯示門檻」的文字,但不能暗中修改配額。
最後,不要將此方法視為跨資料集的單調性保證。調整其他分類的權重或顯示精度後,餘數排名可能改變。測試應驗證目前資料的配額範圍,而不是斷言某個項目的數值在每次重新整理後都絕不減少。
小數格式化只負責顯示
總單位數為 1000 時,334 代表 33.4%。如果格式化介面接收的是零到一之間的比例,應傳入 334 / 1000 所對應的數值,而不是再乘以一百。格式化階段不能逐項以原始權重重新計算,也不能再次捨入成另一套精度。
將失敗整理成可檢查的證據
合併前應保留輸入權重、總單位數、基礎配額、餘數排名與最終輸出。若正式環境的統計可能包含敏感分類,請使用固定的測試資料重現,不要將真實業務明細寫入建置產物。
審查重點有三個邊界:無效輸入能否明確回報失敗;餘數相同時能否穩定決定先後;介面格式化是否維持既定精度。驗收通過後,資料層只提供權重,分配層只提供整數單位,顯示層只負責本地化格式。將三層職責分開,百分比合計異常時就能定位到具體環節,而不必靠最後一項補差額來維持表面正確。
常見問題
為什麼不能將每項百分比各自四捨五入?
逐項取整無法保證總和守恆。三個相同權重若各顯示百分之三十三,合計只有百分之九十九。最大餘數法先分配整數配額,再依餘數補足剩餘單位。
權重增加時,顯示百分比一定會增加嗎?
不能視為跨資料集的單調性保證。其他權重或顯示單位數改變時,餘數排名可能不同。本實作保證當次輸入的總和、配額範圍與確定性,不保證跨輸入的顯示結果單調。