Toán học mới với Java, Phần 2: Các số phảy động

Hãy tham gia cùng Elliotte Rusty Harold để xem xét những đặc trưng mới trong lớp java.lang.Math cổ điển trong bài báo gồm 2 phần này. Phần 1 tập trung vào các hàm toán học đơn thuần hơn. Phần 2 khám phá những hàm được thiết kế để hoạt động trên các số phảy động.

Elliotte Rusty Harold, Giáo sư, Polytechnic University

Elliotte Rusty Harold xuất thân từ bang New Orleans nơi mà ông vẫn thỉnh thoảng về thăm những lúc thảnh thơi. Tuy nhiên, ông đang cư trú gần Trung tâm University Town Center, Irvine cùng với vợ ông là Beth và những chú mèo Charm (được đặt tên theo hạt "charm quark" trong vật lý) và Marjorie (đặt tên theo tên của mẹ vợ ông). Trang Web Cafe au Lait của ông đã trở thành một trong những trang Java độc lập nổi tiếng nhất trên Internet, và trang Web phụ của ông, Cafe con Leche, đã trở thành một trong những trang XML phổ biến nhất. Cuốn sách của ông gần đây nhất là Refactoring HTML



14 09 2009

Phiên bản 5 của Java™ Language Specification đã thêm 10 phương thức mới vào java.lang.Mathjava.lang.StrictMath, và phiên bản Java 6 đã thêm 10 phương thức mới khác nữa. Phần 1 của bài báo này đã xem xét những phương thức mới có thể hiểu được trong toán học. Tức là, chúng đã cung cấp các hàm mà một nhà toán học thời chưa có máy tính có thể cảm thấy quen thuộc. Ở đây trong Phần 2, tôi chỉ tập trung vào các hàm quan trọng khi bạn nhận ra rằng chúng được thiết kế để hoạt động trên các số phảy động thay vì trên các số thực trừu tượng.

Như tôi đã chú ý trong Phần 1, sự phân biệt giữa một số thực như là e hay 0.2 và hiển thị của nó trên máy tính như là một số double trên Java là một điều rất quan trọng. Mô hình lý tưởng Platonic của số là hoàn toàn chính xác, trong khi thể hiện trên Java chỉ có một số bit nhất định để làm việc (32 đối với một số float, 64 đối với một số double). Giá trị cực đại của một số float là khoảng 3.4*1038, chưa đủ lớn để bạn thể hiện tất cả các con số, như là số electron trong vũ trụ.

Một số double có thể thể hiện các con số lên đến khoảng 1.8*10308, tức là nó có thể bao quát được hầu hết tất cả các đại lượng vật lý mà tôi có thể nghĩ ra. Tuy nhiên, khi bạn làm các phép tính trên các đại lượng toán học trừu tượng, thì nó có thể sẽ vượt quá những giá trị này. Ví dụ, chỉ phép tính 171! (171 * 170 * 169 * 168 * ... * 1) là đã đủ để vượt quá giới hạn của một số double. Một số float thì cũng chỉ có giới hạn ở phép tính 35!. Các số nhỏ (đó là các số gần về số 0) cũng có thể là một vấn đề, và các phép tính liên quan đến cả các số lớn và số nhỏ đều có thể là rất nguy hiểm.

Để giải quyết vấn đề này, tiêu chuẩn IEEE 754 cho toán học dấu phảy động (xem Tài nguyên) đã bổ sung các giá trị đặc biệt Inf để thể hiện Infinity (vô hạn) và NaN để thể hiện "Not a Number - không phải là một số." IEEE 754 cũng định nghĩa các số 0 âm và dương. (Trong toán học thông thường, số 0 không phải là âm cũng không phải là dương. Trong toán học ở máy tính, nó lại có thể được cả hai.) Các giá trị này làm phá vỡ những quy tắc cổ điển thông thường. Ví dụ, khi NaN xuất hiện, định luật về loại trừ trung gian không còn có tác dụng nữa. Không nhất thiết là cả hai x == y và x != y đều đúng. Cả hai có thể là sai nếu giá trị x (hoặc y) là NaN.

Ngoài những vấn đề về độ lớn, độ chính xác là một vấn đề thực tế hơn nữa. Chúng ta đã xem xét vấn đề này khi bạn thêm 0,1 khoảng 100 lần và kết quả nhận được là 9,99999999999998 thay vì 10:

for (double x = 0.0; x <= 10.0; x += 0.1) {
    System.err.println(x);
}

Đối với các ứng dụng đơn giản, bạn thường xuyên yêu cầu java.text.DecimalFormat định dạng lại kết quả cuối cùng thành một số nguyên (integer) gần nhất và gọi nó là một ngày (a day). Tuy nhiên, trong các ứng dụng khoa học và kỹ nghệ mà bạn không chắc lắm rằng phép tính đó có kết thúc là một số nguyên hay không, bạn cần rất cẩn thận. Nếu bạn đang trừ các số lớn với nhau để được một số nhỏ, bạn cần hết sức cẩn thận. Nếu bạn đang chia cho số nhỏ đó, bạn vẫn cần phải cẩn thận hơn nữa. Các bước tính đó có thể khuếch đại lên rất nhiều ngay cả với những lỗi rất nhỏ thành các lỗi lớn mà có thể gây ra những hậu quả nghiêm trọng khi những đáp án được áp dụng vào lĩnh vực vật lý. Các phép tính toán học chính xác bị thổi bay thành những sai lệch rất nghiêm trọng bởi những lỗi làm tròn gây ra bởi các số phảy động ít chính xác.

Những cách thể hiện các số float và double bằng nhị phân

Một số float theo tiêu chuẩn IEEE 754, được thực thi bởi ngôn ngữa Java, có 32 bit. Bit đầu tiên là bit dấu, 0 đối với dấu dương và 1 đối với dấu âm. Tám bit tiếp theo là số mũ, chúng có thể nắm giá trị từ -125 đến +127. 23 bit còn lại để nắm phần định trị (đôi khi còn được gọi là significand), dao động từ 0 đến 33.554.431. Đặt tất cả chúng lại với nhau, một số float sẽ được hiểu như sau: dấu * phần định trị * 2số mũ.

Các bạn đọc tinh mắt có thể để ý thấy rằng các số này không lấy tổng. Đầu tiên, tám bit dành cho số mũ sẽ hiển thị từ -128 đến 127, giống như một byte đã có dấu. Tuy nhiên các số mũ thường bị chệch một khoảng 126. Tức là, bạn bắt đầu với một giá trị chưa được đánh dấu (0 đến 255) và sau đó trừ đi một khoảng 126 thì bạn mới nhận được số mũ thật, tức là nó bây giờ sẽ là -126 đến 128. Nhưng, trừ số 128 và -126 vì đây là những giá trị đặc biệt. Khi phần số mũ là 128, thì đó là dấu hiệu chỉ ra rằng con số đó có thể là Inf, -Inf, hoặc NaN. Để rõ hơn nó thuộc loại nào, bạn phải xem phần định trị. Khi phần số mũ là các số 0 (tức là -126), thì đó là dấu hiệu chỉ ra rằng con số đó denormalized (phi chuẩn hóa) (nó ám chỉ nhiều hơn là như vậy) nhưng phần số mũ vẫn là -125.

Phần định trị cơ bản là một số chưa được đánh dấu gồm 23 bit — đủ đơn giản. Hai mươi ba bit có thể nắm một số từ 0 đến 224-1, tức là 16.777.215. Đợi một chút, tôi đã nói rằng phần định trị dao động từ 0 đến 33.554.431 chưa nhỉ? Đó là 225-1. Thế thì cái bit thêm đó từ đâu mà ra nhỉ?

Hóa ra là bạn có thể sử dụng số mũ để thể hiện bit đầu tiên là gì. Nếu số mũ là tất cả các các bit số 0, thì bit đầu tiên cũng là số 0. Ngược lại, bit đầu tiên sẽ là số 1. Bởi vì bạn luôn luôn biết rằng bit đầu tiên là gì nên con số hiển thị sẽ không cần phải bao gồm nó nữa. Bạn sẽ có thêm một bit nữa.

Các số phảy động mà bit đầu của phần định trị là số 1 thì đều là normalized (chuẩn hóa). Tức là, phần định trị luôn luôn có giá trị từ 1 đến 2. Các số phảy động mà bit đầu tiên của phần định trị là số 0 thì đều là denormalized (phi chuẩn hóa) và có thể thể hiện được các số nhỏ hơn nhiều, thậm chí là với số mũ luôn là -125.

Các số double cũng được mã hóa với cách hoàn toàn tương tự chỉ khác ở chỗ chúng dùng một phần định trị 52 bit và phần số mũ là 11 bit vì thế nó chính xác hơn. Độ chệch của số mũ trong một số double là 1023.


Phần định trị và số mũ

Hai phương pháp getExponent() được bổ sung vào Java 6 trả lại số mũ không chệch được sử dụng để thể hiện số float hoặc double. Đây là một số nằm trong khoảng từ -125 đến +127 đối với các số float và khoảng từ -1022 đến +1023 đối với các số double (+128/+1024 cho Inf và NaN). Thí dụ, Ví dụ 1 so sánh các kết quả của phương pháp getExponent() với một logarit cơ số 2 cổ điển hơn:

Ví dụ 1. Math.log(x)/Math.log(2) vs. Math.getExponent()
public class ExponentTest {

    public static void main(String[] args) {
       System.out.println("x\tlg(x)\tMath.getExponent(x)");
       for (int i = -255; i < 256; i++) {
           double x = Math.pow(2, i);
           System.out.println(
                   x + "\t" +
                   lg(x) + "\t" +
                   Math.getExponent(x));
       }
    }

    public static double lg(double x) {
        return Math.log(x)/Math.log(2);
    }
}

Đối với một vài giá trị mà có thể làm tròn, Math.getExponent() có thể là một bit hoặc hai chính xác hơn so với phép tính thông thường:

 x              lg(x)             Math.getExponent(x)
...
2.68435456E8    28.0                      28
5.36870912E8    29.000000000000004        29
1.073741824E9   30.0                      30
2.147483648E9   31.000000000000004        31
4.294967296E9   32.0                      32

Math.getExponent() cũng có thể nhanh hơn nếu bạn đang làm rất nhiều trong số các phép tính này. Tuy nhiên, xin nói trước rằng điều này chỉ có tác dụng với các lũy thừa bậc 2. Ví dụ, đây là kết quả nếu tôi thay đổi thành lũy thừa bậc 3:

x      lg(x)     Math.getExponent(x)
...
1.0    0.0                 0
3.0    1.584962500721156   1
9.0    3.1699250014423126  3
27.0   4.754887502163469   4
81.0   6.339850002884625   6

Phần định trị không được xem xét bởi getExponent() mà bởi Math.log(). Với một chút cố gắng, bạn có thể độc lập tìm ra phần định trị, lấy logarit của nó, và thêm giá trị đó vào số mũ, nhưng điều đó cũng không đáng để cố gắng. Math.getExponent() ban đầu rất hữu ích khi bạn muốn một bản đánh giá nhanh về thứ tự của độ lớn, chứ không phải là giá trị chính xác.

Không giống với Math.log(), Math.getExponent() không bao giờ trả lại giá trị NaN hay Inf. Nếu đối số là một NaN hay Inf, thì kết quả sẽ là 128 đối với một float và 1024 đối với một double. Nếu đối số là 0, thì kết quả sẽ là -127 đối với một float và -1023 đối với một double. Nếu đối số là một số âm, thì số mũ sẽ giống với số mũ của giá trị tuyệt đối của số đó. Ví dụ, số mũ của -8 là 3, cũng giống như số mũ của 8.

Không có một phương pháp getMantissa() tương ứng, nhưng sẽ dễ dàng suy ra được một phương pháp chỉ cần với một chút số học:

    public static double getMantissa(double x) {
        int exponent = Math.getExponent(x);
        return x / Math.pow(2, exponent);
    }

Phần định trị cũng có thể được tìm ra thông qua mặt nạ bit (bit masking), mặc dù thuật toán hơi kém rõ ràng. Để trích xuất các bit, bạn chỉ cần tính toán Double.doubleToLongBits(x) & 0x000FFFFFFFFFFFFFL. Tuy nhiên, bạn khi đó cũng cần phải tính đến một bit thêm nữa trong một số bị chuẩn hóa, và sau đó biến đổi trở lại thành một số phảy động từ 1 đến 2.


Các đơn vị ULP

Các số thực có mật độ rất nhiều. Với bất kì hai số thực khác biệt nào mà bạn có thể đặt ra, tôi đều có thể tìm ra một số khác nằm giữa hai số đó. Nhưng điều đó lại không đúng với các số phảy động. Cho một số float hay double, thì có một số float bên cạnh; và có một khoảng cách giới hạn tối thiểu giữa các số float tiếp theo và các số double. Phương thức nextUp() trả lại số phảy động gần nhất lớn hơn đối số đầu tiên. Thí dụ, Ví dụ 2 in tất cả các số float từ 1.0 đến 2.0:

Ví dụ 2. Đếm các float
public class FloatCounter {

    public static void main(String[] args) {
        float x = 1.0F;
        int numFloats = 0;
        while (x <= 2.0) {
            numFloats++;
            System.out.println(x);
            x = Math.nextUp(x);
        }
        System.out.println(numFloats);
    }

}

Hóa ra là có chính xác 8.388.609 các float trong khoảng từ 1.0 đến 2.0; các số lớn thì có thể nhưng các số vô cùng không đếm được của các số thực thì khó có thể tồn tại trong dãy này. Các số tiếp theo là cách nhau khoảng 0,0000001. Khoảng cách này được gọi là một đơn vị ULP viết tắt của unit of least precision hay unit in the last place.

Nếu bạn cần đi ngược lại — tức là, tìm số phảy động gần nhất nhỏ hơn một số đã định — bạn có thể sử dụng phương thức nextAfter() thay vào đó. Đối số thứ 2 xác định việc tìm số gần nhất ở trên hay dưới đối số đầu tiên:

public static double nextAfter(float start, float direction)
public static double nextAfter(double start, double direction)

Nếu direction lớn hơn start, thì nextAfter() trả lại số kế tiếp ở trên start. Nếu direction nhỏ hơn start, nextAfter() sẽ trả lại một số kế tiếp ở dưới start. Nếu direction bằng start, nextAfter() trả lại start là chính nó.

Các phương thức này có thể hữu ích trong một số ứng dụng mô hình hóa và vẽ biểu đồ. Về số lượng, bạn có thể muốn trích mẫu một giá trị tại 10.000 vị trí giữa khoảng ab, nhưng nếu bạn chỉ đang lấy đủ độ chính xác để xác định 1.000 điểm duy nhất giữa ab, thì bạn đang làm 9 phần 10 của công việc là thừa thãi. Bạn có thể chỉ cần làm 1 phần 10 của công việc đó và thu lại những kết quả tốt tương tự.

Tất nhiên, nếu bạn thực sự cần thêm sự chính xác, thì bạn sẽ cần phải lấy một loại dữ liệu có độ chính xác hơn, ví dụ như là một double hay là một BigDecimal. Ví dụ, tôi đã thấy điều này ở trong Mandelbrot set explorers chỗ mà bạn có thể phóng to đến mức mà toàn bộ hình ảnh rơi vào giữa hai số double gần nhau nhất. Mandelbrot set vô cùng sâu và phức tạp ở mọi mức độ, nhưng một float hay double có thể chỉ đi quá sâu trước khi mất khả năng phân biệt các điểm gần nhau.

Phương thức Math.ulp() trả lại khoảng cách từ một số đến các số kế bên gần nhất của nó. Ví dụ 3 liệt kê các ULP cho các lũy thừa bậc 2 khác nhau:

Ví dụ 3. Các ULP của các lũy thừa bậc 2 cho một số float
public class UlpPrinter {

    public static void main(String[] args) {
        for (float x = 1.0f; x <= Float.MAX_VALUE; x *= 2.0f) {
            System.out.println(Math.getExponent(x) + "\t" + x + "\t" + Math.ulp(x));
        }
    }

}

Đây là một số kết quả:

0   1.0   1.1920929E-7
1   2.0   2.3841858E-7
2   4.0   4.7683716E-7
3   8.0   9.536743E-7
4   16.0  1.9073486E-6
...
20  1048576.0   0.125
21  2097152.0   0.25
22  4194304.0   0.5
23  8388608.0   1.0
24  1.6777216E7 2.0
25  3.3554432E7 4.0
...
125 4.2535296E37    5.0706024E30
126 8.507059E37     1.0141205E31
127 1.7014118E38    2.028241E31

Sự hoàn toàn chính xác của các số phảy động có một hệ quả không mong đợi đó là: quá một điểm nhất định như x+1 == x là đúng. Thí dụ, phép lặp có vẻ như đơn giản này thực ra là vô hạn:

for (float x = 16777213f; x <
  16777218f; x += 1.0f) {
    System.out.println(x);
}

Thực tế, phép lặp này sẽ đi đến bế tắc ở một điểm cố định chính xác là ở 16.777.216. Đó là 224, và điểm mà ở đó ULP bây giờ lớn hơn số gia.

Như các bạn có thể thấy, các float khá chính xác cho các số nhỏ có lũy thừa bậc 2. Tuy nhiên, độ chính xác trở thành vấn đề đối với nhiều ứng dụng gần khoảng 220. Gần giới hạn độ lớn của một float, các giá trị kế tiếp được tách biệt bởi một triệu lũy thừa 6 (thực ra, hơn một chút, nhưng tôi không thể tìm ra một từ nào khác có thể lớn hơn nữa).

Như Ví dụ 3 minh họa, kích thước của một ULP không phải là hằng số. Khi các con số lớn hơn nữa, thì càng có ít các số float giữa chúng. Ví dụ, chỉ có 1.025 số float trong khoảng từ 10.000 đến 10.001; và chúng cách nhau một khoảng là 0,001. Khoảng từ 1.000.000 đến 1.000.001 chỉ có 17 số float, và chúng cách nhau khoảng 0,05. Độ chính xác đi ngược lại với độ lớn. Đối với một số float ở 10.000.000, thì ULP thực sự đã lớn tới 1,0; và quá điểm đó, có rất nhiều giá trị số nguyên mà ánh xạ tới cùng một số float tương tự. Đối với một số double điều này không xảy ra cho đến khoảng 45 triệu lũy thừa 4 (4.5E15),nhưng nó vẫn là một mối lo ngại.

Phương thức Math.ulp() có một cách dùng thực tế trong kiểm tra. Như bạn đã biết rõ, bạn không nên thường xuyên so sánh các số phảy động với nhau để có sự ngang bằng chính xác. Thay vào đó, bạn kiểm tra thấy rằng chúng bằng nhau trong một giá trị dung sai nhất định. Ví dụ, trong JUnit bạn có thể so sánh các giá trị phảy động mong đợi với các giá trị phảy động thực tế như vậy:

assertEquals(expectedValue, actualValue, 0.02);

Điều này khẳng định rằng giá trị thực là nằm trong khoảng 0,02 của giá trị mong đợi. Tuy nhiên, một khoảng 0,02 liệu đã là khoảng dung sai hợp lý chưa? Nếu giá trị mong đợi là 10,5 hay -107,82, thì 0,02 có thể là tốt. Tuy nhiên, nếu giá trị mong đợi là vài tỉ, thì dung sai có thể hoàn toàn không thể phân biệt được với số 0. Thường thì cái bạn kiểm tra là lỗi tương ứng đối với các ULP. Dựa vào độ chính xác mà phép tính yêu cầu, bạn thường sẽ chọn một giá trị dung sai từ 1 đến 10 ULP. Ví dụ, ở đây tôi xác định rõ rằng giá trị thực tế cần phải nằm trong 5 ULP của giá trị đúng:

assertEquals(expectedValue, actualValue, 5*Math.ulp(expectedValue));

Dựa vào giá trị mong đợi là bao nhiêu, nó có thể là cỡ 1 phần nghìn tỉ hoặc nó có thể là hàng triệu.


scalb

Math.scalb(x, y) nhân x với 2y (scalb là viết tắt của "scale binary").

public static double scalb(float f, int scaleFactor)
public static double scalb(double d, int scaleFactor)

Ví dụ, Math.scalb(3, 4) trả lại kết quả 3 * 24, là 3*16, và là 48.0. Bạn có thể sử dụng Math.scalb() theo một thực thi thay thế của getMantissa():

public static double getMantissa(double x) {
    int exponent = Math.getExponent(x);
    return x / Math.scalb(1.0, exponent);
}

Vậy Math.scalb() khác với x*Math.pow(2, scaleFactor) như thế nào? Thực ra, kết quả cuối cùng là không khác. Tôi đã không thể nghĩ ra được bất kì dữ liệu đầu vào nào mà ở đó kết quả trả lại là một bit đơn lẻ khác biệt. Tuy nhiên, kết quả thể hiện cũng đáng để bạn nhìn lại lần thứ 2. Math.pow() là một nhân tố có ảnh hưởng rất lớn đến kết quả thể hiện. Cần phải nắm được thực sự những trường hợp kỳ lạ như là tăng 3,14 lên lũy thừa -0,078. Thông thường nó chọn hoàn toàn một thuật toán sai cho các lũy thừa số nguyên như là 2 và 3, hay là đối với các trường hợp đặc biệt như là một cơ số của 2.

Như với bất cứ khẳng định kết quả thể hiện chung nào khác, tôi phải hết sức lưỡng lự về điều này. Một số trình biên dịch và VM là thông minh hơn cả so với các loại khác. Một số bộ tối ưu hóa có thể nhận biết x*Math.pow(2, y) là một trường hợp đặc biệt và biến đổi nó thành Math.scalb(x, y) hay thành một cái gì đó tương tự. Vì vậy, có thể sẽ không có sự khác biệt nào về kết quả thể hiện cả. Tuy nhiên, tôi đã xác nhận rằng ít nhất một số VM là không hẳn thông minh cho lắm. Khi kiểm tra với Java 6 VM của Apple, ví dụ, Math.scalb() là hai thứ tự độ lớn nhanh hơn x*Math.pow(2, y). Tất nhiên, thông thường điều này sẽ không gây ra vấn đề dù là nhỏ nào cả. Tuy nhiên, trong những trường hợp hiếm hoi đó mà bạn phải thực hiện hàng triệu phép mũ hóa, bạn có thể muốn nghĩ về liệu là bạn có thể chuyển đổi chúng để sử dụng Math.scalb() thay thế hay không.


Copysign

Phương pháp Math.copySign() thiết lập dấu cho đối số đầu tiên tới dấu của đối số thứ 2. Một thực thi ngờ nghệch có thể trông như Ví dụ 4:

Ví dụ 4. Một thuật toán copysign có thể
public static double copySign(double magnitude, double sign) {
    if (magnitude == 0.0) return 0.0;
    else if (sign < 0) {
      if (magnitude < 0) return magnitude;
      else return -magnitude;
    }
    else if (sign > 0) {
      if (magnitude < 0) return -magnitude;
      else return magnitude;
    }
    return magnitude;
}

Tuy nhiên, thực thi trong thực tế lại trông như Ví dụ 5:

Ví dụ 5. Thuật toán thực từ sun.misc.FpUtils
public static double rawCopySign(double magnitude, double sign) {
    return Double.longBitsToDouble((Double.doubleToRawLongBits(sign) &
                                   (DoubleConsts.SIGN_BIT_MASK)) |
                                   (Double.doubleToRawLongBits(magnitude) &
                                   (DoubleConsts.EXP_BIT_MASK |
                                   DoubleConsts.SIGNIF_BIT_MASK)));
}

Nếu bạn nghĩ về điều này kĩ càng và lấy ra những bit, bạn sẽ thấy rằng các dấu của giá trị NaN được coi là dương. Về cơ bản, Math.copySign() không hứa hẹn rằng — chỉ StrictMath.copySign() có — nhưng trong thực tế, chúng cả hai đều dẫn ra mã xoay tròn bit (bit-twiddling code) tương tự.

Ví dụ 5 có lẽ có thể ở một mặt nào đó nhanh hơn Ví dụ 4, nhưng nguyên nhân chính của nó là để nắm được hoàn toàn số 0 âm. Math.copySign(10, -0.0) trả lại -10, trong khi Math.copySign(10, 0.0) trả lại 10.0. Thuật toán ngờ nghệch trong Ví dụ 4 trả lại 10.0 trong cả hai trường hợp. Số 0 âm có thể xuất hiện khi bạn thực hiện các thao tác yêu cầu sự chính xác như là chia một số double âm cực nhỏ với một số double dương cực lớn. Ví dụ, -1.0E-147/2.1E189 trả lại số 0 âm, trong khi 1.0E-147/2.1E189 trả lại một số 0 dương. Tuy nhiên, hai giá trị này có thể so sánh bằng nhau với == vì thế nếu bạn muốn phân biệt chúng, bạn cần sử dụng Math.copySign(10, -0.0) hay Math.signum() (được gọi là Math.copySign(10, -0.0)) để so sánh chúng.


Logarit và các hàm mũ

Một hàm mũ được coi là một ví dụ tốt cho bạn phải cẩn thận khi phải xử lý các số phảy động với độ chính các có hạn thay vì các số thực hoàn toàn chính xác. ex (Math.exp()) xuất hiện trong rất nhiều biểu thức. Ví dụ, nó được sử dụng để xác định hàm cosh như được thảo luận trong Phần 1:

cosh(x) = (ex + e-x)/2.

Tuy nhiên, đối với các giá trị âm của x, đại thể là -4 và thấp hơn, hàm logarit được dùng để tính toán Math.exp() có vẻ như không phù hợp và dễ có thể mắc lỗi làm tròn. Sẽ chính xác hơn nếu tính ex - 1 với một hàm logarit khác và sau đó cộng 1 vào kết quả cuối cùng. Phương thức Math.expm1() thi hành hàm logarit khác này. (Chữ m1 viết tắt cho "minus 1.") Thí dụ, Ví dụ 6 minh họa một hàm cosh mà chuyển đổi giữa hai hàm logarit dựa vào kích thước của x:

Ví dụ 6. Một hàm cosh
public static double cosh(double x) {
    if (x < 0) x = -x;
    double term1 = Math.exp(x);
    double term2 = Math.expm1(-x) + 1;
    return (term1 + term2)/2;
}

Ví dụ này mang một phần nào đó học thuật bởi vì thuật ngữ ex sẽ hoàn toàn át hẳn thuật ngữ e-x trong bất cứ trường hợp nào mà khác biệt giữa Math.exp()Math.expm1() + 1 mang ý nghĩa. Tuy nhiên. Math.expm1() lại khá thực tế trong các phép tính tài chính với những lượng lợi tức nhỏ, như là tỉ lệ hàng ngày của một tờ trái phiếu kho bạc.

Math.log1p() là hàm đảo ngược của Math.expm1(), cũng như Math.log() là hàm đảo ngược của Math.exp(). Nó tính toán hàm logarit của 1 cộng với đối số của nó. (Chữ 1p viết tắt của "plus 1.") Sử dụng cái này cho các giá trị cận 1. Thí dụ, thay vì tính toán Math.log(1.0002), bạn nên tính Math.log1p(0.0002).

Là ví dụ, giả sử bạn muốn biết số của các ngày được yêu cầu cho 1.000 đô la được đầu tư để sinh lãi thành 1.100 đô la ở mức tỉ lệ lãi suất hàng ngày là 0.03. Ví dụ 7 sẽ thực hiện điều này:

Ví dụ 7. Tìm lượng thời gian cần thiết để đạt được một giá trị tương lai định rõ từ sự đầu tư hiện tại
public static double calculateNumberOfPeriods(
        double presentValue, double futureValue, double rate) {
    return (Math.log(futureValue) - Math.log(presentValue))/Math.log1p(rate);
}

Trong trường hợp này, 1p có một cách hiểu rất tự nhiên, bởi vì 1+r xuất hiện trong các công thức thông thường để tính toán những thứ này. Nói cách khác, các nhà cho vay thường trích các tỉ lệ lãi suất như là tỉ lệ phần trăm thêm (phần +r ) mặc dù các nhà đầu tư tất nhiên hy vọng sẽ thu lại được (1+r)n của vốn đầu tư ban đầu của họ. Thực tế, bất cứ nhà đầu tư nào cho vay tiền ở mức 3% và thu lại được cũng chỉ có 3% vốn thực ra là họ đang làm việc kém hiệu quả.


