Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Đếm Các Số Nguyên Tố Trong Dãy

Đếm Các Số Nguyên Tố Trong Dãy
Nguồn: Góp ý bài làm học viên Độ khó: Dễ Số nguyên tố Mảng 1 chiều Đếm Sắp xếp

Đề bài

ĐẾM CÁC SỐ NGUYÊN TỐ TRONG DÃY

Cho dãy số $A$ có $n$ phần tử chỉ bao gồm các số nguyên dương (không quá $10^5$). Hãy xác định các số nguyên tố trong dãy và đếm xem mỗi số xuất hiện bao nhiêu lần.

Input: Dòng đầu tiên ghi số bộ test. Với mỗi bộ test: dòng đầu ghi số $n$ (không quá $100$); dòng tiếp theo ghi $n$ số của dãy.

Output: Với mỗi bộ test ghi ra thứ tự bộ test, sau đó lần lượt là các số nguyên tố trong dãy theo thứ tự từ nhỏ đến lớn và số lần xuất hiện của nó.

Ví dụ – input
1
10
1 7 2 8 3 3 2 1 3 2
Ví dụ – output
Test 1:
2 xuat hien 3 lan
3 xuat hien 3 lan
7 xuat hien 1 lan

Lời mở đầu

Bài tập hôm nay: liệt kê các số nguyên tố phân biệt theo giá trị tăng dần kèm theo tần suất xuất hiện của chúng trong mảng 1 chiều các số nguyên. Một bạn học viên gửi bài làm cho mình và hỏi: "tối ưu được cái gì không anh, với có cách khác không ạ, bài em hơi dài á". Vậy là mình góp ý bài làm của em, chỉ ra những chỗ chưa tối ưu và hướng dẫn em tối ưu lại. Mình viết lại thành bài giải để các bạn mới học mảng 1 chiều cùng tham khảo nha 😇.

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:

Link nộp bài

Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.

Nộp bài trên CLUE OJ

Giải thích ví dụ: dãy 1 7 2 8 3 3 2 1 3 2 có các số nguyên tố là 7, 2, 3, 3, 2, 3, 2 (số 1 và số 8 không phải số nguyên tố). Số 2 xuất hiện 3 lần, số 3 xuất hiện 3 lần, số 7 xuất hiện 1 lần. In theo thứ tự tăng dần là 2, 3, 7; và nhớ in dòng Test 1: trước nhé.

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Cách 1Bài làm của học viên – chính xác nhưng chưa tối ưu

Ý tưởng của em hiện tại là dùng 2 mảng: 1 mảng (phu1) lưu tất cả các số nguyên tố có trong dãy, 1 mảng (phu) chỉ lưu các số nguyên tố phân biệt. Xong rồi em đi sắp xếp mảng các số nguyên tố phân biệt tăng dần, rồi thì đếm từng phần tử trong mảng phân biệt với mảng trùng xem từng phần tử đó xuất hiện bao nhiêu lần, sau đó in ra kết quả.

Lỗi đầu tiên: sai format của bài
Nếu nộp bài lên hệ thống thì chắc chắn nó sẽ không cho thành công đâu, vì em đang sai cấu trúc format của bài: em chỉ đang xử lý với 1 phần nhỏ của bài, đó là với dãy có $N$ số nguyên thôi. Còn bài nó còn yêu cầu số bộ test nữa (và in dòng Test k:), em chưa xử lý phần số bộ test nên nhớ lưu ý hen. Code bên dưới mình đã bổ sung phần này.

Còn nếu xét riêng phần xử lý trên dãy $N$ số này của em thì như hiện tại em làm vậy cũng được, về mặt chính xác thì đồng ý nó chính xác. Tuy nhiên tối ưu thì còn nhiều chỗ chưa tối ưu, để mình phân tích ra:

  1. Hàm kiểm tra số nguyên tố chưa tối ưu. Em đang thử ước từ 2 tới $a / 2$, trong khi chỉ cần thử tới $\sqrt{a}$ là đủ. Với $a \approx 10^5$ là chênh nhau cỡ $50000$ lần lặp so với khoảng $316$ lần lặp. Phần này các bạn có thể tham khảo thêm bài viết "Kiểm tra 1 số có là số nguyên tố không?" của mình để hiểu rõ vì sao chỉ cần tới căn nhé.
  2. So trùng trên mảng ban đầu. Ngay từ bước kiểm tra để chỉ nhận các số nguyên tố phân biệt đưa vào mảng phân biệt, theo code em hiện tại là đi so sánh thằng a[i] hiện tại với tất cả các thằng a[j] ở trước nó xem có thằng nào khiến bị trùng không. Em đang so sánh trên mảng ban đầu là 1 sai lầm lớn vì nó quá phức tạp. Tại sao không so sánh trên mảng đang chứa các số nguyên tố phân biệt? Vì trong mảng ban đầu có cả những số không phải nguyên tố, tại sao phải mất công xét với cả những thằng ấy? Mình chỉ cần đang xét với những thằng thoả là nguyên tố đã tìm thấy xem thằng hiện tại có bị trùng không mà. Chỗ này sửa sẽ giảm được nhiều số lần so sánh hơn.
  3. Bước đếm số lần xuất hiện. Em tưởng tượng thử nhé: giả sử trong nguyên dãy có 100 số toàn là nguyên tố và chỉ có 10 số nguyên tố phân biệt, nghĩa là mỗi số sẽ bị lặp lại 10 lần. Theo cách làm này của em, mỗi số nguyên tố phân biệt trong 10 số phải chạy so sánh hết với mảng 100 số nguyên tố, như thế thì cả thảy $10 \times 100 = 1000$ lần xét duyệt.

Code của em (mình chỉ sắp lại cho gọn, giữ nguyên logic, và bổ sung phần đọc số bộ test):

#include <iostream>
using namespace std;
#define MAX 105

void Nhap(int a[], int n)
{
    for (int i = 0; i < n; i++)
        cin >> a[i];
}

bool KiemTraSoNguyenTo(int a)
{
    if (a < 2)
        return false;
    else if (a > 2)
    {
        if (a % 2 == 0)
            return false;
        else
        {
            for (int i = 2; i <= a / 2; i++)
            {
                if (a % i == 0)
                    return false;
            }
        }
    }
    return true;
}

void XuLy(int a[], int n)
{
    int phu[MAX], phu1[MAX];
    int index = 0, index1 = 0;
    if (KiemTraSoNguyenTo(a[0]) == true)
    {
        phu1[index1++] = a[0];
        phu[index++] = a[0];
    }
    for (int i = 1; i < n; i++)
    {
        if (KiemTraSoNguyenTo(a[i]) == true)
        {
            phu1[index1++] = a[i];
            bool check = true;
            for (int j = i - 1; j >= 0; j--)
            {
                if (a[i] == a[j])
                {
                    check = false;
                }
            }
            if (check == true)
                phu[index++] = a[i];
        }
    }
    // sắp xếp tăng dần
    for (int i = 0; i < index - 1; i++)
    {
        int min = i;
        for (int j = i + 1; j < index; j++)
        {
            if (phu[j] < phu[min])
            {
                min = j;
            }
        }
        swap(phu[i], phu[min]);
    }
    for (int i = 0; i < index; i++)
    {
        int dem = 0;
        for (int j = 0; j < index1; j++)
        {
            if (phu[i] == phu1[j])
            {
                dem++;
            }
        }
        cout << phu[i] << " xuat hien " << dem << " lan" << endl;
    }
}

