JavaRush /Курсы /SQL SELF /Введение в нормализацию данных

Введение в нормализацию данных

SQL SELF
25 уровень , 0 лекция
Открыта

Вы когда-нибудь видели таблицу базы данных, которая выглядела бы как склад барахла? В одной ячейке лежит список телефонов, в другой — адреса, написанные одним длинным предложением, в третьей — несколько дат через запятую. Такой "хаос" затрудняет поиск, обновление и управление данными. Но есть и путь к порядку. Имя ему — нормализация данных.

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

Вот какие проблемы решает нормализация данных:

  1. Устранение дублирования данных. Почему нам нужно хранить одну и ту же информацию дважды? Или трижды? Это увеличивает объём базы и приводит к несогласованности данных.
  2. Минимизация аномалий. Знаете, как в нашей жизни бывают аномалии? Например, забыли удалить бывшего коллегу из списка контактов. В базах данных это тоже происходит. Нормализация помогает избегать таких неловких моментов.
  3. Упрощение структуры данных. Чем проще структура, тем легче ей управлять.
  4. Ускорение работы базы данных. Меньше данных — быстрее запросы.

А что, если без нормализации?

Без нормализации данные в базе становятся "липкими" — они постоянно тянут за собой какие-то лишние куски информации.

Представьте таблицу Студенты:

ID Студента Имя Курсы
1 Otto Lin Математика, Физика
2 Anna Song Химия
3 Otto Lin Биология, Химия

Что может пойти не так:

  • Дублирование данных: Otto Lin появляется несколько раз. Почему? Потому что он учится на нескольких курсах.
  • Трудно обновить информацию: если номер телефона студента Otto Lin изменился, то нам придётся искать все записи с ним, чтобы обновить номер.
  • Удаление данных может нарушить целостность: представьте, что Otto решил бросить курсы. Если мы удалим все его строки, то потеряем всю информацию о нём, включая имя.

Когда нормализация может быть избыточной?

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

  1. В аналитических базах данных, где важна скорость выполнения запросов, а не минимизация объёма данных.
  2. Когда структура становится слишком сложной: если ради соблюдения нормальных форм нам приходится работать с десятками таблиц, запросы будут становиться всё более громоздкими.
  3. Для часто используемых агрегатов: если вы постоянно вычисляете одну и ту же сумму, лучше её хранить.

Допустим, у нас есть интернет-магазин. Если пользователи часто ищут общую сумму заказов, можно хранить эту сумму прямо в таблице Заказы, а не пересчитывать каждый раз.

Только вот важно помнить, что денормализация — это компромисс. Она увеличивает шанс на появление ошибок при обновлении данных.

Примеры проблемной структуры и её нормализации

Давайте рассмотрим пример таблицы до нормализации:

ID Заказа Клиент Товары Сумма заказа
1 Otto Lin Телефон, Наушники 20000
2 Anna Song Холодильник 30000
3 Otto Lin Телевизор 40000

Здесь явно видно нарушение:

  • Данные о клиентах повторяются.
  • Товары хранятся списком — это нарушение принципа атомарности данных.

После нормализации

Мы разделим эту таблицу на три: Таблица Клиенты

ID Клиента Имя
1 Otto Lin
2 Anna Song

Таблица Товары

ID Товара Название Стоимость
1 Телефон 10000
2 Наушники 10000
3 Холодильник 30000
4 Телевизор 40000

Таблица Заказы

ID Заказа ID Клиента ID Товара
1 1 1
1 1 2
2 2 3
3 1 4

Теперь у нас:

  • Нет дублирования данных.
  • Каждый товар находится в отдельной строке.
  • Мы можем легко добавлять новые товары и заказы.

Нормализация — это искусство создания порядка из хаоса. Да, иногда она может казаться слишком строгой и требовательной, но её конечная цель стоит всех усилий. В следующих лекциях мы будем изучать нормальные формы по порядку: сначала 1NF, потом 2NF, и наконец 3NF. Вперёд, к миру упорядоченных данных!

2
Задача
SQL SELF, 25 уровень, 0 лекция
Недоступна
Работа с реальными таблицами
Работа с реальными таблицами
Комментарии (23)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Jh-007 Уровень 27
11 февраля 2026
Опять "правильное решение" не проходит валидацию
Илья Уровень 30
1 февраля 2026
Вообще задача весьма странная. Хорошо, что сначала прочитал комментарии и заглянул в решение. Требуется посчитать количество курсов у студентов, но требуется обработать преподавателей и даты Решение содержит CTE. И не проходит валидацию. Решить можно из без CTE, через подзапрос Не так конечно красиво и лаконично, но можно Но зачем делать обработку преподавателя и дату, если они не выводятся, не понятно Для себя, конечно, можно поиграться и через регулярку обработать дату и через case преподавателя. Для принятия валидатором достаточно сделать lower, trim и через case вставить любую ерунду для преподавателя и даты.
Глеб Уровень 48
15 января 2026
А мы разве CTE — Common Table Expressions проходили чтобы использовать WITH? Раздражают такие задачи...
Юрий Уровень 60
4 ноября 2025
Вот мое решение Но оно не проходит валидатор в части приведения дат, и переделывать мне лень

WITH cleaned_data AS (
    SELECT
        LOWER(TRIM(student_name)) AS n_name,
        LOWER(TRIM(course_title)) AS n_course
         , REGEXP_REPLACE (enrollment_date,'[\./\\]', '-', 'g') as n_date
        , LOWER(TRIM(course_instructor)) as n_instructor
    FROM student_course_data
),
     grouped_students AS (
         SELECT
             CASE
                 WHEN n_name LIKE '%petrov%' THEN 'Ivan Petrov'
                 WHEN n_name LIKE '%orlov%' THEN 'Dmitry Orlov'
                 WHEN n_name LIKE '%volkov%' THEN 'Maria Volkova'
                 ELSE 'Unknown'
                 END AS student_name
             ,n_course
         , case -- нормальная дата
             when n_date ~ '^\d{4}.\d{2}.\d{2}' then to_date(n_date, 'YYYY-MM-DD')
             when n_date ~ '^\d{2}.\d{2}.\d{4}' and substr(n_date, 1,2)::int >12
                 then  to_date(n_date, 'DD-MM-YYYY')
             when n_date ~ '^\d{2}.\d{2}.\d{4}' and substr(n_date, 4,2)::int >12
                 then  to_date(n_date, 'MM-DD-YYYY')
             else NULL
             end dates
             ,CASE -- нормальный препода
                 WHEN n_instructor LIKE '%smirnof%' THEN 'prof. d. smirnoff'
                 WHEN n_instructor LIKE '%brown%' THEN 'dr. alice brown'
                 WHEN n_instructor LIKE '%ivanov%' THEN 'prof. d. ivanov'
                 WHEN n_instructor LIKE '%smith%' THEN 'j. j. smith'
                 ELSE 'UNCNOWN'
                 END AS instructor_CN
         FROM cleaned_data
     )
SELECT
    student_name,
    COUNT(DISTINCT n_course) AS course_count
FROM grouped_students
GROUP BY student_name
ORDER BY student_name;
Юрий Уровень 60
4 ноября 2025
Уважаемые авторы курса, если вы требуете нормализации дат, то будьте добры добавить понимание регулярных выражений

enrollment_date,'[\./\\]', '-', 'g')
А то не учеба а борьба с проверкой чтобы подогнать под условие работающее решение
Евгений Уровень 49 Expert
19 августа 2025
Расскажите, кто как решал задачу? Я решил, но потратил часов 6, и решение настолько огромное, что с трудом верится, что кто-то кроме меня настолько запаривался. Я описал своё решение в комментариях к данному комментарию 🙃 В своём решении я использовал функцию Левенштейна (для группировки строк по сходным значениям) и рекурсивные запросы. Если хотите решить сами, то начните с этого. А если кто-то нашёл другой подход к задаче, то пожалуйста поделитесь 🙌 Кстати, вот эти два условия: 1. У вас в таблице даты записаны в различных форматах. Ваш запрос должен учитывать этот факт. 2. Ваш запрос должен учитывать неточности написания имени преподавателей. Будут проигнорированы в том случае, если вы написали правильное решение.
Евгений Уровень 49 Expert
19 августа 2025
Итак, моё решение 😏 Идея у меня была простая: я решил группировать записи по емэйлам, но сделать это при помощи стандартных операторов = и LIKE невозможно. Можно попробовать зайти со стороны регулярных выражений, но я не пробовал, на мой взгляд это было бы чрезвычайно сложно и малоэффективно. Я решил использовать функцию Левенштейна. Если в общих чертах, то она возвращает число, которое обозначает количество операций, которое надо выполнить, чтобы сделать две строки полностью одинаковыми. Например, у нас есть строки banana1 и ban2ana. Чтобы сделать их одинаковыми, нужно во вторую строку добавить символ 1 и удалить символ 2, а это два действия. Соответственно, расстояние Левенштейна между данными строками будет 2. По умолчанию в Postgres отсутствует функция Левенштейна, но её можно подключить с расширением fuzzystrmatch:

CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch";
Теперь вы можете использовать функцию levenshtein:

-- Данный запрос возвращает 2
SELECT levenshtein('banana1', 'ban2ana');
Есть только одно жирное НО! Валидатор не принимает никаких расширений. Поэтому, стандартную функцию Левенштейна можно использовать только у себя для своей собственной базы данных, а для валидатора мне пришлось полностью реализовывать данную функцию вручную. Далее опишу реализацию данной функции и то, как она используется для решения задачи.
Евгений Уровень 49 Expert
19 августа 2025
Реализация функции Левенштейна описана вот здесь при помощи псевдокода. Если хотите сами переписать её в виде функции для Postgres, то не берите самую первую реализацию, она очень медленная, для моего запроса не подошла (но у меня не самый эффективный запрос). Реализация функции Левенштейна:

CREATE OR REPLACE FUNCTION get_levenshtein_distance(a TEXT, b TEXT)
    RETURNS INTEGER
    LANGUAGE plpgsql
    IMMUTABLE STRICT
AS
$$
DECLARE
    a_length  INT := LENGTH(a);
    b_length  INT := LENGTH(b);
    indicator INT;
    distance  INT[][];
BEGIN
    distance := (SELECT ARRAY_FILL(0, ARRAY [a_length + 1,b_length + 1])::INT[][]);

    FOR i IN 0..a_length
        LOOP
            distance[i + 1][1] := i;
        END LOOP;

    FOR j IN 0..b_length
        LOOP
            distance[1][j + 1] := j;
        END LOOP;

    FOR j IN 1..b_length
        LOOP
            FOR i IN 1..a_length
                LOOP
                    IF SUBSTRING(a FROM i + 1 FOR 1) = SUBSTRING(b FROM j + 1 FOR 1) THEN
                        indicator := 0;
                    ELSE
                        indicator := 1;
                    END IF;

                    distance[i + 1][j + 1] := LEAST(distance[i][j + 1] + 1,
                                                    distance[i + 1][j] + 1,
                                                    distance[i][j] + indicator);
                END LOOP;
        END LOOP;

    RETURN distance[a_length][b_length];
END;
$$;
Евгений Уровень 49 Expert
19 августа 2025
На этом же сайте я нашёл способ ускорения функции:

CREATE OR REPLACE FUNCTION get_levenshtein_distance_faster(a TEXT, b TEXT)
    RETURNS INTEGER
    LANGUAGE plpgsql
    IMMUTABLE STRICT
AS
$$
DECLARE
    common_prefix_length INTEGER := 0;
    common_suffix_length INTEGER := 0;
    a_length             INTEGER := LENGTH(a);
    b_length             INTEGER := LENGTH(b);
    reversed_a           TEXT;
    reversed_b           TEXT;
    tmp                  TEXT;
BEGIN

    FOR i IN 1..LEAST(a_length, b_length)
        LOOP
            IF SUBSTRING(a FROM i FOR 1) = SUBSTRING(b FROM i FOR 1) THEN
                common_prefix_length := common_prefix_length + 1;
            ELSE
                EXIT;
            END IF;
        END LOOP;

    a := SUBSTRING(a FROM common_prefix_length + 1);
    b := SUBSTRING(b FROM common_prefix_length + 1);
    a_length := LENGTH(a);
    b_length := LENGTH(b);

    reversed_a := REVERSE(a);
    reversed_b := REVERSE(b);
    FOR i IN 1..LEAST(a_length, b_length)
        LOOP
            IF SUBSTRING(reversed_a FROM i FOR 1) = SUBSTRING(reversed_b FROM i FOR 1) THEN
                common_suffix_length := common_suffix_length + 1;
            ELSE
                EXIT;
            END IF;
        END LOOP;

    a := SUBSTRING(a FROM 1 FOR a_length - common_suffix_length);
    b := SUBSTRING(b FROM 1 FOR b_length - common_suffix_length);
    a_length := LENGTH(a);
    b_length := LENGTH(b);


    IF a_length = 0 THEN
        RETURN b_length;
    END IF;

    IF b_length = 0 THEN
        RETURN a_length;
    END IF;

    IF b_length > a_length THEN
        tmp := a;
        a := b;
        b := tmp;
    END IF;

    RETURN get_levenshtein_distance(a, b);
END;
$$;
Функция get_levenshtein_distance_faster использует функцию get_levenshtein_distance, сокращая сравниваемые строки и пытаясь выполнить быстрое сравнение без вызова главной функции.
Евгений Уровень 49 Expert
19 августа 2025
Итак, функция Левенштейна реализована. Но в данном виде она бесполезна, потому что нам необходимо определять, являются ли две строки схожими, а расстояние Левенштейна в виде числа нам ничего не даёт. Я реализовал такую функцию для определения схожести двух строк:

CREATE OR REPLACE FUNCTION is_similar(email_1 TEXT, email_2 TEXT)
    RETURNS BOOLEAN
    LANGUAGE plpgsql
    IMMUTABLE STRICT
AS
$$
BEGIN
    RETURN (SELECT (CAST(get_levenshtein_distance_faster(email_1, email_2) AS DECIMAL)
                        / (LENGTH(email_1) + LENGTH(email_2)) / 2 * 100) <= 8);
