《算法笔记》——第四章 其他高效技巧与算法 学习记录

打表

打表是一种典型的用空间换时间的技巧,一般指将所有可能需要用到的结果事先计算出来,这样后面需要用到时就可以直接查表获得。打表常见的用法有如下几种:

  1. 在程序中一次性计算出所有需要用到的结果,之后的查询直接取这些结果。

这个是最常用到的用法,例如在一个需要查询大量Fibonacci数F(n)的问题中,显然每次从头开始计算是非常耗时的,对Q次查询会产生\(O(nQ)\)的时间复杂度;而如果进行预处理,即把所有Fibonacci数预先计算并存在数组中,那么每次查询就只需要0(1)的时间复杂度,对Q次查询就只需要\(O(n + Q)\)的时间
复杂度(其中\(O(n)\)是预处理的时间)。

  1. 在程序B中分一次或多次计算出所有需要用到的结果,手工把结果写在程序A的数组中,然后在程序A中就可以直接使用这些结果。

这种用法一般是当程序的一部分过程消耗的时间过多,或是没有想到好的算法,因此在另一个程序中使用暴力算法求出结果,这样就能直接在原程序中使用这些结果。例如对n皇后问题来说,如果使用的算法不够好,就容易超时,而可以在本地用程序计算出对所有n来说n皇后问题的方案数,然后把算出的结果直接写在数组中,就可以根据题目输入的n来直接输出结果。

  1. 对一些感觉不会做的题目,先用暴力程序计算小范围数据的结果,然后找规律,或许就能发现一-些“蛛丝马迹”。

这种用法在数据范围非常大时容易用到,因为这样的题目可能不是用直接能想到的算法来解决的,而需要寻找一些规律才能得到结果。

活用递推

有很多题目需要细心考虑过程中是否可能存在递推关系,如果能找到这样的递推关系,就能使时间复杂度下降不少。例如就一类涉及序列的题目来说,假如序列的每一位所需要计算的值都可以通过该位左右两侧的结果计算得到,那么就可以考虑所谓的“左右两侧的结果”是否能通过递推进行预处理来得到,这样在后面的使用中就可以不必反复求解。

随机选择算法

本节主要讨论这样一个问题:如何从一个无序的数组中求出第K大的数(为了简化讨论,假设数组中的数各不相同)。例如,对数组{5, 12, 7, 2, 9, 3}来说,第三大的数是5,第五大的数是9。

最直接的想法是对数组排一下序,然后直接取出第K个元素即可。但是这样做法需要\(O(nlogn)\)的时间复杂度,虽然看起来很好,但还有更优的算法。下面介绍随机选择算法,它
对任何输入都可以达到\(O(n)\)的期望时间复杂度。

随机选择算法的原理类似于随机快速排序算法。当对A[left, right]执行一次randPartition函数之后,主元左侧的元素个数就是确定的,且它们都小于主元。假设此时主元是A[p],那么A[p]就是A[left,right]中的第p-left+1大的数。不妨令M表示p-left+1,那么如果K==M成立,说明第K大的数就是主元A[p];如果K<M成立,就说明第K大的数在主元左侧,即A[left...(p-1)]中的第K大,往左侧递归即可;如果K>M成立,则说明第K大的数在主元右侧,即A[(p+1)...right]中的第K-M大,往右侧递归即可。算法以left == right作为递归边界,返回A[left]。由此可以写出随机选择算法的代码:

int randSelect(int a[],int l,int r,int k)
{
    if(l == r) return a[l];
    int p=randPartition(a,l,r);
    int m=p-l+1;
    if(k == m) return a[p];
    if(k<m) return randSelect(a,left,p-1,k);
    else return randSelect(a,p+1,right,k-m);
}

可以证明,虽然随机选择算法的最坏时间复杂度是\(O(n^2)\),但是其对任意输入的期望时间复杂度却是\(O(n)\),这意味着不存在一组特定的数据能使这个算法出现最坏情况,是个相当实用和出色的算法(详细证明可以参考算法导论)。

下面的问题是一个应用:给定一个由整数组成的集合,集合中的整数各不相同,现在要将它分为两个子集合,使得这两个子集合的并为原集合、交为空集,同时在两个子集合的元素个数\(n_1\)\(n_2\)之差的绝对值\(|n_1- n_2|\)尽可能小的前提下,要求它们各自的元素之和\(S_1\)\(S_2\)之差的绝对值\(|S_1-S_2|\)尽可能大。求这个\(|S_1-S_2|\)等于多少。

对这个问题首先可以注意到的是,如果原集合中元素个数为n,那么当n是偶数时,由它分出的两个子集合中的元素个数都是n/2;当n是奇数时,由它分出的两个子集合中的元素个数分别是n/2与n/2+1(除法为向下取整,下同)。显然,为了使\(|S_1-S_2|\)尽可能大,最直接的思路是将原集合中的元素从小到大排序,取排序后的前n/2个元素作为其中一个子集合,剩下的元素作为另一个子集合即可,时间复杂度为\(O(nlogn)\)

而更优的做法是使用上面介绍的随机选择算法。根据对问题的分析,这个问题实际上就是求原集合中元素的第n/2大,同时根据这个数把集合分为两部分,使得其中一个子集合中的元素都不小于这个数,而另一个子集合中的元素都大于这个数,至于两个子集合内部元素的顺序则不需要关心。因此只需要使用randSelect函数求出第n/2大的数即可,该函数会自动切分好两个集合,期望时间复杂度为\(O(n)\)

由于在这个问题中不需要关心第n/2大的数是什么,而只需要实现根据第n/2大的数进行切分的功能,因此randSelect函数不需要设置返回值。另外,如果能保证数据分布较为随机,那么代码中的randPartition函数也可替换成普通的Partition函数。除此之外,还有一种即便是最坏时间复杂度也是\(O(n)\)的选择算法,但是比较偏理论化,就不在此处介绍了。

posted @ 2021-02-11 11:27  Dazzling!  阅读(20)  评论(0编辑  收藏  举报