Αναδρομή και πάλι. Μέθοδος ταξινόμησης mergesort.

Αναδρομικές συναρτήσεις

Ξεκινήσαμε κάνοντας μια επανάληψη και βαθύτερη ανάλυση αναδρομικών συναρτήσεων που έχουμε ήδη δεί. Σε μια από τις ασκήσεις είχαμε γράψει τη συνάρτηση sublists(L,m) που φαίνεται παρακάτω, και η οποία, καλώντας αναδρομικά τον εαυτό της, υπολογίζει πόσες από τις υπολίστες της L έχουν άθροισμα στοιχείων ίσο με m.

Αφού την ξανασχολιάσμε και είδαμε ακριβώς τον τρόπο με τον οποίο τρέχει πάνω σε μια συγκεκριμένη μικρή λίστα, γράψαμε και την επόμενη συνάρτηση που βλέπετε παρακάτω, whichsublists(L,m) η οποία όχι μόνο μετράει τις παραπάνω υπολίστες της L (αυτές με άθροισμα στοιχείων m) αλλά τις βρίσκει. Επιστρέφει λοιπόν η συνάρτηση αυτή μια λίστα που περιέχει όλες τις υπολίστες αυτές. Είναι κι αυτή η συνάρτηση αναδρομική και δουλεύει σχεδόν ταυτόσημα με την sublists μόνο που αντί να προσθέτει αριθμούς λιστών συνενώνει λίστες λιστών.

Τέλος, δοκιμάζουμε τη συνάρτηση αυτή πάνω σε μια συγκεκριμένη λίστα.

In [5]:
def sublists(L, m):
    """Επιστρέφει το πόσες υπολίστες της L έχουν άθροισμα στοιχείων m. Υποθέτουμε ότι όλα τα στοιχεία της L είναι
    διαφορετικά"""
    if len(L)==0: # Αν η λίστα L είναι κενή τότε μόνο στην περίπτωση που το m είναι 0 υπάρχει υπολίστα με άθροισμα στοιχείων m
        if m==0:  #  και μάλιστα μόνο μια υπολίστα, η κενή. Αν m δεν είναι 0 και η L είναι κενή τότε η απάντηση είναι 0.
            return 1
        else:
            return 0
    # Από τις υπολίστες με άθροισμα στοιχείων m άλλες περιέχουν το L[0] κι άλλες όχι.
    # Αυτές που δεν περιέχουν το L[0] είναι όλες οι υπολίστες της υπόλοιπης λίστας L[1:] που έχουν
    # άθροισμα m (δεύτερος όρος στο άθροισμα παρακάτω, που υπολογίζεται με αναδρομική κλήση).
    # Αυτές που περιέχουν το L[0] είναι όλες οι υπολίστες της L[1:] με άθροισμα m-L[0] (πρώτος
    # όρος στο παρακάτω άθροισμα που υπολογίζεται αναδρομικά).
    return sublists(L[1:], m-L[0]) + sublists(L[1:], m)



def whichsublists(L, m):
    """Επιστρέφει μια λίστα που περιέχει όλες τις υπολίστες της L έχουν άθροισμα στοιχείων m.
    Υποθέτουμε ότι όλα τα στοιχεία της L είναι διαφορετικά"""
    if len(L)==0: # Αν η λίστα L είναι κενή τότε μόνο στην περίπτωση που το m είναι 0 υπάρχει υπολίστα με άθροισμα στοιχείων m
        if m==0:  #  και μάλιστα μόνο μια υπολίστα, η κενή. Αν m δεν είναι 0 και η L είναι κενή τότε η απάντηση είναι [].
            return [[]] # μόνο μια υπολίστα της L υπάρχει που πληροί τα κριτήρια, η κενή
        else:
            return [] # καμία υπολίστα της L δεν πληροί τα κριτήρια
    # Από τις υπολίστες με άθροισμα στοιχείων m άλλες περιέχουν το L[0] κι άλλες όχι.
    # Αυτές που δεν περιέχουν το L[0] είναι όλες οι υπολίστες της υπόλοιπης λίστας L[1:] που έχουν
    # άθροισμα m (δεύτερος όρος στη συνένωση λιστών στο return παρακάτω, που υπολογίζεται με αναδρομική κλήση).
    # Αυτές που περιέχουν το L[0] είναι όλες οι υπολίστες της L[1:] με άθροισμα m-L[0]
    # (λίστα l2 που υπολογίζεται αναδρομικά).
    l1 = whichsublists(L[1:], m-L[0])
    l2 = []
    for x in l1:
        l2.append([L[0]]+x) # Τα στοιχεία της l2 είναι ακριβώς αυτά της l1 αλλά τους έχει προστεθεί και το L[0]
        
    return l2 + whichsublists(L[1:], m)

whichsublists([-1,0,1,2,3], 0)
Out[5]:
[[-1, 0, 1], [-1, 1], [0], []]

Η αναδρομική συνάρτηση για την ακολουθία Fibonacci

Έχουμε παλιότερα δει ότι για να υπολογίσουμε την ακολουθία Fibonacci \[ F_1 = F_2 = 1, \] και \[ F_n = F_{n-1}+F_{n-2} \] για \(n \ge 3\), είναι πολύ εύκολο να γράψουμε μια αναδρομική συνάρτηση, η οποία είναι σχεδόν κατά γράμμα μεταφορά του παραπάνω ορισμού.

In [1]:
def fib(n):
    if n == 1 or n == 2:
        x = 1
    else:
        x = fib(n-1) + fib(n-2)
    return x

for i in range(1,11):
    print "F({i}) = {value}".format(i=i, value=fib(i))
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
F(6) = 8
F(7) = 13
F(8) = 21
F(9) = 34
F(10) = 55

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

In [10]:
def fib(n):
    print "Computing fib({n})".format(n=n)
    if n == 1 or n == 2:
        x = 1
    else:
        x = fib(n-1) + fib(n-2)
    return x

x=fib(5)
print "F(5)=",x
Computing fib(5)
Computing fib(4)
Computing fib(3)
Computing fib(2)
Computing fib(1)
Computing fib(2)
Computing fib(3)
Computing fib(2)
Computing fib(1)
F(5)= 5

Αποθήκευση ενδιάμεσων αποτελεσμάτων αντί για επανυπολογισμό

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

Έτσι ορίζουμε μια καθολική μεταβλητή-λεξικό di, αρχικά κενή, στην οποία, μόλις υπολογίσουμε κάποια τιμή x = fib(n) προσθέτουμε την τιμή x με κλειδί το n. Πριν η συνάρτηση fib προσπαθήσει να υπολογίσει μια τιμή ελέγχει πρώτα αν υπάρχει ήδη αποθηκευμένη στο di.

Παρατηρείστε τώρα ότι καμιά τιμή της fib δεν υπολογίζεται δεύτερη φορά. Και, πέρα από την αρχικοποίση του λεξικού, δεν υπάρχει άλλη διαφορά στον τρόπο κλήσης της fib με πριν.

In [9]:
def fib(n):
    global di
    print "Computing fib({n})".format(n=n)
    if n == 1 or n == 2:
        x = 1
    else:
        y = di[n-1] if (n-1) in di else fib(n-1)
        z = di[n-2] if (n-2) in di else fib(n-2)
        x = y+z
    
    di[n] = x
    return x

di = {}
x = fib(10)
print "F(10)=",x
Computing fib(10)
Computing fib(9)
Computing fib(8)
Computing fib(7)
Computing fib(6)
Computing fib(5)
Computing fib(4)
Computing fib(3)
Computing fib(2)
Computing fib(1)
F(10)= 55

Το δυαδικό ψάξιμο γραμμένο ως αναδρομική συνάρτηση

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

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

In [16]:
import random
import time

def binsearch(x, L):
    """ Δυαδικό ψάξιμο του ακεραίου x στην ταξινομημένη σε αύξουσα σειρά λίστα ακεραίων L.
    Επιστρέφει μια θέση του x, ή -1 αν δεν υπάρχει. Χωρίς αναδρομή. """
    N = len(L)
    low = 0
    high = N-1
    while high > low+10:
        m = (low+high)/2
        y = L[m]
        if x == y:
            return m
        if x<y:
            high = m
        else:
            low = m
    for i in range(low, high+1):
        if L[i] == x:
            return i
    return -1

