web-dev-qa-db-ja.com

純粋な関数型言語のBig-O表記とは何ですか?

それはまだ関連していますか?

の代わりに

var result = new List<int>();
for (var i = 0; i < prev.Count; ++i)
{
    result.Add(prev[i] * 2);
}

どこ result.Addprev[i]* 2命令は10回実行されます(次に、そのすべてのサブ命令と、サブルーチンを呼び出すための命令オーバーヘッド)...

関数型言語ではどうですか?

map (*2) prev

このための複雑さはどのように計算されますか?明らかに、サブルーチンを呼び出すたびに別の命令が追加されますが、測定する「ステップ」はありません。

基本的な違いはありません。関数mapは、O(n)複雑です。これは、サイズnのリストを反復処理し、各要素に操作を適用するためです。

最初の例で明示的なループは、map関数の内部で発生します。 mapの一般的な実装は次のとおりです。

map f [] = []
map f (x:xs) = f x : map f xs

ここでは、リスト内の各項目に対して1つの操作が実行されていることが簡単にわかります。実装はループではなく再帰を使用しますが、いずれの場合でも、リスト内の項目ごとに1つの操作が実行されるため、複雑さはO(n)どちらの場合もです。

16
JacquesB

Big O表記は、物理ハードウェアで実行されるすべてのプログラムに完全に関連しています。例として、Clojureは関数型プログラミング言語であり、 その独自のドキュメント は、そのデータ構造(特にコレクション-リスト、ベクトル、およびマップ)に対する操作のBig O表記をリストします。各コレクションのBig O係数を知ることで、リスト、ベクトル、セットなどをインテリジェントに決定できます。

ちなみに、提供した2つのコードは同じことをしません。最初のものは一定の時間で実行されますが、2番目のものはO(n)-ランタイムはprevの長さに比例して増加します。代替として、これを考慮してください:

_var result = new List<int>();
for (var i = 0; i < prev.Count; ++i)
{
    result.Add(prev[i] * 2);
}
_

これはおそらくO(n) prevの長さに対して相対的です-prevがリンクリストの場合、(ElementAtを使用する必要があります。 、および)コードはO(n ^ 2)になります!map (*2) prevはO(n) prevがリンクされたリストであっても。

7
WolfeFan

まず第一に、これは「ビッグオー」とは何の関係もありません。 Big Oは、複雑さ、アルゴリズム、プログラミング、コンピューターサイエンスなどとは関係ありません。BigOは、関数の成長率を単純に比較します。関数が何を記述しているのか、または関数が何かを記述しているのかどうかは関係ありません。

あなたが求めているのは、単にプログラムのコスト(実行時コスト、メモリコストなど)を計算することです。そしてそれを行う方法は、関数型言語でも命令型言語でもまったく同じです。

  1. define what測定したい(実行時、メモリ、操作など)
  2. マシンモデルを定義します(ユニバーサルチューリングマシン、マルチテープチューリングマシン、ランダムアクセスマシン、λ-計算、…)
  3. コストモデルを定義する
  4. プリミティブ(ストア、ロード、比較、スワップなど)を特定する
  5. それらを数える
  6. できた!

これらはすべて重要であることに注意してください。たとえば、ご存じのとおり、並べ替えはO(n log n)ですよね?違う!ランダムアクセスマシンモデルの比較ベースの並べ替えでは、O(n log n)の比較とスワップが行われます。ただし、基数ソートやカウントソートなどの非比較ベースのソートがあり、O(n)比較とスワップのみが必要です。そして、隣接する2つのスワップのみが許可されているマシンモデルがあります。要素、ただし2つの任意の要素ではありません。その場合、O(n2)。

たとえば、検索アルゴリズムの場合、比較数のカウントに関心があります。まあ、コードが純粋であるかどうかは関係ありません。純粋な関数型言語でさえ、何かを見つけるために比較を行う必要があります。並べ替えられていない配列で何かを見つけると、平均でn/2の比較が行われ、最悪の場合はn回の比較が行われます。関数型言語と命令型言語のどちらについて話しているかに関係なく、これらの比較は同じ方法でカウントされます。

ソートアルゴリズムの場合、通常は比較数and swapsに関心があります。ここが興味深いところです。純粋に関数型のデータ構造を持つ純粋な関数型言語では、in-situスワップを実行できないため、常に2つの要素がスワップされたnewデータ構造が生成されます。だが!同じことが、純粋に関数型のデータ構造をソートしようとする命令型言語にも当てはまります。

したがって、この場合、違いは言語ではなく、データ構造です。または、2つの異なるアルゴリズムがあります。1つは不変データ構造を使用し、もう1つは可変データ構造を使用します。また、不変のデータ構造では、「スワップ」をカウントすることは意味がなく、別のものをカウントする必要があります。

非常に一般的なものにしたい場合、ロード、ストア、ポインター逆参照、整数の加算と減算など、理論上のマシンの「基本操作」を数えることがよくあります。 λ-calculusについては、reductionsの数を数えることにより、同様のアイデアがあります。

あなたの具体的な例では、私たちが興味を持っているのは、「変換操作の実行回数」です。そしてそれは確かに、命令バージョンと機能バージョンの両方について計算できるものです。命令型では、「変換操作」はループ本体です。機能バージョンでは、mapの最初の引数として渡される関数です。

