Tối ưu các bài toán duyệt

Phần 1: Tối ưu bài toán duyệt đệ quy bằng nhánh cận

1.1. Bài toán mở đầu: Điền dấu cộng/trừ

Xét bài toán:

\[ 0 \pm 1 \pm 2 \pm 3 \pm \cdots \pm n = m \]

Cần tìm \(k\) cách thay các dấu ± bằng + hoặc - để đẳng thức đúng.

Phân tích:

  • Số cách đặt dấu: \(2^n\) (quá lớn khi \(n \leq 2\times10^5\))
  • Cần tối ưu bằng nhánh cận (branch and bound)

1.2. Ý tưởng nhánh cận

Nguyên lý: Cắt bỏ nhánh không thể đạt kết quả sớm nhất có thể.

Cận trên/ dưới:

  • Tổng lớn nhất có thể đạt: \(S_{max} = 1 + 2 + \cdots + n = \frac{n(n+1)}{2}\)
  • Tổng nhỏ nhất: \(S_{min} = -S_{max}\)
  • Nếu \(|m| > S_{max}\) → không có nghiệm

Cận động:
Tại bước đang xét số \(i\), tổng hiện tại là \(sum\), tổng các số còn lại là:

\[ R = 1 + 2 + \cdots + i = \frac{i(i+1)}{2} \]

Nếu \(|m - sum| > R\) → không thể đạt \(m\) với các số còn lại.

1.3. Thuật toán với nhánh cận

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

vector<string> results;
long long cnt = 0;

void backtrack(long long i, long long sum, long long m, string& signs, long long k) {
    // Nếu đã đủ k cách -> dừng
    if (cnt >= k) return;

    // Tính tổng các số còn lại: 1 + 2 + ... + i
    long long remaining = i * (i + 1) / 2;

    // Cắt nhánh nếu không thể đạt m
    if (abs(m - sum) > remaining) return;

    // Nếu đã xét hết
    if (i == 0) {
        if (sum == m) {
            results.push_back(signs);
            cnt++;
        }
        return;
    }

    // Thử dấu '+'
    signs[i-1] = '+';
    backtrack(i-1, sum + i, m, signs, k);

    // Thử dấu '-'
    signs[i-1] = '-';
    backtrack(i-1, sum - i, m, signs, k);
}

1.4. Tối ưu thêm

  1. Duyệt từ số lớn → nhỏ: Dễ đạt cận hơn
  2. Điều kiện chẵn lẻ:
\[ \text{Tổng} \equiv m \pmod{2} \]

Vì thay đổi dấu làm thay đổi tổng một lượng chẵn (\(2 \times\) số đó)

  1. Cận chặt hơn:
\[ \text{min\_possible} \leq m \leq \text{max\_possible} \]

1.5. Độ phức tạp

  • Không cắt nhánh: \(O(2^n)\)
  • Có cắt nhánh: Thực tế giảm đáng kể
  • Với ràng buộc \(n \times k \leq 2\times10^5\) → chỉ cần tìm tối đa \(k\) cách

1.6. Bài tập áp dụng

Bài 1: Bài toán 8 hậu - dùng nhánh cận để giảm số vị trí thử.

Bài 2: Bài toán mã đi tuần - ưu tiên ô có ít nước đi tiếp theo nhất (quy tắc Warnsdorff).

Bài 3: Bài toán cái túi 0-1 - cắt nhánh khi giá trị còn lại tối đa + giá trị hiện tại ≤ giá trị tốt nhất đã tìm.

1.7. Mẹo thiết kế nhánh cận

Bước Mô tả
1. Xác định thứ tự duyệt tối ưu (lớn → nhỏ, nhiều ràng buộc trước)
2. Tìm cận trên/dưới nhanh (tính toán đơn giản)
3. Thiết kế hàm đánh giá cận (evaluation function)
4. Sắp xếp các lựa chọn theo heuristic tốt nhất trước
5. Kết hợp với ghi nhớ (memoization) nếu có bài toán con trùng

1.8. Sơ đồ hoạt động

┌─────────────────────────────────┐
│   Bắt đầu duyệt                 │
│   sum = 0, i = n                │
└───────────────┬─────────────────┘
                │
        ┌───────▼────────┐
        │ Tính cận:      │
        │ remaining =    │
        │ i*(i+1)/2      │
        └───────┬────────┘
                │
        ┌───────▼────────┐
        │ |m-sum| >      │
        │ remaining ?    │
        └───────┬────────┘
    Có ┌────────┴─┐ Không
       ▼          ▼
       │       i == 0 ?
       │          │
       │      Có ┌┴────┐ Không
       │         │     └──────────┐
       │     sum==m?              │
       │        │                 │
       │ Không ┌┴────┐ Có         │
       │       |     ▼            ▼
       │┌──────┘ Lưu kết quả  Thử dấu +/-
       ▼▼                         │
    Cắt nhánh                     │
       │                          ▼
       └────────────────────► Tiếp tục

Tổng kết chuyên đề 1

Nhánh cận là kỹ thuật tối ưu quan trọng cho bài toán duyệt:

Ưu điểm:

  • Giảm đáng kể thời gian tìm kiếm
  • Dễ cài đặt trên cấu trúc đệ quy
  • Có thể kết hợp với các kỹ thuật khác

Hạn chế:

  • Không đảm bảo cải thiện trong mọi trường hợp
  • Phụ thuộc vào chất lượng hàm cận

Lưu ý:

  1. Luôn tìm cận đơn giản nhưng hiệu quả trước
  2. Thứ tự duyệt ảnh hưởng lớn đến hiệu quả
  3. Kết hợp với các kỹ thuật khác (memoization, heuristic)

Câu hỏi thảo luận: Tại sao trong bài toán điền dấu, duyệt từ số lớn đến nhỏ lại hiệu quả hơn duyệt từ số nhỏ đến lớn?

Phần 2: Tối ưu bài toán duyệt dãy số đã sắp xếp bằng phương pháp hai con trỏ hoặc tìm kiếm nhị phân

2.1. Giới thiệu

Khi làm việc với dãy số đã sắp xếp, chúng ta có thể tận dụng tính chất thứ tự để duyệt một cách hiệu quả hơn. Hai kỹ thuật phổ biến là hai con trỏ (two pointers) và tìm kiếm nhị phân (binary search).

2.2. Phương pháp hai con trỏ

2.2.1. Ý tưởng cơ bản

Phương pháp hai con trỏ sử dụng hai chỉ số (con trỏ) để duyệt dãy số, thường bắt đầu từ hai đầu (đầu và cuối) hoặc cùng từ đầu. Phương pháp này giúp giảm độ phức tạp từ O(n²) xuống O(n) cho nhiều bài toán.

2.2.2. Ví dụ 1: Tìm hai số trong mảng đã sắp xếp có tổng bằng target

Bài toán: Cho mảng đã sắp xếp tăng dần, tìm hai số có tổng bằng một giá trị target cho trước.

Cách giải:

  • Con trỏ left ở đầu mảng (chỉ số nhỏ nhất)
  • Con trỏ right ở cuối mảng (chỉ số lớn nhất)
  • So sánh tổng arr[left] + arr[right] với target:
  • Nếu tổng bằng target → tìm thấy
  • Nếu tổng nhỏ hơn target → tăng left (vì cần tổng lớn hơn)
  • Nếu tổng lớn hơn target → giảm right (vì cần tổng nhỏ hơn)
C++
#include <iostream>
#include <vector>
using namespace std;

pair<int, int> twoSumSorted(vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;

    while (left < right) {
        int sum = arr[left] + arr[right];
        if (sum == target) {
            return {left, right}; // Trả về chỉ số
        } else if (sum < target) {
            left++;
        } else {
            right--;
        }
    }

    return {-1, -1}; // Không tìm thấy
}

int main() {
    vector<int> arr = {1, 3, 5, 7, 9, 11};
    int target = 12;

    pair<int, int> result = twoSumSorted(arr, target);

    if (result.first != -1) {
        cout << "Found: " << arr[result.first] << " + " << arr[result.second] 
             << " = " << target << endl;
    } else {
        cout << "Not found" << endl;
    }

    return 0;
}

2.2.3. Ví dụ 2: Loại bỏ các phần tử trùng trong mảng đã sắp xếp

Bài toán: Cho mảng đã sắp xếp, loại bỏ các phần tử trùng sao cho mỗi phần tử chỉ xuất hiện một lần.

Cách giải:

  • Con trỏ i để duyệt mảng
  • Con trỏ j để lưu vị trí của phần tử không trùng cuối cùng
  • Khi gặp phần tử khác phần tử tại j, tăng j và gán phần tử đó vào vị trí j
C++
#include <iostream>
#include <vector>
using namespace std;

int removeDuplicates(vector<int>& arr) {
    if (arr.empty()) return 0;

    int j = 0; // Con trỏ lưu vị trí cuối cùng của phần tử không trùng
    for (int i = 1; i < arr.size(); i++) {
        if (arr[i] != arr[j]) {
            j++;
            arr[j] = arr[i];
        }
    }

    return j + 1; // Số lượng phần tử không trùng
}

int main() {
    vector<int> arr = {1, 1, 2, 2, 2, 3, 4, 4, 5};

    int newLength = removeDuplicates(arr);

    cout << "Mang sau khi loai trung: ";
    for (int i = 0; i < newLength; i++) {
        cout << arr[i] << " ";
    }
    cout << endl;

    return 0;
}

2.2.4. Ví dụ 3: Tìm dãy con có tổng nhỏ hơn hoặc bằng target

Bài toán: Cho mảng đã sắp xếp (phần tử không âm) và một số target, tìm độ dài dãy con dài nhất có tổng ≤ target.

Cách giải:

  • Sử dụng hai con trỏ left và right để tạo cửa sổ (window)
  • Mở rộng cửa sổ bằng cách tăng right và cập nhật tổng
  • Nếu tổng vượt quá target, thu hẹp cửa sổ bằng cách tăng left
C++
#include <iostream>
#include <vector>
using namespace std;

int longestSubarraySumLE(vector<int>& arr, int target) {
    int left = 0, sum = 0, maxLength = 0;

    for (int right = 0; right < arr.size(); right++) {
        sum += arr[right];

        // Nếu tổng vượt quá target, dịch left sang phải
        while (sum > target && left <= right) {
            sum -= arr[left];
            left++;
        }

        // Cập nhật độ dài cửa sổ
        maxLength = max(maxLength, right - left + 1);
    }

    return maxLength;
}

int main() {
    vector<int> arr = {1, 2, 3, 4, 5};
    int target = 10;

    cout << "Do dai day con dai nhat co tong <= " << target << ": "
         << longestSubarraySumLE(arr, target) << endl;

    return 0;
}

2.3. Tìm kiếm nhị phân

2.3.1. Ý tưởng cơ bản

Tìm kiếm nhị phân (binary search) là thuật toán tìm kiếm trên dãy đã sắp xếp bằng cách chia đôi khoảng tìm kiếm. Độ phức tạp: O(log n).

2.3.2. Ví dụ 1: Tìm kiếm phần tử trong mảng đã sắp xếp

C++
#include <iostream>
#include <vector>
using namespace std;

int binarySearch(vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2;

        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }

    return -1; // Không tìm thấy
}

2.3.3. Ví dụ 2: Tìm phần tử đầu tiên xuất hiện (lower_bound)

Tìm vị trí đầu tiên của phần tử có giá trị ≥ target (giống hàm lower_bound trong C++).

C++
#include <iostream>
#include <vector>
using namespace std;

int firstOccurrence(vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;
    int result = -1;

    while (left <= right) {
        int mid = left + (right - left) / 2;

        if (arr[mid] >= target) {
            result = mid;
            right = mid - 1; // Tiếp tục tìm bên trái
        } else {
            left = mid + 1;
        }
    }

    return result; // Trả về chỉ số đầu tiên có giá trị >= target
}

2.3.4. Ví dụ 3: Tìm phần tử cuối cùng xuất hiện (upper_bound)

Tìm vị trí cuối cùng của phần tử có giá trị ≤ target (hoặc vị trí đầu tiên > target).

C++
#include <iostream>
#include <vector>
using namespace std;

int lastOccurrence(vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;
    int result = -1;

    while (left <= right) {
        int mid = left + (right - left) / 2;

        if (arr[mid] <= target) {
            result = mid;
            left = mid + 1; // Tiếp tục tìm bên phải
        } else {
            right = mid - 1;
        }
    }

    return result; // Trả về chỉ số cuối cùng có giá trị <= target
}

2.4. Kết hợp hai con trỏ và tìm kiếm nhị phân

2.4.1. Ví dụ: Tìm ba số có tổng bằng 0

Bài toán: Cho mảng đã sắp xếp, tìm ba số có tổng bằng 0.

Cách giải:

  • Cố định số thứ nhất i
  • Dùng hai con trỏ left = i+1 và right = n-1 để tìm hai số còn lại
C++
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

vector<vector<int>> threeSumZero(vector<int>& arr) {
    vector<vector<int>> result;
    int n = arr.size();

    sort(arr.begin(), arr.end()); // Đảm bảo mảng đã sắp xếp

    for (int i = 0; i < n - 2; i++) {
        // Bỏ qua các giá trị trùng nhau
        if (i > 0 && arr[i] == arr[i-1]) continue;

        int left = i + 1, right = n - 1;
        while (left < right) {
            int sum = arr[i] + arr[left] + arr[right];
            if (sum == 0) {
                result.push_back({arr[i], arr[left], arr[right]});

                // Bỏ qua các giá trị trùng
                while (left < right && arr[left] == arr[left+1]) left++;
                while (left < right && arr[right] == arr[right-1]) right--;

                left++;
                right--;
            } else if (sum < 0) {
                left++;
            } else {
                right--;
            }
        }
    }

    return result;
}

2.4.2. Ví dụ: Tìm cặp số có tổng gần nhất với target

Bài toán: Cho mảng đã sắp xếp và một số target, tìm cặp số có tổng gần nhất với target.

Cách giải:

  • Dùng hai con trỏ left và right
  • Tính tổng và so sánh với target
  • Cập nhật cặp số gần nhất
C++
#include <iostream>
#include <vector>
#include <climits>
#include <cmath>
using namespace std;

pair<int, int> closestSum(vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;
    int minDiff = INT_MAX;
    pair<int, int> result;

    while (left < right) {
        int sum = arr[left] + arr[right];
        int diff = abs(sum - target);

        if (diff < minDiff) {
            minDiff = diff;
            result = {arr[left], arr[right]};
        }

        if (sum < target) {
            left++;
        } else {
            right--;
        }
    }

    return result;
}

2.5. Bài tập thực hành

Bài 1: Tìm số lần xuất hiện của một số trong mảng đã sắp xếp

Cho mảng đã sắp xếp, đếm số lần xuất hiện của một số target.

Gợi ý: Dùng binary search tìm vị trí đầu tiên và cuối cùng, sau đó tính số lượng.

Bài 2: Tìm phần tử nhỏ nhất trong mảng xoay vòng

Cho mảng đã được sắp xếp và xoay vòng (ví dụ: [4,5,6,1,2,3]), tìm phần tử nhỏ nhất.

Gợi ý: Dùng binary search, so sánh arr[mid] với arr[right].

Bài 3: Tìm khoảng cách nhỏ nhất giữa hai phần tử trong hai mảng

Cho hai mảng đã sắp xếp, tìm hai phần tử (mỗi mảng một phần tử) sao cho khoảng cách (chênh lệch tuyệt đối) nhỏ nhất.

Gợi ý: Dùng hai con trỏ, so sánh và di chuyển con trỏ ở mảng có phần tử nhỏ hơn.

2.6. Bảng tổng hợp

Bài toán Phương pháp Độ phức tạp
Tìm hai số có tổng bằng target Hai con trỏ O(n)
Loại bỏ phần tử trùng Hai con trỏ O(n)
Tìm dãy con có tổng ≤ target Hai con trỏ (cửa sổ) O(n)
Tìm kiếm phần tử Tìm kiếm nhị phân O(log n)
Tìm vị trí đầu tiên/cuối cùng Tìm kiếm nhị phân biến thể O(log n)
Tìm ba số có tổng bằng 0 Kết hợp (cố định + hai con trỏ) O(n²)
Tìm cặp số có tổng gần nhất Hai con trỏ O(n)

2.7. Lời khuyên khi sử dụng

  1. Hai con trỏ:

    • Thường dùng cho mảng đã sắp xếp
    • Có thể duyệt từ hai đầu hoặc cùng một đầu
    • Hữu ích cho bài toán tìm cặp, cửa sổ, loại bỏ trùng
  2. Tìm kiếm nhị phân:

    • Chỉ dùng được khi mảng đã sắp xếp
    • Cần chú ý điều kiện dừng và cập nhật con trỏ
    • Có thể dùng để tìm vị trí chèn, phần tử đầu/cuối
  3. Kết hợp:

    • Cố định một phần tử, dùng hai con trỏ cho phần còn lại
    • Hoặc dùng binary search để tối ưu hóa một bước trong hai con trỏ

Tổng kết chuyên đề 2

Hai kỹ thuật hai con trỏ và tìm kiếm nhị phân là công cụ mạnh mẽ để xử lý các bài toán trên dãy số đã sắp xếp. Chúng giúp giảm độ phức tạp đáng kể so với duyệt toàn bộ.

Key takeaways:

  1. Hai con trỏ thường giảm độ phức tạp từ O(n²) xuống O(n)
  2. Tìm kiếm nhị phân giảm từ O(n) xuống O(log n)
  3. Kết hợp cả hai để giải quyết bài toán phức tạp hơn (ví dụ: ba số có tổng bằng 0)

Quy trình lựa chọn:

  • Mảng đã sắp xếp? → Cân nhắc hai con trỏ hoặc tìm kiếm nhị phân
  • Cần tìm một phần tử? → Tìm kiếm nhị phân
  • Cần tìm hai phần tử? → Hai con trỏ
  • Cần tìm ba phần tử trở lên? → Kết hợp (cố định + hai con trỏ)

Câu hỏi thảo luận: Khi nào nên dùng hai con trỏ thay vì tìm kiếm nhị phân cho bài toán tìm hai số có tổng bằng target?

Bình luận

Mới nhất
Tải bình luận...

Không có bình luận nào.