Как стать автором
Обновить

Тройки Хоара

Время на прочтение11 мин
Количество просмотров33K
Я больше 15 лет при программировании использую логику Хоара и нахожу этот подход очень полезным и хочу поделится опытом. Естественно не надо «стрелять из пушки по воробьям», но при написании достаточно сложных алгоритмов или нетривиальных кусков кода применение логики Хоара сэкономит Ваше время и позволит внести элементы некоторого «промышленного» стандарта при программировании.

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

Бинарный алгоритм возведения в степень


Рассмотрим рекурсивную версию. Будем считать задача имеет решение если a, n одновременно не равны нулю и n >= 0.
Я не люблю псеводокод (возможно напишу отдельный топик почему), поэтому приведу пример решения на Python:

def power(a, n):
  assert n > 0 or (a != 0 or n != 0)
  if n == 0: 
    return 1
  else:
    a2 = power(a, n/2)
    if n & 1:
      return a*a2*a2
    else:
      return a2*a2

И на C/C++:

int power(int a, int n) {
  assert(n > 0 || (a != 0 || n != 0));
  if (n == 0)
    return 1;
  else {
    int a2 = power(a, n/2);
    if (n & 1)
      return a*a2*a2;
    else
      return a2*a2;
  }
}

Теперь попробуйте написать не рекурсивную версию. В случае удачи попробуйте объяснить кому-нибудь как она работает.

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

  • Ясно, что для пустого оператора пред и постусловия совпадают.
  • Для оператора присваивания в постусловие кроме предусловия должно учитывать факт, что значение переменной стало другим.
  • Для составного оператора (в Python это отступы, в C это {}) имеем цепочку пред и постусловий . В результате для составного оператора можно оставить первое предусловие и последнее постусловие.
  • Правило вывода говорит, что можно усилить пред и ослабить постусловие если нам это понадобиться. Нет смысла волочь через всю программу какое-то утверждение, которое не помогает решить поставленную задачу.
  • Оператор ветвления или просто if. Его условно можно разбить на две ветки then и else. Если к предусловию добавить истинность логического условия (то, что стоит под if), то после выполнения ветки then должно следовать постусловие. Аналогично, если к предусловию добавить отрицание логического условия (то, что стоит под if), то после выполнения ветки else должно следовать постусловие
  • Оператор цикла. Это самое нетривиальное и сложное, поскольку цикл может выполняется много раз и даже не окончится. Чтобы решить проблему возможно многократного повтора тела цикла вводят инвариант цикла. Инвариант цикла это то, что истинно перед его выполнением, истинно после каждого выполнения тела цикла и следовательно истинно и после его окончания. Предусловие для оператора цикла это просто его инвариант цикла. Если истинно условие продолжения цикла (то, что стоит под while), то после выполнения тела цикла должна следовать истинность инвариант цикла. В результате после окончания цикла имеем в качестве постусловия истинность инвариант цикла и отрицание условия продолжения цикла.
  • Оператора цикла с полной корректностью. Для этого к предыдущему пункту добавляют ограничивающую функцию, с помощью которой легко доказать, что цикл будет выполнятся ограниченное число раз. На нее накладывают условия, что она всегда >=0, строго убывает после каждого выполнения тела цикла и в точности =0, когда цикл заканчивается.

Правильно работающую программу можно написать очень многими способами, а также она в большом количестве случаев будет эффективной. Этот произвол и именно он усложняет программирование. Для этого вводят стиль. Но этого оказывается мало. Для многих программ (например, связанных косвенно с жизнью человека) нужно доказать и их корректность. Оказалось, что доказательство корректности делает программу дороже на порядок (примерно в 10 раз).

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

По поводу доказательства правильности алгоритма. Есть в математике набор инструментов позволяющий доказывать теоремы. Я назову общеизвестные: метод математической индукции, от противного. Так вот логика Хоара это инструмент доказательства корректности программ (правильнее частичной корректности, но это уже нюансы), который в принципе может быть использован и для доказательства корректности алгоритма, если он записан в основных конструкциях императивного программирования, для которых есть Тройки Хоара.

Полная версия
Я включил пред и постусловия в комментарии. Перед циклом я определяю через : инвариант_цикла и ограничивающая_функция и потом на них просто ссылаюсь. Python:

# -*- coding: utf-8 -*-

# Бинарный алгоритм возведения в степень
def power(a, n):
  """
  Возводит a в степень n
  """
  assert n > 0 or (a != 0 or n != 0)
  r, a0, n0 = 1, a, n
  # r == 1 and a0 == a and n0 == n and n >= 0
  # инвариант_цикла: a0**n0 == r*a**n
  # ограничивающая_функция(n): n
  while n > 0:
    # n > 0 and
    # ограничивающая_функция(n) > 0 and
    # инвариант_цикла
    if n & 1:
      r *= a
    #(n - нечетно and a0**n0*a == r*a**n) or
    # инвариант_цикла
    n >>= 1
    # ограничивающая_функция(n) > ограничивающая_функция(n/2) and
    # a0**n0 == r*a**(2*n)
    a *= a
    # инвариант_цикла
  # n == 0 and
  # инвариант_цикла and
  # ограничивающая_функция(n) == 0

  # a0**n0 == r
  return r

if __name__ == '__main__':
  for i in range(14):
    print "2**%d" % i ,"=", power(2, i)
    print "2**%d" % i ,"=", power(-2, i)

C/C++:
#include <cassert>
#include <cstdlib>
#include <iostream>

// Бинарный алгоритм возведения в степень
int power(int a, int n) {
  // Возводит a в степень n
  assert(n > 0 || (a != 0 || n != 0));
  int r = 1,
      a0 = a,
      n0 = n;
  /* r == 1 and a0 == a and n0 == n and n >= 0
     инвариант_цикла: a0**n0 == r*a**n
     ограничивающая_функция(n): n */
  while(n > 0) {
    /* n > 0
       and ограничивающая_функция(n) > 0
       and инвариант_цикла */
    if (n & 1)
      r *= a;
    /* (n - нечетно and a0**n0*a == r*a**n)
       or инвариант_цикла */
    n >>= 1;
    /* ограничивающая_функция(n) > ограничивающая_функция(n/2) and 
       a0**n0 == r*a**(2*n) */
    a *= a;
    // инвариант_цикла
  }
  /* n == 0
     and инвариант_цикла
     and ограничивающая_функция(n) == 0 */
  // a0**n0 == r
  return r;
}

int main(int argc, char *argv[]) {
  for(int i=0; i < 14; i++) {
    std::cout << "2**" << i << " = " << power(2, i) << std::endl;
    std::cout << "(-2)**" << i << " = " << power(-2, i) << std::endl;
  }
  return EXIT_SUCCESS;
}


Разумная версия
Ясно, что так подробно писать при обычном программирование нет смысла. Слишком громоздко, код захламляется комментариями и становится практически не читаемым. Все эти логические рассуждения нужно проводить у себя в голове, оставляя наиболее нетривиальные в виде комментариев. Тогда можно будет всегда при возвращении к коду быстро все рассуждения восстановить и самое главное. Другое программист владеющий этим подходом, точно также рассуждая и рассматривая Ваш код с оставленными наиболее важными комментариями сможет восстановить Ваши рассуждения и например найти в них ошибку или изменить код для новой постановки задачи. А это уже элементы промышленного подхода.

Самым важным и нетривиальным в примере с бинарным возведением в степень был инвариант цикла. Я бы написал следующим образом.

Python:

# -*- coding: utf-8 -*-

# Бинарный алгоритм возведения в степень
def power(a, n):
  """
  Возводит a в степень n
  """
  assert n > 0 or (a != 0 or n != 0)
  r = 1
  # инвариант_цикла: a0**n0 == r*a**n
  while n > 0:
    if n & 1:
      r *= a
    n >>= 1
    a *= a
  return r

if __name__ == '__main__':
  for i in range(14):
    print "2**%d" % i ,"=", power(2, i)
    print "2**%d" % i ,"=", power(-2, i)

C/C++:

#include <cassert>
#include <cstdlib>
#include <iostream>

// Бинарный алгоритм возведения в степень
int power(int a, int n) {
  // Возводит a в степень n
  assert(n > 0 || (a != 0 || n != 0));
  int r = 1;
  // инвариант_цикла: a0**n0 == r*a**n
  while(n > 0) {
    if (n & 1)
      r *= a;
    n >>= 1;
    a *= a;
  }
  return r;
}

int main(int argc, char *argv[]) {
  for(int i=0; i < 14; i++) {
    std::cout << "2**" << i << " = " << power(2, i) << std::endl;
    std::cout << "(-2)**" << i << " = " << power(-2, i) << std::endl;
  }
  return EXIT_SUCCESS;
}