END;
$$;
Во-первых, почему я создаю plpgsql, а не sql функцию? Потому что иначе ругается валидатор. Что происходит в данной функции? 1. Получаем расстояние Левенштейна между двумя строками и конвертируем его в вещественное число, чтобы при дальнейших операциях не терялась дробная часть. 2. В правой части выражения из длин двух строк мы получаем среднюю длину строки. 3. Расстояние Левенштейна делится на среднюю длину строки и умножается на 100. Так мы получаем коэффициент, который демонстрирует относительную разницу между строками. 4. Проверяем, что вычисленный коэффициент не больше 8.
Евгений Уровень 49 Expert
19 августа 2025
Где я нашёл формулу для вычисления коэффициента и откуда взял число 8? Просто взял из головы 🙂 Мы не можем ориентироваться только на расстояние Левенштейна, потому что, будучи одним и тем же для строк разной длины оно обозначает разные вещи. Например, расстояние Левенштейна между строками abc и ade, как и между строками banana1 и ban2ana составляет 2. Но в первом случае строки отличаются очень сильно, а во втором случае они отличаются слабо. Поэтому, в моей формуле также используется длина строк, чтобы вычислять не абсолютную, а относительную схожесть. Что касается числа 8, то это то число, которое подходило для значений в задаче. То есть, в рамках задачи я группировал записи по емэйлам студентов. Если использовать число больше 8, то в группировку будут попадать чужие емэйлы. А если задать слишком маленькое число, то в группировку не попадут емэйлы, которые должны были попасть.
Евгений Уровень 49 Expert
19 августа 2025
Да, вся функция is_similar, это большая произвольность, работа которой не основана на чётких правилах, а основана скорее на эмпирических данных. Однако, мы пытаемся нормализовать данные, которые, строго говоря, не имеют общих характеристик, а имеют только сходные характеристики, поэтому я считаю данное допущение нормальным. Однако, я бы очень хотел посмотреть ваши решения, если у вас таковые будут 🙂
Евгений Уровень 49 Expert
19 августа 2025
Итак, настало время основного запроса. НЕ ЧИТАЙТЕ ДАННЫЙ КОММЕНТАРИЙ, ЕСЛИ ХОТИТЕ РЕШИТЬ ЗАДАЧУ САМОСТОЯТЕЛЬНО!

WITH RECURSIVE chain AS (SELECT DISTINCT scd.student_email             AS email,
                                         scd.student_name              AS name,
                                         TRIM(LOWER(scd.course_title)) AS counted_courses,
                                         1                             AS courses_count
                         FROM student_course_data AS scd
                         WHERE (scd.student_email, scd.student_name, scd.course_title) IN
                               (SELECT DISTINCT iscd.student_email, iscd.student_name, iscd.course_title
                                FROM student_course_data AS iscd
                                WHERE is_similar(iscd.student_email, scd.student_email)
                                ORDER BY iscd.student_email, iscd.student_name, iscd.course_title
                                LIMIT 1)

                         UNION

                         SELECT c.email                                                       AS email,
                                c.name                                                        AS name,
                                CONCAT(c.counted_courses, '|', TRIM(LOWER(scd.course_title))) AS counted_courses,
                                c.courses_count + 1                                           AS courses_count
                         FROM student_course_data AS scd
                                  INNER JOIN chain AS c ON is_similar(scd.student_email, c.email)
                             AND c.counted_courses NOT LIKE CONCAT('%', TRIM(LOWER(scd.course_title)), '%'))
SELECT name, MAX(courses_count)
FROM chain
GROUP BY email, name;
Евгений Уровень 49 Expert
19 августа 2025
В данном запросе есть три части: 1. Стартовая часть (до UNION). 2. Рекурсивная часть (после UNION). 3. Запрос, который выводит полученные данные в приемлемом виде. Подробнее про рекурсивные запросы рекомендую почитать вот здесь. Основная идея здесь следующая. В стартовой части я получаю уникальных студентов. Как я это делаю? Я нахожу все записи со схожими емэйлами, сортирую и использую самую первую. В рекурсивной части я на основании схожести емэйла присоединяю к стартовым данным новые записи, но при этом избегаю присоединения тех записей, который уже были обработаны (данная логика у меня реализована при помощи поля counted_courses). В целом, это всё. Задача определённо слишком сложная для данного уровня, но попробуйте решить и поделитесь своим решением, мне очень интересно 😉
Slevin Уровень 34
17 сентября 2025
Вот мое решение. Я пошел через создание временной таблицы, куда записал только нужные мне данные - имена студентов и названия курсов чего и просили в задаче. Имена студентов кейсил черещ LIKE '%surname%', а название курсов через убирание лишних пробелов (других ошибок в данных я не обнаружил). Затем сделал выборку из этой таблицы для подсчета количества курсов на каждого студента.

drop table temp_student_course_data;

