关于Java集合类的算法
Java的集合框架除了提供了一些集合类的实现以外,还提供了一些有用的算法。在本随笔中,我们将讨论其中的一些算法的使用,以及用自己的算法思想编写适用于集合框架的算法。
1、使用泛型算法的原因
泛型集合接口有一个非常明显的优点就是算法只需要实现一次。举一个例子:考虑一下计算集合中的最大元素的简单算法。如果使用 传统的方式,程序员可能会用循环实现这个算法,如下:
1 if (a.length==0) throw new NoSuchElementException(); 2 T largest = a[0]; 3 for (int i = 0;i) 4 if(largest.compareTo(a[i]) < 0) 5 largest = a[i];
对于查询数组列表中的最大元素,相关的代码也会有所不同。对于链表,虽然链表没有高效的随机访问操作,但是我们却可以使用链表的迭代器。
1 if(l.isEmpty()) throw new NoSuchElementException(); 2 Iteratoriter = l.iterator(); 3 T largest = iter,next() 4 while(iter.hasNext()) 5 { 6 T next = iter.next(); 7 if(largest.compareTo(next)) 8 largest = next; 9 }
编写这些循环很繁琐,并且容易出错,我们显然不想每次都测试和调试这些代码,也不想实现如下的一系列的方法:
1 staticextends Comparable> T max(T[] a) 2 static extends Comparable> T max(ArryList v) 3 static extends Comparable> T max(LinkList l)
这里就可以使用集合接口。为了高效地执行这些算法所需要的最小的集合接口,采用get和set方法的随机访问要比直接迭代层次要高。在计算链表中最大元素的过程中已经发现,该项任务不需要随机访问。可以直接迭代处理元素来得出最大值。因此,可以将max算法实现为能够接受任何实现了Collection接口的对象:
1 public staticextends Comparable> T max(Clloection c) 2 { 3 if (c.isEmpty()) throw new NoSuchElementException() 4 Iterator iter = c.iterator(); 5 T largest = iter.next(); 6 while(iter.next()) 7 { 8 T next = iter.next(); 9 if(largest.compareTo(next)<0) 10 largest = next; 11 } 12 return largest; 13 }
至此,就可以使用一个方法计算链表、数组列表或数组中的最大元素了。这是功能强大的概念,事实上,标准C++类库已经有几十种非常有用的算法,每个算法都应用于泛型集合。Java类库中的算法虽然没有这么这么丰富,但是也包含了一些基本的算法:排序、二分查找……
2、排序与混排
如今的排序算法已经成为大多数编程语言库中的一个组成部分,Java程序设计语言也是如此。
Collections类中的sort方法可以实现对实现了List接口的集合进行排序。
1 var staff = new LinkedList(); 2 fill collection 3 Collections.sort(staff);
这个方法假定列表元素实现了Comparable接口。如果想采用其它的方法对列表进行排序,可以使用List接口的sort方法并传入一个Comparetor对象。比如:可以按工资对一个员工列表排序:
1 satff.sort(Comparator.comparingDouble(Employee::getSalary));
如果像按照降序的方式对列表进行排序,就可以使用静态的便利方法Collections.reverseOrder()。这个方法将返回一个比较器,比较器则返回b.compareTo(a)。例如;
1 staff.sort(Comparator.reverseOrder())
这个方法将根据元素类型的compareTo方法所给定的排序顺序,按逆序对列表satff中的元素进行排序,同理可得,可以用以下的方法将员工的工资逆序排序:
1 staff.sort(Comparator.comparingDouble(Employee::getSalary).reversed())
sort方法可能引起人们的好奇。通常,在查看有关算法的书籍中的排序算法时,查阅到都是有关数组排序的算法,而且用的通常都是依赖于随即访问的方式。但是链表的随机访问的效率都非常低,实际上,可以使用一种归并排序对链表进行高效率的排序。但是Java语言并不是这么做的,Java将所有元素转入一个数组,对数组进行排序,随后再将已经排序好的数组复制到列表当中去。
集合类库中 的使用的排序算法要慢一些,快速排序是一种通用排序算法的传统选择。但是,归并排序有一个主要的优点:归并排序是稳定的,也就是说,它不会改变相等元素的顺序。用一个例子来说明为什么要关注相等元素的顺序:假设有一个已经按员工姓名顺序排序好的员工列表,现在,要按照工资再进行排序。如果有两个员工的工资是一样的,那么用稳定的排序算法排序获得的员工列表,工资相等的员工将会是局部按照名字顺序排序的。这个原因相信是大家在数据结构与算法课程当中,老师没有提及的。
因为集合不需要实现所有的“可选”方法,因此,所有接受集合参数的方法必须描述什么时候可以安全地将集合传递给算法。那么可以传递什么类型的列表呢?根据文档说明,列表必须是可修改的,但不一定可以改变大小。
下面是相关的术语的定义:
- 如果列表支持set方法,则是可以修改的。
- 如果列表支持add和remove方法,则是可改变大小的。
Collections类有一个算法shuffle,其功能与排序刚好相反,它会随机地混排列表中的元素地顺序,例如:
1 ArryList= cards = ...; 2 Collections.shuffle(cards);
如果提供的列表没有实现RandomAccess接口,shuffle方法将会将元素复制到数组中,然后打乱数组元素的顺序,最后再将打乱顺序后的元素复制回列表。下面举一个简单的例子,例子中用1~49之间的49个Integer对象填充数组中,然后打乱数组元素的顺序,最后再将打乱后的列表选前六个值。最后再将选择的数值进行排序并打印。
1 public class Shuffle 2 { 3 public static void main(String[] args) 4 { 5 var numbers = new ArrayList(); 6 for(int i = 1;i <= 49 ;i++) 7 numbers.add(i); 8 Collections.shuffle(numbers); 9 List winningCombination = numbers.subList(0,6); 10 Collections.sort(winningCombination); 11 System.out.println(winningCombination); 12 } 13 }
3、二分查找
想要在数组中查找一个对象,通常要依次访问数组中的每一个元素,知道查找到匹配的元素为止。不过,如果数组有序的,可以查看中间的元素,判断是否大于要查找的元素。如果是,就在数组的前半部分继续查找,否则,就在数组的后半部分继续查找。这样就可以将问题规模缩减一半,并以同样的方式继续下去。
Collcetions类的binarySearch方法实现了这个算法。注意,集合必须是有序的,否则算法会返回错误的答案。想要查找某个元素,必须提供集合。如果集合没有采用Comparable接口的compareTo方法进行排序,那么还要提供一个比较器对象。
1 i = Collcetions.binarySearch(c,element); 2 i = Collections.binarySearch(c,element,comparator);
如果binarySearch方法返回一个非负的值,这表示匹配对象的索引。也就是说,c.get(i)等于这个比较顺序下的element。如果返回负值,则表示没有匹配的元素。但是可以利用返回值来计算应该将element插入到集合的哪个位置,以保持集合的有序性。
只有采用随机访问马,二分查找才会有意义。如果必须利用迭代方式查找链表的一般元素来找到中间元素,二分查找就完全失去了优势,因此,如果为binarySearch算法提供一个链表,它将自动地退化为一个线性链表。
4、批操作
很多操作会“成批”复制或删除元素,所以调用:
1 coll.remove(coll2)
将从coll1中删除coll2中出现的所有元素,与之相反:
coll.retainAll(coll2)
会从coll中删除所有未在coll2出现的元素。
假设需要找出两个集的交集,首先建立一个空集来存放结果:
1 var result = new HashSet(firstSet)
我们首先应该明白:每一个集合都有一个自己的构造器,其参数就是另外一个集合。现在使用retainAll方法:
1 result.retainAll(secondSet)
这会保留两个集合中都出现的所有元素。这样就可以不需要循环就构成了交集。通过这个方法,我们还可以求两个集合的差、补、对称差。
5、编写自己的算法
如果编写自己的以集合作为参数的算法,应该尽可能不要使用具体的实现而是使用接口。例如,想处理集合元素,可以理所应该的使用下面的方法:
1 public void processItem(ArrayList- items) 2 { 3 for(Item item:items) 4 do something 5 }
不过,这样会限制方法的调用者必须在ArrayList中提供元素。但是最好接受一个更加通用的集合,因为如果这些元素正好在另一个集合中,首先必须对它们重新包装。
在完成这项任务的时候最先考虑的问题就是最通用的集合接口是什么?其次应该思考的是,这个任务对顺序的依赖大吗?如果大,那么应该接受的是List,如果顺序不重要,那么就可以接受任意类型的集合,如下:
1 public void processItem(Collection- items) 2 { 3 for(Item item:items) 4 do something 5 }
相反,如果自己设计的算法返回多个元素,那么出现限制改进的情况肯定是不希望看见的,例如,如下的代码:
1 public ArrayList- lookupItem(...) 2 { 3 var result = new ArrayList
- (); 4 ... 5 return result; 6 }
这个方法承诺返回一个ArrayList,尽管调用者并不关心这是一个什么类型的列表。如果返回一个List,任何时候都可以通过调用List.of返回一个空列表或者单例列表。