在 C 和 Scheme(更智能的语言)中使用链表的技术
级别: 初级
Jonathan Bartlett, 技术总监, New Media Worx
2005 年 1 月 31 日
单链表是使得程序员可以描述多种类型的数据的一种有效抽象;可以将链表的使用扩展到任意类型数据的处理当中,这为处理数据提供一个有效的工具。在本文中,我们将考虑这些处理,并研究 Lisp 的变种 Scheme,它是一门易于使用的面向列表的语言,具有列表处理能力,但不像 C 那样复杂。
列表(list)处理是计算机最强功能之一;单链表(singly linked lists)为可以应用于编程各个方面的许多有趣的算法和技术提供了基础。在本文中,我将介绍这些技术和一门简单的语言,这门语言可以提供这些技术,而它并不必像 C 那样复杂。
首先,让我们来回顾一下链表。
单链表是拥有有序节点序列的数据结构,其中,每个节点包含一个数据元素以及一个指向序列中下一个节点的指针。从概念上讲,在计算机内存中,整数的单链表如下图所示:
图中的箭头表示指向内存中下一个节点位置的指针。注意,列表的顺序由箭头决定,而不是由节点的内存位置决定。
最后,注意序列中的最后一个节点只有一个标记,而不是一个箭头。这表示在列表中不再有更多的节点。这个标记通常称作空值(null value)、空列表(null list)或者空指针(null pointer)。
本文将只讨论单链表;术语“链表(linked list)”和“列表(list)”都被用来表示单链表。
在 C++ 中,标准模板库(Standard Template Library)中包括链表的一个实现,但是,对这里我即将介绍的技术而言,只有标准模板库实现还不够。
对链表进行编码的任务相当简单。例如,上图中的整数链表在 C 中可以由下面的节点结构来描述:
|
这样您就拥有了一个 struct,其中第一个元素为数据条目,第二个元素为指向下一个节点的指针。 NULL 被简单地定义为一个空指针(0),因为无论如何空指针都不是合法的指针。要在程序中使用一个变量来容纳这种链表,您需要做的只是使用一个指向类型 struct ll_int_node 的指针。这个指针将指向列表的第一个元素(也称为“头文件”)。
在标准的列表实现中,有两中基本更新操作:向列表中 插入(inserting) 节点和从列表中 删除(removing)节点。
我不会用太多时间来分析这些操作,因为我的方法是从另一个角度来使用链表(稍后进行解释)。实现这些方法很容易,只需分配或者释放(de-allocating)节点结构,然后修改指针,使给定节点在列表中正确排序即可。
从概念上讲,这只是一个移动箭头的问题。不过,当插入或者删除操作改变了列表头时,必须引起注意,确保正确地更新了列表的头文件。在本文最后的 参考资料一节,可以找到更深入介绍基本链表处理的链接。
如果您不得不对想要存储在链表中的每一种类型的数据使用不同的数据结构,尤其是想在单个列表中存储多种类型的数据时,那么这将是件非常痛苦的事。因此,我将介绍一种更为通用的处理数据的方法,这种方法允许您在任意节点中存储任意数据类型。
为了在一个节点中存储任意类型的数据,我将定义一个结构,它将直接处理基本的 C 数据类型,但通过指针来处理复杂的类型。
为了完成这种双重模式的数据处理,我将使用 类型标签(typetags)。类型标签是一个整型变量,其中整数的值指明将要被处理的数据的类型。对基本的 C 数据类型而言,类型标签会让您知道将要被处理的数据类型。对复杂的类型而言,只需要使用一个指针,类型标签就会告诉您将要指向的 struct 是什么类型。清单 2 给出了这个结构的定义。
|
为了使这个通用的数据结构可用,您必须为每个需要的类型指定一个类型标签值,其中包括指针类型。清单 3 给出了使用类型标签来显示基于类型标签的不同类型数据的完整程序。
|
如果想更加面向对象,那么还可以将类型标签用作 vtable 数组的一个索引。尽管对这种类型标签的深入讨论超出了本文的范围,不过,它确实指出了类型标签的潜在能力。我最初目标仍是为您提供一种简单的数据结构的概念,它可以容纳任意数量的数据类型。
在这个新的通用数据类型的帮助下,您现在可以得到一个非常通用的列表节点结构,如清单 4 所示。
|
使用这个通用的列表节点结构,您现在可以定义列表和不需要考虑存储在列表中的数据类型的列表操作。类型标签机制确实带来了一些开销,不过,相对于重新编写一个又一个列表所带来的挫折感,它通常还是值得的。
在 C++ 中,通过模板可以减少这一开销。事实上,如果创建一个通用基类(类似于 Java 的 Object 类),那么您实际能够使用的只是 C++ 语言的类型机制,而不是定制实现的类型标签。这种方法比类型标签机制稍有不足(它没有用于基本类型的指针开销),但是,如果您要处理复合类型,或者不介意在类中包装基本类型,那么它仍然是有效的。
尽管使用链表人工管理内存是可行的,但那不是一件非常容易的工作。例如,当从一个列表中删除某个节点时,必须记住释放这个节点结构的内存。那还不算太糟糕,不过,如果节点的数据部分是一个指针或者包含指针,尤其是当没有明确的、统一内存管理策略可以用于这个程序的有效管理时,就会出现问题。
例如,如果策略要求只是将数据结构的 副本 而不是原始的结构本身添加到列表中,那么您可以安全地释放那块内存;不过,您通常会希望通过只传递一个共享的指针来提高速度并减少内存的使用。在这种情况下,内存管理会变得更困难。
作为难题之一,内存管理的另一个问题涉及到部分地共享列表节点的思想(后面将讨论)。如果您正在共享列表节点,但是其他地方仍在使用该列表节点,那么删除某个列表元素或者甚至删除整个列表都不会要求您释放该节点的内存。
由于这些内存问题,在本文的其余小节中,我将假定该程序使用了一种垃圾收集器。向程序添加一个垃圾收集器很容易,它允许您不管愿意与否就共享数据结构,根本不必考虑内存管理问题(如果从来没有不管愿意与否就共享数据结构,那么您就错过了机会)。
如果做得不够小心,共享数据结构还可能会导致其他问题,因为不论什么时候通过指针共享数据结构时,通过任何指针对结构进行的更新都会影响指向该结构的每一个指针。
在“内存管理内幕”(请参阅 参考资料)一文中,讨论了多种内存管理技术,包括添加垃圾收集器。
现在,我将研究即将使用的链接的属性。
既然您已经了解了链表,那么您应该意识到将要使用的列表的一些有趣的属性:
- 由于节点结构只指向一个方向,所以它们完全不了解它们之前的节点的顺序。
- 由于节点完全不了解它们之前的节点,所以每个节点本身都是一个列表的开始。
- 由于节点完全不了解它们之前的节点,所以一个给定的列表实际上可能是其他许多列表的子列表(sublist)。
下面的图中展示了一些示例,它们能够帮助您更好地理解列表的这些属性。
在这个图中有三个列表。第一个列表是序列 (1 57 26 9)(从现在起,为了方便阅读,我将把列表写在圆括号中)。现在,这个列表包含若干个子列表。在图中标识出了一个特别的子列表,即 (57 26 9);不过, (26 9)、 (9) 和 () 都是第一个列表的合法子列表。
最后一个示例 () 用来表示一个空列表。由于节点的箭头只指向一个方向,所以无需对数据进行任何修改就可以将其用作子列表。
除了普通的子列表以外,图中还给出了共享子列表的一个示例。该列表 (18 32 26 9) 实际上与前一个列表共享最后两个节点。这意味着对前一个列表的任何改变都将同样会影响到这个列表。因此,这个列表不需要为最后两个节点分配额外的内存。另外,这个列表可能是其他列表的子列表,其他列表也可能会将 (26 9) 用作子列表。
如前所述,为了利用共享列表,您显然需要一个内存管理策略 —— 可能是垃圾收集器。
链表的优势之一是,您可以使用它们来避免锁定状态。例如,假定您正在编写一个游戏程序,并为每一个可移动对象维护了一个列表,与此同时,还运行着与游戏线程无关的可移动线程。在玩游戏的时候,您可能时常需要向这个列表添加对象或者从中删除对象。当从列表删除对象时需要锁定这个列表,添加条目时(比如射出的子弹),如果将条目添加到列表前,则不需要锁定列表。可移动线程可以无所顾虑地遍历列表,因为您知道即使在移动期间添加了游戏对象,它们也不会影响正在移动的节点。您正在移动的对象只是新列表的一个子列表。
无论何时,只要经常需要遍历或者添加某个未排序集合,这项技术就会有效。当向列表前添加元素时,甚至不必修改现有的列表结构,就可以让其他线程自由地遍历它。不过,要注意的是,如果将元素添加到列表的末尾而不是添加到最前面,那么您将会直接修改链表结构本身。如果将某个线程添加到列表末尾时,其他线程正在遍历该列表,那么可能会产生竞态条件(race condition)。
对很多应用程序来说共享子列表都非常实用,其中之一就是撤消(undo)操作。如果将一个文档作为只能添加(append-only)的链表来构造,那么这会使您可以很轻松地将修改回复到先前的状态。
例如,该文档可能如下所示: (modification3 modification2 modification1 original-document)。这种安排使得您只需将列表的头文件移动到某个子列表中,就可以撤消任务修改。执行两次撤消操作后文档将如下所示: (modification1 original-document)。
通过创建从某个特定点起共享文档的分支,您还可以生成文档的多个版本。举例来说,这是 CVS 中的分支所使用的思想。您可能会将 (branch-modification2 branch-modification1 modification1 original-document) 作为原始文档的另一组修改,如同我前面介绍的那组修改一样。
还可以使用列表来描述多节点树。描述二叉树(binary tree)的节点有一个数据值、一个左指针和一个右指针;不过,很多数据体要求树不只有一个左分支和一个右分支。列表是对这种树的极好描述。
可能看起来列表结构只能处理线性排列的值,但是,通过使用共享子列表,它们也可以处理“反转树(inverted tree)” —— 在这里我没有对这一内容进行讨论。如果有多个列表共享同一个子列表,那么从图形上看,它像是一棵树,但是由于列表只能向同一方向前进,所以在任何分支上都不能访问其他分支。因此,为了描述真正的树,您需要使用另一种方法。
在前面我讲过了如何使用加标签的类型来使您可以在节点中使用任意类型的数据。同理,您也可以在节点中存储指向列表节点的指针,这样就可以将列表作为列表节点的数据。以下是它可能具有的形式:
每个节点都可以是一个分支或者是一个叶子。只需将列表作为列表中的值,您就可以在插入符号(parenthetic notation)中对其进行描述。上面的图可以这样描述: (((15 42) 22 18) 32 (6 2))。分析它会发现,它只是一个由三个元素构成的列表 —— (element1 element2 element3) —— 其中 element1 是一个列表, element2 是数字 32, element3 是列表 (6 2)。 element1 的列表本也是一棵树,其中第一个元素是列表 (15 42),其余两个元素是 22 和 18。因此,使用列表的列表,您就可以描述具有任意多个分支和叶子的树。
树的最主要应用就是描述计算机语言的语法。一门计算机语言可以看作是语言结构的一个分层级的树。最顶层是整个程序。这个程序由任意数量的语句构成,语句由任意数量的表达式构成。因而,树(尤其是多节点树)是描述计算机语言的理想方式。
作为一个示例,让我们来解析一个 if 语句,它的语法如下所示:
|
在这个描述中,每个列表的第一个元素将描述一个语言操作、行为或者函数调用,余下的元素描述参数。与此对应的解析树如下所示:
可以将其写成 (if (> x y) (call_some_function a b c))。注意,当处理解析树时,始终要首先列出操作/函数,因为在知道如何处理参数之前,需要知道将要使用哪个操作或者函数。
真正有趣之处在于,许多不同语言的大部分解析实际上是非常类似的。大多数语言都以基本上相同的方法来解析 if 语句和函数调用。不过,真正值得关注的是,是否有直接操作解析树的语言。实际上有这样一门语言,我们将在下一节对其进行介绍。
至此,我已经讨论了列表和列表操作的一些有趣特性:
- 使用适用于列表的列表节点数据结构。
- 通过类型标签使列表节点和变量可以存储任意类型的数据。
- 共享列表和使用子列表。
- 使用垃圾收集器来简化列表的内存管理。
- 在插入符号中描述列表。
尽管所有这些特性都是有用的,但在 C 编程语言中实际实现它们却相当麻烦。为共用体(union)的成员分配类型标签、分配数据以及手工处理列表节点都是冗长乏味的过程。使用垃圾收集器可能使这些过程变得简单,但也可能使它们变得困难,这要取决于所使用的垃圾收集器。插入符号非常好用,但是不能在 C 语言中使用。
如果一门编程语言可以利用所有这些技术,并使它们成为语言的主要部分,那么这门语言将非常了不起。
实际上存在这样的语言,它就是 Lisp。Lisp 是一门 庞大的(huge) 语言,难于使用,不过,有一门称作 Scheme 的派生语言,它的功能也很强大并且它非常简单。包括索引的标准 Scheme 文档也才只有 50 页。
Seheme 几乎完全基于两类列表和数据,即原子(atom)和列表。(也有向量,不过在这里不作讨论。)原子是一个基本的数据,比如一个数字、字符串或者字符。列表是原子的链表,或者是列表的列表,或者两者兼备。甚至使用 Scheme 编写的程序也是解析树的列表,类似于前面所见的那些解析树列表。实际上,前面所研究的解析树示例几乎就是一个合法的 Scheme 程序。
这里是一个具体的、可运行的 Scheme 程序: (if (> 7 5) (display "Hello There!/n"))。这个程序将比较 7 和 5,由于 7 比 5 大,所以它将向屏幕打印“Hello There!”。
要运行某个 Scheme 程序,您需要有一个 Scheme 解释器。如果您正在使用 Linux,那么您可能已经安装了 GUILE Scheme 解释器。要使用 GUILE 运行 Scheme 程序,只需要输入 guile -s filename.scm 即可。
如前所述,一个 Scheme 程序只不过是列表的集合,每个列表都是一个解析树,都通过 Scheme 的求值程序生成一个结果。求值程序处理以下 4 种基本情况:
- 数据是否是一个变量名?如果是,那么找到该变量并返回其值。如果是函数名,则返回该函数。
- 数据是否是一个列表?如果不是,那么它只是一个常量值,所以要返回它。
- 列表的第一个元素是否是某个 特殊形式(special form)(不是函数的操作)的名称?如果是,则运行求值程序对应于那个特殊行为的内部处理过程,并返回结果。
- 否则,要对列表中的每一个元素运行求值程序。对第一个元素求值所得到的结构必须是一个函数,并以其余元素作为参数来调用这个函数。函数的结果作为求值的结果返回。
首先,我将介绍用于定义变量名称的特殊行为,称为 define。 define 语句与以下语句类似: (define variablename value)。以后对变量的修改通过 set! 这一特殊行为来完成。它与 define 的作用类似,不过它只是已经定义某个变量之后起作用。
输入并运行下面的程序来尝试这些特殊行为:
|
由其名称可以明显看出, display 是打印出参数的函数,而 newline 是只打印出一个回车换行字符的函数。
您可以使用任何需要的表达式作为一个值。例如,您可以使用加法函数(在 Scheme 中称为 + )来计算值。您甚至可以将 if 特殊行为作为某个表达式的一部分。
这里是使用这些概念的另一个代码示例:
|
注意您如何可以将任意类型的数据赋给任意变量。在先前的程序中,您使用了整型和字符串数据,因此没有告诉 Scheme 语言正在使用的是哪种类型的数据。Scheme 在内部使用类型标签来区分不同类型的数据。
当然,使用 Scheme 的所有理由首先是处理列表操作。在 Scheme 中,列表节点被称为 pair。它们与前面我向您介绍的 C 中的节点结构稍有不同,因为一个 pair 的下半部分不必是一个指向某个列表节点的指针。不过,为了实现本文的目标,我将使用与列表节点相同的 pair。
Scheme 将一个列表定义为一个空列表,或者是一个下半部分为某个列表的 pair。这意味着列表中使用的 pair 的下半部分将始终指向一个列表。当遇到一个空列表(写为 ())时,您就会知道已经到了列表的末尾。
使用 cons 函数在 Scheme 中创建一个列表节点。 cons (代表 construct)获得两个参数,即节点数据以及 pair 的下半部分将要指向的列表。如果只是要开始创建列表,那么您必须使用空列表。例如:
|
cons 就像是 Scheme pair 专用的 malloc。另外,由于 Scheme 可以进行垃圾收集,所有您根本不必考虑释放这个 pair。一旦停止使用 pair, Scheme 运行时环境将为您完成这个 pair 的清除工作。
空列表前面的单引号用来告诉 Scheme 您正在使用 () 作为数据,而不是用它作为解析树的一部分。
下面是一个 Scheme 程序,演示了如何创建数字 3、4 和 5 的列表:
|
在下面的列表中,我将演示如何使用它来创建共享子列表。
|
为了避免混淆,可能有必要打印出每一个列表节点。记住,是 cons 创建了列表节点。
现在,为了让列表可用,您必须能够获得其数据。从列表的第一个元素获取数据的函数称为 car(这个名字是古老的汇编语言指令的延续)。为了获得列表的下一个节点,您需要使用 cdr 函数。注意,这样所获得的是整个节点,而不仅仅是值 —— 要得到值,还必须对结果执行 car 操作。
将下面的代码添加到先前的程序中,从而展示如何使用 car 和 cdr:
|
除了其他语言中可以使用的通用控制结构之外,Scheme 还有一些特殊的控制结构,专门用于处理列表。其中最重要的是 map,它将某个函数应用到列表中的每个元素。可以这样调用它: (map function list-to-process)。因此,为了使用 map,您必须知道如何定义函数。
在 Scheme 中,使用 lambda 特殊行为来定义函数,它会创建并返回一个没有名称的过程。使用 define 可以为返回的过程提供一个名称,或者直接就可以在适当的地方使用它。 lambda 的第一个参数是函数可以取得的参数列表。 lambda 的其余参数是调用函数时的计算表达式。
这里是一个简短的函数,用于向列表中的每一个元素加 2,并返回结果列表。
|
如您所见,这样就可以创建出一个全新的列表,这个列表是将函数应用到原始列表中每个元素的结果。此外,它保持原始列表不变。
如前所述,您还可以命名您的函数,这样就可以重复使用这些函数,如下所示:
清单 13. 演示在 Scheme 创建并命名函数的示例程序
|
在大部分编程语言中,定义和命名函数是同一个操作。但 Scheme 将其划分为两个独立的过程 —— lambda 定义函数, define 为函数命名。
Scheme 全面地为程序员提供了程序的大部分基本功能模块,并允许程序员以独特并且与众不同的方式来组合使用它们。例如,创建函数和命名函数的分离,使得 Scheme 程序员可以在代码中任意位置创建并使用匿名函数,而同时也可以方便地编写正规的、命名的函数。
单链表是让您可以描述多种类型数据的一种有效抽象。扩展列表处理任意数据类型、启用垃圾收集、使用面向列表的语法处理列表等,这些可以为数据处理提供一个有用的范例。
丰富而强大的 Scheme 语言极其适用于列表处理,而且易于理解和掌握。本文只是一个入门级读物,旨在让您体验了在 Scheme 等面向列表的语言中列表处理是何等简单。不必面临管理 C 结构、C 内存或者直接处理类型标签的困难,您就可以完成 C 实现的全部功能。
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文。
- Staford 计算机科学系给出了一个极好的 关于 C/C++ 中链表的介绍(26 页)。
- 美国标准与技术研究院的 Dictionary of Algorithms and Data Structures 中有一个 链表条目。
- Donald Knuth 在 Fundamental Algorithms 中详尽地描述了链表及其操作。
- Gtk+ 有一个与本文中讨论的类型标签机制非常类似的系统,称作 GtkArgs (即 2.x API 中的 GValues) —— GType API 信息也很有帮助。
- MzScheme 的类型标签实现 使用整数时速度更快并且结构更紧凑。
- How to Design Programs 是关于 Scheme 编程的面向初学者的一本好书,它将真正教您如何像 Scheme 程序员那样去思考。
- Teach Yourself Scheme in Fixnum Days 是关于 Scheme 的一个非常好的在线教程,其中既包括一些初级概念也包括一些高级概念。
- The Scheme R5RS standard 描述了是大部分 Scheme 实现是如何工作的。
- Essentials of Programming Languages 扩展了将 Scheme 语言用作可执行元语言的概念(如解析树示例中所示),以便其他编程语言利用它。
- 内存管理内幕 (developerWorks,2004 年 11 月)对 Linux 程序员可以使用的内存管理技术进行了概括。
- GNOMEnclature: The wonders of GLib (developerWorks,2000 年 4 月)描述了可以显著简化 C 编程的 GLib 工具库。
- Data Structures: Make the right choice (developerWorks,2004 年 9 月)阐述了您对数据结构的选择会如何影响应用程序的操作和性能。
|
| Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书介绍的是 Linux 汇编语言编程。Jonathan Bartlett 是 New Media Worx 的技术总监,负责为客户开发 Web、视频、kiosk 和桌面应用程序。 | |
10万+

被折叠的 条评论
为什么被折叠?



