Опубликован: 06.10.2011 | Доступ: свободный | Студентов: 1677 / 94 | Оценка: 4.67 / 3.67 | Длительность: 18:18:00
Лекция 6:

Фундаментальные структуры данных, универсальность и сложность алгоритмов

< Лекция 5 || Лекция 6: 12345 || Лекция 7 >

5.4. Массивы

Начнем с самого распространенного вида контейнера – массива.

Понятие массива является программистским понятием, но его важность определяется свойствами главной памяти компьютера, которая известна как RAM (Random Access Memory) – память со случайным доступом. Несмотря на такое имя, вовсе не предполагается, что в момент доступа к ячейке памяти компьютер случайным образом выбирает, из какой ячейки выбрать (или куда записать) значение (идея, сама по себе интересная). На самом деле эта память предполагает, что время доступа к ее ячейкам – для чтения или модификации – не зависит от адреса ячейки (понятие "случайный" следует трактовать, как во фразе: "вы можете случайным образом выбрать ячейку и не беспокоиться о времени доступа"). Если у Вас память в 2 GB, то доступ к первой ячейке (адрес 0) или к последней занимает одно и то же время.

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

Последовательный и случайный доступ

Рис. 5.3. Последовательный и случайный доступ

Свиток слева позволяет последовательное чтение (и запись). Справа показаны почтовые ящики, с прямым к ним доступом.

Массивы используют все преимущества прямого доступа, позволяя работать со структурой данных, хранимой в непрерывном сегменте памяти; доступ к каждому элементу массива возможен по индексу (номеру) элемента:


Рис. 5.4.

Границы и индексы

Массив имеет нижнюю и верхнюю границы, задаваемые запросами класса ARRAY[G]:

lower: INTEGER
        — Минимальный индекс.
upper: INTEGER
        — Максимальный индекс.
        

Инвариант класса устанавливает, что count – число элементов (также известное как емкость) задается соотношением upper – lower + 1. Так как count >= 0, требуется выполнение условия:

lower <= upper + 1
        

Случай lower = upper соответствует массиву с одним элементом, lower = upper +1 соответствует пустому массиву (эти наблюдения можно визуализировать, передвигая на последнем рисунке вправо нижнюю границу или влево верхнюю, пока они не пересекутся). Это законные состояния массива.

Почувствуй методологию
Принцип экстремальных случаев

При проектировании структуры объектов, например контейнеров, рассматривайте экстремальные случаи – пустую структуру, структуру с одним элементом, "полную" структуру, если задана максимальная емкость, – и убедитесь, что определения имеют смысл и в этих случаях.

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

Инвариант класса является первичным руководством для проверки того, что определение все еще имеет смысл. Здесь случай lower = upper +1 остается совместимым с инвариантом класса lower <= upper +1, полученное при этом минимальное значение count (upper -lower+1) все еще удовлетворяет требованиям.

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

valid_index (i: INTEGER): BOOLEAN
        — Является ли i правильным индексом, лежащим внутри границ?
    ensure
        Result implies ((i >= lower) and (i <= upper))
        

Создание массива

При создании массива необходимо указать предполагаемые значения границ:

your_array: ARRAY [SOME_TYPE]
…
create your_array.make (your_lower_bound, your_upper_bound)
При этом используется процедура создания:
make (min_index, max_index: INTEGER)
— Выделить память массиву; установить интервал для индекса min_index ..
— max_index
    — установить все значения по умолчанию.
    — (Сделать массив пустым, если min_index = max_index + 1).
require
    valid_bounds: min_index <= max_index + 1
ensure
    lower_set: lower = min_index
    upper_set: upper = max_index
    items_set: all_default
        

Как показывают первые два предложения в постусловии, процедура устанавливает lower и upper в соответствии с переданными в нашем примере значениями your_lower_bound и your_upper_bound. Они могут быть произвольными выражениями, например, константами:

create yearly_twentieth_century_revenue.make (1901,2000)
        

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

create another_array.make (m, m+n)
        

В приведенном примере интервал индексов имеет собственный смысл, задавая годы 20-го столетия. Если же просто необходимо создать массив из n значений, то нижняя граница обычно задается равной 1, а верхняя – n:

create simple_array.make (1,n)
        
Язык С и его последователи (С++, Java, C#) требуют, чтобы у всех массивов начальный индекс был равным 0. В примерах, таких как "годы 20-го столетия", это требует взаимных преобразований (сложение и вычитание 1901 в примере) между физическим индексом и его представлением. Для таких случаев, как simple_array, выбор между 0 и 1 – дело вкуса. Если вы, подобно мне, предпочитаете рассматривать большой палец на руке как первый, а не нулевой, а мизинец как пятый, а не четвертый, то выбор 1 кажется более разумным. Менее субъективный довод состоит в том, что начиная нумерацию элементов с нуля, приходится заканчивать ее номером n-1 для последнего элемента, и это, как показывает практика, является вечным источником ошибок.

Запрос all_default в последнем предложении постусловия выражает тот факт, что все элементы массива типа ARRAY [SOME_TYPE] будут после создания иметь значения по умолчанию, определяемые типом SOME_TYPE: ноль для INTEGER и REAL, false – для булевских, void – для любого ссылочного типа.

Доступ и модификация элементов массива

Приведем базисный запрос и команду для получения и модификации элемента массива:

item (i: INTEGER): G
        — Элемент с индексом i, если это правильный индекс.
    require
        valid_key: valid_index (i)
put (v: like item; i: INTEGER)
        — Изменить значение элемента с индексом i, если это правильный
            — индекс, на значение v.
    require
        valid_key: valid_index (i)
    ensure
        inserted: item (i) = v
        

В обоих случаях предусловие требует, чтобы индекс находился в границах массива. Типичное применение команды, если уже объявлены переменные your_array: ARRAY [SOME_TYPE] и your_value: SOME_TYPE:

your_array.put (your_value, your_index)
        

Вызов метода put установил новое значение соответствующего элемента массива:


Рис. 5.5.

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

your_value:= your_array.item (your_index)
        

присвоит переменной your_value значение элемента массива с индексом your_index.

Постусловие put показывает, что непосредственно после выполнения put значение item с заданным индексом есть значение, заданное в put. Примеры с put и item корректны только при условии, что гарантируются "правильные" индексы. Если гарантии нет, то следует использовать вызовы в форме:

if your_array.valid_index (your_index) then
your_array.put (your_value, your_index)
else
…
end
        

Аналогично для item.

Для любой разумной реализации массивов вызов put и item выполняется за константное время – О(1). Это свойство используемой для хранения массивов RAM-памяти и причина широкого использования массивов.

Скобочная нотация и команды присваивания

Следующая нотация, применяющая квадратные скобки, доступна как для класса ARRAY, так и для некоторых других классов, изучаемых в этой лекции:

your_value:= your_array [your_index]
    — Краткая запись для your_value:= your_array.item (your_index).
your_array [your_index]:= your_value
    — Краткая запись для your_array.put (your_value, your_index).
        

Это особенно удобно для таких операторов, как

a [i]:= a [i] + 1         [3]
        
Листинг 5.3.

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

Ничего магического в скобочной записи нет, и она не является спецификой массивов. Для того, чтобы применить ее к любому типу, где это имеет смысл, достаточно включить сочетание alias "[]" после имени соответствующего метода при его объявлении. Именно это и сделано в классе ARRAY для item:

item(i: INTEGER) alias "[]": G assign put
        — Элемент с индексом i, если индекс правильный
    require
        valid_key: valid_index (i)
    do
        —… Реализация метода …
    end
        

Добавление alias "[]" к имени метода означает, что квадратные скобки являются псевдонимом имени метода ( в данном случае – item) – еще одним способом вызова метода. В результате нотация

your_array [i]
        

это синоним (псевдоним) для

your_array.item (i)
        

В объявлении item также присутствует конструкция assign put. Любой запрос q, независимо от того, имеет ли он псевдоним в виде квадратных скобок, можно пометить assign c, где c – команда из того же класса, которому принадлежит запрос. Эффект состоит в том, чтобы сделать корректной нотацию, подобную присваиванию:

your_array.item(i):= your_value         [4]
        
Листинг 5.4.

представляющую краткую запись вызова команды put

your_array.put (your_value, i)          [5]
        
Листинг 5.5.

Предложение assign связывает команду (put) с запросом (item). Такие команды называются командами-присваивателями.

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

Поскольку item имеет скобочный псевдоним и связан с присваивателем, вполне законно использовать скобочную форму в последнем вызове:

your_array[i]:= your_value              [6]
        
Листинг 5.6.

Такая форма записи полностью согласуется с традиционной математической нотацией для массивов и векторов, используя в то же время семантику ОО-операций. Это сочетание и делает допустимым форму записи, использованную в примере 5.3.

Механизм команд-присваивателей применим к любым запросам, в том числе к атрибутам. Операторы 5.4, 5.6 хотя и имеют форму оператора присваивания, таковыми не являются, поскольку, как известно, скрытие информации запрещает прямое присваивание атрибутам (полям) класса. Они являются простыми вызовами процедур, эквивалентными 5.5 и соблюдающими все ОО-принципы. Это просто "синтаксический сахар", добавленный для удобства записи.

Большинство языков программирования, начиная с Pascal, C и C++ до Java и C#, предлагают скобочную запись для массивов, как при доступе (your_array [i]), так и при модификации (your_array [i]:= your_value). В большинстве случаев эта форма является спецификой массивов, а сами массивы рассматриваются как встроенный в язык специфический тип данных. В языке Eiffel ARRAY рассматривается как обычный класс с методами item и put, согласующийся с другими структурами данных и ОО-подходом (позволяя, например, наследование от класса ARRAY). Язык предлагает скобочную нотацию как псевдоним, благодаря конструкции alias "[]". Это общая конструкция, применимая не только к массивам. Она будет использоваться и при работе с другими структурами данных, в частности, с хэш-таблицами. Ее можно применять с тем же успехом при создании собственных классов.

Изменение размеров массива

В любой момент выполнения массивы имеют границы – lower и upper, следовательно, фиксированное число (count) элементов. Предусловие valid_index методов put и item отражает это свойство. В большинстве языков программирования это свойство массива устанавливается однажды и навсегда либо статически (используя константные границы), либо динамически в момент создания. В Eiffel можно перестраивать массив, используя метод resize:

resize (min_index, max_index: INTEGER)
            — Изменение границ массива, вниз к min_index
            — и вверх к max_index. Существующие элементы сохраняются.
        require
            good_indexes: min_index <= max_index
        ensure
            no_low_lost: lower = min_index.min (old lower)
            no_high_lost: upper = max_index.max (old upper)
        

Функции min и max, применяемые в постусловии, являются функциями над двумя целыми числами, дающими соответственно минимум и максимум из текущего числа, вызывавшего функцию, и ее аргумента.

Чаще всего для перестройки границ массива не вызывается специальный метод – она индуцируется при вызове метода force. Обычно, если нужно изменить значение элемента массива, базисным механизмом является метод put(v, i) с предусловием: valid_index(i). Это правильный способ работы, но при условии, что вы заранее знаете, какие элементы массива могут понадобиться. Если же в своих расчетах вы ошиблись, то это может привести к отказу в работе программы. В таких случаях для работы с массивом вместо put следует использовать метод force:

force (v: like item; i: INTEGER)
          — Заменить значение элемента массива на v, если индекс в допустимых
          — пределах.
          — Всегда применять перестройку границ массива, если индекс выходит
          — за пределы.
          —Сохранять существующие элементы
    ensure
          inserted: item (i) = v
          higher_count: count >= old count
        

В отличие от put, метод force не имеет предусловия, а потому всегда применим. Если i лежит вне интервала lower.. upper, процедура вызовет resize для изменения границ так, чтобы индекс оказался внутри нового интервала.

Из-за того, что реализация массива требует отведения ему непрерывного участка памяти, при перестройке массиву отводится обычно новый участок памяти и происходит копирование старых элементов массива:


Рис. 5.6.

Перераспределение и копирование – это дорогие операции со сложностью O(count). В результате и force имеет сложность O(count), в то время как сложность putO(1). Очевидно, что force следует использовать с осторожностью. Заметьте, что реализация force вполне разумна: она вызывает resize только при необходимости и, что более важно, изменяет размер массива с запасом, по умолчанию размер массива при перестройке увеличивается на 50%.

your_array.force (some_value, your_array.count + 1)
        

При повторных вызовах этого оператора force будет вызывать resize достаточно редко, выполняя в остальных случаях константное число операций.

Использование массивов

Массив типа ARRAY [G] представляет всюду определенную функцию, задающую отображение целочисленного интервала lower..upper в G. Если после создания границы lower и upper не изменяются или редко изменяются, то реализация высокоэффективна: так, доступ к значению и его модификация имеет сложность O (1) и, следовательно, работает быстро. Это делает массивы подходящими в тех случаях, когда:

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

Из-за высокой стоимости перераспределения массивы не подходят для высоко динамичных структур данных, где элементы приходят и уходят. В частности, операции вставки элемента в массив и удаление элемента являются дорогими операциями со сложностью O(count), поскольку это приводит к перенумерации элементов и их перемещению справа или слева от точки вставки или удаления. В таких ситуациях следует использовать другие структуры данных, которые будут рассмотрены позже в этой лекции.

Производительность операций над массивами

Вот итоговая таблица, содержащая стоимость операций.

Операция Метод в классе ARRAY Сложность Комментарий
Доступ по индексу item alias "[]" O(1)
Замена по индексу put alias "[]" O(1)
Замена по индексу вне текущих границ force O(count) Требует перераспределения массива. Только небольшая часть последовательных выполнений метода будет причиной перераспределения
Вставка нового элемента O(count) Требует перенумерации индексов. У класса нет такого метода
Удаление элемента O(count) Требует перенумерации индексов. У класса нет такого метода
< Лекция 5 || Лекция 6: 12345 || Лекция 7 >