铁文整理
13.4 算法
泛型集合接口有一个很大的优点,即算法只需要实现—次。例如,考虑一下计算集合中最大元素这样一个简单的算法。使用传统方式,程序设计人员可能会用循环实现这个算法。下面就是找出数组中最大元素的代码。
……
当然,为找出数组列表中的最大元素所编写的代码会与此有微小的差别。
……
链表应该怎么做呢?对于链表来说,无法实施高效的随机访问,但却可以使用迭代器。
……
编写这些循环代码有些乏味,并且也很容易出错。是否存在严重错误吗?对于空容器循环能正常工作吗?对于只含有一个元素的容器又会发生什么情况呢?我们不希望每次都测试和调试这些代码,也不想实现下面这一系列的方法:
static <T extends Comparable> T max(T[] a)
static <T extends Comparable> T max(ArrayList<T> v)
static <T extends Comparable> T max(LinkedList<T> v)
这正是集合接口的用武之地。仔细考虑一下,为了高效地使用这个算法所需要的最小集接口。采用get和set方法进行随机访问要比直接迭代层次高。在计算链表中最大元素的过程中已经看到,这项任务并不需要进行随机访问,直接用迭代器遍历每个元素就可以计算最大元素。因此,可以将max方法实现为能够接收任何实现了Collection接口的对象。
public static <T extends Comparable> T max(Collection<T> c) {
if (c.isEmpty())
throw new NoSuchElementException();
Iterator<T> iter = c.iterator();
T largest = iter.next();
while (iter.hasNext()) {
T next = iter.next();
if (largest.compareTo(next) < 0)
largest = next;
}
return largest;
}
现在就可以使用一个方法计算链表、数组列表或数组中最大元素了。
这是一个非常重要的概念。事实上,标准的C++类库已经有几十种非常有用的算法,每个算法都是在泛型集合上操作的。Java类库中的算法没有如此丰富,但是,也包含了基本的排序、二分査找等实用算法。
13.4.1 排序与混排
计算机行业的前辈们有时会回忆起他们当年不得不使用穿孔卡片以及手工地编写排序算法的情形。当然,如今排序算法已经成为大多数编程语言标准库中的一个组成部分,Java程序设计语言也示例外。
Collection类中的sort方法可以对实现了List接口的集合进行排序。
List<String> staff = new LinkedList<String>();
// fill collection ...;
Collections.sort(staff);
这个方法假定列表元素实现了Comparable接口。如果想采用其他方式对列表进行排序,可以将Comparator对象作为第二个参数传递给sort方法。(已经在前面的章节中介绍过比较器),下面的代码说明了对列表中各项进行排序的基本方法:
Comparator<Item> itemComparator = new Comparator<Item>() {
public int compare(Item a, Item b) {
return a.partNumber - b.partNumber;
}
};
Collections.sort(items, itemComparator);
如果想按照降序对列表进行排序,可以使用一种非常方便的静态方法Collections.reverseOrder()。这个方法将返回一个比较器,比较器则返回b.compareTo(a)。例如,Collections.sort(items, Collections.reverseOrder());这个方法将根据元素类型的compareTo方法给定排序顺序,按照逆序对列表staff进行排序。
同样,Collections.sort(items, Collections.reverseOrder(itemComparator));将逆置itemComparator的次序。
人们可能会对sort方法所采用的排序手段感到好奇。通常,在翻阅有关算法书籍中的排序算法时,会发觉介绍的都是有关数组的排序算法,而且使用的是随机访问方式,但是,对列表进行随机访问的效率很低。实际上,可以使用归并排序对列表进行高效的排序(例如,可以参看Addison Wesley出版社1998年出版的Robert Sedgewick编写的《Algorithms in C++》第366~369页)。然而,Java程序设计语言并不是这样实现的。它直接将所有元素转入一个数组,并使用一种归并排序的变体对数组进行排序,然后,再将排序后的序列复制回列表。
集合类库中使用的归并排序算法比快速排序要慢一些,快速排序是通用排序算法的传统选择。但是,归并排序有一个主要的优点:稳定,即不需要交换相同的元素。为什么要关注相同元素的顺序呢?下面是一种常见的情况。假设有一个已经按照姓名排列的员工列表。现在,要按照工资再进行排序。如果两个雇员的工资相等发生什么情况呢?如果采用稳定的排序算法,将会保留按名字排列的顺序。换句话说,排序的结果将会产生这样一个列表,首先按照工资排序,工资相同者再按照姓名排序。
因为集合不需要实现所有的“可选”方法,因此,所有接受集合参数的方法必须描述什么时候可以安全地将集合传递给算法。例如,显然不能将unmodifiableList列表传递给排序算法。可以传递什么类型的列表呢?根据文档说明,列表必须是可修改的,但不必是可以改变大小的。
下面是有关的术语定义:
-
如果列表支持set方法,则是可修改的。
-
如果列表支持add和remove方法,则是可改变大小的。
Collections类有一个算法shuffle,其功能与排序刚好相反,即随机地混排列表中元素的顺序。例如:
ArrayList<Card> cards = ...;
Collections.shuffle(cards);
如果提供的列表没有实现RandomAccess接口,shuffle方法将元素复制到数组中,然后打乱数组元素的顺序,最后再将打乱顺序后的元素复制回列表。
例13-6的程序用1~49之间的49个Integer对象填充数组。然后,随机地打乱列表,并从打乱后的列表中选前6个值。最后再将选择的数值进行排序和打印。
例13-6 ShuffleTest.java
import java.util.*;
/**
* This program demonstrates the random shuffle and sort algorithms.
*
* @version 1.10 2004-08-02
* @author Cay Horstmann
*/
public class ShuffleTest {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<Integer>();
for (int i = 1; i <= 49; i++)
numbers.add(i);
Collections.shuffle(numbers);
List<Integer> winningCombination = numbers.subList(0, 6);
Collections.sort(winningCombination);
System.out.println(winningCombination);
}
}
API:java.util.Collections 1.2
-
static <T extends Comparable<? super T>> void sort(List<T> elements)
-
static <T> void sort(List<T> elements, Comparator<? super T> c):使用稳定的排序算法,对列表中的元素进行徘序,这个算法的时间复杂度是O(nlogn),其中n为列表的长度。
-
static void shuffle(List<?> elements)
-
static void shuffle(List<?> elements, Random r):随机地打乱列表中的元素。这个算法的时间复杂度是O(na(n)),n是列表的长度,a(n)是访问元素的平均时间。
-
static <T> Comparator<T> reverseOrder():返回一个比较器,它用与Comparable接口的compareTo方法规定的顺序的逆序对元素进行排序。
-
static <T> Comparator<T> revevseOrder(Comparator<T> comp):返回一个比较器,它用与comp给定的顺序的逆序对元素进行排序。
13.4.2 二分査找
要想在数组中查找一个对象,通常要依次访问数组中的每个元素,直到找到匹配的元素为止。然而,如果数组是有序的,就可以直接査看位于数组中间的元素,看一看是否大于要査找的元素。如果是,用同样的方法在数组的前半部分继续查找,否则,用同样的方法在数组的后半部分继续查找。这样就可以将査找范围缩减一半。一直用这种方式査找下去。例如,如果数组中有1024个元素,可以在10次比较后定位所匹配的元素(或者可以确认在数组中不存在这样的元素),而使用线性查找,如果元素存在,平均需要512次比较,如果元素不存在,需要1024次比较才可以确认。
Collections类的binarySearch方法实现了这个算法。注意,集合必须是排好序的,否则算法将返回错误的答案。要想査找某个元素,必须提供集合(这个集合要实现List接口,下面还要更加详细地介绍这个问题)以及要查找的元素。如果集合没有采用Comparable接口的compareTo方法进行排序,就还要提供一个比较器对象。
i = Collections.binarySearch(c, elements);
i = Collections.binarySearch(c, elements, comparator);
如果binarySearch方法返回的数值大于等于0,则表示匹配对象的索引。也就是说,c.get(i)等于在这个比较顺序下的elemem。如果返回负值,则表示没有匹配的元素。但是,可以利用返回值计算应该将elemem插入到集合的哪个位置,以保持集合的有序性。插入的位置是
insertionPoint = -i - 1;
这并不是简单的-i,因为0值是不确定的。也就是说,下面这个操作:
if (i < 0)
c.add(-i - 1, element);
将把元素插入到正确的位置上。
只有采用随机访问,二分査找才有意义。如果必须利用迭代方式一次次地遍历链表的一半元素来找到中间位置的元素,二分査找就完全失去了优势。因此,如果为binarySearch算法提供一个链表,它将自动地变为线性査找。
注释:在Java SE 1.3中,没有为有序集合提供专门的接口,以进行高效地随机访问,而binarySearch方法使用的是一种拙劣的策略,即检查列表参数是否扩展了AbstractSequentialList类,这个问题在Java SE 1.4中得到了解决。现在binarySearch方法检查列表参数是否实现了RandomAccess接口。如果实现了这个接口,这个方法将采用二分查找;否则,将采用线性査找。
API:java.util.Collections 1.2
-
static <T extends Comparable<? super T>> int binarySearch(List<T> elements, T key)
-
static <T> int binarySearch(List<T> elements, T key, Comparator<? super T> c):从有序列表中搜索一个键,如果元素扩展了AbstractSequentialList类,则采用线性査找,否则将采用二分査找。这个方法的时间复杂度为O(a(n)logn),n是列表的长度,a(n)是访问一个元素的平均时间。这个方法将返回这个键在列表中的索引,如果在列表中不存在这个键将返回负值i,在这种情况下,应该将这个键插入到列表索引-i-1的位置上,以保持列表的有序性。
13.4.3 简单算法
在Collections类中包含了几个简单且很有用的算法。前面介绍的査找集合中最大元素的示例就在其中。另外还包括:将一个列表中的元素复制到另外一个列表中,用一个常量值填充容器;逆置一个列表的元素顺序。为什么会在标准库中提供这些简单算法呢?大多数程序员肯定可以很容易地采用循环实现这些算法。我们之所以喜欢这些算法是因为:它们可以让程序员阅读算法变成一件轻松的事情。当阅读由别人实现的循环时,必须要揣縻编程者的意图。而在看到诸如Collections.max这样的方法调用时,一定会立刻明白其用途。
下面的API注释描述了C1]如i加5类的一些简单算法。
API:java.util.Collections 1.2
-
static <T extends Comparable<? super T>> T min(Collection<T> elements)
-
static <T extends Comparable<? super T>> T max(Conection<T> elements)
-
static <T> min(Conection<T> elements, Comparator<? super T> c)
-
static <T> max(Collection<T> elements, Comparator<? super T> c):返回集合中最小的或最大的元素(为清楚起见,参数的边界被简化了)。
-
static <T> void copy(List<? super T> to, List<T> from):将原列表中的所有元素复制到目标列表的相应位置上。目标列表的长度至少与原列表一样。
-
static <T> void fill(List<? super T> l, T value):将列表中所有位置设置为相同的值。
-
static <T> boolean addAll(Conection<? super T> c, T... values) 5.0:将所有的值添加到集合中。如果集合改变了,则返回true。
-
static <T> boolean replaceAll(List<T> l, T oldValue, T newValue) 1.4:用newValue取代所有值为oldValue的元素。
-
static int indexOfSubList(List<?> l, List<?> s) 1.4
-
static int lastIndexOfSubList(List<?> l, List<?> s) 1.4:返回l中第一个或最后一个等于s子列表的索引。如果l中不存在等于s的子列表,则返回-1。例如,l为[s,t,a,r],s为[t,a,r],两个方法都将返回索引1。
-
static void swap(List<?> l, int i, int j) 1.4:交换给定偏移量的两个元素。
-
static void reverse(List<?> l):逆置列表中元素的顺序。例如,逆置列表[t,a,r]后将得到列表[r,a,t]。这个方法的时间复杂度为O(n),n为列表的长度。
-
static void rotate(List<?> l, int d) 1.4:旋转列表中的元素,将索引i的条目移动到位置(i+d)%l.zise。例如,将列表[r,a,r]旋转移2个位置后得到[a,r,t]。这个方法的时间复杂度为O(n),n为列表的长度。
-
static int frequency(Collection<?> c, Object o) 5.0:返回c中与对象o相同的元素个数。
-
boolean disjoint(Collection<?> c1, Collection<?> c2) 5.0:如果两个集合没有共同的元素,则返回true。
13.4.4 编写自己的算法
如果编写自己的算法(实际上,是以集合作为参数的任何方法),应该尽可能地使用接口,而不要使用具体的实现。例如,假设想用一组菜单项填充JMenu。传统上,这种方法可能会按照下列方式实现:
void fillMenu(JMenu menu, ArrayList<JMenuItem> items) {
for (JMenuItem item : items)
menu.addItem(item);
}
但是,这样会限制方法的调用程序,即调用程序必须在ArrayList中提供选项,如果这些选项需要放在另一个容器中,首先必须对它们重新包装,因此,最好接受一个更加通用的集合。
什么是完成这项工作的最通用的集合接口?在这里,只需要访问所有的元素,这是Collection接口的基本功能。下面代码说明了如何重新编写fillMenu方法使之接受任意类型的集合。
void fillMenu(JMenu menu, Collection<JMenuItem> items) {
for (JMenuItem item : items)
menu.addItem(item);
}
现在,任何人都可以用ArrayList或LinkedList,甚至用Arrays.asList包装器包装的数组调用这个方法。
注释:既然将集合接口作为方法参数是个很好的想法,为什么Java类库不更多地这样做呢?例如,JComboBox又有两个构造器:
JComboBox(Object[] items)
JConboBox(Vector<?> items}
之所以没有这样做,原因很简单:时间问题。Swing类库是在集合类库之前创建的。
如果编写了一个返回集合的方法,可能还想要一个返回接口,而不是返回类的方法,因为这样做可以在日后改变想法,并用另一个集合重新实现这个方法。例如,编写一个返回所有菜单项的方法getAllItems。
List<MenuItem> getAllItems(JMenu menu) {
ArrayList<MenuItem> items = new ArrayList<MenuItem>();
for (int i = 0; i < menu.getItemCount(); i++)
items.add(menu.getItem(i));
return items;
}
日后,可以做出这样的决定:不复制所有的菜单项,而仅仅提供这些菜单项的视图。要做到这一点,只需要返回AbstractList的匿名子类。
List<MenuItem> getAllItems(JMenu menu) {
return new AbstractList<MenuItem>() {
public MenuItem get(int i) {
return item.getItem(i);
}
public int size() {
return item.getItemCount();
}
};
}
当然,这是一项高级技术,如果使用它,就应该将它支持的那些“可选”操作准确地记录在文档中。在这种情况下,必须提醒调用者返回的对象是一个不可修改的列表。