第2章アルゴリズムの時間と空間の複雑さ
1.アルゴリズムの効率
アルゴリズムの品質を測定する方法は?
一般に、アルゴリズムの時間計算量と空間計算量によって測定されます。
時間計算量は主にアルゴリズムの実行速度を測定し、空間計算量は主にアルゴリズムの実行に必要な余分なスペースを測定します。コンピュータ開発の初期には、コンピュータのストレージ容量は非常に小さかった。そのため、スペースの複雑さが非常に懸念されます。しかし、コンピュータ産業の急速な発展の後、コンピュータ
のストレージ容量は非常に高いレベルに達しました。したがって、アルゴリズムのスペースの複雑さに特別な注意を払う必要はなくなりました。
2倍の複雑さ
2.1時間計算量の概念
定義:コンピューターサイエンスでは、アルゴリズムの時間計算量は、そのアルゴリズムの実行時間を定量的に表す関数です。アルゴリズムの基本操作の実行回数は、アルゴリズムの時間計算量です。
つまり、特定の基本ステートメントと問題サイズNの間の数式を見つけることは、アルゴリズムの時間計算量を計算することです。
例:
Q:Func1のステートメント++ countステートメントは何回実行されますか?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
}
実行回数は、次のように関数として表されます。
F(N) = N^2^ + 2 * N + 10
数学関数の観点から、関数
の最終的な演算結果までの2 * N + 10のイメージは、Nが増加するにつれて徐々に小さくなります。Nの面積が無限大の場合、それを無視することもできます。つまり、関数はNになる傾向があります。これは、無限大のN2
。
2.2BigOの漸近表記
Big O表記法:関数の漸近的振る舞いを説明するために使用される数学表記法です。
big-Oメソッドを導出します。
- 実行時のすべての加法定数を定数1に置き換えます(関数の基本ステートメントの実行回数が特定の定数の場合)。
- 変更された実行時間関数では、最上位の項のみが保持されます。
- 最上位の項が存在し、1でない場合は、この項を掛けた定数を削除します(つまり、最上位の項の係数を削除します)。結果はbig-O注文です。(時間計算量は桁違いに測定されます)
big-O漸近表記を使用した後、Func1の時間計算量は次のようになります。
O (N 2)
以上のことから、ビッグOの漸近表現により、結果にほとんど影響を与えない項目が削除され、実行回数が簡潔かつ明確に示されることがわかります。
さらに、一部のアルゴリズムの時間計算量には、最良、平均、および最悪のケースがあります。
最悪の場合:任意の入力サイズの最大実行数(上限)
平均的なケース:任意の入力サイズで必要な実行回数
最良の場合:任意の入力サイズの最小実行回数(下限)
実際には、一般的な懸念事項はアルゴリズムの最悪の場合の操作であるため、配列内のデータを検索する時間計算量はO(N)です。
2.3時間計算量の計算例
2.3.1例1
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
基本的な操作は2N+10回実行され、時間計算量はビッグオーオーダー法を導出することによりO(N)になります。
2.3.2例2
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++k)
{
++count;
}
for (int k = 0; k < N; ++k)
{
++count;
}
printf("%d\n", count);
}
基本的な操作はM+N回実行され、2つの未知数MとNがあり、時間計算量はO(N + M)です。
結論:時間計算量は、必ずしも1つだけ不明であるとは限りませんが、2つある場合があります。これは、特定のパラメーター不明に依存しますが、タイトルが次のように示している場合:
- MはNよりもはるかに大きく、時間計算量はO(M)になります。
- MとNはほぼ同じサイズであり、時間計算量はO(M)またはO(N)になり、どちらも記述できます。
2.3.3例3
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
基本操作は100回実行され、時間計算量はビッグオーオーダー法を導出することによりO(1)になります。
2.3.4例4
//计算strchr的时间复杂度?
const char* strchr(const char* str, int character);
この関数のおおよその実装は次のとおりです。
while(*str)
{
if(*str == character)
return str;
else
++str;
}
return NULL;
基本操作は1回実行するのが最適で、最悪の場合はN回、時間計算量は一般に最悪であり、時間計算量はO(N)です。
2.3.5例5
//计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
注:2層ループは必ずしもO(N 2)である必要はありませんが、関数の特定の実装にも依存します。
最初の比較の数:N-1
2番目の比較の数:N-2
3番目の比較の数:N-3
···
比較数N-1:1
最悪の場合:F(N)=(N *(N-1))/ 2
最良の場合:F(N)= N --1(最初のラウンドと比較すると、交換は発生せず、指定されたデータが正常であることを示しているため、並べ替えを続行する必要はありません)
複雑さ:O(N 2)(最悪の場合)
2.3.6例6
//计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n;
while (begin < end)
{
int mid = begin + ((end - begin) >> 1);
//用右移运算符是为了防止(end+begin)的值溢出,超出最大整数值
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
上記のコードは二分探索。
上記のコードは、左閉区間と右開区間です。2つの書き込み方法の違いは、境界の処理です。どちらの区間であっても、最後まで保持する必要があります。以下は、二分探索法です。左閉区間と右閉区間のコード:
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n - 1;
while (begin <= end)
{
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid - 1;
else
return mid;
}
return -1;
}
ベストケース:O(1)
最悪の場合:
X回検索し、最後に要素が1つだけ残っていて、それがまだ見つからないとします。
1(残りの1つの要素)* 2 X = N
X = log 2 N
時間計算量:O(log 2 N)( logNと省略されるものもあれば、 lgNと省略されるものもありますが、最終的にこの書き込み方法にはエラーがあり、推奨されません)
結論:ループの層の数だけでなく、アルゴリズムのアイデアを正確に分析するために。
2.3.7例7
//计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
計算分析により、基本演算はN回繰り返され(もちろん、関数呼び出しの数はN + 1)、時間計算量はO(N)であることがわかります。
上記の関数のバリエーションは次のとおりです。
long long Fac(size_t N)
{
if (0 == N)
return 1;
for(size_t i = 0;i < N;i++)
{
printf("%d",i);//看这个语句的执行次数
}
return Fac(N - 1) * N;
}
最初の関数呼び出し:N
2番目の関数呼び出し:N-1
···
N番目の関数呼び出し:1
N + 1番目の関数呼び出し:0(N = 0のため、forループは続行できません)
F(N)=(N + 1)* N / 2
したがって、時間計算量は次のとおりです。O(N 2)
知らせ:
再帰的アルゴリズムの時間計算量:
- 各関数呼び出しはO(1)であり、再帰の数によって異なります。
- 各関数呼び出しはO(1)ではなく、再帰呼び出しの数の累積に依存します。
2.3.8例8
//计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
この時点で、比例級数の式を使用して(1 *( 1-2 N-1))/(1-2)= 2 N-1- 1を計算できます
(この時点では完全な状況を想像していましたが、事実それがいっぱいになっている場所はありますか?たとえば、それらの数は比較的少なく、そこに到達できないため、上の右下隅に場所がありません)計算分析により、基本的な操作は2 N回(2N-1は2Nパワーと見なされます)回繰り返され、時間計算量はO(2 N)であることがわかります。
3.スペースの複雑さ
スペースの複雑さも数式であり、アルゴリズムの操作中に一時的に占有されるストレージスペースの量の尺度です。
スペースの複雑さは、プログラムが占めるスペースのバイト数ではありません。これはあまり意味がないため、**スペースの複雑さは変数の数をカウントします。****
スペースの複雑さの計算規則は、基本的に実際の複雑さと同様であり、big-O漸近表記も使用します。
注:関数の実行に必要なスタックスペース(ストレージパラメーター、ローカル変数、一部のレジスタ情報など)はコンパイル時に決定されるため、スペースの複雑さは主に、実行時に関数によって明示的に要求される追加スペースによって決定されます。
3.1例1:
//计算BubbleSort的空间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
分析:end、exchange、およびiの合計3つの変数が開かれます。つまり、一定量の余分なスペースが使用されるため、スペースの複雑さはO(1)になります。
3.2例2:
//计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
if (n == 0)
return NULL;
long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
分析:合計(n + 1)個の整数スペースが動的に開かれるため、スペースの複雑さはO(N)になります。
3.3例3:
//计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
分析:再帰呼び出しがN回行われ、N個のスタックフレームが開かれ、各スタックフレームは一定量のスペースを使用します。スペースの複雑さはO(N)です
3.4例4
//计算斐波那契递归Fib的空间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
注:時間は累積的であり、スペースは再利用できます。
分析:Fib(3)を呼び出した後、最初にFib(2)を呼び出します。Fib(2)が呼び出された後、Fib(2)のスタックフレームが破棄されます。Fib(2)のスタックフレームが破棄された後、続行します。 Fib(1)を呼び出すために、この時点でFib(1)スタックフレームが使用しているスペースは、現在Fib(2)スタックフレームが使用しているスペースと同じです。同じレイヤーによって開かれたスタックフレームが占めるスペースは同じスペースです。あれは、現時点では、合計N-1個のスタックフレームが開かれているため、スペースの複雑さはO(N)です。
例えば:
説明:f1のスタック・フレーム・スペースが破棄された後、f2は元のf1スタック・フレームのスペースを上書きします。(スペースの破壊は、システムに使用権を戻すことだけです)
4.一般的な複雑さの比較
一般的なアルゴリズムの一般的な複雑さは次のとおりです。
5201314 | O(1) | 一定の順序 |
---|---|---|
3n + 4 | オン) | 線形順序 |
3n 2 + 4 * n + 5 | O(n 2) | スクエアオーダー |
3log 2 n + 4 | O(log 2 n) | 対数 |
2n + 3nlog 2 n + 4 | O(nlog 2 n) | nlog2n次_ |
n 3 + 2n 2 + 4n + 6 | O(n 3) | 立方体の順序 |
2 n | O(2 ^ n) | 指数関数的な順序 |
複雑さの比較:
O(n!)> O(2 n)> O(n 2)> O(nlog 2 n)> O(n)> O(log 2 n)> O(1)
5.複雑さのためのoj演習
5.1消える数字
解決:
sort(bubble(N 2)、qsort(nlog 2 N))(対象外)
マッピング(添え字メソッド:各値が対応する添え字の数)(O(N))、ただしこのメソッドにはO(N)スペースの複雑さがあります
コード表示:
int *ret = (int*)malloc(sizeof(int)*(numsSize+1)); int i = 0; for(i = 0;i<numsSize+1;i++) { ret[i] = -1; } for(i = 0;i<numsSize;i++) { ret[nums[i]] = nums[i]; } for(i = 0;i<numsSize+1;i++) { if(ret[i]==-1) { return i; } } return -1; } ```
XOR(変数値を使用して0からNまでのデータをXORし、次に指定されたデータとXORします)(O(N))
int value = 0; int i = 0; for (i = 0; i <= numsSize; i++) { value ^= i; } for (i = 0; i < numsSize; i++) { value ^= nums[i]; } return value; } ```
算術式。0〜Nの合計から元の配列のすべてのデータを減算します。(オン))
int sum = 0; int i = 0; int n = numsSize; sum = (n + 1) * n / 2;//等差数列的和的公式 for (i = 0; i < numsSize; i++) { sum -= nums[i]; } return sum; } ```
5.2回転アレイ
高度:
- 可能な限り多くの解決策を考え出します。この問題に取り組むには、少なくとも3つの異なる方法があります。
- スペース
O(1)
が複雑なインプレースアルゴリズムを使用して、この問題を解決できますか?
一度に1文字ずつ、右にk回回転します。
時間計算量:
最後のケース:O(N)
最悪の場合:O(N * K)(もちろん、O(N 2 )と書くこともできます)
スペースの複雑さ:O(1)
追加の配列を開くには、開いた配列の前に最後のkを置き、配列の後ろに最初のN--kを置きます。
時間計算量:O(N)(Nは移動する配列要素をループします)
スペースの複雑さ:O(N)
3回の反転:1回目:最初のN-Kが反転し、2回目:最後のKが反転し、3回目:全体が反転します。
時間計算量:O(N)(合計2N要素が逆になります)
スペースの複雑さ:O(1)
コード:
while(left<=right) { int temp = nums[left]; nums[left] = nums[right]; nums[right] = temp; left++; right--; } } void rotate(int* nums, int numsSize, int k){ k%=numsSize; reverse(0,numsSize - k-1,nums); reverse(numsSize - k,numsSize-1,nums); reverse(0,numsSize - 1,nums); } ```