常用算法浅析

算法

​ 现在有了自己的博客网站,CSDN上很久没发文章了,欢迎大家去踩踩。算法为程序注入灵魂,它是智慧的体现。前段时间粗略的过了一遍数据结构,读到后面部分感觉到有点枯燥,因为总感觉学的这些东西貌似没有用武之地。由于长期搞数模我倒是对算法提前接触过不少,于是我打算先学习一波算法,感受数据结构结合数学的妙处,再回过头去看一遍数据结构。先整理了这些常用算法,有些虽然核心代码短短几行,但十分抽象,得多回来复习才有可能在实战中使用自如。

排序算法
  • 冒泡(Bubble Sort)

    • 从前往后依次比较相邻元素,将较大者交换到后面(也可根据需求反序),每次遍历都会将当前最大元素交换到最后位置,遍历数组长度-1次即可,遍历后更新遍历区间。

    • public static void bubbleSort(int[] array){
              for (int i = 0; i < array.length - 1; i++) {
                  for (int j = 0; j < array.length - 1 - i; j++) {
                      if (array[j] > array[j + 1]){
                          int temp = array[j];
                          array[j] = array[j + 1];
                          array[j + 1] = temp;
                      }
                  }
              }
          }
      
  • 快排(Quick Sort)

    • 由于递归调用需要封装函数

      Qsort(nums, 0, nums.length - 1);
      
    • Qsort函数:调用Partition函数得到枢轴位置,再对左右两边的高低子数组递归调用排序。

      public static void Qsort(int[] nums, int l, int r) {
              int flag;
              if (l < r) {
                  flag = Partition(nums, l, r);
                  Qsort(nums, l, flag - 1);
                  Qsort(nums, flag + 1, r);
              }
          }
      
    • Partition函数:计算枢轴将数组一分为二,使得左边元素全部小于枢轴值,右边全大于,返回枢轴值pivot所在位置。未优化的快排是默认选取数组最左边元素作为枢轴,再从两边向中间扫描,以右循环扫描为例,若right指向的元素大于枢轴值,则right-1,因为目标就是把比枢轴值大的元素全放在枢轴右侧;若其小于枢轴值,则将其和left所指元素交换位置,即直接放到数组左边。这样比枢轴小的元素就会往左收敛,大的往右收敛,最终枢轴值会到正确的枢轴位置上,将数组完成分割。

      public static int Partition(int[] array, int left, int right) {
              int pivotkey = array[left];
              int temp;
              while (left < right) {
                  while (left < right && array[right] >= pivotkey) {
                      right--;
                  }
                  temp = array[left];
                  array[left] = array[right];
                  array[right] = temp;
      
                  while (left < right && array[left] <= pivotkey) {
                      left++;
                  }
                  temp = array[left];
                  array[left] = array[right];
                  array[right] = temp;
              }
              return left;
          }
      

二分查找(Binary Search)
  • 概述:适用于从有序的数列中查找某个元素。从中间位置开始与目标比较,并根据数列排布规律确定下一个查找区间,不断重复,每次将查找区间砍半,对数时间复杂度。

  • 有两种实现方式:

    1. 非递归:借助循环语句更新区间
    2. 递归:判断语句执行后调用自身更新区间
  • while (start <= end) {
               mid = (end - start) / 2 + start;
        		//mid = (start + end)/2;
               if (key < srcArray[mid]) {
                   end = mid - 1;
               } else if (key > srcArray[mid]) {
                   start = mid + 1;
               } else {
                   return mid;
               }
           }
    

    更新区间时不推荐注释写法,因为首尾相加可能会超出整型范围。


分治算法(Divide and Conquer)
  • 概述:分而治之,将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同,求出子问题的解,就可得到原问题的解。该算法常以递归方式实现,难点在于寻找问题的规律将其合理分解,快排、二分都是分治经典问题,下面给出汉诺塔实例。

  • HanoiTower:无论塔有多少层,总将其看为最下面一层和上面两部分,移动规律即为先将a杆上的上面部分移到b,再把a的最下面一层移到c,最后再把b上的移到c。

  • public static void hanoi(int num, char a, char b, char c){
        if (num == 1){ //当只有1层时,直接从a杆移动到c杆
            System.out.println("Move No.1 from "+a+" to "+c);
        }else {
            //当层数大于1时,将其看为两部分,最下面一层和上面的部分
            hanoi(num-1, a, c, b); //先把上面的部分移动到b
            System.out.println("Move No."+num+" from "+a+" to "+c); //再把最下面一层移动到c
            hanoi(num-1, b, a, c); //再把上面的部分从b移动到c
        }
    }
    

动态规划(Dynamic Programming)
  • DP概述:与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。(百度讲的通透)

  • 背包问题(Knapsack problem)

    • 核心在于从0开始遍历背包容量,得到二维状态转移矩阵,低容量时的数据用于决策后面高容量方案。
    for (int i = 1; i <= M; i++) { //遍历背包容量,得到状态转移矩阵
                for (int j = 1; j <= N; j++) { //遍历物品
                    if (w[j] > i){ //如果第j件物品的重量大于背包容量,直接令当前最大价值为上一件物品对应的最大价值
                        m[j][i] = m[j-1][i];
                    }else {
                        m[j][i] = max( m[j-1][i], m[j-1][i-w[j]]+v[j] ); //否则比较不放入第j件物品和放入时的价值(放入可能会导致前面的物品被拿出),较大者为当前最大价值
                    }
    
                }
            }
    
    • 最大价值组合即为最后一列,要得到相应物品放置策略需要倒序查找
    for (int i = N; i > 0; i--) { //背包容量为M时最大价值物品组合,倒序
        if (m[i][M] != m[i-1][M]){ //和放上一件物品最大价值相同时表示未放该件物品
            System.out.println(i);
            M -= w[i]; //当和上一件最大价值相同时,由m[i-1][M]继续构造最优解;当不同时,则由m[i-1][M-w[i]]继续构造最优解
        }
    }
    
字符串匹配算法
  • 朴素匹配

    对主串的每个字符做大循环,再嵌入子串的小循环,不成功则更新主串下标,子串也重头开始,直到子串下标达到其长度则匹配成功。

  • KMP算法

    • 概述:核心是利用匹配失败后的信息,具体实现就是通过一个包含了子串的局部匹配信息的next数组,回溯到首尾存在重复的地方。通过该数组,若在子串下标为j处匹配失败,我们可以只更新j=next[j],而不必去回溯主串下标i的值。该算法适用于处理重复度较高的字符串,如01串。

    • next数组推导:j从1开始,next[1]=0(便于代码中更新i=i+1),next[2]=1,当j>1时,观察前j-1位,next[j]等于前j-1位字符串中前缀与后缀的最大重复长度+1。编程实现需要用到j值的回溯:

      i = 1;//后缀下标
      j = 0;//前缀下标
      while(i<T.length){ //T为子串
          if(j==0 || T[i]==T[j]){
              ++i;
              ++j;
              next[i] = j;
          }else{
              j = next[j];//字符不相同时,j回溯前移
          }
      }
      
    • nextval改进数组:对于aaaaaab此类子串的匹配,next数组逐步后移产生多余判断。改良数组为:得到第j位的值next[j]后,判断T[next[j]]T[j],若字符相同则nextval[j]=next[next[j]],否则nextval[j]=next[j]

贪心算法(Greedy)
  • 概述:贪心是个比较宽泛的说法,将问题拆分,求其子问题的当前最优解,合并得到局部最优解。适合在无法求出最优时求近似最优,其核心在于贪心规则制定,要保证每一步尽可能做到当前最优。

  • 集合覆盖问题

    • 要求:选出尽可能少的集合,包含所有集合中出现过的元素
    • 思路:遍历每个集合,选出包含当前未覆盖元素最多的集合,并标记其元素。
最小生成树(MST)
  • 概述:在无向图中生成一颗边权之和最小的任意两点联通的树,用到贪心的思想,引出以下经典算法及实例:

  • Prim算法解决修路问题

    • 要求:各个地点连通并且修路的总里程最短

    • 思路:从某个点开始,在其可达的点中选出距离最近的点,将该点加入集合,放入集合的点不可再成为选择对象,然后列出集合中每个点可达的所有点,选出下一个距离最近的点加入集合。以此类推外部大循环点数减一次,即可找够边使所有点流通。该算法思路简单,但是复杂度较高,三层循环。

      for (int i = 1; i < place.length; i++) { //只需地点数-1条边便可将其连通
                  int minDistance = 10000; //一轮遍历中的最小距离
                  int h1 = -1; //对应的始末点
                  int h2 = -1;
                  for (int j = 0; j < place.length; j++) {
                      for (int k = 0; k < place.length; k++) {
                          if (visit[j] == 1 && visit[k] == 0 && distance[j][k] < minDistance && distance[j][k] > 0){ //j对应访问过的点,k对应未访问过的点,选出距离最小并可达的一组
                              minDistance = distance[j][k]; //记录当前最小距离及两点索引
                              h1 = j;
                              h2 = k;
                          }
                      }
                  }
                  visit[h2] = 1; //将选出的点标记为已访问
                  Distance += minDistance;
                  System.out.println("<"+place[h1]+","+place[h2]+">:"+minDistance);
              }
      
  • Kruskal算法解决公交站问题

    • 要求:同修路问题

    • 思路:也是找出点数-1条边,先将每条边按照权值由小到大排序,选出长度最短的边加入集合,再不断从剩下的边中选出长度最短边加入,有个限制条件是加入的边不能使集合中的边形成回路,编程实现通常使用并查集,来快速判断两个顶点是否属于同一个根(集合)。相较于Prim算法复杂度低,只有两次循环,但并查集思想比较抽象且随加入集合的边实时更新,不易理解。

      if (m != n){ //未形成回路则添加该边到树
              ends[m] = n;
          
      public static int getEnd(int[] ends, int i){
              while (ends[i] != 0){
                  i = ends[i];
              }
              return i;
          }
      
最短路径问题
  • 概述:求取点集中某个节点到其他每个节点的最短路径

  • Dijkstra算法:单源最短路径算法,以起始点为中心向外层层扩展,直到扩展到终点为止,广度优先搜索。定义已访问点集S存放已计算出最短路径的点,定义未访问点集U存放其他点当前到源点的最短距离,随着U中的点移入S,需更新U中其他点的距离,因为移入的点可能导致其他点的距离变短。先将源点加入S,从U中选出距离其最近的点并移入S,更新S中剩余点的距离,再选更新后距离最短的点,以此类推。核心是根据移入S的点更新U,其他点在经过该点到达目标点时,距离有可能更短。

    for (int j = 0; j < place.length; j++) {
                    if (visit[j] == 0){
                        if (distance[current][j] > 0){ //更新最短路径矩阵,表示在经过当前点到达目标点时,距离有可能更短
                            ranges[j] = Math.min(ranges[j], distance[current][j]+ranges[current]);
                        }
    
                        if (ranges[j] < min){ //选取下一个访问点,要求在未访问点中当前最短路径最小的
                            min = ranges[j];
                            temp = j;
                        }
                    }
                }
                current = temp;
    
  • Floyd算法:创建动态邻接矩阵,每次更新后都表示两点间当前最短距离,可能非直达,思路简单但三层循环复杂度较高,可求出每个点到其他所有点的最短距离。

    for (int k = 0; k < place.length; k++) { //遍历每个点作为中间节点
                for (int i = 0; i < place.length; i++) { //遍历起点
                    for (int j = i; j < place.length; j++) { //遍历终点
                        distance[i][j] = Math.min(distance[i][j], distance[i][k] + distance[k][j]); //更新距离矩阵,经过某个中间点可能使某两点距离更短
                        distance[j][i] = distance[i][j]; //同步改变矩阵对角元素的值
                    }
                }
            }
    
回溯算法(backtracking)
  • 概述:类似枚举的深度优先搜索尝试过程,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。与穷举不同,回溯算法使用剪枝函数,剪去一些不可能到达最终状态的节点,从而减少状态空间树节点的生成。例如走迷宫,从某个初始节点开始选择一条路走到下一个节点,到之后判断该节点不符,则返回到初始节点尝试选择其他节点,若该初始节点下的点都不符合,则回溯到初始节点的上一层进行尝试。

  • 马踏棋盘(Knight’s tour)

    • 要求:马在棋盘上走日字,从起点开始不重复的访问完所有点并回到起点。
    • 思路:在走到某个节点时,先计算出该节点的可选节点集,并根据每个点的可选集规模由小到大进行排序,再去尝试遍历这些点,即优先使用可选路径更少的节点,能有效降低时间复杂度。这里使用贪心算法做优化很关键,不然运行非常慢。但该实现未考虑最后一步回到起点,此时贪心可能无法体现出其高效甚至不如随机。回溯程序比较关键的是判断满足回溯条件时进入循环和不满足时将该选择复选,本例中体现为:不满足while条件即选择该点会无路可走时,不进入循环体中的回溯并清除本次选择,然后会自动跳到上次循环中递归语句的下一步,即开始下次循环,选出下一个可选点。
    //根据当前点位置选择下一步, step为棋盘移动第几步
        public static void choose(Point point, int step) throws Exception {
            chessboard[point.row][point.col] = step; //标记为已访问
            ArrayList<Point> choice = search(point); //获取该点的可选点集
            greedy(choice); //贪心,尽可能走选择方案少的路
    
            while (!choice.isEmpty()){ //递归调用
                Point currentPoint = choice.remove(0);
                if (chessboard[currentPoint.row][currentPoint.col] == 0){
                    choose(currentPoint, step+1);
                }
            }
    
            if (step == Size*Size){ //满足终止条件后手动抛出异常,捕获结束
                throw new Exception("over");
            }
    
            chessboard[point.row][point.col] = 0; //恢复为未访问
        }
    
  • 八皇后问题(Eight queens)

    • 要求:在棋盘每行放一个皇后,若某个棋盘格上放置了一个皇后,那么下一个皇后放置的位置不能出现在它所在的行列以及两条对角线上。

    • 思路:先从第一行开始遍历每一列寻找皇后的可放位置,直到某一行所有列都无法安放。此时会回溯到上一行,恢复其选择状态,继续遍历下一列,该算法巧妙之处在于点的两条对角线的数学表示。一般设计递归方法都会传入当前第几步参数。抛异常可以在得到第一个结果时就停止,否则程序会遍历完所有可能的结果。

      public void putQueen(int row){
              for (int col = 0; col < squares; col++) {
                  if (colume[col] == available &&        //这是个简单的数学推导:左斜对角数组的索引=行+列;右斜对角数组的索引=行-列+尺寸-1
                      leftDiagonal[row+col] == available &&  //这大大简化了置位操作,不用再去遍历整个棋盘,很强
                      rightDiagonal[row-col+norm] == available){
      
                      positionInRow[row] = col; //记录该行的放置位置
                      colume[col] = !available; //更改此以及对角列状态
                      leftDiagonal[row+col] = !available;
                      rightDiagonal[row-col+norm] = !available;
      
                      if (row < squares-1){
                          putQueen(row+1);
                      }else {
                          sum++;
                          System.out.println("第"+sum+"种方案over"); //如果不在第一次得到结果时抛出异常。那么该方法会不断调用压在栈里的方法直到栈空,其实也就遍历完了每种可能
                          Queen.display(positionInRow);
                      }
      
                      //这里用于回溯时恢复状态
                      colume[col] = available;
                      leftDiagonal[row+col] = available;
                      rightDiagonal[row-col+norm] = available;
                  }
              }
          }
      
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值