最基础的数据结构(转自程序员杂志)

任何一个受过专业训练的程序员,对 数据结构 这门课程中涉及到的各种数据结构都不会感到陌生。但是,在实际的编程工作中,大部分的数据结构都不会用到,而且也许永远都不会用到。造成这种现象的原因有二:一是根据 80/20 法则,常用的数据结构只会占到少部分;二是计算机语言往往已经对常用的数据结构进行了良好的封装,程序员不需要关心内部的实现。
   虽然如此,深入地理解基本数据结构的概念和实现细节,仍然是每一个程序员的任务。这不仅是因为,掌握这些知识,将有利于更加正确和灵活地应用它们,而且也是因为,对于语言背后的实现细节的求知欲,是一个优秀的程序员的素质。
   本文将讨论实际编程最经常使用的三种数据结构:字符串、数组和 Hash 表,比较它们在不同语言中的实现思路,并涉及它们的使用技巧。
  
  字符串
   严格地说,字符串( string )甚至不能算作一种单独的数据结构,至少在 C 语言中,它仅仅是某种特定类型的数组而已。但是,字符串在实际使用中是如此重要,在不同语言中的实现又差异颇大,因此,它值得被作为一种抽象数据类型单独进行讨论,并且在我们讨论的三种结构中排名第一。
   最经典的字符串实现,应该是 C 语言中的零终结 (null-terminated) 字符串。如上所述, C 风格的字符串实质上是一个字符数组,它依次存放字符串中的每个字符,最后以零字符( ’\0’ ,表示为常量 null )作为结束。因此,字符串占据的空间比它实际的长度要多 1 个单元。在实际应用中,它常以数组或字符指针的形式被定义,如下例:
  
   char[] message = “this is a message”;
   char* pmessage = “an other message”;
  
   C 语言中,字符串并不是一种独立的数据类型,也没有提供将字符串作为一个整体进行处理的运算符。对字符串的所有操作,实际上都是通过对字符数组的操作来完成。
   试想一个函数,功能是求 C 风格字符串的长度。实现的思路是:设置一个计数器,然后用一个指针遍历整个字符数组,同时对计数器进行累加,直到字符串结束(指针指向了 null )。实际上, C 语言中的 strlen 函数也是这么实现的。这种方式看上去非常合理,但是在处理一个非常大的字符数组时,会遭遇到严重的性能问题。如果一个字符串长达数 M 甚至更大,那么求其长度的操作,需要执行数百万次甚至更长的循环。更糟糕的是,由于这个结果没有被缓存,所以每次求长度的操作都会重复执行这些循环。
   C 风格字符串的另一个缺陷是,它不会自动管理内存。这意味着,如果字符串的长度超出了数组能够容纳的范围,程序员必须手动申请新的内存空间,并将原来的内容复制过去。这种方式不但产生了大量无谓的工作,而且是无数臭名昭著的溢出漏洞的原因。一个最简单的例子是,当一个程序要求用户输入一个字符串时,如果用户输入的字符串的长度大于程序设定的缓冲区的长度,将会导致溢出,最终程序会崩溃。
   针对 C 风格字符串的这些缺陷,新的语言进行了相应的改进。作为 C 的直接继承者, C++ 语言在标准库中提供了一个基础字符串的实现: std :: basic_string 。它封装了大量常见的操作,例如取长度、比较、插入、拼接、查找、替换等等,并且能够自动管理内存。例如,由于 C++ 支持运算符重载,因此 C++ 字符串可以使用运算符直接进行运算,而不需要调用 strcpy 函数。另外, C++ 字符串也提供了与 C 风格字符串进行转换的功能。基于强大的模板机制, C++ 字符串将字符串的实现和具体的字符类型分离开来了。下面是两种最常见的字符串类型:
  
   typedef basic_string<char> string; // 定义了 ansi 类型的字符串
   typedef basic_string<wchar_t> wstring; // 定义了宽字符类型的字符串
  
   不幸的是,由于复杂的历史原因,许多 C++ 方言(例如 Visual C++ Borland C++Builder )都提供了与标准字符串不同的字符串实现。这些字符串实现各有长处,但是将它们和 C++ 标准字符串以及 C 风格字符串进行转换,又成为了一项令人头疼的工作。
   Delphi 对字符串的改进基于另外一种思路。在 Delphi 中,字符串仍然是一种基本类型,而不是类。它的实现方式也是字符数组,不同于 C 风格字符串的是,在数组的头部增加了两个 32 位整数存储空间,分别用于存放字符串的长度和引用计数。通过前者可以方便地获得字符串的长度,而不需要进行无谓的遍历操作。后者实现了 COW Copy on Write )技术,这种技术的效果是:当字符串被复制时,并不会复制其内容,而只是建立一个新的指针,指向原有的字符串,并在引用计数上加一。当字符串被删除时,引用计数减一,当引用计数为 0 时,字符串的内存将被释放。只有当对字符串进行写入操作时,才会建立一个新的字符串并复制内容。这些工作是由编译器自动完成的,程序员完全可以象使用 C 风格字符串一样使用 Delphi 风格的字符串,只是效率大大地提高了。
   Java C# 中的字符串,是一个封装了常见操作的类,这一点和 C++ 类似。一个特殊之处(往往导致经典的性能问题)是,无论是在 Java 还是在 C# 中, String 类都是不变 (immutable) 的。也就是说, String 的内容不能够被改变,如果代码试图改变一个 String 对象的内容,实际的结果是建立了一个新的 String 对象,并抛弃旧的对象。如下例:
  
   String s = "";
   for (int i = 0;i < 10000;i++) {
   s += i + ", ";
   }
  
   结果是建立并抛弃了 10000 String 对象,这在性能上的开销是惊人的。为了避免这种情况,应该使用 StringBuilder 对象,它可以改变其内容。( C# 一直使用 StringBuilder Java 1.5 开始引入 StringBuilder 以部分替代 StringBuffer ,它们的主要区别在于线程安全性。)如下例:
  
   StringBuilder sb = new StringBuilder();
   for (int i = 0; i < 10000; i++) {
   sb.append(i + ",");
   }
  
  数组
   从抽象数据类型的意义上来说,一维数组 (array) 的定义是:具有相同数据类型的若干个元素的有限序列。
   C 语言中,数组意味着一块连续的内存空间,按顺序存放着若干个相同数据类型的元素。可以通过下标来访问数组中的元素。如下例:
  
   int a[10]; // 定义一个 int 型的数组
   for (int i = 0;i < 10;i++) {
   a[i] = i; // 赋值
   }
  
   C 语言中,数组名事实上是一个指针(指向该数组的第一个元素),因此所有通过数组下标完成的操作,都可以通过指针来完成。通过指针来访问数组,效率上比数组下标要高,而且更加灵活,例如,指针可以进行偏移量的运算,甚至可以进行绝对地址的存取。
   C 语言中的数组没有越界检查,这意味着,程序员可以访问数组最后一个元素以后的地址,或者第一个元素之前的地址(例如, a[-1] a[-2] 这种形式是合法的)。在某些情况下,这是一种有用的技巧,但大多数情况下是一场灾难。 C 语言的数组也不支持自动增长,如果数组的长度发生了变化,程序员必须手动处理所有关于申请和释放内存的工作。
   C++ 提供了 C 风格的数组,同样不支持越界检查和自动增长。但是, C++ (至少是 Stroustrup 博士本人)建议,应该尽量使用 STL 中的容器作为替代品,一般是 vector Vector 基于面向对象和模板技术,构建了一个强大而复杂的类,实现了如下特性:高效率的自动内存管理;按任何顺序访问、插入和删除元素;越界检查,但同时也提供了不进行检查的访问方式,以照顾性能上的考虑;基于运算符重载技术的运算符支持;基于迭代器的漫游机制;与数据类型无关的算法支持;等等。相对于 C 风格的数组, vector 是一种更高抽象层次上的序列概念。它对大量常用的功能进行了封装(例如,对内存的直接操作),同时又尽可能地照顾了效率和可移植性(例如,在自动扩充时通过缓存机制来提高效率)。这也正是 C++ 语言对 C 语言进行改进时的指导思想。
   Delphi 也支持 C 风格的数组,但提供了越界检查。另外, Delphi 还提供了一种动态数组( Dynamic Array ),可以在运行时通过 SetLength 函数动态地改变它的大小。事实上, SetLength 函数就是对内存管理操作的一种封装。类似于 C++ 中的 vector Delphi 也提供了两个可以自动增长的容器: TList TObjectList ,前者用于存放无类型的指针,后者用于存放对象。由于 Delphi 不支持模板机制,所以 TList 不会自动释放指针所指向的内存,它只会维护指针自身占用的内存( TObjectList 能够在销毁时自动释放元素所占用的空间,如果它的 OwnsObjects 属性被设置为 True 的话)。一种常用的解决方法是,编写一个针对具体类型的包裹类,使用一个作为私有数据成员的 TList 对象来管理指针,并手动编写申请和释放内存的那部分代码。这样总比 C 语言中的情况要好得多。
   Java 也支持加上了越界检查的 C 风格数组,但它提供的类似容器更为引人注目。 Java 将序列( List )作为一个单独的接口提取出来,并提供了两个实现: ArrayList LinkedList 。从名字就可以看出来,前者是通过数组来实现的,后者则通过链表。由于都实现了 List 接口,二者可以支持同样的基本操作方式,不同的是, ArrayList 在频繁进行随机访问时有效率上的优势,而 LinkedList 在频繁进行插入和删除操作时效率较优。实现了 List 接口的类还有 Vector Stack ,但是它们在 Java 1.1 以后就被废弃了。由于 LinkedList 可以在序列的头尾插入和删除元素,它可以很好地实现 Stack Queue 的功能。
   Java 1.5 以前的版本中也不支持模板,因此 List (以及其他的容器)接受 Object 类型作为元素。由于在 Java 中所有的类都派生自 Object ,所以这些容器能够支持任何对象。对于不是对象的基本类型, Java 提供了一种包裹类 (wrapped class) ,它能够将基本类型转换成常规的类,从而获得容器的支持。这和 Delphi 的解决思路异曲同工。
  
   Hash
   作为一种抽象数据结构,词典( Dictionary )被定义为键 - (Key-Value) 对的集合。举例来说,在电话号码簿中,通过查找姓名,来找到电话号码,这个例子中姓名是 key ,电话号码是 value 。又比如,在学生花名册中,通过查找学号,来找到学生的姓名,这个例子中学号是 key ,学生的姓名是 value 。词典最常见的实现方式是 Hash 表。
   Hash 表的实现思路如下:通过某种算法,在键 - 值对的存储地址和键 - 值对中的 key 之间,建立一种映射,使得每一个 key ,都有一个确定的存储地址与之对应。这种算法被封装在 Hash 函数中。在查找时,通过 Hash 函数,算出和 key 对应的存储地址,从而找到相应的键 - 值对。相对于通过遍历整个键 - 值对列表来进行查找, Hash 表的查找效率要高得多,理想的情况下算法复杂度仅为 O(1) (遍历查找的复杂度为 O(n) )。
  但是,由于通常情况下 key 的集合比键 - 值对存储地址的集合要大得多,所以有可能把不同的 key 映射到同一个存储地址上。这种情况称为冲突( collision )。一个好的 Hash 函数应该尽可能地把 key 映射到均匀的地址空间中,以减少冲突。 Hash 表的实现也应该提供解决冲突的方案。
   Hash 表是一种相对复杂得多的数据结构,从底层完整地实现一个 Hash 表,也许超出了对一个普通程序员的要求。但是,由于它是如此重要,了解 Hash 表的概念和掌握使用它的接口,仍然是一项必不可少的技能。
   C 语言中没有提供现成的 Hash 表,但是 C++ 提供了优秀的 Hash 表实现容器 hash_map 。象 STL 中的其他容器一样, hash_map 支持任何数据类型,支持内存自动管理,能够自动增长。特别地, hash_map 通过模板机制,实现了和 hash 函数的剥离,也就是说,程序员可以定义自己的 hash 函数,交给 hash_map 去进行相应的工作。如下例:
  
   hash_map <string, int> hml; // 使用默认的 Hash<string> 函数
   hash_map <string, int, hfct> hml; // 使用自定义的 hfct() 作为 hash 函数
   hash_map <string, int, hfct, eql> hml; // 使用自定义的 hfct() 作为 hash 函数,并且使用自定义的 eql() 函数比较对象是否相等
  
   Java 定义了 Map 接口,抽象了关于 Map 的各种操作。在实现了 Map 接口的类中,有两种是 Hash 表: HashMap WeakHashMap HashTable Java 1.1 以后已被废弃)。后者用于实现所谓 标准映射 canonicalizing mappings ),和本文讨论的内容关系不大。 HashMap 接受任何类型的对象作为键 - 值对的元素,支持快速的查找。如下例:
  
   HashMap hm = new HashMap();
   hm.put("akey", "this is a word"); // 使用两个字符串作为键 - 值对
   String str = (String) hm.get("akey");
   System.out.println(str);
  
   HashMap hash 函数也是剥离的,但使用了另一种思路。在 Java 中,根类型 Object 定义了 hashCode() equals() 方法,由于任何类型的对象都派生自 Object ,所以它们都自动继承了这两个方法。用户自定义的类应该重载这两个方法,以实现自己的 hash 函数和比较函数。如果这两个函数没有被重载, Java 会使用 Object hashCode() equals() 方法,它们的默认实现分别是返回对象的地址,以及比较两个对象的地址是否相等。
  在 PHP 中,数组和 Hash 表合而为一了。从语法上看, PHP 中并没有 Hash 表这样的容器,而只支持数组。不同的是, PHP 中的数组不但支持使用数字下标进行索引,而且支持使用字符串下标进行索引。换句话说, PHP 中的数组支持使用键 - 值对作为数组的元素,并且可以使用键来进行索引 ( 键必须为 integer 类型或 string 类型 ) 。而且, PHP 中的数组支持自动增长和嵌套。如下例:
  
   $arr = array(1 => 12, "akey" => "this is a word");
   echo $arr[1]; // 得到 12
   echo $arr["akey"]; // 得到 "this is a word"
  
   PHP 没有提供自定义 hash 函数的接口。由于它不接受 integer string 以外的类型作为键,这一点事实上也没有必要。
  
  结束语
   当接受这篇文章的约稿时,我认为这是一项比较简单的工作。因为这三种数据结构实在是太基础了,所以我甚至怀疑是否能够写出足够长的篇幅。很快我就发现了自己的错误。光是字符串就够写一本书的。
   在撰写本文的过程,我回顾了学习过的大部分编程语言,重温了许多经典书籍中的相关章节,启动了各种 IDE 编写测试用例。我接触到了大量未知的领域,至今我仍然在猜测许多问题的实现细节。这从另外一个方面说明了基本数据结构的重要性:即使在我们最熟悉的事物中,也隐藏着极为深刻的原理。
  
  参考文献:
   K&R C 程序设计语言,第二版
   Bjarne Stroustrup C++ 程序设计语言,第三版
   Koenig & Moo C++ 沉思录
   Delphi Language Guide
   Bruce Eckel Thinking in Java ,第二版
   McLaughlin & Flanagan Java 5.0 Tiger 程序高手秘笈
   Jesse Liberty Programming C#
   W. Gilmore PHP MySQL 5 程序设计
   Lutz & David Ascher Learning Python ,第二版
   Alex Martelli Python in a Nutshell ,第二版
   Introduction to Algorithms ,第二版
  殷人昆等,数据结构(用面向对象和 C++ 描述)
   Joel Spolsky Joel 说软件

转载于:https://www.cnblogs.com/hya1109/archive/2008/03/07/1095821.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值