在上一篇文章中,我已经分析了单链表的结构及其建立。
接下来,我将进一步分析单链表中的其他操作。
首先,我们来看看如何实现查找现有链表中的某个元素或结点。很显然,我们可以遍历整个链表,并将遍历到的结点与要查找的结点进行比较,如果结果相同,表示查找成功。代码示例如下:
NameVal *FindSpecificItem( NameVal *SingleListPtr, char *name )
{
while( NULL != SingleListPtr )
{
if( 0 == strcmp( name, SingleListPtr->name ) )
{
return SingleListPtr; //表示查找成功
}
SingleListPtr = SingleListPtr->next;//查找不成功,则继续遍历查找
}
return NULL; //查找失败
}
算法时间复杂度为O(n). 有没有办法改进呢? 答案是没有。即使链表已经排好序, 我们依然需要遍历才能查找到某个元素。在上篇文章,我们说过:二分查找对链表无效!
接下来,我们再分析下链表的其他操作。如:打印链表中的所有元素; 计算链表的长度等等。简单的办法是,我们可以逐一写函数实现之。经过分析,我们发现,这些操作都需要对链表进行遍历才能实现。有没有一种通用的办法来实现呢? 答案是:YES!如何实现呢?
我们已经找到了共同点:遍历。遍历过程中具体要做什么,是千变万化的。可能是要查找某个元素,也可能是要打印元素,等等。我们可以提供一个通用的遍历操作,在操作中具体要做什么,由需求来决定。自然而然,我们想到了callback函数。
什么是callback函数?遍历函数是用户(上层调用者)调用的,可实现为一个API接口。 在遍历过程中, 遍历函数需要回头调用具体用户(上层调用者)提供的函数来实现特定的需求或功能。这种回头调用调用者所提供的函数的方法,我们称之为回调函数或callback机制。
实现回调函数,可分四步走:
(1)定义要回调的函数指针类型
(2)声明公用接口函数
(3)实现上步中的接口函数
(4)用户调用该callback函数
有点难以理解,是不是?所谓实践出真知,下面我们通过具体的例子来深入理解上述四部曲的意义。
第一步,定义要回调的函数指针类型: void ( *UserCallbackFunc ) ( void * , NameVal * );
第二步, 声明公用接口函数:void CommonUtility( NameVal *SingleListPtr, UserCallbackFunc RequireFunc, void *ctx )//ctx 为回调函数的参数
第三步,实现公用接口函数。代码如下:
void CommonUtility( NameVal *SingleListPtr, UserCallbackFunc RequireFunc, void *ctx )
{
while( NULL != SingleListPtr )
{
RequireFunc( ctx, SingleListPtr );
SingleListPtr = SingleListPtr->next; //则继续遍历查找
}
}
第四步,用户实现自己的需求函数并调用该callback函数。
例如,我们要实现打印链表中的所有元素,我们可以实现一个自己的需求回调函数:
void PrintAllElements( void *ctx, NameVal *SingleListPtr)
{
char *format;
format = (char * ctx;
printf( format, SingleListPtr->name, SingleListPtr->value );
}
之后,我们就可以在公用接口中回头调用该callback 函数。如下所示:
CommonUtility( SingleListPtr, PrintAllElements,“%s: %x.\n”);
用类似的办法,我们可以实现计算链表长度的callback函数。
void CountLength( void *ctx, NameVal *SingleListPtr)
{
int *ListLength;
ListLength = (int * )ctx;
(*ListLength)++;
}
调用方法:
int LengthOfList;
LengthOfList = 0;
CommonUtility( SingleListPtr, CountLength, &LengthOfList);
需要注意的是,这种采用一个公用接口函数的方法,虽然巧妙,但并不适用于所有情况。例如,如果需要销毁一个链表,如果继续采用以下方式代码就会出问题:
void CommonUtility( SingleListPtr, FreeMemory, ctx )
{
while( NULL != SingleListPtr )
{
FreeMemory( ctx, SingleListPtr );//假设这里把该结点释放了,并且它的后继结点在链表中存在
SingleListPtr = SingleListPtr->next;//由于它的上一个结点内存已经释放了,就无法再指向下一个结点了,Memory can not be used after it has been freed.
}
}
由此可见,这个公用接口函数已经失效了,或者说是有缺陷的。要修正这个缺陷,我们必须在释放结点内存前,用一个临时变量Buffer保存该结点的后继结点。然后在free内存之后再进行赋值.伪代码示例如下:
void CommonUtilityCorrect( SingleListPtr, FreeMemory, ctx )
{
NameVal *Buffer;
while( NULL != SingleListPtr )
{
Buffer = SingleListPtr->next;
FreeMemory( ctx, SingleListPtr );//假设这里把该结点释放了,并且它的后继结点在链表中存在
SingleListPtr = Buffer;//由于它的上一个结点内存已经释放了,就无法再指向下一个结点了,Memory can not be used after it has been freed.
}
}
至此为止,我们已经分析了链表的建立(头插法、尾插法)、查找链表中某个元素、计算链表长度、销毁链表等操作;接下来,我们接着简单的介绍下链表中某个元素的插入、删除操作。
首先,我们分析下删除操作。要删除链表中某个指定的结点,需要以下几个步骤:
(1)查找到待删除结点的前驱结点(prev)。伪代码示意如下:
prev = head;//prev初值设置为头指针
while( prev->next != SpecificNotePtr ) prev = prev->next; //查找*SpecificNotePtr 的前驱结点
(2)让前驱结点的后继指向待删除结点(SpecificNotePtr)的后继。这样,待删除结点就从链表中删除了。最后释放删除结点所占内存。
prev->next = SpecificNotePtr->next;
free(SpecificNotePtr);
有时候,我们还需要删除指定结点的后继结点。同样可分为2步走:
(1)查找到后继结点(Successor)。实际上不需要查找,根据链表的特性,只需指向下一个就OK了:
Successor = SpecificNotePtr ->next
(2)让指定结点(SpecificNotePtr)的后继指向后继结点的后继。这样,待删除结点就从链表中删除了。最后释放删除结点所占内存。
SpecificNotePtr->next = Successor ->next;
free( Successor );
我们看一个实际使用的例子,根据某个指定的元素名称,删除该指定结点。完全依照上述规则,代码如下:
NameVal *DelSpecificItem( NameVal *SingleListPtr, char *name )
{
Nameval *prev,*SpecificNotePtr ;
prev = SingleListPtr;//初始值为链表头结点指针
SpecificNotePtr = FindSpecificItem( &SingleListPtr, &name );
while( 0 != strcmp( name, prev ->next->name ) )
{
prev = prev ->next; //寻找“name" 对应结点的前驱结点
}
prev ->next = SpecificNotePtr ->next;
free(SpecificNotePtr );//释放指定结点的空间
}
上述代码需要2个循环才能实现。可不可以一个循环就实现呢?可以的。只需要在FindSpecificItem地循环中保存下前驱结点指针就可实现。:
NameVal *DelSpecificItem( NameVal *SingleListPtr, char *name )
{
Nameval *pBuf, *prev;
pBuf = SingleListPtr;//初始值为链表头结点指针
prev = NULL;
if( NULL == pBuf )
{
printf("Error.Null List.\n");
return;
}
while( NULL != pBuf) )
{
if( 0 == strcmp(name, pBuf->name ) )
{
break;//找到结点,跳出循环
}
prev = pBuf; //寻找“name" 对应结点的前驱结点
pBuf=pBuf->next;
}
if ( prev != NULL )
prev ->next = pBuf ->next;
else
pBuf = SingleListPtr;
free( pBuf );//释放指定结点的空间
}
我们再来分析下插入操作。在分析插入运算前,我们回顾下查找操作。前面我们分析的查找都是按值查找,实际上也可以进行按序号查找。各自的伪代码简单示例重写如下:
ListNode *FindNodeByIndex(ListNode *head, int Index)
{
ListNode *p = head;//从头指针开始查找
int j = 0;
while( p ->next !=NULL && j <index)
{
p=p->next;
j++;
}
if( j == index ) return p;
else return NULL;
}
//按值查找,读者可与前面的实现作一番对比,差别较小
ListNode *FindNodeByValue(ListNode *head, ElemType x)
{
ListNode *p = head->next;//从头指针开始查找
while( p != NULL && p->data != x)
{
p=p->next;
}
return p;
}
我们回到插入运算这个话题。插入运算可以分为后插和前插。后插,在指定结点和指定结点的后继结点之间插入;前插, 则是在指定结点和指定结点的前驱结点之间插入。插入操作使得链表长度增加了。后插可以按2步走:
(1)待插入的结点的后继指向已经知道的指定结点的后继。
(2)指定结点的后继修改为待插入结点。
前插操作可用后插实现。即先实行后插,然后交换插入结点的数据与指定结点的数据。
前插操作也可用以下步骤实现:
(1)先找到指定结点的前驱结点。
(2)然后再前驱结点和指定结点之间进行插入。
例如,我们按index来进行插入,可用如下示例伪代码实现:(前插)
ReturnType InsertNodeAfterIndex(ListNode *head, int Index, ElemType UserData)
{
ListNode *pSpecificNode, *NodeToInsert;//定义前驱结点和待插入结点
int j = 0;
pSpecificNode = FindNodeByIndex(head, Index); //查找第index-1个结点,即前驱结点
if ( NULL == pSpecificNode )
{
return ERROR;
}
else
{
NodeToInsert = ( ListNode * )malloc( sizeof(ListNode ) );
NodeToInsert ->data = UserData;
NodeToInsert->next = pSpecificNode->next;
pSpecificNode ->next = NodeToInsert;
return OK;
}
}
至此,单链表的基本运算介绍完毕。