Αρχική > 23ος ΠΔΠ > anneal (εκφώνηση)

23ος ΠΔΠ Γ' Φάση: Anneal (anneal) - Λύση

Επεξήγηση εκφώνησης

Μας δίνονται \(N\) θάλαμοι και οι θερμοκρασίες \(a_i\) για τον κάθε θάλαμο \(i\). Έστω \(T_i\) η θερμοκρασία πριν τον θάλαμο \(i\). Αν \(a_i > T_i\) τότε πρέπει να πληρώσουμε τη διαφορά \(a_i - T\). Διαφορετικά είτε μπορούμε να ανανεώσουμε τη θερμοκρασία σε \(a_i\) (με μηδενικό κόστος) ή να διατηρήσουμε την ίδια με κόστος \(2 \cdot a_i\).

Μας ζητείται να βρούμε το ελάχιστο κόστος για να περάσουμε από όλους τους \(N\) θαλάμους.

Brute force

Ξεκινάμε με την εξής παρατήρηση:

Παρατήρηση 1: Δεν συμφέρει να έχουμε μεγαλύτερη θερμοκρασία από \(\max_{0 \leq i < N} a_i\) στην αρχή.

Αιτιολόγηση: Έστω ότι η βέλτιστη ακολουθία χρησιμοποιεί μία τιμή μεγαλύτερη από \(\max_{0 \leq i < N} a_i\). Αυτή η ακολουθία πρέπει να αποφεύγει τους πρώτους \(i\) θαλάμους (για κάποιο \(i\)), και μετά να ακολουθεί την τιμή του \(a_{i+1}\). Επομένως, το ίδιο κόστος θα έχει και η ακολουθία που ξεκινάει με το \(\max_{0 \leq i < N} a_i\).

Άρα μπορούμε να δοκιμάσουμε κάθε δυνατή αρχική τιμή και σε κάθε βήμα να πάρουμε όλες τις δυνατές κινήσεις. Από όλες τις δυνατές εκδοχές κρατάμε αυτή με το ελάχιστο κόστος.

#include <algorithm>
#include <cstdio>
#include <vector>

typedef long long ll;

const long MAXN = 50'000;
const ll MAXV = 10'000'000LL * 2 * (MAXN + 1);
long a[MAXN];

long N;

ll solve(long pos, ll temp) {
   if (pos == N) return 0;
   if (a[pos] >= temp) return solve(pos+1, temp) + (a[pos] - temp);
   ll one = solve(pos + 1, temp) + 2 * a[pos];
   ll two = solve(pos + 1, a[pos]);
   return std::min(one, two);
}

int main() {
   FILE *fi = fopen("anneal.in", "r");
   fscanf(fi, "%ld", &N);
   long max_a = 0;
   for (long i = 0; i < N; ++i) {
      fscanf(fi, "%ld", &a[i]);
      max_a = std::max(max_a, a[i]);
   }
   fclose(fi);
   
   ll mn = MAXV;
   for (long i = 0; i <= max_a; ++i) {
      ll candidate = solve(0, i);
      mn = std::min(mn, candidate);
   }
   
   FILE *fo = fopen("anneal.out", "w");
   fprintf(fo, "%lld\n", mn);
   fclose(fo);
   return 0;
}

Υπάρχουν \(\mathcal{O}(2^N)\) δυνατές ακολουθίες και \(\mathcal{O}(\max_{0 \leq i < N} a_i)\) δυνατές αρχικές τιμές. Επομένως ο αλγόριθμος τρέχει σε \(\mathcal{O}(2^N \cdot \max_{0 \leq i < N} a_i)\) χρόνο και χρειάζεται \(\mathcal{O}(N)\) μνήμη.

Δυναμικός προγραμματισμός με την θερμοκρασία

Μπορούμε να λύσουμε το πρόβλημα με την χρήση δυναμικού προγραμματισμού. Έστω \(dp[i][v]\) το ελάχιστο κόστος ώστε μετά από τους πρώτους \(i\) θαλάμους να έχουμε τη θερμοκρασία \(v\). Τότε, η τιμή του δίνεται από την παρακάτω σχέση

\[dp[i][v] = \begin{cases} dp[i-1][v] + 2 \cdot a_i & \text{if $a_i > v$,} \\ \min_{u \geq a_i} dp[i-1][u] & \text{if $a_i = v$}, \\ dp[i-1][v] + v - a_i & \text{otherwise,} \end{cases}\]

όπου θεωρούμε ότι \(dp[-1][\cdot] = 0\). Ο παρακάτω κώδικας λύνει αυτήν την σχέση:

   std::vector<std::vector<ll>> dp(N, std::vector<ll>(max_a + 1, 0));
   for (long i = 0; i < N; ++i) {
      ll mn = MAXV;
      for (long j = 0; j <= max_a; ++j) {
         ll prev = (i > 0) ? dp[i-1][j] : 0;
         if (a[i] > j) dp[i][j] = prev + a[i] - j;
         else {
            mn = std::min(mn, prev);
            dp[i][j] = prev + 2 * a[i];
         }
      }
      dp[i][a[i]] = mn;
   }

Υπάρχουν συνολικά \(N \cdot \max_{0 \leq i < N} a_i\) δυνατά states και κάθε γραμμή \(dp[i][\cdot]\) μπορούμε να την υπολογίσουμε σε \(\mathcal{O}(\max_{0 \leq i < N} a_i)\) χρόνο (γιατί η (1) και (3) περίπτωση θέλουν \(\mathcal{O}(1)\) χρόνο, ενώ η (2) θέλει \(\mathcal{O}(\max_{0 \leq i < N} a_i)\), αλλά υπάρχει μόνο μία τέτοια περίπτωση). Συνολικά ο αλγόριθμος χρειάζεται \(\mathcal{O}(N \cdot \max_{0 \leq i < N} a_i)\) χρόνο και μνήμη, και περνάει περίπου 10 από τα 18 testcases, γιατί το \(\max_{0 \leq i < N} a_i = 10^7\).

Ολόκληρος ο κώδικας είναι εδώ.

Δυναμικός προγραμματισμός με τους δείκτες

Παρατήρηση 2: Δεν συμφέρει να χρησιμοποιήσουμε θερμοκρασίες που δεν υπάρχουν σε κάποιον θάλαμο (δηλαδή σε μία βέλτιστη λύση, κάθε θερμοκρασία \(v_i = a_{j_i}\) για κάποιο \(j_i\)).

Αιτιολόγηση: Αν σε κάποιο βήμα ακολουθήσουμε τη θερμοκρασία του θαλάμου, τότε από εκείνο το σημείο και μετά έχουμε θερμοκρασία που αντιστοιχεί σε κάποιο \(a_{j_i}\). Επομένως, μένει να αιτιολογήσουμε ότι δεν συμφέρει να ξεκινήσουμε από μία θερμοκρασία που δεν εμφανίζεται σε κάποιον θάλαμο. Έστω ότι ξεκινούσαμε από \(x\) και έστω \(a_i\) η μικρότερη τιμή ώστε \(a_i > x\) (υπάρχει λόγω της Παρατήρησης 1), τότε στους διπλασιασμούς έχει το ίδιο κόστος αλλά στην μείωση έχει μικρότερο (\(a_j - a_i < a_j - x\)). Επομένως αν ξεκινήσουμε με το \(a_i\) αντί για το \(x\) τότε θα έχουμε μικρότερο συνολικό κόστος.

Αυτό μας επιτρέπει να μικρύνουμε τις διαστάσεις του πίνακα \(dp\) σε \(N \times N\), όπου \(dp[i][j]\) είναι το ελάχιστο κόστος ώστε μετά από τους πρώτους \(i\) θαλάμους να έχουμε τη θερμοκρασία \(a_j\). Η αναδρομική σχέση αλλάζει (λίγο) σε:

\[dp[i][j] = \begin{cases} dp[i-1][j] + 2 \cdot a_i & \text{if $a_i > a_j$,} \\ \min_{k : a_k \geq a_i} dp[i-1][k] & \text{if $a_i = a_j$}, \\ dp[i-1][j] + a_j - a_i & \text{otherwise,} \end{cases}\]

και η υλοποίηση γίνεται η εξής:

#include <algorithm>
#include <cstdio>
#include <vector>

typedef long long ll;

const size_t MAXN = 50'000;
const ll MAXV = 10'000'000LL * 2 * (MAXN + 1);
long a[MAXN];

int main() {
   long N;
   FILE *fi = fopen("anneal.in", "r");
   fscanf(fi, "%ld", &N);
   for (long i = 0; i < N; ++i) {
      fscanf(fi, "%ld", &a[i]);
   }
   fclose(fi);
   std::vector<std::vector<ll>> dp(N, std::vector<ll>(N, 0LL));
   for (long i = 0; i < N; ++i) {
      ll mn = MAXV;
      for (long j = 0; j < N; ++j) {
         ll prev = (i > 0) ? dp[i-1][j] : 0;
         if (a[i] > a[j]) dp[i][j] = prev + a[i] - a[j];
         else {
            mn = std::min(mn, prev);
            dp[i][j] = prev + 2 * a[i];
         }
      }
      dp[i][i] = mn;
   }
   ll mn = dp[N-1][0];
   for (long i = 0; i < N; ++i) {
      mn = std::min(mn, dp[N-1][i]);
   }
   FILE *fo = fopen("anneal.out", "w");
   fprintf(fo, "%lld\n", mn);
   fclose(fo);
   return 0;
}

Ο αλγόριθμος αυτός χρειάζεται \(\mathcal{O}(N^2)\) χρόνο και μνήμη. Περνάει 14 από τα 18 testcases, λόγω μνήμης.

Δυναμικός προγραμματισμός με τους δείκτες (και memoisation)

Μπορούμε να παρατηρήσουμε ότι για να συμπληρώσουμε την γραμμή \(dp[i][j]\) χρησιμοποιούμε μόνο την γραμμή \(dp[i-1][\cdot]\) (και όχι κάποια από τις προηγούμενες). Επομένως, μπορούμε να κρατήσουμε μόνο αυτή και να αλλάξουμε τον κώδικα ως εξής:

   for (long i = 0; i < N; ++i) {
      ll mn = MAXV;
      for (long j = 0; j < N; ++j) {
         if (a[i] > a[j]) dp[j] += a[i] - a[j];
         else {
            mn = std::min(mn, dp[j]);
            dp[j] += 2 * a[i];
         }
      }
      dp[i] = mn;
   }

Ολόκληρος ο κώδικας είναι εδώ.

Σημείωση: Στο δοσμένα testcases, αν κάνουμε τις τιμές \(a_i\) μοναδικές (με ταξινόμηση και αφαίρεση των διπλών), τότε περνάει με μεγαλύτερη άνεση τα testcases. Μπορείτε να δείτε ολόκληρο τον κώδικα εδώ.