在三星广研待了四年,兼任了两年半内训讲师,其实整理过很多培训资料,却因为安保绝大多数无法带走。里面无关保密部分,一直想重新整理出来,留一个记录。
可仅凭手头遗留的一些纸质资料和记忆,时隔越久越难恢复了。只能怪我在三星期间没有坚持回家写技术博客。
先从容易的开始吧。
我写过一篇汇总,主要是在OJ系统上刷题提交 以及 应对三星内部软件考试的技巧,部分是针对考试的小聪明,但也有很多其实是通用的coding技巧。因为是针对当时的学员常见错误写的,内容很乱没什么章法。以下是整理恢复的正文:
1 测试数据的输入输出重定向
很多人在调试的过程中,都是一个字符一个字符地手动输入测试数据,然后对着小黑窗一个一个地核对答案的正确性。
这样做缺点很明显:
- 效率低,容易出错(包括输入输错,和输出核对出错)
- 没有办法测一些压力比较大的数据,譬如问题范围达到千级别,手输基本没法测
- 一些数据修改之后又重新测,反复手敲浪费时间
- 代码修改前后的输出结果很难比较
解决方法就是,将输入输出重定向到文件上。
这样做,输入数据生成一次可以反复测,需要大的测试数据,可以写一个for循环加随机数生成放文件里。
输出的结果,可以每次换一个文件名存着,方便前后对比代码修改的效果。
上代码:
|
|
2 输出缓冲区置空
这个官方的代码模板里一般都自带,但是很多人不知道它放在那里干什么:
|
|
其实这段代码的作用就是把输出缓冲区置为空,这样任何输出都会马上输出到终端或者文件。
如果程序能保证执行完,那么这个缓冲区的大小是不会影响最终结果的,只是缓冲区大的话会减少输出的频率,提高一点点IO的效率。
但反过来,如果程序可能有潜在的bug,不一定能执行完,那么把缓冲区置空就能尽可能地输出已经完成的结果,尽可能地多拿一部分的分(由于新的考试形式只有一题,而且要求全对,这个技巧可能没有用武之地了)。
3 调试宏
有些题目比较复杂,需要额外增加一些log来调试。但往往调试完大家会忘了把log给 注释掉/删掉,以至于有多余输出引起答案错误。而即使你记得,从复杂的代码中一点一点地把log找出来也是吃力不讨好,还容易漏。
这时候,把调试代码加上条件编译,并用宏控制开关,就是比较省力的做法了:
|
|
一般的题目,我个人喜欢用 DEBUG_IO
和 DEBUG_LOG
两个宏,分别控制IO重定向(见技巧1),和log的输出。
这种技巧在生产代码中也是随处可见的,所以大家应该不难理解,只是有时候想不起来把它用起来。
4 “函数” 宏
对于一些反复出现的算式,手敲累且容易错,写函数又显得没必要,可以写成宏的形式。
如两者取较大值:
|
不过,请注意:
- 这终究不是函数,只是宏展开(相当于预编译器做了一次查找替换),实际行为跟函数差异很大,如果复杂的式子,请还是写成函数;如果使用C++,可以写成 inline 的形式。
- 为了避免展开后优先级的问题,请务必在变量外加一层括号。这不仅在这里推荐,所有宏都推荐这样做,来保证在最终展开的地方能够有正确的优先级。
试考虑这样的宏:
|
|
res 的结果是多少? 3 * 7 = 21
吗?
实际上展开之后的表达式是int res = (1 + 2 * 3 + 4)
, 因为乘法优先,结果是11。
如果写成以下的样子就不会有问题了:
|
5 规避比较语句中误用等号
*该技巧出自《C专家编程》
试看这样一段代码:
|
|
很普通的一段统计次数的代码。
一切看起来都很正常。
大家先不要急着往下看。
看完这段代码,大家能第一时间发现问题吗?
……
if(array[i] = 7)
这个判断永远是真的(这个语句取左值,也就是array[i]的值。而array[i]被赋值成7,在C里面,非零的量都当做真),
所以结果是,数组的所有元素都成了7,也统计了数组大小这么多个7……
如果养成习惯,在比较时将 常量 / 字面量 放在前面,像这样:
|
|
因为常量 / 字面量 不能被赋值,所以是会直接编译失败的,而且编译器还直接指出了错误的行数。
6 预先计算 + 查表 大幅减少程序运行时间
*该技巧出自《Code complete》
在算法里,预先计算(Precomputation)是指在实际运行程序之前,使用初始化运算,生成一个查询表(lookup table)供算法引用,以避免每次运行程序时重复计算这些数据。
——翻译自英文维基Precomputation词条
假设内存用之不尽,又有足够的时间提前准备,那么速度最快的算法应该就是 “预先计算 + 查表” 无疑。
试想我们已经有了所有情况的答案,你一问,我马上就能回答。
“无限内存 + 无限准备时间” 这个前提当然是不可能成立的,所以我们也没办法拿这种做法当万金油。
但是对于某些特定问题,可能的情况如此之少,以至于我们可以把 中间结果 甚至 最终结果 先存起来,运行时直接查表。
筛选素数 是 比较经典的例子。
假设我们的任务要 判断 1’000 个 1’000’000(不用数0了,一百万) 以内 的自然数是否素数:
- 如果逐个判断(对每个数,判断 每一个 比它小的数能否整除),显然太慢了。
(时间复杂度为O(n*k),空间复杂度为O(1),其中k = 1’000, n = 1’000’000,下同) - 如果预先计算并保存每一个数 是否 素数,时间上变成了可以随机访问马上得到结果,但显然太浪费空间了。
(一百万大小的数组,只有其中1’000个元素有用。时间复杂度成了O(k),空间复杂度又成了 O(n)) - 其实素数的分布相当稀疏,在 一百万以内,一共只有78’498个 素数 (7.85%)。如果我们按顺序存储这些素数,然后拿到一个数之后再数组里折半查找,就可以知道这个数是否素数了。
(时间复杂度为 O(k * log m),空间复杂度为O(m),其中m = 78’498, log m 约为 16) - 可是如果给的内存限制,连78’498个数都存不下呢?其实存168个就够了。
1’000以内的素数一共168个。对于任意一个数x, 2 到 根号x 之间的所有素数,构成了对 x 的素数筛。换言之1’000以内的素数,就构成了1’000’000这个范围的素数筛。
所以这168个素数,足以筛选一百万以内的任意一个整数。
(时间复杂度为 O(k * l), 空间复杂度为 O(l),其中 l = 168)
如果你追求 极致的时间要求,内存随便,当然可以选择方案2。
还是 追求时间尽可能短,但内存不够 方案2的要求,可以选择方案3。
但多数情况下,方案4是 在 时间 和 空间 都比较平衡 的一个方案。
这个例子同时也说明了,很多时候不存在完美方案,即使算法,也是各种资源的折衷。需要根据面临的主要矛盾,来选择合适的解决方案。
顺便附上一个输出素数的小脚本。(预先计算因为没有语言限制,建议使用Python, js 等易于编写功能强大的 动态脚本语言。考虑到大家可能没有Python坏境,这里给了一个js的版本。)
原理很简单,初始化一个 素数筛,里面一开始只有2(换言之,只能判断不大于4的数是否素数)。
然后从3开始,对每个奇数(除2以外的偶数都不是素数)用素数筛筛一遍,如果是素数,就加入素数筛。
代码足够短小简单(很容易背下来),效率也还过得去,正好可以在需要 素数表 的题目里,用来做预先计算。
|
|
7 表驱动法 实现状态转移
*该技巧出自《Code complete》
查表法 除了可以用来引用 预先计算 的数据,还能用来实现状态的判断转移。
大多数场合,连续的 if-else 或 switch-case,都是不断复制黏贴的类似代码片段。
这样的代码,一旦需要修改,要在多个地方做类似修改,可维护性非常差。
实际上这是没有好好 运用 合适的 数据结构 的结果。
而将状态之间的映射关系 预先 存放在 表里面,并实现维护一段查表的算法,正是对“数据结构 + 算法 = 程序” 的一种实践。
以 POJ1002 作为例子:
|
|
可以看到,在构建好 数据表 之后,核心的查询代码 只有一行(这个例子里,x 是我们需要的值):
|
|
8 备忘录方法
提到了 预先计算 和 查表,就不得不顺便介绍一下 备忘录方法
大多数时候 备忘录方法 都是用来跟 动态规划 作对比。
这两种方法都是应用于这样的情况:
- 问题可以分解成多个子问题
- 有重复子问题
所以为了避免重复计算的浪费,两种方法都会开辟一个数组用来存放子问题的解。
区别在于
- 动态规划 是自底向上的计算,先计算最小的子问题的解,然后逐渐组合出更复杂的子问题的的解,最后得出原问题的解。因为是从最小的字问题开始计算,可能部分子问题的解是没有派上用场的,因为在计算的时候还不知道哪些会被引用。
- 备忘录方法 是自顶向下的,多数情况下是一个递归结构:先计算原问题,里面引用到子问题时,递归计算子问题……直到全部子问题都有解,再在返回里面组合成原问题的解。这里面, 备忘录方法的优化在于,一旦一个子问题被解决过,就把解缓存起来,以后再引用就直接读取不再计算。
拿 POJ1243 举例
动态规划:
|
|
在计算过一次(G, L)之后,所有比G, L 小的(g, l) 对的解都可以在DP数组中找到。不过多个case里面G, L的值的大小并不确定。所以遇到新的G, L又要再算一遍是一种浪费。
备忘录方法:
|
|
你会发现这道题在case很多而且g, l大小没有顺序时,备忘录方法的效果会更好。(其实这道题也可以预先计算,才30*30的规模)
当然,这道题属于比较特殊的情况,两种方法都能用。
一部分问题 由于引用哪个子问题的解,取决于子问题的解之间的比较,只能先算最小的子问题,只能自底向上,只能动态规划。
而有些题目,实际引用的子问题数占全部子问题数的很少一部分,这时,动态规划把全部子问题算一遍是一种很大的浪费,备忘录是一种更好的选择。
说了这么多好像很复杂,其实 备忘录方法 的核心思想只有一句话: 子问题太多,只在第一次引用时算一次,也只算这一次。
预先计算是:情况这么少,编译前就全算好了。(提前计算)
动态规划是:从最小的子问题开始向上组合,用小的子问题组合出大的子问题,直到原问题。全部子问题都算且只算一次。(从小到大,全部都算)
而备忘录方法:从原问题开始,向下分解,不用不算,只算一次。(推迟计算)
9 邻接矩阵 VS 邻接表 VS 其他图存储法
不同的 图 问题怎么解先按下不表,但是图的存储总是绕不开的问题。
那么最常用的两种表示方法,究竟用 邻接矩阵 还是 邻接表 呢? 还有什么表示方法呢? 分别有什么优缺点,要注意什么呢?
( 为了方便讨论,下面统一用 V 代表顶点数, v 代表一个顶点, E 代表边数, e 代表一条边。*图默认都是无向图,所以是双向操作;如果有向图,请省略反向操作。 另外,为了简便,代码是精简过的伪代码。)
边集数组
在讲那两种主要的存储方式之前,先提一下边集数组。
所谓边集数组,就是用数组记录每条边的两个顶点。部分题目由于input的方式就是给出每条边的两个顶点,所以sample代码里的存储方式默认就是给了边集数组。
|
|
优点:
- 固定使用MAX_E的空间,对于 E << V ^ 2 的图(边数远小于顶点的平方,即,稀疏图),空间上很节省。
- 方便对边遍历。
缺点:
- 不能随机访问,对顶点遍历效率很低。
邻接矩阵
因为直观、实现方便,加上正式考试对内存的限制比较宽松,这应该是大家最常用的表示方式。
示例代码给出了两种输入处理: 1是处理以邻接矩阵给出的数据,2是处理以边表的方式给出数据。
|
|
优点:
- 实现简单(二维数组),容易理解
- 可以随机访问。亦即,知道任意from, to的值,都可以马上判断是否连通。
缺点:
- 固定使用MAX_V ^ 2的空间,对于稀疏图,空间上是很大的浪费。(不过由于考试多数够空间,很多时候这个缺点可以忽略)
- 遍历效率低。跟随机访问相反,如果要遍历from能到的所有to点,就不得不将from所在行遍历一次,在稀疏图中,会导致遍历的效率很低。而恰恰很多算法都需要做遍历。
邻接表
邻接表比邻接矩阵稍复杂一些,一般要用到变长数组vector。
|
|
你可能会问,库不是不给用了吗?其实基于边集数组可以改写成邻接表, 本质上就是用Next数组将边集数组串联起来。
|
|
而如果不为了省内存,只是为了加快遍历速度,直接上二维数组也可以实现邻接表。
|
|
优点:
- 方便对顶点遍历邻接顶点(或者说对顶点,遍历它连接的边),对稀疏图的遍历比邻接矩阵节省时间。 一般空间足够的情况下,推荐最后一种实现方式。
缺点:
- 不能对边遍历
- 不能随机访问
表面上来看,邻接表缺点比较多。但实际使用中,几乎所有图算法都需要对顶点遍历边(Dijkstra,Floyd,Prim…),所以反而是最常用的表示存储方式。
其他存储方式
牵涉到复杂的指针操作,仅供参考了解。
- 十字链表
- 多重邻接表
原本这里写着 …… To be continued……,表示我会不定期更新。但因为我的离职,起码三星内网的那篇文章,我再也不会更新了。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
详情请点击查看协议具体内容。