Uploaded by vgalitsyn8

Лек ПП

advertisement
Федеральное агентство связи
Федеральное государственное бюджетное образовательное
учреждение высшего образования
«Поволжский государственный университет телекоммуникаций и
информатики»
Кафедра «Программное обеспечение и управление в
технических системах»
(наименование кафедры)
«УТВЕРЖДАЮ»
Заведующий кафедрой
ПОУТС
наименование кафедры
Тарасов В.Н.
подпись,
Фамилия И.О.
«
»
2016 г.
УЧЕБНО-МЕТОДИЧЕСКИЙ КОМПЛЕКС
ПО УЧЕБНОЙ ДИСЦИПЛИНЕ
Прикладное программирование
(наименование учебной дисциплины)
Для направлений подготовки бакалавров:
- 27.03.04 «Управление в технических системах»;
09.03.01 «Информатика и вычислительная техника»;
09.03.04 «Программная инженерия»;.
- 09.03.02 «Информационные системы и технологии»
(код и наименование направления (специальности) подготовки)
Обсуждено на заседании кафедры
ПОУТС
«__31_»
08_
протокол №
Самара
2016
2016 г.
1
Федеральное агентство связи
Федеральное государственное бюджетное образовательное
учреждение высшего образования
«Поволжский государственный университет телекоммуникаций и
информатики»
Кафедра «Программное обеспечение и управление в
технических системах»
(наименование кафедры)
«УТВЕРЖДАЮ»
Заведующий кафедрой
ПОУТС
наименование кафедры
Тарасов В.Н.
подпись,
Фамилия И.О.
«
»
2016 г
КОНСПЕКТ ЛЕКЦИЙ
ПО УЧЕБНОЙ ДИСЦИПЛИНЕ
Прикладное программирование
(наименование учебной дисциплины)
Для направлений подготовки бакалавров:
27.03.04 «Управление в технических системах»;
09.03.01 «Информатика и вычислительная техника»;
09.03.04 «Программная инженерия»;.
- 09.03.02 «Информационные системы и технологии»
(код и наименование направления (специальности)
подготовки)
Обсуждено на заседании кафедры
ПОУТС
« 31_» 08_
2016 г.
протокол №
Самара
2016
1
Лекция 1. Арифметика
Классический пример, с которого начинается изучение любого языка:
Пример 1. Hello World.
#include
<iostream> using
namespace std; int
main()
{
cout<<"Hello,
world!"<<endl; return 0;
}
1. Арифметика
Объявление переменной имеет следующий вид:
<тип переменой> <один или несколько идентификаторов переменных через
запятую>; Например, целые переменные n и m типа int можно объявить такой
строкой:
int n, m;
Целочисленные типы данных
Тип
Описание
Размер
int
Целые числа
4 байта
unsigned int
Беззнаковые
целые
4 байта
Диапазо
н
(степен
31 31
-2
ь ..2 1двойки)
0..232-1
Диапазон
long long
unsigned long
long
short int
unsigned short
Длинные целые
Беззнаковые
длинные целые
Короткие целые
Беззнаковые
короткие целые
8 байт
8 байт
-263..2631 63-1
0..2
-2147483647
…
21474836
0
47
…
42949672
-9*10
9518 …
18 18
0 …9*10
18*10
2 байта
2 байта
-215..2151 16-1
0..2
-32767 …
32767
0..65535
Синоним
unsigned
long
short
Вещественную (действительную) переменную x типа double можно
объявить такой строкой:
double x;
Действительные числа можно записывать в виде десятичных дробей как с
фиксированной точкой (например: 3.1415926, 100.001, -10000000.0), так и с плавающей
точкой. В последнем случае число имеет вид <f> e <p>, где <f> — дробное число
(положительноеили отрицательное), называемое мантиссой, а <p> — целое число
(положительное или отрицательное), называемое порядком. Число, записанное таким
образом, равно f×10p . Фактически, порядок означает, на какое число позиций нужно
сдвинуть вправо десятичную точку в записи числа <f>. Если же порядок меньше нуля,
то сдвиг десятичнойточки осуществляется влево.
Примеры записи чисел с
плавающей точкой: 3.14e1 означает 31.4
(3.14*10)
3.14e5 означает 314000 (3.14*105)
3.14e-3 означает 0.00314 (3.14*10-3)
-1e6 означает -1000000 (-1*106)
-1e-6 означает -0.000001 (-1*10-6)
Для представления действительных чисел существует два стандартных типа:
Имя типа Размер Min Max
float
double
4 байта 3.4e-38
3.4e+38
8 байт
1.7e-308
1.7e+308
Как правило, для хранения целых чисел следует использовать тип int, а для
действительных чисел — double.
Арифметические операторы
Арифметическая инструкция — это некоторое выражение, состоящее из
констант, идентификаторов переменных и арифметических операторов, которая
завершается точкой с запятой. Самый главный арифметический оператор — это
оператор присваивания ‘=’, который присваивает одной переменной, идентификатор
которой указывается слева от оператора ‘=’ значение некоторого выражения, которое
стоит справа. Например:
a=2;
b=a+1;
В последней строке встретился оператор сложения ‘+’. Кроме оператора
сложения, есть еще операторы вычитания ‘-’, умножения ‘*’, деления ‘/’ и взятия
остатка от деления целых чисел ‘%’. Если переменные целого типа, то деление ‘/’
будет целочисленным.
Особого внимания заслуживает оператор деления. Если оба его
аргумента имеют целочисленный тип (то есть один из типов, перечисленных
в первой таблице или
целочисленные константы), то этот оператор рассматривается, как оператор деления
целых чисел с остатком. Если же хотя бы один из операторов будет иметь
вещественный тип, то оператор деления выполняется, как оператор деления
десятичных дробей.
В арифметическом выражении сначала выполняются слева направо все
операторы умножения и деления, затем слева направо все операторы сложения и
вычитания, затем справа налево все операторы присваивания. При необходимости
порядок действий можно изменить при помощи скобок.
Ввод-вывод
Для того чтобы вывести на экран значение переменной или текстовой
строки нужно использовать объект ‘cout’ и оператор ‘<<’, который в данном
случае следует называть "Поместить в". cout — объект, связанный со
стандартным выводом программы, как правило, это терминал. Для того чтобы
перейти при печати на новую строку необходимо поместить в cout стандартный
объект endl.
Текстовые строки при выводе на экран необходимо заключать в двойные
кавычки. Если хочется вывести на экран несколько объектов (переменных, текстовых
строк и т.д.), то их нужно разделять между собой оператором ‘<<’.
Для того чтобы считать значение переменной нужно использовать объект ‘cin’
и оператор ‘>>’, который надо называть "Извлечь из". При этом считывание данных
будет производиться со стандартного ввода программы, как правило, являющегося
клавиатурой. Если хочется за одну операцию считать несколько переменных, то их
идентификаторы нужно разделять между собой оператором ‘>>’.
Пример 2. Сумма двух чисел
#include
<iostream> using
namespace std; int
main()
{
int a,b,s;
cout<<"Введите два
числа: "; cin>>a>>b;
s=a+b;
cout<<a<<"+"<<b<<"="<<s<<end
l; return 0;
}
1.1.
Скалярные типы и их диапазоны
Скалярными типами данных называются все типы, принимающие
целочисленные значения: char, short int, int, long int, long long, а также их signed
и unsigned модификации. Для хранения каждого из этих типов в памяти
отводится определенное количество байт.
Узнать размер любого типа данных можно с помощью встроенных
констант. Для этого необходимо подключить библиотеку #include <climits>
(см. Таблицу 1).
Таблица 1. Константы минимальных и максимальных значений типов данных.
na
me
C
HAR_BIT
S
CHAR_MI
N
S
CHAR_M
U
AX
CHAR_M
C
AX
HAR_MIN
C
HAR_MA
S
X
HRT_MIN
S
HRT_MA
U
X
SHRT_MA
IN
X
T_MIN
IN
T_MAX
UI
NT_MAX
L
ONG_MIN
L
ONG_MA
U
X
LONG_M
L
AX
LONG_MI
N
expresses
value*
Number of bits in a char object (byte)
8 or greater
Minimum value for an object of type
signed char
Maximum value for an object of type
signed char
Maximum value for an object of type
unsigned char
Minimum value for an object of type
char
Maximum value for an object of type
char
Minimum value for an object of type
short int
Maximum value for an object of type
short int
Maximum value for an object of type
unsigned short int
Minimum value for an object of type
int
Maximum value for an object of type
int
Maximum value for an object of type
unsigned int
Minimum value for an object of type
long int
Maximum value for an object of type
long int
Maximum value for an object of type
unsigned long int
Minimum value for an object of type
long long int
-127 (-27+1) or less
-128 **
127 (27-1) or greater
255 (28-1) or greater
either SCHAR_MIN or
0
either SCHAR_MAX or
UCHAR_MAX
-32767 (-215+1) or less
32767 (215-1) or greater
65535 (216-1) or greater
-32767 (-215+1) or less
-2147483647 (-231+1)
32767 (215-1) or greater
**
2147483647 (231-1) **
65535 (216-1) or greater
4294967295 (232-1) **
-2147483647 (-231+1)
or less
2147483647 (231-1) or
greater
4294967295 (232-1) or
greater -9223372036854775807
(-263+1) or
less
L
Maximum value for an object of type
9223372036854775807
LONG_M
long long int
63
(2 -1) or
AX
U
Maximum value for an object of
18446744073709551615
greater
LLONG_
type unsigned long long int
(264-1) or
MAX
greater
* Значение зависит от платформы, операционной системы и версии
компилятора.
** Стандартные значения для большинства современных 32-битных платформ.
Для определения выделяемой памяти удобнее использовать фукнцию
sizeof(). Например, sizeof(int) которая возвращает количество байт, необходимых
для хранения переменной типа данных int, а sizeof(A), где A – идентификатор
переменной, возвращает количество байт, необходимой для хранения переменной
A.
Приведем краткую сводку по диапазонам числовых типов данных.
Знаковые типы
Тип
данных
long
РДиапазон значений
а
char
1[-128 … 127]
з
б
short
2[-32768 … 32767]
м
а
б
е
int
4[-2147483648 … 2147483647]
й
ар
б
long long тй
8[-9223372036854775808 …
а
б
9223372036854775807]
т
й
Беззнаковыеат типы
й
а
Тип данныхт
Р Диапазон значений
аз
unsigned char
1 [0 … 255]
ме
ба
unsigned short
2 [0 … 65535]
р
йт
ба
unsigned int
4 [0 … 4294967295]
йт
ба
unsigned long
8 [0 …
а
йт
ба 18446744073709551615]
айт
Вещественные типы
Тип данных
РДиапазон
Точ
азначений
ность
float
4[1.5E-45 …
7-8
з
б
3.4E38]
знаков
double
8[5.0E-324 …
15м
аб1.7E308]
16 знаков
е
long double й
1[1.9E-4932 …
19ар
20 знаков
т2й1.1E4932]
ат типа unsigned char выделяется память в 1 байт, т.е. диапазон
Для переменных
б
числовых значений, которые
могут принимать переменные: 0-255. Поэтому его удобно
а
использовать для иллюстрации
работы битовых операций. Числовые значения
й char соответствуют ASCII -кодам символов (см. Таблицу 2).
переменных типа unsigned
При выводе на консольт будет выведен сам символ. Чтобы вывести числовое значение,
необходимо преобразовать переменную к типу int (см. Пример 3).
Пример 3.
#include
<iostream> using
namespace std; int
main() {
cout<<sizeof(unsigned char)<<endl; //размер типа unsigned char = 1 байт
unsigned char x=98; //98 соответствует символу 'b' cout<<x<<endl;//вывод
символа 'b'
cout<<(int)x<<endl;//вывод числового значения символа
(98) return 0;
}
Результат:
1
b
9
8
Таблица 2. Коды ASCII
1.2.
Битовые операции
Каждую переменную скалярного типа будем представлять в виде
последовательности бит, нумеруя их от 0, биты будем записывать справа налево.
Например, если переменная a объявлена, как unsigned char, то ее можно
записать в виде последовательности из 8 бит:
u
cha
a
//
nsigned r a;
=0
; a 00000000//
=1
; a 00000001//
=2
; a 00000010//
=10
; a 00001010//
=255 ; 11111111
Соответствие двоичной и десятичной систем:
Для двух переменных одинакового скалярного типа определены битовые
операции:
& битовое И (AND)
| битовое ИЛИ (OR)
^ битовое ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR)
~ битовое ОТРИЦАНИЕ (NOT) или дополнение до единицы - унарный оператор
! логическое отрицание - унарный оператор
Результаты выполнения основных битовых операций приведены в таблице:
Из таблицы видно, что оператор & (и) соответствует умножению,
оператор | (или) соответствует сложению, отрицание ~ меняет знак на
противоположный (1 на 0 и наоборот).
Битовые операторы работают следующим образом. Берутся два операнда, и к
каждой паре соответствующих бит для левого и правого операнда применяется данная
операция, результатом будет переменная того же типа, каждый бит которой есть
результат применения соответствующей логической операции к соответствующим
битам двух операндов. Рассмотрим пример:
unsigned char a, b, c, d, e, f, g;
a =5
; / 0
b = 6 / 0000101
; / 0
c = a /& b0000110
; / 0
= 4
d = a /| b0000100
; / 0 =
= 7
e = a /^ b0000111
; / 0 =
= 3
f = /~a0000011
; // =
11111010 == 250 g = !a
;
// 00000000 == 0
Битовое отрицание (величина f в последнем примере) – это число,
полученное из исходного заменой всех нулей на единицы и наоборот.
Логическим отрицанием любого ненулевого (величина g) значения является 0,
т.е. ложь, а отрицанием нулевого значения оказывается 1, т.е. истина.
1.3.
Битовые сдвиги
Сдвиг влево и вправо. Оператор a>>n возвращает число, которое получается
из a сдвигом всех бит на n позиций вправо, при этом самые правые n бит
отбрасываются. Например:
unsigned char a, b,
c, d, e; a = 43 ; // 00101011
b = a >> 1 ; //
00010101 == 21 c = a >> 2 ; //
00001010 == 10 d = a >> 3 ; //
00000101 == 5 e = a >> 5 ; //
00000001 == 1
Понятно, что для положительных чисел битовый сдвиг числа вправо на n
равносилен целочисленному делению на 2n.
Иллюстрация сдвига вправо:
Аналогично, битовый сдвиг влево на n бит равносилен (для
положительных чисел) умножению на 2n и осуществляется при помощи
оператора <<:
unsigned char a;
a = 5 ; // 00000101
b = a << 1 ; // 00001010
== 10 c = a << 2 ; // 00010100
== 20 d = 2 << 3 ; // 00101000
== 40
Для приведения числа к двоичному виду можно воспользоваться типом bitset.
Пример 4.
#include <iostream>
#include <bitset>
#include
<string> using
namespace std;
string asbits(unsigned char n) {//функция для 1байтовых чисел return bitset<8>(n).to_string();
}
string asbits(int n) {//функция для 4байтовых чисел return bitset<32>(n).to_string();
}
int main() {
unsigned char
a=5;
cout<<asbits(a)<<endl
; int x=5;
cout<<asbits(x)<<endl
;
return 0;
}
Результат:
00000101
00000000000000000000000000000101
Приведенная функция asbits(int n) возвращает строку из единиц и нулей,
соответствующую десятичному числу n.
В языке C++ допускается использование функций с одинаковыми именами, но
различными параметрами. В данном случае отличаются типы параметров: unsigned
char и int. Таким образом, в зависимости от типа переменной у нас будет вызываться
первая или вторая функция.
Может возникнуть вопрос, как представляются отрицательные числа в
двоичной записи? Например, для знакового типа char выделяется тот же объем памяти,
что и для unsigned char, т.е. 1 байт. Т.к. всего 1 байтом можно определить 256
различных значений, то диапазон значений типа char будет -128..127. Диапазон
положительных чисел представлен значениями 0..127 (в битовом виде от 00000000 до
01111111). Диапазон отрицательных чисел представлен значениями -128..-1 (в битовом
виде от 10000000 до 11111111). Таким образом, если старший бит = 0, то число
положительное, если = 1, то число отрицательное.
unsigned char a, b, c, d, e,
f, g; a = 127 ; // 01111111
b = -128
; // 10000000
c = -127
; // 10000001
d = -1 ; // 11111111
e = -2 ; // 11111110
Аналогичное представление справедливо и для всех остальных знаковых типов
данных.
2. Файлы
Для того, чтобы в C++ работать с файлами, необходимо подключить
заголовочный файл fstream:
#include <fstream>
После этого можно объявлять объекты, привязанные к файлам: для чтения
данных из файла используются объекты типа ifstream, для записи данных в файл
используются объекты типа ofstream. Например
ifstream in; // Поток in будем использовать для
чтения ofstream out; // Поток out будем использовать для
записи
Чтобы привязать тот или иной поток к файлу (открыть файл для чтения или
для записи) используется метод open, которому необходимо передать параметр –
текстовую строку, содержащую имя открываемого файла.
in.open("input.txt");
out.open("output.txt");
После открытия файлов и привязки их к файловым потокам, работать с
файлами можно так же, как со стандартными потоками ввода-вывода cin и cout.
Например, чтобы вывести значение переменной x в поток out используются
следующая операция
out<<x;
А чтобы считать значение переменной из потока in
in>>x;
Для закрытия ранее открытого файла используется метод close() без аргументов:
in.close();
out.close();
Закрытый файловый поток можно переоткрыть заново при помощи метода
open, привязав его к тому же или другому файлу.
При считывании данных из файла может произойти ситуация достижения конца
файла (end of file, сокращенно EOF). После достижения конца файла никакое чтение из
файла невозможно. Для того, чтобы проверить состояние файла, необходимо вызвать
метод eof(). Данный метод возвращает true, если достигнут конец файла или false, если
не достигнут.
Кроме того, состояние файлового потока можно проверить, если просто
использовать идентификатор потока в качестве логического условия:
if (in)
{
}
Также можно использовать в качестве условия результат, возвращаемой
операцией считывания. Если считывание было удачным, то результат считается
истиной, а если неудачным – ложью. Например, организовать считывание
последовательности целых чисел можно так:
int
d;
while(in>>
d)
{
}
А организовать считывание файла построчно (считая, что строка заканчивается
символом перехода на новую строку) так:
string S;
while ( getline(in,S))
{
}
1. Условный оператор if
Условная инструкция в C++ имеет следующий синтаксис:
if (Условие)
{
Блок инструкций 1
}
else //иначе
{
Блок инструкций 2
}
Пример: Программа должна напечатать значение переменной x, если x>0 или
же величину -x в противном случае. Линейная последовательная структура программы
нарушается – возникает ветвление: в зависимости от справедливости условия x>0
должна быть выведена одна или другая величина.
d
ouble
x;
cin>>x;
if (x>0)
{
cout<<x;
}
else
{
cout<<-x;
}
return 0;
1.1
Вложенные условные инструкции
Покажем это на примере программы, которая по данным ненулевым
числам x и y определяет, в какой из четвертей координатной плоскости
находится точка (x,y):
d
ouble x,y;
cin>>x>>
y; if (x>0)
{
if (y>0) // x>0, y>0
{
cout<<"Первая четверть"<<endl;
}
else
// x>0, y<0
{
cout<<"Четвертая четверть"<<endl;
}
}
else
{
if (y>0) // x<0, y>0
{
cout<<"Вторая четверть"<<endl;
}
else
{
// x<0, y<0
cout<<"Третья четверть"<<endl;
}
}
1.2
Операторы сравнения
Как правило, в качестве проверяемого условия используется результат
вычисления одного из следующих операторов сравнения:
<Меньше — возвращает true, если первый операнд меньше второго.
>Больше — возвращает true, если первый операнд больше второго.
<=Меньше или равно.
>=Больше или равно.
==Равенство. Возвращает true, если два операнда равны.
!=Неравенство. Возвращает true, если два операнда неравны.
1.3
Логические операторы
Иногда нужно проверить одновременно не одно, а несколько условий.
Например, проверить, является ли данное число четным можно при помощи условия
n%2==0(остаток от деления nна 2равен 0), а если необходимо проверить, что два
данных целых числа nи mявляются четными, необходимо проверить справедливость
обоих условий: n%2==0и
m%2==0, для чего их необходимо объединить при помощи оператора &&
(логическое И):
n%2==0 && m%2==0.
В C++ существуют стандартные логические операторы: логическое И,
логическое ИЛИ, логическое отрицание.
&& Логическое И является бинарным оператором (то есть оператором с
двумя операндами: левым и правым) и имеет вид && (два знака амперсанда).
Оператор && возвращает trueтогда и только тогда, когда оба его операнда имеют
значение true.
|| Логическое ИЛИ является бинарным оператором и возвращает true тогда и
только тогда, когда хотя бы один операнд равен true. Оператор "логическое ИЛИ"
имеет вид || (два знака вертикальной черты).
! Логическое НЕ (отрицание) является унарным (то есть с одним операндом)
оператором и имеет вид !(восклицательный знак), за которым следует единственный
операнд.
Логическое НЕ возвращает true, если операнд равен falseи
наоборот. Пример:
x && !y
Данное выражение означает "xИ отрицание y" и равно trueтогда и только тогда,
когда x
равно true, а yравно false.
2 Цикл for
Рассмотрим задачу вычисления суммы всех натуральных чисел от 1 до n. Для
этого заведем переменную s и к ней будем прибавлять значение переменной i, где i
будет принимать все значения от 1 до n. На языке C++ это можно сделать при помощи
цикла for следующим образом:
int n,
s=0, i; cin>>n;
for (i=1; i<=n; ++i)
{
s=s+i;
}
cout<<s<<endl;
При использовании цикла forнеобходимо задать три параметра (в круглых
скобках через точку с запятой).
Первый параметр – начальное значение переменной, задается в виде
присваивания переменной значения, в нашем случае: i=1.
Второй параметр – конечное значение переменной, задается в виде условия на
значение переменной. Цикл будет исполняться, пока условие истинно, в нашем случае
условие i<=n означает, что переменная iбудет принимать значения до nвключительно.
Третий параметр – шаг изменения переменной. Запись ++iозначает, что
переменная i
будет увеличиваться на 1с каждым новым исполнением цикла, запись --i–
уменьшаться.
В нашем примере мы могли бы сделать цикл, в котором переменная i
принимала бы все значения от n до 1, уменьшаясь при этом: for(i=n; i>0;--i).
Если хочется, чтобы значение переменной в цикле менялось не на 1, а
на большую величину, то это можно сделать, например, так: i=i+2.
1 Цикл for (продолжение)
Операторы присваивания
В языке C++ помимо стандартного оператора присваивания = существует еще
несколько операторов присваивания: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=.
Запись x+=a эквивалентна записи x=x+a, то есть значение x увеличивается на
a. Аналогично работают и остальные операторы:
x=a; //присвоить x значение
a; x+=a; //увеличить значение x на a;
x-=a; //уменьшить значение x на a;
x*=a; //умножить x на a;
x/=a; //поделить x на a (не забудьте про то, что бывает деление целочисленное
и с плавающей точкой!);
x%=a; //заменить x на остаток от деления x на a;
Операторы инкремента и декремента
Унарный оператор инкремента ++ увеличивает значение переменной на 1.
Существует две формы оператора инкремента: префиксная форма ++n и постфиксная
форма n++.
Постфиксный оператор инкремента возвращает старое значение
переменной, а префиксный оператор – новое, то есть увеличенное на 1.
Пример:
int x=3, y=3;
cout<<++x<<endl; //Значение префиксного
выражения: 4 cout<<x++<<endl; //Значение постфиксного
выражения: 3 cout<<x<<endl; //Значение х после
приращения: 4 cout<<y<<endl; //Значение у после
приращения: 4
Цикл for (общий вид)
Цикл for является универсальным циклом, который может использоваться и
вместо цикла while. Однако в большинстве случаев цикл for используется для того, чтобы
некоторая переменная изменялась в заданном диапазоне с заданным шагом.
Синтаксис оператора for такой:
for (инициализация ; условие ;
итератор ) инструкция
где инициализация, условие, итератор — арифметические выражения,
инструкция — одна инструкция языка C++.
Если в теле цикла нужно использовать не одну инструкцию, а несколько, то их
надо объединить в блок при помощи фигурных скобок. Тело цикла может и вообще
отсутствовать, тогда в качестве тела цикла следует использовать пустую инструкцию,
которая ничего не делает и имеет синтаксис ";" (точка с запятой). Пример:
for( cout>>n ; n%2==0 ; n/=2) ; //цикл без «тела»
В этом примере инициализация состоит из считывания значения n с клавиатуры,
условие состоит в проверке, делится ли число n на 2, итератор — в делении числа n на 2,
а тело самого цикла — пустое, так как сразу после закрывающей круглой скобки стоит
пустая инструкция ";". Данный цикл будет уменьшать значение введенного числа в 2 раза
до тех пор, пока число делится на 2.
В цикле for можно не указывать выражения инициализации, условия и итератора,
но обязательно нужно проставить точки с запятой даже между отсутствующими
выражениями. Если в инструкции for пропущено условие, то считается, что оно всегда
истинно. Таким образом, бесконечный цикл можно задать следующим образом:
for(;;)
Можно использовать две или более переменных для управления циклом.
int i, j;
for(i=0, j=10; i < j; i++, j--)
Console.WriteLine("i и j : " + i + " " + j);
Инструкции управления циклом
Внутри циклов while и for могут встречаться инструкции управления циклом.
Инструкция break; прерывает выполнение цикла, управление при этом немедленно
передается на следующую после цикла инструкцию. Инструкция continue; продолжает
выполнение цикла со следующей итерации: все входящие в блок цикла инструкции не
выполняются, в цикле for выполняется итератор, после чего проверяется условие (во всех
видах циклов) и в зависимости от его значения выполняется или не выполняется тело
цикла. Как правило, инструкции break; и continue; используются вместе с инструкцией
if. Пример:
for(i=0;i<100;++i)
{
if(i%3==0)
conti
nue;
cout<<i<<end
l;
// Выполнить еще какие-нибудь действия
}
В этом примере переменная i в цикле принимает значения от 0 до 99. Внутри
цикла проверяется условие и если i делится на 3, то оставшаяся часть цикла
пропускается, и на экран будут напечатаны только те значения i, которые не делятся на
3.
Извлечение квадратного корня
Для извлечения квадратного корня (из величин типа double, целочисленные
величины необходимо преобразовать к типу double) используется стандартная функция
sqrt. Для ее использования необходимо в начале файла с программой добавить строку
#include
<cmath>. Пример использования функции: d=sqrt(e);.
Ограничения: M и N целые, 1 <= M <= N <= 109, (N - M) * sqrt(N) <= 107.
http://informatics.mccme.ru/moodle/mod/statements/view3.php?chapterid=629
Быстрое возведение в степень По данному действительному a и натуральному n
вычислите an за время порядка log(n).
Указание воспользуйтесь представлением числа n в двоичном виде, или свойством:
n
2
n/2
a =(a )
при четном n, an=a×an-1 при нечетном n.
http://informatics.mccme.ru/moodle/mod/statements/view3.php?chapterid=311
Бинарный алгоритм Евклида Найдите наибольший общий делитель двух чисел не
используя медленного оператора деления. Можно использовать деление на 2 и взятие
остатка от деления на 2, поскольку они выполняются при помощи битовых операций
быстро.
Указание: НОД(a,b)=2НОД(a/2,b/2),если a и b – четные, НОД(a,b)=НОД(a/2,b),если
a – четное, b – нечетное, НОД(a,b)=НОД(a-b,b),если a и b – нечетные.
http://informatics.mccme.ru/moodle/mod/statements/view3.php?id=4663&chapterid=41
80
2
Цикл while ("пока")
В языке C++ существует два вида циклов while: c предусловием и с постусловием.
Цикл while с предусловием
Синтаксис цикла while("пока") c предусловием такой:
while (условие)
{
блок инструкций
}
Следующий фрагмент программы напечатает на экран квадраты всех
целых чисел от 1 до 10:
int
i=1; while
(i<=10)
{
cout<<i*i<<endl;
++i;
}
В следующем примере цикл используется для того, чтобы найти
количество знаков в десятичной записи целочисленной переменной i.
int
Ndigits=0;
while(n!=0)
{
Ndigits=
Ndigits+1;
n=n/10;
}
Внутри цикла значение переменной n уменьшается в 10 раз до тех пор, пока она
не станет равна 0. Уменьшение целочисленной переменной в 10 раз (с использованием
целочисленного деления) эквивалентно отбрасыванию последней цифры
этой
переменной.
Цикл while с постусловием
Синтаксис цикла с постусловием такой (обратите внимание на
обязательную точку с запятой после условия):
do
{
Блок инструкций
}
while (условие);
Поскольку условие проверяется после выполнения тела цикла, то блок цикла с
постусловием всегда будет выполнен хотя бы один раз, независимо от истинности
условия. Это может привести к ошибкам, поэтому использовать цикл while с
постусловием следует только тогда, когда это действительно упрощает алгоритм.
Кратко о сложности алгоритма (О большое)
Трудоемкость алгоритма можно описать в зависимости от n (количества
входных элементов). Поиск в неотсортированных данных занимает время,
пропорциональное n; при использовании двоичного поиска по отсортированным
данным время будет пропорционально log n. Время сортировки пропорционально n2
или n log n.
Хотелось бы сравнивать время работы и затраты памяти алгоритмов вне
зависимости от языка программирования, компилятора, архитектуры компьютера,
скорости процессора, загруженности системы и других сложных факторов.
Для этой цели существует стандартная форма записи, которая называется "О
большое". Основной параметр этой записи — n, размер входных данных, а сложность
или время работы алгоритма выражается как функция от n. "О" — от английского order,
то есть порядок. Например, фраза "Двоичный поиск имеет сложность O(log n)" означает,
что для поиска в массиве из n элементов требуется порядка log n действий. Запись
О(f(n)) предусматривает, что при достаточно больших n время выполнения
пропорционально f(n), не быстрее, например, О(n2) или O(n log n).
Вот основные случаи:
n)
log n)
Запись
Название времени
Пример
O(1)
Константное
Индексирование массива
O(log
Логарифмическое
Двоичный поиск
O(n)
Линейное
Сравнение строк
O(n
n logn
Quicksort
O(n2)
О(n3)
Квадратичное
Простые методы сортировки
Кубическое
Перемножение матриц
Экспоненциальное
Перебор всех подмножеств
O(2n)
Случайные числа
За генерацию случайных чисел в C++ отвечают методы стандартной
библиотеки (#include<cstdlib>): rand() и srand(). Подобные числа генерируются
алгоритмически и являются псевдослучайными (т.е. числами, обладающими некими
свойствами случайных чисел, но не полностью случайными).
Простой пример генерации случайного числа:
int rnd = rand();
// Генерация случайного целого
числа cout << rnd << endl;
В этом случае, при каждом запуске программы будет генерироваться одно и то
же число. Чтобы понять, почему так происходит, рассмотрим как в самом языке С++
реализована функция rand():
#define
RAND_MAX 32767
unsigned long next = 1;
int rand(void)
{
next = next * 1103515245m;
return((unsigned int)(next / 65536)2768);
}
Очевидно, что полученное с помощью этой функции случайное число, зависит
от стартового числа next, которое установлено в единицу. Отсюда и следует, что числа
всегда получаются одинаковыми. Для того чтобы избежать этой проблемы, в паре с rand()
нужно использовать функцию srand().
void srand(unsigned int seed)
{
next = seed;
}
Как видите, эта функция изменяет стартовое число next, присваивая ему
величину параметра seed. Чтобы получать различные случайные числа, необходимо
использовать различные начальные значения – seed. Для этого логично использовать
значения текущего времени (#include<ctime>): time(0) - количество секунд с 1 января
1970 г.
Рассмотрим пример, иллюстрирующий, как использовать данные функции на
практике:
srand((unsigned)time(0));//инициализация генератора
случайных чисел int rnd;
for(int i=0; i<20; i++){ //генерация 20 случайных чисел
rnd = (rand()%10)+1; //генерация случайного числа в диапазоне от
1 до 10 cout << rnd << endl;
}
Обратите внимание, что srand() достаточно вызвать один раз перед циклом.
Генерация вещественных чисел осуществляется аналогично:
srand((unsigned)time(NULL));
double x = (double)rand() / RAND_MAX; //генерация числа в диапазоне от 0 до 1
x = (double)rand() / RAND_MAX * 20 + 30; //генерация числа в диапазоне от 30 до
50
64-битный тип данных
Компилятор полностью поддерживает 64-битные целые, как со знаком, так и без
знака. 64-битное целое со знаком имеет диапазон значений от –9223372036854775808 до
9223372036854775807, без знака — от 0 до 18446744073709551615. Объявить 64-битную
переменную можно следующим способом:
long long
a; unsigned long long
b;
Читать и выводить 64-битные переменные также можно двумя способами в
зависимости от используемой библиотеки ввода/вывода:
#include <stdio.h>
...
scanf("%lld", &a);
scanf("%llu", &b);
printf("%lld", a);
printf("%llu", b);
#include <iostream>
...
c
in >> a;
cin >> b;
cout <<
a; cout
<< b;
1.
Инструкции управления циклом
В задачах могут быть полезны две инструкции управляющие циклом: breakи
continue. Инструкция break нужна для прерывания цикла, инструкция continue
используется для пропуска оставшейся части цикла и продолжения цикла со
следующей итерации.
Пример:
for(i=0;i<100;++i)
{
if(i%3==0)
conti
nue;
cout<<i<<en
dl;
// Выполнить еще какие-нибудь действия
}
В этом примере переменная i в цикле принимает значения от 0 до 99. Внутри
цикла проверяется условие и если i делится на 3, то оставшаяся часть цикла пропускается,
и на экран будут напечатаны только те значения i, которые не делятся на 3.
Возможное применение инструкции break: в программе проверки числа на
простоту прервать выполнение цикла, если найден делитель.
2.
Массивы – 1 часть
Массив — это структура однотипных данных, занимающих непрерывную
область памяти. Массив имеет размер — количество элементов в нем. Каждый элемент
массива имеет свой номер (также называемый индексом), обращение к элементу
массива осуществляется путем указания его индекса. В языке C++ элементы
нумеруются начиная с 0, поэтому последний элемент массива имеет номер на 1 меньше
размера массива.
Массив в языке C++ задается следующим образом:
тип_элементов идентификатор[размер];
где тип_элементов — произвольный тип данных языка C++, который
будут иметь элементы массива, например, int, double и т.д.; идентификатор —
имя массива, размер — число элементов в нем.
К элементу массива можно обращаться, как идентификатор[индекс].
Например, если было сделано объявление
double A[5];
то таким образом создается 5 элементов массива типа double: A[0], A[1],
A[2], A[3], A[4].
Пример программы, которая создает массив типа int[], заданного пользователем
размера, считывает с клавиатуры его элементы, затем прибавляет к каждому элементу
массива число 1, затем выводит результат на экран:
#include<i
ostream> using
namespace std; int
main()
{
int n;
// Размер массива
int i;
// Счетчик в циклах
cout<<"Введите количество чисел: ";
cin>>n; // Считываем размер
массива int arr[n];
// Объявление
массива
// Считываем массив
cout<<"Введите "<<n<<" целых чисел:
"; for(i=0;i<n;++i)
cin>>arr[i];
// Прибавляем по 1 к каждому элементу
for(i=0;i<n;++i)
arr[i]+=1;
// Выводим массив на экран
for(i=0;i<n;++i)
cout<<arr[i]<<" ";
// Переведем курсор на новую строку
c
out<<endl
; return 0;
}
После объявления массива переменная arr указывает на область памяти, в
которой могут содержаться произвольные значения. В таком случае, чтобы избежать
ошибок при дальнейшей работе, необходимо сразу обнулить массив. Можно
пробежаться циклом по всем элементам и присвоить им нулевые значения, но есть
быстрый способ:
int arr[n]={0}; // Объявление массива с нулевыми значениями
При решении задач бывает удобно воспользоваться массивом, заполненным
случайными числами (см. раздел Случайные числа).
1.
1.1.
Функции
Общее представление функции
Для решения задачи вычисления числа сочетаний из n элементовk по k - Cn ,
необходимо вычислить факториалы трех величин: n, k и n-k. Для этого можно сделать
три цикла, что приводит к увеличению размера программы за счет трехкратного
повторения похожего кода. Вместо этого лучше сделать одну функцию, вычисляющую
факториал любого данного числа n и трижды использовать эту функцию в своей
программе. Соответствующая функция может выглядеть так:
int factorial (int n)
{
int
f=1,i;
for(i=2;i<=n;i++
)
{
f=f*i;
}
return f;
}
Функция должна быть описана до начала основной программы. Сама же
основная программа, как можно догадаться, также является функцией с именем main, не
получающая никаких параметров и возвращающее значение типа int.
Теперь мы можем использовать нашу функцию factorial в основной программе
нахождения числа сочетаний:
int main ()
{
i
nt n,k;
cin>>n>
>k;
cout<<factorial(n)/(factorial(k)*factorial(nk))<<endl; return 0;
}
В этом примере мы трижды вызываем функцию factorial для вычисления
трех факториалов: factorial(n), factorial(k), factorial(n-k).
Мы также можем объявить функцию binomial, которая принимает два
целочисленных параметра nи kи вычисляет число сочетаний из nпо k:
int binomial (int n, int k)
{
return factorial(n)/(factorial(k)*factorial(n-k));
}
Тогда в нашей основной программе мы можем вызвать функцию binomial для
нахождения числа сочетаний:
cout<<binomial(n,k)<<endl;
Поскольку в этом случае функция main вызывает функцию binomial, а функция
binomial вызывает функцию factorial, а каждая функция должна быть описана до ее
вызова из другой функции, то порядок описания функций в программе должен быть
такой:
int factorial (int n)
int binomial (int n,
int k) int main ()
Вернемся к задаче нахождения наибольшего из двух или трех чисел. Напишем
функцию, находящую максимум из двух данных чисел:
double max (double a, double b)
{
if (a>b)
ret
urn a; else
return b;
}
Теперь мы можем реализовать функцию max, находящую максимум трех чисел:
double max (double a, double b, double c)
{
return max( max(a,b), c);
}
В данном примере написаны две различные функции max: первая с двумя
параметрами, вторая с тремя параметрами. Несмотря на то, что функции имеют
одинаковые имена, по количеству передаваемых параметров ясно, какая из них имеется
в виду. В нашем случае функция max (double a, double b, double c) дважды вызывает
функцию maxдля двух чисел: сначала, чтобы найти максимум из a и b, потом чтобы найти
максимум из этой величины и c.
1.2.
Рекурсия
Эпиграф:
void ShortStory()
{
cout<<"У попа была собака, он ее
любил."<<endl; cout<<"Она съела кусок мяса, он
ее убил,"<<endl; cout<<"В землю закопал и
надпись написал:"<<endl; ShortStory();
}
Как мы видели выше, функция может вызывать другую функцию. Но функция
также может вызывать и саму себя. Рассмотрим это на примере функции вычисления
факториала:
int factorial (int n)
{
if (n==0)
ret
urn 1; else
return n*factorial(n-1);
}
Подобный прием (вызов функцией самой себя) называется рекурсией, а сама
функция называется рекурсивной.
Рекурсивные функции являются мощным механизмом в программировании. К
сожалению, они не всегда эффективны. Также часто использование рекурсии приводит к
ошибкам, наиболее распространенная из таких ошибок – бесконечная рекурсия, когда
цепочка вызовов функций никогда не завершается и продолжается, пока не кончится
свободная память в компьютере. Пример бесконечной рекурсии приведен в эпиграфе к
этому разделу. Две наиболее распространенные причины для бесконечной рекурсии:

Неправильное оформление выхода из рекурсии. Например, если мы в
программе вычисления факториала забудем поставить проверку if (n==0), то
factorial(0) вызовет factorial(-1), тот вызовет factorial(-2) и т.д.

Рекурсивный вызов с неправильными параметрами. Например,
если функция factorial(n)
будет вызывать factorial(n), то также получиться бесконечная цепочка.
2.
Массивы – 2 часть
Передача массива в качестве параметра
Массивы можно передавать функции в качестве параметра, при этом сам массив,
в отличие от обычных переменных передается по ссылке, т.е. все изменения с массивом
внутри функции отразятся на исходном массиве.
Например, для следующего кода будет выведено на консоль число 8:
void
testA(int arr[]){
arr[1]=8;
}
int main()
{
int A[3]={1, 4, 5};
test
A(A); cout
<< A[1];
return 0;
}
Размер создаваемого массива может быть неопределен на момент компиляции
программы, поэтому функция не может знать размер полученного массива. Поэтому при
объявлении функции необходимо задавать два параметра: массив передаваемых
элемента (без указания размера массива) и размер массива. Например, функция поиска
наименьшего значения в массиве int A[n]может быть объявлена так:
double Min (int A[], int n)
Соответственно, внутри функции main мы объявляем массив int A[n] и вызываем
функцию Min, передав в качестве параметров массив Aи его размер n:
Min (A, n);
Пример функции для нахождения среднего арифметического элементов массива:
double getAverage(int arr[], int
size) { int i, sum = 0;
double avg;
for (i = 0; i < size;
++i) { sum += arr[i];
}
avg =
double(sum) / size; return
avg;
}
1 Указатели (pointers)
Указатель – переменная, значением которой является адрес ячейки памяти. То
есть указатель ссылается на блок данных из области памяти, причём на самое его начало.
Указатель может ссылаться на переменную или функцию. Для этого нужно знать адрес
переменной или функции. Чтобы узнать адрес конкретной переменной в С++ существует
унарная операция взятия адреса &. Такая операция извлекает адрес объявленных
переменных, для того, чтобы его присвоить указателю.
Указатели используются для передачи по ссылке данных, что намного ускоряет
процесс обработки этих данных (в том случае, если объём данных большой), так как их
не надо копировать, как при передаче по значению, то есть, используя имя переменной.
В основном указатели используются для организации динамического распределения
памяти, например при объявлении массива, не надо будет его ограничивать в размере.
Ведь программист заранее не может знать, какого размера нужен массив тому или иному
пользователю, в таком случае используется динамическое выделение памяти под
массив. Любой указатель необходимо объявить перед использованием, как и любую
переменную.
Принцип объявления указателей такой же, как и принцип объявления
переменных. Отличие заключается только в том, что перед именем ставится символ
звёздочки *. Визуально указатели отличаются от переменных только одним символом.
При объявлении указателей компилятор выделяет несколько байт памяти, в зависимости
от типа данных отводимых для хранения некоторой информации в памяти. Чтобы
получить значение, записанное в некоторой области, на которое ссылается указатель
нужно воспользоваться операцией разыменования указателя *. Необходимо поставить
звёздочку перед именем и получим доступ к значению указателя.
В C++ указатель объявляется с помощью звездочки
int * pointer; //переменная pointer - указатель на целое число;
double * pointer2; //переменная pointer - указатель на вещественное число;
так как указатель хранит адрес памяти, то попытка обращения к указателю
вернет этот адрес. Если pointer объявлен как указатель, то
cout << pointer; //На экран выводится какой-то адрес памяти pointer.
чтобы указатель обрабатывал значение, а не адрес памяти, используется
операция разыменования
cout << *pointer; //звездочка - это оператор разыменования, (не путать со
звездочкой в объявлении), здесь мы получаем значение по адресу указателя
*pointer = 100; // присваиваем значение 100 переменной по адресу pointer
В этом примере сначала на экран выведется какое-то любое значение, после чего
по адресу памяти на который указывает pointer будет записано число 100. Так как в обоих
случаях было применено разыменование, то будут выведены именно значения, а не
адреса памяти.
Когда мы объявляем указатель без инициализации – он указывает на участок
памяти размером под соответствующий тип, где уже будет записано какое-то значение,
оставшееся от других программ.
#include
<iostream> using
namespace std;
int main(int argc, char* argv[])
{
// инициализация
переменной var числом 123 int var
= 123;
// инициализация указателя на переменную var (указателю присвоили
адрес переменной) int * ptrvar = &var;
// адрес переменной var
содержащийся в памяти cout << "&var
= " << &var << endl;
// адрес переменной var, является значением
указателя ptrvar cout << "ptrvar = " << ptrvar << endl;
// значение в переменной var
cout << "var
= " << var << endl;
// значение переменной var через указатель, операцией * разыменования указателя cout << "*ptrvar = " << *ptrvar << endl;
return 0;
}
В коде объявлен и инициализирован адресом переменной var указатель ptrvar.
Можно было сначала просто объявить указатель, а потом его инициализировать, тогда
были бы две строки:
int var=5;
int * ptr; // объявление указателя
ptr = &var; // инициализация указателя адресом переменной var
Указатель инициализируется только значением адреса переменной, например:
int * ptr = &var; // инициализация указателя адресом переменной var
int * ptr = var; //!! Ошибка: попытка инициализации указателя значением
переменной
Чтобы не возникало подобных вопросов (а также в дальнейшем при передаче
параметров в функцию) рекомендую пользоваться приведенным выше синтаксисом.
Следует иметь в виду, что допустимы следующие формы объявления указателя.
int* ptr1;
int *ptr2;
Но может возникнуть путаница в следующем случае:
int* ptr1, a, b;
// указатель и 2 целых переменных, а не 3 указателя int
var=33;
int *ptr2=var; // !!Ошибка преобразования типа: не можем присвоить указателю целое
значение, так как здесь объявление указателя, а не операция разыменования
При этом не будет ошибкой:
int var=33;
int *ptr2; //здесь указатель (объявление)
*ptr2=var; //а здесь уже значение! (операция разыменования)
Поэтому еще раз подчеркну, что объявление указателей понятнее делать следующим
образом:
int * ptr1, *Указатели
ptr2;
могут ссылаться на другие указатели. При этом в ячейках памяти,
на которые будут ссылаться первые указатели, будут содержаться не значения, а адреса
вторых указателей.
Число символов * при объявлении указателя показывает порядок указателя.
Чтобы получить доступ к значению, на которое ссылается указатель его необходимо
разыменовывать соответствующее количество раз. Разработаем программу, которая
будет выполнять некоторые операции с указателями порядка выше первого.
#include
<iostream> using
namespace std;
int main(int argc, char* argv[])
{
int var = 123; // инициализация переменной var
числом 123 int * ptr_var = &var; // указатель на
переменную var
int * *ptr_ptr_var = &ptr_var; // указатель на указатель на
переменную var int * **ptr_ptr_ptr_var = &ptr_ptr_var;
cout << " var\t\t= " << var << endl;
cout << " *ptr_var\t= " << *ptr_var << endl;
// два раза разименовываем указатель, так как он
второго порядка cout << " **ptr_ptr_var = " <<
**ptr_ptr_var << endl;
// указатель третьего порядка
cout<<" ***ptr_ptr_var = " << ***ptr_ptr_ptr_var << endl;
cout<<"\n ***ptr_ptr_ptr_var -> **ptr_ptr_var -> *ptr_var -> var ->
"<<var<<endl; cout<<"\t
" << &ptr_ptr_ptr_var << " -> "
<< "
" << &ptr_ptr_var << " ->"
<< &ptr_var << " -> " << &var << " -> " <<
var << endl; return 0;
}
Для того чтобы освободить память используется нулевой указатель
(null pointer):
*ptr_var = NULL;
либо можно воспользоваться оператором delete:
delete ptr_var;
С помощью указателей можно передавать параметры функции по ссылке
двумя способами:
#include
<iostream> using
namespace std;
void plus1(int &ref) { // функция принимает ссылку на
значение cout <<
"In square(): " << &ref << endl;
ref += ref; // увеличение значения в 2 раза
}
void plus2 (int * ptr) { // функция принимает
указатель cout <<
"In square(): " << ptr << endl;
*ptr += *ptr; // увеличение значения в 2 раза по указателю
}
int main() {
int number = 2;
cout <<
"In main(): " <<
&number << endl;
// cout << number <<
endl;
// 2
plus1(number); // !!!ссылка передается неявно (без
'&')!!! cout << number << endl; // 4
plus2(&number); // явная ссылка, если функция принимает
указатель cout << number << endl; // 8
return 0;
}
Функция может также возвращать указатель, но никогда не возвращайте
указатель на локальную переменную функции, это нарушает логику работы программы
и область видимости переменных. Следующий код скомпилируется, но на выходе в
main’е будут содержаться неверные значения, т.к. локальные переменные исчезнут и в
этой области памяти могут быть записаны уже другие значения. Для сохранения значения
в локальном блоке необходимо объявлять переменную как статическую.
#include
<iostream> using
namespace std;
int * squarePtr(int number) {
int localResult = number * number; //приведет к потере
значения static int localResult1 = number * number; //сохранит
значение cout << "squarePtr: " << localResult << endl;
return &localResult; // !!адрес локальной переменной 'localResult'
}
int & squareRef(int number) {
int localResult = number * number;
cout << "squareRef: " << localResult << endl;
return localResult; // !!ссылка на локальную переменную 'localResult'
}
int main() {
int number = 8;
cout << number <<
endl;
//
8 int * x;
x=squarePtr(number);
//делаем операцию, затрагивающую большой участок памяти
int * a=new
int[10000]; for (int
i=0;i<10000; i++){
a[i]=i;
}
cout << *x << endl; // ??
cout << squareRef(number) << endl; // ??
//здесь, скорее всего, ответ будет верный (но не гарантировано),
//т.к. до вывода проходит слишком мало времени для изменения памяти
}
Запустите приведенный выше код, а затем поменяйте вывод функции
int * squarePtr(int number)на return &localResult1; и сравните результаты.
Часто указатели используются для работы с динамическими массивами, если
длина такого массива неизвестна на момент компиляции. Указатель содержит адрес
первого элемента такого массива. Рассмотрим пример, функции печатающей массив
вещественных чисел.
#include <iostream.h>
void show_float(float *
array, int n) { int i;
for (i = 0; i < n; i++)
cout << *array++ << endl; //вывод значения и инкремент указателя
}
void main(void) {
float values[5] = {1.1, 2.2, 3.3, 4.4, 5.5};
show_float(values, 5);
cout<<*(values+2); //вывод элемента массива с номером 2
}
Есть отличия различных видов компиляторов С++. Так современные стандарты
С++11 и С++14 позволяет использовать только константы для инициализации
статических массивов. Таким образом, допустимая в GCC инициализация приведет к
ошибке:
int n=5;
int arr[n]; //ошибка: expression must have a constant value
int * arr=new int[n]; // Корректное объявление динамического массива
Для массивов справедливо, что запись a[b] всегда эквивалентна *(a + b). Таким
образом:
a[5] = 0;
// 5й элемент массива a = 0
*(a+5) = 0; // значение по указателю на (a+5) = 0
При работе с массивами через указатели мы можем выйти за пределы массивы, и
это не приведет к ошибке. Например:
int * p; // uninitialized pointer (local variable)
int myarray[10];
int * q = myarray+20;
// element out of bounds
Здесь оба указателя будут ссылаться на какие-то неизвестные значения.
Пример смешанного обращения к элементам массива через указатели и индексы:
#include
<iostream> using
namespace std;
int main ()
{
int
numbers[5]; int
* p;
p = numbers;
*p = 10; p++;
*p = 20;
p = &numbers[2];
*p = 30; p = numbers + 3; *p
= 40; p = numbers; *(p+4) =
50; for (int n=0; n<5; n++)
cout << numbers[n] <<
", "; return 0;
}
Результат: 10, 20, 30, 40, 50,
Так как массивы могут быть разных типов, то смещение происходит
относительно количества байт, которые занимает переменная типа:
Интересны сочетания указателей и операций инкремента:
*p++
// то же, что и *(p++):
*++p
// то же, что и *(++p):
++*p
// то же, что и ++(*p):
(*p)++ // получаем значение переменной и увеличиваем его
Существует также оператор delete для удаления (освобождения памяти) массива:
delete[] numbers;
Условные обозначения указателей:
double * ptr; //объявление указателя (reference, pointer) типа double с именем ptr
*ptr //значение, хранящееся по адресу указателя ptr
*(ptr + i) //значение по адресу указателя (ptr плюс
смещение i) &x //адрес переменной x
int * ptr = &x; //присвоение указателю ptr адреса переменной x
(подразумевается, что x – целое)
ptr++; //инкремент указателя ptr
2 Строки
2.1
Символьный тип char
Любой текст состоит из символов. Для хранения одного символа предназначен
тип данных char. Переменную типа char можно рассматривать двояко: как целое
число,
занимающее 1 байт и способное принимать значения от 0 до 255 (тип unsigned
char) или от -128 до 127 (тип signed char) и как один текстовый символ. Сам же тип char
может оказаться как знаковым, так и беззнаковым, в зависимости от операционной
системы и компилятора. Поэтому использовать тип char не рекомендуется, лучше явно
указывать будет ли он знаковым (signed) или беззнаковым (unsigned).
Как и целые числа, данные типа char можно складывать, вычитать, умножать,
делить, а можно выводить на экран в виде одного символа. Именно это и происходит при
выводе символа через объект cout. Если же нужно вывести числовое значение символа
(также называемый ASCII-кодом), то значение символа необходимо преобразовать к
типу int. Например:
#include<i
ostream> using
namespace std; int
main()
{
unsigned char c = 'A'; // Константы char заключаются в одинарные
кавычки cout << c << " " << (int)c << endl;
c = 126;
// char можно присвоить и числовое
значение cout << c << " " << (int)c << endl;
return 0;
}
В этом примере переменной с типа char присваивается значение, равное символу
'A' (константы типа char записываются как символы в одинарных кавычках), затем на
экран выводится значение c, как символа и его ASCII-код, потом переменной c
присваивается значение 126 (то есть символ с ASCII-кодом 126) и снова выводится на
экран символ и его ASCII-код.
Организовать последовательное посимвольное считывание всего входного
потока можно при помощи цикла while:
#include<i
ostream> using
namespace std; int
main()
{
unsigned char c;
while (cin >> c) // Цикл пока считывание успешно
{ // Делаем необходимые действия
}
return 0;
}
В этом примере программа будет посимвольно считывать входной поток (по
умолчанию — ввод с клавиатуры), пока не встретит признак конца файла. Для того,
чтобы сообщить программе о завершении файла при вводе с клавиатуры необходимо
нажать Ctrl-z.
Эта программа при считывании данных будет игнорировать символы–
разделители: пробелы, символы новой строки и табуляции. Если нужно, чтобы в
переменную c считывались все символы, в том числе и разделители, то необходимо для
потока ввода cin установить манипулятор noskipws при помощи инструкции
cin>>noskipws;.
2.2
Строки в языке C++
Текстовую строку можно представить, как массив символов типа char, но в языке
C++ для хранения текстовых строк был создан более удобный тип string. По сути, тип
данных string и является массивом символов, например, если мы объявили переменную
S как string S, а затем присвоили ей значение "телефон" (текстовые строки заключаются
в двойные кавычки), то мы можем обращаться к отдельным символам строки S,
представляя S, как массив символов, например, S[0]=='т', S[1]=='е' и т.д. Для того, чтобы
узнать длину строки используется метод length(), вызываемый в виде S.length().
Строковые данные можно считывать с клавиатуры, выводить на экран,
присваивать переменным типа string. Также строки можно складывать друг с другом:
например, при сложении строк "Hello, " и "world!" получится строка "Hello, world!". Такая
операция над строками называется конкатенацией.
Основные приемы работы с объектами stringпроиллюстрированы в программе:
string S, S1, S2;
// Объявление трех
строк cout << "Как вас зовут? ";
cin >> S1;
// Считали строку S1
S2 = "Привет, ";
// Присвоили строке значение
S = S2 + S1;
// Использование конкатенации
cout << S << endl;
// Вывод строки на экран
cout << S.length();
// Длина строки S
При считывании строк из входного потока считываются все символы, кроме
символов– разделителей (пробелов, табуляций и новых строк), которые являются
границами между строками. Например, если при выполнении следующей программы
string S1, S2, S3; // объявили 3
строки cin >> S1 >> S2 >> S3;
ввести текст ‘Мама мыла раму’ (с произвольным количеством пробелов между
словами), то в массив S1будет записана строка "Мама", в S2— "мыла", в S3— "раму".
Таким образом, организовать считывание всего файла по словам, можно
следующим образом:
string s;
while (cin >> s) // Цикл пока считывание успешно
{
// Делаем необходимые действия
}
Если нужно считать строку со всеми пробелами, то необходимо использовать
функцию
getlineследующим образом:
string
S;
getline(cin,S);
В данном случае если запустить эту программу и ввести строку "Мама мыла
раму", то именно это значение и будет присвоено строке S. Считать же весь входной
поток по строкам можно при помощи следующего кода:
string s;
while ( getline(cin,S) ) // Цикл пока считывание успешно
{
// Делаем необходимые действия
}
2.3
Указатели на символьные строки
Вместо типа string можно использовать указатель на символьную строку
(указатель на первый символ строки).
#include <iostream.h>
void
show_string(char * str){
while (*str != '\0')
{
cou
t << *str;
str++;
}
}
void main(void) {
show_string("Strings in C++");
}
Условие while (*str != '\0') проверяет, не является ли текущий символ,
указываемый с помощью указателя str, символом NULL, который определяет последний
символ строки. Если символ не NULL, цикл выводит текущий символ с помощью
cout. Затем оператор str++; увеличивает указатель sir таким образом, что он указывает на
следующий символ строки. Когда указатель str указывает на символ NULL, функция уже
вывела строку и цикл завершается.
Следующая программа использует указатель на строку в функции string_length
для определения количества символов в строке:
#include <iostream.h>
int
string_length(char * str)
{ int length = 0;
while (*str) {
l
ength+
+;
str++;
}
return(length);
}
void main(void) {
char title[] = "Strings in C++";
cout << title << " содержит " << string_length(title) << " символов";
}
Одно из наиболее широко употребляемых использовании указателей в
программах на C++ заключается в сканировании символьных строк. Для уменьшения
количества кода многие программы используют следующие операторы для сканирования
строки:
while (*str){
//
операторы
str++;
// продвинуть к следующему символу
}
Следующая функция string_uppercase использует указатели для преобразования
символов строки в символы верхнего регистра:
char * str_uppercase(char * str) {
char *starting_address = str; // адрес
str[0]; while (*str) {
if ((*str >= 'a') && (*str <= 'z')) *str = *str - 'a' + 'A';
str++;
}
return(starting_address);
}
Эта функция сохраняет и возвращает начальный адрес строки, который
позволяет вашим программам использовать функцию следующим образом:
cout << *str_uppercase("Hello, uppercase!") << endl;
1. Строковые функции
Управляющие последовательности
Для вставки в строку «непечатных» (с кодами меньше 32) или имеющих в C/C++
специальное значение символов, необходимо использовать так называемые
управляю щ ие посл едовател ьности ( esc ape c odes) , начинающиеся с символа
«обратный слэш»:
\a — звонок;
\t — горизонтальная табуляция;
\r — возврат каретки;
\n — перевод строки;
\b — (backspace);
\' — апостроф;
\" — двойная кавычка;
\\ — обратный слэш;
\0 — символ с кодом 0.
Кроме того, в строку можно вставить любой символ, указав его
восьмеричный или шестнадцатеричный код:
\10 — символ с восьмеричным кодом 10;
\xF0 — символ с шестнадцатеричным кодом F0.
Следующие две строки эквивалентны:
char* str1 =
"Hello,\nworld!"; char* str2 =
"Hello,\x0Aworld!";
Функции обработки строк
Заголовочный файл
#include <string.h>
Еще раз подчеркнем, что все функции обработки строк, предоставляемые
стандартной библиотекой, считают признаком конца строки первый символ с кодом 0,
который присутствует в этой строке. Таким образом, при выполнении любых действий
над строковой переменной, объявленной как
char* str = "Sin\0City";
слово «City» будет игнорироваться. Рассмотрим основные функции обработки
строк.
strcpy
char* strcpy(
char* dest,
const char* src);
Копирует строку srcв destи возвращает dest. Пример использования:
char str[80];
strcpy(str, "Go
Down");
// теперь в str содержится "Go Down"
strcat
char* strcat(
char* dest,
const char* src);
«Дописывает» строку srcв конец строки destи возвращает dest.
Пример использования:
char str[80];
strcpy(str, "Heat");
strcat(str, "seeker");
// теперь в str содержится "Heatseeker"
strlen
int strlen( const char* src);
Возвращает длину строки src, то есть количество символов до завершающего
нуля. Пример использования:
int
n; n =
strlen("Overdose");
//
n
==
8 n = strlen("Dog\0Eat
Dog");
// n == 3
strchr
char* strchr( const char* src, char chr);
Ищет первое вхождение символа chr в строку src и возвращает указатель на часть
строки, начинающуюся с искомого символа. Если символ chr отсутствует в строке src,
функция возвращает NULL. Пример использования:
char str[80];
strcpy(str, strchr("Overdose", 'd'));
// теперь str содержит "dose"
strrchr
char* strrchr( const char* src, char chr);
Ищет последнее вхождение символа chr в строку srcи возвращает указатель на
часть строки, начинающуюся с искомого символа. Если символ chr отсутствует в строке
src, функция возвращает NULL. Пример использования:
char str[80];
strcpy(str, strrchr("Bad Boy Boogie", 'B'));
// теперь str содержит "Boogie"
strstr
char* strstr( const char* str, const char* substr);
Ищет первое вхождение строки substr в строку str и возвращает указатель на
часть строки, начинающуюся с искомой подстроки. Если строка substr отсутствует в
строке str, функция возвращает NULL. Пример использования:
char str[80];
strcpy(str, strstr("Fly On The Wall", "The"));
// теперь str содержит "The Wall"
strcmp
int strcmp( const char* str1, const char* str2);
Сравнивает строки str1 и str2. Если эти строки эквивалентны, функция
возвращает 0; в противном случае, возвращается отрицательное значение, если строка
str1 «меньше» строки str2, или положительное значение, если строка str1 «больше»
строки str2. «Меньше» и «больше» в данном случае определяется разницей кодов первых
несовпадающих символов. Пример использования:
int n = strcmp("Sink The Pink", "Stand Up");
// n < 0, так как 'i' < 't', так как 105 < 116
_stricmp
int _stricmp( const char* str1, const char* str2);
Сравнивает строки str1 и str2 аналогично функции strcmp, но без учета регистра
символов. Данная функция не входит в стандарт ANSI и относится к категории
«Microsoft specific». Пример использования:
int n = _stricmp("Danger", "danGer");
// n == 0
strtok
char* strtok( char* src, const char* seps);
Последовательно разбивает строку src на лексемы (токены), считая
разделителями все символы строки seps. При каждом вызове возвращается указатель на
очередную найденную лексему или NULL, если достигнут конец строки src. Отметим,
что данная функция модифицирует исходную строку. Пример использования:
char cur_lex[80];
char str[] = "Send
For\tThe\r\nMan"; char seps[] =
"\t\r\n";
strcpy(cur_lex, strtok(str, seps));
// теперь в cur_lex содержится "Send For"
strcpy(cur_lex, strtok(NULL, seps));
// теперь в cur_lex содержится "The"
strcpy(cur_lex, strtok(NULL, seps));
// теперь в cur_lex содержится "Man"
Обратите внимание, что указатель на исходную строку передается только при
первом вызове функции; при последующих вызовах для работы с этой же строкой
необходимо в качестве ее адреса передавать значение NULL. Естественно, что в реальных
случаях обработка лексем выполняется в цикле, завершающемся при достижении конца
исходной строки:
char str[] = "Let
There\tBe\r\nRock"; char seps[] =
"\t\r\n";
char* cur_lex = strtok(str,
seps); while (cur_lex != NULL)
{
...
// делаем что-нибудь
полезное с cur_lex cur_lex = strtok(NULL,
seps);
}
Текстовый ввод/вывод
Заголовочный файл
#include <stdio.h>
Рассмотрим две функции стандартной библиотеки, предназначенные для
текстового ввода/вывода.
puts
int puts(
const char* src);
Выводит на экран строку src, переводит курсор в начало следующей строки
экрана и возвращает количество выведенных символов.
gets
c
har*
gets(
char*
dest);
Записывает в dest введенную с клавиатуры строку и возвращает dest. Признаком
конца ввода является символ перевода строки, генерируемый при нажатии пользователя
на клавишу Enter. Заметим, что сам этот символ не копируется в dest.
Функции преобразования данных
Заголовочный файл
#include <stdlib.h>
Периодически возникает необходимость преобразования чисел в их строковое
представление и обратно — для обработки в программе уже двоичных данных. Ниже мы
рассмотрим функции, предназначенные для выполнения таких преобразований.
atoi
int atoi( const char* str);
Преобразует строковое представление целого числа str в двоичное и возвращает
его. Преобразование прекращается на первом недопустимом символе; если такой символ
окажется самым первым в строке, функция вернет значение 0. Пример использования:
int x = atoi("28
bytes"); if (x != 28)
{
puts("oops!");
}
atol
long atol( const char* str);
Под Win32 эта функция полностью аналогична atoi.
strtol
При
необходимости более
гибко
обрабатывать
ошибки
преобразования,
можно воспользоваться
функцией
long strtol( const char* str, char** end_ptr, int radix
);
которая преобразует строковое представление целого числа str в системе
счисления с основанием radix в двоичное и возвращает его. При этом, в переменную
по адресу end_ptrбудет записан указатель на символ, который прервал обработку строки
str. Пример использования:
char
str[80]; char*
end_ptr;
puts("Enter your age:");
long age = strtol(gets(str), &end_ptr,
10); if (*end_ptr != 0)
{
puts("oops!");
}
atof
double atof( const char* str);
Преобразует строковое представление дробного числа str в двоичное и
возвращает его. Преобразование прекращается на первом недопустимом символе; если
такой символ окажется самым первым в строке, функция вернет значение 0.0. Заметим,
что исходная строка может содержать как десятичное, так и экспоненциальное
представление дробного числа.
strtod
При
необходимости более
гибко
обрабатывать
ошибки
преобразования,
можно воспользоваться
функцией
double strtod( const char* str, char** end_ptr);
которая преобразует строковое представление дробного числа str в двоичное и
возвращает его, записывая по адресу end_ptr указатель на символ, который прервал
обработку строки str. Использование этой функции аналогично strtol.
_itoa
char* _itoa( int number, char* dest, int radix);
Записывает
по
адресу destстроковое
представление
целого
числа numberпо
основанию radix и возвращает dest. Пример использования:
char
dec_str[80]; char
hex_str[80];
_itoa(13, dec_str, 10);
// теперь в dec_str содержится "13"
_itoa(13, hex_str, 16);
// теперь в hex_str содержится "d"
_ltoa
char* _ltoa( long number, char* dest, int radix);
Под Win32 эта функция полностью аналогична _itoa.
_ultoa
char* _ultoa( unsigned long number, char* dest, int radix);
Аналогична функции _ltoa, но предназначена для преобразования беззнаковых
целых чисел.
_gcvt
char* _gcvt( double number, int num_dig, char* dest);
Записывает по адресу dest строковое представление дробного числа number и
возвращает dest. Через параметр num_dig необходимо передать требуемое число знаков
строкового представления. Заметим, что если данная функция не сможет представить
исходное число в десятичной форме с требуемым количеством знаков, то будет выбрана
экспоненциальная форма. При преобразовании в десятичную форму, функция
отбрасывает незначащие нули.
_fcvt
char* _fcvt( double number, int num_dec, int* dec_pos, int* has_sign);
Возвращает адрес буфера, содержащего строковое представление дробного
числа number в десятичной форме; заметим, что этот буфер перезаписывается при
каждом вызове функции. Через параметр num_dec необходимо передать требуемое
количество десятичных знаков; при необходимости исходное число будет округлено или
дополнено нулями. В переменную по адресу dec_pos будет записана требуемая позиция
десятичной точки в возвращенной строке; при этом, если целая часть числа равна 0, то
по этому адресу будет записано отрицательное или нулевое значение. В переменную по
адресу has_sign записывается ненулевое значение для отрицательного исходного числа и
0 — в противном случае.
Таким образом, возвращаемая данной функцией строка не содержит ни
знака, ни десятичной точки; ниже рассматривается несколько примеров:
cha
r str[80]; int
dec_pos; int
has_sign;
strcpy(_fcvt(3.85, 3, &dec_pos, &has_sign));
// теперь в str содержится "3850"
// dec_pos == 1
// has_sign == 0
strcpy(_fcvt(-21.6, 1, &dec_pos, &has_sign));
// теперь в str содержится "216"
// dec_pos == 2
// has_sign != 0
strcpy(_fcvt(-0.013, 3, &dec_pos, &has_sign));
// теперь в str содержится "013"
// dec_pos <= 0
// has_sign != 0
_ecvt
char* _ecvt( double number, int num_dec, int* dec_pos, int* has_sign);
Данная функция полностью аналогична _fcvt, за исключением того, что исходное
число представляется в экспоненциальной форме. Заметим, что эта функция использует
тот же буфер, что и _fcvt.
sprintf
int sprintf( char* dest, const char* fmt, ...);
Записывает в буфер по адресу dest строку, сформированную на основании
форматирующей строки fmt и произвольного количества необязательных аргументов.
Строка fmt, помимо обычных символов, может содержать так называемые ф
орматирую щ ие посл едовател ьности . Каждая такая последовательность
соответствует одному необязательному аргументу; она начинается с символа «%» и
имеет в общем случае форму
%fw.pst
Здесь t — это один символ, определяющий тип аргумента, строковое
представление которого должно быть подставлено на место данной форматирующей
последовательности, и вид выполняемого преобразования. Это обязательная
составляющая форматирующей последовательности; допустимо использование
следующих символов:
c
ожидаемый
тип
аргумента
char
—
d
i
intили long
в десятичной системе
u
unsigned int
в десятичной системе
o
unsigned int
в восьмеричной системе
x
unsigned int
X
unsigned int
e
double
E
double
f
double
в шестнадцатеричной системе, буквы a…f
строчные
в шестнадцатеричной системе, буквы A…F
заглавные
в экспоненциальной форме, буква e
строчная
в экспоненциальной форме, буква E
заглавная
в десятичной форме
g
double
G
double
p
void*
s
char*
t
вид преобразования
в наиболее компактной форме, буква e
строчная
в наиболее компактной форме, буква E
заглавная
в шестнадцатеричной системе, буквы A…F
заглавные
параметр интерпретируется как строка
C/C++
Необязательная часть f форматирующей последовательности определяет
выравнивание преобразованного аргумента, необходимость отображения его знака, etc,
и может состоять из одного или нескольких символов, перечисленных ниже:
симво
значени
л
е
преобразованный аргумент выравнивается по левому краю (по умолчанию — по
правому)
+
знак отображается при любом значении аргумента (по умолчанию — только при
отрицательном)
0
«лишние» позиции заполняются символом «0» (по умолчанию — пробелом)
пробе при положительном значении аргумента на месте знака выводится пробел
л
o к преобразованному аргументу добавляется префикс «0»
x к преобразованному аргументу добавляется префикс «0x»
#
X к преобразованному аргументу добавляется префикс «0X»
e
E преобразованный аргумент будет содержать десятичную точку даже при
f отсутствии дробной части
преобразованный аргумент будет содержать десятичную точку даже при
g отсутствии дробной части;
G при необходимости дробная часть дополняется незначащими нулями
Необязательная составляющая w задает требуемую минимальную ширину
преобразованного аргумента; заметим, что аргумент будет выведен полностью, даже
если заданное значение окажется недостаточным.
Необязательная составляющая p определяет точность представления
аргумента; ее интерпретация зависит от типа этого аргумента:
т
p
ип
e
E
требуемое количество знаков после десятичной точки; при
необходимости
f
выполняется округление аргумента или дополнение его
дробной части незначащими нулями
g
G
максимальное количество значащих цифр
s
максимальное количество символов аргумента, которое следует
использовать
Необязательная составляющая s «уточняет» размер целочисленного аргумента
и может быть одним из следующих символов:
симво размер
л
аргумента
l
long
h
short
Если в формируемую строку необходимо вставить символ «%», то его следует
написать два раза подряд. Ниже приведен пример использования функции sprintf:
char str[80];
sprintf(str, "My name is %s. I am %i years old.", "Elijah", 31);
// теперь в str содержится "My name is Elijah. I am 31 years old."
Функции для работы со строками и символами
Функция
Пояснение
strlen(имя_строки) определяет длину указанной строки, без учёта нуль-символа
Копирование строк
strcpy(s1,s2)
выполняет побайтное копирование символов из строки s2 в строку s1
strncpy(s1,s2, n)
выполняет побайтное копирование n символов
строку s1. возвращает значения s1
из строки s2 в
Конкатенация строк
strcat(s1,s2)
объединяет строку s2со строкой s1. Результат сохраняется в s1
strncat(s1,s2,n)
объединяет n символов строки s2 со строкой s1. Результат
сохраняется в s1
Сравнение строк
strcmp(s1,s2)
сравнивает строку s1 со строкой s2 и возвращает результат типа int: 0
–если строки эквивалентны, >0 – если s1<s2, <0 — если s1>s2 С
учётом регистра
strncmp(s1,s2,n)
сравнивает n символов строки s1 со строкой s2и возвращает результат
типа int: 0 –если строки эквивалентны, >0 – если s1<s2, <0 — если
s1>s2 С учётом регистра
stricmp(s1,s2)
сравнивает строку s1 со строкой s2 и возвращает результат типа int: 0
–если строки эквивалентны, >0 – если s1<s2, <0 — если
s1>s2 Без учёта регистра
strnicmp(s1,s2,n)
сравнивает n символов строки s1 со строкой s2и возвращает результат
типа int: 0 –если строки эквивалентны, >0 – если s1<s2, <0 — если
s1>s2 Без учёта регистра
Функция
Пояснение
Обработка символов
isalnum(c)
возвращает значение true,
и falseв других случаях
если с является буквой или цифрой,
isalpha(c)
возвращает значение true, если с является буквой, и false в других
случаях
isdigit(c)
возвращает значение true, если с является цифрой, и false в других
случаях
islower(c)
возвращает значение true, если с является буквой нижнего регистра,
и falseв других случаях
isupper(c)
возвращает значение true, если с является буквой верхнего регистра,
и falseв других случаях
isspace(c)
возвращает значение true, если с является пробелом, и false в других
случаях
toupper(c)
если символ с, является символом нижнего регистра, то функция
возвращает преобразованный символ с в верхнем регистре, иначе
символ возвращается без изменений.
Функции поиска
strchr(s,c)
поиск первого вхождения символа с в строке s. В случае удачного
поиска возвращает указатель на место первого вхождения
символа с. Если символ не найден, то возвращается ноль.
strcspn(s1,s2)
определяет длину начального сегмента строки s1, содержащего те
символы, которые не входят в строку s2
strspn(s1,s2)
возвращает длину начального сегмента строки s1, содержащего
только те символы, которые входят в строку s2
strprbk(s1,s2)
Возвращает указатель первого вхождения любого символа строки s2
в строке s1
Функции преобразования
atof(s1)
преобразует строку s1в тип double
atoi(s1)
преобразует строку s1в тип int
atol(s1)
преобразует строку s1 в тип longint
Функции стандартной библиотеки ввода/вывода <stdio>
getchar(с)
Функция
gets(s)
считывает символ с со стандартного потока ввода, возвращает
символ в формате int
Пояснение
считывает поток символов со стандартного устройства
ввода в строку sдо тех пор, пока не будет нажата клавиша ENTER
2. Многомерные массивы
Объявление, ввод и вывод двумерного массива
Объявление int A[n] создает в памяти одномерный массив: набор
пронумерованных элементов, идущих в памяти последовательно. Но можно создать и
массив массивов следующим образом: int A[n][m]. Данное объявление создает массив из
n объектов, каждый из которых в свою очередь является массивом типа int [m]. Тогда A[i],
где i принимает значения от 0 до n-1 будет в свою очередь одним из n созданных обычных
массивов, и обратиться к элементу с номером j в этом массиве можно через A[i][j].
Подобные объекты (массивы массивов) также называют двумерными массивами.
Двумерные массивы можно представлять в виде квадратной таблицы, в которой первый
индекс элемента означает номер строки, а второй индекс – номер столбца. Например,
массив A[3][4] будет состоять из 12 элементов и его можно записать в виде
A[0][0]
A[0][1]
A[0][2]
A[0][3]
A[1][0]
A[1][1]
A[1][2]
A[1][3]
A[2][0]
A[2][1]
A[2][2]
A[2][3]
Объявить динамический двумерный массив из n строк и m столбцов мы
можем следующим образом:
i
nt n, m;
cin>>n;
cin>>m;
int ** A = new int
*[n]; for(int i=0; i<n; i++)
A[i] = new int[m];
Для считывания, вывода на экран и обработки двумерных массивов необходимо
использовать вложенные циклы. Первый цикл – по первому индексу (то есть по всем
строкам), второй цикл – по второму индексу, то есть по всем элементам в строках.
Например, если мы хотим записать в массив таблицу умножения, то есть
присвоить элементу A[i][j]значение i*j, это можно сделать следующим образом:
for(i=0;i<
n;i++)
for(j=0;j<m;j++)
A[i][j]=i*j;
Например, вывести на экран двумерный массив в виде таблицы, разделяя
элементы в строке одним пробелом можно следующим образом:
for(int i=0;i<n;i++)
{
// Выводим на
экран строку i for(int
j=0;j<m;j++)
cout<<A[i][j]<<" ";
cout<<endl; // Строка завершается символом перехода на новую
строку
}
А считать двумерный массив с клавиатуры можно при помощи еще более
простого алгоритма (массив вводится по строкам, то есть в порядке,
соответствующему первому примеру):
for(i=0;i<
n;i++)
for(j=0;j<m;j++)
cin>>A[i][j];
Рассмотрим более сложную задачу и несколько способов ее решения. Пусть дан
квадратный двумерный массив int A[n][n]. Необходимо элементам, находящимся на
главной диагонали проходящей из левого верхнего угла в правый нижний (то есть тем
элементам A[i][j], для которых i==j) присвоить значение 1, элементам, находящимся
выше главной диагонали – значение 0, элементам, находящимся ниже главной
диагонали – значение 2. То есть получить такой массив (пример для n==4):
1000
2100
2210
2221
Рассмотрим несколько способов решения этой задачи. Элементы, которые лежат
выше главной диагонали – это элементы A[i][j], для которых i<j, а для элементов ниже
главной диагонали i>j. Таким образом, мы можем сравнивать значения i и j и по ним
определять значение A[i][j]. Получаем следующий алгоритм:
for(i=0;i<
n;i++)
for(j=0;j<n;j++)
{
if (i<j)
A[i][j]=0;
else if(i>j)
A[i][j]=2;
else
A[i][j]=1;
}
Данный алгоритм плох, поскольку выполняет одну или две инструкции if для
обработки каждого элемента. Если мы усложним алгоритм, то мы сможем обойтись
вообще без условных инструкций.
Сначала заполним главную диагональ, для чего нам понадобится один цикл:
for(i
=0;i<n;i++)
A[i][i]=1;
Затем заполним значением 0 все элементы выше главной диагонали, для чего
нам понадобится в каждой из строк с номером iприсвоить значение элементам A[i][j]
для j=i+1, ..., n-1. Здесь нам понадобятся вложенные циклы:
for(i=0;i<n;i
++)
for(j=i+1;j<n;j++)
A[i][j]=0;
Аналогично присваиваем значение 2элементам A[i][j]для j=0, ..., i-1:
for(i=0;i<
n;i++)
for(j=0;j<i;j++)
A[i][j]=2;
Можно также внешние циклы объединить в один и получить еще одно, более
компактное решение:
for(i=0;i<n;i++)
{
// Заполняем строку с
номером i for(j=0;j<i;j++)
A[i][j]=2;
// Сначала пишем 2 ниже диагонали A[i][j]=1; //
После завершения предыдущего цикла i==j,
пишем 1
for(j++;j<n;j++)
// Цикл начинаем с увеличения
j на 1 A[i][j]=0; // Записываем 0 выше диагонали
}
Многомерные массивы
Можно объявлять не только двумерные массивы, но и массивы с большим
количеством измерений. Например, объявление int A[n][m][l] создает трехмерный массив
из n*m*l элементов. Для обращения к каждому элементу такого массива необходимо
указать три индекса: A[i][j][k], при этом 0<=i, i<n, 0<=j, j<m, 0<=k, k<l. Количество
измерений в массиве может быть практически бесконечным (т.е. достаточным для
решения любых практических задач).
Форматирование чисел при выводе
Допустим, мы заполним массив таблицей умножения: A[i][j]=i*j как в примере в
начале раздела. Если мы теперь попробуем вывести этот массив на экран, разделяя
элементы в строке одним пробелом, то из-за того, что числа имеют различную длину
столбцы таблицы окажутся неровными:
0000000000
0123456789
0 2 4 6 8 10 12 14 16 18
0 3 6 9 12 15 18 21 24 27
Для того, чтобы получить ровные столбцы необходимо, выводить числа так,
чтобы одно выводимое число имело ширину, например, ровно в 3 символа, а "лишние"
позиции были бы заполнены пробелами. Тогда получится следующая таблица:
0
0
0
0
0
1
2
3
0
2
4
6
0 0 0 0 0
3 4 5 60 70
6
8 10 12
8 149
9 12 15 18 21
24
16 18
27
Для того, чтобы выводимое число или строка имело ровно заданную ширину,
необходимо перед выводом его на экран для потока coutвызвать метод widthс
параметром 3. Данный метод устанавливает ширину поля для выводимого значения.
Получим следующую программу для вывода:
for(int i=0;i<n;i++)
{
for(int j=0;j<m;j++)
{
cout.wi
dth(3);
cout<<A[i][j];
}
cout<<endl;
}
Заметим, что мы теперь не выводим пробел после каждого числа, поскольку мы
добавили этот пробел к ширине выводимого поля. Функция width действует однократно,
только на
следующее выводимый в поток значение, поэтому ее нужно вызывать перед
каждым выводом числа на экран.
Внимание! Если выводимое число или строка имеет большую длину, чем это
было установлено функцией width, то это число или строка будут выведены полностью,
а не будет обрезано до указанного значения. То есть предпочтительней вывести результат
некрасиво, нежели неверно.
1. Полезные алгоритмы по нахождению чисел
Рассмотрим реализации известных вам задач, а также новые алгоритмы.
Делители числа
Нам нужно найти все делители числа N и вывести их на экран.
Самое очевидное решение это перебрать все числа от 1 до N и проверить, делится
ли N на это число. Сложностью такого подхода будет O(N), потому что мы перебираем
все числа от 1 до N. Для N меньших 10^8 все в принципе неплохо, такое решение работает
менее чем за секунду. Но для N>10^12 такой алгоритм может работать днями
(проверьте).
Попробуем сократить перебор. Можно заметить, что после N/2 уже делителей N
уже не будет. Тогда сложность уменьшается в два раза и составляет O(N/2). Это конечно
уже лучше, но для N>10^8 разница будет не сильно ощутима.
Тогда заметим еще одну вещь: можно перебирать числа только до sqrt(N)
[квадратного корня из N]. Это обуславливается тем, что для каждого числа х, которое
является делителем N, число N/x тоже является делителем N. Максимальным числом, до
которого можно вести перебор, будет x, такой что x*x=N; x=sqrt(N).
Для ускорения перебора часто не проверяются чётные делители, кроме числа 2,
а также делители, кратные трём, кроме числа 3. При этом тест ускоряется в три раза, так
как из каждых шести последовательных потенциальных делителей необходимо
проверить только два, а именно вида 6·k±1, где k — натуральное число.
Теперь сложность стала O(sqrt(N)), и вот такой алгоритм хорош для чисел N
порядка 10^16. Стоит отметить, что деление работает долго, поэтому в жизни такой
алгоритм применим к числам порядка 10^14. Мы же считаем, что каждая операция
выполняется за О(1), хотя в жизни это не так.
Код реализации: http://pastebin.com/zZNwkXBh
Простые числа. Решето Эратосфена.
Часто при решении задач возникает необходимость в использовании простых
чисел. Напомню, простым называется число, которое делится только на себя и на
единицу. Так 2, 3, 5, 7, 389 простые числа, а 1, 4, 9, 12 нет.
Как же быстро подсчитать для каждого числа от 1 до N, является ли оно
простым? Для этого существует довольно простой алгоритм, названный решетом
Эратосфена. Основная идея заключается в следующем: пусть на каком-то шаге мы
встретили простое число. Тогда все числа, который на него делятся, уже не являются
простыми.
Можем их вычеркнуть.
Реализация.
Пусть у нас есть массив prime[1..N].
prime[i]=true если i-простое и false в противном случае.
Начнем перебирать числа от 2 до N. Если мы встретили еще не
вычеркнутое число (prime[i]=true) значит мы встретили простое число.
Теперь начнем перебирать j до N, такие что j делится на i и вычеркивать
эти числа (prime[j]=false).
Таким образом получается такой код, результатом выполнения которого
является заполненный массив prime.
Код реализации:
http://pastebin.com/LRhA6Rbd - с массивом, http://pastebin.com/rRbM1Muy - без
массива.
Примечания:
Перебор j начинается с i*i. Это связано с тем, что числа вида i*(i-k) где k>0 мы
перебрали до этого.
Так как мы можем брать довольно большие i их квадраты могут не влазить в int,
поэтому лучше использовать long long ~10^18;
Асимптотика.
Сложность данного алгоритма O( n * log ( log(n)));
Вполне применимо для чисел порядка 10^7, дальше лучше не идти, так как
заводится массив от 1 до n и компилятор может ругаться.
А вообще, есть хорошая оптимизация решета. Суть ее в том, что мы вычеркиваем
числа только для тех простых, квадрат которых <= N. Поэтому можно не проверять
каждый раз квадрат числа, а просто разнести решето на два цикла: для которых
вычеркиваем и для которых нет.
Визуализация:
http://habrastorage.org/storage1/6b8f3222/4acc55aa/91db537b/0696d73c.gif
http://habrastorage.org/storage1/aaedfeb9/ca14dcb9/13d60baf/7e201ac1.gif
2. Рекурсивные алгоритмы
Рекурсия - вызов подпрограммы из самой себя. Проще говоря, когда вы в
функции заходите в нее же, это рекурсия.
Что надо помнить при написании подобных функций:
 глубина рекурсии должна быть не очень большой ибо либо память
сожрете, либо ТЛЕ (Time limit exeeded) получите;
 должен существовать выход из рекурсии (это важно).
Самый простой пример: посчитать факториал, то есть n!=1*2*3*...*n
Суть в следующем: пусть функция fact(n) возвращает n! Тогда
справедливо, что n!=fact(n-1)*n;
Теперь надо придумать выход из рекурсии. Очевидно, что чем глубже мы
уходим, тем меньше становится n. Меньшим можно сделать ноль, при нем возвращать
единицу (0!=1).
Алгоритм Евклида – Наибольший Общий Делитель
Этот алгоритм позволяет находить наибольший общий делитель двух чисел а и
б.
long long gcd(long long a, long long b) {
return b == 0 ? a : gcd(b, a % b);
}
Также вы можете встретить реализацию алгоритма Евклида не с использованием
взятия остатка, а с постепенным вычитанием а-б. Очевидно это асимптотический
инвалид, поэтому никогда так не делайте.
Кстати, асимптотика данного алгоритма О(log(min(a,b)), что очень и очень
хорошо.
Наряду с вычислением НОД часто необходимо найти НОК. Формула
a*b/gcd(a,b). Но так как мы не хотим переполнений, надо писать a / gcd(a,b)*b.
Быстрое (бинарное) возведение в степень.
На несколько минут представим, что память компьютера нас больше не
сдерживает, мы наконец-то можем считать огроменные числа не боясь что-то сломать.
Тогда давайте попробуем возвести число а в степень n. Асимптотика O(n).
Теперь попробуем улучшить алгоритм.
Заметим, что a^n = a^(n/2) * a^(n/2) верно для четных n.
Для нечетных n все легко сводится к четному a^n =
a^(n-1) * a. Последним замечанием будет a^0=1.
Мы нашли рекурсивный способ вычисления a^n работающий за O(log(n)).
Вот его реализация:
http://pastebin.com/GhE6RhNX
http://pastebin.com/Q1H6sweM - без рекурсии.
Длинная арифметика
Несмотря на то, что задачи на «длинку» сегодня практически не встречаются, те
кто придумывают задачки нашли как это обойти. Все расчеты производятся по модулю
какого-то большого числа. Допустим это самое число равно 7. Тогда число 5 по модулю
будет равно 5, число 10 = 3, число 49 = 0. Тогда ответ никогда не будет превышать 6.
Часто бывает необходимо посчитать большое число и вывести его по модулю M, где Мбольшое простое число, например 10^9+7. Алгоритм бинарного возведения в степень по
модулю: http://pastebin.com/AUHZcWPw
Бинарный поиск
Распространенный алгоритм поиска в упорядоченных множествах. Наглядно
представим, что мы ищем точку Y на графике F(x), который всегда неубывает (при всегда
невозрастании смысл не изменится), пример такого графика моей кривой рукой снизу.
Мы знаем, что эта точка расположена в отрезке [L; R]. Давайте возьмем среднюю точку
M=(R+L) /2 и тогда возможно три случая :
1)
F(M)=Y; Да это же прелестно, мы нашли Y прямо в серединке!
2)
F(M)<Y; Что-то мы мало взяли, наверное Y правее
3)
F(M)>Y; Слишком далеко, Y где-то левее
Такие умозаключения мы можем делать по причине постоянного неубывания. А
давайте тогда повторим все те же самые действия в той половине, где мы предполагаем
найти Y.
 если Y слева, то в [L, M];
 если Y справа, то в [M, R];
 если мы вдруг оказались в ситуации, когда левая граница отрезка
совпала с правой, или заехала за нее, то алгоритм надо останавливать, Y мы не
нашли.
Наиболее удачный пример объяснения алгоритма бинарного поиска это игра в
"Угадай число". Вы высказываете версию, а загадавший говорит больше загаданное
число или меньше. Используя алгоритм бинарного поиска вы сможете отгадать
загаданное число максимум за Log (R-L), где L,R - диапазон возможных значений. То
есть для чисел от 1 до 1000 вам понадобится не больше 10 вопросов.
В программировании удачно применяется для поиска элементов в
отсортированном массиве или для нахождения решения в какой-нибудь постоянно себя
ведущей функции.
Самое частое употребление - поиск элемента в отсортированном массиве.
Заострю внимание на обязательной отсортированности массива, только в этом случае он
ведет себя как монотонная функция, а аргументов является индекс в массиве.
Код реализации: http://pastebin.com/z5xSkWM6 - для одномерного массива.
Бинарный поиск по монотонной функции
Допустим, что по условию нам дана некая монотонная функция f(x). Задача найти значение аргумента x на отрезке [a, b], при котором мы получим интересующее нас
значение функции.
Если x может принимать только целые значения, то можно попробовать
перебрать, а если дробным может быть? Что тогда?
Здесь самое время вспомнить о том, что наша функция монотонна. То есть она
или только растет на рассматриваемом участке, или только убывает.
В любом случае одно значение функции будет лишь для одного значения
аргумента. Для других x мы точно можем определить, больше или меньше для них
значение функции, чем в искомой точке.
Допустим, что функция монотонно возрастает. Найдем середину
рассматриваемого отрезка m = (a + b) / 2 и посмотрим на f(m).
Здесь у нас может быть 3 случая:
1)
f(m) - то самое значение с учетом необходимой точности, которое
мы искали. В таком случае m - наш ответ, мы решили задачу.
2)
f(m) меньше искомого значения. В таком случае очевидно, что для
всех x из отрезка [a, m] значения f(x) будут также меньше.
Тогда отбросим этот отрезок из рассмотрения и будем искать ответ
только на отрезке [m, b].
3)
f(m) больше искомого значения. Это случай аналогичен пункту 2, с
единственным различием - ответ мы будем искать на отрезке [a, m].
Для нового отрезка будем искать ответ по такому же
алгоритму. Заметим, что:
1)
Алгоритм корректен и всегда найдет ответ, если он есть.
Ну я думаю очевидно, что на каждом шаге мы оставляем лишь отрезок с
ответом, поэтому ответ мы всегда получим.
А завершится алгоритм потому, что в какой-то момент сам размер
отрезке будет меньше, чем заданная нами точность.
2)
Алгоритм найдет ответ за O(log(Len) * T(f)), где Len - длина отрезка
[a, b], T(f) - время вычисления функции f.
Почему это так? Просто на каждом шаге мы уменьшаем рассматриваемый
отрезок в 2 раза, так как от целого отрезка оставляем лишь часть до/после середины.
Ну а как известно, делить на 2 мы можем только log_2 раз.
Также замечу, что при работе с вещественными границами достаточно
останавливаться не при достижении определенной окрестности ответа, а при завершении
некоторого количества итераций алгоритма. Почему? Просто в компьютере
вещественные числа занимают определенное количество бит, а на каждом шаге мы
улучшаем ответ на один бит. Соответственно, после итераций по всем битам мы уже
ничего не сможем изменить - слишком малые изменения там будут. В принципе, можно
делать не больше 70 итераций, т.к. в double всего 64 бита.
Также стоит помнить, что если на отрезке существует 2 и более ответов,
неизвестно, какой из них будет найден и будет ли найден вообще.
Код реализации простейшего бинпоиска по функции: http://pastebin.com/JPfVqXev
Точность также надо выбирать аккуратно, если не уверены - лучше протестить
на разных значениях и тестах и выбрать лучшее.
Примечание. Мы рассматривали случаи, где функции заданы явно и
вычисляются за O(1). Но обычно в задачах такого нет. Часто функция имеет вид функции
от неизвестного параметра и, например, массива или просто отрезка чисел. В этом случае
поиск нужного нам значения параметра называется "бинпоиск по ответу".
То есть мы берем одно значение параметра и проверяем, подходит ли оно нам.
Если подходит, запоминаем и продолжаем уже с этим знанием; не подходит - перебираем
следующий. Причем проверка может иметь самую разную асимптотику, то есть часто
бинпоиск - лишь добавочный log(N) в асимптотике алгоритма.
3. Быстрые сортировки
Quicksort
Быстрая сортировка - один из самых лучших алгоритмов сортировки и один из
самых коротких по коду.
Метод основан на подходе "разделяй-и-властвуй". Общая схема такова:
1. из массива выбирается некоторый опорный (pivot) элемент a[i],
2. запускается процедура разделения массива, которая перемещает все
ключи, меньшие, либо равные a[i], влево от него, а все ключи, большие, либо
равные a[i] - вправо,
3. теперь массив состоит из двух подмножеств, причем левое
меньше, либо равно правого,
4. для обоих подмассивов: если в подмассиве более двух
элементов, рекурсивно запускаем для него ту же процедуру.
В конце получится полностью отсортированная
последовательность. Псевдокод.
quickSort ( массив a, верхняя граница N ) {
Выбрать опорный элемент p (середину массива) Разделить
массив по этому элементу
Если подмассив слева от p содержит более одного элемента,
вызвать quickSort для него.
Если подмассив справа от p содержит более одного элемента,
вызвать quickSort для него.
}
Важный момент: многие источники утверждают, что надо брать средний элемент
(r-l+1) div 2. Нет. Никогда так не делайте, существуют тесты, на которых такой подход
работает за O(n^2) где n размер массива. Другими словами, решение с такой сортировкой
при хороших тестах будет получать вердикт ТЛЕ.
Правильно писать random(r-l+1)+l. Это надежно, проверено и не валится.
В языках(c++, java) существуют встроенные сортировки. Это приятно, но лучше
уметь писать и ручками, чтобы при определенных условиях можно было поправить чтолибо.
Асимптотика O(n*lg(n)), что значит что мы можем использовать такую
сортировку на массивах размером аж 10^5 (Пузырек от силы поднимал 1000)
Алгоритмы сортировки приходится использовать невероятно часто.
Обоснование корректности и асимптотики:
Здесь могли бы быть строгие математические доказательства с кучей формул, но
вряд ли они вам когда-нибудь пригодятся))
Быстрая сортировка работает по принципу "разделяй и властвуй" - довольно
известный подход к решению многих задач.
Алгоритм квиксорта состоит из двух основных частей:
1)
Выбирается "опорный" (pivot) элемент, и массив разбивается по
следующему правилу:
Все элементы, меньше или равные опорному элементу, должны иметь
меньший индекс, чем опорный, а больше - больший индекс. Заметим, что мы не
требуем упорядоченности элементов слева и справа от опорного, мы лишь задаем для
них верхнюю/нижнюю границу.
2)
Алгоритм быстрой сортировки запускается для части массива,
меньшей опорного, затем - для большей. Соответственно, выход из рекурсии мы
осуществим, когда сортировать будет нечего - мы доберемся до пустого массива.
Почему это работает? Заметим, что когда мы выполним пункт 1, то пропадает
необходимость в обменах элементов, меньших и больших опорного.
Следовательно, когда мы отсортируем эти две части массива, то относительно
друг друга они уже будут находиться в правильном положении.
Теперь асимптотика. Каждое разбиение массива мы будем производить за O(N)
- будем пробегать массив двумя указателями, один будет искать элемент, больший
опорного с индексом, меньшим индекса опорного, второй - меньший опорного с большим
индексом.
Когда оба указателя найдут такие элементы, нам остается только переставить
местами эти элементы и продолжить двигать указатели до их пересечения.
В этом случае каждый указатель сделает максимум N перемещений (мы же по
массиву его двигаем, к тому же только в одну сторону).
Но у нас остается вопрос - а сколько раз мы будем делать такие разбиения
массива? Если посмотреть на наш алгоритм, то можно заметить, что в среднем
он всегда делит массив на 2 примерно равные части, то есть длина сортируемого
массива уменьшается примерно вдвое при каждом заходе в рекурсию.
Максимум таких делений будет log_2(N), а на каждом уровне мы будем делать
разбиения нескольких массивов, в сумме пробегая все равно целый массив длины N.
Отсюда мы сделаем O(N * log(N)) операций, что и требовалось показать.
Код реализации: http://pastebin.com/G1Xtazb6
Визуализация: http://www.sorting-algorithms.com/quick-sort
Merge sort – сортировка слиянием
Здесь опишем другой вид сортировки - сортировку слиянием. На случайных
тестах она проигрывает быстрой сортировке в 3-4 раза по времени, но обладает одним
большим преимуществом - время ее работы не зависит от значений массива, ей важна
лишь количество элементов - ее нельзя "завалить" особым тестом.
Сортировка слиянием также построена на принципе "разделяй-и-властвуй",
однако реализует его несколько по-другому, нежели Quicksort. А именно, вместо
разделения по опорному элементу массив просто делится пополам.
Если в быстрой сортировке мы сначала разбивали массив, а потом уже работали
как бы с несколькими независимыми массивами, то здесь подход будет
противоположным - мы будем поэтапно "собирать" массив из сортированных половинок.
Алгоритм сортировки слиянием ("мержсорта") выглядит так:
1)
Сортируем рекурсивно левую и правую половинки массива.
2)
Производим "слияние" половинок в полностью отсортированный
массив.
Выход из рекурсии - опять же, как и в случае с квиксортом, попытка
отсортировать пустой массив.
Понятно, что главная сложность здесь - это быстро "слить" половинки.
Один из способов состоит в слиянии двух упорядоченных
последовательностей при помощи вспомогательного буфера, равного по размеру
общему количеству имеющихся в них элементов. Элементы последовательностей
будут перемещаться в этот буфер по одному за шаг.
merge ( упорядоченные последовательности A, B , буфер C ) {
пока A и B непусты {
cравнить первые элементы A и
B переместить наименьший в буфер
}
если в одной из последовательностей еще есть
элементы дописать их в конец буфера, сохраняя имеющийся
порядок
}
Результатом является упорядоченная последовательность, находящаяся в
буфере. Каждая операция слияния требует n пересылок и n сравнений, где n - общее число
элементов
Итак, слияние будем делать следующим образом:
В самом начале - и это очень важно – переместим элементы левой и правой
половин в два массива left и right, с которыми и будем далее работать в слиянии. Это
необходимо делать, так как в ином случае мы будем затирать одни элементы исходного
массива другими.
Создадим два указателя - на текущий элемент в левой и правой
половинках. Теперь будем идти по массиву и делать следующую
операцию:
 Если текущий элемент левой половинки меньше или равен, то ставим
