Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Демидов Основы программирования в примерах на языке ПАСЦАЛ 2010

.pdf
Скачиваний:
128
Добавлен:
16.08.2013
Размер:
1.28 Mб
Скачать

Глава 6. Циклы и рекуррентные соотношения

Сортировка в постоянном объеме памяти4

Задача: реализовать алгоритм сортировки в том же объеме памяти. Ограничение на объем памяти означает, что использовать дополнительный массив запрещается.

Алгоритм строится следующим образом:

1)найти минимальный элемент массива. Первый и минимальный элемент поменять местами. Запомнить количество элементов в отсортированной последовательности (на первом шаге это один элемент);

2)начиная с конца растущей отсортированной последовательности, выполнить действие 1. Повторять до тех пор, пока отсортированная последовательность не достигнет

длины n–1. Оставшийся элемент будет максимальным. Прежде чем приступать к реализации алгоритма рассмотрим за-

дачу обмена значениями двух переменных a и b. Очевидно, фрагмент кода

a := b; b := a;

не даст должного результата, так как после первого присваивания значение a будет потеряно. Можно воспользоваться дополнительной переменной buffer для сохранения a:

buffer := a; a := b;

b := buffer;

Однако гораздо интереснее решить эту задачу без дополнительной переменной:

a := a + b; b := a - b; a := a - b;

4 Данный подраздел – продолжение предыдущей главы. Перенесён в главу 6 из соображений сбалансированности глав по времени изложения.

61

Такое решение чревато переполнением типа при вычислении суммы или разности, т.е. не является универсальным. Здесь можно вспомнить, что числа представляются в двоичной форме, над которой определены безопасные битовые операции. Применяя операцию «исключающее или»:

a := a xor b; b := a xor b; a := a xor b;

Проверка. Пусть a = 510 = 01012, b = 310 = 00112. Тогда

a = 0101

xor 0011

= 0110

=

5

b

=

0110

xor

0011

=

0101

a

=

0110

xor

0101

=

0011

=

3

Вернемся к алгоритму сортировки. Потребуются два вложенных цикла, причем число витков второго цикла будет постоянно сокращаться с ростом отсортированной последовательности. Поскольку последовательность растет слева, имеет смысл двигать нижнюю границу счетчика цикла. Тогда

const

Max = 200; type

TCubes = array [1..Max] of integer; var

cubes: TCubes;

i, min, sorted: integer; begin

// инициализация массива

for sorted:=1 to Max-1 do begin

//поиск минимального элемента, начиная с sorted min := sorted;

for i := sorted+1 to Max do begin

if cubes[i] < cubes[min] then min := i; end;

//обмен

cubes[sorted] := cubes[sorted] xor cubes[min]; cubes[min] := cubes[sorted] xor cubes[min]; cubes[sorted] := cubes[sorted] xor cubes[min];

end;

// вывод результата на экран

end.

62

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

Примеры инициализации массива

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

Заполнить исходный массив можно несколькими способами: посредством ввода с клавиатуры (если массив не очень большой) или же случайными числами. Рассмотрим оба варианта инициализации массива и вывод массива на экран с помощью оператора цикла:

begin

// инициализация массива с клавиатуры for i:=1 to Max do begin

write('element ', i, ' = '); readln(cubes[i]);

end;

// инициализация массива случайными числами randomize; // инициализация датчика случайных чисел for i:=1 to Max do begin

cubes[i] := random(100); end;

// вывод на экран элементов массива через пробел for i:=1 to Max do begin

write(cubes[i], ' '); end;

end.

63

Пример зацикливания программы

Чтобы зациклить программу, достаточно поместить тело программы в оператор цикла repeat until. Таким образом, программа выполнится как минимум один раз, а далее всё будет зависеть от желания пользователя. Например:

var

s: string; begin

repeat

… // тело программы // запрос на повтор

writeln('run once again (yes/no)? '); readln(s);

until s ='nо'; end.

Пример вложенных циклов

Вывести на экран: 1 1 3

1 3 5

1 3 5 7

1 3 5 7 9

Для решения этой задачи посмотрим на внешний вид результата

– треугольная матрица со стороной n=5. Матрица – квадрат, а значит, предполагаемая сложность алгоритма – квадратичная, а это в свою очередь может говорить о наличии двух вложенных циклов в алгоритме. Действительно, один цикл может отвечать за перебор строк, а второй – за перебор столбцов, причем элементы выше главной диагонали печататься не должны. Далее можно заметить, что значение элемента можно однозначно вычислить по его положению в матрице. Пусть i [1..5] – номер строки, а j [1..5] – номер столбца, тогда aij = 2* j – 1. В принципе алгоритм готов.

var i,j,n: integer; begin

n:=5;

for i:=1 to n do begin for j:=1 to n do begin

if j<=i then write(2*j–1); end;

writeln;

64

end; end.

После того как наивный алгоритм завершен, можно взяться за его оптимизацию. Условие j<=i, по сути, ограничивает число витков второго цикла, так как никаких действий вне if не выполняется. Получаем

var i,j,n: integer; begin

n:=5;

for i:=1 to n do begin

for j:=1 to i do write(2*j–1); writeln;

end; end.

Общее число витков второго цикла теперь уменьшилось c n2 до 1+2+…+n = (n+1)*n/2. Сложность, тем не менее, осталась квадратичной, но по сравнению со сложностью прежней версии заметно улучшилась.

Понимание программ

Что напечатает следующая программа?

const

n=30; m=10; var

i: integer; begin

for i:=2 to m do

if (n mod i =0) and (m mod i =0) then writeln(i,` `);

end.

Для чисел от 2 до m=10 программа будет проверять, является ли это число делителем n и m (нулевой остаток). Т.е. программа напечатает общие делители n и m. Поскольку используется writeln, числа будут выведены в столбик:

2

5

10

65

Рекуррентные соотношения и их реализация в виде циклических программ

Рассмотрим задачу вычисления экспоненты ex.

На заре развития ЭВМ тригонометрические функции приходилось реализовывать программно. Когда появились языки третьего поколения, то вместе с компиляторами начали поставляться стандартные библиотеки, включающие набор математических функций. С появлением математических сопроцессоров большинство функций были реализованы на аппаратном уровне. Сейчас любой процессор умеет вычислять эти и другие функции, но сначала было разложение в ряд Тейлора. Такие функции, как exp, ln, sin, tg и др., аппроксимируются многочленом, который можно вычислить лишь с некоторой точностью в силу его бесконечности и ограниченности машинного представления чисел. Однако этой точности оказывается достаточно для прикладных вычислений.

Итак,

 

x

 

x

2

 

x

n

 

x

n

ex 1

 

 

...

 

...

 

.

 

 

 

n!

 

 

1!

2!

 

n 1

n!

Можно вычислить сумму последовательности, ограничив число n. Причем чем больше предел суммирования, тем точнее результат. Возведение в степень и вычисление факториала можно реализовать с помощью цикла; сложение членов последовательности также удобно реализовать в виде цикла. Рассмотрим один из способов реализации:

var

i,j, n, fact, pow: integer; sum, x: real;

begin readln(x); sum := 1; n := 100;

for i := 1 to n do begin

//вычисление степени x и факториала pow := 1;

fact := 1;

for j := 1 to i do begin pow := pow * x;

fact := fact * j; end;

//добавление к сумме очередного члена ряда

66

sum := sum + pow/fact; end; writeln('exp(x)=',sum);

end.

Оценим сложность этого алгоритма. Основной характеристикой задачи здесь будет число суммируемых членов последовательности n, так как именно от него зависит количество вычислений. Видно, что число витков внутреннего цикла зависит от значения счётчика внешнего цикла: на первом витке внешнего цикла внутренний цикл выполнится один раз, на втором – два раза и т.д. По методу Гаусса в общей сложности будет выполнено (n+1)*n/2 витков внутреннего цикла. Таким образом, суммарное число выполняемых команд равно 2+n*6+(n+1)*n/2*5 = 2.5n2+8.5n+2.

Квадратичная сложность обычно говорит о неэффективной реализации. Можно заметить, что каждый последующий член последовательности можно выразить через предыдущий следующим образом:

x an an 1 n .

Это соотношение называется рекуррентным (от лат. recurro – возвращаться). Оно позволяет вычислить очередной член последовательности гораздо быстрее. Например, рекуррентное соотношение для арифметических прогрессий выглядит так: an = an-1+b, для геометрических прогрессий: an = an-1*q, для факториала: an = an-1*n, для степени: an = an-1*x. При этом всегда необходимо задавать первый член последовательности (её базис).

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

var

i,j, n: integer; sum, x, member: real;

begin readln(x); member := 1; sum := member; n := 100;

for i := 1 to n do begin member := member * x / i; sum := sum + member;

67

end; writeln('exp(x)=',sum);

end.

Сложность этого алгоритма линейная, так как от вложенного цикла удалось избавиться: 3+n*(3+2) = 5*n+3 = O(n). Это яркий пример упрощения вложенных циклов.

Оптимизация

Зачем оптимизировать программу, когда современные компьютеры обладают огромными аппаратными мощностями? На самом деле разница в сложности огромна: в оптимизированном варианте для расчета суммы тысячи членов ряда требуется выполнить порядка тысячи инструкций, а в наивном алгоритме – уже порядка миллиона (коэффициентами и меньшими степенями можно пренебречь). А теперь представьте, что разработанная функция вызывается миллионы раз в каком-либо цикле внутри приложения, например компьютерной игры. Вспомните работу в Word: пока человек печатает, программа автоматически пересчитывает число страниц, применяет нужные параметры форматирования, выравнивает текущую строку по ширине страницы, периодически выполняет автосохранение и проверку орфографии, подчеркивая незнакомые слова. Одним словом, всегда стремитесь оптимизировать свой алгоритм. Как сказал А.Д. Мишин, «делайте хорошо, плохо само получится». Возьмём это за правило.

Сдругой стороны, Д. Кнут предостерегает от преждевременной оптимизации. Дело в том, что если постоянно думать об уменьшении расхода памяти или времени, то можно настолько ухудшить структуру программы, что даже незначительные ее модификации будут приводить к возникновению ошибок. Вместо этого лучше оптимизировать уже готовую программу.

Сточностью до epsilon

Хотя алгоритм и стал эффективней, всё же число n было задано совершенно произвольным образом. Для задач, где точность вычислений играет решающую роль, такой подход неприемлем. Разложение sin(x) представляет собой бесконечную сумму убывающего ряда, где каждый последующий член ряда вносит всё меньший вклад в общую сумму. В теории пределов говорят о вычислении

68

сумм с точностью до некоторого малого , при этом суммирование прекращается при выполнении условия |an-an-1| < либо, как вариант, более простого условия |an| < . В нашей программе имеет смысл заменить цикл for на цикл с постусловием repeat until:

var

i,j: integer;

sum, x, member, epsilon: real; begin

readln(x); member := 1; sum := member;

epsilon := 0.0001; i := 1;

repeat

member := member * x / i; sum := sum + member;

i := i+1;

until member < epsilon; writeln('exp(x)=',sum);

end.

Эта программа посчитает сумму с точностью до 4-го знака после запятой.

Замечание: программист должен заранее позаботиться о том, чтобы условие выхода из цикла когда-нибудь выполнилось, иначе программа зациклится. Использованное условие применимо лишь при стремлении остаточного члена к нулю, а для возрастающих рядов такой подход вообще не уместен.

В качестве самостоятельного упражнения напишите программу, рассчитывающую sin(x) по формуле

sin(x) x

x3

x5

m 1

x2m 1

 

 

 

... ( 1)

 

 

...

3!

5!

 

 

 

 

 

 

(2m 1)!

Идея для решения: из формулы для an найти рекуррентное соотношение для вычисления an через an-1, т.е. зависимость an = f(an-1). В программе для вычисления экспоненты достаточно будет изменить всего пару строк.

69

Более сложные рекуррентные соотношения

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

Задача о кроликах и числа Фибоначчи

Задача формулируется следующим образом. Как-то хозяин купил пару кроликов. В первый месяц они росли, а во второй месяц принесли еще пару кроликов. Через месяц начала плодоносить выросшая пара кроликов, и первая пара также принесла потомство. Каждый месяц каждая взрослая пара кроликов приносила еще одну пару кроликов. Сколько пар кроликов будет у хозяина через n месяцев?

Леонардо из Пизы, сын Боначчи, обнаружил, что численность пар кроликов в любом месяце может быть рассчитана с помощью соотношений

fib(1) = 1 fib(2) = 1

fib(n) = fib(n–1) + fib(n–2)

Так был открыт ряд чисел (ряд Фибоначчи), каждое из которых представляет собой численность пар кроликов в i-м месяце:

1 1 2 3 5 8 13 21…

При построении программы простейшим решением было бы записывать ряд чисел Фибоначчи в массив, вычисляя в цикле очередное число на основе двух предыдущих элементов массива. Однако если число N выбирается пользователем программы произвольно, то никакого массива может не хватить. Собственно весь массив и не нужен, ведь для расчета очередного числа Фибоначчи достаточно помнить лишь два предыдущих числа.

Тогда программу можно построить следующим образом: var

i,n, f1,f2,f3: integer; begin

readln(n); f1 := 1; f2 := 1; f3 := 1; // расчёт

70

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]