Javaコレクションの時間の複雑さ

Javaコレクションの時間計算量

1. 概要

このチュートリアルでは、we’ll talk about the performance of different collections from the Java Collection API。 コレクションについて話すときは、通常、List, Map,Setのデータ構造とそれらの一般的な実装について考えます。

まず、一般的な操作のBig-Oの複雑さに関する洞察を確認し、その後、いくつかの収集操作の実行時間の実際の数を示します。

2. 時間の複雑さ

通常、when we talk about time complexity, we refer to Big-O notation。 簡単に言えば、表記法は、アルゴリズムの実行時間が入力のサイズとともにどのように増加するかを示しています。

Big-O表記theoryまたは実用的なJava examplesの詳細については、役立つ記事をご覧ください。

3. List

順序付けられたコレクションである簡単なリストから始めましょう。

ここでは、ArrayList, LinkedList,CopyOnWriteArrayListの実装のパフォーマンスの概要を見ていきます。

3.1. ArrayList

The ArrayList in Java is backed by an array。 これは、その実装の内部ロジックを理解するのに役立ちます。 ArrayListのより包括的なガイドは、in this articleで入手できます。

それでは、まず、一般的な操作の時間計算量に大まかに焦点を当てましょう。

  • add()O(1)の時間がかかります

  • add(index, element) –平均してO(n)時間で実行

  • get() –常に一定時間のO(1)操作です

  • remove() –線形のO(n)時間で実行されます。 削除の対象となる要素を見つけるために、配列全体を反復処理する必要があります

  • *indexOf()* –も線形時間で実行されます。 内部配列を反復処理し、各要素を1つずつ確認します。 したがって、この操作の時間計算量には常にO(n)の時間が必要です。

  • contains() –実装はindexOf()に基づいています。 したがって、O(n)秒の時間でも実行されます

3.2. CopyOnWriteArrayList

Listインターフェースのこの実装はvery useful when working with multi-threaded applicationsです。 スレッドセーフであり、this guide hereでよく説明されています。

CopyOnWriteArrayListのパフォーマンスBig-O表記の概要は次のとおりです。

  • add() –付加価値のある位置に依存するため、複雑さはO(n)です。

  • get()O(1) 一定時間の操作です

  • remove()O(n) timeかかります

  • contains() –同様に、複雑さはO(n)です。

ご覧のとおり、このコレクションの使用は、add()メソッドのパフォーマンス特性のために非常にコストがかかります。

3.3. LinkedList

LinkedList is a linear data structure which consists of nodes holding a data field and a reference to another node。 その他のLinkedListの機能については、this article hereをご覧ください。

いくつかの基本的な操作を実行するために必要な時間の平均見積もりを提示しましょう。

  • add() –任意の位置でのO(1)の一定時間挿入をサポート

  • get() –要素の検索にはO(n) timeがかかります

  • remove() –要素の位置を提供するため、要素の削除にもO(1)操作が必要です

  • contains()O(n)の時間計算量もあります

3.4. JVMのウォーミングアップ

それでは、理論を証明するために、実際のデータを試してみましょう。 To be more precise, we’ll present the JMH (Java Microbenchmark Harness) test results of the most common collection operations

JMHツールに慣れていない場合は、このuseful guideを確認してください。

まず、ベンチマークテストの主なパラメーターを示します。

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 10)
public class ArrayListBenchmark {
}

次に、ウォームアップの反復回数を10に設定します。 また、結果の平均実行時間をマイクロ秒単位で表示したいと考えています。

3.5. ベンチマークテスト

それでは、パフォーマンステストを実行します。 まず、ArrayListから始めます。

@State(Scope.Thread)
public static class MyState {

    List employeeList = new ArrayList<>();

    long iterations = 100000;

    Employee employee = new Employee(100L, "Harry");

    int employeeIndex = -1;

    @Setup(Level.Trial)
    public void setUp() {
        for (long i = 0; i < iterations; i++) {
            employeeList.add(new Employee(i, "John"));
        }

        employeeList.add(employee);
        employeeIndex = employeeList.indexOf(employee);
    }
}

ArrayListBenchmarkの中に、初期データを保持するためのStateクラスを追加します。

ここでは、EmployeeオブジェクトのArrayListを作成します。 その後、we initialize it with 100.000 items inside of the setUp() method. The @State indicates that the @Benchmark tests have full access to the variables declared in it within the same thread.

最後に、add(), contains(), indexOf(), remove(),メソッドとget()メソッドのベンチマークテストを追加します。

@Benchmark
public void testAdd(ArrayListBenchmark.MyState state) {
    state.employeeList.add(new Employee(state.iterations + 1, "John"));
}

@Benchmark
public void testAddAt(ArrayListBenchmark.MyState state) {
    state.employeeList.add((int) (state.iterations), new Employee(state.iterations, "John"));
}

@Benchmark
public boolean testContains(ArrayListBenchmark.MyState state) {
    return state.employeeList.contains(state.employee);
}

@Benchmark
public int testIndexOf(ArrayListBenchmark.MyState state) {
    return state.employeeList.indexOf(state.employee);
}

@Benchmark
public Employee testGet(ArrayListBenchmark.MyState state) {
    return state.employeeList.get(state.employeeIndex);
}

@Benchmark
public boolean testRemove(ArrayListBenchmark.MyState state) {
    return state.employeeList.remove(state.employee);
}

3.6. 試験結果

すべての結果はマイクロ秒単位で表示されます。

Benchmark                        Mode  Cnt     Score     Error
ArrayListBenchmark.testAdd       avgt   20     2.296 ±   0.007
ArrayListBenchmark.testAddAt     avgt   20   101.092 ±  14.145
ArrayListBenchmark.testContains  avgt   20   709.404 ±  64.331
ArrayListBenchmark.testGet       avgt   20     0.007 ±   0.001
ArrayListBenchmark.testIndexOf   avgt   20   717.158 ±  58.782
ArrayListBenchmark.testRemove    avgt   20   624.856 ±  51.101

From the results we can learn, that testContains() and testIndexOf() methods run in approximately the same time。 また、testAdd(), testGet()メソッドのスコアと残りの結果との大きな違いもはっきりとわかります。 要素の追加には2.296マイクロ秒かかり、要素の取得は0.007マイクロ秒の操作です。

要素を検索または削除する場合、おおよそ700マイクロ秒かかります。 これらの数値は理論的な部分の証拠であり、add(),get()にはO(1)の時間計算量があり、他の方法はO(n)であることがわかりました。 この例のn=10.000要素。

同様に、CopyOnWriteArrayListコレクションに対して同じテストを作成できます。 必要なのは、employeeListのArrayListCopyOnWriteArrayListインスタンスに置き換えることだけです。

ベンチマークテストの結果は次のとおりです。

Benchmark                          Mode  Cnt    Score     Error
CopyOnWriteBenchmark.testAdd       avgt   20  652.189 ±  36.641
CopyOnWriteBenchmark.testAddAt     avgt   20  897.258 ±  35.363
CopyOnWriteBenchmark.testContains  avgt   20  537.098 ±  54.235
CopyOnWriteBenchmark.testGet       avgt   20    0.006 ±   0.001
CopyOnWriteBenchmark.testIndexOf   avgt   20  547.207 ±  48.904
CopyOnWriteBenchmark.testRemove    avgt   20  648.162 ± 138.379

ここでも、数字は理論を裏付けています。 ご覧のとおり、testGet()は平均0.006ミリ秒で実行され、O(1)と見なすことができます。 Comparing to ArrayList, we also notice the significant difference between testAdd() method results. As we have here O(n) complexity for the add() method versus ArrayList’s O(1). 

We can clearly see the linear growth of the time, as performance numbers are 878.166 compared to 0.051

さて、LinkedList秒の時間です。

Benchmark        Cnt     Score       Error
testAdd          20     2.580        ± 0.003
testContains     20     1808.102     ± 68.155
testGet          20     1561.831     ± 70.876
testRemove       20     0.006        ± 0.001

スコアから、LinkedListの要素の追加と削除は非常に高速であることがわかります。

さらに、追加/削除操作と取得/含む操作の間には、パフォーマンスに大きなギャップがあります。

4. Map

最新のJDKバージョンでは、HashMap, LinkedHashMap 内部実装でLinkedListをバランスの取れたツリーノード構造に置き換えるなど、Map実装のパフォーマンスが大幅に向上しています。 This shortens the element lookup worst-case scenario from O(n) to O(log(n)) time during the HashMap collisions

ただし、適切な.equals()メソッドと.hashcode()メソッドを実装すると、衝突が発生する可能性は低くなります。

HashMapの衝突の詳細については、this write-upを確認してください。 From the write-up, we can also learn, that storing and retrieving elements from the HashMap takes constant O(1) time

4.1. O(1)操作のテスト

実際の数値をいくつか示しましょう。 まず、HashMapの場合:

Benchmark                         Mode  Cnt  Score   Error
HashMapBenchmark.testContainsKey  avgt   20  0.009 ± 0.002
HashMapBenchmark.testGet          avgt   20  0.011 ± 0.001
HashMapBenchmark.testPut          avgt   20  0.019 ± 0.002
HashMapBenchmark.testRemove       avgt   20  0.010 ± 0.001

