《数据结构》(408代码题及应用题)(王道收编真题)

一、线性表

1、顺序表

分析

“循环”左移,那这个循环就应该是我们需要重点思考的点。先考虑最简单的我们可以设置两个数组,其中一个数组保存的是原数据,另一个初始为空。接着想要实现循环左移就只需要找出相对应的位置再一一赋值即可。(我比较笨只会右移,就把左移模拟成右移了)

到这会儿又可以开始想一下如何在时间和空间上优化了。空间的话,我们是可以只用一个数组(+一个中间变量+循环遍历)来实现的。原本我觉得已经可以了啊,然后看了书上觉得比较秒也更好实现。在时间上呢,也是能够发现一个规律。

1、算法思想

  1. 反转数组:首先反转整个数组 R。
  2. 反转前 n−p 个元素:接着反转数组的前 n−p 个元素。
  3. 反转后 p 个元素:最后反转数组的后 p 个元素。

2、算法实现

// 反转数组的辅助函数
void reverse(int arr[], int start, int end) {
    while (start < end) {
        // 交换元素
        int temp = arr[start];
        arr[start] = arr[end];
        arr[end] = temp;
        start++;
        end--;
    }
}

// 循环左移 p 个位置
void rotate(int arr[], int n, int p) {
    // 反转整个数组
    reverse(arr, 0, n - 1);
    // 反转前 n - p 个元素
    reverse(arr, 0, n - p - 1);
    // 反转后 p 个元素
    reverse(arr, n - p, n - 1);
}

3、复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1) 
    • 常数空间使用:该算法仅使用了几个额外的变量(如 tempstartend),这些变量的数量与输入数组的大小无关。
    • 不使用额外数组:算法在进行反转时,直接在原数组上进行操作,而不需要创建新的数组来存放中间结果。

分析

先不考虑细节问题,最简单的做法当然是先将两个数组合并啦然后直接找中位数。

但是我们仔细想想我们只需要找出来并不需要实际的再花额外空间去合并,故只需设置一个计数器和两个指针从两个数组头开始遍历比大小,小的放行,大的留着下一趟比,然后再将计数器加1,加到一个数组的长度时即是我们所求的答案。那这样的话,时间复杂度就是O(n),空间复杂度就是O(1) 。

int findMedian(int A[], int B[], int n) {    //n是一个升序序列的长度
    int i = 0, j = 0;
    int cnt = 0;
    int temp = 0;

    while (cnt < n) {
        if (i < n && (j >= n || A[i] <= B[j])) {
            temp = A[i++];
        } else {
            temp = B[j++];
        }
        cnt++;
    }
    
    return temp;
}

书里头给了这种就是二分咯,没有往这个方面想,那时间复杂度就会缩减到O(log2n),空间复杂度就还是一样的。

#include <stdio.h>

int findMedian(int A[], int B[], int n) {
    int s1 = 0, d1 = n - 1;
    int s2 = 0, d2 = n - 1;

    while (s1 < d1 || s2 < d2) {
        int ml = (s1 + d1) / 2;
        int m2 = (s2 + d2) / 2;

        if (A[ml] == B[m2]) {
            return A[ml]; // 找到中位数
        }

        if (A[ml] < B[m2]) {
            if ((d1 - s1 + 1) % 2 == 1) { // 奇数个元素
                s1 = ml; // 保留中间点
            } else { // 偶数个元素
                s1 = ml + 1; // 舍弃前半部分
            }
            d2 = m2; // 舍弃 B 的后半部分
        } else {
            if ((d2 - s2 + 1) % 2 == 1) { // 奇数个元素
                d1 = ml; // 舍弃 A 的后半部分
            } else { // 偶数个元素
                d1 = ml - 1; // 保留中间点
            }
            s2 = m2 + 1; // 舍弃 B 的前半部分
        }
    }

    // 返回中位数
    if (s1 > d1) return B[s2]; // A 完全被舍弃
    if (s2 > d2) return A[s1]; // B 完全被舍弃

    return (A[s1] < B[s2]) ? A[s1] : B[s2]; // 返回较小的
}

// 测试函数
int main() {
    int A[] = {1, 3, 8, 9, 15};
    int B[] = {7, 11, 18, 19, 21};
    int n = sizeof(A) / sizeof(A[0]);

    int median = findMedian(A, B, n);
    printf("中位数是: %d\n", median);

    return 0;
}

分析

最简单的做法应该可能大概是直接rua个大数组(n范围没讲,就只能尽量大咯)然后对这个整数数列进行扫描,将其值映射到大数组中的下标,然后大数组该对应下标的元素值加1(也就是遍历一遍整数数列,用大数组统计这个整数数列的每个数都出现了多少次)最后如果大数组中元素最大的值未能大于长度的一般则输出-1。

int MajorityElement(int A[], int n) {
    // 创建大小为 n 的统计数组并初始化为 0
    int *count = (int *)calloc(n, sizeof(int));
    
    // 遍历整数序列,统计每个数的出现次数
    for (int i = 0; i < n; i++) {
        count[A[i]]++;
    }

    // 查找出现次数最多的元素
    int majorityElement = -1;
    int maxCount = 0;
    
    for (int i = 0; i < n; i++) {
        if (count[i] > maxCount) {
            maxCount = count[i];
            majorityElement = i;
        }
    }

    // 检查是否为主元素
    if (maxCount > n / 2) {
        free(count); // 释放内存
        return majorityElement;
    } else {
        free(count); // 释放内存
        return -1; // 不存在主元素
    }
}

书里这个应该是一种特殊的算法蛤,没必要背,了解即可。

int fMajorityElement(int A[], int n) {
    int candidate = -1; // 候选元素
    int count = 0;      // 计数器

    // 第一步:选取候选元素
    for (int i = 0; i < n; i++) {
        if (count == 0) {
            candidate = A[i]; // 更新候选元素
            count = 1;        // 重置计数器
        } else if (A[i] == candidate) {
            count++; // 候选元素的支持度加一
        } else {
            count--; // 其他元素的支持度减一
        }
    }

    // 第二步:验证候选元素
    count = 0; // 重置计数器
    for (int i = 0; i < n; i++) {
        if (A[i] == candidate) {
            count++; // 统计候选元素出现的次数
        }
    }

    // 检查候选元素是否为主元素
    if (count > n / 2) {
        return candidate; // 返回主元素
    } else {
        return -1; // 不存在主元素
    }
}

原理来自 Boyer-Moore 投票算法,它是一种高效的寻找主元素的算法。

  1. 候选元素的选取

    • 使用一个计数器来记录当前候选元素的“支持度”。
    • 遍历数组时,如果计数器为零,选择当前元素作为新的候选元素,并将计数器设为1。
    • 如果当前元素与候选元素相同,增加计数器;如果不同,减少计数器。
  2. 为什么有效

    • 如果存在一个元素的出现次数超过 n/2,那么在遍历过程中,该元素将始终保持为候选元素。
    • 计数器的增加和减少保证了在元素数量相等时,候选元素的优势可以保持。

具体步骤:

  1. 初始化

    • 设置一个候选元素 candidate 和一个计数器 count,初始值为 0。
  2. 遍历数组

    • 对于每个元素:
      • 如果 count 为 0,更新 candidate 为当前元素,并将 count 设置为 1。
      • 如果当前元素等于 candidate,则 count 加 1。
      • 如果当前元素不等于 candidate,则 count 减 1。
  3. 验证候选元素

    • 遍历数组,统计 candidate 的出现次数,确认是否超过 n/2。

分析

        先想想最容易实现的是什么呢?自然也还是可以设置一个辅助数组专门记录其下标是否出现过,最后在从小到大第一个未被标记过的不就是我们想找的最小正整数吗。

        然后我们再看回要求,这题只要求时间上高效喔,那就是极致的空间换时间,快就完事了呗。上述思路的时间复杂度为O(n)嘞,那我们思考下有没有什么是可以更快做出来的呢?是不是也可以直接先过一遍排序(但在这个过程中遇到负数就直接跳过)然后遍历再找一遍是不是也出结果了(不过要是想稳定的话,身剩下的几个排序算法的时间复杂度也为O(n))。

注:

书里所给出的和我第一次的想法一样蛤。 

int findMissMin(int A[], int n) {
    int i;
    int *B; // 标记数组

    // 分配空间,使用malloc而不是错误的maloc
    B = (int *)malloc(sizeof(int) * (n + 1)); // 需要为1到n的整数分配空间

    // 赋初值为0,注意memset的第二个参数应该是字节数
    memset(B, 0, sizeof(int) * (n + 1));

    // 遍历数组A,如果A[i]在1到n之间,则标记B[A[i] - 1]为1
    for (i = 0; i < n; i++) {
        if (A[i] > 0 && A[i] <= n) {
            B[A[i]] = 1; // 直接使用A[i]作为索引,因为我们要标记1到n的整数
        }
    }

    // 扫描数组B,找到第一个值为0的位置,即未出现的最小正整数
    for (i = 1; i <= n; i++) { // 从1开始,因为0不是正整数
        if (B[i] == 0) {
            break;
        }
    }

    // 返回结果,i是数组B中第一个未标记的位置,即未出现的最小正整数
    return i;
}
int findMissin(std::vector<int>& nums) {
    int n = nums.size();
    
    // 将每个正整数放到对应的索引位置
    for (int i = 0; i < n; i++) {
        // 只处理在范围内的正整数
        while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
            // 交换到正确的位置
            std::swap(nums[i], nums[nums[i] - 1]);
        }
    }
    
    // 查找第一个缺失的正整数
    for (int i = 0; i < n; i++) {
        if (nums[i] != i + 1) {
            return i + 1; // 返回缺失的最小正整数
        }
    }
    
    return n + 1; // 如果所有位置都正确,返回n+1
}

注:第二种的空间复杂度会较低,但是题目也并未要求。

【2020统考真题】定义三元组(a,b,c)(abc均为整数)的距离D=|a-b|+b-c|+|c-a|。给定3个非空整数集合S1、S2和S3,按升序分别存储在3个数组中。请设计一个尽可能高效的算法,计算并输出所有可能的三元组(a,b,c)(a∈S1,b∈S2,c∈S3)中的最小距离。例如S1={-1,0,9},S2={-25,-10,10,11},S3={2,9,17,30,41},则最小距离为2,相应的三元组(9,10,9)。要求:
  1)给出算法的基本设计思想。
  2)根据设计思想,采用C语言或C++语言描述算法,关键之处给出注释。
  3)说明你所设计算法的时间复杂度和空间复杂度。

分析:既然一没要求时间二没要求空间,那我们直接上暴力!!!

第一层循环遍历S1,然后第二层是S2,第三层是S3(由外向内)然后再来个tempmin,暴力枚举出所有的可能性,再随着循环遍历依次比较当前最小,直至结束选出最优解。

int calculateDistance(int a, int b, int c) {
    return std::abs(a - b) + std::abs(b - c) + std::abs(c - a);
}

int findMinDistance(const std::vector<int>& S1, const std::vector<int>& S2, const std::vector<int>& S3) {
    int minDistance = INT_MAX; // 初始化最小距离为最大整数
    int a, b, c;

    // 遍历S1、S2和S3中的所有元素
    for (a = 0; a < S1.size(); ++a) {
        for (b = 0; b < S2.size(); ++b) {
            for (c = 0; c < S3.size(); ++c) {
                // 计算当前三元组的距离
                int distance = calculateDistance(S1[a], S2[b], S3[c]);
                // 如果当前距离小于已知的最小距离,则更新最小距离
                if (distance < minDistance) {
                    minDistance = distance;
                }
            }
        }
    }

    return minDistance; // 返回最小距离
}

更优解:暴力解法在集合大小较大时效率较低。一个可能的优化方法是使用双指针技巧,首先固定一个集合中的元素,然后在另外两个集合中使用双指针来寻找最小距离。这种方法可以减少一些不必要的计算,但仍然需要O(n1 * n2 + n2 * n3 + n3 * n1)的时间复杂度。如果S1、S2和S3中的元素都是有序的,那么双指针方法可以更有效地找到最小距离。(书里那个例图其实画的很清楚蛤)

int findMinDistance(const std::vector<int>& S1, const std::vector<int>& S2, const std::vector<int>& S3) {
    int minDist = std::numeric_limits<int>::max(); // 初始化最小距离为最大值
    int n1 = S1.size(), n2 = S2.size(), n3 = S3.size();
    
    // 遍历S1中的每个元素
    for (int a : S1) {
        int i = 0, j = 0; // 初始化指针
        
        // 双指针遍历S2和S3
        while (i < n2 && j < n3) {
            int b = S2[i];
            int c = S3[j];
            // 计算当前三元组的距离
            int dist = std::abs(a - b) + std::abs(b - c) + std::abs(c - a);
            minDist = std::min(minDist, dist); // 更新最小距离
            
            // 移动指针,寻找更优解
            if (b < c) {
                i++; // b较小,向右移动S2的指针
            } else {
                j++; // c较小或相等,向右移动S3的指针
            }
        }
    }
    
    return minDist;
}

注:该算法的思维还是很重要的,很多场合都可以用得到。 

2、链表

分析:首先呢,给我们的数据结构是一个带有表头结点的单链表,也不允许我们改变链表的结构。链表的长度不是直接给出的啊,所以这个倒数也很棘手。那我们该如何解决这个“k”呢,我们并不能一下子就知道这个倒数第k位置在哪里,不过不妨倒着想一下,如果现在有一个指针指向尾结点,又有一个指针指向倒数第k个。那我们再逆推一下过程这两个指针一起往回走,当先前指向倒数第k个结点的指针走到表头与list相会时,后面那个指针是不是也到正数第k个结点的头上了?那是不是我们的问题就解决了。

思路:设置两个指针p、q,初始时都指向list,让q先往后走k步(计数器实现),这时再让p、q同时朝后走,直至q到达尾指针(所指的next为空),那么此时此刻的p所指向的结点既是我们所需要的倒数第k个结点,将其data值输出。

详细实现步骤

  1. 初始化指针: 设置两个指针pq,初始时都指向链表的头节点list
  2. 移动快指针: 让指针q向前移动k步。这里需要注意的是,如果k大于链表的长度,那么查找失败,因为不存在倒数第k个节点。
  3. 同步移动双指针: 当q成功移动了k步并且q不为空(即没有到达链表末尾)时,开始同步移动pq两个指针。每次循环,pq都同时向后移动一步,即p = p->next;q = q->next;
  4. 查找结束条件: 当指针q到达链表的末尾(即q->nextNULL)时,停止移动。此时,p所指向的节点就是链表中倒数第k个节点。
  5. 检查并输出结果: 检查指针p是否为NULL。如果p不为空,说明找到了倒数第k个节点,输出该节点的data域的值,并返回1表示成功。如果p为空,说明没有找到倒数第k个节点,返回0表示失败。
  6. 返回结果: 函数返回查找的结果,即1或0。

注:有可能参考答案里头并不是这样写的呀,我还没搞懂这个评分细则。(大概是这样)

// 假设LinkList是如下定义的结构体
typedef struct LinkNode {
    int data;
    struct LinkNode *next;
} LinkList;

int findKthFromEnd(LinkList list, int k) {
    LinkList p, q;
    p = list;
    q = list;
    int cnt = 0;

    // 让q先向后走k步
    while (cnt < k && q != NULL) {
        q = q->next;
        cnt++;
    }

    // 当q到达第k+1个节点或链表尾部时,p和q一起向后移动
    while (q != NULL) {
        p = p->next;
        q = q->next;
    }

    // 此时p指向的就是倒数第k个节点,如果存在的话
    if (p != NULL) {
        printf("%d", p->data);
        return 1; // 查找成功
    } else {
        return 0; // 查找失败
    }
}

分析:最简单的应该是,直接安排两个指针遍历两个链表,再将其数据域的值赋给两个辅助数组,然后根据数组可以比较简单的先把共同后缀比出来,再利用两个index值来判断除去共同后缀之后的长度为多少,再让指针遍历一遍链表,到相对应的位置之后让其中一个指向另一个所指向的结点。(不考虑释放另一边所占用的空间)

(书里那个方法我想不到啊q"o"q

算法的基本设计思想

  1. 确定较长链表: 首先确定两个链表中较长的一个,因为共同后缀不可能长于较短的链表。
  2. 移动较短链表的指针: 将较短链表的指针移动到与较长链表相同长度的位置。
  3. 同时遍历: 从这个点开始,同时遍历两个链表,比较每个节点的值。
  4. 找到共同后缀: 当两个指针同时到达链表末尾或者发现不匹配的节点时,停止遍历。如果到达末尾,说明找到了共同后缀;否则,记录下不匹配时两个链表的指针位置。

算法的详细实现步骤

  1. 计算两个链表的长度。
  2. 使用较长链表的长度减去较短链表的长度,得到需要前进的步数。
  3. 将较长链表的头指针移动到倒数第k个节点,其中k为需要前进的步数。
  4. 同时遍历两个链表,直到两个指针都到达末尾或者发现不匹配的节点。
  5. 如果两个指针都到达末尾,返回较长链表的当前节点,即为共同后缀的起始位置。
  6. 如果发现不匹配的节点,返回nullptr,表示没有共同后缀。
// 计算链表长度
int getLength(ListNode* head) {
    int length = 0;
    ListNode* temp = head;
    while (temp != nullptr) {
        length++;
        temp = temp->next;
    }
    return length;
}

// 找到共同后缀的起始位置
ListNode* findCommonSuffix(ListNode* str1, ListNode* str2) {
    int len1 = getLength(str1);
    int len2 = getLength(str2);

    // 确定较长的链表并移动指针
    ListNode* longer = (len1 > len2) ? str1 : str2;
    ListNode* shorter = (len1 > len2) ? str2 : str1;

    // 计算步数并移动较长链表的指针
    int steps = std::abs(len1 - len2);
    for (int i = 0; i < steps; ++i) {
        if (longer == nullptr) return nullptr; // 防止链表为空
        longer = longer->next;
    }

    // 同时遍历两个链表
    while (longer != nullptr && shorter != nullptr && longer->data == shorter->data) {
        longer = longer->next;
        shorter = shorter->next;
    }

    // 如果遍历结束于链表末尾,返回共同后缀的起始位置
    if (longer == nullptr) {
        return shorter->next; // shorter指针此时指向共同后缀的最后一个节点,所以返回下一个节点
    }

    // 如果没有找到共同后缀,返回nullptr
    return nullptr;
}

分析:首先,依然还是单链表所以其上的指针只能一条路走到黑,那我们其实可以设置两个指针一个走慢一点,走在快指针的后一步。那这样是不是就可以实现找前驱的工作了。然后我们可以再设置一个遍历指针,在快慢指针行进过程中的每一步都从慢指针开始向后遍历。在找到绝对值相同的结点时,利用快慢指针实现删除操作(快指针走一步,慢指针直接指向快指针)。这样实现起来很简单对吧,但是时间复杂度有点点高,需要O(n^2)

不对不对是保留第一次出现的,那就固定一个指针用于遍历,该指针每遍历一个结点就发出一对快慢指针用于删除后续绝对值重复的结点。但是时间复杂度也依旧是O(n^2)。

那如果我先遍历一遍单链表将其数据域的值都用一个cnt记录出现次数(初始为-1,出现1次自动加1)然后最后再用两个指针进行遍历,遍历的同时比对所存放的cnt值是否大于0(重复程度)。所有数据第一次遇到均不会删除只会cnt-1,若后续重复遇到即可根据cnt的值来判断之后要删除几个结点(也同样是用快慢指针来实现删除结点的操作),这时候时间复杂度应该为O(n^2)。

不对不对,我们可以一趟就完成这个事情,直接边遍历边记录。只需要利用数组在遍历的同时记录下链表中已经出现的数值,后续都先过一遍数组(但是又因为数组是可以随机存储的所以不影响)看看该值是否出现过,再碰到直接一删除不就完了。

(书里给的也是这样蛤,但是是直接申请空间来表示)

【2019统考真题】设线性表L=(a1,a2,a3,...,an-1,an-1,an)采用带头结点的单链表保存,链
表中的结点定义如下:

typedef struct node
{    int data;
     struct node* next;
}NODE

请设计一个空间复杂度为O(1)且时间上尽可能高效的算法,重新排列L中的各结点,得到线性表L=(a1,an,a2,an-1,a3,an-2,...)要求:
1)给出算法的基本设计思想。
2)根据设计思想,采用C或C++语言描述算法,关键之处给出注释,
3)说明你所设计的算法的时间复杂度。

分析:这题有硬性要求咯,空间复杂度要为O(1)才行。那其实我们可以借助之前做的10年真题的想法,你看目的是不是要从头和从屁股开始交替形成一个新的链表,那其实我们可以先找到中间(向下取整的那个结点)然后反转后半段,再交替将其插入头结点之后的链表就完事了。

void changeList(NODE* h) {
    NODE *p, *q, *r, *s;
    p = q = h; // 初始化慢指针和快指针

    // 寻找中间节点
    while (q->next != NULL) {
        p = p->next; // 慢指针走一步
        q = q->next; // 快指针走一步
        if (q->next != NULL) {
            q = q->next; // 快指针再走一步
        }
    }

    // p所指节点为中间节点,q为后半段链表的首节点
    NODE* secondHalf = p->next; // 保存后半段链表
    p->next = NULL; // 切断前半段链表

    // 将链表后半段逆置
    NODE* prev = NULL;
    while (secondHalf != NULL) {
        r = secondHalf->next; // 保存下一个节点
        secondHalf->next = prev; // 反转指针
        prev = secondHalf; // 移动前驱
        secondHalf = r; // 移动到下一个节点
    }

    // prev 现在指向反转后的后半段链表的头
    s = h->next; // s指向前半段的第一个数据节点
    q = prev; // q指向后半段的第一个数据节点

    // 将链表后半段的节点插入到指定位置
    while (q != NULL) {
        r = q->next; // r指向后半段的下一个节点
        q->next = s->next; // 将q插入到s之后
        s->next = q; // 更新s的next指向q
        s = q->next; // s指向前半段的下一个插入点
        q = r; // 移动到下一个节点
    }
}

时间复杂度:O(n)

该算法需要遍历链表三次:一次找到中间节点,一次反转后半部分,一次合并两个链表。

二、栈、队列和数组

1、队列

1)顺序存储无法满足要求②的队列占用空间随着入队操作而增加。根据要求来分析:要求①容易满足:链式存储方便开辟新空间,要求②容易满足:对于要求③,出队后的结点并不真正释放,用队头指针指向新的队头结点,新元素入队时,有空余结点则无须开辟新空间,赋值到队尾后的第一个空结点即可,然后用队尾指针指向新的队尾结点,这就需要设计成一个首尾相接的循环单链表,类似于循环队列的思想。设置队头、队尾指针后,链式队列的入队操作和出队操作的时间复杂度均为O(1),要求④可以满足。因此,采用链式存储结构(两段式单向循环链表),队头指针为front,队尾指针为rear。
2)该循环链式队列的实现可以参考循环队列,不同之处在于循环链式队列可以方便地增加空间,出队的结点可以循环利用,入队时空间不够也可以动态增加。同样,循环链式队列也要区分队满和队空的情况,这里参考循环队列性一个单元来判断。初始时,创建只有一个空闲结点的循环单链表,头指针front和尾指针rear均指向空闲结点,如下图所示。

  • 队空的判定条件:front==rear
  • 队满的判定条件:ront==rear->next

3)

4)

2、栈

【非真题】

  • 写定义顺序存储的栈(数组实现),数据元素是 int 型。
    • 基于上述定义,实现“出栈、入栈、判空、判满”四个基本操作。
  • 定义链式存储的栈(单链表实现)。
    • 基于上述定义,栈顶在链头,实现“出栈、入栈、判空、判满”四个基本操作。
  • 定义链式存储的栈(双向链表实现)。
    • 基于上述定义,栈顶在链尾,实现“出栈、入栈、判空、判满”四个基本操作。
  • 自己动手创造,写一个具有多层小括号、中括号的算数表达式。
    • 画图:针对的算数表达式,使用栈进行“括号匹配”,画出栈内元素最多的状态。
    • 简答:请描述使用栈进行括号匹配的过程。
