Γλώσσα Προγραμματισμού ΙΙ

Διάλεξη 19ης Μαΐου 2015 - Δυναμικός προγραμματισμός

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

Θεωρούμε το πρόβλημα του προσδιορισμού τοτ $n$-οστού όρου $F_n$ της ακολουθίας Fibonacci, η οποία ορίζεται από τη σχέση $F_n = F_{n-1} + F_{n-2}$ για $n\ge 3$, με $F_1 = F_2 = 1$. Ο συνηθέστερος (αναδρομικός) αλγόριθμος για την εύρεση του $F_n$ είναι:


def fib(n):
	if n <= 2:
		f = 1
	else:
		f = fib(n-1) + fib(n-2)

	return f

Εύκολα μπορεί να δείξει κανείς ότι ο παραπάνω αλγόριθμος όντως υπολογίζει τον όρο $F_n$ για κάθε $n$, αλλά το μειονέκτημά του είναι ότι ο χρόνος εκτέλεσής του είναι εκθετικός ως προς το $n$. Το πρόβλημα είναι, προφανώς, οι δύο αναδρομικές κλήσεις fib(n-1) και fib(n-2). Παρατηρήστε ότι η κλήση fib(5), προκαλεί τις αναδρομικές κλήσεις fib(4), fib(3) και fib(2), με τις δύο τελευταίες κλήσεις να γίνονται δύο φορές. Μπορούμε να διορθώσουμε αυτό το μειονέκτημα του συγκεκριμένου αλγορίθμου με το να "θυμόμαστε" το αποτέλεσμα όλων των αναδρομικών κλήσεων που κάνουμε, και να μην χρειάζεται να τις επαναλάβουμε. Στην Python μπορούμε να χρησιμοποιήσουμε ένα λεξικό το οποίο με κλειδί $n$ αποθηκύει την τιμή fib(n):


memo = {}

def fib(n):
	if n in memo:
		return memo[n]
	if n <= 2:
		f = 1
	else:
		f = fib(n-1) + fib(n-2)
	memo[n] = f
	return f

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

Αυτή είναι και η θεμελιώσδης ιδέα του δυναμικού πρπγραμματισμού: χωρίζουμε το αρχικό πρόβλημα σε υποπροβλήματα την λύση των οποίων θυμόμαστε έτσι ώστε να μην την υπολογίσουμε ξανά, αν χρειαστεί. Ο χρόνος εκτέλεσης, λοιπόν, οποιουδήποτε αλγορίθμου δυναμικού προγραμματισμού θα είναι ίσος με τον αριθμό των υποπροβλημάτων επί τον χρόνο που απατείται για τη λύση κάθε υποπροβλήματος. Για το πρόβλημα του υπολογισμού των όρων της ακολουθίας Fibonacci, αν θέλω την τιμή $F_n$, θα υπολογίσω ακριβώς μία φορά κάθε ένα από τους όρους $F_1, F_2, \ldots, F_{n-1}$ και θα τους συνδιάσω για τον υπολογισμό του $F_n$. Ο παρακάτω κώδικας υλοποιεί την ιδέα αυτή:


def fib2(n):
	fib = {}

	for k in range(1,n+1):
		if k <= 2:
			f = 1
		else:
			f = fib[k-1] + fib[k-2]
		fib[k] = f

	return fib[n]

Ας έρθουμε τώρα σε ένα δεύτερο παράδειγμα, την εύρεση του συντομότερου μονοπατιού σε ένα γράφημα $V$. Τποθέτουμε ότι έχουμε ένα γράφημα $V$, ένα αρχικό κόμβο $s$ και ενδιαφερόμαστε για το συντομότερο μονοπάτι από τον κόμβο $s$ προς κάθε άλλο κόμβο $v\in V$, το μήκος του οποίου θα συμβολίζουμε με $\delta(s, v)$. Η ιδέα ενός δυναμικού αλγόριθμου για λύση του παραπάνω προβλήματος βασίζεται στη σχέση

$$ \delta(s,v) = \min_{(u,v)\in E} \{ \delta(s,u) + w(u,v)\}, $$

όπου $E$ είναι το σύνολο των ακμών του γραφήματος $V$, $(u,v)$ είναι η ακμή από τον κόμβο $u$ στον κόμβο $v$ και $w(u,v)$ είναι το κόστος διάνυσης αυτής της ακμής. Ο χρόνος εκτέλεσης του συγκεκριμένου αλγορίθμου είναι ανάλογος του αριθμού των κόμβων επί τον αριθμό των ακμλων, και ο οποίος μπορεί φυσικά να βελτιωθεί αν καταγράφουμε τη λύση των υποπροβλημάτων.

Ένα τελευταίο πρόβλημα το οποίο θα κοιτάξουμε είναι πως να χωρίσουμε ένα κείμενο σε γραμμές έτσι ώστε το μήκος των κενών διαστημάτων να είναι ομοιόμορφο, ούτε πολύ μικρά ούτε πολύ μεγάλα διαστήματα μεταξύ λέξεων. Εδώ θα παραλείψουμε το γεγονός ότι μεγάλα κενά μεταξύ λέξεων μπορούν να μειωθούν αν επιτραπεί να συλλαβίσουμε λέξεις. Δεν είναι δύσκολο να πειστούμε ότι ο προφανής αλγόριθμος (που χρησιμοποιείται στον κειμενογράφο Word) δηλαδή να βάλουμε όσο περισσότερες λέξεις στην τρέχουσα γραμμή προτού προχωρήσουμε στην επόμενη, μπορεί να διαμορφώσει πολύ άσχημες γραμμές κειμένου. Μια εναλλακτική τακτική είναι η ακόλουθη:

Ας υποθέσουμε ότι όλες οι λέξεις του κειμένου περιέχονται στη λίστα word και είναι $n$ στο πλήθος. Ορίζουμε την ποσότητα badness(i,j), την ακαταλληλότητα ή "ασχήμια" μιας γραμμής κειμένου αποτελούμενη από τις λέξεις word[i:j] να είναι $\infty$ αν το συνολικό μήκος των λέξεων υπερβαίνει το μέγιστο επιτρεπτό πλάτος της γραμμής, διαφορετικά $(\text{πλάτος σελίδας } - \text{ πλάτος γραμμής κειμένου})^3$. Η δύναμη 3 δεν έχει άλλη σημασία εκτός να αποθαρρύνει το σχηματισμό "άσχημων" γραμμών κειμένου. Προφανώς θα θέλαμε να ελαχιστοποιήσουμε το άθροισμα των badness(i,j) γιά κάθε γραμμή κειμένου. Αν έχουμε τοποθετήσει $i$ λέξεις απομένει να τοποθετήσουμε $n-i$ λέξεις. Αν θέσουμε DP[i] τον καλύτερο τρόπο να τοποθετήσουμε τις λέξεις words[i:], τότε αυτή η ποσότητα ισούται με το ελάχιστο των DP[j] σύν badness(i,j) για $i+1 \le j \le n$. Φυσικά, DP[n] = 0 μια και το κόστος στοιχειοθέτησης μηδενικού αριθμού λέξεων είναι μηδέν.

Άλλα ενδιαφέροντα προβλήματα τα οποία μπορούν να λυθούν με την τεχνική του δυναμικού προγραμματισμού είναι τα ακόλουθα: