Hướng dẫn cho Google Code Jam 2015 - Haircut


Chỉ sử dụng khi thực sự cần thiết như một cách tôn trọng tác giả và người viết hướng dẫn này.

Chép code từ bài hướng dẫn để nộp bài là hành vi có thể dẫn đến khóa tài khoản.

Phân tích

Sau đây là một số thuật toán giải bài toán, theo thứ tự hiệu quả tăng dần.

Mô phỏng trực tiếp

Duyệt từng phút, từ lúc tiệm mở cửa ở thời điểm \(T=0\) cho đến khi khách cuối cùng được phục vụ. Tại thời điểm \(T\), gán khách mới cho tất cả các thợ đang rảnh; một thợ rảnh khi \(T\) là bội số của \(M\) của người đó. Cuối cùng, trả về thợ phục vụ bạn.

Java
public int naiveGetBarberNumber(int N) {
  int customer = 1;
  for (int T = 0; ; T++) {
    for (int barber = 0; barber < B; barber++) {
      if (T % M[barber] == 0) {
        if (customer == N) return barber;
        customer++;
      }
    }
  }
}

Thuật toán này có độ phức tạp thời gian \(O(N\times\max(M)\times B)\), nên sẽ rất chậm ngay cả với dữ liệu nhỏ, vì \(N\) có thể lớn tới \(1\,000\,000\,000\).

Khai thác tính chu kỳ

Xét trường hợp có hai thợ \(B1\)\(B2\), lần lượt mất 2 và 3 phút để cắt tóc cho một khách.

Thời điểm Sự kiện
\(T=0\) Cả hai thợ đều sẵn sàng phục vụ. \(B1\) phục vụ khách số 1 và \(B2\) phục vụ khách số 2.
\(T=2\) \(B1\) phục vụ khách số 3.
\(T=3\) \(B2\) phục vụ khách số 4.
\(T=4\) \(B1\) phục vụ khách số 5.
\(T=6\) Cả hai thợ đều sẵn sàng. \(B1\) phục vụ khách số 6 và \(B2\) phục vụ khách số 7.

Tại \(T=6\), cả hai thợ lại cùng rảnh hệt như ở \(T=0\). Vì thế, trong 6 phút tiếp theo ta sẽ thấy đúng mẫu trạng thái rảnh như 6 phút đầu: tại \(T=2+6\), \(B1\) phục vụ khách số \(3+5\); tại \(T=3+6\), \(B2\) phục vụ khách số \(4+5\); cứ như vậy cho tới \(T=6+6\), khi quá trình lại bắt đầu lặp.

Điều đặc biệt của 6 là đây là bội chung nhỏ nhất (BCNN) của \(M1=2\)\(M2=3\). Ở thời điểm \(T=\operatorname{LCM}(M1,M2)=6\), mọi thợ đều rảnh vì \(T\bmod M=0\) với từng thợ. Có thể tính BCNN của tất cả các \(M\) bằng

\[\operatorname{LCM}(M1,M2,M3,\ldots)=\operatorname{LCM}(M1,\operatorname{LCM}(M2,M3,M4,\ldots)),\]

\[\operatorname{LCM}(A,B)=\frac{A\times B}{\operatorname{GCD}(A,B)}.\]

Ta có thể tận dụng chu kỳ để chỉ mô phỏng một số ít khách. Chẳng hạn, \(M1=2\), \(M2=3\) và bạn đứng thứ \(N=14\). Chu kỳ dài \(\operatorname{LCM}(2,3)=6\). Trong một pha, \(B1\) phục vụ \(6/M1=3\) khách và \(B2\) phục vụ \(6/M2=2\) khách, tổng cộng 5 khách. Do đó bạn được phục vụ trong pha thứ ba. Khi pha thứ ba bắt đầu, bạn đứng thứ tư trong pha vì hai pha trước đã phục vụ 10 khách. Cuối cùng, mô phỏng trực tiếp pha của bạn như lời giải đầu để xác định số thợ.

Vì chỉ mô phỏng một pha, ta chỉ cần mô phỏng nhiều nhất \(\operatorname{LCM}(M1,M2,M3,\ldots)\) phút. Độ phức tạp được cải thiện thành \(O(B\times\operatorname{LCM}(M1,M2,M3,\ldots))\). BCNN của tất cả các \(M\) không vượt quá \(\max(M)^B\); với dữ liệu nhỏ, nó nhỏ hơn \(25^5\).

Java
public int slowGetBarberNumber(int N) {
  int period = M[0];
  for (int i = 1; i < B; i++)
    period = period / gcd(period, M[i]) * M[i];
  int customers_per_phase = 0;
  for (int i = 0; i < B; i++)
    customers_per_phase += period / M[i];
  int N_in_my_phase = N % customers_per_phase;
  return naiveGetBarberNumber(N_in_my_phase != 0
    ? N_in_my_phase : customers_per_phase);
}

Với dữ liệu lớn, \(B\) và các \(M\) có thể rất lớn nên BCNN có thể khổng lồ; cách này không hoạt động.

Tìm kiếm nhị phân

Với một thời điểm \(T\), ta dễ dàng tính số khách đã được gán cho thợ tính đến và bao gồm thời điểm \(T\). Số khách được gán cho thợ \(i\)\(\lfloor T/M_i\rfloor+1\), nên chỉ cần cộng các giá trị này cho mọi thợ.

Java
public int countServedCustomers(long T) {
  if (T < 0) return 0;
  int served_customers = 0;
  for (int barber = 0; barber < B; barber++)
    served_customers += T / M[barber] + 1;
  return served_customers;
}

Nhờ đó, ta có thể tìm kiếm nhị phân thời điểm \(T\) mà bạn được phục vụ. Sau đó chỉ còn xác định thợ nào trong số các thợ rảnh tại \(T\) sẽ phục vụ bạn.

Cần nhớ rằng nhiều thợ có thể cùng rảnh tại \(T\), nên phải tính cả những khách đứng trước bạn và cũng được phục vụ đúng thời điểm đó. Vì biết bạn sẽ được phục vụ tại \(T\), số khách có thể đứng trước bạn và được phục vụ tại \(T\) nhỏ hơn số thợ đang rảnh. Ta mô phỏng nhóm này sau khi biết số khách đã được phục vụ tính đến và bao gồm \(T-1\). Chính xác hơn, số khách ngồi vào ghế tại \(T\) bằng

\[\operatorname{countServedCustomers}(T)-\operatorname{countServedCustomers}(T-1).\]

Duyệt các thợ rảnh tại \(T\) theo thứ tự số tăng dần; người thứ \(N-\operatorname{countServedCustomers}(T-1)\) trong thứ tự đó là đáp án.

Cận tìm kiếm nhị phân nên là gì?

Với cận trên, hãy tưởng tượng trường hợp xấu nhất: mọi khách đứng trước bạn đều được thợ chậm nhất và duy nhất đang rảnh phục vụ. Vì vậy, chắc chắn bạn đã được phục vụ không muộn hơn \(\max(M)\times N\) phút. Với cận dưới, trường hợp tốt nhất là bạn được phục vụ ngay khi tiệm mở.

Mã dưới đây giả sử tại \(T=\text{high}\) bạn chắc chắn đã được phục vụ hoặc được phục vụ sớm hơn, còn tại \(T=\text{low}\) thì chưa. Do đó giá trị ban đầu của low phải là \(-1\), không phải 0.

Độ phức tạp cuối cùng là \(O(B\times\log(N\times\max(M)))\).

Java
public int fastGetBarberNumber(int N) {
  long low = -1, high = 10000L*N;
  while (low + 1 < high) {
    long mid = (low + high) / 2;
    if (countServedCustomers(mid) < N)
      low = mid;
    else
      high = mid;
  }
  long T = high;
  int customers_served_before = 
    countServedCustomers(T - 1);
  int customers_to_be_served = 
    N - customers_served_before;
  for (int barber = 0; barber < B; barber++) 
    // Is the barber available at time T?
    if (T % M[barber] == 0) {
      customers_to_be_served--;
      if (customers_to_be_served == 0)
        return barber;
    }
}

Dựa trên phân tích chính thức của Google Code Jam.

Bình luận

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

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