(转)数据记录的快速查找方法

      在日常的算法中,查找是一个经常涉及到的话题,而如何提高查找的速度,也是很多程序员、软件研究的话题。

      1、问题的提出:

       有这样一个数据类型 S :

       学生姓名(name),性别(sex),年龄(age)。。。,

       现在假设有这样一个需求;

       文件A、B中分别存放大量 S 的记录,需要将A、B中重复的记录去掉。

       我们用c代码来演示今天的话题:

typedef struct tagSTUDENT

{

       char *name ;

       bool bSex ;

       int age ;

       …

} S ;

      

       对于这样一个问题,简单的做法应该是如下:

    A 中读取记录,保存到一个链表(指针链表,数组链表都可以)ListA中,B中读取记录,保存到ListB中,然后用两重循环来实现查找:

int i ,j;

int nCountA = ListA.GetCount() ;

int nCountB = ListB.GetCount() ;

       for (i=0; i < nCountA ;i++)

       {    

              S *pA = (S*)ListA.GetAt(i) ;

              for (j=0; j< nCountB ;j++)

              {

                     S *pB = (S*)ListB.GetAt(j) ;

//比较,匹配

                     if (strcmp(pA->name, pB->name) == 0)

                     {

                            ListA.DeleteAt(i) ;

                            ListB.DeleteAt(j) ;

                            i -- ;

                            nCountB -- ;

                            nCountA-- ;

                            break ;

}

}

}

假设比较匹配使用的是strcmp类似的算法,时间复杂度为O(m),

这样的算法,时间复杂度为 O(n1*n2*m),空间复杂度为 O(n1)+O(n2)。

n1为ListA大小,n2为ListB大小,m为姓名长度。

下面,我们来对这个算法优化。

首先我们在这里讨论,使用指针链表好还是数组链表好。

指针链表的优点在于插入,删除快,但定位慢。

数组链表的优点在于定位块,插入,删除,很慢。

第一感觉应该是指针链表会比较好,确实,我们在匹配过程中可以找到一个重复数据就删除,这样确实会比较方便。

其实,在这个地方,使用数组链表优势会更大。

我们分析一下指针链表和数组链表插入的原理。

链表指针每次插入的时候,直接在尾指针处插入一个新数据,时间复杂度为O(1),不讨论其他细微的开销。

数组插入缓慢的原因,是因为,如果插入的数据不是最后一个,就需要把插入点之后的数据全部向后移动一个位置;删除也是一样的,时间复杂度为O(n)。但是,如果插入的数据是插入到末尾的话,那么这种弊端就不存在。相反,插入,删除,还会变的很快。这就如同链表的尾指针一样,可以达到O(1)时间内完成。

分析了数组的插入原理,我们再对上面查找的过程做修改。在每次查找到重复数据的时候,不要立刻删除,而先做个标记。当查找结束后,再将没有标记的数据复制到另外一个数组中,这样就可以避开数组的缺点,而发挥数组的长处。C代码演示如下

typedef struct tagSTUDENT

{

       char *name ;

       bool bSex ;

       int age ;

       bool bRepeat;// 增加一个标记位

       …

} S ;

int i ,j;

int nCountA = ListA.GetCount() ;

int nCountB = ListB.GetCount() ;

       for (i=0; i < nCountA ;i++)

       {    

              S *pA = (S*)ListA.GetAt(i) ;

              if (!pA-> bRepeat)

{

                     for (j=0; j< nCountB ;j++)

                     {

                            S *pB = (S*)ListB.GetAt(j) ;

                            if (!pB-> bRepeat)

{

//比较,匹配

                                   if (strcmp(pA->name, pB->name) == 0)

                                   {

                                          pA->bRepeat = true ;

                                          pB->bRepeat = true ;

                                          break ;

                                   }

}

}

}

}

有人可能就会说了,使用链表一样也可以做标记,不删除啊,为什么不用链表呢?是的,可以使用链表,但是链表的操作是对指针的操作,定位操作,最快也需要移动一次指针(p = p->next),而对于数组,只需要 i++,这两个操作的速度还是有差别的。(当然,差别不大,但程序员不是经常为一个指令周期而疯狂吗?)

到这里,我们仅是对数据结构的设计,优化的重点就是暂时不删除重复数据,而做标记,等处理完毕后,再统一做处理。这个算法会增加空间复杂度,由O(n)变为O(2*n)。

那么还有没有办法对这样的算法进行更进一步的优化呢?

有的,这也就是我今天要描述的重点。

可以想象,在上面的算法中,有很多重复的步骤。例如,每次ListA中取一个数据,要去B中查找,都需要遍历B中一次,这就导致B中的操作太过频繁。最终算法缓慢。那么现在我们就要优化在B中遍历这一步。

我们这里是以学生姓名做为匹配关键字,为简单起见,假设数据A中的所有学生姓名不重复,数据B也满足这个条件,后面我们再考虑复杂一点的情况。

对于每个学生姓名,既然唯一,那么就可以使用一个数值来代表,例如,1代表“张三”,2代表“李四”,那么如果A中有“张三”,要在B中查找有无“张三”,就只需要看B中有没有1。有人会说,这样做有什么意义?意义有两点:

1)  使用数值(DWORD)查找,会比字符串查找速度快。因为字符串查找时间复杂度是O(n),而数值匹配,时间复杂度为O(1)

2)  理解这个思路,对于后面的优化方法才能更加容易接受。

那么如何将“张三”,“李四”对应到数值1,2呢?

使用CRC(循环冗余校验)来实现这种对应关系。假设“张三”的Hex值为“d5 c5 c8 fd”,那么就可以定义数值DWORD dwIndex = d5 + c5 + c8 + fd,这就是CRC值。当然了,这个值如何计算,完全可以根据需要来,比如你觉得加法算出的CRC值重复性大,那么可以使用乘法,移位等算法,来使数值比较分散,降低重复的几率。但这样还是没有办法避免重复,所以,每次找到匹配数值后,还应该继续检查学生姓名是否匹配。算法描述就是:

      

typedef struct tagSTUDENT

{

       unsigned long ulIndex ;// 在数据A、B中读取的时候填充

       char *name ;

       bool bSex ;

       int age ;

       bool bRepeat;// 增加一个标记位

       …

} S ;

int i ,j;

int nCountA = ListA.GetCount() ;

int nCountB = ListB.GetCount() ;

       for (i=0; i < nCountA ;i++)

       {    

              S *pA = (S*)ListA.GetAt(i) ;

              if (!pA-> bRepeat)

{

                     for (j=0; j< nCountB ;j++)

                     {

                            S *pB = (S*)ListB.GetAt(j) ;

                            if (!pB-> bRepeat)

{

//比较,匹配

                                   if (pA->ulIndex == pB->ulIndex)

                                   {

                                          pA->bRepeat = true ;

                                          pB->bRepeat = true ;

                                          break ;

                                   }

}

}

}

}

这样一来,算法就可以优化到O(n1*n2)了。

到这一步,估计不少人已经看出端倪了。不错,接下来,我们就要使用散列表(HashTable)来优化数据存储。

我们在上面说过,读取数据的时候保存到链表或者数组中,而现在,我们要保存到散列表中,而散列表的索引(index)就是我们上面说过的,根据学生姓名算出来的数值。散列表的大小可以根据情况定义。散列表的数据结构可以设计成散列链表,散列链表的优势在于:

1)  支持的数据可以无线扩大(链表可以存储无线大的)。

2)  对于碰撞的算法简单快速。

typedef struct tagSTUDENT

{

       unsigned long ulIndex ;// 在数据A、B中读取的时候填充

       char *name ;

       bool bSex ;

       int age ;

       bool bRepeat;// 增加一个标记位

       …

} S ;

iterator itA = HashA.begin() ;

for (; itA != itA.end(); ++itA)

{

S *pA = (S *)itA ;

S *pB = HashB.find(pA->ulIndex) ;

if (pB)

{

                     pA->bRepeat = true ;

                     pB->bRepeat = true ;

}

}

我们来看看现在的时间复杂度。假设散列表大小为M,每次匹配的算法复杂度为

O( n/M),完成整个操作的算法复杂度为 O(n1*n2/M)。

回头再看一下我们遗留的一个问题,如果学生姓名有重复,如何解决?

我们可以定义散列链表,如果有重复的数据,就放到链表中,所以匹配的时候,需要

在找到index数值后,然后再进一步使用字符串匹配找到符合的学生记录。

        但是,需要知道的是,如果要支持模糊匹配,这种算法就没有办法进行优化。

但是,如果数据本身可以提取特征值,那么模糊匹配也可以支持。具体情况要根据实际的情况去看了。

        针对链表,散列表的数据结构,可以自己设计,也可以使用STL,但我觉得,自己设计的数据结构,使用起来更加方便。速度慢?呵呵,STL的也没有使用特殊的算法,也没有使用汇编,自己设计的为什么就会慢呢?如果慢,那只能说明你设计的数据结构有问题了,呵呵。

补充知识:

1、二分查找算法与二二叉树查找算法的比较

首先说明什么是二叉查找树:

二叉查找树:
     特点:
     1、如果它的左子树不空,那么左子树上的所有结点值均小于它的根结点值;
     2、如果它的右子树不空,那么右子树上的所有结点值均大于它的根结点值;
     3、它的左右子树也分别为二叉查找树。

二分查找算法的时间复杂度是O(logn),而二叉查找算法的最坏时间复杂度和顺序结构相同为O(n),最好情况与二分查找算法的时间复杂度相同,为O(logn)。

这是由二叉树的结构所决定的,如果二叉查找树是一棵平衡二叉树,查找的算法时间复杂度就较低,如果二叉查找树的结构已经非常不均匀,接近于线性结构,那么这种算法的效率就难以保证。

2、hash表查找算法的时间复杂度

如果hash表中存储了N个元素,而hash表的地址范围是M,那么其查找的时间复杂度是O(N/M)。

举一个应用hash表的例子。

原文地址:https://www.cnblogs.com/wll-zju/p/4326829.html