《漫画算法-小灰的算法之旅》读书笔记

  • Post author:
  • Post category:其他




《漫画算法》读书笔记

其他人的优秀笔记



第一章 算法概述

  1. 算法是用于解决某一类问题的

    公式和思想

    ,例如求解1~10000的和,可以使用等差数列求合公式快速求解
  2. 算法应用:运算(一些求解公式)、查找(二分法等)、排序(十大排序算法)、最优策略(01背包问题,最短路径问题)、面试
  3. 数据结构:线性结构(数组、链表、栈、队列、哈希表(数组加链表))、树(满二叉树、完全二叉树、搜索二叉树/二叉排序树,平衡二叉树/AVL树、二叉堆、红黑树等等)、图(无向图、有向图)、其他数据结构(由基础数据结构变形而来,如跳表、哈希链表、位图)
  4. 衡量算法的好坏有很多标准,最主要的就是

    时间复杂度



    空间复杂度

    • 时间复杂度,可以通过记录

      常数操作执行的次数

      进行理解,常见的时间复杂度:O(1)<O(N)<O(logN)<O(N*logN))<O(N^2)
    • 空间复杂度,算法执行过程中占用的内存资源,包含常量空间、线性空间、二维空间、

      递归空间

      ,常见的空间复杂度:O(1)<O(logN)<O(N)<O(N^2)



第二章 常见线性数据结构



一、数组
  1. 存储方式:由内存中连续存储单元组成,在内存中顺序存储

  2. 基本操作

    • 查找:数组在内存中连续(顺序)存储,所以可以通过数组下标快速定位某个元素(实现随机访问),时间复杂度为O(1)

    • 更新:不考虑节点查找过程,直接进行赋值,时间复杂度O(1)

    • 插入:不考虑节点查找过程,

    • 删除:同插入,可能需要移动元素,因此时间复杂度也为O(N),但是如果不要求删除后原数组有序,可以将数组最后一个值赋值给删除元素的位置实现时间复杂度为O(1)的删除

  3. 数组随机访问速度快(二分查找就是利用数组此优势),但是在插入和删除方面并不快,试用于读操作多,写操作少的场景



二、链表
  1. 存储方式:在内存中随机存储,通过节点中的next指针获取下一个节点(或者通过prev获取当前节点的前一个节点)

  2. 基本操作

    • 查找:链表在内存中随机分散存储,所以无法实现随机访问,只能通过每个节点的指针顺序访问(类似地下党情报传递),时间复杂度为O(N)

    • 更新:直接进行赋值,时间复杂度O(1)

    • 插入:由于可能是尾部插入、中间插入、超范围插入(进行

      扩容-resize

      ),插入过程可能需要移动元素,因此时间复杂度为O(N)

    • 删除:同插入,可能需要移动元素,因此时间复杂度也为O(N),但是如果不要求删除后原数组有序,可以将数组最后一个值赋值给删除元素的位置实现时间复杂度为O(1)的删除

  3. 数组随机访问速度快(二分查找就是利用数组此优势),但是在插入和删除方面并不快,试用于读操作多,写操作少的场景



三、栈和队列
  1. 栈和队列都属于逻辑结构,其物理实现都可以基于数组或基于链表完成

  2. 栈(Stack)

    • 好比一个杯子,先进后出(FILO),最先进去元素存放的位置叫做栈底(bottom),最后进去元素存放的位置叫做栈顶(top)
    • 入栈:不管是基于数组实现的栈还是基于链表实现的,每次都从最后一个位置或者节点插入元素,所以时间复杂度为O(1)
    • 出栈:同理两种方式是实现的栈都是取出最后一个元素,时间复杂度为O(1)
    • 栈的应用:

      • 栈的输出顺序和输入顺序相反, 所以栈通常用于

        对“历史”的回溯

        , 也就是逆流而上追溯“历史”。例如

        实现递归的逻辑,就可以用栈来代替

        ,因为栈可以回溯方法的调用链。
      • 栈还有一个著名的应用场景是面包屑导航, 使用户在浏览页面时可以轻松地回溯到上一级或更上一级页面
  3. 队列(Queue)

    • 好比一条隧道,先进先出(FIFO),队列的出口端叫作队头( front), 队列的入口端叫作队尾( rear)

    • 基于链表的入队出队:比较简单,从尾节点插入元素,从头节点移除元素,时间复杂度为O(1)

    • 基于数组的入队出队:一般情况下入队和出队,可用空间会一直变小,所以每次出栈后就需要移动元素,保证剩余空间都可用,这样的时间复杂度就为O(N);除此之外,可以采用

      循环队列

      的方式来维持队列容量的恒定


      循环队列

      :当新元素入队时,发现尾指针已经越界(超出数组大小),就让队尾指针重新指回数组的首位,这里需要注意的是,当队尾指针和对头指针重合时就代表数组已满,此时如果还要放入进去,就需要考虑进行扩容了

      private int[] array;
      private int front;
      private int rear;
      
      public MyQueue(int capacity) {
          this.array = new int[capacity];
      }
      
      /**
       * 入队
       * 需要注意:队尾指针指向的位置永远空出1位,所以队列最大容量比数组长度小1
       * 
       * @param element 入队的元素
       */
      public void enQueue(int element) throws Exception {
          if ((rear + 1) % array.length == front) {
              throw new Exception(" 队列已满! ");
          }
          array[rear] = element;
          rear = (rear + 1) % array.length;
      }
      
      /**
       * 出队
       */
      public int deQueue() throws Exception {
          if (rear == front) {
              throw new Exception(" 队列已空! ");
          }
          int deQueueElement = array[front];
          front = (front + 1) % array.length;
          return deQueueElement;
      }
      
      /**
       * 输出队列
       */
      public void output() {
          for (int i = front; i != rear; i = (i + 1) % array.length) {
              System.out.println(array[i]);
          }
      }
      
    • 队列的应用:

      • 队列的输出顺序和输入顺序相同, 所以队列通常用于对“历史”的回放, 也就是按照“历史”顺序, 把“历史”重演一遍。
      • 在多线程中,争夺公平锁的等待队列, 就是按照访问顺序来决定线程在队列中的次序的。
      • 常见的消息队列也是基于队列实现的,例如Kafka,每个broker启动后,会为每一个topic默认创建四个消息队列,用来存储消息生产者生产的消息。


四、散列表
  1. 散列表又称哈希表,提供了key和value的映射关系,针对散列表的添删改查时间复杂度都接近于

    O(1)

  2. 哈希函数:用于将key与实现哈希表的数组下标对应,例如取模运算(java中的HashMap中key存放的是一个对象,因为每个对象都有属于自己的整形变量hashcode,所以通过取模运算计算存放的位置:

    index = HashCode (Key) % Array.length

  3. 写操作:通过上述的哈希函数,可以计算出待添加元素的位置,但是可能存在多个对象计算出的结果一样(

    哈希冲突

    解决哈希冲突的方法:

    • 开放寻址法(Java中的

      ThreadLocal

      ):当一个Key通过哈希函数获得对应的数组下标已被占用时, 我们可以“另谋高就”, 寻找下一个空档位置。当然在遇到哈希冲突时, 寻址方式有很多种, 并不一定只是简单地寻找当前元素的后一个元素。
    • 链表法(java中的

      HashMap

      ):每个存放在数组中的元素其实还是一个链表的头节点,当在某一位置出现哈希冲突时,直接插入到当前位置所属链表中去即可。(注意Java中的HashMap处理时,在链表超出一定长度的时候会将链表转化为红黑树进行存储)
  4. 读操作:

    • 第1步通过哈希函数, 把Key转化成数组下标
    • 第2步判断当前节点的key是否与目标key一致,不一致则顺着链表慢慢往下找, 找到与Key相匹配的节点返回即可
  5. 扩容:由于Hash表是基于数组进行实现,当元素源源不断的插入进来时导致发生Hash冲突的概率变得非常高,致使链表过长从而导致散列表的插入和查询检索速度很慢,因此当散列表达到一定饱和时应进行扩容。

    • 在Java的HashMap中,影响扩容的有两个参数:

      Capacity

      (HashMap的容量/当前长度)、

      LoadFactor

      (HashMap的负载因子,0.75f),即当HashMap

      当前HashMap存放元素个数

      大于

      当前长度

      乘以

      负载因子

      时,数组进行扩容

    • 散列表的扩容分为两步:

      • 扩容:创建一个新数组,长度为原来的两倍
      • 重新Hash(rehash):让原本拥挤的散列表重新变得稀疏,java中的Hash只有进行完扩容操作之后方能使用,而Redis中的散列表设计的是渐进式的rehash,效率要比java中的要高



第三章 树



一、树的分类
  1. 满二叉树:除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树,特殊的完全二叉树
  2. 完全二叉树:如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树(如果从上到下从左到右编号,与对应满二叉树一致称为完全二叉树)
  3. 搜索二叉树/二叉查找树/二叉排序树(BST):对于二叉树上面任意一个节点,其所代表的值都大于左子树上任意节点的值(非空条件下),小于右子树上任意节点的值(非空条件下),则此二叉树属于搜索二叉树,并且它的左、右子树也分别为搜索二叉树
  4. 二叉平衡树:平衡二叉搜索树,又被称为

    AVL树

    ,它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树
  5. 二叉堆:前提是一颗完全二叉树

    • 最大堆:父结点的值总是大于或等于任何一个子节点的键值
    • 最小堆:父结点的值总是小于或等于任何一个子节点的键值
  6. 红黑树:是一种自平衡二叉查找树(不是严格意义上的平衡)

    • 每个节点都有红色或黑色
    • 树的根始终是黑色的 (黑土地孕育黑树根)
    • 没有两个相邻的红色节点(红色节点不能有红色父节点或红色子节点,

      并没有说不能出现连续的黑色节点

    • 从节点(包括根)到其任何后代NULL节点(叶子结点下方挂的两个空节点,并且认为他们是黑色的)的每条路径都具有相同数量的黑色节点


二、树的遍历
  1. 深度优先

    • 先序:(头->左->右)

    • 中序:(左->头->右)

    • 后序:(左->右->头)

    • 递归实现

      /**
       * 构建二叉树
       *
       * @param inputList 输入序列
       */
      public static TreeNode createBinaryTree(LinkedList<Integer> inputList) {
          TreeNode node = null;
          if (inputList == null || inputList.isEmpty()) {
              return null;
          }
          Integer data = inputList.removeFirst();
          if (data != null) {
              node = new TreeNode(data);
              node.leftChild = createBinaryTree(inputList);
              node.rightChild = createBinaryTree(inputList);
          }
          return node;
      }
      
      /**
       * 二叉树前序遍历
       *
       * @param node 二叉树节点
       */
      public static void preOrderTraveral(TreeNode node) {
          if (node == null) {
              return;
          }
          System.out.println(node.data);
          preOrderTraveral(node.leftChild);
          preOrderTraveral(node.rightChild);
      }
      
      /**
       * 二叉树中序遍历
       *
       * @param node 二叉树节点
       */
      public static void inOrderTraveral(TreeNode node) {
          if (node == null) {
              return;
          }
          inOrderTraveral(node.leftChild);
          System.out.println(node.data);
          inOrderTraveral(node.rightChild);
      }
      
      
      /**
       * 二叉树后序遍历
       *
       * @param node 二叉树节点
       */
      public static void postOrderTraveral(TreeNode node) {
          if (node == null) {
              return;
          }
          postOrderTraveral(node.leftChild);
          postOrderTraveral(node.rightChild);
          System.out.println(node.data);
      }
      
      /**
       * 二叉树节点
       */
      private static class TreeNode {
          int data;
          TreeNode leftChild;
          TreeNode rightChild;
      
          TreeNode(int data) {
              this.data = data;
          }
      }
      
      public static void main(String[] args) {
          LinkedList<Integer> inputList = new LinkedList<Integer>(Arrays.asList(new Integer[]{3, 2, 9, null, null, 10, null, null, 8, null, 4}));
          TreeNode treeNode = createBinaryTree(inputList);
          System.out.println(" 前序遍历: ");
          preOrderTraveral(treeNode);
          System.out.println(" 中序遍历: ");
          inOrderTraveral(treeNode);
          System.out.println(" 后序遍历: ");
          postOrderTraveral(treeNode);
      }
      
    • 非递归

      借助栈进行实现(栈先进后出,具备回溯的特性)

      /**
       * 二叉树非递归前序遍历
       *
       * @param root 二叉树根节点
       */
      public static void preOrderTraveralWithStack(TreeNode root) {
          Stack<TreeNode> stack = new Stack<TreeNode>();
          TreeNode treeNode = root;
          while (treeNode != null || !stack.isEmpty()) {
              //迭代访问节点的左孩子,并入栈
              while (treeNode != null) {
                  System.out.println(treeNode.data);
                  stack.push(treeNode);
                  treeNode = treeNode.leftChild;
              }
              //如果节点没有左孩子, 则弹出栈顶节点, 访问节点右孩子
              if (!stack.isEmpty()) {
                  treeNode = stack.pop();
                  treeNode = treeNode.rightChild;
              }
          }
      }
      
  2. 广度优先

    • 一层一层进行遍历

    • 代码实现

      借助队列存放为查询子孩子的节点

      /**
       * 二叉树层序遍历
       *
       * @param root 二叉树根节点
       */
      public static void levelOrderTraversal(TreeNode root) {
          Queue<TreeNode> queue = new LinkedList<TreeNode>();
          queue.offer(root);
          while (!queue.isEmpty()) {
              TreeNode node = queue.poll();
              System.out.println(node.data);
              if (node.leftChild != null) {
                  queue.offer(node.leftChild);
              }
              if (node.rightChild != null) {
                  queue.offer(node.rightChild);
              }
          }
      }
      


三、二叉堆

二叉堆本质上是一种完全二叉树, 它分为两个类型:大顶堆、小顶堆

  • 大顶堆:任何一个父节点的值, 都大于或等于它左、 右孩子节点的值
  • 小顶堆:任何一个父节点的值, 都小于或等于它左、 右孩子节点的值

二叉堆主要操作涉及创建(HeapInsert)和调整(Heapify)两部分,其中调整为两步:

  • 上浮:添加元素的时候,插入的是完全二叉树的最后一个位置,需要将添加元素上浮找到其合适的位置
  • 下沉,移除元素的时候,移除堆顶元素,将二叉堆最后一个元素临时补到堆顶,然后下沉找到合适的位置

注意:

  • 二叉堆虽然是一个完全二叉树,但它的存储方式并不是链式存储,而是顺序存储二叉堆的所有节点都存储在数组中

  • 当前节点索引

    i

    ,父节点

    (i-1)/2

    ,左子节点

    2*i+1

    ,右子节点

    2*i+2

/**
 * “上浮”调整
 *
 * @param array 待调整的堆
 */
public static void upAdjust(int[] array) {
    int childIndex = array.length - 1;
    int parentIndex = (childIndex - 1) / 2;
    // temp 保存插入的叶子节点值, 用于最后的赋值
    int temp = array[childIndex];
    while (childIndex > 0 && temp < array[parentIndex]) {
        //无须真正交换, 单向赋值即可
        array[childIndex] = array[parentIndex];
        childIndex = parentIndex;
        parentIndex = (parentIndex - 1) / 2;
    }
    array[childIndex] = temp;
}


/**
 * “下沉”调整
 *
 * @param array       待调整的堆
 * @param parentIndex 要“下沉”的父节点
 * @param length      堆的有效大小
 */
public static void downAdjust(int[] array, int parentIndex,int length) {
    // temp 保存父节点值, 用于最后的赋值
    int temp = array[parentIndex];
    int childIndex = 2 * parentIndex + 1;
    while (childIndex < length) {
        // 如果有右孩子, 且右孩子小于左孩子的值, 则定位到右孩子
        if (childIndex + 1 < length && array[childIndex + 1] <
                array[childIndex]) {
            childIndex++;
        }
        // 如果父节点小于任何一个孩子的值, 则直接跳出
        if (temp <= array[childIndex])
            break;
        //无须真正交换, 单向赋值即可
        array[parentIndex] = array[childIndex];
        parentIndex = childIndex;
        childIndex = 2 * childIndex + 1;
    }
    array[parentIndex] = temp;
}

/**
 * 构建堆
 *
 * @param array 待调整的堆
 */
public static void buildHeap(int[] array) {
    // 从最后一个非叶子节点开始, 依次做“下沉”调整
    for (int i = (array.length - 2) / 2; i >= 0; i--) {
        downAdjust(array, i, array.length);
    }
}

public static void main(String[] args) {
    int[] array = new int[]{1, 3, 2, 6, 5, 7, 8, 9, 10, 0};
    upAdjust(array);
    System.out.println(Arrays.toString(array));

    array = new int[]{7, 1, 3, 10, 5, 2, 8, 9, 6};
    buildHeap(array);
    System.out.println(Arrays.toString(array));
}


四、优先队列

基于上一节的二叉堆进行实现,大顶堆(最大堆)对应最大有先队列

二叉堆节点“ 上浮” 和“ 下沉” 的时间复杂度都是O(logn), 所以优先队列入队和出队的时间复杂度也是O(logn)

private int[] array;
private int size;

public PriorityQueue() {
    //队列初始长度为32
    array = new int[32];
}

/**
 * 入队
 *
 * @param key 入队元素
 */
public void enQueue(int key) {
    //队列长度超出范围, 扩容
    if (size >= array.length) {
        resize();
    }
    array[size++] = key;
    upAdjust();
}

/**
 * 出队
 */
public int deQueue() throws Exception {
    if (size <= 0) {
        throw new Exception("the queue is empty !");
    }
    //获取堆顶元素
    int head = array[0];
    //让最后一个元素移动到堆顶
    array[0] = array[--size];
    downAdjust();
    return head;
}

/**
 * “上浮”调整
 */
private void upAdjust() {
    int childIndex = size - 1;
    int parentIndex = (childIndex - 1) / 2;
    // temp 保存插入的叶子节点值, 用于最后的赋值
    int temp = array[childIndex];
    while (childIndex > 0 && temp > array[parentIndex]) {
        //无须真正交换, 单向赋值即可
        array[childIndex] = array[parentIndex];
        childIndex = parentIndex;
        parentIndex = parentIndex / 2;
    }
    array[childIndex] = temp;
}

/**
 * “下沉”调整
 */
private void downAdjust() {
    // temp 保存父节点的值, 用于最后的赋值
    int parentIndex = 0;
    int temp = array[parentIndex];
    int childIndex = 1;
    while (childIndex < size) {
        // 如果有右孩子, 且右孩子大于左孩子的值, 则定位到右孩子
        if (childIndex + 1 < size && array[childIndex + 1] >
                array[childIndex]) {
            childIndex++;
        }
        // 如果父节点大于任何一个孩子的值, 直接跳出
        if (temp >= array[childIndex])
            break;
        //无须真正交换, 单向赋值即可
        array[parentIndex] = array[childIndex];
        parentIndex = childIndex;
        childIndex = 2 * childIndex + 1;
    }
    array[parentIndex] = temp;
}

/**
 * 队列扩容
 */
private void resize() {
    //队列容量翻倍
    int newSize = this.size * 2;
    this.array = Arrays.copyOf(this.array, newSize);
}

public static void main(String[] args) throws Exception {
    PriorityQueue priorityQueue = new PriorityQueue();
    priorityQueue.enQueue(3);
    priorityQueue.enQueue(5);
    priorityQueue.enQueue(10);
    priorityQueue.enQueue(2);
    priorityQueue.enQueue(7);
    System.out.println(" 出队元素: " + priorityQueue.deQueue());
    System.out.println(" 出队元素: " + priorityQueue.deQueue());
}



第四章 排序算法

具体排序算法参考另一篇,看左神算法的笔记



一、冒泡排序

数组中有n个元素

在数组上0~n-1上从左到右两两比较,谁大谁往右移动,一次下来最大数就排好序

在数组上0~n-2上从左到右两两比较,谁大谁往右移动,一次下来倒数最大数就排好序

  • 时间复杂度:O(N^2)
  • 空间复杂度:O(1)

冒泡的优化:

  • 判断一轮比较下来有没有交换数据,没有交换则证明该数组已经有序

  • 真实已经有许多元素有序,浪费了许多无意义比较:通过引入一个变量记录最后交换元素的位置,下一次再进行比较时就比较到该变量的位置即可

    public static void sort(int array[]) {
        //记录最后一次交换的位置
        int lastExchangeIndex = 0;
        //无序数列的边界, 每次比较只需要比到这里为止
        int sortBorder = array.length - 1;
        for (int i = 0; i < array.length - 1; i++) {
            //有序标记, 每一轮的初始值都是true
            boolean isSorted = true;
            for (int j = 0; j < sortBorder; j++) {
                int tmp = 0;
                if (array[j] > array[j + 1]) {
                    tmp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = tmp;
                    // 因为有元素进行交换, 所以不是有序的, 标记变为false
                    isSorted = false;
                    // 更新为最后一次交换元素的位置
                    lastExchangeIndex = j;
                }
            }
            sortBorder = lastExchangeIndex;
            if (isSorted) {
                break;
            }
        }
    }
    
    public static void main(String[] args) {
        int[] array = new int[]{3, 4, 2, 1, 5, 6, 7, 8};
        sort(array);
        System.out.println(Arrays.toString(array));
    }
    
  • 鸡尾酒排序

    排序过程就像钟摆一样, 第1轮从左到右(最大值放到最右),第2轮从右到左(最小值放到最左),第3轮再从左到右……

    • 优点:能够在大部分元素已经有序的情况下,减少排序的回合数;

    • 缺点:很明显,就是代码量几乎增加了1倍

    /**
    * @ClassName: Test2
    * @Description:
    * @Author: liu-hao
    * @Date: 2021-07-03 23:37
    * @Version: 1.0
    **/
    public class Test2 {
    public static void sort(int array[]) {
        int tmp = 0;
        for (int i = 0; i < array.length / 2; i++) {
            //有序标记, 每一轮的初始值都是true
            boolean isSorted = true;
            //奇数轮, 从左向右比较和交换
            for (int j = i; j < array.length - i - 1; j++) {
                if (array[j] > array[j + 1]) {
                    tmp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = tmp;
                    // 有元素交换, 所以不是有序的, 标记变为false
                    isSorted = false;
                }
            }
            if (isSorted) {
                break;
            }
            // 在偶数轮之前, 将isSorted重新标记为true
            isSorted = true;
            //偶数轮, 从右向左比较和交换
            for (int j = array.length - i - 1; j > i; j--) {
                if (array[j] < array[j - 1]) {
                    tmp = array[j];
                    array[j] = array[j - 1];
                    array[j - 1] = tmp;
                    // 因为有元素进行交换, 所以不是有序的, 标记变为false
                    isSorted = false;
                }
            }
            if (isSorted) {
                break;
            }
        }
    }
    
    public static void main(String[] args) {
        int[] array = new int[]{2, 3, 4, 5, 6, 7, 8, 1};
        sort(array);
        System.out.println(Arrays.toString(array));
    }
    


二、快速排序

随机选择指定元素对数组进行划分,分为大于、等于、小于三部分,然后利用递归思想,将大于和小于部分继续划分,从而让整体有序

  • 时间复杂度:O(N*logN)
  • 空间复杂度:O(logN)


三、堆排序

利用二叉堆的数据结构

首先将数组转化为大根堆(小根堆)—HeapInsert操作,然后移除堆顶元素放入到已排序数组中,继续调整为大根堆—Heapify操作,如此往复依此取出堆顶元素加到已排序数组中得到最终的排序结果(真实情况下,不需要创建排序数组,只需在调整的时候忽略交换的节点即可)

  • 时间复杂度:O(N*logN)
  • 空间发杂度:O(1)


四、计数排序和桶排序

计数排序:创建一个包含所有情况的一个数组,遍历给定的整个待排序数组,在创建数组中找到其对应的位置,并且对其进行计数;等待数组遍历结束,将创建的数组从左到右遍历(不为空的进行输出),得到排序结果

桶排序:首先找出数组中最大的那个数,并判断该数一共有几位,将不足此位数的其他元素,前面补0;准备十个桶,依此代表0~9;然后从将数组中的元素依此遍历,按照个位数字依此进桶,让后按顺序出桶(同一个桶里,先进的先出);重复上述操作,依此比较十位、百位…直到最高位;最后出桶结束,数组就已经排好序了

时间复杂度:O(N)



第五章 面试中的算法



一、如何判断一个链表是否有环
  1. 时间复杂度O(N^2),无额外空间复杂度:每遍历一个到一个元素,就再从头遍历到当前位置看是否存在相同的值,有则证明该链表有环

  2. 时间复杂度为O(N),额外空间复杂度O(n):使用Hash表,用Hashset记录已遍历过的元素,每遍历一个元素和判断是否再hash表中存在,如果存在证明该链表有环

  3. 时间复杂度为O(N),额外空间复杂度O(1):使用

    快慢指针

    ,创建两个指针,快指针一次走两步,慢指针一次走一步,当快指针和慢指针所指位置相同证明链表有环

    /**
     * 链表节点
     */
    private static class Node {
        int data;
        Node next;
    
        Node(int data) {
            this.data = data;
        }
    }
    
    /**
     * 判断是否有环
     *
     * @param head 链表头节点
     */
    public static boolean isCycle(Node head) {
        Node p1 = head;
        Node p2 = head;
        while (p2 != null && p2.next != null) {
            p1 = p1.next;
            p2 = p2.next.next;
            if (p1 == p2) {
                return true;
            }
        }
        return false;
    }
    
  4. 扩展问题

    • 如果链表有环, 如何求出环的长度?

      解法:确定有环后,让快慢指针继续走,并记录走过的步数,当再一次相遇,快指针走过的步数-慢指针走过的步数就为环的长度

    • 如果链表有环, 如何求出入环节点?

      解法:将链表长度分为三个部分:头节点到入环节点的距离d、入环节点到首次相遇节点的距离s1、相遇节点到入环节点的距离s2,两指针相遇时,快指针走了

      d+n(s1+s2)+s1

      ,慢指针走了:

      d+s1

      ,又因为快指针是慢指针的两倍,

      2(d+s1) = d+n(s1+s2)+s1

      ,所以根据可以计算出:

      d = (n-1)*(s1+s2)+s2

      ,转换成具体的描述就是:

      只要把其中一个指针放回到头节点位置, 另一个指针保持在首次相遇点, 两个指针都是每次向前走1步。 那么, 它们最终相遇的节点, 就是入环节点



二、最小栈的实现

实现一个栈, 该栈带有出栈( pop) 、 入栈( push) 、 取最小元素( getMin) 3个方法。 要保证这3个方法的时间复杂度都是O(1)

  1. 直接对jdk中自带栈进行封装,使用两个栈,一个用于记录进栈元素,另一个记录每一次进栈原栈中的最小值

    栈1:[4,5,6,7,3,9,1]

    栈2:[4,3,1]

    private Stack<Integer> mainStack = new Stack<Integer>();
    private Stack<Integer> minStack = new Stack<Integer>();
    
    /**
     * 入栈操作
     *
     * @param element 入栈的元素
     */
    public void push(int element) {
        mainStack.push(element);
    //如果辅助栈为空, 或者新元素小于或等于辅助栈栈顶, 则将新元素压入辅助栈
        if (minStack.empty() || element <= minStack.peek()) {
            minStack.push(element);
        }
    }
    
    /**
     * 出栈操作
     */
    public Integer pop() {
    //如果出栈元素和辅助栈栈顶元素值相等, 辅助栈出栈
        if (mainStack.peek().equals(minStack.peek())) {
            minStack.pop();
        }
        return mainStack.pop();
    }
    
    /**
     * 获取栈的最小元素
     */
    public int getMin() throws Exception {
        if (mainStack.empty()) {
            throw new Exception("stack is empty");
        }
    
        return minStack.peek();
    }
    


三、最大公约数
  1. 暴力枚举法:

    如果大数可以整除小数,则大数就为最大公约数

    如果不能整除则从较小整数的一半开始,试图找到一个合适的整数i,看看这个整数能否被a和b同时整除

    public static int getGreatestCommonDivisor(int a, int b) {
        int big = a > b ? a : b;
        int small = a < b ? a : b;
        if (big % small == 0) {
            return small;
        }
        for (int i = small / 2; i > 1; i--) {
            if (small % i == 0 && big % i == 0) {
                return i;
            }
        }
        return 1;
    }
    
  2. 辗转相除法

    辗转相除法, 又名欧几里得算法( Euclidean algorithm),两个正整数a和b( a>b) , 它们的最大公约数等于a除以b的余数c和b之间的最大公约数

    但是当两个整数较大时, 做a%b取模运算的性能会比较差

    public static int getGreatestCommonDivisorV2(int a, int b) {
        int big = a > b ? a : b;
        int small = a < b ? a : b;
        if (big % small == 0) {
            return small;
        }
        return getGreatestCommonDivisorV2(big % small, small);
    }
    
  3. 更相减损术

    两个正整数a和b( a>b) , 它们的最大公约数等于a-b的差值c和较小数b的最大公约数。 例如10和25, 25减10的差是15, 那么10和25的最大公约数, 等同于10和15的最大公约数

    此算法避免了大整数取模可能出现的性能问题 ,但是该算法并不稳定,当两数相差悬殊时,递归次数就非常多

    public static int getGreatestCommonDivisorV3(int a, int b) {
        if (a == b) {
            return a;
        }
    }
    
  4. 更相减损术与移位相结合:

    对于给出的正整数a和b,设获得最大公约数的方法getGreatestCommonDivisor被简写为gcd

    • 当a和b均为偶数时,

      gcd(a,b) = 2× gcd(a/2, b/2) = 2× gcd(a>>1,b>>1)
    • 当a为偶数, b为奇数时,

      gcd(a,b) = gcd(a/2,b) = gcd(a>>1,b)
    • 当a为奇数, b为偶数时,

      gcd(a,b) = gcd(a,b/2) = gcd(a,b>>1)
    • 当a和b均为奇数时, 先利用更相减损术运算一次,

      gcd(a,b) = gcd(b,a-b)

      , 此时a-b必然是偶数, 然后又可以继续进行移位运算。

    优点:既避免大整数取模, 又能尽可能地减少运算次数

    public static int gcd(int a, int b) {
        if (a == b) {
            return a;
        }
        if ((a & 1) == 0 && (b & 1) == 0) { // a、b均为偶数
            return gcd(a >> 1, b >> 1) << 1;
        } else if ((a & 1) == 0 && (b & 1) != 0) { // a偶数、b奇数
            return gcd(a >> 1, b);
        } else if ((a & 1) != 0 && (b & 1) == 0) { // a奇数、b偶数
            return gcd(a, b >> 1);
        } else { // a、b均为奇数(使用更相减损术)
            int big = a > b ? a : b;
            int small = a < b ? a : b;
            return gcd(big - small, small);
        }
    }
    


四、如何判断一个数是否为2的整数次幂

实现一个方法, 来判断一个正整数是否是2的整数次幂( 如16是2的4次方, 返回true; 18不是2的整数次幂, 则返回false) 。 要求性能尽可能高。

  1. 暴力进行计算,即从1开始一直乘2与目标数做比较,相等就是,超出目标数就算不是,时间复杂度为O(logN)

  2. 使用位运算:2的整数幂代表的数二进制特点是第一位是1剩余都为0,而其减1后的二进制特点是都为1,即可进行与运算,结果为0则证明其为2的整数次幂,时间复杂度为O(1)

    public static boolean isPowerOf2(int num) {
        return (num & num - 1) == 0;
    }
    


五、如何用栈实现队列

通过两个栈实现队列

  • 其中一个队列进行接受入队列元素,当出队的时候使用第二个栈

  • 将第一个栈的元素依次出栈,并入栈到第二个栈中(此时与第一次入栈时的顺序相反),然后进行出栈,也就对应队列的出队

  • 当有元素再次入队时,依然入栈到第一个栈

  • 当需要再次出队时,首先判断第二个栈中是否有元素,有的话直接出栈,没有则同上面操作将第一个栈依此出栈加入第二个栈中,再进行出栈

private Stack<Integer> stackA = new Stack<Integer>();
private Stack<Integer> stackB = new Stack<Integer>();

/**
 * 入队操作
 *
 * @param element 入队的元素
 */
public void enQueue(int element) {
    stackA.push(element);
}

/**
 * 出队操作
 */
public Integer deQueue() {
    if (stackB.isEmpty()) {
        if (stackA.isEmpty()) {
            return null;
        }
        transfer();
    }
    return stackB.pop();
}

/**
 * 栈A元素转移到栈B
 */
private void transfer() {
    while (!stackA.isEmpty()) {
        stackB.push(stackA.pop());
    }
}


六、寻找全排列的下一个数

给出一个正整数, 找出这个正整数所有数字全排列的下一个数

说通俗点就是在一个整数所包含数字的全部组合中, 找到一个大于且仅大于原数的新整数,例如:输入12345, 则返回12354,输入12354, 则返回12435

解法:由于在数字逆序的时候最大,顺序的时候最小,所以:

  • 从后向前查看逆序区域, 找到逆序区域的前一位, 也就是数字置换的边界。
  • 让逆序区域的前一位和逆序区域中大于它的最小的数字交换位置。
  • 把原来的逆序区域转为顺序状态 。
public static int[] findNearestNumber(int[] numbers) {
    //1. 从后向前查看逆序区域,找到逆序区域的前一位,也就是数字置换的边界
    int index = findTransferPoint(numbers);
    // 如果数字置换边界是0,说明整个数组已经逆序, 无法得到更大的相同数
    // 字组成的整数, 返回null
    if (index == 0) {
        return null;
    }
    //2.把逆序区域的前一位和逆序区域中刚刚大于它的数字交换位置
    //复制并入参,避免直接修改入参
    int[] numbersCopy = Arrays.copyOf(numbers, numbers.length);
    exchangeHead(numbersCopy, index);
    //3.把原来的逆序区域转为顺序
    reverse(numbersCopy, index);
    return numbersCopy;
}

// 数组从后向前查看逆序区域,找到逆序区域的前一位
private static int findTransferPoint(int[] numbers) {
    for (int i = numbers.length - 1; i > 0; i--) {
        if (numbers[i] > numbers[i - 1]) {
            return i;
        }
    }
    return 0;
}

// 把逆序区域的前一位和逆序区域中刚刚大于它的数字交换位置
private static int[] exchangeHead(int[] numbers, int index) {
    int head = numbers[index - 1];
    for (int i = numbers.length - 1; i > 0; i--) {
        if (head < numbers[i]) {
            numbers[index - 1] = numbers[i];
            numbers[i] = head;
            break;
        }
    }
    return numbers;
}

// 把原来的逆序区域转为顺序
private static int[] reverse(int[] num, int index) {
    for (int i = index, j = num.length - 1; i < j; i++, j--) {
        int temp = num[i];
        num[i] = num[j];
        num[j] = temp;
    }
    return num;
}

public static void main(String[] args) {
    int[] numbers = {1, 2, 3, 4, 5};
    //打印12345 之后的10个全排列整数
    for (int i = 0; i < 10; i++) {
        numbers = findNearestNumber(numbers);
        outputNumbers(numbers);
    }
}


七、删去k个数字后的最小值

给出一个整数, 从该整数中去掉k个数字, 要求剩下的数字形成的新整数尽可能小。

解法:

  • 从前向后寻找后一个数比当前数小的情况(出现逆序),例如:1243456找到4发现后一个的数字3比当前数小,停止去掉当前的4则为去除一个数字的最优解;当整个整数数字都是顺序的时候去掉最大的那个数也就是最后的那个数为最优解
  • 经历上述步骤k次就可以求出整体的最优解

像这样依次求得局部最优解, 最终得到全局最优解的思想, 叫作

贪心算法

/**
 * 删除整数的k个数字, 获得删除后的最小值
 *
 * @param num 原整数
 * @param k   删除数量
 */
public static String removeKDigits(String num, int k) {
    //新整数的最终长度 = 原整数长度-k
    int newLength = num.length() - k;
    //创建一个栈, 用于接收所有的数字
    char[] stack = new char[num.length()];
    int top = 0;
    for (int i = 0; i < num.length(); ++i) {
        //遍历当前数字
        char c = num.charAt(i);
        //当栈顶数字大于遍历到的当前数字时, 栈顶数字出栈( 相当于删除数字)
        while (top > 0 && stack[top - 1] > c && k > 0) {
            top -= 1;
            k -= 1;
        }
        //遍历到的当前数字入栈
        stack[top++] = c;
    }
    // 找到栈中第1个非零数字的位置, 以此构建新的整数字符串
    int offset = 0;
    while (offset < newLength && stack[offset] == '0') {
        offset++;
    }
    // 当整体都保持升序的时候,通过截取指定范围数等同于删除最大的数的操作
    return offset == newLength ? "0" : new String(stack, offset, newLength - offset);
}


public static void main(String[] args) {
    System.out.println(removeKDigits("123456", 3));
    System.out.println(removeKDigits("30200", 1));
    System.out.println(removeKDigits("10", 2));
    System.out.println(removeKDigits("541270936", 3));
}


八、如何实现大整数相加

注意:大整数可能会超过Long所能表达的方位,因此需要用字符串或者数组表示大整数‘’

解法1:时间复杂度和空间复杂度都为O(N)

  • 为两个数组创建两个数组(已较大整数的长度+1为数组长度),并倒序表示,高位不够补零
  • 按位依此相加,进位加到下一位上,完成两整数的相加

解法2:

  • 同上一样为两整数创建数组,但是并不是将每个数字都存放到一个数组元素当中,而实将原始大整数拆分到可以计算的长度即可;例如int最多可以表示9位数(不溢出情况下),将可以将50位的大整数按照int进行划分,变成6部分存储到六个数组元素当中,如此一来,内存占用空间和运算次数, 都压缩到了原来的1/9
  • 同上按位相加进行相加,又进位加到下一位即可

在Java中,工具类

BigInteger



BigDecimal

的底层实现同样是把大整数拆分成数组进行运算的,和方法二的思路大体类似

  • 解法1实现

    /**
     * 大整数求和
     *
     * @param bigNumberA 大整数A
     * @param bigNumberB 大整数B
     */
    public static String bigNumberSum(String bigNumberA, String bigNumberB) {
        //1.把两个大整数用数组逆序存储, 数组长度等于较大整数位数+1
        int maxLength = bigNumberA.length() > bigNumberB.length() ? bigNumberA.length() : bigNumberB.length();
        int[] arrayA = new int[maxLength + 1];
        for (int i = 0; i < bigNumberA.length(); i++) {
            arrayA[i] = bigNumberA.charAt(bigNumberA.length() - 1 - i) - '0';
        }
        int[] arrayB = new int[maxLength + 1];
        for (int i = 0; i < bigNumberB.length(); i++) {
            arrayB[i] = bigNumberB.charAt(bigNumberB.length() - 1 - i) - '0';
        }
        //2.构建result数组, 数组长度等于较大整数位数+1
        int[] result = new int[maxLength + 1];
        //3.遍历数组, 按位相加
        for (int i = 0; i < result.length; i++) {
            int temp = result[i];
            temp += arrayA[i];
            temp += arrayB[i];
            //判断是否进位
            if (temp >= 10) {
                temp = temp - 10;
                result[i + 1] = 1;
            }
            result[i] = temp;
        }
        //4.把result数组再次逆序并转成String
        StringBuilder sb = new StringBuilder();
        //是否找到大整数的最高有效位
        boolean findFirst = false;
        for (int i = result.length - 1; i >= 0; i--) {
            if (!findFirst) {
                if (result[i] == 0) {
                    continue;
                }
                findFirst = true;
            }
            sb.append(result[i]);
        }
        return sb.toString();
    }
    
    public static void main(String[] args) {
        System.out.println(bigNumberSum("426709752318", "95481253129"));
    }
    


九、如何求解金矿问题

考察动态规划思想

问题:有一位国王拥有5座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人人数也不同。例如10名工人,金矿和和所需人数情况:200-3,300-4,350-3,400-5,500-5;求解想得到尽可能多的黄金,应该选择挖取哪几座金矿?

类似问题:01背包问题

解法:

  • 可以将问题划分为:10选4矿的最优选择(不挖350矿),以及7人选4矿+350两个子结构(必挖350矿)

  • 同样上述子结构可继续划分更小的子结构,就这样,问题一分为二,二分为四,一直把问题简化成在0个金矿或0个工人时的最优选择,这个收益结果显然是0,也就是问题的边界。

    我们把金矿数量设为n,工人数量设为w, 金矿的含金量设为数组g[], 金矿所需开采人数设为数组p[],设F(n, w)为n个金矿、 w个工人时的最优收益函数, 那么状态转移方程式如下:


    • F(n,w) = 0 (n=0或w=0)

      问题边界, 金矿数为0或工人数为0的情况

    • F(n,w) = F(n-1,w) (n≥1, w<p[n-1])

      当所剩工人不够挖掘当前金矿时,只有一种最优子结构

    • F(n,w) = max(F(n-1,w), F(n-1,w-p[n-1])+g[n-1]) (n≥1, w≥p[n-1])

      在常规情况下, 具有两种最优子结构( 挖当前金矿或不挖当前金矿)
  • 递归实现

    缺点:递归做了许多重复的计算,随着金矿数量的增大,时间复杂度指数上升

    /**
     * 获得金矿最优收益
     *
     * @param w 工人数量
     * @param n 可选金矿数量
     * @param p 金矿开采所需的工人数量
     * @param g 金矿储量
     */
    public static int getBestGoldMining(int w, int n, int[] p, int[] g) {
        if (w == 0 || n == 0) {
            return 0;
        }
        if (w < p[n - 1]) {
            return getBestGoldMining(w, n - 1, p, g);
        }
        return Math.max(getBestGoldMining(w, n - 1, p, g), getBestGoldMining(w - p[n - 1], n - 1, p, g) + g[n - 1]);
    }
    
    public static void main(String[] args) {
        int w = 10;
        int[] p = {5, 5, 3, 4, 3};
        int[] g = {400, 500, 200, 300, 350};
        System.out.println(" 最优收益: " + getBestGoldMining(w, g.length, p, g));
    }
    
  • 二维数组实现

    通过数组记录已经计算好的值,避免了递归重复计算的时间复杂度,但是空间复杂度还可以进行优化

    /**
     * 获得金矿最优收益
     *
     * @param w 工人数量
     * @param p 金矿开采所需的工人数量
     * @param g 金矿储量
     */
    public static int getBestGoldMiningV2(int w, int[] p, int[] g) {
        //创建表格
        int[][] resultTable = new int[g.length + 1][w + 1];
        //填充表格
        for (int i = 1; i <= g.length; i++) {
            for (int j = 1; j <= w; j++) {
                if (j < p[i - 1]) {
                    resultTable[i][j] = resultTable[i - 1][j];
                } else {
                    resultTable[i][j] = Math.max(resultTable[i - 1][j], resultTable[i - 1][j - p[i - 1]] + g[i - 1]);
                }
            }
        }
        //返回最后1个格子的值
        return resultTable[g.length][w];
    }
    
  • 一维数组实现

    因为在表格中除第1行之外,每一行的结果都是由上一行数据推导出来的,因此可以使用一个一维数组来记录最优收益(但是如果需要回溯寻找选择了那几个金矿时此方法就不再试用)

    /**
     * 获得金矿最优收益
     *
     * @param w 工人数量
     * @param p 金矿开采所需的工人数量
     * @param g 金矿储量
     */
    public static int getBestGoldMiningV3(int w, int[] p, int[] g) {
        //创建当前结果
        int[] results = new int[w + 1];
        //填充一维数组
        for (int i = 1; i <= g.length; i++) {
            for (int j = w; j >= 1; j--) {
                if (j >= p[i - 1]) {
                    results[j] = Math.max(results[j], results[j - p[i - 1]] + g[i - 1]);
                }
            }
        }
        //返回最后1个格子的值
        return results[w];
    }
    


十、寻找缺失的整数

考察知识点:异或运算

问题一:在一个无序数组里有99个不重复的正整数, 范围是1~100, 唯独缺少1个1~100中的整数。 如何找出这个缺失的整数?

  • 最优方法,求解1~100的和并依此减去无序数组的值,最终得到的就是缺失的整数

问题二:一个无序数组里有若干个正整数, 范围是1~100, 其中99个整数都出现了偶数次, 只有1个整数出现了奇数次, 如何找到这个出现奇数次的整数?

  • 解法:用0依此异或数组中的每一个元素,最终的结果就为出现奇数次的那个整数

问题三:假设一个无序数组里有若干个正整数, 范围是1~100, 其中有98个整数出现了偶数次, 只有2个整数出现了奇数次, 如何找到这2个出现奇数次的整数?

  • 解法:用0依此异或数组中的每一个元素,得到两个出现奇数次整数异或的结果,然后利用此结果求出一个对应某二进制为1的数,并用此数与数组中元素进行与操作,将数组划分为两份,选择其中的一份用0依此异或得到一个整数的值,然后再异或第一步结果,得到另一个出现奇数次的数(或者两部分分别异或获取到两个出现奇数次的整数)
  • 问题三解法实现:

    public static int[] findLostNum(int[] array) {
        //用于存储2个出现奇数次的整数
        int result[] = new int[2];
        //第1次进行整体异或运算
        int xorResult = 0;
        for (int i = 0; i < array.length; i++) {
            xorResult ^= array[i];
        }
        //如果进行异或运算的结果为0,则说明输入的数组不符合题目要求
        if (xorResult == 0) {
            return null;
        }
        //确定2个整数的不同位,以此来做分组
        int separator = 1;
        while (0 == (xorResult & separator)) {
            separator <<= 1;
        }
        //第2次分组进行异或运算
        for (int i = 0; i < array.length; i++) {
            if (0 == (array[i] & separator)) {
                result[0] ^= array[i];
            } else {
                result[1] ^= array[i];
            }
        }
    
        return result;
    }
    
    public static void main(String[] args) {
        int[] array = {4, 1, 2, 2, 5, 1, 4, 3};
        int[] result = findLostNum(array);
        System.out.println(result[0] + "," + result[1]);
    }
    



第六章 算法实际应用



一、位图BitMap


使用场景

  • 用户周活跃
  • 统计活跃用户
  • 用户在线状态
  • 用户签到
  • 代码实现

    注意:

    • 通过Long类型进行存储,一个Long类型存储64位,所以位图初始化大小是多少,对应的就需要(size/64+1)个Long类型的元素


    • 1L << bitIndex

      ,

      Long

      类型是64位的,当左移超过64则又重新开始,例如:“1L << 65 = 2,1L << 66 = 4`

    class MyBitmap {
        // 每一个word是一个long类型元素, 对应一个64位二进制数据
        private long[] words;
        //Bitmap的位数大小
        private int size;
    
        public MyBitmap(int size) {
            this.size = size;
            this.words = new long[(getWordIndex(size - 1) + 1)];
        }
    
        /**
         * 判断Bitmap某一位的状态
         *
         * @param bitIndex 位图的第bitIndex位
         */
        public boolean getBit(int bitIndex) {
            if (bitIndex < 0 || bitIndex > size - 1) {
                throw new IndexOutOfBoundsException(" 超过Bitmap有效范围");
            }
            int wordIndex = getWordIndex(bitIndex);
            System.out.println("-----------" + words[wordIndex]);
            System.out.println("-----------" + (1L << bitIndex));
            return (words[wordIndex] & (1L << bitIndex)) != 0;
        }
    
        /**
         * 把Bitmap某一位设置为true
         *
         * @param bitIndex 位图的第bitIndex位
         */
        public void setBit(int bitIndex) {
            if (bitIndex < 0 || bitIndex > size - 1) {
                throw new IndexOutOfBoundsException(" 超过Bitmap有效范围");
            }
            int wordIndex = getWordIndex(bitIndex);
            System.out.println("+++++++++++++" + words[wordIndex]);
            System.out.println("+++++++++++++" + (1L << bitIndex));
            words[wordIndex] |= (1L << bitIndex);
        }
    
        /**
         * 定位Bitmap某一位所对应的word
         *
         * @param bitIndex 位图的第bitIndex位
         */
        private int getWordIndex(int bitIndex) {
            //右移6位, 相当于除以64
            return bitIndex >> 6;
        }
    
        public static void main(String[] args) {
            MyBitmap bitMap = new MyBitmap(128);
            bitMap.setBit(126);
            bitMap.setBit(75);
            System.out.println(bitMap.getBit(126));
            System.out.println(bitMap.getBit(78));
        }
    }
    


二、LRU算法


LRU

:全称Least Recently Used,最近最少使用算法,是一种内存管理算 法,该算法最早应用于Linux操作系统 ;这个算法基于一种假设:长期不被使用的数据,在未来被用到的几率也不大。因此,当数据所占内存达到一定阈值时, 我们要移除掉最近最少被使用的数据。

  • 在LRU算法中, 使用了一种有趣的数据结构, 这种数据结构叫作

    哈希链表

    ,它形式上和哈希表一样由若干个Key-Value键值对组成 ,但是不一样的是哈希链表中Value存储的是双向链表中的某一节点,即Value存储的是一个Node,通过该Node可以找到他的前驱以及后继节点

  • 基于上述的数据结构,我们可以根据Hash链表的有序性对存储的数据按照最后使用的时间进行排序,当数据到达一定量时就需要将最最近最少访问的数据进行删除,添加新的元素进来

  • Java中的LinkedHashMap已经对哈希链表做了很好的实现

  • LRU算法的实现

    class Node {
        Node(String key, String value) {
            this.key = key;
            this.value = value;
        }
    
        public Node pre;
        public Node next;
        public String key;
        public String value;
    }
    
    private Node head; // 头节点
    private Node end; // 尾节点
    private int limit; // 缓存存储上限
    private HashMap<String, Node> hashMap; // Hash链表
    
    public LRUCache(int limit) {
        this.limit = limit;
        hashMap = new HashMap<String, Node>();
    }
    
    // 获取
    public String get(String key) {
        Node node = hashMap.get(key);
        if (node == null) {
            return null;
        }
        refreshNode(node); // 更新
        return node.value;
    }
    
    // 添加
    public void put(String key, String value) {
        Node node = hashMap.get(key);
        if (node == null) {
            //如果Key 不存在, 则插入Key-Value
            if (hashMap.size() >= limit) {
                String oldKey = removeNode(head); // 获取最近最少使用的那个元素的Key
                hashMap.remove(oldKey);
            }
            node = new Node(key, value);
            addNode(node);
            hashMap.put(key, node);
        } else {
            //如果Key 存在, 则刷新Key-Value
            node.value = value;
            refreshNode(node);
        }
    }
    
    // 删除
    public void remove(String key) {
        Node node = hashMap.get(key);
        removeNode(node);
        hashMap.remove(key);
    }
    
    /**
     * 刷新被访问的节点位置
     *
     * @param node 被访问的节点
     */
    private void refreshNode(Node node) {
        //如果访问的是尾节点, 则无须移动节点
        if (node == end) {
            return;
        }
        //移除节点
        removeNode(node);
        //重新插入节点
        addNode(node);
    }
    
    /**
     * 删除节点
     *
     * @param node 要删除的节点
     */
    private String removeNode(Node node) {
        if (node == head && node == end) {
            //移除唯一的节点
            head = null;
            end = null;
        } else if (node == end) {
            //移除尾节点
            end = end.pre;
            end.next = null;
        } else if (node == head) {
            //移除头节点
            head = head.next;
            head.pre = null;
        } else {
            //移除中间节点
            node.pre.next = node.next;
            node.next.pre = node.pre;
        }
        return node.key;
    }
    
    /**
     * 尾部插入节点
     *
     * @param node 要插入的节点
     */
    private void addNode(Node node) {
        if (end != null) {
            end.next = node;
            node.pre = end;
            node.next = null;
        }
        end = node;
        if (head == null) {
            head = node;
        }
    }
    
    public static void main(String[] args) {
        LRUCache lruCache = new LRUCache(5);
        lruCache.put("001", " 用户1信息");
        lruCache.put("002", " 用户1信息");
        lruCache.put("003", " 用户1信息");
        lruCache.put("004", " 用户1信息");
        lruCache.put("005", " 用户1信息");
        lruCache.get("002");
        lruCache.put("004", " 用户2信息更新");
        lruCache.put("006", " 用户6信息");
        System.out.println(lruCache.get("001"));
        System.out.println(lruCache.get("006"));
    }
    


三、A星寻路算法

在一个n*n的方格中,给定起点和终点以及部分障碍物,求起点到重终点的最优路径

实现流程:

  • 计算当前所属位置的临近位置(除去已标记过、障碍物等位置)到起点和终点的距离(不考虑障碍物)用三个字母代替:

    • G: 从起点走到当前格子的距离
    • H: 在不考虑障碍的情况下,从当前格子走到目标格子的距离
    • F: G和H的综合评估,也就是从起点到达当前格子,再从当前格子到达目标格子的总步数
  • 设置当前节点为这些临近节点的父节点,将邻近位置的节点都放入到

    OpenList

    (可到达的盒子)里面,并从中找出一个F值最小的节点,移除OpenList放入

    CloseList

    (已到达的盒子)然后此节点为基础继续寻找临近节点放入OpenList中…直到临近节点中出现终点或遍历结束所有节点为止
  • 代码实现

    // 迷宫地图
    public static final int[][] MAZE = {
            {0, 0, 0, 0, 0, 0, 0},
            {0, 0, 0, 1, 0, 0, 0},
            {0, 0, 0, 1, 0, 0, 0},
            {0, 0, 0, 1, 0, 0, 0},
            {0, 0, 0, 0, 0, 0, 0}
    };
    
    /**
     * A*寻路主逻辑
     *
     * @param start 迷宫起点
     * @param end   迷宫终点
     */
    public static Grid aStarSearch(Grid start, Grid end) {
        ArrayList<Grid> openList = new ArrayList<Grid>();
        ArrayList<Grid> closeList = new ArrayList<Grid>();
        //把起点加入 openList
        openList.add(start);
        //主循环, 每一轮检查1个当前方格节点
        while (openList.size() > 0) {
            // 在openList中查找 F值最小的节点, 将其作为当前方格节点
            Grid currentGrid = findMinGird(openList);
            // 将当前方格节点从openList中移除
            openList.remove(currentGrid);
            // 当前方格节点进入 closeList
            closeList.add(currentGrid);
            // 找到所有邻近节点
            List<Grid> neighbors = findNeighbors(currentGrid, openList, closeList);
            for (Grid grid : neighbors) {
                if (!openList.contains(grid)) {
                    //邻近节点不在openList 中, 标记“父节点”、 G、 H、 F, 并放入openList
                    grid.initGrid(currentGrid, end);
                    openList.add(grid);
                }
            }
            //如果终点在openList中, 直接返回终点格子
            for (Grid grid : openList) {
                if ((grid.x == end.x) && (grid.y == end.y)) {
                    return grid;
                }
            }
        }
        //openList用尽, 仍然找不到终点, 说明终点不可到达, 返回空
        return null;
    }
    
    // 寻找可达集合里面F值最小的节点、位置
    private static Grid findMinGird(ArrayList<Grid> openList) {
        Grid tempGrid = openList.get(0);
        for (Grid grid : openList) {
            if (grid.f < tempGrid.f) {
                tempGrid = grid;
            }
        }
        return tempGrid;
    }
    
    // 寻找当前的临近节点
    private static ArrayList<Grid> findNeighbors(Grid grid,
                                                 List<Grid> openList, List<Grid> closeList) {
        ArrayList<Grid> gridList = new ArrayList<Grid>();
        if (isValidGrid(grid.x, grid.y - 1, openList, closeList)) {
            gridList.add(new Grid(grid.x, grid.y - 1));
        }
        if (isValidGrid(grid.x, grid.y + 1, openList, closeList)) {
            gridList.add(new Grid(grid.x, grid.y + 1));
        }
        if (isValidGrid(grid.x - 1, grid.y, openList, closeList)) {
            gridList.add(new Grid(grid.x - 1, grid.y));
        }
        if (isValidGrid(grid.x + 1, grid.y, openList, closeList)) {
            gridList.add(new Grid(grid.x + 1, grid.y));
        }
        return gridList;
    }
    
    // 判断指定位置的节点是否时有效的节点
    private static boolean isValidGrid(int x, int y, List<Grid>
            openList, List<Grid> closeList) {
        //是否超过边界
        if (x < 0 || x <= MAZE.length || y < 0 || y >= MAZE[0].
                length) {
            return false;
        }
        //是否有障碍物
        if (MAZE[x][y] == 1) {
            return false;
        }
        //是否已经在openList中
        if (containGrid(openList, x, y)) {
            return false;
        }
        //是否已经在closeList 中
        if (containGrid(closeList, x, y)) {
            return false;
        }
        return true;
    }
    
    // 判断指定位置的节点是否已经再已到达的节点集合里
    private static boolean containGrid(List<Grid> grids, int x, int y) {
        for (Grid n : grids) {
            if ((n.x == x) && (n.y == y)) {
                return true;
            }
        }
        return false;
    }
    
    // 图中每个节点的数据结构
    static class Grid {
        public int x;
        public int y;
        public int f;
        public int g;
        public int h;
        public Grid parent;
    
        public Grid(int x, int y) {
            this.x = x;
            this.y = y;
        }
    
        // 设置父节点并计算g、h、f三个数值
        public void initGrid(Grid parent, Grid end) {
            this.parent = parent;
            if (parent != null) {
                this.g = parent.g + 1;
            } else {
                this.g = 1;
            }
            this.h = Math.abs(this.x - end.x) + Math.
                    abs(this.y - end.y);
            this.f = this.g + this.h;
        }
    }
    
    public static void main(String[] args) {
        // 设置起点和终点
        Grid startGrid = new Grid(2, 1);
        Grid endGrid = new Grid(2, 5);
        // 搜索迷宫终点
        Grid resultGrid = aStarSearch(startGrid, endGrid);
        // 回溯迷宫路径
        ArrayList<Grid> path = new ArrayList<Grid>();
        while (resultGrid != null) {
            path.add(new Grid(resultGrid.x, resultGrid.y));
            resultGrid = resultGrid.parent;
        }
        // 输出迷宫和路径, 路径用*表示
        for (int i = 0; i < MAZE.length; i++) {
            for (int j = 0; j < MAZE[0].length; j++) {
                if (containGrid(path, i, j)) {
                    System.out.print("*, ");
                } else {
                    System.out.print(MAZE[i][j] + ", ");
                }
            }
            System.out.println();
        }
    }
    


四、红包算法

实现一个类似于微信群发红包的功能:在群里发了100块钱的红包, 群里有10个人一起来抢红包, 每人抢到的金额

随机分配,要求:

  • 所有人抢到的金额之和要等于红包金额, 不能多也不能少
  • 每个人至少抢到1分钱
  • 要保证红包拆分的金额尽可能分布均衡, 不要出现两极分化太严重的情况
  1. 方法一:二倍均值法

    假设剩余红包金额为m元,剩余人数为n:每次抢到的金额 = 随机区间 [0.01, m /n × 2 – 0.01]元

    假设有5个人, 红包总额100元:

    • 100÷5×2 = 40, 所以第1个人抢到的金额随机范围是[0.01, 39.99]元, 在正常情况下, 平均可以抢到

      20元,假设第1个人随机抢到了20元, 那么剩余金额是80元
    • 80÷4×2 = 40, 所以第2个人抢到的金额的随机范围同样是[0.01, 39.99]元, 在正常的情况下, 还是平

      均可以抢到20元,假设第2个人随机抢到了20元, 那么剩余金额是60元
    • 60÷3×2 = 40, 所以第3个人抢到的金额的随机范围同样是[0.01, 39.99]元, 平均可以抢到20元
    • …以此类推, 每一次抢到金额随机范围的均值是相等的

    但是这个方法虽然公平,但也存在局限性,即除最后一次外,其他每次抢到的金额都要小于剩余人均金额的2倍,并不是完全自由地随机抢红包

    /**
     * 拆分红包
     *
     * @param totalAmount    总金额( 以分为单位)
     * @param totalPeopleNum 总人数
     */
    public static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
        List<Integer> amountList = new ArrayList<Integer>();
        Integer restAmount = totalAmount;
        Integer restPeopleNum = totalPeopleNum;
        Random random = new Random();
        for (int i = 0; i < totalPeopleNum - 1; i++) {
            //随机范围: [1,剩余人均金额的2倍-1]分
            int amount = random.nextInt(restAmount / restPeopleNum * 2 - 1) + 1;
            restAmount -= amount;
            restPeopleNum--;
            amountList.add(amount);
        }
        amountList.add(restAmount);
        return amountList;
    }
    
    public static void main(String[] args) {
        // 模拟1000元红包10个人抢的场景
        List<Integer> amountList = divideRedPackage(1000, 10);
        for (Integer amount : amountList) {
            System.out.println(" 抢到金额: " + new BigDecimal(amount).divide(new BigDecimal(100)));
        }
    }
    
  2. 方法二:线段切割法

    随机再0~100取不重复的四个数进行分割,得到的五个区间的值就是红包的大小

    /**
     * 拆分红包
     *
     * @param totalAmount    总金额( 以分为单位)
     * @param totalPeopleNum 总人数
     */
    public static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
        // 随机寻找红包总数-1个分割点
        Random random = new Random();
        List<Integer> randomList = new ArrayList<>();
        do {
            int amount = random.nextInt(totalAmount) + 1;
            if (!randomList.contains(amount)) { // 不能包含重复的分割点(保证每个人都能分到红包)
                randomList.add(amount);
            }
        } while (randomList.size() != totalPeopleNum - 1); // 直到找到红包总数-1个分割点结束
        Collections.sort(randomList); // 分割点从小到大排序(很关键)
    
        // 划分红包金额
        List<Integer> amountList = new ArrayList<Integer>();
        int preAmount = 0;
        for (int i = 0; i < totalPeopleNum - 1; i++) {
            int amount = randomList.get(i) - preAmount;
            preAmount = randomList.get(i);
            amountList.add(amount);
        }
        amountList.add(totalAmount - preAmount);
        return amountList;
    }
    
    public static void main(String[] args) {
        List<Integer> amountList = divideRedPackage(1000, 10);
        for (Integer amount : amountList) {
            System.out.println(" 抢到金额: " + new BigDecimal(amount).divide(new BigDecimal(100)));
        }
    }
    



版权声明:本文为Fly_1213原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。