a0, n0 — это начальные значения. Внутри тела цикла r, a, n меняются таким образом, чтобы после выполнения тела цикла соотношение (инвариант цикла) a0**n0 == r*a**n было истинным. По окончание цикла a0**n0 == r*a**n истинно и n==0 значит a0**n0 == r и в r содержится ответ.

Написание утверждений в комментариях это конечно вопрос соглашения, но я не вижу смысла писать где-то еще. Утверждение лучше писать в комментариях непосредственно в том коде к которому они имеют непосредственное отношение и иногда, если можно, то их лучше заменять assert-ами.

Бинарный поиск


Попробуйте себя при решении очень похожей задачи бинарного поиска. Здесь a — линейно упорядоченный массив, v — элемент номер, которого мы ищем, l — нижняя граница с которой мы ищем (она входит), r — верхняя граница до которой мы ищем (она не входит). Для простоты предполагаем, что первый вызов bSearch не выходит за границы массива a. Возвращает -1, если не удалось найти, или номер найденного элемента >=0.

Python
def bSearch(a, v, l, r):
  if l >= r:
    return -1
  else:
    m = (l + r)/2
    assert l <= m < r
    if v > a[m]:
      return bSearch(a, v, m + 1, r)
    elif v < a[m]:
      return bSearch(a, v, l, m)
    else:
      assert v == a[m]
      return m


C/C++
int bSearch(int *a, int v, int l, int r) {
  if (l >= r)
    return -1;
  else {
    int m=(l + r)/2;
    assert(l <= m && m < r);
    if (v > a[m])
      return bSearch(a, v, m + 1, r);
    else if (v < a[m])
      return bSearch(a, v, l, m);
    else {
      assert(v == a[m]);
      return m;
    }
  }
}


Напишите не рекурсивную версию c тройками Хоара.

Рассмотрим более сложные случае со вложенными циклами на примере разных алгоритмов сортировки.

Пирамидальная сортировка


Алгоритм пирамидальной сортировки для массива array из n-элементов состоит из двух основных шагов:

  1. Выстраиваем элементы массива в виде сортирующего дерева:
    ∀ i, 0 ≤ i ∧ (2i+1) < n ∧ array[i] ≥ array[2i+1],
    ∀ i, 0 ≤ i ∧ (2i+2) < n ∧ array[i] ≥ array[2i+2]
    .
  2. Будем удалять элементы из корня по одному за раз и перестраивать дерево. Т.е. на первом шаге обмениваем array[0] и array[n-1] и преобразовываем array[0], …, array[n-2] в сортирующее дерево. Затем переставляем array[0] и array[n-2] и преобразовываем array[0], …, array[n-3] в сортирующее дерево. Процесс продолжается до тех пор, пока в сортирующем дереве не останется один элемент. Тогда array[0], …, array[n-1] — упорядоченная последовательность.

Это оригинальная статья автора алгоритма.
J. W. J. Williams. Algorithm 232 — Heapsort, 1964, Communications of the ACM 7(6): 347–348.
Это описание на вики

Python
# -*- coding: utf-8 -*-

# Cортирует a в порядке возврастания
def heapsort(a):
  """
  Cортирует a применяя алгоритм пирамидальной сортировки
  """
  k, n = 1, len(a)
  # k == 1 and
  # инвариант_цикла1: a[(i - 1)/2] >= a[i] для i=1..k
  # ограничивающая_функция1(k, n): n - k
  while k < n:
    # k < n
    # and инвариант_цикла1
    # and ограничивающая_функция1(k, n) > 0
    leaf = k
    k += 1
    # k <= n
    # and инвариант_цикла1 кроме i == leaf
    # and ограничивающая_функция1(k, n) > ограничивающая_функция1(k+1, n)

    # инвариант_цикла2: инвариант_цикла1 кроме i == leaf
    # ограничивающая_функция2(leaf): (инвариант_цикла1 то 0) иначе leaf-1
    while leaf > 0:
      # leaf > 0
      # and инвариант_цикла2
      # and ограничивающая_функция2(leaf) > 0
      root = (leaf - 1)/2
      if a[root] >= a[leaf]:
        # инвариант_цикла2
        # and инвариант_цикла1
        # and ограничивающая_функция(child) == 0
        break
      else:
        a[root], a[leaf] = a[leaf], a[root]
        leaf = root
        # ограничивающая_функция2(leaf) > ограничивающая_функция2((leaf - 1)/2)
        # and инвариант_цикла2
      # инвариант_цикла2
      # and ограничивающая_функция2(child) == 0
    # инвариант_цикла1
  # k == n
  # and инвариант_цикла1
  # and ограничивающая_функция1(k, n) == 0

  # инвариант_цикла3: a[(i - 1)/2] >= a[i] для i=1..k
  #                   and a[j-1] <= a[j] для j=k..n
  #                   and a[m] <= a[l] для m=0..k, l=k..n
  # ограничивающая_функция3(k): k - 1
  while k > 0:
    # k > 0
    # and инвариант_цикла3
    # and ограничивающая_функция3(k) > 0
    # and a[0] >= a[i] для i=0..k
    k -= 1
    a[k], a[0] = a[0], a[k]
    # инвариант_цикла3 кроме i=0
    # and ограничивающая_функция3(k) > ограничивающая_функция3(k-1)
    root = 0
    # инвариант_цикла4: инвариант_цикла3 кроме i=root
    # ограничивающая_функция4(root, k): (инвариант_цикла3 то 0) иначе (k-(2*root+1))
    while 2*root + 1 < k:
      # 2*root + 1 < k
      # and инвариант_цикла4
      # and ограничивающая_функция4(root, k) > 0
      leaf = 2*root + 1
      # инвариант_цикла4
      # and leaf == 2*root + 1
      if (leaf + 1) < k and a[leaf] < a[leaf + 1]:
        leaf += 1
      # инвариант_цикла4
      # and (2*((leaf-1)/2)+2 == k
      #      or a[leaf] == max(a[2*((leaf-1)/2)+1], a[2*((leaf-1)/2)+2]))
      if a[root] >= a[leaf]:
        # инвариант_цикла4
        # and инвариант_цикла3
        # and ограничивающая_функция4(root, k) == 0
        break
      else:
        a[root], a[leaf] = a[leaf], a[root]
        root = leaf
        # инвариант_цикла4
        # and ограничивающая_функция4(root, k) > ограничивающая_функция4(2*root + 1, k)
      # инвариант_цикла4
      # and ограничивающая_функция4(root, k) == 0
    # инвариант_цикла3
  # k == 0
  # and инвариант_цикла3
  # and ограничивающая_функция3(k) == 0

  # a[j-1] <= a[j] для j=0..n

if __name__ == '__main__':
  import random
  a = map(lambda x: random.randint(-100, 100), range(20))
  print a
  heapsort(a)
  print a


C/C++
#include <cassert>
#include <cstdlib>
#include <ctime>
#include <iostream>

// Cортирует a в порядке возврастания
void sortHeap(int *a, int n) {
  // Cортирует a применяя алгоритм пирамидальной сортировки
  assert(n >= 0);
  int k = 1;
  /* k == 1 and
     инвариант_цикла1: a[(i - 1)/2] >= a[i] для i=1..k
     ограничивающая_функция1(k, n): n - k */
  while(k < n) {
    /* k < n
       and инвариант_цикла1
       and ограничивающая_функция1(k, n) > 0 */
    int leaf = k;
    ++k;
    /* k <= n
       and инвариант_цикла1 кроме i == leaf
       and ограничивающая_функция1(k, n) > ограничивающая_функция1(k+1, n) */
    /* инвариант_цикла2: инвариант_цикла1 кроме i == leaf
       ограничивающая_функция2(leaf): (инвариант_цикла1 то 0) иначе leaf-1 */
    while(leaf > 0) {
      /* leaf > 0
         and инвариант_цикла2
         and ограничивающая_функция2(leaf) > 0 */
      int root = (leaf - 1)/2;
      if (a[root] >= a[leaf])
        /* инвариант_цикла2
           and инвариант_цикла1
           and ограничивающая_функция(child) == 0 */
        break;
      else {
        int tmp = a[root];
        a[root] = a[leaf];
        a[leaf] = tmp;
        leaf = root;
        /* ограничивающая_функция2(leaf) > ограничивающая_функция2((leaf - 1)/2)
           and инвариант_цикла2*/
      }
      /* инвариант_цикла2
        and ограничивающая_функция2(child) == 0 */
    }
    // инвариант_цикла1
  }
  /* k == n
     and инвариант_цикла1
     and ограничивающая_функция1(k, n) == 0 */
  /* инвариант_цикла3: a[(i - 1)/2] >= a[i] для i=1..k
                       and a[j-1] <= a[j] для j=k..n
                       and a[m] <= a[l] для m=0..k, l=k..n
     ограничивающая_функция3(k): k - 1 */
  while(k > 0) {
    /* k > 0
       and инвариант_цикла3
       and ограничивающая_функция3(k) > 0
       and a[0] >= a[i] для i=0..k */
    --k;
    int tmp = a[k];
    a[k] = a[0];
    a[0] = tmp;
    /* инвариант_цикла3 кроме i=0
       and ограничивающая_функция3(k) > ограничивающая_функция3(k-1) */
    int root = 0;
    /* инвариант_цикла4: инвариант_цикла3 кроме i=root
       ограничивающая_функция4(root, k): (инвариант_цикла3 то 0) иначе (k-(2*root+1)) */
    while(2*root + 1 < k) {
      /* 2*root + 1 < k
         and инвариант_цикла4
         and ограничивающая_функция4(root, k) > 0 */
      int leaf = 2*root + 1;
      /* инвариант_цикла4
         and leaf == 2*root + 1 */
      if ((leaf + 1) < k and a[leaf] < a[leaf + 1])
        leaf += 1;
      /* инвариант_цикла4
         and (2*((leaf-1)/2)+2 == k
              or a[leaf] == max(a[2*((leaf-1)/2)+1], a[2*((leaf-1)/2)+2])) */
      if (a[root] >= a[leaf])
        /* инвариант_цикла4
           and инвариант_цикла3
           and ограничивающая_функция4(root, k) == 0 */
        break;
      else {
        int tmp = a[root];
        a[root] = a[leaf];
        a[leaf] = tmp;
        root = leaf;
        /* инвариант_цикла4
           and ограничивающая_функция4(root, k) > ограничивающая_функция4(2*root + 1, k) */
      }
      /* инвариант_цикла4
        and ограничивающая_функция4(root, k) == 0 */
    }
    // инвариант_цикла3
  }
  /* k == 0
     and инвариант_цикла3
     and ограничивающая_функция3(k) == 0 */
  // a[j-1] <= a[j] для j=0..n
}

int main(int argc, char *argv[]) {
  srand((unsigned)time(NULL));

  const int n = 20;
  int a[n];
  for(int i=0; i < 20; i++)
    a[i] = rand()/(RAND_MAX/200) - 100;

  for(int i=0; i < 20; i++)
    std::cout << a[i] << ' ';
  std::cout << std::endl;
  sortHeap(a, n);
  for(int i=0; i < 20; i++)
    std::cout << a[i] << ' ';
  std::cout << std::endl;

  return EXIT_SUCCESS;
}


Напоследок


Попробуйте себя при решении очень похожей задачи сортировки методом Шелла.

Дан массив array из n-элементов. Массив разбивается на подмассивы с шагом k0
{a[0], a[k0], a[2k0], … }, {a[1], a[1+k0], a[1+2k0], … }, …, {a[k0-1], a[2k0-1], a[3k0-1], … }
и если соседние элементы в подмассиве нарушают порядок, то они меняются местами. Затем процедура повторяется с шагом k1 и т.д.
Последний шаг должен быть равен 1.

В качестве шагов можно взять, например, следующую последовательность
steps[16] = [1391376, 463792, 198768, 86961, 33936, 13776, 4592, 1968, 861, 336, 112, 48, 21, 7, 3, 1 ].

Это оригинальная статья автора алгоритма.
Donald Lewis Shell, A High-Speed Sorting Procedure, CACM, 2(7): 30-32, July 1959.

Литература


Это основная книга на русском
O.-J. Dahl, E. W. Dijkstra and C. A. R. Hoare, Structured Programming. Academic Press, 1972. ISBN 0-12-200550-3. Перевод: Дал У., Дейкстра Э., Хоор К., Структурное программирование. М.:«Мир», 1975.

Это первоначальная статья
C. A. R. Hoare. «An axiomatic basis for computer programming». Communications of the ACM, 12(10):576—580,583 October 1969. DOI:10.1145/363235.363259

С.А. Абрамов. Элементы программирования. — М.: Наука, 1982. С. 85-94.

В википедии Логика Хоара.

Из лекций ВМиК МГУ по «Технологии программирования».
Теги:
Хабы:
Всего голосов 41: ↑22 и ↓19+3
Комментарии20

Публикации

Истории

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань