Skip to content

在 Swift 中使用三次多项式和二分搜索建模非线性消耗

简介

你正在构建一个习惯追踪器。每个习惯都有一个"等级",用户签到时填满,随时间消耗。最直接的方法——线性消耗——会产生一个问题:80% 的等级感觉和 20% 没什么区别。没有紧迫感,没有视觉压力促使用户在太迟之前采取行动。

你需要的是一条曲线,开始时缓慢消耗,到后期加速。用户看到他们的等级保持稳定一段时间,然后在截止日期临近时观察到它更快地流失。这种心理压力正是让习惯类应用令人上瘾的关键。

三次多项式用一行数学公式就能给出这种形状。当我们需要反函数——"如果我想让等级在 60%,经过了多少时间?"——二分搜索在 32 次迭代中以十亿分之一的精度找到答案。整个实现只需 68 行 Swift 代码。

为什么不用线性?

线性消耗将经过时间直接映射到填充等级:等级 = 1 - 时间。图形是从 100% 到 0% 的直线。

等级
1.0 ┤╲
    │  ╲               线性:恒定速率
0.5 ┤    ╲             任何时刻都没有紧迫感
    │      ╲
0.0 ┤        ╲
    └──────────
    0    0.5    1.0
       时间 →

问题在于:每个时刻感觉同等重要。用户感受不到截止日期,因为视觉反馈是均匀的。没有"它在快速下降,我需要立刻行动"的时刻。

与末端陡峭的曲线对比:

等级
1.0 ┤──╲
    │    ╲              三次:开始慢,结束快
0.5 ┤      ╲            紧迫感在视觉上逐渐建立
    │        ╲╲
0.0 ┤          ╲╲__
    └──────────────
    0    0.5    1.0
        时间 →

曲线在早期阶段保持稳定,然后加速。用户在 80% 时感到安全,但在 30% 时感到焦虑——正是习惯追踪器需要的行为推动力。

三次多项式

我们的消耗函数是在归一化时间 t ∈ [0, 1] 上计算的三次多项式:

f(t) = at³ + bt² + ct + d

其中:

  • t = 0 → 等级为 1.0(刚刚填满)
  • t = 1 → 等级为 0.0(完全消耗)
  • 曲线单调递减(等级不会自行上升)
  • t = 1 附近斜率更陡(末端紧迫感)

产生这种形状的系数:

swift
private static let coefficients = (
    a: -0.131_963_982_801_121,
    b: -0.093_028_201_856_207_9,
    c: -0.775_007_815_342_671,
    d:  1.0,
)

这些不是随意的——它们满足边界条件 f(0) = 1f(1) = 0,同时产生一条在早期阶段"保持"、末端急剧下降的曲线。d = 1.0 项确保等级从 100% 开始。负系数确保曲线单调递减。

TIP

你可以验证边界条件:f(0) = d = 1.0f(1) = a + b + c + d = -0.132 - 0.093 - 0.775 + 1.0 ≈ 0.0。微小的舍入误差在显示精度上可以忽略不计。

Horner 法:高效求值

朴素地计算 at³ + bt² + ct + d 需要 6 次乘法(计算 ,然后乘以系数)。Horner 法将多项式重写为嵌套形式:

f(t) = ((a·t + b)·t + c)·t + d

这只需 3 次乘法和 3 次加法——减少一半的运算:

swift
public static func level(forNormalizedElapsed normalizedElapsed: Double) -> Double {
    let clamped = max(0.0, min(1.0, normalizedElapsed))
    let (a, b, c, d) = coefficients

    return ((a * clamped + b) * clamped + c) * clamped + d
}

max(0, min(1, ...)) 限制保证输入在范围内。超出 [0, 1] 的值会产生外推结果——负等级或超过 100%——在 UI 中没有意义。

TIP

Horner 法不仅更快——还具有更好的数值稳定性。每个中间结果更接近最终量级,减少了单独计算大幂次时累积的浮点舍入误差。

反问题:二分搜索

正向函数回答:"给定经过时间,等级是多少?"但我们也需要反向:"给定目标等级,经过了多少时间?"

当用户手动调整等级时就会出现这种需求。如果有人将滑块拖到 60%,我们需要计算什么 lastRefillDate 会产生这个等级——实际上是把时钟倒转。

解析地反转三次多项式是可能的(Cardano 公式),但复杂且数值上脆弱。二分搜索更简单、更稳健、够快:

swift
public static func normalizedElapsed(forLevel targetLevel: Double) -> Double {
    let clampedLevel = max(0.0, min(1.0, targetLevel))
    if clampedLevel >= 0.999_999 { return 0.0 }
    if clampedLevel <= 0.000_001 { return 1.0 }

    var lower = 0.0
    var upper = 1.0
    var mid = 0.0

    for _ in 0 ..< maxIterations {
        mid = (lower + upper) * 0.5
        let value = level(forNormalizedElapsed: mid)

        if value > clampedLevel {
            lower = mid
        } else {
            upper = mid
        }
    }

    return (lower + upper) * 0.5
}

工作原理

算法利用了我们的曲线单调递减这一事实:随着时间增加,等级降低。这意味着对于任何目标等级,恰好有一个时间值能产生它。

  1. 从完整范围 [0, 1] 开始
  2. 检查中点:计算 f(mid)
  3. 如果 f(mid) > 目标,答案在右边(需要更多时间)→ 缩小到 [mid, upper]
  4. 如果 f(mid) ≤ 目标,答案在左边 → 缩小到 [lower, mid]
  5. 重复 32 次

每次迭代将搜索区间减半。32 次迭代后,区间为 1 / 2³² ≈ 2.3 × 10⁻¹⁰——归一化时间的亚纳秒精度。对于 7 天的消耗周期,精确到 0.00001 秒以内。

边界情况

两个提前返回处理边界值:

swift
if clampedLevel >= 0.999_999 { return 0.0 }  // "100% 满" → 没有时间经过
if clampedLevel <= 0.000_001 { return 1.0 }  // "0% 满" → 完全经过

没有这些,二分法会收敛但在答案显而易见的值上浪费迭代。epsilon 值(0.999_9990.000_001)避免了精确边界处的浮点相等问题。

WARNING

二分法要求函数在搜索区间上单调。如果你的曲线有局部极小值或极大值(如正弦波),二分法可能收敛到错误的根。对于我们单调递减的三次函数,这不是问题。

连接到实际时间

多项式操作的是归一化时间 t ∈ [0, 1],但应用处理的是日期和天数。转换很直接:

swift
public static let secondsInDay: Double = 86400.0

// 正向:日期 → 等级
func currentLevel(at date: Date = Date()) -> Double {
    let elapsed = max(0.0, date.timeIntervalSince(lastRefillDate))
    let totalDuration = Double(depletionDays) * DepletionCurve.secondsInDay
    let normalizedElapsed = min(1.0, elapsed / totalDuration)

    return DepletionCurve.level(forNormalizedElapsed: normalizedElapsed)
}

// 反向:等级 → 日期
func setCurrentFill(_ fill: Double) {
    let clamped = max(0.0, min(1.0, fill))
    let normalizedElapsed = DepletionCurve.normalizedElapsed(forLevel: clamped)
    let seconds = normalizedElapsed * Double(depletionDays) * DepletionCurve.secondsInDay
    lastRefillDate = Date().addingTimeInterval(-seconds)
}

正向路径将实际经过的秒数归一化到 [0, 1],计算多项式并得到填充百分比。反向路径取一个百分比,通过二分法找到归一化时间,转换回秒数,然后设置那么多秒之前的填充日期。

这种"调整过去以匹配现在"的模式很优雅——不需要存储一个需要同步的单独"当前等级"字段,我们完全从 lastRefillDatedepletionDays 推导等级。多项式是唯一的真实来源。

在 Widget 中使用

同样的曲线在 widget 扩展中也能工作,你需要从快照数据预计算显示值:

