[简易] 链表相关
前言
数数开发者的军备,算下来说多也行、说不多同样没问题。习于搭砖块不是坏事,但流于搭砖块则未必是好事——当我们见到不明原理而产生的偏见(“这里不加锁可以吗,我不敢保证啊”、“你写成这样,易读性是有问题的”)影响开发的时候,恐怕难免不甘。改变现状不容易,更多的时候也许我们会顺着形势反思自己是不是做得有点过了头,不过真的是这样吗?在说滥的基本功面前,滥俗的或许是我们自己?
在这系列科普里,我会试图展示我所见的基础问题。它们不太现代,也许未必适合急着考试求职的伙计;所以读下去之前不妨想想:如果读者真的不喜欢在基本而常见的问题上纠缠,或者认为自己没必要为既定现存的人类智慧添一笔拙劣涂鸦,关掉页面可能是更好的选择。
文中的代码会使用GCC的C扩展,关注移植性的读者请小心。
Side A
假定我们面前有这样一个从天而降的问题:设计一种链表结构,至少支持
- 添加节点
- 删除节点
也可能还有其他的需求,不过暂时不讨论。教科书或许会鼓励作出这样的实现:
// list_impl1.h #pragma once #include <stddef.h> #include <stdint.h> struct linked_list_node { void *entry; struct linked_list_node *neighbors[2]; }; #define LIST_NODE_NEXT 0 #define LIST_NODE_PREV 1 struct linked_list { uint32_t count; void (*destructor)(void *); struct linked_list_node head; };
链表节点类型struct linked_list_node提供看似通用的void *数据域。当然这里只是简化处理,或许读者会希望提供一个联合数据域,方便取出常见的基本类型(这样的需求可以通过_Generic实现出来,写一些宏足矣)。另一方面,我们为作为“表”出现的链表准备专门的结构,看起来会稍微舒服点。
重审我们的需求。当说起“添加节点”的时候,其实并不知道应该在什么位置上添加;同样地考虑下来,“删除节点”也不知道从哪里找到节点。所幸我们的描述足够狭窄——注意,提出的需求针对“节点”而非“元素”,故而现在可以专注解决节点的问题。在写函数之前,首先提供一份头文件,简单地涵盖可能用到的标准C头文件:
// stdc_common.h #pragma once #include <stdlib.h> #include <string.h> #include <stdint.h>
下面给出添加和删除操作。由于对键比较方法缺乏认识,首先假定我们找到了插入位置(在此节点前后)和删除位置(删除此节点):
// list_impl1.c #include "stdc_common.h" #include "list_impl1.h" #define direction_of(node_, direction_) ((node_)->neighbors[(direction_)]) #define next_of(node_) direction_of((node_), LIST_NODE_NEXT) #define prev_of(node_) direction_of((node_), LIST_NODE_PREV) static void do_insert_after(struct linked_list *list, struct linked_list_node *target, struct linked_list_node *newnode) { (prev_of(newnode) = target), (next_of(newnode) = next_of(target)); (prev_of(next_of(target)) = newnode), (next_of(target) = newnode); list->count++; } static void do_insert_before(struct linked_list *list, struct linked_list_node *target, struct linked_list_node *newnode) { (next_of(newnode) = target), (prev_of(newnode) = prev_of(target)); (next_of(prev_of(target)) = newnode), (prev_of(target) = newnode); list->count++; } static struct linked_list_node *do_remove_at(struct linked_list *list, struct linked_list_node *target) { (next_of(prev_of(target)) = next_of(target)), (prev_of(next_of(target)) = prev_of(target)); return list->count--, (prev_of(target) = next_of(target) = NULL), target; }
通俗的双向链表加链/断链过程。为对C语言不太习惯的读者加一注:static修饰要求函数作用域限于本编译单元,换句话说这些函数不会被导出(不妨考虑下原因)。
现在我们有了真正添加和移除节点的操作,它们比涉及键比较的操作更为基本,因此应当用于搭建上层的插入和删除操作——但是现在麻烦也来了:插入和删除的位置如何确定?很容易想到两种通用的做法,也即指定第k位置插入和指定在某键后插入。虽然都很好实现,但用起来很别扭:没人一上来就知道k该是多少,而指定的键也可能重复,这使得链表的插入行为不确定;同时,插入和删除应当是对称的——同样,没人知道该怎么删除。
这迫使我们停下来考虑一开始(或是教科书)的构想是否出现了问题。元素难以区别的链表,可能更多地用来堆积元素,同时在遍历过程中提供检索操作;另外的时候,也可能像数组一样用下标取得元素。
某些读者或许已经明白我们在谈论什么了,不过这里不必挑明。先从比较容易的事情做起,写一个按下标取得元素的简单函数:
// list_impl1.c static struct linked_list_node *do_get_node_at(struct linked_list *list, uint32_t index) { if (!(list->count) || (index >= list->count)) return NULL; uint8_t direction = (index < list->count / 2) ? LIST_NODE_NEXT : LIST_NODE_PREV; uint32_t steps_total = (direction == LIST_NODE_NEXT) ? index : (list->count - index); struct linked_list_node *target = &(list->head); do { target = direction_of(target, direction); } while (--steps_total); return target; } void *list_get_value_at(struct linked_list *list, uint32_t index) { struct linked_list_node *target = do_get_node_at(list, index); return target ? target->entry : NULL; }
它试图从更短的一侧查找下标。现在也许我们冷静一些了,可以放弃过分灵活但不实用的用法。假如这列表处于暂时未变动的状态(这或许暗示它已经生成完成,近乎不可变了)。如果真的是这样,或许重要的是列表的元素被加入的客观顺序,而非其他什么混乱的顺序。在这样的假定下,我们添加两种操作:头插入和尾插入。
// list_impl1.c static struct linked_list_node *create_node(void *value) { struct linked_list_node *node = malloc(sizeof(struct linked_list_node)); if (node == NULL) return NULL; return (node->entry = value), node; } static void destroy_node(struct linked_list_node *node) { free(node); } int list_append_value(struct linked_list *list, void *value) { struct linked_list_node *node = create_node(value); if (node == NULL) return 0; do_insert_before(list, &(list->head), node); return 1; } int list_prepend_value(struct linked_list *list, void *value) { struct linked_list_node *node = create_node(value); if (node == NULL) return 0; do_insert_after(list, &(list->head), node); return 1; }
还差一点。现在链表可以按下标取得元素,也可以在头尾两侧添加元素,不过遍历是做不到的。不妨考虑遍历的一般过程——用户需要一个循环变量遍取列表中的每一个元素,针对循环变量编写自定义的操作。再抽象一步的话,这里可以完成一次解耦:一个迭代器实例向用户承诺遍取列表中的每一个元素,并承诺向用户返回当前取得的元素。不过迭代器之所以值得称为迭代器,在于它应当通用地实现前述行为;现在我们没有太多精力处理这样的通用性,所以只做出根本不像样子的迭代游标。
// list_impl1.h struct linked_list_iterator { uint32_t n_steps_left; struct linked_list *parent; struct linked_list_node *current, *next; };
这里我们假定使用先检测是否可遍历、再进行遍历操作的迭代风格,则迭代器需要的操作是“检测是否可以继续迭代”、“迭代步进”、“取当前元素值”和“删除当前元素”。注意到这里并没有定义任何添加操作,因为在迭代过程中添加会影响迭代语义(若是前插,则新元素不会被遍历到;若只提供后插,语义就更不对称,倒不如全放弃)。
int iterator_has_next(const struct linked_list_iterator *iterator) { return iterator->n_steps_left > 0; } static struct linked_list_iterator *do_step_next(struct linked_list_iterator *iterator) { iterator->n_steps_left--; if (iterator->next) { return (iterator->current = iterator->next), (iterator->next = NULL), iterator; } else { return (iterator->current = next_of(iterator->current)), iterator; } } void *iterator_get_value(struct linked_list_iterator *iterator) { return iterator->current->entry; } void *iterator_next(struct linked_list_iterator *iterator) { return iterator_get_value(do_step_next(iterator)); } void iterator_delete(struct linked_list_iterator *iterator) { struct linked_list_iterator iterator_ = *iterator; iterator->next = next_of(iterator->current); struct linked_list_node *node = do_remove_at(iterator_.parent, iterator_.current); destroy_node(node); } struct linked_list_iterator list_get_iterator(struct linked_list *list) { return (struct linked_list_iterator) { list->count, list, &(list->head), NULL }; }
注意一点,迭代器步进的正确性必须由迭代可行性检查保证——所以我们没做更多的检查。
从链表获取迭代器的操作返回了一个结构而非指针,立刻能想到的原因有二:第一,迭代器结构足够小(数个指针长)、生存期不长(往往在本地用完即扔);第二,迭代器只用来保存恒定索引,它本身并不需要被链表引用。在这种时候,一个单纯的文字量比开动态内存明显有优势。
另外,链表本身的销毁涉及到链表中所有元素的销毁,此操作不应因为申请动态内存失败而失败——所以获得链表迭代器的操作是必须成功的。强迫用户反复调用销毁函数直到销毁成功,或是自己封装一次此类过程,都显得高度不可取。
当然,这里的迭代器实现十分粗糙,几乎是没动大脑就呕出来填实接口的粗暴实现,不值得读者效仿。
有关链表节点的操作告一段落,现在我们可以处理链表本身了:
// list_impl1.c struct linked_list *linked_list_new(void (*destructor)(void *)) { struct linked_list *list = malloc(sizeof(struct linked_list)); if (list == NULL) return NULL; list->count = 0; list->destructor = destructor; struct linked_list_node *head_node = &(list->head); next_of(head_node) = prev_of(head_node) = head_node; return list; } void linked_list_delete(struct linked_list *list, void (*destructor)(void *)) { struct linked_list_iterator iterator = list_get_iterator(list); while (iterator_has_next(&iterator)) { void *entry = iterator_next(&iterator); if (destructor) destructor(entry); iterator_delete(&iterator); } free(list); } uint32_t linked_list_get_size(const struct linked_list *list) { return list->count; }
导出必要的API:
// list_impl1.h void *list_get_value_at(struct linked_list *list, uint32_t index); int list_append_value(struct linked_list *list, void *value); int list_prepend_value(struct linked_list *list, void *value); int iterator_has_next(const struct linked_list_iterator *iterator); void *iterator_get_value(struct linked_list_iterator *iterator); void *iterator_next(struct linked_list_iterator *iterator); void iterator_delete(struct linked_list_iterator *iterator); struct linked_list_iterator list_get_iterator(struct linked_list *list); struct linked_list *linked_list_new(void (*destructor)(void *)); void linked_list_delete(struct linked_list *list); uint32_t linked_list_get_size(const struct linked_list *list);
现在可以试试了。
#include <stdio.h> #include <stdlib.h> #include <time.h> #include "list_impl1.h" char messages[][64] = { "The first element.", "The second one.", "...And the third one.", "Last but not least, the fourth comes forth." }; static void dump_with_check(const char *message) { printf("%s\n", message ? message : "[FATAL] null element."); } int main(void) { struct linked_list *list = linked_list_new(NULL); for (uint32_t index = 0; index < 4; index++) list_append_value(list, messages[index]); dump_with_check(list_get_value_at(list, 1)); dump_with_check(list_get_value_at(list, 3)); dump_with_check(list_get_value_at(list, 4)); printf("\n"); struct linked_list_iterator iterator; iterator = list_get_iterator(list); while (iterator_has_next(&iterator)) { dump_with_check(iterator_next(&iterator)); } printf("\n"); iterator = list_get_iterator(list); srand((unsigned) time(NULL)); int index_to_delete = rand() % 4, counter = 0; while (iterator_has_next(&iterator)) { void *value = iterator_next(&iterator); if (counter++ == index_to_delete) { iterator_delete(&iterator); } else { dump_with_check(value); } } linked_list_delete(list); return 0; }
过程可能稍显混乱。稍微总结一下:首先我们定义最底层的操作,这些操作和表所需要满足的业务可能并无多少关系,专注于维护表自身的秩序性;此后我们试图声明某种隐喻——或者一处故事,这些故事讲述了结构面对客户程序时展现出的功能。
Side B
前一部分中,我们花了太多无谓的努力制造问题。假如现在问题得以简化一点——去掉对下标的要求,只提供迭代语义和头尾插入,则可能给出何种实现?如果我们不满足于链表节点空间利用率的恶劣程度,应当提供什么形式的数据结构定义?
下面的回答来自某内核,并非我的原创。首先我们定义数据结构:
// util_list.h #pragma once #include <stddef.h> struct link_index { struct link_index *prev, *next; };
它没有数据域,只用于维持链表的结构。这比先前所实现的链表更为激进——只有两个指针,难道不会引起更多的空间浪费问题吗?实际上,我们压根不会用malloc()去单独申请这种节点。考虑下面的定义:
struct demo_structure { int x, y; struct link_index linkable; };
我们要求数据域包含链表节点,而链表操作只对链表节点对象暴露。在已知数据域的情况下,得到链表节点无非只是一次成员访问的事;但如果只知道一个合法的链表节点指针,如何获得对应的数据域?
#define offset_of(type, member) ((size_t) &((type *) 0)->member) #define container_of(ptr, type, member) ({ \ const typeof(((type *) 0)->member) *__mptr = (ptr); \ (type *) ((char *) __mptr - offset_of(type, member)); })
宏offset_of危险地算出对象中指定成员的偏移(如果希望行为真正安全,建议读者使用offsetof()宏,它的定义由stddef.h提供);container_of则要求给出指针ptr, 安全地赋给__mptr(这个名字也比较危险,不推荐仿照),它指向所给定的链表节点,所以用链表节点成员在数据域中的偏移向前修正指针所对应的地址,就能得到指向数据域的指针。以上面的结构举例,就是:
struct demo_structure demo; struct demo_structure *demo_ptr = container_of(&(demo.linkable), struct demo_structure, linkable); assert(demo_ptr == &demo); // true
理解了取得数据域的方式,剩下的工作则和往常一样,致力于构造有双指针域的链表。我们要求链表必须有头节点,而空表的头节点形成一元素长的环:
// util_list.h static inline void list_init(struct link_index *h) { h->next = h; h->prev = h; } static inline int list_is_empty(struct link_index *h) { return (h->next == h) && (h->prev == h); }
同样,我们需要实现定位插入操作;不过这次的定位插入是精准的——指定前驱和后继,在前驱和后继之间加入元素。前驱和后继不必相异,因为我们只需要修改它们的各一个指针域;若前驱和后继相同,则这个节点(比如说空表的头节点)的两个指针域分别被正确地修改了。
// util_list.h static inline void list_add__(struct link_index *e, struct link_index *p, struct link_index *n) { n->prev = e; e->next = n; e->prev = p; p->next = e; }
在定位插入的基础上,实现头插和尾插:
// util_list.h static inline void list_add_head(struct link_index *e, struct link_index *h) { list_add__(e, h, h->next); } static inline void list_add_tail(struct link_index *e, struct link_index *h) { list_add__(e, h->prev, h); }
定位删除同样是按前驱和后继定位的,我们简单地略掉前驱和后继之间的元素。显然,若我们知道该删哪个节点,则很容易定位它的前驱和后继,利用定位删除把它从链表上卸掉。仍然需要提醒读者,由于链表节点被数据域包含、不独立创建,它没有权利要求任何构造和析构操作。它仅仅存在着。
// util_list.h static inline void list_del__(struct link_index *p, struct link_index *n) { n->prev = p; p->next = n; }
按照上面的思路,我们实现迭代器风格的删除:
// util_list.h static inline void list_del(struct link_index *e) { list_del__(e->prev, e->next); e->next = e->prev = NULL; }
迭代删除操作是最推荐用户使用的删除方式,它也会为被卸载节点的前驱和后继域下毒,使它们明显地不合法(置为NULL)。在NULL毒的约定下,我们补充判定节点卸载相关的操作。它们不太实用。
static inline int list_node_isolated(struct link_index *e) { return (e->next == NULL) || (e->prev == NULL); } static inline void list_node_isolate(struct link_index *e) { e->prev = e->next = NULL; }
最后是迭代。本节中的链表不使用任何额外的迭代器结构,也不需要特别地获取迭代器——用一份链表节点指针充当迭代器已经足够,因为迭代删除正是基于链表节点的。另一方面,我们在迭代删除操作上并没有任何特殊的处理,所以需要提供两种迭代形式——可变和不可变的foreach.
// util_list.h #define list_foreach(head) \ for (struct link_index *iterator = (head)->next; iterator != (head); iterator = iterator->next) #define list_foreach_remove(head) \ for (struct link_index *iterator_aux = (head)->next, *iterator = iterator_aux; \ ((iterator = iterator_aux) != (head)) && (iterator_aux = iterator_aux->next);) #define current_iterator iterator #define detach_current_iterator list_del(iterator) #define current_object_of_type(type, member) \ container_of(iterator, type, member)
list_foreach比较好理解,只是单纯的for循环变化。list_foreach_remove则使用两组迭代器确保删除的正确性:iterator_aux完成遍历,而iterator则反映出当前的元素;若遍历没有回到头节点,则在每趟循环开始的时候,iterator_aux总比iterator快一步。请注意,list_foreach_remove使用的for循环没有步进表达式——因为步进发生在一趟循环结束时,那时再更新iterator_aux的话,会因为链表结构发生变化而惨遭失败,所以步进表达式(两组)和终止条件写在了一起。
一份简单的演示程序如下:
#include <stdio.h> #include <stdint.h> #include "util_list.h" struct test_schema { int id; struct link_index linkable; } test_data[] = { { .id = 1 }, { .id = 2 }, { .id = 3 }, { .id = 4 } }; void print_list(struct link_index *list) { list_foreach(list) { printf("%d\n", current_object_of_type(struct test_schema, linkable)->id); } } void print_list_remove(struct link_index *list) { list_foreach_remove(list) { detach_current_iterator; printf("%d\n", current_object_of_type(struct test_schema, linkable)->id); } } int main(void) { struct link_index list; list_init(&list); for (uint32_t index = 0; index < sizeof(test_data) / sizeof(struct test_schema); index ++) list_add_tail(&(test_data[index].linkable), &list); print_list(&list); print_list_remove(&list); printf("list is%sempty.\n", list_is_empty(&list) ? " " : " not "); return 0; }

浙公网安备 33010602011771号