Thuật toán cây hậu tố của Ukkonen bằng tiếng Anh


1102

Tôi cảm thấy một chút dày ở điểm này. Tôi đã dành nhiều ngày cố gắng quấn đầu hoàn toàn xung quanh việc xây dựng cây hậu tố, nhưng vì tôi không có nền tảng toán học, nhiều lời giải thích đã lảng tránh tôi khi họ bắt đầu sử dụng quá nhiều phép tượng trưng toán học. Cách giải thích tốt nhất mà tôi đã tìm thấy là Tìm kiếm chuỗi nhanh với cây Suffix , nhưng anh ta nhấn mạnh vào nhiều điểm khác nhau và một số khía cạnh của thuật toán vẫn chưa rõ ràng.

Một lời giải thích từng bước về thuật toán này ở đây trên Stack Overflow sẽ là vô giá đối với nhiều người khác ngoài tôi, tôi chắc chắn.

Để tham khảo, đây là bài viết của Ukkonen về thuật toán: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf

Hiểu biết cơ bản của tôi, cho đến nay:

  • Tôi cần lặp lại qua từng tiền tố P của một chuỗi T đã cho
  • Tôi cần lặp lại qua từng hậu tố S trong tiền tố P và thêm nó vào cây
  • Để thêm hậu tố S vào cây, tôi cần lặp lại qua từng ký tự trong S, với các lần lặp bao gồm cả việc đi xuống một nhánh hiện có bắt đầu bằng cùng một bộ ký tự C trong S và có khả năng tách một cạnh thành các nút hạ xuống khi tôi đạt đến một ký tự khác nhau trong hậu tố, HOẶC nếu không có cạnh phù hợp để đi xuống. Khi không tìm thấy cạnh phù hợp để đi xuống cho C, một cạnh lá mới được tạo cho C.

Thuật toán cơ bản dường như là O (n 2 ), như được chỉ ra trong hầu hết các giải thích, vì chúng ta cần phải bước qua tất cả các tiền tố, sau đó chúng ta cần phải bước qua từng hậu tố cho mỗi tiền tố. Thuật toán của Ukkonen rõ ràng là duy nhất vì kỹ thuật con trỏ hậu tố mà anh ta sử dụng, mặc dù tôi nghĩ đó là điều tôi gặp khó khăn trong việc hiểu.

Tôi cũng gặp khó khăn trong việc hiểu:

  • chính xác thời điểm và cách thức "điểm hoạt động" được chỉ định, sử dụng và thay đổi
  • những gì đang xảy ra với khía cạnh chuẩn hóa của thuật toán
  • Tại sao các triển khai tôi thấy cần phải "sửa" các biến ràng buộc mà chúng đang sử dụng

Đây là mã nguồn C # hoàn thành . Nó không chỉ hoạt động chính xác, mà còn hỗ trợ chuẩn hóa tự động và hiển thị biểu đồ văn bản trông đẹp hơn của đầu ra. Mã nguồn và đầu ra mẫu là tại:

https://gist.github.com/2373868


Cập nhật 2017-11-04

Sau nhiều năm, tôi đã tìm thấy một cách sử dụng mới cho cây hậu tố và đã triển khai thuật toán trong JavaScript . Gist ở dưới Nó sẽ không có lỗi. Kết xuất nó vào một tệp js, npm install chalktừ cùng một vị trí và sau đó chạy với node.js để xem một số đầu ra đầy màu sắc. Có một phiên bản rút gọn trong cùng một Gist, không có bất kỳ mã gỡ lỗi nào.

https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6


2
Bạn đã xem mô tả được đưa ra trong cuốn sách của Dan Gusfield chưa? Tôi thấy rằng sẽ hữu ích.
jogojapan

4
Ý chính không chỉ định giấy phép - tôi có thể thay đổi mã của bạn và xuất bản lại theo MIT (rõ ràng là có phân bổ) không?
Yurik

2
Đúng, đi cho cuộc sống của bạn. Hãy xem nó là miền công cộng. Như được đề cập bởi một câu trả lời khác trên trang này, dù sao cũng có một lỗi cần sửa.
Nathan Ridley

1
có thể việc triển khai này sẽ giúp những người khác, goto code.google.com/p/text-indexing
cos

2
"Hãy coi đó là phạm vi công cộng", có lẽ đáng ngạc nhiên là một câu trả lời rất không có ích. Lý do là bạn thực sự không thể đặt tác phẩm vào phạm vi công cộng. Do đó, bình luận "xem xét ..." của bạn nhấn mạnh thực tế rằng giấy phép không rõ ràng và khiến người đọc nghi ngờ rằng tình trạng của tác phẩm thực sự rõ ràng với bạn . Nếu bạn muốn mọi người có thể sử dụng mã của mình, vui lòng chỉ định giấy phép cho mã đó, chọn bất kỳ giấy phép nào bạn thích (nhưng, trừ khi bạn là luật sư, hãy chọn giấy phép có sẵn!)
James Youngman

Câu trả lời:


2379

Sau đây là một nỗ lực để mô tả thuật toán Ukkonen bằng cách trước tiên hiển thị những gì nó làm khi chuỗi đơn giản (nghĩa là không chứa bất kỳ ký tự lặp lại nào), sau đó mở rộng nó sang thuật toán đầy đủ.

Đầu tiên, một vài tuyên bố sơ bộ.

  1. Những gì chúng tôi đang xây dựng, về cơ bản giống như một bộ ba tìm kiếm. Vì vậy, có một nút gốc, các cạnh đi ra khỏi nó dẫn đến các nút mới và các cạnh khác đi ra khỏi các nút đó, v.v.

  2. Nhưng : Không giống như trong bộ ba tìm kiếm, nhãn cạnh không phải là ký tự đơn. Thay vào đó, mỗi cạnh được dán nhãn bằng một cặp số nguyên [from,to]. Đây là những gợi ý vào văn bản. Theo nghĩa này, mỗi cạnh mang một nhãn chuỗi có độ dài tùy ý, nhưng chỉ chiếm không gian O (1) (hai con trỏ).

Nguyên tắc cơ bản

Trước tiên tôi muốn trình bày cách tạo cây hậu tố của một chuỗi đặc biệt đơn giản, một chuỗi không có các ký tự lặp lại:

abc

Thuật toán hoạt động theo các bước, từ trái sang phải . Có một bước cho mỗi ký tự của chuỗi . Mỗi bước có thể liên quan đến nhiều hơn một thao tác riêng lẻ, nhưng chúng ta sẽ thấy (xem các quan sát cuối cùng ở cuối) rằng tổng số thao tác là O (n).

Vì vậy, chúng tôi bắt đầu từ bên trái và đầu tiên chỉ chèn một ký tự đơn abằng cách tạo một cạnh từ nút gốc (bên trái) sang một chiếc lá và gắn nhãn là [0,#], nghĩa là cạnh đại diện cho chuỗi con bắt đầu từ vị trí 0 và kết thúc ở cuối hiện tại . Tôi sử dụng ký hiệu #để chỉ kết thúc hiện tại , ở vị trí 1 (ngay sau đó a).

Vì vậy, chúng ta có một cây ban đầu, trông như thế này:

Và ý nghĩa của nó là đây:

Bây giờ chúng tôi tiến tới vị trí 2 (ngay sau đó b). Mục tiêu của chúng tôi ở mỗi bước là chèn tất cả các hậu tố lên đến vị trí hiện tại . Chúng tôi làm điều này bằng cách

  • mở rộng a-edge hiện có đểab
  • chèn một cạnh mới cho b

Trong đại diện của chúng tôi, nó trông giống như

nhập mô tả hình ảnh ở đây

Và ý nghĩa của nó là:

Chúng tôi quan sát hai điều:

  • Các đại diện cạnh cho abgiống như nó đã từng là trong cây ban đầu: [0,#]. Ý nghĩa của nó đã tự động thay đổi vì chúng tôi đã cập nhật vị trí hiện tại #từ 1 lên 2.
  • Mỗi cạnh tiêu thụ không gian O (1), bởi vì nó chỉ bao gồm hai con trỏ vào văn bản, bất kể nó đại diện cho bao nhiêu ký tự.

Tiếp theo, chúng tôi tăng vị trí một lần nữa và cập nhật cây bằng cách nối thêm cvào mỗi cạnh hiện có và chèn một cạnh mới cho hậu tố mới c.

Trong đại diện của chúng tôi, nó trông giống như

Và ý nghĩa của nó là:

Chúng tôi quan sát:

  • Cây là cây hậu tố chính xác đến vị trí hiện tại sau mỗi bước
  • Có nhiều bước như có các ký tự trong văn bản
  • Lượng công việc trong mỗi bước là O (1), bởi vì tất cả các cạnh hiện có được cập nhật tự động bằng cách tăng #và chèn một cạnh mới cho ký tự cuối cùng có thể được thực hiện trong thời gian O (1). Do đó đối với một chuỗi có độ dài n, chỉ cần thời gian O (n).

Phần mở rộng đầu tiên: Lặp lại đơn giản

Tất nhiên điều này hoạt động rất tốt chỉ bởi vì chuỗi của chúng tôi không chứa bất kỳ sự lặp lại. Bây giờ chúng ta xem xét một chuỗi thực tế hơn:

abcabxabcd

Nó bắt đầu abcnhư trong ví dụ trước, sau đó abđược lặp lại và theo sau x, và sau đó abcđược lặp lại theo sau d.

Bước 1 đến 3: Sau 3 bước đầu tiên, chúng ta có cây từ ví dụ trước:

Bước 4: Chúng tôi chuyển #đến vị trí 4. Điều này ngầm cập nhật tất cả các cạnh hiện có vào đây:

và chúng ta cần chèn hậu tố cuối cùng của bước hiện tại a, ở gốc.

Trước khi thực hiện điều này, chúng tôi giới thiệu thêm hai biến số (ngoài ra #), tất nhiên đã có ở đó mọi lúc nhưng chúng tôi chưa sử dụng chúng cho đến nay:

  • Điểm hoạt động , là một bộ ba (active_node,active_edge,active_length)
  • Số remaindernguyên là số nguyên cho biết chúng ta cần chèn bao nhiêu hậu tố mới

Ý nghĩa chính xác của hai từ này sẽ sớm trở nên rõ ràng, nhưng bây giờ chúng ta hãy nói:

  • Trong abcví dụ đơn giản , điểm hoạt động luôn luôn (root,'\0x',0), tức active_nodelà nút gốc, active_edgeđược chỉ định là ký tự null '\0x'active_lengthbằng không. Hiệu quả của việc này là một cạnh mới mà chúng ta đã chèn trong mỗi bước được chèn vào nút gốc dưới dạng cạnh mới được tạo. Chúng tôi sẽ sớm thấy lý do tại sao một bộ ba là cần thiết để đại diện cho thông tin này.
  • Các remainderluôn thiết lập để 1 lúc bắt đầu của mỗi bước. Ý nghĩa của việc này là số lượng hậu tố chúng ta phải chủ động chèn vào cuối mỗi bước là 1 (luôn luôn chỉ là ký tự cuối cùng).

Bây giờ điều này sẽ thay đổi. Khi chúng ta chèn ký tự cuối cùng hiện atại vào thư mục gốc, chúng tôi nhận thấy rằng đã có một cạnh ra bắt đầu bằng a, cụ thể là : abca. Dưới đây là những gì chúng tôi làm trong trường hợp như vậy:

  • Chúng tôi không chèn một cạnh mới [4,#]vào nút gốc. Thay vào đó chúng tôi chỉ đơn giản nhận thấy rằng hậu tố ađã có trong cây của chúng tôi. Nó kết thúc ở giữa một cạnh dài hơn, nhưng chúng tôi không bị làm phiền bởi điều đó. Chúng tôi chỉ để mọi thứ theo cách họ đang có.
  • Chúng tôi đặt điểm hoạt động thành (root,'a',1). Điều đó có nghĩa là điểm hoạt động hiện đang ở đâu đó ở giữa cạnh ra của nút gốc bắt đầu bằng a, cụ thể, sau vị trí 1 trên cạnh đó. Chúng tôi nhận thấy rằng cạnh được chỉ định đơn giản bởi ký tự đầu tiên của nó a. Điều đó đủ bởi vì chỉ có thể có một cạnh bắt đầu với bất kỳ ký tự cụ thể nào (xác nhận rằng điều này là đúng sau khi đọc qua toàn bộ mô tả).
  • Chúng tôi cũng tăng remainder, vì vậy ở đầu bước tiếp theo sẽ là 2.

Quan sát: Khi hậu tố cuối cùng chúng ta cần chèn đã tồn tại trong cây , bản thân cây không bị thay đổi (chúng tôi chỉ cập nhật điểm hoạt động và remainder). Cây sau đó không phải là một đại diện chính xác của cây hậu tố cho đến vị trí hiện tại nữa, nhưng nó chứa tất cả các hậu tố (vì hậu tố cuối cùng ađược chứa ẩn ). Do đó, ngoài việc cập nhật các biến (tất cả đều có độ dài cố định, vì vậy đây là O (1)), không có công việc nào được thực hiện trong bước này.

Bước 5: Chúng tôi cập nhật vị trí hiện tại #lên 5. Điều này sẽ tự động cập nhật cây thành này:

bởi vì remainderlà 2 , chúng ta cần chèn hai hậu tố cuối cùng của vị trí hiện tại: abb. Điều này về cơ bản là vì:

  • Các ahậu tố từ bước trước chưa bao giờ được lắp đúng. Vì vậy, nó đã ở lại , và kể từ khi chúng tôi đã tiến một bước, bây giờ nó đã phát triển từ ađến ab.
  • Và chúng ta cần chèn cạnh cuối cùng mới b.

Trong thực tế, điều này có nghĩa là chúng ta đi đến điểm hoạt động (chỉ ra phía sau điểm ahiện tại là abcabcạnh) và chèn ký tự cuối cùng hiện tại b. Nhưng: Một lần nữa, hóa ra bcũng đã có mặt trên cùng một cạnh.

Vì vậy, một lần nữa, chúng tôi không thay đổi cây. Chúng tôi chỉ đơn giản là:

  • Cập nhật điểm hoạt động thành (root,'a',2)(cùng nút và cạnh như trước đây, nhưng bây giờ chúng tôi trỏ đến phía sau b)
  • Tăng remainderlên 3 vì chúng tôi vẫn chưa chèn đúng cạnh cuối cùng từ bước trước và chúng tôi cũng không chèn cạnh cuối cùng hiện tại.

Để rõ ràng: Chúng tôi đã phải chèn abbtrong bước hiện tại, nhưng vì abđã được tìm thấy, chúng tôi đã cập nhật điểm hoạt động và thậm chí không cố gắng chèn b. Tại sao? Bởi vì nếu abở trong cây, mọi hậu tố của nó (bao gồm b) cũng phải ở trong cây. Có lẽ chỉ ngầm , nhưng nó phải ở đó, vì cách chúng tôi đã xây dựng cây cho đến nay.

Chúng tôi tiến hành bước 6 bằng cách tăng dần #. Cây được tự động cập nhật thành:

Bởi vì remainderlà 3 , chúng ta phải chèn abx, bxx. Điểm hoạt động cho chúng ta biết nơi abkết thúc, vì vậy chúng ta chỉ cần nhảy đến đó và chèn x. Thật vậy, xvẫn chưa có, vì vậy chúng tôi chia abcabxcạnh và chèn một nút bên trong:

Các biểu diễn cạnh vẫn là các con trỏ vào văn bản, do đó việc chia và chèn một nút bên trong có thể được thực hiện trong thời gian O (1).

Vì vậy, chúng tôi đã xử lý abxvà giảm remainderxuống còn 2. Bây giờ chúng tôi cần chèn hậu tố còn lại tiếp theo , bx. Nhưng trước khi làm điều đó, chúng ta cần cập nhật điểm hoạt động. Quy tắc này, sau khi tách và chèn một cạnh, sẽ được gọi là Quy tắc 1 bên dưới và áp dụng bất cứ khi nào active_nodelà root (chúng ta sẽ tìm hiểu quy tắc 3 cho các trường hợp khác bên dưới). Đây là quy tắc 1:

Sau khi chèn từ gốc,

  • active_node vẫn còn root
  • active_edge được đặt thành ký tự đầu tiên của hậu tố mới mà chúng ta cần chèn, nghĩa là b
  • active_length giảm 1

Do đó, bộ ba điểm hoạt động mới (root,'b',1)chỉ ra rằng lần chèn tiếp theo phải được thực hiện ở bcabxcạnh, phía sau 1 ký tự, tức là phía sau b. Chúng ta có thể xác định điểm chèn trong thời gian O (1) và kiểm tra xem xđã có mặt hay chưa. Nếu nó có mặt, chúng tôi sẽ kết thúc bước hiện tại và để mọi thứ vẫn như cũ. Nhưng x không có mặt, vì vậy chúng tôi chèn nó bằng cách tách cạnh:

Một lần nữa, điều này mất thời gian O (1) và chúng tôi cập nhật remainderlên 1 và điểm hoạt động thành (root,'x',0)trạng thái quy tắc 1.

Nhưng có một điều nữa chúng ta cần làm. Chúng tôi sẽ gọi Quy tắc 2 này:

Nếu chúng ta tách một cạnh và chèn một nút mới và nếu đó không phải là nút đầu tiên được tạo trong bước hiện tại, chúng ta sẽ kết nối nút được chèn trước đó và nút mới thông qua một con trỏ đặc biệt, một liên kết hậu tố . Sau này chúng ta sẽ thấy tại sao điều đó hữu ích. Đây là những gì chúng ta nhận được, liên kết hậu tố được biểu diễn dưới dạng một cạnh chấm:

Chúng ta vẫn cần chèn hậu tố cuối cùng của bước hiện tại , x. Vì active_lengththành phần của nút hoạt động đã giảm xuống 0, nên lần chèn cuối cùng được thực hiện trực tiếp tại thư mục gốc. Vì không có cạnh ra ở nút gốc bắt đầu bằng x, chúng tôi chèn một cạnh mới:

Như chúng ta có thể thấy, trong bước hiện tại, tất cả các phần chèn còn lại đã được thực hiện.

Chúng tôi tiến hành bước 7 bằng cách đặt #= 7, tự động nối thêm ký tự tiếp theo a, vào tất cả các cạnh của lá, như mọi khi. Sau đó, chúng tôi cố gắng chèn ký tự cuối cùng mới vào điểm hoạt động (gốc) và thấy rằng nó đã ở đó. Vì vậy, chúng tôi kết thúc bước hiện tại mà không chèn bất cứ điều gì và cập nhật điểm hoạt động (root,'a',1).

Trong bước 8 , #= 8, chúng tôi nối thêm bvà như đã thấy trước đây, điều này chỉ có nghĩa là chúng tôi cập nhật điểm hoạt động (root,'a',2)và tăng remaindermà không làm gì khác, vì bđã có sẵn. Tuy nhiên, chúng tôi nhận thấy (trong O (1) thời gian) rằng điểm hoạt động hiện ở cuối một cạnh. Chúng tôi phản ánh điều này bằng cách thiết lập lại nó (node1,'\0x',0). Ở đây, tôi sử dụng node1để chỉ nút nội bộ mà abcạnh kết thúc tại.

Sau đó, trong bước #= 9 , chúng ta cần chèn 'c' và điều này sẽ giúp chúng ta hiểu được mẹo cuối cùng:

Tiện ích mở rộng thứ hai: Sử dụng liên kết hậu tố

Như mọi khi, #bản cập nhật csẽ tự động thêm vào các cạnh lá và chúng tôi đi đến điểm hoạt động để xem liệu chúng tôi có thể chèn 'c' không. Hóa ra 'c' đã tồn tại ở cạnh đó, vì vậy chúng tôi đặt điểm hoạt động thành (node1,'c',1), tăng remaindervà không làm gì khác.

Bây giờ trong bước #= 10 , remainderlà 4, và vì vậy trước tiên chúng ta cần chèn abcd(vẫn còn từ 3 bước trước) bằng cách chèn dtại điểm hoạt động.

Cố gắng chèn dtại điểm hoạt động sẽ gây ra sự phân tách cạnh trong thời gian O (1):

Các active_node, từ đó phân chia được khởi xướng, được đánh dấu màu đỏ ở trên. Đây là quy tắc cuối cùng, Quy tắc 3:

Sau khi tách một cạnh từ một active_nodenút không phải là nút gốc, chúng ta theo liên kết hậu tố đi ra khỏi nút đó, nếu có, và đặt lại active_nodenút cho nút mà nó trỏ tới. Nếu không có liên kết hậu tố, chúng tôi đặt active_nodegốc. active_edgeactive_lengthvẫn không thay đổi.

Vì vậy, điểm hoạt động là ngay bây giờ (node2,'c',1)node2được đánh dấu màu đỏ bên dưới:

Vì việc chèn abcdhoàn tất, chúng tôi giảm remainderxuống còn 3 và xem xét hậu tố tiếp theo còn lại của bước hiện tại , bcd. Quy tắc 3 đã đặt điểm hoạt động thành chỉ nút và cạnh phải để việc chèn bcdcó thể được thực hiện bằng cách chỉ cần chèn ký tự cuối cùng của nó dtại điểm hoạt động.

Làm điều này gây ra sự phân tách cạnh khác và vì quy tắc 2 , chúng ta phải tạo một liên kết hậu tố từ nút được chèn trước đó đến nút mới:

Chúng tôi quan sát: Liên kết Suffix cho phép chúng tôi đặt lại điểm hoạt động để chúng tôi có thể thực hiện thao tác chèn tiếp theo với nỗ lực O (1). Nhìn vào biểu đồ trên để xác nhận rằng thực sự nút tại nhãn abđược liên kết với nút tại b(hậu tố của nó) và nút tại abcđược liên kết đến bc.

Bước hiện tại vẫn chưa kết thúc. remainderbây giờ là 2 và chúng ta cần tuân theo quy tắc 3 để thiết lập lại điểm hoạt động. Vì hiện tại active_node(màu đỏ ở trên) không có liên kết hậu tố, chúng tôi đặt lại để root. Các điểm hoạt động là bây giờ (root,'c',1).

Do đó, lần chèn tiếp theo xảy ra ở một cạnh đi ra của nút gốc có nhãn bắt đầu bằng c: cabxabcd, đằng sau ký tự đầu tiên, tức là phía sau c. Điều này gây ra sự chia rẽ khác:

Và vì điều này liên quan đến việc tạo một nút nội bộ mới, chúng tôi tuân theo quy tắc 2 và đặt liên kết hậu tố mới từ nút nội bộ được tạo trước đó:

(Tôi đang sử dụng Graphviz Dot cho các đồ thị nhỏ. Các liên kết hậu tố mới gây ra dấu chấm để sắp xếp lại các cạnh hiện có, nên kiểm tra cẩn thận để xác nhận rằng điều duy nhất đã được chèn trên là một liên kết hậu tố mới.)

Với điều này, remaindercó thể được đặt thành 1 và vì active_nodelà root, chúng tôi sử dụng quy tắc 1 để cập nhật điểm hoạt động thành (root,'d',0). Điều này có nghĩa là lần chèn cuối cùng của bước hiện tại là chèn một lệnh d gốc:

Đó là bước cuối cùng và chúng tôi đã hoàn thành. Có một số quan sát cuối cùng , mặc dù:

  • Trong mỗi bước chúng ta tiến #lên 1 vị trí. Điều này tự động cập nhật tất cả các nút lá trong thời gian O (1).

  • Nhưng nó không xử lý a) bất kỳ hậu tố nào còn lại từ các bước trước và b) với một ký tự cuối cùng của bước hiện tại.

  • remaindercho chúng tôi biết có bao nhiêu chèn thêm mà chúng tôi cần phải thực hiện. Các chèn này tương ứng một-một với các hậu tố cuối cùng của chuỗi kết thúc tại vị trí hiện tại #. Chúng tôi xem xét từng cái một và thực hiện chèn. Quan trọng: Mỗi lần chèn được thực hiện trong thời gian O (1) vì điểm hoạt động cho chúng ta biết chính xác nơi cần đi và chúng ta chỉ cần thêm một ký tự duy nhất tại điểm hoạt động. Tại sao? Bởi vì các ký tự khác được chứa ẩn (nếu không thì điểm hoạt động sẽ không ở vị trí của nó).

  • Sau mỗi lần chèn như vậy, chúng tôi giảm dần remaindervà theo liên kết hậu tố nếu có. Nếu không chúng ta đi root (quy tắc 3). Nếu chúng tôi đã root, chúng tôi sửa đổi điểm hoạt động bằng quy tắc 1. Trong mọi trường hợp, chỉ mất O (1) thời gian.

  • Nếu, trong một trong những lần chèn này, chúng tôi thấy rằng ký tự chúng tôi muốn chèn đã ở đó, chúng tôi không làm gì cả và kết thúc bước hiện tại, ngay cả khi remainder> 0. Lý do là bất kỳ phần chèn nào còn lại sẽ là hậu tố của cái mà chúng ta vừa thử thực hiện. Do đó tất cả chúng đều ẩn trong cây hiện tại. Thực tế là remainder> 0 đảm bảo chúng ta xử lý các hậu tố còn lại sau này.

  • Nếu ở cuối thuật toán remainder> 0 thì sao? Đây sẽ là trường hợp bất cứ khi nào phần cuối của văn bản là một chuỗi con xảy ra ở đâu đó trước đó. Trong trường hợp đó, chúng ta phải nối thêm một ký tự ở cuối chuỗi chưa xảy ra trước đó. Trong các tài liệu, thông thường ký hiệu đô la $được sử dụng như một biểu tượng cho điều đó. Tại sao lại là vấn đề đó? -> Nếu sau này chúng ta sử dụng cây hậu tố đã hoàn thành để tìm kiếm hậu tố, chúng ta phải chấp nhận kết quả khớp chỉ khi chúng kết thúc tại một chiếc lá . Nếu không, chúng ta sẽ nhận được rất nhiều kết quả trùng khớp, bởi vì có nhiều chuỗi ẩn trong cây không phải là hậu tố thực sự của chuỗi chính. Buộcremaindervề 0 ở cuối về cơ bản là một cách để đảm bảo rằng tất cả các hậu tố kết thúc tại một nút lá. Tuy nhiên, nếu chúng ta muốn sử dụng cây để tìm kiếm các chuỗi con chung , không chỉ các hậu tố của chuỗi chính, bước cuối cùng này thực sự không bắt buộc, như đề xuất của bình luận của OP dưới đây.

  • Vậy độ phức tạp của toàn bộ thuật toán là gì? Nếu văn bản có độ dài n ký tự, rõ ràng có n bước (hoặc n + 1 nếu chúng ta thêm ký hiệu đô la). Trong mỗi bước, chúng tôi sẽ không làm gì cả (ngoài việc cập nhật các biến) hoặc chúng tôi thực hiện remainderchèn, mỗi lần lấy O (1) thời gian. Vì remaindercho biết số lần chúng tôi không làm gì trong các bước trước đó và được giảm cho mỗi lần chèn mà chúng tôi thực hiện bây giờ, tổng số lần chúng tôi làm một cái gì đó chính xác là n (hoặc n + 1). Do đó, tổng độ phức tạp là O (n).

  • Tuy nhiên, có một điều nhỏ mà tôi đã không giải thích chính xác: Có thể xảy ra việc chúng ta theo một liên kết hậu tố, cập nhật điểm hoạt động và sau đó thấy rằng active_lengththành phần của nó không hoạt động tốt với cái mới active_node. Ví dụ, hãy xem xét một tình huống như thế này:

(Các đường đứt nét chỉ phần còn lại của cây. Đường chấm chấm là một liên kết hậu tố.)

Bây giờ chúng ta hãy điểm hoạt động được (red,'d',3), vì vậy nó trỏ đến nơi phía sau ftrên defgmép. Bây giờ giả sử chúng tôi đã thực hiện các cập nhật cần thiết và bây giờ theo liên kết hậu tố để cập nhật điểm hoạt động theo quy tắc 3. Điểm hoạt động mới là (green,'d',3). Tuy nhiên, d-edge đi ra khỏi nút màu xanh lá cây de, vì vậy nó chỉ có 2 ký tự. Để tìm điểm hoạt động chính xác, rõ ràng chúng ta cần phải theo cạnh đó đến nút màu xanh và đặt lại (blue,'f',1).

Trong trường hợp đặc biệt xấu, active_lengthcó thể lớn bằng remainder, có thể lớn bằng n. Và rất có thể xảy ra rằng để tìm ra điểm hoạt động chính xác, chúng ta không chỉ cần nhảy qua một nút bên trong, mà có lẽ nhiều, đến n trong trường hợp xấu nhất. Điều đó có nghĩa là thuật toán có độ phức tạp O (n 2 ) ẩn, bởi vì trong mỗi bước remainderthường là O (n) và các điều chỉnh sau cho nút hoạt động sau khi theo liên kết hậu tố cũng có thể là O (n)?

Không. Lý do là nếu thực sự chúng ta phải điều chỉnh điểm hoạt động (ví dụ từ màu xanh sang màu xanh như trên), điều đó đưa chúng ta đến một nút mới có liên kết hậu tố riêng và active_lengthsẽ bị giảm. Khi chúng tôi theo dõi chuỗi liên kết hậu tố, chúng tôi thực hiện các lần chèn còn lại, active_lengthchỉ có thể giảm và số lần điều chỉnh điểm hoạt động mà chúng tôi có thể thực hiện trên đường không thể lớn hơn active_lengthbất kỳ lúc nào. Vì active_lengthkhông bao giờ có thể lớn hơn remainderremainder O (n) không chỉ trong mỗi bước, mà tổng số gia tăng từng được thực hiện remaindertrong toàn bộ quá trình là O (n), số lần điều chỉnh điểm hoạt động là cũng giới hạn bởi O (n).


74
Xin lỗi điều này đã kết thúc lâu hơn một chút so với tôi hy vọng. Và tôi nhận ra nó giải thích một số điều tầm thường mà tất cả chúng ta đều biết, trong khi những phần khó khăn vẫn có thể không hoàn toàn rõ ràng. Chúng ta hãy chỉnh sửa nó thành hình cùng nhau.
jogojapan

69
Và tôi nên nói thêm rằng điều này không dựa trên mô tả được tìm thấy trong cuốn sách của Dan Gusfield. Đây là một nỗ lực mới trong việc mô tả thuật toán bằng cách trước tiên xem xét một chuỗi không có sự lặp lại và sau đó thảo luận về cách lặp lại được xử lý. Tôi hy vọng rằng sẽ trực quan hơn.
jogojapan

8
Cảm ơn @jogojapan, tôi đã có thể viết một ví dụ hoạt động đầy đủ nhờ lời giải thích của bạn. Tôi đã xuất bản nguồn này để hy vọng ai đó có thể tìm thấy nó sử dụng: gist.github.com/2373868
Nathan Ridley

4
@NathanRidley Có (nhân tiện, bit cuối cùng đó là thứ mà Ukkonen gọi là canonicize). Một cách để kích hoạt nó là đảm bảo có một chuỗi con xuất hiện ba lần và kết thúc bằng một chuỗi xuất hiện thêm một lần nữa trong một bối cảnh khác. Ví dụ abcdefabxybcdmnabcdex. Phần ban đầu abcdđược lặp lại trong abxy(điều này tạo ra một nút bên trong sau ab) và một lần nữa abcdex, và nó kết thúc bcd, nó xuất hiện không chỉ trong bcdexbối cảnh, mà còn trong bcdmnbối cảnh. Sau khi abcdexđược chèn, chúng tôi theo liên kết hậu tố để chèn bcdexvà ở đó canonicize sẽ khởi động.
jogojapan

6
Ok mã của tôi đã được viết lại hoàn toàn và bây giờ hoạt động chính xác cho tất cả các trường hợp, bao gồm cả tự động hóa, cộng với có một đầu ra biểu đồ văn bản đẹp hơn nhiều. gist.github.com/2373868
Nathan Ridley

132

Tôi đã cố gắng thực hiện Cây Suffix với cách tiếp cận được đưa ra trong câu trả lời của jogojapan, nhưng nó không hoạt động trong một số trường hợp do từ ngữ được sử dụng cho các quy tắc. Hơn nữa, tôi đã đề cập rằng không ai quản lý để thực hiện một cây hậu tố hoàn toàn chính xác bằng cách sử dụng phương pháp này. Dưới đây tôi sẽ viết một "tổng quan" về câu trả lời của jogojapan với một số sửa đổi cho các quy tắc. Tôi cũng sẽ mô tả trường hợp khi chúng ta quên tạo các liên kết hậu tố quan trọng .

Các biến bổ sung được sử dụng

  1. điểm hoạt động - một bộ ba (active_node; active_edge; active_length), hiển thị từ nơi chúng ta phải bắt đầu chèn một hậu tố mới.
  2. phần còn lại - hiển thị số lượng hậu tố chúng ta phải thêm một cách rõ ràng . Ví dụ: nếu từ của chúng tôi là 'abcaabca' và phần còn lại = 3, điều đó có nghĩa là chúng tôi phải xử lý 3 hậu tố cuối cùng: bca , caa .

Chúng ta hãy sử dụng khái niệm về một nút bên trong - tất cả các nút, ngoại trừ gốc và các các nút bên trong .

Quan sát 1

Khi hậu tố cuối cùng chúng ta cần chèn đã được tìm thấy tồn tại trong cây, bản thân cây không bị thay đổi (chúng tôi chỉ cập nhật active pointremainder).

Quan sát 2

Nếu tại một thời điểm nào đó active_lengthlớn hơn hoặc bằng độ dài của cạnh hiện tại ( edge_length), chúng ta sẽ di chuyển active pointxuống cho đến khi edge_lengthlớn hơn active_length.

Bây giờ, hãy xác định lại các quy tắc:

Quy tắc 1

Nếu sau khi chèn từ nút hoạt động = root , độ dài hoạt động lớn hơn 0, thì:

  1. nút hoạt động không thay đổi
  2. chiều dài hoạt động được giảm
  3. cạnh hoạt động được dịch chuyển sang phải (sang ký tự đầu tiên của hậu tố tiếp theo, chúng ta phải chèn)

Quy tắc 2

Nếu chúng ta tạo một nút nội bộ mới HOẶC tạo một bộ chèn từ một nút bên trong và đây không phải là nút nội bộ SUCH đầu tiên ở bước hiện tại, thì chúng ta liên kết nút SUCH trước đó với nút NÀY thông qua liên kết hậu tố .

Định nghĩa Rule 2này khác với jogojapan ', vì ở đây chúng tôi tính đến không chỉ các nút nội bộ mới được tạo , mà cả các nút bên trong, từ đó chúng tôi thực hiện chèn.

Quy tắc 3

Sau khi chèn từ nút hoạt động không phải là nút gốc , chúng ta phải theo liên kết hậu tố và đặt nút hoạt động thành nút mà nó trỏ tới. Nếu không có một mối liên hệ hậu tố, thiết lập các nút tích cực đến gốc node. Dù bằng cách nào, cạnh hoạt độngchiều dài hoạt động không thay đổi.

Trong định nghĩa này, Rule 3chúng tôi cũng xem xét việc chèn các nút lá (không chỉ các nút chia).

Và cuối cùng, Quan sát 3:

Khi biểu tượng chúng ta muốn thêm vào cây đã ở rìa, chúng ta, theo đó Observation 1, chỉ cập nhật active pointremainder, để cây không thay đổi. NHƯNG nếu có một nút bên trong được đánh dấu là cần liên kết hậu tố , chúng ta phải kết nối nút đó với hiện tại active nodethông qua một liên kết hậu tố.

Hãy xem ví dụ về cây hậu tố cho cdddcdc nếu chúng ta thêm liên kết hậu tố trong trường hợp đó và nếu chúng ta không:

  1. Nếu chúng ta KHÔNG kết nối các nút thông qua liên kết hậu tố:

    • trước khi thêm chữ cái cuối c :

    • sau khi thêm chữ cái cuối c :

  2. Nếu chúng ta DO kết nối các nút thông qua một liên kết hậu tố:

    • trước khi thêm chữ cái cuối c :

    • sau khi thêm chữ cái cuối c :

Có vẻ như không có sự khác biệt đáng kể: trong trường hợp thứ hai có thêm hai liên kết hậu tố. Nhưng các liên kết hậu tố này là chính xác , và một trong số chúng - từ nút màu xanh đến nút màu đỏ - rất quan trọng đối với cách tiếp cận của chúng tôi với điểm hoạt động . Vấn đề là nếu chúng ta không đặt một liên kết hậu tố ở đây, sau này, khi chúng ta thêm một số chữ cái mới vào cây, chúng ta có thể bỏ qua việc thêm một số nút vào cây do Rule 3, bởi vì, theo nó, nếu không có liên kết hậu tố, sau đó chúng ta phải đặt active_nodegốc.

Khi chúng tôi thêm chữ cái cuối cùng vào cây, nút màu đỏ đã tồn tại trước khi chúng tôi thực hiện thao tác chèn từ nút màu xanh (cạnh labled 'c' ). Vì có một chèn từ nút màu xanh, chúng tôi đánh dấu nó là cần một liên kết hậu tố . Sau đó, dựa vào cách tiếp cận điểm hoạt động , nút active nodeđược đặt thành nút đỏ. Nhưng chúng tôi không thực hiện chèn từ nút đỏ, vì chữ 'c' đã ở trên cạnh. Có nghĩa là nút màu xanh phải được để lại mà không có liên kết hậu tố? Không, chúng ta phải kết nối nút màu xanh với nút màu đỏ thông qua liên kết hậu tố. Tại sao nó đúng? Vì điểm hoạt độngCách tiếp cận đảm bảo rằng chúng ta đến đúng nơi, nghĩa là đến địa điểm tiếp theo nơi chúng ta phải xử lý một đoạn chèn hậu tố ngắn hơn .

Cuối cùng, đây là những triển khai của tôi về Cây Suffix:

  1. Java
  2. C ++

Hy vọng rằng "tổng quan" này kết hợp với câu trả lời chi tiết của jogojapan sẽ giúp ai đó thực hiện Cây Suffix của riêng mình.


3
Cảm ơn rất nhiều và +1 cho bạn nỗ lực. Tôi chắc chắn rằng bạn đúng .. mặc dù tôi không có thời gian để suy nghĩ về các chi tiết ngay lập tức. Tôi cũng sẽ kiểm tra sau và có thể sửa đổi câu trả lời của tôi.
jogojapan

Cảm ơn rất nhiều, nó thực sự có ích. Mặc dù, bạn có thể cụ thể hơn về Quan sát 3 không? Ví dụ, đưa ra sơ đồ của 2 bước giới thiệu liên kết hậu tố mới. Là nút liên kết nút hoạt động? (vì chúng tôi không thực sự chèn nút thứ 2)
dyesdyes

@makagonov Này, bạn có thể giúp tôi xây dựng cây hậu tố cho chuỗi "cdddcdc" của bạn không. Tôi hơi bối rối khi làm như vậy (các bước bắt đầu).
tariq zafar

3
Đối với quy tắc 3, một cách thông minh là đặt liên kết hậu tố của root thành root và (mặc định) đặt liên kết hậu tố của mỗi nút thành root. Vì vậy, chúng ta có thể tránh điều hòa và chỉ cần theo liên kết hậu tố.
sqd

1
aabaacaadlà một trong những trường hợp cho thấy việc thêm liên kết hậu tố có thể giảm thời gian cập nhật bộ ba. Kết luận trong hai đoạn cuối của bài đăng của jogojapan là sai. Nếu chúng ta không thêm các hậu tố liên kết bài đăng này được đề cập, độ phức tạp thời gian trung bình nên là O (nlong (n)) trở lên. Bởi vì phải mất thêm thời gian để đi bộ trên cây để tìm chính xác active_node.
IvanaGyro

10

Cảm ơn về hướng dẫn được giải thích tốt bởi @jogojapan , tôi đã triển khai thuật toán trong Python.

Một vài vấn đề nhỏ được đề cập bởi @jogojapan hóa ra phức tạp hơn tôi mong đợi và cần được xử lý rất cẩn thận. Tôi mất vài ngày để triển khai đủ mạnh (tôi cho là vậy). Các vấn đề và giải pháp được liệt kê dưới đây:

  1. Kết thúc vớiRemainder > 0 hóa ra tình huống này cũng có thể xảy ra trong bước mở ra , không chỉ là kết thúc của toàn bộ thuật toán. Khi điều đó xảy ra, chúng ta có thể giữ nguyên phần còn lại, mã hóa, hành động và hành vi không thay đổi , kết thúc bước mở ra hiện tại và bắt đầu một bước khác bằng cách tiếp tục gấp hoặc mở ra tùy thuộc vào chuỗi char tiếp theo trong chuỗi ban đầu có trên đường dẫn hiện tại hay không không phải.

  2. Leap Over Nodes: Khi chúng tôi theo liên kết hậu tố, hãy cập nhật điểm hoạt động và sau đó thấy rằng thành phần active_length của nó không hoạt động tốt với active_node mới. Chúng ta phải di chuyển về phía trước đến đúng nơi để tách hoặc chèn một chiếc lá. Quá trình này có thể không đơn giản bởi vì trong quá trình di chuyển hành động và hành động cứ thay đổi liên tục, khi bạn phải quay trở lại nút gốc , hành độnghành động có thể sai vì những di chuyển đó. Chúng tôi cần (các) biến bổ sung để giữ thông tin đó.

    nhập mô tả hình ảnh ở đây

Hai vấn đề khác bằng cách nào đó đã được @managonov chỉ ra

  1. Tách có thể thoái hóa Khi cố gắng phân tách một cạnh, đôi khi bạn sẽ thấy thao tác phân tách nằm ngay trên một nút. Trong trường hợp đó, chúng ta chỉ cần thêm một lá mới vào nút đó, coi nó như là một hoạt động phân tách cạnh tiêu chuẩn, có nghĩa là các liên kết hậu tố nếu có, nên được duy trì tương ứng.

  2. Liên kết Suffix ẩn Có một trường hợp đặc biệt khác phát sinh bởi vấn đề 1vấn đề 2 . Đôi khi chúng ta cần nhảy qua một số nút đến đúng điểm để phân tách, chúng ta có thể vượt qua điểm đúng nếu chúng ta di chuyển bằng cách so sánh chuỗi còn lại và nhãn đường dẫn. Trong trường hợp đó, liên kết hậu tố sẽ bị bỏ qua ngoài ý muốn, nếu có. Điều này có thể tránh được bằng cách nhớ đúng điểm khi di chuyển về phía trước. Liên kết hậu tố phải được duy trì nếu nút phân tách đã tồn tại hoặc thậm chí sự cố 1 xảy ra trong bước mở ra.

Cuối cùng, việc triển khai Python của tôi như sau:

Mẹo: Nó bao gồm một chức năng in cây ngây thơ trong đoạn mã trên, điều này rất quan trọng trong khi gỡ lỗi . Nó giúp tôi tiết kiệm rất nhiều thời gian và thuận tiện cho việc định vị các trường hợp đặc biệt.


10

Xin lỗi nếu câu trả lời của tôi có vẻ dư thừa, nhưng tôi đã thực hiện thuật toán của Ukkonen gần đây và thấy mình phải vật lộn với nó trong nhiều ngày; Tôi đã phải đọc qua nhiều bài báo về chủ đề này để hiểu lý do tại sao và làm thế nào một số khía cạnh cốt lõi của thuật toán.

Tôi thấy cách tiếp cận 'quy tắc' của các câu trả lời trước đây không có ích để hiểu lý do cơ bản , vì vậy tôi đã viết mọi thứ bên dưới chỉ tập trung vào tính thực dụng. Nếu bạn đã vật lộn với việc làm theo các giải thích khác, giống như tôi đã làm, có lẽ giải thích bổ sung của tôi sẽ làm cho nó 'nhấp chuột' cho bạn.

Tôi đã xuất bản triển khai C # của mình tại đây: https://github.com/baratgabor/SuffixTree

Xin lưu ý rằng tôi không phải là chuyên gia về chủ đề này, vì vậy các phần sau có thể chứa những điểm không chính xác (hoặc tệ hơn). Nếu bạn gặp phải bất kỳ, hãy chỉnh sửa.

Điều kiện tiên quyết

Điểm bắt đầu của lời giải thích sau đây giả định rằng bạn quen thuộc với nội dung và cách sử dụng cây hậu tố và các đặc điểm của thuật toán Ukkonen, ví dụ như cách bạn mở rộng ký tự cây hậu tố theo từng ký tự, từ đầu đến cuối. Về cơ bản, tôi cho rằng bạn đã đọc một số giải thích khác.

(Tuy nhiên, tôi đã phải thêm một số tường thuật cơ bản cho dòng chảy, vì vậy phần đầu thực sự có thể cảm thấy dư thừa.)

Phần thú vị nhất là phần giải thích về sự khác biệt giữa việc sử dụng các liên kết hậu tố và quét lại từ gốc . Đây là những gì đã cho tôi rất nhiều lỗi và đau đầu trong việc thực hiện.

Các nút lá kết thúc mở và những hạn chế của chúng

Tôi chắc chắn rằng bạn đã biết rằng 'mẹo' cơ bản nhất là nhận ra rằng chúng ta chỉ có thể để phần cuối của hậu tố 'mở', tức là tham chiếu độ dài hiện tại của chuỗi thay vì đặt kết thúc thành giá trị tĩnh. Bằng cách này khi chúng ta thêm các ký tự bổ sung, các ký tự đó sẽ được thêm vào tất cả các nhãn hậu tố mà không phải truy cập và cập nhật tất cả các ký tự đó.

Nhưng kết thúc mở này của hậu tố - vì lý do rõ ràng - chỉ hoạt động đối với các nút đại diện cho phần cuối của chuỗi, tức là các nút lá trong cấu trúc cây. Các hoạt động phân nhánh mà chúng ta thực hiện trên cây (việc thêm các nút nhánh và nút lá mới) sẽ không tự động lan truyền ở mọi nơi mà chúng cần.

Có lẽ là cơ bản, và sẽ không yêu cầu đề cập rằng các chất nền lặp đi lặp lại không xuất hiện rõ ràng trong cây, vì cây đã chứa những thứ này bởi vì chúng là sự lặp lại; tuy nhiên, khi chuỗi con lặp đi lặp lại kết thúc bằng cách gặp một ký tự không lặp lại, chúng ta cần tạo một nhánh tại điểm đó để thể hiện sự phân kỳ từ điểm đó trở đi.

Ví dụ, trong trường hợp chuỗi 'ABCXABCY' (xem bên dưới), một nhánh của XY cần được thêm vào ba hậu tố khác nhau, ABC , BCC ; nếu không, nó sẽ không phải là cây hậu tố hợp lệ và chúng ta không thể tìm thấy tất cả các chuỗi con của chuỗi bằng cách ghép các ký tự từ gốc trở xuống.

Một lần nữa, để nhấn mạnh - bất kỳ thao tác nào chúng ta thực hiện trên hậu tố trong cây cũng cần được phản ánh bằng các hậu tố liên tiếp của nó (ví dụ ABC> BC> C), nếu không, chúng chỉ đơn giản là không còn là hậu tố hợp lệ.

Lặp lại phân nhánh trong hậu tố

Nhưng ngay cả khi chúng tôi chấp nhận rằng chúng tôi phải thực hiện các cập nhật thủ công này, làm thế nào để chúng tôi biết có bao nhiêu hậu tố cần được cập nhật? Vì, khi chúng ta thêm ký tự lặp lại A (và phần còn lại của các ký tự lặp lại liên tiếp), chúng ta không biết khi nào / nơi nào chúng ta cần chia hậu tố thành hai nhánh. Nhu cầu phân tách chỉ được xác định khi chúng ta gặp ký tự không lặp lại đầu tiên, trong trường hợp này là Y (thay vì X đã tồn tại trong cây).

Những gì chúng ta có thể làm là khớp chuỗi dài nhất có thể lặp lại và đếm xem có bao nhiêu hậu tố của nó mà chúng ta cần cập nhật sau. Đây là những gì "phần còn lại" là viết tắt của.

Khái niệm 'phần còn lại' và 'quét lại'

Biến remaindercho chúng ta biết có bao nhiêu ký tự lặp đi lặp lại mà chúng ta đã thêm vào mà không phân nhánh; tức là có bao nhiêu hậu tố chúng ta cần truy cập để lặp lại thao tác phân nhánh một khi chúng ta tìm thấy ký tự đầu tiên mà chúng ta không thể khớp. Điều này về cơ bản tương đương với bao nhiêu ký tự 'sâu' chúng ta ở trong cây từ gốc.

Vì vậy, theo ví dụ trước của chuỗi ABCXABCY , chúng ta khớp với phần ABC lặp lại 'ngầm', tăng dần remaindermỗi lần, kết quả là còn lại 3. Sau đó, chúng ta bắt gặp ký tự không lặp lại 'Y' . Ở đây chúng ta chia thêm trước ABCX vào ABC -> XABC -> Y . Sau đó, chúng tôi giảm remaindertừ 3 ​​xuống 2, vì chúng tôi đã chăm sóc nhánh ABC . Bây giờ chúng tôi lặp lại thao tác bằng cách khớp 2 ký tự cuối cùng - BC - từ gốc để đến điểm cần chia và chúng tôi cũng chia BCX thành BC-> XBC -> Y . Một lần nữa, chúng tôi giảm remainderxuống 1 và lặp lại thao tác; cho đến khi bằng remainder0. Cuối cùng, chúng ta cũng cần thêm chính ký tự ( Y ) vào gốc.

Hoạt động này, theo các hậu tố liên tiếp từ gốc chỉ đơn giản là đạt đến điểm mà chúng ta cần thực hiện một thao tác là cái gọi là 'quét lại' trong thuật toán của Ukkonen và thông thường đây là phần đắt nhất của thuật toán. Hãy tưởng tượng một chuỗi dài hơn mà bạn cần 'quét lại' các chuỗi con dài, qua nhiều hàng chục nút (chúng ta sẽ thảo luận về điều này sau), có khả năng hàng ngàn lần.

Như một giải pháp, chúng tôi giới thiệu cái mà chúng tôi gọi là "liên kết hậu tố" .

Khái niệm 'liên kết hậu tố'

Các liên kết Suffix về cơ bản chỉ đến các vị trí mà chúng ta thường phải 'quét lại' , vì vậy thay vì thao tác quét lại đắt tiền, chúng ta có thể chỉ cần nhảy đến vị trí được liên kết, thực hiện công việc của mình, nhảy đến vị trí được liên kết tiếp theo và lặp lại - cho đến khi không còn vị trí để cập nhật.

Tất nhiên một câu hỏi lớn là làm thế nào để thêm các liên kết này. Câu trả lời hiện tại là chúng ta có thể thêm các liên kết khi chúng ta chèn các nút nhánh mới, sử dụng thực tế là, trong mỗi phần mở rộng của cây, các nút nhánh được tạo tự nhiên lần lượt theo thứ tự chính xác mà chúng ta cần liên kết chúng lại với nhau . Mặc dù vậy, chúng ta phải liên kết từ nút nhánh được tạo cuối cùng (hậu tố dài nhất) với nút được tạo trước đó, vì vậy chúng ta cần lưu trữ bộ đệm cuối cùng chúng ta tạo, liên kết nó với nút tiếp theo chúng ta tạo và lưu trữ bộ đệm mới được tạo.

Một hậu quả là chúng ta thực sự thường không có các liên kết hậu tố để theo dõi, bởi vì nút nhánh đã cho đã được tạo. Trong những trường hợp này, chúng ta vẫn phải quay trở lại 'quét lại' từ gốc. Đây là lý do tại sao, sau khi chèn, bạn được hướng dẫn sử dụng liên kết hậu tố hoặc nhảy đến root.

(Hoặc cách khác, nếu bạn đang lưu trữ các con trỏ cha mẹ trong các nút, bạn có thể thử theo dõi cha mẹ, kiểm tra xem chúng có liên kết hay không và sử dụng nó. Tôi thấy rằng điều này rất hiếm khi được đề cập, nhưng việc sử dụng liên kết hậu tố không thiết lập trong đá. có rất nhiều phương pháp có thể, và nếu bạn hiểu được cơ chế cơ bản bạn có thể thực hiện một phù hợp với nhu cầu của bạn là tốt nhất.)

Khái niệm 'điểm hoạt động'

Cho đến nay chúng tôi đã thảo luận về nhiều công cụ hiệu quả để xây dựng cây và mơ hồ đề cập đến việc đi qua nhiều cạnh và nút, nhưng vẫn chưa khám phá các hậu quả và sự phức tạp tương ứng.

Khái niệm 'phần còn lại' được giải thích trước đây rất hữu ích để theo dõi chúng ta đang ở đâu trên cây, nhưng chúng ta phải nhận ra rằng nó không lưu trữ đủ thông tin.

Đầu tiên, chúng ta luôn nằm trên một cạnh cụ thể của một nút, vì vậy chúng ta cần lưu trữ thông tin cạnh. Chúng ta sẽ gọi đây là "cạnh hoạt động" .

Thứ hai, ngay cả sau khi thêm thông tin cạnh, chúng ta vẫn không có cách nào để xác định vị trí nằm xa hơn trong cây và không được kết nối trực tiếp với nút gốc . Vì vậy, chúng ta cần phải lưu trữ các nút là tốt. Hãy gọi đây là 'nút hoạt động' .

Cuối cùng, chúng ta có thể nhận thấy rằng 'phần còn lại' không đủ để xác định vị trí trên một cạnh không được kết nối trực tiếp với root, vì 'phần còn lại' là độ dài của toàn bộ tuyến đường; và có lẽ chúng ta không muốn bận tâm đến việc ghi nhớ và trừ đi độ dài của các cạnh trước đó. Vì vậy, chúng ta cần một đại diện về cơ bản là phần còn lại trên cạnh hiện tại . Đây là những gì chúng ta gọi là "chiều dài hoạt động" .

Điều này dẫn đến cái mà chúng ta gọi là 'điểm hoạt động' - gói gồm ba biến chứa tất cả thông tin chúng ta cần để duy trì về vị trí của chúng ta trong cây:

Active Point = (Active Node, Active Edge, Active Length)

Bạn có thể quan sát hình ảnh sau đây về tuyến đường phù hợp của ABCABD bao gồm 2 ký tự ở cạnh AB (từ gốc ), cộng với 4 ký tự trên cạnh CABDABCABD (từ nút 4) - dẫn đến một 'số dư' gồm 6 ký tự. Vì vậy, vị trí hiện tại của chúng tôi có thể được xác định là Active Node 4, Active Edge C, Active length 4 .

Phần còn lại và điểm hoạt động

Một vai trò quan trọng khác của 'điểm hoạt động' là nó cung cấp lớp trừu tượng cho thuật toán của chúng tôi, nghĩa là các phần trong thuật toán của chúng tôi có thể thực hiện công việc của chúng trên 'điểm hoạt động' , bất kể điểm hoạt động đó nằm ở gốc hay bất kỳ nơi nào khác . Điều này giúp dễ dàng thực hiện việc sử dụng các liên kết hậu tố trong thuật toán của chúng tôi một cách rõ ràng và đơn giản.

Sự khác nhau của quét lại so với sử dụng liên kết hậu tố

Bây giờ, phần khó khăn, một cái gì đó - theo kinh nghiệm của tôi - có thể gây ra nhiều lỗi và đau đầu, và được giải thích kém trong hầu hết các nguồn, là sự khác biệt trong việc xử lý các trường hợp liên kết hậu tố so với các trường hợp quét lại.

Hãy xem xét ví dụ sau về chuỗi 'AAAABAAAABAAC' :

Phần còn lại trên nhiều cạnh

Bạn có thể quan sát ở trên cách 'phần còn lại' của 7 tương ứng với tổng số ký tự từ gốc, trong khi 'độ dài hoạt động' của 4 tương ứng với tổng số ký tự trùng khớp từ cạnh hoạt động của nút hoạt động.

Bây giờ, sau khi thực hiện thao tác phân nhánh tại điểm hoạt động, nút hoạt động của chúng tôi có thể có hoặc không chứa liên kết hậu tố.

Nếu có liên kết hậu tố: Chúng tôi chỉ cần xử lý phần 'độ dài hoạt động' . Các 'còn lại' là không thích hợp, bởi vì các nút nơi chúng tôi chuyển đến thông qua liên kết hậu tố đã mã hóa đúng 'còn' ngầm , chỉ cần nhờ là trong cây nơi nó được.

Nếu không có liên kết hậu tố: Chúng ta cần 'quét lại' từ zero / root, nghĩa là xử lý toàn bộ hậu tố từ đầu. Để kết thúc này, chúng ta phải sử dụng toàn bộ 'phần còn lại' làm cơ sở cho việc quét lại.

Ví dụ so sánh xử lý có và không có liên kết hậu tố

Xem xét những gì xảy ra ở bước tiếp theo của ví dụ trên. Hãy so sánh làm thế nào để đạt được kết quả tương tự - tức là chuyển sang hậu tố tiếp theo để xử lý - có và không có liên kết hậu tố.

Sử dụng 'liên kết hậu tố'

Tiếp cận các hậu tố liên tiếp thông qua các liên kết hậu tố

Lưu ý rằng nếu chúng tôi sử dụng liên kết hậu tố, chúng tôi sẽ tự động 'ở đúng nơi'. Điều này thường không đúng hoàn toàn do thực tế là 'độ dài hoạt động' có thể 'không tương thích' với vị trí mới.

Trong trường hợp trên, vì 'độ dài hoạt động' là 4, chúng tôi đang làm việc với hậu tố ' ABAA' , bắt đầu từ Nút được liên kết 4. Nhưng sau khi tìm thấy cạnh tương ứng với ký tự đầu tiên của hậu tố ( 'A' ), chúng tôi nhận thấy rằng 'độ dài hoạt động' của chúng tôi vượt quá cạnh này 3 ký tự. Vì vậy, chúng tôi nhảy qua cạnh đầy đủ, đến nút tiếp theo và giảm 'độ dài hoạt động' theo các ký tự mà chúng tôi đã sử dụng với bước nhảy.

Sau đó, sau khi chúng tôi tìm thấy cạnh tiếp theo 'B' , tương ứng với hậu tố giảm dần 'BAA ', cuối cùng chúng tôi lưu ý rằng độ dài cạnh lớn hơn 'độ dài hoạt động' còn lại là 3, có nghĩa là chúng tôi đã tìm đúng vị trí.

Xin lưu ý rằng có vẻ như thao tác này thường không được gọi là 'quét lại', mặc dù với tôi có vẻ như nó tương đương trực tiếp với quét lại, chỉ với một chiều dài rút ngắn và điểm bắt đầu không gốc.

Sử dụng 'quét lại'

Tiếp cận các hậu tố liên tiếp thông qua việc quét lại

Lưu ý rằng nếu chúng ta sử dụng thao tác 'quét lại' truyền thống (ở đây giả vờ rằng chúng ta không có liên kết hậu tố), chúng ta bắt đầu ở ngọn cây, ở gốc và chúng ta phải đi xuống đúng chỗ, theo sau toàn bộ chiều dài của hậu tố hiện tại.

Độ dài của hậu tố này là 'phần còn lại' mà chúng ta đã thảo luận trước đây. Chúng ta phải tiêu thụ toàn bộ phần còn lại này, cho đến khi nó đạt đến không. Điều này có thể (và thường không) bao gồm nhảy qua nhiều nút, tại mỗi lần nhảy giảm phần còn lại theo chiều dài của cạnh chúng ta nhảy qua. Cuối cùng, chúng tôi đạt đến một cạnh dài hơn 'phần còn lại' còn lại của chúng tôi ; ở đây, chúng tôi đặt cạnh hoạt động thành cạnh đã cho, đặt 'độ dài hoạt động' thành 'phần còn lại ' và chúng tôi đã hoàn tất.

Tuy nhiên, lưu ý rằng biến 'phần còn lại' thực tế cần được bảo tồn và chỉ giảm sau mỗi lần chèn nút. Vì vậy, những gì tôi mô tả ở trên giả định sử dụng một biến riêng biệt được khởi tạo thành 'phần còn lại' .

Ghi chú về liên kết hậu tố & cứu hộ

1) Lưu ý rằng cả hai phương pháp đều dẫn đến cùng một kết quả. Nhảy liên kết Suffix, tuy nhiên, nhanh hơn đáng kể trong hầu hết các trường hợp; đó là toàn bộ lý do đằng sau các liên kết hậu tố.

2) Việc triển khai thuật toán thực tế không cần phải khác nhau. Như tôi đã đề cập ở trên, ngay cả trong trường hợp sử dụng liên kết hậu tố, 'độ dài hoạt động' thường không tương thích với vị trí được liên kết, vì nhánh cây đó có thể chứa phân nhánh bổ sung. Vì vậy, về cơ bản, bạn chỉ cần sử dụng 'độ dài hoạt động' thay vì 'phần còn lại' và thực hiện cùng logic quét lại cho đến khi bạn tìm thấy một cạnh ngắn hơn độ dài hậu tố còn lại của bạn.

3) Một lưu ý quan trọng liên quan đến hiệu suất là không cần kiểm tra từng nhân vật trong quá trình quét lại. Do cách xây dựng cây hậu tố hợp lệ, chúng ta có thể giả định rằng các ký tự khớp với nhau một cách an toàn. Vì vậy, bạn chủ yếu đếm độ dài và chỉ cần kiểm tra tương đương ký tự phát sinh khi chúng ta nhảy sang một cạnh mới, vì các cạnh được xác định bởi ký tự đầu tiên của chúng (luôn luôn là duy nhất trong ngữ cảnh của một nút nhất định). Điều này có nghĩa là logic 'quét lại' khác với logic khớp chuỗi đầy đủ (nghĩa là tìm kiếm một chuỗi con trong cây).

4) Liên kết hậu tố ban đầu được mô tả ở đây chỉ là một trong những cách tiếp cận có thể . Ví dụ NJ Larsson et al. đặt tên cho phương pháp này là từ trên xuống theo hướng Node và so sánh nó với từ dưới lên theo hướng Node và hai giống hướng theo hướng . Các cách tiếp cận khác nhau có hiệu suất, yêu cầu, giới hạn điển hình khác nhau và các trường hợp xấu nhất, v.v., nhưng nhìn chung các cách tiếp cận theo định hướng Edge là một cải tiến tổng thể so với bản gốc.


8

@jogojapan bạn đã mang đến lời giải thích và hình dung tuyệt vời. Nhưng như @makagonov đã đề cập, nó thiếu một số quy tắc liên quan đến việc thiết lập liên kết hậu tố. Nó nhìn thấy trong cách tốt đẹp khi đi từng bước trên http://brenden.github.io/ukkonen-animation/ qua từ 'aabaaabb'. Khi bạn đi từ bước 10 đến bước 11, không có liên kết hậu tố từ nút 5 đến nút 2 nhưng điểm hoạt động đột nhiên di chuyển đến đó.

@makagonov kể từ khi tôi sống trong thế giới Java, tôi cũng đã cố gắng theo dõi quá trình triển khai của bạn để nắm bắt quy trình xây dựng ST nhưng thật khó với tôi vì:

  • kết hợp các cạnh với các nút
  • sử dụng con trỏ chỉ mục thay vì tham chiếu
  • phá vỡ các tuyên bố;
  • tiếp tục phát biểu;

Vì vậy, tôi đã kết thúc việc triển khai như vậy trong Java mà tôi hy vọng phản ánh tất cả các bước theo cách rõ ràng hơn và sẽ giảm thời gian học tập cho những người Java khác:

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class ST {

  public class Node {
    private final int id;
    private final Map<Character, Edge> edges;
    private Node slink;

    public Node(final int id) {
        this.id = id;
        this.edges = new HashMap<>();
    }

    public void setSlink(final Node slink) {
        this.slink = slink;
    }

    public Map<Character, Edge> getEdges() {
        return this.edges;
    }

    public Node getSlink() {
        return this.slink;
    }

    public String toString(final String word) {
        return new StringBuilder()
                .append("{")
                .append("\"id\"")
                .append(":")
                .append(this.id)
                .append(",")
                .append("\"slink\"")
                .append(":")
                .append(this.slink != null ? this.slink.id : null)
                .append(",")
                .append("\"edges\"")
                .append(":")
                .append(edgesToString(word))
                .append("}")
                .toString();
    }

    private StringBuilder edgesToString(final String word) {
        final StringBuilder edgesStringBuilder = new StringBuilder();
        edgesStringBuilder.append("{");
        for(final Map.Entry<Character, Edge> entry : this.edges.entrySet()) {
            edgesStringBuilder.append("\"")
                    .append(entry.getKey())
                    .append("\"")
                    .append(":")
                    .append(entry.getValue().toString(word))
                    .append(",");
        }
        if(!this.edges.isEmpty()) {
            edgesStringBuilder.deleteCharAt(edgesStringBuilder.length() - 1);
        }
        edgesStringBuilder.append("}");
        return edgesStringBuilder;
    }

    public boolean contains(final String word, final String suffix) {
        return !suffix.isEmpty()
                && this.edges.containsKey(suffix.charAt(0))
                && this.edges.get(suffix.charAt(0)).contains(word, suffix);
    }
  }

  public class Edge {
    private final int from;
    private final int to;
    private final Node next;

    public Edge(final int from, final int to, final Node next) {
        this.from = from;
        this.to = to;
        this.next = next;
    }

    public int getFrom() {
        return this.from;
    }

    public int getTo() {
        return this.to;
    }

    public Node getNext() {
        return this.next;
    }

    public int getLength() {
        return this.to - this.from;
    }

    public String toString(final String word) {
        return new StringBuilder()
                .append("{")
                .append("\"content\"")
                .append(":")
                .append("\"")
                .append(word.substring(this.from, this.to))
                .append("\"")
                .append(",")
                .append("\"next\"")
                .append(":")
                .append(this.next != null ? this.next.toString(word) : null)
                .append("}")
                .toString();
    }

    public boolean contains(final String word, final String suffix) {
        if(this.next == null) {
            return word.substring(this.from, this.to).equals(suffix);
        }
        return suffix.startsWith(word.substring(this.from,
                this.to)) && this.next.contains(word, suffix.substring(this.to - this.from));
    }
  }

  public class ActivePoint {
    private final Node activeNode;
    private final Character activeEdgeFirstCharacter;
    private final int activeLength;

    public ActivePoint(final Node activeNode,
                       final Character activeEdgeFirstCharacter,
                       final int activeLength) {
        this.activeNode = activeNode;
        this.activeEdgeFirstCharacter = activeEdgeFirstCharacter;
        this.activeLength = activeLength;
    }

    private Edge getActiveEdge() {
        return this.activeNode.getEdges().get(this.activeEdgeFirstCharacter);
    }

    public boolean pointsToActiveNode() {
        return this.activeLength == 0;
    }

    public boolean activeNodeIs(final Node node) {
        return this.activeNode == node;
    }

    public boolean activeNodeHasEdgeStartingWith(final char character) {
        return this.activeNode.getEdges().containsKey(character);
    }

    public boolean activeNodeHasSlink() {
        return this.activeNode.getSlink() != null;
    }

    public boolean pointsToOnActiveEdge(final String word, final char character) {
        return word.charAt(this.getActiveEdge().getFrom() + this.activeLength) == character;
    }

    public boolean pointsToTheEndOfActiveEdge() {
        return this.getActiveEdge().getLength() == this.activeLength;
    }

    public boolean pointsAfterTheEndOfActiveEdge() {
        return this.getActiveEdge().getLength() < this.activeLength;
    }

    public ActivePoint moveToEdgeStartingWithAndByOne(final char character) {
        return new ActivePoint(this.activeNode, character, 1);
    }

    public ActivePoint moveToNextNodeOfActiveEdge() {
        return new ActivePoint(this.getActiveEdge().getNext(), null, 0);
    }

    public ActivePoint moveToSlink() {
        return new ActivePoint(this.activeNode.getSlink(),
                this.activeEdgeFirstCharacter,
                this.activeLength);
    }

    public ActivePoint moveTo(final Node node) {
        return new ActivePoint(node, this.activeEdgeFirstCharacter, this.activeLength);
    }

    public ActivePoint moveByOneCharacter() {
        return new ActivePoint(this.activeNode,
                this.activeEdgeFirstCharacter,
                this.activeLength + 1);
    }

    public ActivePoint moveToEdgeStartingWithAndByActiveLengthMinusOne(final Node node,
                                                                       final char character) {
        return new ActivePoint(node, character, this.activeLength - 1);
    }

    public ActivePoint moveToNextNodeOfActiveEdge(final String word, final int index) {
        return new ActivePoint(this.getActiveEdge().getNext(),
                word.charAt(index - this.activeLength + this.getActiveEdge().getLength()),
                this.activeLength - this.getActiveEdge().getLength());
    }

    public void addEdgeToActiveNode(final char character, final Edge edge) {
        this.activeNode.getEdges().put(character, edge);
    }

    public void splitActiveEdge(final String word,
                                final Node nodeToAdd,
                                final int index,
                                final char character) {
        final Edge activeEdgeToSplit = this.getActiveEdge();
        final Edge splittedEdge = new Edge(activeEdgeToSplit.getFrom(),
                activeEdgeToSplit.getFrom() + this.activeLength,
                nodeToAdd);
        nodeToAdd.getEdges().put(word.charAt(activeEdgeToSplit.getFrom() + this.activeLength),
                new Edge(activeEdgeToSplit.getFrom() + this.activeLength,
                        activeEdgeToSplit.getTo(),
                        activeEdgeToSplit.getNext()));
        nodeToAdd.getEdges().put(character, new Edge(index, word.length(), null));
        this.activeNode.getEdges().put(this.activeEdgeFirstCharacter, splittedEdge);
    }

    public Node setSlinkTo(final Node previouslyAddedNodeOrAddedEdgeNode,
                           final Node node) {
        if(previouslyAddedNodeOrAddedEdgeNode != null) {
            previouslyAddedNodeOrAddedEdgeNode.setSlink(node);
        }
        return node;
    }

    public Node setSlinkToActiveNode(final Node previouslyAddedNodeOrAddedEdgeNode) {
        return setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, this.activeNode);
    }
  }

  private static int idGenerator;

  private final String word;
  private final Node root;
  private ActivePoint activePoint;
  private int remainder;

  public ST(final String word) {
    this.word = word;
    this.root = new Node(idGenerator++);
    this.activePoint = new ActivePoint(this.root, null, 0);
    this.remainder = 0;
    build();
  }

  private void build() {
    for(int i = 0; i < this.word.length(); i++) {
        add(i, this.word.charAt(i));
    }
  }

  private void add(final int index, final char character) {
    this.remainder++;
    boolean characterFoundInTheTree = false;
    Node previouslyAddedNodeOrAddedEdgeNode = null;
    while(!characterFoundInTheTree && this.remainder > 0) {
        if(this.activePoint.pointsToActiveNode()) {
            if(this.activePoint.activeNodeHasEdgeStartingWith(character)) {
                activeNodeHasEdgeStartingWithCharacter(character, previouslyAddedNodeOrAddedEdgeNode);
                characterFoundInTheTree = true;
            }
            else {
                if(this.activePoint.activeNodeIs(this.root)) {
                    rootNodeHasNotEdgeStartingWithCharacter(index, character);
                }
                else {
                    previouslyAddedNodeOrAddedEdgeNode = internalNodeHasNotEdgeStartingWithCharacter(index,
                            character, previouslyAddedNodeOrAddedEdgeNode);
                }
            }
        }
        else {
            if(this.activePoint.pointsToOnActiveEdge(this.word, character)) {
                activeEdgeHasCharacter();
                characterFoundInTheTree = true;
            }
            else {
                if(this.activePoint.activeNodeIs(this.root)) {
                    previouslyAddedNodeOrAddedEdgeNode = edgeFromRootNodeHasNotCharacter(index,
                            character,
                            previouslyAddedNodeOrAddedEdgeNode);
                }
                else {
                    previouslyAddedNodeOrAddedEdgeNode = edgeFromInternalNodeHasNotCharacter(index,
                            character,
                            previouslyAddedNodeOrAddedEdgeNode);
                }
            }
        }
    }
  }

  private void activeNodeHasEdgeStartingWithCharacter(final char character,
                                                    final Node previouslyAddedNodeOrAddedEdgeNode) {
    this.activePoint.setSlinkToActiveNode(previouslyAddedNodeOrAddedEdgeNode);
    this.activePoint = this.activePoint.moveToEdgeStartingWithAndByOne(character);
    if(this.activePoint.pointsToTheEndOfActiveEdge()) {
        this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
    }
  }

  private void rootNodeHasNotEdgeStartingWithCharacter(final int index, final char character) {
    this.activePoint.addEdgeToActiveNode(character, new Edge(index, this.word.length(), null));
    this.activePoint = this.activePoint.moveTo(this.root);
    this.remainder--;
    assert this.remainder == 0;
  }

  private Node internalNodeHasNotEdgeStartingWithCharacter(final int index,
                                                         final char character,
                                                         Node previouslyAddedNodeOrAddedEdgeNode) {
    this.activePoint.addEdgeToActiveNode(character, new Edge(index, this.word.length(), null));
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkToActiveNode(previouslyAddedNodeOrAddedEdgeNode);
    if(this.activePoint.activeNodeHasSlink()) {
        this.activePoint = this.activePoint.moveToSlink();
    }
    else {
        this.activePoint = this.activePoint.moveTo(this.root);
    }
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private void activeEdgeHasCharacter() {
    this.activePoint = this.activePoint.moveByOneCharacter();
    if(this.activePoint.pointsToTheEndOfActiveEdge()) {
        this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
    }
  }

  private Node edgeFromRootNodeHasNotCharacter(final int index,
                                             final char character,
                                             Node previouslyAddedNodeOrAddedEdgeNode) {
    final Node newNode = new Node(idGenerator++);
    this.activePoint.splitActiveEdge(this.word, newNode, index, character);
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, newNode);
    this.activePoint = this.activePoint.moveToEdgeStartingWithAndByActiveLengthMinusOne(this.root,
            this.word.charAt(index - this.remainder + 2));
    this.activePoint = walkDown(index);
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private Node edgeFromInternalNodeHasNotCharacter(final int index,
                                                 final char character,
                                                 Node previouslyAddedNodeOrAddedEdgeNode) {
    final Node newNode = new Node(idGenerator++);
    this.activePoint.splitActiveEdge(this.word, newNode, index, character);
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, newNode);
    if(this.activePoint.activeNodeHasSlink()) {
        this.activePoint = this.activePoint.moveToSlink();
    }
    else {
        this.activePoint = this.activePoint.moveTo(this.root);
    }
    this.activePoint = walkDown(index);
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private ActivePoint walkDown(final int index) {
    while(!this.activePoint.pointsToActiveNode()
            && (this.activePoint.pointsToTheEndOfActiveEdge() || this.activePoint.pointsAfterTheEndOfActiveEdge())) {
        if(this.activePoint.pointsAfterTheEndOfActiveEdge()) {
            this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge(this.word, index);
        }
        else {
            this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
        }
    }
    return this.activePoint;
  }

  public String toString(final String word) {
    return this.root.toString(word);
  }

  public boolean contains(final String suffix) {
    return this.root.contains(this.word, suffix);
  }

  public static void main(final String[] args) {
    final String[] words = {
            "abcabcabc$",
            "abc$",
            "abcabxabcd$",
            "abcabxabda$",
            "abcabxad$",
            "aabaaabb$",
            "aababcabcd$",
            "ababcabcd$",
            "abccba$",
            "mississipi$",
            "abacabadabacabae$",
            "abcabcd$",
            "00132220$"
    };
    Arrays.stream(words).forEach(word -> {
        System.out.println("Building suffix tree for word: " + word);
        final ST suffixTree = new ST(word);
        System.out.println("Suffix tree: " + suffixTree.toString(word));
        for(int i = 0; i < word.length() - 1; i++) {
            assert suffixTree.contains(word.substring(i)) : word.substring(i);
        }
    });
  }
}

6

Trực giác của tôi như sau:

Sau k lần lặp của vòng lặp chính, bạn đã xây dựng một cây hậu tố chứa tất cả các hậu tố của chuỗi hoàn chỉnh bắt đầu trong k ký tự đầu tiên.

Khi bắt đầu, điều này có nghĩa là cây hậu tố chứa một nút gốc duy nhất đại diện cho toàn bộ chuỗi (đây là hậu tố duy nhất bắt đầu từ 0).

Sau khi lặp (chuỗi) len, bạn có một cây hậu tố chứa tất cả các hậu tố.

Trong vòng lặp, khóa là điểm hoạt động. Tôi đoán là điều này đại diện cho điểm sâu nhất trong cây hậu tố tương ứng với một hậu tố thích hợp của k ký tự đầu tiên của chuỗi. (Tôi nghĩ đúng có nghĩa là hậu tố không thể là toàn bộ chuỗi.)

Ví dụ: giả sử bạn đã thấy các ký tự 'abcabc'. Điểm hoạt động sẽ đại diện cho điểm trong cây tương ứng với hậu tố 'abc'.

Điểm hoạt động được đại diện bởi (nguồn gốc, đầu tiên, cuối cùng). Điều này có nghĩa là bạn hiện đang ở điểm trên cây mà bạn nhận được bằng cách bắt đầu tại điểm gốc của nút và sau đó cho ăn các ký tự trong chuỗi [trước: cuối]

Khi bạn thêm một ký tự mới, bạn sẽ xem liệu điểm hoạt động có còn trong cây hiện có hay không. Nếu có thì bạn đã xong. Nếu không, bạn cần thêm một nút mới vào cây hậu tố tại điểm hoạt động, dự phòng cho trận đấu ngắn nhất tiếp theo và kiểm tra lại.

Lưu ý 1: Các con trỏ hậu tố cung cấp một liên kết đến trận đấu ngắn nhất tiếp theo cho mỗi nút.

Lưu ý 2: Khi bạn thêm một nút mới và dự phòng, bạn thêm một con trỏ hậu tố mới cho nút mới. Đích cho con trỏ hậu tố này sẽ là nút tại điểm hoạt động rút ngắn. Nút này sẽ tồn tại hoặc được tạo trong lần lặp tiếp theo của vòng lặp dự phòng này.

Lưu ý 3: Phần chuẩn hóa chỉ đơn giản là tiết kiệm thời gian trong việc kiểm tra điểm hoạt động. Ví dụ: giả sử bạn luôn sử dụng origin = 0 và chỉ thay đổi đầu tiên và cuối cùng. Để kiểm tra điểm hoạt động, bạn sẽ phải theo dõi cây hậu tố mỗi lần dọc theo tất cả các nút trung gian. Có ý nghĩa để lưu trữ kết quả của việc theo đường dẫn này bằng cách chỉ ghi lại khoảng cách từ nút cuối cùng.

Bạn có thể đưa ra một ví dụ mã về ý nghĩa của bạn bằng cách "sửa" các biến ràng buộc không?

Cảnh báo về sức khỏe: Tôi cũng thấy thuật toán này đặc biệt khó hiểu nên xin vui lòng nhận ra rằng trực giác này có thể không chính xác trong tất cả các chi tiết quan trọng ...


Một trong những bài báo học thuật định nghĩa "thích hợp" có nghĩa là "hậu tố thích hợp" của một chuỗi không chứa ký tự đầu tiên của nó. Đôi khi bạn gọi toàn bộ chuỗi con là "hậu tố", nhưng khi xác định thuật toán, các thuật ngữ "chuỗi" và "chuỗi con" và "hậu tố" được ném xung quanh một cách tự do và đôi khi bạn cần phải hiểu rất rõ ý của bạn về "hậu tố", vì vậy thuật ngữ "hậu tố thích hợp" không bao gồm việc gọi toàn bộ hậu tố. Vì vậy, một chuỗi con hậu tố của một chuỗi có thể là bất kỳ chuỗi con hợp pháp nào và có thể có một hậu tố thích hợp không phải là hậu tố giống nhau. Vì logic.
Blair Houghton

3

Xin chào tôi đã cố gắng thực hiện các giải thích được thực hiện ở trên trong ruby, xin vui lòng kiểm tra xem nó. Nó dường như làm việc ổn.

Sự khác biệt duy nhất trong việc thực hiện là, tôi đã cố gắng sử dụng đối tượng cạnh thay vì chỉ sử dụng các ký hiệu.

nó cũng có mặt tại https://gist.github.com/suchitpuri/9304856

    require 'pry'


class Edge
    attr_accessor :data , :edges , :suffix_link
    def initialize data
        @data = data
        @edges = []
        @suffix_link = nil
    end

    def find_edge element
        self.edges.each do |edge|
            return edge if edge.data.start_with? element
        end
        return nil
    end
end

class SuffixTrees
    attr_accessor :root , :active_point , :remainder , :pending_prefixes , :last_split_edge , :remainder

    def initialize
        @root = Edge.new nil
        @active_point = { active_node: @root , active_edge: nil , active_length: 0}
        @remainder = 0
        @pending_prefixes = []
        @last_split_edge = nil
        @remainder = 1
    end

    def build string
        string.split("").each_with_index do |element , index|


            add_to_edges @root , element        

            update_pending_prefix element                           
            add_pending_elements_to_tree element
            active_length = @active_point[:active_length]

            # if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data[0..active_length-1] ==  @active_point[:active_edge].data[active_length..@active_point[:active_edge].data.length-1])
            #   @active_point[:active_edge].data = @active_point[:active_edge].data[0..active_length-1]
            #   @active_point[:active_edge].edges << Edge.new(@active_point[:active_edge].data)
            # end

            if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data.length == @active_point[:active_length]  )
                @active_point[:active_node] =  @active_point[:active_edge]
                @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0])
                @active_point[:active_length] = 0
            end
        end
    end

    def add_pending_elements_to_tree element

        to_be_deleted = []
        update_active_length = false
        # binding.pry
        if( @active_point[:active_node].find_edge(element[0]) != nil)
            @active_point[:active_length] = @active_point[:active_length] + 1               
            @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0]) if @active_point[:active_edge] == nil
            @remainder = @remainder + 1
            return
        end



        @pending_prefixes.each_with_index do |pending_prefix , index|

            # binding.pry           

            if @active_point[:active_edge] == nil and @active_point[:active_node].find_edge(element[0]) == nil

                @active_point[:active_node].edges << Edge.new(element)

            else

                @active_point[:active_edge] = node.find_edge(element[0]) if @active_point[:active_edge]  == nil

                data = @active_point[:active_edge].data
                data = data.split("")               

                location = @active_point[:active_length]


                # binding.pry
                if(data[0..location].join == pending_prefix or @active_point[:active_node].find_edge(element) != nil )                  


                else #tree split    
                    split_edge data , index , element
                end

            end
        end 
    end



    def update_pending_prefix element
        if @active_point[:active_edge] == nil
            @pending_prefixes = [element]
            return

        end

        @pending_prefixes = []

        length = @active_point[:active_edge].data.length
        data = @active_point[:active_edge].data
        @remainder.times do |ctr|
                @pending_prefixes << data[-(ctr+1)..data.length-1]
        end

        @pending_prefixes.reverse!

    end

    def split_edge data , index , element
        location = @active_point[:active_length]
        old_edges = []
        internal_node = (@active_point[:active_edge].edges != nil)

        if (internal_node)
            old_edges = @active_point[:active_edge].edges 
            @active_point[:active_edge].edges = []
        end

        @active_point[:active_edge].data = data[0..location-1].join                 
        @active_point[:active_edge].edges << Edge.new(data[location..data.size].join)


        if internal_node
            @active_point[:active_edge].edges << Edge.new(element)
        else
            @active_point[:active_edge].edges << Edge.new(data.last)        
        end

        if internal_node
            @active_point[:active_edge].edges[0].edges = old_edges
        end


        #setup the suffix link
        if @last_split_edge != nil and @last_split_edge.data.end_with?@active_point[:active_edge].data 

            @last_split_edge.suffix_link = @active_point[:active_edge] 
        end

        @last_split_edge = @active_point[:active_edge]

        update_active_point index

    end


    def update_active_point index
        if(@active_point[:active_node] == @root)
            @active_point[:active_length] = @active_point[:active_length] - 1
            @remainder = @remainder - 1
            @active_point[:active_edge] = @active_point[:active_node].find_edge(@pending_prefixes.first[index+1])
        else
            if @active_point[:active_node].suffix_link != nil
                @active_point[:active_node] = @active_point[:active_node].suffix_link               
            else
                @active_point[:active_node] = @root
            end 
            @active_point[:active_edge] = @active_point[:active_node].find_edge(@active_point[:active_edge].data[0])
            @remainder = @remainder - 1     
        end
    end

    def add_to_edges root , element     
        return if root == nil
        root.data = root.data + element if(root.data and root.edges.size == 0)
        root.edges.each do |edge|
            add_to_edges edge , element
        end
    end
end

suffix_tree = SuffixTrees.new
suffix_tree.build("abcabxabcd")
binding.pry
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.