Раздел 8 — Массивы, поиск и сортировка

Много значений под одним именем, поиск в них и упорядочивание — с подсчётом, сколько это стоит

Прогресс курса Раздел 8 из 28

Что вы освоите в этом разделе

4 академических часа теории и 2 часа практики: практическая работа №7 «Обработка одномерных массивов» — на портале. Все программы страницы — полные; функции из раздела 7 (read_int) используются без повторения текста.
01

Массив

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

int marks[5] = {5, 4, 4, 3, 5};      /* 5 элементов: marks[0] … marks[4] */
marks[2] = 5;                        /* третий элемент */
int zeros[100] = {0};                 /* все 100 элементов — нули */
double prices[] = {9.5, 12.0, 7.25};   /* размер 3 — по числу значений */
  индекс     0     1     2     3     4
           ┌─────┬─────┬─────┬─────┬─────┐
  marks    │  5  │  4  │  5  │  3  │  5  │     5 × sizeof(int) = 20 байт подряд
           └─────┴─────┴─────┴─────┴─────┘

Типичная программа: наибольшее число данных известно (например, до 100), фактическое число n вводится. Массив объявляется на наибольшее, используется первые n элементов:

/* n чисел: вывести в обратном порядке и найти, сколько из них больше среднего */
#include <stdio.h>
#include <stdlib.h>

enum { MAX_N = 100 };

int read_int(const char *prompt, int min, int max);   /* раздел 7, пункт 07 */

int main(void)
{
    int numbers[MAX_N];
    int n = read_int("n: ", 1, MAX_N);
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        numbers[i] = read_int("", -1000000, 1000000);
        sum += numbers[i];
    }

    for (int i = n - 1; i >= 0; i--) {
        printf("%d ", numbers[i]);
    }
    printf("\n");

    double average = (double)sum / n;
    int above = 0;
    for (int i = 0; i < n; i++) {
        if (numbers[i] > average) {
            above++;
        }
    }
    printf("average %.2f, above %d\n", average, above);
    return 0;
}
n: 5            (числа по одному в строке: 3 8 1 9 4)
4 9 1 8 3
average 5.00, above 2

Без массива эту задачу не решить: среднее известно только после чтения всех чисел, а сравнивать с ним нужно каждое — числа приходится хранить.

Выход за границу. У int a[5] нет элемента a[5]. C не проверяет индекс: a[5] = 0 запишет 0 в чужую память — в соседнюю переменную или служебные данные функции. Это неопределённое поведение: программа может работать, выдавать неверный результат или аварийно завершиться — и вести себя по-разному при каждой сборке. gcc замечает выход за границу только с константным индексом (-Warray-bounds, и то не всегда). Правило курса: цикл по массиву — for (int i = 0; i < n; i++), условие строго <, и n проверено на границы до заполнения. Выход за границу массива — одна из главных причин уязвимостей в программах на C.
02

Массив в функции

Массив передаётся в функцию вместе с числом элементов — сама функция его не узнает:

void print_array(const int a[], int n)
{
    for (int i = 0; i < n; i++) {
        printf("%d ", a[i]);
    }
    printf("\n");
}

void fill_squares(int a[], int n)
{
    for (int i = 0; i < n; i++) {
        a[i] = i * i;
    }
}
                    /* int sq[6]; fill_squares(sq, 6); print_array(sq, 6);  ->  0 1 4 9 16 25 */

Параметры передаются по значению (раздел 7), но массив целиком не копируется: в функцию передаётся адрес его первого элемента. Поэтому fill_squares меняет элементы массива вызывающего — в отличие от swap из раздела 7. Как это устроено, разбирается в разделе 10. Отсюда два правила:

03

Линейный поиск

Найти значение в массиве — перебрать элементы по порядку, пока не найдётся. Функция возвращает индекс найденного или −1:

/* Индекс первого элемента, равного key, или -1 */
int linear_search(const int a[], int n, int key)
{
    for (int i = 0; i < n; i++) {
        if (a[i] == key) {
            return i;
        }
    }
    return -1;
}

−1 — договорённость: индекса −1 не бывает, поэтому он означает «не найдено». Сравнений: в лучшем случае 1 (искомое — первое), в худшем n (последнее или его нет), в среднем при успешном поиске около n / 2. Линейный поиск работает с любым массивом, в том числе неупорядоченным.

04

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

Если массив упорядочен по возрастанию, можно сравнить ключ с серединой и отбросить половину, где его быть не может (раздел 2, пункт 07):

/* Индекс элемента, равного key, в упорядоченном массиве, или -1 */
int binary_search(const int a[], int n, int key)
{
    int low = 0, high = n - 1;           /* ключ, если есть, — в a[low..high] */
    while (low <= high) {
        int mid = low + (high - low) / 2;
        if (a[mid] == key) {
            return mid;
        }
        if (a[mid] < key) {
            low = mid + 1;                   /* ключ правее середины */
        } else {
            high = mid - 1;                  /* ключ левее */
        }
    }
    return -1;
}

Поиск 27 в массиве 3 7 11 15 19 23 27 31 35 39 (индексы 0–9):

Шагlowhighmida[mid]Решение
10941919 < 27 → low = 5
25973131 > 27 → high = 6
35652323 < 27 → low = 6
466627найдено, индекс 6

Поиск 4: mid = 4 (19 > 4) → high = 3; mid = 1 (7 > 4) → high = 0; mid = 0 (3 < 4) → low = 1; low > high — не найдено за 3 шага.

05

O-нотация: как растёт число действий

Точное число сравнений зависит от данных и деталей кода, но важнее, как оно растёт с размером n. Это записывают O-нотацией: O(f(n)) — «растёт не быстрее, чем f(n), с точностью до постоянного множителя». Постоянные множители и младшие слагаемые отбрасываются: n / 2 сравнений — это O(n), n(n − 1) / 2 — O(n²).

ОценкаНазваниеПримерn = 1000n = 1 000 000
O(1)постояннаяэлемент по индексу, формула суммы11
O(log n)логарифмическаядвоичный поиск1020
O(n)линейнаялинейный поиск, сумма, максимум1 0001 000 000
O(n log n)быстрые сортировки (раздел 16)≈ 10 000≈ 20 000 000
O(n²)квадратичнаяпростые сортировки этого раздела, вложенный цикл по всем парам1 000 0001012

Компьютер выполняет порядка 108–109 простых операций в секунду. O(n²) при n = 106 — 1012 операций, это десятки минут; O(n log n) — доли секунды. Поэтому для больших данных выбор алгоритма важнее, чем скорость компьютера.

06

Сортировка выбором

Найти наименьший элемент и поставить его на первое место, обменяв с тем, что там стоял; затем наименьший из оставшихся — на второе, и так далее:

void selection_sort(int a[], int n)
{
    for (int i = 0; i < n - 1; i++) {
        int min_pos = i;
        for (int j = i + 1; j < n; j++) {
            if (a[j] < a[min_pos]) {
                min_pos = j;
            }
        }
        if (min_pos != i) {
            int t = a[i];
            a[i] = a[min_pos];
            a[min_pos] = t;
        }
    }
}
Проход iНаименьший в a[i..5]Массив после проходаСравнений
—5 2 8 1 9 3
01 (индекс 3)1 2 8 5 9 35
12 (на месте)1 2 8 5 9 34
23 (индекс 5)1 2 3 5 9 83
35 (на месте)1 2 3 5 9 82
48 (индекс 5)1 2 3 5 8 91

Сравнений всегда (n − 1) + (n − 2) + … + 1 = n(n − 1) / 2 — здесь 15 — независимо от данных: даже упорядоченный массив сортируется так же долго. Обменов не больше n − 1 (здесь 3). Это O(n²) сравнений и O(n) обменов — выбор хорош, когда обмен дорог (большие записи).

07

Сортировка вставками

Так сортируют карты в руке: левая часть массива уже упорядочена; очередной элемент вынимают и сдвигают вправо все бо́льшие его, пока не найдётся место:

void insertion_sort(int a[], int n)
{
    for (int i = 1; i < n; i++) {
        int current = a[i];
        int j = i - 1;
        while (j >= 0 && a[j] > current) {
            a[j + 1] = a[j];                  /* сдвиг вправо */
            j--;
        }
        a[j + 1] = current;
    }
}
ВставляетсяМассив послеСравненийСдвигов
—5 | 2 8 1 9 3
22 5 | 8 1 9 311
82 5 8 | 1 9 310
11 2 5 8 | 9 333
91 2 5 8 9 | 310
31 2 3 5 8 943

Всего 10 сравнений и 7 сдвигов. Число сдвигов равно числу инверсий — пар элементов, стоящих не в том порядке: (5, 2), (5, 1), (5, 3), (2, 1), (8, 1), (8, 3), (9, 3). На упорядоченном массиве инверсий нет: n − 1 сравнений и ни одного сдвига — O(n). На упорядоченном по убыванию — n(n − 1) / 2, O(n²). Вставки быстры на «почти упорядоченных» данных — например, когда к упорядоченному списку добавили несколько записей.

Условие j >= 0 && a[j] > current держится на сокращённом вычислении (раздел 5): при j = -1 обращения к a[-1] не будет. Если переставить части условия, получится выход за границу.

08

Сортировка пузырьком и сравнение сортировок

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

void bubble_sort(int a[], int n)
{
    bool swapped = true;
    for (int pass = 0; pass < n - 1 && swapped; pass++) {
        swapped = false;
        for (int j = 0; j < n - 1 - pass; j++) {
            if (a[j] > a[j + 1]) {
                int t = a[j];
                a[j] = a[j + 1];
                a[j + 1] = t;
                swapped = true;
            }
        }
    }
}

Для 5 2 8 1 9 3: проход 1 → 2 5 1 8 3 9, проход 2 → 2 1 5 3 8 9, проход 3 → 1 2 3 5 8 9, проход 4 — обменов нет, остановка. Сравнений 5 + 4 + 3 + 2 = 14, обменов 7 — снова число инверсий: каждый обмен соседей устраняет ровно одну.

СортировкаСравнений: упорядоченный / случайный / обратныйПеремещенийУстойчива
выборомn²/2 / n²/2 / n²/2≤ n − 1 обменовнет
вставкамиn / ≈ n²/4 / n²/2сдвигов = инверсийда
пузырьком (с флагом)n / ≈ n²/2 / n²/2обменов = инверсийда

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

Все три сортировки — O(n²): для тысячи элементов это около полумиллиона сравнений (доли секунды), для миллиона — 5 · 1011 (десятки минут). Сортировки за O(n log n) — слиянием и быстрая — в разделе 16, там же их сравнение с этими замером времени. В реальных программах вызывают библиотечную qsort (раздел 15).
09

Типовые задачи с массивом

Удалить элемент с индексом k — сдвинуть всё, что правее, на одну позицию влево и уменьшить n:

for (int i = k; i < n - 1; i++) {
    a[i] = a[i + 1];
}
n--;                                   /* 10 20 30 40, k = 1  ->  10 30 40 */

Вставить значение на место k — сдвинуть вправо, начиная с конца (иначе элементы затрут друг друга), если в массиве есть место:

for (int i = n; i > k; i--) {          /* требуется n < MAX_N */
    a[i] = a[i - 1];
}
a[k] = value;
n++;                                   /* 10 30 40, k = 1, value = 20  ->  10 20 30 40 */

Перевернуть массив — менять местами a[i] и a[n − 1 − i] для i от 0 до n / 2 − 1. Если дойти до n − 1, массив перевернётся дважды и вернётся к исходному.

Подсчитать частоты значений из небольшого диапазона — массив счётчиков, где индекс — значение:

int count[6] = {0};                    /* оценки 2..5 — индексы 2..5 */
for (int i = 0; i < n; i++) {
    count[marks[i]]++;                 /* marks[i] проверены: только 2..5 */
}

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

Ключевые выводы раздела

Запомните главное

  • Индексы массива — от 0 до n − 1; выход за границу — неопределённое поведение, C его не проверяет
  • Массив передаётся в функцию адресом: функция может менять элементы; число элементов — отдельный параметр; только чтение — const
  • Линейный поиск — O(n), любой массив; двоичный — O(log n), только упорядоченный
  • Двоичный поиск держится на инварианте «ключ, если есть, в a[low..high]»
  • Выбор, вставки, пузырёк — O(n²); вставки и пузырёк с флагом — O(n) на упорядоченном
  • Число сдвигов вставками и обменов пузырька равно числу инверсий
  • Устойчивая сортировка сохраняет порядок равных; выбор неустойчив
  • Частоты значений небольшого диапазона — массив счётчиков за один проход
Связи раздела. Опирается на раздел 2 — двоичный поиск и число действий; раздел 6 — циклы, вложенные циклы, сокращённое вычисление; раздел 7 — функции и read_int. Нужен для: раздела 9 — двумерные массивы; раздела 10 — массив и указатель; раздела 11 — строка как массив char; раздела 16 — сортировки O(n log n); раздела 19 — хеш-таблица на массиве; раздела 23 — std::vector и std::sort.
Практическая работа №7 и тест по разделу 8 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 7: Функции Все разделы Раздел 9: Двумерные массивы