Как найти минимальный набор объектов, удовлетворяющих нескольким условиям с вложенными атрибутами в Python?
Я работаю над проблемой, в которой мне нужно сгенерировать минимальный набор объектов, удовлетворяющих заданному списку условий. Условия включают вложенные атрибуты, и для каждого атрибута у меня есть предопределенный список возможных значений.
Я хочу сгенерировать минимальный набор объектов, удовлетворяющих всем условиям. Каждый объект должен быть построен с использованием возможных значений атрибутов.
Для входных данных:
attribute_values = {
"object.object.value": ["value1", "value2", "value3"],
"object.other.value": ["value4", "value5", "value6"],
"object.another.value": [10, 20, 30]
}
conditions = [
{"path": "object.object.value", "operator": "==", "value": "value1"}, # Condition 1
{"path": "object.other.value", "operator": "in", "value": ["value5", "value6"]}, # Condition 2
{"path": "object.another.value", "operator": ">", "value": 15}, # Condition 3
{"path": "object.object.value", "operator": "==", "value": "value2",
"path2": "object.another.value", "operator2": "<", "value2": 25} # Condition 4
]
На выходе должен получиться минимальный набор объектов, например:
[
{
"object": {
"object": {
"value": "value1"
},
"other": {
"value": "value5"
},
"another": {
"value": 20
}
}
},
{
"object": {
"object": {
"value": "value2"
},
"other": {
"value": "value6"
},
"another": {
"value": 10
}
}
}
]
Этот вывод удовлетворяет всем условиям:
"object.object.value" == "value1" # Condition 1
"object.other.value" in ["value5", "value6"] # Condition 2
"object.another.value" > 15 # Condition 3
"object.object.value" == "value2" AND "object.another.value" < 25 # Condition 4
Количество возможных значений атрибутов может варьироваться в пределах 3-15.
Мне нужен «минимальный» набор объектов, потому что в дальнейшем они потребуют ручных операций, а при всех возможных комбинациях значений это станет практически невозможным. Это также будет некоторым излишеством.
Ответы (3 шт):
Насколько понимаю, вы делаете свою ORM. Я бы посоветовал рассмотреть использование Pydantic — можно добавить фильтры и встроить логику прямо в модель. Альтернатива — обычный @dataclass с обработкой фильтрации через __post_init__. Оба подхода дадут более читаемый и устойчивый код по сравнению с манипуляциями напрямую с dict.
P.S. Пишу здесь, потому что пока не могу оставить комментарий — не хватает репутации =(
Это скорее рассуждения, а не ответ. Используемый код не тестировался.
Если я правильно понял, вы работаете с объектами, у которых фиксированный набор атрибутов. Каждый атрибут может принимать конечное наперед заданное количество значений. Также вам задан перечень независимых наборов ограничений по тем или иным атрибутам. Перед вами стоит задача собрать минимальное множество объектов, в котором для каждого набора ограничений найдется хотя бы один удовлетворяющий им объект.
Дальше я буду исходить из следующего представления о нотации заданных параметров:
attribute_values = {
"object.object.value": ["value1", "value2", "value3"],
"object.other.value": ["value4", "value5", "value6"],
"object.another.value": [10, 20, 30]
}
conditions = [
# Condition 1
[{"path": "object.object.value", "operator": "==", "value": "value1"}],
# Condition 2
[{"path": "object.other.value", "operator": "in", "value": ["value5", "value6"]}],
# Condition 3
[{"path": "object.another.value", "operator": ">", "value": 15}],
# Condition 4
[{"path": "object.object.value", "operator": "==", "value": "value2"},
{"path": "object.another.value", "operator": "<", "value": 25}]
]
Здесь я позволил себе изменить запись условий, чтобы каждое представляло собой словарь с ключами ('path', 'operator', 'value'). Мы можем использовать это в функции, которая будет возвращать перечень допустимых значений для каждого атомарного условия:
def get_allowed[T](path: str, op: str, value: T | list[T],
attribute_values: dict[str, Iterable]) -> set[T]:
if op == '==':
return {value}
elif op == 'in':
return set(value)
from operator import lt, le, gt, ge
condition = (lt if op == '<' else
le if op == '<=' else
gt if op == '>' else
ge if op == '>=' else
None)
if condition is None:
raise ValueError(f'Unknown operator: {op!r}')
return {x for x in attribute_values[path] if condition(x, value)}
Дальше, мы могли бы выбрать "подмножества" объектов в виде collections.ChainMap из двух словарей, где первый - это ключи с ограничениями, а второй - значения по умолчанию для обращения к ключам без ограничений (подмножества взяты в кавычки, потому что это перечень допустимых значений по каждому ключу, из которых могут составляться объекты подмножества, т.е. мы лишь обозначаем границы подмножества по каждому атрибуту):
from collections import ChainMap
def get_sets(attribute_values: dict[str, list], conditions: list[list[dict]]) -> list[ChainMap]:
attributes = {k: set(v) for k, v in attribute_values.items()}
sets = []
for cond in conditions:
restricted = ChainMap({}, attributes)
for item in cond:
allowed = get_allowed(item['path'], item['operator'], item['value'], attributes)
restricted[item['path']] = allowed & restricted[item['path']]
sets.append(restricted)
return sets
Теперь мы могли бы строить искомое множество, удовлетворяющее заданным ограничениям, постепенно пересекая подмножества из отдельных ограничений, пока пересечения не иссякнут:
Attributes = ChainMap[str, set]
def get_intersection(x: Attributes, y: Attributes) -> Attributes:
intersection = ChainMap({}, x.maps[1])
keys = x.maps[0].keys() | y.maps[0].keys()
for k in keys:
intersection[k] = common_items = x[k] & y[k]
if not common_items:
return {}
return intersection
def get_conditioned_subset(attribute_values, conditions):
sets = get_sets(attribute_values, conditions)
answer = []
while sets:
remained_sets = []
subset = sets.pop()
for s in sets:
intersection = get_intersection(subset, s)
if not intersection:
remained_sets.append(s)
else:
subset = intersection
answer.append({key: next(iter(values)) for key, values in subset.items()})
sets = remained_sets
return answer
answer = get_conditioned_subset(attribute_values, conditions)
Я не уверен, будет ли это минимальное множество объектов, покрывающее заданные условия. Но наверное это можно было бы использовать как первое приближение к ответу. Как минимум, для случая пересекающихся условий этот подход вернет набор из одного объекта, а для попарно пересекающихся условий количество объектов будет не более половины количества условий.
Немного отредактировав ответ @vitalizzare для меня сработало следующее решение
from typing import ChainMap
from operator import lt, le, gt, ge, ne
attribute_values = {
"object.object.value": ["value1", "value2", "value3"],
"object.other.value": ["value4", "value5", "value6"],
"object.another.value": [10, 20, 30]
}
conditions = [
# Condition 1
[{"path": "object.object.value", "operator": "==", "value": "value1"}],
# Condition 2
[{"path": "object.other.value", "operator": "in", "value": ["value5", "value6"]}],
# Condition 3
[{"path": "object.another.value", "operator": ">", "value": 15}],
# Condition 4
[{"path": "object.object.value", "operator": "==", "value": "value2"},
{"path": "object.another.value", "operator": "<", "value": 25}]
]
T = TypeVar('T')
def get_allowed(path: str, op: str, value: Union[T, List[T]],
attribute_values: Dict[str, Iterable]) -> Set[T]:
if op == '==':
return {value}
elif op == 'in':
return set(value)
from operator import lt, le, gt, ge
condition = (lt if op == '<' else
le if op == '<=' else
gt if op == '>' else
ge if op == '>=' else
ne if op == '!=' else
None)
if condition is None:
raise ValueError(f'Unknown operator: {op!r}')
return {x for x in attribute_values[path] if condition(x, value)}
from collections import ChainMap as CollectionsChainMap
def get_sets(attribute_values: Dict[str, List[T]], conditions: List[List[Dict]]) -> List[CollectionsChainMap]:
attributes = {k: set(v) for k, v in attribute_values.items()}
sets = []
for cond in conditions:
restricted = CollectionsChainMap({}, attributes)
for item in cond:
allowed = get_allowed(item['path'], item['operator'], item['value'], attributes)
restricted[item['path']] = allowed & restricted[item['path']]
sets.append(restricted)
return sets
Attributes = ChainMap[str, set]
def get_intersection(x: Attributes, y: Attributes) -> Attributes:
intersection = {}
keys = set(x.keys()) & set(y.keys())
for k in keys:
intersection[k] = x[k] & y[k]
if not intersection[k]:
return {}
return intersection
def get_conditioned_subset(attribute_values, conditions):
sets = get_sets(attribute_values, conditions)
answer = []
while sets:
remained_sets = []
subset = sets.pop()
for s in sets:
intersection = get_intersection(subset, s)
if not intersection:
remained_sets.append(s)
else:
subset = intersection
try:
answer.append({key: next(iter(values)) for key, values in subset.items()})
except StopIteration:
break
sets = remained_sets
return answer
answer = get_conditioned_subset(attribute_values, conditions)