Làm cách nào để định dạng số dấu phẩy động với chính xác 2 chữ số có nghĩa trong bash?


17

Tôi muốn in số dấu phẩy động với chính xác hai chữ số có nghĩa trong bash (có thể sử dụng một công cụ phổ biến như awk, bc, dc, perl, v.v.).

Ví dụ:

  • 76543 nên được in là 76000
  • 0,0076543 nên được in là 0,0076

Trong cả hai trường hợp, các chữ số có nghĩa là 7 và 6. Tôi đã đọc một số câu trả lời cho các vấn đề tương tự như:

Làm thế nào để làm tròn số dấu phẩy động trong vỏ?

Bash giới hạn độ chính xác của các biến số dấu phẩy động

nhưng các câu trả lời tập trung vào việc giới hạn số lượng vị trí thập phân (ví dụ: bclệnh có scale=2hoặc printflệnh với %.2f) thay vì các chữ số có nghĩa.

Có một cách dễ dàng để định dạng số với chính xác 2 chữ số có nghĩa hay tôi phải viết hàm riêng của mình?

Câu trả lời:


13

Câu trả lời cho câu hỏi được liên kết đầu tiên có dòng gần như bỏ đi ở cuối:

Xem thêm %gđể làm tròn đến một số chữ số có nghĩa.

Vì vậy, bạn có thể chỉ cần viết

printf "%.2g" "$n"

(nhưng xem phần bên dưới về dấu tách thập phân và miền địa phương, và lưu ý rằng không phải Bash printfkhông cần hỗ trợ %f%g).

Ví dụ:

$ printf "%.2g\n" 76543 0.0076543
7.7e+04
0.0077

Tất nhiên, bây giờ bạn có biểu diễn số mũ thay vì số thập phân thuần túy, vì vậy bạn sẽ muốn chuyển đổi lại:

$ printf "%0.f\n" 7.7e+06
7700000

$ printf "%0.7f\n" 7.7e-06
0.0000077

Đặt tất cả những thứ này lại với nhau và gói nó trong một chức năng:

# Function round(precision, number)
round() {
    n=$(printf "%.${1}g" "$2")
    if [ "$n" != "${n#*e}" ]
    then
        f="${n##*e-}"
        test "$n" = "$f" && f= || f=$(( ${f#0}+$1-1 ))
        printf "%0.${f}f" "$n"
    else
        printf "%s" "$n"
    fi
}

(Lưu ý - chức năng này được viết bằng vỏ di động (POSIX), nhưng giả sử printfxử lý các chuyển đổi dấu phẩy động. Bash có tích hợp sẵn printf, vì vậy bạn vẫn ổn ở đây và việc triển khai GNU cũng hoạt động, vì vậy hầu hết GNU / Hệ thống Linux có thể sử dụng Dash một cách an toàn).

Các trường hợp thử nghiệm

radix=$(printf %.1f 0)
for i in $(seq 12 | sed -e 's/.*/dc -e "12k 1.234 10 & 6 -^*p"/e' -e "y/_._/$radix/")
do
    echo $i "->" $(round 2 $i)
done

Kết quả kiểm tra

.000012340000 -> 0.000012
.000123400000 -> 0.00012
.001234000000 -> 0.0012
.012340000000 -> 0.012
.123400000000 -> 0.12
1.234 -> 1.2
12.340 -> 12
123.400 -> 120
1234.000 -> 1200
12340.000 -> 12000
123400.000 -> 120000
1234000.000 -> 1200000

Một lưu ý về dấu tách thập phân và miền địa phương

Tất cả các công việc ở trên đều giả định rằng ký tự cơ số (còn được gọi là dấu tách thập phân) ., như ở hầu hết các địa phương tiếng Anh. ,Thay vào đó, các địa phương khác sử dụng và một số vỏ có tích hợp printftôn trọng miền địa phương. Trong các shell này, bạn có thể cần phải thiết lập LC_NUMERIC=Cđể buộc sử dụng .dưới dạng ký tự cơ số hoặc viết /usr/bin/printfđể ngăn việc sử dụng phiên bản tích hợp. Điều này sau đó phức tạp bởi thực tế là (ít nhất là một số phiên bản) dường như luôn phân tích các đối số bằng cách sử dụng ., nhưng in bằng các cài đặt ngôn ngữ hiện tại.


@ Stéphane Chazelas, tại sao bạn thay đổi shebang vỏ POSIX được kiểm tra cẩn thận của tôi trở lại Bash sau khi tôi loại bỏ bashism? Nhận xét của bạn đề cập đến %f/ %g, nhưng đó là printfđối số và người ta không cần POSIX printfđể có vỏ POSIX. Tôi nghĩ bạn nên bình luận thay vì chỉnh sửa ở đó.
Toby Speight

printf %gkhông thể được sử dụng trong tập lệnh POSIX. Đúng là nó thuộc về printftiện ích, nhưng tiện ích đó được tích hợp trong hầu hết các shell. OP được gắn thẻ là bash, vì vậy sử dụng bash shebang là một cách dễ dàng để có được một printf hỗ trợ% g. Mặt khác, bạn cần thêm một giả định printf của bạn (hoặc bản dựng sẵn của printf shnếu bạn printfđược xây dựng ở đó) hỗ trợ phi tiêu chuẩn (nhưng khá phổ biến) %g...
Stéphane Chazelas

dash's có tích hợp printf(hỗ trợ %g). Trên các hệ thống GNU, mkshcó lẽ là lớp vỏ duy nhất trong những ngày này sẽ không có nội dung printf.
Stéphane Chazelas

Cảm ơn những cải tiến của bạn - Tôi đã chỉnh sửa để xóa shebang (vì câu hỏi được gắn thẻ bash) và chuyển một số điều này sang ghi chú - bây giờ nó có chính xác không?
Toby Speight

4

TL; DR

Chỉ cần sao chép và sử dụng chức năng sigftrong phần A reasonably good "significant numbers" function:. Nó được viết (như tất cả các mã trong câu trả lời này) để làm việc với dấu gạch ngang .

Nó sẽ đưa ra printfxấp xỉ cho phần nguyên của N với các $sigchữ số.

Về dấu phân cách thập phân.

Vấn đề đầu tiên cần giải quyết với printf là hiệu ứng và việc sử dụng "dấu thập phân", ở Mỹ là một điểm và trong DE là dấu phẩy (ví dụ). Đó là một vấn đề bởi vì những gì hoạt động cho một số miền (hoặc vỏ) sẽ thất bại với một số miền khác. Thí dụ:

$ dash -c 'printf "%2.3f\n" 12.3045'
12.305
$  ksh -c 'printf "%2.3f\n" 12.3045'
ksh: printf: 12.3045: arithmetic syntax error
ksh: printf: 12.3045: arithmetic syntax error
ksh: printf: warning: invalid argument of type f
12,000
$ ksh -c 'printf "%2.2f\n" 12,3045'
12,304

Một giải pháp phổ biến (và không chính xác) là đặt LC_ALL=Ccho lệnh printf. Nhưng điều đó đặt dấu thập phân thành dấu thập phân cố định. Đối với các địa điểm nơi dấu phẩy (hoặc khác) là ký tự được sử dụng phổ biến là một vấn đề.

Giải pháp là tìm ra bên trong tập lệnh cho shell chạy nó là dấu phân cách thập phân cục bộ. Điều đó khá đơn giản:

$ printf '%1.1f' 0
0,0                            # for a comma locale (or shell).

Loại bỏ số không:

$ dec="$(IFS=0; printf '%s' $(printf '%.1f'))"; echo "$dec"
,                              # for a comma locale (or shell).

Giá trị đó được sử dụng để thay đổi tệp với danh sách kiểm tra:

sed -i 's/[,.]/'"$dec"'/g' infile

Điều đó làm cho các lần chạy trên bất kỳ shell hoặc miền địa phương tự động hợp lệ.


Một số điều cơ bản.

Nó nên trực quan để cắt số được định dạng với định dạng %.*ehoặc thậm chí %.*gcủa printf. Sự khác biệt chính giữa việc sử dụng %.*ehoặc %.*glà cách họ đếm các chữ số. Một người sử dụng số đếm đầy đủ, người kia cần số lượng ít hơn 1:

$ printf '%.*e  %.*g' $((4-1)) 1,23456e0 4 1,23456e0
1,235e+00  1,235

Điều đó làm việc tốt cho 4 chữ số có nghĩa.

Sau khi số chữ số đã được cắt từ số, chúng ta cần một bước bổ sung để định dạng các số có số mũ khác 0 (như ở trên).

$ N=$(printf '%.*e' $((4-1)) 1,23456e3); echo "$N"
1,235e+03
$ printf '%4.0f' "$N"
1235

Điều này hoạt động chính xác. Tổng số phần nguyên (ở bên trái dấu thập phân) chỉ là giá trị của số mũ ($ exp). Số lượng thập phân cần thiết là số chữ số có nghĩa ($ sig) ít hơn số lượng chữ số đã được sử dụng ở phần bên trái của dấu tách thập phân:

a=$((exp<0?0:exp))                      ### count of integer characters.
b=$((exp<sig?sig-exp:0))                ### count of decimal characters.
printf '%*.*f' "$a" "$b" "$N"

Vì phần không thể thiếu cho fđịnh dạng không có giới hạn, nên trên thực tế không cần phải khai báo rõ ràng và mã này (đơn giản hơn) hoạt động:

a=$((exp<sig?sig-exp:0))                ### count of decimal characters.
printf '%0.*f' "$a" "$N"

Thử thách đầu tiên.

Một chức năng đầu tiên có thể làm điều này theo cách tự động hơn:

# Function significant (number, precision)
sig1(){
    sig=$(($2>0?$2:1))                      ### significant digits (>0)
    N=$(printf "%0.*e" "$(($sig-1))" "$1")  ### N in sci (cut to $sig digits).
    exp=$(echo "${N##*[eE+]}+1"|bc)         ### get the exponent.
    a="$((exp<sig?sig-exp:0))"              ### calc number of decimals.
    printf "%0.*f" "$a" "$N"                ### re-format number.
}

Lần thử đầu tiên này hoạt động với nhiều số nhưng sẽ thất bại với các số có số chữ số khả dụng nhỏ hơn số lượng đáng kể được yêu cầu và số mũ nhỏ hơn -4:

   Number       sig                       Result        Correct?
   123456789 --> 4<                       123500000 >--| yes
       23455 --> 4<                           23460 >--| yes
       23465 --> 4<                           23460 >--| yes
      1,2e-5 --> 6<                    0,0000120000 >--| no
     1,2e-15 -->15< 0,00000000000000120000000000000 >--| no
          12 --> 6<                         12,0000 >--| no  

Nó sẽ thêm nhiều số không cần thiết.

Thử nghiệm thứ hai.

Để giải quyết điều đó, chúng ta cần làm sạch N của số mũ và bất kỳ số 0 nào. Sau đó, chúng ta có thể có được độ dài hiệu quả của các chữ số có sẵn và làm việc với điều đó:

# Function significant (number, precision)
sig2(){ local sig N exp n len a
    sig=$(($2>0?$2:1))                      ### significant digits (>0)
    N=$(printf "%+0.*e" "$(($sig-1))" "$1") ### N in sci (cut to $sig digits).
    exp=$(echo "${N##*[eE+]}+1"|bc)         ### get the exponent.
    n=${N%%[Ee]*}                           ### remove sign (first character).
    n=${n%"${n##*[!0]}"}                    ### remove all trailing zeros
    len=$(( ${#n}-2 ))                      ### len of N (less sign and dec).
    len=$((len<sig?len:sig))                ### select the minimum.
    a="$((exp<len?len-exp:0))"              ### use $len to count decimals.
    printf "%0.*f" "$a" "$N"                ### re-format the number.
}

Tuy nhiên, đó là sử dụng toán học dấu phẩy động và "không có gì đơn giản trong dấu phẩy động": Tại sao số của tôi không cộng lại?

Nhưng không có gì trong "điểm nổi" là đơn giản.

printf "%.2g  " 76500,00001 76500
7,7e+04  7,6e+04

Tuy nhiên:

 printf "%.2g  " 75500,00001 75500
 7,6e+04  7,6e+04

Tại sao?:

printf "%.32g\n" 76500,00001e30 76500e30
7,6500000010000000001207515928855e+34
7,6499999999999999997831226199114e+34

Và, cũng, lệnh printflà một nội dung của nhiều đạn pháo.
Những gì printfin có thể thay đổi với vỏ:

$ dash -c 'printf "%.*f" 4 123456e+25'
1234560000000000020450486779904.0000
$  ksh -c 'printf "%.*f" 4 123456e+25'
1234559999999999999886313162278,3840

$  dash ./script.sh
   123456789 --> 4<                       123500000 >--| yes
       23455 --> 4<                           23460 >--| yes
       23465 --> 4<                           23460 >--| yes
      1.2e-5 --> 6<                        0.000012 >--| yes
     1.2e-15 -->15<              0.0000000000000012 >--| yes
          12 --> 6<                              12 >--| yes
  123456e+25 --> 4< 1234999999999999958410892148736 >--| no

Một chức năng "số lượng đáng kể" hợp lý tốt:

dec=$(IFS=0; printf '%s' $(printf '%.1f'))   ### What is the decimal separator?.
sed -i 's/[,.]/'"$dec"'/g' infile

zeros(){ # create an string of $1 zeros (for $1 positive or zero).
         printf '%.*d' $(( $1>0?$1:0 )) 0
       }

# Function significant (number, precision)
sigf(){ local sig sci exp N sgn len z1 z2 b c
    sig=$(($2>0?$2:1))                      ### significant digits (>0)
    N=$(printf '%+e\n' $1)                  ### use scientific format.
    exp=$(echo "${N##*[eE+]}+1"|bc)         ### find ceiling{log(N)}.
    N=${N%%[eE]*}                           ### cut after `e` or `E`.
    sgn=${N%%"${N#-}"}                      ### keep the sign (if any).
    N=${N#[+-]}                             ### remove the sign
    N=${N%[!0-9]*}${N#??}                   ### remove the $dec
    N=${N#"${N%%[!0]*}"}                    ### remove all leading zeros
    N=${N%"${N##*[!0]}"}                    ### remove all trailing zeros
    len=$((${#N}<sig?${#N}:sig))            ### count of selected characters.
    N=$(printf '%0.*s' "$len" "$N")         ### use the first $len characters.

    result="$N"

    # add the decimal separator or lead zeros or trail zeros.
    if   [ "$exp" -gt 0 ] && [ "$exp" -lt "$len" ]; then
            b=$(printf '%0.*s' "$exp" "$result")
            c=${result#"$b"}
            result="$b$dec$c"
    elif [ "$exp" -le 0 ]; then
            # fill front with leading zeros ($exp length).
            z1="$(zeros "$((-exp))")"
            result="0$dec$z1$result"
    elif [ "$exp" -ge "$len" ]; then
            # fill back with trailing zeros.
            z2=$(zeros "$((exp-len))")
            result="$result$z2"
    fi
    # place the sign back.
    printf '%s' "$sgn$result"
}

Và kết quả là:

$ dash ./script.sh
       123456789 --> 4<                       123400000 >--| yes
           23455 --> 4<                           23450 >--| yes
           23465 --> 4<                           23460 >--| yes
          1.2e-5 --> 6<                        0.000012 >--| yes
         1.2e-15 -->15<              0.0000000000000012 >--| yes
              12 --> 6<                              12 >--| yes
      123456e+25 --> 4< 1234000000000000000000000000000 >--| yes
      123456e-25 --> 4<       0.00000000000000000001234 >--| yes
 -12345.61234e-3 --> 4<                          -12.34 >--| yes
 -1.234561234e-3 --> 4<                       -0.001234 >--| yes
           76543 --> 2<                           76000 >--| yes
          -76543 --> 2<                          -76000 >--| yes
          123456 --> 4<                          123400 >--| yes
           12345 --> 4<                           12340 >--| yes
            1234 --> 4<                            1234 >--| yes
           123.4 --> 4<                           123.4 >--| yes
       12.345678 --> 4<                           12.34 >--| yes
      1.23456789 --> 4<                           1.234 >--| yes
    0.1234555646 --> 4<                          0.1234 >--| yes
       0.0076543 --> 2<                          0.0076 >--| yes
   .000000123400 --> 2<                      0.00000012 >--| yes
   .000001234000 --> 2<                       0.0000012 >--| yes
   .000012340000 --> 2<                        0.000012 >--| yes
   .000123400000 --> 2<                         0.00012 >--| yes
   .001234000000 --> 2<                          0.0012 >--| yes
   .012340000000 --> 2<                           0.012 >--| yes
   .123400000000 --> 2<                            0.12 >--| yes
           1.234 --> 2<                             1.2 >--| yes
          12.340 --> 2<                              12 >--| yes
         123.400 --> 2<                             120 >--| yes
        1234.000 --> 2<                            1200 >--| yes
       12340.000 --> 2<                           12000 >--| yes
      123400.000 --> 2<                          120000 >--| yes

0

Nếu bạn đã có số dưới dạng một chuỗi, nghĩa là "3456" hoặc "0,003756", thì bạn chỉ có thể thực hiện số đó bằng cách sử dụng thao tác chuỗi. Sau đây là trên đỉnh đầu của tôi, và không được kiểm tra kỹ lưỡng, và sử dụng sed, nhưng hãy xem xét:

f() {
    local A="$1"
    local B="$(echo "$A" | sed -E "s/^-?0?\.?0*//")"
    local C="$(eval echo "${A%$B}")"
    if ((${#B} > 2)); then
        D="${B:0:2}"
    else
        D="$B"
    fi
    echo "$C$D"
}

Về cơ bản, bạn loại bỏ và lưu bất kỳ nội dung "-0.000" nào khi bắt đầu, sau đó sử dụng thao tác chuỗi con đơn giản trên phần còn lại. Một lưu ý về những điều trên là nhiều 0 hàng đầu không bị xóa. Tôi sẽ để nó như một bài tập.


1
Nhiều hơn một bài tập: nó không đệm số nguyên bằng số 0, cũng không tính đến dấu thập phân nhúng. Nhưng vâng, có thể thực hiện được bằng cách sử dụng phương pháp này (mặc dù việc đạt được điều đó có thể vượt quá các kỹ năng của OP).
Thomas Dickey
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.