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

Хэш-таблицы, стеки, очереди

< Лекция 7 || Лекция 8: 123 || Лекция 9 >

7.2. Распределители

Массивы и хеш-таблицы являются индексируемыми структурами.

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

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

put (x: G)
          — Добавить x в текущую структуру.
    

Сравните c put(x:G, i:INTEGER) для массивов или с put(x:G, k:KEY) для хеш-таблиц. Когда же приходится получать элемент, у вас нет возможности его выбора. Вы делаете запрос

item: G
        — Элемент, полученный из текущей структуры
    require
        not is_empty
    

У запроса нет аргументов (сравните с запросом item(i:INTEGER):G для массивов или с item( k:KEY):G для хеш-таблиц). Мы называем такие структуры распределителями по аналогии с автоматом, выдающим банки с напитком. Автомат, а не покупатель, решает, какую банку выдать покупателю.

Торговый автомат

Рис. 7.5. Торговый автомат

Распределители отличаются политикой, используемой для выбора выдаваемого элемента.

  • Last-In First-Out: выбирается элемент, поступивший последним из существующих. Распределитель с политикой LIFO называется стеком.
  • First-In First-Out: выбирается элемент, поступивший первым из существующих. Распределитель с политикой FIFO называется очередью.
  • Для очереди с приоритетами элементы обладают приоритетами (целое или вещественное число). Тогда по запросу будет выдаваться элемент, обладающий наибольшим приоритетом среди присутствующих. Может показаться, что этот случай ближе к индексированным структурам, но все же это пример распределителя, поскольку приоритет – это внутреннее свойство элемента, и распределитель, а не пользователь выбирает, какой элемент будет выдан.

У всех распределителей существуют четыре базисных метода: put и item с сигнатурами и предусловиями, показанными выше, а также булевский запрос

is_empty: BOOLEAN
          — Правда, что элементов нет?)
и команда для удаления элемента:
remove
          — Удалить элемент из текущей структуры.
      require
          not is_empty
    

Точно так же, как item не позволяет выбирать получаемый элемент, remove не позволяет выбирать удаляемый элемент. Удаляется тот элемент, который можно получить по запросу item, если выполнить его непосредственно перед вызовом remove.

Хорошая реализация распределителей должна выполнять все эти операции за время O(1). Примеры вскоре будут даны.

В некоторых библиотеках можно найти операции, которые комбинируют эффект item и remove: функцию, скажем, get, которая удаляет элемент, а в качестве результата выдает удаленный элемент. Такую функцию можно реализовать в терминах item и remove:

get: G
          — Функция с побочным эффектом, нарушающая принципы методологии!
      do
          Result:= item
          remove
      end
    

Мы не будем использовать такие функции, так как они меняют структуру и возвращают результат, нарушая правило, что только команды, но не запросы, могут менять состояние структуры (принцип разделения команд и запросов). По причинам, объясненным в предыдущих лекциях, предпочтительнее позволять клиентам получать доступ и удалять элементы двумя разными методами – запросом, свободным от побочного эффекта, и командой.

В следующих двух разделах рассматриваются стеки и очереди. Мы не будем рассматривать очереди с приоритетами, но всегда можно обратиться к библиотеке EiffelBase и ознакомиться с классом PRIORITY_QUEUE.

7.3. Стеки

Стек – это распределитель с политикой LIFO: элемент, к которому можно получить доступ, есть элемент, поступивший последним из существующих в распределителе. Этот элемент располагается в "вершине" стека, что соответствует естественному образу стека в обыденном смысле этого термина. Примером может служить множество словарей, громоздящихся на моем столе в предположении, что первым я могу взять словарь, находящийся на вершине этой груды (стека).

Стек

Рис. 7.6. Стек
"Ханойская башня", которая изучается в следующей лекции, посвященной рекурсии, также дает пример работы со стеком.

Стек. Основы

Операции над стеком часто известны как:

  • Push (втолкнуть элемент на вершину стека – команда put);
  • Pop (вытолкнуть элемент с вершины – команда remove);
  • доступ к элементу вершины (запрос item).

Эти операции можно визуализировать.

Концептуальный образ стека

Рис. 7.7. Концептуальный образ стека

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

Стеки имеют множество применений в компьютерной науке. Два примера из реализации языка программирования: один – статический (разбор, иллюстрируемый в простейшем случае обработкой "польской нотации"), другой – динамический, управление вызовами программ в период выполнения.

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

Рассмотрим для примера выражение

2 + (a + b) * (c – d)
        

В польской записи оно выглядит так

2 a b + c d – * +
        

Как будет происходить вычисление этого выражения? Первым знаком операции является +, так что выполнится сложение операндов a и b, предшествующих плюсу. Затем выполнится операция вычитания, затем умножение двух вычисленных операндов, последним выполнится сложение полученного результата с константой 2. Для простоты все операции бинарны, но схема легко адаптируется на произвольную "-арность" операций.

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

from                                   — Инициализация пуста
until
        "Все термы выражения уже прочитаны"
loop
        "Чтение очередного терма выражения - x"
    if "x является операндом" then
        s.put (x)
    else — x является знаком бинарной операции
              — Получить два верхних операнда
          op1:= s.item; s.remove
          op2:= s.item; s.remove
              — Применить операцию к операндам и поместить результат в стек:
          s.put (application (x, op1, op2))
    end
end
        

В алгоритме используются две локальные переменные op1 и op2, представляющие операнды. Функция application вычисляет результат применения бинарной операции к ее операндам, например, application('+', 2, 3) возвращает значение 5. На следующем рисунке показана ключевая операция алгоритма, соответствующая предложению else, – обрабатывается знак умножения для выражения нашего примера.

Вычисление выражения, записанного в польской нотации

Рис. 7.8. Вычисление выражения, записанного в польской нотации

Корректная реализация алгоритма должна справляться с ошибочным вводом (проверяя s.is_empty перед вызовом item и remove и проверяя, что x является знаком операции); необходимо также предусматривать возможность операций различной "-арности".

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

Вызов метода

Рис. 7.9. Вызов метода

В любой момент времени в период выполнения несколько методов – от p до t на рисунке – были вызваны, начали свою работу, но еще ее не завершили. Последний вызванный метод в этом случае называется текущим методом. Рассмотрим одну из его команд, например, присваивание x:= y + z. Если только x, y, z не являются атрибутами охватывающего класса, то они должны принадлежать текущему методу и быть либо его аргументами (но не x, поскольку аргументу нельзя присваивать значения), либо локальными переменными. Будем использовать термин "локальные" для обеих категорий. Для выполнения операторов программы, таких как присваивание, код, генерируемый компилятором, должен иметь доступ ко всем локальным переменным. Решением этой проблемы является создание для каждого вызова метода активирующей записи, содержащей его локальные переменные:

Стек периода выполнения и куча

Рис. 7.10. Стек периода выполнения и куча

Структура справа называется "кучей", она содержит объекты, получаемые в результате вызова процедуры создания или ее эквивалента. Для нашего обсуждения интерес представляет стек вызовов, также называемый стек периода выполнения (чаще всего просто стек), содержащий активирующие записи для всех текущих активных методов. Поскольку метод не может завершиться, пока не завершатся методы, вызовы которых он инициировал, и стартовавшие позже него, подходящей схемой активации является стратегия LIFO и стек будет подходящей структурой.

Во многих языках программирования тексты методов могут быть гнездованы – вложены друг в друга. Тогда операторы могут ссылаться не только на локальные переменные текущего метода, но и на локальные переменные любого охватывающего блока. Это означает, что выполнению может понадобиться доступ не только к верхней активирующей записи, но и к некоторым другим, расположенным ниже ее. В этой схеме структура, представляющая активирующие записи, по-прежнему называется стеком, но использует расширенное понятие стека. В языке Eiffel нет необходимости в гнездовании методов1В языке Eiffel локальные переменные могут быть объявлены только на уровне метода. У метода нет внутренних блоков, в которых могут объявляться локальные переменные блока, например, внутри составного оператора или цикла. Блочная структура метода, даже когда нет вложенности методов, требует расширенного понятия стека. .

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

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

Реализация стеков

Как и для некоторых других структур этой лекции, существуют две общие категории реализации стеков, основанные на массивах и на связных списках. Наиболее общая реализация использует массив rep типа ARRAY[G] и целочисленную переменную count с инвариантом

count >= 0 ; count <= rep.capacity
        

Здесь емкость capacity представляет число элементов массива (upper – lower + 1). Для массивов, индексируемых с 1, элементы стека, если они есть, хранятся в позициях от 1 до count.

Реализация стека на массиве

Рис. 7.11. Реализация стека на массиве
В классе ARRAY число элементов массива известно как count и как capacity, инвариант свидетельствует, что значения этих атрибутов эквивалентны. Не следует путать атрибут count для массивов с count для стеков – атрибутом, который задает число элементов стека, разворачиваемого на массиве.

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

В этой реализации запрос item, который дает элемент, расположенный в вершине стека, просто возвращает rep[count] – элемент массива в позиции count. Достаточно просто может быть реализована и команда remove: count:= count -1, а команда put(x) – как

count:= count + 1                 [9]
rep.force (x, count)
        
Листинг 7.9.

Здесь используется команда force для массивов, заставляющая перестроить массив, если отведенной памяти становится недостаточно.

Более подробно с реализацией можно познакомиться, изучая класс ARRAYED_STACK из библиотеки EiffelBase (фактически классу не нужен rep, поскольку он наследуется от ARRAY, но концептуально это эквивалентно, а мы все же формально наследование еще не изучали). Использование force в алгоритме для put означает, что можно не беспокоиться о размере массива – массив будет создаваться с установками по умолчанию, а потом подстраиваться под нужный размер данных.

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

Перестройка массива, применяемая в Eiffel, не является общедоступной в других программных средах, поэтому там часто стеки, базируемые на массиве, имеют ограниченную емкость. Соответствующий класс есть и в Eiffel – BOUNDED_STACK. Для такого стека наряду с count используется и запрос capacity, и булевский запрос is_full, чье значение дается выражением count = capacity. В этом случае, так же, как существует предусловие для команды remove, будет существовать предусловие и для команды putis_full. Реализация этой команды для такого стека использует put для массива, а не force, как в вышеприведенном примере 7.9. Выполнение предусловия гарантирует корректность выполнения put.

Все рассмотренные выше операции имеют сложность O(1).

Вариантом стека ограниченной емкости, реализованного на массиве, является стек, растущий вниз:

Реализация на массиве стека, растущего вниз

Рис. 7.12. Реализация на массиве стека, растущего вниз

В этом представлении count более не является атрибутом, вместо этого появляется скрытый атрибут free, задающий индекс первой свободной ячейки. Запрос count по-прежнему остается доступным, но реализуется он теперь функцией, возвращающей значение capacityfree.

Инвариант теперь устанавливает, что free >= 0 и free <= capacity. Сравните этот инвариант с инвариантом для count в предыдущем представлении стека.

Случай free = 0 соответствует is_full, а free = capacity соответствует is_empty. Элементы стека, если они есть, располагаются в позициях от capacity до free +1. Метод remove реализуется просто: free = free +1, а put реализуется как

rep.force (x, free)
free:= free – 1
        

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

Два стека на одном массиве

Рис. 7.13. Два стека на одном массиве

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

max (count1 + count2) ≤ max (count1) + max (count2)
        

В упражнении вас попросят написать реализацию класса TWO_STACK, воплощающего эту идею.

Наряду с реализацией на массивах вполне допустимо строить стек на связном списке. Действительно, связный список, изученный ранее в этой лекции, содержит готовую реализацию стека. Рисунок ниже иллюстрирует этот подход: первая ячейка является вершиной стека, а остальные – телом стека.

Связный стек

Рис. 7.14. Связный стек

Операция put(x) реализуется просто как rep.put_front(x), где rep задает связный список. Аналогично, item реализуется как rep.first и так далее. Класс LINKED_STACK в EiffelBase обеспечивает такую реализацию. Все базисные операции имеют сложность O(1), хотя чуть медленнее, чем их двойники на массивах, например, put_front из класса LINKED_LIST, а следовательно, и put из LINKED_STACK должны сперва создать и отвести память ячейке LINKABLE.

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

Операция Метод в классе стека Сложность Комментарий
Доступ к вершине item O(1)
Вталкивание на вершину put O(1) При автоматической перестройке иногда O(count)
Удаление с вершины remove O(1)
< Лекция 7 || Лекция 8: 123 || Лекция 9 >