его на текущее место в итоговом массиве, увеличиваем на 1 указатель левой
половинки.
 Если текущий элемент правой половинки меньше - делаем
аналогичную вещь для правой половинки.
 Если же у нас ситуация, когда в одной из половинок перебрали все
элементы, то просто на оставшиеся места переписываем еще не пройденные
элементы второй половинки.
В итоге мы будем получать каждый раз уже
отсортированный массив. Покажем правильность:
Допустим, что мы совершили слияние половинок, и у нас элемент a[i] > a[j], но i
< j.
1)
a[i] и a[j] были из одной половинки, то это невозможно, так как
половинки уже были отсортированы до слияния, а мы шли по ним "по
возрастанию".
2)
a[i] и a[j] из разных половинок. Без потери общности будем считать,
что a[i] был в левой половине.
Здесь мы опять же получаем противоречие. В самом деле, если a[i] > a[j], то в
левой половинке существует k (k >= 0) элементов, меньших a[i].
Также верно, что в левой же половинке существует l (l <= k) элементов, меньших
или равных a[j].
Тогда, исходя из написанного алгоритма слияния, мы должны были при
сравнении элементов left[l+1] и a[j] поставить в формируемый массив именно элемент
a[j], а уж потом left[l+1].
Так как left[l+1] <= left[k] < a[i], то ясно, что поставить элементы a[i], а затем уже
a[j] мы не могли.
Аналогично доказывается, если a[i] был в правой половине.
Теперь об асимптотике. При каждом заходе в рекурсию мы делим массив на 2,
откуда сделаем мы log_2(N) заходов в рекурсию, где N - длина исходного массива.
На каждом уровне рекурсии мы производим слияние половинок, общая длина
которых опять же равна длине массива.
Очевидно, что слияние производится за O(суммы длин левой и правой
половинок), то есть для каждого уровня рекурсии дает O(N).
Следовательно, асимптотика сортировки слиянием - O(N * log(N)), что ничуть ни
хуже квиксорта или некоторых других.
С помощью этих сортировок вы сможете спокойно сортировать массивы длины
N <= 2 * 10^5.
Для массивов большей длины обычно применяются специальные виды
сортировок, основанные на дополнительной информации о значениях элементов
массива.
Можно применить такой прием: вместо того, чтобы лишний раз проверять, не
прошли ли полностью какую-то из половинок, можно просто приписать в конец каждой
половинки очень большое число - "бесконечность".
Однако, несмотря на хорошее общее быстродействие, у сортировки слиянием
есть и серьезный минус: она требует Theta(n) памяти. Поэтому mergeSort используют
для упорядочения массивов, лишь если требуется устойчивость метода.
При этом сортировка слиянием является одним из наиболее эффективных
методов для односвязных списков и файлов, когда есть лишь последовательный доступ к
элементам.
Код реализации: http://pastebin.com/wuwxwe8b
Визуализация: http://www.sorting-algorithms.com/mergesort
4. Легкие структуры данных и их реализация
Часто бывает необходимо реализовать некоторую модель поведения в своей
программе. Сегодня будет рассмотрено две подобных модели.
Очередь
Интуитивно понятно, что такое очередь. Люди приходят в магазин, встают в
очередь и двигаются в ней. Естественно кто раньше пришел, тот раньше и обслуживается,
или FIFO
– first in, first out (первым пришел, первым ушел).
Тогда можно определить для очереди две операции: добавление элемента в конец
очереди (add) и извлечение элемента из начала очереди (pop).
Очередь реализуется с помощью массива q. Давайте введем два дополнительных
целых указателя head и tail, которые будут указывать на первый и последний элемент
очереди. Тогда что будет происходить при добавлении элемента x?
Мы подвинем правую границу еще вправо на единицу (tail++) и добавим в самый
конец элемент x (q[tail]:=x). При извлечении поступаем похожим образом : первый
элемент в очереди это q[head]. После его извлечения первым станет второй, то есть мы
можем просто увеличить head.
Код реализации:
http://pastebin.com/0g99mP0A Необходимо
подчеркнуть еще два момента:
1)
Признаком пустоты очереди будет тот факт, что head>tail, ну т.е.
левая граница уехала за правую;
2)
начальными значениями head и tail должны бить 1 и 0
соответственно. Тогда изначально очередь пуста и при добавлении первого элемента
все будет отлично.
Стек.
Стек можно представить в виде вертикальной стопки монет. Принцип теперь
LIFO – last in, first out (последним пришел – первым ушел).
Реализация тоже очень похожа, но теперь можно работать только с одним
указателем head. При добавлении элемента двигать его вправо, при удалении влево, как
бы затирая самый левый.
Код реализации: http://pastebin.com/E5Vnx8NW
Эти структуры данных будут нужны в дальнейшем при изучении графов.
Также надо сказать, что в продвинутых языках программирования подобные
структуры встроены. Например, в плюсах очередь это тип данных queue, а стек - stack.
Все равно надо уметь писать подобные вещи руками, мало ли что.
1. Динамическое программирование
Процитирую Википедию: динамическое программирование – способ решения
сложных задач путём разбиения их на более простые подзадачи.
Вкратце, динамическое программирование – это метод решения задач, в котором
ответ для нужной задачи получается с помощью ответов для таких же, но менее
объемных задач. Здесь нет какой-то особенной теории. Новичкам обычно задачи на
динамическое программирование кажутся сложными, потому что они совершенно не
представляют, как их решать. С повышением уровня все больше и больше задач на
динамическое программирование (еще называют динамика или ДП) будут казаться
легкими и писаться за несколько минут.
Итак, главным понятием в задачах на динамику является состояние. Состояние это набор каких-то значений, которыми можно охарактеризовать задачу. Например, это
может быть длина строки, количество цифр или последняя цифра в числе, количества
красных и синих шариков, или все это сразу. Т.е. состояние полностью должно описывать
задачу, которую мы хотим решить.
Вторым важным понятием является переход. Между состояниями могут быть
переходы. Например, мы добавляем к строке один символ - значит, мы перешли в новое
состояние, где длина строки на единицу больше. Или дописали к числу одну цифру
справа
- его последняя цифра изменилась (хотя, могла и остаться прежней), его сумма
цифр увеличилась (хотя могла и остаться прежней, если это был 0), наконец, его длина
увеличилась.
Чтобы задача решалась динамическим программированием, между состояниями
не должно быть циклов. В наших примерах, т.е. дописывание символов, - циклов не было,
т.к. длина строки/числа всегда увеличивалась. Так вот, нас просят найти, например,
сколько существует способов сделать что-то, чтобы было сколько-то чего-то. Как это
делать? Надо отыскать все состояния, из которых можно перейти в данное. Например,
пусть в состояние C можно прийти из состояний A и B. Значит, количество способов
попасть в состояние C равно сумме количеств способов попасть в состояния A и B.
Таким образом, чтобы найти ответ для интересующей нас задачи, нам придется
найти ответы и для всех предшествующих ей задач. Кроме того, мы обязаны знать
начальное значение динамики. Например, если мы приписываем к числу какие-то цифры
и считаем количество способов получить какие-то числа, то в качестве начального
значения можно указать, что для числа из 0 цифр существует 1 способ.
Общая схема решения задач на динамику:
1. определить подзадачи;
2. найти рекуррентное соотношение, или записать последовательность
переходов, изменяющих состояние подзадач (от начального к конечному);
3. рассмотреть и решить начальные случаи.
На примере чисел Фибоначчи, шаги 1, 2: F(n) = F(n-1) + F(n-2);
шаг 3: F(0)=0, F(1) = 1.
Примерами известных задач на динамику являются:

Вычисление чисел Фибоначчи;

Нахождение наибольшей общей подпоследовательности
https://goo.gl/Sh0FpU;

Поиск наибольшей увеличивающейся
подпоследовательности https://goo.gl/17T510;
Методы написания динамик
Существует три метода. В разных ситуациях каждый из них бывает
удобнее, поэтому полезно ознакомиться со всеми тремя.
1) Динамика "назад". То, чему обычно учат. Будем перебирать состояния в
порядке возрастания их "сложности", таким образом, когда мы должны вычислить ответ
для какого-то состояния Y, мы должны перебрать все состояния X, из которых можно
перейти в Y, и осуществить эти переходы (осуществить здесь означает сложить
количество способов или выбрать максимальный или минимальный способ, может быть,
что-то еще - в зависимости от задачи). Т.е. примерно так:
 инициализируем начальное состояние;
 for (перебираем все состояния, кроме начального);
 пусть Y - наше состояние;
 for (перебираем все состояния X, из которых можно перейти в Y);
 обновляем ответ для Y с помощью ответа для X (ведь для X уже все
посчитано!).
2) Динамика "вперед". Чаще всего самый удобный способ. Здесь мы наоборот,
перебираем все состояния, кроме конечного, и смотрим, а куда же из них можно перейти.
Состояния перебираются, опять же, "от простых к сложным".
 инициализируем начальное состояние;
 for (перебираем все состояния, кроме конечного);
 пусть X - наше состояние;
 for (перебираем все состояния Y, в которые можно перейти из X);
 обновляем ответ для Y с помощью ответа для X.
Заметим, что для любого состояния ответ можно считать окончательным только
тогда, когда мы доберемся до него во внешнем цикле. Так как мы для каждого состояния
накапливаем ответ для него по кусочкам, пока находимся в предыдущих состояниях.
3) Рекурсивная динамика. Выглядит как динамика назад и обычно применяется
в том случае, когда трудно или лень определять, в каком порядке правильно обходить
состояния. Обычно для написания динамики делаются два массива: was[x] - был ли
посчитан ответ для состояния X, и dp[X] - ответ для состояния X, если он уже посчитан.
Оформляется это все в коде как обычная рекурсивная функция, с той лишь разницей, что
мы не будем несколько раз считать одно и то же (для этого и нужен массив was). Это
уменьшает асимптотику с экспоненциальной до полиномиальной (т.е. будет работать в
100500 раз быстрее)
 инициализировать начальное состояние, поставить в него was = true,
остальные was заполнить false;
f(y):
 if was[y] return dp[y];
 was[y] = true;
 начинаем считать dp[y];
 перебрать все состояния x, из которых можно добраться до y;
 обновить ответ для y с помощью вызова f(x) (не dp[x]! ведь,
возможно, dp[x] еще не считалось!) ;
 return dp[y].
Пожалуй, это вся теория по динамике. Тут правда больше нечего сказать. Вы
придумываете состояния, как они зависят друг от друга, кодите несколько циклов for или
рекурсивную функцию и сдаете задачу. Либо не придумываете…
Примеры задач на динамику
1.1.
Сумма 1+3+4. Рассмотрим следующую задачу. Дано число n.
Найти количество различных вариантов записать n как сумму чисел 1, 3, 4.
Например, для n=5 – ответ 6. Действительно:
5 = 1 + 1 + 1 + 1 + 1;
= 1 + 1 + 3;
= 1 + 3 + 1;
= 3 + 1 + 1;
= 1 + 4;
= 4 + 1.
Определим подзадачи: пусть D(n) – количество вариантов записи числа n как
суммы 1, 3 и 4.
Найдем рекуррентное соотношение. Рассмотрим одно
возможное решение: n = x1 + x2 + … + xm.
Если xm = 1, тогда оставшаяся сумма должна равняться (n-1). Таким
образом, D(n - 1) - будет количество сумм, оканчивающихся значением xm = 1.
Аналогично рассмотрим случаи с xm =
3 и xm = 4. Рекуррентное соотношение будет
следующим D(n) = D(n-1) + D(n-3) + D(n-4).
Запишем начальные случаи
D(0) = 1; D(n) = 0 для всех отрицательных
n. Альтернативным вариантом будет следующая
запись D(0) = D(1) = D(2) = 1; D(3) = 2.
Теперь можем найти любое D(n), вот код:
D[0] = D[1] = D[2] = 1; D[3] = 2;
for(i = 4; i <= n; i++)
D[i] = D[i-1] + D[i-3] + D[i-4];
Действительно – короткое решение в 3 строчки!
Но как мы можем решить эту задачу для больших значений n ~ 10^12? Можно
воспользоваться способом, аналогичным для быстрого вычисления чисел Фибоначчи,
которые мы можем записать в матричной форме следующим образом:
Таким образом, для вычисления n-го числа Фибоначчи надо возвести матрицу в
степень n. Это мы можем сделать за O(log n) действий (благодаря быстрому бинарному
возведению в степень). Таким образом n-е число Фибоначчи можно легко вычислить за
O(log n) с использованием целочисленной арифметики.
1.2.
Сумма по модулю. Рассмотрим еще одну простую задачу на
динамику. Посчитать количество чисел длины n (лидирующие нули
допускаются), у которых сумма цифр равна k по модулю p. Напишем динамику
вперед, т.к. это здесь наиболее удобно. Попробуйте написать остальные два
способа самостоятельно.
Состоянием будет пара (длина числа, сумма цифр в числе).
dp[0][0] = 1; // начальное значение. остальные dp заполнены нулями можно легко увидеть, что решение будет идеально работать в этом случае
for (int i = 0; i < n; i++) { // перебираем текущую длину числа
for (int s = 0; s < p; s++) { // перебираем текущую сумму цифр в числе
// итак, есть число длины i с суммой цифр s по модулю p. таких у нас dp[i][s]
штук. допишем к нему что-нибудь в конец. например, одну цифру
for (int d = 0; d <= 9; d++) { // перебираем цифру, которую будем дописывать в
конец
// новое число будет иметь длину i+1 и сумму цифр по модулю p, равную
(s+d)%p dp[i+1][(s+d)%p] += dp[i][s]; // это и есть переход. теперь мы знаем, что
те
числа можно получить еще и вот так.
}
}
}
cout << dp[n][k];
А если бы лидирующие нули не допускались, то такой переход не получилось
бы осуществить?
Тогда можно сделать начальные состояния как dp[1][1%p]=1, dp[1][2%p]=1, ...
dp[1][9%p]=1 (т.е. поставить первую цифру на свое место), а затем запустить тот же
самый код, только начиная с i=2.
Или можно просто проверить длину на 1 и постановку 0.
1.3.
Доминошки. Сколькими способами можно замостить
прямоугольник 3xn без пропусков с помощью плиток домино (размером
2x1)?
Для начала решим аналогичную задачу для прямоугольника 2xn.
Рассмотрим элементарные варианты заполнения. Например, два следующих
симметричных заполнения будут различными вариантами (пара одинаковых букв – одна
доминошка):
AAC CAA
BBC CBB
Таким образом, у нас могут быть 2 стартовых случая:
1) *****AA
2) *****C
*****BB
*****C
В первом случае звездочками обозначен прямоугольник размером 2x(n-2), во
втором 2x(n-1). Искомое количество вариантов D(n) = D(n-1) + D(n-2). Ничего не
напоминает? Конечно, это же числа Фибоначчи!
Теперь – случай 3xn. Опять обозначим через D(n) количество способов
замостить доску. Попробуем записать рекуррентное соотношение…
Похоже, лучше начать с начала (не забываем, что доска прямоугольная, и
доминошки не должны вываливаться за пределы). Тогда возможные расклады
представятся следующим образом:
D(n)
= D(n-2) + G(n-1) +
G(n-1)
********
AA******* AA******
A*******
******** = BB******* + B******* + A*******
********
CC******* B*******
BB******
G(n) =
D(n-1)
+
G(n-2)
********
A******** AA*******
******** = A******** + BB*******
*******
********
CC******
Начальные значения: D(0)=1, D(1)=0, G(0)=0,
G(1)=1. Прогоните алгоритм решения задачи на следующих
тестах: D(2) = 3; D(8) = 153; D(12) = 2131.
1.4.
Наибольшая общая подпоследовательноть. Даны две строки x
и у. Найти наибольшую общую подпоследовательность символов (LCS –
longest common subsequence) и вывести ее длину.
Например:
x:
ABCBDAB
y: BDCABC
Общая подпоследовательность, найденная в строках - «BCAB», т.о. ответ –
4.
Данная задача, в отличие от предыдущих, относится к классу «двумерных»
задач ДП. Определим подзадачи: пусть D[i, j] – длина LCS для подстрок x{1..i} и
y{1..j}.
Найдем рекуррентность:

если xi = yi, то они обе входят в LCS: D[i, j] = D[i-1, j-1] + 1;

иначе одна из строк не входит в LCS и может быть отброшена:
D[i, j] = max {D[i-1, j],
D[i, j-1]}; Начальные значения: D(i, 0)
= D(0, j) = 0. Код решения:
for(i = 0; i <= n; i++) D[i][0] = 0;
for(j = 0; j <= m; j++) D[0][j] = 0;
for(i = 1; i <= n;
i++) { for(j = 1; j <= m; j++)
{
if(x[i] == y[j])
D[i][j] = D[i-1][j-1] + 1;
else
D[i][j] = max(D[i-1][j], D[i][j-1]);
}
}
1.5.
Дополнение до палиндрома. Дана строка x = x{1..n}. Найти
минимальное число символов, которое нужно добавить, чтобы превратить x в
палиндром.
Например, x=“Ab3bd”. Можем получить палиндромы “dAb3bAd” или
“Adb3bdA”, добавив 2 символа (одну ‘d’, одну ‘А’).
Определим подзадачи: пусть D[i, j] – минимальное число символов,
которые необходимо, чтобы превратить x в палиндром.
Найдем рекуррентные соотношения:
Пусть y{1..k} – палиндром кратчайшей длины, содержащий
x{i..j}. Выполняется условие: либо y[1]=x[i], либо y[k]=x[j].
y{2…k-1} – оптимальное решения для x{i+1..j}, или для x{i...j-1}, или для
x{i+1...j-1} (последний случай выполняется, если y[1]=y[k]=x[i]=x[j]).
D[i, j] = {
1 + min{ D[i+1, j], D[i+1, j-1]}, если
x[i] != x[j]; D[i+1, j-1],
если x[i] =
x[j].
}
Начальные значения: D[i, i] = D[i, i-1] = 0 для всех i. Значения D заполняются в
возрастающем порядке для (j-i).
Альтернативное решение: D[i, j] = n – L, где L – длина LCS для двух строк – x и
перевернутой x.
2. Структуры данных в STL (stack, queue, deque, vector)
Для структур данных в языках программирования существуют готовые
контейнеры. Приведу названия из стандартной библиотеки шаблонов С++ (STL –
Standard Template Library) и Java.
C+
+ / Java
stack /
Stack
queue /
Queue
deque / ArrayDeque
priority_queue /
PriorityQueue vector /
ArrayList
Стэк
(stack)
– структура данных, обрабатывающая элементы по
принципу
«последним пришел, первым ушел» (LIFO - Last in, First Out). Подключить
библиотеку:
#include <stack>
Операции стека, выполняемые за константное время O(1):
push(x) – добавление элемента в
стэк; pop() – выталкивание последнего
элемента; top() – просмотр последнего
элемента.
Пример кода: http://pastebin.com/dmEyA09U
Очередь (queue) – структура данных, обрабатывающая элементы по
принципу
«первым пришел, первым ушел» (FIFO – First in, First Out). Подключить
библиотеку:
#include <queue>
Операции очереди, выполняемые за константное время O(1):
enqueue(x) – добавление элемента
в очередь; dequeue() – выталкивание
первого элемента; front() – просмотр
первого элемента.
Пример кода: http://pastebin.com/1vBhSjr6
Дек (deque – double ended queue) - двусторонняя очередь. Позволяет за O(1)
осуществлять вставку и удаление элементов.
#include <deque>
Операции контейнера deque:
front – возврат значения первого
элемента; back – возврат значения
последнего элемента; push_front –
добавление элемента в начало; push_back –
добавление элемента в конец; pop_front –
удаление первого элемента; pop_back –
удаление последнего элемента;
size – возврат числа элементов дека;
clear – очистка дека.
Пример кода: http://pastebin.com/5hjFPFZH
Приоритетная очередь (priority_queue) – очередь, в которой каждый элемент
имеет собственное значение приоритета.
#include <queue>
Пример кода: http://pastebin.com/K1ui3t6R
Вектор (vector) – замена стандартного динамического массива,
предоставляет дополнительный функционал для обработки элементов.
#include <vector>
Операции вектора:
push_back(x) - добавление нового элемента в конец вектора, расширяя его
на один элемент;
size() - количество элементов;
pop_back() — удалить
последний элемент; clear() — удалить
все элементы вектора; empty() —
проверить вектор на пустоту.
Примеры кода: http://pastebin.com/cD8r9Htt, http://pastebin.com/dSLgSfpR
Вектор мы можем заполнять действительно динамически (без
предварительного указания размера):
vector< string > vec;
Затем добавляем к нему элементы при помощи различных функций,
например, с помощью push_back().
Если мы определили вектор некоторого размера, например:
vector<int> vec_a ( 10 );
то вставка элементов через push_back() увеличивает его размер, добавляя
новые элементы к существующим.
Если хочется изменить размер вектора, используется метод resize(n). После
вызова метода resize(n) вектор будет содержать ровно n элементов. Если параметр n
меньше, чем размер вектора до вызова resize(n), то вектор уменьшится и «лишние»
элементы будут удалены. Если же n больше, чем размер вектора, то вектор увеличит свой
размер и заполнит появившиеся элементы нулями.
Для очистки вектора (как и любого другого контейнера STL) предназначен
метод clear(). После вызова clear() контейнер окажется пустым, т. е. будет содержать ноль
элементов. Будьте аккуратны: clear() не обнуляет все элементы контейнера, но
освобождает весь контейнер целиком.
Примечание! В некоторых версиях компилятора при вызове метода clear()
память не освобождается. Это может привести к переполнению памяти в некоторых
задачах. Можете проверить у себя следующий код (у меня на gcc 4.7.1 память не
очищалась):
vector <int> v;
v.reserve(15);
cout<<v.capacity()<<endl; //выведет 15
v.clear();
cout<<v.capacity()<<endl; //выведет 0, если очистилось
Для гарантированного освобождения используется swap trick:
vector<int>().swap(v);
Для
того, чтобы
проинициализировать
все
элементы
вектора
при создании значениями по
умолчанию, следует передать конструктору второй параметр:
vector<string> names(20, "Unknown");
Также
очень важно бывает создать
многомерный
массив. Сделать это
с использованием векторов можно при помощи
следующей конструкции:
vector< vector<int> > Matrix;
// Помните о лишних пробелах между угловыми скобками!
Сейчас вам должно быть понятно, как указать размер матрицы при создании:
int N, M;
...
vector< vector<int> > Matrix(N, vector<int>(M, -1));
Вышеприведённая
конструкция
создаёт
матрицу
с
N строками
и M столбцами.
Изначально матрица будет заполнена значениями -1.
При использовании векторов следует помнить об одной очень важной
особенности работы с памятью в STL. Основное правило здесь можно сформулировать
таким образом: контейнеры STL всегда копируются при любых попытках передать их в
качестве параметра.
Таким образом, если вы передаёте вектор из миллиона элементов функции,
описанной следующим образом:
void some_function(vector<int> v) {
// Старайтесь никогда так не делать
...
}
то весь миллион элементов будет скопирован в другой, временный, вектор,
который будет освобождён при выходе их функции some_function. Если эта функция
вызывается в цикле, о производительности программы можно забыть сразу.
Если вы не хотите, чтобы контейнер создавал клон себя каждый раз при вызове
функции, используйте передачу параметра по ссылке. Хорошим тоном считается
использование при этом модификатора const, если функция не намерена изменять
содержимое контейнера.
void some_function(const vector<int>& v) {
// OK
...
}
Если содержимое контейнера может измениться по ходу работы
функции, то
модификатор const писать не следует:
int modify_vector(vector<int>& v) {
// Так
держать
v[0]++;
}
Правило копирования данных применимо ко всем контейнерам STL без
исключения.
Часто используется функция reserve(n). Как уже было сказано, вектор не
выделяет по
одному новому элементу в памяти на каждый вызов push_back(). Вместо этого,
при вызове push_back(), вектор выделяет больше памяти, чем реально требуется. В
большинстве
реализаций
при
необходимости
выделить
лишнюю
память, vector увеличивает объём выделенной памяти в два раза. На практике это бывает
не очень удобно. Наиболее простой способ обойти эту проблему заключается в
использовании метода reserve. Вызов метода reserve(size_t n) выделяет дополнительную
память в будущее пользование vector. Параметр n имеет следующий смысл: вектор
должен выделить столько памяти, чтобы вплоть до размера в n элементов
дополнительных операций выделения памяти не потребовалось.
Рассмотрим следующий пример. Пусть имеется vector из 1 000 элементов, и
пусть объём выделенной им памяти составляет 1 024 элемента. Мы собираемся добавить
в него 50 элементов при помощи метода push_back(). Если вектор расширяется в два раза,
его размер в памяти по завершении этой операции будет составлять 2 048 элементов, т.
е. почти в два раза больше, чем это реально необходимо. Однако, если перед серией
вызовов метода v.push_back(x) добавить вызов
v.reserve(1050);
то память будет использоваться эффективно. Если вы активно спользуете
push_back(), то reserve() -- ваш друг.
3. Алгоритмы STL
Теперь мы можем осуществить краткое введение в стандартные алгоритмы STL.
Большая часть алгоритмов STL построена по единому принципу. Алгоритм получает на
вход пару итераторов -- интервал. Если алгоритм осуществлял поиск элемента, то будет
возвращен либо итератор, указывающий на соответствующий элемент, либо конец
интервала. Конец интервала уже не указывает ни на какой элемент, что очень удобно.
find(iterator begin, iterator end, some_typy value) осуществляет поиск элемента в
интервале. find() возвращает итератор, указывающий на первый найденный элемент,
либо end, если подходящих элементов найдено не было.
vector<int> v;
for(int i = 1; i <= 100;
i++) { v.push_back(i*i);
}
if(find(v.begin(), v.end(), 49) != v.end()) {
// число 49 найдено в списке квадратов натуральных чисел,
// не превосходящих 100
}
else {
// число 49 не найдено
}
Чтобы получить значение, итератор необходимо разыменовать. Это не
актуально для
алгоритма find(), но будет активно использоваться в дальнейшем.
Если контейнер поддерживает итераторы произвольного доступа, то можно
найти индекс найденного элемента. Для это из значения, которое вернул алгоритм, нужно
вычесть начало интервала:
int i = find(v.begin(), v.end(), 49) - v.begin(); if(i
< v.size()) {
// 49 найдено
assert(v[i] == 49);
}
Напомним, что для использования стандартных алгоритмов STL следует
подключать
модуль algorithm.
Алгоритмы min_element и max_element в пояснениях не нуждаются:
int data[5] = { 1, 5, 2, 4, 3 };
vector<int> X(data, data+5);
// Значение
int v1 = *max_element(X.begin(), X.end());
// Индекс
int i1 = min_element(X.begin(), X.end()) - X.begin());
int v2 = *max_element(data, data+5);
// Либо же просто в массиве
int i3 = min_element(data, data+5) - data;
В рамках введения в экстремальное программирование следует
присмотреться к следующему макросу:
#define all(c) c.begin(),c.end()
(скобки вокруг правой части здесь ставить ни в коем
случае нельзя!) Также часто используется алгоритм быстрой
сортировки:
sort(iterator begin, iterator end).
vector<int> X;
...
sort(X.begin(), X.end()); // Стандартный
вызов sort(all(X)); // Почувствуйте разницу
sort(X.rbegin(), X.rend()); // Сортировка в обратном порядке
Пример кода: http://pastebin.com/m32p4ZWD
1. Графы - введение
Граф - это абстрактный математический объект. Он состоит из вершин и ребер.
Каждое ребро соединяет пару вершин. Если одну и ту же пару вершин соединяют
несколько ребер, то эти ребра называются кратными. Ребро, соединяющее вершину с ней
самой, называется петлей. По ребрам графа можно ходить, перемещаясь из одной
вершины в другую. В зависимости от того, можно ли по ребру ходить в обе стороны, или
только в одну, различают неориентированные и ориентированные графы соответственно.
Ориентированные ребра называются дугами. Если у всех ребер графа есть вес (т.е.
некоторое число, однозначно соответствующее данному ребру), то граф называется
взвешенным. Вершины, соединенные ребром, называются соседними. Для
неориентированного графа степень вершины - число входящих в нее ребер. Для
ориентированного графа различают степень по входящим и степень по исходящим
ребрам. Граф называется полным, если между любой парой различных вершин есть
ребро.
Граф - объект абстрактный, и интерпретировать его мы можем по-разному, в
зависимости от конкретной задачи. Рассмотрим пример. Пусть вершины графа - города,
а ребра - дороги, их соединяющие. Если дороги имеют одностороннее движение, то граф
ориентированный, иначе неориентированный. Если проезд по дорогам платный, то граф
взвешенный.
На бумаге граф удобно представлять, изображая вершины точками, а ребра линиями, соединяющими пары точек. Если граф ориентированный, на линиях нужно
рисовать стрелочку, задающую направление; если граф взвешенный, то на каждом ребре
необходимо еще надписывать число - вес ребра.
Давайте пронумеруем вершины в любом порядке. Теперь будем двигаться по
ребрам из вершины в вершину, записывая в какой вершине мы находимся в данный
момент. Такую последовательность вершин будем называть путем.
Циклом будем называть путь, по которому можно прийти из вершины в нее же.
Дерево - это граф без циклов. Кстати, можно заметить, что если в таком графе N
вершин, то в нем обязательно N-1 ребро.
Есть несколько способов представления графа в памяти компьютера.
Массив ребер.
Пусть в графе M ребер. Заведем массив размером Mx2, в котором будем
хранить ребра парами вершин, которые они соединяют. Это наиболее понятный, но
достаточно неудобный способ хранения графа. Однако у него есть один большой
плюс - при таком способе представления легко вводить дополнительные
характеристики ребер. Например, чтобы сохранить веса ребер, достаточно сделать
массив размером Mx3 и в дополнительную ячейку для каждого ребра записать его вес.
Матрица смежности.
Есть граф на N вершин. Тогда можно задать матрицу NxN, в которой элемент (i,j)
равен 1, если существует ребро, соединяющее вершины i и j, и 0 в противном случае.
Для взвешенных графов (i,j) = d, где d = весу ребра (i,j) если оно существует и
бесконечности в другом случае.
(Бесконечность это большое число, несоразмеримое с ограничениями в задаче).
Минус метода - невозможность представления матрицы для 10^5 вершин.
Матрицу смежности можно использовать на количестве вершин до 10^4, но лучше сразу
учитесь писать списки.
Еще один из недостатков такого представления: в матрице смежности
невозможно хранить взвешенный граф с кратными ребрами. Однако, в некоторых
случаях, это можно обойти. Очень часто из всего множества ребер между данной парой
вершин нам достаточно
хранить
только
одно
самое
легкое.
Рассмотрим несколько свойств матрицы смежности.
В матрице смежности графа без петель на главной диагонали стоят 0.
Матрица смежности неориентированного графа
симметрична относительно главной диагонали.
Примечание. Формальное определение графа.
Граф - множество V вершин и набор E неупорядоченных и упорядоченных
пар вершин; обозначается граф через G(V,E).
[Математическая энциклопедия, том 1, Москва, "Советская энциклопедия", 1977]


Списки смежности
Каждой вершине поставим в соответствие список ее соседей. Если есть ребра
из 1 вершины во 2 и 4, то список первой вершины будет состоять из чисел 2 и 4.
Этот метод менее требователен к памяти (что естественно). Т.к. используется
работа с динамическими массивами, так что не удивляйтесь, что список соседей
нумеруется с 0. Если вы хотите узнать второго соседа 5 вершины, вы должны смотреть
a[5][1].
Вершина выхода Вершины входа
1
5
2
6
3
2, 5
4
5
5
1, 4
6
2
2. BFS – поиск в ширину
Начнем с поиска в ширину (BFS – breadth first search). Поиск в ширину решает
следующую задачу: дан невзвешенный граф (т.е. все ребра одинаковой длины) и вершина
v, найти кратчайшие расстояния от вершины v до всех остальных вершин.
Т.е. требуется найти путь от одной вершины графа до другой, причем путь
должен быть минимальным по количеству ребер.
Будем двигаться от вершины v как бы постепенно: сначала обходим всех ее
соседей (т.е. посетим все вершины, расстояние до которых = 1), затем всех еще
непосещенных соседей всех вершин, расстояние до которых = 1 (до них расстояние будет
равно 2) и т.д. Попутно для каждой вершины можно сохранять номер вершины, из
которой в нее пришли - так можно будет восстанавливать кратчайшие пути.
Для реализации используется очередь и массив расстояний (ну и массив предков,
если нужно восстанавливать путь). Очередь в каждый момент времени хранит вершины,
которые мы еще не посетили, но собираемся посетить. Массив расстояний просто хранит
величину кратчайшего пути, если вершина уже обработана, и бесконечность в противном
случае.
Разделим все вершины на три множества:

Полностью обработанные вершины (изначально множество
пусто, на рисунке обозначено черным цветом)

Вершины, до которых известно расстояние (изначально в
множестве только одна вершина — начальная, на рисунке обозначено серым
цветом)

Вершины, про которые ничего не известно (изначально — все
вершины, кроме начальной, на рисунке обозначено белым цветом)
Очевидно, что, как только все вершины черные, работа алгоритма завершена.
Будем хранить все серые вершины в очереди и поддерживать следующее свойство:
расстояния до всех серых вершин в том порядке, в котором они лежат в очереди,
монотонно не убывают.
Достанем первую вершину из очереди (обозначим ее v). Для каждого из ее
соседей w возможен один из двух вариантов:
1.
w — черная или серая вершина. В таком случае, мы не
получаем никакой новой информации.
2.
w — белая вершина. Тогда расстояние до нее равно d(w) = d(v) + 1.
И, поскольку мы узнали расстояние, w становится серой вершиной
Повторяем до тех пор, пока есть хотя бы одна серая вершина.
Асимптотика алгоритма - O(M), где M - число ребер в графе. Действительно,
каждая вершина в цикле будет рассматриваться ровно один раз, так как мы не кладем
вершины в очередь, если они уже там или если они уже обработаны. А для каждой
вершины мы по одному разу смотрим каждое ребро, исходящее из нее.
Предполагается, что граф хранится в массиве vector<vector<int>> edges, причем
edges[v] содержит номера всех вершин, к которым есть ребро от v. Также предполагается,
что в глобальной переменной start хранится номер начальной вершины.
void BFS() {
queue<int> q; // Инициализация: есть информация про начальную вершину
q.pu
sh(start);
d[start] = 0;
mark[start] = 1;
// Главный цикл - пока есть серые вершины
while (!q.empty()) {
// Берем
первую из них int v
= q.front(); q.pop();
// Пробегаемся по всем ее соседям
for (int i = 0; i < (int)edges[v].size(); ++i) {
// Если сосед белый
if (mark[edges[v][i]] == 0) {
// То вычисляем расстояние
d[edges[v][i]] = d[v] + 1;
// И он становится
серым mark[edges[v][i]] =
1; q.push(edges[v][i]); } } }
}
Предлагаю подумать, как можно использовать массив p для восстановления
кратчайшего пути между в ершинами start и finish.
Восстановление пути
Пусть мы знаем кратчайшие расстояния до всех вершин. Как найти кратчайший
путь до конкретной вершины? Рассмотрим эту вершину (обозначим ее V). Пусть длина
пути до нее L. Эта вершина является последней в пути к V. Найдем какую-нибудь
вершину, которая соединена с V, и расстояние до которой равно L-1. Тогда эта вершина
является предпоследней в пути (если таких вершин несколько, значит существует
несколько кратчайших путей и можно выбрать любую из этих вершин). Далее найдем
вершину, которая соединена с предпоследней и расстояние до которой равно L-2. Она
является пред-предпоследней. Продолжая этот процесс мы можем "раскрутить" весь путь
задом наперед. Осталось только запомнить его в массиве и вывести в правильном
порядке.
3. DFS – поиск в глубину
Рассмотрим алгоритм поиска в глубину (DFS – Depth First Search). Требуется
найти путь из вершины A в вершину B.
Предполагается, что граф хранится в массиве vector<vector<int>> edges, причем
edges[v] содержит номера всех вершин, к которым есть ребро от v. Также предполагается,
что в глобальной переменной finish хранится номер конечной вершины.
void DFS(int v, int from) {
if (mark[v] != 0) // Если мы здесь уже были, то тут больше делать нечего
{ return; }
mark[v] = 1; // Помечаем, что мы здесь были
prior[v] = from; // Запоминаем, откуда пришли
if (v == finish) // Проверяем, конец ли
{ cout << "Hooray! The path was found!\n";
return; }
for (int i = 0; i < (int)edges[v].size(); ++i) // Для каждого ребра
{ DFS(edges[v][i], v); // Запускаемся из соседа } }
Тогда задача восстановления пути будет тривиальной:
vector<int> get_path() {
vector<int> ans;
for (int v = finish; v != start; v = prior[v]) // Проходим по пути из конца в начало
{ ans.push_back(v); // Запоминаем вершину
}
ans.push_back(start);
reverse(ans.begin(), ans.end()); // Переворачиваем путь
return ans; }
Любой рекурсивный алгоритм можно переписать в нерекурсивном виде. Вот код
для DFS:
void DFS() {
stack<int> s;
s.push(start);
while (!s.empty()) {
int v = s.top();
s.pop();
for (int i = 0; i < edges[v].size(); ++i) {
if (mark[edges[v][i]] == 0) {
s.push(edges[v][i]);
mark[edges[v][i]] = 1; } } } }
4. Алгоритм Флойда-Уоршелла - поиск кратчайших путей
Пусть дан взвешенный граф с N вершинами и M ребрами. Требуется найти
кратчайшие расстояния между всеми парами. Эта задача может быть решена с помощью
алгоритма Флойда.
Для реализации алгоритма Флойда – Уоршелла сформируем матрицу смежности
D[][] графа G=(V, E), в котором каждая вершина пронумерована от 1 до |V|. Эта матрица
имеет размер |V|´|V|, и каждому ее элементу D[i][j] присвоен вес ребра, идущего из
вершины i в вершину j. По мере выполнения алгоритма, данная матрица будет
перезаписываться: в каждую из ее ячеек внесется значение, определяющее оптимальную
длину пути из вершины i в вершину j (отказ от выделения специального массива для этой
цели сохранит память и время). Теперь, перед составлением основной части алгоритма,
необходимо разобраться с содержанием матрицы кратчайших путей. Поскольку каждый
ее элемент D[i][j] должен содержать наименьший из имеющихся маршрутов, то сразу
можно сказать, что для единичной вершины он равен нулю, даже если она имеет петлю
(отрицательные циклы не рассматриваются), следовательно, все элементы главной
диагонали (D[i][i]) нужно обнулить. А чтобы нулевые недиагональные элементы
(матрица смежности могла иметь нули в тех местах, где нет непосредственного ребра
между вершинами i и j) сменили по возможности свое значение, определим их равными
бесконечности, которая в программе может являться, например, максимально возможной
длинной пути в графе, либо просто – большим числом.
Ключевая часть алгоритма, состоя из трех циклов, выражения и условного
оператора, записывается довольно компактно:
Для k от 1 до |V|
выполнять Для i от 1 до
|V| выполнять Для j от 1
до |V| выполнять
Если D[i][k]+D[k][j]<D[i][j] то D[i][j] ←D[i][k]+D[k][j]
Кратчайший путь из вершины i в вершину j может проходить, как только через
них самих, так и через множество других вершин k∈(1, …, |V|). Оптимальным из i в j
будет путь или не проходящий через k, или проходящий. Заключить о наличии второго
случая, значит
установить, что такой путь идет из i до k, а затем из k до j, поэтому должно
заменить, значение кратчайшего пути D[i][j] суммой D[i][k]+D[k][j].
Рассмотрим полный код алгоритма Флойда – Уоршелла
#include "stdafx.h"
#include
<iostream> using
namespace std; const
int maxV=1000; int i, j,
n;
int GR[maxV][maxV];
//алгоритм ФлойдаУоршелла void FU(int
D[][maxV], int V){
int k;
for (i=0; i<V; i++) D[i][i]=0;
for (k=0;
k<V; k++) for (i=0;
i<V; i++) for (j=0;
j<V; j++)
if (D[i][k] && D[k][j] && i!=j)
if (D[i][k]+D[k][j]<D[i][j] || D[i][j]==0)
D[i][j]=D[i][k]+D[k][j];
for (i=0; i<V; i++){
for (j=0; j<V; j++)
cout<<D[i][j]<<"\t"; cout<<endl;
}
}
void main() {
setlocale(LC_ALL, "Rus");
cout<<"Количество вершин в графе > "; cin>>n;
cout<<"Введите матрицу весов ребер:\n";
for (i=0; i<n;
i++) for (j=0; j<n; j++)
{
cout<<"GR["<<i+1<<"]["<<j+1<<"] > ";
cin>>GR[i][j];
}
cout<<"Матрица кратчайших
путей:"<<endl; FU(GR, n);
}
Оглавление
1. ...............................................................................................................................Г
рафы – Алгоритм Дейкстры ............................................................................................ 1
2. ...............................................................................................................................Г
рафы – Алгоритм Форда-Беллмана ................................................................................. 2
Упражнения 12.1 – Дейкстра .................................................................................... 3
Задача 1. Разминка ................................................................................................. 3
Задача 2. Алгоритм Дейкстры ............................................................................... 4
Задача 3. Заправки .................................................................................................. 4
Задача 4. Автобусы ................................................................................................ 5
Упражнения 12.2 – Форд-Беллман ........................................................................... 6
Задача 1. Форд-Беллман ........................................................................................ 6
Задача 2. Лабиринт знаний .................................................................................... 7
Задача 3. Цикл ........................................................................................................ 8
1. Графы – Алгоритм Дейкстры
Пусть дан взвешенный граф с N вершинами и M ребрами. Требуется найти
кратчайшие расстояния от данной вершины до всех остальных. Эта задача может быть
решена с помощью алгоритма Дейкстры.
Заметим, что алгоритм Дейкстры работает только в графах, веса ребер которых
неотрицательны.
Обозначим начальную вершину X.
Проследим за ходом работы этого алгоритма. В каждый момент времени у нас
будут 3 множества вершин:
1) те вершины, до которых мы нашли кратчайшее расстояние
2) вершины, до которых мы знаем текущее кратчайшее расстояние, за
которое мы до этой вершины можем дойти, но еще не знаем, является ли оно
минимальным в целом.
3) вершины, ни одного пути до которых (и ни одного расстояния
соответственно) нам не известно.
В начале работы алгоритма первое множество состоит из единственной вершины
- начальной, до которой мы знаем расстояние (оно равно нулю); второе множество
состоит из соседей начальной вершины, а третье включает в себя все остальные вершины.
На каждом шаге мы будем совершать следующие действия:

Найдем минимум из расстояний от начальной вершины до
вершин из второго множества и запомним вершину второго множества, на
которой этот минимум достигается (обозначим ее Р)

Перенесем вершину Р из второго множества в первое

Переберем всех соседей вершины Р. Обозначим текущего соседа, с
которым мы работаем, U.
Заметим, что в данный момент мы нашли путь из вершины Х в вершину
U: это путь из вершины Х в вершину Р, дополненный ребром (Р,U).
1. Если U лежит в третьем множестве, то мы должны
перенести вершину U во второе множество, а расстояние до него
посчитать как длину пути, описанного выше.
2. Если U лежит во втором множестве, то сравнив длину
нового найденного нами пути и ранее известное расстояние до этой
вершины, запишем вместо ранее известного расстояния
минимальное из них
3. Если сосед U лежит в первом множестве, то с ним нам
делать ничего не нужно, ведь до него наилучшее расстояние уже
найдено, и с помощью любых дополнительных проверок улучшить его
не удастся.
Алгоритм заканчивает свою работу, когда во втором множестве не останется
вершин.
Заметим, что за каждый шаг работы алгоритма мы перемещаем одну вершину из
второго множества в первое, и, возможно, добавляем вершины из третьего множества во
второе. Так как вершин в третьем множестве конечное число, то алгоритм закончит свою
работу. После окончания работы алгоритма второе множество будет пустым, в первом
множестве будут все вершины, до которых существует кратчайшее расстояние, а в
третьем множестве останутся вершины, попасть в которые из начальной невозможно (а
значит, расстояние до них равно бесконечности).
Хранить множества лучше всего с помощью массива из N элементов, где
соответствующий элемент равен 0, если вершина лежит в первом множестве, 1 - если во
втором и 2 - если в третьем .
Восстановление пути
Заметим, что если кратчайший путь до вершины v проходит через вершину u,
то часть этого пути до вершины u является кратчайшим путем до вершины u. Значит,
чтобы сохранить путь до всех вершин достаточно для каждой вершины хранить
предыдущую в кратчайшем пути до нее.
2. Графы – Алгоритм Форда-Беллмана
Пусть дан взвешенный ориентированный граф с N вершинами и M ребрами.
Требуется найти кратчайшие расстояния от данной вершины s до всех остальных. Эта
задача может быть решена с помощью алгоритма Форда-Беллмана.
В основе алгоритма Форда-Беллмана лежит метод динамического
программирования. Пусть wi,j - длина дуги из вершины i в вершину j или бесконечность,
если такой дуги нет. Пусть An,m - длина кратчайшего пути из начальной вершины в
вершину m, проходящего не более чем по n ребрам. Пусть k - предыдущая вершина в
этом пути. Тогда An,m=An-1,k + wk,m. Значит An,m = min(An-1,m, min{1<=i<=N}(An1,i+wi,m)) (*).
Значения A0,i равны бесконечности для всех i, отличных от s, A0,s=0.
Будем считать Ai,j в порядке неуменьшения i, а при равных i - в порядке
увеличения j. Тогда для вычисления i-й строки матрицы A нам необходимо знать только
i-1-ую ее строку. Ответом на нашу задачу является N-я строка, значит, на каждом шаге
можно хранить только предыдущую строку матрицы.
При такой реализации алгоритм будет выполнять порядка N3 операций.
Заметим, что при вычислении следующей строки матрицы по предыдущей
каждую дугу графа мы рассмотрели ровно 1 раз. Воспользуемся этим, чтобы немного
ускорить наш алгоритм. Пусть у нас известна n-1 строка матрицы A. Скопируем ее в nую строку. Далее рассмотрим все дуги нашего графа (x,y) и изменим An,y согласно
формуле: An,y = min(An,y,An-1,x+wx,y) (**). Теперь у нас посчитана n-я строка матрицы
A.
При такой реализации алгоритм выполнит порядка NM действий.
Восстановление пути
Пусть в формуле (*) в ходе работы алгоритма Форда-Беллмана минимум
достигается при i=k или в формуле (**) на дуге (m,k). Тогда запишем в dm значение k.
В конце работы алгоритма мы получим, что для каждой вершины i в di хранится номер
предыдущей вершины в кратчайшем пути от s до i. По массиву d легко восстановить
путь.
Нахождение вершин, до которых существует сколь угодно короткий путь
Сравним an,i и a2n,i. Если an,i > a2n,i, то до вершины i существует сколь угодно короткий
путь. Иначе - нет.
Оглавление
1................................................................................................................................ К
онтейнеры STL ................................................................................................................. 1
2................................................................................................................................ П
ары объектов Pair ............................................................................................................. 2
3................................................................................................................................ И
тераторы ............................................................................................................................ 3
4................................................................................................................................ М
ножество элементов Set ................................................................................................... 7
5................................................................................................................................ А
ссоциативный контейнер Map ...................................................................................... 11
Упражнения 13.1 ..................................................................................................... 12
1. Контейнеры STL
Каждый раз, когда в программе возникает необходимость оперировать
множеством элементов, в дело вступают контейнеры. Контейнер -- это практическая
реализация функциональности некоторой структуры данных. В языке C (не в C++)
существовал только один встроенный тип контейнера: массив. Сам по себе массив имеет
ряд недостатков: к примеру, размер динамически выделенного массива невозможно
определить на этапе выполнения.
Однако основной причиной для более внимательного ознакомления с
контейнерами STL является отнюдь не вышеперечисленные недостатки массива.
Истинная причина кроется несколько глубже. Дело в том, что в реальном мире структура
данных, информацию о которых необходимо хранить, далеко не всегда удачно
представима в виде массива. В большинстве случаев требуется контейнер несколько
иной функциональности.
К примеру, нам
потребоваться
д
«множество
поддерживающая
следую анных
функции:
может
структура
строк»,
-добавить
щие с
к
множеству;
-удалить
троку строку
и
множества;
-определить,
присутствует
ли
в
з
рассматриваемом
множестве
данная
строка;
-узнать
количество различных строк
в
рассматриваемом
множестве;
-- просмотреть всю структуру данных, «пробежав» все присутствующие строки.
Конечно,
легко
запрограммировать
тривиальную
реализацию
функциональность подобной структуры данных на базе обычного массива. Но такая
реализация будет крайне неэффективной. Для достижения приемлемой
производительности имеет смысл реализовать хэш-таблицу или сбалансированное
дерево, но задумайтесь: разве реализация подобной структуры данных (хэш либо дерево)
зависит от типа хранимых объектов? Если мы потом захотим использовать ту же
структуру не для строк, а, скажем, для точек на плоскости -- какую часть кода придётся
переписывать заново?
Реализация подобных структур данных на чистом C оставляла программисту
два пути.
1) Жёсткое решение (Hard-Coded тип данных). При этом изменение типа
данных приводило к необходимости внести большое число изменений в самых
различных частях кода.
2) По возможности сделать обработчики структуры данных независимыми от
используемого типа данных. Иными словами, использовать тип void* везде, где это
возможно.
По какому бы пути реализации структуры данных в виде контейнера вы не
пошли, скорее всего, никто другой ваш код понять, и тем более модифицировать, будет
не в состоянии. В лучшем случае другие люди смогут им просто пользоваться. Именно
для таких ситуаций существуют стандарты - чтобы программисты могли говорить друг с
другом на одном и том же формальном языке.
Шаблоны (Templates) в C++ предоставляют замечательную возможность
реализовать контейнер один раз, формализовать его внешние интерфейсы, дать
асимптотические оценки времени выполнения каждой из операций, а после этого просто
пользоваться подобным контейнером с любым типом данных. Можете быть уверены:
разработчики стандарта C++ так и поступили. В первой части курса мы на практике
познакомимся с основными концепциями, положенными в основу контейнеров C++.
2. Пары объектов Pair
В качестве введения к данной лекции поговорим о «парах» объектов в STL -std::pair. Пара -- это просто шаблонная структура, которая содержит два поля,
возможно,
различных типов. Поля имеют названия first и second. В максимально
краткой форме прототип пары может выглядеть следующим образом:
template<typename T1, typename T2>
struct pair { T1 first;
T2 second;
};
К примеру, pair<int,int> есть пара двух целых чисел. Более сложный
пример: pair<string, pair<int,int> > -- строка плюс два целых числа. Использовать
подобную пару можно, например, так:
pair<string, pair<int,int> >
P; string s = P.first; // Строка
int x = P.second.first; // Первое целое
int y = P.second.second; // Второе целое
Основной причиной к использованию pair является то, что объекты pair можно
сравнивать. Поэтому, при всей кажущейся простоте, пары активно используются как
внутри библиотеки STL, так и программистами в своих целях.
Сравнение пар предоставляет широкие возможности для экстремального
программирования. Массив пар можно упорядочить, при этом упорядочивание будет
производиться по полям в порядке описания пар слева направо.
Например, необходимо упорядочить целочисленные точки на плоскости по
полярному углу. Одним из простых решений является поместить все точки в структуру
вида
vector< pair<double, pair<int,int> >
где double -- полярный угол точки, а pair<int,int> -- её координаты. После этого
один вызов стандартной функции сортировки приведёт к тому, точки будут упорядочены
по полярному углу.
3. Итераторы
Пришло время поговорить об итераторах. В максимально общем смысле,
итераторы -- это универсальный способ доступа к данным в STL. Однако автору
представляется совершенно необходимым, чтобы программист, использующий STL,
хорошо понимал необходимость в итераторах.
Рассмотрим следующую задачу. Дан массив A длины N. Необходимо изменить
порядок следования элементов в нём на обратный («развернуть массив на месте»).
Начнём издалека, с решения на чистом C.
void reverse_array_simple(int *A, int N) {
// индексы элементов, которые мы
// на данном шаге меняем
местами int first = 0, last = N-1;
while(first < last) { // пока есть что переставлять
// swap(a,b) --- стандартная функция
STL swap(A[first], A[last]);
first++; // смещаем левый индекс
вправо last--; // а правый влево
}
}
Пока ничего сложного в этом коде нет. Его легко переписать, заменив
индексы на указатели:
void reverse_array(int *A,
int N) { int *first = A, *last = A+N1; while(first < last) {
swap(*first,
*last); first++;
last--;
}
}
Рассмотрим основной цикл данной функции. Какие операции над
указателями он выполняет? Всего лишь следующие:
 сравнение указателей (first < last),
 разыменование указателей (*first, *last),
 инкремент и декремент указателя (first++, last-)
Теперь представим, что, решив эту задачу, нам необходимо развернуть на
месте двусвязный список или его часть.
Первый вариант функции, в котором использовались индексы в массиве,
работать, конечно, не будет. Если даже написать функцию обращения к элементам списка
по индексу, о производительности и/или экономии памяти можно забыть.
Обратим теперь внимание на то, что второй вариант функции, который
использует указатели, может работать с любыми объектами, которые обеспечивают
функциональность указателя. А именно:
сравнение,
разыменование,
инкремент/декремент. В языке C уже есть удобный, привычный многим и проверенный
временем синтаксис для непрямого обращения к данным: нам лишь осталось подставить
вместо указателя объект, который умеет делать то же самое.
Именно этот подход используется в STL. Конечно, для контейнера
типа vector итератор -- это почти то же самое, что и указатель. Но для более сложных
структур данных, например, для красно-чёрных деревьев, универсальный интерфейс
просто необходим.
Итак, какую функциональность должен обеспечивать итератор? Примерно ту же,
что и обычный указатель:
 разыменование (int x = *it);


просто

инкремент/декремент (it1++, it2-);
сравнение (об этом
речь
пойдёт
позже; пока
скажем,
что это операции == и !=)
добавление константы (it += 20 -- сдвинуться на 20 элементов
вперёд);
 расстояние между итераторами (int dist = it2-it1);
Язык C++ предоставляет необходимые средства для создания произвольного
объекта, который сможет вести себя именно таким образом.
Итак, какую функциональность должен обеспечивать итератор для двусвязного
списка, чтобы наша функция reverse_array смогла функционировать? Более узкую, чем
указатель, а именно: разыменование, инкремент/декремент, сравнение.
Следует привести более подробное пояснение. Конечно, не для каждого типа
контейнера в итераторе возможно эффективно реализовать ту или иную функцию. Строго
говоря, базисными являются для итератора только следующие операции:
*
разыменование (*it);
*
инкремент (++);
*
сравнение (==).
В нашем примере с двусвязным списком, мы также предполагаем, что для его
итератора определена операция -. Конечно, о добавлении константы, о сравнении двух
итераторов на «больше/меньше» и, тем более, о вычислении разности между итераторами
в двусвязном списке не может быть и речи.
В отличие от обычных указателей, итераторы могут также нести много другой
полезной нагрузки. В качестве основных примеров, не вдаваясь в подробности, следует
отметить
проверку выхода
за
границы массива
(Range
Checking) и
статистику использования контейнера (Profiling).
Основное преимущество итераторов, бесспорно, заключается в том, что они
помогают систематизировать код и повышают коэффициент его повторного
использования. Один раз реализовав некоторый алгоритм, использующий итераторы, его
можно использовать с любым типом контейнера. Можете быть уверены: разработчики
STL так и сделали, поэтому большую часть алгоритмов писать не придётся. С другой
стороны, если вам необходимо реализовать свой тип контейнера, реализуйте ещё и
механизм итераторов -- и широкий спектр алгоритмов, как стандартных, так и авторских,
будет сразу доступен.
На самом деле, не всем итераторам необходимо поддерживать
всю
функциональность. В STL пошли по следующему пути: итератор поддерживает те
операции, которые он может выполнить за O(1), т. е. независимо от размеров контейнера
и параметров. Это означает, к примеру, что для итераторов по двусвязному списку,
операции сравнения (<, >) и арифметические операции (it += offset или shift = it2-it1) не
применимы. Действительно, время работы этих операций зависит как от размеров
контейнера, так и от параметров. С другой стороны, операции сравнения (it1 == it2, it1 !=
it2), и инкремента/декремента (it1++, it2-), конечно, допустимы.
В свете вышесказанного, итераторы подразделяются по типам:
-- random access iterator: итератор произвольного доступа; умеет всё, что
умеет делать указатель, и даже немного больше
-- normal iterator: то же, что итератор произвольного доступа, но не
поддерживает арифметические операции <, >, +=, -=, -- forward iterator: то же, что normal iterator, но не поддерживает
декремент. Пример: односвязный список.
Ввиду того, что итераторы списка нельзя сравнивать при помощи operator <, код
функции обращения списка следует модифицировать:
template<typename T> void reverse_list(T *first, T *last)
{ if(first != last) {
// точнее, параноик написал бы if(!(first ==
last)) while(true) {
swap(*first,
*last); first++;
if(first
== last) { break;
}
last--;
if(first
== last) { break;
}
}
}
}
Пришло время вернуться к STL. Каждый контейнер в STL имеет собственный
тип итератора, и даже часто не один. Программисту об этом знать не обязательно.
Программисту лишь надо помнить, что для любого контейнера определены методы
begin() и end(), которые возвращают итераторы начала и конца, соответственно.
Однако, в отличие от вышеприведённого примера, end() возвращает итератор,
указывающий не на последний элемент контейнера, а на непосредственно следующий за
ним элемент. Это часто бывает удобно. Например, для любого контейнера
c разность (c.end() - c.begin()) всегда равна c.size(), если, конечно, итераторы данного типа
контейнера поддерживают арифметические операции. А (c.begin() ==
c.end())
тождественно равно c.empty() -- для любых типов контейнеров.
Таким образом, STL-совместимая версия функции reverse_list приведена
ниже.
template<typename T> void
reverse_list_stl_compliant(T *begin, T *end) {
// Сначала мы должны уменьшить end,
// но только для непустого
диапозона if(begin != end)
{
end--;
if(begi
n != end) {
while(true) {
swap(*begi
n, *end); begin++;
if(begi
n == end) {
break;
}
end--;
if(begi
n == end) {
break;
}
}
}
}
}
Теперь эта функция полностью соответствует функции std::reverse(iterator begin,
iterator end), определённой в модуле algorithm. В качестве упражнения хотелось бы
порекомендовать читателю прочитать и разобрать код функции std::reverse какой-либо из
стандартных реализаций STL -- например, SGI или Boost.
Каждый STL контейнер имеет так называемый интервальный конструктор:
конструктор, который в качестве параметров принимает два итератора, который задают
интервал объектов, тип которых приводим к типу контейнера. Это будет
продемонстрировано в следующих примерах.
vector<int> v;
...
vector<int> v2(v);
// интервальный конструктор, v3
== v2 vector<int> v3(v.begin(), v.end());
int data[] = { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 };
vector<int> primes(data,data+(sizeof(data)/sizeof(data[0])));
Последняя строка инициализирует vector<int>
primes содержимым
массива dataпри помощи интервального конструктора.
Более сложные примеры:
vector<int> v;
...
vector<int> v2(v.begin(), v.begin() + (v.size()/2));
Создаваемый вектор v2будет содержать первую половину вектора v.
Особо следует выделить тот факт, что в качестве итератора алгоритмам STL
можно передавать объекты произвольной природы -- необходимо лишь, чтобы они
поддерживали соответствующий функционал. Разберём на примере функции reverse:
int data[10] = { 1, 3, 5, 7, 9, 11, 13, 15, 17, 19 };
reverse(data+2, data+6);
// интервал { 5, 7, 9, 11 } переходит в { 11, 9, 7, 5 };
Кроме того, каждый контейнер имеет также так называемые обратные итераторы
-- итераторы, служащие для обхода контейнера в обратном порядке. Обратные итераторы
возвращаются методами rbegin()/rend():
vector<int> v;
vector<int> v2(v.rbegin()+(v.size()/2), v.rend());
Вектор v2содержит первую половину v, в порядке от середины к началу.
Чтобы создать объект типа итератор, следует указать его тип. Тип итератора
получается
приписыванием
к
типу контейнера ::iterator, ::const_iterator, ::reverse_iterator
или ::const_reverse_iterator. Смысл этих типов уже должен быть понятен слушателю.
Таким образом, содержимое вектора можно просмотреть следующим
образом:
vector<int> v;
for(vector<int>::iterator it = v.begin(); it!=v.end(); it++) {
*it = (*it) * (*it); // возводим каждый элемент в квадрат
}
В условии цикла строго рекомендуется использовать operator !=, а не
operator <.
4. Множество элементов Set
Контейнер set
содержит
множество элементов. Строго
говоря, setобеспечивает следующую функциональность:
 добавить элемент в рассматриваемое множество, при этом
исключая возможность появления дублей;
 удалить элемент из множества;
 узнать количество (различных) элементов в контейнере;
 проверить, присутствует ли в контейнере некоторый элемент.
Об алгоритмической эффективности контейнера set мы поговорим позже,
вначале познакомимся с его интерфейсом.
set<int> s;
for(int i = 1; i <= 100; i++) {
s.insert(i); // добавим сто первых натуральных чисел
}
s.insert(42); // ничего не произойдёт --// элемент 42 уже присутствует в множестве
for(int i = 2; i <= 100; i += 2) {
s.remove(i); // удалим чётные числа
}
// set::size() имеет тип unsigned int
int N = int(s.size()); // N будет равно 50
У set нет метода push_back(). Это неудивительно: ведь такого понятия, как
порядок элементов или индекс элемента, в setне существует, поэтому слово «back» здесь
никак не применимо.
А раз уж у set нет понятия «индекс элемента», единственный способ
просмотреть данные, содержащиеся в set, заключается в использовании итераторов:
set<int> S;
...
// вычисление суммы элементов
множества S int r = 0;
for(set<int>::const_iterator it = S.begin();
it != S.end(); it++) {
r += (*it);
}
Если вы пользуетесь GNU C++, то Traversing Macros будет весьма кстати.
#define tr(container, iterator) \ for(typeof(container.begin())
iterator=container.begin(); \ it != container.end(); it++)
Данный макрос -- сокращение от traverse -- будет работать даже для самого
сложного типа контейнера, независимо от того, каким образом этот контейнер к
моменту использования определён. Для const-объектов он породит const_iterator'ы:
void f(const
vector<int>& v) { int r = 0;
tr(v, it) {
r += (*it)*(*it);
}
return r;
}
Показательный пример:
set< pair<string, pair< int, vector<int> > > SS;
...
int
total = 0;
tr(SS, it) {
total += it->second.first;
}
Обратите внимание на синтаксис it->second.first.
итератором, перед использованием его необходимо
синтаксисом было бы (*it).second.first. Однако, в C++ есть
при описании некоторого объекта есть возможность
равенство
Ввиду того, что it является
разыменовать. «Верным»
негласное правило, что если
обеспечить тождественное
конструкций (*it). и it->, то это следует сделать, дабы не вводить пользователей
в заблуждение. Разработчики STL, конечно, позаботились об этом в случае с
итераторами.
Основным преимуществом set перед vector является, несомненно,
быстродействие. В основном это быстродействие проявляется при выполнении операции
поиска. (При добавлении операция поиска также неявно присутствует, потому как дубли
в set не допускаются). Однако, с операцией поиска в set/mapесть существенный нюанс.
Нюанс заключается в том, что вместо глобального алгоритма std::find(...) следует
использовать метод set set::find(...).
Это не означает, что std::find(...) не будет работать с set. Дело в том,
что std::find(...) ничего не знает о типе контейнера, с которым он работает. Принцип
работы std::find(...) крайне прост: он просматривает все элементы до тех пор, пока либо
не будет найден искомый элемент, либо не будет достигнут конец интервала. Основное
преимущество set перед vector заключается в использовании нелинейной структуры
данных, что существенно снижает алгоритмическую сложность операции поиска;
использование же std::find(...)ануллирует все старания разработчиков STL.
Метод set::find(...) имеет всего один аргумент. Возращаемое им значение либо
указывает на найденный элемент, либо равно итератору end()для данного экземпляра
контейнера.
set<int> s;
...
if(s.find(42) != s.end()) {
// 42 присутствует
}
else {
}
// 42 не присутствует
Кроме find(...) существует также операция count(...), которую следует вызывать
как метод set::count(x), а не как алгоритмstd::count(begin,
end,
x).
Ясно, что set::count(x) может вернуть только 0 или 1.
Некоторые программисты считают, что вышеприведённый код лучше выглядит, если
использовать count(x) вместо find(x): if(s.count(42) != 0) {
...
}
Или даже
if(s.count(42)) {
.
..
}
Мнение автора заключается в том, что подобный код вводит читателя в
заблуждение: сам смысл операции count() несовместим со случаями, когда элемент либо
присутствует, либо нет. Если же вам предтавляется слишком длинным каждый раз писать
"[некоторая форма find]" != container.end(), сделайте следующие макросы:
#define present_member(container, element) \
(find(all(container),element) != container.end())
#define present_global(container, element)
\ (container.find(element) != container.end())
Здесь all(c)означает c.begin(),c.end()
Более того, в соответствии с положением cтандарта, которое называется
«конкретизация шаблонов», можно написать следующий код:
template<typename T, typename T2> bool present(const T& c,
const T2& obj) {
return find(c.begin(), c.end(),
(T::element_type)(obj)) != c.end();
}
template<typename T, typename T2> bool
present(const set<T>& c, const T2& obj) { return
c.find((T::element_type)(obj)) != c.end();
}
При работе с контейнером типа set present(container, element) вызовет метод
set::find(element), в других случаях --std::find(container.begin(), container.end(), element).
Для удаления элемента из set необходимо вызвать метод erase(...), передав ему
один элемент -- элемент, который следует удалить, либо итератор, указывающий на
удаляемый элемент.
set<int> s;
...
s.insert(54);
...
s.e rase(29);
s.erase(s.find(57));
Как и полагается erase(...), set::erase(...)имеет интервальную форму.
set<int> s;
...
set<int>::iterator it1,
it2; it1 = s.find(10);
it2 = s.find(100);
// Будет работать, если как 10, так и 100 присутствуют в
множестве if(...) {
s.erase(it1, it2); // при таком вызове будут удалены
// все элементы от 10 до 100 не включительно
}
else {
// сдвинем it2 на один элемент вперёд
// set::iterator является normal iterator
// операция += не определена для итераторов set'а,
//но ++ и -допускаются it2++;
s.erase(it1, it2); // а при таком --- от 10 до 100 включительно
// приведённый код будет работать, даже если 100 был
// последним элементом, входящим в set
}
Также, как и полагается контейнерам STL, у setесть интервальный конструктор:
int data[5] = { 5, 1, 4, 2, 3 };
set<int> S(data, data+5);
Кстати, данная функция set предоставляет эффективную возможность
избавиться от дубликатов в vector:
vector<int> v;
...
set<int>
s(all(v)); vector<int>
v2(all(s));
Теперь v2 содержит те же элементы, что и v, но без дубликатов. Приятной
особенностью также является тот факт, что элементы v2 упорядочены по возрастанию,
но об этом мы поговорим позже.
В setможно хранить элементы любого типа, которые можно упорядочить.
5. Ассоциативный контейнер Map
Теперь мы можем перейти от set к map. «Введение в map для чайников»
могло бы выглядеть следующим образом:
map<string,
int> M; M["One"] =
1;
M["Two"] = 2;
M["Many"] = 7;
int x = M["One"] + M["Two"];
if(M.find("Five") !=
M.end()) { M.erase("Five");
}
Очень просто, не так ли?
На самом деле, map очень похож на set, за исключением того, что вместо
элементов map хранит пары элементов <ключ, значение>. Поиск при этом
осуществляется только по ключу.
Крайне приятно наличие оператора обращения по «индексу» (operator []).
Для того чтобы просмотреть содержимое map, необходимо использовать
итераторы. Удобнее всего это делать при помощи нашего макроса tr. Следует помнить,
что итератор указывает не на элемент key, а на pair<key, value>:
map<string, int> M;
...
M["one"] = 1;
M["two"] = 2;
M["google"] = 1e100;
...
// найдём сумму всех значений --- т.е. всех правых частей
// пар
<string, int> int r = 0;
tr(M, it) {
r += it->second;
// (*it).first == [string], (*it).second == [int]
}
Как и в случае с set, элементы map хранятся упорядоченными по ключу. Поэтому
не следует при работе с map::iterator модифицировать it->first: если вы нарушите правила
упорядочивания элементов в map, за последствия никто отвечать не возьмётся.
В остальном контейнер mapпо интерфейсу практически эквивалентен контейнеру
set.
Также важно помнить, что operator [] при обращении к несуществующему
элементу в map создаст его. Новый элемент при этом будет инициализирован нулём (либо
конструктором по умолчанию, если это не тривиальный тип данных). Данная
особенность map может быть удобной, потому как выполнять операции с элементами
можно не задумываясь об их присутствии в map. Существенным моментом является то,
чтоoperator [] не является константным (то есть может изменить объект, для которого
вызван), поэтому им нельзя пользоваться, если map передан как const reference.
Используйте map::find(element):
void f(const map<string,
int>& M) { if(M["the meaning"] ==
42) {
// Так нельзя! M передан как const reference
}
if(M.find("the meaning") != M.end() &&
M.find("the meaning")->second == 42) {
// А можно именно так
cout << "Don't Panic!" << endl;
}
}
Для хранения повторяющихся элементов используются контейнеры multiset и
multimap.
Download