Кладовочка

О разном, чтобы было под рукой онлайн

Си (C) для студентов: шпаргалка к экзамену и контрольным (МИФИ, МГУ, МФТИ)

Язык Си (C) — это не просто ещё один язык программирования, а фундамент всей компьютерной науки. Простое определение для экзамена: Си — это компилируемый, статически типизированный, процедурный язык программирования общего назначения с низкоуровневым доступом к памяти через указатели, созданный Деннисом Ритчи в 1972 году в Bell Labs для переписывания ядра Unix. На нём написаны ядра операционных систем (Linux, Windows, macOS), компиляторы, интерпретаторы (CPython, оригинальная JVM) и любое «железное» ПО, где важен каждый байт и каждый такт процессора. Разбор каждого слова определения для экзамена:

  • Компилируемый — исходный код целиком переводится компилятором (gcc, clang, msvc) в машинный код конкретной платформы до запуска; получается отдельный исполняемый файл (.exe / ELF), который процессор выполняет напрямую. В отличие от интерпретируемых языков (Python, Ruby), где каждая строка читается и выполняется программой-интерпретатором на лету. Почему это важно: компиляция даёт скорость (нет посредника между кодом и процессором), но лишает переносимости «из коробки» — программу нужно перекомпилировать под каждую архитектуру (x86, ARM, RISC-V).
  • Статически типизированный — тип каждой переменной известен компилятору до выполнения программы и проверяется на этапе компиляции. int x; — это всегда 4 байта целого числа, char *s; — это всегда указатель на символ, и компилятор не разрешит вам сложить указатель с другим указателем или передать int* в функцию, ожидающую char*, без явного приведения типа. Противоположность — динамическая типизация в Python, где тип «приклеивается» к значению в рантайме (x = 5; x = "hello" — законно).
  • Процедурный — программа строится как последовательность вызовов функций (процедур), каждая из которых выполняет законченное действие и может вызывать другие. Нет классов, объектов, наследования в стиле ООП (хотя на Си можно имитировать ООП вручную через структуры и таблицы виртуальных методов). Почему это важно исторически: Си появился раньше объектно-ориентированной парадигмы (C++ создан в 1983 г. как «Си с классами»), поэтому весь его синтаксис построен вокруг функций и данных, а не вокруг «сообщений между объектами».
  • Общего назначения — на Си пишут всё: от ядра ОС до веб-сервера, от драйвера принтера до нейросетевого фреймворка (тензорные вычисления TensorFlow внутри — это Си/C++).
  • Низкоуровневый доступ к памяти через указатели — главная суперсила и главный источник ошибок. Указатель — это переменная, хранящая числовой адрес байта в оперативной памяти; через указатель программа может читать и писать любой байт, до которого «дотянется». В Python/Java таких возможностей нет в принципе.

Аналогия из жизни. Если Python или Java — это «автоматическая коробка передач» (удобно, безопасно, но вы не управляете мотором напрямую), то Си — это «механика»: вы сами выжимаете сцепление, сами переключаете передачу и сами видите, что происходит под капотом. Именно поэтому на Си пишут то, что должно работать максимально быстро и предсказуемо: ядро ОС, драйверы, микроконтроллеры, базы данных (PostgreSQL, SQLite), игровые движки и компиляторы к другим языкам. Расширенная аналогия для запоминания: в «автомате» (Python) компьютер сам решает, где хранить объекты, сам их удаляет, когда они не нужны, сам проверяет границы массива — вы просто «крутите руль и жмёте газ». В «механике» (Си) вы сами выбираете передачу (где разместить переменную — стек/куча/глобальная), сами следите за оборотами (следите за переполнением int, выходом за границы массива), и если заглохли (Segmentation Fault) — виноваты сами, потому что не выжали сцепление (не проверили указатель на NULL перед разыменованием).

Именно поэтому Си остаётся обязательной дисциплиной первых курсов технических вузов — на нём преподаватели проверяют не «знание синтаксиса», а понимание того, как программа реально работает в памяти компьютера: где физически лежат переменные (стек вызовов, куча malloc, сегмент данных, сегмент кода), как ходит указатель, почему printf иногда печатает мусор и куда девается выделенная память после free(). Четыре сегмента памяти — это базовая «карта местности», которую преподаватель ВМК МГУ и ФИВТ МФТИ ожидает увидеть на доске или на листке бумаги при любом вопросе про Си. Эта страница собрана под конкретную цель — экзамен, контрольную или коллоквиум по программированию в НИЯУ МИФИ, МГУ (ВМК) и МФТИ. Здесь нет истории языка и настройки IDE: только определения, тезисные ответы на теоретические вопросы, разобранные задачи без библиотечных функций, структуры данных с оценками сложности и вопросы с ответами. Формат — шпаргалка: плотные таблицы, короткий компилируемый код, типовые ловушки преподавателей. Отдельно разобрана теория алгоритмов (машина Тьюринга, нормальные алгоритмы Маркова, проблема останова), которую требуют на ВМК МГУ. Если нужен базовый синтаксис с нуля — начните с общей шпаргалки по Си, а эту страницу держите как экзаменационный конспект.

Почему именно «понимание памяти» — главное на экзамене. Любой современный высокоуровневый язык (Python, Java, C#) сам управляет памятью за программиста: сборщик мусора освобождает объекты, интерпретатор прячет указатели за «ссылками». В Си этого нет — вы сами решаете, где разместить переменную (на стеке, в куче, в глобальной области), сами выделяете память через malloc/calloc и сами освобождаете её через free. Это даёт скорость и контроль, но и ответственность: забытый free — утечка памяти, двойной free — крах программы, разыменование NULL — Segmentation Fault. Преподаватель на экзамене проверяет именно это умение «руками управлять памятью», потому что оно показывает, понимаете ли вы, как работает компьютер изнутри. Дополнительный аргумент для ответа преподавателю: понимание памяти в Си автоматически означает понимание, как работают Python и Java «под капотом» — потому что они сами реализованы на Си, и их сборщик мусора, и их «объекты», и их «массивы» — это всё те же байты по тем же адресам, просто спрятанные за синтаксическим сахаром.


Как пользоваться шпаргалкой. Экзаменационный ответ по Си держится на трёх китах: память (как данные лежат в байтах — стек вызовов, куча malloc, сегмент данных, сегмент кода), указатели (как к этой памяти обратиться по адресу и не выстрелить себе в ногу разыменованием NULL) и алгоритмы (как её обрабатывать эффективно, с оценкой сложности O(...)). Почему именно эти три темы, а не синтаксис или стандартная библиотека: синтаксис Си занимает 1 страницу (его можно выучить за вечер), стандартная библиотека — справочник (его можно открыть на экзамене, если разрешено), а вот «как код ложится на железо» — это то, что нельзя подсмотреть, и именно это отличает бакалавра от того, кто «прошёл курс по верхам». Именно об этом спрашивают из года в год, поэтому каждый раздел ниже построен по одному шаблону: сначала объяснение простыми словами (что это и зачем — через аналогии из жизни: указатель — это номер квартиры в почтовом ящике, массив — это шкаф с пронумерованными ячейками), затем готовые тезисы для устного ответа преподавателю, затем разобранный код с построчными комментариями «почему так», а в конце — типичные ошибки, за которые снижают баллы.

Три кита — простые определения для билета:

  • Память — это оперативная память компьютера (RAM), представленная как массив байтов, каждый из которых имеет свой номер от 0 до N-1 (этот номер и есть адрес). Программа на Си при работе использует четыре логические области памяти: сегмент кода (машинные инструкции вашей функции, защищён от записи), сегмент данных (глобальные и static переменные, существуют всё время работы программы), стек вызовов (локальные переменные функций, аргументы, адреса возврата — растёт «вниз» от старших адресов, работает по принципу LIFO «последним вошёл — первым вышел», как стопка тарелок) и куча (heap) (динамически выделяемая память через malloc/calloc/realloc — растёт «вверх» от младших адресов, не управляется автоматически, программист обязан вызвать free). Подробнее о каждом сегменте (то, что спрашивают на МФТИ):
    • Сегмент кода (.text) — сюда загрузчик ОС копирует машинные инструкции из .exe/ELF-файла. Размер известен до запуска, защищён от записи (попытка *(char*)main = ... приведёт к Segmentation Fault). Сюда же попадают строковые литералы (в некоторых моделях — в отдельный сегмент .rodata).
    • Сегмент данных (.data / .bss) — .data хранит инициализированные глобальные/статические переменные (int g = 5; — попадёт сюда), .bss хранит неинициализированные (int g; — попадёт сюда, причём обнулённый). Особенность: эти переменные живут всю программу — от main() до exit(). Именно поэтому static int counter = 0; внутри функции сохраняет значение между вызовами.
    • Стек вызовов (call stack) — это «рабочий стол» текущей функции. При вызове функции в стек кладутся: аргументы (или указатели на них), адрес возврата (куда вернуться после ret), локальные переменные. При return всё это автоматически снимается со стека. Растёт «вниз» (от старших адресов к младшим) — это особенность архитектуры x86/ARM. Ловушка для экзамена: стек имеет фиксированный размер (по умолчанию ~1–8 МБ), и бесконечная рекурсия даёт stack overflow — переполнение стека.
    • Куча (heap) — это «склад» произвольного размера (ограничен только объёмом RAM + свопа). Вы запрашиваете блок у менеджера памяти через malloc(size), получаете указатель на него, работаете, потом обязательно вызываете free(ptr). Главное отличие от стека: куча не управляется автоматически, переменные в ней живут, пока вы их явно не освободите. Поэтому malloc используют, когда размер данных неизвестен на этапе компиляции (например, размер массива вводит пользователь).
    • Аналогия: стек — это стопка подносов в столовой (положил сверху — взял сверху, берёшь из середины — всё падает); куча — это склад, куда вы приходите и сами забираете коробку нужного размера, а потом обязаны вернуть её обратно.
  • Указатель — это переменная, которая хранит не данные, а адрес другой переменной в памяти. Синтаксис: int *p = &x; означает «p указывает на x, *p — это значение по этому адресу, &x — взятие адреса x». Подробнее о трёх операциях с указателями (экзаменационный минимум):
    • &x — оператор взятия адреса (address-of), возвращает числовой адрес переменной x. Тип результата — «указатель на тип x» (т.е. int* для int, char* для char).
    • *p — оператор разыменования (dereference), идёт по адресу, хранящемуся в p, и возвращает значение, которое там лежит. Тип результата — тип того, на что p указывает (т.е. int для int*).
    • p + 1 — арифметика указателей: прибавление 1 сдвигает указатель не на 1 байт, а на sizeof(*p) байт (т.е. на 4 для int*, на 8 для double* на 64-бит). Именно поэтому a[i] эквивалентно *(a + i) — массив и указатель тесно связаны.
    • Аналогия: указатель — это номер квартиры на бумажке. Сама бумажка (переменная-указатель) хранит номер (адрес); идя по этому номеру, вы находите квартиру (область памяти), где живут жильцы (данные). Потеряли бумажку — NULL (нулевой адрес, означает «никуда не ведёт»); пытаетесь зайти в несуществующую квартиру — Segmentation Fault. Вторая аналогия для запоминания: указатель — это пульт от телевизора. Сам пульт — это переменная-указатель (хранит «адрес» телевизора, но сам телевизором не является). Кнопки на пульте (*p, p->field) — это операции, которые говорят «сделай что-то с тем, на что я нацелен». Батарейки сели (p == NULL) — кнопки нажимаются, но ничего не происходит, либо телевизор «взрывается» (Segfault).
  • Алгоритмы и сложность O(...) — это способ оценить, как быстро работает ваш код при росте объёма данных. Запись O(n) означает «время работы растёт линейно с размером входа», O(n²) — «квадратично», O(log n) — «логарифмически». Шпаргалка по основным сложностям (та, что просят на МФТИ):
    • O(1) — константное время (доступ к элементу массива по индексу, вставка в хеш-таблицу в среднем).
    • O(log n) — логарифмическое (бинарный поиск, поиск в сбалансированном дереве).
    • O(n) — линейное (проход по массиву, поиск минимума/максимума).
    • O(n log n) — линейно-логарифмическое (быстрые сортировки: слиянием, кучей, qsort в среднем).
    • O(n²) — квадратичное (пузырьковая сортировка, сортировка выбором, вложенные циклы по массиву).
    • O(2ⁿ) — экспоненциальное (рекурсивное вычисление чисел Фибоначчи наивно, полный перебор всех подмножеств).
    • O(n!) — факториальное (перебор всех перестановок, задача коммивояжёра полным перебором).
    • Аналогия: найти слово в неотсортированном списке из 1000 элементов — O(n), придётся просмотреть в худшем случае все 1000; найти слово в отсортированном словаре методом половинного деления — O(log n), хватит ~10 шагов. Экзаменатор всегда спросит, почему ваш алгоритм именно такой, а не быстрее/медленнее. Готовый ответ на «почему O(n)?»: «потому что алгоритм выполняет ровно один простой проход по массиву, на каждом элементе делает O(1) операций, итого n · O(1) = O(n)».

Экзаменационный тезис (запомнить и уметь произнести вслух). Любая программа на Си одновременно существует в трёх измерениях: алгоритмическом (что она делает — логика, циклы, ветвления), модели памяти (где физически лежат данные — стек/куча/сегменты) и адресном (как к этим данным обращаются указатели, какие у них типы, что лежит по адресу). Если вы на экзамене можете объяснить только синтаксис кода, но не можете показать, где в памяти «живёт» каждая переменная и какой указатель на неё смотрит — ответ считается неполным, даже если код «правильно работает». Как произнести этот тезис за 30 секунд преподавателю: «Код на Си — это три слоя. Первый — алгоритм: что мы хотим получить. Второй — память: где физически лежат данные (стек, куча, сегмент данных). Третий — указатели: как из одной части кода обратиться к данным в другой части. Если я не могу объяснить все три слоя — я не понимаю код, я его просто списал».


Как готовиться по этой шпаргалке эффективно:

  • Алгоритм «3 прохода». Первый раз прочитайте всё подряд как художественную книгу — поймите общую картину. Второй раз выпишите на карточки ключевые тезисы (одна карточка — один вопрос из экзаменационного билета). Третий раз — воспроизведите по памяти на бумаге без подглядывания. Почему именно три прохода работают (наука о памяти): первый даёт «общую карту местности» — формирует общее представление в долговременной памяти, второй — превращает пассивное чтение в активное выписывание (эффект активного припоминания, который ускоряет запоминание в 2–3 раза по сравнению с перечитыванием), третий — тест без подсказок, который покажет реальные «дыры» в знаниях и закрепит информацию через «эффект тестирования». Если на третьем проходе вы не смогли воспроизвести раздел — значит, его нужно читать заново, а не зубрить. Дополнительный совет: между вторым и третьим проходом сделайте перерыв минимум в один день — это нужно для «консолидации памяти», мозг во сне переносит информацию из кратковременной памяти в долговременную.
  • Если времени мало — читайте только объяснения и тезисы, а код просматривайте глазами, мысленно проговаривая «что делает каждая строка и где это лежит в памяти». Что проговаривать про себя (мысленный поток): «объявляю int a — это 4 байта в стеке; int *p = &a — указатель тоже в стеке, хранит адрес a; *p = 5 — записал 5 по этому адресу, теперь и a, и *p равны 5». Такая «внутренняя речь» быстро формирует правильную ментальную модель. Усиленная техника для продвинутых: заведите блокнот и записывайте такие «мысленные потоки» для каждого примера из шпаргалки. Через 5–10 примеров вы заметите, что можете прогнозировать, что сделает код, ещё до его запуска — это и есть «интуиция программиста на Си».
  • За день до экзамена обязательно воспроизведите 3–4 задачи по памяти на бумаге: нарисуйте стек вызовов, покажите стрелками указатели, посчитайте смещения в массивах структур через offsetof. На контрольной автодополнения и подсказок IDE не будет. Лайфхак для устного экзамена: когда преподаватель задаёт вопрос про указатели или массивы, возьмите лист бумаги и сразу нарисуйте прямоугольник «стек», прямоугольник «куча», прямоугольники-ячейки для переменных и стрелки для указателей. Преподаватели МИФИ/МФТИ это любят и оценивают дополнительно — вы показываете, что понимаете модель памяти, а не просто «знаете слова». Дополнительный лайфхак: рисуйте адреса внутри ячеек (например, 0x7fff5a00 для стека, 0x555a00 для кучи) — это сразу показывает преподавателю, что вы понимаете разницу между сегментами памяти.
  • Типичная ловушка студента №1 — «выучить синтаксис, но не понять модель памяти». В результате на устном экзамене не можете ответить на простой вопрос «где физически лежит локальная переменная и когда она исчезнет». Не повторяйте эту ошибку — рисуйте память руками при каждом примере. Частный случай ловушки: студент знает, что malloc выделяет память, но не понимает, что после free указатель становится «висячим» (dangling pointer) и его обязательно нужно занулить (p = NULL;), иначе повторное free(p) — это UB и крах программы. Другой частный случай: студент путает «адрес переменной» и «значение переменной» — говорит *p = &x вместо p = &x или *p = x вместо p = &x. Лечится только одним способом — рисовать каждый пример на бумаге с разделением «указатель хранит адрес» vs «по адресу лежит значение».
  • Типичная ловушка №2 — «пишу код, который компилируется, но содержит UB». Undefined Behavior (UB) — это ситуация, поведение программы в которой не определено стандартом Си: компилятор может сделать что угодно (от «работает» до «падает» до «выдаёт мусор»), и на разных платформах/компиляторах результат будет разным. Преподаватель в МИФИ/МФТИ за это снижает балл, даже если программа «работает у меня на компьютере». Поэтому в разобранных примерах ниже всегда отмечено, где начинается UB и почему. Топ-5 примеров UB, которые ловят на экзаменах (с пояснением, почему это UB): (1) выход за границы массива a[10] для массива размера 10 — потому что a[10] обращается к 11-му элементу, который не принадлежит массиву; компилятор не проверяет границы, и за этим адресом может лежать что угодно (чужой указатель, адрес возврата, мусор); (2) использование неинициализированной переменной int x; printf("%d", x); — потому что в этом месте стека может лежать остаток от предыдущего вызова функции, и значение x непредсказуемо; (3) двойной free(p) или free для указателя, не полученного из malloc — потому что менеджер памяти хранит список свободных блоков в специальной структуре, и двойное освобождение портит эту структуру, следующий malloc может вернуть мусор или упасть; (4) разыменование NULL-указателя *p при p == NULL — потому что адрес 0 (или 0xFFFFFFFFFFFFFFFF на некоторых платформах) зарезервирован ОС как «невалидный», обращение к нему вызывает Segmentation Fault; (5) переполнение знакового целого INT_MAX + 1 (для signed int это UB, для unsigned — определено по модулю 2³²) — потому что стандарт Си не определяет, как должно вести себя знаковое переполнение (компилятор может «оптимизировать» код, считая, что INT_MAX + 1 > INT_MAX, и удалить проверку if (x + 1 > x), что приведёт к удалению защитных условий в реальных программах). Дополнительно опасный UB: модификация строкового литерала char *s = "hello"; s[0] = 'H'; — UB, потому что строковые литералы лежат в защищённом от записи сегменте .rodata.
  • Экзаменационный тезис №1 (золотой): любой код на Си — это всегда три вещи: что делает (алгоритм), где хранится (память) и как обращается (указатели). Если на экзамене не можете объяснить все три — ответ неполный. Практический совет: перед тем как писать код в билете, проговорите эти три пункта устно (или шепотом) — «алгоритм: ищу минимум в массиве; память: массив лежит в куче, индекс — локальная переменная на стеке; указатели: передаю в функцию указатель на первый элемент и длину». После этого код пишется почти сам. Расширение тезиса для пятёрки на МФТИ: добавьте четвёртый пункт — «сложность» (как быстро работает алгоритм). Тогда ваш ответ покрывает все четыре темы, которые проверяют на устном экзамене.
  • Экзаменационный тезис №2 (про UB): компиляция без ошибок и предупреждений не означает корректность. В Си есть целый класс ошибок (UB), которые компилятор не ловит, но которые делают программу ненадёжной. Поэтому на экзамене, если код скомпилировался, это только полдела — нужно ещё раз проверить: нет ли выхода за границы массива, неинициализированных переменных, двойных free, разыменования NULL. Полезный приём: после написания кода пройдитесь по нему «взглядом компилятора» — представьте, что вы компилятор, и проверьте каждую строку на соответствие стандарту. Лайфхак: включите в gcc флаги -Wall -Wextra -Werror -fsanitize=address,undefined — эти флаги превращают многие UB из «молчаливых» в «падающие с диагностикой». Если ваш код компилируется с этими флагами — он почти наверняка корректен.
  • Экзаменационный тезис №3 (про сложность): всегда указывайте сложность вашего алгоритма явно — O(n), O(log n), O(n²) — и умейте её обосновать через подсчёт операций. Если алгоритм рекурсивный — пишите рекуррентное соотношение (например, T(n) = 2·T(n/2) + O(n) для сортировки слиянием). Преподаватель спросит «почему O(n), а не O(n²)?» — ответ: «потому что каждый элемент обрабатывается ровно один раз в одном цикле, внешний цикл не открывает дополнительной работы на каждый элемент». Как обосновывать сложность по шагам: (1) посчитайте, сколько раз выполняется каждая строка; (2) выпишите суммарное число операций; (3) оставьте только старший член и уберите константы. Например, цикл for (i=0; i<n; i++) sum += a[i]; выполняет n итераций, в каждой — O(1) операций, итого n · O(1) = O(n). Если в коде два вложенных цикла for (i) for (j), то получается n · n = O(n²). Если цикл делит задачу пополам for (i=n; i>1; i/=2), то число итераций — log₂ n, итого O(log n).

Что проверляют на экзамене (по вузам)

Короткий словарь для навигации. Экзамен по программированию — это не только проверка знания синтаксиса, но и способность превратить условие задачи в работающий код, найти ошибку и обосновать выбор решения. Зачёт vs экзамен: зачёт мягче (допускается оценка «удовлетворительно»), экзамен строже (дифференцированная шкала). Коллоквиум — устная защита теории перед допуском к экзамену. Курсовая работа — законченная программа с отчётом и модульной сборкой. Процедурный C — это императивный стиль программирования, при котором код строится из функций, работающих с данными через аргументы и глобальные переменные; в нём нет классов, наследования и шаблонов — только структуры, массивы, указатели и обычные функции. Стандартная библиотека libc — набор готовых функций (strlen, strcpy, printf, malloc и др.), которые компилятор подключает по умолчанию; на контрольных МИФИ их использование часто запрещено. Инвариант цикла — логическое утверждение о состоянии переменных, которое остаётся истинным до входа в тело цикла, сохраняется после каждой итерации и из которого (вместе с условием выхода) следует правильный ответ программы. Алгоритмическая сложность — функция от размера входа, описывающая, как быстро растёт число операций или памяти; обозначается O (верхняя оценка), Ω (нижняя) и Θ (точная). Машина Тьюринга (МТ) — абстрактная вычислительная модель: бесконечная лента, головка, читающая и пишущая символы, и конечный автомат состояний; с её помощью формализуют само понятие «алгоритм». Нормальный алгоритм Маркова (НАМ) — другая формальная модель: программа задаётся упорядоченной системой подстановок строк, и алгоритм заключается в применении этих правил до тех пор, пока возможно. Проблема останова — классическая алгоритмически неразрешимая задача: нельзя написать программу, которая по тексту любой другой программы и её входу заранее определит, завершится та или «зависнет». Теорема Райса — обобщение: любое нетривиальное свойство результата программы алгоритмически неразрешимо. Метод двух указателей (two pointers) — техника, при которой два индекса (или итератора) движутся по массиву или строке в разных направлениях или с разной скоростью (например, «черепаха и заяц» для поиска цикла в списке). Утечка памяти (memory leak) — ситуация, когда выделенная через malloc память не освобождается через free, и процесс постепенно «съедает» оперативку. Переполнение (overflow) — выход значения за границы типа (int, signed char и т. п.) из-за арифметики; в C для знаковых типов это неопределённое поведение (UB), то есть компилятор не обязан реагировать предсказуемо.

Почему программы вузов различаются? Потому что разные специальности формируют разные компетенции: инженер-программист (МИФИ) — умеет писать «с нуля» без готовых библиотек; математик-программист (ВМК) — умеет доказывать корректность; прикладной разработчик (МФТИ) — умеет переходить с C на C++ и писать эффективный код. К этому стоит добавить ещё три практические причины. Историческая традиция кафедры: в МИФИ сильна школа системного программирования, опирающаяся на Кернигана и Ритчи и классический UNIX, поэтому много внимания уделяется низкоуровневым деталям. Профиль работодателя: ВМК готовит в Яндекс, ABBYY, академические институты — там ценят теорию, формальные модели и умение аргументировать; МФТИ — в IT-компании уровня Google, Yandex, Acronis, где важнее писать быстро и масштабируемо. Форма отчётности: вуз сам определяет, что удобнее проверять — письменную работу, устную защиту или автоматический тест, и программа подстраивается под формат.

Форма контроля и акценты различаются: где-то спрашивают чистый процедурный C без библиотек, где-то добавляют теорию алгоритмов, а где-то требуют C++ на базе C. Ниже — типичная картина по трём вузам; уточняйте программу своей кафедры.

Вуз Формат контроля Ключевые темы
НИЯУ МИФИ Зачёт + экзамен, контрольные в семестре, курсовая работа Процедурный C, задачи без стандартных функций (strlen, strcpy, pow, qsort, atoi), массивы и строки вручную, текстовые и бинарные файлы, динамическая память, побитовые операции, модульная сборка (.h / .c + Makefile)
МГУ (ВМК) Письменный экзамен, практикум на ЭВМ, устные коллоквиумы по теории «Алгоритмы и алгоритмические языки»: C + теория алгоритмов (машина Тьюринга, нормальные алгоритмы Маркова, разрешимость, рекуррентные соотношения), структуры данных, оценки сложности O / Ω / Θ, доказательства корректности через инвариант
МФТИ Зачёт по схеме «1 задача + 2 вопроса», деление на базовый и продвинутый поток Базовый поток: C и алгоритмы (указатели, связные списки, рекурсия, two pointers); продвинутый: акцент на C++ (классы, шаблоны, auto, ссылки, контейнеры и алгоритмы STL) на фундаменте чистого C

Как читать эту таблицу на практике. Колонка «формат контроля» подсказывает, что именно тренировать. Там, где стоит «зачёт + экзамен», одинаково важны и устные определения, и письменные задачи. Где «письменный экзамен» — решают скорость и аккуратность рукописного кода: ошибку в одной строке преподаватель увидит и спросит, почему программа не скомпилируется. Где «1 задача + 2 вопроса» — умейте не только решить, но и объяснить своё решение вслух, потому что первым делом попросят прокомментировать алгоритм. Колонка «ключевые темы» — это фактический список билетов: если тема там названа, она почти наверняка всплывёт. Дополнительный лайфхак: посмотрите на строку своего вуза и на строку «соседа» — пересечение тем подскажет, что точно будет в билете, а уникальные пункты — где можно «набрать» дополнительные баллы глубиной. Если в вашей строке явно указаны «теория алгоритмов» или «STL», готовьтесь отвечать на определения устно, а не только писать код.

МИФИ. Курс строится вокруг «ручного» программирования: на контрольных запрещены strlen, strcpy и часто pow, поэтому нужно уметь писать их самому. Отдельно проверяют понимание представления чисел в памяти, переполнений и работы с файлами. Курсовая — законченная программа с модульной сборкой. Типичные задания: «вывести на экран все простые числа до N без библиотеки math», «подсчитать слова в тексте, не используя strtok», «считать матрицу из файла и транспонировать её». Ключевой навык — аккуратно работать с циклами и границами массивов, потому что санитайзеров на бумаге нет: ошибку вида i <= n преподаватель найдёт при чтении. Помните: если в задании сказано «без стандартных функций», это значит, что strlen, strcpy, pow, qsort и atoi писать нужно самостоятельно — за использование готовой вы получите ноль баллов за задачу. Практический совет: перед сдачей курсовой проверьте три вещи — компилируется ли проект «с нуля» командой make, покрыты ли .h-файлы include-охраной (#ifndef … #define … #endif), и работает ли программа с пустым входным файлом (частый «вопрос с подвохом» на защите). Дополнительно: на защите умейте рассказать, почему вы выбрали конкретную структуру данных и где именно в программе живёт каждая переменная (стек, куча, сегмент данных).

Аналогия из жизни. Контрольная МИФИ похожа на кухню, где вам дали только муку, молоко и нож: ни нарезанного хлеба, ни тёртого сыра. Всё нужно «приготовить» руками. Это не каприз преподавателя, а способ проверить, понимаете ли вы, как устроены strlen и strcpy «под капотом», а не просто умеете их вызывать. Точно так же повар-профессионал не пользуется полуфабрикатами: он знает, как замесить тесто, как поднять соус, как нарезать ровные кубики. Если вы «полуфабрикатный» повар, вы провалитесь там, где нет магазина за углом.

Почему именно так? Инженер, проектирующий встраиваемую систему или драйвер, обязан знать, что происходит на каждом такте процессора. Без понимания внутреннего устройства функций нельзя ни оптимизировать код под микроконтроллер, ни разобраться в причинах сбоя, когда готовых инструментов под рукой нет. Дополнительно: в системах реального времени (авионика, АСУ ТП, медицинские приборы) библиотеки часто запрещены по стандартам безопасности (MISRA C, DO-178C), потому что их поведение не всегда детерминировано и не покрыто формальной верификацией. Поэтому «ручное» программирование в МИФИ — это не архаизм, а репетиция индустриальных требований.

Типичные ошибки на контрольных МИФИ.

  1. Забытый '\0' в конце самописной строки — функция «убегает» за границу массива, читает чужую память.
  2. Замена pow(x, 2) на x*x без проверки диапазона: при больших x сразу ловим переполнение int. Безопаснее — (long long)x * x или явный каст к double.
  3. Путают порядок fclose(fp) и проверки ferror(fp): закрыли файл — и ошибку записи уже не увидим, потому что буфер сбросится в fclose.
  4. В задаче «посчитать слова» увеличивают счётчик после каждой буквы, а нужно — только при переходе из пробела в букву, иначе получают «слова» из одних пробелов и пустую строку в начале.
  5. Забывают free() на одной из веток return — утечка памяти, которую на бумаге не запустишь, но преподаватель увидит по коду и снизит балл.
  6. В реализации strcmp сравнивают через > вместо !=, из-за чего функция возвращает «неправильный» знак для лексикографического порядка.
  7. При чтении из файла не проверяют возврат fscanf/fgets, и программа «падает» на пустом или битом файле — нужна явная проверка if (n != expected) { … }.
  8. В рекурсивной функции забывают терминальное условие: на бумаге это выглядит «почти правильно», но при исполнении — переполнение стека; преподаватель снимает баллы за отсутствие if (n == 0) return ….
  9. При работе с динамической памятью используют указатель после free() (use-after-free) или realloc без сохранения нового адреса — старый указатель становится «висячим».
  10. В реализации atoi неправильно обрабатывают знак числа, пробелы по краям или пустую строку — программа «падает» или возвращает мусор; нужна явная обработка '-' и пропуск whitespace.

Экзаменационные тезисы МИФИ.

  • Представление целых чисел в дополнительном коде и предсказание переполнения int ещё до запуска.
  • Реализация strlen, strcpy, strcmp, atoi, pow «руками» — на автомате, без подглядывания в man.
  • Чтение и запись текстовых и бинарных файлов через fopen / fread / fwrite / fclose и обработка EOF / ferror.
  • Динамические массивы: malloc / calloc / realloc / free, связные списки на указателях.
  • Курсовая: разбиение на .h / .c, Makefile, сборка из нескольких файлов и простая отладка gdb.
  • Чтение кода с указателями: уметь по фрагменту сказать, где *p, где &x, чему равно *(&a[3]) и почему arr и &arr[0] — это одно и то же.
  • Стек и куча: понимать, где живут локальные переменные, где — глобальные, а где — malloc-память; уметь изобразить стек вызовов для простой рекурсии.
  • Побитовые операции: &, |, ^, ~, <<, >> — флаги, маски, выделение отдельных бит числа; типичная задача — установить/снять 3-й бит «регистра».
  • Препроцессор: #define, #include, макросы с аргументами и подводные камни побочных эффектов (SQR(x++) — классический пример).
  • Структуры и typedef: уметь описать структуру, обратиться к полю через . и через ->, выделить массив структур динамически.

МГУ (ВМК). Курс «Алгоритмы и алгоритмические языки» соединяет практику на C с теоретическим минимумом: формальные модели вычислений, оценки сложности, доказательства корректности. Коллоквиум — устная защита теории, письменный экзамен — задачи и определения. Отличительная черта: спрашивают не только «как», но и «почему это правильно» — например, просят доказать завершаемость алгоритма, оценить сложность по числу операций или сформулировать инвариант цикла. На коллоквиуме по машине Тьюринга будьте готовы построить МТ для конкретной задачи (например, «прибавить единицу к числу в унарном коде»), а по нормальным алгоритмам Маркова — применить правила подстановки вручную. Теоретический блок раздела ниже закрывает именно эти вопросы, поэтому его стоит учить наизусть, а не «понимать в общих чертах». Практический совет: доказательство корректности всегда стройте по схеме «инициализация → сохранение → завершение» — за каждый пропущенный пункт снимают баллы; оценку сложности записывайте не просто «O(n)», а с кратким обоснованием «внешний цикл n раз, внутренний не более n/2, итого O(n²)». На устном коллоквиуме сначала дайте определение термина и только потом — пример; преподаватели ВМК это ценят.

Аналогия из жизни. Если практика на C — это вождение автомобиля, то теория алгоритмов — это правила дорожного движения и физика работы двигателя. Можно ездить и без знания физики, но инженер-конструктор обязан понимать, почему машина тормозит и почему алгоритм завершается. Иначе любой «занос» на программе останется необъяснимым. Вспомните, как автогонщик высшего класса чувствует машину: он не просто нажимает педали — он знает, что именно происходит в моторе в каждый момент. Точно так же на ВМК ждут от вас не «работает и ладно», а «я могу объяснить, почему это работает».

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

Типичные ошибки на ВМК.

  1. Путают «доказательство корректности» с «эмпирической проверкой на тестах»: 1000 пройденных тестов не доказывают правильность для всех входов.
  2. Сложность алгоритма оценивают «на глаз», а нужно явно показать: «число операций пропорционально n², потому что внешний цикл выполняется n раз и вложенный тоже n».
  3. Инвариант цикла формулируют как «сейчас i-й элемент правильный», но забывают три обязательные части: (а) инициализация — инвариант верен до входа в цикл, (б) сохранение — если верен до итерации, верен и после, (в) завершение — из инварианта при выходе из цикла следует требуемый ответ.
  4. Путают машину Тьюринга с нормальным алгоритмом Маркова: МТ работает с лентой, головкой и состояниями, а НАМ — только с подстановкой строк по системе правил.
  5. В доказательстве завершаемости не указывают убывающую функцию (например, «длина оставшегося отрезка» или «значение счётчика итераций»), без которой завершаемость строго не обоснована.
  6. При построении МТ забывают, что у каждого состояния должны быть определены все переходы для всех символов на ленте — иначе это «дырявая» программа, которая не является корректной МТ.
  7. В оценке сложности рекурсивного алгоритма пытаются «на глаз» прикинуть O, забывая про уравнение рекуррентности (T(n) = 2T(n/2) + n и т. п.) и мастер-теорему.
  8. На коллоквиуме путают «разрешимое» и «полуразрешимое» (перечислимое): множество программ, которые останавливаются, перечислимо, но не разрешимо — это важное различие.
  9. При анализе НАМ путают порядок применения правил: применяется первое подходящее правило из списка, а не «любое подходящее»; это меняет результат.
  10. В оценке сложности забывают про «худший случай» и приводят оценку «в среднем» без указания распределения входов — преподаватель ВМК это ловит сразу.

Экзаменационные тезисы ВМК.

  • Формулировка и доказательство корректности алгоритма через инвариант цикла.
  • Оценка сложности O / Ω / Θ для итеративных и рекурсивных алгоритмов.
  • Машина Тьюринга: построить МТ для простой задачи (инкремент, копирование, проверка чётности).
  • Нормальные алгоритмы Маркова: применить систему подстановок и дойти до результата вручную.
  • Структуры данных: список, стек, очередь, дерево — реализация на C и сложность операций.
  • Вопросы разрешимости: что можно и что нельзя вычислить (проблема останова, теорема Райса).
  • Тезис Чёрча–Тьюринга: уметь сформулировать и показать, что разные формальные модели (машина Тьюринга, лямбда-исчисление, нормальные алгоритмы) эквивалентны по выразительной силе.
  • Рекуррентные соотношения: вывод мастер-теоремы для типовых случаев T(n) = aT(n/b) + f(n).
  • Классы сложности P, NP, NP-полнота на уровне определений и примеров: уметь отличить задачу из P (сортировка) от NP-полной (перебор подмножеств).
  • Метод «разделяй и властвуй»: уметь записать рекуррентность для сортировки слиянием и быстрой сортировки и оценить её сложность.

МФТИ. Зачёт по схеме «одна задача плюс два теоретических вопроса»: задача обычно на массивы, строки, рекурсию или списки, а вопросы — по памяти, указателям и алгоритмам. Продвинутый поток почти всегда переходит на C++, поэтому крепкая база C здесь особенно важна. Практический совет: на зачёте сначала проговорите идею решения словами («иду по массиву двумя указателями, потому что...»), и только потом пишите код — преподаватель оценивает именно ход мысли, а не скорость набора. Классические задачи: «развернуть односвязный список без дополнительной памяти», «проверить, является ли строка палиндромом, за один проход», «найти k-й по величине элемент массива». Если задача кажется слишком простой, в ней почти всегда спрятана ловушка — переполнение int, пустой вход или строка без '\0'. Лайфхак: перед тем как писать код на доске, набросайте 2–3 примера входов и вручную прогоните алгоритм — это занимает минуту, но почти полностью ловит ошибки «на бумаге». Дополнительно: на устном вопросе сначала определите термин своими словами, потом приведите пример, потом — контрпример (если применимо). Преподаватели МФТИ любят полноту картины.

Аналогия из жизни. Зачёт МФТИ похож на экзамен по вождению, где инспектор сначала спрашивает теорию («зачем нужен этот рычаг?»), а потом предлагает проехать по маршруту. Сначала — понимание, потом — действие. Именно поэтому задачу начинают с устного комментария: так вы показываете, что понимаете модель, а не воспроизводите заученный шаблон. Хороший водитель видит дорогу заранее, а не реагирует в последний момент — точно так же сильный программист «видит» выполнение кода по строчкам, не запуская его.

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

Типичные ошибки на зачёте МФТИ.

  1. В задаче «найти k-й элемент» забывают, что k может быть больше длины массива или равно 0 — нужна явная проверка 0 < k && k <= n, иначе выход за границы или мусор в ответе.
  2. При развороте односвязного списка «теряют» хвост: после next = cur->next; cur->next = prev; забывают правильный порядок prev = cur; cur = next; — и следующая итерация уходит в никуда.
  3. Палиндром строки проверяют через strcmp(s, reversed) — это не «один проход»; нужно либо развернуть копию в новом массиве, либо идти двумя указателями с концов к центру.
  4. Рекурсия без базы индукции — бесконечная рекурсия и переполнение стека. На бумаге не запустишь, но преподаватель увидит по отсутствию if (n == 0) return … и снимет баллы.
  5. В теоретическом вопросе про указатели путают *p (разыменование) и &x (взятие адреса), а также забывают разницу между массивом и указателем — это «классика» зачётов МФТИ.
  6. В задаче на рекурсию сначала пишут тело, а потом «забывают» про базу — получают «почти правильный» код, который уходит в бесконечность; тренируйтесь выписывать базу и шаг индукции отдельно.
  7. При ответе на теоретический вопрос про const-корректность путают «указатель на константу» (const int *p) и «константный указатель» (int * const p) — это две разные вещи, и преподаватель МФТИ это регулярно проверяет.
  8. В задаче «черепаха и заяц» забывают, что перед запуском цикла slow и fast должны указывать на head, а не на head->next — иначе цикл длиной 1 не обнаружится.

Экзаменационные тезисы МФТИ.

  • Указатели и адресная арифметика: *, &, ->, разница между массивом и указателем, связь имени массива с адресом первого элемента.
  • Связные списки: вставка, удаление, разворот на месте — без дополнительной памяти.
  • Рекурсия: база индукции, шаг, оценка глубины стека и преобразование рекурсии в цикл.
  • Метод двух указателей (two pointers): «черепаха и заяц» (поиск цикла), слияние отсортированных массивов.
  • Готовность перейти на C++: классы, шаблоны, auto, ссылки и контейнеры STL — на фундаменте чистого C.
  • Семантика const: различать const T *, T const *, T * const и const T * const — это регулярно встречается и в C, и в C++.
  • Динамическая память в C++: понимать, зачем нужны new/delete, и в чём разница с malloc/free (вызов конструкторов и деструкторов, исключения при нехватке памяти).
  • Введение в STL: vector, list, map, set, итераторы и алгоритмы — уметь выбрать контейнер под задачу и обосновать выбор.
  • Ссылки vs указатели: в чём разница, когда компилятор «под капотом» превращает ссылку в указатель, и почему ссылка не может быть nullptr.
  • RAII и умные указатели (unique_ptr, shared_ptr) на уровне идеи: ресурс привязан к времени жизни объекта, чтобы исключить утечки.

Теоретический минимум: определения и тезисные ответы

Здесь собраны темы, которые спрашивают устно и письменно. Каждая — короткие тезисы плюс минимальный пример кода, который можно воспроизвести на доске или в бланке. Отвечая на билет, держитесь схемы «определение → зачем нужно → как устроено → пример»: экзаменатор почти всегда снижает оценку за ответ без определения, даже если вы умеете писать код. Если формулировка вспоминается с трудом, сначала назовите определение, а потом раскрывайте детали — так вы получите хотя бы базовый балл. Полезно также держать в голове структуру «что → зачем → почему именно так → как проверить» — она совпадает с логикой экзаменационных вопросов и помогает не пропустить важную часть ответа. Практический совет: перед ответом мысленно произнесите «по определению это…», «это нужно для того, чтобы…», «работает это так: …», «проверить можно так: …» — четыре «якоря», за которые цепляется внимание преподавателя. Не пытайтесь сразу рассказать всё — лучше три пункта с примерами, чем десять тезисов без иллюстраций.

Что такое неопределённое поведение (UB)

Прежде чем разбирать конкретные темы, зафиксируем главное понятие, на которое ссылается половина ответов, — неопределённое поведение (UB, undefined behavior). Простое определение для билета: UB — это ситуация, в которой стандарт языка Си не предъявляет к программе никаких требований относительно результата выполнения. То есть компилятор формально имеет право сделать с такой программой что угодно: выдать любое число, «зависнуть», вылететь с ошибкой, удалить файл или «оптимизировать» опасный код в нечто совершенно непредсказуемое. Аналогия из жизни: UB — это как перейти дорогу на красный свет в незнакомом городе: обычно «проносит», но однажды не пронесёт, и предсказать этот момент невозможно. В Си UB принципиально отличается от ошибок в других языках: он не обязан вызывать аварийное завершение (segmentation fault) — он может «повезти» на вашей машине, у вашего компилятора и с вашими флагами оптимизации, и «не повезти» на экзаменационной машине у преподавателя. Поэтому главная опасность UB — его воспроизводимость: баг может годами не проявляться, а потом появиться после смены версии gcc, перехода на другой процессор или включения оптимизации -O2. Аналогия точнее: UB — это не спрятанная мина, а исписанная страница контракта, на которой компилятор имеет право нарисовать что угодно; пока страница пуста, всё работает, но в любой момент «правила» могут измениться. Именно поэтому экзаменаторы так любят вопросы про UB: проверяется понимание того, что в Си программист сам отвечает за корректность, а компилятор — лишь за перевод корректной программы в машинный код.

Почему стандарт допускает UB? Исторически это было сделано для двух целей: (1) дать компилятору свободу для агрессивных оптимизаций (например, компилятор вправе считать, что знаковое переполнение невозможно, и убрать проверки) и (2) учесть архитектурные различия (на разных процессорах переполнение ведёт себя по-разному). Отсюда практический вывод: UB — это контракт между программистом и компилятором, нарушение которого снимает с компилятора все обязательства. Развёрнутое объяснение «почему так»: стандарт Си создан в 1970-х годах, когда каждый такт процессора был на счету, а компиляторы были далеки от совершенства. Если бы стандарт требовал определённого поведения во всех «неоднозначных» ситуациях, разработчикам компиляторов пришлось бы добавлять десятки проверок в каждую инструкцию — и программы стали бы работать в разы медленнее. Проще сказать программисту: «не делай так», чем заставлять железо обрабатывать все возможные случаи. Это так называемая модель «доверенного программиста» (trusted programmer) — в отличие от Java или Python, где безопасность встроена в язык «по умолчанию».

Типичные источники UB, которые нужно назвать на экзамене (запомните как «большую пятёрку»):

  • Переполнение знаковых целых (например, int x = INT_MAX; x++;). Беззнаковые целые при этом определены: они заворачиваются по модулю 2^N, то есть ведут себя как часы: после 255 идёт 0. Это частая «ловушка» в билетах: студенты путают знаковое и беззнаковое переполнение. Почему разница такая принципиальная: для беззнаковых типов архитектура x86 (и большинство современных процессоров) физически реализует «оборачивание» — флаг переноса просто отбрасывается, и поведение однозначно. Для знаковых — процессоры ведут себя по-разному (x86 генерирует исключение только при делении, ARM — то же), поэтому стандарт решил не навязывать конкретный вариант, а снять с себя ответственность.
  • Разыменование NULL (обращение по нулевому указателю), «дикого» (неинициализированного) или висячего (указывающего на освобождённую память) указателя. Практический совет: при работе с указателями полезно сразу после free(p) писать p = NULL; — это «обнулит» висячий указатель, и попытка разыменования даст предсказуемый segfault, а не порчу произвольной памяти. Такой приём называется «защитным обнулением».
  • Выход за границы массива и чтение неинициализированной памяти — компилятор не обязан проверять индексы «из коробки». Почему это разрешено: проверка каждого обращения к массиву добавляет несколько ассемблерных инструкций к каждой строке кода — для научных и системных программ это недопустимо. В Си действует принцип «доверяй программисту» (trust the programmer): если вам нужна проверка — пишите её сами (assert(i < n)).
  • Множественные изменения одной переменной без sequence point (классический пример i = i++ + ++i; или a[i++] = i++;) — до C++11 и до принятия нового стандарта C11 в этом случае порядок вычислений не определён. Термин для запоминания: sequence point (точка следования) — это момент в программе, после которого все побочные эффекты предыдущих вычислений уже выполнены, а до следующего ещё не начались. В C11 точки следования остались, но к ним добавились более строгие правила «sequenced before / sequenced after», которые проще запомнить как «не изменяйте одну переменную дважды в одном выражении».
  • Обращение к строковому литералу на запись (например, char *p = "hello"; p[0] = 'H';) — литералы в большинстве реализаций лежат в read-only памяти, и попытка записи приводит к аварийному завершению или порче данных. Развёрнутое объяснение: строковые литералы компилятор размещает в сегменте .rodata, который при загрузке процесса отображается в память с правами «только чтение». Любая попытка записи ловится аппаратной защитой страниц (page fault) и превращается в SIGSEGV. Но стандарт говорит именно про UB, потому что теоретически реализация может размещать литералы в обычной памяти — и тогда программа «молча» испортит литерал, а при следующем вызове функции вернёт испорченную строку. Поэтому безопасный стиль — всегда объявлять строковые литералы как const char *p = "hello";.

Дополнительно (для полного балла) полезно упомянуть: деление на ноль для целых, выход за пределы объекта через арифметику указателей, нарушение правил restrict, неправильное использование функций с переменным числом аргументов (неверный формат в printf), чтение значения объекта через указатель другого типа (aliasing rules), а в C11 — обращение к памяти через указатель на неактивный член объединения (для Си это формально UB, в отличие от C++). Аналогия для объединений (union): представьте, что union — это коробка с одним отделением, в которое можно положить либо молоток, либо отвёртку. Если вы уберёте молоток и попробуете «использовать» его как отвёртку — никто не гарантирует, что отвёртка окажется в руках; вы можете порезаться, уронить инструмент или случайно забить гвоздь шурупом. Так и с union: пока вы пишете в один член и читаете из того же — всё корректно; иначе — UB.

Правило-мнемоника: UB = «Uвеличить шанс, что препод найдёт баг». На экзамене полезно добавить, что флаги -fsanitize=address,undefined (AddressSanitizer и UndefinedBehaviorSanitizer) превращают скрытые UB в явные сообщения во время выполнения — это современная практика отладки, которая ловит большинство типов UB ещё на этапе тестирования. Практический совет: компилируйте свои программы командой gcc -Wall -Wextra -fsanitize=address,undefined -g program.c -o program — так называемые санитайзеры встроят в бинарник инструментацию, которая сообщит о каждом UB с указанием файла и строки. Это в десятки раз эффективнее, чем искать баги «руками» через отладчик. Типичная ошибка студентов — путать UB и unspecified behavior (неуказанное поведение): unspecified — это когда стандарт разрешает несколько вариантов, но каждый из них корректен, а UB — когда стандарт вообще ничего не гарантирует. Пример для запоминания: порядок вычисления аргументов функции в Си — unspecified (f(a, b) может вычислить a раньше b или наоборот), но оба варианта корректны. А вот разыменование NULL — UB, потому что вариантов «правильного» поведения нет вовсе. Третий термин для полноты: implementation-defined behavior (поведение, определяемое реализацией) — это когда стандарт разрешает выбрать один вариант, но требует от компилятора задокументировать выбор (например, размер int в байтах). Три термина часто путают на экзамене — держите их в голове как «не указано» / «определено реализацией» / «не определено вообще».

Алгоритм и его свойства

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

Алгоритм — конечная последовательность однозначно понимаемых указаний исполнителю, приводящая от исходных данных к результату за конечное число шагов. Классические свойства (по А. Н. Колмогорову и школьной традиции, иногда называют «пятью свойствами алгоритма»):

  • Дискретность — процесс состоит из отдельных, обособленных шагов; каждое действие выполняется строго после предыдущего (нет «размазанных» во времени операций). Аналогия: конвейер на заводе — изделие перемещается от станка к станку дискретно, не «плавно перетекает» от одного рабочего к другому. Каждый шаг имеет чёткое начало и конец.
  • Детерминированность — при одних и тех же входных данных результат и последовательность шагов однозначны (никакой зависимости от случайностей, настроения процессора или фазы Луны). Аналогия: хорошо сделанный кулинарный рецепт — два повара, готовящие борщ по одному рецепту, должны получить одинаковый вкус (если, конечно, не добавлять ингредиенты «на глаз»).
  • Массовость — алгоритм применим не к одной конкретной задаче, а к целому классу задач с общим правилом обработки (алгоритм сложения работает для любых двух чисел, а не только для 2 + 3). Аналогия: формула площади треугольника работает для любого треугольника — хоть с основанием 5, хоть с основанием 5 миллионов; частный случай «площадь треугольника со сторонами 3, 4, 5» — это не алгоритм, а задача.
  • Результативность — алгоритм завершается за конечное число шагов и выдаёт результат (в том числе «нет решения» — это тоже валидный результат, важно понимать!). Частая ошибка: путать результативность с «успехом». Алгоритм поиска может вернуть «элемент не найден» — и это результат, а не провал. Алгоритм проверки пароля может вернуть «неверный пароль» — тоже результат.
  • Конечность (сходимость) — запись самого алгоритма конечна, а число шагов ограничено (не путать с «завершимостью»; конечность относится и к описанию, и к процессу). Аналогия: рецепт на 10 страниц — это конечный текст, но варить борщ по нему 100 лет — нарушение результативности. Конечность относится к самому рецепту (его описание не бесконечно), а результативность — к процессу приготовления (он должен когда-то закончиться).

Мнемоника для запоминания свойств: «Дискретность, Детерминированность, Массовость, Результативность, Конечность» → короткая цепочка «Ди-Де-Ма-Ре-Ко». Расшифровка по вопросам экзаменатора: что делаем? — шаги (дискретность); одинаков ли ответ? — да (детерминированность); для многих ли задач? — да (массовость); получим ли ответ? — да (результативность); закончится ли? — да (конечность). Если преподаватель просит привести контрпример, скажите: «процесс, зависящий от случайных чисел (например, rand() % 2), не детерминирован — при одних и тех же входных данных он может дать разные ответы; бесконечный цикл без условия выхода (while (1) { ... }) нарушает результативность, потому что алгоритм никогда не выдаст ответ». Ещё один контрпример для массовости: программа, складывающая только числа 2 и 3, — она работает, но не является алгоритмом в строгом смысле, потому что решает лишь одну задачу. Дополнительный контрпример для дискретности: непрерывный физический процесс (например, движение стрелки часов от 0 к 12) — он плавный, без отдельных шагов, поэтому не является алгоритмом, хотя описывается математической формулой. Именно для того, чтобы такие процессы «оцифровать», и придумали понятие дискретности.

Иногда добавляют понятность (исполнитель умеет выполнять каждую команду из набора инструкций) и эффективность (разумные затраты времени и памяти, без «бессмысленного» перебора). Эти два свойства не входят в «каноническую пятёрку», но часто упоминаются в расширенных формулировках. Пояснение «зачем»: понятность важна, потому что алгоритм пишется для конкретного исполнителя. Рецепт «открыть банку шуруповёртом» невыполним для повара без шуруповёрта, но тривиален для мастера в гараже. Эффективность важна потому, что вычислительные ресурсы ограничены: алгоритм, который перебирает все перестановки массива из 1000 элементов, математически существует, но практически бесполезен (если только вы не готовы ждать 1000! лет). Формальная модель — машина Тьюринга: она задаёт точное понятие «вычислимая функция» и позволяет доказывать существование или невозможность алгоритма для конкретной задачи. Аналогия: машина Тьюринга — это абстрактный «самый простой компьютер»: бесконечная лента, головка, которая читает/пишет символы, и таблица правил. Любой алгоритм, который можно описать для реального компьютера, можно описать и для машины Тьюринга (это и есть тезис Чёрча-Тьюринга). Важно подчеркнуть на экзамене, что свойства — это требования к алгоритму как объекту, а не к конкретной программе: одна и та же задача может решаться алгоритмом с разной эффективностью (например, сортировка пузырьком и слиянием), и именно оценка эффективности приводит нас к понятию сложности и классам P/NP. Краткое пояснение про P и NP: класс P — задачи, которые можно решить за полиномиальное время (быстро); класс NP — задачи, решение которых можно проверить за полиномиальное время (быстро проверить, но не обязательно быстро найти). Вопрос «равны ли P и NP?» — одна из нерешённых проблем математики, за решение которой обещан миллион долларов. Типичная ошибка студентов — путать алгоритм и программу: программа — это запись алгоритма на конкретном языке, а алгоритм — абстрактная последовательность действий, которая может быть реализована на разных языках или даже выполнена человеком вручную. Типичный вопрос на экзамене: «Является ли программа алгоритмом?» Ответ: программа — это запись алгоритма. Сама по себе программа может содержать синтаксические ошибки и не быть алгоритмом вообще; если же она корректна и реализует алгоритм, то алгоритмом является лежащий в её основе метод, а не текст программы.

// Простейший линейный алгоритм: ни ветвлений, ни циклов,
// поэтому все пять свойств выполняются «по построению».
// Тип возврата double, а не float: точности float для вычислений мало.
double triangle_area(double a, double h) {
    double s = 0.5 * a * h;   // 1. подготовка данных: S = (1/2) * a * h
    return s;                 // 2. вычисление завершено, отдаём результат
}                             // 3. конец функции — алгоритм остановился за один шаг

Почему этот пример удобно приводить на экзамене: он линеен (дискретность видна как три отдельных действия: умножение, присваивание, возврат), детерминирован (для одних и тех же a и h результат один и тот же, никаких rand()), массивен (формула годится для любого треугольника с любыми стороной и высотой, а не для одного конкретного), результативен (всегда возвращает число типа double) и конечен (ровно один оператор вычисления, нет циклов и рекурсий). Сравните с рекурсивной функцией из раздела «Функции»: там результативность и конечность приходится доказывать отдельно — через наличие базы рекурсии и уменьшение аргумента на каждом шаге. Именно эта разница и есть типичный вопрос «объясните, почему рекурсия может нарушить свойство конечности»: если базового случая нет или аргумент не уменьшается, рекурсия уходит в бесконечность (переполнение стека) — и алгоритм перестаёт быть результативным. Как доказывать корректность рекурсивного алгоритма на экзамене: (1) укажите базовый случай, в котором функция возвращает ответ без рекурсивного вызова; (2) покажите, что на каждом шаге аргумент строго уменьшается (например, n-1 вместо n); (3) сделайте индуктивный вывод: если для n-1 функция работает, то и для n работает, а для базы — работает по определению. Ещё один частый экзаменационный вопрос: «Является ли алгоритмом программа, которая иногда зацикливается?» Ответ — нет, потому что нарушается результативность; алгоритм обязан завершаться и выдавать ответ (в том числе «ответ: зацикливаюсь» — но это уже мета-уровень). Уточнение для полного балла: программа, которая зацикливается при определённых входных данных, — это вообще не алгоритм; программа, которая зацикливается при любых входных — это просто ошибка, она не реализует никакого алгоритма.

Блок-схемы по ГОСТ

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

Схемы алгоритмов оформляют по ГОСТ 19.701-90 (ЕСПД) — он заменил ГОСТ 19.002-80 и ГОСТ 19.003-80. По содержанию это международный стандарт ISO 5807, но в русскоязычной учебной среде по привычке ссылаются на ГОСТ. Практическая подсказка: на экзамене достаточно назвать номер «ГОСТ 19.701-90» — это стандарт Единой системы программной документации (ЕСПД), разработанный ещё в СССР и по-прежнему действующий в РФ. Зарубежные аналоги называют этот же стандарт «flowcharts» и ссылаются на ISO 5807:1985 — содержание практически идентично. Основные символы:

Символ Фигура Назначение
Терминатор Скруглённый прямоугольник Начало и конец алгоритма
Процесс Прямоугольник Вычисление, присваивание
Решение Ромб Условие с двумя и более выходами
Ввод/вывод Параллелограмм Чтение и печать данных
Предопределённый процесс Прямоугольник с двойными боковыми сторонами Вызов подпрограммы
Документ Прямоугольник с волнистой нижней стороной Вывод на бумагу
Соединитель Окружность Разрыв линии потока
Комментарий Скобка (фигурная) Пояснение
Линия потока Стрелка Направление выполнения

Правила: поток идёт сверху вниз и слева направо, у ромба подписывают выходы («да»/«нет»), блоки соединяются только линиями потока, а не касанием. Почему именно так: «направление по умолчанию» убирает лишние стрелки и снижает визуальный шум; подписи выходов у ромба обязательны, потому что у одного условия могут быть ветки «да», «нет», «равно», «больше» — без подписи читатель не знает, какая ветка куда ведёт; запрет на касание блоков связан с тем, что в ГОСТ-е линия — это строгое указание на порядок выполнения, и две соприкасающиеся фигуры без стрелки — «серый поток», который стандарт запрещает. Дополнительное пояснение: касание блоков — это «неопределённый поток», в реальной программе аналог отсутствия явного goto: исполнитель (человек или компилятор) не знает, что делать дальше. Поэтому стандарт требует явных стрелок.

Дополнительные правила, которые любят на экзамене:

  • Один блок «Начало» и один блок «Конец» на всю схему. Если в схеме несколько «выходов» (например, return из функции в середине), их показывают стрелками к терминатору «Конец», но сам терминатор рисуют один раз — на нём сходятся все ветки. Аналогия: в здании один вход и один центральный выход; даже если из каждого кабинета можно выйти на улицу, все дороги ведут к одному выходу. Это упрощает контроль: «алгоритм завершился» означает «достигнут терминатор Конец».
  • Внутри блоков пишут осмысленные действия («x = x + 1»), а не код Си целиком: блок-схема отражает логику, а не синтаксис. Сравнение x > 0 записывают именно так, а не как вызов функции. Практическая подсказка: если в блоке не помещается текст, не пытайтесь уменьшить шрифт до нечитаемого — лучше разбейте действие на несколько блоков или используйте предопределённый процесс (вызов подпрограммы).
  • Размеры фигур стандарт не фиксирует жёстко, но отношение сторон у прямоугольника должно быть близко к 2:1, а у ромба — примерно равным высоте и ширине. Зачем: стандартизированные пропорции помогают быстро «считывать» схему: прямоугольник «горизонтальный», ромб «квадратный» — глаз сразу узнаёт фигуру по силуэту.

Как запомнить символы (мнемоника): «овал открывает и закрывает, прямоугольник работает, ромб спрашивает, параллелограмм общается (ввод/вывод), двойной прямоугольник вызывает помощника». На экзамене чаще всего просят нарисовать схему для линейного алгоритма, ветвления (if) и цикла (while): цикл изображают ромбом с обратной стрелкой, возвращающейся к телу цикла. Для цикла for схема строится так: блок инициализации → ромб условия → тело → блок модификации → возврат к ромбу. Для do-while тело рисуется перед условием, а стрелка идёт от тела к ромбу и обратно к телу, что подчёркивает «сначала сделай, потом проверь». Типичный подвох на экзамене: в do-while проверка идёт после тела, поэтому в схеме стрелка от ромба «ложь» идёт на выход из цикла, а «истина» — обратно к телу. Это противоположно while, где «истина» ведёт в тело, а «ложь» — на выход. Запомните: «while сначала думает, потом делает; do-while сначала делает, потом думает».

Типичные ошибки в блок-схемах: забыли подписать выходы ромба «да»/«нет»; нарисовали два терминатора «Начало» (он должен быть один); соединили блоки касанием без стрелки; использовали прямоугольник вместо ромба для условия; поставили блок ввода/вывода в начало без терминатора; перепутали местами «да» и «нет» (где «да» — справа, а где — слева, в ГОСТ-е не указано, но принято, что «да» идёт вниз или вправо); использовали ромб с одним выходом (должно быть минимум два); нарисовали стрелку, идущую внутрь блока, а не от него. Дополнительные ошибки, которые тоже «снимают баллы»: стрелка входит в блок сбоку, а выходит сверху (входить можно с любой стороны, выходить — только снизу или сбоку, но не сверху, потому что это нарушает направление «сверху вниз»); в ромбе написано не условие, а действие (x = 5 вместо x > 0?); в блоке ввода/вывода написано printf(...) (это код, а в схеме пишут «Вывод: x»); не соединены все блоки (получается «висячий» блок, до которого нельзя добраться по стрелкам); отсутствует связь между концом одной ветки и блоком «Конец». На письменном экзамене за компактность снимают меньше баллов, чем за неверные фигуры, поэтому сначала проверьте, что каждая фигура соответствует таблице выше, и только потом улучшайте аккуратность. Алгоритм самопроверки перед сдачей: (1) найдите все ромбы и убедитесь, что у каждого подписаны оба выхода; (2) посчитайте терминаторы — должно быть ровно два («Начало» и «Конец»); (3) проверьте, что из каждого блока есть выход (стрелка) и вход (стрелка или начало потока); (4) пройдите по стрелкам от «Начала» и убедитесь, что достигаете «Конца» из любой ветки; (5) проверьте, что ни одна фигура не используется не по назначению (например, прямоугольник вместо ромба).

Структурные элементы, которые полезно знать:

  • Соединитель (окружность) — используют, когда схема не помещается на одном листе или нужно избежать пересечения линий. Внутри пишут метку (букву или номер), одинаковую в обоих местах разрыва. Аналогия: это как ссылка «см. стр. 42» в книге — читатель знает, куда перейти, не листая всю книгу.
  • Межстраничный соединитель — пятиугольник («домик») — по смыслу похож на обычный соединитель, но связывает блоки на разных листах. Практическая подсказка: на экзамене обычно хватает обычного соединителя; «домик» рисуют только для многостраничных схем, что бывает редко.
  • Параллельные действия — жирная горизонтальная черта, из которой выходят несколько стрелок; в учебных схемах по Си встречается редко, но в стандарте описан. Где применяется: для отображения многопоточных алгоритмов, где несколько веток исполняются одновременно. В однопоточном Си это практически не используется.

Типы данных и представление в памяти

Простыми словами. Тип данных — это «формат ячейки»: он говорит, сколько байт занимает значение и как эти байты толковать. Одни и те же 4 байта могут быть целым числом int, вещественным float или четырьмя символами — смысл задаёт именно тип. Аналогия: типы — это разные коробки на складе: под гвозди — маленькая, под мебель — большая; положить данные «не в ту коробку» (например, прочитать float как int) — классический источник ошибок. Другая аналогия: тип — это система единиц измерения. Число 5 без типа — просто «пять чего?». Тип отвечает на вопрос: пять метров, пять килограмм или пять секунд. На экзамене этот вопрос проверяет понимание того, что размер типа в Си не гарантирован стандартом, а зависит от платформы и модели данных. Развёрнутое объяснение «почему размер не фиксирован»: стандарт Си намеренно оставляет точные размеры на усмотрение реализации, потому что в 1970-х годах разные процессоры имели разную «родную» разрядность. Сделать int строго 32-битным означало бы запретить компиляцию на 8-битных микроконтроллерах, где 32-битный int потребовал бы эмуляции. Стандарт задаёт лишь минимумы: short ≥ 16 бит, int ≥ 16 бит (но на практике 32), long ≥ 32 бит.

Базовые типы: char, знаковые и беззнаковые целые (short, int, long, long long), вещественные (float, double, long double), а также _Bool (bool из stdbool.h). Стандарт задаёт минимумы размеров, фактические определяет модель данных платформы: на 64-битном Linux это LP64 — long и указатель по 8 байт, int — 4. Расшифровка названий моделей данных: первая буква — размер long, вторая — размер указателя (pointer). LP64 = long 64-бит, pointer 64-бит (типичная 64-битная Unix-система: Linux, macOS). ILP32 = int/long/pointer 32-бит (32-битные системы и Windows). LLP64 = long long 64-бит, pointer 64-бит (64-битная Windows: int — 32, long — 32, long long — 64). Практическая подсказка: на экзамене, если не уверены в модели, скажите «на 64-битном Linux это LP64», и добавьте, что стандарт это не гарантирует — зависит от компилятора и платформы.

#include <stdio.h>
#include <limits.h>   // INT_MIN, INT_MAX, CHAR_BIT и другие границы целых

int main(void) {
    // sizeof возвращает size_t, поэтому печатаем через %zu, а не %d
    printf("char=%zu short=%zu int=%zu long=%zu long long=%zu\n",
           sizeof(char), sizeof(short), sizeof(int),
           sizeof(long), sizeof(long long));
    // %zu для размеров; void* печатаем как размер указателя (на Linux LP64 — 8)
    printf("float=%zu double=%zu void*=%zu\n",
           sizeof(float), sizeof(double), sizeof(void *));
    // Диапазон int берём из limits.h, а не «пишем руками»: так переносимо
    printf("INT_MIN=%d INT_MAX=%d\n", INT_MIN, INT_MAX);
    return 0;   // 0 — успешное завершение
}

Как хранятся целые. Знаковые целые хранятся в двоичном дополнительном коде: -x получается инверсией всех битов и прибавлением единицы (например, -1 в 32 битах — это 0xFFFFFFFF). Беззнаковые — обычные двоичные числа, где все биты «рабочие». Отсюда правило диапазона: знаковый n-битный тип вмещает от -2^(n-1) до 2^(n-1) - 1, а беззнаковый — от 0 до 2^n - 1. Для 32-битного int это примерно ±2.1·10⁹, для unsigned int — до 4.29·10⁹. Именно на этой границе чаще всего ловят переполнение: сумма двух больших int внезапно становится отрицательной. Аналогия для запоминания дополнительного кода: представьте одометр (счётчик пробега) автомобиля. Если он показывает 99999 и вы проехали ещё километр, стрелка «перескочит» на 00000. Дополнительный код — это математический трюк, который превращает вычитание в сложение: «вычесть 1» = «прибавить -1», а -1 в дополнительном коде выглядит как «все единицы». Процессор складывает и «автоматически» отбрасывает переполнение — получается правильный результат без отдельной схемы для вычитания.

Почему именно дополнительный код, а не прямой или обратный: дополнительный код позволяет процессору складывать и вычитать знаковые и беззнаковые числа одной и той же схемой — отдельная логика для «минуса» не нужна. Например, -1 + 1 в 32 битах: 0xFFFFFFFF + 1 = 0x10000000, после отбрасывания лишнего бита — 0x00000000, то есть ноль. Знаковое сложение работает «само собой». Если бы использовался знак-бит с прямой кодировкой, пришлось бы разбирать знаки отдельно, что дороже по транзисторам и медленнее. Историческая справка для полного балла: в ранних машинах (1950-е) использовали «знак-бит + модуль», где -1 и +1 отличались только старшим битом. Сложение требовало отдельной схемы: «если знаки одинаковые — складываем модули и ставим их знак; если разные — вычитаем модули и ставим знак большего». Дополнительный код избавил процессор от этой развилки: одно арифметическое устройство обслуживает и знаковые, и беззнаковые числа. Поэтому дополнительный код стал стандартом де-факто для всех современных процессоров — даже язык Си это учитывает (хотя формально стандарт разрешает и другие представления, но ни одна современная реализация ими не пользуется). Как хранятся вещественные. Определение для старта. Вещественное число (число с плавающей точкой, floating-point) — приближённое представление действительного числа, помещающееся в фиксированное количество битов (32 для float, 64 для double). Аналогия из жизни: это линейка с делениями — очень большие и очень маленькие числа на ней либо не помещаются, либо теряют точность. Другая бытовая аналогия: деньги в кошельке хранятся с точностью до копейки, поэтому «1/3 рубля» — это всегда округлённые 33 копейки, и при сложении трёх таких округлений никогда не получится ровно рубль. В Си используется стандарт IEEE 754: знак (1 бит), порядок (экспонента, 8 бит для float, 11 для double) и мантисса (23 / 52 бита). Почему именно IEEE 754: этот стандарт даёт одинаковые результаты на любом процессоре, который его поддерживает, и потому программа с вещественными числами ведёт себя предсказуемо при переносе между платформами. Почему именно такая разбивка битов: научная запись 1.xxx·10^k естественно ложится на двоичную систему, а раздельное хранение мантиссы и порядка позволяет охватить огромный диапазон значений при умеренной точности. Это даёт три важных следствия:

  • Вещественные числа приближённые: 0.1 + 0.2 != 0.3 — последние биты теряются при округлении. На экзамене любят вопрос «почему нельзя сравнивать float через ==». Разбор типичной ошибки: даже проверка if (a == 1.0) ненадёжна — компилятор вправе соптимизировать выражение и свернуть его в 1, что иногда «помогает», а иногда ломает логику программы.
  • Есть специальные значения: +Inf, -Inf, NaN (например, 0.0 / 0.0 даёт NaN). Любая операция с NaN тоже даёт NaN — это удобно для поиска ошибок в вещественных вычислениях: «отравив» результат в одном месте, NaN «протаскивается» дальше и сигнализирует о сбое в самом конце цепочки, что упрощает отладку «от конца к началу».
  • Диапазон float — примерно ±3.4·10³⁸ с точностью около 7 десятичных цифр; double — ±1.8·10³⁰⁸ с точностью ~15 цифр. Почему именно такой диапазон: порядок со смещением (bias) позволяет эффективно сравнивать вещественные как целые, но само смещение «съедает» половину диапазона экспоненты. Для финансовых и точных расчётов double обычно недостаточен, и используют библиотеки десятичной арифметики (например, libdecnumber или встроенные decimal типы в других языках), потому что даже 15 цифр не спасают от округлений в бухгалтерии.

Фиксированные типы (<stdint.h>). Определение для старта. Фиксированные целочисленные типы — это типы с гарантированным размером в битах (например, ровно 32 бита), в отличие от базовых int и long, чей размер зависит от платформы. Аналогия из жизни: int — это «палка с разметкой, длина которой зависит от страны», а int32_t — это «линейка, на которой гарантированно 32 сантиметра, где бы вы её ни купили». Чтобы не зависеть от платформы, в стандарте C99 ввели типы с гарантированным размером: int8_t, int16_t, int32_t, int64_t и соответствующие беззнаковые uintN_t, а также «как минимум N бит» типы int_leastN_t (минимально возможный тип, в который влезает N бит — экономит память) и «быстрые» int_fastN_t (самый быстрый тип, в который влезает N бит — на x86 компилятор обычно выбирает int или long). Для вывода используют макросы из <inttypes.h>: PRId64, PRIu32 и т. д. — они подставляют правильный модификатор длины (l, ll, h и т. д.) в строку формата. Почему это важно: наивный код printf("%lld", x) сломается на Windows, где long long обычно 8 байт, а на старых системах long мог быть 4 байта; макросы PRIdN автоматически подстраиваются под платформу. Это идиоматический способ писать переносимый код для бинарных форматов и сетевых протоколов, где размер поля фиксирован по спецификации, а не по воле компилятора. Практическая подсказка: при работе с бинарными файлами и заголовками пакетов всегда берите uint8_t, uint16_t, uint32_t — это делает размер поля очевидным при чтении кода.

Модель данных. Определение для старта. Модель данных — это соглашение компилятора и ОС о том, сколько байт занимают базовые типы C (char, short, int, long, long long, указатель). Аналогия: это «система мер», в которой работает конкретный компилятор: одна и та же величина long в разных странах может означать разные числа байт. Запомните три модели: LP64 (Linux, macOS — long и указатель по 8 байт), ILP32 (32-битные системы — всё по 4), LLP64 (Windows — long 4 байта, но указатель 8). Почему именно так: на 64-бит Linux исторически решили, что long и указатель растут вместе (чтобы сравнения указателей и long оставались простыми и не требовали приведений), а на Windows сделали long 32-битным ради совместимости со старыми DLL и API, которые рассчитывали на 4-байтный long. Практический вывод: printf("%ld", 10000000000L) на Windows и Linux напечатает разное, потому что long разной ширины. Для переносимого кода берите типы из <stdint.h> (int64_t) и форматы из <inttypes.h> (PRId64). Типичная ошибка: писать long для «большого числа» и потом удивляться, что на Windows оно не влезает или %ld ведёт себя неожиданно — лучше сразу использовать int64_t и PRId64.

Размер указателя и его связь с типом. Определение для старта. Указатель — это переменная, хранящая адрес другой переменной в памяти; тип указателя (int *, char *, void *) говорит компилятору, как интерпретировать данные по этому адресу при разыменовании *p и арифметике. Аналогия из жизни: адрес в памяти — это номер квартиры в доме, а тип указателя — это подсказка «здесь живёт int» или «здесь лежит строка». sizeof(void *) равен размеру указателя, а он равен разрядности адресации. На 32-битной системе — 4 байта, на 64-битной — 8. Почему именно так: размер адреса определяется разрядностью шины данных и регистров процессора: 32-битный регистр может хранить только 2³² разных адресов, а значит, указатель обязан уместиться в регистр. Указатель на char (char *) и указатель на int (int *) занимают одинаково, потому что указатель хранит только адрес, а не тип — тип нужен только компилятору, чтобы правильно выполнять *p и арифметику указателей (в байтах шаг = sizeof(T)). Поэтому sizeof(int *) == sizeof(char *) == sizeof(void *). Исключение: указатели на функции могут быть другого размера (например, в режиме «far» в старых архитектурах или в режиме PIC/PIE с переходниками) — на практике это значит, что сравнивать указатели на функции и на данные нельзя, и в стандарте C это явно сказано.

Порядок байтов (endianness). Определение для старта. Порядок байтов (endianness) — это способ, которым многобайтовое число записывается в память: в каком порядке идут байты — от младшего к старшему или наоборот. Аналогия из жизни: представьте, что вы записываете число «двенадцать тысяч триста сорок пять» как «12345». Один человек запишет слева направо «1-2-3-4-5» (big-endian, «как читаем»), а другой — справа налево «5-4-3-2-1» (little-endian, «единицы сначала»). Многобайтовые числа хранят двумя способами: little-endian (младший байт по младшему адресу; x86, ARM) и big-endian (старший байт первый; сетевые протоколы, часть RISC). Почему это важно: одна и та же последовательность байтов на разных платформах интерпретируется как разные числа, поэтому при чтении бинарных файлов и сетевых пакетов нужно либо фиксировать порядок, либо преобразовывать его. Это важно при чтении бинарных файлов и сетевых пакетов: одна и та же последовательность байтов на разных платформах даёт разные числа. На экзамене любят вопрос «что покажет этот код на little-endian?» — самый надёжный способ проверить самостоятельно через union { int i; unsigned char b[4]; } (все члены union делят одну и ту же память, поэтому изменение через i видно через b).

Пример типового вопроса: «дан массив байт {0x78, 0x56, 0x34, 0x12} на машине с little-endian. Что в нём хранится в виде 32-битного int?». Ответ: 0x12345678, потому что младший байт (0x78) лежит по младшему адресу. На big-endian тот же массив прочитал бы как 0x78563412. Поэтому сетевые протоколы (TCP/IP) исторически используют big-endian и называют его «network byte order»: чтобы разные машины могли одинаково интерпретировать заголовки пакетов, отправитель переводит свой порядок в big-endian функциями htonl / htons, а получатель переводит обратно через ntohl / ntohs. Практическая подсказка: при записи собственного бинарного формата всегда фиксируйте endianness в спецификации и используйте htonl/ntohl или ручную пересборку байт через сдвиги — это страхует от переноса кода на другую архитектуру.

Целочисленная арифметика, знаковость и переполнение

Простыми словами. У целых чисел есть два «режима»: знаковый (можно отрицательные) и беззнаковый (только 0 и положительные, но вдвое больший максимум). Си автоматически переводит один режим в другой при сравнении и арифметике — и именно здесь прячутся самые коварные ошибки. Аналогия: беззнаковое число — это счётчик оборотов спидометра, который после максимума снова показывает ноль; знаковое — термометр, который умеет уходить в минус. Другая аналогия: знаковое число — это банковская карта с овердрафтом, которая может уйти в минус, а беззнаковое — копилка, которая может быть только полной или пустой; при попытке забрать больше она показывает ноль. Главное правило: переполнение знакового — это UB (что угодно), а переполнение беззнакового строго определено (заворот). Почему так: для беззнакового заворот — естественное продолжение модульной арифметики по модулю 2^N, и любая реализация его гарантирует. Для знакового стандарт оставляет поведение неопределённым, потому что исторически разные процессоры вели себя по-разному (x86 заворачивал, MIPS ловил исключение), и закреплять что-то одно мешало бы оптимизациям компилятора.

При смешении знакового и беззнакового операндов знаковый приводится к беззнаковому: -1 превращается в UINT_MAX, и сравнение -1 < 1u даёт ложь.

#include <stdio.h>
#include <limits.h>

int main(void) {
    unsigned u = 1;
    // -1 имеет тип int, но при сравнении с unsigned превращается в UINT_MAX,
    // а UINT_MAX < 1 ложно — поэтому сработает ветка else, а не «знаковое сравнение»
    if (-1 < u) puts("знаковое сравнение");     // НЕ напечатает!
    else        puts("оба беззнаковые");        // напечатает

    int i = INT_MAX;
    // i + 1 — переполнение знакового, UB; компилятор вправе сделать что угодно
    (void)i;                                    // подавляем -Wunused-variable
    unsigned w = UINT_MAX;
    printf("%u\n", w + 1u);                     // 0 — беззнаковое заворачивается
    return 0;
}
  • Переполнение знаковых целых — неопределённое поведение (UB), а не «заворот»: компилятор с -O2 может, например, «доказать», что x + 1 > x всегда, и выбросить проверку if (x + 1 < x). Почему это опасно на практике: компилятор реально выкидывает проверки переполнения в цикле или оптимизирует if (x + 1 < x) handle_overflow(); так, что ветка обработки никогда не вызывается — и баг проявляется только при смене версии компилятора. Как защищаться: беззнаковая арифметика, __builtin_add_overflow из GCC/Clang, переход на безопасные библиотеки вроде SafeInt или переписывание алгоритма через «предсказываемое» переполнение с проверкой до операции.
  • Переполнение беззнаковых определено стандартом: результат берётся по модулю 2^N, то есть UINT_MAX + 1u == 0u. Это активно используется в хеш-функциях и кольцевых буферах. Кольцевой буфер с индексами (i + 1) % size без знаковых типов тоже можно реализовать через (i + 1) & (size - 1), если size — степень двойки (побитовое И быстрее деления по модулю и не зависит от «медленного» деления).
  • Деление на ноль и INT_MIN / -1 — UB: второе выражение математически даёт 2^(n-1), что не влезает в знаковый тип. Почему: деление на ноль знаковых — UB, потому что результата нет в принципе, а процессор вызывает аппаратное исключение; для беззнаковых — формально тоже UB, хотя на практике часто даёт исключение или мусор. Защищаться нужно явной проверкой if (b != 0) перед делением.
  • Остаток в C определён так, что знак результата совпадает со знаком делимого: -7 % 2 == -1, а не 1. Для неотрицательного остатка применяют ((a % b) + b) % b. Почему так: в Си используется правило «усечение к нулю» (как в C++, Java, C#), а не «усечение к минус бесконечности» (как в Python и в математическом определении). Это часто путают с Python, где -7 % 2 == 1; запомните: «в Си знак идёт от делимого».
  • Целочисленные повышения: char и short перед арифметикой повышаются до int, поэтому unsigned char c = 255; c + 1 даёт 256 (в int), а не 0. Это ещё одна ловушка, которую спрашивают вместе с переполнением. Почему так: это наследие ранних процессоров, у которых арифметика над байтами была неэффективной — компилятор всегда расширяет до int, чтобы можно было использовать полный набор команд. Похожее правило действует для обычных int в выражениях с long: если хоть один операнд long, второй тоже повышается до long, поэтому int x = 1; long y = x + 1L; — это уже long. Общее правило: тип результата — «самый широкий» из операндов, при равной ширине знаковый «проигрывает» беззнаковому (см. иерархию ниже).

Иерархия неявных преобразований (usual arithmetic conversions). Когда в одном выражении встречаются разные целые типы, компилятор по стандарту C выполняет преобразования в таком порядке:

  1. Если один из операндов имеет ранг выше (long long > long > int), другой приводится к нему. Почему: цель — не потерять значимые биты более широкого типа.
  2. Если ранги равны, но один знаковый, а другой беззнаковый того же размера — оба приводятся к беззнаковому. Почему: в беззнаковом типе того же размера гарантированно помещаются все значения знакового (плюс ещё столько же положительных), а в знаковый положительные «сверху» уже не влезут.
  3. Если знаковый тип может представить все значения беззнакового — знаковый «побеждает», и беззнаковый приводится к нему (так unsigned int приводится к long, если на платформе long шире int). Почему: так сохраняется знаковость и не происходит «отравления» отрицательными значениями.
  4. Иначе оба приводятся к беззнаковому того же размера, что и знаковый. Почему: это единственный безопасный вариант, когда оба типа одного размера, а беззнаковый шире по диапазону, чем знаковый.

Это правило часто объясняет, почему sizeof(int) == sizeof(long) (на 32-бит) приводит к UB в выражении вроде (unsigned int)(-1), а на 64-бит (где long шире) — нет: на 64-бит беззнаковое -1 безопасно превращается в знаковое long, потому что long шире. Практический совет: если пишете код, который должен работать на обеих моделях данных, избегайте сравнений unsigned и signed одного размера — лучше сразу приведите к int64_t/uint64_t.

Практические советы по защите от ошибок знаковости:

  • Включайте предупреждения: -Wall -Wextra -Wsign-compare -Wconversion. GCC и Clang умеют находить большинство сравнений знаковых и беззнаковых и подсвечивать потенциально опасные приведения типов. Подсказка: -Wconversion особенно полезен — он ругается на неявные сужающие приведения и потерю знака.
  • Используйте unsigned только для битовых операций и счётчиков, где отрицательные значения не имеют смысла (например, размер массива, количество байт, маски). Для индексов массива и количества элементов size_t — идиоматический выбор: он беззнаковый, его размер равен размеру указателя, и он подставляется в %zu.
  • Не смешивайте signed и unsigned в одном выражении: это почти всегда сигнал об ошибке проектирования. Почему: смешение «молча» меняет тип операнда и превращает -1 < 1u в ложь, что ломает интуитивную логику программы. Если приходится смешивать (например, API требует int, а вычисления идут в size_t), делайте явное приведение с комментарием «почему здесь безопасно» — это страхует и вас, и ревьюера.
  • Проверяйте результат getchar() через int, а не через char: char не различает EOF и символ 0xFF. Классическая ловушка: функция возвращает int именно для того, чтобы вместить и EOF, и любой байт.

Вещественные числа, IEEE 754 и машинный эпсилон

Простыми словами.

Определения для старта.

  • Вещественное число (число с плавающей точкой, floating-point) — приближённое представление действительного числа в памяти компьютера; всегда хранится с округлением, потому что битов конечное число. Аналогия: линейка с делениями через равные промежутки — очень маленькое и очень большое значение на ней либо не помещается, либо теряет точность.
  • Мантисса (significand, fraction) — значащие цифры числа, то есть то, что в десятичной записи стоит до «× 10^k» (в IEEE 754 хранится в нормализованном виде как 1.xxxx…). Аналогия: это «полезная нагрузка» числа — именно она определяет, насколько точно мы знаем значение.
  • Порядок (экспонента, exponent) — степень, на которую сдвигается «запятая» в мантиссе; в IEEE 754 хранится со смещением (bias), чтобы можно было сравнивать порядки как обычные целые. Аналогия: это «разряд» линейки — миллиметры, сантиметры или метры.
  • IEEE 754 — международный стандарт, описывающий, как именно биты кодируют знак, порядок и мантиссу для float, double и long double. Именно поэтому результат вычислений одинаков на любом процессоре, который его поддерживает. Почему это важно: без единого стандарта одна и та же программа на x86 и на PowerPC давала бы разные ответы — а это катастрофа для переносимости научных и инженерных расчётов.

Вещественное число в памяти — это «научная запись с фиксированным числом значащих цифр», как 1.23·10^5. Часть битов хранит значащие цифры (мантисса), часть — порядок (насколько сдвинуть запятую), один бит — знак. Почему именно так: научная запись естественно ложится на двоичную систему, а раздельное хранение мантиссы и порядка позволяет охватить огромный диапазон значений при умеренной точности. Беда в том, что привычная десятичная дробь 0.1 в двоичной системе бесконечна, поэтому хранится с округлением — отсюда знаменитое 0.1 + 0.2 != 0.3. Аналогия: вещественный тип — это линейка с ограниченным числом делений; очень маленькое и очень большое число на ней либо не помещается, либо теряет точность. Другая бытовая аналогия: деньги в кошельке можно хранить только с точностью до копейки, поэтому «1/3 рубля» — это всегда округлённые 33 копейки, и при сложении трёх таких округлений никогда не получится ровно рубль.

Почему 0.1 + 0.2 != 0.3? В двоичной системе 0.1 записывается как бесконечная периодическая дробь 0.0001100110011…. 52 бита мантиссы double обрезают её до 52 значащих двоичных цифр, и реально 0.1 хранится как 0.10000000000000000555…. Складывая два таких округлённых числа, получаем 0.30000000000000004…, что не совпадает с округлённым 0.3 (0.29999999999999999…). Почему это не баг: это неизбежное следствие конечности битового представления — ни один конечный набор битов не может точно закодировать бесконечную дробь, как ни одно конечное число «33 копеек» не равно в точности трети рубля.

По стандарту IEEE 754 число хранится как знак, порядок со смещением и мантисса: float — 1 + 8 + 23 бита (смещение 127), double — 1 + 11 + 52 бита (смещение 1023). Отсюда «странности» вида 0.1 + 0.2 != 0.3. Дополнительно: в нормализованных числах старший бит мантиссы всегда равен единице, поэтому он явно не хранится — это экономит один бит точности (фактически 24 и 53 бита). Почему так: нормализация гарантирует единственное представление каждого числа и позволяет всегда иметь «единицу» перед точкой. Существуют ещё денормализованные (subnormal) числа — очень маленькие, у которых старший бит равен нулю: они дают плавный переход к нулю, но работают медленнее и с пониженной точностью. Зачем они нужны: без subnormal был бы разрыв между минимальным нормализованным числом и нулём — и a - a могло бы давать ненулевой результат.

#include <stdio.h>
#include <float.h>   // FLT_EPSILON, DBL_EPSILON, DBL_DIG, FLT_MAX
#include <math.h>    // fabs; при сборке не забудьте ключ -lm

int main(void) {
    double a = 0.1 + 0.2, b = 0.3;
    // Печатаем 17 знаков после точки: видно, что a не равно b ни на бит
    printf("%.17f %.17f равны? %d\n", a, b, a == b);   // 0 — не равны
    // Правильная проверка — не ==, а сравнение модуля разности с эпсилоном
    printf("fabs(a-b) = %.1e < DBL_EPSILON? %d\n",
           fabs(a - b), fabs(a - b) < DBL_EPSILON);
    // Эпсилон показывает относительную точность: у float хуже, чем у double
    printf("FLT_EPSILON=%.3e DBL_EPSILON=%.3e\n", FLT_EPSILON, DBL_EPSILON);
    // DIG — сколько десятичных цифр гарантированно точны
    printf("DIG=%d DBL_DIG=%d FLT_MAX=%.3e\n", FLT_DIG, DBL_DIG, FLT_MAX);
    return 0;
}
  • Машинный эпсилон — минимальное ε, при котором 1.0 + ε != 1.0 (FLT_EPSILON ≈ 1.19·10⁻⁷, DBL_EPSILON ≈ 2.22·10⁻¹⁶). Это мера относительной точности: у double она примерно на 9 десятичных цифр лучше, поэтому в расчётах по умолчанию берут double, а float — для экономии памяти (например, больших массивов графики или GPU-буферов). Почему именно такое определение: эпсилон показывает, насколько относительное изменение числа перестаёт менять его «битовое представление». Теоретически DBL_EPSILON ≈ 2⁻⁵² — предел относительной погрешности округления, а DBL_DIG ≈ 15 — гарантированное число точных десятичных цифр, которые можно хранить и читать без потерь.
  • Особые значения: ±0, ±∞ (INFINITY), NaN. Свойства: NaN != NaN (проверять только через isnan(), потому что сравнение всегда ложно — это сделано специально, чтобы NaN не «равнялся» ничему разумному), бесконечность получается делением ненулевого на ноль, а 0.0/0.0 даёт NaN. Ловушка: NaN «заразен» — любая арифметическая операция с NaN даёт NaN, поэтому сбой в одном месте отравляет весь дальнейший расчёт, и отлаживать его приходится от конца к началу, печатая промежуточные значения и проверяя их isnan().
  • Вещественные нельзя сравнивать на точное равенство — только с допуском: fabs(a - b) < 1e-9. На экзамене уточните, что абсолютный допуск годится для чисел порядка единицы, а для больших значений берут относительный: fabs(a - b) < 1e-9 * fabs(b) (или fmax(fabs(b), ε) для защиты от нуля). Типичная ошибка: писать fabs(a - b) < 1e-9 при a ≈ 1e15 — для таких чисел относительная погрешность уже огромна (около 1), и условие будет ложным, даже когда числа «одинаковы» по физическому смыслу.
  • Катастрофическая потеря точности: вычитание близких чисел (например, 1.0000001 - 1.0) обнуляет значащие разряды, а суммирование большого ряда маленьких чисел накапливает ошибку — на практике числа сортируют по модулю или применяют компенсационное суммирование Кахана. Почему так: при вычитании близких чисел старшие разряды мантиссы взаимно уничтожаются, и в результате достоверными остаются только младшие разряды, где как раз живёт ошибка округления — фактически мы «выбрасываем» точные биты и оставляем шум.
  • Формат вывода: в printf float повышается до double (потому что float всегда приводится к double через variadic-аргументы), поэтому и %f, и %lf печатают double; %Lf — только для long double. В scanf это правило не действует: там float требует строго %f, а double — %lf, и перепутать их означает записать в память мусор (потому что scanf пишет в переменную и должен знать её реальный размер). Экзаменационный тезис: правило «в printf %f и %lf взаимозаменяемы, в scanf — нет» любят давать как задачу со звёздочкой.

Типичные ошибки.

  • Сравнивать double через == — нельзя никогда (даже для значений вроде 1.0, потому что компилятор вправе соптимизировать выражение и свернуть его в 1, что иногда «помогает», а иногда ломает логику). Почему: стандарт IEEE 754 разрешает компилятору любое «безопасное» преобразование, и x == 1.0 после подстановки констант превращается в true, даже если x получился из вычислений с округлением.
  • Использовать «магическое» 1e-9 как универсальный допуск — для очень больших и очень маленьких чисел оно не работает, берите относительный допуск eps * fmax(fabs(a), fabs(b)) или DBL_EPSILON * scale.
  • Забывать -lm при использовании fabs, sqrt, sin — линкер выдаст undefined reference to fabs, потому что математические функции лежат в отдельной библиотеке libm, которая не подключается автоматически.
  • Считать FLT_EPSILON «самым маленьким представимым числом» — нет, это относительная погрешность; минимальное положительное нормализованное число — FLT_MIN (≈ 1.17·10⁻³⁸), а субнормальные идут ещё ниже, вплоть до ~1.4·10⁻⁴⁵.
  • Проверять NaN через x == NaN или x != x — стандарт гарантирует только isnan(x); хотя x != x на практике работает на всех распространённых платформах, это не переносимо и не идиоматично.
  • Складывать double и int без приведения — на самом деле это безопасно (int повышается до double), но обратное (double)x к int обрезает дробную часть и может дать переполнение — это UB.

Экзаменационные тезисы.

  1. Основной вещественный тип — double; float используется только ради экономии памяти и совместимости с GPU/графическими API.
  2. IEEE 754 описывает «знак + порядок со смещением + мантисса»; для float это 1+8+23, для double 1+11+52. Старший бит мантиссы в нормализованных числах не хранится (неявная единица).
  3. Машинный эпсилон — мера относительной точности: DBL_EPSILON ≈ 2.22·10⁻¹⁶, FLT_EPSILON ≈ 1.19·10⁻⁷. DBL_DIG ≈ 15 — гарантированное число точных десятичных цифр.
  4. Сравнение == для вещественных запрещено; используется допуск — абсолютный (fabs(a-b) < eps) или относительный (fabs(a-b) < eps * fmax(fabs(a), fabs(b))).
  5. Особые значения: ±0, ±∞, NaN; NaN != NaN, проверяется только isnan(). NaN «заразен» — любая операция с ним даёт NaN.
  6. В printf %f и %lf эквивалентны (обе печатают double), в scanf — нет (требуется строгое соответствие типу); для long double используется %Lf.
  7. Катастрофическая потеря точности возникает при вычитании близких чисел и при суммировании ряда с большой разницей масштабов — применяют суммирование Кахана или сортировку слагаемых.

Символы и таблица ASCII

Простыми словами. Компьютер «не знает» букв — он умеет хранить только целые числа. Договорились, что числу 65 соответствует буква 'A', числу 48 — символ '0', и так далее. Эта договорённость называется кодировкой, а конкретная историческая таблица для английского алфавита и цифр — ASCII. Понимание этой договорённости — ключ к половине строковых задач на экзамене.

Определения для старта.

  • char — целочисленный тип размером ровно 1 байт (обычно 8 бит), в котором хранится код символа по некоторой кодировке. По сути это просто маленькое целое число, которое «по традиции» называют символом.
  • ASCII — таблица кодировки, в которой символам английского алфавита, цифрам и управляющим кодам сопоставлены числа от 0 до 127. Это 7-битная таблица, она помещается ровно в один байт (старший бит всегда 0), поэтому ASCII-коды никогда не бывают отрицательными.
  • Юникод (Unicode) — современная кодировка, охватывающая символы почти всех письменностей мира (более 140 тысяч символов на 2024 год); кодовая точка Unicode — это просто число от 0 до огромного значения, а как именно оно записывается в память, определяется формой кодирования (UTF-8, UTF-16, UTF-32).
  • UTF-8 — самый популярный «переносимый» вариант Unicode, где символы могут занимать от 1 до 4 байт в зависимости от номера: ASCII-символы — 1 байт, кириллица — 2 байта, многие азиатские иероглифы — 3 байта, редкие символы и эмодзи — 4 байта.
  • CP1251 — устаревшая однобайтовая кодировка для русского языка в Windows; в ней русские буквы лежат «не подряд» — отсюда многие старые баги и несовместимости, когда программа, написанная под CP1251, встречает UTF-8.

char — это на самом деле целое число размером 1 байт, которое договорились считать символом по таблице кодировки ASCII. Отсюда приём, который постоянно нужен на контрольных: символ цифры '7' и число 7 различаются на код нуля, поэтому '7' - '0' == 7. Аналогия: символ — это номер ячейки в таблице, а «превратить символ в цифру» — значит вычесть номер первой ячейки. Другая бытовая аналогия: код символа — это «адрес» в типографии: 'A' живёт по адресу 65, 'a' — по адресу 97, и разница всегда постоянна, потому что таблица устроена иерархически. Третья аналогия: представьте себе многоэтажный дом, где на каждом этаже живут «похожие жильцы» — на этаже 65–90 заглавные латинские буквы, на этаже 97–122 строчные, на этаже 48–57 цифры. Этажи не перепутаны, поэтому если вы знаете номер этажа, вы точно знаете, к какому семейству относится жилец.

char — целый тип размером 1 байт, хранящий код символа. Кодировка ASCII: 0–31 — управляющие (табуляция, перевод строки, звуковой сигнал и т. п.; их редко используют напрямую), 32 — пробел, 48–57 — цифры '0'..'9', 65–90 — 'A'..'Z', 97–122 — 'a'..'z', 127 — DEL. Мнемоника кодов: '0' — 48, 'A' — 65, 'a' — 97; разница между заглавной и строчной латинской буквой всегда 32 ('a' - 'A' == 32), а маленькие и большие буквы каждой группы идут подряд. Почему именно такие числа? ASCII разрабатывался так, чтобы коды букв и цифр были удобны поразрядно: смена регистра меняет ровно один бит (0x20, то есть шестой бит), а перевод цифры в число сводится к вычитанию одного и того же смещения '0'. Поэтому «табличные» операции делаются одной арифметической инструкцией — это было важно в 1960-х, когда процессоры были медленными, и сегодня остаётся важным «по привычке» и по совместимости с огромным массивом существующего кода. Дополнительная деталь: ASCII-коды в двоичном виде красиво ложатся на границы полубайт — '0' = 0011 0000, 'A' = 0100 0001, 'a' = 0110 0001, — что упрощает побитовые проверки вида «это цифра?» через маску 0xF0 == 0x30.

#include <stdio.h>
#include <ctype.h>   // toupper, tolower, isdigit, isalpha

int main(void) {
    char c = '7';
    int digit = c - '0';                 // 7 — перевод символа в число (вычитаем код '0')
    char upper = (char)toupper('a');     // 'A' — приведение нужно: toupper возвращает int
    // 'A' + 25 — это 'Z'; печатаем символ и его код
    printf("%d %c %c код=%d\n", digit, upper, (char)('A' + 25), (int)'A');
    return 0;
}

Полезно помнить: 'a' - 'A' == 32, цифры и латинские буквы идут подряд. Это позволяет без таблицы проверять регистр: if (c >= 'A' && c <= 'Z') c += 32;. Русские буквы в однобайтовой кодировке (CP1251) подряд не идут, а в UTF-8 занимают 2 байта — strlen вернёт длину в байтах, не в символах. Поэтому «посчитать русские буквы» через isalpha из стандартной библиотеки не получится: понадобится либо явная таблица CP1251, либо разбор UTF-8 по старшим битам. Техническая деталь: UTF-8-код русской буквы начинается с битов 110xxxxx (первый байт) и 10xxxxxx (второй), поэтому продвинутая проверка идёт побитово: (c & 0xC0) == 0xC0 означает «это начало многобайтового символа», а (c & 0x80) == 0 — «ASCII-символ». Полная картина для UTF-8: 0xxxxxxx — 1 байт (ASCII), 110xxxxx 10xxxxxx — 2 байта, 1110xxxx 10xxxxxx 10xxxxxx — 3 байта, 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx — 4 байта. Ещё одна ловушка: char может быть знаковым, и для байтов с кодом больше 127 (русские буквы) его нужно приводить к unsigned char, иначе toupper/isdigit получат отрицательное значение — формально UB. Почему так: функции <ctype.h> принимают int, но требуют, чтобы значение было либо EOF (обычно -1), либо представимо как unsigned char. Любой «отрицательный» char (например, русская буква в CP1251) формально нарушает контракт и приводит к неопределённому поведению — на практике чаще всего к «не тем» ответам (вроде «все кириллические буквы — это управляющие символы»). Практический приём: всегда оборачивайте вызовы вроде toupper((unsigned char)c) — это и безопаснее, и переносимее.

Типичные ошибки.

  • Сравнивать '7' == 7 — никогда не сработает: это код символа (55) и число (7). Числа и символы — разные сущности; даже если их значения иногда совпадают (например, символ с кодом 7 — это '\a', а не цифра 7).
  • Писать if (c >= 'A' && c <= 'Z') без приведения к unsigned char — для русских букв «зацепит» отрицательные значения, и условие может сработать «не там». Безопаснее: if ((unsigned char)c >= 'A' && (unsigned char)c <= 'Z').
  • Считать, что strlen вернёт «число символов» — для UTF-8 вернётся число байт; например, длина строки «привет» — 12, а не 6. То же самое верно для sizeof — он меряет байты, а не «элементы», которые вы имели в виду.
  • Применять toupper/tolower к русским буквам — стандартная библиотека работает только с ASCII-буквами; кириллица не изменится (или, что хуже, «превратится» во что-то по локали, если вы забыли setlocale(LC_ALL, "")).
  • Сравнивать char с int без приведения — на некоторых платформах char знаковый, и русская буква в CP1251 даст отрицательное число, которое при сравнении поведёт себя не так, как вы ожидаете.
  • Забыть, что '0' + i — это код цифры, а не само число; путаница между «символом-цифрой» и «числом-цифрой» — классика экзамена.

Зачем это на экзамене. Задачи «перевести строку в верхний регистр», «посчитать цифры в тексте», «проверить, что символ — буква» встречаются почти всегда. Умение работать с кодами символов напрямую (без toupper) ценят на МИФИ, где библиотеку ctype.h могут запретить. Приём для экзамена: запомните три ключевые константы — '0' == 48, 'A' == 65, 'a' == 97 — и умейте за минуту восстановить по ним всю таблицу, а также вывести формулу перевода цифры в число (c - '0') и смены регистра (c + 32 или c - 32). Дополнительная подсказка: '0' = 0x30, 'A' = 0x41, 'a' = 0x61 — кому-то легче помнить в шестнадцатеричной. И ещё: '9' - '0' == 9, поэтому формула c - '0' работает для всех цифр без исключений. На МФТИ любят задачи на побитовые операции с символами — например, проверить, чётная ли цифра, можно через (c & 1) == 0.

Классы памяти, области видимости и время жизни

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

Определения для старта.

  • Класс памяти (storage class) — категория переменной, описывающая, где она лежит в памяти и сколько живёт. В Си это auto, register, static, extern (плюс _Thread_local, но в учебных задачах это редкость).
  • Область видимости (scope) — фрагмент исходного текста программы, где это имя доступно. Определяется тем, где в тексте стоит объявление (внутри блока, в файле, в параметрах функции).
  • Время жизни (storage duration) — период выполнения программы, в течение которого под переменную реально занята память. Определяется тем, когда создаётся и уничтожается объект (на входе в блок, на старте программы, при malloc/free).
  • Линковка (linkage) — возможность связать одно и то же имя в разных единицах трансляции (файлах); бывает внешняя (видна линкеру и доступна из других .c), внутренняя (видно только в своём .c) и «никакая» (только внутри своего блока).

Класс памяти отвечает на два разных вопроса: где переменная лежит (стек или статическая область) и сколько живёт (до конца блока или до конца программы). Не путайте с типом: тип говорит, что за значение, а класс памяти — где и как долго. Аналогия: область видимости — это «из каких комнат видно эту вещь», а время жизни — «сколько эта вещь вообще существует»; лампочка в коридоре видна из коридора (видимость), но горит, пока не выключат (время жизни). Другая бытовая аналогия: класс памяти — это «прописка» переменной: auto живёт в гостинице до выезда из { }, а static прописана в квартире навечно до конца программы. Третья аналогия: представьте себе многоквартирный дом-программу. auto-переменные — это съёмщики: приехали (вошли в блок), пожили, уехали (вышли из блока). static — это собственники квартир: въехали один раз при заселении дома (запуск программы) и живут до его сноса (завершение программы). А extern — это «прописанный у родственника в другом городе»: его нет в вашем файле, но вы знаете его адрес и можете позвонить.

Класс (спецификатор) Где размещается Время жизни Область видимости
auto Стек До выхода из блока Блок
register Регистр CPU (подсказка) До выхода из блока Блок
static (локальная) Статическая область Вся программа Блок
static (глобальная/функция) Статическая область Вся программа Файл (внутренняя линковка)
extern Статическая область Вся программа Вся программа (внешняя линковка)

Что важно из таблицы. Ключевое слово static перегружено: у локальной переменной оно продлевает жизнь, а у глобальной — наоборот, сужает видимость до одного файла. По умолчанию локальные переменные — auto (на стеке), а глобальные функции и переменные — extern (видны всем, кто подключил объявление). Слово register сегодня почти не влияет на код — компилятор сам решает, что класть в регистр, — но на экзамене его спрашивают как «подсказку, которую оптимизатор вправе игнорировать» и напоминают, что адрес у register-переменной брать нельзя. Почему static имеет два смысла? Исторически слово означает «объект с фиксированным положением в памяти»: для локальной переменной это «не на стеке, живи постоянно», а для глобальной — «спрячь от линкера, не экспортируй наружу». Общее в обоих случаях только то, что объект кладётся в статическую область данных (сегмент .data для инициализированных, сегмент .bss для нулевых). Конкретный сегмент определяется наличием инициализатора: static int x = 0; — в .bss (даже если инициализатор ноль!), а static int x = 5; — в .data. Компилятор сам выбирает, и с точки зрения языка разницы нет.

#include <stdio.h>

static int counter = 0;      // глобальная static: видна только в этом .c (внутренняя линковка)

int next(void) {
    static int calls = 0;    // локальная static: инициализируется ОДИН раз, живёт всю программу
    counter++;               // поэтому значение calls сохраняется между вызовами
    return ++calls;          // 1, затем 2, затем 3
}

int main(void) {
    // ВНИМАНИЕ: порядок вычисления аргументов printf не определён стандартом,
    // поэтому не полагайтесь на него: счётчики могут напечататься в любом порядке
    printf("%d %d %d counter=%d\n", next(), next(), next(), counter);
    return 0;   // параметры printf вычисляются в неопределённом порядке
}
  • Область видимости — где доступно имя; время жизни — пока занята память. Это разные понятия, и их путают чаще всего: указатель на локальную переменную «виден», но после выхода из функции указывает на уже освобождённую память. Аналогия: адрес бывшего жильца — бумажка остаётся у вас (видимость имени), но за дверью уже никто не живёт (память освобождена). Экзаменационный вопрос-ловушка: «адрес какой переменной можно безопасно вернуть из функции?» — только статической или глобальной. Возврат адреса локальной переменной — это «классический» пример неопределённого поведения: формально код компилируется, но при попытке использовать указатель может произойти что угодно — от тихой ошибки до повреждения стека.
  • Локальная static инициализируется нулём и не исчезает при выходе из функции: это законный способ хранить состояние между вызовами (счётчики, кеши, «одноразовая» инициализация). Тонкость: инициализатор у static вычисляется один раз, поэтому static int x = f(); гарантирует, что f будет вызвана ровно один раз — этим пользуются для отложенной инициализации и реализации «потокобезопасного» синглтона в однопоточной программе. Это безопаснее, чем «голый» if (first) { first = 0; ... }, потому что инициализация атомарна с точки зрения наблюдателя.
  • Глобальная static даёт внутреннюю линковку — символ не виден линкеру из других единиц трансляции. Это инструмент сокрытия: так прячут вспомогательные функции, чтобы не конфликтовать именами в большом проекте. Зачем это вообще нужно? Без static имя попадает в таблицу символов и может случайно совпасть с таким же именем в другой библиотеке, что приведёт к ошибке линковки multiple definition; static говорит линкеру «не экспортируй это имя наружу», и конфликт невозможен. Именно поэтому во всех современных гайдлайнах рекомендуют объявлять «приватные» функции и переменные файла как static.
  • extern — это объявление, а не определение: оно говорит «такая переменная есть где-то в другом файле». Классическая ошибка — написать extern int x = 5;; так делать можно, но обычно extern применяют без инициализации, а определение дают ровно в одном .c. Аналогия: extern — это «объявление гостя, что у хозяина дома есть сахар»: сахар лежит у хозяина, гости знают, что можно взять, но сам сахар не считают своим. Заголовочный файл с extern int x; — это «визитная карточка» гостя: имя известно всем, кому показали визитку, но сам объект по-прежнему живёт в одном-единственном месте.
  • Статические объекты попадают в сегмент данных (инициализированные) или bss (нулевые), а не на стек, поэтому переполнить ими стек нельзя — но и освободить их вручную тоже нельзя. Где это видно: размер сегментов показывает утилита size(1); туда попадают глобальные и статические переменные, которым компилятор не дал начального значения. Это же объясняет, почему программа с огромным static char buf[1<<30] не запустится — лимит на размер .bss обычно ниже, чем на стек.

Типичные ошибки.

  • Возвращать из функции адрес локальной переменной — классический пример «висячего указателя» (dangling pointer): указатель формально валидный, но разыменование даёт мусор или мусор в квадрате. Безопасный аналог — вернуть malloc-указатель или адрес static-переменной.
  • Писать static int x; в заголовочном файле — каждая единица трансляции получит свою копию x, изменения в одном файле не будут видны в другом, что приводит к трудноуловимым ошибкам «почему счётчик не растёт». В заголовочном файле можно объявлять extern int x;, но определять через static — категорически нельзя.
  • Путать extern и static: static прячет символ от других файлов, extern объявляет его в других файлах — это противоположные действия. Мнемоника: extern → экспорт наружу, static → закрыт внутри файла.
  • Считать, что локальная static инициализируется при каждом вызове функции — нет, только один раз; последующие вызовы «пропускают» инициализатор. Если вам нужно «сбрасывать» значение на каждом входе — используйте обычную auto-переменную.
  • Забыть, что register запрещает взятие адреса — компилятор выдаст ошибку address of register variable requested. Это историческое ограничение, до сих пор соблюдаемое стандартом.
  • Думать, что глобальная переменная «живёт с момента объявления» — на самом деле она живёт с момента запуска программы, даже если объявление стоит в середине файла; компилятор всё равно разместит её в начале сегмента.

Экзаменационные тезисы.

  1. Локальные переменные по умолчанию имеют класс auto: лежат на стеке и живут до конца блока. Слово auto можно писать, но это избыточно — компилятор и так считает все локальные auto.
  2. static у локальной переменной продлевает жизнь до конца программы, у глобальной — сужает область видимости до одного файла. Два эффекта, одно ключевое слово — главный источник путаницы.
  3. extern — это объявление без выделения памяти; определение (с инициализацией) должно быть ровно одно на всю программу. Иначе — multiple definition на этапе линковки.
  4. register — подсказка компилятору; адрес такой переменной брать нельзя, в реальности компилятор часто игнорирует подсказку (всё равно раскладывает по регистрам то, что считает нужным).
  5. Область видимости и время жизни — разные понятия: первое про текст программы, второе про время её выполнения. Классический пример расхождения — указатель на локальную переменную: имя в тексте есть, а памяти уже нет.

Препроцессор

Простыми словами. Препроцессор — это отдельная мини-программа внутри компилятора, которая работает до самого компилятора и занимается только текстом: она ничего не знает о типах, переменных и функциях, а просто механически копирует, заменяет и вырезает куски исходного кода. Аналогия: представьте, что вы пишете письмо с сокращениями («С уважением, И.»), а перед отправкой секретарь раскрывает все сокращения в полные слова — это и есть работа препроцессора; компилятор видит уже «развёрнутое» письмо и думает, что так оно и было написано. Главное следствие: ошибки препроцессора выглядят странно (компилятор ругается на несуществующие строки и имена), потому что он видит результат подстановки, а не ваш исходный текст. Полезный инструмент отладки — флаг компилятора -E (только препроцессор) или -save-temps (сохраняет промежуточные файлы): с ними можно увидеть, во что превратился ваш код после всех #include и #define.

Препроцессор работает до компилятора и оперирует текстом: подставляет файлы, раскрывает макросы, отбирает код по условиям. Эти три действия — #include (вставить файл), #define (объявить подстановку), #if/#else/#endif (условно оставить или выбросить куски) — составляют 95% всей препроцессорной рутины.

#include <stdio.h>

// Скобки вокруг параметра И вокруг всего выражения обязательны:
// без них SQUARE(2+3) превратится в 2 + 3 * 2 + 3 = 11 вместо 25.
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define STR(x)    #x                        // строкификация: STR(abc) -> "abc"
#define CAT(a, b) a##b                      // склейка токенов: CAT(var, 1) -> var1

#if defined(__linux__)                     // условная компиляция по платформе
    #define OS "Linux"
#elif defined(_WIN32)
    #define OS "Windows"
#else
    #define OS "Unknown"
#endif

int main(void) {
    int CAT(var, 1) = 42;                   // на этапе препроцессинга станет var1 = 42
    printf("%d %d %s var1=%d\n", SQUARE(3), MAX(4, 7), OS, var1);
    return 0;
}

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

  • Директивы: #include, #define, #undef, #if/#ifdef/#ifndef/#else/#elif/#endif, #error, #pragma, #line. Все они начинаются с # в начале строки (после необязательных пробелов и табуляций) и завершаются переводом строки — поэтому длинные макросы переносят через обратный слэш \, который «склеивает» следующую строку с текущей. **Почему именно так:** препроцессор задумывался как простой текстовый процессор, и перевод строки для него — естественный разделитель команд (как точка с запятой для компилятора). Обратный слэш в конце строки буквально говорит: «следующую строку не считай новой командой, приклей её к моей». **Практический совет:** перед \ не должно быть ни пробелов, ни комментариев — иначе склейка не сработает, и компилятор выдаст непонятную ошибку далеко от места проблемы. Отладочный приём: посмотреть результат препроцессора можно флагом gcc -E file.c — на экране появится уже «развёрнутый» файл без единой директивы.

  • Двойное вычисление аргументов макроса — главная ловушка: MAX(i++, j) увеличит i лишний раз, потому что параметр a подставляется в текст дважды (и в условии, и в результате), и i++ выполнится дважды. Как это работает «под капотом»: препроцессор не понимает выражений — он просто текстово заменяет каждый формальный параметр на то, что вы передали. Поэтому MAX(i++, j) буквально превращается в ((i++) > (j) ? (i++) : (j)), и i++ встречается дважды в тексте, а значит выполнится дважды в коде. Замена — static inline-функция, у которой аргументы вычисляются ровно один раз, есть проверка типов и которую отладчик видит в стеке вызовов. Экзаменационный тезис: «макрос — это текстовая подстановка, функция — это вызов с семантикой». Лайфхак: если очень хочется макрос без двойного вычисления, используют GCC-расширение __typeof__ или стандартный typeof (C23) вместе с временной переменной через выражение-запятую — но это уже признак того, что проще написать функцию.

  • #include <...> ищет в системных каталогах (пути задаются компилятором и переменными среды вроде CPATH, C_INCLUDE_PATH), #include "..." — сначала рядом с текущим файлом, а потом уже в системных. Поэтому свои заголовки пишут в кавычках, а стандартные — в угловых скобках. Смешение стилей не ошибка, но нарушает договорённость и сбивает читателя. Аналогия: <...> — это «принеси из библиотеки» (путь известен системе), а "..." — это «возьми со стола рядом, а если нет — иди в библиотеку». Типичная ошибка новичка: #include "stdio.h" вместо #include <stdio.h> формально работает, но замедляет сборку на больших проектах — компилятор каждый раз проверяет текущий каталог, и только потом лезет в системные пути.

  • Include guard (#ifndef NAME_H / #define NAME_H / #endif) защищает от повторного подключения и повторных определений при множественном #include; #pragma once делает то же самое короче, но не входит в стандарт C (хотя поддерживается GCC, Clang и MSVC). На экзамене безопаснее использовать guard — он переносим и стандартен. Почему guard работает: при первом включении файла макрос NAME_H ещё не определён, поэтому #ifndef пропускает содержимое, а #define его создаёт; при повторной попытке макрос уже есть, и #ifndef NAME_H сразу даёт false — всё содержимое файла пропускается целиком. Практический совет: имя guard-а должно совпадать с именем файла, быть в верхнем регистре и заканчиваться на _H (например, vector.h → VECTOR_H), иначе можно случайно пересечься с чужим макросом. Редкая ловушка: guard не спасает, если в двух разных заголовках случайно выбрано одно и то же имя — компилятор не выдаст ошибку, но один из заголовков «молча» окажется пустым.

  • Полезные встроенные макросы: __FILE__ (имя файла в виде строки), __LINE__ (номер текущей строки) — вместе с __func__ (имя текущей функции, стандарт C99) дают бесплатную трассировку для отладки; __DATE__, __TIME__ фиксируют момент сборки (полезно в version); __STDC_VERSION__ помогает писать код под разные стандарты (например, 201112L для C11, 201710L для C18). Практический пример трассировки: #define LOG(msg) fprintf(stderr, "[%s:%d] %s\n", __FILE__, __LINE__, msg) — вызов LOG("starting") автоматически печатает имя файла и номер строки. Важный нюанс: __DATE__ и __TIME__ «зашиваются» в момент компиляции каждой единицы трансляции, поэтому в большом проекте разные .c могут показать разные timestamp-ы; для единой даты сборки используют отдельный сборочный скрипт и передают её через -D. Лайфхак на экзамене: если видите в условии задачи «номер строки, где произошла ошибка» — это почти всегда намёк на __LINE__ и __FILE__.

  • Макрос не имеет типа и области видимости: #define N 10 заменит N везде — включая имена переменных, комментарии и чужие идентификаторы, если они случайно совпали. Поэтому имена макросов пишут ЗАГЛАВНЫМИ БУКВАМИ — так их видно в коде и сразу понятно, что это не переменная. Типовая ошибка: #define max(a, b) ... в системном заголовке ломает имена max в пользовательском коде — именно поэтому макросы в стандартной библиотеке давно заменены на шаблоны/функции. Экзаменационная подсказка: идентификатор ЗАГЛАВНЫМИ — это почти всегда макрос или константа времени компиляции; строчными с подчёркиваниями — функция или переменная. Ловушка с подчёркиванием: идентификаторы, начинающиеся с подчёркивания и заглавной буквы или с двух подчёркиваний подряд (__MY_MACRO), зарезервированы стандартом для реализации — не используйте их в своём коде, иначе рискуете пересечься с внутренностями компилятора или библиотеки и получить необъяснимый UB.

Структура программы и функция main

Простыми словами. Любая программа на Си — это набор функций, и ровно одна из них, main, служит точкой входа: с неё начинается выполнение. Аналогия: main — дирижёр, который даёт старт оркестру, а остальные функции — музыканты, каждый со своей партией; дирижёр не играет мелодию, он координирует. Аргументы argc/argv — это то, что вы дописали в командной строке после имени программы (./prog hello world → argc=3, argv={"prog","hello","world"}); код возврата main видит оболочка и может использовать в скриптах через $?. Что происходит после return: среда выполнения (операционная система или симулятор) получает целое число, и об этом числе можно строить логику в shell-скриптах: cmd; echo "exit=$?" или if ./prog; then ....

Программа — набор функций, точка входа — main, возвращающая int (код возврата доступен оболочке как $?).

#include <stdio.h>
#include <stdlib.h>   // EXIT_SUCCESS, EXIT_FAILURE

int main(int argc, char *argv[]) {
    // argc >= 1; argv[0] — имя программы; argv[argc] == NULL
    if (argc < 2) {
        // Диагностику пишем в stderr, а не в stdout: так её увидят даже при перенаправлении вывода
        fprintf(stderr, "Использование: %s <имя>\n", argv[0]);
        return EXIT_FAILURE;                 // обычно 1 — сообщаем об ошибке оболочке
    }
    printf("Привет, %s!\n", argv[1]);        // argv[1] — первый реальный аргумент
    return EXIT_SUCCESS;                     // 0 — успешное завершение
}
  • Стандартные формы main: int main(void) (без аргументов) и int main(int argc, char *argv[]) (с аргументами). Написание void main() нестандартно: на некоторых компиляторах оно «работает», но это UB (undefined behavior), и на экзамене за него снижают оценку. Почему: стандарт C явно требует, чтобы реализация вызывала именно функцию с сигнатурой, возвращающей int — что возвращает void main, среда выполнения не знает, и в оболочку может уйти произвольный «мусор» из регистра. Тонкость: int main() и int main(void) — это разные вещи в Си: пустые скобки означают «аргументы не специфицированы» (K&R-наследие), а (void) — «аргументов нет точно». Всегда пишите (void), если функция ничего не принимает.

  • В C99+ при отсутствии return в main неявно возвращается 0 (только для main! в других функциях это UB), но явный return нагляднее и переносимо читается — привыкайте писать его всегда. Лайфхак: в других функциях, возвращающих int, отсутствие return — это не «неявный ноль», а UB: компилятор может вернуть что угодно, и программа будет вести себя по-разному в debug- и release-сборке. Экзаменационная формулировка: «в Си main — единственная функция, которой разрешено fall off the end».

  • Объявления прототипов нужны до вызова; заголовки (.h) хранят объявления, файлы реализации (.c) — определения. Отсюда типовые ошибки линковки: undefined reference to foo — определение не попало в сборку (забыли скомпилировать .c или не добавили в проект); implicit declaration of function — не подключён заголовок с прототипом, компилятор угадывает сигнатуру и может сгенерировать неверный код. Экзаменационная подсказка: «implicit — проблема компиляции, undefined — проблема линковки». Наглядный пример «implicit»: вызвали printf("%d", 3.14) без #include <stdio.h> — компилятор решит, что printf возвращает int (по умолчанию для неизвестных функций в старых стандартах), и на x86-64 передача double через регистры XMM сломается: вместо 3.14 напечатается мусор. В современных компиляторах это лечится -Wimplicit-function-declaration, который сразу скажет, где забыли #include.

  • Точка входа — не синоним «вся программа»: код принято делить на функции, а main держать коротким (проверить аргументы, вызвать бизнес-логику, вернуть код). Это напрямую спрашивают на защите курсовой про модульность: «длинный main — признак плохой архитектуры». Хороший main — это 5–15 строк оркестровки, а вся работа — в вызываемых функциях. Практический совет: если ваш main не помещается на один экран — это сигнал к рефакторингу; выносите чтение аргументов, инициализацию, основной цикл и финальный отчёт в отдельные функции (parse_args, init, run, cleanup).

Операции и приоритеты

Простыми словами. Приоритет определяет, какая операция выполнится раньше (как в школе: умножение «сильнее» сложения); ассоциативность — как читать операции одного приоритета: слева направо (как в a - b - c = (a - b) - c) или справа налево (как в a = b = c = a = (b = c)). Мнемоника: «сначала скобки, потом унарные, потом арифметика, потом сравнения, потом логика, и только в самом конце — присваивание». Правило выживания на экзамене: если сомневаетесь в приоритете — ставьте скобки; лишние скобки никогда не считаются ошибкой, а вот «съеденный» приоритет — считается, и компилятор вас не спасёт. Главная ловушка: битовые операции (&, ^, |) имеют приоритет ниже, чем сравнения (==, !=), поэтому смешивать их в одном выражении без скобок — почти гарантированная ошибка.

Приоритет Операторы Ассоциативность
1 () [] . -> постфиксные ++ -- слева направо
2 префиксные ++ --, ! ~, унарные + -, * &, sizeof, приведение справа налево
3 * / % слева направо
4 + - слева направо
5 << >> слева направо
6 < <= > >= слева направо
7 == != слева направо
8–10 &, ^, | слева направо
11–12 &&, || слева направо
13 ?: справа налево
14 присваивания (=, +=, …) справа налево
15 , слева направо

Особенности и подводные камни:

  • Целочисленное деление отбрасывает дробную часть (7 / 2 == 3, а не 3.5) — это частая ошибка новичков, которые пишут int x = a / b и ожидают дробный результат; для дробного нужен double и хотя бы один операнд типа double. Лайфхак-диагностика: если компилятор предупреждает «implicit conversion from double to int loses precision» — вы только что попались на эту ловушку. Правило: результат деления имеет тип «самый широкий из операндов», но если оба int — результат тоже int, сколько бы вы ни старались.
  • && и || вычисляются с коротким замыканием: если результат уже известен по левому операнду, правый не вычисляется. Отсюда идиома if (p != NULL && *p > 0) — обращение к *p безопасно, потому что при p == NULL правая часть не запустится. Экзаменационный вопрос-ловушка: чем отличается & от &&? Первый — побитовое И (всегда вычисляет оба аргумента), второй — логическое И с коротким замыканием. То же для | и ||.
  • = вместо == в условии — частая ошибка: пишите if (0 == x) или включайте предупреждения компилятора (-Wall -Wextra), он явно скажет «suggest parentheses around assignment used as truth value». Сравнение с нулём через 0 == x — старая защита от опечатки, потому что 0 = x не скомпилируется. Продвинутая защита: некоторые проекты запрещают присваивания в if через -Werror=parentheses или статические анализаторы (clang-tidy, cppcheck).
  • *p++ — это *(p++), а не (*p)++: постфиксный ++ имеет более высокий приоритет, чем унарный *. Аналогия: представьте, что операторы тянут «руки» к операндам: постфиксный ++ хватает p раньше, чем * успевает к нему прикоснуться — поэтому сначала запоминается старое значение p, а потом указатель сдвигается. Это же поведение — фундамент всех итераторов по массивам в Си.

Как читать таблицу на практике. Три самые коварные строки — 2, 14 и 15. Строка 2 объясняет, почему *p++ разбирается как *(p++): постфиксный ++ стоит в отдельной, более высокой группе (1), чем унарный * (группа 2), поэтому сначала берётся «старое» значение, а потом указатель сдвигается. Строка 14 объясняет, почему a = b = c работает (присваивание правоассоциативно: сначала b = c, потом a = результат), а if (a = b) — почти всегда ошибка: = возвращает присвоенное значение, и условие окажется истинным при ненулевом b. Строка 15 напоминает, что запятая тоже оператор: int x = (1, 2); присвоит 2, потому что значение всего выражения с запятой — последний операнд. Без внешних скобок int x = 1, 2; — это не выражение с запятой, а объявление с ошибкой синтаксиса. Тонкость про запятую: оператор-запятая вводит точку следования (sequence point) между операндами, поэтому i = 0, i++, i++ гарантированно даст i == 2 после выполнения — побочные эффекты не «перемешиваются».

Шпаргалка по частым комбинациям:

Выражение Как читать Что делает
*p++ *(p++) взять *p, затем сдвинуть p
(*p)++ — увеличить значение по адресу p
*++p *(++p) сдвинуть p, затем взять значение
a & b == c a & (b == c) ловушка: == приоритетнее &, нужны скобки
!x & 1 (!x) & 1 ! приоритетнее &
x << 1 + 2 x << (1 + 2) + приоритетнее <<

Проверьте себя: 1 << 2 + 3 — это 1 << 5, потому что сложение приоритетнее сдвига, и в C это классический вопрос с подвохом. Если вы написали 1 << (2+3) = 32, а не (1<<2)+3 = 7 — вы усвоили таблицу. Экзаменационная ловушка: битовые операции (&, ^, |) имеют приоритет ниже, чем сравнения (==, !=), поэтому flags & MASK == 0 читается как flags & (MASK == 0) — почти наверняка не то, что имел в виду автор; всегда оборачивайте побитовые операции в скобки при смешивании с логикой. Ещё одна типовая ошибка: high << 8 + low — кажется, что это «склеивание» двух байт в 16-битное число, но на самом деле это high << (8 + low), что почти всегда бессмысленно; правильно писать (high << 8) | low.

Управляющие конструкции: if, switch, циклы

Простыми словами. Управляющие конструкции задают порядок выполнения: ветвление (if, switch) выбирает один из путей, а циклы (for, while, do-while) повторяют действия, пока условие истинно. Аналогия: ветвление — развилка на дороге, цикл — круговое движение, пока не появится съезд. На экзамене проверяют не только синтаксис, но и «прогон»: сколько раз выполнится тело цикла и что напечатает программа. Главный принцип: в Си нет отдельного булева типа как «первоклассного гражданина» — любое целое число может быть условием, поэтому понимание 0 vs «не ноль» важнее, чем знание синтаксиса if.

Что такое условие в Си. Условие в if, while, for — это обычное выражение языка C (например, x > 0, getchar() != EOF, arr[i]). Оно вычисляется, и результат трактуется как логическое значение: ноль — ложь (false), любое ненулевое значение — истина (true). Поэтому конструкции if (x), if (x != 0) и if (42) означают одно и то же: «выполнить тело, если x не равен нулю». Именно из-за этого правила так опасен оператор = вместо == — он присваивает, а не сравнивает, и результат присваивания сам становится значением условия. Лайфхак-диагностика: функция, возвращающая int, всегда может стоять в if без сравнения — if (malloc(size)) { ... } означает «проверяем, что указатель не NULL», потому что malloc возвращает NULL (== 0) при неудаче.

#include <stdio.h>

int main(void) {
    int score = 85;
    if (score >= 90)      puts("отлично");
    else if (score >= 70) puts("хорошо");
    else                  puts("нужно подтянуть");

    // switch работает только с целыми типами (включая char и enum)
    // и сравнивает на равенство; без break управление «проваливается»
    switch (score / 10) {
        case 10:
        case 9:  puts("A"); break;
        case 8:  puts("B"); break;
        default: puts("C"); break;
    }

    for (int i = 0; i < 3; i++) printf("%d ", i);   // 0 1 2
    putchar('\n');

    int k = 0;
    while (k < 2) printf("w%d ", k++);              // проверка ДО тела
    do { printf("d%d ", k); k--; } while (k > 0);   // тело выполнится хотя бы раз
    putchar('\n');

    for (int i = 0; i < 5; i++) {
        if (i == 1) continue;   // пропустить итерацию
        if (i == 4) break;      // выйти из цикла
        printf("%d ", i);       // 0 2 3
    }
    putchar('\n');
    return 0;
}

Как читать if сверху вниз. В цепочке if / else if / ... / else проверки идут по порядку сверху вниз, и выполняется ровно первая ветка, условие которой истинно. Все остальные пропускаются — даже если их условия тоже истинны. Простое определение: цепочка if/else — это «приоритетный список вариантов»: нашёл подходящий — обработал и вышел.

Аналогия из жизни: представьте, что вы — охранник в клубе и проверяете посетителей по списку приглашений. Вы идёте по списку сверху вниз и пропускаете первого, кто прошёл проверку. Если «VIP» стоит выше «обычного гостя», то VIP пройдёт; если поменять порядок — все VIP будут идти по обычной очереди.

Почему порядок важен. Более строгое условие (score >= 90) ставят раньше менее строгого (score >= 70), иначе первое же совпадение «съест» все последующие случаи: студент с баллом 95 попадёт в ветку >= 70 и никогда не дойдёт до проверки >= 90. Совет: проверяйте порядок от частного к общему, как в матёрой логике вложенных интервалов.

Почему switch сравнивает только на равенство. В отличие от if, который может проверять любое выражение-условие (диапазон, комбинацию, вызов функции), switch принимает только целочисленное выражение (или char, enum, приводимые к int) и сравнивает его с метками case строго на равенство. Простое определение: switch — это «словарь значений»: ключ — ваше выражение, значения — константы в case. Никаких диапазонов и составных условий в самом case записать нельзя — приходится «раскрывать» их через серию case или использовать «проваливание» (fall-through).

Аналогия: switch — это домофон с кнопками 1, 2, 3; нажали одну — сработала одна функция. Нельзя запрограммировать «открыть дверь, если нажата любая кнопка с 1 по 9» одной меткой — только перечислением case 1: case 2: ... case 9: с проваливанием. Почему так: на аппаратном уровне компилятор часто превращает switch по плотным меткам в таблицу переходов (jump table) — массив адресов, где поиск за O(1). Для произвольных условий такая оптимизация невозможна.

  • Условие любого цикла и if — это выражение; в Си «истина» = «не ноль». Поэтому if (x = 5) синтаксически корректно и всегда истинно: классическая ошибка, спасает идиома if (5 == x) (Yoda-условие).
  • for удобен, когда число итераций известно заранее (счётчик от 0 до n), while — когда условие проверяется до тела и заранее число шагов неизвестно, do-while — когда тело обязано выполниться хотя бы раз (например, запрос ввода у пользователя или текстовое меню).
  • break выходит из ближайшего цикла или switch, continue переходит к следующей итерации того же ближайшего цикла. Вложенные циклы одним break не прервать — нужен флаг (int found = 0;) или goto (в Си это не запрещено и иногда уместно, в отличие от C++-идиоматики).
  • Типичная ошибка: цикл for (i = 0; i <= n; i++) при обращении к массиву из n элементов — лишняя итерация выходит за границы. Правильно — строго < n. Проверка в уме: «массив длины n имеет индексы 0..n-1, значит условие цикла — i < n».
  • Ещё одна: бесконечный цикл while (i < n) без изменения i внутри тела или обратный цикл с беззнаковым счётчиком (for (size_t i = n-1; i >= 0; i--)): беззнаковое значение никогда не станет меньше нуля — после 0 идёт UINT_MAX, и условие снова истинно.

Разбор ошибок подробнее.

  1. = вместо ==. Запись if (x = 5) означает «присвой x значение 5 и проверь результат». Так как 5 — ненулевое число, условие всегда истинно, а x молча перезаписан, теряя прежнее значение. Это одна из самых частых и опасных опечаток в Си. Компилятор часто выдаёт предупреждение warning: suggest parentheses around assignment used as truth value — не игнорируйте его. Защитная идиома «Yoda-условие» (if (5 == x)) помогает: если опечатка, то 5 = x не скомпилируется, потому что литералу нельзя присваивать. Аналогия: перепутали «взять» и «положить» в карман — Yoda говорит наоборот: «положи 5 в карман x», и ошибиться невозможно.

  2. Граница массива i <= n вместо i < n. Если массив объявлен как int a[n], его допустимые индексы — от 0 до n-1. Условие i <= n добавляет лишнюю итерацию, в которой происходит чтение/запись a[n] — это выход за границы, классическое UB (undefined behavior). Компилятор не проверяет выход за массив, и программа может «работать» на вашем компьютере, но упасть на другом (или при включённой оптимизации). Правило-памятка: для массива длины n всегда i < n.

  3. Беззнаковый обратный цикл. Тип size_t (а также unsigned, unsigned int) не может быть отрицательным: когда переменная равна 0, вычитание единицы даёт очень большое число (например, 0u - 1u == UINT_MAX для 32-битного типа). Поэтому условие i >= 0 для беззнаковой переменной всегда истинно — цикл становится бесконечным и либо зависает, либо читает чужую память. Безопасные варианты: использовать знаковый тип (int i;), либо цикл for (size_t i = n; i-- > 0; ) (идём от n до 1 и проверяем до декремента — i-- сначала возвращает текущее значение, потом уменьшает), либо цикл в прямом направлении.

  4. Точка с запятой после if / while / for. Случайно поставленная ; делает ветку пустой: if (x > 0); printf("..."); напечатает строку безусловно, потому что точка с запятой завершает пустой оператор, а следующий printf уже не относится к if. Аналогия: как будто вы сказали «если дождь; иди гулять» — точка с запятой съела условие, и «иди гулять» стало самостоятельным предложением. Тот же эффект у while (x > 0); — пустое тело крутится, пока условие истинно, но ничего полезного не делает (часто именно так получают «горячий» цикл, грузящий процессор).

  5. Пустое тело цикла while без прогресса. Если внутри while не меняется переменная из условия, цикл никогда не закончится и программа «зависнет». Это особенно опасно при копировании фрагментов — компилятор не обязан это ловить, потому что в общем случае переменная может меняться через указатель или внешнюю функцию. Совет: для каждого цикла мысленно отвечайте на вопрос «что приближает нас к выходу?».

  6. Забытый break в switch. Без break управление «проваливается» в следующий case. Иногда это используют намеренно (как в примере для оценок A: case 10: без тела проваливается в case 9:), но случайный пропуск — частая причина неверной работы. Совет: после каждого case явно пишите либо break, либо комментарий /* fall-through */, чтобы было видно, что провал намеренный (современные компиляторы умеют ловить случайный fall-through при флаге -Wimplicit-fallthrough).

Экзаменационные тезисы (что нужно уметь отвечать).

  • Условие в Си — выражение; истина трактуется как «не ноль», ложь — как ноль.
  • if / else if / else выполняет ровно одну подходящую ветку, остальные пропускаются; порядок условий важен — от частного к общему.
  • switch принимает только целочисленные типы и сравнивает на равенство; без break происходит fall-through.
  • for — цикл со счётчиком; while — с предусловием; do-while — с постусловием (тело минимум один раз).
  • break прерывает ближайший цикл/switch; continue начинает новую итерацию ближайшего цикла.
  • Ошибка = вместо == — присваивание, не сравнение; защита — идиома Yoda if (5 == x).
  • Выход за границы массива — UB; правильная граница цикла по массиву длины n — i < n.
  • Беззнаковый счётчик неотрицателен: обратный цикл i >= 0 для size_t всегда истинен → бесконечный цикл.

Битовые операции

Простыми словами. Битовые операции работают не с числом целиком, а с каждым его битом независимо: & (И) оставляет единицы там, где они есть в обоих операндах, | (ИЛИ) — где есть хотя бы в одном, ^ (исключающее ИЛИ) — где биты различаются, ~ инвертирует все биты, а сдвиги <</>> двигают биты влево/вправо. Аналогия: это работа с набором выключателей на щитке — можно включать, выключать и проверять каждый отдельно, не трогая остальные. Применяются для флагов (набор логических признаков в одном числе), плотных массивов битов, быстрых умножений/делений на степень двойки, генерации хешей и низкоуровневых протоколов (сетевые заголовки, регистры устройств).

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

#include <stdio.h>

int main(void) {
    unsigned a = 0x0Fu;              // 0000 1111
    unsigned b = 0x33u;              // 0011 0011
    // %08x печатает 8 шестнадцатеричных цифр
    printf("%08x %08x %08x %08x\n", a & b, a | b, a ^ b, ~a);
    // a & b = 0000 0011, a | b = 0011 1111, a ^ b = 0011 1100

    unsigned flags = 0;
    flags |= (1u << 2);              // установить бит 2 (нумерация с нуля)
    flags &= ~(1u << 2);             // сбросить бит 2 через инверсию маски
    int is_set = (flags >> 2) & 1u;  // проверить бит 2
    printf("flags=%u is_set=%d\n", flags, is_set);

    // Полезные приёмы
    int x = 6;
    printf("чётность: %d, x*2: %d, x/2: %d\n", x & 1, x << 1, x >> 1);
    // Обмен без третьей переменной (для демонстрации; на практике читается хуже)
    int p = 3, q = 5;
    p ^= q; q ^= p; p ^= q;
    printf("p=%d q=%d\n", p, q);     // 5 3
    return 0;
}

Шпаргалка по маскам (на примере бита номер k).

Действие Выражение Что происходит
Установить бит k в 1 flags |= 1u<<k ИЛИ с единицей в нужной позиции: остальные биты не меняются
Сбросить бит k в 0 flags &= ~(1u<<k) И с инверсией маски: нужный бит обнуляется, остальные целы
Проверить бит k (flags >> k) & 1u Сдвигом приводим бит к младшей позиции и читаем его
Инвертировать бит k flags ^= 1u<<k Исключающее ИЛИ переключает бит: 0→1, 1→0

Принцип «не трогать чужие биты». Любая корректная битовая операция над флагом должна оставлять все остальные биты неизменными. Именно поэтому для сброса используют & ~(1u << k), а не просто flags &= 0 или flags = 0: первая запись обнулит только целевой бит, остальные оставит как есть. Нарушение этого принципа — частая причина трудноуловимых багов: «я изменил только флаг, а сломалось всё». Аналогия: если вы меняете одну лампочку в гирлянде, остальные должны продолжать гореть, как горели.

  • x & 1 — проверка нечётности (младший бит = 1 означает нечётное), x << 1 — умножение на 2, x >> 1 — деление на 2 (для беззнаковых), x & (x - 1) — сброс младшего установленного бита (классический трюк для подсчёта единиц в числе по алгоритму Брайана Кернигана).
  • Типичные ошибки: сдвиг на число, большее или равное ширине типа, — UB; сдвиг знакового числа влево с переполнением — UB (и даже 1 << 31 для 32-битного int); смешивание логических &&/|| с битовыми &/| — это разные операции с разным приоритетом.
  • Для флагов применяйте беззнаковый тип (unsigned), а маски пишите как 1u << k, чтобы не получить знаковый сдвиг и лишнее приведение типов.
  • Удобный приём проверки нескольких бит сразу: if (flags & MASK) ... — работает, потому что результат & равен нулю, только если все проверяемые биты нулевые.

Разбор ошибок подробнее.

  1. Сдвиг на величину ≥ ширины типа. Стандарт C не определяет поведение, если правый операнд << или >> больше или равен ширине типа левого операнда. Например, для 32-битного unsigned int сдвиг x << 32 — это UB. Безопасный сдвиг: правый операнд в диапазоне [0, ширина-1]. На практике компиляторы часто сворачивают такой сдвиг в «ничего не делать» или используют только младшие биты сдвига (например, x86 берёт x << (n % 32)), но полагаться на это нельзя — другой компилятор или режим оптимизации может дать другой результат.

  2. Сдвиг знакового числа влево. Для знакового типа int сдвиг влево может изменить знаковый бит или привести к переполнению, что для знаковых типов считается UB. Именно поэтому выражение 1 << 31 опасно для 32-битного int — результат «не положительный, но и не отрицательный в обычном смысле» (формально UB, на практике часто получается INT_MIN). Привычка писать 1u << k страхует от этого: литерал 1u имеет тип unsigned int, и весь сдвиг выполняется в беззнаковой арифметике, где переполнение определено как взятие по модулю 2^N.

  3. & vs &&, | vs ||. Битовые & и | всегда вычисляют оба операнда и работают с каждым битом независимо. Логические && и || вычисляют правый операнд только при необходимости (short-circuit, «короткое замыкание») и возвращают ровно 0 или 1. Пример разницы: x & y — побитовое И двух чисел (может вернуть любое число); x && y — «истина (1), если оба не равны нулю, иначе ложь (0)». Случайная замена одного на другое — классический источник UB, например if (ptr != NULL && *ptr > 0) корректно (разыменование только при ненулевом указателе), а if (ptr != NULL & *ptr > 0) — нет: *ptr будет вычислен всегда, и при NULL программа упадёт. Запомните: && и || — для логики, & и | — для битов.

  4. ~ для знаковых чисел. Оператор ~ инвертирует все биты представления, включая знаковый. Для int это даёт «отрицательное число минус один» (~5 == -6, потому что работает формула ~x == -x - 1), что обычно не совпадает с интуитивным «логическим отрицанием». Для логического отрицания используют !, а не ~: !x вернёт 1, если x == 0, и 0 иначе; ~x инвертирует биты и почти всегда даст ненулевое значение (исключение — только x == -1, у которого все биты единичные).

  5. Сдвиг вправо отрицательных значенийых. Для знаковых типов результат >> зависит от реализации: чаще всего это арифметический сдвиг (знаковый бит копируется в освободившиеся старшие позиции, число остаётся отрицательным), но стандарт гарантии не даёт. Поэтому >> для деления на 2 безопасен только для беззнаковых; для знаковых деление делают через обычный оператор /. Аналогия: арифметический сдвиг — как перетащить кубики вправо, не убирая знаковый кубик слева.

  6. Обмен через XOR без временной переменной. Трюк a ^= b; b ^= a; a ^= b; действительно меняет значения местами, но он (а) не быстрее обычного обмена через временную переменную на современных процессорах (компилятор всё равно может вставить регистр), (б) хуже читается, (в) не работает, если a и b — один и тот же объект (a ^= a; a ^= a; a ^= a; обнулит значение, потому что x ^ x == 0). Поэтому на экзамене его уместно упомянуть как трюк, но в реальном коде предпочтительнее обычный обмен через tmp — он надёжнее и понятнее.

Экзаменационные тезисы (что нужно уметь отвечать).

  • Побитовые операции: & (И), | (ИЛИ), ^ (XOR), ~ (NOT), << и >> (сдвиги).
  • Для флагов используют беззнаковые типы, а маску бита k записывают как 1u << k.
  • Установить бит: x |= mask; сбросить: x &= ~mask; проверить: (x >> k) & 1u или x & mask.
  • Сдвиг влево эквивалентен умножению на 2^k, сдвиг вправо (для беззнаковых) — делению на 2^k.
  • Сдвиг на величину ≥ ширины типа — UB; сдвиг знакового влево с переполнением — UB.
  • Битовые &/| отличаются от логических &&/||: первые не short-circuit и работают с битами, вторые — short-circuit и возвращают 0 или 1.
  • x & (x - 1) сбрасывает младший установленный бит — классический трюк для подсчёта единиц (алгоритм Брайана Кернигана).

printf и scanf: спецификаторы

Простыми словами. printf/scanf — это универсальные «принтер и сканер» значений, которые сами не знают типов ваших переменных. Поэтому вы вручную сообщаете функции, какие данные через неё проходят, специальными метками — спецификаторами формата. Спецификатор — это «пропуск» или «этикетка на посылке»: если он не совпадает с типом аргумента, функция прочитает или напечатает мусор, а компилятор чаще всего промолчит (потому что printf принимает переменное число аргументов через ... и не может проверить типы на этапе компиляции). Аналогия из жизни: в коробку положили число, а на ярлыке написали «строка» — получатель прочитает бессмыслицу и не узнает правды. На экзамене чаще всего ловят на несоответствии %d и size_t (нужен %zu), на пропущенном & в scanf (функция не может записать значение в переменную, если ей не передали адрес), а также на путанице %f (для printf это double) и %lf (для scanf это double, не float). Запомните триаду: спецификатор описывает тип аргумента, для scanf почти всегда нужен &, а для printf дополнительные флаги точности/ширины — это про визуальное форматирование, а не про тип. Почему это важно понимать глубже. Спецификатор — это не «украшение», а обязательство о размере и интерпретации битов: он говорит функции, сколько байт снять со стека и как их трактовать. Если вы передали int (4 байта), а написали %lld (8 байт), функция прочитает лишние 4 байта и напечатает чушь; если наоборот — потеряет старшие байты. Поэтому «работает на моей машине» здесь особенно опасно: код может печатать правильно на одной платформе и падать на другой. Ещё одна тонкость: %d и %i для printf ведут себя одинаково, но в scanf %i дополнительно понимает префиксы 0x (шестнадцатеричное) и 0 (восьмеричное) — это ловушка для ввода пользователя.

Аналогия из жизни. Представьте, что printf — это таможенник, а спецификатор — это декларация: «я везу коробку с 4 байтами». Если вы скажете «коробка с 8 байтами», а сами привезёте 4, таможенник всё равно возьмёт 8 — даже если придётся залезть в соседний грузовик. Результат непредсказуем. В Си это называется неопределённое поведение (UB): компилятор не обязан ничего проверять, ответственность — на программисте.

Почему компилятор не спасает. Стандарт C исторически разрешает вызывать вариативные функции (printf, scanf) без проверки типов аргументов — так работают эллипсисы .... Поэтому компилятор не может сверить спецификатор с реальным типом на этапе компиляции (точнее, не обязан). Современные компиляторы умеют предупреждать через -Wformat, но только для известных формат-строк; если строка собирается динамически (printf(msg)), предупреждения не будет. Вот почему аккуратность со спецификаторами — ваша личная ответственность, а не «забота IDE».

#include <stdio.h>

int main(void) {
    int    n = -42;
    unsigned u = 4000000000u;
    long   l = 9000000000L;             // суффикс L: значение не влезает в int
    double d = 3.14159;
    size_t s = sizeof n;                // size_t — правильный тип размеров

    printf("%d %i\n", n, n);            // %d и %i для вывода эквивалентны
    printf("%u %o %x %X\n", u, 255, 255, 255);   // беззнаковое, 8-, 16-ричное
    printf("%ld %zu %%\n", l, s);       // long, size_t, знак процента (%% = один %)
    // Ширина и точность: [%10.3f] — 10 позиций, 3 знака после точки
    // [%-10.3f] — выравнивание влево, [%+d] — всегда знак, [%05d] — нули слева
    printf("[%10.3f] [%-10.3f] [%+d] [%05d]\n", d, d, n, n);
    printf("%c %s %p\n", 'A', "текст", (void *)&n);   // %p требует void*
    return 0;
}

Мнемоника для запоминания суффиксов размера.

  • %d — без суффикса → int. Буква d от слова decimal (десятичное).
  • %ld / %lld — буква l как «long» → long / long long. Две буквы l подряд читайте как «long-long».
  • %hd / %hhd — буква h как «half» → short / char. Две h подряд — «half-half», то есть вообще байт.
  • %zu — буква z как «size» → size_t. Это согласовано с size_t в <stddef.h>.
  • %Lf — столичная L как «Long double» (строчная l уже занята под long). Здесь легко ошибиться: %lf — это double, а %Lf — long double.

Подсказка-лайфхак для экзамена. Если забыли суффикс — посмотрите на сам тип: для int → %d, для long → %ld, для long long → %lld, для size_t → %zu, для double → %f (в printf) или %lf (в scanf). Связь «тип ↔ спецификатор» запоминается проще, чем каждый спецификатор по отдельности.

Типичные ошибки студентов (разбор).

  1. printf("%d", sizeof x); — sizeof возвращает size_t (беззнаковый, на 64-бит 8 байт). Печатайте через %zu, иначе получите мусор или предупреждение -Wformat. Почему это работает кое-где: на 32-бит Linux size_t и unsigned int имеют одинаковый размер, и код случайно «совпадает». На 64-бит — расходятся, и вы видите -1, 4294967295 или обрезанные значения.
  2. scanf("%f", &x); где x объявлен как double — для scanf оба типа (float и double) требуют своих спецификаторов: %f для float, %lf для double. В printf промежуточный %f подходит для double благодаря продвижению типа, а в scanf аргумент уже указатель — продвижения нет. Мнемоника: scanf принимает адрес «куда положить», printf — значение «что показать»; продвижение типа работает только для значений.
  3. scanf("%d", n); без & — компилятор думает, что вы передаёте значение как int, а функция записывает по этому «адресу». Это почти всегда приводит к падению (UB). Исключение: char buf[N]; scanf("%s", buf); — массив сам «затухает» до указателя, & не нужен. Почему опасно: функция запишет прочитанное число по «адресу», который на самом деле является значением переменной — это почти гарантированный segfault или порча памяти.
  4. printf("%s", ptr); где ptr — int * — компилятор выдаст предупреждение, %s ждёт char *. Явно приведите: printf("%s", (char *)ptr); либо используйте правильный тип с самого начала. Аналогия: функция ждёт ключ от квартиры, а вы даёте ключ от гаража — снаружи похоже, но внутрь не попасть.
  5. Забытый %% для вывода знака процента — printf("100%") воспримет второй % как начало спецификатора и напечатает чушь либо проигнорирует. Совет: в строке формата каждый «настоящий» % обязан быть удвоен — это единственный случай «экранирования» в формат-строке.
  6. printf("%s\n", str); где str не заканчивается на \0 — функция побежит по памяти до первого нулевого байта и напечатает мусор или упадёт. Строки в Си обязаны быть null-terminated; это контракт, а не пожелание.
  7. printf("Число: %d\n", 3.14); — передаёте double, а пишете %d. Компилятор в лучшем случае выдаст предупреждение, в худшем — напечатает биты числа как целое (получите огромное число вроде 1374389535).
Спецификатор Тип аргумента Замечание
%d, %i int Знаковое целое; в scanf %i дополнительно понимает префиксы 0x и 0 — на экзамене любят спросить, почему %i в scanf «опаснее»
%u, %o, %x unsigned Беззнаковые, 16-ричное в нижнем регистре; %X — в верхнем; %o — восьмеричное (редко используется, но встречается в старом коде и Юникс-правах 0755)
%hd, %hhd short, char Укороченные целые (буква h — «half»); char через %hhd печатает как число, а не как символ
%ld, %lld long, long long Длинные целые (буква l — «long»); без них long печатается мусором на 64-бит Linux
%zu size_t Единственный правильный выбор для sizeof, длин массивов и strlen; не подставляйте %d — это самая частая ошибка на контрольных
%f, %e, %g double В printf double печатает %f (благодаря продвижению типа); %e — экспонента; %g — компактно, сам выбирает форму (%e или %f в зависимости от величины)
%Lf long double Столичная «L» обязательна; строчная l уже занята под long; путаница %lf и %Lf — классическая ловушка
%c, %s int, char * %c ждёт int (код символа); %s — указатель на null-terminated строку
%p void * Адрес в реализационно-зависимом формате; приводите к void * явно — на 32-бит выведет 0x..., на 64-бит тоже 0x..., но длиной 12-16 цифр
%% — Печатает один знак процента; пара % без спецификатора — UB

Как читать таблицу. Спецификатор — это не «украшение», а обязательство: тип аргумента должен совпадать с ним бит в бит, иначе поведение не определено (UB). Три правила, которые спасают на контрольной: для size_t берите %zu (самая частая ошибка — %d), для long long — %lld, а указатель печатайте через %p, приведя к void *. Ширина поля (%10.3f) влияет только на внешний вид и не меняет значение — это полезно для выравнивания таблиц в консоли.

Практический совет. Если компилятор поддерживает атрибут __attribute__((format(printf, ...))) (GCC/Clang), оборачивайте свои функции-обёртки вокруг printf — тогда компилятор будет проверять спецификаторы и в вашем коде. Это бесплатная страховка от ошибок формат-строки, которые иначе обнаруживаются только в рантайме.

Ширина, точность и флаги — шпаргалка.

  • %10d — минимум 10 позиций, выравнивание вправо (по умолчанию для чисел). Если число короче — слева добавляются пробелы.
  • %-10d — выравнивание влево. Полезно для таблиц: имя слева, число справа.
  • %05d — дополнять нулями слева до 5 позиций. Удобно для дат, номеров заказов: 0042 вместо 42.
  • %+d — всегда печатать знак (+ для положительных). Полезно в отчётах, где -5 и +5 должны быть видны оба.
  • %.3f — три знака после точки. Округление, а не отбрасывание: 3.14159 → 3.142.
  • %10.3f — общая ширина 10, из них 3 после точки. Точка входит в общую ширину, то есть 3.142 займёт 5 позиций, а остальное — пробелы.

Аналогия. Ширина — это «ширина полки в шкафу». Значение кладётся на полку; если оно короче — остаётся пустота (пробелы); если длиннее — полка «раздвигается», число не обрезается. Флаг 0 заполняет пустоту нулями (как пробирки, заполненные жидкостью), флаг - сдвигает значение влево (как книги с корешком слева).

В scanf другое правило: double требует %lf, float — %f, а перед всеми переменными обязателен & (кроме массивов, которые сами «затухают» до указателя). scanf возвращает число успешно прочитанных полей, EOF — при конце ввода; результат проверять обязательно, иначе цикл может зациклиться на «мусорном» вводе. Ширину задают для защиты буфера: scanf("%31s", buf), потому что без неё ввод длиннее буфера переполнит память (классическая уязвимость). Ещё две ловушки: %s в scanf читает до первого пробельного символа (для строки с пробелами нужен fgets или %[^\n]), а %d без ширины на символе, не являющемся цифрой, оставит его во входном потоке и зациклит чтение. Поэтому надёжнее читать строку через fgets, а число разбирать через strtol — это даёт контроль над ошибками и не зависит от «поведения scanf».

Почему fgets + strtol лучше scanf. scanf исторически спроектирован для простых учебных задач, а не для продакшена: он не сообщает, какая позиция во вводе «сломалась», не различает «не число» и «конец ввода», плохо работает с интерактивным вводом и небезопасен по умолчанию. fgets сначала читает строку фиксированной длины (нет переполнения), затем strtol разбирает её с указанием позиции ошибки через второй аргумент. Этот паттерн — стандарт индустрии, его спрашивают на собеседованиях в Яндекс, VK и других компаниях.

Экзаменационные тезисы по теме.

  • printf продвигает float к double, поэтому %f годится; scanf так не делает — отсюда %lf для double. Это любимый вопрос преподавателей: «почему в одном случае %f, а в другом — %lf?»
  • %zu — единственный корректный способ напечатать size_t; %d на некоторых платформах даст предупреждение, а на других — тихий мусор.
  • & обязателен в scanf для всех скаляров; для массивов (char s[N]) — не нужен, потому что имя массива само преобразуется в указатель.
  • Возвращаемое значение scanf (%d/%f) — это число успешно прочитанных полей, а не введённое значение. Всегда проверяйте if (scanf(...) != 1).
  • Ширина в printf — только форматирование, она не обрезает значение. Ширина в scanf — это максимальное число символов, и она обрезает чтение, защищая буфер.
  • %% — единственный способ вывести знак процента; нельзя писать \%, это не Pascal и не Python.
  • %p требует именно void *; на практике компилятор часто принимает любой указатель и выводит, но формально это UB без приведения.

Пример безопасного ввода:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    char buf[32];
    if (!fgets(buf, sizeof buf, stdin)) return 1;   // fgets знает размер буфера
    char *end;
    long n = strtol(buf, &end, 10);                 // разбор числа с проверкой
    if (end == buf) {                               // ни одной цифры не прочитано
        fprintf(stderr, "не число\n");
        return 1;
    }
    printf("вы ввели %ld\n", n);
    return 0;
}

Указатели и адресная арифметика

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

Зачем это вообще нужно. Без указателей функция не может изменить переменную вызывающего кода (параметры передаются по значению — копии). Без указателей нельзя работать с массивами переменной длины, динамической памятью (malloc), строками (char *) и структурами данных (связные списки, деревья). По сути, всё, что делает Си мощным — функции вроде scanf, free, обработчики сигналов — построено на указателях.

Указатель — переменная, хранящая адрес объекта. Тип указателя важен: он говорит, объект какого типа лежит по адресу, и задаёт шаг адресной арифметики. Операции: & (взять адрес), * (разыменовать), сложение с целым, вычитание и сравнение.

Аналогия для типа указателя. Если int *p — это «адрес дома, в каждой комнате живёт int», то char *p — «адрес дома, где в каждой комнате живёт char». Шаг между комнатами разный, поэтому +1 означает разное расстояние. Это помогает запомнить: тип указателя — это не про адрес, а про интерпретацию того, что по нему лежит.

Почему тип указателя влияет на арифметику. Процессор работает с байтами, а программист — с «элементами». Когда вы пишете int *p; p + 1;, компилятор переводит это в «сдвинуться на sizeof(int) байт», а не на 1 байт. Поэтому int * и char * при одинаковом +1 уйдут на разные расстояния. Это сделано специально, чтобы p[i] работало для любого типа без умножения на sizeof вручную. Именно этот вопрос «куда сдвинется указатель?» любят задавать на собеседованиях и экзаменах.

Почему нельзя складывать два указателя. Сложение указателей не имеет смысла: что значит «адрес дома №3 + адрес дома №5»? Это как складывать номера телефонов — результат не имеет интерпретации. Поэтому стандарт Си запрещает p1 + p2 — компилятор выдаст ошибку. Зато вычитание разрешено и даёт количество элементов между указателями (тип результата — ptrdiff_t).

#include <stdio.h>
#include <stddef.h>   // ptrdiff_t для разности указателей

int main(void) {
    int a[5] = {10, 20, 30, 40, 50};
    // Имя массива затухает до указателя на a[0]; p указывает на первый элемент
    int *p = a;
    // p + 2 сдвигает адрес не на 2 байта, а на 2 * sizeof(int) = 8 байт
    printf("%d %d\n", *p, *(p + 2));         // 10 30
    // Разность указателей даёт число ЭЛЕМЕНТОВ, а не байтов: %td для ptrdiff_t
    printf("%td %zu\n", (a + 4) - a, sizeof(int));   // 4 4
    p += 3;                                  // теперь p указывает на a[3]
    printf("%d\n", *p);                      // 40
    printf("%d\n", p[-1]);                   // 30 — p[-1] это сокращение для *(p - 1)
    return 0;
}

Аналогия из жизни. Указатель на int — это «адрес квартиры с указанием, что в ней живёт семья из 4 человек». Указатель на char — «адрес комнаты с указанием, что в ней один человек». Пройти +1 для первого — значит перейти к следующей квартире (4 байта), для второго — к следующей комнате (1 байт). Поэтому никогда не прибавляйте к указателю «байты» — только элементы.

Простыми словами. Указатель в Си — это переменная особого типа, которая хранит не само значение, а адрес другой переменной в оперативной памяти. С точки зрения памяти сам указатель занимает фиксированное число байт (4 на 32-битной платформе, 8 на 64-битной), но благодаря типизации компилятор знает, сколько байт занимает значение, на которое указатель ссылается — и это важно для адресной арифметики. Если int *p означает «адрес целого числа», то p + 1 сдвигается сразу на sizeof(int) байт, а не на 1. Именно поэтому int *p и char *c — разные типы, даже если оба указателя умещаются в одинаковое число байт: у них разная «цена деления».

  • Арифметика идёт в единицах типа: p + 1 сдвигает адрес на sizeof(*p) байт. Именно поэтому int *p и char *c при одинаковом +1 уйдут на разные расстояния — это первое, что проверяют вопросом «куда сдвинется указатель?». Это нужно, чтобы адресная арифметика оставалась «осмысленной»: перебирая элементы массива int, мы должны попадать ровно в начало следующего int, а не в середину предыдущего.
  • Вычитание указателей даёт число элементов (ptrdiff_t); сложение двух указателей запрещено, потому что сумма адресов не имеет смысла (компилятор выдаст ошибку). Тип результата ptrdiff_t — знаковый, специально предназначенный для разности двух адресов, поэтому он может быть отрицательным (когда правый указатель «левее»).
  • Сравнивать и вычитать указатели допустимо только внутри одного массива: формально сравнивать адреса из разных объектов — UB, хотя на практике «работает» на большинстве платформ. Стандарт разрешает сравнивать указатели только в пределах одного объекта (включая «один за последним»), потому что в общем случае адреса разных объектов могут иметь произвольное взаимное расположение (особенно в сегментных моделях памяти).
  • const читайте справа налево: const int *p — нельзя менять *p (указатель на константу); int * const p — нельзя менять p (константный указатель); const int * const p — нельзя ни то, ни другое. Мнемоника: «const прилипает к тому, что стоит слева от него», а пробежать назад по строке проще, чем разбираться в звёздочках. Практический совет: всегда начинайте чтение типа с имени переменной и идите наружу — это самый надёжный способ не запутаться в сложных объявлениях вроде const int * const *p.
  • Указатель void * нельзя разыменовать и к нему нельзя прибавить число: сначала приводят к конкретному типу. Это «универсальная бумажка без указания, что внутри». Исторически void * появился как способ обойти систему типов: в Си он неявно приводится к любому указательному типу и обратно, что делает его удобным для обобщённых интерфейсов (malloc, memcpy, qsort). Но именно из-за отсутствия информации о размере элемента компилятор запрещает арифметику и разыменование до явного приведения.
  • Разыменование NULL, «диких» (неинициализированных) и висячих указателей — UB; после free обнуляйте указатель. NULL в современном C — это ((void *)0), поэтому if (p) корректно проверяет «указатель не пуст». UB — это «неопределённое поведение» (undefined behavior): стандарт не определяет, что произойдёт, и теоретически компилятор может сгенерировать что угодно — от чтения мусора до падения, форматирования диска или вообще ничего. Это худший класс ошибок в Си, потому что программа может «работать» годами, а потом упасть в самый неподходящий момент.

Экзаменационные тезисы по теме.

  • int *p, q; — здесь p указатель, а q обычный int. Звёздочка относится к переменной, а не к типу. Чтобы обе были указателями — int *p, *q;. Это любимый «подвох» преподавателей, и причина его существования — в грамматике Си: декларатор *p относится к p, а не ко всему списку имён через запятую.
  • a[i] и *(a + i) — это одно и то же. Поэтому 2[a] тоже валидный Си (хотя так писать не надо), потому что сводится к *(2 + a). Это работает благодаря коммутативности сложения: оператор [] в стандарте определён именно как разыменование суммы индекса и указателя, поэтому порядок слагаемых не важен.
  • sizeof(*p) даёт размер типа, на который указывает p, а не размер самого указателя (sizeof(p) всегда даёт размер адреса — 4 или 8 байт). Это часто путают: студенты думают, что sizeof(*p) зависит от платформы, но он зависит только от типа, на который указывает p.
  • void * совместим с любым указателем без явного приведения в C (но не в C++); обратное приведение к конкретному типу обязательно перед использованием. Это одна из ключевых «несовместимостей» Си и C++: в C++ void * требует явного приведения, и код типа int *p = malloc(...) в C++ не скомпилируется без static_cast или (int *).
  • Адресная арифметика вне массива (включая «один за последним элементом») — UB, кроме случая &arr[N] для расчёта конца. Это сделано для того, чтобы компилятор мог оптимизировать код, предполагая, что указатели всегда указывают внутрь одного объекта (или ровно на позицию «один за концом»). Практический вывод: не пишите циклы вида for (p = a; p < a + n; p++) с указателями из разных массивов и не передавайте указатели из разных массивов в одни и те же операторы сравнения.

Типичные ошибки студентов (разбор).

  1. int *p; *p = 5; — указатель не инициализирован, *p пишет в случайную память. Всегда инициализируйте: int *p = &x; или хотя бы int *p = NULL;. На практике такая ошибка проявляется не сразу: программа может какое-то время работать, потому что в «мусорной» памяти оказывается что-то осмысленное, а затем неожиданно падать. Полезная привычка — включать предупреждения компилятора (-Wall -Wextra), которые ловят использование неинициализированных переменных.
  2. scanf("%d", p); вместо scanf("%d", &x); — здесь как раз тот случай, где p уже указатель, и &p взял бы адрес самого указателя. Проверяйте уровень косвенности. Правило большого пальца: если scanf требует указатель на переменную, а у вас уже есть указатель — & не нужен; если есть обычная переменная — & нужен. Для строк (%s) & тоже не нужен, потому что имя массива само «затухает» в указатель.
  3. p = p + 1; для void * — компилятор выдаст ошибку: void * не имеет размера. Сначала приведите к типу. Причина та же: компилятор не знает, на сколько байт сдвигать, потому что void означает «ничего конкретного». Решение — привести тип: char *cp = (char *)p; cp++; или сразу работать с типизированным указателем.
  4. Сравнение указателей из разных массивов (if (p1 < p2)) — формально UB, даже если адреса выглядят «разумно». Стандарт разрешает сравнивать только внутри одного объекта. На практике в плоской модели памяти (все современные ОС) адреса разных объектов всё равно корректно сравниваются по числовому значению, но компилятор вправе использовать допущение стандарта для агрессивных оптимизаций. Не полагайтесь на это — пишите код, который корректен по стандарту.
  5. Забытый free или, наоборот, двойной free — после освобождения памяти обнуляйте указатель (p = NULL;), иначе free(p); free(p); — двойное освобождение и UB. Это худший класс ошибок работы с памятью: утечки памяти (memory leak) просто «съедают» RAM, а двойное освобождение может повредить кучу (heap) и привести к падению в произвольном месте программы. Современные инструменты (AddressSanitizer, Valgrind) ловят обе проблемы автоматически — обязательно используйте их при отладке.

Массивы и указатели (decay)

Простыми словами. Массив — это непрерывный блок ячеек одного типа, а имя массива в выражении превращается («затухает», decay) в указатель на первую ячейку. Аналогия: массив — дом с пронумерованными квартирами; при затухании вы передаёте в функцию только адрес первой квартиры, и функция больше не знает, сколько квартир в доме. Именно поэтому длину массива почти всегда передают отдельным параметром — это самая частая причина ошибок у первокурсников. Идея decay — в эффективности: копировать весь массив в функцию дорого, поэтому в Си исторически решили передавать только адрес первого элемента, а длину сообщать отдельным параметром. Это требует дисциплины от программиста, но даёт максимальную скорость.

Имя массива в выражении затухает (decay) до указателя на первый элемент. Исключения: sizeof, &arr, _Alignof и инициализация строковым литералом. Эти исключения нужны, чтобы можно было узнать размер массива и его адрес как «объекта в целом» (а не как адрес первого элемента).

#include <stdio.h>

// int a[] в параметре — тот же int *a: размер массива здесь узнать нельзя,
// поэтому длину передают вторым параметром (int n)
void f(int a[]) {
    printf("в функции: %zu (это размер указателя, а не массива)\n", sizeof a);
}

int main(void) {
    int arr[10];
    // sizeof arr даёт размер ВСЕГО массива: 10 * 4 = 40 байт;
    // sizeof arr / sizeof arr[0] = число элементов — работает только здесь,
    // где arr действительно массив, а не указатель
    printf("массив: %zu элементов: %zu\n",
           sizeof arr, sizeof arr / sizeof arr[0]);  // 40, 10
    // &arr имеет тип «указатель на массив из 10 int» — это тоже 8 байт адреса,
    // но арифметика по нему идёт шагом в весь массив
    printf("&arr: %zu\n", sizeof &arr);              // 8 — указатель на массив
    f(arr);                                          // передаём адрес первого элемента
    return 0;
}

Из-за decay функцию нельзя спросить о длине массива — её передают отдельным параметром. sizeof arr / sizeof arr[0] работает только там, где arr — настоящий массив. Запомните три исключения, где массив не затухает: sizeof arr, &arr и _Alignof(arr). Ещё одно следствие decay: указатель и массив в выражении почти взаимозаменяемы, поэтому a[i] компилятор превращает в *(a + i) — отсюда и странная, но законная запись 2[a], которая тоже означает *(2 + a). На экзамене это отличный вопрос-ловушка: «почему i[a] эквивалентно a[i]?» — потому что + коммутативна, а [] определён именно через разыменование суммы. Практический совет: если вам в функции нужна длина массива, есть три варианта: (1) передавать её отдельным параметром, (2) использовать «sentinel-значение» (например, '\0' в строках), (3) использовать C99 VLA с осторожностью (но в современном C VLA признан неудачным и опциональным). Самый надёжный путь — первый.

Многомерные массивы

Простыми словами. Двумерный массив — это массив массивов: int a[3][4] читается как «три элемента, каждый — массив из четырёх int». В памяти он лежит одним непрерывным блоком по строкам (row-major): сначала все четыре числа первой строки, потом второй и так далее. Аналогия: это тетрадь в клетку — вы заполняете первую строку слева направо, только потом переходите ко второй. Отсюда формула a[i][j] == *(*(a + i) + j), которую любят просить вывести на экзамене. Важно понимать разницу с языками вроде Фортрана или MATLAB: там порядок column-major (сначала заполняется столбец), и эта разница иногда всплывает при численных расчётах — в Си доступ к элементам одной строки заметно быстрее, чем к элементам одного столбца (из-за кеш-памяти процессора).

int a[3][4] — массив из трёх массивов по четыре int; в памяти он непрерывен и хранится по строкам (row-major). Формула: a[i][j] == *(*(a + i) + j).

#include <stdio.h>

void print(int a[][4], int rows) {       // вторая размерность обязательна: без неё нет шага строки
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 4; j++) printf("%d ", a[i][j]);
        putchar('\n');
    }
}

int main(void) {
    // Вложенные списки инициализаторов: 3 строки по 4 элемента
    int m[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
    print(m, 3);
    // m[0] — это массив из 4 int: одна «строка» занимает 16 байт;
    // весь массив m — 48 байт, то есть 3 строки по 16
    printf("sizeof строки: %zu, всего: %zu\n", sizeof m[0], sizeof m);
    return 0;
}

При передаче int a[][4] эквивалентно int (*a)[4] — указателю на массив из 4 int. Вторая размерность обязательна, потому что именно она задаёт шаг, на который указатель сдвигается при переходе к следующей строке; первую размерность писать можно, но она всё равно теряется. Типичная ошибка — объявить параметр как int ** и передать в него m: это разные типы, и программа упадёт или прочитает мусор. Динамический двумерный массив строят двумя способами: как массив указателей (int **, каждая строка выделена отдельно — удобно для треугольных матриц, но медленнее) или как один непрерывный блок rows * cols с ручной индексацией i * cols + j (быстрее и кеше-дружелюбнее — то, что ждут на экзамене). Сравнение подходов: int ** проще для треугольных/нерегулярных структур (каждая строка может иметь свой размер), а плоский блок с индексацией — быстрее из-за последовательного доступа в памяти. Мнемоника для запоминания: «вторая размерность — это размер строки в int, она должна быть известна компилятору, чтобы он мог пересчитать a[i][j] в адрес; первая размерность — это число строк, оно переменное и передаётся параметром».

Разбор типовой ошибки. int **p = (int **)m; — так делать нельзя: m после decay имеет тип int (*)[4], а не int **. Компилятор выдаст предупреждение о несовместимых указателях, а если привести тип молча — p[1] прочитает не адрес строки, а число из первой строки и «разыменует» его. Правильный путь — либо честный int (*p)[4], либо явное построение массива указателей. Чтобы понять, почему так происходит: представьте, что int (*)[4] — это указатель на «коробку» размером 16 байт (строку), а int ** — это указатель на указатель (8 байт на 64-бит). Когда вы делаете p[1], компилятор прибавляет к p размер int * (8 байт), а должен был бы прибавить 16 байт (размер всей строки). В итоге он попадает в середину первой строки и читает «мусор» как указатель — это и есть причина падения.

Строки в C

Простыми словами. Строка в Си — это массив символов, в конце которого стоит нулевой байт '\0'. Длина нигде не хранится: любая строковая функция идёт по байтам, пока не встретит этот ноль. Аналогия: '\0' — точка в конце предложения; если точку забыть, читатель не поймёт, где кончается текст, и побежит читать дальше по чужим страницам (это и есть выход за границы буфера). На экзамене это самая частая тема контрольных «без библиотечных функций». Альтернативный способ организации строк — хранить длину отдельно (как в C++ std::string или Pascal): он безопаснее, но в Си исторически выбрали вариант с '\0' ради совместимости с указателями и экономии памяти. Практический вывод: всегда используйте функции с явным ограничением длины (strncpy, snprintf, fgets), а не их «неограниченные» версии (strcpy, sprintf, gets).

Строка — массив char, завершающийся нулевым байтом '\0'. Длина не хранится: функции ищут '\0', поэтому забытый терминатор ведёт к чтению за границей.

#include <stdio.h>
#include <string.h>

int main(void) {
    // s1 — ИЗМЕНЯЕМАЯ копия литерала в локальном массиве;
    // sizeof(s1) включает '\0'. В UTF-8 каждая русская буква — 2 байта,
    // поэтому strlen вернёт число БАЙТ, а не число букв
    char s1[] = "мир";
    // s2 — только указатель на литерал, лежащий в памяти только для чтения
    char *s2 = "мир";

    printf("len=%zu sizeof=%zu lit=%zu\n",
           strlen(s1), sizeof s1, strlen(s2));   // 6 7 6 в UTF-8
    // Меняем ЛАТИНСКИЙ буфер: один байт = один символ, всё предсказуемо.
    // Для строки "мир" побайтовая правка сломала бы UTF-8 (буква = 2 байта)
    char ascii[] = "abc";
    ascii[0] = 'A';           // OK: меняем свою копию
    puts(ascii);              // Abc
    // s2[0] = 'X';           // UB: литерал изменять нельзя

    // snprintf гарантирует, что запись не выйдет за sizeof buf
    char buf[64] = "";        // пустая строка: первый байт уже '\0'
    snprintf(buf, sizeof buf, "%s + %s", "Си", "строки");
    puts(buf);
    return 0;
}

Строка в Си — это просто массив байтов, заканчивающийся нулём '\0'. Никакого отдельного «строкового» типа в языке нет: компилятор и стандартная библиотека договорились о соглашении — последний байт равен нулю, всё, что до него, — полезные символы. Из-за этого соглашения возникает большинство ошибок: забытый терминатор, нехватка места в буфере, неверный размер. Поэтому функции <string.h> нужно знать наизусть — какие гарантии они дают, а каких не дают. Полезная аналогия: строка в Си — это вагон с грузом, у которого последний вагон всегда пустой (вагон-терминатор); если вы забыли прицепить пустой вагон, поезд будет ехать, пока не встретит «случайный пустой вагон» в составе — то есть пока не прочитает мусор из чужой памяти.

Запомните: strlen возвращает size_t без терминатора; strcpy/strcat не проверяют размер; strcmp возвращает 0 при равенстве; strncpy может не поставить '\0'. Безопасные замены — snprintf, fgets, strncat с явным контролем.

Функция Что делает Главная ловушка
strlen(s) Длина без '\0' Не считает терминатор; при забытом '\0' читает мусор за массивом — типичный источник UB и невоспроизводимых багов
strcpy(d, s) Копирует вместе с '\0' Нет контроля размера d — переполнение буфера (классика CVE-багов и задач «переполнение стека»)
strcat(d, s) Дописывает в конец Нужно, чтобы в d уже был '\0'; итоговый размер не проверяется — результат может вылезти за буфер
strcmp(a, b) <0, 0, >0 Равенство — это ровно 0, а не «любое ненулевое»; в Си «правда» — это != 0, поэтому проверка равенства всегда == 0
strncpy(d, s, n) Копирует не более n Может не поставить '\0', если s длиннее n — тогда d не строка, и strlen(d) уйдёт в мусор
strchr(s, c) Первый c По стандарту находит и '\0' (возвращает указатель на конец строки) — этим пользуются для проверки «а есть ли символ вообще»
strstr(h, n) Первое вхождение подстроки Наивно медленно: O(n·m); для серьёзных задач используют KMP, Z-функцию или Ахо-Корасик

Разница между строкой и массивом символов — важный устный вопрос, и студенты на нём проваливаются чаще всего. Массив символов — это просто хранилище байтов: они лежат в памяти подряд, индексируются с 0, и компилятор ничего о «строковости» не знает. Если последний байт не равен '\0', это не строка, а буфер — и strlen, printf %s, strcpy будут читать дальше, пока не встретят ноль где-то в чужой памяти. Строка — это массив, гарантированно завершённый '\0', поэтому с ней безопасно работают строковые функции. Аналогия: массив символов — это блокнот с буквами, в котором может не быть точки в конце предложения; строка — это блокнот, где последний символ обязательно точка, и «читатель» останавливается на ней. Ещё одно отличие — про размер. sizeof отвечает на вопрос «сколько байт компилятор отвёл под буфер?» и известен на этапе компиляции. strlen отвечает на вопрос «где сейчас находится терминатор?» — это число вычисляется во время выполнения проходом по массиву. Для строкового литерала "abc" эти величины различаются на единицу: sizeof("abc") = 4 (три буквы + терминатор), strlen("abc") = 3 (только буквы). Эту пару обожают и на собеседованиях, и на устных экзаменах — ответ «обе дают длину строки» сразу выдаёт непонимание модели памяти Си.

Функции: передача по значению и по указателю

Простыми словами. Аргумент — это значение, которое функция получает при вызове. В Си действует единственный механизм: передача по значению (pass-by-value), то есть функция работает с копией, а не с оригиналом. Аналогия: вы снимаете ксерокопию с договора и правите её сколько угодно — оригинал в это время лежит в сейфе и не меняется. Чтобы функция всё-таки изменила переменную вызывающего, ей передают адрес этой переменной (указатель) — тогда она работает не с копией, а с самим «оригиналом» через указатель на него. Это первый вопрос к любой задаче «поменять местами / заполнить массив / вернуть два значения», так что формулировку «всё передаётся по значению, изменение — только через указатель» нужно помнить как аксиому. И даже передача «массива по указателю» — это на самом деле та же передача по значению: копируется указатель на первый элемент, а не сам массив.

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

#include <stdio.h>

void swap_bad(int a, int b) { int t = a; a = b; b = t; }   // меняет КОПИИ — бесполезно
void swap(int *a, int *b)   { int t = *a; *a = *b; *b = t; } // меняет ОРИГИНАЛЫ через адреса

// Рекурсия: база n < 2 (иначе бесконечность), затем сведение к двум меньшим задачам
// Минус: fib(40) делает ~2^40 вызовов — экспоненциально медленно
int fib(int n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}

int main(void) {
    int x = 1, y = 2;
    swap_bad(x, y);                           // передали значения — x и y не изменились
    printf("%d %d\n", x, y);                  // 1 2
    swap(&x, &y);                             // передали адреса — функция пишет по ним
    printf("%d %d\n", x, y);                  // 2 1
    printf("fib(10) = %d\n", fib(10));        // 55
    return 0;
}
  • Прототип функции — это её объявление без тела (void swap(int*, int*);). Он сообщает компилятору сигнатуру до определения, чтобы тот мог проверить типы аргументов при любом порядке функций в файле. Без прототипа старый компилятор (С89) молча примет любые аргументы — и баг найдётся только в рантайме, а не при сборке.
  • Возвращать указатель на локальную переменную нельзя — это классический висячий указатель (dangling pointer): память кадра освобождается сразу после return, а указатель остаётся «в никуда». Пример-ошибка: int* f() { int x = 1; return &x; } — &x указывает на уже мёртвую память, и чтение *p снаружи — UB. Если локальная переменная нужна «снаружи», её выносят в static (живёт до конца программы), в кучу (malloc, освобождать через free) или передают указатель на неё извне.
  • Рекурсия требует двух вещей: базы (условия остановки, иначе бесконечность) и сходящегося шага (параметр приближается к базе с каждым вызовом). Классический вопрос билета: «какие две вещи обязательны для рекурсии?» — ответ: база и приближение к ней. Если забыть любое из них, программа либо зациклится, либо получит переполнение стека.
  • Глубокая рекурсия переполняет стек (типично ~10⁴–10⁵ кадров на процесс; зависит от ОС и размера кадра). Поэтому fib(40) наивной реализации даёт ~2³⁹ вызовов и считается секунды — здесь помогает либо мемоизация (сохранение уже посчитанных значений), либо прямой переход к циклу.
  • Любая рекурсия переписывается циклом; хвостовую рекурсию (когда рекурсивный вызов — последняя операция в функции) оптимизатор может превратить в цикл, экономя стек. Рекурсия короче и выразительнее (деревья, обходы графов, разбор выражений, quicksort), но итерация экономит стек и обычно быстрее за счёт отсутствия накладных расходов на вызов.
  • Мнемоника «рекурсия — матрёшка»: чтобы достать самую маленькую куклу, нужно открывать по одной и помнить, где вы сейчас; база — это последняя, неоткрываемая кукла; без неё матрёшка бесконечна, и стек «матрёшек» растёт до переполнения.
  • Вернуть сразу несколько значений в Си «штатным» return нельзя — return один и возвращает одно значение. Поэтому применяют либо выходные параметры через указатели (void get(int *a, int *b)), либо структуру (struct Pair get(void)). Второй способ читается лучше и рекомендуется в современном C: имя поля само подсказывает, что где лежит, и нет соблазна передать NULL.

Указатели на функции

Простыми словами. Указатель на функцию — это переменная, в которой хранится адрес машинного кода функции. Через неё можно вызвать функцию, не зная заранее, какая именно это функция: сегодня это сложение, завтра — умножение. Аналогия: это «номер телефона» функции — номер можно положить в переменную и «позвонить» по нему, не зная заранее, кто именно ответит. На указателях на функции держатся qsort/bsearch с callback, таблицы переходов в интерпретаторах и state-машинах, обработчики событий в GUI и сетевом коде, поэтому вопрос про них встречается и в билетах, и в задачах на защиту. Главное, что нужно помнить: тип указателя включает в себя сигнатуру функции (типы и число аргументов + тип возврата) — иначе вызов через указатель другого типа даёт UB.

#include <stdio.h>
#include <stdlib.h>

int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

int cmp(const void *a, const void *b) {       // сигнатура, которую ждёт qsort
    // qsort передаёт адреса элементов, поэтому приводим void* к int*
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);                 // -1/0/+1 без переполнения, в отличие от x - y
}

int main(void) {
    // Объявление указателя на функцию: op — указатель на функцию int(int, int).
    // Скобки вокруг *op обязательны: int *op(int,int) — это функция, возвращающая int*
    int (*op)(int, int) = add;
    printf("%d\n", op(3, 4));                 // 7 — вызов через указатель
    op = mul;                                 // переключили поведение
    printf("%d\n", op(3, 4));                 // 12

    int arr[] = {5, 1, 4, 2, 3};
    qsort(arr, 5, sizeof arr[0], cmp);        // передаём функцию как аргумент (callback)
    for (size_t i = 0; i < 5; i++) printf("%d ", arr[i]);
    putchar('\n');
    return 0;
}

Тип int (*)(int, int) читается так: «указатель на функцию, принимающую два int и возвращающую int». Скобки вокруг * обязательны: int *f(int, int) — это уже функция, возвращающая int*. Правило простое: видите имя в скобках рядом со звёздочкой — это указатель; скобок нет — функция. Этот же тип int (*)(int,int) можно использовать как параметр (void apply(int (*op)(int,int), int x, int y)), как поле структуры и как элемент массива. Массив указателей на функции — основа таблиц переходов, например калькулятора из раздела «Польская запись»: вместо длинного switch держат int (*ops[])(int,int) = {add, sub, mul, div}; и вызывают ops[i](a,b). Именно такое решение часто просят «улучшить» на защите: оно короче, легче расширяется новой операцией (просто добавить элемент в массив) и не зависит от цепочки if-else.

Типичная ошибка. Забыть скобки и написать int *op(int, int) вместо int (*op)(int, int): компилятор объявит функцию, возвращающую указатель, а не переменную-указатель, и код либо не соберётся, либо соберётся не то, что задумано. Второй источник ошибок — вызов: (*op)(a,b) и op(a,b) оба корректны и полностью равнозначны (указатель на функцию автоматически разыменовывается, как и (*p).x vs p->x), но op(a,b) читается проще, и на экзамене лучше выбирать именно его. Третья частая ошибка — лишний & при передаче функции в qsort или собственную функцию высшего порядка: qsort(arr, n, sz, cmp) правильно, а qsort(arr, n, sz, &cmp) скомпилируется из-за автопреобразования функция→указатель, но писать & для функций не нужно и считается дурным тоном (за исключением взятия адреса для хранения в массиве указателей, где это тоже необязательно).

Структуры, объединения, битовые поля, typedef, enum

Простыми словами. Это четыре разных «способа группировки данных» в Си, и у каждого своя область применения. Разберём на одной аналогии — «карточка на полке»:

  • struct — анкета из нескольких именованных полей разного типа: как карточка студента (имя, курс, средний балл, группа) — все поля лежат в памяти рядом, друг за другом, и обращаться к ним можно по имени. Размер struct — это сумма размеров полей (плюс возможное выравнивание).
  • union — стакан, в который в разное время наливают либо воду, либо сок, либо чай: один и тот же участок памяти интерпретируется как один из нескольких типов. Размер union — это размер самого большого поля, а не сумма. Экономит память, но требует от программиста помнить, что именно сейчас лежит в union, — иначе это UB.
  • Битовые поля — микро-ячейки внутри unsigned int, где каждому полю выделено ровно заданное число бит (1, 3, 4…). Удобно для упаковки флагов и регистров периферии (например, статусы устройств в ядре ОС).
  • enum — набор именованных целочисленных констант вместо «магических чисел» в коде (COLOR_RED вместо 0, COLOR_GREEN вместо 1). Компилятор сам присваивает значения по порядку, но любой элемент можно задать явно.

Эти четыре конструкции нужны и в структурах данных (узел списка, вершина дерева, запись таблицы), и в системном коде (флаги регистров, дескрипторы ОС), поэтому спрашивают их постоянно. Если ответ на устном вопросе «чем отличается struct от union» звучит как «обе группируют данные» — это уже плохо; правильный ответ: «struct хранит все поля одновременно и занимает сумму их размеров, а union хранит только одно из них, перекрывая остальные в одной памяти и занимая место по размеру самого большого поля».

#include <stdio.h>
#include <stddef.h>   // offsetof

typedef struct Point { int x; int y; } Point;      // typedef даёт короткое имя типа

struct Flags {                                     // битовые поля: упаковка в биты
    unsigned ready : 1;                            // 1 бит — флаг «готов»
    unsigned mode  : 3;                            // 3 бита — значение 0..7
    unsigned       : 4;                            // анонимный padding, до байта
};

union Value { int i; float f; unsigned char b[4]; };  // все поля делят одну память

enum Color { RED, GREEN = 5, BLUE };               // RED=0, GREEN=5, BLUE=6

int main(void) {
    Point p = {.y = 2, .x = 1};                    // designated initializers (C99): порядок не важен
    Point *pp = &p;                                // указатель на структуру
    // Доступ через -> это сокращение для (*pp).x; offsetof даёт смещение поля в байтах
    printf("(%d,%d) offset_y=%zu\n", pp->x, pp->y, offsetof(Point, y));

    struct Flags fl = {.ready = 1, .mode = 5};
    printf("%u %u sizeof=%zu\n", fl.ready, fl.mode, sizeof fl);

    union Value v;
    v.f = 1.0f;                                    // записали float...
    // ...и читаем те же 4 байта как int: это не преобразование, а переинтерпретация битов
    printf("union: %08x, размер %zu BLUE=%d\n", v.i, sizeof v, (int)BLUE);
    return 0;
}

Составные типы в Си: что это. struct, union и enum — три способа сгруппировать данные. struct — как заполненная анкета: каждое поле лежит в своём слоте. union — как карман, в котором в разное время хранится то монета, то ключ (положили новое — старое перезаписалось). enum — нумерованный список с человеко-читаемыми именами. Преподаватели МИФИ, МГУ и МФТИ любят эту тройку за простоту и большое количество подводных камней.

  • struct хранит поля последовательно с выравниванием (padding), union — внахлёст: размер union равен размеру самого большого поля, потому что все поля начинаются с одного адреса. Аналогия: struct — это ящик стола с отделениями (каждое поле — отдельная ячейка); union — это одна ячейка, в которой сейчас лежит либо молоток, либо отвёртка. Поэтому читать «неактивное» поле union — частая причина ошибок: компилятор ничего не заметит, но семантика данных будет сломана.
  • Выравнивание (padding) — это «пустые» байты между полями, чтобы каждое поле начиналось с адреса, кратного его размеру. Почему так: процессор за один такт читает из памяти машинное слово (обычно 4 или 8 байт); если int попадает «по кривой» адресу, нужно два обращения к шине — вдвое медленнее. Компилятор сам вставляет выравнивание, чтобы код был быстрее. Пример: struct { char c; int i; } занимает 8 байт, а не 5: после c компилятор вставил 3 байта выравнивания. Порядок полей по убыванию размера даёт минимум padding — это стандартный вопрос «как сэкономить память структуры». Полезный приём: с помощью _Alignof/alignof или offsetof можно увидеть, где стоят «дыры», и при необходимости переупорядочить поля. В сетевых протоколах и форматах файлов padding мешает — там либо вручную упаковывают, либо используют #pragma pack.
  • Битовые поля экономят память, но их раскладка зависит от реализации, и адрес поля взять нельзя; переносимость таких структур между компиляторами не гарантирована. Что это: unsigned int flag : 1; означает «отвести под flag ровно один бит». Используют для экономии памяти, когда объектов очень много (миллионы флагов), или в драйверах устройств, где каждому регистру отводится своё битовое поле. Почему осторожно: стандарт Си не фиксирует, с какой стороны биты растут и где начинается следующее поле — это решает реализация. Поэтому битовые поля нельзя использовать в сетевых протоколах и форматах файлов без #pragma pack или явной спецификации, иначе код поедет на другой платформе. Также нельзя взять адрес битового поля (&f.flag) — формально запрещено стандартом.
  • enum — целочисленные именованные константы, удобны в switch: компилятор предупредит, если вы забыли обработать какое-то значение. По умолчанию нумерация начинается с нуля и увеличивается на единицу, но можно задать значения явно (GREEN = 5). Аналогия: вместо «магических» чисел 0/1/2/3 вы пишете осмысленные названия — код становится понятным, а компилятор помогает найти забытые ветки. В Си (в отличие от C++) enum — это просто int; размер не фиксирован, а базовый тип определяется реализацией. Если нужен строго 32-битный тип — берут uint32_t из <stdint.h>.
  • typedef не создаёт новый тип, а лишь даёт синоним: typedef struct Point Point; избавляет от необходимости писать слово struct в каждом объявлении. Это стилистическое, но важное улучшение читаемости, которое ждут в курсовой. Почему в Си это проблема: структуры и обычные имена живут в разных «пространствах имён» — нельзя написать Point p;, нужно struct Point p;. typedef снимает эту неловкость: typedef struct { int x, y; } Point; — и дальше Point работает как обычный тип. Распространённая ошибка студентов — спрятать структуру через typedef и потерять прозрачность; в учебных проектах преподаватели часто просят, чтобы тег и typedef-имя различались (например, struct Point_ + typedef struct Point_ Point;).
  • union + enum = «размеченное объединение» (tagged union): поле-тег хранит, какой вариант сейчас активен. Так реализуют, например, тип «значение» — либо int, либо double, либо строка. Простыми словами: это «коробка с ярлыком». Без enum-тега вы не знаете, что внутри, и рискуете прочитать биты int как double — типичная ошибка, выглядящая как «странный» вывод программы. В Rust/Haskell подобная конструкция встроена в язык (enum со связанными данными); в Си её собирают вручную — за это и любят давать на экзамене. На собеседованиях просят написать простой tagged union для «узла AST» или «варианта ответа сервера».

Динамическая память

Простыми словами. Куча — это «склад» памяти, которым программа распоряжается вручную: malloc — «арендовать ячейку нужного размера», free — «вернуть ключ от ячейки». Куча нужна, когда размер данных неизвестен заранее или объект должен жить дольше функции. Аналогия: стек — это полка у рабочего стола, которая сама убирается при выходе из функции; куча — арендованный склад, который надо освобождать самому, иначе он забьётся (утечка памяти). На экзамене почти всегда просят написать функции без утечек — это проверка аккуратности.

Когда без кучи не обойтись. Размер становится известен только в рантайме (например, прочитали n из файла), объект должен пережить функцию (возвращаем int* из фабрики), нужен массив, растущий по мере добавления элементов. В остальных случаях хватает автоматической памяти стека — она быстрее и не требует free. Хорошее правило: если можно обойтись VLA или массивом фиксированного размера — обходитесь без кучи.

Четыре функции: malloc, calloc, realloc, free. Запомните их как «MCRF»: M-выделить, C-выделить-с-нулями, R-перевыделить, F-освободить.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t n = 5;
    // malloc выделяет n * sizeof(int) байт; записи sizeof *a достаточно,
    // и при смене типа a выражение менять не придётся — это идиома C
    int *a = malloc(n * sizeof *a);
    if (!a) { perror("malloc"); return 1; }       // проверять результат ВСЕГДА

    int *z = calloc(n, sizeof *z);                // calloc обнуляет память
    if (!z) { free(a); return 1; }                // не забываем освободить уже выделенное

    // realloc может переехать в другое место и вернуть новый адрес.
    // НЕЛЬЗЯ писать a = realloc(a, ...): при неудаче потеряется старый указатель
    int *tmp = realloc(a, 10 * sizeof *a);
    if (!tmp) { free(z); free(a); return 1; }     // при неудаче a по-прежнему действителен
    a = tmp;

    for (size_t i = 0; i < 10; i++) a[i] = (int)i;
    printf("%d %d\n", a[9], z[0]);                // 9 0 (calloc обнулил)

    free(z);
    free(a);
    a = NULL;                                     // защита от double free
    return 0;
}

Разбор кода по строкам. Здесь собраны сразу три ситуации — успех, частичный успех и обработка ошибок. Обратите внимание на три вещи:

  1. sizeof *a вместо sizeof(int) — идиома «устойчивости к смене типа»: если завтра a станет double*, не придётся править формулу. Это любимый приём преподавателей и ревьюеров.
  2. Проверка каждого возвращаемого указателя на NULL. В учебных проектах можно опустить, в продакшн-коде — обязательно. Альтернатива — exit(EXIT_FAILURE) вместо return 1.
  3. При ошибке realloc мы освобождаем уже выделенное (a остался валидным), а затем — z. Это пример «симметрии аллокаций»: сколько выделили — столько же освободили, даже если поймали ошибку на полпути.

Таблица функций. Запомните минимум: что выделяет, инициализирует ли, что делает при ошибке.

Функция Что делает Особенность
malloc(n) Выделяет n байт сырой (неинициализированной) памяти; содержимое — «мусор» Возвращает NULL при нехватке памяти; проверять обязательно
calloc(n, s) Выделяет массив из n элементов по s байт и обнуляет всю выделенную память Безопаснее для числовых массивов; иногда медленнее malloc из-за зануления
realloc(p, n) Меняет размер ранее выделенного блока p до n байт (увеличивает или уменьшает) Адрес может измениться (данные копируются в новый блок, старый освобождается); при неудаче возвращает NULL, старый p остаётся валидным — именно поэтому нужен временный указатель
free(p) Освобождает блок памяти, ранее выделенный malloc/calloc/realloc free(NULL) по стандарту безопасен (no-op); повторный free(p) или free от указателя не из кучи — UB

Типичные ошибки: утечка (нет free), use-after-free, двойное освобождение, потеря указателя при realloc, обращение за границей, чтение неинициализированных данных. Подробнее по каждой:

  • Утечка памяти — выделили malloc, но забыли free или потеряли указатель. Программа работает, но процесс «ест» память. Ловится valgrind --leak-check=full ./prog или -fsanitize=address.
  • Use-after-free — обратились к памяти после free. Может вернуть вроде бы правильные данные (память ещё не занята), а может — мусор или SIGSEGV.
  • Двойной free — free(p) дважды, обычно когда два указателя указывают на один блок. Защита — после free сразу p = NULL; (повторный free(NULL) безопасен).
  • Потеря при realloc — a = realloc(a, ...) без временной переменной. При неудаче realloc вернёт NULL, и старая память «потеряется» вместе с исходным указателем. Решение — int *t = realloc(a, n); if (!t) { /* старый a валиден */ } else a = t;.
  • Обращение за границей — a[i] при i >= n. C не проверяет границы автоматически; ищите через санитайзеры.
  • Чтение неинициализированных данных — malloc не зануляет, поэтому int *a = malloc(...); a[0]++; — это чтение мусора. Лечится calloc или memset(a, 0, n*sizeof *a).

Как объяснить таблицу на экзамене. malloc и calloc отличаются только инициализацией: calloc обнуляет и удобен, когда нужен «чистый» массив; malloc быстрее (не тратит время на зануление), но данные в нём мусорные. realloc — единственная функция, которая может переместить блок: если после него нет свободного места, он выделит новый участок, скопирует данные и освободит старый, вернув новый адрес. Отсюда правило: сохраняйте результат во временную переменную и присваивайте исходной только после проверки на NULL. free(NULL) безопасен по стандарту, а вот free указателя, полученного не из malloc/calloc/realloc, или повторный free — UB.

Мини-история про realloc. Представьте, что вы арендовали склад (malloc). Приехало больше товара — вы просите realloc «сделай склад больше». Менеджер смотрит: позади соседний склад свободен? Если да — достраивает на месте. Если нет — находит новое большое здание, перевозит товар, сдаёт старый склад и возвращает вам ключ от нового. Ключевой момент: если переезд не удался (например, нет большого здания), менеджер не трогает ваш старый склад и возвращает пустой ключ — поэтому временная переменная критична.

Проверка себя: сколько free должно быть на каждое «выделение»? Правило симметрии «сколько malloc — столько free» (с поправкой на realloc и передачи владения в другую функцию) — это то, что проверяют утечки через Valgrind и AddressSanitizer. На экзамене назовите флаг -fsanitize=address — это покажет, что вы знакомы с практикой, а не только с теорией. Практический совет: запустите свою курсовую с -fsanitize=address -g — он покажет каждое UB и утечку с точностью до строки. Это быстрее, чем valgrind, и работает без отдельной установки.

Файлы: текстовые и бинарные

Простыми словами. Работа с файлом идёт через «поток» FILE * — это канал между программой и файлом на диске. Аналогия: fopen — открыть кран, fclose — закрыть его; пока кран не закрыт, часть воды (данных) остаётся в шланге-буфере и может не дойти до диска. Поэтому забытый fclose — это не только утечка дескриптора, но и потеря последних записанных строк. На экзамене это классическая задача на курсовой: «сохранить и прочитать структуру из файла».

Работа идёт через FILE *. Поток буферизуется, поэтому fclose (или fflush) обязателен — иначе данные не попадут на диск. Под буферизацией понимается, что fprintf пишет не на диск напрямую, а в область памяти stdio («буфер»); при fclose/fflush буфер выталкивается на диск одним системным вызовом.

#include <stdio.h>

int main(void) {
    // Текстовый режим
    FILE *out = fopen("out.txt", "w");            // "w" создаёт/обрезает
    if (!out) { perror("fopen"); return 1; }
    fprintf(out, "строка %d\n", 1);
    fclose(out);

    FILE *in = fopen("out.txt", "r");
    if (!in) return 1;
    char line[128];
    while (fgets(line, sizeof line, in)) fputs(line, stdout);
    if (ferror(in)) fprintf(stderr, "ошибка чтения\n");
    fclose(in);

    // Бинарный режим
    int arr[3] = {1, 2, 3};
    FILE *bf = fopen("data.bin", "wb");
    if (!bf) return 1;
    size_t wrote = fwrite(arr, sizeof arr[0], 3, bf);   // число ЭЛЕМЕНТОВ
    fclose(bf);

    FILE *bg = fopen("data.bin", "rb");
    if (!bg) return 1;
    int back[3] = {0};
    fread(back, sizeof back[0], 3, bg);
    fseek(bg, 0, SEEK_END);                       // переход в конец
    long size = ftell(bg);                        // размер файла
    rewind(bg);                                   // назад в начало
    fclose(bg);
    printf("wrote=%zu size=%ld back[2]=%d\n", wrote, size, back[2]);
    return 0;
}

Что здесь важно. fwrite и fread принимают размер элемента и количество элементов — благодаря этому возвращают «сколько удалось», и можно отличить частичное чтение от полного. В бинарном режиме сохраняется ровно то, что в памяти (для int — 4 байта подряд), без перевода строк. На каждом fopen висит обязанность проверить результат и затем сделать ровно один fclose — это и есть правило симметрии, как с malloc/free.

Режим Смысл
"r" / "w" / "a" Чтение существующего файла / запись с обрезкой (или созданием) / дозапись в конец
"r+" / "w+" / "a+" Файл открыт и для чтения, и для записи (без + — только одно из двух)
"rb" / "wb" То же в бинарном режиме: \n не превращается в \r\n на Windows — критично для побайтовой работы

feof сообщает о конце файла после неудачного чтения, поэтому цикл строят на fgets/fread, а feof проверяют затем. Это очень частая ошибка: while (!feof(f)) { fgets(...); } обработает последнюю строку дважды, потому что флаг выставится только после попытки чтения за концом файла. Правильно: while (fgets(line, sizeof line, f)) { ... }.

Почему именно while (fgets(...)), а не while (!feof(f)). feof — это «ретроспективный» флаг: он взводится только когда предыдущая операция чтения не смогла прочитать байт и была именно из-за конца файла. Цикл «проверь feof ДО чтения» успеет последнюю успешную итерацию плюс ещё одну лишнюю (где fgets вернёт NULL, но вы уже не спросили). Идиома «проверить возвращаемое значение самой функции чтения» работает для fgets, fread, fscanf, getc и т. д. То же правило — в C++: while (std::getline(...)) — по той же причине.

Позиция задаётся через fseek(f, offset, SEEK_SET|SEEK_CUR|SEEK_END), текущее смещение читает ftell, а rewind возвращает в начало. Удаление и переименование — remove, rename. Текст ошибки по глобальной переменной errno печатает perror, а расшифровку даёт strerror(errno). Поскольку errno обнуляется не всеми функциями, надёжнее проверять возвращаемое значение, а errno смотреть только после явной ошибки. Когда ftell/fseek дают сюрпризы: для файлов, открытых в текстовом режиме, SEEK_END на Windows ведёт себя не так, как кажется: \n хранится на диске как два байта (\r\n), а в программе — один. Из-за этого ftell возвращает «логическую» позицию, отличающуюся от байтового смещения. Для честной позиции в текстовом файле используйте fgetpos/fsetpos с типом fpos_t либо сразу открывайте в бинарном режиме.

Текстовый или бинарный — как выбрать. Текстовый режим нужен для файлов, которые читает человек: конфигов, логов, CSV — там удобны fprintf/fgets. Бинарный режим ("rb"/"wb") сохраняет байты «как есть»: применяют для структур, изображений, протоколов. На Windows разница принципиальна: в текстовом режиме \n превращается в \r\n, что ломает побайтовое чтение. Универсальное правило: если данные должны остаться байт-в-байт — открывайте с b. На Linux/macOS режим «b» ничего не меняет, поэтому для переносимого кода безопаснее всегда писать b явно — привычка выручает на сдаче у преподавателя с Windows-машиной.

Теория алгоритмов (МГУ, курс «Алгоритмы и алгоритмические языки»)

Теоретический блок проверяют на коллоквиумах и в письменной части. Ниже — минимальный набор определений и результатов, который нужно уметь сформулировать своими словами. Правило ответа на теоретический вопрос: сначала определение, затем интуиция, затем формальная деталь, затем пример. Если забыли точную формулу функции переходов МТ, спасти ответ можно словами: «лента, головка, конечное управление» — это уже половина балла. Остальное (эквивалентность моделей и неразрешимость) выводится из определений, поэтому учите именно определения.

Зачем вообще нужен этот раздел. Это фундамент, на котором стоит весь курс: понятие «алгоритм» формализуется через машину Тьюринга, её вариации (НАМ) и мета-теории (тезис Чёрча–Тьюринга). Без этих определений нельзя понять, что такое «разрешимая задача», «NP-полнота» или «компилятор». Если вы освоите этот блок — дальнейшие темы (алгоритмы сортировки, графовые алгоритмы, синтаксический анализ) лягут гораздо легче, потому что вы будете понимать, что вообще значит «можно/нельзя вычислить».

Как готовиться к коллоквиуму — практический совет. Прочитайте определение, закройте шпаргалку и попробуйте сформулировать его устно вслух. Затем нарисуйте пример «руками» на бумаге (конфигурации МТ, шаги НАМ). На коллоквиуме 60 % успеха — это умение привести конкретный маленький пример и показать на нём работу модели: преподаватели любят спрашивать «а покажите, как ваша машина складывает 1+1».

Машина Тьюринга

Простыми словами. Машина Тьюринга — это идеализированный «робот с бесконечной бумажной лентой»: он смотрит на одну клетку, по таблице правил решает, что записать и куда сдвинуться, и так работает, пока не остановится. Это простейшая формальная модель, которой достаточно, чтобы описать любое вычисление, — поэтому именно на ней определяют понятие «вычислимая функция». Аналогия: МТ — это пишущая машинка с программой из «если видишь символ X — замени на Y и шагни вправо»; ничего умнее не нужно, чтобы смоделировать любую программу.

Ещё одна бытовая аналогия. Представьте себе кассу в магазине: лента — это касса, головка — это «глаза» кассира, программа — это инструкция, которую дали кассиру («если видишь яблоко — посчитай его и сдвинь ленту вправо»). Кассир работает, пока покупатели не закончатся или пока не дойдёт до «стоп-сигнала». Так же и МТ — она «тупая, но упорная»: шаг за шагом выполняет правила, и эта «тупость» гарантирует, что никакую вычислимую задачу мы не упустим.

Почему именно лента, а не оперативная память. Лента даёт два удобства: (1) адресация бесконечная — нам не нужно заранее думать, хватит ли памяти; (2) доступ строго последовательный, без случайного доступа — это «убирает инженерные детали» и оставляет только математическую суть алгоритма. Если бы МТ работала с оперативной памятью, мы бы обсуждали размер кэша и прочую аппаратную конкретику, а это нам не нужно для определения «алгоритм».

Машина Тьюринга (МТ) — формальная модель: M = (Q, Σ, Γ, δ, q₀, q_accept, q_reject), где Q — состояния, Σ — входной алфавит, Γ ⊇ Σ — ленточный алфавит с пустым символом ⊔, δ: Q × Γ → Q × Γ × {L, R, N} — функция переходов, q₀ — начальное состояние. Формулу стоит разобрать по частям: δ берёт пару (состояние, символ под головкой) и возвращает тройку (новое состояние, символ для записи, направление сдвига).

  • Лента неограниченна в обе стороны, головка читает и пишет один символ за такт и сдвигается влево/вправо. Бесконечность ленты — это допущение «памяти хватит сколько угодно», а не реальное устройство. Почему это важно: реальная память ограничена, но для определения «алгоритм» мы хотим убрать ограничения и оставить только идею — если алгоритму нужно «больше памяти», он её «добудет», сдвинувшись по ленте. Так появляется возможность говорить о вычислимости «в принципе», без оглядки на железо.
  • Конфигурация — полное описание текущего момента: состояние + содержимое ленты + позиция головки. Работа МТ — это последовательность конфигураций; именно конфигурациями удобно доказывать, что машина делает «то же самое». Зачем это нужно: преподаватели любят вопрос «что значит, что две машины эквивалентны?» Ответ — у них совпадают множества принимаемых слов; доказывают это обычно через индукцию по конфигурациям: «если у них одинаковая текущая конфигурация, то после шага снова одинаковая». Запомните слово «конфигурация» — оно ключевое.
  • МТ разрешает язык, если останавливается на всех входах, принимая слова языка и отвергая остальные; полуразрешает, если останавливается только на принимаемых, а на «чужих» может зацикливаться. Разница между «разрешить» и «полуразрешить» — излюбленный вопрос коллоквиума. Как запомнить: «разрешить» = всегда есть ответ «да/нет» (типа суда: «виновен / невиновен»); «полуразрешить» = суд скажет «виновен», но если не виновен — может просто молчать до бесконечности. Полуразрешимость формально называется «рекурсивно перечислимое множество» — это важный термин для экзамена.
  • Универсальная МТ (УМТ) моделирует любую другую МТ по её описанию — это идея интерпретатора и программируемости: одна машина может выполнить любую программу, если та записана на ленту. Именно из УМТ вырастает понятие «программа как данные» и, в конечном счёте, идея компьютера с хранимой программой (архитектура фон Неймана). Почему это революция: до УМТ каждая МТ умела делать только одно дело — складывать числа или проверять чётность. УМТ же умеет делать любое дело, если ей дать описание нужной МТ в виде кода. Это и есть фундаментальная идея «программа как данные» и рождения современных компьютеров.
  • Пример для экзамена: МТ, прибавляющая единицу к числу в унарном коде (последовательность единиц). Идём вправо до пустого символа, заменяем его на 1 и останавливаемся. Проговорив такой пример, вы покажете, что понимаете модель, а не только определение. Пошагово, что говорить преподавателю: «Лента содержит 111⊔.... Стартуем с головки на первой единице. По правилу δ(q₀, 1) = (q₀, 1, R) идём вправо, оставляя единицы как есть. Дойдя до ⊔, выполняем δ(q₀, ⊔) = (q_accept, 1, N) — записываем 1 и останавливаемся. На ленте теперь 1111 — то есть число на 1 больше».

Нормальные алгоритмы Маркова

Простыми словами. Нормальный алгоритм Маркова — это модель вычислений через переписывание строк: есть упорядоченный список правил «найти такой-то фрагмент — заменить на такой-то», и машина применяет их, пока может. Аналогия: это правила автозамены в текстовом редакторе — редактор всегда берёт первое подходящее правило и заменяет самое левое совпадение, а когда ни одно правило не подходит, останавливается. Модель важна тем, что доказывает: для вычислений не обязательна лента — достаточно операций над строками.

Ещё одна аналогия для запоминания. Представьте, что у вас есть текст на доске и стиратель. У вас есть список команд: «если видишь слово "кошка" — замени на "собака"». Вы идёте по тексту слева направо, находите самое первое подходящее место и заменяете. Команды просматриваются сверху вниз по списку. Когда ни одна команда не подходит — вы останавливаетесь. Это и есть НАМ в миниатюре.

Почему именно «нормальный». Название отражает, что стратегия применения правил зафиксирована однозначно (всегда берём первое правило, всегда заменяем самое левое). Это убирает свободу выбора и делает модель детерминированной. У «нормальных» алгоритмов нет «развилок» — за счёт этого они проще в анализе, и легче доказать эквивалентность с МТ.

Нормальный алгоритм Маркова (НАМ) — модель переписывания строк: конечный упорядоченный набор правил подстановки α → β (или α → ·β — заключительное, после которого работа немедленно прекращается).

  • На каждом шаге берётся первое применимое правило (порядок правил — часть алгоритма, а не деталь) и самое левое вхождение образца заменяется. Отсюда название «нормальный»: правило применения фиксировано однозначно. Ловушка, на которой «горит» студент: студент говорит «алгоритм ищет любое подходящее место». Это неверно — алгоритм всегда выбирает самое левое вхождение. Если в строке abab правило ab → c, то результат — cab, а не abc (как бывает, если «выбирать любое»).
  • Алгоритм останавливается, когда нет применимых правил или выполнена заключительная подстановка. Результат — строка, получившаяся на момент остановки. Важно: результат берётся в момент остановки, а не «после ещё одного шага». Это частая ошибка — студент пишет «после остановки ещё раз применяю правила». Не надо — НАМ останавливается, и финальная строка становится ответом.
  • НАМ и МТ эквивалентны по вычислительной мощности: всё, что вычислимо на одной модели, вычислимо и на другой. Это важная часть тезиса Чёрча–Тьюринга, и на коллоквиуме просят подтвердить её примером (например, сложение унарных чисел правилами НАМ). Пример-«спасалочка»: сложение |+| даёт ||+. Правила: +1 → 1+, |+ → ·|+. Сначала + «добирается» вправо через все единицы (+|+ → 1|+1 → 11|+), затем «заедает» плюс справа (11+| + → 11||+), и заключительное правило останавливает процесс. Этот пример показывает, что НАМ может имитировать «перенос» без явных регистров.
  • Типичная ошибка в ответе — путать «первое правило» с «любым подходящим» и забывать про «самое левое вхождение». Если отвечать расплывчато, преподаватель предложит применить правила вручную на конкретной строке — здесь неверный порядок сразу виден. Практический совет: на коллоквиуме тренируйтесь проговаривать «я беру правило номер 1, проверяю, есть ли самое левое вхождение; если нет — беру правило номер 2» — это показывает, что вы понимаете механику, а не только определение.

Тезис Тьюринга–Черча и проблема останова

Простыми словами. Тезис Чёрча–Тьюринга — это соглашение о том, что «вычислимо» = «вычислимо на машине Тьюринга»; строго доказать его нельзя, но все разумные модели вычислений (НАМ, λ-исчисление, рекурсивные функции) оказались эквивалентны. Проблема останова — знаменитое следствие: нельзя написать программу, которая для любой другой программы честно скажет, остановится та или нет. Аналогия: проблема останова — это «предсказатель будущего»; если бы он существовал, можно было бы построить программу, которая делает противоположное его предсказанию, — и предсказатель противоречил бы сам себе.

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

Почему это слово «тезис», а не «теорема». Теорема — это строго доказанное утверждение. Тезис — это соглашение, которое нельзя доказать в принципе, потому что оно про «интуитивное» понятие (что такое алгоритм). Тезис говорит: «все наши интуитивные алгоритмы — это в точности то, что умеет МТ». Доказать это нельзя, потому что не определено формально, что такое «интуитивный алгоритм». Зато можно косвенно подтвердить: какую бы модель ни изобретали (НАМ, λ-исчисление, регистровые машины, машина Поста) — все они оказываются эквивалентны МТ. Это коллективное подтверждение и есть «доказательство» тезиса.

Тезис Чёрча–Тьюринга — интуитивное понятие «вычислимая функция» совпадает с вычислимостью на МТ (и в любой эквивалентной модели: НАМ, рекурсивные функции, λ-исчисление). Это не теорема, а соглашение о границах формализации: оно фиксирует, что мы считаем алгоритмом, и не позволяет «изобрести» более мощную модель вычислений. На экзамене обязательно подчеркните слово «тезис» — за фразу «теорема Чёрча–Тьюринга» могут снизить оценку. Ловушка для студента: студент на коллоквиуме говорит «это доказанная теорема». Преподаватель сразу поправит — и если вы не сможете объяснить, почему это не теорема, оценка снижается. Запомните формулировку: «это эмпирически подтверждённое соглашение, не имеющее формального доказательства».

Проблема останова: по описанию программы P и входа x определить, остановится ли P на x. Неразрешима. Доказательство от противного: пусть есть решающая машина H; построим D, которая при H(P,P) = "остановится" зацикливается, иначе останавливается. Тогда анализ D(D) противоречит определению H. Это классическая диагонализация — тот же приём, которым Кантор доказывал несчётность действительных чисел; на коллоквиуме полезно назвать это слово.

  • Разбор доказательства по шагам, чтобы не запутаться в ответе:

    1. Допустим, существует H(P, x), которая всегда останавливается и возвращает «да», если P(x) останавливается, и «нет» — если зацикливается. Это наш «предсказатель».
    2. Строим программу D(P): если H(P, P) = "остановится", то D крутится в бесконечном цикле; иначе D сразу завершается.
    3. Теперь спрашиваем у H: что скажет H про D(D)?
  • Если H говорит «D(D) остановится», то по определению D она должна зациклиться — противоречие.

  • Если H говорит «D(D) зациклится», то D сразу остановится — опять противоречие.

    1. Вывод: такой H не существует, что и требовалось доказать. Эту цепочку на коллоквиуме полезно проговорить вслух шаг за шагом — преподаватели это ценят, потому что видят, что вы понимаете механизм доказательства, а не только заголовок.
  • Следствия: неразрешимы проблема эквивалентности программ (делают ли две программы одно и то же), проверка «делает ли программа то, что нужно», и остановка произвольного кода. Отсюда же — невозможность универсального отладчика, который заранее найдёт все зависания. Следствие для практики: именно поэтому в реальных компиляторах и анализаторах есть false positives (предупреждения о тех местах, которые на самом деле корректны), но никогда нельзя гарантировать «всё, что не предупреждено — безопасно». Математики говорят: неразрешимость — это потолок, а не инженерная недоработка. Идеальный «отладчик всего» в принципе невозможен — это не вопрос развития технологий.

  • Множества: разрешимые (есть МТ, всегда останавливающаяся с ответом «да/нет»), перечислимые (есть МТ, которая перечисляет элементы и может зацикливаться на не входящих), неперечислимые. Ключевое соотношение: разрешимое ⊂ перечислимое ⊂ все множества, причём включения строгие. Как запомнить соотношения: разрешимые ⊂ перечислимые ⊂ всех множеств. Строгость включений: (а) есть перечислимые множества, которые не разрешимы — например, «программа, которая останавливается на входе n» (как раз из проблемы останова, неразрешимая, но полуразрешимая); (б) есть множества вообще неперечислимые — например, дополнение к проблеме останова.

  • Классы сложности: P — задачи, разрешимые за полиномиальное время; NP — задачи, решение которых можно проверить за полином; NP-полные — «самые трудные» в NP, к которым сводится любая задача из NP. Вопрос P = NP открыт: за его решение обещана премия в миллион долларов, и на экзамене достаточно сказать, что это одна из главных нерешённых проблем информатики. Полезные примеры для запоминания:

    • В P: сортировка массива за O(n log n), проверка связности графа, проверка чётности числа.
    • В NP (неизвестно, в P ли): задача коммивояжёра, SAT (выполнимость логической формулы), задача о клике.
    • Если вы слышите на экзамене «задача NP-полна» — это значит «она универсально трудна: если научимся быстро решать её, научимся быстро решать почти всё в NP». Не пытайтесь найти точный полиномиальный алгоритм — это «тот самый» открытый вопрос.
  • Принципиальное замечание про P vs NP. В NP решение проверяется быстро (если дали ответ — можно проверить за полином). В P — решение находится быстро. Все знают, что P ⊆ NP (если решаем за полином, то и проверяем). Вопрос: верно ли, что NP ⊆ P? Большинство математиков считают, что нет — но строго доказать не могут. На коллоквиуме не врите в эту сторону: правильный ответ — «неизвестно, и за решение обещан миллион долларов (Clay Institute)».

БНФ и синтаксические диаграммы

Простыми словами. БНФ — это способ строго записать, из каких «кубиков» состоит правильная программа, — грамматика языка. Аналогия: школьная грамматика говорит «предложение = подлежащее + сказуемое»; БНФ говорит «программа = одно или несколько описаний». Она нужна, чтобы формально описать синтаксис и затем написать парсер (анализатор) — это тема курса «Алгоритмы и алгоритмические языки» на ВМК.

Зачем БНФ нужна на практике. Компилятор должен уметь отличать правильные программы от неправильных. Для этого ему нужна формальная грамматика — и БНФ исторически стала стандартом записи. Современные генераторы парсеров (yacc, bison, ANTLR) принимают грамматику в виде, очень близком к БНФ. То есть БНФ — это мост между «математическим описанием языка» и «реальным кодом компилятора».

Форма Бэкуса–Наура (БНФ) — метаязык описания синтаксиса:

<программа> ::= <описание> | <программа> <описание>
<описание>  ::= <тип> <идентификатор> ;
<тип>       ::= int | float | char
  • ::= — «определяется как» (левая часть раскрывается через правую), | — альтернатива («или»), <...> — нетерминал (имя, которое нужно раскрыть дальше), обычные символы без скобок — терминалы (их пишем буквально). {} — повторение нуль и более раз, [] — необязательная часть. Как их отличать: нетерминал — это «короткое имя для куска», например <выражение>; терминал — это то, что уже похоже на кусок программы (if, ;, 123, +). Поначалу их путают, но привыкаешь: «нетерминал в треугольных скобках раскрываем дальше, терминал пишем как есть».
  • Синтаксическая диаграмма (диаграмма Вирта) — графический эквивалент БНФ: терминалы в кружках, нетерминалы в прямоугольниках, альтернативы «развилками», а путь от входа к выходу задаёт допустимую запись. Так рисуют грамматику в учебниках и на защите. Когда удобнее диаграмма, а когда БНФ. Диаграмма — для небольшой грамматики, чтобы преподаватель сразу увидел структуру глазами. БНФ — для больших грамматик (десятки правил), её легче хранить в текстовом виде и подавать в генератор парсеров.
  • Расширенная БНФ (РБНФ) добавляет повторы и опции прямо в запись ({ ... }, [ ... ]), поэтому она компактнее классической; именно её обычно и используют на практике. Пример из жизни: стандарты языков (например, Паскаль, Си) записаны в РБНФ-подобной форме, потому что там много повторяющихся частей: «список операторов — это ноль или более операторов подряд». Классическая БНФ потребовала бы рекурсивного определения, а РБНФ записывает это в одну строку.
  • Типичная ошибка — путать синтаксис и семантику: БНФ описывает только форму («как записано»), но не смысл («что означает»). Например, что переменная объявлена раньше использования, БНФ выразить не может — это семантическое правило. Как провести границу на экзамене: спросите себя — «может ли компилятор-парсер проверить это правило, читая программу слева направо без анализа таблицы имён?» Если да — это синтаксис. Если нужно знать, что объявлено раньше, чем используется — это семантика. Семантические правила описываются отдельно (атрибутные грамматики, логика Хоара, контрактное программирование).
  • Ещё одна частая ошибка новичков — левая рекурсия. Если правило <A> ::= <A> x | y, то разбор сверху вниз с рекурсивным спуском зациклится. Решение — переписать через правую рекурсию или использовать итерацию (<A> ::= y { x }). Преподаватели иногда специально дают такую грамматику, чтобы проверить, понимаете ли вы, как избежать зацикливания парсера.

Структуры данных и алгоритмы

Ядро практической части. Для каждой структуры — идея, ключевые операции, сложность и рабочий код. На экзамене почти всегда просят не просто назвать сложность, а обосновать её: почему вставка в список O(1), а поиск в нём O(n); почему хеш-таблица «в среднем O(1)», но может выродиться в O(n). Учите структуры вместе с их операциями, а не по отдельности — тогда ответ складывается сам. Ниже для каждой структуры сначала идёт аналогия и идея словами, затем код с комментариями и, где нужно, разбор типичной ошибки.

Зачем вообще нужны разные структуры. Любая программа — это компромисс между временем работы и памятью. Массив быстро даёт доступ по индексу, но медленно вставляет в начало. Список — наоборот. Хеш-таблица даёт почти мгновенный поиск, но плохо ведёт себя в худшем случае и тратит память «в пустоту». Поэтому на экзамене часто звучит контрольный вопрос: «А какую структуру вы бы выбрали для задачи X?» Ответ должен опираться на то, какие операции будут чаще: вставки, поиск, обход, удаление.

Односвязный список

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

Почему именно так. Указатели в языке Си дают нам две суперсилы: (1) динамически расти — не надо заранее знать размер, как в массиве; (2) переставлять узлы без копирования данных — сменил два указателя, и всё. Плата — мы теряем «арифметику указателей»: нельзя сказать «дай i-й элемент» одной командой процессора, как a[i] = *(a + i) для массива. Каждый шаг по списку — это чтение указателя из памяти, а память может быть разбросана по куче (heap), поэтому ещё и кэш процессора работает хуже, чем по плотному массиву.

Список — узлы, связанные указателями; вставка и удаление за O(1) при известном месте, доступ по индексу — O(n).

#include <stdio.h>
#include <stdlib.h>

typedef struct Node { int data; struct Node *next; } Node;

// Вставка в начало за O(1): новый узел просто ссылается на бывшую голову.
// Функция возвращает НОВУЮ голову — иначе вызывающий её не увидит
Node *push_front(Node *head, int value) {
    Node *n = malloc(sizeof *n);
    if (!n) return head;                           // при ошибке возвращаем список как есть
    n->data = value;
    n->next = head;
    return n;                                      // новая голова
}

// Разворот за O(n) без дополнительной памяти: разворачиваем стрелки на ходу.
// Три указателя: prev (уже развёрнутая часть), head (текущий), next (сохраняем!)
Node *reverse(Node *head) {
    Node *prev = NULL;
    while (head) {
        Node *next = head->next;                   // 1. запомнили следующий узел
        head->next = prev;                         // 2. развернули стрелку назад
        prev = head;                               // 3. сдвинули prev
        head = next;                               // 4. перешли к следующему
    }
    return prev;                                   // prev — новая голова
}

// Освобождение списка: сохраняем next ДО free, иначе читаем освобождённую память
void free_list(Node *head) {
    while (head) { Node *t = head; head = head->next; free(t); }
}

int main(void) {
    Node *head = NULL;
    // push_front вставляет в начало, поэтому получается 5 4 3 2 1...
    for (int i = 1; i <= 5; i++) head = push_front(head, i);
    head = reverse(head);                          // ...а после разворота — 1 2 3 4 5
    for (Node *p = head; p; p = p->next) printf("%d ", p->data);
    putchar('\n');
    free_list(head);
    return 0;
}
Операция Односвязный Двусвязный Массив
Доступ по индексу O(n) O(n) O(1)
Вставка в начало O(1) O(1) O(n)
Вставка в конец O(n) / O(1) с хвостом O(1) O(1) амортизированно
Удаление узла по указателю O(n) O(1) O(n)

Как объяснить таблицу. «O(n)» в строке «удаление по указателю» для односвязного списка означает, что даже имея указатель на узел, нельзя удалить его за O(1): нужно найти предыдущий узел, а для этого пройти список с начала. Двусвязный список решает именно эту проблему за счёт поля prev. У массива противоположный компромисс: доступ по индексу мгновенный (адрес вычисляется как base + i * sizeof), но вставка в начало требует сдвига всех элементов — O(n). Отсюда практический вывод: список выбирают, когда много вставок/удалений и мало поиска; массив — когда нужен быстрый доступ по индексу.

Дополнительно про сложность «O(n) / O(1) с хвостом». Если вместе с head хранить и tail (указатель на последний узел), то вставка в конец становится O(1) — иначе нам каждый раз пришлось бы идти до конца. Но тогда усложняется и освобождение списка: нужна функция, которая аккуратно обнулит оба указателя, иначе потеряем либо голову, либо хвост.

Типичная ошибка при работе со списком. Забыть вернуть новую голову: если вставить узел в начало и не присвоить head = push_front(head, x);, голова останется старой, а новый узел «потеряется» (и станет утечкой памяти). Второй частый баг — при удалении сдвинуть указатель обхода p = p->next там, где он не должен двигаться: это пропускает элементы. Третья, не менее частая ошибка — p = p->next после free(p): к этому моменту память уже отдана куче, и чтение из неё — undefined behavior (UB), компилятор это не поймает, программа будет работать «иногда».

Практический совет. Полезно сразу нарисовать список на бумаге с тремя-четырьмя узлами, отметить стрелки next и пройти свой код «карандашом» — 90% ошибок со списками видны глазом, а компилятор их не видит вовсе.

Двусвязный список

Простыми словами. В двусвязном списке каждый узел хранит указатели в обе стороны — на предыдущий и на следующий. Аналогия: вагоны с двумя сцепками, поэтому поезд можно собирать и разбирать с любого конца, а «выцепить» вагон, зная его самого, — мгновенно. Плата за удобство — лишний указатель в каждом узле: памяти тратится больше, а при вставке/удалении нужно аккуратно обновить вдвое больше ссылок (главный источник ошибок).

Почему именно так. Если в односвязном списке «узнать предыдущий узел» нельзя без полного обхода, то в двусвязном достаточно одного разыменования указателя prev->next. Это и даёт O(1) на удаление по указателю. Но за это платим памятью: ещё 8 байт (на 64-битной системе) на каждый узел — для миллиона элементов это уже ~8 МБ дополнительно, что иногда критично. На экзамене достаточно сказать «O(1) за счёт хранения обратного указателя, цена — +sizeof(pointer) на узел».

Каждый узел хранит prev и next — удаление по указателю и обход в обе стороны за O(1) при наличии самого узла.

#include <stdio.h>
#include <stdlib.h>

typedef struct DNode { int data; struct DNode *prev, *next; } DNode;

DNode *dlist_insert(DNode *head, int value) {      // вставка в начало, O(1)
    DNode *n = malloc(sizeof *n);
    if (!n) return head;
    n->data = value;
    n->prev = NULL;                                // у нового первого узла предка нет
    n->next = head;
    if (head) head->prev = n;                      // старый первый теперь смотрит назад на n
    return n;
}

// Указатель на указатель (DNode **head) нужен, чтобы изменить саму голову:
// при удалении первого узла голова сдвигается
void dlist_remove(DNode **head, DNode *n) {
    if (n->prev) n->prev->next = n->next;          // «перепрыгнули» узел слева
    else         *head = n->next;                  // удаляем первый — меняем head
    if (n->next) n->next->prev = n->prev;          // «перепрыгнули» узел справа
    free(n);                                       // память узла освобождаем
}

int main(void) {
    DNode *head = NULL;
    for (int i = 1; i <= 3; i++) head = dlist_insert(head, i);   // 3 2 1
    dlist_remove(&head, head->next);               // удалили средний узел (2)
    for (DNode *p = head; p; p = p->next) printf("%d ", p->data);   // 3 1
    putchar('\n');
    return 0;
}

Почему в dlist_remove передан DNode **head. Передать DNode *head означало бы передачу копии указателя — мы могли бы менять сам список, но не могли бы сказать вызывающему коду «у тебя теперь новая голова». Поэтому, когда нужно менять саму переменную-указатель (например, при удалении первого узла), берётся указатель на указатель: ** → «дай мне возможность переписать твой указатель». На экзамене это любимая тема — будьте готовы объяснить, чем Node *head отличается от Node **head.

Типичная ошибка. Забыть обновить prev у соседа: например, при удалении узла поправили n->prev->next = n->next, но не поправили n->next->prev = n->prev — список «расползается», и при обходе назад программа зацикливается либо падает с SIGSEGV. Вторая типичная ошибка — if (n->prev) и if (n->next) написаны раздельно, а на экзамене просят именно аккуратно обработать оба случая (первый/последний узел).

Когда выбирать двусвязный, а когда односвязный. Если нужен только обход «вперёд» и вставки в начало — хватит односвязного (меньше памяти и меньше шансов ошибиться). Двусвязный оправдан, когда реально нужны: удаление по известному узлу, обход в обе стороны, LRU-кеш, undo/redo в редакторе.

Стек, очередь, дек

Простыми словами. Стек и очередь — это одни и те же данные, но с разным порядком доступа. Стек (LIFO, last in — first out) — как стопка тарелок: кладёте сверху и берёте сверху, поэтому последний добавленный элемент уходит первым. Очередь (FIFO, first in — first out) — как очередь в столовой: кто раньше встал, тот раньше обслужен. Дек — двухсторонняя очередь: подходить можно и с начала, и с конца. Главное, что нужно назвать на экзамене, — все операции выполняются за O(1), и именно поэтому эти структуры незаменимы в обходах графа и разборе выражений.

Почему O(1). У стека и очереди доступ строго к одному краю (у стека — всегда к вершине, у очереди — к голове и хвосту, но к «середине» доступа нет). Это значит, что не нужно искать элемент — мы уже знаем, где он. Перестановка одного-двух указателей — и готово. Линейный поиск тут не появляется именно потому, что поиск этим структурам не полагается: если нужен поиск — это уже другая структура (хеш-таблица, дерево).

Когда какую структуру использовать. Стек — рекурсия, разбор скобок, обратная польская запись, отмена действий (Ctrl+Z). Очередь — обход графа в ширину (BFS), планировщик задач, обработка запросов сервером, любая модель «пришёл раньше — обслужен раньше». Дек — скользящее окно, palindrome-проверка, поддержка максимума на отрезке за O(1). Стек (LIFO, Last In — First Out) — структура данных «последним вошёл — первым вышел»: push кладёт элемент на вершину, pop снимает его оттуда же. Аналогия из жизни: стопка тарелок в буфете — вы всегда берёте верхнюю, и новую кладёте наверх. Обе операции выполняются за O(1), потому что вершина всегда известна (хранится как индекс или указатель) и перебирать ничего не нужно. Почему так: стек — это и есть «упорядоченный по времени поступления» доступ, и его сила в том, что вершина доступна мгновенно, без поиска.

Очередь (FIFO, First In — First Out) — структура данных «первым вошёл — первым вышел»: enqueue добавляет элемент в хвост, dequeue забирает из головы. Аналогия: очередь людей к кассе — кто встал раньше, тот раньше уйдёт. Это фундаментальное отличие от стека: LIFO «помнит последнее», FIFO «помнит первое». Понимание разницы критично для экзамена — на нём любят спрашивать, что вы примените в конкретной задаче: стек или очередь.

Дек (deque, double-ended queue) — двусторонняя очередь: можно вставлять и удалять элементы с обоих концов (часто реализуется через массив с двумя указателями «голова» и «хвост», которые могут «заворачиваться»). Аналогия: живая очередь, в которой разрешено входить и выходить как спереди, так и сзади. Применяется в алгоритмах скользящего окна, в undo/redo (отмена и повтор действий), в планировщиках операционной системы.

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define CAP 8

// Стек на массиве фиксированной ёмкости: top — индекс следующей свободной ячейки
typedef struct { int data[CAP]; int top; } Stack;
bool  push(Stack *s, int v) { return s->top < CAP ? (s->data[s->top++] = v, true) : false; }
int   pop(Stack *s)         { return s->top > 0 ? s->data[--s->top] : -1; }
bool  empty(Stack *s)       { return s->top == 0; }

// Очередь КОЛЬЦЕВАЯ: после конца массива tail и head заворачиваются в 0,
// иначе место в буфере быстро кончится, хотя элементы уже извлечены
typedef struct { int data[CAP]; int head, tail; } Queue;
void enqueue(Queue *q, int v) { q->data[q->tail] = v; q->tail = (q->tail + 1) % CAP; }
int  dequeue(Queue *q)        { int v = q->data[q->head]; q->head = (q->head + 1) % CAP; return v; }

int main(void) {
    Stack s = {.top = 0};                          // .top = 0 — стек пуст
    push(&s, 1); push(&s, 2); push(&s, 3);
    while (!empty(&s)) printf("%d ", pop(&s));     // 3 2 1 — LIFO наглядно
    putchar('\n');

    Queue q = {.head = 0, .tail = 0};              // .head == .tail — очередь пуста
    enqueue(&q, 10); enqueue(&q, 20);
    printf("%d %d\n", dequeue(&q), dequeue(&q));   // 10 20 — FIFO
    return 0;
}

Почему именно так, а не иначе. В стеке top хранит позицию следующей свободной ячейки (а не последней занятой) — это удобно, потому что проверка на переполнение превращается в одно сравнение с CAP, а на пустоту — в одно сравнение с нулём. В кольцевой очереди head и tail — индексы, которые «двигаются по кругу» через % CAP; пустая очередь определяется равенством head == tail, а полная — условием (tail + 1) % CAP == head (одну ячейку оставляют «пустой», чтобы отличить полную от пустой — это классический трюк, его спрашивают отдельно). Не путайте «где вершина» и «где хвост»: в стеке это одно и то же место, в очереди — противоположные концы.

Стек применяют для проверки скобочной последовательности, обхода в глубину (DFS), вычисления выражений, отката рекурсии; очередь — для обхода в ширину (BFS) и планирования задач (в т. ч. в планировщике ОС и в очереди печати).

Задача «проверить скобки» (частый экзаменационный вопрос). Идея в одном предложении: открывающую скобку «запоминаем», закрывающую — сверяем с последней запомненной. Аналогия из жизни: представьте текстовый редактор с вложенными скобками — при вводе ) программа должна проверить, что последняя введённая вами открывающая скобка была именно (, иначе вложенность нарушена. Для этого идеально подходит стек: открывающие скобки кладём в стек, а закрывающие — сравниваем с вершиной. Если вершина не соответствует или стек пуст при закрывающей — последовательность неверна; в конце стек должен быть пуст. Алгоритм за O(n): пройти по строке, push для (, [, {; для ), ], } проверить вершину и pop. Почему стек, а не просто счётчик? Потому что скобки могут быть разных типов: (]) — неверно, хотя количество открывающих и закрывающих совпадает. Счётчик этого не поймает, а стек — поймает, потому что хранит тип последней открывающей скобки.

Типичная ошибка № 1 (самая частая). Не проверять пустоту стека перед pop. Выражение начинается с )]} — стек пуст, а мы пытаемся снять вершину. Программа либо выдаст мусор, либо упадёт (выход за границу массива). Обязательно перед pop пишите if (empty(&s)) return false;.

Типичная ошибка № 2. Не проверять, что после обработки строки стек пуст. Это пропускает лишние открывающие скобки — например, (( оставит в стеке (, и без финальной проверки алгоритм скажет «верно». Всегда делайте «заключительный тест» в самом конце функции — это 1 строка, но она ловит класс ошибок, который иначе выглядит «невидимым».

Типичная ошибка № 3. Забыть про соответствие типов скобок — проверять только количество, а не парность. Стек это лечит естественно, но если вы пишете «в лоб» через счётчики, легко упустить.

Практический совет. На экзамене оформите проверку соответствия отдельной функцией bool is_matching(char open, char close) — это делает код чище и сразу показывает преподавателю, что вы понимаете суть, а не «зазубрили switch-case». Пара строк экономии на экзамене оборачивается парой баллов.

Разница стека на массиве и на списке. Реализация на массиве проще и быстрее (хорошая локальность кеша — элементы лежат подряд, процессор их предзагружает), но имеет фиксированную ёмкость (или требует перевыделения памяти при росте — а это дорого, потому что нужно копировать всё). Реализация на связном списке ограничена только объёмом оперативной памяти, динамически растёт без копирования, но тратит дополнительную память на указатели (×2 от размера данных) и медленнее из-за плохой локальности кеша (узлы разбросаны по памяти, каждый доступ — потенциальный cache miss). На экзамене укажите оба варианта и обязательно проговорите компромисс: «простой массив — быстрее и проще, но ограничен; связный список — гибче по размеру, но платим памятью и временем». Это покажет системное понимание, а не механическое знание кода.

Польская запись

Простыми словами. Обратная польская (постфиксная) запись — это способ записать арифметическое выражение без скобок: сначала операнды, потом операция. Аналогия: так работает калькулятор в обратной польской логике (HP-12C, Forth, советские «Электроника МК-61») — вы вводите числа, они накапливаются в стеке, а когда вы нажимаете операцию, она берёт два верхних числа и кладёт результат обратно. Выражение (3 + 4) * 5 превращается в 3 4 + 5 *, и порядок вычислений задаётся сам собой — никакие скобки не нужны. Эта задача почти обязательна в билетах МИФИ, потому что объединяет стек, разбор строк и вычисления в одной точке и проверяет, понимает ли студент, почему порядок именно такой.

Почему это вообще работает. Каждый оператор «ждёт», пока у него появятся операнды — а они появляются строго в порядке «снизу стека — раньше, сверху — позже». Поэтому b - a в выражении 5 3 - читается как «возьми 5 и 3, вычти из первого второе» — то есть 5 - 3 = 2. Порядок операндов определяется их расположением в исходном выражении, а не скобками. Скобки нужны были в инфиксной записи только затем, чтобы сказать «сначала сложи», а в постфиксной записи это сделано «само» через позицию оператора.

Обратная польская (постфиксная) запись — операторы записываются после операндов, скобки не нужны. Вычисляется одним проходом по строке со стеком; перевод из привычной инфиксной формы (с операторами между операндами) — алгоритм Дейкстры, известный как «сортировочная станция» (shunting-yard algorithm), потому что он расставляет операторы «в правильном порядке», как вагоны на сортировочной станции.

Пошаговый алгоритм вычисления (запомните порядок):

  1. Заводим пустой стек чисел.
  2. Читаем выражение слева направо по токенам (число или оператор).
  3. Если токен — число, кладём его в стек (push).
  4. Если токен — оператор: извлекаем два числа — сначала правое b, потом левое a — вычисляем a op b (порядок критичен для - и /) и кладём результат обратно в стек.
  5. В конце в стеке должен остаться ровно один элемент — это и есть результат.
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <string.h>

// Вычисление постфиксного выражения: "3 4 + 5 *" => 35
int eval_rpn(const char *expr) {
    int st[64], top = 0;                 // стек чисел; top — индекс вершины + 1
    char buf[64];
    while (*expr) {
        if (isspace((unsigned char)*expr)) { expr++; continue; }   // пробелы пропускаем
        if (isdigit((unsigned char)*expr)) {
            // Собираем число из подряд идущих цифр
            int i = 0;
            while (isdigit((unsigned char)*expr)) buf[i++] = *expr++;
            buf[i] = '\0';                // замыкаем строку, иначе atoi прочитает мусор
            st[top++] = atoi(buf);
            continue;
        }
        // Оператор: СНАЧАЛА снимаем правое число b, потом левое a —
        // для вычитания и деления порядок критичен
        int b = st[--top], a = st[--top];
        switch (*expr++) {
            case '+': st[top++] = a + b; break;
            case '-': st[top++] = a - b; break;
            case '*': st[top++] = a * b; break;
            case '/': st[top++] = a / b; break;
        }
    }
    return st[--top];                    // в стеке остался ровно один результат
}

int main(void) {
    printf("%d\n", eval_rpn("3 4 + 5 *"));    // 35
    return 0;
}

Подробнее про порядок операндов. Когда в стеке лежит ... a, b (a глубже, b на вершине), оператор берёт их в порядке «верхний, потом нижний». То есть сначала b = pop(), потом a = pop(). Почему именно так? Потому что в постфиксной записи операнды идут в том порядке, в каком их «увидел» оператор — а оператор стоит после них. Если вы пишете 5 3 -, то 5 встретилось раньше, 3 — позже. Когда - сработает, 5 уже лежит в стеке глубже, 3 — наверху. Оператор должен взять «верхний — это правый, нижний — это левый» и посчитать 5 - 3. Эта связь «позиция в выражении ↔ глубина в стеке» — ключ ко всей задаче.

Типичная ошибка при решении № 1 (самая частая!). Перепутать порядок операндов: если для - взять b - a вместо a - b, то "5 3 -" даст -2 вместо 2. На экзамене за это снимают баллы — запишите в комментарии: «b снимаем первым, потому что он на вершине». Это маленький комментарий, но он покажет, что вы понимаете, почему именно так, а не наоборот.

Типичная ошибка № 2. Не проверять пустоту стека перед операцией: некорректное выражение (например, «+» без чисел или «3 +») приведёт к чтению за границей массива (top--, когда top == 0) — это UB (undefined behavior), программа может вести себя непредсказуемо: выдать мусор, зациклиться или упасть. В хорошем решении перед pop стоит if (top < 2) return ERROR; — две числа нужно минимум.

Типичная ошибка № 3. Не замыкать buf нулём: без buf[i] = '\0' функция atoi прочитает «хвост» предыдущего числа, и вы получите мусор вместо правильного значения. Это типичная сишная ошибка — atoi не знает длины строки, она идёт до нулевого байта. Если забыли — следующее число окажется «склеенным» с предыдущим.

Типичная ошибка № 4. Забыть, что isspace/isdigit принимают int, причём значение должно быть в диапазоне unsigned char или EOF. Если *expr равен EOF (-1) или любому байту > 127, без приведения к (unsigned char) можно получить UB. В коде это isspace((unsigned char)*expr) — обратите внимание на приведение типа, его пропускают почти все.

Финальная проверка на экзамене. После обработки всей строки в стеке должен остаться ровно один элемент. Если их больше — где-то пропущен оператор или есть «лишние» числа; если стек пуст — операторов было слишком много. Можно добавить return top == 1 ? st[0] : ERROR; — это простой способ проверить корректность входа и сразу поднять тревогу, если данные испорчены. На экзамене такая проверка занимает одну строку, а преподаватель видит, что вы понимаете, что бывают некорректные входы.

Сортировки

Простыми словами. Сортировка — это упорядочивание элементов по некоторому ключу; разные алгоритмы отличаются тем, как быстро они работают на разных данных и сколько дополнительной памяти берут. Устойчивость означает, что равные элементы сохраняют исходный относительный порядок. Аналогия: если вы сортируете стопку карточек сначала по имени, потом по курсу, устойчивый алгоритм сохранит алфавитный порядок имён внутри одного курса — а неустойчивый может перемешать. Это не «мелочь»: устойчивость прямо названа в экзаменационной таблице, и её спрашивают почти всегда — без неё ответ про сортировку считается неполным.

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

Алгоритм Лучший Средний Худший Память Устойчивость
Пузырьком O(n) O(n²) O(n²) O(1) Устойчива
Вставками O(n) O(n²) O(n²) O(1) Устойчива
Выбором O(n²) O(n²) O(n²) O(1) Неустойчива
Быстрая (Хоара) O(n log n) O(n log n) O(n²) O(log n) Неустойчива
Слиянием O(n log n) O(n log n) O(n log n) O(n) Устойчива

Как читать сложности на практике. Запись O(n²) означает: при удвоении размера массива время вырастет примерно вчетверо (потому что (2n)² = 4n²), а O(n log n) — немногим более чем вдвое (потому что 2n·log(2n) ≈ 2n·(log n + 1)). Поэтому на 10 000 элементов пузырёк и быстрая сортировка различаются не «в два раза», а на порядки — пузырьку нужны миллионы операций, быстрой — около 130 000. Это разница между секундой и минутой на практике, и между «сдать на стенде» и «ждать ночь». На экзамене эту разницу любят проверять вопросом «почему O(n log n) быстрее O(n²)?».

Почему память различается. «Память O(log n)» у быстрой сортировки — это глубина рекурсии (в среднем она пропорциональна log n, потому что каждый уровень рекурсии делит массив пополам); дополнительных массивов под данные нет. «O(n)» у слияния — это вспомогательный буфер, который приходится выделять целиком для слияния двух половин. На экзамене любят уточняющий вопрос: «а можно ли сортировку слиянием сделать in-place?» — технически да (существуют алгоритмы сложности O(n log² n) без допмассива), но она становится значительно сложнее и медленнее, поэтому в учебных целях используют O(n). Запомните эту связь: «меньше памяти — обычно сложнее или медленнее».

Устойчивость важна, когда элементы — записи с несколькими полями. Классический пример: у вас список студентов (имя, курс). Вы отсортировали его по имени. Теперь нужно отсортировать по курсу, сохраняя алфавитный порядок внутри одного курса. Это можно сделать одной сортировкой по составному ключу (курс, имя), либо двумя сортировками: сначала по имени, потом по курсу устойчивой сортировкой. Второй вариант возможен только с устойчивым алгоритмом — иначе вторая сортировка «сломает» алфавитный порядок из первой.

Худший случай быстрой сортировки — уже отсортированный массив при выборе крайнего опорного элемента (всегда первого или всегда последнего): разбиение каждый раз даёт подмассив из n-1 элемента и пустой второй, и глубина рекурсии становится O(n), а общее время — O(n²). Лечится выбором среднего или случайного pivot (или «медианы трёх» — берём первый, средний и последний элементы, выбираем из них медиану), тогда средняя оценка O(n log n) сохраняется даже на почти отсортированных или обратно отсортированных данных. На экзамене обязательно проговорите эту связь: O(n²) в худшем возникает не «случайно», а из-за неудачного выбора опорного элемента — и решение состоит в том, чтобы pivot выбирать «по-умному». Это показывает, что вы понимаете, почему алгоритм работает, а не просто выучили таблицу.

Почему сортировка выбором неустойчива. При выборе минимума вы меняете его с текущей позицией — а эта замена может «перепрыгнуть» через равный элемент и изменить их относительный порядок. Классический пример: сортируем [2a, 1, 2b] (где 2a и 2b равны по ключу, но это разные элементы). При первом проходе ищем минимум — это 1, меняем его с 2a и получаем [1, 2a, 2b]. Вроде всё хорошо, но если бы 2a стоял правее 2b, после обмена с 1 они бы поменялись местами и 2b оказался бы раньше 2a — порядок равных элементов нарушен. Устойчивая версия сортировки выбором существует, но требует дополнительной памяти (вставлять минимум в начало, сдвигая остальные), и на практике почти никогда не используется.

#include <stdio.h>

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }

void bubble_sort(int *a, int n) {                  // O(n^2), на почти отсортированных — O(n)
    for (int i = 0; i < n - 1; i++) {
        int swapped = 0;
        for (int j = 0; j < n - 1 - i; j++)
            if (a[j] > a[j + 1]) { swap(&a[j], &a[j + 1]); swapped = 1; }
        if (!swapped) break;
    }
}

void insertion_sort(int *a, int n) {               // устойчива, хороша для малых n
    for (int i = 1; i < n; i++) {
        int key = a[i], j = i - 1;
        while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; j--; }
        a[j + 1] = key;
    }
}

void selection_sort(int *a, int n) {               // минимум swaps: O(n) обменов
    for (int i = 0; i < n - 1; i++) {
        int m = i;
        for (int j = i + 1; j < n; j++) if (a[j] < a[m]) m = j;
        if (m != i) swap(&a[i], &a[m]);
    }
}

// Схема Ломуто: опорный — последний элемент.
// i — граница «области элементов <= pivot»; когда встречаем меньший,
// расширяем область и меняем местами
int partition(int *a, int lo, int hi) {
    int pivot = a[hi], i = lo - 1;
    for (int j = lo; j < hi; j++)
        if (a[j] <= pivot) swap(&a[++i], &a[j]);
    swap(&a[i + 1], &a[hi]);                       // ставим pivot на своё место
    return i + 1;                                  // его индекс: слева меньше, справа больше
}

// Быстрая сортировка: рекурсивно делим массив вокруг pivot.
// Риск O(n^2) на отсортированных данных — лечится случайным pivot
void quick_sort(int *a, int lo, int hi) {
    if (lo < hi) {                                 // база: 0 или 1 элемент уже отсортированы
        int p = partition(a, lo, hi);
        quick_sort(a, lo, p - 1);                  // левая часть
        quick_sort(a, p + 1, hi);                  // правая часть
    }
}

void merge(int *a, int lo, int mid, int hi, int *buf) {
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i <= mid) buf[k++] = a[i++];
    while (j <= hi)  buf[k++] = a[j++];
    for (i = lo; i <= hi; i++) a[i] = buf[i];
}

void merge_sort(int *a, int lo, int hi, int *buf) {
    if (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        merge_sort(a, lo, mid, buf);
        merge_sort(a, mid + 1, hi, buf);
        merge(a, lo, mid, hi, buf);
    }
}

int main(void) {
    // Внимание: buf — размером как минимум с исходный массив (память O(n))
    int a[] = {5, 2, 9, 1, 5, 6}, n = 6;
    int buf[6];
    merge_sort(a, 0, n - 1, buf);                  // устойчивая сортировка слиянием
    for (int i = 0; i < n; i++) printf("%d ", a[i]);   // 1 2 5 5 6 9
    putchar('\n');
    return 0;
}

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

Простыми словами. Бинарный поиск ищет элемент в отсортированном массиве, каждый раз отбрасывая половину оставшихся вариантов. Аналогия: поиск слова в бумажном словаре — вы открываете его посередине и решаете, в какой половине искать дальше. Сложность O(log n): миллион элементов проверяется примерно за 20 сравнений. Это прямой «рабочий инструмент» бинарной сортировки по значению и одна из самых частых задач на массивы.

#include <stdio.h>

// Бинарный поиск: возвращает индекс элемента или -1, если его нет.
// Работает ТОЛЬКО на отсортированном по возрастанию массиве
int binary_search(const int *a, int n, int key) {
    int lo = 0, hi = n - 1;              // границы включительного диапазона [lo, hi]
    while (lo <= hi) {                   // пока диапазон не пуст
        // Формула lo + (hi - lo)/2 не переполняется, в отличие от (lo + hi)/2
        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;                           // элемент отсутствует
}

int main(void) {
    int a[] = {1, 3, 5, 7, 9, 11};
    printf("%d %d\n", binary_search(a, 6, 7), binary_search(a, 6, 4));   // 3 -1
    return 0;
}
  • Пошаговый алгоритм: задать lo = 0, hi = n-1; пока lo <= hi вычислить mid, сравнить a[mid] с ключом и сдвинуть соответствующую границу; при выходе из цикла вернуть −1.
  • Типичная ошибка №1: запускать поиск на неотсортированном массиве — тогда O(log n) превращается в произвольный ответ. Отсортированность — обязательное предусловие.
  • Типичная ошибка №2: считать mid = (lo + hi) / 2 — при больших lo и hi сумма переполняет int и результат становится отрицательным. Именно из-за этого баг жил в стандартной библиотеке Java программирования более десяти лет.
  • Типичная ошибка №3: путать включительные и полуоткрытые границы. Если выбран вариант hi = n (полуоткрытый интервал), условие цикла должно быть lo < hi, а hi = mid без вычитания. Главное — не смешивать соглашения в одном коде.

Хеш-таблицы

Простыми словами. Хеш-таблица — это «гардероб по номеркам»: у каждого ключа вычисляется номер ячейки (хеш), и по нему элемент кладётся и находится почти мгновенно. Аналогия точная: в гардеробе вы не ищете пальто по всему ряду, а идёте к своей полке; но если двум людям выдали одинаковый номер, начинается разбирательство — это и есть коллизия. В среднем операции занимают O(1), но при неудачной хеш-функции или переполнении таблицы всё вырождается в O(n) — именно эту оговорку ждёт экзаменатор.

Определение. Хеш-таблица — это структура данных, которая реализует ассоциативный массив (отображение «ключ → значение»). Доступ к элементу выполняется не перебором, как в списке, и не делением пополам, как в массиве, а по вычисленному адресу. Внутри таблицы лежит обычный массив bucket[], а хеш-функция превращает ключ произвольной длины (строку, число, структуру) в индекс этого массива.

Хеш-таблица отображает ключ в индекс через хеш-функцию; в среднем операции O(1), в худшем — O(n). Коллизии разрешают двумя способами:

  • Метод цепочек — в каждой ячейке список элементов с одинаковым хешем. Устойчив к высокой загрузке, память на указатели. Простая идея: каждая ячейка массива хранит голову связного списка; все ключи, попавшие в один индекс, «висят» на этой голове. Поиск идёт по списку, длина которого в среднем равна коэффициенту заполнения α.
  • Открытая адресация — при занятости ячейки ищем следующую: линейное (h(k, i) = (h₁(k) + i) mod m) или квадратичное (h(k, i) = (h₁(k) + c₁·i + c₂·i²) mod m) пробирование, двойное хеширование (h(k, i) = (h₁(k) + i·h₂(k)) mod m). При удалении нужно «надгробие» (tombstone), иначе поиск оборвётся.

Коэффициент заполнения α = n / m: для цепочек приемлем до 1, для открытой адресации держат ниже 0,7. Почему именно 0.7? При открытой адресации с ростом α резко растёт среднее число проб — например, при линейном пробировании ожидаемое число проб ≈ 1/2·(1 + 1/(1-α)). Уже при α = 0.9 оно приближается к 5.5, а при α = 0.99 — к 50! Поэтому таблицу с открытой адресацией принято перестраивать (rehash) при загрузке ~0.7. У цепочек картина мягче: средняя длина цепочки = α, и даже при α > 1 всё ещё работает терпимо.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h>

#define SIZE 16

typedef struct Entry { char key[32]; int value; struct Entry *next; } Entry;

// Полиномиальный хеш строки (вариант djb2): h = h*33 + символ.
// (unsigned char) обязателен: иначе русские буквы с кодом >127 дадут отрицательные значения
size_t hash(const char *s) {
    size_t h = 5381;                    // «магическое» начальное значение djb2
    while (*s) h = h * 33 + (unsigned char)*s++;
    return h % SIZE;                    // приводим к размеру таблицы
}

void put(Entry *table[], const char *key, int value) {
    size_t i = hash(key);
    // Сначала ищем ключ в цепочке: если есть — обновляем значение
    for (Entry *e = table[i]; e; e = e->next)
        if (strcmp(e->key, key) == 0) { e->value = value; return; }
    // Ключа нет — создаём новый узел и вставляем В НАЧАЛО цепочки: это O(1),
    // не нужно искать хвост списка
    Entry *e = malloc(sizeof *e);
    if (!e) return;
    snprintf(e->key, sizeof e->key, "%s", key);   // копируем ключ с ограничением длины
    e->value = value;
    e->next = table[i];
    table[i] = e;
}

int *get(Entry *table[], const char *key) {
    // Идём по цепочке нужной ячейки; возвращаем указатель на значение или NULL
    for (Entry *e = table[hash(key)]; e; e = e->next)
        if (strcmp(e->key, key) == 0) return &e->value;
    return NULL;
}

Хеш-функция должна быть быстрой, детерминированной и равномерной; для строк годится полиномиальная схема Горнера, для целых — k mod m с простым m. Почему m лучше брать простым? Если m делит, скажем, на 4, а ключи — последовательные чётные числа, то все они дадут чётный хеш и осядут в половине ячеек. Простое m (например, близкое к степени двойки, но не равное ей — как 65537) разбивает поток ключей максимально хаотично. «Равномерность» означает, что разные ключи попадают в разные ячейки без закономерности: плохой хеш (например, hash(s) = s[0]) соберёт все слова на одну букву в одну цепочку, и таблица превратится в список с O(n).

Типичные ошибки при работе с хеш-таблицами.

  • Забыть приведение к (unsigned char) — знаковый char при h*33 + c даст UB, если результат переполнит int или c окажется отрицательным байтом кириллицы.
  • Использовать % от отрицательного int (hash(int k) { return k % m; }) — в C результат % может быть отрицательным, что выйдет за границы массива. Решение: ((k % m) + m) % m или сразу работать с unsigned.
  • Сравнивать ключи через ==, а не strcmp — указатели на одинаковые строки в разных местах памяти не равны.
  • При открытой адресации удалять запись как table[i] = NULL — это «разрывает» пробирование; поиск элементов, лежащих правее удалённого, оборвётся. Нужно помечать ячейку флагом TOMBSTONE.
  • Полагаться на среднюю сложность без оценки α — если данные приходят «пачками» с одинаковым хешем (DoS-атака на сетевой сервер!), средняя оценка уже не спасает; нужна криптографическая хеш-функция.

Три вопроса, которые задают про хеш-таблицы. (1) Почему в среднем O(1)? — потому что при равномерном хеше и коэффициенте заполнения α средняя длина цепочки равна α, а она обычно мала. Каждое сравнение внутри цепочки — константа, всего сравнений ≈ α, константа умножается на константу — остаётся O(1). (2) Почему бывает O(n)? — при плохом хеше или при α → ∞ все элементы скапливаются в одной ячейке; тогда мы перебираем всю цепочку длины n. (3) Как удалять при открытой адресации? — ставить «надгробие» (tombstone): просто обнулить ячейку нельзя, иначе поиск, идущий по пробированию, решит, что элемент отсутствует, и оборвётся раньше времени. Лайфхак для экзамена. Когда говорят «надгробие», добавьте: «если в таблице становится слишком много надгробий, поиск тоже деградирует — в реальных реализациях (например, в Java HashMap) периодически делают rehash, перестраивая таблицу и убирая накопившиеся tombstone’ы».

Мнемоника. «Хеш-таблица = гардероб по номеркам»: номер (хеш) мгновенно указывает на полку (ячейку); если номер выдан двоим, они делят одну полку (цепочка). Ключевое слово для ответа — коллизия и способы её разрешения: цепочки и открытая адресация.

Деревья поиска: BST, АВЛ, красно-чёрные, splay

Простыми словами. Двоичное дерево поиска — это структура, где на каждом шаге сравнение с корнем отбрасывает половину вариантов. Аналогия: игра «угадай число» — вы спрашиваете «больше или меньше середины?» и каждый раз сужаете диапазон вдвое. Проблема в том, что волшебство работает только у сбалансированного дерева: если вставлять числа по возрастанию, дерево превращается в обычный список, и поиск деградирует до O(n). Отсюда все «умные» деревья (АВЛ, красно-чёрные, splay), которые следят за высотой.

Определение. BST (Binary Search Tree) — это бинарное дерево, в котором для каждого узла v выполняется инвариант: все ключи в левом поддереве строго меньше v->key, а в правом — строго больше. Этот инвариант позволяет искать ключ рекурсивно: сравнил с корнем — пошёл налево или направо. Без инвариант дерево вырождается, с инвариантом — ускоряется поиск.

Двоичное дерево поиска (BST): для любого узла ключи в левом поддереве меньше, в правом — больше. Операции поиск/вставка/удаление — O(h), где h — высота; при удачном балансе O(log n), в вырожденном случае (отсортированная вставка) — O(n).

Дерево Гарантия высоты Поиск Балансировка
BST (несбалансированное) Нет O(n) в худшем Нет
АВЛ ≤ 1.44 log₂ n O(log n) Вращения LL/RR/LR/RL
Красно-чёрное ≤ 2 log₂(n+1) O(log n) Перекраска + вращения
Splay Амортизированно O(log n) O(log n) амортиз. Splay-подъём к корню

Как объяснить таблицу. Колонка «гарантия высоты» — главное различие: у обычного BST гарантии нет, у АВЛ высота почти минимальна (1.44·log₂ n), у красно-чёрного дерева допуск вдвое больше (2·log₂ n), зато вставка/удаление требуют меньше перестроек. Поэтому в стандартных библиотеках чаще берут красно-чёрное дерево (std::map, TreeMap, ядра ОС): небольшая потеря в скорости поиска компенсируется дешёвым обновлением. АВЛ выигрывает там, где поиск происходит намного чаще изменения (базы данных, справочники). Splay-дерево не хранит пометок баланса вовсе и «подтягивает» к корню недавно использованные элементы — это амортизированная оценка и хорошая локальность.

Почему 1.44 и 2? Это худшие случаи множителя перед log₂ n. Для АВЛ минимально возможное поддерево при разнице высот ≤ 1 — это поддерево Фибоначчи: F_h = F_{h-1} + F_{h-2}. Рекуррентное соотношение Фибоначчи даёт высоту h ≈ log_φ(n) ≈ 1.44·log₂ n, где φ = (1+√5)/2. У красно-чёрного дерева коэффициент 2 — это компромисс: разрешив дереву быть чуть выше, мы сильно упростили правила балансировки и сократили число вращений при вставке/удалении.

  • АВЛ: фактор баланса (разность высот) каждого узла в {-1, 0, +1}; при нарушении выполняют одинарное или двойное вращение. Почему именно {-1, 0, +1}? Потому что это минимальное «окно», в котором дерево ещё не вырождается, но допускает эффективное восстановление — одного или двух вращений достаточно, чтобы вернуть инвариант. Подводный камень: удаление в АВЛ может потребовать до O(log n) вращений (в худшем случае — спуск обратно к родителю), поэтому для потока удалений АВЛ не идеален.
  • Красно-чёрное: корень чёрный, красный узел не имеет красного ребёнка, все пути от узла до листьев содержат одинаковое число чёрных узлов. Меньше вращений, чем в АВЛ, — применяется в std::map, ядрах, базах. Подводный камень: инвариант про «чёрную высоту» нетривиален — на экзамене любят спрашивать, что будет, если перекрасить корень в красный. Ответ: корень всегда чёрный, иначе счётчик чёрных узлов на путях «разъедется».
  • Splay: к часто используемым элементам «подтягивают» узел к корню, что даёт хорошую амортизированную оценку без явных пометок баланса. Амортизированная означает «в среднем по последовательности операций»: одна операция может стоить O(n), но после неё следующие n операций гарантированно стоят по O(1) — итого O(1) на операцию в среднем. Плюс: отличная локальность кеша, потому что «горячие» данные всплывают к корню.

Когда какое дерево выбирать (практическая шпаргалка).

  • Много поиска, мало изменений → АВЛ (самая жёсткая балансировка → минимальная высота).
  • Сбалансированный поток вставок/удалений/поиска → красно-чёрное (как в std::map).
  • Повторный доступ к «горячим» элементам → splay (кэшируется само).
  • Учебная задача на экзамене → АВЛ (легко нарисовать вращения руками).

Типичные ошибки в BST-коде.

  • Путать < и ≤ в сравнениях — если вставлять ключи, равные корню, «налево», инвариант нарушается (равные ключи могут попасть и туда, и туда). Строгое < слева и > справа (или дубликаты хранятся в отдельном счётчике).
  • Забыть обновить указатель родителя при рекурсивной вставке — функция должна возвращать новый корень поддерева (return root после присваивания root->left = ...), иначе root->left останется старым.
  • При удалении узла с двумя детьми брать «случайного» потомка вместо минимума правого поддерева (или максимума левого) — иначе BST-инвариант ломается.
  • Считать h = 1 + max(l, r) за «высоту», но проверять баланс с фактором h(left) - h(right) — для пустого поддерева высота равна 0, не −1; иначе знак баланса «поедет».
#include <stdio.h>
#include <stdlib.h>

typedef struct TreeNode { int key; struct TreeNode *left, *right; } TreeNode;

// Вставка: идём по правилу BST (меньше — налево, больше — направо),
// пока не найдём свободное место. Возвращаем корень поддерева —
// это позволяет присваивать результат root->left/root->right
TreeNode *bst_insert(TreeNode *root, int key) {      // O(h)
    if (!root) {                                     // дошли до пустого места — создаём узел
        TreeNode *n = malloc(sizeof *n);
        if (!n) return NULL;
        n->key = key; n->left = n->right = NULL;
        return n;
    }
    if (key < root->key)      root->left  = bst_insert(root->left, key);
    else if (key > root->key) root->right = bst_insert(root->right, key);
    // Если key == root->key, ничего не делаем (дубликаты игнорируем)
    return root;
}

// Минимум BST — самый левый узел: спускаемся налево до упора
TreeNode *bst_min(TreeNode *root) {
    while (root && root->left) root = root->left;
    return root;
}

int bst_height(TreeNode *root) {                     // высота дерева
    if (!root) return 0;
    int l = bst_height(root->left), r = bst_height(root->right);
    return 1 + (l > r ? l : r);
}

/* Правое вращение вокруг y (случай LL):
        y            x
       / \          / \
      x   C   =>   A   y
     / \              / \
    A   B            B   C   */
TreeNode *rotate_right(TreeNode *y) {
    TreeNode *x = y->left;
    y->left = x->right;      // поддерево B перевешиваем на место x
    x->right = y;            // y становится правым ребёнком x
    return x;                // x — новый корень поддерева
}

Мнемоника. «BST = угадай число от 1 до 100»: каждый ответ «больше/меньше» сужает диапазон. Если числа загадываются «по порядку» — это вырожденный BST-список; АВЛ/КЧД/splay добавляют правила игры, которые не дают «сужающемуся коридору» превратиться в прямую трубу. Ключевые слова для ответа — высота, балансировка, вращения и амортизированная сложность (для splay). Типичная ошибка решения. Потерять возвращаемое значение при рекурсивной вставке: написать bst_insert(root->left, key); без присваивания. Тогда новый узел создастся, но родитель о нём не узнает, и узел «потеряется». Вторая ошибка — не обрабатывать удаление узла с двумя детьми: по правилу его заменяют минимумом правого поддерева (или максимумом левого). Третья — путать высоту и глубину: высота пустого дерева обычно считается 0, а дерева из одного узла — 1; заранее уточняйте это у преподавателя, потому что от соглашения зависят ответы.

Почему рекурсивная вставка возвращает указатель. Дерево — это связная структура: единственный способ «прикрепить» новый узел — переписать указатель у родителя. Рекурсия возвращает новый корень поддерева наверх по стеку вызовов, и каждая «ступенька» лестницы рекурсии переписывает свой указатель. Если вы забудете присваивание (root->left = bst_insert(...)), то новая вершина так и останется висеть в памяти отдельно от дерева — отсюда классический «потерянный узел».

Обходы дерева

Простыми словами. Обход дерева — это порядок, в котором мы посещаем узлы; от порядка зависит результат. Аналогия из жизни: представьте, что вы заходите в гости к семье. Можно сначала поздороваться с главой семьи, потом идти к детям (pre — как визит вежливости сверху вниз), а можно сначала обойти всю левую ветвь, потом зайти к главе, потом — в правую (in — как чтение книги слева направо), а можно сначала объехать всех младших, а в конце зайти к старшему (post — как сбор подписей снизу вверх). Мнемоника по названиям: preorder — корень посещаем до детей; inorder — корень между левым и правым; postorder — корень после детей («pre» — до, «in» — внутри, «post» — после). Это правило работает для любого дерева, а не только для BST, и его достаточно, чтобы восстановить любой обход на бумаге.

Почему существует несколько обходов. Один и тот же набор узлов можно посетить в разном порядке — и это даёт разные практические эффекты: один удобен для сериализации, другой — для сортировки, третий — для очистки памяти. Поэтому на экзамене часто дают дерево и просят «восстановить» обход — нужно лишь строго следовать мнемонике «pre/in/post = до/внутри/после детей».

8
      / \
     3   10
    / \    \
   1   6    14
Обход Порядок Результат
Прямой (preorder) узел → левое → правое 8 3 1 6 10 14
Симметричный (inorder) левое → узел → правое 1 3 6 8 10 14
Обратный (postorder) левое → правое → узел 1 6 3 14 10 8
По уровням (BFS) слева направо по уровням 8 3 10 1 6 14

Что важно из таблицы. Только симметричный (inorder) обход BST выдаёт ключи по возрастанию — это ключевое свойство, и его почти всегда просят доказать словами: «левое поддерево меньше корня, корень меньше правого, поэтому левый→корень→правый даёт сортировку». Прямой обход удобен для копирования дерева (корень создаём первым), обратный — для освобождения памяти (узел удаляем после детей). BFS не является рекурсивным обходом: он использует очередь, а не стек вызовов, — поэтому его реализуют циклом.

Подсказки по сложности. Любой из четырёх обходов посещает каждый узел ровно один раз и выполняет O(1) работы на узел → время O(n), где n — число узлов. Память: для трёх рекурсивных обходов глубина стека вызовов равна высоте дерева O(h) (в худшем случае h = n для вырожденного дерева-«гвоздя»); для BFS очередь может хранить до ширины нижнего уровня, что в худшем случае тоже O(n).

Типичная ошибка обходов. (1) Забыть базу if (!root) return; — без неё рекурсия уйдёт в NULL->left и программа упадёт с segfault. (2) Перепутать порядок в pre/in/post: буквально одна перестановка вызовов, а обход уже другой. (3) В BFS использовать стек (массив с одним концом) вместо очереди — тогда получится не обход по уровням, а «поиск в глубину» (DFS), и порядок будет совсем иной.

#include <stdio.h>

typedef struct TreeNode { int key; struct TreeNode *left, *right; } TreeNode;

// Симметричный обход: левое поддерево, корень, правое.
// Для BST печатает ключи в порядке возрастания
void inorder(TreeNode *root) {
    if (!root) return;                               // база рекурсии: пустое поддерево
    inorder(root->left);
    printf("%d ", root->key);
    inorder(root->right);
}

// Прямой обход: сначала корень, потом дети. Удобен для копирования дерева
void preorder(TreeNode *root) {
    if (!root) return;
    printf("%d ", root->key);
    preorder(root->left);
    preorder(root->right);
}

// Обратный обход: дети, затем корень. Удобен для освобождения памяти
void postorder(TreeNode *root) {
    if (!root) return;
    postorder(root->left);
    postorder(root->right);
    printf("%d ", root->key);
}

// Обход по уровням (BFS): очередь вместо стека вызовов.
// Начинаем с корня, затем добавляем детей текущего узла в конец очереди
void bfs(TreeNode *root) {
    if (!root) return;
    TreeNode *q[128];                                // очередь указателей на узлы
    int head = 0, tail = 0;
    q[tail++] = root;
    while (head < tail) {
        TreeNode *cur = q[head++];                   // достаём узел с начала очереди
        printf("%d ", cur->key);
        if (cur->left)  q[tail++] = cur->left;       // дети уходят в конец
        if (cur->right) q[tail++] = cur->right;
    }
}

Замечание об ограничении q[128]. В BFS выше очередь — массив фиксированного размера, поэтому обход корректно работает только для деревьев с числом узлов ≤ 128. Для продакшн-кода используют динамический массив, двусвязный список или библиотечные контейнеры; на экзамене обычно либо оговаривают ограничение, либо просят написать «универсальную» очередь на связном списке.

КМП (алгоритм Кнута–Морриса–Пратта)

Простыми словами. Алгоритм Кнута–Морриса–Пратта ищет подстроку в тексте за один проход, не возвращаясь назад по тексту. Аналогия: КМП — это умение не перечитывать заново; если совпадение почти состоялось и сорвалось, мы используем уже известную информацию о начале образца, а не начинаем сравнение с нуля. Почему это важно: наивный поиск при срыве возвращается на одну позицию назад по тексту и снова сравнивает уже проверенные символы — это даёт O(n·m) в худшем случае (например, текст "aaaa…a", образец "aaab" — каждое срывание стоит почти m сравнений). КМП не возвращается по тексту и работает строго O(n + m) — именно эту линейность просят объяснить на экзамене.

КМП ищет подстроку за O(n + m) без отката по тексту, используя префикс-функцию π[i] — длину наибольшего собственного префикса, совпадающего с суффиксом подстроки s[0..i]. «Собственного» означает, что префикс не может равняться всей строке; например, для "abab" префикс-функция равна [0,0,1,2]. Почему «собственный»: если разрешить префикс равный всей строке, то для строки "aaaa" вы получили бы значение 4 для π[3], и алгоритм зациклился бы — это самая частая ошибка при написании префикс-функции с нуля.

Расширенный пример вычисления π. Возьмём s = "ababcabab"; посчитаем префикс-функцию шаг за шагом:

i s[0..i] π[i] Объяснение
0 a 0 У одного символа собственный префикс пуст
1 ab 0 Префикс "a" не совпадает с суффиксом "b"
2 aba 1 Префикс "a" совпал с суффиксом "a"
3 abab 2 Префикс "ab" совпал с суффиксом "ab"
4 ababc 0 Ничего общего
5 ababca 1 Только "a"
6 ababcab 2 "ab"
7 ababcaba 3 "aba"
8 ababcabab 4 "abab"

Эта таблица — и есть «зеркало отката»: если сравнение сорвалось на позиции i, мы знаем, что безопасно продолжать сравнивать с позиции π[i−1], не сдвигая i.

Пошаговый алгоритм:

  1. Считаем префикс-функцию образца: для каждой позиции i находим длину самого длинного префикса, который совпадает с суффиксом, заканчивающимся в i.
  2. Идём по тексту одним указателем i, а по образцу — указателем j.
  3. Если символы совпали, увеличиваем j; при j == m — нашли вхождение.
  4. Если не совпали, «откатываем» j по префикс-функции (j = π[j-1]), не трогая i — в этом весь смысл: текст назад не читаем.
  5. Если j == 0 и символы не совпали, просто сдвигаем i вперёд.

Подсказка для запоминания. Думайте о π как о «страховочной сети»: при срыве мы падаем не до нуля, а только до того места, где уже было совпадение начала образца с его «хвостом». Эту же π-таблицу можно использовать повторно, если ищете тот же образец много раз в разных текстах (например, в задачах на обработку логов).

Типичная ошибка КМП. (1) В цикле отката писать i-- — это превращает алгоритм в наивный поиск; принципиально, что i никогда не уменьшается. (2) Считать π для пустого образца — массив нулевой длины, нужно отдельно обработать m == 0. (3) Использовать int pi[256] при образце длиннее 256 символов — будет выход за границы; для экзамена размер обычно дают в условии, но в реальном коде выделяйте память динамически. (4) Возвращать i вместо i - m + 1 — позиция начала вхождения сдвинута на длину образца, это легко забыть.

#include <stdio.h>
#include <string.h>

void prefix_function(const char *s, int *pi, int n) {
    pi[0] = 0;                                         // у одного символа собственный префикс пуст
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];                             // начинаем с предыдущего значения
        while (j > 0 && s[i] != s[j]) j = pi[j - 1];   // откат по префиксу, пока не совпадёт
        if (s[i] == s[j]) j++;                         // символы совпали — удлиняем префикс
        pi[i] = j;
    }
}

int kmp_search(const char *text, const char *pat) {    // индекс первого вхождения или -1
    int n = (int)strlen(text), m = (int)strlen(pat);
    if (m == 0) return 0;                              // пустой образец считаем найденным
    int pi[256];                                       // массив под длину образца
    prefix_function(pat, pi, m);
    int j = 0;                                         // j — сколько символов образца уже совпало
    for (int i = 0; i < n; i++) {
        // Несовпадение: откатываемся по префикс-функции, НЕ сдвигая i
        while (j > 0 && text[i] != pat[j]) j = pi[j - 1];
        if (text[i] == pat[j]) j++;
        if (j == m) return i - m + 1;                  // нашли; можно вернуть все вхождения
    }
    return -1;
}

int main(void) {
    printf("%d\n", kmp_search("abracadabra", "cad"));  // 4
    return 0;
}

Где ещё встречается π-функция. Префикс-функция используется не только в КМП: с её помощью строят Z-функцию, решают задачи на поиск всех вхождений, проверяют, является ли строка периодом другой строки (s = t + t + …), ищут самую длинную «рамку» палиндрома. На экзамене достаточно знать саму π и КМП, но если преподаватель спросит «а зачем ещё нужна префикс-функция» — эти примеры будут к месту. Наивный поиск — O(n·m); КМП линейный. Префикс-функция же лежит в основе поиска всех периодов строки (минимальный период n - π[n-1]).

Что такое префикс-функция (простое определение). Для каждой позиции i в строке s значение π[i] — это длина самого длинного собственного префикса подстроки s[0..i], который одновременно является её суффиксом. Слово «собственный» означает «короче всей подстроки», то есть она сама целиком не считается. Пример: для "abab" имеем π = [0, 0, 1, 2], потому что:

  • позиция 0, подстрока "a" — нет собственного совпадения;
  • позиция 1, подстрока "ab" — нет совпадения;
  • позиция 2, подстрока "aba" — префикс "a" совпадает с суффиксом "a", длина 1;
  • позиция 3, подстрока "abab" — префикс "ab" совпадает с суффиксом "ab", длина 2.

Почему КМП работает за O(n+m). При несовпадении в наивном поиске мы сдвигаем образец на одну позицию и начинаем сравнение заново — теряя уже накопленную информацию о совпавшем «хвосте». Префикс-функция как раз запоминает этот хвост и позволяет сдвинуть образец так, чтобы он стал новым началом сравнения. Указатель i по тексту движется строго вперёд (всего n шагов), указатель j по образцу в среднем тоже движется вперёд, а суммарное число откатов не превышает m. Итого линейное время.

Типичная ошибка при решении. Считать, что откат j «теряет» уже найденное совпадение, и вернуться к сравнению с начала — это как раз превращает КМП обратно в наивный алгоритм. Запомните: указатель i по тексту идёт строго вперёд, назад откатывается только j по образцу. Вторая ошибка — неверно вычислить саму префикс-функцию (например, забыть про цикл while, который может откатывать несколько раз). На экзамене полезно показать префикс-функцию на маленьком примере ("abab" → 0 0 1 2) — это доказывает, что вы понимаете определение, а не заучили код.

Связь с задачей «найти период строки». Минимальный период строки длины n равен n - π[n-1], если n делится на это число без остатка; иначе строка непериодична. Это прямое следствие определения префикс-функции, и такой подвопрос часто добавляют к задаче про КМП.

Полезный факт для экзамена. Значение π[n-1] — это длина самого длинного «бордера» строки (нетождественного ей префикса, равного суффиксу). Если n - π[n-1] делит n, то вся строка — повторение блока длиной n - π[n-1] ровно n / (n - π[n-1]) раз. Эту связь любят спрашивать в виде «докажите, что строка "aaaa" имеет период 1» или «чему равна π для "abcabcabcabc"».

Топологическая сортировка (алгоритм Вирта)

Простыми словами. Топологическая сортировка расставляет вершины графа в таком порядке, чтобы каждая зависимость шла раньше того, что от неё зависит. Аналогия: порядок надевания одежды — носки раньше ботинок, рубашка раньше пиджака; нарушить порядок нельзя, иначе получится нелепо. В программировании — это порядок сборки проекта (библиотеки раньше приложения, которое их использует), порядок прохождения курсов (пререквизиты раньше курсов, которые на них опираются), порядок вычисления формул (внутренние скобки раньше внешних).

Определение. Топологическая сортировка упорядочивает вершины ориентированного ациклического графа (DAG) так, что для каждого ребра u → v вершина u идёт раньше v. Ключевое условие — ацикличность: если в графе есть цикл (A зависит от B, B от A), корректного порядка не существует. Граф с циклом — например, «A любит B, B любит A» — отсортировать нельзя, и любая попытка выдаст противоречие.

Почему работает именно DFS. Обход в глубину естественным образом откладывает вершину «на потом»: сначала мы полностью разбираемся со всеми её потомками и только потом возвращаемся к самой вершине. Это значит, что к моменту добавления вершины в order все её потомки уже там. После разворота массива потомки оказываются позже, а сама вершина — раньше, что и есть требуемый порядок.

Алгоритм Вирта (обход в глубину): запускаем DFS, добавляем вершину в список после обработки всех её потомков, затем разворачиваем список. Обнаружение серой вершины (вершины, которая ещё в текущем стеке рекурсии) означает цикл.

#include <stdio.h>
#include <stdbool.h>

#define N 6

int  g[N][N] = {{0,1,1,0,0,0}, {0,0,0,1,0,0}, {0,0,0,1,0,0},
                {0,0,0,0,1,1}, {0,0,0,0,0,0}, {0,0,0,0,0,0}};
int  color[N];      // 0 — белая, 1 — серая (в стеке), 2 — чёрная
int  order[N], k = 0;
bool cycle = false;

void dfs(int v) {
    color[v] = 1;                                    // красим в серый: мы «внутри» этого поддерева
    for (int u = 0; u < N; u++)
        if (g[v][u]) {                               // есть ребро v -> u
            if (color[u] == 0) dfs(u);               // белого обходим
            else if (color[u] == 1) cycle = true;    // серый — обратное ребро, значит цикл
        }
    color[v] = 2;                                    // обход поддерева закончен — чёрный
    order[k++] = v;                                  // добавляем ПОСЛЕ потомков
}

int main(void) {
    for (int v = 0; v < N; v++) if (color[v] == 0) dfs(v);
    if (cycle) { puts("граф содержит цикл"); return 1; }
    for (int i = N - 1; i >= 0; i--) printf("%d ", order[i]);   // разворачиваем
    putchar('\n');
    return 0;
}

Зачем три цвета. Цветовая разметка — это способ совместить поиск топологического порядка и проверку ацикличности в одном проходе. Белая (0) — вершина ещё не посещалась; серая (1) — мы сейчас находимся «внутри» её поддерева (она лежит в стеке рекурсии); чёрная (2) — поддерево полностью обработано. Если из серой вершины ведёт ребро в серую — значит, мы пришли в вершину, обработка которой ещё не закончилась, то есть нашли цикл. Это классическая «трёхцветная» DFS из учебника Кормена, и именно её просят воспроизвести на доске.

Альтернатива — алгоритм Кана: многократно выбираем вершину с нулевой входящей степенью; если таких не осталось, а вершины есть, граф содержит цикл. Сложность обоих — O(V + E), то есть линейна по размеру графа. Алгоритм Кана удобнее, когда нужно одновременно получать вершины в порядке обработки (например, при параллельном планировании задач) — там не нужна рекурсия и нет риска переполнения стека на больших графах.

Почему список разворачивают. В DFS вершина добавляется в order только после того, как обработаны все её потомки, то есть «самые зависимые» попадают в список раньше. Разворот ставит зависимости в начало. Это ключевая деталь, и её спрашивают: если забыть развернуть, получится корректный, но обратный по смыслу порядок.

Типичная ошибка. Использовать топологическую сортировку на графе с циклом и не проверить это: без проверки «серых» вершин алгоритм выдаст какой-то порядок, который молча нарушит зависимость. Также путают матрицу смежности (g[v][u]) и список смежности — оба допустимы, но для разреженных графов список даёт O(V + E) по памяти вместо O(V²). Ещё одна частая ошибка — забыть про обнуление k или массива color при повторном запуске DFS в одной программе, и тогда второй заход «молча» пропустит уже обработанные вершины.

Практическая подсказка. Если на экзамене нужно одновременно отсортировать и проверить граф на цикл — не пишите две отдельные процедуры, покажите трёхцветный DFS (как в примере выше): это одна конструкция, она решает обе задачи и занимает меньше места на листе.

Перестановки (алгоритм Нарайаны)

Простыми словами. Алгоритм Нарайаны выдаёт следующую перестановку в лексикографическом порядке — то есть «следующую по алфавиту» комбинацию из того же набора элементов. Аналогия: представьте себе тетрадь, где записаны все перестановки цифр 1..n, отсортированные «как в словаре» — сначала 123…n, потом 123…(n-1)n, и так далее до полностью убывающей. Алгоритм Нарайаны — это способ перелистать такую тетрадь: он по текущей странице за O(n) находит следующую. Это стандартный способ перебрать все n! перестановок, и его часто просят вместо рекурсивного перебора.

Определение. Алгоритм Нарайаны порождает следующую перестановку в лексикографическом порядке: найти самый правый подъём, поменять с минимальным большим элементом справа, развернуть суффикс. Всего n! перестановок, общее время O(n·n!). Обратите внимание: суммарное время работы O(n·n!) означает, что в среднем на одну перестановку тратится O(n), а не O(n!) — этот нюанс любят уточнять на экзамене.

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

Пошагово (эти три шага спрашивают наизусть):

  1. Идём справа налево и ищем первый элемент, который меньше своего правого соседа (самый правый подъём). Если такого нет — это последняя перестановка, возвращаем false.
  2. Справа от него ищем минимальный элемент, больший найденного, и меняем их местами.
  3. Хвост справа от позиции i разворачиваем — он после обмена оказался в порядке убывания, а нам нужен возрастающий.
#include <stdio.h>
#include <stdbool.h>

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }

bool next_permutation(int *a, int n) {
    int i = n - 2;
    // Шаг 1: ищем самый правый подъём a[i] < a[i+1].
    // Если его нет, массив убывает и это последняя перестановка
    while (i >= 0 && a[i] >= a[i + 1]) i--;
    if (i < 0) return false;

    // Шаг 2: справа ищем минимальный элемент, который больше a[i]
    int j = n - 1;
    while (a[j] <= a[i]) j--;
    swap(&a[i], &a[j]);

    // Шаг 3: суффикс после i уже убывает — разворачиваем его в возрастающий
    for (int l = i + 1, r = n - 1; l < r; l++, r--)
        swap(&a[l], &a[r]);
    return true;
}

int main(void) {
    int a[] = {1, 2, 3}, n = 3;
    do {
        for (int i = 0; i < n; i++) printf("%d", a[i]);
        putchar(' ');
    } while (next_permutation(a, n));
    putchar('\n');    // 123 132 213 231 312 321
    return 0;
}

Пример по шагам (часто просят на доске). Возьмём перестановку 1 4 3 2:

  • Шаг 1: ищем самый правый подъём справа налево. a[2]=3, a[3]=2 → 3 < 2 нет; a[1]=4, a[2]=3 → 4 < 3 нет; a[0]=1, a[1]=4 → 1 < 4 да, нашли i=0. Суффикс справа [4, 3, 2] уже убывает — это «кандидат» на минимальное изменение.
  • Шаг 2: справа от i=0 ищем минимальный элемент, больший 1. Среди [4, 3, 2] это 2 (на позиции j=3). Меняем a[0] и a[3]: получаем 2 4 3 1.
  • Шаг 3: разворачиваем суффикс начиная с позиции 1: [4, 3, 1] → [1, 3, 4]. Итог: 2 1 3 4.

Связь с C++. В стандартной библиотеке C++ есть готовый std::next_permutation, который работает ровно по этому алгоритму и применяется в одну строку. В Си приходится писать свой — и именно этот код чаще всего просят на собеседованиях и экзаменах МИФИ/МГУ/МФТИ.

Типичная ошибка. Использовать a[i] > a[i+1] вместо a[i] >= a[i+1] на шаге 1 — это даёт неверный подъём для массивов с равными элементами (в перестановках равных нет, поэтому в учебных задачах часто «проходит», а на экзамене с произвольными элементами — нет). Вторая ошибка — забыть развернуть хвост на шаге 3: алгоритм вернёт «следующую по смыслу, но не лексикографическую» перестановку. Третья — не обработать случай, когда i < 0 (массив полностью убывает) и забыть вернуть false: тогда цикл в main уйдёт в бесконечность. Четвёртая — на шаге 2 искать строго больший, забыв условие a[j] <= a[i] и взяв первый попавшийся, который может оказаться слишком большим (нарушится минимальность изменения).

Практическая подсказка. Если нужно перебрать перестановки с повторениями или сочетания фиксированной длины из n элементов, алгоритм Нарайаны напрямую не подходит — там используются другие техники (рекурсия с маской, next_combination Дейкстры, генерация по битовым маскам). Нарайана — это именно про перестановки фиксированного размера без повторений. На экзамене перед применением стоит за 10 секунд проговорить себе: «у меня все элементы различны? если да — Нарайана подходит».

Типовые задачи с решениями

Это ядро практической части: на контрольных и экзаменах МИФИ, МГУ и МФТИ распространены задачи «без стандартных функций». Ниже — постановка, идея и компактный рабочий код. Каждая задача разобрана по единому шаблону, по которому удобно отвечать и на бумаге, и у доски:

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

Практическая подсказка. Преподаватели МИФИ и МФТИ часто проверяют три вещи: (1) корректность граничных случаев (ноль, отрицательные числа, пустая строка); (2) осознанный выбор типа данных и (3) способность обойтись без «запрещённой» функции. Сказанные вслух «я отдельно обработал n=0» и «для n>20 будет переполнение unsigned long long» сразу повышают оценку на 1–2 балла.

Функции без % и pow

Этот блок объединяет задачи, в которых нельзя пользоваться оператором % или функцией pow. Общая идея — реализовать математические операции через сложение, вычитание, циклы и побитовые трюки. Все три задачи ниже часто идут «подряд» на одной контрольной.

НОД (алгоритм Евклида).

Простое определение. НОД (наибольший общий делитель, gcd) двух чисел — это самое большое число, на которое оба числа делятся без остатка. Например, gcd(48, 36) = 12, потому что 48 = 12·4 и 36 = 12·3, а числа больше 12 уже не подходят.

  • Постановка: даны два натуральных числа a и b; найти их наибольший общий делитель — максимальное число, на которое делятся оба.
  • Идея словами: gcd(a, b) = gcd(b, a mod b), и процесс сходится к базе b == 0, где ответ равен a. Смысл в том, что общий делитель не меняется при замене a на остаток от деления.
  • Почему это работает (бытовая аналогия): представьте две стопки монет. Вы откладываете a mod b — это «остаток», который физически невозможно разложить поровну на b кучек. Если монета не делит a, она не будет делить и остаток — а значит, искать НОД можно только среди делителей остатка. Так «лишнее» отсекается шаг за шагом, пока одна из стопок не обнулится.
  • Алгоритм: пока b не ноль — запомнить остаток a % b, присвоить a = b, b = остаток. Когда b == 0, вернуть a. Если % запрещён — вычитать меньшее из большего, пока числа не станут равны (это медленнее, O(max(a,b))).
  • Типичная ошибка: перепутать порядок присваиваний (a = t; b = t; вместо a = b; b = t;), из-за чего алгоритм зацикливается; либо не обработать нули — gcd(a, 0) = a, и это корректный граничный случай, который тоже нужно назвать.
  • Дополнительно для ответа у доски: приведите «след» алгоритма для пары (48, 36): (48,36) → (36,12) → (12,0) ⇒ 12. Такой пример сразу показывает, что вы понимаете инвариант gcd(a,b) не меняется по ходу цикла.
#include <stdio.h>

int gcd(int a, int b) {                  // делением, O(log min(a,b))
    while (b) { int t = a % b; a = b; b = t; }
    return a;
}

int gcd_sub(int a, int b) {              // без %, вычитанием, O(max(a,b))
    while (a != b) {
        if (a > b) a -= b;
        else       b -= a;
    }
    return a;
}

int lcm(int a, int b) {                  // через НОД: a / gcd * b без переполнения
    return a / gcd(a, b) * b;
}

int main(void) {
    printf("gcd=%d lcm=%d gcd_sub=%d\n", gcd(48, 36), lcm(12, 18), gcd_sub(48, 36));
    return 0;   // 12 36 12
}

Практическая подсказка по стилю. На экзамене пишите a / gcd(a,b) * b, а не a * b / gcd(a,b). Деление выполняется первым, и вероятность переполнения a * b исчезает. Это замечают преподаватели.

Возведение в степень.

Простое определение. Возведение в степень base^exp — это умножение base самого на себя exp раз. Для exp = 0 результат по соглашению равен 1 (даже для base = 0: 0^0 = 1 в комбинаторике, хотя математики спорят).

  • Постановка: вычислить base^exp для целых чисел.
  • Идея словами: обычный цикл умножает exp раз (O(exp)), а двоичный метод использует запись показателя степени в двоичном виде: base^13 = base^(8+4+1), и мы «собираем» результат из уже возведённых в квадрат оснований.
  • Почему это работает (бытовая аналогия): представьте, что у вас есть 13 одинаковых стульев, и вы хотите понять, сколько клеток на шахматной доске занято ими. Вы не считаете по одному — вы кладёте стул на 1-ю клетку, на 2-ю кладёте два стула, на 4-ю — четыре, на 8-ю — восемь, а потом просто «снимаете» нужные позиции, смотря на двоичную запись 13 = 1101₂. На каждом шаге количество стульев удваивается — поэтому шагов всего log₂(exp).
  • Алгоритм (двоичный): пока exp > 0: если младший бит равен 1 — умножить результат на текущее основание; возвести основание в квадрат; сдвинуть exp вправо на бит. Каждое умножение удваивает степень, поэтому шагов O(log exp).
  • Типичная ошибка: забыть умножить результат на «текущее» основание, когда бит установлен, или случайно возвести в квадрат сам результат вместо основания. Второе — игнорировать переполнение: 3^40 уже не влезает в long long, о чём стоит сказать на экзамене.
  • Что проговорить у доски: отдельно упомяните, что для exp = 0 цикл не выполняется ни разу, и функция корректно возвращает 1. Также отметьте, что для отрицательных exp алгоритм даст 0 (из-за while (exp > 0)) — и это надо либо отдельно обработать (возвращать 0 для целочисленного деления), либо оговорить, что функция работает только для exp ≥ 0.
#include <stdio.h>

long long power(long long base, int exp) {          // обычное, O(exp)
    long long r = 1;
    while (exp-- > 0) r *= base;
    return r;
}

long long power_fast(long long base, int exp) {     // двоичное, O(log exp)
    long long r = 1;
    while (exp > 0) {
        if (exp & 1) r *= base;                     // бит установлен — умножаем
        base *= base;                               // возводим основание в квадрат
        exp >>= 1;
    }
    return r;
}

int main(void) {
    printf("%lld %lld\n", power(3, 5), power_fast(3, 5));   // 243 243
    return 0;
}

Практическая подсказка. Если в условии сказано «без умножения» (бывает!), используйте сложение в цикле: for (int i = 0; i < exp; i++) r += base; — но это уже совсем редкий случай и его обычно просят явно.

Факториал.

Простое определение. Факториал n! — это произведение всех целых чисел от 1 до n. По соглашению 0! = 1 (это нужно для формул сочетаний: «выбрать 0 элементов из n» равно 1). Факториалы растут очень быстро — быстрее, чем любая экспонента с фиксированным основанием.

  • Постановка: вычислить n! = 1 · 2 · … · n (соглашение 0! = 1).
  • Идея словами: итеративно накапливаем произведение (r *= i), а рекурсивная форма выражает n! = n · (n-1)! с базой 0! = 1. Итерация предпочтительнее: не тратит стек и не рискует переполнить его при большом n.
  • Почему итерация лучше рекурсии (бытовая аналогия): рекурсия — это как стопка бумажек на столе: каждый новый вызов кладёт бумажку сверху, а каждый возврат снимает. При n = 100000 стопка становится опасно высокой и может «обрушиться» (переполнение стека). Итерация же работает с одной бумажкой — той, на которой пишется текущее произведение.
  • Алгоритм: проверить, что n >= 0; начать с r = 1; пройти i от 2 до n, умножая r; вернуть r. Тип результата — unsigned long long, чтобы отодвинуть переполнение.
  • Типичная ошибка: потерять соглашение 0! = 1 (если начать произведение с 0, всё обнулится); либо забыть про переполнение — 13! уже не влезает в int (максимум 12! = 479 001 600), а 21! переполняет даже long long. На экзамене укажите границы явно: 12! < INT_MAX < 13! и 20! < ULLONG_MAX < 21!.
  • Что проговорить у доски: отдельно скажите, почему цикл идёт от 2, а не от 1 — это микрооптимизация (умножение на 1 ничего не меняет), но преподаватели любят, когда студент её упоминает. Также честно отметьте ограничения: «для n > 20 функция возвращает переполненное значение, что в Си не считается UB для беззнаковых типов (оно «оборачивается»), но практический смысл теряется».
#include <stdio.h>

unsigned long long factorial(int n) {     // итеративно, без рекурсии
    unsigned long long r = 1;
    for (int i = 2; i <= n; i++) r *= i;  // n > 20 — переполнение
    return r;
}

int main(void) {
    for (int i = 0; i <= 10; i++) printf("%d! = %llu\n", i, factorial(i));
    return 0;
}

Практическая подсказка. Если на экзамене попросят факториал для очень больших n («не помещается даже в unsigned long long»), предложите возвращать результат по модулю (например, 1e9+7) — это стандартный приём в спортивном программировании и показывает, что вы понимаете проблему.

Строки без библиотечных функций

Простое определение. В Си «строка» — это не отдельный тип, а просто массив байт, заканчивающийся нулевым символом '\0' (ASCII код 0). Длина строки определяется позицией этого терминатора, а не отдельным полем, как в Pascal или C#. Это удобно для простых функций, но требует постоянной осторожности с границами буфера.

  • Постановка: написать собственные версии strlen, strcpy, strcat, strchr, strcmp, strstr, strrev, strtrim без использования библиотеки.
  • Идея словами: все строковые функции — это цикл по байтам до '\0'; различается лишь то, что делать по пути (считать, копировать, сравнивать, искать). Освоив один раз общий приём «идём указателем, пока *p != '\0'», вы напишете любую из них.
  • Почему именно цикл до '\0' (бытовая аналогия): представьте длинный коридор, в конце которого горит красная лампочка ('\0'). Вы идёте, считаете двери, копируете номера, ищете нужную табличку — но всегда останавливаетесь у красной лампочки и не переступаете её. Если вы случайно шагнёте дальше — вы в «неизвестной территории» и можете упасть (segfault) или прочитать мусор.
  • Пошаговый алгоритм (общий): завести рабочий указатель; в цикле проверять текущий байт; для копирования — переносить байт и двигать оба указателя; остановиться на '\0' и не забыть его скопировать/учесть.
  • Ключевые правила: не выходить за '\0', не забывать терминатор в буфере назначения, следить за размером буфера и не писать в строковый литерал.
  • Типичная ошибка: while (*p++) вместо while (*p) — лишний сдвиг после нуля; или my_strlen, возвращающая int вместо size_t. Разбор остальных ловушек — после кода.
  • Дополнительные подводные камни (часто снижают балл):
    • Сравнение через (unsigned char) — my_strcmp обязана приводить байты к unsigned char, иначе символы с кодами ≥ 128 (русские буквы в CP1251) сравниваются некорректно. Это любимый «подвох» на МФТИ.
    • strchr ищет и '\0' — стандартная семантика: если ищем '\0', функция возвращает указатель на терминатор, а не NULL. В коде это реализовано через *s == (char)c.
    • Контракт strcpy — функция обязана вернуть указатель на начало dst, а не на новый конец. Это позволяет вызывать её в цепочке: my_strcpy(buf, my_strcat(a, b)).
    • «Наивный» strstr — квадратичный по времени (O(n·m)). На экзамене этого достаточно, но если спросят «а как быстрее?» — упомяните алгоритм Кнута–Морриса–Пратта (KMP).
#include <stdio.h>
#include <stddef.h>

size_t my_strlen(const char *s) {              // длина без '\0'
    const char *p = s;
    while (*p) p++;
    return (size_t)(p - s);
}

char *my_strcpy(char *dst, const char *src) {  // копирует вместе с '\0'
    char *d = dst;
    while ((*d++ = *src++)) { }                // присваивание и проверка в одном месте
    return dst;
}

char *my_strcat(char *dst, const char *src) {  // дописывает в конец
    char *d = dst;
    while (*d) d++;                            // идём к терминатору
    while ((*d++ = *src++)) { }
    return dst;
}

char *my_strchr(const char *s, int c) {        // первый символ c или NULL
    while (*s && *s != (char)c) s++;
    return (*s == (char)c) ? (char *)s : NULL;  // важно: ищет и '\0'
}

int my_strcmp(const char *a, const char *b) {  // <0, 0, >0
    while (*a && *a == *b) { a++; b++; }
    return (unsigned char)*a - (unsigned char)*b;
}

char *my_strstr(const char *hay, const char *needle) {   // наивный поиск
    if (!*needle) return (char *)hay;
    for (; *hay; hay++) {
        const char *h = hay, *n = needle;
        while (*h && *n && *h == *n) { h++; n++; }
        if (!*n) return (char *)hay;
    }
    return NULL;
}

void my_strrev(char *s) {                      // разворот на месте
    size_t n = my_strlen(s);
    for (size_t i = 0; i < n / 2; i++) {
        char t = s[i]; s[i] = s[n - 1 - i]; s[n - 1 - i] = t;
    }
}

char *my_strtrim(char *s) {                    // убирает пробелы по краям (на месте)
    while (*s == ' ' || *s == '\t') s++;
    char *end = s + my_strlen(s);
    while (end > s && (end[-1] == ' ' || end[-1] == '\t')) end--;
    *end = '\0';
    return s;
}

int main(void) {
    char buf[64] = "  Hello  ";
    printf("len=%zu\n", my_strlen("abc"));                    // 3
    printf("trim=[%s]\n", my_strtrim(buf));                    // [Hello]
    my_strcpy(buf, "мир");
    my_strcat(buf, "!");
    printf("%s %d %s\n", buf, my_strcmp("a", "b"), my_strstr("abracadabra", "cad"));
    my_strrev(buf);
    printf("%s\n", buf);                                       // !рим
    return 0;
}

Практическая подсказка по стилю ответа. В МИФИ и МГУ любят, когда студент пишет перед каждой функцией однострочное «что делает» (например, // длина без '\0'), а перед main — короткий сценарий тестов. Это сразу отличает «работающий код» от «вызубренного кода». Если времени мало — лучше написать три функции с комментариями и без main, чем все восемь без единого комментария. Разбор ловушек: my_strcpy возвращает dst для цепочек (например, printf("%s", my_strcpy(buf, src)) — благодаря этому вызовы можно вкладывать друг в друга, как матрёшку); my_strchr обязан находить и терминатор (по стандарту strchr(s, '\0') возвращает указатель на конец строки — это используется внутри strlen и нужно для корректной работы со «срезом» строки); my_strcmp приводит к unsigned char, иначе сравнение русских букв даст неверный знак. Запомните приём while ((*d++ = *src++)) { }: присваивание и проверка совмещены, потому что значение выражения — скопированный символ, и цикл останавливается на '\0' (он уже скопирован, но не считается телом). Аналогия: представьте конвейер с коробками — *src++ снимает коробку с ленты источника, *d++ кладёт её на ленту приёмника, а оператор каждый раз проверяет «коробка пуста?» и останавливает конвейер, как только встречает пустую (терминатор). Коробка-терминатор тоже кладётся на ленту приёмника, но после неё конвейер стоит.

Три функции, на которых чаще всего срезаются. Это функции, ошибки в которых не видны на простых ASCII-тестах, но взрываются на реальных данных — отсюда и название «ловушки».

(1) my_strcmp без приведения к unsigned char: если char знаковый (а по умолчанию в большинстве компиляторов он знаковый), байт 200 превратится в −56, и сравнение даст неверный знак — для ASCII это незаметно (все коды ≤ 127), для кириллицы в CP1251 (где русские буквы имеют коды 128–255) критично. Пример: сравнение строк "Яблоко" и "Апельсин" без unsigned char даст противоположный результат, потому что 'Я' (0xDF = 223) как знаковое равно −33, а 'А' (0xC0 = 192) равно −64, и в знаковом сравнении −33 > −64, хотя лексикографически "Я" должна быть позже.

(2) my_strncpy без гарантии '\0': если источник длиннее n, терминатор не попадёт в буфер, и последующая printf("%s") прочитает мусор — то есть выведет строку вплоть до первого случайного нулевого байта в памяти. Стандартная strncpy дополняет остаток нулями именно для этой защиты; если вы реализуете свою версию, легко забыть этот момент и нарваться на тяжело отлаживаемый «плавающий» баг.

(3) my_strstr с пустой подстрокой: по стандарту она считается найденной в начале, и это нужно обработать отдельным if (!*needle) return (char *)hay;, иначе цикл не сработает и вернётся NULL вместо указателя на начало. Аналогия: «найти пустоту внутри чего-то» — ответ всегда «вот это что-то», потому что пустая подстрока тривиально содержится в любой позиции строки (как пустое множество ⊆ любого множества).

Как проверять себя на бумаге. Возьмите строку "Hi!" и «прогоните» свои функции вручную, отмечая положение указателей; так сразу видны лишние сдвиги и забытые терминаторы. Экзаменатор часто просит не код, а именно прогон на короткой строке — тренируйте этот навык. Практический совет: рисуйте таблицу с колонками «шаг», *src, *dst и обновляйте её после каждой итерации цикла, пока не дойдёте до терминатора — это нагляднее, чем держать всё в голове, и сразу видно, где указатель «убежал» на одну позицию дальше, чем нужно. Полезно также прогонять функции на «граничных» строках: пустая "", строка из одного символа, строка длиной ровно n (для strncpy) — большинство ошибок прячется именно на таких входах.

Совершенные, автоморфные, триморфные числа и простые числа Софи Жермен

В этой секции собраны четыре задачи на числа, которые на экзаменах МИФИ, МГУ и МФТИ часто дают в связке. Все они опираются на один и тот же базовый приём — перебор делителей или цифр — но с разными вариациями. Разобрав их вместе, вы увидите общий шаблон и сэкономите время на экзамене: научитесь одну — остальные получите «в довесок».

Совершенное число.

  • Определение: натуральное число, равное сумме всех своих собственных делителей (то есть всех делителей, кроме самого числа). Классические примеры: 6 = 1 + 2 + 3, 28 = 1 + 2 + 4 + 7 + 14, 496, 8128. Эти числа были известны ещё Евклиду (IV век до н. э.), и до сих пор открытый вопрос — бесконечно ли их количество. Аналогия: представьте пирог, который идеально делится на кусочки — все части (кроме целого пирога) вместе снова составляют тот же самый пирог.
  • Постановка: найти числа, равные сумме своих собственных делителей (само число делителем не считается).
  • Идея словами: перебирать делители до sqrt(n) и при каждом найденном d прибавлять сразу пару d и n/d — это ускоряет проверку с O(n) до O(√n). Почему это работает: делители всегда идут парами (если d делит n, то и n/d делит n), и один из пары всегда не превышает √n, поэтому достаточно проверить только «маленькие» делители.
  • Алгоритм: если n < 2 — не совершенное; начать сумму с 1 (единица — делитель любого n ≥ 1); для d от 2 до √n добавлять d и n/d, не добавляя n/d дважды при d == n/d (это случай точного квадратного корня, например d = 6 для n = 36); сравнить сумму с n.
  • Типичная ошибка: учитывать само число как делитель (тогда совершенных чисел не найдётся вообще — сумма всегда строго больше n) или забыть про пару n/d, из-за чего проверка неверна для 28 и 496. Дополнительная ловушка — for (d = 2; d <= sqrt(n); d++) без пересчёта sqrt(n) в цикле: из-за целочисленного округления можно либо пропустить делитель, либо захватить лишний; безопаснее условие d * d <= n (с приведением к long long при больших n).

Совершенное число равно сумме собственных делителей (6 = 1+2+3, 28, 496, 8128).

#include <stdio.h>

int is_perfect(int n) {                       // O(sqrt(n))
    if (n < 2) return 0;
    int sum = 1;
    for (int d = 2; d * d <= n; d++)
        if (n % d == 0) { sum += d; if (d != n / d) sum += n / d; }
    return sum == n;
}

int main(void) {
    for (int n = 2; n <= 10000; n++)
        if (is_perfect(n)) printf("%d ", n);   // 6 28 496 8128
    putchar('\n');
    return 0;
}

Автоморфное число. Квадрат оканчивается самим числом: 5² = 25, 6² = 36, 25² = 625, 76² = 5776.

  • Определение: натуральное число n, последние цифры квадрата которого совпадают с самим n. Термин «автоморфный» происходит от греческих корней «авто» (сам) и «морфе» (форма) — число буквально «принимает свою форму» при возведении в квадрат. Аналогия: представьте, что вы смотрите на квадрат через «окно» шириной в k цифр — если в этом окне видно ровно исходное число, значит оно автоморфное.
  • Идея словами: нужно отрезать от квадрата столько младших цифр, сколько цифр в самом числе, и сравнить с числом. Этого добиваются остатком по модулю 10^k, где k — число цифр.
  • Алгоритм: вычислить квадрат в long long (иначе для 76² = 5776 в int ещё помещается, но для 376² = 141 376 уже тесно, а для 625² = 390 625 — подавно); посчитать mod = 10^k, деля число на 10; вернуть sq % mod == n.
  • Типичная ошибка: считать число цифр делением копии (for (t = n; t; t /= 10) mod *= 10;), а не самого n — если случайно использовать n /= 10, оригинальное число испортится и сравнивать будет не с чем; и вычислить квадрат в int — переполнение исказит результат, и функция начнёт «находить» несуществующие автоморфные числа. Дополнительная ошибка: забыть инициализировать mod = 1 — тогда mod *= 10 на каждой итерации будет давать 0, и для однозначных чисел sq % 0 — вообще неопределённое поведение.
#include <stdio.h>

int is_automorphic(int n) {
    long long sq = (long long)n * n;
    int mod = 1;
    for (int t = n; t > 0; t /= 10) mod *= 10;   // 10^k, где k — число цифр n
    return sq % mod == n;
}

int main(void) {
    for (int n = 1; n <= 100; n++)
        if (is_automorphic(n)) printf("%d ", n);  // 1 5 6 25 76
    putchar('\n');
    return 0;
}

Триморфное число. Куб оканчивается самим числом: 4³ = 64, 5³ = 125, 6³ = 216, 24³ = 13824, 49³ = 117649.

  • Определение: натуральное число n, последние цифры куба которого совпадают с самим n. Название образовано аналогично автоморфному: «три» от «третьей степени». Задача — почти брат-близнец автоморфного числа, только вместо квадрата берётся куб; на экзамене их часто дают парой и просят написать обе по одному шаблону.
  • Идея словами: та же, что у автоморфного числа, но берётся куб и модуль 10^k. Автоморфное и триморфное — родственные задачи, и на экзамене их часто дают вместе как «сделайте обе одним шаблоном».
  • Алгоритм: вычислить куб в long long, найти mod = 10^k, сравнить cube % mod с n.
  • Типичная ошибка: вычислить куб как n * n * n в int — уже для 49³ = 117 649 промежуточные значения могут переполниться при больших n; приводите первый множитель к long long ((long long)n * n * n), и тогда всё произведение повышается до 64-битного типа. Дополнительная ошибка: перепутать порядок аргументов и сравнивать mod % cube — это бессмыслица, но от волнения случается.
#include <stdio.h>

int is_trimorphic(int n) {
    long long cube = (long long)n * n * n;
    int mod = 1;
    for (int t = n; t > 0; t /= 10) mod *= 10;
    return cube % mod == n;
}

int main(void) {
    for (int n = 1; n <= 100; n++)
        if (is_trimorphic(n)) printf("%d ", n);   // 1 4 5 6 9 24 25 49 51 75 76 99
    putchar('\n');
    return 0;
}

Простые числа Софи Жермен. Это простые p, для которых 2p + 1 тоже просто. Примеры: 2, 3, 5, 11, 23, 29, 41, 53, 83, 89, 113.

  • Определение и контекст: простое p называется числом Софи Жермен, если 2p + 1 тоже простое; такие простые называют «безопасными» (safe prime) — потому что если в криптографии взять простое q = 2p + 1, то группа точек эллиптической кривой или дискретный логарифм по модулю q оказываются защищены лучше. Названы в честь французского математика Софи Жермен (1776–1831), которая использовала их в работах по великой теореме Ферма и теории чисел. Считается, что таких простых бесконечно много, но строгого доказательства до сих пор нет (это открытая проблема, аналогичная гипотезе о простых близнецах). Аналогия: «двойная защита» — если и p, и 2p + 1 простые, то p особенно «крепко стоит на страже» простоты, как замок с двумя засовами.
  • Идея словами: нужна проверка на простоту, которую вызывают дважды — для p и для 2p + 1. Если оба вызова вернули «простое», то p — Софи Жермен. На экзамене обычно дают диапазон до нескольких сотен или тысяч, и этого хватает для проверки «в лоб».
  • Алгоритм проверки простоты: отбросить n < 2 (0 и 1 не простые); отдельно обработать чётные (n % 2 == 0 — простое только 2); перебирать нечётные делители до √n, потому что если у числа есть делитель больше √n, ему обязательно соответствует делитель меньше √n (они идут парой, и больший в паре — это частное меньшего).
  • Типичная ошибка: условие цикла d * d <= n без приведения к long long, из-за чего при больших n произведение d * d переполняется и цикл ведёт себя неправильно — например, для n около 50 000 d * d уже может выйти за INT_MAX; правильно: (long long)d * d <= n; либо проверять чётные делители, теряя скорость (если не сделать отдельную ветку для n % 2 == 0, цикл будет перебирать все d подряд, хотя половина из них заведомо не делители). Дополнительная ошибка: забыть return n == 2 в ветке для чётных — иначе двойка будет ошибочно считаться не простой, и проверка для p = 2 даст сбой.
#include <stdio.h>
#include <math.h>

int is_prime(int n) {                          // trial division, O(sqrt(n))
    if (n < 2) return 0;
    if (n % 2 == 0) return n == 2;
    for (int d = 3; (long long)d * d <= n; d += 2)
        if (n % d == 0) return 0;
    return 1;
}

int main(void) {
    for (int p = 2; p <= 200; p++)
        if (is_prime(p) && is_prime(2 * p + 1)) printf("%d ", p);
    putchar('\n');   // 2 3 5 11 23 29 41 53 83 89 113 131 173 179 191
    return 0;
}

Решето Эратосфена быстрее перебора для диапазона: сложность O(n log log n) против O(n√n) у trial division.

  • Что это (определение): Решето Эратосфена — классический алгоритм нахождения всех простых чисел до заданного n. Назван в честь древнегреческого математика Эратосфена (III в. до н. э.). Суть метода — постепенно «отсеивать» (англ. sieve — буквально «сито») составные числа, оставляя в массиве только простые. Простое число — это натуральное число больше 1, которое делится только на 1 и на себя.
  • Аналогия из жизни: представьте, что у вас на столе лежат карточки с числами от 2 до n рубашкой вверх (по умолчанию считаем их простыми). Вы берёте карточку «2», переворачиваете её (число простое) и переворачиваете каждую вторую карточку дальше. Затем берёте «3» (если она ещё рубашкой вверх) и переворачиваете каждую третью. Так вы действуете до √n. В итоге рубашкой вверх останутся только простые числа — отсюда и название «решето».
  • Идея словами: вместо проверки каждого числа по отдельности «вычёркиваем» кратные найденных простых чисел прямо в массиве-решете. Каждое составное вычёркивается хотя бы один раз, поэтому суммарная работа почти линейна.
  • Почему именно p*p: если число m = a·b составное, то у него есть делитель ≤ √m. Когда мы дошли до простого p и обрабатываем его кратные, то все кратные p, меньшие p*p, уже были вычеркнуты предыдущими простыми (имеющими меньшие значения). Поэтому начинать «вычёркивание» с p*p — безопасно (ничего не пропустим) и экономно (не делаем лишней работы). Сравните: для p = 2 начинаем с 4 (2·2), для p = 3 — с 9 (3·3, числа 3·2 уже вычеркнуты на прошлом шаге).
  • Почему цикл идёт только до √n: если число m ≤ n составное и имеет делитель p > √n, то второй его делитель m/p < √n. Значит какой-то делитель ≤ √n всегда найдётся — он уже вычеркнет это число на одной из предыдущих итераций.
  • Сложность (подробнее): сумма n/p по всем простым p ≤ √n равна n · (1/2 + 1/3 + 1/5 + 1/7 + …) — это гармонический ряд по простым, который растёт как log log n. Поэтому итоговая сложность O(n log log n) — почти линейная, намного быстрее, чем O(n√n) при «школьном» переборе делителей.
  • Пошаговый алгоритм: создать булев массив prime[0..n] и заполнить true; обнулить prime[0] и prime[1]; для p от 2 до √n, если prime[p] истинно, вычеркнуть все кратные, начиная с p*p (меньшие уже вычеркнуты); вывести индексы со значением true.
  • Практические советы:
    • для экономии памяти можно использовать массив char (битовые маски через >> и & 1) вместо bool — для n = 10^7 это сэкономит ~7 МБ.
    • для очень больших n (миллионы и больше) используют сегментированное решето — обрабатывают диапазон блоками по √n, чтобы массив поместился в кеш L2/L3 процессора.
    • важно для больших n: при p * p может переполниться int. Безопаснее писать (long long)p * p <= n — это спасает уже при n > 46340 ≈ √INT_MAX.
    • при n = 10^7 массив bool prime[10000001] занимает ~10 МБ — ок для кучи, но как VLA на стеке почти наверняка приведёт к переполнению стека.
  • Типичная ошибка: начинать вычёркивание с 2*p, а не с p*p — это не ошибка корректности, но лишняя работа; или забыть, что prime[0] и prime[1] не простые (иначе ноль и единица попадут в вывод).
  • Типичная ошибка №2: использовать условие p <= n вместо p * p <= n (или (long long)p * p <= n). Результат будет верным, но в n / log n раз медленнее — вы будете проверять уже вычеркнутые числа.
  • Типичная ошибка №3 (недопонимание формата): некоторые думают, что prime[i] = false — это «удаление» элемента, и пытаются сдвигать массив. Не надо — массив остаётся того же размера, просто флаг снимается.
  • Что сказать на экзамене: решето выгодно, когда нужно НАЙТИ ВСЕ простые до n; для проверки одного большого числа быстрее trial division до √n или тест Миллера–Рабина. Упомяните «почему начинаем с p*p», «почему √n» и оценку сложности n · Σ(1/p) — это любимые вопросы преподавателей МИФИ/МГУ/МФТИ.
#include <stdio.h>
#include <stdbool.h>

int main(void) {
    int n = 50;
    bool prime[51];                              // индексы 0..n
    for (int i = 0; i <= n; i++) prime[i] = true;
    prime[0] = prime[1] = false;
    for (int p = 2; p * p <= n; p++)
        if (prime[p])
            for (int m = p * p; m <= n; m += p) prime[m] = false;
    for (int i = 2; i <= n; i++) if (prime[i]) printf("%d ", i);
    putchar('\n');
    return 0;
}

Матрицы и разреженные массивы

Основные операции с квадратной матрицей: транспонирование, умножение, след, определитель.

  • Что это (определение): Матрица в программировании — двумерный массив размера N × M, где N — число строк (rows), M — число столбцов (columns). Квадратная матрица — частный случай, где N = M. В Си матрица хранится как «массив массивов»: a[N][M] означает, что a[i] — указатель на i-ю строку, а a[i][j] — элемент на i-й строке и j-м столбце. Обход элементов в памяти — по строкам (row-major order), это важно для производительности.
  • Постановка: реализовать базовые операции над матрицей N×N без библиотек.
  • Идея словами: транспонирование — это «отражение» относительно главной диагонали (t[j][i] = a[i][j]); умножение — тройной цикл «строка × столбец», где сумма произведений накапливается в c[i][j]; след — сумма элементов главной диагонали a[i][i].
  • Аналогия для транспонирования: представьте квадратный стол, на котором разложена таблица чисел. «Транспонировать» — это мысленно повернуть стол относительно главной диагонали (идёт с северо-запада на юго-восток): что было строкой — становится столбцом. Элементы на самой диагонали остаются на месте, потому что они лежат на «оси вращения».
  • Аналогия для умножения: c[i][j] — это скалярное произведение i-й строки первой матрицы и j-го столбца второй. Если представить первую матрицу как набор строк-векторов, а вторую — как набор столбцов-векторов, то умножение — это «скалярное произведение» каждой пары строка–столбец. Для матриц 2×2: c[0][0] = a[0][0]*b[0][0] + a[0][1]*b[1][0] — попарно умножаем и складываем.
  • Почему важен порядок циклов: в Си a[i][j] хранится по строкам: элементы одной строки лежат в памяти подряд. Если внешний цикл по i, а внутри по j, то чтение идёт «по строке» — кеш процессора (L1/L2) срабатывает отлично. Если переставить циклы иначе, мы будем «прыгать» по памяти — кеш-промахи замедлят код в 5–10 раз. Это не влияет на корректность, но критично для производительности.
  • Алгоритм умножения: для каждой пары (i, j) обнулить c[i][j], затем для всех k прибавить a[i][k] * b[k][j]. Порядок циклов влияет на скорость: если внешний цикл — по i, а внутренняя работа идёт по строке, данные читаются последовательно и кеш работает лучше.
  • Почему нельзя забыть обнулить c[i][j]: в C локальная переменная (и элементы массива, объявленного в main) не инициализируются нулём. Если пропустить c[i][j] = 0, к «мусору» прибавятся произведения — результат будет неверным. Это очень частая ошибка на экзамене.
  • Про след и определитель: след (trace) — сумма диагонали, считается за O(N). Определитель (determinant) считается через рекурсию (разложение по строке) за O(N!) напрямую или за O(N³) методом Гаусса — на экзамене обычно просят именно Гаусса, но «облегчённый» след тоже любят.
  • Типичная ошибка: транспонировать матрицу «на месте» вложенными циклами по всей матрице — при j от 0 до N элементы обменяются дважды и матрица вернётся в исходный вид. Правильно — обходить только верхний треугольник (for j = i+1 .. N-1) или писать в отдельную матрицу.
  • Типичная ошибка №2: путать индексы в формулах a[i][k] * b[k][j] и a[j][k] * b[k][i], из-за чего умножение даёт транспонированный результат.
  • Типичная ошибка №3 (на размерность): пытаться умножить матрицы A размера n×m и B размера p×q без проверки m == p. Без этой проверки матрицы «несовместимы», и код выйдет за границы массива — классический undefined behavior.
  • Что сказать на экзамене: сложность умножения — O(N³) (три вложенных цикла), транспонирования — O(N²), следа — O(N). Для больших матриц есть алгоритм Штрассена (O(N^2.81)) и Копперсмита–Винограда (O(N^2.37)). Если спросят про хранение — упомяните, что Си хранит матрицу по строкам (row-major), а Fortran — по столбцам.
#include <stdio.h>

#define N 3

void transpose(int a[N][N], int t[N][N]) {       // O(n^2)
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) t[j][i] = a[i][j];
}

void multiply(int a[N][N], int b[N][N], int c[N][N]) {   // O(n^3)
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) {
            c[i][j] = 0;
            for (int k = 0; k < N; k++) c[i][j] += a[i][k] * b[k][j];
        }
}

int trace(int a[N][N]) {                         // сумма диагонали, O(n)
    int s = 0;
    for (int i = 0; i < N; i++) s += a[i][i];
    return s;
}

int main(void) {
    int a[N][N] = {{1,2,3},{4,5,6},{7,8,9}}, t[N][N], c[N][N];
    transpose(a, t);
    multiply(a, a, c);
    printf("t[0][2]=%d c[0][0]=%d trace=%d\n", t[0][2], c[0][0], trace(a));
    return 0;   // t[0][2]=7 c[0][0]=30 trace=15
}

Разреженный массив хранит только ненулевые элементы. Формат «тройка» (строка, столбец, значение) — просто, но поиск за O(k); формат CSR (values, col_index, row_ptr) — компактнее и быстрее.

  • Что это (определение): Разреженная матрица (sparse matrix) — матрица, в которой большинство элементов — нули (или какое-то «нейтральное» значение). Типичные примеры: матрица смежности графа (вершин обычно немного, рёбер ещё меньше), матрица коэффициентов в задачах линейной алгебры (метод конечных элементов), TF-IDF матрицы в обработке текстов. Хранить такие матрицы в обычном виде (N × M элементов) расточительно, поэтому придумали несколько компактных форматов.
  • Аналогия из жизни: представьте записную книжку. В плотном массиве мы выделяем ячейку под каждый номер от 0 до 9999 (10 000 ячеек), даже если у нас всего 50 контактов. В разреженном формате мы храним только 50 троек «имя — номер — примечание» — это и есть формат «тройка». CSR — это уже более «продвинутая» структура: как если бы мы отсортировали все контакты по алфавиту и записали в три колонки (значения, индексы, указатели на начало «фамилии»).
#include <stdio.h>

typedef struct { int row, col, value; } Triple;   // разреженный в тройках

int main(void) {
    int dense[4][4] = {{0,0,3,0},{0,0,0,0},{5,0,0,7},{0,0,0,0}};
    Triple sp[16];
    int k = 0;

    for (int i = 0; i < 4; i++)
        for (int j = 0; j < 4; j++)
            if (dense[i][j] != 0) sp[k++] = (Triple){i, j, dense[i][j]};

    printf("ненулевых: %d\n", k);                 // 3
    for (int i = 0; i < k; i++)
        printf("(%d,%d)=%d\n", sp[i].row, sp[i].col, sp[i].value);
    return 0;   // (0,2)=3 (2,0)=5 (2,3)=7
}

Экономия достигается при заполненности ниже ~30%; в остальных случаях выгоднее плотный массив из-за накладных расходов индексации.

  • Идея словами: храним только ненулевые элементы, а не всю матрицу. Формат «тройка» (строка, столбец, значение) максимально прост, но поиск по нему — O(k); формат CSR (compressed sparse row) хранит три массива (values, col_index, row_ptr) и позволяет быстро получить все элементы строки, поэтому он стандартен в численных библиотеках.
  • Подробнее про CSR:
    • values — массив всех ненулевых значений подряд (идут «в строчку», обычно построчно);
    • col_index — массив номеров столбцов для каждого значения из values (того же размера — на каждый ненуль свой номер столбца);
    • row_ptr — массив размера N+1: row_ptr[i] указывает, с какого индекса в values начинается i-я строка, а row_ptr[N] равен общему числу ненулей.
    • Чтобы получить все элементы i-й строки, достаточно взять «срез» values[row_ptr[i] .. row_ptr[i+1]) — это O(k_i) операций и кеш-дружелюбно (данные лежат подряд в памяти).
  • Когда экономить не надо: если ненулевых больше ~30%, накладные расходы на индексацию и лишние массивы съедают выигрыш, и плотная матрица быстрее и проще.
  • Практический совет: в реальных библиотеках (SciPy, Eigen, SuiteSparse) разреженные матрицы дополнительно сортируются по (row, col) и нередко хранятся в формате CSC (compressed sparse column) — аналог CSR по столбцам. Подобные структуры экономят и память, и время при умножении матрица–вектор — это стандарт де-факто в численных методах.
  • Оценка экономии памяти: плотная матрица 1000 × 1000 с int — это 4 МБ. Если в ней всего 1000 ненулей, то «тройка» займёт 3 × 4 × 1000 = 12 КБ — выигрыш в ~300 раз.
  • Типичная ошибка: забыть, что при операциях над разреженными матрицами результат тоже разреженный, и выделить под него полный массив N×N — экономия теряется. Второй нюанс — «тройки» удобно сортировать по (строка, столбец), иначе вывод будет в произвольном порядке.
  • Типичная ошибка №2: при создании «тройки» забыть, что value может быть любым, в том числе отрицательным, и проверять только > 0. Условие != 0 корректнее — оно сохраняет и -5, и -1, а не выкидывает их как «нулевые».
  • Что сказать на экзамене: упомяните три формата — COO (тройки), CSR и CSC, и кратко — у каких операций они быстрее (CSR хорош для доступа «построчно», CSC — «постолбцово»). Если матрица плотная — обычный массив, разреженная — только при заполненности ниже ~30%.

Связные списки и двоичные деревья

Задача: удалить все вхождения значения из списка.

  • Что это (определение): Односвязный список (singly linked list) — линейная структура данных, где каждый элемент (узел, node) хранит полезные данные и указатель на следующий узел. У последнего узла next == NULL. В отличие от массива, элементы списка «разбросаны» по памяти и не требуют непрерывного блока. Доступ к k-му элементу — O(k) (надо пройти по цепочке от головы), зато вставка/удаление в известном месте — O(1).
  • Аналогия из жизни: односвязный список — это вагоны поезда. Каждый вагон знает только, какой вагон идёт за ним (указатель next), но не знает, что перед ним. Чтобы добраться до 5-го вагона, надо последовательно пройти от головы состава, заглядывая в каждый. Если нужно отцепить вагон — это одна операция: «предыдущий» вагон просто перестаёт ссылаться на отцепленный и начинает ссылаться на следующий за ним.
  • Постановка: дан односвязный список; удалить из него все узлы с заданным значением, вернув новую голову.
  • Идея словами: голова может измениться (если удаляем первый элемент), поэтому функцию нельзя писать «в пустоту» — она должна возвращать новый head. Отдельно обрабатываем начало списка, отдельно — середину, где удаляем через p->next.
  • Почему важен отдельный цикл по голове: если в начале списка несколько удаляемых узлов подряд (например, 3 -> 3 -> 3 -> 1, удаляем 3), то простой обход «середины» через p->next их не удалит — у head нет «предыдущего» узла, к которому можно привязаться. Поэтому сначала «снимаем» голову в цикле, а потом работаем с остатком, который уже точно начинается с «нужного» узла.
  • Почему в середине не сдвигаем p: представьте, что узлы с целевым значением идут подряд (2 -> 3 -> 3 -> 1, удаляем 3). После удаления первого 3 указатель p смотрит на бывший «третий» узел. Если сдвинуть p = p->next, мы перепрыгнем через второй 3 и не удалим его. Поэтому удаляем, не двигая p, пока p->next->data == value.
  • Пошаговый алгоритм: (1) цикл while (head && head->data == value) — снимаем узлы в начале, освобождая память; (2) идём указателем p по списку: если следующий узел нужно удалить, делаем p->next = p->next->next и free, не сдвигая p; иначе переходим к следующему.
  • Практические советы:
    • В учебных примерах часто забывают освобождать память — на экзамене это обычно не критично, но в реальном коде приведёт к утечкам. Всегда пишите free(t) для удалённого узла и сохраняйте p->next во временную переменную до free, потому что после free обращение к t->next — это use-after-free.
    • Чтобы протестировать функцию, удобно строить список «вставкой в начало» — это O(k) и не требует хождения до хвоста.
    • Для отладки полезно добавить «фиктивный узел» (dummy node) перед головой: это устраняет особый случай с головой, но в Си такой приём применяют реже, чем в C++/Java (в Java — ListNode dummy = new ListNode(0);).
  • Типичная ошибка: сдвинуть p = p->next сразу после удаления — тогда подряд идущие повторяющиеся значения пропускаются. Вторая ошибка — не вернуть новую голову, и вызывающий продолжает работать со старым (освобождённым) адресом.
  • Типичная ошибка №3 (use-after-free): обратиться к t->next после free(t). После освобождения память может быть уже перезаписана, и мы прочитаем мусор. Поэтому в коде сначала сохраняют t->next в локальную переменную (Node *next = t->next), потом free(t), потом p->next = next.
  • Типичная ошибка №4 (на пустой список): забыть, что head может быть NULL. Все проверки вида while (head && ...) обязательны — иначе разыменование NULL даст segmentation fault.
  • Что сказать на экзамене про сложность: алгоритм — O(n) по времени, O(1) дополнительной памяти. Голова может измениться — обязательно вернуть новую голову из функции. Объяснение «почему не сдвигаем p» — почти гарантированный дополнительный вопрос устной части. Если спросят про устойчивость — функция сохраняет относительный порядок оставшихся элементов (т.е. она устойчивая, stable).
#include <stdio.h>
#include <stdlib.h>

typedef struct Node { int data; struct Node *next; } Node;

Node *remove_all(Node *head, int value) {
    while (head && head->data == value) {        // чистим голову
        Node *t = head; head = head->next; free(t);
    }
    for (Node *p = head; p && p->next; ) {
        if (p->next->data == value) {
            Node *t = p->next; p->next = t->next; free(t);   // не сдвигаем p
        } else {
            p = p->next;
        }
    }
    return head;
}

void print_list(const Node *head) {
    for (const Node *p = head; p; p = p->next) printf("%d ", p->data);
    putchar('\n');
}

int main(void) {
    Node *head = NULL;
    int vals[] = {3, 3, 1, 3, 2, 3};
    for (int i = 5; i >= 0; i--) {                // строим список вставкой в начало
        Node *n = malloc(sizeof *n);
        n->data = vals[i]; n->next = head; head = n;
    }
    head = remove_all(head, 3);
    print_list(head);                             // 1 2
    while (head) { Node *t = head; head = head->next; free(t); }
    return 0;
}

Задача: построить двоичное дерево поиска и посчитать число листьев / глубину.

  • Постановка: по последовательности чисел построить BST и вывести его высоту и число листьев.
  • Идея словами: вставка — рекурсивный спуск по правилу «меньше налево, больше направо»; характеристики дерева — рекурсивные формулировки: лист — узел без потомков, высота пустого дерева — 0, иначе 1 + максимум(высоты детей).
  • Пошаговый алгоритм: (1) для каждого ключа вызвать insert(root, key), присваивая результат обратно в root; (2) count_leaves: если узел пуст — 0, если без детей — 1, иначе сумма листьев детей; (3) height: если узел пуст — 0, иначе 1 плюс большая из высот детей; (4) освободить дерево обратным обходом (сначала дети, потом узел).
  • Типичная ошибка: освобождать память прямым или симметричным обходом — узел освобождается раньше, чем мы обращаемся к его детям (use-after-free). Ещё один частый промах — считать листом узел с одним ребёнком.
#include <stdio.h>
#include <stdlib.h>

typedef struct TreeNode { int key; struct TreeNode *left, *right; } TreeNode;

TreeNode *insert(TreeNode *root, int key) {
    if (!root) {
        TreeNode *n = malloc(sizeof *n);
        if (!n) return NULL;
        n->key = key; n->left = n->right = NULL;
        return n;
    }
    if (key < root->key)      root->left  = insert(root->left, key);
    else if (key > root->key) root->right = insert(root->right, key);
    return root;                                  // дубликаты игнорируем
}

int count_leaves(TreeNode *root) {                // лист — без потомков
    if (!root) return 0;
    if (!root->left && !root->right) return 1;
    return count_leaves(root->left) + count_leaves(root->right);
}

int height(TreeNode *root) {
    if (!root) return 0;
    int l = height(root->left), r = height(root->right);
    return 1 + (l > r ? l : r);
}

void free_tree(TreeNode *root) {                  // освобождение снизу вверх
    if (!root) return;
    free_tree(root->left);
    free_tree(root->right);
    free(root);
}

int main(void) {
    int keys[] = {8, 3, 10, 1, 6, 14, 4, 7, 13};
    TreeNode *root = NULL;
    for (int i = 0; i < 9; i++) root = insert(root, keys[i]);
    printf("высота=%d листьев=%d\n", height(root), count_leaves(root));
    free_tree(root);
    return 0;   // высота=4 листьев=4
}

На экзамене часто просят обход дерева без рекурсии — стек моделируют массивом указателей или используют моррисов обход (O(1) памяти). Идея итеративного симметричного обхода: идём влево до упора, складывая узлы в стек; когда дойти некуда, достаём узел со стека, печатаем его и переходим в правое поддерево. Моррисов обход вместо стека использует временные «нити» — ссылки из правого потомка на предка, которые после использования восстанавливаются, поэтому дополнительная память не нужна вовсе.

Ещё один классический вопрос — как проверить, что дерево является BST. Нельзя ограничиться проверкой «ключ больше левого ребёнка и меньше правого»: это условие локально и пропустит нарушение глубже. Правильный способ — симметричный обход: ключи должны идти строго возрастая, либо рекурсивная проверка с передачей допустимого диапазона (min, max) вниз по дереву.

Вопросы к экзамену с ответами

Ниже — типовые формулировки из билетов МИФИ, МГУ (ВМК) и МФТИ, сгруппированные по темам: так удобнее готовиться блоками и видно, какие разделы спрашивают чаще. Ответы развёрнуты до 3–6 предложений и содержат ключевые термины, которых ждёт преподаватель. Метка в скобках указывает вуз и курс, где вопрос встречается чаще всего; на самом деле темы пересекаются, поэтому учите все. Формулировки билетов сохранены, порядок изменён только для группировки.

Как пользоваться шпаргалкой. Прочитайте вопрос, закройте ответ и перескажите его своими словами. Затем откройте и проверьте ключевые термины — преподаватель оценивает именно их наличие в устном ответе. В конце каждого пункта — блок «Типичные ошибки» и «Практическая подсказка».

Алгоритмы и теория вычислений (МГУ; обзорно МИФИ, МФТИ)

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

  1. Что такое алгоритм и его свойства? Алгоритм — конечная последовательность однозначно понимаемых указаний исполнителю, приводящая от исходных данных к результату. Его свойства: дискретность (процесс делится на отдельные шаги), детерминированность (один вход — один результат), массовость (применимость к классу задач), результативность (результат за конечное число шагов) и конечность (ограниченность записи и числа шагов). Иногда добавляют понятность и эффективность. Формальной моделью служит машина Тьюринга, задающая понятие вычислимой функции. (МИФИ, МГУ, МФТИ.)

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

    Почему именно эти свойства. Они нужны, чтобы отличить настоящий алгоритм от «описания задачи» или «эвристики», которая может не сработать. На экзамене любят добавлять «массовость» — то есть один и тот же алгоритм должен решать целый класс задач, а не один конкретный вход.

  2. Что такое машина Тьюринга? Машина Тьюринга (МТ) — формальная модель вычислений: бесконечная лента, головка, читающая и пишущая символ, и конечное управление с функцией переходов δ. МТ разрешает язык, если останавливается на всех входах, и полуразрешает, если останавливается только на принимаемых словах. Существует универсальная МТ, моделирующая любую другую по её описанию, — это прообраз интерпретатора и идея «программа как данные». Будьте готовы построить простую МТ, например прибавляющую единицу к числу в унарном коде. (МГУ — обязательный вопрос.)

    Аналогия из жизни. Представьте себе бесконечную бумажную ленту и карандаш, который стирает и пишет символы по простым правилам — «если вижу 0, заменю на 1 и сдвинусь вправо». Это и есть МТ: ничего сверхъестественного, но из таких простых правил можно собрать любой компьютер.

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

    Типичная ошибка. Путать «остановка на всех входах» (разрешимость) и «остановка только на словах языка» (полуразрешимость). Первое — сильнее: машина обязана ответить «нет» на чужих словах, а не просто зациклиться.

  3. Что такое нормальные алгоритмы Маркова? Нормальный алгоритм Маркова (НАМ) — модель переписывания строк: конечный упорядоченный набор правил подстановки α → β, среди которых может быть заключительное α → ·β. На каждом шаге применяется первое подходящее правило, и заменяется самое левое вхождение образца; работа прекращается, когда правил нет или выполнено заключительное. НАМ эквивалентен машине Тьюринга по вычислительной мощности, что подтверждает тезис Чёрча–Тьюринга. Типичная ошибка — путать «первое правило» с «любым подходящим». (МГУ.)

    Аналогия из жизни. Это похоже на поиск и замену в текстовом редакторе: вы задаёте правила сверху вниз и срабатывает первое подходящее, заменяя самое левое вхождение. Если правило помечено как «заключительное» (точкой в конце), то после его применения работа прекращается.

    Почему это важно. НАМ полезен тем, что очень нагляден — для маленьких задач (например, сложение в унарном коде) правила помещаются на доске. Это любимый формат задач на экзамене: «напишите НАМ, который переводит 1^n в двоичное представление n».

  4. Сформулируйте тезис Чёрча–Тьюринга. Тезис утверждает, что интуитивное понятие вычислимой функции совпадает с вычислимостью на машине Тьюринга и в любой эквивалентной модели (НАМ, λ-исчисление, рекурсивные функции). Это не теорема, а соглашение о границах формализации: строго доказать его нельзя, поскольку «интуитивно вычислимо» не формализовано. Его роль — обосновать равномощность моделей и отсутствие «более сильной» модели вычислений. На экзамене обязательно называйте это именно тезисом. (МГУ.)

    Аналогия из жизни. Тезис — это договорённость между математиками: «давайте считать, что всё, что в принципе можно посчитать на бумаге, можно посчитать и на МТ». Доказать это строго нельзя — как нельзя строго доказать, что «разумное поведение» совпадает с «формальной логикой».

    Типичная ошибка. Говорить «теорема Чёрча–Тьюринга». Это именно тезис (гипотеза-соглашение), а не доказанное утверждение. За такое на экзамене могут снизить балл.

  5. Почему проблема останова неразрешима? Проблема останова — задача по описанию программы P и входу x определить, остановится ли P на x. Она неразрешима: доказательство идёт диагонализацией — предположив решающую машину H, строят D, которая зацикливается, если H(D, D) говорит «остановится», и наоборот, после чего анализ D(D) противоречит определению H. Следствия: неразрешимы эквивалентность программ и автоматическая проверка «делает ли программа то, что нужно». Отсюда невозможен универсальный отладчик, заранее находящий все зависания. (МГУ — ключевой вопрос.)

    Аналогия из жизни. Представьте, что вы написали программу и хотите узнать: она зависнет или нет? Теорема Тьюринга говорит: универсального способа узнать это заранее нет. Можно только запустить и ждать — а если она не останавливается, вы этого никогда не узнаете. Поэтому нельзя построить «идеальный детектор зависаний», как нельзя построить вечный двигатель.

    Почему это важно для практики. Именно из-за этой теоремы существуют только эвристические статические анализаторы и инструменты вроде Valgrind — они не могут поймать все ошибки, а только наиболее типичные. Если бы проблема останова была разрешима, отладка была бы тривиальной.

  6. Чем разрешимое множество отличается от перечислимого? Разрешимое множество имеет машину, которая на любом входе останавливается и даёт ответ «да/нет». Перечислимое (полуразрешимое) имеет машину, перечисляющую его элементы: на входящих она останавливается, а на не входящих может зацикливаться. Всякое разрешимое множество перечислимо, но не наоборот; существуют и неперечислимые множества. Это различие объясняет, почему принадлежность нельзя проверить во всех случаях. (МГУ.)

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

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

  7. Что такое сложность алгоритма и классы P/NP? Сложность — асимптотическая оценка роста числа операций или памяти при увеличении входа, записываемая через O(·). Класс P — задачи, разрешимые за полиномиальное время; класс NP — задачи, решение которых проверяется за полином. Задачи NP-полные — труднейшие в NP: к ним полиномиально сводится любая задача из NP. Вопрос P = NP открыт и считается одной из главных нерешённых проблем информатики. (МГУ, МФТИ.)

    Аналогия из жизни. P — это задачи, которые вы способны решить сами за разумное время (например, отсортировать список покупок). NP — это задачи, для которых вы не можете быстро найти решение, но если кто-то даст готовый ответ, вы мгновенно его проверите (например, судоку или расписание экзаменов).

    Почему важно для Си. Любой алгоритм на Си имеет сложность, и выбор структуры данных (массив, список, дерево, хеш-таблица) определяет, попадёт ли решение в P или будет экспоненциальным. На практике это разница между «работает за миллисекунды» и «висит до конца света».

Представление данных в памяти (МИФИ, МГУ)

Определение. Любое значение в программе на Си — целое, вещественное, указатель — хранится как последовательность бит. Но «как именно» эти биты интерпретируются, определяется типом и архитектурой. Здесь собраны типовые ловушки: переполнение, потеря точности, знаковость — то, что компилятор не отловит без санитайзеров.

  1. Что такое неопределённое поведение (UB)? UB — ситуация, когда стандарт не налагает на программу никаких требований, и компилятор вправе сделать что угодно. Обычно оно не приводит к немедленной аварии: программа может «работать» у вас и сломаться на другой машине или при смене уровня оптимизации. Компилятор вправе предполагать, что UB не возникает, и выбрасывать проверки — классический пример с переполнением знакового int при -O2. Типичные источники: переполнение знаковых целых, разыменование NULL и висячих указателей, выход за границы массива, изменение строкового литерала. Для поиска UB применяют -fsanitize=address,undefined. (МИФИ, МГУ.)

    Аналогия из жизни. UB — это джокер в правилах: стандарт говорит «в этом случае что угодно может случиться». Компилятор использует это как право оптимизировать код в предположении, что такого случая не будет. Поэтому одна и та же программа ведёт себя по-разному при -O0 и -O2 — компилятор по-разному «доверяет» вашему коду.

    Практическая подсказка. Собирайте с -Wall -Wextra -fsanitize=address,undefined во время отладки. Санитайзеры ловят самые частые UB (выход за массив, использование неинициализированной памяти) и не дают компилятору «спрятать» баг за оптимизацией.

    Типичные ошибки. 1) Считать, что UB всегда роняет программу — наоборот, часто «всё работает». 2) Писать int x = a + b; без проверки диапазона и удивляться, что при оптимизации логика сломалась.

  2. Как представляются знаковые целые? В двоичном дополнительном коде: -x получается инверсией всех битов x и прибавлением единицы, поэтому -1 — это все единицы (0xFFFFFFFF). Старший бит играет роль знакового, но отдельного «отрицательного нуля» нет — диапазон симметричен с точностью до одного значения. Для n бит диапазон: от -2^(n-1) до 2^(n-1)-1 (у 32-битного int примерно ±2.1·10⁹). Дополнительный код выбран потому, что сложение и вычитание выполняются одной схемой. (МИФИ, МГУ.)

    Аналогия из жизни. Обычные часы: «-3 часа» означает то же, что «21 час». Дополнительный код устроен аналогично — отсчёт идёт по кругу, и операция -x превращается в «дополни до круглого числа». Благодаря этому процессору не нужны разные схемы для сложения положительных и отрицательных чисел.

    Практическая подсказка. Запомните заклинание: «int 32 бита → от -2^31 до 2^31 - 1». Это примерно от -2.147 миллиардов до 2.147 миллиардов. Если счётчик может превысить 2.1 миллиарда — берите long long или int64_t.

    Типичная ошибка. Считать, что у знакового int есть «отрицательный ноль». Его нет: -0 и +0 кодируются одинаково — нулями во всех битах. Это следствие дополнительного кода.

  3. Что происходит при переполнении целых? Для знаковых целых переполнение — это UB: результат непредсказуем, и оптимизатор может на этом основании выбросить код. Для беззнаковых переполнение определено стандартом: результат берётся по модулю 2^N (UINT_MAX + 1u == 0u). Деление на ноль и INT_MIN / -1 — тоже UB. Практический вывод — считать промежуточные результаты в более широком типе и приводить операнды явно. (МИФИ, МФТИ.)

    Аналогия из жизни. Беззнаковое переполнение — это одометр в машине: после 999 999 км показания сбрасываются на 0. Это «законное» поведение, описанное стандартом. Знаковое переполнение — это спидометр со снятой защитой: стрелка может крутиться как угодно, и стандарт снимает с себя ответственность.

    Почему знаковое — UB. Исторически на разных процессорах переполнение знаковых обрабатывалось по-разному (ловушка, циклический переход, флаг). Чтобы не зависеть от архитектуры, стандарт оставил поведение «не определено» и переложил ответственность на программиста.

    Практическая подсказка. Если есть подозрение на переполнение — используйте unsigned long long для промежуточных вычислений и проверяйте диапазон перед приведением к меньшему типу. Либо применяйте безопасные библиотеки вроде safe_i32_add из SafeLib.

  4. Как хранится число в IEEE 754? По стандарту IEEE 754 число хранится как знак, порядок со смещением и мантисса: у float — 1 бит знака, 8 бит порядка (смещение 127) и 23 бита мантиссы; у double — 1 + 11 (смещение 1023) + 52. Мантисса нормализована и хранится без ведущей единицы, а порядок сдвинут, чтобы одинаково представлять очень малые и очень большие числа. Особые значения ±0, ±∞, NaN кодируются специальными комбинациями битов. Конечное число бит мантиссы объясняет, почему 0.1 + 0.2 != 0.3. (МИФИ, МГУ.)

    Аналогия из жизни. Запись float похожа на научную: 1.234 × 10^5. Здесь мантисса 1.234, а порядок 5. Только основание не 10, а 2, и всё хранится в битах. Смещение порядка нужно, чтобы сравнивать float как обычные целые — так процессору проще сортировать и сравнивать.

    Почему 0.1 + 0.2 != 0.3. В двоичной системе 0.1 — это бесконечная дробь 0.0001100110011.... При округлении до 23/52 бит получается ближайшее, но не точное значение. Сумма двух таких округлений даёт ближайшее к 0.3, но само 0.3 тоже округлено — и они не совпадают.

    Практическая подсказка. Никогда не сравнивайте float/double через ==. Используйте fabs(a - b) < eps с разумным допуском.

  5. Что такое машинный эпсилон? Машинный эпсилон — минимальное число ε, при котором 1.0 + ε != 1.0 для данного типа. Он служит мерой относительной точности: FLT_EPSILON ≈ 1.19·10⁻⁷, DBL_EPSILON ≈ 2.22·10⁻¹⁶. Связанные характеристики — число гарантированно точных десятичных цифр (FLT_DIG = 6, DBL_DIG = 15) и максимальное значение (FLT_MAX). Эпсилон применяют как допуск при сравнении вещественных чисел. (МИФИ, МГУ.)

    Аналогия из жизни. Эпсилон — это «шаг линейки», которой измеряет процессор. Для float этот шаг грубоват (~7 значащих цифр), для double — точен (~15). Если два числа отличаются меньше чем на этот шаг, процессор считает их равными.

    Практическая подсказка. Для float допуск берите порядка 1e-6 относительно масштаба величин, для double — 1e-12. Жёсткое 1e-9 для float бессмысленно — там точность всё равно ниже.

    Типичные ошибки. Использовать абсолютный допуск eps = 1e-9 для больших чисел (порядка 1e10). Здесь нужен относительный допуск eps * fabs(b).

  6. Почему нельзя сравнивать вещественные через ==? Большинство десятичных дробей в двоичной системе бесконечны и хранятся округлёнными, поэтому 0.1 + 0.2 даёт не 0.3, а ближайшее представимое число. Правильная проверка — сравнить модуль разности с допуском: fabs(a - b) < eps, где eps выбирают по масштабу величин. Для больших чисел берут относительный допуск eps * fabs(b). Исключение — проверка x != x для поиска NaN и сравнение с точно представимыми значениями. (МИФИ, МФТИ.)

    Аналогия из жизни. Сравнивать float через == — это как сравнивать длину двух верёвок, измеренную линейкой с ценой деления 1 мм. Если обе верёвки имеют длину «примерно 5 метров», линейка покажет разные миллиметры, хотя на глаз они одинаковы. Поэтому в быту мы говорим «длина примерно равна», а в коде пишут допуск.

    Практическая подсказка. Заведите себе функцию вроде bool nearly_eq(double a, double b, double eps) и используйте её везде. Это дисциплинирует и избавляет от магических чисел в коде.

    Типичные ошибки. Сравнение через == особенно опасно для результатов sin, cos, sqrt — округление внутри библиотеки тоже вносит погрешность.

Указатели, массивы, строки (МИФИ, МФТИ)

Определение. Указатель — это переменная, хранящая адрес другой переменной в памяти. Массив — это непрерывный кусок памяти под элементы одного типа. Строка в Си — это массив char, оканчивающийся нулём. Связь между этими тремя понятиями — главный источник ошибок и одновременно главная сила языка.

  1. Что такое указатель и какова адресная арифметика? Указатель — переменная, хранящая адрес объекта; тип указателя задаёт, объект какого типа по адресу находится. Оператор & берёт адрес, * разыменовывает, к указателю можно прибавлять и вычитать целое. Арифметика идёт в единицах типа: p + 1 сдвигает адрес на sizeof(*p) байт, поэтому int * и char * сдвигаются на разное расстояние. Вычитание указателей даёт число элементов (ptrdiff_t), сложение двух указателей запрещено, а корректная арифметика возможна только внутри одного массива. (МИФИ, МФТИ — ключевой вопрос.)

    Аналогия из жизни. Указатель — это номер дома и квартиры: «город, улица, дом 5, квартира 12». Тип указателя — это подсказка, что в этом доме живут именно int, а не double. Прибавление +1 значит «перейди к следующему дому», и расстояние между домами разное для одноквартирных и многоквартирных.

    Почему арифметика ограничена массивом. За пределами массива компилятор не знает, что лежит по соседнему адресу, — там может быть другая переменная или вообще чужая память. Поэтому p1 - p2 осмысленно только если p1 и p2 указывают в один массив.

    Типичные ошибки. 1) Складывать два указателя — компилятор не даст, но если очень хочется, преобразование в uintptr_t «отключает» защиту. 2) Использовать p++ и p-- на const char * от строкового литерала — аналогично изменению константы.

  2. Что такое array decay? Array decay — превращение имени массива в указатель на первый элемент почти в любом выражении. Исключения: sizeof arr (размер всего массива), &arr (указатель на массив) и _Alignof, а также инициализация строковым литералом. Из-за decay a[i] эквивалентно *(a + i), а параметр int a[] в функции — это на самом деле int *. Практическое следствие: длину массива внутри функции узнать нельзя, её передают отдельным параметром. (МИФИ, МФТИ.)

    Аналогия из жизни. У вас есть стопка книг — это «массив». Когда вы говорите коллеге «дай мне книгу», он не спрашивает «где стопка», а сразу берёт первую книгу. То есть стопка «затухает» до «первой книги». Но если вы спрашиваете sizeof всей стопки — ответ другой, чем про одну книгу: компилятор помнит, что это массив.

    Почему так сделано. Так исторически сложилось: параметры функций передавались через стек, и передать «весь массив» было бы дорого. Передача указателя на первый элемент — это и есть передача массива «по форме».

    Практическая подсказка. Если вам нужна длина массива внутри функции — передавайте её отдельным аргументом size_t n. Иначе придётся заводить «sentinel» (как \0 в строках) или структуру с длиной.

  3. Почему sizeof массива в функции даёт размер указателя? Параметр вида int a[] затухает до указателя int *a, поэтому sizeof a внутри функции возвращает размер указателя (8 байт на 64-битной системе), а не размер массива. Длину передают отдельным параметром int n. Идиома sizeof arr / sizeof arr[0] работает только там, где arr — настоящий массив, то есть в той же области видимости. Это одна из самых частых ошибок новичков. (МИФИ, МФТИ.)

    Аналогия из жизни. Внутри функции у вас есть только записка с адресом первой квартиры. Сколько комнат в квартире — из записки не узнать, надо спрашивать отдельно. А sizeof arr / sizeof arr[0] — это когда вы ещё помните, что дом сдан целиком и видите его «снаружи».

    Типичные ошибки. Писать void f(int a[10]) { for (size_t i = 0; i < sizeof a / sizeof a[0]; ++i) ... } — sizeof a всегда даст размер указателя, число 10 в скобках игнорируется компилятором.

    Практическая подсказка. В Си есть VLA (variable-length arrays, C99), но и они внутри функций тоже затухают до указателей. Для «массивов с длиной» используйте структуру { size_t n; T *data; } или просто передавайте длину аргументом.

  4. Чем отличается char s[] = "abc" от char *s = "abc"? Первое создаёт изменяемый массив из 4 байт (включая '\0'), куда копируется литерал, — элементы можно менять. Второе создаёт лишь указатель на литерал, лежащий в памяти только для чтения: запись в него — UB (часто падение). Различается и sizeof: у массива это 4, у указателя — 8. Правильный тип для строкового литерала — const char *. (МИФИ, МГУ, МФТИ.)

    Аналогия из жизни. char s[] = "abc" — вы выписали текст на свою бумажку и можете её править. char *s = "abc" — у вас только адрес памятника, на котором текст выбит в камне. Попробуете изменить — охрана прибежит.

    Почему это важно. Это любимый вопрос на собеседованиях: «скомпилируется ли char *s = "abc"; s[0] = 'X';?». Скомпилируется без ошибок (компилятор доверчив), но при запуске — segmentation fault на большинстве систем.

    Практическая подсказка. Всегда пишите const char *s = "abc", если строка не должна меняться. Тогда компилятор сразу отловит попытку записи и предупредит.

Функции, классы памяти и динамическая память (МИФИ, МФТИ)

Определение. Функция — именованный блок кода с параметрами и возвращаемым значением. Класс памяти задаёт, где живёт переменная (стек, статическая область, регистр) и как долго она существует. Динамическая память — это «куча», которой программист управляет вручную через malloc/free. Понимание этих трёх вещей — половина успеха на экзамене по Си.

  1. Как передаются аргументы в C? Все аргументы передаются по значению: функция получает копии и не может напрямую изменить переменную вызывающего. Чтобы изменить оригинал, передают указатель, и функция пишет по адресу через *. Массивы — исключение лишь по форме: имя массива затухает до указателя, поэтому функция работает с оригинальными элементами, хотя сам указатель — копия. Классический пример: swap(int a, int b) не работает, а swap(int *a, int *b) работает. (МИФИ, МФТИ.)

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

    Почему так сделано. Передача по значению безопасна: функция не может «сломать» переменные вызывающего кода случайно. А вот передача указателя — это явное согласие вызывающего на изменения (он сам передал адрес).

    Типичные ошибки. Путать «передачу по ссылке» (как в C++) и «передачу указателя» (как в Си). В Си всегда копия — но копия адреса, и через неё можно менять содержимое по адресу.

  2. Что такое висячий указатель? Висячий указатель — указатель на память, которая уже освобождена или вышла из области видимости (после free или после возврата из функции с локальной переменной). Разыменование такого указателя — UB: память может быть переиспользована, поэтому поведение непредсказуемо. Классический случай — возврат адреса локальной переменной. Лечится дисциплиной: после free(p) сразу писать p = NULL, а адреса локальных данных не возвращать. (МИФИ, МФТИ.)

    Аналогия из жизни. Висячий указатель — это записная книжка со старым адресом друга, который уже переехал. По этому адресу теперь живёт другой человек, и вы, позвонив в дверь, попадёте к кому попало.

    Почему опасно. Память после free часто возвращается аллокатору и переиспользуется для других malloc. Разыменование висячего указателя портит чужие данные, и баг проявляется далеко от места, где был сделан.

    Практическая подсказка. Правило «после free сразу NULL» ловится санитайзерами: -fsanitize=address при free(p); free(p); выдаст ошибку, а при free(p); p = NULL; free(p); — нет, потому что free(NULL) безопасен.

  3. Разница между стеком и кучей? Стек — область локальных переменных и кадров вызовов: выделение и освобождение автоматические, доступ быстрый, но размер ограничен (обычно единицы мегабайт), поэтому глубокая рекурсия его переполняет. Куча — область malloc/calloc/realloc: память живёт до явного free, размер ограничен доступной памятью, но возможны утечки и фрагментация. Куча адресуется указателями, время жизни определяется программистом. Обычно стек растёт «вниз», а куча — в сторону увеличения адресов. (МИФИ, МГУ, МФТИ.)

    Аналогия из жизни. Стек — стопка тарелок в буфете: вы кладёте тарелку сверху и берёте верхнюю. Быстро, но стопка не может быть бесконечной — упадёт. Куча — это склад: вы выдаёте ящик и возвращаете его, когда не нужен. Можно хранить что угодно и сколько угодно, но придётся следить за учётом.

    Почему важно. Огромные массивы (мегабайты) надо выделять в куче, иначе стек переполнится. А маленькие временные переменные — на стеке, чтобы не возиться с malloc/free.

    Типичные ошибки. 1) Объявлять int big[1000000] внутри функции — стек переполнится. 2) Возвращать указатель на локальный массив — после выхода из функции память освобождается.

  4. Что делают malloc, calloc, realloc, free? malloc(n) выделяет n байт и не инициализирует; calloc(n, s) выделяет n элементов по s байт и обнуляет; realloc(p, n) меняет размер блока (адрес может измениться, при неудаче вернётся NULL, а старый блок останется действительным); free(p) освобождает блок. Результат выделения нужно проверять на NULL. Типичные ошибки: утечка, use-after-free, двойное освобождение и потеря указателя при прямом p = realloc(p, ...). (МИФИ, МФТИ.)

    Аналогия из жизни. malloc — аренда склада: вам дают ящик, но он грязный (содержит мусор из прошлых жильцов). calloc — аренда склада после уборки: чисто и пусто. realloc — переезд на склад побольше: либо вам дадут новый склад по тому же адресу, либо придётся перевозить вещи самостоятельно. free — сдача ключей.

    Почему realloc опасен. При увеличении блок может быть перенесён по новому адресу. Если вы сразу пишете p = realloc(p, new_size) и realloc вернул NULL, старый указатель потерян — утечка. Безопасный паттерн: void *q = realloc(p, new_size); if (q) p = q; else { /* ошибка */ }.

    Практическая подсказка. Всегда пишите T *p = malloc(n * sizeof *p); — sizeof *p подстраивается под тип указателя и не дублирует имя типа. Это убирает целый класс ошибок при смене типа.

  5. Классы памяти в C? Классы памяти задают время жизни, область видимости и линковку. auto (по умолчанию для локальных) — стек, до выхода из блока; register — подсказка хранить в регистре (адрес брать нельзя); static у локальной продлевает жизнь до конца программы; static у глобальной даёт внутреннюю линковку; extern объявляет объект из другой единицы трансляции. Понимание этих различий объясняет, почему одинаковый код ведёт себя по-разному в одном и в нескольких файлах. (МИФИ, МГУ.)

    Аналогия из жизни. auto — коробка на вашем столе: уйдёте — коробку выбросят. static — коробка в архиве: живёт, пока существует компания. extern — записка «такая-то коробка лежит в соседнем отделе, сами не заводите».

    Почему это сложно. В Си три независимых оси: где живёт (стек/куча/статика), как долго (блок/программа), видна ли в других файлах (внутренняя/внешняя линковка). Классы памяти задают точку на пересечении этих осей.

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

  6. В чём разница области видимости и времени жизни? Область видимости — часть программы, где имя переменной доступно (блок, файл, вся программа). Время жизни — промежуток, пока объект существует в памяти (до выхода из блока, до конца программы или до free). Это независимые понятия: имя может быть видимым, когда объект уже не существует, — именно так появляется висячий указатель. (МИФИ, МГУ.)

    Аналогия из жизни. Область видимости — это ваш пропуск на этаж: пока он есть — вы «видимы» как сотрудник этажа. Время жизни — это ваше физическое присутствие. Вы можете иметь пропуск (имя видно), но уехать в командировку (объекта уже нет) — и попытка позвать вас по имени в лучшем случае вернёт пустоту.

    Практическая подсказка. Если в отладчике видите «valid pointer» по адресу, но обращение к нему падает — скорее всего, время жизни объекта кончилось, хотя имя переменной ещё «существует» в области видимости.

  7. Что делает static для локальной и глобальной переменной? Для локальной переменной static продлевает время жизни до конца программы: она инициализируется один раз и сохраняет значение между вызовами, оставаясь видимой только внутри блока. Для глобальной переменной или функции static даёт внутреннюю линковку: имя не видно из других единиц трансляции, что используют для сокрытия вспомогательных функций. Общее — размещение в статической области, а не на стеке. (МИФИ, МГУ, МФТИ.)

    Аналогия из жизни. static локальная — это записная книжка в ящике стола, которую вы открываете каждый раз при входе в кабинет, но она там лежит с прошлого визита. static глобальная — это документ «для служебного пользования»: в своём отделе его видят, а в соседнем — нет.

    Типичные ошибки. Считать, что static локальная инициализируется при каждом вызове. Она инициализируется один раз — при первом входе в блок (или до main, если инициализатор константный). Это удобно для счётчиков вызовов, но может быть неожиданным для новичков.

Препроцессор, ввод-вывод, файлы (МИФИ)

Определение. Препроцессор — это отдельная программа, которая работает до компилятора и выполняет текстовые подстановки: подключает файлы, раскрывает макросы, вырезает куски кода по условиям. Стандартная библиотека ввода-вывода (stdio.h) даёт функции printf, scanf, fopen, fread — через них программа общается с пользователем и файлами.

  1. Зачем нужен препроцессор и в чём опасность макросов? Препроцессор работает до компилятора и оперирует текстом: подставляет файлы (#include), раскрывает макросы (#define), отбирает код по условиям (#if, #ifdef). Макросы опасны тем, что не типизированы и подставляют аргумент буквально, поэтому MAX(i++, j) увеличит i лишний раз. Дополнительные риски — побочные эффекты, неудобство отладки и невозможность взять адрес. Замена макроса — static inline-функция: аргументы вычисляются один раз и проверяются типы. (МИФИ, МФТИ.)

    Аналогия из жизни. Макрос — это автозамена в текстовом редакторе: вы задаёте шаблон, и он вставляется буквально, без понимания смысла. Если в шаблоне был i++, он подставится в двух местах и i увеличится дважды. Функция — это «умный» шаблон: аргументы она принимает по значению, и i++ в одном аргументе выполнится ровно один раз.

    Почему макросы всё ещё живы. В Си нет шаблонов и перегрузки функций, и для некоторых вещей (например, MAX для произвольного типа) макрос — самый короткий путь. Но для всего остального лучше static inline.

    Типичные ошибки. 1) SQUARE(x) x*x — SQUARE(a+1) даёт a+1*a+1 = a + a + 1. Решение — оборачивать аргументы в скобки и результат тоже: #define SQUARE(x) ((x)*(x)). 2) Многострочные макросы без do { ... } while(0) ломают код в if без фигурных скобок.

  2. Что такое #include guard? Include guard — конструкция #ifndef NAME_H / #define NAME_H / #endif вокруг содержимого заголовка, не дающая подключить файл дважды. Это предотвращает повторные определения и рекурсивные #include. Вместо неё часто пишут #pragma once — короче, но не входит в стандарт (поддерживается всеми популярными компиляторами). Имя макроса делают уникальным, обычно по имени файла. (МИФИ, МФТИ.)

    Аналогия из жизни. Это как проходная с пропуском: когда сотрудник входит первый раз, ему ставят печать. При повторной попытке пройти — печать уже есть, и охранник разворачивает. Без guard'а при каждом #include начинается «новый заход», и определения типов дублируются.

    Почему #pragma once не стандарт. Исторически #pragma — это «расширения» компилятора. Стандарт не требует, чтобы компилятор знал once, но GCC, Clang и MSVC понимают. Для переносимости используют guard — он работает везде.

    Практическая подсказка. Используйте LIBNAME_FILENAME_H_INCLUDED для макроса guard'а — это уникально даже в больших проектах и легко гуглится.

  3. Как работает printf/scanf и что важно? Это форматированный вывод и ввод без информации о типах: тип задаёт спецификатор в строке формата, и его несоответствие аргументу — UB. В scanf перед переменными нужен & (кроме массивов), double требует %lf, float — %f, а возвращаемое число успешно прочитанных полей нужно проверять. Для защиты буфера задают ширину (%31s), а для строк с пробелами применяют fgets. (МИФИ, МФТИ.)

    Аналогия из жизни. printf — это калькулятор, которому вы диктуете «вот число, вот число, сложи их», но если вы ошиблись в команде, он молча выдаст чушь. scanf — наоборот: калькулятор читает инструкцию из строки и сам решает, куда положить ответ.

    Почему опасен scanf. В отличие от printf, он записывает по адресу: пропустили & — пишет в произвольное место стека, segmentation fault или тихий баг. Поэтому для безопасного ввода в реальных проектах предпочитают fgets + sscanf.

    Типичные ошибки. 1) scanf("%d", &x) для строки — типы не совпадают, UB. 2) Не проверять возврат scanf: если пользователь ввёл abc вместо числа, x останется неинициализированным.

  4. Чем текстовый файл отличается от бинарного? Текстовый хранит символы: работают fprintf/fgets, данные удобны для чтения человеком, но запись требует форматирования. Бинарный хранит байты как есть: fwrite/fread пишут структуры целиком, экономя место и сохраняя точность, но файл нечитаем глазами и зависит от порядка байтов. На Windows принципиален режим "b", потому что в текстовом режиме \n превращается в \r\n. Выбор зависит от того, должен ли файл остаться байт-в-байт. (МИФИ.)

    Аналогия из жизни. Текстовый файл — это письмо на бумаге: понятно, можно прочитать в дороге. Бинарный — это контейнер с вещами: внутри всё нужное, но без инструкции не разберёшь, что где лежит, и при переезде в другую страну может быть «несовместимо».

    Почему на Windows важно "b". В текстовом режиме при чтении \r\n превращается в \n, а при записи наоборот. Если вы пишете картинку или архив без "b", получите испорченный файл — лишние \r или потерянные \n в случайных местах.

    Практическая подсказка. Всегда открывайте бинарные файлы через "rb"/"wb", даже на Linux/macOS — это переносимо и подчёркивает намерение. И читайте в цикле, проверяя fread на короткое чтение.

Структуры данных и алгоритмы (МИФИ, МГУ, МФТИ)

Определение. Структура данных — это способ организовать данные в памяти, чтобы операции над ними (поиск, вставка, удаление) выполнялись с нужной сложностью. Алгоритм — это последовательность действий, которая решает задачу, используя структуры данных. На экзамене ожидают, что вы знаете не только «как написать», но и «почему так быстро» и «когда будет медленно».

  1. Сложность основных сортировок? Пузырьком, вставками и выбором работают за O(n²) в среднем и худшем (вставками и пузырьком — O(n) на почти отсортированных данных). Быстрая сортировка Хоара в среднем даёт O(n log n), но в худшем (неудачный опорный элемент на отсортированных данных) — O(n²). Сортировка слиянием всегда O(n log n) и устойчива, но требует O(n) дополнительной памяти. Устойчивы пузырёк, вставки и слияние; быстрая и выбор — нет. (МИФИ, МФТИ.)

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

    Почему важна устойчивость. Устойчивая сортировка не меняет относительный порядок равных элементов. Если вы сортируете сначала по фамилии, а потом по имени — устойчивая сортировка оставит людей с одинаковым именем упорядоченными по фамилии.

    Типичные ошибки. Применять быструю сортировку на уже отсортированных данных и выбирать опорным первый элемент — получится O(n²). Решение — выбирать опорный случайно или «медиана из трёх».

  2. Как разрешаются коллизии в хеш-таблицах? Коллизия — совпадение хешей у разных ключей, из-за которого они претендуют на одну ячейку. Разрешают двумя способами: методом цепочек (в ячейке хранится список таких элементов — устойчив к высокой загрузке, но тратит память на указатели) и открытой адресацией (при занятости ищут следующую ячейку: линейное или квадратичное пробирование, двойное хеширование). При открытой адресации удаление требует «надгробия», иначе поиск оборвётся раньше времени. Средняя сложность операций — O(1), но при плохом хеше она деградирует до O(n). (МИФИ, МГУ.)

    Аналогия из жизни. Хеш-таблица — это почтовые ящики в доме: хеш от адреса говорит, в какой ящик положить письмо. Но если у двух жильцов одинаковый номер квартиры — это коллизия. Метод цепочек — в ящике лежит стопка писем (список). Открытая адресация — если ящик занят, ищешь соседний свободный (пробирование).

    Почему важно «надгробие». При открытой адресации вы ищете ячейку, последовательно проверяя следующие. Если удалённый элемент просто стереть, поиск элемента, который был дальше, «упрётся» в пустую ячейку и решит, что элемента нет. Поэтому ставят метку «тут был удалён» — надгробие.

    Практическая подсказка. Хорошая хеш-функция и коэффициент загрузки ниже 0.7 — залог производительности. Если таблица растёт, её перестраивают (rehash) с увеличенным размером.

План подготовки за 7 дней

Каждый день — теория плюс код руками (без IDE-подсказок и автодополнения). На экзамене пишут на бумаге, поэтому тренируйтесь писать код без запуска. У каждого дня есть «артефакт» — то, что вы должны уметь воспроизвести, не подглядывая; считайте день закрытым только тогда, когда артефакт получается с первого раза.

Важно понимать «зачем» такой формат. Письменный экзамен проверяет не скорость набора текста, а глубину понимания. Если вы пишете код «на автомате» в IDE, на бумаге вы обнаружите, что не помните, где ставится *, а где &, путаете точку с запятой после struct и забываете точку перед ->. Поэтому «ручной режим» — единственный способ натренировать моторную память и внимание к деталям.

  • День 1. Типы и память. Здесь под «типом» понимается описание данных: сколько байт занимает значение, как оно интерпретируется (знаковое/беззнаковое, целое/дробное) и какие операции над ним допустимы. «Память» — это линейная последовательность байт, к которым программа обращается по адресам. Представление целых и IEEE 754, знаковость, переполнение, sizeof, классы памяти. Артефакт: программа, печатающая размеры всех базовых типов и их границы через limits.h/float.h, плюс объяснение вслух, почему -1 < 1u ложно.

    • Простая аналогия. unsigned int — это линейка, которая начинается с 0 и уходит только вверх; signed int — это линейка с нулём посередине, где половина шкалы отрицательная. Когда вы смешиваете их в одном выражении, компилятор C неявно приводит int к unsigned (правило «обычных арифметических преобразований»), и -1 превращается в максимальное число, которое всегда больше 1u. Поэтому -1 < 1u ложно.
    • Почему это важно на экзамене. Преподаватели МИФИ/МГУ/МФТИ любят ловить на неявных приведениях и на знании того, что sizeof — это оператор, а не функция (скобки не обязательны).
    • Типичная ошибка. Писать sizeof(int) и думать, что это «функция от типа». На деле sizeof возвращает size_t (беззнаковый тип), и сравнивать его с int нужно с осторожностью.
    • Практическая подсказка. Запомните «магические числа» для 32-битной платформы: sizeof(char)=1, sizeof(int)=4, sizeof(double)=8. На 64-битной Linux размер long равен 8, а в Windows (LLP64) — 4. Это любят спрашивать.
  • День 2. Указатели и массивы. Указатель — это переменная, хранящая адрес другой переменной (как «закладка» в книге: сама закладка не текст, но указывает на страницу). Массив — это непрерывный блок однотипных элементов. Адресная арифметика, decay, const с указателями, многомерные массивы. Артефакт: копирование массива через указатели, поиск минимума, реверс — без индексации [].

    • Простая аналогия. «Decay» (от англ. decay — распад) — это когда имя массива «распадается» до указателя на свой первый элемент в большинстве выражений. Это как если бы вы сказали «дай мне адрес первой квартиры в этом доме» — из фразы про дом автоматически получается указатель на квартиру №1. Исключения: sizeof arr, &arr, строковый литерал в инициализаторе — здесь decay не происходит.
    • Почему так. В C массивы не являются первоклассными объектами: их нельзя присваивать целиком, передавать по значению в функцию (передаётся указатель), не хранится длина. Это историческое решение ради эффективности 1970-х, и мы с ним живём до сих пор.
    • Типичные ошибки. (1) Писать void f(int a[10]) и думать, что компилятор проверит длину — на самом деле параметр превратится в int *a, и число 10 игнорируется. (2) Путать const int *p, int *const p и const int *const p: первое запрещает менять данные, второе — сам указатель, третье — и то, и другое. Совет: читайте справа налево — «что стоит справа от *, то и константно».
    • Практическая подсказка. Чтобы пройти артефакт без [], привыкните к шаблону: for (const int *p = a; p < a + n; ++p). Это базовый цикл, который требуют написать почти на каждом экзамене.
  • День 3. Строки и функции. Строка в C — это не отдельный тип, а массив char, оканчивающийся нулевым символом '\0' (как поезд: вагоны — полезные символы, а в хвосте обязательно пустой вагончик-терминатор). Свои strlen, strcpy, strcat, strcmp, strstr, strrev, strtrim. Артефакт: все семь функций без подсказок за 20 минут; рекурсия — факториал, Фибоначчи, Ханойская башня, бинарный поиск.

    • Почему именно свои. Экзаменаторы почти никогда не просят «вызвать strlen» — они просят «написать strlen». Цель — проверить, понимаете ли вы, что '\0' обязателен и что функции работают с указателями, а не с «объектами».
    • Типичные ошибки. В своей strcpy забывают скопировать терминатор '\0'. В strcmp пишут *p1 - *p2 (это работает для ASCII, но даёт не «разницу», а «сравнение»; преподаватель может снизить балл за непонимание). В strcat не проверяют, хватит ли места в приёмнике, — на экзамене это не баг, но на устном собеседовании — красный флаг.
    • Про рекурсию. Ханойская башня — классика, потому что в ней видна структура рекурсивного решения: переместить n-1 дисков на вспомогательный, переместить самый большой, переместить n-1 на целевой. Если вы можете за минуту произнести эти три шага — вы поняли рекурсию глубже, чем 80% студентов.
    • Практическая подсказка. Все семь строковых функций имеют один и тот же каркас: «пока символ не '\0' — что-то делай, потом обработай терминатор». Держите этот каркас в голове, и любая строковая задача станет механической.
  • День 4. Структуры и динамическая память. struct — это «чертёж» составного типа: склеенные поля разных типов (как анкета сотрудника, где есть имя, возраст, зарплата). union — это «перекрывающиеся поля»: все они лежат в одной и той же памяти, как один и тот же ящик, в который в разное время кладут разные вещи. Битовые поля, padding, offsetof; malloc/calloc/realloc/free, утечки. Артефакт: динамический массив с ростом через realloc и динамическая матрица, освобождённые без утечек.

    • Зачем padding. Процессор читает память блоками по 4 или 8 байт, и поля структуры выравниваются по границам ради скорости. Поэтому sizeof(struct {char a; int b;}) обычно равен 8, а не 5. Это не баг, это архитектура. На экзамене могут попросить угадать размер структуры или нарисовать её «раскладку» в памяти.
    • Почему realloc нельзя присваивать напрямую. Если realloc вернёт NULL (память кончилась), а вы уже записали результат в исходный указатель — вы потеряли и старый указатель, и данные, и получите утечку. Правильный шаблон: «создать временный указатель → проверить на NULL → только потом присваивать». Это один из самых частых вопросов на защите лабораторных.
    • Разница malloc vs calloc. malloc возвращает «грязную» память (там могут быть старые данные), calloc — обнулённую. На экзамене скажите: «calloc дополнительно зануляет, и в теории может проверить переполнение размера, но на практике malloc быстрее».
    • Типичные ошибки. (1) free по указателю не из malloc/calloc/realloc (например, по указателю в середину массива). (2) Двойной free — классика valgrind. (3) Забытый free в функции при return через несколько веток if. Совет: на экзамене для самопроверки нарисуйте «дерево путей» и убедитесь, что на каждом листе есть free.
    • Практическая подсказка. Динамическая матрица — это «массив массивов»: сначала malloc строк, потом в цикле malloc каждой строки. Освобождение — в обратном порядке (сначала строки, потом массив указателей). Запомните этот двухуровневый паттерн.
  • День 5. Файлы, препроцессор, бинарные числа. Файл в C — это поток байт, к которому подключён буфер; FILE * — это «пульт управления» этим потоком. Текстовый и бинарный ввод-вывод, fseek/ftell; макросы и include guard; битовые операции. Артефакт: мини-проект «телефонная книга в файле» с сохранением и чтением.

    • Текстовый vs бинарный режим. Текстовый режим на Windows превращает \n в пару \r\n при записи и обратно при чтении; на Linux это просто прозрачный режим. Бинарный режим — «никаких преобразований, байты как есть». На экзамене по бумаге это обычно знание без кода, но если попросят написать запись структуры — обязательно "wb" и "rb".
    • Include guard — зачем. Если один и тот же заголовок включится дважды (напрямую и через другой заголовок), без защиты вы получите ошибку «переопределение». Guard #ifndef X #define X ... #endif — это «замок с одним ключом»: первое включение открывает замок, второе видит, что замок уже открыт, и не заходит.
    • Битовые операции — аналогия. & — это «пересечение множеств»: бит остаётся только там, где он был в обоих операндах. | — «объединение». ^ (XOR) — «симметрическая разность»: бит меняется там, где операнды различаются. ~ — инверсия. Сдвиги << и >> — умножение/деление на степень двойки (для unsigned).
    • Типичные ошибки. Использовать >> для знаковых чисел — поведение implementation-defined (арифметический или логический сдвиг — зависит от компилятора). Путать 1 << n с 1 << (n-1) при установке n-го бита.
    • Практическая подсказка. «Телефонная книга» — это, по сути, цикл «открыть файл → прочитать всё в массив структур → изменить → перезаписать». На экзамене достаточно показать функции load и save через fread/fwrite и блок if (fp == NULL) return;.
  • День 6. Структуры данных. Структура данных — это способ организовать данные в памяти так, чтобы определённые операции (поиск, вставка, удаление) выполнялись быстро. Списки (одно- и двусвязный), стек, очередь, дерево BST, обходы, хеш-таблица, КМП, топологическая сортировка. Артефакт: каждая структура реализована хотя бы один раз и её сложности названы вслух.

    • Простые аналогии. Стек — стопка тарелок: последнюю поставил — первую взял (LIFO). Очередь — очередь в магазине: первый пришёл — первый ушёл (FIFO). Односвязный список — поезд: каждый вагон знает только о следующем. Двусвязный — поезд, где каждый вагон знает и о предыдущем, и о следующем (можно ехать в обе стороны, но памяти тратится больше). BST — «двоичный поиск в древе»: в левом поддереве всё меньше корня, в правом — всё больше, как в отсортированном массиве, но с быстрой вставкой. Хеш-таблица — камеры хранения в вокзале: номер камеры = hash(ключ), если камера занята — возникает коллизия.
    • Коллизии: цепочки vs открытая адресация. Цепочки — каждый бакет массива хранит список всех элементов с одинаковым хешем (как шкаф с ящиками, где в одном ящике много вещей). Открытая адресация — если место занято, идём дальше (+1, +2, …) и кладём в первую свободную ячейку (как ищем свободное место на парковке). Цепочки проще, открытая адресация экономит память, но чувствительна к заполненности.
    • КМП. Алгоритм Кнута–Морриса–Пратта ищет подстроку в строке за O(n+m), используя префикс-функцию (длину максимального бордера для каждого префикса). На экзамене обычно достаточно сказать: «КМП не возвращается назад по тексту, как наивный поиск, а использует уже посчитанную информацию».
    • Топологическая сортировка. Это порядок задач, в котором каждая зависимость «B после A» соблюдена. Реализуется либо через DFS с «отложенным выводом» в стек, либо через алгоритм Кана (BFS с подсчётом входящих степеней). Применяется в сборщиках (make, maven), планировщиках задач, разрешении зависимостей в пакетах.
    • Практическая подсказка. На листке рисуйте узлы со стрелками, подписывайте next/prev/left/right. Экзаменатор проверяет не компиляцию, а понимание структуры, и рисунок убеждает лучше, чем код.
  • День 7. Теория алгоритмов и прогон. МТ — машина Тьюринга (воображаемая лента с головкой, которая читает/пишет символы и переходит между состояниями); НАМ — нормальный алгоритм Маркова (формальная система переписывания строк по правилам подстановки). Тезис Чёрча–Тьюринга гласит: всё, что интуитивно «вычислимо», вычислимо на машине Тьюринга (то есть формальные модели эквивалентны по мощности). Проблема останова — неразрешима: нельзя написать программу, которая по любой программе и входу скажет, зависнет она или нет. БНФ — Бэкуса–Наура форма (метаязык для описания синтаксиса, например <digit> ::= 0 | 1 | ... | 9). Сортировки и их сложности. Артефакт: 30 вопросов ниже проговорены вслух, 3–4 задачи написаны на время (для самопроверки — на листе без интернета).

    • Зачем это программисту. Формальные модели объясняют, почему некоторые задачи нельзя решить алгоритмом (вроде останова), а БНФ — это то, как описаны грамматики всех ваших любимых языков. На экзамене по теории это критично: часто просят «сформулировать тезис Чёрча–Тьюринга» или «доказать неразрешимость через сведение».
    • Про сортировки. Запомните пятёрку: пузырёк — O(n²) и стабильный, выбор — O(n²), вставка — O(n²) но быстрая на почти отсортированных, слияние — O(n log n) и стабильное, быстрая (qsort) — O(n log n) в среднем, O(n²) в худшем. «Стабильная» означает, что равные элементы сохраняют относительный порядок.
    • Сведение (reduction). Классический приём доказательства неразрешимости: «если бы мы умели решать задачу X, мы бы умели решать задачу Y, а Y неразрешима — значит, X тоже». Именно так доказывают неразрешимость проблемы останова:假设 (предположим) есть решатель останова → используем его для решения проблемы «выводит ли программа 0», которая сводится к остановке.
    • Практическая подсказка. Прогон 30 вопросов вслух — это устная репетиция. Говорите как на экзамене: чётко, по терминам, без «эээ». Если застряли — запишите тему и вернитесь к ней на следующий день.

Чек-лист самопроверки перед экзаменом:

  • Умею ли я объяснить разницу «область видимости vs время жизни» за 30 секунд?
  • Могу ли я написать strlen и strcpy без единой ошибки с первого раза?
  • Помню ли я, почему realloc нельзя присваивать прямо в указатель?
  • Могу ли я нарисовать блок-схему цикла и подписать выходы ромба?
  • Назову ли я сложности пяти сортировок и разницу между цепочками и открытой адресацией?
  • Сформулирую ли я, почему проблема останова неразрешима, без подсказок?

Небольшой «словарик» к чек-листу, чтобы ответы были точными:

  • Область видимости (scope) — это текстовая часть программы, где имя доступно (глобальная, файловая, блочная, прототипная). Время жизни (lifetime) — это временной интервал от создания до уничтожения объекта в памяти. У локальной static-переменной область видимости — внутри функции, а время жизни — до конца программы. На экзамене любят ловить именно на этой разнице.
  • Блок-схема цикла с ромбом — это «условие продолжения» в ромбе, две стрелки «да/нет», стрелка «да» идёт обратно к телу цикла, «нет» — на выход. Если вы нарисуете «нет» обратно — вы перепутали while с do-while.
  • Цепочки vs открытая адресация — цепочки проще реализовать, открытая адресация быстрее при малом коэффициенте заполнения (обычно < 0.7).

Практический совет: держите под рукой шпаргалку с сигнатурами (printf, malloc, fopen) и таблицей приоритетов — на письменном экзамене мелочи решают. И ещё одно: перед сдачей прогоните свой код глазами построчно «как компилятор» — большинство ошибок на бумаге именно синтаксические, а не логические.

Несколько финальных хитростей, проверенных поколениями:

  • Сигнатуры. Самая частая потеря балла — printf("%d\n", x) без & там, где нужен адрес, или наоборот. Держите в голове: printf принимает значения (потому что сам разыменует по формату), а scanf — адреса (&x). Это родовая травма всех первокурсников.
  • Таблица приоритетов. Запомните хотя бы верхушку: () и [] > унарные *, &, ! > умножение/деление > сложение/вычитание > сдвиги > сравнения > ==/!= > & > ^ > | > && > || > присваивание > ,. Если сомневаетесь — ставьте скобки, на экзамене это не штрафуется.
  • «Глазами как компилятор» — значит читать код слева направо по одному токену и сразу мысленно проверять: «соответствует ли тип? есть ли точка с запятой? не вылез ли за массив?». Так вы заметите if (x = 0) (присваивание вместо сравнения) раньше, чем преподаватель.
  • Про время. Если на задачу дают 15 минут, а вы застряли на 10-й — лучше оставить 5 минут на проверку, чем утонуть в одной задаче. Письменный экзамен — это марафон, а не спринт.

Частые ошибки

Эта таблица — «сводка грабель», на которые наступают почти все студенты на экзамене по Си. Прежде чем разбирать конкретные случаи, держите в голове три источника проблем:

  • Синтаксис и приоритеты — решаются скобками и внимательным чтением.
  • Типы данных и спецификаторы — решаются явным приведением и правильным форматом.
  • Жизненный цикл памяти — кто выделил, кто владеет, кто освобождает.

Аналогия из жизни. Указатель — это номерок из гардероба. Если вы потеряли номерок (NULL), забыли забрать вещь (утечка) или пытаетесь забрать чужую куртку (use-after-free), проблема не в гардеробщике, а в вас. Поэтому таблица ниже — это «чек-лист гардеробщика»: что проверить, прежде чем сдавать программу. Большинство ошибок здесь компилятор не ловит даже с -Wall -Wextra — это UB (undefined behaviour, неопределённое поведение), которое «работает» на вашем ноутбуке и падает на экзаменационном сервере.

Ошибка Причина Решение
Забыт & в scanf Функция ждёт адрес переменной, чтобы записать туда введённое значение. Без & вы передаёте значение (мусор), и scanf пытается писать по случайному адресу — почти всегда SIGSEGV (нарушение сегментации) scanf("%d", &n); и всегда проверять возврат — сколько полей реально прочитано
%d для size_t / double Спецификатор формата не совпадает с типом аргумента — это UB. Для double в printf %f корректен (продвижение до double), но в scanf нужен именно %lf %zu для size_t, %lf в scanf для double
printf("%f", (float)x) float автоматически повышается до double при передаче в вариадическую функцию (функцию с переменным числом аргументов, как printf), поэтому %f (он же для double) работает. Но для long double нужен %Lf Для long double — %Lf; %f для double
= вместо == Присваивание возвращает значение и всегда истинно, кроме 0. if (x = 0) ложно, if (x = 5) всегда истинно — типичная ловушка, особенно когда отступы обманчивы if (0 == x), флаг -Wparentheses
Выход за границы массива Цикл i <= n при n элементах — последний валидный индекс это n-1. Запись в a[n] читает/портит соседнюю переменную или чужую память в куче Верхняя граница строго < n, санитайзеры (-fsanitize=address)
Нет '\0' в строке Строка в C — это массив байт, оканчивающийся нулевым байтом. Любая функция (printf %s, strlen, strcpy) ищет этот терминатор; если его нет — она «бежит» по памяти, пока не найдёт ноль или не упадёт Добавлять терминатор '\0' вручную, помнить, что sizeof("abc") == 4, а не 3
Изменение строкового литерала Строковый литерал "abc" компилятор кладёт в read-only секцию (.rodata, область памяти только для чтения). Любая попытка записи — UB, часто SIGSEGV char s[] = "abc"; — компилятор создаст копию в обычной (изменяемой) памяти
strcpy/strcat без проверки Эти функции копируют до первого '\0' и не знают размер буфера-приёмника. Если источник длиннее — переполнение и, возможно, эксплуатация (использование уязвимости злоумышленником) snprintf(dst, sizeof dst, "..."), fgets, всегда указывать явный лимит длины
realloc присвоен прямо в указатель Если realloc вернул NULL, старое выделение осталось живым, но вы потеряли на него указатель — утечка гарантирована tmp = realloc(p, n); if (tmp) p = tmp; else { free(p); p = NULL; }
Утечка памяти Каждый malloc/calloc/realloc обязан иметь парный free на каждом пути выхода: нормальное завершение, ошибка, return, break, longjmp (прыжок через setjmp/longjmp) Симметрия malloc/free, Valgrind (valgrind --leak-check=full ./a.out), ASan
Двойное free / use-after-free После free(p) память может быть тут же выдана другому malloc. Повторный free(p) или чтение через p — это порча чужих данных, причём тихо free(p); p = NULL; — после обнуления free(NULL) безопасен, а разыменование даст SIGSEGV сразу, а не молчаливую порчу
Возврат адреса локальной переменной После выхода из функции её стековый кадр освобождается. Возвращённый указатель указывает «в никуда» — чтение/запись через него — UB Возвращать значение или выделять память в куче (malloc), либо возвращать структуру по значению
Переполнение int при a * b Если оба операнда int, умножение тоже int — переполнение это UB в C. 1000 * 1000 ещё влезает, а 1'000'000 * 1'000'000 уже нет Приводить к широкому типу до умножения: (long long)a * b или 1LL * a * b
x / 2 вместо x / 2.0 Если x — int, деление целочисленное: дробная часть отбрасывается (не округляется!). 5 / 2 == 2, а не 2.5; -3 / 2 == -1, а не -2 Явно double, литерал 2.0 или приведение (double)x / 2
sizeof массива в функции В параметрах функции массив «затухает» (decay) до указателя: sizeof a даёт размер указателя (8 байт на x64), а не массива Передавать длину отдельным параметром: void f(int a[], size_t n)
Бесконечный цикл в списке При удалении узла p классический код prev->next = p->next; free(p); p = p->next; — это p = p->next после free, плюс смешение итерации и удаления При удалении p не сдвигать итератор; двигать только в случае «не удаляли»: if (удалить) { ... } else { p = p->next; }
Потерянная голова списка Если вставка идёт в начало, новый узел становится head, но вызывающий код продолжает работать со старым указателем — узел «осиротел» в куче Возвращать head из функции или принимать Node **head (указатель на указатель) и менять *head явно
Рекурсия без базы / без уменьшения Каждый рекурсивный вызов кладёт новый кадр в стек; без условия выхода (базы) или без сходимости параметра стек переполнится — SIGSEGV Явная база if (n <= 0) return ...; и сходящийся параметр (например, n/2, n-1, left+1)
Приоритет *p++ Постфиксный ++ имеет более высокий приоритет, чем разыменование *. Поэтому *p++ — это «прочитать *p, затем сдвинуть p», а не «сдвинуть значение по p» Скобки: (*p)++ для инкремента значения, *(++p) для «сначала сдвинуть, потом прочитать»
Плавающее сравнение == Числа с плавающей точкой хранятся приближённо: 0.1 + 0.2 != 0.3 на уровне битов. Прямое == почти всегда ложно там, где математически должно быть истинно Сравнивать с допуском fabs(a-b) < eps, где eps подбирается под задачу (например, 1e-9)
malloc(10) вместо malloc(10 * sizeof(int)) malloc принимает размер в байтах. 10 — это 10 байт, а не 10 элементов. На 64-бит это меньше двух int Всегда malloc(n * sizeof *p) — идиома масштабируется на любой тип указателя, не нужно повторять имя типа
free указателя, полученного не из malloc Семейство malloc/realloc/calloc — единственные источники «кучи». Стековые переменные, литералы, адреса полей структур — это не куча. free от них — UB Помнить, кто владеет памятью; не освобождать литералы и локальные массивы
while (!feof(f)) feof возвращает истину только после неудачного чтения. Цикл делает лишнюю итерацию и читает «за хвостом» файла — мусор в последнем блоке данных Цикл на while (fgets(buf, sizeof buf, f)) или while (fread(...) > 0), проверять возвращаемое значение самой операции чтения
scanf("%s", buf) без ширины %s читает «любое количество непробельных символов» — сколько бы их ни ввёл пользователь, хоть мегабайт. Запись пойдёт за buf — порча стека/кучи scanf("%31s", buf) (ширина на 1 меньше sizeof buf) или fgets(buf, sizeof buf, stdin)
Сравнение int с size_t При сравнении знаковый int приводится к size_t (беззнаковому). Отрицательное -1 становится огромным SIZE_MAX, и условие n < -1 всегда истинно, n >= 0 всегда ложно Приводить к общему типу: (size_t)n если уверены в неотрицательности, или (long long)s для знаковой работы с длинами
Обратный цикл for (size_t i = n-1; i >= 0; i--) size_t беззнаковый и не может быть < 0. Когда i доходит до 0 и делает --, он становится SIZE_MAX — цикл крутится вечно for (size_t i = n; i-- > 0; ) (постдекремент в условии) или взять знаковый индекс ssize_t/long
Забыт break в switch case в C — это не блок, а метка. Без break выполнение «проваливается» (fallthrough) в следующий case, даже через границу — классический источник трудноуловимых багов Ставить break в конце каждого case или явно отметить «проваливание» комментарием /* fallthrough */ (GCC/Clang распознают с -Wimplicit-fallthrough)
int для длины строки strlen возвращает size_t (беззнаковый, обычно 64 бита). Если хранить длину в int, на больших строках (>2 ГБ символов) получите отрицательное число и ложные условия Хранить длину в size_t или ssize_t, печатать через %zu
if (x = 5) Присваивание x = 5 возвращает 5, условие истинно. Программа «работает», но логика совершенно не та, что задумывал автор — классика опечаток if (x == 5) или идиома «Yoda comparison» if (5 == x) — опечатка даст ошибку компиляции, а не тихий баг

Как читать таблицу. Ошибки сгруппированы по «месту происхождения», и это не случайно. Синтаксические (=, break, приоритеты, %d для size_t) ловятся компилятором с -Wall -Wextra или статическим анализатором — поэтому на экзамене достаточно знать, какой флаг включить, и проблема видна сразу. А вот логические ошибки памяти (утечки, выход за границы, use-after-free, потеря головы списка) компилятор не видит вовсе — программа с ними успешно компилируется и даже работает на коротких тестах. Их находят только санитайзеры (-fsanitize=address,undefined), Valgrind и внимательное чтение кода. Поэтому на экзамене полезно отдельно проговаривать: «здесь я работаю с памятью, значит, проверяю границы, владельцев и освобождение».

Три ошибки, которые чаще всего стоят оценки. (1) Потеря головы списка или узла дерева при операциях вставки/удаления — это отдельно проверяют в задачах на структуры; защита здесь — либо возвращать новый head, либо принимать Node **head и менять *head явно. (2) Забытый '\0' при ручной работе со строками: программа «работает» на коротком вводе и падает на длинном, потому что printf %s уходит в чужую память искать терминатор. Ответ — всегда закладывать +1 байт под терминатор и явно ставить buf[len] = '\0'. (3) Переполнение int в промежуточных вычислениях (a * b, d * d <= n, (a + b) / 2): на маленьких тестовых числах незаметно, а на больших ломает логику или даёт UB. Защита — идиома 1LL * a * b или сразу тип long long для счётчиков и аккумуляторов.

Мнемоника для самопроверки перед сдачей: «Память, Указатели, Границы, Ы?» — проверьте: кто владеет памятью (выделена malloc? будет ли free на каждом пути выхода?), куда смотрят указатели (не NULL, не use-after-free, не «адрес локальной переменной наружу»?), не выходят ли индексы за границы массива (строгое < n, а не <= n?). Три вопроса закрывают большинство практических ошибок на экзамене. Если на любой из них ответ «не знаю» — код ещё не готов к сдаче, перечитайте таблицу и прогоните через Valgrind.

Ресурсы

Список подобран под подготовку к экзамену, а не под изучение языка с нуля. Если времени мало: учебник K&R — для формулировок определений, Руденко — для практики, cppreference — для точных сигнатур перед экзаменом. Ниже каждый источник сопровождается пояснением «зачем он нужен» и «в какой момент подготовки к нему обращаться», чтобы не тратить время на лишнее чтение.

  • Брайан Керниган, Деннис Ритчи. «Язык программирования C» (K&R) — канонический учебник, базовый для экзамена: по нему удобно сверять определения и стиль. Авторы — это Деннис Ритчи (один из создателей языка C) и Брайан Керниган, так что формулировки из K&R фактически являются «эталонными» для преподавателей МИФИ, МГУ и МФТИ. Если на устной части экзамена вас просят «по определению» рассказать, что такое lvalue, объявление или время жизни, — отвечайте в терминах K&R, и риск придирок минимален. Когда открывать: при первом проходе по теории и финальной «причёсывающей» читке формулировок за 1–2 дня до экзамена.
  • Стивен Прата. «Язык C. Лекции и упражнения» — подробный самоучитель на русском, много задач и разборов; хорош, если какой-то раздел «не заходит». В отличие от сухого K&R, Прата разжёвывает материал «на пальцах»: например, через аналогию с почтовыми ящими объясняет указатели, а через очередь в магазине — работу буфера ввода. Практический совет: если застряли на теме malloc/free или struct — откройте именно Прату, у него самые понятные пошаговые разборы. Типичная ошибка: пытаться прочесть его целиком «от корки до корки» — времени не хватит, используйте как справочник по слабым местам.
  • А. В. Руденко. «Сборник задач по языку Си» — типовые задачи, близкие к контрольным МГУ: лучший тренажёр перед письменной частью. Задачи в сборнике специально подобраны под формат университетских контрольных: работа с массивами, строками, рекурсией, указателями, простыми структурами данных. Почему именно Руденко: на экзамене в МГУ часто дают похожие формулировки («написать функцию, которая...», «обработать массив без использования...»), и набить руку на его задачах — самый короткий путь к уверенному решению. Практический совет: решайте по 2–3 задачи в день, не подглядывая в ответы; после — обязательно сверяйте стиль с K&R.
  • А. Казачук. «Программирование в ОС UNIX на языке Си» — практикум по системному C: файлы, процессы, инструменты. Полезно, если в билете попадётся тема про fork/exec, pipe, open/read/write, сигналы или работу с файловой системой через POSIX-вызовы. Аналогия из жизни: UNIX-файл — это «труба с краном»: открыли дескриптор (кран), прочитали/записали данные (протолкнули по трубе), закрыли (закрыли кран). Когда открывать: когда чувствуете, что теоретические знания о libc есть, а вот «руки не помнят» сигнатуры stat, opendir или waitpid.
  • Н. В. Волкова, П. В. Карпов. «Программирование на языке Си» (СПбГУ ИТМО) — компактный курс с задачами, удобен для быстрого повторения. Хорош тем, что в одной небольшой брошюре собраны все ключевые конструкции языка без воды — идеально как «экспресс-курс» за неделю до экзамена. Практический совет: используйте его как чек-лист: прошлись по оглавлению — пробежались по параграфу — отметили галочкой, что помните без подсказки.
  • ГОСТ 19.701-90 — правила оформления блок-схем алгоритмов; откройте, если на экзамене требуют схему по стандарту. ГОСТ задаёт жёсткие требования к форме блоков: ромб — условие, прямоугольник — действие, параллелограмм — ввод/вывод, скруглённый прямоугольник — начало/конец. Типичная ошибка студентов: рисовать блоки произвольной формы или не указывать направление стрелок — за это снижают балл даже при правильном алгоритме. Практический совет: потренируйтесь оформить по ГОСТу хотя бы одну блок-схему из Руденко — это занимает 15 минут, а на экзамене прибавит уверенности.
  • cppreference: C — точная справка по стандарту C17/C23 с примерами; сюда смотреть за сигнатурами и формулировками. Это «библия синтаксиса»: здесь видно, что именно говорит стандарт, без упрощений из учебников. Почему важно: в K&R и Прате некоторые детали опущены (например, restrict, _Atomic, поведение enum), а на экзамене МФТИ любят задавать каверзные вопросы именно по «нестандартным» случаям. Практический совет: заходите сюда точечно — например, проверить точную сигнатуру qsort, bsearch или setjmp/longjmp перед экзаменом, а не читать весь сайт.
  • man7.org — man-страницы: man 3 printf, man 3 malloc, man 3 fread — первоисточник по поведению функций. Man-страница — это документация, которую читают сами разработчики libc, поэтому она самая короткая и самая точная: в ней указаны возвращаемые значения, коды ошибок через errno, требования к заголовочным файлам. Аналогия: если K&R — это учебник по физике для школьника, то man — это технический паспорт прибора: сухо, по делу, но если что-то непонятно — лучшего источника нет. Практический совет: научитесь искать в man с разделами: man 3 — библиотечные функции, man 2 — системные вызовы, man 7 — обзоры и соглашения (например, man 7 signal).
  • Compiler Explorer — позволяет посмотреть ассемблер и убедиться, как оптимизатор использует UB; отличная демонстрация на защите. Удобный онлайн-инструмент: вводите код на C — видите, в какой ассемблер он компилируется для разных архитектур и уровней оптимизации. Зачем это студенту: неопределённое поведение (UB) вроде int i = INT_MAX; i++; компилятор может «вырезать» из кода, потому что формально программа не имеет права туда дойти. Показать преподавателю на Godbolt «вот, оптимизатор доказывает, что цикл бесконечный, потому что тут UB» — это сильный аргумент на устной защите. Типичная ошибка: проверять UB с -O0 — на нулевом уровне оптимизации UB «не видно», экспериментируйте с -O2 или -O3.
  • SEI CERT C Coding Standard — правила безопасного C; здесь описаны последствия типичных ошибок из раздела выше. CERT — это база знаний по безопасному программированию от Института программной инженерии Университета Карнеги-Меллон. Каждое правило (ARR.*, MEM.*, STR.* и т. д.) содержит пример «плохого» кода, объяснение, почему это опасно, и «хороший» вариант. Когда открывать: если преподаватель на экзамене спрашивает «а что будет, если...» — например, «что вернёт gets?», «почему опасно sprintf без проверки длины?» — в CERT есть готовый разбор с пометкой severity (low/medium/high). Практический совет: не пытайтесь выучить весь CERT — используйте его как словарь при разборе конкретных ошибок из ваших же программ: нашли у себя strcpy — нашли в CERT правило STR31-C — поняли, почему так нельзя.

Что дальше


Связанные шпаргалки: Си, C++ для студентов, C++, Linux