Considerazioni sull'efficienza: loop annidato vs ricorsione

2

Mi considererei un programmatore Python intermedio. Una delle mie recenti sfide è stata la creazione di un elenco di tutte le possibili soluzioni a un dato Countdown problema. Senza entrare troppo nel dettaglio, ho affrontato il problema attraverso:

  • prima genera un elenco di tutte le possibili disposizioni di Number-Operator usando RPN

  • e poi bruteforcing tutti i possibili numeri / operatori di permutazioni per tutte le possibili disposizioni, registrando i pattern che mi danno la risposta.

L'elenco completo del codice è più avanti.

Sono consapevole che questo è del tutto inefficiente e il mio programma ha una scala di 5-10 minuti da completare.

Mi sono imbattuto in un approccio alternativo qui , che utilizza ricorsione e generatori e termina in modo considerevolmente più veloce - nella scala di 30 secondi. Il mio livello di comprensione di Python non mi consente di leggere semplicemente il codice che ho trovato e di comprendere appieno le sfumature.

Capisco che crea ricorsivamente espressioni ramificate con tutte le possibili permutazioni e le valuta fino a quando non viene raggiunto il risultato corretto, che è essenzialmente un'altra espressione di ciò che sto facendo. Io non capisco perché quel codice è in ordine di grandezza più veloce del mio.

Per quanto riguarda le operazioni, il codice più veloce fa la scala di 5 milioni di tentativi, il mio fa 15 milioni di tentativi, ma ciò non corrisponde ancora alla differenza di tempo di esecuzione.

La mia domanda: sarei molto grato per un puntatore su cosa esattamente sull'approccio di classe / ricorsione lo rende molto più efficiente del mio approccio piuttosto ingenuo a fondamentalmente lo stesso metodo.

Dopo aver armeggiato con lo spegnimento di vari moduli nel ciclo annidato, penso di averlo ristretto. Penso, piuttosto deludente, che la parte più lenta sia il modo in cui valuto per le espressioni RPN.

Che cosa ho fatto:

  • Sostituita la riga result = RPN_eval(...) con result = [0] . Questo completa il programma in meno di 9 secondi.

  • Ho quindi ripristinato la linea per chiamare la funzione RPN_eval (...). Invece, mi sono liberato della generazione della stringa attempt e l'ho sostituita con una 2 2 + fissa - questa versione è terminata in meno di 69 secondi ...

  • Infine, il fixing di attempt su 2 2 + 2 + ha aumentato il tempo di esecuzione a 120 secondi.

Estrapolando (approssimativamente) questo risultato secondo il quale ogni numero aggiuntivo e l'operatore nell'espressione aumentano il tempo del programma di un fattore di circa 1,7 - Ricevo un tempo di esecuzione totale di 10-11 minuti, che è ciò che il mio programma mostra in condizioni normali.

La mia nuova domanda: Pertanto, qual è la parte della funzione RPN_eval che sembra essere così scomoda e lenta? Farà più ricerche e formalizzerà questa in una domanda separata, non pertinente qui come tale

Penso di essere su qualcosa - sto provando a convertire dinamicamente le espressioni del pattern RPN in una (orrenda) funzione lambda, che posso quindi alimentare le singole permutazioni numeriche e ottenere risultati, senza dover rifare la funzione lambda fino al prossimo entra in gioco. Aggiungerà il codice qui una volta che coopererà ...

Elenco dei miei codici:

import itertools as it
import random
import time
operators = ["+", "-", "/", "*"]
count = 0

def RPN_eval(expression, answer): #a standard stack approach to evaluating RPN expressions
    explist = expression.split(" ")
    explist.pop(-1)
    stack = []

    for char in explist:

        if not char in operators:
            stack.append(int(char))
        else:
            if char == "+":
                num1 = stack.pop()
                num2 = stack.pop()

                if num1 > num2:
                    return[-1]

                result = num1 + num2
                stack.append(result)

            if char == "-":
                num1 = stack.pop()
                num2 = stack.pop()
                result = -num1 + num2
                stack.append(result)

            if char == "*":
                num1 = stack.pop()
                num2 = stack.pop()

                if num1 > num2:
                    return [-1]

                result = num1 * num2
                stack.append(result)

            if char == "/":
                divisor = stack.pop()
                divident = stack.pop()

                try:
                    result = divident / divisor
                except:
                    return [-1]

                stack.append(result)

            if result<=0 or result != int(result):
                return [-1]

    return stack

################### This part runs once and generates 37 possible RPN patterns for 6 numbers and 5 operators
def generate_patterns(number_of_numbers): 
#generates RPN patterns in the form NNoNNoo where N is number and o is operator

    patterns = ["N "]

    for pattern1 in patterns:
        for pattern2 in patterns:
            new_pattern = pattern1 + pattern2 + "o "
            if new_pattern.count("N")<=number_of_numbers and new_pattern not in patterns:
                patterns.append(new_pattern)

    return patterns
#######################################


