【每日一练】如何做一个合格的工具人

文章目录

  • 经验分享
  • 题1 - 轮转数组
  • 题2 - 移除元素
  • 题3 - 合并两个有序数组
  • 题4 - 删除有序数组中的重复项
  • 题5 - 移除链表元素

经验分享

在近期刷题的一些经验总结:
【每日一练】如何做一个合格的工具人_第1张图片


题1 - 轮转数组

仙人指路➡️: [力扣189 - 轮转数组]
【每日一练】如何做一个合格的工具人_第2张图片

方法一:使用额外数组

思路:

  1. 创建额外数组,并将原数组拷贝到额外数组。
  2. 额外数组的元素按照 “右移关系” 放到原数组。 “右移关系”是(i + k)%numsSize(常用于解决循环队列问题)

画图:
【每日一练】如何做一个合格的工具人_第3张图片

代码实现(提交已通过):

void rotate(int* nums, int numsSize, int k)
{
    int tmp[numsSize];//创建额外数组
    
    //将原数组拷贝到额外数组
    for (int i = 0; i < numsSize; i++)
    {
        tmp[i] = nums[i];
    }

    //按照额外数组的值按 右移关系 放到原数组
    for (int i = 0; i < numsSize; i++)
    {
        nums[(i + k) % numsSize] = tmp[i];
    }
}

复杂度分析:
时间复杂度: O(n),其中 n 为数组的长度。
空间复杂度: O(n)。

方法二:环状替换

思路: 方法一中使用了额外数组的原因是若我们直接把每个数字放到它最后的位置,这样被放置的元素会被覆盖掉。其实,我们可以创建一个临时变量temp,可以将被替换的元素“备份”起来。避免了额外数组的开销,空间复杂度为O(1).
画图:
【每日一练】如何做一个合格的工具人_第4张图片
分析: 对于一个长度为numsSize 的数组,整体右移k个位置

  • 若numsSize 和 k 的最大公约数 等于 1,则1次遍历"右移"即可,比如numsSize = 5,k = 3
  • 若numsSize 和 k 的最大公约数 等于 m,1次遍历是无法将所有元素归位的,需要m(最大公约数)次,比如numsSize = 4,k = 2(最大公约数为2)

当numsSize = 4,k = 2(最大公约数为2)

  • 它的情况是:nums[0]和nums[2]会一直在“右移”2位造成死循环,那我们该如何终止该死循环,使所有元素得以归位呢?
    当在一轮循环中,若cur == start ,说明回到了起点0,此时start + 1
  • 如何判断所有元素得以归位呢?有两种方式
    方式一:numsSize个元素归位需要numsSize次交换,则定义一个count代表交换次数,当 count = n 时完成归位
    方式二:需要遍历 m (最大公约数)次,则用 m 来控制外循环

方式一:代码实现(提交已通过):

void rotate(int* nums, int numsSize, int k)
{
    k = k % numsSize;
    int count = 0;//记录已交换个数
    for (int start = 0; count < numsSize; start++) 
    {
        int cur = start;//从0开始
        int pre = nums[cur];//pre-需要右移的元素
        do {
            int next = (cur + k) % numsSize;//被替换元素的下标
            int temp = nums[next];//对被替换元素“备份”
            //交换
            nums[next] = pre;
            pre = temp;
            cur = next;
            count++;//交换完成,count+1
        } while (start != cur);
}

方式二:代码实现(提交已通过):

//递归实现返回最大公约数
int gcd(int a, int b)
{
    return b ? gcd(b, a % b) : a;
}
void rotate(int* nums, int numsSize, int k)
{
    k = k % numsSize;
    int i = 0;
    for (int start = 0; i < gcd(numsSize,k); start++,i++) 
    {
        int cur = start;//从0开始
        int pre = nums[cur];//需要右移的元素
        do {
            int next = (cur + k) % numsSize;//被替换元素下标
            int temp = nums[next];//对被替换元素“备份”
            //交换
            nums[next] = pre;
            pre = temp;
            cur = next;
        } while (start != cur);
    }
}

复杂度分析
时间复杂度: O(n),其中 n 为数组的长度。每个元素只会被遍历一次。
空间复杂度: O(1)。我们只需常数空间存放若干变量。

方法三:三步逆置法(最优)
思路: 对于长度为numsSize,整体右移 k 的数组

  • 对数组的后k个元素逆置
  • 对数组的前n - k个元素逆置
  • 对数组整体逆置

以上操作均是对该数组操作,即原地算法

画图:
【每日一练】如何做一个合格的工具人_第5张图片
题目要求空间为O(1),即要在原数组上操作,妙处是逆置。该方法理解起来十分容易,但却难以想到,真是奇技淫巧。
代码实现(提交已通过):

void Reverse(int* nums, int left, int right)
{
	while (left < right)
	{
		int tmp = nums[right];
		nums[right] = nums[left];
		nums[left] = tmp;
		++left;
		--right;
	}
}
void rotate(int* nums, int numsSize, int k)
{
	if (k >= numsSize)
		k %= numsSize;
	//后k个数逆置
	Reverse(nums, numsSize - k, numsSize - 1);

	//前n-k个数逆置
	Reverse(nums, 0, numsSize - k - 1);

	//整体逆置
	Reverse(nums, 0, numsSize - 1);
}

关于Reverse逆置函数实现方法有多种,也可以是

void swap(int* a, int* b)
{
    int t = *a;
    *a = *b;
    *b = t;
}
void Reverse(int* nums, int start, int end)
{
    while (start < end)
    {
        swap(&nums[start], &nums[end]);
        start++;
        end--;
    }
}

复杂度分析
时间复杂度: O(n),其中 n 为数组的长度。每个元素被翻转两次,一共 n 个元素,因此总时间复杂度为 O(2n)=O(n)。
空间复杂度: O(1)。


题2 - 移除元素

仙人指路➡️: [力扣27 - 移除元素]
【每日一练】如何做一个合格的工具人_第6张图片
(注:题目要求是 原地 修改输入数组,仅对原数组操作。)
方法一:通用解法
思路: 遍历数组,把与val不相等的元素往前丢
画图:偷懒一下 比较简单

代码实现(提交已通过):

int removeElement(int* nums, int numsSize, int val)
{
    //k记录不等于val的个数
    int k = 0;
    for (int i = 0; i < numsSize; i++)
    {
        if (nums[i] != val)
            nums[k++] = nums[i];
    }
    return k;//新的顺序表的长度
}

方法二:双指针
思路: 和方法一核心思想一样。

输出数组的长度一定是小于或等于输入数字的长度,因此直接把输出数组写作输入数组上。
右指针right 指向当前将要处理的元素,左指针 left 指向下一个将要赋值的位置。

  • 若右指针指向的元素不等于 val ,则是输出数组的一个元素;便将右指针指向的元素复制到左指针位置,然后左右指针同时右移1位
  • 若右指针指向的元素等于 val,则不是输出数组的元素;此时左指针不动,右指针右移1位

数组区间 [0,left) 中的元素都不等于val。当左右指针遍历完输入数组以后,left 的值就是输出数组的长度。

代码实现(提交已通过):

int removeElement(int* nums, int numsSize, int val) 
{
    int left = 0;
    for (int right = 0; right < numsSize; right++) 
    {
        if (nums[right] != val) 
        {
            nums[left] = nums[right];
            left++;
        }
    }
    return left;
}

这样的算法在最坏情况下(输入数组中没有元素等于val),左右指针各遍历了数组一次。

⚡双指针优化:
若需要移除的元素恰好在数组的开头,如[1,2,3,4,5],val = 1
按照未优化前的代码,会把每一个元素都左移1位。而题目中说到[元素的顺序可以改变]
因此,⚡优化思路: 把最后一个元素5移到数组开头,取代元素1,变成[5,2,3,4]

算法:

  • 右指针 right 指向数组尾部,即从尾部开始处理元素,左指针 left 依旧指向数组开头,表示即将要赋值的位置。

  • 左指针 left 指向的元素等于 val,右指针 right 指向的元素复制到左指针 left 的位置,然后right 左移1位(若复制过去的值还等于 val ,重复该操作,直到左指针指向的元素不等于val)

  • 当左右指针重合时,说明已经遍历了数组所有元素。

⚡代码实现(提交已通过):

int removeElement(int* nums, int numsSize, int val)
{
    int left = 0;
    int right = numsSize;
    while(left < right)
    {
        if(nums[left] == val)
        {
            nums[left] = nums[right - 1];
            right--;
        }
        else
        {
            left++;
        }
    }
    return left;
}

优化后的方法: 左右指针在最坏的情况下合起来只遍历了数组一次。且避免了需要保留的元素的重复赋值操作。
注: 双指针可以用数组的形式,也可以用指针的形式,都是找到某个元素的一种方式。)

复杂度分析:
时间复杂度: O(n),其中 n 为序列的长度。我们只需要遍历该序列至多一次。
空间复杂度: O(1)。我们只需要常数的空间保存若干变量。


题3 - 合并两个有序数组

