34

コードのスニペットを考えると、一般的に複雑さをどのように判断しますか。BigOの質問に非常に混乱していることに気づきました。たとえば、非常に簡単な質問です。

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
    }
}

TAはこれを組み合わせのようなもので説明しました。これがnのように、2 =(n(n-1))/ 2 = n ^ 2 + 0.5を選択し、定数を削除してn^2になります。intテスト値を入れて試すことはできますが、この組み合わせはどのように行われますか?

ifステートメントがある場合はどうなりますか?複雑さはどのように決定されますか?

for (int i = 0; i < n; i++) {
    if (i % 2 ==0) {
        for (int j = i; j < n; j++) { ... }
    } else {
        for (int j = 0; j < i; j++) { ... }
    }
}

では、再帰についてはどうでしょうか...

int fib(int a, int b, int n) {
    if (n == 3) {
        return a + b;
    } else {
        return fib(b, a+b, n-1);
    }
}
4

6 に答える 6

69

一般に、与えられた関数の複雑さを決定する方法はありません

警告!テキストの壁が来ています!

1.停止するかどうかさえわからない非常に単純なアルゴリズムがあります。

特定の入力が与えられた場合に、特定のプログラムが停止するかどうかを判断できるアルゴリズムはありません。アルゴリズムが停止することを証明する必要があるだけでなく、停止する速度を証明する必要があるため、計算の複雑さを計算することはさらに難しい問題です。

//The Collatz conjecture states that the sequence generated by the following
// algorithm always reaches 1, for any initial positive integer. It has been
// an open problem for 70+ years now.
function col(n){
    if (n == 1){
        return 0;
    }else if (n % 2 == 0){ //even
        return 1 + col(n/2);
    }else{ //odd
        return 1 + col(3*n + 1);
    }
}

2.一部のアルゴリズムには奇妙で風変わりな複雑性があります

一般的な「複雑さを決定するスキーム」は、これらの人のために簡単に複雑になりすぎます。

//The Ackermann function. One of the first examples of a non-primitive-recursive algorithm.
function ack(m, n){
    if(m == 0){
        return n + 1;
    }else if( n == 0 ){
        return ack(m-1, 1);
    }else{
        return ack(m-1, ack(m, n-1));
    }
}

function f(n){ return ack(n, n); }

//f(1) = 3
//f(2) = 7
//f(3) = 61
//f(4) takes longer then your wildest dreams to terminate.

3.いくつかの関数は非常に単純ですが、多くの種類の静的解析の試行を混乱させます

//Mc'Carthy's 91 function. Try guessing what it does without
// running it or reading the Wikipedia page ;)
function f91(n){
    if(n > 100){
        return n - 10;
    }else{
        return f91(f91(n + 11));
    }
}

とはいえ、物事の複雑さを見つける方法はまだ必要ですよね?for ループは単純で一般的なパターンです。最初の例を見てください:

for(i=0; i<N; i++){
   for(j=0; j<i; j++){
       print something
   }
}

それぞれprint somethingが O(1) であるため、アルゴリズムの時間の複雑さは、その行を実行する回数によって決まります。TA が述べたように、この場合の組み合わせを調べることでこれを行います。内側のループは (N + (N-1) + ... + 1) 回、合計 (N+1)*N/2 実行されます。

定数を無視するので、O(N 2 ) が得られます。

よりトリッキーなケースについては、より数学的に理解することができます。入力のサイズ N が与えられた場合、値がアルゴリズムの実行にかかる時間を表す関数を作成してみてください。多くの場合、この関数の再帰バージョンをアルゴリズム自体から直接構築できるため、複雑さを計算することは、その関数に境界を設定する問題になります。この機能を繰り返しと呼びます

例えば:

function fib_like(n){
    if(n <= 1){
        return 17;
    }else{
        return 42 + fib_like(n-1) + fib_like(n-2);
    }
 }

実行時間が N で表されることは簡単にわかります。

T(N) = 1 if (N <= 1)
T(N) = T(N-1) + T(N-2) otherwise

T(N) は古き良きフィボナッチ関数です。帰納法を使用して、それにある程度の限界を設けることができます。

たとえば、帰納法によって、すべての N に対して T(N) <= 2^n であることを証明しましょう (つまり、T(N) は O(2^n) です)。

  • 基本ケース: n = 0 または n = 1
    T(0) = 1 <= 1 = 2^0
    T(1) = 1 <= 2 = 2^1
  • 帰納的な場合 (n > 1):
    T(N) = T(n-1) + T(n-2)
    aplying the inductive hypothesis in T(n-1) and T(n-2)...
    T(N) <= 2^(n-1) + 2^(n-2)
    so..
    T(N) <= 2^(n-1) + 2^(n-1)
         <= 2^n

(下限も証明するために同様のことを試すことができます)

ほとんどの場合、関数の最終実行時間を適切に推測することで、帰納法証明を使用して再帰問題を簡単に解決できます。もちろん、これには最初に推測できることが必要です。ここで役立つのは、たくさんの練習だけです。

最後に、マスター定理について指摘したいと思います。これは、一般的に使用されている、現在考えられるより困難な再発問題の唯一のルールです。トリッキーな分割統治アルゴリズムに対処する必要がある場合に使用します。


また、あなたの「if case」の例では、それをだまして2つの別々のループに分割することで解決します。内部に if があります。

for (int i = 0; i < n; i++) {
    if (i % 2 ==0) {
        for (int j = i; j < n; j++) { ... }
    } else {
        for (int j = 0; j < i; j++) { ... }
    }
}

と同じランタイムを持っています

for (int i = 0; i < n; i += 2) {
    for (int j = i; j < n; j++) { ... }
}

for (int i = 1; i < n; i+=2) {
    for (int j = 0; j < i; j++) { ... }
}

また、2 つの部分のそれぞれが O(N^2) であることが簡単にわかり、合計も O(N^2) になります。

ここで「if」を取り除くために、私は良い裏技を使用したことに注意してください。Collat​​zアルゴリズムの例で示されているように、そうするための一般的なルールはありません

于 2011-11-02T20:37:53.583 に答える
14

一般に、アルゴリズムの複雑さを決定することは、理論的には不可能です。

ただし、それを行うためのクールでコード中心の方法の 1 つは、実際にはプログラムの観点から直接考えることです。あなたの例を見てください:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
    }
}

次に、その複雑さを分析したいので、内側の行の実行回数をカウントする単純なカウンターを追加しましょう。

int counter = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
        counter++;
    }
}

System.out.println 行はあまり重要ではないため、削除しましょう。

int counter = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        counter++;
    }
}

カウンターだけが残っているので、内側のループアウトを明らかに単純化できます。

int counter = 0;
for (int i = 0; i < n; i++) {
    counter += n;
}

...インクリメントが正確にn回実行されることがわかっているためです。そして今、counter が n だけ正確に n 回インクリメントされていることがわかるのでこれを単純化して次のようにします。

int counter = 0;
counter += n * n;

そして、(正しい) O(n 2 ) の複雑さで浮上しました:)それはコードにあります:)

これが再帰的なフィボナッチ計算機でどのように機能するかを見てみましょう。

int fib(int n) {
  if (n < 2) return 1;
  return fib(n - 1) + fib(n - 2);
}

実際のフィボナッチ数ではなく、内部で費やされた反復回数を返すようにルーチンを変更します。

int fib_count(int n) {
  if (n < 2) return 1;
  return fib_count(n - 1) + fib_count(n - 2);
}

やはりフィボナッチ!:) これで、再帰的なフィボナッチ計算機の複雑さが O(F(n)) であることがわかりました。ここで、F はフィボナッチ数そのものです。

わかりました、もっと興味深いものを見てみましょう、単純な (そして非効率的な) マージソートとしましょう:

void mergesort(Array a, int from, int to) {
  if (from >= to - 1) return;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  mergesort(a, from, m);
  mergesort(m, m,    to);
  /* Then merge */
  Array b = new Array(to - from);
  int i = from;
  int j = m;
  int ptr = 0;
  while (i < m || j < to) {
    if (i == m || a[j] < a[i]) {
      b[ptr] = a[j++];
    } else {
      b[ptr] = a[i++];
    }
    ptr++;
  }
  for (i = from; i < to; i++)
    a[i] = b[i - from];
}

実際の結果ではなく複雑さに関心があるため、実行された作業単位の数を実際に返すようにルーチンを変更します。

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  /* Then merge */
  Array b = new Array(to - from);
  int i = from;
  int j = m;
  int ptr = 0;
  while (i < m || j < to) {
    if (i == m || a[j] < a[i]) {
      b[ptr] = a[j++];
    } else {
      b[ptr] = a[i++];
    }
    ptr++;
    count++;
  }
  for (i = from; i < to; i++) {
    count++;
    a[i] = b[i - from];
  }
  return count;
}

次に、実際にはカウントに影響を与えない行を削除して単純化します。

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  /* Then merge */
  count += to - from;
  /* Copy the array */
  count += to - from;
  return count;
}

まだ少し単純化しています:

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  count += (to - from) * 2;
  return count;
}

実際に配列を省略できるようになりました。

int mergesort(int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  int count = 0;
  count += mergesort(from, m);
  count += mergesort(m,    to);
  count += (to - from) * 2;
  return count;
}

実際には from と to の絶対値はもはや問題ではなく、それらの距離だけであることがわかります。したがって、これを次のように変更します。

int mergesort(int d) {
  if (d <= 1) return 1;
  int count = 0;
  count += mergesort(d / 2);
  count += mergesort(d / 2);
  count += d * 2;
  return count;
}

そして、次のようになります。

int mergesort(int d) {
  if (d <= 1) return 1;
  return 2 * mergesort(d / 2) + d * 2;
}

ここで明らかに、最初の呼び出しのdはソートされる配列のサイズであるため、複雑さ M(x) の繰り返しがあります (これは 2 行目にはっきりと見えます:)

M(x) = 2(M(x/2) + x)

これは、閉じた形式のソリューションに到達するために解決する必要があります。これは、解 M(x) = x log x を推測することで最も簡単に行うことができ、右側を検証します。

2 (x/2 log x/2 + x)
        = x log x/2 + 2x
        = x (log x - log 2 + 2)
        = x (log x - C)

漸近的に左辺と同等であることを確認します。

x log x - Cx
------------ = 1 - [Cx / (x log x)] = 1 - [C / log x] --> 1 - 0 = 1.
x log x
于 2011-11-03T19:14:01.983 に答える
2

Big-O表記には、最悪のシナリオである標準のBig-Oと、通常発生する平均的なBig-Oの2つを使用するのが好きです。また、Big-O表記は、入力数であるNの関数として実行時間を概算しようとしていることを思い出すのにも役立ちます。

TAはこれを組み合わせのようなもので説明しました。これがnのように、2 =(n(n-1))/ 2 = n ^ 2 + 0.5を選択し、定数を削除してn^2になります。intテスト値を入れて試すことはできますが、この組み合わせはどのように行われますか?

私が言ったように、通常のbig-Oは最悪のシナリオです。各行が実行される回数を数えることを試みることができますが、最初の例を見て、nの長さにわたって2つのループがあり、一方が他方に埋め込まれていると言う方が簡単です。したがって、n * n。それらが次々にある場合、それは2nに等しいn+nになります。近似なので、nまたは線形とだけ言います。

ifステートメントがある場合はどうなりますか?複雑さはどのように決定されますか?

これは私にとって平均的なケースと最良のケースを持つことが私の考えを整理するのに大いに役立つところです。最悪の場合、ifを無視してn^2と言います。平均的な場合、あなたの例では、nを超えるループがあり、nの一部を超える別のループが半分の時間で発生します。これにより、n * n / x / 2が得られます(xは、埋め込みループでループされるnの一部です。これにより、n ^ 2 /(2x)が得られるため、n^2はまったく同じになります。これそれは近似だからです。

これがあなたの質問に対する完全な答えではないことは知っていますが、うまくいけば、コードの複雑さを概算することに何らかの光を当てることができます。

上記の回答で述べたように、コードのすべてのスニペットについてこれを判断することは明らかに不可能です。平均的なケースのBig-Oを使用するというアイデアをディスカッションに追加したかっただけです。

于 2011-11-08T21:57:14.573 に答える
2

これは過度の一般化ですが、リストの長さが N 項目であるリストの観点から Big-O を考えるのが好きです。

したがって、リスト内のすべてを反復する for ループがある場合、それは O(N) です。あなたのコードには、(単独ですべて単独で)0(N)である1行があります。

for (int i = 0; i < n; i++) {

別の for ループ内にネストされた for ループがあり、リスト内のすべての項目を調べる必要がある操作をリスト内の各項目に対して実行する場合、N 個の項目ごとに N 回操作を行うことになります。 O(N^2)。上記の例では、実際には、別の for ループを for ループ内にネストしています。したがって、各 for ループが 0(N) であるかのように考えることができます。これらはネストされているため、それらを掛け合わせて合計値 0(N^2) を得ることができます。

逆に、単一のアイテムに対して簡単な操作を行っているだけの場合、それは O(1) になります。「長さnのリスト」はなく、1回限りの操作です。これをコンテキストに入れるには、上記の例では、次の操作を行います。

if (i % 2 ==0)

は 0(1) です。重要なのは「if」ではありませんが、単一のアイテムが別のアイテムと等しいかどうかを確認することは、単一のアイテムに対する迅速な操作であるという事実です。前と同様に、if ステートメントは外部 for ループ内にネストされています。ただし、これは 0(1) であるため、すべてに「1」を掛けているため、関数全体の実行時間の最終的な計算に「顕著な」影響はありません。

ログについて、およびより複雑な状況 (n だけでなく、j または i までカウントアップするこのビジネスなど) を扱う場合は、ここでよりエレガントな説明を参照してください。

于 2011-10-25T19:07:51.703 に答える
0

最初のスニペットでは、n 操作を n 回実行するため、n^2 になります。jが に初期化された場合i、または に上がったi場合、投稿した説明の方が適切ですが、現状ではそうではありません。

2 番目のスニペットでは、半分の時間で最初のスニペットが実行され、残りの半分で 2 番目のスニペットが実行されることが簡単にわかります。そこにあるものに応じて (できれば に依存していnます)、方程式を再帰的なものとして書き直すことができます。

再帰方程式 (3 番目のスニペットを含む) は次のように記述できます。3 番目の方程式は次のようになります。

T(n) = T(n-1) + 1

簡単にわかるのは O(n) です。

于 2011-10-25T06:59:18.157 に答える
-1

Big-O は単なる概算であり、アルゴリズムの実行にかかる時間を示すものではなく、入力のサイズが大きくなったときにどれだけ時間がかかるかを示しているだけです。

したがって、入力のサイズが N で、アルゴリズムが一定の複雑さの式を評価する場合: O(1) N 回、アルゴリズムの複雑さは線形: O(N) です。式の複雑さが線形の場合、アルゴリズムの複雑さは 2 次 (O(N*N)) になります。

一部の式は、指数関数的な複雑さ (O(N^N)) または対数複雑さ (O(log N)) を持っています。ループと再帰を含むアルゴリズムの場合、ループおよび/または再帰の各レベルの複雑さを乗算します。複雑さという点では、ループと再帰は同等です。アルゴリズムのさまざまな段階でさまざまな複雑さを持つアルゴリズムでは、最高の複雑さを選択し、残りを無視します。最後に、定数の複雑性はすべて同等と見なされます。O(5) は O(1) と同じであり、O(5*N) は O(N) と同じです。

于 2011-11-07T11:45:32.007 に答える