重学数据结构与算法(1~12)

01 | 复杂度:如何衡量程序运行的效率?

复杂度通常包括时间复杂度和空间复杂度,在具体计算时要注意以下几点:

  1. 它与具体常系数无关,O(n)和O(2n)表示的是同样的复杂度。
  2. 复杂度相加时,选择高阶作为结果,也就是说O(n^2)+O(n)和O(n^2)表示同样的复杂度。
  3. O(1)也是表示一个特殊的复杂度,即任务与算例个数n无关。

时间复杂度与代码的结构设计高度相关,空间复杂度与代码中的数据结构的选择高度相关。

02 | 数据结构:将“昂贵”的时间复杂度转换成“廉价”的空间复杂度

我们需要从时间复杂度和空间复杂度两个维度来考虑,常用的降低时间复杂度的方法有递归、二分法、排序算法、动态规划等;而降低空间复杂度的核心思路就是,能用低复杂度的数据结构能解决问题,就千万不要用高复杂度的数据结构。

程序优化的核心思路,梳理如下:

  1. 暴力解法。在没有任何时间、空间约束下,完成代码任务的开发。
  2. 无效操作处理。将代码中无效计算、无效存储剔除,降低时间或空间复杂度。这需要利用递归、二分法、排序算法、动态规划等常用的算法思维。
  3. 时空转换。设计合理的数据结构,完成时间复杂度向空间复杂度的转移。需要你对数据的操作进行细分,全面掌握常见数据结构的基础知识,围绕问题,有针对性的设计数据结构。

03 | 增删查:掌握数据处理的基本操作,以不变应万变

要想灵活使用数据结构,需要先弄清数据在代码中被处理、加工的最小单位动作,也就是数据结构的基本操作,有了这些动作之后,你就可以基于此去选择更合适的数据结构了。

字典的查找是通过键值对的匹配完成的,它可以在O(1)时间复杂度内,实现对数值条件查找。

设计合理的数据结构,需要从问题本身出发,我们可以采用这样的思考顺序:

  • 首先我们分析这段代码到底对数据先后进行了哪些操作。
  • 然后再根据分析出来的数据操作,找到合理的数据结构。

经过对代码的拆解,你会发现即便是很复杂的代码,它对数据的处理也只有三个基本操作,增、删、查。围绕这三个基本操作进行分析,就能得出解决问题的最优方案。常用的分析步骤如下:

  • 首先,这段代码对数据进行了哪些操作?
  • 其次,这些操作中,哪个操作最影响效率,对时间复杂度的损耗最大?
  • 最后,哪种数据结构最能帮助你提高数据操作的使用效率?

04 | 如何完成线性表下的增删查?

      什么是数据结构:即数据的组织方式。接下来学习第一个数据结构-----线程表,线性表是n个数据元素的有限序列,分顺序存储结构(比如数组)和链式存储结构(链表)。

单链表:一个节点存储一条数据,每个节点结构包括两个部分,第一是具体的数值,第二是指向下一个节点的指针。其特点(不足)是只能通过上一个节点的指针找到下一个节点,反过来是行不通的。

循环链表:对于一个单向链表,让最后一个元素的指针指向第一个元素,就得到了循环链表。

双向链表:把节点进行改造,除了有指向下一个节点的指针外,再增加一个指向上一个节点的指针,这样就得到了双向链表。

双向循环链表:对双向链表和循环链表进行融合,就得到了双向循环链表。

      线性表真正的价值在于,他对数据的存储方式是按照顺序的存储。如果数据的元素个数不确定,且需要经常进行数据的新增和删除时,那么链表会比较适合。如果数据元素大小确定,删除、插入操作并不多,那么数组比较适合些。

线性表的常见案例:

1.链表的翻转。给定一个链表,输出翻转后的链表。例如1->2->3->4->5,输出5->4->3->2->1.

 分析:单向链表,它的指针造成了它的数据通路有去无回,一旦修改了某个指针,后面的数据就会造成失联的状态。为了解决这个问题,我们需要构造三个指针prev、curr和next,对当前结点、以及它之前和之后的节点进行缓存,再完成翻转动作。具体如下图所示:

public class Node{

    public int value;
    public Node next;

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

/**
*1.遍历法
*目的:需要从第一个节点开始,遍历修改每个节点的next属性
*思路:遍历每一个节点,用临时变量next存储当前节点的下一个节点,以保证遍历能够持续,修*改当前节点的next属性,也即使其指向prev节点,
*用临时变量prev存储修改后的节点,
public static Node reverseList(Node node){
 
    Node prev = null;
    Node next = null;
    while(node != null){
        //先将当前节点的next赋值给临时变量,为了能保证循环继续,因为第二步要修改其next
        next = node.next;  
        //将上一步遍历后的节点设置给当前节点的next,实际上完成了一次翻转。
        node.next = prev;
        //然后将修改后的当前节点在赋值给prev,作为处理结果供下次遍历使用。
        prev = node;
        //将next节点赋值给当前节点,判断循环是否继续
        node = next; 
    }
    
    return prev;
    
}


/**
* 2.递归法:递归法是从最后一个Node开始,弹栈的过程中将指针顺序置换的。
* 递归实质上就是系统帮你压栈的过程,系统在压栈的时候会保留现场。
*/
public static Node reverse(Node node){

    if(node == null || node.next == null){
        return node;
    }
    
    Node temp = node.next;
    
    Node newNode = reverse(node.next);
    
    temp.next = node;
    node.next = null;
    
    return newNode;
}

我看看一个递归过程1->2->3->4,
1)程序到达 Node newNode = reverse(node.next);时进入递归。
2)假设此时递归到达了3节点,此时node = 3,temp = 4;
3)执行reverse(node.next),传入node为4的节点,返回node为4的节点。
4)接下来就是弹栈过程,执行temp.next = node;相当于4->3.
5) 执行node.next = null,即把3节点指向4节点的指针断掉。
6)返回新链表的头结点newNode,继续恢复2节点的压栈现场。最后完成整个链表的翻转。

2.给定一个奇数个数的链表,查找这个链表中间位置的结点的数值。

分析:该问题也是利用了链表的长度无法直接获取的不足做文章,一个暴力的解决方法是,先通过一次遍历计算链表的长度,这样我们就知道了中间结点的位置,然后再次遍历,找到这个位置的结点。初次之外,还有个巧妙的方法,就是利用快慢指针进行处理,其中快指针每次循环向后跳转两次,慢指针每次向后跳转一次。

public Node searchMid(Node head){

    Node fast = head;
    Node slow = head;

    while(fast != null && fast.next !=null && fast.next.next != null){

        fast = fast.next.next;
        slow = slow.next;
    }
    
    return slow;

}

3.判断链表是否有环。如下所示,这就是一个有环链表:

链表的快慢指针方法,在很多链表操作的场景下都非常适用,对于这个问题也一样。

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

    return false;

}

//找出环的入口:找到快慢指针相遇的位置,然后让慢指针从头开始走(每次跳一步),让快指针从相遇位置继续走,每次跳一次,再次相遇的位置就是环的入口。
//求环上节点数:两指针相遇后,让慢指针不动,快指针每次走一步,计数加1,当再次遇到慢指针后说明走了一圈了。

05 |  栈:后进先出的线性表,如何实现增删查?

      栈是一种特殊的线性表,栈与线性表的不同体现在增和删的操作。具体而言,栈的数据节点必须是后进先出。从宏观上将,栈的这种操作更受限制,为何还要使用?因为数组和链表的操作过于灵活,这意味着,它们过多的暴露了可操作的接口,当数据量很大的时候就会出现一些隐藏的风险,一旦发生代码bug或者受到攻击,就会给系统带来不可预知的风险。虽然栈限定降低了操作的灵活性,但使得栈在处理只涉及一端新增和删除数据的问题时效率更高。

     不管是顺序栈还是链栈,数据的新增、删除、查找与线性表的操作原理极为相似,时间复杂度完全一样,都依赖当前位置的指针来进行数据对象的操作。区别仅仅在于新增和删除的对象,只能是栈顶的数据节点。

栈的案例:

1.给定一个只包括'(',')','{','}','[',']'的字符串,判断字符串是否有效。有效的字符串需满足:左括号必须与同类型的右括号匹配,左括号必须以正确的顺序匹配。例如{[()()]}是合法的,而{([)]}是非法的。

分析:在匹配括号是否合法时,左括号是从左到右依次出现,而右括号则需要按照”后进先出“的顺序依次与左括号匹配。因此实现方案就是按照栈的进出来完成。

思路:从左到右遍历字符串,当出现左括号时,压栈,当出现右括号时,出栈。并且判断当前右括号和被出栈的左括号是否是相互匹配的一对。如果不是,则字符串非法。当遍历完成后,如果栈是空的,则合法。

public static void main(String[] args) {
    String s = "{[()()]}";
    System.out.println(isLegal(s));
}
private static boolean isLeft(char c) {
    if (c == '{' || c == '(' || c == '[') {
        return true;
    } else {
        return false;
    }
}
private static boolean isPair(char p, char curr) {
    if ((p == '{' && curr == '}') || (p == '[' && curr == ']') || (p == '(' && curr == ')')) {
        return true;
    } else {
        return false;
    }
}


private static String isLegtal(String s){
    
    Stack stack = new Stack();
    for(int i = 0; i < s.length; i++){
        if(isLeft(s.charAt(i))){
            stack.push(s.charAt(i));
        }else{
            if(stack.empty()){
                return "非法";
            }else{
                char left = (char)stack.pop();
                if(isPair(left,s.charAt(i))){
                    continue;
                }else{
                    return "非法";
                }
            }
        }
    }
    
    if(stack.empty){
        return "合法";
    }else{
        return "非法";
    }   

}

2.浏览器的页面访问都包含了后退和前进功能,利用栈如何实现?

分析:为了支持前进、后退功能,利用栈来记录用户历史访问页面的顺序是一个不错的选择,此时需要维护两个栈,分别用来支持后退和前进。当用户访问了一个新页面,则对后退栈进行压栈,当用户后退了一个页面时,则后退栈进行出栈,同时前进栈执行压栈。当用户前进了一个页面,则前进栈出栈,同时后退栈压栈。

总结:栈继承了线性表的优先与不足,是个限制版的线性表,只允许在栈顶进行进出。当你面对的问题需要高频使用新增、删除操作且新增和删除操作的数据执行顺序具备后来居上的相反关系时,栈就是个不错的选择。例如浏览器的前进、后退,括号匹配等。

06 | 队列:先进先出的线性表,如何实现增删查?

      队列,先进先出,只能在队列的末端进行新增,只能在始端进行删除。与线性表和栈一样,队列也存在两种存储方式,即顺序队列和链式队列。顺序队列依赖数组来实现,其中的数据在内存中也是顺序存储;而链式存储,则依赖链表来实现,其中的数据依赖每个结点的指针互联,在内存中并不是顺序存储。链式队列,实际上就是只能尾进头出的线程表的单链表。

       我们将队头指针指向链队列的头结点,队尾指针指向终端结点。不管是哪种实现方式,一个队列都依赖队头(front)和队尾(rear)两个指针进行唯一确定。当队列为空是,front和rear都指向头结点,如图:

顺序队列的增删查:

     对于一个顺序队列的数组来说,会设置一个front指针来指向队头,并设置一个rear指针指向队尾。当我们不断地进行插入删除操作时,头尾两个指针都会不断的向后移动。

     为了实现一个有k个元素的顺序存储队列,,我们需要建立一个长度比k大的数组,以便把所有的队列元素存储在数组中。队列的新增操作,就是利用rear指针在队尾新增一个数据元素,这个过程不会影响其他数据,时间复杂度为O(1);

     队列的删除操作与栈不同,队列的出口在队头,即下标为0的位置,当利用front指针删除一个数据时,队列中剩余的元素都需要,队列中剩余的元素都需要向前移动一个位置,以保证队列头部下标为0的位置不为空,此时的时间复杂度就变成O(n)了。

     循环队列进行新增数据元素时,首先判断队列是否为满。如果不满,则可以将新元素赋值给队尾,然后将rear指针向后移动一个位置,如果已经排到队列最后的位置,则rear指针重新指向头部;循环队列在删除时,需要判断队列是否为空,然后将队头元素赋值给返回值,front指针向后移动一个位置。如果已经排到队列的最后位置,则rear指针重新指向头部。

    循环队列为空和为满的时候,front指针和rear指针都相等,怎么区分呢,常用的方法是,设置一个标志变量flag来区分队列是空还是满。

链式队列的数据操作

     链式队列就是一个单链表,同时增加了front指针和rear指针,和单链表一样,通常会增加一个头结点,并令front指针指向头结点,头结点不存储数据,只是用来辅助标识。

    链式队列进行新增数据操作时,将拥有数值X的新结点s赋值给原队列尾结点的后继,即rear.next,然后把当前的s设置为队尾结点,指针rear指向s。如图所示:

     当链式队列进行删除数据操作时,实际删除的是头结点的后继节点。这是因为头结点仅仅是用来标识队列,并不存数据。因此,出队列的操作,就需要找到头结点后继,这就是要删除的节点。接着让头结点指向要删除节点的后继。值得一提的是,如果这个链表除了头结点只剩一个元素,那么删除仅剩的一个元素后,rear指针就变成野指针了。这个时候,需要让rear指针指向头结点。头结点似乎不影响增删的操作,那么为何队列还特别强调要有头结点呢?这主要是为了防止删除最后一个有效节点后,front指针和rear指针变成了野指针,导致队列没有意义了。有了头结点后,哪怕对了为空,头结点依然存在,能让front指针和rear指针依然有意义。

队列的查找

        对于队列的查找操作,不管是顺序还是链式,队列都没有额外的改变。跟线性表一样,它也需要遍历整个队列来完成基于某些条件的数值查找,因此时间复杂度也是O(n)。

队列的案例:用队列来解决约瑟夫环的问题,约瑟夫环是一个数学的应用问题,具体为,已知n个人(以编号1,2,3....n分别表示)围坐在圆桌周围。从编号为k的人开始报数,数到m的那个人出列;他的下一个人又从1开始报数,数到m的那个人又出列;依次规律重复下去,直到圆桌周围的人全部出列。这个问题的输入变量就是n和m,即n个人和数到m的出列的人,输出的结果,就是n个人出列的顺序。

解法如下:

  1. 先把所有人放入循环队列中,注意这个循环队列的长度要大于或等于n。
  2. 从第一个人开始依次出队列,出队列一次则计数变量i自增,如果i比m小,则还需要再入队列。
  3. 直到i等于m的人出队列时,就不用再让这个人进队列了。而是放入一个用来记录出队列顺序的数组中。
  4. 直到数完n个人为止。当队列为空时,则表示队列中的n个人都出队列了,这时结束队列循环,输出数组内记录的元素。
public static void main(String[] args){
    ring(10,5);
}

public static void ring(int n, int m){


    LinkedList<Integer> q = new LinkedList<Integer>();
    
    for(int i = 1;i<=n ; i++){
        q.add(i);
    }

    int k = 2;
    int element = 0;
    int i = 0;
    for(,i<k,i++){
        element = q.poll();
        q.add(element);
    }
    i = 1;
    while(q.size()>0){

        element = q.poll();

        if(i<m){
            q.add(element);
            i++;
        }else{
            i=1;
            System.Out.println(element);
        }
    }
}

这样,我们就通过循环队列解决了约瑟夫环的问题。

总结:队列也是继承了线程表的优点与不足,是加了限制的线性表,队列的删和增的操作只能在这个线性表的头和尾进行。在时间复杂度上,循环队列和链式队列的新增、删除操作都为O(1)。而在查询操作上都需要O(n)的时间复杂度。在空间性能方面,循环队列必须有一个固定的长度,因此存在存储元素数量和空间的浪费,而链式队列不存在这种问题,所以在空间上,链式队列更为灵活一些。通常情况下,在可以确定队列长度值时,建议使用循环队列,无法确定长度时,建议使用链式队列。队列具有先进先出的特点,在面对数据处理顺序非常敏感的问题时,队列一定是一个不错的技术选型。

07 | 数组:如何实现基于索引的查找?

   数组是数据结构中最基本结构,它可以用来存放若干个相同类型的数据元素,并且其在内存中使连续存放的。在高级语言中都已经封装了响应的函数方法,例如,新增系列的push(),unshift(),concat(),删除系列的pop()、shift()和slice(),查找系列的indexOf()和lastIndexOf()等等。

数组增删查操作的特点:

  • 增加:若插入数据在最后,时间复杂度为O(1),如果插入位置在中间某位置,则时间复杂度为O(n)。
  • 删除:同增加类似,操作在最后,时间复杂度为O(1),操作在中间,时间复杂度为O(n)。
  • 查找:如果是按索引查找,时间复杂度为O(1),如果按照数值查找,需要遍历数组,时间复杂度为O(n)。

 实际上数组是一种相当简单的数据结构,其增删查的时间复杂度相对于链表来说整体上更优,那么链表存在的价值是什么的?

  • 首先,链表的长度是可变的,数组的长度是固定的,在申请数组的长度时就已经在内存中开辟了若干个空间。如果没有引用ArrayList时,数组申请的空间永远是我们在估计了数据的大小后才执行的,所以在后期维护中也相当麻烦。
  • 其次,链表不会根据有序位置存储,进行插入数据元素时,可以用指针来充分利用内存空间。数组是有序存储的,数组是有序存储的,如果想充分利用内存的空间就只能选择顺序存储,而且需要在不取数据,不删数据的情况下才能进行。

数组的案例:假设数组存储了5个评委对1个运动员的打分,且每个评委的打分都不相等,去掉一个最高分,去掉一个最低分,计算三个剩余样本的平均分。要求:不允许再开辟O(n)空间复杂度的复杂数据结构。

解析:要求删除最高分和最低分,并不允许再开辟复杂空间,因此,我们只能在原数组中找到最大值和最小值,然后将删除后的数组,计算平均值。

public void getScore() {
    int a[] = { 2, 1, 4, 5, 3 };
    max_inx = -1;
    max_val = -1;
    min_inx= -1;
    min_val = 99;
    for (int i = 0; i < a.length; i++) {
        if (a[i] > max_val) {
        max_val = a[i];
        max_inx = i;
    }
    if (a[i] < min_val) {
        min_val = a[i];
        min_inx = i;
    }
    }

    inx1 = max_inx;
    inx2 = min_inx;
    if (max_inx < min_inx){
        inx1 = min_inx;
        inx2 = max_inx;
    }
    for (int i = inx1; i < a.length-1; i++) {
        a[i] = a[i+1];
    }
    for (int i = inx2; i < a.length-1; i++) {
        a[i] = a[i+1];
    }
    sumscore = 0;
    for (int i = 0; i < a.length-2; i++) {
        sumscore += a[i];
	}
    avg = sumscore/3.0;
    System.out.println(avg);
}

08 | 字符串:如何正确回答面试中高频考察的字符串匹配算法?

字符串的存储结构与线性表相同,也有顺序存储和链式存储两种:

  1. 顺序存储结构:是用一种地址连续的存储单元来存储串中的字符序列,一般是用定长数组来实现。有些语言会在串值后面加一个不计入串长度的结束标记符,比如\0来表示串值的终结。
  2. 链式存储结构:与线程表相似,但由于串结构的特殊性(结构中的每个元素数据都是一个字符,就会造成很大的空间浪费,因此,一个结点可以考虑存放多个字符,如果最后一个结点未被占满时,可以使用”#“或者其他非串字符补全)。每个结点设置字符数量的多少,与串的长度、可以占用的字符空间以及程序实现的功能相关。

字符串匹配问题1:从主串s = "goodgoogle"中找到是否存在子串 t = "google",假设主串长度n,子串长度为m,n肯定是大于m。思考逻辑如下:

  1. 外层循环找出主串中与子串第一个字符相等的字符的位置,外层循环的循环的循环次数最多为n-m+1次。
  2. 找出主串匹配的位置后,开始遍历子串,看是否跟主串后续匹配。若出现不匹配,结束内存循环,继续在外层循环里找与子串第一个字符相同的字符;若内存循环遍历到最后一位后,说明主串包含子串。
  3. public void s1(){
    
        String s = "goodgoogle";
        String t = "google";
    
        int isFind = 0;
        for(int i = 0; i < s.length -t.length +1;i++){
            if(s.charAt(i) == t.charAt(0)){
                int jc = 0;
                for(int j = 0;j<t.length();j++){
                    if(s.charAt(i+j)!=t.charAt(j)){
                        break;
                    }
                    jc = j;
                }
                if(jc == t.length-1){
                    isFind = 1;
                }
            }
        }
        Log.d("result",isFind+"");
    }

     

字符串匹配案例2:查找两个字符串中最大公共子串。假设有且仅有一个最大公共子串。比如,输入a="13452439",b="123456",输出的最大子串为“345”。

分析:改问题其实可以用动态规划来解决,后续课程会讲到,我们暂时还沿用前面的匹配算法。假设字符串a的长度为n,字符串b的长度为m,可见时间复杂度是m和n的函数。

  • 首先,你需要对字符串a和b找到第一个共同出现的字符,这跟前面讲到的匹配算法在主串中查找第一个模式串字符一样。
  • 然后,一旦找到第一个匹配的字符之后,就可以同时在a和b中继续匹配它后续的字符是否相等。这样a和b中互相匹配的子串都会被访问一遍。全局还要维护一个最长子串及其长度的变量,就可以完成了。
  • 从代码结构来看,第一步需要两层循环去查找共同出现的字符,这就是O(mn)。一旦找到了共同出现的字符,还需要再查找共同出现的字符串,这也就是又嵌套了一层循环。可见最终的时间复杂度是O(nmm),也即O(nm^2)。
public void s2(){

    String a = "123456";
    String b = "13452439";

    String maxSubStr = "";
    int max_len = 0;

    for(int i = 0;i<a.length();i++){
        for(int j=0;j<b.length();j++){
            
            if(a.chatAt(i) == b.charAt(j)){
                
                for(int m = i,n = j; m<a.length()&&b.length(); m++,n++){
                    if(a.chatAt(m) != b.chatAt(n)){
                        break;
                    }
                    if(max_len<m-i+1){
                        max_len = m-i+1;
                        maxSubStr = a.substring(i,m+1);
                    }
                }
            }
        }
    }

    System.out.println(maxSubStr);
}

总结:字符串的逻辑和线性表极为相似,区别仅在于串的数据对象约束为字符集,但字符串的基本操作和线程表有很大差别:

  1. 在线性表的基本操作中,大多以“单个元素”最为操作对象。
  2. 在字符串的基本操作中,通常以“串的整体”作为操作对象。
  3. 字符串的删除操作和数组很像,复杂度也一样。但字符串的查找操作就复杂多了,他是参与面试、笔试常考的内容。

09 | 树和二叉树:分支关系与层次结构下,如何实现有效增删查?

树:树是由结点和边组成的,不存在环的一种数据结构。树满足递归定义的特性,也就是说,如果一个数据结构是树的结构,那么剔除掉结点后,得到的若干个子结构也是树,通常称作子树。要理解根节点(没有父节点)、子节点、兄弟节点、叶子节点(没有子节点)的定义。

二叉树:是一种特殊的树,每个节点最多有两个分支,也即最多有两个子节点,分别称作左子节点和右子节点。

在二叉树中,有下面两个特殊的类型,如图所示:

  • 满二叉树:除了叶子节点外,所有节点都有2个子节点。
  • 完全二叉树:定义为除了最后一层以外,其它层的节点个数都达到最大,并且最后一层的叶子节点都靠左排列。

你可能会感到困惑,完全二叉树看上去并不完全,但为啥这样称呼它呢?这其实是和二叉树的存储有关系。存储二叉树有两种办法,一种是基于指针的链式存储法,另一种是基于数组的顺序存储法。

  • 链式存储法:就是想像链表一样,每个节点有三个字段,一个存数据,另外两个分别存放指向左右节点的指针,如下图:

     

  • 顺序存储法:就是按照规律把节点存放在数组里,为了方便计算,我们会约定把根节点放在下标为1的位置。随后B节点在下标为2的位置,C在3的位置,依次类推。我们发现如果节点X的下标为i,则X的左节点总是存放在2*i的位置,X的右子节点总是存放在2*i+1的位置。

     

之所以成为完全二叉树,是从存储空间利用率的角度来看的。对于一颗完全二叉树而言,仅仅浪费了下标为0的存储位置。而如果是一颗非完全二叉树,则会浪费大量的存储空间。如下图所示:

我们看到上图的 非完全二叉树,它即需要保留出5和6的位置,还需要保留5和6的两个子节点10、11、12、13的位置。这样的话,没有完全利用好数组的存储空间。

数的基本操作:

      遍历一棵树,有三种非常经典的方法,分别是前序遍历、中序遍历、后续遍历。这里的序指的是父节点的遍历顺序,前序就是先遍历父节点,中序就是中间遍历父节点,后序就是最后遍历父节点。不管是哪种遍历,都是通过递归调用完成的。如下图所示:

  • 前序遍历:对树中的任意节点来说,先打印这个节点,然后前序遍历他的左子树,最后前序遍历它的右子树。
  • 中序遍历:对树中的任意节点来说,先中序遍历它的左子树,然后打印这个节点,最后中序遍历它的右子树。
  • 后序遍历:对树中的任意节点来说,先后序遍历它的左子树,然后后序遍历它的右子树,最后打印它本身。

//先序遍历
public static void preOrderTraverse(Node node){
    if(node == null)
        return;
    System.out.print(node.data +"");
    preOrderTraverse(node.left);
    preOrderTraverse(node.right);
}

//中序遍历
public static void inOrderTraverse(Node node){
    if(node == null)
        return;
    inOrderTraverse(node.left);
    System.out.print(node.data + "");
    inOrderTraverse(node.right);
}

//后序遍历
public static void postOrderTraverse(Node node){
    if(node == null)
        return;
    postOrderTraverse(node.left);
    postOrderTraverse(node.right);
    System.out.print(node.data + "");
}

二叉树遍历的过程中,每个节点都被访问了一次,时间复杂度为O(n)。接着,在找到位置后,执行增加和删除操作时我们只需要针对指针建立连接关系就可以了。抛开遍历的时间复杂度,真正执行增加和删除操作的时间复杂度是O(1)。

二叉查找树的特性

二叉查找树(也称作二叉搜索树)具备以下几个特性:

  1. 在二叉查找树中的任意一个节点,其左子树中的每个结点的值,都要小于这个结点的值。
  2. 在二叉查找树中的任意一个结点,其右侧数中的每个节点的值,都要大于这个结点的值。
  3. 在二叉查找树中,会尽量规避两个结点数值相等的情况。
  4. 对二叉查找树进行中序遍历,就可以输出一个从小到大的有序数据队列。如下图所示,中序遍历的结果就是10、13、15、16、20、21、22、26。

     

二叉查找树的查找操作

在利用二叉查找树执行查找时,我们可以进行以下判断:

  • 首先判断根节点是否等于要查找的数据,如果是就返回。
  • 如果根节点大于要查找的数据,就在左子树中递归执行查找动作,直到叶子结点。
  • 如果根节点小于要查找的数据,就在右子树中递归执行查找动作,直到叶子结点。

这样的“二分查找”,所消耗的时间复杂度就可以降低为O(logn)。

二叉查找树的插入操作

      从根节点开始,如果插入的数据比根节点的数据大,且根结点的右子节点不为空,则在根节点的右子树中继续尝试执行插入操作。直到找到为空的子节点执行插入动作。

      二叉查找树插入数据的时间复杂度是O(logn)。但这并不意味它比普通二叉树要复杂,原因就在于这里的时间复杂度更多是消耗在了遍历数据区找到这个位置上,真正执行插入动作的时间复杂度仍然是O(1)。

     二叉查找树的删除比较复杂,因为删除完某个结点后的树,仍然要满足二叉查找树的性质。

  1. 如果要删除叶子结点,则直接删除,将其父结点指针指向null即可。
  2. 如果要删除的节点只有一个子节点,只需要将其父结点指向的子节点的指针换成其子节点的指针即可。
  3. 如果要删除的节点有两个子结点,则有两种可行的操作方式。
    1)找到这个节点的左子树中最大的结点,替换要删除的结点。
    2)找到这个结点的右子树中最小的结点,替换要删除的结点。

练习题:给定一棵树,按照层次顺序遍历并打印这棵树。例如:

则打印16、13、20、10、15、22、21、26。

public static void levelTraverse(Node root){

    if(root == null){
        return;
    }

    LinkedList<Node> queue = new LinkedList<Node>();
    Node current = null;
    queue.offer(root);   //根节点入队
    
    while(!queue.isEmpty()){ //只要队列中有元素,就可以一直执行,巧妙地利用了队列的特性
        
        current = queue.poll();
        System.Out.println("===>"+current.data);
        //左子树不为空,入队
        if(current.leftChild != null){
            queue.offer(current.leftChild);
        }
        //右子树不为空,入队
        if(current.rightChild != null){
            queue.offer(current.rightChild);
        }
    }  
}

10 | 哈希表:如何利用好高效率查找的“利器”?

我们先后学习的线程表、数组、字符串和树,他们对数据处理各有千秋:

  • 线程表中的栈和队列对增删有严格的要求,它们会更关注数据的顺序。
  • 数组和字符串需要保持数据类型的统一,并且在基于索引的查找上会更有优势。
  • 树的优势则体现在数据的层次结构上。

它们普遍存在缺陷是按照数据值条件的查找,都需要对全部数据或者部分数据进行遍历。而哈希表可以省去数据比较的过程,从而进一步提升数值条件查找的效率。

哈希表的核心思想:

     哈希表采用了函数映射的思想,将记录的存储位置与记录的关键字关联起来。这样的设计方式,能够快速定位到要查找的记录,而且不需要与表中记录的关键字比较后再进行查找。

      哈希函数存在哈希冲突,需要在设计哈希函数时进行规避,本质上,哈希冲突只能尽可能减少,不能完全避免。这是因为,输入数据的关键字是个开放集合。只要输入的数据量够多,分布够广,就完全有可能发生冲突的情况。因此,哈希表需要设计合理的哈希函数,并且对冲突有一套处理机制。

常用的设计哈希函数的方法:

  1. 直接定制法:哈希函数为关键字到地址的线性函数。如,H(key) = a*key + b。a、b是设置好的常数。
  2. 数字分析法:假设关键字集合中的每个关键字都是由s位数字组成(k1,k2...ks),并从中提取分布均匀的若干位组成哈希地址。上面张一、张二、张三、张四的手机号信息存储,就是使用的这种方法。
  3. 平方取中法:如果关键字的每一位都有某些数字重复出现,并且频率很高,我们就可以先求关键字的平方值,通过平方扩大差异,然后取中间几位作为最终存储地址。
  4. 折叠法:如果关键字的位数很多,可以将关键字分割为几个等长的部分,取它们的叠加和的值(舍去进位)作为哈希地址。
  5. 除留余数法:预先设置一个数p,然后对关键字进行取余运算。即地址为 key mod p。

如何解决哈希冲突

上面这些常用方法都有可能产生哈希冲突,常用的解决哈希冲突的方式有两种:

  1. 开放定址法:即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。常用的探测方法是线性探测法
  2. 链地址法:将哈希地址相同的记录存储在一张线性链表中。

哈希表的不足:哈希表没有顺序概念,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素,在数据处理顺序敏感的问题时,选择哈希表并不是好的处理方法;同时,哈希表中的key不允许key是重复的,在重复性非常高的数据中,哈希表也不是一个号的选择。

哈希表的基本操作

     在很多高级语言中,哈希函数、哈希冲突都已经在底层完成了黑盒化处理,是不需要开发者自己设计的,也就是说,哈希表完成了关键字到地址的映射,可以在常数级时间复杂度通过关键字查找到数据。

     哈希表中的增加和删除操作,不涉及增删后对数据的挪移问题(数组需要考虑),处理就可以了。哈希表的查找过程是,对于给定的key,通过哈希函数计算哈希地址H(key),哈希表的查找细节过程比较麻烦,但高级语言已经做了黑盒化处理,开发者实际上不需要去开发底层代码,只需要调相关的函数就可以了。

案例:设计一个在下系统,可以实时接收用户提交的字符串类型的关键字,并实时返回用户累计至今这个关键字的提交次数。

if(d.containKey(key_str)){

    d.put(key_str, d.get(key_str)+1);
}else{

    d.put(key_str , 1);
}

System.out.println(d.get(key_str));

总结:哈希表在我们平时数据处理操作中有着很多独特的优点,不论哈希表中有多少数据,查找、插入、删除只需要接近常量的时间,即O(1)的时间级。实际上,哈希表的运算非常快,如果需要在一秒内查找上千条记录通常使用哈希表(例如拼写检查器),哈希表的操作明显比树快,树的操作需要O(n)级,不仅速度快,变成也相对容易。如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。

11 | 递归:如何利用递归求解汉诺塔问题?

递归的两层含义:

  1. 递归问题必须可以分解为若干个规模较小、与原问题形式相同的子问题,并且这些子问题可以用完全相同的解题思路来解决;
  2. 递归问题的演化过程是一个对原问题从大到小进行拆解的过程,并且会有一个明确的终点(临界点)。一旦原问题到达了这个临界点,就不用再往更小的问题上拆解了。最后,从这个临界点开始,把小问题的答案原路返回,原问题便得以解决。

      简而言之,递归的基本思想就是把规模大的问题转化为规模小的相同子问题来解决。在函数实现时,因为小问题和大问题的实现是一样的,大问题的解决方法和小问题的解决方法也是同一个方法,这就产生了函数调用自身的情况,这也正是递归的定义所在。格外重要的是,这个解决问题的函数必须有明确的结束条件,否则就会导致无限递归的情况。总结起来,递归的实现包含两部分,一个是递归主体,另一个是终止条件。

当一个问题同时满足以下2个条件时,就可以使用递归的方法求解:

  1. 可以拆解为除了数据规模以外,求解思路完全相同的子问题。
  2. 存在终止条件。

例如,二叉树的中序遍历我们采用递归的思想:

//中序遍历
public static void inOrderTraverse(Node node){
    if(node == null)
        return;
    inOrderTraverse(node.left);
    System.out.print(node.data + "");
    inOrderTraverse(node.right);
}

递归的算法思想:写出递归代码的关键在于,写出递推公式和找出终止条件。也就是说我们需要:首先找到将大问题分解成小问题的规律,并基于此写出递推公式;然后找出终止条件,就是当找到最简单的问题时,如何写出答案;最终将递推公式和终止条件翻译成实际代码。

递归的案例:汉诺塔问题,有三根柱子,x、y、z,其中x上面有从小叠到大的n个圆盘,现要求将x柱子上的圆盘移动到z柱子上去。要求是,每次只能移动一个盘子,且大盘子不能被放放在小盘子上面。求移动步骤。

分解:

  1. 把从小到大的n-1个盘子,从x移动到y;
  2. 接着把最大的一个盘子,从x移动到z;
  3. 再把从小到大的n-1个盘子,从y移动到z。
public static void main(String[] args){
    String x = "x";
    String y = "y";
    String z = "z";
    hanio(3,x,y,z);
}

public void hanio(int n , String x,String y,String z){

    if(n<1){
        System.out.println("汉诺塔的层数不能小于1");
    }else if(n==1){
        System.out.println("移动:"+ x + "->" + z);
        return;
    }else {
        hanio(n-1,x,z,y);
        System.out.println("移动: " + x + " -> " + z);
        hanio(n - 1, y, x, z);
    }

}

总结:递归的应用非常广泛,之后要讲的很多数据结构和算法的编程实现都要用到递归,例如,分治策略、快速排序等。

12 分治:如何利用分治法完成数据查找?

     前一课时我们学习里递归的思想,它是一种函数自我调用缩小问题规模的方法。而今天学习的分治法的思想是分而治之,把一个大规模、高难度的问题分解为若干个小规模、低难度的小问题。分支法的应用比较广泛,很多高效率的算法都是以分治法作为其基础思想,例如排序算法中快速排序和归并排序。

分治法的使用:

当采用分治法时,一般原问题都需要具备以下几个特征:

  1. 难度在降低,即原问题的解决难度,随着数据的规模的缩小而降低。该特征绝大多数都是满足的。
  2. 问题可分,原问题可以分解为若干个规模较小的同类型问题,这是应用分治的前提。
  3. 解可合并,利用所有子问题的解,可合并出原问题的解。这个特征很关键,能否利用分治法完全取决于这个特征。
  4. 相互独立,各个子问题之间互相独立,某个子问题的求解不会影响到另一个子问题。

下面给出二分查找的例子,二分查找的前提是在有序数列里去查找,时间复杂度为O(log n)。

public static void main(String[] args){

    int targetNumb = 8;
    int[] arr = {1,2,3,4,5,6,7,8,9,10};
    int middle = 0;
    int low = 0;
    int high = arr.length - 1;
    int isFind = 0;
    while(low <= high){

        middle = (high + low)/2;
        if(arr[middle] == targetNumb){
            isFind = 1;
            break;
        }else if(arr[middle] > targetNumb){
            
            high = middle -1;
        }else{
            low = middle +1;
        }
    }

    if(isFind == 0){
        Log.d("数组包含"+targetNumb);
    }


}

基于此例子,可以对其进行一些经验和规律的总结,这些经验会辅助大家在面试时找到解题思路:

  1. 二分查找的时间复杂度是O(logn),这也是分治法普遍具备的特性。当你面对某个代码题,而且约束了时间复杂度是O(logn),或者O(nlogn)时,可以想一下分治法是否可行。
  2. 二分查找的循环次数并不确定。一般是达到某个条件就跳出循环。因此,编码的时候,多数会用while循环加break跳出的代码结构。
  3. 二分查找的原问题必须是有序的。因此,当你在一个有序数据环境中处理问题时,可以考虑分治。相反,如果原问题中的数据并不是有序的,则使用分治法的可能性就会很低了。

总结:分治法经常会在海量数据处理中,这也是它显著区别于遍历查找方法的优势。在面对陌生问题时,需要注意原问题的数据是否有序,预期的时间复杂度是否带有log n项,是否可以通过小问题的答案合并出原问题的答案。如果这些先决条件都满足,你应该第一时间想到分治法。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值