web-dev-qa-db-de.com

Warum macht das so? O(n^2) Code schneller ausführen als O (n)?

Ich habe Code für zwei Ansätze geschrieben, um das erste eindeutige Zeichen in einer Zeichenfolge in LeetCode herauszufinden.

Problemstellung: Suchen Sie nach einer Zeichenfolge das erste, sich nicht wiederholende Zeichen darin und geben Sie den Index zurück. Wenn es nicht existiert, wird -1 zurückgegeben.

Beispieltestfälle:

s = "leetcode" gibt 0 zurück.

s = "Loveleetcode", Rückgabe 2.

Ansatz 1 (O (n)) (korrigiere mich, wenn ich falsch liege):

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;
    }
}

Ansatz 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;
    }
}

In Ansatz 2 denke ich, dass die Komplexität O (n ^ 2) sein sollte, da indexOf hier in O (n * 1) ausgeführt wird.

Wenn ich jedoch beide Lösungen auf LeetCode ausführen, erhalte ich eine Laufzeit von 19 ms für Ansatz 2 und 92 ms für Ansatz 1. Ich bin verwirrt; warum passiert das?

Ich nehme an, LeetCode testet sowohl kleine als auch große Eingabewerte für beste, schlechteste und durchschnittliche Fälle.

Update:

Mir ist bekannt, dass O (n ^ 2-Algorithmen) für bestimmte n <n1 eine bessere Leistung erbringen kann. In dieser Frage wollte ich verstehen, warum dies in diesem Fall geschieht. d.h. welcher Teil von Ansatz 1 macht ihn langsamer.

LeetCode-Link zur Frage

37
Nivedita

Erwägen:

  • f1(n) = n2
  • f2(n) = n + 1000

Eindeutig f1 ist O (n2) und f2 ist O (n). Für eine kleine Eingabe (beispielsweise n = 5) haben wir f1(n) = 25 aber f2(n)> 1000.

Nur weil eine Funktion (oder zeitliche Komplexität) O(n) und eine andere O (n) ist2) bedeutet nicht, dass ersteres für alle Werte von n kleiner ist, nur dass es einige n gibt, über die hinausgeht.

90
arshajii

Für sehr kurze Saiten, z. Einzelzeichen Die Kosten für das Erstellen von HashMap, das Ändern der Größe, das Nachschlagen von Einträgen beim Ein- und Ausboxen von char in Character können die Kosten für String.indexOf() überschatten, was wahrscheinlich von JVM als heiß und als Inline angesehen wird.

Ein weiterer Grund könnten die Kosten für den Zugriff auf RAM sein. Mit zusätzlichen HashMap-, Character- und Integer-Objekten, die an einer Suche beteiligt sind, kann zusätzlicher Zugriff auf den und vom RAM erforderlich sein. Der Einzelzugriff beträgt ~ 100 ns und dies kann sich summieren.

Werfen Sie einen Blick auf Bjarne Stroustrup: Warum sollten Sie Linked Lists vermeiden? Diese Vorlesung zeigt, dass Leistung nicht mit Komplexität gleichzusetzen ist und der Speicherzugriff ein Killer für einen Algorithmus sein kann.

40
Karol Dowbecki

Big O Notation ist ein theoretisches Maß für die Art und Weise, in der ein Algorithmus mit N die Anzahl der Elemente oder dominanten Operationen skaliert, und zwar immer als N->Infinity.

In der Praxis ist N in Ihrem Beispiel ziemlich klein. Während das Hinzufügen eines Elements zu einer Hashtabelle im Allgemeinen als amortisiert betrachtet wird, O (1), kann dies auch zu einer Speicherzuordnung führen (wiederum abhängig vom Design Ihrer Hashtabelle). Dies ist möglicherweise nicht O(1) - und kann auch dazu führen, dass der Prozess einen Systemaufruf an den Kernel für eine andere Seite durchführt. 

Nehmen Sie die Lösung O(n^2) - der String in a wird sich schnell im Cache befinden und wahrscheinlich ununterbrochen ausgeführt. Die Kosten für eine einzelne Speicherzuordnung sind wahrscheinlich höher als für das Paar verschachtelter Schleifen. 

In der Praxis mit modernen CPU-Architekturen, in denen Lese-Form-Cache-Speicher um Größenordnungen schneller sind als diejenigen aus dem Hauptspeicher, ist N ziemlich groß, bevor ein theoretisch optimaler Algorithmus eine lineare Datenstruktur und lineare Suche übertrifft. Binäre Bäume sind besonders schlecht für die Cache-Effizienz.

[Edit] Es ist Java: Die Hashtabelle enthält Verweise auf das geschachtelte Java.lang.Character-Objekt. Jede einzelne Addition führt zu einer Speicherzuordnung.

17
marko

Auf2) ist nur die schlimmsteZeitkomplexität des zweiten Ansatzes.

Für Zeichenfolgen wie bbbbbb...bbbbbbbbbaaaaaaaaaaa...aaaaaaaaaaa, in denen x bs und xas vorhanden sind, erfordert jede Schleifeniteration etwa x-Schritte, um den Index zu bestimmen. Daher sind insgesamt ca. 2x2. Für x ungefähr 30000 würde es ungefähr 1-2 Sekunden dauern, während die andere Lösung viel besser wäre.

Beim Online-Versuch: Dieser Benchmark berechnet, dass Ansatz 2 etwa 50-mal langsamer ist als Ansatz 1 für die obige Zeichenfolge. Für größere x ist der Unterschied noch größer (Annäherung 1 dauert ungefähr 0,01 Sekunden, Annäherung 2 dauert einige Sekunden)

Jedoch:

Für Zeichenfolgen, bei denen jedes Zeichen unabhängig ausgewählt wird, einheitlich aus {a,b,c,...,z} [1]Die erwartete Zeitkomplexität sollte O (n) sein. 

Dies ist wahr, vorausgesetzt, Java verwendet den naiven String-Suchalgorithmus, der das Zeichen einzeln durchsucht, bis eine Übereinstimmung gefunden wird, und kehrt sofort zurück. Die zeitliche Komplexität der Suche ist die Anzahl der berücksichtigten Zeichen.

Es kann leicht nachgewiesen werden (der Beweis ähnelt this Math.SE post - Erwarteter Wert der Anzahl der Flips bis zum ersten Kopf ), dass die erwartete Position eines bestimmten Zeichens in einer einheitlichen unabhängigen Zeichenfolge über dem Alphabet {a,b,c,...,z} ist O (1). Daher wird jeder indexOf- und lastIndexOf-Aufruf in der erwarteten O(1) Zeit ausgeführt, und der gesamte Algorithmus benötigt die erwartete O(n) Zeit.

[1]: In der original leetcode Challenge heißt es das

Sie können davon ausgehen, dass die Zeichenfolge nur Kleinbuchstaben enthält.

Das wird in der Frage jedoch nicht erwähnt.

11
user202729

Karol hat bereits eine gute Erklärung für Ihren speziellen Fall geliefert. Ich möchte eine allgemeine Anmerkung bezüglich der großen O-Notation für zeitliche Komplexität hinzufügen. 

Im Allgemeinen sagt Ihnen diese zeitliche Komplexität nicht viel über die tatsächliche Leistung aus. Sie erhalten lediglich eine Vorstellung von der Anzahl der Iterationen, die ein bestimmter Algorithmus benötigt.

Ich sage es so: Wenn Sie eine große Anzahl schneller Iterationen ausführen, kann dies immer noch schneller sein als die Ausführung sehr weniger extrem langsamer Iterationen. 

3
yaccob

Ich habe die Funktionen nach C++ (17) portiert, um zu sehen, ob der Unterschied durch die Komplexität des Algorithmus oder Java verursacht wurde.

#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;
}

Das Ergebnis war:

Die zweite ist für leetcode ~ 30% schneller.

Später bemerkte ich das

    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));
    }

könnte optimiert werden

    char_hash.try_emplace(c, 1);

Das bestätigt auch, dass Komplexität nicht das Einzige ist. Es gibt "Input-Länge", die andere Antworten abgedeckt haben, und ich habe das schließlich bemerkt

Die Implementierung macht auch einen Unterschied. Bei längerem Code werden Optimierungsmöglichkeiten ausgeblendet.

0
MCCCS