Фундаментальные структуры данных, универсальность и сложность алгоритмов
5.4. Массивы
Начнем с самого распространенного вида контейнера – массива.
Понятие массива является программистским понятием, но его важность определяется свойствами главной памяти компьютера, которая известна как RAM (Random Access Memory) – память со случайным доступом. Несмотря на такое имя, вовсе не предполагается, что в момент доступа к ячейке памяти компьютер случайным образом выбирает, из какой ячейки выбрать (или куда записать) значение (идея, сама по себе интересная). На самом деле эта память предполагает, что время доступа к ее ячейкам – для чтения или модификации – не зависит от адреса ячейки (понятие "случайный" следует трактовать, как во фразе: "вы можете случайным образом выбрать ячейку и не беспокоиться о времени доступа"). Если у Вас память в 2 GB, то доступ к первой ячейке (адрес 0) или к последней занимает одно и то же время.
Память RAM противопоставляется памяти с последовательным доступом, где, прежде чем получить доступ к элементу, необходимо пройти некоторый путь от начальной точки через предшествующие элементы к искомой точке. Магнитные ленты являются типичным примером: прежде чем головка чтения-записи будет установлена на нужном элементе, необходимо ленту перемотать от исходной позиции головки к нужной точке ленты. Аналогии встречаются и в устройствах, не связанных с компьютерами:
Свиток слева позволяет последовательное чтение (и запись). Справа показаны почтовые ящики, с прямым к ним доступом.
Массивы используют все преимущества прямого доступа, позволяя работать со структурой данных, хранимой в непрерывном сегменте памяти; доступ к каждому элементу массива возможен по индексу (номеру) элемента:
Границы и индексы
Массив имеет нижнюю и верхнюю границы, задаваемые запросами класса 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)
Запрос 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 установил новое значение соответствующего элемента массива:
Заметьте порядок следования аргументов: сначала новое значение, затем индекс. После этого вызова оператор
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 и соблюдающими все ОО-принципы. Это просто "синтаксический сахар", добавленный для удобства записи.
Изменение размеров массива
В любой момент выполнения массивы имеют границы – 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 для изменения границ так, чтобы индекс оказался внутри нового интервала.
Из-за того, что реализация массива требует отведения ему непрерывного участка памяти, при перестройке массиву отводится обычно новый участок памяти и происходит копирование старых элементов массива:
Перераспределение и копирование – это дорогие операции со сложностью O(count). В результате и force имеет сложность O(count), в то время как сложность put – O(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) | Требует перенумерации индексов. У класса нет такого метода |