Sfaturi pentru implementare

Aceasta este o listă cu sfaturi / bune practici / idei de optimizare care vă pot ajuta când rezolvați probleme la PA. Acoperă aspecte legate de performanță, dar și de coding style sau convenții respectate de comunitatea de programatori.

Deși sunt sfaturi generale, e posibil să pună accent mai mult pe rezolvarea temelor.

C++

Majoritatea punctelor din secțiunea despre C++ pot fi rezumate prin: “nu folosiți lucruri din C, ci folosiți alternativele din C++, dacă există”. Unele schimbări aduc un mic overhead, dar în general nu ar trebui să facă diferența între un program rapid și unul încet.

Folosiți stream-urile din C++ pentru IO

Pentru a citi și scrie date, recomandăm să folosiți stream-urile definite în C++. Pentru lucrul cu stream-urile standard există obiectele std::cin și std::cout, iar pentru lucrul cu fișiere există clasele std::ifstream și std::ofstream.

Stream-urile din C++ nu sunt neapărat mai încete decât IO-ul din C. În orice caz, problemele nu ar trebui să vă ceară să folosiți un tip anume de citire.

int answer;
 
std::ifstream fin("input-file");
fin >> answer;
std::ofstream fout("output-file");
fout << answer;

Când citiți de la tastatură, puteți dezactiva sincronizarea dintre stream-urile C++ și cele din C cu funcția std::ios::sync_with_stdio. Sincronizarea există pentru cazul în care vreți să folosiți atât std::cin / std::cout cât și scanf / printf în același program, ceea ce oricum nu este recomandat. Dezactivarea aduce un plus de viteză.

int main() {
    std::ios::sync_with_stdio(false);
    // Restul codului.
}

Pentru a citi o linie întreagă într-un șir de caractere, puteți folosi funcția std::getline:

std::string line; // Nu e nevoie să setăm vreo dimensiune.
std::getline(std::cin, line);

Folosiți biblioteca standard

Biblioteca standard din C++ conține implementări eficiente pentru unii algoritmi de bază, precum căutarea binară sau găsirea unei statistici de ordine. Recomandăm să folosiți aceste funcții dacă se potrivesc bine în programul vostru. În loc să implementați singuri algoritmi de sortare, folosiți std::sort, eventual cu un predicat custom.

În bibliotecă există clase pentru structuri de date comune precum vectori (std::vector), cozi (std::queue), dicționare (std::unordered_map) sau mulțimi de valori (std::set). Sunt implementate eficient și se ocupă singure de lucrul cu memoria. Implementați structuri de date doar dacă nu există în bibliotecă, sau dacă vă trebuie o versiune modificată.

Notă: e posibil ca indexarea într-un std::vector să fie mai înceată decât într-un vector tip C (int vec[100]) atunci când optimizările sunt dezactivate (cum facem uneori la PA).

Containere “ordered” vs. “unordered”

Containerele map și set stochează datele în arbori, în ordinea crescătoare a cheilor. Asta înseamnă că poți itera prin valori în ordine crescătoare “pe gratis”, dar are două consecințe:

  1. poți folosi clasa doar dacă stochezi în ea valori ordonabile
  2. operațiile vor avea complexitate logaritmică, din cauza rebalansărilor arborilor din spate.

Dacă nu aveți nevoie de proprietatea asta, folosiți unordered_map și unordered_set, pentru complexitate constantă amortizat.

Evitați alocarea dinamică manuală

Deși C++ ne permite să alocăm memorie dinamic, de obicei nu e nevoie să facem asta. Dacă folosiți containere din biblioteca standard scăpați de mare parte din lucrul cu memoria. Este obișnuit să puneți obiectele container pe stivă, pentru că datele pe care le salvează sunt puse pe heap.

int main() {
    // Cod ok, nu crapă stiva. Obiectul `vec` traiește pe stivă,
    // dar cele un milion de elemente pe care le reține sunt pe heap.
    // Obiectul de pe stivă conține doar un pointer către zona unde sunt ele.
    std::vector<int> vec(1'000'000);
    return 0;
}

De asemenea, codul nu devine mai încet de obicei dacă returnați containere din funcții, pentru că se copiază doar pointer-ul către zona unde e stocat conținutul, nu elementele în sine. Ca stil de programare e încurajat, pentru că face scopul funcției mai clar.

std::vector<int> Solve(int input) {
    // E ok să construim un vector aici și să îl returnăm.
    std::vector<int> answer(1'000'000, input);
    return answer;
}
 
int main() {
    // Primim un vector cu un milion de elemente, dar operația este ieftină.
    // Se copiază pointer-ul intern către zona cu un milion de elemente,
    // conținutul nu trebuie copiat => e rapid.
    auto answer = Solve(42);
    return 0;
}

Dacă alegeți să alocați singuri memorie, folosiți operatorii din C++ (new, delete și delete[]), nu funcțiile din C.

Folosiți referințe pentru a pasa parametri

Dacă vreți să trimiteți o cantitate mare de date ca parametru al unei funcții, folosiți referințe pentru a evita să le copiați inutil.

int Solve(vector<int> vec);        // Se copiază conținutul vectorului => scump.
int Solve(vector<int>& vec);       // Se copiază un pointer, dar pot modifica conținutul.
int Solve(const vector<int>& vec); // Se copiază un pointer, și nu am voie să modific.

Ca rule of thumb, obiectele ar trebui pasate mai mereu folosind referințe. Referința constantă semnifică faptul că parametrul este read-only, pe când referința ne-constantă e folosită doar dacă vrem să transmitem un răspuns către exterior prin acea variabilă. Mecanismul de a pasa un container prin copiere (fără referință) e util dacă vrem să modificăm containerul în funcție, dar nu vrem să fie vizibile modificările în exterior.

Regula nu se aplică și pentru tipuri primitive (int, double, char etc.), pentru care e mai simplu să copiezi variabila decât să trimiți o referință la ea.

void Func(
    const std::map<std::string, int>& numbers,
    double factor,
    std::vector<int>& result_accumulator
);

Setați din start dimensiunea vectorilor dacă o cunoașteți

Clasa std::vector ne lasă să adăugăm noi elemente în vector prin metoda push_back. Deși complexitatea a $N$ inserții este liniară amortizat, realocările făcute pe parcurs pot încetini programul și e bine să le evităm, dacă putem.

Dacă știm că vectorul urmează să aibă $N$ elemente, putem specifica asta în constructor, iar vectorul va aloca toate elementele din start. Similar, există și metoda reserve care alocă memorie pentru un anumit număr de elemente, dar păstrează vectorul gol (pregătit pentru multe push_back-uri).

std::vector<int> vec1(100);       // Vector cu 100 de elemente.
std::vector<int> vec2(100, kInf); // Vector cu 100 de elemente infinit.
 
std::vector<int> vec3; // Vector gol.
vec3.reserve(100);     // Tot gol, dar alocă memorie pentru 100 de elemente.
vec3.push_back(1);     // Inserții fără realocări.
vec3.push_back(2);

Nu folosiți std::endl

Funcția std::endl este asemănătoare caracterului \n și produce o linie nouă în output. O diferență față de \n este că forțează golirea buffer-ului în care este scris output-ul (flushing).

Dacă produceți output structurat pe multe linii, std::endl poate încetini destul de mult programul, pentru că nu ne lasă să profităm de buffering.

Operația de flush poate fi declanșată și “manual”, prin metoda flush. De exemplu: std::cout.flush().

Java

Citire și scriere mai rapidă

Clasa Scanner din Java nu face buffering, ceea ce poate încetini programul. Din acest motiv recomandăm să folosiți o clasă custom. Clasa de mai jos are o interfață asemănătoare cu Scanner, așa că nu trebuie să schimbați mult programul pentru a o folosi.

/**
 * A class for buffering read operations, inspired from here:
 * https://pastebin.com/XGUjEyMN.
 */
private static class MyScanner {
    private BufferedReader br;
    private StringTokenizer st;
 
    public MyScanner(Reader reader) {
        br = new BufferedReader(reader);
    }
 
    public String next() {
        while (st == null || !st.hasMoreElements()) {
            try {
                st = new StringTokenizer(br.readLine());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return st.nextToken();
    }
 
    public int nextInt() {
        return Integer.parseInt(next());
    }
 
    public long nextLong() {
        return Long.parseLong(next());
    }
 
    public double nextDouble() {
        return Double.parseDouble(next());
    }
 
    public String nextLine() {
        String str = "";
        try {
            str = br.readLine();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return str;
    }
}

De asemenea, dacă generați mult output, recomandăm să folosiți buffering pentru scriere, de exemplu prin clasa BufferedWriter din biblioteca standard (dacă nu generați mult output, clasa PrintStream are o interfață mai prietenoasă).

public static void main(final String[] args) throws IOException {
    var scanner = new MyScanner(new FileReader("input-file"));
    var answer = scanner.nextInt();
 
    try (var writer = new BufferedWriter(new FileWriter("output-file"))) {
        // `BufferedWriter` stie să scrie doar string-uri.
        writer.write(Integer.toString(answer) + "\n");
    }
}

Independent de limbaj

Nu lăsați printuri nenecesare în cod

O greșeală destul de comună este să lăsați printuri de debug în cod atunci când trimiteți o rezolvare. Deși sunt utile când faceți debugging, ele încetinesc mult programul, mai ales dacă generați mult output. E obișnuit ca o rezolvare corectă să iasă din timp doar din cauza outputului extra. IO is expensive.

Folosiți long long pentru calcule modulo / evitarea overflow-ului

Atunci când trebuie să calculați rezultate foarte mari, de obicei se cere să scrieți răspunsul modulo un număr mare, nu răspunsul exact. Un mod simplu de a evita overflow în cazul ăsta este să folosiți tipuri de date mai mari decât aveți nevoie, precum long long în C++ sau long în Java. În general nu încetinesc programul mult, și vă simplifică viața. În formule puteți converti o valoare int într-una long long înmulțind-o cu 1LL în C++, sau cu 1L în Java.

Folosiți operația modulo după fiecare calcul intermediar dintr-o formulă, pentru a evita erori de tip overflow. Deși sunt operații în plus, nu schimbă complexitatea algoritmului și nici nu ar trebui să facă diferența între un program rapid și unul încet.

// C++
// `int64_t` e o alternativă la `long long`, din biblioteca `<cstdint>`.
constexpr int64_t kMod = 1e9 + 7; // Număr prim folosit des în probleme.
 
int a = 1 << 30;
int b = 1 << 30;
int64_t big = 1LL << 50; // 2^50
 
// Vrem să calculăm formula [(a + b) * big] % kMod.
int answer = (((1LL * a + b) % kMod) * (big % kMod)) % kMod;
// Java
final long MOD = (long) 1e9 + 7; // Număr prim folosit des în probleme.
 
int a = 1 << 30;
int b = 1 << 30;
long big = 1L << 50; // 2^50
 
// Vrem să calculăm formula [(a + b) * big] % MOD.
int answer = (int) (((1L * a + b) % MOD * (big % MOD)) % MOD);

Important: operația de împărțire nu are aceleași proprietăți ca adunarea, înmulțirea etc., trebuie să folosim noțiunea de invers modular în cazul ei.

Folosiți un profiler

În cazul în care vreți să aflați unde petrece cea mai mare parte din timp programul vostru, puteți folosi un program de tip profiler. El poate sugera care părți din program (inclusiv bibliotecile folosite) vor aduce cea mai mare îmbunătățire dacă le înlocuim sau reimplementăm mai eficient.

De exemplu, există profiler-ul gratuit gprofng (parte dintr-o versiune suficient de nouă de GNU binutils). Un exemplu simplu de folosire al lui este următorul:

# Rulezi programul ca în mod obișnuit, dar prin intermediul gprofng.
# Pentru C++ ajută dacă binarul e compilat cu flag-ul `-g` pentru debugging.
gprofng collect app ./main
gprofng collect app java Main < input
# Rularea generează un director cu date despre execuție: "test.1.er".
 
gprofng display text -functions test.1.er # Vezi rezultate text, sau
gprofng display html            test.1.er # vezi rezultate HTML.

Totuși, sperăm să nu fie nevoie de optimizări așa low-level la PA.

pa/tutoriale/coding-tips.txt · Last modified: 2024/03/06 21:21 by andrei.preda3006
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0