排列组合-排列

630 阅读3分钟

给定一组数据,列出所有可能出现的排列情况。

排列特点:内部元素是有序的,即 [1,2,3] 和 [3,2,1] 即使元素相同,但位置不同,也是不同的排列情况

常用的求全排列有如下两种方法:回溯法、字典排序

回溯法

回溯法(无重复元素)

题目:存在 n 个不重复元素,求 n 个元素的排列

举个例子来说明,假设存在编号为 1、2、3 的三个球,分别放入编号为 A、B、C 的三个位置,那么存在多少种放置方法?

  1. 第一步:可以将 1 放置于 A,那么还剩下 2、3

    • 第二步:可以将 2 放置于 B,那么还剩下 3
      • 第三步:可以将 3 放置于 C,此时完成一次排列
    • 第二步:同样可以将 3 放置于 B,那么还剩下 2
      • 第三步:可以将 2 放置于 C,此时完成一次排列
  2. 第一步:可以将 2 放置于 A,那么还剩下 1、3

    • 第二步:可以将 1 放置于 B,那么还剩下 3

      • 第三步:可以将 3 放置于 C,此时完成一次排列

        ...

由此不难看出,对于每一个位置,每个球都可以去争夺一次,即第 A 个位置,由 1、2、3 颗球同时争夺;而对于第 B 个位置,就由剩余的球去争夺(例如 2、3 球);而第 C 个位置,就由最后一个球占据(例如 C)球

由上面的分析,可以得出下面的思路:

  1. 对于每个位置 i,每个球都可以去争夺,那么就 循环剩余的球,让每一颗球都匹配一次该位置
  2. 当有个球占据了 i 后,就该剩余的球去争夺下一个位置了 i+1,其实也就是回到了步骤 1
  3. 当所有球耗尽后,即完成一次排列

根据上面的思路编码

function fullArrangement(nums: number[]): number[][] {
  const ans: number[][] = []; // 最终的排列结果
  recursion(0);
  return ans;

  function recursion(emptyIndex: number): void {
    // emptyIndex 表示当前空的格子索引,可以放入一个元素
    // 当 emptyIndex到数组末尾时,由于此时只剩下一个元素和一个格子,只可能存在一种排列,即此时无需在进行循环了
    if (emptyIndex === nums.length - 1) {
      ans.push(nums.slice());
      return;
    }

    // 把 emptyIndex 后续的每一位都和当前位进行交换,相当于把剩余的球每个都匹配一次该位置
    for (let i = emptyIndex; i < nums.length; i++) {
      swap(nums, emptyIndex, i);
      recursion(emptyIndex + 1);
      swap(nums, emptyIndex, i); // 排完之后,需要还原,再让其他的球来匹配该位置
    }
  }
}

function swap(nums: number[], i: number, j: number): void {
  const s = nums[i];
  nums[i] = nums[j];
  nums[j] = s;
}

回溯(重复元素)

题目:存在 n 个重复的元素,求 n 个元素的全排列

举个例子来说明,假设存在编号为 1、1、2 的三个球,分别放入编号为 A、B、C 的三个位置,那么存在多少种放置方法?

如果还是按照上面的方法分析的话,会生成如下结果

可以很直观的看到,出现了重复的排列,例如[1,1,2]出现了两次,那么想个办法记录下第 i 个位置,已经放入过的元素,对于已经在该位置出现过的元素了,就不再重复计算了,例如记录 A 位置已经出现过 1 了,那么下次 1 再出现时,略过即可

根据上面的思路编码

function fullArrangement(nums: number[]): number[][] {
  const ans: number[][] = []; // 最终的排列结果
  recursion(0);
  return ans;

  function recursion(emptyIndex: number): void {
    // emptyIndex 表示当前空的格子索引,可以放入一个元素
    // 当 emptyIndex到数组末尾时,由于此时只剩下一个元素和一个格子,只可能存在一种排列,即此时无序在进行循环了
    if (emptyIndex === nums.length - 1) {
      ans.push(nums.slice());
      return;
    }

    const repeatSet = new Set<number>(); // 记录当前第i个位置已经出现过的元素,对于重复元素,不做计算

    // 把 emptyIndex 后续的每一位都和当前位进行交换,相当于把剩余的球每个都匹配一次该位置
    for (let i = emptyIndex; i < nums.length; i++) {
      if (repeatSet.has(nums[i])) continue;
      swap(nums, emptyIndex, i);
      recursion(emptyIndex + 1);
      swap(nums, emptyIndex, i); // 排完之后,需要还原,再让其他的球来匹配该位置
      repeatSet.add(nums[i]);
    }
  }
}

function swap(nums: number[], i: number, j: number): void {
  const s = nums[i];
  nums[i] = nums[j];
  nums[j] = s;
}

字典排序

字典排序原理:每一个字符都对应一个值,就拿 [1,2,3] 来说,它的值就是 1、2、3,可以组成最小的数值为 123,最大的数值为 321,那么所有的组合可能性都是从 123 起始,直到 321 结束,寻找完这些值,即为全排列。

确定两个值的比较方式

既然值需要比较大小,那么肯定有比较的方式,对于此处来说,要求返回的一个数组,那么用数组来表示一次排列最为合适,但是数组没有办法向数字这样直接进行比较,所以封装一个如下的比较方法

// nums1 是否小于 nums2
function compareLT(nums1: number[], nums2: number[]): boolean {
  // 逐级比较
  for (let i = 0; i < nums1.length; i++) {
    if (nums1[i] === nums2[i]) continue;
    return nums1[i] < nums2[i];
  }
  return false;
}

确定值的递增方式

既然是寻找 [最小值,最大值] 的所有排列,那么肯定有值递增的方式,使用如下步骤来递增值,并以 [1,2,3,1] 来举例

  1. 从末位(index=nums.length-1)向前(index=0)寻找,寻找到第一个递减的值,记录该值的位置为 i,值为 n;从 index 为 3 往前寻找,当寻找到 index 为 1,value 为 2 时,找到第一个递减的值,此时 i = 1,n = 2

  2. 从 i + 1 位向后寻找,找到最后一个比 n 大的数,记为该值的位置为 j;从 index 为 2 向后寻找,当 index 为 2,value 为 3 时,发现了最后一个比 n 大的数,此时 j 为 2

  3. 交换 i,j 的值;交换 1、2 的位置 ==> [1,3,2,1]

  4. 翻转i+1nums.length-1的所有值,即交换

    • i + 1 和 nums.length - 1 交换

    • i + 2 和 nums.length - 2 交换

      ...

      从 index 为 2 开始,到 index 为 3 截止,进行翻转 ==> [1,3,1,2]

function stepAdd(nums: number[]): number[] {
  const lastIndex = nums.length - 1;

  let max = nums[lastIndex];
  let firstCutIndex = -1; // 第一个递减数的索引
  let lastBigNumberIndex = -1; // 最后一位比n大的树的索引

  // 从后往前寻找递减的数
  for (let i = lastIndex - 1; i >= 0; i--) {
    if (nums[i] < max) {
      firstCutIndex = i;
      break;
    } else {
      max = Math.max(max, nums[i]);
    }
  }

  if (firstCutIndex === -1) return nums; // 已经是最大了

  // 否则从firstCutIndex开始寻找,比firstCutIndex大的数,记住是大的数中最后一位
  for (let i = firstCutIndex + 1; i <= lastIndex; i++) {
    if (nums[i] > nums[firstCutIndex]) {
      lastBigNumberIndex = i;
    }
  }

  // 调换 changeIndex和firstCutIndex的值
  nums = nums.slice();
  swap(nums, firstCutIndex, lastBigNumberIndex);

  // 现在对cutIndex后的进行翻转
  let i = firstCutIndex + 1,
    j = lastIndex;
  while (i < j) {
    swap(nums, i, j);
    i++;
    j--;
  }
  return nums;
}

function swap(nums: number[], i: number, j: number): void {
  const s = nums[i];
  nums[i] = nums[j];
  nums[j] = s;
}

已找出值比较的方法以及值递增的方法,则进行穷举,且由于是对元素进行比较,不会出现对元素重复计算

function fullArrangement(nums: number[]): number[][] {
  const result: number[][] = [];

  let value = nums.sort((a, b) => a - b);
  const maxValue = value.slice().reverse();

  while (compareLT(value, maxValue)) {
    result.push(value);
    value = stepAdd(value);
  }

  result.push(value);

  return result;
}