Bài 2.5. Bài tập tổng hợp về lý thuyết số

Chào mừng bạn trở lại với series blog về Cấu trúc dữ liệu và Giải thuật!

Trong các bài trước, chúng ta đã cùng nhau khám phá những khái niệm cơ bản nhưng mạnh mẽ của lý thuyết số trong lập trình, như số nguyên tố, ước chung lớn nhất (GCD), bội chung nhỏ nhất (LCM), và các phép toán theo modulo. Lý thuyết là nền tảng, nhưng để thực sự nắm vững và áp dụng chúng một cách hiệu quả, không có gì tốt hơn là thực hành.

Bài viết này sẽ là nơi chúng ta cùng nhau vận dụng những kiến thức đã học thông qua một loạt các bài tập tổng hợp, từ cơ bản đến nâng cao một chút. Mỗi bài tập sẽ đi kèm với ví dụ minh họa bằng C++giải thích chi tiết về cách thuật toán hoạt động. Hãy sẵn sàng để bắt tay vào code!


Bài tập 1: Kiểm tra một số có phải là số nguyên tố?

Đây là bài toán kinh điển khi bắt đầu với lý thuyết số. Một số nguyên dương n lớn hơn 1 được gọi là số nguyên tố nếu nó chỉ có đúng hai ước dương phân biệt là 1 và chính nó.

Ý tưởng thuật toán:

Để kiểm tra xem n có phải là số nguyên tố hay không, chúng ta chỉ cần kiểm tra xem n có ước nào khác 1 và chính nó hay không. Cách đơn giản nhất là duyệt từ 2 đến n-1 và kiểm tra tính chia hết. Tuy nhiên, chúng ta có thể tối ưu đáng kể.

Nếu n có một ước d lớn hơn sqrt(n), thì chắc chắn nó phải có một ước n/d nhỏ hơn sqrt(n). Do đó, chúng ta chỉ cần kiểm tra các ước từ 2 đến sqrt(n).

Các trường hợp đặc biệt:

  • Số 0 và 1 không phải là số nguyên tố.
  • Số 2 là số nguyên tố duy nhất chẵn.

Code minh họa (C++):

#include <iostream>
#include <cmath> // Cần cho sqrt

bool isPrime(int n) {
    // 0 và 1 không phải là số nguyên tố
    if (n <= 1) {
        return false;
    }
    // 2 là số nguyên tố duy nhất chẵn
    if (n == 2) {
        return true;
    }
    // Các số chẵn lớn hơn 2 không phải là số nguyên tố
    if (n % 2 == 0) {
        return false;
    }
    // Kiểm tra các ước lẻ từ 3 đến sqrt(n)
    // Bước nhảy 2 để bỏ qua các số chẵn
    for (int i = 3; i * i <= n; i += 2) {
        if (n % i == 0) {
            return false; // Tìm thấy ước, n không phải số nguyên tố
        }
    }
    // Nếu không tìm thấy ước nào, n là số nguyên tố
    return true;
}

int main() {
    int num;
    std::cout << "Nhap mot so nguyen duong: ";
    std::cin >> num;

    if (isPrime(num)) {
        std::cout << num << " la so nguyen to." << std::endl;
    } else {
        std::cout << num << " khong phai la so nguyen to." << std::endl;
    }

    return 0;
}

Giải thích code:

  • Hàm isPrime(int n) nhận vào một số nguyên n.
  • Dòng if (n <= 1): Xử lý trường hợp n là 0 hoặc 1, trả về false ngay lập tức.
  • Dòng if (n == 2): Xử lý trường hợp n là 2, trả về true.
  • Dòng if (n % 2 == 0): Xử lý các số chẵn lớn hơn 2. Vì 2 là số chẵn nguyên tố duy nhất, mọi số chẵn khác chắc chắn chia hết cho 2 và không phải là số nguyên tố, nên trả về false.
  • Vòng lặp for (int i = 3; i * i <= n; i += 2): Đây là phần kiểm tra chính.
    • Bắt đầu từ i = 3 vì đã xử lý số 2.
    • Điều kiện lặp i * i <= n (tương đương i <= sqrt(n)) áp dụng tối ưu hóa đã nói ở trên.
    • Bước nhảy i += 2 giúp chúng ta chỉ kiểm tra các số lẻ, vì nếu n có ước chẵn thì nó đã bị loại ở bước trước, và một số lẻ chỉ có thể có ước lẻ. Điều này giúp giảm số lần lặp đi một nửa.
  • Trong vòng lặp, if (n % i == 0) kiểm tra xem n có chia hết cho i hay không. Nếu có, tức là n có một ước khác 1 và chính nó (là i), nên nó không phải số nguyên tố, và chúng ta trả về false.
  • Nếu vòng lặp kết thúc mà không tìm thấy ước nào, điều đó có nghĩa là n chỉ chia hết cho 1 và chính nó (đối với n > 1), nên hàm trả về true.
  • Hàm main chỉ đơn giản là nhận input, gọi hàm isPrime, và in kết quả.

