数据结构与算法 - 第一篇

828 阅读18分钟

1. 复杂度分析

1. 复杂度分析的意义

1. 事后统计法

将代码运行一遍,通过监控和统计手段获取算法执行的时间及内存占用,称为时候统计法.

2. 事后统计方的局限性

  1. 测试结果受测试环境的影响很大
  2. 测试结果受测试数据的影响很大

3. 我们需要不依赖具体测试环境及测试数据,就可以粗略估计算法执行效率的方法.

2. 大O复杂度表示法

大O时间复杂度并不表示具体代码真正的执行时间,而是表示代码执行时间随数据规模增大的变化趋势

3. 时间复杂度分析方法

1. 加法法则

代码总的复杂度等于量级最大的那段代码的复杂度

2. 乘法法则

嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

4. 几种常见的时间复杂度量级

1. O(1)

2. O(logn) , O(nlogn)

3. O(m+n) , O(mn

5. 几种常见的空间复杂度量级

O(1) , O(n), O(n^2) , O(logn) , O(nlogn)

6. 最好时间复杂度,最坏时间复杂度,平均时间复杂度,均摊时间复杂度

1. 最好时间复杂度

在最好的情况下,执行一段代码的时间复杂度

2. 最坏时间复杂度

在最坏的情况下,执行一段代码的时间复杂度

3. 平均时间复杂度

最好时间复杂度和最坏时间复杂度,是极端情况下的时间复杂度,发生的概率并不大.
平均时间复杂度指的是代码重复执行无数次,对应的时间复杂度的平均值.
将每种情况发生的概率也考虑进去.

4. 均摊时间复杂度

均摊事件复杂度是平均时间复杂度的一种特殊情况.
对一个数据进行一组连续操作,大部分情况时间复杂度很低,个别情况时间复杂度较高,而且这些操作之间存在规律的时序关系.
这时我们可以将这一组操作一起分析,将较高时间复杂度的那次操作平摊到其他较低时间复杂度的操作上.

2. 数组

1. 线性表

数据排列成像一条线一样的结构.
线性表中的数据只有前后两个方向.
数组,链表,栈,队列都是线性表结构.

2. 非线性表

数据之间不是简单的前后关系.
树,图都是非线性表.

3. 数组定义

数组是一种线性表结构,它用一组连续的内存空间,存储一组具有想同类型的数据.

1. 连续的内存空间

数组中的元素在内存地址上是连续的

2. 相同类型的数据

4. 数组最重要的特性: 随机访问

随机访问是指,数组支持在O(1)时间复杂度内按照下标访问对应的数组元素.

  1. a[i]_address = base_address + i * data_type_size
  2. base_address数组内存空间首地址, i是数组元素下标, data_type_size是数组中每个元素占用的内存大小.

5. 数组元素的插入及删除

1. 通常情况下,为保证'内存连续性',数组插入及删除数据,时间复杂度是O(n)

2. 为了提高插入效率,优化措施:

  1. 首先将要插入的位置的元素放到末尾
  2. 然后将插入的元素放到指定位置

3. 为了提高删除效率,优化措施:

  1. 每次删除数据并不真正删除,仅仅标记该位置数据失效
  2. 当数组中空间不够用时,再统一将标记过的位置的数据删除,统一执行数据搬移

6. 容器能否完全替代数组

1. ArrayList的优势

  1. 将很多对数组的操作进行了封装,使用便捷
  2. 支持动态扩容
    • 动态扩容涉及数据搬移及内存申请,耗时,若提取能确定数据规模,容器初始化时就要指定容器的容量.

2. ArrayList的劣势

  1. 无法存储基本类型,使用封装类会涉及 自动装箱拆箱,有性能损耗.
  2. 多维数组直接使用数组更直观

3. 结论

  1. 业务开发直接使用容器类即可
  2. 底层开发,如编写网路框架,做性能优化要优先使用数组.

7. 数组下标为何从0开始

为了计算数组元素内存地址性能最好,下标从0开始,相比从1开始,获取元素内存地址计算少.

  1. address = base + singleSize * i
  2. address = base + singleSize * (i - 1)

8. 编程语言中的数组,并不一定完全符合数据结构中数组的定义.

1. C/C++中实现的数组完全符合数组的标准定义,在一块连续的内存空间存储相同类型的数据

2. Java中

  1. 基本类型数组符合数据结构中数组的定义,在连续的内存空间中存储相同类型数据.
  2. 对象数组存储的是对象在内存中的地址,而不是实例本身.实例本身在内存中并不连续.

3. 链表

1. 缓存淘汰算法

缓存大小有限,当缓存被填满,哪些数据应该被移除,哪些数据应该被保留,需要缓存淘汰算法来决定.

1. 先进先出 FIFO / First In, First Out

2. 最少使用 LFU / Least Frequently Used

3. 最近最少使用 LRU / Lease Recently Used

2. 链表不需要连续的内存空间,通过指针将一组零散的内存块/结点串联起来.

3. 数组擅长按照下标随机访问, 链表擅长插入及删除操作.

4. 链表种类

1. 单链表

  1. 每个Node只有next指针
  2. tail.next = null

2. 双向链表

  1. 每个Node有next指针及pre指针
  2. tail.next = null

3. 循环链表

  1. 每个Node只有next指针
  2. tail.next = head

4. 双向循环链表

  1. 每个Node有next指针及pre指针
  2. tail.next = head

5. 链表和数组对比

  1. 数组使用一组连续的内存空间,可以有效利用CPU缓存,预读数组中多个元素,提高访问效率; 链表中元素在内存中不连续,对CPU缓存不友好.
  2. 数组一经声明就要占据连续的内存空间,容器触发OOM. 若声明的数组过小,就会频繁的扩容消耗性能; 链表本身没有内存限制,天然支持动态扩容.
  3. 链表每个节点需要存储next指针,更消耗内存.如果频繁的插入删除,会涉及频繁的内存申请及释放,触发GC影响性能.如果内存紧张,更适合数组.

6. 利用'哨兵'来简化链表操作代码

public class LinkedList{
    public class Node{
        public int data;
        public Node next;
    }
    private Node head;
    private Node tail;
    
    public LinkedList(){
        //哨兵,保证链表永远不为空
        Node guard = new Node();
        head = guard;
        tail = guard;
    }
    
    public void insertAtTail(Node node){
        tail.next = node;
        tail = node;
    }
}

//在数组中查找指定值
public int findKey(int[] a, int count, int key){
    if(a == null || count <= 0){
        return -1;
    }
    if(a[count - 1] == key){
        return count - 1;
    }
    int temp = a[count -1];
    //a[count - 1] =  key 就是哨兵,可以避免每次判断索引值是否越界
    a[count - 1] =  key;
    int i = 0;
    while(a[i] != key){
        i++;
    }
    a[count - 1] = temp;
    if(i == count - 1){
        return -1;
    }
    return i;
}

7. 数组及链表的几个问题

1. 自定义单链表,校验字符串是否是回文字符串

class Node{
    public char data;
    public Node next;
}
public class LinkedList{
    public Node head;
    
    public Node reverseList(Node tail){
        Node curr = head;
        Node next = null;
        while(curr.next != tail){
            next = curr.next;
            curr.next = next.next;
            next.next = head;
            head = next;
        }
        curr.next = null;
        tail.next = head;
        head = tail;
        return head;
    }
    
    public boolean palindrome(){
        if(head == null){
            return false;
        }
        if(head.next == null){
            return true;
        }
        if(head.next.next == null){
            return head.data == head.next.data;
        }
        if(head.next.next.next == null){
            return head.data == head.next.next.data;
        }
        Node slow = head;
        Node fast = head.next;
        while(slow != null && slow.next != null && fast != null && fast.next != null && fast.next.next != null){
            slow = slow.next;
            fast = fast.next.next;
        }
        //获取要比较的右半边链表的起始结点
        Node right = null;
        if(fast.next == null){
            //偶数个元素
            right = slow.next;
        }else{
            //奇数个元素
            right = slow.next.next;
        }
        //将左半边链表倒置
        Node left = reverseList(slow);
        while(left != null && right != null && left.data == right.data){
            left = left.next;
            right = right.next;
        }
        //如果是回文字符串,则左右两半链表每个字符都相等,最终都是null
        return left.next == null && right.next == null;
    }
}

2. 使用数组实现LRU算法

public class LRUArray<T>{
    public int capacity = 16;
    public T[] datas;
    public Map<T,Integer> map = new HashMap<>();
    public int count = 0;
    
    public LRUArray(){
        datas = new T[capacity];
    }
    public LRUArray(int cap){
        capacity = cap;
        datas = new T[capacity];
    }
    public boolean isFull(){
        return count == capacity;
    }
    public void transferToRight(int idx){
        for(int i = idex; i>0;i--){
            datas[i] = datas[i - 1];
            map.put(datas[i],i);
        }
    }
    public void addToHead(T t){
        datas[0] = t;
        map.put(t,0);
    }
    public void access(T t){
        if(map.containsKey(t)){
            //老元素,将其放到第一位,并移动之前所有的元素
            int index = map.get(t);
            transferToRight(index);
            addToHead(t);
        }else{
            //新元素
            if(isFull()){
                //数组已满,则移动之前所有的元素,并将新元素添加到第一位
                transferToRight(capacity - 1);
                addToHead(t);
            }else{
                //将之前所有元素右移,并将新元素添加到第一位
                transferToRight(count);
                addToHead(t);
                count++;
            }
        }
    }
}

3. 使用单链表实现LRU算法

public class LRUList<T>{
    public class Node{
        public T data;
        public Node next;
    }
    
    public Node head;
    public int capacity = 16;
    public int size = 0;
    
    public LRUList(){
    }
    public LRUList(int cap){
        capacity = cap;
    }
    public boolean isFull(){
        return size == capacity;
    }
    public void addHead(T t){
        t.next = head;
        head = t;
    }
    public void removeTail(){
        if(head == null){
            return;
        }
        if(head.next == null){
            head = null;
            size--;
            return;
        }
        Node curr = head;
        while(curr != null && curr.next != null && curr.next.next != null){
            curr = curr.next;
        }
        curr.next = null;
        size--;
    }
    public void access(T t){
        if(head == null){
            addHead(t);
            size++;
            return;
        }
        if(t == head){
            return;
        }
        Node pre = head;
        while(pre != null && t != pre.next){
            pre = pre.next;
        }
        if(pre != null){
            //说明t是老元素
            pre.next = t.next;
            addHead(t);
        }else{
            //说明t是新元素
            if(isFull()){
                //当前链表已满,需要删除尾部元素,并将新元素添加到首部
                removeTail();
                addHead(t);
                size++;
            }else{
                //当前链表未满,直接将新元素添加到首部
                addHead(t);
                size++;
            }
        }
    }
}

4. 单链表反转

public Node reverseList(Node list){
    if(list == null){
        return null;
    }
    Node curr = list;
    Node head = list;
    Node next = null;
    while(curr.next != null){
        next = curr.next;
        curr.next = next.next;
        next.next = head;
        head = next;
    }
    return head;
}

5. 单链表中环的检测

public boolean hasCircle(Node list){
    if(list == null){
        return false;
    }
    Node slow = list;
    Node fast = list.next;
    while(slow != null && slow.next != null && fast != null && fast.next != null && fast.next.next != null){
        slow = slow.next;
        fast = fast.next.next;
        if(slow == fast){
            return true;
        }
    }
    return false;
}

6. 合并两个有序单链表

public Node mergeTwoSortedList(Node n1, Node n2){
    Node soldier = new Node();
    Node curr = soldier;
    while(n1 != null && n2 != null){
        if(n1.data <= n2.data){
            curr.next = n1;
            n1 = n1.next;
        }else{
            curr.next = n2;
            n2 = n2.next;
        }
        curr = curr.next;
    }
    if(n1 != null){
        curr.next = n1;
    }
    if(n2 != null){
        curr.next = n2;
    }
    return soldier.next;
}

7. 删除单链表的倒数第k个结点

public void deleteLastK(Node list, int k){
    if(lsit == null || k < 1){
        throw new IllegalArgumentException("参数错误!");
    }
    Node fast = list;
    int i = 0;
    while(fast != null && i < k){
        fast = fast.next;
    }
    if(i < k){
        throw new IllegalArgumentException("链表长度不够!");
    }
    if(fast == null){
        //说明链表中一共只有k个元素,直接删除head即可
        Node next = head.next;
        head.next = null;
        head = next;
        return;
    }
    Node slow = head;
    while(slow.next != null && fast.next != null){
        slow = slow.next;
        fast = fast.next;
    }
    //待删除的结点,就是slow.next
    Node next = slow.next;
    slow.next = next.next;
    next.next = null;
    return list;
}

8. 求链表的中心结点

public Node gainMiddleNode(Node list){
    if(list == null){
        throw new IllegalArgumentException("参数错误!");
    }
    Node slow = list;
    Node fast = list;
    //注意:这里只要判断fast即可,因为fast走得更快
    while(fast.next != null && fast.next.next != null){
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

4. 栈

1. 栈的定义

栈是一种操作受限的线性表.
栈只允许在栈顶插入及删除数据.
先进后出,后进先出.

2. 栈仅支持两种操作: 入栈和出栈

1. 入栈就是在栈顶插入一个数据

2. 出栈就是在栈顶删除一个数据

3. 栈分为 顺序栈 及 链式栈

1. 链式栈

底层用链表实现的栈称为链式栈

2. 顺序栈

底层用数组实现的栈称为顺序栈

  1. 顺序栈示例
publc class ArrayStack<T>{
    public T[] items;
    public int n;
    public int count;
    
    public ArrayStack(int n){
        this.n = n;
        items = new T[n];
        count = 0;
    }
    
    public boolean push(T t){
        if(count == n){
            //数组空间已满
            return false;
        }
        items[count] = t;
        count++;
        return true;
    }
    public T pop(){
        if(count == 0){
            //栈是空的
            return null;
        }
        T result = items[count-1];
        count--;
        return result;
    }
}
  1. 支持动态扩容的顺序栈
    • 支持动态扩容的顺序栈,只要底层依赖一个支持动态扩容的数组,当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中.

4. 出栈和入栈的时间复杂度是O(1)

5. 使用2个栈,可以实现浏览器的 前进及后退 功能

public class Exploer{
    //ahead:存储当前正在浏览,以及之前点击浏览的网页
    Stack ahead = new Stack();
    //back:存储之前点击回退前浏览的网页
    Stack back = new Stack();
    public boolean canGoBack(){
        return !ahead.isEmpty();
    }
    public boolean canGoAhead(){
        return !back.isEmpty();
    }
    public void click(Page page){
        ahead.push(page);
    }
    public void goAhead(){
        if(canGoAhead()){
            Page page = back.poll();
            ahead.push(page);
        }
    }
    public void goBack(){
        if(canGoBack()){
            Page page = ahead.poll();
            back.push(page);
        }
    }
}

5. 队列

1. 队列定义

一种线性表.
先进先出,后进后出.

2. 队列支持2种操作: 入队/enqueue 及 出队/dequeue

1. 入队

入队是指将数据放到队列尾部.

2. 出队

出队是指从队列头部获取数据.

3. 队列分为 顺序队列 及 链式队列

1. 链式队列

基于链表实现的队列

2. 顺序队列

基于数组实现的队列

public class ArrayQueue<T>{
    private T[] items;
    private int n = 0;
    //对列头下标
    private int head = 0;
    //队列尾下标
    private int tail = 0;
    
    public ArrayQueue(int capacity){
        this.n = capacity;
        items = new T[n];
        head = tail = 0;
    }
    
    public boolean isEmpty(){
        return head == tail;
    }
    
    public T dequeue(){
        if(isEmpty()){
            return null;
        }
        T result = items[head];
        head++;
        return result;
    }
    public boolean enqueue(T t){
       if(tail == n){
           if(head == 0){
               //队列已满
               return false;
           }
           //队列不满,移动数组元素,将head之前的位置填满
           for(int i = head; i < tail; i++){
               items[i - head] = items[i];
           }
           tail -= head;
           head = 0;
       }
       items[tail] = t;
       tail++;
       return true;
    }
}

3. 为了避免enqueue时候的数组元素搬移,使用循环队列

将顺序队列的首尾相连,形成一个环,就变成了循环对列.
循环队列避免了tail==n时候的数据搬移.

  1. 循环队列判空条件: head == tail
  2. 循环队列已满条件: (tail + 1)%n == head
  3. 循环队列中,tail指针指向的位置不存储数据
public class CircleQueue<T>{
    private T[] items;
    private int n = 0;
    private int head = 0;
    private int tail = 0;
    
    public CircleQueue(int capacity){
        n = capacity;
        items = new T[n];
        head = 0;
        tail = 0;
    }
    
    public boolean isEmpty(){
        return head == tail;
    }
    public boolean isFull(){
        return (tail + 1)%n == head;
    }
    public boolean enqueue(T t){
        if(isFull()){
            return;
        }
        items[tail] = t;
        tail = (tail + 1)%n;
        return true;
    }
    public T dequeue(){
        if(isEmpty()){
            return null;
        }
        T result = items[head];
        head = (head + 1)%n;
        return result;
    }
}

4. 阻塞队列及并发队列

1. 阻塞队列在队列基础上增加了阻塞特性.

  1. 队列为空,获取数据会被阻塞,直到有数据时候才返回队列头的数据.
  2. 队列已满,插入数据会被阻塞,直到队列尾部出现空闲位置再插入数据.

2. 并发队列是入队出队属于线程安全的队列.

  1. 最直接方法是在enqueue和dequeue方法上加锁.
  2. 利用CAS原子操作,可以实现更细粒度的线程安全.

5. 基于数组实现的队列是有界的,因为数组创建时候就要声明元素个数.当队列已满,后续有新的数据,可以报错也可以直接return,适合需要快速响应的场景.

6. 基于链表实现的队列是无界的,当数据很多,可能导致后续加入的数据很久才被处理,适合对处理时间不严格的场景.

6. 递归

1. 适合用递归解决的问题,满足的3个条件:

1. 待求解问题可以分解为几个子问题的解

2. 带求解问题和子问题,只是数据规模不同,求解思路完全相同

3. 存在递归终止条件,不能无限循环

2. 编写递归代码的关键是写递推公式,寻找终止条件.

3. 递归代码的缺点:

1. 堆栈溢出

2. 重复计算

  • 在将问题分解为子问题的过程中,子问题可能是重复的
  • 通过使用备忘录/map的形式,将计算过的子问题的结果保存下来,避免子问题重复计算

3. 函数调用耗时多

4. 空间复杂度高

  • 递归调用一次就会保持一次现场数据,将栈帧压入栈

4. 将递归代码修改为非递归代码

//以上台阶问题为例
//递归写法
public int step(int n){
    if(n == 1){
        return 1;
    }
    if(n == 2){
        return 2;
    }
    return step(n - 1) + step(n - 2);
}

//将递归代码改为非递归代码
public int step(int n){
    if(n == 1){
        return 1;
    }
    if(n == 2){
        return 2;
    }
    //slow代表离目标值还有2个台阶,到达slow位置的走法数量
    int slow = 1;
    //fast代表离目标值还有1个台阶,到达fast位置的走法数量
    int fast = 2;
    int total = 0;
    for(int i = 3; i<=n; i++){
        //到达当前台阶,走法数量是 快+慢 走法数量之和
        total = slow + fast;
        //slow就是到下一个目标值,还有2个台阶
        slow = fast;
        //fast就是到下一个目标值,还有1个台阶
        fast = total;
    }
    return total;
}

7. 从哪几个方面分析排序算法

1. 排序算法的执行效率

1. 最好时间复杂度, 最坏时间复杂度, 平均时间复杂度

2. 时间复杂度的系数,常数,低阶

  • 对于数据规模很小,或时间复杂度相同的不同排序算法,其系数常数及低阶也要考虑进去

3. 比较次数,和交换次数

  • 比较元素大小的耗时,要低于交换元素的耗时,两者应区分开来统计

2. 排序算法的内存消耗

1. 原地排序算法

2. 非原地排序算法

需要额外的非常量级别的数据存储空间才能完成排序

3. 排序算法的稳定性

1. 稳定排序算法

待排序数据中存在值相等的元素,排序过后,相等元素的原始顺序不会改变

2. 不稳定排序算法

值相等的元素的原始顺序可能会被改变.

8. 冒泡排序, 插入排序, 选择排序

1. 冒泡排序

public void bubbleSort(int[] a){
    if(a == null || a.length <= 1){
        return;
    }
    int count = a.length;
    boolean matched = false;
    int temp;
    //冒泡排序需要最多比较n次
    for(int i = 0; i < count; i ++){
        matched = false;
        for(int j = 0; j< count - 1 - i; j++){
            if(a[j] > a[j+1]){
                temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
                matched = true;
            }
        }
        //如果某次比较没有可交换的相邻元素,说明所有元素都是有序的,退出循环
        if(!matched){
            break;
        }
    }
}

1. 冒泡排序是原地排序,因为不需要申请非常量级的额外存储空间

2. 冒泡排序是稳定排序算法,对大小相等的元素不做交换

3. 冒泡排序最好时间复杂度是O(1),最坏时间复杂度是O(n^2),平均时间复杂度是O(n^2)

2. 有序度

1. 有序度是指数组中具有有序关系的元素对的个数

2. 逆序度是数组中逆序元素对的个数

3. 完全有序的数组的有序度,称为 满有序度

4. 逆序度 = 满有序度 - 有序度

3. 插入排序

public void insertSort(int[] a){
    if(a == null || a.length <= 1){
        return;
    }
    int count = a.length;
    int value;
    int j;
    for(int i = 1; i< count; i++){
        //当前要和已排序部分笔记的元素值
        value = a[i];
        //已排序部分末尾元素的索引值
        j = i - 1;
        for(; j >= 0; j--){
            if(a[j] > value){
                //将该元素后移一位
                a[j + 1] = a[j];
            }else{
                break;
            }
        }
        //将value插入
        a[j + 1] = value;
    }
}

1. 插入排序是原地排序

2. 插入排序是稳定排序

3. 插入排序的时间复杂度和冒泡排序一样,是O(n^2)

4. 选择排序

选择排序思路类似插入排序,也是将整个数组划分为已排序区间和未排序区间. 选择排序每次从未排序区间中找到最小的元素,将其和未排序区间的第一个元素进行交换.

public void selectSort(int[] a){
    if(a == null || a.length <= 1){
        return;
    }
    int count = a.length;
    int minIndex;
    int temp;
    for(int i = 0; i< count - 1; i++){
        minIndex = i;
        for(int j = i; j< count; j++){
            if(a[j] < a[minIndex]){
                minIndex = j;
            }
        }
        //交换元素
        temp = a[i];
        a[i] = a[minIndex];
        a[minIndex] = temp;
    }
}

1. 选择排序是原地排序

2. 选择排序是不稳定排序

  • 当min元素和未排序区间的首元素进行交换,可能导致相同值的元素原始顺序改变. 如 6,5,4,6,3,1,2

3. 选择排序的时间复杂度是冒泡排序一样,是O(n^2)

5. 插入排序和冒泡排序的时间复杂度是相同的,为什么插入排序比冒泡排序应用更广

因为在循环中,插入排序仅需要1个赋值语句,而冒泡排序需要3个. 冒泡排序的运算量更大.

9. 归并排序

归并排序用到了分治思想.

public void mergeSort(){
    int [] a = **;
    mergeSort(a, 0, a.length - 1)
}
public void mergeSort(int[] a , int startIndex, int endIndex){
    if(startIndex >= endIndex){
        return;
    }
    int middleIndex = (startIndex + endIndex) / 2;
    mergeSort(a, startIndex, middleIndex);
    mergeSort(a, middleIndex + 1, endIndex);
    merge(a, startIndex, middleIndex, middleIndex + 1, endIndex)
}
public void merget(int[] a, int s1StartIndex, s1EndIndex, s2StartIndex, s2EndIndex){
    int[] temp = new int[s2EndIndex - s1StartIndex + 1];
    int i = 0;
    int s1 = s1StartIndex;
    int s2 = s2StartIndex;
    while(s1 <= s1EndIndex && s2 <= s23EndIndex){
        if(a[s1] <= a[s2]){
            temp[i++] = a[s1++];
        }else{
            temp[i++] = a[s2++];
        }
    }
    while(s1 <= s1EndIndex){
        temp[i++] = a[s1++];
    }
    while(s2 <= s2EndIndex){
        temp[i++] = a[s2++];
    }
    for(int i = s1StartIndex; i<= s2EndIndex; i++){
        a[i] = temp[i - s1StartIndex];
    }
}

1. 归并算法是稳定排序

2. 归并算法不是原地排序,因为'合并'过程中要创建临时数组

3. 归并算法的时间复杂度是O(nlogn)

1. 归并算法每一层,都要处理所有数组元素,所以每一层复杂度是O(n)

2. 归并算法的层数,是O(logn).

3. 归并算法的时间复杂度用乘法法则就是O(nlogn)

4. 归并算法的空间复杂度是O(n)

1. 因为合并操作,需要创建临时数组,每一层创建的多个临时数组的总大小是n

2. 空间复杂度表示在运行过程中最大的内存消耗,每一层计算完毕后都会及时释放内存,并不会持续占用.

3. 归并排序是递归调用,每次调用空间消耗是常量级的,所以函数调用栈的空间消耗是O(logn)

4. 总体空间复杂度是. O(n + logn) -> O(n)

10. 快速排序

快速排序也用到了分治思想.

public void quickSort(){
    int[] a = **
    quickSort(a, 0, a.length - 1);
}
public void quickSort(int[] a, int startIndex, int endIndex){
    if(startIndex >= endIndex){
        return;
    }
    int partition = partition(a, startIndex, endIndex);
    quickSort(a, startIndex, partition - 1);
    quickSort(a, partition + 1, endIndex);
}
public int partition(int[] a, int startIndex, int endIndex){
    int max = a[emdIndex];
    int result = startIndex;
    for(int i = startIndex; i <endIndex; i++){
        if(a[i] < max){
            swap(a, result, i);
            result++;
        }
    }
    swap(a, result, endIndex);
    result result;
}
public void swap(int[] a, int i1, int i2){
    if(i1 == i2){
        return;
    }
    int temp = a[i1];
    a[i1] = a[i2];
    a[i2] = temp;
}

1. 快速排序是原地排序.

2. 快速排序不是稳定排序. 如: 1,2,3,4, 7 ,9,7,8, 6 . 最后7和6交换后,导致2个7的原始顺序改变.

3. 快速排序的时间复杂度是 O(nlogn). 空间复杂度是 O(logn)

4. 使用快速排序,求数组中第K个元素/第K小元素.

1. 时间复杂度是O(n)

2. 每一次快速排序:

  1. 如partion + 1 = K, 则 a[partion]就是第K小元素
  2. 如果 partion + 1 < K , 说明要在 partion后面的元素中继续找
  3. 如果 partion + 1 > K , 说明要在 partion前面的元素中继续找
  4. 假定每次partion都在中间, 则复杂度是 O(n + n/2 + n/4 + ***) -> O(2n) -> O(n)

5. 归并排序不是原地排序,其空间复杂度是O(n),所以没有快速排序应用广泛.

11. 线性排序 : 桶排序, 计数排序, 计数排序, 这几种排序算法的时间复杂度是线性的,被称为线性排序.

12. 桶排序

  1. 首先统计一组数据的min及max,得到数据的范围,根据范围大小将所有数据划分为k个桶. 则每个桶里有 n/k 个数据.
  2. 对每个桶进行快排,每个桶的时间复杂度是 n/k * log(n/k)
  3. k个桶,则总体时间复杂度是 k * n/k*log(n/k) = nlog(n/k)
  4. 当k/桶的数量和n接近,则log(n/k)接近1,总体时间复杂度接近 O(n)
  5. 桶排序比较适用于外部排序,就是数据存储于外部磁盘中,数据量很大,而内存有限,不能一次性加载到内存中处理.

13. 计数排序

计数排序是桶排序的一种特殊情况,适用于数据范围不大的情况.
直接将数据分为k个桶.k就是数据范围/跨度.
每个桶内存放的都是相等的数据,省略了桶内排序的时间.

public void countSort(int[] a){
    if(a == null || a.length <= 1){
        return;
    }
    //获取数组长度
    int n = a.length;
    //记录数组元素最大值
    int max = a[0];
    for(int i=1;i<n;i++){
        if(a[i] > max){
            //修改最大值
            max = a[i];
        }
    }
    //根据最大值创建多个桶. 0 -> max 是 max+1 个桶.
    int[] c = new int[max + 1];
    for(int i = 0; i< n; i++){
        //统计每个桶内元素数量
        c[a[1]]++;
    }
    for(int i = 1; i <= max; i++){
        //每个桶内的值和前一个值累加
        c[i] = c[i - 1] + c[i];
    }
    int[] r = new int[n];
    for(int i = n-1; i >= 0; i--){
        //c[a[i]代表当前值的元素,在原始数组中是第几个
        r[c[a[i]] - 1] = a[i];
        //用掉一个元素后,对应桶内的值要减一
        c[a[i]]--;
    }
    //将临时数组内的值赋值到原始数组
    for(int i = 0; i < n; i++){
        a[i] = r[i];
    }
}

14. 基数排序

例如手机号码有11位,数据范围太大了,不适合桶排序,计数排序这种排序算法.
但是手机号码的位数都是相同的,而且高位排序确定后,低位的大小关系不影响总体排序.
而且每一位的数据范围并不大.

  1. 对于位数相同,或可以补全为位数相同
  2. 数据范围太大的一组数据
  3. 且高位先后顺序确定情况下,低位大小顺序不影响实际排序
  4. 可以从最后一位开始,对所有的数据进行排序. 假设有k位,则进行k次排序即可.
  • 每次对1位进行排序,可以使用桶排序或计数排序完成,复杂度是O(n)
  • 对k位进行排序,复杂度就是O(kn)

15. 如何实现一个高性能的通用排序函数

1. 线性排序的时间复杂度是O(n),但对数据要求很高,不适用与普遍场景.

2. 为了兼顾任意数据规模的排序,一般首选时间复杂度为O(nlogn)的排序算法.

3. 堆排序,快速排序,归并排序的时间复杂度都是O(nlogn).

1. Java使用堆排序实现排序函数

2. C语言使用快排实现排序函数

  • 归并排序的使用场景并并不多,因为归并排序并不是原地排序,merge过程最大空间复杂度是O(n).
  • 实际软件开发中,排序算法是否是原地排序算法是一个非常重要的选择标准.

4. 如何优化快速排序.

1. 快速排序高性能关键,在于分区点的选择.如果分区点正好是中值附近性能最好,如果是极值附近,时间复杂度就会劣化为O(n^2).

2. 为了选择合理的分区点

  1. 三数取中法
  • 从要排序区间的首,尾,中间各取一个数据.取中间值作为初始分区点.
  • 如果数组区间太大,可以继续优化为 五数取中 , 十数取中.
  1. 随机法
  • 随机法是在要排序的区间中随机选一个元素做为初始分区点.不能保证每次都取到最好,但是也不容易取到极值.

16. 二分查找

1. 二分查找的时间复杂度: O(logn)

O(logn)是非常高效的,甚至比O(1)更高效.因为我们表示时间复杂度会忽略系数,常数,很多时候O(1)可能是100,100,远比log(n)更大

2. 二分查找的非递归实现

public int bsearch(int[] a, int value){
    int n = a.length;
    int low = 0;
    int high = n - 1;
    int mid;
    while(low <= high){
        mid = low + ((high - low) >> 1);
        if(a[mid] == value){
            return mid;
        }if(a[mid] < value){
            low = mid + 1;
        }else{
            high = mid - 1;
        }
    }
    return -1;
}

3. 二分查找的递归实现

public int bsearch(int[] a, int value){
    int n = a.length;
    bsearchInternally(a, 0, n - 1, value);
}
public int bsearchInternally(int[] a, int low, int high, int value){
    if(low > high){
        return -1;
    }
    int mid = low + ((high - low) >> 1);
    if(a[mid] == value){
        return mid;
    }else if(a[mid] < value){
        return bsearchInternally(a, mid + 1, high, value);
    }else{
        return bsearchInternally(a, low, mid - 1, value);
    }
}

4. 二分查找的局限性

  1. 二分查找依赖于数组.
  • 链表不适用于二分查找,因为数组支持按照下标访问数据,时间复杂度是O(1),而链表依据位置访问结点的时间复杂度是O(n)
  1. 二分查找只适用于静态有序数据.
  • 如果一组数据涉及经常的增加删除,则每次二分查找前都需要进行排序,时间成本太高.当然频繁插入删除,任何排序算法的时间复杂度都很高.
  1. 数据量太大,也不适合二分查找
  • 二分查找底层依赖数组,数据量太大,数组申请时候容易OOM.
  1. 如何数据之间的比较非常耗时,那无论数据量大小,都推荐二分查找.避免相邻元素间的频繁比较,直接通过下标查找元素后和目标值比较.极大降低比较次数.

5. 二分查找的变体

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

public int bsearch(int[] a, int target){
    int low = 0;
    int high = a.length - 1;
    int mid;
    while(low <= hign){
        mid = low + ((high - low) >> 1);
        if(a[mid] > target){
            high = mid - 1;
        }else if(a[mid] < target){
            low = mid + 1;
        }else{
            if(mid == 0 || (a[mid - 1] != target)){
                return mid;
            }
            high = mid - 1;
        }
    }
    return -1;
}

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

public int bsearch(int[] a, int target){
    int low = 0;
    int high = a.length - 1;
    int mid;
    while(low <= high){
        mid = low + ((high - low) >> 1);
        if(a[mid] > target){
            high = mid - 1;
        }else if(a[mid] < target){
            low = mid + 1;
        }else{
            if(mid == a.length - 1 || (a[mid + 1] != target)){
                return mid;
            }
            low = mid + 1;
        }
    }
    return -1;
}

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

public int bsearch(int[] a, int target){
    int low = 0;
    int high = a.length - 1;
    int mid;
    while(low <= high){
        mid = low + ((high - low) >> 1);
        if(a[mid] < target){
            low = mid + 1;
        }else{
            if(mid == 0 || a[mid - 1] < target){
                return mid;
            }
            high = mid - 1;
        }
    }
    return -1;
}

4. 查找最后一个值小于等于给定值的元素

public int bsearch(int[] a, int target){
    int low = 0;
    int high = a.length - 1;
    int mid;
    while(low <= high){
        mid = low + ((high - low) >> 1);
        if(a[mid] > target){
            high = mid - 1;
        }else{
            if(mid == a.length - 1 || a[mid + 1] > target){
                return mid;
            }
            low = mid + 1;
        }
    }
    return -1;
}