JavaScript数据结构和算法笔记二(排序、搜索、算法)

一、排序

1.排序和搜索概念

  • 排序:把某个乱序的数组变成升序或者降序的数组
  • 搜索:找出数组中某个元素的下标

2.JS中的排序和搜索

  • JS中的排序:数组的sort方法

    var numbers = [4, 2, 5, 1, 3];
    numbers.sort(function(a, b) {
      return a - b;
    });
    console.log(numbers);
    
    也可以写成:
    var numbers = [4, 2, 5, 1, 3];
    numbers.sort((a, b) => a - b);
    console.log(numbers);
    
    // [1, 2, 3, 4, 5]
    
  • JS中的搜索:数组的indexOf方法

    const beasts = ['ant', 'bison', 'camel', 'duck', 'bison'];
    
    console.log(beasts.indexOf('bison'));
    // expected output: 1
    
    // start from index 2
    console.log(beasts.indexOf('bison', 2));
    // expected output: 4
    
    console.log(beasts.indexOf('giraffe'));
    // expected output: -1
    

3.排序算法

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 归并排序
  • 快速排序

4.搜索算法

  • 顺序搜索
  • 二分搜索

5.冒泡排序思路

  • 比较所有相邻元素,如果第一个比第二个大,则交换它们

  • 一轮下来,可以保证最后一个数是最大的

  • 执行n-1轮,就可以完成排序

  • 时间复杂度:冒泡排序的时间复杂度为O(n^2),因为含有两个嵌套循环。

    Array.prototype.bubbleSort = function() {
      for(let i = 0; i < this.length - 1; i++) {
        for(let j = 0; j < this.length - 1 - i; j++) {
          if(arr[j] > arr[j + 1]) {
            const temp = arr[j + 1];
            arr[j + 1] = arr[j];
            arr[j] = temp;
          }
        }
      }
    }
    
    const arr = [5,4,3,2,1];
    arr.bubbleSort();
    console.log(arr);
    

6.选择排序的思路

  • 找到数组中的最小值,选中它并将其放置在第一位

  • 接着找到第二小的值,选中它并将其放置在第二位

  • 以此类推,执行n-1轮

  • 时间复杂度:选择排序的时间复杂度为O(n^2),因为含有两个嵌套循环。

    Array.prototype.selectionSort = function() {
    
      for(let i = 0; i < this.length; i++) {
        let minIndex = i;
    
        for(let j = i; j < this.length; j++) {
          if(arr[j] < arr[minIndex]) {
            minIndex = j;
          }
        }
        //增加一个逻辑,若最小值和i相等则不需要交换了
        if(minIndex !== i) {
          const temp = arr[i];
          arr[i] = arr[minIndex];
          arr[minIndex] = temp;
        }
    
      }
    }
      
    
    const arr = [5,4,3,2,1];
    arr.selectionSort();
    console.log(arr);
    

7.插入排序的思路

  • 从第二个数开始往前比

  • 比它大就往后排

  • 依次类推进行到最后一个数

  • 时间复杂度:插入排序的时间复杂度为O(n^2),因为同样含有两个嵌套循环。

    Array.prototype.insertionSort = function() {
      
      for(let i = 1; i < this.length; i++) {
        const temp = this[i];
        let j = i;
        while(j > 0) {
          if(this[j - 1] > temp) {
            this[j] = this[j - 1];
          }else {
            break;
          }
          j--;
        }
        this[j] = temp;
      }
    }
      
    
    const arr = [5,4,3,2,1];
    arr.insertionSort();
    console.log(arr);
    

8、归并排序的思路(比前三种方法性能都更好)

  • 分:把数组劈成两半,再递归地对子数组进行“分”操作,直到分成一个个单独的数。

  • 合:把两个数合并为有序数组,再对有序数组进行合并,直到全部子数组合并为一个完整数组。

  • 合并两个有序数组

    • 新建一个空数组res,用于存放最后排序后的数组
    • 比较两个有序数组的头部,较小者出队并推入res中
    • 如果两个数组还有值,就重复第二步
  • 时间复杂度:归并排序进行“分操作”的时间复杂度是O(logN),一般涉及到二分操作我们其实就可以直接联想到logN。“合操作”的时间复杂度是O(n),因为使用了一个while循环。故归并排序的时间复杂度为:O(n * logN)

    Array.prototype.mergeSort = function() {
      const rec = (arr) => {
        if(arr.length === 1) {return arr;}
        const mid = Math.floor(arr.length / 2);
        const left = arr.slice(0, mid);
        const right = arr.slice(mid, arr.length);
        const orderLeft = rec(left);
        const orderRight = rec(right);
        const res = [];
        while(orderLeft.length || orderRight.length) {
          if(orderLeft.length && orderRight.length) {
            res.push(orderLeft[0] < orderRight[0] ? orderLeft.shift() : orderRight.shift());
          }else if(orderLeft.length) {
            res.push(orderLeft.shift());
          }else if(orderRight.length) {
            res.push(orderRight.shift());
          }
        }
        return res;
      }
      const res = rec(this);
      res.forEach((v, i) => {
        this[i] = v;
      })
    }
      
    
    const arr = [5,4,3,2,1];
    arr.mergeSort();
    console.log(arr);
    

9.快速排序思路

  • 分区:从数组中任意选择一个“基准”,所有比基准小的元素放在基准前面,比基准大的元素放在基准的后面。

  • 递归:递归地对基准前后的子数组进行分区。

  • 时间复杂度:首先递归的时间复杂度为O(logN),分区操作的时间复杂度是O(n),所以快速排序的时间复杂度为O(n * logN)。

    Array.prototype.quickSort = function() {
      const rec = (arr) => {
        if(arr.length === 1) {return arr;}
        const left = [];
        const right = [];
        const mid = arr[0];
        for(let i = 1; i < arr.length; i++) {
          if(arr[i] < mid) {
            left.push(arr[i]);
          }else{
            right.push(arr[i]);
          }
        }
        return [...rec(left), mid, ...rec(right)];
      };
      const res = rec(this);
      res.forEach((v, i) => {
        this[i] = v;
      })
    }
      
    
    const arr = [2,4,5,3,1];
    arr.quickSort();
    console.log(arr);
    

二、搜索

1.顺序搜索的思路

  • 遍历数组

  • 找到跟目标值相等的元素,就返回它的下标

  • 遍历结束后,如果没有搜索到目标值,就返回-1。

  • 时间复杂度:因为遍历数组是一个循环操作,所以时间复杂度为O(n)。

    Array.prototype.sequentialSearch = function(item) {
      for(let i = 0; i < this.length; i++) {
        if(this[i] === item) {
          return i;
        }
      }
      return -1;
    }
    
    const arr = [5,3,2,1,4];
    console.log(arr.sequentialSearch(3));
    
    

2.二分搜索的思路(使用二分搜索的前提是该数组是有序的)

  • 从数组的中间元素开始,如果中间元素正好是目标值,则搜索结束。

  • 如果目标值大于或者小于中间元素,则在大于或者小于中间元素的那一半数组中搜索。

  • 时间复杂度:由于每一次比较都使得搜索范围缩小一半,故二分搜索的时间复杂度为O(logN)。

    Array.prototype.binarySearch = function(item) {
      let low = 0;
      let high = this.length - 1;
      while(low <= high) {
        const mid = Math.floor((low + high) / 2);
        if(this[mid] < item) {
          low = mid + 1;
        }else if(this[mid] > item) {
          high = mid - 1;
        }else {
          return mid;
        }
      }
      return -1;
    }
    
    const arr = [1,2,3,4,5];
    console.log(arr.binarySearch(0));
    
    

3.合并两个有序链表(题号Leetcode21)

  • 解题思路:

    • 与归并排序中的合并两个有序数组很相似
    • 将数组替换成链表就能解此题
  • 解题步骤:

    • 新建一个新链表,作为返回结果
    • 用指针遍历两个有序数组,并比较两个链表的当前节点,较小者先接入新链表,并将指针后移一步
    • 链表遍历结束,返回新链表
    • 解法2的时间复杂度为O(n),n为两个链表的总长度

    解法1(自己写的):

var mergeTwoLists = function(l1, l2) {
  let p1 = l1;
  let p2 = l2;
  let res = new ListNode(0);
  let p3 = res; 

  while(p1 || p2) {
      if(p1 && p2) {
          if(p1.val < p2.val) {
              p3.next = new ListNode(p1.val);
              p1 = p1.next;
              p3 = p3.next;
          }else {
              p3.next = new ListNode(p2.val);
              p2 = p2.next;
              p3 = p3.next;
          }
      }else if(p1) {
          p3.next = new ListNode(p1.val)
          p1 = p1.next;
          p3 = p3.next;
      }else if(p2) {
          p3.next = new ListNode(p2.val)
          p2 = p2.next;
          p3 = p3.next;
      }
  }
   
   return res.next;
};

解法2:(老师写的)

var mergeTwoLists = function(l1, l2) {
   let p1 = l1;
   let p2 = l2;
   let res = new ListNode(0);
   let p3 = res; 
 
    while(p1 && p2) {
        if(p1.val < p2.val) {
            p3.next = new ListNode(p1.val);
            p1 = p1.next;
        }else {
            p3.next = new ListNode(p2.val);
            p2 = p2.next;
        }
        p3 = p3.next;
    }

    if(p1) {
        p3.next = p1; 
    }
    if(p2) {
        p3.next = p2;
    }

    return res.next;
};

4.猜数字大小(题号Leetcode374)

  • 解题思路:

    • 这就是二分搜索
    • 区别就是,该题需要调用guess函数,来判断中间元素是否是目标值
  • 解题步骤:

    • 从数组的中间元素开始,如果中间元素正好是目标值,则搜索过程结束。
  • 如果目标值大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找。

    • 该解法的时间复杂度为O(logN),空间复杂度为O(1)。
    var guessNumber = function(n) {
        let low = 1;
        let high = n;
        while(low <= high) {
            const mid = Math.floor((low + high) / 2);
            const res = guess(mid);
            if(res === -1) {
                high = mid - 1; 
            }else if(res === 1) {
                low = mid + 1;
            }else if(res === 0) {
                return mid;
            }
        }
    };
    

三、分而治之

1.分而治之概念

  • 分而治之是算法设计中的一种方法。
  • 它将一个问题分成多个和原问题相似的小问题,递归解决小问题,再将结果合并以解决原来的问题。

2.使用场景

  • 场景一:归并排序
    • 分:把数组从中间一分为二。
    • 解:递归地对两个子数组进行归并排序。
    • 合:合并有序子数组。
  • 场景二:快速排序
    • 分:选基准,按基准把数组分成两个子数组。
    • 解:递归地对两个子数组进行快速排序。
    • 合:对两个子数组进行合并。

3.猜数字大小(题号Leetcode374)

利用分而治之方法解决该问题。

这种方案的时间复杂度还是O(logN),因为还是采用一分为二的做法。但空间复杂度是指数增长的,关键看递归了多少层,该题的空间复杂度还是O(logN)。

var guessNumber = function(n) {
    const rec = (low, high) => {
        if(low > high) {return ;}

        const mid = Math.floor((low + high) / 2);
        const res = guess(mid);
        if(res === 0) {
            return mid;
        }else if(res === 1) {
            return rec(mid + 1, high);
        }else {
            return rec(low, mid - 1);
        }
    }

    return rec(1, n);
};

4.翻转二叉树(题号Leetcode226)

  • 解题思路

    • 先翻转左右子树,再将子树换个位置
    • 符合“分、解、合 ”特性
    • 考虑选择分而治之
  • 解题步骤

    • 分:获取左右子树
    • 解:递归地翻转左右子树
    • 合:将翻转后的左右子树换个位置放到根节点上
  • 复杂度

    • 时间复杂度:该解法的时间复杂度其实就看它执行了几次,我们可知其执行了n次,n为二叉树节点数量。所以,时间复杂度为O(n)。
    • 空间复杂度:因为是一个递归方法,这是一个堆栈。空间复杂度为O(h),h为树的高度。
    var invertTree = function(root) {
        if(!root) {return null;}
        return {
            val: root.val,
            left: invertTree(root.right),
            right: invertTree(root.left),
        }
    };
    

5.相同的树(题号Leetcode100)

  • 解题思路:

    • 两个树:根节点的值相同,左子树相同,右子树相同。
    • 符合“分、解、合”特性。
    • 考虑选择分而治之。
  • 解题步骤:

    • 分:获取两个树的左子树和右子树

    • 解:递归地判断两个树的左子树是否相同,右子树是否相同

    • 合:将上述结果合并,如果根节点的值也相同,树就相同

  • 算法的复杂度:

    • 时间复杂度:因为遍历了所有节点,所以时间复杂度是O(n),n为节点数量。
    • 空间复杂度:由于是递归,在内部形成了一个堆栈,在最坏的情况下空间复杂度为O(n),在最好的情况下是O(logN),取决于节点的分布。
    var isSameTree = function(p, q) {
        if(!p && !q) {return true;}
        if(p && q && p.val === q.val && isSameTree(p.left, q.left) 
        && isSameTree(p.right, q.right)) {
            return true;
      }
        return false;
    };
    

    代码写法2:

    var isSameTree = function(p, q) {
      if(p == null && q == null) {
        return true
      }
      if(p == null || q == null) {
        return false
      }
      if(p.val != q.val) {
        return false
      }
      return isSameTree(p.left, q.left) && isSameTree(p.right, q.right)
    }
    

6.对称二叉树(题号Leetcode101)

  • 解题思路:

    • 转化为:左右子树是否镜像。
    • 分解为:树1的左子树和树2的右子树是否镜像,树1的右子树和树2的左子树是否镜像。
    • 符合“分、解、合”特性,考虑选择分而治之
  • 解题步骤:

    • 分:获取两个树的左子树和右子树。
    • 解:递归地判断树1的左子树和树2的右子树是否镜像,树1的右子树和树2的左子树是否镜像。
    • 合:如果上述都成立,且根节点值也相同,两个树就镜像。
  • 算法的复杂度:

    • 时间复杂度:因为该算法访问了所有的节点,所以时间复杂度是O(n),n为二叉树的节点数。
    • 空间复杂度:空间复杂度为O(h),h为二叉树的高度,最坏的情况下h=n.
    var isSymmetric = function(root) {
        if(!root) {return true;}
        const isMirror = (l, r) => {
            if(!l && !r) {return true;}
            if(l && r && l.val === r.val && 
                isMirror(l.left, r.right) && 
                isMirror(l.right, r.left)
            ) {
                return true;
            }
            return false;
        }
        return isMirror(root.left, root.right)
    };
    

    代码写法2:

    var isSymmetric = function(root) {
      if(!root) {return true}
      const isMirror = (l, r) => {
        if(l == null && r == null) {
          return true
        }
        if(l == null || r == null) {
          return false
        }
        if(l.val != r.val) {
          return false
        }
        return isMirror(l.left, r.right) && isMirror(l.right, r.left)
      }
      return isMirror(root.left, root.right)
    }
    

四、动态规划

1.动态规划概念

  • 动态规划是算法设计中的一种方法。
  • 它将一个问题分解为相互重叠的子问题,通过反复求解子问题,来解决原来的问题。

2.动态规划使用场景

  • 斐波那契数列:n=0时,F(n)=0;n=1时,F(n)=1;n>=2时,F(n) = F(n - 1) + F(n - 2)。
    • 定义子问题:F(n) = F(n - 1) + F(n - 2)。
    • 反复执行:从2循环到n,执行上述公式。

3. 动态规划和分而治之的区别

  • 这两种方式的最大区别就在于它们的子问题是否是独立的?
  • 如果它们的子问题是相互重叠的则是动态规划;
  • 如果它们的子问题是独立的则是分而治之。

4.爬楼梯(题号Leetcode70)

  • 解题思路:

    • 爬到第 n 阶可以在第 n-1 阶爬上1个台阶,或者在第 n-2 阶爬2个台阶
    • F(n) = F(n - 1) + F(n - 2)
    • 使用动态规划
  • 解题步骤:

    • 定义子问题:F(n) = F(n - 1) + F(n - 2)。

    • 反复执行:从2循环到n,执行上述公式。

  • 解法的复杂度:

    • 时间复杂度:O(n)。
    • 空间复杂度:O(n)。
  • 方法1:使用数组存储爬上每一阶的方法

var climbStairs = function(n) {
    if(n < 2) {return 1;}
    const dp = [1, 1];
    for(let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
};
  • 方法2:其实可以直接用两个变量存储数据降低空间复杂度,这时空间复杂度为O(1)。

    var climbStairs = function(n) {
        if(n < 2) {return 1;}
        let dp0 = 1;
        let dp1 = 1;
        for(let i = 2; i <= n; i++) {
            const temp = dp0;
            dp0 = dp1;
            dp1 = dp1 + temp;
        }
        return dp1;
    };
    

5.打家劫舍(题号Leetcode198)

  • 解题思路:

    • f(k) = 从前k个房屋中能偷窃到的最大数额。
    • Ak = 第k个房屋的钱数。
    • f(k) = max( f(k - 2) + Ak, f(k - 1) )。
    • 使用动态规划
  • 解题步骤:

    • 定义子问题:f(k) = max(f(k - 2) + Ak, f(k - 1))。
    • 反复执行:从2循环到n,执行上述公式。
  • 算法复杂度:

    • 时间复杂度:因为有一个for循环,所以时间复杂度为O(n),n为nums的长度。
    • 空间复杂度:dp为一个数组,所以空间复杂度为O(n)。
    • 方法一:
    var rob = function(nums) {
        if(!nums) {return 0;}
        const dp = [0, nums[0]];
        for(let i = 2; i <= nums.length; i++) {
            dp[i] = Math.max(dp[i - 2] + nums[i - 1], dp[i - 1]);
        }
        return dp[nums.length];
    };
    
    • 方法二:从数组改用变量,降低空间复杂度。
    var rob = function(nums) {
        if(!nums) {return 0;}
        let dp0 = 0;
        let dp1 = nums[0];
        for(let i = 2; i <= nums.length; i++) {
            const dp2 = Math.max(dp0 + nums[i - 1], dp1);
            dp0 = dp1;
            dp1 = dp2;
        }
        return dp1;
    };
    

6.动态规划的步骤

  • 定义子问题
  • 反复执行

五、贪心算法

1.贪心算法概念

  • 贪心算法是算法设计中的一种方法。
  • 期盼通过每个阶段的局部最优选择,从而达到全局的最优。
  • 结果不一定是最优。

2.分饼干(题号Leetcode455)

  • 解题思路:

    • 本题的局部最优:既能满足孩子,还消耗最少。
    • 可以先将“较小的饼干”分给“胃口最小”的孩子。
  • 解题步骤:

    • 对饼干数组和胃口数组升序排序。
    • 遍历饼干数组,找到能满足第一个孩子的饼干。
    • 然后继续遍历饼干数组,找到满足第二、三、…、n个孩子的饼干。
  • 算法复杂度:

    • 时间复杂度:因为快速分类的时间复杂度为 O(nlogN ),for循环的时间复杂度为O(n), 两者之间取较大者,故为O(nlogN)。
    • 空间复杂度:因为两个数组g和s都是之前就存在的,并未临时创建。所以,该算法的空间复杂度为O(1)。
    var findContentChildren = function(g, s) {
        const sortFunc = (a, b) => {
            return a - b;
        }
    
        g.sort(sortFunc);
        s.sort(sortFunc);
    
        let i = 0;
        s.forEach( n => {
            if(n >= g[i]) {
                i++;
            }
        })
    
        return i;
    };
    

3.买卖股票的最佳时机II(题号Leetcode122)

  • 解题思路:

    • 前提:上帝视角,知道未来价格。
    • 局部最优:见好就收,见差就不动,不做任何长远打算。
  • 解题步骤:

    • 新建一个变量,用来统计总利润。
    • 遍历价格数组,如果当前价格比昨天高,就在昨天买,今天卖,否则就不交易。
    • 遍历结束后,返回所有利润之和。
  • 算法的复杂度:

    • 时间复杂度:O(n)。
    • 空间复杂度:O(1)。
    var maxProfit = function(prices) {
        let profit = 0;
        for(let i = 1; i < prices.length; i++ ) {
            if(prices[i] > prices[i - 1]) {
                profit += prices[i] - prices[i - 1];
            }
        }
        return profit;
    };
    

六、回溯算法

1.回溯算法的概念

  • 回溯算法是算法设计中的一种方法。
  • 回溯算法是一种渐进式寻找并构建问题解决方式的策略。
  • 回溯算法会先从一个可能的动作开始解决问题,如果不行,就回溯并选择另一个动作,直到将问题解决。

2.什么问题适合用回溯算法解决?

  • 有很多路。(这里的路是一个比喻,有的时候表示路径,有时候表示排列情况等等。)
  • 这些路里,有死路,也有出路。(死路表示不符合要求的情况,出路即符合题目要求的情况)
  • 通常需要递归来模拟所有路。

3.全排列(题号Leetcode46)

  • 解题思路:

    • 题目要求:1、所有排列情况;2、没有重复元素
    • 有出路、有死路
    • 考虑使用回溯算法
  • 解题步骤:

    • 用递归模拟出所有情况
    • 遇到包含重复元素的情况,就回溯
    • 收集所有到达递归终点的情况,并返回
  • 算法的复杂度:

    • 时间复杂度:因为每一次递归里面都有一个for循环,但又因为重复序列的数字不会输出,所以每次递归的循环次数都会减少,故时间复杂度为O(n!),,n!=1x2x3x…x (n-1) x n
    • 空间复杂度:O(n),n为数组的长度。
    var permute = function(nums) {
        const res = [];
    
        const backTrack = (path) => {
            if(path.length === nums.length) {
                res.push(path);
                return;
            }
            nums.forEach((k) => {
                if(path.includes(k)) {return ;}
                backTrack(path.concat(k));
            })
        }
    
        backTrack([]);
    
        return res;
    };
    

4.子集(题号Leetcode78)

  • 解题思路:

    • 题目要求:1、所有子集(注意,子集是有顺序性的);2、没有重复元素。
    • 有出路、有死路。
    • 考虑使用回溯算法。
  • 解题步骤:

    • 用递归模拟出所有情况。
    • 保证接的数字都是后面的数字。
    • 收集所有到达递归终点的情况,并返回。
  • 算法的复杂度:

    • 时间复杂度:O(2^n),因为每个元素都有两种可能。n为nums的长度。
    • 空间复杂度:O(n)
    var subsets = function(nums) {
        const res = [];
    
        const backTrack = (path, l, start) => {
            if(path.length === l) {
                res.push(path);
                return ;
            }
    
            for(let i = start; i < nums.length; i++) {
                backTrack(path.concat(nums[i]), l, i+1);
                //这里传入i+1的目的是保证子集顺序性
            }
    
        }
    	//表示需要的子集长度
        for(let i = 0; i <= nums.length; i++) {
            backTrack([], i, 0)
        }
        return res;
    };
    

七、总结

1.重点难点

  • 数据结构:所有数据结构都很重要,跟前端最相关的是链表和树。
  • 算法:链表/树/图的遍历、数组的排序和搜索…
  • 设计思想:分而治之、动态规划较常考,贪心、回溯次之。

2.经验心得

  • 搞清楚数据结构与算法的特点应用场景
  • JS实现一遍,最好能用第二第三语言再实现一遍。
  • 学会分析时间/空间复杂度
  • 提炼前端和算法的结合点,用于工作实践。

3.拓展建议

  • 多刷题,最好保证300道以上。
  • 多总结各种套路、模板。
  • 多阅读源码,比如React、Lodash、V8…
  • 多实战,将数据结构与算法用于工作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值