Độ phức tạp: Độ phức tạp thời gian của thuật toán này là O(sqrt(n)).


Bài tập 2: Tìm ước chung lớn nhất (GCD) của hai số

Ước chung lớn nhất (GCD - Greatest Common Divisor) của hai số nguyên dương ab là số nguyên dương lớn nhất mà cả ab đều chia hết cho nó.

Ý tưởng thuật toán:

Thuật toán nổi tiếng và hiệu quả nhất để tìm GCD là Thuật toán Euclid. Dựa trên nguyên tắc:

GCD(a, b) = GCD(b, a % b) nếu b != 0 GCD(a, 0) = a

Nguyên tắc này cho phép chúng ta giảm kích thước của các số một cách nhanh chóng cho đến khi một số trở thành 0.

Code minh họa (C++):

Chúng ta có thể cài đặt thuật toán Euclid theo hai cách: đệ quy hoặc lặp. Cả hai đều tương đương về mặt logic. Dưới đây là phiên bản lặp (iterative) thường được ưa chuộng trong lập trình thi đấu vì tránh được overhead của đệ quy.

#include <iostream>
#include <numeric> // C++17 trở lên có std::gcd

// Hàm tính GCD sử dụng thuật toán Euclid (phiên bản lặp)
int gcd(int a, int b) {
    // Đảm bảo a và b là số dương
    a = std::abs(a);
    b = std::abs(b);

    while (b != 0) {
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}

int main() {
    int num1, num2;
    std::cout << "Nhap hai so nguyen duong: ";
    std::cin >> num1 >> num2;

    int result = gcd(num1, num2); // Hoặc std::gcd(num1, num2) nếu dùng C++17

    std::cout << "GCD(" << num1 << ", " << num2 << ") = " << result << std::endl;

    return 0;
}

Giải thích code:

  • Hàm gcd(int a, int b) nhận hai số nguyên ab.
  • a = std::abs(a); b = std::abs(b);: GCD thường được định nghĩa cho số dương. Dòng này đảm bảo chúng ta làm việc với giá trị tuyệt đối.
  • Vòng lặp while (b != 0): Vòng lặp tiếp tục miễn là số thứ hai (b) chưa bằng 0.
  • Trong vòng lặp:
    • int temp = b;: Lưu giá trị hiện tại của b vào biến tạm temp.
    • b = a % b;: Cập nhật b thành phần dư của a chia b. Đây là bước cốt lõi của thuật toán Euclid.
    • a = temp;: Cập nhật a thành giá trị cũ của b (đã lưu trong temp).
  • Khi b trở thành 0, vòng lặp dừng lại. Theo thuật toán Euclid, giá trị hiện tại của a chính là GCD ban đầu của hai số. Hàm trả về a.
  • Hàm main nhận input, gọi hàm gcd, và in kết quả.

Lưu ý: Từ C++17 trở đi, thư viện <numeric> cung cấp sẵn hàm std::gcd. Tuy nhiên, việc tự cài đặt giúp bạn hiểu rõ thuật toán.

Độ phức tạp: Độ phức tạp thời gian của thuật toán Euclid là O(log(min(a, b))), cực kỳ hiệu quả ngay cả với số lớn.


Bài tập 3: Tìm bội chung nhỏ nhất (LCM) của hai số

Bội chung nhỏ nhất (LCM - Least Common Multiple) của hai số nguyên dương ab là số nguyên dương nhỏ nhất mà cả ab đều là ước của nó.

Ý tưởng thuật toán:

Có một mối liên hệ tuyệt vời giữa GCD và LCM của hai số ab:

GCD(a, b) * LCM(a, b) = |a * b|

Từ đó, chúng ta có thể suy ra công thức tính LCM:

LCM(a, b) = (|a * b|) / GCD(a, b)

Để tính LCM, chúng ta chỉ cần sử dụng lại hàm gcd đã xây dựng ở bài tập trước.

Lưu ý về tràn số (Overflow): Khi tính a * b, kết quả có thể rất lớn và gây tràn số nếu ab là các số nguyên lớn. Để tránh điều này, chúng ta nên thực hiện phép chia cho GCD trước khi nhân:

LCM(a, b) = (|a| / GCD(a, b)) * |b|

Hoặc LCM(a, b) = (|b| / GCD(a, b)) * |a|. Thứ tự nào cũng được vì |a||b| đều chia hết cho GCD(a, b).

Code minh họa (C++):

#include <iostream>
#include <numeric> // Cần cho std::gcd (hoặc sử dụng hàm gcd tự định nghĩa)
#include <cmath>   // Cần cho std::abs

// Hàm tính GCD (sử dụng lại từ Bài tập 2, hoặc dùng std::gcd)
int gcd(int a, int b) {
    a = std::abs(a);
    b = std::abs(b);
    while (b != 0) {
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}

// Hàm tính LCM
long long lcm(long long a, long long b) {
    if (a == 0 || b == 0) return 0; // LCM của 0 và bất kỳ số nào là 0

    // Sử dụng công thức LCM(a, b) = (|a*b|) / GCD(a, b)
    // Áp dụng công thức tránh tràn số: (|a| / GCD(a, b)) * |b|
    // Sử dụng long long để chứa kết quả có thể lớn
    return (std::abs(a) / gcd(a, b)) * std::abs(b);
}

int main() {
    long long num1, num2; // Sử dụng long long để tránh tràn số khi nhập
    std::cout << "Nhap hai so nguyen: ";
    std::cin >> num1 >> num2;

    long long result = lcm(num1, num2);

    std::cout << "LCM(" << num1 << ", " << num2 << ") = " << result << std::endl;

    return 0;
}

Giải thích code:

  • Hàm lcm(long long a, long long b) nhận hai số ab. Chúng ta dùng kiểu long long vì LCM có thể lớn hơn nhiều so với int.
  • Dòng if (a == 0 || b == 0) return 0;: LCM của 0 và bất kỳ số nào là 0.
  • Dòng return (std::abs(a) / gcd(a, b)) * std::abs(b);: Đây là công thức tính LCM đã được điều chỉnh để tránh tràn số.
    • gcd(a, b): Gọi hàm gcd để tính ước chung lớn nhất.
    • std::abs(a)std::abs(b): Đảm bảo làm việc với giá trị tuyệt đối.
    • std::abs(a) / gcd(a, b): Thực hiện phép chia trước. Kết quả này chắc chắn là số nguyên vì GCD(a, b) là ước của |a|.
    • * std::abs(b): Sau đó nhân với |b|. Phép nhân này ít có khả năng gây tràn số hơn là nhân |a| * |b| ngay từ đầu, đặc biệt khi kết quả cuối cùng vừa vặn với long long.
  • Hàm main nhận input (dùng long long) và in kết quả.

Độ phức tạp: Độ phức tạp thời gian của việc tính LCM bằng cách này chủ yếu phụ thuộc vào độ phức tạp của thuật toán GCD, tức là O(log(min(|a|, |b|))).


Bài tập 4: Tính lũy thừa theo modulo (Modular Exponentiation)

Bài toán: Cho ba số nguyên base, exp (số mũ không âm), và mod (số modulo, mod > 0). Tính (base^exp) % mod.

Bài toán này cực kỳ quan trọng trong nhiều lĩnh vực của lý thuyết số ứng dụng, đặc biệt là mật mã học (ví dụ: thuật toán RSA).

Ý tưởng thuật toán:

Cách ngây thơ nhất là tính base^exp rồi lấy modulo. Tuy nhiên, base^exp có thể là một số khổng lồ, vượt xa khả năng lưu trữ của máy tính ngay cả với long long, dù cho base, exp, và mod có thể tương đối nhỏ.

Chúng ta cần áp dụng các tính chất của phép toán modulo: (a * b) % m = ((a % m) * (b % m)) % m

Từ đó, (base^exp) % mod = (base * base * ... * base) % mod (với exp lần nhân). Chúng ta có thể nhân dần và lấy modulo sau mỗi phép nhân để giữ cho các số luôn nhỏ hơn mod.

Tuy nhiên, cách này vẫn có độ phức tạp O(exp), quá chậm nếu exp là số lớn.

Thuật toán Binary Exponentiation (hay Exponentiation by Squaring - Lũy thừa nhị phân) giúp chúng ta giảm độ phức tạp xuống còn O(log(exp)). Ý tưởng dựa trên việc phân tích số mũ exp dưới dạng nhị phân:

  • Nếu exp chẵn, base^exp = (base^2)^(exp / 2).
  • Nếu exp lẻ, base^exp = base * (base^2)^((exp - 1) / 2).

Chúng ta có thể thực hiện điều này lặp đi lặp lại, đồng thời áp dụng phép modulo sau mỗi phép nhân.

Code minh họa (C++):

#include <iostream>

// Hàm tính (base^exp) % mod sử dụng Binary Exponentiation
long long power(long long base, long long exp, long long mod) {
    long long res = 1; // Kết quả ban đầu là base^0 = 1

    // Đảm bảo base và mod là dương (hoặc xử lý base âm theo luật modulo)
    // Với exp >= 0 và mod > 0, base âm có thể được xử lý bằng (base % mod + mod) % mod
    base %= mod;
    if (base < 0) base = (base + mod) % mod;


    while (exp > 0) {
        // Nếu bit cuối cùng của exp là 1 (exp lẻ), nhân base vào kết quả
        if (exp % 2 == 1) {
            res = (res * base) % mod;
        }

        // Bình phương base, đồng thời lấy modulo
        base = (base * base) % mod;

        // Chia exp cho 2 ( dịch phải 1 bit)
        exp /= 2; // Hoặc exp >>= 1;
    }

    return res;
}

int main() {
    long long base, exp, mod;
    std::cout << "Nhap co so (base), so mu (exp), va modulo (mod): ";
    std::cin >> base >> exp >> mod;

    // Xử lý trường hợp exp âm nếu cần (bài toán này chỉ xét exp >= 0)
    if (exp < 0) {
         std::cout << "So mu phai khong am trong bai tap nay." << std::endl;
         return 1;
    }
    if (mod <= 0) {
        std::cout << "Modulo phai la so duong." << std::endl;
        return 1;
    }


    long long result = power(base, exp, mod);

    std::cout << "(" << base << "^" << exp << ") % " << mod << " = " << result << std::endl;

    return 0;
}

Giải thích code:

  • Hàm power(long long base, long long exp, long long mod) nhận ba giá trị. Sử dụng long long để tránh tràn số trong các phép nhân trung gian trước khi lấy modulo.
  • long long res = 1;: Biến res lưu trữ kết quả cuối cùng, ban đầu là 1 (tương ứng với base^0).
  • base %= mod; if (base < 0) base = (base + mod) % mod;: Đưa base về phạm vi [0, mod - 1] trước khi bắt đầu tính toán để giữ cho các số nhỏ. Xử lý base âm cho đúng với định nghĩa modulo.
  • Vòng lặp while (exp > 0): Lặp cho đến khi số mũ exp về 0. Mỗi lần lặp xử lý một bit của exp từ phải sang trái.
  • if (exp % 2 == 1): Kiểm tra bit cuối cùng của exp. Nếu là 1 (tức exp lẻ), điều này có nghĩa là lũy thừa hiện tại của base (đã được bình phương nhiều lần) cần được nhân vào kết quả cuối cùng res. res = (res * base) % mod; thực hiện phép nhân và lấy modulo để giữ res nhỏ.
  • base = (base * base) % mod;: Bình phương base (tương ứng với việc xử lý base^2 cho bit tiếp theo) và lấy modulo.
  • exp /= 2;: Chia exp cho 2 (dịch bit sang phải), chuyển sang xử lý bit tiếp theo của số mũ.
  • Khi exp bằng 0, res chứa kết quả (base^exp) % mod.
  • Hàm main nhận input, gọi hàm power, và in kết quả.

Độ phức tạp: Độ phức tạp thời gian của thuật toán Binary Exponentiation là O(log(exp)), nhanh hơn đáng kể so với O(exp).


Bài tập 5: Sàng Eratosthenes (Sieve of Eratosthenes)

Bài toán: Tìm tất cả các số nguyên tố nhỏ hơn hoặc bằng một giới hạn cho trước N.

Nếu cần tìm nhiều số nguyên tố trong một phạm vi, việc kiểm tra từng số bằng hàm isPrime (như Bài tập 1) sẽ rất chậm. Sàng Eratosthenes là một thuật toán hiệu quả hơn nhiều cho nhiệm vụ này.

Ý tưởng thuật toán:

Thuật toán Sàng Eratosthenes hoạt động dựa trên nguyên tắc: Một số hợp số luôn có thể được biểu diễn dưới dạng tích của các số nguyên tố.

  1. Tạo một danh sách (hoặc mảng boolean) chứa tất cả các số từ 2 đến N, ban đầu đánh dấu tất cả là "có thể là số nguyên tố".
  2. Bắt đầu với số nguyên tố nhỏ nhất là 2. Đánh dấu tất cả các bội số của 2 (lớn hơn 2) là "không phải số nguyên tố".
  3. Tìm số chưa bị đánh dấu tiếp theo trong danh sách. Số này chắc chắn là số nguyên tố.
  4. Lấy số nguyên tố vừa tìm được (ví dụ là p), đánh dấu tất cả các bội số của p (lớn hơn p) là "không phải số nguyên tố". Có thể bắt đầu đánh dấu từ p*p thay vì 2*p, 3*p,... vì các bội nhỏ hơn p*p đã bị đánh dấu bởi các số nguyên tố nhỏ hơn.
  5. Lặp lại bước 3 và 4 cho đến khi tìm được số chưa bị đánh dấu lớn hơn sqrt(N). Tại điểm này, tất cả các số chưa bị đánh dấu còn lại trong danh sách đều là số nguyên tố.

Code minh họa (C++):

#include <iostream>
#include <vector>
#include <cmath> // Cần cho sqrt (hoặc i*i <= n)

// Hàm thực hiện sàng Eratosthenes để tìm các số nguyên tố đến N
std::vector<bool> sieve(int N) {
    // Tạo mảng boolean, ban đầu giả định tất cả đều là số nguyên tố
    // Kích thước N+1 để dễ dàng truy cập theo chỉ số
    std::vector<bool> is_prime(N + 1, true);

    // 0 và 1 không phải là số nguyên tố
    is_prime[0] = is_prime[1] = false;

    // Bắt đầu từ 2
    for (int p = 2; p * p <= N; ++p) {
        // Nếu p vẫn chưa bị đánh dấu (tức là p là số nguyên tố)
        if (is_prime[p] == true) {
            // Đánh dấu tất cả các bội của p là không phải số nguyên tố
            // Bắt đầu từ p*p vì các bội nhỏ hơn (2p, 3p,...) đã bị đánh dấu bởi các số nguyên tố nhỏ hơn
            for (int i = p * p; i <= N; i += p)
                is_prime[i] = false;
        }
    }

    return is_prime;
}

int main() {
    int limit;
    std::cout << "Nhap gioi han tren (N) de tim so nguyen to: ";
    std::cin >> limit;

    if (limit < 0) {
        std::cout << "Gioi han phai khong am." << std::endl;
        return 1;
    }

    std::vector<bool> primes = sieve(limit);

    std::cout << "Cac so nguyen to nho hon hoac bang " << limit << " la:" << std::endl;
    for (int p = 2; p <= limit; ++p) {
        if (primes[p]) {
            std::cout << p << " ";
        }
    }
    std::cout << std::endl;

    return 0;
}

Giải thích code:

  • Hàm sieve(int N) nhận giới hạn trên N.
  • std::vector<bool> is_prime(N + 1, true);: Khởi tạo một vector<bool> kích thước N+1. is_prime[i] sẽ là true nếu i được xem là số nguyên tố, false nếu là hợp số. Ban đầu tất cả đều được đặt là true.
  • is_prime[0] = is_prime[1] = false;: Đánh dấu 0 và 1 không phải số nguyên tố.
  • Vòng lặp ngoài for (int p = 2; p * p <= N; ++p): Duyệt qua các số từ 2. Điều kiện p * p <= N (tức p <= sqrt(N)) là một tối ưu hóa quan trọng. Chúng ta chỉ cần duyệt đến sqrt(N) để tìm các số nguyên tố lần đầu tiên đánh dấu các hợp số. Các hợp số lớn hơn sqrt(N) mà chưa bị đánh dấu sẽ có ước nguyên tố nhỏ hơn sqrt(N) và đã bị đánh dấu bởi các ước đó.
  • if (is_prime[p] == true): Kiểm tra xem số p hiện tại có còn được đánh dấu là số nguyên tố hay không. Nếu có, p chắc chắn là một số nguyên tố.
  • Vòng lặp trong for (int i = p * p; i <= N; i += p): Duyệt qua tất cả các bội số của p bắt đầu từ `pp`*.
    • i = p * p: Bắt đầu từ p*p vì các bội nhỏ hơn (như 2*p, 3*p, ...) đã được đánh dấu bởi các số nguyên tố nhỏ hơn p (như 2, 3, ...). Ví dụ: 6 bị đánh dấu bởi 2 và 3 trước khi chúng ta xét đến 6. Khi xét p=3, chúng ta bắt đầu đánh dấu từ 3*3=9.
    • i <= N: Giới hạn đến N.
    • i += p: Nhảy tới các bội số tiếp theo của p.
  • is_prime[i] = false;: Đánh dấu bội số i là hợp số.
  • Sau khi vòng lặp ngoài kết thúc, mảng is_prime chứa kết quả: is_prime[i]true nếu i là số nguyên tố, false nếu không.
  • Hàm main gọi sieve và sau đó duyệt mảng kết quả để in ra các số nguyên tố.

Độ phức tạp: Độ phức tạp thời gian của Sàng Eratosthenes là O(N log log N), hiệu quả hơn nhiều so với O(N * sqrt(N)) khi kiểm tra từng số một. Độ phức tạp không gian là O(N) để lưu trữ mảng is_prime.

Bài tập ví dụ:

Ước số nguyên tố nhỏ nhất

Đề bài

Cho số tự nhiên N. Nhiệm vụ của bạn là in ra ước số nguyên tố nhỏ nhất của các số từ 1 đến N.

  • Ước số nguyên tố nhỏ nhất của 1 là 1.
  • Ước số nguyên tố nhỏ nhất của các số chẵn là 2.
  • Ước số nguyên tố nhỏ nhất của các số nguyên tố là chính nó.

Input Format

Một số N được ghi trên một dòng (1 ≤ N ≤ 100000).

Output Format

Đưa ra kết quả theo từng dòng.

Sample Input 0

7

Sample Output 0

1
2
3
2
5
2
7

Okay, đây là hướng dẫn ngắn gọn để giải bài toán tìm ước số nguyên tố nhỏ nhất cho các số từ 1 đến N bằng C++:

  1. Khởi tạo mảng: Tạo một mảng (ví dụ std::vector<int>) có kích thước N+1 để lưu trữ ước số nguyên tố nhỏ nhất (SPF) cho mỗi số từ 1 đến N. Khởi tạo giá trị ban đầu cho mỗi phần tử i trong mảng là i.

  2. Xử lý trường hợp đặc biệt: Gán giá trị SPF cho 1 là 1.

  3. Sàng lọc (Sieve): Sử dụng một thuật toán sàng lọc tương tự Sàng Eratosthenes:

    • Lặp qua các số p từ 2 đến N.
    • Nếu spf[p] vẫn bằng p (điều này có nghĩa p là số nguyên tố, vì nó chưa bị đánh dấu bởi ước nhỏ hơn nào):
      • Lặp qua các bội số m của p, bắt đầu từ p * p (vì các bội số nhỏ hơn p*p đã có ước nguyên tố nhỏ hơn p). Tăng m lên p mỗi lần (m += p). Tiếp tục đến khi m vượt quá N.
      • Đối với mỗi bội số m, nếu spf[m] vẫn bằng m (nghĩa là m chưa được gán ước nguyên tố nhỏ hơn p), hãy gán spf[m] = p.
  4. In kết quả: Duyệt qua mảng SPF từ chỉ số 1 đến N và in từng giá trị trên một dòng riêng biệt.

Làm thêm nhiều bài tập miễn phí tại đây

Comments

There are no comments at the moment.