9/10
</> Algoritmi & Structuri de Date

Backtracking și Algoritmi Greedy

Lecția 9 ⏱ 90 min

Backtracking și Algoritmi Greedy

Backtracking

Backtracking explorează sistematic toate soluțiile posibile, renunțând la o ramură imediat ce detectează că nu poate duce la o soluție validă. Este o formă de DFS cu pruning (tăiere).

Scheletul general

def backtrack(candidat, solutii):
    if este_solutie(candidat):
        solutii.append(candidat[:])  # copie!
        return

    for optiune in genereaza_optiuni(candidat):
        if este_valid(candidat, optiune):   # pruning
            candidat.append(optiune)        # alege
            backtrack(candidat, solutii)    # explorează
            candidat.pop()                  # revino (undo)

Cele trei momente cheie: alege, explorează, revino. Revenirea (backtrack-ul) este cea care dă numele tehnicii.

Problema 1: Generarea Permutărilor

def permutations(nums):
    result = []

    def backtrack(path, remaining):
        if not remaining:
            result.append(path[:])
            return

        for i in range(len(remaining)):
            path.append(remaining[i])
            backtrack(path, remaining[:i] + remaining[i+1:])
            path.pop()

    backtrack([], nums)
    return result

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

Complexitate: $O(n! \cdot n)$ — generăm $n!$ permutări, fiecare de lungime $n$.

Problema 2: N-Queens

Plasăm $n$ regine pe o tablă $n \times n$ astfel încât nicio regină să nu atace altă regină (nici pe linie, coloană, sau diagonală).

def solve_n_queens(n):
    result = []
    board = [-1] * n  # board[row] = coloana reginei de pe rândul row

    def is_safe(row, col):
        for prev_row in range(row):
            prev_col = board[prev_row]
            # Verifică coloana și diagonalele
            if prev_col == col:
                return False
            if abs(prev_row - row) == abs(prev_col - col):
                return False
        return True

    def backtrack(row):
        if row == n:
            # Construim vizualizarea tablei
            solution = []
            for r in range(n):
                line = '.' * board[r] + 'Q' + '.' * (n - board[r] - 1)
                solution.append(line)
            result.append(solution)
            return

        for col in range(n):
            if is_safe(row, col):
                board[row] = col
                backtrack(row + 1)
                board[row] = -1

    backtrack(0)
    return result

# 4-Queens: 2 soluții
for sol in solve_n_queens(4):
    for row in sol:
        print(row)
    print()

Optimizare: putem folosi seturi pentru coloane și diagonale, reducând verificarea is_safe la $O(1)$:

def solve_n_queens_fast(n):
    result = []
    cols = set()
    diag1 = set()  # row - col
    diag2 = set()  # row + col

    def backtrack(row, board):
        if row == n:
            result.append(['.' * c + 'Q' + '.' * (n-c-1) for c in board])
            return

        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue

            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            board.append(col)

            backtrack(row + 1, board)

            board.pop()
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)

    backtrack(0, [])
    return result

Problema 3: Generarea Submulțimilor

def subsets(nums):
    result = []

    def backtrack(start, path):
        result.append(path[:])  # fiecare stare parțială e o submulțime validă

        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result

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

Algoritmi Greedy

Un algoritm greedy face la fiecare pas alegerea locală optimă, sperând că duce la un optim global. Spre deosebire de DP, greedy nu revine niciodată asupra deciziilor.

Când funcționează Greedy?

Greedy funcționează corect doar când problema are:
1. Proprietatea alegerii greedy — o alegere locală optimă face parte din soluția globală optimă
2. Substructură optimală — la fel ca la DP

Nu funcționează pe toate problemele. Exemplu clasic: Knapsack 0/1 nu se rezolvă greedy, dar Knapsack fracționar da.

Problema 1: Planificarea Activităților (Activity Selection)

Dați $n$ activități cu intervale $[start_i, finish_i)$. Selectați numărul maxim de activități care nu se suprapun.

def activity_selection(activities):
    """
    Greedy: sortăm după timp de terminare,
    alegem mereu activitatea care se termină cel mai devreme.
    """
    # Sortare după finish time
    sorted_act = sorted(activities, key=lambda x: x[1])

    selected = [sorted_act[0]]
    last_finish = sorted_act[0][1]

    for start, finish in sorted_act[1:]:
        if start >= last_finish:
            selected.append((start, finish))
            last_finish = finish

    return selected

activities = [(1, 3), (2, 5), (3, 4), (5, 7), (6, 8), (8, 9)]
print(activity_selection(activities))
# [(1, 3), (3, 4), (5, 7), (8, 9)] — 4 activități

De ce funcționează? Activitatea care se termină cel mai devreme lasă cel mai mult spațiu pentru activitățile următoare. Se poate demonstra prin exchange argument că orice soluție optimă poate fi transformată într-una care include prima activitate care se termină.

Problema 2: Knapsack Fracționar

Spre deosebire de 0/1, aici putem lua fracțiuni din obiecte.

def fractional_knapsack(weights, values, W):
    """
    Greedy: sortăm obiectele după valoare/greutate (descrescător)
    și luăm cât putem din fiecare.
    """
    n = len(weights)
    # (valoare per unitate, greutate, valoare)
    items = sorted(
        zip(values, weights),
        key=lambda x: x[0] / x[1],
        reverse=True
    )

    total_value = 0
    remaining = W

    for value, weight in items:
        if weight <= remaining:
            # Luăm tot obiectul
            total_value += value
            remaining -= weight
        else:
            # Luăm o fracțiune
            fraction = remaining / weight
            total_value += value * fraction
            remaining = 0
            break

    return total_value

w = [10, 20, 30]
v = [60, 100, 120]
print(fractional_knapsack(w, v, 50))  # 240.0

Problema 3: Codificarea Huffman

Construim un cod prefix optim pentru compresie, bazat pe frecvența caracterelor.

import heapq

class HuffmanNode:
    def __init__(self, char, freq):
        self.char = char
        self.freq = freq
        self.left = None
        self.right = None

    def __lt__(self, other):
        return self.freq < other.freq

def huffman_encoding(text):
    # 1. Calculăm frecvențele
    freq = {}
    for ch in text:
        freq[ch] = freq.get(ch, 0) + 1

    # 2. Construim arborele Huffman (greedy cu min-heap)
    heap = [HuffmanNode(ch, f) for ch, f in freq.items()]
    heapq.heapify(heap)

    while len(heap) > 1:
        left = heapq.heappop(heap)
        right = heapq.heappop(heap)
        merged = HuffmanNode(None, left.freq + right.freq)
        merged.left = left
        merged.right = right
        heapq.heappush(heap, merged)

    root = heap[0]

    # 3. Generăm codurile
    codes = {}
    def build_codes(node, code=""):
        if node.char is not None:
            codes[node.char] = code or "0"
            return
        build_codes(node.left, code + "0")
        build_codes(node.right, code + "1")

    build_codes(root)
    return codes

text = "abracadabra"
codes = huffman_encoding(text)
for ch, code in sorted(codes.items()):
    print(f"'{ch}': {code}")
# 'a': 0, 'b': 10, 'r': 110, 'c': 1110, 'd': 1111

De ce e greedy? La fiecare pas combinăm cele două noduri cu frecvența cea mai mică. Aceasta garantează că simbolurile rare au coduri lungi, iar cele frecvente au coduri scurte.

Backtracking vs Greedy vs DP

Aspect Backtracking Greedy DP
Explorare Toate soluțiile O singură cale Toate subproblemele
Revine? Da Nu Nu (stochează)
Optimalitate Garantată (exhaustiv) Doar cu demonstrație Garantată
Complexitate Exponențială Polinomială (de obicei) Polinomială (de obicei)
Când? Probleme combinatoriale Proprietatea greedy Subprobleme suprapuse

Exerciții

  1. Rezolvați Sudoku folosind backtracking cu pruning
  2. Demonstrați prin exchange argument că Activity Selection este optim
  3. Implementați un algoritm greedy pentru problema Minimum Spanning Tree (Kruskal sau Prim)
  4. Generați toate combinările de $k$ elemente din $n$ folosind backtracking
Pe această pagină