thảo luận [Học Tập] Topic thuật toán

  • Người tạo chủ đề Người tạo chủ đề unknowpc90
  • Ngày bắt đầu Ngày bắt đầu
Tiếp tục chuyên mục mỗi ngày một leetcode. Các bài mình làm ngày hôm nay:

#399. (Medium) https://leetcode.com/problems/evaluate-division
#400. (Medium) https://leetcode.com/problems/nth-digit
#401. (Easy) https://leetcode.com/problems/binary-watch
#402. (Medium) https://leetcode.com/problems/remove-k-digits

Mình sẽ share về bài Remove K Digits:
#402. (Medium) https://leetcode.com/problems/remove-k-digits

Phân tích bài toán:
  • 1 <= k <= num.length <= 10^5: Độ phức tạp kỳ vọng có thể là O(n) hoặc tệ lắm thì là O(nlogn)
  • Đầu tiên dễ thấy trong trường hợp bình thường, nếu chuỗi ban đầu có n số, thì sau khi bỏ đi k chữ số. Chuỗi kết quả sẽ có (n - k) chữ số.
  • Vậy với một chuỗi cố định có (n - k) số. Ta sẽ làm thế nào để có kết quả nhỏ nhất ?
  • Ý tưởng hiển nhiên là ta sẽ chọn sao cho số nhỏ đứng trước - số lớn đứng sau.
  • Hay nói cách khác, nếu số đứng sau mà nhỏ hơn số đứng trước, thì số đứng trước phải bị bỏ đi
  • Tại đây ta có thể xây dựng một thuật toán naive như sau:

Python:
class Solution:
    def removeKdigits(self, num: str, k: int) -> str:
        res = []
        for n in num:
            while res and res[-1] > n:
                res.pop()
          
            res.append(n)
      
        return ''.join(res)

Dĩ nhiên solution này không work, vì nó còn gặp nhiều vấn đề:

Giải thuật tham lam (greedy):
  • Vấn đề đầu tiên của solution trên là nó mang tính "greedy". Nghĩa là nó chỉ "giải" theo mục tiêu trước mắt, chứ k quan tâm đến mục tiêu toàn cục.
  • Trong đoạn code trên, hễ gặp số trước lớn hơn số sau thì ta loại nó ngay. Nhưng vì ta chỉ có thể loại k phần tử, ta cần cân nhắc rằng sau này có thể có những số khác xuất hiện, khiến cho việc "giữ lại" số này sẽ có lợi hơn là loại bỏ nó ngay lập tức.
  • May mắn thay với bài này thì không như vậy, việc loại phần tử ngay lập tức vẫn tối ưu cho trường hợp toàn cục. Các bạn có thể tự suy nghĩ xem tại sao.
  • Tuy nhiên ta cần hiểu rằng không phải bài toán nào cũng có tính chất này, và ta cần phải cẩn thận mỗi khi sử dụng những solution có tính "greedy" như trên.

Các vấn đề khác
  • Solution trên có thể remove nhiều hơn k phần tử
  • Hoặc có thể remove ít hơn k phần tử
  • Trường hợp leading zero thì sao ?
  • Nếu các số bị bỏ hết thì thế nào ?

Ta sẽ cần suy nghĩ, bổ sung thêm các logic để xử lý các trường hợp này. Việc tự nghĩ ra các edge cases cũng là một kỹ năng quan trọng trong việc coding.

Solution:
  • Với mỗi chữ số trong num
    • Nếu chữ số đứng trước còn lớn hơn chữ số hiện tại:
      • Bỏ chữ số đứng trước đi (nhưng tối đa k lần thôi)
    • Nếu không phải leading zerio thì thêm chữ số hiện tại vào kết quả
  • Nếu chưa loại đủ k ký tự thì loại các ký tự ở cuối cho đủ k (why??)
  • Trả về kết quả, cần để ý trường hợp empty
Python:
class Solution:
    def removeKdigits(self, num: str, k: int) -> str:
        res = []
        for n in num:
            while k > 0 and res and res[-1] > n:
                res.pop() 
                k -= 1
          
            if res or n != '0':
                res.append(n)
      
        if k > 0:
            res = res[:-k]
          
        if not res:
            return '0'
      
        return ''.join(res)
 
Sửa lần cuối:
@_Gia_Cat_Luong_
Đây là solution bằng C++ của mình cho bài
https://leetcode.com/problems/remove-k-digits/
C++:
class Solution {
public:
    string removeKdigits(string num, int k) {
        int idx = 0;
        while (k > 0){
            if (num[idx] > num[idx+1]){
                num.erase(num.begin() + idx);
                k--;
                idx = idx == 0 ? 0 : idx - 1;
            } else{
                idx++;
            }
        }
        num.erase(0, num.find_first_not_of("0")); // remove leading zero
        return  num.empty() ? "0" : num;
    }
};

Ý tưởng bài này giống như trò chơi rút gỗ vậy. Đi từ đầu, cứ cục nào lớn hơn cục phía sau thì rút.

Runtime: 20 ms, faster than 25.01% of C++ online submissions for Remove K Digits.
Memory Usage: 6.9 MB, less than 86.44% of C++ online submissions for Remove K Digits.


Hiện tại do mình thao tác trực tiếp trên num được truyền vào. Chỗ erase cái num làm cho solution này chạy chậm. Đổi lại thì đỡ tốn mem hơn.
Nếu muốn chạy nhanh hơn thì có thể dùng 1 cái string khác để lưu kết quả. Và không cần erase trên num nữa. Làm vậy thì sẽ nhanh mà tốn mem hơn. Cái này là trade-off thôi.

:D
 
Công nhận cái bài này nó lừa đảo. Do cái bài longest common subsequence nó quá popular rồi nên mình bị ảnh hưởng sang bài này. Thực tế nếu suy nghĩ kỹ thì bài này cực dễ.

C++:
class Solution {
public:
    int findLUSlength(string a, string b) {
        return a != b ? max(a.length(), b.length()) : -1;
    }
};

Solution này là t tự làm nhé. Trong phần discuss cũng có ông post gần giống. :D

Runtime: 0 ms, faster than 100.00% of C++ online submissions for Longest Uncommon Subsequence I.
Memory Usage: 6.2 MB, less than 37.16% of C++ online submissions for Longest Uncommon Subsequence I.
 
Sửa lần cuối:
Công nhận cái bài này nó lừa đảo. Do cái bài longest common subsequence nó quá popular rồi nên mình bị ảnh hưởng sang bài này. Thực tế nếu suy nghĩ kỹ thì bài này cực dễ.

C++:
class Solution {
public:
    int findLUSlength(string a, string b) {
        return a != b ? max(a.length(), b.length()) : -1;
    }
};

Solution này là t tự làm nhé. Trong phần discuss cũng có ông post gần giống. :D

Runtime: 0 ms, faster than 100.00% of C++ online submissions for Longest Uncommon Subsequence I.
Memory Usage: 6.2 MB, less than 37.16% of C++ online submissions for Longest Uncommon Subsequence I.
:D troll thật
1628066697434.png
 
Tiếp tục chuyên mục mỗi ngày một leetcode. Các bài mình làm ngày hôm nay:

#403. (Hard) https://leetcode.com/problems/frog-jump/
#404. (Easy) https://leetcode.com/problems/sum-of-left-leaves/
#405. (Easy) https://leetcode.com/problems/convert-a-number-to-hexadecimal/
#406. (Medium) https://leetcode.com/problems/queue-reconstruction-by-height/

Mình sẽ share về bài Frog Jump:
#403. (Hard) https://leetcode.com/problems/frog-jump/

Phân tích bài toán:
  • 2 <= stones.length <= 2000: Độ phức tạp kỳ vọng có lẽ là O(nlogn) hoặc O(n^2)
  • Mục tiêu bài toán chỉ là kiểm tra tính khả thi, chứ không yêu cầu tìm nghiệm (cách nhảy) tối ưu. - Nên ta không cần quá để ý đến việc "nhảy như thế nào" mà chỉ cần kiểm tra "có thể nhảy được hay không".
  • Để có hướng giải bài toán, ta thử consider 2 ví dụ trong đề:
Mã:
Input: stones = [0,1,3,5,6,8,12,17]
Output: true
Explanation: The frog can jump to the last stone by jumping 1 unit to the 2nd stone, then 2 units to the 3rd stone, then 2 units to the 4th stone, then 3 units to the 6th stone, 4 units to the 7th stone, and 5 units to the 8th stone.

0 => 1 => 3 => 5 => 8 => 12 => 17
Tồn tại một cách để nhảy đến viên đá cuối cùng (17) => Trả về true

Mã:
Input: stones = [0,1,2,3,4,8,9,11]
Output: false
Explanation: There is no way to jump to the last stone as the gap between the 5th and 6th stone is too large.
Không tồn tại một cách nào để nhảy đến viên đá cuối cùng (11). Vì đến viên 3 hoặc 4 là hết, không thể nhảy thêm. => Trả về false

  • Cụ thể hơn, ta có thể kiểm tra tất cả trường hợp:
Mã:
- Đầu tiên nhảy từ 0 đến 1 (bắt buộc)
- Vì trước đó ta nhảy 1 bước (k = 1). Nên từ đây ta có thể nhảy 0, 1, hoặc 2 bước (Tương ứng với k - 1, k, và k + 1). Tuy nhiên đề bài chỉ cho phép nhảy về phía trước. Nên ta loại trường hợp 0.
- Vậy từ viên đá 1 ta có thể nhảy đến viên số 2(+1) hoặc 3(+2)
    - Nếu nhảy đến viên số 2(+1), từ đây ta có thể nhảy đến 3 (+1) hoặc 4 (+2)
        - Nếu nhảy 3(+1) thì sau đó ta có thể nhảy 4(+1) hoặc 5(+2). Nhưng viên số 5 k có trong mảng input, nên trường hợp này bị loại.
            - Nếu nhảy 4(+1) thì sau đó sẽ nhảy 5(+1) hoặc 6(+2). Nhưng cả 2 đều k có trong mảng input. => Vậy cách nhảy này fail
        - Nếu nhảy 4(+2) thì sau đó ta có thể nhảy 5(+1), 6(+2) và 7(+3). Cả 3 đều fail => Cách nhảy này fail
    => Cả 2 cách nhảy đến 3 (+1) hoặc 4 (+2) đều fail => Cách này fail
    - Xét tương tự, ta thấy việc nhảy đến viên số 3(+2) cũng sẽ fail vì không có cách nào đến được đích.

  • Thông qua ví dụ, ta rút ra được các kết luận như sau:
    • Viên đá số 1 bắt buộc phải có, nếu không thì fail chắc
    • Từ một viên đá, ta dựa và chiều dài lần nhảy trước mà tìm ra được các đích đến tiếp theo
    • Nếu các đích đến tiếp theo không có trong mảng input => Không thể nhảy được, loại trường hợp này
    • Nếu đích đến tiếp theo nằm trong mảng input, ta check tương tự với trường hợp này xem có khả thi hay không
    • Nếu một lúc nào đó ta đến được viên cuối cùng => Có cách nhảy khả thi, return True luôn, khỏi cần check gì thêm
    • Nếu tất cả các đích tiếp theo đều không khả thi => Cách này không khả thi, thử cách khác từ bước trước.

Từ các kết luận trên, ta thấy bài toán này có tính đệ quy, và có thể dùng đệ quy để giải đơn giản như sau:
Python:
class Solution:
    def canCross(self, stones: List[int]) -> bool:
        self.stones = stones
    
        if 1 not in stones:
            return False
    
        return self.canCrossFromStone(1, 1)
 
    def canCrossFromStone(self, stone, lastJump):
        if stone == self.stones[-1]:
            return True
    
        nextJumps = (lastJump + 1, lastJump, lastJump - 1)
        return any(jump > 0 and stone + jump in self.stones and self.canCrossFromStone(stone + jump, jump) for jump in nextJumps)

Optimization:
  • Solution trên work về mặt lý thuyết. Nhưng nếu đem đi submit sẽ bị TLE. Ta cần phải tối ưu thêm.
  • Trước tiên, ta thấy thuật toán cần liên tục check xem một số có nằm trong mảng input hay không => CTDL mảng k phù hợp với bài toán này. Ta cần chuyển mảng input thành dạng set để việc check là O(1)
  • Tiếp theo, ta thấy ở mỗi viên đá, ta có thể nhảy đến 3 viên tiếp theo và do đó sẽ cần check thêm 3 trường hợp riêng biệt => Độ phức tạp là O(3^n). Quá lớn.
  • Vậy làm sao để giảm độ phức tạp ? Nhớ lại bài trước, ta nhận thấy các bài toán con ở đây có tính chất gối nhau:
    • Giả sử trong quá trình tính toán, ta nhảy đến viên số 100, tính toán đệ quy 10k lần và kết luận nó không khả thi
    • Sau đó ta giải trường hợp khác và cũng nhảy đến viên số 100, đoạn code trên sẽ phải tính lại 10k phép tính chỉ để kết luận một điều mà ta đã biết trước
    • Vậy ta có thể lưu lại kết quả sau mỗi lần tính toán để giảm chi phí. Sau này có gặp lại viên 100 thì ta kết luận luôn là False thay vì phải giải lại 10k lần.
    • Tuy nhiên ta cần để ý giá trị lastJump cũng có ý nghĩa quan trọng. Rõ ràng 2 cách nhảy từ 99 đến 10090 đến 100 có thể dẫn hai đến kết quả hoàn toàn khác nhau.
  • Nếu bạn hiểu được đoạn trên, thì chúc mừng bạn đã hiểu được ý tưởng cốt lõi của Quy hoạch động: Thay vì giải lại các bài toán con đã biết trước, ta lưu lại và trả về kết quả trực tiếp cho các lần sau.
  • Với python, ta chỉ việc thêm @cache vào hàm đệ quy là đã hoàn thành memoization. Dù vậy, mình khuyến khích các bạn nên chủ động làm memoi để hiểu rõ hơn cách QHĐ hoạt động và tại sao nó tối ưu hơn rất nhiều. Cụ thể ở bài trên giảm từ O(3^n) xuống còn O(n^2).

Solution:
Python:
class Solution:
    def canCross(self, stones: List[int]) -> bool:
        self.stones = set(stones)
        self.target = stones[-1]
    
        if 1 not in stones:
            return False
    
        return self.canCrossFromStone(1, 1)
 
    @cache
    def canCrossFromStone(self, stone, lastJump):
        if stone == self.target:
            return True
    
        nextJumps = (lastJump + 1, lastJump, lastJump - 1)
        return any(jump > 0 and stone + jump in self.stones and self.canCrossFromStone(stone + jump, jump) for jump in nextJumps)

Follow up:
  • Bài trên các bạn cũng có thể giải bằng stack thay vì đệ quy
  • Hoặc thay vì dùng memoization, ta có thể giải bằng tabulation
 
Sửa lần cuối:
@_Gia_Cat_Luong_
Solution của mình cho bài: https://leetcode.com/problems/frog-jump/
Capture.JPG

Bài này có 2 điểm cần lưu ý:
  • unordered_map<int,set<int>> tried: cái này để cache lại vị trí và những units đã thử mà k nhảy đc. Có thể thay unordered_map bằng map, set bằng unordered_set,... Tuy nhiên trong bài này thì sự kết hợp trên có vẻ là ổn nhất.
  • Lúc search để tìm được vị trí có stone thay vì dùng linear search thì dùng binary search do stones được input là dãy tăng dần.
Bài này có nhiều chỗ khá dễ sai. Loay hoay cả tối mới ra được cái solution ổn áp, :D
 
Tiếp tục chuyên mục mỗi ngày một leetcode. Các bài mình làm ngày hôm nay:

#403. (Hard) https://leetcode.com/problems/frog-jump/
#404. (Easy) https://leetcode.com/problems/sum-of-left-leaves/
#405. (Easy) https://leetcode.com/problems/convert-a-number-to-hexadecimal/
#406. (Medium) https://leetcode.com/problems/queue-reconstruction-by-height/

Mình sẽ share về bài Frog Jump:
#403. (Hard) https://leetcode.com/problems/frog-jump/

Phân tích bài toán:
  • 2 <= stones.length <= 2000: Độ phức tạp kỳ vọng có lẽ là O(nlogn) hoặc O(n^2)
  • Mục tiêu bài toán chỉ là kiểm tra tính khả thi, chứ không yêu cầu tìm nghiệm (cách nhảy) tối ưu. - Nên ta không cần quá để ý đến việc "nhảy như thế nào" mà chỉ cần kiểm tra "có thể nhảy được hay không".
  • Để có hướng giải bài toán, ta thử consider 2 ví dụ trong đề:
Mã:
Input: stones = [0,1,3,5,6,8,12,17]
Output: true
Explanation: The frog can jump to the last stone by jumping 1 unit to the 2nd stone, then 2 units to the 3rd stone, then 2 units to the 4th stone, then 3 units to the 6th stone, 4 units to the 7th stone, and 5 units to the 8th stone.

0 => 1 => 3 => 5 => 8 => 12 => 17
Tồn tại một cách để nhảy đến viên đá cuối cùng (17) => Trả về true

Mã:
Input: stones = [0,1,2,3,4,8,9,11]
Output: false
Explanation: There is no way to jump to the last stone as the gap between the 5th and 6th stone is too large.
Không tồn tại một cách nào để nhảy đến viên đá cuối cùng (11). Vì đến viên 3 hoặc 4 là hết, không thể nhảy thêm. => Trả về false

  • Cụ thể hơn, ta có thể kiểm tra tất cả trường hợp:
Mã:
- Đầu tiên nhảy từ 0 đến 1 (bắt buộc)
- Vì trước đó ta nhảy 1 bước (k = 1). Nên từ đây ta có thể nhảy 0, 1, hoặc 2 bước (Tương ứng với k - 1, k, và k + 1). Tuy nhiên đề bài chỉ cho phép nhảy về phía trước. Nên ta loại trường hợp 0.
- Vậy từ viên đá 1 ta có thể nhảy đến viên số 2(+1) hoặc 3(+2)
    - Nếu nhảy đến viên số 2(+1), từ đây ta có thể nhảy đến 3 (+1) hoặc 4 (+2)
        - Nếu nhảy 3(+1) thì sau đó ta có thể nhảy 4(+1) hoặc 5(+2). Nhưng viên số 5 k có trong mảng input, nên trường hợp này bị loại.
            - Nếu nhảy 4(+1) thì sau đó sẽ nhảy 5(+1) hoặc 6(+2). Nhưng cả 2 đều k có trong mảng input. => Vậy cách nhảy này fail
        - Nếu nhảy 4(+2) thì sau đó ta có thể nhảy 5(+1), 6(+2) và 7(+3). Cả 3 đều fail => Cách nhảy này fail
    => Cả 2 cách nhảy đến 3 (+1) hoặc 4 (+2) đều fail => Cách này fail
    - Xét tương tự, ta thấy việc nhảy đến viên số 3(+2) cũng sẽ fail vì không có cách nào đến được đích.

  • Thông qua ví dụ, ta rút ra được các kết luận như sau:
    • Viên đá số 1 bắt buộc phải có, nếu không thì fail chắc
    • Từ một viên đá, ta dựa và chiều dài lần nhảy trước mà tìm ra được các đích đến tiếp theo
    • Nếu các đích đến tiếp theo không có trong mảng input => Không thể nhảy được, loại trường hợp này
    • Nếu đích đến tiếp theo nằm trong mảng input, ta check tương tự với trường hợp này xem có khả thi hay không
    • Nếu một lúc nào đó ta đến được viên cuối cùng => Có cách nhảy khả thi, return True luôn, khỏi cần check gì thêm
    • Nếu tất cả các đích tiếp theo đều không khả thi => Cách này không khả thi, thử cách khác từ bước trước.

Từ các kết luận trên, ta thấy bài toán này có tính đệ quy, và có thể dùng đệ quy để giải đơn giản như sau:
Python:
class Solution:
    def canCross(self, stones: List[int]) -> bool:
        self.stones = stones
   
        if 1 not in stones:
            return False
   
        return self.canCrossFromStone(1, 1)
 
    def canCrossFromStone(self, stone, lastJump):
        if stone == self.stones[-1]:
            return True
   
        nextJumps = (lastJump + 1, lastJump, lastJump - 1)
        return any(jump > 0 and stone + jump in self.stones and self.canCrossFromStone(stone + jump, jump) for jump in nextJumps)

Optimization:
  • Solution trên work về mặt lý thuyết. Nhưng nếu đem đi submit sẽ bị TLE. Ta cần phải tối ưu thêm.
  • Trước tiên, ta thấy thuật toán cần liên tục check xem một số có nằm trong mảng input hay không => CTDL mảng k phù hợp với bài toán này. Ta cần chuyển mảng input thành dạng set để việc check là O(1)
  • Tiếp theo, ta thấy ở mỗi viên đá, ta có thể nhảy đến 3 viên tiếp theo và do đó sẽ cần check thêm 3 trường hợp riêng biệt => Độ phức tạp là O(3^n). Quá lớn.
  • Vậy làm sao để giảm độ phức tạp ? Nhớ lại bài trước, ta nhận thấy các bài toán con ở đây có tính chất gối nhau:
    • Giả sử trong quá trình tính toán, ta nhảy đến viên số 100, tính toán đệ quy 10k lần và kết luận nó không khả thi
    • Sau đó ta giải trường hợp khác và cũng nhảy đến viên số 100, đoạn code trên sẽ phải tính lại 10k phép tính chỉ để kết luận một điều mà ta đã biết trước
    • Vậy ta có thể lưu lại kết quả sau mỗi lần tính toán để giảm chi phí. Sau này có gặp lại viên 100 thì ta kết luận luôn là False thay vì phải giải lại 10k lần.
    • Tuy nhiên ta cần để ý giá trị lastJump cũng có ý nghĩa quan trọng. Rõ ràng 2 cách nhảy từ 99 đến 10090 đến 100 có thể dẫn hai đến kết quả hoàn toàn khác nhau.
  • Nếu bạn hiểu được đoạn trên, thì chúc mừng bạn đã hiểu được ý tưởng cốt lõi của Quy hoạch động: Thay vì giải lại các bài toán con đã biết trước, ta lưu lại và trả về kết quả trực tiếp cho các lần sau.
  • Với python, ta chỉ việc thêm @cache vào hàm đệ quy là đã hoàn thành memoization. Dù vậy, mình khuyến khích các bạn nên chủ động làm memoi để hiểu rõ hơn cách QHĐ hoạt động và tại sao nó tối ưu hơn rất nhiều. Cụ thể ở bài trên giảm từ O(3^n) xuống còn O(n^2).

Solution:
Python:
class Solution:
    def canCross(self, stones: List[int]) -> bool:
        self.stones = set(stones)
        self.target = stones[-1]
   
        if 1 not in stones:
            return False
   
        return self.canCrossFromStone(1, 1)
 
    @cache
    def canCrossFromStone(self, stone, lastJump):
        if stone == self.target:
            return True
   
        nextJumps = (lastJump + 1, lastJump, lastJump - 1)
        return any(jump > 0 and stone + jump in self.stones and self.canCrossFromStone(stone + jump, jump) for jump in nextJumps)

Follow up:
  • Bài trên các bạn cũng có thể giải bằng stack thay vì đệ quy
  • Hoặc thay vì dùng memoization, ta có thể giải bằng tabulation
Bạn ơi, cho mình hỏi một chút, khi mình tìm ra công thức cho 1 bài toán.
Ví dụ công thức tìm được là: if(a < b) thì a = b + 1.
Thì việc cần làm là duyệt qua toàn bộ trường hợp của a và b. tức là 2 vòng for:
for(tất cả các trường hợp của a)
for(tất cả các trường hợp của b){
công thức
}
Với việc viết code như vậy thì theo tự nhiên, sẽ sinh ra kết quả đúng mà không sót trường hợp nào đúng không ?
Từ đó mình có thể rút ra được: khi tìm ra 1 công thức cho 1 bài toán với n biến: f(x,y,z......)
thì mình duyệt qua tất cả các giá trị của x,y,z....
for(x)
for(y)
for(z)
(công thức);
Viết như vậy sẽ không sót trường hợp đúng không bạn ?
 
Tiếp tục chuyên mục mỗi ngày một leetcode. Các bài mình làm ngày hôm nay:

#403. (Hard) https://leetcode.com/problems/frog-jump/
#404. (Easy) https://leetcode.com/problems/sum-of-left-leaves/
#405. (Easy) https://leetcode.com/problems/convert-a-number-to-hexadecimal/
#406. (Medium) https://leetcode.com/problems/queue-reconstruction-by-height/

Mình sẽ share về bài Frog Jump:
#403. (Hard) https://leetcode.com/problems/frog-jump/

Phân tích bài toán:
  • 2 <= stones.length <= 2000: Độ phức tạp kỳ vọng có lẽ là O(nlogn) hoặc O(n^2)
  • Mục tiêu bài toán chỉ là kiểm tra tính khả thi, chứ không yêu cầu tìm nghiệm (cách nhảy) tối ưu. - Nên ta không cần quá để ý đến việc "nhảy như thế nào" mà chỉ cần kiểm tra "có thể nhảy được hay không".
  • Để có hướng giải bài toán, ta thử consider 2 ví dụ trong đề:
Mã:
Input: stones = [0,1,3,5,6,8,12,17]
Output: true
Explanation: The frog can jump to the last stone by jumping 1 unit to the 2nd stone, then 2 units to the 3rd stone, then 2 units to the 4th stone, then 3 units to the 6th stone, 4 units to the 7th stone, and 5 units to the 8th stone.

0 => 1 => 3 => 5 => 8 => 12 => 17
Tồn tại một cách để nhảy đến viên đá cuối cùng (17) => Trả về true

Mã:
Input: stones = [0,1,2,3,4,8,9,11]
Output: false
Explanation: There is no way to jump to the last stone as the gap between the 5th and 6th stone is too large.
Không tồn tại một cách nào để nhảy đến viên đá cuối cùng (11). Vì đến viên 3 hoặc 4 là hết, không thể nhảy thêm. => Trả về false

  • Cụ thể hơn, ta có thể kiểm tra tất cả trường hợp:
Mã:
- Đầu tiên nhảy từ 0 đến 1 (bắt buộc)
- Vì trước đó ta nhảy 1 bước (k = 1). Nên từ đây ta có thể nhảy 0, 1, hoặc 2 bước (Tương ứng với k - 1, k, và k + 1). Tuy nhiên đề bài chỉ cho phép nhảy về phía trước. Nên ta loại trường hợp 0.
- Vậy từ viên đá 1 ta có thể nhảy đến viên số 2(+1) hoặc 3(+2)
    - Nếu nhảy đến viên số 2(+1), từ đây ta có thể nhảy đến 3 (+1) hoặc 4 (+2)
        - Nếu nhảy 3(+1) thì sau đó ta có thể nhảy 4(+1) hoặc 5(+2). Nhưng viên số 5 k có trong mảng input, nên trường hợp này bị loại.
            - Nếu nhảy 4(+1) thì sau đó sẽ nhảy 5(+1) hoặc 6(+2). Nhưng cả 2 đều k có trong mảng input. => Vậy cách nhảy này fail
        - Nếu nhảy 4(+2) thì sau đó ta có thể nhảy 5(+1), 6(+2) và 7(+3). Cả 3 đều fail => Cách nhảy này fail
    => Cả 2 cách nhảy đến 3 (+1) hoặc 4 (+2) đều fail => Cách này fail
    - Xét tương tự, ta thấy việc nhảy đến viên số 3(+2) cũng sẽ fail vì không có cách nào đến được đích.

  • Thông qua ví dụ, ta rút ra được các kết luận như sau:
    • Viên đá số 1 bắt buộc phải có, nếu không thì fail chắc
    • Từ một viên đá, ta dựa và chiều dài lần nhảy trước mà tìm ra được các đích đến tiếp theo
    • Nếu các đích đến tiếp theo không có trong mảng input => Không thể nhảy được, loại trường hợp này
    • Nếu đích đến tiếp theo nằm trong mảng input, ta check tương tự với trường hợp này xem có khả thi hay không
    • Nếu một lúc nào đó ta đến được viên cuối cùng => Có cách nhảy khả thi, return True luôn, khỏi cần check gì thêm
    • Nếu tất cả các đích tiếp theo đều không khả thi => Cách này không khả thi, thử cách khác từ bước trước.

Từ các kết luận trên, ta thấy bài toán này có tính đệ quy, và có thể dùng đệ quy để giải đơn giản như sau:
Python:
class Solution:
    def canCross(self, stones: List[int]) -> bool:
        self.stones = stones
    
        if 1 not in stones:
            return False
    
        return self.canCrossFromStone(1, 1)
 
    def canCrossFromStone(self, stone, lastJump):
        if stone == self.stones[-1]:
            return True
    
        nextJumps = (lastJump + 1, lastJump, lastJump - 1)
        return any(jump > 0 and stone + jump in self.stones and self.canCrossFromStone(stone + jump, jump) for jump in nextJumps)

Optimization:
  • Solution trên work về mặt lý thuyết. Nhưng nếu đem đi submit sẽ bị TLE. Ta cần phải tối ưu thêm.
  • Trước tiên, ta thấy thuật toán cần liên tục check xem một số có nằm trong mảng input hay không => CTDL mảng k phù hợp với bài toán này. Ta cần chuyển mảng input thành dạng set để việc check là O(1)
  • Tiếp theo, ta thấy ở mỗi viên đá, ta có thể nhảy đến 3 viên tiếp theo và do đó sẽ cần check thêm 3 trường hợp riêng biệt => Độ phức tạp là O(3^n). Quá lớn.
  • Vậy làm sao để giảm độ phức tạp ? Nhớ lại bài trước, ta nhận thấy các bài toán con ở đây có tính chất gối nhau:
    • Giả sử trong quá trình tính toán, ta nhảy đến viên số 100, tính toán đệ quy 10k lần và kết luận nó không khả thi
    • Sau đó ta giải trường hợp khác và cũng nhảy đến viên số 100, đoạn code trên sẽ phải tính lại 10k phép tính chỉ để kết luận một điều mà ta đã biết trước
    • Vậy ta có thể lưu lại kết quả sau mỗi lần tính toán để giảm chi phí. Sau này có gặp lại viên 100 thì ta kết luận luôn là False thay vì phải giải lại 10k lần.
    • Tuy nhiên ta cần để ý giá trị lastJump cũng có ý nghĩa quan trọng. Rõ ràng 2 cách nhảy từ 99 đến 10090 đến 100 có thể dẫn hai đến kết quả hoàn toàn khác nhau.
  • Nếu bạn hiểu được đoạn trên, thì chúc mừng bạn đã hiểu được ý tưởng cốt lõi của Quy hoạch động: Thay vì giải lại các bài toán con đã biết trước, ta lưu lại và trả về kết quả trực tiếp cho các lần sau.
  • Với python, ta chỉ việc thêm @cache vào hàm đệ quy là đã hoàn thành memoization. Dù vậy, mình khuyến khích các bạn nên chủ động làm memoi để hiểu rõ hơn cách QHĐ hoạt động và tại sao nó tối ưu hơn rất nhiều. Cụ thể ở bài trên giảm từ O(3^n) xuống còn O(n^2).

Solution:
Python:
class Solution:
    def canCross(self, stones: List[int]) -> bool:
        self.stones = set(stones)
        self.target = stones[-1]
    
        if 1 not in stones:
            return False
    
        return self.canCrossFromStone(1, 1)
 
    @cache
    def canCrossFromStone(self, stone, lastJump):
        if stone == self.target:
            return True
    
        nextJumps = (lastJump + 1, lastJump, lastJump - 1)
        return any(jump > 0 and stone + jump in self.stones and self.canCrossFromStone(stone + jump, jump) for jump in nextJumps)

Follow up:
  • Bài trên các bạn cũng có thể giải bằng stack thay vì đệ quy
  • Hoặc thay vì dùng memoization, ta có thể giải bằng tabulation
Bài nào cũng giải đc = tabulation à fen

via theNEXTvoz for iPhone
 
1628138895702.png

Bài này lúc đầu em giải theo kiểu for đồ mà thấy nó chạy lâu quá, xong rồi em chơi trò khôn lỏi này.
Giải bài design Hashmap bằng chính cái map. Thì có được coi là hợp lệ ko mấy bác
 
Sửa lần cuối:
Bạn ơi, cho mình hỏi một chút, khi mình tìm ra công thức cho 1 bài toán.
Ví dụ công thức tìm được là: if(a < b) thì a = b + 1.
Thì việc cần làm là duyệt qua toàn bộ trường hợp của a và b. tức là 2 vòng for:
for(tất cả các trường hợp của a)
for(tất cả các trường hợp của b){
công thức
}
Với việc viết code như vậy thì theo tự nhiên, sẽ sinh ra kết quả đúng mà không sót trường hợp nào đúng không ?
Từ đó mình có thể rút ra được: khi tìm ra 1 công thức cho 1 bài toán với n biến: f(x,y,z......)
thì mình duyệt qua tất cả các giá trị của x,y,z....
for(x)
for(y)
for(z)
(công thức);
Viết như vậy sẽ không sót trường hợp đúng không bạn ?
Đúng rồi bạn. Kiểu này là brute-force hay còn gọi là vét cạn đó. :D.
 
@_Gia_Cat_Luong_
Solution của mình cho bài: https://leetcode.com/problems/frog-jump/
Xem tệp đính kèm 692925
Bài này có 2 điểm cần lưu ý:
  • unordered_map<int,set<int>> tried: cái này để cache lại vị trí và những units đã thử mà k nhảy đc. Có thể thay unordered_map bằng map, set bằng unordered_set,... Tuy nhiên trong bài này thì sự kết hợp trên có vẻ là ổn nhất.
  • Lúc search để tìm được vị trí có stone thay vì dùng linear search thì dùng binary search do stones được input là dãy tăng dần.
Bài này có nhiều chỗ khá dễ sai. Loay hoay cả tối mới ra được cái solution ổn áp, :D
Hay quá mai fen, nhưng sao phải phải dùng binary search O(logn) nhỉ. Nếu dùng unordered_map để check O(1) thì sẽ tối ưu hơn ?

Bài nào cũng giải đc = tabulation à fen

via theNEXTvoz for iPhone
Nói thật là mình không biết chắc. Nhưng trước giờ mình thấy bài nào cũng được. Đôi lúc làm cách này dễ tối ưu hơn cách kia. Nhưng hầu hết là cả 2 đều được.

Xem tệp đính kèm 693693
Bài này lúc đầu em giải theo kiểu for đồ mà thấy nó chạy lâu quá, xong rồi em chơi trò khôn lỏi này.
Giải bài design Hashmap bằng chính cái map. Thì có được coi là hợp lệ ko mấy bác
Đương nhiên là k đc rồi mai fen :D. Thử giải lại bằng array đi, khá thú vị. Làm được bài này bạn sẽ hiểu rõ hơn tại sao hashmap, hashset lại là O(1). Nó tối ưu và không tối ưu trong trường hợp nào. Sau này trong code thực tế mình cũng ý thức được khi nào nên dùng CTDL nào chứ k dùng một cách máy móc nữa. Practically, mình đánh giá bài này đáng làm hơn mấy bài QHĐ này nọ.

Bạn ơi, cho mình hỏi một chút, khi mình tìm ra công thức cho 1 bài toán.
Ví dụ công thức tìm được là: if(a < b) thì a = b + 1.
Thì việc cần làm là duyệt qua toàn bộ trường hợp của a và b. tức là 2 vòng for:
for(tất cả các trường hợp của a)
for(tất cả các trường hợp của b){
công thức
}
Với việc viết code như vậy thì theo tự nhiên, sẽ sinh ra kết quả đúng mà không sót trường hợp nào đúng không ?
Từ đó mình có thể rút ra được: khi tìm ra 1 công thức cho 1 bài toán với n biến: f(x,y,z......)
thì mình duyệt qua tất cả các giá trị của x,y,z....
for(x)
for(y)
for(z)
(công thức);
Viết như vậy sẽ không sót trường hợp đúng không bạn ?
Hoàn toàn đúng luôn bạn. Cách này gọi là vét cạn (brute-force). Và khá hữu dụng trong nhiều trường hợp thực tế. Tuy nhiên thường trên LC, không gian vét cạn của các bài toán rất lớn, nên kiểm tra hết các trường hợp là không khả thi. Do đó ta mới phải học và vận dụng đủ thứ CTDL & thuật toán nhằm hạn chế số lượng bài toán cần phải kiểm tra xuống.
 
Hay quá mai fen, nhưng sao phải phải dùng binary search O(logn) nhỉ. Nếu dùng unordered_map để check O(1) thì sẽ tối ưu hơn ?
Ý bạn là unordered_set đúng k? Nếu dùng unordered_set thì đúng là khi search sẽ là O(1). Nhưng mà sẽ mất thêm thời gian để build từ cái vector stones nữa. Chưa kể nếu không biết cách thì khi build hashmap table nó có thể phải re-hash nữa. Sẽ tốn cả về tgian và mem. Do đó tận dụng luôn cái stones đầu vào đã được sort và dùng binary_search là cách tốt nhất.
Có 1 trick nhỏ trong bài này để chạy nhanh hơn là không cần search toàn bộ stones, mà chỉ cần search từ vị trí tiếp theo của stone hiện tại đến stone cuối. Làm vậy không giảm độ phức tạp nhưng sẽ chạy nhanh hơn được một chút. :D
 
Sửa lần cuối:
Hay quá mai fen, nhưng sao phải phải dùng binary search O(logn) nhỉ. Nếu dùng unordered_map để check O(1) thì sẽ tối ưu hơn ?


Nói thật là mình không biết chắc. Nhưng trước giờ mình thấy bài nào cũng được. Đôi lúc làm cách này dễ tối ưu hơn cách kia. Nhưng hầu hết là cả 2 đều được.


Đương nhiên là k đc rồi mai fen :D. Thử giải lại bằng array đi, khá thú vị. Làm được bài này bạn sẽ hiểu rõ hơn tại sao hashmap, hashset lại là O(1). Nó tối ưu và không tối ưu trong trường hợp nào. Sau này trong code thực tế mình cũng ý thức được khi nào nên dùng CTDL nào chứ k dùng một cách máy móc nữa. Practically, mình đánh giá bài này đáng làm hơn mấy bài QHĐ này nọ.


Hoàn toàn đúng luôn bạn. Cách này gọi là vét cạn (brute-force). Và khá hữu dụng trong nhiều trường hợp thực tế. Tuy nhiên thường trên LC, không gian vét cạn của các bài toán rất lớn, nên kiểm tra hết các trường hợp là không khả thi. Do đó ta mới phải học và vận dụng đủ thứ CTDL & thuật toán nhằm hạn chế số lượng bài toán cần phải kiểm tra xuống.
cảm ơn bạn nhiều
 
Sau khi làm bài https://leetcode.com/problems/queue-reconstruction-by-height/
Mình thấy khá là hay. Nên sẽ chia sẻ lại cách mình giải bài này cho mọi người.

Input của bài này có dạng như sau:
[[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]

Đây là một list chứa các list con, mỗi list con sẽ chứa 2 phần tử: mình tạm gọi là a,b.
Yêu cầu của bài này là sắp xếp lại list trên sao cho tại mỗi list con bất kỳ, thì số list con đứng trước nó có giá trị a lớn hơn hoặc bằng giá trị a của nó đúng bằng b. Nghe chỗ này hơi rối đúng k? :D .
Ví dụ với input là list bên trên, thì output như dưới đây được coi lại hợp lệ:
[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
Trước [5,0] không có list nào có phần tử đầu lớn hơn 5.
Trước [7,0] không có list nào có phần tử đầu lớn hơn 7.
Trước [5,2] có đúng 2 list có phần đầu lớn hơn 5.
Trước [6,1] có đúng 1 list có phần đầu lớn hơn 6.
...

Đến đây hi vọng mọi người đã hiểu được yêu cầu của bài toán.

Bài này mình sẽ giải bằng greedy. Nếu ai chưa biết greedy là gì thì có thể tự tìm hiểu hoặc tìm đọc những post của @_Gia_Cat_Luong_ có chia sẻ rồi.

Cách giải của mình trong bài này dựa trên 2 nhận xét quan trọng như sau.
  • Giả sử ta đang có một list hợp lệ: l = [[a1,b1],[a2,b2],...,[an,bn]] và một list con [ax,bx] mà ax nhỏ hơn tất cả các phần tử a1,...,an.
    Thì khi ta chèn [ax,bx] vào một vị trí bất kỳ trong l thì luôn cho ra một list hợp lệ.
    VD: [[7,0]] ,[6,1],[9,0],[8,1]] đang hợp lệ, chèn [5,0] vào bất kỳ vị trí nào thì vẫn cho ra một list hợp lệ.
  • Đầu ra của bài toán sẽ luôn có dạng như sau: [[a,b1],...,[a,b2],...,[a,b3]]. mà b1,b2,b3 là dãy tăng dần. Nếu không thì sẽ vi phạm yêu cầu bài toán ngay.

Sau khi nhìn ra được 2 điểm này thì mình bắt đầu xây dựng ra solution. Đễ dễ hơn thì mình sẽ đi thẳng vào ví dụ cụ thể.
  1. Input:
    [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
  2. Sort ra 1 list như sau:
    [[7,0],[7,1],[6,1],[5,0],[5,2],[4,4]]
    (a giảm dần, khi a bằng nhau thì b lại tăng dần)
  3. Nếu chỉ dừng lại ở đây thì rõ ràng chưa thỏa mãn điều kiện của bài toán.
    Do đó mình sẽ phải duyệt lại cái list trên.
  4. Do list đã được sort nên một list con nằm ở vị trí idx thì sẽ luôn có có idx phần tử có a lớn hơn nó.
    Do đó ta dựa vào idx và giá trị của b để xác định được một list con có cần vi phạm hay không, và nếu nó vi phạm thì sẽ biết được vị trí mới của nó.
    Vd: [[7,0],[7,1],[6,1],[5,0],[5,2],[4,4]]
    idx = 0, duyệt [7,0]: idx <= 0 ? -> true -> output: [[7,0]]
    idx = 1, duyệt [7,1]: idx <= 1? -> true -> output: [[7,0],[7,1]]
    idx = 2, duyệt [6,1]: idx <= 1? -> false -> chèn vào vị trí 1-> output: [[7,0],[6,1],[7,1]]
    idx = 3, duyệt [5,0]: idx <= 0? -> false -> chèn vào vị trí 0-> output: [[5,0],[7,0],[6,1],[7,1]]
    idx = 4, duyệt [5,2]: idx <= 2? -> false -> chèn vào vị trí 2-> output: [[5,0],[7,0],[5,2],[6,1],[7,1]]
    idx = 5, duyệt [4,4]: idx <= 4? -> false -> chèn vào vị trí 4-> output: [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
  5. Đến đây là đã hoàn tất.
Còn đây là code C++ của mình.

C++:
class Solution {
public:
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort(people.begin(), people.end(), [] (auto a, auto b) {
            return a[0] == b[0] ? a[1] < b[1] : a[0] > b[0];
        });
        
        list<vector<int>> list_people;
        for(int i = 0; i < people.size(); i++){
            if (i > people[i][1]){
                auto it = list_people.begin();
                std::advance(it, people[i][1]);
                list_people.insert(it, move(people[i]));
            } else{
                list_people.push_back(move(people[i]));
            }
        }
        people.clear();
        people.insert(people.begin(), list_people.begin(), list_people.end());
        return people;
    }
};

@_Gia_Cat_Luong_ : cách của thím có giống như t k?
 

Thống kê chủ đề

Ngày tạo
unknowpc90,
Người trả lời cuối
Spaghetti Code,
Trả lời
1.460
Lượt xem
154.138
Quay lại
Lên đầu trang