众所周知,力扣上的程序员们各个身怀绝技。他们化身为算法诗人、段子高手、灵魂画手、剪辑能手、幻灯片专家……穿梭和活跃于题解区。
一、力扣题解有哪些打开方式?算法思路讲解 + 配图 + 作画
@灵魂画手 「力扣leetcode-cn.com
作诗注释 + 代码
@while(1)「力扣leetcode-cn.com
1. 若问雨水深何许,先当首寻最高峰。
2. 高峰两侧群山列,须将右侧倒个个。
3. 右山若较左山矮,右山山高累加和。
4. 左山不及右山高,距乘左山减加和。
5. 如此这般算将好,左右群山水深得。
6. 天帝赞叹算法妙,玄之又玄乐无穷。
算法思路讲解 + 思维导图 + 多图讲解
@liweiwei1419 「
作为一个算法新手,每次做完题后,写一篇题解是非常必要的。它能够帮助你梳理自己的算法思路,也便于和其他小伙伴交流,及时发现自己的思路中可以优化的部分以及错误。一篇高质量的题解应该怎么写?下面就让我们跟随 @zerotrac 同学的思路一起来看看吧!
二、迈出第一步
在说明「如何编写一份高质量的力扣题解」之前,我们先来想一想,一篇题解应该包含哪些部分:前言:题解的一些相关背景;
方法一、方法二、方法三等:不同的解题方法。每一种方法会包括:思路及算法:对如何解决问题进行详细地阐述;
代码:给出相关的代码;
时空复杂度分析:对算法的复杂度进行分析。结语:作者的一些思考,与本题类似的一些题目等。
大部分高质量的题解都是按照这个流程编写的。
三、格式要求
大标题
前面提到的「前言」「方法一」「方法二」「结语」都属于 大标题。我们使用四级标题 #### 表示大标题。对于「前言」和「结语」这两个大标题,可以使用其它的表述方法,例如「说明」「写在前面」「总结」「写在最后」等等;
对于「方法一」这一类大标题,最好不要使用其它的表述方法。一般来说,我们会使用「方法一:xxxx」作为大标题,其中的 xxxx 就是作者需要讲解的方法。例子:「方法一:深度优先搜索」
前言
如果没有想说的前言,这一部分可以不写。
方法 x如果有多个方法,建议从易到难、循序渐进地介绍:例子:「方法一:排序 + 遍历」「方法二:哈希表」不建议把会超时的方法单独作为一个「方法 x」。如果该方法是题解中不可或缺的一部分(例如推导出正确方法过程中的一步),可以考虑将超时的方法浓缩成一段话,或者作为一个小标题(下文中会介绍小标题)写进正确的方法中:例子:我们可以用递归 + 回溯的方法,搜索出所有可能的路径,并找出最优的答案。然而这样会超出时间限制。考虑到这种方法的瓶颈在于大量的重复搜索,我们可以 ...,这样就能用动态规划的方法解决本题了。
结语
如果没有想说的结语,这一部分可以不写。
小标题
前面提到的「思路及算法」「代码」「时空复杂度分析」都属于 小标题。我们使用加粗环境 ** ** 表示小标题。对于「思路及算法」这个小标题,如果作者希望在其与「方法 x」的大标题之前再增加一个小标题,例如对「方法 x」特定的「预备知识」等等,那么「思路及算法」必须被保留,作用是与「预备知识」在内容上进行区分;如果这两者之间没有其它的小标题,那么「思路及算法」可以省去不写,因为在「方法 x」下面直接开始写这一部分也是很合理的。同样的道理,如果这两者之间有「预备知识」,那么「预备知识」这个小标题也是可以省去的;
对于「代码」这个小标题,由于力扣有专门的代码环境,因此「代码」也可以省去不写;
对于「时空复杂度分析」这个小标题,一般的习惯是写成「复杂度分析」,并且需要保留,具体的原因在讲解「时空复杂度分析」这一部分的章节会进行阐述。
预备知识
预备知识部分一般用来对「思路及算法」部分中的一些不常用的技巧进行简单的介绍:如果使用的是不常用的数据结构,可以介绍一下我们会用到哪些操作和 API:例子:「树状数组」,我们会用到它的「区间查询」和「单点修改」这两个操作。如果使用的是不常用的算法,可以介绍一下这种算法的具体作用:例子:「Rabin-Karp 字符串加密算法」,它是将字符串看成一个 k 进制的数,其对应的十进制表示就是加密的结果。如果使用的是不常用的技巧,可以介绍一下这些技巧以及对应的伪代码:例子:「位运算」,我们可以使用 x & (1 << i) 判断 x 二进制表示的第 i 位是否为 1。
考虑到这是「题解」而不是「算法小百科」,我们并不需要花长篇幅来对使用的技巧进行详细的讲解,点到为止即可。我们期望读者不要「超前」地去做自己能力范围之外的题,可以通过网上的其它「百科」性质的资料先进行学习,再通过「预备知识」以及后面的部分来验证自己是否学习完成。
思路及算法
这是题解中最重要的一部分。不同的作者对于这一部分都有自己习惯的处理方式,因此这里不对这部分做详细的规范,只给一些建设性的建议:避免「保姆式」的算法讲解。在「思路及算法部分」,作者应当更多地讲解抽象的概念,而不是将代码翻译成自然语言:正确的例子:最后一步我们可以用二分查找来解决。二分的上下界分别为 ... 和 ...。通过二分查找,我们可以得到最小的满足 ... 要求的整数,这样就可以得到最终的答案了;
错误的例子:最后一步我们可以用二分查找来解决。二分查找的上界为 ...,下界为 ...;
在二分查找的每一步中,我们用 mid = (left + right) / 2 得到待查找的整数,并与 ... 进行比较。如果 ... 就将 left 置为 ...,否则就将 right 置为 ...;
当 ... 时,我们结束二分查找,就可以得到最终的答案。
用图解的形式对讲解进行补充。相较于概念性的文字而言,读者更愿意接受形象化的图解。图解一般有三种类型:MarkDown 中的 ``` ``` 「代码块」环境,图解以「字符画」的形式,出现在代码框中;
图片环境,图解以单张图片的形式,出现在文字段落中;
幻灯片环境,这是力扣编辑器支持的一种环境,图解以多张图片的形式,被放在幻灯片环境中进行播放。行文保持一致。如果在前文进行了定义,那么后文相同的定义需要保持一致,并且所有在题目描述中未出现,但在这部分出现了的定义都需要进行解释:正确的例子:我们用lchild和rchild表示该节点的左孩子和右孩子;
错误的例子:我们用 lchild 和 rchild 表示该节点的左孩子和右儿子;
正确的例子:我们用
来进行递推,其中
表示函数 ...,这样就能得到最终的答案;
错误的例子:我们用
来进行递推,这样就能得到最终的答案。
代码
代码需要有基本的代码规范,遵守代码规范可以使得代码更加易读。题解中的代码一般只有十几到几十行,虽然不需要像工程代码那样完全遵守规范,但也需要有基本的准则,建议做到下面的两条:一行只做一件事情:
正确的例子:
C++ 实现
result = get();
if (result > threshold) {
...
}
错误的例子:
C++ 实现
if ((result = get()) > threshold) {
...
}
如果是 arr[++pos] = x 这种通俗易懂的语句,则可以考虑写在一行,但 arr[++pos] = x++ 则一定不行。加上括号:
正确的例子:
C++ 实现
for (int i = 0; i < n; ++i) {
result += get(i);
}
错误的例子:
C++ 实现
for (int i = 0; i < n; ++i)
result += get(i);
对于 Python 这类没有大括号的语言则可以不加。正确的例子:
C++ 实现
if (cond_a || (cond_b && cond_c)) {
...
}
错误的例子:
C++ 实现
if (cond_a || cond_b && cond_c) {
...
}
不同语言的运算符优先级是不相同的,尤其是逻辑运算的部分。为了使得学习不同语言的读者都能看懂代码,应当适当地添加括号,突出运算顺序。
这些准则保证了代码的易读性且无歧义。
如果作者想要提供多种语言的代码,则需要使用力扣编辑器的「混合代码块」环境。同时需要注意,由于 Python2 已经停止维护,尽量不要提供 Python2 的代码。
时空复杂度分析
复杂度分析也是题解中不可或缺的一部分。这部分的作用是:解释方法可以在规定的时间和空间的限制内通过的原因。
由于复杂度分析一般会拆分成「时间复杂度」和「空间复杂度」,因此会使用「无序列表」环境分别进行阐述,因此需要加上「复杂度分析」这一小标题,否则在「代码」部分之后直接接上「无序列表」的环境显得不美观。
对于这两个部分:使用「大 O 表示法」,并且需要对复杂度进行简单的解释;
如果复杂度中出现字母,并且在题目描述中没有对应(以字母的形式),则需要进行说明。常见的有数组的长度、数组中元素的最大值等;
如果复杂度中出现了非系数的常数,并且会随着题目要求变化,则不能省略,一般用 C 表示,并且要说明 C 的含义以及本题中 C 的值:例子:对 N 个在 [1,10000] 内的整数进行计数排序,时间复杂度为 O(N + C),其中 C 表示待排序整数的范围,在本题中 C = 10000;
例子:在 32 位二进制数的范围内进行二分查找,每次查找的时间复杂度为 O(N),那么总时间复杂度为 O(N log C),其中 C 表示二分查找的范围,在本题中 C = 2^32。
对于「时间复杂度」部分:需要给出算法在「最坏情况下」的时间复杂度。一些暴力的算法在「平均情况下」拥有和正确算法媲美的时间复杂度,但在「最坏情况下」则会超出时间限制。
对于「空间复杂度」部分:我们只计算除了函数返回值(也就是答案)以外的「额外空间复杂度」。也就是说,如果题目中需要返回一个数组,在代码中申请了一个数组的空间和若干个临时变量,那么空间复杂度记为 O(1);
如果重复使用同一个数组,使用的空间只记录一次。也就是说,如果我们使用「滚动数组」优化动态规划,将原本的二维数组减少至两个一维数组,那么虽然我们的计算量不变,但空间复杂度只记为 O(N),其中 N 是数组的长度;
栈空间经常容易被忽略,但也必须计入空间复杂度:例子:使用语言自带的排序,空间复杂度一般是 O(log N),其中 N 是待排序的元素个数。
四、环境要求
变量环境
在编写题解的过程中,对于变量的处理我们一般会用到三种环境:行内代码环境,即 result = x + y;
行内公式环境,即
;
整行公式环境,即
一般来说,对于比较长的公式(例如状态转移方程)我们用「整行公式环境」;对于「大 O 表示法」以及「函数」,我们用「行内公式环境」。对于其余的情况,我们可以使用「行内代码环境」和「行内公式环境」中的任意一种,但要保持行文统一:正确的例子:对于数组中的任意一个元素 x,我们用
来更新答案 result,其中 f(a,b) 表示 a 和 b 的相关程度。错误的例子:对于数组中的任意一个元素 x,我们用
来更新答案 result,其中 f(a, b) 表示 a 和 b 的相关程度。
常用技巧很多的数学符号都是有对应的
表示的:正确的例子:
对应 \max, \min, \log, \ln;
错误的例子:
如果变量较长,可以使用 \textit 环境使其变得紧凑。在变量名为大写时,这一点尤其突出:正确的例子:
对应 \textit{result},\textit{RESULT};
错误的例子:
。
五、示例
本篇文章是按照题解的格式要求编写的,但缺少了例如「代码」等内容部分。
下面以最经典的 Hello World! 为例,给出一个题解的范例。
说明
Hello World! 是程序员在学习一门新语言时会尝试编写的第一段代码,可以算得上是经典中的经典。在这篇题解中,我们将会学习到两种不同的让程序输出 Hello World! 的方法。
方法一:直接输出
大部分语言都直接提供了输出字符串常量的 API,因此我们可以直接调用 API 输出 Hello World!。
C 实现
void hello() {
puts("Hello World!");
}
C++ 实现
void hello() {
cout << "Hello World!" << endl;
}
Python 3 实现
def hello():
print("Hello World!")
复杂度分析时间复杂度:
,这里我们将字符串 Hello World! 的长度看作是常数。
空间复杂度:
。
方法二:格式化输出
我们也可以将 Hello 和 World 分别保存在临时变量中,随后调用格式化输出字符串的 API 得到结果。
C 实现
void hello() {
char* hello = "Hello";
char* world = "World";
printf("%s %s!\n", hello, world);
}
Python 3 实现
def hello():
hello = "Hello"
world = "World"
print("%s%s!\n" % (hello, world))
复杂度分析时间复杂度:
,这里我们将字符串 Hello 和 World 的长度均看作是常数。
空间复杂度:
。
看完本篇题解撰写攻略,不妨赶快去力扣题解区练练手吧!
本文作者:zerotrac
题解作者:灵魂画手、while(1)、liweiwei1419
声明:本文归 “力扣” 版权所有,如需转载请联系。