Java编程的逻辑 (53) - 剖析Collections - 算法
之前几节介绍了各种具体容器类和抽象容器类,上节我们提到,Java中有一个类Collections,提供了很多针对容器接口的通用功能,这些功能都是以静态方法的方式提供的。
都有哪些功能呢?大概可以分为两类:
- 对容器接口对象进行操作
- 返回一个容器接口对象
对于第一类,操作大概可以分为三组:
- 查找和替换
- 排序和调整顺序
- 添加和修改
对于第二类,大概可以分为两组:
- 适配器:将其他类型的数据转换为容器接口对象
- 装饰器:修饰一个给定容器接口对象,增加某种性质
它们都是围绕容器接口对象的,第一类是针对容器接口的通用操作,这是我们之前在接口的本质一节介绍的面向接口编程的一种体现,是接口的典型用法,第二类是为了使更多类型的数据更为方便和安全的参与到容器类协作体系中。
由于内容比较多,我们分为两节,本节讨论第一类,下节我们讨论第二类。下面我们分组来看下第一类中的算法。
查找和替换
查找和替换包含多组方法,我们分别来看下。
二分查找
我们在剖析Arrays类的时候介绍过二分查找,Arrays类有针对数组对象的二分查找方法,Collections提供了针对List接口的二分查找,如下所示:
public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key) public static <T> int binarySearch(List<? extends T> list, T key, Comparator<? super T> c)
从方法参数,容易理解,一个要求List的每个元素实现Comparable接口,另一个不需要,但要求提供Comparator。
二分查找假定List中的元素是从小到大排序的。如果是从大到小排序的,也容易,传递一个逆序Comparator对象,Collections提供了返回逆序Comparator的方法,之前我们也用过:
public static <T> Comparator<T> reverseOrder() public static <T> Comparator<T> reverseOrder(Comparator<T> cmp)
比如,可以这么用:
List<Integer> list = new ArrayList<>(Arrays.asList(new Integer[]{ 35, 24, 13, 12, 8, 7, 1 })); System.out.println(Collections.binarySearch(list, 7, Collections.reverseOrder()));
输出为:
5
List的二分查找的基本思路与Arrays中的是一样的,但,数组可以根据索引直接定位任意元素,实现效率很高,但List就不一定了,我们来看它的实现代码:
public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key) { if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD) return Collections.indexedBinarySearch(list, key); else return Collections.iteratorBinarySearch(list, key); }
分为两种情况,如果List可以随机访问(如数组),即实现了RandomAccess接口,或者元素个数比较少,则实现思路与Arrays一样,调用indexedBinarySearch根据索引直接访问中间元素进行查找,否则调用iteratorBinarySearch使用迭代器的方式访问中间元素进行查找。
indexedBinarySearch的代码为:
private static <T> int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) { int low = 0; int high = list.size()-1; while (low <= high) { int mid = (low + high) >>> 1; Comparable<? super T> midVal = list.get(mid); int cmp = midVal.compareTo(key); if (cmp < 0) low = mid + 1; else if (cmp > 0) high = mid - 1; else return mid; // key found } return -(low + 1); // key not found }
调用list.get(mid)访问中间元素。
iteratorBinarySearch的代码为:
private static <T> int iteratorBinarySearch(List<? extends Comparable<? super T>> list, T key) { int low = 0; int high = list.size()-1; ListIterator<? extends Comparable<? super T>> i = list.listIterator(); while (low <= high) { int mid = (low + high) >>> 1; Comparable<? super T> midVal = get(i, mid); int cmp = midVal.compareTo(key); if (cmp < 0) low = mid + 1; else if (cmp > 0) high = mid - 1; else return mid; // key found } return -(low + 1); // key not found }
调用get(i, mid)寻找中间元素,get方法的代码为:
private static <T> T get(ListIterator<? extends T> i, int index) { T obj = null; int pos = i.nextIndex(); if (pos <= index) { do { obj = i.next(); } while (pos++ < index); } else { do { obj = i.previous(); } while (--pos > index); } return obj; }
通过迭代器方法逐个移动到期望的位置。
我们来分析下效率,如果List支持随机访问,效率为O(log2(N)),如果通过迭代器,比较的次数为O(log2(N)),但遍历移动的次数为O(N),N为列表长度。
查找最大值/最小值
Collections提供了如下查找最大最小值的方法:
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll) public static <T> T max(Collection<? extends T> coll, Comparator<? super T> comp) public static <T extends Object & Comparable<? super T>> T min(Collection<? extends T> coll) public static <T> T min(Collection<? extends T> coll, Comparator<? super T> comp)
含义和用法都很直接,实现思路也很简单,就是通过迭代器进行比较,比如,其中一个方法的代码为:
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll) { Iterator<? extends T> i = coll.iterator(); T candidate = i.next(); while (i.hasNext()) { T next = i.next(); if (next.compareTo(candidate) > 0) candidate = next; } return candidate; }
其他方法就不赘述了。
查找元素出现次数
方法为:
public static int frequency(Collection<?> c, Object o)
返回元素o在容器c中出现的次数,o可以为null。含义很简单,实现思路也是,就是通过迭代器进行比较计数。
查找子List
在剖析String类一节,我们介绍过,String类有查找子字符串的方法:
public int indexOf(String str) public int lastIndexOf(String str)
对List接口对象,Collections提供了类似方法,在source List中查找target List的位置:
public static int indexOfSubList(List<?> source, List<?> target) public static int lastIndexOfSubList(List<?> source, List<?> target)
indexOfSubList从开头找,lastIndexOfSubList从结尾找,没找到返回-1,找到返回第一个匹配元素的索引位置,比如:
List<Integer> source = Arrays.asList(new Integer[]{ 35, 24, 13, 12, 8, 24, 13, 7, 1 }); System.out.println(Collections.indexOfSubList(source, Arrays.asList(new Integer[]{24, 13}))); System.out.println(Collections.lastIndexOfSubList(source, Arrays.asList(new Integer[]{24, 13})));
输出为:
1 5
这两个方法的实现都是属于"暴力破解"型的,将target列表与source从第一个元素开始的列表逐个元素进行比较,如果不匹配,则与source从第二个元素开始的列表比较,再不匹配,与source从第三个元素开始的列表比较,依次类推。
查看两个集合是否有交集
方法为:
public static boolean disjoint(Collection<?> c1, Collection<?> c2)
如果c1和c2有交集,返回值为false,没有交集,返回值为true。
实现原理也很简单,遍历其中一个容器,对每个元素,在另一个容器里通过contains方法检查是否包含该元素,如果包含,返回false,如果最后不包含任何元素返回true。这个方法的代码会根据容器是否为Set以及集合大小进行性能优化,即选择哪个容器进行遍历,哪个容器进行检查,以减少总的比较次数,具体我们就不介绍了。
替换
替换方法为:
public static <T> boolean replaceAll(List<T> list, T oldVal, T newVal)
将List中的所有oldVal替换为newVal,如果发生了替换,返回值为true,否则为false。用法和实现都比较简单,就不赘述了。
排序和调整顺序
针对List接口对象,Collections除了提供基础的排序,还提供了若干调整顺序的方法,包括交换元素位置、翻转列表顺序、随机化重排、循环移位等,我们逐个来看下。
排序
Arrays类有针对数组对象的排序方法,Collections提供了针对List接口的排序方法,如下所示:
public static <T extends Comparable<? super T>> void sort(List<T> list) public static <T> void sort(List<T> list, Comparator<? super T> c)
使用很简单,就不举例了,内部它是通过Arrays.sort实现的,先将List元素拷贝到一个数组中,然后使用Arrays.sort,排序后,再拷贝回List。代码如下所示:
public static <T extends Comparable<? super T>> void sort(List<T> list) { Object[] a = list.toArray(); Arrays.sort(a); ListIterator<T> i = list.listIterator(); for (int j=0; j<a.length; j++) { i.next(); i.set((T)a[j]); } }
交换元素位置
方法为:
public static void swap(List<?> list, int i, int j)
交换list中第i个和第j个元素的内容。实现代码为:
public static void swap(List<?> list, int i, int j) { final List l = list; l.set(i, l.set(j, l.get(i))); }
翻转列表顺序
方法为:
public static void reverse(List<?> list)
将list中的元素顺序翻转过来。实现思路就是将第一个和最后一个交换,第二个和倒数第二个交换,依次类推直到中间两个元素交换完毕。
如果list实现了RandomAccess接口或列表比较小,根据索引位置,使用上面的swap方法进行交换,否则,由于直接根据索引位置定位元素效率比较低,使用一前一后两个listIterator定位待交换的元素。具体代码为:
public static void reverse(List<?> list) { int size = list.size(); if (size < REVERSE_THRESHOLD || list instanceof RandomAccess) { for (int i=0, mid=size>>1, j=size-1; i<mid; i++, j--) swap(list, i, j); } else { ListIterator fwd = list.listIterator(); ListIterator rev = list.listIterator(size); for (int i=0, mid=list.size()>>1; i<mid; i++) { Object tmp = fwd.next(); fwd.set(rev.previous()); rev.set(tmp); } } }
随机化重排
我们在随机一节介绍过洗牌算法,Collections直接提供了对List元素洗牌的方法:
public static void shuffle(List<?> list) public static void shuffle(List<?> list, Random rnd)
实现思路与随机一节介绍的是一样的,从后往前遍历列表,逐个给每个位置重新赋值,值从前面的未重新赋值的元素中随机挑选。如果列表实现了RandomAccess接口,或者列表比较小,直接使用前面swap方法进行交换,否则,先将列表内容拷贝到一个数组中,洗牌,再拷贝回列表。代码如下:
public static void shuffle(List<?> list, Random rnd) { int size = list.size(); if (size < SHUFFLE_THRESHOLD || list instanceof RandomAccess) { for (int i=size; i>1; i--) swap(list, i-1, rnd.nextInt(i)); } else { Object arr[] = list.toArray(); // Shuffle array for (int i=size; i>1; i--) swap(arr, i-1, rnd.nextInt(i)); // Dump array back into list ListIterator it = list.listIterator(); for (int i=0; i<arr.length; i++) { it.next(); it.set(arr[i]); } } }
循环移位
我们解释下循环移位的概念,比如列表为:
[8, 5, 3, 6, 2]
循环右移2位,会变为:
[6, 2, 8, 5, 3]
如果是循环左移2位,会变为:
[3, 6, 2, 8, 5]
因为列表长度为5,循环左移3位和循环右移2位的效果是一样的。
循环移位的方法是:
public static void rotate(List<?> list, int distance)
distance表示循环移位个数,一般正数表示向右移,负数表示向左移,比如:
List<Integer> list1 = Arrays.asList(new Integer[]{ 8, 5, 3, 6, 2 }); Collections.rotate(list1, 2); System.out.println(list1); List<Integer> list2 = Arrays.asList(new Integer[]{ 8, 5, 3, 6, 2 }); Collections.rotate(list2, -2); System.out.println(list2);
输出为:
[6, 2, 8, 5, 3] [3, 6, 2, 8, 5]
这个方法很有用的一点是,它也可以用于子列表,可以调整子列表内的顺序而不改变其他元素的位置。比如,将第j个元素向前移动到k (k>j),可以这么写:
Collections.rotate(list.subList(j, k+1), -1);
再举个例子:
List<Integer> list = Arrays.asList(new Integer[]{ 8, 5, 3, 6, 2, 19, 21 }); Collections.rotate(list.subList(1, 5), 2); System.out.println(list);
输出为:
[8, 6, 2, 5, 3, 19, 21]
这个类似于列表内的"剪切"和"粘贴",将子列表[5, 3]"剪切","粘贴"到2后面。如果需要实现类似"剪切"和"粘贴"的功能,可以使用rotate方法。
循环移位的内部实现比较巧妙,根据列表大小和是否实现了RandomAccess接口,有两个算法,都比较巧妙,两个算法在《编程珠玑》这本书的2.3节有描述。
篇幅有限,我们只解释下其中的第二个算法,它将循环移位看做是列表的两个子列表进行顺序交换。再来看上面的例子,循环左移2位:
[8, 5, 3, 6, 2] -> [3, 6, 2, 8, 5]
就是将[8, 5]和[3, 6, 2]两个子列表的顺序进行交换。
循环右移两位:
[8, 5, 3, 6, 2] -> [6, 2, 8, 5, 3]
就是将[8, 5, 3]和[6, 2]两个子列表的顺序进行交换。
根据列表长度size和移位个数distance,可以计算出两个子列表的分隔点,有了两个子列表后,两个子列表的顺序交换可以通过三次翻转实现,比如有A和B两个子列表,A有m个元素,B有n个元素:
要变为:
可经过三次翻转实现:
1. 翻转子列表A
2. 翻转子列表B
3. 翻转整个列表
这个算法的整体实现代码为:
private static void rotate2(List<?> list, int distance) { int size = list.size(); if (size == 0) return; int mid = -distance % size; if (mid < 0) mid += size; if (mid == 0) return; reverse(list.subList(0, mid)); reverse(list.subList(mid, size)); reverse(list); }
mid为两个子列表的分割点,调用了三次reverse以实现子列表顺序交换。
添加和修改
Collections也提供了几个批量添加和修改的方法,逻辑都比较简单,我们看下。
批量添加
方法为:
public static <T> boolean addAll(Collection<? super T> c, T... elements)
elements为可变参数,将所有元素添加到容器c中。这个方法很方便,比如,可以这样:
List<String> list = new ArrayList<String>(); String[] arr = new String[]{"深入", "浅出"}; Collections.addAll(list, "hello", "world", "老马", "编程"); Collections.addAll(list, arr); System.out.println(list);
输出为:
[hello, world, 老马, 编程, 深入, 浅出]
批量填充固定值
方法为:
public static <T> void fill(List<? super T> list, T obj)
这个方法与Arrays类中的fill方法是类似的,给每个元素设置相同的值。
批量拷贝
方法为:
public static <T> void copy(List<? super T> dest, List<? extends T> src)
将列表src中的每个元素拷贝到列表dest的对应位置处,覆盖dest中原来的值,dest的列表长度不能小于src,dest中超过src长度部分的元素不受影响。
小结
本节介绍了类Collections中的一些通用算法,包括查找、替换、排序、调整顺序、添加、修改等,这些算法操作的都是容器接口对象,这是面向接口编程的一种体现,只要对象实现了这些接口,就可以使用这些算法。
在与容器类和Collections中的算法进行协作时,经常需要将其他类型的数据转换为容器接口对象,为此,Collections同样提供了很多方法。都有哪些方法?有什么用?体现了怎样的设计模式和思维?让我们在下一节继续探索。