Haskellでのリスト操作のパフォーマンス



List Manipulation Performance Haskell



解決:

HaskellとGHCの2つの機能があるため、これは驚くほど複雑な質問です。

  1. 遅延評価
  2. リストフュージョン

リスト融合とは、状況によっては、GHCがリスト処理コードをリストセルを割り当てないループに書き換えることができることを意味します。したがって、使用されるコンテキストによっては、同じコードで追加コストが発生することはありません。



遅延評価とは、操作の結果が消費されない場合、それを計算するためのコストを支払わないことを意味します。たとえば、リストの最初の10個の要素を作成するだけでよいため、これは安価です。

例= 10を取る([1..1000000] ++ [1000001])

実際、そのコードではテイク10はリストの追加と融合できるので、それはちょうどと同じです[1..10]。



ただし、作成するすべてのリストのすべての要素を消費しており、コンパイラーがリスト操作を融合していないと仮定しましょう。今あなたの質問に:

Haskellのリストに要素を追加すると、Haskellは(完全に?)新しいリストを返し、元のリストを操作しません。ここで、100万個の要素のリストがあり、最後に1つの要素を追加するとします。 Haskellはリスト全体(100万要素)を「コピー」し、そのコピーに要素を追加しますか?それとも、リスト全体をコピーすることを避けるために、舞台裏で巧妙な「トリック」が行われていますか?

リスト全体をコピーしないようにするためのトリックがありますが、その最後に追加することで、それらを打ち負かすことができます。理解すべきことは、機能データ構造は通常、それらを「変更」する操作が悪用されるように設計されているということです。 構造共有 古い構造を可能な限り再利用します。したがって、たとえば、2つのリストの追加は次のように定義できます。



(++):: [a]-> [a]-> [a] [] ++ ys = ys(x:xs)++ ys = x:xs ++ ys

この定義を見ると、リストがysは結果で再利用されます。だから私たちが持っているならxs = [1..3]、ys = [4..5]およびxs ++ ysは、すべて完全に評価され、一度にメモリに保持されます。メモリに関しては、次のようになります。

+ --- + --- + + --- + --- + + --- + --- + xs = | 1 | -----> | 2 | -----> | 3 | -----> [] + --- + --- + + --- + --- + + --- + --- + + --- + --- + + --- +- -+ ys = | 4 | -----> | 5 | -----> [] + --- + --- + + --- + --- + ^ | + ------------------------------------ + | + --- + --- + + --- + --- + + --- + --- + | xs ++ ys = | 1 | -----> | 2 | -----> | 3 | ------- + + --- + --- + + --- + --- + + --- + --- +

それはこれを言う長い道のりです:あなたがそうするならxs ++ ys、そしてそれは融合せず、あなたがリスト全体を消費すると、それはのコピーを作成しますxsですが、メモリを再利用しますイース

しかし、今度はあなたの質問のこのビットをもう一度見てみましょう:

ここで、100万個の要素のリストがあり、最後に1つの要素を追加するとします。 Haskellはリスト全体(100万要素)を「コピー」し、そのコピーに要素を追加しますか?

それは次のようなものになります[1..1000000] ++ [1000001]、そうです、100万個の要素全体をコピーします。しかしその一方で、[0] ++ [1..1000000]はコピーするだけです[0]。経験則は次のとおりです。

  • リストの先頭に要素を追加するのが最も効率的です。
  • リストの最後に要素を追加することは、特にそれを何度も繰り返す場合、しばしば非効率的です。

この種の問題の一般的な解決策は次のとおりです。

  1. アルゴリズムを変更して、リストが効率的にサポートするアクセスパターンでリストを使用するようにします。
  2. リストは使用しないでください。手元の問題に必要なアクセスパターンを効率的にサポートする他のシーケンスデータ構造を使用します。別の回答は相違点リストに言及しましたが、言及する価値のある他のものは次のとおりです。
    • Data.Sequence
    • Data.Set
    • Data.Vector

使用しているデータ構造によって異なります。通常のHaskellリストを使用している場合、これらはCまたはC ++での一般的なリンクリストの実装に類似しています。この構造では、追加とインデックス作成(最悪の場合)はO(n)の複雑さであり、追加はO(1)の複雑さです。頻繁に追加していて、リストが直線的に増加している場合、これは事実上O(n ^ 2)になります。大きなリストの場合、これは問題です。これは、使用している言語、Haskell、C、C ++、Python、Java、C#、さらにはアセンブラーに関係ありません。

ただし、次のような構造を使用する場合Data.Sequence.Seqの場合、内部で適切な構造を使用してO(1)の付加と付加を提供しますが、RAMを少し多く使用する可能性があるというコストがかかります。すべてのデータ構造にはトレードオフがありますが、どちらを使用するかはあなた次第です。

または、Data.Vector.VectorまたはData.Array.Arrayは、どちらも固定長の連続したメモリ配列を提供しますが、配列全体をRAM内の新しい場所にコピーする必要があるため、追加と追加にはコストがかかります。ただし、インデックス作成はO(1)であり、要素が散在しているリンクリストやシーケンスとは対照的に、配列のチャンクが一度にCPUキャッシュに収まるため、これらの構造の1つをマッピングまたはフォールドする方がはるかに高速です。あなたのRAM。

Haskellはリスト全体(100万要素)を「コピー」し、そのコピーに要素を追加しますか?

必ずしもそうとは限りませんが、コンパイラは最後の値だけを使用しても安全かどうかを判断できます。次のポインタは、空のリストではなく新しい値を指すように変更されます。安全でない場合は、リスト全体をコピーする必要がある場合があります。ただし、これらの問題は、言語ではなく、データ構造に固有のものです。一般に、HaskellのリストはCリンクリストよりも優れていると言えます。これは、コンパイラーがプログラマーよりも安全な場合に分析できるためです。Cコンパイラーはこの種の分析を行わず、まったく同じように行います。言われています。


リストを使用する場合、追加にはコストがかかり、要素ではなくリストをコピーする必要があります。また、新しい値は元のリストを指しているだけなので、先頭に追加するのは安価です。

追加してください「3番目」から['first'、 'second']:新しいリストは(:) '第一、第二第三' []))。したがって、最初のコンストラクターは新しいコンストラクターである必要があります。2番目の引数は新しい値である必要があります...ただし、文字列は複製されません。新しいリストは、メモリ内の同じ文字列を指します。

古い値が破棄された場合、コンパイラは、新しい値にメモリを割り当てたり、古い値をガベージコレクションしたりする代わりに、それを再利用することを決定する場合があることに注意してください。いずれにせよ、追加はO(n)で行われ、その終わりを見つける必要があります。

プログラムがリストに多くを追加している場合は、別のデータ構造を使用して、次のようなO(1)に追加できるようにすることができます。パッケージからのDListdlist。 (https://hackage.haskell.org/package/dlist-0.5/docs/Data-DList.html)