Фундаментальные структуры данных, универсальность и сложность алгоритмов
5.2. Контейнерные операции
Во второй части этой лекции дается обзор фундаментальных контейнерных структур, начиная с массивов, связных списков, других видов списков, и заканчивая стеками и хэш-таблицами. Все они широко используются на практике. У всех у них много общего и есть своя специфика.
- Большинство базисных операций одни и те же: вставка и удаление элемента, поиск заданного элемента, определение числа элементов …
- Каждый вариант контейнера по-своему реализует эти операции. Эти различия затрудняют обеспечение равно эффективной реализации для всех операций. Массивы позволяют получать элемент весьма быстро, если известен его индекс, но они медленны, если необходимо вставить новые элементы. Связные списки эффективны для вставки, но медленнее, чем массивы, для доступа по индексу. Другие структуры также имеют свои "за" и "против". Не существует единой структуры, оптимальной во всех ситуациях.
Когда возникает необходимость в контейнере, всякий раз приходится выбирать одну из доступных структур в зависимости от операций, которые необходимо выполнять над контейнером.
Прежде чем начать рассматривать специфические виды контейнеров, дадим обзор фундаментальных операций: вначале рассмотрим запросы, а затем команды. Пусть G будет обозначать тип элементов контейнера, и он будет всегда первым родовым параметром соответствующих классов, как в ARRAY [G] или LINKED_LIST [G].
Запросы
Одна из операций, необходимая всем контейнерам, – это запрос, определяющий, пуст ли контейнер (не содержит элементов). Запрос, возвращающий значение BOOLEAN, называется is_empty. Его сигнатура проста:
is_empty: BOOLEAN
Другими словами, у него нет аргументов, он вызывается в форме c.is_empty, возвращая булевское значение для каждого контейнера c.
Выяснить, находится ли некоторый элемент в контейнере, можно с помощью запроса
has(v:G):BOOLEAN
Для определения числа элементов в контейнере служит запрос:
count: INTEGER
Инвариант, применимый ко всем рассматриваемым контейнерным классам:
is_empty = (count = 0)
Для получения элемента контейнера, определяемого политикой контейнера, а не клиентом:
item: G
Некоторые контейнеры, такие как массивы, позволяют получить элемент по заданному клиентом индексу, как в предложении "дайте мне третий элемент". Такой запрос с параметром имеет вид:
item (i:INTEGER): G
Используется запрос с тем же именем, но неопределенности нет, поскольку различаются сигнатуры запросов.
Индекс, являющийся целым числом, является частным случаем ключа элемента, позволяющего получать элемент по ключу – информации, связанной с элементом. Есть много различных видов ключей – один из наиболее общих имеет тип string, как в контейнере, представляющем Web-страницу и позволяющем поисковой машине найти на странице заданный набор слов. Для строковых ключей запрос имеет вид:
item (i: STRING): G
Мы покажем, как обобщить тип ключа, допуская не только строки. Пока используемое имя запроса по-прежнему не приводит к конфликтам.
Команды
Процедура создания (конструктор класса) для контейнеров обычно называется make. Зачастую она не имеет аргументов. Но иногда задается аргумент, определяющий ожидаемое число элементов:
make(n:INTEGER)
Для всех контейнеров этой лекции n является указанием на начальное создание структуры данных, но не является абсолютным максимумом.
Для наиболее общей операции по добавлению или замене элемента используется имя put. Эта операция применима с разными сигнатурами, соответствующими сигнатуре запроса item, но с добавлением еще одного аргумента, задающего новое значение:
put (v: G) put (v: G; i: INTEGER) put (v: G; k: STRING)
Постусловие всегда должно включать предложение
inserted: has (x)
и вдобавок должно выражать отношение с соответствующей версией item:
- item = v, если put не имеет аргументов (первый случай);
- item (i) = v для версии с целочисленным индексом;
- item (k) = v в последнем случае.
Процедура put может либо добавлять новый элемент, либо заменять существующий. Иногда эти два случая необходимо различать, применяя или:
extend (v: G) extend (v: G; i: INTEGER) extend (v: G; k: STRING)
с постусловием
one_more: count = old count + 1
или для замены использовать
replace (v: G) replace (v: G; i: INTEGER) replace (v: G; key: STRING)
с постусловием
same_count: count = old count
Когда существует либо extend, либо replace, то put обычно является синонимом одного из них, соответствуя (если оба присутствуют) более общему использованию. Во всех случаях постусловие has(v) выражает то, что после добавления элемента структура должна давать ответ "да", если делается запрос о его присутствии.
Процедура для удаления элемента в зависимости от контекста называется remove или prune.
Стандартизация имен методов для базисных операций
Имена, применяемые выше – item, has, put …, – повторяются во всех библиотеках. Даже беглый взгляд на контейнерные классы показывает, что большинство из них содержит методы с этими именами.
Это осознанный выбор. Конечно, можно было бы придумывать новые имена для каждого класса, отражающие специфические свойства соответствующего контейнера. Но эти особенности уже отражены в сигнатуре, заголовочных комментариях и контрактах методов, например, для put в классе ARRAY:
put (v: like item; i: INTEGER) — Заменить i-й элемент на v, если индекс в допустимом интервале. require valid_key: valid_index (i) ensure replaced: item (i) = v
Аналогично для put в классе STACK:
put (v: G) — Поместить v в вершину стека. require extendible: extendible ensure pushed: item = v
Поэтому из-за сходства имен двусмысленность не возникает. Использование согласованных имен облегчает использование библиотек и – для новичков – обучение использованию: при знакомстве с новым классом читатели могут быстро идентифицировать ключевые методы и их назначение.
Почувствуй методологию
Используйте стандартные имена, когда они применимы, для методов ваших собственных классов, что улучшает согласованность и читабельность.
Автоматическая перестройка размера
Мы уже видели, что процедуры создания (обычно с именем make) позволяют задавать размер контейнера, но задаваемый аргумент следует рассматривать как указание начального размера, а не его постоянную максимальную границу. Структуры данных библиотеки EiffelBase почти всегда неограниченны или, имея начальную границу, могут изменять свой размер. Один из признаков хорошего программиста состоит в том, что в своих программах он избегает задания абсолютных границ.
Не позволяйте никому закрывать вас в камерах – это же справедливо и по отношению к пользователям вашей программы. У компьютеров большая память. Проектируя структуры данных, всегда возможно сделать так, что если размер данных превзошел ожидания, то нужно не отказываться работать с такой структурой, а перестроить ее, предоставив ей больше памяти. Если не следовать этому совету, можно столкнуться с самыми тяжелыми последствиями во время выполнения программы. Самое печальное, что программа прекратит работу, в то время как в системе достаточно пространства для ее работы.
Даже наши массивы должны быть перестраиваемыми.
Годом позже стала известна информация об отказе ПО, обеспечивающего выборы в Сан-Франциско. Причина была "в жестко зашитой константе, задающей максимальное число выборщиков, установленной слишком низкой".
Не попадайте в такие ловушки!
Почувствуй методологию
Не используйте встроенные постоянные границы. Позволяйте вашим структурам данных перестраивать размер, адаптируясь к потребностям, возникающим при решении конкретной задачи.
Мы увидим, что для некоторых вариантов контейнера, особенно для массивов, перестройка является дорогостоящей по времени операцией, так что к ней следует относиться внимательно. Однако помните, что соображения эффективности никогда не могут служить поводом для отказа от перестройки границ структур данных, если в этом возникает необходимость. Более того, структуры с фиксированными границами чаще всего вредят рациональному использованию памяти, так как "на всякий случай" запрашивают больше памяти, чем требуется в конкретной ситуации. Лучше вначале отвести память, исходя из ожидаемых средних потребностей, и перестроить структуру динамически, если необходимо.