web-dev-qa-db-fra.com

Pourquoi cela O(n^2) code exécuter plus vite que O (n)?

J'ai écrit du code pour deux approches afin de trouver le premier caractère unique d'une chaîne sur LeetCode.

Déclaration du problème: Étant donné une chaîne, trouvez le premier fichier non répétitif caractère dedans et retourne son index. S'il n'existe pas, retourne -1.

Exemples de cas de test:

s = "leetcode" renvoie 0.

s = "loveleetcode", retourne 2.

Approche 1 (O (n)) (corrigez-moi si je me trompe):

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

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

Dans l'approche 2, je pense que la complexité devrait être O (n ^ 2) car indexOf s'exécute dans O (n * 1) ici.

Mais lorsque j'exécute les deux solutions sur LeetCode, la durée d'exécution est de 19 ms pour l'approche 2 et de 92 ms pour l'approche 1. Je suis confus; pourquoi cela se produit-il?

Je suppose que LeetCode teste les valeurs d'entrée petites et grandes pour les meilleurs, les pires et les moyens.

Mettre à jour:

Je suis conscient du fait que O (n ^ 2 algorithmes) peut mieux fonctionner pour certains n <n1. Dans cette question, je voulais comprendre pourquoi cela se produit dans ce cas. c'est-à-dire quelle partie de l'approche 1 le ralentit.

LeetCode lien vers la question

37
Nivedita

Considérer:

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

Clairement f1 est sur2) et f2 est sur). Pour une petite entrée (disons n = 5), nous avons f1(n) = 25 mais f2(n)> 1000.

Juste parce qu’une fonction (ou complexité temporelle) est O(n) et une autre est O (n2) ne signifie pas que le premier est plus petit pour toutes les valeurs de n, mais qu’il existe un n au-delà duquel ce sera le cas.

90
arshajii

Pour des chaînes très courtes, par exemple seul caractère, le coût de la création de HashMap, de son redimensionnement, de la recherche d'entrées lors de la mise en boîte et de l'extraction de char dans Character pourrait éclipser le coût de String.indexOf(), ce qui est probablement considéré chaud et intégré par JVM dans les deux cas.

Une autre raison pourrait être le coût de l'accès RAM. Avec les objets HashMap, Character et Integer supplémentaires impliqués dans une recherche, un accès supplémentaire à la RAM peut être nécessaire. L'accès unique est ~ 100 ns et cela peut s'additionner.

Jetez un coup d'œil à Bjarne Stroustrup: Pourquoi éviter les listes chaînées. Cette conférence montre que la performance n’est pas la même chose que la complexité et que l’accès à la mémoire peut tuer un algorithme.

40
Karol Dowbecki

La notation Big O est une mesure théorique de la manière dont un algorithme évolue en termes de consommation de mémoire ou de temps de calcul avec N - le nombre d'éléments ou d'opérations dominantes, et toujours en tant que N->Infinity.

En pratique, N dans votre exemple est relativement petit. Bien que l’ajout d’un élément à une table de hachage soit généralement considéré comme amorti O (1), il peut en résulter une allocation de mémoire (là encore, selon la conception de votre table de hachage). Il se peut que ce ne soit pas O(1) - et que le processus appelle également le système pour envoyer une autre page au noyau. 

Prenant la solution O(n^2) - la chaîne dans a se trouvera rapidement dans le cache et s'exécutera probablement sans interruption. Le coût d'une seule allocation de mémoire sera probablement plus élevé que la paire de boucles imbriquées. 

Dans la pratique, avec les architectures de CPU modernes, où le cache de formulaires de lecture est beaucoup plus rapide que celui de la mémoire principale, N sera assez grand avant d'utiliser un algorithme théoriquement optimal surpassant une structure de données linéaire et une recherche linéaire. Les arbres binaires sont particulièrement mauvaises pour l'efficacité du cache.

[Edit] c'est Java: La table de hachage contient des références à un objet Java.lang.Character encadré. Chaque ajout entraînera une allocation de mémoire

17
marko

Sur2) n’est que le pire cas temps complexe de la deuxième approche.

Pour les chaînes telles que bbbbbb...bbbbbbbbbaaaaaaaaaaa...aaaaaaaaaaa où il y a x b et x a, chaque itération de boucle nécessite environ x pour déterminer l'index. Par conséquent, le total des étapes exécutées est d'environ 2x2. Pour x environ 30000, cela prend environ 1 à 2 seconde (s), tandis que l’autre solution serait beaucoup plus performante.

Dans Essayez-le en ligne, ce repère calcule que l'approche 2 est environ 50 fois plus lente que l'approche 1 pour la chaîne ci-dessus. Pour x plus grand, la différence est encore plus grande (l'approche 1 prend environ 0,01 seconde, l'approche 2 prend quelques secondes)

Toutefois:

Pour les chaînes avec chaque caractère choisi indépendamment, uniformément à partir de {a,b,c,...,z} [1], la complexité temporelle attendue devrait être O (n). 

Ceci est vrai en supposant que Java utilise l'algorithme de recherche de chaîne naïf, qui recherche le caractère un par un jusqu'à ce qu'une correspondance soit trouvée, puis retourne immédiatement. La complexité temporelle de la recherche est le nombre de caractères considérés.

Il peut être facilement prouvé (la preuve est similaire à this Math.SE post - Valeur attendue du nombre de retournements jusqu’à la première tête ) que la position attendue d’un caractère particulier dans une chaîne indépendante uniforme sur l’alphabet {a,b,c,...,z} est O (1). Par conséquent, chaque appel indexOf et lastIndexOf s'exécute dans le temps attendu O(1), et l'ensemble de l'algorithme prend le temps attendu O(n).

[1]: Dans le original leetcode challenge , on dit que

Vous pouvez supposer que la chaîne ne contient que des lettres minuscules.

Cependant, cela n'est pas mentionné dans la question.

11
user202729

Karol a déjà fourni une bonne explication pour votre cas particulier. Je souhaite ajouter une remarque générale concernant la grande notation O pour la complexité temporelle. 

En général, cette complexité temporelle ne vous en dit pas beaucoup sur les performances réelles. Cela vous donne simplement une idée du nombre d'itérations nécessaires pour un algorithme particulier.

Permettez-moi de le formuler comme suit: si vous exécutez une grande quantité d'itérations rapides, cela peut être plus rapide que d'exécuter très peu d'itérations extrêmement lentes. 

3
yaccob

J'ai porté les fonctions en C++ (17) pour voir si la différence était due à la complexité de l'algorithme ou à 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;
}

Le résultat était:

Le second est environ 30% plus rapide pour leetcode.

Plus tard, j'ai remarqué que

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

pourrait être optimisé pour

    char_hash.try_emplace(c, 1);

Ce qui confirme également que la complexité n'est pas la seule chose à faire. Il y a "longueur d'entrée", ce que d'autres réponses ont couvert et enfin, j'ai remarqué que

La mise en œuvre fait également une différence. Un code plus long cache des opportunités d'optimisation.

0
MCCCS