web-dev-qa-db-ja.com

Pythonの組み込み辞書の実装方法

pythonの組み込み辞書タイプの実装方法を知っている人はいますか?私の理解では、それはハッシュテーブルのようなものですが、決定的な答えを見つけることができませんでした。

251
ricree

ここに、私がまとめることができたPython辞書に関するすべてがあります(おそらく、誰もが知りたいと思うよりも多くですが、答えは包括的です)。

  • Python辞書はhash tablesとして実装されます。
  • ハッシュテーブルはhash collisionsを許可する必要があります。つまり、2つの異なるキーが同じハッシュ値を持つ場合でも、テーブルの実装にはキーと値のペアを明確に挿入および取得する戦略が必要です。
  • Python dictopen addressingを使用してハッシュ衝突を解決します(以下で説明します)( dictobject.c:296-297 を参照)。
  • Pythonハッシュテーブルは、連続したメモリブロックです(配列のようなものなので、インデックスでO(1)ルックアップを実行できます)。
  • テーブル内の各スロットには1つのエントリのみを格納できます。これは重要です。
  • テーブル内の各entryは、実際には3つの値の組み合わせです:<hash、key、value>。これはC構造体として実装されます( dictobject.h:51-56 を参照)。
  • 次の図は、Pythonハッシュテーブルの論理表現です。以下の図では、左側の0, 1, ..., i, ...はハッシュテーブル内のslotsのインデックスです(これらは単に説明のためのものであり、テーブルと一緒に保存されていないことは明らかです!) 。

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • 新しい辞書が初期化されると、8slotsで始まります。 ( dictobject.h:49 を参照)

  • テーブルにエントリを追加するとき、キーのハッシュに基づくスロットiから始めます。 CPythonは最初にi = hash(key) & maskを使用します(ここでmask = PyDictMINSIZE - 1ですが、それはそれほど重要ではありません)。チェックされる最初のスロットiは、キーのhashに依存することに注意してください。
  • そのスロットが空の場合、エントリはスロットに追加されます(エントリごと、つまり<hash|key|value>)。しかし、そのスロットが占有されている場合はどうでしょう!?ほとんどの場合、別のエントリが同じハッシュを持っているためです(ハッシュ衝突!)
  • スロットが占有されている場合、CPython(およびPyPyでさえ)は、スロット内のエントリのハッシュとキーを比較します(比較すると、is比較ではなく==比較を意味します)挿入される現在のエントリのハッシュとキーに対して( dictobject.c:337,344-345bothが一致する場合、エントリはすでに存在するとみなし、あきらめて、挿入する次のエントリに進みます。ハッシュまたはキーのいずれかが一致しない場合、probingが開始されます。
  • プローブとは、スロットごとにスロットを検索して空のスロットを見つけることを意味します。技術的には、i+1, i+2, ...を1つずつ実行し、最初に使用可能なものを使用することができます(線形プローブ)。ただし、コメントで美しく説明されている理由( dictobject.c:33-126 を参照)のため、CPythonはrandom probingを使用します。ランダムプローブでは、次のスロットが擬似ランダムな順序で選択されます。エントリは最初の空のスロットに追加されます。この説明では、次のスロットを選択するために使用される実際のアルゴリズムはそれほど重要ではありません(プローブのアルゴリズムについては dictobject.c:33-126 を参照してください)。重要なのは、最初の空のスロットが見つかるまでスロットがプローブされることです。
  • ルックアップでも同じことが起こり、最初のスロットiから始まります(iはキーのハッシュに依存します)。ハッシュとキーの両方がスロット内のエントリと一致しない場合、一致するスロットが見つかるまでプローブを開始します。すべてのスロットが使い果たされると、失敗が報告されます。
  • ところで、dictは、3分の2がいっぱいになるとサイズが変更されます。これにより、ルックアップが遅くなるのを防ぎます。 ( dictobject.h:64-65 を参照)

注:独自の question に応じて、Python Dictの実装に関する調査を行いました。dict内の複数のエントリが同じハッシュ値を持つ方法についてです。すべての調査はこの質問にも非常に関連しているため、ここに回答のわずかに編集したバージョンを投稿しました。

419

Python辞書は オープンアドレス指定Beautifulコード内の参照 )を使用します

NB!オープンアドレッシング、別名クローズドハッシングウィキペディアで述べたように、その反対のオープンハッシュと混同しないでください!

オープンアドレス指定とは、dictが配列スロットを使用し、オブジェクトのプライマリ位置がdictで取得されると、オブジェクトのハッシュ値が役割を果たす「摂動」スキームを使用して、オブジェクトのスポットが同じ配列の異なるインデックスで検索されることを意味します。

44
u0b34a0f6ae

Pythonの組み込み辞書はどのように実装されていますか?

短いコースは次のとおりです。

  • それらはハッシュテーブルです。
  • Python 3.6以降の新しいプロシージャ/アルゴリズムは、それらを作成します
    • キー挿入によって順序付けられ、
    • スペースを取りません
    • パフォーマンスのコストはほとんどありません。
  • 別の最適化は、dictがキーを共有するときのスペースを節約します(特別な場合)。

順序付けられたアスペクトはPython 3.6の時点では非公式ですが、 Python 3.7の公式 です。

Pythonの辞書はハッシュテーブルです

長い間、このように機能していました。 Pythonは8つの空の行を事前に割り当て、ハッシュを使用してキーと値のペアをどこに貼り付けるかを決定します。たとえば、キーのハッシュが001で終了した場合、それは1インデックスに固定されます(次の例のように)。

     hash         key    value
     null        null    null
...010001    ffeb678c    633241c4 # addresses of the keys and values
     null        null    null
      ...         ...    ...

各行は、64ビットアーキテクチャでは24バイト、32ビットアーキテクチャでは12バイトを占有します。 (列ヘッダーは単なるラベルであることに注意してください-実際にはメモリに存在しません。)

ハッシュが既存のキーのハッシュと同じように終了した場合、これは衝突であり、キーと値のペアを別の場所に固定します。

5つのKey-Valueが保存された後、別のKey-Valueペアを追加すると、ハッシュ衝突の確率が大きすぎるため、辞書のサイズが2倍になります。 64ビットプロセスでは、サイズ変更の前に、72バイトが空になり、その後、10行の空のために240バイトが無駄になります。

これには多くのスペースが必要ですが、検索時間はほぼ一定です。キー比較アルゴリズムは、ハッシュを計算し、予想される場所に移動し、キーのIDを比較します-それらが同じオブジェクトである場合、それらは等しくなります。そうでない場合、ハッシュ値を比較し、それらがnot同じであれば、それらは等しくありません。それ以外の場合は、最後にキーが等しいかどうかを比較し、等しい場合は値を返します。同等性の最終比較は非常に遅くなる可能性がありますが、以前のチェックは通常、最終比較を短縮し、検索を非常に高速にします。

(衝突により速度が低下し、攻撃者は理論的にハッシュ衝突を使用してサービス拒否攻撃を実行できるため、新しいPythonプロセスごとに異なるハッシュを計算するようにハッシュ関数をランダム化しました。)

上記の無駄なスペースにより、辞書の実装が変更され、辞書が(挿入によって)順序付けられるという(非公式な場合は)刺激的な新しい機能が追加されました。

新しいコンパクトハッシュテーブル

代わりに、挿入のインデックスに配列を事前に割り当てることから始めます。

最初のキーと値のペアが2番目のスロットに入るため、次のようにインデックスを作成します。

[null, 0, null, null, null, null, null, null]

そして、私たちのテーブルは、挿入順序によって読み込まれます:

     hash         key    value
...010001    ffeb678c    633241c4 
      ...         ...    ...

したがって、キーの検索を行うとき、ハッシュを使用して予想される位置を確認し(この場合、配列のインデックス1に直接移動します)、ハッシュテーブルのインデックス(インデックス0など)に移動します)、キーが等しいことを確認し(前述の同じアルゴリズムを使用)、もしそうであれば、値を返します。

一定のルックアップ時間を保持しますが、場合によっては速度がわずかに低下しますが、既存の実装よりもかなり多くのスペースを節約できるという利点があります。無駄なスペースは、インデックス配列のnullバイトだけです。

Raymond Hettingerはこれを2012年12月に python-dev に導入しました。最終的に Python 3.6 でCPythonになりました。挿入による順序付けは、Pythonの他の実装が追いつく機会を与えるための実装の詳細とみなされます。

共有キー

スペースを節約するためのもう1つの最適化は、キーを共有する実装です。したがって、そのスペースをすべて使用する冗長な辞書を使用する代わりに、共有キーとキーのハッシュを再利用する辞書があります。次のように考えることができます。

     hash         key    dict_0    dict_1    dict_2...
...010001    ffeb678c    633241c4  fffad420  ...
      ...         ...    ...       ...       ...

64ビットマシンの場合、これは追加の辞書ごとにキーごとに最大16バイトを節約できます。

カスタムオブジェクトと代替の共有キー

これらの共有キーdictは、カスタムオブジェクトの__dict__に使用することを目的としています。この動作を得るには、次のオブジェクトをインスタンス化する前に__dict__の設定を完了する必要があると思います( PEP 412 を参照)。つまり、すべての属性を__init__または__new__に割り当てる必要があります。そうしないと、スペースを節約できない可能性があります。

ただし、__init__の実行時にすべての属性を知っている場合は、オブジェクトに__slots__を指定し、__dict__がまったく作成されないことを保証することもできます(そうでない場合) (親で使用可能)、または__dict__を許可しますが、予測される属性がスロットに保存されることを保証します。 __slots__の詳細については、 こちらの回答をご覧ください

こちらもご覧ください:

42
Aaron Hall