0Pricing
Coding Interview Prep · Lezione

Schema del backtracking: scegliere, esplorare, annullare la scelta

Implementi lo scheletro del backtracking in tre passaggi, lo segua su un esempio semplice e individui dove inserire le condizioni di potatura.

Schema del backtracking: scegliere, esplorare, annullare la scelta è una lezione Coding Interview Prep gratuita su CoddyKit. Questa è la lezione 1 di 4. Puoi leggere la lezione completa qui gratuitamente — poi esercitati direttamente nel browser con un editor di codice integrato e un tutor IA disponibile 24/7. Fa parte del percorso di apprendimento Coding Interview Prep, e i tuoi progressi si sincronizzano tra il web e l'app CoddyKit. Il corso Coding Interview Prep include 4 lezioni in totale.

Che cos'è il backtracking?

Il backtracking è un metodo sistematico per trovare tutte (o alcune) le soluzioni, esplorando progressivamente ogni candidato e abbandonando (tramite la potatura) un ramo non appena si determina che non può produrre una soluzione valida. È l'algoritmo alla base della risoluzione del Sudoku, della generazione delle permutazioni e della ricerca di tutte le combinazioni valide. Lo si può considerare una ricerca in profondità su un albero decisionale.

# Mental model: backtracking explores a decision tree
# At each node you make a choice, go deeper, then undo it
#
# Tree for generating subsets of [1,2,3]:
#        []
#      /    \
#    [1]   []
#   / \    / \
# [1,2][1][2] []
# ...

# Every leaf is a potential solution
# Pruning cuts branches early based on constraints
print('Backtracking = DFS on decision tree with pruning')

Il modello in tre passaggi

Ogni funzione di backtracking segue tre passaggi: Scegli — selezioni il candidato successivo tra le opzioni disponibili. Esplora — ricorra con quella scelta, scendendo di un livello nell'albero decisionale. Annulla la scelta — annulli la scelta dopo il ritorno dalla ricorsione, per ripristinare lo stato in vista del candidato successivo. A seconda del contesto, questo modello è chiamato anche add/recurse/remove oppure mark/recurse/unmark.

def backtrack(current_state, choices, results):
    # Base case: is current_state a complete solution?
    if is_complete(current_state):
        results.append(list(current_state))  # record solution
        return
    
    for choice in choices:
        if is_valid(choice, current_state):    # pruning condition
            # 1. CHOOSE
            current_state.append(choice)
            # 2. EXPLORE
            backtrack(current_state, choices, results)
            # 3. UNCHOOSE (backtrack)
            current_state.pop()

# Placeholder functions — filled per problem
def is_complete(state): return True
def is_valid(choice, state): return True

Esempio più semplice: tutti i sottoinsiemi

Generi tutti i sottoinsiemi di [1, 2, 3]. A ogni indice, scelga se includere o escludere l'elemento. L'indice di partenza avanza dopo ogni chiamata, così non vengono rivisitati gli elementi precedenti. Non è necessario verificare alcun vincolo: ogni stato parziale è valido. Si ottengono 2ⁿ sottoinsiemi. Il passaggio di annullamento della scelta è path.pop() dopo la chiamata ricorsiva.

def subsets(nums):
    result = []
    def backtrack(start, path):
        result.append(list(path))  # every state is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])    # CHOOSE
            backtrack(i + 1, path)  # EXPLORE
            path.pop()              # UNCHOOSE
    backtrack(0, [])
    return result

print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

Individuare la condizione di potatura

Il punto di forza del backtracking rispetto alla forza bruta sta nella potatura: riconoscere tempestivamente che un percorso parziale non può portare a una soluzione valida. Nel problema della somma delle combinazioni (somma obiettivo con un limite), non appena la somma corrente supera l'obiettivo, ogni ramo più profondo può solo aumentare: si pota il ramo restituendo immediatamente. Per le N-regine, se una regina attacca quelle già posizionate, si salta quella colonna. La potatura trasforma alberi esponenziali in ricerche gestibili.

def combination_sum(candidates, target):
    result = []
    candidates.sort()  # sort enables early termination
    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(list(path))
            return
        for i in range(start, len(candidates)):
            c = candidates[i]
            if c > remaining: break   # PRUNE: sorted, so rest are bigger too
            path.append(c)            # CHOOSE
            backtrack(i, path, remaining - c)   # EXPLORE (reuse allowed)
            path.pop()                # UNCHOOSE
    backtrack(0, [], target)
    return result

print(combination_sum([2, 3, 6, 7], 7))  # [[2,2,3],[7]]

Il ripristino dello stato è fondamentale

Un errore comune nel backtracking consiste nel non ripristinare completamente lo stato prima dell'iterazione successiva. Se si usa una struttura dati mutabile (lista, insieme, griglia), ogni modifica apportata durante il passaggio Scegli deve essere annullata durante Annulla la scelta. Ad esempio, quando si modifica una griglia (come nel Sudoku o nella ricerca di parole), si imposti la cella come vuota dopo la chiamata ricorsiva. Dimenticarlo lascia lo stato corrotto per i rami fratelli.

# Bug: forgetting to unmark in word search
# Correct pattern for grid backtracking:
def word_search(board, word):
    m, n = len(board), len(board[0])
    def dfs(r, c, k):
        if k == len(word): return True
        if not (0<=r<m and 0<=c<n): return False
        if board[r][c] != word[k]: return False
        temp, board[r][c] = board[r][c], '#'  # CHOOSE (mark visited)
        found = any(dfs(r+dr, c+dc, k+1)
                    for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)])
        board[r][c] = temp  # UNCHOOSE (restore cell)
        return found
    return any(dfs(r, c, 0) for r in range(m) for c in range(n))

board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']]
print(word_search([row[:] for row in board], 'ABCCED'))  # True

Seguire l'albero decisionale

Per la somma delle combinazioni con [2, 3, 6, 7] e obiettivo 7, si segua l'albero: alla radice si prova 2. Da 2, si prova di nuovo 2 (remaining=3). Da 2+2, si prova ancora 2 (remaining=1). 2>1, quindi si pota. Si prova 3: 3>1, si pota. Si torna indietro. Da 2+2 si prova 3 (remaining=3). 3 corrisponde al valore rimanente: si registra [2,2,3]. Si torna indietro e si continua. Questa traccia mostra come la potatura elimini i rami prima che producano risultati non validi.

def combination_sum_trace(candidates, target):
    result = []
    candidates.sort()
    def backtrack(start, path, remaining, depth):
        indent = '  ' * depth
        print(f'{indent}explore({path}, remaining={remaining})')
        if remaining == 0:
            result.append(list(path))
            print(f'{indent}FOUND: {path}')
            return
        for i in range(start, len(candidates)):
            c = candidates[i]
            if c > remaining:
                print(f'{indent}PRUNE at {c}')
                break
            path.append(c)
            backtrack(i, path, remaining - c, depth + 1)
            path.pop()
    backtrack(0, [], target, 0)
    return result

combination_sum_trace([2, 3, 6, 7], 7)

Backtracking e forza bruta

La forza bruta prova tutte le possibili soluzioni complete e poi verifica ciascuna di esse. Il backtracking pota durante la costruzione, senza mai completare i percorsi non validi. Per N=8 nel problema delle N-regine, la forza bruta verifica 8^8 = 16 milioni di disposizioni. Il backtracking riduce il numero a circa 2.057 chiamate ricorsive. La differenza cresce rapidamente con N: per N=12, la forza bruta prova 8,9 miliardi di disposizioni, mentre il backtracking esplora solo una frazione dell'albero.

# Compare call counts: brute force vs backtracking for permutations
import sys
calls_brute = [0]
calls_back = [0]

def brute_force_perms(nums):
    from itertools import permutations
    return list(permutations(nums))

def backtrack_perms(nums):
    result = []
    used = [False] * len(nums)
    def bt(path):
        calls_back[0] += 1
        if len(path) == len(nums):
            result.append(list(path))
            return
        for i, n in enumerate(nums):
            if not used[i]:
                used[i] = True
                path.append(n)
                bt(path)
                path.pop()
                used[i] = False
    bt([])
    return result

backtrack_perms([1,2,3,4])
print(f'Backtrack calls for 4 items: {calls_back[0]}')

Raccolta dei risultati e restituzione anticipata

I problemi di backtracking rientrano in due categorie: enumerare tutte le soluzioni (raccogliere ogni percorso completo) oppure trovare una qualunque soluzione (restituire True non appena un percorso ha esito positivo). Per l'enumerazione, aggiunga sempre ogni risultato a un elenco. Per trovare una sola soluzione, restituisca immediatamente True dalla chiamata ricorsiva e propaghi il valore verso l'alto. Restituire any(backtrack(...)) oppure usare if backtrack(...): return True implementa il comportamento di short-circuit.

# Enumerate all: collect in results list
def all_solutions(candidates):
    results = []
    def bt(path, remaining):
        if remaining == 0:
            results.append(list(path))
            return
        for c in candidates:
            if c <= remaining:
                path.append(c); bt(path, remaining - c); path.pop()
    bt([], 5)
    return results

# Find any one: return True on first success
def any_solution(candidates, target):
    def bt(path, remaining):
        if remaining == 0: return True
        for c in candidates:
            if c <= remaining:
                path.append(c)
                if bt(path, remaining - c): return True  # short-circuit
                path.pop()
        return False
    path = []
    return bt(path, target), path

Memoizzazione con backtracking

Il backtracking puro esplora ogni percorso senza memorizzazione nella cache, il che va bene quando servono tutte le soluzioni. Tuttavia, alcuni problemi di backtracking presentano sotto-problemi sovrapposti. Ad esempio, Word Break II può essere risolto con backtracking + memoizzazione: si memorizza nella cache l'elenco delle frasi possibili a partire da ogni indice iniziale. In questo modo il backtracking esponenziale nel caso peggiore diventa un algoritmo in tempo polinomiale. Riconosca quando i sotto-problemi si ripetono per applicare questo approccio ibrido.

from functools import lru_cache

def word_break_all(s, wordDict):
    words = set(wordDict)
    
    @lru_cache(maxsize=None)
    def bt(start):
        if start == len(s): return ['']  # empty suffix
        result = []
        for end in range(start + 1, len(s) + 1):
            word = s[start:end]
            if word in words:
                for rest in bt(end):
                    result.append(word if not rest else word + ' ' + rest)
        return result
    
    return bt(0)

print(word_break_all('catsanddog', ['cat','cats','and','sand','dog']))
# ['cat sand dog', 'cats and dog']

Complessità temporale del backtracking

La complessità temporale del backtracking dipende dal numero di foglie dell'albero decisionale moltiplicato per il lavoro per nodo. Per i sottoinsiemi: O(n × 2ⁿ). Per le permutazioni: O(n × n!). Per la somma delle combinazioni: O(target/min_candidate ^ n) nel caso peggiore. La potatura riduce la costante, ma non l'ordine asintotico. Quando Le viene chiesta la complessità in un colloquio, indichi la dimensione dell'albero nel caso peggiore e specifichi che la potatura di solito rende l'algoritmo molto più veloce nella pratica.

# Complexity quick reference:
# Subsets of n elements:     O(n * 2^n)  - 2^n subsets, each copied in O(n)
# Permutations of n:          O(n * n!)   - n! perms, each copied in O(n)
# Combination sum (target T): O(T^n / n!) worst case without pruning
# N-Queens:                   O(n!)       - prune reduces practical count

# For n=10 permutations: 10! = 3,628,800 paths
import math
n = 10
print(f'n={n}: n!={math.factorial(n):,} paths')
print(f'n={n}: 2^n={2**n:,} subsets')

Individuare i problemi di backtracking

Segnali che indicano la necessità del backtracking: (1) trovare tutte o generare tutte le combinazioni, permutazioni o sottoinsiemi; (2) il problema richiede di posizionare elementi o persone sotto determinati vincoli (N-regine, Sudoku); (3) lo spazio delle soluzioni è esponenziale, ma i vincoli eliminano presto la maggior parte dei rami; (4) è necessario esplorare percorsi in un grafo o in una griglia che potrebbero rivisitare stati. Quando riconosce questi segnali, ricorra al modello scegli-esplora-annulla la scelta.

# Common backtracking problem types:
# 1. Subsets / Power set
# 2. Permutations (with/without duplicates)
# 3. Combinations (k from n, combination sum)
# 4. Grid path finding (word search, unique paths with visited tracking)
# 5. Constraint satisfaction (N-queens, Sudoku solver)
# 6. String partitioning (palindrome partition, word break all)

# Template reminder:
def backtrack(start, path):
    # base case: add to results or return True
    for choice in get_choices(start):
        if is_valid(choice, path):   # prune
            path.append(choice)      # choose
            backtrack(start+1, path) # explore
            path.pop()               # unchoose

def get_choices(start): return []
def is_valid(c, p): return True

Verifica rapida

Metta alla prova la Sua comprensione dei concetti di Data Structures & Algorithms — Coding Interview Prep trattati in questa lezione.

Riepilogo della lezione

In questa lezione ha imparato che: il modello del backtracking ha tre passaggi — scegli, esplora, annulla la scelta — che corrispondono rispettivamente all'aggiunta di una scelta, alla ricorsione e alla sua rimozione, le condizioni di potatura eliminano presto i rami e sono ciò che rende il backtracking pratico rispetto alla forza bruta e lo stato deve essere completamente ripristinato dopo ogni chiamata ricorsiva per evitare di corrompere i rami fratelli. Ora applicheremo il modello per generare tutti i sottoinsiemi e l'insieme delle parti.

Domande Frequenti

La lezione «Schema del backtracking: scegliere, esplorare, annullare la scelta» è gratuita?

Sì — il testo completo di «Schema del backtracking: scegliere, esplorare, annullare la scelta» è gratuito qui sul web. Per esercitarvi in modo interattivo (un editor di codice integrato e un tutor IA 24/7) e sbloccare il resto del corso Coding Interview Prep, passa a CoddyKit PRO. Il corso Coding Interview Prep include 4 lezioni in totale.

Cosa imparerò in «Schema del backtracking: scegliere, esplorare, annullare la scelta»?

Implementi lo scheletro del backtracking in tre passaggi, lo segua su un esempio semplice e individui dove inserire le condizioni di potatura. Eserciti Coding Interview Prep con codice pratico che esegui direttamente nel browser, e un tutor IA 24/7 risponde alle tue domande mentre lavori sulla lezione.

Ho bisogno di esperienza per iniziare Coding Interview Prep?

Non è richiesta alcuna esperienza precedente. Coding Interview Prep su CoddyKit è strutturato per principianti e studenti avanzati, quindi puoi iniziare da qui o dall'inizio e procedere al tuo ritmo. Questa è la lezione 1 di 4.

Quanto tempo richiede la lezione «Schema del backtracking: scegliere, esplorare, annullare la scelta»?

La maggior parte delle lezioni CoddyKit richiede circa 5–10 minuti. Ogni lezione è breve e interattiva, quindi fai progressi costanti e riprendi esattamente da dove hai lasciato su web e app.

Posso scrivere ed eseguire codice in questa lezione Coding Interview Prep?

Sì. Ogni lezione Coding Interview Prep include un editor di codice integrato, quindi scrivi ed esegui codice reale direttamente nel tuo browser e ricevi feedback istantaneo dall'IA — nessuna configurazione locale necessaria.

Tutte le lezioni di questo corso

  1. Schema del backtracking: scegliere, esplorare, annullare la scelta
  2. Sottoinsiemi e insieme delle parti
  3. Permutazioni e combinazioni
  4. N-regine e propagazione dei vincoli
← Torna a Coding Interview Prep