这学期快结束了,算法课程也进入尾声,这学期一共有6个实验+大作业,一共7个实验,当时做的时候苦于没有思路,虽然网上可以搜到往届前辈的报告,因为没有代码可以参考,导致每次实验都比较艰难。因为伪代码往往不会将算法的全部细节描述出来,而且一些实际编程中的细节,伪代码无法描述,所以有了这个总结。
这篇博客分为几个部分:
首先是容器介绍,因为算法都是基于容器的,我们不需要关系容器怎么实现,我们专注于算法的实现,所以不应该在容器上花费时间。搞定算法实验,先搞定容器。
然后是编程技巧,编程技巧中主要以c++为基础,介绍一些算法实验中有用的技巧,这些技巧能够大大优化代码复杂度或时间复杂度,使编程变得轻松。
然后是实验介绍,这部分介绍每个实验的要求,有那些算法能够实现,然后会有传送门,跳转到每一个实验的讲解报告。
最后是总结以及一些感悟。这部分就是谈谈算法实验是如何折磨我的 心得
既然是算法实验,我们精力自然是放在算法上,不应该花费精力在准备算法的容器上,比如邻接表,我见过有老哥自己写一个链表来存数据的,其实完全没有必要自己写,C++ STL提供了相当方便且高效的容器。写下来介绍一些C++ STL的容器,及其常用场合
vector也就是向量,数组,线性表容器,底层实现就是一维数组。最最最常用的容器,没有之一。
push_back()
方法添加元素进容器尾部,pop_back()
删除尾部元素。.resize(长度)
来产生一个特定长度的vectorvector a(123, 456)
来创建一个长度为123,每一个元素都是456的vector[]
运算符,可以快速访问下标,但是不设置越界判断,要小心一般使用push_back来增加一个尾部元素,复杂度为O(1)
一般使用pop_back来删除一个尾部元素,复杂度为O(1)
其他位置的增删,不推荐,因为会有元素的移动,复杂度为O(n)
任何需要线性存储元素的场合,最常用的容器没有之一。
栈/队列/双端队列容器 底层为线性表
栈:后进先出,push加入元素,pop弹出元素,top返回栈顶元素引用但是不弹出。
队列:先进先出,front返回队头元素引用,push添加元素,pop弹出元素
双端队列:pop_back , pop_front 头尾都可以增删
O(1)
stack的应用相对vector来说较少,因为stack是特化了的vector
queue主要应用于bfs广度优先搜索
deque则更加少,在某些需要快速在首尾增删的线性表,比如滑动窗口最大值问题
关联式容器,底层是红黑树
通过insert方法插入元素,erase方法删除元素。find方法查找元素。
find方法返回元素迭代器,如果 .find() != .end()
则说明找到
set的迭代器存储元素对象,假设有迭代器对象it,那么通过 *it
即可访问元素
map的迭代去存储元素<键,值>
对,假设有迭代器对象it,那么通过 it->first
即可访问键, it->second
即可访问值
map可以通过[]下标运算符直接找到键对应的值,用法:值 = map[键]
O(log(2, n))
set比较少,主要用于快速查找,map也是,主要也是用于快速查找,比如记忆化递归,键表示状态,值表示答案。因为查找有更优秀的哈希容器,接下来会介绍
关联式容器,底层是哈希表,通过对元素的哈希,来快速存取元素,前提是元素拥有高效的哈希函数
插入,访问,删除元素方法同 map/set
注:如果使用自定义类型,需要在构造函数传入自定义的哈希函数与比较函数
参考:【C++ 自定义哈希函数使得自制数据类型也可使用STL的哈希set,map】
O(1)
unordered_map用于记忆化递归比较多,尤其是那些【键】不连续的问题,比如消消乐,就不能开mem数组来表示,而是用哈希把他们映射到连续的内存
unordered_set用于存储不重复元素,比如要筛重复元素,或者要快速查找一些元素是否在某个集合中(主要是这个 快速查找),舒适好用~~(就是insert插入元素效率略低 常数代价。。。)~~
优先队列容器,底层是堆,队头总是保存最大(小)值
详情见【C++ STL 优先队列(priority_queue)容器的简单使用】
push插入元素,pop弹出队头元素,top返回队头元素引用
O(log(2, n))
广泛,只要用到堆的地方,都可以用。主要用于在动态更新的序列中快速获取最值的场合。你可以用来堆排,topk,最高标号预流推进等等
容器提供的方便是不言而喻的,使用容器能够大大减少编程的代码复杂度,而且出错的机会也会随之降低。
在函数尤其是递归函数,如果希望动态改变容器参数的内容,那么需要使用引用,引用不仅能够避免元素的多次拷贝(尤其是容器元素较多的时候),而且可以使函数思路更加清晰,因为高级语言默认所有形参都是引用
有些问题往往需要我们自定义一些类型,比如图论中的【边】,或者是一些其他的类型。
比如实现图的一条边,可以用vector,下标0表示起始,1表示终点,2表示权值,但是这一切值得吗 这样会使得代码的可读性降低,这对后续代码的维护是不利的。
少量的代码就可以定义数据类型,却可以使代码更加易懂
typedef struct edge
{
int st, ed; // 起点, 终点
edge(){}
}edge;
#include
多用万能头文件,虽然编译很慢,但是要用到什么函数,不用再查找他是哪个头文件的,可以减少编译错误的几率,比如想用memset,又要用strcpy和clock,就要include很多头,而#include
可以解决一切。
缺点是容易造成命名冲突,比如clock就是函数名,不能用了
如果要做很多次bfs,而每次bfs不必搜完所有节点,那么我们没有必要重置整个visit数组,因为这样做大量的时间会浪费在重置visit上面,而bfs的时间占少数。
假设bfs起点为src,我们可以令
这样做n次bfs只用一次初始化vis数组为-1即可,大大减少访问控制数组初始化的开销。
注释可以增加代码可读性,将程序模块化。因为一个实验往往需要好多天来写代码,隔夜代码难读,需要注释的索引。
下面贴一个我比较常用的函数注释的格式,表明函数的功能, 参数, 返回值 ,以及函数的解释
/*
function : 对nums的子数组进行快速排序
param nums : 要排序的数组引用
param l : 子数组范围 左边界
param r : 子数组范围 右边界
return : ---
explain : 左右边界都是闭区间
*/
void quick_sort(vector<int>& nums, int l, int r)
{
if(l<0 || r>=nums.size() || l>=r) return;
int key=nums[l], i=l, j=r;
while(i<j)
{
while(i<j && nums[j]>=key) j--;
swap(nums[i], nums[j]);
while(i<j && nums[i]<=key) i++;
swap(nums[i], nums[j]);
}
nums[i] = key;
quick_sort(nums, l, i-1);
quick_sort(nums, i+1, r);
}
库函数就是为了减少代码复杂度而生的,虽然难记忆,但是一回生二回熟。下面介绍常用库函数
memset
批量设置内存
max_element / min_element
数组最大 / 最小值
inplace_merge
原地归并,归并排序用
sort
快速排序,很快 比手写的要快
swap
交换两个数据,不限类型(本质是函数模板)
next_premutation
下一个全排列
reverse
反转数组
lower / upper _bound
二分搜索(前提是数组有序)
所有实验的资源
链接: https://pan.baidu.com/s/1lukZRM3Rsd1la35EyyJcvg
提取码: iv72
【实验传送门】
第一题是要实现几种排序算法,然后测时间,这个没啥,用vector然后排就完事了
第二题要找topk元素,用优先队列模拟即可
【实验传送门】
需要定义结构体 point 表示点,初始化需要对点排序,用sort,自定义比较函数即可。然后就是分治法有两种,一种是合并前排序,一种是边分治边排序。
注意生成测试数据需要去重,要用哈希set,要自定义比较/哈希函数
【实验传送门】
这个消去方块比较难搞,我是用长度为 3/4/5 的横竖条去覆盖棋盘,看看有没有相等的三联,四联,五联,统计数目为 cnt3, cnt4, cnt5,然后将他们标记成负数,方便之后消除。
覆盖结束后,消除所有负数的方块,然后下落。注意下落后要再次判断,因为可能可以再消。用递归
因为四联,五联包含三联,所以要去重重复的计数。一个五联包含两个四联和三个三联,一个四联包含2个三联。
cnt4-=2*cnt5, cnt3-=(3*cnt5+2*cnt4); // 消去重复的计数
记忆化递归,先将棋盘转为字符串,然后因为c++自带对字符串的哈希,用unordered_map即可存状态和得分。
【实验传送门】
这个实验主要用STL的string对象即可完成字符的处理,其他倒没啥。。
【实验传送门】
这里我做完才知道,用哈希set来标记边太慢了。应该设一个mark数组就行了,然后就是找lca的时候,沿途标记环边,要压缩,即所有的点挂到lca下
不用在dfs的时候就找lca,也可以边标记环边边找lca,过程如下:
【实验传送门】
定义边结构体,然后边数组,偶数下标存正向边,奇数下标存反向边
typedef struct edge
{
int st, ed, val, pair; // 起点, 终点, 还能通过多少流量, 反向边下标
edge(){}
edge(int a, int b, int c, int d){st=a;ed=b;val=c;pair=d;}
}edge;
后来我发现,不用pair这个变量来找反向边,因为【偶数下标存正向边,奇数下标存反向边】,对一条边的下标异或1,就能找到反向边
edge e1 = edges[i]; // 正向边
edge e2 = edges[i^1] // 它的反向边
然后就是正常的求最大流就行了
先咕着,等我交完报告就更
基于RDF图的语义地点skyline查询问题,问题分为
有向图,图的每个顶点包含一些词汇信息
假设现在有查询词汇集合w,w的语义地点是RDF图的一个特殊子图,具有树的结构。W的语义地点是一颗以地点p为根的树,树的所有节点的词汇的并集,包含查询词汇集合w。
如果对两个查询词汇集合w的语义地点p1和p2
那么语义地点p1支配p2。P1支配p2说明p1是比p2更好的选择
假设我们现在已经拥有所有符合条件的语义地点集合p1
,我们希望找出p1集合中所有语义地点skyline。
新建集合p2,枚举p1中的每一个语义地点c,对每个c都遍历p2中的所有语义地点
其实就是暴力筛,当然也可以根据距离之和排序,距离之和小的不会被距离之和大的地点支配,这样我们只用比较一次支配关系,这样做可以减少比较的次数
通过建立每一个节点的哈希表来作为节点词汇集合,利用哈希的特性可以在O(1)的常数时间内查询该节点是否具有某个词汇,表的建立在读取原始数据时就已经完成。
当然顺序查找也是可以的,不过时间稍微慢一些,因为最多300个词汇多点,效率差别不大
我们根据语义地点的定义,对每个点找离其最近的查询词汇。通过bfs广度优先搜索可以快速确定符合条件的点到源点的最短距离。蛮力法通过枚举所有顶点作为bfs的源点,做n次bfs。Bfs过程中查看遍历到的点是否包含查询词汇并尝试更新查询词汇到源点的最短距离。
我们认为用户查询的词汇总是有很强的相关性,而距离根节点远端的词汇表示其相关性弱,是答案的可能性小,于是把它剪掉。
我们预设一个步数来限制bfs的搜索。bfs搜索的时候,搜索层次达到k以上就放弃搜索
我们假设语义地点根节点常常会包含某个查询词汇,没有包含任何查询词汇的节点,通常不会作为语义地点的根节点,我们通过对语义地点的根节点做进一步筛选,然后再对选出的根节点施加蛮力法,即只选择包含关键词的节点做源点进行bfs,从而排除部分无效的bfs。
既然每次查询都要对所有节点做一次bfs,我们为何不在一次bfs中记录所有词汇到源点的距离,而非只记录查询词汇。
使用预处理的思想,在一次预处理中进行bfs,记录所有词汇到源点的距离并且将结果存储在n张巨大的哈希表中(每个节点都有一张),这意味着我们可以花费常数时间来查询任意节点到任意词汇的最短距离。
注:离线查询的本质还是蛮力法
反向bfs采用逆向思维。即从拥有查询词汇的节点开始,沿着有向图的反向边进行bfs,沿路更新父节点们到词汇的距离。即只做有用的bfs。
除此之外,如果一个节点到所有词汇的距离都不能被更新,那么不再对其bfs,因为该点及其之后的节点最短距离的计算,都不会依赖这一次的bfs带来的最短距离信息,对他们来说这是一次无效的bfs,故可以提前结束
和蛮力法搜寻词汇不同,反向bfs自己主动更新其他节点到查询词汇的距离,而不是等待别人来搜索自己。这么做大大减少了无效的bfs。这种更新方式和自底向上的动态规划异曲同工。