# ============================================================================
# REPONSE EX01 - Generateurs (yield)
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# yield = "retourne une valeur et MET EN PAUSE la fonction"
# - Memoire O(1) : un seul element en memoire a la fois
# - Lazy evaluation : ne calcule que ce qu'on demande
# - Peut representer des sequences INFINIES
# Montrer qu'on comprend la diff entre return (termine) et yield (pause)
# ============================================================================
def fibonacci(n: int):
"""
Genere les n premiers nombres de Fibonacci avec yield.
Complexite : O(n) temps, O(1) espace
>>> list(fibonacci(7))
[0, 1, 1, 2, 3, 5, 8]
>>> list(fibonacci(0))
[]
Comment ca marche :
gen = fibonacci(3)
next(gen) -> yield 0, puis PAUSE -> retourne 0
next(gen) -> reprend, yield 1, PAUSE -> retourne 1
next(gen) -> reprend, yield 1, PAUSE -> retourne 1
next(gen) -> reprend, boucle finie, StopIteration
"""
a, b = 0, 1
for _ in range(n):
yield a # retourne 'a' et met la fonction en PAUSE
a, b = b, a + b # reprend ici au prochain next()
# Pourquoi a, b = b, a+b marche :
# Python evalue le cote droit AVANT d'assigner
# Equivalent a : temp_a = a; a = b; b = temp_a + b
def fibonacci_infinite():
"""
Generateur INFINI - possible UNIQUEMENT avec yield.
Impossible avec une liste (memoire infinie).
>>> from itertools import islice
>>> list(islice(fibonacci_infinite(), 7))
[0, 1, 1, 2, 3, 5, 8]
"""
a, b = 0, 1
while True: # boucle infinie, OK car yield PAUSE
yield a
a, b = b, a + b
# --- PIEGES CLASSIQUES ---
# 1. yield n'est PAS une fonction : yield a (pas yield(a))
# 2. Un generateur ne peut etre itere qu'UNE fois :
# gen = fibonacci(5); list(gen) -> [0,1,1,2,3]; list(gen) -> []
# 3. Ne pas appeler au top-level : fibonacci(6) ne fait RIEN
# (il faut iterer dessus : list(fibonacci(6)) ou for x in fibonacci(6))
if __name__ == "__main__":
print(list(fibonacci(7))) # [0, 1, 1, 2, 3, 5, 8]
from itertools import islice
print(list(islice(fibonacci_infinite(), 10))) # les 10 premiers
# ============================================================================
# REPONSE EX04 - Context Manager
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# __enter__ = setup (retourne self pour le "as")
# __exit__ = cleanup (TOUJOURS execute, meme si exception)
# __exit__ retourne True -> supprime l'exception (avalee)
# __exit__ retourne False -> propage l'exception (crash normal)
#
# Equivalent a try/finally mais plus propre et reutilisable
# ============================================================================
import time
class Timer:
"""
Context manager pour mesurer le temps d'execution.
>>> with Timer() as t:
... time.sleep(0.01)
>>> t.elapsed > 0
True
>>> with Timer(suppress_exceptions=True) as t:
... raise ValueError("test")
>>> t.exception is not None
True
"""
def __init__(self, suppress_exceptions: bool = False):
self.suppress_exceptions = suppress_exceptions
self.elapsed: float = 0.0
self.exception: Exception | None = None
def __enter__(self):
self._start = time.time()
return self # le "as t" dans "with Timer() as t"
def __exit__(self, exc_type, exc_val, exc_tb):
# exc_type = type de l'exception (ou None)
# exc_val = l'instance de l'exception (ou None)
# exc_tb = le traceback (ou None)
self.elapsed = time.time() - self._start
if exc_val is not None:
self.exception = exc_val
return self.suppress_exceptions # True = avale l'exception
# --- PIEGES ---
# 1. __exit__ est appele meme si __enter__ leve une exception ?
# NON ! __exit__ est appele seulement si __enter__ a REUSSI
# 2. Oublier de retourner self dans __enter__ -> t sera None
# 3. Retourner True dans __exit__ avale TOUTES les exceptions, pas juste
# celle qu'on attend -> utiliser avec precaution
if __name__ == "__main__":
# Cas 1 : mesurer le temps
with Timer() as t:
time.sleep(0.1)
print(f"Elapsed: {t.elapsed:.2f}s") # ~0.10
# Cas 2 : capturer une exception
with Timer(suppress_exceptions=True) as t:
raise ValueError("boom")
print(f"Exception: {t.exception}") # ValueError("boom")
print(f"Elapsed: {t.elapsed:.4f}s")
# ============================================================================
# REPONSE EX05 - Closures et portee de variables
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Une closure "capture" les variables de la portee englobante
# nonlocal = modifier une variable de la portee englobante (pas globale)
# Sans nonlocal, on peut LIRE et MUTER (append), mais PAS REASSIGNER (+=)
# On peut attacher des fonctions comme attributs d'une autre fonction
# ============================================================================
def make_accumulator(initial: float = 0):
"""
Cree un accumulateur avec historique via closures.
>>> acc = make_accumulator(10)
>>> acc(5)
15
>>> acc(3)
18
>>> acc(-8)
10
>>> acc.history()
[10, 15, 18, 10]
>>> acc.reset()
>>> acc(1)
1
>>> acc.history()
[0, 1]
"""
total = initial
history_list = [initial]
def accumulator(val):
nonlocal total # NECESSAIRE pour reassigner total (total += val)
total += val
history_list.append(total) # append = MUTATION, pas besoin de nonlocal
return total
def history():
return list(history_list) # retourne une COPIE (copie defensive)
def reset():
nonlocal total
total = 0
history_list.clear() # mutation, pas besoin de nonlocal
history_list.append(0)
# Attacher comme attributs de la fonction
accumulator.history = history
accumulator.reset = reset
return accumulator
# --- PIEGES ---
# 1. Oublier nonlocal -> UnboundLocalError sur total += val
# Python voit += comme une assignation -> considere total comme local
# 2. history_list += [0] NECESSITE nonlocal (c'est une reassignation)
# history_list.append(0) n'en a PAS besoin (c'est une mutation)
# 3. Retourner history_list directement (sans copie) -> l'appelant peut
# le modifier et casser l'etat interne
if __name__ == "__main__":
acc = make_accumulator(10)
print(acc(5)) # 15
print(acc(3)) # 18
print(acc.history()) # [10, 15, 18]
acc.reset()
print(acc(1)) # 1
print(acc.history()) # [0, 1]
# ============================================================================
# REPONSE EX06 - Descripteurs Python
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Protocole descripteur = __get__, __set__, __set_name__
# Quand on fait obj.attr = val, Python appelle descripteur.__set__()
# C'est le mecanisme SOUS-JACENT de @property, @classmethod, etc.
#
# __set_name__(self, owner, name) : appele auto quand la classe est creee
# __get__(self, obj, objtype) : appele quand on lit l'attribut
# __set__(self, obj, value) : appele quand on ecrit l'attribut
# ============================================================================
import re
class ValidatedField:
"""
Descripteur de validation avec type, min/max, regex.
>>> class User:
... name = ValidatedField(type_=str, min_length=1, max_length=100)
... age = ValidatedField(type_=int, min_value=0, max_value=150)
>>> u = User()
>>> u.name = "Alice"
>>> u.name
'Alice'
>>> u.age = 25
>>> u.age
25
"""
def __init__(self, type_=None, min_value=None, max_value=None,
min_length=None, max_length=None, pattern=None):
self.type_ = type_
self.min_value = min_value
self.max_value = max_value
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern
def __set_name__(self, owner, name):
# Appele automatiquement quand la classe est creee
# owner = la classe (User), name = le nom de l'attribut ("name", "age")
self.attr_name = '_' + name # stockage prive sur l'instance
def __get__(self, obj, objtype=None):
if obj is None:
return self # acces via la CLASSE -> retourne le descripteur
return getattr(obj, self.attr_name, None) # acces via l'INSTANCE
def __set__(self, obj, value):
# Toutes les validations centralisees ici
if self.type_ and not isinstance(value, self.type_):
raise ValueError(f"Expected {self.type_.__name__}, got {type(value).__name__}")
if self.min_length is not None and len(value) < self.min_length:
raise ValueError(f"Too short (min {self.min_length})")
if self.max_length is not None and len(value) > self.max_length:
raise ValueError(f"Too long (max {self.max_length})")
if self.min_value is not None and value < self.min_value:
raise ValueError(f"Too small (min {self.min_value})")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"Too large (max {self.max_value})")
if self.pattern and not re.match(self.pattern, value):
raise ValueError(f"Pattern mismatch")
setattr(obj, self.attr_name, value) # stocke sur l'INSTANCE avec prefix _
# --- Exemple d'utilisation ---
class User:
name = ValidatedField(type_=str, min_length=1, max_length=100)
age = ValidatedField(type_=int, min_value=0, max_value=150)
email = ValidatedField(type_=str, pattern=r'^[\w.\-]+@[\w.\-]+\.\w+$')
# --- PIEGES ---
# 1. Oublier __set_name__ -> il faut alors passer le nom manuellement
# 2. Stocker la valeur sur le DESCRIPTEUR au lieu de l'INSTANCE
# -> toutes les instances partagent la meme valeur !
# Fix : setattr(obj, self.attr_name, value) stocke sur l'instance
# 3. __get__ sans "if obj is None" -> crash quand on fait User.name
if __name__ == "__main__":
u = User()
u.name = "Alice"
u.age = 25
u.email = "alice@example.com"
print(u.name, u.age, u.email)
# Tests de validation
for test in [
lambda: setattr(u, 'name', ''), # trop court
lambda: setattr(u, 'age', -1), # trop petit
lambda: setattr(u, 'email', 'invalid'), # pattern invalide
]:
try:
test()
print("ECHEC")
except ValueError as e:
print(f"OK: {e}")
# ============================================================================
# REPONSE EX07 - LRU Cache manuel avec OrderedDict
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# OrderedDict garde l'ordre d'insertion ET a move_to_end()
# - Hit cache : move_to_end(key) -> marque comme le plus recent
# - Cache plein : popitem(last=False) -> supprime le plus ancien (LRU)
#
# Pourquoi pas un dict normal ?
# dict garde l'ordre d'insertion mais n'a PAS move_to_end()
# ============================================================================
from collections import OrderedDict
from functools import wraps
def lru_cache(maxsize: int = 128):
"""
Decorateur LRU Cache, implementation manuelle.
>>> @lru_cache(maxsize=2)
... def add(a, b):
... return a + b
>>> add(1, 2) # miss
3
>>> add(1, 2) # hit
3
>>> add(3, 4) # miss
7
>>> add(5, 6) # miss, evince (1,2) car LRU
11
>>> add(1, 2) # miss (etait evince)
3
"""
def decorator(func):
cache = OrderedDict()
hits = 0
misses = 0
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal hits, misses
key = (args, tuple(sorted(kwargs.items())))
if key in cache:
hits += 1
cache.move_to_end(key) # -> le plus recent (fin)
return cache[key]
misses += 1
result = func(*args, **kwargs)
cache[key] = result
if len(cache) > maxsize:
cache.popitem(last=False) # supprime le PREMIER (le + ancien = LRU)
return result
def cache_info():
return {"hits": hits, "misses": misses,
"maxsize": maxsize, "currsize": len(cache)}
def cache_clear():
nonlocal hits, misses
cache.clear()
hits = misses = 0
wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return wrapper
return decorator
# --- PIEGES ---
# 1. popitem(last=True) -> supprime le DERNIER (le plus recent) = MAUVAIS
# popitem(last=False) -> supprime le PREMIER (le plus ancien) = BON (LRU)
# 2. Oublier move_to_end() sur un hit -> l'element ne sera pas marque recent
# et sera evince alors qu'il est encore utilise
# 3. Les kwargs ne sont pas ordonnes dans le tuple -> les trier pour la cle
if __name__ == "__main__":
@lru_cache(maxsize=2)
def add(a, b):
print(f" computing {a}+{b}")
return a + b
print(add(1, 2)) # miss -> computing
print(add(1, 2)) # hit -> pas de computing
print(add(3, 4)) # miss -> computing
print(add(5, 6)) # miss -> computing, evince (1,2)
print(add(1, 2)) # miss -> computing (etait evince)
print(add.cache_info())
# ============================================================================
# REPONSE EX08 - Metaclasse Singleton
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Une metaclasse controle la CREATION des classes
# type est la metaclasse par defaut (toute classe est instance de type)
# __call__ de la metaclasse est appele quand on fait MaClasse()
#
# Singleton = une seule instance par classe
# Utilite : connexions DB, loggers, configuration globale
# ============================================================================
class SingletonMeta(type):
"""
Metaclasse qui garantit une seule instance par classe.
>>> class Database(metaclass=SingletonMeta):
... def __init__(self, url):
... self.url = url
>>> db1 = Database("postgres://localhost")
>>> db2 = Database("postgres://other")
>>> db1 is db2
True
>>> db1.url # garde la valeur du PREMIER appel
'postgres://localhost'
"""
_instances = {}
def __call__(cls, *args, **kwargs):
# cls = la classe (ex: Database), PAS SingletonMeta
if cls not in cls._instances:
# super().__call__ = type.__call__ = cree l'instance normalement
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
# --- Comment ca marche ---
# 1. class Database(metaclass=SingletonMeta): ...
# -> Database est une INSTANCE de SingletonMeta (pas de type)
# 2. Database("url")
# -> Appelle SingletonMeta.__call__(Database, "url")
# -> Verifie si Database est dans _instances
# -> Si non: cree l'instance avec type.__call__ et la stocke
# -> Si oui: retourne l'instance existante
# --- PIEGES ---
# 1. __init__ n'est PAS rappele pour le 2eme appel
# db2 = Database("other") -> retourne db1, db2.url == "postgres://localhost"
# 2. Pas thread-safe ! Deux threads pourraient creer deux instances
# Fix : ajouter un threading.Lock dans __call__
# 3. Confondre __new__ (cree l'instance) et __call__ (appele quand on fait cls())
if __name__ == "__main__":
class Database(metaclass=SingletonMeta):
def __init__(self, url):
self.url = url
db1 = Database("postgres://localhost")
db2 = Database("postgres://other")
print(db1 is db2) # True
print(db1.url) # postgres://localhost
# ============================================================================
# REPONSE EX09 - Pipeline fonctionnel avec generateurs
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Chaque .filter()/.map() retourne un NOUVEAU Pipeline avec un generateur
# -> Rien n'est calcule tant qu'on n'appelle pas collect() ou reduce()
# -> C'est la LAZY EVALUATION (comme les streams Java, les iterators Rust)
#
# .collect() et .reduce() sont des operations TERMINALES
# .filter(), .map(), .take(), .skip() sont des operations INTERMEDIAIRES
# ============================================================================
class Pipeline:
"""
Pipeline de transformation lazy utilisant des generateurs.
>>> result = (Pipeline([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
... .filter(lambda x: x % 2 == 0)
... .map(lambda x: x ** 2)
... .filter(lambda x: x > 10)
... .collect())
>>> result
[16, 36, 64, 100]
>>> total = (Pipeline(range(1, 101))
... .map(lambda x: x * 2)
... .filter(lambda x: x % 3 == 0)
... .reduce(lambda acc, x: acc + x, 0))
>>> total
3468
"""
def __init__(self, data):
self._data = iter(data) # tout est converti en iterateur
# --- Operations intermediaires (LAZY) ---
# Chacune retourne un NOUVEAU Pipeline wrappant un generateur
def filter(self, predicate):
return Pipeline(item for item in self._data if predicate(item))
def map(self, transform):
return Pipeline(transform(item) for item in self._data)
def take(self, n: int):
"""Prend les n premiers elements."""
def _take():
for i, item in enumerate(self._data):
if i >= n:
break
yield item
return Pipeline(_take())
def skip(self, n: int):
"""Saute les n premiers elements."""
def _skip():
for i, item in enumerate(self._data):
if i >= n:
yield item
return Pipeline(_skip())
# --- Operations terminales (declenchent le calcul) ---
def collect(self) -> list:
"""Consomme le pipeline et retourne une liste."""
return list(self._data)
def reduce(self, func, initial):
"""Consomme le pipeline et reduit a une seule valeur."""
result = initial
for item in self._data:
result = func(result, item)
return result
# --- PIEGES ---
# 1. Un Pipeline ne peut etre consomme qu'UNE fois (comme un generateur)
# p = Pipeline([1,2,3]); p.collect() -> [1,2,3]; p.collect() -> []
# 2. Oublier iter() dans __init__ -> les generateurs chains ne marchent pas
# 3. Utiliser une liste au lieu d'un generateur dans filter/map
# -> perd le lazy (tout est calcule immediatement)
if __name__ == "__main__":
result = (Pipeline([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
.filter(lambda x: x % 2 == 0) # [2, 4, 6, 8, 10]
.map(lambda x: x ** 2) # [4, 16, 36, 64, 100]
.filter(lambda x: x > 10) # [16, 36, 64, 100]
.collect())
print(result) # [16, 36, 64, 100]
total = (Pipeline(range(1, 101))
.map(lambda x: x * 2)
.filter(lambda x: x % 3 == 0)
.reduce(lambda acc, x: acc + x, 0))
print(total) # 3468
# ============================================================================
# REPONSE EX10 - Decorateur de validation de type runtime
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Python est dynamiquement type : les annotations NE SONT PAS verifiees
# Ce decorateur ajoute la verification a l'execution
# inspect.signature -> lie les args aux noms de parametres
# get_type_hints -> lit les annotations de type
# ============================================================================
import inspect
from typing import get_type_hints
from functools import wraps
def type_check(func):
"""
Decorateur qui verifie les types a l'execution bases sur les annotations.
>>> @type_check
... def greet(name: str, times: int) -> str:
... return name * times
>>> greet("hi", 3)
'hihihi'
>>> @type_check
... def bad(x: int) -> str:
... return x
>>> try:
... bad(5)
... except TypeError:
... print("caught")
caught
"""
hints = get_type_hints(func) # {'name': str, 'times': int, 'return': str}
sig = inspect.signature(func) # pour mapper position -> nom de parametre
@wraps(func)
def wrapper(*args, **kwargs):
# Lier les arguments aux noms de parametres
bound = sig.bind(*args, **kwargs)
bound.apply_defaults() # remplir les valeurs par defaut
# Verifier chaque argument
for param_name, value in bound.arguments.items():
if param_name in hints:
expected = hints[param_name]
if not isinstance(value, expected):
raise TypeError(
f"Argument '{param_name}' must be {expected}, "
f"got {type(value)}")
# Appeler la fonction
result = func(*args, **kwargs)
# Verifier le type de retour
if 'return' in hints and not isinstance(result, hints['return']):
raise TypeError(
f"Return value must be {hints['return']}, got {type(result)}")
return result
return wrapper
# --- PIEGES ---
# 1. get_type_hints vs func.__annotations__ :
# get_type_hints resout les string annotations ("int" -> int)
# 2. sig.bind() peut lever TypeError si les args ne matchent pas la signature
# 3. Ne gere pas les types generiques (list[int], Optional[str], etc.)
# Pour ca il faudrait typing.get_origin() et typing.get_args()
# 4. N'oubliez pas apply_defaults() sinon les params avec defaut ne sont pas lies
if __name__ == "__main__":
@type_check
def greet(name: str, times: int) -> str:
return name * times
print(greet("hi", 3)) # hihihi
try:
greet(123, 3) # TypeError: name must be str
except TypeError as e:
print(f"Error: {e}")
@type_check
def bad_return(x: int) -> str:
return x # retourne int au lieu de str
try:
bad_return(5)
except TypeError as e:
print(f"Error: {e}")
# ============================================================================
# EXERCICE 12 - MOYEN : Collections avanc\u00e9es
# ============================================================================
# Impl\u00e9mentez une classe TextAnalyzer qui utilise les collections
# avanc\u00e9es de Python : Counter, defaultdict, deque, namedtuple.
from collections import Counter, defaultdict, deque, namedtuple
# namedtuple pour stocker les statistiques d'un mot
class TextAnalyzer:
"""
Analyseur de texte utilisant les collections avanc\u00e9es.
>>> analyzer = TextAnalyzer()
>>> analyzer.analyze("le chat le chien le chat")
>>> analyzer.most_common(2)
[WordStats(word='le', count=3, frequency=0.5), WordStats(word='chat', count=2, frequency=0.3333333333333333)]
>>> dict(analyzer.words_by_length())
{2: ['le', 'le', 'le'], 4: ['chat', 'chat'], 5: ['chien']}
>>> analyzer.sliding_window("a b c d e", 3)
[('a', 'b', 'c'), ('b', 'c', 'd'), ('c', 'd', 'e')]
"""
def __init__(self):
# TODO: Initialiser word_counts (Counter), length_groups (defaultdict(list)),
# recent_words (deque), total_words (int)
pass
def analyze(self, text: str):
"""
Analyse un texte : compte les mots, les groupe par longueur.
>>> a = TextAnalyzer()
>>> a.analyze("python est super python")
>>> a.word_counts["python"]
2
>>> a.total_words
4
"""
# TODO: Remplir word_counts avec Counter
# TODO: Remplir length_groups avec defaultdict
# TODO: Mettre \u00e0 jour total_words
pass
def most_common(self, n: int = 5):
"""
Retourne les N mots les plus fr\u00e9quents sous forme de WordStats.
>>> a = TextAnalyzer()
>>> a.analyze("a b a c a b")
>>> result = a.most_common(2)
>>> result[0].word
'a'
>>> result[0].count
3
>>> result[0].frequency
0.5
"""
# TODO: Utiliser self.word_counts.most_common(n)
# TODO: Retourner une liste de WordStats(word, count, frequency)
pass
def words_by_length(self):
"""
Retourne le defaultdict groupant les mots par longueur.
>>> a = TextAnalyzer()
>>> a.analyze("je suis ici")
>>> dict(a.words_by_length())
{2: ['je'], 4: ['suis'], 3: ['ici']}
"""
# TODO: Retourner self.length_groups
pass
def sliding_window(self, text: str, window_size: int):
"""
Fen\u00eatre glissante sur les mots du texte en utilisant deque.
>>> a = TextAnalyzer()
>>> a.sliding_window("a b c d", 2)
[('a', 'b'), ('b', 'c'), ('c', 'd')]
>>> a.sliding_window("a b c", 3)
[('a', 'b', 'c')]
>>> a.sliding_window("a b", 5)
[]
"""
# TODO: Utiliser deque(maxlen=window_size) pour construire les fen\u00eatres
# TODO: Retourner une liste de tuples
pass
# ============================================================================
# REPONSE EX12 - Collections avancees (Counter, defaultdict, deque, namedtuple)
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Le module collections fournit des conteneurs specialises qui etendent
# les types natifs (dict, list, tuple) :
# - Counter : comptage d'elements (sous-classe de dict)
# - defaultdict : dict avec valeur par defaut (evite KeyError)
# - deque : double-ended queue, O(1) aux deux extremites
# - namedtuple : tuple immutable avec champs nommes
#
# PIEGE : Counter.most_common() retourne des tuples (element, count),
# defaultdict cree la cle des le premier acces (effet de bord)
# ============================================================================
from collections import Counter, defaultdict, deque, namedtuple
# namedtuple : cree une classe immutable avec des champs nommes
# Equivalent leger a une dataclass(frozen=True) sans methodes
WordStats = namedtuple("WordStats", ["word", "count", "frequency"])
class TextAnalyzer:
"""
Analyseur de texte utilisant les collections avancees.
>>> analyzer = TextAnalyzer()
>>> analyzer.analyze("le chat le chien le chat")
>>> analyzer.most_common(2)
[WordStats(word='le', count=3, frequency=0.5), WordStats(word='chat', count=2, frequency=0.3333333333333333)]
>>> dict(analyzer.words_by_length())
{2: ['le', 'le', 'le'], 4: ['chat', 'chat'], 5: ['chien']}
>>> analyzer.sliding_window("a b c d e", 3)
[('a', 'b', 'c'), ('b', 'c', 'd'), ('c', 'd', 'e')]
"""
def __init__(self):
# Counter : sous-classe de dict specialisee pour le comptage
self.word_counts = Counter()
# defaultdict(list) : chaque cle inexistante est initialisee a []
self.length_groups = defaultdict(list)
# deque : liste doublement chainee, performante aux extremites
self.recent_words = deque()
self.total_words = 0
def analyze(self, text: str):
"""
Analyse un texte : compte les mots, les groupe par longueur.
>>> a = TextAnalyzer()
>>> a.analyze("python est super python")
>>> a.word_counts["python"]
2
>>> a.total_words
4
"""
words = text.lower().split()
# Counter peut etre mis a jour incrementalement avec update()
self.word_counts = Counter(words)
self.total_words = len(words)
# defaultdict evite le pattern "if key not in dict: dict[key] = []"
self.length_groups = defaultdict(list)
for word in words:
self.length_groups[len(word)].append(word)
# deque avec maxlen garde automatiquement les N derniers elements
self.recent_words = deque(words, maxlen=100)
def most_common(self, n: int = 5):
"""
Retourne les N mots les plus frequents sous forme de WordStats.
>>> a = TextAnalyzer()
>>> a.analyze("a b a c a b")
>>> result = a.most_common(2)
>>> result[0].word
'a'
>>> result[0].count
3
>>> result[0].frequency
0.5
"""
results = []
# Counter.most_common(n) retourne [(element, count), ...]
# trie par count decroissant, puis par ordre d'insertion
for word, count in self.word_counts.most_common(n):
freq = count / self.total_words if self.total_words > 0 else 0
# namedtuple : acces par nom (stats.word) ET par index (stats[0])
results.append(WordStats(word=word, count=count, frequency=freq))
return results
def words_by_length(self):
"""
Retourne le defaultdict groupant les mots par longueur.
>>> a = TextAnalyzer()
>>> a.analyze("je suis ici")
>>> dict(a.words_by_length())
{2: ['je'], 4: ['suis'], 3: ['ici']}
"""
return self.length_groups
def sliding_window(self, text: str, window_size: int):
"""
Fenetre glissante sur les mots du texte en utilisant deque.
>>> a = TextAnalyzer()
>>> a.sliding_window("a b c d", 2)
[('a', 'b'), ('b', 'c'), ('c', 'd')]
>>> a.sliding_window("a b c", 3)
[('a', 'b', 'c')]
>>> a.sliding_window("a b", 5)
[]
"""
words = text.split()
if len(words) < window_size:
return []
results = []
# deque(maxlen=N) : quand on append au-dela de N, l'element
# le plus ancien est automatiquement supprime (FIFO)
window = deque(maxlen=window_size)
for word in words:
window.append(word)
if len(window) == window_size:
results.append(tuple(window))
return results
# ============================================================================
# POINTS CLES INTERVIEW
# ============================================================================
# Counter :
# - Sous-classe de dict : counter["mot"] retourne 0 si absent (pas KeyError)
# - most_common(n) : les N plus frequents, O(n log n)
# - Operations ensemblistes : counter1 + counter2, counter1 - counter2
# - Counter("abracadabra") -> Counter({'a': 5, 'b': 2, 'r': 2, ...})
#
# defaultdict :
# - Evite le pattern if/else pour initialiser des valeurs
# - defaultdict(list), defaultdict(int), defaultdict(set)
# - ATTENTION : l'acces cree la cle -> effet de bord avec `in` vs `[]`
# d = defaultdict(list); "x" in d -> False, d["x"] -> [] ET cree la cle
#
# deque :
# - appendleft/popleft en O(1) vs O(n) pour list.insert(0, x)
# - maxlen : taille fixe, suppression auto du cote oppose
# - rotate(n) : rotation circulaire
# - Ideal pour : files d'attente, historiques, fenetres glissantes
#
# namedtuple :
# - Immutable (comme tuple), hashable, utilisable comme cle de dict
# - _replace() pour creer une copie modifiee
# - _asdict() pour convertir en dict
# - Alternative moderne : dataclass(frozen=True)
# ============================================================================
if __name__ == "__main__":
analyzer = TextAnalyzer()
texte = "le python est un langage le python est populaire le langage python"
analyzer.analyze(texte)
print("=== Most Common ===")
for stats in analyzer.most_common(3):
print(f" {stats.word}: {stats.count}x ({stats.frequency:.1%})")
print("\n=== Words by Length ===")
for length, words in sorted(analyzer.words_by_length().items()):
print(f" {length} lettres: {words}")
print("\n=== Sliding Window (taille 3) ===")
for window in analyzer.sliding_window(texte, 3):
print(f" {window}")
# Demo Counter
print("\n=== Counter Demo ===")
c = Counter("abracadabra")
print(f" Counter: {c}")
print(f" most_common(2): {c.most_common(2)}")
# Demo deque
print("\n=== Deque Demo ===")
d = deque([1, 2, 3], maxlen=5)
d.appendleft(0)
d.append(4)
d.append(5) # maxlen atteint, 0 est supprime
print(f" deque apres ajouts: {d}")
# Demo namedtuple
print("\n=== NamedTuple Demo ===")
ws = WordStats("python", 5, 0.25)
print(f" WordStats: {ws}")
print(f" Acces par nom: ws.word={ws.word}")
print(f" Acces par index: ws[0]={ws[0]}")
print(f" _asdict(): {ws._asdict()}")
Itertools Patterns
MOYEN
ex13_itertools_patterns.py
# ============================================================================
# EXERCICE 13 - MOYEN : Itertools Patterns
# ============================================================================
# Impl\u00e9mentez des fonctions utilitaires en utilisant le module itertools.
# Chaque fonction doit utiliser les outils d'itertools plut\u00f4t que des
# boucles manuelles.
import itertools
def flatten_itertools(nested):
"""
Aplatit une liste de listes en utilisant itertools.chain.
>>> list(flatten_itertools([[1, 2], [3, 4], [5]]))
[1, 2, 3, 4, 5]
>>> list(flatten_itertools([[], [1], [], [2, 3]]))
[1, 2, 3]
>>> list(flatten_itertools([]))
[]
"""
# TODO: Utiliser itertools.chain.from_iterable
pass
def group_consecutive(iterable):
"""
Groupe les \u00e9l\u00e9ments cons\u00e9cutifs \u00e9gaux avec itertools.groupby.
>>> group_consecutive([1, 1, 2, 3, 3, 3, 1])
[(1, [1, 1]), (2, [2]), (3, [3, 3, 3]), (1, [1])]
>>> group_consecutive("aaabbc")
[('a', ['a', 'a', 'a']), ('b', ['b', 'b']), ('c', ['c'])]
>>> group_consecutive([])
[]
"""
# TODO: Utiliser itertools.groupby
# TODO: Retourner une liste de (cl\u00e9, [elements])
pass
def sliding_window_iter(iterable, n):
"""
Fen\u00eatre glissante utilisant itertools.islice.
>>> list(sliding_window_iter([1, 2, 3, 4, 5], 3))
[(1, 2, 3), (2, 3, 4), (3, 4, 5)]
>>> list(sliding_window_iter("abcd", 2))
[('a', 'b'), ('b', 'c'), ('c', 'd')]
>>> list(sliding_window_iter([1, 2], 5))
[]
"""
# TODO: Utiliser itertools.islice et itertools.tee (ou zip)
pass
def powerset(iterable):
"""
G\u00e9n\u00e8re tous les sous-ensembles (power set) avec itertools.combinations.
>>> list(powerset([1, 2, 3]))
[(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]
>>> list(powerset("ab"))
[(), ('a',), ('b',), ('a', 'b')]
>>> list(powerset([]))
[()]
"""
# TODO: Utiliser itertools.chain et itertools.combinations
pass
def chunked(iterable, n):
"""
D\u00e9coupe un it\u00e9rable en morceaux de taille n.
>>> list(chunked([1, 2, 3, 4, 5], 2))
[(1, 2), (3, 4), (5,)]
>>> list(chunked("abcdef", 3))
[('a', 'b', 'c'), ('d', 'e', 'f')]
>>> list(chunked([], 3))
[]
"""
# TODO: Utiliser itertools.islice ou itertools.zip_longest
pass
# ============================================================================
# REPONSE EX13 - Itertools Patterns
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# itertools fournit des iterateurs composes "a la Unix pipe" :
# - Paresseux (lazy) : ne consomment la memoire que pour un element a la fois
# - Composables : on chaine les iterateurs comme des blocs LEGO
# - Performants : implementes en C, plus rapides que les boucles Python
#
# PIEGE : les iterateurs sont a usage unique !
# it = chain([1,2], [3,4])
# list(it) # [1, 2, 3, 4]
# list(it) # [] <-- epuise !
# ============================================================================
import itertools
def flatten_itertools(nested):
"""
Aplatit une liste de listes en utilisant itertools.chain.
>>> list(flatten_itertools([[1, 2], [3, 4], [5]]))
[1, 2, 3, 4, 5]
>>> list(flatten_itertools([[], [1], [], [2, 3]]))
[1, 2, 3]
>>> list(flatten_itertools([]))
[]
"""
# chain.from_iterable prend UN iterable d'iterables
# Plus efficace que chain(*nested) car ne depack pas tout en arguments
# chain(*nested) = chain([1,2], [3,4]) -> necessite de tout charger
# chain.from_iterable(nested) -> consomme paresseusement
return itertools.chain.from_iterable(nested)
def group_consecutive(iterable):
"""
Groupe les elements consecutifs egaux avec itertools.groupby.
>>> group_consecutive([1, 1, 2, 3, 3, 3, 1])
[(1, [1, 1]), (2, [2]), (3, [3, 3, 3]), (1, [1])]
>>> group_consecutive("aaabbc")
[('a', ['a', 'a', 'a']), ('b', ['b', 'b']), ('c', ['c'])]
>>> group_consecutive([])
[]
"""
# groupby groupe les elements CONSECUTIFS avec la meme cle
# ATTENTION : contrairement a SQL GROUP BY, les elements doivent etre contigus
# [1, 1, 2, 1] -> (1, [1,1]), (2, [2]), (1, [1]) -- PAS (1, [1,1,1])
# Pour grouper tous les identiques : trier d'abord (sorted + groupby)
return [(key, list(group)) for key, group in itertools.groupby(iterable)]
def sliding_window_iter(iterable, n):
"""
Fenetre glissante utilisant itertools.islice.
>>> list(sliding_window_iter([1, 2, 3, 4, 5], 3))
[(1, 2, 3), (2, 3, 4), (3, 4, 5)]
>>> list(sliding_window_iter("abcd", 2))
[('a', 'b'), ('b', 'c'), ('c', 'd')]
>>> list(sliding_window_iter([1, 2], 5))
[]
"""
# Technique classique : creer N copies decalees de l'iterateur
# tee(it, 3) -> it0, it1, it2 (3 copies independantes)
# Puis decaler chaque copie : it1 avance de 1, it2 avance de 2, etc.
# zip(it0, it1, it2) donne les fenetres
iterators = itertools.tee(iterable, n)
for i, it in enumerate(iterators):
# Avancer l'iterateur i de i positions
# consume() avec islice est le pattern standard pour avancer
for _ in range(i):
next(it, None)
return list(zip(*iterators))
def powerset(iterable):
"""
Genere tous les sous-ensembles (power set) avec itertools.combinations.
>>> list(powerset([1, 2, 3]))
[(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]
>>> list(powerset("ab"))
[(), ('a',), ('b',), ('a', 'b')]
>>> list(powerset([]))
[()]
"""
# Recette classique de la doc itertools
# Pour chaque taille r de 0 a len(s), generer toutes les combinations(s, r)
# chain.from_iterable les concatene paresseusement
s = list(iterable)
return list(itertools.chain.from_iterable(
itertools.combinations(s, r) for r in range(len(s) + 1)
))
def chunked(iterable, n):
"""
Decoupe un iterable en morceaux de taille n.
>>> list(chunked([1, 2, 3, 4, 5], 2))
[(1, 2), (3, 4), (5,)]
>>> list(chunked("abcdef", 3))
[('a', 'b', 'c'), ('d', 'e', 'f')]
>>> list(chunked([], 3))
[]
"""
# Technique : creer un iterateur, puis prendre des tranches de n
# iter() est idempotent sur un iterateur : iter(it) is it
# Donc [iter(it)] * n cree n REFERENCES au MEME iterateur
# zip_longest consomme n elements a chaque iteration
it = iter(iterable)
# islice(it, n) prend les n prochains elements de it
# iter(lambda: ..., sentinel) appelle la lambda jusqu'a sentinel
# On utilise un pattern avec iter + islice
while True:
chunk = tuple(itertools.islice(it, n))
if not chunk:
break
yield chunk
# ============================================================================
# POINTS CLES INTERVIEW
# ============================================================================
# Iterateurs infinis :
# count(10) -> 10, 11, 12, ...
# cycle("ABC") -> A, B, C, A, B, C, ...
# repeat(5, 3) -> 5, 5, 5
#
# Combinatoire :
# product("AB", repeat=2) -> AA, AB, BA, BB (produit cartesien)
# permutations("ABC", 2) -> AB, AC, BA, BC, CA, CB
# combinations("ABC", 2) -> AB, AC, BC (sans repetition, ordonne)
# combinations_with_replacement("AB", 2) -> AA, AB, BB
#
# Filtrage :
# takewhile(pred, it) : prend tant que pred est vrai
# dropwhile(pred, it) : ignore tant que pred est vrai, puis tout
# filterfalse(pred, it) : inverse de filter()
# compress("ABCDE", [1,0,1,0,1]) -> A, C, E
#
# Aggregation :
# accumulate([1,2,3,4]) -> 1, 3, 6, 10 (sommes partielles)
# chain(*iterables) : concatene plusieurs iterables
# chain.from_iterable(it) : version paresseuse de chain
# groupby(it, key) : groupe les elements consecutifs
#
# ATTENTION :
# - groupby ne groupe que les elements CONSECUTIFS (trier avant si besoin)
# - tee() partage un buffer -> memoire si les copies divergent beaucoup
# - Les iterateurs sont a usage unique (list(it) les epuise)
# ============================================================================
if __name__ == "__main__":
print("=== flatten ===")
print(list(flatten_itertools([[1, 2], [3, 4], [5]])))
print("\n=== group_consecutive ===")
print(group_consecutive([1, 1, 2, 3, 3, 3, 1]))
print("\n=== sliding_window ===")
print(list(sliding_window_iter([1, 2, 3, 4, 5], 3)))
print("\n=== powerset ===")
print(list(powerset([1, 2, 3])))
print("\n=== chunked ===")
print(list(chunked([1, 2, 3, 4, 5], 2)))
# Demo : combinatoire
print("\n=== Combinatoire ===")
print(f" product('AB', repeat=2): {list(itertools.product('AB', repeat=2))}")
print(f" permutations('ABC', 2): {list(itertools.permutations('ABC', 2))}")
print(f" combinations('ABC', 2): {list(itertools.combinations('ABC', 2))}")
# Demo : iterateurs infinis (avec islice pour limiter)
print("\n=== Iterateurs infinis (limites) ===")
print(f" count(10): {list(itertools.islice(itertools.count(10), 5))}")
print(f" cycle('AB'): {list(itertools.islice(itertools.cycle('AB'), 6))}")
# Demo : accumulate
print(f"\n accumulate([1,2,3,4]): {list(itertools.accumulate([1, 2, 3, 4]))}")
Dataclasses & Enum
MOYEN
ex14_dataclasses_enum.py
# ============================================================================
# EXERCICE 14 - MOYEN : Dataclasses & Enum
# ============================================================================
# Construisez un syst\u00e8me d'inventaire utilisant les dataclasses et Enum.
# Explorez les fonctionnalit\u00e9s : frozen, field, __post_init__,
# ordering, slots et les Enum avec propri\u00e9t\u00e9s.
from dataclasses import dataclass, field
from enum import Enum
from typing import List
class Category(Enum):
"""
Cat\u00e9gories de produits avec propri\u00e9t\u00e9s.
>>> Category.ELECTRONICS.value
'electronics'
>>> Category.ELECTRONICS.tax_rate
0.2
>>> Category.FOOD.tax_rate
0.055
"""
# TODO: D\u00e9finir ELECTRONICS = "electronics", CLOTHING = "clothing", FOOD = "food"
# TODO: Ajouter une propri\u00e9t\u00e9 tax_rate qui retourne le taux de taxe
# ELECTRONICS -> 0.2, CLOTHING -> 0.1, FOOD -> 0.055
pass
@dataclass(frozen=True)
class Price:
"""
Objet valeur immutable pour un prix.
>>> p = Price(amount=10.0, currency="EUR")
>>> p.amount
10.0
>>> p.with_tax(0.2)
Price(amount=12.0, currency='EUR')
>>> Price(amount=-5.0, currency="EUR")
Traceback (most recent call last):
...
ValueError: Le montant doit \u00eatre positif ou nul
"""
# TODO: D\u00e9finir les champs amount (float) et currency (str, default "EUR")
# TODO: Valider dans __post_init__ que amount >= 0
# TODO: Impl\u00e9menter with_tax(rate) qui retourne un nouveau Price
pass
@dataclass(order=True)
class Product:
"""
Produit avec tri automatique par prix.
>>> p1 = Product("Laptop", Category.ELECTRONICS, Price(999.99))
>>> p2 = Product("T-shirt", Category.CLOTHING, Price(19.99))
>>> p1 > p2
True
>>> p1.total_price()
1199.988
>>> Product("", Category.FOOD, Price(5.0))
Traceback (most recent call last):
...
ValueError: Le nom ne peut pas \u00eatre vide
"""
# TODO: D\u00e9finir name (str), category (Category), price (Price)
# TODO: Ajouter sort_index (float) avec field(init=False, repr=False) pour l'ordering
# TODO: Valider dans __post_init__ que name n'est pas vide
# TODO: Assigner sort_index = price.amount dans __post_init__
# TODO: Impl\u00e9menter total_price() qui retourne price * (1 + tax_rate)
pass
@dataclass
class Inventory:
"""
Gestionnaire d'inventaire.
>>> inv = Inventory()
>>> inv.add_product(Product("Laptop", Category.ELECTRONICS, Price(999.99)))
>>> inv.add_product(Product("Pain", Category.FOOD, Price(1.50)))
>>> inv.add_product(Product("T-shirt", Category.CLOTHING, Price(19.99)))
>>> len(inv)
3
>>> [p.name for p in inv.by_category(Category.ELECTRONICS)]
['Laptop']
>>> [p.name for p in inv.sorted_by_price()]
['Pain', 'T-shirt', 'Laptop']
>>> inv.total_value()
1021.48
"""
# TODO: D\u00e9finir products (List[Product]) avec field(default_factory=list)
def add_product(self, product: "Product"):
# TODO: Ajouter le produit \u00e0 la liste
pass
def __len__(self) -> int:
# TODO: Retourner le nombre de produits
pass
def by_category(self, category: Category) -> List["Product"]:
# TODO: Filtrer les produits par cat\u00e9gorie
pass
def sorted_by_price(self) -> List["Product"]:
# TODO: Retourner les produits tri\u00e9s par prix (utilise l'ordering)
pass
def total_value(self) -> float:
# TODO: Retourner la somme des prix (amount) de tous les produits
pass
# ============================================================================
# REPONSE EX02 - Service Layer avec Dependency Injection
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# Le Service orchestre la logique METIER
# - Valide les entrees (name non vide, price > 0)
# - Utilise le Repository pour la persistance (INJECTE, pas cree)
# - Leve des exceptions metier (ProductNotFound, InsufficientStock)
# ============================================================================
from ex01_repository import Product, ProductRepository
class InsufficientStockError(Exception):
pass
class ProductNotFoundError(Exception):
pass
class ProductService:
"""
Service layer qui orchestre la logique metier.
Le repository est INJECTE via le constructeur (pas cree ici).
"""
def __init__(self, repository: ProductRepository):
self._repo = repository
def create_product(self, name: str, price: float, category: str,
stock: int = 0) -> Product:
if not name:
raise ValueError("Name cannot be empty")
if price <= 0:
raise ValueError("Price must be positive")
return self._repo.save(
Product(id=0, name=name, price=price, category=category, stock=stock))
def update_price(self, product_id: int, new_price: float) -> Product:
product = self._repo.find_by_id(product_id)
if not product:
raise ProductNotFoundError(f"Product {product_id} not found")
product.price = new_price
return self._repo.save(product)
def purchase(self, product_id: int, quantity: int) -> Product:
product = self._repo.find_by_id(product_id)
if not product:
raise ProductNotFoundError(f"Product {product_id} not found")
if product.stock < quantity:
raise InsufficientStockError(
f"Only {product.stock} in stock, requested {quantity}")
product.stock -= quantity
return self._repo.save(product)
def get_products_summary(self) -> dict:
products = self._repo.find_all()
categories: dict[str, int] = {}
for p in products:
categories[p.category] = categories.get(p.category, 0) + 1
return {
"total_products": len(products),
"total_value": sum(p.price * p.stock for p in products),
"categories": categories,
"out_of_stock": [p.name for p in products if p.stock == 0],
}
# ============================================================================
# REPONSE EX01 - Two Sum
# ============================================================================
# CONCEPT CLE : Hash map pour transformer O(n^2) en O(n)
# Pour chaque nombre, on cherche si son COMPLEMENT (target - num) existe deja
# C'est LE classique d'interview. Montrer brute force puis optimiser.
# Complexite : O(n) temps, O(n) espace
# ============================================================================
def two_sum(nums: list[int], target: int) -> tuple[int, int] | None:
"""
>>> two_sum([2, 7, 11, 15], 9)
(0, 1)
>>> two_sum([3, 2, 4], 6)
(1, 2)
>>> two_sum([1, 2, 3], 10)
"""
seen = {} # valeur -> index
for i, num in enumerate(nums):
complement = target - num # ce qu'on cherche
if complement in seen: # O(1) lookup dans le dict
return (seen[complement], i)
seen[num] = i # stocker pour les prochains
return None
# AIDE-MEMOIRE : "Trouver une paire" -> Hash map
# ============================================================================
# REPONSE EX03 - Parentheses valides (Stack)
# ============================================================================
# CONCEPT CLE : Utiliser une PILE (stack)
# Ouvrant -> push sur la pile
# Fermant -> pop et verifier que c'est le bon type
# A la fin, la pile doit etre vide
# Complexite : O(n) temps, O(n) espace
# ============================================================================
def is_valid_parentheses(s: str) -> bool:
"""
>>> is_valid_parentheses("()")
True
>>> is_valid_parentheses("()[]{}")
True
>>> is_valid_parentheses("(]")
False
>>> is_valid_parentheses("([)]")
False
>>> is_valid_parentheses("{[]}")
True
"""
stack = []
matching = {"(": ")", "[": "]", "{": "}"}
for char in s:
if char in matching: # ouvrant -> push
stack.append(char)
elif not stack or char != matching[stack.pop()]: # fermant -> pop + check
return False
return len(stack) == 0 # pile vide = tout est matche
# AIDE-MEMOIRE : "Parentheses/imbrication" -> Stack
# PIEGE : Oublier "not stack" -> IndexError si on pop une pile vide
# ============================================================================
# REPONSE EX04 - Longest Unique Substring (Sliding Window)
# ============================================================================
# CONCEPT CLE : Sliding window avec deux pointeurs (left, right)
# - right avance toujours de 1
# - quand on trouve un doublon, left avance jusqu'a l'eliminer
# - O(n) AMORTIE car chaque element est ajoute/retire du set AU MAX 1 fois
#
# POURQUOI O(n) et PAS O(n^2) :
# right fait n pas. left fait AU TOTAL max n pas.
# Total = n + n = 2n = O(n)
# Complexite : O(n) temps, O(min(n, alphabet)) espace
# ============================================================================
def longest_unique_substring(s: str) -> int:
"""
>>> longest_unique_substring("abcabcbb")
3
>>> longest_unique_substring("bbbbb")
1
>>> longest_unique_substring("pwwkew")
3
>>> longest_unique_substring("")
0
"""
seen = set()
left = 0
max_len = 0
for right in range(len(s)):
while s[right] in seen: # doublon -> retrecir la fenetre
seen.remove(s[left]) # retirer le char le plus a gauche
left += 1
seen.add(s[right]) # ajouter le nouveau char
max_len = max(max_len, right - left + 1) # taille de la fenetre
return max_len
# Trace pour "abcabcbb" :
# right=0: seen={a} window="a" max=1
# right=1: seen={a,b} window="ab" max=2
# right=2: seen={a,b,c} window="abc" max=3
# right=3: 'a' in seen -> remove 'a', left=1. seen={b,c,a} window="bca" max=3
# right=4: 'b' in seen -> remove 'b', left=2. seen={c,a,b} window="cab" max=3
# ...
# AIDE-MEMOIRE : "Sous-chaine/sous-array" -> Sliding Window
# ============================================================================
# REPONSE EX05 - Merge Intervals
# ============================================================================
# CONCEPT CLE : TRIER d'abord par debut, puis fusionner en un seul pass
# Apres tri, si interval.start <= result[-1].end -> chevauchement -> fusionner
# Sinon -> pas de chevauchement -> ajouter
# Complexite : O(n log n) pour le tri + O(n) pour le parcours
# ============================================================================
def merge_intervals(intervals: list[list[int]]) -> list[list[int]]:
"""
>>> merge_intervals([[1,3],[2,6],[8,10],[15,18]])
[[1, 6], [8, 10], [15, 18]]
>>> merge_intervals([[1,4],[4,5]])
[[1, 5]]
>>> merge_intervals([[1,4],[0,4]])
[[0, 4]]
"""
if not intervals:
return []
intervals.sort() # trie par debut (1er element)
result = [intervals[0]]
for start, end in intervals[1:]:
if start <= result[-1][1]: # chevauchement
result[-1][1] = max(result[-1][1], end) # ATTENTION : max() !
else:
result.append([start, end])
return result
# PIEGE CRITIQUE : utiliser result[-1][1] = end au lieu de max(result[-1][1], end)
# Echoue sur [[1,4],[2,3]] car [2,3] est CONTENU dans [1,4]
# Sans max, on ecrirait result[-1][1] = 3, perdant le 4
# AIDE-MEMOIRE : "Intervalles" -> Trier + sweep
# ============================================================================
# REPONSE EX06 - Binary Tree (profondeur + equilibre)
# ============================================================================
# CONCEPT CLE : Recursion sur les arbres
# max_depth(node) = 1 + max(left, right)
# is_balanced : helper retourne -1 si desequilibre (evite O(n^2))
# Complexite : O(n) pour les deux
# ============================================================================
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def max_depth(root: TreeNode | None) -> int:
"""
>>> max_depth(None)
0
>>> max_depth(TreeNode(1))
1
>>> max_depth(TreeNode(1, TreeNode(2, TreeNode(4)), TreeNode(3)))
3
"""
if root is None:
return 0 # cas de base
return 1 + max(max_depth(root.left), max_depth(root.right))
def is_balanced(root: TreeNode | None) -> bool:
"""
Equilibre = diff de profondeur gauche/droite <= 1 POUR CHAQUE noeud.
>>> is_balanced(TreeNode(1, TreeNode(2), TreeNode(3)))
True
>>> is_balanced(TreeNode(1, TreeNode(2, TreeNode(3, TreeNode(4)))))
False
"""
def check(node) -> int:
"""Retourne la profondeur si equilibre, -1 sinon."""
if node is None:
return 0
left = check(node.left)
if left == -1: return -1 # propagation rapide
right = check(node.right)
if right == -1: return -1
if abs(left - right) > 1: # desequilibre
return -1
return 1 + max(left, right)
return check(root) != -1
# PIEGE : Appeler max_depth() dans is_balanced() pour chaque noeud
# -> O(n^2) car on recalcule la profondeur a chaque niveau
# La version avec -1 fait tout en UNE seule passe O(n)
# ============================================================================
# REPONSE EX07 - Linked List Cycle (Floyd's Algorithm)
# ============================================================================
# CONCEPT CLE : Tortue et Lievre
# - Tortue avance de 1 pas, Lievre de 2 pas
# - S'ils se rencontrent -> cycle
# - Pour le DEBUT du cycle : remettre un pointeur au debut,
# avancer les deux de 1 pas -> rencontre = debut du cycle
# Complexite : O(n) temps, O(1) espace (PAS de set !)
# ============================================================================
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def has_cycle(head: ListNode | None) -> bool:
"""Floyd's algorithm. O(n) temps, O(1) espace."""
slow = fast = head
while fast and fast.next:
slow = slow.next # 1 pas
fast = fast.next.next # 2 pas
if slow is fast: # rencontre -> cycle
return True
return False # fast a atteint None -> pas de cycle
def find_cycle_start(head: ListNode | None) -> ListNode | None:
"""Trouve le noeud ou le cycle commence. O(n) temps, O(1) espace."""
slow = fast = head
# Phase 1 : detecter le cycle
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
break
else:
return None # pas de cycle
# Phase 2 : trouver le debut
# Remettre un pointeur au debut, avancer les deux de 1 pas
slow = head
while slow is not fast:
slow = slow.next
fast = fast.next
return slow # point de rencontre = debut du cycle
# POURQUOI Phase 2 marche :
# Soit F = distance avant le cycle, C = longueur du cycle
# Au moment de la rencontre : fast a fait 2x la distance de slow
# fast = F + a + kC, slow = F + a
# 2(F + a) = F + a + kC -> F = kC - a
# Donc en repartant du debut et avancant de F pas -> on arrive au debut du cycle
# AIDE-MEMOIRE : "Detect cycle" -> Floyd (tortue/lievre)
if __name__ == "__main__":
# Creer une liste avec cycle : 1 -> 2 -> 3 -> 4 -> 2
n1 = ListNode(1)
n2 = ListNode(2)
n3 = ListNode(3)
n4 = ListNode(4)
n1.next = n2; n2.next = n3; n3.next = n4; n4.next = n2
print(has_cycle(n1)) # True
print(find_cycle_start(n1).val) # 2
# ============================================================================
# REPONSE EX09 - Longest Common Subsequence (DP)
# ============================================================================
# CONCEPT CLE : Dynamic Programming avec tableau 2D
# dp[i][j] = longueur LCS de s1[:i] et s2[:j]
# Si s1[i-1] == s2[j-1] : dp[i][j] = dp[i-1][j-1] + 1 (on prend ce char)
# Sinon : dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (on skip un char)
# RECONSTRUCTION : remonter le tableau pour trouver la sequence
# Complexite : O(n*m) temps et espace
# ============================================================================
def lcs(s1: str, s2: str) -> str:
"""
>>> lcs("abcde", "ace")
'ace'
>>> lcs("abc", "abc")
'abc'
>>> lcs("abc", "def")
''
"""
n, m = len(s1), len(s2)
# 1. Construction du tableau DP
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if s1[i - 1] == s2[j - 1]: # meme caractere
dp[i][j] = dp[i - 1][j - 1] + 1 # on le prend
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # on skip
# 2. Reconstruction de la sequence (remonter le tableau)
result = []
i, j = n, m
while i > 0 and j > 0:
if s1[i - 1] == s2[j - 1]: # ce char fait partie de la LCS
result.append(s1[i - 1])
i -= 1
j -= 1
elif dp[i - 1][j] > dp[i][j - 1]:
i -= 1 # aller vers la plus grande valeur
else:
j -= 1
return ''.join(reversed(result))
# EN INTERVIEW : dessiner le tableau DP sur le tableau blanc
# "" a c e
# "" [ 0 0 0 0 ]
# a [ 0 1 1 1 ]
# b [ 0 1 1 1 ]
# c [ 0 1 2 2 ]
# d [ 0 1 2 2 ]
# e [ 0 1 2 3 ]
# AIDE-MEMOIRE : "Optimisation avec contrainte" -> DP
# ============================================================================
# REPONSE EX03 - Parallel Map
# ============================================================================
# CONCEPT CLE : ThreadPoolExecutor.map() preserve l'ordre des resultats
# Threads = I/O bound (reseau, fichiers)
# Processes = CPU bound (calculs lourds)
# Le GIL empeche le parallelisme CPU en threads Python
# ============================================================================
from typing import Callable
from concurrent.futures import ThreadPoolExecutor
def parallel_map(func: Callable, items: list, max_workers: int = 4) -> list:
"""
map() parallele. Preserve l'ordre des resultats.
>>> import time
>>> def slow_square(x):
... time.sleep(0.01)
... return x ** 2
>>> parallel_map(slow_square, [1, 2, 3, 4])
[1, 4, 9, 16]
"""
with ThreadPoolExecutor(max_workers=max_workers) as executor:
return list(executor.map(func, items))
# C'est un one-liner mais il faut savoir l'expliquer en interview :
# 1. ThreadPoolExecutor cree un pool de threads reutilisables
# 2. executor.map() distribue les items aux threads
# 3. list() attend que tout soit fini et collecte les resultats EN ORDRE
# 4. "with" ferme proprement le pool a la fin
# ============================================================================
# REPONSE EX01 - Password Validator + Tests
# ============================================================================
# CONCEPT CLE : Penser aux CAS LIMITES quand on ecrit des tests
# - Happy path (mot de passe valide)
# - Chaque regle violee individuellement
# - Edge cases : string vide, exactement 8 chars, 3 chars identiques
# ============================================================================
class PasswordValidator:
SPECIAL_CHARS = "!@#$%^&*()_+-="
def validate(self, password: str, username: str = "") -> tuple[bool, list[str]]:
errors = []
if len(password) < 8:
errors.append("Password must be at least 8 characters")
if not any(c.isupper() for c in password):
errors.append("Password must contain at least one uppercase letter")
if not any(c.islower() for c in password):
errors.append("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in password):
errors.append("Password must contain at least one digit")
if not any(c in self.SPECIAL_CHARS for c in password):
errors.append("Password must contain at least one special character")
if username and username.lower() in password.lower():
errors.append("Password must not contain the username")
for i in range(len(password) - 2):
if password[i] == password[i + 1] == password[i + 2]:
errors.append("Password must not contain 3+ consecutive identical characters")
break
return (len(errors) == 0, errors)
# --- TESTS A ECRIRE ---
# import pytest
#
# @pytest.fixture
# def validator():
# return PasswordValidator()
#
# def test_valid_password(validator):
# ok, errors = validator.validate("MyP@ss1x")
# assert ok and errors == []
#
# def test_too_short(validator):
# ok, errors = validator.validate("Ab1!")
# assert not ok
# assert any("8 characters" in e for e in errors)
#
# def test_no_uppercase(validator):
# ok, _ = validator.validate("myp@ss1x")
# assert not ok
#
# def test_no_lowercase(validator):
# ok, _ = validator.validate("MYP@SS1X")
# assert not ok
#
# def test_no_digit(validator):
# ok, _ = validator.validate("MyP@ssxx")
# assert not ok
#
# def test_no_special(validator):
# ok, _ = validator.validate("MyPass1x")
# assert not ok
#
# def test_contains_username(validator):
# ok, _ = validator.validate("aliceP@ss1", username="alice")
# assert not ok
#
# def test_consecutive_chars(validator):
# ok, _ = validator.validate("MyP@aaa1x")
# assert not ok
#
# def test_empty_string(validator):
# ok, errors = validator.validate("")
# assert not ok and len(errors) >= 4
#
# def test_exactly_8_chars(validator):
# ok, _ = validator.validate("MyP@ss1x") # 8 chars
# assert ok
# ============================================================================
# REPONSE EX03 - Text Chunker pour RAG
# ============================================================================
# CONCEPT CLE : Decouper du texte en morceaux pour le RAG
# Fixed : taille fixe avec overlap (simple mais coupe les phrases)
# By sentences : respecte les phrases (meilleure qualite)
# By paragraphs : respecte les paragraphes
# Trade-off : chunk_size petit = plus precis, grand = plus de contexte
# ============================================================================
import re
class TextChunker:
def __init__(self, chunk_size: int = 500, overlap: int = 50):
self.chunk_size = chunk_size
self.overlap = overlap
def chunk_fixed(self, text: str) -> list[str]:
"""Chunks de taille fixe avec overlap."""
chunks = []
start = 0
while start < len(text):
end = start + self.chunk_size
chunks.append(text[start:end])
start += self.chunk_size - self.overlap
return chunks
def chunk_by_sentences(self, text: str, max_chunk_size: int = None) -> list[str]:
"""Decoupe par phrases sans couper au milieu d'une phrase."""
max_size = max_chunk_size or self.chunk_size
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) + 1 > max_size and current:
chunks.append(current.strip())
current = ""
current += (" " if current else "") + sentence
if current.strip():
chunks.append(current.strip())
return chunks
def chunk_by_paragraphs(self, text: str) -> list[str]:
"""Decoupe par paragraphes. Les gros sont decoupes par phrases."""
paragraphs = text.split("\n\n")
chunks = []
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(para) <= self.chunk_size:
chunks.append(para)
else:
chunks.extend(self.chunk_by_sentences(para))
return chunks
# ============================================================================
# REPONSE EX01 - Les 12 pieges Python a connaitre en interview
# ============================================================================
# Pour chaque piege : le code trompeur, la reponse, l'explication, le fix
# ============================================================================
# --- GOTCHA 1 : Mutable Default Arguments ---
# def append_to(element, to=[]):
# to.append(element)
# return to
# append_to(1) -> [1]
# append_to(2) -> [1, 2] <-- PAS [2] !
# append_to(3) -> [1, 2, 3]
#
# POURQUOI : Les args par defaut sont evalues UNE SEULE FOIS
# La MEME liste est reutilisee a chaque appel
# FIX : def append_to(element, to=None):
# if to is None: to = []
# --- GOTCHA 2 : Late Binding Closures ---
# functions = [lambda: i for i in range(5)]
# [f() for f in functions] -> [4, 4, 4, 4, 4]
#
# POURQUOI : i est evalue au moment de l'APPEL, pas de la creation
# Quand on appelle, la boucle est finie et i == 4
# FIX : lambda i=i: i (capture par argument par defaut)
# --- GOTCHA 3 : is vs == ---
# 256 is 256 -> True (CPython cache les int -5 a 256)
# 257 is 257 -> False (hors cache, objets differents)
# "hello" is "hello" -> True (strings simples internees)
#
# REGLE : TOUJOURS == pour comparer, is UNIQUEMENT pour None
# if x is None: (correct)
# if x == 256: (correct)
# --- GOTCHA 4 : Variable Scope (LEGB) ---
# def tricky():
# print(x) # UnboundLocalError !
# x = 5
# x = 10; tricky()
#
# POURQUOI : Python determine la portee au PARSING, pas a l'execution
# x = 5 dans la fonction -> x est LOCAL
# Mais print(x) est AVANT l'assignation -> erreur
# --- GOTCHA 5 : List Multiplication ---
# matrix = [[0]*3] * 3
# matrix[0][0] = 1
# print(matrix) -> [[1,0,0], [1,0,0], [1,0,0]]
#
# POURQUOI : * cree 3 REFERENCES a la MEME liste
# FIX : matrix = [[0]*3 for _ in range(3)] (3 listes independantes)
# --- GOTCHA 6 : Dict Key True/1/1.0 ---
# d = {}; d[True] = "a"; d[1] = "b"; d[1.0] = "c"
# print(d) -> {True: 'c'}
#
# POURQUOI : True == 1 == 1.0 et hash(True) == hash(1) == hash(1.0)
# Meme cle, la premiere cle est conservee, derniere valeur gagne
# --- GOTCHA 7 : Generator Exhaustion ---
# gen = (x for x in range(3))
# print(2 in gen) -> True (consomme 0, 1, 2)
# print(2 in gen) -> False (generateur epuise)
# print(list(gen)) -> []
#
# REGLE : Un generateur ne peut etre itere qu'UNE fois
# --- GOTCHA 8 : String Immutability ---
# s = "hello"; s += " world" -> NOUVEL objet (pas in-place)
# id(s) change apres +=
#
# IMPACT : Concatenation en boucle = O(n^2)
# FIX : ''.join(list_of_strings)
# --- GOTCHA 9 : Finally Return ---
# def f():
# try: return 1
# finally: return 2
# f() -> 2
#
# POURQUOI : finally s'execute TOUJOURS, meme apres un return
# Le return de finally REMPLACE celui du try
# REGLE : JAMAIS de return dans finally
# --- GOTCHA 10 : Class vs Instance Variables ---
# class Dog:
# tricks = [] # variable de CLASSE (partagee)
# d1 = Dog(); d2 = Dog()
# d1.tricks.append("roll")
# print(d2.tricks) -> ['roll'] (meme liste !)
#
# FIX : def __init__(self): self.tricks = [] (variable d'INSTANCE)
# --- GOTCHA 11 : Tuple Mutability Surprise ---
# t = ([1, 2],)
# t[0] += [3] -> TypeError MAIS la liste EST modifiee !
# print(t) -> ([1, 2, 3],)
#
# POURQUOI : += fait 2 choses :
# 1. t[0].extend([3]) -> OK (mute la liste)
# 2. t[0] = t[0] -> ECHEC (tuple immuable)
# --- GOTCHA 12 : Walrus Operator Scope ---
# results = [y := x**2 for x in range(5) if (y := x**2) > 5]
# print(results) -> [9, 16]
# print(y) -> 16 (y existe dans la portee englobante !)
#
# POURQUOI : := cree la variable dans la portee ENGLOBANTE, pas la comprehension
# ============================================================================
# REPONSE EX02 - Find and Fix Bugs
# ============================================================================
# 5 fonctions avec bugs subtils, chacune corrigee et expliquee
# ============================================================================
import threading
# --- BUG 1 : type() vs isinstance() ---
def buggy_flatten(nested: list) -> list:
"""BUG : type(item) == list ne gere pas les sous-classes de list."""
result = []
for item in nested:
if type(item) == list: # BUG : echoue avec des sous-classes
result.extend(buggy_flatten(item))
else:
result.append(item)
return result
def fixed_flatten(nested: list) -> list:
"""FIX : isinstance gere les sous-classes de list."""
result = []
for item in nested:
if isinstance(item, list): # FIX
result.extend(fixed_flatten(item))
else:
result.append(item)
return result
# --- BUG 2 : Args mutables non hashables ---
def buggy_cache(func):
"""BUG : crash si on passe un dict ou une liste comme argument."""
cache = {}
def wrapper(*args):
if args not in cache: # TypeError si args contient un dict
cache[args] = func(*args)
return cache[args]
return wrapper
def fixed_cache(func):
"""FIX : convertir en representation hashable, ou catch TypeError."""
cache = {}
def wrapper(*args):
try:
key = args
if key not in cache:
cache[key] = func(*args)
return cache[key]
except TypeError: # args non hashable -> pas de cache
return func(*args)
return wrapper
# --- BUG 3 : ZeroDivisionError ---
def buggy_average(numbers):
"""BUG : ZeroDivisionError si la liste est vide."""
total = sum(numbers)
return total / len(numbers) # crash si numbers = []
def fixed_average(numbers):
"""FIX : verifier que la liste n'est pas vide."""
if not numbers:
raise ValueError("Cannot compute average of empty list")
return sum(numbers) / len(numbers)
# --- BUG 4 : open() dans __init__ ---
class BuggyContext:
"""BUG : open() dans __init__, pas dans __enter__."""
def __init__(self, filename):
self.file = open(filename, 'r') # ouvre AVANT __enter__
def __enter__(self):
return self.file
def __exit__(self, *args):
self.file.close()
# PROBLEME : si une erreur survient entre __init__ et __enter__,
# le fichier reste ouvert car __exit__ n'est jamais appele
class FixedContext:
"""FIX : ouvrir le fichier dans __enter__."""
def __init__(self, filename):
self.filename = filename
def __enter__(self):
self.file = open(self.filename, 'r') # ouvre dans __enter__
return self.file
def __exit__(self, *args):
self.file.close()
# --- BUG 5 : Singleton pas thread-safe ---
def buggy_singleton(cls):
"""BUG : deux threads peuvent creer deux instances en parallele."""
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances: # Thread A verifie: pas d'instance
# Thread B verifie aussi: pas d'instance
instances[cls] = cls(*args, **kwargs) # les deux creent !
return instances[cls]
return get_instance
def fixed_singleton(cls):
"""FIX : Lock pour la thread-safety."""
instances = {}
lock = threading.Lock()
def get_instance(*args, **kwargs):
with lock: # un seul thread a la fois
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
# ============================================================================
# REPONSE EX01 - Tokenizer BPE simplifie
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# BPE (Byte-Pair Encoding) = algorithme de tokenization des LLM modernes.
# 1. Commencer avec des caracteres individuels
# 2. Fusionner iterativement la paire la plus frequente
# 3. Repeter jusqu'a atteindre la taille de vocabulaire souhaitee
#
# Variantes : WordPiece (BERT), SentencePiece (T5), tiktoken (GPT)
# En interview : expliquer POURQUOI BPE -> gere les mots inconnus,
# equilibre entre caracteres et mots complets
# ============================================================================
class SimpleBPETokenizer:
"""
>>> tok = SimpleBPETokenizer()
>>> tok.train(["low lower newest widest"], num_merges=10)
>>> tok.vocab_size() > 26 # au moins les lettres + les merges
True
>>> tokens = tok.tokenize("low")
>>> isinstance(tokens, list) and len(tokens) >= 1
True
"""
def __init__(self):
self.merges = [] # Liste de paires fusionnees [(a, b), ...]
self.vocab = set() # Vocabulaire complet
def train(self, corpus: list[str], num_merges: int = 10):
# 1. Decouper chaque mot en caracteres + '</w>'
words = []
for text in corpus:
for word in text.split():
words.append(list(word) + ['</w>'])
# Vocabulaire initial = tous les caracteres
for word in words:
for char in word:
self.vocab.add(char)
# 2. Boucle BPE
for _ in range(num_merges):
pairs = self.count_pairs(words)
if not pairs:
break
# Paire la plus frequente
best_pair = max(pairs, key=pairs.get)
self.merges.append(best_pair)
# Nouveau token = concatenation
new_token = best_pair[0] + best_pair[1]
self.vocab.add(new_token)
# Fusionner dans tous les mots
words = self._merge_pair(words, best_pair)
def tokenize(self, text: str) -> list[str]:
tokens = []
for word in text.split():
word_tokens = list(word) + ['</w>']
# Appliquer les merges dans l'ordre appris
for pair in self.merges:
word_tokens = self._apply_merge(word_tokens, pair)
tokens.extend(word_tokens)
return tokens
def vocab_size(self) -> int:
return len(self.vocab)
@staticmethod
def count_pairs(words: list[list[str]]) -> dict:
"""
>>> SimpleBPETokenizer.count_pairs([['l', 'o', 'w']])
{('l', 'o'): 1, ('o', 'w'): 1}
"""
pairs = {}
for word in words:
for i in range(len(word) - 1):
pair = (word[i], word[i + 1])
pairs[pair] = pairs.get(pair, 0) + 1
return pairs
@staticmethod
def _merge_pair(words, pair):
"""Fusionne une paire dans tous les mots."""
new_words = []
for word in words:
new_words.append(SimpleBPETokenizer._apply_merge(word, pair))
return new_words
@staticmethod
def _apply_merge(word_tokens, pair):
"""Applique un merge a une liste de tokens."""
result = []
i = 0
while i < len(word_tokens):
if (i < len(word_tokens) - 1 and
word_tokens[i] == pair[0] and word_tokens[i + 1] == pair[1]):
result.append(pair[0] + pair[1])
i += 2
else:
result.append(word_tokens[i])
i += 1
return result
# --- POINTS CLES INTERVIEW ---
# Q: Pourquoi pas juste split par espaces ?
# R: "the" et "there" partagent des sous-tokens â meilleure generalisation
#
# Q: Difference BPE vs WordPiece ?
# R: BPE fusionne la paire la plus frequente
# WordPiece fusionne celle qui maximise la vraisemblance du corpus
#
# Q: Combien de tokens dans un mot ?
# R: ~1 token par 4 caracteres en anglais (regle empirique)
# Les mots rares sont decomposes en plus de tokens
#
# Q: Pourquoi '</w>' ?
# R: Marqueur de fin de mot pour distinguer "un" standalone de "un" dans "under"
if __name__ == "__main__":
tok = SimpleBPETokenizer()
tok.train(["low low low lower lowest newest widest"], num_merges=10)
print(f"Vocab size: {tok.vocab_size()}")
print(f"Merges: {tok.merges}")
print(f"tokenize('low'): {tok.tokenize('low')}")
print(f"tokenize('lower'): {tok.tokenize('lower')}")
print(f"tokenize('newest'): {tok.tokenize('newest')}")
# ============================================================================
# REPONSE EX07 - Agent ReAct (Reasoning + Acting)
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# ReAct = Reasoning + Acting (Yao et al., 2023)
# Le LLM ALTERNE entre raisonnement et action :
# Thought â Action â Observation â Thought â ... â Final Answer
#
# C'est le pattern de TOUS les frameworks d'agents :
# LangChain, CrewAI, AutoGPT, Claude tool use, etc.
#
# PIEGE : toujours limiter le nombre d'iterations (max_steps)
# pour eviter les boucles infinies et les couts explosifs.
# ============================================================================
from dataclasses import dataclass, field
from abc import ABC, abstractmethod
import re
import ast
import operator
@dataclass
class AgentStep:
thought: str
action: str = None
action_input: str = None
observation: str = None
is_final: bool = False
final_answer: str = None
@dataclass
class AgentResult:
answer: str
steps: list[AgentStep]
total_llm_calls: int
total_tool_calls: int
class AgentTool(ABC):
name: str
description: str
@abstractmethod
def run(self, input_str: str) -> str:
pass
class CalculatorTool(AgentTool):
"""
>>> calc = CalculatorTool()
>>> calc.run("2 + 3 * 4")
'14'
"""
name = "calculator"
description = "Evaluates mathematical expressions. Input: a math expression string."
# Operateurs autorises (PAS de eval brut !)
_SAFE_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.Pow: operator.pow, ast.Mod: operator.mod,
ast.FloorDiv: operator.floordiv,
ast.USub: operator.neg,
}
def run(self, input_str: str) -> str:
try:
tree = ast.parse(input_str.strip(), mode='eval')
result = self._eval_node(tree.body)
return str(result)
except Exception as e:
return f"Error: {e}"
def _eval_node(self, node):
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError("Only numbers allowed")
elif isinstance(node, ast.BinOp):
left = self._eval_node(node.left)
right = self._eval_node(node.right)
op = self._SAFE_OPS.get(type(node.op))
if op is None:
raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
return op(left, right)
elif isinstance(node, ast.UnaryOp):
operand = self._eval_node(node.operand)
op = self._SAFE_OPS.get(type(node.op))
if op is None:
raise ValueError(f"Unsupported operator")
return op(operand)
raise ValueError(f"Unsupported expression: {type(node).__name__}")
class SearchTool(AgentTool):
"""
>>> search = SearchTool({"python": "Python is a programming language"})
>>> "programming" in search.run("python")
True
"""
name = "search"
description = "Searches for information. Input: a search query string."
def __init__(self, knowledge_base: dict[str, str]):
self.kb = {k.lower(): v for k, v in knowledge_base.items()}
def run(self, input_str: str) -> str:
query = input_str.lower().strip()
# Chercher la meilleure correspondance
for key, value in self.kb.items():
if key in query or query in key:
return value
# Chercher dans les valeurs
for key, value in self.kb.items():
if query in value.lower():
return value
return "No results found."
class ReActAgent:
def __init__(self, tools: list[AgentTool], llm_fn=None, max_steps: int = 5):
self.tools = {t.name: t for t in tools}
self.llm_fn = llm_fn
self.max_steps = max_steps
def build_prompt(self, question: str, steps: list[AgentStep]) -> str:
tools_desc = "\n".join(
f"- {name}: {tool.description}" for name, tool in self.tools.items()
)
prompt = f"""Answer the following question using the available tools.
Tools:
{tools_desc}
Use this format:
Thought: <your reasoning about what to do next>
Action: <tool_name>
Action Input: <input for the tool>
When you have the final answer:
Thought: <your reasoning>
Final Answer: <your final answer>
Question: {question}
"""
# Ajouter les etapes precedentes
for step in steps:
prompt += f"\nThought: {step.thought}"
if step.action:
prompt += f"\nAction: {step.action}"
prompt += f"\nAction Input: {step.action_input}"
if step.observation:
prompt += f"\nObservation: {step.observation}"
if step.is_final:
prompt += f"\nFinal Answer: {step.final_answer}"
return prompt
def parse_llm_output(self, output: str) -> AgentStep:
"""
>>> agent = ReActAgent([])
>>> step = agent.parse_llm_output("Thought: test\\nAction: calc\\nAction Input: 2+2")
>>> step.action
'calc'
"""
thought = ""
action = None
action_input = None
final_answer = None
for line in output.strip().split("\n"):
line = line.strip()
if line.startswith("Thought:"):
thought = line[len("Thought:"):].strip()
elif line.startswith("Action:"):
action = line[len("Action:"):].strip()
elif line.startswith("Action Input:"):
action_input = line[len("Action Input:"):].strip()
elif line.startswith("Final Answer:"):
final_answer = line[len("Final Answer:"):].strip()
is_final = final_answer is not None
return AgentStep(
thought=thought, action=action, action_input=action_input,
is_final=is_final, final_answer=final_answer,
)
def run(self, question: str) -> AgentResult:
steps = []
llm_calls = 0
tool_calls = 0
for _ in range(self.max_steps):
prompt = self.build_prompt(question, steps)
llm_output = self.llm_fn(prompt)
llm_calls += 1
step = self.parse_llm_output(llm_output)
steps.append(step)
if step.is_final:
return AgentResult(
answer=step.final_answer, steps=steps,
total_llm_calls=llm_calls, total_tool_calls=tool_calls,
)
if step.action and step.action in self.tools:
observation = self.tools[step.action].run(step.action_input or "")
step.observation = observation
tool_calls += 1
# Max steps atteint
last_thought = steps[-1].thought if steps else "Could not determine answer"
return AgentResult(
answer=f"Reached max steps. Last thought: {last_thought}",
steps=steps, total_llm_calls=llm_calls, total_tool_calls=tool_calls,
)
# --- POINTS CLES INTERVIEW ---
# Q: Quelle est la difference entre ReAct et Chain-of-Thought ?
# R: CoT = raisonnement pur (pas d'actions). ReAct = raisonnement + actions.
# ReAct peut interagir avec le monde (APIs, DBs, outils).
#
# Q: Comment eviter les boucles infinies ?
# R: max_steps, budget de tokens, timeout, detection de repetition.
#
# Q: Comment le LLM choisit quel outil utiliser ?
# R: Le prompt contient la description des outils. Le LLM "raisonne"
# sur quel outil est pertinent. C'est du prompting, pas de la magie.
#
# Q: ReAct vs function calling natif (OpenAI/Anthropic) ?
# R: Function calling = ReAct integre dans l'API. Plus fiable car
# le format est contraint par l'API (pas de parsing regex fragile).
# ReAct = plus flexible, fonctionne avec n'importe quel LLM.
if __name__ == "__main__":
# Simuler un LLM avec des reponses pre-programmees
responses = iter([
"Thought: I need to calculate 15% of 85\nAction: calculator\nAction Input: 85 * 0.15",
"Thought: 15% of 85 is 12.75. Now I know the answer.\nFinal Answer: 15% of 85 is 12.75",
])
agent = ReActAgent(
tools=[CalculatorTool(), SearchTool({"python": "Python est un langage de programmation"})],
llm_fn=lambda _: next(responses),
max_steps=5,
)
result = agent.run("What is 15% of 85?")
print(f"Answer: {result.answer}")
print(f"Steps: {len(result.steps)}")
print(f"LLM calls: {result.total_llm_calls}")
print(f"Tool calls: {result.total_tool_calls}")
for i, step in enumerate(result.steps):
print(f"\n Step {i+1}:")
print(f" Thought: {step.thought}")
if step.action:
print(f" Action: {step.action}({step.action_input})")
print(f" Observation: {step.observation}")
if step.is_final:
print(f" Final: {step.final_answer}")
# ============================================================================
# REPONSE EX09 - Metriques d'evaluation LLM
# ============================================================================
# CONCEPT CLE EN INTERVIEW :
# "Comment evaluer un LLM ?" est LA question d'interview IA.
#
# Metriques automatiques (rapides, pas toujours fiables) :
# - BLEU : chevauchement de n-grams (traduction)
# - ROUGE : recall de n-grams (summarization)
# - Perplexity : surprise du modele
# - F1 token : overlap de tokens (Q&A, SQuAD)
# - Exact Match : reponse exacte
#
# Metriques avancees (plus fiables, plus lentes) :
# - LLM-as-judge (GPT-4 evalue les reponses)
# - Human evaluation (gold standard mais cher)
# - Task-specific metrics (code: pass@k, math: accuracy)
# ============================================================================
from collections import Counter
import math
import re
def bleu_score(reference: str, candidate: str, max_n: int = 4) -> float:
"""
>>> round(bleu_score("the cat is on the mat", "the cat is on the mat"), 2)
1.0
>>> bleu_score("the cat is on the mat", "completely different text") < 0.1
True
"""
ref_tokens = reference.lower().split()
cand_tokens = candidate.lower().split()
if not cand_tokens:
return 0.0
# Brevity penalty
bp = 1.0
if len(cand_tokens) < len(ref_tokens):
bp = math.exp(1 - len(ref_tokens) / len(cand_tokens))
# N-gram precisions
log_precisions = []
for n in range(1, max_n + 1):
ref_ngrams = Counter()
for i in range(len(ref_tokens) - n + 1):
ngram = tuple(ref_tokens[i:i + n])
ref_ngrams[ngram] += 1
cand_ngrams = Counter()
for i in range(len(cand_tokens) - n + 1):
ngram = tuple(cand_tokens[i:i + n])
cand_ngrams[ngram] += 1
# Clipped count
clipped = sum(min(count, ref_ngrams.get(ng, 0))
for ng, count in cand_ngrams.items())
total = sum(cand_ngrams.values())
if total == 0:
return 0.0
precision = clipped / total
if precision == 0:
return 0.0
log_precisions.append(math.log(precision))
if not log_precisions:
return 0.0
avg_log = sum(log_precisions) / len(log_precisions)
return bp * math.exp(avg_log)
def rouge_l(reference: str, candidate: str) -> dict:
"""
>>> scores = rouge_l("the cat is on the mat", "the cat sat on the mat")
>>> scores["recall"] > 0.8
True
"""
ref_tokens = reference.lower().split()
cand_tokens = candidate.lower().split()
if not ref_tokens or not cand_tokens:
return {"precision": 0.0, "recall": 0.0, "f1": 0.0}
# LCS via programmation dynamique
m, n = len(ref_tokens), len(cand_tokens)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if ref_tokens[i - 1] == cand_tokens[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
lcs_len = dp[m][n]
precision = lcs_len / n if n > 0 else 0.0
recall = lcs_len / m if m > 0 else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
return {"precision": precision, "recall": recall, "f1": f1}
def perplexity(log_probs: list[float]) -> float:
"""
>>> round(perplexity([-1.0, -1.0, -1.0]), 2)
2.72
"""
if not log_probs:
return float('inf')
avg_neg_log_prob = -sum(log_probs) / len(log_probs)
return math.exp(avg_neg_log_prob)
def exact_match(reference: str, candidate: str, normalize: bool = True) -> float:
"""
>>> exact_match("Hello World!", "hello world")
1.0
"""
if normalize:
reference = re.sub(r'[^\w\s]', '', reference.strip().lower())
candidate = re.sub(r'[^\w\s]', '', candidate.strip().lower())
return 1.0 if reference == candidate else 0.0
def f1_token_score(reference: str, candidate: str) -> float:
"""
>>> round(f1_token_score("the cat is on the mat", "the cat sat on a mat"), 2)
0.73
"""
ref_tokens = reference.lower().split()
cand_tokens = candidate.lower().split()
if not ref_tokens or not cand_tokens:
return 0.0
# Compter les tokens communs (avec multiplicite)
ref_counter = Counter(ref_tokens)
cand_counter = Counter(cand_tokens)
common = 0
for token, count in ref_counter.items():
common += min(count, cand_counter.get(token, 0))
if common == 0:
return 0.0
precision = common / len(cand_tokens)
recall = common / len(ref_tokens)
return 2 * precision * recall / (precision + recall)
def semantic_similarity_score(embedding_a: list[float],
embedding_b: list[float]) -> float:
"""
>>> semantic_similarity_score([1, 0], [1, 0])
1.0
>>> semantic_similarity_score([1, 0], [0, 1])
0.5
"""
dot = sum(a * b for a, b in zip(embedding_a, embedding_b))
norm_a = math.sqrt(sum(a ** 2 for a in embedding_a))
norm_b = math.sqrt(sum(b ** 2 for b in embedding_b))
if norm_a == 0 or norm_b == 0:
return 0.5
cosine = dot / (norm_a * norm_b)
return (cosine + 1) / 2 # map [-1,1] -> [0,1]
class LLMEvaluator:
"""
>>> evaluator = LLMEvaluator()
>>> scores = evaluator.evaluate("Python is a language", "Python is a popular language",
... metrics=["exact_match", "f1", "rouge_l"])
>>> "f1" in scores
True
"""
def __init__(self):
self._metrics = {
"exact_match": lambda r, c: exact_match(r, c),
"f1": lambda r, c: f1_token_score(r, c),
"rouge_l": lambda r, c: rouge_l(r, c)["f1"],
"bleu": lambda r, c: bleu_score(r, c),
}
def evaluate(self, reference: str, candidate: str,
metrics: list[str] = None) -> dict:
if metrics is None:
metrics = list(self._metrics.keys())
return {m: self._metrics[m](reference, candidate) for m in metrics
if m in self._metrics}
def evaluate_batch(self, references: list[str], candidates: list[str],
metrics: list[str] = None) -> dict:
all_scores = [self.evaluate(r, c, metrics)
for r, c in zip(references, candidates)]
if not all_scores:
return {}
keys = all_scores[0].keys()
return {k: sum(s[k] for s in all_scores) / len(all_scores) for k in keys}
# --- POINTS CLES INTERVIEW ---
# Q: BLEU est-il suffisant pour evaluer un LLM ?
# R: NON. BLEU mesure le chevauchement lexical, pas semantique.
# "The cat sat" et "The feline rested" ont un BLEU faible
# mais sont semantiquement equivalents.
#
# Q: Quelle metrique pour quel use case ?
# R: - Traduction : BLEU, COMET
# - Summarization : ROUGE, BERTScore
# - Q&A : F1, Exact Match
# - Code : pass@k (execution-based)
# - General : LLM-as-judge, human eval
#
# Q: Qu'est-ce que la perplexite ?
# R: Mesure la "surprise" du modele. PPL=1 = parfait, PPL=vocab_size = random.
# GPT-4 ~ 10-20 PPL sur du texte general.
#
# Q: LLM-as-judge, c'est fiable ?
# R: ~80% d'accord avec les humains. Biais : prefere les reponses longues,
# prefere son propre style. Solution : utiliser des rubrics precis.
if __name__ == "__main__":
evaluator = LLMEvaluator()
ref = "The cat sat on the mat"
tests = [
"The cat sat on the mat", # Exact match
"The cat is on the mat", # Close
"A dog stood on the rug", # Different meaning
"The feline rested on the carpet", # Paraphrase (hard for n-gram metrics)
]
for cand in tests:
scores = evaluator.evaluate(ref, cand)
print(f" Candidate: {cand}")
for metric, score in scores.items():
print(f" {metric}: {score:.3f}")
print()