Giải thuật và lập trình: §3.1. Dãy con đơn điệu tăng dài nhất

Các khóa học qua video:
Python SQL Server PHP C# Lập trình C Java HTML5-CSS3-JavaScript
Học trên YouTube <76K/tháng. Đăng ký Hội viên
Viết nhanh hơn - Học tốt hơn
Giải phóng thời gian, khai phóng năng lực

Cho dãy số nguyên A = a1, a2, …, an. (n £ 5000, -10000 £ ai £ 10000). Một dãy con của A là một cách chọn ra trong A một số phần tử giữ nguyên thứ tự. Như vậy A có 2n dãy con.

Yêu cầu: Tìm dãy con đơn điệu tăng của A có độ dài lớn nhất.

Ví dụ: A = (1, 2, 3, 4, 9, 10, 5, 6, 7). Dãy con đơn điệu tăng dài nhất là: (1, 2, 3, 4, 5, 6, 7).

Input: file văn bản INCSEQ.INP

  • Dòng 1: Chứa số n
  • Dòng 2: Chứa n số a1, a2, …, an cách nhau ít nhất một dấu cách

Output: file văn bản INCSEQ.OUT

  • Dòng 1: Ghi độ dài dãy con tìm được
  • Các dòng tiếp: ghi dãy con tìm được và chỉ số những phần tử được chọn vào dãy con đó.

Cách giải:

Bổ sung vào A hai phần tử: a0 = -¥ và an+1 = +¥. Khi đó dãy con đơn điệu tăng dài nhất chắc chắn sẽ bắt đầu từ a0 và kết thúc ở an+1.

Với " i: 0 £ i £ n + 1. Ta sẽ tính L[i] = độ dài dãy con đơn điệu tăng dài nhất bắt đầu tại ai.

1. Cơ sở quy hoạch động (bài toán nhỏ nhất):

L[n + 1] = Độ dài dãy con đơn điệu tăng dài nhất bắt đầu tại an+1 = +¥. Dãy con này chỉ gồm mỗi một phần tử (+¥) nên L[n + 1] = 1.

2. Công thức truy hồi:

Giả sử với i chạy từ n về 0, ta cần tính L[i]: độ dài dãy con tăng dài nhất bắt đầu tại ai. L[i]

được tính trong điều kiện L[i + 1], L[i + 2], …, L[n + 1] đã biết:

Dãy con đơn điệu tăng dài nhất bắt đầu từ ai sẽ được thành lập bằng cách lấy ai ghép vào đầu một trong số những dãy con đơn điệu tăng dài nhất bắt đầu tại vị trí aj đứng sau ai. Ta sẽ chọn dãy nào để ghép ai vào đầu? Tất nhiên là chỉ được ghép ai vào đầu những dãy con bắt đầu tại aj nào đó lớn hơn ai (để đảm bảo tính tăng) và dĩ nhiên ta sẽ chọn dãy dài nhất để ghép ai vào đầu (để đảm bảo tính dài nhất). Vậy L[i] được tính như sau: Xét tất cả các chỉ số j trong khoảng từ i + 1 đến n + 1 mà aj > ai, chọn ra chỉ số jmax có L[jmax] lớn nhất. Đặt L[i] := L[jmax] + 1.

3. Truy vết

Tại bước xây dựng dãy L, mỗi khi gán L[i] := L[jmax] + 1, ta đặt T[i] = jmax. Để lưu lại rằng: Dãy con dài nhất bắt đầu tại ai sẽ có phần tử thứ hai kế tiếp là ajmax.

Sau khi tính xong hay dãy L và T, ta bắt đầu từ 0. T[0] là phần tử đầu tiên được chọn, T[T[0]] là phần tử thứ hai được chọn,

T[T[T[0]]] là phần tử thứ ba được chọn …Quá trình truy vết có thể diễn tả như sau:

i := T[0];
while i <> n + 1 do {Chừng nào chưa duyệt đến số an+1=+¥ ở cuối}
  begin
    <Thông báo chọn ai> i := T[i];
  end;

Ví dụ: với A = (5, 2, 3, 4, 9, 10, 5, 6, 7, 8). Hai dãy L và T sau khi tính sẽ là:


Tính toán và truy vết

 

P_3_03_1.PAS * Tìm dãy con đơn điệu tăng dài nhất

program LongestSubSequence;

const
  InputFile = 'INCSEQ.INP';
  
OutputFile = 'INCSEQ.OUT';
  
max = 5000;

var
  a, L, T: array[0..max + 1] of Integer;
  n: Word;

procedure Enter;
var

  i: Word;
  f: Text;

begin
  Assign(f, InputFile);
  Reset(f);

  ReadLn(f, n);
  for i := 1 to n do Read(f, a[i]);
  Close(f);

end;

procedure Optimize; {Quy hoạch động}
var
i, j, jmax: Word;
begin

  a[0] := -32768;
  a[n + 1] := 32767;
{Thêm hai phần tử canh hai đầu dãy a}

  L[n + 1] := 1; {Điền cơ sở quy hoach động vào bảng phương án}
  for i := n downto 0 do {Tính bảng phương án}
    begin
    {Chọn trong các chỉ số j đứng sau i thoả mãn aj > ai ra chỉ số jmax có L[jmax] lớn nhất}
      jmax := n + 1;
      for j := i + 1 to n + 1 do
        if (a[j] > a[i]) and (L[j] > L[jmax]) then jmax := j;
      L[i] := L[jmax] + 1; {Lưu độ dài dãy con tăng dài nhất bắt đầu tại ai}
      T[i] := jmax; {Lưu vết: phần tử đứng liền sau ai trong dãy con tăng dài nhất đó là ajmax}
    end;
end;

procedure Result;
var

  f: Text;
i: Integer;
begin

  Assign(f, OutputFile); Rewrite(f);
  WriteLn(f, L[0] - 2); {Chiều dài dãy con tăng dài nhất}
  i := T[0]; {Bắt đầu truy vết tìm nghiệm}
  while i <> n + 1 do
    begin

      WriteLn(f, 'a[', i, '] = ', a[i]);
      i := T[i];

    end;
  Close(f);

end;

begin
  Enter;
  Optimize;
  Result;
end.

Nhận xét: Công thức truy hồi tính các L[.] có thể tóm tắt là:

và để tính hết các L[.], ta phải mất một đoạn chương trình với độ phức tạp tính toán là O(n2). Ta có thể cải tiến cách cài đặt để được một đoạn chương trình với độ phức tạp tính toán là O(nlogn) bằng kỹ thuật sau:

Với mỗi số k, ta gọi StartOf[k] là chỉ số x của phần tử a[x] thoả mãn: dãy đơn điệu tăng dài nhất bắt đầu từ a[x] có độ dài k. Nếu có nhiều phần tử a[.] cùng thoả mãn điều kiện này thì ta chọn phần tử a[x] là phần tử lớn nhất trong số những phần tử đó. Việc tính các giá trị StartOf[.] được thực hiện đồng thời với việc tính các giá trị L[.] bằng phương pháp sau:

L[n + 1] := 1;
StartOf[1] := n + 1;
m := 1; {m là độ dài dãy con đơn điệu tăng dài nhất của dãy ai, ai+1, …, an+1 (ở bước khởi tạo này i = n + 1)}
for i := n downto 0 do
  begin

    <Tính L[i];
    đặt k := L[i]>;

    if k > m then {Nếu dãy con tăng dài nhất bắt đầu tại a[i] có độ dài > m}
      begin
        m := k; {Cập nhật lại m}
        StartOf[k] := i; {Gán giá trị cho StartOf[m]}
      end
    
else

      if a[i] > a[StartOf[k]] then {Nếu có nhiều dãy đơn điệu tăng dài nhất độ dài k thì}
        StartOf[k] := i; {chỉ ghi nhận lại dãy có phần tử bắt đầu lớn nhất}
  end;

Khi bắt đầu vào một lần lặp với một giá trị i, ta đã biết được:

m: Độ dài dãy con đơn điệu tăng dài nhất của dãy ai+1, ai+2, …, an+1

StartOf[k] (1 £ k £ m): Phần tử aStartOf[k] là phần tử lớn nhất trong số các phần tử ai+1, ai+2, …, an+1 thoả mãn: Dãy con đơn điệu tăng dài nhất bắt đầu từ aStartOf[k] có độ dài k. Do thứ tự tính toán được áp đặt như trong sơ đồ trên, ta dễ dàng nhận thấy rằng: aStartOf[k] < aStartOf[k - 1]<…<aStartOf[1].

Điều kiện để có dãy con đơn điệu tăng độ dài p+1 bắt đầu tại ai chính là aStartOf[p] > ai (vì theo thứ tự tính toán thì khi bắt đầu một lần lặp với giá trị i, aStartOf[p] luôn đứng sau ai). Mặt khác nếu đem ai ghép vào đầu dãy con đơn điệu tăng dài nhất bắt đầu tại aStartOf[p] mà thu được dãy tăng thì đem ai ghép vào đầu dãy con đơn điệu tăng dài nhất bắt đầu tại aStartOf[p - 1] ta cũng thu được dãy tăng. Vậy để tính L[i], ta có thể tìm số p lớn nhất thoả mãn aStartOf[p] > ai bằng thuật toán tìm kiếm nhị phân rồi đặt L[i] := p + 1 (và sau đó T[i] := StartOf[p], tất nhiên)

P_3_03_2.PAS * Cải tiến thuật toán tìm dãy con đơn điệu tăng dài nhất program LongestSubSequence;

const
  InputFile = 'INCSEQ.INP';
  
OutputFile = 'INCSEQ.OUT';

const
  max = 5000;
var

  a, L, T, StartOf: array[0..max + 1] of Integer;
  
n, m: Integer;

procedure Enter;
var

  i: Word;
  f: Text;
begin
  Assign(f, InputFile);
  Reset(f);

  ReadLn(f, n);
  for i := 1 to n do Read(f, a[i]);
  Close(f);

end;

procedure Init;
begin

  a[0] := -32768;
  a[n + 1] := 32767;
  m := 1;
  L[n + 1] := 1;
  StartOf[1] := n + 1;
end;

{Hàm Find, tìm vị trí j mà nếu đem ai ghép vào đầu dãy con đơn điệu tăng dài nhất bắt đầu từ aj sẽ được dãy đơn điệu tăng dài nhất bắt đầu tại ai}
function Find(i: Integer): Integer;
var

  inf, sup, median, j: Integer;
begin

  inf := 1;
  sup := m + 1;

  repeat {Thuật toán tìm kiếm nhị phân}
  median := (inf + sup) div 2;
  j := StartOf[median];

  if a[j] > a[i] then inf := median {Luôn để aStartOf[inf] > ai ³ aStartOf[sup]}
  else sup := median;
  until inf + 1 = sup;
  Find := StartOf[inf];

end;

procedure Optimize;
var

  i, j, k: Integer;
begin

  for i := n downto 0 do
    begin

      j := Find(i);
      k := L[j] + 1;
      if k > m then
        begin

          m := k;
          StartOf[k] := i;
        end
      else

        if a[StartOf[k]] < a[i] then StartOf[k] := i;
      L[i] := k;
      T[i] := j;
    end;
end;

procedure Result;
var

  f: Text;
  i: Integer;
begin

  Assign(f, OutputFile);
  Rewrite(f);

  WriteLn(f, m - 2);

  i := T[0];
  while i <> n + 1 do
    begin

      WriteLn(f, 'a[', i, '] = ', a[i]);
      i := T[i];

    end;
  Close(f);

end;
begin
  Enter;
  Init;
  Optimize;
  Result;
end.

Dễ thấy chi phí thời gian thực hiện giải thuật này cấp O(nlogn), đây là một ví dụ điển hình cho thấy rằng một công thức truy hồi có thể có nhiều phương pháp tính.

Nguồn: Giáo trình Giải thuật và Lập trình - Lê Minh Hoàng - Đại học sư phạm Hà Nội
» Tiếp: §3.2. Bài toán cái túi
« Trước: §2. Phương pháp quy hoạch động
Các khóa học qua video:
Python SQL Server PHP C# Lập trình C Java HTML5-CSS3-JavaScript
Học trên YouTube <76K/tháng. Đăng ký Hội viên
Viết nhanh hơn - Học tốt hơn
Giải phóng thời gian, khai phóng năng lực
Copied !!!