私は実際には標準セットコンテナーはほとんど役に立たないことがわかり、配列だけを使用することを好みますが、それは別の方法で行います。
セットの交差を計算するために、最初の配列を反復処理し、要素を1ビットでマークします。次に、2番目の配列を反復処理して、マークされた要素を探します。ちなみに、ハッシュテーブルよりもはるかに少ない作業とメモリで線形時間で交差を設定します。たとえば、ユニオンと差分は、この方法を使用して適用するのも同じくらい簡単です。それは私のコードベースがそれらを複製するのではなくインデックス付け要素を中心に展開するのに役立ち(要素自体のデータではなく要素にインデックスを複製します)、ソートする必要があることはほとんどありませんが、私は何年もセットデータ構造を使用していませんでした。結果。
また、要素がそのような目的でデータフィールドを提供していない場合でも、私が使用するいくつかの邪悪なビット操作Cコードがあります。トラバースされた要素をマークする目的で最上位ビット(私が使用することはありません)を設定することにより、要素自体のメモリを使用します。それはかなり大まかなことですが、アセンブリに近いレベルで実際に作業している場合を除き、そうしないでください。ただし、要素がトラバーサルに固有のフィールドを提供しない場合でも、それがどのように適用できるかを説明したかっただけです。特定のビットは使用されません。私のdinky i7で2億個の要素(データの約2.4ギグ)間のセット交差を1秒未満で計算できます。std::set
それぞれ同時に1億個の要素を含む2つのインスタンス間の集合交差を実行してみてください。近づくことすらありません。
それはさておき...
ただし、各elementoを別のベクトルに追加し、要素が既に存在するかどうかを確認することで、これを行うこともできます。
要素が新しいベクトルに既に存在するかどうかを確認するチェックは、通常、線形時間演算になります。これにより、セットの交点自体が2次演算になります(爆発的な量の作業が入力サイズが大きくなる)。単純な古いベクトルまたは配列を使用し、見事にスケーリングする方法で実行する場合は、上記の手法をお勧めします。
基本的に、どのような種類のアルゴリズムがセットを必要とし、他の種類のコンテナでは実行すべきではないのですか?
コンテナーレベルでそれについて話しているのか(セット操作を効率的に提供するために具体的に実装されているデータ構造の場合など)、偏った意見を聞いた場合はありませんが、概念レベルでセットロジックを必要とするものはたくさんあります。たとえば、飛行と水泳の両方が可能なゲームの世界にいる生き物を見つけたいとし、あるセット(実際にセットコンテナを使用しているかどうかに関係なく)と別のセットで泳ぐことができる空飛ぶクリーチャーがあるとします。 。その場合は、交差点を設定します。飛ぶことができる、または魔法のようなクリーチャーが必要な場合は、集合和集合を使用します。もちろん、実際にこれを実装するためにセットコンテナーは必要ありません。最も最適な実装では、通常、セットとして設計されたコンテナーを必要としないか、必要としません。
接線から外れます
よし、このセット交差点アプローチに関して、JimmyJamesからいい質問を受けた。それはちょっと話題から外れていますが、まあ、私はより多くの人々がこの基本的な侵入型アプローチを使用して交差を設定し、バランスの取れたバイナリツリーやハッシュテーブルなどの補助構造全体を設定操作のためだけに構築していないことを知りたいと思っています。前述のように、基本的な要件は、リストが浅いコピー要素であるため、最初の並べ替えられていないリストまたは配列または2番目にピックアップするものを通過するときに「マーク」できる共有要素をインデックスまたはポイントすることです。 2番目のリストを通過します。
ただし、これは、次の条件が満たされていれば、マルチスレッドのコンテキストでも要素に触れずに実際に実行できます。
- 2つの集約には、要素へのインデックスが含まれています。
- インデックスの範囲は大きすぎず(たとえば[0、2 ^ 26]、数十億以上ではない)、かなり密に占有されています。
これにより、集合演算のために並列配列(要素ごとに1ビットのみ)を使用できます。図:
スレッド同期は、プールから並列ビット配列を取得し、それをプールに解放するときにのみ必要です(スコープ外に出ると暗黙的に行われます)。設定操作を実行するための実際の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)
}
...またはこの効果に何か。最初の図の疑似コードの最も高価な部分はintersection.append(index)
2番目のループにありstd::vector
、それは事前に小さいリストのサイズに予約されている場合にも当てはまります。
すべてをディープコピーするとどうなりますか?
やめて、やめて!交差を設定する必要がある場合は、交差するデータを複製していることを意味します。ごく小さなオブジェクトでさえ、32ビットのインデックスより小さくない可能性があります。実際にインスタンス化されるエレメントが43億個を超える必要がない限り、エレメントのアドレス範囲を2 ^ 32(2 ^ 32バイトではなく2 ^ 32エレメント)に減らすことは非常に可能です。そしてそれは間違いなくメモリ内のセットコンテナを使用していません)。
キーマッチ
要素が同一ではないが一致するキーを持つ可能性がある場合に、セット演算を実行する必要がある場合はどうでしょうか?その場合、上記と同じ考えです。一意の各キーをインデックスにマップする必要があるだけです。たとえば、キーが文字列の場合、インターンされた文字列はそれを行うことができます。これらの場合、トライやハッシュテーブルなどの素晴らしいデータ構造が文字列キーを32ビットインデックスにマップするために呼び出されますが、結果の32ビットインデックスで集合演算を行うためにそのような構造は必要ありません。
非常に安価で簡単なアルゴリズムソリューションとデータ構造の多くは、マシンの完全なアドレス指定範囲ではなく、非常に合理的な範囲の要素のインデックスを操作できる場合に、このように開きます。一意のキーごとに一意のインデックスを取得できます。
インデックスが大好きです!
ピザやビールと同じくらい私はインデックスが大好きです。私が20代のとき、私は本当にC ++に入り、すべての種類の完全に標準に準拠したデータ構造(コンパイル時に範囲アクタから範囲アクタを明確にするために必要なトリックを含む)の設計を始めました。振り返ってみると、それは大きな時間の無駄でした。
要素を断片化して、場合によってはマシンのアドレス可能な範囲全体に格納するのではなく、配列に要素を集中的に格納し、それらにインデックスを付けることを中心にデータベースを展開すると、アルゴリズムとデータ構造の可能性の世界を探ることができます。プレーンオールドint
またはを中心に展開するコンテナとアルゴリズムの設計int32_t
。また、あるデータ構造から別のデータ構造に要素を常に転送していなかった場合でも、最終的な結果が非常に効率的で維持しやすいことがわかりました。
の一意の値にT
一意のインデックスがあり、中央の配列にインスタンスが存在すると想定できるユースケースの例:
マルチスレッドの基数ソートは、インデックスの符号なし整数でうまく機能します。私は実際には、マルチスレッドの基数ソートを行っています。これは、1億の要素をIntel独自の並列ソートとしてソートするのに約1/10の時間を要しますstd::sort
。もちろん、Intelは比較ベースのソートであり、辞書順でソートできるため、はるかに柔軟性が高く、リンゴとオレンジを比較します。しかし、ここでは、キャッシュに適したメモリアクセスパターンを実現したり、重複をすばやく除外したりするために、基数ソートパスを実行する場合があるように、多くの場合、オレンジだけが必要です。
ノードごとのヒープ割り当てなしで、リンクリスト、ツリー、グラフ、個別のチェーンハッシュテーブルなどのリンク構造を構築する機能。要素に並行してノードを一括で割り当て、それらをインデックスでリンクすることができます。ノード自体は次のノードへの32ビットインデックスになり、次のように大きな配列に格納されます。
並列処理に適しています。多くの場合、リンクされた構造は並列処理に適していません。これは、たとえば、配列を介して並列forループを実行するだけではなく、ツリーまたはリンクされたリストのトラバーサルで並列処理を実現しようとすることは少なくとも厄介です。インデックス/中央配列表現を使用すると、常にその中央配列に移動して、すべてを分厚い並列ループで処理できます。一部のみを処理したい場合でも、常にこの方法で処理できるすべての要素の中央配列があります(この時点で、中央配列を介してキャッシュに適したアクセスのために、基数でソートされたリストによってインデックスが付けられた要素を処理できます)。
一定の時間でオンザフライで各要素にデータを関連付けることができます。上記のビットの並列配列の場合と同様に、たとえば一時的な処理のために、並列データを要素に簡単かつ非常に安価に関連付けることができます。これには、一時データ以外のユースケースがあります。たとえば、メッシュシステムでは、ユーザーが好きなだけ多くのUVマップをメッシュにアタッチできるようにする場合があります。このような場合、AoSアプローチを使用して、すべての単一の頂点と面に存在するUVマップの数をハードコードすることはできません。そのようなデータをその場で関連付けることができる必要があり、並列配列はそこで便利であり、あらゆる種類の洗練された連想コンテナ、さらにはハッシュテーブルよりもはるかに安価です。
もちろん、並列配列は、並列配列を互いに同期させておくというエラーが発生しやすい性質があるため、不快に感じられます。たとえば、「ルート」配列からインデックス7の要素を削除するときは常に、「子」に対しても同じことを行う必要があります。ただし、ほとんどの言語では、この概念を汎用コンテナに一般化するのは簡単なので、並列配列を互いに同期させるトリッキーなロジックは、コードベース全体で1か所に存在するだけでよく、そのような並列配列コンテナは上記のスパース配列の実装を使用して、配列内の連続する空のスペースが後続の挿入時に回収されるために大量のメモリを浪費しないようにします。
詳細:スパースビットセットツリー
さて、私は皮肉なことだと思うもう少し詳しく説明するように依頼されましたが、とにかくそうするつもりなので、とても楽しいです!このアイデアをまったく新しいレベルに引き上げたい場合は、N + M要素を直線的にループすることなく、セットの交差を実行できます。これは、私が古くから使用してきた基本的なデータ構造であり、基本的にはモデルset<int>
です。
両方のリストの各要素を検査することなく、セットの交差を実行できる理由は、階層のルートにある単一のセットビットが、たとえば、セット内で100万個の連続する要素が占有されていることを示すことができるためです。1ビットを検査するだけで、範囲内のN個のインデックスが[first,first+N)
セットに含まれていることがわかります。Nは非常に大きな数になる可能性があります。
セット内で800万のインデックスが占有されているとしましょう。占有されたインデックスをトラバースするとき、私は実際にこれをループオプティマイザとして使用します。その場合、通常、メモリ内の800万の整数にアクセスする必要があります。これを使用すると、潜在的に数ビットを検査して、ループする占有インデックスのインデックス範囲を作成できます。さらに、それが提供するインデックスの範囲はソートされた順序になっているため、たとえば、元の要素データへのアクセスに使用されるインデックスのソートされていない配列を反復するのではなく、非常にキャッシュフレンドリーなシーケンシャルアクセスが可能になります。もちろん、この手法は極端にまばらなケースではさらに悪くなり、最悪のシナリオでは、すべてのインデックスが偶数である(またはすべてのインデックスが奇数である)場合があり、その場合、隣接する領域はまったくありません。しかし、少なくとも私のユースケースでは、