- PVSM.RU - https://www.pvsm.ru -
Представим типичную ситуацию на первом курсе: вы прочитали про алгоритм Диница [1], реализовали, а он не заработал, и вы не знаете, почему. Стандартное решение — это начать отлаживать по шагам, каждый раз рисуя текущее состояние графа на листочке, но это жутко неудобно. Я попробовала исправить положение в рамках семестрового проекта по Software Engineering, а в посте расскажу, как у меня в итоге получился плагин для Visual Studio. Скачать можно тут [2], исходный код и документацию можно посмотреть тут [3]. Вот скриншот графа, который получился для алгоритма Диница.

Меня зовут Ольга, я закончила третий курс направления «Прикладная математика и информатика» в Питерской Вышке по специализации Software Engineering. До поступления в университет я не занималась программированием.
На нашем факультете практикуются ежесеместровые научно-исследовательские работы. Обычно это происходит так: в начале семестра происходит презентация НИРов — представители разных компаний предлагают всевозможные проекты. Потом студенты выбирают понравившиеся проекты, научные руководители выбирают понравившихся студентов и так далее. В конце концов, каждый студент находит себе проект.
Но есть и другой путь: вы можете самостоятельно найти научного руководителя и проект, а потом убедить куратора, что этот проект действительно может быть полноценным НИРом. Для этого нужно доказать, что:
Я выбрала второй путь. Первое, что нужно было сделать после того, как я договорилась с научным руководителем — это найти тему проекта. В списке идей были: code style checker для Lua, отладчик, позволяющий вычислять выражения по частям, и тул для олимпиадников / обучения программированию, который позволяет визуализировать, что происходит в коде. То есть визуализатор для произвольных структур данных, совмещённый с отладчиком. Например, пишет человек Декартово дерево [4], и в процессе рисуются вершины, рёбра, текущая вершина и прочее. В итоге я решила остановиться именно на этом варианте.
Моя работа над проектом состояла из следующих этапов:
Планирование я всегда осуществляла вместе со своим научным руководителем. Все возникающие по ходу дела идеи мы также всегда придумывали и обсуждали вместе. Помимо этого, научный руководитель давал мне советы по коду и помогал с time management'ом. Про все технические проблемы (непонятные баги и прочее) я тоже всегда докладывала, но чаще всего удавалось решить их самостоятельно.
Для начала, следовало убедить наше руководство, что эта тема достойна того, чтобы быть моим НИРом. И начать следовало с первого пункта: поиск аналогов.
Как оказалось, аналогов много. Но большинство из них были рассчитаны на визуализацию памяти. То есть они бы прекрасно справились с визуализацией Декартова дерева, но то, что кучу на массиве [5] надо рисовать не как массив, а как дерево, они понять не могут. Среди этих аналогов были gdbgui [6], Data Display Debugger [7], плагин для Eclipse jGRASP [8] и плагин под Visual Studio Data Structures Visualizer [9]. Последний тоже создавался для задач олимпиадного программирования, но умел визуализировать только структуры данных на указателях.
Было ещё несколько тулов: Algojammer [10] для питона (а мы хотели плюсы, как наиболее популярный язык у олимпиадников) и разработанный в 1994 году тул Lens [11]. Последний, судя по описанию, делал почти то, что нужно, но, к сожалению, создавался под Sun OS 4.1.3 (операционную систему родом из 1992 года). Так что, несмотря на наличие исходников, было решено не тратить время на эту сомнительную археологию.
Итак, после некоторого исследования было установлено, что тула, который бы делал ровно то, чего нам хотелось, и при этом работал бы на современных машинах, в природе пока нет.
Вторым шагом нужно было доказать, что это задача достаточно сложная, но выполнимая. Для этого требовалось предложить что-то более конкретное, чем «хочу, чтобы была красивая картинка, и чтобы из неё всё сразу стало понятно».
В этом проекте мы решили сконцентрироваться на визуализации только графов: существует достаточно много алгоритмов на графах, которые могут быть по-разному реализованы, и даже будучи суженной, задача всё ещё остается нетривиальной.
Также более-менее очевидно, что тул должен быть как-то интегрирован с отладчиком. Нам надо уметь просматривать значения переменных и выражений, и по этим значениям рисовать готовую картинку.
После этого нужно было придумать некий язык, позволяющий по текущему состоянию программы строить граф так, как мы захотим. Кроме самого графа нужно было предусмотреть возможность менять цвет вершин и рёбер, добавлять к ним произвольные надписи и изменять прочие свойства. Соответственно, первой идеей было:
Прототип пользовательского интерфейса я нарисовала в NinjaMock [12]. Это требовалось для лучшего понимания, как интерфейс будет выглядеть с точки зрения пользователя, и какие действия ему потребуется произвести. Если бы возникли проблемы с прототипом, мы бы поняли, что есть какие-то логические нестыковки, и эту идею следует отбросить. Для верности я взяла несколько алгоритмов. На картинке ниже примеры того, как я представляла себе настройки визуализации DFS [13] и алгоритма Флойда [14].

Как я себе представляла настройки для DFS. Граф хранится как список смежности, поэтому ребро между вершинами i и j определяется условием g[i].find() != g[i].end() (на самом деле, чтобы не дублировать рёбра, надо проверить, что i <= j). Отдельно показан путь DFS: p[j] == i. Эти рёбра будут ориентированными.

Я предполагала, что для алгоритма Флойда потребуется нарисовать чёрным настоящие рёбра, хранящиеся в массиве c, а серым — найденные на данном этапе кратчайшие пути, хранящиеся в массиве d. Для каждого ребра и кратчайшего пути написан его вес.
Для следующего шага нужно было понять, как именно это всё делать. В первую очередь, требовалась интеграция с отладчиком. Первое, что приходит в голову при слове «отладчик» — это gdb, но тогда пришлось бы создавать весь графический интерфейс с нуля, что действительно сложно сделать студенту за семестр. Вторая очевидная идея — сделать плагин к какой-нибудь существующей среде разработки. В качестве вариантов я рассматривала QTCreator, CLion и Visual Studio.
Вариант с CLion отбросили почти сразу, потому что у него, во-первых, закрытый исходный код, а во-вторых, всё очень плохо с документацией (а дополнительные сложности никому не нужны). QTCreator, в отличие от Visual Studio, кроссплатформенный и имеет открытый исходный код, и поэтому вначале мы решили остановиться на нём.
Оказалось, однако, что QTCreator плохо приспособлен для расширения при помощи плагинов. Первый шаг при создании плагина для QTCreator — это сборка из исходников. У меня это заняло полторы недели. В итоге я отправила два баг-репорта (вот [15] и вот [16]), касающиеся процесса сборки. Да, вот столько усилий было потрачено на сборку QTCreator только для того, чтобы обнаружить, что отладчик в QTCreator не имеет публичного API. Я вернулась к другому варианту, а именно к Visual Studio.
И это оказалось верным решением: у Visual Studio не просто прекрасное API, но и отличная документация к нему. Вычисление выражений упрощалось до вызова _debugger.GetExpression(...).Value. Также Visual Studio предоставляет возможность итерироваться по стекфреймам, и вычислять выражение в контексте любого из них. Для этого надо поменять property CurrentStackFrame на требуемый. Ещё можно отслеживать обновления контеста отладчика, чтобы при изменении перерисовывать картинку.
Разумеется, не предполагалось, что я буду заниматься визуализацией графов с нуля — для этого есть куча специальных библиотек. Самая знаменитая из них — Graphviz [17], и именно её мы и планировали вначале использовать. Но для плагина под Visual Studio логичнее было бы использовать библиотеку для C#, так как я собиралась писать именно на нём. Я немножко погуглила и нашла библиотеку MSAGL [18]: она обладала всей требующейся функциональностью и имела простой и понятный интерфейс.
Теперь, имея механизм для вычисления произвольных выражений с одной стороны и библиотеку для визуализации графов с другой, требовалось сделать прототип. Первый прототип был сделан для DFS, потом в качестве примеров были взяты алгоритм Диница, алгоритм Куна [19], поиск компонент двусвязности [20], Декартово дерево, СНМ [21]. Я брала свои старые реализации этих алгоритмов с первого-второго курса, создавала новый плагин, в нём рисовала граф, соответствующий этой задаче (все имена переменных были захардкожены). Вот пример графа, который у меня получился для алгоритма Куна:

На этом графе текущие рёбра паросочетания показаны фиолетовым, текущая вершина dfs — красным, посещенные вершины — серым, ребра чередующейся цепи не из паросочетания — красным.
Я считала допустимым немного менять код алгоритма, чтобы было проще визуализировать. Например, в случае Декартового дерева оказалось, что проще складывать все созданные node’ы в вектор, чем обходить дерево внутри плагина.
Неприятным открытием стало то, что отладчик в Visual Studio не поддерживает вызов методов и функций из STL. Это означало, что проверить наличие элемента в контейнере при помощи std::find, как предполагалось изначально, было нельзя. Эту проблему можно решить либо созданием пользовательской функции, либо дублированием свойства «элемент содержится в контейнере» в булевском массиве.
В моих пробных плагинах происходило примерно следующее (если граф хранился как список смежности):
for от 0 до _debugger.GetExpression("n").Value, который добавлял в граф все вершины, каждую со своим номером.
for, первый по i — от 0 до n, второй по j — от 0 до _debugger.GetExpression($"g[{i}].size()").Value, и в граф добавлялось ребро {i, _debugger.GetExpression($"g[{i}][{j}]").Value}.
d, отвечающего за расстояние до выделенной вершины.
stackFrame.FunctionName.Equals("dfs") && stackFrame.Arguments.Item(1) == v) подсвечивались серым.
i от 0 до n, обозначающего номера вершин, проверялись некоторые условия, и если они выполнялись, то у вершины менялись какие-то свойства, чаще всего цвет.
На тот момент я писала код «как придётся», не пытаясь ни придумать общую схему для всех алгоритмов, ни писать код хоть сколько-то красиво. Создание каждого нового плагина начиналось с копипаста предыдущего.
После проведённых исследований нужно было составить общую схему, которую можно было бы применить для всех алгоритмов. Первое, что было введено — это индексы для вершин и рёбер. У каждого индекса есть уникальное имя и концы диапазона, вычисляемые при помощи _debugger.GetExpression. Для обращения к значению индекса используют его имя, окружённое __ (то есть __x__). Выражения с местами для подстановки значений индексов, а также имени текущей функции (__CURRENT_FUNCTION__) и значений её аргументов (__ARG1__, __ARG2__, …), называются шаблонами.
Для каждой вершины или ребра подставляются значения индексов, и после этого оно вычисляется в отладчике. Шаблоны использовали, чтобы отфильтровать некоторые значения индексов (если граф хранится как матрица смежности c, то индексы будут a и b от 0 до n, а шаблон для валидации c[__a__][__b__]). Границы диапазона индекса тоже являются шаблонами, так как могут содержать предыдущие индексы.
В графе могут быть разные типы вершин и рёбер. Например, в случае поиска максимального паросочетания в двудольном графе каждая доля может индексироваться отдельно. Поэтому было введено понятие семейства для вершин и рёбер. Для каждого семейства индексация и все свойства определяются независимо. При этом ребра могут соединять вершины из разных семейств.
Семейству вершин или рёбер можно приписать определённые свойства, которые будут выборочно применяться к элементам в семействе. Свойство применяется, если выполняется условие. Условие состоит из шаблона, который вычисляется в true или false, и регулярного выражения для имени функции. Условие проверяется либо только для текущего стекфрейма, либо для всех стекфреймов (и тогда оно считается выполненным, если оно выполнено хотя бы для одного).
Свойства бывают самые разнообразные. Для вершин: лейбл, цвет заливки, цвет границы, ширина границы, форма, стиль границы (например, пунктир). Для рёбер: лейбл, цвет, ширина, стиль, ориентация (можно задать две стрелки — от начала в конец или наоборот; при этом могут быть две стрелки одновременно).
Важно, что каждый раз граф рисуется с нуля, и предыдущее состояние никак не учитывается. Это может быть проблемой, если граф динамически меняется в процессе алгоритма — вершины и рёбра могут резко менять своё положение, и тогда сложно понять, что происходит.
Подробное описание конфигурации графа можно почитать тут [22].
С интерфейсом я решила особо не заморачиваться. Основное окно с настройками (ToolWindow [23]) содержит textarea для сериализованного в JSON конфига и список семейств вершин и рёбер. Каждого семейству соответствует своё окошко с настройками, и каждому свойству в семействе — ещё одно окошко (получается три уровня вложенности). Сам граф рисуется в отдельном окошке. Поместить его в ToolWindow не получилось, поэтому я отправила bug report [24] разработчикам MSAGL, но мне ответили, что это не является целевым сценарием использования. Ещё один bug report [25] был отправлен в Visual Studio, так как в дополнительных окошках с настройками иногда подвисали TextBox’ы.

Для того, чтобы задать конфигурацию графа, плагин имеет пользовательский интерфейс и возможность (де)сериализации конфига в JSON. Общая схема взаимодействия всех компонент выглядит следующим образом:

Голубым показаны компоненты, которые существовали исходно, серым — которые создала я. При старте Visual Studio расширение инициализируется (здесь основной компонент обозначен как Main). Пользователь получает возможность задать конфигурацию через интерфейс. При каждом изменении контекста отладчика основной компонент получает уведомление. Если конфигурация определена и отлаживаемая программа выполняется, запускается GraphRenderer. Он получает на вход конфиг и при помощи отладчика строит по нему граф, который после этого отображается в специальном окошке.
В итоге я создала инструмент, позволяющий визуализировать графовые алгоритмы и требующий небольших изменений в коде. Он был опробован на восьми различных задачах. Вот несколько получившихся картинок:

Алгоритм Форда-Беллмана [26]: вершина, до которой мы считаем кратчайшие пути, обозначена домиком, текущее найденное кратчайшое расстояние для вершин равно d, красным обозначено ребро, вдоль которого проходит релаксация.

Анимация с DFS — красным показана текущая вершина, серым — вершины в стеке, зелёным — прочие посещённые вершины. Малиновые рёбра показывают направление обхода.
Больше примеров алгоритмов доступно тут [27]
На защите НИРов студентам требуется за семь минут рассказать о своей работе за семестр. При этом, вне зависимости от того, была ли тема предложена в рамках презентации НИРов или студент нашёл её самостоятельно, требуется уметь отвечать обосновывать, почему тема проекта попадает под требования, описанные вначале. Обычно доклад строится следующим образом: сначала идёт мотивация, после этого — обзор аналогов, говорится про то, почему они нас не устраивают, потом формулируются цели и задачи, и дальше про каждую из задач рассказывается, как она была решена. В конце идёт слайд с результатами, где ещё раз говорится, что поставленная цель достигнута и все задачи решены.
Так как с мотивацией и обзором аналогов я определилась уже на начальном этапе, мне требовалось просто собрать всю информацию воедино и сжать до семи минут. В итоге мне это удалось, защита прошла гладко, за НИР мне поставили максимальный балл.
Автор: hse_spb
Источник [29]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/325161
Ссылки в тексте:
[1] про алгоритм Диница: https://e-maxx.ru/algo/dinic
[2] тут: https://marketplace.visualstudio.com/items?itemName=OlgaLupuleac.graphrenderer2019
[3] тут: https://github.com/olgalupuleac/GraphAlgorithmRenderer
[4] Декартово дерево: https://e-maxx.ru/algo/treap
[5] кучу на массиве: https://neerc.ifmo.ru/wiki/index.php?title=%D0%94%D0%B2%D0%BE%D0%B8%D1%87%D0%BD%D0%B0%D1%8F_%D0%BA%D1%83%D1%87%D0%B0
[6] gdbgui: https://www.gdbgui.com/
[7] Data Display Debugger: https://www.gnu.org/software/ddd/
[8] jGRASP: https://www.jgrasp.org/eclipse_plugin.html
[9] Data Structures Visualizer: https://marketplace.visualstudio.com/items?itemName=EvgeniyAkimov.DataStructuresVisualizer
[10] Algojammer: https://github.com/ChrisKnott/Algojammer
[11] Lens: https://www.cc.gatech.edu/gvu/ii/softvis/visdebug/visdebug.html
[12] NinjaMock: https://ninjamock.com/
[13] DFS: https://e-maxx.ru/algo/dfs
[14] алгоритма Флойда: https://e-maxx.ru/algo/floyd_warshall_algorithm
[15] вот: https://bugreports.qt.io/browse/QTCREATORBUG-22101
[16] вот: https://bugreports.qt.io/browse/QTCREATORBUG-22103
[17] Graphviz: https://www.graphviz.org/
[18] MSAGL: https://github.com/Microsoft/automatic-graph-layout
[19] алгоритм Куна: https://e-maxx.ru/algo/kuhn_matching
[20] поиск компонент двусвязности: https://neerc.ifmo.ru/wiki/index.php?title=%D0%9F%D0%BE%D1%81%D1%82%D1%80%D0%BE%D0%B5%D0%BD%D0%B8%D0%B5_%D0%BA%D0%BE%D0%BC%D0%BF%D0%BE%D0%BD%D0%B5%D0%BD%D1%82_%D1%80%D0%B5%D0%B1%D0%B5%D1%80%D0%BD%D0%BE%D0%B9_%D0%B4%D0%B2%D1%83%D1%81%D0%B2%D1%8F%D0%B7%D0%BD%D0%BE%D1%81%D1%82%D0%B8
[21] СНМ: https://e-maxx.ru/algo/dsu
[22] тут: https://github.com/olgalupuleac/GraphAlgorithmRenderer#glossary
[23] ToolWindow: https://docs.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.modeling.shell.toolwindow
[24] bug report: https://github.com/microsoft/automatic-graph-layout/issues/204
[25] Ещё один bug report: https://developercommunity.visualstudio.com/content/problem/627810/implementing-a-vs-extension-text-is-sometimes-type.html
[26] Алгоритм Форда-Беллмана: https://e-maxx.ru/algo/ford_bellman
[27] тут: https://github.com/olgalupuleac/GraphAlgorithmRenderer/tree/master/GraphAlgorithmRenderer/Samples
[28] Пост на Codeforces: https://codeforces.com/blog/entry/67116
[29] Источник: https://habr.com/ru/post/461473/?utm_campaign=461473&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.