云端 Mac 上的 iOS 百分比分配验收:最大余数法

云端 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 对应的数值,而不是再乘一百。格式化阶段不能逐项重新计算原始权重,也不能二次取整成另一套精度。

把失败收敛成可检查的证据

合并前保留输入权重、总单位、基础配额、余数排名和最终输出。生产统计可能包含敏感分类时,使用固定夹具重现,不把真实业务明细写入构建产物。

评审重点是三个边界:非法输入能否明确失败;相同余数能否稳定裁决;界面格式化是否保持既定精度。验收通过后,数据层只提供权重,分配层只提供整数单位,展示层只负责本地化格式。三层职责分开,百分比合计异常就能定位到具体环节,而不是靠最后一项补差维持表面正确。

常见问题

为什么不能对每项百分比单独四舍五入?

逐项舍入不能保证总和守恒。例如三个相同权重各显示为百分之三十三,合计只有百分之九十九。最大余数法先分配整数配额,再按余数补齐剩余单位。

最大余数法能保证每项百分比随权重增加而增加吗?

不能把它当作跨数据集的单调性保证。其他权重或显示单位变化时,余数排名可能改变。本文保证当前输入的总和、配额边界与确定性,不保证跨输入的显示结果单调。

用自己的流水线验证云端 Mac

ArmRDP 提供独享物理 Mac mini,非虚拟机。按天起租,先核对工具链与构建结果,再选择适合团队的租期。

开始租用云端 Mac

让配置有据可查,让构建可以复现

核对机型、租期与接入步骤,再把任务交给独享物理节点。