пятница, 26 июня 2009 г.

Вариации на тему венгерской теоремы

Статья посвящена методике простого и доступного изложения темы.

Д. Кёниг и Е. Эгервари доказали теорему, названную в их честь как «венгерская теорема». Её можно сформулировать в терминах двоичных матриц, то есть матриц, элементы которых равны 0 или 1. Под линией двоичной матрицы понимается ее строка или столбец.
Теорема. Для произвольной двоичной матрицы максимальная мощность множества (появлений) единиц, из которых никакие две не лежат на одной линии, равна минимальному числу линий, которыми можно покрыть все единицы [1].
Пример. Дана двоичная матрица A[1..6,1..5], её вид – ???. Столбцы и строки пронумерованы. Двойным подчеркиванием выделен вариант (он не единственный) множества единиц максимальной мощности, а символом «*» линии, покрывающие все единицы матрицы. Как максимальная мощность множества единиц, так и минимальное число линий равно 4.
Преобразование двоичной матрицы в двудольный граф. Напомним, что двудольным графом называется граф G=(V,E) такой, что его множество вершин V можно разбить на два подмножества X и Y таких, что каждое ребро из E соединяет вершины из разных подмножеств. Построим двудольный граф G=(X,Y,E), где X={1, 2, 3, 4, 5, 6}, Y={1, 2, 3, 4, 5} и E={(i,j), если соответствующий элемент матрицы A[i,j]=1. Граф имеет вид, представленный на рис. 1. Естественно, что и обратное преобразование – по любому двудольному графу строится единственным образом матрица А, также верно.


Рис. 1. Двудольный граф, соответствующий матрице А

Два понятия – «покрытие» и «независимость».
1. Вершина графа покрывает инцидентные её (связанные с ней) ребра, а ребро графа покрывает инцидентные ему (связанные с ним) вершины.
Естественным образом возникают две задачи: поиск минимального числа вершин, покрывающих все ребра графа G (вершинное покрытие минимальной мощности), это число вершинного покрытия – 0(G); поиск минимального числа ребер (реберное покрытие минимальной мощности), покрывающих все вершины графа G, это число реберного покрытия – 1(G).
2. Множество вершин графа G называют независимым, если никакие две из них не смежны (не связаны между собой). Наибольшее число вершин в независимом множестве называют вершинным числом независимости – 0(G), а соответствующее множество наибольшим. Аналогично, в независимом множестве ребер любая пара ребер не смежна. Наибольшее число ребер в независимом множестве ребер называют реберным числом независимости – 1(G). Для независимого множества ребер двудольного графа используется другой термин – «паросочетание».
На рис. 1 выделены вершины графа из минимального вершинного покрытия и ребра графа из наибольшего паросочетания. Паросочетание M графа G является наибольшим (максимальным по количеству ребер), если для любого другого паросочетания M’ справедливо неравенство M’M. Есть сходное, но отличающееся понятие «максимального паросочетания». Это такое паросочетание, которое не содержится ни в каком другом, то есть оно максимально по включению.
Другая формулировка «венгерской теоремы». Для произвольного двудольного графа выполняется равенство 0(G)=1(G) или, другими словами, минимальная мощность вершинного покрытия равна максимальной мощности паросочетания.
Вывод. Умея находить наибольшее паросочетание в двудольном графе, мы умеем решать и задачу нахождения множества единиц в двоичной матрице, максимальной мощности, из которых никакие две не лежат на одной линии, и задачу нахождения вершинного покрытия минимальной мощности или поиска минимального числа линий, которыми можно покрыть все единицы двоичной матрицы, и не только.
Алгоритм нахождения наибольшего паросочетания в двудольном графе. В некоторых источниках этот алгоритм также называют «венгерским» [2]. Он, по количеству практических применений, по временной эффективности, ибо он полиномиальный, а не переборный, относится к разряду основных, классических и не только в алгоритмах на графах, но и в целом, в проблемах, решаемых с помощью компьютера. При этом общая идея алгоритма чрезвычайно проста, что очередной раз подтверждает тезис – «все гениальное (фундаментальное) просто» и в «фундаменте» информатики лежит совсем немного основополагающих понятий и идей[3].
Для пояснения алгоритма необходимо еще одно понятие «чередующей цепи». Оно опять же предельно простое. Пусть мы построили произвольным образом некоторое паросочетание M (это некоторое независимое множество ребер). На рис. 2 (а) ребра M выделены «жирным» цветом {(2,1), (3,2), (4,4)}. Остальные ребра G определим в данный момент как «тонкие». Относительно данного паросочетания вершина с номером 1 из X и вершины с номерами 3 и 5 из Y свободны, то есть не смежны с ребрами из M. Чередующаяся цепь начинается из свободной вершины, принадлежащей к X, и заканчивается в свободной вершине, принадлежащей к Y. Кроме того (коль она чередующаяся) в ней чередуются «тонкие» и «жирные» ребра, и заканчивается она, как и начинается, «тонким» ребром. В предельном случае чередующаяся цепь может состоять из одного «тонкого» ребра. Для графа G и паросочетания M на рис. 2 (б) найдена чередующаяся цепь – (1,4), (4,4), (4,2), (2,3), (3,3). Мы пока не говорим, как она найдена. Найдена, если её можно найти, то есть, если она существует, и все. Следующий шаг – это изменение «толщины» ребер, принадлежащих чередующейся цепи. Ребра (1,4), (4,2) и (3,3) делаем «жирными», а ребра (4,4) и (3,2) – «тонкими» (рис. 2 (в)). Процесс поиска чередующейся цепи следует продолжить относительно нового паросочетания M – {(1,4), (2,1), (3,3), (4,2)}. Оказывается, что если не будет найдена чередующаяся цепь, ибо её нет (а не алгоритм плохой), то паросочетание M является наибольшим. Эти простые рассуждения в математике оформлены в виде теоремы.

Рис. 2. Пример графа для иллюстрации логики нахождения
наибольшего паросочетания.

Теорема. Паросочетание M в двудольном графе G наибольшее тогда и только тогда, когда в G не существует чередующейся цепи относительно M.
Примечание. Заметим, что математическое доказательство этой и ранее приведенных теорем не являются сложными и требуют только определенных навыков.
Ответим на вопрос о том, как искать чередующуюся цепь (описание любого из приводимых способов общеизвестны, в частности, они описаны в работе одного из авторов [4]).
1. Просмотр вершин графа методом поиска в глубину (с ограничением). Просмотр последовательно начинается из свободных вершин множества X. Ограничение заключается в том, что из вершин множества X переход осуществляется по «тонким» ребрам, а из вершин множества Y по «жирным» ребрам. Условием завершения поиска следует считать достижение первой свободной вершины из Y.
2. Просмотр вершин графа методом поиска в ширину (с ограничением). Просмотр последовательно начинается из свободных вершин множества X. Ограничение заключается в том, что из вершин множества X в очередь записываются «тонкие» ребра, а из вершин множества Y – «жирное» ребро. Условием завершения поиска следует считать достижение первой свободной вершины из Y.
3. Методы поиска выхода из лабиринта. У лабиринта начальными позициями являются свободные (относительно паросочетания M) вершины из X, конечными позициями – свободные вершины из Y, препятствие трансформируется в условие чередования «тонких» и «жирных» ребер. Например, текущая клетка (лабиринт на клеточном поле) светлая – из неё можно делать ход в темную клетку, и наоборот.
Реализация каждого метода зависит от искусства синтеза в единое целое (цельность, выражаясь философским языком) управляющих конструкций и структур данных. Представим себе на минуту, например, что в нашем распоряжении нет такой структуры данных как массив, и мы можем работать только с конкретными ячейками памяти. Это вполне реальная ситуация начальной стадии развития информатики. Оцените, во сколько раз возрастет в программе количество управляющих конструкций. Однако и в то время создавали эффективные и работающие программы, не уступающие современным.
«Набросаем» схему реализации первого метода. Общая логика не зависит от выбранного метода и имеет вид.
Begin
<ввод и инициализация данных>;
<найти первое паросочетание>;
While <есть чередующаяся цепь> Do <изменить паросочетание>;
<вывод результата>;
End.
Пусть граф описывается в памяти компьютера матрицей А[n,m], где n – количество вершин в множестве X, а m – в Y. Паросочетание будем хранить в двух одномерных массивах Xn и Ym. Для рассмотренного примера Xn={0,1,2,4} и Ym={2,3,0,4,0}. Уточнение фрагмента «найти первое паросочетание» не требует разъяснений. Можно начать и с одного ребра в первом паросочетании, и с пустого паросочетания, но в этом случае количество итераций в основной логике возрастает. Как лучше хранить чередующуюся цепь, учитывая то, как мы описываем паросочетание и продумывая логику её построения? Пусть это будет массив, Chain:Array[1..NMmax] Of NMmax..NMmax (NMmax – максимальное число вершин в графе), и его значение для рассмотренного примера равно [1, 4, 4, 2, 3, 3], то есть вершины из множества Ym хранятся со знаком минус (чисто технический прием для упрощения логики определения принадлежности вершины к множествам Xn и Ym). Итак, поиск чередующейся цепи (при n=m).
Function FindChain: Boolean;
Var i, j, t, r, q : Integer;
Nnew : Array [1..n] Of Boolean;
Begin
For i:=1 To n Do Nnew[i]:=False;
i := 1;
While i<=n Do Begin
FillChar(Chain, SizeOf(Chain), 0);
If Xn[i] = 0 Then Begin {Вершина из Х свободна.}
yk := 1;
Chain[yk] := i; {Первая вершина чередующейся цепи.}
t := 1;
Repeat
r := Chain[t]; {Текущая вершина из чередующейся цепи. Она принадлежит Х.}
q := n;
j := Chain[t+1]+1; {Номер вершины из У, с которой осуществляется поиск.}
While (j <= q) And ((A[r, j]=0) Or Nnew[j] Or (Xn[r]=j)) Do Inc(j); {Находим «тонкое» ребро, не принадлежащее паросочетанию.}
If j<=q Then Begin
Inc(yk); {Добавляем вершину из У в цепь.}
Chain[yk] := j;
Nnew[j] := True;
If Yn[j] = 0 Then Begin {Найдена чередующуюся цепь.}
FindChain := True;
Exit;
End
Else Begin {Переход по «жирному» ребру к вершине из Х.}
Inc(yk);
Chain[yk] := Yn[j]; {Добавляем эту вершину из Х в цепь.}
Inc(t, 2);
End;
End
Else Begin {Не находим вершину в У. Возвращаемся на шаг назад.}
Dec(t, 2);
Dec(yk, 2);
End;
Until (t>yk) Or (t<1);
End;
Inc(i);
End;
FindChain := False;
End;
При такой схеме реализации поиска чередующейся цепи цикл While основной логики имеет вид: While FindChain Do <изменить паросочетание>;.
Обобщением задачи построения наибольшего паросочетания в двудольном графе является поиск паросочетаний во взвешенном двудольном графе G=(X,Y,E), то есть графе, в котором с каждым ребром связано некое число (обычно целое), называемое весом. Ищется, как правило, совершенное паросочетание с минимальным суммарным весом (для краткости назовем его оптимальным). Напомним, что паросочетание – это независимое множество ребер и если оно является реберным покрытием, то есть покрывающим все вершины графа, то этот тип паросочетаний выделяется названием «совершенное». Этот класс задач называется «задачами о назначениях». Сузим общую задачу о назначении до случая полного двудольного графа G (каждая вершина из X связана с каждой вершиной из Y, и наоборот) и n=m. Исследованием методов решения задачи о назначениях в 50 е годы XX века помимо Д. Кёнига и Е. Эгервари интенсивно занимался Х. Кун. Алгоритмы её решения, как правило, называются венгерскими. Мы в своем изложении опираемся на исследование темы Н. Кристофидесом [5] и М. О. Асановым [6].
Сформулируем следующие простые утверждения.
Утверждение 1. Если веса всех ребер графа, инцидентных какой либо вершине, увеличить (уменьшить) на одно и то же число, то всякое оптимальное паросочетание в графе с новыми весами останется оптимальным и в графе с исходными весами.
Утверждение 2. Пусть в G=(X,Y,E) выделено множество вершин X’X и Y’Y. Без ограничения в общности считаем, что X’ – первые t вершин X, а Y’ – первые q вершин Y’. Найдем d=Min(A[i,j]), где iX’ и jY\Y’. Схема разбивки матрицы смежности графа A на части показана на рис. 3. Действия: элементы в строках X’ уменьшим на значение d; элементы в столбцах из Y’ увеличим на значение d. Результаты выполнения действий отражены на рис. 3. Элементы в частях 3, 4 не изменятся, в части 1 – уменьшаться на значение d, в части 3 – увеличатся на значение d. В целом веса ребер двудольного графа останутся неотрицательными. Суть утверждения заключается в том, что если текущее паросочетание построено только на вершинах X’ и Y’, то в результате описанного действия оно не изменится. Утверждением «закладывается» (определяется) некая аддитивность задачи.

Рис. 3. Иллюстрация логики действий в утверждении 2

Утверждение 3. Если веса всех ребер графа неотрицательны и некоторое совершенное паросочетание состоит из ребер нулевого веса, то, очевидно, что оно является оптимальным.
Утверждения 1, 2 следуют из того, что в искомое паросочетание входит только одно ребро инцидентное конкретной вершине. Сформулированные утверждения позволяют дать одну из алгоритмических модификаций венгерской темы.
Поиск оптимального паросочетания в двудольном графе G при n=m. Дадим неформальное описание (случай неотрицательных весов) алгоритма по методике Д. Кнута.
Шаг 1. [Преобразование матрицы смежности графа G на основании утверждения 1.] Изменить веса ребер так, чтобы у каждой вершины было хотя бы одно инцидентное ей ребро нулевого веса.
Шаг 2. [Построение начального паросочетания M.] Сформируем любым методом (путем прямого просмотра ребер, инцидентных вершинам из множества X) начальное паросочетание с суммарным нулевым весом.
Шаг 3. [Цикл по паросочетаниям M. Проверка паросочетания M на оптимальность, например, путем установки факта наличия вершин в Х, не задействованных в M.] Пока M не является оптимальным паросочетанием, выполнять шаги 4, 5, 6. Если паросочетание M оптимальное, то завершить работу.
Шаг 4. [Поиск чередующейся цепи.] Из свободных относительно M вершин X искать чередующуюся цепь (обозначим её P) с суммарным нулевым весом.
Шаг 5. [Принятие решения.] Если цепь Р найдена, то изменить паросочетание, и вернуться на шаг 3, иначе перейти на шаг 6.
Шаг 6. [Изменение весов ребер графа.] При поиске чередующейся цепи (шаг 4) выделены множества X’ и Y’. Путем применения действий утверждения 2 изменить веса ребер. Появится, по крайней мере, одно новое ребро с нулевым весом. После чего перейти на шаг 4.

Рис. 4. Граф из примера, иллюстрирующего логику
поиска оптимального паросочетания

Как видим, ключевым моментом алгоритма является рассмотренный ранее шаг 4 – построение чередующейся цепи. Действия на шаге 6 не изменяют веса текущего паросочетания M. Этот факт позволяет говорить о некой аддитивности задачи. Поясним сказанное – пусть для некоторой части графа построено оптимальное паросочетание. Оно, конечно, в силу техники нахождения чередующейся цепи, незначительно измениться (в окрестности оптимальности), но останется оптимальным, при его распространении на большую часть графа. Эта неформальная аналогия наводит на мысль о неразрывности понятий «аддитивности» и «полиномиальности» в информатике.

Рис. 5. Граф на рис. 3 после выполнения шага 6 алгоритма

Пример. Рассмотрим работу алгоритма на конкретном примере. Пусть матрица смежности полного двудольного графа имеет вид: ???.
Выполнение действий шага 1 сводится к вычитанию значения 2 из элементов второй строки, и затем значения 4 из элементов второго столбца. Граф, соответствующий полученной матрице, показан на рис. 4, изображены только ребра, имеющие нулевой вес. «Жирными» изображены на рис. 4 ребра, образующие начальное паросочетание. Оно сформировано путем просмотра свободных вершин из X и включением первого встретившегося ребра с нулевым весом и идущим в свободную вершину в Y в паросочетание. Найденное паросочетание не является оптимальным. Поиск чередующейся цепи из вершины 2X (она одна свободная) дает отрицательный результат – строится цепь (2,1)  (1,1)  (1,2)  (2,3). Завершить построение цепи в свободной вершине из Y у нас нет возможности, однако в процессе поиска есть возможность сформировать множества X’={1, 2, 3} и Y’={1, 2}. «В действие» вступает шаг 4 алгоритма. Выделим в матрице смежности (квадратными скобками) те элементы, к которым применимы действия утверждения 2 на шаге 6 алгоритма – ???. Минимальный элемент в первых трех строках и четвертом, пятом столбцах равен 1. После выполнения действия матрица имеет вид – ???. Появилось новое ребро (1,4) с нулевым весом, добавим его в граф на рис. 4, получим граф на рис. 5. Поиск чередующейся цепи (шаг 4 алгоритма) дает положительный результат – {(2,1), (1,1) и (1,4)}. Изменяем паросочетание, его окончательный вид приведен на рис. 6. Оно является оптимальным.

Рис. 6. Оптимальное паросочетание в примере, иллюстрирующем логику его нахождения

В качестве примера рассмотрим задачу, предложенную для Российской олимпиады школьников по информатике 2005 года, но не прошедшей конкурсный отбор.
Металлолом. На базу утилизации поступили n контейнеров с металлоломом. В каждом из контейнеров находится ровно n различных типов металла. Рабочему дано задание разложить металлолом так, чтобы в каждом контейнере находился весь металл одного типа. Помогите рабочему — подсчитайте, какое наименьшее количество металла он должен переложить, чтобы выполнить поставленные требования.
Формат входных данных. Первая строка входного файла содержит число n (1≤n≤150) — количество контейнеров и типов металла. Далее идут n строк по n целых чисел в каждой: j ое число в i ой строке Mij (1 ≤ i, j ≤ n) показывает, сколько килограммов металла j го типа находится в i ом контейнере (0 ≤ Mij ≤ 10000).
Формат выходных данных. В выходной файл выведите единственное число – минимальный вес переложенного металлолома.
Пример входных и выходных данных.
Входные данные:
4
8 4 4 7
8 1 6 1
6 2 4 2
7 3 8 8 Выходные данные:
54

Пояснение к описанию данных. «Жирным» шрифтом во входных данных выделено решение. Его стоимость – 54. В 1 м контейнере собираем металл четвертого типа, во 2 м – первого, в 3 м – второго, и в 4 м – третьего типа.
Обсуждение решения. Очевидно, что временная сложность переборного варианта решения оценивается значением n!, что при заданном максимальном значении n говорит о нереальности этого метода решения.
Первая схема решения. Осмыслим поставленную перед рабочим цель. Ему требуется разложить n типов металла в n контейнеров так, чтобы в каждом контейнере был металл одного типа и суммарный вес перекладываний был минимален. Из этого следует, что суммарный вес металла, остающегося в своих контейнерах должен быть максимален. Остается найти, какой металл, в каком контейнере оставить так, чтобы их суммарный вес был максимален. Очевидно, что задача описывается двудольным взвешенным графом. Изменим входные данные так, чтобы требовалось искать паросочетание минимального суммарного веса. Для этого найдем максимальный элемент матрицы смежности графа (Max) и для каждого элемента матрицы выполним преобразование a[i, j] := Max – a[i, j]. Матрица смежности из примера в формулировке задачи будет иметь вид: ???. После выполнения действий согласно утверждения 1, а именно вычитания из элементов третьей строки минимального элемента этой строки, а затем из элементов второго столбца, минимального элемента этого столбца, получаем матрицу ???. Каждая вершина соответствующего двудольного графа имеет инцидентное ребро с нулевым весом. Дальнейшие действия для данного варианта решения задачи совпадают с разбором примера из венгерского алгоритма поиска оптимального паросочетания двудольного графа. Вес найденного паросочетания (по исходной матрице) равен 25. Из суммы всех элементов матрицы (79) вычитаем найденное значение и получаем ответ задачи, а именно значение 54.
Оценка времени работы алгоритма. Основной цикл выполняется n раз. Поиск чередующейся цепи (или поиск в глубину) имеет сложность O(n2). Выполнение операций по утверждению 2 имеют временную оценку O(n2), но этот цикл выполняется максимум n раз. Чередующаяся цепь находится за время O(n3), окончательная оценка временной сложности алгоритма O(n4).
Вторая схема решения [7]. Дадим по прежней методике неформальное описание алгоритма, иллюстрируя его разбором приведенного выше примера. Даная задача является задачей о нахождении паросочетания максимального веса в полном взвешенном двудольном графе.
Шаг 1. [Преобразование данных]. Построим полный двудольный граф, в котором вершины множества X соответствуют контейнерам, вершины множества Y – типам металла. Вес ребра (i,j) равен весу металла j го типа, который нужно переместить, чтобы уложить весь металл типа j в i й контейнер. Таким образом, вес ребра (i,j) равен сумме чисел j го столбца исходной матрицы без i го его элемента. Целесообразно при вводе пересчитать исходную матрицу так, чтобы (i, j) й её элемент соответствовал весу ребра (i, j). Исходная матрица A (примера) после преобразования имеет вид: ???. Итак, требуется найти совершенное паросочетание минимального веса.
Шаг 2. [Построение начального паросочетания]. Используя «жадную» логику (находим минимальный элемент в строке среди не входящих в уже «задействованные» столбцы, в матрице они выделены двойным подчеркиванием), находим начальное паросочетание. Для нашего примера вес начального паросочетания равен 60.
Вспомогательный алгоритм ориентации ребер. Ориентируем рёбра, вошедшие в паросочетание, из X в Y, беря их вес со знаком минус, а ребра, не вошедшие в паросочетание, из Y в X, считая их вес положительным.
Шаг 3. [Повторение]. Пока в графе есть циклы с отрицательным суммарным весом выполнять шаг 4.
Пояснение. На рис. 7 показано начальное паросочетание и цикл с отрицательным суммарным весом, равным 3: 12341. Ясно, что в цикле «жирные» и «тонкие» ребра чередуются. «Жирные» ребра паросочетания из цикла безболезненно заменяются на «тонкие», с улучшением общей оценки решения. Так, новой оценкой является значение 57. Для поиска циклов с отрицательным суммарным весом можно использовать, например, алгоритм Р. Флойда – поиска кратчайших путей между всеми вершинами графа [8].


Рис. 7. Иллюстрация логики нахождения циклов с отрицательным суммарным весом

Рис. 8. Цикл с отрицательным суммарным весом, равным 2 и текущее паросочетание


Шаг 4. [Изменение паросочетания]. По найденному циклу с отрицательным суммарным весом изменяем паросочетание (также как в технике работы с чередующимися цепями). Используя вспомогательный алгоритм, осуществляем переориентацию соответствующих рёбер графа.
Пояснение. На рис. 8 и рис. 9 показаны следующие две итерации алгоритма. Цикл 14411 (рис. 8) имеет вес 2. Общая оценка становится равной 55. Цикл 1123441 (рис. 9) имеет вес 1. Получен окончательный результат – 54.
Временная сложность алгоритма Р. Флойда O(n3), плюс внешний цикл – общая оценка O(n4). Она несколько улучшается за счет искусства синтеза данных и управления в единое целое – программу (мы упоминали об этом). Граф можно хранить в «сжатом» виде – не все 2*n, а только вершины из X. Ребрами «сжатого» графа являются пары ребер из исходного двудольного графа: ребро из паросочетания, ведущее из X в правую Y, с отрицательным весом и ребро из Y в X, с положительным весом. Граф остается полным и так как алгоритм Р. Флойда имеет кубическую оценку относительно количества вершин, происходит ускорение в 8 раз.
Экспериментальная проверка методов показала большую эффективность первого варианта решения задачи.

Рис. 9. Цикл с отрицательным суммарным весом, равным 1 и текущее паросочетание


Примечания
1. Липский, В. Комбинаторика для программистов [Текст] / В. Липский. М.: Мир, 1988. С. 166.
2. Белов, В. В. Теория графов [Текст] / В. В. Белов, Е. М. Воробьев, В. Е. Шаталов. М.: Высшая школа, 1976. С. 144.
3. Окулов, С. М. Информатика: развитие интеллекта школьника [Текст] / С. М. Окулов. М.: БИНОМ. Лаборатория знаний, 2005. 212 с.
4. Окулов, С. М. Программирование в алгоритмах [Текст] / С. М. Окулов. М.: БИНОМ. Лаборатория знаний, 2002. 341 с.
5. Кристофидес, Н. Теория графов. Алгоритмический подход [Текст] / Н. Кристофидес. М.: Мир. 1978. 452 с.
6. Асанов, М. О. Дискретная оптимизация [Текст] / М. О. Асанов. Екатеринбург: УралНАУКА, 1998. 206 с.
7. Идея решения и его проработка сделана А. Лахно, членом научного комитета Российской олимпиады школьников по информатике. Авторами использован его рабочий материал.
8. Окулов, С. М. Программирование в алгоритмах [Текст] / С. М. Окулов. М.: БИНОМ. Лаборатория знаний, 2002. 341 с.


Вы можете найти у нас файлы для кардшаринга, а скачать прошивку для openbox - оперативность и удобный интерфейс

Удобная программа для сжатия трафика - минимизируйте затраты на интернет

Интереснейшие дзенские притчи и индуистские притчи не дадут вам заскучать