C++ std::set とは?特徴、メリット、コード例で理解を深める

はい、承知いたしました。C++のstd::setについて、特徴、メリット、コード例を含む詳細な記事を作成します。約5000語の要件に合わせて、各セクションを深く掘り下げて説明します。


C++ std::set とは?特徴、メリット、コード例で理解を深める

はじめに

C++ 標準ライブラリは、強力で効率的なコンテナ(データ構造)を多数提供しています。その中でも std::set は、ユニークな要素をソートされた状態で保持するのに特化したコンテナとして、多くの場面で活用されます。プログラミングにおいて、「重複を許さず、常にソートされた状態を保ちたい」という要件は頻繁に登場しますが、std::set はまさにこのような課題を解決するための最適なツールの一つです。

この記事では、std::set がどのようなコンテナなのか、その内部的な仕組み、特徴、メリット、そして具体的なコード例を通して、深く理解することを目指します。単に使い方を説明するだけでなく、なぜそのような振る舞いをするのか、どのような場合に他のコンテナ(std::vector, std::unordered_set, std::map など)よりも優れているのかについても掘り下げて解説します。

std::set の定義と基本概念

std::set は、C++ 標準ライブラリの <set> ヘッダーファイルで提供される連想コンテナです。連想コンテナとは、値をキーとして格納し、キーに基づいて要素にアクセスするコンテナのことです。std::set の場合、格納される要素そのものがキーとなります。

std::set の最も重要な特徴は以下の2点です。

  1. ユニークな要素のみを保持する: 同じ値を持つ要素を複数格納することはできません。2番目以降に挿入しようとした重複要素は無視されます。
  2. 要素が常にソートされた状態で保持される: 要素は内部的に、コンテナのテンプレートパラメータで指定された比較関数(デフォルトは std::less、つまり昇順)に従って自動的にソートされます。

これらの特性から、std::set は数学的な集合(Set)の概念をコンピュータ上で実現するためのコンテナと見なすことができます。

std::set の内部実装:平衡二分探索木

C++ 標準規格では std::set の特定の内部実装を義務付けてはいませんが、一般的に std::set平衡二分探索木(Balanced Binary Search Tree)、特に赤黒木(Red-Black Tree)AVL木(AVL Tree)といった自己平衡機能を持つ木構造によって実装されています。

平衡二分探索木は、以下の性質を持ちます。

  • 各ノードがキー(std::set の場合は要素の値)を保持する。
  • 各ノードは最大二つの子ノード(左子、右子)を持つ。
  • 任意のノードについて、その左部分木にある全てのノードのキーは、そのノードのキーよりも小さい。
  • 任意のノードについて、その右部分木にある全てのノードのキーは、そのノードのキーよりも大きい。
  • 木の高さが常にバランスされており、要素数 $N$ に対して高さが $O(\log N)$ に保たれる。

この自己平衡機能が、std::set の主要な操作(挿入、削除、検索)を非常に効率的に行うことを可能にしています。木構造の根(ルート)から葉(リーフ)までの経路の長さに比例して操作時間がかかるため、高さが $O(\log N)$ に保たれることで、これらの操作の計算量は対数時間 $O(\log N)$ となります。これは、要素数が増加してもパフォーマンスの劣化が比較的小さいことを意味します。

要素のユニーク性の保証は、挿入時に木を探索し、同じ値の要素が既に存在するかどうかを確認することで実現されます。存在しない場合にのみ、新しいノードとして木に挿入されます。

std::set の主な特徴と性質

std::set の定義と内部実装を踏まえ、その特徴をさらに詳しく見ていきましょう。

1. ユニーク性 (Uniqueness)

これは std::set の最も基本的な性質です。同じ値の要素を複数格納しようとしても、コンテナはそれを拒否します。

“`cpp

include

include

int main() {
std::set mySet;
mySet.insert(10);
mySet.insert(20);
mySet.insert(10); // 重複要素

std::cout << "Set elements: ";
for (int val : mySet) {
    std::cout << val << " "; // 出力: 10 20
}
std::cout << std::endl;
std::cout << "Set size: " << mySet.size() << std::endl; // 出力: 2

return 0;

}
“`

上記の例では、10 を2回挿入しようとしていますが、mySet には最初の 10 のみが格納され、サイズは2のままです。insert メソッドは、要素が実際に挿入されたかどうかを示す情報を返すため、これを利用して重複挿入を検知することも可能です(詳細は後述の「メンバー関数」セクションで説明します)。

2. ソート順序 (Sorted Order)

std::set の要素は、コンテナのテンプレートパラメータで指定された比較基準に基づいて常にソートされた状態で保持されます。デフォルトでは std::less<T> が使用され、これは数値や文字列などの標準的な型に対して昇順のソートを提供します。

“`cpp

include

include

include

int main() {
std::set mySet;
mySet.insert(“banana”);
mySet.insert(“apple”);
mySet.insert(“cherry”);

std::cout << "Set elements: ";
for (const std::string& str : mySet) {
    std::cout << str << " "; // 出力: apple banana cherry
}
std::cout << std::endl;

return 0;

}
“`

文字列がアルファベット順にソートされて格納されていることがわかります。このソート順序は、イテレータによる要素へのアクセスや、検索・削除などの操作の効率性に寄与しています。

カスタムな型を std::set に格納する場合や、デフォルトとは異なる順序でソートしたい場合は、テンプレートパラメータでカスタム比較関数オブジェクトを指定する必要があります。カスタム比較関数オブジェクトは、2つの要素を受け取り、1つ目の要素が2つ目の要素よりも小さい場合に true を返す operator() を持つ必要があります。これは「厳密弱順序 (Strict Weak Ordering)」と呼ばれる性質を満たす必要があります。

“`cpp

include

include

include

include // std::greater を使用するために必要

int main() {
// std::greater を使用して降順にソート
std::set> mySet;
mySet.insert(10);
mySet.insert(30);
mySet.insert(20);

std::cout << "Set elements (descending): ";
for (int val : mySet) {
    std::cout << val << " "; // 出力: 30 20 10
}
std::cout << std::endl;

return 0;

}
“`

3. 連想コンテナ (Associative Container)

std::set は連想コンテナであり、値そのものをキーとして使用します。要素にアクセスするには、その値を知っている必要があります。位置(インデックス)によるアクセスは提供されません。これは、要素が内部的に木構造に配置されており、連続したメモリ上に格納されているわけではないためです。

4. キーベースのアクセスと効率性 (Key-based Access and Efficiency)

要素の挿入、削除、検索といった主要な操作は、要素の値(キー)に基づいて行われます。これらの操作は、平衡二分探索木のおかげで平均的に $O(\log N)$ の計算量で実行されます。

  • 挿入 (insert): 指定された値がコンテナに既に存在するか O(log N) で検索し、存在しない場合は O(log N) で木に挿入します。
  • 検索 (find, count): 指定された値を持つ要素を O(log N) で木の中から探索します。
  • 削除 (erase): 指定された値またはイテレータが指す要素を O(log N) で木から削除します。

これらの操作は、要素数が増加しても非常に高速であるという大きなメリットをもたらします。

5. イテレータ (Iterators)

std::set は双方向イテレータを提供します。これにより、コンテナの要素をソート順に(または逆順に)効率的に走査することができます。イテレータは begin(), end(), rbegin(), rend(), cbegin(), cend(), crbegin(), crend() などのメンバー関数によって取得できます。

重要な注意点として、std::set のイテレータは、要素の値自体を変更することはできません。イテレータを介してアクセスできる要素は const 参照であると考えるべきです。これは、要素の値を変更してしまうと、コンテナのソート順序やユニーク性の制約が破られる可能性があるためです。要素の値を変更したい場合は、一度その要素を削除し、新しい値を持つ要素を改めて挿入する必要があります。

また、要素の挿入や削除によって、木構造は再編成される可能性があります。しかし、要素が削除されない限り、既存の要素を指しているイテレータは無効になりません。これは std::vector などとは異なる非常に便利な特性です(std::vector は中間に要素を挿入/削除すると、その位置以降の全てのイテレータが無効になる可能性があります)。ただし、削除された要素を指していたイテレータはもちろん無効になります。

6. 値の型に関する制約 (Constraints on Value Type)

std::set<T> に格納する要素の型 T は、以下の要件を満たす必要があります。

  • デフォルト構築可能 (DefaultConstructible) かどうかは必須ではない: 要素を挿入する際にコピーまたはムーブされるため、コンテナがデフォルトコンストラクタを必要とすることはありません。
  • コピー構築可能 (CopyConstructible) および コピー代入可能 (CopyAssignable) または ムーブ構築可能 (MoveConstructible) および ムーブ代入可能 (MoveAssignable): 要素をコンテナにコピーまたはムーブして格納できる必要があります。
  • 比較可能 (LessThanComparable またはカスタム比較関数による比較): コンテナの比較関数(デフォルトは operator<)によって、任意の二つの要素を比較できる必要があります。これは厳密弱順序を満たす必要があります。カスタム比較関数を使用する場合は、その関数がこの要件を満たす必要があります。

特にカスタム型を格納する場合、operator< を定義するか、あるいはコンテナにカスタム比較関数オブジェクトを渡す必要があります。

7. メモリ効率 (Memory Efficiency)

std::set は平衡二分探索木によって実装されるため、各要素(ノード)は値だけでなく、子ノードへのポインタ(通常2つ)、親ノードへのポインタ(場合によっては)、そしてバランス情報(赤黒木の色など)といったオーバーヘッドを持ちます。このため、同じ要素数を格納する場合、std::vector のような連続メモリコンテナに比べて、一般的にメモリ使用量は多くなります。

std::set の主要なメンバー関数

std::set が提供する主要なメンバー関数について説明します。

コンストラクタ (Constructors)

  • set(): 空の set を構築します。
  • set(InputIt first, InputIt last): [first, last) の範囲の要素をコピーして set を構築します。重複は自動的に除外されます。
  • set(const set& other): 他の set をコピーして構築します。
  • set(set&& other): 他の set をムーブして構築します。
  • set(std::initializer_list<value_type> init): 初期化リストから set を構築します。
  • set(const Compare& comp): カスタム比較関数オブジェクトを指定して空の set を構築します。
  • 各種コンストラクタにカスタム比較関数オブジェクトやアロケータを指定するバージョンがあります。

挿入 (Insertion)

  • pair<iterator, bool> insert(const value_type& value): 値を挿入します。
    • 戻り値は pair です。
    • pair.first は、挿入された要素(または既に存在していた重複要素)を指すイテレータです。
    • pair.second は、要素が実際に挿入された場合に true、重複していて挿入されなかった場合に false です。
  • iterator insert(iterator hint, const value_type& value): ヒントイテレータ hint を利用して値を挿入します。hint は、新しい要素が挿入されるであろう位置に近い場所を指しているべきです。正しいヒントが与えられた場合、挿入は償却定数時間 $O(1)$ で行われる可能性があります。ヒントが全く役に立たない場合でも、挿入は $O(\log N)$ で行われます。
  • template<class InputIt> void insert(InputIt first, InputIt last): [first, last) の範囲の要素を挿入します。
  • insert(std::initializer_list<value_type> init): 初期化リストの要素を挿入します。

削除 (Deletion)

  • iterator erase(iterator pos): pos が指す要素を削除します。削除された要素の次の要素を指すイテレータを返します。
  • iterator erase(iterator first, iterator last): [first, last) の範囲の要素を削除します。削除範囲の次の要素を指すイテレータを返します。
  • size_type erase(const value_type& value): 指定された値と等しい要素を削除します。削除された要素の数を返します(std::set では常に 0 または 1 です)。

検索 (Lookup)

  • iterator find(const value_type& value): 指定された値を持つ要素を検索します。要素が見つかった場合、その要素を指すイテレータを返します。見つからなかった場合、end() を返します。
  • size_type count(const value_type& value): 指定された値を持つ要素の数を返します。std::set では要素はユニークなので、値が存在すれば 1、存在しなければ 0 を返します。find と同様に O(log N) の計算量です。find の方が要素そのものが必要な場合に効率的です。
  • iterator lower_bound(const value_type& value): 指定された値以上の最初の要素を指すイテレータを返します。
  • iterator upper_bound(const value_type& value): 指定された値より大きい最初の要素を指すイテレータを返します。
  • pair<iterator, iterator> equal_range(const value_type& value): 指定された値と等しい要素の範囲(イテレータのペア)を返します。std::set では、これは lower_bound(value)upper_bound(value) を組み合わせた結果になります。値が存在する場合、ペアの最初のイテレータは要素を指し、二番目のイテレータはその次の要素を指します。値が存在しない場合、両方のイテレータは lower_bound (つまり、値が挿入されるべき位置) を指します。

反復処理 (Iteration)

  • iterator begin(), const_iterator cbegin(): コンテナの最初の要素を指すイテレータ/定数イテレータを返します。
  • iterator end(), const_iterator cend(): コンテナの最後の要素の次を指すイテレータ/定数イテレータを返します。
  • reverse_iterator rbegin(), const_reverse_iterator crbegin(): コンテナの最後の要素を指す逆順イテレータ/定数逆順イテレータを返します。
  • reverse_iterator rend(), const_reverse_iterator crend(): コンテナの最初の要素の前に対応する逆順イテレータ/定数逆順イテレータを返します。

容量と状態 (Capacity and State)

  • bool empty() const: コンテナが空の場合に true を返します。
  • size_type size() const: コンテナの要素数を返します。
  • size_type max_size() const: コンテナが保持できる要素の最大数を返します。

その他の操作 (Other Operations)

  • void clear(): コンテナから全ての要素を削除します。
  • void swap(set& other): 他の set と内容を交換します。O(1) の計算量です。
  • key_compare key_comp() const: 要素の比較に使用される関数オブジェクトのコピーを返します。
  • value_compare value_comp() const: std::set では key_compare と同じです。

アロケータ (Allocator)

std::set はメモリ管理のためにアロケータを使用します。デフォルトでは std::allocator<value_type> が使用されますが、テンプレートパラメータとしてカスタムアロケータを指定することも可能です。

std::set の計算量 (Complexity)

std::set の最も重要な側面の1つは、その計算量です。平衡二分探索木による実装のおかげで、多くの操作が効率的に行われます。

操作 計算量 (Complexity) 説明
構築 (Constructor) $O(N \log N)$ N個の要素を挿入する場合。
挿入 (insert(value)) $O(\log N)$ 要素の探索と挿入。
挿入 (insert(hint, value)) $O(1)$ (償却平均) ヒントが有効な場合。無効なら $O(\log N)$。
範囲挿入 (insert(first, last)) $O(M \log N)$ M個の要素を挿入する場合。
検索 (find, count) $O(\log N)$ 木の探索。
下限/上限検索 (lower_bound, upper_bound, equal_range) $O(\log N)$ 木の探索。
削除 (erase(iterator)) $O(\log N)$ イテレータが指すノードの削除。
削除 (erase(value)) $O(\log N)$ 値を持つ要素を検索し削除。
範囲削除 (erase(first, last)) $O(K \log N)$ K個の要素を削除する場合。
イテレータ取得 (begin, end, etc.) $O(1)$ 木の端のノードへのアクセスは定数時間。
サイズ/空判定 (size, empty) $O(1)$ (通常) 要素数はカウンタで保持されるため。
全要素削除 (clear) $O(N)$ 全てのノードを解放する必要があるため。
交換 (swap) $O(1)$ ポインタの交換のみ。

ここで $N$ は std::set の現在の要素数です。対数時間 $O(\log N)$ は、要素数 $N$ が大きくなっても処理時間の増加が緩やかであることを示しており、大規模なデータセットを扱う際に非常に効率的です。例えば、$N=100万$ の場合、$\log_2 10^6 \approx 20$ 程度であり、探索や挿入が約20ステップで完了することを意味します。これは、線形時間 $O(N)$ の処理(100万ステップ)と比較して圧倒的に高速です。

std::set のメリット (Advantages)

std::set を使用する主なメリットは以下の通りです。

  1. 自動的なユニーク性の維持: 重複要素を自動的に排除してくれるため、別途重複チェックのロジックを実装する必要がありません。
  2. 自動的なソート順序の維持: 要素は常にソートされた状態で保持されるため、要素を順序通りに取得したい場合に便利です。挿入や削除を行ってもソート状態は自動的に維持されます。
  3. 高速な検索、挿入、削除: 主要な操作が $O(\log N)$ で実行されるため、大規模なデータセットに対しても高いパフォーマンスを発揮します。特に要素の追加・削除が頻繁に行われ、かつ常にソート・ユニークな状態を保つ必要がある場合に強力です。
  4. 要素が削除されない限り、イテレータは無効にならない: std::vectorstd::deque と比較して、挿入や削除によるイテレータの無効化が限定的です(削除された要素を指すイテレータのみが無効)。これにより、コンテナの変更中にイテレータを使用するコードが書きやすくなります。
  5. 数学的な集合操作への応用: ユニーク性とソート順序のおかげで、和集合、積集合、差集合といった数学的な集合演算を効率的に行うための基盤として利用できます(std::set 自体はこれらの操作を直接提供しませんが、アルゴリズムライブラリと組み合わせて容易に実現できます)。

std::set のデメリット (Disadvantages)

もちろん、std::set にもデメリットはあります。

  1. メモリオーバーヘッド: 平衡二分探索木の実装に伴う各ノードごとのポインタや管理情報のオーバーヘッドにより、std::vector などの連続メモリコンテナに比べてメモリ使用量が多くなる傾向があります。
  2. ランダムアクセスが不可能: インデックスを指定して O(1) で要素にアクセスする手段がありません。N番目の要素にアクセスするには、イテレータを先頭から N回進める必要があり、これは O(N) の計算量がかかります。
  3. 挿入速度が unordered_set より遅い: 要素のハッシュ値に基づいて配置される std::unordered_set は、平均的に O(1) で要素を挿入できます。std::set は木構造の探索と再編成に O(log N) かかるため、単純な挿入速度だけを見れば unordered_set に劣ります。
  4. 要素の値を変更できない: イテレータ経由で要素の値自体を変更することはできません。変更したい場合は、削除して再挿入する手間が必要です。
  5. 比較可能性の要件: 格納する要素型は、定義された比較基準(デフォルトは operator<)で比較可能である必要があります。カスタム型の場合は、この要件を満たすように設計する必要があります。

どのような場合に std::set を使うか? (Use Cases)

std::set の特徴とメリット・デメリットを踏まえると、以下のような場面で std::set が適しています。

  • ユニークな要素のコレクションを保持したい: 重複を自動的に排除したい場合に最適です。例えば、システム内のアクティブなユーザーIDのリスト(重複なし)、処理済みのアイテムの追跡など。
  • 要素を常にソートされた順序で保持したい: 要素をいつでもソート順に走査したい場合に便利です。手動でソートしたり、挿入時に適切な位置を探したりする手間が省けます。例えば、スコアボードの上位N件、日付でソートされたイベントリストなど(ただし、値そのものが日付である必要あり)。
  • 要素の追加、削除、検索が頻繁に行われる: これらの操作が O(log N) で高速に実行されるため、動的なデータセットを扱う場合に適しています。
  • 要素の値そのものに基づいてアクセスしたい: キーが要素の値であるため、要素の値を知っていれば効率的にアクセスできます。
  • イテレータの無効化を最小限に抑えたい: 挿入や削除によるイテレータへの影響が限定的である点が役立つ場合があります。
  • 数学的な集合演算を効率的に行いたい: 和集合、積集合などの操作を実装する際に、std::set のソート順序とユニーク性が利用できます。

std::set と他のコンテナとの比較

std::set の理解を深めるために、他の標準コンテナと比較してみましょう。

  • std::vector:

    • メモリ: 連続メモリ。set よりメモリ効率が良い傾向。
    • アクセス: ランダムアクセス $O(1)$。set よりはるかに高速。
    • 挿入/削除: 末尾以外での挿入/削除は $O(N)$。set より遅い。
    • ソート/ユニーク: 自動ではない。手動で std::sort ($O(N \log N)$) および std::unique ($O(N)$) を行う必要がある。これらの操作後の挿入ではソート/ユニーク性を維持するための追加処理が必要。
    • 用途: 要素へのランダムアクセスが頻繁、末尾への追加が主、要素のソート/ユニーク化はまとめて行う、または不要な場合。
  • std::multiset:

    • 特徴: std::set とほぼ同じ(平衡二分探索木、ソート順序)。
    • ユニーク性: 重複要素を許容する点のみが異なる。
    • 用途: ソートされた状態で重複要素を保持したい場合。例えば、複数の出現をカウントする必要があるが、要素自体をソート順に管理したい場合。
  • std::unordered_set:

    • 特徴: ハッシュテーブルによる実装。
    • ユニーク性: std::set と同様にユニーク要素のみ。
    • ソート順序: 要素の順序は保証されない。格納順序はハッシュ値に依存。
    • アクセス/挿入/削除: 平均 $O(1)$、最悪 $O(N)$ (ハッシュ衝突が多い場合)。平均的には std::set より高速。
    • 用途: 順序が不要で、とにかく高速な挿入、削除、検索を行いたい場合。要素の型はハッシュ可能 (Hashable) である必要がある。
  • std::map:

    • 特徴: キーと値のペアを保持する連想コンテナ。std::set と同様に平衡二分探索木。
    • 格納: pair<const Key, Value> を格納。キーはユニークかつソートされる。
    • 用途: キーに基づいて関連する値をルックアップしたい場合。std::set は値そのものがキーだが、std::map は独立したキーと値を持つ。

まとめると:

  • ユニーク & ソート順が必要std::set
  • 重複許容 & ソート順が必要std::multiset
  • ユニーク & 高速(順序不問)std::unordered_set
  • キー-値ペア & ソート順が必要std::map
  • 重複許容 & 高速(順序不問)std::unordered_map
  • ランダムアクセス & 高速な末尾操作std::vector

これらの比較を理解することで、解決したい具体的な問題に対して最も適切なコンテナを選択できるようになります。

コード例 (Code Examples)

ここからは、具体的なコード例を通して std::set の様々な使い方を学びます。

例1: 基本的な使い方(挿入、繰り返し、サイズ、空判定)

“`cpp

include

include

include

int main() {
// int 型の空の set を作成
std::set numbers;

// 要素を挿入
numbers.insert(10);
numbers.insert(5);
numbers.insert(20);
numbers.insert(15);
numbers.insert(5); // 重複

// set のサイズを表示
std::cout << "Set size after insertions: " << numbers.size() << std::endl; // 出力: 4 (5は重複排除)

// set が空かどうか判定
if (!numbers.empty()) {
    std::cout << "Set is not empty." << std::endl;
}

// 要素をソート順に繰り返し処理 (range-based for loop)
std::cout << "Elements (sorted): ";
for (int num : numbers) {
    std::cout << num << " ";
}
std::cout << std::endl; // 出力: 5 10 15 20

// イテレータを使った繰り返し処理
std::cout << "Elements (using iterators): ";
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
    // *it は要素の const 参照
    std::cout << *it << " ";
}
std::cout << std::endl;

// 逆順での繰り返し処理 (reverse iterators)
std::cout << "Elements (reverse order): ";
for (auto it = numbers.rbegin(); it != numbers.rend(); ++it) {
    std::cout << *it << " ";
}
std::cout << std::endl; // 出力: 20 15 10 5

// 全要素をクリア
numbers.clear();
std::cout << "Set size after clear: " << numbers.size() << std::endl; // 出力: 0
if (numbers.empty()) {
    std::cout << "Set is empty." << std::endl;
}

return 0;

}
“`

例2: insert の戻り値を利用した重複チェック

insert(const value_type&)pair<iterator, bool> を返します。これを利用して、要素が新しく挿入されたのか、それとも既に存在していたのかを判断できます。

“`cpp

include

include

int main() {
std::set mySet;

// 10を挿入 -> 新規挿入
auto result1 = mySet.insert(10);
if (result1.second) {
    std::cout << *result1.first << " was inserted successfully." << std::endl; // 出力: 10 was inserted successfully.
} else {
    std::cout << *result1.first << " already existed." << std::endl;
}

// 20を挿入 -> 新規挿入
auto result2 = mySet.insert(20);
if (result2.second) {
    std::cout << *result2.first << " was inserted successfully." << std::endl; // 出力: 20 was inserted successfully.
} else {
    std::cout << *result2.first << " already existed." << std::endl;
}

// 10を挿入 -> 重複
auto result3 = mySet.insert(10);
if (result3.second) {
    std::cout << *result3.first << " was inserted successfully." << std::endl;
} else {
    std::cout << *result3.first << " already existed." << std::endl; // 出力: 10 already existed.
}

std::cout << "Final set size: " << mySet.size() << std::endl; // 出力: 2

return 0;

}
“`

例3: finderase の使い方

要素の検索や特定の要素の削除を行います。

“`cpp

include

include

int main() {
std::set numbers = {10, 20, 30, 40, 50};

// 要素 30 を検索
int value_to_find = 30;
auto it_find = numbers.find(value_to_find);

if (it_find != numbers.end()) {
    std::cout << value_to_find << " found in the set." << std::endl; // 出力: 30 found in the set.
    // 見つかった要素を削除
    std::cout << "Erasing " << *it_find << "..." << std::endl; // Erasing 30...
    numbers.erase(it_find); // イテレータを指定して削除
} else {
    std::cout << value_to_find << " not found in the set." << std::endl;
}

// 要素 60 を検索 (存在しない)
int value_to_find_2 = 60;
auto it_find_2 = numbers.find(value_to_find_2);
if (it_find_2 != numbers.end()) {
    std::cout << value_to_find_2 << " found in the set." << std::endl;
} else {
    std::cout << value_to_find_2 << " not found in the set." << std::endl; // 出力: 60 not found in the set.
}

// 要素 20 を値で削除
int value_to_erase = 20;
size_t erased_count = numbers.erase(value_to_erase); // 値を指定して削除
if (erased_count > 0) {
    std::cout << value_to_erase << " was erased from the set." << std::endl; // 出力: 20 was erased from the set.
} else {
    std::cout << value_to_erase << " was not found to erase." << std::endl;
}

// 現在のセットの内容を表示
std::cout << "Set after erasures: ";
for (int num : numbers) {
    std::cout << num << " "; // 出力: 10 40 50
}
std::cout << std::endl;

// 範囲削除
// {10, 40, 50} -> 40 から最後までを削除
auto it_begin_erase = numbers.find(40); // 40 を指すイテレータを取得
if (it_begin_erase != numbers.end()) {
    numbers.erase(it_begin_erase, numbers.end()); // [it_begin_erase, end()) の範囲を削除
}

std::cout << "Set after range erase: ";
for (int num : numbers) {
    std::cout << num << " "; // 出力: 10
}
std::cout << std::endl;

return 0;

}
``erase(iterator)` は、削除された要素の次の要素へのイテレータを返すため、ループ内で要素を削除しながら処理を進める場合に便利です。

“`cpp

include

include

int main() {
std::set numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

std::cout << "Initial set: ";
for (int num : numbers) {
    std::cout << num << " ";
}
std::cout << std::endl;

// 偶数を削除する
for (auto it = numbers.begin(); it != numbers.end(); ) {
    if (*it % 2 == 0) {
        std::cout << "Erasing " << *it << std::endl;
        it = numbers.erase(it); // 削除された要素の次の要素へのイテレータを取得
    } else {
        ++it; // 削除しない場合は次の要素へ進む
    }
}

std::cout << "Set after erasing even numbers: ";
for (int num : numbers) {
    std::cout << num << " "; // 出力: 1 3 5 7 9
}
std::cout << std::endl;

return 0;

}
“`

例4: 範囲と初期化リストによる構築/挿入

他のコンテナや配列の要素から std::set を構築したり、初期化リストで直接要素を指定したりできます。

“`cpp

include

include

include

include

int main() {
// 初期化リストからの構築
std::set set1 = {50, 10, 30, 10, 40, 20, 30};
std::cout << “Set from initializer list: “;
for (int num : set1) {
std::cout << num << ” “; // 出力: 10 20 30 40 50 (重複排除、ソート)
}
std::cout << std::endl;

// vector からの構築 (範囲指定)
std::vector<double> vec = {3.14, 1.618, 2.718, 1.618};
std::set<double> set2(vec.begin(), vec.end());
std::cout << "Set from vector: ";
for (double val : set2) {
    std::cout << val << " "; // 出力: 1.618 2.718 3.14 (重複排除、ソート)
}
std::cout << std::endl;

// 配列からの挿入 (範囲指定)
std::array<std::string, 5> arr = {"cat", "dog", "cat", "bird", "fish"};
std::set<std::string> set3;
set3.insert(arr.begin(), arr.end());
std::cout << "Set from array: ";
for (const std::string& str : set3) {
    std::cout << str << " "; // 出力: bird cat dog fish (重複排除、ソート)
}
std::cout << std::endl;

return 0;

}
“`

例5: カスタム比較関数オブジェクトの使用

要素をデフォルトの昇順ではなく、降順などでソートしたい場合に利用します。カスタム型の場合は、その型に対する比較関数が必要です。

“`cpp

include

include

include

include // std::greater のために必要

include // std::abs のために必要

// 絶対値で比較するカスタム比較関数オブジェクト
struct CompareAbsolute {
bool operator()(int a, int b) const {
return std::abs(a) < std::abs(b);
}
};

int main() {
// 降順にソートする std::set
std::set> descending_set;
descending_set.insert(10);
descending_set.insert(30);
descending_set.insert(20);
descending_set.insert(10); // 重複

std::cout << "Descending set: ";
for (int num : descending_set) {
    std::cout << num << " "; // 出力: 30 20 10
}
std::cout << std::endl;

// 絶対値の昇順にソートする std::set
// 注意: 同じ絶対値の要素 (-20 と 20) は、最初の1つだけが格納される
std::set<int, CompareAbsolute> abs_set;
abs_set.insert(20);
abs_set.insert(-10);
abs_set.insert(30);
abs_set.insert(-20); // 絶対値が重複 (20)

std::cout << "Absolute sorted set: ";
for (int num : abs_set) {
    std::cout << num << " "; // 出力: -10 20 30
}
std::cout << std::endl;
std::cout << "Absolute set size: " << abs_set.size() << std::endl; // 出力: 3

return 0;

}
``
カスタム比較関数を使用する場合、**等価性の判断は比較関数に基づいて行われる**ことに注意が必要です。デフォルトの
std::lessの場合、!(a < b) && !(b < a)abが等価であると判断される条件です。カスタム比較関数CompareAbsoluteの場合、!CompareAbsolute()(a, b) && !CompareAbsolute()(b, a)が等価と判断されます。例えば-2020の場合、CompareAbsolute()(-20, 20)abs(-20) < abs(20)(すなわち20 < 20) でfalseCompareAbsolute()(20, -20)abs(20) < abs(-20)(すなわち20 < 20) でfalseです。したがって!false && !falsetrueとなり、-2020CompareAbsoluteの基準では等価であると判断されます。このため、どちらか一方しかabs_set` に格納されません。

例6: lower_boundupper_bound の使い方

特定の範囲の要素を取得したり、要素の存在を効率的に確認したりするのに便利です。

“`cpp

include

include

int main() {
std::set numbers = {10, 20, 30, 40, 50, 60, 70};

int lower_val = 30;
int upper_val = 60;

// lower_bound: lower_val 以上の最初の要素を指すイテレータ
auto it_lower = numbers.lower_bound(lower_val);
// upper_bound: upper_val より大きい最初の要素を指すイテレータ
auto it_upper = numbers.upper_bound(upper_val);

std::cout << "Elements in range [" << lower_val << ", " << upper_val << "]: ";
// 範囲 [it_lower, it_upper) を繰り返し処理
for (auto it = it_lower; it != it_upper; ++it) {
    std::cout << *it << " "; // 出力: 30 40 50 60
}
std::cout << std::endl;

// 要素 30 が存在するかどうかを lower_bound/upper_bound で確認
auto range_30 = numbers.equal_range(30);
if (range_30.first != range_30.second) {
    std::cout << "Element 30 exists." << std::endl; // 出力: Element 30 exists.
} else {
    std::cout << "Element 30 does not exist." << std::endl;
}

// 要素 35 が存在するかどうかを確認
auto range_35 = numbers.equal_range(35);
 if (range_35.first != range_35.second) {
    std::cout << "Element 35 exists." << std::endl;
} else {
    std::cout << "Element 35 does not exist." << std::endl; // 出力: Element 35 does not exist.
}

return 0;

}
``equal_range(value)は、lower_bound(value)upper_bound(value)をペアとして返します。std::setでは重複がないため、valueが存在すればlower_boundはその要素を、upper_boundはその次の要素を指し、range_30.first != range_30.secondとなります。存在しなければ、両方のイテレータがvalueが挿入されるべき位置(lower_boundの結果と同じ)を指し、range_35.first == range_35.secondとなります。この性質は、std::multiset` で特定の値を持つ要素の範囲を取得する際にも利用できます。

例7: setを使った集合演算(和集合、積集合、差集合)

C++ 標準ライブラリには、ソートされた範囲に対して集合演算を行うアルゴリズムが用意されています(<algorithm> ヘッダー)。std::set は常にソートされているため、これらのアルゴリズムを直接適用できます。結果を新しい std::set に格納することで、ユニーク性とソート順序を保ったまま集合演算の結果を得られます。

“`cpp

include

include

include // std::set_union, std::set_intersection, std::set_difference

int main() {
std::set set1 = {10, 20, 30, 40, 50};
std::set set2 = {30, 40, 50, 60, 70};

std::cout << "Set 1: ";
for (int num : set1) std::cout << num << " "; std::cout << std::endl;
std::cout << "Set 2: ";
for (int num : set2) std::cout << num << " "; std::cout << std::endl;

// 和集合 (Union): set1 または set2 の少なくとも一方に含まれる要素
std::set<int> set_union_result;
std::set_union(set1.begin(), set1.end(),
               set2.begin(), set2.end(),
               std::inserter(set_union_result, set_union_result.begin()));

std::cout << "Union (set1 | set2): ";
for (int num : set_union_result) std::cout << num << " "; // 出力: 10 20 30 40 50 60 70
std::cout << std::endl;

// 積集合 (Intersection): set1 と set2 の両方に含まれる要素
std::set<int> set_intersection_result;
std::set_intersection(set1.begin(), set1.end(),
                      set2.begin(), set2.end(),
                      std::inserter(set_intersection_result, set_intersection_result.begin()));

std::cout << "Intersection (set1 & set2): ";
for (int num : set_intersection_result) std::cout << num << " "; // 出力: 30 40 50
std::cout << std::endl;

// 差集合 (Difference): set1 に含まれるが set2 には含まれない要素 (set1 - set2)
std::set<int> set_difference_result;
std::set_difference(set1.begin(), set1.end(),
                    set2.begin(), set2.end(),
                    std::inserter(set_difference_result, set_difference_result.begin()));

std::cout << "Difference (set1 - set2): ";
for (int num : set_difference_result) std::cout << num << " "; // 出力: 10 20
std::cout << std::endl;

// 対称差集合 (Symmetric Difference): どちらか一方にのみ含まれる要素 ((set1 | set2) - (set1 & set2))
std::set<int> set_symmetric_difference_result;
std::set_symmetric_difference(set1.begin(), set1.end(),
                              set2.begin(), set2.end(),
                              std::inserter(set_symmetric_difference_result, set_symmetric_difference_result.begin()));

std::cout << "Symmetric Difference: ";
for (int num : set_symmetric_difference_result) std::cout << num << " "; // 出力: 10 20 60 70
std::cout << std::endl;

return 0;

}
``
これらのアルゴリズムは、出力イテレータを受け取ります。ここでは
std::inserterを使用しています。std::inserter(container, iterator)は、containeriteratorが指す位置に要素を挿入するための特殊なイテレータ(インサータ)を作成します。std::setの場合、挿入位置は自動的にソート順序に従って決定されるため、インサータの第2引数に渡すイテレータは何でも構いません(ここではset_union_result.begin()` などを使っていますが、実際には無視されます)。

これらの集合演算アルゴリズムは、入力範囲がソートされていることを前提としており、std::set はこの要件を満たしているため効率的に機能します。計算量は通常、入力セットのサイズ $N$ と $M$ に対して $O(N+M)$ となります。

よくある落とし穴と注意点

std::set を使用する際に注意すべき点をいくつか挙げます。

  1. 要素の値の変更: 前述の通り、std::set のイテレータや参照を介して要素の値自体を変更することは禁止されています。これは、値を変更するとソート順序やユニーク性の制約が破られる可能性があるためです。もし要素の値を変更したい場合は、その要素を一度削除し、新しい値の要素を再挿入する必要があります。
  2. イテレータの無効化: 要素が削除された場合、その削除された要素を指していたイテレータのみが無効になります。他の要素を指すイテレータは有効なままです。これは std::vector などに比べてイテレータの管理が容易な点ですが、削除直後に無効なイテレータにアクセスしないよう注意が必要です。erase(iterator) の戻り値(削除された要素の次の要素を指すイテレータ)を正しく利用することが、ループ内で安全に削除を行う鍵です。
  3. カスタム型の比較: カスタム型を格納する場合、比較関数オブジェクトまたは operator< の定義が必要です。この比較が「厳密弱順序」を満たしていることを確認してください。特に、等価性の判断が意図通りに行われるか(例: 大文字小文字を区別しない比較など)を確認することが重要です。比較関数が正しく定義されていないと、コンテナの動作が不定になる可能性があります。
  4. メモリ消費: 大量の要素を格納する場合、ポインタや管理情報のオーバーヘッドにより、std::vector より多くのメモリを消費する可能性があることを考慮に入れてください。
  5. ヒント付き挿入の活用: 大量の要素をまとめて挿入する場合や、新しい要素が挿入される位置の見当がついている場合は、ヒント付きの insert を使うことでパフォーマンスが向上する可能性があります。例えば、既にソートされた範囲を挿入する場合、前回の挿入位置をヒントとして与えると効率的です。

まとめ

std::set は、C++ 標準ライブラリが提供する非常に有用なコンテナです。その最大の強みは、ユニークな要素を常にソートされた状態で効率的に管理できる点にあります。内部的には平衡二分探索木によって実装されており、挿入、削除、検索といった主要な操作を対数時間 $O(\log N)$ で実行できるため、要素数が増加しても高いパフォーマンスを維持できます。

自動的なユニーク性の維持とソートは多くのプログラミング課題を簡潔に解決し、イテレータの無効化が限定的である点はコードの安全性を高めます。一方で、メモリオーバーヘッドやランダムアクセスが遅いといったトレードオフも存在します。

どのコンテナを選択するかは、解決したい問題の性質(データの特性、必要な操作、パフォーマンス要件)によって異なります。ユニーク性とソート順序が重要であり、要素の挿入・削除・検索が頻繁に行われる場面では、std::set は最も適した選択肢の一つとなるでしょう。

この記事を通して、std::set の基本的な概念から、内部実装、詳細な特徴、メリット・デメリット、そして様々なコード例まで、深く理解していただけたなら幸いです。std::set を効果的に活用することで、C++ プログラムの設計と実装がより効率的かつ堅牢になります。


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール