【OD机试题解法笔记】快递员的烦恼

题目

快递公司每日早晨,给每位快递员推送需要送到客户手中的快递以及路线信息,快递员自己又查找了一些客户与客户之间的路线距离信息,请你依据这些信息,给快递员设计一条最短路径, 告诉他最短路径的距离。
注意:
1. 不限制快递包裹送到客户手中的顺序,但必须保证都送到客户手中
2. 用例保证一定存在投递站到每位客户之间的路线,但不保证客户与客户之间有路线,客户位置及投递站均允许多次经过。
3. 所有快递送完后,快递员需回到投递站

输入描述

首行输入两个正整数n,m。
接下来n行,输入快递公司发布的客户快递信息,
格式为:客户id 投递站到客户之间的距离distance
再接下来的m行,是快递员自行查找的客户与客户之间的距离信息,

在每行数据中,数据与数据之间均以单个空格分割
规格;
0 < n <=10
0 <= m <= 10
0< 客户id <= 1000
0< distance <= 10000

输出描述

最短路径距离,如无法找到,请输出 -1

用例

输入输出说明
2  1
1  1000
2  1200
1  2 300
2500路径1: 快递员先把快递送到客户1手中,接下来直接走客户1到客户2之间的直通路线,最后走投递站与客户2之间的路,回到投递站,距离为1000 + 300 + 1200 = 2500
路径2: 快递员先把快递送到客户1手中,接下来回快递站,再出发把客户2的快递送到,在返回到快递站,距离为:1000 + 1000 + 1200 + 1200 = 4400
路径3:快递员先把快递送到客户2手中,接下来直接走客户2到客户1之间的直通线路,最后走投递站和客户1之间的路,回到投递站,距离为1200 + 300 + 1000 =2500
其他路径……
所有路径中,最短路径距离为2500
5   1
5   1000
9   1200
17  300
132  700
500  2300
5 9  400
9200在所有可行的路径中,最短路径长度为1000 + 400 + 1200 + 300 + 300 + 700 + 700 + 2300 + 2300 = 9200

思考一

       没有好的思路先想想暴力解法怎么搞。要计算所有的送货路径距离,然后取其中最短的距离。首先距离分两种:1)投递站与客户的距离;2)客户i与客户j的距离(也许没有)。快递员开始在投递站,所以开始要选择一个客户进行投递,假如投递给第i个客户,记快递员从投递站到客户i的距离为a[i],此时移动距离为D = a[i]。接下来投递给客户j,会有两种情形:1)客户i和客户j之间存在路径(i,j),距离记为b[i][j],这时快递员可以直接从客户i到达客户j,把客户j的快递送到,此时移动距离为D = a[i] + b[i][j]; 或者快递员送完客户i就回到投递站再投递客户j,这时距离为D=a[i]*2+a[j],在决策时尝试用贪心策略选择最短的距离为D=Math.min(a[i]+b[i][j], a[i]*2+a[j]),如果后面还有客户k重复前面的流程和贪心策略。上面的分析是已知快递员先给谁投递,实际上需要枚举每个客户作为起始投递对象,而后面继续投递给哪个客户也有很多选择,这个暴力枚举可通过递归来实现,每次递归的时候可供选择的范围(客户范围)都会变小,直到只有一个客户可以选,这也是递归枚举的一条路径终点,把这个路径的距离计算出来更新的全局定义的最短距离变量,所有递归搜索结束,最短的送货距离应该能求得。这个递归搜索的过程和对N个不同字母进行全排列类似。最终投递完所有客户返回投递站的距离也要加上。快递员最后返回投递站都是从最后一个客户直接返回吗?肯定不是,有可能通过别的客户中转距离更短,这个是不是又得把所有能直达的客户再递归搜索一遍?这样的代码写起来费力,性能很差,意义不大!稍微优化下,对所有客户与客户之间的最短距离做个备忘录从而避免递归搜索过程中的大量重复计算。回顾图的多源最短路径知识,客户和投递站都可以看成图中的节点,它们没有本质区别。基于动态规划思想的弗洛伊德算法可以求解图中任意两点之间的最短路径,用二维矩阵存储,矩阵matrix[i][j]表示第i个节点和第j个节点之间的最短路径。完成节点之间的最短路径矩阵预计算后,对所有客户进行全排列,查询矩阵计算每个排列路径的距离,如:投递站->客户1>客户2>客户2>客户3->...->客户N->投递站。最后选择最短的距离minDistance = Min(d1,d2,d3,..,dn)

最短距离矩阵
投递站客户1客户2客户3客户4客户5...客户N
投递站0
客户10
客户20
客户30
客户40
客户50
...0
客户N0

算法过程

一、计算最短距离

1、题目中给出 0 < 客户id <= 1000,因此可以把投递站id设为 0,这样作为和客户一样的普通节点也很容易根据id区分;输入的客户数据是客户id和投递站到客户之间的距离映射,而且客户id不一定连续,为了方便计算,需要把邻接矩阵索引和客户id(1~N)做映射得到变量 idToIndexMap

2、根据输入数据获取客户数量 n, 定义邻接矩阵 dist[n+1][n+1]n+1包含投递站;根据投递站到客户距离映射、客户到客户距离映射和前面定义的邻接矩阵索引和客户id映射初始化邻接矩阵中节点之间距离,其余部分初始化为无穷大;

3、根据弗洛伊德算法计算任意两点之间距离更新矩阵:

1)第一层循环,从 0n 枚举中间位置 kk 作为中转站,可能使某 2 个节点之间连通距离变短;

2)第二层循环,从 0n 枚举起始位置 i;第三层循环,从 0n 枚举结束位置 j,从 开始自底向上遍历实现预先计算规模最小的子问题,随着规模上升可以重复利用前面计算过的子问题的解;

3)在第三层循环中判断从 i k ,再从 kj 如果使 i j 的距离更短则更新 ij 的距离,即 dist[i][j] = Min(dist[i][k] + dist[k][j], dist[i][j])

二、计算全排列和求解最短送货距离

1、对客户id索引(1~N)求全排列使用递归+回溯,注意点是每次回溯时需要删除当前加入的对象,比如对[1,2,3]全排列时,临时列表tmp中是[1],继续递归到2时,tmp[1,2],递归到3时[1,2,3]得到一个排列,回溯时要删掉3,tmp为[1,2],3选过了没其它选择,继续回溯,删掉2,此时tmp为[1],可以选择 3 ,这是tmp是[1,3],如此循环;

2、得到所有客户的全排列 permutaions 后,遍历全排列列表并查询邻接矩阵计算每个全排列下的路径距离,最后取最短的距离就是最终结果;

3、暴力枚举法(全排列)的时间复杂度为 阶乘级(\(O(n!)\)),仅适用于 \(n \leq 10\) 的极小规模问题。

参考代码

function solution(lines) {
  const arr = lines.split('\n');
  const [n, m] = arr[0].trim().split(' ').map(e => parseInt(e));
  const s2c = arr.slice(1, n+1).map(e=>e.trim().split(' ').map(e1 => parseInt(e1)));
  const c2c = arr.slice(n+1).map(e=>e.trim().split(' ').map(e1 => parseInt(e1)));
  
  // 建立客户ID到索引的映射
  const idToIndex = new Map();
  s2c.forEach(([id, dist], idx) => {
    idToIndex.set(id, idx);
  });
  
  // 构建邻接矩阵,初始化所有距离为无穷大
  const size = n + 1; // 0为投递站,1~n为客户
  const dist = Array.from({length: size}, () => Array(size).fill(Infinity));
  
  // 初始化投递站到各客户的距离
  for (let i = 0; i < n; i++) {
    const [id, d] = s2c[i];
    dist[0][i+1] = d;
    dist[i+1][0] = d;
  }
  
  // 初始化客户间的距离
  for (let [a, b, c] of c2c) {
    const i = idToIndex.get(a) + 1;
    const j = idToIndex.get(b) + 1;
    dist[i][j] = c;
    dist[j][i] = c;
  }
  
  // 弗洛伊德算法计算任意两点间最短路径
  for (let k = 0; k < size; k++) {
    for (let i = 0; i < size; i++) {
      for (let j = 0; j < size; j++) {
        if (dist[i][k] + dist[k][j] < dist[i][j]) {
          dist[i][j] = dist[i][k] + dist[k][j];
        }
      }
    }
  }
  
  let minDistance = Infinity;

  // 生成所有可能的客户排列
  function permute(arr) {
    const result = [];
    function backtrack(current, remaining) {
      if (remaining.length === 0) {
        result.push([...current]);
        return;
      }
      for (let i = 0; i < remaining.length; i++) {
        const next = remaining[i];
        current.push(next);
        backtrack(current, [...remaining.slice(0, i), ...remaining.slice(i+1)]);
        current.pop();
      }
    }
    backtrack([], arr);
    return result;
  }
  
  const customers = Array.from({length: n}, (_, i) => i + 1);
  const permutations = permute(customers);
  
  // 计算每种排列的总距离
  permutations.forEach(perm => {
    let total = dist[0][perm[0]]; // 从投递站到第一个客户
    for (let i = 1; i < perm.length; i++) {
      total += dist[perm[i-1]][perm[i]]; // 客户间移动
    }
    total += dist[perm[perm.length-1]][0]; // 最后一个客户返回投递站
    if (total < minDistance) {
      minDistance = total;
    }
  });

  return minDistance === Infinity ? -1 : minDistance;   
}

let inputs = [
  `2 1
1 1000
2 1200
1 2 300`,
  `5 1
5 1000
9 1200
17 300
132 700
500 2300
5 9 400`,
  `5 2
5 1000
9 1200
17 300
132 700
500 2300
5 9 400
132 500 100`
];

inputs.forEach(input => {
  console.log(solution(input));
});

思考二

       经过查询资料,这个题目应该是改自NP完全问题——旅行商问题(TSP)。简要说下TSP问题:给定一系列城市和每对城市之间的距离,求解访问每个城市恰好一次并回到起点的最短路径。求解旅行商问题对于规模N<=20 可以使用状态压缩动态规划求解。具体思路是动态规划+位掩码状态压缩+广度优先搜索。

      位掩码(Bitmask)是一种利用二进制位来表示状态或标志的技术。位掩码利用整数的二进制表示中的各个位(bit)来表示不同的状态或标志。每个位可以是0或1,表示某个特定条件是否成立。假如我用一个字节表示一个整数8,8的二进制是0000 1000,有8个bit,每个bit有0或1两种取值,可以表示2^8=256种状态。对于题目中假如有4个客户,可以用四位二进制存储快递员是否投递某个客户的状态,0000表示快递员在投递站还没投递过任何客户,0010表示快递员投递过索引为2的客户,0101表示索引为1和3的客户已经投递,1111表示所有四个客户都被投递过。对于 n 个客户的投递状态可以表示 status = 1<< (n-1),如果第 i 个客户被访问,更新二进制第 i 位为 1,用或操作:status | 1 << (i-1),表示把第 i 位变成1,二进制是从右向左,第1个客户对应二进制最低位。

      用动态规划数组 dp[i][j] 表示状态为 i 当前位置为 j 的最短路径,状态 i 这个整数转成二进制格式后的每个为1的位表示对应的客户已被访问,客户的索引从1开始,二进制位最低位对应索引为1的客户,从右向左计算对应的客户索引。进一步说明dp[i][j] 表示整数 i 表示的二进制位中所有为1的客户都被访问一次再回到当前位置 j 所经历的最短距离。

     将初始状态[0, 0](表示未访问任何客户,当前位于投递站)加入队列, 利用广度优先搜索遍历能访问所有客户的路径,更新动态规划数组,直到队列为空遍历完成。此时dp[(1<<n)-1][0]就是最终结果。

算法过程

1、定义距离矩阵dists,存储节点之间的距离;

2、建立用户id和索引之间的映射,并用投递站到客户的距离更新距离矩阵dists;

3、用客户与客户之间的距离数据继续更新距离矩阵dists;

4、定义动态规划二维数组dp[i][j],i表示状态,第一个维度长度为1<<n,表示管理n个客户的状态,索引为1的客户状态整数是1<<0 =1,二进制0b000...001,索引为n的客户状态整数是1<<n-1,二进制0b10000...0。j表示当前的位置;

5、向搜索队列中放入初始状态[0,0],表示开始位于投递站,尚未访问任何客户节点。执行广度优先搜索,取队首元素,根据参数当前状态curStatus和当前位置curPos,从动态规划数组中获取当前状态的最短距离curDist;

6、遍历n个客户(位置索引),从0到n-1,如果下个位置不是自身并且当前位置与下一个位置距离不是无穷大,证明是连通的,可以访问下一个位置看看。如果下一个位置是新客户,则更新掩码中的对应位置为1表示访问过了,如果是投递站就不用修改状态,记住位掩码是用来标记每个客户访问状态的工具,相当于DFS算法中visited集合记录bool类型的访问标识;

7、将更新后状态记为nextStatus,根据nextStatus和当前遍历的位置nextPos从dp数组中查询最短距离dp[nextStatus][nextPos] ,如果dp[nextStatus][nextPos] > curDist + dists[curPos][nextPos],则执行更新 dp[nextStatus][nextPos]为最短路径,并把nextStatus和nextPos参数加入队列;

8、最终完成搜索,dp[(1<<n)-1][0]就是最短距离,如果为无穷大则表示没有最短距离,返回-1。总时间复杂度:O (n² × 2ⁿ),适用于 n≤20 的场景。

参考代码

function solution(lines) {
  const arr = lines.split('\n');
  const [n, m] = arr[0].trim().split(' ').map(e => parseInt(e));
  const s2c = arr.slice(1, n+1).map(e=>e.trim().split(' ').map(e1 => parseInt(e1)));
  const c2c = arr.slice(n+1).map(e=>e.trim().split(' ').map(e1 => parseInt(e1)));
  
  // 距离矩阵,dists[i][j]表示从位置i到位置j的最短距离
  const dists = Array.from({length: n+1}, ()=>new Array(n+1).fill(Infinity));

  // 建立客户ID到索引的映射和初始化距离矩阵
  const idToIndex = new Map();
  s2c.forEach(([id, d], index) => {
    dists[0][index+1] = dists[index+1][0] = d; // 投递站到各客户的距离
    idToIndex.set(id, index+1);
  });

  // 用客户与客户之间的距离信息更新距离矩阵
  c2c.forEach(([id1, id2, d], index) => {
    const [i, j] = [id1, id2].map(e => idToIndex.get(e));
    dists[i][j] = dists[j][i] = d; // 客户间的双向距离
  });

  // 动态规划数组
  // dp[status][pos] 表示:在客户访问状态为 status(二进制掩码中所有为 1 的位对应的客户都已被访问一次)的情况下,
  // 从配送站出发,经过这些客户后最终到达位置 pos 的最短路径长度。
  // status是二进制掩码,例如0b0011表示客户1和2已被访问
  const dp = Array.from({length: 1<<n}, ()=>new Array(n+1).fill(Infinity));
  dp[0][0] = 0; // 初始状态:未访问任何客户,位于投递站

  const queue = [[0,0]]; // 初始化队列,[状态掩码, 当前位置]

  // 执行广度优先搜索
  while (queue.length) {
    let cur = queue.shift();
    let [curStatus, curPos] = cur;
    let curDist = dp[curStatus][curPos]; // 当前状态当前位置最短距离
    for (let nextPos = 0; nextPos <= n; nextPos++) {
      // 检查路径是否存在:下一位置不能是当前位置,且两者间必须有直接连接
      if (nextPos != curPos && dists[curPos][nextPos] !== Infinity) {
        // 计算新状态:若下一位置是客户,则将对应客户的二进制位加入状态掩码
        const nextStatus = (nextPos !== 0) ? curStatus | (1 << (nextPos - 1)): curStatus;
        // 若新路径更短,则更新状态并加入队列
        if (dp[nextStatus][nextPos] > curDist + dists[curPos][nextPos]) {
          dp[nextStatus][nextPos] = curDist + dists[curPos][nextPos];
          queue.push([nextStatus, nextPos]);
        }        
      }
    }    
  }

  // 最终答案:所有客户都被访问(状态掩码为全1)且回到投递站(位置0)
  const minDistance = dp[(1<<n)-1][0];
  
  return minDistance !==Infinity ? minDistance : -1;
}

let inputs = [
  `2 1
1 1000
2 1200
1 2 300`,
  `5 1
5 1000
9 1200
17 300
132 700
500 2300
5 9 400`,
  `5 2
5 1000
9 1200
17 300
132 700
500 2300
5 9 400
132 500 100`
];

inputs.forEach(input => {
  console.log(solution(input));
});

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值