Я думаю, что ответ таков: в общем, не стоит пытаться это делать. В вашем конкретном случае, может быть.
Если большинство ваших символов - простые ASCII, и у вас редко встречаются последовательности UTF, то, возможно, стоит построить некую разреженную структуру данных со смещениями.
В общем случае каждый отдельный символ может быть не-ASCII, и у вас может быть много смещений для хранения. На самом деле, наиболее общий случай - создать строку байтов, которая будет в точности соответствовать длине строки символов Unicode, и каждое значение байта будет смещением следующего символа. Но это означает один целый байт на символ и, следовательно, чистую экономию всего одного байта на символ Юникода; вероятно, не стоит усилий. И это означает, что индексирование в вашей строке теперь является операцией O (n), когда вы пробегаете эти смещения и суммируете их, чтобы найти фактический индекс.
Если вы хотите попробовать разреженную структуру данных, я предлагаю массив пар значений, первое из которых является индексом в строке Unicode символа, а второе - индексом в последовательности байтов, где это персонаж действительно появляется. Затем после каждой escape-последовательности UTF8 вы должны добавить два значения, чтобы найти следующий символ в строке. Наконец, когда задан индекс для символа Unicode, ваш код может выполнить двоичный поиск в этом массиве, чтобы найти самый высокий индекс в разреженном массиве, который меньше запрашиваемого индекса, а затем использовать его для поиска фактического байта, который представляет начало нужного символа.
Если вам нужно сэкономить память, вы можете рассмотреть возможность использования библиотеки сжатия данных. Хлебать строки Юникода как полный Юникод, затем сжимать их; затем для индексации в строку, сначала вы распаковываете эту строку. Это действительно сэкономит память, и будет легко и быстро получить правильный код, чтобы он работал; но это может добавить слишком много ресурсов процессора, чтобы быть разумным.