def recbinsearch(x, L):
    """ Δυαδικό ψάξιμο του ακεραίου x στην ταξινομημένη σε αύξουσα σειρά λίστα ακεραίων L.
    Επιστρέφει μια θέση του x, ή -1 αν δεν υπάρχει. Γραμμένο ως αναδρομική συνάρτηση. """
    N = len(L)
    
    if N<10: # Αν η λίστα είναι μικρή την ψάχνουμε σειριακά
        for i in range(N):
            if x == L[i]:
                return i
        return -1
    
    # Αλλιώς χωρίζουμε τη λίστα σε δύο μισά και, ανάλογα με το πώς συγκρίνεται το x με την τιμή
    # στο μέσο της λίστας, ψάχνουμε το αριστερό ή το δεξί μισό καλώντας αναδρομικά την ίδια συνάρτηση.
    m = N/2
    y = L[m]
    if y == x:
        return m
    if y>x:
        return recbinsearch(x, L[:m])
    return recbinsearch(x, L[m:])

# Φτιάχνουμε μια τυχαία λίστα ακεραίων και την ταξινομούμε κατά αύξουσα σειρά
l = []
N = 100000
for i in range(N):
    l.append(random.randint(1, 100*N))
l.sort()

x = 100*N+1 # Αυτή η τιμή σίγουρα δεν είναι μέσα στη λίστα l
t1 = time.time()
binsearch(x, l)
t2 = time.time()
T1 = t2-t1
print "Non-recursive binary search took",T1,"sec"

t1 = time.time()
recbinsearch(x, l)
t2 = time.time()
T2 = t2-t1
print "Recursive binary search took",T2,"sec"

print "Recursive is",T2/T1,"times slower than non-recursive"
Non-recursive binary search took 0.000162124633789 sec
Recursive binary search took 0.012717962265 sec
Recursive is 78.4455882353 times slower than non-recursive

Η διαδικασία ταξινόμησης mergesort

Μέχρι στιγμής έχουμε δει τη διαδικασία ταξινόμησης bubble sort (δείτε στο τέλος της διάλεξης εδώ).

Αυτός ο αλγόριθμος ταξινόμησης είναι αργός: παίρνει χρόνο της τάξης του \(N^2\) για να ταξινομήσει μια λίστα μήκους \(N\), αφού συγκρίνει σχεδόν κάθε θέση με κάθε άλλη θέση της λίστας.

Εδώ θα δούμε μια διαδικασία ταξινόμησης πολύ πιο γρήγορη από την bubble sort (και δεν είναι η μόνη), τη μέθοδο mergesort.

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

In [22]:
def mergesort(L):
    """Ταξινομεί την L σε αύξουσα σειρά"""
    print "mergesort({L})".format(L=L) # Τυπώνουμε ποια λίστα ταξινομούμε
    
    N = len(L) # μήκος της λίστας
    m = N/2 # μέσο της λίστας
    
    # Αν η λίστα έχει μήκος 0, 1, 2 το κάνουμε με απλό τρόπο
    if N == 0:
        return L
    if N == 1:
        return L
    if N == 2:
        return [min(L), max(L)]
    
    # Αλλιώς ταξινομούμε το αριστερό και το δεξί μισό, αναδρομικά καλώντας τον εαυτό μας
    l = mergesort(L[:m])
    r = mergesort(L[m:])
    
    ll = [] # αυτή θα είναι η τελική λίστα, αρχικά κενή, που τη γεμίζουμε από τα δεξιά
    hl = 0 # πρώτο στοιχείο της αριστερής λίστας που δεν έχουμε χρησιμοποιήσει ακόμη
    hr = 0 # πρώτο στοιχείο της δεξιάς λίστας που δεν έχουμε χρησιμοποιήσει ακόμη
    
    for i in range(N):
        if hl >= len(l): # Αν έχει τελειώσει η αριστερή λίστα πάρε το στοιχείο από τη δεξιά
            z = r[hr]
            hr += 1
            ll.append(z)
            continue
        if hr >= len(r): # Αν έχει τελειώσει η δεξιά λίστα πάρε το στοιχείο από την αριστερή
            z = l[hl]
            hl += 1
            ll.append(z)
            continue
        if l[hl] < r[hr]: # Αν το πρώτο στοιχείο της αριστερής λίστας είναι το μικρότερο τότε πάρε αυτό
            z = l[hl]
            hl += 1
        else: # Αν το πρώτο στοιχείο της δεξιάς λίστας είναι το μικρότερο τότε πάρε αυτό
            z = r[hr]
            hr += 1
        ll.append(z)
    return ll

# Δοκιμάζουμε τη συνάρτηση εδώ
# Έχουμε βάλει τη συνάρτηση να τυπώνει το πότε ξεκινά να ταξινομεί μια λίστα ώστε να φαίνονται οι αναδρομικές κλήσεις
L = [1, 2, 4, 3, 10, 6, 5, 1, 2, 0, 11]
mergesort(L)
        
            
mergesort([1, 2, 4, 3, 10, 6, 5, 1, 2, 0, 11])
mergesort([1, 2, 4, 3, 10])
mergesort([1, 2])
mergesort([4, 3, 10])
mergesort([4])
mergesort([3, 10])
mergesort([6, 5, 1, 2, 0, 11])
mergesort([6, 5, 1])
mergesort([6])
mergesort([5, 1])
mergesort([2, 0, 11])
mergesort([2])
mergesort([0, 11])

Out[22]:
[0, 1, 1, 2, 2, 3, 4, 5, 6, 10, 11]

Η χρονική πολυπλοκότητα της mergesort

Πόσο χρόνο παίρνει να τρέξει η mergesort; Είναι όντως καλύτερη (γρηγορότερη) από την bubble sort;

Ας ονομάσουμε \(T(N)\) το μέγιστο χρόνο που παίρνει να τρέξει η mergesort για μια λίστα μήκους \(N\).

Αν κανείς εξετάσει τη μέθοδο θα δει ότι για να γίνει η ταξινόμηση μιας λίστας μεγέθους \(N\) (ας υποθέσουμε από δω και πέρα ότι το \(N\) είναι μια δύναμη του 2, \(N=2^k\), ενδεχομένως μεγαλώνοντας το \(N\) το πολύ κατά ένα παράγοντα του 2) κάνουμε τα εξής:

  1. Ταξινομούμε δύο υπολίστες μεγέθους \(N\) η κάθε μία.

  2. Μετακινούμε δεδομένα και κάνουμε διάφορες πράξεις ώστε να φτιάξουμε την ταξινομημένη λίστα μας από τις δύο υπολίστες που ταξινομήσαμε στο βήμα 1.

Από τον ορισμό της \(T(N)\) το βήμα 1. παραπάνω μας κοστίζει χρόνο το πολύ \(2 T(N/2)\).

Παρατηρώντας το τι κάνει η mergsort μετά τις δύο αναδρομικές κλήσεις (αλλά και πριν από αυτές) βλέπουμε ότι όλες οι υπόλοιπες πράξεις (που τις έχουμε ενσωματώσει στο βήμα 2.) κοστίζουν χρόνο ανάλογο του \(N\). Υπάρχει δηλ. μια σταθερά \(K\) (την οποία δεν έχει κάποιο νόημα να υπολογίσουμε ακριβώς γιατί εξαρτάται και από τις λεπτομέρειες της υλοποίησης αλλά και από το μηχάνημα στο οποίο τρέχει το πρόγραμμα) τέτοια ώστε το χρονικό κόστος του βήματος 2. είναι το πολύ \(KN\).

Επειδή το άθροισμα των χρόνων των βημάτων 1. και 2. είναι το σύνολο παίρνουμε έτσι την ανισότητα \[ T(N) \le K N + 2 T(N/2). \] Αν εφαρμόσουμε την ανισότητα αυτή στο δεύτερο όρο του δεξιού μέλους παίρνουμε \[ T(N) \le KN + 2\left(K\frac{N}{2}+2T(N/4)\right) = 2KN + 4 T(N/4). \] Αν την εφαρμόσουμε ξανά παίρνουμε \[ T(N) \le 2KN + 4\left(K\frac{N}{4}+2T(N/8)\right) = 3KN + 8 T(N/8), \] και αν το επαναλάβουμε αυτό \(k\) φορές παίρνουμε \[ T(N) \le k K N + 2^k T(N/2^k). \] Όμως \(N=2^k\) και \(k = \log_2N\) και το παραπάνω γίνεται \[ T(N) \le (K \log_2N + T(1)) N, \] δηλ. ο χρόνος που παίρνει η mergesort είναι το πολύ της τάξης \(N \log_2 N\) χρόνος σημαντικά μικρότερος από το \(N^2\) που παίρνει η bubble sort.

In []: