705687a42e
Extract shared evolutionary algorithm infrastructure (genetic operators, candidate management, Das-Dennis reference points) from NSGA-II into a new genetic.rs module, then build two new multi-objective samplers on top: - NSGA-III: reference-point-based niching for well-distributed fronts on 3+ objective problems (Das-Dennis structured points, normalization, perpendicular distance association, niching selection) - MOEA/D: decomposition-based optimization with three scalarization methods (Tchebycheff, WeightedSum, PBI), weight-vector neighborhoods, and neighborhood-based mating selection Both implement MultiObjectiveSampler with builder pattern, seeded RNG, and SBX crossover / polynomial mutation via the shared genetic module.
604 lines
19 KiB
Rust
604 lines
19 KiB
Rust
//! MOEA/D (Multi-Objective Evolutionary Algorithm based on Decomposition) sampler.
|
|
//!
|
|
//! Decomposes a multi-objective problem into scalar subproblems using
|
|
//! weight vectors and solves them collaboratively. Supports Weighted Sum,
|
|
//! Tchebycheff, and Penalty-based Boundary Intersection (PBI) scalarization.
|
|
//!
|
|
//! # Examples
|
|
//!
|
|
//! ```
|
|
//! use optimizer::Direction;
|
|
//! use optimizer::multi_objective::MultiObjectiveStudy;
|
|
//! use optimizer::parameter::{FloatParam, Parameter};
|
|
//! use optimizer::sampler::moead::MoeadSampler;
|
|
//!
|
|
//! let sampler = MoeadSampler::with_seed(42);
|
|
//! let study =
|
|
//! MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
|
|
//!
|
|
//! let x = FloatParam::new(0.0, 1.0);
|
|
//! study
|
|
//! .optimize(100, |trial| {
|
|
//! let xv = x.suggest(trial)?;
|
|
//! Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
|
|
//! })
|
|
//! .unwrap();
|
|
//! ```
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
use super::genetic::{
|
|
self, Candidate, EvolutionaryState, Phase, advance_generation, auto_divisions,
|
|
collect_evaluated_generation, crossover, das_dennis, extract_trial_params,
|
|
generate_random_candidates, mutate, sample_from_candidate, sample_random,
|
|
};
|
|
use crate::distribution::Distribution;
|
|
use crate::multi_objective::MultiObjectiveTrial;
|
|
use crate::param::ParamValue;
|
|
use crate::types::Direction;
|
|
|
|
/// Decomposition (scalarization) method for MOEA/D.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub enum Decomposition {
|
|
/// Weighted sum: `sum(w_i * f_i)`.
|
|
WeightedSum,
|
|
/// Tchebycheff: `max(w_i * |f_i - z_i*|)`.
|
|
#[default]
|
|
Tchebycheff,
|
|
/// Penalty-based Boundary Intersection with parameter theta.
|
|
Pbi {
|
|
/// Penalty parameter controlling the balance between convergence
|
|
/// and diversity. Default: 5.0.
|
|
theta: f64,
|
|
},
|
|
}
|
|
|
|
/// MOEA/D sampler for multi-objective optimization.
|
|
///
|
|
/// Decomposes the multi-objective problem into scalar subproblems
|
|
/// using weight vectors, solving them collaboratively via
|
|
/// neighborhood-based mating and replacement.
|
|
pub struct MoeadSampler {
|
|
state: Mutex<MoeadState>,
|
|
}
|
|
|
|
impl MoeadSampler {
|
|
/// Creates a new MOEA/D sampler with a random seed.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
state: Mutex::new(MoeadState::new(MoeadConfig::default(), None)),
|
|
}
|
|
}
|
|
|
|
/// Creates a new MOEA/D sampler with a fixed seed.
|
|
#[must_use]
|
|
pub fn with_seed(seed: u64) -> Self {
|
|
Self {
|
|
state: Mutex::new(MoeadState::new(MoeadConfig::default(), Some(seed))),
|
|
}
|
|
}
|
|
|
|
/// Creates a builder for configuring a `MoeadSampler`.
|
|
#[must_use]
|
|
pub fn builder() -> MoeadSamplerBuilder {
|
|
MoeadSamplerBuilder::default()
|
|
}
|
|
}
|
|
|
|
impl Default for MoeadSampler {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Builder for [`MoeadSampler`].
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MoeadSamplerBuilder {
|
|
population_size: Option<usize>,
|
|
neighborhood_size: Option<usize>,
|
|
decomposition: Decomposition,
|
|
crossover_prob: Option<f64>,
|
|
crossover_eta: Option<f64>,
|
|
mutation_eta: Option<f64>,
|
|
seed: Option<u64>,
|
|
}
|
|
|
|
impl MoeadSamplerBuilder {
|
|
/// Sets the population size. If unset, equals the number of
|
|
/// Das-Dennis weight vectors.
|
|
#[must_use]
|
|
pub fn population_size(mut self, size: usize) -> Self {
|
|
self.population_size = Some(size);
|
|
self
|
|
}
|
|
|
|
/// Sets the neighborhood size (T). Default: `min(20, pop_size)`.
|
|
#[must_use]
|
|
pub fn neighborhood_size(mut self, size: usize) -> Self {
|
|
self.neighborhood_size = Some(size);
|
|
self
|
|
}
|
|
|
|
/// Sets the decomposition method. Default: Tchebycheff.
|
|
#[must_use]
|
|
pub fn decomposition(mut self, decomp: Decomposition) -> Self {
|
|
self.decomposition = decomp;
|
|
self
|
|
}
|
|
|
|
/// Sets the crossover probability. Default: 1.0.
|
|
#[must_use]
|
|
pub fn crossover_prob(mut self, prob: f64) -> Self {
|
|
self.crossover_prob = Some(prob);
|
|
self
|
|
}
|
|
|
|
/// Sets the SBX distribution index. Default: 20.0.
|
|
#[must_use]
|
|
pub fn crossover_eta(mut self, eta: f64) -> Self {
|
|
self.crossover_eta = Some(eta);
|
|
self
|
|
}
|
|
|
|
/// Sets the polynomial mutation distribution index. Default: 20.0.
|
|
#[must_use]
|
|
pub fn mutation_eta(mut self, eta: f64) -> Self {
|
|
self.mutation_eta = Some(eta);
|
|
self
|
|
}
|
|
|
|
/// Sets the random seed for reproducibility.
|
|
#[must_use]
|
|
pub fn seed(mut self, seed: u64) -> Self {
|
|
self.seed = Some(seed);
|
|
self
|
|
}
|
|
|
|
/// Builds the configured [`MoeadSampler`].
|
|
#[must_use]
|
|
pub fn build(self) -> MoeadSampler {
|
|
let config = MoeadConfig {
|
|
user_population_size: self.population_size,
|
|
neighborhood_size: self.neighborhood_size,
|
|
decomposition: self.decomposition,
|
|
crossover_prob: self.crossover_prob.unwrap_or(1.0),
|
|
crossover_eta: self.crossover_eta.unwrap_or(20.0),
|
|
mutation_eta: self.mutation_eta.unwrap_or(20.0),
|
|
};
|
|
MoeadSampler {
|
|
state: Mutex::new(MoeadState::new(config, self.seed)),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct MoeadConfig {
|
|
user_population_size: Option<usize>,
|
|
neighborhood_size: Option<usize>,
|
|
decomposition: Decomposition,
|
|
crossover_prob: f64,
|
|
crossover_eta: f64,
|
|
mutation_eta: f64,
|
|
}
|
|
|
|
impl Default for MoeadConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
user_population_size: None,
|
|
neighborhood_size: None,
|
|
decomposition: Decomposition::default(),
|
|
crossover_prob: 1.0,
|
|
crossover_eta: 20.0,
|
|
mutation_eta: 20.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MoeadState {
|
|
evo: EvolutionaryState,
|
|
config: MoeadConfig,
|
|
/// Weight vectors (Das-Dennis), one per subproblem.
|
|
weight_vectors: Vec<Vec<f64>>,
|
|
/// Neighborhoods: for each subproblem, indices of T nearest weight vectors.
|
|
neighborhoods: Vec<Vec<usize>>,
|
|
/// Ideal point z* (best per-objective in minimize-space).
|
|
ideal_point: Vec<f64>,
|
|
/// Current population's objective values in minimize-space (one per subproblem).
|
|
population_values: Vec<Vec<f64>>,
|
|
/// Current population's parameter vectors (one per subproblem).
|
|
population_params: Vec<Vec<ParamValue>>,
|
|
/// Whether the MOEA/D state has been initialized.
|
|
initialized: bool,
|
|
}
|
|
|
|
impl MoeadState {
|
|
fn new(config: MoeadConfig, seed: Option<u64>) -> Self {
|
|
Self {
|
|
evo: EvolutionaryState::new(seed),
|
|
config,
|
|
weight_vectors: Vec::new(),
|
|
neighborhoods: Vec::new(),
|
|
ideal_point: Vec::new(),
|
|
population_values: Vec::new(),
|
|
population_params: Vec::new(),
|
|
initialized: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MultiObjectiveSampler implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl crate::multi_objective::MultiObjectiveSampler for MoeadSampler {
|
|
fn sample(
|
|
&self,
|
|
distribution: &Distribution,
|
|
trial_id: u64,
|
|
history: &[MultiObjectiveTrial],
|
|
directions: &[Direction],
|
|
) -> ParamValue {
|
|
let mut state = self.state.lock();
|
|
|
|
match &state.evo.phase {
|
|
Phase::Discovery => {
|
|
if let Some(value) =
|
|
genetic::sample_discovery(&mut state.evo, distribution, trial_id)
|
|
{
|
|
return value;
|
|
}
|
|
// Transitioned to active phase
|
|
initialize_moead(&mut state, directions);
|
|
generate_random_candidates(&mut state.evo);
|
|
sample_from_candidate(&mut state.evo, trial_id)
|
|
}
|
|
Phase::Active => {
|
|
maybe_generate_new_generation(&mut state, history, directions);
|
|
sample_from_candidate(&mut state.evo, trial_id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Initialize MOEA/D: weight vectors, neighborhoods, ideal point.
|
|
fn initialize_moead(state: &mut MoeadState, directions: &[Direction]) {
|
|
let n_obj = directions.len();
|
|
|
|
// Generate weight vectors
|
|
let divisions = auto_divisions(n_obj, state.config.user_population_size.unwrap_or(100));
|
|
state.weight_vectors = das_dennis(n_obj, divisions);
|
|
|
|
let pop_size = state
|
|
.config
|
|
.user_population_size
|
|
.unwrap_or(state.weight_vectors.len())
|
|
.max(4);
|
|
|
|
// Trim or pad weight vectors to match population size
|
|
state.weight_vectors.truncate(pop_size);
|
|
while state.weight_vectors.len() < pop_size {
|
|
// Duplicate random existing weight vectors
|
|
let idx = state.evo.rng.usize(0..state.weight_vectors.len());
|
|
let w = state.weight_vectors[idx].clone();
|
|
state.weight_vectors.push(w);
|
|
}
|
|
|
|
// Compute neighborhoods
|
|
let t = state
|
|
.config
|
|
.neighborhood_size
|
|
.unwrap_or_else(|| 20.min(pop_size));
|
|
let t = t.min(pop_size);
|
|
state.neighborhoods = compute_neighborhoods(&state.weight_vectors, t);
|
|
|
|
state.evo.population_size = pop_size;
|
|
state.evo.phase = Phase::Active;
|
|
state.ideal_point = vec![f64::INFINITY; n_obj];
|
|
state.initialized = true;
|
|
}
|
|
|
|
/// Compute T-nearest neighborhoods by Euclidean distance between weight vectors.
|
|
fn compute_neighborhoods(weights: &[Vec<f64>], t: usize) -> Vec<Vec<usize>> {
|
|
let n = weights.len();
|
|
weights
|
|
.iter()
|
|
.map(|wi| {
|
|
let mut distances: Vec<(usize, f64)> = (0..n)
|
|
.map(|j| {
|
|
let d: f64 = wi
|
|
.iter()
|
|
.zip(&weights[j])
|
|
.map(|(&a, &b)| (a - b).powi(2))
|
|
.sum::<f64>()
|
|
.sqrt();
|
|
(j, d)
|
|
})
|
|
.collect();
|
|
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
|
|
distances.into_iter().take(t).map(|(idx, _)| idx).collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Convert values to minimize-space.
|
|
fn to_minimize_space(values: &[f64], directions: &[Direction]) -> Vec<f64> {
|
|
values
|
|
.iter()
|
|
.zip(directions)
|
|
.map(|(&v, d)| match d {
|
|
Direction::Minimize => v,
|
|
Direction::Maximize => -v,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn maybe_generate_new_generation(
|
|
state: &mut MoeadState,
|
|
history: &[MultiObjectiveTrial],
|
|
directions: &[Direction],
|
|
) {
|
|
if state.evo.candidates.is_empty() {
|
|
generate_random_candidates(&mut state.evo);
|
|
return;
|
|
}
|
|
|
|
if let Some(evaluated) = collect_evaluated_generation(&state.evo, history) {
|
|
let offspring = moead_generate_offspring(state, &evaluated, directions);
|
|
advance_generation(&mut state.evo, offspring);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scalarization functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Weighted sum scalarization: `sum(w_i * f_i)`.
|
|
fn scalarize_weighted_sum(values: &[f64], weight: &[f64]) -> f64 {
|
|
values.iter().zip(weight).map(|(&v, &w)| w * v).sum()
|
|
}
|
|
|
|
/// Tchebycheff scalarization: `max(w_i * |f_i - z_i*|)`.
|
|
fn scalarize_tchebycheff(values: &[f64], weight: &[f64], ideal: &[f64]) -> f64 {
|
|
values
|
|
.iter()
|
|
.zip(weight)
|
|
.zip(ideal)
|
|
.map(|((&v, &w), &z)| {
|
|
let w = if w < 1e-6 { 1e-6 } else { w };
|
|
w * (v - z).abs()
|
|
})
|
|
.fold(f64::NEG_INFINITY, f64::max)
|
|
}
|
|
|
|
/// PBI scalarization: `d1 + theta * d2`.
|
|
///
|
|
/// d1 = projection onto weight direction, d2 = perpendicular distance.
|
|
fn scalarize_pbi(values: &[f64], weight: &[f64], ideal: &[f64], theta: f64) -> f64 {
|
|
let n = values.len();
|
|
|
|
// Direction from ideal to the point
|
|
let diff: Vec<f64> = values.iter().zip(ideal).map(|(&v, &z)| v - z).collect();
|
|
|
|
// Normalize weight vector
|
|
let w_norm: f64 = weight.iter().map(|&w| w * w).sum::<f64>().sqrt();
|
|
if w_norm < 1e-30 {
|
|
return f64::INFINITY;
|
|
}
|
|
let w_unit: Vec<f64> = weight.iter().map(|&w| w / w_norm).collect();
|
|
|
|
// d1 = projection of diff onto weight direction
|
|
let d1: f64 = diff.iter().zip(&w_unit).map(|(&d, &w)| d * w).sum();
|
|
|
|
// d2 = perpendicular distance
|
|
let d2_sq: f64 = (0..n)
|
|
.map(|i| {
|
|
let proj = d1 * w_unit[i];
|
|
(diff[i] - proj).powi(2)
|
|
})
|
|
.sum::<f64>();
|
|
|
|
d1 + theta * d2_sq.sqrt()
|
|
}
|
|
|
|
/// Evaluate scalarization for a given decomposition method.
|
|
fn scalarize(values: &[f64], weight: &[f64], ideal: &[f64], decomposition: &Decomposition) -> f64 {
|
|
match decomposition {
|
|
Decomposition::WeightedSum => scalarize_weighted_sum(values, weight),
|
|
Decomposition::Tchebycheff => scalarize_tchebycheff(values, weight, ideal),
|
|
Decomposition::Pbi { theta } => scalarize_pbi(values, weight, ideal, *theta),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MOEA/D generation algorithm
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn moead_generate_offspring(
|
|
state: &mut MoeadState,
|
|
population: &[&MultiObjectiveTrial],
|
|
directions: &[Direction],
|
|
) -> Vec<Candidate> {
|
|
let pop_size = state.evo.population_size;
|
|
|
|
if population.len() < 2 {
|
|
return (0..pop_size)
|
|
.map(|_| {
|
|
let params = state
|
|
.evo
|
|
.dimensions
|
|
.iter()
|
|
.map(|d| sample_random(&mut state.evo.rng, &d.distribution))
|
|
.collect();
|
|
Candidate { params }
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
// Extract current population parameters and objective values
|
|
let current_params: Vec<Vec<ParamValue>> = population
|
|
.iter()
|
|
.map(|t| extract_trial_params(t, &state.evo.dimensions, &mut state.evo.rng))
|
|
.collect();
|
|
|
|
let current_values: Vec<Vec<f64>> = population
|
|
.iter()
|
|
.map(|t| to_minimize_space(&t.values, directions))
|
|
.collect();
|
|
|
|
// Update ideal point
|
|
for vals in ¤t_values {
|
|
for (i, &v) in vals.iter().enumerate() {
|
|
if i < state.ideal_point.len() && v < state.ideal_point[i] {
|
|
state.ideal_point[i] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assign each solution to its best subproblem via scalarization
|
|
// and select the best solution for each subproblem as its representative
|
|
let n_weights = state.weight_vectors.len();
|
|
let mut best_for_subproblem: Vec<usize> = Vec::with_capacity(n_weights);
|
|
|
|
for j in 0..n_weights {
|
|
let mut best_idx = 0;
|
|
let mut best_val = f64::INFINITY;
|
|
for (k, vals) in current_values.iter().enumerate() {
|
|
let s = scalarize(
|
|
vals,
|
|
&state.weight_vectors[j],
|
|
&state.ideal_point,
|
|
&state.config.decomposition,
|
|
);
|
|
if s < best_val {
|
|
best_val = s;
|
|
best_idx = k;
|
|
}
|
|
}
|
|
best_for_subproblem.push(best_idx);
|
|
}
|
|
|
|
// Store current population state
|
|
state.population_values = current_values;
|
|
state.population_params = current_params;
|
|
|
|
// Generate offspring: for each subproblem, mate from neighborhood
|
|
let mut offspring = Vec::with_capacity(pop_size);
|
|
|
|
for i in 0..pop_size.min(state.neighborhoods.len()) {
|
|
let neighborhood = &state.neighborhoods[i];
|
|
|
|
// Pick two parents from the neighborhood using subproblem assignments
|
|
let n1 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
|
|
let n2 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
|
|
|
|
let p1_idx = best_for_subproblem[n1 % best_for_subproblem.len()];
|
|
let p2_idx = best_for_subproblem[n2 % best_for_subproblem.len()];
|
|
|
|
let p1 = &state.population_params[p1_idx];
|
|
let p2 = &state.population_params[p2_idx];
|
|
|
|
let (mut child1, _child2) = crossover(
|
|
&mut state.evo.rng,
|
|
p1,
|
|
p2,
|
|
&state.evo.dimensions,
|
|
state.config.crossover_prob,
|
|
state.config.crossover_eta,
|
|
);
|
|
|
|
mutate(
|
|
&mut state.evo.rng,
|
|
&mut child1,
|
|
&state.evo.dimensions,
|
|
state.config.mutation_eta,
|
|
);
|
|
|
|
offspring.push(Candidate { params: child1 });
|
|
}
|
|
|
|
// If pop_size > neighborhoods, fill remaining with random neighborhood crossover
|
|
while offspring.len() < pop_size {
|
|
let i = state.evo.rng.usize(0..state.neighborhoods.len());
|
|
let neighborhood = &state.neighborhoods[i];
|
|
let n1 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
|
|
let n2 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
|
|
|
|
let p1_idx = best_for_subproblem[n1 % best_for_subproblem.len()];
|
|
let p2_idx = best_for_subproblem[n2 % best_for_subproblem.len()];
|
|
|
|
let (mut child1, _) = crossover(
|
|
&mut state.evo.rng,
|
|
&state.population_params[p1_idx],
|
|
&state.population_params[p2_idx],
|
|
&state.evo.dimensions,
|
|
state.config.crossover_prob,
|
|
state.config.crossover_eta,
|
|
);
|
|
|
|
mutate(
|
|
&mut state.evo.rng,
|
|
&mut child1,
|
|
&state.evo.dimensions,
|
|
state.config.mutation_eta,
|
|
);
|
|
|
|
offspring.push(Candidate { params: child1 });
|
|
}
|
|
|
|
offspring
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_scalarize_weighted_sum() {
|
|
let values = [1.0, 2.0, 3.0];
|
|
let weight = [0.5, 0.3, 0.2];
|
|
let result = scalarize_weighted_sum(&values, &weight);
|
|
assert!((result - (0.5 + 0.6 + 0.6)).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scalarize_tchebycheff() {
|
|
let values = [3.0, 2.0];
|
|
let weight = [0.5, 0.5];
|
|
let ideal = [1.0, 1.0];
|
|
let result = scalarize_tchebycheff(&values, &weight, &ideal);
|
|
// max(0.5 * |3-1|, 0.5 * |2-1|) = max(1.0, 0.5) = 1.0
|
|
assert!((result - 1.0).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scalarize_pbi() {
|
|
let values = [2.0, 2.0];
|
|
let weight = [1.0, 1.0];
|
|
let ideal = [0.0, 0.0];
|
|
let result = scalarize_pbi(&values, &weight, &ideal, 5.0);
|
|
// d1 = projection of (2,2) onto (1/√2, 1/√2) = 2*√2
|
|
// d2 = 0 (point is on the weight direction)
|
|
let expected_d1 = 2.0 * (2.0_f64).sqrt();
|
|
assert!((result - expected_d1).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_neighborhoods() {
|
|
let weights = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.0, 1.0]];
|
|
let neighborhoods = compute_neighborhoods(&weights, 2);
|
|
assert_eq!(neighborhoods.len(), 3);
|
|
// Each neighborhood should have 2 entries
|
|
for n in &neighborhoods {
|
|
assert_eq!(n.len(), 2);
|
|
}
|
|
// First weight [1,0] should be closest to itself and [0.5,0.5]
|
|
assert_eq!(neighborhoods[0][0], 0); // itself
|
|
assert_eq!(neighborhoods[0][1], 1); // nearest neighbor
|
|
}
|
|
}
|