2020中兴捧月算法大赛——傅里叶赛道 第1名方案

大家好,我是轶扬。
最近我在总结研究生阶段参与的一些项目和比赛,翻到了2020年参加的中兴捧月算法大赛,题目很有意思,解法上也有一些有趣的创新,所以拿出来特别记录一下。
中兴捧月算法大赛是中兴通讯公司主办的算法赛事,每年都会根据当下热点设置多个赛道,包括图像、音频、通信和数据库等。由于我是通信背景出身,所以我在2020年参加了中兴捧月的“傅里叶”赛道,这个赛道的题目有一种大家很容易想到的解法,因此有很多非通信专业的同学参与进来,人数达到了一千多名,是当年5个赛道之最。我最终凭借算法效率的巨大优势(程度运行时间最短),在傅里叶赛道的两个阶段中均获得第一名,并大幅领先于第二名。下面是对于赛题和解决方案的详细介绍。

这个赛道的题目很有意思,出题方用一个有趣的小故事描述了一个数学问题。

赛题描述

丰收祭前的游戏

情景描述:
在某片遥远的大陆上,居住着两个世代友好的部落,分别是部落A和部落B。他们一起耕耘劳作,互相帮助,亲如一家。久而久之,部落里的每个人都在对方部落里找到了志趣相投,互相欣赏的好朋友。有的人性格热情开朗,好朋友很多;有的人性格沉稳内敛,好朋友相对少一些。
每到秋天丰收的季节,这两个部落的人民都会聚集在一起举行盛大的“丰收祭”,来祈祷下一年的风调雨顺。今年的丰收祭马上又要举行了。为了进一步增进两个部落的友谊,也为了明年能有一个好收成,这两个部落的祭司们商量后决定:在今年的丰收祭前举办一场特别的“击鼓传花”游戏。只不过游戏中并非有人真的击鼓,并且所传递的“花”也不是真的花,而是等待在丰收祭上献上的祭品。
游戏规则如下:

  1. 两个部落的所有人都可以事先准备自己的祭品,且每个人的祭品样式都不同,每一个祭品都分别盛放在一个相对应的木托盘里;准备此祭品的人熟悉自己的祭品;
  2. 每个人可以准备的祭品数量不限;祭品的最小不可分割单位是1份;
  3. 游戏开始后,在整个游戏过程中,每个人都能且只能将祭品(包括木托盘)传递给自己在对方部落里的好朋友们,每个好友可以接收的祭品数量不限;
  4. 收到祭品的人必须在盛放此祭品的木托盘上刻上自己的名字(代表留下自己美好的祝愿),随后按按照上一条规则,继续传递;
  5. 如果祭品回到最初准备此祭品的人手中,此人也在木托盘上刻上自己的名字之后,终止传递;
  6. 木托盘上不允许出现重复的人名,如果无法满足此条件,则不再继续传递该祭品;
  7. 当所有的祭品都不再传递后,游戏结束;

游戏开展得非常顺利。游戏结束后,祭司们将收集同时满足如下三个条件的祭品用于接下来的丰收祭活动:

  1. 此祭品回到了最初准备它的人手中;
  2. 盛放此祭品的木托盘上至少有4个名字,至多有14个名字;
  3. 如果有多个木托盘上的名字完全一样(不区分名字的排列顺序),则从其中随机选择一个木托盘所对应的祭品。

问题:
已知这两个部落里的所有人都不重名,并且部落A的人和部落B的人之间的好朋友关系以附件的csv数据表格文件给出,其中行索引代表部落A中的人,列索引代表部落B中的人,表格中的数字“1”代表他们两人是好朋友,“0”代表他们两人不是好朋友。请问:
如果以木托盘上的名字的数量对用于丰收祭的祭品分类,每一类分别最多有多少个祭品?

请参赛者答题:
木托盘上有4个名字的祭品最多有()个;木托盘上有6个名字的祭品最多有()个;木托盘上有8个名字的祭品最多有()个;木托盘上有10个名字的祭品最多有()个;木托盘上有12个名字的祭品最多有()个;木托盘上有14个名字的祭品最多有()个;
请在每个()中填写一个正整数答案;
网上初赛将分为两个阶段,其中第一阶段将于赛题发布的同时启动。参赛者需参加全部两阶段的比赛。两阶段的结果准确性得分及排名互相独立。即第二阶段启动后,第一阶段的的得分及排名将不再更新。赛事方将根据参赛者在两阶段比赛中提交的结果的准确性得分和参赛者所设计的算法的效率给出参赛者的综合成绩。

参赛者需注意以下事项:

  • 参赛者以个人为单位参赛,最后结果只关联到一个参赛自然人。
  • 每天第一次登陆时参赛者会随机得到一个代表两个部落的人民之间好朋友关系的csv数据表格文件。参赛者需要在得到此表格文件后的1小时内提交答案,在此时间内参赛者最多可提交答案2次。超过该指定时间,则需等待至第二天再重新答题,重新答题后,参赛者将获得一份新的csv数据表格文件,并基于新得到的csv数据表格文件给出答案。
  • 参赛者可使用C/C++/Python/Matlab 计算机编程语言,在自己的计算机上计算出结果,再把计算结果输入到系统中。
  • 参赛者分别给出不同种类的祭品数量。系统将根据参赛者所提交的祭品数量的准确性给出参赛者的当次得分和历史最高得分。系统理论上能给出的最高得分为100分;系统将对参赛者的历史最高得分排名,该排名数据公开;得分相同的,以先提交结果者排名靠前。
  • 参赛者还需提供可在PC个人电脑上使用CPU单线程执行的算法程序源代码文件、编译后的程序文件(如有)和算法原理及测试环境搭建说明文档。参赛者需将源代码文件、编译后的程序文件(如有)和算法原理及测试环境搭建说明文档打包压缩为zip文件后上传系统,压缩包文件的命名规则为“拼音姓名_学校英文缩写.zip”。例如ZhangSan_NJU.zip。
  • 参赛者提交的源代码应当可直接读取符合赛题形式的本地csv数据表格文件,并将计算结果保存在以“result.txt”命名的文本文件中。源代码应符合常见的代码书写规范,源代码文件中应有必要的注释信息,具备良好的可读性。输出结果的内容应清晰可读。算法原理说明文档应使用中文文字清晰地说明所设计算法的思想。参赛者提交的说明文档不要和历史提交的文档关联,即提交的说明文档应是对算法原理和测试环境搭建的完整描述,而不是增量描述。算法原理的说明应不少于300字,说明文档请使用PDF或MS Word文件格式。
  • 在一般家用计算机配置条件下,使用CPU单线程,计算结果应当能在十分钟之内得到。如果参赛者的计算耗时明显高于此时间,则应考虑为所设计算法程序的效率问题,请对算法程序进行优化。留意观察数据的特征,参赛者也许可以找到提升效率的思路。

题目理解

对于这样一个以故事的形式描述的赛题,首先我们需要抽象出数学问题,其实就是Tanner图中不同长度的无向环的查找和计数问题,对应到我们通信领域中,就是要解决一个LDPC校验矩阵的短环问题,因为短环的存在会影响LDPC迭代译码时外信息传播的独立性,降低迭代译码收敛速度,甚至会使得算法最终无法收敛,因此评估LDPC校验矩阵的短环分布十分重要。说回到赛题,Tanner图中校验节点和变量节点的关系由一个256*640大小的邻接矩阵给出,我们把第一维看作校验节点,把第二维看作是变量节点,那么邻接矩阵中的每一个元素就表示两个节点之间是否有连接。根据题目描述,可知我们需要找出Tanner图中长度为4、6、8、10、12、14的无向环。因为数据维度较高,并且其中包含的无向环数量很大(长度为14的环的个数有两亿多个),所以需要我们在保证找出所有环的前提下(正确率必须达到100%),算法的计算复杂度越低越好(程序运行速度越快越好),这也符合5G 数据信道大带宽低延时的要求。

传统算法

对于LDPC校验矩阵中的短环查找问题,有研究者提出一种算法将短环计数问题转换为路径计数问题,最多可以对长度为g+4(tanner图的最短环长称为围长,用g表示)的短环进行计数,但本赛题的围长是4,同时要找出最大环长为14的环数量,所以这种算法不适用;还有一种算法通过枚举校验矩阵中不同环的形状,然后进行类似模式匹配的方法查找校验矩阵中不同长度的环,文献列举了环长最大到8的pattern,这种方法的缺点也很明显,随着环长增大,需要考虑的情况呈指数级增加。当然还可以直接从深度优先搜索DFS的方法出发,查找Tanner图中的无向环,这也是本赛题大部分选手使用的方法,这类算法相当于需要对图中的所有节点进行遍历,然后用回溯+去重的思路查找不同的无向环,算法的整体复杂度极高。

基于树结构展开的无向环查找算法

我所使用的是一种基于树结构展开的算法,具体来说,将tanner图中某一个变量节点作为根节点,构建一个树结构,树的偶数层由与变量节点连接的校验节点构成,树的奇数层则由与校验节点连接的变量节点构成,当树的第k层中某一变量节点或校验节点出现两次或两次以上时,就会构成长度为2k的环。我们来画一个图进行更具象的解释:
2020中兴捧月算法大赛——傅里叶赛道 第1名方案_第1张图片
矩阵H表示了一个由4个变量节点和6个校验节点构成的Tanner图,其中变量节点和校验节点的编号为1到10。现在以1号节点为根节点,与其相连的有5、6、8号节点,因此这三个节点构成了树的第一层(包括根节点则为第二层),然后同理,在矩阵中找到和这三个节点相连的节点,构成树的第二层,分别为3、2、4,至此,还没有同层中含有相同节点的情况,再继续构造第三层,此时发现7、9、10均有两个,因此可以形成长度为2k=6的三个不同的环,即(1,5,3,9,2,6),(1,5,3,10,4,8),(1,6,2,7,4,8),注意(1,6,2,9,3,5)与第一个环相比只是路径顺序不同,在此塞题中,路径不同但所经过节点相同的环被认为是同一种环。图中右边的表格记录了每一层的每个结点到根节点的路径,在第三层中,两个颜色相同的行表示相同编号节点到根节点的两条路径,将这两条路径相连,即形成一个环。因此,通过上图介绍的树展开思想,一直构建到k=7,即可找出所有环长为4、6、8、10、12、14的环。

排除无效环

但是,由于本赛题的特殊性,我们还需要排除三种“无效环”情况,并设计相应的算法进行处理:
1)提前成环:第k层出现相同的结点,若其父节点也存在相同,则此时长度为2k环即为无效环。我的处理方法是,将两条分支所经过的点进行桶计数,如果某个结点出现大于一次(不包括根),则应对其进行去重;
2)顺序不同但结点相同:根据题意,当环所经过的节点相同但路径顺序不同时(例如1A2B与1B2A),上述方法会进行多次计数,但在本题要求中只能记一次。我的处理方法是,保留环的路径后对其进行排序,然后放入无序集合中(其底层是hashmap,将路径进行hash散列,放在对应的桶中,同一个桶中的hash值相同),如果出现上述情况,则不同路径但经过节点相同的环的hash值相同,可实现只记一次的目的;
3)由环上的不同根节点展开得到相同的环:由于对所有变量结点展开至k层时,一种环会共计出现k次,因此将最后的计数除以k即为长度为2k环的数目。

利用数据特征提升算法效率

出题方在题目中指出 “留意观察数据的特征,参赛者也许可以找到提升效率的思路” 因此,分析一下数据特征,矩阵的维度为256 * 640, 可以明显看出是一个稀疏矩阵,对应到LDPC校验矩阵中,是一个QC-LDPC矩阵,其基矩阵大小为64*64,这一特征说明以64行为一组,将其中每个变量节点作为根节点,利用树结构展开之后所形成的环的数量是相同的,所以对于这64个变量节点来说,只需要以任意一个为根节点,然后计算环的数量,最后乘64即可,这个思路也大大降低了算法整体的计算时间。

其他实现细节与代码优化思路

  • 因为LDPC是稀疏矩阵,所以将结点之间的关系由邻接矩阵转换为邻接表存储可以节省内存空间的同时加快查找速度。邻接表主要由两种指针构建,第一种指针是vertex结点指针(其中值为结点的值,指针为第一条边的指针),第二种指针是edge边指针(其中值为结点的值,指针为下一条边的指针),所以对所有变量节点和校验节点创建结点指针,然后根据节点之间的关系构建edge边指针即可。
  • 为解决“顺序不同但结点相同”的情况,需要把排序后的环放入unordered_set中保证元素的唯一性,因为它能够快速查找和添加,首先需要对环进行散列,散列的对象是类型为array的数组,其中array中保存的是14环的排序后的路径(不足14则补零),因此散列函数需要自定义:arrayhash相当于对所有元素进行hash变换之后进行异或,最终得到该array的hash值。
//unordered_set需要自定义hash函数
struct ArrayHash {
	size_t operator()(const std::array<short int, 14>& v) const {
		std::hash<short int> hasher;
		size_t seed = 0;
		for (short int i : v) {
			seed ^= hasher(i) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
		}
		return seed;
	}
};
  • 使用visual studio的性能探查器,可以针对每一行代码或者每一个函数的调用次数以及运行时间占比进行统计:大量的时间都耗费在了unordered_set.insert()这个函数中,分析其原因,就是是扩容造成的。然后还有去重算法、vector初始化大小上的优化。

  • 扩容优化:在vector中,每当我们插入一个新元素时,如果当前的容量(capacity)已不足,需要向系统申请一个更大的空间,然后将原始数据拷贝到新空间中。这种现象在unordered_set中也存在,在本题中也极大的影响了计算时间。每个桶放太多的元素就会影响到unordered_set的存取效率,而标准库通过采用某种策略来对当前空间进行扩容,来提高存取效率。但是这里面有一点我们需要注意的是,每当unordered_set内部进行一次扩容或者缩容,都需要对表中的数据重新计算,也就是说,扩容或者缩容的时间复杂度至少为O(N)。标准库的默认初始桶的数量是16,而在我们的任务中,环的数量以亿为单位,环长14的环为两亿左右,最终近三百多万个桶,如果按照标准库的策略进行扩容,需要非常多次的扩容操作,将导致时间的大量耗费,这一问题是使用性能探查器的时候发现的,因此我查看unordered_set的底层实现,发现我们可以通过rehash函数进行桶的人为初始化,如果不初始化则默认为16,所以将桶的初始数量设置为三百万,这样就避免了多次扩容,节省了大量的时间。

  • 使用5*5的全1矩阵进行算法校验,然后把找到的环打印出来,可以看到自己到底是没有针对哪种情况进行剪枝。

  • 对数据结构的优化,例如
    (1)Vector的排序要比array的排序快20%;
    (2)将存储的结点类型从4字节int改为short int 也就是2字节的,由于需要进行大量的hash变换,这样可以大大节省计算量,时间从0.8秒左右,降低为了0.5秒。

比赛结果

比赛分为两个阶段,第二个阶段的矩阵维度更大,环的数量更多,因此对算法的计算复杂度要求更严格,一部分选手的算法由于过于复杂,没有在要求的30分钟内找到所有环。本文所使用的基于树展开的算法在两个阶段均为第一名,并在算法效率上大幅领先于第二名。
比赛第一阶段
在256 * 640大小的LDPC矩阵中,找到所有长度为4、6、8、10、12、14的环时间为0.5秒左右

比赛第二阶段
在1344 * 1344大小的LDPC矩阵中,找到所有长度为4、6、8、10、12、14的环时间为5秒左右
2020中兴捧月算法大赛——傅里叶赛道 第1名方案_第2张图片

你可能感兴趣的