As we see, the numbers prove the O(1) constant time for running the methods listed above.次に、HashMapテストスコアを他のMapインスタンススコアと比較してみましょう。

リストされているすべてのメソッドについて、we have O(1) for HashMap, LinkedHashMap, IdentityHashMap, WeakHashMap, EnumMap and ConcurrentHashMap.

残りのテストスコアの結果を1つの表の形式で提示しましょう。

Benchmark      LinkedHashMap  IdentityHashMap  WeakHashMap  ConcurrentHashMap
testContainsKey    0.008         0.009          0.014          0.011
testGet            0.011         0.109          0.019          0.012
testPut            0.020         0.013          0.020          0.031
testRemove         0.011         0.115          0.021          0.019

出力数から、O(1)の時間計算量の主張を確認できます。

4.2. O(log(n))の操作のテスト

ツリー構造の場合TreeMap and ConcurrentSkipListMap the put(), get(), remove(), containsKey()  operations time is O(log(n)).

[.pl-smi] #Here、we want to make sure that our performance tests will run approximately in logarithmic time。 そのため、n=1000, 10,000, 100,000, 1,000,000のアイテムでマップを継続的に初期化します。 #

この場合、実行の合計時間に関心があります。

items count (n)         1000      10,000     100,000   1,000,000
all tests total score   00:03:17  00:03:17   00:03:30  00:05:27

[.pl-smi]#n=1000の場合、合計00:03:17ミリ秒の実行時間があります。 n=10,000時間はほとんど変化していません。00:03:18 ms. n=100,000はわずかに増加しています。00:03:30。 そして最後に、n=1,000,000の場合、実行は00:05:27 msで完了します。 #

ランタイム数を各nlog(n)関数と比較した後、両方の関数の相関が一致していることを確認できます。

5. Set

通常、Setは一意の要素のコレクションです。 ここでは、SetインターフェースのHashSetLinkedHashSetEnumSet, TreeSet, CopyOnWriteArraySet,、およびConcurrentSkipListSetの実装について説明します。

HashSetの内部をよりよく理解するために、this guideが役立ちます。

それでは、時間計算量の数値を示しましょう。 For HashSetLinkedHashSet, and EnumSet the add(), remove() and contains() operations cost constant O(1) time. Thanks to the internal HashMap implementation.

同様に、 前のグループにリストされた操作のTreeSet has O(log(n)) time complexity。 これは、TreeMapの実装によるものです。 スキップリストのデータ構造に基づいているため、ConcurrentSkipListSetの時間計算量もO(log(n))時間です。

CopyOnWriteArraySet,の場合、add(), remove() およびcontains()メソッドの平均時間計算量はO(n)です。

5.1. 試験方法

それでは、ベンチマークテストにジャンプしましょう。

@Benchmark
public boolean testAdd(SetBenchMark.MyState state) {
    return state.employeeSet.add(state.employee);
}

@Benchmark
public Boolean testContains(SetBenchMark.MyState state) {
    return state.employeeSet.contains(state.employee);
}

@Benchmark
public boolean testRemove(SetBenchMark.MyState state) {
    return state.employeeSet.remove(state.employee);
}

さらに、残りのベンチマーク構成はそのままにします。

5.2. 数字の比較

HashSetアイテムとLinkedHashSet シェービングn = 1000; 10,000; 100,000アイテムの実行時実行スコアの動作を見てみましょう。

HashSet, の場合、数値は次のとおりです。

Benchmark      1000    10,000    100,000
.add()         0.026   0.023     0.024
.remove()      0.009   0.009     0.009
.contains()    0.009   0.009     0.010

同様に、LinkedHashSetの結果は次のとおりです。

Benchmark      1000    10,000    100,000
.add()         0.022   0.026     0.027
.remove()      0.008   0.012     0.009
.contains()    0.008   0.013     0.009

ご覧のとおり、スコアは各操作でほぼ同じままです。 さらに、それらをHashMapのテスト出力と比較すると、同じように見えます。

その結果、テストしたすべてのメソッドが一定のO(1)時間で実行されることを確認しました。

6. 結論

この記事では、we present the time complexity of the most common implementations of the Java data structures.

別に、JVMベンチマークテストを通じて、各タイプのコレクションの実際の実行時パフォーマンスを示します。 また、異なるコレクションで同じ操作のパフォーマンスを比較しました。 その結果、ニーズに合った適切なコレクションを選択することを学びます。

いつものように、この記事の完全なコードはover on GitHubで入手できます。