######### Slowest part of program ################
def calculate_solutions(numbers, answer):
    global count
    patterns = generate_patterns(len(numbers)) #RPN symbolic patterns for a given number pool, runs once, takes less than 1 second
    random.shuffle(patterns) #not necessary, but yields answers to look at faster on average
    print(patterns)
    solutions = [] #this list will store answer strings of good solutions. This particular input produces 56 answers.

    for pattern in patterns:
        nn = pattern.count("N") #counts the number of numbers in a symbolic pattern to produce corresponding number group permutations
        no = pattern.count("o") #same for operators
        numpermut = it.permutations(numbers,nn) #all possible permutations of input numbers, is an itertools.permutations object, not a list. Takes 0 seconds to define.

        print(pattern)

        for np in numpermut:
            oppermut = it.product(["+","-","*","/"],repeat=no) #all possible permutations of operator order for a given pattern, itertools object, not a list. Takes 0 seconds to define
            for op in oppermut:
                attempt = ""
                ni = 0
                oi = 0
                for sym in pattern:
                    if "N" in sym:
                        attempt+=str(np[ni])+" " #replace Ns in pattern with corresponding numbers from permutations
                        ni+=1
                    if "o" in sym:
                        attempt+=str(op[oi])+" " #replace os in pattern with corresponding operators from permutations
                        oi+=1

                count+=1
                result = RPN_eval(attempt, answer) #evaluate attempt

                if result[0] == answer:
                    solutions.append(attempt) #if correct, append to list

                    print(solutions)
    return solutions
#####################################    




solns = calculate_solutions([50 , 8 , 3 , 7 , 2 , 10],556)
print(len(solns), count)

E l'elenco dei codici più veloce:

class InvalidExpressionError(ValueError):
    pass

subtract = lambda x,y: x-y
def add(x,y):
    if x<=y: return x+y
    raise InvalidExpressionError
def multiply(x,y):
    if x<=y or x==1 or y==1: return x*y
    raise InvalidExpressionError
def divide(x,y):
    if not y or x%y or y==1:
        raise InvalidExpressionError
    return x/y

count = 0
add.display_string = '+'
multiply.display_string = '*'
subtract.display_string = '-'
divide.display_string = '/'

standard_operators = [ add, subtract, multiply, divide ]

class Expression(object): pass

class TerminalExpression(Expression):
    def __init__(self,value,remaining_sources):
        self.value = value
        self.remaining_sources = remaining_sources
    def __str__(self):
        return str(self.value)
    def __repr__(self):
        return str(self.value)

class BranchedExpression(Expression):
    def __init__(self,operator,lhs,rhs,remaining_sources):
        self.operator = operator
        self.lhs = lhs
        self.rhs = rhs
        self.value = operator(lhs.value,rhs.value)
        self.remaining_sources = remaining_sources
    def __str__(self):
        return '('+str(self.lhs)+self.operator.display_string+str(self.rhs)+')'
    def __repr__(self):
        return self.__str__()

def ValidExpressions(sources,operators=standard_operators,minimal_remaining_sources=0):
    global count
    for value, i in zip(sources,range(len(sources))):
        yield TerminalExpression(value=value, remaining_sources=sources[:i]+sources[i+1:])
    if len(sources)>=2+minimal_remaining_sources:
        for lhs in ValidExpressions(sources,operators,minimal_remaining_sources+1):
            for rhs in ValidExpressions(lhs.remaining_sources, operators, minimal_remaining_sources):
                for f in operators:
                    try:
                        count+=1
                        yield BranchedExpression(operator=f, lhs=lhs, rhs=rhs, remaining_sources=rhs.remaining_sources)
                    except InvalidExpressionError: pass

def TargetExpressions(target,sources,operators=standard_operators):
    for expression in ValidExpressions(sources,operators):
        if expression.value==target:
            yield expression

def FindFirstTarget(target,sources,operators=standard_operators):
    for expression in ValidExpressions(sources,operators):
        if expression.value==target:
            return expression
    raise (IndexError, "No matching expressions found")

if __name__=='__main__':
    import time
    start_time = time.time()
    target_expressions = list(TargetExpressions(556,[50,8,3,7,2,10]))
    #target_expressions.sort(lambda x,y:len(str(x))-len(str(y)))
    print ("Found",len(target_expressions),"solutions, minimal string length was:")
    print (target_expressions[0],'=',target_expressions[0].value)
    print()
    print ("Took",time.time()-start_time,"seconds.")
    print(target_expressions)
    print(count)
    
posta IliaK 16.01.2017 - 16:35
fonte

2 risposte

3

Passiamo a pochi passi dalla soluzione "veloce":

TargetExpression viene chiamato con il problema. Chiama ValidExpression che produce due iteratori. Il primo iteratore fornisce ogni TerminalExpression uno alla volta. Il ciclo in TargetExpression controlla ciascuno per vedere se è la risposta in tal caso, viene restituito (uno alla volta, tramite iteratore) al programma principale. Una volta fatto questo, produce un secondo iteratore che restituisce le espressioni candidate una alla volta permutando il possibile utilizzo di iteratori nidificati. Questi valori vengono nuovamente ricollegati dal ciclo in TargetExpression uno alla volta. Ciascuno degli iteratori nidificati restituirà anche solo un valore alla volta.

Una differenza qui è che penso che la versione 'veloce' che pota i calcoli. Cioè, se gli operandi sono fuori servizio (vale a dire prima non è inferiore al secondo) smette di guardare a qualsiasi altro risultato che inizia con quegli operandi. Ad esempio, quando inizia con 50 + 8 , la versione veloce esegue immediatamente il bail e controlla una coppia di partenza diversa. Se non sbaglio, la tua versione controllerà tutte le permutazioni che iniziano con 50 + 8 . Li ignorerà ma uno alla volta, mentre la versione "veloce" ignorerà l'intera parte dell'albero.

Dopo la modifica in cui le cose si restringono un po ', ecco alcuni pensieri sul metodo RPN_eval :

In primo luogo, le cose facili. Hai un insieme di istruzioni if che si escludono a vicenda. Stai andando a controllare tutti e quattro gli operatori su ciascun ciclo anche se solo uno di questi controlli sarà vero. Dovresti cambiarlo in catena, se così:

if not char in operators:
  #...
elif char == "+":
  #...
elif char == "-":
  #...
elif char == "*":
  #...
elif char == "/":

Non sono sicuro che tu abbia bisogno dell'ultima istruzione if, ma forse mi manca qualcosa. Dubito che questo farà un'enorme differenza, ma è più corretto trasmettere l'intenzione e sarà meno incline a errori di codifica.

L'unica cosa che questo codice sembra costoso è il continuo spingere e scoppiare e non è così costoso. La cosa migliore che posso immaginare è che stai controllando gli stessi operandi più delle altre versioni che aggiunge. Questo è per la stessa ragione sopra descritta per la potatura. Quando la versione 'veloce' controlla tutti gli alberi che iniziano con 8 + 50 , esegue questa operazione una volta. Il tuo approccio eseguirà 2 push e 2 pop per ogni albero candidato che inizia con 8 + 50 . Non ho tempo per fare quella matematica ma non è un numero piccolo. Dovresti provare a contare quante espressioni diverse calcoli e quante iniziano con la stessa radice. Probabilmente aprirà gli occhi.

    
risposta data 16.01.2017 - 17:16
fonte
0

Miglioramento del mio parser RPN. La soluzione in sé è ancora adattata in modo abbastanza ristretto al mio problema specifico, ma le abilità coinvolte sono, penso, molto Pythonic e ne valgono la pena. Quello che ho fatto alla fine:

  • Utilizzato ciascun modello di forma RPN, ad es. NNoNo e analizzato in una funzione lambda composita
  • La funzione composita comprende due elenchi: una permutazione di numeri da tentare e una permutazione di simboli.
  • I singoli elementi degli elenchi di argomenti per il lambda sono preallocati internamente sulla creazione di lambda, quindi ogni numero va nel posto giusto. Lo stesso vale per gli operatori
  • Questa funzione composita definita viene utilizzata per tutte le permutazioni per un dato motivo, dopo di che la funzione viene ridefinita per un nuovo motivo. Dato che ci sono solo 37 pattern, risparmio un po 'di tempo nell'analisi e nella lista delle manipolazioni.

I risultati sono ancora insoddisfacenti, ma continuerò a esaminarlo. Sulla mia macchina desktop l'approccio alla funzione dinamica dà tutti i risultati in 120 secondi contro 155 secondi per l'approccio precedente.

Solo le funzioni aggiunte sono elencate di seguito:

import operator

def safe_division(num1,num2):
    try:
        r = num1/num2
        if int(r) == r:
            return r
        else:
            raise ValueError
    except ZeroDivisionError:
        raise ValueError

def ordered_add(num1,num2):
    if num1>num2:
        return num1+num2
    else:
        raise ValueError

def ordered_times(num1,num2):
    if num1>num2:
        return num1*num2
    else:
        raise ValueError

def positive_subtract(num1,num2):
    if num1>=num2:
        return num1-num2
    else:
        raise ValueError

ARITHMETIC_OPERATORS = {
    '+':  ordered_add, '-':  operator.sub,
    '*':  ordered_times, '/':  safe_division, '%':  operator.mod,
    '**': operator.pow, '//': operator.floordiv,
}



def RPN_to_opexpression(pattern):
    val = 0 #keeping track of which element in the permutation list to pass to the lambda
    sym = 0 #same but for symbols
    stack = []

    for char in pattern:
        if char == "N":
            #if the character represents a Number, the "result" of this operation is a lambda that returns the number itself
            stack.append(lambda symlist,vallist, val=val : vallist[val]) 
            val+=1
        else:
            #if the character is an operator, the attached lambda is a composite of two previous stack contents composited over the operator
            rhs = stack.pop()
            lhs = stack.pop()
            stack.append(lambda symlist,vallist, sym=sym, lhs=lhs, rhs=rhs: ARITHMETIC_OPERATORS[symlist[sym]](lhs(symlist,vallist),rhs(symlist,vallist)))
            sym+=1
    superfunction = stack.pop()

    return superfunction

######################
#and in main body:
superfun = RPN_to_opexpression(pattern)
...
result = superfun(op,np)
    
risposta data 18.01.2017 - 01:25
fonte

Leggi altre domande sui tag