create temp table temp_student_course_data (
    id serial primary key,
    name varchar(50) not null,
    course varchar(50) not null
);

-- truncate temp_student_course_data restart identity;

insert into temp_student_course_data (name, course)
select distinct
    case
        when LOWER(TRIM(student_name)) like '%volkova%' then 'Maria Volkova'
        when LOWER(TRIM(student_name)) like '%orlov%' then 'Dmitry Orlov'
        when LOWER(TRIM(student_name)) like '%petrov%' then 'Ivan Petrov'
        else student_name
    end as name,
    TRIM(course_title) as course
from student_course_data;

-- select * from temp_student_course_data;

select
    name as name,
    Count(LOWER(course))
from temp_student_course_data
group by
    name
;

Там же можно обнаружить, как я проверял данные и очищал временную таблицу в процессе шлифовки идеи. Валидатор конечно же сначала пристал что я не шлифую время (нахрена мне это надо?), через пару перезапусков решил что время шлифовать и не надо, но теперь просит шлифовать имена преподавателей и по ним отбирать курсы (с чего бы мне этим заниматься, если меня просят подсчитать количество курсов у каждого студента, а не количество преподавателей...)
Slevin Уровень 34
17 сентября 2025
Ага... Оказывается у нас там есть один курс с двумя разными преподавателями, и валидатор ХОЧЕТ считать эти курсы - РАЗНЫМИ курсами! Естественно в условии это вообще не написано. Но после этого валидатор вспомнил, что там еще чето со временем нужно распарсить, а когда я распарсил - он начал жаловаться что я не использую распарсенную дату в дальнейшем запросе (а нахера бы?) В общем задачу писал (и добавил сюда) полный мудак. "Решить" ее не представляется возможным и можно только случайно. Хотя правильный ответ получен легко и просто. Также не подходит ни "правильное" решение от гениев из JavaRush, ни одно из написанных выше (я попробовал, ребят, эта херня всегда на что-то жалуется). P.S. Кстати, хотите лулзов? 😂 Вы можете общаться с валидатором через комментарии к коду. У меня получилось начать спорить на тему, нужна ли проверка парсинга дат, если в основном задании она не указана и для решения - не нужна. И ответы валидатора начали меняться, где он мне отвечал, что раз написано в условии - значит нужно! 🤣🤣🤣 Попробуйте!
Slevin Уровень 34
17 сентября 2025
Внезапно после пятого рестарта Web-Storm, валидатор решил принять мое изначальное решение, не задавая своих тупых вопросов! Я бы расписал подробно, что я думаю об этом курсе - но как показала практика - такие комментарии отсюда удаляются 😉
Евгений Уровень 49 Expert
18 сентября 2025
Да, такое ощущение, что задумывалось решение через CASE. В принципе, дальше по следующим задачам это становится более очевидным 😏 Просто на опыте по курсу по Java, там они могли использовать для тестов данные, которые тебе не показывали, и надо было покрывать все кейсы, но здесь в качестве тестовых данных используются, видимо, те же данные, что используем и мы.
Anonymous #3080756 Уровень 45
17 августа 2025
Каким образом "Правильное решение" учитывает 3 и 4 требования: "3. У вас в таблице даты записаны в различных форматах. Ваш запрос должен учитывать этот факт. 4. Ваш запрос должен учитывать неточности написания имени преподавателей"??? Что-то к середине курса мой восторг по поводу курса уже сильно уменьшился: слишком много неточностей и ошибок (((
JaFFar Уровень 25
14 августа 2025
Вы бы не тянули резину, написали бы подсказку, какой способ решения предусматривает задача.
8 августа 2025
Очень слабая лекция. Не помогает решить задачу
Ra Уровень 35 Student
31 июля 2025
Задачка супер, сижу думаю 👍 насколько нормализация чОткая вещь. По моему, проще написать create temp table; insert into ...; select *. Это как Армстронг на Яве.