Вы находитесь на странице: 1из 258

ТОМСКИЙ ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ

ИНСТИТУТ ПРИКЛАДНОЙ МАТЕМАТИКИ И


КОМПЬЮТЕРНЫХ НАУК

Ю. Л. Костюк

ЛЕКЦИИ ПО ОСНОВАМ
ПРОГРАММИРОВАНИЯ

Учебное пособие

Издательство
2019
УДК 510.5
ББК 22.18.73

Костюк Ю. Л. Лекции по основам программирования: учебное посо-


бие. – Томск: Изд-во 2018. – 258 с., ил.

ISBN

Учебное пособие соответствует программе начального курса по программирова-


нию для вузовских специальностей, ориентированных на подготовку специалистов в
области информатики и компьютерных технологий. В книге излагаются методы тести-
рования, исследования трудоёмкости и доказательства свойств алгоритмов. Приводятся
и исследуются простые алгоритмы из важнейших классов: вычисление рекуррентных
последовательностей; сортировка и поиск; рекурсивные вычисления, алгоритмы с мно-
жествами и графами, а также простые алгоритмы линейной алгебры. Особое внимание
уделено анализу эффективности алгоритмов. В первой части лекций алгоритмы и про-
граммы записываются на языке Паскаль, а во второй части лекций – на Си. Кратко опи-
сываются также основные элементы этих языков. Пособие не ставит своей целью изу-
чение всего многообразия алгоритмов, оно является лишь первым шагом к более обсто-
ятельному и подробному их изучению.
Для студентов соответствующих специальностей, а также специалистов и препо-
давателей информатики, желающих начать систематическое изучение программирова-
ния.
УДК 510.5
ББК 22.18.73

Рецензенты:
заведующий кафедрой автоматизированных систем управления Том-
ского государственного университета систем управления и радиоэлек-
троники, доктор технических наук, профессор А. М. Кориков;
профессор кафедры программирования Томского государственного
университета, доктор технических наук А. Ю. Матросова

ISBN © Ю. Л. Костюк, 2019


Предисловие

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


хорошие программы. Все же остальное (знание языка программирования,
транслятора, операционной системы, умение быстро работать за компью-
тером) необходимо лишь постольку, поскольку помогает писать хорошие
алгоритмы и тем самым разрабатывать хорошие программы.
К сожалению, большинство учебников по программированию мало
внимания уделяет алгоритмизации – науке об алгоритмах. В этой книге не
только рассматриваются и исследуются важнейшие классы простых алго-
ритмов, но и излагаются методы тестирования и доказательства свойств
программ. Книга состоит из лекций, читавшихся для студентов первого
курса института прикладной математики и компьютерных наук ТГУ. Па-
раллельно с лекциями должны выполняться практические занятия по
написанию и выполнению программ на компьютере.
Для успешного освоения начального университетского курса програм-
мирования необходимы предварительные знания по математике и инфор-
матике в объеме средней школы. Весьма желательным является не про-
стое знакомство с каким-либо языком программирования, а умение запи-
сывать на этом языке программы для решения простых задач и владение
навыками отладки таких программ на компьютере. В книге используются
языки Паскаль и Си, точнее их базовые подмножества, достаточные для
написания большинства алгоритмов. В них входят такие основные элемен-
ты языков, как:
1) целые, вещественные символьные и логические типы данных;
2) одно- и двумерные массивы, символьные строки, списочные струк-
туры;
3) присваивания, выражения, арифметические и логические операции;
4) условные операторы if и case (switch), операции сравнения;
5) циклы while и for;
6) процедуры и функции, их описание, вызов, подстановка параметров;
7) стандартные процедуры и функции.
В первой части лекций программы записываются на языке Паскаль, а
во второй части лекций – на Си. Кратко описываются также те элементы
4 Предисловие

языков, которые далее используются в программах. При этом совершенно


не рассматриваются объектно-ориентированные средства языков, которые
необходимы лишь при создании больших и сложных программ, и которые
только мешают на начальном этапе изучения программирования.
Когда требуется учитывать особенности конкретного варианта языка
Паскаль, алгоритмы излагаются в расчете на трансляторы Turbo Pascal®
или Delphi® фирмы Borland, или свободно распространяемые трансляторы
Free Pascal или Lazarus. В отличие от языка Паскаль, язык Си (а также
Си++) более строго стандартизован, поэтому для реализации на компьюте-
ре рассматриваемых программ можно использовать любой из доступных
трансляторов.
Каждая лекция снабжена контрольными вопросами и заданиями для
самостоятельной работы. Задания рекомендуется читателю реализовать на
компьютере, разработать для них тесты и провести тестирование.
Отзывы, замечания и предложения по книге просьба присылать по ад-
ресу:
634050, г. Томск, пр. Ленина, 36, ТГУ, институт прикладной математи-
ки и компьютерных наук.
E-mail: kostyuk▬y▬l@sibmail.com
Лекция 1.
Алгоритмы и программы. Тестирование.
Аналитическая верификация
1.1. Алгоритмы и программы
С алгоритмами человек сталкивается всюду в своей повседневной
жизни: любое сколько-нибудь сложное действие, которое можно разделить
на последовательно выполняемые этапы, является алгоритмом. В обыден-
ной жизни вполне хватает интуитивного понимания алгоритма: человек,
руководствуясь здравым смыслом и своим опытом, по ходу дела уточняет
детали и, в конце концов, получает задуманный результат.
В математике требуется более строгое определение алгоритма. Поня-
тие алгоритма начало складываться со времен Евклида (около 300 г. до
н.э.), однако только в 1930-е годы появилась математическая теория алго-
ритмов. Согласно этой теории, под алгоритмом понимается совокупность
правил, определяющих процедуру решения любой задачи из некоторого
множества задач. При этом подразумевается, что существует некий испол-
нитель, который выполняет действия алгоритма для конкретного варианта
задачи в соответствии с правилами, заданными в алгоритме. Алгоритм
вместе с исполнителем можно представить в виде устройства, на вход ко-
торого подаются некоторые входные данные, а на выходе, в процессе ис-
полнения алгоритма, формируются выходные данные, см. рис. 1.1.

Входные Алгоритм и Выходные


данные исполнитель данные

Рис. 1.1

С понятием алгоритма связаны его основные свойства:


1) массовость – способность алгоритма решить любую задачу из за-
данного множества задач;
2) завершимость – способность алгоритма останавливаться после по-
лучения решения задачи;
6 Лекция 1

3) наличие внутренней структуры, понимаемой как совокупность от-


дельных правил (действий) и порядок их выполнения;
4) существование исполнителя, который понимает все отдельные дей-
ствия в алгоритме и реализует их в таком порядке, как предписано струк-
турой алгоритма.
Свойство массовости означает, что на вход алгоритма могут поступать
любые варианты входных данных, принадлежащие некоторому множеству,
и для любого варианта алгоритм способен получить соответствующий им
результат – вариант выходных данных. Именно благодаря свойству массо-
вости однажды созданный алгоритм остается необходимым, пока суще-
ствует потребность в решении этого класса задач для различных входных
данных.
Если исполнителем алгоритма является человек, то алгоритм может
быть задан не очень строго и даже не совсем полно: человек может домыс-
лить то, чего недостает в его описании. Если же исполнителем является
автоматическое устройство, например компьютер, то алгоритм должен
быть определен абсолютно точно – машина не может догадаться, что под-
разумевал человек при записи алгоритма, если он что-то опустил. Алго-
ритмы можно определять и записывать многими способами, в частности
используя естественный (русский или английский) язык. Однако такую
запись трудно сделать однозначно понимаемой для человека и практиче-
ски невозможно – для компьютера. Поэтому для записи алгоритмов изоб-
ретены искусственные языки программирования, в которых все действия
определяются абсолютно полно и однозначно.
Так как компьютер понимает только свой внутренний (машинный)
язык, то алгоритм должен быть записан на машинном языке (тогда его
называют машинной программой). Создание машинной программы –
сложная и кропотливая работа, так как команды машинного языка выпол-
няют простые действия и кодируются числами. Поэтому для реализации
даже простого алгоритма потребуется машинная программа большого раз-
мера. Изобретение в конце 1950-х годов языков программирования нако-
нец-то избавило программистов от копания в машинных командах и при-
вело к настоящей революции в программировании.
Современные языки программирования сочетают строгость и абсо-
лютную однозначность с понятностью для человека. Их практическое
применение стало возможным лишь тогда, когда были созданы специаль-
ные программы-трансляторы, выполняющие перевод алгоритмов, напи-
Алгоритмы и программы. Тестирование. Аналитическая верификация 7

санных на языке программирования, в машинные программы. Понятно,


что сама программа-транслятор должна быть записана на машинном языке.
При использовании языка программирования компьютер реализует ис-
полнение алгоритма в два этапа. Вначале компьютер, исполняя программу-
транслятор, переводит алгоритм, записанный на языке программирования,
в машинную программу. Такую программу-транслятор называют также
компилятором, а процесс перевода – трансляцией или компиляцией.
На втором этапе компьютер исполняет транслированную машинную
программу, в процессе ее исполнения на вход программы поступают вход-
ные данные, а на выходе формируются выходные данные (результат вы-
числений согласно алгоритму). Машинная программа может исполняться
многократно для различных входных данных без повторной трансляции.
Текст алгоритма на языке программирования называют исходным мо-
дулем, а транслированную программу на машинном языке – исполняемым
модулем. Весь процесс трансляции и исполнения программы представлен
на рис. 1.2.

Входные
данные

Исходный Исполняемый Выходные


модуль Транслятор модуль данные

Компьютер

Рис. 1.2

Алгоритм на языке программирования называют программой, если он


записан таким образом, что компьютер с помощью компилятора может его
транслировать в машинную программу. В программе должны присутство-
вать описания данных, стандартные заголовки и другие обязательные эле-
менты, без которых трансляция невозможна. В то же время человеку разо-
браться в алгоритме бывает проще без подобных второстепенных элемен-
8 Лекция 1

тов (которые легко доопределить), поэтому программы в тексте данной


книги записаны, как правило, без описаний.
Следует различать понятия алгоритм и программа. Алгоритм может
быть записан любым способом и не до конца определен, лишь бы его был
способен понять человек. Программу же должен «понимать» компьютер
(или транслятор). Говорят, что алгоритм реализован в виде некоей про-
граммы. Поэтому одному алгоритму может соответствовать несколько
различных программ (даже записанных на одном и том же языке програм-
мирования).

1.2. Тестирование
Компьютер, исполняя программу-транслятор, действует по строго
формальным правилам: он не способен вникнуть в замысел программиста
и понять идею алгоритма, положенного в основу анализируемой програм-
мы. Из этого следует, что транслируемая программа не должна содержать
ни одной синтаксической ошибки, т.е. программа должна строго соот-
ветствовать правилам языка программирования. Но так как программу
пишет человек (а человеку свойственно ошибаться!), то синтаксические
ошибки почти всегда присутствуют. В процессе трансляции транслятор
выявляет такие ошибки и выдает о них сообщения. Программист должен
проанализировать сообщения транслятора и исправить ошибки. Следует
заметить, что процесс исправления не всегда прост, тем более что сообще-
ния транслятора относятся к тому участку текста программы, в котором
была обнаружена синтаксическая ошибка, но не к первоначальной
причине ошибки. И даже если программа транслировалась успешно, это
совсем не означает, что она безупречна: в ней вполне могут быть смысло-
вые, т.е. семантические ошибки.
Компьютер является идеальным исполнителем алгоритмов: на то, что
современным компьютером выполняется за одну секунду, человеку может
потребоваться несколько лет. Однако чтобы программу можно было ис-
пользовать по ее прямому назначению, в ней не должно быть ошибок: при
ее исполнении для любых допустимых входных данных она каждый раз
должна выдавать правильные выходные данные. К сожалению, как пока-
зывает практика, никакой сверхгениальный программист не может напи-
сать достаточно большую программу сразу без единой семантической
ошибки.
Алгоритмы и программы. Тестирование. Аналитическая верификация 9

Таким образом, чтобы быть уверенным в правильности программы,


необходимо провести ее испытание (тестирование) на различных входных
данных. Для этого надо подготовить тест (вариант входных данных) и
подать их на вход исполняемой программы. После исполнения программы
необходимо сравнить получившиеся выходные данные с ожидаемыми
выходными данными.
Рассмотрим два примера простых программ на языке Паскаль.
Пример 1.1. Программа вычисления суммы двух чисел.
program ex1; {имя программы}
var a,b,c: integer;{описание переменных}
begin {начало программы}
read(a,b); {ввод переменных a,b}
c := a + b; {сложение a,b и присваивание c}
writeln(’c=’,c); {вывод надписи и результата с}
end. {конец программы}
В языке Паскаль при записи программы используются зарезервиро-
ванные служебные слова. В первой строке – слово «program», затем – имя
программы. Во второй строке – описание переменных, начинающееся со
слова «var». Тип этих переменных – целочисленный (integer), они могут
иметь значение от –2147483648 до +2147483647, они занимают в памяти 4
байта. В фигурных скобках записаны комментарии, не влияющие на
трансляцию программы. После слова «begin» записываются операторы
программы, они выполняют следующие действия:
1) оператор read требует ввода (с клавиатуры) числового значения
для переменных а и b с пробелом между ними, а в конце – нажатия кла-
виши «enter»;
2) к значениям переменных а и b применяется операция сложения, а её
результат присваивается переменной с;
3) оператор writeln выводит на экран (в окно вывода) надпись
’c=’, числовое значение переменной с, т.е. результат сложения, после
чего производит перевод строки выводимого текста.
Слово «end» с точкой завершает текст программы.
Пример входных данных: 39 -14
выходные данные: с=25
Конец примера.
10 Лекция 1

Пример 1.2. Программа вычисления корней квадратного уравнения


2
a∙x +b∙x+c=0:
program ex2; {имя программы}
var a,b,c,d,x1,x2: real;{описание переменных}
begin {начало программы}
read(a,b,c); {ввод переменных a,b,c}
d := b*b – 4*a*c; {вычисление дискриминанта}
x1 := (-b – sqrt(d))/(2*a); {вычисление}
x2 := (-b + sqrt(d))/(2*a); {корней х1, х2}
writeln(’x1=’,x1:7:3,’ x2=’,x2:7:3);
{вывод надписи и вывод результата}
end. {конец программы}
Все переменные описаны типом real (вещественный), и могут иметь
положительное или отрицательное значение величиной приблизительно до
1038. В отличие от целочисленного типа, вещественный тип имеет прибли-
женное значение, около 10 верных десятичных цифр.
В программе выполняются следующие действия:
1) оператор read требует ввода числовых значений для переменных а,
b и с через пробелы (переменные задают коэффициенты квадратного
уравнения);
2) вычисляется дискриминант, результат присваивается переменной d
(звездочка обозначает операцию умножения);
3) вычисляются два корня уравнения, их значения присваиваются пе-
ременным х1 и х2 соответственно (наклонная черта обозначает операцию
деления, а функция sqrt – вычисление квадратного корня);
4) оператор writeln выводит на экран надпись ’х1=’, затем число-
вое значение переменной х1, надпись ’ х2=’, числовое значение пере-
менной х2 (двоеточия и числа 7 и 3 после переменных задают формат вы-
вода, для значения каждой переменной отводится по 7 символов, из них 3
символа после десятичной точки).
Пример входных данных: 1 -3.5 -1
выходные данные: x1= -0.500 x2= 4.000
Конец примера.
В рассмотренных примерах приведено только по одному тесту, хотя в
реальности этого может быть недостаточно. Так, в примере 1.2 значение
Алгоритмы и программы. Тестирование. Аналитическая верификация 11

переменной d может оказаться отрицательным, и тогда при вычислении


функции sqrt произойдет аварийное прекращение вычислений, програм-
ма не доработает до конца и не выведет никакого результата. Поэтому для
полноценного тестирования необходимо задавать тесты в том числе для
обнаружения подобных ситуаций.
Можно гипотетически представить, что нам удалось проверить про-
грамму, исполняя ее столько раз для различных данных, сколько всего су-
ществует их возможных вариантов. Такое тестирование называется исчер-
пывающим. Если во всех случаях программа выдавала правильный ре-
зультат, то можно надеяться, что и в будущем для любого конкретного ва-
рианта входных данных программа выдаст правильный результат.
Однако в большинстве случаев исчерпывающее тестирование невоз-
можно физически. Рассмотрим программу в примере 1.1 (сложение двух
целых чисел). Диапазон для каждого из чисел от –2147483648 до
+2147483647, всего их 232. Количество всех возможных тестов, т.е. раз-
личных пар слагаемых равно 264. Даже если выполнять по 109 проверок в
секунду (миллиард!), для полного тестирования потребуется более трех
тысяч лет! Если же испытывать программу не на всех вариантах входных
данных, то нет никакой гарантии, что для произвольно выбранного вари-
анта программа выдаст правильный результат: как раз для этого (непрове-
ренного!) варианта программа может ошибиться. Более того, существуют
такие ошибки, которые могут остаться незамеченными даже после исчер-
пывающего тестирования, например отсутствие присваивания для пере-
менной, значение которой используется. При первом запуске такая про-
грамма может выдать один результат, а при втором, при тех же входных
данных, – иной результат, так как неприсвоенное значение переменной
может обладать (случайно!) другим значением при втором запуске про-
граммы. На основе подобных рассуждений Э. Дейкстра сформулировал
закон: «Тестированием можно доказать наличие ошибок в программе,
но никогда – их отсутствие».
К счастью, на практике ситуация не столь безрадостна. Существует
инженерная дисциплина, называемая технологией программирования,
которая дает рекомендации относительно того, как разрабатывать и как
проверять программы, чтобы они были надёжными (к сожалению, без-
ошибочная программа – это в большинстве случаев недостижимый идеал).
Надёжной можно считать такую программу, при выполнении которой ве-
роятность получить ошибочный результат настолько мала, что ею можно
12 Лекция 1

пренебречь. Для различных практических применений необходима разная


надёжность программ: достаточно сравнить требования к программе рисо-
вания иллюстраций и требования к программе управления ядерным реак-
тором. Важно научиться подбирать такие тесты, чтобы с их помощью
можно было выявить (и в последующем исправить) как можно больше
ошибок в программе. Многие начинающие программисты искренне счи-
тают, что тесты должны демонстрировать правильную работу программы.
Это подталкивает их к использованию простых тестов, которые бы сразу
выполнялись правильно. На самом деле задача тестирования состоит в том,
что тесты должны выявлять максимум ошибок! Если же тест не
способен выявить ни одной в принципе возможной ошибки, то он просто
бесполезен. Искусство тестирования в том и состоит, чтобы небольшим
числом тестов обнаружить как можно больше ошибок в программе.
Тестирование методом черного ящика: при создании тестов внутрен-
няя структура программы не принимается во внимание. В этом методе те-
сты может создавать человек, не знающий, как написана программа. Необ-
ходимо лишь знать, какую задачу решает программа, что за данные пода-
ются на вход программы, в каком формате, и что за данные и в каком фор-
мате выводятся.
Тестирование методом белого ящика: тесты создаются на основе
внутренней структуры программы, так, чтобы все компоненты программы
выполнялись в разных тестах.
Для более надёжного тестирования следует использовать оба этих ме-
тода. После того как при выполнении какого-либо теста обнаружен непра-
вильный результат, необходимо найти причину ошибки и устранить ее.
Этот процесс называется отладкой. Поиск ошибки может оказаться не-
простым делом, ведь требуется найти именно то действие в программе, из-
за которого произошла ошибка. Для поиска ошибки полезно пошаговое
тестирование – выполнение программы в интерактивном режиме, отсле-
живая промежуточные значения переменных. Это возможно для таких те-
стов, при которых количество шагов будет не слишком большим.

1.3. Аналитическая верификация


Чтобы создавать достаточно надежные программы тестирования недо-
статочно, поэтому Э. Дейкстрой и рядом других великих программистов
были заложены основы аналитической верификации программ, т.е. анали-
Алгоритмы и программы. Тестирование. Аналитическая верификация 13

тического исследования (доказательства) свойств программ. При этом про-


грамма проверяется не для одного конкретного набора входных значений,
как при обычном тестировании, а для множества наборов входных значе-
ний, удовлетворяющих некоторому логическому соотношению.
Идея доказательства заключается в следующем. Любая программа в
процессе своего исполнения использует и изменяет значения переменных.
Для таких переменных составляют два логических условия, называемых
предусловием и постусловием. Предполагается, что предусловие должно
быть истинным перед исполнением программы, а постусловие – после ис-
полнения.
Доказательство стоит из двух частей:
1) доказательство завершимости, т.е. доказательство того, что при лю-
бых значениях переменных на входе программы она когда-нибудь обяза-
тельно закончит свое выполнение;
2) доказательство истинности постусловия после завершения про-
граммы при предположении истинности предусловия перед исполнением
программы.
Еще в 60-е годы ХХ века было доказано, что любой алгоритм можно
записать, используя только следующие действия (операторы) в различных
комбинациях:
- присваивание, арифметические операции,
- последовательность действий,
- ветвление (конструкция if – then – else),
- цикл (конструкция while – do).
Для проведения доказательства необходимо рассмотреть правила по-
строения пред- и постусловий перечисленных типов структурных элемен-
тов программ и операторов языка программирования.
Присваивание. Если имеется некоторое условие P(x), в запись кото-
рого входит переменная x, то
{P(w)} x:=w {P(x)}
где P(w) есть то же самое условие, что и P(x), в котором каждое вхожде-
ние переменной x заменено правой частью присваивания (формулой) w.
После выполнения всех действий в формуле w результат вычислений
присваивается переменной х.
В отличие от обычной математической записи, в программах формулы
записываются в строку. Если формула длинная, то можно делать переносы,
14 Лекция 1

но без дублирования знаков операций. Имена переменных могут состоять


из букв и (или) цифр, но должны всегда начинаться с буквы. Константы
могут быть целыми или вещественными, во втором случае в их записи
должна присутствовать точка, отделяющая целую часть от дробной. Тип
переменных определяется их описанием.
Арифметические операции обозначаются знаками (+, –, *, /) или сло-
вами (div, mod). В отличие от обычной математической записи знак умно-
жения «*» нельзя опускать. Если в операциях (+, –, *) участвуют оба цело-
численных аргумента, то тип результата будет также целым. Если хотя бы
один из аргументов вещественный, рузультат будет вещественным. Ре-
зультат операции деления «/» всегда вещественный. Операция div вычис-
ляет частное от деления целых с отбрасыванием остатка, а операция mod –
остаток от деления целых.
В языке Паскаль определен большой набор стандартных математиче-
ских функций. Аргументы функций записываются после имени функции в
круглых скобках, результат вычисления большинства функций – веще-
ственный, однако есть исключения. Так, результат функции «abs» (абсо-
лютное значение) такой же, как у аргумента (целый или вещественный).
Порядок вычислений в формуле – слева-направо, но с учетом приори-
тетов операций. Круглые скобки меняют порядок вычислений: вначале
вычисляются операции внутри скобок.
Последовательность операторов. Если А и В – операторы, для ко-
торых выполняются условия (см. рис. 1.3):
{P} А {Q} и {Q} В {R}

A B

Рис. 1.3

то
{P} А; В {R},
т. е. постусловие для А есть предусловие для В.
Последовательность может состоять из любого количества операторов.
Если последовательность взять в «операторные скобки» begin и end, то ее
можно считать одним оператором.
Алгоритмы и программы. Тестирование. Аналитическая верификация 15

Ветвление (условный оператор). Вначале проверяется условие Е.


Если оно истинно, то выполняется оператор А, в противном случае – опе-
ратор В, см. рис. 1.4.

да
A

B
нет

Рис. 1.4

Если А и В – операторы, для которых выполняются условия


{P, Е} А {Q} и {P, not Е} В {Q}
то
{P} if Е then А else В {Q}
Если к тому же В – пустой оператор, то:
{P} if Е then А {Q}

Цикл (с условием). Вначале проверяется условие Е. Если оно истин-


но, то выполняется оператор А и производится переход на проверку усло-
вия Е, в противном случае – больше ничего не делается см. рис. 1.5.

да
A

E
нет

Рис. 1.5

Если А – оператор, для которого выполняется условие


{P, Е} А { P }
то
{P} while Е do А { P, not Е }
16 Лекция 1

Условие P здесь не изменяется в процессе выполнения цикла, оно


называется инвариантом цикла. Цикл завершается, когда условие Е стано-
вится ложным, поэтому, если в операторе А переменные, входящие в усло-
вие Е, не изменяются, то цикл будет бесконечно повторяться!
Корректность рассмотренных соотношений следует из смысла опера-
торов языка Паскаль. Их применение полезно при выводе пред- и посту-
словий для рассмотренных операторов.
Доказательство завершимости нередко бывает очевидным, если,
например, программа состоит из последовательности присваиваний или из
цикла for. Если же переменные, входящие в условие цикла while, из-
меняются сложным образом, то доказательство завершимости может ока-
заться непростым. В любом случае необходимо определить, при каком
условии цикл завершится – это условие будет частью постусловия.
Доказательство истинности постусловия после завершения программы,
как правило, далеко не очевидно. Для проведения такого доказательства
используют следующие специальные методы:
1) последовательное перечисление выполняемых действий, применя-
ется для последовательности операторов в программе, когда по ходу ис-
полнения операторов отслеживают изменение логических соотношений;
2) перечисление вариантов, применяется для ветвлений в программе,
когда вся область значений переменных, входящих в предусловие, разби-
вается на подобласти и для каждой подобласти доказательство проводится
отдельно;
3) метод математической индукции, применяется для циклов, этот
метод является стандартным для математических доказательств;
4) инвариант, также применяется для циклов, когда из предусловия и
постусловия выделяется общая, неизменяемая в цикле часть;
5) метод эквивалентов используют, когда есть две разные программы
с одинаковыми пред- и постусловиями и для доказательства корректности
второй программы доказывают корректность первой и эквивалентность
первой и второй программ;
6) абстракция, применяется при доказательстве сложных программ,
когда невозможно провести доказательство сразу для всей программы це-
ликом.
Применение различных методов доказательства рассмотрим на не-
скольких простых примерах.
Алгоритмы и программы. Тестирование. Аналитическая верификация 17

Пример 1.3. Пусть для числовых переменных x,y выполнено пред-


условие x=a, y=b, где a, b – некоторые числовые значения. В записи
условия запятая подразумевает логическую связку "и". Требуется доказать,
что после выполнения программы:
z:=y; y:=x; x:=z;
будет справедливым постусловие x=b, y=a.

Доказательство. Завершимость очевидна. Доказательство истинности


постусловия проведем последовательным перечислением выполняемых
действий. После первого присваивания текущее условие: x=a, y=b,
z=b, после второго: x=a, y=a, z=b, после третьего: x=b, y=a,
z=b, что и требовалось доказать.
Конец примера.
Пример 1.4. Пусть для числовых переменных x,y выполнено пред-
условие x=a, y=b, где a, b – некоторые числовые значения. Требуется
доказать, что после выполнения программы
if x<y then
begin z:=y; y:=x; x:=z end;
будет справедливым постусловие x≥y.
Доказательство. Завершимость очевидна. Доказательство проведем
перечислением двух вариантов:
1) x≥y; условие оператора if будет ложным, поэтому больше ника-
ких действий в программе выполняться не будет; из этого условия и пред-
условия следует справедливость постусловия;
2) x<y; условие оператора if будет истинным, поэтому в программе
будут выполнены присваивания, в результате которых, как доказано в
примере 2.1, переменные x и y обменяются своими значениями, что
приведет к смене условия x<y на противоположное, т.е. на x≥y.
Конец примера.
Доказательство – мощное средство для обнаружения ошибок в про-
грамме. Предположим, что программа (1.3) записана в виде двух присваи-
ваний: x:=y; y:=x. Тогда после первого присваивания: x=b, y=b,
после второго ничего не изменилось: x=b, y=b. Последнее условие не
совпадает с требуемым постусловием, т.е. в программе ошибка!
18 Лекция 1

Программа в примере 1.2, которая вычисляет корни квадратного урав-


нения, обладает серъезным недостатком. Если введенные три числа задают
коэффициенты уравнения, не имеющего вещественных корней, то его дис-
криминант будет отрицательным. Тогда при вычислении квадратного кор-
ня из отрицательной величины произойдет аварийное прекращение вычис-
лений.
Пример 1.5. Программа вычисления корней квадратного уравнения
2
a∙x +b∙x+c=0 с проверкой дискриминанта.
program ex3; {имя программы}
var a,b,c,d,x1,x2: real;{описание переменных}
begin {начало программы}
read(a,b,c); {ввод переменных a,b,c}
d := b*b – 4*a*c; {вычисление дискриминанта}
if d>=0 then begin {проверка того, что d≥0}
x1 := (-b – sqrt(d))/(2*a); {вычисление}
x2 := (-b + sqrt(d))/(2*a); {корней х1, х2}
writeln(’x1=’,x1:7:3,’ x2=’,x2:7:3);
{вывод надписи и вывод результата}
end
else writeln(’error!’); {если d<0 то ошибка}
end. {конец программы}
Доказательство. Дискриминант вычисляется по известной из алгебры
формуле, после чего в операторе if проверяется условие d>=0. При этом
возможны два варианта:
1) условие d>=0 истинно, тогда выполняется вычисление корней по
известным формулам и вывод результата;
2) в противном случае оператор writeln выводит текст «error!».
Тестирование по методу белого ящика.
Пример теста 1. (условие d>=0 ложно)
Ввод: 1 -2 3
вывод: error!
Пример теста 2. (условие d>=0 истинно)
Ввод: 2 -10 12
вывод: x1= 2.000 x2= 3.000
Конец примера.
Алгоритмы и программы. Тестирование. Аналитическая верификация 19

Метод математической индукции. Метод позволяет доказывать


утверждение, зависящее от параметра индукции – некоторой целочислен-
ной переменной. Утверждение доказывается для всех значений параметра,
бóльших или равных некоторому начальному значению.
Доказательство методом математической индукции содержит три эта-
па: 1) базис, 2) предположение, 3) индуктивный вывод.
На этапе базиса проверяют, что доказываемое утверждение P(n) отно-
сительно параметра индукции n справедливо при n = n0.
На втором этапе делается предположение, что утверждение P(n)
справедливо для всех значений n, не бóльших, чем некоторое k,
k ≥ n ≥ n0.
Наконец, на этапе индуктивного вывода доказывается, что P(n) будет
справедливо при n = k + 1 > n0. Из этого следует, что утверждение P(n)
справедливо для любого n ≥ n0.
Покажем это для n = M такого, что M > n0. Вначале положим k = n0.
Согласно третьему этапу доказательства утверждение будет справедливо
при n = n0 + 1. Применив еще раз третий этап доказательства при
n = n0 + 1, получим справедливость утверждения P(n) при n = n0 + 2 и
т.д., пока не дойдем до n = M.
Метод математической индукции широко используется в математике
для доказательства самых разных логических утверждений. Рассмотрим
несколько различных примеров его применения.
Пример 1.6. Докажем, что для вычисления суммы квадратов чисел от
1 до n
S n = 12 + 22 + … + n2
справедлива формула
2n 3  3n 2  n
Sn  . (*)
6
Доказательство.
Базис. При n = 0 сумма Sn = 0, с другой стороны, при n = 0 формула
(*) верна.
Предположение. Пусть при n ≥ 0 формула (*) верна.
Индуктивный вывод. При n + 1 по формуле (*) получаем
20 Лекция 1

2n 3  3n 2  n 2(n  1) 3  3(n  1) 2  (n  1)
S n1   (n  1) 2  .
6 6
Конец примера.
Проиллюстрируем применение метода математической индукции на
примере программы вычисления факториала.
Пример 1.7. Пусть выполнено предусловие n≥1. Требуется доказать,
что после выполнения программы
f:=1; i:=2;
while i<=n do
begin f:=f*i;
i:=i+1
end;
будет справедливо постусловие:
f=1*2*...*n, i=n+1.
Доказательство. Завершимость очевидна, так как переменной i пе-
ред выполнением цикла присваивается 2, а при каждом исполнении цикла
она увеличивается на 1. Методом математической индукции легко дока-
зать, что после n-1 исполнений цикла переменная i будет равна n+1
и условие в заголовке цикла станет ложным. Справедливость постусловия
также докажем этим методом.
Базис. Если n=1, то f=1, i=2, так как цикл ни разу не будет выпол-
няться. Таким образом, базис справедлив.
Предположение. Предполагаем, что при n=k, k≥1, справедливо по-
стусловие f=1*2*...*k, i=k+1.
Индуктивный вывод. Все отличие выполнения программы при n=k+1
от выполнения при n=k состоит в том, что вначале программа выполнит-
ся в точности так же, как при n=k, а затем цикл будет исполнен еще один
раз для i=k+1. Так как, по предположению, перед последним выполне-
нием цикла f=1*2*...*k, то после последнего выполнения цикла (по-
сле исполнения операторов f:=f*i; i:=i+1) будет верно:
f=1*2*...*k*(k+1)=1*2*...*n, i=k+2=M+1,
что и требовалось доказать.
Конец примера.
Алгоритмы и программы. Тестирование. Аналитическая верификация 21

Метод инварианта. Метод доказательства с помощью инварианта со-


стоит в следующем. Пусть предусловие записано как (P, Q), а постусло-
вие – как (S, Q), т.е. в них имеется общая часть Q, называемая инвариан-
том.
Инвариант – такое логическое условие, которое истинно как до выпол-
нения алгоритма, так и после его завершения.
Доказательство для цикла:
1. Доказать завершимость цикла.
2. Проверить что условие истинно до выполнения цикла.
3. Проверить, что условие истинно при однократном выполнении цик-
ла.
Тогда, согласно математической индукции, следует, что условие будет
истинным после завершения цикла.
Рассмотрим применение инварианта на предыдущем примере.
Пример 1.8. Пусть выполнено предусловие n≥1. Требуется доказать,
что после выполнения программы
f:=1; i:=2;
while i<=n do
begin f:=f*i;
i:=i+1
end;
будет справедливо постусловие:
f=1*2*...*n, i=n+1.
Доказательство. Завершимость доказана в примере 1.7. После выпол-
нения присваиваний в первой строчке программы предусловие для опера-
тора цикла: n=≥1, i=2, f=1. Последнее равенство запишем в виде:
f=1*...*(i-1). Постусловие запишем в виде: n≥1, i=n+1,
f=1*...*(i-1). Справедливость равенства i=n+1 следует из того, что
после окончания выполнения цикла i>n, но так как переменная i при
каждом выполнении цикла увеличивается на 1, она не может быть больше
чем n+1. Равенство f=1*...*(i-1) здесь является инвариантом. Дей-
ствительно, непосредственной проверкой операторов в программе убежда-
емся, что выполнение двух действий в теле цикла изменяет переменные f
и i, но оставляет инвариант справедливым.
Конец примера.
22 Лекция 1

Самое главное в доказательстве методом инварианта – умение форму-


лировать условие в виде инварианта, что требует некоторого навыка. В то
же время использование инварианта обычно упрощает доказательство
программы.
Тесты для программы в двух предыдущих примерах. Тесты по методу
черного ящика:
- минимально возможное n=0;
- на 1 больше минимально возможного n, n=1;
- значение n, несколько большее, например, n=5.
Тесты по методу белого ящика:
- такое n, чтобы цикл ни разу не выполнялся, n=1;
- такое n, чтобы цикл выполнился 1 раз, n=2;
- значение n, несколько большее, например, n=5.
Т.е. все тесты: n=0, n=1, n=2, n=5.
На выходе соответственно: f=1, f=1, f=2, f=120.
Метод эквивалентов. Если две программы в процессе выполнения
изменяют все переменные из набора х1, . . . , хn одинаковым образом, то эти
две программы эквивалентны относительно этого набора переменных.
Тогда доказательство для одной из программ относительно этого
набора переменных справедливо и для другой программы.
При этом в программах могут выполняться различные действия над
другими переменными, не входящими в набор х1, . . . , хn.
Пример 1.9. Эквивалентность операторов цикла. Пусть S – оператор,
не изменяющий переменную i. Тогда, если выражения a и b не содер-
жат вызовов функций с побочными эффектами и переменных, изменяю-
щихся внутри цикла, то эквивалентны циклы
for i:=a to b do S
и
i:=a; while i<=b do begin S; i:=i+1 end
по правилам языка Паскаль:
Предусловие для обоих циклов: i=a, a<=b+1.
После завершения цикла while: i=b+1.
После завершения цикла do по правилам языка Паскаль значение i не
определено, но в постусловии его следует считать равным b+1.
Алгоритмы и программы. Тестирование. Аналитическая верификация 23

Аналогичным образом эквивалентны циклы:


for i:=b downto a do S
и
i:=b; while i>=a do begin S; i:=i-1 end
Конец примера.
Рассмотренный пример является частным случаем эквивалентности
операторов цикла while и for. Рассмотрим ряд других случаев эквива-
лентности.
Пример 1.10. Эквивалентность составных операторов, отличающихся
порядком их выполнения. Если S1 и S2 – операторы, в которых исполь-
зуются различные переменные, то эквивалентны последовательности
S1; S2 и S2; S1
Конец примера.
Пример 1.11. Эквивалентность условных операторов if и case. Эк-
вивалентны операторы
case i of a1:S1; a2:S2 else S3 end
и
if i=a1 then S1 else if i=a2 then S2 else S3
Конец примера.
Массивы в Паскале. Массив – это пронумерованный набор перемен-
ных одного и того же типа. В описании массива указывается тип и границы
для номеров его элементов. Например, описание:
var M: array[1..100]of real;
определяет одномерный массив из переменных вещественного типа, про-
нумерованных от 1 до 100. Любой из элементов массива можно использо-
вать так же, как простую переменную вещественного типа, но для этого
нужно указывать индекс – номер этого элемента, например:
M[5], M[i], M[i+5].
при этом значение индекса или индексного выражения должно иметь це-
лочисленный тип и быть не менее 1 и не более 100.
24 Лекция 1

Массив может быть двумерным и даже большей размерности. Напри-


мер, описание:
var M2: array[1..10,0..49]of integer;
определяет двумерный массив из переменных целочисленного типа, про-
нумерованных от 1 до 10 по первому измерению и от 0 до 49 по второму
измерению. Всего в массиве М2 задано 500 элементов. Примеры индекси-
рования элементов массива М2:
M2[5,0], M2[i,j], M2[i+5,j-1].
Рассмотренные примеры задают статические массивы, их размеры задают-
ся константами в описании и не могут изменяться в процессе вычислений
по программе.
Пример 1.12. Программа ввода массива из 10 элементов и их вывода.
program ex4; {имя программы}
var i: integer; {описание переменных}
M: array[1..10]of integer;
begin {начало программы}
for i:=1 to 10 do {ввод элементов массива М}
read(M[i]);
for i:=10 downto 1 do {вывод элементов}
write(M[i],’ ’); {массива М в обратном порядке}
writeln; {переход к новой строчке вывода}
end. {конец программы}
Пример ввода: 1 3 21 4 -9 10 125 33 31 2
вывод: 2 31 33 125 10 -9 4 21 3 1
Конец примера.
Пример 1.13. Докажем, что программа
min:=A[1];
for i:=2 to n do
if min>A[i] then min:=A[i]
вычисляет минимальное значение в целочисленном массиве A среди
элементов от 1-го до n-го.
Алгоритмы и программы. Тестирование. Аналитическая верификация 25

Доказательство. Используя эквивалентность циклов for и while,


можно записать предусловие: i=2 и постусловие: i=n+1. Тогда инвари-
ант цикла будет следующим:
min= min{A[1],...,A[i-1]},
где функция min обозначает минимальное значение из совокупности ар-
гументов.
Тестирование по методу чёрного ящика:
- минимальное n=1, массив А: А[1]=10;
- n на 1 больше минимального, n=2, два варианта массива А:
1) А[1]=10, А[2]=5; {убывание}
2) А[1]=5, А[2]=10; {возрастание}
- n несколько большее, например, n=4, массив А:
1) А[1]=10,А[2]=5,А[3]=1,А[4]=-2; {убывание}
2) А[1]=-5,А[2]=0,А[3]=1,А[4]=7; {возрастание}
3) А[1]=5,А[2]=5,А[3]=5,А[4]=5; {равенство элементов}
Тестирование по методу белого ящика:
- такое n, чтобы цикл ни разу не выполнялся, n=1;
- n=2, чтобы цикл выполнился 1 раз, два варианта массива А:
1) такой массив А, чтобы условие min>A[i] было истинным;
2) такой массив А, чтобы условие min>A[i] было ложным;
- n несколько большее, например, n=4, два варианта массива А:
1) условие min>A[i]всегда было истинным, убывание в массиве;
2) условие min>A[i] всегда было ложным, возрастание в массиве.
Здесь тесты по обоим методам могут быть почти одинаковыми.
Конец примера.
Пример 1.14. Сравним программу
min:=A[1]; k:=1;
for i:=2 to n do
if min>A[i] then
begin min:=A[i]; k:=i end
с программой из примера 1.13. Эти программы эквивалентны относительно
переменных min и i. Что же касается переменной k, то нетрудно доказать,
что для нее справедлив следующий инвариант:
k= argmin{A[1],...,A[i-1]},
26 Лекция 1

где функция argmin обозначает номер минимального значения среди за-


писанных в скобках аргументов.
Тесты для этого примера могут быть такими же, как для примера 1.13.
Конец примера.
Метод эквивалентов полезен как для доказательства программ, так и
для их разработки. Опытный программист, анализируя задачу, пытается,
прежде всего, приспособить для ее решения известные ему алгоритмы и
программы, внося в них необходимые изменения. Если ему это удается, то
новая программа будет эквивалентна старой программы относительно не-
которых переменных. Такой метод ускоряет саму разработку новой про-
граммы, упрощает ее доказательство и повышает ее надежность (так как
старая программа была протестирована и исследована раньше!). Но для
этого программист должен знать не только большое количество типовых
программ, но и методы их доказательства.
Метод абстракции. Применяется для доказательства сложных про-
грамм. Для этого отдельные части всей программы представляются как
отдельные программы со своими входами и выходами, и для них задаются
свои пред- и постусловия. Каждая такая отдельная программа доказывает-
ся независимо (абстрагируясь) от других частей. В свою очередь вся про-
грамма в целом доказывается независимо от её отдельных частей в пред-
положении правильности всех этих отдельных частей.
Пример 1.15. Вычисление всех простых чисел в диапазоне от 2 до n:
for i:=2 to n do
begin j:=2; p:=0;
while (j<i)and(p=0) do
if i mod j = 0
then p:=1
else j:=j+1;
if p=0 then writeln(i)
end;
Внешний цикл перебирает все числа в диапазоне от 2 до n, а внутрен-
ний цикл проверяет каждое число i, не делится ли оно нацело на какое-
либо из чисел, которое меньше i.
Доказательство для внешнего цикла.
Предусловие для внешнего цикла: i=2.
Алгоритмы и программы. Тестирование. Аналитическая верификация 27

Постусловие: i=n+1.
Инвариант: «вычислены все простые числа от 2 до i-1.
Доказательство для внутреннего цикла.
Предусловие для внутреннего цикла: j=2,p=0.
Постусловие: j=n,p=0, тогда число i простое,
или: j<n,p=1, тогда число i не простое.
Инвариант: «число i не делится на числа от 2 до j-1
и: (число не делится на j,p=0)
или: (число делится на j,p=1)».
Конец примера.
Метод абстракции применяется также при разработке сложных про-
грамм. В этом случае он называется методом поэтапной разработки или
методом сверху-вниз и реализуется следующим образом:
- вначале создаётся общая программа (на первом уровне), внутри кото-
рой отдельные операторы – это программы второго уровня;
- затем создаются программы второго уровня, внутри которых отдель-
ные операторы – это программы третьего уровня и т.д.
Наибольший положительный эффект достигается, если в качестве от-
дельных операторов удается применить ранее созданные программы. Для
удобства их можно оформить в виде процедур или функций, как типовые
программы. При этом часто такие типовые программы приходится моди-
фицировать, подгонять под конкретные условия.
Таким образом, чтобы успешно разрабатывать сложные программы,
программист должен:
1) знать и понимать большое количество типовых программ;
2) уметь модифицировать типовые программы;
3) уметь из типовых программ собирать, как из «кирпичиков», более
сложные программы.
На практике аналитическая верификация не заменяет, а дополняет
компьютерное тестирование. Имеется ряд причин, по которым нельзя
ограничиться только аналитическим доказательством.
Во-первых, очень трудно учесть все ограничения, связанные с кон-
кретным представлением данных (особенно целых и вещественных чисел)
в компьютере.
28 Лекция 1

Во-вторых, при выполнении реальной программы на компьютере


трудно заранее учесть все нюансы взаимодействия программы со стан-
дартными программами и операционной системой.
В-третьих, можно сформулировать такие постусловия, которые не
полностью определяют свойства программы.
И наконец, в доказательстве могут быть ошибки!
Поэтому только при сочетании тестирования и аналитического доказа-
тельства можно добиться того, чтобы программа была достаточно надеж-
ной. На самом деле наибольшая практическая польза от доказательства
состоит в том, что оно заставляет программиста писать ясные и понятные
программы, в которых в принципе будет меньше ошибок, чем при написа-
нии программ методом «как получится».
Кроме того, весьма эффективно при тестировании программы методом
белого ящика вставить в нее операторы проверки пред- и постусловий, ин-
вариантов. Такие операторы при нарушении условия должны выводить
текстовое сообщение, которое поможет найти ошибку. В последующем,
при эксплуатации готовой программы, эти операторы можно закомменти-
ровать.

1.4. Анализ трудоёмкости


Любая программа для своего выполнения требует двух основных ре-
сурсов – пространства и времени. Пространство измеряется объемом памя-
ти для входных и выходных данных, переменных внутри программы, а
также памятью, необходимой для размещения самой программы. Память
для входных и выходных данных определяется условиями задачи. Память
для размещения программы определяется ее длиной и транслятором и ни-
как не зависит от размера данных. Обычно она много меньше, чем память
для данных, если данные размещаются в массивах. В то же время объем
памяти для размещения внутренних переменных в программе может раз-
личаться для разных программ, решающих одну и ту же задачу. Програм-
ма, которой требуется меньше памяти, более эффективна по памяти.
Самый важный ресурс – время. В большинстве случаев экономия вре-
мени важнее экономии памяти. Время работы программы определяется
количеством выполняемых в ней действий и их сложностью, а также вре-
менем выполнения на данном компьютере отдельных элементарных дей-
ствий. Однако при анализе программ важно лишь знать, во сколько раз
Алгоритмы и программы. Тестирование. Аналитическая верификация 29

одна программа будет выполняться быстрее другой при одних и тех же


входных данных на одном и том же компьютере. Поэтому для каждой про-
граммы необходимо вычислить трудоёмкость – функцию зависимости
количества элементарных действий от размера входных данных, например,
от размера массива n. При этом достаточно оценить трудоёмкость при
n →∞.
Часто бывает, что количество элементарных действий в программе за-
висит не только от n, но и от конкретных значений элементов и от их вза-
имного расположения. Тогда оценивают трудоёмкость в наихудшем, когда
реальная трудоёмкость не больше трудоёмкости в наихудшем. Иногда оце-
нивают трудоёмкость в наилучшем или трудоёмкость в среднем. Так, про-
грамма в примере 1.13, которая вычисляет минимальное значение среди n
элементов массива, имеет трудоёмкость:
T(n) = A·n + B,
так как количество выполнений цикла прямо пропорционально n, а коли-
чество действий внутри цикла не превышает некоторой константы А. При
n →∞ величиной B можно пренебречь.
Реальное (физическое) время выполнения программы зависит не толь-
ко от быстродействия компьютера, но и на каком языке программирования
написана программа, на каком трансляторе она транслируется. Поэтому
вид функции трудоёмкости записывают с точностью до константы – мно-
жителя перед функцией, используя обозначение О-большое, и тогда гово-
рят о порядке роста функции трудоёмкости. Поэтому для трудоёмкости
программы из примера 1.13 вместо T(n) = A·n + B достаточно записать
O(n), здесь порядок трудоёмкости линейный.
Пример 1.16. Вычисление суммы S элементов квадратной матрицы D
размером n x n:
S:=0;
for i:=1 to n do
for j:=1 to n do
S:=S+D[i,j];
Здесь трудоёмкость T(n) = A·n2 + B·n + C, так как количество выполне-
ний цикла по i – n раз, и для каждого его выполнения цикл по j выполня-
30 Лекция 1

ется n раз, т.е. всего – n2 раз. При n →∞ величинами B и С можно прене-


бречь. Порядок трудоёмкости O(n2) – квадратичный.
Конец примера.
Пример 1.17. Трудоёмкость программы из примера 1.15 (вычисление
всех простых чисел в диапазоне от 2 до n) в наихудшем:
T(n) = A·n2 + B·n + C,
так как общее количество выполнений внутреннего цикла не превышает
суммы:
1 + 2 + 3 + . . . + (n − 2) = (n − 1)· (n − 2) / 2 ≈ n2/2.
Таким образом, порядок трудоёмкости O(n2) – квадратичный.
Конец примера.
Программу из примера 1.15 можно заметно ускорить, если во внутрен-
нем цикле проверять делимость числа i не на все числа, меньшие i, а
только на числа, меньшие или равные i , так как если число i делится
нацело на j, то оно также делится нацело на i/j.
Пример 1.18. Ускоренная программа вычисления всех простых чисел в
диапазоне от 2 до n:
for i:=2 to n do
begin j:=2; p:=0; x=sqrt(i);
while (j<=x)and(p=0) do
if i mod j = 0
then p:=1
else j:=j+1;
if p=0 then writeln(i)
end;
Трудоёмкость программы в наихудшем:
T(n) = A·n3/2 + B·n + C,
так как общее количество выполнений внутреннего цикла не превышает:

1  2  3  ...  n  n  n .
Таким образом, порядок трудоёмкости O(n3/2).
Конец примера.
Алгоритмы и программы. Тестирование. Аналитическая верификация 31

При сравнении двух алгоритмов (или программ, их реализующих), с


различными функциями быстродействия (или требуемой памяти) лучшим
следует считать тот алгоритм, у которого функция растет медленнее.
Например, O(n) – линейная трудоёмкость, лучше чем O(n2) – квадратичная
трудоёмкость. Так, при увеличении n в 10 раз время работы алгоритма с
линейной трудоёмкостью увеличится в 10 раз, алгоритма с трудоёмкостью
O(n3/2) – в 31 раз, а алгоритма с квадратичной трудоёмкостью – в 100 раз!
Если же у алгоритмов порядок трудоёмкости одинаков, то лучше тот
алгоритм, у которого множитель А перед функцией трудоёмкости меньше.

Вопросы и задания
1. Какими свойствами должен обладать алгоритм? В чем смысл этих свойств?
2. Какими свойствами обладает алгоритм, если у него нет входных данных?
3. Для чего необходим специальный язык программирования?
4. Что такое синтаксическая и что такое семантическая ошибка в программе? Как и
когда они обнаруживаются?
5. В чем состоит цель тестирования?
6. Почему невозможно, как правило, исчерпывающее тестирование?
7. Привести пример алгоритма, для которого возможно исчерпывающее тестирование.
8. Почему тестированием нельзя доказать отсутствие ошибок в программе?
9. Написать программу, которая вводит числа a и b, и вычисляет x согласно уравне-
нию: a∙x + b = 0. Привести примеры тестов по методу черного и белого ящика для
неё. В каком случае выполнение программы будет невозможно?
10. Программа возводит целое число n в целую степень m и выдает результат, при-
сваиваемый переменной типа integer. Разработать тесты по методу черного ящи-
ка. Каков будет результат при n = 8 и m = 40?
11. Как выполнять пошаговую отладку, используя какой-либо транслятор для Паскаля?
12. Написать программу, которая вводит три вещественных числа, проверяет, может ли
существовать треугольник со сторонами, равными этим числам. Если не может, то
выдает сообщение об этом и просит ввести данные повторно. Если может, то вы-
числяет и выводит площадь треугольника. Разработать тесты по методу черного
ящика и по методу белого ящика, протестировать программу на объединенных те-
стах.
13. Как выбирать тесты по методу белого ящика для последовательности операторов,
условного оператора и цикла?
14. Доказать методом математической индукции, что 1 + 2 + … + n = n (n + 1)/2 .
15. Доказать методом математической индукции, что 1 + 3 + … + (2n – 1) = n2 .
32 Лекция 1

16. В чем состоит доказательство свойств программы? Что значит доказать заверши-
мость программы?
17. Что такое предусловие и постусловие, как их задавать?
18. Какую структуру имеют программы, при доказательстве свойств которых применя-
ется последовательное перечисление выполняемых действий?
19. Какую структуру имеют программы, при доказательстве свойств которых применя-
ется перечисление вариантов?
20. Какую структуру имеют программы, при доказательстве свойств которых применя-
ется метод математической индукции?
21. Какую структуру имеют программы, при доказательстве свойств которых применя-
ется метод инварианта?
22. Какую структуру имеют программы, при доказательстве свойств которых применя-
ется метод абстракции?
23. В чем состоит доказательство методом эквивалентов?
24. Написать программу, которая вычисляет максимальное значение из трёх введённых
чисел. Разработать для неё тесты по методу черного и белого ящика. Доказать пра-
вильность программы.
25. Написать программу, не использующую массивы, которая вводит n, затем n целых
чисел и выводит два наибольших из введённых чисел. Разработать для неё тесты по
методу черного и белого ящика. Доказать правильность программы. Какова трудо-
ёмкость программы?
26. Дан целочисленный двумерный массив из n строк и m столбцов. Написать про-
грамму вычисления сумм его элементов во всех столбцах. Доказать ее корректность
и вывести трудоёмкость.
27. Программу из примера 1.18 можно ещё больше ускорить, если во внутреннем цикле
проверять делимость числа i не подряд на все числа, которые не больше √i, а только
на ранее вычисленные простые числа, не большие √i. Вывести формулу трудоёмко-
сти, учитывая, что при больших n количество простых чисел, не больших n, стре-
мится к величине n/ln n.
Лекция 2.
Рекуррентные алгоритмы

2.1. Рекуррентные последовательности и пределы

Определение. Числовая последовательность {xk} называется рекур-


рентной ранга p, если
 xk  a k , k  0, 1, ..., p  1,
 (*)
 xk  f (k , xk 1 , xk  2 ,..., xk  p ), k  p, p  1, ...
где a0, a1, …, ap – 1 – константы, а f – функция.
Определение рекуррентной последовательности само является алго-
ритмом, который надо просто «переписать» на Паскале. Этот алгоритм
состоит из присваиваний начальных значений элементам x0, x1, …, xp – 1 и
цикла вычисления последующих элементов xk . Доказательство коррект-
ности алгоритма строится на основе метода математической индукции или
инварианта. Исследование эффективности алгоритма также несложно. Ес-
ли в дальнейшем необходимо использовать все элементы последователь-
ности {x0, . . ., xn}, то их необходимо запоминать в массиве размером n + 1.
Если же необходимы не все элементы, а только p элементов, то достаточно
использовать массив размером p. Трудоёмкость определяется количеством
выполнений операторов в цикле и является линейной, если мкость вычис-
ления функции f – константа.
Пример 2.1. Сумму каких-либо элементов {a1, . . ., an} можно пред-
ставить рекуррентной последовательностью ранга 1:
S0  0,

Si  Si 1  ai , i  1, 2, ..., n .
Сумма вычисляется программой:
S:=0;
for i:=1 to n do
S:=S + a(i);
Здесь a(i) – формула вычисления i-го элемента суммы.
Конец примера.
34 Лекция 2

В некоторых случаях рекуррентная последовательность может иметь


предел при n → ∞.
Пример 2.2. Бесконечные суммы (ряды) задаваются соотношениями:
S1  f1 ,

 S k  S k 1  f k , k  2, 3, ...
в которых слагаемые f k стремятся к нулю при k → ∞. При этом сами сла-
гаемые также могут задаваться в виде рекуррентной последовательности:
 f1  a,

 f k  p(k , f k 1 ), k  2, 3, ...
Вычисление предела такой последовательности с заданной точностью
eps можно осуществить следующей программой:
S:=a; f:=a; k:=2;
while abs(f)>=eps do
begin
f:= p(k, f);
S:=S+f;
k:=k+1
end
Здесь инвариант следующий: f = f k-1, S = Sk-1.
Трудоёмкость программы, т.е. количество k выполнений цикла, опре-
деляется из формулы | f k |≤ ε , где ε – заданная точность вычисления по-
следовательности.
Конец примера.
Пример 2.3. Приближенное значение функции sin x можно вычислить
с помощью суммы
x3 x5 x 2 k 1
sin x  x    ...(1) k 1 ,
3! 5! (2k  1)!
для которой справедливы рекуррентные соотношения в примере 2.2. По-
делив выражение для k-го члена суммы :
x 2 k 1
f k  (1) k 1
(2k  1)!
на выражение для (k–1)-го члена суммы:
Рекуррентные алгоритмы 35

x 2k 3
f k 1  (1) k  2 ,
(2k  3)!
получим рекуррентное соотношение для элементов суммы:
 f1  x,

f f x2
 k k 1 , k  2, 3, ...
 (2k  1)(2k  2)
Программа вычисления суммы с заданной точностью eps:
S:=x; f:=x; k:=2;
while abs(f)>=eps do
begin
f:=-f*x*x/((2*k-1)*(2*k-2));
S:=S+f;
k:=k+1
end
Завершимость программы следует из того, что fk → 0 при k → ∞.
Здесь инвариант такой же, как в примере 2.2:
f = f k-1, S = Sk-1,
Трудоёмкость алгоритма (количество k выполнений цикла) можно
определить из формулы:
2 k 1
x
fk  ,
(2k  1)!

где ε – заданная точность.


Конец примера.
Для суммы со знакопеременными слагаемыми, как в примере 2.3, вы-
численное значение суммы будет отличаться от истинного значения пре-
дела не более чем на величину абсолютного значения первого отброшен-
ного слагаемого. Если же все слагаемые имеют один и тот же знак, то для
определения того, сколько слагаемых необходимо суммировать, приходит-
ся выводить формулу так называемого «остаточного члена суммы». Более
того, сумма, содержащая слагаемые одного и того же знака, может даже не
иметь предела (т.е. быть бесконечной).
Вот еще несколько примеров рекуррентных последовательностей,
имеющих предел.
36 Лекция 2

Пример 2.4. Рассмотрим рекуррентную последовательность для вы-


числения квадратного корня заданного числа, которая была известна еще
Герону:
 s0  1,

s  1  s  a , k  1, 2, ...
 k 2  k 1 sk 1 
  
Докажем, что lim s k  a при a ≥ 0.
k 

Доказательство. Пусть sk  a  ek , где ek – ошибка k-го прибли-


жения. Тогда
1 a  2
  a  1  ek 1
s k   a  ek 1   a  ek .
2  a  ek 1  2 a  ek 1
Вычислим e1:

1  a   a  ( a  1)  0 ,
2
1
e1  s1  a 
2 2
и тогда
1 ek21 1
ek    0, ek   ek 1 , k  2, ...
2 a  ek 1 2

Полученные неравенства утверждают, что, начиная с e1, все ek неот-


рицательны и каждая последующая ошибка меньше предыдущей, по край-
ней мере, в 2 раза. То есть e1/e2 ≥ 2, e1/e3 ≥ 4, …, e1/ek ≥ 2k – 1, откуда
1 a
k  log 2 .

Поэтому lim ek  0 . Кроме того, начиная с s1 , все sk убывают.
k 

Вычисление предела последовательности, начиная с s1 , с заданной


точностью eps записано в виде программы:
s:=(1+a)/2; e:=eps;
while e>=eps do
begin s1:=(s+a/s)/2;
e:=s-s1; s:=s1
end
Рекуррентные алгоритмы 37

Трудоёмкость программы, т.е. количество выполнений цикла, имеет


порядок O(log (1+a)/ɛ).
Конец примера.
Пример 2.5. Пусть на интервале [a, b] задана непрерывная функция
y = f (x), причем значения функции на концах интервала f (a) и f (b)
имеют разные знаки. Тогда на этом интервале функция имеет хотя бы
один корень x0 такой, что f (x0) = 0. Задача состоит в вычислении корня
функции с заданной точностью ε / 2 , т.е. требуется найти такое z, что
выполняется неравенство:
| x0 – z | ≤ ε / 2 .
Метод дихотомии для решения этой задачи состоит в том, что на каж-
дом шаге интервал делится пополам и определяется тот из полуинтерва-
лов, где может находиться корень. Можно построить следующую рекур-
рентную последовательность пар чисел {ui , vi}:
u 0  a, v0  b,

u i 1  u i , vi 1  (u i  vi ) / 2, при signf (u i )  signf ((u i  vi ) / 2),
u  (u  v ) / 2, v  v , при signf (u )  signf ((u  v ) / 2),
 i 1 i i i 1 i i i i

которая сходится к искомому корню. Программа вычисляет ее предел x с


заданной точностью eps/2:
u:=a; v:=b; x:=(u+v)/2;
while (v-u)>=eps do
begin
if f(u)*f(x)<=0 then v:=x
else u:=x;
x:=(u+v)/2
end
Трудоёмкость программы, т.е. количество выполнений цикла, опреде-
ляется формулой:
 b  a
k  log 2 .
 ε 
Конец примера.
38 Лекция 2

2.2. Другие рекуррентные алгоритмы

Пример 2.6. Полином от x степени n можно представить в виде фор-


мулы Горнера:
Pn ( x)  an x n  an1 x n1  ...  a1 x  a0  (...(an x  an1 ) x  ...  a1 ) x  a0 ,
где an, an–1, …, a1, a0 – коэффициенты полинома.
Из этой формулы можно получить следующее рекуррентное соотно-
шение для вычисления полинома при заданном значении x и коэффици-
ентов a0, a1, …, an :
 P0  an ,

Pi  Pi 1 x  ani , i  1, 2, ..., n .
В свою очередь, вычисления по рекуррентному соотношению можно
выполнить программой:
P:=a[n];
for i:=1 to n do
P:=P*x+a[n-i];
в которой коэффициенты полинома заданы в виде элементов массива a.
Конец примера.
Формулу полинома в примере 2.6 можно трактовать как представление
неотрицательного целого числа P(x) в системе счисления с целочислен-
ным основанием x. Тогда программа в примере 2.6 позволяет вычислить
значение числа при заданном основании x и наборе цифр в записи числа,
заданных в виде элементов массива a.
Пример 2.7. Из формулы в примере 2.6 можно получить другое рекур-
рентное соотношение для вычисления цифр a0, a1, …, an неотрицательно-
го целого числа Pn при заданном значении основания x:
 ai  Pni mod x,

Pni 1  Pni div x, i  0, 1, ..., n, Pni  0,

где операция mod есть вычисление остатка от деления, а div – деление с


отбрасыванием остатка. При этом подразумевается, что количество цифр
Рекуррентные алгоритмы 39

n в представлении числа Pn заранее не известно. Вычисления по рекур-


рентному соотношению можно выполнить программой:
i:=0;
while P>0 do
begin
a[i]:=P mod x;
P:=P div x;
i:=i+1
end;
n:=i-1;
После завершения цикла самый последний вычисленный ненулевой
элемент массива – a[i-1] (самая старшая цифра числа), переменная n
содержит номер этого элемента в массиве a.
Конец примера.
Пример 2.8. Числа Фибоначчи задаются следующей рекуррентной по-
следовательностью ранга 2:
 f 0  0, f1  1,

 f k  f k 1  f k  2 , k  2, 3, ... ,
n-й элемент которой вычисляется следующей программой:
f1:=0; f2:=1;
for k:=2 to n do
begin
f3:=f2+f1;
f1:=f2; f2:=f3
end
Докажем, что для этой рекуррентной последовательности справедлива
формула Муавра:
1   1  5   1  5  
n n

fn   . (*)
5   2   2  
 
Доказательство.
Базис. При n = 0 и n = 1 проверяем простой подстановкой в форму-
лу (*).
Предположение. Пусть при n ≥ 0 формула (*) верна.
40 Лекция 2

Вывод. При n + 1 по формулам получаем:

1   1  5   1  5   1   1  5   1  5  
n n n 1 n 1

f n1       
5   2   2   5   2   2  
 
   
 n 1
 1  5  
n 1
1   1  5   
  ,
5   2   2  
 
 
учитывая, что:
2
1 5 
   1 1 5 .
 2  2
 
Число   lim ( f k 1 / f k )  (1  5 ) / 2  1,61803398875, входящее в фор-
k 
мулу (*), называют золотым сечением. Заметим, что эту формулу целе-
сообразно применять при достаточно больших n. При этом вычисления
необходимо выполнять над вещественными, т.е. приближенными числами,
а результат округлять до целого. Так как абсолютное значение второго
слагаемого в формуле (*) меньше 0.5, то им можно пренебречь.
Конец примера.
Пример 2.9. Алгоритм Евклида, вычисляющий наибольший общий де-
литель НОД(a, b) двух целых чисел a ≥ 0, b ≥ 0, основан на следующих
инвариантных соотношениях:
1) НОД(a, 0) = a;
2) НОД(a, b) = НОД(b, a);
3) НОД(a, b) = НОД(a mod b, b), a ≥ b > 0.
Первые два соотношения очевидны. Докажем третье. Во-первых, заме-
тим, что операция r = a mod b эквивалентна многократному вычитанию b
из a до тех пор, пока не будет выполняться 0 ≤ r < b. Поэтому достаточно
доказать, что
НОД(a, b) = НОД(a – b, b), a ≥ b > 0.
Пусть верно противоположное утверждение, а именно:
НОД(a, b) = d, НОД(a – b, b) = c, причем c ≠ d.
Рекуррентные алгоритмы 41

ab a b ab b
Но тогда   , при этом , а также – оба целые, в то вре-
c c c c c
a
мя как может быть целым лишь в случае, если c = d. Это противоречие
c
и доказывает третье соотношение.
Из этих соотношений можно построить такую последовательность пар
{xi, yi}, что НОД(xi, yi) = НОД(a, b), max(xi, yi) > max(xi + 1, yi + 1):
 x0  a, y0  b,

 xi 1  xi mod yi , yi 1  yi , при xi  yi  0,
 y  y mod x , x  x , при yi  xi  0.
 i 1 i i i 1 i

Программа вычисляет последовательность {xi, yi} до тех пор, пока ее


можно продолжать (пока обе переменные, как x, так и y, положительны):
x:=a; y:=b;
while (x>0)and(y>0) do
if x>=y then x:=x mod y
else y:=y mod x;
if x>0 then nod:=x
else nod:=y
Трудоёмкость программы, т.е. количество выполнений цикла, опреде-
ляется числами a и b. При этом наихудший случай будет, если при вы-
полнении условия:
fk ≥ max(a, b) > fk – 1,
где fk , fk – 1 – числа последовательности Фибоначчи, когда операция
fk mod fk – 1 будет эквивалентна операции fk – fk – 1, и, кроме того, справед-
ливо:
max(a, b) = fk , min(a, b) = fk – 1 .
Тогда k (количество выполнений цикла) будет максимально возмож-
ным. Пренебрегая вторым слагаемым в формуле Муавра и логарифмируя
левую и правую части, получим:
k  log  ( 5 f k )  log  2  log 2 ( 5 f k ) ,
k  log  2  log 2 ( 5 max(a, b))  1.4  log 2 max(a, b) ,

где   (1  5 ) / 2 .
42 Лекция 2

Таким образом, трудоёмкость (в наихудшем) алгоритма Евклида имеет


порядок O(log max(a, b)).
Конец примера.
Пример 2.10. Программа быстрого возведения числа x в положитель-
ную целочисленную степень p. Результат – число z:
z:=1; s:=p; y:=x;
while s>0 do
begin
if odd(s) then begin
s:=s-1; z:=z*y
end;
s:=s div 2; y:=y*y
end;
Функция odd выдаёт значение «истина», если её аргумент – нечетное
целое число, и значение «ложь» – в противном случае. Для большей эф-
фективности эту функцию можно заменить выражением, состоящим из
поразрядной операции and над двоичным представлением целых чисел и
сравнением результата с числом 1:
(s and 1)=1.
Операцию s div 2 можно заменить операцией сдвига поразрядно-
го представления s вправо на 1 бит:
s:=s shr 1;
Завершимость программы следует из того, что до выполнения цикла
s≥1, а при каждом исполнении цикла s уменьшается (но остается неот-
рицательным, т.е. s=0 после окончания цикла).
Предусловие цикла: z=1, s=p, y=x; постусловие: s=0.
Инвариант: z*ys = xp. Правильность инварианта следует из соот-
ношений:
z*ys = (z*y)*ys-1 = z*(y*y)s/2,
и из соотношений пред- и постусловия.
Из правильности инварианта и постусловия следует, что после завер-
шения цикла z = xp.
Подсчитаем количество выполнений цикла, учитывая, что при каждом
исполнении выполняется одно или два умножения z*y и y*y. При каждом
Рекуррентные алгоритмы 43

исполнении цикла число s уменьшается не менее чем в 2 раза. Поэтому


если первоначально выполняется:
2k ≤ s = p < 2k+1,
то после k шагов s=1, а после k+1 шагов цикл завершится. Таким обра-
зом, общее количество умножений не превысит 2(1 + └log2 p┘), а в целом
порядок трудоёмкости – O(log p).
Так, при возведении в степень 1000 цикл выполнится 11 раз, а количе-
ство умножений будет менее чем 22.
Конец примера.

Вопросы и задания

1. Доказать корректность программы, вычисляющей сумму разложения функции


sin x, методом инварианта.
2. Приближённое значение функции cos x:
x2 x4 x 2n
cos x  1   ...  (1) n .
2! 4! (2n)!
Записать рекуррентное соотношение для элементов суммы и программу вычисле-
ния cos x при заданном значении x с заданной точностью ε.
3. Написать программу, которая вводит значение для x и заданную точность ε и вы-
числяет x по формуле Герона, подсчитывая при этом, сколько итераций будет
выполнено в цикле и оценку количества итераций в наихудшем. Проверить работу
программы на тестах для x, равном соответственно: 0,000001, 0,0001, 0,01, 100,
10000, 1000000 при разных значениях ε.
4. Написать и отладить программу, вычисляющую квадратный корень числа по фор-
муле Герона и (для сравнения) с помощью стандартной функции sqrt. Опреде-
лить (с помощью счетчика в цикле), сколько необходимо вычислить членов после-
довательности, чтобы добиться относительной точности вычисления квадратного
корня 10–6 из чисел 0.0001, 0.01, 0.5, 2, 100, 10 4, 106.
5. Написать программу, которая вводит границы интервала a, b и заданную точность
ε, и вычисляет методом дихотомии корень функции y = x3 – 7x2 + 9x – 8. Вводя раз-
личные границы, вычислить все корни этой функции.
6. Написать программу, которая вводит границы интервала a, b и заданную точность
ε, и вычисляет методом дихотомии корень функции y = x4 – 10 x3 + 35 x2 – 40 x + 24 .
С помощью этой программы найти корни функции на интервалах: [0.5, 1.5],
[1.5, 2.5], [2.5, 3.5], [3.5, 4.5].
44 Лекция 2

7. Написать программу, которая вводит основание системы счисления, а затем целое


число, записанное в этой системе. После этого вводит основание второй системы
счисления, вычисляет и выводит представление числа во второй системе счисления.
Придумать принципы представления чисел с основанием, бóльшим, чем 10. Разра-
ботать и обосновать тесты для этой программы, протестировать ее для чисел с ос-
новапниями: 2, 8, 10, 16.
8. Доказать корректность программы, вычисляющей числа Фибоначчи, методом инва-
рианта.
9. Написать программу, вычисляющую n-е число Фибоначчи по рекуррентной после-
довательности и по урезанной формуле Муавра без второго члена. Подбирая n, вы-
яснить, начиная с какого номера по урезанной формуле Муавра можно вычислить
числа Фибоначчи с точностью 0.5. С точностью 0.001?
10. Написать программу, вычисляющую НОД для двух вводимых целых чисел. Разра-
ботать и обосновать для нее тесты, провести тестирование и отладку. Предусмот-
реть счетчик для вычисления количества выполнений цикла. Для различных вводи-
мых чисел сравнить значение счетчика с оценкой трудоёмкости алгоритма Евклида.
11. Написать программу, которая вводит число x(типа double), положительное целое
p, и вычисляет xp перемножением числа x само на себя (p – 1) раз, а также ускорен-
ным алгоритмом. Протестировать программу для различных x: 2, 1.1, 1.01, 1.0001,
и различных p: 10, 100, 1000. Сравнить между собой результаты (выводимые с от-
носительной точностью 15 знаков), получаемые этими двумя способами.
Лекция 3.
Поиск и сортировка

3.1. Поиск в массиве

Задача поиска состоит в следующем. Пусть задан массив A из n эле-


ментов, пронумерованных от 1 до n и некоторое значение p (поисковое).
Требуется найти такой номер i, чтобы выполнялось: A[i]=p.
Результаты поиска могут быть такими:
1) существует единственный элемент с номером i, для которого
A[i]=p;
2) в массиве A не существует ни одного элемента, равного p, т.е.
A[i]≠p при любых i=1,2,...,n;
3) в массиве A существует несколько элементов с номерами
i1,i2,... таких, что A[i1]=p, A[i2]=p,... и т.д.
Чаще всего при поиске требуется найти любой элемент, равный p.
Пример 3.1. Поиск в неупорядоченном массиве. Для поиска в алго-
ритме сравниваются элементы массива, начиная с 1-го, со значением p до
тех пор, пока не будет найдено совпадение или пока не будут просмотрены
все элементы:
i:=0; k:=1;
while k<=n do
if A[k]=p then begin
i:=k; k:=n+1
end
else k:=k+1;
Результат работы алгоритма: если i > 0, то A[i] = p, если i=0, то
элемента со значением p в массиве нет.
Инвариант цикла:
1) если i=0, то среди элементов массива {A[1], ..., A[k-1]} нет
ни одного, равного р,
2) если i>0, то A[i]=p, k=n+1.
Трудоёмкость в наихудшем: O(n).
Конец примера.
46 Лекция 3

Пример 3.2. Поиск в упорядоченном массиве. Пусть массив упорядо-


чен по неубыванию:
A[1]≤ A[2]≤ ...≤A[n].
Определим в массиве область «подозрительных» элементов таким об-
разом: если в массиве элемент со значением p существует, то он имеется
внутри этой области. Обозначим номер начального элемента области пе-
ременной b, а номер конечного элемента – переменной e. До начала рабо-
ты алгоритма b=1,e=n.
Поиск называется дихотомическим, потому что на каждом шаге рабо-
ты алгоритма область подозрительных элементов уменьшается вдвое. Для
этого вычисляется номер элемента с в середине области по формуле:
c=(b+e)div 2.
Возможны два случая:
1) A[c]<p. Тогда искомый элемент может находиться среди:
A[c+1],...,A[e];
2) A[c]≥p. Искомый элемент может находиться среди:
A[b],...,A[c].
Заметим, что область подозрительных элементов уменьшается вдвое,
даже если в ней было всего 2 элемента.
b:=1; e:=n; {1}
while b<e do {2}
begin {3}
c:=(b+e)div 2; {4}
if A[c]<p then b:=c+1 {5}
else e:=c {6}
end; {7}
if A[b]=p then i:=b else i:=0 {8}
Поиск продолжается до тех пор, пока область подозрительных элементов
не уменьшится до одного элемента. После этого остается проверить, сов-
падает ли этот едиственный элемент со значением p.
Предусловие цикла: b=1, e=n.
Постусловие: b=e.
Инвариант: искомый элемент (если он существует) находится среди:
A[b], ...,A[e].
Подсчитаем трудоёмкость алгоритма. Пусть:
2m – 1 < k ≤ 2m, (*)
Поиск и сортировка 47

где k – размер подозрительной области.


Вначале k = n. При четном k размеры области уменьшаются в два раза,
а при нечетном – в два с округлением, неравенство (*) сохраняется.
После m = log2 n шагов размер подозрительной области k = 1.
Общее количество сравнений C в наихудшем случае: C = log2 n + 1, с
учетом сравнения после цикла, а в целом порядок трудоёмкости – O(log n).
В упорядоченном массиве элементы, равные поисковому значению,
располагаются подряд. Программа ищет номер начального элемента такой
группы, так как в обоих случаях этот элемент остаётся в области.
Чтобы отыскать номер последнего элемента в этой группе, программу
следует изменить в строках 4-6:
c:=(b+e+1)div 2; {4}
if A[c]<=p then b:=c {5}
else e:=c-1 {6}
Заметим, что изменив только строки 5-6, но не изменив строку 4, мы
получим зацикливающийся алгоритм!
Конец примера.
Пример 3.3. Удаление повторяющихся элементов в упорядоченном
массиве. В таком массиве элементы с одинаковым значением располагают-
ся подряд. Из исходного массива А требуется сформировать такой массив
С, в котором имеются все значения из массива А, но без повторений. Алго-
ритм просматривает последовательно все элементы из массива А, при этом
в массив С копирует первый элемент из каждой группы одинаковых:
m:=1; C[1]:=A[1];
for i:=2 to n do
if C[m]<A[i] then begin
m:=m+1; C[m]:=A[i]
end;
Количество элементов, скопированных в массив С, подсчитывается в
переменной m.
Инвариант цикла: среди элементов {C[1],…,C[m]} имеются все,
которые есть среди {A[1],…,A[i-1]}, но без повторений.
Очевидно, что трудоёмкость алгоритма O(n).
Конец примера.
48 Лекция 3

Пример 3.4. В произвольном массиве A из n элементов требуется


определить начало bm и конец em упорядоченного (по неубыванию) отрез-
ка максимальной длины. Любой упорядоченный отрезок начинается либо с
первого элемента, либо с такого, что предыдущий элемент строго больше
его. Конечный элемент отрезка либо последний в массиве, либо следую-
щий за ним элемент строго меньше его. Такой отрезок может содержать,
как минимум, один элемент. В алгоритме в переменной b фиксируется
начало очередного просматриваемого отрезка:
b:=1; bm:=1; em:=1;
for i:=1 to n do
if (i=n)or(A[i]>A[i+1]) then
begin {обнаружен конец очередного отрезка}
if (i-b)>(em-bm) then begin
bm:=b; em:=i
end;
b:=i+1
end;
Инвариант цикла: среди элементов {A[1],…,A[b]} имеется отре-
зок максимальной длины от элемента A[bm] до элемента A[em].
Очевидно, что трудоёмкость алгоритма O(n).
Конец примера.

3.2. Простые алгоритмы сортировки

Задача сортировки (упорядочения) массива является одной из самых


важных, поэтому для ее решения придуманы различные алгоритмы.
Пример 3.5. Простейший алгоритм сортировки, в котором всего лишь
один цикл и один условный оператор:
i:=1;
while i<n do
if X[i]<=X[i+1] then i:=i+1
else begin
z:=X[i]; X[i]:=X[i+1]; X[i+1]:=z;
i:=1
end;
Поиск и сортировка 49

Инвариант цикла: {X[1]≤X[2]≤. . . ≤X[i]},


«Набор значений в массиве X остается неизменным».
Так как условие завершения цикла i=n, то после завершения цикла
массив станет полностью упорядоченным. Однако завершимость алгорит-
ма не очевидна.
Определим инверсию следующим образом: если X[i]>X[j] при i<j
то для этой пары элементов массива существует инверсия.
Вычислим максимально возможное количество инверсий для различ-
ных j:
если j=2, то 1 инверсия,
если j=3, то 2 инверсии,
. . .
если j=n, то n – 1 инверсий.
Всего инверсий: 1 + 2 + . . . + (n – 1) = n·(n – 1)/2.
Так как при обнаружении инверсии (условие X[i]<=X[i+1] ложно)
в алгоритме после обмена значениями количество инверсий уменьшается
на 1, то рано или поздно все инверсии исчезнут. Но тогда при работе цикла
переменная i будет увеличиваться на каждом шаге и, в конце концов, ста-
нет равной n, после чего цикл завершится.
Инверсия при некотором i может быть обнаружена, если выполняется:
X[1]≤X[2]≤ ... ≤X[i], X[i]>X[i+1]. (*)
Поэтому, чтобы устранить одну инверсию, потребуется i шагов цикла,
чтобы дойти от 1-го элемента массива до i-го. В худшем случае при вы-
полнении условия (*) выполняется также: X[1]>X[i+1]. Тогда, чтобы
стало выполняться условие:
X[1]≤X[2]≤ ... ≤X[i+1], (**)
цикл должен выполниться R(i) раз:
R(i) = 1 + 2 + . . . + i = i·(i + 1)/2.
Тогда, чтобы устранить все n·(n – 1)/2 инверсий, цикл должен выполниться
Т(i) раз:
Т(i) = R(1) + R(2) + . . . + R(n – 1) ≈ n3/6.
Таким образом, трудоёмкость в наихудшем имеет порядок O(n3).
50 Лекция 3

Заметим, что трудоёмкость в наилучшем, когда массив уже полностью


упорядочен, имеет порядок O(n).
Конец примера.
Пример 3.6. Улучшение простейшего алгоритма сортировки:
i:=1;
while i<n do
if X[i]<=X[i+1] then i:=i+1
else begin
z:=X[i]; X[i]:=X[i+1];X[i+1]:=z;
if i>1 then i:=i-1;{вместо: i:=1}
end;
Инвариант цикла при этом остается прежним, однако в наихудшем
случае на устранение каждой инверсии потребуется ровно два шага цикла,
а всего будет n·(n – 1) шагов. Таким образом, трудоёмкость в наихудшем
будет иметь порядок O(n2).
После изменения алгоритма трудоёмкость в наихудшем уменьшилась
примерно в n/6 раз. Так, массив из 300 элементов будет обрабатываться в
50 раз быстрее, а массив из 30000 элементов – в 5000 раз быстрее!
Конец примера.
Пример 3.7. Алгоритм обменной сортировки. Его можно считать
дальнейшим усовершенствованием простейшего алгоритма сортировки:
for i:=1 to n-1 do
begin j:=i;
while (j>0)and(X[j]>X[j+1]) do
begin
z:= X[j+1]; X[j+1]:=X[j]; X[j]:=z;
j:=j-1
end;
end;
Так как в нем используется два вложенных цикла, то для доказатель-
ства, следует использовать метод абстракции, по отдельности анализируя
внутренний и внешний цикл.
Инвариант внутреннего цикла:
{X[1] ≤ X[2] ≤ . . . ≤ X[j]},
Поиск и сортировка 51

{X[j+1] ≤ X[j+2] ≤ . . . ≤ X[i]},


«Набор значений в массиве X остается неизменным»
Инвариант внешнего цикла:
{X[1] ≤ X[2] ≤ . . . ≤ X[i]}.
Трудоёмкость алгоритма. Общее количество выполнений внутренне-
го цикла в наихудшем случае:
1 + 2 + . . . + (n – 1) = n·(n – 1)/2,
Т.е. трудоёмкость в наихудшем O(n2).
Если массив уже упорядочен, то внутренний цикл ни разу не будет ис-
полняться, и тогда трудоёмкость (в наилучшем!) будет иметь порядок O(n).
Конец примера.
Пример 3.8. Алгоритм сортировки вставками. Этот алгоритм, в свою
очередь, является усовершенствованием алгоритма обменной сортировки.
В нём во внутреннем цикле вместо трех присваиваний используется лишь
одно. При этом элементы массива сдвигаются вправо, освобождая место
для вставляемого элемента:
for i:=1 to n-1 do
begin j:=i; z:= X[i+1];
while (j>0)and(X[j]>z) do
begin X[j+1]:=X[j]; j:=j-1 end;
X[j+1]:=z;
end;
Доказательство: метод абстракции.
Инвариант внутреннего цикла:
{X[1] ≤ X[2] ≤ . . . ≤ X[j]},
z = X[i+1], {z ≤ X[j+2] ≤ . . . ≤ X[i]},
«Набор значений в массиве X и z остаются неизменными»
Инвариант внешнего цикла:
{X[1] ≤ X[2] ≤ . . . ≤ X[i]}.
Трудоёмкость в наихудшем такая же, как в предыдущем алгоритме,
O(n2). При этом алгоритм сортировки вставками выполняется быстрее бла-
годаря меньшему количеству действий во внутреннем цикле. Т.е. для него
множитель перед n2 меньше.
Трудоёмкость в наилучшем имеет порядок O(n).
Конец примера.
52 Лекция 3

Пример 3.9. Алгоритм сортировки выбором. В алгоритме во внутрен-


нем цикле из элементов в диапазоне от j–го до n–го выбирается мини-
мальный, после чего он обменивается с j–м элементом.
{предусловие: j=1}
for j:=1 to n-1 do
begin k:=j; {предусловие: i=j+1, k=j}
for i:=j+1 to n do
if A[k]>A[i] then k:=i;
{постусловие: i=n+1}
{инвариант: k = argmin(A[j],...,A[i-1])}
z:=A[j]; A[j]:=A[k]; A[k]:=z
end;
{постусловие: j=n}
Инвариант внешнего цикла:
{A[1]≤A[2]≤,...≤A[j-1]},A[j-1]≤min{A[j],...,A[n]}
Постусловие: A[1]≤A[2]≤,...≤A[n].
Трудоёмкость. Общее количество выполнений внутреннего цикла:
1 + 2 + . . . + (n – 1) = n·(n – 1)/2.
Т.е. трудоёмкость O(n2), как в наихудшем, так и в наилучшем.
Конец примера.
Пример 3.10. Алгоритм пузырьковой сортировки:
for i:=n downto 2 do
for j:=1 to i-1 do
if X[j]>X[j+1] then
begin
z:=X[j]; X[j]:=X[j+1];
X[j+1]:=z
end;
После очередного выполнения внутреннего цикла наибольший среди
первых i элементов окажется на i-м месте в массиве. Общее количество
выполнений внутреннего цикла равно n·(n – 1)/2, Трудоёмкость: O(n2).
Конец примера.
Поиск и сортировка 53

Косвенная упорядоченность. Задается дополнительным массивом,


при этом элементы упорядочеваемого массива не переставляются, а оста-
ются на своих местах. Для косвенного упорядочения массива А задается
такой дополнительный массив In из n элементов, называемый индексным,
что выполняется:
A[In[1]]≤ A[In[2]]≤ ... ≤A[In[n]].
Элемент In[i] обозначает, что если массив A упорядочить, то эле-
мент A[In[i]] будет располагаться на i–м месте.
Модификация алгоритма сортировки для косвенной упорядоченности:
1) вначале элементам массива In надо присвоить начальные значения:
In[1]=1, In[2]=2, . . ., In[n]=n;
2) везде в программе, где элемент массива A[j] используется в опе-
рации сравнения, заменить A[j] на A[In[j]];
3) везде в программе, где элемент массива A[j] используется в при-
сваивании, заменить A[j] на In[j].
Для модификации можно использовать любой алгоритм сортировки.
Пример 3.11. Модификация простейшего алгоритма сортировки для
косвенного упорядочения массива А:
for i:=1 to n In[i]:=i;
i:=1;
while i<n do
if А[In[i]]<=А[In[i+1]]
then i:=i+1
else begin
z:=In[i]; In[i]:= In[i+1]; In[i+1]:=z;
i:=1
end;
Сравнения выполняются с использованием массива In, обмениваются
также элементы массива In.
Конец примера.
Пример 3.12. Модификация дихотомического поиска в косвенно упо-
рядоченном массиве А:
54 Лекция 3

b:=1; e:=n;
while b<e do
begin
c:=(b+e)div 2;
if A[In[c]]<p then b:=c+1
else e:=c
end;
if A[In[b]]=p then i:=b else i:=0
Конец примера.

3.3. Динамические массивы и случайные числа

Массивы в программе могут быть статическими, когда в описании


массива границы индексов задаются константами. При этом одновременно
выделяется память для размещения элементов массивов.
В описании переменных – динамических массивов границы индексов
не задаются, и память для размещения элементов массивов не выделяется.
Выделение памяти для динамических массивов производится при выпол-
нении программы в момент вызова специальной процедуры SetLength.
Пример 3.13. Описание переменных A, B – одномерных динамиче-
ских массивов и переменной C – двумерного динамического массива:
var A, B: array of integer;
C: array of array of integer;
Выделение памяти для этих массивов:
n:=100;
SetLength(A,n); SetLength(B,n);
SetLength(C,10,15);
Нумерация элементов в динамических массив начинается с нуля:
A[0], A[1],. . ., A[n-1];
C[0,0],C[0,1],. . .,C[0,14],
. . .
C[9,0],C[9,1],. . .,C[9,14].
Поиск и сортировка 55

После того, как массивы перестанут использоваться в программе,


можно выполнить освобождение памяти:
SetLength(A,0);SetLength(B,0);
SetLength(C,0,0);
Конец примера.
Пример 3.14. Программа с динамическим массивом:
program ex14;
var X: array of integer;
n, i, z: integer;
begin readln(n); SetLength(X,n);
for i:=0 do n-1 do read(X[i]);
i:=0;
while i<n-1 do
if X[i]<=X[i+1] then i:=i+1
else begin z:=X[i]; X[i]:=X[i+1];
X[i+1]:=z; i:=1
end;
for i:=0 do n-1 do write(X[i],’ ’);
writeln; SetLength(X,0);
end.
Вначале программа вводит количество n элементов в массиве X и вы-
деляет для него память. После этого в цикле вводит значения элементов
массива X, с нулевого номера по n-1 включительно. Затем массив X упо-
рядочивается простейшим алгоритмом, и в цикле выводится. При выводе
после значения каждого элемента выводится пробел, чтобы отделить одно
число от другого. В конце выделенная массиву X память освобождается.
Конец примера.
Переназначение стандартного ввода и вывода. При выполнении
программы вместо стандартного ввода данных с клавиатуры и вывода на
экран можно сделать ввод из файла и вывод в файл. Для этого необходимо
создать вводимый файл и записать в него входные данные так, как будто
они вводились с клавиатуры (включая все пробелы и переходы на новые
строки ввода). Этот файл должен быть записан в ту же папку, где записана
исполняемая программа, в которой продусмотрен ввод таких данных.
56 Лекция 3

Пусть, например, prog.exe – исполняемая программа (после транс-


ляции), вводимый файл – in.txt, тогда вызов из командной строки будет
следующим:
. . . >prog <in.txt >out.txt
Файл in.txt можно создать с помощью программы «блокнот» или
Far. Файл out.txt создается при выполнении программы prog.exe,
его можно затем просмотреть в программе «блокнот» или Far.
Использование файлов для ввода и вывода данных особенно полезно
при тестировании программы, так как после обнаружения и исправления
какой-либо в ней ошибки необходимо провести её повторное тестирование
на тех же самых тестах, на которых она проверялась ранее.
Другой способ переназначения стандартного ввода и вывода – записать
в начале программы вызов стандандартных процедур: reset – для пере-
назначения ввода и rewrite – для переназначения вывода. Например,
для переназначения ввода и вывода на файлы in.txt и out.txt соот-
ветственно:
reset(input,’in.txt’);
rewrite(output,’out.txt’);
Генерация случайных чисел. Если для тестирования программы тре-
буется большое количество данных, то вместо подготовки этих данных
вручную можно написать программу для их генерации. Для этого можно
использовать стандартную функцию random.
Пример 3.15. Программа создания файла in.txt содержащего
число n, а затем – n случайных чисел:
program ex6;
var n,d,r,i: integer;
begin rewrite(output,’in.txt’);
readln(n,d,r); {d – диапазон чисел}
randseed:=r; {r - начальное случайное число}
writeln(n);
for i:=0 to n-1 do
write(random(d):5);
writeln;
end.
Поиск и сортировка 57

Вначале вводятся значения для переменных n, d, r. До генерации


стандартной переменной randseed присваивается начальное значение r.
В файл output выводится введённое значение n, затем – перевод строки
вывода. После этого в цикле генерируются случайные целые числа из диа-
пазона от 0 до d-1 включительно и записываются в файл, для каждого
числа выделяется по 5 символьных позиций.
В дальнейшем файл in.txt можно использовать, как входной, для
тестирования программы сортировки чисел.
Конец примера.
Хотя генерируемые функцией random числа называют случайными,
но они лишь имитируют случайность. Компьютер – детерминированная
система, он в принципе не допускает никаких случайностей.
Стандартная функция random генерирует числа по рекуррентной
формуле:
xi+1 = (a∙xi + c) mod m,
где a, c, m – особые константы (целые числа), xi , xi+1 – предыдущий и по-
следующий элементы случайной последовательности, их значение хранит-
ся в стандартной переменной randseed.
При вызове функции random(d) окончательный результат f вычисля-
ется по формуле:
f = (xi+1 / m) mod d,
где деление производится с отбрасыванием дробной части.
Если требуется генерировать различные случайные последовательно-
сти, то перед генерацией каждой последовательности необходимо задавать
различные начальные значения для переменной randseed.

Вопросы и задания

1. Провести полное доказательство методом инварианта программы поиска элемента


в неупорядоченном массиве. Доказать, что если в массиве имеется несколько эле-
ментов, равных p, то программа вычислит наименьший номер среди них.
2. Изменить программу поиска элемента в неупорядоченном массиве так, чтобы в
случае, если в массиве имеется несколько элементов, равных p, то программа вы-
числяла наибольший номер среди них. Провести полное доказательство этого факта.
58 Лекция 3

3. Провести полное доказательство методом инварианта программы дихотомического


поиска элемента в упорядоченном массиве. Доказать, что если в массиве имеется
несколько элементов, равных p, то программа вычислит наименьший номер среди
них.
4. Изменить программу дихотомического поиска элемента в упорядоченном массиве
так, чтобы в ней велся поиск элемента, равного p, с наибольшим номером. Прове-
сти полное доказательство этого факта.
5. Провести полное доказательство методом инварианта программы удаления повто-
ряющихся значений элементов в упорядоченном массиве.
6. Провести полное доказательство методом инварианта программы поиска начала и
конца упорядоченного отрезка максимальной длины в массиве.
7. Написать программу, которая вводит число n, затем выделяет память для n элемен-
тов массива А, и вводит n элементов в массив А. После этого выполняет поиск
начала и конца упорядоченного отрезка максимальной длины, и затем выводит его
начало и конец. Создать тесты для программы методом чёрного и белого ящика и
провести тестирование.
8. Провести полное доказательство методом инварианта программы улучшенного
простейшего алгоритма сортировки. За счёт чего ускоряется его работа?
9. Провести полное доказательство методом инварианта программы обменной сорти-
ровки.
10. Провести полное доказательство методом инварианта программы сортировки
вставками.
11. Провести полное доказательство методом инварианта программы сортировки вы-
бором.
12. Провести полное доказательство методом инварианта программы пузырьковой
сортировки.
13. Написать программу, которая вводит число n, затем выделяет память для n элемен-
тов массива А, и вводит n элементов в массив А. После этого упорядочивает его ал-
горитмом сортировки выбором, и затем выводит его элементы. Создать тесты для
программы методом чёрного и белого ящика и провести тестирование.
14. Написать программу, которая вводит число n, затем выделяет память для n элемен-
тов массива А и вводит n элементов в массив А. После этого косвенно упорядочива-
ет его алгоритмом сортировки вставками, и затем выводит его элементы в упорядо-
ченном виде. Создать тесты для программы методом чёрного и белого ящика и
провести тестирование.
15. Написать программу, которая вводит число n, затем выделяет память для n элемен-
тов массива А, и вводит n элементов в массив А. После этого косвенно упорядочи-
вает его алгоритмом пузырьковой сортировки, вводит поисковое значение р, вы-
полняет дихотомический поиск, затем выводит номер элемента, равного р, в масси-
Поиск и сортировка 59

ве А. Создать тесты для программы методом чёрного и белого ящика и провести те-
стирование.
16. Написать программу, которая вводит числа n, d, r и строку символов S c именем для
файла, затем переназначает стандартный вывод на этот файл. После этого выводит
в файл число n, генерирует и записывает в файл n случайных чисел из диапазона от
–d до +d при заданном начальном значении r. Создать тесты для программы мето-
дом чёрного и белого ящика и провести тестирование.
17. Написать программу, которая вводит строку символов S c именем для входного
файла (который записан программой из предыдущего задания), затем переназначает
стандартный ввод на этот файл. Из файла считывает число n, выделяет память для n
элементов массива А и вводит n элементов в массив А. После этого косвенно упоря-
дочивает его алгоритмом сортировки выбором и затем выводит его элементы в
упорядоченном виде без повторений. Создать тесты для программы методом чёрно-
го и белого ящика и провести тестирование.
Лекция 4.
Рекурсия

4.1. Процедуры и функции

Процедуры и функции позволяют создавать такие отдельные алгорит-


мы, из которых, как из строительных блоков, можно впоследствии кон-
струировать большие программы.
Описание процедуры или функции – алгоритм, который может ис-
полняться в будущем. В описании могут определяться аргументы (пара-
метры), они называются формальными.
Вызов процедуры или функции может выполняться в программе или
другой процедуре или функции, когда вместо формальных параметров
подставлены фактические параметры. Их типы должны соответствовать
друг другу!
Вызов функции выполняется внутри вычисляемого выражения, ре-
зультат работы функции (возвращаемое значение) используется при вы-
числении этого выражения.
Вызов процедуры записывается как отдельный оператор.
Подстановка параметров при вызове. При вызове процедуры или
функции возможны следующие варианты подстановки параметров:
1) подстановка по значению, при вызове вместо формального парамет-
ра может стоять вычисляемое выражение (в частности, константа или имя
переменной) такому параметру нельзя присваивать новое значение внутри
алгоритма процедуры;
2) по ссылке на имя переменной (тогда в описании перед именем па-
раметра пишется var), при вызове вместо формального параметра может
стоять только имя переменной, в частности, массива, такому параметру
можно присваивать новое значение внутри алгоритма процедуры;
Формальные параметры – массивы целесообразно описывать без ука-
зания границ, чтобы можно было при вызове подставлять массивы с раз-
личным количеством элементов.
Рекурсия 61

Пример 4.1. Программа с процедурой, вычисляющей минимальное


значение в массиве.
program ex1;
procedure pmin(var X: array of integer;
n: integer; var r: integer);
var i: integer; {i – локальная переменная}
begin r:=X[0];
for i:=1 to n-1 do
if r>X[i] then r:=X[i];
end; {конец описания процедуры}
var A: array of integer;
n,i,min: integer;
begin readln(n); SetLength(A,n);
for i:=0 to n-1 do read(A[i]);
pmin(A,n,min); {вызов процедуры}
writeln(’min=’,min);
SetLength(A,0);
end.
Процедура pmin описана с тремя формальными параметрами: X – мас-
сив без указания границ, он будет подставляться по ссылке, n – целое, под-
становка по значению, r – целочисленная переменная, подстановка по
ссылке. Внутри алгоритма процедуры описана локальная переменная i,
она действует только внутри процедуры. В алгоритме процедуры перемен-
ной r присваивается минимальное значение среди элементов массива X.
Далее описаны переменные, действующие в главной программе, при-
чем переменная i не имеет ничего общего с такой же переменной, описан-
ной внутри процедуры, а переменная n – с такой же переменной, парамет-
ром процедуры.
Выполнение программы начинается с ввода числа для n – размера
массива. Далее для массива A выделяется память – n целочисленных эле-
ментов. После этого в цикле вводятся n значений для массива A. Затем
выполняется вызов процедуры pmin с подстановкой параметров: вместо X
подставляется A, вместо n – n, вместо r – min. Результатом работы вызова
процедуры pmin является вычисленное в процедуре минимальное значе-
ние в массиве A, записанное в переменную min. В конце программы выво-
62 Лекция 4

дится надпись (min=) и значение переменной min. Кроме того, выделен-


ная массиву A память освобождается.
Конец примера.
Пример 4.2. Программа с функцией, вычисляющей минимальное зна-
чение в массиве.
program ex8;
function pmin(var X: array of integer;
n: integer): integer;
var i,r: integer; {i,r – локальные переменные}
begin r:=X[0];
for i:=1 to n-1 do
if r>X[i] then r:=X[i];
pmin:=r;
end; {конец описания функции}
var A: array of integer;
n,i,min: integer;
begin readln(n); SetLength(A,n);
for i:=0 to n-1 do read(A[i]);
min:=pmin(A,n); {вызов функции}
writeln(’min=’,min);
end.
В отличие от предыдущей программы здесь описана функция, а не
процедура. В описании функции нет третьего параметра, но указан тип
возвращаемого функцией значения (тип integer). Внутри алгоритма
функции описаны две локальнве переменные – i и r. Полученное в пере-
менной r минимальное значение среди элементов массива X в самом конце
описания присваивается имени функции, как возвращаемое значение.
Вызов функции отличается от вызова процедуры тем, что вызов функ-
ции выполняется в правой части присваивания, в котором переменная min
получает результат вычисления – минимальное значение в массиве A.
Конец примера.
Рекурсия 63

4.2. Рекурсивные алгоритмы

Рекурсивный алгоритм записывается в виде процедуры или функции,


которыая вызывает сама себя. В частности, вычисления по рекуррентной
последовательности можно записать как рекурсивный алгоритм.
Пример 4.3. Факториал – рекуррентная последовательность ранга 1:
 0! 1,

n! n  (n  1)! n  1, 2, ...
function fact(n:integer): integer;
begin
if n<=0 then fact:=1
else fact:=fact(n-1)*n
end; {функция вызывает сама себя!}
Доказательство.
Базис индукции. При n = 0 функция fact вычислит результат 0.
Предположение. Пусть функция fact вычисляет правильный резуль-
тат n! при n = k, k ≥ 0.
Индукция. Убеждаемся, что при n = k + 1 функция fact вычислит ре-
зультат, равный (n – 1)!·n = n!.
Проследим процесс вычислений при вызове:
d:=fact(3);
Чтобы вычислить fact(3)согласно алгоритму вначале вычисляется
fact(2), для вычисления которого вычисляется fact(1), затем вычис-
ляется fact(0). Это можно представить, как погружение «вглубь» вы-
числений, как в левой таблице на рис. 4.1. Наконец, при вычислении
fact(0)на выходе из алгоритма получается результат, равный 1, далее
при выходе из вычисления fact(1)вычисляется fact(2), и, наконец,
вычисляется fact(3). Этот процесс – воврат из рекурсии – представлен в
правой таблице на рис. 4.1.
Глубина рекурсии – количество повторных вызовов функции или про-
цедуры до выхода.
В этом примере глубина рекурсии равна 4, а в общем случае при вызо-
ве fact(n) – n+1.
64 Лекция 4

n fact(n); n fact(n);
3 3 6
2 2 2
1 1 1
0 0 1
Рис. 4.1

Конец примера.
При каждом вызове в момент входа в процедуру или функцию выделя-
ется память для аргументов и для всех внутренних переменных, описанных
внутри процедуры или функции. Поэтому максимальная глубина рекурсии
определяет максимальный размер выделяемой памяти, которую надо учи-
тывать при анализе рекурсивных процедур и функций. При этом особое
внимание следует обращать на то, как описаны способы подстановки фор-
мальных параметров – по значению или по ссылке. При подстановке по
значению в стеке выделяется память для значения этого параметра в мо-
мент вызова (если параметр массив, то выделяется память для всех эле-
ментов массива, т.е. создается его копия, и все действия с массивом внутри
процедуры или функции будут выполняться с этой копией). При подста-
новке по ссылке выделяется память для адреса в памяти, где расположено
значение фактического параметра (так, для массива копия не создаётся, все
действия с массивом будут выполняться с подставленным при вызове
функции массивом).
Пример 4.4. Задача «Ханойские башни». Имеется три колышка, обо-
значенные буквами: a,b,c. На колышке a нанизано n дисков в виде
башни, как в детской пирамидке.
Задача: переложить всю башню на колышек c, перекладывая по одному
диску так, чтобы любой диск большего размера не лежал на меньшем дис-
ке для любого из колышков.
Доказательство.
Базис. Число дисков n=1. Переложить диск с колышка a на
колышек c.
Рекурсия 65

Предположение. Пусть алгоритм умеет перекладывать башни из


n=k≥1 дисков.
Индукция. Пусть n=k+1≥2. Перекладываем башню из k дисков
(верхнюю часть) с колышка a на колышек b, затем один нижний диск с
колышка a на колышек c и, наконец, башню из k дисков с колышка b
на колышек c. Задача решена!
На рис. 4.2 показан этот процесс:
1) исходное положение – k+1 диск на колышке а;
2) после перекладывания k дисков на колышек b;
3) после перекладывания нижнего диска на колышек с;
4) после перекладывания k дисков с колышка b на колышек с, т.е.
окончательное положение.

Рис. 4.2
66 Лекция 4

Алгоритм решения игры можно записать, повторяя процесс доказа-


тельства. В процедуре процесс перекладывания одного диска записан в
виде вывода номера колышка, где лежал диск, и номера колышка, на кото-
рый переложен диск.
Если при проведении доказательства учесть, что нижний диск можно
переложить тогда и только тогда, когда все верхние диски переложены в
виде башни на промежуточный колышек, то из этого следует, что алгоритм
реализует минимально возможное количество перекладываний!
procedure hanoi(n,a,b,c:integer);
begin
if n=1 then writeln(a,’>’,c)
else begin
hanoi(n-1,a,c,b);
writeln(a,’>’,c);
hanoi(n-1,b,a,c);
end;
end;
Пример вызова для 5 дисков: hanoi(5,1,2,3);
Обозначим количество перекладываний n дисков, как функцию H(n).
Рекуррентное соотношение для функции H(n):
 1, n  1,
H ( n)  
2H (n  1)  1, n  2, 3, ...,

Из него получаем:
H(n) = 2H(n – 1) + 1 = 22H(n – 2) + 2 + 1 = 2n – 1 + 2n – 2 + … + 2 + 1 = 2n – 1.
Таким образом, трудоёмкость алгоритма O(2n), и это минимально воз-
можная трудоёмкость решения задачи. Глубина рекурсии: n.
Если на перекладывание одного диска тратить 1 секунду, то при n = 64
всю работу можно завершить за 264 с ≈ 580 млрд. лет.
Конец примера.
Пример 4.5. Рекурсивное вычисление минимального значения среди
элементов массива A[0],...,A[n]:
Рекурсия 67

function fmin(var A: array of integer,


n:integer):integer;
var mi:integer;
begin
if n=0 then fmin:=A[0]
else begin
mi:=fmin(A,n-1);
if A[n]>=mi then fmin:=mi
else fmin:=A[n]
end
end;
Доказательство.
Базис. n=0. Результат – A[0].
Предположение. Пусть n=k≥0, результат вычислений – минимальное
значение среди {A[0],...,A[k]}.
Индукция. Если n=k+1≥1, то результат – наименьшее из минимально-
го значения среди {A[0],...,A[n-1]} и A[0].
Нетрудно видеть, что трудоёмкость (количество рекурсивных вызовов)
и глубина рекурсии равны n.
Конец примера.
Пример 4.6. Рекурсивный алгоритм дихотомического поиска в упоря-
доченном массиве:
function seek(var A: array of integer;
p,b,e:integer):integer;
var c:integer;
begin
if b=e then
if A[b]=p then seek:=b else seek:=-1
else begin
c:=(b+e)div 2;
if A[c]<p then seek:=seek(A,p,c+1,e)
else seek:=seek(A,p,b,c)
end
end;
68 Лекция 4

Параметры функции : A – массив для поиска, p – поисковое значение,


b,e – начало и конец области в массиве, где ищется номер элемента мас-
сива, имеющий значение, равное p. Возвращаемое значение – номер эле-
мента массива, если поиск успешен, и –1, если такого элемента в массиве
нет. Вызов для поиска значения d в массиве X из n элементов с нумераци-
ей от 0 до n-1:
i:=seek(X,d,0,n-1);
Нетрудно видеть, что трудоёмкость (количество рекурсивных вызовов)
и глубина рекурсии равны log 2 n .
Конец примера.
Пример 4.7. Рекурсивное вычисление чисел Фибоначчи:
function fib(n:integer):integer;
begin
if n=0 then fib:=0
else if n=1 then fib:=1
else fib:=fib(n-1)+fib(n-2)
end;
Вычислим трудоёмкость, как количество рекурсивных вызовов Rn:
 R0  1, R1  1,

Rn  Rn1  Rn2  1, n  2, 3, ...

Величины Rn растут быстрее, чем числа Фибоначчи Fn:


Rn : 1, 1, 3, 5, 9, 15, 25, 41, 67, . . .
Fn: 0, 1, 1, 2, 3, 5, 8, 13, 21, . . .
Методом индукции легко доказать, что: Rn = 2Fn+1 – 1.
При этом глубина рекурсии равна n.
Таким образом, такой рекурсивный алгоритм для вычисления чисел
Фибоначчи крайне неэффективен!
Причина неэффективности в том, что при вычислении fib(n-2)не
используется ранее вычисленное при вызове fib(n-1)число Фибонач-
чи. Ситуацию можно исправить, запоминая в массиве все ранее вычислен-
ные числа Фибоначчи. Подобный метод в программировании получил
название «метод динамического программирования»:
Рекурсия 69

function fib(n:integer):integer;
begin
if n=0 then begin F[0]:=0; fib:=0 end
else if n=1 then
begin F[1]:=1; fib:=1 end
else if F[n]>0 then fib:=F[n] end
else begin
F[n]:=fib(n-1)+fib(n-2); fib:=F[n];
end;
end;
Числа Фибоначчи запоминаются в массиве F с нумерацией от 0 до n.
Перед вызовом элементы этого массива надо обнулить:
for i:=0 to n do F[i]:=0;
x:=fib(n);
Этот вариант алгоритма имеет трудоёмкость и глубину рекурсии O(n),
так как каждое из чисел Фибоначчи вычисляется только один раз, однако
трудоёмкость его в несколько раз больше, чем при вычислении чисел
Фибоначчи в цикле.
Конец примера.
Любой алгоритм, вычисляющий рекуррентное соотношение, можно
реализовать в рекурсивном виде. Однако из рассмотрения последних двух
примеров можно сделать вывод о том, что если рекуррентную последова-
тельность можно вычислять с помощью цикла, то её и следует вычис-
лять с помощью цикла, а не рекурсии!

4.3. Алгоритм сортировки слиянием

Пример 4.8. Задача слияния двух упорядоченных массивов A (n1


элементов) и B (n2 элементов) в упорядоченный массив C. Эту задачу
можно решить, скопировав содержимое массивов A и B в массив C (n1+n2
элементов), и упорядочив массив C. Однако возможно более эффективное
нерекурсивное решение.
В алгоритме входные массивы A и B просматриваются параллельно та-
ким образом, что очередной элемент, наименьший из оставшихся, перепи-
70 Лекция 4

сывается в массив C. Первый цикл выполняется до тех пор, пока в одном


из входных массивов не останется непросмотренных элементов. Если по-
сле окончания работы первого цикла остались непросмотренными часть
элементов массива A, то во втором цикле оставшиеся элементы будут ко-
пироваться в массив C, а если остались непросмотренными элементы мас-
сива B, то оставшиеся там элементы копируются в C третьим циклом.
i1:=1; i2:=1; j:=1;
while (i1<=n1)and(i2<=n2) do begin
if A[i1]<=B[i2] then
begin C[j]:=A[i1]; i1:=i1+1 end
else begin C[j]:=B[i2]; i2:=i2+1 end;
j:=j+1
end;
while i1<=n1 do begin
C[j]:=A[i1]; i1:=i1+1; j:=j+1
end;
while i2<=n2 do begin
C[j]:=B[i2]; i2:=i2+1; j:=j+1
end;
Инвариант цикла (для каждого из трех циклов):
1) в массиве С множество элементов {C[1], . . ., C[j-1]} сов-
падает со всеми элементами из части массива А: {A[1],...,A[i1-1]} и
части массива В: {B[1],...,B[i2-1]};
2) C[1] <= C[2] <= ... <= C[j-1];
3) C[j-1]<=A[i1] при i1 <= n1,
C[j-1] <= B[i2] при i2<=n2.
Трудоёмкость всего алгоритма O(n).
Конец примера.
Алгоритм слияния имеет важное самостоятельное значение. Его можно
применить для конструирования эффективного алгоритма сортировки.
Пример 4.9. Алгоритм слияния в виде процедуры. Предполагается, что
сливаться будут два соседних отрезка из массива X в массив Y. Параметры
в процедуре имеют следующий смысл:
b1 – начало 1-го отрезка,
e1 – конец 1-го отрезка,
Рекурсия 71

e2 – конец 2-го отрезка.


В массиве Y формируется упорядоченный отрезок:
b1 – начало отрезка,
e2 – конец отрезка.
procedure S(var X,Y: array of integer;
b1,e1,e2:integer);
var i1, i2, j:integer;
begin i1:=b1; i2:=e1+1; j:=b1;
while (i1<=e1)and(i2<=e2) do begin
if X[i1]<=X[i2] then begin
Y[j]:=X[i1]; i1:=i1+1
end
else begin
Y[j]:=X[i2]; i2:=i2+1
end;
j:=j+1
end;
while i1<=e1 do begin
Y[j]:=X[i1]; i1:=i1+1; j:=j+1
end;
while i2<=e2 do begin
Y[j]:=X[i2]; i2:=i2+1; j:=j+1
end;
end;
Конец примера.
Пример 4.10. Алгоритм сортировки слиянием:
procedure sort(var X,Y: array of integer;
b,e:integer);
var c,i:integer;
begin
if b<e then begin
c:=(b+e)div 2;
sort(X,Y,b,c);
sort(X,Y,c+1,e);
S(X,Y,b,c,e);
for i:=b to e do X[i]:=Y[i]
end
end;
72 Лекция 4

В процедуре sort используется процедура S из примера 4.9. Проце-


дура упорядочивает отрезок массива X с номерами элементов от b до e.
Массив Y – вспомогательный.Вызов процедуры sort для массивов A и B
из n элементов:
sort(A,B,0,n-1);
Доказательство.
Базис. Размер n упорядочиваемого отрезка в массиве X равен 1. Ал-
горитм ничего не делает.
Предположение. Пусть алгоритм умеет упорядочивать отрезки в мас-
сиве X размером от 1 до n = k ≥ 1 элементов.
Индукция. Пусть отрезок в массиве X имеет размер n = k + 1 ≥ 2 эле-
ментов. Тогда алгоритм выполняет следующие действия:
1) делит отрезок на две равные (или почти равные) части;
2) каждую из частей (размером не более k элементов) рекурсивно упо-
рядочивает;
3) выполняет слияние двух упорядоченных частей в один отрезок мас-
сива Y;
4) копирует отрезок массива Y в массив X на место исходного отрез-
ка, получив упорядоченный отрезок длины n = k + 1 в массиве X.
Если размер n упорядочиваемого массива
2m – 1 < n ≤ 2m ,
то не более чем за m последовательных делений размер фрагмента масси-
ва станет равным 1. Т.е. глубина рекурсии также не превысит m, т.е.
log 2 n , что намного меньше размера массива X.
Рекуррентное соотношение для трудоёмкости T(n):
 T (1)  1,

 T (n)  2T (n / 2)  cn, n  2, 3, ...,
где c – константа.
Полагая 2m – 1 < n ≤ 2m, получим:
T (n) = 2 T (n/2) + c n = 22 T (n/4) + c n + c n = … ≤ c n m = c n log 2 n
Рекуррентное соотношение для количества рекурсивных вызовов Rn
процедуры sort при n = 2m:
Рекурсия 73

 R1  1,

Rn  2 Rn / 2  1, n  2, 2 , ...
2

Отсюда получим:
Rn = 2Rn/2 + 1 = 4Rn/4 + 2 + 1 = . . . = 2∙2m – 1 ≈ 2 n.
Таким образом, количество всех рекурсивных вызовов гораздо мень-
ше, чем количество других действий при выполнении алгоритма (сравне-
ний и копирований).
В целом трудоёмкость T (n) имеет порядок: O(n log n).
Конец примера.
В таблице 4.1 приведены значения таких функций, как log 2 n ,
n log 2 n , n ,n , n , 2 , которые характеризуют трудоёмкость ряда типо-
3/2 2 3 n

вых алгоритмов.
Таблица 4.1

log 2 n n log 2 n 
3/2 2 3
n n n n 2n
4 2 8 8 16 64 16
10 4 40 31 100 1000 1024
20 5 100 100 400 8000 ≈106
40 6 240 280 1600 64000 ≈1012
100 7 700 1000 10000 106 ≈1030
1000 10 10000 31000 106 109 ≈10300
106 20 20∙106 109 1012 1018 ≈10300000

Превосходство более эффективного алгоритма особенно наглядно при


больших значениях n. Так, для упорядочения массива из миллиона элемен-
тов простейшая сортировка выполнит (в худшем случае) около 166∙1015
циклов (n3/6), пузырьковая сортировка – около 5∙1011 циклов (n2/2), а сор-
тировка слиянием – около 40∙106 циклов ( 2n log 2 n  ). Если компьютер
выполняет по 109 циклов в 1 секунду, что соответствует быстродействию
около 5 млрд операций в секунду, то время работы этих программ будет
соответственно: 5 лет, 8 минут и 0,04 секунды.
74 Лекция 4

Если алгоритм имеет экпоненциальную трудоёмкость, например, О(2n),


то его время работы с увеличением n растёт катастрофически быстро, и
никакое увеличение быстродействия компьютера не поможет. Так, при
n = 100, 2100 ≈1030, при выполнении 109 действий в 1 секунду потребуется
более 40 триллионов лет, а при 1015 действий в 1 секунду потребуется бо-
лее 40 миллионов лет.

Вопросы и задания

1. Написать программу, в которой описана функция с параметрами n и А, вычисляю-


щая в цикле сумму n элементов массива А. Программа вводит число n, выделяет
память для n элементов массива А и вводит n элементов в массив А. После этого
вызовом функции вычисляет и выводит сумму элементов массива А. Создать тесты
для программы методом чёрного и белого ящика и провести тестирование.
2. Написать программу, в которой описана рекурсивная функция с параметрами n и А,
вычисляющая сумму n элементов массива А. Доказать методом математической ин-
дукции корректность функции. Программа вводит число n, выделяет память для n
элементов массива А и вводит n элементов в массив А. После этого вызовом функ-
ции вычисляет и выводит сумму элементов массива А. Создать тесты для програм-
мы методом чёрного и белого ящика и провести тестирование.
3. Как необходимо записывать рекурсивный алгоритм, чтобы гарантировать его за-
вершимость?
4. Записать в рекурсивном виде алгоритм вычисления наибольшего общего делителя
двух неотрицательных чисел и доказать его корректность.
5. Написать и отладить программу, которая вводит число перекладываемых дисков в
задаче «Ханойские башни» и вызывает рекурсивную процедуру, которая выводит
сообщения о каждом перекладывании диска. В конце выводит сосчитанное в функ-
ции количество перекладываний, а также количество перекладываний, вычисленное
по формуле. Создать тесты для программы методом чёрного ящика и провести те-
стирование.
6. Доказать методом инварианта корректность рекурсивной функции дихотомическо-
го поиска элемента в упорядоченном массиве. Доказать, что если в массиве имеется
несколько элементов, равных p, то программа вычислит наименьший номер среди
них.
7. На основе рекуррентного соотношения о количестве вызовов Rn рекурсивной функ-
ции вычисления чисел Фибоначчи доказать методом математической индукции со-
отношение: Rn = 2Fn+1 – 1, где Fn – число Фибоначчи.
Рекурсия 75

8. Доказать методом математической индукции корректность рекурсивной функции


вычисления чисел Фибоначчи методом «динамического программирования». Дока-
зать, что количество вызовов этой рекурсивной функции при вычислении n–го
числа Фибоначчи не более n.
9. Доказать методом инварианта корректность процедуры слияния двух рядом распо-
ложенных упорядоченных частей массива X в одну упорядоченную часть массива
Y.
10. Изменить рекурсивную процедуру сортировки слиянием так, чтобы вместо вызова
внутри неё процедуры слияния были вставлены действия, реализующие такое слия-
ние.
11. Написать и отладить программу, которая вводит число n, выделяет память для n
элементов массива А и вводит n элементов в массивы А и С. После этого упорядо-
чивает массив А вызовом процедуры сортировки слиянием, а также упорядочивает
массив С вызовом процедуры сортировки вставками, после чего сравнивает между
собой массивы А и С. Создать тесты для программы методом чёрного ящика и про-
вести тестирование.
12. Написать и отладить программу, которая вводит величину n, генерирует n случай-
ных целых чисел из диапазона от –1000 до 1000, запоминает их в массиве (для мас-
сива выделяется динамическая память) и поочередно сортирует несколькими алго-
ритмами сортировки (простейшей сортировки, обменной сортировки, пузырьковой
сортировки, сортировки слиянием). После выполнения очередного алгоритма сор-
тировки проверить, правильно ли сработал этот алгоритм. Кроме того, внутри каж-
дого алгоритма предусмотреть счетчики, подсчитывающие количество выполнений
самого внутреннего цикла. После окончания всех вычислений для различных n (10,
100, 1000, 10000) вывести значения этих счетчиков и сделать выводы о том, какие
из реализованных алгоритмов хуже, какие лучше.
13. Задан массив из n случайных чисел. Простейшая сортировка при упорядочении та-
кого массива выполнит приблизительно n3/12 повторений цикла, пузырьковая сор-
тировка – n2/2 повторений цикла, а сортировка слиянием – 2∙n∙log2 n повторений
циклов. Если быстродействие компьютера 109 циклов в секунду, то сколько време-
ни будет выполняться упорядочение этими алгоритмами массива размером 100000
элементов? Как изменится время работы алгоритмов, если быстродействие компь-
ютера увеличится в 10 раз, и размер массива увеличится в 10 раз?
Лекция 5.
Списочные структуры

5.1. Алгоритмы со списками

Если в задаче необходимо многократно удалять или вставлять отдель-


ные элементы массива, то эффективнее и удобнее использовать не масси-
вы, а списочные структуры (которые для краткости называют просто
списками). В списке все элементы списка связаны в последовательность
так, что можно от одного элемента за один шаг перейти к соседнему. Каж-
дый элемент списка состоит из двух частей: содержимого и указателя.
Так как содержимое определяется решаемой задачей и может быть любого
типа, то в целом элемент списка представляется структурой данных, назы-
ваемой записью (record). Запись содержит несколько полей, типы кото-
рых могут быть различными. Каждое поле записи обозначается отдельным
именем. Пример описания типов данных для элементов списка:
type pel=^elem;
elem=record s:integer; p:pel end;
В этом описании описано два тппа.
Тип pel – описан как указатель на тип elem, т.е. элемент списка.
Тип elem – это тип элемента списка. Он определен как структура из
двух полей:
поле s – содержимое, здесь тип содержимого – integer;
поле p – указатель, который указывает на другой элемент списка, тип
поля p – pel.
Описание типа не создает никаких переменных или других объектов в
программе, а определяет лишь типы переменных, которые далее могут
быть описаны, например:
var p1,p2,p3: pel;
Здесь описаны только указатели, самих элементов списка еще нет.
Элементы списка создаются при выделении памяти оператором new,
например:
new(p1); new(p2);
Списочные структуры 77

Здесь созданы два элемента списка: элемент списка, на который указы-


вает указатель p1, и элемент списка, на который указывает указатель p2.
Примеры создания двух списков из созданных элементов представле-
ны на рис. 5.1 в графическом виде.

5 10 5 10

p1 p2 p1 p2

Рис. 5.1

p1^.s:=5; p1^.p:=p2;
В первом элементе списка содержимое 5, указатель указывает на 2-й
элемент. Здесь p1^.s – это поле s элемента списка, на который указывает
указатель p1, а p1^.p – это поле p элемента списка, на который указывает
указатель p1.
p2^.s:=10; p2^.p:=nil;
Во втором элементе списка содержимое 10, указатель равен nil (осо-
бое пустое значение, на рисунке перечеркнутый прямоугольник, означает
конец списка). В результате получился простой линейный список. Указа-
тель p1 указывает на начало списка, указатель p2 – на конец списка. Если
последнее присваивание заменить на:
p2^.p:=p1;
то во втором элементе списка указатель будет указывать на начальный
элемент списка. В результате получится циклический список, в котором из
последнего элемента можно по ссылке перейти к первому элементу.
Список может содержать произвольное число элементов, даже ни од-
ного, тогда указатель на список равен nil.
Если какой-либо элемент списка больше не нужен, его можно удалить
стандартной процедурой dispose, аргумент которой – переменная-
указатель, ссылающаяся на удаляемый элемент списка, например:
dispose(p1);
После удаления доступ к элементу становится невозможным, а указа-
телю присваивается значение nil.
78 Лекция 5

Пример 5.1. Программа ввода с клавиатуры совокупности целых чисел


и формирования из них последовательного списка. Конец ввода определя-
ется по введенному числу 0. Указатель p1 указывает на первый, а указа-
тель p2 – на последний элемент списка.
type pel=^elem;
elem=record s:integer; p:pel end;
var p1,p2,p3:pel;
s: integer;
begin
p1:=nil; p2:=nil;
read(s);
while s<>0 do
begin new(p3);
if p1=nil then p1:=p3
{для 1-го элемента списка}
else p2^.p:=p3;{для 2-го и т.д. элементов списка}
p2:=p3; p2^.s:=s;
read(s)
end;
p2^.p:=nil;
...
end.
В цикле очередной новый элемент списка присоединяется в конец
списка.
Конец примера.
Следующий пример показывает, как можно просмотреть все элементы
последовательного списка.
Пример 5.2. Программа вычисления суммы содержимого элементов
линейного списка, созданного в примере 5.1.
p3:=p1; sum:=0;
while p3<>nil do
begin
sum:=sum+p3^.s;
p3:=p3^.p
end;
Списочные структуры 79

Указатель p3 в цикле пробегает по всем элементам списка. Оператор


p3:=p3^.p переводит указатель p3 к следующему элементу списка.
Конец примера.
Пример 5.3. Программа удаления всех элементов линейного списка,
созданного в примере 5.1.
while p1<>nil do
begin
p3:=p1^.p
dispose(p1);
p1:=p3
end;
p2:=nil;
Оператор dispose(p1); удаляет элемент списка, на который ука-
зывает указатель p1.
Конец примера.
В примерах 5.1 – 5.3 список может быть пустым, тогда оба указателя
p1 и p2 будут иметь значение nil.
В ряде задач применяются такие специальные структуры данных, как
очереди и стеки (магазины). Каждая из этих структур содержит набор
однородных элементов, которые можно добавлять в набор и извлекать из
набора. Очередью называют такой набор данных, который работает по
принципу: первый вошел – первый ушел. Стек работает по принципу: по-
следний вошел – первый ушел. Если в задаче заранее известно максималь-
ное количество элементов очереди или стека, то их можно реализовать в
виде массивов, а если неизвестно – то в виде списков.
Пример 5.4. Программы, выполняющие действия со стеком и очере-
дью. Для списка, реализующего стек или очередь, заданы также два указа-
теля: p1, указывающий на начало списка, и p2 – на конец списка.
Добавление элемента со значением X в очередь (в конец списка):
new(p3); p3^.s:=X; p3^.p:=nil;
if p2=nil then p1:=p3 {если список был пуст}
else p2^.p:=p3; {иначе - не пуст}
p2:=p3;
80 Лекция 5

Добавление элемента со значением X в стек (в начало списка):


new(p3); p3^.s:=X; p3^.p:=p1; p1:=p3;
if p2=nil then p2:=p3; {если список был пуст}
Удаление элемента из очереди или стека и копирование его значения в
переменную X (удаление с начала списка):
if p1<>nil then begin {если список не пуст}
p3:=p1; X:=p1^.s; p1:=p1^.p;
dispose(p3)
end;
if p1=nil then p2:=nil; {если список пуст}
Конец примера.

5.2. Поиск в списках

Пример 5.5. Программа поиска элемента списка со значением S0 в


линейном списке. Указатель p1 – начало списка:
p3:=p1;
while (p3<>nil)and(p3^.s<>S0) do
p3:=p3^.p;
Результат поиска: указатель p3 указывает на искомый элемент или ра-
вен nil, если нет такого элемента.
Инвариант цикла (указатель p3 указывает на i-й элемент):
«среди элементов списка с номерами 1, …, i – 1 содержимое ни одного
из них не равно S0.»
Постусловие: p3=nil или p3^.s=S0.
Трудоёмкость O(n). При безуспешном поиске цикл будет выполняться
ровно n раз, а при успешном может закончиться раньше.
Конец примера.
Пример 5.6. Программа поиска элемента списка со значением S0 в
упорядоченном по возрастанию списке:
Списочные структуры 81

p3:=p1;
while (p3<>nil)and(p3^.s<S0) do
p3:=p3^.p;
if (p3<>nil)and(p3^.s>S0) then
p3:=nil;
Инвариант цикла здесь такой же, как в примере 5.5.
Постусловие цикла: p3=nil или p3^.s≥S0.
Трудоёмкость O(n). В отличие от примера 5.5 цикл может закончиться
раньше как при успешном, так и при безуспешном поиске.
Конец примера.
Пример 5.7. Вставка элемента со значением S0 в упорядоченный по
возрастанию список. Указатель p1 – начало списка, p2 – конец списка:
new(p3); p3^.s:=S0;
p4:=nil; p5:=p1;
while (p5<>nil)and(p5^.s<S0) do
begin p4:=p5; p5:=p5^.p end;
if p4=nil then p1:=p3{вставка в начало списка}
else p4^.p:=p3;{вставка в список между p4 и p5}
p3^.p:=p5;
if p5=nil then p2:=p3;{вставка в конец списка}
Вначале в цикле выполняется поиск места для вставки нового элемента
(с указателем p3). Варианты постусловия цикла:
1) p5=nil, p4=nil – список был пустой, создаётся из нового элемен-
та;
2) p5=nil, p4≠nil – просмотрен весь список, p4 указывает на по-
следний элемент списка, новый элемент присоединяется в конец списка;
3) p5≠nil, p5^.s≥S0, p4=nil – цикл ни разу не выполнялся, новый
элемент присоединяется в начало списка;
4) p5≠nil, p5^.s≥S0, p4≠nil – новый элемент вставляется между
элементами списка с указателями p4 и p5.
Конец примера.
Пример 5.9. Удаление элемента со значением S0 из упорядоченного по
возрастанию списка. Указатель p1 – начало списка, p2 – конец списка:
82 Лекция 5

p4:=nil; p5:=p1;
while (p5<>nil)and(p5^.s<S0) do
begin p4:=p5; p5:=p5^.p end;
if (p5<>nil)and(p5^.s=S0) then begin
{p5 указывает на удаляемый элемент}
if p4<>nil then p4^.p:=p5^.p
else p1:= p5^.p; {удаляемый элемент 1-й}
if p5^.p=nil then p2:=p4;
{удаляемый элемент последний}
dispose(p5);
end;
Вначале в цикле выполняется поиск удаляемого элемента. Варианты
постусловия цикла:
1) p5=nil – список не содержит удаляемого элемента;
2) p5≠nil, p5^.s≥S0, p4=nil – цикл ни разу не выполнялся, удаля-
емый элемент 1-й;
3) p5≠nil, p5^.s≥S0, p4≠nil – удаляемый элемент находится меж-
ду элементами списка с указателями p4 и p5.
Конец примера.
Рассмотренные программы выполняют поиск с помощью цикла. С дру-
гой стороны, рекурсивные алгоритмы поиска обычно более простые.
Пример 5.10. Рекурсивный поиск значения S0 в неупорядоченном ли-
нейном списке:
function flist(p:pel,S0:integer):pel;
begin
if p=nil then flist:=nil
else if p^.s=S0 then flist:=p
else flist:=flist(p^.p,S0)
end;
Выход из рекурсии: либо возвращаемый указатель указывает на эле-
мент с содержимым, равным S0, либо он равен nil, тогда искомого зна-
чения в списке нет. Пример вызова:
p3:=flist(p1,d);
Конец примера.
Списочные структуры 83

Пример 5.11. Рекурсивный поиск значения S0 в упорядоченном ли-


нейном списке:
function flist2(p:pel,S0:integer):pel;
if p=nil then flist2:=nil
else if p^.s=S0 then flist2:=p
else if p^.s>S0 then flist2:=nil
else flist2:=flist(p^.p,S0)
end;
Отличие от предыдущего варианта в том, что здесь возможен дополни-
тельный случай выхода из рекурсии с результатом поиска nil, когда со-
держимое очередного элемента списка больше S0.
Конец примера.

5.3. Сортировка списков

Пример 5.12. Программа пузырьковой сортировки списка строится по


аналогии с сортировкой массива. Здесь указатель p1 – начало списка.
p3:=nil;
while p3<>p1 do begin
p4:=p1; p5:=p1^.p;
while p5<>p3 do begin
if p4^.s>p5^.s then begin
z:=p4^.s; p4^.s:=p5^.s; p5^.s:=z
end;
p4:=p5; p5:=p5^.p
end;
p3:=p4
end;
Указатель p3 при выполнении внешнего цикла перемещается в обрат-
ную сторону, от конца списка к началу аналогично тому, как в сортировке
для массива изменяется параметр внешнего цикла. Указатели p4 и p5
перемещаются по списку от начала к концу, с их помощью сравниваются
два соседних элемента списка и, если необходимо, производится обмен их
содержимым.
84 Лекция 5

Условно можно считать, что элементы списка перенумерованы, их ко-


личество равно n, и что во внешнем цикле элементы нумеруются «пере-
менной» i, а во внутреннем цикле – «переменной» j .
Инвариант внутреннего цикла:
1) указатель p4 указывает на j-й элемент списка;
2) указатель p5 указывает на (j + 1)-й элемент списка;
3) j-й элемент списка имеет максимальное содержимое
(поле s) среди элементов списка с 1-го по j-й;
4) набор значений (полей s) всех элементов списка остается неизмен-
ным.
Инвариант внешнего цикла:
1) указатель p3 указывает на (i + 1)-й элемент списка;
2) элементы списка с (i + 1)-го по n-й упорядочены;
3) любой элемент списка с 1-го по i-й имеет содержимое (поле s) не
больше, чем содержимое элементов списка с (i + 1)-го по последний.
Таким образом, после окончания внутреннего цикла указатель p4 ука-
зывает на i-й элемент списка, и после присваивания p3:=p4 указатель p3
сдвинется влево по списку.
Трудоёмкость алгоритма O(n2), такая же, как у сортировки для мас-
сива.
Конец примера.
Пример 5.13. Слияние двух упорядоченных списков. На начало двух
входных списков указывают указатели p1 и p2, из элементов этих списков
строится выходной упорядоченный список, на который указывает указа-
тель p3.
procedure slist(var p1,p2,p3:pel);
var p4:pel;
begin
if p1^.s<=p2^.s then
begin p3:=p1; p4:=p1; p1:=p1^.p end
else begin p3:=p2; p4:=p2; p2:=p2^.p end;
{наименьший элемент перемещен в выходной список}
while (p1<>nil)and(p2<>nil) do
{цикл, пока оба входных списка не пусты}
if p1^.s<=p2^.s then
begin p4^.p:=p1; p4:=p1; p1:=p1^.p end
Списочные структуры 85

else begin p4^.p:=p2; p4:=p2; p2:=p2^.p end;


{в цикле наименьший элемент – в выходной список}
if p1<>nil then p4^.p:=p1 else p4^.p:=p2;
{остаток одного входного списка присоединяется в конец}
p1:=nil; p2:=nil
end;
Вначале, до цикла, в выходной список перемещается наименьший эле-
мент, и на него указывает указатель p3. Указатели p1 и p2 в процессе ра-
боты цикла перемещаются по входным спискам, и после перемещения всех
элементов в выходной список им присваивается nil. Локальный указатель
p4 указывает на последний элемент создаваемого в выходного списка.
Особенность этого алгоритма в том, что для элементов нового списка
память не выделяется, выходной список создаётся из элементов входных
списков.
Трудоёмкость алгоритма O(n), такая же, как у слияния для массивов.
Конец примера.
Пример 5.14. Сортировка слиянием списка. На начало списка указы-
вает указатель p, переменная n должна содержать количество элементов
списка.
procedure sortlist(var p:pel;n:integer);
var p1,p2:pel;
k,i:integer;
begin
if n>1 then begin
k:=n div 2; p1:=p;
for i:=1 to k-1 do p1:=p1^.p;
p2:=p1^.p; p1^.p:=nil; p1:=p;
{список разделен на две почти одинаковые части}
sortlist(p1,k);
sortlist(p2,n-k);
{обе части списка рекурсивно отсортированы}
slist(p1,p2,p)
{обе части списка объединены слиянием}
end
end;
86 Лекция 5

Алгоритм похож на сортировку слиянием для массива. Отличие в том,


что вначале, после вычисления длины первого из сливаемых списков, ис-
ходный список «разрезается» надвое. Кроме того, после отдельной рекур-
сивной сортировки каждой из двух частей списка и их слияния в общий
упорядоченный список процедурой slist, здесь нет необходимости како-
го-либо копирования.
Трудоёмкость алгоритма такая же, как в сортировке слиянием для мас-
сива, т.е. O(n log n).
Вычисление длины списка, на который указывает указатель p1, и вы-
зов процедуры сортировки:
p3:=p1; n:=0;
while p3<>nil do
begin n:=n+1; p3:=p3^.p end;
sortlist(p1,n);
Конец примера.

Вопросы и задания

1. Написать и отладить программу, которая вводит число n и вводит n чисел, помещая


их последовательно в создаваемые элементы списка, как в очередь. После этого по-
следовательно извлекает и удаляет элементы из начала списка, суммируя их значе-
ния, а также вычисляя минимальное и максимальное значение. В конце выводит
сумму, минимальное и максимальное значение. Создать тесты для программы ме-
тодом чёрного и белого ящика и провести тестирование.
2. Написать и отладить программу, которая вводит число n и вводит n чисел, помещая
их в создаваемые элементы списка так, чтобы список получился упорядоченным. В
конце выводит последовательно значения из списка. Создать тесты для программы
методом чёрного и белого ящика и провести тестирование.
3. Провести полное доказательство методом инварианта программы (содержащей
цикл) поиска элемента в неупорядоченном списке.
4. Провести полное доказательство методом инварианта программы (содержащей
цикл) поиска элемента в упорядоченном списке.
5. Провести полное доказательство методом математической индукции рекурсивной
программы поиска элемента в неупорядоченном списке.
6. Провести полное доказательство методом математической индукции рекурсивной
программы поиска элемента в упорядоченном списке.
Списочные структуры 87

7. Написать программу, которая из упорядоченного списка удаляет элементы с повто-


ряющимися значениями. Провести её полное доказательство методом инварианта.
8. Провести полное доказательство методом инварианта программы пузырьковой
сортировки для списков.
9. Провести полное доказательство методом инварианта программы пузырьковой
сортировки для списков.
10. Провести полное доказательство методом инварианта программы слияния упорядо-
ченных списков.
11. Провести полное доказательство методом инварианта программы сортировки слия-
нием упорядоченных списков.
12. Написать и отладить программу, которая вводит число n и вводит n чисел, помещая
их последовательно в создаваемые элементы списка, как в очередь. После этого
упорядочивает список пузырьковой сортировкой и выводит последовательно зна-
чения из списка. Создать тесты для программы методом чёрного и белого ящика и
провести тестирование.
13. Написать и отладить программу, которая вводит число n и вводит n чисел, помещая
их последовательно в создаваемые элементы списка, как в очередь. После этого
упорядочивает список вызовом процедуры сортировки слиянием и выводит после-
довательно значения из списка. Создать тесты для программы методом чёрного и
белого ящика и провести тестирование.
Лекция 6.
Бэктрекинг

6.1. Комбинаторные алгоритмы

Существует ряд задач, которые без рекурсии решить весьма сложно. К


ним относятся, в частности, комбинаторные задачи, в которых ищется ре-
шение, состоящее из комбинации значений некоторых переменных. В ка-
честве примера рассмотрим задачу нахождения всех перестановок нату-
ральных чисел от 1 до n. Задачу будем решать следующим образом:
1) в качестве первого числа выбираем любое из чисел 1, …, n;
2) в качестве второго числа выбираем любое из чисел 1, …, n, кроме
того числа, которое выбрано первым;
3) третьим числом выбираем одно из чисел, которое не выбрано пер-
вым или вторым, и т.д.
Этот процесс продолжаем до тех пор, пока не выберем последнее, n-е
число для перестановки. Таким образом, для первого числа выбираются n
вариантов, для второго n – 1 вариант и т.д., для последнего, n-го – 1 вари-
ант. Всего получается n (n – 1) … 2∙1 = n! вариантов перестановок.
Чтобы получить все возможные перестановки, надо на каждом этапе
выбора k-го числа последовательно перебирать все допустимые числа, од-
нако перейти к следующему варианту можно, только если найдены полные
перестановки для всех предыдущих вариантов. Такой процесс можно
представить деревом решений, изображенным на рис. 6.1 для n = 3. В
кружках на схеме записаны выбираемые на очередном шаге числа, стрел-
ками – переход к выбору очередного числа.
В соответствии со схемой вначале будет выбрана перестановка 1–2–3,
затем 1–3–2, 2–1–3 и т.д. При выборе очередного числа движение по
схеме идет по стрелке вниз, а при отказе от этого выбора (чтобы выбрать
другое число) – обратное движение (бэктрекинг). Этот процесс легко реа-
лизовать рекурсивным алгоритмом.
Бэктрекинг 89

1 2 3

2 3 1 3 1 2

3 2 3 1 2 1

Рис. 6.1

Пример 6.1. Рекурсивная процедура генерации перестановок. Пере-


становки генерируются в глобальном массиве P, содержащем n элемен-
тов. Глобальный массив R (из n элементов) содержит признаки включе-
ния чисел в перестановку: если R[i]=1, то число i включено в пере-
становку, а если R[i]=0, то нет.
Рекурсивная процедура per(k) выбирает для перестановок все
оставшиеся числа, начиная с k-й позиции. Словом ВЫВОД обозначены дей-
ствия в алгоритме по запоминанию или выводу очередной сгенерирован-
ной перестановки:
procedure per(k:integer);
var i:integer;
begin
for i:=1 to n do
if R[i]=0 then begin
P[k]:=i; R[i]:=1;
if k=n then ВЫВОД
else per(k+1);
R[i]:=0
end
end;
Перед вызовом процедуры генерации перестановок необходимо обну-
лить массив R, так ни одно из чисел еще не было включено в перестанов-
ку. Процедура генерации перестановок per вызывается с аргументом,
равным 1:
90 Лекция 6

for i:=1 to n do R[i]:=0;


per(1);
Инвариант цикла:
Элементы массива P[1],...,P[k-1] не изменяются;
P[k] не совпадает ни с одним из элементов P[1],...,P[k-1];
Те элементы R[i]=1, для которых число i имеется среди элементов
P[1],...,P[k-1].
Рекурсивный вызов per(k+1) сохраняет инвариант истинным для
k+1.
Глубина рекурсии равна n, так как при каждом вложенном рекурсив-
ном вызове параметр k увеличивается на 1, а возврат из рекурсии происхо-
дит при k=n. При этом величина n на практике не может быть большой из-
за огромного количества перестановок (например, 10! = 3628800).
Конец примера.
Пример 6.2. Нерекурсивная программа генерации перестановок для
n=3 получена из процедуры per путём замены рекурсивного вызова пря-
мой вставкой алгоритма:
for i:=1 to n do R[i]:=0;
for i1:=1 to 3 do begin
P[1]:=i1; R[i1]:=1;
for i2:=1 to 3 do
if R[i2]=0 then begin
P[2]:=i2; R[i2]:=1;
for i3:=1 to 3 do
if R[i3]=0 then begin
P[3]:=i3; R[i3]:=1;
ВЫВОД;
R[i3]:=0
end;
R[i2]:=0
end;
R[i1]:=0
end;
Бэктрекинг 91

Таким способом можно получить программу для любого фиксирован-


ного n, однако при этом теряется гибкость алгоритма, а также возрастают
его размеры.
Конец примера.
Пример 6.3. Рекурсивная процедура генерации расстановок длины m
из n чисел с повторениями. Аналогично примеру 6.2, расстановки генери-
руются в глобальном массиве P из n элементов. Здесь нет необходимо-
сти в массиве R, так как не нужно проверять, какие числа уже вошли в рас-
становку:
procedure gen(k:integer);
var i,j:integer;
begin
for i:=1 to n do
begin P[k]:=i;
if k=m then ВЫВОД
else gen(k+1);
end
end;
Вызов процедуры gen аналогичен вызову процедуры генерации пере-
становок per:
gen(1);
Трудоёмкость алгоритма можно оценить общим количеством выпол-
нений цикла во всех рекурсивных вызовах:
– при первом вызове: n раз,
– при глубине рекурсии 2: n раз по n, т.е. n2 раз,
– при глубине рекурсии 3: n раз по n2, т.е. n3 раз,
– и т.д.
Всего (сумма геометрической прогрессии):
T(n) = n + n2 + . . . + nm = (nm – 1)·n /(n – 1).
Конец примера.
Трудоёмкость генерации перестановок. Трудоёмкость алгоритма ге-
нерации перестановок можно оценить общим количеством выполнений
цикла во всех рекурсивных вызовах процедуры per. Если бы в цикле не
92 Лекция 6

было проверки оператором if, то трудоёмкость была бы такой же, как


при генерации расстановок процедурой gen при условии n = m:
T1(n) = (nn – 1)·n /(n – 1).
С другой стороны, если бы не было «лишних» выполнений цикла в
процедуре per, т.е. цикл выполнялся бы не n раз, а всего лишь n – k + 1
раз, то общее количество выполнений цикла было бы равно:
n! n!  1 1 
T2 (n)  n  n(n  1)  ...    n! n!1  1   ...    e  n! ,
2! 1!  2! (n  1)!
так как предел выражения в скобках при n → ∞ равен e (основанию нату-
ральных логарифмов).
Величины T1(n) и T2(n) определяют соответственно верхнюю и
нижнюю границы количества выполнений цикла. Точное значение этой
величины можно подсчитать, если учесть, что все циклы исполняются по
n раз, но рекурсивный вызов из них – всего лишь n – k + 1 раз:
n! n!
T3 (n)  n  nn  nn(n  1)  ...  n  n 
2! 1!
 1 1 1 
 nn!1   ...     nn!(e  1) (*)
 2! ( n  1)! n !
Таким образом, трудоёмкость генерации перестановок имеет порядок
O(n∙n!). Такой же порядок трудоёмкости имеют действия по выводу или
запоминанию всех сгенерированных перестановок.
Например, при n = 3: T3(3) = 3 + 3∙3 + 3∙3∙2 = 30, оценка T3(3) по фор-
муле (*) равна 3∙3!∙1.71828 ≈ 30.926. Трудоёмкость вывода шести сгенери-
рованных перестановок: 3∙3! = 18.
Пример 6.4. Генерация перестановок в списке. Перестановки генери-
руются в глобальном списке из n элементов. Предварительно требуется
создать этот список:
new(p); p1:=p;
for i:=2 to n do
begin
new(p2); p1^.p:=p2; p1:=p2
end;
p1^.p;=nil;
Бэктрекинг 93

Также как в процедуре per, в процедуре perlist используется гло-


бальный массив R, содержащий признаки включения чисел в перестанов-
ку:
procedure perlist(p:pel);
var i:integer;
begin
for i:=1 to n do
if R[i]=0 then begin
p^.s:=i; R[i]:=1;
if p^.p=nil then ВЫВОД
else perlist(p^.p);
R[i]:=0
end
end;
Процедура генерации perlist вызывается так же, как процедура
per:
for i:=1 to n do R[i]:=0;
perlist(1);
Конец примера.
Пример 6.5. Генерация перестановок строк символов. Пусть задан на-
бор (массив) символьных строк, т.е. строки этого набора перенумерованы.
Чтобы сгенерировать все возможные перестановки строк, достаточно сге-
нерировать все перестановоки номеров строк процедурой per, в которой
вместо вывода чисел выводить строки. Например, при количестве чисел
для перестановки n=6 описание
var St:array[1..6]of string[10];
задаёт массив из шести строк, причём каждая строка может иметь макси-
мальную длину до 10 символов. Если строкам массива задать конкретные
значения, то в описании процедуры per вывод перестановки строк можно
записать так:
for j:=1 to 6 do
write(St[P[j]],’ ’);
writeln;
94 Лекция 6

Каждая перестановка из шести символьных строк выводится подряд в


одну строку вывода через пробел.
Конец примера.
Пример 6.6. Генерация сочетаний из n чисел по m. Из n чисел необхо-
димо сгенерировать все возможные сочетания (группы) по m чисел, при-
чем в одной группе все числа должны быть различными. Если в процедуре
per в операторе if сравнение k=n заменить сравнением k=m, то будут
генерироваться все перестановки таких групп, т.е. размещения. Количе-
ство размещений
n!
Anm  n(n  1) . . . (n  m  1)  .
(n  m)!
Число сочетаний из n по m, равное биномиальному коэффициенту
C nm , в m! раз меньше, чем число размещений Anm :
n!
Cnm 
(n  m)!m!

В каждой такой группе порядок чисел не важен, но удобнее всего ге-


нерировать числа в возрастающем порядке, помещая их в массив C из m
элементов. Тогда на первом месте будет располагаться число C1 в диапа-
зоне от 1 до n – m + 1, на втором месте – число C2 в диапазоне от C1 + 1 до
n – m + 2, …, на m-м месте – число Cm в диапазоне от Cm – 1 + 1 до n.
В этом случае нет необходимости использования массива R, но для то-
го, чтобы процедура выполнялась одинаково как при генерации первого
числа, так и остальных чисел, глобальный массив C должен содержать до-
бавочный элемент C[0]с предварительно присвоенным ему значением 0.
Рекурсивная процедура генерации сочетаний:
procedure comb(k:integer);
var i:integer;
begin
for i:=C[k-1]+1 to n-m+k do
begin C[k]:=i;
if k=m then ВЫВОД
else comb(k+1);
end
end;
Бэктрекинг 95

Перед вызовом необходимо задать нулевое значение для C[0]:


C[0]:=0; comb(1);
В процедуре comb глубина рекурсии равна m. Что касается трудоёмко-
сти, то процедура comb, генерируя C nm сочетаний, в отличие от процедуры
per, не делает никакой лишней работы.
Конец примера.

6.2. Бэктрекинг с отсечением

Многие комбинаторные задачи требуют генерации только таких объ-


ектов, которые дополнительно удовлетворяют некоторым соотношениям.
В этом случае можно проверять эти соотношения после окончательной
генерации объекта, но тогда будет выполняться лишняя работа, если объ-
ект не будет им удовлетворять. Гораздо эффективнее ещё на промежуточ-
ных этапах генерации определять, смогут ли в дальнейшем сгенерирован-
ные объекты удовлетворить этим соотношениям.
Такой процесс генерации называется бэктрекингом с отсечением, в
нём выполняется обратный ход по дереву решений, как только выясняется
бесперспективность продолжения генерации объектов.
Пример 6.7. Генерация таких перестановок из n чисел от 1 до n, что-
бы сумма первых n1 чисел была равна заданной величине sum. Величины
n1 и sum должны быть заданы как глобальные переменные, и должно вы-
полняться условие: 1<=n1<n. Процедура генерации:
procedure pers(k,s:integer);
var i:integer;
begin
if k=n1 then begin
i:=sum-s;
if (i>=1)and(i<=n)and(R[i]=0) then begin
P[k]:=i; R[i]:=1;
pers(k+1,s+i);
R[i]:=0;
end
end
96 Лекция 6

else
for i:=1 to n do
if R[i]=0 then begin
P[k]:=i; R[i]:=1;
if k=n then ВЫВОД
else pers(k+1,s+i);
R[i]:=0
end
end;
Параметры процедуры:
k – номер элемента массива P, в котором будут запоминаться очеред-
ные генерируемые числа;
s – сумма чисел в ранее вычисленной части генерируемой перестанов-
ки, s=P[1]+...+P[k-1].
Вызов процедуры генерации:
for i:=1 to n do R[i]:=0;
pers(1,0);
Алгоритм процедуры pers построен на основе алгоритма per. Отли-
чие в том, что в процедуре pers проверка того, что генерируемая переста-
новка чисел будет удовлетворять требуемому условию, выполняется при
генерации числа номер n1 в перестановке. Такое число может не суще-
ствовать, и тогда производится выход из процедуры, т.е. отсечение всех
продолжений вариантов генерации.
Конец примера.
Пример 6.8. Задача о ферзях. Пусть на шахматной доске клетки нуме-
руются двумя числами – номером строки и номером столбца. Фигура
ферзь, стоящая на клетке (i, j), держит под ударом клетки шахматной доски
в строке i, столбце j, а также клетки двух диагоналей, проходящих через
клетку (i, j). Будем рассматривать «шахматную» доску размером n x n. Тре-
буется на доске расставить n ферзей таким образом, чтобы они не «били»
друг друга. Расстановка записывается в виде n чисел, число i на j–м месте
задаёт ферзя на клетке (i, j). Например, для доски 4х4 возможна расстанов-
ка (2, 4, 1, 3), показанная на рис. 6.1.
Бэктрекинг 97

Ф
Ф
Ф
Ф
Рис. 6.1

Таким образом, расстановка ферзей это такая перестановка n чисел,


которая дополнительно обеспечивает условие, что ферзи не бьют друг дру-
га по диагоналям. Каждая клетка (i, j) доски находится на двух диагона-
лях – правой (её номер j – i) и левой (её номер j + i), как показано на
рис. 6.2.
0 1 2 3 2 3 4 5
-1 0 1 2 3 4 5 6
-2 -1 0 1 4 5 6 7
-3 -2 -1 0 5 6 7 8
Рис. 6.2

Рекурсивная процедура Queen вычисляет расстановку ферзей на доске


n x n:
procedure Queen(j:integer);
var i:integer;
begin
for i:=1 to n do
if (S[i]=0)and(R[j-i]=0)and(L[j+i]=0)
then begin
S[i]:=1; R[j-i]:=1; L[j+i]:=1; Q[j]:=i;
if j=n then ВЫВОД
else Queen(j+1);
S[i]:=0; R[j-i]:=0; L[j+i]:=0;
end;
end;
В отличие от процедуры per, в процедуре Queen при генерации оче-
редного числа i в строке j проверяется, что клетка (i, j) не находится на
98 Лекция 6

занятом столбце (S[i]=0), на занятой левой диагонали (R[j-i]=0) и на


занятой правой диагонали (L[j+i]=0).
Главная программа, содержащая описания глобальных переменных,
вводит размер доски n (не более 20), обнуляет массивы S, R, L, и вызывает
процедуру Queen:
var S,Q:array[1..20]of integer;
R:array[-19..19]of integer;
L:array[2..40]of integer;
n,i:integer;
begin
readln(n);
for i:=1 to n do S[i]:=0;
for i:=1-n to n-1 do R[i]:=0;
for i:=2 to 2*n do L[i]:=0;
Queen(1);
end.
Конец примера.
Пример 6.9. Головоломка «магические числовые квадраты». В квад-
ратной таблице n x n требуется так расставить числа 1, 2, …, n2, чтобы
суммы по всем строкам, столбцам и главным диагоналям были одинаковы.
На рис. 6.3 приведены примеры магических квадратов для n = 3, n = 4 и
n = 5. Задача имеет решение лишь для n ≥ 3.
1 2 13 24 25

9 1 8 16 5 23 22 7 8

2 7 6 4 12 5 13 20 19 11 12 3

9 5 1 14 6 11 3 21 4 9 16 15

4 3 8 7 15 10 2 18 17 10 6 14

Рис. 6.3

Будем искать все возможные магические квадраты для заданного n.


Квадратную таблицу вытянем в одну строку с нумерацией ее элементов от
1 до n2. Тогда решением будет такая перестановка чисел 1, 2, …, n2, кото-
рая удовлетворяет условиям магического квадрата. Поэтому за основу ал-
Бэктрекинг 99

горитма можно взять процедуру per, дополнив её проверкой соответству-


ющих условий.
Величину суммы для проверки условий определим из следующих со-
ображений. Сумма S всех чисел квадрата равна:
S = 1 + 2 + … + n2 = n2 (n2 + 1) / 2.
Эта сумма делится на n одинаковых частей (по количеству строк в
квадрате). Таким образом, искомая сумма:
S0 = n∙(n2 + 1)/2.
В отличие от перестановок в процедуре per, магическими квадратами
будет лишь небольшая часть всех перестановок. Так, для n = 3 только 8
перестановок из 9! = 362880, а для n = 4 всего лишь 7040 из
16! = 20922789888000. Пусть, например, компьютер способен за 1 секунду
сгенерировать и проверить 107 квадратов. Тогда для генерации всех маги-
ческих квадратов при n = 4 ему потребуется более 20 дней!
Чтобы уменьшить лишнюю работу, в процедуре sq генерации магиче-
ских квадратов необходимо организовать отсечение тех вариантов квадра-
та, которые заведомо не будут магическими. Для этого проверка условий
магического квадрата выполняется не после того, как сгенерирована оче-
редная перестановка, а как можно раньше, когда сгенерирована только ее
начальная часть. Отсечение выполняется путем немедленного возврата из
рекурсивного вызова на очередном шаге, что позволяет не генерировать
все перестановки, имеющие одинаковую, только что полученную началь-
ную часть:
procedure sq(k:integer);
var i:integer;
begin
if k mod n=0 then begin {последний столбец}
i:=s0-sumH(k);
if (i>=1)and(i<=n2)and(R[i]=0) then begin
R[i]:=1; T[k]:=i;
if k=n2 then ВЫВОД
else sq(k+1);
R[i]:=0
end
end
else if k>n2-n then begin {последняя строка}
i:=s0-sumV(k);
100 Лекция 6

if (i>=1)and(i<=n2)and(R[i]=0) then begin


R[i]:=1; T[k]:=i;
sq(k+1);
R[i]:=0
end
end
else begin {другие элементы}
for i:=1 to n2 do
if R[i]=0 then begin
R[i]:=1; T[k]:=i;
sq(k+1);
R[i]:=0
end
end
end;
Отсечение реализовано следующим образом. Пусть k – порядковый
номер генерируемого элемента в массиве T, который представляет собой
вытянутый в одну линию квадрат. Если номер k соответствует послед-
нему столбцу, то можно сразу определить то число i, которое должно
быть на k-м месте. Число i равно S0 за вычетом суммы предыдущих
элементов квадрата в текущей строке (при этом должно выполняться
R[i]=0). Если к тому же номер k соответствует последнему элементу
квадрата, то нужно дополнительно проверить сумму последнего столбца и
суммы двух диагоналей. Если номер k соответствует последней строке, то
очередное число можно определить аналогичным образом.
Переменная s0 содержит значение S0 (сумму одной строки или столб-
ца в магическом квадрате), n2=n*n. Вспомогательная функция sumH(k)
вычисляет сумму элементов в текущей строке квадрата, расположенных в
массиве T перед k-м элементом, а функция sumV(k) – сумму элемен-
тов в текущем столбце квадрата, расположенных выше k-го элемента.
Функция ВЫВОД осуществляет проверку последнего столбца и двух диа-
гоналей квадрата и при положительном исходе проверок выводит сформи-
рованный магический квадрат.
Для еще большего ускорения в процедуре sq можно дополнительно
предусмотреть отсечение на более ранних этапах генерации квадрата: ко-
гда номер k соответствует предпоследнему столбцу и когда номер k
соответствует предпоследней строке квадрата. Если реализовать все эти
Бэктрекинг 101

усовершенствования, можно уменьшить время работы программы при


n = 4 до нескольких секунд на обычном компьютере.
Конец примера.
А теперь кратко рассмотрим общие принципы метода бэктрекинга для
решения задач комбинаторного характера:
1) вначале следует определить такие структуры данных, с помощью
которых можно перечислить все возможные варианты решения задачи;
2) затем необходимо записать основную рекурсивную процедуру с
циклом, в котором производится перебор вариантов движения вниз на од-
ну ступень по схеме из текущего состояния (с помощью рекурсивного вы-
зова), либо выдачу полностью сгенерированного очередного варианта;
3) в процедуре следует предусмотреть проверку корректности полного
сгенерированного варианта;
4) следует предусмотреть проверку бесперспективности частично сге-
нерированных вариантов и их отсечения, для чего при отрицательной про-
верке не следует выполнять рекурсивный вызов.

Вопросы и задания

1. Провести полное доказательство методом математической индукции для проце-


дуры генерации перестановок. Индукцию следует провести два раза: 1) по парамет-
ру индукции n при фиксированном k; 2) по параметру индукции k при фиксирован-
ном n.
2. Записать нерекурсивный алгоритм генерации перестановок при n = 4. Провести его
полное доказательство методом математической индукции.
3. Провести полное доказательство методом математической индукции для процеду-
ры генерации расстановок с повторениями при фиксированном m. Индукцию сле-
дует провести два раза: 1) по параметру индукции n при фиксированном k; 2) по
параметру индукции k при фиксированном n.
4. Записать нерекурсивный алгоритм генерации расстановок с повторениями при
m = 4 и произвольном n. Провести его полное доказательство методом математиче-
ской индукции.
5. Написать и отладить программу, которая вводит число n и вводит в массив n сим-
вольных строк с записанными в них словами. После этого процедурой генерации
перестановок генерирует и выводит перестановки этих слов. Создать тесты для
программы методом чёрного и белого ящика и провести тестирование.
102 Лекция 6

6. Написать и отладить программу, которая вводит числа n и m, затем вводит в массив


n символьных строк с записанными в них словами. После этого процедурой генера-
ции размещений из n по m генерирует и выводит размещения этих слов. Создать те-
сты для программы методом чёрного и белого ящика и провести тестирование.
7. Написать и отладить программу, которая вводит числа n и m, затем вводит в массив
n символьных строк с записанными в них словами. После этого процедурой генера-
ции сочетаний из n по m генерирует и выводит сочетания этих слов. Создать тесты
для программы методом чёрного и белого ящика и провести тестирование.
8. Провести полное доказательство методом математической индукции для програм-
мы генерации сочетаний из n по m.
9. Записать нерекурсивный алгоритм генерации сочетаний из n по m при m = 3. Про-
вести его полное доказательство методом математической индукции.
10. Записать алгоритм генерации размещений из n по m при ограничении, что сумма
чисел в размещении должна быть равна S. Предусмотреть как можно раньше воз-
врат из рекурсии. Провести его полное доказательство методом математической
индукции.
11. Записать алгоритм генерации сочетаний из n по m при ограничении, что сумма чи-
сел в сочетании должна быть равна S. Предусмотреть как можно раньше возврат из
рекурсии. Провести его полное доказательство методом математической индукции.
12. Написать и отладить программу, которая вводит число n, после чего процедурой
генерации перестановок ферзей на шахматной доске размером n х n генерирует и
выводит в виде изображения доски с ферзями на ней все перестановки. Провести
тестирование для n от 4 до 8.
13. Какой алгоритм генерирует все перестановки ладей на шахматной доске размером
n х n (ладья бъёт по горизонтали и вертикали)?
14. Написать программу, которая вводит размер квадрата n и количество генерируемых
квадратов L. После этого программа должна генерировать и выводить L первых ма-
гических квадратов (если при заданном n их оказалось меньше L, то все сгенериро-
ванные квадраты. Провести тестирование для n от 3 до 5.
Лекция 7.
Множества

7.1. Задание множества двоичным массивом

Пусть задано N некоторых объектов (множество объектов). Если объ-


екты перенумеровать числами от 1 до N, то каждый объект будет опреде-
ляться своим номером. При этом любым двум различным объектам будут
приписаны различные номера. Все множество рассматриваемых объектов
называют исходным множеством или универсумом. Некоторые из объ-
ектов универсума можно выделить, тогда образуется подмножество выде-
ленных объектов, которое называют просто множеством. В теории мно-
жеств изучаются как конечные, так и бесконечные множества, но в любом
завершимом алгоритме можно иметь дело только с конечными множе-
ствами.
Множества можно задавать несколькими способами. Самый простой
способ – в виде двоичного массива, размер которого равен количеству
объектов в универсуме, а каждый элемент массива может принимать два
значения. Пусть N – количество объектов в универсуме, массив M содержит
N элементов, которые можно задать, типом integer или byte (тогда их
значение 0 или 1), или типом boolean (тогда их значение false или
true). Конкретное множество задается набором значений элементов мас-
сива M. Если M[i]=1 (или true), то i-й объект универсума присут-
ствует в множестве, а если M[i]=0 (или false), то нет. Всего суще-
ствует 2N таких различных наборов из N нулей и единиц, т.е. 2N различных
подмножеств данного универсума.
Пример 7.1. Выполнение основных действий с множеством, заданным
в виде целочисленного массива M.
Проверка принадлежности объекта множеству:
if M[i]=1 then {объект номер i принадлежит множеству}.
Добавление объекта номер i в множество:
M[i]:=1.
104 Лекция 7

Удаление объекта номер i из множества:


M[i]:=0.
Вычисление мощности K множества (количества элементов в нем):
K:=0;
for i:=1 to N do
K:=K+M[i];
Цикл выполняется N раз, т.е. трудоёмкость имеет порядок O(N).
Конец примера.

Пример 7.2. Операции над двумя множествами, заданными двоичны-


ми массивами A и B. Множества должны быть определены на одном и том
же универсуме из N объектов, поэтому размер массивов A и B должен
быть равен N. Результат в массиве C длины N.
Объединение (A U B) – в множество C входят те объекты, которые есть
в множестве A или в множестве B или в обоих множествах одновременно:
for i:=1 to N do
if (A[i]=1)or(B[i]=1) then C[i]:=1
else C[i]:=0;
Пересечение (A ∩ B) – в множество C входят те объекты, которые
есть в множестве A и в множестве B одновременно:
for i:=1 to N do
if (A[i]=1)and(B[i]=1) then C[i]:=1
else C[i]:=0;
Разность (A \ B) – в множество C входят те объекты, которые есть в
множестве A, но отсутствуют в множестве B:
for i:=1 to N do
if (A[i]=1)and(B[i]=0) then C[i]:=1
else C[i]:=0;
Симметрическая разность (A  B) – в множество C входят те объек-
ты, которые есть в множестве A или в множестве B но не в обоих множе-
ствах одновременно:
Множества 105

for i:=1 to N do
if (A[i]=1)xor(B[i]=1) then C[i]:=1
else C[i]:=0;
Дополнение множества A (\ A) – в множество C входят те объекты,
которые отсутствуют в множестве A (но есть в универсуме):
for i:=1 to N do
C[i]:=1-A[i];
Конец примера.
Пример 7.3. Операции над множествами, представленными логиче-
скими массивами (тип boolean).
Проверка принадлежности объекта множеству М:
if M[i] then {объект номер i принадлежит множеству}.
Добавление объекта номер i в множество М:
M[i]:= true;
Удаление объекта номер i из множества М:
M[i]:= false;
Вычисление мощности K множества М:
K:=0;
for i:=1 to N do
if M[i] then K:=K+1;
Объединение (A U B):
for i:=1 to N do C[i]:= A[i] or B[i];
Пересечение (A ∩ B) :
for i:=1 to N do C[i]:= A[i] and B[i];
Разность (A \ B):
for i:=1 to N do C[i]:= A[i] and not B[i];
Симметрическая разность (A  B):
for i:=1 to N do C[i]:= A[i] xor B[i];
106 Лекция 7

Дополнение множества A (\ A):


for i:=1 to N do C[i]:= not A[i];
Конец примера.

7.2. Задание множества целочисленным массивом

Если размер универсума слишком велик, то множества целесообразно


задавать целочисленным массивом. Пусть, например, универсум – множе-
ство всех целых чисел определенной длины (например, 32 бита), тогда его
размер равен 232 = 4294967296. В компьютере для массива такой длины
может просто не хватить оперативной памяти, а если хватит, то время вы-
полнения операций может оказаться слишком большим.
Если при этом мощность множества всегда ограничена некоторым
числом L, то и размер массива для представления множества должен быть
равен L. Тогда кроме массива M из L элементов, необходима переменная
K, задающая текущую мощность множества, причем всегда K ≤ L.
Значения M[1],...,M[K] задают номера объектов, принадлежащих
множеству. Заметим, что в массиве не должно быть элементов с одинако-
выми значениями! При этом номера объектов в массиве могут распола-
гаться как в произвольном порядке, так и в упорядоченном, например, по
возрастанию.
Пример 7.4. Действия с неупорядоченным массивом M, представляю-
щим множество.
Поиск объекта номер i в множестве:
j:=1;
while (j<=K)and(M[j]<>i) do j:=j+1;
if j<=K then НАЙДЕН else НЕТ;
Добавление объекта номер i в множество:
j:=1;
while (j<=K)and(M[j]<>i) do j:=j+1;
if j>K then begin K:=K+1; M[K]:=i end;
Множества 107

Удаление объекта номер i из множества:


j:=1;
while (j<=K)and(M[j]<>i) do j:=j+1;
if j<=K then begin M[j]:=M[K]; K:=K-1 end;

Во всех трёх алгоритмах вначале в цикле производится поиск. Условие


завершения цикла: либо j = K+1, тогда искомого объекта в массиве нет,
либо M[j] = i, тогда объект номер i найден. Во втором алгоритме объ-
ект номер i добавляется в множество только в том случае, если его там не
было, а в третьем алгоритме объект номер i удаляется из множества толь-
ко в том случае, если он там был, и тогда на то место, где он был, записы-
вается объект, который был на K-м месте.
Все действия с поиском, добавлением и удалением объекта имеют тру-
доёмкость O(K).
Конец примера.
Пример 7.5. Операции над двумя множествами, заданными неупоря-
доченными массивами A и B. Количество объектов в множествах K1 и K2
соответственно. Результат вычислений записывается в массив C, количе-
ство заполненных элементов в нём – K3, подсчитывается в процессе вы-
числений.
Объединение (A U B):
for i:=1 to K1 do C[i]:=A[i];
K3:=K1;
for i:=1 to K2 do
begin j:=1;
while (j<=K1)and(A[j]<>B[i]) do
j:=j+1;
if j=K1+1 then begin
K3:=K3+1; C[K3]:=B[i]
end
end;
Вначале производится копирование всех объектов из массива A в мас-
сив C. Затем из массива В в массив C копируются те объекты, которых нет
108 Лекция 7

в массиве A. В цикле while ищется совпадение элемента массива B[i] с


каким-либо элементом из массива A.
Трудоёмкость 1-го цикла O(K1), 2-го (двойного вложенного) цикла
O(K1*K2), т.е. общая трудоёмкость – O(K1*K2).
Пересечение (A ∩ B):
K3:=0;
for i:=1 to K2 do
begin j:=1;
while (j<=K1)and(A[j]<>B[i]) do
j:=j+1;
if j<=K1 then begin
K3:=K3+1; C[K3]:=B[i]
end;
end;
Здесь из массива В в массив C копируются только те объекты, которые
есть в массиве A. Трудоёмкость – O(K1*K2).
Разность (A \ B):
K3:=0;
for i:=1 to K1 do
begin j:=1;
while (j<=K2)and(A[i]<>B[j]) do
j:=j+1;
if j=K2+1 then begin
K3:=K3+1; C[K3]:=A[i]
end;
end;
Здесь из массива А в массив C копируются те объекты, которых нет в
массиве В. Трудоёмкость – O(K1*K2).
Симметрическая разность множеств A и В равна (A\B)U(B\A).
Чтобы ее вычислить, надо вначале в массив C записать разность A\B, а за-
тем массив C дополнить разностью B\A.
Конец примера.
Множества 109

Пример 7.6. Действия с упорядоченным массивом M, представляющим


множество.
Поиск объекта номер i в множестве:
b:=1; e:=K;
while b<e do
begin
c:=(b+e)div 2;
if M[c]<i then b:=c+1
else e:=c
end;
if M[b]=i then НАЙДЕН else НЕТ;
Реализуется как дихотомический поиск, его трудоёмкость O(log K).
Добавление объекта номер i в множество:
b:=1; e:=K;
while b<e do
begin c:=(b+e)div 2;
if M[c]<i then b:=c+1 else e:=c
end;
if M[b]<>i then begin
K:=K+1; j:=K;
if M[b]<i then b:=b+1;
while j>b do begin
M[j]:=M[j-1]; j:=j-1;
end;
M[b]:=i
end;
Вначале выполняется дихотомический поиск в массиве M элемента со
значением i. Добавление производится только в том случае, если такого
элемента нет. Когда цикл поиска while завершается, b=е, и новый эле-
мент со значением i должен располагаться на месте M[b](или M[b+1],
если максимальный элемент в массиве меньше i). Перед размещением
этого элемента в массиве все элементы, расположенные на местах от b до
K последовательно сдвигаются вправо на одну позицию.
110 Лекция 7

Трудоёмкость O(K), если добавляемого объекта не было, или O(log K),


если этот объект уже был в множестве.
Удаление объекта номер i из множества:
b:=1; e:=K;
while b<e do
begin c:=(b+e)div 2;
if M[c]<i then b:=c+1 else e:=c
end;
if M[b]=i then begin
while b<K do begin
M[b]:=M[b+1]; b:=b+1;
end;
K:=K-1;
end;
Вначале выполняется поиск в массиве M элемента со значением i.
Удаление производится только в том случае, если такой элемент есть. Ко-
гда цикл поиска while завершается, b=е, удаляемый элемент находится
на месте M[b]. Для удаления этого элемента все элементы, расположен-
ные на местах от b+1 до K последовательно сдвингаются влево на одну
позицию.
Трудоёмкость O(K), если удаляемый объект был, или O(log K), если
этого объекта не было в множестве.
Конец примера.
Пример 7.7. Операции над двумя множествами, заданными упорядо-
ченными массивами A и B с количеством объектов в них K1 и K2 соответ-
ственно. Результат вычислений – в массиве C, количество элементов в
нём – K3.
Объединение (A U B):
i1:=1; i2:=1; K3:=0;
while (i1<=K1)and(i2<=K2) do
if A[i1]<B[i2] then begin
K3:=K3+1; C[K3]:=A[i1]; i1:=i1+1
end
else if A[i1]>B[i2] then begin
Множества 111

K3:=K3+1; C[K3]:=B[i2]; i2:=i2+1


end
else begin
K3:=K3+1; C[K3]:=B[i2];
i2:=i2+1; i1:=i1+1
end;
while i1<=K1 do begin
K3:=K3+1; C[K3]:=A[i1]; i1:=i1+1
end;
while i2<=K2 do begin
K3:=K3+1; C[K3]:=B[i2]; i2:=i2+1
end;
Объединение множеств в виде упорядоченных массивов производится
по принципу слияния. Однако, в отличие от слияния обычных массивов, в
которых могут быть элементы с повторяющимися значениями, здесь в
первом цикле, когда элементы в обоих массивах еще не до конца просмот-
рены, т.е. условие продолжения цикла истинно, различаются три варианта:
1) A[i1]<B[i2], тогда в массив C копируется элемент из A;
2) A[i1]>B[i2], тогда в массив C копируется элемент из B;
3) A[i1]=B[i2], тогда в массив C копируется элемент из B, но в этом
случае считаются просмотренными оба элемента: A[i1] и B[i2].
Когда первый цикл закончится, останутся непросмотренными или эле-
менты из массива A, или элементы из массива B, или вообще не останется
непросмотренных элементов. Тогда оставшиеся элементы дописываются в
конец массива C.
Трудоёмкость O(K1+K2).
Пересечение (A ∩ B):
i1:=1; i2:=1; K3:=0;
while (i1<=K1)and(i2<=K2) do
if A[i1]<B[i2] then i1:=i1+1
else if A[i1]>B[i2] then i2:=i2+1
else begin
K3:=K3+1; C[K3]:=B[i2];
i2:=i2+1; i1:=i1+1
end;
112 Лекция 7

Пересечение множеств в виде упорядоченных массивов производится


по принципу слияния. Однако, в отличие от слияния обычных массивов, в
которых могут быть элементы с повторяющимися значениями, здесь, когда
элементы в обоих массивах еще не до конца просмотрены, т.е. условие
продолжения цикла истинно, различаются три варианта:
1) A[i1]<B[i2], тогда в массив C ничего не копируется, считается
просмотренным элемент A[i1];
2) A[i1]>B[i2], тогда в массив C ничего не копируется, считается
просмотренным элемент В[i2];
3) A[i1]=B[i2], тогда в массив C копируется элемент из B, в этом
случае считаются просмотренными как элемент A[i1], так и элемент
B[i2].
После завершения цикла оставшиеся непросмотренными элементы мо-
гут находиться только в одном из массивов, они заведомо не могут совпа-
дать с элементами из другого массива.
Трудоёмкость: O(K1+K2)
Симметрическая разность (A  B):
i1:=1; i2:=1; K3:=0;
while (i1<=K1)and(i2<=K2) do
if A[i1]<B[i2] then begin
K3:=K3+1; C[K3]:=A[i1]; i1:=i1+1
end
else if A[i1]>B[i2] then begin
K3:=K3+1; C[K3]:=B[i2]; i2:=i2+1
end
else begin
i2:=i2+1; i1:=i1+1
end;
while i1<=K1 do begin
K3:=K3+1; C[K3]:=A[i1]; i1:=i1+1
end;
while i2<=K2 do begin
K3:=K3+1; C[K3]:=B[i2]; i2:=i2+1
end;
Множества 113

Симметрическая разность множеств в виде упорядоченных массивов


производится по принципу слияния. В первом цикле, когда элементы в
обоих массивах еще не до конца просмотрены, т.е. условие продолжения
цикла истинно, различаются три варианта:
1) A[i1]<B[i2], тогда в массив C копируется элемент из A;
2) A[i1]>B[i2], тогда в массив C копируется элемент из B;
3) A[i1]=B[i2], тогда в массив C ничего не копируется, но в этом
случае считаются просмотренными как элемент A[i1], так и элемент
B[i2].
Когда первый цикл закончится, останутся непросмотренными или эле-
менты из массива A, или элементы из массива B, или вообще не останется
непросмотренных элементов. Тогда оставшиеся элементы дописываются в
конец массива C.
Трудоёмкость O(K1+K2).
Конец примера.

7.3. Задание множества списком номеров объектов

Из номеров элементов универсума, принадлежащих конкретному


множеству, можно сформировать не только массив, но и список. Для эле-
ментов списка будем использовать описания типа pel из примера 5.1. Та-
кой список может быть как упорядоченным, так и неупорядоченным.
Пример 7.8. Действия с неупорядоченным списком, представляющим
множество. Указатель p1 указывает на начало списка.
Проверка принадлежности объекта номер i множеству:

P3:=p1;
while (p3<>nil)and(p3^.s<>i) do p3:=p3^.p;
if (p3<>nil)and(p3^.s=i)then НАЙДЕН else НЕТ;

Добавление объекта номер i в множество. Сначала проверяется, не


принадлежит ли уже объект номер i множеству. Если нет, то объект до-
бавляется в начало списка.
114 Лекция 7

P3:=p1;
while (p3<>nil)and(p3^.s<>i) do p3:=p3^.p;
if (p3=nil) then begin
new(p3); p3^.s:=i; p3^.p:=p1; p1:=p3;
end;
Удаление объекта номер i из множества:
p3:=nil; p4:=p1;
while (p4<>nil)and(p4^.s<>i) do
begin p3:=p4; p4:=p4^.p end;
if (p4<>nil)and(p4^.s=i) then begin
if p3<>nil then p3^.p:=p4^.p
else p1:=p1^.p;
dispose(p4)
end;
Сначала проверяется, принадлежит ли объект номер i множеству.
Постусловие для цикла:
1) (p4=nil)or(p4^.s=i) – объект номер i не принадлежит мно-
жеству;
2) (p4^.s=i)and(p3<>nil) – объект номер i находится в сере-
дине или в конце списка;
3) (p4^.s=i)and(p3=nil) – объект номер i находится в начале
списка;
Вычисление мощности множества:
K:=0; p3=p1;
while (p3<>nil) do
begin K:=K+1; p3:=p3^.p end;
Трудоёмкость каждого из рассмотренных действий – O(K), где K – ко-
личество объектов в множестве.
Конец примера.
Пример 7.9. Операции над двумя множествами, заданными неупоря-
доченными списками. Указатель p1 указывает на начало 1-го списка, ука-
затель p2 – на начало 2-го списка. В результате создаётся 3-й список с ука-
зателем p3.
Множества 115

Объединение множеств:
new(p3); p4:=p3; p5:=p1;
while p5<>nil do begin
new(p6); p4^.p:=p6; p4:=p6;
p6^.s:=p5^.s; p5:=p5^.p;
end;
p7:=p2;
while p7<>nil do begin
p5:=p1;
while(p5<>nil)and(p7^.s<>p5^.s)do p5:=p5^.p;
if p5=nil then begin
new(p6); p4^.p:=p6; p4:=p6; p6^.s:=p7^.s;
end;
p7:=p7^.p;
end;
p4^.p:=nil; p6:=p3; p3:=p3^.p; dispose(p6);
Сначала создается фиктивный элемент 3-го списка (с указателем p3), к
нему далее в 1-м цикле присоединяются копии элементов 1-го списка. Ука-
затель p4 указывает на последний элемент в 3-м списке.
Затем в цикле просматриваются все элементы 2-го списка, и для каж-
дого из них во вложенном цикле проверяется совпадение с каким-либо из
элементов 1-го списка. Если совпадения не было, то к 3-му списку присо-
единяется копия элемента из 1-го списка.
В завершение уничтожается фиктивный элемент в начале 3-го списка.
Пересечение множеств:
new(p3); p4:=p3; p7:=p2;
while p7<>nil do begin
p5:=p1;
while(p5<>nil)and(p7^.s<>p5^.s)do p5:=p5^.p;
if p5<>nil then begin
new(p6); p4^.p:=p6; p4:=p6; p6^.s:=p7^.s;
end;
p7:=p7^.p;
end;
p4^.p:=nil; p6:=p3; p3:=p3^.p; dispose(p6);
116 Лекция 7

Здесь также вначале создается фиктивный элемент 3-го списка. Затем в


цикле просматриваются все элементы 2-го списка, и для каждого из них во
вложенном цикле проверяется совпадение с каким-либо из элементов 1-го
списка. Если совпадение было, то к 3-му списку присоединяется копия
элемента из 1-го списка.
В завершение уничтожается фиктивный элемент в начале 3-го списка.
Симметрическая разность множеств:
new(p3); p4:=p3; p7:=p1;
while p7<>nil do begin
p5:=p2;
while(p5<>nil)and(p7^.s<>p5^.s)do p5:=p5^.p;
if p5=nil then begin
new(p6); p4^.p:=p6; p4:=p6; p6^.s:=p7^.s;
end;
p7:=p7^.p;
end;
p7:=p2;
while p7<>nil do begin
p5:=p1;
while(p5<>nil)and(p7^.s<>p5^.s)do p5:=p5^.p;
if p5=nil then begin
new(p6); p4^.p:=p6; p4:=p6; p6^.s:=p7^.s;
end;
p7:=p7^.p;
end;
p4^.p:=nil; p6:=p3; p3:=p3^.p; dispose(p6);
Аналогично предыдущим операциям также вначале создается фиктив-
ный элемент 3-го списка. Затем в цикле просматриваются все элементы
1-го списка, и для каждого из них во вложенном цикле проверяется совпа-
дение с каким-либо из элементов 2-го списка. Если совпадения не было, то
к 3-му списку присоединяется копия элемента из 1-го списка. В результате
в 3-м списке формируется разность 1-го и 2-го множеств. Далее аналогич-
ным способом к 3-му списку добавляется разность 2-го и 1-го множеств.
В завершение уничтожается фиктивный элемент в начале 3-го списка.
Множества 117

Трудоёмкость каждой из рассмотренных операций – O(K1*K2), где K1


и K2 – количество объектов в 1-м и 2-м множествах соответственно.
Конец примера.
Пример 7.10. Действия с упорядоченным списком, представляющим
множество. Указатель p1 указывает на начало списка.
Проверка принадлежности объекта номер i множеству:
P3:=p1;
while (p3<>nil)and(p3^.s<i) do p3:=p3^.p;
if (p3<>nil)and(p3^.s=i)then НАЙДЕН else НЕТ;
Добавление объекта номер i в множество:
if p1=nil then begin {создание списка}
new(p1); p1^.s:=i; p1^.p:=nil;
end
else begin
p4:=nil; p5:=p1;
while (p5<>nil)and(p5^.s<S0) do
begin p4:=p5; p5:=p5^.p end;
if (p5=nil)or(p5^.s>S0) then begin
new(p3); p3^.s:=i;
if p4=nil then p1:=p3{вставка в начало списка}
else p4^.p:=p3;{вставка в список между p4 и p5}
p3^.p:=p5;
end;
end;
Отдельно проверяется, не пустой ли список, и тогда он создаётся из
элемента i. В противном случае в цикле проверяется, не принадлежит ли
уже объект номер i множеству. Если нет, новый элемент вставляется в
начало списка или между указателями p4 и p5.
Удаление объекта номер i из множества выполняется алгоритмом
из примера 5.8 (удаление элемента из упорядоченного списка).
Трудоёмкость каждого из рассмотренных действий – O(K), где K – ко-
личество объектов в множестве.
Конец примера.
118 Лекция 7

Пример 7.11. Операции над двумя множествами, заданными упорядо-


ченными списками. Указатель p1 указывает на начало 1-го списка, указа-
тель p2 – на начало 2-го списка. В результате создаётся 3-й список с указа-
телем p3.
Все операции реализуются алгоритмами по методу слияния, но, в от-
личие от обычного слияния, в создаваемом списке не должно быть элемен-
тов с одинаковым значением. Аналогично операциям над неупорядочен-
ными списками, вначале создаётся фиктивный элемент 3-го списка, к ко-
торому присоединяются другие элементы списка, а в конце этот фиктив-
ный элемент удаляется.
Объединение множеств:
new(p3); p4:=p3; p11:=p1; p22:=p2;
{Создан фиктивный элемент}
while (p11<>nil)and(p22<>nil) do begin
new(p5); p4^.p:=p5; p4:=p5;
if p11^.s<p22^.s then begin
p5^.s:=p11^.s; p11:=p11^.p
end
else if p11^.s>p22^.s then begin
p5^.s:=p22^.s; p22:=p22^.p end
else begin
p5^.s:=p11^.s; p11:=p11^.p; p22:=p22^.p
end
end;
while (p11<>nil) do begin
new(p5); p4^.p:=p5; p4:=p5;
p5^.s:=p11^.s; p11:=p11^.p
end;
while (p22<>nil) do begin
new(p5); p4^.p:=p5; p4:=p5;
p5^.s:=p22^.s; p22:=p22^.p
end;
p4^.p:=nil; p5:=p3; p3:=p3^.p;
dispose(p5); {удалён фиктивный элемент}
Множества 119

Операция отличается от обычного слияния тем, что в первом цикле,


если два очередных элемента 1-го и 2-го списком равны, то копируется
только один такой элемент.
Пересечение множеств:
new(p3); p4:=p3; p11:=p1; p22:=p2;
{Создан фиктивный элемент}
while (p11<>nil)and(p22<>nil) do
if p11^.s<p22^.s
then p11:=p11^.p
else if p11^.s>p22^.s
then p22:=p22^.p
else begin
new(p5); p4^.p:=p5; p4:=p5;
p4^.s:=p11^.s;
p11:=p11^.p; p22:=p22^.p
end;
p4^.p:=nil; p5:=p3; p3:=p3^.p;
dispose (p5); {удален фиктивный элемент}
Эта операция отличается от обычного слияния тем, что если два оче-
редных элемента 1-го и 2-го списком равны, то копируется только один
такой элемент, а неравные элементы не копируются.
Симметрическая разность множеств:
new(p3); p4:=p3; p11:=p1; p22:=p2;
{Создан фиктивный элемент}
while (p11<>nil)and(p22<>nil) do
if p11^.s<p22^.s then begin
new(p5); p4^.p:=p5; p4:=p5;
p5^.s:=p11^.s; p11:=p11^.p
end
else if p11^.s>p22^.s then begin
new(p5); p4^.p:=p5; p4:=p5;
p5^.s:=p22^.s; p22:=p22^.p end
else begin
p11:=p11^.p; p22:=p22^.p
120 Лекция 7

end;
while (p11<>nil) do begin
new(p5); p4^.p:=p5; p4:=p5;
p5^.s:=p11^.s; p11:=p11^.p
end;
while (p22<>nil) do begin
new(p5); p4^.p:=p5; p4:=p5;
p5^.s:=p22^.s; p22:=p22^.p
end;
p4^.p:=nil; p5:=p3; p3:=p3^.p;
dispose(p5); {удален фиктивный элемент}
Операция отличается от обычного слияния тем, что в первом цикле ко-
пируются только неравные очередные элементы из 1-го и 2-го списков.
Конец примера.

Вопросы и задания

1. Доказать, что если в универсуме N объектов, то всего существует 2N различных


подмножеств.
2. Как вычисляются объединение, пересечение, разность, симметрическая разность и
дополнение множеств, заданных двоичными массивами, и с какой трудоёмкостью?
3. Написать программу, которая преобразует представление множества из двоичного
массива А длиной N элементов в упорядоченный массив M номеров объектов мно-
жества, если известно, что мощность множества не превышает L. Вычислить при
этом мощность множества K.
4. Написать программу, которая преобразует представление множества из упорядо-
ченного массива M номеров объектов множества в двоичный массив А длиной n
элементов, если известно, что мощность множества равна K. Какова её трудоём-
кость?
5. Написать программу, которая преобразует представление множества из неупорядо-
ченного массива M номеров объектов множества в двоичный массив А длиной n
элементов, если известно, что мощность множества равна K. Какова её трудоём-
кость?
6. Написать программу, которая преобразует представление множества из двоичного
массива А длиной N элементов в упорядоченный список номеров объектов множе-
Множества 121

ства, если известно, что мощность множества не превышает L. Вычислить при этом
мощность множества K.
7. Написать программу, которая преобразует представление множества из упорядо-
ченного списка номеров объектов множества в двоичный массив А длиной n эле-
ментов, если известно, что мощность множества равна K. Какова её трудоёмкость?
8. Написать программу, которая преобразует представление множества из неупорядо-
ченного списка номеров объектов множества в двоичный массив А длиной n эле-
ментов, если известно, что мощность множества равна K. Какова её трудоёмкость?
9. Доказать корректность программы, добавляющей объект в множество, представ-
ленное целочисленным упорядоченным массивом. Какова её трудоёмкость?
10. Доказать корректность программы, удаляющей объект из множества, представлен-
ного целочисленным упорядоченным массивом. Какова её трудоёмкость?
11. Доказать корректность программы, вычисляющей объединение множеств, заданных
целочисленными неупорядоченными массивами. Какова её трудоёмкость?
12. Доказать корректность программы, вычисляющей пересечение множеств, заданных
целочисленными неупорядоченными массивами. Какова её трудоёмкость?
13. Написать программу вычисления симметрической разности множеств, заданных
целочисленными неупорядоченными массивами. Доказать её корректность. Какова
её трудоёмкость?
14. Доказать корректность программы, вычисляющей объединение множеств, заданных
целочисленными упорядоченными массивами. Какова её трудоёмкость?
15. Доказать корректность программы, вычисляющей пересечение множеств, заданных
целочисленными упорядоченными массивами. Какова её трудоёмкость?
16. Написать программу вычисления разности разности множеств, заданных целочис-
ленными упорядоченными массивами. Доказать её корректность. Какова её трудо-
ёмкость?
17. Доказать корректность программы, удаляющей объект из множества, представлен-
ного упорядоченным списком. Какова её трудоёмкость?
18. Доказать корректность программы, вычисляющей объединение множеств, заданных
неупорядоченными списками. Какова её трудоёмкость?
19. Доказать корректность программы, вычисляющей пересечение множеств, заданных
неупорядоченными списками. Какова её трудоёмкость?
20. Написать программу вычисления разности множеств, заданных неупорядоченными
списками. Доказать корректность программы. Какова её трудоёмкость?
21. Доказать корректность программы, вычисляющей объединение множеств, заданных
упорядоченными списками. Какова её трудоёмкость?
22. Доказать корректность программы, вычисляющей пересечение множеств, заданных
упорядоченными списками. Какова её трудоёмкость?
23. Написать программу вычисления разности множеств, заданных упорядоченными
списками. Доказать корректность программы. Какова её трудоёмкость?
Лекция 8.
Символьные строки и таблицы

8.1. Алгоритмы обработки символьных строк

Символы и их коды. Символьные переменные имеют тип char.


Пример описания таких переменных:
var c1, c2: char;
Над символьными переменными возможны следующие действия:
1) присваивание, например:
c1:=’a’; c2:=’+’;
2) сравнение такими же операциями, как для чисел, при этом сравни-
ваются коды символов, как целые числа.
3) функция ord, вычисляет код символа, её результат – целое число.
Коды символов задаются одной из нескольких возможных таблиц ко-
дирования. В частности, стандартная таблица ASCII задаёт коды символов,
занимающих 1 байт. Код, который можно записать в 1 байт – неотрица-
тельное число в диапазоне от 0 до 255.
Основная часть кодовой таблицы ASCII содержит символы с кодами
от 0 до 127, в двоичной записи от 00000000 до 01111111. Символы с кода-
ми от 0 до 31 – специальные управляющие (перевод строки и др.). В этой
части таблицы ASCII имеются следующие символы:
1) знаковые символы, включая символ пробела;
2) цифры от 0 до 9;
3) заглавные буквы латинского алфавита;
4) строчные буквы латинского алфавита.
Вторая часть таблицы ASCII имеет несколько вариантов для различ-
ных алфавитов и различных операционных систем. Для русского алфавита
используются два варианта: кодовая таблица CP866 (MS DOS) и кодовая
таблица CP1251 (Windows). В обоих вариантах имеются заглавные и
строчные буквы русского алфавита (кириллица), но коды одной и той же
буквы в этих таблицах различные.
В таблице на рис. 8.1 и рис. 8.2 код символа (в шестнадцатеричной си-
стеме) вычисляется как сумма номера строки (от 0 до F) и числа, которым
помечен столбец. Например, код символа '<' равен:
Символьные строки и таблицы 123

C16 + 3016 = 3C16 = 11210.


Основная таблица ASCII содержит символы с кодами от 0 до 127, для
их записи достаточно 7 бит. При записи кода в байт (8 бит) старший бит
полагается равным нулю.
00 10 20 30 40 50 60 70
0 0 @ P ‘ p
1 ! 1 A Q a q
2 " 2 B R b r
3 # 3 C S c s
4 $ 4 D T d t
5 % 5 E U e u
6 & 6 F V f v
7 ’ 7 G W g w
8 ( 8 H X h x
9 ) 9 I Y i y
A * : J Z j z
B + ; K [ k {
C , < L \ l |
D - = M ] m }
E . > N ^ n ~
F / ? O _ o

Рис. 8.1

Коды от 0 до 31 соответствуют специальным управляющим символам, код


32 имеет символ пробела, код 127 – символ del (удаление).
Вторая часть таблицы ASCII содержит символы с кодами от 128 до 255,
при записи кода в байт (8 бит) старший бит равен единице. В варианте CP866
содержатся русские буквы, символы псевдографики (которые можно исполь-
зовать для оформления таблиц), а также ряд других символов.
124 Лекция 8

80 90 A0 B0 C0 D0 E0 F0
0 А Р а ░ └ ╨ р Ё
1 Б С б ▒ ┴ ╤ с ё
2 В Т в ▓ ┬ ╥ т Є
3 Г У г │ ├ ╙ у є
4 Д Ф д ┤ ─ ╘ ф Ї
5 Е Х е ╡ ┼ ╒ х ї
6 Ж Ц ж ╢ ╞ ╓ ц Ў
7 З Ч з ╖ ╟ ╫ ч ў
8 И Ш и ╕ ╚ ╪ ш º
9 Й Щ й ╣ ╔ ┘ щ •
A К Ъ к ║ ╩ ┌ ъ ∙
B Л Ы л ╗ ╦ █ ы √
C М Ь м ╝ ╠ ▄ ь №
D Н Э н ╜ ═ ▌ э ¤
E О Ю о ╛ ╬ ▐ ю ■
F П Я п ┐ ╧ ▀ я

Рис. 8.2
Символьные строки. Из символов, также как из других типов дан-
ных, можно создавать массивы. Кроме того, можно описывать символьные
строки, как особые массивы. Пример описания:
var s1, s2: string[20];
Описаны две символьные строки, для каждой строки выделена память
размером 20 символов, это значит, что в строке может быть записано от 0
до 20 символов. Если в описании отсутствует число в квадратных скобках,
то по умолчанию выделяется память размером 255 символов.
Действия с символьными строками:
- присваивание, например:
s1:=’’; {текущая длина строки 0}
s1:=’abcd’; {текущая длина строки 4}
- сложение (конкатенация, склеивание), например:
Символьные строки и таблицы 125

s2:=s1+’efg’; {текущая длина строки s2 равна 7}


- доступ к символу внутри строки, как к элементу массива, например:
с1:=s1[1];
- функция length вычисляет фактическую длину строки;
- сравнение строк.
Сравнение двух строк символов производится в лексикографическом
порядке:
1) вначале сравниваются между собой первые символы обеих строк,
затем вторые и т.д.;
2) сравнение продолжается до первого несовпадения двух сравнивае-
мых символов, большей считается та строка, код очередного символа у
которой больше;
3) если первая строка полностью совпадает с началом более длинной
второй строки, то первая строка считается меньшей.
Примеры сравнения двух строк:
’ABC’ < ’BBC’ (здесь первый символ ’A’<’B’)
’ABC’ < ’ABD’ (здесь 3-й символ ’C’<’D’)
’ABC’ < ’ABCD’(строка ’ABC’короче ’ABCD’)
’ABC’ < ’abc’ (здесь первый символ ’A’<’a’)
’Abc’ < ’abc’ (здесь первый символ ’A’<’a’)
’ДОМ’ < ’ДЫМ’ (здесь 2-й символ ’О’<’Ы’)
’ДОМ’ < ’ДОМИК’ (строка ’ДОМ’короче ’ДОМИК’)
’ДОМ’ < ’Дом’ (здесь 2-й символ ’О’<’о’)
Множество из объектов – символьных строк. Массив из символьных
строк можно интерпретировать, как множество, если присвоенные строкам
значения различны. Такое множество аналогично множеству из объектов,
перенумерованных целыми числами.
Пример описания такого множества:
var D: array[1..1000]of string[20];
Универсум для этого примера – множество всех возможных строк дли-
ной от 0 до 20 символов. Количество объектов в таком универсуме обычно
гораздо больше того, что можно перенумеровать целыми числами стан-
дартной длины. Так, для данного примера:
1 + 256 + 2562 + 2563 + . . . + 25620 = (25621 – 1)/(256 – 1) ≈ 1,467 ∙ 1048.
126 Лекция 8

Алгоритмы для множеств из символьных строк тождественны соот-


ветствующим алгоритмам для множеств из чисел, так как строки можно
упорядочивать и сравнивать, как целые числа. Такое множество можно
упорядочить любым алгоритмом сортировки (прямой или косвенной).
Пример 8.1. Алгоритм сортировки вставками для массива из n строк.
Для такого алгоритма необходима вспомогательная строка z такой же мак-
симальной длины, как для элементов сортируемого массива D:
var z: string[20];
Вид алгоритма такой же, как для сортировки массива чисел:
for i:=1 to n-1 do begin
j:=i; z:=D[j+1];
while(j>0)and(D[j]>z) do begin
D[j+1]:=D[j]; j:=j-1;
end;
D[j+1]:=z;
end;
Конец примера.
Сравнение строк с перекодировкой символов. Иногда при сравне-
нии строк некоторые различные символы требуется считать равными,
например заглавные и строчные буквы. Для такого сравнения можно ис-
пользовать таблицу перекодировки, которая описывается как одномерный
целочисленный массив констант T с нумерацией от 0 до 255 (для симво-
лов, занимающих один байт). Значение элемента T[d] должно быть равно
новому коду для символа, имеющего код d. Получить числовой код сим-
вола, заданного в переменной типа char, можно функцией ord. Тогда
вместо непосредственного сравнения двух символьных переменных c1 и
c2 необходимо сравнивать T[ord(c1)] и T[ord(c2)].
Пример 8.2. Сравнение символьных строк с отождествлением заглав-
ных и строчных букв. Отождествляются буквы для латинского и русского
алфавитов. Кроме того, в русском алфавите считаются равными буквы ’е’
и ’ë’. Таблицу перекодировки для кода ASCII (CP866) можно задать опи-
санием массива констант в программе. Тип элементов в таблице описан (с
целью экономии памяти) как byte:
Символьные строки и таблицы 127

const T:array[0..255]of byte=


( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,
16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,
48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,
64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,
80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,
96,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,
80,81,82,83,84,85,86,87,88,89,90,123,124,125,126,
127,128,129,130,131,132,133,134,135,136,137,138,
139,140,141,142,143,144,145,146,147,148,149,150,
151,152,153,154,155,156,157,158,159,128,129,130,
131,132,133,134,135,136,137,138,139,140,141,142,
143,176,177,178,179,180,181,182,183,184,185,186,
187,188,189,190,191,192,193,194,195,196,197,198,
199,200,201,202,203,204,205,206,207,208,209,210,
211,212,213,214,215,216,217,218,219,220,221,222,
223,144,145,146,147,148,149,150,151,152,153,154,
155,156,157,158,159,133,133,242,243,244,245,246,
247,248,249,250,251,252,253,254,255);
В этом описании коды букв выделены наклонным шрифтом.
Функция scomp реализует сравнение двух строк с помощью таблицы
перекодировки T:
function scomp(var s1,s2:string):integer;
var i,r,n1,n2:integer;
begin n1:=length(s1); n2:=length(s2);
r:=0; i:=1;
while (i<=n1)and(i<=n2)and
(T[ord(s1[i])]=T[ord(s2[i])]) do i:=i+1;
if (i<=n1)and(i<=n2) then begin
if T[ord(s1[i])]<T[ord(s2[i])] then r:=-1
else r:=1
end
else if (i<=n1) then r:=1
else if (i<=n2) then r:=-1;
scomp:=r
end;
128 Лекция 8

Функция scomp выдает –1, если строка s1 меньше s2, выдает 0,


если строки равны, и выдает +1, если строка s1 больше s2.
Нетрудно видеть, что трудоёмкость программы линейная и определя-
ется количеством символов в более короткой строке (n1 и n2 – длины
строк):
O(min(n1, n2)).
Конец примера.
Пример 8.3. Сортировка массива строк с учётом перекодировки. Опи-
сание массива D и вспомогательной строки z:
var D: array[1..1000]of string[20];
z: string[20];
Алгоритм сортировки вставками с перекодировкой (n – количество ис-
пользуемых элементов массива D):
for i:=1 to n-1 do begin
j:=i; z:=D[j+1];
while (j>0)and(scomp(D[j],z)=1) do begin
D[j+1]:=D[j]; j:=j-1;
end;
D[j+1]:=z;
end;
Конец примера.

8.2. Контекстный поиск в символьных строках

Задача контекстного поиска состоит в том, что в строке (тексте) необ-


ходимо найти подстроку, совпадающую со строкой-образцом.
Рассмотрим простой алгоритм контекстного поиска. Пусть S – текст
длиной n символов, d – строка-образец. Вначале ищется символ текста
S[i], равный первому символу образца d[1]. При совпадении сравни-
ваются следующие символы: S[i+1] и d[2], S[i+2] и d[3] и т.д.
до конца образца. Если для всех пар обнаружится совпадение, то поиск
завершится успешно, если нет, то i увеличится на единицу, и сравнение
будет продолжаться. В общем случае возможны три ситуации:
1) нет ни одного полного совпадения с образцом;
Символьные строки и таблицы 129

2) есть только одно совпадение с образцом;


3) существует более одного совпадения с образцом, при этом возмож-
но даже их перекрытие, как, например, в тексте ’мамама’ для образца
’мама’.
Пример 8.4. Контекстный поиск в символьном массиве S длиной n
по образцу-строке d длиной m. Результат: p=1, если совпадение найде-
но, тогда i – номер символа S[i], совпадающего с первым символом
образца. Если полного совпадения нет, то p=0:
m:=length(d); p:=0; i:=1;
while (p=0)and(i<=n-m+1) do
if (d[1]<>S[i]) then i:=i+1
else begin
j:=1;
while(j<m)and(d[j+1]=S[i+j])do j:=j+1;
if j=m then p:=1 else i:=i+1
end;
Если очередной символ в массиве S совпал с первым символом в стро-
ке d, то во втором (внутреннем) цикле проверяется совпадение последу-
ющих символов строки d с символами S. Условие j=m окончания второго
цикла означает, что обнаружено полное совпадение строки d с подряд рас-
положенными символами массива S: S[i], S[i+1], . . ., S[i+m-1].
Программа имеет трудоёмкость в наихудшем O(n∙m). Действительно,
так как несовпадение может обнаружиться при сравнении последнего,
m-го символа образца, то для перехода от сравнения образца с i-м симво-
лом текста к сравнению с (i+1)-м символом текста потребуется от 1 до m
сравнений символов.
Конец примера.
Пример 8.5. Контекстный поиск в символьном массиве S длиной n
по образцу-строке d длиной m с перекодировкой и поиском всех совпа-
дений. Отличие от предыдущего алгоритма здесь в том, что после обнару-
жения полного совпадения строки d с подряд расположенными символами
массива S (с учётом перекодировки) выводится текущее значение i, и
внешний цикл продолжает выполняться.
130 Лекция 8

m:=length(d); p:=0; i:=1;


while i<=n-m+1 do begin
if T[ord(d[1])]=T[ord(S[i])] then begin
j:=1;
while (j<m)and
(T[ord(d[j+1])]=T[ord(S[i+j])]do j:=j+1;
if j=m then writeln(i);
end;
i:=i+1
end;
Трудоёмкость остается такой же: O(m·n).
Конец примера.
Если строка-образец для поиска содержит не любые символы, а только
некоторые, то поиск можно выполнить более эффективно, чем с трудоём-
кость O(m·n). Пусть, например, символы в строке-образце могут быть
только буквами. Будем считать такую строку словом. Тогда можно счи-
тать, что весь текст, в котором производится поиск, состоит из слов, разде-
ленных другими символами (не буквами). И тогда в алгоритме поиска для
каждого очередного символа необходимо определять, он относится к бук-
вам или к разделителям. Кроме того, в алгоритме будем использовать пе-
ременную p – признак распознавания слова:
p=0, если слово еще не началось (или были только разделители),
p=1, если слово началось (были буквы),
p=2, если слово закончилось и распознано.
Для проверки разделителей будем использовать специальную таблицу
Y (массив констант):
Y[ord(c)]=1, если символ c - буква,
Y[ord(c)]=0, если символ c - не буква (разделитель).
Описание таблицы для проверки букв и разделителей:
const Y:array[0..255]of byte =
(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,
0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
Символьные строки и таблицы 131

1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,
0,0,0);
Пример 8.6. Контекстный поиск слова d (первого совпадения) в сим-
вольном массиве S длиной n с учетом перекодировки символов:
m:=length(d); p:=0; j:=1;
while (p<2)and(j<=n) do
begin c:=S[j];
if p=0 then begin
if Y[ord(c)]=1 then {начало слова}
begin d1:=c; p:=1; i:=j end
end
else begin
if Y[ord(c)]=1 then d1:=d1+c
{слово продолжается}
else if scomp(d,d1)=0 then p:=2
{слово закончилось и совпало с образцом}
else p:=0
{слово закончилось но не совпало с образцом}
end;
j:=j+1
end;
if p=0 then i:=0
else if (p=1)and(scomp(d,d1)<>0) then i:=0;
В переменной d1 накапливается очередное слово из массива S. Когда
встречается символ не буква при p=1, очередное слово полностью сфор-
мировано в d1, и оно сравнивается с образцом d с помощью функции
сравнения с переколировкой scomp. При совпадении цикл завершается.
После завершения цикла последнее выделенное слово в d1 также сравни-
вается с образцом d.
132 Лекция 8

Результат работы алгоритма – номер символа i в массиве S, начиная с


которого найдено совпадение с образцом d. Если совпадения не найдено,
то i=0.
Трудоёмкость в наихудшем – O(n), так как цикл выполняется не более
n раз, а суммарная длина всех сформированных в строке d1 слов также не
превышает n.
Конец примера.
Пример 8.7. Распознавание группы слов в символьном массиве S
длиной n. Задан массив D из k строк символов, в каждой строке записано
слово. Требуется найти вхождение каждого слова в тексте S:
p:=0; j:=0;
while j<=n do begin
j:=j+1;
if j<=n then c:=S[j] else c:=' ';
if p=0 then begin
if Y[ord(c)]=1 then
begin d1:=c; p:=1; i:=j end
end
else begin
if Y[ord(c)]=1 then d1:=d1+c
else begin {слово выделено, поиск совпадения}
q:=1; p:=0;
while q<=k do {D - массив слов длиной k}
if scomp(d1,D[q])=0 then begin
writeln(D[q],’ ’,i); q:=k+1
end
else q:=q+1
end
end
end;
Здесь цикл работает n+1 раз, внутри цикла j пробегает значения от 1
до n+1, обрабатывая символ S[j]. При j=n+1 обрабатывается символ
пробела, это необходимо для того, чтобы после последнего слова в тексте
обязательно был разделитель. В переменной d1 накапливается очередное
слово из массива S. Когда встречается символ не буква при p=1, очередное
Символьные строки и таблицы 133

слово полностью сформировано в d1, и оно сравнивается в цикле со всеми


образцами из массива D с помощью функции сравнения с переколировкой
scomp. При обнаружении совпадения цикл сравнения принудительно пре-
рывается и выводится слово и номер i начала этого слова в массиве D.
Трудоёмкость: O(n∙k), так как каждое выделенное слово из текста мо-
жет сравниваться в худшем случае k раз, а суммарная длина всех слов в
тексте не превышает n.
Конец примера.
Пример 8.8. Распознавание группы слов в символьном массиве S дли-
ной n:
p:=0; j:=0;
while j<=n do begin
j:=j+1;
if j<=n then c:=S[j] else c:=’ ’;
if p=0 then begin
if Y[ord(c)]=1 then begin
d1:=c; p:=1; i:=j
end
end
else begin
if Y[ord(c)]=1 then d1:=d1+c
else begin p:=0; {слово выделено, поиск совпадения}
b:=1; e:=k;
while b<=e do begin
{D – упорядоченный массив слов длиной k}
q:=(b+e)div 2; r:=scomp(d1,D[q]);
if r<0 then b:=q+1
else if r>0 then e:=q-1
else begin
writeln(D[q],’ ’,i); b:=e+1
end
end
end
end
end;
134 Лекция 8

В отличие от алгоритма в примере 8.7, здесь предполагается, что мас-


сив D из k строк символов предварительно упорядочен с учетом перекоди-
ровки. Поэтому сравнение сформированного в строке d1 слова со словами
из массива D выполняется дихотомическим алгоритмом.
Трудоёмкость: O(n·log k), так как цикл сравнения сформированного
слова со словами из массива D выполняется не более log k раз.
Конец примера.
Пример 8.8. Формирование словаря D из символьного массива S дли-
ной n:
p:=0; j:=0; k:=0;
while j<=n do begin j:=j+1;
if j<=n then c:=S[j] else c:=' ';
if p=0 then begin
if Y[ord(c)]=1 then begin
d1:=c; p:=1; i:=j
end
end
else begin
if Y[ord(c)]=1 then d1:=d1+c
else begin {слово выделено, поиск совпадения}
q:=1; p:=0;
while q<=k do {D – массив слов длиной k}
if scomp(d1,D[q])=0 then q:=k+2
else q:=q+1;
if q=k+1 then begin
{добавление в массив D еще одного слова}
k:=k+1; D[k]:=d1
end
end
end
end;
Здесь из массива S последовательно выделяются все слова и записы-
ваются в массив строк D без повторений. В переменной k подсчитывается
количество выделенных слов.
Символьные строки и таблицы 135

Трудоёмкость: O(n∙k), так как каждое выделенное слово из текста при


поиске повторений может сравниваться в худшем случае k раз, а суммар-
ная длина всех слов в тексте не превышает n.
Конец примера.

8.3. Информационные таблицы

Объект, как элемент множества, можно задать в виде записи (струк-


туры) значений характеристик, которые могут быть различных типов
(числовыми, символьными строками и др.). Такие характеристики называ-
ют полями или ключами. Множество объектов при этом задается в виде
информационной таблицы, каждый столбец в которой определяет поле, а
каждая строка является записью. В программе информационную таблицу
можно представить различными способами:
1) в виде набора массивов, для каждого поля используется отдельный
массив;
2) в виде массива или списка записей, для каждого объекта – своя за-
пись;
Пусть, например, таблица T содержит три поля: A (целый тип), B
(строковый тип, длина до 20 символов), C (целый тип) и в ней должно по-
меститься 1000 записей. Описание при первом способе:
var A, C: array[1..1000]of integer;
B: array[1..1000]of string[20];
Тогда i-я запись в информационной таблице – это совокупность эле-
ментов массивов: {A[i], B[i], C[i]}.
Описание при втором способе:
var T: array[1..1000]of record
A:integer; B: string[20]; C:integer
end;
В этом случае i-я запись в таблице представляется совокупностью по-
лей i-го элемента массива T: (T[i].A, T[i].B, T[i].C)
Пример 8.9. Задача «спортивное двоеборье». Результаты соревнований
спортсменов по двум видам сведены в следующую таблицу:
136 Лекция 8

Фамилия Результат по 1-му виду Результат по 2-му виду

Требуется определить места, занятые спортсменами в двоеборье, по


сумме мест в отдельных видах. Победитель определяется по минималь-
ной сумме мест.
Пусть правила определения мест в двоеборье следующие:
1) если результаты по одному из видов у всех спортсменов различны и
эти результаты упорядочить по возрастанию, то место по этому виду у
спортсмена равно номеру этого спортсмена в упорядоченном списке,
а если результаты нескольких спортсменов одинаковы, то им следует при-
писать одинаковое среднее место по этому виду;
3) если у нескольких спортсменов сумма мест по обоим видам одина-
кова, то им следует приписать одинаковое среднее место по двоеборью.
Пусть входная таблица задана следующими массивами: F (фамилия,
тип string), R1 (результат по 1-му виду, тип real), R2 (результат по
2-му виду, тип real). Количество записей в таблице – n.
Зададим также четыре дополнительных столбца во входной таблице:
Место по Место по Сумма мест по Общее место по
1-му виду 2-му виду двум видам двум видам
Массив M1 – место по 1-му виду, тип real; массив M2 – место по
2-му виду. тип real; MS – сумма мест по двум видам, тип real; M0 –
общее место по двум видам, тип real. Кроме того, для косвенного упоря-
дочения столбцов понадобится индексный массив In (тип integer).
В программе используется процедура place, вычисляющая места (в
массиве M ) по одному результату (в массиве R):
procedure place(var R,M:array of real;n:integer);
var i,ib,j:integer;
begin
ssort(R,In,n); {косвенная сортировка}
ib:=1;
for i:=1 to n do
if (i=n)or(R[In[i]]<>R[In[i+1]]) then begin
for j:=ib to i do M[In[j]]:=(ib+i)/2;
ib:=i+1
end
end;
Символьные строки и таблицы 137

В процедуре place используется процедура косвенной сортировки


ssort, формирующая индексный массив In.
В целом программа расчета мест будет следующей:
place(R1,M1,n); {вычисление мест M1 по 1-му виду}
place(R2,M2,n); {вычисление мест M2 по 2-му виду}
for i:=1 to n do {вычисление суммы мест M0}
MS[i]:=M1[i]+M2[i];
place(MS,M0,n); {вычисление мест M0 по обоим видам}

Трудоёмкость всей программы определяется трудоёмкостью использу-


емого алгоритма сортировки и может оцениваться как O(n log n) .
Конец примера.
Особый интерес представляет случай, когда информация об объектах
универсума рассеяна по нескольким таблицам. Такие таблицы можно со-
единять. Соединение двух таблиц производится по следующим правилам:
1) в каждой из входных таблиц определяется одно или несколько по-
лей, которые однозначно определяют объекты универсума; такие поля,
одинаковые для обеих таблиц, называют ключевыми;
2) выходная таблица содержит поля из обеих входных таблиц;
3) в случае, когда некоторый объект присутствует в виде записи в обе-
их входных таблицах, в выходной таблице для него формируется запись,
содержащая значения из записей обеих входных таблиц;
4) в случае, когда некоторый объект присутствует в виде записи только
в одной из входных таблиц, то в выходной таблице соответствующая за-
пись не формируется, т.е. выходная таблица будет пересечением множеств
объектов из входных таблиц.
Пример 8.10. Соединение отдельных таблиц результатов по двум ви-
дам в общую таблицу. Результаты соревнований спортсменов представле-
ны в двух таблицах:

Фамилия Результат по 1-му Фамилия Результат по 2-му


F1 виду R01 F2 виду R02

Общая таблица после соединения результатов:


138 Лекция 8

Фамилия F Результат по 1-му виду R1 Результат по 2-му виду R2

Программа вычисления пересечения множеств строк из 1-й и 2-й таб-


лиц по фамилиям:
sort2(F1,R01,n1); {упорядочение 1-й входной таблицы}
sort2(F2,R02,n2); {упорядочение 2-й входной таблицы}
{Пересечение множеств строк из 1-й и 2-й таблиц по фамилиям:}
i1:=1; i2:=1; n:=0;
while (i1<=n1)and(i2<=n2) do
if F1[i1]<F2[i2] then i1:=i1+1
else if F1[i1]>F2[i2] then i2:=i2+1
else begin
n:=n+1;
F[n]:=F1[i1];R1[n]:=R01[i1];R2[n]:=R02[i1];
i2:=i2+1; i1:=i1+1
end;
Используемая в программе процедура sort2 упорядочивает сим-
вольные строки массива, заданного как первый параметр, с одновременной
перестановкой элементов числового массива – второго параметра. Третий
параметр задает размеры массивов.
Трудоёмкость всей программы определяется трудоёмкостью использу-
емого алгоритма сортировки и может оцениваться как O(n log n) .
Конец примера.
Пример 8.11. Вычисление средних оценок учащихся по таблице. Дана
таблица с оценками учащихся по некоторому предмету, причем у каждого
ученика может быть несколько текущих оценок:

Фамилия F Оценка R

Требуется вычислить среднюю оценку и количество текущих оценок


для каждого из учеников:

Фамилия FS Средняя оценка RS Количество оценок KS

Программа вычисления средних оценок:


Символьные строки и таблицы 139

sort2(F,R,n);
S:=0; k:=0; m:=0;
for i:=1 to n do
begin S:=S+R[i]; k:=k+1;
if (i=n)or(F[i]<>F[i+1]) then begin
m:=m+1;
FS[m]:=F[i]; KS[m]:=k; RS[m]:=S/k
S:=0; k:=0
end
end;
Процедура sort2 упорядочивает символьные строки массива, задан-
ного как первый параметр, с одновременной перестановкой элементов
числового массива – второго параметра. После этого оценки для каждого
учащегося будут располагаться подряд в массиве R.
Далее в цикле вычисляется сумма оценок и их количество для одного
учащегося. В операторе if условие будет истинным, когда обнаружена
группа записей с одинаковыми фамилиями.
Трудоёмкость всей программы определяется трудоёмкостью использу-
емого алгоритма сортировки и может оцениваться как O(n log n) .
Конец примера.

Вопросы и задания

1. Какова мощность множества из всевозможных строк длиной от 1 до 20 символов,


содержащих символы – только русские строчные буквы? Можно ли перенумеро-
вать все элементы этого множества целыми числами типа integer (длиной 32
бита) или int64 (длиной 64 бита), и почему?
2. Что означает лексикографический порядок символьных строк?
3. Символьные строки длиной от 1 до 3 символов содержат только цифры 0 и 1.
Сколько всего возможно таких строк? Выписать все эти строки в лексикографиче-
ском порядке?
4. Для чего используется таблица перекодировки?
5. Доказать корректность функции, выполняющей лексикографическое сравнение
двух символьных строк.
140 Лекция 8

6. Написать процедуру лексикографического упорядочения массива символьных


строк с использованием таблицы перекодировки, взяв за основу процедуру сорти-
ровки слиянием.
7. Написать и отладить программу, которая вводит две символьные строки (первая
длинная строка, вторая – короткая) и выполняет контекстный поиск всех свпадений
второй строки внутри первой с отождествлением заглавных и строчных букв. Со-
здать тесты для программы методом чёрного и белого ящика и провести тестирова-
ние.
8. Написать и отладить программу, которая вначале вводит символьную строку дли-
ноу до 20 символов, содержащую слово из русских букв. Затем программа вводит
посимвольно текст произвольной длины, содержащий слова из русских букв, разде-
лённых разделителями, в конце текста – символ ’#’, и которая в процессе ввода
ищет все совпадения (с отождествлением заглавных и строчных букв) введённой
строки со словами в тексте. Создать тесты для программы методом чёрного и бело-
го ящика и провести тестирование.
9. Написать и отладить программу, которая вводит посимвольно текст произвольной
длины, содержащий слова из русских букв, разделённых разделителями, в конце
текста – символ ’#’, и которая в процессе ввода выделяет все слова и формирует
словарь из этих слов как массив строк. В словаре все слова должны быть различ-
ными (с отождествлением заглавных и строчных букв). Предполагается, что длина
любого слова не более 20 символов, а количество различных слов в тексте не более
1000. Создать тесты для программы методом чёрного и белого ящика и провести
тестирование.
10. Написать и отладить программу, которая полностью решает задачу «спортивное
троеборье»: вводит три отдельных таблицы результатов спортсменов в отдельных
видах, после чего вычисляет и выводит места спортсменов по сумме мест, имею-
щих результаты по всем трём видам. Каждая таблица вводится следующим обра-
зом: 1) количество участвовавших спортсменов, 2) фамилия спортсмена, пробел,
числовой результат, пробел и т.д. Предполагается, что количество спортсменов не
более 100, длина фамилий – не более 20 символов. Создать тесты для программы
методом чёрного и белого ящика и провести тестирование.
11. Написать и отладить программу, которая вычисляет средние оценки учащихся по
двум предметам: вначале вводит общее количество оценок n, затем n строк, в каж-
дой строке – фамилия, пробел, номер предмета (1 или 2), числовая оценка. Предпо-
лагается, что количество учащихся не более 50, количество всех оценок не более
1000. Вывести вначале результаты по предмету 1, затем по предмету 2. Создать те-
сты для программы методом чёрного и белого ящика и провести тестирование.
Лекция 9.
Язык Си. Базовые понятия

9.1. Программа, типы данных, операции и операторы

Программа на языке Си (или С++). Представляет собой один или


несколько текстовых файлов с расширением "с" или "срр". При трансляции
каждый файл вначале обрабатывается препроцессором, который, исполняя
директивы препроцессора, делает некоторые преобразования программы, в
частности, вставки текста из указанных в директивах файлов. Затем произ-
водится собственно трансляция в машинные команды, результатом являет-
ся объектный модуль – файл с расширением "obj". Объектный модуль ещё
не является исполняемым модулем: в нём, как правило, имеются ссылки на
другие объектные модули, в частности на стандартные функции из биб-
лиотеки программ. На заключительном этапе работает редактор связей,
объединяющий все связанные ссылками объектные модули в единый ис-
полняемый модуль с расширением "exe".
Каждый текстовый файл программы может содержать следующие ча-
сти:
- директивы препроцессора (вставка заголовочных файлов и др.),
например: #include <stdio.h>
- описания глобальных типов данных (typedef... struct ... и др.);
- описания глобальных данных (переменных, констант);
- описания заголовков функций;
- описания функций.
При этом в одном из файлов должно быть описание главной функции с
именем main (или _tmain), именно с этой функции начинается выпол-
нение исполняемого модуля программы.
Описания данных. Могут быть глобальными или локальными, во
втором случае описания данных располагаются внутри описания функции
и они действуют только внутри этой функции. Данные могут быть кон-
стантами или переменными. Константы могут задаваться явно (например,
числовым значением) или описываться, как имя с описателем const. При
описании указывается тип переменных или констант. Основные описатели
142 Лекция 9

базовых типов – char (символ), int (целый), unsigned (беззнаковый),


float (плавающий), double (плавающий двойной длины), long (длин-
ный), short (короткий) – могут комбинироваться. В общем случае опи-
сание константы:
const <описатели типа> <имя> = <значение>;
Описание переменных:
<описатели типа> <имя-1>, <имя-2>, . . .<имя-n>;
Если переменная является массивом, то после имени в квадратных
скобках записывается число элементов. Если переменная является указате-
лем, то перед именем записывается звёздочка.
Например, описание:
const long int L[]={1,2,3,456789};
задаёт константу – массив из 4-х чисел, занимающих по 8 байт с указан-
ным в описании значением. Описание:
unsigned short int a, b, c;
задаёт три целочисленных переменных, занимающих в памяти по 2 байта,
которые могут иметь значение в диапазоне от 0 до 216 – 1. Описание:
float A[100], B[10][20];
задаёт два массива плавающих (вещественных) переменных, занимающих
в памяти по 4 байта. В массиве A элементы нумеруются от 0 до 99, в мас-
сиве B – от 0 до 9 по первому измерению, и от 0 до 19 по второму измере-
нию. Количество элементов в массивах определено в описании и не может
изменяться в процессе выполнения программы. Описание:
int *p1, *p2;
задаёт два указателя (ссылки) на какие-либо целочисленные объекты типа
int.
В некоторых случаях применяется описатель void, задающий неопре-
делённый тип данных.

Выражения и операции. Выражение состоит из переменных и кон-


стант, к которым применены операции. Каждая операция имеет некоторый
Язык Си. Базовые понятия 143

приоритет, определяющий порядок исполнения, от 1-го (высшего) до 15-го


(низшего). Операции одинакового приоритета исполняются либо слева-
направо, либо справа-налево, как указано в таблице 9.1. Операции подраз-
деляются также на унарные (с одним операндом), бинарные (с двумя) и
тернарные (с тремя операндами).
Таблица 9.1
Приори- Порядок
Операции
тет исполнения
1 () [] -> . →
2 ! ~ + - ++ -- & * (<тип>) sizeof ←
3 * / % →
4 + - →
5 << >> →
6 < <= >= > →
7 == != →
8 & →
9 ^ →
10 | →
11 && →
12 || →
13 ? : ←
14 = *= /= %= += -= &= ^= |= <<= >>= ←
15 , →
Операция круглые скобки () используется в двух вариантах: унар-
ном и бинарном. В унарном варианте операнд, являющийся выражением,
записывается между скобками, результат операции равен значению опе-
ранда. В бинарном варианте (вызове функции) 1-й операнд – имя функции
– записывается перед открывающей скобкой, а 2-й операнд – список ар-
гументов, который может отсутствовать, – между скобками. Здесь ре-
зультат операции равен возвращаемому функцией значению, которое, в
частности, может быть пустым (void).
Операция квадратные скобки [] – бинарная, в ней 1-й операнд –
имя массива, 2-й – индексное выражение (целочисленное), результат –
элемент массива.
Операции выбора поля структуры – косвенный выбор -> и прямой
выбор (точка). Обе операции бинарные, записываются в виде:
144 Лекция 9

1) <имя указателя на структуру> -> <имя поля структуры>


2) <имя структуры> . <имя поля структуры>
В обеих операциях результат – выбранное поле в структуре.
Унарные операции с приоритетом 2 (! ~ + - & * (<тип>)
sizeof) являются префиксными, т.е. записываются перед операндом.
Операция ! – логическое отрицание. Если операнд равен 0, т.е. ложь,
то результат равен 1, т.е. истина. Если операнд не равен 0, то результат
равен 0.
Операция ~ – поразрядное инвертирование двоичного представления
целочисленного операнда, рассматриваемого как битовый вектор.
Операции + и - – унарные плюс и минус соответственно. Резуль-
тат – значение операнда (+) или значение операнда с противоположным
знаком (-).
Операция & – получение адреса операнда, который должен быть пе-
ременной.
Операция * – доступ к объекту, на который указывает операнд- ука-
затель.
Операция преобразования к указанному типу (<тип>). Операнд
может быть любым целочисленным или вещественным типом, тип резуль-
тата определяется операцией. При выполнении операции возможна потеря
точности (например, при преобразовании от типа double к типу float)
или потеря значения (например, при преобразовании значения 1000 к
типу char, из-за нехватки разрядности). Операнду типа void можно
приписать любой тип, хотя никакого преобразования при этом не произво-
дится. Если операция записана в виде (void), то операнд как бы «теря-
ет» свой тип без какого-либо преобразования.
Операция sizeof – вычисление размера (в байтах) операнда. Воз-
можны два формата операции: sizeof <выражение> или sizeof
(<тип>). Операция не вычисляет значение выражения, она лишь опреде-
ляет его тип.
Унарные операции с приоритетом 2 (++ --) могут применяться в
префиксном или в постфиксном варианте. В первом варианте они запи-
сываются перед, а во втором – после операнда. Операнд должен быть пе-
ременной числового типа. Результат операции – та же самая переменная,
значение которой либо увеличено на 1 (операция ++), либо уменьшено на
Язык Си. Базовые понятия 145

1 (операция --). В префиксном варианте переменная-операнд вначале


изменяется, а затем используется, в постфиксном – вначале используется,
а затем изменяется.
Тернарная операция с приоритетом 13 (? :) называется условной
операцией. Она записывается двумя отдельными символами с тремя опе-
рандами в виде:
<выражение-1> ? <выражение-2> : <выражение-3>
Эта операция выполняется следующим образом. Вначале вычисляется
<выражение-1>. Если его значение не равно 0, то вычисляется <выраже-
ние-2>, в противном случае вычисляется <выражение-3>. Результатом
операции является значение 2-го или 3-го выражения.
Остальные операции из таблицы 9.1 являются бинарными, они записы-
ваются между двумя операндами.
Мультипликативные операции с приоритетом 3 (* / %). Операции
умножения (*) и деления (/) могут иметь любые числовые операнды. Тип
результата определяется типом операнда с наибольшей разрядностью. Ес-
ли хотя бы один из операндов вещественный, результат также будет веще-
ственным. Если же оба операнда целочисленные, то результат также будет
целочисленным, причем в операции деления остаток от деления отбрасы-
вается. Операция остатка от деления (%) требует целочисленных операн-
дов. При целочисленных a, b и b ≠ 0 справедливо тождество
(a/b)*b + (a%b) = a.
Аддитивные операции с приоритетом 4 – сложение (+) и вычитание
(-) – могут иметь любые числовые операнды и даже указатели. Тип ре-
зультата определяется типом операнда с наибольшей разрядностью. Если
хотя бы один из операндов вещественный, результат также будет веще-
ственным. Если же оба операнда целочисленные, то результат также будет
целочисленным. Если первый операнд указатель, а второй – целочислен-
ный, то результат будет указателем.
Операции сдвига с приоритетом 5 (<< >>) требуют целочисленных
операндов. Первый операнд, рассматриваемый как битовый вектор, сдви-
гается влево (<<) или вправо (>>) на число бит, равных второму операнду.
При сдвиге влево освободившиеся позиции заполняются нулями, при
сдвиге вправо – также нулями, если первый операнд имеет тип с описате-
лем unsigned или старшим битом в противном случае.
146 Лекция 9

Операции сравнения с приоритетом 6 (меньше <), (больше >),


(меньше или равно <=), (больше или равно >=) и с приоритетом 7 (равно
==), (не равно !=) могут иметь числовые операнды, а последние две опе-
рации, кроме того, – операнды-указатели. Результат сравнения – целое
число 0 (ложь) или 1 (истина).
Поразрядные операции над целочисленными операндами (одинако-
вой длины), рассматриваемыми как битовые векторы – конъюнкция «И»
(&) с приоритетом 8, исключающее «ИЛИ» (^) с приоритетом 9, дизъюнк-
ция «ИЛИ» (|) с приоритетом 10. Результат – битовый вектор.
Логические операции над целочисленными операндами – конъюнк-
ция «И» (&&) с приоритетом 11, дизъюнкция «ИЛИ» (||) с приоритетом
12. Значение операнда, равное нулю, трактуется как ложь, а не равное ну-
лю – как истина. Результат – целое число 0 (ложь) или 1 (истина).
Операции присваивания с приоритетом 14 (= *= /= %= += -=
&= ^= |= <<= >>=) в качестве первого операнда должны иметь пере-
менную, а второго операнда – значение.
При выполнении простого присваивания (=) значение переменной (ле-
вого операнда) приобретает значение правого операнда. Типы операндов
должны совпадать или допускать преобразование от типа второго операн-
да к типу первого. В частности, целочисленный тип может быть преобра-
зован к вещественному, может измениться длина битового представления
целочисленного типа и др.
Остальные операции присваивания совмещают обычную бинарную
операцию (<опер>) с присваиванием. Запись a <опер>= b эквивалентна
a = a <опер> b.
Операция запятая (,) разделяет последовательно (слева направо) вы-
числяющиеся выражения. Результатом операции становится значение по-
следнего вычисленного выражения.
Операторы в языке Си. Выражение, заканчивающее знаком "точка с
запятой" считается оператором. Последовательность операторов, взятая в
фигурные скобки, также считается оператором.
Оператор if может иметь полную форму:
if (<выражение>)<оператор-1> else <оператор-2>
или сокращённую форму:
if (<выражение>)<оператор-1>
Язык Си. Базовые понятия 147

Вначале вычисляется <выражение>. Если его значение не равно 0, то


выполняется <оператор-1>, в противном случае выполняется <оператор-2>
(если он есть).
Оператор switch реализует ветвящийся алгоритм:
switch (<выражение>)
{ case <константа-1>: <список операторов-1> break;
case <константа-2>: <список операторов-2> break;
...
case <константа-n>: <список операторов-n> break;
default: <список операторов-n+1>
}
Вначале вычисляется <выражение>. Если его значение совпало с
<константой-i>, то выполняется <список операторов-i>, а после него –
оператор break, который приводит к завершению всей конструкции
switch. Если же вычисленное значение не совпало ни с одной из <кон-
стант-i>, то выполняется <список операторов-n+1>.
Допускается несколько подряд идущих меток case с различными
константами. Допускается также отсутствие метки default, тогда при
несовпадении вообще ничего не исполняется. Кроме того, некоторые опе-
раторы break могут отсутствовать, тогда после выполнения предше-
ствующего списка операторов не будет производиться завершение всей
конструкции switch, а будут выполняться последующие операторы.
Оператор while:
while (<выражение>)<оператор>
реализует циклический алгоритм. При его исполнении вычисляется <вы-
ражение>. Если оно равно 0, то выполнение цикла заканчивается, если нет,
то выполняется <оператор>, снова вычисляется <выражение> и т.д.
Оператор for:
for(<выражение-1>;<выражение-2>;<выражение-3>)<оператор>
также реализует циклический алгоритм. При его исполнении вначале вы-
числяется <выражение-1>. Затем вычисляется <выражение-2>. Если оно
равно 0, то выполнение цикла заканчивается, если нет, то выполняется
<оператор>, после него – <выражение-3>, затем снова вычисляется <вы-
ражение-2> и т.д.
148 Лекция 9

Оператор do:
do {<список операторов>} while (<выражение>);
реализует циклический алгоритм с проверкой условия в конце. При его ис-
полнении вначале выполняется <список операторов>, после этого вычисля-
ется <выражение>. Если оно равно 0, то выполнение цикла заканчивается,
если нет, то снова выполняется <список операторов>, вычисляется <выра-
жение> и т.д.
Оператор return:
return <выражение>;
является последним выполняемым оператором внутри описания функции.
После него программа возобновляет выполнение после вызова этой функ-
ции. При этом вычисленное <выражение> – результат функции. <Выраже-
ние> в записи оператора может отсутствовать, если тип значения, выраба-
тываемого функцией, описан как void.
Пример 9.1. Программа ввода чисел, их упорядочения и вывода:
#include ”stdafx.h” /* или <stdio.h> 1*/
void main(void) /*2*/
{int i, n, z, X[1000]; /*3*/
scanf(”%d”,&n); /*4*/
for(i=0;i<n;i++) scanf(”%d”,&X[i]); /*5*/
i=0; /*6*/
while(i<n-1) /*7*/
if(X[i]<=X[i+1]) i++; /*8*/
else /*9*/
{z=X[i];X[i]=X[i+1];X[i+1]=z; /*10*/
if(i>0)i--; /*11*/
} /*12*/
for(i=0;i<n;i++) printf(”%8d”,X[i]); /*13*/
printf(”\n”); /*14*/
} /*15*/
Номера строк в программе записаны в виде комментариев. 1-я строка –
директива препроцессора, задающая вставку текста из файла (его имя взя-
то в двойные кавычки или в угловые скобки). Этот файл содержит описа-
ния стандартных функций ввода/вывода. 2-я строчка – заголовок главной
Язык Си. Базовые понятия 149

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


граммы. В 3-й строчке описаны целочисленные переменные i,n,z и мас-
сив X из 1000 элементов.
4-я строчка – вызов стандартной функции ввода данных с клавиатуры.
1-й параметр – символьная строка, задающая формат ввода для 2-го пара-
метра. Символьные строки – константы в Си берутся в двойные кавычки.
Символ % обозначает, что следующий за ним символ задаёт конкретный
формат, символ d – целочисленный формат. 2-й параметр задаёт ссылку
(операцией &) на переменную n, так как результатом ввода будет присвое-
ние переменной n введённого значения.
5-я строчка – цикл по первым n элементам массива X, на каждом шаге
цикла – ввод значения для X[i].
В строчках с 6-й по 12-ю – простейший улучшенный алгоритм сорти-
ровки для n элементов массива X.
13-я строчка – цикл по первым n элементам массива X, на каждом шаге
цикла – вывод стандартной функцией значения X[i], формат вывода – 8
символов для каждого числа, все числа выводятся в одну строчку в окне
вывода. 14-я строчка – вывод формата, задающего переход к новой строчке
в окне вывода.
Конец примера.

9.2. Функции

Описание функции состоит из заголовка, после которого записан ал-


горитм – тело функции – которое состоит из последовательности описаний
и операторов, взятых в фигурные скобки:
<тип> <имя> (<тип> <имя параметра> . . .)
{<описания> <операторы> . . .}
В заголовке вначале указывается тип возвращаемого функцией значе-
ния (он может быть void), затем имя функции, далее – взятые в круглые
скобки описания формальных параметров функции, перечисленные через
запятую. Например, описание:
float sq2(float x, float y)
{float z; z=sqrt(x*x+y*y); return z;}
150 Лекция 9

задаёт функцию sq2 с двумя формальными параметрами типа float,


подставляемыми по значению, которая вычисляет результат типа float.
При вычислении вызывается стандартная функция вычисления квадратно-
го корня sqrt. Пример вызова этой функции из другой функции или глав-
ной программы:
float t; t=sq2(4,5.5);
Описание заголовка функции. Оно состоит только из заголовка и
точки с запятой в конце:
<тип> <имя> (<тип> <имя параметра> . . .);
Такое описание необходимо в том файле текста программы, где запи-
сан вызов этой функции, если полное описание функции имеется в другом,
объектном файле, с расширением obj. Например, описание:
float sq2(float x, float y);
задаёт заголовок функции sq2. Заголовок должен полностью соответство-
вать полному описанию функции.
Пример 9.2. Программа с функцией, выполняющей обмен значениями
двух переменных:
#include ”stdafx.h”
void mov(int *a, int *b)
{int z=*a; *a=*b; *b=z;}
void main(void)
{int x,y;
scanf("%d%d", &x, &y);
mov(&x,&y);
printf("x=%d y=%d\n",x,y);
}
Функция mov не возвращает никакого значения, т.е. используется, как
процедура. Два её формальных параметра (a и b) описаны как ссылки
(указатели) на переменные типа int. Внутри функции описана вспомога-
тельная переменная z типа int, её описание совмещено с присваиванием.
Так как присваивания относятся не к ссылкам, а к значениям, на которые
Язык Си. Базовые понятия 151

ссылаются параметры, то они используются совместно с унарной операци-


ей *(переход от ссылки к объекту).
В главной программе функцией scanf выполняется ввод числовых
значений для переменных x и y, после чего вызывается функция mov, в
которой эти переменные, как фактические параметры, записаны с унарной
операцией & (переход от объекта к ссылке на него).
В конце выполняется вывод изменённых значений переменных x и y.
Первый параметр в функции printf (строка форматов для вывода) опре-
деляет следующий порядок вывода: 1) текст x=, 2) значение x, 3) два про-
бела, 4) текст y=, 5) значение y, 6) переход на новую строчку вывода.
Конец примера.
Пример 9.3. Программа с функцией, вычисляющей минимальное зна-
чение в массиве:
#include ”stdafx.h”
void pmin(int *X, int n, int *r)
{int i; /*i – локальная переменная*/
*r=X[0];
for (i=1; i<n; i++) if (*r>X[i]) *r=X[i];
} /*конец описания процедуры*/
void main()
{int *A,n,i,min;
scanf ("%d",&n); A= new int[n];
for (i=0; i<n; i++) scanf("%d",&A[i]);
pmin(A,n,&min); /*вызов функции, как процедуры*/
printf(”min=%d\n",min);
delete []A;
}
Функция pmin не возвращает никакого значения, т.е. используется,
как процедура. Первый формальный параметр X описан как ссылка на мас-
сив элементов типа int, второй параметр n должен задавать количество
элементов в массиве X и описан как значение типа int, третий r – как
ссылка на значение типа int, которое используется в качестве результата
вычислений при вызове функции.
В главной программе переменная A описана как ссылка на значение
типа int, она далее используется как имя массива. После ввода значения
152 Лекция 9

для переменной n опреатором new выделяется область памяти для n эле-


ментов типа int, и ссылке A присваивается адрес расположения этой об-
ласти. Затем в цикле вводятся значения для элементов массива A и вызыва-
ется функция pmin, результат её вычислений присватвается переменной
min. В конце выводится текст min= и результат вычислений, а также
освобождается область памяти для массива A.
Конец примера.
Пример 9.4. Программа с функцией, возвращающей минимальное зна-
чение в массиве:
#include ”stdafx.h”
int pmin(int *X, int n)
{int i,r; /*i,r – локальная переменная*/
r=X[0];
for (i=1; i<n; i++) if (r>X[i]) r=X[i];
return r;
} /*конец описания функции*/
void main()
{int *A,n,i,min;
scanf ("%d",&n); A= new int[n];
for (i=0; i<n; i++) scanf("%d",&A[i]);
min=pmin(A,n); /*вызов функции*/
printf(”min=%d\n",min);
delete [] A;
}
В отличии от программы в примере 9.3, здесь функция pmin непо-
средственно возвращает результат вычислений, поэтому её вызов записы-
вается в виде правой части оператора присваивания.
Конец примера.
Пример 9.5. Функция (процедура) сортировки слиянием в массиве.
Параметры b и e задают соответственно номер первого и номер последне-
го элемента в массиве, которые будут упорядочиваться. Параметры A и B
задают ссылки на массивы одинакового размера. В массиве A элементы
упорядочиваются, массив B – вспомогательный. Переменные c, i1, i2,
j – локальные, их описание совмещено с присваиванием.
Язык Си. Базовые понятия 153

void sort (int b, int e, int *A, int *B)


{if(b<e)
{int c=(b+e)/2;
sort(b,c,A,B); sort(c+1,e,A,B);
int i1=b, i2=c+1, j=b;
while (i1<=c && i2<=e)
if(A[i1]<=A[i2]) {B[j]=A[i1]; i1++; j++;}
else {B[j]=A[i2]; i2++; j++;}
while (i1<=c) {B[j]=A[i1]; i1++; j++;}
while (i2<=e) {B[j]=A[i2]; i2++; j++;}
for (j=b; j<=e; j++) A[j]=B[j];
}
}
Алгоритм функции здесь практически такой же, как в примере 4.10,
отличие состоит в том, что здесь слияние двух частей из массива A в мас-
сив B реализовано непосредственно, а не через вызов отдельной функции.
Конец примера.
Пример 9.6. Программа вводит числа и упорядочивает их вызовом
функции сортировки слиянием:
void main(void)
{ int n, *X, *Y, i;
scanf ("%d",&n);
X= new int[n]; Y= new int[n];
for (i=0; i<n; i++) scanf ("%d",&X[i]);
sort(0,n-1,X,Y);
for (i=0; i<n; i++) printf("%d ",X[i]);
printf("\n");
delete [] X; delete [] Y;
}
Эту программу необходимо транслировать вместе с описанием функ-
ции sort, добавив директиву препроцессора #include. В программе
переменные X и Y описаны как ссылки на значения типа int, они исполь-
зуются как имена массивов. После ввода значения для переменной n опе-
ратором new выделяются две области памяти по n элементов типа int для
массивов X и Y. Затем в цикле вводятся значения для элементов массива X
154 Лекция 9

и вызывается функция sort, после чего в цикле выводятся значения эле-


ментов упорядоченного массива X, разделённые пробелами. В конце осво-
бождаются области памяти для массивов.
Конец примера.
Пример 9.7. Функция (процедура) генерации перестановок ферзей:
int n,P[21],H[21],R[41],L[41];/*глобальные описания */
void queen(int k) /*функция генерации перестановок */
{int i,j;
for(i=1;i<=n;i++)
if(H[i]==0 && R[i-k+21]==0 && L[i+k]==0)
{P[k]=i; H[i]=1; R[i-k+21]=1; L[i+k]=1;
if(k==n) /*вывод сгенерированной перестановки*/
{for(j=1;j<=n;j++)printf("%2d ",P[j]);
printf("\n");
}
else queen(k+1);
H[i]=0; R[i-k+21]=0; L[i+k]=0;
}
}
Алгоритм полностью аналогичен процедуре из примера 6.8, отличается
от неё лишь тем, что массив R, как и все другие массивы, имеет нумерацию
элементов, начиная с нуля, поэтому при вычислении индекса производится
добавление числа 21. Размеры массивов заданы такими, чтобы генерация
была корректной вплоть до n, равного 20.
Конец примера.
Пример 9.8. Программа вводит размер доски n для задачи о ферзях и
вызывает функцию queen:
void main(void)
{int i;
scanf("%d",&n);
for(i=1;i<=n;i++) H[i]=0;
for(i=2;i<=n+n;i++)
{R[i]=0; L[i]=0;}
queen (1);
}
Язык Си. Базовые понятия 155

Перед вызовом функции queen в программе обнуляются те элементы


в массивах H, R и L, которые используются в функции при заданном значе-
нии n.
Конец примера.
Пример 9.8. Вычисление интеграла непрерывной функции методом
трапеций. Для приближённого вычисления интеграла функции f(x) на ин-
тервале [a, b] площадь под кривой y = f(x) заменяется суммой площадей
трапеций, получившихся при разбиении интервала на n равных частей, как
показано на рис. 9.1.

X
a b
Рис. 9.1.

Формула для вычисления интеграла:


b
1 1 
 f ( x)dx   2 f (a)  f (a  d )  f (a  2d )  ...  f (b  d ) 
2
f (b)   d ,

a
где d = (b – a) / n.
Функция Integr вычисляет интеграл для подинтегральной функции f
на интервале от a до b при разбиении интервала на n частей:
float Integr(float(*f)(float),float a,float b,int n)
{int i; float d,s;
s=(f(a)+f(b))/2; d=(b-a)/n;
for(i=1;i<n;i++) s=s+f(a+i*d);
return s*d;
}
Параметр f описан как ссылка на функцию, возвращающую значение
типа float и имеющую один параметр типа float. Примеры описаний
подинтегральных функций:
156 Лекция 9

float f1(float x) {return exp(sqrt(x));}


float f2(float x) {return sqrt(exp(x));}
При трансляции этих функций необходима директива препроцессора,
подключающая библиотеку стандартных математических функций:
#include <math.h>
Примеры вызовов функции Integr с подинтегральными функциями
f1 и f2:
float y1=Integr(f1,0,1.5,10);
float y2=Integr(f2,-1.2,1.2,30);
Конец примера.
Пример 9.9. Использование массива ссылок на функции на примере
вычисления интеграла из примера 9.8:
. . .
void main(void)
{float y, a, b; int i,n;
const float (*F[])(float)={f1,f2};
/* описание константного массива функций */
scanf("%d%f%f%d",&i,&a,&b,&n);
/*ввод входных данных */
y=Integr(F[i-1],a,b,n); /*вычисление интеграла*/
printf("%10.5f",y); /*вывод результата */
}
В программе описан массив констант F, элементы которого имеют тип
ссылок на функции, возвращающие значение типа float и имеющие один
параметр типа float. В этом описании указаны ссылки на две функции с
именами f1 и f2., обращение к первой функции: F[0], а ко второй функ-
ции: F[1].
Вначале в программе выполняется ввод переменных, задающих номер
подинтегральной функции (1 или 2), начало и конец интервала, а также
количество частей разбиения интервала. После этого вызывается функция
вычисления интеграла и выводится результат.
Конец примера.
Язык Си. Базовые понятия 157

Вопросы и задания

1. Имеется описание: int *x,y; Какие действия правильные, и что они значат в
операторах: x=y; y=5; *x=10; x=*y; *y=20;
2. В каком порядке будут выполняться операции и что они значат в операторе:
x+=(b*c+d)*2<i&(y=a==b);
3. Для чего нужно описание заголовка функции без самого алгоритма функции?
4. Написать на Си и отладить программу, которая вводит три числа, проверяет, можно
ли построить треугольник со сторонами такой длинв, и если можно, то вычисляет
его площадь. Создать тесты для программы методом чёрного и белого ящика и про-
вести тестирование.
5. Написать на Си описание функции с двумя параметрами: имя массива веществен-
ных элементов и его длина, которая возвращает вещественное значение, и которая
вычисляет сумму элементов массива. Написать пример вызова этой функции.
6. Написать на Си описание функции с тремя параметрами: имя вещественного мас-
сива, его длина, ссылка на вещественное значение (результат вычислений), и кото-
рая вычисляет сумму элементов массива. Написать пример вызова этой функции.
7. Написать на Си и отладить программу, которая содержит описание функции, реали-
зующей сортировку вещественного массива методом вставки. Программа должна
вводить размер массива, выделять ему память, вводить элементы массива, вызывать
функцию, выводить массив после сортировки, проверять, что массив стал упорядо-
ченным. Создать тесты для программы методом чёрного и белого ящика и провести
тестирование.
8. Написать на Си описание функции, генерирующей все сочетания чисел из n по m.
Написать пример вызова этой функции.
9. Написать на Си описание функции Sum, возвращающей вещественное, имеющей
два параметра: 1) ссылка на функцию f с одним целочисленным параметром, воз-
вращающей вещественное; 2) количество суммируемых элементов n. Функция Sum
должна вычислять сумму: Sum=f(1)+f(2)+. . .+f(n). Написать примеры
вызова функции Sum для функций f: 1/n, 1/n2, n, n2.
Лекция 10.
Язык Си. Продолжение

10.1. Списки, файлы, стандартные функции

Списки. Чтобы работать со списочными структурами, вначале необ-


ходимо описать тип данных для структуры, используемой как элемент
списка, например:
struct el{int s; struct el *p;};
В этом описании поле s – содержимое, поле p – указатель на следую-
щий элемент списка. В дальнейших примерах со списками будет использо-
ваться именно это описание типа.
Пример описания 3-х переменных-указателей:
struct el *p1,*p2,*p3;
Выделение памяти для 3-х элементов списка:
p1=new struct el;
p2=new struct el;
p3=new struct el;
Сцепление этих элементов в список:
p1->p=p2; p2->p=p3; p3->p=NULL;
Операция -> выполняет переход от указателя к полю структуры (эле-
мента списка), на которую ссылается указатель.
Пример 10.1. Формирование, сортировка, вывод и удаление списка:
void main(void)
{int i,n,v; struct el *p1, *p2, *p3;
scanf("%d",&n); /*ввода числа элементов n*/
p1=new struct el; p2=p1;/*создан 1-й элемент списка*/
for(i=0;i<n;i++) /*в цикле формирование списка*/
{scanf("%d",&v); p2->s=v;
if(i<n-1) /*если не последний элемент списка*/
Язык Си. Продолжение 159

{p3=new struct el; p2->p=p3; p2=p3;}


}
p2->p=NULL; /*обнуление последней ссылки в списке*/
sortlist(&p1,n); /*вызов функции сортировки списка*/
p2=p1;
while(p2!=NULL) /*вывод в цикле элементов списка*/
{printf("%d ",p2->s); p2=p2->p;}
printf("\n");
while(p1!=NULL) /*удаление элементов списка*/
{p2=p1; p1=p1->p; delete p2;}
}
Сортировка в программе выполняется вызовом функции sortlist.
Конец примера.
Пример 10.2. Слияние упорядоченных списков:
void slist(struct el *p11, struct el *p22,
struct el **p3)
{struct el *p4,*p1,*p2;
p1=p11; p2=p22;
if (p1->s<=p2->s)
{*p3=p1; p4=p1; p1=p1->p;}
else {*p3=p2; p4=p2; p2=p2->p;}
while ((p1!=NULL)&&(p2!=NULL))
if (p1->s<=p2->s)
{p4->p=p1; p4=p1; p1=p1->p;}
else {p4->p=p2; p4=p2; p2=p2->p;}
if (p1!=NULL) p4->p=p1;
else p4->p=p2;
}
Функция slist аналогична процедуре из примера 5.13. Её трудоём-
кость O(n).
Конец примера.
Пример 10.3. Упорядочение списка методом слияния, в функции
sortlist используется функция slist:
160 Лекция 10

void sortlist(struct el **p, int n)


{struct el *p1, *p2; int k,i;
if (n>1)
{k=n/2; p1=*p;
for (i=1;i<=k-1;i++) p1=p1->p;
p2=p1->p; p1->p=NULL; p1=*p;
sortlist(&p1,k);
sortlist(&p2,n-k);
slist(p1,p2,p);
}
}
Функция sortlist аналогична процедуре из примера 5.14. Её трудо-
ёмкость O(n log n).
Конец примера.
Файлы. Работу с файлами рассмотрим на двух примерах. В примере
10.4 используются текстовые файлы, в которых данные записаны в виде
текста, а в примере 10.5 используются двоичные файлы, в которых данные
могут быть в любого вида, и рассматриваются как последовательность
байтов.
Пример 10.4. Чтение чисел из 1-го текстового файла и вывод во 2-й
текстовый файл количества чисел и их среднего значения:
void main(void)
{FILE *F1,*F2; int n,k,s;
char *S1="in.txt", *S2="out.txt";
if((F1=fopen(S1,"r"))==NULL||
(F2=fopen(S2,"w"))==NULL) puts("Ошибка!\n");
else
{n=0; s=0;
while(feof(F1)==0)
{fscanf(F1,"%d",&k); s+=k; n++;}
fprintf(F2,"%d %8.3f\n",n,float(s)/n);
fclose(F1); fclose(F2);
}
}
Язык Си. Продолжение 161

Описатель FILE определяет два указателя F1 и F2 (файловые пере-


менные). Переменные S1 и S2 – указатели, им присвоены ссылки на сим-
вольные строки, они задают имена двух файлов.
1-й вызов функции fopen связывает имя файла S1 (1-й параметр) с
файловой переменной F1, строка "r" (2-й параметр) открывает этот файл
для чтения, как текстовый файл. 2-й вызов функции fopen связывает имя
файла S2 (1-й параметр) с файловой переменной F2, строка "w" (2-й па-
раметр) открывает этот файл для записи как текстовый файл. Если хотя бы
одна из переменных F1 или F2 получила значение NULL, (это означает,
что файл не удалось открыть), то вызовом функции puts выводится со-
общение об ошибке.
Если оба файла открыть удалось, то в цикле функцией fscanf после-
довательно считываются записанные в файле F1 целые числа, вычисляется
их сумма и количество. Вызов функции feof проверяет, что конец файла
не достигнут, и тогда цикл продолжается.
После обнаружения конца файла F1 цикл заканчивается, и после него в
файл F2 выводится количество прочитанных чисел, затем 3 пробела, а за-
тем среднее значение чисел в вещественном формате (8 позиций, из них 3
цифры после десятичной точки). В конце оба файла закрываются функци-
ей fclose.
Конец примера.
Пример 10.5. Копирование двоичных данных из файла в файл:
void main(int p,char *S[])
{FILE *F1,*F2; long n,k; char X[4096];
if(p!=3||(F1=fopen(S[1],"rb"))==NULL||
(F2=fopen(S[2],"wb"))==NULL)
puts("Ошибка!\n");
else
{n=0;
while(feof(F1)==0)
{k=fread(X,1,4096,F1);fwrite(X,1,k,F2); n+=k;}
fclose(F1); fclose(F2);
printf("Длина файла=%ld\n",n);
}
}
162 Лекция 10

Здесь главная программа описана с параметрами p и S, и она может


после трансляции вызываться в командной строке как функция с двумя
параметрами. Если при трансляции задано, что оттранслированной про-
грамме будет приписано имя fcopy, то вызов из командной строки дол-
жен быть в виде:
fcopy файл-1 файл-2
Параметр p задаёт количество символьных строк в вызове программы,
включая как имя самой программы, так и имена двух файлов, т.е. в этом
примере правильное значение p равно 3. Параметр S имеет тип массив
ссылок на символьные строки, которые записаны в командной строке, т.е.
S[0]– ссылка на строку с именем программы, S[1]– ссылка на строку с
именем 1-го файла, S[2]– ссылка на строку с именем 2-го файла.
В операторе if проверяется, что p равно 3, после чего 1-й файл откры-
вается с параметром "rb", как двоичный файл для чтения, а 2-й файл от-
крывается с параметром "wb", как двоичный файл для записи. Если p не
равно 3 или хотя бы одна из переменных F1 или F2 получила значение
NULL, то выводится сообщение об ошибке.
Если оба файла открыть удалось, то в цикле функцией fread после-
довательно считываются в массив X, как в 1 блок длиной 4096 байтов, за-
писанные в файле F1 данные, причём функция fread возвращает в пере-
менную k количество прочитанных байтов. Функция write записывает из
массива X данные в файл F2, при этом в переменной n подсчитывается
количество всех прочитанных байтов. Вызов функции feof проверяет, что
конец файла не достигнут, и тогда цикл продолжается.
После обнаружения конца файла F1 цикл заканчивается, и после него
оба файла закрываются функцией fclose, а на экран выводится текст
"Длина файла=" и величина n.
Конец примера.
Стандартные функции. Заголовочные файлы, содержащие описание
прототипов стандартных функций, включаются в программу препроцессорны-
ми директивами #include. Наиболее важные заголовочные файлы:
assert.h – средства диагностики;
ctype.h – проверка символов и их преобразование;
errno.h – проверка ошибок выполнения программы;
float.h – предельные значения вещественных чисел;
Язык Си. Продолжение 163

limits.h – предельные значения целочисленных данных;


math.h – математические функции;
signal.h – средства обработки исключительных ситуаций;
stdio.h – средства ввода-вывода;
stdlib.h – функции общего назначения;
string.h – функции обработки символьных строк;
time.h – функции определения даты и времени.
В частности, заголовочный файл stdio.h содержит описание функ-
ций ввода-вывода. В функциях printf и scanf первый параметр – стро-
ка символов, содержащая спецификации преобразования. Каждая специ-
фикация преобразования соответствует одному значению в списке вывода
(или одному указателю на переменную в списке ввода). В общем случае
спецификация имеет вид:
%<флаг><ширина поля>.<точность><модификатор><спецификатор>
Обязательный символ здесь – знак процента и буква спецификатора,
определяющая тип вводимых или выводимых данных:
d или i – тип int, изображается в виде десятичных цифр;
u – тип unsigned, изображается в виде десятичных цифр;
o – тип unsigned или int, изображается восьмеричными цифра-
ми;
x или X – тип unsigned или int, изображается шестнадцатерич-
ными цифрами;
f – тип float или double, изображается целая и дробная части;
e или E – тип float или double, изображается мантисса и пока-
затель;
g или G – тип float или double, выбирается наилучшее пред-
ставление;
c – тип char, изображается в виде символа;
s – тип char *, изображается в виде символьной строки.
Модификатор состоит из буквы l или L. Буква l задает тип данных
long или unsigned long, буква L – тип long double.
Ширина поля – количество символьных позиций в представлении эле-
мента данных. Точность – целое число, задающее количество цифр в дроб-
ной части вещественного числа. Флаг (знак + или -) задает обязательное
изображение знака в представлении числа.
164 Лекция 10

Формат кроме спецификаций преобразования может содержать любые


другие символы, в том числе символы табуляции, перехода на новую стро-
ку и др. При выводе эти символы отображаются.

10.2. Символьные строки

Строки определяются в программе как массивы из элементов типа


char или, что то же самое, как указатели на область памяти типа char.
Элементы в таком массиве могут иметь любое значение, кроме нуля, так
как нуль отмечает конец содержимого строки. При работе со строками
необходимо гарантировать, чтобы не выйти за пределы массива и чтобы в
массиве хватило места для концевого нулевого байта.
В заголовочном файле string.h описаны прототипы функций об-
работки символьных строк. Наиболее важные функции приведены в
табл. 10.1.
Таблица 10.1

Функция Тип Тип Пояснение


аргументов результата
strcat char *s1, char * Конкатенация строк. После символов
char *s2 строки s1 дописываются символы
из s2. Возвращает указатель на стро-
ку s1
strchr char *s, char * В строке s производит поиск симво-
char c ла c. Возвращает указатель на
найденный символ
strcmp char *s1, int Сравнивает лексикографически
char *s2 строки s1 и s2. Возвращает 0, если
они равны, <0, если s1<s2, и >0,
если s1>s2
strcpy char *s1, char * Копирует строку s2 в строку s1.
char *s2 Возвращает указатель на строку s1
strlen char * unsigned Вычисляет длину строки

strncat char *s1, char * После символов строки s1 дописы-


char *s2, вается не более n символов из s2.
unsigned n Возвращает указатель на строку s1
Язык Си. Продолжение 165

Таблица 10.1 (продолжение)

Функция Тип Тип Пояснение


аргументов результата
strncmp char *s1, int Сравнивает лексикографически не
char *s2, более чем n символов строк s1 и
unsigned n s2. Возвращает 0, если они равны,
<0, если s1<s2, и >0, если s1>s2
strncpy char *s1, char * Копирует не более чем n символов
char *s2, строки s2 в строку s1. Возвращает
unsigned n указатель на строку s1
strnset char *s, char * Заполняет не более чем n символов
char c, строки s символом c. Возвращает
unsigned n указатель на строку s
strset char *s, char * Заполняет строку s символом c.
char c Возвращает указатель на строку s
strstr char *s1, char * В строке s1 производит поиск под-
char *s2 строки s2. Возвращает указатель на
найденную подстроку
Пример 10.5. Сравнение строк символов с перекодировкой. Во всех
этих функциях при сравнении символов сравниваются их коды, как целые
числа. Если при сравнении требуется отождествлять некоторые символы,
например, соответствующие заглавные и строчные буквы, то необходимо
задать таблицу перекодировки – массив констант T размером 256 элемен-
тов, в котором эначение T[k] задаёт новый код для символа с кодом k:
const short T[256]=
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,
16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,
48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,
64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,
80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,
96,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,
80,81,82,83,84,85,86,87,88,89,90,123,124,125,126,
127,128,129,130,131,132,133,134,135,136,137,138,
139,140,141,142,143,144,145,146,147,148,149,150,
151,152,153,154,155,156,157,158,159,128,129,130,
166 Лекция 10

131,132,133,134,135,136,137,138,139,140,141,142,
143,176,177,178,179,180,181,182,183,184,185,186,
187,188,189,190,191,192,193,194,195,196,197,198,
199,200,201,202,203,204,205,206,207,208,209,210,
211,212,213,214,215,216,217,218,219,220,221,222,
223,144,145,146,147,148,149,150,151,152,153,154,
155,156,157,158,159,133,133,242,243,244,245,246,
247,248,249,250,251,252,253,254,255};
Таблица соответствует кодировке ASCII, CP866. Функция scomp лек-
сикографического сравнения двух строк с перекодировкой:
inline int ord(char c) /*тип char -> int*/
{return c<0 ? c+256 : c;}
int scomp(char *s1, char *s2)
{int i=0,r=0;
while(s1[i]!=0 && s2[i]!=0 &&
T[ord(s1[i])]==T[ord(s2[i])]) i++;
if(s1[i]!=0 && s2[i]!=0)
{if(T[ord(s1[i])]<T[ord(s2[i])])r=-1;
else r=1;
}
else if(s1[i]!=0) r=1;
else if(s2[i]!=0) r=-1;
return r;
}
Здесь используется вспомогательная функция ord, которая аргумент
типа char (как число длиной 1 байт со знаком) преобразует в тип int из
диапазона от 0 до 255. Функция ord имеет описатель inline, это означа-
ет, что при трансляции программы вызов такой функции заменяется пря-
мой текстовой вставкой алгоритма функции. В этой функции использована
тернарная операция «условное выражение», обозначаемая двумя символа-
ми: вопросительным знаком и двоеточием. Всё это необходимо для более
эффективной трансляции программы в машинные команды.
Функция scomp имеет два параметра – указатели на символьные стро-
ки s1 и s2. Конец символьных строк определяется нулевым байтом. Ре-
Язык Си. Продолжение 167

зультат сравнения имеет тип int: –1, если s1<s2, +1, если s1>s2, и 0,
если s1=s2.
Конец примера.
Пример 10.6. Составление словаря. Задача состоит в том, что из запи-
санного в файле текста, который содержит слова из букв, отделенных друг
от друга другими символами (не буквами), необходимо выделить все слова
и записать их в массив символьных строк без повторений. При этом следу-
ет отождествить заглавные и строчные буквы. Результат (последо-
вательность слов) вывести.
Таблица для эффективного распознавания букв задана в виде массива
констант (код ASCII, CP866):
const char Y[]=
{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,
0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,
0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
Если Y[i]=1, то символ с кодом i буква, а если Y[i]=0, то символ
не буква. Программа составления словаря описана с параметрами q и S,
она может после трансляции вызываться в командной строке как функция
с одним параметром – символьной строкой, в которой записано имя файла.
При этом значение параметра q должно быть равно 2. В операторе if про-
веряется, что q равно 2, после чего файл F1 с именем S[1] открывается.
168 Лекция 10

#include <string.h>
void main(int q, char *S[])
{FILE *F1;
if (q<2 || (F1=fopen(S[1],"rb"))==NULL)
printf("ERROR\n");
else
{char d1[21],*D[100],c;
int q=1,p=0,n=0,k1,i;
while(q)
{if (!feof(F1)) i=fgetc(F1);
else {i=' '; q=0;}
c=i;
if (p==0) {if (Y[i]) {d1[0]=c; k1=1; p=1;}}
else if (Y[i]) {d1[k1]=c; k1++;}
else
{d1[k1]=0; p=0; i=0;
while (i<n && scomp(d1,D[i])!=0) i++;
if (i==n)
{D[n]=new char[k1+1];
strcpy(D[n],d1); n++;
}
}
}
fclose(F1);
for(i=0;i<n;i++) printf(”%s\n”,D[i]);
}
}
Переменная d1 описана как массив типа char длиной 21 элемент, т.е.
строка d1 может содержать до 20 символов. Переменная D описана как
массив указателей на тип char длиной 100 элементов.
Если файл F1 удалось открыть, то в цикле while на каждом шаге в
переменную i функцией fgetc считывается очередной символ из файла,
если же функцией feof обнаружен конец файла, то переменной i присва-
ивается код символа пробел.
Дальнейшие действия определяются значением переменной p и симво-
лом c. Если p=0, то буква ранее ещё не была обнаружена, и если при этом
Y[i]=1, то прочитанный символ – первая буква очередного слова, она
Язык Си. Продолжение 169

запоминается в строке d1. Если же p=1, и если при этом Y[i]=1, то про-
читанный символ добавляется к строке d1. Если p=1 и Y[i]=0, то в стро-
ку d1 добавляется нулевой символ – признак конца строки, после чего в
массиве ищется совпадение слова в строке d1 с ранее помещёнными в
массив D словами. Если совпадения не обнаружено, то n увеличивается на
1, для n–го элемента массива D выделяется память, в которую копируется
d1.
После завершения цикла while закрывается файл F1 и выводятся n
строк массива D, каждая строка с новой строчки.
Конец примера.

10.3. Синтаксис Си

Язык программирования, в отличие от любого естественного языка,


должен быть строго однозначным, поэтому задаётся строгими правилами.
Синтаксис – формальные правила, которым должна соответствовать
программа на некотором языке программирования.
Семантика – смысл отдельных элементов программы, написанной по
правилам синтаксиса.
Прагматика – правила преобразования элементов программы, напи-
санной на языке программирования, в элементы программы на другом
языке, например в команды компьютера.
Синтаксис задается в виде правил грамматики, точнее, порождающей
грамматики. Один из способов записи правил – расширенные порождаю-
щие правила Бэкуса, которые задаются следующим образом:
1) каждое правило записывается с новой строки текста;
2) понятие – слово, взятое в угловые скобки < и >;
3) слова Си – зарезервированные слова (например, int, for и др.), ис-
пользуются при написании программы;
4) символы Си (например, +, /, *, =, <= и др.), используются
при написании программы;
5) правило начинается с определяемого понятия, после него пишутся
символы ::= (два двоеточия и равно, их надо читать «это есть»), далее идет
собственно определение понятия;
6) определение понятия состоит из последовательности понятий (слов,
взятых в угловые скобки < и >), слов Си, символов Си, символов | (боль-
170 Лекция 10

шая вертикальная черта, надо читать как «или»), символов {} (большие


фигурные скобки), символов [ ] (большие квадратные скобки);
7) если две части определения разделены символом |, то для конкрет-
ного варианта определяемого понятия используется только одна из таких
частей;
8) если часть определения взята в скобки { }, то она рассматривается
как единое целое;
9) если часть определения взята в скобки [ ], то в конкретном варианте
определяемого понятия она может отсутствовать.
Правила грамматики языка программирования применяются следую-
щим образом: вместо определяемого понятия подставляется один из вари-
антов его определения, т.е. получается последовательность символов язы-
ка, а также (возможно) понятий. Вместо каждого полученного понятия
подставляется его определение, и т.д. до тех пор, пока в полученной по-
следовательности символов не останется ни одного понятия. Таким обра-
зом, каждое понятие порождает, после всех возможных подстановок, неко-
торую часть программы на языке программирования.
Среди всех понятий языка понятие <программа> является главным,
любая синтаксически правильная программа на языке Си является порож-
дением этого понятия. Далее приведены некоторые из правил грамматики
языка Си.

<программа> ::= {<описание типа> | <описание функции> |


<описание объектов>} [<программа>]
<имя> ::= <имя> {<буква> | _ | <цифра>} | {<буква> | _ }
<описание типа> ::= {typedef <тип> <имя типа> ; |
struct <имя структуры> {<поля структуры>};}
<поля структуры> ::= <описание объектов> [<поля структуры>]
<описание функции> ::= <тип> <имя функции>
({<список аргументов>[,...]| void}) {<блок> | ; }
<список аргументов> ::= [<список аргументов>,] <тип> <объект>
<описание объектов> ::= <тип> <список объектов> ; |
<указатель на функцию>
Язык Си. Продолжение 171

<список объектов> ::= [<список объектов>,] <объект> [ = <значение>]


<объект> ::= { * } <объект> { [{<значение>|<пусто>}] } |
<имя> |(<объект>)
<указатель на функцию> ::= <тип> (* <объект>)
({<список типов аргументов>[,...]| void})[ = <значение>];
<список типов аргументов> ::= [<список типов аргументов>,] <тип>
<блок> ::= {<список операторов>}
<список операторов> ::= <оператор> [<список операторов>]
<оператор> ::= <описание объектов> | <блок> | <выражение>; |
if (<выражение>)<оператор> [ else <оператор>] |
switch (<выражение>)<блок> |
while (<выражение>)<оператор> |
for (<выражение>;<выражение>;<выражение>)<оператор> |
do <блок> while (<выражение>);|
return [<выражение>] ;| break;
Для примера рассмотрим правило для понятия <имя>. Один из вариан-
тов его определения – буква (заглавная или строчная латинская) или сим-
вол подчерк. Другой вариант определения – имя, после которого записана
буква или цифра или символ подчерк. На русском языке правило для поня-
тия <имя> можно записать так: имя – это последовательность букв, цифр и
символов подчерк, начинающаяся с буквы или символа подчерк.
Рассмотренные правила грамматики задают язык Си не полностью, так
как не для всех понятий заданы их определения.

Вопросы и задания

1. Написать на Си и отладить программу, которая содержит описание функции, реа-


лизующей сортировку слиянием для списков. Программа должна вводить размер
списка, вводить значения для элементов списка и формировать из них список, вы-
зывать функцию сортировки, выводить список после сортировки. Создать тесты для
программы методом чёрного и белого ящика и провести тестирование.
172 Лекция 10

2. Написать на Си и отладить программу, которая вводит имя входного файла, затем


открывает этот файл для ввода, вводит из файла целое n, затем n чисел, их сумми-
рует и выводит сумму. Создать тесты для программы методом чёрного и белого
ящика и провести тестирование.
3. Написать на Си и отладить программу, которая содержит описание функции срав-
нения двух строк с перекодировкой, вводит посимвольно текст произвольной дли-
ны, содержащий слова из русских букв, разделённых разделителями, в конце тек-
ста – символ ’#’, и которая в процессе ввода выделяет все слова и формирует сло-
варь из этих слов как массив строк. В словаре все слова должны быть различными
(с отождествлением заглавных и строчных букв). Предполагается, что количество
различных слов в тексте не более 1000. Создать тесты для программы методом чёр-
ного и белого ящика и провести тестирование.
4. Что проверяет транслятор языка Си, синтаксис или семантику? Что произойдёт с
программой на языке Си, если в ней нет синтаксических ошибок? Является ли такая
программа правильной?
5. Какие из имён правильные, а какие – нет, и почему:
f, 3f, _32, _abc, 3_a, b11110
6. В каком случае в операторе if перед else должна быть точка с запятой, а когда –
не должна, и почему?
Лекция 11.
Алгоритмы линейной алгебры. Векторы и матрицы

11.1. Сложение и умножение векторов и матриц

Векторы в программах представляются одномерными, а матрицы –


двумерными массивами. Если память для таких массивов выделять дина-
мически, т.е. в процессе работы программы, то тип переменных для одно-
мерных массивов надо задавать как ссылки, а для двумерных массивов –
как ссылки на ссылки.
Пример 11.1. Задание в программе вектора X из n элементов, и матри-
цы M из n строк и k столбцов:
void main(void)
{int n, k, i, j;
float *X, **M;
scanf ("%d%d",&n,&k);
X = new float[n];
for (i=0; i<n; i++)
scanf ("%f",&X[i]);
M = new *float[n];
for (i=0; i<n; i++)
M[i] = new float[k];
for (i=0; i<n; i++)
for (j=0; i<k; k++)
scanf ("%f",&M[i][j]);
. . .
}
Вначале вводятся размеры массивов n и k, выделяется память для мас-
сива X из n элементов типа float, и в цикле вводится n значений для всех
n элементов массива X. Если требуется увеличенная точность вычислений
с вещественными числами, то тип должен быть double.
Затем выделяется память для массива M из n элементов типа ссылки на
float, и в цикле для каждого элемента M[i] выделяется память для k
174 Лекция 11

элеменов типа float. После этого в двойном цикле вводится n*k значе-
ний для всех элементов M[i][j].
В конце программы, когда массивы становятся не нужными, выделен-
ную память для них можно освободить:
for (i=0; i<n; i++)
delete [] M[i];
delete [] M;
delete [] X;
Конец примера.
Операции над векторами и матрицами. Сложение и различные виды
умножения векторов и матриц реализуются непосредственно по определе-
нию соответствующих операций.
Пример 11.2. Вычисление суммы векторов (массивов) A и B размер-
ности n. Результат – вектор (массив) C:
for (i=0; i<0=n; i++)
C[i]=A[i]+B[i];
Конец примера.
Пример 11.3. Вычисление суммы матриц (двумерных массивов) A и B
размерности n×m. Результат – матрица (двумерный массив) C размерно-
стью n×m:
for (i=0; i<n; i++)
for (j=0; j<m; j++)
C[i][j]=A[i][j]+B[i][j];
Конец примера.
Пример 11.4. Вычисление скалярного произведения векторов (масси-
вов) A и B размерности n. Результат – переменная d.
d=0;
for (i=0; i<n; i++)
d+=A[i]*B[i];
Конец примера.
Алгоритмы линейной алгебры. Векторы и матрицы 175

Пример 11.5. Вычисление произведения вектора-строки (одномерного


массива) A размерности n и матрицы (двумерного массива) B размерности
n×m. Результат – вектор-строка (одномерный массив) C размерности m:
for (j=0; j<m; j++)
{d=0;
for (i=0; i<n; i++)
d+=A[i]*B[i][j];
C[j]=d;
}
Во внутреннем цикле вычисляется скалярное произведение C[j] век-
тора-строки A и j–го вектора-столбца матрицы B.
Конец примера.
Пример 11.6. Вычисление произведения матрицы (двумерного масси-
ва) A размерности n×m и вектора-столбца (одномерного массива) B раз-
мерности m. Результат – вектор-столбец (одномерный массив) C размерно-
сти n:
for (i=0; i<n; i++)
{d=0;
for (j=0; j<m; i++)
d+=A[i][j]*B[j];
C[i]=d;
}
Во внутреннем цикле вычисляется скалярное произведение C[i] i–го
вектора-строки матрицы A и вектора-столбца B.
Конец примера.
Пример 11.7. Вычисление произведения матрицы на матрицу:
for (i=0; i<n; i++)
for (j=0; j<k; j++)
{d=0;
for (q=0; q<m; q++)
d+=A[i][q]*B[q][j];
C[i][j]=d;
}
176 Лекция 11

Матрица A размерностью n×m умножается на матрицу B размерностью


m×k. Результат – матрица C размерностью n×k: Во внутреннем цикле вы-
числяется C[i][j] – скалярное произведение i–го вектора-строки мат-
рицы A и j–го вектора-столбца матрицы B.
Конец примера.
Трудоёмкость вычислений в примерах 11.2 – 11.7 определяется коли-
чеством повторений самого внутреннего цикла. В частности, трудоёмкость
вычисления произведения матриц имеет порядок O(n·k·m), а для квадрат-
ных матриц – O(n3).
Для квадратных матриц наряду с операцией произведения определена
операция возведения матрицы в положительную целочисленную степень.
Для возведения в p-ю степень достаточно (p – 1) раз перемножить мат-
рицу саму на себя. При больших p можно использовать более эффектив-
ный алгоритм, который годится для возведения в целочисленную степень
любых объектов. Требуется лишь, чтобы операция умножения для этих
объектов обладала свойством ассоциативности (выполнение свойства ком-
мутативности не требуется).
Пример 11.8. Возведение числа (объекта) x в положительную цело-
численную степень p. Результат – число (объект) z:
z=1; s=p; y=x;
while (s>0)
{if (s&1) {s--; z*=y;}
s>>=1; y*=y;
}
Эта программа отличается от программы в примере 2.10 только тем,
что она записана на Си. Результат условия в операторе if равен 1 (что со-
ответствует значению «истина»), если s нечётное, и равен 0, если s чётное.
Операция сдвига вправо на 1 с присваиванием (>>=) для неотрицательного
значения s эквивалентна делению s на 2.
Предусловие цикла: z=1, s=p, y=x; постусловие: s=0.
Инвариант: z*ys = xp.
После завершения цикла z = xp.
Общее количество умножений, подсчитанное в примере 2.10, не пре-
вышает 2∙(1 + └log2 p┘), однако в этом алгоритме при любом p выполняет-
Алгоритмы линейной алгебры. Векторы и матрицы 177

ся на два умножения больше, чем необходимо. Одно лишнее умножение


выполняется, когда z, равное 1, умножается на y. Второе лишнее умноже-
ние выполняется, когда на последнем шаге цикла s равно 1, y возводится в
квадрат, но далее y не используется.
Если трудоёмкость умножения велика, то целесообразно использовать
улучшенный вариант программы, количество умножений в котором не
превышает 2∙└log2 p┘:
s=p; y=x;
while (!(s&1)) {s>>=1; y*=y;}
z=y; s--;
while (s>0)
{if (s&1) {s--; z*=y;}
s>>=1;
if (s>0) y*=y;
}
В первом цикле, пока s чётное, s делится на 2 и y возводится в квад-
рат. Во втором цикле y возводится в квадрат, если s больше нуля.
Конец примера.
Пример 11.9. Возведение квадратной матрицы X в положительную це-
лочисленную степень p, размерность – n, результат – матрица Z:
Copymat(X,Y,n); s=p;
while (!(s&1))
{s>>=1; Multmat(Y,Y,C,n); Copymat(C,Y,n);}
Copymat(Y,Z,n); s--;
while (s>0)
{if (s&1)
{s--; Multmat(Z,Y,C,n); Copymat(C,Z,n);}
s>>=1;
if (s>0)
{Multmat(Y,Y,C,n); Copymat(C,Y,n);}
}
Отличие от улучшенного варианта программы из примера 11.8 в том,
что здесь объект для возведения в степень – квадратная матрица, поэтому
178 Лекция 11

умножение матриц выполняется функцией Multmat, а копирование –


функцией Copymat:
void Copymat(float **A, float **B,int n)
{int i,j;
for(i=0;i<n;i++)
for(j=0;j<n;j++) B[i][j]=A[i][j];
}
void Multmat(float **A, float **B,
float **C,int n)
{int i,j,q; float d;
for (i=0; i<n; i++)
for (j=0; j<n; j++)
{d=0;
for (q=0; q<n; q++)
d+=A[i][q]*B[q][j];
C[i][j]=d;
}
}
Трудоёмкость одного копирования матрицы – O(n2), поэтому общая
трудоёмкость определяется количеством умножений и сложений в самом
внутреннем цикле функции Multmat, равным n3, умноженным на количе-
ство умножений матриц, т.е.: 2∙└log2 p┘∙n3.
Конец примера.

11.2. Решение систем линейных уравнений

Систему линейных уравнений можно представить в матричном виде:


 
A x  b ,

где A – квадратная матрица коэффициентов уравнений; x – вектор-стол-

бец неизвестных; b – вектор-столбец правых частей.
Единственное решение такой системы существует, если матрица A
невырожденная, т.е. ее определитель не равен нулю. Известно много
методов решения систем линейных уравнений. Метод Гаусса состоит в
том, что вначале матрица приводится к треугольной, т.е. такой, у которой
Алгоритмы линейной алгебры. Векторы и матрицы 179

все элементы, лежащие ниже главной диагонали, равны нулю. Это дела-
ется путем сложения одного из уравнений с другим, умноженным на спе-
циально подобранный коэффициент, в результате коэффициент при одной
из переменных становится нулевым, и этот процесс (прямой ход) продол-
жается до получения треугольной матрицы. Далее тем же способом обну-
ляются элементы, лежащие выше главной диагонали (этот этап называют
обратным ходом). В результате матрица становится диагональной, по ней
неизвестные вычисляются путем деления правой части на соответствую-
щий диагональный коэффициент в левой части уравнения.
Рассмотрим промежуточный этап прямого хода, когда требуется обну-
лить коэффициенты ai + 1,i , ai + 2,i , …, an,i (в формуле i = 3):
a11 a12 a13 ... a1n   x1   b1 
0 a a 23 ... a 2n   x 2  b2 
 22
0 0 a33 ... a 3n    x3   b3 
     
       
0 0 a n3 ... a nn   x n  bn 

Для этого из k-го уравнения, k = i + 1, …, n, необходимо вычесть i-е
уравнение, умноженное на коэффициент c  ak ,i / ai ,i . При этом возникает
следующая проблема: делитель ai,i может оказаться равным нулю, и тогда
коэффициент c вычислить невозможно. Более того, если этот коэффици-
ент не равен, но близок к нулю, то результат (неизвестные x1, ..., xn) будет
вычислен с большими ошибками. Дело в том, что вычисления над веще-
ственными числами в компьютере выполняются приближенно, т.е. с
ошибками округления, а в этом случае ошибки возрастают во много раз.
Для решения этой проблемы в методе Гаусса выбирают ведущий (имею-
щий максимальное абсолютное значение) элемент среди еще не обрабо-
танных коэффициентов матрицы. Проще всего его выбирать из ai,i , ai + 1,i ,
…, an,i . Чтобы решение системы уравнений из-за этого не изменилось,
необходимо строку с выбранным элементом поменять местами с i-й стро-

кой матрицы (обменяв также соответствующие коэффициенты вектора b ).
Пример 11.10. Программа реализует метод Гаусса с выбором ведуще-
го элемента в столбце. Массив A размерностью n×(n+1) в первых n
столбцах содержит коэффициенты при неизвестных, а в последнем столб-
180 Лекция 11

це – коэффициенты правой части уравнения. Результат – массив X из n


неизвестных.
Прямой ход – приведение матрицы к треугольной форме:
for (i=0; i<n-1; i++)
{v=i; /*выбор ведущего элемента:*/
for (j=i+1; j<n; j++)
if (abs(A[j][i])>abs(A[v][i])) v=j;
if (v!=i) /*перестановка i–го уравнения с v–м*/
for (j=i; j<=n; j++)
{z=A[i][j]; A[i][j]=A[v][j]; A[v][j]=z;}
for (k=i+1; k<n; k++) /*вычитание уравнений*/
{c=A[k][i]/A[i][i];
for (j=i; j<=n; j++)
A[k][j]-=c*A[i][j];
}
}
Обратный ход – приведение матрицы к диагональной форме:
for (i=n-1; i>=1; i--)
{for (k=0; k<i-1; k++) /*вычитание уравнений*/
{c=A[k][i]/A[i][i];
A[k][n]-=c*A[i][n];
A[k][i]=0;
}
}
Вычисление неизвестных:
for (i=0; i<n; i++)
X[i]=A[i][n]/A[i][i];
Оценим трудоёмкость программы. На этапе прямого хода в цикле по i
выполняется три действия: 1) выбор ведущего элемента, 2) перестановка
уравнений, 3) вычитание уравнений. Количество исполнений самых внут-
ренних циклов в этих действиях во всех шагах цикла по i соответственно:
T1(n) = (n – 1) + … + 1 = n∙(n – 1)/2 ≈ n2/2,
T2(n) = n + (n – 1) + … + 1 = n∙(n + 1)/2 ≈ n2/2,
Алгоритмы линейной алгебры. Векторы и матрицы 181

T3(n) = (n – 1)(n + 1) + (n – 2) n + … + 1∙3 = (2n3 +3n2 – 5n)/6 ≈ n3/3.


Количество исполнений внутреннего цикла на этапе обратного хода:
T4(n) = n + (n – 1) + … + 1 = n∙(n + 1)/2 ≈ n2/2.
Количество шагов цикла на этапе вычисления неизвестных:
T5(n) = n.
Таким образом, общая трудоёмкость всего алгоритма имеет порядок
O(n3), причём основной вклад в трудоёмкость вносит вычитание уравнений
в прямом ходе, количество которых приблизительно равно n3/3.
Конец примера.
Пример 11.11. Функция решения системы уравнений реализует метод
Гаусса. В программе из примера 11.10 на этапе прямого хода предполага-
ется, что при выборе ведущего элемента A[v,i] каждый раз получается
ненулевое значение. Если же это не так, то при последующем на него де-
лении произойдет аварийное прекращение выполнения программы. Более
того, если ведущий элемент не равен, но близок к нулю, то из-за накопле-
ния ошибок округления при вычислениях над вещественными числами
полученный результат (величина неизвестных) может оказаться далёким
от ожидаемого, и ему нельзя доверять. Для обнаружения такой ситуации в
функции system выполняется соответствующая проверка:
float eps=0.000001;
int system(int n, float **A, float *X)
{ int i, j, k, v;
for (i=0; i<n-1; i++)
{ /*выбор ведущего элемента A[v][i]*/
if (abs(A[v][i])<eps) return 0;
else
{/*перестановка i–го уравнения с v–м*/
/*вычитание уравнений*/
}
}
/*обратный ход*/
/*вычисление неизвестных*/
return 1;
}
Формальные параметры функции system:
182 Лекция 11

n – количество уравнений и неизвестных;


A – ссылка на массив размерностью n×(n+1), в первых n столбцах
содержит коэффициенты при неизвестных, а в последнем столбце – коэф-
фициенты правой части уравнения;
X – результат, ссылка на массив из n неизвестных.
Глобальной переменной eps в описании задано малое, близкое к нулю
значение, используемое для сравнений с ведущими элементами. В функ-
ции system комментариями обозначены действия, совпадающие с дей-
ствиями из примера 11.10. В отличие от программы в примере 11.10, в
функции system после выбора ведущего элемента сравнивается его абсо-
лютное значение с заданным eps, и если оно меньше, то выполняется не-
медленный выход из функции с возвращаемым значением 0. Если же вы-
числения доходят до конца, то функция возвращает значение 1.
Конец примера.
Пример 11.12. Программа вводит матрицу, решает систему уравнений,
выводит результат:
void main(void)
{ int i, j, g, n; float **A, *X;
scanf("%d",&n);
X = new int[n]; A = new *float[n];
for (i=0; i<n; i++)
{A[i]= new float[n+1];
for (j=0; j<=n; j++) scanf("%f",&A[i][j]);
}
g=system(n,A,X); /* Вызов функции решения системы */
if (g) /* Единственное решение существует */
{for (i=0; i<n; i++) printf("%8.3f",X[i]);
printf("\n");
}
else printf("ERROR\n");
}
Вначале вводится размерность системы уравнений n, после чего выде-
ляется память для вектора неизвестных X и для массива указателей на
строки матрицы A. Затем в цикле выделяется память для (n+1)-го элемента
в каждой строке матрицы и вводятся коэффициенты строки матрицы
Алгоритмы линейной алгебры. Векторы и матрицы 183

(включая элемент вектора правой части системы уравнений). После этого


вызывается функция system для решения системы уравнений и проверя-
ется возвращаемое функцией значение g. Если g равно 1, то в цикле выво-
дятся вычисленные в массиве X значения неизвестных, иначе выводится
сообщение об ошибке.
Конец примера.

Вопросы и задания

1. Написать функцию вычисления суммы двух векторов, функцию вычисления ска-


лярного произведения двух векторов и функцию вычисления произведения вектора
на скаляр. Написать примеры их вызовов. Какова трудоёмкость этих функций и по-
чему?
2. Написать функцию вычисления суммы двух матриц. Написать пример её вызова.
Какова её трудоёмкость и почему?
3. Написать функцию вычисления произведения матрицы на скаляр. Написать пример
её вызова. Какова её трудоёмкость и почему?
4. Написать функцию вычисления произведения вектора на прямоугольную матрицу.
Написать пример её вызова. Какова её трудоёмкость и почему?
5. Написать функцию вычисления произведения прямоугольной матрицы на вектор.
Написать пример её вызова. Какова её трудоёмкость и почему?
6. Написать функцию вычисления произведения двух прямоугольных матриц. Напи-
сать пример её вызова. Какова её трудоёмкость и почему?
7. Написать функцию, которая вычисляет произведение прямоугольной матрицы на
неё же транспонированную. Написать пример её вызова. Какова её трудоёмкость и
почему?
8. Написать программу, которая содержит описание функции возведения квадратной
матрицы в положительную целочисленную степень с подсчётом количества умно-
жений матриц. Программа должна вводить матрицу и степень, вызывать функцию и
выводить результат. Создать тесты для программы методом чёрного и белого ящи-
ка и провести тестирование. Какова её трудоёмкость и почему?
9. Написать функцию решения системы линейных уравнений с параметрами:
1) размерность системы, 2) квадратная матрица коэффициентов уравнения,
3) вектор правых частей, 4) вектор вычисленных неизвестных. Написать пример её
вызова. Какова её трудоёмкость и почему?
10. Как соотносятся трудоёмкости вычисления произведения двух квадратных матриц
и решения системы линейных уравнений той же размерности, и почему?
Лекция 12.
Алгоритмы линейной алгебры. Продолжение

12.1. Алгоритмы с квадратными матрицами

Рассмотрим две важнейшие операции над квадратными матрицами –


вычисление определителя и обращение матрицы.
Вычисление определителя квадратной матрицы. Как известно из
линейной алгебры, определитель квадратной матрицы не изменится, если
из какой-либо строки вычесть другую строку, умноженную на произволь-
ный множитель. Если же обменять между собой значения двух строк мат-
рицы, то абсолютное значение определителя не изменится, а его знак из-
менится на противоположный. Поэтому методом Гаусса можно привести
матрицу к треугольной, определитель которой вычисляется как произведе-
ние диагональных элементов. Необходимо также подсчитать количество
переставленных при выборе ведущего элемента строк матрицы, чтобы
корректно учесть знак определителя.
Пример 12.1. Программа вычисляет определитель квадратной матри-
цы методом Гаусса:
float eps=0.000001;
i=0; r=n; p=1;
while (i<r)
{v=i; /*выбор ведущего элемента A[v][i]*/
for (j=i+1; j<n; j++)
if (abs(A[j][i])>abs(A[v][i])) v=j;
if (abs(A[v,i])<eps) r=i;
else
{if (v!=i) /*перестановка строк*/
{p=-p;
for (j=i; j<n; j++)
{z=A[i][j]; A[i][j]=A[v][j];
A[v][j]=z;
}
Алгоритмы линейной алгебры. Продолжение 185

}
/*вычитание строк матрицы*/
for (k=i+1; k<n; k++)
{c=A[k][i]/A[i][i];
for (j=i; j<n; j++)
A[k][j]-=c*A[i][j];
}
i++;
}
}
/*вычисление определителя*/
if (r<n) X=0;
else
{X=p*A[0,0];
for (i=1; i<n; i++) X*=A[i][i];
}
Массив A размерностью n×n содержит коэффициенты матрицы. Ре-
зультат вычисляется в переменной X.
Прямой ход реализуется при выполнении цикла while с параметром
i, изменяющемся от 0 до r=n, если матрицу удаётся преобразовать к тре-
угольному виду, когда на всех шагах цикла ведущий элемент больше (по
абсолютной величине) eps. Если же это не так, то цикл преждевременно
заканчивается, при этом r=i<n, что означает равенство нулю определите-
ля. Переменная p, задающая знак определителя, при каждой перестановке
строк матрицы изменяется с 1 до –1 или обратно.
Нетрудно видеть, что общее количество исполнений внутреннего цик-
ла на этапе вычитания уравнений (самом трудоёмком этапе) приближенно
равно n3/3, как в программе из примера 11.10.
Конец примера.
Вычисление обратной матрицы. Согласно определению, обратная
матрица должна удовлетворять равенству:
A∙D = I , (*)
где A – исходная квадратная матрица; D – обратная матрица; I – еди-
ничная матрица. Это равенство можно представить в виде:
 
A  D j  I j , j  1,..., n ,
186 Лекция 12

 
где D j – j-й столбец обратной матрицы; I j – j-й столбец единичной мат-
рицы.
Таким образом, для вычисления обратной матрицы необходимо ре-
шить n систем уравнений с одной и той же квадратной матрицей коэффи-
циентов, но с различными правыми частями. Трудоёмкость последова-
тельного решения n систем уравнений – O(n4). Более эффективно решать
все n систем уравнений одновременно, преобразуя матрицу A в единич-
ную, и тогда единичная матрица в правой части уравнения (*) преобразу-
ется в обратную:
I ∙D = D.
При этом следует учесть случай, когда определитель системы равен
нулю и тогда обратной матрицы не существует.
Пример 12.2. Программа вычисляет обратную матрицу D к исходной
матрице A методом Гаусса:

float eps=0.000001;
/*задание единичной матрицы D*/
i=0; r=n;
while (i<r)
{/*выбор ведущего элемента A[v,i]*/
if (abs(A[v][i])<eps) r=i;
else
{/*перестановка строк матриц*/
/*деление i-й строки на A[i,i]*/
/*вычитание строк матриц*/
i++;
}
}
Массив A размерностью n×n содержит коэффициенты матрицы.
Комментариями здесь обозначены отдельные действия в программе. Зада-
ние единичной матрицы D:
for (i=0; i<n; i++)
for (j=0; j<n; j++)
if (i==j) D[i][j]=1; else D[i][j]=0;
Алгоритмы линейной алгебры. Продолжение 187

Выбор ведущего элемента A[v,i]:


v=i;
for (j=i+1; j<n; j++)
if (abs(A[j][i])>abs(A[v][i])) v=j;
Перестановка строк матриц:
if (v!=i)
{for (j=i; j<n; j++)
{z=A[i][j]; A[i][j]=A[v][j]; A[v][j]=z;}
for (j=1; j<n; j++)
{z=D[i][j]; D[i][j]=D[v][j]; D[v][j]=z;}
}
Деление i-й строки на A[i,i]:

aii= A[i][i];
for (j=i; j<=n; j++) A[i][j]/=aii;
for (j=1; j<=n; j++) D[i][j]/=aii;
Вычитание строк матриц:
for (k=0; k<n; k++)
if (k!=i)
{c=A[k,i];
for (j=i; j<n; j++) A[k][j]-=c*A[i][j];
for (j=0; j<n; j++) D[k][j]-=c*D[i][j];
}
Если после исполнения программы величина r оказалась меньше чем
n, то это означает, что обратная матрица не существует.
Благодаря тому, что перед вычитанием уравнений выполняется деле-
ние строк матриц A и D на диагональный элемент матрицы A, диагональ-
ные элементы матрицы A становятся единичными. Кроме того, в отличие
от программы из примера 11.10, здесь вычитание строк матриц реализова-
но так, что одновременно выполняется прямой и обратный ход, в котором
участвуют обе матрицы – A и D.
Трудоёмкость программы, так же как программы из примера 11.10,
определяется количеством повторений в циклах при вычитании уравнений.
Из-за совмещентя прямого и обратного хода это количество для матрицы A
188 Лекция 12

равно n3/2, а для матрицы D – n3, т.е. общее количество – 1,5∙n3. Если реа-
лизовать прямой и обратный ход раздельно, то трудоёмкость можно
уменьшить до величины n3/3 + n3 ≈ 1,33∙n3 .
Конец примера.

12.2. Другие алгоритмы с матрицами

Вычисление ранга матрицы. Рангом матрицы называется макси-


мальное линейно независимое количество строк (столбцов) матрицы. Если
матрица ненулевая и в ней m строк и n столбцов, то её ранг будет не менее
чем 1, и не более чем min(m, n).
Перестановка двух строк или двух столбцов в матрице не изменяет её
ранга. Ранг также не изменится, если из какой-либо строки вычесть другую
строку, умноженную на ненулевой коэффициент. Методом Гаусса матрицу
можно попытаться привести к треугольной форме. Однако может возник-
нуть ситуация, что некоторый диагональный элемент aii, а также все эле-
менты, лежащие в том же столбце ниже его, окажутся нулевыми. Тогда
можно продолжить преобразование матрицы, перейдя к правому столбцу и
используя в качестве диагонального элемент ai,i+1. Эта ситуация показана
на матрице из m строк и n столбцов, в которой все элементы, лежащие в
3-м столбце ниже элемента a23 оказались нулевыми, но среди элементов
4-го столбца a34, . . ., am4 были ненулевые:

 a11 a12 a13 a14 a15  a1n 


 0 a22 a23 a24 a25  a2n 

 0 0 0 a34 a35  a3n 
 
 0 0 0 0 a45  a4 n 
       
 
 0 0 0 0 am5  amn 

В матрице на этом шаге вычислений уже выявлены три линейно неза-


висимые строки, т.е. её ранг будет не меньше трёх.
Преобразуя матрицу подобным образом, можно отследить количество
линейно независимых строк.
Алгоритмы линейной алгебры. Продолжение 189

Пример 12.3. Программа вычисляет ранг матрицы A методом Гаусса:


float eps=0.000001;
for (r=0,i=0; (i<n)&&(r<m); i++)
{v=r; /*выбор ведущего элемента: A[v][i]*/
for (j=r+1; j<m; j++)
if (abs(A[j][i])>abs(A[v][i])) v=j;
if (abs(A[v][i])>=eps)
{if (v!=r) /*перестановка r–й строки с v–й*/
for (j=i; j<n; j++)
{z=A[r][j]; A[r][j]=A[v][j]; A[r][j]=z;}
for (k=r+1; k<m; k++) /*вычитание строк*/
{c=A[k][i]/A[i][i];
for (j=i; j<n; j++) A[k][j]-=c*A[i][j];
}
r++;
}
}
Массив A размерностью m×n содержит коэффициенты матрицы. Пе-
ременная r отслеживает количество линейно независимых строк, по окон-
чании вычислений это и будет ранг матрицы. При поиске ведущего эле-
мента в i-м столбце матрицы проверяется, что ведущий элемент больше
заданной малой величины eps, в противном случае он считается нулевым.
Трудоёмкость программы для случая m ≥ n , r=n. Выбор ведущего эле-
мента:
T1(n,m) = (m – 1) + (m – 2) + … + (m – n + 1) =
= (2m – n)·(n – 1)/2 ≈ m∙n – n2/2.
Перестановка строк:
T2(n) = n + … + 1 = (n + 1)∙n /2 ≈ n2/2.
Вычитание строк:
T3(n,m) = m·n + (m – 1)·(n – 1)… + (m – n + 1) ·1 ≈ m·n2/2 – n3/6.
Общая трудоёмкость:
T(n,m) ≈ m·n2/2 – n3/6.
190 Лекция 12

Общая трудоёмкость при m = n:


T(n) ≈ n3/3.
Конец примера.
Общее решение системы из m уравнений и n неизвестных. В этом
случае матрица коэффициентов A прямоугольная, состоит из m строк и n
столбцов. Возможно три варианта решений такой системы:
1) система имеет единственное решение тогда и только тогда, когда
ранг r матрицы A равен n и «лишние» m – r уравнений (если таковые
есть) являются линейными комбинациями остальных r уравнений;
2) система противоречива и не имеет ни одного решения, если «лиш-
ние» m – r уравнений не являются линейными комбинациями остальных
r уравнений;
3) система имеет бесконечно много решений, если ранг r матрицы A
меньше n, и «лишние» m – r уравнений являются линейными комбинация-
ми остальных r уравнений.
В последнем случае можно «лишние» n – r неизвестных считать неза-
висимыми и задавать для них произвольный набор значений, каждый раз
получая новое решение для r «основных» неизвестных.
Алгоритм получения общего решения должен привести матрицу A к
такому виду, чтобы система уравнений была следующей:
1 0  0 a1,r 1  a1,n   b1 
0 1  0 a2,r 1  a2,n  b 
  x1   
2
           
  x  
0 0  1 ar ,r 1  a2,n    2    br 

0 0  0 0  0    br 1 
  x  
       n   
0 0  0 0  0  b 
  m
Для этого при выборе ведущего элемента ai,i необходимо просматри-
вать все элементы матрицы A, лежащие в строках с i-й по n-ю и в
столбцах с i-го по n-й, выполняя последующую перестановку как двух
строк, так и двух столбцов. Тогда вариант решений системы определяется
следующими проверками:
Алгоритмы линейной алгебры. Продолжение 191

1) система имеет единственное решение, если ранг r = n и коэффици-


енты правой части br + 1, …, bm (если они существуют) все равны нулю (или
близки к нулю из-за ошибок округления);
2) система не имеет ни одного решения, если хотя бы один из коэффи-
циентов правой части br + 1, …, bm не равен нулю (или не близок к нулю);
3) система имеет бесконечно много решений, если ранг r < n и коэф-
фициенты правой части br + 1, …, bm (если они существуют) все равны нулю
(или близки к нулю из-за ошибок округления).
В первом случае значения неизвестных равны первым n коэффициен-
там правой части:
x1 = b1, …, xn = bn .
В третьем случае неизвестные xr + 1 , …, xn считаются независимыми, а
неизвестные x1 , …, xr вычисляются через них по формулам:
x1 = b1 – a1,r + 1∙xr + 1 – … – a1,n∙xn ,

xr = br – ar,r + 1∙xr + 1 – … – ar,n∙xn .
Пример 12.4. Программа вычисления общего решения системы урав-
нений:
/*задание индексного массива L*/
i=0;
if (n<m) r=n; else r=m;
while (i<r)
{/*выбор ведущего элемента A[v][u]*/
if (abs(A[v][u])<eps) r=i;
else
{/*перестановка уравнений*/
/*перестановка столбцов*/
/*деление i-й строки на A[i][i]*/
/*вычитание уравнений*/
i++;
}
}
/*проверка решения системы уравнений*/
192 Лекция 12

Массив A размерностью m×(n+1) содержит коэффициенты при неиз-


вестных и вектор правых частей уравнений в последнем столбце. Массив
L – индексный массив для нумерации неизвестных. Массив X – массив вы-
численных значений неизвестных. Комментариями здесь обозначены от-
дельные действия в программе.
Задание индексного массива L:
for (i=0; i<n; i++) L[i]=i;
Выбор ведущего элемента A[v][u]:
v=i; u=i;
for (j=i; j<m; j++)
for (k=i; k<n; k++)
if (abs(A[j][k])>abs(A[v][u])) {v=j; u=k;}
Перестановка уравнений:
if (v!=i)
for (j=i; j<=n; j++)
{z=A[i][j]; A[i][j]=A[v][j]; A[v][j]=z;}
Перестановка столбцов:
if (u!=i)
{for (k=0; k<m; k++)
{z=A[k][i]; A[k][i]=A[k][u]; A[k][u]=z;}
p=L[i]; L[i]=L[u]; L[u]=p;
}
Деление i-й строки на A[i][i]:
c=A[i][i];
for (j=i; j<=n+1; j++) A[i][j]/=c;
Вычитание уравнений:
for (k=0; k<m; k++)
if (k!=i)
{c=A[k][i];
for (j=i; j<=n; j++) A[k][j]-=c*A[i][j];
}
Алгоритмы линейной алгебры. Продолжение 193

Проверка решения системы уравнений:


i=r;
while (i<m && abs(A[i][n])<eps) i++;
if (i<m) {/*решение системы не существует*/}
else if (r==n) /*решение системы единственное*/
{for (j=0; j<n; j++) X[L[j]]=A[j][n];}
else
{/*задание значений независимым переменным:
X[L[r]], ..., X[L[n-1]]*/
/*вычисление зависимых переменных:*/
for (j=0;j<r;j++)
{X[L[j]]=A[j][n];
for (k=r;k<n;k++)
X[L[j]]-= A[j][k]*X[L[k]];
}
}
Здесь ведущий элемент ищется не в одном i-м столбце матрицы A, а в
прямоугольной части матрицы, от i-й до m-й строки, и от i-го до n-го
столбца включительно. И если обнаружится, что ведущий элемент близок
к нулю (тем самым первые r строк и r столбцов матрицы A содержат еди-
ничную подматрицу), то цикл while досрочно прекращается. При пере-
становке столбцов производится перенумерация неизвестных путём обме-
на значений в индексном массиве L. Вычитание уравнений выполняется
так, что совмещается прямой и обратный ход.
После завершения цикла while выполняется проверка того, что эле-
менты вектора правой части: A[r][n], ..., A[m][n] близки к нулю.
Если обнаружится, что хотя бы один из них больше (по абсолютному зна-
чению) eps, то система уравнений несовместна и не имеет решения. Если
проверка прошла успешно, то возможно два варианта:
1) r=n, тогда система имеет единственное решение, значения неиз-
вестных вычислены в векторе правой части уравнений;
2) r<n, тогда система имеет бесконечное множество решений, неиз-
вестные разделяются на две группы: а) независимые, им можно задать ка-
кие угодно значения, их номера от r до n-1; б) зависимые, вычисляются
через значения в векторе правой части уравнений с номерами от 0 до r-1.
194 Лекция 12

Если всем независимым неизвестным задать нулевое значение, то за-


висимые неизвестные будут равны элементам вектора правых частей.
Трудоёмкость программы определяется двумя действиями: выбором
ведущего элемента и вычитанием уравнений на всех шагах выполнения
цикла while. Другие действия имеют намного меньший порядок трудо-
ёмкости: O(n2) или O(m∙n).
Выбор ведущего элемента:
T1(n,m) = m·n + (m – 1) ·(n – 1) + … + (m – n + 1)·1 =
= n2 + (n – 1)2 + … + 12 + (m – n)·n + … + (m – n)·1 ≈
≈ n3/3 + (m – n) n2/2 = m·n2/2 – n3/6.
Вычитание уравнений:
T2(n,m) = m·(n + 1) + m·n + … + m ·2 ≈ m·n2/2.
Общая трудоёмкость: T(n,m) ≈ m·n2 – n3/6.
Общая трудоёмкость при m = n: T(n) ≈ 5/6·n3.
Конец примера.

Вопросы и задания

1. Написать функцию вычисления определителя квадратной матрицы. Написать при-


мер её вызова. Какова её трудоёмкость и почему?
2. Написать функцию вычисления обратной матрицы к заданной квадратной матрице.
Написать пример её вызова. Какова её трудоёмкость и почему?
3. Написать программу, которая вводит квадратную матрицу, вычисляет обратную
матрицу и вычисляет произведение исходной матрицы на обратную. Создать тесты
для программы методом чёрного и белого ящика и провести тестирование. Какова
её трудоёмкость и почему?
4. Написать программу, которая вводит прямоугольную матрицу и вычисляет её ранг.
Создать тесты для программы методом чёрного и белого ящика и провести тести-
рование. Какова её трудоёмкость и почему?
5. Написать программу, вычисляющую общее решение системы n линейных уравне-
ний с m неизвестными методом Гаусса. Какова её трудоёмкость и почему?
6. Как соотносятся трудоёмкости вычисления определителя квадратной матрицы,
ранга той же матрицы и вычисления обратной матрицы одинаковой размерности, и
почему?
Лекция 13.
Графы. Начало

13.1. Представление графов в программе

Граф задается двумя множествами: множеством вершин и множеством


рёбер (дуг). Ребро (дуга) задается парой вершин (которые оно соединяет).
Имеются различные виды графов:
1) неориентированные графы, в таких графах для рёбер не важен поря-
док задания вершин в паре, если вершина i соединена ребром с верши-
ной j, то это то же самое, что вершина j соединена ребром с вершиной i;
2) ориентированные графы, или орграфы, в таких графах дуга опреде-
ляет порядок задания вершин в паре, если есть дуга из вершины i в верши-
ну j, то дуга из вершины j в вершину i может существовать, а может и не
существовать; в таком графе могут существовать также петли, т.е. дуги из
вершины в саму себя;
3) взвешенные неориентированные графы, в которых каждому ребру
приписывается вес;
4) взвешенные ориентированные графы, в которых каждой дуге зада-
ется вес;
Кроме того, имеются также мультиграфы, в которых одну и ту же па-
ру вершин могут соединять несколько различных ребер (дуг).
Отношение на конечном множестве можно представить в виде ориен-
тированного графа, поэтому задачи для отношений можно переформули-
ровать в виде задач для орграфов.
Представление графа списком рёбер (дуг). Для задания множества
вершин графа достаточно указать их количество, предполагая, что верши-
ны перенумерованы натуральными числами 1, 2, …, n. В ориентирован-
ном графе дуги или в неориентированном графе ребра можно задать спис-
ком (массивом) пар вершин, причем для ориентированного графа важен
порядок задания вершин в каждой из пар. Если граф взвешен, то дополни-
тельно с каждой парой вершин задают вес ребра (дуги). Этот способ обыч-
но используется для хранения информации о графе, в то же время он не
196 Лекция 13

слишком эффективен для использования в алгоритмах, в которых прихо-


дится многократно просматривать вершины и рёбра.
Представление графа в графическом виде. Для наглядности граф
можно изобразить на листе бумаги: вершины графа точками или кружоч-
ками, рёбра в неориентированном графе – линиями, соединяющими пары
вершин, дуги в ориентированном графе – линиями со стрелками, показы-
вающими порядок соединения вершин. При этом координаты вершин не
имеют значения, вершины можно изображать в каком угодно месте на ли-
сте бумаги. Линии могут быть отрезками прямых или кривых, пересечения
линий между собой не принимаются во внимание. Граф, который можно
изобразить так, чтобы линии (рёбра или дуги) не пересекались, называется
планарным.
Представление графа в виде матрицы смежности (матрицы инци-
дентности). Рёбра (дуги) графа можно задать в виде квадратной матрицы
смежности, элемент которой Mij = 1, если есть ребро, соединяющее вер-
шины i и j (или дуга, идущая из вершины i в j), и равен нулю в про-
тивном случае. Для неориентированного графа матрица смежности сим-
метрична (Mij = Mji), так как каждое ребро представлено двумя элемента-
ми матрицы. Во взвешенном графе элемент матрицы равен весу ребра (ду-
ги) или особому значению (бесконечности), если между соответствующи-
ми вершинами нет ребра (дуги).
Чтобы просмотреть все вершины, смежные по рёбрам с вершиной i
(или вершины, в которые идут дуги из i), необходимо перебрать элементы
i-й строки матрицы. Для просмотра вершин, из которых идут дуги в вер-
шину i, необходимо перебрать элементы i-го столбца матрицы.
Представление графа списками смежных вершин. Если число ребер
графа с большим числом вершин n существенно меньше чем n2, то в
матрице смежности большинство элементов будут нулевыми и трудоём-
кость алгоритмов, просматривающих все ребра, будет намного больше ми-
нимально возможной. Так, например, по теореме Эйлера в планарном гра-
фе число ребер меньше чем 3∙n, однако трудоёмкость просмотра всех
ребер по матрице смежности будет порядка O(n2).
В этом случае более эффективно представление графа в виде массива
из n указателей и n списков вершин таких, что i-й указатель ссылается на
тот список, который содержит номера вершин, смежных с i-й вершиной.
Т.е. каждый из списков является множеством смежных вершин Такое
Графы. Начало 197

представление подходит как для ориентированного, так и для неориенти-


рованного графа. Если граф взвешенный, то в каждом из элементов списка
необходимо в отдельном поле хранить вес соответствующего ребра (дуги).
Чтобы просмотреть все вершины, смежные по ребрам с вершиной i
(или вершины, в которые идут дуги из вершины i), необходимо пере-
брать все элементы i-го списка. Однако для просмотра вершин, из кото-
рых идут дуги в вершину i, необходимо перебрать все элементы во всех
списках.
На рис. 13.1 изображен пример неориентированного графа и его пред-
ставление в графическом виде, списком ребер, матрицей смежности и
списками смежных вершин.

1 (1,2) 1 2 3 4 5 6 7
(3,1) 1 1 1 2 3
3
2 (2,3) 2 1 1 1 3
4 (4,3) 3 1 1 1 1 2 4
4 1 3
5 6 (5,6)
5 1 6
6 1 5
7 7

Рис. 13.1

Пример 13.1. Задание графа в виде матрицы смежности:


int **M, i, j, n, m, k;
scanf("%d",&n);
M=new int*[n];
for (i=0;i<n;i++)
{M[i]=new int[n];
for (j=0;j<n;j++) M[i][j]=0;
}
scanf("%d",&m);
for (k=0;k<m;k++)
{scanf("%d%d",&i,&j);
M[i][j]=1; M[j][i]=1;
}
198 Лекция 13

Вначале вводится количество вершин графа n, выделяется память для


массива из n указателей на строки матрицы смежности. После этого в цик-
ле выделяется память для n строк матрицы с обнулением в строках эле-
ментов матрицы. Так как в Си нумерация элементов массива всегда начи-
нается с нуля, то нумерация вершин графа также будет от нуля до n-1.
Далее вводится количество рёбер графа m, после чего в цикле вводится
m пар номеров i и j вершин графа, и в матрицу M неориентированного
графа заносятся две единицы: в i-ю строку, j-й столбец, а также j-ю стро-
ку, i-й столбец.
Если задаётся ориентированный граф, то единица для ребра заносится
в матрицу только один раз, в i-ю строку, j-й столбец.
Трудоёмкость имеет порядок O(n2), так как m ≤ n.
Конец примера.
Пример 13.2. Задание графа в виде массива списков:
struct el {int s; struct el *p;};
struct el *S[], *ps; int i, j, n, m, k;
scanf("%d",&n);
S=new *struct el[n];
for (k=0;k<n;k++) S[k]=NULL; /*обнуление указателей*/
for (k=0;k<m;k++) /*формирование списков*/
{ps=new *struct el;
scanf("%d%d",&i,&j); /* k-е ребро – (i,j)*/
ps->s=j; ps->p=S[i]; S[i]=ps;
/*формирование 2-го элемента для неориентированного графа:*/
ps=new *struct el;
ps->s=i; ps->p=S[j]; S[j]=ps;
}
Вначале вводится количество вершин графа n, выделяется память для
массива S из n указателей на списки. После этого в цикле обнуляются ука-
затели в массиве S. Затем формируются списки номеров смежных вершин.
Для неориентированного графа формируется по два элемента списка на
каждое ребро, а для ориентированного – один элемент на каждую дугу.
Трудоёмкость обоих вариантов программы имеет порядок O(n + m).
Если списки требуется упорядочить, то при заполнении списков необ-
ходимо в целочисленном массиве L подсчитывать их длины, а затем до-
Графы. Начало 199

полнительным этапом каждый из списков отсортировать по отдельности.


Если граф взвешенный, то в структуре el необходимо предусмотреть ещё
одно поле – вес ребра.
Конец примера.
Представление графа массивом номеров смежных вершин. Если
списки номеров смежных вершин разместить плотно подряд, в общем для
всех списков массиве, получим структуру, изображенную на рис. 13.2.

1 2 3 4 5 6 7 8 9 10
L S D 2 3 1 3 1 2 4 3 6 5
2 1
2 3
3 5
1 8
1 9
1 10
0 11

Рис. 13.2
Здесь все списки размещены в целочисленном массиве D, размер кото-
рого для ориентированного графа равен числу ребер, а для неориентиро-
ванного – удвоенному числу рёбер. Эти списки могут быть упорядоченны-
ми для ускорения в них поиска. В массиве S размещены для каждого из
списков индексы начала, а в массиве L – длины списков. Длина массивов S
и L равна количеству вершин графа. Если граф взвешенный, то необходим
еще один массив (такой же длины, что и массив D) с весами ребер.
Чтобы определить, имеется ли ребро (дуга), соединяющее вершины i
и j, необходимо среди элементов i-го списка:
D[S[i]], ..., D[S[i]+L[i]-1]
найти элемент, равный j. При упорядоченности списков можно использо-
вать алгоритм дихотомического поиска.
Чтобы просмотреть все вершины, смежные по ребрам с вершиной i
(или вершины, в которые идут дуги из i), необходимо перебрать все эле-
менты i-го списка. А для просмотра вершин, из которых идут дуги в вер-
шину i, необходимо перебрать все списки, т.е. все элементы массива D.
200 Лекция 13

Пример 13.3. Задание графа массивом номеров смежных вершин:


int *v1, *v2, *D, *S, *L, *U;
int i, n, m;
scanf("%d%d",&n,&m);
v1=new int[m]; v2=new int[m];
for (i=0;i<m;i++)
scanf("%d%d",&v1[i],&v2[i]);
D=new int[m+m]; S=new int[n];
L=new int[n]; U=new int[n];
for (j=0;j<n;j++) L[j]=0;//обнуление длин списков
for (i=0;i<m;i++) //вычисление длин списков
{L[v1[i]]++; L[v2[i]]++;}
S[0]=0; //вычисление начальных индексов на списки в массиве D
for (j=1;j<n;j++) S[j]=S[j-1]+L[j-1];
for (j=0;j<n;j++) U[j]=S[j];
//дублирование начальных индексов
for (i=0;i<m;i++) //распределение смежных вершин
//по спискам массива D
{k=v1[i]; D[U[k]]=v2[i]; U[k]++;
k=v2[i]; D[U[k]]=v1[i]; U[k]++;
}
Вначале вводится количество вершин графа n, количество рёбер m и
выделяется память вспомогательным массивам v1 и v2 длиной m для вво-
да рёбер. Затем в цикле в массивы v1 и v2 вводятся пары чисел, задающие
номера вершин рёбер. Далее выделяется память массивам D, S и L струк-
туры графа, а также вспомогательному массиву U.
После этого обнуляется массив L, затем в этом массиве просмотром
массивов v1 и v2 вычисляются длины списков, которые будут позже раз-
мещены в массиве D. После этого в массиве S подсчитываются начальные
индексы размещения этих списков. На завершающем этапе заполняется
массив D, при этом элементы распределяются по спискам за один про-
смотр благодаря дублированию начальных индексов в массиве U, отсле-
живающим свободные места по спискам. Нетрудно видеть, что трудоём-
кость всех этапов имеет порядок O(n + m).
Списки в массиве D можно упорядочить, тогда необходим дополни-
тельный этап, на котором каждый из списков сортируется отдельно.
Графы. Начало 201

Программа в примере задаёт неориентированный граф, когда в массиве


D каждое ребро представлено в двух списках. Для задания ориентирован-
ного графа в программе следует сделать ряд изменений:
1) память массиву D выделить в размере m элементов;
2) при вычислении длин списков в массиве L использовать только мас-
сив v1;
3) при распределении смежных вершин по спискам массива D каждое
ребро помещать только в один список.
Конец примера.

13.2. Просмотр неориентированного графа

Задача просмотра всех вершин неориентированного графа с учетом ре-


бер, связывающих их с соседними вершинами, является важным этапом
при решении многих более сложных задач. Имеется два основных метода:
просмотр графа вглубь и просмотр вширь. Просмотр может начинаться с
любой начальной вершины. При этом вершины могут получить новые но-
мера в порядке просмотра.
При просмотре вглубь в цикле ищется непросмотренная вершина,
смежная с текущей. Как только такая вершина обнаружится, алгоритм ре-
курсивно запускается с этой новой начальной вершины.
Пример 13.4. Рекурсивная функция просмотра вглубь графа, заданно-
го матрицей смежности:
void deеp(int k)
{int i;
for (i=0;i<n;i++)
if ((M[k][i]==1)&&(R[i]==0))
{nom++; R[i]=nom;
deеp(i);
}
}
Граф из n вершин задан матрицей смежности M. Массив R содержит
номера вершин в порядке просмотра, начиная с единицы, причем R[i]=0,
если вершина i ещё не просмотрена.
202 Лекция 13

Перед вызовом процедуры всем n элементам глобального массива R


необходимо присвоить нули, а глобальной переменной nom задать началь-
ное значение, равное номеру для той вершины, с которой начинается про-
смотр. Если просмотр начинается с вершины номер a, то вызов функции
deеp:
for (i=0;i<n;i++) R[i]=0;
R[a]=1; nom=1;
deеp(a);
Завершимость выполнения функции следует из того, что перед рекур-
сивным вызовом deеp(i) проверяется, что R[i]==0 и присваивается
R[i]=nom. Поэтому общее количество вызовов не может превышать n.
Глубина рекурсии ограничена той же величиной n.
Если граф связный, то по математической индукции можно доказать,
что будут просмотрены все его вершины. Если же граф несвязный, то бу-
дут просмотрены все те вершины, до которых можно добраться по ребрам
из начальной вершины a, т.е. до всех вершин компоненты связности, куда
входит вершина a.
Трудоёмкость программы определяется тем, что при каждом вызове
функции цикл выполняется n раз, просматривая все элементы строки мат-
рицы смежности, т.е. общая трудоёмкость имеет порядок O(n2). Глубина
рекурсии не превышает n.
Конец примера.
Пример 13.5. Рекурсивная функция просмотра вглубь графа, заданно-
го массивом списков:
void deеps(int k)
{el *p1;
for (p1=S[k];p1!=NULL;p1=p1->p;)
if (R[p1->s]==0)
{nom++; R[p1->s]=nom;
deеp(p1->s);
}
}
Функция deеps похожа на функцию в примере 13.4, вызовы этих двух
функций идентичны. Однако, в отличие от функции deеps, здесь граф из
Графы. Начало 203

n вершин задан массивом списков S, поэтому при каждом вызове функции


цикл выполняется ровно столько раз, сколько имеется смежных вершин с
вершиной k. При этом суммарное выполнение всех циклов во всех рекур-
сивных вызовах не превышает удвоенного числа ребер, т.е. общая трудо-
ёмкость имеет порядок O(n + m) .
Конец примера.
Используя алгоритм просмотра графа вглубь, можно решить задачу
выделения компонент связности графа.
Пример 13.6. Программа выделения компонент связности графа из n
вершин:
for (i=0;i<n;i++) C[i]=0;
q=0;
for (i=0;i<n;i++)
if (C[i]==0)
{q++; C[i]=q;
cdeеp(i);
}
Программа формирует элементы массива C, определяющие компонен-
ты связности для вершин графа. Глобальная переменная q – счётчик ком-
понент связности. C[i]=q, если i-я вершина принадлежит q-й компо-
ненте связности. Первоначально всем элементам массива C присваивают-
ся нули, это означает, что ни одна из вершин графа не отнесена ни к одной
из компонент. Внешний цикл в программе отыскивает очередную верши-
ну, для которой C[i]=0, приписывает этой вершине номер новой компо-
ненты, после чего производит просмотр графа, начиная с этой вершины,
присваивая всем вершинам компоненты (в массиве C) один и тот же но-
мер. Для примера графа, представленного на рис. 13.2, результат вычисле-
ний в массиве C будет следующим:
1, 1, 1, 1, 2, 2, 3.
В этой программе функция просмотра вглубь должна такой, чтобы в
ней перед рекурсивным вызовом было присваивание C[i]=q. Модифи-
цированная функция cdeеp приведена ниже. В ней, кроме того, исполь-
зуется другое представление графа – в виде массива номеров смежных
вершин D:
204 Лекция 13

void cdeеp(int k)
{int i, j;
for (i=S[k];i<S[k]+L[k];i++)
{j=D[i];
if (C[j]==0) {C[j]=q; cdeеp(j);}
}
}
Нетрудно видеть, что общая трудоёмкость программы, включая время
выполнения всех вызовов функции cdeеp, имеет порядок O(n + m).
Конец примера.
При просмотре вширь заданной начальной вершине приписывается
уровень 1. Затем в цикле ищутся все непросмотренные вершины, смежные
с текущей, и им приписывается уровень, на 1 больший, чем уровень теку-
щей вершины. Далее текущей становится очередная вершина среди после-
довательности просмотренных вершин и т.д. В результате всем вершинам,
достижимым из начальной, приписывается уровень, на 1 больший, чем
кратчайшее расстояние от начальной вершины, измеряемое числом прой-
денных ребер.
Пример 13.7. Программа просмотра вширь графа из n вершин, задан-
ного в виде массива номеров смежных вершин:
P[0]=a; r=0; t=0; //очередь из одной вершины a
for (i=0;i<n;i++) V[i]=0;
V[a]=1; //уровень для вершины a
while (t<=r)
{k=P[t]; q=V[k]+1;
for (i=S[k];i<=S[k]+L[k]-1;i++)
{j=D[i];
if (V[j]==0) {V[j]=q; r++; P[r]=j;}
}
t++;
}
В массиве V записываются уровни для просмотренных вершин, в мас-
сиве P – очередь из номеров просмотренных вершин. Размер этих масси-
вов – n. Первоначально элементу P[0] присваивается номер начальной
вершины a, элементу V[a] – единица, а всем остальным элементам мас-
Графы. Начало 205

сива V – нули. Переменная r обозначает количество просмотренных вер-


шин, помещённых в очередь P, а переменная t – элемент в массиве P, со-
держащий номер текущей обрабатываемой вершины.
Обработка текущей вершины P[t] состоит в том, что все непросмот-
ренные смежные с ней вершины помещаются в очередь P, и им присваива-
ется уровень, на 1 больший, чем уровень вершины P[t].
Для связного графа все n вершин попадут в очередь P, и для каждой из
них цикл for выполнится столько раз, сколько у неё имеется смежных
вершин, поэтому общая трудоёмкость имеет порядок O(n + m).
Конец примера.
Пример 13.8. Программа просмотра вширь графа из n вершин, задан-
ного в виде матрицы смежности M:
P[0]=a; r=0; t=0; //очередь из одной вершины a
for (i=0;i<n;i++) V[i]=0;
V[a]=1; //уровень для вершины a
while (t<=r)
{k=P[t]; q=V[k]+1;
for (i=0;i<n;i++)
if ((V[i]==0)&&(M[k][i]==1))
{V[j]=q; r++; P[r]=j;}
t++;
}
В отличие от программы в примере 13.7, здесь при обработке текущей
вершины цикл for выполняется n раз, поэтому общая трудоёмкость имеет
порядок O(n2).
Конец примера.
Алгоритм просмотра вширь можно использовать для решения задачи вы-
деления компонент связности графа с такой же трудоёмкостью, как и при ис-
пользовании алгоритма просмотра вглубь. Кроме того, алгоритм просмотра
вширь попутно вычисляет кратчайшие расстояния, измеряемые числом прой-
денных ребер от заданной начальной вершины графа до всех достижимых
вершин.
206 Лекция 13

Вопросы и задания

1. Написать программу, которая вводит: 1) количество вершин графа, 2) количество


ориентированных дуг, 3) дуги – пары вершин. Программа должна формировать
матрицу смежности ориентированного графа. Какова её трудоёмкость и почему?
2. Написать программу, которая вводит: 1) количество вершин графа, 2) количество
неориентированных взвешенных рёбер, 3) рёбра – пары вершин и вес. Программа
должна формировать матрицу смежности неориентированного взвешенного графа.
Какова её трудоёмкость и почему?
3. Написать программу, которая вводит: 1) количество вершин графа, 2) количество
неориентированных взвешенных рёбер, 3) рёбра – пары вершин и вес. Программа
должна формировать списки номеров смежных вершин с весами неориентирован-
ного взвешенного графа. Какова её трудоёмкость и почему?
4. Написать программу, которая вводит: 1) количество вершин графа, 2) количество
ориентированных взвешенных рёбер, 3) рёбра – пары вершин и вес. Программа
должна формировать массив номеров смежных вершин с весами неориентирован-
ного взвешенного графа. Какова её трудоёмкость и почему?
5. Написать программу, которая преобразует представление неориентированного гра-
фа из матрицы смежности в массив номеров смежных вершин. Какова её трудоём-
кость и почему? Как следует изменить программу, если граф ориентированный?
6. Написать программу, которая преобразует представление неориентированного гра-
фа из массива номеров смежных вершин в матрицу смежности. Какова её трудоём-
кость и почему? Как следует изменить программу, если граф ориентированный?
7. Неориентированный граф задан матрицей смежности. Написать программу, которая
выделяет компоненты связности графа просмотром в глубину и для каждой компо-
ненты выводит следующие данные: 1) номер компоненты; 2) номера входящих в
нее вершин. Какова её трудоёмкость и почему?
8. Неориентированный граф задан массивом номеров смежных вершин. Написать
программу, которая выделяет компоненты связности графа просмотром в глубину и
для каждой компоненты выводит следующие данные: 1) номер компоненты; 2) но-
мера входящих в нее вершин. Какова её трудоёмкость и почему?
9. Неориентированный граф задан матрицей смежности. Написать программу, которая
выделяет компоненты связности графа просмотром в ширину и для каждой компо-
ненты выводит следующие данные: 1) номер компоненты; 2) номера входящих в
нее вершин. Какова её трудоёмкость и почему?
10. Неориентированный граф задан массивом номеров смежных вершин. Написать
программу, которая выделяет компоненты связности графа просмотром в ширину и
для каждой компоненты выводит следующие данные: 1) номер компоненты; 2) но-
мера входящих в нее вершин. Какова её трудоёмкость и почему?
Графы. Начало 207

11. Неориентированный граф задан списками номеров смежных вершин. Написать


программу, которая выделяет компоненты связности графа просмотром в глубину и
для каждой компоненты выводит следующие данные: 1) номер компоненты; 2) но-
мера входящих в нее вершин. Какова её трудоёмкость и почему?
12. Неориентированный граф задан списками номеров смежных вершин. Написать
программу, которая выделяет компоненты связности графа просмотром в ширину и
для каждой компоненты выводит следующие данные: 1) номер компоненты; 2) но-
мера входящих в нее вершин. Какова её трудоёмкость и почему?
13. Неориентированный граф задан массивом номеров смежных вершин. Задан также
номер u одной из вершин. Написать программу, которая просмотром в ширину для
всех вершин подсчитывает расстояние до них от вершины u в количестве пройден-
ных дуг. Какова её трудоёмкость и почему? Как следует изменить программу, если
граф ориентированный?
Лекция 14.
Графы. Простые алгоритмы

14.1. Поиск кратчайшего пути в лабиринте

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


ритмом просмотра графа вширь. Для этой задачи нет необходимости ис-
пользовать какую-либо специальную структуру для графа: само представ-
ление лабиринта в виде двумерного массива является графом.
Пусть лабиринт задан числовым двумерным массивом L, который со-
ответствует квадратной матрице n×n. Нулевой элемент массива соответ-
ствует проходу в лабиринте, а элемент с большим значением (например,
1000) – стене в лабиринте. Из произвольной клетки лабиринта можно пе-
ремещаться (если нет стены) в четырех направлениях: вправо, влево, вверх
или вниз. Пусть также задана начальная клетка L[i0,j0] и конечная
клетка L[ik,jk]. На рис. 14.1 показан пример лабиринта, в нем знаком
’#’ отмечены клетки со стеной, левая верхняя клетка – начало пути, пра-
вая нижняя клетка – конец пути.

# # # # # # # #

Н # # # 1 # # #

# # # # 2 # # # 11 #

# # 3 4 5 # 9 10 #

# 4 5 6 7 8 9 #

# # # # 5 # # 8 # 10 #

# К # 6 # 10 9 10 11 #

# # # # # # # #

Рис. 14.1

В этой задаче матрица L сама задает граф. Клетки со значением 0 (на


рис. 14.1 клетки с пробелами) задают вершины графа. Каждая из вершин
Графы. Простые алгоритмы 209

графа имеет ребра теми из четырёх соседних вершин, которые также со-
держат 0. Для упрощения проверок того, что при перемещении по лаби-
ринту не произойдёт выход за его пределы, лабиринт со всех сторон можно
окружить стенами, как показано справа на рис. 14.1. Решение всей задачи
можно выполнить за 4 этапа: 1) ввод данных и формирование структуры
лабиринта; 2) разметка лабиринта; 3) отслеживание кратчайшего пути по
разметке; 4) вывод результата.
Пример 14.1. Ввод данных и формирование структуры лабиринта:
int L[22][22],i,j,n,i0,j0,ik,jk; char S[21];
scanf("%d\n",&n);
for (i=1;i<=n;i++)
{S=gets();
for (j=1;j<=n;j++)
if (S[j-1]==’-’) L[i][j]=0;
else L[i][j]=1000;
}
for (j=0;j<=n+1;j++)
{L[0][j]=1000; L[n+1][j]=1000;}
for (i=1;i<=n;i++)
{L[i][0]=1000; L[i][n+1]=1000;}
scanf("%d%d\n",&i0,&j0);
scanf("%d%d\n",&ik,&jk);
Максимальные размеры лабиринта – 20×20. Вначале вводится размер
лабиринта n, затем построчно (в строку символов S) сам лабиринт, после
этого – строка i0 и столбец j0 начала пути, а также строка ik и столбец
jk конца пути в лабиринте. Вводимые данные для лабиринта на рис. 14.1:
6
-#---#
-##-#-
---#--
------
-##-#-
-#----
1 1
6 6
210 Лекция 14

Здесь символ «-» соответствует нулевой клетке, а символ «#» – клетке


со стеной в лабиринте.
Конец примера.
Пример 14.2. Разметка лабиринта:
int Pi[401], Pj[401], r, t, i, j;
Pi[1]=i0; Pj[1]=j0; r=1; t=1;
L[i0][j0]=1;
while (t<=r)
{i=Pi[t]; j=Pj[t]; q=L[i][j]+1;
if (L[i-1][j]==0)
{L[i-1][j]=q; r++; Pi[r]=i-1; Pj[r]=j;}
if (L[i][j-1]==0)
{L[i][j-1]=q; r++; Pi[r]=i; Pj[r]=j-1;}
if (L[i+1][j]==0)
{L[i+1][j]=q; r++; Pi[r]=i+1; Pj[r]=j;}
if (L[i][j+1]==0)
{L[i][j+1]=q; r++; Pi[r]=i; Pj[r]=j+1;}
t++;
}
Программа реализует просмотр графа вширь. Здесь вместо одного мас-
сива P используются два массива: Pi и Pj, определяющих номер строки и
номер столбца клетки. Значение уровня вершины при работе программы
записывается непосредственно в массив L, в клетку с нулём. На рис. 14.1,
справа, показан массив L после выполнения разметки. Заметим, что тру-
доёмкость программы имеет порядок O(n2), так как цикл исполняется не
более чем n2 раз (не может превышать количество клеток в лабиринте), и
при каждом выполнения цикла делается по 4 проверки в операторах if.
Конец примера.
Пример 14.3. Отслеживание кратчайшего пути по разметке. Програм-
ма формирует путь в массивах Mi и Mj от конца к началу. В элементе
Mi[k] запоминается номер строки клетки, а в элементе Mj[k] – номер
столбца клетки на k-м шаге маршрута. Кратчайший путь может быть не
единственным, но в любом случае вычисляется один из вариантов крат-
чайшего пути.
Графы. Простые алгоритмы 211

int Mi[401], Mj[401], k, i, j;


k=L[ik][jk]; i=ik; j=jk;
while (k>0) do
{Mi[k]=i; Mj[k]=j;
if (L[i-1][j]<L[i][j]) i--;
else if (L[i][j-1]<L[i][j] j--;
else if L[i+1][j]<L[i][j] i++;
else j++;
k--;
}
Нетрудно видеть, что количество выполнений цикла в программе рав-
но длине кратчайшего пути, которое в любом случае не может превышать
количество клеток в лабиринте.
Конец примера.
Для сравнения рассмотрим другой способ поиска кратчайшего пути в
лабиринте вместо рассмотренных этапов разметки лабиринта и отслежива-
ния кратчайшего пути по разметке.
Пример 14.3. Поиск кратчайшего пути в лабиринте алгоритмом бэк-
трекинга:
int Mi[401], Mj[401], kmin=1000;
void Lab(int i,int j, int k)
{if (i==ik && j==jk)
{kmin=k; ЗАПОМНИТЬ ПУТЬ}
else if (k<kmin)
{if (L[i-1][j]==0)
{L[i-1][j]=1; Mi[k]=i-1; Mj[k]=j;
Lab(i-1,j,k+1);
L[i-1][j]=0;
}
if (L[i][j-1]==0)
{L[i][j-1]=1; Mi[k]=i; Mj[k]=j-1;
Lab(i,j-1,k+1);
L[i][j-1]=0;
}
if (L[i+1][j]==0)
212 Лекция 14

{L[i+1][j]=1; Mi[k]=i+1; Mj[k]=j;


Lab(i+1,j,k+1);
L[i+1][j]=0;
}
if (L[i][j+1]==0)
{L[i][j+1]=1; Mi[k]=i+1; Mj[k]=j;
Lab(i,j+1,k+1);
L[i][j+1]=0;
}
}
}
Текущий отслеживаемый путь запоминается в массивах Mi и Mj. Кро-
ме того, должны быть описаны ещё два массива для запоминания наилуч-
шего среди ранее отслеженных путей. В переменной kmin запоминается
длина наилучшего пути. Параметры i и j функции Lab задают номер
строки и номер столбца текущей клетки, а параметр k – длину от начала
отслеживаемого пути до текущей клетки.
Выход из рекурсии в происходит в двух случаях:
1) текущая клетка оказалась конечной клеткой пути, и тогда запомина-
ется новый путь;
2) текущая длина маршрута k оказалась такой, что при продолжении
пути в какую-либо соседнюю клетку его длина будет не короче длины ра-
нее запомненного наилучшего пути.
Если выхода из рекурсии нет, то последовательно проверяются 4 со-
седние клетки для возможного продолжения пути. При этом клетки, по
которым текущий отслеживаемый путь прошёл, помечаются единицей,
рекурсивно вызывается функция Lab, а при возврате из функции обнуля-
ется проверенная клетка лабиринта.
Предполагается, что ввод данных и формирование структуры лабирин-
та должны быть такими же, как в примере 14.1. Вызов функции Lab для
отслеживания пути, начиная от клетки i0, j0:
L[i0][j0]=1; Mi[1]=i0; Mj[1]=j0;
Lab(i0,j0,1);
Трудоёмкость определяется тем, что из второй и т.д. текущей клетки
возможно не более трёх возможных движений по пути (так как минимум
Графы. Простые алгоритмы 213

одна соседняя клетка помечена единицей), и тогда общее количество про-


веряемых клеток будет не более:
T (k) = 4 + 32 + 33 + . . . + 3k = 1 + 3(3k – 1)/2,
где k – максимальная длина пути, k ≤ n2.
Таким образом, в подавляющем числе случаев решение этой задачи ал-
горитмом бэктрекинга крайне неэффективно.
Конец примера.
Вывод результата для обоих вариатов поиска кратчайшего пути пред-
лагается реализовать самостоятельно.

14.2. Вычисление топологической сортировки

Циклом в графе называется замкнутый путь, начинающийся в некото-


рой вершине и проходящий через ряд ребер (дуг) графа, причем ни одно
ребро (дуга) не может входить в цикл более одного раза. Если граф неори-
ентированный, то цикл может проходить по ребрам в любом направлении,
а если граф ориентированный, то по каждой из входящих в него дуге
направление прохода должно совпадать с ориентацией дуги. Граф, в кото-
ром не существует ни одного цикла, называется ациклическим. В ориенти-
рованном ациклическом графе, имеющем n вершин, можно выполнить
разметку вершин числами 1, …, n таким образом, что если из вершины i
есть дуга в вершину j, то метка вершины i должна быть меньше метки
вершины j. Такая разметка называется топологической сортировкой.
Как доказывается в теории графов, в ориентированном ациклическом
графе обязательно существует хотя бы одна вершина, в которую не входит
ни одна дуга. Тогда этой вершине можно приписать метку номер 1. После
удаления из графа этой вершины и выходящих из нее ребер в графе по-
явится (если не было раньше) еще хотя бы одна вершина, в которую не
входит ни одна дуга, ей приписывается следующая метка и т.д., пока в
графе еще остаются непомеченные вершины. Такой алгоритм вычисляет
одну из возможных топологических сортировок, которых может существо-
вать несколько для одного и того же графа.
Примером задачи, сводящейся к вычислению топологической сорти-
ровки, является задача выстраивания последовательности взаимозависи-
214 Лекция 14

мых работ. Для того чтобы приготовить жареный картофель, требуется


выполнить такие работы:
а) почистить картофель;
б) помыть картофель;
в) положить картофель на сковороду;
г) положить масло на сковороду;
д) включить плиту и поставить сковороду;
е) нарезать картофель;
ж) жарить, переворачивая, картофель;
з) посолить;
и) снять сковороду с плиты.
Последовательность выполнения работ, как один из вариантов реше-
ния задачи:
б→а→е→д→г→в→з→ж→и
Для сведения этой задачи к задаче на графах сопоставим каждой рабо-
те вершину графа. Если для некоторой пары работ последовательность вы-
полнения строго задана, то зададим ориентированную дугу между соответ-
ствующими вершинами.
Пример 14.4. Вычисление топологической сортировки в ориентиро-
ванном ациклическом графе из n вершин. Граф задан в виде массива D но-
меров смежных вершин. Подсчёт в массиве R количества ребер, входящих
во все вершины:
for (i=0;i<n;i++) R[i]=0;
for (i=0;i<m;i++) {j=D[k]; R[j]++;}
Запись в массив P (очередь) номеров вершин, в которые не входит ни
одна из дуг:
k=-1; t=0;
for (i=0;i<n;i++)
if (R[i]==0) {k++; P[k]=i;}
При просмотре графа в ширину для каждой записанной в массив P
вершины i как бы удаляются выходящие из нее ребра (на самом деле из
каждого элемента q массива R вычитается 1, если из вершины i есть дуга в
вершину q). При этом, как только обнаружится новая вершина с нулевым
Графы. Простые алгоритмы 215

числом входящих в нее ребер, она также записывается в массив P. Про-


грамма заканчивает свою работу, когда не останется вершин в графе, кото-
рые еще можно записать в массив P. Просмотр графа в ширину в соответ-
ствии с очередью в массиве P:
while (t<k)
{i=P[t];
for (j=S[i];j<=S[i]+L[i]-1;j++)
{q=D[j]; R[q]--;
if (R[q]==0) {k++; P[k]=q;}
}
t++;
}
Трудоёмкость программы O(n + m), так как каждая вершина просмат-
ривается один раз, и при этом просматриваются все выходящие из нее ду-
ги.
Заметим также, что если граф не является ациклическим, то обязатель-
но наступит такой момент при выполнении цикла while, когда нет ни
одной вершины с нулевым количеством входящих в нее ребер, хотя еще не
все вершины записаны в массив P. В этом случае цикл while прекратит
выполнение досрочно, при этом k<n-1.
Конец примера.
Пример 14.5. Вычисление топологической сортировки для графа, за-
данного матрицей смежности. Подсчёт в массиве R количества дуг, входя-
щих во все вершины:
for (i=0;i<n;i++) R[i]=0;
for (i=0;i<n;i++)
for (j=0;j<n;j++)
if (M[j][i]==1) R[i]++;}
Запись в массив P (очередь) номеров вершин, в которые не входит ни
одна из дуг:
k=-1; t=0;
for (i=0;i<n;i++)
if (R[i]==0) {k++; P[k]=i;}
Просмотр графа в ширину в соответствии с очередью в массиве P:
216 Лекция 14

while (t<k)
{i=P[t];
for (j=0;j<n;j++)
{if (M[i][j]==1)
{R[j]--;
if (R[j]==0) {k++; P[k]=j;}
}
}
t++;
}
Трудоёмкость программы O(n2), так как каждая вершина просматрива-
ется один раз, и при этом просматриваются все выходящие из нее дуги.
Конец примера.
Результатом топологической сортировки является последовательность
номеров вершин в массиве P. Если же для вершины графа необходимо
найти ее порядковый номер в массиве P, то следует вычислить обратную
к P перестановку. Обратная перестановка определяется следующим обра-
зом. Заполним элементы в массиве Q числами 0, …, n-1 так, что Q[i]=i.
Тогда на i-м месте в массиве топологической сортировки P находится
вершина P[Q[i]] графа. Если отсортировать массив P с одновремен-
ной перестановкой элементов массива Q, то Q и будет массивом обратной
перестановки. На самом деле ничего сортировать не нужно: существует
гораздо более простой алгоритм.
Пример 14.6. Вычисление обратной перестановки. Задан массив P, n
элементов которого образует некоторую перестановку чисел 0, …, n-1.
Вычисление в массиве Q обратной перестановки чисел:
for (i=0;i<n;i++) Q[P[i]]=i;
Пример перестановки чисел от 0 до 5 (массив P):
2, 3, 0, 5, 1, 4
Обратная перестановка (массив Q):
2, 4, 0, 1, 5, 3
Конец примера.
Графы. Простые алгоритмы 217

14.3. Вычисление кратчайших расстояний во взвешенных


графах

Матрица смежности взвешенного графа содержит веса (длины) рёбер,


поэтому называется матрицей расстояний. Эта матрица в общем случае
несимметричная. Будем считать, что веса рёбер неотрицательны, а при от-
сутствии ребра вместо него записывается очень большое значение (беско-
нечность). Расстоянием от одной вершины до другой называется сумма
длин рёбер, которые необходимо последовательно пройти через промежу-
точные вершины по заданному пути. Среди всех возможных путей наибо-
лее важным является путь с кратчайшим расстоянием. Алгоритм Э.
Дейкстры вычисляет все кратчайшие расстояния от заданной вершины до
всех остальных вершин.
Рассмотрим вычисление кратчайших расстояний алгоритмом Э.
Дейкстры для вершины a=0 на примере матрицы расстояний графа из 5
вершин, представленной на рис. 14.1. Тире в диагональных элементах мат-
рицы обозначает бесконечность.
0 1 2 3 4
0 – 10 5 4 15
1 8 – 2 8 1
2 7 1 – 5 3
3 12 3 13 – 7
4 20 11 9 10 –

Рис. 14.1

Нулевая строка матрицы – длины рёбер между вершиной 0 и всеми


остальными. Среди них минимальная длина до 3-й вершины, это и будет
минимальное расстояние, так как путь через какую-либо другую вершину
до 3-й будет длиннее. Пересчитываем длины путей из вершины 0 через
вершину 3 до других вершин, если они стали меньше. В результате полу-
чаем длины путей на рис. 14.2.
218 Лекция 14

0 1 2 3 4
– 7 5 4 11

Рис. 14.2

Среди вычисленных длин путей минимальная длина (при игнорирова-


нии вершины 3) – у вершины 2. Пересчитывая длины путей через верши-
ну 2 до других вершин, получаем длины путей на рис. 14.3.

0 1 2 3 4
– 6 5 4 8

Рис. 14.3

Среди вычисленных длин путей минимальная длина (при игнорирова-


нии вершин 3 и 2) – у вершины 1. Пересчитывая длину пути через верши-
ну 1 до вершины 4, получаем кратчайшие длины путей на рис. 14.4.

0 1 2 3 4
– 6 5 4 7

Рис. 14.4

Если параллельно с пересчётом длин путей на каждом шаге в отдель-


ном массиве отмечать для каждой вершины, из какой другой вершины был
в неё переход, то получим массив ссылок на рис. 14.5.

0 1 2 3 4
– 2 0 0 1
Рис. 14.5
По такому массиву можно восстановить (в обратном порядке) каждый
кратчайший путь из вершины 0. Например, кратчайший путь от вершины 0
до вершины 4:
0→2→1→4
Пример 14.7. Вычисление кратчайших расстояний. В программе ис-
пользуются массивы R, P, Q длиной n. Элемент R[i] содержит вычислен-
Графы. Простые алгоритмы 219

ное текущее минимальное расстояние от заданной вершины а до верши-


ны i. Элемент P[i] содержит номер вершины, из которой идет последнее
ребро текущего кратчайшего пути в вершину i. Массив Q содержит при-
знаки принадлежности вершин двум множествам:
1) множество вершин, до которых уже вычислено самое кратчайшее
расстояние. Если вершина i принадлежит этому множеству, то Q[i]=0;
2) множество остальных вершин, до них вычислено кратчайшее рас-
стояние среди путей, проходящих только через вершины 1-го множества.
Если вершина i принадлежит этому множеству, то Q[i]=1.
for (i=0;i<n;i++) //инициализация массивов Q,R,P
{Q[i]=1; R[i]=M[a][i]; P[i]=a;}
Q[a]=0; P[a]=0;
for (j=1;j<n;j++) //цикл n-1 раз
{rmin=1000000;
for (i=0;i<n;i++)
if (Q[i] && (R[i]<rmin)) {rmin=R[i]; k=i;}
Q[k]=0; //до вершины k вычислено кратчайшее расстояние
for (i=0;i<n;i++) //коррекция массивов R и P
if (Q[i] && (z=M[k][i]+R[k])<R[i])
{R[i]=z; P[i]=k;}
}
При инициализации массивов вершина а присоединяется к 1-му мно-
жеству, все остальные вершины – к 2-му множеству. Второй цикл for вы-
полняется n-1 раз, на каждом шаге цикла к 1-му множеству добавляется
по одной вершине из 2-го множества, до которой имеется самый короткий
путь из вершины а, после чего корректируются массивы R и P. При этом
однократное выполнение цикла сохраняет истинными утверждения о 1-м
и 2-м множествах, что и доказывает правильность программы.
После завершения второго цикла for массив R будет содержать крат-
чайшие расстояния от вершины а до всех остальных вершин, массив P –
ссылки на вершины, препоследние в кратчайших путях.
Трудоёмкость программы O(n2), так как второй цикл for выполняется
n-1 раз, а внутри него – последовательно два цикла по n шагов каждый.
Конец примера.
220 Лекция 14

Пример 14.8. Вычисление кратчайшего расстояния и пути между дву-


мя вершинами a и b. Чтобы в программе из примера 14.7 не выполнять
лишние действия, на каждом шаге выполнения второго цикла for необхо-
димо дополнительно проверять, что вершина b всё ещё находится во 2-м
множестве. Для этого заголовок второго цикла for необходимо заменить
на следующий:
for (j=1;(j<n) && Q[b];j++) //цикл не более n-1 раз
Тогда этот цикл может закончиться досрочно, как только будет вычис-
лено кратчайшее расстояние до вершины b, однако в худшем случае тру-
доёмкость всё равно будет O(n2).
Восстановление кратчайшего пути между вершинами a и b (в обрат-
ном порядке):
i=P[b]; k=0; S[0]=b;
while (i!=P[a])
{i=P[i]; k++; S[k]=i;}
Кратчайший путь будет проходить через следующие k вершин:
S[k-1], S[k-2], . . . S[0].
Конец примера.

Вопросы и задания

1. Написать программу, которая вводит лабиринт, начало и конец пути в лабиринте,


вычисляет просмотром вширь и выводит кратчайший маршрут в виде лабиринта с
помеченными клетками, по которым проходит путь. Создать тесты для программы
методом чёрного и белого ящика и провести тестирование. Какова её трудоёмкость
и почему?
2. Написать программу, которая вводит лабиринт, начало и конец пути в лабиринте,
вычисляет алгоритмом бэктрекинга и выводит кратчайший маршрут в виде лаби-
ринта с помеченными клетками, по которым проходит путь. Создать тесты для про-
граммы методом чёрного и белого ящика и провести тестирование. Какова её тру-
доёмкость и почему?
3. Написать программу, которая вводит данные ориентированного графа и представ-
ляет его в виде массива номеров смежных вершин, после чего вычисляет топологи-
ческую сортировку и определяет, является ли граф ациклическим. Если граф ацик-
лический, то вычисляет также обратную перестановку номеров вершин. Создать те-
Графы. Простые алгоритмы 221

сты для программы методом чёрного и белого ящика и провести тестирование. Ка-
кова её трудоёмкость и почему?
4. Написать программу, которая вводит данные ориентированного графа и представ-
ляет его в виде матрицы смежности, после чего вычисляет топологическую сорти-
ровку и определяет, является ли граф ациклическим. Если граф ациклический, то
вычисляет также обратную перестановку номеров вершин. Создать тесты для про-
граммы методом чёрного и белого ящика и провести тестирование. Какова её тру-
доёмкость и почему?
5. Написать программу, которая для ориентированного графа представленного в виде
массива списков номеров вершин вычисляет топологическую сортировку и опреде-
ляет, является ли граф ациклическим. Какова её трудоёмкость и почему?
6. Доказать корректность программы вычисления обратной перестановки.
7. Написать программу, которая вводит матрицу расстояний между вершинами графа
и номер начальной вершины, после чего вычисляет и выводит все кратчайшие рас-
стояния и все пути от начальной вершины до всех остальных. Создать тесты для
программы методом чёрного и белого ящика и провести тестирование. Какова её
трудоёмкость и почему?
8. Написать программу, которая вводит матрицу расстояний между вершинами графа
и номера начальной и конечной вершины, после чего вычисляет и выводит крат-
чайшее расстояние и путь от начальной вершины до конечной. Создать тесты для
программы методом чёрного и белого ящика и провести тестирование. Какова её
трудоёмкость и почему?
Лекция 15.
Циклы и пути в графах

15.1. Эйлеровы циклы и пути

Эйлеровым циклом называется такой замкнутый путь, который начи-


нается в некоторой вершине и проходит по всем рёбрам (дугам) графа
строго по одному разу. При этом по некоторым вершинам эйлеров цикл
может пройти несколько раз. Если граф неориентированный, то цикл мо-
жет проходить по рёбрам в любом направлении, а если граф ориентиро-
ванный, то по каждой из входящих в него дуге направление прохода долж-
но совпадать с ориентацией дуги.
Чтобы эйлеров цикл существовал, граф должен быть связным, но этого
не достаточно. Как известно из теории графов, в неориентированном графе
эйлеров цикл существует тогда и только тогда, когда все вершины графа
инцидентны чётному числу рёбер. Для ориентированного графа эйлеров
цикл существует тогда и только тогда, когда в каждую вершину входит
ровно столько же дуг, сколько и выходит из нее. Поэтому, прежде чем
строить эйлеров цикл, необходимо проверить условие его существования.
Пример 15.1. Проверка существования эйлерова цикла. Граф задан
массивом D номеров смежных вершин. Если граф неориентированный:
i=0;
while ((i<n) && ((L[i]&1)==0)) i++;
if (i==n) {существует} else {не существует}
Если граф ориентированный, то вначале в массиве R вычисляется ко-
личество дуг, входящих во все вершины, затем сравнивается с количе-
ством исходящих:
for (i=0;i<n;i++) R[i]=0;
for (j=0;j<m;j++) R[D[j]]++;
i=0;
while ((i<n) && (L[i]==R[i])) i++;
if (i==n) {существует} else {не существует}
Циклы и пути в графах 223

Трудоёмкость первой проверки – O(n), второй проверки – O(n + m).


Конец примера.
Если эйлеров цикл существует, то он, как правило, не единственный.
Алгоритм Эйлера вычисляет какой-либо из возможных циклов. Вначале
строится цикл, начиная с произвольной, например первой, вершины. Затем
просматриваются все вершины на построенном цикле, и, как только будет
обнаружена вершина i, из которой выходит еще не включенная в цикл
дуга, отслеживается боковой цикл, который затем вставляется после вер-
шины i.
Так как в формируемый цикл многократно вставляются новые фраг-
менты, то удобнее всего для этого использовать списочные структуры. На
рис. 15.1 показано, как в начальный цикл, записанный в линейном списке,
вставляется сформированный боковой цикл.

1 i k 1

pb p

i1 i

1 i k 1

pb p

Рис. 15.1

Пример 15.2. Вычисление эйлерова цикла в ориентированном графе из


n вершин. Граф задан массивом D номеров смежных вершин.
Вначале создается список из одного элемента с записанной в него вер-
шиной 0, представляющий пустой начальный цикл из одной вершины.
Указатель pb указывает на начало списка. Указатель p указывает на теку-
щий просматриваемый элемент списка и переходит к следующему элемен-
ту списка только тогда, когда для текущей вершины в цикле не останется
непросмотренных боковых дуг. При отслеживании бокового цикла, как
только формируется новый элемент списка, из представления графа удаля-
ется вошедшая в цикл дуга (для этого элемент L[i] уменьшается на 1, а
224 Лекция 15

S[i] увеличивается на 1). По окончании формирования бокового цикла он


в виде отдельного списка вставляется в основной список.
struct el *pb, *p *p0, *p1, *p2;
int i, i0;
p=new struct el; pb=p; p->s=0; p->p=NULL;
while (p!=NULL)
{if (L[p->s]>0)
{p1=p->p; i0=p->s; i=-1; p0=p;
while (i!=i0)
{p2=new struct el; p->p=p2; i=p->s;
i1=D[S[i]]; S[i]++; L[i]--;
i=i1; p2->s=i; p=p2;
}
p->p=p1; p=p0;
}
else p=p->p
}
Трудоёмкость программы – O(n + m), так как все дуги графа просмат-
риваются по одному разу.
Конец примера.
Пример 15.3. Проверка существования эйлерова цикла. Граф задан
матрицей смежности M. Если граф неориентированный:
q=1;
for (i=0;(i<n) && q;i++)
{s=0;
for (j=0;j<n;j++) if (M[i][j]) s++;
if (!(s&1)) q=0; //если не чётное количество рёбер
}
if (q) {существует} else {не существует}
Если граф ориентированный:
q=1;
for (i=0;(i<n) && q;i++)
{s1=0;
for (j=0;j<n;j++) if (M[i][j]) s1++;
Циклы и пути в графах 225

s2=0;
for (j=0;j<n;j++) if (M[j][i]) s2++;
if (s1!=s2) q=0; //если не совпадает количество
} //входящих и исходящих дуг
if (q) {существует} else {не существует}
Трудоёмкость обоих проверок – O(n2), так как просматриваются все n2
элементов матрицы смежности M.
Конец примера.
Пример 15.4. Вычисление эйлерова цикла в неориентированном графе
из n вершин. Граф задан матрицей смежности M:
p=new struct el;
pb=p; p->s=0; p->p=NULL;
for (i=0;i<n) U[i]=0;
while (p!=NULL)
{j=p->s; k=U[j];
while ((k<n) && (M[j][k]==0)) k++;
U[j]=k;
if (k<n)
{p1=p->p; i0=p->s; i=-1; p0=p;
while (i!=i0)
{p2=new struct el;
p->p=p2; i=p->s; k=U[i];
while (M[i][k]==0) k++;
U[i]=k; M[i][k]=0;
M[k][i]=0; //для орграфа удалить это присваивание
i=k; p2->s=k; p=p2;
}
p->p=p1; p=p0;
}
else p=p->p;
}
В отличие от программы из примера 15.1 здесь используется вспомога-
тельный массив U, элемент U[i] указывает на элемент матрицы
M[i][k], с которого следует продолжать просмотр i-й строки матрицы
для обнаружения очередной вершины, смежной с i-й вершиной. При об-
226 Лекция 15

наружении ребра (i,k) обнуляется элемент матрицы M[i][k], а также


M[k][i], так как в неориентированном графе каждое ребро представлено
двумя элементами матрицы. В остальном программа эквивалентна про-
грамме из примера 15.1.
Если граф ориентированный, то следует удалить присваивание
M[k][i]=0.
Трудоёмкость имеет порядок O(n2), так как просматриваются все n2
элементов матрицы смежности M, причём только один раз благодаря ис-
пользованию массива U.
Конец примера.
Если не выполняются условия существования эйлерова цикла, то в
графе может существовать незамкнутый эйлеров путь, который начинается
в одной вершине, а заканчивается в другой. Условие существования эйле-
рова пути в неориентированном графе следующее: валентности двух вер-
шин нечетные, а всех остальных вершин – четные. Путь начинается в од-
ной из вершин с нечетной валентностью, а заканчивается в другой, поэто-
му в программе первым отслеживается начальный путь между этими вер-
шинами. В остальном программа построения пути остается аналогичной
программе построения эйлерова цикла.
Условие существования эйлерова пути в ориентированном графе сле-
дующее: в одной из вершин i число выходящих дуг на 1 больше числа вхо-
дящих, а в другой j, наоборот, число входящих дуг на 1 больше числа вы-
ходящих. При этом для каждой из остальных вершин число входящих дуг
должно быть равно числу выходящих дуг. При выполнении этого условия
эйлеров путь начинается в вершине i и заканчивается в вершине j.
Пример 15.5. Проверка существования эйлерова пути или цикла. Граф
задан массивом D номеров смежных вершин. Если граф неориентирован-
ный:
q=0;
for (i=0;(i<n)&&(q<3);i++)
if (L[i]&1) {q++; ib=ik; ik=i;}
if (q==0) {существует цикл}
else if (q==2) {существует путь}
else {не существует}
Циклы и пути в графах 227

Если граф ориентированный, то вначале в массиве R вычисляется ко-


личество дуг, входящих во все вершины, затем сравнивается с количе-
ством исходящих:
for (i=0;i<n;i++) R[i]=0;
for (j=0;j<m;j++) R[D[j]]++;
q=0;
for (i=0;(i<n)&&(q<3);i++)
if (L[i]!=R[i])
{q++;
if(R[i]==L[i]+1) ib=i;
else if(R[i]==L[i]-1) ik=i;
else q=3;
if (q==0) {существует цикл}
else if (q==2) {существует путь}
else {не существует}
В переменной q подсчитывается, сколько вершин имеют дисбаланс по
рёбрам. В переменной ib вычисляется начало, а в переменной ik – конец
пути, при этом для неориентированного графа начало и конец можно об-
менять между собой.
Трудоёмкость первой проверки – O(n), второй проверки – O(n + m).
Конец примера.
Пример 15.6. Вычисление начального эйлерова пути в ориентирован-
ном графе из n вершин. Граф задан массивом D номеров смежных вершин:
p=new struct el; pb=p; p->s=ib; i=ib;
while (L[i]>0)
{p1=new struct el; p->p=p1;
i1=D[S[i]]; S[i]++; L[i]--;
p1->s=i1; p=p1; i=i1;
}
p->p=NULL;
Если путь существует, то будет построен линейный список, в первом
элементе которого вершина ib, а в последнем – ik. Для построения всего
эйлерова пути после этой программы необходимо выполнить программу из
примера 15.2, без начальных присваиваний перед главным циклом.
228 Лекция 15

Трудоёмкость вычисления всего пути – O(n + m), такая же, как при по-
строении эйлерова цикла.
Конец примера.
Пример 15.7. Проверка существования эйлерова пути или цикла. Граф
задан матрицей смежности M. Если граф неориентированный:
q=0;
for (i=0;(i<n)&&(q<3);i++)
{s=0;
for (j=0;j<n;j++) if (M[i][j]) s++;
if (s&1) {q++; ib=ik; ik=i;}
}
if (q==0) {существует цикл}
else if (q==2) {существует путь}
else {не существует}
Если граф ориентированный:
q=0;
for (i=0;(i<n)&&(q<3);i++)
{s1=0;
for (j=0;j<n;j++) if (M[i][j]) s1++;
s2=0;
for (j=0;j<n;j++) if (M[j][i]) s2++;
if (s1!=s2)
{q++;
if(s1==s2-1) ib=i;
else if(s1==s2+1) ik=i;
else q=3;
}
}
if (q==0) {существует цикл}
else if (q==2) {существует путь}
else {не существует}
Аналогично программе из примера 15.5, в переменной q подсчитыва-
ется, сколько вершин имеют дисбаланс по рёбрам. В переменной ib вы-
числяется начало, а в переменной ik – конец пути, при этом для неориен-
тированного графа начало и конец можно обменять между собой.
Циклы и пути в графах 229

Трудоёмкость каждого варианта проверки – O(n2).


Конец примера.
Пример 15.8. Вычисление начального эйлерова пути в неориентиро-
ванном графе из n вершин. Граф задан матрицей смежности M:
for (i=0;i<n) U[i]=0;
p=new struct el; pb=p; p->s=ib; i=ib;
k=0;
while (M[i][k]==0) k++;
while (k<n)
{p1=new struct el; p->p=p1;
M[i][k]=0;
M[k][i]=0; //для орграфа - удалить
k++;
while (k<n && M[i][k]==0) k++;
U[i]=k;
p1->s=k; p=p1; i=k;
}
p->p=NULL;
Аналогично программе из примера 15.6, будет построен линейный
список, в первом элементе которого вершина ib, а последнем – ik. Для
построения всего эйлерова пути после этой программы необходимо вы-
полнить программу из примера 15.4, удалив в ней начальные присваивания
перед главным циклом.
Если граф ориентированный, то следует удалить присваивание
M[k][i]=0.
Трудоёмкость вычисления всего пути – O(n2), такая же, как при по-
строении эйлерова цикла.
Конец примера.

15.2. Гамильтоновы циклы и пути

Гамильтонов цикл должен проходить по всем вершинам строго по од-


ному разу, а по ребрам (дугам) – не более одного раза. При этом некоторые
ребра (дуги) могут вообще не входить в цикл. Гамильтонов цикл также
существует не для каждого графа.
230 Лекция 15

Несмотря на некоторое сходство, задачи построения эйлерова цикла и


гамильтонова цикла принципиально различаются. Эйлеров цикл (если он
существует) можно построить достаточно эффективно (трудоёмкость про-
порциональна числу ребер графа). Однако гамильтонов цикл можно по-
строить в общем случае лишь за экспоненциальное время.
Гамильтонов цикл можно рассматривать как циклическую перестанов-
ку вершин графа, которая может начинаться с любой вершины, например
первой. Поэтому за основу можно взять программу генерации перестано-
вок чисел, внеся в нее следующие изменения:
1) в первой позиции генерируемых перестановок должна быть записа-
но 1, если вершины нумеруются от числа 1, или записано 0, если вершины
нумеруются от числа 0;
2) очередную вершину следует включать в перестановку только в том
случае, если из предыдущей вершины до нее есть ребро (дуга);
3) перед включением последней вершины должно проверяться наличие
ребра (дуги) между последней и первой вершинами в перестановке.
Пример 15.9. Вычисление всех гамильтоновых циклов в графе из n
вершин. Граф задан матрицей смежности (глобальным массивом) M:
void hamilton(int k)
{int i,j;
i=P[k-1];
for (j=0;j<n;j++)
if ((R[j]==0)&&(M[i][j]))
{P[k]=j; R[j]=1;
if (k==n-1) {if (M[j][0]) ВЫВОД ЦИКЛА}
else hamilton(k+1);
R[j]=0;
}
}
Аналогично программе генерации перестановок чисел, глобальный
массив P содержит последовательность номеров вершин в цикле, а гло-
бальный массив R – признаки вхождения вершин в цикл. Вызов функции:
for (i=0;i<n;i++) R[i]=0;
P[0]=0; R[0]=1;
hamilton(1);
Циклы и пути в графах 231

Трудоёмкость программы в худшем случае такая же, как у программы


генерации перестановок для n – 1, т.е. O(n∙(n – 1)!) = O(n!). Если окажется,
что для заданного графа не существует ни одного гамильтонового цикла,
то программа ничего не выведет.
Заметим также, что для программы совершенно неважно, ориентиро-
ванный граф или нет, так как в программе проверяются только исходящие
из вершин рёбра или дуги.
Конец примера.
Пример 15.10. Вычисление одного гамильтонова цикла в графе из n
вершин. Граф задан матрицей смежности M:
void hamilton1(int k)
{int i,j;
i=P[k-1];
for (j=0;(j<n)&&(z==0);j++)
if (R[j]==0)&&(M[i][j]))
{P[k]=j; R[j]=1;
if (k==n-1)
{if (M[j][0])
{ВЫВОД ЦИКЛА; z=1;}
}
else hamilton1(k+1);
R[j]=0;
}
}
Глобальная переменная z=0, пока ни один цикл ещё не вычислен. Как
только будет построен первый цикл, z=1, после чего происходит выход из
всех рекурсивных вызовов. Вызов функции hamilton1:
for (i=0;i<n;i++) R[i]=0;
P[0]=0; R[0]=1; z=0;
hamilton1(1);
Трудоёмкость программы в худшем случае такая же, как у программы
вычисления всех гамильтоновых циклов, т.е. O(n∙(n – 1)!) = O(n!).
Конец примера.
232 Лекция 15

Если в графе не существует гамильтонов цикл, то в нём может суще-


ствовать незамкнутый гамильтонов путь. Для его отыскания необходимо
пытаться строить путь, начинающийся, поочерёдно, от каждой из вершин
графа.
Пример 15.11. Вычисление всех гамильтоновых путей в графе из n
вершин. Граф задан матрицей смежности M:
void hamiltonp(int k)
{int i,j;
i=P[k-1];
for (j=0;j<n;j++)
if ((R[j]==0)&&(M[i][j]))
{P[k]=j; R[j]=1;
if (k==n-1) {ВЫВОД ПУТИ}
else hamilton(k+1);
R[j]=0;
}
}
Вызов функции hamiltonp:
for (i=0;i<n;i++) R[i]=0;
for (i=0;i<n;i++)
{P[0]=i; R[i]=1; hamiltonp(1); R[i]=0;}
Трудоёмкость программы в худшем случае аналогична трудоёмкости
программы генерации перестановок для n – 1, т.е. O(n!). Если окажется,
что для заданного графа не существует ни одного гамильтонового цикла,
то программа ничего не выведет.
Конец примера.
Пример 15.12. Вычисление одного гамильтонова пути в графе из n
вершин. Граф задан матрицей смежности M:
void hamiltonp1(int k)
{int i,j;
i=P[k-1];
for (j=0;(j<n)&&(z==0);j++)
if ((R[j]==0)&&(M[i][j]))
Циклы и пути в графах 233

{P[k]=j; R[j]=1;
if (k==n-1) {ВЫВОД ПУТИ; z=1;}
else hamilton1(k+1);
R[j]=0;
}
}
Вызов функции hamiltonp1:
z=0;
for (i=0;(i<n)&&(z==0); i++)
{P[0]=i; R[i]=1; hamiltonp1(1); R[i]=0;}
Трудоёмкость программы в худшем случае такая же, как у программы
вычисления всех гамильтоновых путей, т.е. O(n∙(n – 1)!) = O(n!).
Конец примера.
Пример 15.13. Вычисление всех гамильтоновых циклов в графе из n
вершин. Граф задан массивом D номеров смежных вершин:
void hamiltons(int k)
{int i,j,q,r;
i=P[k-1];
for (j=S[i]; j<S[i]+L[i]; j++)
{q=D[j];
if (R[q]==0)
{P[k]=q; R[q]=1;
if (k==n-1)
{r=S[q];
while (r<S[q]+L[q])
if (D[r]==0)
{ВЫВОД ЦИКЛА; r=S[q]+L[q];}
else r++;
}
else hamiltons(k+1);
R[q]=0;
}
}
}
234 Лекция 15

В отличие от программы из примера 15.9, здесь для проверки замыка-


ния цикла приходится перебирать номера вершин, смежных с последней
вершиной, для поиска вершины 0, с которой начинается цикл.
Трудоёмкость в наихудшем можно оценить как O(m1∙m2∙ . . .∙mn), где
mi – количество вершин, смежных с i-й вершиной, так как на очередном
шаге рекурсии цикл выполняется mi раз. Вызов функции hamiltons ана-
логичен вызову функции hamilton.
Конец примера.
Пример 15.14. Вычисление всех гамильтоновых путей в графе из n
вершин. Граф задан массивом D номеров смежных вершин:
void hamiltonsp(int k)
{int i,j,q,r;
i=P[k-1];
for (j=S[i]; j<=S[i]+L[i]; j++)
{q=D[j];
if (R[q]==0)
{P[k]=q; R[q]=1;
if (k==n-1) {ВЫВОД ПУТИ}
else hamilton(k+1);
R[q]=0;
}
}
}
Вызов функции hamiltonsp аналогичен вызову hamiltonp. Трудо-
ёмкость в наихудшем O(m1∙m2∙ . . .∙mn), такая же, как в примере 15.13.
Конец примера.
Пример 15.15. Вычисление одного гамильтонова пути в графе из n
вершин. Граф задан массивом D номеров смежных вершин:
void hamiltonsp1(int k)
{int i,j,q,r;
i=P[k-1];
for (j=S[i];(j<S[i]+L[i])&&(z==0); j++)
{q=D[j];
if (R[q]==0)
{P[k]=q; R[q]=1;
Циклы и пути в графах 235

if (k==n-1) {ВЫВОД ПУТИ; z=1;}


else hamilton(k+1);
R[q]=0;
}
}
}
Вызов функции hamiltonsp1 аналогичен вызову hamiltonp1.
Трудоёмкость в наихудшем O(m1∙m2∙ . . .∙mn), такая же, как в примере 15.13.
Конец примера.

15.3. Задача коммивояжёра

Знаменитая задача коммивояжёра состоит в следующем. Бродячему


торговцу (коммивояжёру) необходимо объехать n городов, начиная с неко-
торого города, побывать в каждом городе единожды, и вернуться в началь-
ный город. При этом требуется, чтобы сумма всех преодолённых им рас-
стояний была минимальной. По-английски эта задача называется Travelling
Salesman Problem (TSP).
Эта задача тесно связана с задачей построения гамильтонова цикла на
графе, но, в отличие от последней, здесь граф взвешенный, а его матрица
инцидентности содержит длины рёбер между парами вершин. Отсутствие
ребра между парой вершин задаётся бесконечностью (или очень большим
числом). Диагональные элементы также целесообразно задавать бесконеч-
ностью. В общем случае матрица может быть несимметричной, т.е. граф
ориентированный. Решением задачи является гамильтонов цикл с мини-
мальной суммой весов входящих в него рёбер.
Пусть задана следующая матрица расстояний между пятью городами:
1 2 3 4 5

1 – 8 12 3 15
2 6 – 9 7 11
3 10 4 – 16 8
4 3 12 15 – 7
5 13 9 5 6 –

Рис. 15.2
236 Лекция 15

Всего имеется (n – 1)! вариантов гамильтоновых циклов, т.е. 24. Реше-


нием (маршрутом коммивояжёра) является цикл: 1 – 4 – 5 – 3 – 2 – 1. Его
длина: 3+7+5+4+6=25.
Пример 15.16. Вычисление гамильтонова цикла минимальной длины в
графе из n вершин. Граф задан матрицей расстояний M между вершинами:
void TSP(int k,float S)
{int i,j,q; float mij;
i=P[k-1];
for (j=0;j<n;j++)
if ((R[j]==0)&&((mij=M[i][j]+S)<Smin))
{P[k]=j; R[j]=1;
if (k==n-1)
{if (mij+M[j][0]<Smin)
{Smin=mij+M[j][0];
for (q=0;q<n;q++) P0[q]=P[q];
}
}
else TSP(k+1,mij);
R[j]=0;
}
}
В программе должны быть описаны следующие глобальные перемен-
ные и массивы:
n – количество вершин в графе,
Smin – длина минимального маршрута (начальное значение – беско-
нечность),
P0 – массив (из n элементов) с номерами вершин минимального
маршрута,
P – массив (из n элементов) с номерами вершин текущего маршрута,
R – массив (из n элементов) с признаками включения вершин в теку-
щий маршрут.
Параметр k в функции TSP задаёт номер в массиве P, в который запи-
сывается номер вершины текущего маршрута, а параметр S – длину ча-
стично построенного маршрута от вершины 0 до вершины P[k-1].
Вызов функции TSP и вывод результата:
Циклы и пути в графах 237

for (i=1;i<n;i++) R[i]=0;


R[0]=1; P[0]=0; Smin=999999;
TSP(1,0);
printf("Smin=%8.3f\n", Smin);
for (i=0;i<n;i++)
printf("%d ",P0[i]);
printf("\n");
Трудоёмкость программы в худшем случае такая же, как у программы
вычисления всех гамильтоновых циклов графа, заданного матрицей смеж-
ности, т.е. O(n∙(n – 1)!) = O(n!).
Конец примера.

Вопросы и задания

1. Ориентированный граф задан массивом номеров смежных вершин. Написать про-


грамму, которая проверяет существование для этого графа эйлерова цикла или пу-
ти. Если цикл существует, то строит цикл, если путь существует, то строит цикл.
Какова её трудоёмкость и почему?
2. Нериентированный граф задан массивом номеров смежных вершин. Написать про-
грамму, которая проверяет существование для этого графа эйлерова цикла или пу-
ти. Какова её трудоёмкость и почему?
3. Неориентированный граф задан матрицей смежности. Написать программу, кото-
рая проверяет существование для этого графа эйлерова цикла или пути. Если цикл
существует, то строит цикл, если путь существует, то строит цикл. Какова её трудо-
ёмкость и почему?
4. Ориентированный граф задан массивом списков номеров смежных вершин. Напи-
сать программу, которая проверяет существование для этого графа эйлерова цикла
или пути. Если цикл существует, то строит цикл, если путь существует, то строит
цикл. Какова её трудоёмкость и почему?
5. Граф задан списками номеров смежных вершин. Написать программу, которая
строит и выводит все существующие гамильтоновы циклы в графе. Какова её тру-
доёмкость и почему?
6. Написать программу, которая вводит данные ориентированного графа и представ-
ляет его в виде матрицы смежности, после чего вычисляет все существующие га-
мильтоновы циклы в графе. Какова её трудоёмкость и почему? Создать тесты для
программы методом чёрного и белого ящика и провести тестирование.
238 Лекция 15

7. Написать программу, которая вводит данные ориентированного графа и представ-


ляет его в виде матрицы смежности, после чего вычисляет один (любой) гамильто-
нов цикл в графе. Какова её трудоёмкость и почему? Создать тесты для программы
методом чёрного и белого ящика и провести тестирование.
8. Написать программу, которая вводит данные ориентированного графа и представ-
ляет его в виде массива номеров смежных вершин, после чего вычисляет один (лю-
бой) гамильтонов путь в графе. Какова её трудоёмкость и почему? Создать тесты
для программы методом чёрного и белого ящика и провести тестирование.
9. Написать программу, которая вводит матрицу расстояний между вершинами графа,
затем вычисляет и выводит оптимальный маршрут коммивояжёра. Какова её трудо-
ёмкость и почему? Создать тесты для программы методом чёрного и белого ящика
и провести тестирование.
Лекция 16.
Технология программирования

16.1. Программы, комплексы программ и программные


продукты

Научная дисциплина технология программирования начала создавать-


ся в 60–е годы, когда стали разрабатывать программы большого размера
коллективами программистов, и когда повысились требования к надёжно-
сти программ. В дальнейшем рекомендации этой научной дисциплины во-
шли в различные промышленные и государственные стандарты. В послед-
нее время технологию программирования стали называть программной
инженерией.
Рассмотрим основные термины этой научной дисциплины:
1) программа имеет вход и выход, может состоять из одного или не-
скольких программных модулей;
2) программный (исполняемый) модуль – программа, которая хранится
как файл и при выполнении предварительно записывается (загружается) в
оперативную память;
3) текст программы – может существовать в разных видах (на исход-
ном языке программирования, на языке ассемблера, на машинном языке);
4) программная система, или просто система – программа или набор
программ, взаимодействующих с людьми, техническими устройствами и
т.п., имеет много входов и выходов, но является единым целым;
5) программный комплекс - набор программ, предназначенных для ре-
шения некоторого множества задач, для решения отдельной задачи обычно
требуются не все программы комплекса;
6) инструментальный программный комплекс – набор программных
средств, предназначенных для разработки программ для решения некото-
рого множества задач, может существовать в виде библиотек процедур,
классов и т.п.;
7) программный продукт – программа (система, комплекс) + докумен-
тация, необходимая для ее использования;
240 Лекция 16

8) жизненный цикл программного продукта – время от начала его со-


здания до последнего применения.
Далее, для краткости, программный продукт или комплексный про-
граммный продукт также будем называть системой. При планировании
сроков разработки системы следует учитывать необходимые для этого
трудозатраты. Создание программного продукта, т.е. программы и доку-
ментации, требует примерно в 3 раза большего труда, чем написание ана-
логичной по объему простой программы. Создание программной системы
или комплекса требует в 3–4 раза большего труда, чем написание анало-
гичной по объему простой программы. Если же создаётся комплексный
программный продукт, то в целом трудозатраты возрастают приблизитель-
но в 10 раз, см. рис. 16.1.
комплексный
комплекс программный
3 продукт

программа
1
1 2 3 программный
программа продукт
Рис.16.1

Жизненный цикл системы можно разделить на следующие фазы:


1) анализ требований к системе – разработка технического задания;
2) проектирование – разработка модели функционирования системы,
её общей структуры, основных структур данных;
3) кодирование – запись компонент системы на языке программирова-
ние, их компилирование и отладка;
4) тестирование компонентов системы и системы в целом;
5) документирование – создание документации для системы;
6) сопровождение – контроль использования системы и, при необхо-
димости, доработка системы, выпуск новых версий.
Эти действия могут выполняться не строго последовательно, после
любой фазы может, при необходимости, происходить возврат к первой фа-
зе и повторное выполнение последующих фаз на основе откорректирован-
Технология программирования 241

ного технического задания. Жизненный цикл системы продолжается, по-


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

16.2. Программная документация

Программная документация начинает создаваться с самого начала раз-


работки системы.
К эксплуатационной документации относятся документы, необхо-
димые для использования системы, такие, как руководство по установке
системы, описание применения, а также, при необходимости, другие доку-
менты. Количество таких документов, их конкретное содержание опреде-
ляется сложностью системы и уровнем подготовки пользователей, на ко-
торых рассчитана система.
В руководстве по установке системы (или в соответствующем разделе
описания применения) должны быть описаны технические характеристики
оборудования, операционной системы, где может функционировать систе-
ма. Должны быть описаны действия пользователя для развертывания си-
стемы на компьютере. Как правило, файлы с программами поставляются
для пользователей в готовом (откомпилированном) виде.
В описании применения должны быть указаны технические характери-
стики компьютера и операционной системы, а также технические характе-
ристики самой системы (ее производительность и др.). Должны быть при-
ведены сведения по непосредственному использованию системы (как зада-
вать входные данные, как общаться с системой в диалоге, в каком виде
система выдает результаты, и т.п.). Кроме того, должны быть приведены
примеры выполнения системы (контрольные примеры) с подробными по-
яснениями. Так как описание применения рассчитано не на программиста,
то в нем не следует использовать без особой необходимости специальные
компьютерные термины.
Хорошая система должна содержать и программные средства, помога-
ющие пользователю быстрее освоиться с системой, помочь ему, если поль-
зователь что-то забыл в описании применения. Обычно эти средства реа-
лизуются в виде справок помощи, вызываемые в любой момент диалога с
системой.
242 Лекция 16

Документация сопровождения должна содержать полное описание


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

16.3. Анализ, проектирование и кодирование

Реализация отдельных фаз жизненного цикла системы зависит от того,


какая технология программирования используется. Рассмотрим кратко ряд
наиболее известных технологий.
Модульное программирование. Иного способа написать большую
программу, кроме как разбить ее на части, т.е. модули, не существует.
Программа, разделенная на части, может считаться модульной, если:
1) размеры каждого модуля таковы, чтобы средний программист, осу-
ществляющий сопровождение, мог его целиком понять, отсюда ограниче-
ние в 50–100 строк текста;
2) каждый модуль должен быть максимально независим от других мо-
дулей, возможные изменения в одном модуле (при неизменных входах и
выходах) не должны привести к необходимости внесения изменений в дру-
гих модулях.
Технология программирования 243

Модульное программирование возникло на рубеже 1960-х годов, ко-


гда в языках программирования появилась возможность реализации про-
цедур и функций.
Структурное программирование. Структурное программирование
можно считать дальнейшим развитием модульного, когда внутренняя
структура модуля состоит только из таких структурных единиц, у каждой
из которых есть только один вход и один выход. Для написания таких
структурированных модулей вполне достаточно использовать последова-
тельные, ветвящиеся и циклические структуры операторов или их эквива-
ленты.
В настоящее время это общепринятый метод написания программ, хо-
тя в момент его рождения, на рубеже 1970-х годов, его применение было
скорее исключением, чем правилом. В то время в практическом програм-
мировании широко использовался «антиструктурный» оператор goto, из-
за которого, в частности, было невозможно проводить аналитическую ве-
рификацию программ, существенно повышающую их надёжность.
Программирование с защитой от ошибок. Для обнаружения воз-
можных ошибок в программу вставляют дополнительные операторы, про-
веряющие текущие значения переменных или логические соотношения
между ними, и выдающие диагностические сообщения, если эти значения
стали недопустимыми. Это существенно облегчает и ускоряет отладку, т.е.
поиск ошибок при тестировании программы.
Чтобы такие проверки не замедляли выполнение всей программы в
процессе её эксплуатации, проверки можно сделать отключаемыми зада-
нием специального параметра. В то же время, если будет обнаружена ситу-
ация, при которой программа выдала некорректный результат, следует
лишь повторить этот вариант вычислений с включенными проверками – и
место ошибки будет локализовано. Таким образом, метод программирова-
ния с защитой от ошибок облегчает и ускоряет не только тестирование и
отладку при разработке программы, но и последующее ее сопровождение.
Объектно-ориентированное программирование (ООП). Возникло на
рубеже 1990-х годов, как развитие модульного программирования, когда
были созданы первые языки объектно-ориентированного программирова-
ния. В программе описываются классы объектов, каждый класс – это такая
структура данных, в которой описаны типы данных и режимы доступа для
244 Лекция 16

них, а также описаны привязанные к этим данным функции, называемые


методами. Объекты порождаются, используются и уничтожаются в про-
цессе выполнения программы. Всё это облегчает создание больших про-
грамм.
CASE (computer-aided software engineering)-технологии. Возникли на
рубеже 2000-х годов в виде программных продуктов – технологических
систем, ориентированных на создание сложных систем и поддержку их
полного жизненного цикла или его основных фаз. Включают инструмен-
тальные средства анализа требований, проектирования спецификаций и
структуры системы, редактирования интерфейсов, автоматической генера-
ции исходных текстов на языке программирования, тестов, документации,
а также сопровождения спецификаций.
UML (Unified Modeling Language – унифицированный язык модели-
рования) – язык графического описания для объектного моделирования.
Применяется в инструментальных CASE-средствах, которые позволяют в
интерактивном режиме создавать формализованное описание системы, её
компонент, структур данных с помощью графических схем, диаграмм и
формальных языков. CASE-средства наиболее полно облегчают труд про-
граммистов при создании информационных систем со сложными структу-
рами данных, когда именно данные определяют алгоритмы их обработки.
Анализ требований. При выполнении этой фазы жизненного цикла
уточняются и формализуются требования технического задания, опреде-
ляются входные и выходные данные, их структура, порядок взаимодей-
ствия системы с пользователем. Для этого строятся и анализируются раз-
личные схемы и диаграммы, в частности, диаграмма вариантов использо-
вания.
Проектирование системы. При проектировании определяются ос-
новные внутренние наборы данных и их структура, создаётся общая
укрупнённая структура системы, ее разбиение на основные компоненты.
Выбираются основные алгоритмы для реализации в системе. Спроектиро-
ванная структура тщательно анализируется, насколько она будет удовле-
творять требованиям технического задания. Нередко для этого создают
упрощенный действующий макет системы (прототип), чтобы проанализи-
ровать его соответствие техническому заданию и уточнить требования.
При создании сложных систем этот процесс раскручивается, как спираль,
Технология программирования 245

чтобы в результате получить детальный проект, в котором для каждой


компоненты был определён алгоритм и структуры данных для него.
Кодирование. Если алгоритмы компонент стандартные, то для них
можно выполнить автоматическое кодирование с помощью инструмен-
тальных CASE-средств. В остальных случаях необходимо создавать про-
граммы компонент вручную. Для этого можно использовать метод по-
этапной детализации, называемым также методом сверху-вниз. Суть ме-
тода в том, что вначале разрабатывается алгоритм программы в целом (мо-
дуль первого уровня), в котором используются модули второго уровня,
которые еще не описаны, но для них определены входные и выходные
данные. Затем разрабатываются по отдельности алгоритмы для каждого из
модулей второго уровня, внутри которых, в свою очередь, также выделя-
ются модули (третьего уровня), и т.д., пока не окажется возможным все
модули записать непосредственно на языке программирования. Для слож-
ных программ полезно их структуру представлять в графическом виде, как
иерархию модулей.
Пример 16.1. Программа, вычисляющая определитель квадратной
матрицы. Программа должна вводить входные данные, вычислять опреде-
литель и выводить его значение.
Порядок ввода:
1) величина, определяющая точность вычислений;
2) размер матрицы n;
3) элементы матрицы через пробел (их количество n2).
Порядок вывода выходных данных: вещественное значение определи-
теля.
Программа в целом имеет следующую структуру:
– модуль «Описания» содержит описания всех входных, выходных и
внутренних переменных;
– модуль «Ввод входных данных» содержит операторы ввода входных
данных в указанном выше порядке и выделение памяти для матрицы;
– модуль «Вычисление определителя» содержит реализацию метода
Гаусса для вычисления определителя;
– модуль «Вывод выходных данных» содержит оператор вывода вы-
численного значения определителя.
После такого определения модули второго уровня «Описания», «Ввод
входных данных» и «Вывод выходных данных» легко запрограммировать.
246 Лекция 16

Модуль второго уровня «Вычисление определителя» содержит в свою


очередь, модули третьего уровня – алгоритмы из примера 12.1.
На рис. 16.2 представлена иерархия модулей программы.

Программа

Ввод входных Вычисление Вывод выходных


Описания данных определителя данных

Выбор
ведущего Переста- Вычитание Вычисление
новка строк строк определителя
элемента

Рис. 16.2
Конец примера.
Несмотря на всю привлекательность метода проектирования сверху-
вниз, иногда приходится сознательно отходить от него. Примером служит
разработка системы, в которой на нижних уровнях многие из модулей по-
вторяют идентичные действия, возможно, с небольшими вариациями. В
этом случае такие одинаковые модули желательно спроектировать один
раз и, реализовав их в виде процедур или функций, просто вызывать в
нужном месте. По-существу, проектирование набора таких универсальных
(для данной системы) модулей не отличается от проектирования комплекса
программ.

16.4. Тестирование и отладка

Как известно, тестирование небольших программ можно выполнять


либо методом чёрного ящика, когда тесты составляют на основе описания
входных и выходных данных, игнорируя структуру программы, либо ме-
тодом белого ящика, когда тесты составляют на основе внутренней струк-
туры программы. После неверного исполнения теста требуется локализо-
вать и исправить ошибку, для чего полезно использовать пошаговое тести-
рование при выполнении теста небольшой размерности.
Технология программирования 247

Сложную программу, состоящую из многих модулей, оттестировать


как единое целое практически невозможно: скорее всего, в самом начале
тестирования в ней будет настолько много ошибок, что все тесты будут
исполняться неправильно, а локализация ошибок будет затруднена.
Существует две технологии тестирования сложных программ: методом
сверху-вниз (нисходящая) и методом снизу-вверх (восходящая).
Восходящая технология тестирования. Тестирование по методу
снизу-вверх начинается, когда проектирование и программирование всех
модулей системы завершено и для каждого модуля определены входы и
выходы.
Вначале по отдельности тестируют модули самого нижнего уровня,
которые в системе вызываются из модулей более высоких уровней. Чтобы
запустить тестируемый модуль на исполнение, необходимо написать для
него тестирующую программу, которая вначале вводит входные данные,
затем вызывает модуль и в конце выводит выходные данные. При этом
используют методы чёрного и белого ящика. Затем тестируют модули бо-
лее высоких уровней, собирая их из модулей нижних уровней и также со-
здавая для них тестирующие программы. И только на самом последнем
этапе тестируется модуль самого высокого уровня, т.е. вся программа це-
ликом.
Если тестирование начинается действительно после проектирования
всей системы, то метод снизу-вверх обеспечивает достаточно хорошее ка-
чество. Однако на практике из-за нехватки времени тестирование нередко
начинают до окончания полного проектирования, причем проектируют
систему также методом снизу-вверх. В результате при тестировании моду-
лей высоких уровней может быть обнаружена ошибка в согласовании вхо-
дов и выходов вызываемых модулей, что повлечет необходимость их пере-
делки. Чем выше уровень тестируемого модуля, тем выше вероятность та-
кой ошибки и тем больше труда требуется на её исправление.
Нисходящая технология тестирования. Тестирование по методу
сверху-вниз начинается, когда спроектирован и запрограммирован модуль
самого высокого уровня и определены вызываемые им модули и их входы
и выходы. Так как эти модули еще не существуют, на время тестирования
их заменяют заглушками, т.е. модулями, которые для заранее заготовлен-
ного набора входных данных (теста) выдают заранее заготовленный набор
выходных данных. Иногда заглушку реализуют в виде предельно упро-
248 Лекция 16

щенного варианта алгоритма, решающего ту же задачу, которую и должен


решать модуль, но, возможно, не в полном объеме.
На последующих этапах тестирования каждую из заглушек поочередно
заменяют настоящим модулем, но со своими заглушками. Таким образом,
в процессе тестирования система становится все более полной, и весь про-
цесс заканчивается, когда заглушки на всех уровнях заменены настоящими
модулями.
Тестирование по методу сверху-вниз обладает следующими достоин-
ствами:
1) тестирование можно начинать почти сразу же после того, как нача-
лось проектирование;
2) при проектировании и тестировании очередного модуля уменьшает-
ся вероятность ошибки в согласовании входов и выходов вызываемых им
модулей;
3) с самого начала тестирования система выглядит как единое целое,
что создает у программиста уверенность в успешном завершении разра-
ботки.
Однако этот метод тестирования не лишен некоторых недостатков,
главный из которых – трудно осуществить достаточно полное тестирова-
ние для модулей низких уровней. Поэтому для полноценного тестирования
желательно использовать оба метода при главенстве нисходящей техноло-
гии тестирования.
После окончания тестирования все использованные заглушки, тести-
рующие программы и тестовые наборы данных сохраняются, а их описа-
ния включаются в документацию сопровождения. Это необходимо для то-
го, чтобы при необходимости всегда можно было повторить тестирование
любого модуля в системе и системы в целом.
Тестирование параметров системы. В техническом задании при-
водятся, как правило, основные характеристики, которым должна удовле-
творять система. К ним относятся, в частности, предельный объем входных
данных, максимальные потребности в оперативной и дисковой памяти для
размещения самой системы и обрабатываемых данных, время обработки
больших объемов данных и др.
Поэтому для определения реальных характеристик требуется провести
тестирование на специальных тестовых наборах данных. При исполнении
системы с такими тестами в систему необходимо также включить операто-
Технология программирования 249

ры, измеряющие характеристики (объем занятой памяти, замеры времени


до и после обработки и др.). При этом следует учитывать, что измеряемое
время есть случайная величина, зависящая, при одном и том же объеме
обрабатываемых данных, от конкретных значений этих данных. Поэтому в
качестве характеристик системы следует использовать не сами измеренные
величины, а их усредненные значения. В простейшем случае можно усред-
нить интервалы времени обработки достаточно большого количества
наборов входных данных одинакового объема, но различного содержимо-
го.
Тестирование документации. Эксплуатационная документация пи-
шется для пользователя, и она должна облегчить ему работу с системой. К
ней предъявляются следующие требования:
1) документация должна быть понятной для пользователя, который,
как правило, не является программистом;
2) документация должна быть полной, т.е. в ней должны быть описаны
все функции системы, все варианты задания и представления выходных
данных;
3) документация должна быть точной, т.е. в ней не должно быть ника-
ких отклонений от истины в описании функционирования системы и в
представлении данных.
Соответственно можно выделить три вида тестирования документа-
ции: на понятность, на полноту и на точность.
Аналитическое тестирование (доказательство) при проектирова-
нии и написании программ. Технология нисходящего проектирования
позволяет проводить доказательство в процессе проектирования. При про-
ектировании главного модуля системы создается алгоритм, для которого
заданы входы и выходы. Если их записать в форме логических условий, то
это будет предусловие и постусловие. Анализ структуры алгоритма модуля
позволяет сформулировать вход и выход (т.е. предусловие и постусловие)
для каждого вызываемого в нем модуля более низкого уровня. Предпола-
гая правильность всех вызываемых модулей (применяя метод абстракции),
можно доказать правильность главного модуля.
Далее этот процесс повторяется для всех вызываемых модулей второ-
го, затем третьего уровня и т.д. Таким способом можно доказать правиль-
ность всех модулей даже очень большой системы. Несмотря на дополни-
тельные усилия, проведение доказательства в конечном итоге себя оправ-
250 Лекция 16

дывает, так как оно позволяет с меньшим числом ошибок спроектировать


систему и существенно облегчает тестирование и отладку всей системы
благодаря потенциально меньшему числу оставшихся ошибок.
При аналитическом исследовании трудоёмкость выводится чаще всего
для наихудшего случая, это позволяет заранее, ещё до тестирования быст-
родействия, оценить, смогут ли применённые в системе алгоритмы в прин-
ципе удовлетворить пользователей.
Независимое тестирование. Как известно, целью тестирования явля-
ется поиск возможных ошибок в системе. Однако во время приемки систе-
мы заказчиком ее публичное тестирование как раз и служит демонстраци-
ей работоспособности системы. В результате у разработчика всегда суще-
ствует искушение существенно сократить работу по тестированию. Имен-
но поэтому в коллективе разработчиков окончательное тестирование дол-
жен осуществлять не тот, кто писал тестируемый модуль, а другой про-
граммист.
Но даже этих мер бывает недостаточно для того, чтобы быть уверен-
ным в надежности системы. Поэтому нередко провести тестирование си-
стемы поручаетсяя независимой организации. Такое тестирование может
быть реализовано всей системы в целом по методу черного ящика. При
этом тестируется не только система, но и документация к ней.

16.5. Организационные проблемы технологии


программирования

Еще в 1960-х годах руководители проектов обнаружили, что примене-


ние традиционных методов управления, с успехом применявшихся,
например, при проектировании зданий, часто не приводит к успеху при
создании больших систем.
Во-первых, очень часто программисты недооценивают длительность
выполнения проекта. Дело в том, что до начала разработки невозможно
предвидеть все сложности, которые будут обнаружены при непосред-
ственной разработке. Кроме того, реальная производительность програм-
мистов, даже имеющих одинаковую квалификацию, сильно различается,
иногда в 5–10 раз! Поэтому если считать, что при выполнении проекта все
Технология программирования 251

будут работать одинаково эффективно, легко допустить ошибку с плани-


рованием сроков завершения проекта.
Во-вторых, если при затягивании сроков выполнения проекта к кол-
лективу разработчиков добавить исполнителей, то, как правило, оконча-
тельный срок завершения работы отодвинется еще больше: новому про-
граммисту необходимо время, чтобы разобраться в задаче и изучить со-
зданную часть проекта. Одновременно новые исполнители будут отвлекать
от работы тех, кто участвует в работе с самого начала.
Все это позволило Ф. Бруксу сформулировать закон, носящий его имя:
«Если при выполнении проекта добавить исполнителей, то окончатель-
ный срок завершения проекта может только увеличиться».
Для уменьшения негативного эффекта от этого закона, необходимо
сразу спланировать более длительные сроки выполнения проекта, чем их
оценивают сами исполнители, в процессе работы контролировать ход вы-
полнения проекта. Кроме того, необходимо так распределить работу меж-
ду исполнителями, чтобы в случае выбытия любого из них не произошло
существенное замедление выполнения работ.
При разработке больших проектов, к которым привлекаются большие
коллективы людей, значительная часть усилий тратится не непосредствен-
но на программирование, а на взаимодействие между участниками проек-
та. Специальные исследования показывают, что: «При увеличении сложно-
сти системы в n раз для ее создания в прежние сроки обычно требуется в
n2 раз больше людей».
Чтобы каждый программист мог применить свои способности в кол-
лективе с максимальной эффективностью, Ф. Бруксом еще в конце 1960-х
годов было предложено коллективы программистов организовывать в виде
бригад, в которых расписаны индивидуальные роли каждого. Бригада со-
здается вокруг главного программиста, обладающего наивысшей квалифи-
кацией. Основные принципы организации бригады:
1) каждый член бригады имеет свою специализацию и основные обя-
занности наряду с текущими заданиями, получаемыми от руководителя;
2) любой из членов бригады работает в непосредственном контакте с
одним или двумя коллегами так, чтобы даже в случае его выбытия работа
по проекту не приостанавливалась.
Главный программист принимает основные решения при проектирова-
нии архитектуры системы, создает наиболее ответственные модули в си-
стеме, пишет или контролирует написание документации. В бригаде дол-
252 Лекция 16

жен быть его заместитель, который, так же как главный программист, вла-
деет всей наиболее существенной информацией по проекту, и в случае
необходимости может его заменить. Кроме того, некоторые обязанности
распределяются между отдельными программистами в бригаде. Архивари-
ус отвечает за сохранность всех копий написанных компонентов системы и
документации, причем все копии должны регулярно обновляться. Инстру-
ментальщик отвечает за подготовку и освоение вспомогательных про-
граммных инструментов, необходимых всем другим членам бригады. Те-
стировщик отвечает за тестирование системы и ее модулей. Литературный
редактор отвечает за подготовку документации.
При использовании нисходящей технологии проектирования можно
уже на первых этапах проведения разработки более-менее равномерно за-
грузить работой всех членов бригады, чтобы вовремя завершить проект. В
целом бригада многократно умножает способности главного программи-
ста, от которого в наибольшей степени зависит успех работы.
При планировании проведения работ по разработке системы следует
учесть и другие законы технологии программирования.
«Первая система (её 1-й вариант) всегда создается на выброс». Дело
в том, что заказчик полностью осознает то, что ему необходимо в системе,
как правило, только после того, как увидит ее в действии. Поэтому, как бы
разработчик тщательно и аккуратно не старался реализовать систему, её
все равно придется переделывать, возможно, с самого начала. Отсюда вы-
вод: первую систему следует создавать так, чтобы было не жалко
выбрасывать. Т.е. вначале целесообразно создавать действующий макет
системы, в котором реализованы основные функции, но в упрощенном ви-
де с самыми простыми вариантами алгоритмов и т.п. После того, как за-
казчик или пользователь изучит разработанную систему, определит, что в
ней его не устраивает, можно разрабатывать проект второго варианта си-
стемы.
«Вторая система (её 2-й вариант) обладает эффектом второй си-
стемы». При создании второго варианта системы легко впасть в край-
ность и реализовать излишние функции в системе, которые на самом деле
не нужны пользователю, сделать архитектуру системы чрезмерно громозд-
кой и неуклюжей, из-за чего она снова будет мало устраивать заказчика.
Отсюда вывод: вторую систему не имеет смысла «доводить до блеска»,
после ее испытания и опробования надо быстрее переходить к созданию 3-
го варианта системы.
Технология программирования 253

Вопросы и задания

1. Почему трудозатраты создания комплексного программного продукта на порядок


больше, чем трудозатраты при разработке аналогичной по объему простой про-
граммы?
2. Каковы цели и задачи основных этапов разработки системы?
3. В чем состоит сопровождение программного продукта? Что такое жизненный
цикл системы?
4. Какие документы относятся к эксплуатационной документации, и какие сведения
должны быть в этих документах?
5. Какие документы относятся к документации сопровождения, и какие сведения
должны быть в этих документах?
6. Реализовать проект программы вычисления определителя квадратной матрицы,
описанный в примере 16.1. Выполнить нисходящее тестирование программы.
Написать эксплуатационную документацию и документацию сопровождения.
7. В каком случае целесообразно метод проектирования сверху-вниз сочетать с проек-
тированием снизу-вверх?
8. Какими качествами должна обладать программа, чтобы ее можно было считать
модульной?
9. В чем состоит структурное программирование?
10. Что такое программирование с защитой от ошибок?
11. В каких случаях целесообразно применять объекно-ориентированное программиро-
вание?
12. Для чего предназначены CASE-технологии?
13. Как проводить доказательство правильности и вывод трудоёмкости большой про-
граммы методом сверху-вниз?
14. В чем преимущества нисходящей технологии тестирования по сравнению с восхо-
дящей? В каких случаях, тем не менее, необходимо применять обе технологии?
15. Как тестировать трудоёмкость программы?
16. Какие требования предъявляются к эксплуатационной документации, и в чем со-
стоит ее тестирование?
17. Как минимизировать негативный эффект законов Брукса?
18. В чем состоят основные принципы организации бригады программистов?
19. Для чего необходимо независимое тестирование и как его организовать?
Литература
По языкам Паскаль и Си существует необъятное число книг и учебников.
Интересны книги автора языка Паскаль Н. Вирта [6, 13] и автора языка Си Д.
Ритчи [15]. Книга [22] посвящена языку Си++, который является объектно-
ориентированным развитием Си. Для практической работы на компьютере
требуются книги или руководства по конкретному транслятору, а также соот-
ветствующее программное обеспечение. Например, для работы с транслятора-
ми Free Pascal и Lazarus можно использовать учебник [2], а для работы с
транслятором Си – учебник [20]. На сайте [23] размещён транслятор Lazarus, а
на сайте [24] – транслятор Dev C++.
Основные идеи доказательства алгоритмов см. в книгах [1, 6, 9, 10, 11].
Проблемы технологии программирования обсуждаются в книгах [3, 4, 8, 14,
18], на сайте [25] размещена система StarUML для создания проектов про-
грамм на языке UML. О рекуррентных последовательностях и алгоритмах см.
в [1, 6, 7]. Об алгоритмах сортировки и поиска см. в [7, 17]. О рекурсивных
алгоритмах и бэктрекинге см в [1, 21]. Алгоритмы контекстного поиска име-
ются в [7, 17], Теория графов и алгоритмы с графами – в [12, 19, 21]. Вычисли-
тельные алгоритмы линейной алгебры – в [5].
Настоящая книга является существенно переработанным и дополненным
учебным пособием [16]. К книге прилагается комплект из 16 презентаций.

1. Абрамов С. А. Математические построения и программирование. – М.:


Наука, 1978. 192 с.
2. Алексеев Е. Р., Чеснокова О. В., Кучер Т. В. Free Pascal и Lazarus: Учебник
по программированию. – М.: Альт Линукс, ДМК Пресс, 2010. 440 с.
3. Брукс Ф. П. (мл.) Как проектируются и создаются программные комплексы.
Мифический человеко-месяц / Пер. с англ. – М.: Мир, 1979. 159 с.
4. Буч Г., Якобсон А., Рамбо Дж. UML. Классика CS. 2-е изд. / Пер. с англ. –
СПб.: Питер. 2006. 736 с.
5. Вержбицкий В.М. Основы численных методов. – М.: Высшая школа, 2002.
840 с.
6. Вирт Н. Систематическое программирование. Введение / Пер. с англ. – М.:
Мир, 1977. – 184 с.
Литература 255

7. Вирт Н. Алгоритмы + структуры данных = программы Пер. с англ. / Пер. с


англ. – М.: Мир, 1985. 406 с.
8. Гласс Р. Руководство по надежному программированию / Пер. с англ. – М.:
Мир, 1982. 280 с.
9. Грис Д. Наука программирования / Пер. с англ. – М.: Мир, 1984. 416 с.
10. Дал У., Дейкстра Э., Хоор К. Структурное программирование / Пер. с
англ. – М.: Мир, 1975. 245 c.
11. Дейкстра Э. Дисциплина программирования / Пер. с англ. – М.: Мир,
1978. 275 c.
12. Зыков А.А. Основы теории графов. – М.: Вузовская книга. 2004. 664 с.
13. Йенсен К., Вирт Н. Паскаль: Руководство для пользователя и описание
языка / Пер. с англ. – М.: Финансы и статистика, 1982. 151 с.
14. Йодан Э. Структурное проектирование и конструирование программ / Пер.
с англ. – М.: Наука, 1979. 410 с.
15. Керниган Б., Ритчи Д. Язык программирования Си / Пер. с англ. – М.: Фи-
нансы и статистика, 1992. 272 с.
16. Костюк Ю.Л. Основы программирования. Разработка и анализ алгоритмов:
учебное пособие. – Томск: Изд-во Том. ун-та. 2006. 244 с.
17. Кнут Д. Искусство программирования для ЭВМ: В 3 т. Т. 3: Сортировка и
поиск / Пер. с англ. – М.: Мир. 1978. 846 с.
18. Майерс Г. Искусство тестирования программ / Пер. с англ. – М.: Финансы и
статистика, 1982. 175 с.
19. Оре О. Теория графов. 2-е изд. / Пер. с англ. – М.: Наука, 1980. 336 с.
20. Подбельский В.В., Фомин С.С. Программирование на языке Си: Учеб. посо-
бие. – 2-е изд., доп. – М.: Финансы и статистика, 1999. 600 с.
21. Рейнгольд Э., Нивергельд Ю., Део Н. Комбинаторные алгоритмы. Теория и
практика / Пер. с англ. – М.: Мир, 1980. 476 с.
22. Страуструп Б. Язык программирования Си++ / Пер. с англ. – М.: Радио и
связь, 1991. 352 c.
23. https://lazarus-rus.ru
24. https://soft.mydiv.net/win/download-DEV-C.html
25. https://freeanalog.ru/StarUML
Оглавление
Предисловие........................................................................................................... 3
Лекция 1. Алгоритмы и программы. Тестирование. Аналитическая
верификация ............................................................................................ 5
1.1. Алгоритмы и программы ........................................................................ 5
1.2. Тестирование ........................................................................................... 8
1.3. Аналитическая верификация ................................................................ 12
1.4. Анализ трудоёмкости ........................................................................... 28
Вопросы и задания ........................................................................................ 31
Лекция 2. Рекуррентные алгоритмы .................................................................. 33
2.1. Рекуррентные последовательности и пределы.................................. 33
2.2. Другие рекуррентные алгоритмы ........................................................ 38
Вопросы и задания ........................................................................................ 43
Лекция 3. Поиск и сортировка ............................................................................ 45
3.1. Поиск в массиве ..................................................................................... 45
3.2. Простые алгоритмы сортировки........................................................ 48
3.3. Динамические массивы и случайные числа .......................................... 54
Вопросы и задания ........................................................................................ 57
Лекция 4. Рекурсия .............................................................................................. 60
4.1. Процедуры и функции............................................................................ 60
4.2. Рекурсивные алгоритмы ....................................................................... 63
4.3. Алгоритм сортировки слиянием .......................................................... 69
Вопросы и задания ........................................................................................ 74
Лекция 5. Списочные структуры ........................................................................ 76
5.1. Алгоритмы со списками........................................................................ 76
5.2. Поиск в списках...................................................................................... 80
5.3. Сортировка списков .............................................................................. 83
Вопросы и задания ........................................................................................ 86
Лекция 6. Бэктрекинг .......................................................................................... 88
6.1. Комбинаторные алгоритмы ................................................................ 88
6.2. Бэктрекинг с отсечением ..................................................................... 95
Вопросы и задания ...................................................................................... 101
Лекция 7. Множества ........................................................................................ 103
7.1. Задание множества двоичным массивом ........................................ 103
7.2. Задание множества целочисленным массивом ............................... 106
7.3. Задание множества списком номеров объектов ............................. 113
Литература 257

Вопросы и задания ...................................................................................... 120


Лекция 8. Символьные строки и таблицы ....................................................... 122
8.1. Алгоритмы обработки символьных строк ....................................... 122
8.2. Контекстный поиск в символьных строках .................................... 128
8.3. Информационные таблицы ................................................................ 135
Вопросы и задания ...................................................................................... 139
Лекция 9. Язык Си. Базовые понятия .............................................................. 141
9.1. Программа, типы данных, операции и операторы .......................... 141
9.2. Функции ................................................................................................ 149
Вопросы и задания ...................................................................................... 157
Лекция 10. Язык Си. Продолжение .................................................................. 158
10.1. Списки, файлы, стандартные функции .......................................... 158
10.2. Символьные строки ........................................................................... 164
10.3. Синтаксис Си .................................................................................... 169
Вопросы и задания ...................................................................................... 171
Лекция 11. Алгоритмы линейной алгебры. Векторы и матрицы .................. 173
11.1. Сложение и умножение векторов и матриц .................................. 173
11.2. Решение систем линейных уравнений .............................................. 178
Вопросы и задания ...................................................................................... 183
Лекция 12. Алгоритмы линейной алгебры. Продолжение ............................. 184
12.1. Алгоритмы с квадратными матрицами ......................................... 184
12.2. Другие алгоритмы с матрицами ..................................................... 188
Вопросы и задания ...................................................................................... 194
Лекция 13. Графы. Начало ................................................................................ 195
13.1. Представление графов в программе ................................................ 195
13.2. Просмотр неориентированного графа ........................................... 201
Вопросы и задания ...................................................................................... 206
Лекция 14. Графы. Простые алгоритмы .......................................................... 208
14.1. Поиск кратчайшего пути в лабиринте ........................................... 208
14.2. Вычисление топологической сортировки........................................ 213
14.3. Вычисление кратчайших расстояний во взвешенных графах ....... 217
Вопросы и задания ...................................................................................... 220
Лекция 15. Циклы и пути в графах................................................................... 222
15.1. Эйлеровы циклы и пути ..................................................................... 222
15.2. Гамильтоновы циклы и пути............................................................ 229
15.3. Задача коммивояжёра ...................................................................... 235
Вопросы и задания ...................................................................................... 237
258 Литература

Лекция 16. Технология программирования ..................................................... 239


16.1. Программы, комплексы программ и программные продукты ...... 239
16.2. Программная документация ............................................................ 241
16.3. Анализ, проектирование и кодирование .......................................... 242
16.4. Тестирование и отладка................................................................... 246
16.5. Организационные проблемы технологии программирования ....... 250
Вопросы и задания ...................................................................................... 253
Литература ......................................................................................................... 254

Вам также может понравиться