【从浅到深的算法技巧】排序应用,查找

5.8.4 排序应用一览

排序的直接应用极为普遍和广泛,无法一一列举。你可以将歌曲按照曲名或是歌手排序,将邮件按照时间或是发件人排序(或者来电按照时间或来电者排序),将照片按照日期排序。大学会将学生的账户按照姓名或是ID排序。信用卡公司会将上百万甚至上亿的交易按照日期或是金额排序。科学家会将实验数据按照时间或其他标准排序来精确地模拟现实世界,从粒子或者天体的运动,到物质的结构,到社会中的人际关系。实际上,很难找到和排序无关的任何计算性应用!为了更好地说明这一点,我们在这一小节中举几个比应用归约更加复杂的例子。

5.8.4.2 信息搜索

有序的信息确保我们可以用经典的二分查找法来进行高效的搜索。你会看到许多其他种类的查询也可以用相同的方式完成。有多少元素小于给定的元素?有哪些在给定的范围之内?我们不但会解答这些问题,还会具体学习排序算法和二分查找的各种扩展,使得我们能够用删除和插人的混合操作解答这些问题, 并保证所有操作的对数级别的性能。

5.8.4.3 运筹学

运筹学指的是研究数学模型并将其应用于问题解决和决策的领域。在本书中我们会看到若干运筹学和算法研究的关系的例子。这里我们先来看排序算法在运筹学的经典问题一调度中的应用。假设我们需要完成N个任务,第j个任务需要耗时,秒。我们需要在完成所有任务的同时尽量确保客户满意,将每个任务的平均完成时间最小化。按照最短优先的原则,只要我们将任务按照处理时间升序排列就可以达到目标。因此我们可以将任务按照耗时排序,或是将它们插入到一- 个最小优先队列中。如果加上其他各种限制,我们可以得到不同的调度问题,这在工业界的应用中很常见,也被很好地研究过。另一个例子是负载均衡问题。假设我们有M个相同的处理器以及N个任务,我们的目标是用尽可能短的时间在这些处理器上完成所有的任务。这个问题是NP-困难的,因此我们实际上不可能算出一种最优的方案。 但-种较优调度方法是最大优先。我们将任务按照耗时降序排列,将每个任务依次分配给当前可用的处理器。要实现这种算法,我们先要逆序排列这些任务,然后为M个处理器维护一个优先队列,每个元素的优先级就是对应的处理器上运行的任务的耗时之和。每-一步中, 我们都删去优先级最低的那个处理器,将下一个任务分配给这个处理器,然后再将它重新插入优先队列。

5.8.4.4 事件驱动模拟

很多科学上的应用都涉及模拟,用大量计算来将现实世界的某个方面建模以期能够更好地理解它。在计算机发明之前,科学家们除了构建数学模型之外别无选择,而现在计算机模型很好地补充了这些数学模型。逼真地模拟现实世界是很有挑战的,而使用正确的算法使得我们能够在有限的时间内完成这些模拟,而不是无奈地接受不精确的实验结果或是无尽地等待计算的完成。

5.8.4.5 数值计算

在科学计算中,精确度非常重要(我们距离真正的答案有多远),特别是当我们在计算机中使用的只是真正的实数的近似值——浮点数来进行上百万次计算的时候。一些数值计算算法使用优先队列和排序来控制计算中的精确度。例如,在求曲线下区域的面积时,数值积分的一个方法就是使用一个优先队列存储一组小间隔中每段的近似精确度。积分的过程就是删去精确度最低的间隔并将其分为两半(这样两半都能变得更加精确),然后将两半都重新加入优先队列。如此这般,直到达到预期的精确程度。

5.8.4.6 组合搜索

人工智能领域一个解决“疑难杂症”的经典范式就是定义一组状态、由一组状态演化到另一组状态可能的步骤以及每个步骤的优先级,然后定义一个起始状态和目标状态(也就是问题的解决办法)。著名的A*算法的解决办法就是将起始状态放人优先队列中,然后重复下面的方法直到到达目的地:删去优先级最高的状态,然后将能够从该状态在一一步 之内达到的所有状态全部加入优先队列(除了刚刚删去的那个状态之外)。和事件驱动模拟- _样, 这个过程简直就是为优先队列量身定做的。它将问题的解决转化为了定义-一个适当的优先级雨数问题。下面我们举应用作为例子,它们都依赖于排序算法和优先队列数据类型的高效实现。

