演算法題:洗牌演算法

一杯綠茶 發表於 2021-10-14
演算法

以前面試的時候考過洗牌演算法,當時寫的比較簡陋,核心思路如下:

  • 建立一個空的結果陣列;
  • 依次從原陣列中隨機取樣,然後按順序 push 到結果陣列中;
  • 為防止出現碰撞,每次取樣後,將該元素從原陣列刪除;
其實還有一種思路,按順序從原陣列中獲取元素,然後放到新陣列的隨機位置,但是這樣無法消除碰撞的問題
function shuffle(arr) {
  let len = arr.length;
  let res = [];
  for (let i=0; i<len; i++) {
    res.push(sample(arr));
  }
  return res;
}

function sample(arr) {
  // 陣列隨機取樣
  let randIndex = Math.floor(Math.random() * arr.length);
  // 獲取元素並從原陣列中刪除
  let [ele] = arr.splice(randIndex, 1);
  return ele;
}

可以看到上面的程式碼存在兩個問題:

  • 建立新陣列,導致使用了額外空間;
  • 從陣列中刪除元素需要移動其他元素下標,導致總的時間複雜度為 O(n^2)

那麼有沒有一種方法可以不建立新陣列,直接在原陣列上修改呢?今天看到一種 Fisher-Yates 洗牌演算法,思路與上面程式碼類似,但沒有建立新陣列,而是採用了與快排類似的元素交換方法:

  • 從陣列最後一個元素開始倒序遍歷;
  • 從包含當前元素在內的陣列中,隨機抽取元素,與當前元素交換;
  • 不斷往前遍歷,直到所有元素都被交換;
function randomIndex(index: number): number {
  return Math.floor(Math.random() * (index + 1));
}

function swap<T>(arr: T[], a: number, b: number) {
  let temp = arr[a];
  arr[a] = arr[b];
  arr[b] = temp;
}

export function shuffle<T>(arr: T[]): T[] {
  for (let i=arr.length-1; i>=0; i--) {
    let r = randomIndex(i);
    swap<T>(arr, r, i);
  }
  return arr;
}

const res = shuffle<number>([1, 2, 3, 4, 5, 6]);
console.log(res);

從程式碼中可以看出,每一步都是從陣列中隨機抽取元素,放到陣列的最後,然後指標前進一位,繼續從剩下陣列中抽取元素,放到陣列的最後。按這個過程依此類推,最終整個陣列的元素就被打亂了。實現的思路其實和本人之前的程式碼是一樣的,但是有兩點改進:

  • 沒有建立新陣列,空間複雜度為 O(n)
  • 通過元素交換的方法,避免了從陣列中刪除元素導致需要移動其他元素下標的問題,時間複雜度為 O(n)