typedef struct node{
	int data[MAX_SIZE];		//用于存放数据 
	int top;	//栈顶指针 
} intStack;		//定义顺序存储的栈(数组实现)	

void initStack(intStack *s) {	//初始化 
    s->top = -1;
}

int isFull(intStack s) {	//判满
    return s.top == MAX_SIZE - 1;
}

int isEmpty(intStack s) {	//判空 
    return s.top == -1;
}

void push(intStack *s, int element) {	//入栈 
    if (!isFull(*s)) {
        s->data[++s->top] = element;
    }
    return -1;
}

int pop(intStack s){	//出栈	
	if(!isEmpty(*s)) {
		return s->data[s->top--];
	}
	return -1;
} 
// 链表节点结构定义
typedef struct Snode {
    int data;              // 数据域
    struct Snode *next;    // 指向下一个节点的指针
} Snode, *listStack; // listStack 是指向 Snode 的指针类型

// 初始化栈
void initStack(listStack &S) {
    S = (Snode*)malloc(sizeof(Snode)); // 分配内存给头结点
    S->next = NULL; // 头结点的下一个指针初始化为 NULL
}

// 判空函数
bool isEmpty(listStack S) {
    return S->next == NULL; // 如果头结点的下一个指针为 NULL,则栈为空
}

// 入栈操作
bool push(listStack &S, int element) {
    Snode *p = (Snode*)malloc(sizeof(Snode)); // 创建新节点
    if (p == NULL) return false; // 检查内存分配是否成功
    p->data = element; // 设置新节点的数据
    p->next = S->next; // 新节点指向当前栈顶元素
    S->next = p; // 更新栈顶为新节点
    return true; // 返回成功
}

// 出栈操作
bool pop(listStack &S, int &x) {
    if (!isEmpty(S)) { // 检查栈是否为空
        Snode *p = S->next; // 获取栈顶元素
        x = p->data; // 返回栈顶元素
        S->next = p->next; // 更新栈顶为下一个元素
        free(p); // 释放栈顶元素的内存
        return true; // 返回成功
    }
    return false; // 栈为空,出栈失败
}
// 双向链表节点结构定义
typedef struct Dnode {
    int data;              // 数据域
    struct Dnode *prior;   // 指向前一个节点的指针
    struct Dnode *next;    // 指向下一个节点的指针
} Dnode, *dlistStack; // dlistStack 是指向 Dnode 的指针类型

// 初始化栈
void initStack(dlistStack &S) {
    S = (Dnode*)malloc(sizeof(Dnode)); // 分配内存给头结点
    S->next = NULL; // 头结点的下一个指针初始化为 NULL
    S->prior = NULL; // 头结点的前一个指针初始化为 NULL
}

// 判空函数
bool isEmpty(dlistStack &S) {
    return S->next == NULL; // 如果头结点的下一个指针为 NULL,则栈为空
}

// 入栈操作
bool push(dlistStack &S, int element) {
    Dnode *p = (Dnode*)malloc(sizeof(Dnode)); // 创建新节点
    if (p == NULL) return false; // 检查内存分配是否成功
    p->data = element; // 设置新节点的数据
    p->next = NULL; // 新节点的下一个指针初始化为 NULL
    p->prior = S; // 新节点的前一个指针指向当前栈顶(头结点)

    if (S->next != NULL) { // 如果栈不空
        S->next->prior = p; // 更新原栈顶的前指针
    }
    S->next = p; // 更新栈顶为新节点
    return true; // 返回成功
}

// 出栈操作
bool pop(dlistStack &S, int &x) {
    if (!isEmpty(S)) { // 检查栈是否为空
        Dnode *p = S->next; // 获取栈顶元素
        x = p->data; // 返回栈顶元素
        S->next = p->prior; // 更新栈顶为前一个元素
        if (S->next != NULL) { // 如果新的栈顶不为空
            S->next->next = NULL; // 断开新的栈顶与旧栈顶的连接
        }
        free(p); // 释放栈顶元素的内存
        return true; // 返回成功
    }
    return false; // 栈为空,出栈失败
}

分析:最简单,也最容易想到应该就是,直接设置6个计数器来统计左右括号的出现,然后将其相对应的一一比对,只要出现不相等那就是不配对。当然这个简单背后所对应的就是如果面对表达式中的括号嵌套很深,那么可能出问题。

更好的方法是使用栈(stack)(毕竟本来就是在栈这块章节的问题hhhh)来处理括号匹配,这样可以有效地管理括号的配对关系。

使用栈的方法

  1. 遇到左括号时,压入栈
  2. 遇到右括号时,检查栈顶元素
    • 如果栈为空,说明没有对应的左括号,返回不配对。
    • 如果栈顶元素与当前右括号不匹配,返回不配对。
    • 如果匹配,弹出栈顶元素。
  3. 遍历结束后,检查栈是否为空
    • 如果栈为空,说明所有括号都配对成功;
    • 否则,说明有未配对的左括号。
bool isBalanced(const std::string& expression) {
    std::stack<char> s;
    std::unordered_map<char, char> brackets = {
        {')', '('},
        {']', '['},
        {'}', '{'}
    };

    for (char ch : expression) {
        if (ch == '0') {
            break; // 遇到结束符
        }
        // 如果是左括号,压入栈
        if (ch == '(' || ch == '[' || ch == '{') {
            s.push(ch);
        }
        // 如果是右括号
        else if (ch == ')' || ch == ']' || ch == '}') {
            if (s.empty() || s.top() != brackets[ch]) {
                return false; // 不配对
            }
            s.pop(); // 匹配成功,弹出栈顶元素
        }
    }

    return s.empty(); // 栈空则配对成功
}

设单链表的表头指针为L,结点结构由data和next两个域构成,其中data域为字符型。试设计算法判断该链表的全部n个字符是否中心对称。例如xyx、xyyx都是中心对称。

分析

这题完全可以参考19年那题,我们直接找到中间结点然后将后半段全部翻转,接着遍历一遍就好了(头指针和指向中间结点的那个指针同时向后直至后面那个指针指向空为止)唯一要额外考虑的是要是单链表长度为奇数时,要对正中间这个结点做点特殊处理,使其不参与后续的遍历比较。

struct Node {
    char data;
    Node* next;
};

bool isSymmetric(Node* head) {
    if (!head || !head->next) return true; // 空链表或只有一个节点

    Node *slow = head, *fast = head;
    Node *prev = nullptr;

    // 找到中间节点,反转后半段链表
    while (fast && fast->next) {
        fast = fast->next->next; // 快指针每次走两步
        Node* nextNode = slow->next; // 保存下一个节点
        slow->next = prev; // 反转当前节点的指针
        prev = slow; // 移动前驱
        slow = nextNode; // 移动到下一个节点
    }

    // 如果链表长度为奇数,跳过中间节点
    if (fast) {
        slow = slow->next; // 跳过中间节点
    }

    // 比较前半段与反转后的后半段
    while (prev && slow) {
        if (prev->data != slow->data) {
            return false; // 不对称
        }
        prev = prev->next; // 移动前半段
        slow = slow->next; // 移动后半段
    }

    return true; // 对称
}

当然还有一种做法还是借助数组,第一轮遍历时,将其数值复制到数组当中。接着利用数组可以随机存取的特点,再从中间结点向两端比对即可。

struct Node {
    char data;
    Node* next;
};

bool isSymmetric(Node* L) {
    std::vector<char> chars;
    Node* current = L;

    // 遍历链表并存储字符
    while (current != nullptr) {
        chars.push_back(current->data);
        current = current->next;
    }

    // 检查对称性
    int n = chars.size();
    for (int i = 0; i < n / 2; ++i) {
        if (chars[i] != chars[n - 1 - i]) {
            return false; // 不对称
        }
    }
    return true; // 对称
}

3、压缩存储

  • 给自己出题:自己动手创造,画一个5行5列的对称矩阵
  • 画图:按“行优先”压缩存储上述矩阵,画出一维数组的样子
  • 简答:写出元素 i,j 与 数组下标之间的对应关系
  • 画图:按“列优先”压缩存储上述矩阵,画出一维数组的样子
  • 简答:写出元素 i,j 与 数组下标之间的对应关系
  • 画图:假设你的对称矩阵表示一个无向图,画出无向图的样子

那我们知道由于是对称矩阵,其实没必要把所有的元素都存放在数组中,不然我们按上三角不然我们按下三角存其实都可以。

三、串

四、树

并查集

写代码:定义一个并查集(用长度为n的数组实现)
基于上述定义,实现并查集的基本操作—— 并 Union
基于上述定义,实现并查集的基本操作—— 查 Find
自己设计一个例子,并查集初始有10个元素,进行若干次Union操作,画出每一次Union后的样子
自己设计一个例子,基于上一步得到的并查集,进行若干次find操作(每次find会进行“路径压缩”)。画出每次 find (路径压缩)后的样子
#define SIZE 13
int UFSets[SIZE]; //用一个数组表示并查集

//初始化并查集
void Initial(int S[]){
    for(int i=0;i<SIZE;i++)
    S[i]=-1;
}

//Find “查”操作,找 x 所属集合(返回 x 所属根结点)
int Find(int S[],int x){
    while(S[x]>=0) //循环寻找 x 的根
        x=S[x];
    return x; //根的 S[]小于 0
}

//Union “并”操作,将两个集合合并为一个
void Union(int S[],int Root1,int Root2){//要求 Root1 与 Root2 是不同的集合
    if(Root1==Root2) 
        return;
    
    S[Root2]=Root1;    //将根 Root2 连接到另一根 Root1 下面
}
//Union “并”操作,小树合并到大树
void Union(int S[], int Root1, int Root2) {
    if (Root1 == Root2) return; // 如果两个元素已经在同一个集合中,则不进行操作
    if (S[Root2] > S[Root1]) { // 假设Root2的树的节点数更少或两棵树节点数相等
        S[Root1] += S[Root2]; // 将Root2树的节点数累加到Root1上
        S[Root2] = Root1; // 将Root2的根节点指向Root1,即把Root2的树合并到Root1的树下
    } else {
        S[Root2] += S[Root1]; // 将Root1树的节点数累加到Root2上
        S[Root1] = Root2; // 将Root1的根节点指向Root2,即把Root1的树合并到Root2的树下
    }
}

//Find “查”操作优化,先找到根节点,再进行“压缩路径”
int Find(int S[],int x){
    int root = x;
    while(S[root]>=0) 
        root=S[root]; //循环找到根

    while(x!=root){ //压缩路径
        int t=S[x]; //t 指向 x 的父节点
        S[x]=root; //x 直接挂到根节点下
        x=t;
    }
    return root; //返回根节点编号
}

【2016统考真题】若一棵非空k(k≥2)叉树T中的每个非叶结点都有k个孩子,则称T为正则k叉树。请回答下列问题并给出推导过程。
1)若T有m个非叶结点,则T中的叶结点有多少个?
2)若T的高度为h(单结点的树h=1),则T的结点数最多为多少个?最少为多少个?

注:正则的定义要求。

二叉树

写代码:定义顺序存储的二叉树(数组实现,树的结点从数组下标1开始存储
基于上述定义,写一个函数 int findFather ( i ) ,返回结点 i 的父节点编号
基于上述定义,写一个函数 int leftChild ( i ) ,返回结点 i 的左孩子编号
基于上述定义,写一个函数 int rightChild ( i ) ,返回结点 i 的右孩子编号
利用上述三个函数,实现先/中/后序遍历
写代码:定义顺序存储的二叉树(数组实现,树的结点从数组下标0开始存储
基于上述定义,写一个函数 int findFather ( i ) ,返回结点 i 的父节点编号
基于上述定义,写一个函数 int leftChild ( i ) ,返回结点 i 的左孩子编号
基于上述定义,写一个函数 int rightChild ( i ) ,返回结点 i 的右孩子编号
利用上述三个函数,实现先/中/后序遍历

分析:都说了是要数组实现的话,那应该就是说我们得找找二叉树左孩子右孩子与父节点之间得关系才对。(也就是根据不同的数组下标去寻找其对应的父节点or孩子结点)那接下来再考虑下特殊情况,可能会存在空结点的对吧,所以需要再设置一个标志位来判断当前结点是否为空。

那又是二叉树又是从一开始其实很简单的,直接上代码。

typedef struct TreeNode {
    int data;
    bool empty;
} TreeNode;

void Initialize(TreeNode t[], int length) {
    for (int i = 1; i <= length; i++)
        t[i].empty = true;  // 初始化所有节点为空
}

bool isEmpty(TreeNode t[], int index, int length) {
    if (index < 1 || index >= length)
        return true;  // 超出范围
    return t[index].empty; 
}

int findFather(TreeNode t[], int index, int length) {
    int tmp = index / 2;
    return (tmp >= 1 && tmp < length && !isEmpty(t, tmp, length)) ? tmp : -1;  // 返回父节点编号
}

int findLeftChild(TreeNode t[], int index, int length) {
    int tmp = index * 2;
    return (tmp < length && !isEmpty(t, tmp, length)) ? tmp : -1;  // 返回左孩子编号
}

int findRightChild(TreeNode t[], int index, int length) {
    int tmp = index * 2 + 1;
    return (tmp < length && !isEmpty(t, tmp, length)) ? tmp : -1;  // 返回右孩子编号
}

可如果是从0开始的话就要重新捋一捋了,首先0有1、2两个孩子,也就是1和2找爸爸都要找到0才行对吧,那1很明显根据C语言的要求 " 其实明显是还可以除以2来求的,可2除以2的话结果就不对了。那其实我们就再看看后面还是不是呢(也很简单就能验证,其实奇数端不受影响,而偶数端找爸爸的时候会多1,那就好办啦,将奇偶区分开就行。那再看回如何找孩子,先不看最特殊的0,我们看1的孩子3and4,2的孩子5and6。这个规律还是能够一眼就看出来的对吧,就是直接乘2加1或者加2。看回0,你会发现也是一样的道理对不对~

typedef struct TreeNode {
    int data;
    bool empty;
} TreeNode;

void Initialize(TreeNode t[], int length) {
    for (int i = 0; i < length; i++)
        t[i].empty = true;  // 初始化所有节点为空
}

bool isEmpty(TreeNode t[], int index, int length) {
    if (index < 0 || index >= length)
        return true;  // 超出范围
    return t[index].empty; 
}

int findFather(TreeNode t[], int index, int length) {
    if (index == 0) return -1;  // 根节点没有父节点
    int tmp = (index % 2 == 0) ? (index / 2 - 1) : (index / 2);
    return isEmpty(t, tmp, length) ? -1 : tmp;  // 返回父节点编号
}

int findLeftChild(TreeNode t[], int index, int length) {
    int tmp = index * 2 + 1;
    return isEmpty(t, tmp, length) ? -1 : tmp;  // 返回左孩子编号
}

int findRightChild(TreeNode t[], int index, int length) {
    int tmp = index * 2 + 2;
    return isEmpty(t, tmp, length) ? -1 : tmp;  // 返回右孩子编号
}

 那又如何来实现前/中/后序遍历呢?我想应该只需要使用深度优先即可。(两题的答案是一样的哦)

void preOrder(TreeNode t[], int root, int length) {
    if (isEmpty(t, root, length)) {
        return;  // 退出递归条件
    }
    cout << t[root].data << " ";  // 先访问根节点
    preOrder(t, findLeftChild(t, root, length), length);  // 访问左子树
    preOrder(t, findRightChild(t, root, length), length); // 访问右子树
}

void midOrder(TreeNode t[], int root, int length) {
    if (isEmpty(t, root, length)) {
        return;  // 退出递归条件
    }
    midOrder(t, findLeftChild(t, root, length), length);  // 访问左子树
    cout << t[root].data << " ";  // 访问根节点
    midOrder(t, findRightChild(t, root, length), length); // 访问右子树
}

void postOrder(TreeNode t[], int root, int length) {
    if (isEmpty(t, root, length)) {
        return;  // 退出递归条件
    }
    postOrder(t, findLeftChild(t, root, length), length);  // 访问左子树
    postOrder(t, findRightChild(t, root, length), length); // 访问右子树
    cout << t[root].data << " ";  // 访问根节点
}

1. 算法基本设计思想

带权路径长度(WPL)是二叉树中所有叶结点的权值与其到根节点的路径长度的乘积之和。为了计算 WPL,可以采用深度优先遍历(DFS)的方法,遍历树的每一个节点:

  • 对于每个节点,记录当前深度。
  • 当到达叶节点时,计算其带权路径长度 WPL+=weight×depthWPL+=weight×depth。

2. 二叉树节点的数据类型定义

struct TreeNode {
    TreeNode* left;   // 指向左子树的指针
    TreeNode* right;  // 指向右子树的指针
    int weight;       // 节点的权值(仅在叶节点有效)

    TreeNode(int w) : left(nullptr), right(nullptr), weight(w) {} // 构造函数
};

3. WPL 计算算法的 C++ 实现

以下是计算二叉树 WPL 的算法实现:

struct TreeNode {
    TreeNode* left;   // 指向左子树的指针
    TreeNode* right;  // 指向右子树的指针
    int weight;       // 节点的权值(仅在叶节点有效)

    TreeNode(int w) : left(nullptr), right(nullptr), weight(w) {} // 构造函数
};

// 递归函数:计算 WPL
void calculateWPL(TreeNode* node, int depth, int& totalWPL) {
    if (node == nullptr) {
        return; // 如果节点为空,直接返回
    }
    
    // 如果是叶节点
    if (node->left == nullptr && node->right == nullptr) {
        totalWPL += node->weight * depth; // 计算带权路径长度
    } else {
        // 递归遍历左子树和右子树,深度加1
        calculateWPL(node->left, depth + 1, totalWPL);
        calculateWPL(node->right, depth + 1, totalWPL);
    }
}

// 计算给定二叉树的 WPL
int getWPL(TreeNode* root) {
    int totalWPL = 0; // 初始化总 WPL
    calculateWPL(root, 0, totalWPL); // 从根节点开始计算
    return totalWPL; // 返回总 WPL
}
  • 时间复杂度:O(n),其中 nn 是树中节点的数量,因为每个节点都被访问一次。
  • 空间复杂度:O(h),其中 hh 是树的高度,递归调用栈的深度。

这样就完成了 WPL 的计算算法设计与实现!

分析:要实现将给定的二叉树转换成为等价的中缀表达式并输出,那其实我们只需要中序遍历就好了,一边遍历一边输出,最后的结果自然就是该题想要我们实现的。任务就转换成为对中序遍历稍加改变一下咯。

还有还有为了保证输出出来的中缀表达式有正确的运算顺序,我们还应该在合适的位置添加括号。(遍历左子树之前加上,遍历完右子树之后也加上)

typedef struct node{
    char data[];          
    struct node *left, *right; 
}BTree;
 
//BreeToTree(root,1); //调用该程序 
 
void BreeToTree(BTree *root,int deep){
    if(root!=NULL){
   		if(root->left=NULL&&root->right=NULL){
   			printf("%s",root->data);	
		}else{
			if(deep>1) printf("(");	//遍历左子树之前 
			BreeToTree(root->left,deep+1);	//递归遍历左子树
        	printf("%s",root->data);
        	BreeToTree(root->right,deep+1);	//递归遍历右子树
			if(deep>1) printf("(");	//遍历右子树之后 
		}     
    }else{
   		return;	//空结点直接返回 
    }
}

【2022统考真题】已知非空二叉树T的结点值均为正整数,采用顺序存储方式保存,数据结构定义如下: 

写代码:使用“双亲表示法”,定义顺序存储的树(以及森林)
写代码:使用“孩子表示法”,定义链式存储的树(以及森林)
对比:树的孩子表示法存储 v.s. 图的邻接表存储 v.s. 散列表的拉链法 v.s. 基数排序。你发现了什么?
写代码:使用“孩子兄弟表示法”,定义链式存储的树(以及森林)
自己动手创造,画一个结点总数不少于10的树,并画出对应的“双亲表示法、孩子表示法、孩子兄弟表示法”三种数据结构的示意图
自己动手创造,画一个至少包含3棵树的森林,并画出对应的“双亲表示法、孩子表示法、孩子兄弟表示法”三种数据结构的示意图

 1、使用“双亲表示法”,定义顺序存储的树(以及森林)

#define MAX_TREE_SIZE 100  //树中最多结点数
typedef struct{      //树的结点定义
   ElemType data;    //数据元素(存放结点本身信息。)
   int parent;      //双亲位置域(指示本结点的双亲结点在数组中的位置。)
}PTNode;
 
typedef struct{                   //树的类型定义
   PTNode nodes[MAX_TREE_SIZE];   //双亲表示
   int n;                         //结点数
}PTree;

2、 使用“孩子表示法”,定义链式存储的树(以及森林)

struct CTNode{
   int child;    //孩子结点在数组中的位置
   struct CTNode *next;    // 下一个孩子
};
 
typedef struct{
   ElemType data;
   struct CTNode *firstChild;    // 第一个孩子
}CTBox;
 
typedef struct{
   CTBox nodes[MAX_TREE_SIZE];
   int n, r;   // 结点数和根的位置
}CTree;

 3、树的孩子表示法存储 v.s. 图的邻接表存储 v.s. 散列表的拉链法 v.s. 基数排序。你发现了什么?

嗯~它们应该图都长一个样子,别的不造。

4、使用“孩子兄弟表示法”,定义链式存储的树(以及森林)

typedef struct CSNode{
   ElemType data;                               //数据域
   struct CSNode *firstchild, *nextsibling;     //第一个孩子和右兄弟指针
}CSNode. *CSTree;

5、自己动手创造,画一个结点总数不少于10的树,并画出对应的“双亲表示法、孩子表示法、孩子兄弟表示法”三种数据结构的示意图。

6、自己动手创造,画一个至少包含3棵树的森林,并画出对应的“双亲表示法、孩子表示法、孩子兄弟表示法”三种数据结构的示意图。

BST & AVL

自己设计一个例子,给出不少于10个关键字序列,按顺序插入一棵初始为空的二叉排序树,画出每一次插入后的样子
基于你设计的例子,计算二叉排序树在查找成功和查找失败时的 ASL
基于你设计的例子,依次删除不少于4个元素,画出每一次删除之后的样子(需要包含四种删除情况——删一个叶子结点、删一个只有左子树的结点、删一个只有右子树的结点、删一个既有左子树又有右子树的结点)
自己设计一个例子,给出不少于10个关键字序列,按顺序插入一棵初始为空的平衡二叉树,画出每一次插入后的样子(你设计的例子要涵盖LL、RR、LR、RL四种调整平衡的情况)
基于你设计的例子,计算平衡二叉树在查找成功和查找失败时的 ASL

 

Huffman(Tree and Code)

【2012统考真题】设有6个有序表A,B,C,D,E,F,分别含有10,35,40,50,60和200个数据元素,各表中的元素按升序排列。要求通过5次两两合并,将6个表最终合并为1个升序表,并使最坏情况下比较的总次数达到最小。请回答下列问题:
1)给出完整的合并过程,并求出最坏情况下比较的总次数。
2)根据你的合并过程,描述n(n≥2)个不等长升序表的合并策略,并说明理由。

分析:我的第一反应比较总次数最小?应该是要用Huffman吧。结果,嘿~还真是。

1)由于合并两个长度分别为m和n的有序表,最坏情况下需要比较m+n-1次,所以最坏情况下比较的总次数计算如下:

  • 第1次合并:最多比较次数=10+35-1=44。
  • 第2次合并:最多比较次数=40+45-1=84。
  • 第3次合并:最多比较次数=50+60-1=109。
  • 第4次合并:最多比较次数=85+110-1=194。
  • 第5次合并:最多比较次数=195+200-1=394。
  • 比较的总次数最多为44+84+109+194+394=825。

2)各表的合并策略是:对多个有序表进行两两合并时,若表长不同,则最坏情况下总的比较次数依赖于表的合并次序。可以借助于哈天曼树的构造思想,依次选择最短的两个表进行合并,此时可以获得最环情况下的最佳合并效率。

【2020统考真题】若任意一个字符的编码都不是其他字符编码的前缀,则称这种编码具有前缀特性。现有某字符集(字符个数≥2)的不等长编码,每个字符的编码均为二进制的0、1序列,最长为位,且具有前缀性。请回答下列问题
1)哪种数据结构适宜保存上述具有前缀特性的不等长编码?
2)基于你所设计的数据结构,简述从0/1串到字符串的译码过程。
3)简述判定某字符集的不等长编码是否具有前特性的过程。

书本课后答案

1)使用一棵二叉树保存字符集中各字符的编码,每个编码对应于从根开始到达某叶结点的一条路径,路径长度等于编码位数,路径到达的叶结点中保存该编码对应的字符。
2)从左至右依次扫描0/1串中的各位。从根开始,根据串中当前位沿当前结点的左子指针或右子指针下移,直到移动到叶结点时为止。输出叶结点中保存的字符。然后从根开始重复这个过程,直到扫描到0/1串结束,译码完成。
3)二叉树既可用于保存各字符的编码,又可用于检测编码是否具有前特性。判定编码是否具有前缀性的过程,也是构建二叉树的过程。初始时,二叉树中仅含有根结点,其左子指针和右子指针均为空。

依次读入每个编码C,建立/寻找从根开始对应于该编码的一条路径,过程如下:
       对每个编码,从左至右扫描C的各位,根据C的当前位(0或1)沿结点的指针(左子指针或右子指针)向下移动。当遇到空指针时,创建新结点,让空指针指向该新结点并继续移动。沿指针移动的过程中,可能遇到三种情况:

  1. 若遇到了叶结点(非根),则表明不具有前缀性,返回。
  2. 若在处理C的所有位的过程中,均没有创建新结点,则表明不具有前缀性,返回。
  3. 若在处理C的最后一个编码位时创建了新结点,则继续验证下一个编码。
  4. 若所有编码均通过验证,则编码具有前缀特性。

五、图

1、最短路径

【2009统考真题】带权图(权值非负,表示边连接的两顶点间的距离)的最短路径问题是找出从初始顶点到目标顶点之间的一条最短路径。假设从初始顶点到目标顶点之间存在路径,现有一种解决该问题的方法:
①设最短路径初始时仅包含初始顶点,令当前顶点为初始顶点。
②选择离最近且尚未在最短路径中的一个顶点,加入最短路径,修改当前顶点u=v。
③重复步骤②,直到是目标顶点时为止
请问上述方法能否求得最短路径?若该方法可行,请证明;否则,请举例说明。

分析:咋一看貌似很有道理嘞,还是贪心的思路。那我们回忆一下局部贪心是否能一定得到最优解嘞?显然并不是所有的结果都是最优的对吧。

该方法不一定能够求得最优解(最短路径)。

因此,该方法并不总是能找到最短路径。特别是当存在多个路径且某些路径的间接距离较短时,贪心选择可能导致错误的结果。(不用写这句,应用题看答案是否正确而不是看你哔哩吧啦了多久)

2、关键路径(AOE网)

注:时间余量为零(即这个事情拖不了一点)故该为关键事件,由关键事件所组成的路径就被称作关键路径。

l(i)=Vl(i)-weigh

3、邻接矩阵

分析:我们作为后来人显然已经背过该结论了,不过这个A^n邻接矩阵的含义到底是怎么推出来的呢?第一问撒撒水,第二问求A^2也还是很简单,可我们回顾一下求A^2的过程(a[0][0] = a[0][0]*a[0][0] + a[0][1]*a[1][0] + a[0][2]*a[2][0] + a[0][3]*a[3][0] + a[0][4]*a[4][0] = 3)是不是只有当乘积中的任意一方不为0时,才有1的贡献度。那我们再思考下一阶邻接矩阵中元素的含义:

  1. 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非无穷元素)的个数正好是顶点i的度。
  2. A[i][j]不为0是不是表示它们之间是有通路的。

再捋一下a[0][1]*a[1][0]=1是不是说明从1到0,和从0到1是不是都有通路,那这是不是一个来回,且这个通路的长度(2)您猜怎么着?嘿~恰好等于矩阵的次方数。那是不是第三列元素值的含义应该是0~4结点到3这个结点长度为2的路径数量。故第三问的答案就应该为:B^m中,B[i][j]表示的是从i出发走到点j走m步,有多少种走法。非零元素就代表存在长度为m的路径有多少条。

自己设计一个不少于6个结点的带权有向无环图,并画出其邻接矩阵的样子
用一维数组将你设计的有向无环图的邻接矩阵进行压缩存储
文字描述:基于你压缩存储的数组,如何判断结点 i、j 之间是否有边?
基于你设计的带权有向无环图,写出所有合法的拓扑排序序列
文字描述:拓扑排序的过程

那不就是上三角矩阵压缩存储与k之间的关系嘛 

4、邻接表

分析:好家伙这一长串属实是怪吓人的,但是其实我们只关注题干(123)本身的会发现,这纯纯就是一道数据结构的设计题+Dijkstra算法的手算蛤。

这一看就是无向图嘛,那我们直接开始设计,定义一个结构体RXnode,接着定义一个Router ID用于标识路由器,再定义三个小结点Link1、Link2、Net1,最后来设置三个指针域,根据三个小结点的数据指向其相对应的结点。欸到这我才想起来,这不就是邻接表吗,又有顶点表结点,又有边表结点。那我们只需要对其稍加改造不就是第二题的答案了吗?剩下最后一个手算其实就很简单了,瞪眼秒杀法。

1)无向图

2)

typedef struct{
    int ID, IP;
} LinkNode; // 定义链路结构体,包含ID和IP

typedef struct {    
    int Prefix, Mask;
} NetNOde; // 定义网络结构体,包含Prefix和Mask

typedef struct ArcNode{
    bool Flag; // 判断是Link还是Net
    union{
        LinkNode Lnode; // 联合体用于存储LinkNode或NetNode
        NetNode Nnode;
    } NL;
    int Merit; // 弧的度量值
    struct ArcNode *next; // 指向下一个ArcNode的指针
} ArcNode; // 定义弧结构体

typedef struct VNode{
    int RouterID; // 路由器ID
    ArcNode *Link; // 指向相连的第一个弧的指针
    struct VNode *next; // 指向下一个VNode的指针
} VNode; // 定义表结构体,用于构建顶点链表

 3)

  • 192.1.1.0:1
  • 192.1.5.0:4
  • 192.1.6.0:5
  • 192.1.7.0:8
写代码:定义一个顺序存储的图(邻接矩阵实现)
写代码:定义一个链式存储的图(邻接表实现)
自己设计一个不少于6个结点的带权无向图,并画出其邻接矩阵、邻接表的样子
自己设计一个不少于6个结点的带权有向图,并画出其邻接矩阵、邻接表的样子

分析: 先考虑下邻接矩阵的长相,其实就是存边对吧,但是需要一个额外的数组用于存储顶点集。还需要几个变量用于存储图的顶点数和边数。

#define MaxVertexNum 100    //项目数目的最大值(自己定)
typedef char VertexType;    //顶点对应的数据类型
typedef int EdgeType;    //边对应的数据类型
typedef struct{
    VertexType Vex[MaxVertexNum];    //顶点集
    EdgeType Edge[MaxVertexNum][MaxVertexNum];    //邻接矩阵,边表(也就是权重)
    int vexnum,arcnum;    //图的当前顶点数和边数
}MGraph;

那邻接表和上头那个还是差蛮大的,是不是又有边表,又有顶点表。然后边表中是不是有个邻接点域(该弧所指向的顶点的位置(弧头))和指针域,而顶点表中存放的讯息则是一个顶点域和一个边表头指针。(个人觉得最重要的是要把图记下来,手搓代码反而是其次的)

#define MaxVertexNum 100    //图中顶点数目的最大值
typedef struct ArcNode{    //边表结点
    int adjvex;    //该弧所指向的顶点的位置
    struct ArcNode *next;    //指向下一条弧的指针
    //InfoType info;    //网的边权值
}ArcNode;

typedef struct VNode{    //顶点表结点
    VertexType data;    //顶点信息
    ArcNode *first;    //指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];

typedef struct{
    AdjList vertices;    //邻接表
    int vexnum,arcnum;    //图的顶点数和弧数
}ALGraph;    //ALGraph是以邻接表存储的图类型

5、最小生成树(MST)

【2017统考真题】使用Prim算法求带权连通图的最小(代价)生成树(MST)。请回答下列问题:
1)对下列图G,从顶点A开始求G的MST,依次给出按算法选出的边。
2)图G的MST是唯一的吗?
3)对任意的带权连通图,满足什么条件时,其MST是唯一的?

分析:Prim还是简单的吧,从某一个顶点开始构建最小生成树,依次加入当前剩余顶点中代价最小的顶点,直到加入所有顶点。

那MST是否唯一呢?显然是唯一的,因为这四条边的权值最小(AE会被自动排除因为如果有AE的话会造成环路,这是不允许的)所以无论你从哪个结点开始遍历最后的生成树都长这个样子。

从中我们可以得到启发,只要在一张带权连通图中的任意一个环各边的权值都不想等即可满足其生成树必然唯一。

分析:读完题干我们发现,是不是只要考虑权值之和最少且能连上所有结点就完事了呗,那这是啥?不就是MST嘛。

那我们就有方向了,不就是考我们这俩算法生成的树呗。

用邻接表还是邻接矩阵其实都可以。TTL为5的话,意思应该就是最远传输距离吧,那看图便一目了然了,左边可以,右边不行。 

六、查找

1、散列表

【2010统考真题】将关键字序列(7,8,30,11,18,9,14)散列存储到散列表中。散列表的存储空间是一个下标从0开始的一维数组,散列函数为H(key)=(key×3)mod7,处理冲突采用线性探测再散列法,要求装填(载)因子为0.7。
1)请画出所构造的散列表。
2)分别计算等概率情况下,查找成功和查找不成功的平均查找长度。

注:

  • 装载因子是一个很容易被遗忘的知识点。
  • ASL成功和ASL不成功是根本不一样的哦,其关键就在于一个是key,而另一个是h(key),一个的次数是指找到(成功)一个的次数是指找不到(失败)。

2、顺序查找和折半查找

【2013统考真题】设包含4个数据元素的集合S={'do','for','repeat','while'}各元素的查找概率依次为P=0.35,P=0.15,P=0.15,p=0.35。将S保存在一个长度为4的顺序表中,采用折半查找法,查找成功时的平均查找长度为2.2。
1)若采用顺序存储结构保存S,且要求平均查找长度更短,则元素应如何排列?应使用何种查找方法?查找成功时的平均查找长度是多少?
2)若采用链式存储结构保存S,且要求平均查找长度更短,则元素应如何排列?应使用何种查找方法?查找成功时的平均查找长度是多少?

分析:折半查找会生成折半查找树,故其ASL应该是这样的

那折半查找默认要求应该是元素有序的,故其数据元素应该按照字典序排列(do<for<repeat<while)但是这题的查找概率并不是一样的,所以折半查找未必是最优。那我们用顺序存储结构保存的话如何使其ASL变短呢?其实也很简单就是将概率更大的元素放在前面,这样查找长度会比较短。链式当然也是一样的咯。(答案有更好的做法)

七、排序 

1、插入排序

2、交换排序

3、选择排序(简单选择、堆)

【2022统考真题】现有n(n>100000)个数保存在一维数组M中,需要查找M中最小的10个数。请回答下列问题
1)设计一个完成上述查找任务的算法,要求平均情况下的比较次数尽可能少,简述其算法思想(不需要编程实现)。
2)说明你所设计的算法平均情况下的时间复杂度和空间复杂度。

分析:比较次数较少,那就是要找的比较快咯,而且查找的又不是只有一个数的话,应该可以用小根堆来实现呀,也就是堆排序。那确定算法之后,只要简单说一下其思想过程是什么就好了。

堆的插入:对于大(或小)根堆,要插入的元素放到表尾,然后与父节点对比,若新元素比父节点更大(或小),则将二者互换。新元素就这样一路“上升”,直到无法继续上升为止。

还有一点需要注意的是,我们只需要10个最小数嘛,即咱只需要维护一个10元素大根堆就好啦。那为什么是大根堆嘞,原因很简单,你用小根堆其实不太好比较。大队堆则只需要你先拎出来10个元素塞进去,然后从第11起,你都和其堆顶(当前最大值)比较一次,如果小于的话,就将其插入并删除根顶。如此反复,到最后根中剩下的10个元素一定就是所要寻找的10个最小元素。

1)算法思想。
【方法1】定义含10个元素的数组A,初始时元素值均为该数组类型能表示的最大数MAX。

for M中的每个元素s
       if(s<A[9)丢弃A[9]并将s按升序插入A;
当数据全部扫描完毕,数组A[O]~A[9]保存的就是最小的10个数。

【方法2】定义含10个元素的大根堆H(,元素值均为该堆元素类型能表示的最大数MA。

for M中的每个元素s
        if(S<H的堆顶元素)删除堆顶元素并将s插入H;
当数据全部扫描完毕,堆H中保存的就是最小的10个数。

2)算法平均情况下的时间复杂度是O(n),空间复杂度是O(1)。

注:两个方法的思路其实差不多。

自己设计一个长度不小于10的乱序数组,用堆排序,最终要生成升序数组,画出建堆后的状态
画出每一轮堆排序的状态

分析: 由于最终是要生成升序数组,故而我们应该使用大根堆。

4、Other排序(归并、基数、*计数)

    

分析:大概遍历一边代码,我们可以发现该函数实现的功能其实就是利用一个指针实现了数组按从小到大的排序。(基于计数排序的思想,count数组第i个元素记录着比该元素小的个数)

1)那自然就是 b[6] = {-10、10、11、19、25、25}

2)比较次数的话,因为每一趟i都是往后全部遍历一遍的,所以应该是n-1 + n-2 + ... + 1 = n(n+1)/2(一定是n-1开始蛤,因为咱自己是不需要和自己比对的)

3)既然它诚心诚意地问了,我便大发慈悲告诉它,并不稳定蛤。

我们可以看到25的相对次序发生了改变,所以是不稳定的,那怎么能够使其出现相同大小的情况,也不会改变相对次序呢?很显然就是调整一下边界的判断条件。

if(a[i]<=a[j])    count[j]++;
else    count[i]++;

自己设计一个长度不小于15的乱序链表,每个数据元素取值范围0~99,用基数排序,最终要生成升序链表
画出每一轮基数排序的状态

5、外部排序

【2023统考真题】对含有n(n>0)个记录的文件进行外部排序,采用置换-选择排序生成初始归并段时需要使用一个工作区,工作区中能保存m个记录。请回答:
1)若文件中含有19个记录,其关键字依次是51,94,37,92,14,63,15,99,48,56,23,60,31,17,43,8,90,166,100,则当m=4时,可生成几个初始归并段?各是什么?
2)对任意的m(n>>m>0),生成的第一个初始归并段的长度最大值和最小值分别是多少?

分析:本题没什么好分析的,直接开干就完事了。

1)

由上图可知,可以生成3个归并段分别是{37,51,63,92,94,99}、{14,15,23,31,48,56,60,90,166}、{8,17,41,100}(但其实手算的话不需要这么复杂,直接看和抄录最小值即可)

2)那最牛犇的情况就是全部有序嘛,一次就干完了,那就是长度为n呗;那最菜的情况就是拎一个出来之后再也没有比它大(小),那就是长度为1呗。

好,我才是菜的那个,答案里面说,最小值是m。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值