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 →

Count Number of Special Subsequences (LeetCode 1955)

Count Number of Special Subsequences (LeetCode 1955)
Nguồn: LeetCode 1955 Độ khó: Khó Quy hoạch động Đếm Modulo

Đề bài

1955. Count Number of Special Subsequences – Đếm số dãy con đặc biệt (LeetCode, độ khó Hard)

Một dãy được gọi là đặc biệt nếu nó gồm một số dương các số $0$, tiếp theo là một số dương các số $1$, rồi đến một số dương các số $2$.

  • Ví dụ, [0,1,2] và [0,0,1,1,1,2] là các dãy đặc biệt.
  • Ngược lại, [2,1,0], [1] và [0,1,2,0] không phải dãy đặc biệt.

Cho mảng nums (chỉ gồm các số nguyên $0$, $1$ và $2$), hãy trả về số lượng dãy con khác nhau là dãy đặc biệt. Vì đáp án có thể rất lớn, hãy trả về nó theo modulo $10^9 + 7$.

Một dãy con của mảng là dãy thu được từ mảng bằng cách xoá đi một số (hoặc không xoá) phần tử mà không làm thay đổi thứ tự các phần tử còn lại. Hai dãy con được coi là khác nhau nếu tập chỉ số được chọn của chúng khác nhau.

Ràng buộc:

  • $1 \le$ nums.length $\le 10^5$
  • $0 \le$ nums[i] $\le 2$

Định dạng vào/ra để chạy thử trên máy: trên LeetCode các bạn chỉ cần viết hàm countSpecialSubsequences trong class Solution. Để chạy được như một chương trình bình thường (và dùng được khung chạy tay bên dưới), mình đặt thêm định dạng:

  • Dữ liệu vào: dòng đầu là số nguyên $n$ (độ dài mảng), dòng thứ hai gồm $n$ số nguyên nums[0], nums[1], ..., nums[n-1].
  • Kết quả: một số nguyên là số dãy con đặc biệt, lấy modulo $10^9 + 7$.
Ví dụ 1 – LeetCode
Input: nums = [0,1,2,2]
Output: 3
Ví dụ 1 – stdin / stdout
4
0 1 2 2
---
3
Ví dụ 2 – LeetCode
Input: nums = [2,2,0,0]
Output: 0
Ví dụ 2 – stdin / stdout
4
2 2 0 0
---
0
Ví dụ 3 – LeetCode
Input: nums = [0,1,2,0,1,2]
Output: 7
Ví dụ 3 – stdin / stdout
6
0 1 2 0 1 2
---
7

Giải thích ví dụ của đề:

  • Ví dụ 1: các dãy con đặc biệt (in đậm là phần được chọn) là [0,1,2,2], [0,1,2,2] và [0,1,2,2].
  • Ví dụ 2: không có dãy con đặc biệt nào trong [2,2,0,0].
  • Ví dụ 3: các dãy con đặc biệt là [0,1,2,0,1,2], [0,1,2,0,1,2], [0,1,2,0,1,2], [0,1,2,0,1,2], [0,1,2,0,1,2], [0,1,2,0,1,2], [0,1,2,0,1,2].

1955. Count Number of Special Subsequences (LeetCode, Hard)

A sequence is special if it consists of a positive number of 0s, followed by a positive number of 1s, then a positive number of 2s.

  • For example, [0,1,2] and [0,0,1,1,1,2] are special.
  • In contrast, [2,1,0], [1], and [0,1,2,0] are not special.

Given an array nums (consisting of only integers 0, 1, and 2), return the number of different subsequences that are special. Since the answer may be very large, return it modulo $10^9 + 7$.

A subsequence of an array is a sequence that can be derived from the array by deleting some or no elements without changing the order of the remaining elements. Two subsequences are different if the set of indices chosen are different.

Example 1:

Input: nums = [0,1,2,2]
Output: 3

Explanation: The special subsequences are bolded [0,1,2,2], [0,1,2,2], and [0,1,2,2].

Example 2:

Input: nums = [2,2,0,0]
Output: 0
Explanation: There are no special subsequences in [2,2,0,0].

Example 3:

Input: nums = [0,1,2,0,1,2]
Output: 7

Explanation: The special subsequences are bolded:

  • [0,1,2,0,1,2]
  • [0,1,2,0,1,2]
  • [0,1,2,0,1,2]
  • [0,1,2,0,1,2]
  • [0,1,2,0,1,2]
  • [0,1,2,0,1,2]
  • [0,1,2,0,1,2]

Constraints:

  • $1 \le$ nums.length $\le 10^5$
  • $0 \le$ nums[i] $\le 2$

Lời mở đầu

Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇.

Lưu ý nhỏ: khi nộp trên CLUE OJ các bạn dán toàn bộ code, gồm cả hàm main, vì đề trên CLUE OJ đọc dữ liệu vào và in kết quả đúng theo định dạng ở phần đề bài, giống như khi các bạn chạy thử trên máy hoặc ở trang Chạy code online của blog.

Mình giải thích thêm ví dụ 1 cho bạn nào chưa hiểu đề: mảng là [0,1,2,2] với các vị trí 0, 1, 2, 3. Dãy con đặc biệt phải có ít nhất một số 0, rồi ít nhất một số 1, rồi ít nhất một số 2, đúng thứ tự đó. Số 0 và số 1 thì chỉ có một cách chọn (vị trí 0 và 1), còn phần số 2 ta có thể chọn {2}, {3} hoặc {2, 3} – tức là 3 cách, nên đáp án là 3. Chú ý là hai dãy [0,1,2,2] và [0,1,2,2] tuy giá trị giống nhau (đều là 0,1,2) nhưng chọn ở các vị trí khác nhau nên vẫn được tính là 2 dãy khác nhau.

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

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

Cách 1Duyệt tất cả dãy con – O(2^N · N)

Trước khi vào lời giải chính, mình làm nhanh cách "làm theo đề" để các bạn thấy rõ bài đang đếm cái gì. Mỗi dãy con ứng với một tập chỉ số, ta biểu diễn bằng một số nhị phân mask $n$ bit: bit thứ $i$ bằng 1 nghĩa là chọn nums[i]. Với mỗi mask từ $1$ tới $2^n - 1$, ta lấy ra dãy con b rồi kiểm tra xem b có dạng 0..0 1..1 2..2 hay không:

  • Đi một con trỏ p từ đầu dãy b, lần lượt "ăn" hết các số 0 liên tiếp, rồi các số 1 liên tiếp, rồi các số 2 liên tiếp.
  • Nếu có loại nào ăn được 0 phần tử thì dãy không đặc biệt (đề yêu cầu mỗi loại phải có số lượng dương).
  • Ăn xong 3 loại mà p chưa tới cuối dãy (ví dụ [0,1,2,0]) thì cũng không đặc biệt.

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

#include <bits/stdc++.h>
using namespace std;

class Solution {
public:
    // b có dạng 0..0 1..1 2..2 (mỗi loại ít nhất 1 số) hay không
    bool isSpecial(vector<int>& b) {
        int k = (int) b.size(), p = 0;
        for (int v = 0; v <= 2; v++) {
            int cnt = 0;
            while (p < k && b[p] == v) {
                p++;
                cnt++;
            }
            if (cnt == 0) return false;
        }
        return p == k;
    }
    int countSpecialSubsequences(vector<int>& nums) {
        int n = (int) nums.size();
        long long ans = 0;
        for (int mask = 1; mask < (1 << n); mask++) {
            vector<int> b;
            for (int i = 0; i < n; i++) {
                if (mask >> i & 1) b.push_back(nums[i]);
            }
            if (isSpecial(b)) ans++;
        }
        return ans % 1000000007;
    }
};

int main() {
    int n;
    cin >> n;
    vector<int> nums(n);
    for (int i = 0; i < n; i++) cin >> nums[i];
    Solution sol;
    cout << sol.countSpecialSubsequences(nums);
    return 0;
}
import sys
from typing import List

class Solution:
    # b có dạng 0..0 1..1 2..2 (mỗi loại ít nhất 1 số) hay không
    def isSpecial(self, b: List[int]) -> bool:
        k, p = len(b), 0
        for v in range(3):
            cnt = 0
            while p < k and b[p] == v:
                p += 1
                cnt += 1
            if cnt == 0:
                return False
        return p == k

    def countSpecialSubsequences(self, nums: List[int]) -> int:
        n = len(nums)
        ans = 0
        for mask in range(1, 1 << n):
            b = [nums[i] for i in range(n) if mask >> i & 1]
            if self.isSpecial(b):
                ans += 1
        return ans % 1000000007

def main():
    data = sys.stdin.read().split()
    n = int(data[0])
    nums = list(map(int, data[1:1 + n]))
    print(Solution().countSpecialSubsequences(nums))

if __name__ == "__main__":
    main()

Đánh giá độ phức tạp của cách làm này:

  • Không gian: mảng b tối đa $n$ phần tử nên là $O(N)$.
  • Thời gian: có $2^n$ dãy con, mỗi dãy mất $O(n)$ để lấy ra và kiểm tra, tổng cộng $O(2^N \cdot N)$.
Không gian$O(N)$
Thời gian$O(2^N \cdot N)$
Chỉ dùng được khi n rất nhỏ
Với $n = 20$ cách này đã mất khoảng nửa giây, còn đề cho $n$ tới $10^5$ thì $2^{100000}$ là con số không tưởng ⇒ chắc chắn TLE. Cách này mình chỉ dùng để hiểu đề và làm code vét cạn đối chiếu kết quả cho cách 2.

Cách 2Quy hoạch động theo "số cuối cùng đã chọn" – O(N)

Bài này giải quyết theo Quy Hoạch Động với độ phức tạp $O(N)$. Ý tưởng là đi lần lượt từng phần tử của mảng, với mỗi phần tử ta chỉ có 2 lựa chọn: không chọn hoặc chọn nó vào dãy con. Để biết chọn vào có hợp lệ hay không, ta chỉ cần nhớ số cuối cùng của dãy con đang xây dựng.

Trạng thái
Gọi $f[i][j]$ là số cách chọn ra một dãy "đang hợp lệ" khi đã xét $i$ số đầu tiên của mảng và số cuối cùng đang có trong dãy là $j$ ($j \in \{0, 1, 2\}$). "Đang hợp lệ" nghĩa là dãy có dạng 0..0, 0..0 1..1 hoặc 0..0 1..1 2..2 (mỗi khúc có ít nhất 1 số).

Vì dãy thỏa mãn phải bắt đầu bằng số 0, mà ta cần gán một bài toán cơ sở, nên tạm thời ta coi như trước khi chọn gì thì "số cuối" là $-1$: $f[0][-1] = 1$ (có đúng 1 cách là dãy rỗng). Tuy nhiên mảng không chấp nhận chỉ số âm, nên ta dùng cột số 3 để thay cho $-1$:

Bài toán cơ sở
$f[0][3] = 1$ – cột $j = 3$ nghĩa là "chưa chọn số nào".

Với mỗi $f[i][j] \gt 0$ hiện tại, ta có những tình huống sau:

  • Không chọn phần tử thứ $i + 1$ (tức nums[i] trong code, đánh số từ 0): số cuối không đổi, $f[i + 1][j] \mathrel{+}= f[i][j]$.
  • Chọn phần tử thứ $i + 1$, có những tình huống sau:
    • Nếu $j = 3$ (chưa chọn gì), đồng thời nums[i] $= 0$ thì ta mở đầu dãy bằng số 0 này: $f[i + 1][0] \mathrel{+}= f[i][3]$.
    • Nếu $j \ne 3$, xét nums[i] $= j$ (nối thêm một số cùng loại) hoặc nums[i] $= j + 1$ (chuyển sang khúc tiếp theo), khi đó cập nhật $f[i + 1][\text{nums}[i]] \mathrel{+}= f[i][j]$.
Kết quả
$f[n][2]$ – các dãy đã xét hết $n$ số và đang kết thúc bằng số 2 chính là các dãy đặc biệt.

Các bạn để ý: nếu nums[i] $= 1$ mà $j = 2$ thì không được chọn (vì 1 không được đứng sau 2), nếu nums[i] $= 2$ mà $j = 0$ cũng không được (thiếu khúc số 1), và $j = 3$ chỉ được nối với số 0. Gom lại, mỗi bước chỉ thay đổi đúng một ô:

Nhìn gọn lại công thức
  • nums[i] $= 0$: $f[i+1][0] = 2 \cdot f[i][0] + f[i][3]$.
  • nums[i] $= 1$: $f[i+1][1] = 2 \cdot f[i][1] + f[i][0]$.
  • nums[i] $= 2$: $f[i+1][2] = 2 \cdot f[i][2] + f[i][1]$.
Các ô còn lại giữ nguyên giá trị của hàng $i$. Hệ số 2 là vì mỗi dãy cũ đang kết thúc bằng $v$ sẽ sinh ra 2 dãy: không lấy và có lấy thêm số $v$ này.

Minh hoạ bảng quy hoạch động

Em nhập mảng nums (tối đa 12 số) rồi bấm Tiếp. Mỗi bước xét thêm một phần tử, bảng $f$ có thêm một hàng; bên dưới liệt kê luôn các dãy con đang thuộc từng trạng thái (ghi bằng các vị trí đã chọn), dãy mới sinh ra ở bước này tô vàng:

Ví dụ nhanh:

Code dưới đây của mình đã AC với tư tưởng đã trình bày ở trên. Bảng dp có $n + 1$ hàng, 4 cột (cột 3 thay cho "$-1$"), mỗi lần cộng xong lấy modulo luôn. Source code để các bạn tham khảo nếu làm theo hướng này:

#include <bits/stdc++.h>
using namespace std;

const int MOD = 1000000007;
int dp[100005][4];

class Solution {
public:
    int countSpecialSubsequences(vector<int>& nums) {
        int n = (int) nums.size();
        memset(dp, 0, sizeof(dp));
        dp[0][3] = 1;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j <= 3; j++) {
                (dp[i + 1][j] += dp[i][j]) %= MOD;
                if (j == 3) {
                    if (nums[i] == 0) {
                        (dp[i + 1][0] += dp[i][j]) %= MOD;
                    }
                }
                else {
                    if (nums[i] == j || nums[i] == j + 1) {
                        (dp[i + 1][nums[i]] += dp[i][j]) %= MOD;
                    }
                }
            }
        }
        return dp[n][2];
    }
};

int main() {
    int n;
    cin >> n;
    vector<int> nums(n);
    for (int i = 0; i < n; i++) cin >> nums[i];
    Solution sol;
    cout << sol.countSpecialSubsequences(nums);
    return 0;
}
import sys
from typing import List

MOD = 1000000007

class Solution:
    def countSpecialSubsequences(self, nums: List[int]) -> int:
        n = len(nums)
        dp = [[0] * 4 for _ in range(n + 1)]
        dp[0][3] = 1
        for i in range(n):
            for j in range(4):
                dp[i + 1][j] = (dp[i + 1][j] + dp[i][j]) % MOD
                if j == 3:
                    if nums[i] == 0:
                        dp[i + 1][0] = (dp[i + 1][0] + dp[i][j]) % MOD
                else:
                    if nums[i] == j or nums[i] == j + 1:
                        dp[i + 1][nums[i]] = (dp[i + 1][nums[i]] + dp[i][j]) % MOD
        return dp[n][2]

def main():
    data = sys.stdin.read().split()
    n = int(data[0])
    nums = list(map(int, data[1:1 + n]))
    print(Solution().countSpecialSubsequences(nums))

if __name__ == "__main__":
    main()

Khung chạy tay bên dưới chạy đúng từng dòng của code C++ ở trên (cả hàm main lẫn hàm trong class Solution). Ô vàng trong bảng là $dp[i][j]$ đang được dùng, ô viền vàng là ô vừa được cộng thêm:

C++▶ Chạy tay từng dòng codeQHĐ dp[i][j]: đã xét i số, số cuối của dãy là j (3 = chưa chọn), O(N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const int MOD = 1000000007;
5int dp[100005][4];
6
7class Solution {
8public:
9 int countSpecialSubsequences(vector<int>& nums) {
10 int n = (int) nums.size();
11 memset(dp, 0, sizeof(dp));
12 dp[0][3] = 1;
13 for (int i = 0; i < n; i++) {
14 for (int j = 0; j <= 3; j++) {
15 (dp[i + 1][j] += dp[i][j]) %= MOD;
16 if (j == 3) {
17 if (nums[i] == 0) {
18 (dp[i + 1][0] += dp[i][j]) %= MOD;
19 }
20 }
21 else {
22 if (nums[i] == j || nums[i] == j + 1) {
23 (dp[i + 1][nums[i]] += dp[i][j]) %= MOD;
24 }
25 }
26 }
27 }
28 return dp[n][2];
29 }
30};
31
32int main() {
33 int n;
34 cin >> n;
35 vector<int> nums(n);
36 for (int i = 0; i < n; i++) cin >> nums[i];
37 Solution sol;
38 cout << sol.countSpecialSubsequences(nums);
39 return 0;
40}

Đánh giá độ phức tạp của cách làm này:

  • Không gian: bảng dp có $(n + 1) \times 4$ ô nên là $O(N)$. (Nếu muốn, các bạn có thể chỉ giữ 4 biến cho hàng hiện tại theo công thức gọn ở trên để còn $O(1)$, nhưng với $n \le 10^5$ thì không cần.)
  • Thời gian: hai vòng lặp, vòng ngoài $n$ lần, vòng trong 4 lần, nên là $O(4 \cdot N) = O(N)$ nha các bạn.
Không gian$O(N)$
Thời gian$O(N)$
Modulo và kiểu int
Mỗi lần cộng mình đều lấy % MOD ngay. Hai số đều nhỏ hơn $10^9 + 7$ nên tổng nhỏ hơn $2 \cdot (10^9 + 7) \approx 2 \cdot 10^9$, vẫn vừa kiểu int (tối đa khoảng $2.147 \cdot 10^9$). Nếu các bạn cộng dồn nhiều lần rồi mới lấy modulo thì phải dùng long long để khỏi tràn số.
Vì sao dùng mảng toàn cục + memset?
Mảng dp[100005][4] khai báo toàn cục cho khỏi tràn stack. Trên LeetCode hàm countSpecialSubsequences được gọi nhiều lần với nhiều test trong cùng một lần chạy, nên đầu hàm phải memset lại về 0, nếu không thì dữ liệu test trước sẽ "dính" sang test sau.
Ghi chú của mình: code C++ và Python của cả 2 cách đều cho đúng 3 ví dụ của đề, và đã được so với một chương trình vét cạn độc lập (duyệt mọi tổ hợp chỉ số) trên 400 bộ test ngẫu nhiên với $n \le 12$ – khớp hết. Với $n = 10^5$, cách 2 bản C++ chạy khoảng 0.05 giây, bản Python khoảng 0.3 giây; cách 1 với $n = 20$ đã mất 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