Session 6 · Computer Systems
Text processing and a key–value store
A practice block for the practical test: anchored regular expressions, glob tests, string slicing, tr and sed, arrays – and associative arrays for counting per key, with a worked example on messy data.
These two sessions are practice. The tasks prepare you for the practical test, and almost all of them use the same handful of tools again and again: the line-reading loop from last session, regular expressions, string operations and arrays. One tool is new – the associative array, a key–value store, which turns "count how often each thing occurs in this file" into a few lines.
The way to use this page: read the toolkit sections once, then try each task yourself before you open its hint. The hint shows the approach and the lines that matter; the rest of the script is yours to write.
The pattern behind every task: read the file safely, clean each line, decide with a pattern, then print, count or store. Only the middle two steps change from task to task.
The toolkit: one loop, split inside
Every file-reading solution starts from the universal pattern of the previous session. Paste it and rewrite only the processing part:
while IFS= read -r line || [ -n "$line" ]; do
line=${line%$'\r'} # drop a Windows \r
if [ -z "$line" ]; then # skip empty lines
continue
fi
# --- process the line ---
done < "$file"This time we don't split fields in the while header with IFS=',' read. Instead we split the line inside the loop with a here string. The loop header then stays identical in every script, and the cleaning (\r, empty lines) happens once, before the split.
IFS=',' read -r name date illness <<< "$line" # split at the commas
read -r word1 word2 rest <<< "$line" # split at spacesread puts one field into each variable, and the rest of the line into the last one. That is why the swap-words task uses a third, unused rest variable: without it, a line with three words would leave two of them in word2.
$ read -r first second <<< "one two three four"; echo "[$second]"
[two three four]
$ read -r first second rest <<< "one two three four"; echo "[$second]"
[two]Regular expressions in a script
[[ $line =~ pattern ]] is true when the line matches the extended regular expression; [[ ! $line =~ pattern ]] negates it. These are the elements the tasks need:
| Pattern | Meaning | Used in |
|---|---|---|
^ … $ | Start and end of the line: the whole line has to match, not just a part of it | every task |
[0-9]+ | At least one digit | tasks 8–11 |
[0-9]{2} | Exactly two digits | task 6 |
[0-9]{4}-[0-9]{2}-[0-9]{2} | A date like YYYY-MM-DD | task 13 |
[+-]? | Optional sign – ? means 0 or 1 of the previous item | task 1 |
[0-9]*\.?[0-9]+ | Decimal number. \. is a literal dot; a bare . matches any character | task 1 |
[[:alpha:]], [[:space:]] | POSIX classes: any letter, any whitespace. Combined: [[:alpha:][:space:]] | task 2 |
The anchors are the part beginners drop, and the result is a check that lets almost everything through (Fig. 1).
=~ asks "is there a match somewhere in the line?"A decimal-number check, read piece by piece (Fig. 2):
if [[ $line =~ ^[+-]?[0-9]*\.?[0-9]+$ ]]; then # 23, -12.2, +7, .5 – yes
echo "$line" # 33.3o, 12., asd2 – no
fiRegex or glob?
To decide whether a word is somewhere in the line you don't need a regex. With == and !=, [[ ]] uses glob patterns – the same wildcards as for file names, where * is any text:
if [[ $line == *"$word"* ]]; then # the line contains $word
if [[ $line != *alma* && $line != *körte* ]]; then # contains neither"$word" stays quoted inside the pattern, so special characters in it match literally.
The same with grep
Every loop-based filter has a one-line grep version. The loop wins when you need to do more per line (count, transform, write to two files); grep wins when filtering is all you do.
| With a loop | With grep |
|---|---|
[[ $line =~ re ]] + echo | grep -E 're' file |
[[ ! $line =~ re ]] + echo | grep -v -E 're' file |
[[ $line =~ re ]] + counter | grep -c -E 're' file |
[[ $line != *"$word"* ]] + echo | grep -v "$word" file |
String operations
Bash can cut strings without calling any external program. The ones these tasks use:
| Expression | Meaning | Example (s="1234") |
|---|---|---|
${#s} | Length | 4 |
${s:i:1} | The character at index i (from 0) | ${s:0:1} → 1, ${s:3:1} → 4 |
${s//a/b} | Replace every a with b (one /: only the first) | ${s//1/X} → X234 |
${s%$'\r'} | Remove a trailing \r | |
${key%%:*} | The part before the first colon (cuts the longest :* from the end) | 10:headache → 10 |
${key#*:} | The part after the first colon (cuts the shortest *: from the start) | 10:headache → headache |
Walking the digits of a number combines the length with one-character slices (Fig. 3). Treat the number as a string: this is also how you reverse it without any extra command.
${#number} − 1; the direction of the loop decides the task.for (( i=0; i<${#number}; i++ )); do
digit=${number:i:1}
sum=$(( sum + digit ))
donetr works on whole character classes; sed on patterns:
| Command | Effect |
|---|---|
tr -d '[:space:]' | Delete every space, tab and \r – for numbers |
tr -d '[:punct:]' | Delete punctuation (. , ! ? ; …) |
tr '[:upper:]' '[:lower:]' | Lowercase (reliable for unaccented letters) |
sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | Trim only the ends; inner spaces stay (" Hanna Molnar " → Hanna Molnar) |
sed "s/$old/$new/g" | Replace in every line; g = every occurrence in the line, not just the first |
sed walks the file by itself, so replacing a word needs no loop. It does need care with the output file:
sed "s/$old/$new/g" "$file" > "$file.tmp"
mv "$file.tmp" "$file"> before the command runs – that is why the temporary file is not optional.The same applies to a loop that rewrites its own input: done < "$file" > "$file.tmp", then mv.
Arrays
An array keeps all the lines (or words) in memory, which you need whenever the output order differs from the input order – reversing a file, for instance.
lines=() # empty array
lines+=("$line") # append
echo "${#lines[@]}" # number of elements
echo "${lines[0]}" # first element
read -r -a words <<< "$line" # the words of a line into an array
for (( i=${#lines[@]}-1; i>=0; i-- )); do # back to front
echo "${lines[i]}"
doneThe last index is ${#lines[@]}-1, because indexing starts at 0. Starting the backwards loop at ${#lines[@]} prints an empty element first.
Key–value store: associative arrays
An ordinary array is indexed by numbers. An associative array – called a map, dictionary or hash in other languages – is indexed by any string, and each key has one value. For counting it is ideal: the key is what you count, the value is how many.
declare -A.declare -A counts # create it – the -A is required
counts["apple"]=3 # set a value
counts["apple"]=$(( ${counts["apple"]:-0} + 1 )) # increment; a missing key starts at 0
echo "${counts["apple"]}" # one value: 4
echo "${!counts[@]}" # all KEYS (the !), in no particular order
echo "${#counts[@]}" # number of keys${counts["apple"]:-0} reads "the value, or 0 if the key doesn't exist yet" – that is what makes the first increment work. Counting words then looks like this:
declare -A counts
for word in "${words[@]}"; do
counts["$word"]=$(( ${counts["$word"]:-0} + 1 ))
done
for word in "${!counts[@]}"; do
echo "$word: ${counts["$word"]}"
doneoutput – note the order
plum: 1
pear: 2
apple: 3Finding the maximum means walking the keys and remembering the best one so far:
max_count=0
max_word=""
for word in "${!counts[@]}"; do
if [ "${counts["$word"]}" -gt "$max_count" ]; then
max_count=${counts["$word"]}
max_word=$word
fi
doneComposite keys
To count by two things at once – month and illness – glue them into one key with a separator, key="$month:$illness", and take it apart later with the two cutting expansions.
%% cuts from the end, # cuts from the start; the pattern says where.Worked example: messy data
Task 13 below puts everything together, on a file that is dirty in every way a real export is: a header line, spaces around the commas, punctuation after the illness (headache., headache!), mixed case, an empty and a broken line, CRLF line endings and no final newline. The goal: for every month, the most common illness and how many patients had it. (The file itself is in Hungarian – fejfájás is headache – the figure uses English.)
The cleaning step is two lines – spaces and punctuation out, everything lowercase:
date=$(echo "$date" | tr -d '[:space:]')
illness=$(echo "$illness" | tr -d '[:space:][:punct:]' | tr '[:upper:]' '[:lower:]')The solution has three phases: clean and count per month:illness key; find the maximum per month by walking the keys and keeping two more associative arrays, max_count[month] and max_illness[month]; and print sorted, because the key order is random.
Practice
For every task: check the arguments and that the file exists, error messages go to standard error, and the script stops with exit 1. Read files with the universal pattern. Tasks 5 and 12 rewrite their input file – try them on a copy (cp replace.txt test.txt).
Task 1
Lines that are numbers
Print the lines of a file that contain only a number. Think of real numbers too: sign and decimal point. Input: numbers_mixed.txt.
23
11
12.2
-12.2Approach
The decimal pattern from Fig. 2. 33.3o and asd2 fail because of the anchors. -12.2 is the last line, without a newline – if it's missing, check your loop header.
if [[ $line =~ ^[+-]?[0-9]*\.?[0-9]+$ ]]; then
echo "$line"
fiTask 2
Count the lines made of letters
Count how many lines contain only letters (and spaces). Input: letters.txt.
There are 2 lines in the file that contain only letters.Approach
One bracket expression with two classes; asd3asd fails because of the digit.
if [[ $line =~ ^[[:alpha:][:space:]]+$ ]]; then
count=$(( count + 1 ))
fi
# one line: grep -c -E '^[[:alpha:][:space:]]+$' letters.txtTask 3
Copy, leaving out a word
Copy a file to another file, leaving out every line that contains a given word. Arguments: source, destination, word. If the destination doesn't exist, create it (and say so).
Approach
A glob test, the output emptied before the loop. One-line version: grep -v "$word" "$source" > "$destination".
> "$destination"
while ...; do
if [[ $line != *"$word"* ]]; then
echo "$line" >> "$destination"
fi
done < "$source"Task 4
Exclude several words
Print the lines that contain neither alma (apple) nor körte (pear), nor the word given as an argument. Input: fruits.txt.
Approach
Three != conditions joined with &&.
if [[ $line != *alma* && $line != *körte* && $line != *"$word"* ]]; then
echo "$line"
fiTask 5
Replace a word in the file
Replace every occurrence of a word with another one, in the file itself. Arguments: file, old word, new word.
$ ./replace.sh test.txt alma barack
Word 'alma' replaced with 'barack' in 'test.txt'.Approach
The only task that needs no loop: sed with a temporary file and mv (Fig. 4). Plain sed also replaces inside words (almafa becomes barackfa); with GNU sed, \b limits it to whole words:
sed "s/\b$old\b/$new/g" # GNU sed: whole words only, "almafa" staysTask 6
Reverse two-digit numbers
A file has two-digit numbers, one per line. Print each with its digits swapped.
12 → 21
21 → 12
56 → 65
90 → 09Approach
Validate with ^[0-9]{2}$, then glue the two characters in the other order. Linux has rev for this, but Git Bash doesn't – slicing works everywhere.
if [[ $number =~ ^[0-9]{2}$ ]]; then
echo "${number:1:1}${number:0:1}"
fiTask 7
Swap two words
Every line of a file has two words. Swap them in every line.
asd1 asd2 → asd2 asd1
egy ketto → ketto egyApproach
Split with a here string; the third variable catches anything extra.
read -r word1 word2 rest <<< "$line"
echo "$word2 $word1"Task 8
Sum the numbers in a file
A file has one number per line. Add them up; warn about lines that aren't numbers.
Warning: skipping invalid number: 'asd'
Sum of numbers in the file: 20Approach
Validate, warn to standard error, continue; add up the rest.
if [[ ! $line =~ ^[0-9]+$ ]]; then
echo "Warning: skipping invalid number: '$line'" >&2
continue
fi
sum=$(( sum + line ))Task 9
Copy the non-numeric lines
A file mixes numbers and text. Copy every line that is not purely a number to a new file.
Approach
The mirror image of task 1: the same kind of pattern with !. One line: grep -v -E '^[0-9]+$' in.txt > out.txt.
Task 10
Reverse a number
Read a number from the keyboard with read and print it reversed.
Enter a number: 1234
Reversed number: 4321Approach
Handle it as a string: walk the characters from the back and append them to a new string. That also keeps leading zeros – 1200 becomes 0021. Validate the input first.
read -r -p "Enter a number: " number
for (( i=${#number}-1; i>=0; i-- )); do
reversed="$reversed${number:i:1}"
doneTask 11
Digit sum
Read a number and print the sum of its digits. It should also work with piped input (echo 1234 | ./digit_sum.sh).
Enter a number: Sum of digits: 10Approach
The forward loop of Fig. 3. Check the input is all digits, or the arithmetic fails on a letter.
Task 12
Reverse the lines of a file
Reverse a file in place – the last line becomes the first. Use an array as temporary storage.
Approach
Read every line into an array, build the reversed array (or print backwards), and only write the file when all lines have been read.
printf '%s\n' "${reversed[@]}" > "$file" # only after ALL lines are readTask 13
Most common illness per month
Each line of patients_messy.txt is Name, YYYY-MM-DD, illness – with every kind of dirt described in the worked example. For every month, print the most common illness and the number of patients. Warn about invalid lines.
Warning: invalid line skipped: 'Név, Dátum, Betegség'
Warning: invalid line skipped: 'Hibás Sor'
Month 05: lábfájás (3 patient(s))
Month 09: gyomorpanaszok (2 patient(s))
Month 10: fejfájás (3 patient(s))
Month 11: megfázás (3 patient(s))Approach
Follow Fig. 7. The month is ${date:5:2}. The header and the broken line fail the date check, so they need no special case.
key="$month:$illness"
counts["$key"]=$(( ${counts["$key"]:-0} + 1 ))
...
for key in "${!counts[@]}"; do
month=${key%%:*}
illness=${key#*:}
if [ "${counts["$key"]}" -gt "${max_count["$month"]:-0}" ]; then
max_count["$month"]=${counts["$key"]}
max_illness["$month"]=$illness
fi
doneMore practice
The inputs contain the usual traps – CRLF, no final newline, empty lines, extra spaces. Start every solution from the universal pattern.
Task 14
Palindromes
Print the lines of palindromes.txt that read the same backwards, ignoring spaces and case (Never odd or even). Expected: 6 lines, the last one without a newline in the file.
Approach
Normalise first, then reuse the reversing loop of task 10.
clean=$(echo "$line" | tr -d ' ' | tr '[:upper:]' '[:lower:]')
# reverse $clean into $reversed (task 10), then:
[ "$clean" == "$reversed" ] && echo "$line"Task 15
Reverse the words in every line
Task 7 for any number of words: in every line of word_lines.txt, reverse the word order. Expected first lines: harom ketto egy, korte alma, e d c b a.
Approach
Split the line into an array with read -r -a, then walk it backwards.
read -r -a words <<< "$line"
out=""
for (( i=${#words[@]}-1; i>=0; i-- )); do
out="$out${words[i]} "
done
echo "${out% }" # drop the trailing spaceTask 16
Censor banned words
banned_words.txt has one banned word per line (with Windows line endings). Replace every banned word in story.txt with ***, write the result to censored.txt, and print how many lines changed. Expected: 3.
Approach
Read the banned words into an array – stripping the \r, or not a single replacement happens. Then replace each word in each line, and compare with the original line.
for word in "${banned[@]}"; do
line=${line//"$word"/***}
done
[ "$line" != "$original" ] && changed=$(( changed + 1 ))Task 17
Digit sums from a file
numbers_digits.txt has one number per line, with spaces, invalid and empty lines. Write number: digit sum for each valid number to digit_sums.txt, then the number with the largest digit sum. Warn about invalid lines with their line number. Expected: 6 valid numbers, 2 invalid lines, Max: 99 (18).
Approach
Clean, validate, then the digit loop of task 11 plus a running maximum.
number=$(echo "$line" | tr -d '[:space:]')
[[ $number =~ ^[0-9]+$ ]] || { echo "Warning: line $n: '$number'" >&2; continue; }Task 18
Longest line, longest word
Print the longest line and the longest word of text.txt, with their lengths. Expected: a 59-character line and a 22-letter word.
Approach
Two running maximums: one over lines, one over the words of each line (read -r -a).
if [ "${#line}" -gt "$max_len" ]; then
max_len=${#line}
longest=$line
fiTask 19
Filter with options
Filter a file (first argument) by an option: -n numeric lines only, -t text lines only, -w <word> lines containing the word. Unknown option: usage text and exit 1. Expected on numbers_and_text.txt: -n → 2 lines, -t → 5, -w asd → 4.
Approach
Wrap tasks 1, 9 and 3 into functions and dispatch with case.
case "$2" in
-n) numeric_lines "$file" ;;
-t) text_lines "$file" ;;
-w) lines_with_word "$file" "$3" ;;
*) usage; exit 1 ;;
esacTask 20
The most frequent patient
Lines of visits.txt are Name, visits, last date; a name can appear in several lines, and its visits add up. Print who visited most, and how many times. Watch the spaces and the CRLF endings. Expected: Kiss Lajos - 12.
Approach
Sum per name in an associative array, then find the maximum. Trim only the ends of the name – the space inside it belongs to it.
IFS=',' read -r name count date <<< "$line"
name=$(echo "$name" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') # trim the ends only
count=$(echo "$count" | tr -d '[:space:]')
visits["$name"]=$(( ${visits["$name"]:-0} + count ))Common mistakes
| Wrong | Right | Why |
|---|---|---|
[[ $line =~ [0-9]+ ]] | [[ $line =~ ^[0-9]+$ ]] | Without anchors asd2 matches too. |
[[ $line =~ "^[0-9]+$" ]] | [[ $line =~ ^[0-9]+$ ]] | Quoted, the pattern is literal text. |
[[ $line == "$word" ]] for "contains" | [[ $line == *"$word"* ]] | Without * only an identical line matches. |
| regex on a Windows file | first line=${line%$'\r'} | The \r stops $ from matching. |
sed … "$file" > "$file" | temporary file, then mv | > empties the file before sed starts. |
for (( i=${#a[@]}; i>=0; i-- )) | for (( i=${#a[@]}-1; i>=0; i-- )) | The last index is length − 1. |
counting by string key without declare -A | declare -A counts first | Otherwise it is a numeric array and every string key lands on index 0. |
for key in ${!counts[@]} | for key in "${!counts[@]}" | Unquoted, Lajos Kiss splits into two keys. |
echo "$x" | rev | ${x:i:1} in a loop | rev isn't available everywhere (Git Bash). |
- anchor
^and$– the start and end of the line in a regex.- glob
- A wildcard pattern (
*,?,[…]), as in file names; used by==inside[[ ]]. - here string
<<< "$line"– feeds one string to a command such asread.- associative array
- An array indexed by strings, created with
declare -A. - composite key
- Two values glued into one key, e.g.
10:headache.
Summary
- Anchor every validating regex:
^…$. Never quote it inside[[ =~ ]]. - "Contains a word" is a glob:
[[ $line == *"$word"* ]]. - Strings are arrays of characters:
${#s}and${s:i:1}cover reversing and digit sums. - Never redirect into the file you are reading – temporary file, then
mv. - Count with
declare -A: clean the key first,${counts[$k]:-0} + 1, walk"${!counts[@]}"for the maximum,sortfor ordered output.