計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

swiftma發表於2016-12-06

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (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個元素:

計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

要變為:

計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

可經過三次翻轉實現:

  1. 翻轉子列表A

計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

  1. 翻轉子列表B

計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

  1. 翻轉整個列表

計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

這個演算法的整體實現程式碼為:

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程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (53) - 剖析Collections - 演算法

相關文章