Embedded HandbookПриложения
Все разделы › IX. Язык C: от основ до продвинутого
STM32F103, STM32F407, STM32G431 · уровень базовый · 10 мин чтения
Открыть в интерактивном справочнике

76. Алгоритмы

В разделе 75 выяснилось, что выбор алгоритма даёт тысячи раз там, где переписывание кода даёт проценты. Здесь — как оценивать алгоритм до того, как его писать, и несколько приёмов, которые встречаются в программах для микроконтроллеров постоянно: двоичный поиск и поиск по таблице калибровки, быстрое возведение в степень, вычисления без переполнения, стек для разбора выражений, работа с битами.

Каждый ответ в разделе получен запуском программы и пересчитан независимо.

Как это называется в документации

Оригинал Перевод
time, space complexity сложность по времени, по памяти
big-O notation О-нотация
worst, average case худший, средний случай
binary search двоичный поиск
lookup table, interpolation таблица, интерполяция
exponentiation by squaring быстрое возведение в степень
binomial coefficient биномиальный коэффициент
reverse Polish notation обратная польская запись
shunting-yard algorithm алгоритм сортировочной станции
count leading zeros (CLZ) подсчёт ведущих нулей
population count подсчёт единичных битов

76.1 Как оценивают алгоритм

Точное время работы зависит от процессора, компилятора и данных. Поэтому алгоритмы сравнивают по тому, как растёт работа с ростом объёма данных, — и записывают это О-нотацией, оставляя только главное слагаемое. Алгоритм на 10n⁵ + 2n шагов — это O(n⁵).

Для тысячи элементов:

Сложность Шагов при n = 1000 Пример
O(1) 1 элемент массива по индексу
O(log n) около 10 двоичный поиск
O(n) 1000 поиск максимума, линейный поиск
O(n log n) около 10 000 хорошая сортировка
O(n²) 1 000 000 пузырёк: в худшем случае 499 500 сравнений
O(2ⁿ) число из 302 цифр перебор всех подмножеств; рекурсивный Фибоначчи (раздел 66.8) растёт чуть медленнее — примерно как 1,618ⁿ

Две оговорки для камня.

При малых n константы важнее порядка. Для десяти элементов пузырёк с его крошечным кодом может оказаться лучше «правильной» сортировки, которая тянет за собой заметно больше кода. Порядок начинает решать, когда данных сотни и тысячи.

Сложность по памяти важна не меньше, чем по времени. Алгоритм, которому нужен второй массив того же размера или глубокая рекурсия, на камне с 20 КБ ОЗУ может просто не поместиться (раздел 66.7).


76.2 Двоичный поиск

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

int bsearch_int(const int *a, int n, int key)
{
    int lo = 0, hi = n - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == key) return mid;
        if (a[mid] < key)  lo = mid + 1;
        else               hi = mid - 1;
    }
    return -1;                         /* нет */
}

В массиве из миллиона элементов последний найден за 20 шагов, и столько же понадобилось, чтобы установить, что числа нет. Линейный поиск потратил бы до миллиона.

Классическая ошибка — границы без ±1: lo = mid и hi = mid вместо lo = mid + 1 и hi = mid - 1. В массиве {10, 20, 30, 40, 50} такой вариант находит 20 на втором шаге, но на поиске 50 — последнего элемента — застревает навсегда: середина между 3 и 4 — это 3, граница сдвигается на 3, и всё повторяется. Хватает и одной ошибки lo = mid: тогда застревает уже поиск 20. Проверяют поиск всегда на крайних элементах и на отсутствующем.

Шаги двоичного поиска 50: верные границы и ошибка lo = mid

И тонкость: (lo + hi) / 2 на огромных массивах переполняется, а lo + (hi - lo) / 2 — никогда. На камне массивы маленькие, но привычка полезная. Готовая функция — bsearch из стандартной библиотеки (раздел 74.7).


76.3 🔧 Таблица калибровки

Типичная задача на камне: характеристика датчика нелинейна и задана таблицей точек. Таблицу кладут во Flash (const, раздел 68.3), нужный отрезок находят двоичным поиском, а внутри отрезка — линейная интерполяция в целых числах (раздел 69.9):

static const int16_t adc_tab[]  = { 400,  800, 1200, 1600, 2000, 2400, 2800, 3200, 3600};
static const int16_t temp_tab[] = {-400, -200,    0,  150,  300,  450,  600,  800, 1000}; /* 0,1 °C */

int16_t adc_to_temp(int adc)
{
    int lo = 0, hi = 8;
    if (adc <= adc_tab[lo]) return temp_tab[lo];      /* ниже таблицы */
    if (adc >= adc_tab[hi]) return temp_tab[hi];      /* выше таблицы */
    while (hi - lo > 1) {
        int mid = (lo + hi) / 2;
        if (adc_tab[mid] <= adc) lo = mid; else hi = mid;
    }
    return temp_tab[lo] + (temp_tab[hi] - temp_tab[lo]) * (adc - adc_tab[lo])
                        / (adc_tab[hi] - adc_tab[lo]);
}

Числа в таблице условные, но работа алгоритма проверена: код 1000 даёт −10,0 °C, 2000 — 30,0 °C, 3300 — 85,0 °C, а всё ниже таблицы ограничивается нижней точкой, −40,0 °C. Отрезок в таблице из девяти точек находится ровно за три шага, без единой операции с плавающей точкой.


76.4 Быстрое возведение в степень

aⁿ можно считать n умножениями, а можно — пользуясь тем, что aⁿ = (aⁿ/²)² для чётного n и aⁿ = aⁿ⁻¹ · a для нечётного:

long binpow(long a, int n)
{
    if (n == 0) return 1;
    if (n & 1)  return binpow(a, n - 1) * a;
    long b = binpow(a, n >> 1);
    return b * b;
}

3¹³ = 1 594 323: 13 умножений в лоб, 6 быстрым способом. Для степени 1000 — 15 умножений вместо тысячи. Число умножений — число разрядов двоичной записи степени минус один плюс число единиц в ней: вот почему алгоритм работает за O(log n).

Приём годится для любой операции, где порядок скобок не важен. Самое частое применение на камне — возведение в степень по модулю в криптографии (раздел 43).


76.5 Фибоначчи за логарифм

Числа Фибоначчи получаются возведением в степень матрицы [[0, 1], [1, 1]] — а значит, тем же быстрым способом:

Способ Работы для F(n)
двойная рекурсия O(2ⁿ) — 2 692 537 вызовов для n = 30 (раздел 66.8)
цикл O(n)
степень матрицы O(log n)

Проверено: F(10) = 55, F(90) = 2 880 067 194 370 816 120 — последнее ещё помещается в 64 бита. Урок шире Фибоначчи: одна и та же задача решается за O(2ⁿ), O(n) и O(log n), и разница определяется идеей, а не кодом.


76.6 Биномиальные коэффициенты и переполнение

Число сочетаний C(n, k) — сколькими способами выбрать k предметов из n. По формуле через факториалы его считать нельзя: промежуточные значения огромны.

int cnk_naive(int n, int k)      /* 21 · 22 · … · 30, потом делить */
{
    int res = 1;
    for (int i = n - k + 1; i <= n; ++i) res *= i;
    for (int i = 2; i <= k; ++i)         res /= i;
    return res;
}

C(30, 10) так даёт −108 вместо 30 045 015: произведение 21 · … · 30 не помещается в int, и дальше делится уже мусор.

Правильно — умножать и делить по очереди:

long cnk(int n, int k)
{
    long res = 1;
    for (int i = 1; i <= k; ++i)
        res = res * (n - k + i) / i;       /* деление всегда нацело */
    return res;
}

После i-го шага в res лежит C(n − k + i, i) — целое число, и деление на каждом шаге точное. Промежуточные значения остаются рядом с ответом.

Тип long даёт здесь запас только на компьютере с Linux, где пример и проверялся: там он 64-битный. На Cortex-M long — те же 32 бита, что int (раздел 64.3). Для C(30, 10) хватает и их: наибольшее промежуточное значение — 10 015 005 × 30 = 300 450 150, меньше 2³¹. А C(34, 17) = 2 333 606 220 не помещается в 32-битный знаковый даже ответом. На камне для запаса пишут uint64_t — и у res, и у результата; с binpow из 76.4 та же история.

Третий способ — треугольник Паскаля, где каждое число — сумма двух над ним. В нём только сложения, и промежуточные значения никогда не превышают ответа. Строка 5: 1 5 10 10 5 1.

Пример из жизни камня: сколько байтов содержат ровно три единицы? C(8, 3) = 56 — и прямой перебор всех 256 байтов даёт те же 56.


76.7 Стек в деле: обратная польская запись

В обратной польской записи операция пишется после операндов: 1 2 + 4 * 3 + вместо (1 + 2) * 4 + 3. Скобки не нужны, а вычисляется такая запись одним проходом со стеком (раздел 72.5): число — положить на стек, операция — снять два числа, выполнить, положить результат.

Запись Результат
3 4 + 7
1 2 + 4 * 3 + 15
100 25 + 25 / 5

Обычную запись в польскую переводит алгоритм сортировочной станции: числа сразу идут в выход; пришедшая операция сначала выталкивает со стека в выход все операции с приоритетом не ниже своего и только потом ложится на стек. «(» кладут на стек, «)» выталкивает всё до «(», в конце стек выгружают в выход. Проверено: (3+5)*12-17*3 → 3 5 + 12 * 17 3 * - → 45.

На камне это разбор команд и формул, пришедших по порту, и простые встроенные калькуляторы настроек.

Обратная польская запись «1 2 + 4 * 3 +»: один проход, один стек, скобки не нужны


76.8 Биты как алгоритм

Подсчёт нулей в двоичной записи. Сколько чисел от 1 до N имеют ровно K нулей? Перебор с разбором битов: для N = 8, K = 1 — 3 числа (10, 101 и 110 в двоичной), для 13 и 2 — 4, для 1000 и 5 — 210.

Логарифм по основанию 2 за одну команду. Номер старшего единичного бита — это 31 − (число ведущих нулей). На Cortex-M3 и M4 для этого есть команда clz, и __builtin_clz превращается ровно в неё. На Cortex-M0 такой команды нет, и то же вычисление — больше двух десятков команд. Подсчёт единичных битов отдельной команды не имеет нигде: на M3 __builtin_popcount — дюжина команд.

Шифр исключающим ИЛИ — каждый байт текста складывается по модулю 2 с байтом ключа, повторное применение того же ключа возвращает текст. Но с ключом HASSWORD и текстом Hello world первый байт даёт H ^ H = 0, и зашифрованная «строка» имеет длину ноль. Зашифрованное — это двоичные данные, а не строка: хранят их вместе с длиной (раздел 68.8). И главное — это не шифрование: ключ восстанавливается по нескольким байтам известного текста. Настоящая защита данных — в разделе 43.


76.9 Выбор алгоритма на камне


76.10 Грабли

Двоичный поиск зависает. Границы сдвигаются без ±1; проверять на последнем элементе и на отсутствующем.

Двоичный поиск возвращает не то. Массив не упорядочен.

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

Возведение в степень циклом тормозит. Быстрый способ — O(log n) умножений.

Интерполяция по таблице даёт скачки на краях. Не обработаны значения за пределами таблицы.

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

Зашифрованная строка «обрезается». В результате появился нулевой байт; хранить с длиной.

__builtin_clz(0) ведёт себя непредсказуемо. Для нуля результат встроенной функции не определён, и компилятор вправе считать, что ноль не придёт. Ноль проверяют отдельно.

Логарифм «медленный» на F0. У Cortex-M0 нет команды clz.

Алгоритм хорош на компьютере и не влезает в камень. Не посчитана сложность по памяти.


76.11 Итоги

Дальше: раздел 77 «Компиляция и компиляторы».

Открыть в интерактивном справочнике