计算机程序的思维逻辑 (53) - 剖析Collections - 算法

之前几节介绍了各种具体容器类和抽象容器类,上节我们提到,Java中有一个类Collections,提供了很多针对容器接口的通用功能,这些功能都是以静态方法的方式提供的。

都有哪些功能呢?大概可以分为两类:

  1. 对容器接口对象进行操作
  2. 返回一个容器接口对象

对于第一类,操作大概可以分为三组:

  • 查找和替换
  • 排序和调整顺序
  • 添加和修改

对于第二类,大概可以分为两组:

  • 适配器:将其他类型的数据转换为容器接口对象
  • 装饰器:修饰一个给定容器接口对象,增加某种性质

它们都是围绕容器接口对象的,第一类是针对容器接口的通用操作,这是我们之前在接口的本质一节介绍的面向接口编程的一种体现,是接口的典型用法,第二类是为了使更多类型的数据更为方便和安全的参与到容器类协作体系中。

由于内容比较多,我们分为两节,本节讨论第一类,下节我们讨论第二类。下面我们分组来看下第一类中的算法。

查找和替换

查找和替换包含多组方法,我们分别来看下。

二分查找

我们在剖析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同样提供了很多方法。都有哪些方法?有什么用?体现了怎样的设计模式和思维?让我们在下一节继续探索。

----------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

时间: 2024-10-17 02:50:56

计算机程序的思维逻辑 (53) - 剖析Collections - 算法的相关文章

计算机程序的思维逻辑 (54) - 剖析Collections - 设计模式

上节我们提到,类Collections中大概有两类功能,第一类是对容器接口对象进行操作,第二类是返回一个容器接口对象,上节我们介绍了第一类,本节我们介绍第二类. 第二类方法大概可以分为两组: 接受其他类型的数据,转换为一个容器接口,目的是使其他类型的数据更为方便的参与到容器类协作体系中,这是一种常见的设计模式,被称为适配器. 接受一个容器接口对象,并返回一个同样接口的对象,目的是使该对象更为安全的参与到容器类协作体系中,这也是一种常见的设计模式,被称为装饰器(不过,装饰器不一定是为了安全). 下

计算机程序的思维逻辑 (29) - 剖析String

上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比较简单直接的,我们来看下. 基本用法 可以通过常量定义String变量 String name = "老马说编程"; 也可以通过new创建String String name = new String("老马说编程"); String可以直接使用+和+=运算符,如: S

计算机程序的思维逻辑 (28) - 剖析包装类 (下)

本节探讨Character类,它的基本用法我们在包装类第一节已经介绍了,本节不再赘述.Character类除了封装了一个char外,还有什么可介绍的呢?它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char,本节详细介绍这些方法以及相关的Unicode知识. 在介绍这些方法之前,我们需要回顾一下字符在Java中的表示方法,我们在第六节.第七节.第八节介绍过编码.Unicode.char等知识,我们先简要回顾一

计算机程序的思维逻辑 (51) - 剖析EnumSet

上节介绍了EnumMap,本节介绍同样针对枚举类型的Set接口的实现类EnumSet.与EnumMap类似,之所以会有一个专门的针对枚举类型的实现类,主要是因为它可以非常高效的实现Set接口. 之前介绍的Set接口的实现类HashSet/TreeSet,它们内部都是用对应的HashMap/TreeMap实现的,但EnumSet不是,它的实现与EnumMap没有任何关系,而是用极为精简和高效的位向量实现的,位向量是计算机程序中解决问题的一种常用方式,我们有必要理解和掌握. 除了实现机制,EnumS

计算机程序的思维逻辑 (30) - 剖析StringBuilder

上节介绍了String,提到如果字符串修改操作比较频繁,应该采用StringBuilder和StringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于,StringBuffer是线程安全的,而StringBuilder不是. 线程以及线程安全的概念,我们在后续章节再详细介绍.这里需要知道的就是,线程安全是有成本的,影响性能,而字符串对象及操作,大部分情况下,没有线程安全的问题,适合使用StringBuilder.所以,本节就只讨论StringBuild

计算机程序的思维逻辑 (38) - 剖析ArrayList

从本节开始,我们探讨Java中的容器类,所谓容器,顾名思义就是容纳其他数据的,计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类,我们不会介绍所有数据结构的内容,但会介绍Java中的主要实现,并分析其基本原理和主要实现代码. 前几节在介绍泛型的时候,我们自己实现了一个简单的动态数组容器类DynaArray,本节,我们介绍Java中真正的动态数组容器类ArrayList. 我们先来看它的基本用法. 基本用法 新建ArrayList ArrayList是一个泛型容器,新建ArrayLi

计算机程序的思维逻辑 (43) - 剖析TreeMap

40节介绍了HashMap,我们提到,HashMap有一个重要局限,键值对之间没有特定的顺序,我们还提到,Map接口有另一个重要的实现类TreeMap,在TreeMap中,键值对之间按键有序,TreeMap的实现基础是排序二叉树,上节我们介绍了排序二叉树的基本概念和算法,本节我们来详细讨论TreeMap. 除了Map接口,因为有序,TreeMap还实现了更多接口和方法,下面,我们先来看TreeMap的用法,然后探讨其内部实现. 基本用法 构造方法 TreeMap有两个基本构造方法: public

计算机程序的思维逻辑 (40) - 剖析HashMap

前面两节介绍了ArrayList和LinkedList,它们的一个共同特点是,查找元素的效率都比较低,都需要逐个进行比较,本节介绍HashMap,它的查找效率则要高的多,HashMap是什么?怎么用?是如何实现的?本节详细介绍. 字面上看,HashMap由两个单词组成,Hash和Map,这里Map不是地图的意思,而是表示映射关系,是一个接口,实现Map接口有多种方式,HashMap实现的方式利用了Hash. 下面,我们先来看Map接口,接着看如何使用HashMap,然后看实现原理,最后我们总结分

计算机程序的思维逻辑 (46) - 剖析PriorityQueue

上节介绍了堆的基本概念和算法,本节我们来探讨堆在Java中的具体实现类 - PriorityQueue. 我们先从基本概念谈起,然后介绍其用法,接着分析实现代码,最后总结分析其特点. 基本概念 顾名思义,PriorityQueue是优先级队列,它首先实现了队列接口(Queue),与LinkedList类似,它的队列长度也没有限制,与一般队列的区别是,它有优先级的概念,每个元素都有优先级,队头的元素永远都是优先级最高的. PriorityQueue内部是用堆实现的,内部元素不是完全有序的,不过,逐