Индексация строк

Доступ к символам в строках основан на операции индексирования – после строки или имени переменной, ссылающейся на строку, в квадратных скобках указываются номера позиций необходимых символов.

Так же следует понимать, что этот самый доступ, основан на смещении, т.е. расстоянии символов от левого или правого края строки. Данное расстояние измеряется целыми числами и по сути определяет номер позиции символов в строке – их индекс. Подвох заключается в словосочетании "измеряется целыми числами", а это означает, что индекс может быть как положительным так и отрицательным: положительные индексы – это отсчет от левого края, а отрицательные – от правого. Причем отсчет символов от левого края начинается с \(0\), а с правого начинается с \(-1\) (минус единицы).

В самом простом случае можно извлеч один произвольный символ:

>>> 'string'[0]
's'
>>> 
>>> 'string'[5]
'g'
>>> 
>>> 
>>> s = 'STRING'
>>> 
>>> s[0]
'S'
>>> 
>>> s[5]
'G'

Так же мы можем извлеч срез – последовательность символов внутри исходной строки, которую еще иногда называют подстрокой:

>>> s = 'AAA***BBB^^^'
>>> 
>>> s[0:3]
'AAA'
>>> 
>>> s[3:6]
'***'
>>> 
>>> s[9:12]
'^^^'

А еще мы можем извлекать символы из строки или среза с указанным шагом:

>>> s = 'a0-b1-c2-d3-e4-f5-g6'
>>> 
>>> s[::3]
'abcdefg' 
>>> 
>>> s[1::3]
'0123456' 
>>> 
>>> s[2::3]
'------'
>>> 
>>> 
>>> s[3:12:3]
'bcd'
>>> s[9:1:-3]
'dcb'

Как видите, с одной стороны – все вроде бы просто, но с другой – особенно глядя на последние примеры, становится понятно, что какие-то сложности все-таки есть. Давайте разберемся по порядку.


Извлечение символов — S[i]

Извлечение одного символа из строки – это, действительно, самый простой случай. Давайте снова рассмотрим строку:

>>> s = 'STRING'

Что бы извлеч символы 'S', 'R' и 'G' мы можем выполнить следующие простые операции:

>>> s[0], s[2], s[5]
('S', 'R', 'G')

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

>>> s[-6], s[-4], s[-1]
('S', 'R', 'G')

Отрицательные и положительные индексы символов могут быть выражены через длину строки:

>>> s[4], s[-2]
('N', 'N')
>>> 
>>> s[len(s) - 2], s[4 - len(s)]
('N', 'N')

Визуально отображение индексов на символы строки выглядит вот так:

Индексирование символов строк в языке Python

Если указать индекс, который выходит за пределы строки, то это приведет к ошибке:

>>> s[10], s[-10]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: string index out of range

Отсутствие индекса, так же считается ошибкой.


Извлечение срезов — S[i:j]

Извлечение срезов строк выполняется по двум индексам, которые разделяются двоеточием:

>>> s = '*a*bb*ccc*dddd*'
>>> 
>>> s[1:2]
'a'
>>> 
>>> s[3:5]
'bb'
>>> 
>>> s[6:9]
'ccc'
>>> 
>>> s[10:14]
'dddd'

Обратите внимание на то, что срез начинается с символа, индекс которого указан первым, а заканчивается перед символом, индекс которого указан вторым:

Индексирование срезов строк положительными числами в языке Python

Тех же результатов можно добиться если указывать отрицательные индексы:

>>> s[-14:-13], s[-12:-10], s[-9:-6], s[-5:-1]
('a', 'bb', 'ccc', 'dddd')

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

>>> s[5:1]
''
>>> s[-10:-14]
''

И даже, если индексы окажутся равны, мы все равно увидим пустую строку вместо сообщения об ошибке:

>>> s[1:1]
''
>>> s[-14:-14]
''

Появится ли сообщение об ошибке если не указать индексы вообще? Нет, вместо этого мы увидим всю строку целиком:

>>> s[:]
'*a*bb*ccc*dddd*'

Такое поведение связано с тем, что индексы начала и конца среза имеют значения по умолчанию: начало – всегда \(0\), конец – всегда len(s). Так что команда s[:] является эквивалентной команде s[0:len(s)]. Наличие таких предустановленных значений является весьма удобным, если мы хотим извлекать срезы от начала строки до указанного символа, или от некоторого символа до конца строки:

>>> s = '*a*bb*ccc*dddd*'
>>> 
>>> s[:2]    #  эквивалентно s[:-13]
'*a'
>>> 
>>> s[:5]    #  эквивалентно s[:-10]
'*a*bb'
>>> 
>>> s[:-1]    #  эквивалентно s[:14]
'*a*bb*ccc*dddd'
>>> 
>>> s[-3:]    #  эквивалентно s[12:]
'dd*'
>>> 
>>> s[-10:]    #  эквивалентно s[5:]
'*ccc*dddd*'

Индексирование срезов строк положительными числами в языке Python

Снова, обратите внимание на то, что если срез извлекается от начала строки до указанного символа, то сам указанный символ в разрез не включается. А если срез извлекается от указанного символа до конца строки, то этот символ будет включен в начало среза. Такое поведение гарантирует что конкатенация двух таких срезов будет равна исходной строке:

>>> s = 'xxxxYYYY'
>>> 
>>> s[:4] + s[4:]
'xxxxYYYY'
>>> 
>>> s[:-3] + s[-3:]
'xxxxYYYY'

Операция извлечения среза допускает указание индексов, выходящих за пределы строки:

>>> s = '*a*bb*ccc*dddd*'
>>> 
>>> s[:100]
'*a*bb*ccc*dddd*'
>>> 
>>> s[6:100]
'ccc*dddd*'
>>> 
>>> s[100:]
''

Извлечение срезов с заданным шагом — S[i:j:k]

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

>>> s = 'A1-B2-C3-D4-E5-'
>>> 
>>> s[3:12:3]
'BCD'
>>> 
>>> s[:9:2]
'A-2C-'
>>> 
>>> s[3::4]
'B3-'
>>> 
>>> s[::6]
'ACE'

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

>>> s[-12:-3:3], s[:-6:2], s[-12::4]
('BCD', 'A-2C-', 'B3-')

Визуально все это можно представить вот так:

Индексирование срезов строк с заданным шагом в языке Python

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

Величина шага имеет значение по умолчанию, равное \(1\) – это соответствует извлечению всех элементов подряд. А учитывая, что начало среза и его конец так же имеют установленные по умолчанию значения, равные \(0\) и len(s), то мы можем вообще ничего не указывать (кроме двух двоеточий, разумеется):

>>> s = 'A1-B2-C3-D4-E5-'
>>> 
>>> s[::]
'A1-B2-C3-D4-E5-'

Может ли значение шага быть отрицательным? Да:

>>> 'ABCDEFG'[::-1]
'GFEDCBA'
>>> 
>>> 'ABCDEFG'[::-2]
'GECA'

По сути, знак указанного числа в шаге указывает направление в котором выполняется извлечение символов из среза. Значение \(-1\) означает, что нужно извлеч каждый последующий символ, двигаясь от правого края к левому. А значение \(-2\) — извлеч каждый второй символ, так же двигаясь от конца к началу строки.

Давайте выполним несколько примеров и посмотрим, как это можно наглядно представить:

>>> s = 'ABCDEFGHIJKLMNO'
>>> 
>>> s[::2]
'ACEGIKMO'
>>> 
>>> s[::-2]
'OMKIGECA'
>>> 
>>> s[::4]
'AEIM'
>>> 
>>> s[::-4]
'OKGC'

Индексирование срезов строк с заданным отрицательным шагом в языке Python

Помните, мы говорили о том, что индекс начала среза должен быть меньше чем индекс его конца? На самом деле такая необходимость связана с направлением в котором извлекаются символы среза. По умолчанию, значение шага равняется \(1\) — это соответствует изъятию каждого символа в направлении от начала строки до ее конца. Именно поэтому мы ранее получали пустую строку, когда указывали левый индекс больше чем правый:

>>> s = 'ABCDEFGHIJKLMNO'
>>> 
>>> s[10:4]    #  по умолчанию шаг равен 1
''

Как зависит извлечение символов из срезов строк от направления шага в языке Python

Глядя на пустую строку, котору вернул интерпретатор и картинку становится понятно, что что-то не так. Мы ясно видим, что извлечение начинается с индекса под номером \(10\), т.е. с символа "K" и даже несмотря на то, что извлечение происходит слева направо (так как по умолчанию шаг равен \(1\)), символ "K" просто обязан попасть в результирующую строку. Но его там нет!!!

Настало время осознать, что индексы символов в строке и их смещения – это не одно и то же. Работая с последовательностями нам проще всего воспринимать индексы символов (номера их позиций) чем их смещений. Я даже уверен что после того как вы разберетесь со смещениями, вы все равно будете представлять себе индексы.


Индексы и смещения символов

Что ж давайте сделаем небольшое отступление и попробуем разобраться. Вот уже знакомая нам строка:

>>> s = 'STRING'

А вот так мы отображаем индексы на символы:

Индексирование символов строк в языке Python

Почему мы так делаем? Потому что это удобнее чем смещения:

Индексирование символов строк в языке Python

Говоря о смещениях, мы подразумеваем левую границу символов (левую сторону квадратов в которых они изображены на рисунках). Так, например, смещение символа "S" относительно левого края строки равно \(0\) т.е. он вообще не смещен, поэтому операция s[0] его и возвращает. А смещение символа "G" относительно правого края равно \(-1\), поэтому операция s[-1] возвращает "G". На этом же принципе основаны и все другие операции индексирования, будь-то срезы или срезы с заданным шагом.

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

Теперь, когда мы знаем, что такое смещения, нам гораздо проще понять почему операция s[4:2:1] возвращает пустую строку вместо символа "I":

Индексирование символов строк в языке Python

Глядя на эту картинку, нам начинает казаться, что символ "I" с индексом \(4\) должен попасть в результирующую строку. Но интерпретатор Python ориентируется не по индексам, а смещениям и с его точки зрения все выглядит вот так:

Индексирование символов строк в языке Python

Так как символ "I" располагается перед смещением с номером \(4\), а отсчет ведется именно от смещений, то и в результирующей строке его быть не должно.

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

>>> s[2:6]    #  эквивалентно s[2:]
'RING'

Если посмотреть на эту операцию в контексте индексов, то можно утверждать, что мы указываем элемент с индексом \(6\), которого на самом деле не существует, но перед которым извлечение символов из строки должно остановиться.

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

Скорее всего, у вас возник вопрос по поводу того как работает операция s[::-1], ведь ее левый индекс по умолчанию равен \(0\), а правый – len(s), но даже не смотря на то, что извлечение символов должно выполняться справа налево, т.е. мы, по идее, должны увидеть пустую строку, мы видим, как все прекрасно работает:

>>> s = 'STRING'
>>> 
>>> s[::-1]
'GNIRTS'
>>> 
>>> s[::-2]
'GIT'

Такое исключение из правил, создано намеренно, а именно – для удобства. Потому что это, действительно, удобнее чем каждый раз писать, что-то вроде s[len(s):0:-1] или s[len(s):0:-2].


Отрицательное значение шага

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

>>> s = 'ABCDEFGHIJKLMNO'
>>> 
>>> s[11:2:-2]
'LJHFD'
>>> 
>>> s[:4:-3]
'OLIF'
>>> 
>>> s[10::-4]
'KGC'
>>> 
>>> s[-3:-6:-7]
'M'

Индексирование символов строк в языке Python

Помните мы задавались вопросом о том, как работает операция s[::-1]? И был дан расплывчато-туманный ответ, что это яко бы просто техническая особенность, созданная для удобства. Так вот это действительно технически реализованный трюк, который заключается в том, установленные по умолчанию значения начала и конца среза (начало – \(0\), конец – len(s)) меняются местами, если величина шага имеет отрицательное значение:

>>> s[::2], s[0:len(s):2]
('ACEGIKMO', 'ACEGIKMO')
>>> 
>>> s = 'ABCDEFGHIJKLMNO'
>>> s[::-2], s[-1:-len(s)-1:-2]
('OMKIGECA', 'OMKIGECA')

Возможно вы немного удивились, ожидая вместо команды s[-1:-len(s)-1:-2] увидеть команду s[len(s):0:-2]. Но если вспомнить, что символы извлекаются не по индексам, а по смещениям, так же вспомнив, что извлечение среза выполняется от первого указанного смещения до второго, то станет ясно, что команда s[len(s):0:-2] выдаст не то, что нужно:

>>> s[len(s):0:-2], s[::-2], s[-1:-len(s)-1:-2]
('OMKIGEC', 'OMKIGECA', 'OMKIGECA')

В заключение, хочется напомнить о двух простых правилах из Дзена Python:

  • Простое лучше чем сложное;
  • Сложное лучше чем запутанное.

Почему именно эти правила. Потому что вам может захотеться использовать в своем коде какие-то, так сказать, хитро-выдуманные трюки с индексированием. С одной стороны: "Почему бы и нет??? Ведь программирование – это еще и способ самовыражения!". Но с другой стороны – это еще замечательный способ вынести мозг и себе, и что хуже всего, другим людям. Так что, если вдруг, вы придумаете, каой-нибудь фокус с индексированием строк, то не поленитесь раскрыть его секрет в комментариях к коду.

Сам по себе механизм индексирования устроен довольно просто. Тем не менее, даже из такой "простоты" можно слепить что-то вроде этого:

>>> sym = 'ABCDEFGHIJ'
>>> num = '1234567890'
>>> i = 2
>>> 
>>> sym[int(num[i])-len(sym):int(num[len(sym)-i])-i:int(num[i])]
'DG'

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