Приветствую всех, уважаемые читали! Меня зовут Сергей Семенов, я frontend-разработчик в компании Домклик. Эта статья посвящена созданию интерактивного приложения для визуализации алгоритмов сортировки. Надеюсь, многим из вас тема покажется интересной. Уверен, что вы успешно пройдёте через все этапы разработки и пополните свою копилку пет-проектов.
Основные технологии, которые будем использовать:
JavaScript — один из самых популярных в мире языков программирования и основной язык для разработки веб-приложений.
React — одна из самых популярных JavaScript-библиотек для создания пользовательских интерфейсов.
Redux — библиотека для управления состояниями приложения.
Redux-saga — библиотека для обработки побочных эффектов в Redux.
Создание проекта
Начнём с создания и настройки проекта. Вы можете сделать это самостоятельно или воспользоваться моим шаблоном, специально подготовленным для нашего будущего приложения. Он представляет из себя базовое пустое React-приложение и уже содержит в себе все необходимые модули и настройки для наиболее удобной разработки. Вам нужно будет лишь локально запустить проект, следуя инструкции в readme, и убедиться, что всё работает. Базовая конфигурация для начала работы доступна по ссылке.
Первые шаги
Так как эта статья о визуализации алгоритмов сортировки, то для начала стоит определиться с тем, что именно мы будем сортировать. Очевидно, это должно быть что-то вроде массива чисел. А представить себе такой массив можно, скажем, как столбцы разной высоты, выстроенные в один ряд. Так давайте же начнём с того, что выведем этот ряд на экран.
В директории src
, в которой хранится исходный код приложения, необходимо создать папку modules
. В ней будут располагаться основные компоненты. Первыми из таковых у нас будет компонент <Array />
и его дочерний компонент <Bar />
, являющийся составной частью массива в виде отдельного столбца. Начнём со второго.
Всё, что нам от него потребуется, это чтобы он возвращал столбец, высота которого будет равна значению соответствующего элемента в исходном массиве. То есть прямоугольный блок с заданной высотой и шириной. Ширина всех столбцов должна быть одинаковой, и при этом такой, чтобы весь массив выстраивался в одну линию на экране, не выходя за его пределы. К параметру ширины вернёмся позже, а сейчас необходимо добавить в файлы компонента следующий код:
bar/index.jsx
import styles from './styles';
const Bar = ({ width, height }) => {
const barStyles = {
height: `${ height }px`,
width: `${ width }px`
};
return <div className={ styles.bar } style={ barStyles } />;
}
export default Bar;
Компонент в качестве пропсов принимает ширину и высоту. Будем считать, что это ширина и высота нашего столбца в пикселях. Значит, их необходимо установить в качестве стилей блока. Для этого создаётся объект стилей, в котором свойствам width и height присваиваются соответствующие значения с обязательным указанием единицы измерения.
bar/styles.styl
.bar
background-color #ffb86c
border .1rem solid #44475a
Здесь задаётся цвет столбца. Вы можете выбрать любой понравившийся. И тонкая рамка, которая поможет визуально разделять столбцы между собой. Лучше задать ей толщину не больше 1px и цвет, идентичный цвету заднего фона страницы. В файлах стилей привычным для меня является использование единицы измерения rem, которая задаёт размер относительно размера шрифта элемента <html />. Для удобства я применяю отношение 1/10, т.е. 1rem = 10px.
Отлично, вспомогательный компонент готов. Самое время вернуться к основному массиву. От компонента <Array />
мы будем ожидать выполнение очевидной функции: вывод на экран массива случайных чисел, используя уже подготовленный компонент <Bar />
. Откройте основные файлы компонента и добавьте в них:
array/index.jsx
import { createArray } from 'utils';
import Bar from './bar';
import styles from './styles';
const WIDTH_MULTIPLIER = window.screen.width;
const ARRAY = createArray(30);
const BAR_WIDTH = WIDTH_MULTIPLIER / ARRAY.length;
const Array = () => (
<div className={ styles.array }>
{
ARRAY.map((height, index) => (
<Bar
key={ index }
width={ BAR_WIDTH }
height={ height }
/>
)
)
}
</div>
);
export default Array;
В первой строчке импортируется вспомогательная функция, которая будет отвечает за создание нужного нам массива заданной длины. Добавим её следующим шагом. После импорта дочернего компонента и стилей определяется константа WIDTH_MULTIPLIER со значением ширины экрана в пикселях. Она поможет рассчитать оптимальную ширину для каждого отдельного столбца таким образом, чтобы все они ровно поместились в видимую зону экрана, без каких-либо сдвигов и переносов. Константе ARRAY присваивается значение, вернувшееся из вспомогательной функции. Чтобы отрисовать массив, необходимо пройтись по нему, используя метод map, и для каждого элемента вернуть столбец, передавая в качестве высоты значение элемента, а в качестве ширины - рассчитанное выше значение константы BAR_WIDTH.
array/styles.styl
.array
height 65rem
width 100%
display flex
justify-content center
align-items flex-end
В файле стилей задаются некоторые параметры, ограничивающие область вывода массива и определяющие параметры отображения внутри неё
Теперь вернёмся к вспомогательной функции, с помощью которой создаётся массив. Такого рода функции я предлагаю хранить в отдельной директории. Создайте папку utils
внутри src
. Сейчас необходимо добавить в неё два файла:
createArray.js
const randomIntFromInterval = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
export const createArray = arrayLength => {
const array = [];
for (let i = 0; i < arrayLength; i++) {
array.push(randomIntFromInterval(30, 600));
}
return array;
}
Функция createArray возвращает массив заданной длины, заполненный случайными числами. Для генерации элементов используется вспомогательная функция, возвращающая случайное целое число из указанного диапазона.
index.js
import { createArray } from "./createArray";
export {
createArray
}
Файл импорта/экспорта вспомогательных функций. В дальнейшем при создании новых функций следует экспортировать их через этот файл подобным образом.
Всё почти готово к выводу нашего массива на экран. Необходимо лишь немного поправить главный компонент <App />
. Откройте файл app.jsx
и приведите его к подобному виду:
app.jsx
import Array from './modules/array'
const App = () => {
return <Array />;
};
export default App;
Отлично! Сейчас мы можем наблюдать результат выполнения первых шагов. Откройте браузер и убедитесь сами. По адресу localhost:3000 вы увидите нечто похожее на это:
Теперь можно перезагрузить страницу и убедиться, что на каждую загрузку создаётся и отрисовывается новый массив. Отлично, всё работает!
Базовые элементы управления
Давайте избавимся от необходимости каждый раз перезагружать страницу ради изменения исходного массива. Для этого на следующем этапе создадим элементы управления, через которые будет изменяться сам массив и будущие параметры сортировки.
Начиная с добавления элементов управления и дальше мы будем понемногу усложнять логику нашего приложения. Теперь при взаимодействии с компонентами в одной части приложения мы будет ожидать реакции и изменения состояния в другой его части. Для организации такого взаимодействия как раз и используем redux в связке с redux-saga.
Первым делом добавим кнопку, нажатие на которую будет пересоздавать исходный массив. Таким образом можно будет избавиться от необходимости каждый раз перезагружать и перерисовывать всю страницу ради одного действия.
Перейдите в каталог reducers
и создайте в нём новый файл arraySettings.js
:
arraySettings.js
const initial = {
array: [],
arrayLength: 30
};
export default (state = initial, { type, value }) => {
switch (type) {
case 'ARRAY/SET_ARRAY':
return { ...state, array: value };
case 'ARRAY/SET_LENGTH':
return { ...state, arrayLength: value };
default:
return state;
}
};
В исходном состоянии у нас будет пустой массив и значение длины, которое будет использоваться при создании нового массива. И экшены, отвечающие за изменение массива и значения его длины.
Важно не забыть экспортировать новые редьюсеры через файл index.js
по аналогии с примером:
index.js
import { combineReducers } from 'redux';
import arraySettings from './arraySettings'
export default combineReducers({
arraySettings
});
Теперь добавим первые саги (saga) для обработки будущих действий. Перейдите в каталог sagas
и создайте внутри него новый файл controls.js
. Он будет содержать в себе всё, что связано с действиями через элементы управления:
controls.js
import {
put,
select,
takeLatest
} from 'redux-saga/effects';
import { createArray } from "utils";
function* resetArray() {
const arrayLength = yield select(({ arraySettings }) => arraySettings.arrayLength);
yield put({ type: 'ARRAY/SET_ARRAY', value: createArray(arrayLength) });
}
export default [
takeLatest('CONTROLS/RESET_ARRAY', resetArray)
];
Для начала импортируем необходимые эффекты и вспомогательную функцию создания массива. Затем создаем функцию-генератор. Внутри неё обращаемся к стору и забираем значение длины массива. И в качестве нового массива устанавливаем тот, который возвращает знакомая нам функция createArray. При экспорте указываем, что сага будет запущена при действии CONTROLS/RESET_ARRAY.
И не забудьте добавить только что созданную сагу в экспорт корневой саги в index.js
:
index.js
import { all } from 'redux-saga/effects';
import controls from './controls'
export default function* rootSaga() {
yield all([
...controls
]);
};
Файл импорта/экспорта саг. В дальнейшем также следует экспортировать их через этот файл подобным образом.
Теперь можно вернуться к добавлению компонентов. Перейдите в папку shared внутри директории src. Здесь будут храниться компоненты, которые могут быть переиспользованы в процессе разработки. Добавим сюда основу для будущих компонентов-кнопок:
button/index.jsx
import classnames from 'classnames/bind';
import styles from './styles';
const cn = classnames.bind(styles);
const Button = ({ text, className, onClick }) => {
return (
<button onClick={ onClick } className={cn('button', className) }>{ text }</button>
)
};
export default Button;
Компонент будет выступать в качестве обёртки над дефолтными элементом кнопки. В целом, это сделано для того, чтобы добавить свой визуальный стиль стандартным кнопкам.
button/styles.styl
@import '~constants'
.button
height 4rem
padding 0 1.6rem
font-weight 600
background-color #fff
border .1rem solid $cyan
border-radius .5rem
cursor pointer
&:hover
background-color $light-grey
&:active
color #fff
background-color $cyan
Некоторые переиспользуемые стили, например различные цвета, у меня вынесены в отдельный файл с константами для большего удобства
Добавьте экспорт через файл index.js
:
index.js
import Button from './button';
export {
Button
};
Файл импорта/экспорта переиспользуемых компонентов. В дальнейшем также следует экспортировать их через этот файл подобным образом.
Следующим шагом надо создать компонент <Controls />
, который будет объединять в себе все элементы управления, и <ResetButton />
— кнопку, отвечающую за сброс и пересоздание массива. Начнём со второго.
На одном уровне с папкой array
создайте новую папку controls
. Добавьте в неё файлы для исходного кода и стилей компонента и оставьте пока что их пустыми. Внутри controls
создайте новую папку resetButton
. Добавьте в исходный файл компонента этот код:
resetButton/index.jsx
import { useDispatch } from "react-redux";
import { Button } from 'shared';
const resetArray = () => ({ type: 'CONTROLS/RESET_ARRAY' });
const ResetButton = () => {
const dispatch = useDispatch();
const onClick = () => dispatch(resetArray());
return (
<Button
text="Пересоздать массив"
disabled={ false }
onClick={ onClick }
/>
);
};
export default ResetButton;
Первым делом создаём генератор экшена CONTROLS/RESET_ARRAY. Далее внутри компонента определяем колбэк, который будет выполняться при клике на кнопку и в свою очередь диспатчить экшн. Получается, что в качестве реакции на клик по кнопке мы ожидаем исполнение добавленной ранее саги, которая будет пересоздавать наш массив.
Теперь вернёмся к компоненту, который выступает в качестве обёртки наших элементов управления. Перейдите на уровень выше, в каталог controls
, и в подготовленные ранее файлы добавьте следующее:
controls/index.jsx
import ResetButton from "./resetButton";
import styles from './styles';
const Controls = () => {
return (
<div className={ styles.controls }>
<div className={ styles.controls__buttons }>
<ResetButton />
</div>
</div>
);
};
export default Controls;
Вероятно, вам может показаться странным, почему разметка возвращается именно в таком виде. Будем считать, что это задел на будущее.
controls/styles.styl
.controls
width fit-content
display flex
flex-direction column
justify-content center
&__buttons
margin-bottom 1.2rem
display flex
Стили всего блока элементов управления и отдельно для контейнера кнопок
Отлично! Можно сказать, что на этом создание кнопки сброса завершено. Сейчас необходимо обновить код основного компонента <App />
и добавить в него наш новый компонент <Controls />
. Также потребуется немного скорректировать <Array />
, чтобы он реагировал на изменение состояния в сторе:
array/index.jsx
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import Bar from './bar';
import styles from './styles';
const WIDTH_MULTIPLIER = window.screen.width;
const resetArray = () => ({ type: 'CONTROLS/RESET_ARRAY' });
const Array = () => {
const array = useSelector(({ arraySettings }) => arraySettings.array);
const dispatch = useDispatch();
const createArray = () => dispatch(resetArray());
useEffect(() => {
createArray()
}, []);
const barWidth = WIDTH_MULTIPLIER / array.length || 0;
return (
<div className={ styles.array }>
{
array.map((height, index) => (
<Bar
key={ index }
width={ barWidth }
height={ height }
/>
)
)
}
</div>
);
}
export default Array;
Необходимо удалить константы ARRAY и BAR_WIDTH, а также импорт вспомогательной функции. Значение массива теперь будем забирать из стора, и перерисовывать компонент всякий раз, когда это состояние будет изменяться. Также необходимо добавить генератор экшена CONTROLS/RESET_ARRAY и отправлять действие единожды при маунте компонента, чтобы создать исходный массив.
app.jsx
import Array from './modules/array';
import Controls from './modules/controls';
const App = () => {
return (
<>
<Controls />
<Array />
</>
);
};
export default App;
После успешного выполнения всех предыдущих шагов вы можете открыть страницу и убедиться, что в области экрана под нашим массивом появилась новая кнопка «Пересоздать массив». Теперь она будет отвечать за сброс и создание нового массива, благодаря чему нет необходимости перезагружать страницу для обновления состояния.
Сортировка
Подготовительный этап завершён. Кажется, мы добавили всё необходимое для того, чтобы перейти к разработке основной части нашего приложения. В рамках этой статьи я предлагаю рассмотреть несколько алгоритмов сортировки для их визуализации. Возьмём как самые простые пузырьковую сортировку, сортировку перемешиванием, а также те, что будут выглядеть немного интереснее: быструю сортировку и сортировку слиянием.
Считаю, что начать следует с одного из самых простых для понимания алгоритмов: пузырьковой сортировки. На его примере мы разберём основную концепцию, которая будет применяться для визуализации.
Первым делом необходимо добавить новый редьюсер для управления состоянием процесса сортировки. В папке reducers
создайте новый файл comparison.js
и добавьте в него следующее:
comparison.js
const initial = {
inProgress: false,
sortingSpeed: 1,
activeElements: [],
sortedElements: [],
auxiliaryElements: []
};
export default (state = initial, { type, value }) => {
switch (type) {
case 'COMPARISON/RESET':
return { ...initial, sortingSpeed: state.sortingSpeed };
case 'COMPARISON/TOGGLE_SORT':
return { ...state, inProgress: value };
case 'COMPARISON/SET_SORTING_SPEED':
return { ...state, sortingSpeed: value };
case 'COMPARISON/SET_ACTIVE_ELEMENTS':
return { ...state, activeElements: value };
case 'COMPARISON/SET_AUXILIARY_ELEMENTS':
return { ...state, auxiliaryElements: value };
case 'COMPARISON/SET_SORTED_ELEMENTS':
return { ...state, sortedElements: value };
default:
return state;
}
};
В исходном состоянии храним флаг, который говорит нам, активна ли в данный момент анимация процесса сортировки. Параметр скорости сортировки, который будет регулируемым, но к этому мы вернёмся позже. И три отдельных массива, того, чтобы выделять часть элементов массива среди остальных. Первый - для элементов, над которыми совершается действие в данный момент. Второй - для уже отсортированных элементов. Третий - для вспомогательных элементов, которые могут потребоваться для некоторых методов сортировки.
Добавьте экспорт нового редьюсера в index.js
.
Теперь добавим новые саги, которые будут объединять в себе основную логику методов сортировки. Для удобства создайте внутри директории sagas
папку sorting
. Для каждого нового алгоритма сортировки будем добавлять сюда соответствующие файлы с сагами. Создайте здесь два файла: sorting.js
и bubbleSort.js
. Первый будет содержать в себе некоторые вспомогательные функции, которые будут вызываться во время выполнения алгоритма. Например, в процессе сортировки нам, конечно, необходимо изменять состояние исходного массива путём перестановки элементов. Также для имитации и восприятия анимации сортировки необходимо добавлять паузы в определённые моменты выполнения алгоритма. И две функции, обозначающие запуск сортировки и успешное её завершение. Давайте преобразуем в код всё вышеперечисленное:
sorting.js
import { delay, put, select } from "redux-saga/effects";
// перестановка элементов в исходном массиве
export function* setNewParams(params) {
const array = yield select(({ arraySettings }) => arraySettings.array);
for (let index in params) {
array[index] = params[index];
}
yield put({ type: 'ARRAY/SET_ARRAY', value: array });
}
// имитация паузы в процессе визуализации для восприятия анимации
export function* setPause(multiplier = 100) {
const sortingSpeed = yield select(({ comparison }) => comparison.sortingSpeed);
// длительность задержки зависит от входного параметра и скорости сортировки
yield delay(multiplier / sortingSpeed);
}
export function* startSorting() {
// при запуске процесса сбрасывается активное состояние
yield put({ type: 'COMPARISON/RESET' });
yield put({ type: 'COMPARISON/TOGGLE_SORT', value: true });
}
export function* afterSuccessSorting() {
const sortedArrayLength = yield select(({ arraySettings }) => arraySettings.arrayLength);
yield put({type: 'COMPARISON/RESET'});
// цикл по всем элементам массива
for(let length = 1; length <= sortedArrayLength; length++) {
// все элементы до текущего индекса помечаются как отсортированные
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: Array.from(Array(length).keys()) });
// минимальная пауза для анимации
yield setPause(1);
}
}
Перед реализацией основных алгоритмов стоит учесть, что идея заключается в том, чтобы во время выполнения алгоритма всегда понимать, над какими элементами в массиве производится та или иная интересующая нас операция (сравнение, перестановка, выбор опорного элемента) в данный момент времени. Исходя из этого не всегда будет возможность использовать наиболее удобную, привычную или максимально упрощённую реализацию алгоритмов. Иногда придётся описывать их подробнее. Например, сложности могут возникнуть при добавлении сортировки слиянием или быстрой сортировки. Эти методы рассмотрим немного позже.
С методом сортировки пузырьком, с которого мы начинаем, дело обстоит немного проще. Сейчас можно воспользоваться самым базовым способом реализации алгоритма, лишь немного дополнив его. Я добавил комментарии с кратким пояснением к основным моментам:
bubbleSort.js
import {
select,
put,
race,
take,
takeLatest
} from 'redux-saga/effects';
import { startSorting, setNewParams, setPause } from "./sorting";
// множитель скорости сортировки
const SPEED_MULTIPLIER = 80;
// функция-помощник запускает процесс сортировки
// и следит за результатом его выполнения
function* bubbleSortHelper() {
const { array, arrayLength } = yield select(({ arraySettings }) => ({
array: arraySettings.array,
arrayLength: arraySettings.arrayLength
}));
yield startSorting();
// гонка для возможности прерывания процесса сортировки
const { success } = yield race({
success: bubbleSort(array, arrayLength),
canceled: take('COMPARISON/RESET')
});
// завершаем при успешном выполнении
if(success) {
yield put({ type: 'COMPARISON/TOGGLE_SORT', value: false });
}
}
// основная функция метода сортировки
function* bubbleSort(array, arrayLength) {
// массив для индексов отсортированных элементов
const completedElements = [];
for(let step = 0; step < arrayLength - 1; step++) {
for(let compareIndex = 0; compareIndex < arrayLength - 1 - step; compareIndex++) {
// в качестве активных выделяем индексы элементов,
// которые сравниваются между собой в данный момент
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [compareIndex, compareIndex + 1] });
// добавляем задержку для восприятия анимации
yield setPause(SPEED_MULTIPLIER);
const left = array[compareIndex];
const right = array[compareIndex + 1];
if(left > right) {
const params = {
[compareIndex]: right,
[compareIndex + 1]: left
};
array[compareIndex] = right;
array[compareIndex + 1] = left;
// изменяем состояние массива в хранилище путём перестановки элементов
yield setNewParams(params);
// добавляем задержку для восприятия анимации
yield setPause(SPEED_MULTIPLIER);
}
}
// в конце цикла сравнений добавляем новый элемент в список
completedElements.push(arrayLength - 1 - step);
// добавляем отсортированные элементы в хранилище
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: completedElements });
}
// в конце всего цикла добавляем нулевой элемент к отсортированным
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: [0, ...completedElements] });
// возвращаем true чтобы зафиксировать успешное завершение всего цикла
return true
}
// запускаем bubbleSortHelper при отправленном действии SORTING/BUBBLE_SORT
export default [
takeLatest('SORTING/BUBBLE_SORT', bubbleSortHelper),
];
Не забудьте добавить экспорт в index.js
.
Внесите небольшое изменение в функцию resetArray в файле controls.js
:
controls.js
function* resetArray() {
const arrayLength = yield select(({ arraySettings }) => arraySettings.arrayLength);
yield all([
put({ type: 'COMPARISON/RESET' }),
put({ type: 'ARRAY/SET_ARRAY', value: createArray(arrayLength) })
]);
}
Это позволит прерывать активную анимацию при клике на кнопку пересоздания массива
Сейчас, когда добавлены функции, выполняющие основную работу нашего приложения, необходимо вернуться и доработать компоненты, отвечающие за отображение массива на экране.
Так как было решено выделять различными цветами основные элементы, над которыми в данный момент совершается определённая операция, то следует добавить логику выбора цвета для каждого отдельного столбца. Доработайте компоненты <Array />
и <Bar />
и приведите их к подобному виду:
array/index.jsx
import { useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import Bar from './bar';
import styles from './styles';
const WIDTH_MULTIPLIER = window.screen.width;
const ACTIVE_COLOR = '#ff5555';
const SORTED_COLOR = '#4ed26c';
const AUXILIARY_COLOR = '#bd93f9';
const DEFAULT_COLOR = '#ffb86c';
const resetArray = () => ({ type: 'CONTROLS/RESET_ARRAY' });
const Array = () => {
const {
activeElements,
auxiliaryElements,
sortedElements
} = useSelector(({ comparison }) => ({
activeElements: comparison.activeElements,
auxiliaryElements: comparison.auxiliaryElements,
sortedElements: comparison.sortedElements
}), shallowEqual);
const array = useSelector(({ arraySettings }) => arraySettings.array);
const dispatch = useDispatch();
const createArray = () => dispatch(resetArray());
useEffect(() => {
createArray()
}, []);
const barWidth = WIDTH_MULTIPLIER / array.length || 0;
return (
<div className={ styles.array }>
{
array.map((height, index) => {
const barColor = sortedElements.includes(index) && SORTED_COLOR
|| activeElements.includes(index) && ACTIVE_COLOR
|| auxiliaryElements.includes(index) && AUXILIARY_COLOR
|| DEFAULT_COLOR;
return (
<Bar
key={ index }
width={ barWidth }
height={ height }
color={ barColor }
/>
)
}
)
}
</div>
);
}
export default Array;
bar/index.jsx
import styles from './styles';
const Bar = ({ width, height, color }) => {
const barStyles = {
height: `${ height }px`,
width: `${ width }px`,
backgroundColor: color
};
return <div className={ styles.bar } style={ barStyles } />;
}
export default Bar;
Готово! Теперь в процессе сортировки помимо самой анимации мы будем явно видеть активные, вспомогательные и отсортированные элементы, дополнительно выделенные определёнными цветами.
Возвращаемся к элементам управления. Сейчас к ним необходимо добавить кнопки, с помощью которых можно будет запускать сортировку. Для этой цели будем переиспользовать наш общий компонент <Button />
. Перейдите в каталог controls
и создайте в нём новую папку sortButtons
. Подготовьте два пустых файла для кода компонента и стилей. Компонент <SortButtons />
будет объединять в себе все будущие кнопки запуска соответствующих методов сортировки. Добавьте в пустые файлы этот код:
sortButtons/index.jsx
import { useDispatch, useSelector } from "react-redux";
import { Button } from "shared";
import styles from './styles'
const bubbleSort = () => ({ type: 'SORTING/BUBBLE_SORT' });
const SortButtons = () => {
const dispatch = useDispatch();
const onBubbleSort = () => dispatch(bubbleSort());
return(
<div className={ styles.buttons }>
<Button
text="Пузырьком"
onClick={ onBubbleSort }
/>
</div>
);
};
export default SortButtons;
Для начала создаём генератор экшена SORTING/BUBBLE_SORT. Далее внутри компонента определяем колбэк, который будет выполняться при клике на кнопку и в свою очередь диспатчить экшн. Получается, что в качестве реакции на клик по кнопке мы ожидаем исполнение добавленной ранее саги, которая будет запускать процесс сортировки.
sortButtons/styles.styl
.buttons
margin-left 3.2rem
Осталось лишь поместить только что созданный компонент внутрь <Controls />
прямо после кнопки сброса:
controls/index.jsx
// ...
<div className={ styles.controls__buttons }>
<ResetButton />
<SortButtons />
</div>
// ...
Теперь рядом со сбросом появилась кнопка запуска сортировки. Если вы видите нечто похожее, то это говорит о том, что все предыдущие шаги выполнены успешно:
Вы можете нажать кнопку и убедиться в том, что анимация действительно запускается. Алгоритм делает в точности то, что от него и ожидалось: циклически проходит по всем элементам массива и попарно сравнивает между собой соседние, каждый раз сдвигая наибольший из них на один шаг к правой границе. Как мне кажется, наблюдать за этим процессом довольно интересно!
Изменение исходных параметров длины массива и скорости сортировки
На этом этапе, когда основная часть приложения уже готова, предлагаю ненадолго вернуться к элементам управления. Ведь было бы намного веселее, если бы помимо визуализации была возможность изменять, например, длину нашего массива или управлять скоростью сортировки прямо во время процесса. Сейчас такой возможности нет, ведь для этого необходимо прямо в коде изменять исходные параметры и перезагружать страницу для их применения. Поэтому я предлагаю вывести на экран дополнительные интерактивные элементы для изменения длины массива и скорости сортировки. Оба элемента будут представлены в виде слайдера. Значит, начать следует с добавления нового компонента <RangeInput />
в shared
для более удобного использования слайдера по молчанию <input type=”range” />
. Перейдите в каталог переиспользуемых компонентов и создайте в нём новую папку rangeInput
. Добавьте следующий код в исходные файлы:
rangeInput/index.jsx
import classnames from "classnames/bind";
import styles from './styles';
const cn = classnames.bind(styles);
const RangeInput = ({
value,
title,
min,
max,
disabled,
wrapperClassName,
onValueChange
}) => {
const onInput = (value) => {
if (disabled) {
return
}
onValueChange(value);
};
return(
<div className={ cn('wrapper', wrapperClassName) }>
{ title && <div className={ styles.title }>{ title }</div> }
<input
type='range'
value={ value }
min={ min }
max={ max }
onInput={ (event) => onInput(Number(event.target.value)) }
className={ cn('range', { disabled }) }
/>
</div>
);
};
export default RangeInput;
Компонент принимает вполне стандартные пропсы: значение инпута, заголовок, границы диапазона допустимого значения, дополнительный класс для обёртки, флаг, обозначающий, что элемент заблокирован и взаимодействие с ним невозможно и колбэк для реакции на изменение значения
rangeInput/styles.styl
@import '~constants'
$thumb()
box-shadow .1rem .1rem .1rem $grey
border .1rem solid $grey
height 1.6rem
width 1.4rem
border-radius .5rem
background $cyan
cursor pointer
-webkit-appearance none
$track()
width 100%
height 1rem
.wrapper
width 24.8rem
display flex
flex-direction column
.title
padding-left .4rem
margin-bottom .4rem
font-size 1.2rem
color #8c8c8c
.range
margin 0
height 1.2rem
-webkit-appearance none
width 100%
border .1rem solid $grey
border-radius .5rem
&:focus
outline none
&::-webkit-slider-thumb
$thumb()
&::-moz-range-track
$track()
&::-moz-range-thumb
$thumb()
&::-ms-thumb
$thumb()
&::-ms-track
$track()
.disabled
background-color #edeeee
border-color #edeeee
color #a7a8a9
cursor not-allowed
Стилизуем компонент. Добавляем класс, отвечающий за внешний вид в заблокированном состоянии.
Не забудьте про импорт и экспорт нового компонента в index.js
. Теперь, когда наш слайдер готов, мы можем использовать его для создания новых элементов управления. Но перед этим стоит добавить ещё одну дополнительных функцию. Модифицируйте файл с сагами controls.js
следующим образом:
controls.js
// ...
function* setArrayLength({ value }) {
yield put({ type: 'ARRAY/SET_LENGTH', value })
yield resetArray();
}
export default [
takeLatest('CONTROLS/RESET_ARRAY', resetArray),
takeLatest('CONTROLS/SET_ARRAY_LENGTH', setArrayLength)
];
При изменения значения длины массива в хранилище нам необходимо пересоздать исходный массив, учитывая уже новую длину. Сага setArrayLength будет запущена при отправке действий CONTROLS/SET_ARRAY_LENGTH.
Затем перейдите в директорию controls
с основными компонентами и создайте в ней папку lengthInput
. Начнём с добавления компонента <LengthInput />
для изменения длины массива. Создайте внутри нового каталога исходные файлы:
lengthInput/index.jsx
import { useDispatch, useSelector } from "react-redux";
import { RangeInput } from 'shared';
import styles from './styles';
const setArrayLength = value => ({ type: 'CONTROLS/SET_ARRAY_LENGTH', value });
const LengthInput = () => {
const arrayLength = useSelector(({ arraySettings }) => arraySettings.arrayLength);
const inProgress = useSelector(({ comparison }) => comparison.inProgress);
const dispatch = useDispatch();
const onChangeLength = length => dispatch(setArrayLength(length));
return(
<RangeInput
value={ arrayLength }
title="Длина массива"
min={ 15 }
max={ 180 }
disabled={ inProgress }
wrapperClassName={ styles.range }
onValueChange={ onChangeLength }
/>
);
};
export default LengthInput;
Добавляем генератор экшена CONTROLS/SET_ARRAY_LENGTH. Далее внутри компонента определяем колбэк, который будет выполняться при изменении значения инпута и в свою очередь диспатчить экшн. Текущее значение длины селектим из стора. Также необходимо селектить флаг inProgress, по которому будем определять, доступно ли взаимодействие с компонентов. Во время выполнения процесса сортировки возможность изменения длины массива будет заблокирована.
lengthInput/styles.styl
.range
width 38rem
margin-right 3rem
Для изменения скорости сортировки добавим компонент <SortingSpeed />
. На одном уровне с lengthInput
внутри controls
создайте папку sortingSpeed
с исходным файлом:
sortingSpeed/index.jsx
import { useDispatch, useSelector } from "react-redux";
import { RangeInput } from "shared";
const changeSpeed = value => ({ type: 'COMPARISON/SET_SORTING_SPEED', value });
const SortingSpeed = () => {
const sortingSpeed = useSelector(({ comparison }) => comparison.sortingSpeed);
const dispatch = useDispatch();
const onChangeSpeed = multiplier => dispatch(changeSpeed(multiplier));
return(
<RangeInput
value={ sortingSpeed }
title="Скорость сортировки"
min={ 1 }
max={ 20 }
onValueChange={ onChangeSpeed }
/>
);
};
export default SortingSpeed;
Элементы готовы. Чтобы вывести их на экран, необходимо добавить наши слайдеры к уже имеющимся элементам управления. Немного модифицируйте исходный файл и файл стилей компонента <Controls />
:
controls/index.jsx
// ...
<div className={ styles.controls }>
<div className={ styles.controls__buttons }>
<ResetButton />
<SortButtons />
</div>
<div className={ styles.controls__sliders }>
<LengthInput />
<SortingSpeed />
</div>
</div>
// ...
controls/styles.styl
.controls
// ...
&__sliders
display flex
Отлично! У нас появилась возможность регулировать длину массива и скорость сортировки, взаимодействуя с новыми элементами управления. Так мы избавились от необходимости изменять эти параметры непосредственно в коде приложения.
Перед запуском сортировки вы можете увеличить или уменьшить длину массива. А менять скорость можно прямо по ходу выполнения. Теперь, когда добавилось больше интерактивности, управлять процессом стало проще и следить за ним явно интереснее!
Визуализация более интересных алгоритмов
На этом этапе наше приложение уже в полном объёме выполняет свою главную функцию. Если ваш результат совпадает с тем, что представлено в примерах, то можно смело говорить, что все этапы разработки были успешно завершены.
Сейчас я хочу внести лишь небольшое дополнение и показать примеры реализации более интересных алгоритмов: сортировки перемешиванием, слиянием и быстрой сортировки. Всё это реализуется аналогично тому, как делалось ранее: сначала создаёте файл с рабочими сагами, в который добавляете основную логику, а затем в компоненты добавляете соответствующую кнопку запуска сортировки.
Сортировка перемешиванием. Модификация метода пузырьковой сортировки. В ней границы части массива, по которому проходит цикл и в которой происходят перестановки, сужаются путём установки их в месте последнего обмена. К тому же при каждой итерации массив просматривается в две стороны: слева направо, сдвигая к правой границе наибольший элемент, и справа налево, сдвигая к левой границе наименьший:
sorting/shakerSort.js
import {
select,
put,
race,
take,
takeLatest
} from 'redux-saga/effects';
import { startSorting, setNewParams, setPause } from "./sorting";
// множитель скорости сортировки
const SPEED_MULTIPLIER = 60;
// функция-помощник запускает процесс сортировки
// и следит за результатом его выполнения
function* shakerSortHelper() {
const { array, arrayLength } = yield select(({ arraySettings }) => ({
array: arraySettings.array,
arrayLength: arraySettings.arrayLength
}));
yield startSorting();
// гонка для возможности прерывания процесса сортировки
const { success } = yield race({
success: shakerSort(array, arrayLength),
canceled: take('COMPARISON/RESET')
});
// при успешном выполнении завершаем процесс
if(success) {
yield put({ type: 'COMPARISON/TOGGLE_SORT', value: false });
}
}
// вспомогательная функция для свапа элементов
function* swap(array, i, j) {
const params = {
[i]: array[j],
[j]: array[i]
};
let temp = array[i];
array[i] = array[j];
array[j] = temp;
yield setNewParams(params);
yield setPause(SPEED_MULTIPLIER);
}
// основная функция метода сортировки
function* shakerSort(array, arrayLength) {
// массив для индексов отсортированных элементов
let completedElements = [];
// индексы первого и последнего элемента массива
let left = 0;
let right = arrayLength - 1;
// индексы вспомогательных элементов, нужны для оптимизации
// сначала это крайние элементы массива, а затем - элементы,
// которые последними за время одной итерации менялись местами со своим соседом
let leftSwap = 0;
let rightSwap = arrayLength - 1;
while (left < right) {
for (let i = left; i < right; i++) {
// в качестве активных выделяем индексы элементов,
// которые сравниваются между собой в данный момент
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [i, i + 1] });
yield setPause(SPEED_MULTIPLIER);
if (array[i] > array[i + 1]) {
yield swap(array, i, i + 1);
// запоминаем индекс элемента, который только что помеялся местами с соседом справа
rightSwap = i;
}
}
// если на предыдущей итерации элементы не менялись местами, то массив отсортирован
if(rightSwap === right) {
completedElements = Array.from(Array(arrayLength).keys());
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: completedElements });
return true;
}
// начиная с нашего вспомогательного элемента будем двигаться к левому краю массива
// все элементы правее него считаются отсортированными
right = rightSwap;
// все элементы до левого опорного и после правого опорного считаются отсортированными
completedElements = [...Array.from(Array(left).keys()), ...Array.from(Array(arrayLength).keys()).splice(right + 1)];
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: completedElements });
// повторяем процедуру, двигаясь к левому краю
for (let i = right; i > left; i--) {
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [i - 1, i] });
yield setPause(SPEED_MULTIPLIER);
if (array[i] < array[i - 1]) {
yield swap(array, i, i - 1);
// запоминаем индекс элемента, который только что помеялся местами с соседом слева
leftSwap = i;
}
}
// начиная с нашего вспомогательного элемента будем двигаться к правому краю массива
// все элементы левее него считаются отсортированными
left = leftSwap;
// все элементы до левого опорного и после правого опорного считаются отсортированными
completedElements = [...Array.from(Array(left).keys()), ...Array.from(Array(arrayLength).keys()).splice(right + 1)];
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: completedElements });
}
// в конце цикла весь массив отсортирован
yield put({ type: 'COMPARISON/SET_SORTED_ELEMENTS', value: Array.from(Array(arrayLength).keys()) });
return true;
}
// запускаем shakerSortHelper при отправленном действии SORTING/SHAKER_SORT
export default [
takeLatest('SORTING/SHAKER_SORT', shakerSortHelper),
];
Сортировка слиянием. Думаю, что она не нуждается в представлении. Метод подразумевает разбиение массива поровну, пока из одного массива не получится несколько мелких, в которых будет легко упорядочить элементы. Затем следует обратное слияние, при котором за проход цикла выбирается по одному элементу от каждого массива и сравниваются между собой. Наибольший элемент отправляется в результирующий массив, а наименьший будет актуальным для сравнения на следующем шаге.
Для отслеживания нужных индексов активных элементов во время сортировки был выбран нестандартный способ реализации алгоритма:
sorting/mergeSort.js
import {
select,
put,
race,
take,
takeLatest
} from 'redux-saga/effects';
import {
startSorting,
setNewParams,
setPause,
afterSuccessSorting
} from "./sorting";
// множитель скорости сортировки
const SPEED_MULTIPLIER = 100;
// функция-помощник запускает процесс сортировки
// и следит за результатом его выполнения
function* mergeSortHelper() {
const array = yield select(({ arraySettings }) => arraySettings.array);
yield startSorting();
// для главного и вспомогательного массива создаём две копии исходного
const mainArray = array.slice();
const auxiliaryArray = array.slice();
// гонка для возможности прерывания процесса сортировки
const { success } = yield race({
success: mergeSort(mainArray, auxiliaryArray, 0, array.length - 1),
canceled: take('COMPARISON/RESET')
});
// при успешном выполнении запускаем соответствующую сагу
if(success) {
yield afterSuccessSorting();
}
}
function* mergeSort(
mainArray,
auxiliaryArray,
startIdx,
endIdx
) {
if (startIdx === endIdx) return;
// находим середину массива
const middleIdx = Math.floor((startIdx + endIdx) / 2);
// рекурсивный вызов для левой части
yield mergeSort(auxiliaryArray, mainArray, startIdx, middleIdx);
// рекурсивный вызов для правой части
yield mergeSort(auxiliaryArray, mainArray, middleIdx + 1, endIdx);
// выполняем сортировку отдельной части массива
yield sort(mainArray, auxiliaryArray, startIdx, middleIdx, endIdx);
return true;
}
function* sort(
mainArray,
auxiliaryArray,
startIdx,
middleIdx,
endIdx
) {
// индекс в главном массиве
let k = startIdx;
// начало левой части
let i = startIdx;
// начало правой части
let j = middleIdx + 1;
// идём одновременно по левой и правой частям массива
while (i <= middleIdx && j <= endIdx) {
// в качестве активных выделяем индексы элементов,
// которые сравниваются между собой в данный момент
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [i,j] });
yield setPause(SPEED_MULTIPLIER);
// сравниваем и устанавливаем соответствующие элементы
// после установки элементов увеличиваем индексы на единицу
if (auxiliaryArray[i] <= auxiliaryArray[j]) {
yield setNewParams({ [k]: auxiliaryArray[i] });
mainArray[k++] = auxiliaryArray[i++];
} else {
yield setNewParams({ [k]: auxiliaryArray[j] });
mainArray[k++] = auxiliaryArray[j++];
}
}
// если в левой или правой частях массива остались незатронутые элементы
// устанавливаем их на свои места в главном массиве
while (i <= middleIdx) {
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [i] });
yield setPause(SPEED_MULTIPLIER);
yield setNewParams({ [k]: auxiliaryArray[i] });
mainArray[k++] = auxiliaryArray[i++];
}
while (j <= endIdx) {
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [j] });
yield setPause(SPEED_MULTIPLIER);
yield setNewParams({ [k]: auxiliaryArray[j] });
mainArray[k++] = auxiliaryArray[j++];
}
}
// запускаем mergeSortHelper при отправленном действии SORTING/MERGE_SORT
export default [
takeLatest('SORTING/MERGE_SORT', mergeSortHelper),
];
Быстрая сортировка. Наиболее широко применяемый и один их самых эффективных и универсальных алгоритмов. Основан на подходе «разделяй-и-властвуй». Сначала из массива выбирается опорный элемент, затем запускается процедура разделения, которая перемещает все элементы, меньшие либо равные опорному, влево от него, а все большие него — вправо. Теперь массив состоит из двух подмножеств, для которых рекурсивно запускается та же процедура:
sorting/quickSort.js
import {
select,
put,
race,
take,
takeLatest
} from 'redux-saga/effects';
import {
startSorting,
setNewParams,
setPause,
afterSuccessSorting
} from "./sorting";
// множитель скорости сортировки
const SPEED_MULTIPLIER = 100;
// функция-помощник запускает процесс сортировки
// и следит за результатом его выполнения
function* quickSortHelper() {
const { array, arrayLength } = yield select(({ arraySettings }) => ({
array: arraySettings.array,
arrayLength: arraySettings.arrayLength
}));
yield startSorting();
// гонка для возможности прерывания процесса сортировки
const { success } = yield race({
success: quickSort(array, 0, arrayLength - 1),
canceled: take('COMPARISON/RESET')
});
// при успешном выполнении запускаем соответствующую сагу
if(success) {
yield afterSuccessSorting();
}
}
// вспомогательная функция для свапа элементов
function* swap(items, firstIndex, secondIndex){
const params = {
[firstIndex]: items[secondIndex],
[secondIndex]: items[firstIndex]
};
let temp = items[firstIndex];
items[firstIndex] = items[secondIndex];
items[secondIndex] = temp;
yield setNewParams(params);
yield setPause(SPEED_MULTIPLIER);
}
// функция разделения массива
function* partition(items, left, right, pivot) {
// указатели на левый и правый конец массива
let i = left;
let j = right;
while (i <= j) {
// слева-направо
// если элемент в левой части меньше опорного, то пропускаем его и сдвигаем указатель
while (items[i] < pivot) {
i++;
}
// справа-налево
// если элемент в правой части больше опорного, то пропускаем его и сдвигаем указатель
while (items[j] > pivot) {
j--;
}
// меняем местами элементы, если условие выполняется
if (i <= j) {
// выделяем индексы элементов в качестве активных
yield put({ type: 'COMPARISON/SET_ACTIVE_ELEMENTS', value: [i,j] });
yield setPause(SPEED_MULTIPLIER);
yield swap(items, i, j);
yield setPause(SPEED_MULTIPLIER);
i++;
j--;
}
}
// возвращаем индекс, по которому массив разделяется на два подмассива
return i;
}
// рекурсивная функция быстрой сортировки
function* quickSort(items, left, right) {
// берём средний элемент массива как опорный
const pivotIndex = Math.floor((right + left) / 2);
const pivot = items[pivotIndex];
// выделяем опорный элемент дополнительным цветом
yield put({ type: 'COMPARISON/SET_AUXILIARY_ELEMENTS', value: [pivotIndex] });
// индекс, по которому массив разделяется на два подмассива
const index = yield partition(items, left, right, pivot);
// рекурсивно запускаем процедуру для левой части
if (left < index - 1) {
yield quickSort(items, left, index - 1);
}
// рекурсивно запускаем процедуру для правой части
if (index < right) {
yield quickSort(items, index, right);
}
return items;
}
// запускаем quickSortHelper при отправленном действии SORTING/QUICK_SORT
export default [
takeLatest('SORTING/QUICK_SORT', quickSortHelper),
];
После реализации новых методов добавьте их к корневой саге в файле index.js
:
index.js
import { all } from 'redux-saga/effects';
import controls from './controls'
import bubbleSort from "./sorting/bubbleSort";
import shakerSort from "./sorting/shakerSort";
import mergeSort from "./sorting/mergeSort";
import quickSort from "./sorting/quickSort";
export default function* rootSaga() {
yield all([
...controls,
...bubbleSort,
...shakerSort,
...mergeSort,
...quickSort
]);
};
Теперь осталось лишь добавить кнопки запуска к элементам управления. Модифицируйте компонент <SortButtons />
следующим образом:
sortButtons/index.jsx
import { useDispatch } from "react-redux";
import { Button } from "shared";
import styles from './styles'
const bubbleSort = () => ({ type: 'SORTING/BUBBLE_SORT' });
const mergeSort = () => ({ type: 'SORTING/MERGE_SORT' });
const shakerSort = () => ({ type: 'SORTING/SHAKER_SORT' });
const quickSort = () => ({ type: 'SORTING/QUICK_SORT' });
const SortButtons = () => {
const dispatch = useDispatch();
const onClick = action => dispatch(action())
return(
<div className={ styles.buttons }>
<Button
text="Пузырьком"
onClick={ () => onClick(bubbleSort) }
/>
<Button
text="Перемешиванием"
onClick={ () => onClick(shakerSort) }
/>
<Button
text="Слиянием"
onClick={ () => onClick(mergeSort) }
/>
<Button
text="Быстрая"
onClick={ () => onClick(quickSort) }
/>
</div>
);
};
export default SortButtons;
Готово! Давайте посмотрим на итоговый результат:
Мы рассмотрели четыре, на мой взгляд, наиболее известных алгоритма сортировки и на их основе разработали интерактивный инструмент, который наглядно демонстрирует процесс их выполнения. Теперь у вас есть возможность попробовать каждый из них.
Помимо того, что разработка оказалась довольно интересной, осмелюсь предположить, что конечный результат в виде этого приложения для кого-то может оказаться полезным, поможет погрузиться в тему алгоритмов сортировки и лучше понять принцип их работы.
Надеюсь, вам понравится играть с этим инструментом визуализации ровно настолько, насколько мне понравилось его создавать. Спасибо за уделённое время!
Полезные ссылки
Ознакомиться с моей собственной реализацией и посмотреть весь исходный код приложения можно в этом репозитории.
Итоговый результат опубликован здесь.
Комментарии (5)
domix32
22.09.2022 20:58-1Как-то некрасиво у вас значения растут. А что мешало сделать их массивом от 1 до N и не пересоздавать их каждый раз, а просто перемешивать?
HellWalk
23.09.2022 12:27Хорошая статья, отдельно спасибо за github и пример проекта, где можно его сразу посмотреть в работе (все бы так делали)
Мне кажется было бы хорошо добавить на сайт отображение двух параметров: длина созданного массива, и в процессе сортировки - сколько операций сделано.
P.S. Ну и конечно же можно добавить другие алгоритмы сортировки, как раз в соседней теме есть большой список :)
3au4onok
23.09.2022 14:44Верстка немного поплыла, на ноутах не видно панель управления. А в целом получилось достаточно симпатично
neword
вот еще пара примеров визуализации: первый, второй
RranAmaru
третий и много-много видео, а еще танцы )