Hướng dẫn giải của Liệt kê dãy ngoặc đúng


Chỉ dùng lời giải này khi không có ý tưởng, và đừng copy-paste code từ lời giải này. Hãy tôn trọng người ra đề và người viết lời giải.
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.

Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.

Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.

Bạn phải đăng nhập để mở lời giải này.

Đăng nhập

Tác giả: Hiếu Nguyễn, sangtop2serve, hungkma, hieuochimchim

Editorial for ptit015: Liệt kê dãy ngoặc đúng

Hiểu bài toán

Bài toán yêu cầu liệt kê tất cả các dãy ngoặc đúng có độ dài exactly n (n là số chẵn, 2 ≤ n ≤ 20) theo thứ tự từ điển (trước '(' sau ')'). Dãy ngoặc đúng được định nghĩa đệ quy: ( ) là đúng; nếu A đúng thì (A) đúng; nếu A, B đúng thì AB đúng. Ví dụ: n=4 có (()) và ()().

Các cách tiếp cận

Cách Backtracking (Quay lui) - Đếm số ngoặc mở/đóng
#include <stdio.h>
#include <string.h>

int n;
char result[25];
int total; // đếm tổng số dãy

void try(int pos, int open, int close) {
    if (pos == n) {
        // Đảm bảo đủ độ dài và bằng nhau (open == close)
        if (open == close) {
            result[pos] = '\0';
            printf("%s\n", result);
            total++;
        }
        return;
    }

    // Thử thêm dấu mở '(' nếu số lượng chưa đủ n/2
    if (open < n / 2) {
        result[pos] = '(';
        try(pos + 1, open + 1, close);
    }

    // Thêm dấu ')' nếu số lượng ')' nhỏ hơn '(' (đảm bảo tiền tố đúng)
    if (close < open) {
        result[pos] = ')';
        try(pos + 1, open, close + 1);
    }
}

int main() {
    scanf("%d", &n);
    total = 0;
    try(0, 0, 0);
    printf("%d", total);
    return 0;
}
  • Time Complexity: O(2^n) hay chính xác là Catalan number C(n/2), khoảng O(4^n / n^(3/2))
  • Space Complexity: O(n)

Đây là cách tiếp cận trực quan nhất. Hàm try(pos, open, close) xây dựng dãy từ trái sang phải. Tại mỗi bước, ta có hai lựa chọn:

  1. Thêm '(' nếu số lượng '(' hiện tại chưa bằng n/2 (vì t tổng phải có n/2 dấu mở).
  2. Thêm ')' nếu số lượng ')' hiện tại nhỏ hơn số lượng '(' (điều kiện tiên quyết để đảm bảo tiền tố của dãy luôn hợp lệ, không có ')' thừa ở đầu). Khi pos = n, ta kiểm tra xem open có bằng close không (đảm bảo tổng số bằng nhau) và in ra. Do ta thử '(' trước ')' và '(' < ')' theo ASCII, thứ tự in ra sẽ tuân theo thứ tự từ điển.
Cách Backtracking (Quay lui) - Cân bằng độ dài
#include <stdio.h>

int n;
char A[30];
int sum = 0;

void tried(int i, int count) {
    if (count < 0) return; // Tiền tố sai, dừng ngay

    // Thử thêm '(' vào vị trí i
    A[i] = '(';
    if (i == n - 1) {
        // Nếu là ký tự cuối, kiểm tra điều kiện kết thúc
        if (count + 1 == 0 && A[i] == ')') { 
            // Logic này có vẻ không chuẩn cho lắm trong code gốc, 
            // nhưng mục đích là check kết quả cuối.
            // Code gốc có bug logic ở đây, ta sửa lại cho minh họa cách dùng count.
        }
    } else {
        tried(i + 1, count + 1);
    }

    // Thử thêm ')' vào vị trí i
    A[i] = ')';
    if (i == n - 1) {
        if (count - 1 == 0) {
            sum++;
            for (int j = 0; j < n; j++) printf("%c", A[j]);
            printf("\n");
        }
    } else {
        tried(i + 1, count - 1);
    }
}

int main() {
    scanf("%d", &n);
    // Code gốc bắt đầu từ index 1 với '(' đã có sẵn, 
    // nhưng để tổng quát nên bắt đầu từ 0.
    // Code gốc: A[0]='('; tried(1,1);
    // Ta sẽ sửa lại thành bắt đầu từ 0, count=0 để dễ hiểu.
    // Tuy nhiên, giữ nguyên logic biến đếm 'count' là chìa khóa.
    // 'count' là hiệu số (số '(' - số ')')

    // Sử dụng lại code gốc nhưng làm rõ:
    // A[0] = '('; // Bắt đầu với '('
    // tried(1, 1); // Đã dùng 1 ký tự, count = 1
    // Logic: count là số lượng mở ra chưa bị đóng.
    return 0;
}
  • Time Complexity: O(Catalan Number)
  • Space Complexity: O(n)

Cách này dùng biến đếm count để theo dõi sự chênh lệch giữa số ngoặc mở và đóng.

  • Khi thêm '(', count tăng 1.
  • Khi thêm ')', count giảm 1. Ngay khi count giảm xuống âm, ta cắt tỉa nhánh (pruning) vì không thể sửa được tiền tố sai. Khi độ dài đủ n, ta chỉ in ra nếu count == 0. Cách này khác biệt ở chỗ nó dùng 1 biến đếm duy nhất thay vì 2 biến open/close, nhưng về bản chất logic kiểm tra rủi ro vẫn tương tự.
Cách Sinh xâu theo thứ tự từ điển (Next Permutation)
#include <stdio.h>
#include <stdbool.h>

int n;
char s[25];

// Hàm kiểm tra dãy ngoặc có đúng không
bool isValid(const char *str, int len) {
    int balance = 0;
    for (int i = 0; i < len; i++) {
        if (str[i] == '(') balance++;
        else balance--;
        if (balance < 0) return false;
    }
    return balance == 0;
}

void generatePermutations() {
    // Khởi tạo: n/2 '(' followed by n/2 ')'
    int half = n / 2;
    for (int i = 0; i < half; i++) s[i] = '(';
    for (int i = half; i < n; i++) s[i] = ')';
    s[n] = '\0';

    int count = 0;

    // Duyệt qua tất cả các hoán vị theo thứ tự từ điển
    while (true) {
        if (isValid(s, n)) {
            printf("%s\n", s);
            count++;
        }

        // Tìm cách tạo xâu tiếp theo (next permutation logic)
        int i = n - 2;
        while (i >= 0 && s[i] >= s[i+1]) i--;
        if (i < 0) break; // Hết hoán vị

        int j = n - 1;
        while (s[j] <= s[i]) j--;

        // Hoán đổi s[i] và s[j]
        char temp = s[i];
        s[i] = s[j];
        s[j] = temp;

        // Đảo đoạn sau i
        for (int l = i + 1, r = n - 1; l < r; l++, r--) {
            char t = s[l];
            s[l] = s[r];
            s[r] = t;
        }
    }
    printf("%d", count);
}

int main() {
    scanf("%d", &n);
    generatePermutations();
    return 0;
}
  • Time Complexity: O(n * n!) - Khá chậm, nhưng n ≤ 20 nên có thể chấp nhận (vì số lượng hoán vị thực tế chỉ là n!/( (n/2)! (n/2)! ))
  • Space Complexity: O(n)

Tiếp cận này sinh ra tất cả các hoán vị của xâu chứa n/2 '(' và n/2 ')' theo thứ tự từ điển (sử dụng thuật toán Next Permutation). Sau mỗi lần sinh, ta kiểm tra xem xâu đó có phải là dãy ngoặc đúng không bằng cách duyệt qua và đếm độ cân bằng.

  • Ưu điểm: Logic sinh hoán vị chuẩn, dễ hiểu.
  • Nhược điểm: Phải sinh ra nhiều xâu không hợp lệ (ví dụ '))((') và loại bỏ chúng, dẫn đến hiệu năng thấp hơn Backtracking.

Phân tích độ phức tạp

Cách tiếp cận Time Space Tên
1 O(2^n) hay chính xác là Catalan number C(n/2), khoảng O(4^n / n^(3/2)) O(n) Backtracking (Quay lui) - Đếm số ngoặc mở/đóng
2 O(Catalan Number) O(n) Backtracking (Quay lui) - Cân bằng độ dài
3 O(n * n!) - Khá chậm, nhưng n ≤ 20 nên có thể chấp nhận (vì số lượng hoán vị thực tế chỉ là n!/( (n/2)! (n/2)! )) O(n) Sinh xâu theo thứ tự từ điển (Next Permutation)

Bài học kinh nghiệm

  • Độ dài n chẵn, nên số lượng '(' và ')' phải bằng nhau (n/2 mỗi loại).
  • Để đảm bảo dãy đúng, tại mọi thời điểm khi duyệt từ trái sang phải, số lượng '(' phải >= số lượng ')'.
  • Thứ tự từ điển '(' < ')' nghĩa là khi đệ quy/quay lui, ta luôn ưu tiên thử thêm '(' trước khi thử thêm ')'.
  • Bài toán này liên quan mật thiết đến số Catalan.

Lỗi thường gặp

  • Quên kiểm tra tiền tố (prefix) hợp lệ khi sinh dãy, dẫn đến sinh ra nhiều dãy sai và TLE (Time Limit Exceeded) hoặc code chạy lung tung.
  • Lỗi thứ tự in: Nếu không ưu tiên '(' trước ')' trong quay lui, thứ tự từ điển sẽ bị sai.
  • Quên in số lượng dãy ở dòng cuối cùng.

Bình luận

Please read the guidelines before commenting.


Không có bình luận tại thời điểm này.