Bài 5.4 - Bài tập Python: thống kê số lớn thứ 2

Chào mừng bạn trở lại với loạt bài tập Python của chúng ta! Hôm nay, chúng ta sẽ cùng nhau giải quyết một bài toán thú vị và khá phổ biến trong lập trình: tìm ra số lớn thứ hai trong một tập hợp (danh sách) các số. Đây không chỉ là một bài tập rèn luyện kỹ năng làm việc với danh sách mà còn giúp chúng ta suy nghĩ về các thuật toán khác nhau và đánh giá hiệu quả của chúng.

Tại sao bài toán này lại quan trọng? Trong phân tích dữ liệu, thống kê, hoặc thậm chí là các bài toán competitive programming, việc xác định các giá trị "top N" (lớn nhất, lớn thứ hai, lớn thứ ba...) xuất hiện rất thường xuyên. Hiểu rõ cách tìm số lớn thứ hai sẽ là nền tảng để bạn giải quyết các bài toán phức tạp hơn sau này.

Chúng ta sẽ khám phá một vài cách tiếp cận khác nhau để giải quyết bài toán này trong Python, từ cách đơn giản, dễ hiểu nhất đến những cách có thể hiệu quả hơn về mặt tốc độ xử lý.

Đặt Vấn Đề

Cho một danh sách các số nguyên hoặc số thực, nhiệm vụ của chúng ta là tìm và trả về giá trị của số lớn thứ hai trong danh sách đó.

  • Lưu ý: Chúng ta cần xem xét các trường hợp đặc biệt như danh sách rỗng, danh sách chỉ có một phần tử, danh sách có các số trùng lặp, hoặc tất cả các số đều giống nhau. Kết quả mong muốn trong những trường hợp này cần được xác định rõ. Thông thường, nếu danh sách không đủ điều kiện (ví dụ: ít hơn 2 phần tử), chúng ta nên trả về một thông báo lỗi hoặc giá trị đặc biệt (như None).
Phương pháp 1: Sắp xếp danh sách (Sorting)

Cách tiếp cận trực quan và thường là đơn giản nhất để bắt đầu là sắp xếp danh sách. Khi danh sách đã được sắp xếp theo thứ tự tăng dần, số lớn nhất sẽ nằm ở vị trí cuối cùng, và số lớn thứ hai (nếu có) sẽ nằm ở vị trí áp chót.

Hãy xem cách thực hiện bằng Python:

def find_second_largest_sorting(numbers):
    # Bước 1: Kiểm tra danh sách có đủ phần tử không
    # Nếu danh sách rỗng hoặc chỉ có 1 phần tử, không thể có số lớn thứ hai
    if len(numbers) < 2:
        return "Danh sách cần ít nhất 2 phần tử để có số lớn thứ hai."

    # Bước 2: Tạo một bản sao của danh sách và sắp xếp nó
    # Sử dụng sorted() thay vì sort() để không làm thay đổi danh sách gốc
    # sorted() trả về một danh sách mới đã được sắp xếp
    sorted_numbers = sorted(numbers)

    # Bước 3: Trả về phần tử áp chót
    # Trong Python, chỉ số âm -1 là phần tử cuối cùng, -2 là phần tử áp chót
    return sorted_numbers[-2]

# --- Các ví dụ minh họa ---

print("--- Phương pháp 1: Sắp xếp ---")

# Ví dụ 1: Danh sách cơ bản
my_list_1 = [10, 5, 20, 15, 25]
result_1 = find_second_largest_sorting(my_list_1)
print(f"Danh sách: {my_list_1}")
print(f"Số lớn thứ hai: {result_1}") # Kết quả: 20

# Ví dụ 2: Danh sách có số trùng lặp
my_list_2 = [30, 10, 20, 30, 25, 10]
result_2 = find_second_largest_sorting(my_list_2)
print(f"\nDanh sách: {my_list_2}")
print(f"Số lớn thứ hai (có trùng lặp): {result_2}") # Kết quả: 30 (vì 30 xuất hiện 2 lần, vị trí áp chót là 30)

# Ví dụ 3: Danh sách tất cả các số giống nhau
my_list_3 = [7, 7, 7, 7]
result_3 = find_second_largest_sorting(my_list_3)
print(f"\nDanh sách: {my_list_3}")
print(f"Số lớn thứ hai (tất cả giống nhau): {result_3}") # Kết quả: 7 (vì sau khi sắp xếp là [7, 7, 7, 7], áp chót là 7)

# Ví dụ 4: Danh sách có số âm
my_list_4 = [-10, -5, -20, -15, -25]
result_4 = find_second_largest_sorting(my_list_4)
print(f"\nDanh sách: {my_list_4}")
print(f"Số lớn thứ hai (số âm): {result_4}") # Kết quả: -10

# Ví dụ 5: Edge case - Chỉ có 1 phần tử
my_list_5 = [42]
result_5 = find_second_largest_sorting(my_list_5)
print(f"\nDanh sách: {my_list_5}")
print(f"Số lớn thứ hai (1 phần tử): {result_5}") # Kết quả: Thông báo lỗi

# Ví dụ 6: Edge case - Danh sách rỗng
my_list_6 = []
result_6 = find_second_largest_sorting(my_list_6)
print(f"\nDanh sách: {my_list_6}")
print(f"Số lớn thứ hai (rỗng): {result_6}") # Kết quả: Thông báo lỗi

Giải thích mã:

  1. Chúng ta định nghĩa hàm find_second_largest_sorting nhận một danh sách numbers làm đầu vào.
  2. Kiểm tra len(numbers) < 2: Đây là bước xử lý trường hợp ngoại lệ quan trọng. Nếu danh sách có ít hơn 2 phần tử, về mặt logic không thể có số lớn thứ hai, vì vậy chúng ta trả về một thông báo.
  3. sorted(numbers): Hàm sorted() của Python trả về một danh sách mới chứa tất cả các phần tử của danh sách gốc nhưng đã được sắp xếp theo thứ tự tăng dần. Việc sử dụng sorted() thay vì phương thức .sort() trên danh sách gốc giúp giữ nguyên trạng thái ban đầu của danh sách đầu vào, điều này thường là tốt trong thiết kế hàm.
  4. sorted_numbers[-2]: Sau khi sắp xếp, phần tử lớn nhất là sorted_numbers[-1], phần tử lớn thứ hai là sorted_numbers[-2]. Chúng ta sử dụng chỉ số âm để truy cập các phần tử từ cuối danh sách một cách dễ dàng.

Ưu điểm của phương pháp này: Đơn giản và dễ hiểu, mã ngắn gọn. Nhược điểm: Việc sắp xếp toàn bộ danh sách có thể không hiệu quả đối với các danh sách rất lớn về mặt thời gian xử lý (độ phức tạp thường là O(n log n)). Ngoài ra, nó trả về phần tử áp chót trong danh sách đã sắp xếp, điều này có nghĩa nếu số lớn nhất xuất hiện nhiều lần, số lớn thứ hai được tìm thấy có thể chính là số lớn nhất (như trong Ví dụ 2 và 3).

Phương pháp 2: Duyệt qua danh sách (Iteration)

Thay vì sắp xếp toàn bộ danh sách, chúng ta có thể duyệt qua danh sách chỉ một lần và theo dõi hai giá trị: số lớn nhất và số lớn thứ hai đã thấy cho đến hiện tại. Cách này yêu cầu suy nghĩ về thuật toán nhiều hơn một chút nhưng có thể hiệu quả hơn về mặt thời gian xử lý (độ phức tạp O(n)).

import sys # Cần thiết để sử dụng sys.float('-inf')

