算法之链表

链表

介绍

157888166333418

数组

首先我们来看一下数组。

我们先来看一下它的特点:首先数组是一种线性表数据结构,用一组连续的内存空间来存储一组具有相同类型的数据,左边的示意图,最左边0到8是它的下标,也就是数组从0开始,按照它的下标,可以随机的访问这个数组中的任何一个元素,那右边的话,8位的一串的数字是什么意思呢?就是它的内存地址,这里的内存地址只是一个示意,那么真正在程序里面的话,特别大家用的是现在64位电脑,它的内存地址比这个复杂得多,而且还会有一套寻址算法,而且可能还会有虚拟内存之类的,这就是一个简单的示意,它通过右边的示意图里的memory controller,即内存管理器,可以实现随机的访问任何一个下标位置里的内存元素,也就是它的数组元素,所以在这里访问任何一个位置的数组元素,它的时间复杂度是多少呢?就是O(1)的,就是它的硬件可以保证不管访问哪个位置,只需要一次操作,就可以拿出这个位置里面的值,所以是O(1)的查找。

157888193502358

那么接下来,如果要改变这个数组里的元素,会有两种经常用到的元素,一种是插入,一种是删除。那么为了保证这些数组元素是连续的,看例子,比如要插入D到第三个位置,之前的下标3,4,5中因为有元素,所以这几个元素要分别挪到4,5,6去,所以它的时间复杂度不是O(1)的,而是取决于挪动多少个后面的元素,也就是O(n),这也是作为一种连续的在内存中存放的数据结构,在修改的时候的劣势所在,也就是插入的话,它的时间复杂度是O(n)的。这里有个极端的例子,如果你插入数组的最后位置的话,那的确不需要挪动元素就可以顺利插入,所以是O(1),但同样极端的情况,如果插入数组的最前面,那么必须挪动所有的元素,即O(n),所以平均时间复杂度为O(n/2),最后还是O(n)。删除同理,在这里就不多说了。

157888169485517

Java的ArrayList

  很多编程语言都针对数组进行了封装,比如Java的ArrayList,可以将数组的很多操作细节封装起来(插入删除的搬移数据或动态扩容),可以参考ArrayList的扩容数据搬移方法,ArrayList默认size是10,如果空间不够了会先按1.5倍扩容(如果还不够就可能会用到最大容量)。所以在使用的时候如果事先知道数组的大小,可以一次性申请,这样可以免去自动扩容的性能损耗。

什么时候选择使用编程语言帮我们封装的数组,什么时候直接使用数组呢?

1、Java ArrayList不支持基本数据类型,需要封装为Integer、Long类才能使用。Autoboxing、Unboxing有一定的性能消耗。如果比较关注性能可以直接使用数组

2、使用前已经能确认数据大小,并且操作比较简单可以使用数组

链表

由于这些原因,后来有些人提出想改善插入和删除操作,让它变得更快。有没有新的数据结构呢?

第二种数据结构就应运而生,这种数据结构就叫做链表。这里主要介绍两种:单链表和双链表。

链表一般应用于两种场景:

  • 想改善它的插入和删除操作,也就是这两种操作会非常多,你不想这两种操作浪费你太多时间。
  • 不知道总共有多少个元素在,每来一个新元素就放在后面链到这个表里,那这种情况下,用链表处理也是最佳方式。

单链表

157888194863646

如图,链表大家在大学期间都学习过,它本质上就是一个一个的元素,有一个指针,链向它的后继结点,一般用next表示。还有的指针链向的是前驱结点,用的prev表示,这些都是链表的结构。

157888196164621

这里还有一种单链表的变形形式,用的是两个指针,内部是用next连接起来的,同时还提供了头指针和尾指针,让你很方便的知道头和尾分别在什么地方。

接下来介绍链表常见的两种操作:插入和删除

157888197848420

插入就是把前面的结点的next指针指向新节点,新结点的指针指向后面的这个节点,即可插入。

删除就是前面的结点直接指向要删除元节点原来的指向的下一节点,再把要删除结点在内存中释放掉。

