您的位置:首页 > 理论基础 > 数据结构算法

数据结构学习笔记之链表分析与实现(三)

2011-10-10 17:14 686 查看
在前面2篇文章,我们简单介绍了单链表的基本运算及其实现。同时,我们还介绍了回调函数。并将公共接口函数抽象出来,具体应用由用户提供回调函数来实现。此外,我们也注意到,采用上节提到的回调机制,当要释放链表所有元素的内存空间时,将带来些不方便或者无法直接使用。

有没有办法让这种回调机制进行更大一步的改善呢?当然有。本文参考了李先静前辈的《系统程序员成长计划》一书,并加入些自己的见解。与该书一致,本文也以双向链表的实现为载体进行深入探讨和分析。

本文引用的C语言代码采用了封装机制。为什么要封装?总体来说,封装主要有以下两大好处(具体影响后面再说):

隔离变化。程序的隐私通常是程序最容易变化的部分,比如内部数据结构,内部使用的函数和全局变量等等,把这些代码封装起来,它们的变化不会影响系统的其它部分。

降低复杂度。接口最小化是软件设计的基本原则之一,最小化接口容易被理解和使用。封装内部实现细节,只暴露最小的接口,会让系统变得简单明了,在一定程度上降低了系统的复杂度。

如何封装?

隐藏数据结构
暴露内部数据结构,会使头文件看起来杂乱无章,让调用者发蒙。其次是如果调用者图方便,直接访问这些数据结构的成员,会造成模块之间紧密耦合,给以后的修改带来困难。隐藏数据结构的方法很简单,如果是内部数据结构,外面完全不会引用,则直接放在C文件中就好了,千万不要放在头文件里。如果该数据结构在内外都要使用,则可以对外暴露结构的名字,而封装结构的实现细节,做法如下:

在头文件中声明该数据结构。

如:

struct _LrcPool;

typedef struct _LrcPool LrcPool;

在C文件中定义该数据结构。

struct _LrcPool
      {
          size_t unit_size;
          size_t n_prealloc_units;
      };

提供操作该数据结构的函数,哪怕只是存取数据结构的成员,也要包装成相应的函数。

如:

void* lrc_pool_alloc(LrcPool* thiz);

void lrc_pool_free(LrcPool* thiz, void* p);

提供创建和销毁函数。因为只是暴露了结构的名字,编译器不知道它的大小(所占内存空间),外部可以访问结构的指针(指针的大小的固定的),但不能直接声明结构的变量,所以有必要提供创建和销毁函数。

如:

这样是非法的:LrcPool lrc_pool;

应该对外提供创建和销毁函数。

LrcPool* lrc_pool_new(size_t unit_size, size_t n_prealloc_units);

void lrc_pool_destroy(LrcPool* thiz);

任何规则都有例外。有些数据结构纯粹是社交型的,为了提高性能和方便起见,常常不需要对它们进行封装,比如点(Point)和矩形(Rect)等。当然封装也不是坏事,MFC就对它们作了封装,是否需要封装要根据具体情况而定。

隐藏内部函数
内部函数通常实现一些特定的算法(如果具有通用性,应该放到一个公共函数库里),对调用者没有多大用处,但它的暴露会干扰调用者的思路,让系统看起来比实际的复杂。函数名也会污染全局名字空间,造成重名问题。它还会诱导调用者绕过正规接口走捷径,造成不必要的耦合。隐藏内部函数的做法很简单:

在头文件中,只放最小接口函数的声明。

在C文件上,所有内部函数都加上static关键字。

禁止全局变量
除了为使用单件模式(只允许一个实例存在)的情况外,任何时候都要禁止使用全局变量。这一点我反复的强调,但发现初学者还是屡禁不止,为了贪图方便而使用全局变量。请读者从现在开始就记住这一准则。

全局变量始终都会占用内存空间,共享库的全局变量是按页分配的,那怕只有一个字节的全局变量也占用一个page,所以这会造成不必要空间浪费。全局变量也会给程序并发造成困难,想把程序从单线程改为多线程将会遇到麻烦。重要的是,如果调用者直接访问这些全局变量,会造成调用者和实现者之间的耦合。

在整个系统程序员成长计划中,我们都是以面向对象的方式来设计和实现的(封装就是面向对象的主要特点之一。



下面,我们具体进行双向链表的实现进行分析.在头文件中,李先静老师是这么做的:

#include <stdlib.h>

#ifndef DLIST_H

#define DLIST_H


#ifdef __cplusplus

extern "C" {

#endif/*__cplusplus*/


typedef enum _DListRet

{

DLIST_RET_OK,

DLIST_RET_OOM,

DLIST_RET_STOP,

DLIST_RET_PARAMS,

DLIST_RET_FAIL

}DListRet;


struct _DList;

typedef struct _DList DList;


typedef void (*DListDataDestroyFunc)(void* ctx, void* data);



。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。

#ifdef __cplusplus

}

#endif/*__cplusplus*/


#endif/*DLIST*/

可见,在头文件中定义了双向链表的基本操作。包括链表结点的创建、链表结点的插入、删除、查找以及链表的建立和销毁。

我们先看下双向链表的结构:

typedef struct _DListNode

{

struct _DListNode* prev;

struct _DListNode* next;

void* data;

}DListNode;



struct _DList

{

DListNode* first;

DListDataDestroyFunc data_destroy;

void* data_destroy_ctx;

};

1. 双向链表中结点的插入

设P指向双向链表中某结点, s指向待插入的值为X的新结点,将*s插入到*p的前面:



步骤如下:

(1)s->prev = p->prev

(2)p->prev->next =s

(3)s->next = p

(4)p->prev = s

指针操作的顺序不是唯一的。但是第一步必须放到第四部前完成,否者p的前驱结点的指针就丢掉了。

下面我们按照上述原理分析李先静前辈的代码:

DListRet dlist_insert(DList* thiz, size_t index, void* data)

{

DListNode* NodeToInsert = NULL; //定义待插入结点

DListNode* NodeToInsertBefore= NULL; //在该指定结点之前插入,也就是待插入结点的后继结点


if( ( NodeToInsert= dlist_create_node(thiz, data ) ) == NULL ) //根据传入数据,创建待插入结点

{

return DLIST_RET_OOM; //创建待插入结点失败!

}


if(thiz->first == NULL)

{

thiz->first = NodeToInsert; //如果第一个结点为空,把该结点插入到第一个结点


return DLIST_RET_OK;

}


NodeToInsertBefore= dlist_get_node(thiz, index, 1); //查找要插入的结点的指针位置,也就是说在该结点指针之前插入

if(index < dlist_length(thiz)) //如果索引序号值小于链表长度,则是在链表中插入

{

if(thiz->first == NodeToInsertBefore) //如果第一个结点就是插入位置

{

thiz->first = NodeToInsert; //则把第一个结点替换为待插入结点


}

else

{

NodeToInsertBefore->prev->next = NodeToInsert; //前面讲述规则的第二步,使得指定结点的前驱的后继指向待插入结点

NodeToInsert->prev = NodeToInsertBefore->prev; //前面讲述规则的第一步,使得待插入结点的前驱指向指定结点的前驱

}

NodeToInsert->next = NodeToInsertBefore; //前面讲述规则的第三步,使得待插入结点的后继指向指定结点


NodeToInsertBefore->prev = NodeToInsert; //前面讲述规则的第四步,使得指定结点的前驱指向待插入结点

}

else //如果索引序号值大于或等于链表长度,说明是在链表末尾插入

{

NodeToInsertBefore->next = NodeToInsert;

NodeToInsert->prev = cursor;

}


return DLIST_RET_OK;

}




1. 双向链表中结点的删除

设P指向双向链表中某结点, 将P从链表从删除:



步骤如下:

(1)p->prev->next = p->next, 修改前驱结点的后继结点

(2)p->next->prev =p->prev,修改后继结点的前驱结点

(3) free(P) 释放内存空间

下面我们按照上述原理分析李先静前辈的代码:



DListRet dlist_delete(DList* thiz, size_t index)

{

DListNode* NodeToDel= dlist_get_node(thiz, index, 0);
//查找待删除结点


if( NodeToDel!= NULL )

{

if( NodeToDel == thiz->first ) //如果待删除结点是第一个结点

{

thiz->first = NodeToDel->next; //那么直接使得第一个结点指针指向待删除结点

}


if( NodeToDel->next != NULL ) //如果待删除结点有后继结点

{

NodeToDel->next->prev = NodeToDel->prev; //前面所述规则的第二步,修改后继结点的前驱结点

}


if( NodeToDel->prev != NULL ) //如果待删除结点有前驱结点

{

cursor->prev->next = cursor->next; //前面所述规则的第一步,修改前驱结点的后继结点

}


dlist_destroy_node(thiz, cursor); //释放内存空间, 前面所述规则的第三步

}


return DLIST_RET_OK;

}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: