CSC201 数据结构

第 6 章 设计

| 关于   英文版   «  6.3. 记录的比较   ::   目录   ::   7.1. 二叉树章节简介  »

6.4. 字典 ADT

6.4.1. 字典 ADT

计算机程序最常见的目标就是存储和取回数据。本书很大一部分内容是关于如何高效地组织数据记录集合,以便能够快速地存储和取回它们。本节我们描述这样一个集合的简单接口,称为 字典 。字典 ADT 提供了在集合中存储记录、查找记录和删除记录的操作。这个 ADT 为我们比较各种数据结构提供了一个标准基础。粗略地说,任何支持插入、查找和删除的数据结构都可以称为"字典"。

字典依赖于 查找键 和 可比较(comparable) 对象这两个概念。 为实现字典的查找功能,我们将要求键是 全序 的。 对天然是多维的字段(例如二维或三维空间中的点)进行排序, 如果我们想利用其多维特性,会带来特殊的机会。 这个问题由 空间数据结构 处理。

下面是用代码定义的一个简单抽象字典类。

/** The Dictionary abstract class. */
public interface Dictionary {

  /** Reinitialize dictionary */
  public void clear();

  /** Insert a record
      @param key The key for the record being inserted.
      @param elem The record being inserted. */
  public void insert(Comparable key, Object elem);

  /** Remove and return a record.
      @param key The key of the record to be removed.
      @return A maching record. If multiple records match
      "k", remove an arbitrary one. Return null if no record
      with key "k" exists. */
  public Object remove(Comparable key);

  /** Remove and return an arbitrary record from dictionary.
      @return the record removed, or null if none exists. */
  public Object removeAny();

  /** @return A record matching "k" (null if none exists).
      If multiple records match, return an arbitrary one.
      @param key The key of the record to find */
  public Object find(Comparable key);

  /** @return The number of records in the dictionary. */
  public int size();
}
/** The Dictionary abstract class. */
public interface Dictionary<K extends Comparable<K>, E> {

  /** Reinitialize dictionary */
  public void clear();

  /** Insert a record
      @param k The key for the record being inserted.
      @param e The record being inserted. */
  public void insert(K key, E elem);

  /** Remove and return a record.
      @param k The key of the record to be removed.
      @return A maching record. If multiple records match
      "k", remove an arbitrary one. Return null if no record
      with key "k" exists. */
  public E remove(K key);

  /** Remove and return an arbitrary record from dictionary.
      @return the record removed, or null if none exists. */
  public E removeAny();

  /** @return A record matching "k" (null if none exists).
      If multiple records match, return an arbitrary one.
      @param k The key of the record to find */
  public E find(K key);

  /** @return The number of records in the dictionary. */
  public int size();
}

方法 insert 和 find 是这个类的核心。方法 insert 接受一条记录并将其插入字典。方法 find 接受一个键值,并从字典中返回某个其键与所提供的值相匹配的记录。如果字典中有多条记录具有该键值,则不规定返回哪一条。

方法 clear 只是重新初始化字典。方法 remove 与 find 类似,只是它还会把从字典中返回的记录删除。同样,如果字典中有多条记录匹配所需的键,则不规定实际删除并返回的是哪一条。方法 size 返回字典中元素的数目。

剩下的方法是 removeAny 。它与 remove 类似,只是它不接受键值。相反,它从字典中删除一条任意记录(如果存在的话)。这个方法的目的是让用户能够遍历字典中的所有元素(当然,在此过程中字典会变为空)。如果没有 removeAny 方法,字典用户就无法访问他们尚不知道键值的字典记录。有了 removeAny 方法,用户就可以处理字典中的所有记录,如下面的代码片段所示。

while (dict.size() > 0) {
  Object it = dict.removeAny();
  doSomething(it);
}

还有其他一些似乎更自然的遍历字典的方法,例如使用 "first" 和 "next" 函数。但并非我们想用来实现字典的所有数据结构都能高效地完成 "first"。例如,哈希表实现无法高效地定位表中键值最小的记录。通过使用 RemoveAny ,我们获得了一种提供通用访问的机制。

给定一个存储某种特定类型记录的数据库,我们可能想用多种方式查找记录。例如,我们可能想把工资单记录存储在一个允许按 ID 查找的字典中,同时把这些相同的记录存储在第二个允许按姓名查找的字典中。

下面是工资单记录的一个实现。

/** A simple payroll entry with ID, name, address fields */
public class Payroll {

  private Integer ID;
  private String name;
  private String address;

  /** Constructor */
  Payroll(int inID, String inname, String inaddr) {
    ID = inID;
    name = inname;
    address = inaddr;
  }

  /** Data member access functions */
  public Integer getID() { return ID; }
  public String getname() { return name; }
  public String getaddr() { return address; }
}

类 Payroll 有多个字段,每个字段都可以用作查找键。只需改变键的类型,并在每条记录中使用适当的字段作为键值,我们就能定义一个以 ID 字段为查找键的字典、一个以姓名字段为查找键的字典,以及一个以地址字段为查找键的字典。下面是一个示例,其中 Payroll 对象存储在两个单独的字典中,一个使用 ID 字段作为键,另一个使用姓名字段作为键。

// IDdict organizes Payroll records by ID
Dictionary IDdict = new UALDictionary();

// namedict organizes Payroll records by name
Dictionary namedict = new UALDictionary();

Payroll foo1 = new Payroll(5, "Joe", "Anytown");
Payroll foo2 = new Payroll(10, "John", "Mytown");

IDdict.insert(foo1.getID(), foo1);
IDdict.insert(foo2.getID(), foo2);
namedict.insert(foo1.getname(), foo1);
namedict.insert(foo2.getname(), foo2);

Payroll findfoo1 = (Payroll)IDdict.find(5);
Payroll findfoo2 = (Payroll)namedict.find("John");

就目前的写法而言,这个示例的一个问题是, 字典依赖于程序员自觉保持键的一致性。 这些字典本应拥有 同构(homogeneity) 元素。 但没有什么能阻止程序员向姓名字典中插入一个整数键, 或者用一个整数查找键去查找。 这个问题可以通过使用 C++ 模板或 Java 泛型来处理。

字典的基本操作是查找与给定键相匹配的记录。 这就引出了如何从记录中 提取键(comparable) 的问题。 我们通常假定字典实现存储 键值对 , 以便能够提取与该字典中某条记录相关联的键。

字典类的 insert 方法支持键值对的实现,因为它接受两个参数:一条记录以及该记录在该字典中关联的键。

既然我们已经定义了字典 ADT,并确定了为字典条目存储键值对这一设计方法,就可以考虑实现它的方式了。两种可能的方式是使用基于数组的线性表或链表。下面是使用(未排序的)基于数组的线性表实现的字典。

// Dictionary implemented by unsorted array-based list.
public class UALDictionary implements Dictionary {
  private static final int defaultSize = 10; // Default size
  private AList list;                        // To store dictionary

  // Constructors
  UALDictionary() { this(defaultSize); }
  UALDictionary(int sz) { list = new AList(sz); }

  // Reinitialize
  public void clear() { list.clear(); }

  // Insert an element: append to list
  public void insert(Comparable k, Object e) {
    KVPair temp = new KVPair(k, e);
    list.append(temp);
  }

  // Use sequential search to find the element to remove
  public Object remove(Comparable k) {
    Object temp = find(k);
    if (temp != null) { list.remove(); }
    return temp;
  }

  // Remove the last element
  public Object removeAny() {
    if (size() != 0) {
      list.moveToEnd();
      list.prev();
      KVPair e = (KVPair)list.remove();
      return e.value();
    }
    else { return null; }
  }

  // Find k using sequential search
  // Return the record with key value k
  public Object find(Comparable k) {
    for(list.moveToStart(); list.currPos() < list.length();
        list.next()) {
      KVPair temp = (KVPair)list.getValue();
      if (k.compareTo(temp.key()) == 0) {
        return temp.value();
      }
    }
    return null; // "k" does not appear in dictionary
  }

  // Return list size
  public int size() { return list.length(); }
}

考察类 UALdict (UAL 表示"未排序的基于数组的线性表"),我们很容易看出 insert 是一个常数时间操作,因为它只是把新记录插入到线性表的末尾。然而, find 和 remove 在平均情况和最坏情况下都需要 \(\Theta(n)\) 时间,因为我们需要进行顺序查找。方法 remove 尤其必须触及线性表中的每条记录,因为一旦找到所需记录,其余记录就必须在线性表中向下移动以填补空缺。方法 removeAny 删除线性表中的最后一条记录,所以这是一个常数时间操作。

作为替代方案,我们可以用链表来实现字典。其实现与 UALDictionary 的实现相当类似,各函数的开销在渐近意义下应当相同。

另一种替代方案是用有序线性表来实现字典。这种方法的优点是,我们或许能够通过使用二分查找来加速 find 操作。为此,我们首先必须定义 List ADT 的一个变体以支持有序线性表。有序线性表与未排序线性表有些不同,它不允许用户控制元素插入的位置。因此, insert 方法在有序线性表中必须与在未排序线性表中相当不同。同样,也不允许用户向线性表追加元素。由于这些原因,有序线性表无法通过从 List ADT 直接继承来实现。

对于长度为 \(n\) 的线性表,有序线性表中 find 的开销是 \(\Theta(\log n)\) 。这比未排序线性表中 find 的开销有了很大改进。遗憾的是, insert 的开销从未排序线性表中的常数时间变为有序线性表中的 \(\Theta(n)\) 时间。字典 ADT 的有序线性表实现是否比未排序线性表实现更高效,取决于要执行的 insert 和 find 操作的相对数量。如果使用的 find 操作远多于 insert 操作,那么用有序线性表来实现字典可能是值得的。在两种情况下, remove 在最坏和平均情况下都需要 \(\Theta(n)\) 时间。即使我们使用二分查找来减少删除前查找记录的时间,我们仍然需要在线性表中向下移动其余记录,以填补 remove 操作留下的空缺。

搜索树(search tree) 是能够在 \(\Theta(\log n)\) 时间内 执行插入、查找和删除这三种关键操作的结构。

   «  6.3. 记录的比较   ::   目录   ::   7.1. 二叉树章节简介  »

关闭窗口