Các số double không phải là các số thực

Các số phảy động không phải là các số thực. Có hữu hạn các số đó. Chúng có thể thể hiện được các giá trị cực đại và cực tiểu. Nhưng quan trọng nhất, chúng có độ chính xác giới hạn mặc dù là độ chính xác đó lớn và có khuynh hương gặp các lỗi làm tròn. Thực ra, khi làm việc với các số integer (số nguyên), các số float và các số double có thể có độ chính xác kém hơn so với các số int và long. Bạn nên xem xét cẩn thận những giới hạn này để tạo ra mã trình đáng tin cậy và mạnh mẽ, đặc biệt là trong các ứng dụng khoa học và kĩ nghệ. Các ứng dụng tài chính (và đặc biệt là các ứng dụng kế toán yêu cầu độ chính xác đến số hàng trăm cuối cùng) cũng cần phải hết sức cẩn thận khi xử lý các số float và các số double.

Các lớp java.lang.Mathjava.lang.StrictMath đã được thiết kế cẩn thận để giải quyết các vấn đề này. Việc sử dụng thích hợp các lớp này và những phương thức của chúng sẽ cải thiện các chương trình của bạn. Nếu không có gì khác, bài báo này cũng đã chỉ ra cho bạn mức độ toán học phảy động thực sự phức tạp như thế nào. Tốt hơn hết là giao phó cho các chuyên gia còn hơn là bạn tự làm các thuật toán của bạn thêm rắc rối. Nếu bạn có thể sử dụng java.lang.Mathjava.lang.StrictMath, thì hãy làm như vậy. Chúng luôn luôn là lựa chọn tốt hơn.

Tài nguyên

Học tập

Lấy sản phẩm và công nghệ

  • OpenJDK: Xem xét mã nguồn của các lớp toán bên trong thực thi Java SE mã mở này.
  • Tải xuống IBM® các phiên bản đánh giá sản phẩm và bắt tay vào các công cụ phát triển ứng dụng và các sản phẩm phần mềm trung gian (middleware) từ DB2®, Lotus®, Rational®, Tivoli®, và WebSphere®.

Thảo luận

Bình luận

developerWorks: Đăng nhập

Các trường được đánh dấu hoa thị là bắt buộc (*).


Bạn cần một ID của IBM?
Bạn quên định danh?


Bạn quên mật khẩu?
Đổi mật khẩu

Bằng việc nhấn Gửi, bạn đã đồng ý với các điều khoản sử dụng developerWorks Điều khoản sử dụng.

 


Ở lần bạn đăng nhập đầu tiên vào trang developerWorks, một hồ sơ cá nhân của bạn được tạo ra. Thông tin trong bản hồ sơ này (tên bạn, nước/vùng lãnh thổ, và tên cơ quan) sẽ được trưng ra cho mọi người và sẽ đi cùng các nội dung mà bạn đăng, trừ khi bạn chọn việc ẩn tên cơ quan của bạn. Bạn có thể cập nhật tài khoản trên trang IBM bất cứ khi nào.

Thông tin gửi đi được đảm bảo an toàn.

Chọn tên hiển thị của bạn



Lần đầu tiên bạn đăng nhập vào trang developerWorks, một bản trích ngang được tạo ra cho bạn, bạn cần phải chọn một tên để hiển thị. Tên hiển thị của bạn sẽ đi kèm theo các nội dung mà bạn đăng tải trên developerWorks.

Tên hiển thị cần có từ 3 đến 30 ký tự. Tên xuất hiện của bạn phải là duy nhất trên trang Cộng đồng developerWorks và vì lí do an ninh nó không phải là địa chỉ email của bạn.

Các trường được đánh dấu hoa thị là bắt buộc (*).

(Tên hiển thị cần có từ 3 đến 30 ký tự)

Bằng việc nhấn Gửi, bạn đã đồng ý với các điều khoản sử dụng developerWorks Điều khoản sử dụng.

 


Thông tin gửi đi được đảm bảo an toàn.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=70
Zone=Nguồn mở
ArticleID=424084
ArticleTitle=Toán học mới với Java, Phần 2: Các số phảy động
publish-date=09142009