概括:用数组实现的带头尾指针的双向减序链表,使用插入排序,并通过中位数下标来决定是使用尾部插入还是头部插入的,实现从大量数据(存储于文件)中提取其最大100个的算法。优点,读取文件一遍,空间的非动态申请,无数组插入排序的大量”后移”操作,避免有序文件带来的最差表现。
缘起:这个月初,有同学去腾讯面试,这是最后一题,从1000万个数据中取出最大的100个,觉得点意思。我第一反应和同学是一样的,遍历这个1000万的超级数组或文件100次,每次找出当前最大的那个,并从中移除。第二反应是,显然100*1000万的规模太大,不可取,特别是,当1000万的数据在文件中,(如果是int型数据,32位机,将有40M,实际字段可能不止sizeof(int)),文件读取更耗时,更别说100遍了,最好能只读取一遍,从可以使得算法适用更大规模的存储在文件中的数据。
解决过程:好久不用考虑算法了,突然这么一个问题,头脑有点空,只好点开一本讲算法的电子书,从目录开始,第一章是算法的复杂性度量,然后是数据结构,然后是排序,一些相关的知识犹如从硬盘载入内存。问题的相关知识被串起来。觉得这本身是个排序问题,排序算法一大堆,但基本操作都是比较和赋值(+空间分配)。回顾各种排序算法的特点后,觉得插入排序,即每次插入到合适的位置,比较符合。主要是每次插入后都能得到一个有序的“数组”供后续数组插入,而插入一个元素到一个有序数组,有些特殊的优势,总结来说是减少比较次数:例如对一个减数组,你先比较后面的(较小的数据),如果要插入的数据比当前比较位置的数据都要小,就可以停止比较并进行插入,继而转向下一个数据的插入了。虽然这里会引发一个不容易注意的问题,原来数据的有序的程度会导致排序时间的极大波动,这里的这里测试的是10倍左右,[38s , 5min30s].迫使后面不得不引进中位数来决定当前待插入元素究竟是“头插”,还是“尾插”。由于这里只要前100个最大的,意味着我只以需考虑(挑选出)最大的100个,占10万分之一,而至于剩下的0.99999都可以忽略(被覆蓋,不出现在最终结果数组中的100个中)。相比其他经典的排序算法,以上两点是选择插入排序算法的主要原因。
有了算法,还得有数据结构,“程序=算法+数据结构”,尽管以上的考虑都是以数组为例子(考虑场景)的。本质使用的是线性数据结构的特点(类似于谍战中的单线联系),然而具备线性结构的除数组以外还有链表,数组靠空间相连来实现(指示)线性表中前后关系,而链表靠指针域来实现,以指针域的特点来划分,链表除单链表以外,还有循环和双链表,对应到数组,数组也具备这样的划分,或者说可以实现相应的功能,如果不深究,如下表述是正确的,通常的数组遍历类似单链表的特点,通过取模运算((i++)%(sizeof(array)/sizeof(array[0]));)实现循环链表的循环特点,至于双向特性,是数组的天生优点,i-1,意味着this->prev,i+1,意味着this->next;
选哪个好呢,数组?,怎么实现,不能,又缺少什么?还是链表,具体些,是单链表,循环还是双向链表?又会有什么问题。
如果用数组,每次插入会带来平均为50次的 “搬动”,如果用单链表,会带来接近1000万的malloc和free的操作,尽管这种操作(系统调用)单个可能很快。循环链表似乎和单链表没有多大区别。至于双向链表,不是有“头插”和 “尾插”的区别吗,看样子应该利用 “双向”的特性, “可是…”,你可能会问到,“难道双向链表就不需要那么多的malloc 和free了?”,确实,想必,你已猜到,使用完100个空间后,插入第101个元素时,让其覆蓋前100个中的最小的元素,插入的时机是在找到其合适的位置,例如在一个已建立的100个元素的递减的双向链表中,从尾部依次往前比较,直到找到正确的位置p(指针),则把当前元素覆蓋掉尾指针指向的元素,并把该空间从链表中断开,并把该空间插入p和p->prev之间的位置。想到这,程序就可以写出来的,只是要注意一些边界问题。不过真动手写时,这里有两个技巧值得你借鉴,一是分阶段处理,二是引入边界值。
分阶段处理,是指将第一阶段先排100个元素,把100个空间用完,第二阶段在原来的空间用完的基础上插入余下的元素,然后在每个阶段单独考虑,以此减少编写的难度。
引入边界值,是引入MAX和MIN ,并把循环链表初始为只有这两个元素,并排好序.MIN ,MAX 取值为对应字段的边界值或应用中的边界值,例如在我的系统32位,redhat6上,采用编译器预定义的宏 __INT_MAX__ 作为MAX ,其相反数作为MIN.这样的好处是可以统一条件判断,和减少初始情况判断。因为在对一个int数而言,一定是在MAX和MIN之间。
问题到这似乎解决了,新问题出现了:
以下实验中的要建立的都是递减(非递增)的序列。
实验1:首先单独读取,不排序读取一个随机数大文件花费时间为34s.
[[email protected] tmp]# ll ./r.dat
———-. 1 root root 1073741824 11月 18 12:45 ./r.dat
[[email protected] tmp]# time cat ./r.dat > /dev/null
real 0m34.775s
user 0m0.003s
sys 0m2.102s
实验2:再(1024*64)byte大小缓冲区(通过宏定义,改变时需重新编译)读取该文件,派出前100个花费时间是 4分52秒
[[email protected] tmp]# time ./pick 1073741824 ./r.dat >/dev/null
real 4m52.041s
user 3m55.498s
sys 0m6.049s
实验3:减少缓冲区为128byte时,花费5分48秒,与实验2相比,增大缓冲区还是有优势,
[[email protected] tmp]# time ./pick 1073741824 ./r.dat >/dev/null
real 5m48.831s
user 4m3.556s
sys 0m19.492s
实验4:以(1024*64)读取,通过“前插”法,对递增文件进行排序,时间接近实验1不排序的结果,而“后插法”对随机文件的排序也是接近实验1不排序的时间。
这三者的时间都接近30秒和实验二的4分52比较说明文件的有序情况影响着前叉和后插的效率,具体来说。 “前插法” 在对减序文件排序时有最差表现, “后插”对增序文件排序时有最差表现。另外在实验中发现的对于普通的随机数文件,“后插法”远比 “前插法“ 有效的的原因是因为,这里需要的是前100个最大的,只占1000万个的极小一部,而且有维护被插入的序列是减序前提,因而对于1000万中的0.9999的数据而言,采用 “后插法” 时,只比较了1次,就被判断不在前一百个中,而被跳过去了。
[[email protected] tmp]# time ./pick 1073741824 ./r.dat >/dev/null 1
real 0m33.782s
user 0m1.000s
sys 0m4.370s
为了避免这种由于文件有序程度带来的程序执行时间的波动,要么维护文件的有序性,要么修改算法(维护减序序列的前提不变):
维护一个中位数指针mid(或者数组索引,如果采用数组实现的双向链表的话),其初始化是在第一阶段后,即有100个数据被插入到链表中,使该指针指向第50(或49)个元素,
其后每个元素插入之前都与该指针所指向结构的data域相比较,若待插入元素大,则使用前插,并且mid=mid->prev,否则使用尾插,并且mid=mid->next,当然如果被插入的位置刚好是mid所指,则不用移动。{这里逻辑有待详细考虑}
这里上一段提到的用数组实现双向链表,其实类似如下代码:
typedef struct tagHead
{
int data;
unsigned short prev;
unsigned short next;
}HEAD;
HEAD head[100];