アルゴリズムの複雑さ - アルゴリズムとデータ構造の入門 (2)

CSDNロゴ役職

この記事は、アルゴリズムとデータ構造に関する学習ノートの第 2 部であり、今後も更新されていく予定です。友達と一緒に読んで学んでください。わからないことや間違っていることがあれば、ご連絡ください

アルゴリズムの複雑さとは何ですか?

アルゴリズムの複雑さは、入力データ量NNを計算するように設計されています。Nの場合、アルゴリズムの「時間使用量」と「スペース使用量」。これは、アルゴリズムが「データ サイズ NN」で実行するために使用される時間とスペースを反映しますN」と速度を上げます。

アルゴリズムの複雑さは、時間空間の2 つの観点から評価できます。

  • 時間: 各操作の実行時間が固定定数であると仮定すると、アルゴリズムによって実行される「計算操作の数」がカウントされ、アルゴリズムの実行に必要な時間を表します。
  • Space : 最悪の場合にアルゴリズムを実行するために必要な「最大スペース」をカウントします。

「入力データサイズ」NNN は、アルゴリズムによって処理される入力データの量を指します。アルゴリズムが異なれば、定義も異なります。次に例を示します。

  • ソートアルゴリズムNNN はソートされる要素の数を表します。
  • 検索アルゴリズムNNN は、配列サイズ、行列サイズ、二分木ノードの数、グラフ ノードとエッジなど、検索範囲内の要素の総数を表します。

次に、「時間計算量」と「空間計算量」について、概念定義、記号表現、分析ルール、一般的な型、分析例、時空トレードオフの観点から紹介します。

時間の複雑さ

概念定義

定義上、時間計算量は入力データ サイズを指します。NNNの場合、アルゴリズムの実行にかかる時間。注意が必要です:

  • カウントされるのはアルゴリズムの「演算回数」であり、「絶対実行時間」ではありません。計算操作の数と実行にかかる絶対時間は正の相関関係がありますが、等しくありません。アルゴリズムの実行時間は「プログラミング言語、コンピュータのプロセッサ速度、動作環境」などのさまざまな要因に影響されます。たとえば、同じアルゴリズムでも、Python または C++ で実装される場合、CPU または GPU を使用する場合、ローカル IDE またはオンライン プラットフォームを使用する場合、ランタイムは異なります。
  • データサイズNNに応じて計算回数が変化することを反映しています。Nが変わるとどうなるかアルゴリズムが合計「1 回の操作」と「100 回の操作」を必要とすると、両方の場合の時間計算量は定数O ( 1 ) O(1)O ( 1 ) ; 「N 操作」と「100N 操作」の時間計算量は線形ですO ( N ) O(N)O ( N )

象徴的な表現

入力データの特性に応じて、時間計算量はOOを使用して「最悪」、「平均」、「最高」の 3 つの状況に分けられます。OΘ \シータΘΩ \オメガΩは3つの記号で表されます。以下は、理解を助ける検索アルゴリズムの例です。

トピック: 入力の長さはNNですNの整数配列nums。この配列に数値 7 があるかどうかを判断し、ある場合は を返しtrue、そうでない場合は を返しますfalse
問題解決のアイデア: 線形検索、つまり配列全体を走査し、 7 に遭遇したら戻るtrue
Cコード:

#include <stdbool.h>

bool find_seven(int* nums, int length) {
   for (int i = 0; i < length; i++) {
      if (nums[i] == 7) {
          return true;
       }
   }
   return false;
}
  • 最良の場合 Ω ( 1 ) \Omega(1)Ω ( 1 ) : nums = [7, a, b, c, …]、つまり、配列の最初の数値が 7 の場合、要素の数にnums関係
  • 最悪の場合 O ( N ) O(N)O ( N ) : nums = [a, b, c, …] で、中の数値はnumsすべてNNN回。
  • 平均ケース Θ \ThetaΘ : 入力データの分布を考慮し、すべてのデータケースの平均時間計算量を計算する必要があります。たとえば、このトピックでは、配列の長さ、配列要素の値の範囲などを考慮する必要があります。 .;

O O Oは、最も一般的に使用される時間計算量評価記号であり、漸近上限とも呼ばれ、NNN は時間リソースのオーバーヘッドを徐々に増加させますT ( N ) T(N)T ( N )は増加傾向。実際、分析の結果により、プログラムを一定期間内に終了できることが保証されます。プログラムは早めに終了する場合がありますが、遅れることはありません。

時間計算量の分析ルール

以下は、時間計算量解析を理解するのに役立つプログラムです。計算は次のとおりです∑ i = 1 N i 3 \sum\limits_{i = 1}^N { { i^3}}i = 1N3 つの単純なプログラムの断片

int sum(int N){
	int i, PartialSum;

	PartialSum = 0;
	for( i=1; i<=N; i++){
		PartialSum += i * i * i;
	}
	return PartialSum;
}

このプログラムの解析は簡単です。発言は時間にカウントされません。4 行目と 8 行目はそれぞれ 1 時間単位を取ります。行 6 は、 NN の実行中に、実行ごとに 4 つの時間単位 (2 つの乗算、1 つの加算、および 1 つの代入) を要します。N回は4N 4N4 N時間単位。5行目は初期化中ですiii,测试 i ≤ N i\le N Nとペアiiiの自己インクリメント操作には暗黙的なオーバーヘッドがあります。これらすべての合計オーバーヘッドは、初期化に 1 時間単位であり、すべてのテストはN+1 N+1 です。N+1時間単位、すべての自己インクリメント演算NNN時間単位、合計2 N + 2 2N+22N_ _+関数の呼び出しと値の返しのオーバーヘッドを無視すると、合計は6 N + 4 6N+46N _+4なので、プログラムはO ( N ) O(N)()分析を次の図に示します。
時間計算量解析の例

プログラムを分析するたびにこの作業すべてを実証しなければならないとしたら、その作業はすぐに実行不可能になってしまいます。幸いなことに、私たちには大きなOOがあるので、結果としては、最終結果に影響を与えることなく実行できるショートカットがたくさんあります。たとえば、行 6 (各実行時) は明らかにO ( 1 ) O(1)O ( 1 )ステートメントなので、それが 2 時間単位か 3 時間単位か 4 時間単位かどうかを正確に計算するのは愚かです。それは問題ではありません。4 行目は for ループに比べれば明らかに簡単なので、ここにも時間を費やすのは賢明ではありません。これにより、いくつかの一般法則が導かれます。

  • ルール 1 — for ループ:
    for ループの実行時間は、最大でも for ループ内のステートメント (テストを含む) の実行時間に反復回数を乗じたものになります。
  • ルール 2 - ネストされた for ループ:
    これらのループを内側から外側に分析します。ネストされたループのセット内のステートメントの合計実行時間は、ステートメントの実行時間とセット内のすべての for ループのサイズの積です。
    例として、次のプログラム部分はO ( N 2 ) O(N^2)です。O ( N2 ):
    for( i=0; i<N; i++){
    	for( j=0; j<N; j++){
    		k++;
    	}
    }
    
  • ルール 3 - シーケンシャル ステートメント:
    最大のマルチセグメント ステートメントを選択します。合計の複雑さは、最大の大きさを持つコードの複雑さに等しいです。
    例として、次のプログラム部分では最初にO ( N ) O(N)を使用します。O ( N )、コストO ( N 2 ) O(N^2)O ( N2 )、合計オーバーヘッドもO ( N 2 ) O(N^2)O ( N2 ):
    for( i=0; i<N; i++){
    	A[i] = 0:
    }
    for( i =0; i<N; i++){
    	for( j=0; j<N; j++){
    		A[i] += A[j] + i + j; 
    	}
    }
    
  • ルール 4 - IF/ELSE ステートメント:
    プログラムフラグメントの場合
    if(Condition){
    	S1;
    }
    else{
    	S2;
    }
    
    if/ise ステートメントの実行時間は、述語S1S2合計実行時間と と の実行時間のどちらか長い方を超えることはありません。

一般的なタイプ

最終的に表現されるアルゴリズムの時間計算量は、入力サイズNNの独立変数でなければなりませんNの単項関数を小さいものから大きいものへと並べると、一般的なアルゴリズムの時間計算量は主に次のとおりです。 O ( 1 ) < O ( log ⁡ N ) < O ( N ) < O ( N log ⁡ N ) < O ( N 2 ) < O ( N c ) < O ( 2 N ) < O ( N ! ) O(1)<O(\log N)<O(N)<O(N\log N)<O(N^2) < O(N^c)<O(2^N)<O(N!)<O (ログ_<O ( N )<O ( Nログ_<O ( N2 )<O ( Nc )<2<O ( N !)指数関数レベルと階乗レベルは壊滅的ですが、他のレベルは許容されます。
一般的な複雑さのタイプ

分析例

以下に、さまざまな複雑さの C コードの例をいくつか示します。

一定レベルO( 1 ) O(1)O ( 1 ) :
実行数 vs.NNNのサイズには一定の関係があります。つまり、入力データNNNが変わります。
次のコードの場合、aaaの大きさは入力データのサイズNNと同じですNは無関係であるため、時間計算量は依然としてO ( 1 ) O(1)( 1 )

int algorithm(int N) {
    int count = 0;
    int a = 10000;
    for (int i = 0; i < a; i++) {
        count += 1;
    }
    return count;
}

一定レベル

線形クラスO ( N ) O(N)O ( N ) :
実行数 vs.NNNのサイズは線形で、時間計算量はO ( N ) O(N)()次のコードは、 NN
を実行する単層ループです。N回なので、時間責任はO ( N ) O(N)O ( N )

int algorithm(int N) {
    int count = 0;
    for (int i = 0; i < N; i++) {
        count += 1;
    }
    return count;
}

リニアレベル

正方形クラスO ( N ) O(N)O ( N ) :
2 層のループを例にとります。2 層のループが互いに独立している場合、両方ともNNNは線形関係にあるため、全体とNNNには二乗関係があり、時間計算量はO ( N 2 ) O(N^2)O ( N2 )
スクエアレベル

多項式レベルO ( N c ) O(N^c)O ( Nc ):
その中で、cccは定数です。O( N 3 ) O(N^3)O ( N3 )時間計算量を考慮したプログラムの書き方。

指数関数O ( 2 N ) O(2^N)2N ):
生物学における「細胞分裂」は指数関数的な増殖です。初期状態は 1 セル、1 回の分割後に 2、2 回の分割後に 4、...、分割NNNラウンド後は2 N 2^Nになります。2N個の細胞。
アルゴリズムでは再帰的に指数準位が現れることが多く、アルゴリズムのコードと模式図を以下に示します。

int algorithm(int N) {
    if (N <= 0) {
        return 1;
    }
    int count_1 = algorithm(N - 1);
    int count_2 = algorithm(N - 1);
    return count_1 + count_2;
}

指数関数的

対数O ( log ⁡ N ) O(\log N)O (ログ_N ) :
対数次数は指数次数の逆で、指数次数は「ラウンドごとに 2 つのケースを分割する」、対数次数は「ラウンドごとに半分のケースを除外する」です。対数次数は、「二分法」や「分割統治」などのアルゴリズムによく登場し、「1 つが 2 つに分割される」または「1 つが多数に分割される」というアルゴリズムの概念を具体化します。

int algorithm(int N) {
	int count = 1;
	while(count<N){
    	count *= 2;
}

count初期値は1で、NNに近づくように継続的に2倍していきます。N、サイクル数をmmmの場合、入力データ サイズはNN です。N2 m 2^m2mには線形関係があり、log ⁡ 2 \log_2ログ_2対数を計算し、サイクル数を取得しますmmmlog ⁡ 2 N \log_2Nログ_2Nは線形です。つまり、時間計算量はO ( log ⁡ N ) O(\log N)O (ログ_N
対数

線形対数スケールO ( N log ⁡ N ) O(N\log N)O ( Nログ_N ) :
ループの 2 つの層は互いに独立しており、最初の層と 2 番目の層の時間計算量はO ( log ⁡ N ) O(\log N)O (ログ_N) O ( N ) O(N) O ( N )、全体の時間計算量はO ( N log ⁡ N ) O(N\log N)O ( Nログ_

int algorithm(int N) {
    int count = 0;
    int i = N;
    while (i > 1) {
        i = i / 2;
        for (int j = 0; j < N; j++) {
            count += 1;
        }
    }
    return count;
}

線形対数順序は、「クイック ソート」、「マージ ソート」、「ヒープ ソート」などのソート アルゴリズムでよく使用されます。その時間計算量の原理は、次の図に示されています。
線形対数次数

階乗水準O ( N ! ) O(N!)O ( N !) :
階乗レベルは、数学における一般的な「完全順列」に対応します。つまり与えられたNNN個の非繰り返し要素の可能な順列をすべて見つけると、スキームの数は次のようになります: N × ( N − 1 ) × ( N − 2 ) × ⋯ × 2 × 1 = N ! N×(N−1) ×(N −2)×⋯×2×1=N!N×( N1 )×( N2 )××2×1=N !以下の図とコードに示すように、階乗は再帰を使用して実装されることがよくあります アルゴリズムの原理: 最初の層でNNN、2 番目の層がN − 1 N−1N1 , ... ,NN 日Nレベルで終了してバックトラックします

int algorithm(int N) {
    if (N <= 0) {
        return 1;
    }
    int count = 0;
    for (int i = 0; i < N; i++) {
        count += algorithm(N - 1);
    }
    return count;
}

階乗レベル

空間の複雑さ

概念定義

空間の複雑さに関係する空間タイプは次のとおりです。

  • 入力スペース: 入力データを保存するために必要なスペースのサイズ。
  • 一時記憶スペース: アルゴリズムの動作中に、すべての中間変数、オブジェクト、およびその他のデータを保存するために必要なスペース。
  • 出力スペース: アルゴリズムが実行から戻ったときに、出力データを保存するために必要なスペース。

通常、空間計算量は入力データ サイズNNを指します。Nの場合、実行するアルゴリズムによって使用される「一時記憶領域」+「出力領域」の全体のサイズ。
空間の複雑さ
さまざまな情報源によると、アルゴリズムによって使用されるメモリ空間は 3 つのカテゴリに分類されます。
命令空間:
コンパイル後、プログラム命令によって使用されるメモリ空間。
データ空間:
アルゴリズム内のさまざまな変数によって使用される空間。これには、宣言された定数、変数、動的配列、および動的オブジェクトによって使用されるメモリ空間が含まれます。
スタックフレーム空間:
プログラム呼び出し関数はスタックに基づいて実装されており、呼び出し中、関数は復帰後に解放されるまで一定サイズのスタックフレーム空間を占有します。次のコードに示すように、関数はループ内で呼び出され、呼び出しの各ラウンドでtest()return が返されO ( 1 ) O(1)( 1 )

int test() {
    return 0;
}

void algorithm(int N) {
    for (int i = 0; i < N; i++) {
        test();
    }
}

アルゴリズムでは、スタック フレーム スペースの蓄積が再帰呼び出しで頻繁に発生します。次のコードに示すように、再帰呼び出しを通じて、同時にNNが存在します。N 個の返されない関数O ( N ) O(N)algorithm()の累積使用O ( N )サイズのスタック フレーム スペース。

int algorithm(int N) {
    if (N <= 1) {
        return 1;
    }
    return algorithm(N - 1) + 1;
}

象徴的な表現

通常、スペース複雑さの統計アルゴリズムは、アルゴリズムの実行のために予約されているスペースの量を反映するために、「最悪の場合」のスペース サイズを使用します。記号OOを使用します。Oさんは言いました。
ワーストケースには、アルゴリズムの動作における「最悪の入力データ」と「最悪の動作点」の 2 つの意味があります。たとえば、次のコード:

整数NNを入力してくださいN、値の範囲N ≥ 1 N≥1N1

  • 最悪の入力データ: N ≤ 10 N\le10の場合N10の場合、配列numsO ( 10 ) = O ( 1 ) O(10)=O(1)( 10 )=O ( 1 );当N > 10 N>10N>10の場合、配列numsの長さはNNN、空間複雑さはO ( N ) O(N)O ( N ) ; したがって、入力データの最悪の場合、空間計算量はO ( N ) O(N)になります。O ( N )
  • 最悪の実行点: アルゴリズムは実行int* nums = (int*)malloc(10 * sizeof(int));時にO ( 1 ) O(1)のみを使用します。O ( 1 )サイズのスペース。実行nums = (int*)malloc(N * sizeof(int));O ( N ) O(N)O ( N )空間。したがって、最悪実行点での空間複雑さはO ( N ) O(N)O ( N )
void algorithm(int N) {
    int num = 5;              // O(1)
    int* nums = (int*)malloc(10 * sizeof(int));  // O(1)
    
    if (N > 10) {
        free(nums);  // 释放原来分配的内存
        nums = (int*)malloc(N * sizeof(int));  // O(N)
    }
}

一般的なタイプ

一般的なアルゴリズム空間の複雑さを小さいものから大きいものまで並べると、次のとおりです。O ( 1 ) < O ( log N ) < O ( N ) < O ( N 2 ) < O ( 2 N ) O(1)<O(logN) <O (N)<O(N^2)<O(2^N)<O (ログN ) _ _<O ( N )<O ( N2 )<2一般的な複雑さ

分析例

以下のすべての例では、入力データ サイズを正の整数NNとします。N、ノード クラスNode、次のコードに示すtest()関数

// 节点结构体
struct Node {
    int val;
    struct Node* next;
};

// 创建节点函数
struct Node* createNode(int val) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    newNode->val = val;
    newNode->next = NULL;
    return newNode;
}

// 函数 test()
int test() {
    return 0;
}

一定レベルO( 1 ) O(1)O ( 1 ) :
要素数が入力データ サイズ N に依存しない通常の定数、変数、オブジェクト、およびコレクションはすべて、一定サイズの空間を使用します。

int N = 0;                        // 变量
int num = 0;
int nums[10000] = {0};            // 数组
struct Node* node = createNode(0); // 动态对象

次のコードに示すように、関数test()NNを呼び出しますが、N回実行されますが、各ラウンドの呼び出しがtest()戻っO ( 1 ) O(1)( 1 )

void algorithm(int N) {
    for (int i = 0; i < N; i++) {
        test();
    }
}

線形クラスO ( N ) O(N)O ( N ) :
要素数 vsNNN間に線形関係を持つあらゆるタイプのコレクション(一般的に 1 次元配列、リンク リスト、ハッシュ テーブルなどで見られます) は、すべて線形サイズ空間を使用します。

int* nums_1 = (int*)malloc(N * sizeof(int));
int* nums_2 = (int*)malloc((N / 2) * sizeof(int));
struct Node** nodes = (struct Node**)malloc(N * sizeof(struct Node*));

以下の図とコードに示すように、この再帰呼び出し中に、同時にNNが発生します。Nalgorithm()個の関数は を返さないO ( N ) O(N)O ( N )サイズのスタック フレーム スペース。

int algorithm(int N) {
    if (N <= 1) return 1;
    return algorithm(N - 1) + 1;
}

リニアレベル

平方レベルO ( N 2 ) O(N^2)O ( N2 ):
要素数とNNN乗のコレクション (行列でよく見られる) で、すべて正方形サイズの空間を使用します。

int** num_matrix = (int**)malloc(N * sizeof(int*));
struct Node*** node_matrix = (struct Node***)malloc(N * sizeof(struct Node**));

// 初始化 num_matrix 二维数组
for (int i = 0; i < N; i++) {
    num_matrix[i] = (int*)malloc(N * sizeof(int));
    for (int j = 0; j < N; j++) {
        num_matrix[i][j] = 0;
    }
}

// 创建节点对象并初始化
for (int i = 0; i < N; i++) {
    node_matrix[i] = (struct Node**)malloc(N * sizeof(struct Node*));
    for (int j = 0; j < N; j++) {
        node_matrix[i][j] = createNode(j);
    }
}

以下の図とコードに示すように、再帰呼び出しと同時にNNが存在します。N 個の非戻りalgorithm()関数( N ) O(N)O ( N )スタック フレーム スペース。配列は再帰関数の各層で宣言され、平均長はN 2 \frac{N}{2}2N​ ,使用 O ( N ) O(N) O ( N )空間。したがって、全体の空間複雑さはO ( N 2 ) O(N^2)O ( N2 )

int algorithm(int N) {
    if (N <= 0) return 0;
    int* nums = (int*)malloc(N * sizeof(int));
    return algorithm(N - 1);
}

スクエアレベル

指数関数O ( 2 N ) O(2^N)2N ):
指数関数的順序は、バイナリ ツリーとマルチフォーク ツリーで一般的です。たとえば、高さがNNNの「完全なバイナリ ツリー」のノード数は2 N 2^N2N ,占用 O ( 2 N ) O(2^N) 2N )サイズ空間; 同様に、高さはNNNのフルmm「m分木」m N m^NメートルN,占用O ( m N ) = O ( 2 N ) O(m^N)=O(2^N)O ( m=2N )サイズのスペース。
指数関数的

対数O ( log ⁡ N ) O(\log N)O (ログ_N ) :
対数次数は、分割統治アルゴリズムのスタック フレーム領域の蓄積とデータ型変換によく現れます。次に例を示します。

  • クイックソート、平均空間複雑度はΘ ( log ⁡ N ) \Theta(\log N)です。Θ (ログ_N )、最悪の空間複雑さはO ( N ) O(N)O ( N )
  • 数値は文字列に変換され、正の整数がNNに設定されます。Nの場合、文字列の空間計算量はO ( log ⁡ N ) O(\log N)O (ログ_N 導出は次のとおりです: 正の整数NNNの桁数はlog⁡ 10 N \log_{10} Nログ_10N、つまり、変換された文字列の長さはlog ⁡ 10 N \log_{10} Nログ_10N、つまり空間複雑さはlog ⁡ N \log Nログ_

時空のトレードオフ

アルゴリズムの性能は、時間と空間の使用状況から総合的に評価する必要があります。優れたアルゴリズムには、時間と空間の複雑さが低いという 2 つの特性が必要です。実際、アルゴリズム問題の時間計算量と空間計算量を同時に最適化することは非常に困難です。時間の複雑さを軽減すると、空間の複雑さが増大するという犠牲が生じることがよくあり、その逆も同様です。

現代のコンピューターには十分なメモリがあるため、通常の状況では、アルゴリズムの設計には「時間のための空間」という方法が一般的に採用されています。つまり、アルゴリズムの実行速度を上げるためにコンピューターの記憶領域の一部が犠牲になります。

LeetCode の最初の質問である2 つの例にとると、「暴力的な列挙」と「補助ハッシュ テーブル」は、それぞれ「空間最適」と「時間最適」の 2 つのアルゴリズムです。

  • 方法 1: 暴力的な列挙
    時間計算量O ( N 2 ) O(N^2)O ( N2 )、空間計算量は O ( 1 ) O(1) です。O ( 1 ) ; は「スペースの時間」に属しますが、一定サイズの追加スペースのみを使用しますが、実行が遅すぎます。
  • 方法 2: 補助ハッシュ テーブルの
    時間計算量O ( N ) O(N)O ( N )、空間計算量O ( N ) O(N)O ( N ) ; は「時間の空間」に属し、補助ハッシュ テーブルの助けを借りて、配列要素の値とインデックスのマッピングを保存してアルゴリズムの動作効率を向上させることで、この問題に対する最良の解決策となります。 。

まとめ

上記はアルゴリズムの複雑さに関連する内容ですが、
次の記事では一般的に使用される 9 つのデータ構造を詳しく紹介し、継続的に更新されます。

おすすめ

転載: blog.csdn.net/a2360051431/article/details/130736803