LeetCodeの文字列の最初の一意の文字を見つけるための2つのアプローチのコードを記述しました。
問題文:文字列が与えられると、その中の最初の非繰り返し文字を見つけて、そのインデックスを返します。存在しない場合は、-1を返します。
サンプルテストケース:
s = "leetcode"は0を返します。
s = "loveleetcode"、2を返します。
アプローチ1(O(n))(間違っている場合は修正してください):
class Solution {
public int firstUniqChar(String s) {
HashMap<Character,Integer> charHash = new HashMap<>();
int res = -1;
for (int i = 0; i < s.length(); i++) {
Integer count = charHash.get(s.charAt(i));
if (count == null){
charHash.put(s.charAt(i),1);
}
else {
charHash.put(s.charAt(i),count + 1);
}
}
for (int i = 0; i < s.length(); i++) {
if (charHash.get(s.charAt(i)) == 1) {
res = i;
break;
}
}
return res;
}
}
アプローチ2(O(n ^ 2)):
class Solution {
public int firstUniqChar(String s) {
char[] a = s.toCharArray();
int res = -1;
for(int i=0; i<a.length;i++){
if(s.indexOf(a[i])==s.lastIndexOf(a[i])) {
res = i;
break;
}
}
return res;
}
}
アプローチ2では、indexOfはここでO(n * 1)で実行されるため、複雑さはO(n ^ 2)になるはずです。
しかし、LeetCodeで両方のソリューションを実行すると、アプローチ2で19ミリ秒、アプローチ1で92ミリ秒のランタイムが得られます。なぜそれが起こるのですか?
LeetCodeは、最高、最悪、および平均の場合について、小さい入力値と大きい入力値の両方をテストすると考えられます。
更新:
O(n ^ 2アルゴリズム)が特定のn <n1でより良いパフォーマンスを発揮できるという事実を知っています。この質問では、なぜこの場合にそれが起こっているのかを理解したかったのです。つまり、アプローチ1のどの部分が遅くなるか。
非常に短い文字列の場合、例えば単一文字HashMap
の作成コスト、サイズ変更、char
のCharacter
へのボックス化およびボックス化解除中のエントリの検索は、おそらくString.indexOf()
のコストを覆い隠すかもしれません。いずれにしても、JVMによってホットでインライン化されていると見なされます。
別の理由は、RAMアクセスのコストかもしれません。ルックアップに関係する追加のHashMap
、Character
、およびInteger
オブジェクトを使用すると、RAMとの追加アクセスが必要になる場合があります。単一アクセスは最大100 nsであり、これは合計されます。
Bjarne Stroustrup:リンクリストを避けるべき理由を見てください。この講義では、パフォーマンスは複雑さと同じではなく、メモリアクセスはアルゴリズムにとって致命的である可能性があることを示しています。
考慮してください:
明らかにf1 O(n2)およびf2 O(n)です。小さな入力(たとえば、n = 5)の場合、f1(n)= 25ただしf2(n)> 1000。
1つの関数(または時間の複雑さ)がO(n)であり、別の関数がO(n2)は、前者がnのすべての値に対して小さいことを意味するのではなく、nがそれを超える場合にこれが当てはまります。
ビッグO表記 は、N
-要素または支配的な操作の数で、常にN->Infinity
。
実際には、例のN
はかなり小さいです。ハッシュテーブルに要素を追加することは一般に償却O(1)と見なされますが、メモリ割り当てが発生することもあります(これも、ハッシュテーブルの設計によって異なります)。これはO(1)ではない場合があり、プロセスが別のページのカーネルにシステムコールを行うこともあります。
O(n^2)
解決策を講じる-a
の文字列はすぐにキャッシュ内で検出され、中断されることはありません。単一のメモリ割り当てのコストは、ネストされたループのペアよりも高くなる可能性があります。
キャッシュからの読み取りがメインメモリからの読み取りよりも桁違いに速い最新のCPUアーキテクチャの実際には、N
は、理論的に最適なアルゴリズムを使用して線形データ構造および線形検索よりも性能が大きくなる前に非常に大きくなります。バイナリツリーは、キャッシュの効率にとって特に悪いニュースです。
[編集] Java:ハッシュテーブルには、ボックス化されたJava.lang.Character
オブジェクトへの参照が含まれています。 1回追加するごとにメモリが割り当てられます
オン2)は、2番目のアプローチの最悪のケース時間の複雑さのみです。
x
bとx
aが存在するbbbbbb...bbbbbbbbbaaaaaaaaaaa...aaaaaaaaaaa
などの文字列の場合、各ループの反復はインデックスを決定するのにx
のステップを要するため、実行される合計ステップは2x2
になります。 x
が約30000の場合、約1〜2秒かかりますが、他のソリューションの方がはるかに優れています。
「オンラインで試す」では、 このベンチマーク は、上記の文字列について、アプローチ2がアプローチ1の約50倍遅いことを計算します。 x
が大きくなると、差はさらに大きくなります(アプローチ1は約0.01秒かかり、アプローチ2は数秒かかります)
ただし:
{a,b,c,...,z}
から一様に、各文字が個別に選択された文字列の場合 [1]、予想される時間の複雑さはO(n)になります。
これは、Javaが単純な文字列検索アルゴリズムを使用し、一致が見つかるまで文字を1つずつ検索し、すぐに戻ると仮定した場合に当てはまります。検索の時間の複雑さは、考慮される文字の数です。
簡単に証明できます(証明は このMath.SEの投稿-最初のヘッドまでのフリップ回数の期待値 と似ています)。アルファベット{a,b,c,...,z}
はO(1)です。したがって、各indexOf
およびlastIndexOf
呼び出しは予想されるO(1)時間で実行され、アルゴリズム全体は予想されるO(n)時間かかります。
[1]: 元のleetcodeチャレンジ では、
文字列には小文字のみが含まれていると想定できます。
ただし、それは質問には記載されていません。
カロルはあなたの特別なケースについてすでに良い説明を提供してくれました。時間の複雑さのための大きなO表記に関する一般的なコメントを追加したいと思います。
一般的に、今回の複雑さは、実際のパフォーマンスについてあまり語りません。特定のアルゴリズムに必要な反復回数を知ることができます。
このようにしましょう:大量の高速反復を実行する場合、ごく少数の極端に遅い反復を実行するよりも高速です。
関数をC++(17)に移植して、違いがアルゴリズムの複雑さまたはJavaによって引き起こされたかどうかを確認しました。
#include <map>
#include <string_view>
int first_unique_char(char s[], int s_len) noexcept {
std::map<char, int> char_hash;
int res = -1;
for (int i = 0; i < s_len; i++) {
char c = s[i];
auto r = char_hash.find(c);
if (r == char_hash.end())
char_hash.insert(std::pair<char, int>(c,1));
else {
int new_val = r->second + 1;
char_hash.erase(c);
char_hash.insert(std::pair<char, int>(c, new_val));
}
}
for (int i = 0; i < s_len; i++)
if (char_hash.find(s[i])->second == 1) {
res = i;
break;
}
return res;
}
int first_unique_char2(char s[], int s_len) noexcept {
int res = -1;
std::string_view str = std::string_view(s, s_len);
for (int i = 0; i < s_len; i++) {
char c = s[i];
if (str.find_first_of(c) == str.find_last_of(c)) {
res = i;
break;
}
}
return res;
}
結果は次のとおりです。
2つ目は、
leetcode
の場合に最大30%高速です。
後で、私はそれに気づいた
if (r == char_hash.end())
char_hash.insert(std::pair<char, int>(c,1));
else {
int new_val = r->second + 1;
char_hash.erase(c);
char_hash.insert(std::pair<char, int>(c, new_val));
}
に最適化できます
char_hash.try_emplace(c, 1);
また、複雑さだけが問題ではないことも確認できます。 「入力の長さ」がありますが、他の答えもカバーしていますが、最後に、
実装も違いをもたらします。長いコードは最適化の機会を隠します。