Part 1 新手小白数据结构刷题的理解

顺序表与单链表LeetCode题解题思路

目录

1.顺序表

(1)Leetcode 27题(删除操作的理解:覆盖)

(2)Leetcode 26题(删除操作的理解:覆盖)

 2.单链表

         (1)Leetcode 203题(删除操作的理解:连接)

         (2)Leetcode 206题(反转操作的理解:cur->next->next = cur)

         (3)Leetcode 876题(查找操作的理解:单向访问)

                  (4)Leetcode 02.02题 (查找操作的理解:单向访问)


1.顺序表

(1)Leetcode 27题(删除操作的理解:覆盖)

我们先不根据题目要求解题:1.原地 2.顺序可以改变

给了这样一个顺序表(数组实现),要求删除2,我们要充分理解顺序表的删除操作其实本质是覆盖操作,而且这个覆盖可以是:1.比如删除第二个位置的2,我们把后面的五个元素都往前挪动进行覆盖;2.也可以是比如删除第四、五个位置的2,我们通过指针把3放到第四个位置,5放到第5个位置,这样减少了数据的挪动量和次数。

好,下面来写解题思路:(任何题目都是从最简单无脑的方式想的,慢慢完善)

思路一:(最无脑最粗暴的办法)

我们用变量i从0开始遍历一遍数组,当数组元素的值=val时,我们进行顺序表的删除操作(也就是把后面所有的元素都往前挪动一个单位,覆盖掉val,显然这样的操作非常消耗时间复杂度)。这样遍历(循环结束条件i<数组原始长度)一遍数组把等于val的值都删除了,但是要求返回于val不同的元素个数(也就是新数组的长度),所以我们需要一个新的变量来记录有数据部分(新数组)的长度;用变量length=0记录,每次判断数组元素的值不等于val时再++,这样length记录的就是新数组的长度。

代码一:分析:显然每次遇到等于val的元素就把后面所有元素都进行挪动非常消耗时间,当数据量特别大的时候,就会超出题目要求时间限制。时间复杂度O(N^2)(i变量先遍历一遍完整数组,而每次挪遇到val都要挪动后面的数据,假设最坏情况是所有元素都是val,那么依次需要挪动numsSize-1,numsSize-2,……,1;加起来就是挪动了1/2(n)(n-1)次,所以挪动消耗的时间复杂度是O(N^2);总的时间复杂度是O(N+1/2(N)(N-1)))也就是O(N^2))空间复杂度O(1)

优化:主要拖延时间的点是挪动数据,我们不要每次都挪动后面的大片元素,只要把不等于val的值都挪到左边部分,val被覆盖或者被挤到后边部分即可。我们可以想到指针操作可以一个一个元素进行操作,这样就不需要一次进行大幅度的挪动。所以我们有了思路二。

思路二:(双指针单向单次挪动法)(前后指针)

我们定义两个变量left,right,用left留在左边作为存放不等于val的值的下标,right去右边作为遍历的下标。每当right遇到不等于val的值就把他给left,任何left和right同时右边挪动一次,去遍历下一个(也就是left++,right++),right遇到等于val的值的时候不进行记录到左边,right继续遍历(right++),直到right到原数组尾,返回left既可(left就是存放val数组的尾的下标)。

代码二:

分析:时间复杂度:O(N)(两个指针,right遍历一遍完整数组,left只遍历val单位个元素,最大也就是遍历两边完整数组,即O(N~2N));空间复杂度:O(1)(没有利用额外的空间,只利用了原数组的空间,也就是原地)时间复杂度能否再进行优化呢?最多只遍历一遍完整数组呢?两个指针都在开头,一个指针结束的标志是遍历完整个数组,所以如果两个指针分别位于开头和结尾,向中间遍历,直到两者相等就结束,这样能否解题呢?所以我们有了思路三。

思路三:(双指针双向单次挪动法)(左右指针)

定义begin和end变量,分别从数组开头和末尾进行遍历,begin的值等于val就将右边的end的值给begin,然后end--,如果end此时复制过来也是val,继续将end复制过来,end--,如此直到end不等于val,此时左边的begin再++(这样保证了左边都是val的值,把val都覆盖或者放在了右边了)。遍历结束条件就是当begin和end重合。

代码三:

分析:要求返回的时不等于val的元素的个数k,而我们返回的时begin和end相遇的下标,数组元素个数和下标相差一个单位,所以我们需要返回begin+1(或者把end先定义为int end = numsSize;后面再nums[end-1])

代码四:

分析:时间复杂度O(N)(O(N~N))空间复杂度O(1)。目前已经比较优了,更好的代码应该在小细节上,这里就不再进行优化了,我们只讨论题目整体思路有个脉络。

总结:

           1.顺序表的删除操作要理解为覆盖,有整体覆盖也可以用两个指针在单个元素上进行覆盖

           2.顺序表的随机访问(可前后访问元素),便有了前后指针的办法,这点拿出来就是为了               区分单链表的不能随机访问。

(2)Leetcode 26题(删除操作的理解:覆盖)

题目要求:1.原地 2.返回新数组长度 3.元素相对顺序不能变

思路一:(最粗暴办法)

要删除重复项,就需要进行对比,比较是否相同,最少需要两个数,所以我们可以用两个指针去遍历;一个left指针在0下标,一个指针right在1下标;上来先比较第一个数和第二个数是否相同,不相同的话两个指针都往后挪动一个单位,如果相同,那么让right往右走,走一步比较一下left和right,如果相同就继续往右,直到right和left不一样,此时把right位置包括后面的元素都挪到left++的位置(也就是left的右边),然后right再右挪一个单位,然后重复以上操作把数组遍历完,返回left+1(元素个数比下标大一)即可

分析:明显通过27题能知道26题思路一的挪动大片元素太消耗时间了,我们只需要挪动单个元素覆盖即可,便有了思路二。

思路二:(前后指针)

根据思路一,在right往右遍历找到不等于left的值的时候,把right给++left即可。

代码二:

​
int removeDuplicates(int* nums, int numsSize) {
    int left = 0;
    int right = 1;
    while(right<numsSize)
    {
        if(nums[right]!=nums[left])
        {
            nums[++left] = nums[right];//++left是把right给了left右边的一个
            ++right;
        }
        else
        {
            ++right;
        }
    }
    return left+1;
}

​

分析:时间复杂度O(N)(right走完整个数组,left走完不重复的所有元素(0~N),所以总的时间复杂度为O(N~2N)),空间复杂度O(1)。能不能继续优化?只走一遍数组呢?以我目前的水平只觉得既然要比较,一个指针遍历一遍数组后另一个指针遍历数组不重复的元素用来对比,所以还是要走一遍多。当然,有大佬有题解,欢迎评论区留下宝贵建议。

总结:1. 题27删除具体的某一个值,不需要进行比较,所以可以一遍遍历完就出结果,而题26删除重复的值,需要进行比较,需要遍历一遍完整数组+不重复的元素

2.单链表

(1)Leetcode 203题(删除操作的理解:连接)

思路一:(简单粗暴法)(迭代)

因为单链表根据结构特性没有向前的指针,没有随机访问的特点,我们找到val只能从头找到尾,找到val后要删除结点,然后把前后结点进行连接,可是我们删除val后不能找到下一个结点,而且前一个结点我们也没有保存起来。所以我们需要把当前结点的前一个结点在每一次往后走的时候先保存在一个变量里面,在删除val前把前一个结点和后一个结点进行连接,再进行删除即可。要注意单链表头删的操作(也就是删除第一个结点,head指向的是第一个结点,让他指向head = cur->next,再删除才行free(cur),cur = head;)

代码一:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* removeElements(struct ListNode* head, int val) {
    struct ListNode* cur = head;
    struct ListNode* prev = NULL;
    while(cur!=NULL)
    {
        if(cur->val == val)
        {
            if(cur==head)//头删
            {
                head = cur->next;
                free(cur);
                cur = head;
            }
            else
            {
            prev->next = cur->next;
            free(cur);
            cur = prev->next;
            }
        }
        else
        {
            prev = cur;
            cur = cur->next;
        }
    }
    return head;
}

分析:时间复杂度:O(N)(cur遍历一遍链表,prev记录不等于val的数据,最坏情况是都是val,所以总时间复杂度为O(N~2N)),空间复杂度O(1)。能不能只遍历一遍链表呢?因为单链表不支持随机访问,不能用两个指针两头去找,所以理论上是不能的。链表的结构中含当前结点的next指针,这里具有递归性质,所以我们也可以采用递归来解决,便有了思路二。

思路二:(递归连接)

指针cur=head,目前指向第一个结点,递归是一层一层往下调用的,我们递归到最后一个结点,递归结束的条件是调用的结点等于NULL。递归一般的结束条件现在调用子函数的上面,然后进行操作的部分写在下面。调用到最后结束条件结点=NULL时,返回到上一层,此时是最后一个非NULL结点,返回到了调用函数的下一条语句(操作部分,也就是删除结点操作)。所以语句我们写成:如果该节点数据等于val就返回该节点的next(这里会有点绕,既然这个结点是val那应该删除对吧,这里要理解递归的内涵,我们不删除结点,只是默认把他放到一边,返回它的next,回到上一层递归的时候,上一层就连接的是该节点的next,就好像把这个结点删除了一样);如果该节点数据不等于val就返回该结点即可。

代码二:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* removeElements(struct ListNode* head, int val) {
    if(head==NULL)
    {
        return head;//这里注意,为什么不返回NULL,其实这里是有两个判断
                    //一个是判断单链表是不是为空,为空返回NULL或者head都行
                    //另一个是作为递归结束的条件,当前结点为NULL时递归结束,返回当前结点head
    }
    //递归
    head->next = removeElements(head->next, val);
    //删除操作:不进行free而是默认返回当前结点的next,让当前结点前一个和后一个进行连接
    //这个连接操作在等号左边的head->next上,怎么理解呢?head如果是当前结点,那么head——>next = 
    //右边递归函数,就是连接的下一层,我们从下一层往上走,如果=val就往上返回next,默认把这层的
    //结点删除了
    return head->val == val?head->next:head; 
}

分析:这里最重要要理解的就是head->next = removeElements(head->next, val);这一句,它是把前后结点进行连接了,而且递归到最后一个非NULL结点(因为head==NULL时递归结束了返回上一层,此时head->next=NULL,而head在这里时最后一个结点,要反向来看);我们进行删除操作是从最后一个结点往上走的。时间复杂度O(N)(递归遍历,先递归一层一层遍历到最后一个个结点,走完整个单链表,然后再一层一层向上返回,应该是O(2N)?这里有疑问,希望站友友能评论指正),空间复杂度O(N)(这里应该是跟深度有关,博主比较菜,还未学习有关内存栈之类的,这里给个链接,就不误导大家了。递归空间复杂度)。

总结:

           1.单链表因为结构没有返回指针所以不能随机访问,删除操作要进行链表连接的时                              候找不到当前结点前一个和后一个,所以需要提前用变量保存,也就是双指针。

            2.单链表递归连接操作是通过->next=递归函数来实现的,删除操作是通过返回当前结点的                 下一个来实现的。

(2)Leetcode 206题(反转操作的理解:cur->next->next = cur)

思路一:(简单粗暴法)(迭代)

想到翻转,先急着把第一个结点翻转了,cur->next = NULL,然后第二个,此时cur->next已经找不到第二个结点了,所以我们要用一个变量来记录后一个结点再翻转,或者从最后一个结点往前翻转,此时又需要记录前一个结点,所以都至少需要两个变量。当前遍历的next我们可以用after变量表示。

prev=NULL,cur=head,after=cur->next,三个结点,prev用来保存当前结点cur的前一个结点,after用来记录cur下一个结点,翻转就是把每一个结点的next指回前一个即可;先用prev保存前一个,然后cur->next指向prev,接着prev,cur,after依次往后挪动(cur的位置给prev,after的位置给cur,after自己next即可)遍历链表,注意遍历结束的标志是after==NULL,此时cur在上面已经到了after的位置,变成了NULL,prev到了最后一个结点,所以返回prev。(当然不这样写也是可以的,只是我画图的自己的思路)

* Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* reverseList(struct ListNode* head) {
    if(head==NULL)
       return NULL;
   struct ListNode* cur = head;
   struct ListNode* after = cur->next;
   struct ListNode* prev = NULL;
   while(cur!=NULL)//这里其实循环里面after==NULL一定是比cur更前的,这里写的不是很好
   {
        cur->next = prev;
        prev = cur;
        cur = after;
        if(after == NULL)
            break;
        after = after->next;
   } 
   return prev;
}

分析:时间复杂度O(N)(prev,cur和after都遍历到了整个链表,依次是N,N+1,N+1,总的时间复杂度是O(3N+2)??这里存疑),空间复杂度O(1);头插法和迭代的本质是一样的,所以我们就不在写一遍了由题203知应该可以用递归进行操作,便有了思路二。

思路二:(递归反转)

我们知道递归单链表到最后一层是最后一个结点,我们要从最后一个结点进行反转连接,可是最后一个结点的next怎么连接到前一个结点呢?这里有一个很奇妙的操作,递归的条件进行更改,让最后一层是倒数第二个结点,此时还有一个神奇的操作,head->next->next = head,这一步怎么理解呢?比如下图,当前结点1的一下个结点2的next指向当前结点1,就是把下一个结点2反转连接到当前结点1了 ,然后返回上一层,假设是0,此时把0的next的next给0,就是把1的next连接到了0,就样就完成了反转,然后递归到第一个结点指向NULL即可(这里要注意:1的next要在自己那一层,也就是head=1的时候就断开,不要连接2,有人会问,后面的递归不是会把1的next解开连接到0吗?是的,但是最后我们要把第一个结点的next给NULL,所以为了完成递归连接操作,先默认所有元素都是第一个结点,next给给NULL。当然,我们也可以单独写一条语句给第一个结点的next=NULL。)

代码二:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* reverseList(struct ListNode* head) {
   if(head==NULL)//这里是链表为空返回NULL
        return NULL;
   if(head->next==NULL)//这里是最后一个结点的next为空返回该结点,是递归结束往回走的条件
        return head;
    struct ListNode* newHead = reverseList(head->next);
    //我们要返回倒数第二个结点,如果head=NULL,递归往回走,返回到上一层就是最后一个结点
    //所以我们可以让head->next=NULL,这样返回的时候就是最后一个结点的上一个结点了,也就是倒数第二个结点

    //反转操作
    head->next->next = head;
    head->next = NULL;//这里就是为了把第一个结点的next = NULL;
    return newHead;//这里要注意,递归在倒数第二个结点的时候,这里返回的是倒数第二个结点,而我们往回递归的时候,走到第一个结点后,递归函数是已经走完了
                   //他这里newHead = reverseLisy(head->next);是递归的整个结构也就是递归到最底层,也就是原链表的最后一个结点,而不是原链表的第一个结点
}

分析:递归单链表反转,注意要从倒数第二个结点进行反转,用到head->next->next = head;这个操作,最后还要把反转过来的最后一个结点的next给NULL。时间复杂度:O(N)(head->next作为cur->next从第二个结点遍历到NULL,遍历了一遍单链表,时间复杂度为O(N)),空间复杂度O(N)

总结:

        1.单链表的缺点,不能随机访问,也就是不能找到前一个结点,缺点通常会拿来做要求进行               一些操作,要用变量提前保存结点。

(3)Leetcode 876题(查找操作的理解:单向访问)

思路一:(最粗暴方法)

单链表不能随机访问,所以也不知道中间结点到底在哪里,我们先遍历一遍链表,用一个变量length记录链表元素长度,计算出一半的长度,再去遍历一遍就可以了。

分析:这里就不写代码了。时间复杂度O(N)(总时间复杂度O(N+N/2)),空间复杂度O(1)。我们这里能否只遍历一遍链表呢?数组的一遍遍历的基础是在于它有随机访问的特点,可以从后面遍历,而链表不行,但是我们可以让两个指针从头开始走,一个走一步,一个走两步,当快的走到尾,也就是慢的到中间了,此时快的走了一半的元素,慢的也是,加起来元素个数是遍历了整个数组。

思路二:(快慢指针法)

两个指针slow,fast,一个走一步,一个走两步,快的走完,慢的走到一半,返回slow。

代码:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* middleNode(struct ListNode* head) {
    //快慢指针
    struct ListNode *fast,*slow;
    fast = head; slow = head;
    while(fast && fast->next)//单链表元素奇数个和偶数个在末尾的区别
    {
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;
}

分析:时间复杂度O(N)空间复杂度O(1)。能否用递归做呢?递归走两步到最后一个结点或者倒数第二个结点,因为奇偶数的原因,然后返回走一步到一半也可以,后续再进行补充。

(4)Leetcode 02.02题 (查找操作的理解:单向访问)

思路一:(粗暴法)

又是由于单链表的缺陷出的题,要找到第k个,就要先遍历完整链表,用一个变量length去记录链表的长度(不同于数组,数组一般会给元素当前个数),然后再去遍历到length-k个即可。

代码:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */


int kthToLast(struct ListNode* head, int k){
    if(head==NULL)
        return NULL;
    int length = 0;
    struct ListNode* cur = head;
    while(cur!=NULL)//先遍历一遍,计算出链表长度
    {
        cur = cur->next;
        ++length;
    }
    cur = head;//再从头遍历到length-k个结点即可
    while(length-k>0)
    {
        cur = cur->next;
        length--;
    }
    return cur->val;
}

分析时间复杂度O(N)(遍历完整链表后又遍历了length-k,最坏情况k是最后一个结点,则需要再遍历完整链表,所以时间复杂度为O(N~2N)),空间复杂度O(1)。根据题876能知道,两个指针同向遍历如果满足某种条件就可以只遍历一次,这种条件是两倍数关系,但是这里k并不是一个固定的位置,所以哪个指针都不能走多步,只能一步一步走;我们先让fast走k步,slow和fast再同时走,这样fast走到底,slow刚好到倒数第k个位置。这样还是不能满足只遍历一遍,因为k的不确定性,这就是思路二。

思路二:(快慢指针法)

代码:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */


int kthToLast(struct ListNode* head, int k){
    if(head==NULL)
        return NULL;
    struct ListNode* slow = head;
    struct ListNode* fast = head;
    while(k-->0)//先让fast走k步
    {
        fast = fast->next;
    }
    while(fast!=NULL)//然后两个指针一起走
    {
        slow = slow->next;
        fast = fast->next;
    }
    return slow->val;
}

分析:本质和思路一一样,思路一是先遍历一遍再去遍历到倒数第k个,思路二不过是同时遍历,本质没有优化。时间复杂度O(N)(fast遍历一遍,slow遍历到length-k,总的时间复杂度O(N~2N)),空间复杂度O(1)。能不能使用递归呢?先走到最后一个结点,然后返回到倒数第k个。那么就需要用一个变量记录长度。便有了思路三。

思路三:(递归)

递归先走到最后一个结点,然后返回到倒数第k个结点,那么我们就需要对递归返回到倒数第k层就出来,不要继续返回了,也就是递归出栈的条件要写出来(出栈,这里博主只能粗略理解,这里有详细的说明:出栈入栈怎么理解?  递归注意点);我们可以用一个变量记录链表出栈的次数,出到第k次就停止,不再继续出栈,这样返回的就是倒数第k个结点了。但是这里要十分注意,我们的这个变量必须是全局变量,为什么呢?因为递归是函数内套函数,一层一层往下走,如果函数里面我们用局部变量,那么当递归走到底满足结束条件的时候就会返回,此时一层一层的出函数,那么函数内部的局部变量就会被销毁,如果我们使用全局变量,就可以记录返回的次数而不被销毁。

注意点1:递归入栈结束的条件是 当前结点=NULL,此时要把count置成1,因为返回到的是上一层,也就是最后一个结点。

注意点2:递归出栈结束的条件是 全局变量=K时,就返回当前结点的值,不出栈就继续返回value

代码:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */

int count = 0;//定义的全局变量,用来记录出栈的次数

int kthToLast(struct ListNode* head, int k){
    //1.递归入栈结束的条件
    if(head == NULL)
    {
        count = 1;
        return head;
    }


    //2.递归函数
    int value = kthToLast(head->next, k);


    //3.递归出栈结束的条件
    if(count == k)
    {
        ++count;
        return head->val; 
    }
    else
    {
        ++count;
        return value;
    } 
}

分析时间复杂度O(N)(入栈走完整个链表,出栈走了k个结点,总的时间复杂度O(N~2N)),空间复杂度O(N)(深度是整个链表的长度,开辟了N个函数的空间)。

总结:1.单链表找结点,需要变量记录长度,再去遍历,只遍历一遍单链表想找到一个随机结点是               不行的

以上只是个人的理解,写到这里的时候,发现上面对于递归的理解似乎有些错误,如果有站友看到了,希望评论区里不要吝啬,放心指出错误,博主也只是刚接触编程的小白,能接受各种大佬的批评和指点。对于刷题有自己见解的站友也可以评论区输出一波。后面持续更新。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值