私の最初のプログラミングコースでは、何かの重複を削除するなどの必要があるときは常にセットを使用するように言われました。例:ベクトルからすべての重複を削除するには、上記のベクトルを繰り返し処理して各要素をセットに追加すると、一意のオカレンスが残ります。ただし、各elementoを別のベクトルに追加し、その要素が既に存在するかどうかを確認することで、これを行うこともできます。使用する言語によってパフォーマンスに違いがあると思います。しかし、それ以外のセットを使用する理由はありますか?
基本的に:どのような種類のアルゴリズムがセットを必要とし、他のコンテナータイプでは実行すべきではないのですか?
あなたは特にセットについて質問していますが、あなたの質問はより大きな概念、つまり抽象化に関するものだと思います。 Vectorを使用してこれを実行できることは間違いありません(Javaを使用している場合は、代わりにArrayListを使用してください)。ベクターは何のために必要ですか?これはすべて配列で行うことができます。
配列に項目を追加する必要があるときはいつでも、単純にすべての要素をループして、そこにない場合は最後に追加できます。ただし、実際には、最初に配列にスペースがあるかどうかを確認する必要があります。ない場合は、より大きな新しい配列を作成し、既存のすべての要素を古い配列から新しい配列にコピーする必要があります。その後、新しい要素を追加できます。もちろん、古い配列へのすべての参照を更新して、新しい配列を指すようにする必要もあります。すべて完了しましたか?すごい!では、もう一度何をしようとしていたのでしょうか。
または、代わりにSetインスタンスを使用してadd()
を呼び出すこともできます。セットが存在する理由は、セットが多くの一般的な問題に役立つ抽象であるからです。たとえば、アイテムを追跡し、新しいアイテムが追加されたときに反応したいとします。セットでadd()
を呼び出すと、セットが変更されたかどうかに応じてtrue
またはfalse
が返されます。すべてプリミティブを使用して手動で作成できますが、なぜですか?
実際にリストがあり、重複を削除したい場合があります。あなたが提案するアルゴリズムは、基本的にあなたがそれを行うことができる最も遅い方法です。いくつかの一般的な迅速な方法があります:それらをバケット化またはソートします。または、これらのアルゴリズムの1つを実装するセットに追加することもできます。
あなたのキャリア/教育の早い段階での焦点は、これらのアルゴリズムの構築とそれらの理解にあり、それを行うことが重要です。しかし、それはプロの開発者が通常行うことではありません。彼らはこれらのアプローチを使用してより興味深いものを構築し、事前に構築された信頼性の高い実装を使用することで、時間を大幅に節約できます。
使用する言語によってパフォーマンスに違いがあると思います。しかし、それ以外のセットを使用する理由はありますか?
ああ、はい(ただし、パフォーマンスではありません)。
set を使用できるのは、使用しない場合は追加のコードを記述する必要があるためです。セットを使用すると、何をしているのかが読みやすくなります。一意性ロジックのテストはすべて、それについて考える必要がない他の場所に隠されています。すでにテスト済みの場所にあり、動作することを信頼できます。
それを行うために独自のコードを書いてください、そしてあなたはそれについて心配する必要があります。ブレー。誰がそれをしたいですか?
基本的に:どのような種類のアルゴリズムがセットを必要とし、他のコンテナータイプでは実行すべきではないのですか?
「他の種類のコンテナでは実行すべきではない」アルゴリズムはありません。セットを利用できる単純なアルゴリズムがあります。追加のコードを書く必要がないのはいいことです。
現在、この点に関してセットについて特別なことは何もありません。常にニーズに最も適したコレクションを使用する必要があります。 Javaこの画像は、その決定に役立つことがわかりました。3種類のセットがあることに気づくでしょう。
@germiが正しく指摘しているように、ジョブに適切なコレクションを使用すると、コードが他の人にとって読みやすくなります。
ただし、各elementoを別のベクトルに追加し、要素が既に存在するかどうかを確認することで、これを行うこともできます。
それを行う場合は、ベクターデータ構造の上にセットのセマンティクスを実装していますです。余分なコード(エラーが含まれる可能性があります)を記述しているため、多くのエントリがある場合、結果は非常に遅くなります。
既存のテスト済みの効率的なセット実装を使用してそれを行うのはなぜですか?
多くの場合、実際のエンティティを表すソフトウェアエンティティは論理的にセットされています。たとえば、車を考えてみましょう。車には一意の識別子があり、車のグループがセットを形成します。セットの概念は、プログラムが知ることができる車のコレクションに対する制約として機能し、データ値の制約は非常に価値があります。
また、集合には非常に明確に定義された代数があります。 Georgeが所有するCarsのセットとAliceが所有するSetのセットがある場合、GeorgeとAliceが同じ車を所有していても、結合は明らかにGeorgeとAliceの両方が所有するセットです。したがって、セットを使用する必要があるアルゴリズムは、関係するエンティティのロジックがセットの特性を示すアルゴリズムです。それはかなり一般的であることが判明しました。
セットの実装方法と一意性制約の保証方法は別の問題です。セットがロジックにとって非常に基本的であることを考えると、重複を排除するセットロジックの適切な実装を見つけられることが望まれますが、独自に実装を行ったとしても、一意性の保証は、セットへのアイテムの挿入に固有です。 「要素がすでに存在するかどうかを確認する」必要はありません。
パフォーマンス特性(非常に重要であり、簡単に却下することはできません)を除いて、セットは抽象コレクションとして非常に重要です。
配列でSet動作(パフォーマンスを無視)をエミュレートできますか?そのとおり!挿入するたびに、要素がすでに配列内にあるかどうかを確認し、要素がまだ見つからない場合にのみ要素を追加できます。しかし、それは意識的に意識しなければならないことであり、Array-Psuedo-Setに挿入するたびに覚えておいてください。ああ、何ですか、最初に重複をチェックせずに、直接挿入しましたか?ウェルプ、あなたの配列はその不変式を壊しています(すべての要素が一意であり、同等に、重複が存在しないこと)。
それを回避するにはどうしますか?新しいデータ型を作成し、それを呼び出します(たとえば、PsuedoSet
)。これは、内部配列をラップし、insert
操作を公開して、要素の一意性を強制します。ラップされた配列には、このパブリックinsert
APIを介してのみアクセスできるため、重複が発生しないことを保証します。次に、ハッシュを追加してcontains
チェックのパフォーマンスを向上させます。遅かれ早かれ、完全なSet
を実装したことがわかります。
私も声明で応答し、質問をフォローアップします:
私の最初のプログラミングコースでは、何かの複数の順序付けられた要素を格納するなどの必要があるときはいつでも配列を使用するように言われました。例:同僚の名前のコレクションを保存するため。ただし、未加工のメモリを割り当て、開始ポインタ+オフセットで指定されたメモリアドレスの値を設定することで、これを行うこともできます。
生のポインタと固定オフセットを使用して配列を模倣できますか?そのとおり!挿入するたびに、オフセットが、作業中の割り当てられたメモリの終わりから逸脱しないかどうかを確認できます。しかし、それは意識的に意識しなければならないことであり、疑似配列に挿入するたびに覚えておいてください。あれは何ですか、最初にオフセットをチェックせずに直接1回挿入しましたか?ウェルプ、あなたの名前にセグメンテーション違反があります!
それを回避するにはどうしますか?新しいデータ型を作成し、それを呼び出します(たとえば、PsuedoArray
)。これは、ポインターとサイズをラップし、insert
操作を公開します。これにより、オフセットは強制されません。サイズを超えています。ラップされたデータにはこのパブリックinsert
APIを介してのみアクセスできるため、バッファオーバーフローが発生しないことを保証します。ここで、他のいくつかの便利な関数(配列のサイズ変更、要素の削除など)を追加します。遅かれ早かれ、フルアウトArray
を実装したことがわかります。
すべての種類のセットベースのアルゴリズムがあります。特に、セットの共通部分とユニオンを実行し、結果をセットにする必要がある場合です。
セットベースのアルゴリズムは、さまざまな経路探索アルゴリズムなどで頻繁に使用されます。
セット理論の入門書については、このリンクをチェックしてください: http://people.umass.edu/partee/NZ_2006/Set%20Theory%20Basics.pdf
セットのセマンティクスが必要な場合は、セットを使用します。ある段階でベクター/リストをプルーニングするのを忘れたため、偽の重複によるバグを回避し、ベクター/リストを常にプルーニングするよりも速くなります。
私は実際には標準セットのコンテナーはほとんど役に立たないことがわかり、配列だけを使用することを好みますが、それは別の方法で行います。
セットの交差を計算するために、最初の配列を反復処理して、要素を1ビットでマークします。次に、2番目の配列を反復処理して、マークされた要素を探します。出来上がり。ハッシュテーブルよりもはるかに少ない作業とメモリで、線形時間で交差を設定します。ユニオンと差異は、この方法を使用して適用するのも同じくらい簡単です。それは私のコードベースがそれらを複製するのではなく要素のインデックス付けを中心に展開するのに役立ちます(私は要素自体のデータではなく要素にインデックスを複製します)ソートする必要があることはめったにありませんが、次のように何年もセットデータ構造を使用していません結果。
また、要素がそのような目的でデータフィールドを提供していない場合でも、私が使用するいくつかの邪悪なビットいじくるCコードがあります。トラバースされた要素をマークする目的で最上位ビット(私が使用することはありません)を設定することにより、要素自体のメモリを使用します。それはかなり大げさですが、実際にアセンブリレベルに近いレベルで作業している場合を除き、それを行わないでください。ただし、要素がトラバーサルに固有のフィールドを提供しない場合でも、それがどのように適用できるかを説明したかっただけです。特定のビットは使用されません。私のdinky i7で2億個の要素(データの約2.4ギグ)間のセット交差を1秒未満で計算できます。それぞれ同時に1億個の要素を含む2つの_std::set
_インスタンス間の集合交差を試してください。近づくことすらありません。
それはさておき...
ただし、各elementoを別のベクトルに追加し、要素が既に存在するかどうかを確認することによっても、これを行うことができます。
要素が新しいベクトルに既に存在するかどうかを確認するこのチェックは、通常、線形時間演算になります。これにより、セットの交差自体が2次演算になります(爆発的な量の作業が入力サイズが大きくなる)。単純な古いベクトルまたは配列を使用し、見事にスケーリングする方法で実行する場合は、上記の手法をお勧めします。
基本的に:どのような種類のアルゴリズムがセットを必要とし、他のコンテナータイプでは実行すべきではありませんか?
コンテナーレベルでそれについて話しているのか(セット操作を効率的に提供するために具体的に実装されているデータ構造のように)、私の偏った意見を聞いた場合はありませんが、概念レベルでセットロジックを必要とするものはたくさんあります。たとえば、飛行と水泳の両方が可能なゲームの世界にいる生き物を見つけたいとします。あるセット(実際にセットコンテナを使用するかどうかに関係なく)と他のセットで泳ぐことができる飛行クリーチャーがいるとします。 。その場合は、交差点を設定します。飛ぶことができる、または魔法のようなクリーチャーが必要な場合は、集合和集合を使用します。もちろん、これを実装するために実際にセットコンテナーは必要ありません。最も最適な実装では、通常、セットとして特別に設計されたコンテナーは必要ありません。
オフタンジェント
よし、ジミージェームスからこのセット交差点アプローチに関していくつかの素敵な質問を受けた。それはちょっと話題から外れていますが、まあ、私はより多くの人々がこの基本的な侵入型アプローチを使用して交差を設定し、バランスの取れた二分木やハッシュテーブルなどの補助構造全体を単にセット操作の目的で構築していないことを知りたいと思っています。前述のように、基本的な要件は、リストが浅いコピー要素であるため、最初の並べ替えられていないリストまたは配列または2番目にピックアップするものを通過するときに「マーク」できる共有要素をインデックスまたはポイントすることです。 2番目のリストを通過します。
ただし、これは、次の条件が満たされていれば、マルチスレッドのコンテキストでも要素に触れることなく実際に実現できます。
これにより、集合演算のために並列配列(要素ごとに1ビットのみ)を使用できます。図:
スレッド同期は、プールから並列ビット配列を取得し、それをプールに解放するときにのみ必要です(スコープ外に出ると暗黙的に行われます)。 set操作を実行する実際の2つのループは、スレッドの同期を必要としません。スレッドがビットをローカルに割り当てて解放できる場合は、並列ビットプールを使用する必要さえありませんが、ビットプールは、中央の要素が頻繁に参照されるこの種のデータ表現に適合するコードベースでパターンを一般化するのに便利です。各スレッドが効率的なメモリ管理に煩わされる必要がないように、インデックスによって。私の領域の主な例は、エンティティコンポーネントシステムとインデックス付きメッシュ表現です。どちらも頻繁にセットの交差が必要であり、中央に格納されているすべてのもの(ECSのコンポーネントとエンティティ、インデックス付きメッシュの頂点、エッジ、ポリゴン)をインデックスで参照する傾向があります。
インデックスが密に占有されず、まばらに散在している場合でも、512ビットのチャンク(512の隣接するインデックスを表す展開されていないノードごとに64バイト)にのみメモリを格納するような、並列ビット/ブール配列の合理的なまばらな実装でこれは引き続き適用できます。 )、完全に空いている連続したブロックの割り当てをスキップします。中央のデータ構造が要素自体によってまばらに占められている場合、おそらくこのようなものをすでに使用している可能性があります。
...並列ビット配列として機能するスパースビットセットの同様のアイデア。新しい不変コピーを作成するために深くコピーする必要のないチャンクブロックを浅くコピーするのは簡単なので、これらの構造は不変性にも役立ちます。
この場合も、非常に平均的なマシンでこのアプローチを使用すると、数億個の要素間の交差を1秒未満で設定でき、それは単一のスレッド内にあります。
また、クライアントが両方のリストにある要素にロジックを適用するだけでよい場合など、クライアントが結果の共通部分の要素のリストを必要としない場合は、半分未満の時間で実行できます。関数ポインタ、ファンクタ、デリゲート、または交差する要素の範囲を処理するためにコールバックされるもの。この効果に何か:
_// 'func' receives a range of indices to
// process.
set_intersection(func):
{
parallel_bits = bit_pool.acquire()
// Mark the indices found in the first list.
for each index in list1:
parallel_bits[index] = 1
// Look for the first element in the second list
// that intersects.
first = -1
for each index in list2:
{
if parallel_bits[index] == 1:
{
first = index
break
}
}
// Look for elements that don't intersect in the second
// list to call func for each range of elements that do
// intersect.
for each index in list2 starting from first:
{
if parallel_bits[index] != 1:
{
func(first, index)
first = index
}
}
If first != list2.num-1:
func(first, list2.num)
}
_
...またはこの効果に何か。最初の図の疑似コードの最も高価な部分は、2番目のループのintersection.append(index)
であり、これは、事前に小さいリストのサイズに予約されている_std::vector
_にも適用されます。
すべてをディープコピーするとどうなりますか?
やめて、やめて!交差を設定する必要がある場合は、交差するデータを複製していることを意味します。最も小さなオブジェクトでさえ、32ビットのインデックスより小さくない可能性があります。実際にインスタンス化される〜43億を超える要素が必要な場合を除いて、要素のアドレス指定範囲を2 ^ 32(2 ^ 32バイトではなく2 ^ 32要素)に減らすことは非常に可能です。その時点で、まったく異なるソリューションが必要です(そしてそれは間違いなくメモリ内のセットコンテナを使用していません)。
キーマッチ
要素は同一ではないが一致するキーを持つ可能性がある場合に、集合演算を行う必要がある場合はどうでしょうか?その場合、上記と同じ考えです。一意の各キーをインデックスにマップする必要があるだけです。たとえば、キーが文字列の場合、インターンされた文字列はそれを行うことができます。これらの場合、トライやハッシュテーブルなどのニースデータ構造が文字列キーを32ビットインデックスにマップするために呼び出されますが、結果の32ビットインデックスで集合演算を行うためにそのような構造は必要ありません。
非常に安価で簡単なアルゴリズムソリューションとデータ構造の多くは、マシンの完全なアドレス指定範囲ではなく、非常に合理的な範囲の要素のインデックスを操作できる場合に、このように開かれます。そのため、多くの場合、それだけの価値があります。一意のキーごとに一意のインデックスを取得できます。
I Love Indices!
ピザやビールと同じくらい私はインデックスが大好きです。私が20代のとき、私は本当にC++に入り、あらゆる種類の完全に標準に準拠したデータ構造の設計を開始しました(コンパイル時に範囲アクタから範囲アクタを明確にするために必要なトリックを含みます)。振り返ってみると、それは大きな時間の無駄でした。
要素を断片化して、場合によってはマシンのアドレス指定可能な範囲全体に格納するのではなく、配列に要素を集中的に格納し、それらにインデックスを付けることを中心にデータベースを展開すると、アルゴリズムとデータ構造の可能性の世界を探ることができます。単純な古いint
または_int32_t
_を中心に展開するコンテナーとアルゴリズムの設計。また、あるデータ構造から別のデータ構造に要素を常に転送していない場合でも、最終的な結果がはるかに効率的で維持しやすいことがわかりました。
T
の一意の値には一意のインデックスがあり、中央の配列にインスタンスが存在すると想定できるユースケースの例:
インデックス用の符号なし整数とうまく機能するマルチスレッド基数ソート。実際には、1億の要素をIntel独自の並列ソートとしてソートするのに約1/10の時間を要するマルチスレッドの基数ソートがあり、Intelはそのような大きな入力に対して_std::sort
_よりも4倍高速です。もちろん、Intelは比較ベースのソートであり、辞書順でソートできるため、はるかに柔軟性が高く、リンゴとオレンジを比較します。しかし、ここでは、キャッシュに適したメモリアクセスパターンを実現したり、重複をすばやく除外したりするために基数ソートパスを実行する場合があるように、多くの場合、オレンジだけが必要です。
ノードごとのヒープ割り当てなしで、リンクリスト、ツリー、グラフ、個別のチェーンハッシュテーブルなどのリンク構造を構築する機能。ノードを一括で要素に平行に割り当て、それらをインデックスでリンクすることができます。ノード自体は次のノードの32ビットインデックスになり、次のように大きな配列に格納されます。
並列処理に適しています。リンク構造は、並列処理にあまり適していません。これは、ツリーまたはリンクリストトラバーサルで並列処理を実現しようとするのではなく、 、たとえば、配列の並列forループを実行するだけです。インデックス/中央配列表現を使用すると、常にその中央配列に移動して、すべてを分厚い並列ループで処理できます。一部のみを処理したい場合でも、この方法で処理できるすべての要素の中央配列が常にあります(この時点で、中央配列を介したキャッシュに適したアクセスのために、基数ソートリストによってインデックスが付けられた要素を処理できます)。
一定の時間でその場で各要素にデータを関連付けることができます。上記のビットの並列配列の場合と同様に、たとえば一時処理のために、並列データを要素に簡単かつ非常に安価に関連付けることができます。これには、一時データ以外のユースケースがあります。たとえば、メッシュシステムでは、ユーザーが好きなだけメッシュにUVマップをアタッチできるようにする場合があります。このような場合、AoSアプローチを使用して、すべての頂点と面にUVマップがいくつあるかをハードコードすることはできません。そのようなデータをその場で関連付けることができる必要があり、並列配列はそこで便利であり、あらゆる種類の洗練された連想コンテナ、さらにはハッシュテーブルよりもはるかに安価です。
もちろん、並列配列は、並列配列を互いに同期させておくというエラーが発生しやすい性質があるため、不快に感じられます。たとえば、「ルート」配列からインデックス7の要素を削除するときはいつでも、「子」に対しても同じことを行う必要があります。ただし、ほとんどの言語ではこの概念を汎用コンテナに一般化するのは簡単なので、並列配列を互いに同期させるトリッキーなロジックは、コードベース全体で1か所に存在するだけでよく、そのような並列配列コンテナは上記のスパース配列の実装を使用して、後続の挿入時に再利用される配列内の隣接する空のスペースのために大量のメモリを浪費しないようにします。
詳細:スパースビットセットツリー
さて、私は皮肉なことだと思うもう少し詳しく説明するように依頼されましたが、とにかくそうするつもりなので、とても楽しいです!このアイデアをまったく新しいレベルに引き上げたい場合は、N + M要素を直線的にループすることなく、セットの交差を実行できます。これは私が長い間使用してきた基本的なデータ構造であり、基本的には_set<int>
_をモデル化しています。
両方のリストの各要素を検査することなくセットの交差を実行できる理由は、階層のルートにある単一のセットビットが、たとえば、100万個の連続する要素がセットで占有されていることを示すことができるためです。 1ビットを調べるだけで、範囲内のN個のインデックス_[first,first+N)
_がセットに含まれていることがわかります。Nは非常に大きな数になる可能性があります。
セット内で800万のインデックスが占有されているとしましょう。占有されたインデックスをトラバースするときに、これをループオプティマイザーとして実際に使用します。その場合、通常、メモリ内の800万の整数にアクセスする必要があります。これを使用すると、潜在的に数ビットを検査して、ループするために占有インデックスのインデックス範囲を作成できます。さらに、それが提供するインデックスの範囲はソートされた順序になっているため、たとえば、元の要素データへのアクセスに使用されるインデックスのソートされていない配列を反復するのとは対照的に、非常にキャッシュフレンドリーな順次アクセスが可能になります。もちろん、この手法は極端にまばらなケースではさらに悪くなります。最悪のシナリオでは、すべてのインデックスが偶数である(またはすべてのインデックスが奇数である)場合があり、その場合、隣接する領域はまったくありません。しかし、少なくとも私の使用例では、それが実際に発生することはありません。