Министерство образования РФ Тверской государственный университет Кафедра информатики
А.В. Масюков
ЛЕКЦИИ ПО ИНФОРМАТИКЕ (Краткий конспект) Учебное пособие для студентов, обучающихся по специальностям «прикладная математика и информатика», «математические методы в экономике»
Тверь 2002
Настоящее пособие посвящено принципам программирования и базовым алгоритмам, а не конкретному языку или системе программирования (используется Borland Pascal для MS-DOS). Основное внимание в настоящем пособии уделяется вопросам, которые с трудом воспринимаются студентами. Для успешного прохождения курса необходимо запрограммировать и отладить решения упражнений настоящего пособия или 100 аналогичных задач.
СОДЕРЖАНИЕ
§1
ОСНОВНЫЕ ПОНЯТИЯ
4
§2
СИНТАКСИС, ПРОСТЫЕ ТИПЫ, ОПЕРАТОРЫ
7
§3
ПРОЦЕДУРЫ И ФУНКЦИИ
16
§4
СИСТЕМЫ СЧИСЛЕНИЯ, ФОРМАТЫ ДАННЫХ, БИТОВЫЕ ОПЕРАЦИИ
22
§5
ТИПЫ ARRAY, RECORD, STRING
29
§6
ХЕШИРОВАНИЕ
39
§7
НЕТИПИРОВАННЫЕ ПАРАМЕТРЫ, ПРИВЕДЕНИЕ ТИПА, УКАЗАТЕЛИ, ОПЕРАТОР ВЗЯТИЯ АДРЕСА, ДИНАМИЧЕСКИЕ МАССИВЫ
§8 §9
42
СТЕК, ПОСТФИКСНАЯ ЗАПИСЬ, БЫСТРАЯ СОРТИРОВКА, СЛОЖНОСТЬ ВЫЧИСЛЕНИЙ, МЕТОД ВЕТВЕЙ И ГРАНИЦ, РЕКУРСИЯ
47
ФАЙЛЫ. ВНЕШНИЕ СОРТИРОВКИ. HEAPSORT.
61
§10 BMP-ФАЙЛЫ. ЭЛЕМЕНТЫ КОМПЬЮТЕРНОЙ ГРАФИКИ.
74
§11 БЫСТРОЕ ПРЕОБРАЗОВАНИЕ ФУРЬЕ
82
§12 ДИНАМИЧЕСКИЕ СПИСКИ
87
§13 ДИНАМИЧЕСКИЕ ДЕРЕВЬЯ
92
§14 ПРОПЕДЕВТИЧЕСКИЙ ОБЗОР ЯЗЫКА СИ
98
ЛИТЕРАТУРА
101
3
§1 ОСНОВНЫЕ ПОНЯТИЯ Компьютер – это устройство для хранения, обработки и визуализации информации. Основные части компьютера: центральный процессор (CPU) и оперативная память (RAM – Random Access Memory – произвольного доступа). Процессор
имеет
инструкции
(команды) изменения значений ячеек памяти
(арифметических операций, сравнения, и т.д.). Адрес (иначе, указатель) – это номер ячейки памяти (как правило, относительно некоторого стартового адреса). Процессор устроен так, что считывает и выполняет инструкцию, находящуюся по адресу, который хранится на специальном регистре процессора (instruction pointer). При этом значение instruction pointer увеличивается на размер инструкции, если очередная команда не является командой перехода (goto), изменяющей значение instruction pointer специально. Содержимое оперативной памяти теряется при выключении компьютера, для хранения информации используются внешние носители: магнитные и оптические диски. Чтение-запись диска означает копирование информации между диском и RAM. Как правило, диски организованы иерархично – как деревья папок (folder, directory, catalogue), листьями являются файлы – именованные области данных диска. CPU способен посылать сообщения периферийным процессорам (диска, клавиатуры, и т.д.). Контроллер диска может непосредственно читать-писать RAM. Графическую плату (карту) можно рассматривать, как самостоятельный (специализированный) компьютер со своим процессором (графический процессор может быть мощнее центрального) и своей памятью, содержимое которой отображается на экране монитора. Компьютер может иметь различную периферию и аксессуары (коврик для мыши, тапочки для таракана). Программа – это 1) файл, содержащий инструкции процессора, которые выполняются после загрузки в память, или 2) исходный текст на некотором языке программирования. Компилятор – это программа трансляции (перевода) исходного текста программы в выполняемый файл (инструкции процессора). Операционная система (ОС) – это программа, которая поддерживает файловую систему дисков и управляет процессами (загружает другие программы). Любая ОС имеет консоль, на которой отображается команда ОС, набираемая на клавиатуре. Имеются команды просмотра содержимого папки, копирования файлов, запуска программ. Некоторые
4
юзеры могут пользоваться только графическим интерфейсом (надстройкой ОС) и никогда не видеть консоль. Программа, скомпилированная для одной платформы, не может выполняться на другой: процессоры имеют различные наборы инструкций. На одном компьютере могут быть две ОС, но каждая имеет свою файловую систему и свой формат выполняемых файлов. Некоторая переносимость программ имеется только на уровне исходных текстов. Следует различать стандарт языка программирования (системнонезависимый) и его конкретную реализацию (компилятор). В курсе информатики мы используем Borland Pascal для MS-DOS (далее – BP), являющийся расширением стандарта языка Паскаль (автор – Н. Вирт). BP позволяет изучать основные принципы программирования не тратя много времени на изучение среды программирования (очень простой). Считается, что разработка программного продукта в незнакомой среде (и на новом языке программирования) требует от настоящего программиста всего на 10% больше времени. Программа на языке ассемблера состоит из инструкций процессора (в символьной записи). Программа на языке высокого уровня (Паскаль, Си) состоит из операторов, каждый из которых компилируется в последовательность инструкций. (Разные
компиляторы
выдают
различный
код.)
Операторы
применяются
к
переменным, которые могут принимать числовые значения или быть более сложного типа. Например, файловые переменные являются системно-зависимыми структурами, которые могут оставаться неизвестными для программиста высокого уровня. Переменными языков сверхвысокого уровня могут быть, например, системы дифференциальных уравнений (Maple, MatLab). Переменная суть адрес того места, где хранится ее значение. Точнее, программа-транслятор для каждой переменной должна хранить
кортеж:
(идентификатор,
адрес,
тип).
Тип
переменной
определяет
применимые к ней операции, и ее формат: сколько байтов, начиная с адреса переменной, занимает ее значение и как оно закодировано. Некоторые задачи можно решать на языках высокого уровня, не зная системно-зависимых форматов, или внутреннего представления данных. Однако, рассмотрение примеров форматов необходимо для понимания работы системных программ и компьютера в целом. Идентификатор, или имя переменной, есть последовательность букв и цифр,
5
начинающаяся с буквы. Это определение можно представить в виде синтаксической диаграммы: идентификатор
буква буква цифра
Другим способом формализации синтаксиса являются формы Бэкуса-Наура (БНФ), например: идентификатор ⇒ буква [буква | цифра] Здесь квадратные скобки означают то, что может повторяться (возможно, ноль раз), вертикальная черта – или. Компилятор Паскаля не различает строчные и прописные буквы. Рекомендуется давать переменным имена вроде IndexOfRow, IndexOfColumn вместо i и j, для ускорения разработки программ.
6
§2 СИНТАКСИС, ПРОСТЫЕ ТИПЫ, ОПЕРАТОРЫ В языке Паскаль все переменные должны быть объявлены заранее в блоке описаний, который начинается с зарезервированного слова var (от слова variables – переменные) и вслед за каждым списком переменных, разделенных запятыми, следует двоеточие и тип этих переменных. Программа начинается с begin и заканчивается end с точкой, между begin и end программы находятся операторы, разделяемые точкой с запятой. Простейшая паскаль-программа представляется в виде синтаксической диаграммы: Простая программа
var
идентификатор
:
оператор
;
real
,
begin
integer
end
.
;
Если двигаться в диаграмме по стрелкам (выбирая любой вариант разветвления), мы получим синтаксически правильную программу. Несколько десятков синтаксических диаграмм однозначно определяют грамматику языка программирования (и задачу компилятора). Синтаксически правильная программа может содержать ошибки, не обнаруживаемые компилятором. Считается, что тестирование (обнаружение ошибок) и отладка (их локализация и исправление) занимают половину времени разработки. При этом самые распространенные (107 копий) и самые дорогие (107 USD) программные продукты (ПП) не свободны от жучков (bugs). В приведенной выше диаграмме предполагается существование всего двух типов: integer (целые) и real (вещественные, или с плавающей точкой, могут хранить дробные числа). Синтаксис оператора присваивания: Оператор присваивания
идентификатор
:=
выражение
подчеркивает, что это не математическое равенство – во время работы программы вычисляется выражение, стоящее в правой части (с использованием значений переменных, входящих в выражение), и результат вычислений записывается по адресу 7
переменной, стоящей в левой части оператора. При этом тип выражения должен совпадать, или быть совместимым с типом этой переменной. В языке Паскаль целой переменной нельзя присвоить выражение типа real, и результат деления целых выражений имеет тип real. Для целочисленного деления имеется операция div (деление без остатка), mod – остаток от деления нацело. Рассмотрим пример программы с ошибками (фигурных скобки {} для комментариев): var i,j : integer; {Блок описания переменных} x:real; begin {Начало программы} i:=3.14;
{ошибка несоответствия типов}
readln(x);
{программа будет продолжена после ввода числа}
x:=3.14; i:=x;
{ошибка}
i:=trunc(x);
{i получит значение 3, отбрасывание дробной части}
i:=4/2;
{ошибка, 4/2 есть 2.0 (в языке Паскаль)}
i:=5 div 2; j:=5 mod 2; x:=i; x:=x*x; i:=i+1; { в выражении может присутствовать переменная, стоящая слева от := } k:=1;
{ошибка, переменная k не объявлена}
writeln(‘i=’, i:2, ‘j=’, j:2, ‘x=’, x:5:2, ‘Press Enter’); readln;
{оператор ввода без списка ввода}
end. {Конец программы} Текст, который в операторе вывода writeln находится в апострофах, просто копируется на экран (проверьте), а значения переменных списка вывода преобразуются в символьную десятичную запись. На первом этапе изучения информатики важно понять, что компьютерная программа в конечном счете состоит из элементарных действий и искусство алгоритмизации состоит в сведении задачи к последовательности имеющихся операций. Например, поиск нужного слова в тексте состоит в сравнении каждого слова
8
данным, пока не будет совпадения или текст не кончится. Оператор сравнения (или условный оператор) if есть основа логики программы. Его синтаксис:
Условный оператор
if
Логическое выражение
then
оператор else
оператор
Заметим, что else (иначе) может отсутствовать и перед else разделитель (точка с запятой) не ставится. В качестве логического выражения могут выступать пересечения (and) и объединения (or) равенств и неравенств. Например, if (i mod 2 <>0) or (i<=j) then i:=i+1; {увеличить, если i нечетное или не больше j (в обоих случаях)} if (x=y) and (y=z) then writeln(‘все равны’) else writeln(‘не все равны’); Обратите внимание, что в языке Паскаль логические операции имеют более высокий приоритет, чем сравнения, поэтому здесь необходимы скобки для указания последовательности операций. Разделение исходного текста паскаль-программы на строки (где переход на новую строку) не влияет на ее компиляцию. Упражнение. Разберитесь, как работают следующие фрагменты и расположите операторы по строкам в соответствии со смыслом (более читабельно): 1) if a>b then b:=a; writeln(a,b); 2) if i=j then if j>k then x:=2 else x:=3; Итак, оператор if означает проверку условия, в зависимости от истинности которого происходит выполнения одной или другой ветки программы. В каждой ветке может быть много операторов. Структурированные языки программирования (к которым относится язык Паскаль) основаны следующем принципе: всюду (в том числе в синтаксической диаграмме if) в качестве оператора может стоять составной оператор, являющейся последовательностью операторов, заключенных в операторные
Оператор
простой оператор составной оператор
Простой оператор
:= if …..
9
скобки (которыми являются begin и end в языке Паскаль): Составной оператор
begin
оператор
end
;
Структура программы отображается в исходном тексте отступами. Стиль отступов может быть различным, например, if ……. then begin …… end else {под then} begin …… …… end; или if …… then begin …… end else begin …… …… end; Главное, отступы в исходном тексте показывают вложенность, которая может быть также изображена в виде дерева: A B D E
A
⇔
C
F
B
C
G
F G D
E
10
Если бы не было циклов, скорость процессора была бы бесполезна – мы не можем писать 106 операторов в секунду. А с помощью вложенных циклов лаймер (lame under the hat) может загрузить самый мощный процессор бессмысленными действиями надолго (если не навсегда). Основным оператором цикла является while (пока), его диаграмма такова: Оператор цикла
Логическое выражение
while
do
оператор
Тело цикла (то есть оператор, стоящий после do; обычно это составной оператор) выполняется повторно, пока условие, стоящее после while, истинно. Например, while TRUE do; есть бесконечный цикл (тело цикло – пустой оператор, а условие цикла – логическая константа ИСТИНА). Следующий цикл i:=0; {инициализация переменной (счетчика) цикла} while i<24 do begin writeln(i); i:=i+1; end; выводит на экран числа от 0 до 24 (по одному в строке). Тело цикла while 1>2 do не будет выполняться ни разу. Условие while проверяется перед выполнением тела цикла. Действие цикла можно изобразить с помощью следующей схемы:
НЕТ
Условие цикла истинно?
ДА
Тело цикла
11
Хотя в языке Паскаль имеется также цикл for, цикл while является основным (используется в 90% случаев). Поэтому я советую на этапе изучения языка пользоваться только while. Другая рекомендация состоит в отказе от использования операторов continue (возврат на проверку условие цикла) и break (выход из цикла), которые отсутствуют в стандарте языка и заимствованы в BP из Си. Конечно, в некоторых (простейших случаях) конструкция for удобнее, но надо научиться сочинять сложные условия цикла. Операторы continue и break очень сильны, но их лучше оставить на случай переструктурирования (усложнения) программы. Следующая программа находит наибольший общий делитель двух чисел, используя наивный алгоритм (вычитание 1 пока оба числа – и одно и другое – не разделятся без остатка): var x,y,z: integer; begin writeln(‘введи два числа’); readln(x,y); if x
0) or (y mod z <>0) do z:=z–1; writeln(‘НОД для ’, x, y, ’ равен ’, z); readln; end. Упражнение. Составьте программу нахождения НОД по алгоритму Eвклида. При назначении условия цикла иногда удобно воспользоваться следующими правилами: while {условие (продолжения) цикла} эквивалентно while not {условие выхода из цикла} not (A and B) эквивалентно not A or not B not (A or B) эквивалентно not A and not B Для расстановки скобок надо учитывать, что отрицание not имеет высший приоритет, а логическое умножение and приоритетнее логического сложения or. В предыдущем примере условие выхода из цикла (нахождения общего делителя) есть (x mod z =0) and (y mod z =0), и условие цикла можно было написать исходя из первых двух правил. Несомненно, таблицы истинности для and и or любой студент(ка) ПМК может
12
нарисовать с закрытыми глазами. Переменные и выражения типа boolean могут принимать значения только TRUE и FALSE. Например, var a,b: boolean; x: integer; ….. a:=(x>1) and b; Упражнение. Составьте синтаксические диаграммы арифметических и логических выражений. Проверьте, что диаграммы порождают только допустимые выражения и все допустимые выражения. Переменные типа char (символьный, или литерный тип) принимают значения символов. На самом деле символы хранятся в памяти компьютера как их коды, в соответствии с той или иной кодировкой символов, а изображения символов – это уже другая песня. Переход от символа к его коду осуществляет функция ord, а обратный переход – функция chr. Следующая программа выводит символы в соответствии с их кодировкой: var i:integer; begin writeln('Чтобы получить код символа, сложите числа строки и столбца'); i:=0; write('
');
while i<16 do begin write(i:3); i:=i+1; end; writeln; i:=32; while i<256 do begin if i mod 16 = 0 then write(i:4); write(chr(i):3); if i mod 16 = 15 then writeln; i:=i+1; end; readln; end. Вот часть этой таблицы:
13
0 32
1
2
3
4
5
6
7
8
9
10 11 12 13 14 15
!
"
#
$
%
&
'
(
)
*
+
,
-
.
/
48
0
1
2
3
4
5
6
7
8
9
:
;
<
=
>
?
64
@
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
80
P
Q
R
S
T
U
V
W
X
Y
Z
[
\
]
^
_
96
`
a
b
c
D
e
f
g
h
i
j
k
l
m
n
o
112
P
q
r
s
T
u
v
w
x
y
z
{
|
}
~
128
А
Б
В
Г
Д
Е
Ж
З
И
Й
К
Л
М
Н
О
П
144
Р
С
Т
У
Ф
Х
Ц
Ч
Ш Щ
Ъ
Ы
Ь
Э
Ю
Я
160
А
б
в
г
Д
е
ж
з
и
й
к
л
м
н
о
п
224
Р
с
т
у
Ф
х
ц
ч
ш
щ
ъ
ы
ь
э
ю
я
Символы с кодами < 32 считаются управляющими. Например, символ chr(13) является разделителем строк текстового файла. Первая половина таблицы стандартна (ASCII), вторая зависит от страны. Естественно, другая ОС может иметь другую кодировку, но цифры и латинские буквы всегда расположены в естественном порядке. Поэтому переносимо следующее выражение, преобразующее число x от 0 до 9 в символ, являющийся соответствующей десятичной цифрой: Var
x:0..9; {тип – диапазон} c:char;
…………. c:=chr( x+ord('0') ); Преобразование заглавной латинской буквы в малую запишем так: c:char;
…………
if (c>='A') and (c<='Z') then c:=chr( ord(c) + ord('a') – ord('A') ); Константы являются примитивными макроопределениями. Это означает, что вместо константы на этапе компиляции просто подставляется ее значение. В BP есть также типированные
константы,
которые
на
самом
деле
не
константы,
а
инициализированные переменные. Например, объявление const x:integer=0;
14
означает выделение памяти под переменную x и запись в эту ячейку начального значения. Следует считать, что неинициализированные переменные при старте программы содержат мусор.
15
§3 ПРОЦЕДУРЫ И ФУНКЦИИ Программа объемом до 10000 операторов считается малой, до 100000 – средней. Понятно, что строить большие программы непосредственно из элементарных операций (хотя бы и языка высокого уровня) было бы невозможно. Для упрощения разработки программ синтаксис языка высокого уровня предполагает подпрограммы (процедуры и функции в терминах языка Паскаль). Использование подпрограмм соответствует строительству здания из панелей и блоков, а не отдельных кирпичей. Подпрограмма состоит из заголовка (объявления), блока описания локальных констант, типов и переменных, и тела, заключенного в операторные скобки. Заголовок процедуры имеет следующий синтаксис: Заголовок процедуры
procedure
имя
имя параметра
(
:
тип
)
var , ;
Смысл заголовка состоит в том, что: 1) процедура получает имя, по которому она будет вызвана (сколько угодно раз, с возвращением в то место программы, которое следует за вызовом); 2) перечисляются формальные параметры (и их типы), вместо которых (в правильном порядке) при вызове следует подставить фактические параметры (выражения или переменные программы). С точки зрения использования подпрограмма является «черным ящиком», который по входным параметрам вычислят выходные. Выходные параметры помечаются в заголовке как var. Например, следующей процедуре не требуются локальные переменные: procedure MinMax(a,b,c:integer; var min,max:integer); {процедура находит меньшее и большее из чисел a,b,c} begin if a>b then begin max:=a; min:=b; end else begin max:=b; min:=a; end; if c>max then max:=c;
16
if c<min then min:=c; end; {of MinMax} Пример вызова этой процедуры: MinMax(i, 2, j+k, m1, m2); writeln(m1,m2); При этом переменным программы m1 и m2 (фактическим параметрам) присваиваются значения: наименьшее и наибольшее из значений целых выражений i, 2, j+k. Заголовок и тело процедуры расположено в блоке описаний программы (до ее begin). Параметры, объявленные в заголовке как var, называются параметрами-переменными, в отличие от остальных — параметров-значений. Параметры-переменные передаются по адресу, то есть в стек процедуры помещаются не значения, а адреса. Поэтому параметры-переменные, в отличие от параметров-значений, процедура может изменить (имея адрес), и по смыслу это выходные параметры. Фактические параметры-значения процедура изменить не может, даже если в ее теле происходят изменения соответствующего
формального
параметра.
Понятно
также,
что
фактическим
параметром-переменной не может быть константа или выражение (только переменная, отсюда и название). Упражнение. Что измениться, если заголовок (не тело) процедуры MinMax записать так: procedure MinMax(var a,b,c,min,max:integer);? Упражнение. Напишите процедуру Inc(var i:integer); заменяющую присваивание i:=i+1. (Такая процедура уже есть в BP, но наши подпрограммы «закрывают» стандартные подпрограммы (объявленные во внешних модулях) с теми же именами.) Если процедура имеет единственный выходной параметр, например, procedure power(x,y:real; var result:real); {вычисляет степень y положительного x} begin if x>0 and then result:=exp( y*ln(x) ) else result:=0; end; то ее удобнее оформить как функцию: function power1(x,y:real): real; {вычисляет степень y положительного x}
17
begin if x>0 and then power1:=exp( y*ln(x) ) else power1:=0; end; Теперь в программе можно написать, например, z:=power1(a,0.5); Может быть, в некоторых случаях удобнее иметь следующую функцию: function power2(x,y:real; var result:real):boolean; {вычисляет степень y положительного x} begin power2:=true; if (x=0) and (y>0) then result:=0 else if x>0 and then result:=exp( y*ln(x) ) else power2:=false; end; Теперь в программе можно совместить вычисление с проверкой корректности аргументов, например, if not power2(a,b,c) then writeln('power error',a,b) else {степень уже вычислена, лежит в переменной c} ….. Как мы видим, нет принципиального различия между процедурами и функциями (только не надо путать их синтаксис). Стандартные математические функции тоже появляются не святым духом. Например, синус можно вычислять как частичную сумму ряда: sin x = x −
k =∞ x3 x5 x7 x 2 k −1 k +1 + − + ... = ∑k =1 (− 1) (2k − 1)! 3! 5! 7!
причем ошибка (сумма «хвоста» ряда) не превышает по абсолютной величине первого из отброшенных слагаемых. Для этой функции уже потребуются локальные переменные. function sin(x:real):real; var i,factorial:longint;
{локальные переменные}
x2,eps,sum:double; {двойная точность} SinGreaterZero,plus:boolean;
18
begin SinGreaterZero := x>=0; x:=abs(x);
{ниже определим знак синуса}
{абсолютная величина аргумента, знак запомнили выше}
i:=trunc(x/(2*Pi)); {округление} x:=x–2*Pi*i;
{вычтем период}
if x>=Pi then begin SinGreaterZero := not SinGreaterZero; x:=x–Pi; end; if x>Pi/2 then x:=Pi–x; x2:=x*x;
{инициализация цикла суммы}
i:=1; factorial:=1; plus:=true; sum:=0; eps:=x; while eps>1e–10 do begin {цикл суммирования, с точностью до 10 знаков} if not plus then eps := –eps; sum:=sum+eps; x:=x*x2; factorial:=factorial*(i+1)*(i+2); i:=i+2; eps:=x/factorial;
{новое слагаемое}
plus:=not plus;
{его знак}
end; {while} sin:=sum;
{возвращаемое значение}
if not SinGreaterZero then sin := –sum; end; {sin} Заметим, что имя функции используется в ее теле слева от присваивания для назначения возвращаемого значения. Использование имени справа от присваивания означает ее вызов. Так, в функции sin нельзя заменить переменную sum на sin, иначе получится рекурсия, в данном случае бессмысленная. Упражнение. Нарисуйте синтаксическую диаграмму «функция».
19
Упражнение. Напишите функцию (процедуру), которая возвращает первое простое число после данного числа. Вложенность процедур и функций − сильная и красивая сторона языка Паскаль. Это означает, что в блоке описаний любой процедуры или функции (а не только программы) могут быть описаны ее локальные процедуры и функции (вместе с локальными константами, типами и переменными). При этом локальная процедура «видит» блок описания подпрограммы (программы), в которой она объявлена (все идентификаторы блока доступны). Более того, все идентификаторы (типы, переменные, подпрограммы),
которые
«видит»
подпрограмма,
доступны
ее
локальным
подпрограммам (процедурам и функциям). Рассматривая вложенность подпрограмм как дерево, можно сказать, что подпрограмма «видит» блоки описания всех родительских подпрограмм (в том числе программы, то есть глобальные переменные и подпрограммы), и только их. Причем, при совпадении идентификаторов (переменных или подпрограмм) «видимыми» оказывается элементы, расположенные ближе к обращающейся подпрограмме (дальше от корня дерева). В частности, локальные переменные «закрывают» глобальные. Например, если вложенность процедур A, B, C, D, E, F соответствует следующему рисунку, то подпрограмма D может вызывать A, B, C, но не E или F. Подпрограмма D
program
может
А
B C
использовать
глобальные
переменные и локальные переменные A, но не B, C, E, F.
E F
Упражнение.
Изобразите
вложенность этого рисунка в виде D
дерева.
Какие
подпрограммы
и
переменные может использовать F? Хорошим стилем является отказ от использования в подпрограмме внешних переменных. Если подпрограмма «общается» с внешним миром только с помощью передачи параметров, ее можно легко применить даже в Африке. Заметим, что в языке Си нет вложенности функций, поэтому вложенность подпрограмм не следует использовать, если иметь в виду возможность переноса алгоритма на Си. Тогда остается разделение программы на модули (исходный
20
текст − в нескольких файлах). Для организации модулей в BP смотрите help по ключевым словам uses, interface, implementation.
21
§4 СИСТЕМЫ СЧИСЛЕНИЯ, ФОРМАТЫ ДАННЫХ, БИТОВЫЕ ОПЕРАЦИИ. Мы не сможем заменить writeln своей процедурой, потому что writeln − это не процедура (хотя обращение похоже), а оператор. Оператор вывода writeln преобразует значения выражений любого типа в символьную запись, «заглядывая» в блоки описания перемннных (этого процедура делать не может). Для каждого типа мы можем написать свою процедуру вывода, использующую только операцию вывод символа. Например, procedure PrintInteger(x:integer); {вывод integer в десятичной записи} const TopDigit=10000; {BP integer не более 5 десятичных цифр} var digit:integer; begin if x<0 then begin write(‘−’); x:=−x; end; digit:=TopDigit; while digit>0 do begin write( chr( ord(‘0’)+x div digit ) ); {цифра старшего (из оставшихся) разряда} x:=x mod digit;
{остаток − младшие цифры}
digit:=digit div 10;
{к следующей цифре}
end; writeln; end; Упражнение. Измените эту процедуру так, чтобы не выводились ведущие нули. Система счисления по основанию n имеет n цифр: числа 0.. n−1 являются однозначными (записываются одной цифрой). При любом натуральном x разложение по степеням n x=c0n0+ c1n1+ c2n2+ c3n3+… единственно (докажите). И коэффициенты c этого разложения являются цифрами числа x в n-ричной системе. Упражнение. Выполните процедуру вывода целого в системе счисления по основанию n (n есть второй параметр процедуры).
22
В двоичной системе всего 2 цифры: 0 и 1. Двоичная цифра − это один бит (единица измерения) информации. Байт (в настоящее время) состоит из 8-ми битов. Поэтому байт может принимать 28=256 значений. Максимальное значение беззнакового байта 1111,1111B=255. Суффикс B означает двоичную запись (binary), полубайты (для наглядности) иногда отделяют запятыми. Биты обычно нумеруют по степеням двойки, младший бит − нулевой. Память компьютера состоит из байтов. Переменная может занимать только целое число байтов. Адрес переменной − это номер байта (если адресуется каждый байт). BP имеет несколько целых типов (совместимых между собой): byte − беззнаковый байт, диапазон 0..255, word − двухбайтное целое без знака, диапазон 0..216−1, shortint − знаковый байт, диапазон −128..127, integer − двухбайтное знаковое целое, диапазон −215..215−1, longint − 4-х-байтное знаковое целое, диапазон −231..231−1. Для измерения информации (объема данных) используют и более крупные единицы: 1К=210=1024 байт, 1М=210К, 1Г=210М, и т.д. Для знаковых целых используется дополнительная кодировка. Это означает, что знаковый байт (shortint), принимающий значение от 0 до 127 хранится так же, как беззнаковый, а отрицательный байт x ( − 128 ≤ x < 0 ) хранится как беззнаковый байт 256+ x. Например, − 3 ↔ 256 − 3 = 253 = 1111,1101B . Дополнительная кодировка придумана для того, чтобы знаковые целые складывались (вычитались) так же, как беззнаковые. Например, 1111 , 11 0 1B + 0000,0 1 00 B −3+ 4 = = 1. 1,0000,000 1B В этом примере при сложении вторых битов происходит перенос в третий, и так далее. Единица, выходящая за разрядную сетку (байта), теряется. Таким образом, процессор имеет одну инструкцию для сложения, но для умножения (деления) знаковых и беззнаковых целых требуются различные инструкции. Битовые операции в языке Паскаль записываются так же, как логические (но применяются к целым выражениям). Битовые not, and, or применяются независимо ко всем битам операндов, как логические (ведь бит имеет два значения). Например,
23
0000,00 11B and 0000,0 1 0 1B = 1, 3 and 5 = 0000,000 1B
0000,00 11B or 0000,0 1 0 1B 3 or 5 = =7. 0000,0 111B
Чтобы проверить, включен ли k-тый бит, надо сделать and с числом (иногда говорят − с маской) 2k. Чтобы включить k-тый бит (сделать равным 1, не изменяя остальные) надо выполнить or с той же маской. Чтобы выключить k-тый бит надо сделать and с not 2k. Битовые операции shl (SHift Left) и shr (SHift Right) означают битовый сдвиг и эквивалентны умножению (делению) на степени двух (но выполняются быстрее). Битовые операции быстрее даже сложения-вычитания. Напишем процедуру вывода значения байта в двоичной записи: procedure PrintB(x:byte); {вывод байта в двоичной записи} const TopBit=128; {включен только старший бит} var mask:byte; begin mask:=TopBit; while mask>0 do begin if x and mask = mask then write('1') else write('0'); mask:= mask shr 1; end; {переход к следующему биту} writeln('B'); end; В 16-ричной системе − 16 цифр: 0..9,A..F. 16-ричные константы в BP записываются с префиксом $. Например, $F=15, $FF=255, $1A3=256+10*16+3. Одна 16-ричная цифра содержит 4 бита, 2 цифры − байт. Поэтому 16-ричная система так любима программистами. Вывод в 16-ричной системе происходит через битовые операции: procedure PrintH(x:word); {16-ричная запись 2-х-байтного числа} var digit,DigitNum:byte; begin for DigitNum:=3 downto 0 do begin digit:=( x shr (DigitNum*4) ) and $F; if digit<10 then write( chr( ord('0')+digit ) ) else write(chr(ord('A')+digit–10)); end;
24
writeln('H'); end; Следует запомнить, что для процессоров персональных компьютеров (PC) старший байт 2-х-байтного слова имеет больший адрес, как и старшее 2-х-байтное слово в 4х-байтном слове. То есть байты внутри чисел расположены в обратном порядке (по отношению к чтению слева направо). Упражнение. Процедура включает или выключает заданный бит. Упражнение. Функция возвращает word по старшему и младшему байтам. Упражнение. Процедура переставляет местами старший и младший байты параметра типа word. Упражнение. Процедура переставляет местами старший и младший полубайты байта. Упражнение. Процедура изменяет знак параметра типа integer, используя битовые операции. Упражнение. Процедура возвращает два 12-битных числа, передаваемых в трех байтах. Важно понимать, что не существует двоичных, 16-ричных или 10-тичных чисел. Число есть аксиоматическое понятие. Двоичной или 10-тичной может быть запись числа, его символьное представление. Следует считать, что переменные программы содержат сами числа, безотносительно к системе счисления. То, что аппаратное представление чисел связано с двоичной системой, означает только то, что битовые операции выполняются быстрее, а диапазоны значений типов определяются тем, что переменная занимает целое число байтов. Оставшуюся часть параграфа (формат данных с плавающей точкой) при первом чтении можно пропустить. Знак переменной с плавающей точкой (в частности, типа real) определяется старшим битом (старшего байта). Если этот бит включен − число отрицательное. Остальные биты поделены между порядком (степенью двойки) и мантиссой, хранящейся в виде двоичной дроби (каждый бит означает присутствие соответствующей положительной степени 1 2 ). Имеющийся в BP 6-ти байтовый тип real не является стандартным. Это означает, что file of real, созданный программой, скомпилированной в BP, нельзя будет так же легко прочитать программой, созданной другим компилятором. Поэтому разберем детально 4-х-байтный формат single, соответствующий стандарту IEEE. Чтобы использовать типы single и double в BP, надо в начало программы поставить директиву {$N+} использования сопроцессора. Формат 25
IEEE 4-х-байтного с плавающей точкой устроен так (направление возрастания адреса дано для PC):
мантисса { 1 4 2 4 3m порядок 1 4 2 43 e знак биты 0..23
биты 24..30
бит 31
→ возрастание адреса →
Если порядок 0 < e < 255 , то абсолютная величина числа определяется по формуле
2 e −127 ⋅ (1.m ) . Ведущая единица двоичной дроби (1.m ) не храниться в мантиссе. Если e = 0 и m ≠ 0 , то число = ±2 e −126 ⋅ (0.m ) , в зависимости от старшего бита, этом случае ведущей 1-цы нет. Если e = 0 и m = 0 , то число = ±0 . Если e = 255 и m = 0 , то число = ±∞ . Если e = 255 и m ≠ 0 , то эта ошибка называется NaN (Not a Number). Некоторые компиляторы предусматривают, что результат выражения, содержащего NaN, есть тоже NaN, но аварийного завершения программы не происходит. Диапазон single 2 ±127 ~ 10 ±38 . Однако, в мантиссе не более 25 двоичных цифр, следовательно, в десятичной записи не более 7 цифр (правильных). Если этой относительной точности недостаточно, следует использовать тип double (8-ми-байтный IEEE). Процессор, понятно, не может знать, что по смыслу находится в конкретном байте: код символа, часть целого числа или часть числа с плавающей точкой. Исключительно важной является операция приведения типа (хотя она отсутствует в стандарте языка Паскаль). Следующий фрагмент {$N+} var
x:longint; y:single;
………. x:=longint(y); означает, что в переменной x окажутся те же байты, что были в y (конечно, будет x ≠ y ). Другое важнейшее расширение стандарта языка Паскаль, осуществленное в BP,
заключается в допущении нетипированных параметров-переменных. Это означает, что в подпрограмму передается (как для параметров-переменных) адрес параметра, но тип параметра не указывается, и подпрограмма может взять переменную любого типа с этого адреса, используя приведение типа. Следующая процедура складывает два 4-х-
26
байтных числа с плавающей точкой, не используя (!) операции с плавающей точкой (программная эмуляция инструкции процессора). procedure AddFloat(var a,b,c); {c:=a+b, var
как операция над 4-х-байтными числами с плавающей точкой}
e1,e2,e:byte;
m1,m2,m:longint;
begin e1:=( longint(a) shr 23 ) and $FF;
{порядки}
e2:=( longint(b) shr 23 ) and $FF; if (e1=255) or (e2 =255) then longint(c):=$FFFFFFFF {NaN} else begin {not NaN} if e2>e1 then begin
{обмен, чтобы было a>b (по порядку)}
m:=longint(b); longint(b):=longint(a); longint(a):=m; e:=e2; e2:=e1; e1:=e; end; m1:=longint(a) and $007FFFFF;
{мантисса}
if e1>0 then m1:=m1 or $00800000
{учтем ведущую 1-цу}
else m1:=m1 shl 1; m2:=longint(b) and $007FFFFF; if e2>0 then m2:=m2 or $00800000 else m2:=m2 shl 1; for i:=1 to e1–e2 do m2:=m2 shr 1; {привели меньшее число к порядку большего} if longint(a) and $80000000 <> 0 then m1:= –m1;
{знаки}
if longint(b) and $80000000 <> 0 then m2:= –m2; m:=m1+m2;
{теперь можем сложить мантиссы}
if m<0 then begin longint(c):=$80000000; m:= –m; end;
{знак суммы}
{если при сложении был перенос в старший разряд (бит 25)} if m and $01000000 <> 0 then begin e1:=e1+1; m:=m shr 1; end; if m = 0 then e1:=0 {zero} else begin while m and $00800000 = 0 do begin
{сделаем ведущую 1-цу}
if e1=0 then begin { ведущей 1-цы не получается} m:=m shr 1; break; end; m:= m shl 1; e1:=e1–1; end; m:=m and $FF7FFFFF;
{ убьем ведущую 1-цу }
27
end; {not zero} longint(c):=longint(c) or m or longint(e1) shl 23; {результат} end;
{not NaN}
end; Упражнение. Перепишите функцию AddFloat, обеспечивая максимальную точность результата. Упражнение. Функция вычисляет число формата single по его байтам, не используя приведение типа.
28
§5 ТИПЫ ARRAY, RECORD, STRING Если циклы необходимы для использования процессора, то переменные с индексами, или массивы, − для использования памяти (RAM). В самом деле, зачем нам мегабайты памяти, если мы не можем заполнить ее своими переменными? Тип-массив объявляется как array[тип индекса (перечислимый)] of тип элементов массива Тип индекса, как правило, есть тип-диапазон целого типа. Например, объявление const n=10; var x:array[0..n–1] of real; означает выделение памяти под 10 переменных типа real, обращаться к которым можно как x[i], где i есть выражение целого типа, принимающее значение от 0 до 9. В объявлении
диапазона
индекса
могут
использоваться
только
константы
(не
переменные), так как это статическое выделение памяти – на этапе компиляции. В MS-DOS общий размер переменных модуля не более 64К, но в «более настоящих» ОС ограничений на размеры массивов практически нет. В языке Паскаль возможно и такое объявление: var x:array[‘a’..’z’] of integer; но я бы рекомендовал нумерацию индексов, и всегда (за редкими исключениями) с нуля, как в первом примере. Тогда значение индекса соответствует смещению от адреса начала массива, и программа легче переносится на язык Си, где нумерация элементов разрешена только с нуля. В языке Паскаль разрешено присваивание a:=b, если переменные a и b есть массивы одного типа (объявлены с одним именем типа). Наверное, не стоит отказываться от этой возможности, но следует понимать, что на самом деле происходят поэлементные операции, и при переносе на Си такие присваивания придется заменить подпрограммами. В объявлении подпрограммы, по правилам языка Паскаль, не может быть написано array[что-то там] of чего-то – в заголовке подпрограммы можно указывать только имя типа. В подпрограмму всегда передается адрес массива, а не значения его элементов. Если массив является параметром-значением, то при вызове подпрограммы создается копия фактического параметра, и подпрограмме передается адрес этой копии (почему?). Это значит, что большие массивы следует делать параметрами-переменными (зачем?), даже если они
29
не являются, по смыслу, выходными параметрами. Следующая процедура копирует заданное число (начальных) элементов из одного массива в другой: type T=array[0..n–1] of integer;
{объявление типа}
procedure ArrayCopy(var a:T; b:T; count:integer); {процедура копирует count элементов из массива b в массив a} { в заголовке имя типа, а не array[…] !!!} begin while count>0 do begin a[count–1]:=b[count–1]; count:=count–1; end; end; { ArrayCopy} Задача поиска заключается в нахождении среди данных нужного значения. Следующая функция находит в массиве заданное число. T=array[0..n–1] of integer; function find(a:T; x:integer):integer; { Возвращает позицию числа x в массиве a. Или n, если такого числа нет. } var i:integer; begin i:=0; while (ix) do i:=i+1; find:=i; end; В среднем (для случайных данных) такой последовательный поиск требует n 2 сравнений, если искомое число есть в массиве, и n сравнений для того, чтобы убедиться в отсутствии искомого числа. Если данные упорядочены (например, по возрастанию), следует использовать двоичный поиск: сравнение искомого числа с числом, стоящим в середине массива, сужает область поиска в два раза. T=array[0..n–1] of integer; function BinFind(a:T; x:integer):integer; {Двоичный поиск} {Возвращает позицию числа x в массиве a. Или n, если такого числа нет.} var left,right,k: integer; begin left:=0; right:=n–1; BinFind:=n; while left<=right do begin 30
k:=(left+right) div 2; if x=a[k] then begin BinFind:=k; left:=right+1; end else
if x
end; end; Сортировка выбором заключается в перестановке минимального элемента массива с нулевым, минимального из оставшихся – с первым, и т.д. type T=array[0..n–1] of real; procedure SortBySelection(var a:T); { Сортировка выбором } var
i,j,current_min:integer; x:real;
begin for i:=0 to n–2 do begin current_min:=i; { Поиск минимального среди элементов i..n-1 } for j:=i+1 to n–1 do if a[j]i then begin { перестановка минимального на место i } x:=a[i]; a[i]:=a[current_min]; a[current_min]:=x; end; {if} end; {i} end; Сортировка вставками предполагает что, когда основной цикл доходит до элемента i, предшествующие элементы уже упорядочены, и остается вставить элемент i так, чтобы длина упорядоченной части возросла на 1: type T=array[0..n–1] of real; procedure SortByInsertion(var a:T); { Сортировка вставками } var
i,j,k:integer; x:real;
begin for i:=1 to n–1 do begin j:=0;
{найдем место j, куда надо вставить элемент i}
while a[j]i then begin {раздвинем упорядоченную часть массива и вставим}
31
x:=a[i]; for k:=i downto j+1 do a[k]:=a[k–1]; a[j]:=x; end; {if} end; {i} end; Сосчитаем число сравнений в алгоритме сортировки выбором. Для нахождения минимального из n элементов требуется n–1 сравнений. Следовательно, общее число сравнений
(n − 1) + (n − 2 ) + ... + 1 = (n − 1) ⋅ n
2
квадратично зависит от размерности массива. В алгоритме сортировки вставками число сравнений зависит от самих данных, но среднее число сравнений (для случайного массива) также квадратично зависит от длины массива (попробуйте сосчитать). Алгоритм сортировки «пузырьком» лучше забыть, потому что он медленнее, чем выбор или вставка. С другой стороны, изобретены алгоритмы более быстрые (HeapSort, QuickSort – см. далее), в которых число операций пропорционально n log n (основание
логарифма входит в константу пропорциональности). Чем больше n, тем больший выигрыш
дают
логарифмические
методы
сортировки
( log 2 1000 ≈ 10, log 2 1000000 ≈ 100 ). Как правило, более быстрый (при больших n) алгоритм оказывается более сложным и не дает выигрыша при малой размерности задачи. Однако, реальные задачи, как правило, всегда на пределе (или за пределом) возможностей компьютеров (как бы эти возможности ни возрастали!), поэтому использование эффективных алгоритмов для нас жизненно важно. Переменная типа string (этого типа нет в стандарте языка Паскаль) занимает 256 байтов и отличается от типа array[0..255] of char тем, что нулевой байт хранит динамическую длину строки (т.е. изменяющуюся во время работы программы).
Вместо хранения длины массива в самом массиве, можно считать некоторое значение маркером конца. Строки, в которых нулевой байт (zero) означает конец строки, называют ASCIIZ. Использование строк ASCIIZ предусмотрено в языке Си. Важно не спутать статические массивы динамической длины с динамическими массивами (см. далее), для которых выделяется память во время работы программы. В BP имеется оператор конкатенации (сцепления) строк, он обозначается просто +. Однако, мы
32
напишем процедуру конкатенации строк на более низком уровне, чтобы понять, как работает этот оператор: procedure AddStr(var a:string; b:string); { конкатенация строк } { тело процедуры можно заменить на a:=a+b; } var i,j:integer; begin i:=ord(a[0]);
{ длина строки a}
i:=i+1; j:=1; while (i<=255) and (j<=ord(b[0])) do begin a[i]:=b[j]; inc(i); inc(j); end; a[0]:=chr(i–1);
{скорректируем длину строки a}
end;
Заметим, что элементы переменной типа string имеют тип char, поэтому для получения длины строки (целое) из нулевого элемента и обратно требуются функции ord и chr. Упражнение. Процедура возвращает 8-ричную запись байта. Записи (record) отличаются от массивов (array) тем, что к элементам записи,
называемым также полями, обращаются по именам, а не по номерам, и поля записи могут иметь различные типы. Так достигается инкапсуляция различных данных в одну переменную. Например, переменная типа type person= record FirstName,LastName: string; age: byte; AverageMark: real; end; {record}
может содержать имя студента, возраст и средний балл. Можно объявить массив записей: type Tgroup=array [0..n–1] of record
FirstName,LastName: string; age: byte; AverageMark: real; end; {record}
var group:Tgroup; Точка после идентификатора означает обращение к полю записи. Следующий цикл
печатает фамилии студентов и их средний балл:
33
for i:=0 to n–1 do writeln(group[i].Lastname:20, group[i].AverageMark:4:2);
Упражнение. Процедура сортировки выбором массива типа Tgroup по полю Lastname. Если массив записей сортируется по одному полю (ключу), а потом по другому, то естественное требование состоит в том, чтобы сортировка по первому ключу сохранилась среди данных, у которых ключ второй сортировки совпадает. Сортировка называется устойчивой, если она сохраняет порядок следования элементов с равными ключами. Упражнение.
Проверьте,
что
предложенная
выше
SortBySelection
устойчива.
Перепишите процедуру SortByInsertion так, чтобы сортировка стала устойчивой. Для работы с многомерными массивами (с несколькими индексами) можно использовать описания следующего вида: type
vector=array[0..n–1] of real; matrix= array[0..n–1] of vector;
var
A:matrix;
где n, понятно, есть константа, объявленная ранее. После этих объявлений можно считать, что A[i] есть i-тая строка квадратной матрицы A (тип vector, одномерный массив), и A[i][j] есть элемент строки i и столбца j (число, тип real). Самая главная подпрограмма, которая наибольшее число раз выполнялась на компьютерах – это, конечно, решение систем линейных уравнений. Дело в том, что алгоритмы решения многих задач прикладной математики сводятся к решению систем линейных уравнений (число неизвестных равно числу уравнений). Для огромных разреженных матриц (в которых много нулей) используются итерационные методы, для матриц специального вида (напр,. матрицы Топлица) существуют эффективные методы. Но в огромном числе случаев достаточно прямого метода исключения неизвестных (метод Гаусса). В методе Гаусса матрица системы приводится к треугольному виду (так, чтобы система с верхней треугольной матрицей была эквивалентна данной системе), затем так называемый обратный ход дает решение системы (неизвестные определяются в обратном порядке): procedure Gauss(A:matrix; b:vector; var x:vector); {Метод Гаусса для системы линейных уравнений Ax=b} var
row,row1,col:integer;
k:real;
34
begin for row:=0 to n–2 do begin
{исключим неизвестное row}
for row1:=row+1 to n–1 do begin {из уравнения row1} k:=A[row1][row]/A[row][row]; for col:=row+1 to n–1 do
{вычитание уравнения row}
A[row1][col]:=A[row1][col]–A[row][col]*k; b[row1]:=b[row1]–b[row]*k; end; end; x[n–1]:=b[n–1]/A[n–1][n–1];
{обратный ход}
for row:=n–2 downto 0 do begin {определяем неизвестное row} k:=b[row]; for col:=row+1 to n–1 do k:=k–A[row][col]*x[col]; x[row]:=k/A[row][row]; end; end;
Для произвольной системы надо использовать метод Гаусса с выбором ведущего элемента (изменение нумерации неизвестных или уравнений), иначе может
возникнуть деление на ноль. Достаточным (не необходимым) условием применимости метода Гаусса без выбора ведущего элемента является положительная определенность симметрической
матрицы
системы
(системы
уравнений
с
симметрическими
положительно определенными матрицами возникают, в частности, в очень часто используемом методе наименьших квадратов). Упражнение. Напишите процедуру по методу Гаусса с выбором ведущего элемента. При этом не надо переставлять строки, достаточно держать одномерный массив number, хранящий порядок строк (number[i] есть номер строки в исходной матрице, которая является строкой i в матрице с перестановками). Вначале инициализация вида number[i]:=i, затем вместо A[i,j] следует везде использовать A[ number[i] , j ] и b[number[i]] вместо b[i]. Перестановка строк i1 и i2 в матрице запишется так: i:=number[i1];
number[i1]:=number[i2];
number[i2]:=i;
Многомерные массивы хранятся в (одномерной) памяти так, что каждый последующий индекс изменяется быстрее предыдущего. То есть строки матрицы
35
(тип matrix – см. выше) расположены в памяти друг за другом (если первый индекс, как мы договорились, есть номер строки). Это значит, что элемент [i][j] массива типа matrix имеет смещение
(i ⋅ n + j ) ⋅ SizeOf(real) .
Следовательно, обращения к элементам
многомерных массивов компилируются в вычисления, содержащие умножения (умножения выполняются существенно медленнее, чем сложения). Программы работают быстрее, если многомерные (по смыслу) данные мы будем хранить как одномерные массивы (сами вычисляя индексы в одномерных массивах). Например, если имеются объявления: var
A:matrix;
type
Thin_matrix=array[0..n*n–1] of real;
то вместо A[i][j] можно всюду использовать Thin_matrix(A)[i*n+j] (приведение типа), и программа будет работать быстрее. Особенно если в проходах по столбцам матрицы индекс Thin_matrix(A) увеличивать на n (без умножения). Итак, квадратную матрицу n × n можно представить в программе как одномерный массив из n2 элементов.
Упражнение. Напишите процедуру по методу Гаусса, принимающую матрицу системы как одномерный массив. Функции непрерывного аргумента представляются в компьютере своими значениями в дискретных точках (узлах), т.е. как сеточные функции, или массивы. На равномерной сетке с шагом ∆ функция f ( x ) хранится как массив f [i ] = f ( x0 + i ⋅ ∆ ) . Сегодня звук (во времени) и изображение (в пространстве или пространстве-времени) передаются как массивы (цифровой сигнал вместо аналогового сигнала). Понятно, что шаг сетки должен быть достаточно мал для точности интерполяции, т.е. восстановления значения функции между узлами сетки. Очевидно, что функции нескольких переменных соответствуют многомерным массивам. К сеточным функциям применяются
аналоги
и
аппроксимации
непрерывных
операторов.
Например,
дискретное преобразование Фурье (см. соотв. параграф) аналогично непрерывному преобразованию
Фурье.
А
оператор
дифференцирования
может
быть
аппроксимирован (приближен) вычислением конечных разностей: при малом шаге
равномерной сетки правая разность f [i + 1] − f [i ] ≅ ∆ ⋅ df ). dx
Симметричная
разность
( f [i + 1] −
df (x0 + i ⋅ ∆ ) (аппроксимирует dx
f [i − 1]) 2∆
точнее
аппроксимирует
36
производную
df dx
в узле i. Разность между правой и левой разностями дает
аппроксимацию второй производной: ( f [i + 1] + f [i − 1] − 2 f [i ]) ∆2 (проверьте). Отсюда, например, понятно, что если g [i ] := f [i ] + k ∗ ( f [i + 1] + f [i + 1] − 2 ∗ f [i ])
то вычисление массива g означает сглаживание сигнала f при k > 0 или его обострение при k < 0 (нарисуйте). (Возможно, слова smooth и sharpen знакомы Вам по цифровой обработке
фотографий.)
Математические
модели
часто
основаны
на
дифференциальных уравнениях (обыкновенных и в частных производных). При этом очень немногие диф. ур-я имеют аналитические решения (в виде формул), и приходится программировать их численные решения. Отсюда разностные схемы (в программе – обработка массивов), а аппроксимация и устойчивость разностных схем – это целый раздел прикладной математики. Для понимания постановок некоторых дискретных задач достаточно школьных знаний. Пусть необходимо из n предметов оптимальным (в каком-то смысле) выбрать некоторые. Для этого достаточно (может быть, необходимо) перебрать все 2n возможных сочетаний и выбрать оптимальное (наилучшее). Вариант выбора (сочетание) можно хранить в массиве типа type TC=array[0..n–1] of boolean;
Пусть оптимальный выбор соответствует минимальному значению штрафной функции: function penalty(x:TC):real; {своя в каждой задаче разделения на две части}
Для полного перебора всех вариантов достаточно понять, что сочетания можно кодировать числами от 0 до 2n–1–1 (нумерация вариантов) так что бит номера определяет, выбирается ли соответствующий номеру бита предмет или нет. const n=10; {здесь n не более 15 (почему?)} type TC=array[0..n–1] of boolean; procedure choice(var x:TC); {процедура оптимального разделения n предметов на две части} {в соответствии со штрафной функцией penalty} var
current:TC; _record,cur:real;
37
i,maxi:longint; j:byte; mask:word; first:boolean; begin first:=true; maxi:=2; for i:=1 to n–1 do maxi:=maxi+maxi; maxi:=maxi–1; {2n–1–1} for i:=0 to maxi do begin mask:=1; for j:=0 to n–1 do begin if i and mask <> 0 then current[j]:=true else current[j]:=false; mask:=mask shl 1; end; cur:=penalty(current); if first then begin _record:=cur; x:=current; first:=false; end else if _record>cur then begin _record:=cur; x:=current; end; {обновление рекорда} end; {i} end; {choice}
Частным случаем (определяющим penalty) является «задача о рюкзаке»: из набора предметов выбрать подмножество, имеющее максимальную «стоимость» среди ограниченных по «весу» (влезающих в рюкзак). Упражнение. Запрограммируйте решение задачи о рюкзаке. Представление о системах счисления позволяет также решить важную задачу генерации перестановок. Чтобы получить все n! перестановок чисел 0..n − 1 , можно считать, что каждая перестановка является записью чисел 0..n n − 1 в системе счисления по основанию n . Надо, конечно, взять только те числа, которые не имеют совпадающих цифр (в n-ричной системе). Каждая перестановка является вариантом в «задаче коммивояжера», на примере которой далее рассматривается метод ветвей и границ.
Упражнение. Выполните процедуру нахождения оптимальной перестановки.
38
§6 ХЕШИРОВАНИЕ
Как Вы хорошо запомнили, двоичный поиск в отсортированном массиве длины n требует не более log 2 n сравнений, а последовательный поиск (в неупорядоченном массиве) – в среднем n 2 . Оказывается, можно организовать поиск быстрее, чем двоичный. Пусть нам необходимо многократно (иначе зачем стараться) проводить поиск в массиве: var dat:array[0..n–1] of record key:longint; {ключ поиска} info:Tinfo;
{этот тип не уточняем}
end;
по ключу key. Было бы чудесно, если бы по значению ключа мы (применив простые вычиления) получали бы индекс в массиве dat (номер элемента, имеющего данный ключ). Это, конечно, невозможно для произвольных данных. Но было бы достаточно, если бы некоторая функция ключа (H) давала нам индекс в другом массиве (назовем его хеш-таблицей), содержащем индексы данного массива. Это условие можно (условно) записать как тождество: dat[ HashTable[H (key)] ]=key.
Мы рассматриваем практическую ситуацию, когда число возможных значений ключа много больше размерности данных n (и хэш-таблицы, длина которой не должна быть намного больше n). Тогда понятно, что функция H не может быть взаимно однозначной. Ситуация, когда двум имеющимся ключам функция H ставит в соответствие один и тот же индекс хеш-таблицы, называется коллизией. Хеширование и заключается в выборе хеш-функции H и способа разрешения хеш-коллизий. Вопервых, понятно, что хеш-таблица (из-за возможности коллизий) должна содержать не только индексы (исходного массива), но и ключи: var HashTable:array[0..HashSize–1] of record key:longint; datIndex:integer; end;
Способом разрешения коллизий может являться правило вычисления следующего номера в хеш-таблице, если элемент хеш-таблицы, на который указала хеш-функция, оказался занятым другим ключом. 39
В качестве хеш-функции обычно используют H(key)=key mod HashSize, а в качестве HashSize берут простое число, превышающее n. Можно, например, взять первое простое число, превышающее n+n shr 3 . (Хеш-таблица должна оставаться хотя бы на несколько процентов незаполненной, для уменьшения числа коллизий.) Использование в хеш-функции остатка от деления приводит к тому, что относительно близкие по значению ключи окажутся в хеш-таблице далеко друг от друга. Отсюда название метода (hash – перемалывать). Действие хеш-функции аналогично, в некотором смысле, мультипликативному датчику псевдослучайных чисел. Для разрешения коллизий можно применять к аргументу хеш-функции последовательные натуральные числа или (для "лучшего" перемалывания) квадраты натуральных чисел. Тогда основной цикл (как при поиске, так и при заполнении хеш-таблицы) имеет следующий смысл: k:=0; ПОКА H(key+k*k) вызывает коллизию do k:=k+1 Таким образом, вначале надо вызвать процедуру составления таблицы: procedure FillTable; var i,j,k:integer; begin for i:=0 to HashSize–1 do HashTable[i].datIndex:= –1; {пустая таблица} for i:=0 to n–1 do begin j:=dat[i].key mod HashSize;
k:=1;
while HashTable[j].datIndex >= 0 do begin {пока коллизия} j:=(dat[i].key + k*k) mod HashSize; k:=k+1; end; HashTable[j].datIndex:=i; HashTable[j].key:=dat[i].key; end; {i} end; { FillTable }
Когда хеш-таблица составлена, можно (сколько угодно раз) вызывать функцию поиска: function Find(key:integer):integer; {возвращает (первый попавшийся) индекс в массиве dat, соответствующий key, или –1, если ключа key нет}
40
var i,j,k:integer; begin j:=key mod HashSize; k:=1; while TRUE do begin if HashTable[j].datIndex = –1 then
{ключа нет}
begin Find:= –1; break; end; if HashTable[j].key <> key then
{коллизия}
begin j:=(key+k*k) mod HashSize; k:=k+1; continue; end; Find:=HashTable[j].datIndex; break;
{ коллизия разрешена}
end; {while} end; { Find }
Оказывается, при равномерном распределении ключей в исходных данных и заполненности хеш-таблицы на 90%, потребуется, в среднем, всего 2.5 раза вызывать хеш-функцию (в процедуре FillTable или функции Find). Причем, благодаря "перемалыванию", никакое случайное распределение ключей существенно не ухудшит результат. Получается, что скорость поиска вообще не зависит от размерности задачи! Однако за все хорошее приходится платить. Во-первых, затраты на составление хештаблицы (правда, это всего лишь линейная сложность). Во-вторых, если объем данных поиска динамически возрастает и приближается к размеру хеш-таблицы, то хештаблицу придется создавать заново. Причем старая таблица даже не поможет при создании новой (а вставка в упорядоченный массив быстрее полной сортировки). Наконец, при удалении части данных элементы хеш-таблицы можно пометить как удаленные (например, в поле datIndex занести –2), но не как пустые (datIndex = –1), иначе хеш-поиск будет невозможен (почему?). Упражнение. Напишите процедуру поиска всех элементов с данным ключом. Упражнение. Напишите процедуру пополнения хеш-таблицы одним элементом. Упражнение. Квадраты последовательных натуральных чисел можно вычислять без умножения. С учетом этого перепишите подпрограммы этого параграфа. Упражнение.
Исследуйте
экспериментально
зависимость
числа
коллизий
от
заполненности хеш-таблицы. Упражнение. Придумайте другие способы разрешения коллизий. Упражнение. Как сделать хеширование в случае 8-ми-байтного ключа?
41
§7 НЕТИПИРОВАННЫЕ ПАРАМЕТРЫ, ПРИВЕДЕНИЕ ТИПА, УКАЗАТЕЛИ, ОПЕРАТОР ВЗЯТИЯ АДРЕСА, ДИНАМИЧЕСКИЕ МАССИВЫ
Стандарт
языка
Паскаль
не
удовлетворяет
требованиям
практического
программирования. Действительно, в объявлении подпрограммы требуется указать имя типа, а в объявлении типа-массива указывается число элементов (константа). Следовательно, невозможно написать подпрограмму, реализующую некоторый алгоритм для массива произвольной размерности. Конечно, можно написать подпрограмму для типа-массива максимально возможной (на все случаи жизни?) длины и передавать подпрограмме динамическую длину фактического параметра. Но тогда и все фактические параметры занимают максимально возможный для формального параметра объем памяти. Такое решение никак нельзя считать изящным или удовлетворительным для практики разработки ПП. Значит, для создания универсальных (библиотечных) подпрограмм необходимо, чтобы, получив адрес (фактического параметра), подпрограмма могла делать что угодно с байтами по этому адресу, безотносительно к типу фактического параметра. Эта естественная (с точки зрения программирования на низком уровне) возможность изначально заложена в язык Си и реализована в BP. В BP разрешены нетипированные параметры-переменные, для них в заголовке подпрограммы тип не указывается. Для обращения к нетипированному
параметру
необходимо
приведение
типа:
выражение
тип
(параметр) означает переменную указанного типа, расположенную с адреса параметра. Для примера напишем функцию вычисления евклидовой нормы (длины)
вещественного вектора произвольной размерности: function norm(var a; n:integer):real; { вычисляет длину вектора a (of real) размерности n } type TA=array[0..1] of real; var x,sum:real;
{формальный тип для приведения}
i:integer;
begin sum:=0; for i:=0 to n–1 do begin x:=TA(a)[i]; { приведение типа имеет высший приоритет } sum:=sum+x*x; end;
42
norm:=sqrt(sum); end;
Теперь norm(x,k) означает вычисление квадратного корня из суммы квадратов первых k элементов массива х. При этом тип индекса фактического массива х не имеет значения! Упражнение. Что будет, если фактический параметр функции norm окажется array[1..10] of integer? BP имеет альтернативный синтаксис реализации свободы действий с данными по адресу фактического параметра. В этом, втором, способе используются типуказатель (pointer) и адресный оператор, или оператор взятия адреса (обозначается @, эта «собака» в шрифте BP выглядит как @). Рассмотрим следующие объявления: var p1,p2:^integer; {два указателя на переменные типа integer} p3:pointer;
{нетипированный указатель}
x:integer; type TI=^integer;
{тип-указатель}
Статические переменные p1 и p2 могут хранить адреса переменных типа integer. Выражения p1^ и p2^ означают те переменные (типа integer), на которые указывают (адреса которых содержат) p1 и p2. Переход от указателя к переменной, на которую он указывает, называется разыменованием указателя. Указатели произвольного типа имеются в стандарте языка Паскаль, как и оператор разыменования («шляпка» после указателя, в некоторых книгах печатается как ↑). Нельзя разыменовывать указатель, не получивший значения, т.е. содержащий «мусор», и нельзя разыменовывать
указатель, равный константе NIL (пустой указатель). Указатель может получить значение в результате динамического (run-time) выделения памяти под (динамическую) переменную, адрес которой будет присвоен указателю (см. далее) или в результате применения оператора взятия адреса. Например, p1:=@x;
{теперь p1 хранит адрес переменной x, и p1^ есть x}
p1^:=p1^+1; {x увеличивается на 1} p2:=p1;
{теперь p2 содержит тот же адрес, что p1}
p2^:=0;
{теперь p1^ и x равны нулю}
43
В BP имеется тип pointer (нетипированный указатель), указатель типа pointer совместим с указателями любого типа и не может быть разыменован без приведения к типированному указателю. Предыдущий пример можно продолжить: p3:=p1;
{теперь p3 содержит тот же адрес, что p1}
TI(p3)^:=1
{сначала приведение типа, потом разыменование} {теперь p1^, p2^ и x равны 1}
Следовательно, функцию norm можно переписать так: function norm(a:pointer; n:integer):real; { вычисляет длину вектора (of real) размерности n, адрес которого содержит a} type
TA=array[0..1] of real; TAP=^TA;
var x,sum:real;
{тип-указатель на переменную типа TA} i:integer;
begin sum:=0; for i:=0 to n–1 do begin x:=TAP(a)^[i]; {последовательно: приведение типа, разыменование, взятие элемента i} sum:=sum+x*x; end; norm:=sqrt(sum); end;
Теперь обращение к norm для фактического массива x должно происходить через адресный оператор: norm( @x ,k ). Заметим, что как только мы можем передавать подпрограммам адреса фактических параметров, мы можем вообще обойтись без параметров-переменных. В языке Си единственный способ передачи параметров – по значению (для изменяемых параметров передаем значения их адресов).
Упражнение. Выполните библиотечную процедуру SortBySelection (для массива произвольной длины). Упражнение. Выполните библиотечную процедуру решения системы линейных уравнений (для произвольного числа неизвестных). Заметьте, что параметр-матрицу можно привести только к типу-массиву с одним индексом.
44
Итак, мы научились создавать универсальные подпрограммы, которым можно передавать массивы произвольной длины. Следующий шаг – создание (и уничтожение) динамического массива произвольной длины во время работы программы (а не на
этапе
компиляции).
Как
правило,
в
«настоящих»
программах
используются
динамические, а не статические массивы. Статически (на этапе компиляции) выделяется память только под указатель (адрес будущего динамического массива). В BP имеется procedure GetMem(var p:pointer; size:word); которая выделяет блок памяти размера size байтов и присваивает адрес этого блока параметру-указателю. Полученный указатель можно, как и раньше, привести к типу указателя на массив, разыменовывать и индексировать. Перед вызовом GetMem следует вызвать функцию MaxAvail, которая возвращает размер максимального свободного блока памяти (см. также директиву компилятора $M). В более «настоящей» ОС не надо беспокоится, имеется ли достаточное количество памяти для размещения динамического массива – мы даже не можем определить, находятся данные в оперативной памяти или виртуальной (подкачка с диска). Другое дело, что задача, которой необходим для длительных вычислений произвольный доступ к данным более 1Гб, может очень медленно работать (практически лечь из-за свопинга с диском) даже на приличном сервере. В таком случае следует изменить алгоритм так, чтобы произвольный доступ был необходим для данных меньшего объема. Процедура Dispose в BP освобождает блок памяти, выделенный ранее с помощью GetMem. Следующая программа выдают первые n простых чисел, запоминая уже найденные в динамическом массиве, что ускоряет дальнейшую проверку на простоту. {$M 16384,100000,655360} {Размеры стека, минимальный и максимальный размеры «кучи»,} {из которой GetMem берет свободные блоки} {Если DOS не имеет минимально заказанного размера кучи, программа не стартует} var
p:pointer; n,i,j:word; x:longint;
{число, проверяемое на простоту}
simple:boolean;
45
type
T=array[0..1] of longint; TP=^T;
begin writeln('сколько простых чисел хотите?'); readln(n); if n*SizeOf(longint)>MaxAvail then writeln('Not enough memory. Use $M option') else begin getmem(p,n*SizeOf(longint));
{память под массив простых чисел}
TP(p)^[i]:=1; i:=1; TP(p)^[i]:=2; while i
{предположим, что x – простое}
while true do begin for j:=1 to i do if x mod TP(p)^[j] = 0 then begin x:=x+1; simple:=false; break; end; if simple then break; simple:=true; end;
{действительно простое}
{на проверку следующего числа x}
{while true}
i:=i+1; TP(p)^[i]:=x; end;
{while i}
for i:=0 to n–1 do write(i+1:5,'-->',TP(p)^[i]:8); end; Dispose(p); readln; end.
Упражнение. Напишите процедуры хеширования для динамической таблицы.
46
§8 СТЕК, ПОСТФИКСНАЯ ЗАПИСЬ, БЫСТРАЯ СОРТИРОВКА (QUICKSORT), СЛОЖНОСТЬ ВЫЧИСЛЕНИЙ, МЕТОД ВЕТВЕЙ И ГРАНИЦ, РЕКУРСИЯ
Стек, или магазин, – это данные, которые обрабатываются по принципу LIFO (Last In – First Out). Сами данные могут быть представлены массивом динамической длины, хранящейся в переменной SP (Stack Pointer). const StackSize=…; SP:word=0; {сначала стек пуст} var Stack:array[0.. StackSize–1] of StackElement; {тип StackElement определяется задачей}
Работа со стеком не предполагает обращений к переменной Stack, но использование процедур push (положить в стек – по адресу SP) и pop (взять из вершины стека). procedure push(x: StackElement); begin stack[sp]:=x; sp:=sp+1;
end;
procedure pop(var x: StackElement); begin sp:=sp-1;
x:=stack[sp];
end;
Во многих алгоритмах необходимо проверять пустоту стека. Это делает function empty:boolean; begin
empty:= sp=0;
end;
Упражнение. Проясните следующие фрагменты: 1) for i:=1 to 10 do push(2*y[i]);
for i:=1 to 10 do pop(y[i]);
2) for i:=1 to 10 do push(y[i]);
for i:=1 to 10 do pop(3*y[i]);
Упражнение.
Выполните
push
как
булевскую
функцию,
контролирующую
переполнение стека. Упражнение. Разработайте модуль поддержки динамического стека (потребуется дополнительная функция инициализации, вызывающая GetMem). Концепция стека очень важна в computer science. Здесь мы говорим о программном стеке, но есть еще аппаратный стек – инструкции процессора, соответствующие push и pop, и регистры, соответствующие переменной SP. При вызове подпрограмм адрес возврата («обратный билет») запоминается в стеке. Параметры подпрограмм и локальные переменные также помещаются в стек. Это естественно, так как процедура, вызванная позже, должна закончиться раньше (LIFO). Трансляция
47
языков программирования основана на алгоритмах со стеком. Рассмотрим вычисление арифметического выражения по его постфиксной записи, в которой знак операции следует после операндов. В отличие от обычной (инфиксной) записи, постфиксная запись не требует скобок. Например, для (A+B)*C+D/E постфиксная запись есть
AB+C*DE/+. При просмотре постфиксной записи (слева направо) значения операндов помещаются в стек, а когда встречается знак (бинарной, т.е. связывающей два операнда) операции, из стека извлекаются два операнда и в стек помещается результат операции. type StackElement=real; function letter(c:char):real;
forward; {опережающее описание}
function value(s:string):real; {вычисляет арифметическое выражение s в постфиксной записи с} {однобуквенными идентификаторами, значения которым дает функция letter} var i:byte; op1,op2:real; begin for i:=1 to ord(s[0]) do case s[i] of 'a'..'z': push(letter(s[i])); '+':
begin pop(op2); pop(op1); push(op1+op2); end;
'–':
begin pop(op2); pop(op1); push(op1–op2); end;
'*':
begin pop(op2); pop(op1); push(op1*op2); end;
'/':
begin pop(op2); pop(op1); push(op1/op2); end;
end; {case} pop(op1); value:=op1; end;
Упражнение. Выполните процедуру трансляции из постфиксной записи в инфиксную. Постарайтесь не иметь в результате лишних скобок. То,
что
процедура
value
предусматривает
только
однобуквенные
идентификаторы, на самом деле не принципиально. На этапе лексического анализа идентификаторы любой длины заменяются ссылками одинаковой длины на таблицу
48
переменных. Трансляция из инфиксной записи в постфиксную также выполняется с помощью стека по следующему алгоритму. В стек помещается открывающая скобка, к инфиксной
записи
дописывается
закрывающая
скобка.
Инфиксная
запись
просматривается слева направо, и встреченные операнды подаются на выход (в постфиксную запись). Если встречается открывающая скобка, она (ее код) помещается в стек. Если закрывающая – из стека копируются на выход все знаки операций до открывающей скобки, которая также выталкивается из стека. Если встречается знак операции, то из стека копируются на выход все знаки операций до открывающей скобки, приоритет которых не меньше, чем у встреченного знака, после чего он помещается в стек. Упражнение. Выполните процедуру трансляции из инфиксной записи в постфиксную. Стек используется не только в алгоритмах трансляции. В частности, самый быстрый (на случайных данных) из известных методов сортировки массивов – QuickSort (C. Hoare, 1962) – можно запрограммировать с помощью стека. Алгоритм
основан на идее разделения массива на две части – ключи, меньшие некоторого разделителя x, должны стоять левее ключей, больших x. В качестве разделителя можно принять ключ, расположенный в середине массива. Когда массив разделен на две части, ту же процедуру можно применить к этим частям, и т.д., пока части не станут единичной длины. Если длина массива n есть степень двух, и части всегда делятся пополам, то общее число сравнений равно 1⋅ n + 2 ⋅
n n n + 4 ⋅ + ... + ⋅ 2 = n log 2 n . 2 4 2
Конечно, разделитель не всегда разделит части пополам. В наихудшем случае длина одной из частей равна 1. Тогда число сравнений равно n + (n − 1) + (n − 2 ) + ... + 2 =
n+2 ⋅ (n − 1) , 2
квадратично зависит от размерности задачи. Однако в среднем QuickSort имеет логарифмическую сложность и при больших n становится бесконечно эффективнее «наивных» методов сортировки (выбором, вставками), так как n
lim log n = +∞ .
n → +∞
49
Границы обеих частей, полученных разделением, помещаются в стек (если длина части больше 1). Основной цикл начинается c извлечения из стека границ некоторой части. type StackElement=record left,right:integer; end; procedure QSort(p:pointer;n:word); {сортировка массива из n элементов real, расположенного по адресу p} type
T=array[0..1] of real; TP=^T;
var
x, a:real; part, part1, part2: StackElement; l, r:integer;
begin part.left:=0; part.right:=n–1; push(part); {сначала весь массив – для разделения} while not empty do begin {основной цикл в алгоритмах с разветвлением} pop(part); l:=part.left; r:=part.right; {часть для разделения – из стека} x:=TP(p)^[(l+r) div 2];
{разделитель}
while l<=r do begin
{разделение}
while TP(p)^[l]<x do l:=l+1; while TP(p)^[r]>x do r:=r–1; if l<=r then
{перестановка}
begin a:=TP(p)^[l]; TP(p)^[l]:=TP(p)^[r]; TP(p)^[r]:=a; l:=l+1; r:=r–1;end end;
{разделение окончено}
part1.left:=part.left; part1.right:=r;
{часть 1}
part2.left:=l; part2.right:=part.right;
{часть 2}
if part1.right-part1.left<part2.right-part2.left then {сначала в стек – большая часть} begin part:=part1; part1:=part2; part2:=part; end; if part1.right>part1.left then push(part1); if part2.right>part2.left then push(part2);
50
end; end;
{empty}
{QSort}
Обратите внимание, что меньшая часть помещается в стек позже (следовательно, разделяется раньше). Это приводит к тому, что размер стека в любом случае не превысит размера массива. Упражнение. Понятно, что за скорость алгоритма мы платим его усложнением. Неудивительно, что для коротких массивов (10-20 элементов) «наивные» методы оказываются быстрее Quicksort. Модифицируйте процедуру QSort так, чтобы части, размер которых меньше некоторой константы (экспериментально выберите ее), сортировались выбором, а не разделением. Сложность вычислений – это зависимость числа операций в алгоритме
решения задачи от ее размерности. Хотя размерность задачи можно определять поразному и операции можно подсчитывать по-разному (например, учитывать только самые длительные операции – умножения с плавающей точкой, если они определяют время решения), понятие сложности вычислений позволяет сравнивать задачи и алгоритмы. Например, последовательный поиск имеет линейную сложность (число сравнений пропорционально размеру массиву), а двоичный поиск в упорядоченных данных – логарифмическую (существенно быстрее для большого объема данных). «Наивные» сортировки имеют квадратичную сложность, а QuickSort – n log n . Дискретное преобразование Фурье (см. далее) массива длины n требует n2 умножений чисел с плавающей точкой, если вычислять «в лоб». Для решения этой задачи был изобретен эффективный алгоритм сложности n log n (быстрое преобразование Фурье). Эффективность растеризации (рисования) линий (см. далее) означает использование в алгоритме только целочисленной арифметики (без переменных с плавающей точкой). Во многих задачах прикладной математики надо найти оптимальный вариант, когда варианты
разветвляются,
и
число
вариантов
растет
от
размерности
задачи
экспоненциально ( a n , a > 1 ) или еще быстрее: как n! или n n . Неудивительно, если за несколько часов можно найти оптимальный вариант вручную для n=10, а на самом мощном компьютере – для n=30. Причем дальнейшее увеличение n быстро оборачивается
годами
компьютерного
времени.
Для
таких
задач
любой
полиномиальный алгоритм (nk, k – любое) может считаться эффективным. Однако не следует даже пытаться построить полиномиальный алгоритм для рассмотренной ранее 51
задачи о рюкзаке. Для нескольких тысяч важных задач прикладной математики (они называются NP-полными и NP-трудными, будем называть их просто «трудными») доказана их сводимость друг к другу: если хотя бы для одной из них существует полиномиальный алгоритм, то все эти задачи – полиномиальной сложности. Следует исходить из того, что «трудные» задачи не могут быть точно решены за полиномиальное время. Для этих задач остается полный перебор всех вариантов, который в некоторых случаях можно сократить: метод ветвей и границ состоит в том, чтобы не разветвлять варианты, про которые можно выяснить, что они не приведут к оптимальному решению. Метод ветвей и границ – самый эффективный для «трудной» «задачи коммивояжера» (см. далее), и на некоторых данных работает так быстро, как будто это полиномиальный алгоритм. Иногда для трудных задач используют полиномиальные эвристические алгоритмы, которые дают квазиоптимальные (приближенные) решения. Например, в задаче о рюкзаке можно сначала «положить в рюкзак» предмет, лучший по отношению стоимость-вес, потом – лучший из оставшихся предметов и т.д., пока ограничение по весу не нарушено. В этом алгоритме нет разветвлений и возвратов, и он может быть реализован для задачи практически любой размерности. В некоторых случаях удается доказать, что эвристический алгоритм дает решение хуже оптимального не более чем на x процентов. Если Вы не можете найти полиномиального решения задачи, проверьте в литературе (см. список в конце пособия), не является ли она (или более простая – ее частный случай) «трудной». Если задача оказалась «трудной», попытайтесь применить метод (идею) ветвей и границ, или придумайте эвристический алгоритм. Задача коммивояжера состоит в следующем. Имеется n городов, расстояния
между которыми заданы квадратной n × n матрицей W: Wij есть расстояние между городами i и j по дороге, не проходящей через другие города. Если Wij = +∞ , то города i и j не соединены дорогой, не проходящей через другие города. По смыслу задачи Wij > 0 для всех i ≠ j , матрица W не обязательно симметричная. Необходимо найти
кратчайший путь, проходящий по одному разу через все города. (Будем считать, что путь начинается и заканчивается в городе 1.) Другими словами, это задача нахождения оптимальной перестановки размера n. Можно также говорить о разветвления вариантов – вариант (пройденный путь) можно продолжить любым городом, который не ходит в
52
пройденный путь. Чтобы просмотреть все разветвления и обеспечить возвраты к еще не разветвленным вариантам, надо использовать стек. Элементом стека является массив номеров посещенных городов (динамической длины). Общая схема решения (полного перебора) такова: ПОМЕСТИТЬ В СТЕК НАЧАЛЬНЫЙ ВАРИАНТ ПОКА СТЕК НЕ ПУСТ ДОСТАТЬ ИЗ СТЕКА ВАРИАНТ ЕСЛИ ВАРИАНТ ЗАКОНЧЕН (посетили все города), ТО ОБНОВИТЬ РЕКОРД ИНАЧЕ ПОМЕСТИТЬ В СТЕК ВСЕ РАЗВЕТВЛЕНИЯ ВАРИАНТА Под обновлением рекорда понимаются простые действия: если это первый законченный вариант, то запомним его и его длину как рекорд, иначе (не первый законченный вариант) рекорд изменяется, если вариант лучше рекорда. Естественно, нет смысла разветвлять вариант, если его длина (она может только увеличиться) уже хуже рекорда (границы). В этом отсечении ветвей и состоит идея, которая называется методом ветвей и границ. Для задач, в которых можно ее применить, схема решения
такова: ПОМЕСТИТЬ В СТЕК НАЧАЛЬНЫЙ ВАРИАНТ ПОКА СТЕК НЕ ПУСТ ДОСТАТЬ ИЗ СТЕКА ВАРИАНТ ЕСЛИ ВАРИАНТ ЗАКОНЧЕН, ТО ОБНОВИТЬ РЕКОРД ИНАЧЕ ЕСЛИ ПРОДОЛЖЕНИЕ ВАРИАНТА МОЖЕТ УЛУЧШИТЬ РЕКОРД, ТО ПОМЕСТИТЬ В СТЕК ВСЕ РАЗВЕТВЛЕНИЯ ВАРИАНТА Применимость метода ветвей и границ в задаче коммивояжера обеспечивается тем, что элементы матрицы W положительны. В следующей подпрограмме считается, что Wij = +∞ кодируется как Wij < 0 . Естественно, используются рассмотренные ранее
подпрограммы push, pop, empty. type
StackElement=record path:array[1..n] of byte; path_count:byte;
{номера городов в порядке посещения}
{число посещенных городов,} {или динамическая длина path}
path_len:real;
{суммарная длина пути (штраф)}
53
end; TW=array[1..n,1..n] of real; {тип матрицы расстояний между городами} procedure transposition(W:TW; var best:StackElement); {best – решение задачи коммивояжера для матрицы W} var
current:StackElement; first,visited:boolean; _record,distance:real; i,j:byte;
begin first:=true;
{еще нет законченного варианта}
_record:=1e20;
with best do begin path[1]:=1;
{начинаем в городе 1}
path_count:=1; path_len:=0;
end; {with}
push(best); {начальный вариант – в стек} while not empty do begin {основной цикл (возвраты)} pop(current);
{вариант для возможного ветвления – из стека}
if current.path_count=n then begin
{если вариант закончен}
distance:=W[current.path[n],1];
{в город 1}
if distance<0 then continue;
{нет пути}
current.path_len:=current.path_len+distance; if first then {если первый законченный вариант, запомним его} begin best:=current; first:=false; _record:= best.path_len; end else if current.path_len<_record then
{если лучше рекорда}
begin best:=current; _record:=best.path_len; end; continue; end;
{если вариант закончен}
if current.path_len>_record then continue; {оператор выше – метод ветвей и границ}
54
with current do begin for i:=2 to n do begin
{попытка пойти в город i}
distance:=W[path[path_count],i]; if distance<0 then continue; visited:=false;
{не были в городе i ?}
for j:=2 to path_count do if path[j]=i then begin visited:=true; break; end; if visited then continue;
{уже были в городе i }
path_count:=path_count+1; path[path_count]:=i; path_len:=path_len+distance; push(current);
{вариант – в стек}
path_count:=path_count–1; path_len:=path_len–distance; end; end;
{i}
{with}
end; {empty} end;
{transposition}
Упражнение. Квадратный лабиринт из n × n комнат задан матрицей A. Биты байта Aij кодируют
в
какие
из
8-ми
смежных
комнат
(по
сторонам
света:
N,S,W,E,NW,NE,SW,SE) можно пройти из комнаты (i,j). Методом ветвей границ найти кратчайший путь между заданной парой комнат. Не всякая задача с разветвлениями является «трудной». Например, пусть расстояния между городами задаются матрицей W как в задаче коммивояжера. Отрицательными значениями будем кодировать + ∞ , как и раньше. Необходимо вычислить длины кратчайших путей из города 1 во все остальные, т.е. заполнить массив var
min_len:array[1..n] of real;
который инициализируется следующим образом: min_len[1]:=0; for i:=2 to n do min_len[i]:=–1;
и вычисляется в следующим фрагменте:
55
for step:=1 to n–1 do begin {каждый город достижим не более чем с n–2 пересадкой} for i:=1 to n do begin
{из города i}
if min_len[i]<0 then continue; for j:=1 to n do begin
{он еще не достигнут}
{проверяем ход из i в j}
if w[i,j]<0 then continue;
{нет хода}
if (min_len[j]<0) or (min_len[i]+W[i,j]<min_len[j]) then {если новый путь в j короче} min_len[j]:=min_len[i]+w[i,j]; end; end; end;
{j}
{i}
{step}
Очевидно, что это полиномиальной (кубической) сложности алгоритм решения задачи. Оказывается, для решения этой задачи имеется еще более быстрый (квадратичной сложности) алгоритм – алгоритм Дейкстра. Очевидно, что для города, ближайшего к 1, длина минимального пути определяется сразу и далее значение min_len для этого города обновлять не следует (фиксируется). На каждом шаге алгоритма Дейкстра число таких –«фиксированных» – городов увеличивается на 1. Эти города помечаются в массиве var
fixed:array[1..n] of boolean;
который инициализируется следующим образом: fixed[1]:=true; for i:=2 to n do fixed[i]:=false;
На каждом шаге алгоритма Дейкстра рассматриваются движения из текущего города в соседние (не фиксированные) и обновляются соответствующие элементы массива min_len. Затем город с минимальным не фиксированным значением min_len
становится текущим и фиксированным. Следующий фрагмент реализует алгоритм Дейкстра: current:=1; for step:=1 to n–1 do begin for j:=1 to n do begin
{проверка хода из current в j}
if fixed[j] then continue;
{если j фиксирован}
56
if w[current,j]<0 then continue;
{если хода нет}
if (min_len[j]<0) or (min_len[current]+W[current,j]<min_len[j]) then min_len[j]:=min_len[current]+W[current,j]; end;
{обновление}
{j}
_record:=1e20; nearest:=0; {поиск ближайшего к 1 из нефиксированных} for j:=1 to n do begin if fixed[j] then continue; if min_len[j]<0 then continue; if min_len[j]<_record then begin _record:=min_len[j]; nearest:=j; end; end;
{j}
fixed[nearest]:=true; current:=nearest; end;
{step}
Задача определения не только длины кратчайшего пути, но и самого пути из i в j становится элементарной, если длины кратчайших путей из i вычислены. Тогда кратчайший путь определяется с конца – предпоследним (перед j) будет (любой) город x такой что min_len[i,x]+W[x,j] = min_len[i,j]
и т.д. Это уравнение динамического программирования. Упражнение. Докажите корректность алгоритма Дейкстра. Упражнение. Решите задачу предыдущего упражнения (лабиринт) по алгоритму Дейкстра. Рекурсия – это когда с помощью высказывания (процедуры) конечной длины
определяется (вычисляется) бесконечно много элементов через самих себя. Рекурсивны многие
математические
определения
(приведите
примеры).
Рекурсивны
синтаксические диаграммы, определяющие грамматику языка программирования (приведите примеры). Рекурсивная подпрограмма вызывает саму себя. Если подпрограмма
A вызывает B, а B вызывает A, то это тоже рекурсия (иногда
называемая косвенной). Если подпрограмма вызывает саму себя, можно представить, что одновременно существует много копий этой подпрограммы, и каждая копия
57
продолжает выполнение только тогда, когда завершается порожденная ею копия. На самом деле в памяти хранится только один экземпляр кода подпрограммы, но запоминаются адреса, с которых каждая «копия» продолжит выполнение. Адрес возврата запоминается в стеке (не только при рекурсии), в стеке же хранятся параметры и локальные переменные каждой «копии». Таким образом, действительно можно считать, что одновременно существуют копии рекурсивной подпрограммы. Ясно также, что возможности рекурсивных алгоритмов полностью совпадают с возможностями алгоритмов, основанных на использовании стека. Рекурсивные
алгоритмы записываются короче. Используя алгоритмы со стеком, мы опускаемся на более низкий уровень программирования, и, следовательно, более полно контролируем ресурсы (например, возможное переполнение стека). Числа Фибоначчи второго порядка определяются рекурсивно: a0 = a1 = 0, a2 = 1, ai = ai −1 + ai − 2 + ai − 3
при i > 2.
В полном соответствии с этим определением пишем рекурсивную функцию: function Fib2(k:integer):longint;
{правильная, но ужасно неэффективная}
begin if k<2 then Fib2:=0 else
if k=2 then Fib2:=1 else Fib2:=Fib2(k–1) + Fib2(k–2) + Fib2(k–3);
end;
{Fib2}
Функция Fib2(5) вызывает Fib2(4), Fib2(3) и Fib2(2) и т.д., как показано на рисунке. Fib2(5)
Fib2(4)
Fib2(2)
Очевидно,
Fib2(3)
Fib2(2)
Fib2(1)
Fib2(0)
что
для
задачи
Fib2(3)
Fib2(1)
линейной
Fib2(2)
сложности
Fib2(1)
мы
Fib2(2)
Fib2(0)
измудрили
алгоритм
экспоненциальной сложности (сосчитайте число рекурсивных вызовов Fib2 для n-го
58
числа Фибоначчи). Вывод: не следует применять рекурсию там, где возможно итерационное решение. Рекурсия адекватна рекурсивным данным или разветвлению.
Перепишем быструю сортировку рекурсивно: procedure QSort(p:pointer;n:word); {сортировка массива из n элементов real, расположенного по адресу p} type
T=array[0..1] of real; TP=^T;
var
a,x:real;
procedure sort(l,r:integer);
{рекурсивная процедура}
{l,r – левая и правая граница (индексы исходного массива) сортируемой части} var
l1,r1:integer;
begin l1:=l; r1:=r;
{запомним границы}
x:=TP(p)^[(l+r) div 2];
{разделитель}
while l<=r do begin
{разделение}
while TP(p)^[l]<x do l:=l+1; while TP(p)^[r]>x do r:=r–1; if l<=r then
{обмен}
begin a:=TP(p)^[l]; TP(p)^[l]:=TP(p)^[r]; TP(p)^[r]:=a; l:=l+1; r:=r–1;end end;
{разделение}
if r–l1
{сначала будет разделяться меньшая часть}
if l1
{рекурсия (для левой части)}
if l< r1 then sort(l,r1);
{рекурсия (для правой части)}
end else begin
{противоположный порядок сортировки частей}
if l
{sort}
begin {QSort} sort(0,n–1); {первый вызов рекурсивной процедуры}
59
end;
{QSort}
Упражнение.
Решение
задачи
коммивояжера
методом
ветвей
и
границ
с
использованием рекурсии. Даже совсем краткие (несколько строк) рекурсивные процедуры могут быть очень сложны для понимания, так как рекурсивность не свойственна мышлению нормального человека. (Надо учиться понимать как формализованные, так и неформализованные рассуждения.) К счастью, мы еще потренируемся в рекурсии, рассматривая динамические деревья (см. далее).
60
§9 ФАЙЛЫ. ВНЕШНИЕ СОРТИРОВКИ. HEAPSORT.
Как правило, исходные данные для программ и результаты вычислений имеют большие объемы и хранятся во внешней памяти – как дисковые файлы. Исходное значение слова файл – лента, что предполагает последовательный доступ: лента протягивается от начала до конца, и всегда читается с начала. Для дисковых файлов имеет место произвольный доступ к любому байту, физически возможный, потому что диск
вращается, а головка чтения-записи может двигаться радиально. Чтение-запись файла – это копирование информации между внешней памятью и оперативной памятью. В операциях чтения-записи (read, write; readln, writeln, blockread, blockwrite) указывается не имя файла (путь), а файловая переменная. Открытие файла – это установление связи между именем файла и файловой переменной. В BP для открытия файла вызываются друг за другом две процедуры: 1) assign и 2) reset или rewrite. При вызове rewrite создается новый файл с именем, указанным в вызове assign, а если файл с таким именем существовал, имевшаяся в нем информация уничтожается. В BP имеются файловые переменные трех видов: var
f1: text;
{текстовый файл}
f2: file of T; {типированный файл (здесь Т – имя типа)} f3: file;
{нетипированный файл}
Основная разница между текстовыми (ASCII) и двоичными (не текстовыми) файлами заключается в том, что при чтении (записи) текстовых файлов происходит преобразование чисел из символьной десятичной записи во внутренние форматы (и обратно), как и при обмене данными между программой и консолью. В двоичных, или бинарных, файлах информация хранится точно так же как в оперативной памяти. Текстовые файлы могут быть разделены на строки (переменной длины) – определенные байты имеют смысл маркеров конца строки. Поэтому для текстовых файлов имеется только последовательный доступ – чтение (запись) от начала файла до конца (файла или поиска). Двоичные файлы состоят из записей фиксированной длины. Длина записи определяется типом файла или, для нетипированных файлов, вторым параметром reset (rewrite). Для двоичных файлов возможен также произвольный доступ (процедура seek). Файловая переменная хранит в себе файловый указатель – смещение байта (от
начала файла), с которого будет продолжаться чтение или запись. Операции чтения-
61
записи перемещают файловый указатель на число прочитанных или записанных байтов. Функция FilePos возвращает текущее значение файлового указателя (пересчитанного из байтов в записи), процедура seek устанавливает его в нужное положение, в том числе за концом существовавшего файла. Чтобы установить новую длину существовавшего файла надо вызвать truncate ("обрезать" файл на текущем положении файлового указателя). Текстовый файл можно просмотреть с помощью текстового редактора – программы, выводящий на экран символы, соответствующие
байтам файла. Естественно, текстовый редактор покажет file of real как бессмысленный набор символов. ASCII файлы используются также для обмена данными между программными продуктами, которые, как правило, хранят данные в собственных форматах. Различия между текстовыми и двоичными файлами можно представить следующей таблицей: Text
File
(текстовый файл)
(двоичный файл)
Открывает файл для
Открывает файл для
последовательного чтения
чтения-записи
Создает новый файл для
Создает новый файл
последовательной записи
для чтения-записи
Readln, Writeln, EOLN
Есть
Нет (нет строк)
Seek, FilePos, Truncate
Нет (произвольного доступа)
Есть
Reset Rewrite
Рассмотрим процедуры записи матрицы в текстовый и двоичный файл: type matrix=array[0..n–1,0..n–1] of real; procedure matrix2text(filename:string; A:matrix); var
f:text; i,j:integer;
begin assign(f,filename); rewrite(f); for i:=0 to n–1 do begin
{создание файла с данным именем} {цикл по строкам матрицы}
for j:=0 to n–1 do write(f,A[i,j]:8:3); {может быть указан формат вывода}
62
writeln(f);
{на новую строку}
end; close(f);
{закрыть файл}
end; procedure matrix2file_of_real(filename:string; A:matrix); var
f:file of real; i,j:integer;
begin assign(f,filename); rewrite(f); for i:=0 to n–1 do
for j:=0 to n–1 do
write(f,A[i,j]);
close(f); end; Тип файловой переменной определяет способ работы с файлом и может не соответствовать формату файла. Если мы используем тип file of byte, то можем
делать все что угодно (через приведение типа), но любой файл можно открыть (и читать) как text (даже если это бессмысленно). Упражнение. Какой байт является маркером конца строки в текстовом файле? (Прочитайте ASCII файл как file of byte). Для демонстрации произвольного доступа рассмотрим следующую задачу: заменить в файле каждую букву A на букву B. Это делает следующая процедура (точнее, она заменяет байты, равные коду буквы A, на ord('B')): procedure A2B(filename:string); var
f:file of byte; b:byte; filepointer:longint;
begin assign(f,filename); reset(f); while not EOF(f) do begin {пока не конец файла} read(f,b); if b=ord('A') then begin filepointer:=FilePos(f); Seek(filepointer–1); {на один байт назад}
63
b:=ord('B'); write(f,b); end; end;
{EOF}
close(f); end;
Упражнение. Выполните процедуру сортировки файла чисел, основанную на произвольном доступе (без буфера в оперативной памяти). Как ни странно, в стандарте языка Паскаль не предусмотрена проверка успешности файловых операций. Ошибка возникает, например, при открытии (reset) несуществующего файла или записи при отсутствии права доступа или места на диске. В BP для проверки отсутствия и диагности ошибок имеется функция IOResult (см. help). Основные процедуры чтения-записи blockread и blockwrite также отсутствуют в стандарте языка. Они позволяют читать-писать буфер любого размера. Обратите внимание, что смещения в файле (для seek) указываются не байтах, а в записях. Для типированных файлов размер записи равен размеру переменной соответствующего типа, для нетипированных – указывается при открытии. Действие следующей процедуры эквивалентно рассмотренной выше matrix2file_of_real. procedure matrix2file(filename:string; A:matrix); var
f:file;
begin assign(f,filename); rewrite(f,SizeOf(real)); {размер записи как в file of real} blockwrite(f,A,n*n);
{копировать в файл n2 записей}
close(f); end;
Следующая универсальная процедура создает файл и сбрасывает в него буфер заданного размера с заданного адреса: procedure buf2file(filename:string; buf:pointer; count:word); var
f:file;
begin assign(f,filename); rewrite(f,1);
{размер записи 1 байт}
blockwrite(f,buf^,count);
{копировать в файл count байтов}
close(f);
64
end;
Естественно, вызов matrix2file('m.bin', a);
эквивалентен buf2file('m.bin', @a, n*n*sizeof(real));
Обращения к дисковой памяти на порядки медленнее, чем к оперативной памяти. Поэтому вместо использования произвольного доступа (см. последнее упражнение) предпочитают взять весь файл в буфер, затем в оперативной памяти обработать информацию (например, отсортировать) и сохранить на диске. Следующая процедура считывает весь файл в буфер (передается адрес буфера): procedure file2buf(filename:string; buf:pointer); var
f:file;
begin assign(f,filename); reset(f,1);
{открыть, размер записи 1 байт}
blockread(f,buf^,FileSize(f));
{копировать в буфер весь файл}
close(f); end;
Упражнение. Выполните программу просмотра текстовых файлов (или простой текстовый редактор). Упражнение. Проверьте, что читать (писать) файл большими порциями существенно быстрее, чем порциями по несколько байтов. Конечно, в MS-DOS невозможно читать более чем по 64 К – буфера в оперативной памяти большего размера просто не может быть. Но это ограничение только нашей "учебной" ОС. С другой стороны, возникают задачи с объемом данных, который намного превосходит любую оперативную память. Для них требуются специальные алгоритмы. Например, внешние сортировки (см. далее) на много порядков быстрее, чем использование для сортировки больших файлов изученных методов сортировки (массивов) и произвольного доступа (seek). Внешние сортировки используют только последовательный доступ (но много
проходов) и основаны на идее слияния упорядоченных частей файла (серий). Следующий фрагмент содержит алгоритм слияния двух упорядоченных по возрастанию массивов:
65
{слияние массивов A[1..n] и B[1..m] в C[1..n+m]} i:=1; j:=1; k:=1; while (i<=n) and (j<=m) do begin if A[i]
{основной цикл слияния}
then
begin C[k]:=A[i]; i:=i+1; end
else
begin C[k]:=B[j]; j:=j+1; end;
end; {while} {в одном из входных массивов есть «хвост», который копируется в выходной массив} if i<=n then while i<=n do begin C[k]:=A[i]; i:=i+1; k:=k+1; end else
while j<=m do begin C[k]:=B[j]; j:=j+1; k:=k+1; end;
Естественно, слияние двух лент уменьшает суммарное число серий на них вдвое (если оно четное, иначе последняя серия не включается в слияние на этом проходе). Сортировка заканчивается, когда остается одна серия. Естественно считать, что конец серии – это последний элемент ленты или элемент, следующий за которым меньше его. Поскольку дисковые операции самые медленные, сложность внешней сортировки следует подсчитывать как число проходов по данным. Очевидно, мы имеем логарифмическую сложность. Следующий алгоритм сортировки (естественное 2-хпутевое сбалансированное слияние) предполагает использование четырех лент (файлов последовательного доступа). Вначале серии исходных данных (дан один файл) разделяются (пополам) на две ленты (1 и 2). Далее серии (упорядоченные по возрастанию куски лент) 1-ой и 2-ой лент попарно сливаются, и выходные серии поочередно записываются на 3-ю и 4-ю ленты. На следующем проходе вход и выход меняются местами – читаются 3-я и 4-я ленты, а пишутся 1-я и 2-я. На некотором проходе одна из выходных лент окажется пустой – сортировка закончена. Упражнение. Выполните программу по этому алгоритму внешней сортировки. Известны теоретически более эффективные алгоритмы (многофазная сортировка, каскадная сортировка), чем рассмотренный выше метод. Однако практическая эффективность программы внешней сортировки определяется в основном двумя аспектами: 1) буферизацией чтения-записи и 2) организацией начальных серий. Эти полезные вопросы мы сейчас и рассмотрим. Как Вы уже убедились, чтение файла большими порциями гораздо эффективнее. Буферизация последовательного чтения означает, что очередной элемент берется из
66
заранее прочитанного буфера, в котором просто сдвигается указатель на следующий элемент. Обращение к диску происходит только тогда, когда указатель выходит за пределы буфера: тогда считывается целый буфер, а указатель буфера обнуляется. Аналогично осуществляется буферизация записи (напишите алгоритм). Конечно, буферизация может оказаться полезной и при произвольном доступе, но мы рассмотрим набор подпрограмм (выполните его как модуль), обеспечивающий буферизацию
чтения-записи
при
последовательном
доступе,
используемом
в
алгоритмах внешней сортировки. Конечно, этот модуль можно было написать совсем по-другому – рассмотрите возможные варианты на уровне объявлений типов, констант и подпрограмм. Вместо файловых переменных при обращениях к нашим функциям последовательного буферизованного чтения и записи – fread и fwrite – следует использовать переменные типа type
_file= record fvar:file; buf:pointer;
{указатель на буфер}
buf_index:integer; {указатель в буфере чтения-записи} for_read:boolean;
{true, если файл открыт для чтения}
buf_end:integer;
{динамическая длина буфера при чтении}
{(последний буфер чтения может быть неполным)} end;
На самом деле, для использования наших fread и fwrite не требуется знать структуру типа _file, точно также Вы обходитесь без знания структуры файловых переменных. Нам потребуются также объявления, которые могут уточняться: type
f_element=…;
const buf_size=…;
{тип записей файлов – задает пользователь} {размер буфера – в записях, а не байтах}
Для открытия файла предлагается использовать функцию {$I-} function fopen(var f:_file;name:string; for_read:boolean):boolean; {Открывает файл для последовательного чтения, если for_read=TRUE,} {иначе для последовательной записи. Возвращает FALSE при ошибке.} begin if MaxAvail
67
else begin
{enough memory}
assign(f.fvar,name); if for_read
then reset(f.fvar,SizeOf(f_element)) else rewrite(f.fvar,SizeOf(f_element));
if IOResult<>0 then fopen:=false else begin
{file opened}
fopen:=true; GetMem(f.buf,buf_size*SizeOf(f_element)); f.buf_index:=0; f.for_read:=for_read; if for_read then blockread(f.fvar,f.buf^,buf_size,f.buf_end); {если чтение – заполним буфер} end; end; end;
{fopen}
Для закрытия – procedure fclose(f:_file); begin if not f.for_read then blockwrite(f.fvar,f.buf^,f.buf_index); {если запись, запишем последний буфер} dispose(f.buf);
{освободим память}
close(f.fvar); end;
{fclose}
Фактически разработка архитектуры модуля закончена, и сами функции чтения-записи теперь составить элементарно: type
TA=array[0..1] of f_element; pTA=^TA;
function fread(var f:_file;var x:f_element):boolean; {последовательное чтение элемента x из файла f, открытого fopen} {Возвращает FALSE, если прочитан весь файл} begin if (f.buf_end=f.buf_end)
68
then fread:=false {End Of File} else begin {not EOF} fread:=true; x:=pTA(f.buf)^[f.buf_index]; f.buf_index:=f.buf_index+1;
{элемент – из буфера} {указатель
на
следующий
элемент
буфера} if f.buf_index>=buf_size then begin
{если буфер прочитан}
blockread(f.fvar,f.buf^,buf_size,f.buf_end);
{новый буфер}
f.buf_index:=0; end; {if } end; end;
{else, not EOF}
{fread}
function fwrite(var f:_file; x:f_element):boolean; {Возвращает FALSE при ошибке записи} var count:word;
{счетчик записи}
begin pTA(f.buf)^[f.buf_index]:=x;
{элемент – в буфер}
f.buf_index:=f.buf_index+1; {указатель на следующий элемент буфера} fwrite:=true; if f.buf_index>=buf_size then begin
{если буфер заполнен}
blockwrite(f.fvar,f.buf^,buf_size,count); {запишем буфер} if count
{fwrite}
Следующая программа (буферизованного) копирования одного файла в другой демонстрирует
использование
разработанной
подсистемы
буферизации
последовательного чтения-записи файлов: var
f1,f2:_file; x:f_element;
69
begin {программа копирует файл source в destination} if not (fopen(f1,'source',true) and fopen(f2,'destination',false)) then writeln('file open error') else
begin while fread(f1,x) do if not fwrite(f2,x) then break; fclose(f1); fclose(f2); end;
end.
Слияния внешней сортировки можно начинать с совершенно неупорядоченных данных, считая длины серий равными 1. Однако, можно существенно (на сколько?) сократить число проходов, если в первом проходе считывать буфера максимальной длины, сортировать их (в оперативной памяти, используя Quicksort) и сбрасывать серии (максимальной длины) на ленты (для внешней сортировки). Понятно, что когда длины серий превосходят доступный объем оперативной памяти, алгоритмы сортировки массивов уже никак не помогут и надо использовать слияния. Рассмотрим сортировку массивов, которая позволяет получать начальные серии большие (!), чем используемый буфер. Она называется Heapsort, или пирамидальная сортировка, или сортировка с помощью
дерева
проигравших).
(иногда
Во-первых,
–
дерева
заметим,
что
1
двоичное дерево можно хранить в виде массива, считая, что для элемента k элемент k div 2 является родителем, 2 ⋅ k – левым потомком, 2⋅k +1
–
правым.
Нумерация
2
3
элементов
начинается здесь с 1, первый элемент – корень
4
5
6
7
дерева (см. рис.): Теперь назовем пирамидой для массива a[1..n] элементы от L до R, если выполняются соотношения: ai ≥ a2i , ∀i : i ≥ L, 2i ≤ R, ai ≥ a2i +1 , ∀i : i ≥ L, 2i + 1 ≤ R. При отображении массива в дерево это означает, что предок больше потомков (для элементов, принадлежащих пирамиде). Heapsort начинается с построения пирамиды – 70
элементы массива переставляются так, чтобы весь массив стал пирамидой. Очевидно, что элементы a[n div 2+1]..a[n] удовлетворяют определению пирамиды, т.к. у них нет предков. Далее пирамида расширяется влево – элементы i:=n div 2 downto 1 «просеиваются» через пирамиду – элемент переставляется с большим из потомков, если он меньше его. Конечно, на новом месте элемент должен опять «сыграть» с потомками. Когда пирамида построена, в ее вершине (корне дерева) – наибольший элемент. Далее 1-ый элемент массива переставляется в конец, а новый 1-ый элемент просеивается через пирамиду 1..n–1, и т.д. Таким образом, пирамида сжимается справа, и массив заполняется с конца невозрастающими элементами. Теперь нетрудно разобраться в процедуре сортировки: procedure HeapSort(p:pointer;n:integer); {пирамидальная сортировка массива of real длины n по адресу p} type
T=array[1..2] of real; pT=^T;
var
l,r,i,j:integer; {l, r – границы пирамиды} x:real;
begin r:=n; for l:=n div 2 downto 1 do begin {построение пирамиды} i:=l;
{просеиваемый элемент}
x:=pT(p)^[i]; while true do begin {просеивание} j:=2*i; {j – левый потомок} if j>r then break;
{потомка нет – просеивание закончено }
if jpT(p)^[j] then j:=j+1;
{ j – больший потомок}
if x>=pT(p)^[j] then break; {потомки меньше – просеивание закончено} pT(p)^[i]:=pT(p)^[j]; {потомок вверх по дереву} i:=j; end;
{элемент x встал на место потомка и просеивается дольше}
{просеивание закончено}
pT(p)^[i]:=x; { элемент x встал на свое место в пирамиде} end;
{пирамида построена}
71
while r>1 do begin {сжатие пирамиды} x:=pT(p)^[r]; pT(p)^[r]:=pT(p)^[1]; i:=1;
{макс. элемент – в конец пирамиды}
{элемент 1 просеивается через пирамиду 1..r–1}
r:=r–1; while true do begin {просеивание (как при построении пирамиды)} j:=2*i; if j>r then break; if jpT(p)^[j] then j:=j+1; if x>=pT(p)^[j] then break; pT(p)^[i]:=pT(p)^[j]; i:=j; end;
{просеивание закончено}
pT(p)^[i]:=x; end;
{сжатие пирамиды закончено}
end;
{HeapSort}
Очевидно,
что
просеивание
элемента
имеет
логарифмическую
сложность
(пропорционально высоте дерева). Следовательно, сложность Heapsort есть O (n log n ) , как и у алгоритма Quicksort. Хотя порядок сложности у обоих алгоритмов одинаков, Quicksort в среднем почти в два раза быстрее (не зависит от большого n), чем Heapsort, изобретенная ранее (1962 г.). Значит, просто для сортировки массивов Heapsort применять не следует. Однако Heapsort более эффективно создает начальные серии для внешней сортировки. Заменим знаки неравенств в определении пирамиды и алгоритме просеивания. Теперь построим пирамиду из буфера ленты, в ее вершине будет минимальный элемент. Минимальный элемент пишется в выходную серию (а не переставляется с последним элементом пирамиды, как при сортировке массива), а на его место ставится для просеивания следующий элемент ленты пока он больше удаленного элемента. Если последнее условие не выполняется, то серия закончена, и для нее остается провести сжатие пирамиды. В среднем длина серии оказывается в два раза больше размера буфера сортировки. Упражнение. Процедура создает файл упорядоченных серий из данных входного файла, используя Heapsort.
72
Упражнение. Программа внешней сортировки с буферизацией ввода-вывода и генерацией начальных серий с помощью Heapsort Обсудим еще одну задачу, в которой идея Heapsort наиболее эффективна. Пусть имеется очередь данных, которая пополняется в произвольные моменты времени, и из которой в произвольные моменты времени надо вынимать минимальные (на момент запроса) элементы. Такая подзадача возникает, например, в алгоритме Дейкстра (см. ранее). Если очередь хранить как упорядоченный массив (динамической длины), то обслужить запрос на минимальный элемент элементарно. А вставка новых элементов в очередь потребует логарифмической сложности двоичного поиска и линейной сложности сдвига элементов массива. При большой размерности очереди линейная сложность вставки может оказаться неприемлемой. Организация очереди в виде пирамиды более эффективна. Действительно, минимальный элемент берется из вершины пирамиды, а поступающие в очередь элементы просеиваются через пирамиду (она динамически расширяется вправо). Теперь для вставки одного элемента мы имеем логарифмическую сложность (а полная упорядоченность очереди здесь не нужна). Упражнение. Определите структуру данных динамической очереди и напишите процедуры put и get: вставки элемента в очередь и удаления из очереди элемента с минимальным ключом, на основе Heapsort.
73
§10 BMP-ФАЙЛЫ. ЭЛЕМЕНТЫ КОМПЬЮТЕРНОЙ ГРАФИКИ.
Рассмотрим системно-независимую графику – работу с BMP-файлами. Результаты работы можно просмотреть стандартными программами. Понятно, что прямоугольное растровое изображение (битовая карта, или bit map) представляет собой матрицу (массив с двумя индексами) цветов точек. Кроме растра файл изображения должен иметь заголовок (header), в котором, прежде всего, указано число точек в строке изображения (строке матрицы), иначе было бы невозможно разделить растр (некоторое число последовательных байтов памяти) на строки. Во-вторых, заголовок файла содержит информацию о цветах. В BMP-файле можно хранить монохромное изображение (1 бит на точку, или 8 точек в байте), 16-ти-цветное (4 бита на точку, 2 точки в байте), 256-цветное (8 бит на точку, точка = байт) или 24-х-битное (цвет точки занимает 3 байта, 224=16 Мб цветов). В 24-х-битном растре для каждой точки хранятся интенсивности (0..255) синего, зеленого и красного цвета (RGB). В 4-х-битных и 8-мибитных растрах хранятся номера цветов, соответствие между номерами цветов и истинными цветами (RGB) устанавливает палитра, которая также должна храниться в заголовке файла (если 4 или 8 битов на точку). Палитра представляет собой массив из 16 или, соответственно числу цветов, 256 четверок байтов – B,G,R,0 (четвертый байт нулевой). Теперь все, что необходимо знать для работы с BMP-файлами – это формат заголовка. Программистам часто приходится иметь с различными форматами данных: стандартными или придумывать свои форматы. Заметим, что в заголовке BMP-файла содержится его длина, т.е. она динамическая, и заголовок может иметь дополнительные поля, которые мы не будем рассматривать. Основная структура заголовка такова: Заголовок BMP-файла
Смещение
Размер
(байты)
(байты)
0
2
‘B’,’M’ – 2 байта сигнатуры BMP-файла
2
4
Размер файла в байтах
6
4
Зарезервировано – должны быть нулевые байты
Содержание
74
10
4
Смещение растра от начала файла (заголовок + палитра)
14
4
18
2
Ширина растра (в точках) – длина строки
20
2
Высота растра (в точках) – число строк
22
2
Число битовых плоскостей – должна быть 1
24
2
Битов на точку (1, 4, 8 или 24)
Смещение палитры (если она есть) или растра (если ее нет) от этого места – динамическая длина этой части заголовка
Остается заметить, что строки растра хранятся (по умолчанию) в порядке снизу-вверх, и длины строк кратны четырем байтам (добиваются нулями, если число точек в строке не кратно 32, 8, 4 или 12, в соответствии с числом битов на точку). Конечно, можно читать нужные поля заголовка, используя произвольный доступ (seek), но лучше (все так делают) объявить соответствующую формату структуру данных: {заголовок BMP файла} var
fileheader:
record c1,c2:char;
{'BM'}
flen:longint;
{длина файла}
reserved:longint;
{0}
RasterOffset:longint; {начало растра в файле (смещение в байтах)} end; {заголовок растра} RasterInfo:
record infolen:longint;
{динамическая длина:}
{смещение таблицы цветов от начала этой структуры} {(после RasterInfo перед палитрой могут быть дополнительные поля)} width,height,planes,bits:word; {ширина, высота, 1, бит на точку} end;
Естественно с растром работать как с указателем (на массив байтов):
75
type
tRaster=array[0..1] of byte; tpRaster=^TRaster; {тип-указатель на массив}
Напишем процедуру инициализации 8-ми-битного растра: procedure CreateRaster8bit(nx,ny:word; var r:pointer); {nx – ширина растра (точек в строке)} var
i:word;
begin if nx mod 4 <> 0 then nx:=(nx div 4 + 1)*4; {выравнивание ширины растра} GetMem(r,nx*ny); for i:=0 to nx*ny–1 do tpRaster(r)^[i]:=255; {все точки пропишем цветом 255} end;
Ограничение MS-DOS на размер растра (64 К) считаем несущественным для понимания рассматриваемого способа работы – на этой площадке учатся тому, что будут делать на больших аренах. Палитру (8-битную) будем считать глобальной переменной (как и остальной заголовок): var palette8bit: array[0..255] of record B,G,R:byte; end;
256 цветов достаточно для передачи черно-белых фотоизображений. Этому случаю соответствует палитра в оттенках серого цвета (gray scale): for i:=0 to 255 do begin {Gray Scale} palette8bit[i].R:=i; palette8bit[i].G:=i; palette8bit[i].B:=i; {i – яркость, белый цвет – это равные интенсивности RGB} end;
Следующая процедура записывает изображение в BMP-файл: procedure WriteRaster8bit(nx,ny:word; r:pointer; fname:string); {запись BMP-файла fname, растр nx*ny с адреса r, 8-ми-битный} var
f:file; nx1:word;
{выровненная на границу long ширина растра}
begin
76
nx1:=nx; if nx mod 4 <> 0 then nx1:=(nx div 4 + 1)*4; {выравнивание ширины растра – напишете для упражнения без if} assign(f,fname); rewrite(f,1); with fileheader do begin
{заполняем заголовок}
c1:='B'; c2:='M'; RasterOffset:=sizeof(fileheader)+sizeof(RasterInfo)+sizeof(palette8bit); reserved:=0; flen:=RasterOffset+nx1*ny; end; blockwrite(f,fileheader,SizeOf(fileheader)); {запись первой части заголовка} with RasterInfo do begin {заполняем вторую часть заголовка} infolen:=sizeof(RasterInfo); width:=nx; height:=ny; planes:=1; bits:=8; end; blockwrite(f,RasterInfo,SizeOf(RastrInfo)); {запись второй части заголовка} blockwrite(f,palette8bit,SizeOf(palette8bit)); blockwrite(f,r^,nx1*ny);
{запись палитры}
{запись растра}
close(f); end;
{WriteRaster8bit}
В данном контексте вызов этой процедуры запишет в файл изображение, состоящее из точек белого цвета. Собственно рисование заключается в том, чтобы присвоить точкам растра некоторые цвета (до записи растра в файл). Следовательно, основная процедура – это «нарисовать» точку: procedure Pix8bit(x,y:word; r:pointer; nx:word; color:byte); {поставить точку x,y цветом color в растре r шириной (уже выровненной) nx} begin tpRastr(r)^[y*nx+x]:=color; end;
77
Упражнения. Выполните подпрограммы, необходимые для рисования в монохромных, 16-цветных и 24-х-битных файлах. При считывании BMP-файла не забудьте учесть динамическую длину заголовка (поле infolen в RasterInfo). Естественно, при 1 и 4 битах на точку не обойтись без битовых операций, которые мы хорошо изучили. Из точек можно нарисовать любое растровое изображение. Но сразу возникают задачи эффективной растеризации. Пусть необходимо соединить точки ( x0 , y0 ) и
(x1, y1 )
отрезком прямой. Во-первых, заметим, что цикл должен выполняться по той
координате (x или y), которая больше (по абсолютной величине) у направляющего вектора прямой. Если угол между прямой и осью x меньше 450, то прямая будет состоять из горизонталей и диагоналей, иначе из вертикалей и диагоналей. Во-вторых, прямое использование уравнения прямой, проходящей через две точки: y = y 0 + ( x − x0 ) ⋅
∆y , где ∆y = y1 − y0 , ∆x = x1 − x0 , ∆x
предполагает использование арифметики с плавающей точкой. Эффективность алгоритма Брезенхейма растеризации прямой заключается в использовании только
целочисленного сложения. Не ограничивая общности, будем считать ∆x ≥ ∆y ≥ 0 , то есть отрезок в первом октанте. Знак величины
ε = ( y − y0 )∆x − ( x − x0 )∆y определяет, выше или ниже прямой лежит точка следует
сместиться
по
горизонтали
(x, y ) .
( x := x + 1 ),
Очевидно, что при ε ≥ 0
иначе
–
по
диагонали
( x := x + 1; y := y + 1 ). Для обновления ε не требуется даже умножения. В первом случае ε := ε − ∆y , во втором ε := ε − ∆y + ∆x . Упражнение. Процедура line(x0,y0,x1,y1:word) по алгоритму Брезенхейма (через процедуру рисования точки, то есть независимо от числа битов на точку). Аналогично решается задача растеризации окружности. Для окружности радиуса R (целое число) с центром (0,0) знак величины
ε = x2 + y2 − R2 , определяет, вне или внутри окружности лежит точка (x, y ) . Рисование первого октанта можно начать с точки (R,0 ) . Далее при ε ≤ 0 делаем шаг по вертикали:
(
)
y := y + 1; ε := ε + y 2 − ( y − 1)2 ≡ ε + y + y − 1 ;
78
иначе (оказались вне окружности) – по диагонали:
) (
(
)
x := x − 1; ε := ε + y 2 − ( y − 1)2 + x 2 − ( x + 1)2 ≡ ε + y + y − x − x − 2 ;
y := y + 1;
Цикл продолжается пока
x ≥ y . Остальные октанты окружности рисуются из
симметрии. Итак, алгоритм растеризации окружности опять же обходится даже без умножения: procedure circle(x,y,R:word;color:byte); {окружность радиуса R с данным центром и цветом} var eps,x1,y1:integer; begin eps:=0; x1:=R; y1:=0;
{инициализация – первая точка}
while x1>=y1 do begin putpixel (x+x1,y+y1,color);
{точка в 1-м октанте}
putpixel (x–x1,y+y1,color);
{симметричные точки}
putpixel (x+x1,y–y1,color); putpixel (x–x1,y–y1,color); putpixel (x+y1,y+x1,color); putpixel (x–y1,y+x1,color); putpixel (x+y1,y–x1,color); putpixel (x–y1,y–x1,color); if eps<=0 then begin
{движение по вертикали}
y1:=y1+1; eps:=eps+y1+y1–1; end else begin
{движение по диагонали}
y1:=y1+1; x1:=x1–1; eps:=eps+y1+y1–x1–x1–2; end end; end;
{while}
{circle}
Упражнение. Процедура рисования эллипса. Фотоизображение
можно
рассматривать
как
частный
случай
цветокодированного изображения функции двух переменных. Если функция u ( x, y )
принимает значения от umin до umax , то значению u в шкале из 256 цветов можно сопоставить номер цвета = round((u–umin)*255.0/(umax–umin))
79
При обработке изображений часто применяются фильтры – новое изображение u~ получается из старого u как свертка u с фильтром (матрицей) f : u~ij =
k =K
l=L
∑ ∑
k =−K l =−L
ui − k , j − l f kl .
Упражнение. Программа повышения контрастности изображения, содержащегося в BMP-файле. Сложность вычисления свертки определяется размером фильтра. Применение длинного фильтра к большому растру занимает много времени. Эффективнее применять длинные фильтры с помощью алгоритма быстрого преобразования Фурье (см. далее). В узком смысле предметом компьютерной графики является построение трехмерных изображений (на плоском экране). Основной принцип заключается в том,
что яркость точки изображения определяется углом между направлением на соответствующую точку изображаемой трехмерной поверхности и направлением отраженного в этой точке луча от источника света. Пусть точка ( x, y , z ) видимой части поверхности проецируется в направлении зрения n€ ( n€ = 1 ) в точку (ξ ,η ) плоскости
экрана. Оставим без рассмотрения вопросы алгоритмов определения видимой части поверхности, учета перспективы и уменьшения яркости из-за расхождения лучей. Алгоритмы зависят также от способа задания поверхностей. Пусть в точке ( x, y , z ) вычислен единичный вектор внешней нормали n€1 и единичный вектор n€2 задает направление на источник (бесконечно удаленный, как и экран). Если поверхность задана аналитически z = f ( x, y ) , и мы смотрим сверху (относительно z), то ∂f ∂f n€1 = − ,− , 1 ∂x ∂y
2
2
∂f ∂f + + 1 . ∂x ∂y
Направление отраженного луча в любом случае вычисляется как n€3 = n€1 (n€2 , n€1 ) − (n€2 − n€1 (n€2 , n€1 )) = − n€2 + 2n€1 (n€2 , n€1 ) , где (•,• ) есть скалярное произведение векторов. Последняя формула выражает закон отражения – отраженный луч принадлежит плоскости падающего луча и нормали, и углы лучей с нормалью равны – при этом тангенциальная (касательная к поверхности) составляющая вектора n€2 изменяет знак. Теперь точке (ξ ,η ) можно присвоить яркость
(n€, n€3 )
или некоторую функцию этого косинуса, и построить ее цветокодированное 80
изображение. При построении изображений прозрачных (отражающих, преломляющих, рассеивающих) объектов приходится использовать трассировку лучей. Упражнение. Программа создает BMP-файл с трехмерным изображением кругового конуса.
81
§11 БЫСТРОЕ ПРЕОБРАЗОВАНИЕ ФУРЬЕ.
Быстрое преобразование Фурье (БПФ) – это знаменитый эффективный алгоритм (Кули и Тьюки, 1965) вычисления дискретного преобразования Фурье (ДПФ), которое для ряда (массива) u[0..N-1] определяется следующим образом: kl 2π i 1 N −1 wl = ∑ uk e N , l = 0..N − 1 , N k =0
где i – мнимая единица, eiz ≡ cos z + i sin z . То есть оператор ДПФ преобразует один ряд (вообще говоря, комплексных чисел) в другой, это можно записать как w = F [u ] . Замечательно, что этот оператор имеет обратный, отличающийся только знаком в экспоненте. Докажем это:
(F
−1
[F [u]] )m = ( F
−1
kl lm N −1 − 2π i lm N −1 − 2π i 2π i 1 N −1 1 [w] m = ∑ wl e N = N ∑ e N ∑ uk e N N l =0 k =0 l =0
)
l (k − m ) 1 N −1 N −1 2π i N = = um , ∑ uk ∑ e N k =0 l =0
так как N −1 − 2π i lp N , при p = 0, N = e 0, p ≠ 0 (p − целое, как сумма геометрической прогрессии ). l =0
∑
Еще одно важное свойство ДПФ: преобразование Фурье свертки есть произведение преобразований Фурье сворачиваемых функций.
Упражнение. Докажите теорему о свертке. Очевидно, что прямое вычисление ДПФ требует N 2 умножений. Алгоритм БПФ сокращает число умножений (самых длительных операций) до N log 2 N . Значит, при двумерном преобразовании Фурье растра 1000 × 1000 точек БПФ дает выигрыш в 10000 раз. Мы не можем здесь останавливаться на приложениях, а рассмотрим сам алгоритм БПФ, основанный на битовых операциях. Будем считать, что N есть степень двух, то есть n = log 2 N – целое. (Если длина ряда не является степенью двух, он продолжается нулями.) Пусть
2π i q=e N ,
тогда надо вычислить суммы 82
N −1
∑ uk q kl .
wl =
k =0
Представим индекс суммирования в двоичной системе: k = k 0 + 2k1 + 2 2 k 2 + ... + 2 n −1 k n −1 , и введем функцию n булевских переменных u 0 (k 0 , k1 ,..., k n −1 ) = uk , соответствующую данному ряду. Тогда vl =
1
1
1
∑ q k l ∑ q 2k l ... ∑ q 2 0
k0 =0
1
k1 = 0
n −1
k n −1l
k n −1 = 0
u 0 (k 0 , k1 ,..., k n −1 ) .
В БПФ эти суммы вычисляются последовательно, начиная с внутренней. Представим l также в двоичной системе: l = l0 + 2l1 + 2 2 l2 + ... + 2 n −1 ln −1 , тогда самая внутренняя сумма есть u1 (l0 , k 0 , k1 ,..., k n − 2 ) =
1
∑ q2
n −1
k n −1l 0
k n −1 = 0
u 0 (k 0 , k1 ,..., k n −1 )
= u 0 (k 0 , k1 ,..., k n − 2 , 0) + q 2
n −1
u (k 0 , k1 ,..., k n − 2 , 1).
l0 0
Следующая сумма: u 2 (l0 , l1, k0 , k1,..., k n − 3 ) =
1
∑ q2
n −2
k n −2 (l0 + 2l1 ) 1
u (l0 , k0 , k1,..., k n − 2 )
k n −2 = 0
= u1 (l0 , k0 , k1,..., k n − 3 , 0) + q 2
n −2
(l0 + 2l1 )u1 (l , k , k ,..., k ,1). 0 0 1 n −3
Предпоследняя сумма есть u n −1 (l0 , l1,...ln − 2 , k0 ) =
∑ q 2k (l 1
1 0 + 2 l1 + ... + 2
n −2
l n −2
k1 = 0
) u n − 2 (l0 , l1,..., ln − 3 , k0 , k1 )
= u n − 2 (l0 , l1 ,..., ln − 3 , k 0 , 0) + q 2(l0 + 2l1 +... + 2
n −2
l n −2
)u n − 2 (l0 , l1,..., ln − 3 , k0 ,1).
Последняя сумма совпадает с преобразованием Фурье: vl = u (l0 , l1 ,...ln −1 ) = n
∑ q k (l 1
0
k0 =0
0 + 2 l1 + ... + 2
n −1
l n −1
) u n −1 (l0 , l1,..., ln − 2 , k0 )
= u n −1 (l0 , l1 ,..., ln − 2 , 0) + q (l 0 + 2l1 + ... + 2
n −1
l n −1
)u n −1 (l0 , l1,..., ln − 2 ,1). 83
На каждом шаге сумма состоит из двух слагаемых и вычисляется за одно умножение (комплексное, с плавающей точкой) в N отсчетах. Следовательно, общее число умножений Nn = N log 2 N . Степени q (фазовые множители) следует насчитать заранее – в процедуре инициализации. Можно предложить следующие глобальные объявления: const nmax=1024; {некоторая степень двух, максимально возможная длина ряда} type complex=record re,im:real; end; procedure mult(a,b:complex;var c:complex); {умножение комплексных чисел c:=a*b} begin c.re:=a.re*b.re–a.im*b.im; c.im:=a.re*b.im+a.im*b.re; end; type TA=array[0..nmax–1] of complex; var
PhasePlus,PhaseMinus, {фазовые множители} Buf:TA;
var
nf,nf_div_2,nf_log_2:word;
{буфер для вычисления } {nf – степень двух, длина ряда}
sqr_nf:real; function init(len:integer):boolean; {инициализация для БПФ, len – фактическая длина рядов} var k:word; x:real; begin nf:=2; nf_div_2:=1; nf_log_2:=1; {ищем степень двух, превосходящую len} while nfnmax then init:=false else begin
{len не превосходит nmax}
init:=true; sqr_nf:=1/sqrt(nf); for k:=0 to nf–1 do begin {вычисление фазовых множителей} x:=cos(2*Pi*k/nf); PhasePlus[k].re:=x; PhaseMinus[k].re:=x; x:=sin(2*Pi*k/nf); PhasePlus[k].im:=x; PhaseMinus[k].im:=–x;
84
end; end; end;
{init}
Для составления самой процедуры БПФ остается решить простую задачку: перейти от числа j (индекс результата на шаге n − p ), заданного битовым представлением:
(
j = l0 , l1 ,..., ln − p −1 , k 0 ,..., k p −1
)
к индексу входного на данном шаге ряда:
(
m1 = l0 , l1 ,..., ln − p − 2 , k 0 ,..., k p −1 ,0
)
и индексу фазового множителя:
(
)
m2 = 2 p × l0 , l1 ,..., ln − p −1 ,0,...0 ,
при этом
p
уменьшается от
n −1
до 0. Это делается с помощью пары
непересекающихся масок, сумма которых равна N − 1 (все n битов включены). procedure FFT(var Buf1,Phase:TA); {БПФ комплексного массива Buf1, дополненного нулями до длины nf} {второй параметр равен PhasePlus или PhaseMinus (прямое или обратное ПФ)} var
p,j,m1,m2,mask1,mask2:word; z:complex; var l:byte;
begin mask1:=1; mask2:=nf–1–mask1; z.re:=sqr_nf; z.im:=0; for j:=0 to nf–1 do mult(buf1[j],z,buf1[j]); {нормировка на корень из длины} for p:=nf_log_2–1 downto 0 do begin for j:=0 to nf–1 do begin
{шаг вычисления сумм}
{в каждом отсчете}
m1:=j and (mask1 shr 1) or (j and mask2) shr 1; {индекс (отсчет) предыдущей суммы} m2:=(j and mask1) shl p; {индекс фазового множителя} mult(Buf1[m1+nf_div_2],Phase[m2],z); {умножение} Buf[j].re:=Buf1[m1].re+z.re;
{новая сумма в точке j}
Buf[j].im:=Buf1[m1].im+z.im; end;
{конец шага}
85
Buf1:=Buf; {вычисленный ряд становится исходным на следующем шаге} mask1:=mask1 shl 1 + 1; {обновим маски} mask2:=nf–1–mask1; end; end;
{FFT}
Упражнение.
Эта
процедура
сделана
возможно
более
понятной
в
ущерб
эффективности. Найдите резервы ускорения.
86
§12 ДИНАМИЧЕСКИЕ СПИСКИ.
Рассмотрим декларации: type
TPnode=^Tnode; Tnode=
record key:longint; info:Tinfo; next:TPnode; end;
Переменная типа Tnode содержит в поле next указатель на переменную того же типа (следующий узел списка). Это означает, что данные типа Tnode могут образовать цепочку (список), как показано на рисунке: top NIL
Здесь стрелки изображают значения полей next, равные адресам следующих (соседних) узлов. Поле next последнего элемента списка содержит значение NIL как маркер конца списка. Список передается подпрограмме как адрес первого элемента (вершины, top). Следующая процедура возвращает указатель на первый узел списка с данным
значением ключа: procedure find(top:TPnode; key:longint; var node:TPnode); {поиск в списке top ключа key} begin node:=NIL; {если ключа нет, выход NIL} while top<>NIL do {цикл прохода по списку} begin if top^.key=key then
{если ключ найден}
begin node:=top; break; end; top:=top^.next;
{на следующий узел, аналог i:=i+1}
end; end;
{find}
87
Естественно сравнивать списки с массивами (для выбора адекватной задаче структуры данных). В линейном списке возможен только последовательный поиск, для двоичного поиска создают другие списки (деревья – см. далее). С другой стороны, хотя размер динамического массива определяется во время работы программы (run-time), размер динамического списка вообще не ограничен (только общим размером
памяти). Заметим также, что стандарт языка Паскаль не позволяет создавать универсальные программы для обработки массивов (произвольной длины), но это не относится к спискам. Здесь используется только стандарт языка, в частности, выделение памяти с помощью оператора new, а не процедуры GetMem. Например, напишем модуль работы со стеком, реализованным как линейный список: type
TPnode=^Tnode; Tnode=
record info:StackElement; next:TPnode; end;
const SP:TPnode=NIL;
{Stack Pointer}
procedure push(x: StackElement); var NewNode: TPnode; begin
end;
new(NewNode);
{создание нового узла (выделение памяти)}
NewNode^.next:=SP;
{«привязываем» новый узел к списку}
NewNode^.info:=x;
{помещаем x в стек}
SP:=NewNode;
{теперь новый узел – вершина}
{push}
procedure pop(var x: StackElement); var top: TPnode; begin if SP<>NIL then begin
{если стек не пуст}
x:=SP^.info;
{берем x из стека}
top:=SP;
{запомним вершину – для удаления}
88
SP:=SP^.next; {указатель стека передвинем на следующий элемент} Dispose(top); {удалим старую вершину (освобождение памяти)} end; end;
{if}
{pop}
Естественно, что в программы, использующие стек (QuickSort или решение задачи коммивояжера), не придется вносить изменения, несмотря на изменение способа организации стека – в этом и состоит процедурно-ориентированное программирование. Упражнение. Программа читает строки текстового файла в динамический список, определяет максимальную длину строки и выводит данные списка в двоичный файл (все строки – максимальной длины). Если при удалении элемента массива надо сдвигать все последующие элементы (или как-то помечать «дырку»), то удаление из списка осуществляется изменением одного указателя: p до удаления
p^.next
p^.next^.next
после
Следующая процедура удаляет из линейного списка узел, расположенный после данного: procedure DeleteNext(p:TPnode); var node2del:TPnode; begin if p<>NIL then
{нельзя разыменовывать NIL}
if p^.next<>NIL then begin node2del:=p^.next; p^.next:=p^.next^.next;
89
{удаление из списка – см. рисунок} dispose(node2del); {удаление из памяти уже удаленного из списка узла} end; end;
{if}
{DeleteNext}
Упражнение. Процедура вставки узла в линейный список после данного. Обычно удобнее работать с двусвязным линейным списком, каждый узел которого хранит
адреса
обоих
соседей.
Это
соответствует
минимальному
изменению
объявления: type
TPnode=^Tnode; Tnode=
record key:longint; info:Tinfo; next,prev:TPnode; {указатели на следующий и предыдущий узлы} end;
и следующему рисунку: top
NIL
bottom
key
key
key
info
info
info
next
next
next
prev
prev
prev
NIL
Для этого рисунка справедливы равенства: top^.next^.prev=top, top^.next^.next=bottom, bottom^.prev^.prev=top. Как правило, следует держать указатели и на вершину и на дно (bottom) списка, хотя каждый из них может быть найден, если дан другой. Упражнение. Процедура чтения файла в двусвязный список. Упражнение. Процедура поиска и удаления элемента для двусвязного списка. Упражнение. Процедура сортировки двусвязного списка. 90
Упражнение. Если данные имеют два ключа поиска, можно хранить первичные ключи в линейном списке, узлы которого имеют указатели на линейные списки, хранящие вторичные ключи и информацию. Нарисуйте такую структуру данных, напишите соответствующие декларации и процедуры создания такого списка из файла и поиска в списке. Упражнение. Разреженные матрицы (т.е. состоящие почти из одних нулей) компактнее хранить как динамические списки (без нулевых элементов). Напишите процедуры
создания
списка-матрицы
и
сложения
матриц,
хранящихся
как
динамические списки.
91
§13 ДИНАМИЧЕСКИЕ ДЕРЕВЬЯ.
Декларация type
ptree=^Tnode; Tnode=record key:integer; left,right:ptree; end;
var
root:ptree;
соответствует двоичному дереву – каждая вершина дерева (узел списка) имеет левого и правого потомка. Получается рекурсивная структура данных – каждая вершина имеет левое и правое поддерево. Динамическое двоичное дерево может быть создано из массива a из n элементов (integer) с помощью вызова root:=CreateTree(a,0,n–1);
рекурсивной процедуры: function CreateTree(var x;left,right:integer):ptree; {элементы массива x от left до right – } { – в динамическую структуру (двоичное дерево)} var p:ptree; type TA=array[0..1] of integer; begin if right
{нет элементов – пустое поддерево}
else begin new(p); p^.key:=TA(x)[(left+right) div 2];
{корень поддерева}
CreateTree:=p; p^.right:=CreateTree(x,(left+right) div 2 + 1,right); {создано правое поддерево – из правой половины чисел} p^.left:=CreateTree(x,left,(left+right) div 2 – 1); {левое поддерево} end;
{else}
end; {CreateTree}
92
Эта
процедура
массива
(или
помещает его
части)
средний в
корень
элемент
4
дерева
(поддерева). Если элементы занумеровать с 1, то 8 элементов образуют следующее дерево:
2
6
По построению, дерево получается идеально сбалансированным, т.е. количество вершин в
1
3
5
7
каждом левом поддереве отличается от числа вершин в соответствующем правом поддереве не
8
более чем на 1.
Обход дерева означает посещение каждой вершины (например, с целью поиска).
Следующая процедура (ей, конечно, передается указатель на корень дерева) выводит на консоль ключи всех вершин: procedure PrintTree(p:ptree); {обход дерева} begin if p<>NIL then begin PrintTree(p^.left);
{обход левого поддерева}
write(' ',p^.key); PrintTree(p^.right); end; end;
в порядке «слева направо». Если оператор write поставить после рекурсивных вызовов, то получится порядок «снизу вверх». Арифметическое выражение может быть задано деревом-формулой,
например,
(A+B)*C+D/E
+
можно представить следующим образом: При
обходе
«слева
направо»
мы
получим *
инфиксную запись (только без скобок), при обходе «снизу Изменив
вверх»
–
структуру
постфиксную: данных
/
AB+C*DE/+.
(введя
пометки
+
С
D
E
вершин), можно выполнить обход дерева без рекурсии, но этот не тот случай, когда надо что-то
A
B
93
придумывать. Для деревьев (рекурсивные структуры) рекурсивные процедуры естественны – не надо усложнять простые решения. Упражнение. Процедура уничтожения двоичного дерева. Упражнение. Функция вычисляет высоту двоичного дерева (длина максимального пути от корня к листу). Следующая функция возвращает высоту двоичного дерева и ключи вершин, принадлежащих максимальному пути от корня к листу (как указатель на линейный динамический список). Функция выполнена по алгоритму «лобовой» рекурсии – она решает ту же задачу для каждого поддерева (а можно эффективнее?). Type plist=^Tlist; Tlist=record {линейный список} key:integer; next:plist; end; function height(root:ptree;var path:plist):integer; {вычисляет высоту двоичного дерева с корнем root} {и создает линейный список ключей максимального пути} var
p,p1,p2:plist; h1,h2:integer;
begin if root<>NIL then begin new(path); path^.key:=root^.key; {новый узел пути} h1:=height(root^.left,p1); {все сделано для левого поддерева} h2:=height(root^.right,p2); {все сделано для правого поддерева} height:=h1+1; path^.next:=p1; if h2>h1 then begin {если макс. путь в правом поддереве} height:=h2+1; path^.next:=p2; while p1<>NIL do begin p:=p1; p1:=p1^.next; dispose(p); end; {уничтожили список макс. пути в левом поддереве} end
{если макс. путь в правом поддереве}
94
else begin
{если макс. путь в левом поддереве}
while p2<>NIL do begin p:=p2; p2:=p2^.next; dispose(p); end; {уничтожили список макс. пути в правом поддереве} end end
{не пустое поддерево (root<>NIL)}
else begin height:=0; path:=NIL; end; end;
{пустое поддерево}
{height}
Если на вход процедуры CreateTree подать упорядоченный (по возрастанию) массив, то создается двоичное дерево поиска – ключ каждой вершины (по построению) не меньше ключей в ее левом поддереве и не больше ключей в ее правом поддереве. Процедура PrintTree, очевидно, выведет ключи в порядке возрастания. Понятно, что поиск в двоичном дереве поиска имеет логарифмическую сложность и не требует обхода (и рекурсии): function find(root:ptree;k:integer):ptree; {поиск ключа k в двоичном дереве поиска с корнем root} {возвращает указатель на вершину с ключом k или NIL, если такого ключа нет} begin while root<>NIL do begin if k=root^.key then break; if k
{find}
Упражнение. Процедуры вставки-удаления вершины с данным ключом в двоичное дерево поиска. Получается, что мы имеем логарифмический поиск в динамической структуре (что невозможно в линейных списках). Однако число сравнений при поиске ограничено двоичным логарифмом числа вершин только пока дерево остается сбалансированным. В результате вставок и удалений вершин сбалансированность нарушается, если ее специально не поддерживать. В предельном случае (нарисуйте) двоичное дерево поиска может превратиться в линейный список. Двоичное дерево поиска называется
95
АВЛ-деревом (по фамилиям авторов) если высоты любого левого и соответствующего
правого
поддерева
отличаются
не
более
чем
на
1.
Естественно,
такая
сбалансированность гарантирует логарифмический поиск, она слабее идеальной сбалансированности (см. выше) и ее проще поддерживать (при каждой операции удаления и вставки). Алгоритмы работы с АВЛ-деревьями можно найти в литературе. Заметим только, что поддержание сбалансированности эффективно, когда запросы поиска происходят существенно чаще, чем удаление-вставка. Упражнение. Процедуры вставки-удаления в дереве поиска, обновляющие поля вершин дерева, хранящие несбалансированность поддеревьев. Теперь можно проводить балансировку с помощью CreateTree тогда, когда сбалансированность существенно нарушена. Упражнение. Придумайте алгоритм создания дерева оптимального поиска, в котором поиск занимает минимальное время при заданных вероятностях (частотах) ключей поиска. Для деревьев степени больше n>2 – сильно ветвящиеся деревья – можно использовать следующее объявление: type
pMultiWayTree=
^TMultiWayNode;
TMultiWayNode = record key:integer; offsprings:array[1..n] of pMultiWayTree; end;
Здесь каждая вершина имеет n указателей на потомков, некоторые из указателей могут быть нулевыми (NIL). Следующее объявление адекватно сильно ветвящимся деревьям любой степени: type
pMultiWayTree=
^TMultiWayNode;
TMultiWayNode = record key:integer; right, down:pMultiWayTree; end;
Здесь отношения «отец – сыновья» заменяется отношениями «отец – старший сын» и «брат – следующий брат». Структура последней декларации в точности повторяет
96
двоичное дерево и выглядит такое дерево (нарисуйте) как двоичное дерево, повернутое на 450, но смысл иерархии совсем другой. Упражнение. Процедура создает сильно ветвящееся дерево подсчета слов файла – каждая вершина хранит букву и счетчик. Например, для 6-ти слов: аня, анна, би, анита, анна, бета – получается следующее дерево: а 0
б 0
н 0
е 0
и 0
н 0
т 0
а 2
я 1
и 1
т 0
а 1
а 1
Обсудите эффективность такой структуры данных. Б-деревья являются обобщением двоичных деревьев поиска. Каждая вершина
(страница) Б-дерева хранит массив ключей и указатели на поддеревья (их на 1 больше чем ключей), которые содержат ключи в интервалах, определяемых ключами страницы. Пример Б-дерева: 10 20 40 058
11 15
25 30 35
50 60 45 48
Б-деревья имеют смысл при поиске в данных, объем которых не может быть загружен в оперативную память. Тогда размер страницы определяется как максимальный блок памяти, работа с которым не замедляет систему существенно, а указатели являются не адресами в оперативной памяти, а именами (номерами) файлов, хранящих страницы. Упражнение. Процедура создает из файла файловую систему поиска по Б-дереву. Поиск в Б-дереве. 97
§14 ПРОПЕДЕВТИЧЕСКИЙ ОБЗОР ЯЗЫКА СИ.
Язык Си (C) – наиболее распространенный – очень близок к языку Паскаль. Оба языка вобрали в себя все лучшее на время появления. Любой алгоритм можно запрограммировать,
используя
пересечение
множеств
операций
этих
языков.
Операторные скобки (begin…end) записываются в Си как фигурные скобки. Компилятор Си различает большие и маленькие буквы. Объявления в Си располагаются внутри операторных скобок подпрограмм. Несколько различается приоритет (и обозначение) операций. В Си символы совместимы с целыми, а булевского типа нет – 0 есть false, иначе true. Точка с запятой в Си входит в оператор, а не является разделителем. Оператор return обеспечивает выход из функции (и возвращает значение). Константы задаются с помощью директивы define (это ее частный случай), которая означает макроподстановку (во время компиляции). Наиболее принципиальны два отличия: 1) К указателю можно прибавлять целое выражение, а массив является указателем на его нулевой элемент – массивы нумеруются с нуля. Это значит что выражения a[i] (элемент массива) и *(a+i) (смещение и разыменование указателя) эквивалентны. 2) В Си нет вложенности подпрограмм. Все функции (функция может не возвращать значение – типа void) равноправны, кроме функции main, с которой начинается выполнение программы. Параметры передаются только по значению.
Следующая таблица достаточна для трансляции с одного языка на другой основных конструкций: Паскаль
Си
begin…end
{…}
const n=10;
#define n 10
var
i,j:integer;
int i,j;
x,y:real;
float x,y;
a:array [0..n–1] of real;
float a[n];
Tcomp= record x,y:real end;
typedef struct {float x,y;} Tcomp;
type
98
var
z: record x,y:real end;
struct comp {float x,y;} z;
var
z1:Tcomp;
Tcomp z1;
const pi:real=3.14;
float pi=3.14159;
c:char=’a’;
char c=’a’;
s:string=’Hello’
char s[]=”Hello”; или char s[6]={'H','e','l','l','o',0};
var
p:pointer;
void *p;
pr:^real;
float *pf i:=j div 2;
i=j/2;
i:=j/2;
i:=j/2.0;
z.x:=pi;
z.x=pi;
pr^
*pf
type TA=array[0..1] of real; pTA=^TA; pTA(p)[i];
*((float *)p + i)
@x
&x
if i=j then begin…end
if (i==j) {…}
if (i<>j) and (x>y) or (i=1) then
if (i!=j && x>y || i==1)
i and 3 (битовое)
i&3
i or 3
i|3
not i
!i
while true do begin…end
while(1) {…}
x:=0; for i:=0 to n–1 do x:=x+a[i];
var
for(x=0,i=1;i
function sum(i,j:integer):real;
int sum(int i,int j)
begin sum:=i+j; end;
{return i+j;}
write(‘i=’,i:4);
printf(“i=%4d”,i);
writeln(s,x:8:2);
printf(“%s %8.2f\n”,s,x);
read(y);
scanf(“%f”,&y);
ft:text;
FILE *f, *ft;
f:file;
99
assign(ft,’c:\1.txt’); rewrite(ft);
ft=fopen(“c:\\1.txt”,”wt”);
assign(f,’c:\1.bin’); reset(f,1);
f=fopen(“c:\\1.bin”,”rb”);
if IOresult<>0 then begin обработка end
if(f==NULL) {обработка ошибки}
blockread(f,a,n*sizeof(real));
fread(a,n,sizeof(float),f);
blockwrite
fwrite
seek
fseek
writeln(ft,i:3,j:3);
fprintf(ft,”%3d%3d\n”,i,j)
close(f); close(ft);
fclose(f); fclose(ft);
GetMem(pr,n*sizeof(real));
pf=(float *)malloc(n*sizeof(float));
Следующая программка считывает двоичный файл чисел с плавающей точкой в динамический и буфер и записывает эти числа в текстовый файл. #include <stdio.h> #include <malloc.h> #include /*директива include включает в исходные тексты h-файлы:*/ /*объявлений типов, функций, и т.д.*/ /*help по функции содержит имена нужных h-файлов*/ float *pf; FILE *f, *ft; long flen,i; int main(void) { ft=fopen("1.txt","wt"); f=fopen("1.bin","rb"); if(f==NULL) {printf("no bin file\n"); return 1; /*выход из функции*/} flen=_filelength(_fileno(f)); /*длина файла*/ printf("%d\n",flen); pf=(float *)malloc(flen);
/*динамический буфер*/
fread(pf,flen,1,f);
/*чтение всего файла*/
flen/=sizeof(float);
/*число записей*/
for(i=0;i
100
fprintf(ft,"%10.5f\n",pf[i]); /*запись в текстовый файл*/ fclose(ft); fclose(f); return 0; }
ЛИТЕРАТУРА
1.
Вирт Н. Алгоритмы+структуры данных=программы. М, 1984.
2.
Вирт Н. Систематическое программирование. М, 1977.
3.
Грис Д. Наука программирования. М, 1984.
4.
Гэри М., Джонсон Д. Вычислительные машины и трудноразрешимые задачи. М,
1982. 5.
Дейкстра Э. Дисциплина программирования. М, 1978.
6.
Зуев Е.А. Программирование на языке Turbo Pascal 6.0, 7.0. М, 1993.
7.
Керниган Б., Ритчи Д. Язык программирования Си. М, 1985.
8.
Кнут Д. Искусство программирования. М, 1978.
9.
Немнюгин С.А. Turbo Pascal. С-Пб, 2001
10.
Самарский А.А., Гулин А.В. Численные методы. М, 1989.
11.
Самарский А.А., Гулин А.В. Численные методы математической физики. М, 2000.
12.
Уэйт М. и др. Язык Си. М.: Мир, 1988.
101