1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 3 из 6

Инвариант представления: чем реализация держит обещание

Коротко

Откуда реализация ЗНАЕТ, что её операция уложится в обещанную цену? Она держит инвариант представления - условие, истинное между операциями, на которое каждая операция опирается и которое каждая сохраняет. Отсортированный массив держит порядок - и поэтому находит бинарным поиском за O(log n). Динамический массив держит num ≤ size и удваивается при заполнении - и поэтому вставка O(1) амортизированно. Нарушь инвариант снаружи - и обещание цены (или даже корректности) рухнет. Поэтому амортизированную цену тоже пишут в контракт честно: «O(1) амортизированно», а не «O(1) всегда».

Если ты понимаешь, что такое инвариант представления, можешь назвать инвариант отсортированного и динамического массива и знаешь, почему амортизированную цену указывают в контракте явно, листай до «Теперь сам».

Как думаешь, откуда берётся обещание «найду за log n»

Реализация обещает: бинарный поиск в массиве - O(log n). Но бинарный поиск работает только на ОТСОРТИРОВАННОМ массиве: он на каждом шаге отбрасывает половину, полагаясь на то, что слева меньше, справа больше. На беспорядочном массиве он даст неверный ответ. Значит, обещание держится не само по себе - оно опирается на условие «массив отсортирован». Кто-то должен это условие ПОДДЕРЖИВАТЬ. Такое условие, на которое операции опираются и которое сами же сохраняют, и называют инвариантом представления.

На что это похоже

Библиотека обещает найти книгу за минуту. Обещание держится на порядке: книги стоят по алфавиту (это инвариант). Пришёл посетитель, поставил книгу куда попало - порядок нарушен, и следующий поиск «по алфавиту» промахнётся. Поэтому в библиотеке книги ставят на место сотрудники, а не читатели: только так инвариант выживает между запросами. Реализация структуры - тот же библиотекарь: каждая её операция обязана вернуть полку в порядок, иначе обещание «найду быстро» перестаёт работать.

Механизм: условие, которое каждая операция бережёт

Инвариант представления - это условие про внутреннее состояние, истинное ДО и ПОСЛЕ каждой операции (внутри операции оно может временно нарушаться). Работает он в связке: каждая операция ОПИРАЕТСЯ на инвариант (пользуется тем, что он верен) и ОБЯЗАНА его восстановить к своему концу. Разберём два примера.

Отсортированный массив. Инвариант: a[0] ≤ a[1] ≤ ... ≤ a[n−1]. На него опирается поиск (бинарный, O(log n)). Его обязана сохранить вставка: она ставит новый элемент не в конец, а на его место в порядке (за O(n) - сдвигает хвост). Дорогая вставка - плата за дешёвый поиск, и держится всё на инварианте порядка.

Динамический массив. Инвариант: num ≤ size, и буфер удваивается ровно при заполнении. На него опирается обещание «вставка O(1) амортизированно»: раздел про амортизацию доказал это потенциалом Φ = 2·num − size - но доказательство верно ТОЛЬКО пока буфер удваивается. Нарушь правило удвоения (расти на +1) - и инвариант другой, и вставка честно становится O(n) амортизированно.

Правило

Инвариант представления - условие про внутреннее состояние, истинное между операциями: каждая операция на него опирается и каждая его восстанавливает. На инварианте держится и корректность, и обещанная стоимость. Нарушишь инвариант извне (полезешь в приватное представление) - и операции перестают держать и цену, и правильность. Обещание цены - это всегда обещание «при сохранённом инварианте».

Разбор: инвариант динамического массива

Вернёмся к динамическому массиву из прошлого раздела уже с новым словом. Почему его append стоит O(1) амортизированно? Потому что реализация держит инвариант: занято не больше, чем вмещает буфер (num ≤ size), и как только num упёрся в size, буфер удваивается. Этот инвариант - причина, по которой потенциал Φ = 2·num − size остаётся неотрицательным, а значит амортизированная цена выходит константной. Убери удвоение - инвариант станет другим, потенциал перестанет работать, и та же операция «добавить в конец» подорожает до O(n) амортизированно. То есть обещанная цена - не свойство операции самой по себе, а следствие инварианта, который реализация бережёт.

Разбор: амортизированную цену пишут в контракт честно

Раз обещание цены опирается на инвариант, его и формулируют аккуратно. append в динамический массив - это не «O(1) всегда»: отдельная вставка при удвоении стоит O(n). Честная запись контракта - «O(1) амортизированно», то есть по любой серии вставок средняя цена константна. Разница не придирка: если ты рассчитываешь на O(1) в каждом ОТДЕЛЬНОМ вызове (скажем, у тебя жёсткий предел времени на один шаг реального времени), редкая дорогая вставка тебя подведёт. Поэтому в контракте пишут именно тот вид цены, который гарантирован: «амортизированно», «в среднем», «в худшем» - это разные обещания, и путать их нельзя.

Что ломается без этого

Главная беда - нарушить инвариант снаружи, полезши в приватное представление. Влез руками в буфер динамического массива, сбил num - и append начинает писать не туда; переставил элементы отсортированного массива - и бинарный поиск молча промахивается. Именно поэтому внутренности контейнеров прячут: не из вредности, а чтобы никто извне не сломал инвариант, на котором держатся все обещания. Вторая беда - принять амортизированную цену за гарантию на каждый вызов и напороться на редкую дорогую операцию там, где важен именно худший ОДИН шаг.

Зачем это в работе

Инвариант объясняет, почему нельзя менять внутренности стандартных структур и почему поля контейнеров прячут (это и называют инкапсуляцией - «спрятать внутренности за операциями», дать снаружи только контракт, а не сами данные). Структура держит инвариант в своём собственном коде - в тех самых операциях; пусти чужой код к внутренним полям напрямую - и он его нарушит, а поймать такую поломку тяжело: структура «иногда» отвечает неверно. Отсюда же практическое правило: если структура обещает цену «амортизированно» или «в среднем», а тебе нужен предсказуемый ХУДШИЙ случай на каждый вызов (реальное время, интерактивность), выбирай структуру с гарантией в худшем случае - обещания разной силы решают разные задачи. Умение читать, КАКОЙ вид цены гарантирован, - часть инженерного выбора структуры.

Теперь сам

Стек (кладём и снимаем только с одного конца) реализован на динамическом массиве: элементы лежат в буфере, num - сколько занято. Какой инвариант держит реализация и на что он опирает обещание «push и pop - O(1) амортизированно»?

Ответ. Инвариант тот же, что у динамического массива: num ≤ size, и буфер удваивается при заполнении (а push кладёт в a[num] и растит num, pop - уменьшает num). На нём держится цена: pop всегда снимает с конца за O(1), а push - O(1) амортизированно, потому что удвоение - редкое (доказано потенциалом Φ = 2·num − size в прошлом разделе, и доказательство верно ровно пока инвариант удвоения соблюдён). Обещание «O(1) амортизированно» - следствие инварианта: расти буфер на константу, инвариант стал бы другим, и push подорожал бы до O(n) амортизированно.

Главное

Инвариант представления - условие про внутреннее состояние, истинное между операциями: каждая на него опирается и каждая его восстанавливает. На нём держится и корректность, и обещанная цена. Отсортированный массив держит порядок - отсюда поиск O(log n); динамический массив держит num ≤ size с удвоением - отсюда вставка O(1) амортизированно. Нарушишь инвариант извне - рухнут и цена, и правильность, поэтому внутренности прячут. И цену в контракт пишут честно по её виду: «O(1) амортизированно» - не то же, что «O(1) всегда».

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.