`
totoxian
  • 浏览: 1028024 次
  • 性别: Icon_minigender_2
  • 来自: 西安
文章分类
社区版块
存档分类
最新评论

链表的设计--单链表逆序开始

 
阅读更多

这个问题仅仅可以考察人们对c语言特别是指针的熟悉程度,在实际编程中没有任何的意义,单链表逆序无论如何都要花费大量的时间,如果非要这么做为何不用空间来换时间,比如用双链表,然而如果你使用了双链表的话,逆序就更是一个没有意义的操作了,不管怎么说,单链表逆序这件事还是要做的,方法有二,一个是递推,另一个是递归,递归法可以省去一个中间变量,而递推法则必须使用三个变量:一个表头,一个临时节点指针,一个临时节点的下一个节点的指针,为何需要这些额外的变量呢?因为一旦将一个节点从链表摘除,那么它就会彻底孤立,再也与原来的链表节点联系不起来,因此需要在将一个节点摘除之前保存它的下一个节点,因为交换后序指针需要两个节点参与,因此还需要保存当前节点和当前节点的前一个节点,因此最终需要三个额外的变量,有了这三个变量,程序算法就简单了:

数据结构定义如下:

struct PS_list_head{

PS_list_head * next;

};

初始化如下:

PS_list_head * list_curr = head; //初始化为头节点

PS_list_head * list_prev = NULL; //初始化时头结点没有前序指针

PS_list_head * list_next = head->next; //后序指针为头结点的next字段

接着每一步做以下工作:

list_curr->next = list_prev?list_prev:NULL;

list_prev = list_curr;

list_curr = list_next;

list_next = list_curr->Next;

一直到最终。

现在看一下递归的方式,递归可以节省一个额外的变量,其实上面的操作最关键的一个步骤就是list_curr->next = list_prev?list_prev:NULL其余的操作都是为这个操作做准备的,那么既然通过定义额外变量加上递推的方式能帮助程序完美实现这关键的一步,换一种方式十有八九也一定可以,于是尝试一下递归,之所以在递推中保存当前节点的下一个节点指针是因为一旦当前节点脱离链表就再也找不到了,毕竟后面的还没有被处理,而递归算法可以节省一个额外的变量,因为递归保证后面的节点都已经被处理过了:

struct PS_list_head * list_reverse( struct PS_list_head* head )

{

struct PS_list_head * list_next=head->next;

if( likely(list_next) ) //likely优化,参见linux内核源码

{

struct PS_list_head * list_tail=reverse(list_next); //保证后面的都被处理

list_next->next=head; //关键的一步

return list_tail;

}

else

return head;

}

递归有递归的缺点,一旦深度过大就会导致栈溢出,空间开销过大,仅仅为了节省一个变量不值得。以上就是单链表的逆序的全部内容,关于单链表还有很多问题,比如判断有无环路,比如一次遍历找到倒数特定索引活着特定总长度比例处的元素,其实这两个问题是一个问题,既然限制了遍历的次数,那么只好在这仅有的一次遍历中多动用一些资源,典型的就是指针,对于特定索引典型的解法就是定义两个指针,一个先开始遍历,等到达这个倒数特定索引的数值步长的时候第二个指针开始遍历,然后当第一个指针完毕之后第二个指针就到了,单链表的特点就是在一次单指针遍历中,中间状态很难得到保存,过了就丢了,全部保存呢开销过于大,因此必须动用第二个指针,求特定索引的节点其实就是等差问题,只需要一个差值,而另一个问题,求总长度特定比例节点的问题就不是等差问题,而是等速度比问题,比如让一个指针的速度是另一个的两倍,那么当一个指针到尾部时,另一个正好到中间,这其实和小学时学的相遇问题和相离问题十分类似要么就是施工队问题,看来编程的问题都不难,很多都是小学知识。

以上就是这个最无趣的问题,linux内核当然不会这么无趣了,所以我在内核中没有找到单链表的上述操作,list_head用的是最多的了,虽然是双链表,多了一个指针,但是来了很多方便,可以线速的在任何一个节点的前或者后添加或者删除一个节点,链表的两个方向是对称的,总体来看,list_head根本就没有方向,也没有表头和表尾。为了适应通用类型,内核中没有使用STL的泛型之类的机制,而是使用了OO的思想,将数据和操作分离,链表结构中不再有数据,也就是list_head中只是代表了链表本身,没有数据,也没有任何泛型,泛型体现了同一个层次上的通用数据结构的算法,可以泛型的类型必须属于同一个层次,并且泛型主要方便了通用算法的使用上,泛型的算法是相同的,只是数据不同,而继承却不是这样,或者说恰恰相反,继承指的是参与计算的数据操作相同,但是操作的算法不同,继承更加适合于扩展新操作算法而不是重用已有的操作算法。我们来看一下list_head的设计,首先为了统一的使用双向链表的操作,内核抽象出了list_head数据结构,它剥离了数据,仅仅留下来了一个数据结构,既然没有了数据那么就不存在不同的数据类型,因此泛型已经被pass掉了,剩余的东西仔细一看,其实就是继承,list_head就是一个基类,当然你也可以理解成是包含,总之list_head是抽象的结果,体现了OO的思想。

紧接着问题又来了,考虑哈希链表,一个哈希桶由于碰撞难免要携带一个链表,该链表完全可以用list_head表示,但是不同于list_head的其它使用场合,哈希表总是不希望特别长的,因为好的哈希算法总是试图扩展宽度而不是增加每一条冲突哈希链表的长度,因此哈希算法越好,各个链表的长度越均匀,实际上的结果就是没有特别长的链表,因此list_head的一些优势,比如快速遍历,随机插入,删除等就不再那么明显了,毕竟链表不会很长的,但是却很多。很多的话就要考虑是否占用了太多的空间以及在此基础上能否优化空间开销了,因此就设计出了hlist_head数据结构同时更改了list_head数据结构使之成为一个新的数据结构hlist_node:

/*

* Double linked lists with a single pointer list head.

* Mostly useful for hash tables where the two pointer list head is

* too wasteful.

* You lose the ability to access the tail in O(1).

*/

struct hlist_head {

struct hlist_node *first;

};

struct hlist_node {

struct hlist_node *next, **pprev;

};

hlist_head是哈希链表的头,注意hlist和list_head不同,后者没有头和尾,但是hlist的头并不和尾相连接,直接的后果就是你无法通过链表头直接得到链表尾,看一下以上的hlist_node结构,它本质上还是一个双向链表结构,但是pprev却不再是hlist_node类型的指针,而是其指针的指针,这又当何解呢?hlist的设计过程是很有趣的,首先要说明的是仅仅是查找的话,hlist设计成单链表就可以,但是由于涉及到随机的删除和插入(删除是真的随机的,但是插入一般都是往前面或者往后面插入的),如果是单链表的话,没有当前节点上一个节点的信息就会很麻烦,对于删除而言,由于要修改上一个节点的next字段,因此必须遍历单链表才能找到上一个元素,这是很不和谐的,于是最终还是要用双向链表;接下来的问题就是对于哈希冲突链表来讲,在哈希桶很大的时候,这种链表会很多而不像list_head那样数量确定,比如有一条task_struct链表,一条文件系统的链表等等,哈希冲突链表的数量越多对内存的消耗就越大吗?不,不是这样的,哈希算法好的话,链表会很多,但是每一条都会很短,总的算下来开销还是很哈希算法不好的那种链表很少但很长的情况一样,但是问题是进入内核的哈希算法一般都不赖,最起码都在朝着好的方向发展,因此哈希冲突链表在朝着短而多的方向发展,因此减少一条链表的开销显得有意义而重要,对,钻的就是这个空子,内核开发就是这样,有空子就钻,只要能优化。链表头的优化是个好主意,可以将链表头的prev指针优化掉,如此一来类似于list_head那样的循环链表岂不是断掉了吗?是的,断掉了,但是首尾相连有什么好处呢?既然没有好处那么断掉可以节约一个指针又何尝不可呢?作为一个例子,我们看一下内核中对tcp-bind的处理数据结构:

struct tcp_bind_bucket {

unsigned short port;

signed short fastreuse;

struct hlist_node node; //该结构是另外一个更上层结构的节点

struct hlist_head owners; //该结构保有一个hash链表,该链表的内容是sock

};

struct tcp_bind_hashbucket {

spinlock_t lock;

struct hlist_head chain; //tcp_bind_bucket的node字段连入的链表

};

struct tcp_bind_bucket *tcp_bucket_create(struct tcp_bind_hashbucket *head, unsigned short snum)

{

struct tcp_bind_bucket *tb = kmem_cache_alloc(tcp_bucket_cachep, SLAB_ATOMIC);

if (tb) {

tb->port = snum;

tb->fastreuse = 0;

INIT_HLIST_HEAD(&tb->owners);

hlist_add_head(&tb->node, &head->chain);

}

return tb;

}

void tcp_bind_hash(struct sock *sk, struct tcp_bind_bucket *tb, unsigned short snum)

{

inet_sk(sk)->num = snum;

sk_add_bind_node(sk, &tb->owners); //将sk的hlist_node加入到tb的owners

tcp_sk(sk)->bind_hash = tb;

}

hlist的操作几乎和list_head一样,如果哪个结构需要挂接在一个哈希链表中,那么就加入一个hlist_node的结构体指针,一切很简单,实际上在内核中很少有地方用到hlist_add_before/after的,并且可以看出,真的就是没有用到首尾相接这一个特性,现在可以看到优化方案的重要了,每个链表头去掉prev指针,后面的不去掉,还保持双向链接的特性以保证删除和随机插入的高效性,但是等等,为何要将prev指针换成pprev,原因很简单,prev不能用了,为何不能用了呢?其实不是不能用,而是不好用,现在已经明白hlist链表不是循环链表了,那么头指针的prev就是NULL了,那么在删除和插入时必须判断当前的节点是不是头结点,如果是要做一番处理,比如将其prev设置成NULL等等,这将导致大量的if判断,还要传入保存的hlist_head的信息,否则就没有办法找到hlist_head从而更新它的first字段,但是并不是每次都删除表头,删除表头的占少数,为了这少数而多传入一个参数值得吗?而在编程中判断总不是什么好事,特别是在基于长流水线的分支预测的机器中更是这样的。最好是能通过一个统一的方式来进行操作,就像list_head那样,省略掉不必要的分支判断。于是pprev就被设计出来了,再次看一下hlist_node数据结构,第一个字段同样也是一个hlist_node的指针,看的好像AT&T的内存管理算法中的mem的设计,其实原理是一样的,如果是prev的话,那么prev指向的是前一个节点所在的地址,而如果是pprev的话,懂指针的人就会认为是前一个节点的地址的地址,其实不然,正确的解释应该是前一个节点的next的地址,巧就巧在hlist_node的设计,第一个字段是一个同类型的指针。真的是这样吗?真的是这样,不过前面的说法也没有错啊,难道它不是地址的地址吗?实际上前面的说法也是正确的,两种方式的解释正是表达了两种行为,这正包容了链表头的特殊性,之所以设计pprev就是为了统一的表示不同的行为,而恰恰pprev可以有上述两种解释,我们先看一下普通位置的插入:

static __inline__ void hlist_add_before(struct hlist_node *n, struct hlist_node *next)

{

n->pprev = next->pprev;

n->next = next; //n->next本身就是一个指针,后面取&就是指针的指针

next->pprev = &n->next; //此场景中,pprev的解释就是前一个节点的next字段的地址

*(n->pprev) = n; //直接修改指针指向的值,不必像list_head那样得到原先next的prev字段指向的值

}

再看看在链表头插入:

static __inline__ void hlist_add_head(struct hlist_node *n, struct hlist_head *h)

{

struct hlist_node *first = h->first;

n->next = first;

if (first)

first->pprev = &n->next;

h->first = n;

n->pprev = &h->first; //这里解释为地址的地址,因为h->first本身就是一个指针代表地址,再加上&就是地址的地址

}

删除节点的函数很简洁:

static __inline__ void __hlist_del(struct hlist_node *n)

{

struct hlist_node *next = n->next;

struct hlist_node **pprev = n->pprev;

*pprev = next;

if (next)

next->pprev = pprev;

}

如果是list_head的非循环版本的话,就是下面的实现了:

static __inline__ void __hlist_del_v2(struct list_head *n)

{

if(n->prev ==NULL) //删除头结点

{

n->next->prev = NULL;

//接下来我要如何找到hlist_head并且更改它的first字段呢?

}

...

}

看看hlist的设计总流程吧,首先意识到大哈希表时链表会很多,每个哈希桶中放一条list_head看一下效果,然后果断的将表头的prev去掉,并且为了和后面的正式节点区分,将表头更名为hlist_head,后面的删除遇到了麻烦,于是将prev改为了pprev,利用了其双重含义,最终hlist被设计完成。有人会问,哈希链表的第一个元素是hlist_head本身还是其first字段指示的呢?看一下list_head就知道了:

static inline int list_empty(const struct list_head *head)

{

return head->next == head;

}

如果只有一个list_head的话,那么就是空表,因此在list_head中,起初的这个list_head就相当于hlist中的hlist_head,所以仅有一个head它只是一个桩子,第一个元素是它的first指向的节点元素。

链表其实是很复杂的数据结构,设计得好的链表效率会非常高,毕竟它的操作很单一,如果有很多数据就要想想怎样将这些数据组织到链表,是一个表还是多个表,另外就是长链表的处理问题,很多时候会将长链表分解为多个短链表,但是会带来管理开销,不如直接管理一个长链表,效率的高低就看管理算法了,最简单的就是每次记住结束处理的索引,下次从这里开始而不是从头开始,比如一个系统的lru链表很长,一次内存回收操作仅仅遍历到其长度的三分之一处,那么下次遍历如果从头开始的话就会引起抖动或者类似饥饿的现象所以下次遍历就从三分之一后面开始。还有一种算法就是每次取链表长度内的随机数作为开始处理的节点,这样在统计意义上会比较公平。最复杂的就是自适应的管理算法了,我还没有做出来,这里就不说了。

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics