2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事について

前回はVRP(Vehicle Routing Problem)の基礎概念を説明しました。
今回はCVRPを実際のアルゴリズムで解いていきます。

TSPシリーズで学んだ「構築ヒューリスティクス」と「局所改善」の考え方をVRPに応用していきます。

配送最適化入門シリーズの記事一覧はこちら。

記事 内容
⓪ 配送最適化とは?基礎概念
① 近傍法の説明
② 2-optの説明
③ 3-optの説明
④ Lin-Kernighan法の説明
⑤ 焼きなまし法(SA)の説明
⑥ 遺伝的アルゴリズム(GA)の説明
⑦ VRPとは?
⑧(本記事) VRPをアルゴリズムで解く

Clarke-Wright節約法

VRPの最もよく知られた構築ヒューリスティクスが Clarke-Wright節約法(Savings Algorithm) です。

G. Clarke and J. W. Wright, "Scheduling of Vehicles from a Central Depot to a Number of Delivery Points," Operations Research, vol. 12, no. 4, pp. 568-581, 1964.

アイデア

まず「各顧客を別々の車両で往復する」解($n$ 台使用)を出発点とします。

初期解(n台):
  デポ → 顧客1 → デポ
  デポ → 顧客2 → デポ
  デポ → 顧客3 → デポ
  ...

2つのルートを1つにまとめると、デポへの往復の一部が省略されて距離が節約できます。

顧客iと顧客jを同じルートにまとめた場合の節約量:
s(i, j) = dist(depot, i) + dist(depot, j) - dist(i, j)

この節約量が大きい組み合わせから順にルートをまとめていき、容量制約を超えない範囲で統合します。

アルゴリズムの手順

① 全顧客ペア $(i, j)$ の節約量 $s(i, j) = d(0, i) + d(0, j) - d(i, j)$ を計算
② 節約量の降順にソート
③ 節約量が大きいペアから順に、容量制約を守れる場合にルートを統合

Python実装

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = 'Yu Gothic'

class CVRP:
    def __init__(self, depot, customers, demands, capacity):
        self.depot     = np.array(depot)
        self.customers = np.array(customers)
        self.demands   = np.array(demands)
        self.capacity  = capacity
        self.n         = len(customers)

        all_points    = np.vstack([depot, customers])
        self.n_nodes  = len(all_points)
        self.dist     = np.zeros((self.n_nodes, self.n_nodes))
        for i in range(self.n_nodes):
            for j in range(self.n_nodes):
                self.dist[i][j] = np.linalg.norm(all_points[i] - all_points[j])

    def route_distance(self, route):
        if not route:
            return 0
        d = self.dist[0][route[0]]
        for i in range(len(route) - 1):
            d += self.dist[route[i]][route[i + 1]]
        d += self.dist[route[-1]][0]
        return d

    def total_distance(self, routes):
        return sum(self.route_distance(r) for r in routes)


def clarke_wright(problem):
    """
    Clarke-Wright節約法
    """
    n = problem.n
    dist = problem.dist

    # ① 節約量の計算
    savings = []
    for i in range(1, n + 1):
        for j in range(i + 1, n + 1):
            s = dist[0][i] + dist[0][j] - dist[i][j]
            savings.append((s, i, j))

    # ② 節約量の降順にソート
    savings.sort(reverse=True)

    # 初期ルート:各顧客を個別のルートに
    routes = {i: [i] for i in range(1, n + 1)}  # {顧客: そのルート}
    route_loads = {i: problem.demands[i - 1] for i in range(1, n + 1)}
    route_ids   = {i: i for i in range(1, n + 1)}  # どのルートに属するか

    # ③ 節約量が大きい順に統合
    for saving, i, j in savings:
        if saving <= 0:
            break

        ri = route_ids[i]
        rj = route_ids[j]
        if ri == rj:
            continue  # すでに同じルート

        route_i = routes[ri]
        route_j = routes[rj]

        # iがルートiの末尾、jがルートjの先頭でなければ統合できない
        # (あるいはその逆)
        can_merge = False
        merged = None

        if route_i[-1] == i and route_j[0] == j:
            merged = route_i + route_j
            can_merge = True
        elif route_i[-1] == i and route_j[-1] == j:
            merged = route_i + route_j[::-1]
            can_merge = True
        elif route_i[0] == i and route_j[0] == j:
            merged = route_i[::-1] + route_j
            can_merge = True
        elif route_i[0] == i and route_j[-1] == j:
            merged = route_j + route_i
            can_merge = True

        if not can_merge:
            continue

        # 容量チェック
        new_load = route_loads[ri] + route_loads[rj]
        if new_load > problem.capacity:
            continue

        # ルートを統合
        routes[ri] = merged
        route_loads[ri] = new_load
        del routes[rj]
        del route_loads[rj]

        for c in route_j:
            route_ids[c] = ri

    return list(routes.values())

実行してみる

def generate_cvrp(n_customers=15, capacity=50, seed=42):
    np.random.seed(seed)
    depot     = [50, 50]
    customers = np.random.rand(n_customers, 2) * 100
    demands   = np.random.randint(5, 20, size=n_customers)
    return CVRP(depot, customers.tolist(), demands.tolist(), capacity)

problem = generate_cvrp(n_customers=15, capacity=50, seed=42)

# Clarke-Wright節約法で初期解を生成
routes = clarke_wright(problem)

print(f'使用車両数: {len(routes)}台')
print(f'総距離: {problem.total_distance(routes):.1f}')
for k, route in enumerate(routes):
    load = sum(problem.demands[c - 1] for c in route)
    print(f'  車両{k+1}: {route}  (需要={load}/{problem.capacity})')
使用車両数: 4台
総距離: 495.1
  車両1: [2, 9]  (需要=27/50)
  車両2: [12, 3, 8, 14]  (需要=50/50)
  車両3: [5, 13, 1, 6, 4]  (需要=50/50)
  車両4: [10, 11, 15, 7]  (需要=41/50)

改善:VRP向け2-opt

各車両のルートに対してTSPシリーズで学んだ2-optを適用して、さらに改善します。

def vrp_two_opt_intra(problem, routes):
    """
    各車両ルート内で2-optを適用(ルート内改善)
    """
    dist = problem.dist
    improved_routes = []

    for route in routes:
        if len(route) <= 2:
            improved_routes.append(route)
            continue

        # デポ(0)を含む完全なルートで2-opt
        full_route = [0] + route + [0]
        n = len(full_route) - 1  # 最後の0は除く

        improved = True
        while improved:
            improved = False
            for i in range(1, n - 1):
                for j in range(i + 1, n):
                    a, b = full_route[i - 1], full_route[i]
                    c, d = full_route[j], full_route[j + 1]
                    if dist[a][c] + dist[b][d] < dist[a][b] + dist[c][d] - 1e-10:
                        full_route[i:j + 1] = full_route[i:j + 1][::-1]
                        improved = True

        improved_routes.append(full_route[1:-1])  # デポを除いて返す

    return improved_routes


def vrp_two_opt_inter(problem, routes):
    """
    車両間で顧客を移動・交換する改善(ルート間改善)
    """
    dist = problem.dist
    best_routes = [r[:] for r in routes]
    best_total  = problem.total_distance(best_routes)
    improved = True

    while improved:
        improved = False
        for ri in range(len(best_routes)):
            for ci in range(len(best_routes[ri])):
                customer = best_routes[ri][ci]
                demand   = problem.demands[customer - 1]

                # 別のルートに移動できるか試す
                for rj in range(len(best_routes)):
                    if ri == rj:
                        continue

                    load_rj = sum(problem.demands[c - 1] for c in best_routes[rj])
                    if load_rj + demand > problem.capacity:
                        continue

                    # 移動後のルートを構築
                    new_ri = best_routes[ri][:ci] + best_routes[ri][ci + 1:]
                    for pos in range(len(best_routes[rj]) + 1):
                        new_rj = best_routes[rj][:pos] + [customer] + best_routes[rj][pos:]
                        new_routes = [r[:] for r in best_routes]
                        new_routes[ri] = new_ri
                        new_routes[rj] = new_rj
                        # 空になったルートを除去
                        new_routes = [r for r in new_routes if r]
                        new_total = problem.total_distance(new_routes)
                        if new_total < best_total - 1e-10:
                            best_routes = new_routes
                            best_total  = new_total
                            improved = True
                            break
                    if improved:
                        break
                if improved:
                    break
            if improved:
                break

    return best_routes

改善を適用

# 2-opt(ルート内)で改善
routes_intra = vrp_two_opt_intra(problem, routes)
print(f'ルート内2-opt後: {problem.total_distance(routes_intra):.1f}')

# ルート間の改善
routes_inter = vrp_two_opt_inter(problem, routes_intra)
print(f'ルート間改善後:  {problem.total_distance(routes_inter):.1f}')
ルート内2-opt後: 494.2
ルート間改善後:  492.0

結果の可視化

def plot_cvrp_routes(problem, routes, title='CVRP解'):
    colors = ['#e74c3c', '#3498db', '#2ecc71', '#e67e22',
              '#9b59b6', '#1abc9c', '#f39c12', '#34495e']
    plt.figure(figsize=(8, 7))
    plt.scatter(*problem.depot, c='black', s=200, marker='*', zorder=6, label='デポ')
    for i, (pos, d) in enumerate(zip(problem.customers, problem.demands)):
        plt.scatter(*pos, c='gray', s=80, zorder=5)
        plt.annotate(f'{i+1}({d})', pos + [1, 1], fontsize=7)
    for k, route in enumerate(routes):
        color  = colors[k % len(colors)]
        pts    = [problem.depot] + [problem.customers[c - 1] for c in route] + [problem.depot]
        xs = [p[0] for p in pts]
        ys = [p[1] for p in pts]
        load = sum(problem.demands[c - 1] for c in route)
        plt.plot(xs, ys, '-o', color=color, linewidth=1.8,
                 markersize=5, label=f'車両{k+1}(需要{load})')
    d_total = problem.total_distance(routes)
    plt.title(f'{title}  総距離:{d_total:.1f}  {len(routes)}台')
    plt.legend(fontsize=8, loc='upper left')
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig('cvrp_solution.png', dpi=150)
    plt.show()

plot_cvrp_routes(problem, routes, title='Clarke-Wright', name='cvrp_clarke_wright.png')

plot_cvrp_routes(problem, routes_intra, title='Clarke-Wright + 2-opt(ルート内)', name='cvrp_clarke_wright_intra.png')

plot_cvrp_routes(problem, routes_inter, title='Clarke-Wright + 2-opt(ルート内)+ ルート間改善', name='cvrp_clarke_wright_inter.png')

微妙な違いで分かりにくいですが、以下が出力となります。

cvrp_clarke_wright.png

cvrp_clarke_wright_intra.png

cvrp_clarke_wright_inter.png

まとめ:シリーズ全体の振り返り

今回はVRPをアルゴリズムで解く方法を説明しました。

  • Clarke-Wright節約法:節約量の大きい顧客ペアから順にルートを統合する構築ヒューリスティクス
  • ルート内2-opt:各車両のルートをTSPの2-optで改善
  • ルート間改善:顧客を別の車両ルートに移動させて全体最適化

これでシリーズ全8記事が完結です。改めて全体を振り返ると:

フェーズ 手法 記事
問題理解 TSP・VRPの定義 ⓪⑦
初期解構築 近傍法・Clarke-Wright ①⑧
局所改善 2-opt・3-opt・LK法 ②③④
大域探索 SA・GA ⑤⑥

実務では「近傍法 → 2-opt → SAまたはGA」という組み合わせがバランスが良く、まず試してみると良いかと思います。

参考にした記事や本、論文等

  • G. Clarke and J. W. Wright, "Scheduling of Vehicles from a Central Depot to a Number of Delivery Points," Operations Research, vol. 12, no. 4, pp. 568-581, 1964.
  • P. Toth and D. Vigo, eds., "The Vehicle Routing Problem," SIAM, 2002.
  • Google OR-Tools VRPドキュメント
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?