0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaのSetを「重複を許さないList」だと思っていた話

0
Posted at

背景

Listはよく使う。Mapもなんとなく使える。でもSetは、「重複を排除したいときだけ使うやつ」という認識で止まっていた。

先日、コードレビューで「ここはSetの方が意図が明確では」とコメントをもらったのがきっかけで、改めてSetの設計思想から見直してみた。使い方は知っていても、なぜそう設計されているかを理解できていなかった、という感じの整理記録。


SetはMapの上に成り立っている

まず、これを知ったときに少し驚いた。

HashSetの内部実装は、実はHashMapをそのまま利用している。要素をMapのキーに保存し、値にはダミーのオブジェクトを入れることで「キーの重複を許容しない」というMapの性質を流用している。TreeSetTreeMapLinkedHashSetLinkedHashMapを使っている。

Setを独立したデータ構造として捉えていたが、実際にはMapのラッパー的な存在だった。この事実を知ってから、Setの各実装の性質がMapと対応づけて理解できるようになった。


3つの実装クラスの使い分け

Setの主な実装は3つある。

HashSetは、内部でハッシュテーブルを使っているため、追加・検索・削除が高速。ただし、要素の順序は保証されない。順序を気にしない場合はこれが基本の選択肢になる。

TreeSetは、木構造(赤黒木)で管理されるため、要素が自然順(昇順)に並ぶ。Comparatorを渡すことでカスタムの並び順も指定できる。ソート済みの状態を常に保ちたいときに使う。

LinkedHashSetは、HashSetと同じ速度特性を持ちながら、挿入順を維持する。「重複は排除したいが、追加した順番は保持したい」という場面に向いている。

import java.util.HashSet;
import java.util.TreeSet;
import java.util.LinkedHashSet;

// 順序なし(HashSet)
HashSet<String> hashSet = new HashSet<>();
hashSet.add("banana");
hashSet.add("apple");
hashSet.add("cherry");
System.out.println(hashSet); // 順序は不定

// 昇順(TreeSet)
TreeSet<String> treeSet = new TreeSet<>();
treeSet.add("banana");
treeSet.add("apple");
treeSet.add("cherry");
System.out.println(treeSet); // [apple, banana, cherry]

// 挿入順(LinkedHashSet)
LinkedHashSet<String> linkedSet = new LinkedHashSet<>();
linkedSet.add("banana");
linkedSet.add("apple");
linkedSet.add("cherry");
System.out.println(linkedSet); // [banana, apple, cherry]

以前は「なんとなくHashSetを使っておけばいい」という判断をしていたが、順序の保証が必要かどうかをちゃんと考えてから選ぶべきだった、と今は思う。


ループの書き方:インデックス指定ができない

Listに慣れていると、インデックスを使ったfor文を書こうとして詰まることがある。Setはインデックスアクセスをサポートしていないので、拡張for文(for-each)かIterator、またはStreamを使う必要がある。

import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.stream.Collectors;

HashSet<String> set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("orange");
set.add("mango");
set.add("kiwi");

// for-each
for (String element : set) {
    System.out.println(element);
}

// Iterator
Iterator<String> ite = set.iterator();
while (ite.hasNext()) {
    System.out.println(ite.next());
}

// Stream(絞り込みも組み合わせられる)
Set<String> filtered = set.stream()
    .filter(x -> x.length() > 4)
    .collect(Collectors.toSet());
System.out.println(filtered);

余談だが、collect(Collectors.toList())に慣れすぎていて、Streamの結果をSetに戻したいときにCollectors.toSet()の存在をしばらく忘れていた。Stream APIの前の記事でも書いたが、Collectorsのバリエーションはまだ意識的に使いこなせていない部分が多い。


理解が甘かった部分:containsの速度差

ListとSetのcontainsは同じメソッド名でも、計算量が異なる。

ArrayListcontainsはリストを先頭から順に走査するため、O(n)の時間がかかる。一方、HashSetcontainsはハッシュ値で直接位置を特定できるため、O(1)に近い。

「重複を排除したい」という目的でSetを選んでいたが、「多数の要素から特定の値が含まれるかを頻繁に確認する」という場面でも、Setの方が適切だということを意識できていなかった。コレクションの選択を「何を入れるか」だけで決めていたのを、「どんな操作を頻繁にするか」も含めて考えるようになったのは、この整理のおかげだと思っている。

HashSetcontainsはO(1)に近いが、ハッシュ衝突が多発する状況では性能が劣化する場合がある。一般的なユースケースでは問題にならないが、キーのhashCode実装が偏っている場合は注意が必要。


整理して気づいたこと

今回はっきりしたのは以下の3点。

  • SetはMapの実装を流用して成り立っており、実装クラスの選択もMapと同じ観点で判断できる
  • HashSetTreeSetLinkedHashSetの違いは「順序の保証」という軸で整理できる
  • containsの計算量がListとSetで異なることを意識して、コレクションを使い分けるべきだった

まだ掘り切れていないのは、equalshashCodeのオーバーライドがSetの動作にどう影響するか、という部分。独自クラスをSetに入れるケースで、重複判定が意図通りに動かないことがあり、その原因がはっきり説明できていない。次に機会があれば、そこを重点的に確認したいと思っている。


この記事を書いた人について

株式会社Flexibilityでエンジニアをしています。
DX推進・システム開発を軸に、エンジニアが自律的に動ける環境を大事にしている会社です。

技術的に面白いことをやっていきたい方や、働き方に柔軟さを求めている方は、
よかったら一度のぞいてみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?