这两个操作大家看到了,是需要进行两次next指针的调整,不管你在什么位置上插入,只需要两次操作,也就是常数级别的,即O(1)的时间复杂度,要优于数组。

但就查询来讲,如果要查找某个元素,需要从头指针一直遍历查找,所以查找的时间复杂度是O(n)的。

所以从这里大家可以看到,不同的算法和数据结构之间的关系就是动态平衡的关系,并不存在这么一个数据结构在任何领域都是最好的,否则也就没必要研究其他的数据结构了。

双链表

下面看一下双链表,它的不同的一点既有前驱,又有后继,这样的话你在查询链表元素的时候就会更加的简洁一些。

因为每个结点要额外的空间来保存前驱结点的地址,所以相同数据情况下,双向链表比单链表占用的空间更多。双向链表在找前驱结点时间复杂度为O(1),插入删除都比单链表高效,典型的空间换时间的例子。

157888199460086

时间复杂度如下:

157888200756702

面试题

斐波那契数列

斐波那契数列指的是这样一个数列 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368……

特别指出:第0项是0,第1项是第一个1。

这个数列从第三项开始,每一项都等于前两项之和。

请用java构建出该数列的数据结构

递归法

    private static int fibonacci(int i) {
        if (i == 0) {
            return 0;
        } else if (i == 1) {
            return 1;
        } else {
            return fibonacci(i - 1) + fibonacci(i - 2);
        }
    }

循环法

    private static int loop(int i) {
        if (i == 0) {
            return 0;
        } else if (i == 1) {
            return 1;
        }
        //初始状态为i=2,从i=3开始循环
        int n1 = 1;
        int n = 1;
        int temp;
        for (int j = 3; j <= i; j++) {
            //最新值、新值、旧值
            //保存新值
            temp = n;
            //n=n+n1,求出最新值赋给新值
            n += n1;
            //将保存的新值赋给旧值
            n1 = temp;
        }
        return n;
    }

题外话

循环法的时间复杂度为O(n),相比之下递归法的效率很差,时间复杂度是指数级别的,所以实际项目中一般不建议使用递归方式处理,但深层次的了解递归还是有必要的。【备注:循环法其实是缓存了n-1和n-2的值,这样就将递归的指数循环省略掉了】

强烈建议大家看一下这篇文章,从多方面解答了这道题目:

拜托,面试别再问我斐波那契数列了!!!

反转链表

反转一个单链表。

示例:

输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL

Node

public static class Node {
    public int value;
    public Node next;
    public Node(int data) {
        this.value = data;
    }
}

递归法

总体来说,递归法是从最后一个Node开始,在弹栈的过程中将指针顺序置换的。

157888202737944

    private static Node reverse(Node head) {
        // 递归到链表末尾,直接返回链表节点
        if (head.next == null) {
            return head;
        }
        //每次保存node之后的所有结点到temp
        Node temp = head.next;
        //递归temp尽头,得到新链表的头结点
        Node newHead = reverse(temp);
        //尾插法,在新链表结点指向先前结点
        temp.next = head;
        //断开原有指针
        head.next = null;
        return newHead;
    }

递归实质上就是系统帮你压栈的过程,系统在压栈的时候会保留现场。

递归过程

  • 程序到达Node newHead = reverse(head.next);时进入递归

  • 我们假设此时递归到了3结点,此时head=3结点,temp=3结点.next(实际上是4结点)

  • 执行Node newHead = reverse(head.next);传入的head.next是4结点,返回的newHead是4结点。

  • 接下来就是弹栈过程了

    • 程序继续执行 temp.next = head就相当于4->3

    • head.next = null 即把3结点指向4结点的指针断掉。

    • 返回新链表的头结点newHead

      注意:当retuen后,系统会恢复2结点压栈时的现场,此时的head=2结点;temp=2结点.next(3结点),再进行上述的操作。最后完成整个链表的翻转。

遍历法

遍历法就是在链表遍历的过程中将指针顺序置换

157888205214735

    private static Node reverse(Node node) {
        Node pre = null;
        Node temp;
        while (node != null) {
            //保存下一个结点状态
            temp = node.next;
            //头插法,当前结点指向先前结点
            node.next = pre;
            //先前结点移动
            pre = node;
            //结点移动,遍历下一个结点
            node = temp;
        }
        return pre;
    }

遍历过程

  • 准备两个空结点 pre用来保存先前结点、next用来做临时变量
  • 在头结点node遍历的时候此时为1结点
    • next = 1结点.next(2结点)
    • 1结点.next=pre(null)
    • pre = 1结点
    • node = 2结点
  • 进行下一次循环node=2结点
    • next = 2结点.next(3结点)
    • 2结点.next=pre(1结点)=>即完成2->1
    • pre = 2结点
    • node = 3结点
  • 进行循环

将链表相邻的两个节点反转

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。

示例:

输入: 1->2->3->4->
输出: 2->1->4->3->

说明:

  • 你的算法只能使用常数的额外空间。
  • 你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。

递归法

    private static Node swapPairs(Node head) {
        // 递归到链表末尾,直接返回链表节点
        if (head == null || head.next == null) {
            return head;
        }
        //找到交换结点中第二个
        Node res = head.next;
        //递归之后的链表
        Node temp = swapPairs(head.next.next);
        //交换结点中第二个指向第一个
        res.next = head;
        //第一个指向之后的链表
        head.next = temp;
        return res;
    }

循环法

    private static Node swapPairs(Node head) {
        //创建新结点
        Node tmp = new Node(0);
        //新结点指向当前结点
        tmp.next = head;
        //复制新结点
        Node temp = tmp;
        while (head != null && head.next != null) {
            //复制的新结点指向当前结点的下一个结点(此结点为后结点)
            temp.next = head.next;
            //前结点指向复制结点下两个结点(前结点指向链表)
            head.next = temp.next.next;
            //后结点指向前结点(后结点指向前结点,相邻结点反转)
            temp.next.next = head;
            //temp后移两个结点
            temp = temp.next.next;
            head = temp.next;
        }
        return tmp.next;
    }

链表中存在环问题

判断链表是否有环

单链表中的环是指链表末尾的节点的 next 指针不为 NULL ,而是指向了链表中的某个节点,导致链表中出现了环形结构。

链表中有环示意图:157888213959035

链表的末尾节点 8 指向了链表中的节点 3,导致链表中出现了环形结构。

穷举比较法

解题思路

  1. 遍历链表,记录已访问的节点。
  2. 将当前节点与之前以及访问过的节点比较,若有相同节点则有环。
    否则,不存在环。

这种穷举比较思想简单,但是效率过于低下,尤其是当链表节点数目较多,在进行比较时花费大量时间,时间复杂度大致在 O(n^2)。这种方法自然不是出题人的理想答案。如果笔试面试中使用这种方法,估计就要跪了,忘了这种方法吧

哈希缓存法

既然在穷举遍历时,元素比较过程花费大量时间,那么有什么办法可以提高比较速度呢?

解题思路

  1. 首先创建一个以节点 ID 为键的 HashSet集合,用来存储曾经遍历过的节点。
  2. 从头节点开始,依次遍历单链表的每一个节点。
  3. 每遍历到一个新节点,就用新节点和 HashSet 集合当中存储的节点作比较,如果发现 HashSet 当中存在相同节点 ID,则说明链表有环,如果 HashSet 当中不存在相同的节点 ID,就把这个新节点 ID 存入 HashSet ,之后进入下一节点,继续重复刚才的操作。

假设从链表头节点到入环点的距离是 a ,链表的环长是 r 。而每一次 HashSet 查找元素的时间复杂度是 O(1), 所以总体的时间复杂度是 1 * ( a + r ) = a + r,可以简单理解为 O(n) 。而算法的空间复杂度还是 a + r - 1,可以简单地理解成 O(n) 。

快慢指针法

解题思路

  1. 定义两个指针分别为 slow,fast,并且将指针均指向链表头节点。
  2. 规定,slow 指针每次前进 1 个节点,fast 指针每次前进两个节点。
  3. 当 slow 与 fast 相等,且二者均不为空,则链表存在环。

图解过程

无环过程:

157888219392708

157888234822916

157888239370267

通过图解过程可以看出,若表中不存在环形,fast 与 slow 指针只能在链表末尾相遇。

有环过程:

157888244321236

157888249504850

157888252981731

图解过程可以看出,若链表中存在环,则快慢指针必然能在环中相遇。这就好比在环形跑道中进行龟兔赛跑。由于兔子速度大于乌龟速度,则必然会出现兔子与乌龟再次相遇情况。因此,当出现快慢指针相等时,且二者不为NULL,则表明链表存在环。

代码实现

    public static boolean isLoop(Node head) {
        if (head == null || head.next == null) {
            return false;
        }
        Node one = head;
        Node two = head;
        while (one != null && two.next != null) {
            one = one.next;
            two = two.next.next;
            if (one.value == two.value) {
                return true;
            }
        }
        return false;
    }

定位环入口

当链表中存在环,如何确定环的入口节点呢?

哈希缓存法

用HashSet来记录遍历过的所有节点,当再次跑到环入口的时候就判断出来了

    private static Node entry(Node pHead) {
        HashSet<Node> hs = new HashSet<>();
        while (pHead != null) {
            if (!hs.add(pHead)) {
                return pHead;
            }
            pHead = pHead.next;
        }
        return null;
    }

此方法时间复杂度为O(n),但是HashSet中需要存储所有元素,空间复杂度也是O(n),链表很长的时候空间复杂度会很高

计算循环

用两个指针,一个fast指针,每次走两步,一个slow指针,每次走一步,当fast指针与slow指针相遇时,假设fast指针走了2x,那么slow指针走了x,由于有环,那么为了便于理解,分为两种情况:

情况一
1、当fast指针仅仅只比slow指针多走一个环,如图所示

158521232264440

2、第一次相遇的时候,如图

158521236430968

3、这个时候将fast 重新赋值为开头,如图

158521240380656

4、fast和slow单步再走两次,则同时找到了环的入口结点

158521247274521

解题思路

a、第一步,找环中相汇点。分别用fast,slow指向链表头部,slow每次走一步,fast每次走二步,直到fast=slow找到在环中的相汇点。
b、第二步,找环的入口。接上步,当fast=slow时,fast所经过节点数为2x,slow所经过节点数为x,设环中有n个节点,fast比slow多走一圈有2x=n+x; n=x;

可以看出slow实际走了一个环的步数,再让fast指向链表头部,slow位置不变。
假设链表开头到环接口的距离是y,如下图所示,那么x-y表示slow指针走过的除链表开头y在环中走过的距离,那么slow和fast单步再走y步,此时fast结点与slow结点相遇,fast == slow ,x-y+y=x = n,即此时slow指向环的入口。

158521259409865

情况二

当fast比slow 多走n个环:

158521265403347

解题思路

a、第一步,找环中相汇点。分别用fast,slow指向链表头部,slow每次走一步,fast每次走二步,直到fast==slow找到在环中的相汇点。

b、第二步,找环的入口。接上步,当fast==slow时,fast所经过节点数为2x,slow所经过节点数为x,设环中有n个节点,fast比slow多走r圈有2x=r * n+x; x=r * n;(r为环圈数,n为一圈的结点数)

可以看出slow实际走了多个环的步数,再让fast指向链表头部,slow位置不变。

假设链表开头到环接口的距离是y,那么x-y表示slow指针走过的除链表开头y在环中走过的距离,那么slow和fast单步再走y步,此时fast结点与slow结点相遇,fast == slow ,x-y+y=x = rn,即此时slow指向环的入口。

代码实现

    private static Node entry(Node pHead) {
        Node slow = pHead;
        Node fast = pHead;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
            //当快指针与慢指针相遇时
            if (fast == slow) {
                fast = pHead;
                //再次相遇
                while (fast != slow) {
                    fast = fast.next;
                    slow = slow.next;
                }
                return fast;
            }
        }
        return null;
    }

计算环长度

解题思路

在上面我们找到了 slow 与 fast 的相遇节点,起点到相遇结点的距离即为环的长度,让其中一个引用处于头的位置,另一个引用不动(处于相遇点),处于链表头的引用一步一步向后走,用count计数,直到遇到另一个引用;我们可以直接计算slow在相遇时的步数。

    private static Node entry(Node pHead) {
        Node slow = pHead;
        Node fast = pHead;
        int i = 0;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
            i++;
            //当快指针与慢指针相遇时
            if (fast == slow) {
                fast = pHead;
                System.out.println("length:" + i);
                //再次相遇
                while (fast != slow) {
                    fast = fast.next;
                    slow = slow.next;
                }
                return fast;
            }
        }
        return null;
    }

输入单链表倒数第K个节点

题目:输入一个单链表,输出此链表中的倒数第 K 个节点。(去除头结点,节点计数从 1 开始)。

两次遍历法

解题思路

  1. 遍历单链表,遍历同时得出链表长度 N 。
  2. 再次从头遍历,访问至第 N - K 个节点为所求节点

157888284812750

采用这种遍历方式需要两次遍历链表,时间复杂度为O(n+n-k)。可见这种方式最为简单,也较好理解,但是需要循环两遍,面试肯定不会考核这种解法。

递归法

解题思路

  1. 定义num = k
  2. 使用递归方式遍历至链表末尾。
  3. 由末尾开始返回,每返回一次 num 减 1
  4. 当 num 为 0 时,即可找到目标节点

157888289625557

双指针法

解题思路

  1. 定义两个指针 p1 和 p2 分别指向链表头节点。
  2. p1 前进 K 个节点,则 p1 与 p2 相距 K 个节点。
  3. p1,p2 同时前进,每次前进 1 个节点。
  4. 当 p1 指向到达链表末尾,由于 p1 与 p2 相距 K 个节点,则 p2 指向目标节点。

157888302644808

157888307487163

    private static Node findKthToTail(Node head, int k) {
        if (head == null || k == 0) {
            return null;
        }
        Node first = head;
        Node behind = head;
        for (int i = 0; i < k - 1; i++) {
            if (first.next != null) {
                first = first.next;
            } else {
                return null;
            }
        }
        while (first.next != null) {
            first = first.next;
            behind = behind.next;
        }
        return behind;
    }

可以看出使用双指针法只需遍历链表一次,这种方法更为高效时间复杂度为O(n),通常笔试题目中要考的也是这种方法。

使用链表实现大数加法

给定两个非空链表来表示两个非负整数。位数按照逆序方式存储,它们的每个节点只存储单个数字。将两数相加返回一个新的链表。

你可以假设除了数字 0 之外,这两个数字都不会以零开头。

示例

输入:(2 -> 4 -> 3) + (5 -> 6 -> 4)
输出:7 -> 0 -> 8
原因:342 + 465 = 807
    private static Node addTwoNumbers(Node l1, Node l2) {
        Node node = new Node(0);
        Node p = node;
        int sum = 0;
        while (l1 != null || l2 != null || sum != 0) {
            if (l1 != null) {
                sum += l1.value;
                l1 = l1.next;
            }
            if (l2 != null) {
                sum += l2.value;
                l2 = l2.next;
            }
            p.next = new Node(sum % 10);
            sum = sum / 10;
            p = p.next;
        }
        return node.next;
    }

有序链表合并

题目:将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

示例:
输入:
1->2->4,
1->3->4
输出:
1->1->2->3->4->4s

直接合并

解题思路

  1. 对空链表存在的情况进行处理,假如 head1为空则返回 head2,head2为空则返回 head1。(两个都为空此情况在pHead1为空已经被拦截)
  2. 在两个链表无空链表的情况下确定第一个结点,比较链表1和链表2的第一个结点的值,将值小的结点保存下来为合并后的第一个结点。并且把第一个结点为最小的链表向后移动一个元素。
  3. 继续在剩下的元素中选择小的值,连接到第一个结点后面,并不断next将值小的结点连接到第一个结点后面,直到某一个链表为空。
  4. 当两个链表长度不一致时,也就是比较完成后其中一个链表为空,此时需要把另外一个链表剩下的元素都连接到第一个结点的后面。
    public Node mergeTwoLists(Node l1, Node l2) {
        Node temp = new Node(0);
        //保留头节点的引用
        Node head = temp;
        while (l1 != null && l2 != null) {
            if (l1.value < l2.value) {
                temp.next = l1;
                l1 = l1.next;
            } else {
                temp.next = l2;
                l2 = l2.next;
            }
            temp = temp.next;
        }
        if (l1 == null) {
            temp.next = l2;
        }
        if (l2 == null) {
            temp.next = l1;
        }
        return head.next;
    }

递归法

解题思路

  1. 对空链表存在的情况进行处理,假如 pHead1 为空则返回 pHead2 ,pHead2 为空则返回 pHead1。
  2. 比较两个链表第一个结点的大小,确定头结点的位置
  3. 头结点确定后,继续在剩下的结点中选出下一个结点去链接到第二步选出的结点后面,然后在继续重复(2 )(3) 步,直到有链表为空。
    public static Node mergeTwoList(Node head1, Node head2) {
        //递归结束条件
        if (head1 == null && head2 == null) {
            return null;
        }
        if (head1 == null) {
            return head2;
        }
        if (head2 == null) {
            return head1;
        }
        //合并后的链表
        Node head = null;
        if (head1.value > head2.value) {
            //把head较小的结点给头结点
            head = head2;
            //继续递归head2
            head.next = mergeTwoList(head1, head2.next);
        } else {
            head = head1;
            head.next = mergeTwoList(head1.next, head2);
        }
        return head;
    }

删除单链表中节点

给定一个单链表中的表头和一个等待被删除的节点。请在 O(1) 时间复杂度删除该链表节点。并在删除该节点后,返回表头。

示例

输入 1->2->3->4,和节点 3
输出 1->2->4。

解题思路

一般单链表删除某个节点,需要知道删除节点的前一个节点,则需要O(n)的遍历时间,显然常规思路是不行的。。
如果我们把删除节点的下一个结点的值赋值给要删除的结点,然后删除这个结点,这相当于删除了需要删除的那个结点。因为我们很容易获取到删除节点的下一个节点,所以复杂度只需要O(1)。若要删除的节点是尾节点,则我们只能通过遍历链表来删除尾节点,时间复杂度为O(n)。

将结点j的下一个结点完全复制给j
    j->data = j->next->data;
    j->next = j->next->next;
特殊情况为:
    当j为尾结点时,需要从头遍历
    当链表中只有一个结点时,需将头节点置NULL,
    平均时间复杂度为((n-1)*O(1) + O(n) )/n = O(1);

示例
单链表:1->2->3->4->NULL
若要删除节点 3 。第一步将节点3的下一个节点的值4赋值给当前节点。变成 1->2->4->4->NULL,然后将就 4 这个结点删除,就达到目的了。 1->2->4->NULL

157888313368047

    public static void deleteNode(Node head, Node targetNode) {
        if (null != head && null != targetNode) {
            return;
        }
        // 要删除的节点是尾节点
        if (null == targetNode.next) {
            Node currentNode = head;
            if (currentNode == targetNode) {
                // 链表中只有一个节点,删除该节点时应该同时将指向它的引用都置为null。
                targetNode = null;
                head = null;
                currentNode = null;
            } else {
                // 链表中节点数大于1
                while (currentNode != null && currentNode.next != targetNode) {
                    currentNode = currentNode.next;
                }
                if (null != currentNode) {
                    currentNode.next = null;
                }
            }
        }
        // 要删除的节点不是尾节点
        else {
            // targetNode的后继节点
            Node nextOfTargetNode = targetNode.next;
            // 将targetNode的后继节点的值复制到targetNode中,
            // 此时targetNode的值和它的后继节点的值相等,故删除掉targetNode的后继节点等价于删除targetNode
            targetNode.value = nextOfTargetNode.value;
            // 删除targetNode的后继节点
            targetNode.next = nextOfTargetNode.next;
            nextOfTargetNode.next = null;
        }
    }

从尾到头打印链表

输入一个链表,按链表值从尾到头的顺序返回一个 ArrayList 。

解题思路

初看题目意思就是输出的时候链表尾部的元素放在前面,链表头部的元素放在后面。这不就是 先进后出,后进先出 么。

什么数据结构符合这个要求?

157888316299736

    public static void printListReverseByStack(Node node) {
        //如果为空,直接返回
        if (node == null) {
            return;
        }
        Stack<Integer> stack = new Stack<Integer>();
        while (node != null) {
            //将数据放入栈中
            stack.push(node.value);
            //指针域指向下一个指针
            node = node.next;
        }
        while (!stack.isEmpty()) {
            System.out.print(stack.pop() + " ");
        }
    }

高效对有序数组/链表去重

给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度

说明:不要使用额外的数组空间,你必须在原地修改输入数组并在使用O(1)额外空间的条件下完成。

示例1:

给定数组 nums = [1,1,2],

函数应该返回新的长度2,并且原数组nums的前两个元素被修改为1,2。

你不需要考虑数组中超出新长度后面的元素。

示例2

给定数组 nums = [0,0,1,1,1,2,2,3,3,4],

函数应该返回新的长度5,并且原数组nums的前两个元素被修改为0,1,2,3,4。

你不需要考虑数组中超出新长度后面的元素。

解题思路

显然,由于数组已经排序,所以重复的元素一定连在一起,找出它们并不难,但如果毎找到一个重复元素就立即删除它,就是在数组中间进行删除操作,整个时间复杂度是会达到 O(N^2),效率较低。而且题目要求我们原地修改,也就是说不能用辅助数组,空间复杂度得是 O(1)。

其实,对于数组相关的算法问题,有一个通用的技巧:要尽量避免在中间删除元素,那我就先想办法把这个元素换到最后去

这样的话,最终待删除的元素都拖在数组尾部,一个一个 pop 掉就行了,每次操作的时间复杂度也就降低到 O(1) 了。

按照这个思路呢,又可以衍生出解决类似需求的通用方式:双指针技巧。具体一点说,应该是快慢指针。

我们让慢指针slow走左后面,快指针fast走在前面探路,找到一个不重复的元素就告诉slow并让slow前进一步。

这样当fast指针遍历完整个数组nums后,nums[0..slow]就是不重复元素,之后的所有元素都是重复元素

    private static int removeDuplicates(int[] array) {
        int n = array.length;
        if (n == 0) {
            return 0;
        }
        int slow = 0, fast = 1;
        while (fast < n) {
            if (array[slow] != array[fast]) {
                slow++;
                array[slow] = array[fast];
            }
            fast++;
        }
        return slow + 1;
    }

看下算法执行的过程:

157888324541602

再简单扩展一下,如果给你一个有序链表,如何去重呢?其实和数组是一模一样的,唯一的区别是把数组赋值操作变成操作指针而已:

    private static Node removeDuplicates(Node node) {
        if (node == null) {
            return null;
        }
        Node slow = node, fast = node.next;
        while (fast != null) {
            if (slow.value != fast.value) {
                slow.next = fast;
                slow = slow.next;
            }
            fast = fast.next;
        }
        slow.next = null;
        return node;
    }

对于链表去重,算法执行的过程是这样的:

157888333309833

判断一个链表是否为回文链表

给定一个链表的头结点是head,请判断该链表是否为回文结构,例如:

1->2->1,
返回true
1->2->2->1
返回true
1->2->3
返回false

进阶:你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

数组存储法

解题思路

遍历链表,用数组存下每个节点的值,然后从数组两头开始向中间遍历,是否相等
时间复杂度O(n),空间复杂度O(n)

栈辅助法

解题思路

使用一个辅助的栈结构,再使用两指针 ,一个快指针,一个慢指针,当快指针走完整个链表的时候,慢指针刚好来到链表中间位置。

  • 中间的位置,此时从中间的位置开始将剩余链表中的元素放入到栈中;然后重头开始遍历链表,栈中的元素也依次弹出。
  • 如果栈为空的时候,还没有出现元素不相等的情况的话,那么该链表是回文结构。
  • 相比较于第一种方法,这样的方法能够节省一半的空间,但是额外空间复杂度还是O(n)的

快慢指针法

解题思路

遍历一遍链表,使用两个指针,一个快指针,一个慢指针,,当快指针走完整个链表的时候,慢指针刚好来到链表中间位置。将右半边的链表反转,然后对比前半段和后半段的data值是否相同,如果相同的话则是回文;最后对右半部分链表进行反转,还原为最初的链表
只需要固定的若干个临时变量,不需要额外开辟空间
时间复杂度为O(n),空间复杂度为O(1)
代码如下:

    public static boolean isPalindrome(Node head) {
        //如果链表只有一个有效节点或者没有有效节点,return true
        if (head == null || head.next == null) {
            return true;
        }
        Node quick = head;
        Node slow = head;
        //快慢指针,快指针一次走两步,慢指针一次走一步
        while (quick != null && quick.next != null) {
            quick = quick.next.next;
            slow = slow.next;
        }
        //从slow开始反转后半段链表
        Node pre = null;
        Node p = slow;
        Node temp;
        while (p != null) {
            temp = p.next;
            p.next = pre;
            pre = p;
            p = temp;
        }
        //对比前半段和后半段的data值是否相同
        while (pre != null) {
            if (pre.value == head.value) {
                pre = pre.next;
                head = head.next;
            } else {
                return false;
            }
        }
        //返回true
        return true;
    }

有序链表的快速查询

链表,相信大家都不陌生,维护一个有序的链表是一件非常简单的事情,我们都知道,在一个有序的链表里面,查询跟插入的算法复杂度都是O(n),插入此处是O(n)的原因是因为链表是有序的,得先找到上级节点,然后插入过程本身是O(1)的,我们一般说的链表插入和删除复杂度还是O(1),这个别记混了。

158581568245846

我们能不能进行优化呢,比如我们一次比较两个呢?那样不就可以把时间缩小一半?

158581569341301

同理,如果我们4个4个比,那不就更快了?

158581570351021

跳表就是这样的一种数据结构,结点是跳过一部分的,从而加快了查询的速度。跳表跟数据库索引的实现类似,大家可以这么理解。跳表类似平衡二叉树,可以实现有序链表的快速查询,这样要插入和删除元素也很快

跳表的时间复杂度是O(logN),因为类似平衡二叉树。
我们还是以标准的跳表来分析,每两个元素向上提取一个元素,那么,最后额外需要的空间就是:
n/2 + (n/2)^2 + (n/2)^3 + … + 8 + 4 + 2 = n - 2
所以,跳表的空间复杂度是O(n)

【备注】
Redis是常用的分布式缓存结构,Redis选择使用跳表而不是红黑树来实现有序集合,原则何在?
首先,我们来分析下Redis的有序集合支持的操作:

  • 插入元素
  • 删除元素
  • 查找元素
  • 有序输出所有元素
  • 查找区间内所有元素

其中,前4项红黑树都可以完成,且时间复杂度与跳表一致。
但是,最后一项,红黑树的效率就没有跳表高了。

在跳表中,要查找区间的元素,我们只要定位到两个区间端点在最低层级的位置,然后按顺序遍历元素就可以了,非常高效。
而红黑树只能定位到端点后,再从首位置开始每次都要查找后继节点,相对来说是比较耗时的。

此外,跳表实现起来很容易且易读,红黑树实现起来相对困难,所以Redis选择使用跳表来实现有序集合。

posted @ 2022-07-26 22:19  Faetbwac  阅读(50)  评论(0编辑  收藏  举报