swift
let elapsed = now.timeIntervalSince(snapshot.lastRefillDate)
let totalDuration = Double(snapshot.depletionDays) * DepletionCurve.secondsInDay
let normalizedElapsed = min(1.0, elapsed / totalDuration)

let currentLevel = DepletionCurve.level(forNormalizedElapsed: normalizedElapsed)
let percentage = max(0, min(100, Int((currentLevel * 100).rounded())))

因为 DepletionCurve 是一个无状态无依赖的纯函数,它可以在主应用 target 和 widget 扩展之间共享,无需任何额外设置——只需将文件包含在两个 target 中。

完整实现

这是完整的 DepletionCurve 枚举——68 行,除 Foundation 外无依赖:

swift
import Foundation

public enum DepletionCurve {
    public static let secondsInDay: Double = 86400.0

    private static let coefficients = (
        a: -0.131_963_982_801_121,
        b: -0.093_028_201_856_207_9,
        c: -0.775_007_815_342_671,
        d: 1.0,
    )

    private static let maxIterations = 32

    /// 正向:归一化时间 → 填充等级
    public static func level(
        forNormalizedElapsed normalizedElapsed: Double
    ) -> Double {
        let clamped = max(0.0, min(1.0, normalizedElapsed))
        let (a, b, c, d) = coefficients

        return ((a * clamped + b) * clamped + c) * clamped + d
    }

    /// 反向:填充等级 → 归一化时间(二分搜索)
    public static func normalizedElapsed(
        forLevel targetLevel: Double
    ) -> Double {
        let clampedLevel = max(0.0, min(1.0, targetLevel))
        if clampedLevel >= 0.999_999 { return 0.0 }
        if clampedLevel <= 0.000_001 { return 1.0 }

        var lower = 0.0
        var upper = 1.0
        var mid = 0.0

        for _ in 0 ..< maxIterations {
            mid = (lower + upper) * 0.5
            let value = level(forNormalizedElapsed: mid)

            if value > clampedLevel {
                lower = mid
            } else {
                upper = mid
            }
        }

        return (lower + upper) * 0.5
    }
}

无 case 的 enum 是标准的 Swift 命名空间模式——它不能被实例化,清晰地表明这是一组纯函数的集合,而不是有状态的对象。

调整曲线

不同的应用可能需要不同的曲线形状。你可以调整系数,同时保持边界条件 f(0) = 1f(1) = 0

因为 f(0) = d,我们总是需要 d = 1.0。又因为 f(1) = a + b + c + d = 0,我们需要 a + b + c = -1。这给了我们两个自由参数。

曲线形状abc效果
当前-0.132-0.093-0.775开始慢,结束陡
更线性0.00.0-1.0直线
末端非常陡-0.50.0-0.5保持更久,下降更快
开始陡0.50.0-1.5快速下降,然后减速

TIP

要可视化不同的系数,在任何绘图工具中绘制 ((a*t + b)*t + c)*t + 1t ∈ [0, 1])。只要 a + b + c = -1,曲线就会从 1 开始到 0 结束。

总结

这个 68 行的实现展示了三种技术的组合,产生了大于各部分之和的效果:

  1. 三次多项式 —— 一个简单的函数,创造心理上有效的非线性反馈。曲线形状(开始慢、结束陡)比线性衰减更有效地推动用户行为。
  2. Horner 法 —— 用 3 次乘法而非 6 次计算多项式,数值稳定性更好。当每帧都要调用来渲染进度条时,这个微优化很重要。
  3. 二分搜索 —— 在 32 次迭代中以亚纳秒精度数值反转多项式。对于这个用例,比解析替代方案(Cardano 公式)更简单、更稳健。

关键的架构洞察是将一切归一化到 [0, 1]。多项式不知道天数、日期或像素——它将归一化时间映射到归一化等级。调用代码处理真实世界单位和归一化域之间的转换。这使得曲线无需任何修改就可以在主应用、widget、连续记录计算和手动等级编辑中重复使用。