どちらの場合も、Big Oは必要ありません。estimate成長率は必要ありません。なぜなら、count実行頻度を正確にできるからです。 :正確に実行されますprev.Count回、両方の場合。

ただし、2つの例は完全に等価ではないことに注意してください。

たとえば、次のC♯コードを見てください。

var result = prev.Select(el => el * 2);
// alternatively:
var result = from el in prev select el * 2;

または、ECMAScriptで:

const result = prev.map(el => el * 2);

これは関数型の例のコードにはるかに似ていますが、命令型言語で記述されています。

命令型および関数型のmapの異なる実装は、次のようになります this (ECMAScriptのすべての例):

// imperative style
Array.prototype.imperativeMap = function (fn) {
  const res = [];
  for (let el of this) res.Push(fn(el));
  return res;
};

// naive functional style in O(n) call stack space
Array.prototype.naiveFunctionalMap = function (fn) {
  const [first, ...rest] = this;
  return this.length === 0 ? [] : [fn(first)].concat(rest.map(fn));
};

// tail-recursive functional style in O(1) call stack space
Array.prototype.tailRecursiveFunctionalMap = function (fn) {
  const mapTailrec = (ary, acc) => 
    ary.length === 0 ? 
      acc : 
      mapTailrec(ary.slice(0, -1), acc.concat([fn(ary[ary.length-1])]));
  return mapTailrec(this, []).reverse();
};

ご覧のとおり、すべてのケースで、数えることができるものがあります。あなただけを決定する必要がありますwhatカウントしたいです。カウントする意味があるいくつかのこと:コールバック関数の呼び出しの数(3つのケースすべてでn)、コールスタックの深さ(ケース#1および#3のO(1)、O(n)ケース#2)、中間データ構造のサイズ(3つのケースすべてでO(n))。

詳細については、おそらくあなたが知りたかった以上に、これから始めることができます Lambda the Ultimate というタイトルの投稿 関数型言語のコストセマンティクス 、これには、関数型言語のコストセマンティクスに関するいくつかの論文がリストされています。また、コメントセクションでは、リストされている論文とトピック全体の両方で活発な議論が行われています。

2
Jörg W Mittag

関数型言語ではどうですか?

_map (*2) prev
_

このための複雑さはどのように計算されますか?明らかに、サブルーチンを呼び出すたびに別の命令が追加されますが、測定する「ステップ」はありません。

測定できるステップがあります! Haskellでは、mapは次のように定義できます。

_map :: (a -> b) -> [a] -> [b]
map _ [] = []
map f (a:as) = f a : map f as
_

これは式の定義です。定義は式のセットの形式であるため、左側のパターンに一致する式の値は、対応する式の値と同じでなければなりません。右側の式。

つまり、方程式の定義はrewrite rules for reduction to normal formとして読み取ることができることを意味します。もう書き直されました)。このプロセスを手作業でスケッチすることで、適切な「ステップ」が得られます。これを測定して、表現の最悪の場合の時間の複雑さを識別するために使用できます。したがって、たとえば:

_map (*2) [1..n]
  ==> 1*2 : map (*2) [2..n]
  ==> 0*2 : 1*2 : map (*2) [3..n]
   .
   .
   .
  ==> 0*2 : 1*2 : ... : n*2 : map (*2) []
  ==> 0*2 : 1*2 : ... : n*2 : []
_

ご覧のとおり、ここで正規形に到達するためのステップ数は、リストの長さに比例しています。これは、mapがO(n)の最悪の場合の複雑さを持つことを意味します。


ただし、私たちの例はHaskellにあります。つまり、大きな問題があります。Haskellの実装では遅延評価を使用します。それらを消費する必要があります。リストの先頭から最大take要素を返すn関数を例にとります:

_take :: Int -> [a] -> [a]
take _ [] = []
take n (a:as) = if n <= 0 then [] else a : take (n-1) as
_

ここで、前の例の結果の最初の要素をtakesする次の式について考えます。

_take 1 (map (*2) [1..n])
  ==> take 1 (1*2 : map (2) [2..n])
  ==> if 1 <= 0 then [] else 1*2 : take (1-1) (map (2) [2..n])
  ==> if False then [] else 1*2 : take (1-1) (map (2) [2..n])
  ==> 1*2 : take (1-1) (map (*2) [2..n])
  ==> 1*2 : take 0 (map (*2) [2..n])
  ==> 1*2 : if 0 <= 0 then [] else map (*2) [2..n]
  ==> 1*2 : if True then [] else map (*2) [2..n]
  ==> 1*2 : []
_

map (*2) [1..n]が最悪の場合のO(n)であるにもかかわらず、この式の最悪の場合の時間の複雑さはO(1)です。熱心な評価では、部分式の評価の複雑さは「加算」されます。複雑な式の評価は、通常、少なくともそのサブパートの評価と同じくらい複雑です。これは遅延評価には当てはまりません。複雑な式を評価するコストは、私の例のように、その部分式よりも少ない場合があります。

その事実の1つの古典的で楽しい例は次のとおりです。

_take 1 (sort xs)
_

ソートはリストの長さで最悪の場合O(n log n)ですが、sortの実装方法によっては、Haskellではこの式は最悪の場合O(n)で評価される可能性があります。

2
sacundim