def find_second_largest_iteration(numbers):
    # Bước 1: Kiểm tra danh sách có đủ phần tử không
    if len(numbers) < 2:
        return "Danh sách cần ít nhất 2 phần tử để có số lớn thứ hai."

    # Bước 2: Khởi tạo hai biến để lưu trữ số lớn nhất và lớn thứ hai
    # Ban đầu, gán chúng bằng giá trị âm vô cùng để đảm bảo mọi số trong danh sách
    # (kể cả số âm) đều lớn hơn giá trị khởi tạo này.
    largest = float('-inf')
    second_largest = float('-inf')

    # Bước 3: Duyệt qua từng phần tử trong danh sách
    for number in numbers:
        # Nếu số hiện tại (number) lớn hơn số lớn nhất (largest) đã biết
        if number > largest:
            # Số lớn nhất cũ (largest) bây giờ trở thành số lớn thứ hai (second_largest)
            second_largest = largest
            # Cập nhật số lớn nhất mới
            largest = number
        # Ngược lại (số hiện tại KHÔNG lớn hơn số lớn nhất),
        # Kiểm tra xem nó có lớn hơn số lớn thứ hai đã biết không
        # VÀ đảm bảo nó KHÔNG phải là số lớn nhất hiện tại (để xử lý trùng lặp của số lớn nhất)
        elif number > second_largest and number != largest:
            # Nếu đúng, cập nhật số lớn thứ hai
            second_largest = number

    # Bước 4: Kiểm tra kết quả cuối cùng
    # Nếu sau khi duyệt hết, second_largest vẫn là float('-inf'),
    # điều này có nghĩa là không tìm thấy số lớn thứ hai hợp lệ.
    # Điều này xảy ra nếu tất cả các số trong danh sách đều giống nhau
    # hoặc danh sách chỉ có đúng 2 phần tử và 2 phần tử đó bằng nhau.
    # Trong trường hợp tất cả các số giống nhau, chúng ta có thể coi số lớn thứ hai là chính số đó
    # (tương tự cách xử lý của phương pháp Sắp xếp) hoặc trả về thông báo.
    # Ở đây, ta sẽ trả về giá trị của largest nếu second_largest không thay đổi.
    if second_largest == float('-inf'):
         # Điều này xảy ra nếu list có dạng [x, x, x...] hoặc [x, x].
         # Theo cách hiểu của phương pháp Sắp xếp, số lớn thứ hai cũng là x.
         # Hoặc có thể trả về thông báo "Không có số lớn thứ hai duy nhất".
         # Chọn trả về largest để nhất quán hơn với phương pháp Sắp xếp khi có trùng lặp
         return largest
    else:
        return second_largest

# --- Các ví dụ minh họa ---

print("\n--- Phương pháp 2: Duyệt qua danh sách ---")

# Ví dụ 1: Danh sách cơ bản
my_list_1 = [10, 5, 20, 15, 25]
result_1 = find_second_largest_iteration(my_list_1)
print(f"Danh sách: {my_list_1}")
print(f"Số lớn thứ hai: {result_1}") # Kết quả: 20

# Ví dụ 2: Danh sách có số trùng lặp (số lớn nhất trùng lặp)
my_list_2 = [30, 10, 20, 30, 25, 10]
result_2 = find_second_largest_iteration(my_list_2)
print(f"\nDanh sách: {my_list_2}")
print(f"Số lớn thứ hai (có trùng lặp số lớn nhất): {result_2}") # Kết quả: 25 (lớn nhất là 30, số lớn thứ hai phân biệt là 25)

# Ví dụ 3: Danh sách tất cả các số giống nhau
my_list_3 = [7, 7, 7, 7]
result_3 = find_second_largest_iteration(my_list_3)
print(f"\nDanh sách: {my_list_3}")
print(f"Số lớn thứ hai (tất cả giống nhau): {result_3}") # Kết quả: 7

# Ví dụ 4: Danh sách có số âm
my_list_4 = [-10, -5, -20, -15, -25]
result_4 = find_second_largest_iteration(my_list_4)
print(f"\nDanh sách: {my_list_4}")
print(f"Số lớn thứ hai (số âm): {result_4}") # Kết quả: -10

# Ví dụ 5: Edge case - Chỉ có 1 phần tử
my_list_5 = [42]
result_5 = find_second_largest_iteration(my_list_5)
print(f"\nDanh sách: {my_list_5}")
print(f"Số lớn thứ hai (1 phần tử): {result_5}") # Kết quả: Thông báo lỗi

# Ví dụ 6: Edge case - Danh sách rỗng
my_list_6 = []
result_6 = find_second_largest_iteration(my_list_6)
print(f"\nDanh sách: {my_list_6}")
print(f"Số lớn thứ hai (rỗng): {result_6}") # Kết quả: Thông báo lỗi

# Ví dụ 7: Edge case - Chỉ có 2 phần tử bằng nhau
my_list_7 = [50, 50]
result_7 = find_second_largest_iteration(my_list_7)
print(f"\nDanh sách: {my_list_7}")
print(f"Số lớn thứ hai (2 phần tử bằng nhau): {result_7}") # Kết quả: 50

# Ví dụ 8: Kết hợp số âm và dương
my_list_8 = [-10, 5, -20, 15, -25, 0]
result_8 = find_second_largest_iteration(my_list_8)
print(f"\nDanh sách: {my_list_8}")
print(f"Số lớn thứ hai (âm dương): {result_8}") # Kết quả: 5

Giải thích mã:

  1. Chúng ta nhập thư viện sys để sử dụng sys.float('-inf'), đại diện cho giá trị âm vô cùng. Điều này rất hữu ích khi khởi tạo largestsecond_largest để đảm bảo bất kỳ số nào trong danh sách (kể cả số âm rất nhỏ) cũng sẽ lớn hơn giá trị khởi tạo này và được cập nhật đúng.
  2. Kiểm tra len(numbers) < 2 vẫn là bước quan trọng đầu tiên.
  3. Khởi tạo largestsecond_largest với float('-inf').
  4. Chúng ta duyệt qua từng number trong danh sách.
  5. Điều kiện if number > largest: Nếu số hiện tại lớn hơn số lớn nhất đã thấy, điều này có nghĩa chúng ta đã tìm thấy một số lớn nhất mới. Số lớn nhất giờ đây trở thành số lớn thứ hai, và number hiện tại là số lớn nhất mới.
  6. Điều kiện elif number > second_largest and number != largest: Nếu số hiện tại không lớn hơn largest (nghĩa là nó nhỏ hơn hoặc bằng largest), chúng ta kiểm tra xem nó có lớn hơn second_largest hiện tại không. Đồng thời, điều kiện number != largest rất quan trọng để xử lý các số trùng lặp của số lớn nhất. Nếu số hiện tại bằng số lớn nhất, nó không thể là số lớn thứ hai phân biệt. Chỉ khi nó lớn hơn second_largest và khác largest, chúng ta mới cập nhật second_largest.
  7. Sau khi duyệt hết danh sách, chúng ta kiểm tra giá trị của second_largest. Nếu nó vẫn là float('-inf'), điều này chỉ có thể xảy ra nếu tất cả các số trong danh sách đều giống nhau (ví dụ: [7, 7, 7]) hoặc danh sách chỉ có 2 phần tử bằng nhau ([50, 50]). Trong trường hợp này, số lớn thứ hai thực tế là bằng với số lớn nhất. Chúng ta có thể trả về largest hoặc một thông báo tùy theo yêu cầu cụ thể của bài toán. Đoạn mã này trả về largest để xử lý trường hợp [7, 7, 7] cho ra kết quả 7.
  8. Nếu second_largest đã được cập nhật (tức là khác float('-inf')), đó chính là kết quả chúng ta cần.

Ưu điểm của phương pháp này: Hiệu quả hơn về mặt thời gian xử lý đối với danh sách lớn (độ phức tạp O(n)). Nhược điểm: Logic phức tạp hơn một chút, đặc biệt là việc xử lý các trường hợp trùng lặp và khởi tạo giá trị ban đầu. Nó có xu hướng tìm số lớn thứ hai phân biệt, trừ trường hợp đặc biệt tất cả các số giống nhau được xử lý ở cuối hàm.

Phương pháp 3: Sử dụng Set và Sắp xếp (Cho số duy nhất)

Nếu yêu cầu cụ thể của bài toán là tìm số lớn thứ hai duy nhất (distinct), tức là loại bỏ hết các số trùng lặp trước khi tìm, chúng ta có thể kết hợp việc sử dụng set (tập hợp) để loại bỏ trùng lặp, sau đó chuyển lại thành danh sách và sắp xếp.

def find_second_largest_set_sorting(numbers):
    # Bước 1: Kiểm tra danh sách đầu vào
     if not numbers: # Kiểm tra danh sách rỗng
        return "Danh sách không được rỗng."

    # Bước 2: Chuyển danh sách sang set để loại bỏ các số trùng lặp
    # Sau đó chuyển set lại thành list để có thể sắp xếp và truy cập theo chỉ số
    unique_numbers = sorted(list(set(numbers)))

    # Bước 3: Kiểm tra số lượng phần tử duy nhất còn lại
    # Nếu chỉ còn 0 hoặc 1 phần tử duy nhất, không thể có số lớn thứ hai duy nhất
    if len(unique_numbers) < 2:
        return "Không đủ số duy nhất để có số lớn thứ hai duy nhất."

    # Bước 4: Trả về phần tử áp chót trong danh sách các số duy nhất đã sắp xếp
    return unique_numbers[-2]

# --- Các ví dụ minh họa ---

print("\n--- Phương pháp 3: Sử dụng Set và Sắp xếp ---")

# Ví dụ 1: Danh sách cơ bản
my_list_1 = [10, 5, 20, 15, 25]
result_1 = find_second_largest_set_sorting(my_list_1)
print(f"Danh sách: {my_list_1}")
print(f"Số lớn thứ hai duy nhất: {result_1}") # Kết quả: 20

# Ví dụ 2: Danh sách có số trùng lặp (số lớn nhất trùng lặp)
my_list_2 = [30, 10, 20, 30, 25, 10]
result_2 = find_second_largest_set_sorting(my_list_2)
print(f"\nDanh sách: {my_list_2}")
print(f"Số lớn thứ hai duy nhất (có trùng lặp): {result_2}") # Kết quả: 25 (sau khi loại bỏ trùng lặp: [10, 20, 25, 30])

# Ví dụ 3: Danh sách tất cả các số giống nhau
my_list_3 = [7, 7, 7, 7]
result_3 = find_second_largest_set_sorting(my_list_3)
print(f"\nDanh sách: {my_list_3}")
print(f"Số lớn thứ hai duy nhất (tất cả giống nhau): {result_3}") # Kết quả: Thông báo lỗi (chỉ còn 1 số duy nhất là 7)

# Ví dụ 4: Edge case - Chỉ có 1 phần tử duy nhất sau khi loại bỏ trùng lặp
my_list_4 = [42, 42, 42]
result_4 = find_second_largest_set_sorting(my_list_4)
print(f"\nDanh sách: {my_list_4}")
print(f"Số lớn thứ hai duy nhất (chỉ 1 duy nhất): {result_4}") # Kết quả: Thông báo lỗi

# Ví dụ 5: Edge case - Danh sách rỗng
my_list_5 = []
result_5 = find_second_largest_set_sorting(my_list_5)
print(f"\nDanh sách: {my_list_5}")
print(f"Số lớn thứ hai duy nhất (rỗng): {result_5}") # Kết quả: Thông báo lỗi

# Ví dụ 6: Chỉ có 2 số duy nhất
my_list_6 = [100, 100, 50, 50, 100]
result_6 = find_second_largest_set_sorting(my_list_6)
print(f"\nDanh sách: {my_list_6}")
print(f"Số lớn thứ hai duy nhất (2 số duy nhất): {result_6}") # Kết quả: 50 (sau khi loại bỏ trùng lặp: [50, 100])

Giải thích mã:

  1. Kiểm tra danh sách rỗng đầu tiên.
  2. set(numbers): Tạo một set từ danh sách. Đặc điểm của set là chỉ chứa các phần tử duy nhất.
  3. list(...): Chuyển set trở lại thành danh sách. Thứ tự các phần tử trong set ban đầu là không đảm bảo, nên việc chuyển lại thành danh sách là cần thiết để có thể sắp xếp.
  4. sorted(...): Sắp xếp danh sách các số duy nhất theo thứ tự tăng dần.
  5. Kiểm tra len(unique_numbers) < 2: Sau khi loại bỏ trùng lặp, nếu số lượng phần tử duy nhất còn lại nhỏ hơn 2, thì không thể có số lớn thứ hai duy nhất, vì vậy chúng ta trả về thông báo.
  6. unique_numbers[-2]: Trả về phần tử áp chót trong danh sách các số duy nhất đã sắp xếp.

Ưu điểm của phương pháp này: Dễ triển khai nếu đã quen với set, đảm bảo kết quả là số lớn thứ hai duy nhất. Nhược điểm: Không phù hợp nếu bạn muốn kết quả là số lớn thứ hai bao gồm cả trùng lặp (như trường hợp [30, 10, 30] mà kết quả mong muốn là 30). Việc chuyển đổi sang set và sắp xếp vẫn có thể tốn kém đối với danh sách rất lớn (độ phức tạp tương tự sắp xếp, O(n log n), cộng thêm chi phí tạo set O(n)).

So sánh các Phương pháp
Phương pháp Độ phức tạp thời gian (Trung bình) Xử lý trùng lặp số lớn nhất Dễ triển khai Ghi chú
1. Sắp xếp O(n log n) Coi số lớn thứ hai là phần tử áp chót Cao Đơn giản, kết quả có thể trùng số lớn nhất
2. Duyệt (Iteration) O(n) Tìm số lớn thứ hai phân biệt (mặc định) Trung bình Hiệu quả hơn, logic phức tạp hơn một chút
3. Set + Sắp xếp O(n log n) hoặc O(n) Tìm số lớn thứ hai duy nhất Trung bình Yêu cầu kết quả là số duy nhất
  • O(n) (Linear time): Thời gian xử lý tăng tuyến tính với kích thước danh sách (n). Duyệt qua danh sách một lần là O(n).
  • O(n log n) (Log-linear time): Thời gian xử lý tăng nhanh hơn tuyến tính nhưng chậm hơn bậc hai. Sắp xếp là ví dụ điển hình.

Lựa chọn phương pháp nào?

  • Nếu danh sách có kích thước nhỏ hoặc trung bình, và bạn muốn cách đơn giản nhất, phương pháp 1 (Sắp xếp) là lựa chọn tốt. Nó cũng xử lý trường hợp các số lớn nhất bị trùng lặp một cách tự nhiên theo định nghĩa "phần tử áp chót sau khi sắp xếp".
  • Nếu hiệu năng là quan trọng nhất và danh sách rất lớn, phương pháp 2 (Duyệt) thường là tối ưu hơn về mặt thời gian chạy (O(n)). Bạn cần cẩn thận hơn khi triển khai để xử lý đúng các trường hợp trùng lặp.
  • Nếu yêu cầu cụ thể là tìm số lớn thứ hai sau khi loại bỏ trùng lặp, phương pháp 3 (Set + Sắp xếp) là phù hợp nhất.
Tổng kết Bài tập 5.4

Qua bài tập này, chúng ta đã thấy cùng một bài toán "tìm số lớn thứ hai" có thể được giải quyết bằng nhiều cách khác nhau trong Python. Mỗi phương pháp có ưu và nhược điểm riêng, phù hợp với các ngữ cảnh và yêu cầu khác nhau. Việc hiểu rõ các cách tiếp cận này (sắp xếp, duyệt, sử dụng cấu trúc dữ liệu trung gian như set) không chỉ giúp bạn giải quyết bài toán hiện tại mà còn mở rộng tư duy giải thuật cho những vấn đề phức tạp hơn.

Hãy thử tự viết lại các hàm trên, thử nghiệm với nhiều loại danh sách khác nhau và so sánh kết quả để củng cố kiến thức nhé!

Chúc bạn học tốt!

Comments

There are no comments at the moment.