1.Prim算法和Dijkstra算法

它们都是经典算法。主题是图的处理算法,图是由结点和连接两个结点的边组成的一种重要的基础模型。图算法的基石就是图的搜索,也就是一个结点一个结点地查找,优先队列在其中扮演了重要的角色。

2.Kruskal算法

这是图中的加权图的另一个经典算法, 其中边的处理顺序取决于它的权重。算法的运行时间是由排序所需的时间决定的。

3.霍夫曼压缩

这是一个经典的数据压缩算法。它处理的数据中的每个元素都有一个小整数作为权重,而处理的过程就是将权重最小的两个元素归并成一个新元素,并将其权重相加得到新元素的权重。使用优先队列可以立即实现这个算法。其他几种数据压缩算法也是基于排序的。

6.查找

现代计算机和网络使我们能够访问海量的信息。高效检索这些信息的能力是处理它们的重要前提。我们会使用符号表这个词来描述一张抽象的表格, 我们会将信息(值)存储在其中,然后按照指定的键来搜索并获取这些信息。键和值的具体意义取决于不同的应用。符号表中可能会保存很多键和很多信息,因此实现一张高效的符号表也是一项很有挑战性的任务。

符号表有时被称为字典,类似于那本将单词的释义按照字母顺序排列起来的历史悠久的参考书。在英语字典里,键就是单词,值就是单词对应的定义、发音和词源。符号表有时又叫做索引,即书本最后将术语按照字母顺序列出以方便查找的那部分。在一本书的索引中,键就是术语,而值就是书中该术语出现的所有页码。

在说明了基本的API和两种重要的实现之后,我们会学习用三种经典的数据类型来实现高效的符号表:二叉查找树、红黑树和散列表。在总结中我们会看到它们的若干扩展和应用,它们的实现都有赖于我们学到的高效算法。

6.1符号表

符号表最主要的目的就是将一个键 和一个值联系起来。用例能够将一个键值对插入符号表并希望在之后能够从符号表的所有键值对中按照键直接找到相对应的值。本章会讲解多种构造这样的数据结构的方法,它们不光能够高效地插入和查找,还可以进行其他几种方便的操作。要实现符号表,我们首先要定义其背后的数据结构,并指明创建并操作这种数据结构以实现插入、查找等操作所需的算法。

查找在大多数应用程序中都至关重要,许多编程环境也因此将符号表实现为高级的抽象数据结构,包括Java–我们会讨论Java的符号表实现。下表给出的例子是在一些典型的应用场景中可能出现的键和值。我们马上会看到一些参考性的用例。

定义:号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中:查找(get),即根据给定的键得到相应的值。

典型的符号表应用
应用 查找的目的
字典 找出单词的释义 单词 释义
图书索引 找出相关的页码 术语 一串页码
文件共享 找到歌曲的下载地址 歌曲名 计算机ID
账户管理 处理交易 账户号码 交易详情
网络搜索 找出相关网页 关键字 网页名称
编译器 找出符号的类型和值 变量名 类型和值
6.1.1 API

符号表是一种典型的抽象数据类型:它代表着一组定 义清晰的值以及相应的操作,使得我们能够将类型的实现和使用区分开来。和以前一样,我们要用应用程序编程接口( API)来精确地定义这些操作,为数据类型的实现和用例提供一份“契约”。

一种简单的泛型符号表API
public class ST
ST() 创建一张符号表
void put(Key key, value, val) 将键值对存人表中( 若值为空则将键key从表中删除)
Value get(Key key) 获取键key对应的值(若键key不存在则返回null )
void delete(Key key) 从表中删去键key (及其对应的值)
boolean contains(Key key) 键key在表中是否有对应的值
boolean isEmpty() 表是否为空
int size() 表中的键值对数量
Iterable keys() 表中的所有键的集合

在查看用例代码之前,为了保证代码的一致、简洁和实用,我们要先说明具体实现中的几个设计决策。

6.1.1.1 泛型

和排序一样,在设计方法时我们没有指定处理对象的类型,而是使用了泛型。对于符号表,我们通过明确地指定查找时键和值的类型来区分它们的不同角色,而不是像优先队列那样将键和元素本身混为一谈。 在考虑了这份基本的API后(例如,这里没有说明键的有序性),我们会用Comparable的对象来扩展典型的用例,这也会为数据类型带来许多新的方法。

6.1.1.2 重复的键

我们的所有实现都遵循以下规则:

1.每个键只对应着一个值 (表中不允许存在重复的键) ;

2.当用例代码向表中存入的键值对和表中已有的键(及关联的值)冲突时,新的值会替代旧的值。这些规则定义了关联数组的抽象形式。你可以将符号表想象成一个数组,键即索引,值即数组的元素。在一个一般的数组中,键就是整型的索引,我们用它来快速访问数组的内容;在一个关联数组(符号表)中,键可以是任意类型,但我们仍然可以用它来快速访问数组的内容。一些编程语言(非Java)直接支持程序员使用st[key]来代替st.get(key), st[key]=va]l来代替st.put(key,val), 其中key (键)和val (值)都可以是任意类型的对象。

6.1.1.3 空(null) 键

键不能为空。和Java中的许多其他机制一样, 使用空键会产生一个运行时异常。

6.1.1.4 空(null)值

我们还规定不允许有空值。这个规定的直接原因是在我们的API定义中,当键不存在时get()方法会返回空,这也意味着任何不在表中的键关联的值都是空。这个规定产生了两个(我们所期望的)结果:第一,我们可以用get()方法是否返回空来测试给定的键是否存在于符号表中;第二,我们可以将空值作为put()方法的第二个参数存人表中来实现删除。

6.1.1.5 删除操作

在符号表中,删除的实现可以有两种方法:延时删除,也就是将键对应的值置为空,然后在某个时候删去所有值为空的键;或是即时删除,也就是立刻从表中删除指定的键。刚才已经说过,put(key,null) 是delete(key)的一种简单的(延时型)实现。而实现(即时型) delete(就是为了替代这种默认的方案。在我们的符号表实现中不会使用默认的方案,而在本书的网站上put()实现的开头有这样一句防御性代码:

if (val == null) { 

   delete(key);

   return; 

} 

这保证了符号表中任何键的值都不为空。为了节省版面我们没有在本书中附上这段代码(我们也不会在调用put()时使用null)。

6.1.1.6 便捷方法

为了用例代码的清晰,我们在API中加入了contains()和isEmpty()方法,它们的实现如下表所示,只需要一行。

默认实现
方法 默认实现
void delete(Key key) put(key, null);
boolean contains(key) return get(key) != null;
boolean isEmpty() return size() == 0;
6.1.1.7 迭代

为了方便用例处理表中的所有键值,我们有时会在API的第一行加上implements Interable这句话,强制所有实现都必须包含iterator()方法来返回一个实现了hasNext()和next()方法的迭代器,如栈和队列所述。但是对于符号表我们采用了一个更简单的方法。我们定义了keys()方法来返回-个Interable对象以方便用例遍历所有的键。这么做是为了和以后的有序符号表的所有方法保持一致,使得用例可以遍历表的键集的一个指定的部分。

6.1.1.8 键的等价性

要确定一个给定的键是否存在于符号表中,首先要确立对象等价性的概念。在Java中,按照约定所有的对象都继承了一个equals()方法,Java 也为它的标准数据类型例如Integer. Double 和String以及一些更加复杂的类型, 如File和URL,实现了equals()方法一当使用这 些数据类型时你可以直接使用内置的实现。例如,如果x和y都是String类型,当且仅当x和y的长度相同且每个位置上的字母都相同时,x. equals(y)返回true。而自定义的键则需要重写equals(方法。你可以参考我们为Date类型实现的equals(方法为你自己的数据类型实现equals()方法。和优先队列一样, 最好使用不可变的数据类型作为键,否则表的一致性是无法保证的。

你可能感兴趣的:(从浅到深的算法技巧,算法,排序应用一览,查找)