Απομυθοποιητικός δυναμικός προγραμματισμός

Πώς να δημιουργήσετε και να κωδικοποιήσετε δυναμικούς αλγορίθμους προγραμματισμού

Ίσως το έχετε ακούσει για την προετοιμασία κωδικοποίησης συνεντεύξεων. Ίσως το έχετε αντιμετωπίσει σε ένα πρόγραμμα αλγορίθμων Ίσως προσπαθείτε να μάθετε πώς να κωδικοποιείτε μόνοι σας και σας είπαν κάπου στο δρόμο ότι είναι σημαντικό να κατανοήσετε τον δυναμικό προγραμματισμό. Η χρήση δυναμικού προγραμματισμού (DP) για την εγγραφή αλγορίθμων είναι εξίσου απαραίτητη με τον φόβο.

Και ποιος μπορεί να κατηγορήσει αυτούς που συρρικνώνονται από αυτό; Ο δυναμικός προγραμματισμός φαίνεται εκφοβιστικός, επειδή δεν είναι διδάσκεται. Πολλά μαθήματα επικεντρώνονται στο αποτέλεσμα - εξηγώντας τον αλγόριθμο, αντί της διαδικασίας - βρίσκοντας τον αλγόριθμο. Αυτό ενθαρρύνει την απομνημόνευση και όχι την κατανόηση.

Κατά τη διάρκεια του μαθήματος αλγορίθμων φέτος, ένωσα τη δική μου διαδικασία για την επίλυση προβλημάτων που απαιτούν δυναμικό προγραμματισμό. Τμήματα αυτού προέρχονται από τον καθηγητή αλγορίθμων μου (στον οποίο οφείλεται μεγάλη πίστωση!) Και τμήματα από τη δική μου ανατομή δυναμικών αλγορίθμων προγραμματισμού.

Αλλά πριν μοιραστώ τη διαδικασία μου, ας ξεκινήσουμε με τα βασικά. Τι είναι ο δυναμικός προγραμματισμός, ούτως ή άλλως;

Καθορισμένος δυναμικός προγραμματισμός

Ο δυναμικός προγραμματισμός ισοδυναμεί με διάσπαση ενός προβλήματος βελτιστοποίησης σε απλούστερα υπο-προβλήματα και αποθήκευση της λύσης σε κάθε υπο-πρόβλημα, έτσι ώστε κάθε υπο-πρόβλημα να επιλύεται μόνο μία φορά.

Για να είμαι ειλικρινής, αυτός ο ορισμός μπορεί να μην έχει νόημα μέχρι να δείτε ένα παράδειγμα ενός δευτερεύοντος προβλήματος. Εντάξει, έρχεται στην επόμενη ενότητα.

Αυτό που ελπίζω να μεταφέρω είναι ότι το DP είναι μια χρήσιμη τεχνική για προβλήματα βελτιστοποίησης, εκείνα τα προβλήματα που αναζητούν τη μέγιστη ή ελάχιστη λύση δεδομένων ορισμένων περιορισμών, επειδή εξετάζει όλα τα πιθανά υπο-προβλήματα και δεν υπολογίζει ποτέ τη λύση σε οποιοδήποτε υπο-πρόβλημα. Αυτό εγγυάται την ορθότητα και την αποτελεσματικότητα, την οποία δεν μπορούμε να πούμε για τις περισσότερες τεχνικές που χρησιμοποιούνται για την επίλυση ή την προσέγγιση αλγορίθμων. Αυτό μόνο κάνει το DP ξεχωριστό.

Στις επόμενες δύο ενότητες, θα εξηγήσω τι είναι ένα υπο-πρόβλημα και, στη συνέχεια, θα παρακινήσω γιατί η αποθήκευση λύσεων - μια τεχνική γνωστή ως απομνημόνευση - έχει σημασία στον δυναμικό προγραμματισμό.

Υπο-προβλήματα σε Υπο-προβλήματα σε Υπο-προβλήματα

Τα δευτερεύοντα προβλήματα είναι μικρότερες εκδόσεις του αρχικού προβλήματος. Στην πραγματικότητα, τα υπο-προβλήματα μοιάζουν συχνά με μια αναδιατυπωμένη έκδοση του αρχικού προβλήματος. Εάν διατυπωθεί σωστά, τα δευτερεύοντα προβλήματα βασίζονται το ένα στο άλλο προκειμένου να επιτευχθεί η λύση στο αρχικό πρόβλημα.

Για να σας δώσουμε μια καλύτερη ιδέα για το πώς λειτουργεί αυτό, ας βρούμε το υπο-πρόβλημα σε ένα παράδειγμα πρόβλημα δυναμικού προγραμματισμού.

Προσποιηθείτε ότι επιστρέφετε στη δεκαετία του 1950 δουλεύοντας σε έναν υπολογιστή IBM-650. Ξέρεις τι σημαίνει αυτό - κάρτες διάτρησης! Η δουλειά σας είναι στον άνδρα ή τη γυναίκα, το IBM-650 για μια ημέρα. Σας δίνεται ένα φυσικό αριθμό n punchcards να τρέξει. Κάθε punchcard i πρέπει να τρέξει σε κάποιο προκαθορισμένο αρχή του χρόνου s_i και σταματήσουν να προβάλλονται σε κάποιο προκαθορισμένο χρόνο τερματισμού f_i . Μόνο μία punchcard μπορεί να τρέξει ταυτόχρονα στο IBM-650. Κάθε κάρτα punchcard έχει επίσης μια σχετική τιμή v_i με βάση το πόσο σημαντικό είναι για την εταιρεία σας.

Πρόβλημα : Ως υπεύθυνος του IBM-650, πρέπει να καθορίσετε το βέλτιστο πρόγραμμα των καρτών διάτρησης που μεγιστοποιεί τη συνολική αξία όλων των τρεχούμενων καρτών.

Επειδή θα διαβάσω αυτό το παράδειγμα με μεγάλη λεπτομέρεια σε όλο αυτό το άρθρο, θα σας πειράξει μόνο με το υπο-πρόβλημα του προς το παρόν:

Υπο-πρόβλημα : Το πρόγραμμα μέγιστης τιμής για κάρτες διάτρησης i έως n έτσι ώστε οι κάρτες διάτρησης να ταξινομούνται κατά την ώρα έναρξης.

Παρατηρήστε πώς το υπο-πρόβλημα διασπά το αρχικό πρόβλημα σε στοιχεία που δημιουργούν τη λύση. Με το υπο-πρόβλημα, μπορείτε να βρείτε το πρόγραμμα μέγιστης τιμής για κάρτες διάτρησης n-1 έως n και, στη συνέχεια, για κάρτες διάτρησης n-2 έως n , και ούτω καθεξής. Βρίσκοντας τις λύσεις για κάθε επιμέρους πρόβλημα, μπορείτε στη συνέχεια να αντιμετωπίσετε το ίδιο το αρχικό πρόβλημα: το πρόγραμμα μέγιστης τιμής για κάρτες διάτρησης 1 έως n . Δεδομένου ότι το υπο-πρόβλημα μοιάζει με το αρχικό πρόβλημα, τα δευτερεύοντα προβλήματα μπορούν να χρησιμοποιηθούν για την επίλυση του αρχικού προβλήματος.

Στον δυναμικό προγραμματισμό, αφού λύσετε κάθε υπο-πρόβλημα, πρέπει να το απομνημονεύσετε ή να το αποθηκεύσετε. Ας μάθουμε γιατί στην επόμενη ενότητα.

Παρακίνηση απομνημόνευσης με αριθμούς Fibonacci

Όταν σας ζητήθηκε να εφαρμόσετε έναν αλγόριθμο που υπολογίζει την τιμή Fibonacci για οποιονδήποτε συγκεκριμένο αριθμό, τι θα κάνατε; Οι περισσότεροι άνθρωποι που γνωρίζω θα επιλέξουν έναν αναδρομικό αλγόριθμο που μοιάζει με αυτό στο Python:

def fibonacciVal(n): if n == 0: return 0 elif n == 1: return 1 else: return fibonacciVal(n-1) + fibonacciVal(n-2)

Αυτός ο αλγόριθμος επιτυγχάνει τον σκοπό του, αλλά με τεράστιο κόστος. Για παράδειγμα, ας δούμε τι πρέπει να υπολογίσει αυτός ο αλγόριθμος για να λύσει το n = 5 (συντομογραφία F (5)):

F(5) / \ / \ / \ F(4) F(3) / \ / \ F(3) F(2) F(2) F(1) / \ / \ / \ F(2) F(1) F(1) F(0) F(1) F(0) / \ F(1) F(0)

Το παραπάνω δέντρο αντιπροσωπεύει κάθε υπολογισμό που πρέπει να γίνει για να βρείτε την τιμή Fibonacci για n = 5. Παρατηρήστε πώς επιλύεται το υπο-πρόβλημα για το n = 2 τρεις φορές. Για ένα σχετικά μικρό παράδειγμα (n = 5), αυτό είναι πολύ επαναλαμβανόμενο και χαμένο, υπολογισμός!

Τι γίνεται αν, αντί να υπολογίσουμε την τιμή Fibonacci για n = 2 τρεις φορές, δημιουργήσαμε έναν αλγόριθμο που τον υπολογίζει μία φορά, αποθηκεύει την τιμή του και αποκτά πρόσβαση στην αποθηκευμένη τιμή Fibonacci για κάθε επακόλουθη εμφάνιση του n = 2; Αυτό ακριβώς κάνει η απομνημόνευση.

Έχοντας αυτό κατά νου, έχω γράψει μια δυναμική λύση προγραμματισμού στο πρόβλημα αξίας Fibonacci:

def fibonacciVal(n): memo = [0] * (n+1) memo[0], memo[1] = 0, 1 for i in range(2, n+1): memo[i] = memo[i-1] + memo[i-2] return memo[n]

Παρατηρήστε πώς η λύση της τιμής επιστροφής προέρχεται από το σημείωμα πίνακα απομνημονεύσεων [], το οποίο συμπληρώνεται επαναληπτικά από το βρόχο για. Με το «επαναληπτικά», εννοώ ότι το σημείωμα [2] υπολογίζεται και αποθηκεύεται πριν από το σημείωμα [3], το σημείωμα [4],… και το σημείωμα [ n ]. Επειδή το σημείωμα [] συμπληρώνεται με αυτήν τη σειρά, η λύση για κάθε υπο-πρόβλημα (n = 3) μπορεί να επιλυθεί με τις λύσεις στα προηγούμενα δευτερεύοντα προβλήματα (n = 2 και n = 1) επειδή αυτές οι τιμές είχαν ήδη αποθηκευτεί σε σημείωμα [] νωρίτερα.

Η απομνημόνευση δεν σημαίνει εκ νέου υπολογισμό, κάτι που δημιουργεί έναν πιο αποτελεσματικό αλγόριθμο. Έτσι, η απομνημόνευση διασφαλίζει ότι ο δυναμικός προγραμματισμός είναι αποτελεσματικός, αλλά επιλέγει το σωστό υπο-πρόβλημα που εγγυάται ότι ένα δυναμικό πρόγραμμα περνά από όλες τις δυνατότητες για να βρει το καλύτερο.

Τώρα που έχουμε αντιμετωπίσει την απομνημόνευση και τα δευτερεύοντα προβλήματα, ήρθε η ώρα να μάθουμε τη δυναμική διαδικασία προγραμματισμού. Αγκράφα μέσα.

Η διαδικασία δυναμικού προγραμματισμού μου

Βήμα 1: Προσδιορίστε το υπο-πρόβλημα με λέξεις.

Πολύ συχνά, οι προγραμματιστές θα στραφούν στο γράψιμο κώδικα πριν σκεφτούν κριτικά το πρόβλημα που υπάρχει. ΟΧΙ καλα. Μία στρατηγική για να ενεργοποιήσετε τον εγκέφαλό σας πριν αγγίξετε το πληκτρολόγιο είναι να χρησιμοποιήσετε λέξεις, αγγλικά ή με άλλο τρόπο, για να περιγράψετε το υπο-πρόβλημα που έχετε εντοπίσει στο αρχικό πρόβλημα.

Εάν επιλύετε ένα πρόβλημα που απαιτεί δυναμικό προγραμματισμό, πάρτε ένα κομμάτι χαρτί και σκεφτείτε τις πληροφορίες που χρειάζεστε για να λύσετε αυτό το πρόβλημα. Γράψτε το υπο-πρόβλημα λαμβάνοντας υπόψη αυτό.

For example, in the punchcard problem, I stated that the sub-problem can be written as “the maximum value schedule for punchcards i through n such that the punchcards are sorted by start time.” I found this sub-problem by realizing that, in order to determine the maximum value schedule for punchcards 1 through n such that the punchcards are sorted by start time, I would need to find the answer to the following sub-problems:

  • The maximum value schedule for punchcards n-1 through n such that the punchcards are sorted by start time
  • The maximum value schedule for punchcards n-2 through n such that the punchcards are sorted by start time
  • The maximum value schedule for punchcards n-3 through n such that the punchcards are sorted by start time
  • (Et cetera)
  • The maximum value schedule for punchcards 2 through n such that the punchcards are sorted by start time

If you can identify a sub-problem that builds upon previous sub-problems to solve the problem at hand, then you’re on the right track.

Step 2: Write out the sub-problem as a recurring mathematical decision.

Once you’ve identified a sub-problem in words, it’s time to write it out mathematically. Why? Well, the mathematical recurrence, or repeated decision, that you find will eventually be what you put into your code. Besides, writing out the sub-problem mathematically vets your sub-problem in words from Step 1. If it is difficult to encode your sub-problem from Step 1 in math, then it may be the wrong sub-problem!

There are two questions that I ask myself every time I try to find a recurrence:

  • What decision do I make at every step?
  • If my algorithm is at step i, what information would it need to decide what to do in step i+1? (And sometimes: If my algorithm is at step i, what information did it need to decide what to do in step i-1?)

Let’s return to the punchcard problem and ask these questions.

What decision do I make at every step? Assume that the punchcards are sorted by start time, as mentioned previously. For each punchcard that is compatible with the schedule so far (its start time is after the finish time of the punchcard that is currently running), the algorithm must choose between two options: to run, or not to run the punchcard.

If my algorithm is at stepi, what information would it need to decide what to do in stepi+1? To decide between the two options, the algorithm needs to know the next compatible punchcard in the order. The next compatible punchcard for a given punchcard p is the punchcard q such that s_q (the predetermined start time for punchcard q) happens after f_p (the predetermined finish time for punchcard p) and the difference between s_q and f_p is minimized. Abandoning mathematician-speak, the next compatible punchcard is the one with the earliest start time after the current punchcard finishes running.

If my algorithm is at stepi, what information did it need to decide what to do in stepi-1? The algorithm needs to know about future decisions: the ones made for punchcards i through n in order to decide to run or not to run punchcard i-1.

Now that we’ve answered these questions, perhaps you’ve started to form a recurring mathematical decision in your mind. If not, that’s also okay, it becomes easier to write recurrences as you get exposed to more dynamic programming problems.

Without further ado, here’s our recurrence:

OPT(i) = max(v_i + OPT(next[i]), OPT(i+1))

This mathematical recurrence requires some explaining, especially for those who haven’t written one before. I use OPT(i) to represent the maximum value schedule for punchcards i through n such that the punchcards are sorted by start time. Sounds familiar, right? OPT(•) is our sub-problem from Step 1.

In order to determine the value of OPT(i), we consider two options, and we want to take the maximum of these options in order to meet our goal: the maximum value schedule for all punchcards. Once we choose the option that gives the maximum result at step i, we memoize its value as OPT(i).

The two options — to run or not to run punchcard i — are represented mathematically as follows:

v_i + OPT(next[i])

This clause represents the decision to run punchcard i. It adds the value gained from running punchcard i to OPT(next[i]), where next[i] represents the next compatible punchcard following punchcard i. OPT(next[i]) gives the maximum value schedule for punchcards next[i] through n such that the punchcards are sorted by start time. Adding these two values together produces maximum value schedule for punchcards i through n such that the punchcards are sorted by start time if punchcard i is run.

OPT(i+1)

Conversely, this clause represents the decision to not run punchcard i. If punchcard i is not run, its value is not gained. OPT(i+1) gives the maximum value schedule for punchcards i+1 through n such that the punchcards are sorted by start time. So, OPT(i+1) gives the maximum value schedule for punchcards i through n such that the punchcards are sorted by start time if punchcard i is not run.

In this way, the decision made at each step of the punchcard problems is encoded mathematically to reflect the sub-problem in Step 1.

Step 3: Solve the original problem using Steps 1 and 2.

In Step 1, we wrote down the sub-problem for the punchcard problem in words. In Step 2, we wrote down a recurring mathematical decision that corresponds to these sub-problems. How can we solve the original problem with this information?

OPT(1)

It’s that simple. Since the sub-problem we found in Step 1 is the maximum value schedule for punchcards i through n such that the punchcards are sorted by start time, we can write out the solution to the original problem as the maximum value schedule for punchcards 1 through n such that the punchcards are sorted by start time. Since Steps 1 and 2 go hand in hand, the original problem can also be written as OPT(1).

Step 4: Determine the dimensions of the memoization array and the direction in which it should be filled.

Did you find Step 3 deceptively simple? It sure seems that way. You may be thinking, how can OPT(1) be the solution to our dynamic program if it relies on OPT(2), OPT(next[1]), and so on?

You’re correct to notice that OPT(1) relies on the solution to OPT(2). This follows directly from Step 2:

OPT(1) = max(v_1 + OPT(next[1]), OPT(2))

But this is not a crushing issue. Think back to Fibonacci memoization example. To find the Fibonacci value for n = 5, the algorithm relies on the fact that the Fibonacci values for n = 4, n = 3, n = 2, n = 1, and n = 0 were already memoized. If we fill in our memoization table in the correct order, the reliance of OPT(1) on other sub-problems is no big deal.

How can we identify the correct direction to fill the memoization table? In the punchcard problem, since we know OPT(1) relies on the solutions to OPT(2) and OPT(next[1]), and that punchcards 2 and next[1] have start times after punchcard 1 due to sorting, we can infer that we need to fill our memoization table from OPT(n) to OPT(1).

How do we determine the dimensions of this memoization array? Here’s a trick: the dimensions of the array are equal to the number and size of the variables on which OPT(•) relies. In the punchcard problem, we have OPT(i), which means that OPT(•) only relies on variable i, which represents the punchcard number. This suggest that our memoization array will be one-dimensional and that its size will be n since there are n total punchcards.

If we know that n = 5, then our memoization array might look like this:

memo = [OPT(1), OPT(2), OPT(3), OPT(4), OPT(5)]

However, because many programming languages start indexing arrays at 0, it may be more convenient to create this memoization array so that its indices align with punchcard numbers:

memo = [0, OPT(1), OPT(2), OPT(3), OPT(4), OPT(5)]

Step 5: Code it!

To code our dynamic program, we put together Steps 2–4. The only new piece of information that you’ll need to write a dynamic program is a base case, which you can find as you tinker with your algorithm.

A dynamic program for the punchcard problem will look something like this:

def punchcardSchedule(n, values, next): # Initialize memoization array - Step 4 memo = [0] * (n+1) # Set base case memo[n] = values[n] # Build memoization table from n to 1 - Step 2 for i in range(n-1, 0, -1): memo[i] = max(v_i + memo[next[i]], memo[i+1]) # Return solution to original problem OPT(1) - Step 3 return memo[1]

Congrats on writing your first dynamic program! Now that you’ve wet your feet, I’ll walk you through a different type of dynamic program.

Paradox of Choice: Multiple Options DP Example

Although the previous dynamic programming example had a two-option decision — to run or not to run a punchcard — some problems require that multiple options be considered before a decision can be made at each step.

Time for a new example.

Pretend you’re selling the friendship bracelets to n customers, and the value of that product increases monotonically. This means that the product has prices {p_1, …, p_n} such that p_i ≤ p_j if customer j comes after customer i. These n customers have values {v_1, …, v_n}. A given customer i will buy a friendship bracelet at price p_i if and only if p_iv_i; otherwise the revenue obtained from that customer is 0. Assume prices are natural numbers.

Problem: You must find the set of prices that ensure you the maximum possible revenue from selling your friendship bracelets.

Take a second to think about how you might address this problem before looking at my solutions to Steps 1 and 2.

Step 1: Identify the sub-problem in words.

Sub-problem: The maximum revenue obtained from customers i through n such that the price for customer i-1 was set at q.

I found this sub-problem by realizing that to determine the maximum revenue for customers 1 through n, I would need to find the answer to the following sub-problems:

  • The maximum revenue obtained from customers n-1 through n such that the price for customer n-2 was set at q.
  • The maximum revenue obtained from customers n-2 through n such that the price for customer n-3 was set at q.
  • (Et cetera)

Notice that I introduced a second variable q into the sub-problem. I did this because, in order to solve each sub-problem, I need to know the price I set for the customer before that sub-problem. Variable q ensures the monotonic nature of the set of prices, and variable i keeps track of the current customer.

Step 2: Write out the sub-problem as a recurring mathematical decision.

There are two questions that I ask myself every time I try to find a recurrence:

  • What decision do I make at every step?
  • If my algorithm is at step i, what information would it need to decide what to do in step i+1? (And sometimes: If my algorithm is at step i, what information would it need to decide what to do in step i-1?)

Let’s return to the friendship bracelet problem and ask these questions.

What decision do I make at every step? I decide at which price to sell my friendship bracelet to the current customer. Since prices must be natural numbers, I know that I should set my price for customer i in the range from q — the price set for customer i-1 — to v_i — the maximum price at which customer i will buy a friendship bracelet.

If my algorithm is at stepi, what information would it need to decide what to do in stepi+1? My algorithm needs to know the price set for customer i and the value of customer i+1 in order to decide at what natural number to set the price for customer i+1.

With this knowledge, I can mathematically write out the recurrence:

OPT(i,q) = max~([Revenue(v_i, a) + OPT(i+1, a)])
such that max~ finds the maximum over all a in the range q ≤ a ≤ v_i

Once again, this mathematical recurrence requires some explaining. Since the price for customer i-1 is q, for customer i, the price a either stays at integer q or it changes to be some integer between q+1 and v_i. To find the total revenue, we add the revenue from customer i to the maximum revenue obtained from customers i+1 through n such that the price for customer i was set at a.

In other words, to maximize the total revenue, the algorithm must find the optimal price for customer i by checking all possible prices between q and v_i. If v_iq, then the price a must remain at q.

What about the other steps?

Working through Steps 1 and 2 is the most difficult part of dynamic programming. As an exercise, I suggest you work through Steps 3, 4, and 5 on your own to check your understanding.

Runtime Analysis of Dynamic Programs

Now for the fun part of writing algorithms: runtime analysis. I’ll be using big-O notation throughout this discussion . If you’re not yet familiar with big-O, I suggest you read up on it here.

Generally, a dynamic program’s runtime is composed of the following features:

  • Pre-processing
  • How many times the for loop runs
  • How much time it takes the recurrence to run in one for loop iteration
  • Post-processing

Overall, runtime takes the following form:

Pre-processing + Loop * Recurrence + Post-processing

Let’s perform a runtime analysis of the punchcard problem to get familiar with big-O for dynamic programs. Here is the punchcard problem dynamic program:

def punchcardSchedule(n, values, next): # Initialize memoization array - Step 4 memo = [0] * (n+1) # Set base case memo[n] = values[n] # Build memoization table from n to 1 - Step 2 for i in range(n-1, 0, -1): memo[i] = max(v_i + memo[next[i]], memo[i+1]) # Return solution to original problem OPT(1) - Step 3 return memo[1]

Let’s break down its runtime:

  • Pre-processing: Here, this means building the the memoization array. O(n).
  • How many times the for loop runs: O(n).
  • How much time it takes the recurrence to run in one for loop iteration: The recurrence takes constant time to run because it makes a decision between two options in each iteration. O(1).
  • Post-processing: None here! O(1).

The overall runtime of the punchcard problem dynamic program is O(n) O(n) * O(1) + O(1), or, in simplified form, O(n).

You Did It!

Well, that’s it — you’re one step closer to becoming a dynamic programming wizard!

One final piece of wisdom: keep practicing dynamic programming. No matter how frustrating these algorithms may seem, repeatedly writing dynamic programs will make the sub-problems and recurrences come to you more naturally. Here’s a crowdsourced list of classic dynamic programming problems for you to try.

So get out there and take your interviews, classes, and life (of course) with your newfound dynamic programming knowledge!

Ευχαριστώ πολύ τους Steven Bennett, Claire Durand και Prithaj Nath για την διόρθωση αυτής της ανάρτησης. Ευχαριστώ τον καθηγητή Hartline που με ενθουσιάστηκε τόσο για τον δυναμικό προγραμματισμό που έγραψα για αυτό.

Σας αρέσει αυτό που διαβάζετε; Διαδώστε την αγάπη αρέσει και μοιραστείτε αυτό το κομμάτι. Έχετε σκέψεις ή ερωτήσεις; Επικοινωνήστε μαζί μου στο Twitter ή στα παρακάτω σχόλια.