-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: LCS-based movement implementation
This new algorithm implements generation of move actions by utilizing a longest common subsequence algorithm (LCS). First, based on the position type, we generate a list describing the expected order of the elements. LCS algorithm is then used to find the longest sequence of items that is shared between the expected list, and an actual list (e.g. a list of entries from the server). Once longest sequence is known, we figure out the least amount of moves to translate existing list into its expected form, and those movements are returned back.
- Loading branch information
1 parent
11eb059
commit 6af5f61
Showing
3 changed files
with
500 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,348 @@ | ||
package movement | ||
|
||
import ( | ||
"fmt" | ||
"log/slog" | ||
"slices" | ||
) | ||
|
||
var _ = slog.LevelDebug | ||
|
||
type Movable interface { | ||
EntryName() string | ||
} | ||
|
||
type MoveAction struct { | ||
EntryName string | ||
Where string | ||
Destination string | ||
} | ||
|
||
type Position interface { | ||
Move(entries []Movable, existing []Movable) ([]MoveAction, error) | ||
} | ||
|
||
type PositionTop struct{} | ||
|
||
type PositionBottom struct{} | ||
|
||
type PositionBefore struct { | ||
Directly bool | ||
Pivot Movable | ||
} | ||
|
||
type PositionAfter struct { | ||
Directly bool | ||
Pivot Movable | ||
} | ||
|
||
func removeEntriesFromExisting(existing []Movable, entries []Movable) []Movable { | ||
entryNames := make(map[string]bool, len(entries)) | ||
for _, elt := range entries { | ||
entryNames[elt.EntryName()] = true | ||
} | ||
|
||
filtered := make([]Movable, len(existing)) | ||
copy(filtered, existing) | ||
|
||
filtered = slices.DeleteFunc(filtered, func(entry Movable) bool { | ||
_, ok := entryNames[entry.EntryName()] | ||
return ok | ||
}) | ||
|
||
return filtered | ||
} | ||
|
||
func findPivotIdx(entries []Movable, pivot Movable) int { | ||
return slices.IndexFunc(entries, func(entry Movable) bool { | ||
if entry.EntryName() == pivot.EntryName() { | ||
return true | ||
} | ||
|
||
return false | ||
}) | ||
|
||
} | ||
|
||
type movementType int | ||
|
||
const ( | ||
movementBefore movementType = iota | ||
movementAfter | ||
) | ||
|
||
func processPivotMovement(entries []Movable, existing []Movable, pivot Movable, direct bool, movement movementType) ([]MoveAction, error) { | ||
existingLen := len(existing) | ||
existingIdxMap := make(map[Movable]int, existingLen) | ||
|
||
for idx, elt := range existing { | ||
existingIdxMap[elt] = idx | ||
} | ||
|
||
pivotIdx := findPivotIdx(existing, pivot) | ||
if pivotIdx == -1 { | ||
return nil, fmt.Errorf("pivot point not found in the list of existing items") | ||
} | ||
|
||
if !direct { | ||
movementRequired := false | ||
entriesLen := len(entries) | ||
loop: | ||
for i := 0; i < entriesLen; i++ { | ||
|
||
// For any given entry in the list of entries to move check if the entry | ||
// index is at or after pivot point index, which will require movement | ||
// set to be generated. | ||
existingEntryIdx := existingIdxMap[entries[i]] | ||
switch movement { | ||
case movementBefore: | ||
if existingEntryIdx >= pivotIdx { | ||
movementRequired = true | ||
break | ||
} | ||
case movementAfter: | ||
if existingEntryIdx <= pivotIdx { | ||
movementRequired = true | ||
break | ||
} | ||
} | ||
|
||
if i == 0 { | ||
continue | ||
} | ||
|
||
// Check if the entries to be moved have the same order in the existing | ||
// slice, and if not require a movement set to be generated. | ||
switch movement { | ||
case movementBefore: | ||
if existingIdxMap[entries[i-1]] >= existingEntryIdx { | ||
movementRequired = true | ||
break loop | ||
|
||
} | ||
case movementAfter: | ||
if existingIdxMap[entries[i-1]] <= existingEntryIdx { | ||
movementRequired = true | ||
break loop | ||
|
||
} | ||
|
||
} | ||
} | ||
|
||
if !movementRequired { | ||
return nil, nil | ||
} | ||
} | ||
|
||
expected := make([]Movable, len(existing)) | ||
|
||
filtered := removeEntriesFromExisting(existing, entries) | ||
filteredPivotIdx := findPivotIdx(filtered, pivot) | ||
|
||
switch movement { | ||
case movementBefore: | ||
expectedIdx := 0 | ||
for ; expectedIdx < filteredPivotIdx; expectedIdx++ { | ||
expected[expectedIdx] = filtered[expectedIdx] | ||
} | ||
|
||
for _, elt := range entries { | ||
expected[expectedIdx] = elt | ||
expectedIdx++ | ||
} | ||
|
||
expected[expectedIdx] = pivot | ||
expectedIdx++ | ||
|
||
filteredLen := len(filtered) | ||
for i := filteredPivotIdx + 1; i < filteredLen; i++ { | ||
expected[expectedIdx] = filtered[i] | ||
expectedIdx++ | ||
} | ||
} | ||
|
||
return GenerateMovements(existing, expected) | ||
} | ||
|
||
func (o PositionAfter) Move(entries []Movable, existing []Movable) ([]MoveAction, error) { | ||
return processPivotMovement(entries, existing, o.Pivot, o.Directly, movementAfter) | ||
} | ||
|
||
func (o PositionBefore) Move(entries []Movable, existing []Movable) ([]MoveAction, error) { | ||
return processPivotMovement(entries, existing, o.Pivot, o.Directly, movementBefore) | ||
} | ||
|
||
type Entry struct { | ||
Element Movable | ||
Expected int | ||
Existing int | ||
} | ||
|
||
type sequencePosition struct { | ||
Start int | ||
End int | ||
} | ||
|
||
func longestCommonSubsequence(existing []Movable, expected []Movable) ([]Movable, sequencePosition) { | ||
// Implementation of DP-based algorithm for solving LCP | ||
// See https://en.wikipedia.org/wiki/Longest_common_subsequence for details | ||
// and the algorithm. | ||
m, n := len(existing), len(expected) | ||
|
||
dp := make([][]int, m+1) | ||
for i := range m + 1 { | ||
dp[i] = make([]int, n+1) | ||
} | ||
|
||
for i := 0; i < m; i++ { | ||
for j := 0; j < n; j++ { | ||
if existing[i].EntryName() == expected[j].EntryName() { | ||
dp[i+1][j+1] = 1 + dp[i][j] | ||
} else { | ||
dp[i+1][j+1] = max(dp[i+1][j], dp[i][j+1]) | ||
} | ||
} | ||
} | ||
|
||
// Once LCS algorithm is finished we know the length of the longest common | ||
// sequence (dp[m][n]) and we can then go backward in the matrix to build | ||
// the actual sequence. | ||
index := dp[m][n] | ||
|
||
i := m | ||
j := n | ||
|
||
commonSubsequence := make([]Movable, index) | ||
|
||
position := sequencePosition{ | ||
Start: i - index, | ||
End: i - 1, | ||
} | ||
|
||
for { | ||
if i == 0 || j == 0 { | ||
break | ||
} | ||
|
||
if existing[i-1].EntryName() == expected[j-1].EntryName() { | ||
commonSubsequence[index-1] = existing[i-1] | ||
|
||
i-- | ||
j-- | ||
index-- | ||
|
||
} else if dp[i-1][j] > dp[i][j-1] { | ||
i-- | ||
} else { | ||
j-- | ||
} | ||
} | ||
|
||
return commonSubsequence, position | ||
} | ||
|
||
func GenerateMovements(existing []Movable, expected []Movable) ([]MoveAction, error) { | ||
if len(existing) != len(expected) { | ||
return nil, fmt.Errorf("existing length != expected length: %d != %d", len(existing), len(expected)) | ||
} | ||
|
||
commonSequence, position := longestCommonSubsequence(existing, expected) | ||
|
||
existingIdxMap := make(map[Movable]int, len(existing)) | ||
for idx, elt := range existing { | ||
existingIdxMap[elt] = idx | ||
} | ||
|
||
expectedIdxMap := make(map[Movable]int, len(expected)) | ||
for idx, elt := range expected { | ||
expectedIdxMap[elt] = idx | ||
} | ||
|
||
commonIdxMap := make(map[Movable]int, len(commonSequence)) | ||
for idx, elt := range commonSequence { | ||
commonIdxMap[elt] = idx | ||
} | ||
|
||
var movements []MoveAction | ||
|
||
expectedCommonLastIdx := expectedIdxMap[existing[position.End]] | ||
|
||
firstElt := existing[0] | ||
afterElt := existing[position.End] | ||
for _, elt := range existing { | ||
var ok bool | ||
if _, ok = commonIdxMap[elt]; ok { | ||
continue | ||
} | ||
|
||
existingIdx, _ := existingIdxMap[elt] | ||
expectedIdx, _ := expectedIdxMap[elt] | ||
|
||
if expectedIdx == existingIdx { | ||
continue | ||
} else if expectedIdx < expectedCommonLastIdx && expectedIdx == 0 { | ||
movements = append(movements, MoveAction{ | ||
EntryName: elt.EntryName(), | ||
Where: "top", | ||
Destination: "top", | ||
}) | ||
firstElt = elt | ||
|
||
} else if expectedIdx < expectedCommonLastIdx && expectedIdx != 0 { | ||
movements = append(movements, MoveAction{ | ||
EntryName: elt.EntryName(), | ||
Where: "after", | ||
Destination: firstElt.EntryName(), | ||
}) | ||
firstElt = elt | ||
} else if expectedIdx >= expectedCommonLastIdx { | ||
movements = append(movements, MoveAction{ | ||
EntryName: elt.EntryName(), | ||
Where: "after", | ||
Destination: afterElt.EntryName(), | ||
}) | ||
afterElt = elt | ||
} | ||
} | ||
|
||
return movements, nil | ||
} | ||
|
||
func (o PositionTop) Move(entries []Movable, existing []Movable) ([]MoveAction, error) { | ||
entryNames := make(map[string]bool, len(entries)) | ||
|
||
for _, elt := range entries { | ||
entryNames[elt.EntryName()] = true | ||
} | ||
|
||
filtered := removeEntriesFromExisting(existing, entries) | ||
|
||
expected := append(entries, filtered...) | ||
|
||
return GenerateMovements(existing, expected) | ||
} | ||
|
||
func (o PositionBottom) Move(entries []Movable, existing []Movable) ([]MoveAction, error) { | ||
entryNames := make(map[string]bool, len(entries)) | ||
|
||
for _, elt := range entries { | ||
entryNames[elt.EntryName()] = true | ||
} | ||
|
||
filtered := make([]Movable, len(existing)) | ||
copy(filtered, existing) | ||
|
||
filtered = slices.DeleteFunc(filtered, func(entry Movable) bool { | ||
_, ok := entryNames[entry.EntryName()] | ||
return ok | ||
}) | ||
|
||
expected := append(filtered, entries...) | ||
|
||
return GenerateMovements(existing, expected) | ||
} | ||
|
||
func MoveGroup(position Position, entries []Movable, existing []Movable) ([]MoveAction, error) { | ||
return position.Move(entries, existing) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package movement_test | ||
|
||
import ( | ||
"log/slog" | ||
"testing" | ||
|
||
. "github.com/onsi/ginkgo/v2" | ||
. "github.com/onsi/gomega" | ||
) | ||
|
||
func TestMovement(t *testing.T) { | ||
handler := slog.NewTextHandler(GinkgoWriter, &slog.HandlerOptions{ | ||
Level: slog.LevelDebug, | ||
}) | ||
slog.SetDefault(slog.New(handler)) | ||
RegisterFailHandler(Fail) | ||
RunSpecs(t, "Movement Suite") | ||
} |
Oops, something went wrong.