Эффективная реализация дерева в MATLAB
Класс дерева в MATLAB
Я внедряю структуру данных дерева в MATLAB. Добавление новых дочерних узлов в дерево, назначение и обновление значений данных, связанных с узлами, являются типичными операциями, которые я ожидаю выполнить. Каждый node имеет тот же тип data
, который связан с ним. Удаление узлов не требуется для меня. До сих пор я решил реализовать класс, наследующий класс handle
, чтобы иметь возможность передавать ссылки на узлы вокруг функций, которые будут изменять дерево.
Изменить: 2 декабря
Прежде всего, спасибо за все предложения в комментариях и ответах до сих пор. Они уже помогли мне улучшить мой древовидный класс.
Кто-то предложил попробовать digraph
, представленный в R2015b. Мне еще предстоит изучить это, но, поскольку он не работает как ссылочный параметр аналогично классу, наследующему от handle
, я немного скептически отношусь к тому, как он будет работать в моем приложении. На этом этапе еще не ясно, насколько легко будет работать с ним, используя пользовательский data
для узлов и ребер.
Изменить: (3 декабря) Дополнительная информация о главном приложении: MCTS
Вначале я предположил, что детали основного приложения будут иметь только предельный интерес, но, читая комментарии и ответ от @FirefoxMetzger, я понимаю, что это имеет важные последствия.
Я реализую алгоритм алгоритм поиска по дереву Монте-Карло. Дерево поиска исследуется и расширяется итеративно. Википедия предлагает приятный графический обзор процесса:
![Поиск по дереву Монте-Карло]()
В моем приложении я выполняю большое количество поисковых итераций. На каждой итерации поиска я просматриваю текущее дерево, начиная с корня до листа node, затем разворачиваю дерево, добавляя новые узлы и повторяя. Поскольку метод основан на случайной выборке, в начале каждой итерации я не знаю, какой лист node я завершу на каждой итерации. Вместо этого это определяется совместно data
узлов, находящихся в настоящее время в дереве, и результатов случайных выборок. Независимо от того, какие узлы я посещаю во время одной итерации, обновляется их data
.
Пример. Я нахожусь в node n
, у которого есть несколько детей. Мне нужно получить доступ к данным у каждого из детей и нарисовать случайную выборку, которая определяет, какой из детей я перехожу к следующему в поиске. Это повторяется до тех пор, пока не будет достигнут лист node. Практически я делаю это, вызывая функцию search
в корне, которая решит, какой дочерний объект будет расширяться дальше, вызовите search
на этом node рекурсивно и т.д., Наконец, вернув значение, как только лист node будет достиг. Это значение используется при возврате из рекурсивных функций для обновления data
узлов, посещаемых во время итерации поиска.
Дерево может быть довольно неуравновешенным, так что некоторые ветки являются очень длинными цепочками узлов, а другие быстро заканчиваются после уровня корня и не расширяются дальше.
Текущая реализация
Ниже приведен пример моей текущей реализации с примером нескольких функций-членов для добавления узлов, запросов к глубине или количеству узлов в дереве и т.д.
classdef stree < handle
% A class for a tree object that acts like a reference
% parameter.
% The tree can be traversed in both directions by using the parent
% and children information.
% New nodes can be added to the tree. The object will automatically
% keep track of the number of nodes in the tree and increment the
% storage space as necessary.
properties (SetAccess = private)
% Hold the data at each node
Node = { [] };
% Index of the parent node. The root of the tree as a parent index
% equal to 0.
Parent = 0;
num_nodes = 0;
size_increment = 1;
maxSize = 1;
end
methods
function [obj, root_ID] = stree(data, init_siz)
% New object with only root content, with specified initial
% size
obj.Node = repmat({ data },init_siz,1);
obj.Parent = zeros(init_siz,1);
root_ID = 1;
obj.num_nodes = 1;
obj.size_increment = init_siz;
obj.maxSize = numel(obj.Parent);
end
function ID = addnode(obj, parent, data)
% Add child node to specified parent
if obj.num_nodes < obj.maxSize
% still have room for data
idx = obj.num_nodes + 1;
obj.Node{idx} = data;
obj.Parent(idx) = parent;
obj.num_nodes = idx;
else
% all preallocated elements are in use, reserve more memory
obj.Node = [
obj.Node
repmat({data},obj.size_increment,1)
];
obj.Parent = [
obj.Parent
parent
zeros(obj.size_increment-1,1)];
obj.num_nodes = obj.num_nodes + 1;
obj.maxSize = numel(obj.Parent);
end
ID = obj.num_nodes;
end
function content = get(obj, ID)
%% GET Return the contents of the given node IDs.
content = [obj.Node{ID}];
end
function obj = set(obj, ID, content)
%% SET Set the content of given node ID and return the modifed tree.
obj.Node{ID} = content;
end
function IDs = getchildren(obj, ID)
% GETCHILDREN Return the list of ID of the children of the given node ID.
% The list is returned as a line vector.
IDs = find( obj.Parent(1:obj.num_nodes) == ID );
IDs = IDs';
end
function n = nnodes(obj)
% NNODES Return the number of nodes in the tree.
% Equal to root + those whose parent is not root.
n = 1 + sum(obj.Parent(1:obj.num_nodes) ~= 0);
assert( obj.num_nodes == n);
end
function flag = isleaf(obj, ID)
% ISLEAF Return true if given ID matches a leaf node.
% A leaf node is a node that has no children.
flag = ~any( obj.Parent(1:obj.num_nodes) == ID );
end
function depth = depth(obj,ID)
% DEPTH return depth of tree under ID. If ID is not given, use
% root.
if nargin == 1
ID = 0;
end
if obj.isleaf(ID)
depth = 0;
else
children = obj.getchildren(ID);
NC = numel(children);
d = 0; % Depth from here on out
for k = 1:NC
d = max(d, obj.depth(children(k)));
end
depth = 1 + d;
end
end
end
end
Однако производительность порой медленная, причем операции над деревом занимают большую часть моего времени вычисления. Какие конкретные способы могли бы сделать реализацию более эффективной? Было бы возможно изменить реализацию на что-то еще, чем тип наследования handle
, если есть прирост производительности.
Результаты профилирования с текущей реализацией
Поскольку добавление новых узлов в дерево является наиболее типичной операцией (наряду с обновлением data
node), я сделал несколько профилирование об этом.
Я выполнил профайлер по следующему эталонному коду с Nd=6, Ns=10
.
function T = benchmark(Nd, Ns)
% Tree benchmark. Nd: tree depth, Ns: number of nodes per layer
% Initialize tree
T = stree(rand, 10000);
add_layers(1, Nd);
function add_layers(node_id, num_layers)
if num_layers == 0
return;
end
child_id = zeros(Ns,1);
for s = 1:Ns
% add child to current node
child_id(s) = T.addnode(node_id, rand);
% recursively increase depth under child_id(s)
add_layers(child_id(s), num_layers-1);
end
end
end
Результаты профилирования:
![Результаты профилирования]()
Производительность R2015b
Было обнаружено, что R2015b улучшает производительность функций MATLAB OOP. Я перечеркнул вышеупомянутый бенчмарк и действительно наблюдал улучшение производительности:
![Результат профилирования R2015b]()
Итак, это уже хорошая новость, хотя, конечно, принимаются и другие улучшения;)
Резервирование памяти по-разному
В комментариях также было предложено использовать
obj.Node = [obj.Node; data; cell(obj.size_increment - 1,1)];
зарезервировать больше памяти, чем текущий подход, с помощью repmat
. Это немного улучшило производительность. Я должен отметить, что мой контрольный код предназначен для фиктивных данных, и поскольку фактический data
более сложный, это, скорее всего, поможет. Благодарю! Результаты профилировщика ниже:
![стиль резервной копии zeeMonkeez]()
Вопросы по еще большему повышению производительности
- Возможно, существует альтернативный способ сохранить память для дерева, которое более эффективно? К сожалению, я обычно не знаю заранее, сколько узлов будет в дереве.
- Добавление новых узлов и изменение
data
существующих узлов являются наиболее типичными операциями, которые я выполняю на дереве. На данный момент они фактически занимают большую часть времени обработки моего основного приложения. Любые улучшения в отношении этих функций будут приветствоваться.
Как последнее замечание, я бы идеально хотел, чтобы реализация была чистой MATLAB. Однако такие варианты, как MEX или использование некоторых встроенных функций Java, могут быть приемлемыми.
Ответы
Ответ 1
TL: DR. Вы полностью скопируете все данные, хранящиеся в каждой вставке, инициализируйте ячейку parent
и Node
больше, чем вы ожидаете.
У ваших данных есть древовидная структура, однако вы не используете это в своей реализации. Вместо этого реализованный код представляет собой вычислительную голодную версию таблицы поиска (фактически 2 таблицы), которая хранит данные и реляционные данные для дерева.
Причины, о которых я говорю, следующие:
- Чтобы вставить вам вызов stree.addnote(parent, data), который будет хранить все данные в полях дерева
stree
Node = {}
и Parent = []
- вам, кажется, известно, какой элемент в вашем дереве вы хотите получить, поскольку код поиска не указан (если вы используете stree.getchild(ID) для него, у меня есть плохие новости)
- После того, как вы обработали node, вы трассируете его с помощью
find()
, который является поиском по списку
Ни в коем случае это не означает, что реализация является неуклюжей для данных, она может быть даже самой лучшей, в зависимости от того, что вы делаете. Однако он объясняет проблемы с распределением памяти и дает подсказки о том, как их разрешать.
Хранить данные в виде таблицы поиска
Одним из способов хранения данных является сохранение базовой таблицы поиска. Я бы сделал это, только если вы знаете ID
первого элемента, который вы хотите изменить , не ища его. Этот случай позволяет сделать вашу структуру более эффективной в два этапа.
Сначала инициализируйте массивы больше, а затем ожидайте, что вам нужно сохранить данные. Если превышена пропускная способность таблицы вверх, будет инициализирована новая, которая больше X-полей и будет сделана глубокая копия старых данных. Если вам нужно увеличить количество экземпляров один или два раза (во время всех вставок), это может не быть проблемой, но в вашем случае глубокая копия будет сделана навсегда!
Во-вторых, я бы изменил внутреннюю структуру и объединил две таблицы Node
и parent
. Причина этого в том, что обратное распространение в вашем коде принимает O (depth_from_root * n), где n - количество узлов в вашей таблице. Это потому, что find() будет перебирать всю таблицу для каждого родителя.
Вместо этого вы можете реализовать что-то похожее на
table = cell(n,1) % n bigger then expected value
end_pointer = 1 % simple pointer to the first free value
function insert(data,parent_ID)
if end_pointer < numel(table)
content.data = data;
content.parent = parent_ID;
table{end_pointer} = content;
end_pointer = end_pointer + 1;
else
% need more space, make sure its enough this time
table = [table cell(end_pointer,1)];
insert(data,parent_ID);
end
end
function content = get_value(ID)
content = table(ID);
end
Это мгновенно дает вам доступ к родительскому ID
без необходимости find()
, сначала, сохраняя n итераций на каждом шаге, поэтому вы можете получить O (глубина). Если вы не знаете свой начальный node, тогда вы должны find()
указать тот, который стоит O (n).
Обратите внимание, что эта структура не нуждается в is_leaf()
, depth()
, nnodes()
или get_children()
. Если вам все еще нужны те, мне нужно больше понять, что вы хотите делать с вашими данными, так как это очень влияет на правильную структуру.
Структура дерева
Эта структура имеет смысл, если вы никогда не знаете первый node ID
и, следовательно, всегда должны искать для него.
Преимущество состоит в том, что поиск произвольной ноты работает с O (глубина), поэтому поиск O (глубина) вместо O (n), а обратное распространение - O (глубина ^ 2) вместо O (глубина + n). Обратите внимание, что глубина может быть чем угодно: от log (n) для идеально сбалансированного дерева, которое может быть возможно в зависимости от ваших данных, до n для вырожденного дерева, которое является связанным списком.
Однако, чтобы предложить что-то надлежащее, мне потребовалось бы более глубокое понимание, так как каждый вид древовидной структуры имеет свой собственный nich. Из того, что я вижу до сих пор, я предлагаю несимметричное дерево, которое "сортируется" по простому порядку, заданному узлом, которым нужен родитель. Это может быть дополнительно оптимизировано в зависимости от
- Можно ли определить общий порядок ваших данных.
- как вы относитесь к двойным значениям (одни и те же данные появляются дважды)
- каков масштаб ваших данных (тысячи, миллионы,...)
- - поиск/поиск всегда в паре с обратным распространением
- как долго цепочки "родитель-ребенок" на ваших данных (или насколько сбалансированы и глубоки будут использовать этот простой порядок).
- всегда есть только один родитель или один и тот же элемент, вставленный дважды с разными родителями.
Я с удовольствием предоставил бы пример кода для вышеупомянутого дерева, просто оставьте комментарий.
EDIT:
В вашем случае оптимальным вариантом является неуравновешенное дерево (то есть параллельное исполнение для MCTS). В приведенном ниже коде предполагается, что данные разделены на state
и score
, а кроме того, a state
является уникальным. Если это не так, это все равно будет работать, однако есть возможная оптимизация для повышения производительности MCTS.
classdef node < handle
% A node for a tree in a MCTS
properties
state = {}; %some state of the search space that identifies the node
score = 0;
childs = cell(50,1);
num_childs = 0;
end
methods
function obj = node(state)
% for a new node simulate a score using MC
obj.score = simulate_from(state); % TODO implement simulation state -> finish
obj.state = state;
end
function value = update(obj)
% update the this node using MC recursively
if obj.num_childs == numel(obj.childs)
% there are to many childs, we have to expand the table
obj.childs = [obj.childs cell(obj.num_childs,1)];
end
if obj.do_exploration() || obj.num_childs == 0
% explore a potential state
state_to_explore = obj.explore();
%check if state has already been visited
terminate = false;
idx = 1;
while idx <= obj.num_childs && ~terminate
if obj.childs{idx}.state_equals(state_to_explore)
terminate = true;
end
idx = idx + 1;
end
%preform the according action based on search
if idx > obj.num_childs
% state has never been visited
% this action terminates the update recursion
% and creates a new leaf
obj.num_childs = obj.num_childs + 1;
obj.childs{obj.num_childs} = node(state_to_explore);
value = obj.childs{obj.num_childs}.calculate_value();
obj.update_score(value);
else
% state has been visited at least once
value = obj.childs{idx}.update();
obj.update_score(value);
end
else
% exploit what we know already
best_idx = 1;
for idx = 1:obj.num_childs
if obj.childs{idx}.score > obj.childs{best_idx}.score
best_idx = idx;
end
end
value = obj.childs{best_idx}.update();
obj.update_score(value);
end
value = obj.calculate_value();
end
function state = explore(obj)
%select a next state to explore, that may or may not be visited
%TODO
end
function bool = do_exploration(obj)
% decide if this node should be explored or exploited
%TODO
end
function bool = state_equals(obj, test_state)
% returns true if the nodes state is equal to test_state
%TODO
end
function update_score(obj, value)
% updates the score based on some value
%TODO
end
function calculate_value(obj)
% returns the value of this node to update previous nodes
%TODO
end
end
end
Несколько комментариев к коду:
- в зависимости от настройки
obj.calculate_value()
может и не понадобиться. Например. если это какое-то значение, которое может быть вычислено путем оценки только дочерних показателей
- Если a
state
может иметь несколько родителей, имеет смысл повторно использовать объект примечания и покрыть его в структуре
- поскольку каждый
Node
знает все его дочерние элементы, поддерево может быть легко сгенерировано с помощью Node
как root node
- поиск дерева (без какого-либо обновления) - это простой рекурсивный жадный поиск
- в зависимости от фактора разветвления вашего поиска, возможно, стоит посетить каждый возможный дочерний один раз (после инициализации node), а затем выполнить
randsample(obj.childs,1)
для исследования, поскольку это позволяет избежать копирования/перераспределения дочернего массива
- свойство
parent
кодируется, когда дерево обновляется рекурсивно, передавая value
родительскому элементу после завершения обновления для node
- Единственное время, когда я перераспределяю память, - это когда один node имеет более 50 дочерних элементов, я только перераспределяю для этого отдельного node
Это должно работать намного быстрее, поскольку оно просто беспокоится о том, какая часть дерева выбрана и не трогает ничего.
Ответ 2
Я знаю, что это может показаться глупым... но как насчет сохранения количества свободных узлов вместо общего количества узлов? Это потребует сравнения с константой (которая равна нулю), которая представляет собой доступ к одному свойству.
Еще одно улучшение voodoo будет перемещаться .maxSize
рядом с .num_nodes
и помещать их перед ячейкой .Node
. Таким образом, их положение в памяти не изменится относительно начала объекта из-за роста свойства .Node
(вуду здесь, когда я угадываю внутреннюю реализацию объектов в MATLAB).
Позднее редактирование. Когда я профилировал с .Node
, перемещенным в конце списка свойств, основная часть времени выполнения была потреблена путем расширения свойства .Node
, как и ожидалось (5.45 секунды, по сравнению с 1,25 с для сравнения, о котором вы упомянули).
Ответ 3
Вы можете попытаться выделить несколько элементов, которые пропорциональны количеству фактически заполненных элементов: это стандартная реализация для std::vector в С++
obj.Node = [obj.Node; data; cell(q * obj.num_nodes,1)];
Я точно не помню, но в MSCC q
равен 1, а для GCC - 0,75.
Это решение с использованием Java. Мне это не очень нравится, но он выполняет свою работу. Я реализовал пример, извлеченный из википедии.
import javax.swing.tree.DefaultMutableTreeNode
% Let create our example tree
top = DefaultMutableTreeNode([11,21])
n1 = DefaultMutableTreeNode([7,10])
top.add(n1)
n2 = DefaultMutableTreeNode([2,4])
n1.add(n2)
n2 = DefaultMutableTreeNode([5,6])
n1.add(n2)
n3 = DefaultMutableTreeNode([2,3])
n2.add(n3)
n3 = DefaultMutableTreeNode([3,3])
n2.add(n3)
n1 = DefaultMutableTreeNode([4,8])
top.add(n1)
n2 = DefaultMutableTreeNode([1,2])
n1.add(n2)
n2 = DefaultMutableTreeNode([2,3])
n1.add(n2)
n2 = DefaultMutableTreeNode([2,3])
n1.add(n2)
n1 = DefaultMutableTreeNode([0,3])
top.add(n1)
% Element to look for, your implementation will be recursive
searching = [0 1 1];
idx = 1;
node(idx) = top;
for item = searching,
% Java transposes the matrices, remember to transpose back when you are reading
node(idx).getUserObject()'
node(idx+1) = node(idx).getChildAt(item);
idx = idx + 1;
end
node(idx).getUserObject()'
% We made a new test...
newdata = [0, 1]
newnode = DefaultMutableTreeNode(newdata)
% ...so we expand our tree at the last node we searched
node(idx).add(newnode)
% The change has to be propagated (this is where your recursion returns)
for it=length(node):-1:1,
itnode=node(it);
val = itnode.getUserObject()'
newitemdata = val + newdata
itnode.setUserObject(newitemdata)
end
% Let see if the new values are correct
searching = [0 1 1 0];
idx = 1;
node(idx) = top;
for item = searching,
node(idx).getUserObject()'
node(idx+1) = node(idx).getChildAt(item);
idx = idx + 1;
end
node(idx).getUserObject()'