Myers 차이 알고리즘(MDA)은 두 시퀀스의 최장 공통 부분 시퀀스(LCS) 또는 최소 편집 스크립트(SES)를 찾는 알고리즘이다. 두 시퀀스의 공통 부분 시퀀스는 두 시퀀스에서 동일한 순서로 나타나는 엘리먼트의 시퀀스를 의미한다. 예를 들어 다음과 같은 두 배열이 있다고 가정해 보자.
let firstArray = [1, 2, 3]
let secondArray = [2, 3, 4]
이 두 배열의 공통 부분 시퀀스는 [2]
와 [2, 3]
이다. 이 경우 최장 공통 시퀀스는 [2, 3]
이다. MDA는 두 시퀀스 길이의 합인 N에 대해 O(ND) 시간 복잡도로 이 작업을 수행할 수 있다.
MDA는 LCS(최장 공통 부분 수열)/SES(최단 편집 스크립트) 문제를 해결하기 위해 편집 그래프를 사용한다. 아래는 편집 그래프를 나타낸 그림이다:
그래프 상단의 x축은 시퀀스 X
를 나타내고, 왼쪽의 y축은 시퀀스 Y
를 나타낸다. 따라서 두 시퀀스는 다음과 같다:
X = [A, B, C, A, B, B, A]
Y = [C, B, A, B, A, C]
MDA는 다음과 같은 단계로 편집 그래프를 생성한다:
X
의 요소를 x축에 배치하고, Y
의 요소를 y축에 배치한다.(x, y)
에 정점을 생성한다. 여기서 x
는 [0, N]
범위, y
는 [0, M]
범위이다. N
은 시퀀스 X
의 길이, M
은 Y
의 길이이다.x - y = k
인 선을 그린다. 이 선을 k-선이라고 부르며, 검은 점선이 이에 해당한다. 분홍색 숫자는 k의 값을 나타낸다.X[i] = Y[j]
인 점 (i, j)
를 찾는다. 이 점을 매치 포인트라고 하며, 연두색 점으로 표시된다.(i, j)
에서 정점 (i - 1, j - 1)
과 정점 (i, j)
를 연결한다. 이때 대각선 엣지가 생성된다.그림의 각 요소는 다음과 같은 의미를 가진다:
X[i] == Y[j]
인 점 (i, j)
를 나타낸다.참고: 여기서 시퀀스의 시작 인덱스는 0이 아닌 1이다. 따라서
X[1] = A
,Y[1] = C
이다.
이제 시작점에서 끝점까지의 최단 경로가 무엇인지 논의한다. 그래프의 엣지를 따라 이동할 수 있다. 즉, 그리드의 가로, 세로, 대각선 엣지를 통해 이동할 수 있다.
이동은 편집 스크립트와 호환된다. 삽입 또는 삭제가 그것이다. 편집 스크립트라는 용어는 소개 부분에서 언급한 SES(최단 편집 스크립트)를 의미한다.
다시 본론으로 돌아가자. 이 편집 그래프에서 정점 (i, j)
로의 가로 이동은 X의 인덱스 i에서 삭제
스크립트와 호환된다. 정점 (i, j)
로의 세로 이동은 Y의 인덱스 j에 있는 요소를 X의 인덱스 i 바로 뒤에 삽입
스크립트와 호환된다. 대각선 이동은 어떨까? 정점 (i, j)
로의 대각선 이동은 X[i] = Y[j]
를 의미하므로 스크립트가 필요 없다.
다음으로, 비대각선 이동에는 스크립트와 호환되므로 비용 1을 추가한다. 대각선 이동은 스크립트가 필요 없으므로 비용 0이다.
시작점에서 끝점까지의 최단 경로를 탐색할 때의 총 비용은 최장 공통 부분 수열 또는 최단 편집 스크립트의 길이와 동일하다.
따라서 LCS/SES 문제는 시작점에서 끝점까지의 최단 경로를 찾는 것으로 해결할 수 있다.
위에서 언급했듯이, 최소 편집 스크립트를 찾는 문제는 source (0, 0)
에서 sink (N, M)
까지 가장 적은 수의 수평 및 수직 간선으로 이루어진 경로를 찾는 문제로 축소할 수 있다. D-path
는 source
에서 시작해 정확히 D
개의 비대각선 간선을 가지거나, 비대각선으로 D
번 이동한 경로를 의미한다.
예를 들어, 0-path는 오직 대각선 간선만으로 구성된다. 이는 두 시퀀스가 완전히 동일함을 의미한다.
간단한 귀납법에 의해, D-path는 (D-1)-path에 비대각선 간선을 추가한 후 대각선 간선(snake
)으로 이어지는 형태여야 한다. D의 최소값은 0이며, 이는 두 시퀀스가 동일한 경우이다. 반대로 D의 최대값은 N + M이다. X의 모든 요소를 삭제하고 Y의 모든 요소를 X에 삽입하는 것이 최악의 경우의 편집 스크립트이기 때문이다. D, 즉 최소 편집 스크립트(SES)의 길이를 구하기 위해 0부터 N + M까지 루프를 실행하면 충분하다.
for D in 0...N + M
다음으로, D-path가 k-line에서 가장 멀리 도달할 수 있는 지점을 생각해 보자. 아래와 같이, k-line에서 수평으로 이동하면 (k+1)-line에 도달하고, 수직으로 이동하면 (k-1)-line에 도달한다. 빨간색 분필 선이 이를 보여준다.
따라서 D-path의 끝점은 여러 k-line에 위치할 수 있다. 위에서 언급한 대로 다음 경로((D+1)-path)를 얻기 위해 이 정보가 필요하다. 사실, D-path는 k-line에서 끝나야 하며, 여기서 k는 { -D, -D + 2, ....., D - 2, D } 범위에 속한다. 이는 매우 간단하다. 시작점인 source
는 (k=0)-line에 위치한 (0, 0)
이다. D는 비대각선 간선의 수이며, 비대각선 이동은 현재 k-line을 (k±1)-line으로 변경한다. 0은 짝수이므로, D가 짝수면 D-path는 (짝수_k)-line에서 끝나고, D가 홀수면 D-path는 (홀수_k)-line에서 끝난다.
검색 루프의 개요는 다음과 같다.
for D in 0...N + M {
for k in stride(from: -D, through: D, by: 2) {
// k-line에서 가장 멀리 도달한 D-path의 끝점을 찾는다.
if furthestReachingX == N && furthestReachingY == M {
// D-path가 최단 경로이다.
// D가 최소 편집 스크립트의 길이이다.
return
}
}
}
k-line에서의 D-path는 다음과 같이 분해할 수 있다.
snake
로 이어지는 형태.snake
로 이어지는 형태.Myers 알고리즘의 핵심은 다음과 같다.
이를 통해 계산량이 줄어든다.
public struct MyersDifferenceAlgorithm<E: Equatable> {
public static func calculateShortestEditDistance(from fromArray: Array<E>, to toArray: Array<E>) -> Int {
let fromCount = fromArray.count
let toCount = toArray.count
let totalCount = toCount + fromCount
var furthestReaching = Array(repeating: 0, count: 2 * totalCount + 1)
let isReachedAtSink: (Int, Int) -> Bool = { x, y in
return x == fromCount && y == toCount
}
let snake: (Int, Int, Int) -> Int = { x, D, k in
var _x = x
while _x < fromCount && _x - k < toCount && fromArray[_x] == toArray[_x - k] {
_x += 1
}
return _x
}
for D in 0...totalCount {
for k in stride(from: -D, through: D, by: 2) {
let index = k + totalCount
// (x, D, k) => k_line에서 스크립트 수가 D인 x 위치
// 스크립트는 삽입 또는 삭제를 의미한다.
var x = 0
if D == 0 { }
// k == -D, D는 경계 k_line이 된다.
// k == -D일 때, k - 1_line에서 D - 1이 불가능한 경우 Edit Graph에서 오른쪽으로 이동(삭제 스크립트)한다.
// k == D일 때, k + 1_line에서 D - 1이 불가능한 경우 Edit Graph에서 아래로 이동(삽입 스크립트)한다.
// 가장 멀리 도달한 x 위치가 계산 우선순위가 높다. (x, D - 1, k - 1), (x, D - 1, k + 1)
else if k == -D || k != D && furthestReaching[index - 1] < furthestReaching[index + 1] {
// 초기 x 위치를 얻는다.
// D - 1인 k + 1_line에서 가장 멀리 도달한 X 위치를 사용한다.
// 즉, (x, D, k)를 (x, D - 1, k + 1) + 아래로 이동 + snake로 얻는다.
// Edit Graph에서 아래로 이동하는 것은 삽입 스크립트와 호환된다.
x = furthestReaching[index + 1]
} else {
// 초기 x 위치를 얻는다.
// D - 1인 k - 1_line에서 가장 멀리 도달한 X 위치를 사용한다.
// 즉, (x, D, k)를 (x, D - 1, k - 1) + 오른쪽으로 이동 + snake로 얻는다.
// Edit Graph에서 오른쪽으로 이동하는 것은 삭제 스크립트와 호환된다.
x = furthestReaching[index - 1] + 1
}
// snake
// 대각선 이동은 0 비용으로 수행할 수 있다.
// `동일` 스크립트가 필요한가?
let _x = snake(x, D, k)
if isReachedAtSink(_x, _x - k) { return D }
furthestReaching[index] = _x
}
}
fatalError("Never comes here")
}
}