<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Coding | Haonan Chen's Homepage</title><link>https://chenhn02.github.io/courses/problemsolving/coding/</link><atom:link href="https://chenhn02.github.io/courses/problemsolving/coding/index.xml" rel="self" type="application/rss+xml"/><description>Coding</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Wed, 07 Jun 2023 00:00:00 +0000</lastBuildDate><image><url>img/map[gravatar:%!s(bool=false) shape:circle]</url><title>Coding</title><link>https://chenhn02.github.io/courses/problemsolving/coding/</link></image><item><title>并查集</title><link>https://chenhn02.github.io/courses/problemsolving/coding/dsu/</link><pubDate>Wed, 07 Jun 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/dsu/</guid><description>&lt;p>《算法导论》中提到基于路径压缩和按秩合并的并查集更新和查询的均摊时间复杂度为 $O(\alpha(n))$，其中 $\alpha$ 为反阿克曼函数。实践中大家通常写只带有路径压缩的并查集，因为按秩合并写起来更加麻烦 (虽然只需要几行)。我们承认只带有路径压缩的并查集时间复杂度会退化为 $O(\log n)$&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>，但这样的数据不太容易构造，且 $\log n$ 其实也相当好了。然而，对于新手来说，路径压缩可能也相当难实现，因此本讲义向大家展示并查集的实现技巧。&lt;/p>
&lt;p>并查集和树状数组是许多程序员心爱的数据结构，因为它们思维巧妙，功能强大，且优秀的实现极其简洁。这里我们给出一份完整的带有路径压缩和按秩合并的并查集模板：&lt;/p>
&lt;pre>&lt;code class="language-c++">namespace DSU
{
int pre[MAXN], rnk[MAXN];
void init()
{
for (int i = 1; i &amp;lt;= n; i++)
pre[i] = i, rnk[i] = 1;
}
int find_anc(int x)
{
if (pre[x] != x) pre[x] = find_anc(pre[x]);
return pre[x];
}
void merge(int x, int y)
{
x = find_anc(x); y = find_anc(y);
if (rnk[x] &amp;gt; rnk[y])
pre[y] = x;
else
{
if (rnk[x] == rnk[y]) rnk[y]++;
pre[x] = y;
}
}
}
&lt;/code>&lt;/pre>
&lt;p>你需要重点关注的是 &lt;code>find_anc()&lt;/code> 函数，它只用一行就在查询集合代表元的同时完成了路径压缩，本质上是在找到代表元之后将路径上所有的节点直接挂到根下。&lt;/p>
&lt;p>如果你觉得麻烦，可以省略按秩合并的部分 (但要付出一点点复杂度的代价，通常可以接受)。还有一些程序员会选择用启发式合并代替按秩合并 (即维护每个集合的元素个数，每次将小的集合合并到大的集合当中)，可以证明路径压缩+启发式合并也可以做到 $O(\alpha(n))$&lt;sup id="fnref1:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>。&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>如果你感兴趣，可以参考
&lt;a href="https://www.luogu.com.cn/blog/Atalod/shi-jian-fu-za-du-shi-neng-fen-xi-qian-tan" target="_blank" rel="noopener">这篇博文&lt;/a> 详细了解。&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&amp;#160;&lt;a href="#fnref1:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>调试艺术</title><link>https://chenhn02.github.io/courses/problemsolving/coding/defense/</link><pubDate>Thu, 18 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/defense/</guid><description>&lt;h3 id="调试理论">调试理论&lt;/h3>
&lt;p>为什么 debug 如此困难？因为 bug 的传播链总是非常长。我们给出如下三个概念：&lt;/p>
&lt;ul>
&lt;li>Fault: 这是 bug 产生的地方，例如你失手打错了循环变量或者逻辑运算符。&lt;/li>
&lt;li>Error: 这是 bug 第一次导致程序的内部状态与正确状态发生偏离的地方 (例如某个变量的值不正确)。&lt;/li>
&lt;li>Failure: 这是你最早能观测到 bug 的地方，例如你的程序输出了错误的结果，或者发生了段错误。&lt;/li>
&lt;/ul>
&lt;p>Debug 的本质就是在观测到 Failure 后向前追溯找到 Fault 的过程。&lt;/p>
&lt;p>我们通过一个例子来体会这三个概念：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int i = 1; i &amp;lt;= n; i++)
for (int j = i + 2; j &amp;lt;= n; j++)
if (a[i] &amp;gt; a[j]) swap(a[i], a[j]);
// lots of other code
for (int i = 1; i &amp;lt;= n; i++)
printf(&amp;quot;%d &amp;quot;, a[i]);
&lt;/code>&lt;/pre>
&lt;p>上面的代码块展示了一个选择法排序的实现。在这个例子中&lt;/p>
&lt;ul>
&lt;li>Fault 发生在第二行：&lt;code>j&lt;/code> 应当从 &lt;code>i + 1&lt;/code> 开始循环而不是 &lt;code>i + 2&lt;/code>。&lt;/li>
&lt;li>Error 发生的地方很难确定，在某些输入下，这个“错误”的选择法排序仍然能输出正确的结果。但如果在某一轮， &lt;code>a[i+1]&lt;/code> 恰好是最小值而 &lt;code>j&lt;/code> 略过了 &lt;code>i+1&lt;/code>，导致该轮结束后 &lt;code>a[i]&lt;/code> 存储的不是 &lt;code>a[i...n]&lt;/code> 中的最小值，那么 a 数组的数据的状态就与正确状态发生了偏离，产生了 Error。&lt;/li>
&lt;li>Failure 发生在打印的地方，你发现在某些输入下输出的序列并不是有序的。&lt;/li>
&lt;/ul>
&lt;p>通过这个例子，我们可以从理论角度总结出 bug 难找的一些原因和启发性的调试思路：&lt;/p>
&lt;ul>
&lt;li>Fault 并不一定能立刻转化成 Error，甚至在某些输入下不会产生 Error。
$\Longrightarrow$ &lt;b>我们需要生成更多的输入对程序进行全方面检查。Differential testing 中包含的自动化测试的思想可以视作一种解决方案。&lt;/b>&lt;/li>
&lt;li>对于程序员来说，容易观察到的是 Fault 和 Failure (前者在源代码中，后者有明显的症状)，而 Error 难以观测，因为程序的内部状态 (内存，寄存器，etc.) 不是直接可见的，且程序员不容易想清楚一个正确的中间状态应该长什么样。$\Longrightarrow$ &lt;b>我们需要想办法以人类可以理解的方式让程序员观测到程序的内部状态。&lt;/b>&lt;/li>
&lt;li>从 Error 到 Failure 往往要经过很多代码，因此 debug 时需要向前看很多代码。$\Longrightarrow$ &lt;b>我们需要想办法让 Error 迅速地暴露为 Failure。&lt;/b>&lt;/li>
&lt;/ul>
&lt;p>分析清楚了 debug 困难的原因，我们就可以对症下药，给出一些有针对性的调试技巧。由于大家对计算机的底层细节尚不了解，本讲义主要总结一些在源代码层面/通过工具可以轻松完成的技巧。&lt;/p>
&lt;h3 id="打印">打印&lt;/h3>
&lt;p>打印是最朴素也最有效的 debug 方法之一，它的理论依据是调试理论的第二条困难——打印可以帮助我们查看程序的中间状态。以之前的选择法排序为例，在看到最终排序结果错误时，你最可能采取的 debug 方法就是在循环中添加打印语句，在每轮内层循环结束后查看当前数组的情况，于是你容易发现在某个特定的轮次元素顺序错误，这也就帮你从 failure 追溯到 error 了。&lt;/p>
&lt;h3 id="gdb">GDB&lt;/h3>
&lt;p>GDB 是程序员人手一个的王牌调试器，它的理论依据也是调试理论的第二条，但它比打印更加灵活和强大——你可以在任何一个你想要的时刻让程序暂停，然后查看任何你想要看的程序状态 (各个变量的值，寄存器的值，内存的值，……)。如果你喜欢 CLI，你可以用命令行打开 gdb，但我们更推荐你使用一些与图形化界面集成在一起的 gdb 工具 (例如 vscode 的 gdb)，它可以给你带来更好的调试体验。&lt;/p>
&lt;p>GDB 唯一的缺点是上手难度比较高，我们这里给出
&lt;a href="https://sourceware.org/gdb/current/onlinedocs/" target="_blank" rel="noopener">官方手册&lt;/a> ，其中有完整的文档 (将近 1000 页 🤯)，还有 cheatsheet。不过在大语言模型时代，让 LLM 帮助你阅读 1000 多页的手册总是不坏的，如果你有任何使用问题，你可以写一个 prompt 丢给 ChatGPT，它通常能给你非常不错的建议。这里我们总结几条目前对于大家来说比较重要的 GDB feature:&lt;/p>
&lt;ul>
&lt;li>断点 (breakpoint): 你可以在程序中打断点，程序运行到断点后就会暂停，供你查询各种程序状态。&lt;/li>
&lt;li>打印：你可以用打印任何你想要的内容，但如果你使用 CLI，你可能要学习各种小技巧让 GDB 输入人类可以理解的内容 (如 &lt;code>p/s&lt;/code> &lt;code>p/i&lt;/code> &lt;code>p/x&lt;/code> 等)，多问问 ChatGPT/多读手册。&lt;/li>
&lt;li>监视点 (watchpoint)：你可以给某个变量/某个地址打监视点，在之后运行的过程中一旦监视点的值发生变化 GDB 就会暂停下来供你调试。&lt;/li>
&lt;/ul>
&lt;p>为了说服你克服学习新事物的惰性并多用 GDB，这里我们展示一个 GDB 极其好用的场景。假设你的程序 &lt;code>error.cpp&lt;/code> 发生了段错误，寻找到底是哪条语句触发了段错误本身就不是一件简单的事情。但如果你使用 GDB，你可以使用 &lt;code>-g&lt;/code> 参数编译代码，然后启动 GDB 运行可执行文件。&lt;/p>
&lt;pre>&lt;code class="language-bash">g++ -o error error.cpp -g &amp;amp;&amp;amp; gdb ./error
&lt;/code>&lt;/pre>
&lt;p>之后直接输入 &lt;code>run&lt;/code> 命令运行，你可以看到段错误发生，然后使用 &lt;code>where&lt;/code> 命令查看函数调用链，你可以清楚地看到源程序中触发段错误的行号，这极大加速了 debug 的过程。&lt;/p>
&lt;h3 id="防御性编程">防御性编程&lt;/h3>
&lt;p>防御性编程 (defensive programming) 指的是在程序中加入一些显式的断言 (assertion) 来对程序的状态进行检查。这恰恰对应了调试理论中的第三条困难的解决方案。我们以书写平衡树的旋转操作为例：&lt;/p>
&lt;pre>&lt;code class="language-c++">void rotate(Node *u)
{
// 结构约束
assert(u-&amp;gt;parent == u /* u is root */ || u-&amp;gt;parent-&amp;gt;left == u || u-&amp;gt;parent-&amp;gt;right == u);
assert(!u-&amp;gt;left || u-&amp;gt;left-&amp;gt;parent == u);
assert(!u-&amp;gt;right || u-&amp;gt;right-&amp;gt;parent == u);
// 数值约束
assert(!u-&amp;gt;left || u-&amp;gt;left-&amp;gt;val &amp;lt; u-&amp;gt;val);
// rotation code
}
&lt;/code>&lt;/pre>
&lt;p>&lt;code>assert(expr)&lt;/code> 的语义是：如果其中包含的逻辑表达式 &lt;code>expr&lt;/code> 值为假，则抛出异常 (在没有 error handler 的情况下这通常会使得程序直接终止，注意程序终止是一种 Failure)。例子中的这些 assertion 描述了一个平衡树节点理应满足的性质，它们看上去非常地显然，但如果你的程序有 bug，你的错误很可能被这些 assertion 抓住。这使得你可以从尽可能接近 Error 的地方出发寻找 Fault。&lt;/p>
&lt;p>防御性编程的代价在于，作为程序员你需要额外写很多代码，以及有些 assertion 在抓 error 方面很有价值，有些则可能无关紧要，这其中的判断力需要你不断积累经验。我们的建议是：能加尽可能多加，写几条 assertion 的时间和深夜 debug 到头秃的时间相比不值一提。&lt;/p>
&lt;h3 id="sanitizers">Sanitizers&lt;/h3>
&lt;p>手写 assertion 终究还是太过繁琐。例如从极其严谨的角度来说，你应当将数组封装成这样：&lt;/p>
&lt;pre>&lt;code class="language-c++">class Array
{
int N = 1000, a[1000];
public:
void store(int pos, int value)
{
assert(0 &amp;lt;= pos &amp;amp;&amp;amp; pos &amp;lt; N);
a[pos] = value;
}
int access(int pos)
{
assert(0 &amp;lt;= pos &amp;amp;&amp;amp; pos &amp;lt; N);
return a[pos];
}
};
&lt;/code>&lt;/pre>
&lt;p>这两条 assertion 可以帮助你检查对数组的越界操作，&lt;del>但如果把程序写成这样，那程序员可别活了&lt;/del>。有没有什么工具可以帮我们在每次访问数组时自动检查是否越界？&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>计算机世界的两条公理&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>机器永远是对的，未测试代码永远是错的。&lt;/li>
&lt;li>你如果发现自己有某种需求，一定有某种工具可以帮助你实现它。&lt;/li>
&lt;/ol>
&lt;/blockquote>
&lt;p>根据公理第二条，这样的工具应该是存在的。这里我们为大家介绍 Address Sanitizer (它好像应当被翻译成地址消毒剂，但我从未见过这样的表达)。Address Sanitizer (ASAN) 可以检查 C/C++ 程序中与内存访问相关的错误，主流的编译器/IDE，例如 Visual Studio, GCC, Clang 都支持 ASAN。下面是一个例子：&lt;/p>
&lt;pre>&lt;code class="language-c++">// error.cpp
#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
int a[10];
int main ()
{
a[11] = 1;
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>该程序在 &lt;code>main()&lt;/code> 函数中包含了对全局数组的越界读取。虽然直接编译运行这个程序(通常)不会导致段错误，但这样的操作仍然是危险的，因为它属于
&lt;a href="https://en.wikipedia.org/wiki/Undefined_behavior" target="_blank" rel="noopener">undefined behavior&lt;/a>。我们来看看使用 ASAN 会得到什么样的结果 (在编译命令中加上 &lt;code>-fsanitize=address&lt;/code> 即可使用 ASAN)：&lt;/p>
&lt;pre>&lt;code class="language-bash">&amp;gt; g++ -o error error.cpp -fsanitize=address &amp;amp;&amp;amp; ./error
==40526==ERROR: AddressSanitizer: global-buffer-overflow on address 0x55c41dbbdf6c at pc 0x55c41dbb928b bp 0x7ffda7143ce0 sp 0x7ffda7143cd0
WRITE of size 4 at 0x55c41dbbdf6c thread T0
#0 0x55c41dbb928a in main (error+0x228a)
#1 0x7ff6ab1a0564 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x28564)
#2 0x55c41dbb918d in _start (error+0x218d)
0x55c41dbbdf6c is located 4 bytes to the right of global variable 'a' defined in 'error.cpp:5:5' (0x55c41dbbdf40) of size 40
SUMMARY: AddressSanitizer: global-buffer-overflow (error+0x228a) in main
Shadow bytes around the buggy address:
0x0ab903b6fb90: f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9
0x0ab903b6fba0: f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9
0x0ab903b6fbb0: f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9
0x0ab903b6fbc0: f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9
0x0ab903b6fbd0: f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 00 00 00 00
=&amp;gt;0x0ab903b6fbe0: 01 f9 f9 f9 f9 f9 f9 f9 00 00 00 00 00[f9]f9 f9
0x0ab903b6fbf0: f9 f9 f9 f9 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab903b6fc00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab903b6fc10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab903b6fc20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab903b6fc30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==40526==ABORTING
&lt;/code>&lt;/pre>
&lt;p>可以看到 ASAN 提醒我们全局数组越界，甚至告诉了我们是在第五行定义的 &lt;code>a&lt;/code> 数组合法范围向右偏移 4 字节的地方发生了越界访问。有了这些信息 debug 将会变得非常方便 (更重要的是，它告诉了我们 bug 的存在！)。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>为什么这样就有段错误了？&lt;/strong>&lt;/p>
&lt;pre>&lt;code class="language-c++">int main ()
{
int a[10];
a[11] = 1; // 等等，怎么换成 a[100] = 1 就又没有段错误了 😵‍💫
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>非常好的观察！解释清楚这个问题需要较多的计算机底层知识，如果你真的感兴趣，可以上网搜索 Stack Canary 相关的内容。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>性能优化</title><link>https://chenhn02.github.io/courses/problemsolving/coding/profiling/</link><pubDate>Mon, 15 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/profiling/</guid><description>&lt;blockquote>
&lt;p>Premature optimization is the root of all evil. &amp;ndash; Donald Knuth&lt;/p>
&lt;/blockquote>
&lt;p>性能优化的需求非常普遍——在 OJ 层面，这主要体现为将 TLE 的程序改到 AC。本文旨在对于 OJ 层面的性能优化问题给予一些最基本的指导。&lt;/p>
&lt;h3 id="计算程序的运行时间">计算程序的运行时间&lt;/h3>
&lt;p>衡量一个程序的性能的指标有很多，其中最简单、最直接的方式就是运行时间，因此你至少应该学会如何计算一个程序的运行时间。&lt;/p>
&lt;ul>
&lt;li>如果你使用类 unix 系统，你可以直接使用 &lt;code>time&lt;/code> 命令。&lt;/li>
&lt;li>如果你使用的系统没有可以直接测算时间的命令，你可以使用 C/C++ 库的 &lt;code>time()&lt;/code> 函数来打印运行时间，具体的使用方法请自行上网搜索。&lt;/li>
&lt;/ul>
&lt;h3 id="对抗式的输入构造策略">“对抗”式的输入构造策略&lt;/h3>
&lt;p>假设你写了一个如下的快速排序程序：&lt;/p>
&lt;pre>&lt;code class="language-c++">void quick_sort(int l, int r)
{
if (l == r) return;
int pos = partition(l, r, a[l]);
quick_sort(l, pos - 1);
quick_sort(pos + 1, r);
}
&lt;/code>&lt;/pre>
&lt;p>这个每次选择第一个数作为 pivot 的快速排序程序在随机数据上可以给到 $O(n\log n)$ 的时间复杂度，但假设你现在是一个“找茬”的人，为了让这个程序跑得很慢，你一定会构造一个很长且原本有序的数列，这样每次 &lt;code>partition()&lt;/code> 只能去掉 pivot 一个数，从而时间复杂度退化到 $O(n^2)$。&lt;/p>
&lt;p>对于算法题来说，我们通常在意的是算法的“最坏时间复杂度”。所以测试程序性能时，你应该代入“找茬”的角色，去思考什么样的输入能将程序卡到最慢。这也是 online judge 对大家的程序进行性能测试时需要考虑的点。&lt;/p>
&lt;h3 id="寻找程序运行的时间瓶颈">寻找程序运行的时间瓶颈&lt;/h3>
&lt;p>假设你写了一个如下的排序程序：&lt;/p>
&lt;pre>&lt;code class="language-c++">int a[100000];
int main ()
{
for (int i = 1; i &amp;lt;= n; i++)
scanf(&amp;quot;%d&amp;quot;, a + i);
for (int i = 1; i &amp;lt;= n; i++)
for (int j = i + 1; j &amp;lt;= n; j++)
if (a[i] &amp;gt; a[j])
swap(a[i], a[j]);
}
&lt;/code>&lt;/pre>
&lt;p>并发现它在处理 $n=10^5$ 的数列时超时了。此时你有两个优化方案：&lt;/p>
&lt;ul>
&lt;li>用“快速输入输出”章节中的 &lt;code>getchar()&lt;/code> 方法代替 &lt;code>scanf()&lt;/code> 进行读入。&lt;/li>
&lt;li>修改排序算法，使用归并排序。&lt;/li>
&lt;/ul>
&lt;p>你一定会采纳第二条建议，因为这个程序运行的时间瓶颈在核心排序算法的部分——它的复杂度达到了 $O(n^2)$，而输入部分是线性的。换句话说，对着仅占总运行时间 $1%$ 的部分优化，即使你让该部分快了一倍，它对整个程序的优化效果也是微乎其微的。因此，在做性能优化时你应该先仔细分析哪个部分是耗时最长的，对着耗时最长的 critical path 优化才能取得最显著的效果。至于如何找出运行时间最慢的部分，对于 OJ 程序来说，最简单的方法是注释掉某些部分，然后观察程序的运行时长有无显著的变化。&lt;/p>
&lt;p>再举一个例子：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int i = 1; i &amp;lt;= n; i++)
{
a[i] = 0;
for (int j = i + 1; j &amp;lt;= n; j++)
a[i] += compute(b[j]);
a[i] %= MOD;
}
&lt;/code>&lt;/pre>
&lt;p>假设你通过定位确定了这个程序段是效率瓶颈，此时你有两个优化方案：&lt;/p>
&lt;ul>
&lt;li>将 &lt;code>a[i] %= MOD&lt;/code> 改写为效率更高的减法 (内层循环也要同步修改)。&lt;/li>
&lt;li>优化函数 &lt;code>compute()&lt;/code> 的效率。&lt;/li>
&lt;/ul>
&lt;p>你仍然应该选择第二条方案。虽然取模的效率不高，但这条语句不在最内层循环。换句话说，从时间复杂度的角度来讲，取模操作被执行了 $O(n)$ 次，而 &lt;code>compute()&lt;/code> 被执行了 $O(n^2)$ 次。因此，我们最需要关注的，是&lt;strong>效率瓶颈模块的最内层语句&lt;/strong>。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Profiling: The Real World&lt;/strong>&lt;/p>
&lt;p>在真实世界中，profiling 也是被广泛使用的一项技术。&lt;/p>
&lt;blockquote>
&lt;p>“&lt;em>Computer Architecture: A Quantitative Approach&lt;/em> 这本书对于计算机体系结构的 insight 可以简单概括为两条：(1)处理器也是一个编译器。(2) 木桶效应。” —— jyy&lt;/p>
&lt;/blockquote>
&lt;p>这里的“木桶效应”指的是：一个计算机系统的真实性能由最短的那块木板决定。因此优化工程师的日常工作便是盯着 profiling report，找“最短的木板”，尝试让它变长一点，再去寻找新的短板。&lt;/p>
&lt;p>现实世界中 profiling 的工具有很多 (比“注释-测试”的 OJ 程序法要方便)，它们的主要原理是运行大量的单元测试/压力测试，然后统计每个“基本单元”运行时长占总时长的比例。这里的“基本单元”对于工作在不同层级的人来说有不同的颗粒度。对于软件系统的开发者，“基本单元”可能是颗粒度较粗的高级语言语句、函数甚至模块；对于编译器/硬件开发者，“基本单元”则是中间代码、机器指令甚至是微指令。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>逆元</title><link>https://chenhn02.github.io/courses/problemsolving/coding/mulinv/</link><pubDate>Sat, 13 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/mulinv/</guid><description>&lt;p>对于整数 $a, b&amp;lt;p$，计算 $(a/b)\text{ mod }p$ 可以转化为计算 $a\cdot b^{-1}\text{ mod }p$，这里 $b^{-1}$ 称为 $b$ 在模 $p$ 下的&lt;strong>逆元&lt;/strong>，可以证明当 $p$ 为质数时，$b^{-1}$ 在 $[0, p)$ 范围内是唯一的。逆元的数学推导会在后续的理论课程中给出，这里不做赘述 (如果你不懂也暂时不必深究)。本文主要阐述人们通常是如何书写计算逆元的代码。&lt;/p>
&lt;p>当 $p$ 为质数时，计算逆元最简单的方法之一是使用费尔马小定理。由于&lt;/p>
&lt;p>$$a^{p-1}\equiv 1(\text{mod }p)$$&lt;/p>
&lt;p>所以令 $a^{-1}=a^{p-2}\text{ mod }p$，则有 $a\cdot a^{-1}\equiv 1(\text{mod }p)$。这里的 $a^{-1}$ 即为 $a$ 在模 $p$ 意义下的逆元。你可以使用快速幂算法快速计算 $a^{p-2}$ 的值，时间复杂度为 $O(\log p)$。&lt;/p>
&lt;p>另外一种常见的需求是计算 $[1, n]$ 中所有数的逆元。如果对每个数用费尔马小定理计算逆元，时间复杂度将达到 $O(n\log p)$，有时不可接受。前人发明过一些非常精巧的算法递推地 $O(n)$ 求出所有数的逆元，如果你感兴趣可以参考
&lt;a href="https://blog.csdn.net/bcr_233/article/details/87898670" target="_blank" rel="noopener">这篇博客&lt;/a>。但我们更推荐你采用如下非常简洁的做法：&lt;/p>
&lt;ul>
&lt;li>顺序递推求出 $1!, 2!, \cdots, n!$。&lt;/li>
&lt;li>使用快速幂计算 $(n!)^{-1}$。&lt;/li>
&lt;li>从后往前递推算出所有阶乘的逆元 (注：$(k!)^{-1}\cdot k=((k-1)!)^{-1}$)。&lt;/li>
&lt;li>对于 $x$，$x^{-1}=(x-1)!\cdot (x!)^{-1}$。&lt;/li>
&lt;/ul>
&lt;p>该做法虽然看上去多了几遍递推，但时间复杂度仍为 $O(n)$，且通常拥有更加优秀的常数因子 (即在实际运行中效率可能反而比链接中的一遍递推要高，因为链接中的做法有大量的取模操作)。&lt;/p>
&lt;details>&lt;summary>&lt;b>Reference&lt;/b> :: &lt;i>click to expand&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-c++">const int MOD = 998244353;
int fac[MAXN], ifac[MAXN], inv[MAXN];
void init_inv()
{
fac[0] = 1;
for (int i = 1; i &amp;lt;= n; i++)
fac[i] = 1ll * fac[i - 1] * i % MOD;
ifac[n] = quick_pow(fac[n], MOD - 2);
for (int i = n - 1; i &amp;gt;= 0; i--)
{
ifac[i] = 1ll * ifac[i + 1] * (i + 1) % MOD;
inv[i + 1] = 1ll * fac[i] * ifac[i + 1] % MOD;
}
}
&lt;/code>&lt;/pre>
&lt;/details></description></item><item><title>快速输入输出</title><link>https://chenhn02.github.io/courses/problemsolving/coding/fastio/</link><pubDate>Wed, 19 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/fastio/</guid><description>&lt;p>大家比较熟悉的输入输出有两种：&lt;/p>
&lt;pre>&lt;code class="language-c++">int x;
scanf(&amp;quot;%d&amp;quot;, &amp;amp;x); // C style
printf(&amp;quot;%d\n&amp;quot;, x);
std::cin &amp;gt;&amp;gt; x; // C++ style
std::cout &amp;lt;&amp;lt; x &amp;lt;&amp;lt; endl;
&lt;/code>&lt;/pre>
&lt;p>如果输入/输出量过大，以至于输入输出部分成为算法问题实现的效率瓶颈，那么采取合适的方法进行快速输入/输出就变得非常重要。本章节介绍若干种常见的输入输出优化。&lt;/p>
&lt;div class="alert alert-warning">
&lt;div>
输入输出函数背后的实现非常复杂，其效率与缓冲区设计、操作系统、磁盘IO等多个环节息息相关。因此本章节会尽可能略去原理的讲解 (或者以补充链接的形式给出)。如果你无法理解这其中的奥妙，不用担心，先把写法学会，等你学过了计算机体系结构/计算机系统基础/操作系统后就会对它们有更深刻的认识。
&lt;/div>
&lt;/div>
&lt;h3 id="关闭与-stdio-的同步">关闭与 stdio 的同步&lt;/h3>
&lt;p>一种
&lt;a href="https://stackoverflow.com/questions/1042110/using-scanf-in-c-programs-is-faster-than-using-cin" target="_blank" rel="noopener">主流的说法&lt;/a> 是: &lt;code>cin&lt;/code> 比 &lt;code>scanf&lt;/code> 读入要慢。在选择接受这条“定理”之前，你应当自己做个实验来验证一下：&lt;/p>
&lt;pre>&lt;code class="language-c++">int N = 30000000;
int a[N];
int main ()
{
for (int i = 0; i &amp;lt; N; i++) cin &amp;gt;&amp;gt; a[i];
for (int i = 0; i &amp;lt; N; i++) scanf(&amp;quot;%d&amp;quot;, a + i);
}
&lt;/code>&lt;/pre>
&lt;p>将 $N$ 设置成一个很大的数，并用 &lt;code>cin&lt;/code> 和 &lt;code>scanf&lt;/code> 分别跑一遍测试时间。不出意外的话你会发现这条定理确有其正确之处。但事实上 &lt;code>cin&lt;/code> 比 &lt;code>scanf&lt;/code> 看上去慢的一个主要原因是：&lt;code>cin&lt;/code> 花了很多代价进行缓冲区同步，从而让你可以在混用 &lt;code>cin&lt;/code> 和 &lt;code>scanf&lt;/code> 的情况保证程序的正确性。我们可以通过在 &lt;code>main()&lt;/code> 函数开头显式地添加一句 &lt;code>ios::sync_with_stdio(false);&lt;/code> 的方式来关闭这种同步。关闭同步后，你如果再尝试一次上述的效率实验，会发现 &lt;code>cin&lt;/code> 和 &lt;code>scanf&lt;/code> 其实没什么差别。&lt;/p>
&lt;p>需要注意的是：一旦手动关闭了同步，你就不能再混用两种风格的输入函数。你可以尝试运行下面的例子：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
int main ()
{
ios::sync_with_stdio(false);
int a, b, c;
cin &amp;gt;&amp;gt; a &amp;gt;&amp;gt; b;
scanf(&amp;quot;%d&amp;quot;, &amp;amp;c);
cout &amp;lt;&amp;lt; a &amp;lt;&amp;lt; ' ' &amp;lt;&amp;lt; b &amp;lt;&amp;lt; ' ' &amp;lt;&amp;lt; c &amp;lt;&amp;lt; endl;
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>你会发现该程序会给你意想不到的结果。&lt;/p>
&lt;h3 id="使用-getchar-代替-scanf">使用 &lt;code>getchar()&lt;/code> 代替 &lt;code>scanf()&lt;/code>&lt;/h3>
&lt;p>C库提供的 &lt;code>scanf()&lt;/code> 函数功能丰富，例如可以用 &lt;code>%d&lt;/code> &lt;code>%s&lt;/code> &lt;code>%p&lt;/code> &lt;code>%x&lt;/code>，甚至正则表达式，去匹配各种类型的数据。但功能丰富的代价就是效率不够高。如果你确定当前场景下需要读入的一定是某种特定类型的数据 (例如整数)，那么可以考虑使用 &lt;code>getchar()&lt;/code> (其功能为读入一个字符) 来手写一个读入函数，这通常能获得更好的效率。下面展示一个读入 int 类型整数的函数：&lt;/p>
&lt;pre>&lt;code class="language-c++">int getint()
{
char ch; int res; bool f;
while (!isdigit(ch = getchar()) &amp;amp;&amp;amp; ch != '-'); // 过滤所有不是数字和&amp;quot;-&amp;quot;的字符
if (ch=='-')
f = false, res = 0;
else
f = true, res = ch - '0'; // 判断正负性
while (isdigit(ch = getchar()))
res = res * 10 + ch - '0'; // 读取数字并计算
return f ? res : -res;
}
&lt;/code>&lt;/pre>
&lt;h3 id="使用-n-代替-endl">使用 &lt;code>\n&lt;/code> 代替 &lt;code>endl&lt;/code>&lt;/h3>
&lt;p>有的时候，你会发现 &lt;code>cout &amp;lt;&amp;lt; &amp;quot;\n&amp;quot;;&lt;/code> 比 &lt;code>cout &amp;lt;&amp;lt; endl;&lt;/code> 要快。这是因为 &lt;code>endl&lt;/code> 会强制冲刷缓冲区，在缓冲区没满的时候多次冲刷会让效率变低。(
&lt;a href="https://stackoverflow.com/questions/213907/stdendl-vs-n" target="_blank" rel="noopener">参考链接&lt;/a> )&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>什么是缓冲区?&lt;/strong>&lt;/p>
&lt;p>了解缓冲区设计的动机需要对计算机系统中的 memory hierarchy 有一定了解，这里尝试尽可能短和通俗地解释清楚这个概念。&lt;/p>
&lt;p>假设你人在宿舍，要去图书馆借书。上午要去借A，下午要去借B，晚上去借C，于是你跑了三趟图书馆，这非常耗时。假设你是一个先知，上午就知道了自己要借 ABC 三本书，为了节省跑图书馆的时间，你在宿舍楼下放了一个书架，上午你一次性抱了三本书回来放在楼下的书架上，后面每次你需要书了就只需要下楼从书架上拿即可。还书也是相同的道理，你发现与其跑三趟，不如把要还的书先放在书架上，等手里所有的书都看完了再把书架上的书一起还到图书馆。&lt;/p>
&lt;p>这个例子里&lt;/p>
&lt;ul>
&lt;li>从图书馆借书/还书就是输入输出。相比较 &lt;code>a[i]=b[j]&lt;/code> &lt;code>a++&lt;/code> 这样的操作，执行一次输入输出是十分耗时的。&lt;/li>
&lt;li>书A/B/C是数据，或者说即将读入/准备输出的字符。&lt;/li>
&lt;li>书架是缓冲区。缓冲区就像一个大数组，暂存读入和输出数据。所谓的“冲刷缓冲区”就是将书架里的书全部还回图书馆/将缓冲区的数据真正输出。&lt;/li>
&lt;/ul>
&lt;p>因此，使用 &lt;code>endl&lt;/code> 就像每次往书架上放书时都强制把书架上所有的书放回图书馆，效率自然不高。&lt;/p>
&lt;/div>
&lt;/div>
&lt;h3 id="使用-freadfwrite">使用 &lt;code>fread()&lt;/code>/&lt;code>fwrite()&lt;/code>&lt;/h3>
&lt;p>一般只有在读入/输出量极大的时候我们才会考虑使用 &lt;code>fread()&lt;/code>/&lt;code>fwrite()&lt;/code> 完成输入输出。如果延用之前借书的例子，&lt;code>fread()&lt;/code> 和 &lt;code>fwrite()&lt;/code> 相当于手动开辟一个巨大的书架，这个书架比 &lt;code>scanf()&lt;/code>/&lt;code>cin&lt;/code> 的书架大的多，从而显著减少了“把书从图书馆搬到书架”的次数。&lt;/p>
&lt;p>这里提供一份使用 fread/fwrite 的代码供参考。&lt;/p>
&lt;details>&lt;summary>&lt;b>fastio class&lt;/b> &lt;i>::click to expand&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-c++">struct fastio
{
static const int S=1e7;
char rbuf[S+48],wbuf[S+48];int rpos,wpos,len;
fastio() {rpos=len=wpos=0;}
inline char Getchar()
{
if (rpos==len) rpos=0,len=fread(rbuf,1,S,stdin);
if (!len) return EOF;
return rbuf[rpos++];
}
template &amp;lt;class T&amp;gt; inline void Get(T &amp;amp;x)
{
char ch;bool f;T res;
while (!isdigit(ch=Getchar()) &amp;amp;&amp;amp; ch!='-') {}
if (ch=='-') f=false,res=0; else f=true,res=ch-'0';
while (isdigit(ch=Getchar())) res=res*10+ch-'0';
x=(f?res:-res);
}
inline void getstring(char *s)
{
char ch;
while ((ch=Getchar())&amp;lt;=32) {}
for (;ch&amp;gt;32;ch=Getchar()) *s++=ch;
*s='\0';
}
inline void flush() {fwrite(wbuf,1,wpos,stdout);fflush(stdout);wpos=0;}
inline void Writechar(char ch)
{
if (wpos==S) flush();
wbuf[wpos++]=ch;
}
template &amp;lt;class T&amp;gt; inline void Print(T x,char ch)
{
char s[20];int pt=0;
if (x==0) s[++pt]='0';
else
{
if (x&amp;lt;0) Writechar('-'),x=-x;
while (x) s[++pt]='0'+x%10,x/=10;
}
while (pt) Writechar(s[pt--]);
Writechar(ch);
}
inline void printstring(char *s)
{
int pt=1;
while (s[pt]!='\0') Writechar(s[pt++]);
}
}io;
&lt;/code>&lt;/pre>
&lt;/details>
&lt;br>
&lt;p>需要注意： 因为 &lt;code>fread()&lt;/code> 和 &lt;code>fwrite()&lt;/code> 开辟了大数组作为缓冲区一次性搬入很多数据，所以 &lt;code>fread()&lt;/code>/&lt;code>fwrite()&lt;/code> 不能和其他使用自带 buffer 的IO方法混用。换句话说，你只能从通过操作自定义的缓冲区的方式来输入输出。&lt;/p></description></item><item><title>Differential Testing</title><link>https://chenhn02.github.io/courses/problemsolving/coding/difftest/</link><pubDate>Tue, 28 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/difftest/</guid><description>&lt;p>如何论证你写的程序是正确的？这是一个历史悠久且难以回答的问题。通常来说有两种思路：&lt;/p>
&lt;ul>
&lt;li>Verification: 形式化验证简单来说是通过数学手段证明你的程序的行为符合预期。这是对程序正确性的强而有力的证明，但通常非常困难、局限性很大、scalability 较差，因此更多用于对于安全/可靠性要求非常严苛的场景，如自动驾驶，火星车等。问题求解一中提到的“通过 loop invariant 证明循环正确性”可以认为是 verification 的一种非常简单的形式。由于 verificaton 需要大量的程序语言(PL)/形式化方法(FM)的背景知识，且对于简单算法问题来说颇有大材小用之嫌，故在这里不作赘述。&lt;/li>
&lt;li>Testing: 测试是更加常用的检查程序是否有漏洞的方法，其核心思想是构造大量的输入并检查目标程序在这些输入上的输出是否符合预期。测试有着天然的局限性：它只能证明你的程序有 bug，但无法证明你的程序没有 bug (OJ 的本质也是测试，因此在 OJ 上通过的程序并不一定是对的 &lt;del>而且由于助教太懒数据太水很可能确实是错的&lt;/del>)。不过我们总可以认为，如果我们构造的输入足够多且足够丰富，那么通过了所有测试的程序的可靠性相对会很高。&lt;/li>
&lt;/ul>
&lt;p>这里我们介绍一种简单易行的测试技术：differential testing (如果你曾经参加过算法竞赛，“对拍”的本质就是 DiffTest)。DiffTest 的核心思想是针对同一个问题完成两份不同的实现，然后构造大量的输入喂给两份实现观察它们的输出是否相同 (就像你每天早晨和同桌对作业答案，虽然你们都不能保证自己做的是对的，但做错且错得一样的概率毕竟很小，如果你们的解题思路不一样就更小了)。为了避免过于空洞，我们以“计算Fibonacci第n项”这个问题为例说明如何在 OJ 算法题上进行 DiffTest。&lt;/p>
&lt;p>假设你已经写好了一个使用矩阵快速幂优化 Fibonacci 计算的程序 &lt;code>fib.cpp&lt;/code>，现在希望对其进行测试。我们很容易写一个朴素版的 &lt;code>fib_bruteforce.cpp&lt;/code> 来实现同样的功能 (虽然它能处理的 $n$ 规模较小)：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
const int MOD = 998244353;
int main ()
{
int n, f0 = 0, f1 = 1, f2;
cin &amp;gt;&amp;gt; n;
for (int i = 2; i &amp;lt;= n; i++)
{
f2 = (f0 + f1) % MOD;
f0 = f1; f1 = f2;
}
printf(&amp;quot;%d\n&amp;quot;, f2);
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>根据我们的预期，对于任意 $n$，如果 &lt;code>fib.cpp&lt;/code> 和 &lt;code>fib_bruteforce.cpp&lt;/code> 都给出了答案，那么答案应该是相同的。这个问题的好处在于测试数据形式非常简单：只有一个数。因此我们很容易写出一个数据生成程序 &lt;code>gen_data.cpp&lt;/code> (注意我们的朴素版本无法处理过大的 $n$)：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
int main ()
{
int limit = 10000000;
mt19937 mt(time(nullptr));
cout &amp;lt;&amp;lt; mt() % limit + 1 &amp;lt;&amp;lt; endl;
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>&lt;code>mt19937&lt;/code> class 是一个非常高效的伪随机数生成器。你也可以使用 &lt;code>rand()&lt;/code> 等其他函数来生成随机数 (注意设置随机种子，否则可能每次生成的随机数都一样)。&lt;/p>
&lt;p>有了两份实现和一个数据生成器，接下来的任务就是写一个脚本来做如下循环：&lt;/p>
&lt;pre>&lt;code class="language-plaintext">while True
{
DataGenerator &amp;gt;&amp;gt; data
data &amp;gt;&amp;gt; Program1 &amp;gt;&amp;gt; output1
data &amp;gt;&amp;gt; Program2 &amp;gt;&amp;gt; output2
compare output1 and output2
}
&lt;/code>&lt;/pre>
&lt;p>如果你会在 Windows 下写 Batch/Powershell 脚本，你应该可以给出一个上述伪代码的轻巧实现；如果你使用类 Unix 系统，你大概率也可以给出一个 bash 脚本。如果你啥也不会，可以考虑使用下面给出的这份 Python 程序进行测试。&lt;/p>
&lt;details>&lt;summary>一份 Difftest Python 实现 &lt;i>[Click to expand]&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-python">&amp;quot;&amp;quot;&amp;quot;
usage: difftest.py [-h] --impl IMPL IMPL --gen GEN [--num NUM] [--time TIME]
optional arguments:
-h, --help show this help message and exit
--impl IMPL IMPL two source code files
--gen GEN data generator, cpp or python
--num NUM number of tests
--time TIME time limit for each test, in seconds
&amp;quot;&amp;quot;&amp;quot;
import os, subprocess
import atexit
objs = []
def compile(*progs):
for prog in progs:
assert os.path.exists(prog), f&amp;quot;{prog} not exist&amp;quot;
if prog.endswith(&amp;quot;.cpp&amp;quot;):
obj = f&amp;quot;obj-{prog}&amp;quot;
objs.append(obj)
p = subprocess.run(f&amp;quot;g++ -o {obj} {prog} -O2&amp;quot;, shell=True)
assert p.returncode == 0, f&amp;quot;{prog} failed to be compiled, make sure that g++ is in your enviroment path&amp;quot;
def destructor():
for obj in objs:
if os.path.exists(obj):
os.remove(obj)
def run(prog: str, tl: float, data: str):
cmd = f&amp;quot;./obj-{prog}&amp;quot; if prog.endswith(&amp;quot;.cpp&amp;quot;) else f&amp;quot;python3 {prog}&amp;quot;
p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
try:
stdout, _ = p.communicate(input=data.encode(), timeout=tl)
except subprocess.TimeoutExpired:
p.kill()
raise Exception(f&amp;quot;{prog} exceed {tl}s time limit&amp;quot;)
return stdout.decode()
def dump(data, o1, o2):
with open(&amp;quot;input&amp;quot;, &amp;quot;w&amp;quot;) as f: f.write(data)
with open(&amp;quot;output1&amp;quot;, &amp;quot;w&amp;quot;) as f: f.write(o1)
with open(&amp;quot;output2&amp;quot;, &amp;quot;w&amp;quot;) as f: f.write(o2)
def cmp(data, o1, o2):
t1, t2 = o1.strip().split(&amp;quot;\n&amp;quot;), o2.strip().split(&amp;quot;\n&amp;quot;)
if len(t1) != len(t2):
dump(data, o1, o2)
return False
for line1, line2 in zip(t1, t2):
if line1.strip() != line2.strip():
dump(data, o1, o2)
return False
return True
if __name__ == &amp;quot;__main__&amp;quot;:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(&amp;quot;--impl&amp;quot;, nargs=2, required=True, help=&amp;quot;two source code files&amp;quot;)
parser.add_argument(&amp;quot;--gen&amp;quot;, type=str, required=True, help=&amp;quot;data generator, cpp or python&amp;quot;)
parser.add_argument(&amp;quot;--num&amp;quot;, type=int, default=100, help=&amp;quot;number of tests&amp;quot;)
parser.add_argument(&amp;quot;--time&amp;quot;, type=float, default=5.0, help=&amp;quot;time limit for each test, in seconds&amp;quot;)
args = parser.parse_args()
atexit.register(destructor)
compile(args.impl[0], args.impl[1], args.gen)
for i in range(args.num):
data = run(args.gen, args.time, &amp;quot;&amp;quot;)
res1 = run(args.impl[0], args.time, data)
res2 = run(args.impl[1], args.time, data)
assert cmp(data, res1, res2), &amp;quot;Wrong answer, input/output files dumped&amp;quot;
print(f&amp;quot;Test {i+1} OK!&amp;quot;)
&lt;/code>&lt;/pre>
&lt;/details>
&lt;hr>
&lt;p>DiffTest 显然比自己出数据，运行程序，再手动验证结果要高效得多。用正确的工具和方法做事能让你事半功倍。&lt;/p></description></item><item><title>优雅地取模</title><link>https://chenhn02.github.io/courses/problemsolving/coding/modulation/</link><pubDate>Wed, 22 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/modulation/</guid><description>&lt;p>不少同学被OJ的取模问题折磨得心力憔悴——不论多么仔细地检查每一处四则运算，总会有一处漏网之鱼让程序输出错误的结果。这里我们展示一种比较优雅的代码书写方式：&lt;/p>
&lt;pre>&lt;code class="language-c++">const int MOD = 998244353;
int add(int x, int y) { x += y; if (x &amp;gt;= MOD) x -= MOD; return x;}
int sub(int x, int y) { x -= y; if (x &amp;lt; 0) x += MOD; return x; }
int mul(int x, int y) { return 1ll * x * y % MOD; }
void Add(int &amp;amp;x, int y) { x = add(x, y); }
void Sub(int &amp;amp;x, int y) { x = sub(x, y); }
void Mul(int &amp;amp;x, int y) { x = mul(x, y); }
&lt;/code>&lt;/pre>
&lt;p>这里我们定义了 &lt;code>MOD&lt;/code> 这个变量以及 6 个函数统一完成加法、减法和乘法的取模操作。这样后续程序中的任何运算都可以调用这几个函数来实现：&lt;/p>
&lt;pre>&lt;code class="language-c++">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;
&lt;/code>&lt;/pre>
&lt;p>使用统一的取模函数和 &lt;code>MOD&lt;/code> 变量有包括但不限于以下好处：&lt;/p>
&lt;ul>
&lt;li>正确性: 你只需要仔细地书写这几个取模函数，后面的程序中你只要保证不出现 &lt;code>+&lt;/code> &lt;code>-&lt;/code> &lt;code>*&lt;/code>，就不会发生“漏了取模”的悲剧。&lt;/li>
&lt;li>简洁性: 在上述的的第三个例子中可以看出使用取模函数可以避免重复书写长变量名，使代码更加简洁。&lt;/li>
&lt;li>可维护性: 假设某一天我们通知需要将模数紧急换成 &lt;code>1000000007&lt;/code>，相比较将程序中散落在各处的 &lt;code>998244353&lt;/code> 修改掉，如果你定义了 &lt;code>MOD&lt;/code> 变量，你只需要修改一处。&lt;/li>
&lt;li>性能: 你也许注意到了我们在加法和减法中使用了 “if 判断 + 加减”的方式代替了取模，你可以证明只要传入的参数在 &lt;code>[0, MOD)&lt;/code> 范围内，该写法的正确性是可以保证的。在计算机底层实现中，进行一次取模操作的代价显著高于简单判断和加减，这样的写法有助于提升效率 (有时候这种提升是惊人的！)。&lt;/li>
&lt;/ul>
&lt;p>我们承认取模这件事情在工程开发中可能并不常见，但这样的程序设计却体现了软件工程的通用思想：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>将需要频繁使用的常量定义成宏/constexpr&lt;/strong>。&lt;/li>
&lt;li>&lt;strong>将容易出错的功能单独封装成函数，之后调用接口解决问题&lt;/strong>。&lt;/li>
&lt;/ul>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>程序设计优化是一件&lt;b>“绝知此事要躬行”&lt;/b>的事情。阅读一遍这篇文章大抵不会对你的思想产生重大的影响：你也许会认为这些是多此一举，或者你对自己写代码时的仔细程度非常自信。你只有经历了现实的捶打，经历了熬夜通宵的折磨，才会深刻地意识到人类永远无法克服基因决定的共同弱点，才会理解这些文字背后是前人的智慧和血的教训。&lt;/p>
&lt;p>笔者在高中时曾参加一项极其重要的编程比赛。他针对一道难题推导出了一个极其复杂的数学公式并编码实现了它，但在比赛结束前的 5 分钟他发现自己的程序处处漏了取模——他永远不会忘记在手指被汗水浸湿以至于键盘打滑的情况下狂按 ctrl+F, ctrl+C, ctrl+V 是怎样的紧张和绝望，也不会忘记最终也没有把取模问题解决完，因为这样一个小细节与自己想要的结果失之交臂的痛苦。从那之后，他再也没有犯过低级的漏取模错误。&lt;/p>
&lt;p>我不期望你看完这段话就真的能听进去 (因为这也是人类刻在基因里的弱点之一)，但我衷心希望你为之付出的代价能比我小一些。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>矩阵快速幂</title><link>https://chenhn02.github.io/courses/problemsolving/coding/matmul/</link><pubDate>Wed, 22 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/coding/matmul/</guid><description>&lt;p>按照线性代数中的矩阵乘法规则，常见的矩阵乘法写法如下：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int i = 1; i &amp;lt;= n; i++)
for (int j = 1; j &amp;lt;= n; j++)
for (int k = 1; k &amp;lt;= n; k++)
c[i][j] += a[i][k] * b[k][j]
&lt;/code>&lt;/pre>
&lt;p>但是我们强烈建议你调整循环的顺序，按照如下方式书写矩阵乘法：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int i = 1; i &amp;lt;= n; i++)
for (int k = 1; k &amp;lt;= n; k++)
for (int j = 1; j &amp;lt;= n; j++)
c[i][j] += a[i][k] * b[k][j]
&lt;/code>&lt;/pre>
&lt;p>虽然这样有点“反直觉”，但在稍大规模的数据下运行时，你会发现这种写法的效率比前者高很多。理解效率提高的原因需要对计算机体系结构和 C/C++ 语言中数组在内存中的存储有一定的了解，这里不作赘述。你可以暂且将其背下来 😂&lt;/p>
&lt;p>为了更优雅地实现矩阵快速幂，我们还强烈建议你学习 C++ Class 相关的内容并了解操作符重载。这样你可以自己定义矩阵类并为矩阵类书写乘法规则，从而直接复用“快速幂”一讲中的代码计算矩阵快速幂。下面给出一个参考实现：&lt;/p>
&lt;pre>&lt;code class="language-c++">class Matrix
{
int b[10][10];
Matrix () { memset(b, 0, sizeof(b)); }
void init_I() { for (int i = 1; i &amp;lt;= n; i++) b[i][i] = 1; }
Matrix operator * (Matrix other)
{
Matrix res;
for (int i = 1; i &amp;lt;= n; i++)
for (int k = 1; k &amp;lt;= n; k++)
for (int j = 1; j &amp;lt;= n; j++)
res.b[i][j] += b[i][k] * other.b[k][j];
return res;
}
};
Matrix quick_pow(Matrix x, int y)
{
Matrix res; res.init_I();
while (y)
{
if (y &amp;amp; 1) res = res * x;
x = x * x; y &amp;gt;&amp;gt;= 1;
}
return res;
}
&lt;/code>&lt;/pre></description></item></channel></rss>