优雅地取模

不少同学被OJ的取模问题折磨得心力憔悴——不论多么仔细地检查每一处四则运算,总会有一处漏网之鱼让程序输出错误的结果。这里我们展示一种比较优雅的代码书写方式:

const int MOD = 998244353;

int add(int x, int y) { x += y; if (x >= MOD) x -= MOD; return x;}
int sub(int x, int y) { x -= y; if (x < 0) x += MOD; return x; }
int mul(int x, int y) { return 1ll * x * y % MOD; }
void Add(int &x, int y) { x = add(x, y); }
void Sub(int &x, int y) { x = sub(x, y); }
void Mul(int &x, int y) { x = mul(x, y); }

这里我们定义了 MOD 这个变量以及 6 个函数统一完成加法、减法和乘法的取模操作。这样后续程序中的任何运算都可以调用这几个函数来实现:

c = sub(a, b);  // instead of c = (a - b) % MOD;
Add(c, d);      // instead of c += d %= MOD;
Mul(a_very_very_long_variable_name, c); 
  // instead of a_very_very_long_variable_name = 1ll * a_very_very_long_variable_name * c % MOD;

使用统一的取模函数和 MOD 变量有包括但不限于以下好处:

  • 正确性: 你只需要仔细地书写这几个取模函数,后面的程序中你只要保证不出现 + - *,就不会发生“漏了取模”的悲剧。
  • 简洁性: 在上述的的第三个例子中可以看出使用取模函数可以避免重复书写长变量名,使代码更加简洁。
  • 可维护性: 假设某一天我们通知需要将模数紧急换成 1000000007,相比较将程序中散落在各处的 998244353 修改掉,如果你定义了 MOD 变量,你只需要修改一处。
  • 性能: 你也许注意到了我们在加法和减法中使用了 “if 判断 + 加减”的方式代替了取模,你可以证明只要传入的参数在 [0, MOD) 范围内,该写法的正确性的可以保证的。在计算机底层实现中,进行一次取模操作的代价显著高于简单判断和加减,这样的写法有助于提升效率 (有时候这种提升是惊人的!)。

我们承认取模这件事情在工程开发中可能并不常见,但这样的程序设计却体现了软件工程的通用思想:

  • 将需要频繁使用的常量定义成宏/constexpr
  • 将容易出错的功能单独封装成函数,之后调用接口解决问题

程序设计优化是一件“绝知此事要躬行”的事情。阅读一遍这篇文章大抵不会对你的思想产生重大的影响:你也许会认为这些是多此一举,或者你对自己写代码时的仔细程度非常自信。你只有经历了现实的捶打,经历了熬夜通宵的折磨,才会深刻地意识到人类永远无法克服基因决定的共同弱点,才会理解这些文字背后是前人的智慧和血的教训。

笔者在高中时曾参加一项极其重要的编程比赛。他针对一道难题推导出了一个极其复杂的数学公式并编码实现了它,但在比赛结束前的 5 分钟他发现自己的程序处处漏了取模——他永远不会忘记在手指被汗水浸湿以至于键盘打滑的情况下狂按 ctrl+F, ctrl+C, ctrl+V 是怎样的紧张和绝望,也不会忘记最终也没有把取模问题解决完,因为这样一个小细节与自己想要的结果失之交臂的痛苦。从那之后,他再也没有犯过低级的漏取模错误。

我不期望你看完这段话就真的能听进去 (因为这也是人类刻在基因里的弱点之一),但我衷心希望你为之付出的代价能比我小一些。

Previous
Next