仙人指路➡️: [力扣88 - 合并两个有序数组]
【每日一练】如何做一个合格的工具人_第7张图片
要求: 时间复杂度要求为O(m+n),则考查 原地修改,将空间复杂度降低到O(1)。不需要额外的空间,将nums2放进nums1

方法一:逆向指针法

思路: nums1初始长度为 m + n,实际有m个元素,n个未放置元素的空间;原地修改时,为了避免从前往后遍历导致原数组元素被破坏,我们应从后往前遍历!
创建三个指针:
i、j:分别指向nums1和nums2的初始化元素数量的末位,即分别是m - 1 和 n - 1
dst:指向数组的末位,即m + n - 1

画图:

代码实现(提交已通过):

void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n)
{
    int i = m - 1;
    int j = n - 1;
    int dst = m + n - 1;
    while (j >= 0)//必须保证nums2中的元素合并完
    {
        if (i >= 0 && nums1[i] > nums2[j])
        {
            nums1[dst] = nums1[i];
            dst--;
            i--;
        }
        else
        {
            nums1[dst] = nums2[j];
            dst--;
            j--;
        }
        //if()...else可用三目运算符优化为
        //nums1[end--] = (i >= 0 && nums1[i] > nums2[j]) ? nums1[i--] : nums2[j--];
    }
}

复杂度分析:
时间复杂度: O(m+n)。指针移动单调递减,最多移动 m+n 次,因此时间复杂度为 O(m+n)。
空间复杂度: O(1)。对nums1原地修改,不需要额外空间。

方法二:先合并再排序

思路: 比较容易想到:把nums2数组中的所有元素放到nums1数组中,然后用qsort函数对nums1数组整体进行排序。
【每日一练】如何做一个合格的工具人_第8张图片
画图:,上面这幅够形象了哈哈哈哈。

代码实现(提交已通过):

int cmp(int* a, int* b) 
{
    return *a - *b;
}
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n)
{
    for(int i = 0; i < nums2Size; i++)
    {
        nums1[m + i] = nums2[i];
    }
    qsort(nums1,nums1Size,sizeof(int),cmp);
}

(注:qsort函数,我们在学习C指针进阶时已经模拟实现过。)
仙人指路➡️: 【详解C语言指针】我真的让C指针给我唱征服了~乌拉

复杂度分析:
时间复杂度: O((m+n)log(m+n))
排序序列长度为 m+n,套用快速排序的时间复杂度即可,平均情况为 O((m+n)log(m+n))。
空间复杂度: O(log(m+n))。
排序序列长度为 m+n,套用快速排序的空间复杂度即可,平均情况为 O(log(m+n))。


题4 - 删除有序数组中的重复项

仙人指路➡️: [力扣26 - 删除有序数组中的重复项]
【每日一练】如何做一个合格的工具人_第9张图片
要求:

  • 数组相对顺序保持一致
  • 原地修改输入数组,不能使用额外的数组空间。即O(1)。

方法一:双指针

思路: 类似题2,我们有经验:看到关键字“原地修改”,需要保存可覆盖位置(slow)和观测位置(fast),立马想到双指针;

❕ 需要考虑:

  • 数组是有序的,相等的元素的数组下标一定是连续的。
  • 若数组nums 长度为 0 ,即空数组,返回 0 。
  • 若数组nums 长度大于 0 ,因为是删除重复项,最离谱的全部是相同元素的情况最后也会剩一个元素,即至少剩余一个元素。因此nums[0]不用处理,下标从1开始删除重复元素是完全没问题的!

算法:

  • 定义slow、fast快慢指针,初始指向下标1;slow慢指针表示下一个不同元素要填入的下标位置,fast快指针表示遍历数组到达的下标位置。
  • fast快指针依次遍历 1 到 n - 1 的每个位置;对于每个位置,当nums[fast] != nums[fast - 1],说明nums[fast]和之前的元素都不同,则执行 nums[slow] = nums[fast]; 然后slow+=1,指向下一个位置。
  • 无论是否执行 nums[slow] = nums[fast];,fast都得fast+=1,以达到遍历整个数组的目的。

遍历完事后,从 nums[0] 到 nums[slow−1] 的每个元素都不相同且包含原数组中的每个不同的元素,因此新的长度即为 slow,返回 slow 即可。

画图:【每日一练】如何做一个合格的工具人_第10张图片
代码实现(提交已通过):

int removeDuplicates(int* nums, int numsSize)
{
	if (numsSize == 0)
		return 0;
	int slow = 1;
	int fast = 1;
	while (fast < numsSize)
	{
		if (nums[fast] != nums[fast - 1])
		{
			nums[slow] = nums[fast];
			slow += 1;
		}
		fast += 1;
	}
	return slow;
}

复杂度分析
时间复杂度: O(n),其中 n 是数组的长度。快指针和慢指针最多各移动 n 次。
空间复杂度: O(1)。只需要使用常数的额外空间。

经验: 覆盖原有位置来有效利用空间,从而降低空间复杂度 - 双指针解法


题5 - 移除链表元素

仙人指路➡️:[力扣203 - 移除链表元素]
【每日一练】如何做一个合格的工具人_第11张图片

移除链表元素有三种可能:

  1. 空链表,理所当然返回NULL
  2. 链表如:1→4→2→4→NULL,移除4,即移除非头结点
  3. 链表如:1→4→2→4→NULL,移除1,即移除头结点

方法一:无哨兵位节点,直接删除法

思路:
当删除头结点时:
定义结构体指针tmp指向head,执行head = head->next;后free掉tmp

画图:
【每日一练】如何做一个合格的工具人_第12张图片

当移除非头结点时:
定义结构体指针cur,指向head;cur遍历整个链表,当cur->next->val == val时,定义结构体指针tmp指向cur->next,当cur指向它的下一个的下一个节点时,free掉tmp;当cur->next->val != val时,cur = cur->next
画图:
【每日一练】如何做一个合格的工具人_第13张图片

注意: C写数据结构题目时,没有手动在内存删除节点依旧可以通过,但内存使用空间会大一些。建议养成手动清理内存的习惯)

代码实现(提交已通过):

struct ListNode* removeElements(struct ListNode* head, int val)
{
	//删除头结点
	while (head != NULL && head->val == val)
	{
		struct ListNode* tmp = head;
		head = head->next;
		free(tmp);
		//tmp=NULL;
	}
	//删除非头节点
	struct ListNode* cur = head;
	while (cur != NULL && cur->next != NULL)
	{
		if (cur->next->val == val)
		{
			struct ListNode* tmp = cur->next;
			cur->next = cur->next->next;
			free(tmp);
			//tmp =NULL;
		}
		else
		{
			cur = cur->next;
		}
	}
	return head;//当链表为空,以上操作均不执行,返回head即可
}

方法二:创建哨兵位头结点,统一所有情况

思路: 方法一需要分为移除头结点和非头结点,我们能否使本为头结点的变为非头结点从而统一为删除的只是非头结点呢?
我们可以找一个工具人做头结点,它就是哨兵位头节点guard(它不存储数据,只负责站岗,这就是合格的工具人)
【每日一练】如何做一个合格的工具人_第14张图片

画图:
【每日一练】如何做一个合格的工具人_第15张图片
有了工具人 - 哨兵位头结点guard,当要移除旧头结点时,就变得和移除非头结点一样了。

因此,哨兵结点的使用有利于链表代码的标准化,可以减少一些额外的分支判断。

代码实现(提交已通过):

struct ListNode* removeElements(struct ListNode* head, int val)
{
    //创建哨兵位,统一所有情况
    struct ListNode* guard = (struct ListNode*)malloc(sizeof(struct ListNode));
    guard->next = head;

    struct ListNode* cur = guard;
    
    while(cur->next != NULL)
    {
        if(cur->next->val == val)
        {
            struct ListNode* tmp = cur->next;
            cur->next = tmp->next;
            free(tmp);
            //tmp = NULL;
        }
        else
            cur = cur->next;
    }
    head = guard->next;
    free(guard);
    //guard = NULL;
    return head;
}

❕ 注意: 在最后return 头结点时,切记要head = guard->next;,因为在此之前,head有可能会被free掉(第一个就移除哨兵位的下一个节点的情况)


方法三:递归
【每日一练】如何做一个合格的工具人_第16张图片

思路: 链表的定义具有递归的性质,因此链表题目常可以用递归的方法求解。

对于给定的链表:

  • 首先对除了头节点 head 以外的节点进行删除操作,然后判断 head 的节点值是否等于给定的 val。
  • 如果 head 的节点值等于 val,则 head 需要被删除,因此删除操作后的头节点为 head->next;
  • 如果 head 的节点值不等于 val,则 head 保留,因此删除操作后的头节点还是 head。
    上述过程是一个递归的过程。

递归的终止条件 是 head 为空,此时直接返回 head。
当 head 不为空时,递归地进行删除操作,然后判断 head 的节点值是否等于 val 并决定是否要删除 head。

代码实现(提交已通过):

struct ListNode* removeElements(struct ListNode* head, int val)
 {
    if (head == NULL) 
        return head;
    head->next = removeElements(head->next, val);
    return head->val == val ? head->next : head;
}


你可能感兴趣的:(数据结构与算法,c语言,数据结构)