在 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附近斜率更陡(末端紧迫感)
产生这种形状的系数:
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) = 1 和 f(1) = 0,同时产生一条在早期阶段"保持"、末端急剧下降的曲线。d = 1.0 项确保等级从 100% 开始。负系数确保曲线单调递减。
TIP
你可以验证边界条件:f(0) = d = 1.0 和 f(1) = a + b + c + d = -0.132 - 0.093 - 0.775 + 1.0 ≈ 0.0。微小的舍入误差在显示精度上可以忽略不计。
Horner 法:高效求值
朴素地计算 at³ + bt² + ct + d 需要 6 次乘法(计算 t²、t³,然后乘以系数)。Horner 法将多项式重写为嵌套形式:
f(t) = ((a·t + b)·t + c)·t + d这只需 3 次乘法和 3 次加法——减少一半的运算:
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 公式),但复杂且数值上脆弱。二分搜索更简单、更稳健、够快:
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
}工作原理
算法利用了我们的曲线单调递减这一事实:随着时间增加,等级降低。这意味着对于任何目标等级,恰好有一个时间值能产生它。
- 从完整范围
[0, 1]开始 - 检查中点:计算
f(mid) - 如果
f(mid) > 目标,答案在右边(需要更多时间)→ 缩小到[mid, upper] - 如果
f(mid) ≤ 目标,答案在左边 → 缩小到[lower, mid] - 重复 32 次
每次迭代将搜索区间减半。32 次迭代后,区间为 1 / 2³² ≈ 2.3 × 10⁻¹⁰——归一化时间的亚纳秒精度。对于 7 天的消耗周期,精确到 0.00001 秒以内。
边界情况
两个提前返回处理边界值:
if clampedLevel >= 0.999_999 { return 0.0 } // "100% 满" → 没有时间经过
if clampedLevel <= 0.000_001 { return 1.0 } // "0% 满" → 完全经过没有这些,二分法会收敛但在答案显而易见的值上浪费迭代。epsilon 值(0.999_999 和 0.000_001)避免了精确边界处的浮点相等问题。
WARNING
二分法要求函数在搜索区间上单调。如果你的曲线有局部极小值或极大值(如正弦波),二分法可能收敛到错误的根。对于我们单调递减的三次函数,这不是问题。
连接到实际时间
多项式操作的是归一化时间 t ∈ [0, 1],但应用处理的是日期和天数。转换很直接:
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],计算多项式并得到填充百分比。反向路径取一个百分比,通过二分法找到归一化时间,转换回秒数,然后设置那么多秒之前的填充日期。
这种"调整过去以匹配现在"的模式很优雅——不需要存储一个需要同步的单独"当前等级"字段,我们完全从 lastRefillDate 和 depletionDays 推导等级。多项式是唯一的真实来源。
在 Widget 中使用
同样的曲线在 widget 扩展中也能工作,你需要从快照数据预计算显示值:
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 外无依赖:
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) = 1 和 f(1) = 0:
因为 f(0) = d,我们总是需要 d = 1.0。又因为 f(1) = a + b + c + d = 0,我们需要 a + b + c = -1。这给了我们两个自由参数。
| 曲线形状 | a | b | c | 效果 |
|---|---|---|---|---|
| 当前 | -0.132 | -0.093 | -0.775 | 开始慢,结束陡 |
| 更线性 | 0.0 | 0.0 | -1.0 | 直线 |
| 末端非常陡 | -0.5 | 0.0 | -0.5 | 保持更久,下降更快 |
| 开始陡 | 0.5 | 0.0 | -1.5 | 快速下降,然后减速 |
TIP
要可视化不同的系数,在任何绘图工具中绘制 ((a*t + b)*t + c)*t + 1(t ∈ [0, 1])。只要 a + b + c = -1,曲线就会从 1 开始到 0 结束。
总结
这个 68 行的实现展示了三种技术的组合,产生了大于各部分之和的效果:
- 三次多项式 —— 一个简单的函数,创造心理上有效的非线性反馈。曲线形状(开始慢、结束陡)比线性衰减更有效地推动用户行为。
- Horner 法 —— 用 3 次乘法而非 6 次计算多项式,数值稳定性更好。当每帧都要调用来渲染进度条时,这个微优化很重要。
- 二分搜索 —— 在 32 次迭代中以亚纳秒精度数值反转多项式。对于这个用例,比解析替代方案(Cardano 公式)更简单、更稳健。
关键的架构洞察是将一切归一化到 [0, 1]。多项式不知道天数、日期或像素——它将归一化时间映射到归一化等级。调用代码处理真实世界单位和归一化域之间的转换。这使得曲线无需任何修改就可以在主应用、widget、连续记录计算和手动等级编辑中重复使用。
