使用.NET Core與Google Optimization Tools實現員工排班計划Scheduling


上一篇說完《Google Optimization Tools介紹》,讓大家初步了解了Google Optimization Tools是一款約束求解(CP)的高效套件。那么我們用.NET Core與Google Optimization Tools來實現一個有關員工排班計划的場景感受一下。

眾所周知,現實生活中有些工作是7X24工作制的,如呼叫中心或醫院護士,最常見的問題就是如何安排多名員工進行倒班,制定好日程時間表,使每班配備足夠的人員來維持運營。時間表有各種不同的約束要求,例如:員工不允許連續兩次輪班之類。接下來我們介紹類似問題的一個示例,叫護士調度問題,並展示了如何使用.NET Core與Google Optimization Tools實現排班計划。

護士調度問題

在本例中,醫院主管需要為四名護士創建一個周時間表,具體情況如下:

  • 每天分為早、中、晚三班輪班。
  • 在每一天,所有護士都被分配到不同的班次,除了有一名護士可以休息。
  • 每位護士每周工作五到六天。
  • 每個班次不會有超過兩名護士在工作。
  • 如果一名護士某一天的班次是中班或晚班,她也必須在前一日或次日安排相同的班次。

有兩種方式來描述我們需要解決的問題:

  • 指派護士輪班
  • 將班次分配給護士

事實證明,解決問題的最好方法是結合兩種方式來求解。

指派護士輪班

下表顯示了指派護士輪班視角的排班情況,這些護士被標記為A,B,C,D,換班,編號為0 - 3(其中0表示護士當天不工作)。

 

星期日

星期一 星期二 星期三 星期四 星期五 星期六
班次1

A

B

A

A

A

A

A

班次2

C

C

C

B

B

B

B

班次3

D

D

D

D

C

C

D

將班次分配給護士

下表顯示了將班次分配給護士視角的排班情況。

  星期日 星期一 星期二 星期三 星期四 星期五 星期六
護士A 1 0 1 1 1 1 1
護士B 0 1 0 2 2 2 2
護士C 2 2 2 0 3 3 0
護士D 3 3 3 3 0 0 3

 

.NET Core解決方案

首先使用VS017創建一個.NET Core的控制台項目。

 

由於Google Optimization Tools對.NET Core的支持還不友好,需要通過NuGet引用一個第三方專門為Core編譯好的程序集以及相關依賴,Google.OrTools.Core和CrossPlatformLibraryLoader。

 

准備完成后,我們逐一介紹編碼的過程。首先介紹幾個基本概念:

  • IntVar是約束求解中使用最多的變量形式,一般約束問題中變化的對象都應該定義為一個類似在一定范圍內整形數值的變量。
  • solver.MakeIntVar是創建約束求解中變量的方法,約束求解一定會定義一些可變化的對象,一般都需要轉化成數值類型。
  • solver.Add是添加若干約束條件的方法。
  • solver.MakePhase定義了求解的目標以及求解的取值策略。
  • solver.Solve進行求解,並對指定的集合賦值。
  • solver.MakeAllSolutionCollector表示獲取解的集合對象。

定義約束求解器和相關變量

我們用shift和nurse分別來表示班次和護士 。

       // 創建約束求解器.
        var solver = new Solver("schedule_shifts");
        var num_nurses = 4;
        var num_shifts = 4;  // 班次數定為4,這樣序號為0的班次表示是休息的班。
        var num_days = 7;

        // [START]
        // 創建班次變量
        var shifts = new Dictionary<(int, int), IntVar>();

        foreach (var j in Enumerable.Range(0, num_nurses))
        {
            foreach (var i in Enumerable.Range(0, num_days))
            {
                // shifts[(j, i)]表示護士j在第i天的班次,可能的班次的編號范圍是:[0, num_shifts)
                shifts[(j, i)] = solver.MakeIntVar(0, num_shifts - 1, string.Format("shifts({0},{1})", j, i));
            }
        }

        // 將變量集合轉成扁平化數組
        var shifts_flat = (from j in Enumerable.Range(0, num_nurses)
                           from i in Enumerable.Range(0, num_days)
                           select shifts[(j, i)]).ToArray();

        // 創建護士變量
        var nurses = new Dictionary<(int, int), IntVar>();

        foreach (var j in Enumerable.Range(0, num_shifts))
        {
            foreach (var i in Enumerable.Range(0, num_days))
            {
                // nurses[(j, i)]表示班次j在第i天的當班護士,可能的護士的編號范圍是:[0, num_nurses)
                nurses[(j, i)] = solver.MakeIntVar(0, num_nurses - 1, string.Format("shift{0} day{1}", j, i));
            }
        }

shifts和nurses兩個對象含義如下:

shifts[(j, i)]表示護士j在第i天的班次,可能的班次的編號范圍是:[0, num_shifts)。
nurses[(j, i)]表示班次j在第i天的當班護士,可能的護士的編號范圍是:[0, num_nurses)。
shifts_flat是將shifts的Values簡單地處理成扁平化,后面直接用於當參數傳給約束求解器solver以指定需要求解的變量。

定義shifts和nurses的對應關系

將每一天的nurses單獨列出來,按照編號順序扁平化成一個數組對象,s.IndexOf(nurses_for_day)是一種OR-Tools要求的特定用法,相當於nurses_for_day[s]求值。這里利用了s的值恰好是在nurses_for_day中對應nurse的編號。注意這里的兩層foreach循環,v外層不能互換,必須是現在這樣,內層循環的主體對象與shifts_flat一致。

       // 定義shifts和nurses之前的關聯關系
        foreach (var day in Enumerable.Range(0, num_days))
        {
            var nurses_for_day = (from j in Enumerable.Range(0, num_shifts)
                                  select nurses[(j, day)]).ToArray();
            foreach (var j in Enumerable.Range(0, num_nurses))
            {
                var s = shifts[(j, day)];
                // s.IndexOf(nurses_for_day)相當於nurses_for_day[s]
                // 這里利用了s的值恰好是在nurses_for_day中對應nurse的編號
                solver.Add(s.IndexOf(nurses_for_day) == j);
            }
        }

 

定義護士在不同的班次當班約束

AllDifferent方法是OR-Tools定義約束的方法之一,表示指定的IntVar數組在進行計算時受唯一性制約。滿足每一天的當班護士不重復,即每一天的班次不會出現重復的護士的約束條件,同樣每一個護士每天不可能同時輪值不同的班次。

        // 滿足每一天的當班護士不重復,每一天的班次不會出現重復的護士的約束條件
        // 同樣每一個護士每天不可能同時輪值不同的班次
        foreach (var i in Enumerable.Range(0, num_days))
        {
            solver.Add((from j in Enumerable.Range(0, num_nurses)
                        select shifts[(j, i)]).ToArray().AllDifferent());
            solver.Add((from j in Enumerable.Range(0, num_shifts)
                        select nurses[(j, i)]).ToArray().AllDifferent());
        }

 

定義護士每周當班次數的約束

Sum方法是OR-Tools定義運算的方法之一。注意shifts[(j, i)] > 0運算被重載過,其返回類型是WrappedConstraint而不是默認的bool。滿足每個護士在一周范圍內只出現[5, 6]次。

        // 滿足每個護士在一周范圍內只出現[5, 6]次
        foreach (var j in Enumerable.Range(0, num_nurses))
        {
            solver.Add((from i in Enumerable.Range(0, num_days)
                        select shifts[(j, i)] > 0).ToArray().Sum() >= 5);
            solver.Add((from i in Enumerable.Range(0, num_days)
                        select shifts[(j, i)] > 0).ToArray().Sum() <= 6);
        }

 

定義每個班次在一周內當班護士人數的約束

Max方法是OR-Tools定義運算的方法之一,表示對指定的IntVar數組求最大值。注意MakeBoolVar方法返回類型是IntVar而不是默認的bool,works_shift[(i, j)]為True表示護士i在班次j一周內至少要有1次,BoolVar類型的變量最終取值是0或1,同樣也表示了False或True。滿足每個班次一周內不會有超過兩名護士當班工作。

        // 創建一個工作的變量,works_shift[(i, j)]為True表示護士i在班次j一周內至少要有1次
        // BoolVar類型的變量最終取值是0或1,同樣也表示了False或True
        var works_shift = new Dictionary<(int, int), IntVar>();

        foreach (var i in Enumerable.Range(0, num_nurses))
        {
            foreach (var j in Enumerable.Range(0, num_shifts))
            {
                works_shift[(i, j)] = solver.MakeBoolVar(string.Format("nurse%d shift%d", i, j));
            }
        }

        foreach (var i in Enumerable.Range(0, num_nurses))
        {
            foreach (var j in Enumerable.Range(0, num_shifts))
            {
                // 建立works_shift與shifts的關聯關系
                // 一周內的值要么為0要么為1,所以Max定義的約束是最大值,恰好也是0或1,1表示至少在每周輪班一天
                solver.Add(works_shift[(i, j)] == (from k in Enumerable.Range(0, num_days)
                                                   select shifts[(i, k)].IsEqual(j)).ToArray().Max());
            }
        }

        // 對於每個編號不為0的shift, 滿足至少每周最多同一個班次2個護士當班
        foreach (var j in Enumerable.Range(1, num_shifts - 1))
        {
            solver.Add((from i in Enumerable.Range(0, num_nurses)
                        select works_shift[(i, j)]).ToArray().Sum() <= 2);
        }

 

定義護士在中班和晚班的連班約束

        // 滿足中班或晚班的護士前一天或后一天也是相同的班次
        // 用nurses的key中Tuple類型第1個item的值表示shift為2或3
        // shift為1表示早班班次,shift為0表示休息的班次
        solver.Add(solver.MakeMax(nurses[(2, 0)] == nurses[(2, 1)], nurses[(2, 1)] == nurses[(2, 2)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 1)] == nurses[(2, 2)], nurses[(2, 2)] == nurses[(2, 3)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 2)] == nurses[(2, 3)], nurses[(2, 3)] == nurses[(2, 4)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 3)] == nurses[(2, 4)], nurses[(2, 4)] == nurses[(2, 5)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 4)] == nurses[(2, 5)], nurses[(2, 5)] == nurses[(2, 6)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 5)] == nurses[(2, 6)], nurses[(2, 6)] == nurses[(2, 0)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 6)] == nurses[(2, 0)], nurses[(2, 0)] == nurses[(2, 1)]) == 1);

        solver.Add(solver.MakeMax(nurses[(3, 0)] == nurses[(3, 1)], nurses[(3, 1)] == nurses[(3, 2)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 1)] == nurses[(3, 2)], nurses[(3, 2)] == nurses[(3, 3)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 2)] == nurses[(3, 3)], nurses[(3, 3)] == nurses[(3, 4)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 3)] == nurses[(3, 4)], nurses[(3, 4)] == nurses[(3, 5)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 4)] == nurses[(3, 5)], nurses[(3, 5)] == nurses[(3, 6)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 5)] == nurses[(3, 6)], nurses[(3, 6)] == nurses[(3, 0)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 6)] == nurses[(3, 0)], nurses[(3, 0)] == nurses[(3, 1)]) == 1);

 

定義約束求解器的使用

        // 將變量集合設置為求解的目標,Solver有一系列的枚舉值,可以指定求解的選擇策略。
        var db = solver.MakePhase(shifts_flat, Solver.CHOOSE_FIRST_UNBOUND, Solver.ASSIGN_MIN_VALUE);
        

        // 創建求解的對象
        var solution = solver.MakeAssignment();
        solution.Add(shifts_flat);
        var collector = solver.MakeAllSolutionCollector(solution);

 

執行求解計算並顯示結果

        solver.Solve(db, new[] { collector });
        Console.WriteLine("Solutions found: {0}", collector.SolutionCount());
        Console.WriteLine("Time: {0}ms", solver.WallTime());
        Console.WriteLine();

        // 顯示一些隨機的結果
        var a_few_solutions = new[] { 340, 2672, 7054 };

        foreach (var sol in a_few_solutions)
        {
            Console.WriteLine("Solution number {0}", sol);

            foreach (var i in Enumerable.Range(0, num_days))
            {
                Console.WriteLine("Day {0}", i);
                foreach (var j in Enumerable.Range(0, num_nurses))
                {
                    Console.WriteLine("Nurse {0} assigned to task {1}", j, collector.Value(sol, shifts[(j, i)]));
                }
                Console.WriteLine();
            }
        }

運行結果如下:

 

 

 

最后,放出完整的代碼如下

using Google.OrTools.ConstraintSolver;
using System;
using System.Collections.Generic;
using System.Linq;

public class ConsoleApp1
{
    static void Main()
    {
        // 創建約束求解器.
        var solver = new Solver("schedule_shifts");
        var num_nurses = 4;
        var num_shifts = 4;  // 班次數定為4,這樣序號為0的班次表示是休息的班。
        var num_days = 7;

        // [START]
        // 創建班次變量
        var shifts = new Dictionary<(int, int), IntVar>();

        foreach (var j in Enumerable.Range(0, num_nurses))
        {
            foreach (var i in Enumerable.Range(0, num_days))
            {
                // shifts[(j, i)]表示護士j在第i天的班次,可能的班次的編號范圍是:[0, num_shifts)
                shifts[(j, i)] = solver.MakeIntVar(0, num_shifts - 1, string.Format("shifts({0},{1})", j, i));
            }
        }

        // 將變量集合轉成扁平化數組
        var shifts_flat = (from j in Enumerable.Range(0, num_nurses)
                           from i in Enumerable.Range(0, num_days)
                           select shifts[(j, i)]).ToArray();

        // 創建護士變量
        var nurses = new Dictionary<(int, int), IntVar>();

        foreach (var j in Enumerable.Range(0, num_shifts))
        {
            foreach (var i in Enumerable.Range(0, num_days))
            {
                // nurses[(j, i)]表示班次j在第i天的當班護士,可能的護士的編號范圍是:[0, num_nurses)
                nurses[(j, i)] = solver.MakeIntVar(0, num_nurses - 1, string.Format("shift{0} day{1}", j, i));
            }
        }

        // 定義shifts和nurses之前的關聯關系
        foreach (var day in Enumerable.Range(0, num_days))
        {
            var nurses_for_day = (from j in Enumerable.Range(0, num_shifts)
                                  select nurses[(j, day)]).ToArray();
            foreach (var j in Enumerable.Range(0, num_nurses))
            {
                var s = shifts[(j, day)];
                // s.IndexOf(nurses_for_day)相當於nurses_for_day[s]
                // 這里利用了s的值恰好是在nurses_for_day中對應nurse的編號
                solver.Add(s.IndexOf(nurses_for_day) == j);
            }
        }

        // 滿足每一天的當班護士不重復,每一天的班次不會出現重復的護士的約束條件
        // 同樣每一個護士每天不可能同時輪值不同的班次
        foreach (var i in Enumerable.Range(0, num_days))
        {
            solver.Add((from j in Enumerable.Range(0, num_nurses)
                        select shifts[(j, i)]).ToArray().AllDifferent());
            solver.Add((from j in Enumerable.Range(0, num_shifts)
                        select nurses[(j, i)]).ToArray().AllDifferent());
        }

        // 滿足每個護士在一周范圍內只出現[5, 6]次
        foreach (var j in Enumerable.Range(0, num_nurses))
        {
            solver.Add((from i in Enumerable.Range(0, num_days)
                        select shifts[(j, i)] > 0).ToArray().Sum() >= 5);
            solver.Add((from i in Enumerable.Range(0, num_days)
                        select shifts[(j, i)] > 0).ToArray().Sum() <= 6);
        }

        // 創建一個工作的變量,works_shift[(i, j)]為True表示護士i在班次j一周內至少要有1次
        // BoolVar類型的變量最終取值是0或1,同樣也表示了False或True
        var works_shift = new Dictionary<(int, int), IntVar>();

        foreach (var i in Enumerable.Range(0, num_nurses))
        {
            foreach (var j in Enumerable.Range(0, num_shifts))
            {
                works_shift[(i, j)] = solver.MakeBoolVar(string.Format("nurse%d shift%d", i, j));
            }
        }

        foreach (var i in Enumerable.Range(0, num_nurses))
        {
            foreach (var j in Enumerable.Range(0, num_shifts))
            {
                // 建立works_shift與shifts的關聯關系
                // 一周內的值要么為0要么為1,所以Max定義的約束是最大值,恰好也是0或1,1表示至少在每周輪班一天
                solver.Add(works_shift[(i, j)] == (from k in Enumerable.Range(0, num_days)
                                                   select shifts[(i, k)].IsEqual(j)).ToArray().Max());
            }
        }

        // 對於每個編號不為0的shift, 滿足至少每周最多同一個班次2個護士當班
        foreach (var j in Enumerable.Range(1, num_shifts - 1))
        {
            solver.Add((from i in Enumerable.Range(0, num_nurses)
                        select works_shift[(i, j)]).ToArray().Sum() <= 2);
        }

        // 滿足中班或晚班的護士前一天或后一天也是相同的班次
        // 用nurses的key中Tuple類型第1個item的值表示shift為2或3
        // shift為1表示早班班次,shift為0表示休息的班次
        solver.Add(solver.MakeMax(nurses[(2, 0)] == nurses[(2, 1)], nurses[(2, 1)] == nurses[(2, 2)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 1)] == nurses[(2, 2)], nurses[(2, 2)] == nurses[(2, 3)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 2)] == nurses[(2, 3)], nurses[(2, 3)] == nurses[(2, 4)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 3)] == nurses[(2, 4)], nurses[(2, 4)] == nurses[(2, 5)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 4)] == nurses[(2, 5)], nurses[(2, 5)] == nurses[(2, 6)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 5)] == nurses[(2, 6)], nurses[(2, 6)] == nurses[(2, 0)]) == 1);
        solver.Add(solver.MakeMax(nurses[(2, 6)] == nurses[(2, 0)], nurses[(2, 0)] == nurses[(2, 1)]) == 1);

        solver.Add(solver.MakeMax(nurses[(3, 0)] == nurses[(3, 1)], nurses[(3, 1)] == nurses[(3, 2)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 1)] == nurses[(3, 2)], nurses[(3, 2)] == nurses[(3, 3)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 2)] == nurses[(3, 3)], nurses[(3, 3)] == nurses[(3, 4)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 3)] == nurses[(3, 4)], nurses[(3, 4)] == nurses[(3, 5)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 4)] == nurses[(3, 5)], nurses[(3, 5)] == nurses[(3, 6)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 5)] == nurses[(3, 6)], nurses[(3, 6)] == nurses[(3, 0)]) == 1);
        solver.Add(solver.MakeMax(nurses[(3, 6)] == nurses[(3, 0)], nurses[(3, 0)] == nurses[(3, 1)]) == 1);

        // 將變量集合設置為求解的目標,Solver有一系列的枚舉值,可以指定求解的選擇策略。
        var db = solver.MakePhase(shifts_flat, Solver.CHOOSE_FIRST_UNBOUND, Solver.ASSIGN_MIN_VALUE);


        // 創建求解的對象
        var solution = solver.MakeAssignment();
        solution.Add(shifts_flat);
        var collector = solver.MakeAllSolutionCollector(solution);

        solver.Solve(db, new[] { collector });
        Console.WriteLine("Solutions found: {0}", collector.SolutionCount());
        Console.WriteLine("Time: {0}ms", solver.WallTime());
        Console.WriteLine();

        // 顯示一些隨機的結果
        var a_few_solutions = new[] { 340, 2672, 7054 };

        foreach (var sol in a_few_solutions)
        {
            Console.WriteLine("Solution number {0}", sol);

            foreach (var i in Enumerable.Range(0, num_days))
            {
                Console.WriteLine("Day {0}", i);
                foreach (var j in Enumerable.Range(0, num_nurses))
                {
                    Console.WriteLine("Nurse {0} assigned to task {1}", j, collector.Value(sol, shifts[(j, i)]));
                }
                Console.WriteLine();
            }
        }
    }
}

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM