本帖最后由 107874017 于 2019-4-27 11:10 编辑
在这里获得更好的阅读体验
复制代码
#1.zip
(6.37 KB, 下载次数: 1)
在这里获得更好的阅读体验
- # 简单算法入门教程
- 由于作者习惯的缘故 本教程代码全部使用C++编写
- ## 前言
- > 算法是什么?
- 对一组规范的输入,合适的算法可以在有限时间内获得所要求的的输出
- > 为什么要学算法?
- 算法是程序的灵魂,高效的算法是写出高效程序,引领项目的必由之路,光做一个增删改查的程序猿是不够的
- ~~还能装13~~
- > 有什么比较好的算法教程吗?
- 竞赛党:入门程度可以参考刘汝佳的《算法竞赛入门经典》
- 进阶后可以参考李煜东的《算法竞赛进阶指南》
- 工作党:《数据结构与算法分析(C语言/Java语言描述)》第三版
- 《算法导论》第三版(不建议通读)
- 《算法》第四版
- > 我有什么理由相信你的算法能力?
- NOIP2018提高组**一等奖
- 现役省选选手
- > 我可以在什么地方练习算法?
- 假定你的项目并不需要写你想要练习的算法
- 你可以在[洛谷](www.luogu.org)或[牛客](nowcoder.com)或者[LeetCode](leetcode-cn.com/)做题
- 当然如果你觉得你的水平十分的高 那么你可以在[bzoj](lydsy.com)做往年的NOI省队选拔赛真题
- 这些网站基本都支持Java
- > 这篇教程会以什么顺序讲算法?
- 第一部分会讲几个比较简单的入门算法以及概念
- 之后的所有算法均可按自己喜好学习
- 同时本教程默认所有读者都有充足的语言基础
- 由于篇幅限制 本教程不会非常详细的讲解每个算法
- ## 0x00 入门
- ### 0x01 时空复杂度
- 我们知道算法的效率有快慢之分,但是如果仅仅通过"快","慢"这样的说法描述一个算法,是十分模糊而不可行的。
- 于是我们引入了时空复杂度的概念,分为时间复杂度和空间复杂度。
- 时间复杂度一般表现为一个函数$T$或函数$O$ ,它反映了算法的运行时间和输入数据规模的关系
- 另外需要注意的是 在教程中 绝大多数的时空复杂度都默认输入数据的规模为n
- $T$函数可以比较精确的反应一个算法的这种关系 我们举个例子来理解这个函数
- 比如说我们定义了一个函数
- ```cpp
- int func(int n){
- int ans = 0;
- for(int i=1;i<=n;i++)
- ans += i;
- return ans;
- }
- ```
- 可以看出 func的作用是求$\sum_{i=1}^ni$
- 对于输入的一个数n 这个算法需要进行的计算次数在n次左右 于是我们认为这个算法的复杂度为$T(n)$
- 但是你发现这样写太傻了 因为高斯在很久以前就说过
- $\sum_{i=1}^ni=\frac{n*(n+1)}2$
- 于是你把这个函数写成了
- ```cpp
- int func(int n){
- return n*(n+1)/2
- }
- ```
- 现在,由于计算一个乘法的时间是固定的,与输入规模(这里指n的大小)无关,我们说这个算法的复杂度是$T(1)$。
- 但是我们发现用$T$来表示一个算法的复杂度比较麻烦 因为如果遇到这种代码:
- ```cpp
- int func(int n){
- for(int i=1;i<=n;i++){
- for(int j=1;j<=n;j++){
- printf("%d %d\n",i,j);
- for(int k=1;k<=n;k++)
- printf("%k\n",k);
- }
- }
- }
- ```
- 我们在分析复杂度的时候就应该是$T(n?+n?)$ 因为第四行被执行了$n?$次而第六行被执行了$n?$次(三重循环,n?)
- 所以我们需要一个比较简单的函数来描述复杂度 于是我们选择了大O记号
- $O$一般指算法的**渐进时间复杂度**
- 它的计算方式比较玄学
- 首先我们需要选择一个函数$f(n)$ 使得$\lim_\limits{n\rightarrow\infty}\frac {T(n)}{f(n)}=k(k\not=0)$
- 那么我们就可以让$O(f(n))$来表示这个算法的时间复杂度
- 如果你看不懂上面的说法 你可以简单的理解为算法的计算次数在$f(n)$左右
- 如果你还是不懂 你可以认为$f(n)$就是只保留$T(n)$的最高次项,例如$T(n?+n?) -> O(n?)$,
- 因为随着n逼近无穷大,n?相对于n?是无穷小,不起决定作用。
- 一般来说 $f(n)$会取到$logn(多见于二分算法),n(多见于递归),nlogn,nlog?n,n?(多见于多重循环),n!$之类的简单而好看的函数
- 我们再拿上面那段代码举例 此时的渐进时间复杂度就应该是$O(n?)$
- 按照现在的比较大众的CPU效率 常数比较优秀的算法可以在一秒内执行大约$10^8$次运算
- 换句话来说 当你将$n$带入到大O记号内计算后得出的结果如果在$10^8$以内 那么你的算法就**差不多**可以在一秒内得出结果
- 空间复杂度的计算方式与时间复杂度相似 不再赘述
- ### 0x02 模拟&暴力
- 当我们需要解决一个问题时 我们可以按照这个问题的思路去直接实现算法
- 再以求$\sum_{i=1}^ni$为例
- 我们可以直接从1枚举到n 再将它累加进答案
- 当我们这么做的时候 这个做法就称为模拟 有时也可以叫做暴力
- 一般情况下模拟的时间复杂度会比较高
- ~~当然我相信有能力写项目的人的模拟水平一般都不会差~~
- 举例来说
- [[jsoi2007]麻将](www.luogu.org/problemnew/show/P4050)
- 题目大意:
- 给定$3m+1$张麻将中的牌 每张牌只有序数没有花色
- 同时给出序数的范围$n$
- 问所有听的牌
- 本题不考虑七对子
- $9\leq n\leq 400,4\le m\le 1000$
- 首先我们要了解麻将和牌的规则 如果不了解 可以百度一下
- 我们会发现在和的牌中只有要求的一个对子是比较特殊的
- 那么我们可以
- - 用一个长度为n的数组来表示手上有的牌(数组下标为i时的值表示手上序数为i的牌的数量)
- - 从1到n枚举所有牌加到手牌中
- - 从1到n枚举对子
- - 从1到n枚举判断能否组成3m对刻子或者顺子
- 同时最后一步中我们可以发现 当我们能组成刻子的时候 就可以直接直接组一个刻子
- 于是这道题就十分简单的解决掉了
- 其时间复杂度为$O(n?)$
- ### 0x03 贪心
- 实际上来说 贪心并不算是一种算法 而是类似于一种思想
- 有一类问题会让我们求对于给定的输入数据的最优解之类的东西
- 我们称最后的答案为**全局最优解**
- 比如说这道题
- [纪念品分组]()
- 题目大意:
- 给定$n$个数和一个常数$k$
- 你需要将这$n$个数分成任意组 每组的和都需要小于等于$k$并且每组的数的个数不多于$2$
- 求最少的组数
- $n\le3\times10^4$
- 我们有一个显而易见的思路
- 枚举每一个数分到哪一组
- 但是更显而易见的是这么做又难写又跑得慢
- 于是我们可以想想别的做法
- 所以你把这一堆数排序后 把每一对尽可能小的值和尽可能大的值分成一组
- 之后再统计一下分了多少组就行了 复杂度$O(nlogn)$
- 但我们还没有严格的讨论这个做法的正确性 也就是说很有可能这个做法是**错误的**
- 不过 这个做法是没有错的 具体的证明在这里就略过了 有兴趣可以看题解
- 在这个题中 我们在每一次选择分组的时候都选择了对**当前**状态来说最优秀的选择
- 我们做出的选择被称为**贪心策略**
- 一个优秀的贪心策略是应当能从**当前**最优选择来推导出**全局**最优解的
- (也就是说,我们姑且认为下一步拿到的就是最好的,先拿到一个结果再说)
- 当然 也有可能有不存在满足上面这个条件的贪心策略的时候
- 这时候就说明这道题不可以用贪心解决
- 大多数贪心都有这样的过程: 首先给出一个感觉是对的的贪心策略 然后证明这个策略确实是对的
- 然而一般来说证明的过程总要比想出策略难很多
- 因此一般会以找反例的方式证明一个策略的正确性 找不到就是没有错
- 错误的贪心一般都会对某些特殊的输入数据得到错误的答案 于是我们可以构造反例来证明一个做法是错误的 这也是证明一个贪心做法错误的时候常用的方法
- ### 0x04 排序
- 在这一部分中 我们默认待排序的为$A$ 排序方式为升序排序
- #### 0x05 选择排序
- 选择排序是我认为最简单易懂的一种排序做法
- 其本质就是不断从数组中选择最小值放到最前面
- 选择排序需要确定$n$个位置 每次确定一个位置需要$O(n)$的时间扫一遍$A$
- 于是其复杂度为$O(n?)$
- 当你需要进行选择排序时 你只需要每次选择$A$中的最小元素 放到数组的最前面 然后再对剩下的$n-1$个数排序即可
- ```cpp
- for(int i=1;i<=n;i++){
- int pos = i;
- for(int j=i;j<=n;j++)
- if(A[j] < A[pos])pos = j;
- swap(A[i],A[j]);
- }
- ```
- #### 0x06 插入排序
- 插入排序也是一种比较易于理解的排序做法
- 我们可以在数列$A$外再定义一个数列$B$
- 将$A$从1到n遍历后 把每个$A_i$插入到$B$中合适的位置 同时满足B依然有序
- 最后的结果就是$B$
- 很明显的对于每一个$A_i$ 你都需要遍历一次$B$来找到适合它的位置
- 于是这个算法的复杂度也是$O(n?)$的
- 有必要说明的是,在数据序列较小的时候(N<=15),插入排序优于快排
- 代码太长 不在这写了
- #### 0x07 冒泡排序
- 冒泡排序的每一趟操作会从1到n扫一遍 并且将构成满足$A_i>A_i+1$的两个数交换
- 接下来我们证明当某一趟操作中没有交换任何数时 这个序列是有序的
- 首先我们发现:
- 当$A_i>A_j,i<j-1$ 并且对于任意的$k\in(i,j)$ 满足$A_k<A_j$ 那么同时有$A_i>A_k$
- 也就是说只要$A$中仍然存在$A_i>A_j,i<j$ 就一定会在下一趟操作中交换至少一次
- 之后我们会发现每一次交换后 满足$i<j,A_i>A_j$的$(i,j)$的对数一定会减少
- 当这个值减少到0时 这个数组显然就是有序的了 同时由上文可知 当存在$(i,j)$时 就一定会进行交换 于是冒泡排序的正确性得证
- ```cpp
- bool flag = true;
- while(flag){
- flag = false;
- for(int i=1;i<n;i++)
- if(A[i] > A[i+1]){
- flag = true;
- swap(A[i],A[i+1]);
- }
- }
- ```
- #### 0x08 桶排序
- 桶排序一般用于大量元素相对均匀分布的场合,这里只讲最简单的桶排序 也就是说只能排序非负整数
- 我们可以开一个数组$B$ 其中$B_i$表示在原数列中$i$出现的次数 我们认为每个$B_i$是一个桶
- 我们首先可以用$O(n)$的时间把$B$建出来
- 之后再从0到$maxA_i$ 枚举 把每个桶里的元素拿出来 最后得到的数组就是有序的
- 尽管看起来这个算法的复杂度是$O(n)$的 但是实际上你会发现最后一步导致了这个算法的复杂度为$O(n+maxA_i)$ 同时其空间复杂度也是$O(maxA_i)$
- 在$maxA_i$十分大时 时空复杂度都不可以接受
- ```cpp
- int B[n],maxA=0;
- for(int i=1;i<=n;i++)++B[A[i]],maxA=max(maxA,A[i]);
- for(int i=0,j=1;i<=maxA;i++)
- while(B[i]--)A[j++] = i;
- ```
- 实际上这个算法在经过优化后是一个十分优秀的时空复杂度均为$O(n)$的算法
- 它的名字是基数排序 但是这并不在我们的讨论范围之中
- #### 0x09 归并排序
- 归并排序实际上是基于分治的思想来进行的排序
- 首先我们可以用$O(n)$的时间来合并两个**有序**的数列
- 于是我们可以认为对于其中的每个元素$A_i$ 是可以在$O(1)$的时间内进行合并的
- 那么我们可以将原数列分成两个数列$A_1\to A_{mid},A_{mid+1}\to A_n$
- 之后我们使用双指针i,j分别指向两个数列A,B的列头元素,比较元素大小,小者移动到
- 新数列中,与此同时被移动元素的数列,指针移动到下一个元素递归比较,直至某一数组
- 为空, 最后再合并起来得到一个有序的数列
- 对于这个算法 我们需要证明的是其复杂度为$O(nlogn)$
- 我们将算法的递归树建出来
- 很明显的 在分解部分我们有一个深度为$logn$的递归树
- 那么这就意味着每一个元素$A_i$都会被合并$logn$次
- 于是对于$n$个元素 总共的合并次数就应当是$nlogn$的
- 同时每次合并的代价是$O(1)$ 于是归并排序的复杂度为$O(nlogn)$
- 代码太长
- #### 0x0A 快速排序
- 看名字就知道这个排序很快
- 但是依然还是$O(nlogn)$的 甚至最劣还能达到$O(n?)(最坏情况极难出现)$
- 对于每一趟操作,为了避免以前的排序结果(或者反序)带来的坏影响,
- 我们首先应用“三数中值分割法”,随机取三个数(例如数组开头,末尾和中位)取中值
- 选择一个枢纽元$A_i$ 然后将数列分为三部分 $B,A_i,C$
- 满足$\forall x\in B,x\le A_i$并且$\forall x\in C,A_i< x$
- 我们选定枢纽元之后,应该将其放到数组末端,然后采取双指针(i指向第一个元素,j指向倒数第二个元素)相向而行,
- 若是遇到比枢纽元“大”的元素,将其移动到右边,比枢纽元“小”的元素移动到左边,直至i,j交错
- 然后将枢纽元与最后一次右移的值交换
- 之后再对$B,C$分别进行快速排序
- 复杂度可以参考网上的资料 这里不再做证明
- #### 0x0B sort
- 大部分编程语言提供的库中都会有排序的函数 直接调用就好了(例如Java的Timsort算法和Array.sort)
- 这是我最推荐的一种排序方式