<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Haonan Chen's Homepage</title><link>https://chenhn02.github.io/</link><atom:link href="https://chenhn02.github.io/index.xml" rel="self" type="application/rss+xml"/><description>Haonan Chen's Homepage</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Sat, 01 Jun 2030 13:00:00 +0000</lastBuildDate><image><url>img/map[gravatar:%!s(bool=false) shape:circle]</url><title>Haonan Chen's Homepage</title><link>https://chenhn02.github.io/</link></image><item><title>Example Talk</title><link>https://chenhn02.github.io/talk/example/</link><pubDate>Sat, 01 Jun 2030 13:00:00 +0000</pubDate><guid>https://chenhn02.github.io/talk/example/</guid><description>&lt;div class="alert alert-note">
&lt;div>
Click on the &lt;strong>Slides&lt;/strong> button above to view the built-in slides feature.
&lt;/div>
&lt;/div>
&lt;p>Slides can be added in a few ways:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Create&lt;/strong> slides using Academic&amp;rsquo;s
&lt;a href="https://sourcethemes.com/academic/docs/managing-content/#create-slides" target="_blank" rel="noopener">&lt;em>Slides&lt;/em>&lt;/a> feature and link using &lt;code>slides&lt;/code> parameter in the front matter of the talk file&lt;/li>
&lt;li>&lt;strong>Upload&lt;/strong> an existing slide deck to &lt;code>static/&lt;/code> and link using &lt;code>url_slides&lt;/code> parameter in the front matter of the talk file&lt;/li>
&lt;li>&lt;strong>Embed&lt;/strong> your slides (e.g. Google Slides) or presentation video on this page using
&lt;a href="https://sourcethemes.com/academic/docs/writing-markdown-latex/" target="_blank" rel="noopener">shortcodes&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Further talk details can easily be added to this page using &lt;em>Markdown&lt;/em> and $\rm \LaTeX$ math code.&lt;/p></description></item><item><title>ObjectWorld: Scaling Object-Centric World Models with a Large-Scale 3D Full-Object Flow Dataset</title><link>https://chenhn02.github.io/publication/objectworld/</link><pubDate>Wed, 14 Oct 2026 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/objectworld/</guid><description/></item><item><title>Goal-VLA: Image-Generative VLMs as Object-Centric World Models Enable Zero-shot Robot Manipulation</title><link>https://chenhn02.github.io/publication/goal-vla/</link><pubDate>Mon, 02 Feb 2026 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/goal-vla/</guid><description/></item><item><title>AdaptPNP: Integrating Prehensile and Non-Prehensile Skills for Adaptive Robotic Manipulation</title><link>https://chenhn02.github.io/publication/adaptpnp/</link><pubDate>Sun, 01 Feb 2026 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/adaptpnp/</guid><description/></item><item><title>Manual2Skill++: Connector-Aware General Robotic Assembly from Instruction Manuals via Vision-Language Models</title><link>https://chenhn02.github.io/publication/manual2skill++/</link><pubDate>Sun, 01 Feb 2026 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/manual2skill++/</guid><description/></item><item><title>MetaFold: Language-Guided Multi-Category Garment Folding Framework via Trajectory Generation and Foundation Model</title><link>https://chenhn02.github.io/publication/metafold/</link><pubDate>Sun, 19 Oct 2025 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/metafold/</guid><description/></item><item><title>Manual2Skill: Learning to Read Manuals and Acquire Robotic Skills for Furniture Assembly Using Vision-Language Models</title><link>https://chenhn02.github.io/publication/manual2skill/</link><pubDate>Sat, 21 Jun 2025 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/manual2skill/</guid><description/></item><item><title>问题求解II-Final 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-final/</link><pubDate>Fri, 28 Jun 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-final/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 卓尔不群&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n\times m$ 的地图，每个格子上有一个数。问从 $(1, 1)$ 走到 $(n, m)$ 路径上数的和最大是多少，只能向右或向下走。&lt;/li>
&lt;li>$n, m\leq 1000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>一道比较基础的动态规划问题。令 $dp(i, j)$ 表示从 $(1, 1)$ 走到 $(i, j)$ 的最大值，状态转移是容易的：&lt;/p>
&lt;p>$$
dp(i, j) = \max \left\{dp(i-1, j), dp(i, j-1)\right\} + a_{ij}
$$&lt;/p>
&lt;p>$dp(n, m)$ 即为答案。总时间复杂度为 $O(nm)$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 双重麻烦&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n\times m$ 的地图，每个格子上有一个数。小 A 从 $(1, 1)$ 走到 $(n, m)$，只能向右或向下走；小 B 从 $(1, m)$ 走到 $(n, 1)$，只能向左或向下走。求两人路径上数的和的最大值(两人重复经过的格子计算答案时只算一次)。&lt;/li>
&lt;li>$n, m\leq 1000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>观察两人的行进方向容易发现：&lt;strong>小 A 和 小 B 路径的重合区域必然在同一行或同一列中&lt;/strong>。以下分析只叙述重合区域在同一行的情况，同一列的情况同理。&lt;/p>
&lt;p>令 $LU/LD/RU/RD_{ij}$ 分别表示单人从左上/左下/右上/右下角出发到 $(i, j)$ 的最大值，这部分可以通过和上一题几乎一样的动态规划方法求出。在此基础上，记重合部分为 $(x, y_1)$ 到 $(x, y_2)$，该行的前缀和数组为 $sum$，则两人路径权值和为&lt;/p>
&lt;p>$$
\begin{align}
S &amp;amp;= LU_{x, y_1} + LD_{x, y_1} + RU_{x, y_2} + RD_{x, y_2} + sum_{y_2} - sum_{y_1 - 1}\\
&amp;amp;= (LU_{x, y_1} + LD_{x, y_1} - sum_{y_1 - 1}) + (RU_{x, y_2} + RD_{x, y_2} + sum_{y_2}) \\
&amp;amp;\overset{def}{=} f(x, y_1) + g(x, y_2)
\end{align}
$$&lt;/p>
&lt;p>严谨来说上面的表达式不太正确——如果重合区域恰好从 $(x, y_1)$ 开始，那么我们要保证小 A 和小 B 不同时从 $(x, y_1 - 1)$ 走过来，即 $f(x, y_1)$ 实际上应该为&lt;/p>
&lt;p>$$
f(x, y_1) = \max\left\{ LU_{x - 1, y_1 - 1} + LD_{x, y_1}, LU_{x, y_1} + LD_{x + 1, y_1}\right\}
$$&lt;/p>
&lt;p>$g(x, y_2)$ 也是同理。但上式的重点是展示出重合路径的左右端点是互不影响的，即右端点的变化不会影响最优左端点的决策。因此我们可以对于每一行枚举重合路径的右端点，并同步更新前缀的左端点 $f$ 最大值用来更新答案。总时间复杂度为 $O(nm)$。&lt;/p>
&lt;p>本题细节较多，对代码能力的要求较高。除了上文提到的 $f$ 和 $g$ 函数的细节处理，还要对重合路径只有一个点的情况进行特殊判断。很多同学在考场上写出的正确代码将近 200 行，这里强烈建议大家看一看我们提供的标准程序，从中学习一些复用代码片段、减少无谓调试的技巧。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem C: 智能数据结构&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>实现一个容器并接受 $n$ 次操作，每次操作要么向容器中加入一个数，要么输出容器中第 $I$ 小的数。变量 $I$ 从 1 开始计数，每次询问操作之后值加一。&lt;/li>
&lt;li>$n\leq 2\times 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题的思路和之前作业中的“动态中位数”十分类似。我们可以维护一个小根堆和一个大根堆，大根堆维护前 $I$ 小的数，小根堆维护剩下的数。加入新数字时根据大小关系加入到正确的堆中，并调整两个堆的规模使得大根堆中始终只有 $I$ 个元素；查询时输出大根堆中的最大值即可。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem D: 积木塔&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个 $n$ 个数的数组 $a_1, \cdots, a_n$，每次操作可以挑选一个区间 $[l, r]$，并将该区间内所有的数减一，反复操作直到所有的数变为 0。问最优操作下最短区间的长度最长是多少。&lt;/li>
&lt;li>$n\leq 5\times 10^6$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题改编自洛谷的
&lt;a href="https://www.luogu.com.cn/problem/P4447" target="_blank" rel="noopener">这道题&lt;/a>。本题考场版本的数据比较简单，所有数的大小均不超过100，因此一些复杂度和数的大小相关的做法甚至常数较小的 $O(n\log n)$ 做法也顺利通过了。这里我们介绍一个标准的基于贪心的 $O(n)$ 解法。&lt;/p>
&lt;p>假设当前我们要选择一个区间 $[1, R]$，考虑如何选择 $R$ 对答案最有利。我们让一个变量 $r$ 从第一列开始向右扫描，注意到以下两个性质：&lt;/p>
&lt;ul>
&lt;li>如果 $a_r\leq a_{r+1}$，那么 $r$ 应当继续向后移。这是因为当前列上的 $a_r$ 个区间每个都能和右边“接上头”，那么在这里断开就没有任何收益。&lt;/li>
&lt;li>如果 $a_r&amp;gt;a_{r+1}$，那么 $r$ 就应当到此为止不再右移。这是因为下一列的数比较小，当前列的 $a_r$ 个区间必然有一些要在这里结束，那么为了让最短的区间尽可能长，我们应当挑选当前最长的区间结束。我们考虑的 $[1, R]$ 这个区间显然是当前最长的 (第一列的数用完后，有一些其他区间会从 $2, 3, 4\cdots$ 开始)，所以应该就此结束。&lt;/li>
&lt;/ul>
&lt;p>根据这两条观察，最优的 $R$ 应当满足：&lt;/p>
&lt;p>$$
(a_R &amp;gt; a_{R+1}) \wedge(\forall 1\leq r&amp;lt;R. a_r\leq a_{r+1})
$$&lt;/p>
&lt;p>根据这个条件，我们其实已经有了一个正确的做法：我们可以不断地从当前第一个非0的列 $L$ 开始，根据条件寻找最优的 $R$ 并操作 $[L, R]$ 这个区间，重复该过程直到所有数全部为0。接下来的问题是如何快速地寻找 $R$ 以及如何应对过大的数字。&lt;/p>
&lt;p>关于如何寻找 $R$，我们可以发现其实并不需要每次从 $L$ 开始从头开始检查，这是因为 $R$ 的选择存在单调性：每次 $[L, R]$ 区间内的数同时减一，所以之前满足 $a_r\leq a_{r+1}$ 的那些位置被操作之后仍然满足该性质。因此我们每次只需从上一次的 $R$ 开始往右检查即可。&lt;/p>
&lt;p>关于如何应对过大的数，我们会发现当数很大的时候，每次操作一个区间效率其实很低，因为这会导致很多次选出相同的区间。会导致选择区间发生变化的事件只有两种：&lt;/p>
&lt;ul>
&lt;li>当前的 $a_L$ 被减到了0。此时我们需要寻找新的第一个非零列。&lt;/li>
&lt;li>当前的 $R$ 不再满足 $a_R&amp;gt;a_{R+1}$，换句话说 $a_R=a_{R+1}$。此时我们要向后寻找新的 $R$。&lt;/li>
&lt;/ul>
&lt;p>因此，每次我们固定好当前的 $L$ 和 $R$ 后，可以判断一下这两个事件哪个先发生，并将发生之前的多个重复区间统一处理，这样做复杂度就能保证是线性的。这是因为每一批操作结束后，要么有一个数变为0，要么有两个数变得一样，可想而知 $O(n)$ 次操作后所有数都会变为 0。&lt;/p>
&lt;p>本题的标程代码非常简短，但除了上文已经提及的实现细节外，然有一些技巧需要大家仔细思考，总体而言是一道思维难度较大，程序也不是非常好写的题目。&lt;/p></description></item><item><title>问题求解II-HW11 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-11/</link><pubDate>Fri, 28 Jun 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-11/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 字符串哈希&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个字符串构成的字典和 $m$ 个询问串，问多少个询问串在字典里出现过。&lt;/li>
&lt;li>$n, m \leq 10000$，所有字符串长度不超过 16。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-10">这份题解&lt;/a>。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 两数之和&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个数和一个目标和 $target$，问是否存在两个数和为 $target$。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>对于数组中的每个数 $x$，我们只需判断数组中是否存在 $target-x$ 即可。这一步可以通过哈希实现，使用 C++ STL 容器可以写得更加轻巧。一个需要注意的细节是对重复数的判断，若数组中存在唯一的 $x$ 满足 $2x=target$，根据题目要求 $x$ 是不能被重复使用算作答案的。&lt;/p></description></item><item><title>问题求解II-HW13 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-13/</link><pubDate>Fri, 28 Jun 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-13/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 高楼抛鸡蛋&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有一个 $n$ 层的高楼。存在一个未知的分界楼层 $X$，在 $1&amp;hellip;X$ 层扔鸡蛋落地不会碎；在 $X+1$ 层以及更高的楼层扔鸡蛋落地会碎。问用 $k$ 个鸡蛋最少扔几次可以确定 $X$。&lt;/li>
&lt;li>$n\leq 10^4, k\leq 100$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-a">这份题解&lt;/a>。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 背包问题&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有一个容积为 $v$ 的背包和 $n$ 个物品，第 $i$ 个物品的体积是 $v_i$，价值是 $w_i$。求用背包最多能装下多少价值的物品。&lt;/li>
&lt;li>$n\leq 500, v\leq 10^9, \sum w_i\leq 10^6$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-c">这份题解&lt;/a>。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem C: Cache调度&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有两块 Cache 和 $n$ 个程序，每个程序有一个类别 (共有 $k$ 种类别)，在同一块 Cache 上连续执行相同种类的程序第二次只需 $hot_i$ 的时间，否则需要 $cold_i$ 的时间。问串行执行所有程序所需的最小时间。&lt;/li>
&lt;li>$n, k\leq 5000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-6-b">这份题解&lt;/a>。&lt;/p></description></item><item><title>问题求解II-HW14 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-14/</link><pubDate>Fri, 28 Jun 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-14/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 最长公共子序列&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定两个字符串 $s, t$，求两者的最长公共子序列的长度。&lt;/li>
&lt;li>$1\leq |s|\leq 10^6, 1\leq |t|\leq 10^3$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-b">这份题解&lt;/a>。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 写作文&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定长度为 $n$ 的数列，现可以挑选 $k$ 个数加 $x$，剩下的数减 $x$。问在最优操作下，数列的最长连续子序列和是多少。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>考虑对于每一个右端点寻找最优的(即能使连续子序列之和最大的)左端点。尝试写出区间 $(l, r]$ 最优修改后的区间和表达式：&lt;/p>
&lt;p>$$
sum(l, r) =
\begin{cases}
s_r - s_l + x(r - l)= (s_r+xr) - (s_l + xl)&amp;amp;, r - l \leq k \\
s_r - s_l + xk - x(r - l - k) = (s_r - xr) - (s_l + xl) + 2xk &amp;amp;, r - l &amp;gt; k
\end{cases}
$$&lt;/p>
&lt;p>其中 $s_i$ 表示前 $i$ 个数原始的和。容易发现，左端点在两个式子中相关的项只有 $s_l + xl$，即左端点的选择和右端点的位置无关。因此对于每一个右端点 $r$，我们只需在 $[1, r-k]$ 和 $(r-k, r]$ 两个区间中分别挑选 $s_l + xl$ 值最小的端点作为备选左端点更新答案即可。&lt;/p>
&lt;p>$[1, r-k]$ 这个区间处理起来相对容易，可以在枚举右端点的同时维护前缀最小值；$(r-k, r]$ 这个区间处理起来稍微麻烦一些。使用堆等数据结构是一种方法，不过利用单调性可以将这个问题做的更好。这个单调性指的是：从左向右枚举右端点时，如果 $l_1&amp;lt;l_2$ 且 $s_{l_1}+xl_1&amp;gt;s_{l_2}+xl_2$，那么 $l_2$ 一定优于 $l_1$。这是因为 $l_2$ 不仅好而且位置更右，可以服务更多的后续右端点。凭借这个性质，我们可以用一个双端队列维护 $(r-k, r]$ 区间内值递增的一系列位置，每次从队头取最优左端点，在队尾添加新的端点并更新队列。总时间复杂度为 $O(n)$。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>单调队列/单调栈&lt;/strong>&lt;/p>
&lt;p>文末提到的根据单调性使用双端队列维护窗口区间内有意义的一系列值的方法叫做单调队列优化。感兴趣的同学可以阅读
&lt;a href="https://oi-wiki.org/ds/monotonous-queue/" target="_blank" rel="noopener">这篇文章&lt;/a> 和
&lt;a href="https://oi-wiki.org/dp/opt/monotonous-queue-stack/" target="_blank" rel="noopener">这篇文章&lt;/a> 了解更多。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>问题求解II-HW10 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-10/</link><pubDate>Thu, 27 Jun 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-10/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 堆&lt;/strong>&lt;/p>
&lt;p>给定框架代码，补全堆实现的 &lt;code>heapify&lt;/code>, &lt;code>top&lt;/code>, &lt;code>pop&lt;/code>, &lt;code>make_heap&lt;/code> 等函数。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>模板题。在代码实现上的技巧是：使用数组 (1-base) 存储完全二叉树时，下标 $x$ 元素的父亲节点下标是 $\lfloor \frac{x}{2}\rfloor$，左孩子下标是 $2x$，右孩子下标是 $2x+1$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 动态中位数&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个数，对于每个 $1\leq i\leq n$ 输出前 $i$ 个数的中位数。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>考虑使用一个大根堆和一个小根堆来维护动态中位数。我们将 $n$ 个数依次插入，每次插入时根据它和小根堆最大数的大小关系决定一下它应该去哪个队。插入完毕后再调整一下两个堆的大小，保证数的个数差始终在1以内。如此维护，每次只需要根据小根堆最大数和大根堆最小数计算中位数即可。总时间复杂度 $O(n\log n)$。&lt;/p></description></item><item><title>问题求解II-HW09 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-9/</link><pubDate>Wed, 05 Jun 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-9/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 恢复多叉树&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一棵 $n$ 个节点的有根多叉树的前序和后序遍历，求其每一层的最右侧节点的编号。&lt;/li>
&lt;li>$n\leq 50000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>众所周知，对于二叉树来说知道前序遍历和后序遍历并不能确定唯一的树形态，这是因为有的只有一个子树的节点我们无法确定子树挂在左边还是右边。但事实上，如果只要求确定每一个节点所有孩子的顺序，前后序遍历是足够的。考虑 $solve(l_1, r_1, l_2, r_2)$ 表示分析出前序遍历 $[l_1, r_1]$ 和后序遍历 $[l_2, r_2]$ 对应的子树形态的算法，$solve(1, n, 1, n)$ 即为所求。我们容易发现前序遍历中的 $l_1$ 位置的元素是树根；相对应地，后序遍历中的 $r_2$ 位置的元素也是树根（同一个元素）。进一步，我们可以得出如下的结构：&lt;/p>
&lt;img src="https://chenhn02.github.io/img/problemsolving/ps-sol-23-2091.png" style="zoom:100%;" />
&lt;p>solve 的流程是：我们可以利用前序遍历中每个子树的根到后序遍历中寻找对应的根的位置，从而在后序遍历中确定子树的元素个数，以此类推将所有子树划分好，再递归地对每段子树用 solve 处理。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 新马走日&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个 $n\times m$ 的棋盘和象棋棋子马的初始位置，问经过 $k$ 步到达棋盘上任意点的方案数。&lt;/li>
&lt;li>$n,m\leq 15, k\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-1-c/">这份题解&lt;/a>。&lt;/p></description></item><item><title>TieBot: Learning to Knot a Tie from Visual Demonstration through a Real-to-Sim-to-Real Approach</title><link>https://chenhn02.github.io/publication/tiebot/</link><pubDate>Wed, 01 May 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/tiebot/</guid><description/></item><item><title>问题求解II-HW08 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-8/</link><pubDate>Mon, 22 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-8/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 棋盘&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>$R\times C$ 的棋盘上有 $n$ 个棋子，一个格子是好的当且仅当他所在行列的棋子数目恰好为 $k$，求好格子的数目。&lt;/li>
&lt;li>$1\leq n, R, C, k\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>读入所有棋子的位置后，我们很容易统计出每行、每列也多少个棋子，也很容易对每个 $i=1,\cdots, n$ 统计出有 $i$ 个棋子的行列有多少个。对于一个有 $p$ 个棋子的行，所有有 $k-p$ 个棋子的列都可以与之搭配在交叉点产生一个好位置。因此只需要枚举所有的行，按照数目进行统计即可。&lt;/p>
&lt;p>一个重要的细节是，如果某行某列的交叉点处正好有棋子，那么我们之前所说的将行列棋子数相加的统计方法就会多算一个。因此我们要对每个棋子特判一次。具体地，对于每个棋子，它所在的行列棋子数相加如果是 $k$ 个，那么实际上就是 $k-1$ 个，需要从答案中扣除；如果是 $k+1$ 个，那么实际上就是 $k$ 个，需要加到答案中。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>调试和测试&lt;/strong>&lt;/p>
&lt;p>本题有一些细节容易遗漏处理 (例如对于每个棋子所在行列的特判)，一旦没有考虑到就容易陷入“样例全过，交到 OJ 上 WA，我也一筹莫展”的窘境。将来大家走向工作岗位是要为自己写出的代码负责的。实际生产环境中没有 OJ，软件开发员也不可能完全依赖用户来给自己报 bug (不然公司早就倒闭了)。在软件工程中有一个概念叫作“软件测试”，主要关注的问题就是在没有“标准答案”的方法，如何用测试来自己给自己纠错。&lt;/p>
&lt;p>在做 OJ 题这个层面，最好用的纠错能力就是掌握良好的调试技术和采用正确的调试方法。我们在此强烈推荐大家阅读
&lt;a href="https://chenhn02.github.io/courses/problemsolving/coding/defense/">调试艺术&lt;/a> 和
&lt;a href="https://chenhn02.github.io/courses/problemsolving/coding/difftest/">差异化测试&lt;/a> 这两篇文章。对于此题而言，写出一个对却慢的标准程序是非常容易的：你甚至可以 $O(n^3)$ 地枚举每一行，每一列，并暴力数这一行列中的棋子个数。然后你可以生成大量规模在 $100$ 左右的小测试数据进行 differential testing。掌握正确的调试和测试方法可以让你写程序事半功倍。&lt;/p>
&lt;/div>
&lt;/div>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 发奖金&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>本题改编自
&lt;a href="https://www.luogu.com.cn/problem/P1080" target="_blank" rel="noopener">[NOIP2012]国王游戏&lt;/a>。&lt;/li>
&lt;li>本题只需要输出排在最后的人的两个数字之积。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>下面证明：按照每个人 $AB$ 两数之积从小到大排序的结果是最优的。&lt;/p>
&lt;p>假设存在另一个非升序的排列更优，那么一定存在一对相邻的人反序。不妨设他们手里的数为 $A_1, B_1$ 和 $A_2, B_2$，他们之前的人的 $A$ 之积为 $P$。现在这两个人的奖金的较大值为&lt;/p>
&lt;p>$$
\max\left(\frac{P}{B_1}, \frac{PA_1}{B_2}\right)
$$&lt;/p>
&lt;p>考虑将这两个人交换顺序，那么交换后两人奖金的较大值为&lt;/p>
&lt;p>$$
\max\left(\frac{P}{B_2}, \frac{PA_2}{B_1}\right)
$$&lt;/p>
&lt;p>因为 $A_1B_1&amp;gt;A_2B_2$，即 $\frac{A_1}{B_2}&amp;gt;\frac{A_2}{B_1}$，所以 $\frac{PA_1}{B_2}&amp;gt;\frac{PA_2}{B_1}$，又这里所有数都大于一，所以显然有 $\frac{PA_1}{B_2}&amp;gt;\frac{P}{B_2}$。因此交换之后这两个人的奖金较大值会变小。又因为这两个人之前和之后的人的奖金不会变化，所以交换之后的答案不会变大。一个非升序的排列可以通过不断交换相邻的非升序员工变成升序，且每次交换都不会变劣，所以升序的排列不会比该排列劣，这与假设矛盾。&lt;/p>
&lt;p>综上，升序排列是一个最优排列，该题的答案即为 $\max_{i=1}^nA_iB_i$。&lt;/p></description></item><item><title>问题求解II-HW01 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-1/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-1/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 排序&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个整数，输出排序后的结果。&lt;/li>
&lt;li>$n\leq 1000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>关于排序，我们强烈建议大家学会快速排序和归并排序两种做法。关于快速排序，其思想虽然不那么复杂，但要写出一个对于各种输入都足够高效的版本仍十分有挑战性 (尤其当输入中有大量重复数时)，建议大家利用
&lt;a href="https://www.luogu.com.cn/problem/P1177" target="_blank" rel="noopener">这道题目&lt;/a> 检测自己的快速排序写法是否合格。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 逆序对&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个 $n$ 个数的序列，求逆序对个数。&lt;/li>
&lt;li>$n\leq 2\times 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>求逆序对有非常经典的基于归并排序的做法。设 $solve(l, r)$ 为 $a_l\cdots a_r$ 中逆序对的个数，计算方法如下：令 $mid=(l+r)/2$，：&lt;/p>
&lt;ul>
&lt;li>递归计算 $solve(l, mid)$ 和 $solve(mid+1, r)$。&lt;/li>
&lt;li>考虑 $[l, mid]$ 里的数对 $[mid+1, r]$ 里的数的贡献。在两个子区间都排好序的情况下，我们只需要对左子区间的每个数计算右边有多少个数比它小即可，这可以通过 $O(n)$ 的一边扫描做到。&lt;/li>
&lt;/ul>
&lt;p>由主定理可知，该做法的时间复杂度为 $O(n\log n)$。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>CDQ分治&lt;/strong>&lt;/p>
&lt;p>CDQ分治是一类特别的分治技巧，它的框架是：在解决 $[l, r]$ 上的问题时，先递归解决 $[l, mid]$ 和 $[mid+1, r]$，再考虑左侧区间对右侧区间的影响。求逆序对背后的二维偏序是CDQ分治最典型的应用之一，如果你感兴趣，可以参考
&lt;a href="https://chenhn02.github.io/courses/problemsolving/algorithms/cdq">这篇文章&lt;/a>。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>问题求解II-HW02 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-2/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-2/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: Twelvefold Way I&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>将 $n$ 个完全相同的球放入 $m$ 个不同的篮子，问有多少种不同的放法。&lt;/li>
&lt;li>$1\leq n, m\leq 1000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>经典的组合数学问题。由于盒子可空，考虑对 $n+m$ 个球使用插板法，最后再从每个盒子里扣掉一个球，因此总方案数为 $\binom{n+m-1}{m-1}$。&lt;/p>
&lt;p>因为本题数据范围较小，所以可以利用 $\binom{n}{m}=\binom{n-1}{m-1}+\binom{n-1}{m}$ 的组合数递推公式来计算答案取模后的值。但我们仍然建议大家学习快速计算阶乘逆元的方法，以应对可能出现的更大的数据范围。你可以参考
&lt;a href="https://chenhn02.github.io/courses/problemsolving/coding/mulinv/">这篇文章&lt;/a> 学习求逆元的常见写法。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: Twelvefold Way II&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>将 $n$ 个各不相同的球放入 $m$ 个完全相同的篮子，且每个篮子里至少有一个球，问有多少种不同的放法。&lt;/li>
&lt;li>$1\leq n, m\leq 500$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>令 $s(n, m)$ 表示将 $n$ 个不同的球放入 $m$ 个相同的篮子的方案数，考虑最后一个球的放法：&lt;/p>
&lt;ul>
&lt;li>该球独占一个篮子，则剩下的 $n-1$ 个球放在 $m-1$ 个篮子里，方案数为 $s(n-1, m-1)$。&lt;/li>
&lt;li>该球不独占一个篮子，则先将剩下的 $n-1$ 个球放入 $m$ 个篮子里，再挑选一个篮子放入最后一个球，方案数为 $m\cdot s(n-1, m)$。&lt;/li>
&lt;/ul>
&lt;p>因此 $s(n, m)=s(n-1, m-1) + m\cdot s(n-1, m)$。这里的 $s(n, m)$ 称为第二类斯特林数，有兴趣的同学可以上网搜索与之相关的其他性质。&lt;/p></description></item><item><title>问题求解II-HW03 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-3/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-3/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 最大连续子数组和&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定长度为 $n$ 的数组，求和最大的一个连续子数组。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>我们直接给出如下的贪心做法，并尝试说明其正确性：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int i = 1; i &amp;lt;= n; i++) {
sum += a[i]; ans = max(ans, sum);
if (sum &amp;lt; 0) sum = 0;
}
&lt;/code>&lt;/pre>
&lt;p>我们只要说明每轮循环中，$sum$ 记录的都是以 $a_i$ 结尾的最大和子数组即可。为了说明这点，我们需要想清楚 $sum$ 求的是哪一段的和，即 if 语句丢弃的段有什么性质。&lt;/p>
&lt;p>令 $sum$ 上次被置零和本次被置零丢弃的区间为 $[l, r]$，那么我们可以知道：&lt;/p>
&lt;ul>
&lt;li>$\sum_{k=l}^r a_i &amp;lt; 0$。&lt;/li>
&lt;li>对于任意 $l\leq t&amp;lt;r$，$\sum_{k=l}^t a_i&amp;gt;0$ (因为一直没有被丢弃)。&lt;/li>
&lt;/ul>
&lt;p>由这两条，我们可以推出：对于任意 $l\leq t\leq r$，都有 $\sum_{k=t}^ra_i&amp;lt;0$，从而这一段无论如何都只会对后面产生负数的贡献，因此完全丢弃它是合理且正确的。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 芯片测试&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有 $n$ 个芯片，其中好芯片个数多于坏芯片个数。每次可以挑选两个芯片进行一轮测试，根据测试结果可以得到一定的芯片状态信息。请找出至少一个好的芯片。&lt;/li>
&lt;li>$n\leq 10^5$，交互题。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>我们采取如下的策略：随机抽取一个芯片，并拿着它和其他所有芯片各做一次测试。考虑如下两种情况：&lt;/p>
&lt;ul>
&lt;li>如果这个芯片是好的，由于初始好芯片数目大于坏芯片数目，所以剩下的芯片中好芯片数目仍不少于坏芯片数目，从而至少有一半的测试实际上是 &lt;code>(好，好)&lt;/code>，从而至少有一半的测试结果是 &lt;code>(好，好)&lt;/code>。&lt;/li>
&lt;li>如果这个芯片是坏的，那么剩下芯片中好芯片数目仍然大于坏芯片数目，从而少于一半的测试实际上是 &lt;code>(坏，坏)&lt;/code>，从而少于一半的测试结果是 &lt;code>(好，好)&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>综上，一波测试之后我们可以完全确定当前抽取的芯片是好是坏。因为好芯片多于一半，所以多抽几次总能抽到好芯片，可以在时间限制内完成任务。&lt;/p></description></item><item><title>问题求解II-HW04 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-4/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-4/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: Hanoi Tower&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>输出 $n$ 个盘子的汉诺塔的移动序列。&lt;/li>
&lt;li>$n\leq 15$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>令 $seq(n, a, b, c)$ 表示将 $n$ 个盘子从 $a$ 柱借助 $b$ 柱移到 $c$ 柱的序列，那么&lt;/p>
&lt;p>$$
seq(n, a, b, c)=seq(n-1, a, c, b) + move(n, a, c) + seq(n-1, b, a, c)
$$&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 数列&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>求 Fibonacci 数列的第 $n$ 项。&lt;/li>
&lt;li>$n\leq 10^9$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>求 Fibonacci 数列有经典的利用矩阵快速幂加速的手法，可以参考
&lt;a href="https://chenhn02.github.io/posts/recurrences">这篇文章&lt;/a>。&lt;/p></description></item><item><title>问题求解II-HW05 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-5/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-5/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 被打乱的邮件&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>求长度为 $n$ 的序列的错排数。&lt;/li>
&lt;li>$n\leq 5000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>&lt;strong>方法一：容斥&lt;/strong>&lt;/p>
&lt;p>考虑对有多少个数被“确定”地放在了正确的位置上进行容斥，最终方案数为&lt;/p>
&lt;p>$$
\begin{align}
ans &amp;amp;= \sum_{k=0}^n(-1)^k\binom{n}{k}(n-k)! \\
&amp;amp;= n!\sum_{k=0}^n\frac{(-1)^k}{k!}
\end{align}
$$&lt;/p>
&lt;p>&lt;strong>方法二：递推&lt;/strong>&lt;/p>
&lt;p>令 $f(n)$ 表示长度为 $n$ 的序列的错排数。考虑数字 $1$ 的位置，因为需要错排，所以有 $n-1$ 种放法。不妨设 $1$ 被放在了 第 $k$ 个位置，接下来考虑 $k$ 的位置：&lt;/p>
&lt;ul>
&lt;li>$k$ 被放在了第一个位置，那么剩下的 $n-2$ 个数构成错排，共有 $f(n-2)$ 种放法。&lt;/li>
&lt;li>$k$ 不被放在第一个位置，那么 $k$ 此时有了一个我们新安排的“禁忌位置”，从而这 $n-1$ 个数构成错排，共有 $f(n-1)$ 种放法。&lt;/li>
&lt;/ul>
&lt;p>因此，$f(n)=(n-1)(f(n-1)+f(n-2))$，递推即可，时间复杂度为 $O(n)$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 检测 GPU&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定三个 $n\times n$ 的矩阵 $A, B, C$，判断 $AB=C$ 是否成立。&lt;/li>
&lt;li>$n\leq 5000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>在这样的数据规模下，我们无法接受将 $AB$ 的结果算出后再判断是否正确。这里我们为大家介绍 Freivalds 算法：&lt;/p>
&lt;ul>
&lt;li>令 $\vec{r}$ 为长度为 $n$ 的随机01向量。&lt;/li>
&lt;li>计算 $C\vec{r}$ 和 $A(B\vec{r})$，比较两个结果是否相同。&lt;/li>
&lt;/ul>
&lt;p>如果 $AB=C$，那么结果必定相同；下面证明如果 $AB\neq C$，结果相同的概率小于 $\frac{1}{2}$：&lt;/p>
&lt;p>令 $D=(AB-C)$，则 $D$ 是非零矩阵，不妨设 $d_{ij}\neq 0$。令 $D\vec{r}=\vec{p}$，因为 $A(B\vec{r})=C\vec{r}$，所以 $\vec{p}=\vec{0}$，那么关注第 $i$ 行的结果，有&lt;/p>
&lt;p>$$
\sum_{k=0}^nd_{ik}r_k=d_{ij}r_k+\sum_{k\neq j}d_{ik}r_k=0
$$&lt;/p>
&lt;p>令后一项为 $y$，即 $d_{ij}r_k+y=p_i=0$，我们有&lt;/p>
&lt;p>$$
Pr(p_i=0) = Pr(p_i=0|y=0)Pr(y=0)+Pr(p_i=0|y\neq 0)Pr(y\neq 0)
$$&lt;/p>
&lt;p>又&lt;/p>
&lt;p>$$
\begin{align}
Pr(p_i=0|y=0) &amp;amp;= Pr(r_k=0)=\frac{1}{2} \\
Pr(p_i=0|y\neq 0) &amp;amp;= Pr(r_k=1\wedge d_{ij}=-y)\leq Pr(r_k=1)=\frac{1}{2}
\end{align}
$$&lt;/p>
&lt;p>所以&lt;/p>
&lt;p>$$
\begin{align}
Pr(p_i=0) &amp;amp;= Pr(p_i=0|y=0)Pr(y=0)+Pr(p_i=0|y\neq 0)Pr(y\neq 0) \\
&amp;amp;\leq \frac{1}{2}Pr(y=0)+\frac{1}{2}Pr(y\neq 0) \\
&amp;amp;= \frac{1}{2}Pr(y=0) + \frac{1}{2}(1-Pr(y=0)) \\
&amp;amp;= \frac{1}{2}
\end{align}
$$&lt;/p>
&lt;p>从而&lt;/p>
&lt;p>$$
Pr(D\vec{r}=0) = Pr\left(\bigwedge_{k=1}^n p_k=0\right)\leq Pr(p_i=0)\leq \frac{1}{2}
$$&lt;/p>
&lt;p>综上，这是一个符合单边蒙特卡洛的测试，我们只要重复多次就可以将错误的概率降到很低。&lt;/p></description></item><item><title>问题求解II-HW06 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-6/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-6/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 购物&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>令第 $i$ 个人买东西为事件 $E_i$，令恰好有 $r$ 个人买了东西为事件 $E$，已知 $P(E_1),\cdots, P(E_n)$，求 $P(E_1|E),\cdots, P(E_n|E)$。&lt;/li>
&lt;li>$1\leq r\leq n\leq 20$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>&lt;strong>方法一：枚举&lt;/strong>&lt;/p>
&lt;p>因为本题数据范围足够小，所以我们可以枚举所有人买或不买的情况，计算出 $P(E)$ 和针对每个人的 $P(E_iE)$，从而根据公式 $P(E_i|E)=P(E_iE)/P(E)$ 得到答案。&lt;/p>
&lt;p>&lt;strong>方法二：动态规划&lt;/strong>&lt;/p>
&lt;p>考虑如何高效计算 $P(E)$，令 $f(i, j)$ 表示前 $i$ 个人有 $j$ 个购买了东西的概率，那么考虑第 $i$ 个人买不买，有状态转移方程：&lt;/p>
&lt;p>$$
f(i, j) = f(i-1, j)\cdot (1-P(E_i)) + f(i-1, j-1)\cdot P(E_i)
$$&lt;/p>
&lt;p>最后 $f(n, r)$ 即为所求。对于任意一个 $P(E_iE)$，我们只要提前将第 $i$ 个人拎出去，对剩下的 $n-1$ 个人做动态规划，最后 $P(E_i)\cdot f_i(n-1, r-1)$ 即为所求。总时间复杂度 $O(n^3)$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 取数游戏&lt;/strong>&lt;/p>
&lt;p>给定一个长度为 $2n$ 的数列，保证所有数的和是奇数。两个人轮流取数，每次只能取数列的第一个或最后一个数。问双方都采取最优策略的情况下谁能获得胜利。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-2-a/">这份题解&lt;/a>。&lt;/p></description></item><item><title>问题求解II-HW07 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-7/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2023/2-7/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 数字接龙&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个数的序列，求将其首尾相接能组成的最大的数。&lt;/li>
&lt;li>$n\leq 50000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>令 $w(x)=10^{x的位数}$。首先，在最优解中，相邻的两个数 $A,B$ 一定满足 $\overline{AB}\geq \overline{BA}$。又 $\overline{AB}\geq \overline{BA}$ 意味着 $Aw(B)+B\geq Bw(A)+A$，移项化简可得 $\frac{A}{w(A)-1}\geq \frac{B}{w(B)-1}$，所以我们只需要将所有数按照 $\frac{x}{w(x)-1}$ 的值从大到小排序再拼接即可获得最优解 (该属性值相同的若干个连续数无论如何换序不会影响答案)。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 优化&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定包含 $n$ 个整数的集合 $S$，要求恰好移除 $k$ 个数，使得剩下的数中 $\min_{x,y\in S，x\neq y}x-y$ 最大。&lt;/li>
&lt;li>$1\leq k, n\leq 10^5, n-k\geq 2$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>见
&lt;a href="https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-7/">这份题解&lt;/a> 的第一题。&lt;/p></description></item><item><title>问题求解III-Final 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-final/</link><pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-final/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 探险&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一棵 $n$ 个点的树以及树上的两个点 $x, y$。一个树上的点对 $(u, v)$ 是好的当且仅当从 $u$ 到 $v$ 的路径不会先经过 $x$ 后经过 $y$ (两者不同时经过，或者先经过 $y$ 后经过 $x$)。问有多少个好的点对。&lt;/li>
&lt;li>$n\leq 3\times 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>考虑连接 $x, y$ 的树上的链，这是从 $x$ 走到 $y$ 的唯一道路，因此一条路径是坏的当且仅当它的出发点在 $x$ 的子树里且结束点在 $y$ 的子树里（这里子树是指将x-y链提起来之后下面挂着的子树）。我们只要做一遍搜索，得到 $sz_x$ 和 $sz_y$，最终的答案即为 $n(n-1)-sz_xsz_y$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 赶车&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有 $n$ 个车站，每个车站有一个换乘所需时间；有 $m$ 班列车，每一班列车会给出它经过的所有车站以及到达每个车站的时间。现在有一个乘车查询，要求给出不晚于 ddl 的最优路线（出发时间晚&amp;gt;到达时间早&amp;gt;路径字典序小）。&lt;/li>
&lt;li>数据规模小。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题的算法部分难度不大，只需使用一个稍加修改的单源最短路径算法 (e.g., Dijkstra) 即可。但本题的代码书写难度较高，需要仔细处理各种比较的优先级，字符串格式的车站名等等。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem C: 填平&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个 $n\times m$ 的棋盘，每个格子上有一个数。现要进行若干次操作，每次操作可以挑选相邻的两个方格，将上面的数加一。问至少多少次操作可以让所有方格里的数相等，如果做不到输出 $-1$。&lt;/li>
&lt;li>$n, m\leq 40$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>将棋盘黑白染色 (第一行的第一个染黑色)后，容易发现每次选择相邻的两个格子 $+1$ 的话，在黑格子和白格子上加的数的总和是一样的。下面根据 $n, m$ 的奇偶性分两种情况讨论：&lt;/p>
&lt;ul>
&lt;li>$n, m$ 均为奇数：此时黑格子比白格子多。令 $cnt_1=\lceil\frac{nm}{2}\rceil$ 为黑格子数目，$cnt_2=\lfloor\frac{nm}{2}\rfloor$ 为白格子数目，$sum_1, sum_2$ 分别为初始黑格子和白格子上的数的总和，$ans$ 为最少操作次数下所有方格等于的那个数，那么根据之前描述的性质，有
\begin{align}
&amp;amp;ans\cdot cnt_1 - sum_1 = ans\cdot cnt_2 - sum2 \\
&amp;amp;ans = \frac{sum_1-sum_2}{cnt_1-cnt_2}
\end{align}
即当 $n, m$ 均为奇数时，可能的答案有且仅有一个，因此我们只需要判断上述的 $ans$ 能否做到即可。&lt;/li>
&lt;li>$n, m$ 至少有一个为偶数时，黑格子和白格子一样多。此时的棋盘有如下性质：如果将所有数变成 $v$ 能够做到，那么将所有数变为 $v+1$ 也能做到，因为整个棋盘可以用 $1\times 2$ 的形状完整地铺一层。因此这种情况下可以用二分答案的方法去找那个从“不行”到“行”的分界线。&lt;/li>
&lt;/ul>
&lt;p>以上两种情况剩余的唯一需要解决的问题是给定一个所有数最后等于的数 $v$，判断将所有数变为 $v$ 能否做到。在黑白染色的基础上，这件事很容易通过网络流建模的方式解决：&lt;/p>
&lt;ul>
&lt;li>建立一个超级源点向所有的黑色格子连边，容量为该格子初始数值与 $v$ 的差值。&lt;/li>
&lt;li>建立一个超级汇点，所有白色格子向它连边，容量为该格子初始数值与 $v$ 的差值。&lt;/li>
&lt;li>黑色格子向棋盘上相邻的白色格子连边，容量为 $\inf$。&lt;/li>
&lt;/ul>
&lt;p>做一遍最大流检查是否满流即可。&lt;/p></description></item><item><title>问题求解III-HW11 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-11/</link><pubDate>Mon, 25 Dec 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-11/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 字符串匹配&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个字符串池，有 $q$ 查询，每次查询提供一个字符串，问该字符串是否在字符串池中。&lt;/li>
&lt;li>字符串池中字符串数目和 $q$ 同阶，$q\leq 10^5$，所有字符串长度不超过 16。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;h4 id="解法1-内置数据结构">&lt;strong>解法1: 内置数据结构&lt;/strong>&lt;/h4>
&lt;p>对于 C++ 而言，使用红黑树实现的 &lt;code>set&amp;lt;string&amp;gt;&lt;/code> 或哈希实现的 &lt;code>unordered_set&amp;lt;string&amp;gt;&lt;/code> 都可以轻松完成本题。&lt;/p>
&lt;h4 id="解法2-哈希">&lt;strong>解法2: 哈希&lt;/strong>&lt;/h4>
&lt;p>参考
&lt;a href="https://chenhn02.github.io/courses/problemsolving/algorithms/string-hash">讲义&lt;/a> 中的讲解。注意本题可能需要使用讲义最后提到的双哈希才能通过。&lt;/p>
&lt;h4 id="解法3-字典树">&lt;strong>解法3: 字典树&lt;/strong>&lt;/h4>
&lt;p>这是一道字典树的模板题，参考
&lt;a href="https://chenhn02.github.io/courses/problemsolving/algorithms/trie">讲义&lt;/a> 中的讲解。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 顶点覆盖&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一张图 $G(V, E)$，求图的一个尽可能小的顶点覆盖 (即所有边都至少有一个端点在选中的点集中)，你的答案只要不超过最优解的 2 倍即可。&lt;/li>
&lt;li>$|V|\leq 2000, |E|\leq 20000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>近似比为 2 的顶点覆盖近似算法非常简单：只需要在原图中任取一个极大匹配，然后将匹配边的端点全部选中即可。具体细节和证明见教材。&lt;/p></description></item><item><title>问题求解III-HW12 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-12/</link><pubDate>Mon, 25 Dec 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-12/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 探索遗迹 II&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一棵 $n$ 个节点的带权有根树，求树上的一个最大权独立集。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题和 3-7 的第二题“万圣节舞会”重复。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 探索遗迹 III&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一张 $n$ 个点 $m$ 条边的有向图，求每个顶点可以支配的点的个数。这里点 $i$ 支配点 $j$ 指从图的1号点出发，任意一条到 $j$ 的路径都会经过 $i$。&lt;/li>
&lt;li>$n\leq 5000, m\leq 10000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题数据范围不大，因此可以采用暴力搜索的方式通过本题——先计算从1号点出发能到达的点的数目，再计算从1号点出发不经过 $i$ 能到达的点的数目，两者的差值即为 $i$ 支配的点的数目。对每个点进行搜搜的复杂度为 $O(n+m)$，因此该做法总时间复杂度为 $O(n^2 + nm)$。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>更快的做法？&lt;/strong>&lt;/p>
&lt;p>如果你感兴趣，可以阅读这篇介绍支配树和使用 Lengauer-Tarjan 算法求解支配树的
&lt;a href="https://oi-wiki.org/graph/dominator-tree/" target="_blank" rel="noopener">文章&lt;/a>。&lt;/p>
&lt;p>支配问题在编译优化领域有着重要的应用。简单来说，在控制流图中编译器可以通过支配关系分析识别出“循环”结构，而针对循环，编译器可以采取循环不变式外提等手段进行优化。感兴趣的同学可以在编译课程中深入学习。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>Trie树</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/trie/</link><pubDate>Mon, 25 Dec 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/trie/</guid><description>&lt;p>字典树 (又称 Trie 树) 是一个可以 $O(length)$ 地插入，$O(length)$ 地查询的 &lt;code>unordered_set&amp;lt;string&amp;gt;&lt;/code>，下面的图例展示了它的基本工作原理，这个 Trie 树存储的字符串有 to, tea, ted, ten, A, i, in, inn。&lt;/p>
&lt;figure>
&lt;img src="https://chenhn02.github.io/img/problemsolving/trie.svg" style="zoom: 70%;" >
&lt;figcaption>
&lt;font color="grey">该例子中每个字符串还存储了一个对应的数值，用这种方法，Trie也可以实现 unordered_map&amp;lt;string, Any&amp;gt;&lt;/font>
&lt;/figcaption>
&lt;/figure>
&lt;p>从根节点出发向下走，每一条路径上的字母连接起来代表一个字符串，拥有相同前缀的字符串可以共享前缀节点 (这也是它能够高效查找的核心)。&lt;/p>
&lt;ul>
&lt;li>插入字符串时，只需要根据每一位字符选择对应的边向下走 (如果不存在对应符号的边则新建边和顶点)，在最后停留的节点上打一个“存在标记”，时间复杂度为 $O(length)$。&lt;/li>
&lt;li>查询字符串时，仍然根据每一位字符选择对应的边向下走，字符串存在当且仅当能完整走完且最后停留的节点上确实有“存在标记”，时间复杂度为 $O(length)$。&lt;/li>
&lt;/ul></description></item><item><title>问题求解III-HW13 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-13/</link><pubDate>Thu, 21 Dec 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-13/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 最长回文子串&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定字符串 $s$，求其最长回文子串的长度。&lt;/li>
&lt;li>$|s|\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>我们希望大家掌握
&lt;a href="https://chenhn02.github.io/courses/problemsolving/algorithms/string-hash">字符串的滚动哈希技术&lt;/a>。简单来说，字符串滚动哈希可以在线性预处理后，$O(1)$ 地回答任意子串的哈希值。在拥有这项技术的前提下，我们只需要对输入串正着和倒着分别进行滚动哈希预处理，然后枚举字符串的每个位置作为“中间点”，二分最长的可以向左右延伸的回文长度，用哈希值验证即可。总时间复杂度 $O(n\log n)$。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>更高效的回文串算法与数据结构&lt;/strong>&lt;/p>
&lt;p>求解最长回文子串存在线性做法。如果你感兴趣，可以上网搜索
&lt;a href="https://oi-wiki.org/string/manacher/" target="_blank" rel="noopener">Manacher 算法&lt;/a> 或者
&lt;a href="https://oi-wiki.org/string/pam/" target="_blank" rel="noopener">回文自动机&lt;/a>。但它们精巧程度过高且可解决的问题比较专门，所以我们仍然建议你掌握字符串滚动哈希这一“万金油”。&lt;/p>
&lt;/div>
&lt;/div>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 方块涂色&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n\times m$ 的方格纸和 $k$ 种不同颜色的画笔，现在要将所有方格选一种颜色涂色，并要求曼哈顿距离为奇数的方格不能涂相同的颜色，问一共有多少种涂色方法。&lt;/li>
&lt;li>$n, m\leq 50, k\leq n*m$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>一个重要的观察是：如果我们将初始的棋盘“黑白染色”——即将坐标和为偶数的格子归为一类，奇数的格子归为另一类，那么两个格子曼哈顿距离为奇数的充要条件就是它们属于不同的类。因此，我们只要保证这两类的格子使用的颜色集合交集为空即可。&lt;/p>
&lt;p>不妨设两类格子的数量分别为 $N_1$ 和 $N_2$ (一个是 $\lfloor\frac{nm}{2}\rfloor$，另一个是 $\lceil\frac{nm}{2}\rceil$), 然后我们枚举给第一类格子使用 $k_1$ 种颜色，第二类格子使用 $k_2$ 种颜色 (颜色集合需要满足互不相交)，有：&lt;/p>
&lt;p>$$
ans=\sum_{k_1=0}^k\sum_{k_2=0}^{k-k_1}\binom{k}{k_1}\binom{k-k_1}{k_2}S(N_1, k_1)S(N_2, k_2)k_1!k_2!
$$&lt;/p>
&lt;p>其中 $S(i, j)$ 表示将 $i$ 个可区分的小球放入 $j$ 个不可区分的桶里，不准存在空桶的方案数 (第一行式子里最后有 $k_1!k_2$ 是因为颜色实际上是可区分的)，这是经典的第二类斯特林数，有简单的动态规划状态转移方程：&lt;/p>
&lt;p>$$
S(i, j) = S(i - 1, j - 1) + S(i - 1, j) * j
$$&lt;/p>
&lt;p>该公式的含义是：考虑最后一个小球，它可以自成一桶，从而剩下的 $i-1$ 个球放入剩下的 $j-1$ 个桶中；或者它不自成一桶，那么其他 $i-1$ 个球放入 $j$ 个桶后，最后一个球选一个桶放入 (注意虽然桶本身不可区分，但前 $i-1$ 个不同的球使得这些桶变得可区分了)。&lt;/p>
&lt;p>综上，我们只要用预处理组合数和第二类斯特林数，最后枚举 $k_1, k_2$ 即可。总时间复杂度为 $O(nm + k^2)$。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>进一步优化&lt;/strong>&lt;/p>
&lt;p>令 $nm$ 和 $k$ 同阶，上述算法的时间复杂度可以被进一步优化到 $O(k\log k)$。大家学习过多项式技术后可以再来想一想本题。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>字符串哈希</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/string-hash/</link><pubDate>Thu, 21 Dec 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/string-hash/</guid><description>&lt;p>字符串哈希的本质是将一个字符串集合映射到一个数的集合，从而将比较两个字符串是否相等转化为比较他们的哈希值是否相等，将复杂度降低到 $O(1)$。&lt;/p>
&lt;p>字符串哈希的经典哈希函数是类似于进制转换的按位权相加，即对于字符串 $s=s_1\cdots s_n$，&lt;/p>
&lt;p>$$
hash(s)=\left(\sum_{i=1}^np^{n-i}s_i\right)\ \text{mod}\ M
$$&lt;/p>
&lt;p>其中 $p$ 和 $M$ 是预先选好的参数。$p$ 被称为种子，通常取大质数，$M$ 的选择有两种类型：&lt;/p>
&lt;ul>
&lt;li>一个大质数；&lt;/li>
&lt;li>$2^{64}$；该模数的好处是可以直接利用 unsigned long long 类型的自然溢出取模，速度更快写起来也更简洁。&lt;/li>
&lt;/ul>
&lt;p>在普通的哈希中我们通常用拉链法/线性探查法来解决哈希冲突，但在字符串哈希中，我们通常不处理冲突，而是选择“相信”不会遇到两个不同的字符串有相同哈希值。这份“自信”来源于巨大的值空间：当 $M$ 取得足够大时 (比如 $10^{18}$ 级别)，将远小于值空间数目的字符串映射进去，冲突的概率极小 (如果你对这个问题感兴趣，请参考本文最后绿色框内的补充内容&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>)。&lt;/p>
&lt;h4 id="滚动哈希">&lt;strong>滚动哈希&lt;/strong>&lt;/h4>
&lt;p>计算上述的哈希值是很有技巧的。在很多情形下，我们需要对一个大字符串的多个子串计算哈希值，如果我们求出 $\sum$ 里的每一项再相加，那么每个子串的计算复杂度均为 $O(length)$，这不可接受。&lt;/p>
&lt;p>滚动哈希技术可以在对字符串线性预处理后，$O(1)$ 求出任意子串的哈希值。具体做法为，令&lt;/p>
&lt;p>$$
H(i)=\begin{cases}
0&amp;amp;, i=0 \\
H(i-1)\cdot p + s_i&amp;amp;, i &amp;gt; 0
\end{cases}
$$&lt;/p>
&lt;p>那么区间 $[l, r]$ 的子串的哈希值为&lt;/p>
&lt;p>$$
hash(s[l\ldots r]) = H(r) - H(l - 1)\cdot p^{r - l + 1}
$$&lt;/p>
&lt;p>你可以仔细计算一下以体会它的正确性。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>我真的可以放心地不做冲突处理地使用字符串哈希吗？&lt;/strong>&lt;/p>
&lt;p>从理论角度，使用自然溢出的字符串单哈希是可以通过精心构造输入数据卡掉的。具体方法如下：&lt;/p>
&lt;ul>
&lt;li>采用的种子是偶数时：如果两个字符串的第 $i$ 位不同，它们在这一位的哈希值将相差 $p^i\cdot \Delta s$。那么构造两个字符串，它们的低位都相同，第一个不同的位是第 $x$ 位，那么可想而知它们哈希值的差会是 $p^x$ 的倍数。由于 $p$ 是偶数，所以当 $x\geq 64$ 时，这个差值必然是自然溢出模数的倍数，从而发生哈希冲突。&lt;/li>
&lt;li>采用的种子是奇数时：考虑按照如下方式构造 01 串，其中的 “not” 符号表示将字符串取反：
$$
\begin{align*}
S(0) &amp;amp;= 1\\
S(n) &amp;amp;= S(n - 1) + \text{not}(S(n-1))
\end{align*}
$$
令 $f(n) = hash(S(n)) - hash(\text{not}(S(n)))$，有
$$
\begin{align*}
f(0) &amp;amp;= 1\\
f(n) &amp;amp;= f(n-1)\cdot \left(p^{2^{n-1}} - 1\right)
\end{align*}
$$
从而
$$
f(n) = \prod_{i=0}^{n-1}\left(p^{2^i}-1\right)
$$
考察 $g(n)=p^{2^n}-1$ 的2因子的个数，运用平方差公式，有
$$
\begin{align*}
g(n) &amp;amp;= \left(p^{2^{n-1}}+1\right)\left(p^{2^{n-1}}-1\right) \\
&amp;amp;= \left(p^{2^{n-1}}+1\right)\cdot g(n-1)
\end{align*}
$$
因为 $p^{2^{n-1}}+1$ 是偶数，$g(0)=p-1$ 也是偶数，所以 $g(n)$ 至少含有因子 $2^{n+1}$，从而 $f(n)$ 至少含有 $\frac{1}{2}n(n+1)$ 个2因子。只要将 $n$ 取得稍大一些，$S(n)$ 和 $\text{not}(S(n))$ 的哈希差值就会是自然溢出模数的倍数，然而他们没有一位是相同的。更加精妙的是，由于 $S(n)$ 和 $\text{not}(S(n))$ 的高度对称性，从右往左哈希的结果只是取一个负号，仍然是自然溢出等价的。&lt;/li>
&lt;/ul>
&lt;p>综合以上两点，我们可以先用奇数种子的方法构造 $S(n)$ 和 $\text{not}(S(n))$，然后再在它们首尾各接上一大串相同的字符，这样不论自然溢出哈希是从左向右还是从右向左，不论种子的值是什么，这两个串都会冲突。&lt;/p>
&lt;hr>
&lt;p>以上只是从非常理论的角度分析了目前人类智慧已经可以找到不处理冲突的自然溢出哈希的反例，但事实上除非存在一个“出题人”成心想卡，自然溢出哈希的正确性和效率还是非常可观的。如果你实在不放心，可以采取以下的双哈希策略：&lt;/p>
&lt;p>$$
\begin{align*}
h_1(s) &amp;amp;= \left(\sum_{i=1}^np_1^{n-i}s_i\right)\ \text{mod}\ M_1 \\
h_2(s) &amp;amp;= \left(\sum_{i=1}^np_2^{n-i}s_i\right)\ \text{mod}\ M_2 \\
\end{align*}
$$&lt;/p>
&lt;p>然后令 $s$ 的哈希值为 $(h_1(s), h_2(s))$ 这样一个 pair，其中 $p_1,p_2$ 通常取质数，$M_1, M_2$ 通常取 $10^9$ 附近的大质数。值域 $[1, M_1]\times [1, M_2]$ 的宽广性保证了哈希冲突的概率极低，且目前不存在已知的可以针对任意模数的双哈希反例构造方法。&lt;/p>
&lt;/div>
&lt;/div>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>&lt;del>搬运&lt;/del>参考了许昊然的
&lt;a href="https://sillycross.github.io/cnblog/2012/05/18/2012-05-18/" 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>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>问题求解III-HW10 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-10/</link><pubDate>Mon, 04 Dec 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-10/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 字符串匹配&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定由 $n$ 个字符串构成的字典，有 $m$ 次查询，每次查询给定一个字符串，问是否在字典中。&lt;/li>
&lt;li>$n, m\leq 10000$，字符串长度不超过 16。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>&lt;del>如果你对 C++ STL 容器比较熟悉，那么你只需要使用&lt;/del>&lt;/p>
&lt;pre>&lt;code class="language-c++">std::unordered_set&amp;lt;std::string&amp;gt; dictionary;
&lt;/code>&lt;/pre>
&lt;p>&lt;del>即可解决此题。&lt;/del>&lt;/p>
&lt;p>我们出题的本意是让大家学习
&lt;a href="https://chenhn02.github.io/courses/problemsolving/algorithms/string-hash">字符串哈希&lt;/a> /
&lt;a href="https://chenhn02.github.io/courses/problemsolving/algorithms/trie">Trie树&lt;/a>。字符串哈希的强处主要体现在滚动哈希上，即线性预处理后可以 $O(1)$ 地求出任意子串的哈希值 (虽然本题用不上)；Trie树的强处主要体现在它的小常数，以及在其基础上衍生出的用于多模式串匹配的 AC 自动机。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 观光旅行&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个 $n$ 个点 $m$ 条边的弱连通混合图，问是否存在无向边的定向方案，使得该图为欧拉图。&lt;/li>
&lt;li>$n\leq 200, m\leq 700$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>用于解决无向图欧拉图判定的 Fleury/Hierholzer 算法并不能很好地运用到混合图/有向图场景。不过一个连通有向图 $G=(V, E)$ 是欧拉图的充要条件非常简单：&lt;/p>
&lt;p>$$
\text{Euler}(G)\Longleftrightarrow \forall u\in V, deg_{in}(u)=deg_{out}(u)
$$&lt;/p>
&lt;p>该结论的必要性比较显然：因为欧拉回路是一个闭迹，所以图中的每个点“进来了就要出去”，从而入度必须等于出度。充分性的证明也不难，你可以参考 Hierholzer 的工作方式来构造回路。&lt;/p>
&lt;p>现在考虑混合图。我们首先为所有的无向边假定一个方向，构成一个有向图。这个初始有向图不一定是欧拉图，我们把不满足判定条件的顶点筛出来，构成两个集合：&lt;/p>
&lt;p>$$
\begin{align*}
S &amp;amp;= \{u\in V(G): deg_{in}(u) &amp;lt; deg_{out}(u)\} \\
T &amp;amp;= \{u\in V(G): deg_{in}(u) &amp;gt; deg_{out}(u)\}
\end{align*}
$$&lt;/p>
&lt;p>我们希望通过调整初始的定向策略来消除这些不平衡。具体地，如果我们可以找到一条路径 $u_1\to u_2\to \cdots \to u_t$，满足 $u_1\in S, u_2\in T$，且中间的“有向”边原本都是无向的，那么我们只要将这些边的方向都取反，就可以使 $u_1$ 的出度入度差减少2，$u_t$ 的入读出度差增加2，且路径中间的节点入度出度不变。因此，我们只要找到足够数量的这样的路径，就可以将整张图调整成欧拉图。&lt;/p>
&lt;p>通过搜索寻找这样的路径不够高明，因为 (1) 我们缺乏足够的信息高效找到一条路径 (2) 图中可能有一些关键的“枢纽”边不能随便分配给任意路径，搜索可能导致我们无法找到足够数量的路径。因此我们考虑带有“反悔”机制的网络流：&lt;/p>
&lt;ul>
&lt;li>建立超级源点向 $S$ 中的所有节点连边，流量为 $\frac{1}{2}(deg_{out}(u) - deg_{in}(u))$；&lt;/li>
&lt;li>建立超级汇点，$T$ 中所有节点向超级汇点连边，流量为 $\frac{1}{2}(deg_{in}(u) - deg_{out}(u))$；&lt;/li>
&lt;li>将图中所有的无向边引入流网络，流量为 1，方向为初始假定的方向。&lt;/li>
&lt;/ul>
&lt;p>式子中带有 $1/2$ 是因为将一条边反向会带来 2 的出度入度差影响。对上述流网络跑最大流，如果可以满流则说明可以调整成欧拉图。&lt;/p></description></item><item><title>问题求解III-HW09 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-9/</link><pubDate>Sat, 18 Nov 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-9/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 电路交换&lt;/strong>&lt;/p>
&lt;p>给定一个流网络，求最大流。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>这是一道纯正的模板题，不过我们希望大家可以尽可能学会一个高效的最大流算法实现，你可以选择学习&lt;/p>
&lt;ul>
&lt;li>增广路流派: Dinic/SAP。&lt;/li>
&lt;li>预流推进流派: HLPP。&lt;/li>
&lt;/ul>
&lt;p>其中 Dinic 算法较为简单。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 探索遗迹&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>
&lt;p>给定一个 $n\times n$ 的网格，第 $i$ 行第 $j$ 列格子的权值为 $w(i, j)$。现希望选出一个集合 $S\subseteq \{1,\cdots, n\}\times \{1,\cdots, n\}$，满足&lt;/p>
&lt;p>$$
\forall (x_1, y_1), (x_2, y_2)\in S, \lnot \text{adjacent}((x_1, y_1), (x_2, y_2))
$$&lt;/p>
&lt;p>且
$$
\sum_{(x, y)\in S}w(x, y)
$$&lt;/p>
&lt;p>最大。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>$2\leq n\leq 25$。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题的数据范围容易让人想到使用状态压缩动态规划求解：令 $dp(i, Mask)$ 表示当前考虑到第 $i$ 行，上一行宝箱选择状态为 $Mask$ 的情况下的最大收益，转移考虑枚举当前行的选择即可。该思路和第8周习题“炮兵阵地”的解法类似，但本题的数据范围稍大，恰好使得该做法无法通过 &lt;del>(事实上我们是故意这样设计的)&lt;/del>。&lt;/p>
&lt;p>本题的典型解法是使用网络流，且采用了一种“最小割建图”的思路。这种思路比较精巧，如果之前从未见过，只需欣赏即可。&lt;/p>
&lt;p>我们首先转化一下问题：从方格阵中抛弃最小代价的宝物，使得剩下的宝物满足不相邻的要求。然后构建这样一个流网络：&lt;/p>
&lt;ul>
&lt;li>该网络包含一个超级源点，超级汇点，和为每个方格建立的顶点，共 $n\times n+2$ 个。&lt;/li>
&lt;li>将原方格图黑白染色，将两种颜色的格子分别连到源点和汇点，边的权值为该点的权值。更具体地，对于一个方格 $(x, y)$ 如果 $x+y$ 为奇数，则从源点向它连边，权值为 $w(x, y)$；如果 $x+y$ 为偶数，则从它向汇点连边，权值为 $w(x, y)$。在求割的时候，如果一个方格顶点与源点/汇点的边被割去了，则认为我们“抛弃”了这个宝物。&lt;/li>
&lt;li>对于方格图中每组相邻的格子，从“奇数坐标和”点向“偶数坐标和”点连一条权值为 $\inf$ 的边。&lt;/li>
&lt;/ul>
&lt;p>可以证明，这个网络的最小割就是满足要求的最小代价。这是因为该网络中从超级源点走向超级汇点的通路均满足 $S\to v_1\to v_2\to T$ 的形式，其中 $S/T$ 表示超级源点/汇点，$v_1, v_2$ 为一对相邻的方格顶点。因为 $v_1\to v_2$ 的边权值是 $\inf$，所以要破坏这条通路，我们必然要割去 $S\to v_1$ 或 $v_2\to T$ 里的至少一条，即相邻的两个方格必然会抛弃至少一个。因此任意一个割给出的都是合法方案，而最小割给出的就是最优方案。&lt;/p>
&lt;p>根据最大流最小割定理，我们只需求解该网络从 $S$ 到 $T$ 的最大流即可。&lt;/p></description></item><item><title>问题求解III-HW08 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-8/</link><pubDate>Mon, 13 Nov 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-8/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 加油&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>
&lt;p>给定 $n$ 个二元组 $(x_1, y_1)\cdots (x_n, y_n)$，另给定 $k$，求 $S\subseteq \{1,\cdots, n\}$，满足&lt;/p>
&lt;p>$$
\sum_{i\in S} x_i = k\cdot \sum_{i\in S}y_i
$$
且
$$
\sum_{i\in S} x_i + y_i
$$
最大。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>$n\leq 100, 1\leq k\leq 10, 1\leq x_i, y_i\leq 100$。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>考虑如下动态规划：令 $dp(i, j)$ 表示当前考虑到第 $i$ 个物品，当前第一种混合物与第二种混合物达成 $k$ 的比例后还富余/亏欠 $j$ 的情况下，选取所有物品的最大重量。转移和背包问题类似，考虑当前物品选或不选：&lt;/p>
&lt;p>$$
dp(i, j) = \max\{dp(i-1, j), dp(i-1, j - (x_i - k\cdot y_i)) + x_i + y_i\}
$$&lt;/p>
&lt;p>$dp(n, 0)$ 即为最终的答案。&lt;/p>
&lt;p>在实现上有几个细节需要注意，以下分析中记 $x_i, y_i$ 的最大值为 $v(v\leq 100)$：&lt;/p>
&lt;ul>
&lt;li>
&lt;p>该 dp 状态中 $j$ 的值可以是负数，但 C/C++ 代码中不方便直接用数组实现负数的下标索引。因此我们可以先预估 $j$ 的真实值域，记为 $[-l, r]$，然后用数组的 $[0, l + r]$ 来模拟这个区间。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>对 $l, r$ 的预估将直接影响算法的时间和空间复杂度。一个简单且有效的预估是：如果当前的“亏欠值”已经大于了 $v\cdot n$，那么无论怎么选也“补救”不回来了。另一方面，即使将所有物品全选，第一种混合物的总量也不会超过 $v\cdot n$，所以令 $l, r=v\cdot n$ 是合理的。&lt;/p>
&lt;p>值得注意的是，该 dp 第二维的真实值域其实大约是 $[-k\cdot vn, vn]$，但我们通过分析“最大的可补救范围”大大缩小了负数方向的范围，算得上是一种“剪枝”。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>时间复杂度上，动态规划的总状态数为 $O(n^2v)$，转移是 $O(1)$ 的，因此总时间复杂度为 $O(n^2v)$。如果没有考虑到上述的对第二维值域的剪枝，时间复杂度为 $O(n^2kv)$ 的算法也可以通过本题，但需要指出本题的较优实现可以做到复杂度与 $k$ 无关。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 炮兵阵地&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>
&lt;p>给定 $n\times m$ 的方格，坐标为 $(i, j)$ 的格子是否可以放置炮兵的状态记为 $\text{accept}(i, j)$。求集合 $S\subseteq \{1,\cdots n\}\times \{1,\cdots m\}$，满足&lt;/p>
&lt;p>$$
\begin{align}
&amp;amp;\forall (x, y)\in S, \text{accept}(x, y)=\mathbf{True} \ \wedge \\
&amp;amp;\forall (x_1, y_1), (x_2, y_2)\in S, (x_1\neq x_2\vee y_1\neq y_2) \\
&amp;amp;\qquad \Rightarrow (x_1=x_2\to |y_1-y_2|&amp;gt;2 \wedge y_1=y_2\to |x_1-x_2|&amp;gt;2)
\end{align}
$$&lt;/p>
&lt;p>且 |S| 最大。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>$n\leq 100, m\leq 10$。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>考虑状态压缩动态规划：令 $dp(i, Mask_1, Mask_2)$ 表示当前考虑到第 $i$ 行，第 $i-1$ 行炮兵的布置情况为 $Mask_1$，第 $i-2$ 行炮兵的布置情况为 $Mask_2$ 的情况下最多能布置多少个炮兵 (之所以记录两行是因为第 $i$ 行炮兵向上的覆盖范围最多与前两行有交集)。转移考虑当前行的炮兵布置情况，状态转移方程为&lt;/p>
&lt;p>$$
\begin{align}
&amp;amp;dp(i, Mask_1, Mask_2) \to dp(i + 1, Mask, Mask_1) + \text{count_one}(Mask)\\
&amp;amp;\qquad if\ \text{valid}(Mask, Mask_1, Mask_2)
\end{align}
$$&lt;/p>
&lt;p>其中 $\text{valid}$ 函数检查三行的炮兵部署是否合法。该动态规划的时间复杂度看似为 $O(2^{3m}\cdot n)$，但事实上由于同行的炮兵之间距离至少为 3，所以 $m\leq 10$ 时在本行内合法的状态数很少 (不超过 70，远小于 $2^{10}$)，因此可以通过。&lt;/p></description></item><item><title>问题求解III-HW07 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-7/</link><pubDate>Mon, 06 Nov 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-7/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 优化&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定包含 $n$ 个整数的集合 $S$，要求恰好移除 $k$ 个数，使得剩下的数中 $\min_{x,y\in S，x\neq y}x-y$ 最大。&lt;/li>
&lt;li>$1\leq k, n\leq 10^5, n-k\geq 2$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>当遇到“最大化最小值”或者“最小化最大值”的问题时，&lt;a href=https://chenhn02.github.io/courses/problemsolving/algorithms/answer-binary-search target="_blank">二分答案&lt;/a> 通常是将优化问题转化为判定问题的好思路。在这里我们检查二分答案适用的两个条件：&lt;/p>
&lt;ul>
&lt;li>答案的离散有限性：该问题的答案必然为整数，且落在 $[1, \max(S)-\min(S)]$ 之间。&lt;/li>
&lt;li>问题的单调性：如果我们找到一个方案满足答案 $d$，那么该方案也可以满足所以比 $d$ 更小的答案。&lt;/li>
&lt;/ul>
&lt;p>因此，我们可以在 $[1, \max(S)-\min(S)]$ 中二分答案 $mid$，每轮判断是否可以找到一个删除方案，使得最小的差值大于 $mid$。这一步可以通过贪心线性地判定：&lt;/p>
&lt;ul>
&lt;li>如果存在一个最小差值大于 $mid$ 且不包含 $S$ 中最小数的方案，那么我们总可以将这个方案中的最小数换成 $\min(S)$，这个新方案只会比原方案更好。因此我们总是可以将 $\min(S)$ 作为构造方案中的最小数。&lt;/li>
&lt;li>将 $S$ 中的数排序后，贪心地将数加入到方案中，用伪代码可以表示为
&lt;pre>&lt;code class="language-python">current_max, count = S[0], 1
for i in range(1, n):
if S[i] - current_max &amp;gt;= mid:
count += 1
current_max = S[i]
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>最后检查贪心选中的数的个数是否大于等于 $n-k$ 即可。&lt;/li>
&lt;/ul>
&lt;p>每轮检查的复杂度为 $O(n)$，加上外层二分答案的复杂度，总时间复杂度为 $O(n\log \max(S))$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 万圣节舞会&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一棵 $n$ 个节点的带权有根树，求树上的一个最大权独立集。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题旨在向大家展示树型动态规划基本的状态设计思想。令 $dp(u, 0/1)$ 表示考虑以 $u$ 为根的子树，$u$ 这个节点不选(0)/选(1)的情况下该子树内的最大权独立集权值，转移时考虑 $u$ 的所有孩子，确保不选中相邻的节点即可：&lt;/p>
&lt;p>$$
\begin{align}
dp(u, 1) &amp;amp;= \sum_{v\in son(u)}dp(v, 0) \\
dp(u, 0) &amp;amp;= \sum_{v\in son(u)}\max\{dp(v, 0), dp(v, 1)\}
\end{align}
$$&lt;/p>
&lt;p>最终 $\max\{dp(Root, 0), dp(Root, 1)\}$ 即为答案。&lt;/p>
&lt;p>状态的数目为 $O(n)$，所有状态转移的代价之和和树的边数的规模相同，也是 $O(n)$，因此该动态规划的总时间复杂度为 $O(n)$。&lt;/p></description></item><item><title>问题求解III-HW06 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-6/</link><pubDate>Mon, 30 Oct 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-6/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 路由规划&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个点 $m$ 条边的带权无向图，求最小生成树。&lt;/li>
&lt;li>$n\leq 5000, m\leq 2\times 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>最小生成树的模板题。常见的最小生成树算法有 Prim 和 Kruskal。感兴趣的同学可以了解 Boruvka 算法，它在一些具有特殊性质的稠密图上能发挥神奇的作用。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 找工作&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定左侧 $x$ 个点，右侧 $y$ 个点，共有 $z$ 条边的二分图，求最大匹配。&lt;/li>
&lt;li>$1\leq x, y\leq 10000, 1\leq z\leq 2\times 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>二分图最大匹配的模板题。本题由于数据规模较大，使用匈牙利算法可能无法通过。希望大家学会 Hopcroft-Karp 算法，或者通过建立超级源点/超级汇点的方式将其转换成最大流问题求解。&lt;/p></description></item><item><title>问题求解III-HW05 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-5/</link><pubDate>Mon, 23 Oct 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-5/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 最小环&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一个 $n$ 个点 $m$ 条边的无向图，求权值最小的非平凡环。&lt;/li>
&lt;li>$n\leq 200, m\leq 6000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>求图的最小环 (也称为图的围长(girth)) 有多种常见的思路，以下介绍两种方法，分别适用于稀疏图和稠密图。&lt;/p>
&lt;p>&lt;strong>基于删边+最短路的算法&lt;/strong>&lt;/p>
&lt;p>枚举图中的每条边，对于一条边 $(u, v)$，求出不经过这条边从 $u$ 到 $v$ 的最短路，这样拼上边 $(u, v)$ 就找到了经过边 $(u, v)$ 的最小环。&lt;/p>
&lt;p>该算法需要枚举每条边，暂时性地删除该边之后跑一遍 Dijkstra 算法，故时间复杂度为 $O(m^2\log n)$，适用于稀疏图。&lt;/p>
&lt;p>&lt;strong>基于Floyd的算法&lt;/strong>&lt;/p>
&lt;p>理解该算法需要对 Floyd 有比较深入的理解。上一个算法中我们对于每条边求经过它的最小环，该算法中我们对于每个顶点求&lt;strong>经过它且它的标号最大的环&lt;/strong>中权值最小的那个。形式化地，&lt;/p>
&lt;p>$$
\begin{align}
girth(G)&amp;amp;=\min_{c\subseteq G} len(c) \qquad \text{&amp;hellip;以下所有 c 均代表环}\\
&amp;amp;=\min_{k=1}^n\min_{c\subseteq G\wedge \text{c中标号最大的点为k}}len(c)\\
&amp;amp;= \min_{k=1}^n\min_{1\leq i&amp;lt;j&amp;lt;k\wedge (i, k), (k, j)\in E(G)}\min_{c\subseteq G\wedge \text{c中标号最大的点为k}\wedge i, j\in V(c)} len(c) \\
&amp;amp;= \min_{k=1}^n\min_{1\leq i&amp;lt;j&amp;lt;k\wedge (i, k), (k, j)\in E(G)}\min_{c\subseteq G\wedge \text{c中标号最大的点为k}\wedge i, j\in V(c)} w(i, k) + w(k, j) + dist(i, j, &amp;lt;k)
\end{align}
$$&lt;/p>
&lt;p>简单来说，设最大标号的顶点为 $k$，我们再枚举环上和 $k$ 相邻的两个顶点 $i$ 和 $j$，此时只要求出 &lt;strong>从 $i$ 到 $j$，只经过标号小于 $k$ 的顶点的最短路&lt;/strong>，将这条路与 $(i, k), (k, j)$ 拼接起来，就构成了一个经过 $(i, k), (k, j)$ 且编号最大顶点为 $k$ 的最小环。将所有这些候选环放在一起求最小，就是全局的最小环。&lt;/p>
&lt;p>至于加粗部分，求法就在 Floyd 的三重循环中：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int k = 1; k &amp;lt;= n; k++)
/*
* 循环不变式：
* 此时 dist[i][j] 的值为从 $i$ 到 $j$ 且中途只经过小于 $k$ 的顶点的最短路
*/
for (int i = 1; i &amp;lt;= n; i++)
for (int j = 1; j &amp;lt;= n; j++)
if (i != j &amp;amp;&amp;amp; i != k &amp;amp;&amp;amp; j != k)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
&lt;/code>&lt;/pre>
&lt;p>大家只要证明了注释中的循环不变式，便理解了 Floyd 算法执行过程的精髓，也理解了 Floyd 算法的正确性由来。该算法的时间复杂度为 $O(n^3)$，适用于稠密图。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 全源最短路&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个点 $m$ 条边的带权有向图，求两两顶点之间的最短路。&lt;/li>
&lt;li>$n\leq 3000, m\leq 6000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>多源最短路有著名的 Johnson 算法求解，其主要步骤为：&lt;/p>
&lt;ul>
&lt;li>建立一个超级起点向图中所有节点连边，使用 Bellman-Ford 算法求出超级起点到所有节点的最短路，记为 $h$。该过程同时可以判断图中是否有负圈。&lt;/li>
&lt;li>对于原图中的边 $(u, v)$，将其边权改为 $w&amp;rsquo;(u, v) = w(u, v) + h(u) - h(v)$，枚举起点使用 Dijkstra 算法求最短路。&lt;/li>
&lt;/ul>
&lt;p>该算法的正确性主要体现在：&lt;/p>
&lt;ul>
&lt;li>由于 $h$ 是最短路，所以有三角不等式：对于任意 $u, v$，有 $h(u) + w(u, v) \geq h(v)$，因此修改过后的边权 $w&amp;rsquo;(u, v)$ 必为非负数，满足 Dijkstra 算法的使用条件。&lt;/li>
&lt;li>$h$ 的存在很像物理中的“势能”，这是因为在修改过边权的图中，从 $u$ 到 $v$ 的任意一条路径 $p: u=x_1\to x_2\to \cdots \to x_n=v$ 的长度为
$$
\begin{align}
len(p) &amp;amp;= \sum_{i=1}^{n-1} w&amp;rsquo;(x_i, x_{i+1}) \\
&amp;amp;= \sum_{i=1}^{n-1} w(x_i, x_{i+1}) + h(x_i) - h(x_{i+1}) \\
&amp;amp;= \sum_{i=1}^{n-1} + w(x_i, x_{i+1}) + h(u) - h(v)
\end{align}
$$
可以看到，修改过边权的图上的路径长度相较于原图，只和起点、终点的“势能”有关，和中间经过的节点无关。这点保证了新图上的最短路一定也是原图上的最短路。&lt;/li>
&lt;/ul></description></item><item><title>问题求解III-HW04 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-4/</link><pubDate>Thu, 28 Sep 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-4/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 飘雪圣域&lt;/strong>&lt;/p>
&lt;p>给定一张 $n$ 个点 $m$ 条边的图，求图的割点。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>求图的割点有著名的时间复杂度为 $O(n+m)$ 的 Tarjan 算法，此处不做赘述。&lt;/p>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 同色三角形&lt;/strong>&lt;/p>
&lt;p>给定一张 $n$ 个点的完全图，每条边为黑色或白色，问图中有多少个同色三角形。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>&lt;del>作为中秋+国庆放假的福利，本题设置的数据范围可以允许最简单的 $O(n^3)$ 枚举算法通过&lt;/del>&lt;/p>
&lt;p>这里主要讲解非平凡的 $O(n^2)$ 做法应当如何实现；将复杂度优化到平方级需要仔细分析该问题的性质以减少枚举的次数。&lt;/p>
&lt;p>枚举同色三角形似乎很难逃出三方枚举的困境——对于每个顶点 $u$，它的任意两条同色边关联的相邻点 $v_1, v_2$ 都需要再检查边 $(v_1, v_2)$ 的颜色。因此我们换一个角度考虑：该图中三角形的总数为 $\binom{n}{3}$，我们只要转而计算图中非同色三角形的数目，就可以算出同色三角形的数目。&lt;/p>
&lt;p>另一个重要的观察是：&lt;strong>对于任意顶点 $u, v_1, v_2$，如果 $\text{color}(u, v_1)\neq \text{color}(u, v_2)$，那么无论边 $(v_1, v_2)$ 是什么颜色，$(u, v_1, v_2)$ 都一定能形成一个异色三角形。&lt;/strong> 进一步地，记 $black_u$ 表示 $u$ 的黑色邻边的数目，那么顶点带有 $u$ 的异色三角形数目为 $black_u\cdot (n-1-black_u)$。因此，同色三角形的数目为&lt;/p>
&lt;p>$$
ans=\binom{n}{3} - \frac{1}{2}\sum_{u\in V}black_u\cdot (n - 1 - black_u)
$$&lt;/p>
&lt;p>公式中的 $1/2$ 来源于一个异色三角形会被它的两个“异色邻边顶点”计算两次。&lt;/p></description></item><item><title>问题求解III-HW03 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-3/</link><pubDate>Thu, 21 Sep 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-3/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 一笔画&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>求无向图 $G=(V, E)$ 的欧拉回路。&lt;/li>
&lt;li>$|V|\leq 10^5, |E|\leq 10^6$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题数据范围较大，使用 fleury 算法无法通过，需要实现线性的 Hierholzer 算法。Hierholzer 算法的思路如下：&lt;/p>
&lt;ol>
&lt;li>如果存在奇数度数点 $u, v$，则取任意一条 $uv$ 迹，否则取图中任意一条迹。记当前的路径为 $T$。&lt;/li>
&lt;li>取 $T$ 中仍有未访问邻边的非孤立节点 $w$，寻找一条 $ww$ 迹，将这条闭迹插入到 $T$ 中。&lt;/li>
&lt;li>重复步骤 2 直到所有的边均被访问。&lt;/li>
&lt;/ol>
&lt;p>寻找合法的 $uv$ 迹和 $ww$ 迹看似困难，其实在如下几条性质的加持下非常简单：&lt;/p>
&lt;ul>
&lt;li>在一个有两个奇数度数节点的图中从一个奇点出发，以任意方法不走重复边地遍历，直到走到死路，最后一定停留在另一个奇点上。且去掉这条迹之后，图中所有点度数均为偶数。&lt;/li>
&lt;li>在一个所有节点度数均为偶数的图中从任意节点 $w$ 出发，以任意方法不走重复边地遍历，直到走到死路，最后一定回到 $w$。且去掉这条迹之后，图中所有点度数仍然均为偶数。&lt;/li>
&lt;/ul>
&lt;p>这两条性质的证明并不困难，形象化地，对于一个偶数度数点来说，有“进”则必有“出”，所以最后一定会停在奇点/出发点上。因此在上述算法中，所有找迹的部分只要“闷头向前搜”即可。&lt;/p>
&lt;p>现在的另外一个问题是：如何寻找步骤2中的合法出发点 $w$，以及找到 $ww$ 迹之后如何高效插入。Hierholzer 算法存在经典且精巧的回溯实现可以完成这两件事。我们直接给出伪代码：&lt;/p>
&lt;pre>&lt;code class="language-python">def hierholzer(u):
for (u, v) in G.edges:
delete (u, v) from G.edges
hierholzer(v)
path.append(u)
&lt;/code>&lt;/pre>
&lt;p>大家可以仔细阅读这段代码并思考两个问题：&lt;/p>
&lt;ul>
&lt;li>该函数是如何通过回溯找到当前路径上仍有出边的节点的？&lt;/li>
&lt;li>该函数在走完所有 $u$ 的出边后将 $u$ 加入路径，该顺序是如何做到将步骤 2 中的 $ww$ 迹“插入”原路径的？&lt;/li>
&lt;/ul>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 观光&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>求带权无向图 $G=(V,E)$ 中权值最小的哈密尔顿路径。&lt;/li>
&lt;li>$|V|\leq 15$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;h3 id="做法1-折半搜索">做法1: 折半搜索&lt;/h3>
&lt;p>折半搜索的主要思想是将一个大任务拆成两个规模各为一半的子任务，然后尝试在中间将两部分的结果拼接起来。假设我们可以高效地拼接，记原算法复杂度为 $f(n)$，则折半搜索可以将复杂度优化为 $2\cdot f(n/2)$。当 $f$ 是一个指数或更高阶的函数时，折半搜索通常能显著降低复杂度。&lt;/p>
&lt;p>直接阶乘枚举所有可能路径的时间复杂度为 $O(n!)$。如果使用折半搜索，我们可以计算图中所有节点数为 $n/2$ 的路径的长度。然后枚举每条“半路径”(此时另一半路径的顶点已知)和另一半路径与当前路径连接的端点，更新最短的哈密尔顿路径。时间复杂度为 $O(C(n, n/2)\cdot n)$。&lt;/p>
&lt;h3 id="做法2状态压缩动态规划">做法2：状态压缩动态规划&lt;/h3>
&lt;p>本题更正统的做法是采取状态压缩动态规划。所谓状态压缩，是指用一个 01 串表示当前已经经过的顶点的集合 (1表示已经走过)，这样一个 01 串又可以映射到一个十进制数的二进制表示，因此我们可以用 $[0, 2^n)$ 中的整数来刻画所有可能的经过状态。以下为了表示方便，我们以 $0$ 到 $n-1$ 标号节点。&lt;/p>
&lt;p>令 $dp(Mask, u)$ 表示当前已经经过的节点集合为 Mask，当前节点为 $u$ 的情况下，$Mask$ 中经过节点构成的哈密尔顿路径的最短长度。&lt;/p>
&lt;ul>
&lt;li>边界条件是容易的：对于所有的 $0\leq x\leq n-1$，有 $dp(2^x, x)=0$ (只有一个点的路径，当前长度为 0)。&lt;/li>
&lt;li>转移只需枚举路径下一个节点 $v$(需要保证 $v$ 尚未经过)，状态转移方程式为
$$
dp(Mask|2^v, v) = \min\{dp(Mask|2^v, v), dp(Mask, u) + c(u, v)\}
$$&lt;/li>
&lt;/ul>
&lt;p>最终答案为
$$
ans = \min_{0\leq x&amp;lt;n}dp(2^n-1, x)
$$
状态的数量为 $O(2^n\cdot n)$，每个状态可以向 $O(n)$ 个状态转移，因此总时间复杂度为 $O(2^n\cdot n^2)$。&lt;/p></description></item><item><title>问题求解III-HW02 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-2/</link><pubDate>Sun, 17 Sep 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-2/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 补图最短路&lt;/strong>&lt;/p>
&lt;p>给定一张不带权无向图，求其补图中1号点到其他各个点的最短路。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>本题由于数据规模较小 ($n,m\leq 500$)，所以大家可以直接求出补图并用 BFS 求出所有的最短路。因为补图的边数是 $O(n^2)$ 级的，所以该做法时间复杂度为 $O(n^2)$。&lt;/p>
&lt;p>事实上，本题存在 $O(n+m)$ 的做法：与BFS用图中的边去更新可达关系相反，我们可以直接用当前图中的边去更新补图中的不可达关系。下面直接给出算法流程：&lt;/p>
&lt;ul>
&lt;li>维护一个集合 $S$ 表示当前 1 号点不可达的顶点集合，初始 $S=V-\{1\}$。&lt;/li>
&lt;li>每次选择一个尚未访问且到1号点距离最短的顶点 $v$，令 $S&amp;rsquo;=S\cap Neighbor(v)$，则 $S-S&amp;rsquo;$ 中的顶点在原图中可以通过 $v$ 到达，更新距离；同时更新 $S=S&amp;rsquo;$。将 $v$ 标记为已访问。&lt;/li>
&lt;/ul>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 单词接龙&lt;/strong>&lt;/p>
&lt;p>给定 $\text{beginWord}$, $\text{endWord}$ 和一个单词池，求从开始词到结束词最短的接龙序列，接龙序列中相邻的单词只能有一个字母不同。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>如果将每个单词看作图上的顶点，两个单词之间连边当且仅当他们相差一个字母，那么原问题就转化为了从开始词顶点到结束词顶点的最短路径问题，可以通过 BFS 解决。&lt;/p>
&lt;p>最朴素的想法是枚举所有的单词对并判断是否可以连边。该做法的时间复杂度为 $O(n^2\cdot l)$，其中 $l$ 为单词的长度。&lt;del>虽然有很多人这样水过了&lt;/del>需要继续优化。&lt;/p>
&lt;p>朴素做法过慢的一个原因是：有太多不可能连边的单词对被枚举了——一个单词不可能有这么多的“相邻”单词。事实上，一个单词的相邻单词数最多为 $|\Sigma|\cdot l$，其中 $\Sigma$ 为字符集。因此，我们可以将单词池中的单词加入哈希表，对每个单词枚举它所有可能的相邻单词 (枚举位置+替换字母) 并在哈希表中查询是否存在，如果存在则连边。这样算法复杂度降到 $O(n\cdot |\Sigma|\cdot l)$，可以通过。&lt;/p>
&lt;p>本题的 $\Sigma$ 为小写字母集。如果 $\Sigma$ 很大上述做法仍然效率过低。事实上我们有更巧妙的建图方法：以单词 &lt;code>cat&lt;/code> 为例，我们建立虚拟节点 &lt;code>*at&lt;/code>, &lt;code>c*t&lt;/code> 和 &lt;code>ca*&lt;/code>，将 &lt;code>cat&lt;/code> 连向这三个虚拟节点。这里的 &lt;code>*&lt;/code> 可以理解为“百搭”。这样只有一个字母不一样的两个单词就可以通过共同的虚拟节点被连接起来。最后求出的最短距离只要除以2就是我们需要的答案。算法复杂度 $O(n\cdot l)$。&lt;/p></description></item><item><title>问题求解III-HW01 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-1/</link><pubDate>Sat, 09 Sep 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/3-1/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: 控制流图&lt;/strong>&lt;/p>
&lt;p>给定一段伪代码，求出其控制流图中每个节点的入度和出度。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>伪代码中我们只需关注 &lt;code>GOTO&lt;/code>, &lt;code>IF-GOTO&lt;/code>，和所有的 label 语句即可。对于 &lt;code>GOTO&lt;/code> 和 &lt;code>IF-GOTO&lt;/code> 语句，当前基本块会有一条连向 GOTO 目标的边；此外，除非当前基本块的最后一条语句是无条件跳转，每个基本块都会向它的下一个基本块连边。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: 躺平&lt;/strong>&lt;/p>
&lt;p>在一群人中给定一些两两排名先后的比较关系，问能否求出全场 rank 1。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>将每个人抽象为图的一个顶点，A 的排名比 B 高则连一条从 A 到 B 的有向边。这样原问题转化为判断是否存在唯一的入度为 0 的节点，非常简单。&lt;/p></description></item><item><title>问题求解II-Final 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-final/</link><pubDate>Sat, 24 Jun 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-final/</guid><description>&lt;div align="center">
&lt;p>&lt;embed src="https://chenhn02.github.io/files/II-Final-AB.pdf" width="820" height="1000"
type="application/pdf">&lt;/p>
&lt;/div>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem C: 实况足球&lt;/strong>&lt;/p>
&lt;p>有一个 $n$ 个点 $m$ 条边的带权无向图。$q$ 次询问，第 $i$ 次询问从点 $x_i$ 出发，每步交替移动到最远邻居和最近邻居，共移动 $k_i$ 步，求移动总距离。&lt;/p>
&lt;ul>
&lt;li>对于 $40\%$ 的分数：$k\leq 100$。&lt;/li>
&lt;li>对于另外 $40\%$ 的分数：$n\leq 100$。&lt;/li>
&lt;li>对于 $100\%$ 的分数：$n, k, q\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>&lt;strong>$40\%$ 分数的解法&lt;/strong>&lt;/p>
&lt;p>由于 $k\leq 100$，所以我们可以模拟完整的移动流程，只需为图上的每个点预处理最远的邻居和最近的邻居，然后模拟即可。假设 $n, m, q$ 同阶 (后续的复杂度分析类似)，总时间复杂度 $O(nk)$。&lt;/p>
&lt;p>&lt;strong>$80\%$ 分数的解法&lt;/strong>&lt;/p>
&lt;p>令 &lt;code>(球员编号,0/1)&lt;/code> 这个状态表示当前球在谁的脚下且下一步是短传还是长传，以所有这样的状态为点建一个新图。如果编号为 $i$ 的球员在短传时目标球员是 $j$，则在 &lt;code>(i, 0)&lt;/code> 和 &lt;code>(j, 1)&lt;/code> 之间连一条有向边，长传的情况类似。容易发现这样一张图完整地刻画了传球的情况，且它只有 $2n$ 个点，每个点有且仅有一条出边。一个形如 $(x_i, k_i)$ 的询问只需要在这张新图上从点 &lt;code>(xi, 1)&lt;/code> 出发连走 $k_i$ 步计算距离之和。&lt;/p>
&lt;p>额外 $40\%$ 的分数满足 $n\leq 100$，这意味着新图节点也很少。所以虽然 $k$ 可能很大，但根据鸽巢原理，走超过 $2n$ 步之后我们一定会进入一个之前走过的状态，也就是说我们会在一个环中兜圈，如下图所示 (虚线箭头代表省略了中间的部分节点)：&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
S1(1) --&amp;gt; S2(2) -.-&amp;gt; Si(i) --&amp;gt; S4(i+1) -.-&amp;gt; Sm(m) --&amp;gt; Si
&lt;/code>&lt;/pre>
&lt;p>因此我们只要尝试性地走若干步识别出这个环，然后用数学方法计算出在这个环上绕多少圈，最后再手动模拟剩余的不足一圈的步数即可。时间复杂度为 $O(n^2)$。&lt;/p>
&lt;p>&lt;strong>$100\%$ 分数的解法&lt;/strong>&lt;/p>
&lt;p>在“倍增”章节中我们曾说：倍增适用于“有固定的下一个状态”的场合，在树上求 LCA 时，这点体现为每个点有唯一的父亲。这里由于新图中每个点有且仅有一条出边，所以也适合用倍增处理移动问题。&lt;/p>
&lt;p>令 $to(i, j)$ 表示新图上从点 $i$ 出发，走 $2^j$ 步后到达的节点编号，$sum(i, j)$ 表示新图上从点 $i$ 出发，走 $2^j$ 步经过的路程之和，这两个都是可以高效计算的：&lt;/p>
&lt;p>$$
to(i, j) = \begin{cases}
next(i)&amp;amp;, j = 0\\
to(to(i, j-1), j-1)&amp;amp;, j &amp;gt; 0
\end{cases}
$$&lt;/p>
&lt;p>$$
sum(i, j) = \begin{cases}
w(i, next(i))&amp;amp;, j = 0\\
sum(i, j - 1) + sum(to(i, j-1), j-1)&amp;amp;, j &amp;gt; 0
\end{cases}
$$&lt;/p>
&lt;p>对于任意的 $k$，只要将其分解成若干个2的次幂相加的形式，然后累加对应的 sum 即可。时间复杂度为 $O(n\log k)$。&lt;/p>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem D: 排序代价&lt;/strong>&lt;/p>
&lt;p>对于一个数列 $s$，每次可以选定一个区间 $[l, r]$ 并花费 $r-l+1$ 的代价将区间中的数排序，将 $s$ 完全排序的最小代价记为 $cost(s)$。现给定数列 $S$，求
$$
\sum_{s是S的子串}cost(s)
$$&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>首先可以发现一个性质：对于任何一个子串 $s$，最优的排序方案应该满足选择的区间两两不相交。这是因为如果两个区间相交，我们总可以将其换成这两个区间的并集，这个区间的代价比两个区间的代价和更小且效果与两个区间相同。又因为给 $[l, r]$ 排序的代价正好等于其长度 $r-l+1$，所以在子串中，我们每能找到一个数不被覆盖，我们就能为最终的代价节省 $1$。&lt;/p>
&lt;p>综合以上分析，我们可以得出结论：要最小化子串的代价之和，我们只要&lt;strong>对于每个元素 $s_i$，计算它在多少个子串的排序过程中可以不被区间覆盖，记为 $f(i)$&lt;/strong>，最后用所有子串的总长减去计数的结果即可。&lt;/p>
&lt;p>接下来我们考虑一个元素在区间排序的过程中不被覆盖的充分必要条件是什么。考场上有大量的同学询问 &lt;code>4 3 1&lt;/code> 的排序代价为何是 3 而不是 2，这是因为虽然 3 在正确的位置，但 1 和 4 “站错边”了，想要将 1 和 4 排到正确的位置不可能通过选择不包括 3 的区间实现。结合这个样例，我们可以发现：数列 $s_1, \cdots, s_n$ 中 $s_k$ 可以在排序中不被覆盖的充要条件是
$$
\forall i\in [1, n], (i - k)(s_i - s_k) \geq 0
$$
用人话来说，就是在 $s_k$ 左边的数都小于等于它，在 $s_k$ 右边的数都大于等于它。这样我们就可以对 $[1, k-1]$ 和 $[k+1, n]$ 局部地排序。&lt;/p>
&lt;p>结合这个充要条件，我们可以得到计算 $f(i)$ 的方法：令 $l_i$ 为 $s_i$ 左侧第一个大于 $s_i$ 的数的位置 (若不存在这样的数则为 0)，$r_i$ 为 $s_i$ 右侧第一个小于 $s_i$ 的数的位置 (若不存在则为 $n+1$)，那么任意满足 $l_i&amp;lt; l\leq i, i\leq r&amp;lt; r_i$ 的区间 $[l, r]$ 都是可以使 $s_i$ 不被覆盖的区间，从而 $f(i)=(i - l_i)(r_i - i)$。&lt;/p>
&lt;p>最后的问题是如何计算 $l_i$ 和 $r_i$。以计算 $l_i$ 为例，计算的方法很多，一种可行的做法是将所有数按照从大到小的顺序加入 C++ set，每次使用 &lt;code>lower_bound()&lt;/code> 方法寻找左侧最靠右的元素位置，类似于 II-HW-3A 的做法。该算法总复杂度为 $O(n\log n)$，虽然分析了很多，但实现起来其实非常简短。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>本题其实相当有难度，这是因为人很难克服惯性思维：题目中要对各个子串的最小代价求和，所以我们很自然地想对每个子串单独求代价，最后相加。将视角转化为对每个元素计算可以不在多少个区间中被覆盖，打破了原题中对总代价的划分方式，这步转化需要相当的功力。&lt;/p>
&lt;p>这道题目的原型是
&lt;a href="https://codeforces.com/contest/1827/problem/B1" target="_blank" rel="noopener">Codeforces-1827B&lt;/a>，区别在于原题中排序一个区间的代价不是 $r-l+1$，而是 $r-l$。这一点小小的差异对题目难度的影响其实是巨大的——因为排序一个区间的代价不等于区间的长度，所以“省下”一个元素不被覆盖的收益难以定义，从而原题更加复杂。感兴趣的同学可以去挑战一下。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>【问题求解II-HW6.C】关押罪犯</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-6-c/</link><pubDate>Wed, 07 Jun 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-6-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;p>给定一张 $n$ 个点 $m$ 条边的图 $G=(V, E)$，要求将图上的顶点二染色，使得&lt;/p>
&lt;p>$$
\max_{(u, v)\in E, color(u)=color(v)} w(u, v)
$$&lt;/p>
&lt;p>最小。&lt;/p>
&lt;/div>
&lt;/div>
&lt;h3 id="基于并查集的思路">基于并查集的思路&lt;/h3>
&lt;p>这是一道经典的并查集练习题，我们出题的本意是希望大家使用并查集解决。考虑如下最自然的思路：按边权从大到小排序，然后按顺序将边一条一条插入到图中，直到图无法被二染色。你可能会思考为何不能在加边的时候顺便给节点染上色，每次判断新边连接的两个节点是否同色。该做法在连接不同的连通块时会出现问题，例如&lt;/p>
&lt;img src="https://chenhn02.github.io/img/problemsolving/ps-sol-2063.png" style="zoom: 50%;" />
&lt;p>新加入的红色边其实并没有导致问题，我们只要将下面连通块的染色方案换一下就行。但我们每次加边创建出新连通块时无法确定怎么给它染色，因此这条路行不通。我们应该转而去维护点之间的同类关系而不是强行染色，而维护同类关系正是并查集所擅长的。&lt;/p>
&lt;p>本题的难点在于：每条边描述的都是“两个点不能属于同一阵营”，如何将其转化为对同类关系的描述？这里有一个非常精妙的技巧。我们创建 $2n$ 个节点，1~n 是原本的节点，n+1~2n 这些节点，$n+i$ 是 $i$ 的“假想敌”。这样如果 $i$ 和 $j$ 之间有边，我们要做的就是在并查集中将 $i$ 和 $n+j$ 合并，$j$ 和 $n+i$ 合并。容易发现“假想敌”的设计非常好地实现了“敌人的敌人是朋友”：如果 $i$ 和 $j$，$k$ 和 $j$ 有边，那么 $i$ 和 $k$ 都会与 $n+j$ 在并查集中合并，从而 $i$ 和 $k$ 属于同一阵营。在每条边 $(i, j)$ 加入之前，我们只需在并查集中查询 $i$ 和 $j$ 的关系，然后按照上面的方法更新关系即可。假设 $n$ 和 $m$ 同阶，理论时间复杂度为 $O(n\alpha(n))$。&lt;/p>
&lt;h3 id="基于二分答案的思路">基于二分答案的思路&lt;/h3>
&lt;p>如果你仔细阅读了二分答案章节的讲义，你会发现该问题满足二分答案问题的所有“套路”，尤其是最关键的一点：这是一个最小化最大值的问题。因此我们只需二分答案 $mid$，然后关注由原图中边权大于 $mid$ 的边构成的子图。我们要保证这些边不会落到同一个颜色中，所以要判断该图是否可以二染色。你可以参考 I-Final-C，用一遍搜索完成可二染色判断。假设 $n$ 和 $m$ 同阶，则时间复杂度为 $O(n\log n)$。&lt;/p></description></item><item><title>二分答案</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/answer-binary-search/</link><pubDate>Wed, 07 Jun 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/answer-binary-search/</guid><description>&lt;p>二分答案是一种&lt;strong>充分利用答案的离散特性和问题的单调特性，用 $O(\log n)$ 倍的时间复杂度的代价将最优化问题转化为判定问题的思想&lt;/strong>。这句话有些抽象，我们通过一道例题来展示二分答案思想的运用。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>例题&lt;/strong>&lt;/p>
&lt;p>给定一个长度为 $n(1\leq n\leq 10^5)$ 的正整数数列和一个整数 $k$，要求将数列划分成连续的 $k$ 份 (形象地来说，将其切成 $k$ 段)，对每一段中的数求和，要求最大的和最小，并输出这个和。&lt;/p>
&lt;/blockquote>
&lt;p>这题困难的地方在于你很难确定第一刀切在哪里——如果切的太靠后，这一段的和本身可能就太大了；如果切的太靠前，后面的段可能包含的数过多，会导致后面的段的和太大。该题最优化的要求使得我们每一步既要瞻前也要顾后，从而难以下手。&lt;/p>
&lt;p>我们利用这个问题来解释二分答案思想的合适使用场景：&lt;/p>
&lt;ul>
&lt;li>答案的离散性：最终答案一定是一个 $[1, \sum a_i]$ 之间的整数，可选的答案是有限个。&lt;/li>
&lt;li>问题的单调性：如果 $s$ 满足题目的要求，即存在一个划分方案满足最大和小于等于 $s$，那么所有大于 $s$ 的值都满足条件。在单调性的基础上，你可以看出 $[1, \sum a_i]$ 中的整数存在一个“分界线”，小于分界线的数都给不出划分方案 (无法成为答案)，大于等于该分界线的都可以给出划分方案。我们要找的就是这个“分界线”。&lt;/li>
&lt;/ul>
&lt;p>二分答案 (以原问题为最小化问题为例，最大化问题相反) 的框架如下&lt;/p>
&lt;pre>&lt;code class="language-python">l, r, ans = 答案的下限, 答案的上限, 0
while l &amp;lt;= r:
mid = (l + r) &amp;gt;&amp;gt; 1
if check(mid): # mid 是合法的
ans = mid # 作为备选答案
r = mid - 1 # 尝试往左寻找更小的答案
else:
l = mid + 1 # “分界线”在右侧
&lt;/code>&lt;/pre>
&lt;p>在本问题中，通过二分答案，我们可以将每轮的问题转化为：是否存在一个划分方案，使得每段的和都不超过 $mid$？这个问题存在简单的贪心解法，我们尽可能让当前段容纳更多的数，直到再加入一个数就超过限制了的时候，我们划一刀开启新的一段。最终是否合法取决于能否用不超过 $k$ 段把所有的数容纳进来。&lt;/p>
&lt;p>二分答案的外层框架提供了 $O(\log \sum a_i)$ 的代价，里面每一轮检查都是 $O(n)$ 的，因此总时间复杂度为 $O(n\log \sum a_i)$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>套路&lt;/strong>&lt;/p>
&lt;p>虽然硬背套路是高考生才做的事，但我们在这里仍然给出可使用二分答案思想解决的问题通常具有的典型特征，供大家作为参考：&lt;/p>
&lt;ul>
&lt;li>答案的离散性、问题的单调性。&lt;/li>
&lt;li>问题最后求的是“最小值的最大值”或“最大值的最小值”。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div></description></item><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>【问题求解II-HW6.B】Cache调度</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-6-b/</link><pubDate>Tue, 06 Jun 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-6-b/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有两块 Cache 和 $n$ 个程序，每个程序有一个类别 (共有 $k$ 种类别)，在同一块 Cache 上连续执行相同种类的程序第二次只需 $hot_i$ 的时间，否则需要 $cold_i$ 的时间。问串行执行所有程序所需的最小时间。&lt;/li>
&lt;li>$n, k\leq 5000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题摘自
&lt;a href="https://codeforces.com/problemset/problem/1799/D1" target="_blank" rel="noopener">Codeforces 1799D1&lt;/a>。你可以点击网站右侧的 Tutorial 查看官方题解 (官方题解从最朴素的时间复杂度为 $O(nk^2)$ 的动态规划讲起，逐步优化，清晰易懂，非常建议同学们仔细阅读)。这里我们给出一个另外的解法，比官方解法更加简洁高效。&lt;/p>
&lt;p>我们首先可以发现一个贪心性质。对于某一个类型为 $t$ 的程序来说，如果它只需要花 $hot_t$ 的时间执行，那么情况一定是：在这个程序之前&lt;strong>最近&lt;/strong>的一个类型为 $t$ 的程序在某块 Cache 上 (不妨记为 Cache 0) 执行之后，它们中间的程序都在 Cache 1 上执行，然后当前程序在 Cache 0 上执行。为什么当前程序不可能“继承&amp;quot;更早的 $t$ 类型程序使用的 Cache 呢？假设这种情况发生了，如下图：&lt;/p>
&lt;img src="https://chenhn02.github.io/img/problemsolving/ps-sol-2062.png" style="zoom: 50%;" />
&lt;p>在这个例子中，当前的1类型程序“继承”了更早的同类型程序，它们中间还夹着一个1类型程序。那么我们没有道理不把中间的这个1类型程序拉到下面的 Cache 上执行——一方面，这个1类型程序的左右都不是1类型，将它挪下来甚至可能让上面这块 Cache 多命中一次 (虽然图示例子不符合这个情况，1 的左右程序类型不一样)，另一方面，把这个程序拉下来可以让它享受 $hot_1$ 的执行时间。因此这是一笔稳赚不赔的买卖。&lt;/p>
&lt;p>我们在这个性质的基础上进行动态规划。为了方便叙述，我们首先约定一些记号：&lt;/p>
&lt;ul>
&lt;li>令 $last_i$ 表示在第 $i$ 个程序之前最近的一个和 $i$ 同类型的程序的位置。该数组不难获取，具体细节留给大家思考。&lt;/li>
&lt;li>令 $sum_{l, r}$ 表示将 $[l+1, r]$ 这个区间里的程序按顺序在同一块 Cache 上执行所需的总时间，注意我们不计算第 $l$ 个程序的执行时间，将第 $l$ 个程序纳入讨论是为了确定第 $l+1$ 个程序能否享受到 Cache hit 的加速。该记号可以通过前缀和实现。&lt;/li>
&lt;/ul>
&lt;p>令 $dp(i, 0/1)$ 表示当前看到第 $i$ 个程序，第 $i$ 个程序使用的 Cache 和第 $i-1$ 个程序一样 (用第二维的 1 表示)/不一样 (0) 的情况下，最小的执行时间。令第 $i$ 个程序的类型为 $t$，分以下两种情况讨论：&lt;/p>
&lt;ul>
&lt;li>第 $i$ 个程序没有享受到 Cache hit，花了 $cold_t$ 的时间执行：这种情况下我们完全不用在意前面是哪两个程序留在了 Cache 里，因为我们没打算 Cache hit。因此
$$
dp(i, 0) = dp(i, 1) = \min\{dp(i-1, 0), dp(i-1, 1)\} + cold_t
$$&lt;/li>
&lt;li>第 $i$ 个程序享受到了 Cache hit，花了 $hot_t$ 的时间执行：根据我们之前发现的结论，它一定是继承了和它最近的相同类型程序的 Cache，然后中间的其他程序在另一块 Cache 上执行，这里又分两种情况：
&lt;ul>
&lt;li>
&lt;p>上一个同类型的程序是第 $i-1$ 个，则此时满足“和前一个程序使用了同一块 Cache”，因此更新 $dp(i, 1)$：
$$
dp(i, 1) = \min\{dp(i - 1, 0), dp(i - 1, 1)\} + hot_t
$$&lt;/p>
&lt;/li>
&lt;li>
&lt;p>上一个同类型的程序不是第 $i-1$ 个，则此时更新 $dp(i, 0)$:
$$
dp(i, 0) = dp(last_i + 1, 0) + sum_{last_i+1, i - 1} + hot_t
$$&lt;/p>
&lt;p>注意一个细节：$dp(last_i+1, 0)$ 只能从 0 状态转移来，因为我们要强制 $[last_i + 1, i - 1]$ 的程序换到另一块 Cache 上。在这种情况中，你也可以体会到为什么状态设计中有这么一个看似奇怪的“和前一个程序是否使用同一块 Cache”。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>对上述所有情况取 min 即可。你可能会有疑问：第一种情况计算的是不是不太精细，随便放也是有可能命中的？但命中情形下的最优解一定会被第二类情况覆盖到，所以第一种计算的粗糙一些问题不大。第一种情况存在的意义是保证 Cache miss 下达到最小时间的情况被覆盖到 (有点玄妙，请仔细体会)。&lt;/p>
&lt;p>最终答案为 $\min\{dp(n, 0), dp(n, 1)\}$，时间复杂度和空间复杂度均为 $O(n+k)$。&lt;/p></description></item><item><title>【问题求解II-HW5.A】高楼抛鸡蛋</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-a/</link><pubDate>Sun, 28 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-a/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有一个 $n$ 层的高楼。存在一个未知的分界楼层 $X$，在 $1&amp;hellip;X$ 层扔鸡蛋落地不会碎；在 $X+1$ 层以及更高的楼层扔鸡蛋落地会碎。问用 $k$ 个鸡蛋最少扔几次可以确定 $X$。&lt;/li>
&lt;li>$n\leq 10^4, k\leq 100$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;h3 id="引子">引子&lt;/h3>
&lt;p>这是一个十分有趣的问题。我们首先思考扔鸡蛋到底意味着什么：&lt;/p>
&lt;ul>
&lt;li>一个鸡蛋如果在 $x$ 层扔下去没碎，说明 $X\geq x$，否则 $X&amp;lt;x$。&lt;/li>
&lt;li>鸡蛋碎与不碎是有区别的：如果所有鸡蛋都碎了但我们仍没有找出 $X$，那就寄了。&lt;/li>
&lt;/ul>
&lt;p>基于这些观察，我们先进行一些简单情况的思考：&lt;/p>
&lt;ul>
&lt;li>如果我们手里只有一个鸡蛋，那么我们没有犯错空间，只能从一楼开始一层一层往上扔，次数为 $O(n)$。&lt;/li>
&lt;li>如果我们手里有两个鸡蛋，那我们的策略应该是这样的：用第一个鸡蛋把一个大致的范围框出来——例如选择在 $l_1=1, l_2, \cdots, l_m=n$ 这些点扔鸡蛋，把范围缩小到某个 $[l_i, l_{i+1})$ 后，再用第二个鸡蛋一层层试过去。这个策略很像之前提过的“分块”。如果我们选取那些 $\sqrt n$ 的倍数位置作为第一轮的节点，则抛鸡蛋次数为 $O(\sqrt n)$。&lt;/li>
&lt;li>……&lt;/li>
&lt;/ul>
&lt;h3 id="动态规划-i">动态规划 (I)&lt;/h3>
&lt;p>我们可以看到“用前面的鸡蛋的牺牲为后面的鸡蛋缩小范围”是一个核心思路。对于更多数量的鸡蛋，硬想已经很难想得清楚。考虑使用动态规划。&lt;/p>
&lt;p>一个非常直接的动态规划状态设计是：令 $dp(k, n)$ 表示手里有 $k$ 个鸡蛋，要确定 $n$ 层大楼的答案，最少需要扔几次 (这个状态设计和原问题的描述是完全一致的)。转移考虑第一次应该在哪一层扔鸡蛋。如果第一次在第 $i$ 层扔鸡蛋，有两种情况：&lt;/p>
&lt;ul>
&lt;li>鸡蛋碎了，则需要用 $k-1$ 个鸡蛋在 $1\cdots i-1$ 层确定答案。&lt;/li>
&lt;li>鸡蛋没碎，则需要用 $k$ 个鸡蛋在 $i\cdots n$ 层确定答案。&lt;/li>
&lt;/ul>
&lt;p>这两种情况会落入哪一种是我们无法预知的，但为了保证找出答案，求次数时应该对其取 max。不过第一次在哪里扔是我们可以决定的，所以我们可以遍历所有可能的第一层，对所有情况取 min (请仔细体会取min/max的逻辑)。因此状态转移方程为&lt;/p>
&lt;p>$$
dp(k, n) = \min_{1\leq i\leq n}\left(\max\{dp(k-1,i-1)+1, dp(k, n-i+1)+1\}\right)
$$&lt;/p>
&lt;p>该算法的空间复杂度为 $O(kn)$，时间复杂度为 $O(kn^2)$。对于本题来说仍需要优化。&lt;/p>
&lt;h4 id="基于状态转移方程单调性的优化">&lt;strong>基于状态转移方程单调性的优化&lt;/strong>&lt;/h4>
&lt;p>我们将 $dp(k, n)$ 看作关于 $k$ 和 $n$ 的二元函数，来观察它的单调性。容易发现它关于 $n$ 是单调增的 (大楼高度增加扔的次数肯定更多)。再次观察上述的状态转移方程，可以发现随着 $i$ 的增大，第一项 $dp(k-1, i-1)$ 一直在变大，第二项 $dp(k, n-i+1)$ 一直在变小。如果画成图的话大概是这样：&lt;/p>
&lt;img src="https://chenhn02.github.io/img/problemsolving/ps-sol-2051.png" style="zoom: 50%"/>
&lt;p>可以看到，状态转移方程中的函数 (绿色) 是&lt;strong>单峰&lt;/strong>的 (即形如二次函数)。它在红色和蓝色线相等的地方取到最小值。因此对于每个 $dp(k, n)$，我们可以通过二分查找而不是一一枚举的方式寻找取到最小值的点 (红色减蓝色的结果是单调的)，时间复杂度优化至 $O(kn\log n)$。(注意：图上的红色和蓝色线是连续的，而实际问题中 dp 数组是离散的，因此你实际需要找的是“红色和蓝色差值最小的地方”。)&lt;/p>
&lt;h4 id="基于决策单调性的优化">&lt;strong>基于决策单调性的优化&lt;/strong>&lt;/h4>
&lt;p>在这个思路的基础上还可以进一步优化。我们令 $M_{k, n}$ 表示使得 $dp(k, n)$ 取到最小值的状态转移方程中的那个 $i$。朴素方法通过枚举确定 $M_{k, n}$，第一版优化通过二分查找确定 $M_{k, n}$，这里我们利用&lt;strong>决策单调性&lt;/strong>均摊 $O(1)$ 地确定 $M_{k, n}$。&lt;/p>
&lt;p>如果把 $M_{k, n}$ 看作关于 $k$ 和 $n$ 的函数，我们容易发现它关于 $n$ 是单调递增的。&lt;/p>
&lt;ul>
&lt;li>直觉上，大楼的总层数增高了，那么第一次扔鸡蛋的位置肯定应该相对应地调高，否则如果鸡蛋没碎，上面待探索的层数就会太多。&lt;/li>
&lt;li>严谨地计算上，对于 $dp(k, n)$ 问题，$M_{k, n}$ 满足 $dp(k-1, M_{k, n}) = dp(k, n-M_{k, n}+1)$。那么对于 $dp(k, n+1)$ 问题，
$$
dp(k-1, M_{k,n}) = dp(k, n-M_{k, n}+1) \overset{dp关于n的单调性}{\leq} dp(k, (n+1) - M_{k, n} + 1)
$$
因此必然有 $M_{k, n+1}\geq M_{k, n}$。&lt;/li>
&lt;/ul>
&lt;p>$M_{k, n}$ 是每轮的最优点，也称为决策点。所以 $M_{k, n}$ 满足的单调性质称为决策单调性。基于决策单调性，对于每个 $k$，我们在计算 $M_{k, n}$ 时，不用从 1 开始枚举，而可以从 $M_{k, n-1}$ 开始枚举。因为 $dp(k, *)$ 一层中所有的 $M_{k, *}$ 的枚举合起来复杂度为 $O(n)$，所以算法的总时间复杂度降至 $O(kn)$。&lt;/p>
&lt;h3 id="动态规划-ii">动态规划 (II)&lt;/h3>
&lt;p>动态规划 (I) 的优点在于它选择从一个非常自然的状态设计出发解决问题，整个思维过程没有大的跃迁点。但缺点在于对优化能力的要求较高，如果水平不足很可能卡在 $O(kn^2)$ 的位置无法前进。这里我们介绍另一种动态规划的状态设计，它的状态和转移都有点“神之一手”的意味，但一旦想到整个问题就变得非常简单。&lt;/p>
&lt;p>令 $dp(k, m)$ 表示用 $k$ 个鸡蛋扔 $m$ 次，最多可以在多高的楼层范围内确定答案 (例如 $dp(1, m)=m$，因为一个鸡蛋只能从1楼开始一层层往上)。考虑如何转移：在这种状态设计下，我们可以精确地确定第一次该在哪里扔鸡蛋。因为鸡蛋如果碎了，我们就要用 $k-1$ 个鸡蛋在 $m-1$ 次内找出答案，而这个条件下能确定的最大层数恰好是 $dp(k-1, m-1)$。所以我们第一轮应该在第 $dp(k-1, m-1) + 1$ 层扔鸡蛋。另外，如果鸡蛋没碎，我们还可以用 $k$ 个鸡蛋扔 $m-1$ 次，从而最多可以再向上探索 $dp(k, m-1)$ 层，因此状态转移方程为&lt;/p>
&lt;p>$$
dp(k, m) = dp(k-1, m-1) + 1 + dp(k, m-1)
$$&lt;/p>
&lt;p>剩下的问题是，对于每个 $k$，$m$ 要枚举到多大？由于 $dp(k, m)$ 关于 $m$ 单调递增，所以我们只要枚举到 $dp(k, m)\geq n$ 的 $m$ 即可。一件显然的事情是 $m\leq n$，因此复杂度的一个上界是 $O(kn)$，这已经足够优秀了&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>。&lt;/p>
&lt;h3 id="基于信息论的思路">基于“信息论”的思路&lt;/h3>
&lt;p>这个解法的思路比较清奇，仅供大家欣赏。令 $f(k, m)$ 表示用 $k$ 个鸡蛋，扔 $m$ 次可以确定答案的最多楼层数，我们其实可以不借助任何基础推导，直接给出数学结果&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup>:&lt;/p>
&lt;p>$$
f(k, m) = \sum_{i=1}^k\binom{m}{i}
$$&lt;/p>
&lt;p>它的道理来自以下的分析：&lt;/p>
&lt;p>用 $k$ 个鸡蛋扔 $m$ 次这个实验的本质，是建立一个从仅包含0和1的状态字符串到最终答案的映射。这里 01 串至多 $m$ 位，表示每次扔鸡蛋的结果，0 是碎了，1 是没有碎。因为我们只有 $k$ 个鸡蛋，所以 01 串里至多只能有 $k$ 个 0。注意两个细节：&lt;/p>
&lt;ul>
&lt;li>如果从头到尾鸡蛋都没有碎过，那么我们不可能知道答案 (因为没有上界)，所以全 1 的串无效。&lt;/li>
&lt;li>如果串中有 $k$ 个 0，那么最后一个必须是 0 (因为鸡蛋碎完了就没有鸡蛋可扔了)。&lt;/li>
&lt;/ul>
&lt;p>因此合法的状态共有&lt;/p>
&lt;p>$$
\sum_{i=1}^{k-1}\binom{m}{i} + \sum_{i=k}^m\binom{i-1}{k-1}
$$&lt;/p>
&lt;p>其中前面一个求和表示包含小于 $k$ 个 0 的合法字符串的个数，后面一个求和表示恰好 $k$ 个 0 的合法字符串的个数。后面一类由于固定了一个 0 在字符串末尾，所以只有 $k-1$ 个可支配的 0 (注：我们认为组合数 $n$ 选 $m$ 如果 $n&amp;lt;m$ 则值为 0，这与广义组合数的定义相容)。通过简单的数学推导你可以发现 $\displaystyle \sum_{i=k}^m\binom{i-1}{k-1}=\binom{m}{k}$ (将第一项 $\displaystyle\binom{k-1}{k-1}$ 改写为 $\displaystyle \binom{k}{k}$，然后采用“滚雪球法”)，或者你也可以通过思维推导发现如果在这种字符串的末尾添加占位符将其长度补到 $m$，本质上就是 $\displaystyle \binom{m}{k}$。总之，合法的状态总数为&lt;/p>
&lt;p>$$
\sum_{i=1}^k\binom{m}{i}
$$&lt;/p>
&lt;p>鸡蛋落地的结果序列必须可以和楼层建立一一映射，否则一定存在无法分辨的两个楼层 (可以参考使用决策树证明基于比较的排序算法的复杂度下界的过程来理解这句话)，所以答案的上界是 $\sum_{i=1}^k\binom{m}{i}$。又因为每次鸡蛋碎与不碎会将我们引导到两个不相交的区域 ($[1, x-1]$ 和 $[x, n]$) 进行下一步操作，所以这个上界是可以做到的。因此 $f(k, m)=\sum_{i=1}^k\binom{m}{i}$。进一步地，你可以发现我们在此处定义 0 为碎的巧妙之处：将所有状态串按照字典序从小到大排序，排在第 $i$ 位的字符串恰好就是确定第 $i$ 层的扔法。&lt;/p>
&lt;p>有了这个结果，我们可以直接二分 $m$，然后计算 $f(k, m)$ 并与 $n$ 比较。时间复杂度 $O(k\log n)$。&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>LeetCode的
&lt;a href="https://leetcode.cn/problems/super-egg-drop/solution/ji-dan-diao-luo-by-leetcode-solution-2/" target="_blank" rel="noopener">官方题解&lt;/a> 不加证明地给出了 $O(k\sqrt[k]{n})$ 的复杂度。&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>事实上，第二种解法里的动态规划状态转移方程本身长的就很像二项式系数的递推式。如果你对这个转移方程进行差分等数学处理，也可以得到如下的结果。&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>【问题求解II-HW5.B】最长公共子序列</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-b/</link><pubDate>Fri, 26 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-b/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定两个字符串 $s, t$，求两者的最长公共子序列的长度。&lt;/li>
&lt;li>$1\leq |s|\leq 10^6, 1\leq |t|\leq 10^3$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>求字符串的最长公共子序列长度是一个经典的动态规划入门问题。该问题有如下非常经典的状态设计和转移“套路”：&lt;/p>
&lt;p>令 $dp(i, j)$ 表示 $s[1&amp;hellip;i]$ 和 $t[1..j]$ 这两个串的最长公共子序列长度，那么最终答案显然为 $dp(|s|, |t|)$。转移考虑 $s[i]$ 和 $t[j]$ 是否在最长公共子序列中：&lt;/p>
&lt;ul>
&lt;li>若 $s[i]$ 不在最长公共子序列中，则可以从 $dp(i-1, j)$ 转移来。&lt;/li>
&lt;li>若 $t[j]$ 不在最长公共子序列中，则可以从 $dp(i, j-1)$ 转移来。&lt;/li>
&lt;li>若 $s[i]$ 和 $t[j]$ 都在公共子序列中 (注意它们是最后一个字符，所以它们一定要能匹配上 (相同))，则可以从 $dp(i-1, j-1)+1$ 转移来。&lt;/li>
&lt;/ul>
&lt;p>对以上三种情况取最大值即可。该动态规划的时间复杂度和空间复杂度均为 $O(|s||t|)$。&lt;/p>
&lt;hr>
&lt;p>本题的特别之处在于 $s$ 很长而 $t$ 很短，且 $|s||t|$ 超出了我们能够承受的范围 (无论是时间还是空间)，因此前面提到的传统做法不太奏效。本题希望让大家明白的是：&lt;strong>对于求极值的动态规划问题，状态和值之间通常可以互相转化&lt;/strong>。一个动态规划问题通常有若干个变量 (记为 $m$ 个)，动态规划状态会固定住其中的 $m-1$ 个变量，动态规划的值则是剩下的那个变量的极值。对于大部分人来说，最自然的选择是将题目要求的那个变量作为动态规划的值，但有时为了缩减状态数，我们会选择“看起来别扭”的设计，将&lt;strong>取值空间小的那些变量作为动态规划的状态&lt;/strong>。&lt;/p>
&lt;p>以本题为例，本题的变量有三个：$s$ 的前缀长度，$t$ 的前缀长度，最长公共子序列的长度。因为本题求的是第三个，所以前面提到的传统状态设计最容易让人理解。但在本题的数据范围下，你会发现 $s$ 的前缀长度有 $10^6$ 种可能，而后两者的取值空间都是 $10^3$，因此我们来设计如下一种“看起来很奇怪的状态”：&lt;/p>
&lt;p>令 $dp(i, j)$ 表示考虑 $t[1&amp;hellip;i]$，如果想要获得长度为 $j$ 的最长公共子序列，$s$ 的前缀至少要取到哪里 (如果做不到则值为 $|s|+1$)。虽然听起来很拗口，但转移仍然可行。考虑以下情形：&lt;/p>
&lt;ul>
&lt;li>最长公共子序列不包含 $t[i]$，则可以从 $dp(i-1, j)$ 直接转移来。&lt;/li>
&lt;li>最长公共子序列包含 $t[i]$，从 $dp(i-1, j-1)$ 转移来。记 $x=dp(i-1, j-1)$，现在的状况是：$s[1&amp;hellip;x]$ 和 $t[1&amp;hellip;i-1]$ 有一个长度为 $j-1$ 的公共子串。我们要从 $x+1$ 往后继续延伸，找到第一个和 $t[i]$ 相同的字符匹配上从而达到要求。因此我们可以预处理一个数组 $nxt(i, ch)$ 表示从 $s[i]$ 开始第一个字符 $ch$ 出现在哪里 (这并不困难，留给大家作为思考)。&lt;/li>
&lt;/ul>
&lt;p>我们把取值空间小的两个状态作为动态规划的状态，把最大的那个作为值。值是不需要在执行过程中枚举的，因此我们有效优化了复杂度。该做法的时间复杂度和空间复杂度均为 $O(|t|^2)$。&lt;/p></description></item><item><title>【问题求解II-HW5.C】背包问题</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-c/</link><pubDate>Fri, 26 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-5-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有一个容积为 $v$ 的背包和 $n$ 个物品，第 $i$ 个物品的体积是 $v_i$，价值是 $w_i$。求用背包最多能装下多少价值的物品。&lt;/li>
&lt;li>$n\leq 500, v\leq 10^9, \sum w_i\leq 10^6$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>本题虽然是经典的 01 背包问题 (它被称为 01 背包是因为每个物品要么选要么不选，只有两种状态)，但仍然有一些值得注意的细节。和 2-5-B 类似地，本题也需要仔细斟酌状态的选取。01 背包的一种常见状态设计是：令 $dp(i, j)$ 表示考虑到第 $i$ 个物品，使用容积为 $j$ 的背包，最多可以获得多少价值。转移考虑第 $i$ 个物品是否放进背包。方程是容易写出的：&lt;/p>
&lt;p>$$
dp(i, j) = \max\{dp(i-1, j), dp(i-1, j-v_i) + w_i\}
$$&lt;/p>
&lt;p>时间总复杂度为 $O(nv)$。但本题中 $v$ 的取值范围很大，这样做无法通过。&lt;/p>
&lt;p>考虑将值域更小的价值作为状态，将值域大的容积作为动态规划的值，重新设计：令 $dp(i, j)$ 表示考虑到第 $i$ 个物品，想要选取出总价值为 $j$ 的物品，至少需要多少容积。最终所有容积不超过 $v$ 的状态的 $j$ 的最大值即为题目所求。转移仍然考虑第 $i$ 个物品是否选择：&lt;/p>
&lt;p>$$
dp(i, j) = \min\{dp(i-1, j), dp(i-1, j-w_i) + v_i\}
$$&lt;/p>
&lt;p>看上去和之前的方程式长得差不多，但现在时间复杂度变为了 $O(n\cdot \sum w_i)$。而且在做第 $i$ 轮时，第二维实际只需要枚举到 $\sum_{k=1}^iw_k$，因此实际实现时会有一个十分可观的小于 1 的常数因子，足够通过。&lt;/p>
&lt;hr>
&lt;p>另外一个需要考虑的问题是: $O(n\cdot \sum w_i)$ 的空间复杂度似乎过高，使用了太多的内存。这里我们为大家介绍“滚动数组”的技术：观察状态转移方程，我们容易发现 $dp(i, *)$ 只使用了 $dp(i-1, *)$ 来更新自己的结果，因此在做 $dp(i+, *)$ 时仍然存留这 $dp(i-2, *)$ 以及更之前的数据就是对空间的浪费。因此我们的 dp 数组可以只开两行：$dp(previous, *)$ 和 $dp(current, *)$。current 层依赖 previous 层获取结果，然后 previous 和 current 互换，依次类推。采用滚动数组的代码通常会写成如下形式：&lt;/p>
&lt;pre>&lt;code class="language-c++">int dp[2][MAXN];
int previous = 0, current = 1;
for (int i = 1; i &amp;lt;= n; i++)
{
// DP logic: dp[current][...] = compute(dp[previous][...])
swap(previous, current);
}
&lt;/code>&lt;/pre>
&lt;p>滚动数组是压缩空间的通用技术，不过就本题而言，我们还可以做得更激进一些，只需要开一维数组即可，这样代码书写起来也更加方便。你可以参考 2-3-C 关于空间优化的部分进行思考。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>背包问题是 NP-Complete 问题&lt;/strong>&lt;/p>
&lt;p>不少同学疑惑的问题是：背包问题存在如此简明的动态规划算法可以高效解决，为什么它通常被归入“难问题”的行列呢？这是因为我们给出的动态规划算法有效的前提是物品的总体积/总价值不太大。换句话说，我们目前暂时无法给出一个只和物品数量 $n$ 相关的时间复杂度。这样的不仅依赖输入的数量，还依赖输入值的大小的“多项式”算法称为&lt;strong>伪多项式算法 (pseudo-polynomial algorithm)&lt;/strong>。大家会在问题求解IV中学习这方面的内容。&lt;/p>
&lt;/div>
&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>cdq分治</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/cdq/</link><pubDate>Sat, 13 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/cdq/</guid><description>&lt;p>cdq分治是一种常用的分治思想，最早由
&lt;a href="https://www.cs.princeton.edu/~danqic/" target="_blank" rel="noopener">陈丹琦&lt;/a> 整理和总结 (
&lt;a href="https://www.cs.princeton.edu/~danqic/papers/divide-and-conquer.pdf" target="_blank" rel="noopener">原文地址&lt;/a>)。这种思想从 high-level 层面来说十分简单和抽象：当我们希望解决某个问题 &lt;code>solve(l, r)&lt;/code> 时，我们可以考虑如下的分治步骤：&lt;/p>
&lt;ul>
&lt;li>解决 &lt;code>solve(l, mid)&lt;/code>。&lt;/li>
&lt;li>考虑 &lt;code>[l, mid]&lt;/code> 对 &lt;code>[mid+1, r]&lt;/code> 的贡献。&lt;/li>
&lt;li>解决 &lt;code>solve(mid+1, r)&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>第一步和第三步非常简明，但第二步的“贡献”非常抽象，需要具体问题具体分析。这里我们以二维偏序和三维偏序为例示范一些“贡献”的计算方法。&lt;/p>
&lt;p>二维偏序问题指对于一个二元组序列 $(a_1, b_1), (a_2, b_2),\cdots, (a_n, b_n)$，求满足 $a_i&amp;lt;a_j$ 且 $b_i&amp;lt;b_j$ 的数对 $(i, j)$ 的个数。显然我们如果将二元组按照 $a_i$ 从小到大排序，那么该问题就转化为了 $b_i$ 序列的逆序数问题。我们尝试套用cdq分治的“模板”来解决它。令 $solve(l, r)$ 表示区间 $[l, r]$ 内的逆序对个数，最终答案显然为 $solve(1, n)$。$solve(l, r)$ 分为三个步骤：&lt;/p>
&lt;ul>
&lt;li>$solve(l, mid)$：递归解决.&lt;/li>
&lt;li>计算下标在 $[l, mid]$ 中的数对 $[mid+1, r]$ 中的数的贡献。换言之，我们需要计算满足 $i\in [l, mid], j\in [mid+1, r]， b_i&amp;gt;b_j$ 的 $(i, j)$-pair 个数。&lt;/li>
&lt;li>$solve(mid+1, r)$：递归解决。&lt;/li>
&lt;/ul>
&lt;p>重点关注第二条。虽然看上去还是在数“逆序对”，但它比原问题简单了很多——因为 $[l, r]$ 的数被鲜明地分成了 $[l, mid]$ 和 $[mid+1, r]$ 两类。换言之，每个数 $b_i$ 可以被看作 $(0, b_i)$ 或者 $(1, b_i)$，取决于它在左半边还是右半边，我们只关心 0 的那一类 $b_i$ 比 1 的那一类 $b_i$ 大的数目。我们可以通过按照 $b_i$ 做一遍归并排序，然后统计每个 0 前面有多少个 1 的方法来解决。使用主定理分析容易得知该算法的时间复杂度为 $O(n\log n)$。&lt;/p>
&lt;p>在进入三维偏序之前，我们先尝试总结 cdq 分治降低问题难度的本质：它通过分治递归处理子问题，然后在当前层面上只考虑左对右的贡献，从而将&lt;strong>某一维度的排序问题转换成了 0/1 的二元问题&lt;/strong>。&lt;/p>
&lt;p>再来看三维偏序问题。三维偏序问题和二维偏序定义类似，只不过每个元素都换成了三元组 $(a_i, b_i, c_i)$。我们如法泡制，对第一维排序后套用 cdq 分治，相当于将所有的三元组转换成了 $(0/1, b_i, c_i)$。由于还剩下两维，直接下手有点困难 (除非你掌握了某些高级的数据结构)，但我们可以在 cdq 分治上再套一层 cdq 分治：具体来说，我们可以对 $b_i$ 这一维度做归并排序，然后对其 cdq 分治，这样在第三维上每个三元组实际上被转化成了 $(0/1, 0/1, c_i)$，我们只需要考虑所有形如 $(0, 0, c_1)$ 对 $(1, 1, c_2)$ 的贡献即可。总时间复杂度 $O(n\log^2n)$。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>致歉&lt;/strong>&lt;/p>
&lt;p>本讲义写的相当糟糕——cdq分治难度较大，思想比较微妙，笔者功力有限，用语言难以描述清楚。我们提供两份额外的参考资料，一是
&lt;a href="https://oi-wiki.org/misc/cdq-divide/" target="_blank" rel="noopener">OIWiki&lt;/a> 上对于 cdq 分治的讲解，二是一份 cdq 套 cdq 的三维偏序的参考实现，希望尽可能帮助感兴趣的同学理解。&lt;/p>
&lt;/div>
&lt;/div>
&lt;details>&lt;summary>&lt;b>Code&lt;/b> :: &lt;i>Click to expand&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-c++">struct node
{
int x,y,z;
int nx,ny,nz;
bool operator == (const node cp)
{
return (x==cp.x) &amp;amp;&amp;amp; (y==cp.y) &amp;amp;&amp;amp; (z==cp.z);
}
}p[100048],a[100048],b[100048],c[100048];
int n;
int ans = 0;
bool cmp(node x,node y)
{
if (x.x!=y.x) return x.x&amp;lt;y.x;
if (x.y!=y.y) return x.y&amp;lt;y.y;
return x.z&amp;lt;y.z;
}
void cdq2(int left,int right)
{
if (left==right) return;
int i,k1,k2,pt,mid=(left+right)&amp;gt;&amp;gt;1;
cdq2(left,mid);cdq2(mid+1,right);
int cnt=0;
for (k1=left,k2=mid+1,pt=left;pt&amp;lt;=right;pt++)
{
if (k2&amp;gt;right || (k1&amp;lt;=mid &amp;amp;&amp;amp; k2&amp;lt;=right &amp;amp;&amp;amp; b[k1].z&amp;lt;=b[k2].z))
{
if (!b[k1].nx) cnt++;
c[pt]=b[k1++];
}
else
{
if (b[k2].nx) ans += cnt;
c[pt]=b[k2++];
}
}
for (i=left;i&amp;lt;=right;i++) b[i] = c[i];
}
void cdq1(int left,int right)
{
if (left==right) return;
int i,k1,k2,pt,mid=(left+right)&amp;gt;&amp;gt;1;
cdq1(left,mid); cdq1(mid+1,right);
for (k1=left,k2=mid+1,pt=left;pt&amp;lt;=right;pt++)
{
if (k2&amp;gt;right || (k1&amp;lt;=mid &amp;amp;&amp;amp; k2&amp;lt;=right &amp;amp;&amp;amp; a[k1].y&amp;lt;=a[k2].y))
{
b[pt]=a[k1++];
b[pt].nx=0;
}
else
{
b[pt]=a[k2++];
b[pt].nx=1;
}
}
for (i=left;i&amp;lt;=right;i++) a[i] = b[i];
cdq2(left,right);
}
int main ()
{
int i;
n=getint();i=getint();
for (i=1;i&amp;lt;=n;i++) {p[i].x=getint();p[i].y=getint();p[i].z=getint();}
sort(p+1,p+n+1,cmp);
for (i=1;i&amp;lt;=n;i++) a[i] = p[i];
cdq1(1, n);
}
&lt;/code>&lt;/pre>
&lt;/details></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>【问题求解II-HW4.C】烧烤</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-4-c/</link><pubDate>Mon, 08 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-4-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题面描述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>有 $n$ 个包，第 $i$ 个包里有 $A_i$ 个黑球和 $B_i$ 个白球。问有多少种方法选取两个包，再将包中的球排成一排。同色的球之间不可区分，选择不同的包被视为不同方案。&lt;/li>
&lt;li>$n\leq 2\times 10^5, A_i, B_i\leq 2000$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>该题面描述与原题背景不太相同，但容易看出它们讨论的问题是一样的。本题解法比较精巧，供大家欣赏。&lt;/p>
&lt;p>如果我们枚举选择哪两个包，那么选取第 $i$ 个包和第 $j$ 个包的情况下，问题会被转化为将 $A_i+A_j$ 个黑球和 $B_i+B_j$ 个白球排成一排有多少种方案，这等价于在 $A_i+A_j+B_i+B_j$ 个位置中选择 $A_i + A_j$ 个位置放黑球。因此最终答案可以写为
$$
\sum_{i,j\in \{1,\cdots, n\}, i&amp;lt;j} \binom{A_i+A_j+B_i+B_j}{A_i+A_j}
$$
即使我们可以通过预处理 $O(1)$ 地求解组合数，直接计算该表达式的时间复杂度也会达到 $O(n^2)$。本题 $n\leq 2\times 10^5$，无法通过。&lt;/p>
&lt;p>本题的关键在于一步精妙的模型转化。通常我们认为能够写出
&lt;a href="https://en.wikipedia.org/wiki/Closed-form_expression" target="_blank" rel="noopener">closed form&lt;/a> 的数学表达式是优美的，但这里我们反其道而行之，将上面表达式中的组合数转化为一个具体的动态规划问题。考虑如下问题：&lt;/p>
&lt;blockquote>
&lt;p>在一张 $n\times m$ 的方格纸上，从 $(0, 0)$ 走到 $(n, m)$ 有多少种方案？&lt;/p>
&lt;/blockquote>
&lt;p>很容易看出该问题的答案是 $\binom{n+m}{n}$，即从 $(0, 0)$ 到 $(n, m)$ 一共要走 $n$ 条横向边和 $m$ 条竖向边，从 $n+m$ 步中选 $n$ 步走横的。不过我们还有一个“笨方法”：令 $dp(i, j)$ 表示从 $(0, 0)$ 走到 $(i, j)$ 的方案数，显然 $(i, j)$ 可以从 $(i-1, j)$ 或 $(i, j-1)$ 转移来，因此
$$
dp(i, j)=
\begin{cases}
1&amp;amp;, (i, j) = (0, 0)\\
dp(i-1, j) + dp(i, j-1)&amp;amp;, otherwise
\end{cases}
$$
注意上述状态转移方程没有考虑边界问题。
回到原问题，对于 $\binom{A_i+A_j+B_i+B_j}{A_i+A_j}$，我们可以套用走方格纸问题，将其转化为从 $(-A_i, -B_i)$ 到 $(A_j, B_j)$ 的方案数，并通过动态规划递推解决。我们看似用一个 $O(|A_i|^2)$ 的算法替代了 $O(1)$ 的组合数求解，但&lt;strong>动态规划的递推过程是可以叠加的&lt;/strong>。我们不需要单独对每组点对跑动态规划，我们可以给地图上所有的负坐标 $(-A_i, -B_i)$ 打上 1，然后从地图的左下角开始一遍推到右上角，然后在所有的正坐标位置收取答案。这样我们花一趟完成了 $n^2$ 次动态规划，时间复杂度降低到 $O(|A_i|^2)$。&lt;/p>
&lt;p>这个过程有一点抽象，如果你没有完全理解，可以简单构造一个 3 个点的样例手动模拟一下算法，看看一个负坐标上的标记是如何同时贡献到所有的正坐标上，以及不同的标记是如何叠加的。&lt;/p>
&lt;p>此外还有一些细节需要注意：&lt;/p>
&lt;ul>
&lt;li>C/C++ 中无法直接支持负数下标，你可以考虑给所有的坐标加上一个偏移量 offset 转化成正数。&lt;/li>
&lt;li>上述递推过程会出现 $(-A_i, B_i)$ 向 $(A_i, B_i)$ 贡献的情况，相当于选取了两个一样的包，需要 $O(n)$ 地单独计算并扣除。&lt;/li>
&lt;li>对于任意一对 $(i, j)$，上述递推过程会同时计算 “$(-A_i, -B_i)$ 向 $(A_j, B_j)$ 贡献” 和 “$(-A_j, B_J)$ 向 $(A_i, B_i)$ 贡献”，即重复计算了两次。因此扣除第二点中的数量后还需要除以 2。&lt;/li>
&lt;li>在模意义下如何除以 2 不是一个简单的问题，你可以上网搜索“逆元”或 &amp;ldquo;modular multiplicative inverse&amp;rdquo;。现阶段你不需要过深地理解背后的数论原理，因为后续的理论课程会涉及。&lt;/li>
&lt;/ul></description></item><item><title>SDV: Simple Double Validation Model-Based Offline Reinforcement Learning</title><link>https://chenhn02.github.io/publication/sdv/</link><pubDate>Tue, 02 May 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/sdv/</guid><description/></item><item><title>【问题求解II-HW3.A】签到题</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-3-a/</link><pubDate>Sat, 29 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-3-a/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>
&lt;p>给定数列 $a_1, \cdots, a_n$，求区间 $(l, r)$，使得&lt;/p>
&lt;p>$$
\left(\min_{i=l}^r a_i\right)\cdot \left(\max_{i=l}^r a_i\right)\cdot \left(\text{OR}_{i=l}^r a_i\right) \cdot (r - l + 1)
$$&lt;/p>
&lt;p>最大。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>$n\leq 10^6$。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>遇到复杂的表达式不要慌，应当仔细观察它的性质。我们很容易发现该表达式的一个特点：除了 $\min$ 这个操作，其他的三项都是随着区间的扩大而增加的。换句话说，如果没有 $\min$ 这一项，这题的答案就是整个数列。&lt;/p>
&lt;p>接下来我们考虑如何对付这个棘手的 $\min$。$\min$ 的一大特点在于它是有限的——任何一个区间的 $\min$ 一定是原数列中的一个数，因此所有可能的 $\min$ 最多只有 $n$ 种。如果我们把所有的区间按照 $\min$ 的位置归类，那么根据之前的结论，享有同一个 $\min$ 的区间集合中，只有最长的那个才可能是答案的候选区间。&lt;/p>
&lt;p>到这里，我们的问题转化成了：枚举数列中的每个数 $a_i$ 作为最小值的情况，我们希望找到以此为最小值的最长区间，即从 $a_i$ 出发向左向右扩展，把所有 $\geq a_i$ 的数纳入到区间中，直到碰到边界/比 $a_i$ 小的数。但这件事仍然不容易，如果暴力地向左向右查看，复杂度仍然会达到 $O(n^2)$。&lt;/p>
&lt;p>考虑这样一种精巧的做法：我们不按照下标的顺序依次枚举 $a_i$，而是按照 $a_i$ 值从小到大的顺序枚举 $a_i$。这样在枚举到任意 $a_i$ 的时刻整个数列的格局如下：&lt;/p>
&lt;img src="https://chenhn02.github.io/img/ps-sol-2031.png" style="zoom: 50%;" />
&lt;p>其中蓝色的格子代表已经被枚举过的数，红色的是当前枚举的数。我们发现所有蓝色的数一定比当前数小(小于等于)，所有白色的数一定比当前的数大。因此要寻找“最长区间”，我们只要在“蓝色数”的下标数列中寻找比当前下标小的最大数和比当前下标大的最小数即可。你可以借助 C++ STL 的 set 容器以及 &lt;code>lower_bound&lt;/code> 方法来轻松完成这件事，时间复杂度降低到 $O(n\log n)$。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>遇到大小相同的数怎么办？&lt;/strong>&lt;/p>
&lt;p>其实不用担心这个问题。你可以按照任意顺序处理大小相同的数，因为先处理的数对应的区间可以包括后处理的数，所以后处理的数被先处理的数“卡住”区间边界也无关紧要了。&lt;/p>
&lt;/blockquote>
&lt;p>或者你也可以采取类似分治的做法，每次找完当前区间的最小值后将区间拆分成左右两个区间分别处理 (因为后续的区间不应该跨越这个最小值)。至于如何寻找一个区间最小值的位置，你可以在维护 ST 表时同时维护最小值位置，具体细节留给大家自己思考。&lt;/p>
&lt;p>剩下的最后一件事情是：对于每个最小值，我们找到了它对应的最长区间后，如何求该区间的最大值/同或和/长度。长度是容易的，剩下的两样恰好是 ST 表擅长的内容。你只需要预处理 ST 表即可 $O(1)$ 地查询。整个算法的时间复杂度为 $O(n\log n)$。&lt;/p></description></item><item><title>【问题求解II-HW3.B】砍树</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-3-b/</link><pubDate>Sat, 29 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-3-b/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意描述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定一棵 $n$ 个节点的数和 $m$ 个点对，每个点对染不同的颜色。要求找出树中的一条边，使得砍掉这条边后得到的两棵树中没有颜色相同的节点 (即颜色相同的点对不能被划分到同一棵树中)。求满足条件的边的最大编号，若不存在输出 -1。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>树上任意两个点之间有且仅有一条简单路径。并且我们容易发现：如果要把两个点分到两棵树中，那么选择的这条边必须在连接这两个点的路径上。因此该问题被转化为了对树上的 $m$ 条路径求交。以下我们介绍三种做法供大家参考。&lt;/p>
&lt;h4 id="做法1-基于lca的路径标记">&lt;strong>做法1: 基于LCA的路径标记&lt;/strong>&lt;/h4>
&lt;p>对于树上的任意两个点 $u$, $v$，从 $u$ 到 $v$ 的路径总可以被拆分为 $u\to lca(u, v)$ 和 $lca(u,v)\to v$ 两条直上直下的链。我们希望可以快速给链上所有的边打一个 +1 标记，这样最后只需要找有 $m$ 个标记的边即可。&lt;/p>
&lt;p>如果对每条路径上的所有边暴力打 +1 标记的话，总复杂度为 $O(\sum_{i=1}^m len(u_i\to v_i))$，最坏情况下可以达到 $O(mn)$，不可接受。&lt;/p>
&lt;p>以 $o(t)$ 的代价给 $t$ 条边打上标记似乎是一件“违反物理”的事情。但我们向大家展示如下的技巧做到这一点：&lt;/p>
&lt;ul>
&lt;li>我们不把操作作用在边上，而是作用在点上，每个点与连向它父亲的边对应 (根节点没有对应的边)。&lt;/li>
&lt;li>对于路径 $(u, v)$：
&lt;ul>
&lt;li>$mark(u) + 1$。&lt;/li>
&lt;li>$mark(v) + 1$。&lt;/li>
&lt;li>$mark(lca(u, v)) - 2$。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>每个点最终的标记 $Mark(u) = \sum_{v\in subtree(u)} mark(v)$ (即以 $u$ 为根的子树中所有节点的 mark 之和)。&lt;/li>
&lt;/ul>
&lt;p>这个技巧有一个很炫酷的名字，叫“树上差分”。你可以对一条路径按照上述操作做一遍，然后验证一下处于各个位置的节点的 Mark，你会发现只有 $(u, v)$ 路径上的节点 (除了 LCA) Mark 为 1，其他的都是 0。这恰好符合我们在边上打标记的需求。此外，这个标记系统是可以累加的，即你不需要每次计算 Mark，而是可以把 $m$ 条边的 mark 做完之后再一起计算 Mark。这样我们在 $O(m+n)$ 的时间内完成了打标记的动作。加上预处理和计算 LCA 的复杂度，该算法的总时间复杂度为 $O((n+m)\log n)$。&lt;/p>
&lt;h4 id="做法2-将树上路径求交转化为一维的区间求交">&lt;strong>做法2: 将树上路径求交转化为一维的区间求交&lt;/strong>&lt;/h4>
&lt;p>把第一个点对所在的路径 $(u_1, v_1)$ 抓出来，考虑剩下的 $m-1$ 条路径在 $(u_1, v_1)$ 上的相交部分。想象一下容易发现，如果把树中的一条链 $(u_1, v_1)$ 提出来，“用手拎着两端提在空中”，那么整个树的格局会像一个晾衣绳，晾衣绳上的每个点挂了一个树，如下图所示。&lt;/p>
&lt;img src="https://chenhn02.github.io/img/ps-sol-2032-link.png" style="zoom: 50%;" />
&lt;p>给 $(u_1, v_1)$ 这条链上的点重新标号 $x_1, x_2,\cdots, x_t$。对于其他任意一个点对 $(u_i, v_i)$，如果 $u_i, v_i$ 在同一个 $x_p$ 的树下 (如图中红色所示)，那么 $(u_i, v_i)$ 这条路径将完全在 $x_p$ 的子树内部，从而和 $(u_1, v_1)$ 没有边的交集；如果 $u_i, v_i$ 在不同的子树 $x_p, x_q (p&amp;lt;q)$ 下 (如图中蓝色所示)，那么 $(u_1, v_1)$ 和 $(u_i, v_i)$ 这两条链将会有 $(x_p, x_q)$ 这一段是公共的。&lt;/p>
&lt;p>对于 $(u_2, v_2), \cdots, (u_m, v_m)$ 中的每一对，我们都可以检查 $u_i, v_i$ 在哪棵子树中，从而算出它和 $(u_1, v_1)$ 的交集。这时问题已经被转化成了在一维序列 $x_1, \cdots, x_t$ 上的区间交集问题。剩下的一点点细节非常简单，留给大家思考。&lt;/p>
&lt;p>这个做法颇有“大道至简”的意味——没有“倍增求LCA”“树上差分“这样炫酷的技术，就是平平无奇的几遍搜索，就给出了更优秀的时间复杂度 $O(n)$。在这里我们也想给大家传递一个价值观：&lt;/p>
&lt;blockquote>
&lt;p>高级算法和高级数据结构就像武林中的重武器，而你分析问题、转化问题的能力则像内功。内功和武器是相辅相成的，内功不足却想耍大刀，适得其反。比起盲目地学习很多炫酷的技术，我们更希望大家能充分锻炼自己的思维能力，这才是成为一个优秀的算法设计师的正道。&lt;/p>
&lt;/blockquote>
&lt;h4 id="做法3-一个神奇的随机算法">&lt;strong>做法3: 一个神奇的随机算法&lt;/strong>&lt;/h4>
&lt;p>这个做法有些“过于”精巧，大家只需欣赏即可。&lt;/p>
&lt;p>为每个数对 $(u_i, v_i)$ 随机一个数 $c_i$，并将 $c_i$ 打在 $u_i, v_i$ 两个节点上。对于每条边，砍掉它合法的充分必要条件是这条边下面的子树恰好包含 $c_1, c_2, \cdots, c_m$ 各一个。这件事并不容易检查，但我们直接&lt;strong>对子树上的数值求异或和&lt;/strong>，并通过&lt;strong>检查子树异或和是否等于所有 $c_i$ 的异或和&lt;/strong>的方式来“判定”子树是否满足要求。时间复杂度 $O(n)$。&lt;/p>
&lt;hr>
&lt;p>看上去有点雷人。下面尝试用不太严谨的方式论证该做法错误的概率极小：&lt;/p>
&lt;p>异或的性质是：$x\otimes x = 0$ (其中 $\otimes$ 常用于表示异或算符)。因此如果一个子树同时包含了一对点 $(u_i, v_i)$ 中的两个或零个，那么它最终的异或和将缺少 $c_i$ 这一项。因此要使得上述随机算法错误，一定存在一个 $\{1, 2, \cdots,m\}$ 的子集 $\{k_1, k_2, \cdots k_t\}$ 满足 $\bigotimes_{i=1}^t c_{k_i} = 0$，且恰好存在一条边能把这个子集精准地“选出来”。&lt;/p>
&lt;p>对于一个集合，若该集合中的数在 $[0, 2^k)$ 之间随机，那么不论这个集合中元素的个数有多少，该集合的异或和为 0 的概率都是 $\frac{1}{2^k}$ (考虑每个二进制位，有若干个要么是 0 要么是 1 的数，但不论有多少，其中有偶数个 1 的概率都是 $\frac{1}{2}$)。&lt;/p>
&lt;p>我们可以不严谨地认为，树上的每条边相当于独立地在 $\{1, 2, \cdots, m\}$ 中选一次子集 (说它不严谨是因为 ①不同的边选出的集合之间存在互相包含关系，并不是独立的； ②该过程和 $2m$ 个数在树上的分布有关，并不是随机选取。但总体可以感受到在概率上两者是同阶的)。因此该算法正确的概率&lt;/p>
&lt;p>$$
P(correct)\sim \left(1-\frac{1}{2^k}\right)^{\min(2^m, n)}
$$&lt;/p>
&lt;p>本题中 $n\sim 10^5$。简单计算可知，当 $k=32$ 时正确率已经达到 $99.997\%$，当 $k=64$ 时该算法正确率将极其接近 $100\%$ (64位恰好是 long long 的范围，在 long long 范围内随机整数并不困难)。&lt;/p>
&lt;p>虽然从理论计算科学的角度，有错误率的随机算法和确保正确的算法之间存在本质区别，但在实际中，如果算法错误的概率已经远小于硬件出错的概率，那么该算法的可用性便已经很高。对于这道题来说，我们确实容易设计出简单高效的线性算法，但事实上，绝大多数问题是难的 (关于难的定义，以及 P, NP, NP-hard 相关的概念大家会在第四学期学习)，对于难问题，很多时候我们只能从近似角度尝试解决。&lt;/p></description></item><item><title>【问题求解II-HW3.C】单词接龙</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-3-c/</link><pubDate>Sat, 29 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-3-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>给定 $n$ 个英文单词，两个单词可以接在一起当且仅当前一个单词的最后一个字母和后一个单词的第一个字母相同。问最少删除多少个单词可以让剩下的单词全部接龙。&lt;/li>
&lt;li>$n\leq 10^5$。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>从本次作业开始我们会逐渐向大家介绍简单的动态规划 (dynamic programming) 设计。动态规划是一项“很难教”的技术，因为它没有什么特别的理论基础，好的状态设计和转移优化也没有固定的模式，需要大家多看、多想、多总结。我们通过“单词接龙”这道题来向大家展示好的状态设计是如何显著减少算法时间复杂度的。&lt;/p>
&lt;p>首先，删除尽量少的单词等价于选择尽量多的可接龙单词，因此我们将问题转化为寻找原单词序列的最长可接龙子序列。我们首先考虑如下最容易想到的状态：令 $dp(i)$ 表示&lt;strong>以第 $i$ 个单词结尾&lt;/strong>的最长可接龙子序列的长度。为了计算这个状态，我们需要在 i-th 前找到另外一个单词，满足可以和 i-th 接上。因此转移方程可以写为
$$
dp(i) = 1 + \max_{k\in [1, i-1], word_k \to word_i} dp(k)
$$
其中 $word_i\to word_j$ 表示第 $i$ 个词的最后一个字母和第 $j$ 个词的第一个字母相同。式子最前方的 1 表示第 $i$ 个单词贡献的序列长度。计算完所有的 dp 值之后，$\max_{k=1}^n dp(k)$ 即为答案。&lt;/p>
&lt;p>这自然是一个正确的算法，但计算 $dp(i)$ 时需要依次枚举前面的所有单词，从而时间复杂度达到了 $O(n^2)$，在本题的数据规模下不可接受。&lt;/p>
&lt;hr>
&lt;p>优化状态设计的动机是：我们最关心的其实是单词的开头/结尾字母，而小写字母一共只有 26 个。因此我们应该在这方面作文章，把每次遍历前面所有单词的过程节省掉。考虑如下状态：令 $dp(i, ch)$ 表示在前 $i$ 个单词中，以字符 $ch$ 结尾的最长可接龙子序列的长度 &lt;strong>(注意！不再要求一定以 $word_i$ 结尾！)&lt;/strong>。转移考虑对以下两种情况取 max：&lt;/p>
&lt;ul>
&lt;li>最长子序列不包括第 $i$ 个单词: $dp(i-1, ch)$。&lt;/li>
&lt;li>最长子序列包括第 $i$ 个单词，且第 $i$ 个单词确实以 $ch$ 结尾: 设第 $i$ 个单词的开头字符为 $ch&amp;rsquo;$，则这种情况的最长序列长度为 $dp(i-1, ch&amp;rsquo;) + 1$。&lt;/li>
&lt;/ul>
&lt;p>因此状态转移方程可以写为&lt;/p>
&lt;p>$$
dp(i, ch) = \begin{cases}
\max\{dp(i-1, ch), dp(i-1, \text{start}(word_i)) + 1\} &amp;amp;, \text{end}(word_i) = ch\\
dp(i-1, ch)&amp;amp;, \text{otherwise}
\end{cases}
$$&lt;/p>
&lt;p>可以看到，虽然状态的数量上升到了 $O(n|\Sigma|)$，但转移的代价变成了 $O(1)$，所以总时间复杂度降低到了 $O(|\Sigma|n)$，其中 $\Sigma$ 为字符集，在本题中 $|\Sigma|=26$，可以通过。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>有没有什么小技巧可以略微优化一下复杂度？&lt;/strong>&lt;/p>
&lt;p>上述状态转移方程中，我们每次只会对一个 $ch$ 更新 dp，其余的都是照抄。而且 $dp(i, ch)$ 的计算只会用到 $dp(i-1, ch)$ 的结果，因此我们可以把状态的第一维省掉，把代码写成如下形式：&lt;/p>
&lt;pre>&lt;code class="language-python">dp = [0] * MAX_CHARACTER # 长度为 |Sigma| 的数组
for i in range(1, n + 1):
# 此时的 dp[ch] 存储的是方程式中 dp(i-1, ch) 的值
startch, endch = strings[i].start, strings[i].end
dp[endch] = max(dp[endch], dp[startch] + 1)
# 根据转移方程，此时 dp[endch] 的值是 dp(i, ch) 的值
# 又因为其他的 dp[ch] 不需要改变，所以自动“升级”成了 dp(i, ch)
# 至此，dp[ch] 存储了方程式中 dp(i, ch) 的值
&lt;/code>&lt;/pre>
&lt;p>可以看到，省掉了第一维之后，动态规划的时间复杂度下降到了 $O(n)$，空间复杂度下降到了 $O(|\Sigma|)$ (不考虑存储字符串的额外代价)。如果字符集的大小达到了很大的级别 (比如 unicode 中的所有字符)，那么这一简单的优化可以节省大量的时间和存储。&lt;/p>
&lt;/div>
&lt;/div>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>第一个 dp 思路真的是“死胡同”吗？&lt;/strong>&lt;/p>
&lt;p>再来重温一下状态转移方程&lt;/p>
&lt;p>$$
dp(i) = 1 + \max_{k\in [1, i), word_k \to word_i} dp(k)
$$&lt;/p>
&lt;p>在考虑 $dp(i)$ 的转移时，我们在意的是所有的那些结尾字母与 $word_i$ 开头字母相同的位置的 dp 的最大值。每次把 1 到 i-1 扫一遍效率太低，但注意我们在做到 $dp(i)$ 时，之前的所有 dp 值已经求好了，因此我们可以额外对每种结尾字母维护当前最大值，从而实现 $O(1)$ 转移。&lt;/p>
&lt;p>形式化地，令
$$
maxdp(i, ch)\triangleq \max_{k\in [1, i],\text{end}(word_k)=ch} dp(k)
$$
则我们可以同时写出 dp 和 maxdp 的状态转移方程&lt;/p>
&lt;p>$$
\begin{align}
dp(i) &amp;amp;= 1 + maxdp(i-1, \text{start}(word_i))\\
maxdp(i, ch) &amp;amp;=
\begin{cases}
\max\{maxdp(i-1, ch), dp(i)\} &amp;amp;, \text{end}(word_i) = ch\\
maxdp(i-1, ch) &amp;amp;, \text{otherwise}
\end{cases}
\end{align}
$$&lt;/p>
&lt;p>两个转移都是 $O(1)$ 的，而且由于 $dp(i)$ 只用到 $maxdp(i-1, ch)$，所以我们可以用和类似的技巧将 maxdp 的第一维度省去，将代码写成类似下面的模样：&lt;/p>
&lt;pre>&lt;code class="language-python">for i in range(1, n + 1):
startch, endch = strings[i].start, strings[i].end
dp[i] = 1 + maxdp[startch]
maxdp[endch] = max(maxdp[endch], dp[i])
&lt;/code>&lt;/pre>
&lt;p>你又可以发现一件事情：$dp(i)$ 在当前循环算完立即使用，且后续再也不会使用，所以可以把 $dp(i)$ 省略：&lt;/p>
&lt;pre>&lt;code class="language-python">for i in range(1, n + 1):
startch, endch = strings[i].start, strings[i].end
maxdp[s.endch] = max(maxdp[endch], 1 + maxdp[startch])
&lt;/code>&lt;/pre>
&lt;p>和之前的代码对比一下，你会发现除了数组名字不一样，其他完全一样。我们用两条看上去不太相同的思考路径得到了相同的结果，但实际上它们的本质是一致的：&lt;/p>
&lt;p>&lt;strong>动态规划状态设计就像给当前时刻做一张“快照”。你需要想清楚快照中保存怎样的性质可以完整地刻画当前的状态并为后续所用。在此基础上，记录的性质应当越少越好。&lt;/strong> 在这个问题中，每个接龙字符串序列的最后一个字符是最本质地刻画特征的性质，而最后一个字符串的下标并不是 ($i+1$ 往后的状态并不关心 $[1,i]$ 中结尾为 $ch$ 的最长接龙字符串序列的最后一个字符串是 $[1, i]$ 中的哪一个)。这就是第一个 dp 方向错误的根本原因。&lt;/p>
&lt;p>大家日后要做的，就是稳准狠地抓住一个状态最本质的性质。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>最近公共祖先-倍增</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/lca/</link><pubDate>Fri, 21 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/lca/</guid><description>&lt;p>在计算机世界中，所有与2的次幂相关的事情总是充满魔力的——即便是很大的数，对2取对数后也会落入我们容易处理的范围。之前介绍过的快速幂算法其实就是倍增思想的一种运用。这里我们以计算树上最近公共祖先 (lowest common ancestor, LCA) 为例再次展示倍增思想的强大。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>树&lt;/strong>&lt;/p>
&lt;p>如果你对“树”一无所知，你可以参考
&lt;a href="https://en.wikipedia.org/wiki/Tree_%28data_structure%29" target="_blank" rel="noopener">维基百科&lt;/a> 中的解释。这里强调一些简明的入门概念。&lt;/p>
&lt;ul>
&lt;li>树是一个有 $n$ 个顶点和 $n-1$ 条边构成的连通图 (连通指整个图只有“一块”，即任意两点之间都存在路径可达)。容易发现，树中是不会有环的。&lt;/li>
&lt;li>如果选择一个节点作为树根 (root)，那么整棵树会形成一个层次结构。树上的每个节点到根有且仅有一条路径，这个路径的长度称为节点的深度。&lt;/li>
&lt;li>在有根树中，每个节点“上面”相邻的只有一个节点，称为该节点的父亲。每个节点“下面”相邻的有一堆节点 (也可能没有)，称为该节点的孩子。一个节点A的父亲，父亲的父亲，…… 一直向上到根这条链上所有的节点都是A的祖先。&lt;/li>
&lt;/ul>
&lt;/div>
&lt;/div>
&lt;p>对于树中的两个节点 $u, v$，$LCA(u, v)$ 指的是 $u$ 和 $v$ 的所有公共祖先中最深的那个 (也可以说是离 $u, v$ 最近的那个)。下面是一个例子:&lt;/p>
&lt;img src="https://chenhn02.github.io/img/LCA.png" style="zoom: 30%;" />
&lt;p>暴力地求解LCA不算困难，总体思想是：我们先让深度大的节点往上爬，爬到和另一个节点相同深度，然后让 $u$ 和 $v$ 一直向上爬，直到它们相遇。下面的代码非常易懂&lt;/p>
&lt;pre>&lt;code class="language-c++">int LCA_bruteforce(int u, int v)
{
if (depth[u] &amp;lt; depth[v]) swap(u, v);
while (depth[u] &amp;gt; depth[v])
u = father[u];
while (u != v)
{
u = father[u];
v = father[v];
}
return u;
}
&lt;/code>&lt;/pre>
&lt;p>该算法的问题在于：如果树的深度很大 (例如达到了和 $n$ 同阶)，那么每次求解两个节点的 LCA 都需要 $O(n)$ 的时间。如果我们需要多次求解多个点对的 LCA (例如 $q$ 次)，就需要 $O(qn)$ 的时间。在 $q$ 较大的情况下这不可接受。&lt;/p>
&lt;p>接下来我们向大家展示如何利用倍增思想优化 LCA 的求解：&lt;/p>
&lt;p>令 $anc(u,i)$ 表示节点 $u$ 向上爬 $2^i$ 步之后到达的节点编号，如果 $depth(u)&amp;lt;2^i$ 则 $anc(u,i)=0$。我们发现 $anc(u,i)$ 是容易计算的：&lt;/p>
&lt;p>$$
anc(u, i)=
\begin{cases}
father(u)&amp;amp;, i=0\\
anc(anc(u, i-1), i-1)&amp;amp;, i \geq 1
\end{cases}
$$&lt;/p>
&lt;p>简单来说，向上爬 $2^n$ 步的结果等于先向上爬 $2^{n-1}$ 步，再向上爬 $2^{n-1}$ 步的结果。如果我们按照 $i$ 从小到大的顺序计算所有节点的 $anc(u,i)$，那么可以递推地完成计算过程。在实际实现时我们通常树搜索的过程中完成 anc 数组的计算，详见最后的参考代码。&lt;/p>
&lt;p>有了 anc 数组后，“向上跳”的流程就可以被大幅加速。我们先假设 $u, v$ 深度相同，这时我们不需要每次向上爬一格，而可以用 anc “赌一把大的”：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (int i = 20; i &amp;gt;= 0; i--)
if (anc[u][i] != anc[v][i])
{
u = anc[u][i];
v = anc[v][i];
}
&lt;/code>&lt;/pre>
&lt;p>这里巧妙地利用了整数二进制拆分的唯一性：假设 $depth(u)-depth(LCA(u, v))=d$，且
$$
d-1 = 2^{a_1} + 2^{a_2} + \cdots + 2^{a_k}, a_1&amp;gt;a_2&amp;gt;\cdots&amp;gt;a_k
$$
那么上述循环正好会在 $i=a_1, a_2,\cdots, a_k$ 的地方“向上跳”。之所以是 $d-1$ 而不是 $d$ 是因为我们要求 &lt;code>anc[u][i] != anc[v][i]&lt;/code>，只有这样我们才能确保没有“跳过头”，因此上述循环结束后 $u, v$ 都会正好在 LCA 的下面 (孩子)。我们强烈建议你手画一个例子体会这个过程。&lt;/p>
&lt;p>还剩下一个问题：如果 $u, v$ 深度不同该怎么办。和暴力做法的思路一样，我们可以让深度大的节点向上爬，爬到和另一个节点同深度。不过在 anc 数组的加持下，我们不再需要一个一个地爬了：&lt;/p>
&lt;pre>&lt;code class="language-c++">// 假设 depth[u] &amp;gt;= depth[v]
for (int i = 20; i &amp;gt;= 0; i--)
if (depth[anc[u][i]] &amp;gt;= depth[v])
u = anc[u][i];
&lt;/code>&lt;/pre>
&lt;p>你仍然可以用整数拆分的方式证明：$u$ 只会在 $depth(u)-depth(v)$ 的二进制表示中为 1 的那些位置向上跳，且循环结束后 $u$ 会和 $v$ 同深度。&lt;/p>
&lt;p>我们来分析这个倍增做法的复杂度：它尝试用 $2^k, 2^{k-1},\cdots, 2^1, 2^0$ 去覆盖 $u, v$ 到 LCA 的深度差距，因此只需要 $O(\log n)$ 的时间即可完成一次查询。虽然预处理 anc 表需要 $O(n\log n)$ 的时间，但在查询次数较多的情况下，$O(n\log n + q\log n)$ 就会比 $O(n + qn)$ 更有优势。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>通用思想&lt;/strong>&lt;/p>
&lt;p>从更抽象的层面来说，倍增思想成功的关键很多时候是一种“单一的扩展可能”：&lt;/p>
&lt;ul>
&lt;li>在快速幂的例子中，$*2$ 这件事非常固定，这使得 $2^n$ 可以通过重复 $*2$ 得到；&lt;/li>
&lt;li>在 LCA 的例子中，每个节点的父亲只有一个，这使得往上 $n$ 层的祖先可以通过重复 &lt;code>u=father[u]&lt;/code> 得到；&lt;/li>
&lt;li>……&lt;/li>
&lt;/ul>
&lt;p>这段话看起来有点玄学，但如果你接触了更多可以通过倍增思想解决的问题结构，回过头看可能会对此有更深的理解。&lt;/p>
&lt;/div>
&lt;/div>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>更快地求解LCA?&lt;/strong>&lt;/p>
&lt;p>虽然 $O(\log n)$ 的效率已经足够令人满意，但事实上在充分预处理的情况下，我们可以 $O(1)$ 地完成一对点的 LCA 查询。如果你对此感兴趣，可以尝试搜索 ST 表、dfs 序等关键词。我们会在合适的时机向大家展示这种技术。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>以下是一份参考代码：&lt;/p>
&lt;details>&lt;summary>&lt;b>LCA&lt;/b>&lt;i>::click to expand&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-c++">const int MAXN = 2e5 + 10;
vector&amp;lt;int&amp;gt; v[MAXN]; // vector 存储了每个节点相邻点的编号
int depth[MAXN]; // depth 存储了每个节点的深度
int anc[MAXN][21]; // 2 ^ 21 &amp;gt; MAXN
void dfs(int x, int fa)
{
// 搜索到 x 时 x 的所有祖先都已经被访问过，anc 数组已被计算
// 因此现在就可以计算 x 的 anc 数组
anc[x][0] = fa;
for (int i = 1; i &amp;lt;= 20; i++)
anc[x][i] = anc[anc[x][i - 1]][i - 1];
// 搜索
for (int y : v[x])
if (y != fa) // 相邻的节点不是父亲，那就是孩子，向下搜索
{
depth[y] = depth[x] + 1;
dfs(y, x); // y 是 x 的孩子，x 是 y 的父亲
}
}
int query_lca(int x, int y)
{
if (depth[x] &amp;lt; depth[y]) swap(x, y);
for (int i = 20; i &amp;gt;= 0; i--)
if (depth[anc[x][i]] &amp;gt;= depth[y])
x = anc[x][i];
// 此时有 depth[x] = depth[y]
if (x == y) return x;
for (int i = 20; i &amp;gt;= 0; i--)
if (anc[x][i] != anc[y][i])
{
x = anc[x][i];
y = anc[y][i];
}
// 注意不等号条件，此时 x, y 一定都是 LCA 的孩子, LCA = anc[x][0] = anc[y][0]
return anc[x][0];
}
&lt;/code>&lt;/pre>
&lt;/details></description></item><item><title>ST表</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/st/</link><pubDate>Fri, 21 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/st/</guid><description>&lt;blockquote>
&lt;p>已知一个数列 $a_1, \cdots, a_n$。给定 $l, r$，问区间 $a_l, \cdots a_r$ 的某种值。&lt;/p>
&lt;/blockquote>
&lt;p>ST表是一种处理上述格式的区间查询问题的利器。这类问题通常被称为 RMQ (range minimum query)，但实际上 ST 表可以应对的问题远不止最小值这一种。&lt;/p>
&lt;p>大家已经或多或少地接触过一些区间查询问题，例如求区间的和。以 $+$, $xor$ (异或操作) 这类算符为代表的操作的特点在于可以简明地写出逆运算。例如&lt;/p>
&lt;ul>
&lt;li>a + x - x = a&lt;/li>
&lt;li>a ^ x ^ x = a&lt;/li>
&lt;/ul>
&lt;p>满足这种特性的算符使用前缀技术可以高效地求解区间查询：
$$
\begin{align}
&amp;amp;pre(n)\triangleq op_{k=1}^n a_k \\
&amp;amp;op(l, r) = pre(r)\space op^{-1}\space pre(l-1)
\end{align}
$$&lt;/p>
&lt;p>但有些算符，例如 $\max, \min, or$ (同或操作)，它们不存在简明的逆运算符，从而无法用前缀技术解决区间问题 (例如，知道 $\max_{i\in [1, r]}a_i$ 和 $\max_{i\in [1, l-1]}a_i$ 对求解 $[l, r]$ 内的最大值没什么帮助)。但这些算符又具有加法、异或所不具备的特性——重复操作不会对结果带来影响：&lt;/p>
&lt;ul>
&lt;li>$\max(a, b) = \max(\max(\max(a, b), b), b)$&lt;/li>
&lt;li>$a | b = a | b | b | b$&lt;/li>
&lt;/ul>
&lt;p>ST表正适合用于解决这类算符的区间查询问题。ST表的本质是倍增思想 (由此可见倍增思想运用之广)。下面的讲解以最小值为例，但可以容易地扩展到其他算符上：&lt;/p>
&lt;p>令 $ST(i, j)$ 表示区间 $[i, i + 2^j)$ 的最小值。容易发现对于一个数列来说，按照 $j$ 的顺序从小到大计算 ST 可以递推地高效求解：&lt;/p>
&lt;p>$$
ST(i, j)=
\begin{cases}
a_i&amp;amp;, j = 0\\
\min(ST(i, j-1), ST(i + 2^{j-1}, j-1))&amp;amp;, j \geq 1
\end{cases}
$$&lt;/p>
&lt;p>简单来说，长度为 $2^j$ 的区间的最小值可以用已经求好的两个长度为 $2^{j-1}$ 的区间的最小值求 $\min$ 得到。如果 $i + 2^{j-1}$ 超出了 $n$，那么可以不考虑后一半的贡献。&lt;/p>
&lt;p>预处理好所有长度为2的次幂的区间的最小值，对于任意区间 $[l, r]$ 的最小值，我们可以用如下方式 $O(1)$ 地求解：令 $k$ 为最大的满足 $2^k\leq r-l+1$ 的整数，那么
$$
\min_{i=l}^r a_i = \min(ST(l, k), ST(r - 2^k + 1, k))
$$
它的思想是：$[l, l + 2^k)$ 和 $[r - 2^k + 1, r + 1)$ 这两个部分重叠的区间完整地覆盖了 $l[l, r]$，因此拿着它们的最小值求 min 即可得到全区间的最小值。在这里你可以看出 $\min$ 算符的“重复操作不影响结果”的特性对该做法的正确性保证至关重要。&lt;/p>
&lt;p>最后一个简单的小问题是：如何求 $k$。从数学的角度，你可以使用数学库中的 $\log$ 函数，但更常见的做法是线性预处理所有可能长度对应的 $k$ 值：&lt;/p>
&lt;pre>&lt;code class="language-c++">K[1] = 0;
for (int i = 2; i &amp;lt;= n; i++) K[i] = K[i &amp;gt;&amp;gt; 1] + 1;
&lt;/code>&lt;/pre>
&lt;p>你可以用数学归纳法来证明该做法的正确性。&lt;/p>
&lt;hr>
&lt;p>总结：&lt;/p>
&lt;ul>
&lt;li>ST 表可以在 $O(n\log n)$ 的预处理复杂度下，$O(1)$ 地求解任意区间的值。&lt;/li>
&lt;li>算符需要保证具有“重复操作不影响结果”的特性。&lt;/li>
&lt;li>ST 表无法处理带修改的情况。&lt;/li>
&lt;/ul>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>真的要具有“可重复贡献”性吗?&lt;/strong>&lt;/p>
&lt;p>如果你仔细学习了LCA章节的讲义，可能会想：如果把区间 $[l, r]$ 通过二进制拆分的方式拆成若干互不相交的，长度为2的次幂的区间的并，不就可以在 $O(\log n)$ 的时间内完成任意算符的区间查询了么？&lt;/p>
&lt;p>非常好的想法！而且确实是正确的。只不过一般没有人会这么做——ST表不支持修改的特性是硬伤，它的“卖点”主要是 $O(1)$ 的效率。因此如果要花费 $O(\log n)$ 的代价才能完成区间求值，它的价值就十分有限了。我们会在合适的时机向大家展示支持修改的 $O(\log n)$ 地完成各种区间操作的技术。&lt;/p>
&lt;/div>
&lt;/div></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>分块——优雅的暴力</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/chunking/</link><pubDate>Sat, 08 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/chunking/</guid><description>&lt;p>分块的思想被应用在生活的方方面面：举一个最简单的例子，大家上小学的时候通常班级里的座位被分成若干个“组”。每组有一个小组长 (传说中的“一道杠”)，班里有班干部 (传说中的“二道杠&amp;quot;)。班长在统计人数的时候，最便捷的方法就是让各个小组长统计自己组内的人数，汇总到班长手中再做一次简单加法。&lt;/p>
&lt;p>在算法设计中，分块的思想也常常被使用。空说无益，我们通过一个比 OJ 练习题更复杂的问题来解释分块思想的运用：&lt;/p>
&lt;blockquote>
&lt;p>给定数列 $a_1, \cdots, a_n$，高效实现 $q$ 次以下两种操作：&lt;/p>
&lt;ul>
&lt;li>&lt;code>1 l r x&lt;/code>：给区间 $[l, r]$ 中的数统一加上 $x$。&lt;/li>
&lt;li>&lt;code>2 l r&lt;/code>：查询 $\sum_{k=l}^r a_k$ 的值。&lt;/li>
&lt;/ul>
&lt;/blockquote>
&lt;p>暴力修改和统计有着相同的问题：如果给定的区间很长，那么操作执行就相当缓慢。总时间复杂度可以达到 $O(qn)$。&lt;/p>
&lt;p>我们来考虑如下的一种分块策略：将数列分成若干个长度为 $m$ 的小段 (第一段是 $[0,m)$，第二段是 $[m, 2m)$，依次类推，我们假设 $n$ 可以被 $m$ 整除)，命名为 $B_1, B_2, \cdots, B_b$。我们为每个块分配一个累加标记 $t_i$，表示这个块上的数被&lt;strong>整体&lt;/strong>加了多少，再维护一个和标记 $s_i$，表示在不考虑累加标记的情况下块内的数的和。那么&lt;/p>
&lt;ul>
&lt;li>
&lt;p>对于给 $[l, r]$ 上的数加 $x$ 这件事，我们可以把 $[l,r]$ 这个大区间拆分为：&lt;/p>
&lt;ul>
&lt;li>$[l, l_1]$，开头的一段，是某个块的后缀&lt;/li>
&lt;li>$[l_1+1, r_1]$，这一段正好对应若干个完整的块。&lt;/li>
&lt;li>$[r_1+1, r]$，结尾的零碎一段，是某个块的前缀。&lt;/li>
&lt;/ul>
&lt;br>
对于 $[l, l_1]$ 和 $[r_1, r]$，我们一个一个地给每个数 +x (别忘了同步更新该块对应的 $s_i$)。对于中间的若干个完整的块，我们直接把 +x 标记打在对应的 $t_i$ 上 (即 "ti += x")。
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;ul>
&lt;li>对于求 $\sum_{k=1}^ra_k$，我们仍然可以把区间拆成上述的三个部分。对于开头和结尾的零碎区间，我们一个一个地累加，对于中间的若干个完整的块，我们发现一个块的实际的和可以通过如下方式算出：
$$
sum_i = s_i + t_i * m
$$
将中间完整块的 $sum_i$ 累加起来即可。&lt;/li>
&lt;/ul>
&lt;p>为了更清楚地说明上述思想，我们给出修改和查询操作的简单伪代码：&lt;/p>
&lt;details>&lt;summary>Pseudocode :: Click to expand&lt;/summary>
&lt;pre>&lt;code class="language-c++">void modify(int l, int r, int x)
{
decompose [l, r] into [l, l1], [l1, r1] and [r1, r]
suppose [l1, r1] = Bp, Bp+1, ..., Bq
// 开头零碎段
for (int i = l; i &amp;lt;= l1; i++)
{
a[i] += x;
s[p-1] += x;
}
// 中间完整段
for (int i = p; i &amp;lt;= q; i++)
t[i] += x;
// 结尾零碎段
for (int i = r1 + 1; i &amp;lt;= r; i++)
{
a[i] += x;
s[q+1] += x;
}
}
int query(int l, int r)
{
decompose [l, r] into [l, l1], [l1, r1] and [r1, r]
suppose [l1, r1] = Bp, Bi+1, ..., Bq
int sum = 0;
// 开头零碎段
for (int i = l; i &amp;lt;= l1; i++)
sum += a[i];
// 中间完整段
for (int i = p; i &amp;lt;= q; i++)
sum += s[i] + t[i] * m;
// 结尾零碎段
for (int i = r1 + 1; i &amp;lt;= r; i++)
sum += a[i];
return sum
}
&lt;/code>&lt;/pre>
&lt;/details>
&lt;br>
&lt;p>接下来我们考虑这个块的长度应当如何设计。我们容易发现块如果太大，那么一个修改/查询区间首尾的零碎部分就可能太长；如果块太小，那么一个修改/查询区间就可能包含太多的块。具体的，在块大小为 $m$ 时，整个数列被划分成 $n/m$ 个块，那么在一次操作中，首尾的零碎部分的求和复杂度可以达到 $2m = O(m)$，累加区间中间包含的整块的信息的时间复杂度最多可以达到 $O(n/m)$ ，于是有
$$
\arg\min_m \left\{O(m), O(n/m)\right\} \Longrightarrow m=\sqrt n
$$
所以，将块的大小定义在 $\sqrt n$ 附近是最合理的选择，这也是分块思想通常被称为“根号暴力”的原因。可以算出，在我们的分块算法下，解决原问题的复杂度被降低到了 $O(q\sqrt n)$。&lt;/p>
&lt;p>数列上分块是分块思想最常见的应用，但分块的应用不仅停留在这种线性结构上。从抽象层面说，如果你发现你可以将某一些东西绑成一个整体，然后将某些操作统一在整体层面，那么便可以考虑分块。分块被称为是一种优雅的暴力，因为它没有复杂的数据结构设计，看上去就是一些加加减减，便举重若轻地解决了问题。&lt;/p></description></item><item><title>【问题求解II-HW2.A】取数游戏</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-2-a/</link><pubDate>Fri, 07 Apr 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-2-a/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;p>给定一个长度为 $2n$ 的数列，保证所有数的和是奇数。两个人轮流取数，每次只能取数列的第一个或最后一个数。问双方都采取最优策略的情况下谁能获得胜利。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>为了说明这道题目的合理性，我们介绍策梅洛定理 (以下是摘自 Wikipedia 的解释)：&lt;/p>
&lt;blockquote>
&lt;p>In game theory, Zermelo&amp;rsquo;s theorem is a theorem about finite two-person games of perfect information in which the players move alternately and in which chance does not affect the decision making process. It says that if the game cannot end in a draw, then one of the two players must have a winning strategy (i.e. can force a win).&lt;/p>
&lt;/blockquote>
&lt;p>简单来说，一个双方轮流行动的游戏如果满足&lt;/p>
&lt;ul>
&lt;li>在有限步内结束。&lt;/li>
&lt;li>场上所有信息对双方公开。&lt;/li>
&lt;li>没有随机因素。&lt;/li>
&lt;li>没有平局。&lt;/li>
&lt;/ul>
&lt;p>那么必然存在先手必胜策略或后手必胜策略。常见的棋类竞技运动如象棋、围棋都是有必胜策略的 (只不过搜索空间太大人们找不到)；当然飞行棋没有必胜策略，因为扔骰子这件事情带来了随机因素。&lt;/p>
&lt;hr>
&lt;p>回到这道题，如果撇除那条充满诱导性的提示，大家很可能想到的是使用如下的一个递推过程计算谁必胜：令 $s(l, r)$ 表示用区间 $[l, r)$ 中的数玩这个游戏，先手拿到的数的和最多可以比后手多多少。那么有&lt;/p>
&lt;p>$$
s(l, r)=\begin{cases}
0, &amp;amp;l=r\\
\max(\\
\qquad s(l+2, r) + a_l - a_{l+1},\\
\qquad s(l+1, r-1) + a_l - a_{r-1},\\
\qquad s(l+1, r-1) + a_{r-1} - a_l,\\
\qquad s(l, r-2) + a_{r-1} - a_{r-2}\\
), &amp;amp;l &amp;lt; r
\end{cases}
$$&lt;/p>
&lt;p>这个公式看上去复杂，但其实只是枚举了两个人从数列里各取一个数的四种情况： (头,头)，(头,尾)，(尾,头)，(尾,尾)。如果我们认为区间的长度代表这个问题的规模的话，那么我们的递推公式就成功将大规模的问题转化为了小规模的同质问题。如果按照区间长度从小到大的顺序递推，你可以在 $O(n^2)$ 的时间复杂度内计算出 $s(1,2n+1)$，并根据 $s(1,2n+1)$ 的正负性判断谁必胜。&lt;/p>
&lt;hr>
&lt;p>上面的过程本质上是一个&lt;strong>动态规划 (dynamic programming)&lt;/strong>，如果你没有完全看懂也不要紧 (毕竟还没学)。我们出这道题的根本目的是为了展示一个更妙的想法：&lt;/p>
&lt;p>称 kk 取一个数，ff 再取一个数的两次操作为一轮。考虑每个数在原数列中的下标的奇偶性，我们容易发现：因为任意时刻剩余的数列一定是原数列的一个连续的子区间，所以每一轮开始时，剩余的数列在原数列中的位置一定是 “奇偶奇偶……” 或者 “偶奇偶奇……”。此时 kk 有着挑选奇偶的权利：如果 kk 拿走奇数位置的数，那么 ff 只能从两个偶数位置的数里选一个，反之亦然。&lt;/p>
&lt;p>因为每一轮 kk 都有挑选奇偶的权利，所以 kk 有能力拿走原数列中所有奇数位置的数，或者原数列中所有偶数位置的数。而“所有奇数位置的数的和”与“所有偶数位置的数的和”必定有一个更大，所以 kk 一定能把大的那组拿走，把小的留给 ff。所以这个游戏 kk 必胜。&lt;/p>
&lt;p>这道题旨在展示：通过充分动脑筋，一个看似复杂的问题可以解得非常简单。&lt;/p></description></item><item><title>【问题求解II-HW1.C】新马走日</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-1-c/</link><pubDate>Thu, 30 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/2-1-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;p>给定一个 $n\times m$ 的棋盘和象棋棋子马的初始位置，问经过 $k$ 步到达棋盘上任意点的方案数。&lt;/p>
&lt;p>$n,m\leq 15, k\leq 10^5$。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>邻接矩阵是描述图的一种常见方法：$\text{ga}(i,j)=1$ 当且仅当图中点 $i,j$ 之间有边。它之所以被称为“矩阵”，是因为矩阵的乘法恰好可以用于描述图上的游走。&lt;/p>
&lt;p>令 $A_k$ 为一个 $n$ 阶矩阵，其中 $A_k(i,j)$ 表示从点 $i$ 走 $k$ 步到点 $j$ 的方案数，那么我们可以发现 $A_0=I,A_1=\text{ga}$。更一般地，我们可以发现&lt;/p>
&lt;p>$$
\forall p, q. A_{p+q}=A_p\cdot A_q
$$&lt;/p>
&lt;blockquote>
&lt;p>Proof: 考虑任意点对 $i,j$，从 $i$ 走 $p+q$ 步到 $j$ 可以被分解成两个阶段：&lt;/p>
&lt;ul>
&lt;li>从 $i$ 走 $p$ 步到某一个节点 $k$。&lt;/li>
&lt;li>从 $k$ 走 $q$ 步到 $j$。&lt;/li>
&lt;/ul>
&lt;p>因此我们可以枚举这个中间节点 $k$，再根据加法/乘法原理有如下递推式：
$$
A_{p+q}(i,j)=\sum_{k=1}^n A_p(i,k)\cdot A_q(k,j)
$$&lt;/p>
&lt;p>容易发现这恰好是矩阵乘法的计算公式，从而原命题得证。$\square$&lt;/p>
&lt;/blockquote>
&lt;p>因此，$A_k=\text{ga}^k$。至于如何将原问题转化为一个图上的游走问题，想必你能够自己解决。&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><item><title>Homogeneous Linear Recurrences: An Algorithmic Perspective</title><link>https://chenhn02.github.io/posts/recurrences/</link><pubDate>Sat, 18 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/posts/recurrences/</guid><description>&lt;ul>
&lt;li>
&lt;a href="#getting-started-fibonacci">Getting Started: Fibonacci&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#a-variation-of-fibonacci">A Variation of Fibonacci&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#when-it-comes-to-higher-orders">When It Comes to Higher Orders…&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#an-eigendecomposition-approach">An “Eigendecomposition” Approach&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#fibonacci-review-i">Fibonacci: Review I&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#cayley-hamilton-theorem">Cayley-Hamilton Theorem&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#the-characteristic-polynomial-for-homogeneous-linear-recurrence-matrices">The Characteristic Polynomial For Homogeneous Linear Recurrence Matrices&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#magical-decomposition">Magical Decomposition&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#summary--complexity-analysis">Summary &amp;amp; Complexity Analysis&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#a-generatingfunctionology-approach">A “Generatingfunctionology” Approach&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#fibonacci-review-ii">Fibonacci: Review II&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#generating-function-equation-for-general-linear-recurrences">Generating Function Equation for General Linear Recurrences&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#an-even-odd-separation-idea">An Even-Odd Separation Idea&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#summary--complexity-analysis-1">Summary &amp;amp; Complexity Analysis&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#conclusion--misc">Conclusion &amp;amp; Misc&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#references">References&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>The post focuses on solving homogeneous linear recurrences quickly with the aid of computers. Specifically, we are interested in figuring out the $n$-th term of sequences with the following recurrence formula:&lt;/p>
&lt;p>$$
f_n=\sum_{k=1}^m c_kf_{n-k}
$$&lt;/p>
&lt;p>Since the answer may be too large, we usually focus on $f&amp;rsquo;_n=f_n\mod M$. We are more interested in cases where $M$ is suitable for FFT, such as $998,244,353$, because polynomial-related techniques can be leveraged to accelerate the computation. In the whole passage, we will use $f_n$ (instead of $f&amp;rsquo;_n$) to represent the answer under modulation for simplicity.&lt;/p>
&lt;h2 id="getting-started-fibonacci">Getting Started: Fibonacci&lt;/h2>
&lt;p>Let’s start with one of the simplest (and most famous) sequences: Fibonacci.&lt;/p>
&lt;p>$$
F_n=\begin{cases}0&amp;amp;, n=1\\1&amp;amp;, n=2\\F_{n-1}+F_{n-2}&amp;amp;, n\geq 2\end{cases}
$$&lt;/p>
&lt;p>Computing $F_n$ in $O(n)$ time complexity is trivial. But when $n$ is extremely large, say, $\geq 10^9$, it’s hard for general computers to get the answer within seconds. One of the most common approaches to solving such problems is using matrices.&lt;/p>
&lt;p>Matrices are good at describing linear relationships, and the recurrence formula of Fibonacci happens to be linear. Therefore we can rewrite it in matrix language:&lt;/p>
&lt;p>$$
\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}\begin{pmatrix}F_{n-1}\\F_n\end{pmatrix}=\begin{pmatrix}0\cdot F_{n-1}+1\cdot F_n\\1\cdot F_{n-1}+1\cdot F_n\end{pmatrix}=\begin{pmatrix}F_n\\F_{n+1}\end{pmatrix}
$$&lt;/p>
&lt;p>$\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}$is a good matrix for “pushing” the computation of $F_n$ by 1. Although this seems trivial, when we repeatedly use the matrix to push forward our computation, things become interesting:&lt;/p>
&lt;p>$$
\begin{pmatrix}F_n\\F_{n+1}\end{pmatrix}=\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}\cdots\left(\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}\left(\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}\begin{pmatrix}F_0\\F_1\end{pmatrix}\right)\right)
$$&lt;/p>
&lt;p>Thanks to the associativity of matrix multiplication, we can neatly rewrite the expression as&lt;/p>
&lt;p>$$
\begin{pmatrix}F_n\\F_{n+1}\end{pmatrix}=\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}^n\begin{pmatrix}F_0\\F_1\end{pmatrix}
$$&lt;/p>
&lt;p>So far, we have not simplified the computation of $F_n$, and matrix multiplications even place more burden on the computer. However, we have powerful techniques to deal with exponents: repeated squaring, i.e., denote the transformation matrix as $M$, we first compute $M^2$, then compute $M^4$ by taking the square of $M^2$, and so on. In this way, we can compute $\begin{pmatrix}0 &amp;amp; 1\\1 &amp;amp; 1\end{pmatrix}^n$in $O(\log n)$ complexity, and finally by applying it to the initial vector $\begin{pmatrix}F_0\\F_1\end{pmatrix}$we’ll get $F_n$.&lt;/p>
&lt;p>If you’re an experienced math learner, you must have known several ways to directly compute the general term formula of the Fibonacci sequence and may complain about this “stupid” matrix approach. However, the general term formula containing irrational coefficients is unfriendly for numerical computations. I’m not saying that those approaches are useless - instead, in the following sections we’ll come back to the basic Fibonacci example again and again and see how advanced math gives insights into efficient algorithm design.&lt;/p>
&lt;h3 id="a-variation-of-fibonacci">A Variation of Fibonacci&lt;/h3>
&lt;p>Before delving deeper into the problem, let&amp;rsquo;s consider a slightly varied version of the Fibonacci sequence:&lt;/p>
&lt;p>$$
F_n=\begin{cases}0&amp;amp;, n=1\\1&amp;amp;, n=2\\F_{n-1}+F_{n-2}+n&amp;amp;, n\geq 2\end{cases}
$$&lt;/p>
&lt;p>Rigorously, this is not a homogeneous linear recurrence, but we list it here because it’s a nice example of designing transformation matrices. The only difference is the additional $+n$ when $n\geq 2$, but it seems much more challenging because we cannot represent $n$ as the linear combination of $F_{n-1}$ and $F_{n-2}$.&lt;/p>
&lt;p>A general insight is that we can include more terms in the “state vector” ( in the standard Fibonacci sequence it’s $\begin{pmatrix}F_{n} &amp;amp; F_{n+1}\end{pmatrix}$). Apart from necessary terms in the sequence, we can add “helper terms” to help us find the linear combinations of wanted expressions. Let’s try adding $n$ into the state vector:&lt;/p>
&lt;p>$$
\begin{pmatrix}F_{n-2}\\F_{n-1}\\n\end{pmatrix}\overset{which\ matrix?}{\Longrightarrow}\begin{pmatrix}F_{n-1}\\F_{n}\\n+1\end{pmatrix}
$$&lt;/p>
&lt;p>Unfortunately, getting $n+1$ from $n$ is impossible because we have nobody to borrow the “1” from - but why don’t we add constant 1 into our state vector as well?&lt;/p>
&lt;p>$$
\begin{pmatrix}0 &amp;amp; 1 &amp;amp; 0 &amp;amp; 0\\1 &amp;amp; 1 &amp;amp; 1 &amp;amp; 0\\0 &amp;amp; 0 &amp;amp; 1 &amp;amp; 1\\0 &amp;amp; 0 &amp;amp; 0 &amp;amp; 1\end{pmatrix}\begin{pmatrix}F_{n-2}\\F_{n-1}\\n\\1\end{pmatrix}=\begin{pmatrix}F_{n-1}\\F_n\\n+1\\1\end{pmatrix}
$$&lt;/p>
&lt;p>Once we have the transformation matrix of a sequence, the rest is simple: do matrix multiplications by repeated squaring. To solve this Fibonacci variation, we need a matrix with rank 4, even if it&amp;rsquo;s a second-order recurrence. There&amp;rsquo;s no fixed procedure for devising such a matrix - in some subtle cases, a little human wisdom is necessary.&lt;/p>
&lt;h2 id="when-it-comes-to-higher-orders">When It Comes to Higher Orders…&lt;/h2>
&lt;p>The examples shown above are all constant-order recurrences. In the Fibonacci example, we claim that the time complexity is $O(\log n)$ because the influence from the rank is negligible. However, if we’re confronted with recurrences with higher orders, say $k$, the time complexity of the matrix approach should be $O(k^3\log n)$ (we don’t take into account algorithms like Strassen’s multiplication because they often come with huge constants), which is prohibitively high even if $k$ is only a few thousand.&lt;/p>
&lt;p>The journey has just begun! The following sections introduce 2 directions for optimization: one is related to the characteristic polynomial of matrices, and the other leverages generating functions to tackle the issue.&lt;/p>
&lt;h2 id="an-eigendecomposition-approach">An “Eigendecomposition” Approach&lt;/h2>
&lt;h3 id="fibonacci-review-i">Fibonacci: Review I&lt;/h3>
&lt;p>A common approach to optimizing matrix powering is eigendecomposition, i.e., for a matrix $M$ with rank $n$, if $M$ has $n$ independent eigenvectors, then $M$ is diagonalizable: $M=S\Lambda S^{-1}$, where $\Lambda$ is a diagonal matrix consisting of eigenvalues and $S$ is a matrix consisting of (column) eigenvectors. With this diagonalized form, powering becomes super easy: $M^n=(S\Lambda S^{-1})^n=S\Lambda^nS^{-1}$.&lt;/p>
&lt;p>Come back to the Fibonacci example, the only thing left is to compute the eigenvalues of the transformation matrix. For $2\times 2$ matrices, solving the following quadratic characteristic equation is simple:&lt;/p>
&lt;p>$$
M=\begin{pmatrix}0&amp;amp;1\\1&amp;amp;1\end{pmatrix}\qquad\qquad
\begin{align*}f(\lambda)&amp;amp;=\det(M-\lambda I)=0\\&amp;amp;\Longrightarrow\lambda_{1,2}=\frac{1\pm\sqrt 5}{2},\mathbf{x}=\begin{pmatrix} \lambda\\1\end{pmatrix}\end{align*}
$$&lt;/p>
&lt;p>Therefore,&lt;/p>
&lt;p>$$
\begin{align*}\begin{pmatrix}F_n\\F_{n+1}\end{pmatrix}&amp;amp;=\begin{pmatrix}0&amp;amp;1\\1&amp;amp;1\end{pmatrix}^n\begin{pmatrix}F_0\\F_1\end{pmatrix}\\&amp;amp;=\left[\begin{pmatrix}\mathbf{x}_1&amp;amp;\mathbf{x}_2\end{pmatrix}\begin{pmatrix}\lambda_1 &amp;amp; \\&amp;amp;\lambda_2\end{pmatrix}\begin{pmatrix}\mathbf{x}_1&amp;amp;\mathbf{x}_2\end{pmatrix}^{-1}\right]^n\begin{pmatrix}F_0\\F_1\end{pmatrix}\\&amp;amp;=\begin{pmatrix}\mathbf{x}_1&amp;amp;\mathbf{x}_2\end{pmatrix}\begin{pmatrix}\lambda_1^n&amp;amp;\\&amp;amp;\lambda_2^n\end{pmatrix}\begin{pmatrix}\mathbf{x}_1&amp;amp;\mathbf{x}_2\end{pmatrix}^{-1}\begin{pmatrix}F_0\\F_1\end{pmatrix}\end{align*}
$$&lt;/p>
&lt;h3 id="cayley-hamilton-theorem">Cayley-Hamilton Theorem&lt;/h3>
&lt;p>Unfortunately, when it comes to transformation matrices with arbitrary rank, it’s difficult to solve high-order characteristic equations and irrational eigenvalues are hard for numerical computations. However, the general idea still works: we should find nice properties related to characteristic polynomials and try to do decomposition.&lt;/p>
&lt;p>We introduce the Cayley-Hamilton theorem, which describes an interesting property of square matrices.&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>$\fbox{Theorem}$ &lt;strong>(Cayley-Hamilton)&lt;/strong>&lt;/p>
&lt;p>In linear algebra, the Cayley-Hamilton theorem states that every square matrix over a commutative ring satisfies its own characteristic equation, i.e., denote $f(\lambda)=\det(\lambda I-A)$ as the characteristic polynomial of $A$, then $f(A)=O$.&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>Beginners may regard this theorem as trivial by the observation that $\det(AI-A)=0$. However, this “bogus proof” is totally ungrounded because $f(A)$ is a matrix, not a scalar. Since it’s not a rigorous math post, we’ll not prove this theorem in a detailed manner from scratch. Instead, we give the following lemma without proof:&lt;/p>
&lt;p>$\fbox{Lemma}$ Diagonalizable matrices are dense in the space of all matrices.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Proof of Cayley-Hamilton theorem&lt;/strong>&lt;/p>
&lt;p>We start with diagonalizable matrices. For an arbitrary diagonalizable matrix $A$ with rank $n$, it has $n$ independent eigenvectors satisfying $A\mathbf{x_i}=\lambda_i\mathbf{x_i}$. Consider $f(A)\mathbf{x_i}$, suppose $f(\lambda)$ is a polynomial with coefficient $c_i$ for term $\lambda^i$, then&lt;/p>
&lt;p>$$
\begin{align*}f(A)\mathbf{x_i}&amp;amp;=\left(\sum_{k=0}^nc_kA^k\right)\mathbf{x_i}=\sum_{k=0}^nc_k(A^k\mathbf{x_i})\\&amp;amp;=\sum_{k=0}^nc_k(\lambda_i^k\mathbf{x_i})\\&amp;amp;=\left(\sum_{k=0}^nc_k\lambda_i^k\right)\mathbf{x_i}\\&amp;amp;=f(\lambda_i)\mathbf{x_i}=\mathbf{0}\end{align*}
$$&lt;/p>
&lt;p>Therefore, for each eigenvector $\mathbf{x_i}$, $f(A)\mathbf{x_i}=\mathbf{0}$. By combining eigenvectors to a matrix $S=\begin{pmatrix}\mathbf{x_1}&amp;amp;\cdots&amp;amp;\mathbf{x_n}\end{pmatrix}$, we have $f(A)S=O$. Since $S$ is a full-rank matrix, the only possibility is that $f(A)=O$.&lt;/p>
&lt;p>Now let’s move to non-diagonalizable matrices, denoted as $D$. According to the density of diagonalizable matrices in the whole matrix space, there exists a matrix $H$ such that a cluster of matrices $D_t=D+tH$, $t\in (-1, 0)\cup (0, 1)$, are diagonalizable. Since $D_t$ is continuous on $t$, $f_{D_t}(D_t)$ is also continuous on $t$, therefore&lt;/p>
&lt;p>$$
f(D)=\lim_{t\to 0}f_{D_t}(D_t)=O
$$&lt;/p>
&lt;/blockquote>
&lt;h3 id="the-characteristic-polynomial-for-homogeneous-linear-recurrence-matrices">The Characteristic Polynomial For Homogeneous Linear Recurrence Matrices&lt;/h3>
&lt;p>For homogeneous linear recurrence transformation matrices, characteristic polynomial $f$ is easy to compute: according to the recurrence formula, we have&lt;/p>
&lt;p>$$
A=\begin{pmatrix}0&amp;amp; 1\\&amp;amp; &amp;amp; 1\\&amp;amp; &amp;amp; &amp;amp; \ddots\\&amp;amp; &amp;amp; &amp;amp; &amp;amp; 1\\c_1 &amp;amp; c_2 &amp;amp; c_3 &amp;amp; \cdots &amp;amp; c_k\end{pmatrix}
$$&lt;/p>
&lt;p>Then&lt;/p>
&lt;p>$$
f(\lambda)=\det(\lambda I-A)=\begin{vmatrix}\lambda &amp;amp; -1\\&amp;amp; \lambda &amp;amp; -1\\&amp;amp; &amp;amp; \ddots&amp;amp; \ddots\\&amp;amp; &amp;amp; &amp;amp; \lambda &amp;amp; -1\\-c_1 &amp;amp; -c_2 &amp;amp; -c_3&amp;amp;\cdots&amp;amp;\lambda-c_k\end{vmatrix}
$$&lt;/p>
&lt;p>Expanding the determinant by the last row, we’ll observe that every minor is easy to compute: $A_{k,i}=(-1)^{k-i}\lambda^{i-1}$. Therefore&lt;/p>
&lt;p>$$
\begin{align*}f(\lambda)&amp;amp;=(-1)^{2k}(\lambda-c_k)\lambda^{k-1}+\sum_{i=1}^{k-1}(-1)^{k+i}\cdot ((-1)^{k-i}\lambda^{i-1})\cdot (-c_i)\\&amp;amp;=\lambda^k-\sum_{i=1}^kc_i\lambda^{i-1}\end{align*}
$$&lt;/p>
&lt;h3 id="magical-decomposition">Magical Decomposition&lt;/h3>
&lt;p>It’s still unclear how Cayley-Hamilton theorem can help in optimization. The following approach was proposed by Fiduccia in 1985 [2]. For an arbitrary transformation matrix $A$ with rank $k$, consider the following decomposition:&lt;/p>
&lt;p>$$
x^n=f(x)g(x)+r(x)
$$&lt;/p>
&lt;p>where $f$ is the characteristic polynomial of $A$ and $g(x)/r(x)$ can be computed through polynomial division/modulation. In fact, we are more interested in $r(x)$ because when we let $x=A$:&lt;/p>
&lt;p>$$
A^n=f(A)g(A)+r(A)\overset{\text{Cayley-Hamilton}}{=}r(A)
$$&lt;/p>
&lt;p>It seems that we haven’t made solid progress because $r$ is a $k$-order polynomial and computing $r(A)$ by brute force requires $O(k^4)$ effort. However, suppose $\displaystyle r(x)=\sum_{i=0}^{k-1}c_ix^i$, actually we’re able to compute $F_n$ in $O(k)$ thanks to the following deduction:&lt;/p>
&lt;p>$$
\begin{align*}\begin{pmatrix}F_n\\F_{n+1}\\\vdots\\F_{n+k-1}\end{pmatrix}&amp;amp;=A^n\begin{pmatrix}F_0\\F_1\\\vdots\\F_{k-1}\end{pmatrix}=R(A)\begin{pmatrix}F_0\\F_1\\\vdots\\F_{k-1}\end{pmatrix}\\&amp;amp;=\left(\sum_{i=0}^{k-1}c_iA^i\right)\begin{pmatrix}F_0\\F_1\\\vdots\\F_{k-1}\end{pmatrix}\\&amp;amp;=\sum_{i=0}^{k-1}c_i\left(A^i\begin{pmatrix}F_0\\F_1\\\vdots\\F_{k-1}\end{pmatrix}\right)\\&amp;amp;=\sum_{i=0}^{k-1}c_i\begin{pmatrix}F_i\\F_{i+1}\\\vdots\\F_{i+k-1}\end{pmatrix}\end{align*}
$$&lt;/p>
&lt;p>Therefore&lt;/p>
&lt;p>$$
F_n=\sum_{i=0}^{k-1}c_iF_i
$$&lt;/p>
&lt;h3 id="summary--complexity-analysis">Summary &amp;amp; Complexity Analysis&lt;/h3>
&lt;p>Let’s have a quick summary of the whole procedure and analyze the complexity:&lt;/p>
&lt;ol>
&lt;li>Construct a transformation matrix $A$ according to the recurrence formula.&lt;/li>
&lt;li>Compute the characteristic polynomial $f_A$.&lt;/li>
&lt;li>Compute $r_A(x)=x^n\text{ mod }f_A$.&lt;/li>
&lt;li>Compute $F_n$ according to the coefficients in $r_A$.&lt;/li>
&lt;/ol>
&lt;p>The main complexity lies in the third step. Since $n$ is extremely large, it’s impossible to directly do polynomial modulation. Instead, we can leverage repeated squaring:&lt;/p>
&lt;p>$$
x\Rightarrow [x^2]=(x)^2 \text{ mod }f_A\Rightarrow [x^4]=[x^2]^2\text{ mod }f_A\Rightarrow \cdots \Rightarrow x^n\text{ mod }f_A
$$&lt;/p>
&lt;p>In this way, we need to conduct $O(\log n)$ times polynomial multiplication and modulation, which gives a $O(k^2\log n)$ complexity. If the problem is considered in a ring that supports Fast Fourier Transformation (typically an NTT ring), we can further optimize it to $O(k\log k\log n)$.&lt;/p>
&lt;h2 id="a-generatingfunctionology-approach">A “Generatingfunctionology” Approach&lt;/h2>
&lt;h3 id="fibonacci-review-ii">Fibonacci: Review II&lt;/h3>
&lt;p>Generating function is always a powerful weapon for tackling sequence problems. Consider&lt;/p>
&lt;p>$$
G(x)=\sum_{n=0}^\infty F_nx^n
$$&lt;/p>
&lt;p>Since $F_n=F_{n-1}+F_{n-2}$ when $n\geq 2$, we can get&lt;/p>
&lt;p>$$
G(x)=F_0+F_1x+xG(x)+x^2G(x)=x+(x+x^2)G(x)
$$&lt;/p>
&lt;p>Therefore $\displaystyle G(x)=\frac{x}{1-x-x^2}=\frac{1}{\sqrt 5}\left(\frac{1}{1-\phi x}-\frac{1}{1-\hat\phi x}\right)$, where $\phi=\displaystyle \frac{1+\sqrt 5}{2}$ and $\displaystyle \hat\phi=\frac{1-\sqrt 5}{2}$. Expand it to Taylor series, we have&lt;/p>
&lt;p>$$
\begin{align*}G(x)&amp;amp;=\frac{1}{\sqrt 5}\left(\sum_{n\geq 0}(\phi x)^n-\sum_{n\geq 0}(\hat\phi x)^n\right)\\&amp;amp;=\sum_{n\geq 0}\left(\frac{1}{\sqrt 5}(\phi^n-\hat\phi^n)\right)x^n\end{align*}
$$&lt;/p>
&lt;p>Thus $\displaystyle F_n=\frac{1}{\sqrt 5}(\phi^n-\hat\phi^n)$.&lt;/p>
&lt;h3 id="generating-function-equation-for-general-linear-recurrences">Generating Function Equation for General Linear Recurrences&lt;/h3>
&lt;p>However, when it comes to recurrences with arbitrary order, techniques like Taylor expansion will lose its power for computers. Anyway, let’s figure out the equation first. Put the coefficients of linear recurrence into a generation function, denoted as $G(x)$, i.e.,&lt;/p>
&lt;p>$$
G(x)=\sum_{i=1}^kc_ix^i
$$&lt;/p>
&lt;p>we have&lt;/p>
&lt;p>$$
F(x)=F(x)G(x)+R(x)
$$&lt;/p>
&lt;p>Where $R(x)$ is a polynomial for correcting the first few terms in the sequence and can be obtained by computing $F&amp;rsquo;(x)-F&amp;rsquo;(x)G(x)$, where $F&amp;rsquo;(x)$ is the first $k$ terms of $F(x)$.&lt;/p>
&lt;p>Therefore&lt;/p>
&lt;p>$$
F(x)=\frac{R(x)}{1-G(x)}
$$&lt;/p>
&lt;h3 id="an-even-odd-separation-idea">An Even-Odd Separation Idea&lt;/h3>
&lt;p>Since $n$ is extremely huge, computing the polynomial inverse of $1-G(x)$ and then computing $[x^n]F(x)$ through polynomial multiplication is unreasonable. We need a quick way to compute&lt;/p>
&lt;p>$$
[x^n]F(x)=[x^n]\frac{P(x)}{Q(x)}
$$&lt;/p>
&lt;p>where $P(x)$ and $Q(x)$ stands for $R(x)$ and $1-G(x)$ in this case.&lt;/p>
&lt;p>Consider the following transformation:&lt;/p>
&lt;p>$$
F(x)=\frac{P(x)}{Q(x)}=\frac{P(x)Q(-x)}{Q(x)Q(-x)}
$$&lt;/p>
&lt;p>$Q(x)Q(-x)$ is an even function. Let $U(x^2)=Q(x)Q(-x)$, and divide $P(x)Q(-x)$ into two parts according to the parity of $x^k$, we have&lt;/p>
&lt;p>$$
F(x)=\frac{xOdd(x^2)}{U(x^2)}+\frac{Even(x^2)}{U(x^2)}
$$&lt;/p>
&lt;p>Thanks to the fact that $U(x^2)$ only contains even powers, $F(x)$ is successfully separated as an odd part and an even part, and we can choose the part containing $x^n$ (according to the parity of $n$) to continue our computation.&lt;/p>
&lt;h3 id="summary--complexity-analysis-1">Summary &amp;amp; Complexity Analysis&lt;/h3>
&lt;p>Let’s have a quick summary of the whole procedure and analyze the complexity:&lt;/p>
&lt;ol>
&lt;li>Compute $R(x)$ and $G(x)$.&lt;/li>
&lt;li>Compute $P(x)Q(-x)$ and $Q(x)Q(-x)$ respectively. Choose the part with the same parity as $n$.&lt;/li>
&lt;li>Repeatedly conduct step 2 until the polynomial becomes a constant one.&lt;/li>
&lt;/ol>
&lt;p>Step 2 contains polynomial multiplications, which requires $O(k^2)$/$O(k\log k)$ depending on whether FFT is supported. Every time we conduct step 2, the formal variable of the generating functions grow from $x$ to $x^2$. Therefore the power grows exponentially and we can terminate within $O(\log n)$ iterations. So the overall complexity is $O(k^2\log n)$/$O(k\log k\log n)$.&lt;/p>
&lt;p>It’s worth noting that although the “generatingfunctionology” approach has the same time complexity as the previous one, it requires simpler polynomial techniques (only multiplication). The paper [3] argues that this approach improves the result of Fiduccia on constant factors.&lt;/p>
&lt;h2 id="conclusion--misc">Conclusion &amp;amp; Misc&lt;/h2>
&lt;p>The post introduces several kinds of techniques for tackling homogeneous linear recurrence problems. Matrix multiplication with repeated squaring optimizes the complexity from the most trivial $O(kn)$ to $O(k^3\log n)$, and the following 2 approaches leverage linear algebra/generating function to further improve it to $O(k^2\log n)$/$O(k\log k\log n)$. Readers can refer to the papers for deeper understanding.&lt;/p>
&lt;p>I learned most of the techniques mentioned above when I was taking part in competitive programming contests during high school. There was no detailed tutorial on the Internet at that time and the knowledge are spread through word of mouth. I would be most happy if the post can bring the wisdom of TCS researchers to more people in the world.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>[1]
&lt;a href="https://en.wikipedia.org/wiki/Cayley%E2%80%93Hamilton_theorem" target="_blank" rel="noopener">Cayley-Hamilton Theorem&lt;/a>. &lt;em>Wikipedia&lt;/em>.&lt;/li>
&lt;li>[2] C. M. Fiduccia.
&lt;a href="https://doi.org/10.1137/0214007" target="_blank" rel="noopener">An efficient formula for linear recurrences&lt;/a>. &lt;em>SIAM Journal on Computing&lt;/em>,
14(1):106–112, 1985.&lt;/li>
&lt;li>[3] Bostan, A., &amp;amp; Mori, R. (2020).
&lt;a href="https://arxiv.org/abs/2008.08822" target="_blank" rel="noopener">A Simple and Fast Algorithm for Computing the N-th Term of a Linearly Recurrent Sequence&lt;/a>. &lt;em>ArXiv.&lt;/em>&lt;/li>
&lt;/ul></description></item><item><title>快速幂</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/repeatedsquare/</link><pubDate>Sat, 18 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/repeatedsquare/</guid><description>&lt;p>给定 $a,n$，考虑如何计算 $a^n$ 。如果使用如下循环计算结果&lt;/p>
&lt;pre>&lt;code class="language-c++">int res = 1;
for (int i = 1; i &amp;lt;= n; i++)
res = res * a;
&lt;/code>&lt;/pre>
&lt;p>时间复杂度为 $O(n)$，当 $n$ 很大时效率太低。快速幂是用于计算该类问题的常见算法，其本质思想是递归/倍增:&lt;/p>
&lt;p>$$
a^n=\begin{cases}1&amp;amp;,n=0\\(a^{n/2})^2&amp;amp;,n是偶数, n\geq 1\\(a^{\lfloor n/2\rfloor})^2\cdot a&amp;amp;,n是奇数, n\geq 1\end{cases}
$$&lt;/p>
&lt;p>因此我们可以通过不断折半幂次的方式来求解 $a^n$。写出该算法的复杂度递归表达式：&lt;/p>
&lt;p>$$
T(n)=T(n/2)+O(1)
$$&lt;/p>
&lt;p>容易得出 $T(n)=O(\log n)$。&lt;/p>
&lt;p>直接根据递归式，我们容易写出如下递归代码:&lt;/p>
&lt;pre>&lt;code class="language-c++">int quick_pow(int a, int n)
{
if (n == 0) return 1;
res = quick_pow(a, n / 2);
if (n % 2 == 0)
return res * res;
else
return res * res * a;
}
&lt;/code>&lt;/pre>
&lt;p>但我们通常使用一种更加优美的写法来使用循环替代递归：&lt;/p>
&lt;pre>&lt;code class="language-c++">int quick_pow(int a, int n)
{
int res = 1;
while (n)
{
if (n &amp;amp; 1) res = res * a;
a = a * a; n &amp;gt;&amp;gt;= 1;
}
return res;
}
&lt;/code>&lt;/pre>
&lt;p>该实现的核心思想是利用 $n$ 的二进制表示中 1 的位置来判断 $a^n$ 是由哪些 $a^{2^k}$ 组合而成的。即如果 $n$ 的二进制表示中在 $k_1,k_2,\cdots, k_m$ 这些位置上是 1，那么&lt;/p>
&lt;p>$$
a^n=a^{\sum_{i=1}^m2^{k_i}}=\prod_{i=1}^ma^{2^{k_i}}
$$&lt;/p></description></item><item><title>矩阵快速幂</title><link>https://chenhn02.github.io/courses/problemsolving/algorithms/matmul/</link><pubDate>Sat, 18 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/algorithms/matmul/</guid><description>&lt;p>关于基础理论，参见
&lt;a href="https://chenhn02.github.io/posts/recurrences/">这篇博文&lt;/a>。如果你之前从未了解过线性递推相关的知识/目前以完成OJ为主要目标，只需看懂该文章的 Getting Started 章节。本文主要对矩阵乘法和矩阵快速幂的实现给出一点建议。&lt;/p>
&lt;div class="alert alert-warning">
&lt;div>
&lt;p>&lt;strong>Don&amp;rsquo;t Panic!&lt;/strong>&lt;/p>
&lt;p>只有 &lt;code>Problem Solving&lt;/code> 目录下的内容才是大家需要且应当掌握的内容。链接中博文的大部分内容与问题求解课程并不相关，且我不认为掌握这些数学强相关的知识对计算机思维有太多提升。因此如果你不愿意读 Getting Started 之后的内容/读不懂后面的内容，这非常正常也无关紧要！&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>你可以参考
&lt;a href="https://chenhn02.github.io/courses/problemsolving/coding/matmul">这篇博文&lt;/a> 来学习矩阵快速幂实现的代码细节。&lt;/p></description></item><item><title>问题求解 I-Final 题解</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/1-final/</link><pubDate>Sun, 05 Mar 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/1-final/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem A: BrainF**k Syntax Checker&lt;/strong>&lt;/p>
&lt;p>题意概括：给定一个 BF 程序，判断它是否符合语法要求。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>BF 程序中的 &lt;code>+&lt;/code> &lt;code>-&lt;/code> &lt;code>,&lt;/code> &lt;code>.&lt;/code> &lt;code>&amp;gt;&lt;/code> &lt;code>&amp;lt;&lt;/code> 显然不会对程序的合法性造成影响，因此本题的主要任务是判断 BF 程序中的 &lt;code>[&lt;/code> &lt;code>]&lt;/code> 是否形成了合法的括号序列，即两两匹配。&lt;/p>
&lt;p>值得注意的是，BF 程序中 &lt;code>[&lt;/code> 和 &lt;code>]&lt;/code> 的个数相同并不是括号序列合法的充要条件，比如 &lt;code>][&lt;/code> 这个序列就是不合法的。在此基础上，我们还要保证 BF 程序的任意前缀中，&lt;code>[&lt;/code> 的个数要大于等于 &lt;code>]&lt;/code> 的个数。&lt;/p>
&lt;details>&lt;summary>&lt;b>Code&lt;/b> &lt;i>[Click to expand]&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
char program[1000];
string ValidChar = &amp;quot;+-,.&amp;lt;&amp;gt;[]&amp;quot;;
int main()
{
scanf(&amp;quot;%s&amp;quot;, program);
int len = strlen(program), LeftRightDelta = 0;
bool flag = true;
for (int i = 0; i &amp;lt; len; i++)
{
if (ValidChar.find(program[i]) == string::npos) flag = false;
if (program[i] == '[') LeftRightDelta++;
if (program[i] == ']') LeftRightDelta--;
if (LeftRightDelta &amp;lt; 0) flag = false;
}
puts((flag &amp;amp;&amp;amp; LeftRightDelta == 0) ? &amp;quot;Yes&amp;quot; : &amp;quot;No&amp;quot;);
return 0;
}
&lt;/code>&lt;/pre>
&lt;/details>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem B: BrainF**k Interpreter&lt;/strong>&lt;/p>
&lt;p>题意概括： 给定一个 BF 程序，模拟其运行过程并输出最后的内存状态。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>这是一道相当有意思的题目，解法也很多。该问题的核心难点是如何处理嵌套的中括号。考场上有很多同学利用递归处理中括号的嵌套，这是一个很好且可行的思路。我们在这里介绍另外一种不需要递归的思路，它书写起来更加简洁，且在一定程度上揭示了计算机运行的本质——大家在本学期的 &lt;em>数字逻辑与计算机组成&lt;/em> 和下学期的 &lt;em>计算机系统基础&lt;/em> 中会不断与这种逻辑打交道。&lt;/p>
&lt;p>在题面的提示中我们已经给出了计算机执行指令的四步骤，我们在这里再重复一遍：&lt;/p>
&lt;ul>
&lt;li>取指：取出下一条执行的指令。&lt;/li>
&lt;li>译码：根据指令的格式确定该指令的类型。&lt;/li>
&lt;li>执行：根据指令类型，执行该指令定义的动作。&lt;/li>
&lt;li>跳转：根据指令类型和执行过程，确定下一条应当执行的指令的位置。&lt;/li>
&lt;/ul>
&lt;p>真正的计算机处理的是“汇编语言”，我们的 BF 解释器处理的是 BF 语言，这两者在本质上没有区别。计算机内部通常有一个程序计数器 (program counter, 简称 pc)，它的功能和计数没什么关系，反而像一个指针，用来指向下一条要执行的指令。我们来具体地看看四个步骤对应到 BF 语言应当如何操作。&lt;/p>
&lt;ul>
&lt;li>
&lt;p>取指：这是最简单的一步，对应到 C++ 代码大概就是 &lt;code>char instruction = program[pc]&lt;/code>。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>译码&amp;amp;执行：这两步在 BF 中可以放在一起做 (因为 BF 的所有指令都没有“操作数”)，你大抵会用一个 if/switch 语句来判断 instruction 是 8 个符号中的哪一种并执行相应的操作。前 6 种指令的操作都非常简单，这里不再赘述。&lt;code>[&lt;/code> 和 &lt;code>]&lt;/code> 本质上是分支控制指令，所以在“执行”这个步骤中它们什么也不用做。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>跳转：前 6 种指令的跳转都非常简单：做完了就做紧接着的下一条指令，即 &lt;code>pc++&lt;/code>。我们着重讲解 &lt;code>[&lt;/code> 和 &lt;code>]&lt;/code> 的跳转如何处理：&lt;/p>
&lt;ul>
&lt;li>&lt;code>[&lt;/code>：左中括号指令的逻辑是
&lt;pre>&lt;code class="language-c++">if (memory[pointer] != 0)
pc++ // go into the loop
else
pc = (the position of the corresponding &amp;quot;]&amp;quot;) + 1 // skip the loop
&lt;/code>&lt;/pre>
&lt;code>pc&lt;/code> 直接加一意味着进入循环，&lt;code>pc&lt;/code> 跳转到对应的 &lt;code>]&lt;/code> 的下一条指令意味着跳过循环。&lt;/li>
&lt;li>&lt;code>]&lt;/code>：右中括号指令的逻辑是
&lt;pre>&lt;code class="language-c++">pc = (the position of the corresponding &amp;quot;[&amp;quot;)
&lt;/code>&lt;/pre>
遇到右中括号，无条件跳转到对应的左中括号，开始新一轮循环条件判断。&lt;/li>
&lt;/ul>
&lt;p>注意到 &lt;code>[&lt;/code> 和 &lt;code>]&lt;/code> 的处理都依赖与之匹配的“另一半”，因此在正式开始执行程序之前，我们要预处理一遍程序以知道与每个 &lt;code>[&lt;/code>/&lt;code>]&lt;/code> 配对的 &lt;code>]&lt;/code>/&lt;code>[&lt;/code> 在什么位置。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>按照这四个步骤书写本题的代码，可以完成得轻松且高效，从中你也可以领悟到计算机系统设计的伟大智慧。&lt;/p>
&lt;details>&lt;summary>&lt;b>Code&lt;/b> &lt;i>[Click to expand]&lt;/i>&lt;/summary>
&lt;p>注：下面参考程序仅仅实现了一个 BF 解释器，输入格式和输出格式与原题并不相同。&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
int memory[1000], pointer;
char program[1000]; int n;
int input_count, input[1000];
int jumpto[1000], stk[1000], stot;
int main ()
{
scanf(&amp;quot;%s&amp;quot;, program); n = strlen(program);
scanf(&amp;quot;%d&amp;quot;, &amp;amp;input_count);
for (int i = 1; i &amp;lt;= input_count; i++) scanf(&amp;quot;%d&amp;quot;, input + i);
// 预处理每个 [ 和 ] 对应的 ]/[
stot = 0;
for (int i = 0; i &amp;lt; n; i++)
{
if (program[i] == '[') stk[++stot] = i;
if (program[i] == ']')
{
jumpto[i] = stk[stot];
jumpto[stk[stot]] = i + 1;
stot--;
}
}
pointer = 0;
int pc = 0, input_pt = 0;
bool printed = false;
while (pc &amp;lt; n)
{
int next_pc = pc + 1;
switch (program[pc])
{
case '+': memory[pointer]++; break;
case '-': memory[pointer]--; break;
case '&amp;lt;': pointer--; break;
case '&amp;gt;': pointer++; break;
case ',': memory[pointer] = input[++input_pt]; break;
case '.': printf(&amp;quot;%d &amp;quot;, memory[pointer]); printed = true; break;
case '[':
if (memory[pointer] == 0)
next_pc = jumpto[pc];
break;
case ']': next_pc = jumpto[pc]; break;
}
pc = next_pc;
}
if (printed) puts(&amp;quot;&amp;quot;);
for (int i = 0; i &amp;lt; 10; i++)
printf(&amp;quot;%d &amp;quot;, memory[i]);
puts(&amp;quot;&amp;quot;);
return 0;
}
&lt;/code>&lt;/pre>
&lt;/details>
&lt;hr>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>Problem C: DXY&amp;rsquo;s Graph Problem&lt;/strong>&lt;/p>
&lt;p>题意概括：给定一张图 $G=(V, E)$，其中 $V$, $E$ 分别是点和边的集合。对于图中任意顶点 $v\in V$，令 $\text{cover}(v)\triangleq \{(x, y)\in E: x=v\vee y=v\}$ (即与该点相邻的所有边的集合)。问是否存在原图顶点的划分 $S_1, S_2$，满足 $S_1\uplus S_2=V$ (即 $S_1\cup S_2=V$ 且 $S_1\cap S_2=\emptyset$)，且&lt;/p>
&lt;p>$$
\bigcup_{v\in S_1}\text{cover}(v)=\bigcup_{v\in S_2}\text{cover}(v)=E
$$&lt;/p>
&lt;p>(注：本题完整题意里中文描述中有关“DXY可以通过自己的点集将原图恢复”的部分给大家带来了较大困扰，对此我们深表歉意。)&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>每条边都有两个端点，要想这条边被双方都覆盖到，那么这两个端点必须分属于不同的集合。因此可以成功划分的充要条件是：存在将整张图上的顶点黑白染色，且每条边的两个端点一黑一白的方案。&lt;/p>
&lt;p>我们容易发现这样的方案如果存在一定是唯一的，因为我们已经规定了 1 号点属于 DXY，我们不妨将白色分配给 DXY，那么整个过程相当于从一个白色的 1 号点开始向外搜索，每搜索到一个新的节点就给它涂上和邻居节点不同的颜色。由于整个图是连通的 (即任意两个节点都存在路径互相可达)，所以这样的搜索一定能给每个节点一个唯一的颜色 (如果你想要严谨地证明，可以考虑数学归纳法)。在搜索的过程中，如果发现无法解决的矛盾 (例如某个节点同时和一个黑点和白点相邻)，则问题无解。&lt;/p>
&lt;p>如果你愿意继续深究这个问题 (这部分知识对于解决这道题并无必要)，你会发现可以成功黑白染色充要条件是&lt;strong>图中不存在奇数长度的环&lt;/strong>，这也是判断二分图 (Bipartite Graph) 的方法，大家在后续的问题求解课程中会学习到这个概念和相关的证明。&lt;/p>
&lt;details>&lt;summary>&lt;b>Code&lt;/b> &lt;i>[Click to expand]&lt;/i>&lt;/summary>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
const int MAXN = 1e5 + 10;
int n, m;
vector&amp;lt;int&amp;gt; v[MAXN];
int color[MAXN];
bool flag;
void dfs(int cur)
{
for (auto neighbor : v[cur])
if (color[neighbor] == 0)
{
color[neighbor] = 3 - color[cur];
// 两种颜色用 1, 2 表示
// 3 - color[cur] 则表示“另一种颜色”
dfs(neighbor);
}
else if (color[neighbor] != 3 - color[cur])
flag = false;
}
int main ()
{
scanf(&amp;quot;%d%d&amp;quot;, &amp;amp;n, &amp;amp;m); int x, y;
for (int i = 1; i &amp;lt;= m; i++)
{
scanf(&amp;quot;%d%d&amp;quot;, &amp;amp;x, &amp;amp;y);
v[x].push_back(y); v[y].push_back(x);
}
color[1] = 1; flag = true;
dfs(1);
if (flag)
{
puts(&amp;quot;Yes&amp;quot;);
int cnt1 = 0, cnt2 = 0;
for (int i = 1; i &amp;lt;= n; i++) if (color[i] == 1) cnt1++; else cnt2++;
printf(&amp;quot;%d %d\n&amp;quot;, cnt1, cnt2);
for (int i = 1; i &amp;lt;= n; i++) if (color[i] == 1) printf(&amp;quot;%d &amp;quot;, i);
puts(&amp;quot;&amp;quot;);
for (int i = 1; i &amp;lt;= n; i++) if (color[i] == 2) printf(&amp;quot;%d &amp;quot;, i);
puts(&amp;quot;&amp;quot;);
}
else
puts(&amp;quot;No&amp;quot;);
return 0;
}
&lt;/code>&lt;/pre>
&lt;/details></description></item><item><title>ClothesNet: An Information-Rich 3D Garment Model Repository with Simulated Clothes Environment</title><link>https://chenhn02.github.io/publication/clothesnet/</link><pubDate>Fri, 03 Feb 2023 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/publication/clothesnet/</guid><description/></item><item><title>【问题求解I-HW6.C】万圣节的新娘</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/1-6-c/</link><pubDate>Wed, 14 Dec 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/1-6-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;p>给定 $n$ 个数对，保证 $1\cdots n$ 中的每个数恰好出现两次。每次操作可以任意交换两个数的位置。问至少多少次交换可以使得每对数都相同。&lt;/p>
&lt;p>约束条件：$n\leq 10^6$。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>这道题颇有思维难度，需要仔细观察并发现问题的性质。发现问题的性质不能靠双眼瞪着屏幕——动起手来，画几个样例手算出解法，很多时候解决问题的灵感就是从手算得出的。&lt;/p>
&lt;p>我们首先可以确定一件事：答案的上限是 $n-1$，因为每轮操作你总可以让某一个数对匹配起来。如果你手算尝试了一些样例，你一定会发现想要让交换次数最少，我们会格外喜欢这样的数对：&lt;/p>
&lt;pre>&lt;code class="language-plaintext">1 2
2 1
&lt;/code>&lt;/pre>
&lt;p>因为我们只要让上面的 2 和下面的 1 交换，我们就可以一下子得到两个匹配的数对，这看起来非常赚。但不是什么时候都能有“动一次成两对”这么赚的事情，你很快会发现有的时候格局可能是这样的：&lt;/p>
&lt;pre>&lt;code class="language-plaintext">1 2
2 3
3 5
5 8
8 1
&lt;/code>&lt;/pre>
&lt;p>通过观察你可以发现：这样的 5 个数对你只要将前 4 个搞定，最后一个也会随之搞定，且你无法给出比 4 次交换更好的方案。&lt;/p>
&lt;p>你是否觉得上面的 5 个数对像是 &lt;code>1, 2, 3, 5, 8&lt;/code> 五个数构成的环？事实上，稍加思索你便能发现 $n$ 个数对其实就是由这样的若干个互不影响的“环”组成的。每个环形如&lt;/p>
&lt;pre>&lt;code class="language-plaintext">a1 a2
a2 a3
...
an-1 an
an a1
&lt;/code>&lt;/pre>
&lt;p>这样一个长度为 $n$ 的环，我们需要 $n-1$ 次操作将其全部搞定，每有一个“环”，我们就可以“节省”一次操作。因此，设 $n$ 个数对共由 $m$ 个环构成，那么最小的操作次数就是 $n-m$。&lt;/p>
&lt;p>判断环的个数相对简单，你并不一定需要使用“并查集”这样高级的数据结构——事实上搜索已经足够完成任务了，这部分的细节留给大家自行思考。&lt;/p></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/20221104/</link><pubDate>Thu, 03 Nov 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/20221104/</guid><description>&lt;h2 id="tfpytorch-with-sanitizers">TF/Pytorch with Sanitizers&lt;/h2>
&lt;hr>
&lt;h2 id="pytorch">PyTorch&lt;/h2>
&lt;hr>
&lt;h3 id="summary">Summary&lt;/h3>
&lt;ul>
&lt;li>1085 APIs are tested.&lt;/li>
&lt;li>ASan+UBSan / Compute Sanitizer&lt;/li>
&lt;li>Two kinds of false positives are filtered:
&lt;ul>
&lt;li>Invalid argument error&lt;/li>
&lt;li>Out-of-memory allocations&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="results">Results&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">#APIs&lt;/th>
&lt;th style="text-align:center">#Bugs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">ASan&lt;/td>
&lt;td style="text-align:center">7&lt;/td>
&lt;td style="text-align:center">5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">UBSan&lt;/td>
&lt;td style="text-align:center">19&lt;/td>
&lt;td style="text-align:center">8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">CSan&lt;/td>
&lt;td style="text-align:center">2+&lt;/td>
&lt;td style="text-align:center">2+&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;ul>
&lt;li>ASan:
&lt;ul>
&lt;li>heap-buffer-overflow&lt;/li>
&lt;li>heap use-after-free&lt;/li>
&lt;li>SEGV on unknown address&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>UBSan:
&lt;ul>
&lt;li>signed integer overflow&lt;/li>
&lt;li>non-zero offset to null pointer&lt;/li>
&lt;li>negative shift exponent&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>CSan:
&lt;ul>
&lt;li>Warp assertion&lt;/li>
&lt;li>CudaInvalidConfiguration&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="tensorflow">TensorFlow&lt;/h2>
&lt;hr>
&lt;h3 id="results-1">Results&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">#APIs&lt;/th>
&lt;th style="text-align:center">#Bugs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">CSan&lt;/td>
&lt;td style="text-align:center">2+&lt;/td>
&lt;td style="text-align:center">2+&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;ul>
&lt;li>Out-of-bound reads&lt;/li>
&lt;/ul></description></item><item><title>C++标准模板库——容器</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/stl-container/</link><pubDate>Tue, 01 Nov 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/stl-container/</guid><description>&lt;p>之前章节介绍的分支、循环、函数、递归……等概念都是命令式编程语言通用的思想方法。时常有同学问：我们到底学的是 C 还是 C++？可以说之前大家写的程序基本都是 C 风格的 (除了 &lt;code>cin&lt;/code> &lt;code>cout&lt;/code> &lt;code>string&lt;/code> 等少数内容）。这一章介绍的标准模板库 (Standard Template Library, STL)，是 C++ 区别于 C 的重要内容之一。STL 提供的内容将程序员从复杂的底层算法和数据结构中解放出来，使得程序员写程序更加得心应手。&lt;/p>
&lt;p>标准模板库由四个部分构成：&lt;/p>
&lt;ul>
&lt;li>算法 (Algorithm)&lt;/li>
&lt;li>容器 (Container)&lt;/li>
&lt;li>函数 (Function)&lt;/li>
&lt;li>迭代器 (Iterator)&lt;/li>
&lt;/ul>
&lt;p>这一章主要讲解容器。&lt;/p>
&lt;p>大家最近学习了“抽象数据结构“，STL的容器可以理解为C++库为大家实现好了一批数据结构，你只要读懂这些容器对外暴露的接口的功能说明，合理地使用这些接口，就可以在不知道底层实现的情况下享受这些数据结构的福利。我们在这里介绍几个最常用的STL容器和相关操作。&lt;/p>
&lt;p>注：STL容器的用法极其丰富，这里只是浮光掠影简单介绍，大家可以在
&lt;a href="https://en.cppreference.com" target="_blank" rel="noopener">cppreference&lt;/a> 上查询详细的方法列表。&lt;/p>
&lt;h2 id="vector">&lt;code>vector&lt;/code>&lt;/h2>
&lt;p>&lt;code>vector&lt;/code> 的中文名是向量，但这个容器和数学中的向量几乎毫无关系，vector 其实更像一个可以自由变换长度，完成一系列操作的“动态数组”。当你不能确定数组长度，或者需要在任意位置插入/删除元素时，vector 将会成为你的一大助力。下面我们通过例子展示 vector 的常见使用方法：&lt;/p>
&lt;ul>
&lt;li>
&lt;p>初始化&lt;/p>
&lt;pre>&lt;code class="language-c++">// 通用格式： vector&amp;lt;类型&amp;gt; 名字(最大容量，初始值)
#include &amp;lt;vector&amp;gt; // 大家常用的 &amp;lt;bits/stdc++.h&amp;gt; 已经包括了该头文件
int main ()
{
vector&amp;lt;double&amp;gt; v_double(0); // 一个空的，存储的变量为 double 类型的 vector
vector&amp;lt;int&amp;gt; v_int(10, 0); // 一个长度为 10 的 vector，初始所有元素为 0
}
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>
&lt;p>元素访问&lt;/p>
&lt;pre>&lt;code class="language-c++">// vector 和数组一样支持用下标访问，下标从 0 开始
vector&amp;lt;int&amp;gt; v_int(10, 1);
cout &amp;lt;&amp;lt; v_int[2]; // 输出： 0
cout &amp;lt;&amp;lt; v_int[10]; // 下标越界，未定义行为！
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>
&lt;p>迭代器&lt;/p>
&lt;p>虽然迭代器的概念有点复杂，但为了更好地使用容器，我们还是简单地介绍一下。迭代器可以理解为指向容器中某个元素的“指针”，如果你不知道什么是指针，你可以把它想象成一个“小箭头”。&lt;/p>
&lt;p>vector 中最常用的几个和迭代器相关的函数有：&lt;/p>
&lt;ul>
&lt;li>&lt;code>begin()&lt;/code>：返回一个指向第一个元素的迭代器。&lt;/li>
&lt;li>&lt;code>end()&lt;/code>：返回一个指向最后一个元素的“下一个位置”的迭代器。&lt;/li>
&lt;li>&lt;code>rbegin()&lt;/code>：返回指向最后一个元素的迭代器。&lt;/li>
&lt;/ul>
&lt;br/>
&lt;p>迭代器可以通过简单的 “+1/-1” 操作向右/左移动。我们通过迭代器可以顺序访问数组中的所有元素：&lt;/p>
&lt;pre>&lt;code class="language-c++">// 假设当前 vector&amp;lt;int&amp;gt; v 中有 5 个元素，分别是 1, 2，3, 4, 5
for (vector&amp;lt;int&amp;gt;::iterator iter = v.begin(); iter != v.end(); iter++)
cout &amp;lt;&amp;lt; *iter &amp;lt;&amp;lt; ' '; // 通过 *iterator 的方式来获取”小箭头“指向的元素的值
// 输出结果： 1 2 3 4 5
for (vector&amp;lt;int&amp;gt;::iterator iter = v.rbegin(); iter != v.begin(); iter--)
cout &amp;lt;&amp;lt; *iter ** ' ';
// 输出结果： 5 4 3 2，请体会 end() 与 begin() 的不同之处！
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>
&lt;p>元素的添加和删除&lt;/p>
&lt;pre>&lt;code class="language-c++">vector&amp;lt;int&amp;gt; v_int(3, 1); // vector 内容：[1, 1, 1]
// push_back(x) 方法用于在末尾添加元素 x
v_int.push_back(2) // vector 内容：[1, 1, 1, 2]
v_int.push_back(3) // vector 内容：[1, 1, 1, 2, 3]
// insert(iter, x) 方法用于在迭代器 iter 指向的位置前插入元素 x
v_int.insert(v_int.begin(), 3) // vector 内容：[3, 1, 1, 1, 2, 3]
// pop_back() 方法用于删除最后一个元素
v_int.pop_back() // vector 内容：[3, 1, 1, 1, 2]
// erase(iter) 方法用于删除迭代器 iter 指向的元素
v_int.erase(v_int.begin() + 1) // vector 内容：[3, 1, 1, 2]
// 注：删除了第二个元素
// clear() 函数用于清空 vector
v_int.clear() // vector 内容：[]
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>
&lt;p>相关参数&lt;/p>
&lt;pre>&lt;code class="language-c++">// 假设当前 vector&amp;lt;int&amp;gt; v 中有 5 个元素，分别是 1, 2，3, 4, 5
cout &amp;lt;&amp;lt; int(v.size()); // 输出：5
bool isEmpty = v.empty() // isEmpty = false
&lt;/code>&lt;/pre>
&lt;/li>
&lt;/ul>
&lt;h2 id="stack">&lt;code>stack&lt;/code>&lt;/h2>
&lt;p>stack (栈) 是一个先进后出的容器，你可以把它想象成一个电梯：第一个进入电梯的人总是最后一个出来。下面是一些简单的用法：&lt;/p>
&lt;pre>&lt;code class="language-c++">stack&amp;lt;int&amp;gt; s; // 初始为空
// push(x) 方法向栈顶放入元素 x
s.push(1);
s.push(2);
s.push(3); // s的内容: (底) [1, 2, 3] (顶)
// top() 方法用于获取栈顶元素
int currentTop = s.top(); // currentTop = 3, s: [1, 2, 3]
// pop() 方法用于弹出栈顶元素
s.pop(); // s: [1, 2]
currentTop = s.top(); // currentTop = 2
// size() 方法用于获取 s 中元素个数
cout &amp;lt;&amp;lt; int(s.size()); // 输出：2
// empty() 方法用于判断 s 是否为空
bool isEmpty = s.empty(); // isEmpty = false
&lt;/code>&lt;/pre>
&lt;h2 id="queue">&lt;code>queue&lt;/code>&lt;/h2>
&lt;p>queue (队列) 是一个先进先出的容器，你可以把它想象成一个双开门电梯：第一个进入电梯的人第一个出来。下面是一些简单的用法：&lt;/p>
&lt;pre>&lt;code class="language-c++">queue&amp;lt;int&amp;gt; q; // 初始为空
// push(x) 方法向队列的尾部加入元素 x
q.push(1);
q.push(2);
q.push(3); // q的内容: (队首) [1, 2, 3] (队尾)
// front() 方法用于获取队首元素
int currentFront = q.front(); // currentFront = 1
// q.pop() 方法用于弹出队首元素
q.pop(); // q: [2, 3]
currentFront = q.front(); // currentFront = 2
// size() 方法用于获取 q 中元素个数
int currentSize = int(q.size()); // currentSize = 2
// empty() 方法用于判断 q 是否为空
bool isEmpty = q.empty(); // isEmpty = false
&lt;/code>&lt;/pre>
&lt;h2 id="set">&lt;code>set&lt;/code>&lt;/h2>
&lt;p>set (集合) 顾名思义实现了一个集合应有的功能：插入、去重、判断一个元素是否存在。特别的是 set 中的元素还是按照顺序排列的 (这其实和集合定义中的无序性稍有不符)。下面是一些简单的用法：&lt;/p>
&lt;pre>&lt;code class="language-c++">set&amp;lt;int&amp;gt; s; // s: {}
// insert(x) 方法向集合插入元素 x
s.insert(2); // s: {2}
s.insert(1); // s: {1, 2}
s.insert(1); // s: {1, 2}，重复元素不会被反复插入
s.insert(3); // s: {1, 2, 3}
// find(x) 方法查询 x 是否在集合中，如果在则返回指向该元素的迭代器，否则返回 s.end()
set&amp;lt;int&amp;gt;::iterator iter_1 = s.find(1);
cout &amp;lt;&amp;lt; *iter; // 输出：1
set&amp;lt;int&amp;gt;::iterator iter_0 = s.find(0);
bool isEnd = (iter_0 == s.end()); // isEnd = true
// erase(x) 方法用于删除元素 x
s.erase(2); // s: {1, 3}
// 使用迭代器按顺序访问 s,会发现元素是按顺序排列的
for (set&amp;lt;int&amp;gt;::iterator iter = s.begin(); iter != s.end(); iter++)
cout &amp;lt;&amp;lt; *iter &amp;lt;&amp;lt; ' '; // 输出：1 3
// clear() 方法用于清空集合
s.clear(); // s: {}
&lt;/code>&lt;/pre>
&lt;h2 id="映射">映射&lt;/h2>
&lt;p>&lt;del>在标题中使用 map 这个单词会被自动渲染成地图。。。因此使用了“映射”作为标题&lt;/del>&lt;/p>
&lt;p>map (映射) 和数学中的映射一样，维护了一个 key-value pair 的集合。下面是一些简单的用法：&lt;/p>
&lt;pre>&lt;code class="language-c++">map&amp;lt;string, int&amp;gt; m; // 这是一个从 string 到 int 的映射
// 映射的插入非常简单：直接使用数组赋值的语法格式即可
m[&amp;quot;apple&amp;quot;] = 1;
m[&amp;quot;banana&amp;quot;] = 2;
// 访问一个 key 对应的 value：直接使用数组访问的语法格式
cout &amp;lt;&amp;lt; m[&amp;quot;apple&amp;quot;]; // 输出：1
cout &amp;lt;&amp;lt; m[&amp;quot;orange&amp;quot;]; // 对于不存在的 key，通常会输出默认值 0
m[&amp;quot;apple&amp;quot;] = 3;
cout &amp;lt;&amp;lt; m[&amp;quot;apple&amp;quot;]; // 输出：3
// find(x) 方法用于查询映射中是否有 x 这个 key，如果有则返回指向该 pair 的迭代器，否则返回 m.end()
map&amp;lt;string, int&amp;gt;::iterator iter_pear = m.find(&amp;quot;pear&amp;quot;);
bool isEnd = (iter_pear == m.end()) // isEnd = true
map&amp;lt;string, int&amp;gt;::iterator iter_banana = m.find(&amp;quot;banana&amp;quot;);
// 使用 .first 获取 key，.second 获取 value
cout &amp;lt;&amp;lt; *iter_banana.first &amp;lt;&amp;lt; ' ' &amp;lt;&amp;lt; *iter_banana.second; // 输出： banana 2
&lt;/code>&lt;/pre></description></item><item><title>有限状态机简介</title><link>https://chenhn02.github.io/courses/problemsolving/dfa/</link><pubDate>Tue, 01 Nov 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/dfa/</guid><description>&lt;h2 id="一有限状态机的介绍">一.有限状态机的介绍&lt;/h2>
&lt;p>首先说说自动机是干什么的。用简单的话来说，有限状态机是一个黑箱，输入是一个合法的字符串(随着你们逐渐深入的学习，这里的“字符串”的概念将会被&amp;quot;Language&amp;quot;替换)，输出&amp;quot;Accept&amp;quot;或者&amp;quot;Reject&amp;quot;，是不是很像你们的OJ（OJ也是状态机！甚至放开了说，一切程序都是状态机！）而要知道哪些字符串会被&amp;quot;Accept&amp;quot;，哪些字符串会被&amp;quot;Reject&amp;quot;，我们就需要继续了解状态机的概念。&lt;/p>
&lt;p>自动机的工作原理和地图很类似。假设你在你仙林校区，然后你从仙林校区到鼓楼校区，按顺序经过了很多地铁站。每个地铁站都可能有多条换乘路线，而你在所有这些地铁站的选择就构成了一个序列。&lt;/p>
&lt;p>例如，你的选择序列是“ 南大仙林校区-&amp;gt; 羊山公园-&amp;gt;仙林中心 -&amp;gt;学则路 -&amp;gt; 仙鹤门 -&amp;gt; 金马路 -&amp;gt;马群 -&amp;gt; 钟灵街 -&amp;gt; 孝陵卫 -&amp;gt; 下马坊 -&amp;gt; 苜蓿园 -&amp;gt; 明故宫 -&amp;gt; 西安门 -&amp;gt;大行宫 -&amp;gt; 新街口 -&amp;gt; （转1号线）-&amp;gt; 珠江路 -&amp;gt; 鼓楼”，那你按顺序经过的地铁线路可能是“2号线-&amp;gt;1号线&amp;quot;。可以发现，通勤的选择序列不止这一个。同样要去鼓楼校区，你还可以从金马路换乘到4号线，再从4号线到鼓楼站。&lt;/p>
&lt;p>而我们如果找到一个选择序列，就可以在地图上比划出这个选择序列能不能去鼓楼校区。比如，如果一个选择序列是“南大仙林校区-&amp;gt; 羊山公园-&amp;gt;仙林中心 -&amp;gt;学则路 -&amp;gt; 仙鹤门 -&amp;gt; 金马路 -&amp;gt;马群 -&amp;gt; 钟灵街 -&amp;gt; 孝陵卫 -&amp;gt; 下马坊 -&amp;gt; 苜蓿园 -&amp;gt; 明故宫 -&amp;gt; 西安门 -&amp;gt;大行宫 -&amp;gt; 新街口 -&amp;gt; 上海路-&amp;gt;汉中门 -&amp;gt; 莫愁湖 -&amp;gt; 云锦路”，那么它就不会带你去学校，但是仍旧可能是一个可被接受的序列（这里的可接受是任意两个相邻的地点都有地铁能只经过一站而到达），因为目标地点可能不止一个。&lt;/p>
&lt;p>也就是说，我们通过这个地图和一组目的地，将信号序列分成了三类，一类是无法识别的信号序列（例如“南大仙林校区-&amp;gt; ???”），一类是能去学校的信号序列，另一类是不能的信号序列。我们将所有合法的信号序列分成了两类，完成了一个判定问题。&lt;/p>
&lt;p>既然自动机是一个数学模型，那么显然不可能是一张地图。对地图进行抽象之后，可以简化为一个有向图。因此，自动机的结构就是一张有向图。&lt;/p>
&lt;p>而自动机的工作方式和流程图类似，不同的是：自动机的每一个结点都是一个判定结点；自动机的结点只是一个单纯的状态而非任务；自动机的边可以接受多种字符（不局限于 T 或 F）。&lt;/p>
&lt;p>例如，完成“判断一个二进制数是不是偶数”的自动机如下：&lt;/p>
&lt;p>&lt;img alt="奇偶DFA" src="https://oi-wiki.org/string/images/automaton2.png">&lt;/p>
&lt;p>从起始结点开始，从高到低接受这个数的二进制序列，然后看最终停在哪里。如果最终停在红圈结点，则是偶数，否则不是。&lt;/p>
&lt;p>如果需要判定一个有限的信号序列和另外一个信号序列的关系（例如另一个信号序列是不是某个信号序列的子序列），那么常用的方法是针对那个有限的信号序列构建一个自动机。这个在学习 KMP 的时候会讲到。（早着呢）&lt;/p>
&lt;p>需要注意的是，自动机只是一个 &lt;strong>数学模型&lt;/strong>，而 &lt;strong>不是算法&lt;/strong>，也 &lt;strong>不是数据结构&lt;/strong>。实现同一个自动机的方法有很多种，可能会有不一样的时空复杂度。&lt;/p>
&lt;h2 id="二有限状态机的形式化定义">二.有限状态机的形式化定义&lt;/h2>
&lt;p>一个 &lt;strong>确定有限状态自动机（DFA）&lt;/strong> 由以下五部分构成：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>字符集&lt;/strong>（$\Sigma$），该自动机只能输入这些字符。&lt;/li>
&lt;li>&lt;strong>状态集合&lt;/strong>（$Q$）。如果把一个 DFA 看成一张有向图，那么 DFA 中的状态就相当于图上的顶点。（这里大家没学过图论，但这些基本概念需要大家主动了解，这些概念算是图论里的“常识”了)&lt;/li>
&lt;li>&lt;strong>起始状态&lt;/strong>（$start$），$start \in Q$，是一个特殊的状态。起始状态一般用 $s$表示，为了避免混淆，本文中使用$start$ 。&lt;/li>
&lt;li>&lt;strong>接受状态集合&lt;/strong>（$F$），$F \subseteq Q$，是一组特殊的状态。&lt;/li>
&lt;li>&lt;strong>转移函数&lt;/strong>（$\delta$），$\delta$ 是一个接受两个参数返回一个值的函数，其中第一个参数和返回值都是一个状态，第二个参数是字符集中的一个字符。如果把一个 DFA 看成一张有向图，那么 DFA 中的转移函数就相当于顶点间的边，而每条边上都有一个字符。&lt;/li>
&lt;/ol>
&lt;p>DFA 的作用就是识别字符串，一个自动机$A$ ，若它能识别（接受）字符串$S$ ，那么 $A(S)=True$，否则$A(S)=False$ 。&lt;/p>
&lt;p>当一个 DFA 读入一个字符串时，从初始状态起按照转移函数一个一个字符地转移。如果读入完一个字符串的所有字符后位于一个接受状态，那么我们称这个 DFA &lt;strong>接受&lt;/strong> 这个字符串，反之我们称这个 DFA &lt;strong>不接受&lt;/strong> 这个字符串。&lt;/p>
&lt;p>如果一个状态 $v$没有字符$c$ 的转移，那么我们令$\delta(v,c)=null$ ，而$null$只能转移到$null$ ，且$null$不属于接受状态集合。无法转移到任何一个接受状态的状态都可以视作 $null$，或者说， $null$代指所有无法转移到任何一个接受状态的状态。&lt;/p>
&lt;p>（注:上一段表示一旦不存在当前状态的某个转移，那么状态机一定会&lt;strong>不接受&lt;/strong>这个字符串)&lt;/p>
&lt;p>我们扩展定义转移函数 $\delta$，令其第二个参数可以接收一个字符串：$\delta(v,s)=\delta(\delta(v,s[1]),s[2..|s|])$，扩展后的转移函数就可以表示从一个状态起接收一个字符串后转移到的状态。那么，$A(s)=[\delta(start,s)\in F]$。&lt;strong>这里难以理解抽象函数的话，可以当成在字符串中一个个读取字符，然后一步步转移当前状态，最后在最终状态判断是否为接受状态&lt;/strong>&lt;/p>
&lt;p>如，一个接受且仅接受字符串 &amp;ldquo;a&amp;rdquo;, &amp;ldquo;ab&amp;rdquo;, &amp;ldquo;aac&amp;rdquo; 的 DFA：&lt;/p>
&lt;p>&lt;img alt="DFA" src="https://oi-wiki.org/string/images/automaton1.png">&lt;/p>
&lt;p>NFA是在DFA的基础上存在某些状态$v$，满足$\delta(v,\epsilon)\neq null$，这里的$\epsilon$表示空字符串，这就导致NFA可能处于多种状态的叠加态中（&lt;strong>这是后面将NFA转化成DFA的关键，将当前的多种状态当成一个新状态！这样记原状态数为k种，此时总状态数就会有$2^k$种，但每一次状态转移都是唯一的！&lt;/strong>)，一个NFA的例子如下，我们判断一个字符串是否以&amp;quot;01&amp;quot;结尾，正则表达式表示为&amp;quot;$.*01$&amp;ldquo;的NFA如下 (S3为接收节点)：&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
state1((s1)) --&amp;gt;|0| state2((s2))
state2 --&amp;gt;|e/任意字母|state1
state2 --&amp;gt; |1| state3((s3))
state3 --&amp;gt; |e/任意字母|state1
&lt;/code>&lt;/pre>
&lt;p>以上内容节选自&lt;a href="https://oi-wiki.org/string/automaton/">OI-Wiki&lt;/a>&lt;/p>
&lt;h2 id="三有限状态机的代码实现">三.有限状态机的代码实现&lt;/h2>
&lt;h3 id="1dfa代码实现">1.DFA代码实现&lt;/h3>
&lt;h4 id="i非图论建模">I.非图论建模&lt;/h4>
&lt;p>非图论建模DFA，主要要确定状态机的各个状态，和状态之间的转移，以上文的只接受&amp;quot;a&amp;rdquo;,&amp;ldquo;ab&amp;rdquo;,&amp;ldquo;aac&amp;quot;的DFA为例，可以实现一个函数“get_nxt_state()&amp;ldquo;实现状态的转移。缺点很明显，没有建图的过程，用一个个if判断，代码会冗长不好看&lt;/p>
&lt;pre>&lt;code class="language-C++">//函数返回值为true表示转移到一个合法状态，函数返回值为false表示转移到一个非法状态
bool get_nxt_state(char ch,int&amp;amp; state){
switch(state){
case 0:{
if(ch=='a')state=1;//合法输入
else return false;//非法输入
return true;
}
case 1:{
...
}
case 2:{
}
}
}
bool dfa(string str){
int status;
for(int i=0;i&amp;lt;str.size();i++){
if(get_nxt_status(str[i],state)==false)
return false;
}
return status在接受状态中;
}
&lt;/code>&lt;/pre>
&lt;h4 id="ii图论建模">II.图论建模&lt;/h4>
&lt;p>图论建模DFA，同上，只是在每个状态内部预先处理碰到不同字符后的下一个状态，一种可能的实现方案为&lt;/p>
&lt;pre>&lt;code class="language-C++">struct Node{
int nxt[128];//不太好的编程习惯
}Nodes[100000];//不太好的编程习惯2，当然这样会超空间
void add(int from,int to,char ch){
Nodes[from].nxt[ch]=to;
}
bool dfa(string str){
int tNode=0;//0是初始节点
//预先设置好每个节点是否是available;
for(int i=0;i&amp;lt;str.size();i++){
tNode=Nodes[tNode].nxt[str[i]];
if(tNode==0x3f3f3f3f)return false;
}
return tNode是一个接受状态;
}
&lt;/code>&lt;/pre>
&lt;h3 id="2nfa代码实现">2.NFA代码实现&lt;/h3>
&lt;h4 id="i以矩阵和向量的形式建模nfa-转换成-dfa">I.以矩阵和向量的形式建模（NFA 转换成 DFA)&lt;/h4>
&lt;p>NFA可能在一次状态转移后，可能同时满足多个状态，记NFA的总状态数为k,则可以用一个长度为k的列向量表示当前所处状态，该列向量的第i位表示当前是否可能处于第i种状态，同时可以使用矩阵表示状态之间的转移，转移矩阵需要提前构造。&lt;/p>
&lt;p>这个思路中的列向量相当于把所有的状态压缩进一个列向量中，比如共有4个状态，状态1和状态2均有可能，则列向量为{0,1,1,0}，列向量共有2^k种不同的可能代表&lt;/p>
&lt;pre>&lt;code class="language-c++">typedef vector&amp;lt;int&amp;gt;arr;
typedef vector&amp;lt;arr&amp;gt;matrix;
arr operator*(matrix mat,arr vec){
int n=mat.size();
arr ret(n,0);
for(int i=0;i&amp;lt;n;i++)
for(int j=0;j&amp;lt;n;j++)
ret[i]|=mat[i][j]&amp;amp;vec[j];
return ret;
}//矩阵乘列向量
matrix transition_matrix[256];//转移矩阵，其中第i个转移矩阵表示当前遇到的字符为 i时的转移矩阵，其第j行第k列表示可以从第k个状态转移到第j个状态
int state_count;//总的状态数，初始状态为状态0
void dfa_init(string patton){
//TBD 确认状态数
//TBD 构造转移矩阵
}
bool nfa(string str){
arr vec(state_count,0);
vec[0]=1;
for(int i=0;i&amp;lt;str.size();i++){
vec=vec*transition_matrix[str[i]];
}
//最后结果遍历所有vec中为true的位，判断它是否是接受状态
return vec[state_count-1];
}
&lt;/code>&lt;/pre>
&lt;h4 id="ii以图论建模用dfs搜索">II.以图论建模，用DFS搜索&lt;/h4>
&lt;p>用DFS去尝试所有的匹配可能，如果在DFS过程中完成字符串匹配后处于接受状态，则接受这个字符串，否则不接受&lt;/p>
&lt;pre>&lt;code class="language-c++">vector&amp;lt;vector&amp;lt;int&amp;gt;&amp;gt;graph[10000];//不好的编程风格
//graph[i][j][k]表示当前状态为i，读入的字符为j，能到达的第k个状态的状态号
void add(int from,int to, char ch){
graph[from][ch].push_back(to);
}
bool nfa_dfs(int status,string str,int pl){
if(pl==patton.size())return status为接受状态;
if(graph[status][str[pl]].size()==0)return false;
bool flag=false;
for(int i=0;i&amp;lt;graph[status][str[pl]].size();i++){
flag|=nfa_dfs(graph[status][str[pl]][i],str,pl+1);
if(flag)return true;//剪枝
}
return false;
}
bool nfa(string str){
return nfa_dfs(0,str,0);
}
&lt;/code>&lt;/pre>
&lt;h2 id="四以实际应用为例进行模型构建和代码实现">四.以实际应用为例，进行模型构建和代码实现&lt;/h2>
&lt;h3 id="1以匹配合法邮件地址为例">1.以匹配合法邮件地址为例&lt;/h3>
&lt;p>为了方便表示，我们不考虑判断合法域名，以及合法邮件地址，我们给出邮件地址的BNF范式&lt;/p>
&lt;pre>&lt;code class="language-plaintext">email address::=\&amp;lt;string '@' domain&amp;gt;
string::=\&amp;lt;word|digit&amp;gt;{word|digit}
domain::=\&amp;lt;string '.' string&amp;gt;
&lt;/code>&lt;/pre>
&lt;p>word和digit分别表示单词和数字，定义不给出&lt;/p>
&lt;h4 id="例子分析">例子分析:&lt;/h4>
&lt;p>我们首先确定状态的个数&lt;/p>
&lt;ol>
&lt;li>初始状态&lt;/li>
&lt;li>位于第一个string的状态&lt;/li>
&lt;li>位于&amp;rsquo;@&amp;lsquo;的状态&lt;/li>
&lt;li>位于第二个string的状态&lt;/li>
&lt;li>位于&amp;rsquo;.&amp;lsquo;的状态&lt;/li>
&lt;li>位于第三个string的状态&lt;/li>
&lt;/ol>
&lt;p>我们注意到，对于本题，合法的邮件地址要求下，只有状态6才是接受状态&lt;/p>
&lt;p>接下来分析状态转移&lt;/p>
&lt;p>对于状态1,当接收到数字或字母时,会转移到状态2,其他情况均不合法&lt;/p>
&lt;p>对于状态2,当接收到数字或字母时,保持当前状态,接收到&amp;rsquo;@&amp;lsquo;时,转移到状态3,其他情况均不合法&lt;/p>
&lt;p>对于状态3,当接收到数字或字母时,会转移到状态4,其他情况均不合法&lt;/p>
&lt;p>对于状态4,当接收到数字或字母时,保持当前状态,接收到&amp;rsquo;.&amp;lsquo;时,转移到状态5,其他情况均不合法&lt;/p>
&lt;p>对于状态5,当接收到数字或字母时,会转移到状态6,其他情况均不合法&lt;/p>
&lt;p>对于状态6,当接收到数字或字母时,保持当前状态,其他情况均不合法&lt;/p>
&lt;h4 id="代码实现">代码实现：&lt;/h4>
&lt;p>接下来有两种代码实现方式，头文件在此均忽略不计&lt;/p>
&lt;pre>&lt;code class="language-C++">//方案1，状态转移
bool isdigit(char ch){
return '0'&amp;lt;=ch&amp;amp;&amp;amp;ch&amp;lt;='9';
}
bool isletter(char ch){
return ('a'&amp;lt;=ch&amp;amp;&amp;amp;ch&amp;lt;='z')||('A'&amp;lt;=ch&amp;amp;&amp;amp;ch&amp;lt;='Z');
//注意不要写成 'a'&amp;lt;=ch&amp;lt;='z'这样的情况，否则相当于('a'&amp;lt;=ch)&amp;lt;='z'
}
//此函数是表示给出当前状态和当前读入的字符，判断是否存在合法转移，以及如果合法，跳转到下一个状态
//对于函数的参数的&amp;quot;&amp;amp;&amp;quot;符号，可以了解一下实参和形参，以及函数的副作用
//注意这种写法较为冗长，可以建立一个accept的列表
bool get_next_state(char ch,int&amp;amp; state){
switch(state){
case '1':{
if(isdigit(ch)||isletter(ch)){
state=2;
return true;
}
return false;
break;//这里的break不需要，仅仅是为了提醒大家switch里记得不要漏掉break语句
}
case '2':{
if(isdigit(ch)||isletter(ch)){
state=2;
return true;
}
else if(ch=='@'){
state=3;
return true;
}
return false;
break;
}
case '3':{
if(isdigit(ch)||isletter(ch)){
state=4;
return true;
}
return false;
break;
}
case '4':{
if(isdigit(ch)||isletter(ch)){
state=4;
return true;
}
else if(ch=='.'){
state=5;
return true;
}
return false;
break;
}
case '5':{
if(isdigit(ch)||isletter(ch)){
state=6;
return true;
}
return false;
break;
}
case '6':{
if(isdigit(ch)||isletter(ch)){
state=6;
return true;
}
return false;
break;
}
}
}
//另一种get_next_state的实现如下
bool get_next_state(char ch,int&amp;amp; state){
static bool init=false;
struct table{
string str;
int next_state;
};
static vector&amp;lt;table&amp;gt;nextstate[7];
if(!init){
//将合法状态加入表格中，这个合法状态字符串常量可以用一个const string 表示，下文用String表示字符串常量&amp;quot;abcdefghijklmnopqrstuvwxyz0123456789&amp;quot;
table[1].push_back((table){String,2});
table[2].push_back((table){String,2});
table[3].push_back((table){String,4});
table[4].push_back((table){String,4});
table[5].push_back((table){String,6});
table[6].push_back((table){String,6});
table[2].push_back((table){&amp;quot;@&amp;quot;,3});
table[4].push_back((table){&amp;quot;.&amp;quot;,5});
init=true;
//当然这个步骤可以在函数外部完成
}
for(int i=0;i&amp;lt;nextstate[state].size();i++){
//遍历合法状态
string availstr=nextstate[state][i].str;
for(int j=0;j&amp;lt;availstr.size();j++){
if(ch==availstr[j]){
state=nextstate[state][i].next_state;
return true;
}
}
}
return false;
}
bool solve(){
string str;//推荐使用string存放字符串数据
cin&amp;gt;&amp;gt;str;
int state=1;
for(int i=0;i&amp;lt;str.size();i++){
if(!get_next_state(str[i],state)){
return false;//如果转移不合法，则返回false
}
}
return state==6;//只有状态6是接受状态
}
int main(){
int t=1;
while(t--)solve();
}
&lt;/code>&lt;/pre>
&lt;p>如果大家注意到的话，第二种方式可以当做”图“来理解，”图“在计算机中只是一种模型，并不一定只有了解”图“的知识，才能写出来含有”图“思想的题目，我这里避免了使用传统建图的方式，不过这里的状态转移表的思想和”图&amp;quot;类似&lt;/p>
&lt;h4 id="其他例题">其他例题：&lt;/h4>
&lt;p>
&lt;a href="https://leetcode.cn/problems/valid-number/" target="_blank" rel="noopener">https://leetcode.cn/problems/valid-number/&lt;/a> (这玩意居然还标了困难&amp;hellip;是我大意了，以为是个比较简单的题目&amp;hellip;不过LeetCode上的困难题大家大二以后都是随便手撕的（误） 但总的来说，LeetCode是一个适合新手练习的网站，LeetCode中，所有错误都会把错误样例给你，便于调试和debug)&lt;/p>
&lt;h3 id="2更简易正则表达式">2.更简易正则表达式&lt;/h3>
&lt;p>为了给大家一个NFA建模的例子，同时保证不直接提供OJ代码，我这里将以一个更简易版的正则表达式的判别&lt;/p>
&lt;p>这里的正则表达式只包含26个小写字母和&amp;rdquo;*&amp;ldquo;符号，其含义和OJ中正则表达式的含义相同&lt;/p>
&lt;h4 id="例子分析-1">例子分析&lt;/h4>
&lt;p>首先要对正则表达式进行语法分析，得到该正则表达式对应的所有状态&lt;/p>
&lt;p>对于正则表达式”a*a*“来说，用它去匹配&amp;quot;aa&amp;rdquo;，可能有多重匹配的结果，比如第一个&amp;rsquo;a&amp;rsquo;匹配了两次，第二个&amp;rsquo;a&amp;rsquo;匹配了零次，和第一个&amp;rsquo;a&amp;rsquo;匹配一次，第二个&amp;rsquo;a&amp;rsquo;匹配一次，和第一个&amp;rsquo;a&amp;rsquo;匹配零次，第二个&amp;rsquo;a&amp;rsquo;匹配两次。因此同一个字符串，可能在正则表达式中有多种对应的匹配**（思考一下如何计算到底有多少种可能的匹配方式呢）**。因此我们可以将正则表达式建模成NFA&lt;/p>
&lt;p>以&amp;quot;a*aba*&amp;ldquo;为正则表达式举例，连同初始状态，它一共有以下四个状态&lt;/p>
&lt;ol>
&lt;li>初始状态&lt;/li>
&lt;li>正在匹配第一个&amp;quot;a*&amp;rdquo;&lt;/li>
&lt;li>正在匹配&amp;quot;b&amp;rdquo;&lt;/li>
&lt;li>正在匹配第二个&amp;quot;a*&amp;quot;&lt;/li>
&lt;/ol>
&lt;p>注意:由于正则表达式的NFA特性，导致初始状态时，既可以处在状态1，也可以处在状态2(当前匹配了0次&amp;rsquo;a&amp;rsquo;，为合法的匹配)&lt;/p>
&lt;h4 id="代码实现-1">代码实现：&lt;/h4>
&lt;p>我这里将给出上文提到的两种实现方式去解决该问题，当然，对于本题，动态规划也是一个可行的算法，这里不多赘述&lt;/p>
&lt;pre>&lt;code class="language-C++">//NFA转化成DFA去做
typedef vector&amp;lt;int&amp;gt; arr;
typedef vector&amp;lt;arr&amp;gt; matrix;
arr operator*(matrix mat,arr vec){
assert(mat.size()&amp;gt;0&amp;amp;&amp;amp;vec.size()&amp;gt;0&amp;amp;&amp;amp;mat[0].size()==vec.size());//矩阵运算的前提
int n=mat.size(),m=vec.size();
arr ret(n,0);
for(int i=0;i&amp;lt;n;i++){
for(int j=0;j&amp;lt;m;j++){
ret[i]|=mat[i][j]&amp;amp;vec[j];//0-1矩阵计算
}
}
return ret;
}
struct _state{
char ch=0;//这里因为没有出现一个state里允许多字符的'[]'和'.'出现，于是用char表示当前状态允许的字符
int type=0;//当前state的修饰符状态，0表示无修饰符，1表示用'*'修饰,这里建议了解一些enum，在完成较大工程时适合使用
};
//正则表达式的parse过程，将正则表达式标记成多个状态
vector&amp;lt;_state&amp;gt; parse(string regex){
vector&amp;lt;_state&amp;gt;states;
for(int i=0;i&amp;lt;regex.size();i++){
_state state;
state.ch=regex[i];
state.type=0;
if(i+1&amp;lt;regex.size()&amp;amp;&amp;amp;regex[i+1]=='*'){
i++;
state.type=1;
}
states.push_back(state);
}
return states;
}
bool solve(){
string regex;//正则表达式
string str;//被匹配的字符串
cin&amp;gt;&amp;gt;regex&amp;gt;&amp;gt;str;
vector&amp;lt;_state&amp;gt;states=parse(regex);
int state_count=states.size()+1;//总共的状态数
//初始化合法状态
arr state(state_count,0);//表示当前状态
state[0]=1;
for(int i=0;i&amp;lt;states.size();i++){
if(states[i].type==1){
state[i+1]=1;
}
else break;
}
//接下来构建转移矩阵的部分，每一个字母有一个独特的转移矩阵，转移矩阵含义照上文解读
vector&amp;lt;matrix&amp;gt;trans_matrix(26,matrix(state_count,arr(state_count,0)));
int lastavail=0;//这里表示连续的'*'的开始位
for(int i=0;i&amp;lt;states.size();i++){
int index=states[i].ch-'a';
int type=states[i].type;
if(type==1){
//如果当前是&amp;quot;*&amp;quot;的话，那么对于之前的状态，只要他们能到达上一个状态，都能实现转移到当前状态(epsilon边)
for(int j=0;j&amp;lt;=i;j++){
for(int k=0;k&amp;lt;26;k++)trans_matrix[k][i+1][j]=trans_matrix[k][i][j];
}
trans_matrix[index][i+1][i+1]=1;
trans_matrix[index][i+1][i]=1;//这个是多余的(因为能到达i的话，一定能到达状态i+1，但是是可行的)
}
else{
trans_matrix[index][i+1][i]=1;
//否则只能实现从i到i+1的转移
}
}
//不断转移计算
for(int i=0;i&amp;lt;str.size();i++){
state=trans_matrix[str[i]-'a']*state;
}
return state[state_count-1];//正则表达式的支持状态只有末状态.
}
int main(){
int t=1;
while(t--){
solve();
}
}
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-C++">//DFS去做
struct _state{
char ch=0;//这里因为没有出现一个state里允许多字符的'[]'和'.'出现，于是用char表示当前状态允许的字符
int type=0;//当前state的修饰符状态，0表示无修饰符，1表示用'*'修饰,这里建议了解一些enum，在完成较大工程时适合使用
};
//正则表达式的parse过程，将正则表达式标记成多个状态
vector&amp;lt;_state&amp;gt; parse(string regex){
vector&amp;lt;_state&amp;gt;states;
for(int i=0;i&amp;lt;regex.size();i++){
_state state;
state.ch=regex[i];
state.type=0;
if(i+1&amp;lt;regex.size()&amp;amp;&amp;amp;regex[i+1]=='*'){
i++;
state.type=1;
}
states.push_back(state);
}
return state;
}
vector&amp;lt;_state&amp;gt;states;
int lastavail;//详情见dfs
bool dfs(string str,int index, int state){
if(index==str.size())return state==states.size();
//首先判断当前state可以转移的state
vector&amp;lt;int&amp;gt;availstate;
//注意当前的state下标在states数组里应该是states[state-1]
if(state&amp;gt;0&amp;amp;&amp;amp;states[state-1].type==1&amp;amp;&amp;amp;str[index]==states[state-1].ch)availstate.push_back(state);
//可以贪心，对于所有的可到达的&amp;quot;*&amp;quot;标志，只要进入最小下标的可到达的&amp;quot;*&amp;quot;标记位置即可,这是一种剪枝策略，这里不使用该剪枝策略，不用剪枝策略的话，复杂度最高可以达到2^n
//(为什么可以这样贪心，请自己理解，不懂可以问助教)
for(int i=state;i&amp;lt;states.size();i++){
if(states[i].type!=1){
if(states[i].ch==str[index])availstate.push_back(i+1);
break;
//这里碰到一次type==0的情况后，仍然可以转移到它后面的type==1的情况，但是那样是无意义的，于是这里我写了一个lastavail的标志，表示可以通过epsilon转移到最后一个状态的最小state
}
else{
if(states[i].ch==str[index])availstate.push_back(i+1);
}
}
for(int i=0;i&amp;lt;availstate.size();i++){
if(dfs(str,index+1,availstate[i]))return true;//这里也是简单的剪枝,不剪枝的写法如下
}
/*
不剪枝的写法
bool flag=false;
for(int i=0;i&amp;lt;availstate.size();i++){
flag|=dfs(str,index+1,availstate[i]);
}
return flag;
*/
return false;
}
bool solve(){
string regex;
string str;
cin&amp;gt;&amp;gt;regex&amp;gt;&amp;gt;str;
states=parse(regex);
vector&amp;lt;int&amp;gt;initialstate;
initialstate.push_back(0);
lastavail = states.size();
for (int i = states.size()-1; i &amp;gt;= 0; i--) {
if (states[i].type == 1)lastavail = i;
else break;
}
return dfs(str,0,0);
}
&lt;/code>&lt;/pre>
&lt;h4 id="动态规划解法">动态规划解法：&lt;/h4>
&lt;p>自行了解一下
(
&lt;a href="https://leetcode.cn/problems/regular-expression-matching/" target="_blank" rel="noopener">https://leetcode.cn/problems/regular-expression-matching/&lt;/a>)&lt;/p></description></item><item><title>Python 快速入门教程</title><link>https://chenhn02.github.io/courses/problemsolving/python-intro/</link><pubDate>Mon, 31 Oct 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/python-intro/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>声明&lt;/strong>&lt;/p>
&lt;p>Python语言的学习不是这门课的硬性要求，因此我们&lt;strong>不会&lt;/strong>在OJ作业/期末测试中强制大家使用Python完成习题。但我们仍然强烈建议你在初学阶段至少涉猎一下这样一门与 C/C++ 风格迥异的语言。&lt;/p>
&lt;p>接触 Python 可以给你带来不限于以下好处：&lt;/p>
&lt;ul>
&lt;li>感受 Python 精简灵活的语法：在这里你不用书写头文件，不需要书写 &lt;code>main()&lt;/code> 函数，不需要在每行后面写分号，不需要定义变量……Python 提供的丰富的内置数据类型让你可以轻松自由地操纵数据。&lt;/li>
&lt;li>感受函数式编程范式：Python 是一门兼有命令式和函数式风格的语言。在 Python 中你可以轻而易举地玩转高阶函数、lambda表达式等，感受函数式编程的独特思想和魅力。&lt;/li>
&lt;li>强大的第三方库：Python 丰富的第三方库的支持可以让你轻松地完成一些“惊为天人”的东西，比如处理电脑文件的自动化脚本，带有图形界面的小游戏等等。&lt;/li>
&lt;/ul>
&lt;p>&lt;del>接触 Python 可能带来的坏处：总是在 C/C++ 程序中写出 Python 风格的会导致编译错误的代码。&lt;/del>&lt;/p>
&lt;p>我们不会介绍 Python 的安装，如果你感兴趣可以上网自行搜索教程安装 Python 的运行环境，并亲自上手感受这样一门现代语言。你目前只需要能看懂这份教程即可。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>想必大家听过一句著名的广告词“人生苦短，我用Python”。Python是世界上程序员最喜爱的编程语言之一 (主要原因已经在上方的蓝框中涉及了一部分)。我们仍然用输出 &amp;ldquo;Hello, world!&amp;rdquo; 的例子来入门：&lt;/p>
&lt;pre>&lt;code class="language-python">print('Hello, world!')
&lt;/code>&lt;/pre>
&lt;p>是的，只需要一行。不需要头文件、&lt;code>main()&lt;/code> 函数，也不需要理解复杂的 &lt;code>scanf()&lt;/code>/&lt;code>printf()&lt;/code> 语法和输入输出流，一个直观的 &lt;code>print()&lt;/code> 函数解决一切！&lt;/p>
&lt;p>下一个例子是喜闻乐见的 a+b problem：&lt;/p>
&lt;pre>&lt;code class="language-python">a = int(input())
b = int(input())
print(a + b)
&lt;/code>&lt;/pre>
&lt;p>在 Python 中你不需要提前定义变量，可以“拿来就用”，这是因为 Python 是一门动态语言，可以在执行的过程中进行 type inference。这里需要注意的是 &lt;code>input()&lt;/code> 读入进来的内容默认是字符串类型，我们需要用 &lt;code>int()&lt;/code> 将其转换为整数类型。如果你想要查看一个变量存储的数据的类型，你可以使用 &lt;code>type()&lt;/code> 函数：&lt;/p>
&lt;pre>&lt;code class="language-python">a = input()
print(type(a)) # 在 Python 中，你可以用&amp;quot;#&amp;quot;进行行末注释！
&amp;quot;&amp;quot;&amp;quot;
在 Python 中，你可以用三引号框住
一段注释！
&amp;quot;&amp;quot;&amp;quot;
&lt;/code>&lt;/pre>
&lt;p>在 Python 中定义函数可以使用 &amp;ldquo;def&amp;rdquo; 关键字：&lt;/p>
&lt;pre>&lt;code class="language-python">def add(a, b): # 注意这里必须有冒号！
return a + b
print(add(1, 2)) # 输出：3
&lt;/code>&lt;/pre>
&lt;p>值得注意的是，Python没有C/C++语言中一层层的大括号，所以Python程序需要依靠严格的缩进来保证清晰的程序结构。因此函数体的语句前方必须有缩进！类似地，if语句的分支，循环语句的循环体也必须保证正确的缩进。&lt;/p>
&lt;p>Python中的判断语句：&lt;/p>
&lt;pre>&lt;code class="language-python">def judge_even_odd(x):
if x % 2 == 0: # 注意这里要有冒号！
return 'even'
else: # 注意这里要有冒号！
return 'odd'
print(judge_even_odd(1)) # 输出：odd
&lt;/code>&lt;/pre>
&lt;p>Python中的循环语句：&lt;/p>
&lt;pre>&lt;code class="language-python">def cumulative_sum(n):
res = 0
while n &amp;gt;= 0: # 注意这里要有冒号！
res += n
n -= 1
return res
print(cumulative_sum(10)) # 输出：55
&lt;/code>&lt;/pre>
&lt;p>注意：在 Python 中 if/while 的判断条件左右是不需要加括号的。&lt;/p>
&lt;p>一个简单的递归程序：&lt;/p>
&lt;pre>&lt;code class="language-python">def fibonacci(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(9)) # 输出：34
&lt;/code>&lt;/pre></description></item><item><title>【问题求解I-HW4.C】jyy为什么是神</title><link>https://chenhn02.github.io/courses/problemsolving/solutions-2022/1-4-c/</link><pubDate>Thu, 27 Oct 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/solutions-2022/1-4-c/</guid><description>&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>题意概述&lt;/strong>&lt;/p>
&lt;p>给定字符串 $s$，求 $|\{(i,j,k)|s_i=j,s_j=y,s_k=y\}|$。&lt;/p>
&lt;p>约束条件：$|s|\leq 10^6$。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>大家容易想到的一个非常简明的做法是使用三重循环计数：&lt;/p>
&lt;pre>&lt;code class="language-c++">ans = 0;
for (int i = 0; i &amp;lt; int(s.size()); i++)
for (int j = i + 1; j &amp;lt; int(s.size()); j++)
for (int k = j + 1; k &amp;lt; int(s.size()); k++)
if (s[i] == 'j' &amp;amp;&amp;amp; s[j] == 'y' &amp;amp;&amp;amp; s[k] == 'y')
ans++;
&lt;/code>&lt;/pre>
&lt;p>但由于此题中 $|s|$ 达到了 $10^6$，使用三重循环意味着循环最内层的核心语句被执行了将近 $|s|^3$ 次。计算机一秒钟可以执行的 C/C++ 基本语句数目大约在 $10^8$ 量级，这样的程序显然会超时。&lt;/p>
&lt;p>本题的出题助教 (aka. dxy) 希望大家构造自动机解题。这里我们给出一个另外的比较简单的思路：&lt;/p>
&lt;p>我们将上面的程序改写为如下伪代码：&lt;/p>
&lt;pre>&lt;code class="language-c++">ans = 0;
for (int i = 0; i &amp;lt; int(s.size()); i++)
if (s[i] == 'j')
ans += &amp;quot;s[i]的后面(可以不连续)的yy的数目&amp;quot;;
&lt;/code>&lt;/pre>
&lt;p>之前的代码中我们使用双重循环来数 &amp;ldquo;yy&amp;rdquo; 的个数，但事实上我们有更快速的方法：假设 &lt;code>s[i]&lt;/code> 的后面一共有 &lt;code>n&lt;/code> 个字母y，那么任意挑选两个都可以组成一个yy，所以yy的总数目是 $\binom{n}{2}=\frac{1}{2}n(n-1)$。基于这个想法，我们可以用一个二重循环解决该问题：&lt;/p>
&lt;pre>&lt;code class="language-c++">ans = 0;
for (int i = 0; i &amp;lt; int(s.size()); i++)
if (s[i] == 'j')
{
int y_count = 0;
for (int j = i + 1; j &amp;lt; int(s.size()); j++)
if (s[j] == 'y') y_count++;
ans += C(y_count, 2); // 组合数需要另外实现
}
&lt;/code>&lt;/pre>
&lt;p>我们的算法已经得到了改进，但 $|s|^2$ 次运行仍然无法在规定时间内获得结果，算法还需要进一步的优化。上述方法的瓶颈在于我们每遇到一个j都会把它后面的y数一遍，这样有很多的字母y被反复数了很多遍，这无疑拖慢了速度。&lt;/p>
&lt;p>事实上，一个巧妙的顺序的改变就可以“柳暗花明”：我们将外层循环的顺序倒过来，一边寻找j一边把j“身后”的y的个数数出来，这样就不需要内层循环了：&lt;/p>
&lt;pre>&lt;code class="language-c++">ans = y_count = 0;
for (int i = int(s.size()) - 1; i &amp;gt;= 0; i--) {
if (s[i] == 'y') y_count++;
if (s[i] == 'j') ans += C(y_count, 2);
}
&lt;/code>&lt;/pre>
&lt;p>循环的使用方法博大精深，大家可以仔细体会这段代码。&lt;/p>
&lt;p>此外，本题由于结果过大，最终需要输出答案对 $998244353$ 取模的结果。大家在本题中可能会使用乘法，此时必须格外小心两个 &lt;code>int&lt;/code> 类型变量相乘 (还没来得及取模时) 结果溢出的情况，一个好的解决方案是使用更大的数据类型存储中间结果。&lt;/p></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/20221007/</link><pubDate>Thu, 06 Oct 2022 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/20221007/</guid><description>&lt;h2 id="tf-with-sanitizers">TF with Sanitizers&lt;/h2>
&lt;hr>
&lt;h3 id="addressmemoryub-sanitizer">Address/Memory/UB Sanitizer&lt;/h3>
&lt;ul>
&lt;li>asan/msan: build fail (GCC/Clang)&lt;/li>
&lt;li>ubsan: stuck during linking&lt;/li>
&lt;/ul>
&lt;p align=left>GitHub issue (#50892): "Currently we don't officially support an OSS ASAN build, although one is in the long term roadmap."&lt;/p>
&lt;p align=right>Aug 2, 2021&lt;/p>
&lt;hr>
&lt;h3 id="compute-sanitizer">Compute Sanitizer&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>Don&amp;rsquo;t need additional flags for compilation, even a binary version works (?)&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Command: &lt;code>compute-sanitizer --tool memcheck python3 test.py&lt;/code>&lt;/p>
&lt;pre>&lt;code class="language-python">import tensorflow as tf
_ = tf.config.list_physical_device('GPU')
&lt;/code>&lt;/pre>
&lt;p>=&amp;gt; terminate w/o error&lt;/p>
&lt;pre>&lt;code class="language-python">import tensorflow as tf
_ = tf.abs(1)
&lt;/code>&lt;/pre>
&lt;p>=&amp;gt; deadlock&lt;/p>
&lt;/li>
&lt;/ul></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/20220727/</link><pubDate>Tue, 05 Feb 2019 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/20220727/</guid><description>&lt;h2 id="dl-library-fuzzing">DL Library Fuzzing&lt;/h2>
&lt;hr>
&lt;h2 id="bug-report">Bug Report&lt;/h2>
&lt;ul>
&lt;li>Source: release notes of latest versions, Github PRs&lt;/li>
&lt;li>Version: TensorFlow 2.8&amp;amp;2.9, PyTorch 1.12&lt;/li>
&lt;li>Amount: 40&lt;/li>
&lt;li>Multi-API triggered bugs: 8 (all in PyTorch)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="typical-bugs">Typical Bugs&lt;/h3>
&lt;p>PyTorch, GitHub #73187&lt;/p>
&lt;p align=left>Error: unexpected RuntimeError&lt;/p>
&lt;pre>&lt;code class="language-python">import torch
grad_output = torch.full((1, 1, 1, 4, 4,), 1, dtype=torch.float64, requires_grad=True)
input = torch.full((5, 5, 5, 5, 5,), 3.5e+35, dtype=torch.float64, requires_grad=True)
grid = torch.full((1, 1, 1, 4, 4,), 1, dtype=torch.float64, requires_grad=True)
interpolation_mode = 0
padding_mode = 0
align_corners = True
res = torch.grid_sampler_3d(input, grid, interpolation_mode, padding_mode, align_corners)
grad_out = torch.zeros_like(res)
torch.autograd.backward(res, grad_tensors=grad_out)
&lt;/code>&lt;/pre>
&lt;p>&lt;code>grid_sampler_3d()&lt;/code> + &lt;code>backward()&lt;/code>&lt;/p>
&lt;hr>
&lt;h3 id="typical-bugs-1">Typical Bugs&lt;/h3>
&lt;p>PyTorch, GitHub #75781&lt;/p>
&lt;p align=left>Error: unexpected warning&lt;/p>
&lt;pre>&lt;code class="language-python">import torch
if __name__ == &amp;quot;__main__&amp;quot;:
n = 8
x = torch.zeros(n).normal_()
x.requires_grad = True
z = torch.fft.irfft(x).sum()
z.backward()
&lt;/code>&lt;/pre>
&lt;p>&lt;code>fft()&lt;/code> + &lt;code>irfft()&lt;/code> + &lt;code>sum()&lt;/code> + &lt;code>backward()&lt;/code>&lt;/p>
&lt;hr>
&lt;h3 id="typical-bugs-2">Typical Bugs&lt;/h3>
&lt;p>PyTorch, GitHub #77245&lt;/p>
&lt;p align=left>Error: unexpected RuntimeError&lt;/p>
&lt;pre>&lt;code class="language-python">import torch
def fn(input):
offset = 0
fn_res = torch.diagonal(input, offset=offset, )
return fn_res
input = torch.rand([0, 1], dtype=torch.complex128, requires_grad=True)
torch.autograd.gradcheck(fn, (input), check_forward_ad=True, check_backward_ad=False)
&lt;/code>&lt;/pre>
&lt;p>&lt;code>gradcheck()&lt;/code> + &lt;code>diagonal()&lt;/code> + function parameter&lt;/p>
&lt;hr>
&lt;h3 id="typical-bugs-3">Typical Bugs&lt;/h3>
&lt;p>PyTorch, GitHub #77526&lt;/p>
&lt;p align=left>Error: man-made assertion error&lt;/p>
&lt;pre>&lt;code class="language-python">a = torch.randn((2, 2), dtype=torch.cfloat).transpose(0, 1)
result = torch.abs(a)
assert a.stride() == b.stride()
&lt;/code>&lt;/pre>
&lt;p>&lt;code>transpose()&lt;/code> + &lt;code>abs()&lt;/code> + &lt;code>stride()&lt;/code>&lt;/p>
&lt;hr>
&lt;h3 id="summary">Summary&lt;/h3>
&lt;ul>
&lt;li>Linux kernel(system) v.s. DL libraries(tool kit)&lt;/li>
&lt;li>Common bug types
&lt;ul>
&lt;li>Integer overflow, division by zero&lt;/li>
&lt;li>Out of memory(OOM), Out of bound(OOB)&lt;/li>
&lt;li>missing validation&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="documentation">Documentation&lt;/h2>
&lt;hr>
&lt;h3 id="documentation-semantics">Documentation Semantics&lt;/h3>
&lt;p>Learning-based semantics &amp;lsquo;understanding&amp;rsquo; seems inevitable. $\Rightarrow$ NLP work&lt;/p>
&lt;p align=left>An interesting bug: PyTorch, GitHub #70657&lt;/p>
&lt;pre>&lt;code class="language-python">import torch
assert torch.ones(10)[::2].ravel().is_contiguous() == True
&lt;/code>&lt;/pre>
&lt;p align=left>The assertion comes from the document: "ravel() returns a &lt;b>contiguous&lt;/b> flattened tensor".&lt;/p>
&lt;hr>
&lt;h3 id="documentation-structure">Documentation Structure&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>The &amp;ldquo;Python API&amp;rdquo; module is divided into 54 sections, including &lt;code>torch&lt;/code>, &lt;code>torch.nn&lt;/code>, &lt;code>torch.cuda&lt;/code> etc.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>The &lt;code>torch.nn&lt;/code> section is divided into 20 subsections, including &amp;ldquo;Convolutional layers&amp;rdquo;, &amp;ldquo;Pooling layers&amp;rdquo;, &amp;ldquo;Padding layers&amp;rdquo; etc. The section contains 186 APIs in total.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Subsection &amp;ldquo;Convolutional layers&amp;rdquo; includes 14 APIs, offering rich relational information.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="documentation-structure-1">Documentation Structure&lt;/h3>
&lt;p align=left>Drawback: scalability (e.g. TensorFlow's doc only has coarse categories and lots of APIs are sorted in chronological order.)&lt;/p>
&lt;hr>
&lt;h2 id="fuzzing">Fuzzing&lt;/h2>
&lt;hr>
&lt;h3 id="fuzzing-perspectives">Fuzzing perspectives&lt;/h3>
&lt;ul>
&lt;li>Model-level fuzzing: CRADLE, LEMON&lt;/li>
&lt;li>Sub-Model?&lt;/li>
&lt;li>API-level fuzzing: FreeFuzz, DeepREL, DocTer&lt;/li>
&lt;li>Sub-API?&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="sub-model-fuzzing">Sub-Model Fuzzing&lt;/h3>
&lt;ul>
&lt;li>Model-level: precison loss, hard for mutation&amp;hellip;&lt;/li>
&lt;li>API-level: complicated situations uncovered&lt;/li>
&lt;/ul>
&lt;p align=left>
Sub-model level fuzzing serves as an auxiliary approach to cover more cases. The scale of sub-models/combinations of APIs should be small.
&lt;/p>
&lt;hr>
&lt;h4 id="example-pytorch-github-74404">Example: PyTorch, GitHub #74404&lt;/h4>
&lt;pre>&lt;code class="language-python">import torch
from torch import nn
class MyModule(nn.Module):
def __init__(self):
super().__init__()
self.module_list = nn.ModuleList([nn.Linear(1,1) for _ in range(10)])
self.parameter_list = nn.ParameterList([nn.Parameter(torch.zeros(1)) for _ in range(10)])
def forward(self, x):
for m in self.module_list:
x = m(x)
return x
if __name__ == '__main__':
model = MyModule()
optimize = True
with torch.jit.optimized_execution(optimize):
a = torch.jit.script(model, 2)
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h3 id="sub-api-fuzzing">Sub-API Fuzzing&lt;/h3>
&lt;p>Under Python APIs: C++ codes&lt;/p>
&lt;p align=left>Idea:&lt;/p>
&lt;ul>
&lt;li>Most bugs come from missing validations/boundary argument values.&lt;/li>
&lt;li>The propagation chain of a bug: fault $\to$ error $\to$ failure&lt;/li>
&lt;li>#error &amp;gt; #failure&lt;/li>
&lt;/ul>
&lt;p align=left>Open the state machine&lt;/p>
&lt;hr>
&lt;h3 id="example-assertion-injections">Example: assertion injections&lt;/h3>
&lt;p align=left>TensorFlow: CVE-2022-21725 (division by 0)&lt;/p>
&lt;pre>&lt;code class="language-diff">int64_t GetOutputSize(const int64_t input, const int64_t filter,
const int64_t stride, const Padding&amp;amp; padding) {
+ assert(stride != 0);
if (padding == Padding::VALID) {
return (input - filter + stride) / stride; // what if stride = 0 ?
} else { // SAME.
return (input + stride - 1) / stride;
}
}
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h3 id="example-assertion-injections-1">Example: assertion injections&lt;/h3>
&lt;p align=left>TensorFlow: CVE-2022-21728 (heap OOB)&lt;/p>
&lt;pre>&lt;code class="language-diff">static DimensionHandle DimKnownRank(ShapeHandle s, int64_t idx) {
+ assert(-s-&amp;gt;dims_.size() &amp;lt;= idx &amp;amp;&amp;amp; idx &amp;lt; s-&amp;gt;dims_.size());
CHECK_NE(s-&amp;gt;rank_, kUnknownRank);
if (idx &amp;lt; 0) {
return s-&amp;gt;dims_[s-&amp;gt;dims_.size() + idx];
}
return s-&amp;gt;dims_[idx];
}
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h3 id="example-assertion-injections-2">Example: assertion injections&lt;/h3>
&lt;p align=left>TensorFlow: CVE-2022-23589 (null pointer dereference)&lt;/p>
&lt;pre>&lt;code class="language-diff">NodeDef* mul_left_child = node_map_-&amp;gt;GetNode(node-&amp;gt;input(0));
NodeDef* mul_right_child = node_map_-&amp;gt;GetNode(node-&amp;gt;input(1));
+ assert(mul_left_child != NULL &amp;amp;&amp;amp; mul_right_child != NULL);
const bool left_child_is_constant = IsReallyConstant(*mul_left_child);
const bool right_child_is_constant = IsReallyConstant(*mul_right_child);
&lt;/code>&lt;/pre></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/20220907/</link><pubDate>Tue, 05 Feb 2019 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/20220907/</guid><description>&lt;h2 id="atheris-automation">Atheris Automation&lt;/h2>
&lt;hr>
&lt;h2 id="methodology">Methodology&lt;/h2>
&lt;hr>
&lt;ul>
&lt;li>Use FreeFuzz&amp;rsquo;s tests as templates&lt;/li>
&lt;li>Substitute concrete values with appropriate functions
&lt;pre>&lt;code class="language-python">arg_1 = 56 --&amp;gt; arg_1 = fh.get_int()
arg_1 = [-1.0, 6.0] --&amp;gt; arg_1 = fh.get_float_list()
padding = 'VALID' --&amp;gt; padding = fh.get_string(type=padding)
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>An Example:
&lt;pre>&lt;code class="language-python">def TestOneInput(data):
fh = FuzzingHelper(data)
arg_0_tensor = fh.get_random_tensor(
shape=None,
dtype_set=[tf.float16, tf.float32, tf.float64,
tf.int32, tf.int64],
min_size=1,
max_size=8)
arg_0 = tf.identity(arg_0_tensor)
dtype = tf.float16
_ = tf.cast(arg_0,dtype=dtype,)
&lt;/code>&lt;/pre>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p align=left>Issue: lots of APIs contains hidden specifications.&lt;/p>
&lt;p align=left>Example: tf.nn.conv2d()&lt;/p>
&lt;ul>
&lt;li>Specification:
&lt;ul>
&lt;li>The input tensor may have rank 4 or higher.&lt;/li>
&lt;li>&lt;code>padding&lt;/code> should be in &lt;code>{'VALID', 'SAME'}&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A dummy test such as
&lt;pre>&lt;code class="language-python">arg_0 = fh.get_random_tensor()
arg_1 = fh.get_random_tensor()
strides = fh.get_int()
padding = fh.get_string()
_ = tf.nn.conv2d(arg_0,arg_1,strides=strides,padding=padding,)
&lt;/code>&lt;/pre>
will fail.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p align=left>Solution: use FreeFuzz to find specifications through trials and errors.&lt;/p>
&lt;p align=left>Example:&lt;/p>
&lt;ul>
&lt;li>Goal: Identify whether the API accepts negative inputs.&lt;/li>
&lt;li>Steps:
&lt;ul>
&lt;li>Obtain a valid FreeFuzz test.&lt;/li>
&lt;li>Substitute the argument with &amp;ldquo;-1&amp;rdquo;.&lt;/li>
&lt;li>Execute the modified tests and try to catch exceptions.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;ul>
&lt;li>&lt;code>int/float&lt;/code>: value range&lt;/li>
&lt;li>&lt;code>int/float&lt;/code> list: length, negative value&lt;/li>
&lt;li>&lt;code>string&lt;/code>: special names (reduction/padding/activation/channel)&lt;/li>
&lt;li>Tensor: dtype, shape(length, value range)&lt;/li>
&lt;/ul>
&lt;p align=left>Sometimes, two lists/tensors are required to have the same length/dimension. In this situation, we analyze the structure of an invocation and try to add restrictions on arguments on the same level.
&lt;hr>
&lt;h2 id="results">Results&lt;/h2>
&lt;hr>
&lt;h3 id="success-rate">Success Rate&lt;/h3>
&lt;p align=left>(Here success means that the test terminates without "InvalidArgument" errors.)&lt;/p>
&lt;ul>
&lt;li>Before specification learning: &amp;lt;50%&lt;/li>
&lt;li>After specification learning: 478/533, 89.6%&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="coverage">Coverage&lt;/h3>
&lt;p align=left>5 untrivial APIs were selected for coverage test:&lt;/p>
&lt;ul>
&lt;li>&lt;code>tf.random.stateless_parameterized_truncated_normal&lt;/code>&lt;/li>
&lt;li>&lt;code>tf.optimizers.schedules.ExponentialDecay&lt;/code>&lt;/li>
&lt;li>&lt;code>tf.keras.layers.SpatialDropout3D&lt;/code>&lt;/li>
&lt;li>&lt;code>tf.keras.layers.Convolution3DTranspose&lt;/code>&lt;/li>
&lt;li>&lt;code>tf.keras.initializers.LecunNormal&lt;/code>&lt;/li>
&lt;/ul>
&lt;p align=left>150 tests were generated for each API&lt;/p>
&lt;hr>
&lt;h3 id="coverage-1">Coverage&lt;/h3>
&lt;p align=left>The dummy test&lt;/p>
&lt;pre>&lt;code class="language-python">import tensorflow as tf
&lt;/code>&lt;/pre>
&lt;p align=left>covers 70005 lines. Besides that,&lt;/p>
&lt;ul>
&lt;li>FreeFuzz&amp;rsquo;s tests cover 1151 lines.&lt;/li>
&lt;li>Atheris&amp;rsquo;s tests cover 1040 lines.&lt;/li>
&lt;li>1038 of which are common.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="current-problems">Current Problems&lt;/h3>
&lt;ul>
&lt;li>We cannot learn complicated specifications through simple experiments.
&lt;ul>
&lt;li>FreeFuzz itself is not very stable: some tests rely on random seeds to run normally.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>We haven&amp;rsquo;t found bugs through these tests.
&lt;ul>
&lt;li>Some OOM bugs have been caught but it seems that they are false positive bugs due to my local machine limitations.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/20220923/</link><pubDate>Tue, 05 Feb 2019 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/20220923/</guid><description>&lt;h2 id="cc-coverage">C/C++ Coverage&lt;/h2>
&lt;hr>
&lt;h2 id="methods">Methods&lt;/h2>
&lt;hr>
&lt;p align=left>
We found that running programs with Atheris will not produce coverage information, i.e. ".gcda" files. Jiawei has created an issue in Atheris's GitHub repository.
&lt;/p>
&lt;p align=left>
We manage to walk around this issue by tracing the bytes generated by Atheris and replay the tests natively.
&lt;/p>
&lt;hr>
&lt;h2 id="results">Results&lt;/h2>
&lt;hr>
&lt;p align=left>5 APIs, 100 tests under FreeFuzz &amp; Atheris&lt;/p>
&lt;ul>
&lt;li>How many lines are covered.&lt;/li>
&lt;li>How many lines are covered only by FreeFuzz/Atheris.&lt;/li>
&lt;li>How many lines are executed more.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;code>tf.keras.initializers.LecunNormal&lt;/code>&lt;/p>
&lt;p align=left>Overall:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">211&lt;/td>
&lt;td style="text-align:center">2390&lt;/td>
&lt;td style="text-align:center">6447&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">105&lt;/td>
&lt;td style="text-align:center">1468&lt;/td>
&lt;td style="text-align:center">6342&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p align=left>TensorFlow:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">109&lt;/td>
&lt;td style="text-align:center">1080&lt;/td>
&lt;td style="text-align:center">3735&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">33&lt;/td>
&lt;td style="text-align:center">884&lt;/td>
&lt;td style="text-align:center">3659&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;p>&lt;code>tf.keras.layers.Convolution3DTranspose&lt;/code>&lt;/p>
&lt;p align=left>All:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">192&lt;/td>
&lt;td style="text-align:center">5884&lt;/td>
&lt;td style="text-align:center">16423&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">163&lt;/td>
&lt;td style="text-align:center">1457&lt;/td>
&lt;td style="text-align:center">16394&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p align=left>TensorFlow:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">80&lt;/td>
&lt;td style="text-align:center">2907&lt;/td>
&lt;td style="text-align:center">5649&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">11&lt;/td>
&lt;td style="text-align:center">461&lt;/td>
&lt;td style="text-align:center">5580&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;p>&lt;code>tf.optimizers.schedules.ExponentialDecay&lt;/code>&lt;/p>
&lt;p align=left>All:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">194&lt;/td>
&lt;td style="text-align:center">2307&lt;/td>
&lt;td style="text-align:center">6511&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">117&lt;/td>
&lt;td style="text-align:center">2383&lt;/td>
&lt;td style="text-align:center">6434&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p align=left>TensorFlow:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">166&lt;/td>
&lt;td style="text-align:center">1340&lt;/td>
&lt;td style="text-align:center">3872&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">15&lt;/td>
&lt;td style="text-align:center">1311&lt;/td>
&lt;td style="text-align:center">3720&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;p>&lt;code>tf.losses.CategoricalHinge&lt;/code>&lt;/p>
&lt;p align=left>All:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">34&lt;/td>
&lt;td style="text-align:center">181&lt;/td>
&lt;td style="text-align:center">7045&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">52&lt;/td>
&lt;td style="text-align:center">3136&lt;/td>
&lt;td style="text-align:center">7064&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p align=left>TensorFlow:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">20&lt;/td>
&lt;td style="text-align:center">25&lt;/td>
&lt;td style="text-align:center">4171&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">1&lt;/td>
&lt;td style="text-align:center">1871&lt;/td>
&lt;td style="text-align:center">4153&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;p>&lt;code>tf.math.reduce_sum&lt;/code>&lt;/p>
&lt;p align=left>All:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">589&lt;/td>
&lt;td style="text-align:center">2955&lt;/td>
&lt;td style="text-align:center">7247&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">178&lt;/td>
&lt;td style="text-align:center">1992&lt;/td>
&lt;td style="text-align:center">6838&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p align=left>TensorFlow:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">&lt;/th>
&lt;th style="text-align:center">Unique&lt;/th>
&lt;th style="text-align:center">More Execution&lt;/th>
&lt;th style="text-align:center">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">FreeFuzz&lt;/td>
&lt;td style="text-align:center">350&lt;/td>
&lt;td style="text-align:center">1805&lt;/td>
&lt;td style="text-align:center">4257&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">Atheris&lt;/td>
&lt;td style="text-align:center">57&lt;/td>
&lt;td style="text-align:center">994&lt;/td>
&lt;td style="text-align:center">3965&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/20220930/</link><pubDate>Tue, 05 Feb 2019 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/20220930/</guid><description>&lt;h2 id="scalability">Scalability&lt;/h2>
&lt;hr>
&lt;h3 id="setup">Setup&lt;/h3>
&lt;ul>
&lt;li>1000 times for each API (FreeFuzz&amp;rsquo;s standard)&lt;/li>
&lt;li>Including invalid inputs&lt;/li>
&lt;li>Total coverage (including C++&amp;rsquo;s libraries)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="coverage">Coverage&lt;/h3>
&lt;ul>
&lt;li>209 APIs were tested.&lt;/li>
&lt;li>Approximately 150 new lines per API.&lt;/li>
&lt;li>4158 new lines are covered, 567 of which are &amp;ldquo;completely new&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="speed">Speed&lt;/h3>
&lt;ul>
&lt;li>Approximately 5 min per API (with coverage test) :(&lt;/li>
&lt;li>Several seconds per API (generate tests only)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="optimization">Optimization&lt;/h2>
&lt;hr>
&lt;p align=left>Three strategies:&lt;/p>
&lt;ul>
&lt;li>Successful-execution path:
&lt;ul>
&lt;li>Leverage FreeFuzz&amp;rsquo;s mutation strategies. (working)&lt;/li>
&lt;li>Non-aggresive argument generation (with learned specifications)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Error-handling path:
&lt;ul>
&lt;li>Aggresive argument generation (no specification)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="atheris-test-framework">Atheris Test Framework&lt;/h3>
&lt;p align=left>Example: tf.tile&lt;/p>
&lt;pre>&lt;code class="language-python">def TestOneInput(data):
fh = FuzzingHelper(data)
aggresive = False if fh.random_dice(0.3) else True
follow_freefuzz = False if fh.random_dice(0.6) else True
arg_0_tensor = get_argument_arg_0_tensor(
name='arg_0_tensor', fh,
aggresive=aggresive, follow_freefuzz=follow_freefuzz)
arg_0 = tf.identity(arg_0_tensor)
arg_1 = get_argument_arg_1(
name='arg_1', fh,
aggresive=aggresive, follow_freefuzz=follow_freefuzz)
_ = tf.tile(arg_0, arg_1,)
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h3 id="atheris-test-framework-contd">Atheris Test Framework (cont&amp;rsquo;d)&lt;/h3>
&lt;pre>&lt;code class="language-python">def get_argument_arg_0_tensor(name, fh, aggresive=False, follow_freefuzz=False):
global argument_dict
if name not in argument_dict:
follow_freefuzz = False
if follow_freefuzz is True:
res = fh.mutate(argument_dict[name])
elif not aggresive:
res = fh.get_random_tensor(shape=None,
dtype_set=[tf.float16, tf.float32, tf.float64, tf.int32, tf.int64],
min_size=3, max_size=3)
else:
res = fh.get_random_tensor()
argument_dict[name] = res
return res
&lt;/code>&lt;/pre></description></item><item><title>Slides</title><link>https://chenhn02.github.io/slides/example/</link><pubDate>Tue, 05 Feb 2019 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/slides/example/</guid><description>&lt;h1 id="create-slides-in-markdown-with-academic">Create slides in Markdown with Academic&lt;/h1>
&lt;p>
&lt;a href="https://sourcethemes.com/academic/" target="_blank" rel="noopener">Academic&lt;/a> |
&lt;a href="https://sourcethemes.com/academic/docs/managing-content/#create-slides" target="_blank" rel="noopener">Documentation&lt;/a>&lt;/p>
&lt;hr>
&lt;h2 id="features">Features&lt;/h2>
&lt;ul>
&lt;li>Efficiently write slides in Markdown&lt;/li>
&lt;li>3-in-1: Create, Present, and Publish your slides&lt;/li>
&lt;li>Supports speaker notes&lt;/li>
&lt;li>Mobile friendly slides&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="controls">Controls&lt;/h2>
&lt;ul>
&lt;li>Next: &lt;code>Right Arrow&lt;/code> or &lt;code>Space&lt;/code>&lt;/li>
&lt;li>Previous: &lt;code>Left Arrow&lt;/code>&lt;/li>
&lt;li>Start: &lt;code>Home&lt;/code>&lt;/li>
&lt;li>Finish: &lt;code>End&lt;/code>&lt;/li>
&lt;li>Overview: &lt;code>Esc&lt;/code>&lt;/li>
&lt;li>Speaker notes: &lt;code>S&lt;/code>&lt;/li>
&lt;li>Fullscreen: &lt;code>F&lt;/code>&lt;/li>
&lt;li>Zoom: &lt;code>Alt + Click&lt;/code>&lt;/li>
&lt;li>
&lt;a href="https://github.com/hakimel/reveal.js#pdf-export" target="_blank" rel="noopener">PDF Export&lt;/a>: &lt;code>E&lt;/code>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="code-highlighting">Code Highlighting&lt;/h2>
&lt;p>Inline code: &lt;code>variable&lt;/code>&lt;/p>
&lt;p>Code block:&lt;/p>
&lt;pre>&lt;code class="language-python">porridge = &amp;quot;blueberry&amp;quot;
if porridge == &amp;quot;blueberry&amp;quot;:
print(&amp;quot;Eating...&amp;quot;)
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h2 id="math">Math&lt;/h2>
&lt;p>In-line math: $x + y = z$&lt;/p>
&lt;p>Block math:&lt;/p>
&lt;p>$$
f\left( x \right) = ;\frac{{2\left( {x + 4} \right)\left( {x - 4} \right)}}{{\left( {x + 4} \right)\left( {x + 1} \right)}}
$$&lt;/p>
&lt;hr>
&lt;h2 id="fragments">Fragments&lt;/h2>
&lt;p>Make content appear incrementally&lt;/p>
&lt;pre>&lt;code>{{% fragment &amp;quot;weight&amp;quot;=1%}} One {{% /fragment %}}
{{% fragment &amp;quot;weight&amp;quot;=1%}} **Two** {{% /fragment %}}
{{% fragment %}} Three {{% /fragment %}}
&lt;/code>&lt;/pre>
&lt;p>Press &lt;code>Space&lt;/code> to play!&lt;/p>
&lt;span class="fragment " >
One
&lt;/span>
&lt;span class="fragment " >
**Two**
&lt;/span>
&lt;span class="fragment " >
Three
&lt;/span>
&lt;hr>
&lt;p>A fragment can accept two optional parameters:&lt;/p>
&lt;ul>
&lt;li>&lt;code>class&lt;/code>: use a custom style (requires definition in custom CSS)&lt;/li>
&lt;li>&lt;code>weight&lt;/code>: sets the order in which a fragment appears&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="speaker-notes">Speaker Notes&lt;/h2>
&lt;p>Add speaker notes to your presentation&lt;/p>
&lt;pre>&lt;code class="language-markdown">{{% speaker_note %}}
- Only the speaker can read these notes
- Press `S` key to view
{{% /speaker_note %}}
&lt;/code>&lt;/pre>
&lt;p>Press the &lt;code>S&lt;/code> key to view the speaker notes!&lt;/p>
&lt;aside class="notes">
&lt;ul>
&lt;li>Only the speaker can read these notes&lt;/li>
&lt;li>Press &lt;code>S&lt;/code> key to view&lt;/li>
&lt;/ul>
&lt;/aside>
&lt;hr>
&lt;h2 id="themes">Themes&lt;/h2>
&lt;ul>
&lt;li>black: Black background, white text, blue links (default)&lt;/li>
&lt;li>white: White background, black text, blue links&lt;/li>
&lt;li>league: Gray background, white text, blue links&lt;/li>
&lt;li>beige: Beige background, dark text, brown links&lt;/li>
&lt;li>sky: Blue background, thin dark text, blue links&lt;/li>
&lt;/ul>
&lt;hr>
&lt;ul>
&lt;li>night: Black background, thick white text, orange links&lt;/li>
&lt;li>serif: Cappuccino background, gray text, brown links&lt;/li>
&lt;li>simple: White background, black text, blue links&lt;/li>
&lt;li>solarized: Cream-colored background, dark green text, blue links&lt;/li>
&lt;/ul>
&lt;hr>
&lt;section data-noprocess data-shortcode-slide
data-background-image="/img/boards.jpg"
>
&lt;h2 id="custom-slide">Custom Slide&lt;/h2>
&lt;p>Customize the slide style and background&lt;/p>
&lt;pre>&lt;code class="language-markdown">{{&amp;lt; slide background-image=&amp;quot;/img/boards.jpg&amp;quot; &amp;gt;}}
{{&amp;lt; slide background-color=&amp;quot;#0000FF&amp;quot; &amp;gt;}}
{{&amp;lt; slide class=&amp;quot;my-style&amp;quot; &amp;gt;}}
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h2 id="custom-css-example">Custom CSS Example&lt;/h2>
&lt;p>Let&amp;rsquo;s make headers navy colored.&lt;/p>
&lt;p>Create &lt;code>assets/css/reveal_custom.css&lt;/code> with:&lt;/p>
&lt;pre>&lt;code class="language-css">.reveal section h1,
.reveal section h2,
.reveal section h3 {
color: navy;
}
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h1 id="questions">Questions?&lt;/h1>
&lt;p>
&lt;a href="https://spectrum.chat/academic" target="_blank" rel="noopener">Ask&lt;/a>&lt;/p>
&lt;p>
&lt;a href="https://sourcethemes.com/academic/docs/managing-content/#create-slides" target="_blank" rel="noopener">Documentation&lt;/a>&lt;/p></description></item><item><title>External Project</title><link>https://chenhn02.github.io/project/external-project/</link><pubDate>Wed, 27 Apr 2016 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/project/external-project/</guid><description/></item><item><title>Internal Project</title><link>https://chenhn02.github.io/project/internal-project/</link><pubDate>Wed, 27 Apr 2016 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/project/internal-project/</guid><description>&lt;p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis posuere tellus ac convallis placerat. Proin tincidunt magna sed ex sollicitudin condimentum. Sed ac faucibus dolor, scelerisque sollicitudin nisi. Cras purus urna, suscipit quis sapien eu, pulvinar tempor diam. Quisque risus orci, mollis id ante sit amet, gravida egestas nisl. Sed ac tempus magna. Proin in dui enim. Donec condimentum, sem id dapibus fringilla, tellus enim condimentum arcu, nec volutpat est felis vel metus. Vestibulum sit amet erat at nulla eleifend gravida.&lt;/p>
&lt;p>Nullam vel molestie justo. Curabitur vitae efficitur leo. In hac habitasse platea dictumst. Sed pulvinar mauris dui, eget varius purus congue ac. Nulla euismod, lorem vel elementum dapibus, nunc justo porta mi, sed tempus est est vel tellus. Nam et enim eleifend, laoreet sem sit amet, elementum sem. Morbi ut leo congue, maximus velit ut, finibus arcu. In et libero cursus, rutrum risus non, molestie leo. Nullam congue quam et volutpat malesuada. Sed risus tortor, pulvinar et dictum nec, sodales non mi. Phasellus lacinia commodo laoreet. Nam mollis, erat in feugiat consectetur, purus eros egestas tellus, in auctor urna odio at nibh. Mauris imperdiet nisi ac magna convallis, at rhoncus ligula cursus.&lt;/p>
&lt;p>Cras aliquam rhoncus ipsum, in hendrerit nunc mattis vitae. Duis vitae efficitur metus, ac tempus leo. Cras nec fringilla lacus. Quisque sit amet risus at ipsum pharetra commodo. Sed aliquam mauris at consequat eleifend. Praesent porta, augue sed viverra bibendum, neque ante euismod ante, in vehicula justo lorem ac eros. Suspendisse augue libero, venenatis eget tincidunt ut, malesuada at lorem. Donec vitae bibendum arcu. Aenean maximus nulla non pretium iaculis. Quisque imperdiet, nulla in pulvinar aliquet, velit quam ultrices quam, sit amet fringilla leo sem vel nunc. Mauris in lacinia lacus.&lt;/p>
&lt;p>Suspendisse a tincidunt lacus. Curabitur at urna sagittis, dictum ante sit amet, euismod magna. Sed rutrum massa id tortor commodo, vitae elementum turpis tempus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean purus turpis, venenatis a ullamcorper nec, tincidunt et massa. Integer posuere quam rutrum arcu vehicula imperdiet. Mauris ullamcorper quam vitae purus congue, quis euismod magna eleifend. Vestibulum semper vel augue eget tincidunt. Fusce eget justo sodales, dapibus odio eu, ultrices lorem. Duis condimentum lorem id eros commodo, in facilisis mauris scelerisque. Morbi sed auctor leo. Nullam volutpat a lacus quis pharetra. Nulla congue rutrum magna a ornare.&lt;/p>
&lt;p>Aliquam in turpis accumsan, malesuada nibh ut, hendrerit justo. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque sed erat nec justo posuere suscipit. Donec ut efficitur arcu, in malesuada neque. Nunc dignissim nisl massa, id vulputate nunc pretium nec. Quisque eget urna in risus suscipit ultricies. Pellentesque odio odio, tincidunt in eleifend sed, posuere a diam. Nam gravida nisl convallis semper elementum. Morbi vitae felis faucibus, vulputate orci placerat, aliquet nisi. Aliquam erat volutpat. Maecenas sagittis pulvinar purus, sed porta quam laoreet at.&lt;/p></description></item><item><title>English</title><link>https://chenhn02.github.io/courses/problemsolving/cser0/english/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/cser0/english/</guid><description>&lt;p>随着科学技术的发展, 在国际学术交流中使用英语已经成为常态: 顶尖的论文无一不使用英文来书写, 在国际上公认的计算机领域经典书籍也是使用英文编著。顶尖的论文没有中文翻译版; 如果需要获取信息, 也应该主动去阅读英文材料, 而不是等翻译版出版。&amp;ldquo;我是中国人, 我只看中文&amp;quot;这类观点已经不符合时代发展的潮流, 要站在时代的最前沿, 阅读英文材料的能力是不可或缺的。&lt;/p>
&lt;p>想要学好计算机，大家一定要趁早“逼迫”自己熟悉英语环境，尤其强化自己的英语阅读能力 (将来快速略读大段的英文手册很可能是你的工作/学习常态)。我们强烈建议你注意以下几个方面：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>不要依赖教材的中文翻译版本。&lt;/strong> 将来大家学习的《算法导论》等教材是比较容易找到中文翻译版本的，但正如名著的翻译版时常体现不出名著本身的意境，翻译版的教材在很多细节上可能与原版有出入，影响大家的理解，也不利于大家熟悉一些英文术语。我们认为，英文阅读能力合格的标准之一便是阅读学术方面的英文材料和中文材料应该基本不存在速度差异。这个过程刚开始可能会比较痛苦，但随着学期深入大家一定能克服障碍。&lt;del>大家也不必对这个标准感到过于惊慌，因为助教认为自己的英语阅读能力也不合格&lt;/del>&lt;/li>
&lt;li>&lt;strong>不要依赖百度等中文网站解决问题。&lt;/strong> 在刚刚接触计算机以及学习算法的初级阶段，中文的博客 (以CSDN为主) 可以帮助大家解决一部分问题。但随着大家水平的提高，很多复杂的问题 (尤其是与系统配置，环境配置相关的问题) 在中文环境下很难找到契合的回答。这时推荐大家使用对应的英文关键字在 google/bing/stackoverflow 上搜索回答。&lt;/li>
&lt;li>(如果我想到了会再添加)&lt;/li>
&lt;/ul>
&lt;p>英语的学习和强化方法是一个见仁见智的问题。我们的建议是不要期待 &lt;em>大学英语读写/大学英语视听说&lt;/em> 能给你带来质的改变。提升语言能力的核心还是多读多听多说多写。引用陈道蓄老师的话: “坚持一年, 你就会发现有不同; 坚持两年, 你就会发现大有不同。”&lt;/p></description></item><item><title>Coding Environment</title><link>https://chenhn02.github.io/courses/problemsolving/cser0/ide/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/cser0/ide/</guid><description>&lt;p>正所谓“工欲善其事，必先利其器”，想要拥有良好的代码书写体验就应当配置好一套舒适的代码编写环境。打开 Windows 的“记事本”编写代码可不是什么好的选择——一方面记事本无法提供代码高亮、自动补全、智能缩进等功能，盯着白纸黑字写代码颇为心累；另一方面即使你写完了代码，记事本也没有集成好配套的编译、调试工具帮助你确定代码的正确性。&lt;/p>
&lt;p>很多优秀的程序员都会选择 IDE (Integrated Development Environment，中文翻译为集成开发环境）来书写代码。所谓集成开发环境，就是将写代码所需要的一系列工具都集成在了一个软件里，从书写代码所需的编辑器，到编译运行代码所需的编译器、链接器，到调试代码所需的调试器……IDE里应有尽有，且很多功能都可以“一键运行”，极好地将程序员的精力解放了出来。&lt;/p>
&lt;p>由于本课程主要希望培养大家掌握 C/C++ 这门语言，所以我们着重介绍了几个和 C/C++ 配合体验良好的 IDE，包括 Dev-C++ (极其推荐新手入门使用，入门门槛低)，Visual Studio (工业级的重型IDE，功能强大但身材略显臃肿), Visual Studio Code (宇宙最强编辑器) 等，大家可以参考杜星宇助教撰写的
&lt;a href="https://chenhn02.github.io/docs/C-env.pdf">这篇文档&lt;/a> 来学习。&lt;/p>
&lt;p>如果你已经是比较有经验的代码书写者，你可能并不需要 IDE。你也许会习惯使用 Vim/Emacs 等老牌编辑器，自己写脚本编译和运行代码，使用 GDB 进行调试……如果你能驾驭这些，我们当然也鼓励你充分地使用这些命令行工具。不过我们还是极力地推荐你尝试一下 VS Code (这不是为微软打广告)，其丰富的第三方插件库，强大的远程连接功能，自由的代码跳转、补全、纠错使其成为编辑器界的 killer application。毕竟写代码怎么舒服怎么来，能拥有一个
&lt;a href="https://code.visualstudio.com/" target="_blank" rel="noopener">这样&lt;/a> 炫酷的界面何乐而不为呢？&lt;/p></description></item><item><title>Self Learning</title><link>https://chenhn02.github.io/courses/problemsolving/cser0/self-learning/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/cser0/self-learning/</guid><description>&lt;p>高中的时候，每天老师会给你布置一堆试卷，你什么也不用管只需要跟着老师的节奏走；但大学里，你很可能在课下根本找不到老师的踪影，大学课程的作业量通常不大，每天会有大量的空余时间不知道该做什么……因此进入大学后，大家一定要扭转自己的学习模式，做一个 self-motivated 的人。你应当学会为自己规划：应该看一些什么样的书，应当上一些什么样的网课，如果打算出国我是不是应当着手准备英语测试……学会 self-learning，你才能在大学中存活下来。&lt;/p>
&lt;p>上面的话是各个学科通用的，着眼于计算机这个学科，self-learning 有其独特的方法。计算机学科的一大特点在于技术更新极快——你学习的某项技术很可能是去年刚刚产生的，很多工具的使用也不会在教科书中写出。因此不同于学习数学物理时四处寻找好的教材，学习计算机一定要学会利用好强大的&lt;strong>互联网&lt;/strong>。学习工具时，多找一找官方的文档 (Almost all official manuals are written in English, which again demonstrates the significance of proficiency in English.)，遇到疑难杂症时，多看一看有没有类似的问题解答可以参照……当你遇到问题第一反应是自己动手寻求解决方案而不是打开和“大腿”的QQ聊天框求救时，你便已经进步了很多。&lt;/p>
&lt;p>下面的话来自大家未来操作系统课程的老师：&lt;/p>
&lt;blockquote>
&lt;p>Search the friendly(f**king) web.&lt;/p>
&lt;p>Read the friendly(f**king) manual.&lt;/p>
&lt;p>其中的 F 让它们更具传奇色彩。&lt;/p>
&lt;/blockquote></description></item><item><title>Typesetting</title><link>https://chenhn02.github.io/courses/problemsolving/cser0/typesetting/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/cser0/typesetting/</guid><description>&lt;p>从某种角度来说，计算机领域的大牛多少有一点偏执——毕竟写代码的时候漏打一个符号便可能酿成大祸，所以学计算机的人总有点“强迫症”。退一步来说，即使你没有强迫症，写作文的时候歪七扭八、行间距不一、每个字都不一样大，开头时不时地忘记空两格……总不是件好事。&lt;/p>
&lt;p>你也许在书面助教的作业要求中注意到了 LaTeX 这个东西。$\LaTeX$ 是一个高质量的排版系统，你可以通过编写代码生成文档的方式来精确地控制文档里的每一处细节，比如分隔线的粗细、图片的大小，页边距的宽窄等等，精度可以达到毫米。我们不强制要求大家使用 LaTeX 书写作业 (因为对于新手来说这可能会耗费很多的时间)，但我们仍然推荐你学习这项工具 (不出意外，你撰写毕业论文的时候是肯定会用到它的)。&lt;/p>
&lt;p>如果你认为 $\LaTeX$ 太过麻烦，不妨尝试一下 Markdown。从原理上来说 Markdown 和 LaTeX 很不一样，但你可以简单地认为它们都是通过一些特殊的符号来生成具有格式，编排美观的文档 (助教博客中的大部分页面都是用 Markdown 书写的)。Markdown 比 LaTeX 简明很多，比如你可以通过 &lt;code>#&lt;/code> 来生成各个级别的标题：&lt;/p>
&lt;h2 id="这是二级标题">这是二级标题&lt;/h2>
&lt;h3 id="这是三级标题">这是三级标题&lt;/h3>
&lt;p>可以通过 &lt;code>*&lt;/code> 来生成列表：&lt;/p>
&lt;ul>
&lt;li>列表1&lt;/li>
&lt;li>列表2&lt;/li>
&lt;/ul>
&lt;p>Markdown 同样支持丰富的数学符号：&lt;/p>
&lt;pre>&lt;code class="language-markdown">\begin{align}
f(n)=\sum_{d|n}g(d)\Longleftrightarrow g(n)=\sum_{d|n}\mu(d)f\left(\frac{n}{d}\right).
\end{align}
&lt;/code>&lt;/pre>
&lt;p>$$
\begin{align}
f(n)=\sum_{d|n}g(d)\Longleftrightarrow g(n)=\sum_{d|n}\mu(d)f\left(\frac{n}{d}\right).
\end{align}
$$&lt;/p>
&lt;p>我们强烈建议你至少掌握一些 Markdown 的基本语法，这可以帮助你将来迅速地写出一份还算美观的文档。&lt;/p></description></item><item><title>Typing</title><link>https://chenhn02.github.io/courses/problemsolving/cser0/typing/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/cser0/typing/</guid><description>&lt;p>对于零基础的同学来说，键盘可能是十分陌生的。但大家既然选择了CS，计算机之于大家就应当相当于宝剑之于骑士，打字就应当像拿筷子吃饭一样熟练。如果你连&lt;/p>
&lt;blockquote>
&lt;p>应当如何通过键盘敲出下划线 _ ？&lt;/p>
&lt;/blockquote>
&lt;p>这个问题都感到无从下手，那你一定要花一些功夫练习打字。&lt;/p>
&lt;p>打字这件事很像投篮。能把球投进篮筐的姿势都是好姿势，同理打得快、准确率高的姿势也都是好姿势。但我们还是建议初学者打好基础，学习正规的十指布局。我们认为一个合格的CSer应当拥有200+cpm (character per minute) 的打字速度。&lt;/p></description></item><item><title>函数</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/functions/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/functions/</guid><description>&lt;p>随着程序规模的扩大，你的 &lt;code>main()&lt;/code> 函数可能变得越来越臃肿了。这时候使用函数将不同的模块分开书写，可以让程序变得条理清晰、简明易懂。例如下面是 OJ 习题 &amp;ldquo;环形矩阵&amp;rdquo; 的一段代码：&lt;/p>
&lt;pre>&lt;code class="language-c++">int n;
int a[100][100];
int main ()
{
scanf(&amp;quot;%d&amp;quot;, &amp;amp;n);
for (int k = 1; k &amp;lt;= (n + 1) / 2; k++)
for (int i = k; i &amp;lt;= n + 1 - k; i++)
for (int j = k; j &amp;lt;= n + 1 - k; j++)
a[i][j] = k;
for (int i = 1; i &amp;lt;= n; i++)
{
for (int j = 1; j &amp;lt;= n; j++)
printf(&amp;quot;%4d&amp;quot;, a[i][j]);
puts(&amp;quot;&amp;quot;);
}
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>读入、矩阵制作、输出三个步骤全部放在 &lt;code>main()&lt;/code> 函数中显得有些冗长。我们可以根据功能将上述代码分成三个模块，每个模块写在一个单独的子函数里：&lt;/p>
&lt;pre>&lt;code class="language-c++">int n;
int a[100][100];
void input()
{
scanf(&amp;quot;%d&amp;quot;, &amp;amp;n);
}
void matrix_generation()
{
for (int k = 1; k &amp;lt;= (n + 1) / 2; k++)
for (int i = k; i &amp;lt;= n + 1 - k; i++)
for (int j = k; j &amp;lt;= n + 1 - k; j++)
a[i][j] = k;
}
void print()
{
for (int i = 1; i &amp;lt;= n; i++)
{
for (int j = 1; j &amp;lt;= n; j++)
printf(&amp;quot;%4d&amp;quot;, a[i][j]);
puts(&amp;quot;&amp;quot;);
}
}
int main ()
{
input();
matrix_generation();
print();
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>由于这个程序比较简单，你可能会觉得这样写代码非常拖沓。但随着程序结构的进一步复杂化，你一定会发现将程序分功能分模块书写能帮助你理清思路，也方便 debug。&lt;/p>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>代码风格&lt;/strong>&lt;/p>
&lt;p>对于初学者来说，养成良好的代码风格极其重要。一方面，条理清晰的代码看起来赏心悦目，方便自己 debug；另一方面，如果你将来与别人合作开发项目，良好的代码风格可以让他人快速理解你书写的模块，方便沟通交流。大家或许听说过一些类似于 “老程序员一走，整个项目就玩不转了，因为没人知道他写了什么” 的笑话，撇开工程复杂性的客观原因，这样的现象也说明很大一部分程序员的代码书写习惯极其糟糕。&lt;/p>
&lt;p>所以无论你是否有编程基础，请一定重视自己的代码风格。我们建议你至少在以下几个方面注意：&lt;/p>
&lt;ul>
&lt;li>合理的变量命名：不要使用无意义的 &lt;code>a&lt;/code> &lt;code>b&lt;/code> &lt;code>c&lt;/code> &lt;code>_&lt;/code> &lt;code>__&lt;/code>，而应该使用 &lt;code>arrayLength&lt;/code> &lt;code>stuCount&lt;/code> 等。有意义的变量名可以让你/他人立刻理解这个变量的用途。至于变量的命名格式，我们建议你搜索驼峰命名法。&lt;/li>
&lt;li>合理的函数使用：不要将一大堆代码塞在一起。&lt;/li>
&lt;li>合理书写注释，对程序的功能做一点解释。&lt;/li>
&lt;li>合理的符号使用 (括号，换行符 etc.)&lt;/li>
&lt;li>合理的缩进&lt;/li>
&lt;li>……&lt;/li>
&lt;/ul>
&lt;p>如果你感兴趣，你可以上网搜索 Google 的代码风格规范。&lt;/p>
&lt;p>最后是一个小笑话：&lt;/p>
&lt;blockquote>
&lt;p>世界上有两件最讨厌的事情，一个是写注释，一个是别人的代码不写注释。&lt;/p>
&lt;/blockquote>
&lt;/div>
&lt;/div>
&lt;h2 id="定义">定义&lt;/h2>
&lt;p>函数的基本语法如下：&lt;/p>
&lt;pre>&lt;code class="language-c++">int func(int a, float b, char c, ...)
{
}
&lt;/code>&lt;/pre>
&lt;p>其中&lt;/p>
&lt;ul>
&lt;li>第一个 &lt;code>int&lt;/code> 指明了函数的返回值类型为 &lt;code>int&lt;/code>。你当然可以选择别的类型，如果你的函数不需要返回值，可以用 &lt;code>void&lt;/code>。&lt;/li>
&lt;li>&lt;code>func&lt;/code> 是函数的名称。这是你可以自由指定的部分。&lt;/li>
&lt;li>&lt;code>(int a, float b, ...)&lt;/code> 是函数的参数。你可以指定任意个数的参数，但每个参数都要有类型。&lt;/li>
&lt;/ul>
&lt;p>下面是一个简明的例子：&lt;/p>
&lt;pre>&lt;code class="language-c++">int abs(int x) {
int res = x &amp;gt;= 0 ? x : -x;
return res;
}
int main ()
{
int x = -1;
x_abs = abs(x); // x_abs = 1
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>注意所有的子函数要写在 main 函数的上方 (这是 C/C++ 的要求)。如果你想要将子函数写在 main 函数的下方，需要在 main 函数之前先&lt;strong>声明&lt;/strong>该子函数。声明简单来说就是将函数的定义抄一遍：&lt;/p>
&lt;pre>&lt;code class="language-c++">int abs(int x);
int main ()
{
int x = -1;
x_abs = abs(x);
return 0;
}
int abs(int x) {
int res = x &amp;gt;= 0 ? x : -x;
return res;
}
&lt;/code>&lt;/pre>
&lt;h2 id="变量的作用域">变量的作用域&lt;/h2>
&lt;p>有了函数的概念后我们就必须要区分“全局变量“和“局部变量”。顾名思义在函数内部定义的变量是局部变量，该变量只能在当前函数内使用。在所有函数外定义的变量是全局变量，该变量可以在任何函数中使用 (注：一个函数的参数也是该函数的局部变量)。下面是一个例子：&lt;/p>
&lt;pre>&lt;code class="language-c++">
int n = 0;
void func()
{
printf(&amp;quot;%d\n&amp;quot;, n); // 输出结果为 0。
printf(&amp;quot;%d\n&amp;quot;, m); // 编译错误：m 是 main() 的局部变量，在 func() 中不可用。
}
int main ()
{
int n = 1, m = 2;
printf(&amp;quot;%d\n&amp;quot;, n); // 输出结果为 1，全局变量和局部变量重名优先使用局部变量。
}
&lt;/code>&lt;/pre>
&lt;h2 id="按值传递和按引用传递">按值传递和按引用传递&lt;/h2>
&lt;p>按值传递和按引用传递是函数中必须提及的另一个重要概念。很多初学者会疑惑：如果我在子函数中对参数的值做了改动，主函数里相应的变量会变吗？你可以做一个小实验探索这一点：&lt;/p>
&lt;pre>&lt;code class="language-c++">void func(int x) { x = 2; }
int main ()
{
int x = 1;
func(x);
printf(&amp;quot;%d\n&amp;quot;, x); // 输出结果为 1
}
&lt;/code>&lt;/pre>
&lt;p>事实证明子函数的改动不会影响主函数。这是因为 C/C++ 中函数变量参数的传递默认使用&lt;strong>按值传递&lt;/strong>，即子函数的参数变量 &lt;code>x&lt;/code> 和主函数的变量 &lt;code>x&lt;/code> 是两个独立的主体，在函数调用时主函数的 &lt;code>x&lt;/code> 的值会被复制到子函数的 &lt;code>x&lt;/code> 中，在子函数中对 &lt;code>x&lt;/code> 的所有改动都是针对这个“复印件”的，对原件没有影响。&lt;/p>
&lt;p>那么有没有办法可以让子函数直接修改主函数中的变量的值呢？ C++ 提供了引用变量的机制，一个例子如下：&lt;/p>
&lt;pre>&lt;code class="language-c++">void func(int x, int &amp;amp;y) { x++; y++; }
int main ()
{
int x = 1, y = 1;
func(x, y);
printf(&amp;quot;%d %d\n&amp;quot;, x, y); // 输出结果为 1 2
}
&lt;/code>&lt;/pre>
&lt;p>在这个例子中，&lt;code>x&lt;/code> 是按值传递的，&lt;code>y&lt;/code> 是&lt;strong>按引用传递&lt;/strong>的 (注意函数参数前的 &lt;code>&amp;amp;&lt;/code> 符号)。按引用传递可以理解为直接将原件交给了子函数，子函数的改动会影响主函数的值。&lt;/p></description></item><item><title>分支</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/branch/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/branch/</guid><description>&lt;p>很多时候我们需要根据某个条件决定去做事情1还是事情2,这就是分支。一个分支框架的格式如下：&lt;/p>
&lt;pre>&lt;code class="language-c++">if (condition)
{
// your code
}
&lt;/code>&lt;/pre>
&lt;p>它的意思是：如果 &lt;code>if ()&lt;/code> 括号中的条件成立，那么就执行大括号中的那些语句，否则跳过这些语句。此外，我们还可以添加 else 分支：&lt;/p>
&lt;pre>&lt;code class="language-c++">if (condition)
{
// your code
}
else
{
// your code
}
&lt;/code>&lt;/pre>
&lt;p>这时如果条件成立就执行紧接着的大括号中的语句，并跳过 else 下的语句；如果条件不成立就跳过紧接着的语句，直接执行 else 下的语句。一个额外的规则是：如果 &amp;ldquo;your code&amp;rdquo; 的地方你只需要写一条语句，你可以省略大括号对 (这样代码看上去更紧凑简洁)，不过如果你没有把握，加上大括号永远是最稳妥最正确的选择。&lt;/p>
&lt;p>我们来看一个简单的例子：输入两个数，如果它们的和是奇数就输出 &amp;ldquo;odd&amp;rdquo;，否则输出 &amp;ldquo;even&amp;rdquo;：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int a, b, sum;
std::cin &amp;gt;&amp;gt; a &amp;gt;&amp;gt; b;
sum = a + b;
if (sum % 2 == 1)
std::cout &amp;lt;&amp;lt; &amp;quot;odd&amp;quot; &amp;lt;&amp;lt; '\n';
else
std::cout &amp;lt;&amp;lt; &amp;quot;even&amp;quot; &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>这里需要对 if 的条件语句做一点说明：&lt;/p>
&lt;ul>
&lt;li>&lt;code>%&lt;/code> 是取模符号，&lt;code>sum % 2&lt;/code> 即计算变量 &lt;code>sum&lt;/code> 除以 2 后的余数。&lt;/li>
&lt;li>在 C++ 中我们要格外小心 &lt;code>==&lt;/code> 和 &lt;code>=&lt;/code> 的区别:
&lt;ul>
&lt;li>
&lt;p>&lt;code>==&lt;/code> 是一个比较运算符，用来比较式子的左边和右边是否相等。其他的一些常用的比较运算符列举如下&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:center">符号&lt;/th>
&lt;th style="text-align:center">意义&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:center">&lt;code>&amp;lt;=&lt;/code>&lt;/td>
&lt;td style="text-align:center">小于等于&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">&lt;code>&amp;lt;&lt;/code>&lt;/td>
&lt;td style="text-align:center">小于&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">&lt;code>&amp;gt;=&lt;/code>&lt;/td>
&lt;td style="text-align:center">大于等于&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">&lt;code>&amp;gt;&lt;/code>&lt;/td>
&lt;td style="text-align:center">大于&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:center">&lt;code>!=&lt;/code>&lt;/td>
&lt;td style="text-align:center">不等于&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;/li>
&lt;li>
&lt;p>&lt;code>=&lt;/code> 是赋值符号，比如在 &lt;code>sum = a + b;&lt;/code> 中，将 &lt;code>a + b&lt;/code> 的值赋给 &lt;code>sum&lt;/code>。&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></description></item><item><title>基本框架</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/framework/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/framework/</guid><description>&lt;p>一个最简单的 C++ 程序长成这样：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
// your code
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>我们对这个程序的几个组成部分做一点说明：&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;include&amp;quot;一行使程序包含了一系列&lt;strong>头文件 (header file)&lt;/strong>, 头文件中定义了许多有用的函数，我们只有使用 include 包含这些头文件才能使用这些函数（头文件在安装环境时就有了，你暂时不需要关心它们在哪里以及是如何实现的，你只需要知道 include 这行几乎是必须要写的）。&lt;/li>
&lt;li>&lt;code>int main () {}&lt;/code> 称为 main 函数。每个程序都必须有 main 函数，当程序开始运行时，第一条执行的指令就是 main 函数的第一条指令。&lt;/li>
&lt;li>&lt;code>return 0;&lt;/code> 是一条语句，无论 main 函数中写了什么内容，最后一样都应当是 &lt;code>return 0;&lt;/code>。&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>C++ 语法&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>
&lt;p>你的每条语句都&lt;strong>必须&lt;/strong>以 &lt;code>;&lt;/code> 结尾。一行可以有多条语句，但每个语句后都要有 &lt;code>;&lt;/code>。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>使用 &lt;code>//&lt;/code> 可以在 C++ 代码中书写&lt;strong>注释 (comment)&lt;/strong>，注释类似于批注，其目的是让阅读代码的人更好地理解代码的意思，注释中的内容不会被执行。此外，如果你想书写一段多行的注释，可以使用如下方法：&lt;/p>
&lt;pre>&lt;code class="language-c++">/*
write your comments here
write your comments here
*/
&lt;/code>&lt;/pre>
&lt;/li>
&lt;/ul>
&lt;/blockquote></description></item><item><title>嵌套循环</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/nestedloop/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/nestedloop/</guid><description>&lt;p>上一节的所有示例代码中我们定义的都是一维数组，一维数组顾名思义就是将所有的”小盒子“排成一排，用一个下标去索引它们。C/C++ 中可以定义各种维度的数组，比如你可以定义一个二维数组来存储方阵：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int a[100][100];
int main ()
{
a[0][0] = 1; // 二维数组的每个维度都是从0开始编号的！
a[2][3] = 5;
std::cout &amp;lt;&amp;lt; a[0][0] &amp;lt;&amp;lt; '\n'; //输出结果为1
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>有了多维数组后，你会发现我们手里原有的线性的循环工具有点不够用了。但事实上我们可以用嵌套循环来轻松地对多维数组进行访问。下面的示例程序接受一个 $n$ 行 $m$ 列的包含整数的方阵作为输入，它会将其原封不动地打印出来 (假设 $n,m\leq 100$)：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int a[100][100];
int main ()
{
int row, col;
std::cin &amp;gt;&amp;gt; row &amp;gt;&amp;gt; col;
// input
for (int i = 0; i &amp;lt; row; i++)
{
for (int j = 0; j &amp;lt; col; j++)
{
std::cin &amp;gt;&amp;gt; a[i][j];
}
}
// output
for (int i = 0; i &amp;lt; row; i++)
for (int j = 0; j &amp;lt; col; j++)
{
char suffix_char = ((j == col - 1) ? '\n' : ' ');
std::cout &amp;lt;&amp;lt; a[i][j] &amp;lt;&amp;lt; suffix_char;
}
}
&lt;/code>&lt;/pre>
&lt;p>从逻辑上来看嵌套循环也没什么“了不起”：以输出部分为例，对于每一个 $i=0,1,\cdots,row-1$，循环变量 $j$ 都会从 $0$ 循环到 $col-1$，这样在内层循环的里面我们就可以按照 $(0,0)$, $(0,1)$,$\cdots$, $(0, col-1)$, $(1, 0)$, $(1, 1)$, $\cdots$ ,$(1, col-1)$, $(2, 0)$,$\cdots$, $(row-1, 0)$, $\cdots$, $(row-1, col-1)$ 的顺序去访问二维数组中的每个元素。&lt;/p>
&lt;p>这个代码中有一些细节值得讲解：&lt;/p>
&lt;ul>
&lt;li>在书写嵌套循环时，内层循环和外层循环应当使用不同的循环变量。如果你在内层循环中使用了和外层循环一样的变量，那么执行完内层循环后回到外层循环时，变量的值就乱了。不要小瞧这条看上去显而易见的结论，你们所有人大抵都会在过去、现在或将来犯一些类似下面的错误：
&lt;pre>&lt;code class="language-c++">for (int i = 0; i &amp;lt; n; i++)
for (int j = 0; j &amp;lt; m; i++)
&lt;/code>&lt;/pre>
&lt;/li>
&lt;li>我们在给出两段双重循环的代码时特地使用了不同的括号风格，你可以借此机会对到底什么时候需要打大括号有更深入的理解：我们之前提到如果当前结构内部只有一条语句，for循环/if语句可以不打大括号；但更准确地说应该是如果只有“一块”一句，比如第二段双重循环的外层循环内部只有一个for循环这“一块”语句，所以可以省略大括号。还是那句话，如果你拿捏不准，把大括号写全永远是最稳妥的。&lt;/li>
&lt;li>示例代码中出现了一个比较奇怪的语句：&lt;code>suffix_char = ((j == col - 1) ? '\n' : ' ')&lt;/code>。&lt;code>? :&lt;/code> 被称为三目运算符，是 C/C++ 提供的一个语法机制。&lt;code>(a ? b : c)&lt;/code> 的意思是如果 &lt;code>a&lt;/code> 的值为真则返回 &lt;code>b&lt;/code>，否则返回 &lt;code>c&lt;/code>。上面的这一行代码等价于一个if语句：
&lt;pre>&lt;code class="language-c++">if (j == col - 1)
suffix_char = '\n';
else
suffix_char = ' ';
&lt;/code>&lt;/pre>
可以看到合理使用三目运算符有助于使代码更加简洁。&lt;/li>
&lt;/ul>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>运算符优先级&lt;/strong>&lt;/p>
&lt;p>你可能会疑惑：三目运算符的代码写成&lt;/p>
&lt;pre>&lt;code class="language-c++">suffix_char = (j == col - 1 ? '\n' : ' ');
&lt;/code>&lt;/pre>
&lt;p>甚至是&lt;/p>
&lt;pre>&lt;code class="language-c++">suffix_char = j == col - 1 ? '\n' : ' ';
&lt;/code>&lt;/pre>
&lt;p>可不可以呢？
在坐等答案之前，你应该做的事情是把括号去掉并尝试运行程序，这是你自己通过试验寻求问题答案的途径。当然在这里我们会告诉你：上面两段代码是没有问题的，因为 C/C++ 中不同的符号存在优先级差异。正如算术中乘除法比加减法优先级高，C/C++内部也有一套严格的优先级金字塔。优先级相同的运算符优先计算左侧的；优先级不同的运算符优先计算等级高的。这个
&lt;a href="https://en.cppreference.com/w/cpp/language/operator_precedence" target="_blank" rel="noopener">链接&lt;/a> 详细讲述了优先级的划分。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>下面展示一个更精巧的例子，它的作用是打印出一个字符三角形：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int n;
std::cin &amp;gt;&amp;gt; n;
for (int i = 1; i &amp;lt;= n; i++)
{
for (int j = 0; j &amp;lt; i; j++)
std:: cout &amp;lt;&amp;lt; '*';
std::cout &amp;lt;&amp;lt; '\n';
}
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>可以看到外层的循环变量是可以在内层循环体和循环条件中使用的。&lt;/p></description></item><item><title>循环</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/loop/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/loop/</guid><description>&lt;p>假设我们现在要计算 $1+2+3+4+5$，相信你已经有能力写出一段代码完成这个任务：&lt;/p>
&lt;pre>&lt;code class="language-c++">int sum;
sum = 1;
sum = sum + 2; // 你也可以写 sum += 2; 它们的功能是一样的
sum = sum + 3;
sum = sum + 4;
sum = sum + 5;
&lt;/code>&lt;/pre>
&lt;p>但是这样的代码看起来未免有些笨拙，如果我们要计算 $1+2+\cdots+100$，岂不是要写 100 行？当遇到这种需要大量重复做相同/相似操作的情况时，循环便能派上用场。while 循环的基本架构为：&lt;/p>
&lt;pre>&lt;code class="language-c++">while (condition)
{
// your code here
}
&lt;/code>&lt;/pre>
&lt;p>在进入循环体之前，程序会先检查括号中的条件是否成立，如果成立就执行大括号中的内容，执行完内容后程序会再次判断括号中的条件是否成立……直到某次执行完循环体后条件不成立了，while 循环才会退出，下面的流程图很好地诠释了 while 循环的执行过程：&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
s0[start]
s1{while yes?}
s2(body)
s3(break)
s0 --&amp;gt; s1
s1 --&amp;gt; |yes| s2
s2 --&amp;gt; s1
s1 --&amp;gt; |no| s3
&lt;/code>&lt;/pre>
&lt;p>有了 while 循环后，我们可以容易地解决下面的这个问题：输入一个整数 $n$，输出 $1+2+\cdots+n$：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int n, sum;
std::cin &amp;gt;&amp;gt; n;
sum = 0;
while (n != 0)
{
sum += n;
n--; // &amp;quot;n--&amp;quot; is equal to &amp;quot;n = n - 1&amp;quot;
}
std::cout &amp;lt;&amp;lt; sum &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>上述 while 循环将判断条件放在开头，另有一种 do-while 循环框架将判断条件放在末尾：&lt;/p>
&lt;pre>&lt;code class="language-c++">do
{
// your code
}
while (condition); // 这里要加分号！
&lt;/code>&lt;/pre>
&lt;p>do-while 循环一定会先执行循环体再检查条件，其他的部分和 while 循环没有区别。&lt;/p>
&lt;p>除了 while 循环，for 循环也是一种常用的循环框架，不过 for 循环的格式相对复杂：&lt;/p>
&lt;pre>&lt;code class="language-c++">for (/* statement 1 */; /* statement 2 */; /* statement 3 */)
{
// your code
}
&lt;/code>&lt;/pre>
&lt;p>C++ 语言的 for 循环中有 3 条语句，其执行顺序非常微妙 (这是 C/C++ 经常被吐槽的一个点)。用流程图可以比较清楚地解释执行的顺序：&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
s0[start]
s1(statement 1)
s2{statement 2 yes?}
s3(body)
s4(statement 3)
s5(break)
s0 --&amp;gt; s1
s1 --&amp;gt; s2
s2 --&amp;gt; |yes| s3
s3 --&amp;gt; s4
s4 --&amp;gt; s2
s2 --&amp;gt; |no| s5
&lt;/code>&lt;/pre>
&lt;p>如果你觉得这个流程比较复杂，也可以暂时不那么仔细地理解它，毕竟 while 循环可以做到 for 循环能做到的所有事情。不过你可以记住的是：&lt;/p>
&lt;pre>&lt;code class="language-c++">int i;
for (i = 1; i &amp;lt;= n; i++) {}
&lt;/code>&lt;/pre>
&lt;p>这样一个 for 循环可以让变量 &lt;code>i&lt;/code> 分别取 $1, 2, \cdots, n$，然后退出循环。如果你把三条语句代入刚才的流程图，你会发现它确实是正确的。这样一个从 $1$ 枚举到 $n$ 的写法比 while 循环看上去简洁一些。&lt;/p>
&lt;p>我们给出两个使用 for 循环实现计算 $1+2+\cdots n$ 的示例代码：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int n, sum;
std::cin &amp;gt;&amp;gt; n;
for (int i = 1; i &amp;lt;= n; i++) // 如果你想在循环体内部临时使用一个变量，你可以选择像这样的定义方式
sum += i; // 别忘了单条语句可以不用{}包裹起来
std::cout &amp;lt;&amp;lt; sum &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>如你所见，你可以在 for 循环中临时定义变量 &lt;code>i&lt;/code>，这个变量 &lt;code>i&lt;/code> 只可以在循环体中使用，在循环体以外你不能使用 &lt;code>i&lt;/code>。&lt;/p>
&lt;p>这个 for 循环的写法是之前介绍的比较规整的写法，下面是一个长相比较特别的 for 循环：&lt;/p>
&lt;pre>&lt;code class="language-c++">int main ()
{
int n, sum;
std::cin &amp;gt;&amp;gt; n;
for ( ;n != 0; n--)
sum = sum + n;
std::cout &amp;lt;&amp;lt; sum &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>如果你代入之前的流程图理解 for 循环执行的过程，你会发现这个写法比较像 while 循环的逻辑。事实上，之所以说 for 循环完全可以用 while 循环代替，是因为&lt;/p>
&lt;pre>&lt;code class="language-c++">for (s1; s2; s3)
{
// your code
}
&lt;/code>&lt;/pre>
&lt;p>等价于&lt;/p>
&lt;pre>&lt;code class="language-c++">s1;
while (s2)
{
// your code
s3;
}
&lt;/code>&lt;/pre></description></item><item><title>数组</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/array/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/array/</guid><description>&lt;div class="alert alert-warning">
&lt;div>
为了解释清楚一些现象背后的原因，本文涉及一些超纲的计算机底层知识。这部分内容都在绿色的框内，如果你无法看懂可以直接跳过。等到大家学完了计算机系统基础 (ICS) 后自然就能理解这些话的含义。
&lt;/div>
&lt;/div>
&lt;p>有了循环之后，你很快就会发现简单地定义一个一个的变量有点“不够用”了，比如考虑如下问题：&lt;/p>
&lt;blockquote>
&lt;p>输入整数 $N$，然后输入 $N$ 个数的一个数列，将这个数列倒序输出。&lt;/p>
&lt;/blockquote>
&lt;p>我们想要倒序输出，就说明我们在读取完数列的最后一个整数时，还要“记住”前面的 $N-1$ 个整数，所以我们每读取到一个整数都得将其保存在变量里，但我们在预先不知道数列长度的情况下怎么知道该定义多少个变量呢？这似乎陷入了死局。&lt;/p>
&lt;p>(在这里我们不考虑递归等技巧) 我们希望有一种语法，可以批量开一堆变量，而且最好能用下标去索引它们。在 C/C++ 中我们可以通过定义数组来实现这一点。我们先给出上面问题的一段示例代码：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int n;
std::cin &amp;gt;&amp;gt; n;
int a[n];
for (int i = 0; i &amp;lt; n; i++)
std::cin &amp;gt;&amp;gt; a[i];
for (int i = n - 1; i &amp;gt;= 0; i--)
std::cout &amp;lt;&amp;lt; a[i] &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>这里出现的新语法是 &lt;code>int a[n]&lt;/code>。如果说 int 类型的变量是一个可以存放一个整数的小盒子，那么 &lt;code>int a[n]&lt;/code> 就定义了 $n$ 个小盒子，每个小盒子都可以存放一个整数。值得注意的是，这里的“小盒子”是按照 $0,1,\cdots, n-1$ 编号的。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>Variable-Length Array (VLA)&lt;/strong>&lt;/p>
&lt;p>在上面的例子中，我们定义的数组的长度依赖于我们输入的变量 $n$ 的值，也就是说，在程序开始运行之前，我们无法知道数组的具体长度。这种以变量作为长度的数组称为 variable-length array (VLA)。&lt;/p>
&lt;p>VLA 的微妙之处在于，编译器在不知道数组具体长度的情况下可能会在内存分配上犯难。C99 标准首次允许 VLA 的使用，但对其作出了诸多限制，比如不能使用 $\mathbf{extern}$, $\mathbf{static}$ 等关键字修饰。不同的编译器支持的标准也略有差异，例如 Visual Studio 使用的 msvc 编译器很可能会对上面的示例代码报错。&lt;/p>
&lt;p>如果你对这些内容感兴趣，可以上网查询更多的资料。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>如果上面关于 VLA 的内容你没有看懂，没有关系，一句话概括就是使用变量作为数组的长度“不太好”。我们的 OJ 题会对输入数据的范围作出严格的限制，你可以根据数据范围将数组开到足够大的一个固定长度。假设题目规定了 $N\leq 1000$，那么一个不使用 VLA 的程序应该这样写：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int a[1000];
int main ()
{
int n;
std::cin &amp;gt;&amp;gt; n;
for (int i = 0; i &amp;lt; n; i++)
std::cin &amp;gt;&amp;gt; a[i];
for (int i = n - 1; i &amp;gt;= 0; i--)
std::cout &amp;lt;&amp;lt; a[i] &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>这里我们希望不加解释地做出一个规定：如果你要定义一个定长的数组，请将它定义在 &lt;code>main()&lt;/code> 函数的外面。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>不行，我就是想知道为什么要这么规定&lt;/strong>&lt;/p>
&lt;p>C/C++ 的编译器需要负责将高级语言程序映射到具体的硬件上。对于数组这样的存储设施，编译器会将其映射到内存中的某块区域。如果你将数组定义在函数内部，编译器会将其安排在栈上；如果你将数组定义在全局 (函数外部)，编译器会将其安排在静态数据区。除非特别配置，一个程序的栈空间通常不是很大，如果在函数内部定义了过长的数组可能会导致栈溢出，栈溢出会导致不可预知的严重后果。&lt;/p>
&lt;p>如果你想亲手体验一下“栈溢出”，你可以尝试运行以下代码 (不要加任何编译优化)：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
&lt;p>int main () { char s[1 &amp;lt;&amp;lt; 25]; }
&lt;/code>&lt;/pre>&lt;/p>
&lt;p>直接运行这段代码可能会获得段错误 (Segmentation Fault)。&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>如果你没有看懂这段话，那就老老实实地遵守我们的规定吧。&lt;/p>
&lt;p>总有一些“完美主义者”觉得这样写代码十分令人不爽——如果 $N$ 远小于 1000，我们的代码岂不是无谓的多使用了很多资源？这里我们再介绍一种定义数组的方式，它在功能上和前面的几种是完全相同的：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int n;
std::cin &amp;gt;&amp;gt; n;
int *a = new int [n];
for (int i = 0; i &amp;lt; n; i++)
std::cin &amp;gt;&amp;gt; a[i];
for (int i = n - 1; i &amp;gt;= 0; i--)
std::cout &amp;lt;&amp;lt; a[i] &amp;lt;&amp;lt; '\n';
delete [] a;
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>&lt;code>int *a = new int [n]&lt;/code> 的功能是定义一个长度为 $n$ 的数组，每个数组元素都是 int 类型，这个数组的名字叫做 &lt;code>a&lt;/code>。我们在这里希望强调一点：&lt;strong>如果你使用了new语法定义数组，请一定在你确定不会再使用该数组的时刻 (例如 &lt;code>return 0&lt;/code> 之前) 使用delete释放它&lt;/strong>。&lt;/p>
&lt;div class="alert alert-tip">
&lt;div>
&lt;p>&lt;strong>我不写delete这一行好像也没报错啊？&lt;/strong>&lt;/p>
&lt;p>如果你是一个曾经学过算法竞赛的同学，你很可能已经养成了“随手new，从不delete”的习惯。我们在这里必须严肃地警告：这是一个非常危险的习惯！你之前写过的忘记 delete 的 OJ 程序之所以可以正常退出，是因为当 OJ 程序所在的进程被销毁时，操作系统会将进程申请的资源自动释放——换言之，操作系统帮你默默地做了 delete。没有及时释放申请的内存会导致内存泄漏 (memory leak)，如果将来你维护一个大型的项目，内存泄漏的累积很可能导致程序崩溃。&lt;/p>
&lt;p>如果你是一个计算机小白，恭喜你拥有了一个小小的优势：你没有经历过算法竞赛中各种糟糕的代码书写习惯的熏陶。从初学阶段开始严格遵守各种规范，你将自然而然地将书写安全、高质量、可读性强的代码作为一种本能。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>输入输出</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/io/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/io/</guid><description>&lt;p>如果你使用的是 Dev-cpp，将基本框架中的程序拷贝到代码区并按下 F11，你会发现程序已经可以成功地运行了！不过跳出的黑色窗口中没有任何内容——因为我们的 main 函数中什么都没写。我们现在来尝试输出一行 &amp;ldquo;hello world!&amp;quot;：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
std::cout &amp;lt;&amp;lt; &amp;quot;Hello World&amp;quot; &amp;lt;&amp;lt; '\n'; // don't forget to add ';' at the end!
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>&lt;code>cout&lt;/code> 是一个&lt;strong>对象 (object)&lt;/strong>，作为初学者我们不需要知道对象是什么意思，一个好的理解方式是：你可以把 &amp;ldquo;cout&amp;rdquo; 想象成运行时蹦出的窗口，现在我们的任务是将要输出的内容喂给窗口，这里的 &lt;code>&amp;lt;&amp;lt;&lt;/code> 就像箭头一样指明了数据的流向，“Hello World&amp;rdquo; 和代表换行符的 &amp;ldquo;\n&amp;rdquo; 依次流入 &amp;ldquo;cout&amp;rdquo; 并被显示出来，这就是 &lt;code>cout&lt;/code> 的用法，颇有象形文字的智慧。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>&lt;code>std::&lt;/code> 这个前缀是什么意思？&lt;/strong>&lt;/p>
&lt;p>&lt;code>std&lt;/code> 是一个&lt;strong>命名空间 (namespace)&lt;/strong>。对于初学者来说命名空间的概念太过复杂了，你可以把它当作“头文件”来理解：&lt;code>std&lt;/code> 中有很多函数/对象/&amp;hellip;可以供我们使用。在使用它们时，为了让计算机知道这个函数/对象/&amp;hellip;来自于 &lt;code>std&lt;/code>，我们要前面加上 &lt;code>std::&lt;/code> 这个前缀。&lt;/p>
&lt;p>一个自然的问题是：我们既然有一个 include 语句包含头文件，那么我们可不可以也用一条语句来“包含” &lt;code>std&lt;/code> 从而避免每次使用 &lt;code>std&lt;/code> 中的东西都要写 &lt;code>std::&lt;/code> 呢？答案是肯定的，我们可以这样写：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
using namespace std;
&lt;/code>&lt;/pre>
&lt;p>第二行的意思是：在整个程序中我都可以任意地使用 &lt;code>std&lt;/code> 这个 namespace 的所有内容，不再需要 &lt;code>std::&lt;/code> 这个前缀。这样写固然方便，但我们仍然推荐新手养成不滥用 &lt;code>using namespace std;&lt;/code> 的习惯，因为这样你能够更好地知道哪些函数是头文件中的，哪些函数是 &lt;code>std&lt;/code> 中的，这对将来的编程学习很有好处。&lt;/p>
&lt;/blockquote>
&lt;p>一个只能输出的程序没有意思，我们希望程序可以与人类交互，因此我们接下来考虑一个更复杂的例子：输入两个数，输出它们的和。我们直接给出示例代码：&lt;/p>
&lt;pre>&lt;code class="language-c++">#include &amp;lt;bits/stdc++.h&amp;gt;
int main ()
{
int a, b, sum;
std::cin &amp;gt;&amp;gt; a &amp;gt;&amp;gt; b; // don't forget the &amp;quot;std::&amp;quot; prefix!
sum = a + b;
std::cout &amp;lt;&amp;lt; sum &amp;lt;&amp;lt; '\n';
return 0;
}
&lt;/code>&lt;/pre>
&lt;p>这个程序颇为复杂，出现了很多新东西，我们来一一看：&lt;/p>
&lt;ul>
&lt;li>程序中的 &lt;code>a&lt;/code> &lt;code>b&lt;/code> &lt;code>sum&lt;/code> 被称为&lt;strong>变量 (variable)&lt;/strong>。变量可以被理解为一个“小盒子“，里面可以存放一个值。在使用变量前我们需要定义变量，定义的格式是 &lt;code>类型 变量名;&lt;/code>，这里我们定义了 &lt;code>int&lt;/code> 类型的变量，表示 &lt;code>a&lt;/code> &lt;code>b&lt;/code> &lt;code>sum&lt;/code> 这三个“盒子”里只能存放&lt;strong>整数&lt;/strong>。&lt;/li>
&lt;li>&lt;code>cin&lt;/code> 是用于输入的对象。有了 &lt;code>cout&lt;/code> 的经验，&lt;code>cin&lt;/code> 一行很好理解：我们还是将 &amp;ldquo;cin&amp;rdquo; 想象成黑色窗口，当我们输入了两个数字的时候，&amp;ldquo;cin&amp;rdquo; 需要将数据喂给变量，因此流符号的方向是 &lt;code>&amp;gt;&amp;gt;&lt;/code>。&lt;/li>
&lt;li>&lt;code>sum = a + b;&lt;/code> 是一个赋值语句，它的含义是将 &lt;code>a&lt;/code> 和 &lt;code>b&lt;/code> 中的值加起来，存放到 &lt;code>sum&lt;/code> 中，注意该操作不会影响 &lt;code>a&lt;/code> 和 &lt;code>b&lt;/code> 中的内容。&lt;/li>
&lt;/ul>
&lt;div class="alert alert-note">
&lt;div>
&lt;p>&lt;strong>变量&lt;/strong>&lt;/p>
&lt;p>上述示例代码中定义的是 &lt;code>int&lt;/code> 型的变量 (&amp;ldquo;int&amp;quot;是整数 integer 的缩写）。C++ 为我们提供了很多的变量类型，比如存储小数(浮点数）的 &lt;code>float&lt;/code> 类型和 &lt;code>double&lt;/code> 类型，存储更大的整数的 &lt;code>long long&lt;/code> 类型，存储字符的 &lt;code>char&lt;/code> 类型等等，你可以上网搜索这些类型的含义。&lt;/p>
&lt;p>除此之外，变量的命名看似自由，其实也有一定的约束条件，比如变量名的第一个字符不能是数字。如果你感兴趣，你也可以上网搜索相关的资料。&lt;/p>
&lt;/div>
&lt;/div></description></item><item><title>递归</title><link>https://chenhn02.github.io/courses/problemsolving/c++0/recursion/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://chenhn02.github.io/courses/problemsolving/c++0/recursion/</guid><description>&lt;p>递归对于初学者来说是一个非常头疼的概念。如果你觉得暂时无法理解，请不要灰心，因为正常的人类倾向于使用递推思考问题，即从一个 base case 出发从小往大推，而不是把一个大的问题逐渐拆解。但你必须逐渐习惯计算机世界中的这种将大任务拆成小任务解决的思想，大家后面会接触到的分治思想更是将这一点发挥到了极致。&lt;/p>
&lt;p>这篇讲义希望用一个故事把递归的思想讲明白。假设一个国家的国王有一个任务：计算 $1+2+\cdots+100$。日理万机的国王肯定不会一个一个累加浪费时间，但他也没有聪明到能够发现高斯公式。幸运的是他有一批听话的大臣可以使用，于是他设计了一个这样的策略：&lt;/p>
&lt;blockquote>
&lt;p>让丞相去计算 $1+2+\cdots +99$，等他把结果返回给我了，我只要计算这个结果+100，答案就出来了。&lt;/p>
&lt;/blockquote>
&lt;p>丞相同样忙碌且数学天分不高，但幸运的是他也有一批手下可以召唤，于是他设计了一个同样的策略：&lt;/p>
&lt;blockquote>
&lt;p>找一个手下大臣去计算 $1+2+\cdots +98$，等他把结果返回给我了，我只要计算这个结果+99，就能向国王交差了。&lt;/p>
&lt;/blockquote>
&lt;p>这个王国的所有人都深谙资本主义压榨下属的套路 (bushi)，于是这个任务被一级一级传递下去，直到村长拿到 $1+2+3$ 时，把 $1+2$ 这个任务分配给了一个普通的村民。村民没有手下可以使用了，但幸运的是这个问题足够简单，他想都没想就机智地得到了答案 $3$ 并将结果返回给了村长。村长收到 $3$ 后计算出 $3+3=6$，又将结果返回给了镇长……一路下放的任务在收结果的过程中又被一路上传回去，最后国王收到了丞相的回复：$4950$，于是他完成了最后一步加法，$4950+100=5050$，并很高兴地宣称该任务圆满结束。&lt;/p>
&lt;p>这样的写法可能还不足以唤醒你在课堂上学习的递归“套路”。我们不妨写的更形式化一点：令 $F(n)$ 表示计算 $1+2+\cdots +n$ 这个任务，那么国王的任务是计算 $F(100)$，丞相的任务是计算 $F(99)$……镇长的任务是计算 $F(4)$，村长的任务是计算 $F(3)$。不论这个参数是大是小，他们都采取了相同的战术：
$$
F(n)=F(n-1)+n
$$
只有村民不一样，他的任务就是一个简单的加法。所以整个王国的策略可以被归纳为
$$
F(n)=
\begin{cases}
3&amp;amp;,n=2\\
F(n-1)+n&amp;amp;,n\geq 3
\end{cases}
$$
这就是递归的基本思想：如果一个大任务 (例如 $F(n)$) 可以被分解成一个性质相同但规模更小的任务 (例如 $F(n-1)$) 以及一些简单的额外运算 (例如 $+n$)，那么这个问题就非常适合用递归解决。我们可以为上面的递归加法写一个程序：&lt;/p>
&lt;pre>&lt;code class="language-c++">int query_sum(int n)
{
if (n == 2)
return 3;
else
return query_sum(n - 1) + n;
}
&lt;/code>&lt;/pre>
&lt;p>该函数的执行过程和王国里任务下放再回收的过程完全相同：&lt;code>query_sum(100)&lt;/code> 调用 &lt;code>query_sum(99)&lt;/code> 等待其返回值，&lt;code>query_sum(99)&lt;/code> 又调用了 &lt;code>query_sum(98)&lt;/code>……从而形成了一个长长的调用链。&lt;code>query_sum(3)&lt;/code> 调用 &lt;code>query_sum(2)&lt;/code> 时，&lt;code>query_sum(2)&lt;/code> 不需要再调用子函数，直接返回了结果，从而 &lt;code>query_sum(3)&lt;/code> 执行完加法后也返回了结果，一层层返回结果，直到 &lt;code>query_sum(100)&lt;/code> 返回。&lt;/p>
&lt;p>大家更加喜闻乐见的可能是小学/中学接触过的斐波那契数列：
$$
fib_n=
\begin{cases}
0&amp;amp;, n=0\\
1&amp;amp;, n=1\\
fib_{n-1}+fib_{n-2}&amp;amp;, n\geq 2
\end{cases}
$$&lt;/p>
&lt;p>能够写出这种递推式的数列一定可以非常简明地用递归实现计算：&lt;/p>
&lt;pre>&lt;code class="language-c++">int query_fib(int n)
{
if (n == 0)
return 0;
else if (n == 1)
return 1;
else
return query_fib(n - 1) + query_fib(n - 2);
}
&lt;/code>&lt;/pre>
&lt;p>这个例子比上一个例子稍稍复杂一些，因为它把一个大任务分解成了两个小任务，可以想象递归的调用会形成一个树状结构而不是链状结构。你可以自己用纸笔画一画 &lt;code>query_fib(5)&lt;/code> 的递归调用过程，画完之后参考
&lt;a href="https://inst.eecs.berkeley.edu/~cs61a/fa21/assets/slides/09-Tree_Recursion.html#/11" target="_blank" rel="noopener">这个链接&lt;/a> 的动画进行比对。&lt;/p></description></item></channel></rss>