首页 > 编程语言 >数据结构与算法之美

数据结构与算法之美

时间:2023-03-02 17:56:42浏览次数:51  
标签:排序 nums int 复杂度 之美 算法 数组 数据结构 public

复杂度分析

为什么需要时间复杂度?

通过统计、监控获得代码的运行时长和占用内存有一定的局限性。

  1. 测试结果非常依赖测试环境。 如在不同的处理器下获得的运行结果不同。
  2. 测试结果受限于数据的规模。 如在数据量少的情况下,插入排序会快于快速排序。 因此需要一个粗略的估计方法。

常见的时间复杂度量级

O(1)

    int a = 8;
    int b = 2;
    int sum = a + b

O(n)

int sum = 0;
for(int i = 0;i < n;i++) {
    sum += i;
}

O(logn)O(nlogn)

int i = 1;
while(i <= n) {
    i *= 2;
}

O(n2)**、**O(n3)...

for(...){
    for(...) {
        ...
    }
}

O(m + n)O(m * n)

for(int i = 0;i < n;i++){...}
for(int i = 0;i < m;i++){...}

O(n!)

O(2^n)

image.png

思考:

有人说,我们项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析,是不是多此一举呢?而且,每段代码都分析一下时间复杂度、空间复杂度,是不是很浪费时间呢?你怎么看待这个问题呢?

答:代码的时间复杂度和空间复杂度分析是程序员的基本功,这并不会消耗过多的时间,同时正是因为有这一概念,使得我们在写代码时,有一个优化的目标。

最好时间复杂度、最坏时间复杂度

// n表示数组array的长度 
int find(int[] array, int n, int x) { 
    int i = 0; 
    int pos = -1; 
    for (; i < n; ++i) { 
        if (array[i] == x) { 
            pos = i; 
            break;
        }
    } 
    return pos; 
}

当运气最好时,即第一个就为所要查的值,此时时间复杂度为O(1),当运气最差时,即该数组中没有这个值,此时时间复杂度为O(n)

平均时间复杂度

无论最好时间复杂度还是最差时间复杂度,都只在极端的情况下才会发生,发生的概率不大。因此为了更好地表示平均情况下的复杂度, 我们需要引入另一个概念:平均情况时间复杂度。

算平均时间复杂度的方法,加权平均值。同样以上面那个代码为例,要查找的变量x在数组中的位置,有n+1种情况:在数组的0~n-1位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以n+1, 就可以得到需要遍历的元素个数的平均值,即:

image.png

我们知道,要查找的变量x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概 率都为1/2。另外,要查找的数据出现在0~n-1这n个位置的概率也是一样的,为1/n。所以,根据概率乘法法则,要查找的数据出现在0~n-1中任意位置的概率就 是1/(2n)。

image.png

大部分情况下使用一种复杂度即可,只有在量级变动大的时候,才使用最好、最坏、平均时间复杂度进行分析。

均摊时间复杂度

    // array表示一个长度为n的数组 
    // 代码中的array.length就等于n 
    int[] array = new int[n];
    int count = 0;

    void insert(int val) {
        if (count == array.length) {
            int sum = 0;
            for (int i = 0; i < array.length; ++i) {
                sum = sum + array[i];
            }
            array[0] = sum;
            count = 1;
        }
        array[count] = val;
        ++count;
    }

与上一个代码例子不同的是,find函数只在极端情况下的时间复杂度为O(1),而insert函数在大部分情况下的时间复杂度为O(1),同时insert函数每隔n-1次O(1)的操作后,迎来一次O(n)的时间复杂度操作,具有规律性。

摊还分析法:每一次O(n)的插入操作,都会跟着n-1次O(1)的插入操作,所以把耗时多的那次操作均摊到接下来的n-1次耗时少的 操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是O(1)。

思考:

分析一下下列代码的时间复杂度:

// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10];
int len = 10;
int i = 0;
// 往数组中添加一个元素
void add(int element) {
    if (i >= len) { // 数组空间不够了
        // 重新申请一个2倍大小的数组空间
        int new_array[] = new int[len*2];
        // 把原来array数组中的数据依次copy到new_array
        for (int j = 0; j < len; ++j) {
            new_array[j] = array[j];
        }
        // new_array复制给array,array现在大小就是2倍len了
        array = new_array;
        len = 2 * len;
    }
    // 将element放到下标为i的位置,下标i加一
    array[i] = element;
    ++i;
}

答:仔细看,发现符合摊还分析法。大部分情况为O(1)时间复杂度的操作,只有当下标超过数组空间时,才会申请一个更大的空间,进行数据的迁移,此时的时间复杂度为O(n).故平均复杂度为O(1).

数组

概念

数组的概念:数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

1、线性表:指数据只有前后两个方向的结构。常见的如栈、队列、链表等。而树、图、堆为非线性的。

2、连续的内存空间和相同类型的数据。 有利且有弊,利在于数组支持随机访问(一般不说查询的时间复杂度为O(1),顺序的二叉查找时间复杂度都为O(logn)),弊在于在插入和删除操作时,为保证顺序性需要迁移数据。

数组是如何实现随机访问的?

我们拿一个长度为10的int类型的数组int[] a = new int[10]来举例。 在图中,计算机给数组a[10],分配了一块连续内存空间1000~1039,其中,内存块的首地址为base_address = 1000

image.png

计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:

a[i]_address = base_address + i * data_type_size 

其中data_type_size表示数组中每个元素的大小。我们举的这个例子里,数组中存储的是int类型数据,所以data_type_size就为4个字节。

数组插入删除效率的提高

如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为O(1)。但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是O(n)。 因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为(1+2+…n)/n=O(n)。

为避免大规模的迁移数据,可以在将数据插入到第k位后,将原先第k位的数字插入到数组尾部

image.png

这样可以提高插入的时间复杂度,但无法保证原先数组的顺序性。快排中引入了这个思想。

删除操作同插入操作的时间复杂度一致。对于删除操作可以先标记要删除的元素,在数组满的时候,再统一删除,这样可以大大减少删除导致的数据迁移。如jvm的标记清理。

容器ArrayList与数组如何抉择?

ArrayList相对于数组的优势是,将很多数组操作的细节封装了,使得程序员不用关心底层的逻辑,如添加,删除,扩容等。

而数组相对于ArrayList的优点是,ArrayList无法存储基本数据类型,他只能存储基本数据类型的封装类以及其他类,而装箱与拆箱有一定的性能消耗。

通常在写业务代码时使用的是ArrayList,因为这点性能影响不了整体系统的性能,而在做底层开发时,会选择使用数组,为了达到极致的性能。

数组为什么从0开始编号?

数组a[n],a代表数组首地址,n表示偏移,a[k]表示偏移k*type_size的位置。故其随机访问公式为:

a[k]_address = base_address + k * type_size

如果从1开始的话,那么公式就为

a[k]_address = base_address + (k - 1) * type_size

每次随机访问都多一次减的操作。

从历史上讲,java,js等设计者为了减少c语言程序员的学习成本,沿袭了c语言中0作为数组起始编号的设计

思考

二维数组的随机访问公式应该是怎样的?

答:a[i][j] = base_address + (i * n + j) * type_size.

链表

链表是线性表,与数组不同的是,他并不占据连续的内存空间,链表包括单链表,双链表,循环链表,循环双链表。

由于链表是通过指针将离散的内存空间串起来,因此他并不具备随机访问特性。

其好处是数组需要合理设置大小,过小会导致频繁扩容,过大会由于内存不足而导致创建失败。链表不必担心内存中没有连续的内存空间,同时其插入删除操作的时间复杂度为O(1),不需要进行数据的迁移操作。链表由于没有大小的限制,天然支持扩容。

其弊端在于,cpu读取内存时,会先将一个数据块读入cpu缓存中。下次访问内存数据,会先从cpu缓存中读取,cpu缓存没有时,才会读取内存,该机制是为了弥补内存访问慢与cpu处理速度快的差异。利用该缓存机制,cpu读取数组的效率会快于链表,因为数组的内存空间是连续的,而链表是非连续的。同时链表会有大量的插入删除操作,这在java语言中,可能导致频繁的gc。链表每个节点需要额外的存储空间去存储指向下一个节点的指针,这也使得链表的内存消耗会翻倍。

单链表

image.png 利用头节点和尾节点,可以完整遍历链表。

循环链表

image.png 循环链表是特殊的单链表,其尾节点指向头节点,适合解决约瑟夫问题。 约瑟夫问题:

剑指 Offer 62. 圆圈中最后剩下的数字 - 力扣(LeetCode) (leetcode-cn.com)

class Solution {
    public int lastRemaining(int n, int m) {
        if(m == 1) {
            return n - 1;
        }
        Node head = new Node(0);
        Node cur = head;
        for(int i = 1;i < n;i++) {
            cur.next = new Node(i);
            cur = cur.next;
        }
        cur.next = head;
        cur = head;
        int count = 0;
        while(cur.next != cur) {
            if(count == m - 2) {
                cur.next = cur.next.next;
                count = 0;
                cur = cur.next;
                continue;
            }
            cur = cur.next;
            count++;
        }
        return cur.val;
    }
}
class Node{
    public int val;
    public Node next;
    public Node(int val) {
        this.val = val;
    }
    public Node(){}
}

仅作示例,该解法会超时。

双链表

image.png 相对于单链表而言,需要额外的存储空间,但他使得插入删除相对于单链表而言更加高效。

以删除为例,单链表的删除有两个情境:

  1. 删除链表中指定的值
  2. 删除链表中已知结点。 情境1下,无论单链表还是双链表,其删除的时间复杂度是一致的,都需要进行一次O(n)的遍历,而情境二下,单链表需要重新进行一次遍历,找到删除结点的前驱节点,而双链表只需要通过前驱指针即可,该情况下双链表的删除只需要O(1).这体现了空间换时间的思想。LinkedHashMap即采用双向链表。

思考:

单链表如何判断字符串为回文串?

public class Main {
    public static void main(String[] args) {
        String s = "abc";
        Node root = new Node(s.charAt(0));
        Node cur = root;
        for (int i = 1; i < s.length(); i++) {
            cur.next = new Node(s.charAt(i));
            cur = cur.next;
        }
        boolean flag = isPalindrome(root);
        System.out.println(flag);
    }

    public static boolean isPalindrome(Node root) {
        Node slow = root;
        Node fast = root;
        Node pre = null;
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            Node tmp = slow.next;
            slow.next = pre;
            pre = slow;
            slow = tmp;
        }
        if (fast != null) {
            slow = slow.next;
        }
        while (slow != null) {
            if (slow.val != pre.val) return false;
            slow = slow.next;
            pre = pre.next;
        }
        return true;
    }
}

class Node {
    char val;
    Node next;

    public Node() {
    }

    public Node(char val) {
        this.val = val;
    }
}

链表练习题

剑指 Offer II 024. 反转链表 - 力扣(LeetCode) (leetcode-cn.com)

面试题 02.08. 环路检测 题解 - 力扣(LeetCode) (leetcode-cn.com)

21. 合并两个有序链表 - 力扣(LeetCode) (leetcode-cn.com)

剑指 Offer II 021. 删除链表的倒数第 n 个结点 - 力扣(LeetCode) (leetcode-cn.com)

如何理解栈?

栈是操作受限的线性表,之所以需要这样的数据结构,是因为数组、链表相对于一些功能而言暴露了太多没必要的接口,栈适合于一些先进后出的场景。

实现一个栈

基于数组

class Stack{
    private String[] arr;
    private int capacity;
    private int cur;
    public Stack(int capacity) {
        this.capacity = capacity;
        arr = new String[capacity];
        cur = 0;
    }
    public boolean put(String s) {
        if (cur == capacity) return false;
        arr[cur] = s;
        cur++;
        return true;
    }
    public String remove(){
        if (cur == 0) return null;
        String res = arr[cur - 1];
        cur--;
        return res;
    }
}

基于链表

class Stack{
    private int capacity;
    private int cur;
    private Node node;

    public Stack(int capacity) {
        this.capacity = capacity;
        cur = 0;
        node = null;
    }

    public boolean put(String s) {
        if (cur == capacity) {
            return false;
        }
        if (node == null) {
            node = new Node(s);
            cur++;
            return true;
        }
        Node t = new Node(s);
        node.next = t;
        t.pre = node;
        node = node.next;
        cur++;
        return true;
    }

    public String remove(){
        if (cur == 0) {
            return null;
        }
        String res = node.val;
        node = node.pre;
        cur--;
        if (node == null) return res;
        node.next = null;
        return res;
    }
}
class Node{
    public String val;
    public Node next;
    public Node pre;
    public Node(String val) {
        this.val = val;
    }
    public Node(){}
}

栈在函数调用的应用

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

栈在表达式求值的应用

栈可用作加减乘除的四则运算。编译器通过两个栈来实现加减乘除的四则运算,其中一个栈保存操作数,另一个栈保存运算符。们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。

如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取2个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

image.png

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;

/**
 * @author EnochStar
 * @title: Calculator
 * @projectName basic_use
 * @description: TODO
 * @date 2021/10/17 14:26
 */
public class Calculator {
    private static Map<Character,Integer> map = new HashMap<>();
    static {
        map.put('+',1);
        map.put('-',1);
        map.put('*',2);
        map.put('/',2);
    }
    public int solve (String s) {
        char[] cs = s.toCharArray();
        Deque<Integer> nums = new ArrayDeque();
        Deque<Character> symbol = new ArrayDeque();
        int len = cs.length;
        for(int i = 0;i < len;i++) {
            if(cs[i] == ' ') continue;
            if(Character.isDigit(cs[i])){
                int num = cs[i] - '0';
                while(i + 1 < len && Character.isDigit(cs[i + 1])) {
                    num = num * 10 + cs[++i] - '0';
                }
                nums.addLast(num);
            }else if(cs[i] == '(') {
                symbol.addLast(cs[i]);
            }else if(cs[i] == ')') {
                while(!symbol.isEmpty()) {
                    if(symbol.peekLast() != '(') {
                        cal(nums,symbol);
                    }else{
                        symbol.pollLast();
                        break;
                    }
                }
            }else{
                while(!symbol.isEmpty() && symbol.peekLast() != '(') {
                    char pre = symbol.peekLast();
                    if(map.get(pre) >= map.get(cs[i])) {
                        cal(nums,symbol);
                    }else{
                        break;
                    }
                }
                symbol.addLast(cs[i]);
            }
        }
        while(!symbol.isEmpty() && symbol.peekLast() != '(') {
            cal(nums,symbol);
        }
        int ans = nums.pollLast();
        return ans;
    }
    public void cal(Deque<Integer> nums,Deque<Character> symbol) {
        if(nums.isEmpty()) return;
        int fis = nums.pollLast();
        int sec = nums.pollLast();
        char c = symbol.pollLast();
        int num;
        if(c == '+') {
            num = fis + sec;
        }else if(c == '-'){
            num = sec - fis;
        }else if(c == '*') {
            num = fis * sec;
        }else {
            num = sec / fis;
        }
        nums.addLast(num);
    }
}

表达式求值_牛客题霸_牛客网 (nowcoder.com)

除此之外,栈还可用作括号匹配,在此不再赘述。

如何实现浏览器的前进与后退?

浏览器的前进与后退也可以用栈来实现,即一个栈用于存储浏览的历史,当执行后退时,就将第一个栈的一个内容推出到第二个栈中,当执行前进时,就将第二个栈的内容返还给第一个栈,当打开新的页面时,就清空第二个栈的内容。如图:

顺序查看了a,b,c三个页面,我们就依次把a,b,c压入栈

image.png

当你通过浏览器的后退按钮,从页面c后退到页面a之后,我们就依次把c和b从栈X中弹出,并且依次放入到栈Y。

image.png

这个时候你又想看页面b,于是你又点击前进按钮回到b页面,我们就把b再从栈Y中出栈,放入栈X中。

image.png

这个时候,你通过页面b又跳转到新的页面d了,页面c就无法再通过前进、后退按钮重复查看了,所以需要清空栈Y。

image.png

思考

1、在讲栈的应用时,讲到用函数调用栈来保存临时变量,为什么函数调用要用“栈”来保存临时变量呢?用其他数据结构不行吗?

2、我们都知道,JVM内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储Java中的对象。那JVM里面的“栈”跟我们这里说 的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢?

解答: 1、并不一定要用栈保存临时变量,只是函数调用时,刚好符合后进先出这个特性,因此用栈来保存临时变量是很方便的。

2、不是一回事,内存中的堆栈是真实存在的物理区,而这里的栈只是一个抽象的数据结构。只是jvm中的栈符合数据结构中栈的特性。

队列

如何理解队列?

队列和栈类似,也是操作受限的数据结构,其特性是先进先出,可理解为日常生活中的排队。

如何实现队列?

方法1、

/**
 * @author EnochStar
 * @title: Queue
 * @projectName basic_use
 * @description: TODO
 * @date 2021/10/17 16:46
 */
public class Queue {
    private String[] queue;
    private int capacity;
    private int head;
    private int tail;

    public Queue(int capacity) {
        this.capacity = capacity;
        queue = new String[capacity];
        head = 0;
        tail = 0;
    }

    public boolean enqueue(String s){
        if (tail == capacity) return false;
        queue[tail++] = s;
        return true;
    }
    public String dequeue() {
        if (head == tail) return null;
        String res = queue[head++];
        return res;
    }
}

这种方法有个明显的缺点,即进行对此的入队出队操作后,就无法继续添加数据。因此可以对入队操作进行优化。

方法2、

/**
 * @author EnochStar
 * @title: Queue
 * @projectName basic_use
 * @description: TODO
 * @date 2021/10/17 16:46
 */
public class Queue {
    private String[] queue;
    private int capacity;
    private int head;
    private int tail;

    public Queue(int capacity) {
        this.capacity = capacity;
        queue = new String[capacity];
        head = 0;
        tail = 0;
    }

    public boolean enqueue(String s){
        if (tail == capacity && head == 0) return false;
        else if (tail == capacity) {
            for (int i = head;i < tail;i++) {
                queue[i - head] = queue[i];
            }
            tail -= head;
            head = 0;
        }
        queue[tail++] = s;
        return true;
    }
    public String dequeue() {
        if (head == tail) return null;
        String res = queue[head++];
        return res;
    }
}

由摊还分析法可知,该平均时间复杂度为O(1),但最坏时间复杂度为O(1)

image.png

方法3、

采取循环队列的思路。

/**
 * @author EnochStar
 * @title: Queue
 * @projectName basic_use
 * @description: TODO
 * @date 2021/10/17 16:46
 */
public class Queue {
    private String[] queue;
    private int capacity;
    private int head;
    private int tail;
    private int size;

    public Queue(int capacity) {
        this.capacity = capacity;
        queue = new String[capacity];
        head = 0;
        tail = 0;
        size = 0;
    }

    public boolean enqueue(String s){
        if (size == capacity) {
            return false;
        }
        queue[tail] = s;
        tail = (tail + 1) % capacity;
        size++;
        return true;
    }
    public String dequeue() {
        if (head == tail) return null;
        String res = queue[head];
        head = (head + 1) % capacity;
        size--;
        return res;
    }
}

image.png

(图片有些许出入)

方法4、

基于链表实现队列

public class Queue {

    private int capacity;
    private int size;
    private Node tail;
    private Node head;

    public Queue(int capacity) {
        this.capacity = capacity;
        size = 0;
    }

    public boolean enqueue(String s) {
        if (size == capacity) return false;
        if (head == null) {
            head = new Node(s);
            tail = head;
            size++;
            return true;
        }
        tail.next = new Node(s);
        tail = tail.next;
        size++;
        return true;
    }

    public String dequeue() {
        if (size == 0) return null;
        String res = head.val;
        head = head.next;
        size--;
        return res;
    }

}
class Node {
    public String val;
    public Node next;

    public Node(String val) {
        this.val = val;
    }
}

队列一般可用作线程池的阻塞队列,或是并发队列。因此如果采用数组去实现队列的话,需要选择合适的大小,以充分的利用系统资源。当队列容量过大时,会有过多的请求等待,而当队列容量过小时,会导致无法充分利用系统资源,最大化系统性能。

基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。

递归

实现递归的三大条件

  1. 一个问题的解可分为几个子问题的解
  2. 该问题和子问题,除了数据的规模不同外,解题思路完全相同
  3. 存在递归终止

递归需要注意的问题

1、警惕堆栈溢出。由于函数调用是依靠栈来存储临时变量的,当函数调用过深,数据规模过大时,就会有堆栈溢出的问题。

2、警惕重复计算。通常采用备忘录来解决这个问题。

image.png

3、递归代码中有很多函数调用,当函数调用数量较大时,也会有很大的时间成本。同时递归调用的本质是不断压栈和出栈的过程,故其空间复杂度也不低。关于时间复杂度的计算方法是:函数调用的次数 * 每次函数调用的操作次数。 可看博客:带你逐步分析递归算法的时间复杂度 - 知乎 (zhihu.com)

能否将递归代码改写为非递归?

递归调用本质是依靠栈来进行不断的入栈和出栈的操作,因此,笼统来讲是可以改写的。但没必要,因为大多数情况下,将递归改为非递归,对其时间复杂度的影响并不大,只是徒增了实现的复杂度。

实际项目中的递归是如何的

实际项目中不好用递归,因为不好解决递归栈的问题。

思考

对于递归深度很深,递归规模大的代码,有什么好的调试方法?

1、打印日志,发现递归值

2、结合条件断点进行调试。

插入、冒泡、选择

如何分析一个“排序算法”?

排序算法的执行效率

  1. 最好、最坏、平均时间复杂度。用于分析在有序度不同情况下,不同算法的性能比较。
  2. 时间复杂度的系数、常数、低阶。时间复杂度表示的是在数据规模n很大的时候增长趋势,因此会忽略系数、常数、低阶。但在数据规模小的时候,同一阶时间复杂度比较时,需要考虑这些因素。
  3. 比较和交换的次数。基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如 果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。

排序算法的内存消耗

算法的内存消耗可用空间复杂度来度量。对于一些原地排序的排序算法,其时间复杂度为O(1)

排序算法的稳定性

即经过排序后,相同元素之间的顺序是不变的。为什么需要稳定性呢?可以考虑一种场景,当对一个订单进行排序,排序的要求为先根据金额进行排序,金额相同时再根据下单时间进行排序。

利用排序算法的稳定性,就可以先将数据根据下单时间排序,然后再根据金额排序。通过两次排序就可以达到这个要求。

冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一 个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。

public void bubbleSort(int[] nums) {
    for (int i = 0;i < nums.length;i++) {
        boolean flag = false;
        for (int j = i + 1;j < nums.length;j++) {
            if (nums[j] < nums[i]) {
                int tmp = nums[j];
                nums[j] = nums[i];
                nums[i] = tmp;
                flag = true;
            }
        }
        if (!flag) break;
    }
}

由代码可知,冒泡排序只需要O(1)的空间复杂度,故是原地排序。每次都对相邻的数据进行比较,进而决定要不要交换位置,由此可知,冒泡排序是稳定的。现在分析冒泡排序的时间复杂度,当数据本身有序时,只需要进行一次冒泡排序即可,此时时间复杂度为O(1)。最差情况是需要进行n次的冒泡排序,此时时间复杂度为O(n^2)。

平均时间复杂度可通过有序度和逆序度来分析。有序度是数组中具有有序关系的元素对的个数。

image.png

对于一个倒序排列的数组,比如6,5,4,3,2,1,有序度是0;对于一个完全有序的数组,比如1,2,3,4,5,6,有序度就是n*(n-1)/2,也就是15。我们把这种完全有序的数组的有序度叫作满有序度。

逆序度=满有序度-有序度,要排序的数组的初始状态是4,5,6,3,2,1 ,其中,有序元素对有(4,5) (4,6)(5,6),所以有序度是3。n=6, 所以排序完成之后终态的满有序度为n*(n-1)/2=15。其逆序度为12,因此需要交换12次。

image.png

对于包含n个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是0,所以要进行n(n-1)/2次交换。最好情况下,初始状态的有序度是n(n-1)/2,就不需要进行交换。我们可以取个中间值n(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。 换句话说,平均情况下,需要n(n-1)/4次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是O(n2),所以平均情况下的时间复杂度就是O(n^2)。

插入排序

插入排序,即将数据插入已排序的顺序子数组中,是一个动态排序的过程。

image.png

插入排序也包括比较和移动两个操作,插入排序初始数据的移动操作是固定的,等于逆序度。

public void insertSort(int[] nums) {
    for (int i = 1;i < nums.length;i++) {
        int value = nums[i];
        int j = i - 1;
        for (;j >= 0;j--) {
            if (nums[j] > value) {
                nums[j+1] = nums[j];
            }else{
                break;
            }
        }
        nums[j + 1] = value;
    }
}

显然插入排序是原地排序,且是稳定的排序,对于有序的数组,每次只需要比较一次即可,时间复杂度为O(n),如果该数组是倒叙的话,那么每次需要迁移n次,时间复杂度为o(n2),还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度为O(n2)。

选择排序

是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

image.png

public void selectSort(int[] nums) {
    for (int i = 0;i < nums.length;i++) {
        int minIndex = i;
        int min = nums[i];
        for (int j = i + 1;j < nums.length;j++) {
            if (nums[j] < min) {
                min = nums[j];
                minIndex = j;
            }
        }
        int tmp = nums[i];
        nums[i] = min;
        nums[minIndex] = tmp;
    }
}

简单分析可知,其为原地排序,无论数组是否有序,其时间复杂度都为O(n^2)。它并非稳定排序,以数组[5,4,5,2,7]可知,在第一次查找最小值时,会将第1个5的位置放在第二个5的后面。

为什么插入排序会比冒泡排序更受欢迎?

根据代码实现来看

冒泡排序中数据的交换操作: 
if (a[j] > a[j+1]) {
// 交换 int tmp = a[j]; 
a[j] = a[j+1];
a[j+1] = tmp; 
flag = true; 
}
插入排序中数据的移动操作: 
if (a[j] > value) { 
a[j+1] = a[j]; 
// 数据移动 
} else { 
break; 
}

可见,把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是K的数组进行排序。用冒泡排序,需要K次交换操作,每次需要3个赋值语句,所以交换操作总耗时就是3*K单位时间。而插入排序中数据移动操作只需要K个单位时间。

总结,思考

image.png 这三种排序算法,实现代码都非常简单,对于小规模数据的排序,用起来非常高效。但是在大规模数据排序的时候,这个时间复杂度还是稍微有点高,所以我们更倾向于时间复杂度为O(nlogn)的排序算法。

这几种排序算法,都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?

解答:前提,是否允许修改链表的节点value值,还是只能改变节点的位置。一般而言,考虑只能改变节点位置,冒泡排序相比于数组实现,比较次数一致,但交换时操作更复杂;插入排序,比较次数一致,不需要再有后移操作,找到位置后可以直接插入,但排序完毕后可能需要倒置链表;选择排序比较次数一致,交换操作同样比较麻烦。综上,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会 变大,插入排序系数会减小,选择排序无明显变化。

快速排序、归并排序

归并排序和快速排序都运用了分治的思想。分治即分而治之,将大问题分解成小问题,小问题解决了,大问题就解决了,是一种编程思想,通常使用递归实现,递归是一种编程技巧。

归并排序

归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

image.png

image.png

public void mergeSort(int[] nums,int left,int right){
    if (left >= right) return;
    int mid = ((right - left) >> 1) + left;
    mergeSort(nums,left,mid);
    mergeSort(nums,mid + 1,right);
    mergeSort(nums,left,mid,right);
}
public void mergeSort(int[] nums,int left,int mid,int right) {
    int[] tmp = new int[right - left + 1];
    int l1 = left;
    int l2 = mid + 1;
    int index = 0;
    while (l1 <= mid && l2 <= right) {
        if (nums[l1] <= nums[l2]) {
            tmp[index++] = nums[l1++];
        }else{
            tmp[index++] = nums[l2++];
        }
    }
    while (l1 <= mid) {
        tmp[index++] = nums[l1++];
    }
    while (l2 <= right) {
        tmp[index++] = nums[l2++];
    }
    for (int i = 0;i < tmp.length;i++) {
        nums[i + left] = tmp[i];
    }
}

归并排序性能分析

1、归并排序是稳定的。其稳定的关键在于mergeSort(int[] nums,int left,int mid,int right)中,l1和l2的比较。

2、归并排序非原地排序。根据代码很容易得知,每次合并操作时需要申请额外的空间,合并完成后,该空间就会释放,他最多需要申请O(n)的空间,故空间复杂度为O(n)

3、归并排序的时间复杂度分析。已知对n个元素进行归并排序,需要分解的时间为O(n),分解两个子数组排序的时间复杂度为O(n/2),每次执行mergeSort(int[] nums,int left,int mid,int right)需要的时间复杂度为O(n).推导公式为:

 T(n) = 2*T(n/2) + n = 2*(2*T(n/4) + n/2) + n 
      = 4*T(n/4) + 2*n = 4*(2*T(n/8) + n/4) + 2*n 
      = 8*T(n/8) + 3*n = 8*(2*T(n/16) + n/8) + 3*n 
      = 16*T(n/16) + 4*n ...... = 2^k * T(n/2^k) + k * n

当T(n/2^k) = 1时,k = log2n,代入T(n) = nlog2n.归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是O(nlogn)。

快速排序

快排的思想是这样的:如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。 我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。

image.png

public void quickSort(int[] arr,int begin,int end) {
    if(end <= start) {
        return;
    }
    int pivot = partition(arr,begin,end);
    quickSort(arr,begin,pivot - 1);
    quickSort(arr,pivot,end);
} 
public int partition(int[] arr,int begin,int end) {
    int pivot = end,counter = begin;
    for(int i = begin;i < end;i++) {
        if(arr[i] < arr[pivot]) {
            int temp = arr[counter];
            arr[counter] = arr[i];
            arr[i] = temp;
            counter++;
        }
    }
    int temp = arr[pivot];
    arr[pivot] = arr[counter];
    arr[counter] = temp;
    return counter;
}

image.png

image.png

快排与归并排序的区别:归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

快排性能分析

快排是原地排序,但非稳定,当数组分区平衡情况下,快排的时间复杂度为O(nlogn),当分区极不平衡情况下,快排的时间复杂度为O(n^2),平均时间复杂度为O(nlogn).

如何利用快排找到数组中第k大的数字

我们选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot,对数组A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。

如果p+1=K,那A[p]就是要求解的元素;如果K>p+1, 说明第K大元素出现在A[p+1…n-1]区间,我们再按照上面的思路递归地在A[p+1…n-1]这个区间内查找。同理,如果K<p+1,就在A[0...p-1]区间查找。

image.png

第一次分区查找,我们需要对大小为n的数组执行分区操作,需要遍历n个元素。第二次分区查找,我们只需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为1。

如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于2n-1。所以,上述解决思路的时间复杂度就为O(n)。

你可能会说,我有个很笨的办法,每次取数组中的最小值,将其移动到数组的最前面,然后在剩下的数组中继续找最小值,以此类推,执行K次,找到的数据不就是第K大元素了吗?

不过,时间复杂度就并不是O(n)了,而是O(K * n)。你可能会说,时间复杂度前面的系数不是可以忽略吗?O(K * n)不就等于O(n)吗? 这个可不能这么简单地划等号。当K是比较小的常量时,比如1、2,那最好时间复杂度确实是O(n);但当K等于n/2或者n时,这种最坏情况下的时间复杂度就是O(n2)了。

具体代码

class Solution {
    public int findKthLargest(int[] nums, int k) {
        return quickSort(nums,0,nums.length - 1,k);
    }
    public int quickSort(int[] nums,int start,int end,int k) {
        int pivot = partition(nums,start,end);
        if(pivot + 1 == k) {
            return nums[pivot];
        }else if(pivot + 1 < k) {
            return quickSort(nums,pivot + 1,end,k);
        }else{
            return quickSort(nums,start,pivot - 1,k);
        }
    }
    public int partition(int[] arr,int begin,int end) {
        int pivot = end,counter = begin;
        for(int i = begin;i < end;i++) {
            if(arr[i] > arr[pivot]) {
                int tmp = arr[counter];
                arr[counter] = arr[i];
                arr[i] = tmp;
                counter++;
            }
        }
        int tmp = arr[pivot];
        arr[pivot] = arr[counter];
        arr[counter] = tmp;
        return counter;
    }
}

思考

现在你有10个接口访问日志文件,每个日志文件大小约300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件,合并为1个 日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有1GB,你有什么好的解决思路,能“快速”地将这10个日志文件合并吗?

解答:先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然 后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据,以此类推,完成文件的合 并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存。

线性排序

时间复杂度均为O(n),是非基于比较的排序

桶排序

将有序的数据放在有序的桶中,然后桶内进行排序。排序完后依次取出,即是有序的数据。

时间复杂度分析。假设有n个数据,将他们均匀的划分到m个桶中,然后桶内进行快速排序,那么桶内的数据为k = n/m,则桶内的时间复杂度为O(klogk),那么m个桶排序时的时间复杂度为O(m * klogk),当m的个数与n近似时,时间复杂度接近O(n)。

桶排序虽然时间复杂度比较好,但只适用于特定的场景。

  1. 能容易的划分为m个桶,且桶与桶间存在天然的大小顺序
  2. 数据在各个桶的分配比较均匀,如果分布不均匀的话,那么在极端情况下,数据都放入一个桶内,时间复杂度为O(nlogn)
  3. 桶排序适合外部排序,即数据存储在外部磁盘中,数据量比较大,且内存有限,无法将所有数据载入内存

桶排序的实现

package algorithm;

/**
 * @author EnochStar
 * @title: BucketSort
 * @projectName basic_use
 * @description: 桶排序
 * @date 2021/10/28 16:15
 */
public class BucketSort {
    public static void main(String[] args) {
        int[] nums = new int[] {1,0,2,3,6,5,4};
        BucketSort bucketSort = new BucketSort();
        bucketSort.bucketSort(nums,3);
        for (int i : nums) {
            System.out.println(i);
        }
    }

    /**
     * @param arr 待排序数组
     * @param bucketSize 每个桶中的数量
     */
    public void bucketSort(int[] arr,int bucketSize){
        if (arr.length < 2) return;
        int maxNum = arr[0];
        int minNum = arr[0];
        // 找到最大最小值
        for (int i = 1;i < arr.length;i++) {
            if (arr[i] > maxNum) maxNum = arr[i];
            if (arr[i] < minNum) minNum = arr[i];
        }
        int bucketNum = (maxNum - minNum) / bucketSize + 1;
        int[][] bucket = new int[bucketNum][bucketSize];
        // 每个桶下对应的下标
        int[] indexArr = new int[bucketNum];

        // 将值填充入桶中
        for (int i : arr) {
            int bucketIndex = (i - minNum) / bucketSize;
            if (indexArr[bucketIndex] == bucket[bucketIndex].length) {
                ensureCapacity(bucket,bucketIndex);
            }
            bucket[bucketIndex][indexArr[bucketIndex]++] = i;
        }
        int k = 0;
        // 对桶内数据进行排序
        for (int i = 0;i < bucketNum;i++) {
            quickSort(bucket[i],0,indexArr[i] - 1);
            for (int j = 0;j < indexArr[i];j++) {
                arr[k++] = bucket[i][j];
            }
        }
    }
    // 数组扩容
    public void ensureCapacity(int[][] bucket,int bucketIndex) {
        int[] newArr = new int[bucket[bucketIndex].length * 2];
        for (int i = 0;i < bucket[bucketIndex].length;i++) {
            newArr[i] = bucket[bucketIndex][i];
        }
        bucket[bucketIndex] = newArr;
    }
    public void quickSort(int[] arr,int begin,int end) {
        if (end <= begin) return;
        int pivot = partition(arr,begin,end);
        quickSort(arr,begin,pivot - 1);
        quickSort(arr,pivot,end);
    }
    public int partition(int[] arr,int begin,int end) {
        int count = begin;
        int pivot = end;
        for (int i = begin;i < end;i++) {
            if (arr[i] < arr[pivot]) {
                int tmp = arr[i];
                arr[i] = arr[count];
                arr[count++] = tmp;
            }
        }
        int tmp = arr[pivot];
        arr[pivot] = arr[count];
        arr[count] = tmp;
        return count;
    }
}

计数排序

计数排序是一种特殊的桶排序,适用于数据量不大的情况下,省去了排序的时间,比如高考成绩排名,多个人会处于同一个分数。

实现

package algorithm;

/**
 * @author EnochStar
 * @title: CountSort
 * @projectName basic_use
 * @description: 计数排序
 * @date 2021/10/28 15:12
 */
public class CountSort {
    public static void main(String[] args) {
        int[] score = new int[] {1,0,2,3,4,5,1,0};
        int[] sort = countSort(score);
        for (int i : sort) {
            System.out.println(i);
        }
    }
    public static int[] countSort(int[] nums) {
        int maxNum = 0;
        for (int i = 0;i < nums.length;i++) {
            if (nums[i] > maxNum)
                maxNum = nums[i];
        }
        int[] tmp = new int[maxNum + 1];
        for (int num : nums) {
            tmp[num]++;
        }
        for (int i = 1;i < maxNum + 1;i++) {
            tmp[i] += tmp[i - 1];
        }
        int[] sort = new int[nums.length];
        for (int i = nums.length - 1;i >= 0;i--) {
            int index = tmp[nums[i]] - 1;
            sort[index] = nums[i];
            tmp[nums[i]]--;
        }
        return sort;
    }
}

基数排序

假设有100000个手机号码,希望对该手机号码进行排序,显然以上两种排序方法都不适用,而快排的时间复杂度为O(nlogn),那么能否有更有效的排序方法?可以利用排序的稳定性来解决这个问题,依次对每个手机号的某一位进行排序,利用了计数排序。假设手机号码的长度为k,计数排序的时间复杂度为O(n),则总的时间复杂度为O(K*N)。

package algorithm;

/**
 * @author EnochStar
 * @title: RadixSort
 * @projectName basic_use
 * @description: 基数排序
 * @date 2021/10/28 16:56
 */
public class RadixSort {
    public static void main(String[] args) {
        int[] nums = new int[] {201101,201102,101105,201103,201104};
        radixSort(nums);
        for (int i : nums) {
            System.out.println(i);
        }
    }
    public static void radixSort(int[] nums) {
        int maxNum = nums[0];
        for (int i : nums) {
            if (i > maxNum) {
                maxNum = i;
            }
        }
        for (int exp = 1;maxNum / exp > 0;exp *= 10) {
            countingSort(nums,exp);
        }
    }
    public static void countingSort(int[] nums,int exp) {
        int[] count = new int[10];
        for (int i = 0;i < nums.length;i++) {
            count[(nums[i] / exp) % 10]++;
        }
        for (int i = 1;i < count.length;i++) {
            count[i] += count[i - 1];
        }
        int[] r = new int[nums.length];
        for (int i = nums.length - 1;i >= 0;i--) {
            int index = count[(nums[i] / exp) % 10] - 1;
            r[index] = nums[i];
            count[(nums[i] / exp) % 10]--;
        }
        for (int i = 0;i < nums.length;i++) {
            nums[i] = r[i];
        }
    }
}

思考

假设我们现在需要对D,a,F,B,c,A,z这个字符串进行排序,要求将其中所有小写字母都排在大写字母的前面,但小写字母内部和大写字母内部不要求有序。比如经过排序之后为a,c,z,D,F,B,A,这个如何来实现呢?如果字符串中存储的不仅有大小写字母,还有数字。要将小写字母的放到前面,大写字母放在最后,数字放在中间,不用排序算法,又该怎么解决呢?

答:可以利用桶排序,设置三个桶分别处理小写字母,数字,大写字母,各个桶根据自己的排序规则进行排序,再遍历读取。

二分查找(基础)

时间复杂度O(logn)

二分查找每次查找后数据的范围就会变成原来的一半n,n/2,n/4...n/2^k,当n/2^k == 1时,k=logn,时间复杂度为O(k)

二分查找的实现(递归与非递归)

非递归

public int binarySearch(int[] nums,int key) {
    int left = 0;
    int right = nums.length-1;
    while (left <= right) {
        int mid = ((right - left) >>> 1) + left;
        if (nums[mid] == key) return mid;
        else if (nums[mid] > key)
            right = mid - 1;
        else {
            left = mid + 1;
        }
    }
    return -1;
}

递归

public int binarySearchReverse(int[] nums,int key) {
    int left = 0;
    int right = nums.length - 1;
    return binarySearchReverse(nums,key,left,right);
}
public int binarySearchReverse(int[] nums,int key,int left,int right) {
    if (left > right) return - 1;
    int mid = ((right - left) >>> 1) + left;
    if (nums[mid] == key) return mid;
    else if (nums[mid] > key) return binarySearchReverse(nums,key,left,mid - 1);
    else {
        return binarySearchReverse(nums,key,mid + 1,right);
    }
}

二叉查找应用场景

  1. 依赖于数组这一数据结构,因为数组支持随机访问,如果底层数据结构是链表的话,那么时间复杂度还是很高
  2. 二叉查找针对的是有序数据
  3. 数据量过少不适合二叉查找
  4. 数据量过大也不适合用二叉查找,因为二叉查找底层数据结构是数组,数组要求有连续的内存空间

思考

1、不用API,如何求一个数的平方根(可舍弃小数点) 2、如果二叉查找应用到有序链表上,那么它的时间复杂度是多少?

解答:

1、剑指 Offer II 072. 求平方根 - 力扣(LeetCode) (leetcode-cn.com)

class Solution {
    public int mySqrt(int x) {
        long left = 0;
        long right = x;
        while(left <= right) {
            long mid = left + ((right - left) >>> 1);
            long num = mid * mid ;
            if(num == x) {
                return (int)mid;
            }else if(num > x) {
                right = mid - 1;
            }else{
                left = mid + 1;
            }
        }
        return (int)right;
    }
}

2、当底层是双向链表时,则每次迁移数据的二分之一,n/2 + n / 4 + n / 8 + ... + 1 = n - 1 时间复杂度为O(n)

二分查找(进阶)

查找第一个值等于给定值的元素

public int bsearch(int[] nums,int key) {
    int l = 0;
    int r = nums.length - 1;
    while (l <= r) {
        int mid = ((r - l) >>> 1) + l;
        if (nums[mid] > key) {
            r = mid - 1;
        }else if (nums[mid] < key) {
            l = mid + 1;
        }else{
            if (nums[mid - 1] != key) {
                return mid;
            }
            r = mid - 1;
        }
    }
    return -1;
}

查找最后一个值大于等于给定值的元素

public int bsearchII(int[] nums,int key) {
    int l = 0;
    int r = nums.length - 1;
    while (l <= r) {
        int mid = ((r - l) >>> 1) + l;
        if (nums[mid] >= key) {
            if (nums[mid - 1] < key) return mid;
            r = mid - 1;
        }else{
            l = mid + 1;
        }
    }
    return -1;
}

大部分情况下,凡是二分查找能解决的都会采用散列表或者二叉查找树来解决,因为内存紧缺的情况并不多,二分查找通常用于以上的变体问题。

思考

如果有序数组是一个循环有序数组,比如4,5,6,1,2,3。针对这种情况,如何实现一个求“值等于给定值”的二分查找算法呢?

解:33. 搜索旋转排序数组 - 力扣(LeetCode) (leetcode-cn.com)

class Solution {
    public int search(int[] nums, int target) {
        int l = 0;
        int r = nums.length - 1;
        while(l <= r) {
            int mid = ((r - l) >>> 1) + l;
            if(nums[mid] == target) return mid;
            if(nums[mid] >= nums[l]) {
                if(target < nums[mid] && target >= nums[l]) {
                    r = mid - 1;
                }else{
                    l = mid + 1;
                }
            }else{
                if(target > nums[mid] && target <= nums[r]) {
                    l = mid + 1;
                }else{
                    r = mid - 1;
                }
            }
        }
        return -1;
    }
}

标签:排序,nums,int,复杂度,之美,算法,数组,数据结构,public
From: https://www.cnblogs.com/luojw/p/17172826.html

相关文章