int main()
{
    int t;
    cin >> t;
    for (int k = 1; k <= t; k++)
    {
        int a[MAX];
        int n;
        cin >> n;
        Nhap(a, n);
        cout << "Test " << k << ":" << endl;
        XuLy(a, n);
    }
    return 0;
}
def kiem_tra_so_nguyen_to(a):
    if a < 2:
        return False
    elif a > 2:
        if a % 2 == 0:
            return False
        for i in range(2, a // 2 + 1):
            if a % i == 0:
                return False
    return True


def xu_ly(a, n):
    phu = []   # các số nguyên tố phân biệt
    phu1 = []  # tất cả các số nguyên tố (kể cả trùng)
    for i in range(n):
        if kiem_tra_so_nguyen_to(a[i]):
            phu1.append(a[i])
            check = True
            for j in range(i - 1, -1, -1):
                if a[i] == a[j]:
                    check = False
            if check:
                phu.append(a[i])
    # sắp xếp tăng dần (selection sort)
    for i in range(len(phu) - 1):
        mn = i
        for j in range(i + 1, len(phu)):
            if phu[j] < phu[mn]:
                mn = j
        phu[i], phu[mn] = phu[mn], phu[i]
    for x in phu:
        dem = 0
        for y in phu1:
            if x == y:
                dem += 1
        print(x, "xuat hien", dem, "lan")


data = list(map(int, open(0).read().split()))
p = 0
t = data[p]; p += 1
for k in range(1, t + 1):
    n = data[p]; p += 1
    a = data[p:p + n]; p += n
    print("Test " + str(k) + ":")
    xu_ly(a, n)

Đánh giá độ phức tạp của cách làm này (mỗi bộ test, $V$ là giá trị lớn nhất):

  • Kiểm tra nguyên tố: $O(n \cdot V)$ trong trường hợp xấu (thử ước tới $V / 2$).
  • So trùng trên mảng gốc: $O(n^2)$; sắp xếp chọn: $O(k^2)$ với $k$ là số nguyên tố phân biệt; đếm: $O(k \cdot p)$ với $p$ là số lượng số nguyên tố.
Không gian$O(n)$
Thời gian$O(n \cdot V + n^2)$

Cách 2Vừa so trùng vừa đếm luôn: 2 mảng song song – O(n·√V + n²)

Có 1 cách tốt hơn, em suy nghĩ thử theo hướng này nhé. Đằng nào mỗi khi ta gặp 1 số nguyên tố trong dãy $A$ ban đầu, ta cũng phải duyệt lại trong mảng đang chứa các số nguyên tố phân biệt xem coi thằng số nguyên tố vừa tìm được có bị trùng không đúng không? Để nếu không trùng thì ta đem nó bỏ vào mảng các số nguyên tố phân biệt, còn nếu trùng thì ta bỏ qua không lấy.

Ý tưởng chính
Vậy thay vì bỏ qua như thế – trong khi cũng đã mất công đi tìm xem nó có trùng không – thì nếu đã tìm thấy bị trùng, ta có thể lưu vết lại số nguyên tố đó đã xuất hiện thêm +1 lần, cứ thế +1. Rồi sau cùng ta vừa có mảng chỉ chứa các số nguyên tố phân biệt, mà còn có rõ thông tin mỗi số xuất hiện bao nhiêu lần. Khi ta không uổng phí việc xét trùng, sẵn xét trùng thì nó +1 số lần xuất hiện lên luôn.

Thử trên giấy nháp

Ví dụ cho dãy số: 1 7 2 8 3 3 2 1 3 2, các bạn ghi dãy số này ra tờ giấy nháp rồi thử hen. Bắt đầu xét dãy số từ trái qua phải; để tượng trưng số lần xuất hiện mình sẽ ghi nó trong dấu ngoặc, ví dụ 7 (1) là số 7 đang xuất hiện 1 lần (trên giấy thì các bạn đánh một số 1 nhỏ lên đầu số 7):

  • a[0] = 1 ⇒ bỏ qua vì không phải số nguyên tố.
  • a[1] = 7 ⇒ đây là số nguyên tố. Kiểm tra xem 7 có xuất hiện trong mảng chứa các số nguyên tố phân biệt chưa? Lúc này mảng đang rỗng, nghĩa là thằng 7 này sẽ yên tâm không bị trùng với thằng nào hết (vì nó là thằng đầu tiên mà) ⇒ đưa nó vào mảng: 7 (1).
  • a[2] = 2 ⇒ là số nguyên tố, so với mảng đang có mỗi số 7 ⇒ không bị trùng ⇒ đưa vào: 7 (1), 2 (1).
  • a[3] = 8 ⇒ bỏ qua vì đây không phải số nguyên tố.
  • a[4] = 3 ⇒ là số nguyên tố, số 3 chưa xuất hiện ⇒ đưa vào: 7 (1), 2 (1), 3 (1).
  • a[5] = 3 ⇒ số 3 đã xuất hiện rồi ⇒ không đưa vào nữa, nhưng cập nhật số lần xuất hiện của nó +1: 7 (1), 2 (1), 3 (2).
  • a[6] = 2 ⇒ số 2 đã xuất hiện ⇒ +1: 7 (1), 2 (2), 3 (2).
  • a[7] = 1 ⇒ bỏ qua. a[8] = 3 ⇒ +1: 7 (1), 2 (2), 3 (3). a[9] = 2 ⇒ +1: 7 (1), 2 (3), 3 (3).

Rồi lúc này mảng $a$ đã hết phần tử để xét. Đi sắp xếp nó theo giá trị số nguyên tố tăng dần ta được: 2 (3), 3 (3), 7 (1). 🎉

Cài đặt chỉ với mảng 1 chiều

Về mặt lý thuyết nói là vậy, nhưng giờ hiện thực cho máy tính nó hiểu mà làm đúng như vậy được: trên giấy thì em "đánh số 1 nhỏ lên đầu" của từng số trong mảng các số nguyên tố phân biệt, nhưng máy tính đâu có đánh kiểu đó được :)) Bạn học viên nghĩ ngay tới struct – ok struct cũng được đó, gọn 👍, nhưng nếu giả sử bài này chưa học đến đó thì đâu được phép dùng; bài này chỉ đang là mảng 1 chiều thôi.

Ý tưởng của bạn học viên: chung 1 mảngĐưa số 7 vào mảng thì "xí" luôn ô tiếp theo để lưu số lần xuất hiện của 7. Đó cũng là 1 ý tưởng xử lý, nhưng hãy cẩn thận: khi cần xét giá trị các số nguyên tố phân biệt thì chỉ xét với các index chẵn 0, 2, 4, 6, 8… (phải nhớ +2 mỗi bước lặp), và lúc sắp xếp thì chỉ sắp xếp các vị trí index chẵn với nhau, mà khi swap 2 phần tử index chẵn nhớ phải swap cả 2 phần tử index lẻ tương ứng, tụi nó đi cả bộ ^^. Chỗ này căng à nha :))
Gọn hơn: 2 mảng song songDùng 1 mảng A để lưu các số nguyên tố phân biệt, 1 mảng B để lưu tần suất xuất hiện của các số tương ứng trong mảng A: B[i] là số lần xuất hiện của số nguyên tố A[i]. Khi sắp xếp mảng A tăng dần, cần hoán vị 2 số ở vị trí nào thì ta cũng hoán vị 2 số tại vị trí tương ứng trong mảng B. Xong rồi duyệt mảng A tăng dần, xét đến A[i] thì gọi ra B[i] chính là số lần xuất hiện tương ứng.

Thực ra ở chỗ này mình đã cố gắng gợi mở cho em hướng 2 mảng song song – đơn giản hơn nhiều – nhưng có vẻ em ấy không nhận ra ^^. Code bên dưới là theo hướng 2 mảng song song đó, kèm hàm kiểm tra nguyên tố chỉ thử ước tới căn.

Minh hoạ

Em nhập dãy số rồi bấm Tiếp để xem 2 mảng A (số nguyên tố phân biệt) và B (số lần xuất hiện) được xây dựng ra sao, rồi được sắp xếp "đi cả bộ" thế nào:

Ví dụ nhanh:

Source code để các bạn tham khảo nếu làm theo hướng này:

#include <iostream>
using namespace std;
#define MAX 105

bool KiemTraSoNguyenTo(int x)
{
    if (x < 2)
        return false;
    for (int i = 2; i * i <= x; i++)
        if (x % i == 0)
            return false;
    return true;
}

int main()
{
    int t;
    cin >> t;
    for (int k = 1; k <= t; k++)
    {
        int n, a[MAX];
        cin >> n;
        for (int i = 0; i < n; i++)
            cin >> a[i];

        int A[MAX], B[MAX], m = 0;
        for (int i = 0; i < n; i++)
        {
            if (!KiemTraSoNguyenTo(a[i]))
                continue;
            int j = 0;
            while (j < m && A[j] != a[i])
                j++;
            if (j < m)
                B[j]++;
            else
            {
                A[m] = a[i];
                B[m] = 1;
                m++;
            }
        }

        for (int i = 0; i < m - 1; i++)
            for (int j = i + 1; j < m; j++)
                if (A[j] < A[i])
                {
                    swap(A[i], A[j]);
                    swap(B[i], B[j]);
                }

        cout << "Test " << k << ":" << endl;
        for (int i = 0; i < m; i++)
            cout << A[i] << " xuat hien " << B[i] << " lan" << endl;
    }
    return 0;
}
def kiem_tra_so_nguyen_to(x):
    if x < 2:
        return False
    i = 2
    while i * i <= x:
        if x % i == 0:
            return False
        i += 1
    return True


data = list(map(int, open(0).read().split()))
p = 0
t = data[p]; p += 1
for k in range(1, t + 1):
    n = data[p]; p += 1
    a = data[p:p + n]; p += n

    A = []  # các số nguyên tố phân biệt
    B = []  # B[j] = số lần xuất hiện của A[j]
    for x in a:
        if not kiem_tra_so_nguyen_to(x):
            continue
        j = 0
        while j < len(A) and A[j] != x:
            j += 1
        if j < len(A):
            B[j] += 1
        else:
            A.append(x)
            B.append(1)

    m = len(A)
    for i in range(m - 1):
        for j in range(i + 1, m):
            if A[j] < A[i]:
                A[i], A[j] = A[j], A[i]
                B[i], B[j] = B[j], B[i]

    print("Test " + str(k) + ":")
    for i in range(m):
        print(A[i], "xuat hien", B[i], "lan")
C++▶ Chạy tay từng dòng codeCách 2: gặp số nguyên tố thì tìm trong A; trùng thì B[j]++, chưa có thì thêm vào cuối; sau cùng sắp xếp A kèm B.
Dữ liệu mẫu:
1#include <iostream>
2using namespace std;
3#define MAX 105
4
5bool KiemTraSoNguyenTo(int x)
6{
7 if (x < 2)
8 return false;
9 for (int i = 2; i * i <= x; i++)
10 if (x % i == 0)
11 return false;
12 return true;
13}
14
15int main()
16{
17 int t;
18 cin >> t;
19 for (int k = 1; k <= t; k++)
20 {
21 int n, a[MAX];
22 cin >> n;
23 for (int i = 0; i < n; i++)
24 cin >> a[i];
25
26 int A[MAX], B[MAX], m = 0;
27 for (int i = 0; i < n; i++)
28 {
29 if (!KiemTraSoNguyenTo(a[i]))
30 continue;
31 int j = 0;
32 while (j < m && A[j] != a[i])
33 j++;
34 if (j < m)
35 B[j]++;
36 else
37 {
38 A[m] = a[i];
39 B[m] = 1;
40 m++;
41 }
42 }
43
44 for (int i = 0; i < m - 1; i++)
45 for (int j = i + 1; j < m; j++)
46 if (A[j] < A[i])
47 {
48 swap(A[i], A[j]);
49 swap(B[i], B[j]);
50 }
51
52 cout << "Test " << k << ":" << endl;
53 for (int i = 0; i < m; i++)
54 cout << A[i] << " xuat hien " << B[i] << " lan" << endl;
55 }
56 return 0;
57}

Đánh giá độ phức tạp của cách làm này (mỗi bộ test, $V \le 10^5$ là giá trị lớn nhất, $k$ là số số nguyên tố phân biệt):

  • Kiểm tra nguyên tố: mỗi số thử ước tới căn ⇒ $O(n\sqrt{V})$, cỡ $100 \times 316$ phép tính.
  • Tìm trùng + đếm: mỗi số nguyên tố chỉ duyệt trên mảng $A$ gồm $k$ số phân biệt ⇒ $O(n \cdot k)$, và việc đếm làm luôn trong lúc tìm, không tốn thêm vòng lặp nào.
  • Sắp xếp: sắp xếp đơn giản $O(k^2)$; $k \le n \le 100$ nên rất nhẹ.
Không gian$O(n)$
Thời gian$O(n\sqrt{V} + n^2)$
Còn hướng nào khác?
Vì giá trị các số không quá $10^5$, các bạn đã học kỹ thuật mảng đánh dấu còn có thể dùng mảng đếm cnt[100001]: gặp số nguyên tố $x$ thì cnt[x]++, cuối cùng duyệt $x$ từ 2 tới $10^5$, gặp cnt[x] > 0 thì in – vừa khỏi tìm trùng vừa khỏi sắp xếp (nhớ reset mảng đếm cho mỗi bộ test). Nhưng với giới hạn kiến thức của bài là mảng 1 chiều cơ bản thì hướng 2 mảng song song ở trên là đủ tốt và rất dễ hiểu rồi.
Ghi chú của mình: code C++ và Python của cả 2 cách đã được kiểm tra với test của đề và 400 bộ dữ liệu ngẫu nhiên (1 đến 4 bộ test, $n \le 100$, giá trị đến $10^5$), so với code vét cạn dùng Counter – khớp hết. Với 100 bộ test, mỗi bộ 100 số nguyên tố cỡ $10^5$ (đề không ghi giới hạn số bộ test, mình tự chọn để thử): cách 1 (C++) chạy khoảng 1.7 giây, bản Python của cách 1 tới khoảng 51 giây – chủ yếu do hàm kiểm tra nguyên tố thử ước tới $a / 2$; còn cách 2 (C++) chỉ khoảng 0.03 giây, Python khoảng 0.5 giây.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét