JavaScript实现《啊哈!算法》中的系列算法

526 阅读15分钟

《啊哈!算法》pdf版 下载

第1章 一大波数正在靠近——排序

第1节 最快最简单的排序——桶排序.

var testArr = [3, 5, 3, 5, 9, 7, 6]
// 桶排序---浪费空间,且只能比较自然数,时间复杂度是 O(m+n)
// 需要对数据范围在 0~n 之间的整数进行排序
// skipArr 需要排序的数据组成的数组
function skipSort (n, skipArr) {
  var arr = []
  var sortArr = []
  arr.length = n
  for (let i = 0; i <= n; i++) {
    arr[i] = 0
  }
  for (let j = 0; j < skipArr.length; j++) {
    arr[skipArr[j]]++
  }
  // 从小到大的排序
  // for (let t = 0; t <= n; t++) {
  //   if (arr[t]) {
  //     for (let k = 1; k <= arr[t]; k++) {
  //       sortArr.push(t)
  //     }
  //   }
  // }
  // 从大到小的排序
  for (let t = n; t > 0; t--) {
    if (arr[t]) {
      for (k = 1; k <= arr[t]; k++) {
        sortArr.push(t)
      }
    }
  }
  return sortArr
}

console.log('桶排序:', skipSort(10, testArr))

第2节 邻居好说话——冒泡排序

var testArr = [3, 5, 3, 5, 9, 7, 6]
// 冒泡排序---时间复杂度是O(N的2次方),执行效率底
// bubbleArr 需要排序的数据组成的数组
function bubbleSort (bubbleArr) {
  var i, j, temp
  for (i = 0; i < bubbleArr.length; i++) {
    for (j = 0; j < bubbleArr.length - 1 - i; j++) {
      // 从小到大的排序
      // if (bubbleArr[j] > bubbleArr[j +1]) {
      //   temp = bubbleArr[j]
      //   bubbleArr[j] = bubbleArr[j + 1]
      //   bubbleArr[j + 1] = temp
      // }
      // 从大到小的排序
      if (bubbleArr[j] < bubbleArr[j +1]) {
        temp = bubbleArr[j]
        bubbleArr[j] = bubbleArr[j + 1]
        bubbleArr[j + 1] = temp
      }
    }
  }
  return bubbleArr
}

console.log('冒泡排序:', bubbleSort(testArr))

第3节 最常用的排序——快速排序

// 快速排序---时间复杂度最差是O(N的2次方),平均时间复杂度为O(NlogN)
// quickArr 需要排序的数据组成的数组
function quickSort (quickArr, left, right) {
  var i, j, k, temp
  if (left > right) {
    return
  }
  temp = quickArr[left]
  i = left
  j = right
  while (i !== j) {
    // 从小到大的排序
    // while (quickArr[j] >= temp && i < j) {
    //   j--
    // }
    // while (quickArr[i] <= temp && i < j) {
    //   i++
    // }
    // 从大到小的排序
    while (quickArr[j] <= temp && i < j) {
      j--
    }
    while (quickArr[i] >= temp && i < j) {
      i++
    }
    if (i < j) {
      t = quickArr[i]
      quickArr[i] = quickArr[j]
      quickArr[j] = t
    }
  }
  quickArr[left] = quickArr[i]
  quickArr[i] = temp
  quickSort(quickArr, left, i - 1)
  quickSort(quickArr, i + 1, right)
  return quickArr
}

console.log('快速排序:', quickSort(testArr, 0, testArr.length - 1))

第2章 栈、队列、链表

第1节 解密 QQ号——队列

规则是这样的:首先将第 1个数删除,紧接着将第 2 个数放到 这串数的末尾,再将第 3 个数删除并将第 4 个数放到这串数的末尾,再将第 5 个数删除…… 直到剩下最后一个数,将最后一个数也删除。按照刚才删除的顺序,把这些删除的数连在一 起就是真实的QQ啦。

// 队列--先进先出的数据结构

// 加密后的QQ字符串是 6 3 1 7 5 8 9 2 4
const arrAfter = [6, 3, 1, 7, 5, 8, 9, 2, 4];

function getQQ(arrAfter) {
  let middleArr = Object.assign([], arrAfter);
  middleArr.unshift(0);
  let arrBefore = [];
  let head = 1;
  let tail = middleArr.length;
  while (head < tail) {
    arrBefore.push(middleArr[head]);
    head++;
    middleArr.push(middleArr[head]);
    tail++;
    head++;
  }
  return arrBefore;
}

console.log("getQQ:", getQQ(arrAfter)); //  [ 6, 1, 5, 9, 4, 7, 2, 8, 3 ]

第2节 解密回文——栈

// 栈--后进先出的数据结构
// 用来判断回文字符串
function stackFun (stackStr) {
  var stackNewArr = [], len, mid, next, top
  len = stackStr.length
  mid = Math.floor(len / 2 - 1)
  top = 0
  for (var i = 0; i <= mid; i++) {
    stackNewArr[++top] = stackStr[i]
  }
  if (len % 2 === 0) {
    next = mid + 1
  } else {
    next = mid + 2
  }
  for (var j = next; j <= len-1; j++) {
    if (stackStr[j] !== stackNewArr[top]) {
      break
    }
    top--
  }

  if (top === 0) {
    return 'YES'
  } else {
    return 'NO'
  }
}

console.log(stackFun('abeffeba'))

第3节 纸牌游戏——小猫钓鱼

// 纸牌游戏--小猫钓鱼--队列和栈配合使用

// 初始化队列--小哼手里牌的相关信息(先进先出)
const q1 = {
  head : 1,
  tail : 1,
  data: []
}

// 初始化队列--小哈手里牌的相关信息(先进先出)
const q2 = {
  head : 1,
  tail : 1,
  data: []
}

// 初始化栈--桌面上牌的相关信息(先进后出)
const s = {
  top: 0,
  data: []
}

// 初始化用来标记的数组,用来标记哪些牌已经在桌上(桶排序)
const book = []
book.length = 10

for (let i = 1; i <= 9; i++) {
  book[i] = 0
}

// 依次向队列中插入6个数
const arr1 = [2, 4, 1, 2, 5, 6]
const arr2 = [3, 1, 3, 5, 6, 4]

// 小哼手上的6张牌
for (let i = 1; i <= arr1.length; i++) {
  q1.data[q1.tail] = arr1[i - 1]
  q1.tail++
}

// 小哈手上的6张牌
for (let i = 1; i <= arr2.length; i++) {
  q2.data[q2.tail] = arr2[i - 1]
  q2.tail++
}

// 当队列不为空的时候执行循环
while (q1.head < q1.tail && q2.head < q2.tail) {
  // 小哼出一张牌
  t = q1.data[q1.head]
  // 判断小哼当前打出的牌是否能赢牌
  if (book[t] === 0) {  // 表明桌上没有牌面为t的牌
    // 小哼此轮没有赢牌
    q1.head++  // 小哼已经打出一张牌,所以要把打出的牌出队
    s.top++
    s.data[s.top] = t  // 再把打出的牌放到桌上,即入栈
    book[t] = 1 // 标记桌上现在已经有牌面为t的牌
  } else {
    // 小哼此轮可以赢牌
    q1.head++
    q1.data[q1.tail] = t
    q1.tail++
    while (s.data[s.top] !== t) { // 把桌上可以赢得的牌依次放到手中牌的末尾
      book[s.data[s.top]] = 0 // 取消标记
      q1.data[q1.tail] = s.data[s.top] // 依次放入队尾
      q1.tail++
      s.top--  // 栈中少了一张牌,所以栈顶要减1
    }
  }

  // 小哈出一张牌
  t = q2.data[q2.head]
  // 判断小哈当前打出的牌是否能赢牌
  if (book[t] === 0) {  // 表明桌上没有牌面为t的牌
    // 小哈此轮没有赢牌
    q2.head++  // 小哈已经打出一张牌,所以要把打出的牌出队
    s.top++
    s.data[s.top] = t  // 再把打出的牌放到桌上,即入栈
    book[t] = 1 // 标记桌上现在已经有牌面为t的牌
  } else {
    // 小哈此轮可以赢牌
    q2.head++
    q2.data[q2.tail] = t
    q2.tail++
    while (s.data[s.top] !== t) { // 把桌上可以赢得的牌依次放到手中牌的末尾
      book[s.data[s.top]] = 0 // 取消标记
      q2.data[q2.tail] = s.data[s.top] // 依次放入队尾
      q2.tail++
      s.top--  // 栈中少了一张牌,所以栈顶要减1
    }
  }
}

if (q2.head === q2.tail) {
  console.log('小哼赢')
  console.log('小哼当前手中的牌是:')
  for (let i = q1.head; i <= q1.tail-1; i++) {
    console.log(q1.data[i])
  }
  if (s.top > 0) { // 如果桌上有牌则依次输出桌上的牌
    console.log('桌上的牌是:')
    for (let i = 1; i <= s.top; i++) {
      console.log(s.data[i])
    }
  } else {
    console.log('桌上已经没有牌了')
  }
} else {
  console.log('小哈赢')
  console.log('小哈当前手中的牌是:')
  for (let i = q2.head; i <= q2.tail-1; i++) {
    console.log(q2.data[i])
  }
  if (s.top > 0) { // 如果桌上有牌则依次输出桌上的牌
    console.log('桌上的牌是:')
    for (let i = 1; i <= s.top; i++) {
      console.log(s.data[i])
    }
  } else {
    console.log('桌上已经没有牌了')
  }
}

第4节 链表

function Node(element) {
    this.element = element
    this.next = null
    this.prev = null
}

function LinkList() {
    this.head = new Node('head')
    this.tail = new Node('tail')
    this.head.next = this.tail
    this.tail.prev = this.head
}

LinkList.prototype = {
    find: function (item) {
        var currNode = this.head
        while ((currNode !== null) && (currNode.element !== item)) {
            currNode = currNode.next
        }
        return currNode
    },
    findFromTail: function (item) {
        var currNode = this.tail
        while ((currNode !== null) && (currNode.element !== item)) {
            currNode = currNode.prev
        }
        return currNode
    },
    insert: function (newElement, item) {
        var newNode = new Node(newElement)
        var currNode = this.findFromTail(item)
        if (currNode !== null) {
            if (currNode.next === null) {
                currNode.next = newNode
                newNode.prev = currNode
            } else {
                currNode.next.prev = newNode
                newNode.next = currNode.next
                newNode.prev = currNode
                currNode.next = newNode
            }
        } else {
            this.tail.prev.next = newNode
            newNode.prev = this.tail.prev
            newNode.next = this.tail
            this.tail.prev = newNode
        }

    },
    findPrevious: function (item) {
        var currNode = this.head
        while ((currNode.next !== null) && (currNode.next.element !== item)) {
            currNode = currNode.next
        }
        return currNode

    },
    remove: function (item) {
        // var prevNode = this.findPrevious(item)
        // if (prevNode.next !== null) {
        //     prevNode.next = prevNode.next.next
        // }
        var currNode = this.find(item)
        if (currNode.next === null) {
            currNode.prev.next = null
        } else {
            currNode.prev.next = currNode.next
            currNode.next.prev = currNode.prev
        }
    },
    edit: function (item, newElement) {
        var currNode = this.find(item)
        currNode.element = newElement
    },
    display: function () {
        var currNode = this.head
        while (currNode !== null) {
            console.log(currNode.element)
            currNode = currNode.next
        }
    }
}

var nums = new LinkList()
nums.insert('1', 'head')
nums.insert('2', '1')
nums.insert('3', '2')
nums.insert('4', '3')
nums.insert('10', '11')
nums.display()    // head 1 2 3 4 10 tail

第3章 枚举!很暴力

第1节 坑爹的奥数

// XXX + XXX = XXX,将数字1-9分别填入9个X中,每个数字只能使用一次使得等式成立,例如173 + 286 = 459
// 算法逻辑如下:
// num数组表示9个位置的对应的数字
const num = []
num.length = 10

const book = []
book.length = 10

var i , total = 0, sum

for (num[1] = 1; num[1] <= 9; num[1]++) {
    for (num[2] = 1; num[2] <= 9; num[2]++) {
        for (num[3] = 1; num[3] <= 9; num[3]++) {
            for (num[4] = 1; num[4] <= 9; num[4]++) {
                for (num[5] = 1; num[5] <= 9; num[5]++) {
                    for (num[6] = 1; num[6] <= 9; num[6]++) {
                        for (num[7] = 1; num[7] <= 9; num[7]++) {
                            for (num[8] = 1; num[8] <= 9; num[8]++) {
                                for (num[9] = 1; num[9] <= 9; num[9]++) {
                                    // 初始化book数组
                                    for (i = 1; i <= 9; i++) {
                                        book[i] = 0
                                    }
                                    // 如果某个数出现过就标记一下
                                    for (i = 1; i <= 9; i++) {
                                        book[num[i]] = 1
                                    }
                                    // 统计共出现了多少个不同的数
                                    sum = 0
                                    for (i = 1; i <= 9; i++) {
                                        sum += book[i]
                                    }
                                    // 如果正好出现了9个不同的数,~并且满足等式条件,则输出
                                    if (sum === 9 && (num[1] * 100 + num[2] * 10 + num[3] +
                                        num[4] * 100 + num[5] * 10 + num[6] ===
                                        num[7] * 100 + num[8] * 10 + num[9])) {
                                        total++
                                        console.log(num[1] + ',' + num[2] + ',' + num[3] + ',' + num[4] + ',' + num[5] + ',' + num[6] + ',' + num[7] + ',' + num[8] + ',' + num[9])
                                        console.log( '' + num[1] + num[2] + num[3] + '+' + num[4] + num[5] + num[6] + '=' + num[7] + num[8] + num[9])
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

console.log('total:', total / 2)   // 168

第6章 最短路径

第5节 只有五行的算法——Floyd-Warshall

  • 上图中有4个城市8条公路,公路上的数字表示这条公路的长短。请注意这些公路是单向的。我们现在需要求任意两个城市之间的最短路程,也就是求任意两个点之间的最短路径。这个问题也被称为“多源最短路径”问题。
// 最短路径--动态规划的思想
// Floyd-Warshall算法是解决任意两点间的最短路径的一种算法。通常可以在任何图中使用,包括有向图、带负权边的图。  算法的时间复杂度为O(N^3)
// 该算法不能解决带有“负权回路”(或者叫“负权环”)的图。因为这类型的图没有最短路径

var k, i, j, t1, t2, t3
var inf = 999999
var n = 4 // 表示4个点
var m = 8 // 表示4个点之间所有边之和
var e = [[], [], [], [], []]

// 初始化--默认点与点之间都有边,且距离为inf(无穷大)
for (i = 1; i <= n ; i++) {
  for (j = 1; j <= n; j++) {
    if (i === j) {
      e[i][j] = 0 // 表示 始点和终点相同的时候,距离为0
    } else {
      e[i][j] = inf // 表示 从i点到j点的距离为inf(无穷大)
    }
  }
}

// 给部分点与点之间设置边以及距离
e[1][2] = 2 // 表示 从1点 到 2点 的距离为 2
e[1][3] = 6 // 表示 从1点 到 3点 的距离为 6
e[1][4] = 4 // 表示 从1点 到 4点 的距离为 4
e[2][3] = 3 // 表示 从2点 到 3点 的距离为 3
e[3][1] = 7 // 表示 从3点 到 1点 的距离为 7
e[3][4] = 1 // 表示 从3点 到 4点 的距离为 1
e[4][1] = 5 // 表示 从4点 到 1点 的距离为 5
e[4][3] = 12 // 表示 从4点 到 3点 的距离为 12

// 最短路径--算法核心语句
for (k = 1; k <= n; k++) {
  for (i = 1; i <= n; i++) {
    for (j = 1; j <= n; j++) {
      if ((e[i][k] < inf) && (e[k][j] < inf) && (e[i][j] > e[i][k] + e[k][j])) {
        e[i][j] = e[i][k] + e[k][j]
      }
    }
  }
}

// 输出结果
for (i = 1; i<= n; i++) {
  for (j = 1; j <= n; j++) {
    console.log('从' + i + '点到' + j + '点的最短距离为' + e[i][j])
    console.log('-----------------------------------------------')
  }
}

第7章 神奇的树

第2节 二叉树

  • 二叉树的性质
    • 在二叉树的第i层上最多有2^(i-1)个节点(i>=1)
    • 深度为k的二叉树最多有2^k-1个节点(k>=1)
    • 对任何一棵二叉树T,如果其终端节点数为n0,度为2的节点数为n2,则n0=n2+1
      • 一棵深度为k且有2^k-1个节点的二叉树称为满二叉树
      • 深度为k的,有n个节点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中编号从1至n的节点一一对应时,称之为完全二叉树
  • 二叉树的遍历
    • 二叉树的遍历指的是按照某种顺序,依次访问二叉树的每个节点,有且访问一次
    • 二叉树的遍历有以下三种
      • 前序遍历,从根节点,到左子树,再到右子树,简称根左右
      • 中序遍历,从左节点,到根节点,再到右子树,简称左根右
      • 后序遍历,从左子树,到右子树,再到根节点,简称左右跟
  • 完全二叉树的特性
    • 具有n个节点的完全二叉树的深度为Math.floor(log2 n)+1
    • 如果对一棵有n个节点的完全二叉树(其深度为Math.floor(log2 n)+1)的节点按层序编号(从第1层到第Math.floor(log2 n)+1,每层从左到右),则对任一节点(1<=i<=n)有:
      • 如果i=1,则节点i是二叉树的根,无双亲;如果i>1,则其双亲parent(i)是节点Math.floor(i/2)
      • 如果2i>n,则节点i无左孩子(节点i为叶子节点);否则其左孩子LChild(i)是节点2i
      • 如果2i+1>n,则节点i无右孩子;否则其右孩子RChild(i)是节点2i+1
  • 综上:
    • 完全二叉树:若设二叉树的深度为h,除第h层外,其它各层 (1~h-1) 的结点数都达到最大个数,第h层所有的结点都连续集中在最左边,这就是完全二叉树。
    • 满二叉树的任意节点,要么度为0,要么度为2.
// 定义一个节点类
function Node (data, left, right) {
  this.data = data
  this.left = left
  this.right = right
  this.show = function () {
    return this.data
  }
}

// 定义一个二叉查找树类BST
function BST () {
  this.root = null
  this.insert = insert
  this.preOrder = preOrder
  this.inOrder = inOrder
  this.postOrder = postOrder
}

// 插入节点
function insert (data) {
  // 创建一个节点保存数据
  var node = new Node(data, null, null)
  // 下面将节点node插入到树中
  // 如果树是空的,就将节点设为根节点
  if (!this.root) {
    this.root = node
  } else {
    //树不为空
    // 判断插在父节点的左边还是右边
    // 所以先要保存一下父节点
    // var parent = this.root;
    var current = this.root
    var parent
    // 如果要插入的节点键值小于父节点键值,则插在父节点左边,
    // 前提是父节点的左边为空,否则要将父节点往下移一层,
    // 然后再做判断
    while (true) {
      // data小于父节点的键值
      parent = current
      if (data < parent.data) {
        // 将父节点往左下移(插入左边)
        // parent = parent.left;
        current = current.left
        // 如果节点为空,则直接插入
        if (!current) {
          // !!!此处特别注意,如果就这样把parent赋值为node,也仅仅只是parent指向node,
          // 而并没有加到父元素的左边!!!根本没有加到树中去。所以要先记住父元素,再把当前元素加入进去
          parent.left = node
          break
        }
      } else {
        // 将父节点往右下移(插入右边)
        current = current.right
        if (!current) {
          parent.right = node
          break
        }
      }
    }
  }
}

// 先序遍历(根左右)
function  preOrder (node) {
  if (node) {
    // console.log(node.show())
    DLR_arr.push(node.show())
    if (!node.left && !node.right && DLR_arr.length === 5) {
      console.log("前序遍历:", DLR_arr);
    }
    this.preOrder(node.left)
    this.preOrder(node.right)
  }
}

// 中序遍历(左根右)
function  inOrder (node) {
  if (node) {
    this.inOrder(node.left)
    // console.log(node.show())
    LDR_arr.push(node.show())
    if (!node.left && !node.right && LDR_arr.length === 5) {
      console.log("中序遍历:", LDR_arr);
    }
    this.inOrder(node.right)
  }
}

// 后序遍历(左右根)
function postOrder (node) {
  if (node) {
    this.postOrder(node.left)
    this.postOrder(node.right)
    // console.log(node.show())
    LRD_arr.push(node.show())
    if (LRD_arr.length === 5) {
      console.log("后序遍历:", LRD_arr);
    }
  }
}

// 实例化一个BST树
var tree = new BST()
// 添加节点
tree.insert(30)
tree.insert(14)
tree.insert(35)
tree.insert(12)
tree.insert(17)

var DLR_arr = []
var LDR_arr = []
var LRD_arr = []

// 先序遍历
tree.preOrder(tree.root)
// 中序遍历
tree.inOrder(tree.root)
// 后序遍历
tree.postOrder(tree.root)

第3节 堆——神奇的优先队列

  • 一种特殊的二叉树
  • 最小堆:所有父节点都比子节点要小
  • 最大堆:所有父节点都比子节点要大
  • 应用:优先队列:支持插入元素和寻找最大(小)值元素的数据结构
// Base
var h = [0, 99, 5, 36, 7, 22, 17, 46, 12, 2, 19, 25, 28, 1, 92] // 用来存放堆的数组

var n = 14 // 用来存放堆中元素的个数,也就是堆的大小

var sum = n

// 交换函数,用来交换堆中的两个元素的值
function swap (x, y) {
    let t = h[x]
    h[x] = h[y]
    h[y] = t
}

// 向下调整函数
function siftDown (i) {
    let t
    let flag = 0
    while (i * 2 <= n && flag === 0) {
        if (h[i] > h[i * 2]) {
            t = i * 2
        } else {
            t = i
        }
        if (h[t] > h[i * 2 + 1]) {
            t = i * 2 + 1
        }
        if (t !== i) {
            swap(i , t)
            i = t
        } else {
            flag = 1
        }
    }
}

// 建立堆函数
function create () {
    let i
    for (i = Math.floor(n / 2); i >= 1; i--) {
        siftDown(i)
    }
}

// 删除最小元素
function deleteMin () {
    let t
    t = h[1]
    h[1] = h[n]
    n--
    siftDown(1)
    return t
}

create()

for (let j = 1; j <= sum ; j++) {
    console.log(deleteMin())  // 1, 2, 5, 7, 12, 17, 19, 22, 25, 28, 36, 46, 92, 99
}

// More
var h = [0, 99, 5, 36, 7, 22, 17, 46, 12, 2, 19, 25, 28, 1, 92] // 用来存放堆的数组

var n = 14 // 用来存放堆中元素的个数,也就是堆的大小

function HeapSort (h, n ,n) {
    this.h = h
    this.n = n
    this.num = n
}

HeapSort.prototype = {
    swap: function (x, y) {
        let t = this.h[x]
        this.h[x] = this.h[y]
        this.h[y] = t
    },
    siftDown: function (i) {
        let t
        let flag = 0
        while (i * 2 <= this.n && flag === 0) {
            if (this.h[i] > this.h[i * 2]) {
                t = i * 2
            } else {
                t = i
            }
            if (this.h[t] > this.h[i * 2 + 1]) {
                t = i * 2 + 1
            }
            if (t !== i) {
                this.swap(i , t)
                i = t
            } else {
                flag = 1
            }
        }
    },
    create: function () {
        let i
        for (i = Math.floor(this.n / 2); i >= 1; i--) {
            this.siftDown(i)
        }
    },
    deleteMin: function () {
        let t
        t = this.h[1]
        this.h[1] = this.h[this.n]
        this.n--
        this.siftDown(1)
        return t
    }
}

var newHeap = new HeapSort(h, n, n)

newHeap.create()

for (let j = 1; j <= newHeap.num ; j++) {
    console.log(newHeap.deleteMin())  // 1, 2, 5, 7, 12, 17, 19, 22, 25, 28, 36, 46, 92, 99
} 

第4节 擒贼先擒王——并查集

// 存放每个节点的根节点
var f = []

// 记录节点的个数
var n = 10

// 有关联的节点关系
var m = []
m.length = 10
m[1] = [1, 2]
m[2] = [3, 4]
m[3] = [5, 2]
m[4] = [4, 6]
m[5] = [2, 6]
m[6] = [8, 7]
m[7] = [9, 7]
m[8] = [1, 6]
m[9] = [2, 4]

// 记录独立根节点的个数
var sum = 0

// 初始化 数组里 存的是 自己数组下标的编号
function init () {
  for (let i = 1; i <= n; i++) {
    f[i] = i
  }
}

// 找祖宗的递归函数,不停地去找祖宗,直到找到祖宗为止,
// 其实就是去找犯罪团伙的最高领导人,“擒贼先擒王”原则
function getF (v) {
  if (f[v] === v) {
    return v
  } else {
    // 这里是路径压缩,每次在函数返回的时候,顺带把路上遇到的人的“BOSS”改为最后
    // 找到的祖宗编号,也就是犯罪团伙的最高领导人编号。这样可以提高今后找到犯罪团伙的最高领导人
    //(其实就是树的祖先)的速度
    f[v] = getF(f[v])
    return f[v]
  }
}
// 合并两子集合的函数
function merge (v, u) {
  let t1, t2
  t1 = getF(v)
  t2 = getF(u)
  if (t1 !== t2) {  // 判断两个节点是否在同一个集合中,即是否为同一个祖先
    f[t2] = t1
    // “靠左”原则左边变成右边的BOSS。即把右边的集合,作为左边集合的子集合。
    // 经过路径压缩后,将f[u]的根的值也赋值为v的祖先f[t1]
  }
}

// 初始化
init()
// 开始合并犯罪团伙
for (let i = 1; i < m.length; i++) {
  merge(m[i][0], m[i][1])
}
// 求出独立的根节点,即扫描出有多少个独立的犯罪团伙
for (let i = 1; i <= n; i++) {
  if (f[i] === i) {
    sum++
  }
}

console.log(sum)  // 3