Skip to content

Commit 6c99a5b

Browse files
authored
feat: add replaceValue... methods to MutableSolutionView (#2180)
In addition to moveValue...
1 parent 30d18f0 commit 6c99a5b

3 files changed

Lines changed: 401 additions & 31 deletions

File tree

core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package ai.timefold.solver.core.impl.move;
22

3+
import java.util.ArrayList;
4+
import java.util.Collections;
35
import java.util.List;
46
import java.util.Objects;
57
import java.util.function.BiFunction;
@@ -197,6 +199,29 @@ public final <Entity_, Value_> Value_ moveValueBetweenLists(
197199
return element;
198200
}
199201

202+
@Override
203+
public <Entity_, Value_> Value_ replaceValue(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
204+
Entity_ sourceEntity, int sourceIndex, Entity_ destinationEntity, int destinationIndex) {
205+
if (destinationEntity == sourceEntity) {
206+
return replaceValue(variableMetaModel, sourceEntity, sourceIndex, destinationIndex);
207+
}
208+
209+
var variableDescriptor = extractVariableDescriptor(variableMetaModel);
210+
var toReplace = (Value_) variableDescriptor.getElement(destinationEntity, destinationIndex);
211+
externalScoreDirector.beforeListVariableElementUnassigned(variableDescriptor, toReplace);
212+
externalScoreDirector.beforeListVariableChanged(variableDescriptor, destinationEntity, destinationIndex,
213+
destinationIndex + 1);
214+
externalScoreDirector.beforeListVariableChanged(variableDescriptor, sourceEntity, sourceIndex, sourceIndex + 1);
215+
var toMove = variableDescriptor.removeElement(sourceEntity, sourceIndex);
216+
variableDescriptor.setElement(destinationEntity, destinationIndex, toMove);
217+
externalScoreDirector.afterListVariableChanged(variableDescriptor, sourceEntity, sourceIndex, sourceIndex);
218+
externalScoreDirector.afterListVariableChanged(variableDescriptor, destinationEntity, destinationIndex,
219+
destinationIndex + 1);
220+
externalScoreDirector.afterListVariableElementUnassigned(variableDescriptor, toReplace);
221+
externalScoreDirector.triggerVariableListeners();
222+
return toReplace;
223+
}
224+
200225
@SuppressWarnings("unchecked")
201226
@Override
202227
public final <Entity_, Value_> Value_ moveValueInList(
@@ -207,32 +232,104 @@ public final <Entity_, Value_> Value_ moveValueInList(
207232
"When moving values in the same list, sourceIndex (%d) and destinationIndex (%d) must be different."
208233
.formatted(sourceIndex, destinationIndex));
209234
} else if (sourceIndex < 0 || destinationIndex < 0) {
210-
throw new IllegalArgumentException(
211-
"The sourceIndex (%d) and destinationIndex (%d) must both be >= 0."
212-
.formatted(sourceIndex, destinationIndex));
235+
throw new IndexOutOfBoundsException("The sourceIndex (%d) and destinationIndex (%d) must both be >= 0."
236+
.formatted(sourceIndex, destinationIndex));
213237
}
214238

215-
var fromIndex = Math.min(sourceIndex, destinationIndex);
216-
var toIndex = Math.max(sourceIndex, destinationIndex) + 1;
217-
218239
var variableDescriptor = extractVariableDescriptor(variableMetaModel);
219240
var list = variableDescriptor.getValue(sourceEntity);
220241
var listSize = list.size();
221242
if (sourceIndex >= listSize) {
222-
throw new IllegalArgumentException(
223-
"The sourceIndex (%d) must be less than the list size (%d).".formatted(sourceIndex, listSize));
224-
} else if (destinationIndex > listSize) { // destinationIndex == listSize is allowed (append to the end of the list)
225-
throw new IllegalArgumentException(
226-
"The destinationIndex (%d) must be less than or equal to the list size (%d)."
227-
.formatted(destinationIndex, listSize));
243+
throw new IndexOutOfBoundsException("The sourceIndex (%d) must be less than the list size (%d)."
244+
.formatted(sourceIndex, listSize));
245+
} else if (destinationIndex >= listSize) {
246+
throw new IndexOutOfBoundsException("The destinationIndex (%d) must be less than the list size (%d)."
247+
.formatted(destinationIndex, listSize));
228248
}
229249

250+
var fromIndex = Math.min(sourceIndex, destinationIndex);
251+
var toIndex = Math.max(sourceIndex, destinationIndex) + 1;
230252
externalScoreDirector.beforeListVariableChanged(variableDescriptor, sourceEntity, fromIndex, toIndex);
231-
var element = (Value_) list.remove(sourceIndex);
232-
list.add(destinationIndex, element);
253+
moveInList(list, sourceIndex, destinationIndex);
233254
externalScoreDirector.afterListVariableChanged(variableDescriptor, sourceEntity, fromIndex, toIndex);
234255
externalScoreDirector.triggerVariableListeners();
235-
return element;
256+
return (Value_) list.get(destinationIndex);
257+
}
258+
259+
@Override
260+
public <Entity_, Value_> Value_ replaceValue(
261+
PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel, Entity_ entity, int sourceIndex,
262+
int destinationIndex) {
263+
if (sourceIndex == destinationIndex) {
264+
throw new IllegalArgumentException(
265+
"When replacing values in the same list, sourceIndex (%d) and destinationIndex (%d) must be different."
266+
.formatted(sourceIndex, destinationIndex));
267+
} else if (sourceIndex < 0 || destinationIndex < 0) {
268+
throw new IndexOutOfBoundsException("The sourceIndex (%d) and destinationIndex (%d) must both be >= 0."
269+
.formatted(sourceIndex, destinationIndex));
270+
}
271+
272+
var variableDescriptor = extractVariableDescriptor(variableMetaModel);
273+
var fromIndex = Math.min(sourceIndex, destinationIndex);
274+
var toIndex = Math.max(sourceIndex, destinationIndex) + 1;
275+
var list = variableDescriptor.getValue(entity);
276+
var toReplace = (Value_) list.get(destinationIndex);
277+
externalScoreDirector.beforeListVariableElementUnassigned(variableDescriptor, toReplace);
278+
externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
279+
if (destinationIndex > sourceIndex) {
280+
// Remove from sourceIndex after setting the destination to preserve index validity.
281+
list.set(destinationIndex, list.get(sourceIndex));
282+
list.remove(sourceIndex);
283+
} else {
284+
list.set(destinationIndex, list.remove(sourceIndex));
285+
}
286+
externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex - 1);
287+
externalScoreDirector.afterListVariableElementUnassigned(variableDescriptor, toReplace);
288+
externalScoreDirector.triggerVariableListeners();
289+
return toReplace;
290+
}
291+
292+
/**
293+
* Moves the element at index {@code from} to index {@code to} in a list,
294+
* choosing the faster of two strategies based on the move's distance and position within the list.
295+
*
296+
* <p>
297+
* <b>Strategy selection</b> (lo = min(from, to), d = |from − to|):
298+
* <ul>
299+
* <li>Use {@code Collections.rotate} when {@code d * 8 < n − lo}
300+
* (distance is small relative to the remaining tail).</li>
301+
* <li>Use {@code remove + add} otherwise.</li>
302+
* </ul>
303+
*
304+
* <p>
305+
* <b>Why position matters</b>: {@code remove+add} shifts {@code (n−1−from) + (n−1−to)} elements in total.
306+
* When one endpoint is near the tail, one of those copies is nearly free,
307+
* making {@code remove+add} cheap even for large lists.
308+
* {@code rotate} always pays for the full sublist span,
309+
* so it only wins when that span is short relative to what {@code removeAdd} would have to copy.
310+
*
311+
* <p>
312+
* The threshold constant 8 was determined empirically by benchmarking on HotSpot
313+
* with a microbenchmark that performed moves of varying distances and positions within lists of varying sizes.
314+
*
315+
* @param list the list to mutate; assumes {@link ArrayList}
316+
* @param from index of the element to move
317+
* @param to index the element should occupy after the move
318+
*/
319+
private static <T> void moveInList(List<T> list, int from, int to) {
320+
var distance = Math.abs(from - to);
321+
if (distance == 1) {
322+
Collections.swap(list, from, to);
323+
return;
324+
}
325+
var distanceTimesEight = distance * 8L; // Long prevents unlikely yet possible overflow.
326+
var lowerIndex = Math.min(from, to);
327+
var tailLength = list.size() - lowerIndex;
328+
if (distanceTimesEight < tailLength) {
329+
Collections.rotate(list.subList(lowerIndex, lowerIndex + distance + 1), from < to ? -1 : 1);
330+
} else {
331+
list.add(to, list.remove(from));
332+
}
236333
}
237334

238335
@Override
@@ -276,15 +373,11 @@ public <Entity_, Value_> void swapValuesInList(PlanningListVariableMetaModel<Sol
276373
}
277374

278375
var variableDescriptor = extractVariableDescriptor(variableMetaModel);
279-
var leftElement = variableDescriptor.getElement(entity, leftIndex);
280-
var rightElement = variableDescriptor.getElement(entity, rightIndex);
281-
282376
var fromIndex = Math.min(leftIndex, rightIndex);
283377
var toIndex = Math.max(leftIndex, rightIndex) + 1;
284378
externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
285379
var list = variableDescriptor.getValue(entity);
286-
list.set(leftIndex, rightElement);
287-
list.set(rightIndex, leftElement);
380+
Collections.swap(list, leftIndex, rightIndex);
288381
externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
289382
externalScoreDirector.triggerVariableListeners();
290383
}

core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ <Entity_, Value_> void unassignValue(PlanningListVariableMetaModel<Solution_, En
131131
* Acceptable values range from zero to one less than list size.
132132
* All values after the index are shifted to the left.
133133
* @return the removed value
134-
* @throws IndexOutOfBoundsException if the index is out of bounds
135134
*/
136135
<Entity_, Value_> Value_ unassignValue(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
137136
Entity_ entity, int index);
@@ -171,12 +170,44 @@ <Entity_, Value_> void changeVariable(PlanningVariableMetaModel<Solution_, Entit
171170
* All values at or after the index are shifted to the right.
172171
* To append to the end of the list, use the list size as index.
173172
* @return the value that was moved
174-
* @throws IndexOutOfBoundsException if either index is out of bounds
175173
* @throws IllegalArgumentException if sourceEntity == destinationEntity
176174
*/
177175
<Entity_, Value_> Value_ moveValueBetweenLists(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
178176
Entity_ sourceEntity, int sourceIndex, Entity_ destinationEntity, int destinationIndex);
179177

178+
/**
179+
* Replaces a value in one entity's {@link PlanningListVariable planning list variable} with a value taken from another.
180+
* The value is removed from {@code sourceEntity} at {@code sourceIndex}, shifting all later values to the left.
181+
* The removed value is then assigned to {@code destinationEntity} at {@code destinationIndex},
182+
* overwriting the pre-existing value and unassigning it.
183+
* This means that the sourceEntity's list will be one item shorter after the move,
184+
* while the destinationEntity's list size remains unchanged.
185+
*
186+
* @param variableMetaModel Describes the variable to be changed.
187+
* @param sourceEntity The entity from which the replacement value will be taken and removed.
188+
* @param sourceIndex The index in the sourceEntity's list variable which contains the value to be moved and
189+
* removed;
190+
* Acceptable values range from zero to one less than the source list size.
191+
* All values at or after the index are shifted to the left.
192+
* @param destinationEntity The entity in which the value at {@code destinationIndex} will be replaced (overwritten).
193+
* @param destinationIndex The index in the destinationEntity's list variable whose current value will be overwritten;
194+
* Acceptable values range from zero to one less than the destination list size.
195+
* @return the value that was replaced
196+
* @see #moveValueBetweenLists(PlanningListVariableMetaModel, Object, int, Object, int) Similar operation that moves the
197+
* value to the destination without removing the pre-existing value.
198+
*/
199+
<Entity_, Value_> Value_ replaceValue(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
200+
Entity_ sourceEntity, int sourceIndex, Entity_ destinationEntity, int destinationIndex);
201+
202+
/**
203+
* As defined by {@link #replaceValue(PlanningListVariableMetaModel, Object, int, Object, int)},
204+
* but using {@link PositionInList} to specify the positions.
205+
*/
206+
default <Entity_, Value_> Value_ replaceValue(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
207+
PositionInList source, PositionInList destination) {
208+
return replaceValue(variableMetaModel, source.entity(), source.index(), destination.entity(), destination.index());
209+
}
210+
180211
/**
181212
* As defined by {@link #moveValueBetweenLists(PlanningListVariableMetaModel, Object, int, Object, int)},
182213
* but using {@link PositionInList} to specify the source and destination positions.
@@ -205,14 +236,47 @@ default <Entity_, Value_> Value_ moveValueBetweenLists(
205236
* Acceptable values range from zero to one less than list size.
206237
* All values at or after the index are shifted to the right.
207238
* @return the value that was moved
208-
* @throws IndexOutOfBoundsException if either index is out of bounds
209239
* @throws IllegalArgumentException if sourceIndex == destinationIndex
240+
* @see #replaceValue(PlanningListVariableMetaModel, Object, int, int) Similar operation that replaces the value at
241+
* the destination index instead.
210242
* @see #shiftValue(PlanningListVariableMetaModel, Object, int, int) Equivalent operation using offset calculation instead
211243
* of index arithmetics.
212244
*/
213245
<Entity_, Value_> Value_ moveValueInList(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
214246
Entity_ sourceEntity, int sourceIndex, int destinationIndex);
215247

248+
/**
249+
* Moves a value within one entity's {@link PlanningListVariable planning list variable}.
250+
* Behaves as if the value is first put in the destinationIndex,
251+
* and then removed from the sourceIndex, shifting all later values to the left
252+
* The value previously at the destinationIndex is unassigned.
253+
*
254+
* @param variableMetaModel Describes the variable to be changed.
255+
* @param entity The entity in which the value at {@code destinationIndex} will be replaced (overwritten).
256+
* @param sourceIndex The index in the entity's list variable which contains the value to be moved and removed;
257+
* Acceptable values range from zero to one less than the list size.
258+
* All values at or after the index are shifted to the left.
259+
* @param destinationIndex The index in the entity's list variable whose current value will be overwritten;
260+
* Acceptable values range from zero to one less than the list size.
261+
* @return the value that was replaced
262+
* @throws IllegalArgumentException if sourceIndex == destinationIndex
263+
* @see #moveValueInList(PlanningListVariableMetaModel, Object, int, int) Similar operation that moves the value to the
264+
* destination index instead.
265+
* @see #shiftValue(PlanningListVariableMetaModel, Object, int, int) Equivalent operation using offset calculation instead
266+
* of index arithmetic.
267+
*/
268+
<Entity_, Value_> Value_ replaceValue(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
269+
Entity_ entity, int sourceIndex, int destinationIndex);
270+
271+
/**
272+
* As defined by {@link #replaceValue(PlanningListVariableMetaModel, Object, int, int)},
273+
* but using {@link PositionInList} to specify the source position.
274+
*/
275+
default <Entity_, Value_> Value_ replaceValue(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
276+
PositionInList source, int destinationIndex) {
277+
return replaceValue(variableMetaModel, source.entity(), source.index(), destinationIndex);
278+
}
279+
216280
/**
217281
* Moves a value within one entity's {@link PlanningListVariable planning list variable},
218282
* by the given offset.
@@ -225,7 +289,6 @@ <Entity_, Value_> Value_ moveValueInList(PlanningListVariableMetaModel<Solution_
225289
* The offset must not be zero.
226290
* The offset must not move the value out of bounds.
227291
* @return the value that was moved
228-
* @throws IndexOutOfBoundsException if either index is out of bounds
229292
* @throws IllegalArgumentException if sourceIndex == destinationIndex
230293
* @see #moveValueInList(PlanningListVariableMetaModel, Object, int, int) Equivalent operation using index arithmetics
231294
* instead of offset calculation.
@@ -252,7 +315,6 @@ default <Entity_, Value_> Value_ shiftValue(PlanningListVariableMetaModel<Soluti
252315
* @param rightEntity The second entity whose variable value is to be swapped.
253316
* @param rightIndex The index in the right entity's list variable which contains the other value to be swapped;
254317
* Acceptable values range from zero to one less than list size.
255-
* @throws IndexOutOfBoundsException if either index is out of bounds
256318
* @throws IllegalArgumentException if leftEntity == rightEntity while leftIndex == rightIndex
257319
*/
258320
<Entity_, Value_> void swapValuesBetweenLists(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
@@ -267,7 +329,6 @@ <Entity_, Value_> void swapValuesBetweenLists(PlanningListVariableMetaModel<Solu
267329
* Acceptable values range from zero to one less than list size.
268330
* @param rightIndex The index in the entity's list variable which contains the other value to be swapped;
269331
* Acceptable values range from zero to one less than list size.
270-
* @throws IndexOutOfBoundsException if either index is out of bounds
271332
* @throws IllegalArgumentException if leftIndex == rightIndex
272333
*/
273334
<Entity_, Value_> void swapValuesInList(PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,

0 commit comments

Comments
 (0)