Hướng dẫn giải của Câu đối Tết


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, ntkkdl, chiiiiii, Vvvvvvvvvvvv

Hiểu bài toán

Cho một xâu s gồm các ký tự thường. Ban đầu có câu đối gốc s. Các bạn học sinh khác copy câu đối này, nhưng mỗi người chọn một đoạn con liên tiếp bất kỳ của câu đối hiện tại và đảo ngược đoạn con đó. Mỗi người copy chỉ sao chép từ câu đối gốc (hoặc từ các câu đối đã được tạo ra từ gốc bằng các phép đảo ngược), không thực hiện nhiều phép đảo trên cùng một bản copy. Câu hỏi là có tất cả bao nhiêu câu đối khác nhau có thể tạo được (bao gồm cả câu đối gốc)?

Giải thích chi tiết: Một câu đối mới được tạo ra bằng cách chọn một đoạn con [i, j] của xâu hiện tại và thay thế nó bằng chính đoạn đó nhưng đảo ngược thứ tự ký tự. Ví dụ: Với s = "abab", các phép đảo có thể:

  • Đảo "ab" (vị trí 2-3): "abab" -> "aabb"
  • Đảo "ba" (vị trí 2-3): "abab" -> "abba"
  • Đảo "aba" (vị trí 1-3): "abab" -> "baab"
  • Đảo "bab" (vị trí 2-4): "abab" -> "abba"
  • Đảo "abab" (vị trí 1-4): "abab" -> "baba"

Ta thấy:

  • "abab" (gốc)
  • "aabb" (đảo "ab")
  • "abba" (đảo "ba" hoặc "bab")
  • "baab" (đảo "aba")
  • "baba" (đảo "abab") Tổng cộng 5 câu đối.

Bài toán yêu cầu đếm số lượng xâu khác nhau có thể thu được bằng cách thực hiện chính xác một phép đảo ngược bất kỳ trên xâu gốc (hoặc các xâu đã được tạo ra, nhưng do tính đối xứng và tính duy nhất của phép biến đổi, ta có thể quy về phân tích trên xâu gốc).

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

Cách Brute Force simulation (Mô phỏng thô)
#include <iostream>
#include <string>
#include <vector>
#include <set>
#include <algorithm>

using namespace std;

int main() {
    string s;
    cin >> s;
    int n = s.length();
    set<string> unique_strings;

    // Luôn thêm xâu gốc vào
    unique_strings.insert(s);

    // Duyệt qua tất cả các đoạn con có thể đảo
    for (int i = 0; i < n; ++i) {
        for (int j = i; j < n; ++j) {
            string temp = s;
            // Đảo ngược đoạn từ i đến j
            reverse(temp.begin() + i, temp.begin() + j + 1);
            unique_strings.insert(temp);
        }
    }

    cout << unique_strings.size() << endl;
    return 0;
}
  • Time Complexity: O(N^3 log N) hoặc O(N^3) nếu dùng hash, với N là độ dài xâu. Do có khoảng N^2/2 đoạn con, mỗi đoạn đảo mất O(N), và insert vào set mất O(N log N). Với N=10^5, giải pháp này quá chậm.
  • Space Complexity: O(N^3) - Có thể tạo ra rất nhiều xâu khác nhau (tối đa O(N^2) xâu, mỗi xâu độ dài N).

Đây là cách tiếp cận trực quan nhất. Ta duyệt qua tất cả các cặp (i, j) với 0 <= i <= j < n. Với mỗi cặp, ta tạo một bản sao của xâu s, đảo ngược đoạn từ i đến j, và lưu vào một set để đảm bảo tính duy nhất. Cuối cùng, trả về kích thước của set.

Cách này chỉ mang tính tham khảo cho các bài toán độ dài xâu nhỏ (N <= 100). Với giới hạn N <= 10^5, nó chắc chắn bị TLE (Time Limit Exceeded) và MLE (Memory Limit Exceeded).

Cách Quy luật Toán học (Phân tích số lượng)
#include <iostream>
#include <string>
#include <vector>

using namespace std;

int main() {
    string ch;
    cin >> ch;
    int n = ch.size();

    // Tổng số đoạn con liên tiếp (bao gồm cả đoạn độ dài 1)
    // Công thức tổng quát: n*(n+1)/2
    // Tuy nhiên, bài toán chỉ quan tâm đến việc tạo ra xâu KHÁC VỚI GỐC.
    // Đoạn độ dài 1 khi đảo không thay đổi xâu.
    // Đoạn độ dài >= 2 khi đảo có thể tạo ra xâu mới hoặc trùng xâu cũ.

    // Các giải pháp mẫu tính:
    // tong_so_cau_doi = 1 + (n*(n-1))/2 - (tong_cac_cap_ky_tu_trung)
    // Phân tích:
    // 1 là xâu gốc.
    // (n*(n-1))/2 là số lượng đoạn con có độ dài >= 2 (từ i=0 đến n-2, j từ i+1 đến n-1, tổng = tong_{i=0}^{n-2} (n-1-i) = n(n-1)/2).
    // - tong_cac_cap_ky_tu_trung là tổng của C(freq[c], 2) cho mỗi ký tự c.
    // 
    // Biện luận:
    // Tại sao lại trừ đi các cặp ký tự trùng?
    // Nếu ta đảo một đoạn con "...xx...", nếu hai ký tự x này nằm ở vị trí đối xứng trong đoạn đang đảo,
    // hoặc đoạn đó bao gồm các ký tự giống nhau ở đúng vị trí đối xứng khi đảo,
    // thì kết quả có thể bị trùng lặp.
    // 
    // Tuy nhiên, có một cách giải thích chính xác hơn:
    // 
    // Giả sử ta chỉ xét các đoạn con có độ dài >= 2.
    // Nếu ta có một đoạn con bất kỳ, việc đảo ngược nó tạo ra một xâu mới trừ khi:
    // 1. Đoạn con đó là một palindrome (xâu đối xứng). Khi đó, đảo ngược nó không đổi.
    //    Ví dụ: "aba", "aa", "abba".
    // 2. Đoạn con "ab" khi đảo thành "ba", đoạn "ba" khi đảo thành "ab".
    //    Nếu "ab" và "ba" cùng xuất hiện trong xâu, ta có thể tạo ra các xâu trùng lặp.
    // 
    // Nhưng hãy nhìn vào các giải pháp đã được chấp nhận:
    // long long tt = (long long)n * (n - 1) / 2; // Số đoạn con độ dài >= 2
    // long long gn = 0;
    // for(int i = 0; i < 26; i++) gn += dem[i] * (dem[i] - 1LL) / 2;
    // long long kq = 1 + tt - gn;
    // 
    // Biện luận công thức:
    // - 1: Xâu gốc.
    // - tt: Giả sử tất cả các đoạn con độ dài >= 2 đều tạo ra xâu mới và duy nhất.
    // - gn: Số cặp ký tự giống nhau trong xâu.
    // 
    // Tại sao trừ số cặp ký tự trùng?
    // Nếu ta có một đoạn con độ dài 2 là "aa" (hai ký tự giống nhau), đảo nó lên vẫn là "aa".
    // Xâu "aa" không tạo ra xâu mới.
    // Nếu ta có một đoạn con "aba", trong đó 'a' xuất hiện 2 lần.
    // 
    // Dưới đây là code mô phỏng lại logic của các solution đã accept, giải thích theo cách của:
    // 'Có n.(n-1)/2 đoạn con liên tiếp có thể đảo (độ dài >= 2).
    // Nếu đảo ngược một đoạn mà không làm thay đổi chuỗi, số câu đối không tăng.
    // Đếm số lần xuất hiện của từng ký tự để tính số cặp ký tự giống nhau.'
    // 
    // Có vẻ như các solution này dựa trên một nhận định toán học khá đặc biệt.
    // 
    // Có một cách tiếp cận khác chính xác hơn:
    // Xét các đoạn con độ dài >= 2.
    // Nếu đoạn con không phải là palindrome, nó tạo ra 1 xâu mới.
    // Nếu đoạn con là palindrome, nó tạo ra xâu cũ (không thêm mới).
    // 
    // Tuy nhiên, bài toán này thực chất là đếm số lượng xâu có thể tạo được.
    // Các solution trên đưa ra công thức: 1 + [số đoạn con độ dài >= 2] - [số cặp ký tự giống nhau].
    // 
    // Code implement lại logic này:
    long long tt = (long long)n * (n - 1) / 2;

    vector<long long> dem(26, 0);
    for(char c : ch) dem[c - 'a']++;

    long long gn = 0;
    for(int i = 0; i < 26; i++)
        gn += dem[i] * (dem[i] - 1LL) / 2;

    long long kq = 1 + tt - gn;

    cout << kq << endl;
    return 0;
}
  • Time Complexity: O(N) - Duyệt xâu 1 lần để đếm tần suất.
  • Space Complexity: O(1) - Mảng tần suất cố định 26 phần tử.

Approach này dựa trên quan sát toán học về số lượng xâu khác nhau có thể sinh ra.

Giả sử:

  • Xâu gốc luôn có 1.
  • Có tổng cộng n*(n-1)/2 đoạn con có độ dài từ 2 trở lên (tức là các đoạn [i, j] với j > i).
  • Nếu ta coi mỗi đoạn con này là một phép biến đổi tiềm năng, ta có 1 + n*(n-1)/2 kết quả.
  • Tuy nhiên, nhiều phép biến đổi có thể cho ra cùng một kết quả.

Cách tiếp cận này cho rằng: Số lượng xâu khác nhau = (Xâu gốc) + (Số đoạn con có thể đảo) - (Số trường hợp trùng lặp).

Theo đó, số trường hợp trùng lặp được tính là tổng số cặp ký tự giống nhau trong xâu.

Giải thích "Số cặp ký tự giống nhau": Với mỗi ký tự xuất hiện k lần, có k*(k-1)/2 cách chọn 2 vị trí cho ký tự đó. Tại sao lại trừ đi con số này? Có thể giải thích rằng: Mỗi cặp ký tự trùng lặp tạo ra một "ràng buộc" khiến một số đoạn con không tạo ra xâu mới. Ví dụ, nếu có 2 ký tự 'a' ở vị trí p và q (p < q), việc có các ký tự này giống nhau có thể dẫn đến các đoạn con đối xứng hoặc các phép đảo tạo ra trùng lặp.

Cụ thể hơn, một lý giải khác cho công thức này: Giả sử ta chỉ xét các đoạn con độ dài >= 2.

  • Nếu ta có n*(n-1)/2 đoạn con, giả sử tất cả đều tạo ra xâu mới.
  • Nhưng nếu có hai ký tự giống nhau, ví dụ 'a', ở hai vị trí bất kỳ, điều này có thể tạo ra một đoạn con palindrome "aa".
  • "aa" khi đảo vẫn là "aa" (xâu cũ).
  • Do đó, mỗi cặp ký tự giống nhau làm giảm đi 1 xâu mới.

Tuy nhiên, cách giải thích này chưa thực sự thỏa mãn cho tất cả các trường hợp (ví dụ đoạn "aba" có 2 'a'). Nhưng nhìn vào các giải pháp đã được kiểm chứng (Accepted), công thức này là chính xác: Result = 1 + n*(n-1)/2 - sum(count[char] * (count[char] - 1) / 2)

Đây là cách tiếp cận tối ưu nhất về mặt thời gian và được chấp nhận trong kỳ thi.

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

Cách tiếp cận Time Space Tên
1 O(N^3 log N) hoặc O(N^3) nếu dùng hash, với N là độ dài xâu. Do có khoảng N^2/2 đoạn con, mỗi đoạn đảo mất O(N), và insert vào set mất O(N log N). Với N=10^5, giải pháp này quá chậm. O(N^3) - Có thể tạo ra rất nhiều xâu khác nhau (tối đa O(N^2) xâu, mỗi xâu độ dài N). Brute Force simulation (Mô phỏng thô)
2 O(N) - Duyệt xâu 1 lần để đếm tần suất. O(1) - Mảng tần suất cố định 26 phần tử. Quy luật Toán học (Phân tích số lượng)

Bài học kinh nghiệm

  • Bài toán có thể được quy về một công thức toán học đơn giản thay vì mô phỏng tất cả các trường hợp.
  • Số lượng đoạn con có thể đảo ngược (độ dài >= 2) là n*(n-1)/2.
  • Số lượng xâu mới tạo được bằng 1 + (số đoạn con) - (số cặp ký tự giống nhau).

Lỗi thường gặp

  • Lầm tưởng rằng cần phải mô phỏng tất cả các phép đảo ngược (O(N^3)), dẫn đến quá thời gian.
  • Sai lệch trong các biến kiểu số integer (overflow). Cần sử dụng long long cho các tính toán tổng số đoạn con và số cặp ký tự.
  • Quên xử lý trường hợp xâu rỗng hoặc độ dài 1 (mặc dù đề bài đảm bảo xâu chỉ gồm chữ cái thường, nhưng logic phải xử lý đúng số đoạn con = 0 khi n<2).

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.