用回溯算法做一个离线网页版点菜助手

95 阅读5分钟

最近又再看《剑指Offer数据结构与算法》了,看到里面的回溯算法时想起我当时在某DN发过一个Java版的点菜小Demo,这次把他用纯 html/css/js 实现以下,可以使用浏览器在线使用,目前是个小Demo,所以暂且具备下面功能:

  1. 菜品的添加、删除和修改
  2. 价格区间的设定
  3. 菜品数量的设定
  4. 生成符合给定条件的策略
  5. 对生成的结果自动排序

可通过点击:Demo演示地址 查看演示。

暂且具备上面的内容,后期有空了再完善。回溯算法的原理是每次在进行下一步的时候会有同样的两种决策,例如要求数组 [1,2] 的所有子集,从空数组开始,每次会面临两个选择,将元素添加到空数组中,和不添加进去,具体的原理可以查询其他大佬的文章,下面是使用JavaScript实现的回溯算法方法:

function makeOrder(foodMenuList, index, maxPriceSum, minPriceSum, size, helperList, resultList){
    var priceList = helperList.map(item=>item.foodPrice);
    var priceSum = sum(priceList);
    if(priceSum >= minPriceSum && priceSum <= maxPriceSum && index == foodMenuList.length && helperList.length > 0){
        if(size == -1){
            resultList.push(deepClone(helperList));
        }else{
            if(size == helperList.length){
                resultList.push(deepClone(helperList));
            }
        }
    }else if(index < foodMenuList.length){
        makeOrder(foodMenuList, index+1, maxPriceSum, minPriceSum, size, helperList, resultList);
        helperList.push(foodMenuList[index]);
        makeOrder(foodMenuList, index+1, maxPriceSum, minPriceSum, size, helperList, resultList);
        helperList.splice(helperList.length - 1, 1);
    }
}

参数解释:

  1. foodMenuList: 菜单列表
  2. index: 索引
  3. maxPriceSum: 最高菜品价格总和
  4. minPriceSum: 最低菜品价格总和
  5. size: 菜品数量
  6. helperList: 辅助数组
  7. resultList: 最终结果集

最终实现效果如下:

image.png

image.png

完整代码(复制可直接运行):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta describe="这是一个通过回溯算法实现的点菜Demo,可以根据价格限制和菜的数量限制快速决策出可以点的策略。">
    <title>点菜助手Demo</title>
    <style>
        html,body{
            margin: 0;
            height: 100vh;
        }
        header{
            font-size: 18px;
            font-family: '宋体';
            font-weight: bold;
            color: white;
            background-color: black;
            padding: 10px;
        }
        main{
            height: calc(100vh - 45px);
            display: flex;
            flex-direction: column;
            background-color: antiquewhite;
        }
        table{
            margin: 10px;
            font-size: 14px;
            border: 1px solid black;
            border-spacing: 0;
            width: calc(100% - 20px);
            overflow-y: auto;
        }
        th,td{
            border: 1px solid black;
            padding: 5px 10px;
        }
        thead{
            top: 10px;
            color: black;
        }
        td:first-child{
            width: 60px;
            text-align: center;
        }
        td:last-child{
            width: 400px;
            text-align: center;
        }
        td:last-child > button{
            margin-right: 10px;
        }
        .toolBar{
            margin: 10px 10px 0 10px;
            display: flex;
            align-items: center;
        }
        button{
            width: 100px;
            border: none;
            height: 30px;
            border-radius: 4px;
            margin-right: 10px;
            cursor: pointer;
        }
        .tableBox{
            max-height: 400px;
            overflow: auto;
        }
        input{
            height: 20px;    
        }
        .tool-item{
            margin-right: 20px;
            font-size: 14px;
        }
        button:hover{
            opacity: 0.9;
        }
        .btn-info {
            color: white;
            background-color: orange;
        }

        .btn-del {
            color: white;
            background-color: red;
        }

        .btn-edit {
            color: white;
            background-color: gray;
        }
        .line{
            margin-top: 10px;
            border-top: 1px solid black;
        }
        .resultBox{
            max-height: calc(100vh - 495px);
            overflow: auto;
        }
        .result{
            font-size: 14px;
        }
        #editDialog{
            width: 500px;
            position: absolute;
            margin-top: 60px;
            border: 1px solid black;
            background-color: black;
            color: white;
            outline: none;
            border-radius: 4px;
            box-shadow: 3px 6px 10px black;
        }
        .dialog-title{
            font-weight: bold;
        }
        .dialog-body{
            padding: 20px 0;
        }
        .form-item{
            margin-bottom: 20px;
            display: flex;
            align-items: center;
        }
        label{
            font-weight: bold;
        }
        input{
            padding: 2px 10px;
            border-radius: 4px;
        }
    </style>
</head>
<body>
    <header>点菜助手</header>
    <main>
        <div class="toolBar">
            <div class="tool-item">
                <label for="minPrice">预算</label>
                <input style="width: 100px; text-align: center;" value="0" id="minPrice" type="number" placeholder="最低"><span> - </span>
                <input style="width: 100px; text-align: center;" id="maxPrice" type="number" placeholder="最高">
            </div>
            <div class="tool-item">
                <label for="foodSize">菜品数量</label>
                <input id="foodSize" type="number" placeholder="菜品数量(可选)">
            </div>
            <button class="btn-info" id="startMake">生成点菜计划</button>
            <button class="btn-info" id="addFoodMenu">添加菜品</button>
        </div>
        <div class="tableBox">
            <table>
                <thead>
                    <tr>
                        <th>序号</th>
                        <th>菜名</th>
                        <th>价格(元)</th>
                        <th>操作</th>
                    </tr>
                </thead>
                <tbody id="menuTable"></tbody>
            </table>
        </div>
        <div class="line"></div>
        <div class="resultBox">
            <div style="padding: 10px 0 0 10px; font-size: 14px; font-weight: bold;">生成总数:<span id="resultTotal">0</span></div>
            <ul id="result"></ul>
        </div>
        <dialog id="editDialog">
            <div class="dialog-title">菜品信息</div>
            <div class="dialog-body">
                <form>
                    <div class="form-item">
                        <label style="width: 60px;" for="foodName">菜名</label>
                        <input placeholder="菜名" type="text" id="foodName" style="width: 100%;">
                    </div>
                    <div class="form-item">
                        <label style="width: 60px;" for="foodPrice">价格</label>
                        <input placeholder="价格" type="Number" id="foodPrice" style="width: 100%;">
                    </div>
                </form>
                <div class="form-item" style="justify-content: end;">
                    <button class="btn-info" style="width: 100px; margin-right: 10px;" id="closeDialog">取消</button>
                    <button class="btn-info" style="width: 100px;" id="submitForm">提交</button>
                </div>
            </div>
        </dialog>
    </main>
    <script>

        var isAdd = true;   //标记dialog提交按钮是新增还是修改
        var currentEditFoodId = -1; //当前编辑的foodId

        //默认菜单
        var defaultFoodMenuList = [
            {id:0, foodName: '米饭', foodPrice: 4},
            {id:1, foodName: '鱼香肉丝', foodPrice: 48},
            {id:2, foodName: '糖醋排骨', foodPrice: 68},
            {id:3, foodName: '京酱肉丝', foodPrice: 48},
            {id:4, foodName: '地锅鸡', foodPrice: 78},
            {id:5, foodName: '蚂蚁上树', foodPrice: 178},
            {id:6, foodName: '毛氏红烧肉', foodPrice: 79},
            {id:7, foodName: '蒜末拍黄瓜', foodPrice: 12}
        ];

        function init(){
            var localFoodList = localStorage.getItem('foodList');
            if(localFoodList){
                defaultFoodMenuList = JSON.parse(localFoodList)
            }
        }

        init()

        function saveFoodListToLocal(foodList){
            localStorage.setItem('foodList', JSON.stringify(foodList));
        }

        //获取指定ID的Element
        function getElById(elId){
            return document.getElementById(elId);
        }

        //显示Dialog
        getElById('addFoodMenu').addEventListener('click',()=>{
            isAdd = true;
            getElById('foodName').value = '';
            getElById('foodPrice').value = '';
            getElById('editDialog').hidden = false;
            getElById('editDialog').show();
        })

        //关闭Dialog
        getElById('closeDialog').addEventListener('click',()=>{
            getElById('editDialog').hidden = true;
        })

        //提交新增或修改
        getElById('submitForm').addEventListener('click',()=>{
            var foodName = getElById('foodName').value;
            var foodPrice = Number(getElById('foodPrice').value);
            if(!foodName || !foodPrice){
                alert('请输入菜名和价格后提交')
                return;
            }else{
                if(isAdd){
                    if(defaultFoodMenuList.filter(item=>item.foodName == foodName).length > 0){
                        alert(foodName+'已经存在,如需修改请点击编辑按钮进行修改');
                        return;
                    }
                    defaultFoodMenuList.push({id: defaultFoodMenuList[defaultFoodMenuList.length-1].id+1, foodName: foodName, foodPrice: foodPrice});
                }else{
                    console.log("进入修改")
                    defaultFoodMenuList.forEach(element => {
                        if(element.id == currentEditFoodId){
                            element.foodName = foodName;
                            element.foodPrice = foodPrice;
                        }
                    });
                }
                saveFoodListToLocal(defaultFoodMenuList);
                buildMenuTable(defaultFoodMenuList)
                getElById('closeDialog').click();
            }
        })

        //构建表格渲染
        function buildMenuTable(foodMenuList){
            var menuTableEl = getElById("menuTable");
            cleanAndrender(menuTableEl);
            foodMenuList.forEach(foodMenu => {
                var trNode = document.createElement("tr");
                var idNode = document.createElement("td");
                idNode.append(document.createTextNode(foodMenu.id));
                var foodNameNode = document.createElement("td");
                foodNameNode.append(document.createTextNode(foodMenu.foodName));
                var foodPriceNode = document.createElement("td");
                foodPriceNode.append(document.createTextNode(foodMenu.foodPrice));
                var editNode = document.createElement("td");
                var editBtn = document.createElement("button");
                editBtn.classList.add('btn-edit')
                var delBtn = document.createElement("button");
                delBtn.classList.add('btn-del')
                delBtn.addEventListener('click',()=>{deleteFood(foodMenu)})
                editBtn.addEventListener('click',()=>{editFood(foodMenu)})
                editBtn.appendChild(document.createTextNode("编辑"))
                delBtn.appendChild(document.createTextNode("删除"))
                editNode.append(editBtn);
                editNode.append(delBtn);
                trNode.appendChild(idNode);
                trNode.appendChild(foodNameNode);
                trNode.appendChild(foodPriceNode);
                trNode.appendChild(editNode)
                menuTableEl.appendChild(trNode);
            });
        }

        //删除菜单
        function deleteFood(item){
            defaultFoodMenuList.forEach((element,index) => {
                if(element.id == item.id){
                    defaultFoodMenuList.splice(index,1);
                }
            });
            
            buildMenuTable(defaultFoodMenuList);
        }

        //编辑菜单
        function editFood(item){
            isAdd = false;
            currentEditFoodId = item.id;
            getElById('foodName').value = item.foodName;
            getElById('foodPrice').value = item.foodPrice;
            getElById('editDialog').hidden = false;
            getElById('editDialog').show();
        }

        //初始化表格
        buildMenuTable(defaultFoodMenuList);

        //生成策略
        function makeOrder(foodMenuList, index, maxPriceSum, minPriceSum, size, helperList, resultList){
            var priceList = helperList.map(item=>item.foodPrice);
            var priceSum = sum(priceList);
            if(priceSum >= minPriceSum && priceSum <= maxPriceSum && index == foodMenuList.length && helperList.length > 0){
                if(size == -1){
                    resultList.push(deepClone(helperList));
                }else{
                    if(size == helperList.length){
                        resultList.push(deepClone(helperList));
                    }
                }
            }else if(index < foodMenuList.length){
                makeOrder(foodMenuList, index+1, maxPriceSum, minPriceSum, size, helperList, resultList);
                helperList.push(foodMenuList[index]);
                makeOrder(foodMenuList, index+1, maxPriceSum, minPriceSum, size, helperList, resultList);
                helperList.splice(helperList.length - 1, 1);
            }
        }

        //对菜价求和
        function sum(priceList) {
            var result = 0;
            priceList.forEach(element => {
                result += element;
            });
            return result;
        }

        //拼接菜名
        function joinName(nameList) {
            var result = "";
            for(let i=0; i<nameList.length; i++){
                if(i == nameList.length - 1){
                    result = result + nameList[i];
                }else{
                    result = result +nameList[i] + ", ";
                }
            }
            return result;
        }

        //深拷贝
        function deepClone(list){
            var jsonList = JSON.stringify(list);
            return JSON.parse(jsonList);
        }
        
        //开始生成按钮事件
        getElById('startMake').addEventListener('click',()=>{
            var resultList = []
            var helperList = []
            var maxPrice = getElById('maxPrice').value;
            var minPrice = getElById('minPrice').value;
            var foodSize = getElById('foodSize').value;
            if(!maxPrice){
                alert('请输入你的最大预算')
                return;
            }
            foodSize = foodSize ? foodSize : -1;
            console.log("预算:"+maxPrice+"; 指定数量:"+foodSize)
            makeOrder(defaultFoodMenuList, 0, maxPrice,minPrice,foodSize, helperList, resultList);
            var resultEl = getElById('result');
            cleanAndrender(resultEl)
            if(resultList.length == 0){
                alert("没有满足该条件的策略")
                return
            }
            getElById('resultTotal').innerText = resultList.length;
            quickSort(resultList,0,resultList.length-1)
            resultList.forEach(element => {
                var foodNameListStr = ""
                var foodNameList = element.map(i=>i.foodName);
                var priceList = element.map(i=>i.foodPrice);
                var priceSum = sum(priceList);
                foodNameListStr = joinName(foodNameList);
                var liNode = document.createElement('li');
                liNode.append(document.createTextNode("菜品: "+foodNameListStr+"; 总价: "+priceSum+" 元"));
                resultEl.appendChild(liNode)
            });
            

        })

        //清楚父节点下的子节点元素
        function cleanAndrender(parentEl){
            var first = parentEl.firstElementChild;
            while(first){
                first.remove();
                first = parentEl.firstElementChild;
            }
        }

        //快速排序
        function quickSort(arr, start, end){
            if(start < end){
                var pivotIndex = quickSortHelper(arr, start, end);
                quickSort(arr, start, pivotIndex - 1);
                quickSort(arr, pivotIndex + 1, end);
            }
        }
        function quickSortHelper(arr, start, end){
            var pivot = sum(arr[start].map(item=>item.foodPrice))
            var i = start;
            var j = end;
            while(i < j){
                while(i < j && sum(arr[j].map(item=>item.foodPrice)) >= pivot){
                    j--;
                }
                while(i < j && sum(arr[i].map(item=>item.foodPrice)) <= pivot){
                    i++;
                }
                swap(arr, i, j);
            }
            swap(arr, i, start);
            return i;
        }

        function swap(arr,i,j){
            var temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }

    </script>
</body>
</html>