Стандартные типы данных в C++

Двоичная запись числа

В двоичной системе счисления числа записываются с помощью двух символов (0 и 1). Чтобы не путать, в какой системе счисления записано число, его снабжают указателем справа внизу. Например, число в десятичной системе $5_{10}$, в двоичной $101_2$. Иногда двоичное число обозначают префиксом 0b, например 0b101, например такая работает в Python или C++.

В двоичной системе счисления (как и в других системах счисления, кроме десятичной) знаки читаются по одному. Например, число $101_2$ произносится «один ноль один».

Натуральные числа

Натуральное число, записываемое в двоичной системе счисления как $(a_{n-1}a_{n-2}\ldots a_{1}a_{0})_{2}$, имеет значение:

\[(a_{n-1}a_{n-2}\ldots a_{1}a_{0})_{2} = a_0 + 2^1 \cdot a_1 + \cdots + 2^{n - 1}a_{n-1},\]

где:

$n$ — количество цифр (знаков) в числе, $a_k$ — значения k-го разряда.

А как их хранить?

В компьютерах числа хранятся в двоичной записи. Проще всего хранить натуральные числа: нужно лишь запомнить последовательность из нулей и единиц. К сожалению, мы не можем хранить неограниченно большое число, ниже вы увидите таблицу для ограничений типов в C++:

Таблица ограничений беззнаковых целочисленных типов в C++

Тип данных Размер, бит Ограничения в степенях двойки Примерные ограничения
unsigned char 8 [0; $2^{8} - 1$] [0; 255]
unsigned short 16 [0; $2^{16} - 1$] [0; 65 535]
unsigned int 32 [0; $2^{32} - 1$] [0; $4.2 \times 10^{9}$]
unsigned long long 64 [0; $2^{64} - 1$] [0; $1.8 \times 10^{19}$]

Например, если вы напишете код

1
unsigned char x = 11;

То в памяти 11 будет храниться как 00001011.

Отрицательные целые числа

Прежде чем читать дальше, попробуйте сами придумать способ хранения отрицательных чисел в двоичной системе счисления. Найдите плюсы минусы вашего способа.

Первая идея, которая приходит в голову - это зарезервировать один бит под знак (например, старший). Такой подход называется “прямой код”, и при его использовании возникнет сразу две проблемы:

  1. Числа 00000000 и 10000000 обозначают 0 и -0. То есть есть два обозначение одного и того же числа 0.
  2. Другая проблема - это арифметические операции. Оказывается, их очень неудобно реализовывать, если старший бит будет отвечать за знак.

Но есть и несколько плюсов:

  1. Достоинства представления чисел с помощью кода с дополнением до единицы
  2. Простое получение кода отрицательных чисел.
  3. Из-за того, что 0 обозначает +, коды положительных чисел относительно беззнакового кодирования остаются неизменными. Количество положительных чисел равно количеству отрицательных.

Всё же оказывается, что данный подход не применим для архитектуры компьютера, поэтому люди придумали решение, именуемое “дополнительный код”:

Будем хранить “остатки” от деления на $2^8$ (или нужную степень двойки). Таким образом, если мы хотим сохранить число $x$ от $0$ до $127$, то мы будем хранить именно его. Для чисел $x$ из интервала [-128; -1] будем хранить его остаток от деления на 256, что эквивалентно числу 256 + x.

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

По итогу, если вы напишете:

1
char x = -2;

То в x будет записан в памяти как 256 - 2 = 254: 11111110

Но мы породили несколько менее существенных недостатков, с которыми будем жить:

  1. Ряд положительных и отрицательных чисел несимметричен, но это не так важно: с помощью дополнительного кода выполнены гораздо более важные вещи, желаемые от способа представления целых чисел.
  2. В отличие от сложения, числа в дополнительном коде нельзя сравнивать как беззнаковые, или вычитать без расширения разрядности.

Таблица ограничений знаковых целочисленных типов в C++

Тип данных Размер, бит Ограничения в степенях двойки Примерные ограничения
char 8 [$-2^{7}$; $2^{7} - 1$] [$-128$; $127$]
short 16 [$-2^{15}$; $2^{15} - 1$] [$-32 768$; $32 767$]
int 32 [$-2^{31}$; $2^{31} - 1$] [$-2,1 \times 10^{9}$; $2,1 \times 10^{9}$]
long long 64 [$-2^{63}$; $2^{63} - 1$] [$-9 \times 10^{18}$; $9 \times 10^{18}$]

Упражнения на целые числа

Как найти середину отрезка [a, b], если a и b могут быть ЛЮБЫМ числом из диапазона long long (спойлер, проблема в переполнении)?

Решение
1
2
long long a = 1, b = 100;
long long mid = a + (b - a) / 2;

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

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

Логические операции

Есть несколько видов битовых операций: логические или, и, ксор и отрицание. Они выполняют логические операции над каждым битом независимо. На C++ это будет соответственно так:

1
2
3
4
5
6
char a = 0b0011; // 3
char b = 0b0101; // 5
char c = a | b; // 0011 | 0101 = 0111 = 7
char d = a & b; // 0011 & 0101 = 0001 = 1
char e = a ^ b; // 0011 ^ 0101 = 0110 = 6
char g = ~a; // ~00000011 = 11111100 = -4

Кроме того, часто пригождается еще и операции битового сдвига влево и вправо соответственно:

1
2
char h = a << 2; // 11 << 2 = 1100 = 12
char i = b >> 2; // 101 >> 2 = 1

С помощью таких примитивов предлагается решить несколько упражнений.

Упражнения на битовые операции

Как получить $2^n$?

Решение
1
2
int x = 1 << n;
long long y = 1LL << n;

Как убрать последнюю единицу в битовой записи?

Решение
1
2
int x = 228;
int y = x & (x - 1); // x без последней единицы в битовой записи

Как убрать группу из единиц на конце числа? Например, если число в двоичной записи равно 100111100, то после операции число должно стать 100000000.

Подсказка 1

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

Подсказка 2

Сведите произвольную задачу к решению задачи из предыдущего пункта.

Решение

Решение в два этапа:

  1. Заменим группу нулей в конце числа на единицы, а остальные биты не будем трогать
  2. После этого уберем всю группу единиц на конце числа
1
2
3
int x = 0b100111100;
x |= x - 1; // x = 100111111
x &= x + 1; // x = 100000000

Вещественные числа

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

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

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

Число с плавающей запятой состоит из набора отдельных двоичных разрядов, условно разделенных на так называемые знак (англ. sign), порядок (англ. exponent) и мантиссу (англ. mantis). В наиболее распространённом формате (стандарт IEEE 754) число с плавающей запятой представляется в виде набора битов, часть из которых кодирует собой мантиссу числа, другая часть — показатель степени, и ещё один бит используется для указания знака числа (0 — если число положительное, 1 — если число отрицательное). При этом порядок записывается как целое число в коде со сдвигом, а мантисса — в нормализованном виде, своей дробной частью в двоичной системе счисления. Вот пример такого числа из 16 двоичных разрядов:

half precision float schema

На самом деле всё просто. В школьной физике вы часто записывали ответ именно в таком формате, только в 10-й системе счисления. Например, если вам нужно было записать число -742367864235, то вы бы записали примерно $-7.42 \times 10^{11}$. Только в C++ такая запись будет выглядеть как -7.42e11. А в памяти компьютера это будет раделено на несколько компонент и хранится в двоичной системе счисления.

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

$(−1)^S×M×B^E$, где $S$ — знак, $B$ — основание, $E$ — порядок, а $M$ — мантисса. Десятичное число, записываемое как $ReE$, где $R$ — число в полуинтервале $[1;B)$, $E$ — степень, в которой стоит множитель $10$; в нормализированной форме модуль $R$ будет являться мантиссой, а $E$ — порядком, а $S$ будет равно $1$ тогда и только тогда, когда $R$ принимает отрицательное значение.

Например, для числа $2.4e9$: $S = 1, B = 10, M = 2.4, E = 9$.

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

Диапазон значений чисел с плавающей запятой

Диапазон чисел, которые можно записать данным способом, зависит от количества бит, отведённых для представления мантиссы и показателя. Пара значений показателя (когда все разряды нули и когда все разряды единицы) зарезервирована для обеспечения возможности представления специальных чисел. К ним относятся ноль, значения $NaN$ (Not a Number, “не число”, получается как результат операций типа деления нуля на ноль) и $±∞$.

Диапазон значений чисел с плавающей запятой

Название типа переменной в C++ Диапазон значений Бит в мантиссе Бит на переменную
float $-3,4 \times 10^{38}$..$3,4 \times 10^{38}$ 23 32
double $-1,7 \times 10^{308}$..$1,7 \times 10^{308}$ 53 64
long double $-3,4 \times 10^{4932}$..$3,4 \times 10^{4932}$ 65 80

Данная таблица только лишь ПРИМЕРНО указывает границы допустимых значений, без учета возрастающей погрешности с ростом абсолютного значения и существования денормализованных чисел.

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

simplfied distribution

Упражнения на вещественные числа

Как сравнить два числа с плавающей точкой на равенство?

Решение

Сравнивать вещественные числа МОЖНО только с погрешностью. Например, так:

1
2
3
4
5
6
7
double a = 0.1 + 0.2;
double b = 0.3;

const double EPS = 1e-9;
if (abs(a - b) < EPS) {
    cout << "Equal";
}

Есть следующий известный сходящийся ряд: $\frac{\pi}{4} = 1 - \frac{1}{3} + \frac{1}{5} - \frac{1}{7} + \ldots$. Как посчитать $\pi$ с наибольшей точностью, используя данное представление?

Решение

Поскольку точность вычислений с плавающей точкой максимальна в районе нуля, то лучше всего считать сумму ряда в обратном порядке:

1
2
3
4
5
6
7
double pi = 0;
const int N = 1000;

for (int i = N; i >= 1; i--) {
    pi += (i % 2 == 0 ? -1 : 1) / (2.0 * i - 1);
}
pi *= 4;