Searching Large Text Files and Character Streams

If you want to search for a specific substring (pattern) within a string, you can use the str.find() or str.index() methods. If the pattern is found, both return the index of its first occurrence. If there is no match, str.find() returns -1, while str.index() raises a ValueError exception.

However, these two methods can only be called on strings of type str. Therefore, they cannot be used when:

  • you need to search for a specific pattern in a large text file whose contents cannot be loaded into memory all at once, or
  • you need to perform the search on a character sequence whose characters are supplied one at a time by an iterable object.

Since the standard library does not provide a function or method specifically designed for searching in these situations, we need to implement our own search function. We will create one function that accepts an iterable and another that accepts a file object.

These search functions should meet the following requirements:

  1. memory usage can be controlled during the search,
  2. the positions of all occurrences are provided,
  3. overlapping matches are detected.

Because of the last two requirements, it makes sense to implement the functions as generator functions. For a long sequence with many overlapping matches, the resulting sequence of indices can itself be very long. A generator function, however, gives us a generator object from which we can retrieve the positions one at a time. If we only need the first occurrence, we simply retrieve only one value from the generator.

As our functions search for all occurrences, it is useful to find the positions of overlapping matches, because there may be use cases where these are needed. If only non-overlapping matches are required, they can be filtered out relatively easily from the resulting sequence of indices.

Memory usage can be controlled by processing the incoming characters in blocks (chunks) of a configurable size.

This means that the incoming characters are grouped into strings of the specified chunk size (or, when searching a file, the file is read in chunks of this size), and the actual search is performed within each chunk. We’ll do this by calling find() on each chunk. The str.find() method is optimized and implemented in C, so it performs the search very quickly—much faster than an equivalent implementation written in Python. However, we have to keep in mind that str.find() only finds the first occurrence.

When processing the input stream chunk by chunk, we must account for the possibility that a pattern may span the boundary between two consecutive blocks. Therefore, the search cannot actually be performed on a single block. Instead, we need to search a string (the search string) formed by combining the appropriate trailing portion of the previous chunk with the new one.

When determining how much of the end of the previous chunk to include in the search string, we have to consider the longest possible overlap. This occurs when only a single character of the pattern is at the beginning of the next chunk, while all the remaining characters are at the end of the current chunk. Thus, the beginning of each search string overlaps with the last pattern length-1 characters of the previous chunk. This is illustrated in the following image.

The definitions of the find_in_iterable() and find_in_file() functions, implemented according to the principles described above, are shown below. The detailed comments in the code help explain how the functions work.

To test the functions, we need large files whose size can be specified in advance and in which we can place the string being searched for—the pattern—at a specified position. All other characters are generated randomly. To test the search with more than just ASCII characters, we also include accented letters (vowels) both in the pattern and among the randomly generated characters.

For each file size, we measure the search time under worst-case conditions. To achieve this, we place the pattern at the very end of the file. With sufficiently long sequences, however, the pattern may also occur by chance within the randomly generated section. It is therefore useful to estimate how many occurrences we can expect for a given sequence length and pattern length. This allows us to determine the character sequence lengths for which the pattern is almost certain to occur only at the end of the file, as well as the length beyond which multiple occurrences can be expected. In the latter case, our functions are expected to return the positions of all occurrences. This approach also gives us an opportunity to test our assumptions about the random distribution of characters.

The derivation of the formulas needed to design the large files containing long character sequences can be followed in the images below.

The definitions of the functions that generate the files and character stream using these formulas, along with the code that creates the files, are shown below.

The code used to measure the search time of the find_in_iterable() and find_in_file() functions is the following.

The results are summarized in a table below.

As we can see from the results, the search time increases linearly with the number of characters. This is what we would expect because for a given search pattern, the optimized search algorithm used by str.find() has a worst-case execution time that grows linearly with the length of the input sequence—even when the pattern occurs at the very end of the sequence.

For file-based searching, the linear relationship holds only in the 1–1000 MiB range. For a 10,000 MiB file, the search time is noticeably higher than we would expect based on the previous trend. The reason is that the smaller files fit entirely into the operating system’s page cache during testing, so the data was effectively read from fast RAM rather than from the storage device. The 10,000 MiB file, however, exceeded the available page-cache capacity. As a result, processing speed was no longer determined by the CPU and memory, but by the slower read speed of the physical storage device.

In the iterable-based version, the search time increased by an order of magnitude (approximately tenfold), but it still increased linearly even for the largest number of characters tested.

The slowdown is caused by processing each character at the Python level. When constructing the search strings, the combination of islice() and ''.join() requires the Python interpreter to perform a large number of internal operations, including function calls and temporary memory allocations. This also explains why the linear behavior is maintained across the entire range: the code becomes purely CPU-bound. Since a Python-level loop is much slower than C-based file reading, the processing speed here is no longer limited by the physical I/O performance of the storage device or by the size of the page cache. Instead, it is determined primarily by the CPU’s ability to perform computations at a steady rate.

Creating the functions presented in this article requires knowledge of a number of Python language features. These topics are covered in the e-book Python Knowledge Building Step by Step: From the Basics to Your First Desktop Application.

Interested in the e-book Python Knowledge Building Step by Step: From the Basics to Your First Desktop Application?