6

I don't know how to make my code working for more lines.

This is the original file t.txt:

Hello Earth
Hello Mars

But I get the following output:

Mars Hello Earth Hello

My expected output is this:

Earth Hello
Mars Hello

In general , I want to keep line order same, but reverse words. For general case input would be this:

one two 
four five

and expected output is this:

two one
five four

My code is the following:

#!/bin/bash
text=$(cat $1)
arr=($text)
al=${#arr[@]}
let al="al-1"

while (($al >= 0))
do
    echo -n "${arr[al]}"
    echo -n " "
    let al="al - 1"
done

echo
  • 7
    Please don't post images of text. – Michael Homer Jan 10 '17 at 2:59
  • Can you clarify the output you expect? It will probably involve more and more complex lines of input. You can edit your post to provide clearer examples. – Michael Homer Jan 10 '17 at 3:02
  • 2
    @vadikrobot yeah, but OP wants to reverse words while keeping line order. So if a line #3 has words "abra cadabra boom" , the result should be "boom cadabra abra",and it still should be 3rd line in output. – Sergiy Kolodyazhnyy Jan 11 '17 at 8:02
  • note your solution uses a lot of memory for large files. As it unnecessarily load the whole file, in line 2. – ctrl-alt-delor Jan 11 '17 at 14:56
14

All examples presented below work for general case where there's an arbitrary number of words on the line. The essential idea is the same everywhere - we have to read the file line by line and print the words in reverse. AWK facilitates this the best because it already has all the necessary tools for text processing done programmatically, and is most portable - it can be used with any awk derivative, and most systems have it. Python also has quite a few good utilities for string processing that allow us to do the job. It's a tool for more modern systems, I'd say. Bash, IMHO, is the least desirable approach, due to portability, potential hazards, and the amount of "trickery" that needs to be done.

AWK

$ awk '{for(i=NF;i>=1;i--) printf "%s ", $i;print ""}' input.txt                                                  
Earth Hello 
Mars Hello 

The way this works is fairly simple: we're looping backwards through each word on the line, printing words separated with space - that's done by printf "%s ",$i function (for printing formatted strings) and for-loop. NF variable corresponds to number of fields. The default field separator is assumed to be space. We start by setting a throw-away variable i to the number of words, and on each iteration, decrement the variable. Thus, if there's 3 words on line, we print field $3, then $2, and $1. After the last pass, variable i becomes 0, the condition i>=1 becomes false, and the loop terminates. To prevent lines being spliced together, we insert a newline using print "". AWK code blocks {} are processed for each line in this case (if there's a matching condition in front of code block, it depends on the match for the code block to be executed or not).

Python

For those who like alternative solutions, here's python:

$ python -c "import sys;print '\n'.join([ ' '.join(line.split()[::-1]) for line in sys.stdin ])"  < input.txt     
Earth Hello
Mars Hello

The idea here is slightly different. < operator tells your current shell to redirect input.txt into python's stdin stream, and we read that line by line. Here we use list comprehension to create a list of lines - that's what the [ ' '.join(line.split()[::-1]) for line in sys.stdin ] part does. The part ' '.join(line.split()[::-1]) takes a line, splits it into list of words, reverses the list via [::-1] , and then ' '.join() creates a space-separated string out of it. We have as a result a list of larger strings. Finally, '\n'.join() makes an even larger string, with each item joined via newline.

In short, this method is basically a "break and rebuild" approach.

BASH

#!/bin/bash

while IFS= read -r line
do
     bash -c 'i=$#; while [ $i -gt 0 ];do printf "%s " ${!i}; i=$(($i-1)); done' sh $line   
     echo 
done < input.txt

And a test run:

$ ./reverse_words.sh                                                                                              
Earth Hello 
Mars Hello 

Bash itself doesn't have strong text processing capabilities. What happens here is that we read the file line by line via

while IFS= read -r line
do
   # some code
done < text.txt

This is a frequent technique and is widely used in shell scripting to read output of a command or a text file line-by-line. Each line is stored into $line variable.

On the inside we have

bash -c 'i=$#; while [ $i -gt 0 ];do printf "%s " ${!i}; i=$(($i-1)); done' sh $line

Here we use bash with -c flag to run a set of commands enclosed into single-quotes. When -c is used, bash will start assigning command-line arguments into variables starting with $0. Because that $0 is traditionally used to signify a program's name, I use sh dummy variable first.

The unquoted $line will be broken down into individual items due to the behavior known as word-splitting. Word splitting is often undesirable in shell scripting, and you will often hear people say "always quote your variables, like "$foo"." In this case, however, word-splitting is desirable for processing simple text. If your text contains something like $var, it might break this approach. For this, and several other reasons, I'd say python and awk approach are better.

As for the inner code, it's also simple: the unquoted $line is split into words and is passed to the inner code for processing. We take the number of arguments $#, store it into the throw away variable i, and again - print out each item using something known as variable indirection - that's the ${!i} part (note that this is bashism - it's not available in other shells). And again, we use printf "%s " to print out each word, space-separated. Once that's done, echo will append a newline.

Essentially this approach is a mix of both awk and python. We read the file line by line, but divide and conquer each line, using several of bash's features to do the job.

A simpler variation can be done with the GNU tac command, and again playing with word splitting. tac is used to reverse lines of input stream or file, but in this case we specify -s " " to use space as separator. Thus, var will contain a newline-separated list of words in reverse order, but due to $var not being quoted, newline will be substituted with space. Trickery, and again not the most reliable, but works.

#!/bin/bash

while IFS= read -r line
do
     var=$(tac -s " " <<< "$line" )
     echo  $var
done < input.txt

Test runs:

And here's the 3 methods with arbitrary lines of input

$ cat input.txt                                                                                                   
Hello Earth end of line
Hello Mars  another end of line
abra cadabra magic
$ ./reverse_words.sh                                                                                              
line of end Earth Hello 
line of end another Mars Hello 
magic cadabra abra 
$ python -c "import sys;print '\n'.join([ ' '.join(line.split()[::-1]) for line in sys.stdin ])"  < input.txt  
line of end Earth Hello
line of end another Mars Hello
magic cadabra abra
$ awk '{for(i=NF;i>=1;i--) printf "%s ", $i;print ""}' input.txt
line of end Earth Hello 
line of end another Mars Hello 
magic cadabra abra 

Extra: perl and ruby

Same idea as with python - we split each line into array of words, reverse the array, and print it out.

$ perl -lane '@r=reverse(@F); print "@r"' input.txt                           
line of end Earth Hello
line of end another Mars Hello
magic cadabra abra


$ ruby -ne 'puts $_.chomp.split().reverse.join(" ")' < input.txt                                                  
line of end Earth Hello
line of end another Mars Hello
magic cadabra abra
  • Your awk codes skip one word. I found this that works instead echo $str | awk '{ for (i=NF; i>1; i--) printf("%s ",$i); print $1; }'. Anyway, I'd really appreciate if you could explain the single parts of both the awk and bash solution. Thank you in advance. – Bersekz Jan 10 '17 at 6:57
  • @Bersekz interesting , because it shouldn't skip any words, not unless there's a problem with file. I also don't think this has to do with awk version. Did you copy the code properly ? Did you use i>=1 ? because i>1 will skip first word. As for explanation, i'll provide that once i come back to my laptop - i'm on phone right now – Sergiy Kolodyazhnyy Jan 10 '17 at 7:11
  • I solved the problem re-using my initial solution inside a "while read line cycle" where I assign the single line to the array and then print it on reverse. Basically I execute the same exact code for each line. I didn't know about this construct, and I don't understand what "done < input.txt" means exactly. Shouldn't </> be a redirect for the stdout? Anyway,i'll wait for your full explanation. Btw, u were right, your awk code is fine. – Bersekz Jan 10 '17 at 7:49
  • @Bersekz I've added explanation and extra approach with perl. I would recommend that you get familiar with while read var ; do . . .done < input.txt because it's a frequent technique. < just redirects a file into processes' stdin stream. This is sort of similar to using pipe, except it's faster – Sergiy Kolodyazhnyy Jan 10 '17 at 9:08
  • 1
    (ninja) You can use a function prrev(){ for(i=$#;i>0;i--);do printf "%s " ${!i}; done; printf '\n'; } prrev $line instead of bash -c '..' $line.but note both of these (try to) expand words that contain glob chars ?*[..]. In bash you can instead read into an array var with only wordsplit using read -a ary and iterate over (the subscripts of) that array. – dave_thompson_085 Jan 10 '17 at 11:19
9

Just swap the words around, with awk:

awk '{print $2, $1}'

Example:

% cat bar.txt
Hello Earth
Hello Mars

% awk '{print $2, $1}' bar.txt
Earth Hello
Mars Hello
  • This works just with 2 words per line, right? I wanted something that works in any case. – Bersekz Jan 10 '17 at 5:32
  • @Bersekz then you need to loop over all words backwards. See my answer below – Sergiy Kolodyazhnyy Jan 10 '17 at 5:33
  • @Bersekz Add an example input and your desired output from that. – heemayl Jan 10 '17 at 5:36
3

The obligatory sed solution

The following GNU sed program uses a loop to move each word (starting from the first) at the end of the line. More details are inserted in the code as comments.

sed -r '
    # Mark the current end of the line by appending a LF character ("\n")
    G

    # Main loop: move the first word of the line just after the LF
    # and repeat until the LF is at the beginning of the line
    :loop
    s/([^[:space:]]+)(.*\n)/\2\1 /
    t loop

    # Remove remaining spaces up to the LF and the superfluous trailing space
    s/.*\n| $//g
'

Write-only version:

sed -r 'G; :loop; s/(\S+)(.*\n)/\2\1 /; t loop; s/.*\n| $//g'

Test:

$ sed -r '...' <<< "The quick
brown fox jumps

over
the lazy dog"

... yields:

quick The 
jumps fox brown 

over 
dog lazy the 

Portably (POSIXly):

sed '
  G
  :loop
     s/\([^[:space:]]\{1,\}\)\(.*\n\)/\2\1 /
  t loop
  s/ $//
  s/.*\n//'
  • Portably: sed -e 'G;:1' -e 's/\([^[:space:]]\{1,\}\)\(.*\n\)/\2\1 /;t1' -e 's/ $//;s/.*\n//' – Stéphane Chazelas Jan 11 '17 at 15:28
  • Thank you @StéphaneChazelas for this suggestion and for your edit. Feel free to add your portable version at the end of my answer. Except for the -r option and the + RE, was there other GNUisms you had to fix? – xhienne Jan 11 '17 at 15:37
  • There's also \s, [^\n]; \n on the RHS of the s command, labels followed by ;. – Stéphane Chazelas Jan 11 '17 at 15:45
  • @StéphaneChazelas Thanks for enlightening me ;-) – xhienne Jan 11 '17 at 16:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.