数据结构课程设计(算法)

问题 A: 复杂度分析(Ⅰ)

一、题目描述

分析如下代码

cpp 复制代码
for(i=1;i<n;i++)
    for(j=1;j<i;j++)
        for(k=1;k<j;k++)
            printf("\n");

问printf语句共执行了几次?这段代码执行完以后i+j+k值为多少?

二、输入

由多行组成,每行一个整数n, 1<= n <= 3000

三、输出

对每一行输入,输出对应的一行,包括空格分开的两个整数,分别代表printf语句的执行次数以及代码执行完以后i+j+k的值, 如果值不确定,输出"RANDOM"取代值的位置

四、算法分析

通常在分析一个算法的执行时间(时间复杂度)时,可以通过分析算法中的"基本语句"的执行次数(频度)来计算其执行时间(时间复杂度)。所谓"基本语句"指的是算法中重复执行次数和算法的执行时间成正比的语句,它对算法运行时间的贡献最大。通常,算法的执行时间是随问题规模增长而增长的,因此对算法的评价通常只需考虑其随问题规模增长的趋势。这种情况下,我们只需要考虑当问题规模充分大时,算法中基本语句的执行次数在渐近意义下的阶。

一般情况下算法中基本语句重复执行的次数是问题规模n的某个函数f(n),算法的时间复杂度记作T(n)=O(f(n)),它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度(时间复杂度)。

因此当给定了具体代码以后可以逐次分析语句的频度并通过求和运算得到具体某个语句的执行次数。

五、算法实现

流程图如下:

核心算法描述如下:

cpp 复制代码
while(~scanf("%lld",&n)){
    if(n>2) {
        printf("%lld ",((n-3)*(n-2)*(2*n-5)/6+(n-3)*(n-2)/2)/2);
        printf("%lld\n",n-1+n-2+n);
    } else 
        printf("0 RANDOM\n");
}

分析如下:

算法的时间复杂度为O(n³)。

逐次分析三个for语句,从内到外可分析出printf语句的执行次数为:

算法的空间复杂度为S(1)。

六、小结

题目考查了时间复杂度的基本概念以及如何分析代码语句的频度,并利用其频度和循环嵌套的结构特点计算出f(n)的表达式,由此代入具体的值以求解printf执行的次数,同理也可分析循环变量i,j,k的末态值的和。这里要注意到尽管n的值最大只有3000,但是最终循环次数的值会很大,故必须要定义long long类型的变量,否则将无法得到正确答案。

问题 B: 复杂度分析(Ⅱ)

一、题目描述

有如下代码段(n为正整数):

cpp 复制代码
i=1;
while(i++<n) {
    j=1;
    while(j++<i){
        k=1;
        while(k++<j)
            printf("\n");
    }
} 

问printf语句共执行了几次?这段代码执行完以后i+j+k值为多少?

二、输入

由多行组成,每行一个整数n, 1<= n <= 3000

三、输出

对每一行输入,输出对应的一行,包括空格分开的两个整数,分别代表printf语句的执行次数以及代码执行完以后i+j+k的值, 如果值不确定,输出"RANDOM"取代值的位置。

四、算法分析

通常在分析一个算法的执行时间(时间复杂度)时,可以通过分析算法中的"基本语句"的执行次数(频度)来计算其执行时间(时间复杂度)。所谓"基本语句"指的是算法中重复执行次数和算法的执行时间成正比的语句,它对算法运行时间的贡献最大。通常,算法的执行时间是随问题规模增长而增长的,因此对算法的评价通常只需考虑其随问题规模增长的趋势。这种情况下,我们只需要考虑当问题规模充分大时,算法中基本语句的执行次数在渐近意义下的阶。

一般情况下算法中基本语句重复执行的次数是问题规模n的某个函数f(n),算法的时间复杂度记作T(n)=O(f(n)),它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度(时间复杂度)。

因此当给定了具体代码以后可以逐次分析语句的频度并通过求和运算得到具体某个语句的执行次数。

五、算法实现

流程图如下:

核心算法描述如下:

cpp 复制代码
while(~scanf("%lld",&n)){
    if(n == 2) 
        printf("1 9\n");
    else if(n > 2) 
        printf("%lld %lld\n",n*(n+1)*(n-1)/6,3*n+3);
    else
        printf("0 RANDOM\n");
}

分析如下:

算法的时间复杂度为O(n³)。

逐次分析三个for语句,从内到外可分析出printf语句的执行次数为:

算法的空间复杂度为S(1)。

六、小结

题目考查了时间复杂度的基本概念以及如何分析代码语句的频度,并利用其频度和循环嵌套的结构特点计算出f(n)的表达式,由此代入具体的值以求解printf执行的次数,同理也可分析循环变量i,j,k的末态值的和。这里要注意到尽管n的值最大只有3000,但是最终循环次数的值会很大,故必须要定义long long类型的变量,否则将无法得到正确答案。

问题 C+D+E: Josephus问题(Ⅰ)(Ⅱ)(Ⅲ)

一、题目描述

n个人排成一圈,按顺时针方向依次编号1,2,3...n。从编号为1的人开始顺时针"一二三...."报数,报到m的人退出圈子。这样不断循环下去,圈子里的人将不断减少。最终一定会剩下一个人。试问最后剩下的人的编号。

二、输入

不超过1000组数据。

每组数据一行,每行两个正整数,代表人数n (1 <= n < )和m(1<=m<=1000)。

三、输出

每组输入数据输出一行, 仅包含一个整数,代表最后剩下的人的编号。

四、算法分析

第一个Josephus问题属于常规问题,可以用循环链表的结构存储数据并解决该问题。

由题可知,循环链表的抽象数据类型定义如下:

cpp 复制代码
ADT sq {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    基本操作:
        InitsqList(&sq)        //构造一个空的循环链表
        GetElem(sq)            //返回sq中的数据元素
        sqListInsert(&sq)      //向sq中插入数据元素
        sqListDelete(&sq)      //向sq中删除数据元素
};

第二个Josephus问题数据量n的规模非常大,因此已经不适合使用链表的结构进行求解,应考虑通过数学分析寻求效率更高的解法如显式公式、递推公式等多种可能存在的有效解法。此题可以考虑通过循环用特定的公式进行求解,从而有效降低时间复杂度的阶。

而相比于前两个Josephus问题来说,第三个的数据量n的规模同样非常大,并且对执行速度提出了更高的要求(经测试,时间复杂度的阶至少要达到对数阶)。因此已经不适合使用链表的结构或者普通的递推公式进行求解,而应考虑通过数学分析寻求效率更高的有效解法。此题可以考虑针对第二个Josephus问题的算法思想以及相关的数学原理进一步优化循环结构和内部的语句,从而有效降低时间复杂度的阶。

五、算法实现

1、Josephus问题(Ⅰ)

先定义集合类型和结点类型,如下所示:

cpp 复制代码
typedef struct sq {
    int num;       //结点数据域
    struct sq* next;    //结点指针域
}SQ;

再为每个输入的数据开辟空间存储,如下所示:

cpp 复制代码
SQ* createnode() {
    SQ* node = (SQ*)malloc(sizeof(SQ));
    node->next = NULL;
    node->num = 0;
    return node;
}

读入数据时区分一下人数为1和大于1的情况,一个人时也就是这个人能存活,否则就要将人数存入结构,如下所示:

cpp 复制代码
for (i = 1;i <= n;i++) {
    c = createnode(); 
    root->next = c;
    c->next = top; 
    c->num = i;
    root = c; 
    c = NULL;
}

最后对循环链表内的成员进行报数处决,即将对应结点删除并将其前驱和后继连接后再计数继续处决,最后计算出存活的人的号码,如下所示:

cpp 复制代码
root->next = top->next;
free(top); 
top = root->next; 
SQ* p;
while (1) {
    if (x == 1)
        break;
    p = createnode();
    p = top->next;
    top->next = top->next->next;
    top = top->next;
    free(p); 
    p = NULL; 
    x--;
}
printf("%d\n", top->num);

算法的时间复杂度为O(n)。

建立循环链表时要创建n个结点,语句频度为n;删除循环链表结点时每次只删除一个结点,要执行n-1次才能得到结果,则语句频度为n。因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立循环链表时需要动态申请n个结点,故有S(n)=O(n)。

2、Josephus问题(Ⅱ)

流程图如下:

核心算法描述如下:

cpp 复制代码
for(count = 1, n = number; n >= 2; n /= 2) {
    number -= count;
    count *= 2;
}
printf("%d\n", number * 2 - 1);

算法的时间复杂度为O(logn)。

执行for循环时n每循环一次都会减半,即语句的频度为logn,其他语句的频度均为1,因此取语句频度最大的作为时间复杂度,即T(n)=O(logn)。

算法的空间复杂度为O(1)。

3、Josephus问题(Ⅲ)

流程图如下:

核心算法描述如下(单独讨论n = 1的情况,即存活的人就是此人):

cpp 复制代码
result = 0; 
count = 1;
while (count < n) {
    if (result + m < count) {
        k = (count - 1 - result) / m;
        S = min(k, n - count);
        count += S; 
        result += S * m;
    } else {
        result = (result + m) % (count + 1);
        count++;
    }
}
printf("%lld\n", result + 1);

算法的时间复杂度为O(logn)。

while循环的语句的频度为logn,而其他语句的频度均为常数阶,因此取语句频度最大的作为时间复杂度,即T(n)=O(logn)。

算法的空间复杂度为O(1)。

六、小结

第一个问题考查了对Josephus问题的理解以及如何利用循环链表的特点来解决Josephus问题,通过此题巩固了对循环链表部分基本操作的理解,培养了运用循环链表去解决实际问题的能力。

第二个问题还考查了对Josephus问题进行数学分析的能力,注意到相比于Josephus问题(Ⅰ)来说,此题的数据量n的规模非常大(),因此必须采用数学分析的方法降低时间复杂度的阶,避免时间超限。

而相比于Josephus问题(Ⅰ)(Ⅱ)来说,第三个问题需要处理的数据量n的规模同样非常大(),并且对于可变的间隔人数m,需要找到更普适的数学方程求解,同时还应避免时间超限。

问题 F: Josephus Problem

一、题目描述

Do you know the famous Josephus Problem? There are n people standing in a circle waiting to be executed. The counting out begins at the first people in the circle and proceeds around the circle in the counterclockwise direction. In each step, a certain number of people are skipped and the next person is executed. The elimination proceeds around the circle (which is becoming smaller and smaller as the executed people are removed), until only the last person remains, who is given freedom.

In traditional Josephus Problem, the number of people skipped in each round is fixed, so it's easy to find the people executed in the i-th round. However, in this problem, the number of people skipped in each round is generated by a pseudorandom number generator:

x[i+1] = (x[i] * A + B) % M.

Can you still find the people executed in the i-th round?

二、输入

There are multiple test cases.

The first line of each test cases contains six integers 2 ≤ n ≤ 100000, 0 ≤ m ≤ 100000, 0 ≤ x[1], A, B < M ≤ 100000. The second line contains m integers 1 ≤ q[i] < n.

三、输出

For each test case, output a line containing m integers, the people executed in the q[i]-th round.

四、算法分析

此题相比于前面所有的Josephus问题来说更加复杂,在保证数据量的同时,处决间隔人数也采用了伪随机数生成器来获得,并要求获取指定次数的处决编号。此题可以考虑结合之前的Josephus问题的算法思想以及相关的数学原理进一步优化循环结构和内部的语句,同时对伪随机数生成器的间隔做出处理。该题适合使用线段树或树状数组完成。

五、算法实现

流程图如下:

核心算法描述如下:

首先完成对线段树的定义和建立,如下所示:

cpp 复制代码
void build(int l, int r, int rt) {   //线段树求解
    sum[rt] = r - l + 1;
    if(l == r)
        return;
    int m = (l + r) / 2;
    build(ltree);      //#define ltree l, m, rt << 1
    build(rtree);      //#define rtree m + 1, r, rt << 1|1
}

再设计完成求解过程对线段树的更新,如下所示:

cpp 复制代码
int update(int l, int r, int rt, int num) {   //更新线段树
    int tree;
    sum[rt]--;
    int m = (l + r) / 2;
    if(l == r)
        return l;
    if(num <= sum[rt << 1]){
        tree = update(ltree,num);
    } else {
        tree = update(rtree, num - sum[rt << 1]);
    }
    return tree;
}

最后在主函数内读取数据后根据伪随机数生成器生成的间隔更新线段树,如下所示:

cpp 复制代码
for(int i = 1; i <= n; i++) {
    if(i != 1) {
        x=((long long)x * A + B) % M;
    }
    result = (result + x) % sum[1];
    if(result == 0) {
        result = sum[1];
    }
    a[i] = update(1, n, 1, result);
}

分析如下:

算法的时间复杂度为O(n)。

主函数内循环的语句频度均为一次函数,其中语句频度较大的是后两个for循环,语句频度为O(n),内部语句的语句频度为常数阶(包括子函数),因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立线段树时需要动态申请n个结点,故有S(n)=O(n)。

六、小结

题目考查了对非传统Josephus问题的理解和对Josephus问题进行数学分析的能力,此题的关键是伪随机数生成器给出的处决间隔人数,兼顾效率和结构,此题没有明显的显式公式,推导过程也比较困难,对于考查的线段树和树状数组也了解得不够全面,在解题过程中推进十分艰难,最终也未能独立解答该题。此题对数学能力和算法结构编程能力提出了一定的要求,也发现自己在数学分析能力上存在着不足,今后也应更加重视。

问题 G: 交集

一、题目描述

有两个相等长度的正整数序列A和B,都是有序的(递增排序),同时一个序列中没有重复元素,现在需要求这两个序列的交――序列C,同时打印输出。

二、输入

输入由多组测试用例组成。

每个测试用例一共有2*n+1行,第一行输入为数列的长度n,然后下面2~n+1行,依次输入序列A中的数。n+2~2*n+1行,依次输入序列B中的数。其中 1 <= n <= 50000 , 序列中每个数大小不会超过1000000。

当程序输入n为0时表示结束。

三、输出

每个测试用例输出一行,先输出序列C的长度,然后依次输出C中的整数,两个数之间间隔一个空格。注意行末不要出现空格。C中的整数递增排序。

四、算法分析

顺序表的抽象数据类型定义如下:

cpp 复制代码
ADT num {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    基本操作:
        InitsqList(&num)        //构造空的顺序表num
        GetElem(num)            //读取num中的数据元素
        ListInsert(&num)        //向num中加入数据元素
};

五、算法实现

流程演示及核心算法如下(以表中数据为例):

cpp 复制代码
if(num[n - 1] < num[n]) 
    k = 0;
else {
    for(i = 0, j = n; i < n && j < n * 2;) {
        if (num[i] < num[j]) 
            i++;
        else if (num[i] > num[j]) 
            j++;
        else if (num[i] == num[j]) {
            count[t] = num[i];
            i++; j++; k++; t++; 
        }
    }
}

此题的关键在于如何去比较两组数据的每个数据元素是否相等,这里可以先将两组数据存在同一个顺序表内,假设长度均为n,则第一组数据的第一个元素位置为0,最后一个元素的位置为n-1;第二组数据的第一个元素位置为n,最后一个元素的位置为2n-1。因此可以设置两个"指针"(第一组数据用i,第二组数据用j)分别放在两组数据的起始位置,数据更大的那一组数据的"指针"依次向前移动1位,如果两个所指元素相等,就将该元素存入另一个用于输出的顺序表内并计数,直到有一个"指针"遍历完一组数据,检查结束并将用于输出的顺序表内的数据个数和数据元素输出即为结果。

分析如下:

算法的时间复杂度为O(n)。

读入数据的语句频度为n,而读取数据并进行比较的语句是两组长度为n的顺序表进行比较,次数在n到2n之间,其语句频度也为n,输出同理。因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立两个顺序表至少分别需要开辟2n、n个结点,故有S(n)=O(n)。

六、小结

题目考察了顺序表的运用,利用顺序表的结构特点和对每一位数据依次比较的思想进行比较求解。此题的思路相对比较简单,实现难度也并不大,代码也具有较高的可读性,主要巩固了对顺序表基本操作的实现方法。

问题 H: 大爱线性表

一、题目描述

不少参赛同学刚学数据结构,对线性表最是熟悉不过。这里我们给线性表增加两个特殊的操作,第一个是'R' 操作,表示逆转整个表,如果表长为L,原来的第i个元素变成第L-i+1个元素。第二个操作是'D',表示删除表的第一个元素,如果表为空,则返回一个"error"信息。我们可以给出一系列的'R' 和'D'组合,例如"RDD"表示先逆转表,然后删除最前面的两个元素。

本题的任务是给定表和一个操作串S,求出执行S后的表,如果中途出现'D'操作于空表,输出"error"。

二、输入

第一行是一个整数,表示有多少组数据。每组测试数据格式如下:

(1)第一行是操作串S,有'R' 和'D'组成,S的长度大于0,不超过100 000;

(2)第二行是整数n,表示初始时表中的元素个数。n的值不小于0,不超过100 000;

(3) 第三行是包含n个元素的表,用'[' 和 ']'括起来,元素之间用逗号分开。各元素值在[1,100]之间。

三、输出

对于每一组测试数据,输出执行S后的表(格式要求同输入)或者"error"。

四、算法分析

本题可用顺序表或链表完成,而经过分析不难发现,此题选用顺序表会更节约时间,主要体现在逆转操作上,链表需要对每个元素进行操作,而对于顺序表而言即换一个方向读取数据,因此省去了真正逆转整个表的时间,提高了算法效率。

顺序表的抽象数据类型定义如下:

cpp 复制代码
ADT List {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    基本操作:
        InitsqList(&List)        //构造空的顺序表List
        GetElem(List)            //读取List中的数据元素
        ListInsert(&List)        //向List中加入数据元素
        ListDelete(&List)        //向List中删除数据元素
        ListReverse(&List)       //将List的数据位置逆转
};

五、算法实现

此题关键在于如何实现逆转。先用start和end指向首尾,对操作字符串逐项读取,如果是"R"就用sum计数。当读取到"D"时如果sum为奇数,表被逆转,于是就令end向左移动删除元素,反之表未被逆转,于是就令start向右移动删除元素,如下所示:

cpp 复制代码
for(i = 0; i < len; i++) {
    if(ch[i] == 'R') 
        sum++;
    else if(ch[i] == 'D') {
        if(start > end) 
            flag = 1, break;
        if(sum % 2 == 0)
            start++;
        else
            end--;
    }  
}

当操作完成后可以根据sum的奇偶性来判断顺序表是否被逆转,从而判断是顺序输出还是逆序输出。如果sum为奇数,表被逆转,于是就令end向左移动输出元素;反之表未被逆转,于是就令start向右移动输出元素。注意flag的作用是检测是否出现删除过空表,有则输出错误信息,否则按照上述输出结果,如下所示:

cpp 复制代码
if(flag) 
    cout<<"error"<<endl;
else {
    cout << "[";
    if(sum % 2 == 0)
        if(start <= end) {
            for(i = start; i < end;i++) 
                cout << s[i] << ",";
            cout << s[end];
        } else if(start <= end) {
            for(i = end; i > start; i--) 
                cout << s[i] << ",";
            cout << s[start];
        }
        cout << "]" << endl;
}

流程图如下:

分析如下:

算法的时间复杂度为O(n)。

将输入的数据存入顺序表中的语句频度为n;而此处逆转表的操作选择了控制两端指针的方式,即并没有真正移动顺序表内的数据,因此该操作的语句频度为1;删除操作的执行次数由读入的"D"的次数决定,因此语句频度为n。因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立一个顺序表需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考查了对线性表的理解,结合顺序表和链表的结构特点分析,初次尝试时想到之前课上练习过的原地逆转单链表的代码,因此选择了用链表解题,但由于在逆转操作上更耗时以及代码的优化不足,出现了时间超限的问题。后来想到顺序表的逆转就是换一个方向读取数据,因此省去了真正逆转整个表的时间,提高了算法效率,也算是对算法的进一步改进优化,加深了对线性表的理解。

问题 I+J+K: 单词检查(Ⅰ)(Ⅱ)(Ⅲ)

一、题目描述

许多应用程序,如字处理软件,邮件客户端等,都包含了单词检查特性。单词检查是根据字典,找出输入文本中拼错的单词,我们认为凡是不出现在字典中的单词都是错误单词。不仅如此,一些检查程序还能给出类似拼错单词的修改建议单词。 例如字典由下面几个单词组成:

bake cake main rain vase

如果输入文件中有词vake ,检查程序就能发现其是一个错误的单词,并且给出 bake, cake或vase做为修改建议单词。

修改建议单词可以采用如下生成技术:

(1)在每一个可能位置插入'a-'z'中的一者

(2)删除单词中的一个字符

(3)用'a'-'z'中的一者取代单词中的任一字符

很明显拼写检查程序的核心操作是在字典中查找某个单词,如果字典很大,性能无疑是非常关键的。

你写的程序要求读入字典文件,然后对一个输入文件的单词进行检查,列出其中的错误单词并给出修改建议。

课程设计必须采用如下技术完成并进行复杂度分析及性能比较。

(1)朴素的算法,用线性表维护字典

(2)使用二叉排序树维护字典

(3)采用hash技术维护字典

二、输入

输入分为两部分。

第一部分是字典,每个单词占据一行,最后以仅包含'#'的一行表示结束。所有的单词都是不同的,字典中最多10000个单词。

输入的第二部分包含了所有待检测的单词,单词数目不超过50。每个单词占据一行,最后以仅包含'#'的一行表示结束。

字典中的单词和待检测的单词均由小写字母组成,并且单词最大长度为15。

三、输出

按照检查次序每个单词输出一行,该行首先输出单词自身。如果单词在字典中出现,接着输出" is correct"。如果单词是错误的,那么接着输出':',如果字典中有建议修改单词,则按照字典中出现的先后次序输出所有的建议修改单词(每个前面都添加一个空格),如果无建议修改单词,在':'后直接换行。

四、算法分析

题目要求读入字典文件,然后对一个输入文件的单词进行检查,列出其中的错误单词并给出修改建议,其中修改建议的具体操作方法已经在题目中给出,不难发现本质上就是让输入的单词与字典中存在的三种类型(多一个字母、少一个字母和长度相等)的单词进行逐位比较即可,把符合要求的单词按照读入字典的顺序输出即可。要求三个程序分别使用线性表、二叉排序树和Hash表维护字典,其中第一个程序分析后选择使用顺序表来维护字典。

顺序表的抽象数据类型定义如下:

cpp 复制代码
ADT List {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    基本操作:
        InitsqList(&List)        //构造空的顺序表List
        GetElem(List)            //读取List中的数据元素
        ListInsert(&List)        //向List中加入数据元素
        ListSearch(List)         //从List中查找数据元素
};

二叉树的抽象数据类型定义如下:

cpp 复制代码
ADT BinaryTree {
    数据对象D: D是具有相同特性的数据元素的集合。
    数据关系R:
        若D=φ,则R=φ,称BinaryTree为空二叉树;
        若D≠φ,则R={H}, H是如下二元关系:
            (1)在D中存在唯一的称为根的数据元素root,它在关系H下无前驱;
            (2)若D-{root)≠φ,则存在D-(root)=(D1, Dr), 且D1∩Dr=φ;
            (3)若Dl≠φ,则Dl中存在唯一的元素xl,<root,xl>∈H, 且存在Dl上的关系Hl⊂H;若Dr≠φ,则Dr中存在唯一的元素xr,<root,xr>∈H,且存在Dr上的关系Hr⊂H;H={<root, xl>,< root,xr>, Hl,Hr};
            (4)(Dl, {Hl})是一棵符合本定义的二叉树,称为根的左子树,(Dr, {Hr})是棵符合本定义的二叉树,称为根的右子树。
    基本操作P:
        InitBiTree(&Tree)        //构造空的二叉树Tree
        CreateBiTree(&Tree)      //创建二叉树Tree
        PostOrderTraverse(Tree)  //后序遍历二叉树Tree
};

五、算法实现

单词检查的三种结构无论是哪一种方法,关键都是如何查找字典中的单词以及如何完成修改建议,这里首先可以对每一个读入的单词和字典内的所有单词进行匹配,如果找到完全相同的单词就将其按格式输出,如下所示:

cpp 复制代码
if(!strcmp(List[i].Elem, S.Elem)) {
    printf("%s is correct\n", S.Elem);
    flag = 0;
    break;
}

如若未找到完全相同的单词的话,就可以根据分析题目得知的三种类型分别进行查找,查找的过程可以先用两个指针分别指向字典内符合查找条件的单词首字母和输入的单词首字母,如果字母相同,就令两个指针都向前移动一个字母的长度;如果字母不相同,就令长度较短的单词的指针原地不动,再令长度较长的单词的指针向前移动一个字母的长度,并用计数变量Difwords计数,如果Difwords大于1就说明该单词不符合修改建议的要求,跳过当前循环并继续查找下一个单词,直到遍历完整个字典,如下所示:

cpp 复制代码
if(S.length == List[i].length + 1) {    //多一个字母的单词
    for(j = k = Difwords = 0; List[i].Elem[j] != '\0'; j++, k++ ) {
        if(List[i].Elem[j] != S.Elem[k]) {
            Difwords++; j--;
        }
        if(Difwords > 1)
            break;
    }
    if(Difwords <= 1) 
        printf(" %s",List[i].Elem);
}

if(S.length == List[i].length - 1) {    //少一个字母的单词
    for(j = k = Difwords = 0; S.Elem[k] != '\0'; j++, k++ ) {
        if(List[i].Elem[j] != S.Elem[k]) {
            Difwords++; 
            k--;
        }
        if(Difwords > 1)
            break;
    }
    if(Difwords <= 1)
        printf(" %s",List[i].Elem);
}

if(S.length == List[i].length) {        //长度相等的单词
    for(j = k = Difwords = 0; S.Elem[k] != '\0'; j++, k++ ) {
        if(List[i].Elem[j] != S.Elem[k]) 
            Difwords++;
        if(Difwords > 1) 
            break;
    }
    if(Difwords <= 1) 
        printf(" %s",List[i].Elem);
}

分析如下:

顺序表和二叉排序树算法的时间复杂度为O(n),Hash表算法的时间复杂度为O(n2)。

三种数据结构的算法读入字典操作的语句频度都为n,而顺序表和二叉排序树的查找操作是直接进入表中遍历整个字典,语句频度为n,而对单词的修改建议部分是对单词字母的逐个比较,语句频度为常数,因此取语句频度最大的作为时间复杂度,即T(n)=O(n);而在Hash表中除了有语句频度为n的遍历字典操作以外,还有一个按序移动表内元素的操作,语句频度也为n,这两部分存在嵌套关系,则整个语句的语句频度为n2,因此取语句频度最大的作为时间复杂度,即T(n)=O(n2)。

算法的空间复杂度为O(n)。

建立字典需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考察了顺序表、二叉排序树和Hash表的应用,通过对题目对修改建议的描述,设计出了合理的查字典的方法。该题用顺序表和二叉排序树来维护字典的难度不大,但由于对Hash表给出的操作的不熟悉以及设计Hash表的不合理,导致最后使用开放地址法时出现数组越界和使用链地址法时必出现聚集现象,从而出现运行错误和时间超限等问题,最终未能独立完成Hash表的设计,算法设计能力有待提升。

问题 L: 后缀表达式求值

一、题目描述

为了便于处理表达式,常常将普通表达式(称为中缀表示)转换为后缀{运算符在后,如X/Y写为XY/表达式。在这样的表示中可以不用括号即可确定求值的顺序,如:(P+Q)*(R-S) → PQ+RS-*。后缀表达式的处理过程如下:扫描后缀表达式,凡遇操作数则将之压进堆栈,遇运算符则从堆栈中弹出两个操作数进行该运算,将运算结果压栈,然后继续扫描,直到后缀表达式被扫描完毕为止,此时栈底元素即为该后缀表达式的值。

二、输入

输入一行表示后缀表达式,数与数之间一定有空格隔开(可能不只一个空格),最后输入@表示输入结束。

数据保证每一步的计算结果均为不超过100000的整数。

三、输出

输出一个整数,表示该表达式的值.

四、算法分析

根据题目描述和所学知识,可以利用栈的结构将操作数和运算符依次进栈并按照一定的规律进行进栈、出栈和运算,最终求得输入的后缀表达式的值。

顺序栈的抽象数据类型定义如下:

cpp 复制代码
ADT Stack {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    约定an端为栈顶,a1端为栈底。
    基本操作:
        InitStack(&S)        //构造空的顺序栈Stack
        StackEmpty(S)        //判断栈Stack是否为空
        StackFull(S)         //判断栈Stack是否为满
        Push(&S)             //将数据元素压入栈Stack
        Pop(&S)              //将数据元素弹出栈Stack
};

五、算法实现

流程演示如下:

对后缀表达式求值的基本方法为:扫描后缀表达式,凡遇操作数则将之压进堆栈,遇运算符则从堆栈中弹出两个操作数进行该运算,将运算结果压栈,然后继续扫描,直到后缀表达式被扫描完毕为止,此时栈底元素即为该后缀表达式的值。但是此题解题的关键在于数字,由于读入的后缀表达式是一个字符串,因此多位数的数字在字符串中不能表示一个正确的数字,因此需要将连续的数字进行转换,将其变为整型数字,从而可以进行后续的操作运算,如下所示:

cpp 复制代码
if(data[i] >= '0' && data[i] <= '9' && data[i] != ' ') {
    num = num * 10 + data[i] - '0';  //将字符形式的数字转换为整型数字
    flag = 1;                        //标志位检测数字
}

if((data[i] == '+' || data[i] == '-' || data[i] == '/' || data[i] == '*') && !flag) {
    op[len++] = data[i];             //将运算符保存在操作符数组内
    number[n++] = MAXSIZE;           //标记

}

if((data[i] == ' ' || data[i] == '+' || data[i] == '-' || data[i] == '/' || data[i] == '*') && flag) {
    number[n++] = num;               //将转换后的整形数字保存在数组内
    op[len++] = '#';                 //标记
    num = flag = 0;                  //重置数字和标志位
    if(data[i] == '+' || data[i] == '-' || data[i] == '/' || data[i] == '*') {
        op[len++] = data[i];         //将运算符保存在操作符数组内
        number[n++] = MAXSIZE;       //标记
    }
}

分析如下:

算法的时间复杂度为O(n)。

将字符串送入栈中的语句频度为n,计算过程的语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立顺序栈需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考察了栈的应用,此题考查的知识点也源自于数据结构的课上内容,因此题目总体完成起来较为顺利,只需要特别注意数字转换的问题即可。加深了对顺序栈的理解。

问题 M: 中缀表达式转后缀表达式

一、题目描述

输入一个中缀表达式,编程输出其后缀表达式,要求输出的后缀表达式的运算次序与输入的中缀表达式的运算次序相一致。为简单起见,假设输入的中缀表达式由+(加)、-(减)、×(乘)、/(除)四个运算符号以及左右圆括号和英文字母组成,其中算术运算符遵守先乘除后加减的运算规则。假设输入的中缀表达式长度不超过300个字符,且都是正确的,即没有语法错误,并且凡出现括号其内部一定有表达式,即内部至少有一个运算符号。

中缀表达式转后缀表达式的方法:

  1. 遇到操作数:直接输出(添加到后缀表达式中)
  2. 栈为空时,遇到运算符,直接入栈
  3. 遇到左括号:将其入栈
  4. 遇到右括号:执行出栈操作,并将出栈的元素输出,直到弹出栈的是左括号,括号不输出。
  5. 遇到其他运算符:加减乘除:弹出所有优先级大于或者等于该运算符的栈顶元素,然后将该运算符入栈
  6. 最终将栈中的元素依次出栈,输出。

二、输入

只有一行,为中缀表达式

三、输出

只有一行,为转换后的后缀表达式

四、算法分析

根据题目描述和所学知识,可以利用栈的结构将操作数和运算符依次进栈并按照一定的规律进行进栈、出栈,最终求得原中缀表达式转换后的后缀表达式。

顺序栈的抽象数据类型定义如下:

cpp 复制代码
ADT Stack {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    约定an端为栈顶,a1端为栈底。
    基本操作:
        InitStack(&S)        //构造空的顺序栈Stack
        StackEmpty(S)        //判断栈Stack是否为空
        StackFull(S)         //判断栈Stack是否为满
        GetTop(S)            //获取Stack栈的栈顶元素
        Push(&S)             //将数据元素压入栈Stack
        Pop(&S)              //将数据元素弹出栈Stack
};

五、算法实现

中缀表达式转后缀表达式的基本方法为:

  1. 1.遇到操作数:直接输出(添加到后缀表达式中);
  2. 2.栈为空时,遇到运算符,直接入栈;
  3. 3.遇到左括号:将其入栈;
  4. 4.遇到右括号:执行出栈操作,并将出栈的元素输出,直到弹出栈的是左括号,括号不输出;
  5. 5.遇到其他运算符:加减乘除:弹出所有优先级大于或者等于该运算符的栈顶元素,然后将该运算符入栈;
  6. 6.最终将栈中的元素依次出栈,输出。

这里要额外注意一个问题:遇到其他运算符弹出所有优先级大于或者等于该运算符的栈顶元素时,必须判断栈空,否则会因为栈空时执行GetTop函数时异常退出程序,如下所示:

cpp 复制代码
for (int i = 0; i < length; i++) {
    if (ch[i] >= 'A' && ch[i] <= 'Z' || ch[i] >= 'a' && ch[i] <= 'z')
        cout << ch[i];                                     //情况1
    else if (StackEmpty(sq) && (ch[i] == '+' || ch[i] == '-' || ch[i] == '*' || ch[i] == '/' || ch[i] == '('))  
        Push(sq, ch[i]);  //情况2
    else if (ch[i] == '(')  
        Push(sq, ch[i]);             //情况3
    else if (ch[i] == ')') {                              //情况4
        while (GetTop(sq) != '(') 
            cout << Pop(sq);
        Pop(sq);
    } else {                                              //情况5
        while (Judge(GetTop(sq), ch[i]) != '<') {
            cout << Pop(sq);
            if (StackEmpty(sq)) 
                break;
        }
        Push(sq, ch[i]);
    }
}
while (!StackEmpty(sq)) 
    cout << Pop(sq);                  //情况6

分析如下:

算法的时间复杂度为O(n)。

将字符串送入栈中的语句频度为n,题目给出的6种处理操作的最大语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立顺序栈需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考察了栈的应用,此题考查的知识点源自于数据结构的课上内容,并且中缀表达式转后缀表达式的方法也由题目给出,因此只需要按部就班设计完成操作即可这里只需要额外注意一个问题:遇到其他运算符弹出所有优先级大于或者等于该运算符的栈顶元素时,必须判断栈空,否则会因为栈空时执行GetTop函数时异常退出程序导致出现答案错误。

问题 N: 二叉树的创建和文本显示

一、题目描述

编一个程序,读入先序遍历字符串,根据此字符串建立一棵二叉树(以指针方式存储)。

例如如下的先序遍历字符串:

A ST C # # D 10 # G # # F # # #

各结点数据(长度不超过3),用空格分开,其中"#"代表空树。

建立起此二叉树以后,再按要求输出二叉树。

二、输入

输入由多组测试数据组成。

每组数据包含一行字符串,即二叉树的先序遍历,字符串长度大于0且不超过100。

三、输出

对于每组数据,显示对应的二叉树,然后再输出一空行。输出形式相当于常规树形左旋90度。见样例。 注意二叉树的每一层缩进为4,每一行行尾没有空格符号。

四、算法分析

根据题目描述,此题使用二叉树进行对先序遍历字符串的存储,然后实现将二叉树按常规树形左旋90°并按照指定格式要求打印。

二叉树的抽象数据类型定义如下:

cpp 复制代码
ADT BinaryTree {
    数据对象D: D是具有相同特性的数据元素的集合。
    数据关系R:
        若D=φ,则R=φ,称BinaryTree为空二叉树;
        若D≠φ,则R={H}, H是如下二元关系:
        (1)在D中存在唯一的称为根的数据元素root,它在关系H下无前驱;
        (2)若D-{root)≠φ,则存在D-(root)=(D1, Dr), 且D1∩Dr=φ;
        (3)若Dl≠φ,则Dl中存在唯一的元素xl,<root,xl>∈H, 且存在Dl上的关系Hl⊂H;若Dr≠φ,则Dr中存在唯一的元素xr,<root,xr>∈H,且存在Dr上的关系Hr⊂H;H={<root, xl>,< root,xr>, Hl,Hr};
        (4)(Dl, {Hl})是一棵符合本定义的二叉树,称为根的左子树,(Dr, {Hr})是棵符合本定义的二叉树,称为根的右子树。
基本操作P:
        InitBiTree(&Tree)        //构造空的二叉树Tree
        CreateBiTree(&Tree)      //创建二叉树Tree
        INorder(Tree, depth)     //反向后序遍历二叉树Tree
};

五、算法实现

此题的难点是如何实现二叉树按常规树形左旋90°,分析可以发现,二叉树每深入一层,文本的缩进都增加4,因此可以设置一个深度计数变量depth实现,并且从行的顺序来看是从右子树向左子树的中序遍历得到的结果,因此将正常的中序遍历的递归左子树和递归右子树交换位置即可,如下所示:

cpp 复制代码
void INorder(Bitree T, int depth) {
    if(T != NULL) {
        INorder(T->rchild, depth + 1);
        int n = depth * 4;
        while(n) 
            printf(" "), n--;
        printf("%s\n", T->data);
        INorder(T->lchild, depth + 1);
    }
}

分析如下:

算法的时间复杂度为O(n)。

将数据存入二叉树的语句频度为n,打印左旋树的语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立二叉树需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考察了二叉树的应用,左旋的操作相对而言不难,重点是注意增加缩进的位置和改变遍历左右子树的顺序即可,加深了对二叉树的理解。

问题 O: 表达式树的创建与输出

一、题目描述

编一个程序,读入先序遍历字符串,根据此字符串建立一棵二叉树(以指针方式存储),请注意的是,我们保证该树一定是表达式树(见教材5.2 5.8)。

例如下面的先序遍历字符串:

  • 13 # # * 5 # # 9 # #

运算符只可能是加减乘除,数值为小于等于100,各结点用空格分开,其中"#"代表空树。

建立起此二叉树以后,再按要求输出二叉树。

二、输入

输入由多组测试数据组成。

每组数据包含一行字符串,即表达式树的先序遍历序列,字符串长度大于0且不超过100。

三、输出

对于每组数据,输出一行,内容是该表达式树的全括号表达式。

四、算法分析

根据题目描述,此题使用二叉树进行对先序遍历字符串的存储,然后通过遍历二叉树的方式输出先序遍历字符串对应的以全括号形式输出的中序遍历字符串。

二叉树的抽象数据类型定义如下:

cpp 复制代码
ADT BinaryTree {
    数据对象D: D是具有相同特性的数据元素的集合。
    数据关系R:
        若D=φ,则R=φ,称BinaryTree为空二叉树;
        若D≠φ,则R={H}, H是如下二元关系:
        (1)在D中存在唯一的称为根的数据元素root,它在关系H下无前驱;
        (2)若D-{root)≠φ,则存在D-(root)=(D1, Dr), 且D1∩Dr=φ;
        (3)若Dl≠φ,则Dl中存在唯一的元素xl,<root,xl>∈H, 且存在Dl上的关系Hl⊂H;若Dr≠φ,则Dr中存在唯一的元素xr,<root,xr>∈H,且存在Dr上的关系Hr⊂H;H={<root, xl>,< root,xr>, Hl,Hr};
        (4)(Dl, {Hl})是一棵符合本定义的二叉树,称为根的左子树,(Dr, {Hr})是棵符合本定义的二叉树,称为根的右子树。
    基本操作P:
        InitBiTree(&Tree)        //构造空的二叉树Tree
        CreateBiTree(&Tree)      //创建二叉树Tree
        InOrderTraverse(Tree)    //中序遍历二叉树Tree
};

五、算法实现

题目的关键在于判断插入括号的位置,通过对全括号表达式的分析可知,括号的位置出现在每一个左子树和右子树的两侧,在输出每个左子树的数据之前输出左括号,再输出每个右子树的数据之后输出右括号,即可实现全括号表达式的输出,如下所示:

核心代码如下:

cpp 复制代码
void InOrderTraverse(BiTree T) {
    if (T) {
        if (T->data[0] == '+' || T->data[0] == '-' || T->data[0] == '*' || T->data[0] == '/') {
            cout << '(';
        }
        /*if (T->lchild) {
            cout << '(';
        }*/
        InOrderTraverse(T->lchild);
        cout << T->data;
        InOrderTraverse(T->rchild);
        /*if (T->rchild) {
            cout << ')';
        }*/
        if (T->data[0] == '+' || T->data[0] == '-' || T->data[0] == '*' || T->data[0] == '/') {
            cout << ')';
        }
    }
}

分析如下:

算法的时间复杂度为O(n)。

将数据存入二叉树的语句频度为n,中序遍历二叉树的语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立二叉树需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考察了二叉树的应用,其中解题的难点在于正确判断插入括号的位置,这里初次编写代码的时候选择判断T->data[0]的内容是否为运算符,若是则在中序遍历的前后按照之前的分析再先后输出左右括号,在后续的改进中发现其实可以直接判断T是否为叶子结点,如果是就按照之间的分析再先后输出左右括号即可。

问题 P: 表达式树的值

一、题目描述

读入表达式树的先序遍历字符串,求其值。运算符只可能是加减乘除,保证输入的每个子表达式树的结果都是整数值且可以用C语言的int类型表达。

二、输入

输入由多组测试数据组成。

每组数据包含一行字符串,即表达式树的先序遍历序列,字符串长度大于0且不超过100,如:

  • 13 # # * 5 # # 9 # #

* + 13 # # 5 # # 9 # #

三、输出

(13+(5*9))=58

((13+5)*9)=162

四、算法分析

根据题目描述,此题使用二叉树进行对先序遍历字符串的存储,然后通过遍历二叉树的方式输出先序遍历字符串对应的以全括号形式输出的中序遍历字符串,最后在先序遍历二叉树,根据结点存储的数据类型(操作数或运算符)进行四则运算并输出结果。

二叉树的抽象数据类型定义如下:

cpp 复制代码
ADT BinaryTree {
    数据对象D: D是具有相同特性的数据元素的集合。
    数据关系R:
        若D=φ,则R=φ,称BinaryTree为空二叉树;
        若D≠φ,则R={H}, H是如下二元关系:
        (1)在D中存在唯一的称为根的数据元素root,它在关系H下无前驱;
        (2)若D-{root)≠φ,则存在D-(root)=(D1, Dr), 且D1∩Dr=φ;
        (3)若Dl≠φ,则Dl中存在唯一的元素xl,<root,xl>∈H, 且存在Dl上的关系Hl⊂H;若Dr≠φ,则Dr中存在唯一的元素xr,<root,xr>∈H,且存在Dr上的关系Hr⊂H;H={<root, xl>,< root,xr>, Hl,Hr};
        (4)(Dl, {Hl})是一棵符合本定义的二叉树,称为根的左子树,(Dr, {Hr})是棵符合本定义的二叉树,称为根的右子树。
    基本操作P:
        InitBiTree(&Tree)        //构造空的二叉树Tree
        CreateBiTree(&Tree)      //创建二叉树Tree
        InOrderTraverse(Tree)    //中序遍历二叉树Tree
        Calculate(Tree)          //遍历并计算二叉树Tree存储表达式的值
};

五、算法实现

首先要将读入的所有运算符进行标注,以便后续运算中能够正确执行对操作数的计算,同时还要注意到读入的是先序遍历字符串,因此多位数的数字在字符串中不能表示一个正确的数字,因此需要将连续的数字进行转换,将其变为整型数字,从而可以进行后续的操作运算,如下所示:

cpp 复制代码
if (op[0] == '+') 
    data = -1;
else if (op[0] == '-') 
    data = -2;
else if (op[0] == '*') 
    data = -3;
else if (op[0] == '/') 
    data = -4;
else
    for (int i = 0; i < strlen(op); i++)
        data = data * 10 + op[i] - '0';

其次是同上一题类似的全括号表达式的输出,如下所示:

cpp 复制代码
if (T) {
    if (T->Data < 0) {
        cout << '(';
        InOrderTraverse(T->lchild);
        if (T->Data == -1) 
            cout << "+";
        else if (T->Data == -2) 
            cout << "-";
        else if (T->Data == -3) 
            cout << "*";
        else if (T->Data == -4) 
            cout << "/";
        InOrderTraverse(T->rchild);
        cout << ')';
    }
    else cout << T->Data;
}

最后是计算表达式的值,由之前做的准备工作(标注、数字转化)直接进行先序遍历计算求得结果,如下所示:

cpp 复制代码
if (T->Data >= 0)
    return T->Data;
else {
    data = Calculate(T->lchild);
    if (T->Data == -1) 
        return data + Calculate(T->rchild);
    else if (T->Data == -2) 
        return data - Calculate(T->rchild);
    else if (T->Data == -3) 
        return data * Calculate(T->rchild);
    else if (T->Data == -4) 
        return data / Calculate(T->rchild);
}

分析如下:

算法的时间复杂度为O(n)。

将数据存入二叉树的语句频度为n,中序遍历二叉树的语句频度为n,先序遍历并计算表达式的值的语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立二叉树需要开辟n个结点,故有S(n)=O(n)。

六、小结

题目考察了二叉树的应用,相比于前一道题来说这题多了计算的步骤,因此原来输入的数据类型不能直接使用字符串,而应该把数字部分进行转换,结合运算符进行运算才能得到结果。

问题 Q: 24点游戏(Ⅰ)

一、题目描述

24点游戏的玩法是这样的:任取一幅牌中的 4张牌(不含大小王),每张牌上有数字(其中A代表1,J 代表11,Q代表 12,K代表13),你可以利用数学中的加、减、乘、除以及括号想办法得到24,每张牌只能用一次。例如有四张6,那么6+6+6+6=24,也可以6*6-6-6=24。但是有些牌是无法得到24的,比如两张 A 和两张2。

读入表达式树的先序遍历字符串, 这里的表达式树是来自24点游戏的真实场景,也就是对应四个数字(值在1到13之间)组成的表达式,问该表达式树能不能得到24?

二、输入

输入由多组测试数据组成。

每组数据包含一行字符串,即24点游戏的表达式树的先序遍历序列。

三、输出

对于每组数据,输出一行。如果不能得到24,输出"NO"。如果能得到24,按样例输出。

四、算法分析

根据题目描述,此题使用二叉树进行对数据的存储,然后通过遍历二叉树的方式输出先序遍历字符串对应的以全括号形式输出的中序遍历字符串,最后在先序遍历二叉树,根据结点存储的数据类型(操作数或运算符)进行四则运算并输出结果。

二叉树的抽象数据类型定义如下:

cpp 复制代码
ADT BinaryTree {
    数据对象D: D是具有相同特性的数据元素的集合。
    数据关系R:
        若D=φ,则R=φ,称BinaryTree为空二叉树;
        若D≠φ,则R={H}, H是如下二元关系:
        (1)在D中存在唯一的称为根的数据元素root,它在关系H下无前驱;
        (2)若D-{root)≠φ,则存在D-(root)=(D1, Dr), 且D1∩Dr=φ;
        (3)若Dl≠φ,则Dl中存在唯一的元素xl,<root,xl>∈H, 且存在Dl上的关系Hl⊂H;若Dr≠φ,则Dr中存在唯一的元素xr,<root,xr>∈H,且存在Dr上的关系Hr⊂H;H={<root, xl>,< root,xr>, Hl,Hr};
        (4)(Dl, {Hl})是一棵符合本定义的二叉树,称为根的左子树,(Dr, {Hr})是棵符合本定义的二叉树,称为根的右子树。
    基本操作P:
        InitBiTree(&Tree)        //构造空的二叉树Tree
        CreateBiTree(&Tree)      //创建二叉树Tree
        InOrderTraverse(Tree)    //中序遍历二叉树Tree
        Calculate(Tree)          //遍历并计算二叉树Tree存储表达式的值
};

五、算法实现

24点游戏(Ⅰ)与上一道题的解题思路完全一致,但要注意表达式除0的情况,这种情况是由于整型数据执行除法运算时会自动向下取整,从而导致数据损失,因此需要将表达式内的数字转换为浮点型数据(double),并且还要注意等式成立的判断要考虑到double型数据的精度问题,合理设置比较表达式,如下所示:

cpp 复制代码
CreateBiTree(T);
if(fabs(Calculate(T) - 24) < 1e-6) {
    InOrderTraverse(T);
    cout << "=" << Calculate(T) << endl;
} else 
    cout << "NO" << endl;

分析如下:

算法的时间复杂度为O(n)。

将数据存入二叉树的语句频度为n,中序遍历二叉树的语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立二叉树需要开辟n个结点,故有S(n)=O(n)。

六、小结

第一个问题考察了二叉树的应用,相比于前一道题来说要额外注意表达式除0的情况,同时还要注意等式成立的判断要考虑到double型数据的精度问题,否则可能无法得到正确的解。

后续三题由于能力有限未能解答,第二题分析出来总共会产生5种表达式树,从理论上来说可以通过递归的方式以改变根节点内的符号和叶子结点数字的交换(其规律与5中表达式树的结构有关),从中感觉到自己的算法编程能力还有待提高,解决难题的能力仍有不足,在今后的学习生活中会不断努力提高自己的能力。

问题 U: 推箱子游戏-广度优先搜索版本

一、题目描述

推箱子是一款经典游戏。这里我们玩的是一个简单版本,就是在一个N*M的地图上,有1个玩家、1个箱子、1个目的地以及若干障碍,其余是空地。玩家可以往上下左右4个方向移动,但是不能移动出地图或者移动到障碍里去。如果往这个方向移动推到了箱子,箱子也会按这个方向移动一格,当然,箱子也不能被推出地图或推到障碍里。当箱子被推到目的地以后,游戏目标达成。现在告诉你游戏开始是初始的地图布局,请问玩家至少要多少步才能达成目标?

二、输入

第一行输入两个数字N,M表示地图的大小。其中0<N,M<=12。

接下来有N行,每行包含M个字符表示游戏地图。其中 . 表示空地、X表示玩家、*表示箱子、#表示障碍、@表示目的地。

三、输出

输出一个数字表示玩家最少需要移动多少步才能将游戏目标达成。当无论如何达成不了的时候,输出-1。

四、算法分析

由题可知在进行BFS时需要对当前玩家的位置和箱子的位置进行实时更新,并且每次按顺序取出当前玩家的位置和箱子的位置可以考虑运用循环队列求解。

循环队列的抽象数据类型定义如下:

cpp 复制代码
ADT SqQueue {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    约定a1端为队头,an端为队尾。
    基本操作:
        InitQueue(&Q)        //构造空的循环队列Queue
        QueueEmpty(Q)        //判断队列Queue是否为空
        QueueFull(Q)         //判断队列Queue是否为满
        EnQueue(&Q,Elem)     //将数据Elem入队
        DeQueue(&Q,&Elem)    //将数据Elem出队
};

五、算法实现

流程图如下:

首先此题需要有一个正确的判断函数来检查所有操作的合理性,如下所示:

cpp 复制代码
bool Judgestep(int x, int y) {  //判断移动的合理性
    if (x > 0 && x < X_map && y > 0 && y < Y_map && Map[x][y] != '#')
        return true;
    else
        return false;
}

之后便要实现BFS的具体算法,首先需要将起始位置信息标记,并令当前玩家位置和箱子位置入队等待,出队后要先检验箱子是否已经到达终点,如果已经到达终点就返回Step_Flag四维数组标记的序号并减去1即最优解,否则应依次判断玩家是否可以移动、玩家是否推到箱子(如果是,则判断箱子是否可以推动)等,并令当前Step_Flag标记数组的值继承上一次的值并加1以计数,重新再将当前玩家位置和箱子位置入队等待,循环执行上述操作直至队空或者检验到箱子是否已经到达终点跳出循环,如下所示:

cpp 复制代码
Step_Flag[X_start][Y_start][X_box][Y_box] = 1;    //标记
    EnQueue(Q, X_start); EnQueue(Q, Y_start);
    EnQueue(Q, X_box); EnQueue(Q, Y_box);
    while (!QueueEmpty(Q)) {
        DeQueue(Q, X_Player); 
        DeQueue(Q, Y_Player);
        DeQueue(Q, X_Box); 
        DeQueue(Q, Y_Box);
        if (X_Box == X_end && Y_Box == Y_end) {
            cout << Step_Flag[X_Player][Y_Player][X_Box][Y_Box] - 1;
            flag = 0; break;
        } else {
            for (int i = 0; i < 4; i++) {  //四个方向
                int xp = Move[i][0] + X_Player;
                int yp = Move[i][1] + Y_Player;
                if(Judgestep(xp, yp) && !Step_Flag[xp][yp][X_Box][Y_Box]) {    //判断玩家是否可以移动
                    if (xp == X_Box && yp == Y_Box) {    //判断玩家是否推到箱子(推到了)
                        int xb = Move[i][0] + X_Box;
                        int yb = Move[i][1] + Y_Box;
                        if(Judgestep(xb, yb) && !Step_Flag[xp][yp][xb][yb]) {    //判断箱子是否可以推动
                            EnQueue(Q, xp); EnQueue(Q, yp);
                            EnQueue(Q, xb); EnQueue(Q, yb);
                            Step_Flag[xp][yp][xb][yb] = Step_Flag[X_Player][Y_Player][X_Box][Y_Box] + 1;    //标记
                    }
                } else {  //判断玩家是否推到箱子(没推到)
                    EnQueue(Q, xp);
                    EnQueue(Q, yp);
                    EnQueue(Q, X_Box); 
                    EnQueue(Q, Y_Box);
                    Step_Flag[xp][yp][X_Box][Y_Box] = Step_Flag[X_Player][Y_Player][X_Box][Y_Box] + 1;    //标记
                }
            }
        }
    }
}

分析如下:

算法的时间复杂度为O(N×M)。

读入地图信息时将N×M个信息依次存入Map数组内,语句频度为N×M,进行BFS时语句频度与初值有关,最坏情况为搜索了整个地图并无解,语句频度为N×M,因此取语句频度最大的作为时间复杂度,即T(n)=O(N×M)。

算法的空间复杂度为O(N×M)。

建立二维地图需要开辟N×M的空间,故有S(n)=O(N×M)。

六、小结

本题的关键在于Step_Flag四维数组的设置,巧妙的利用该数组存放对应不同玩家位置和箱子位置时的步数,同时还有标记已经走过的作用,把具体情况分析清楚并逐个求解,即可得到最优解答案。难度适中,通过完成此题巩固了BFS算法。

问题 V: 推箱子游戏-深度优先搜索版本

一、题目描述

推箱子是一款经典游戏。这里我们玩的是一个简单版本,就是在一个N*M的地图上,有1个玩家、1个箱子、1个目的地以及若干障碍,其余是空地。玩家可以往上下左右4个方向移动,但是不能移动出地图或者移动到障碍里去。如果往这个方向移动推到了箱子,箱子也会按这个方向移动一格,当然,箱子也不能被推出地图或推到障碍里。当箱子被推到目的地以后,游戏目标达成。现在告诉你游戏开始是初始的地图布局,要求用深度优先搜索找到游戏的解(注意这里不保证步数最少)。

玩家每到一个格子,就按上(U),右(R),下(D),左(L)顺时针方向尝试,每一个方向都都在前一个方向失败时才可能尝试。如下图,如果s6为终态,则游戏解为UU; 如果s21为终态,则玩家要尝试UU,UR,UD,UL,RU,RR,RD,RL,...,LD, 才能确定LL是游戏的解。

状态由玩家位置和箱子位置构成,算法结构大体如下:

cpp 复制代码
DFS(state s){
   for(i = 0; i < 4; i++)
       DFS(trans(s, i)); //tans表示状态s往方向i走形成的新状态
}

注意得到解后要立即返回。

二、输入

第一行输入两个数字N,M表示地图的大小。其中0<N,M<=12。

接下来有N行,每行包含M个字符表示游戏地图。其中 . 表示空地、X表示玩家、*表示箱子、#表示障碍、@表示目的地。

三、输出

有解时,输出玩家走的每一步。当无论如何达成不了的时候,输出-1。

四、算法分析

本题要求使用DFS算法实现对每条路线的求解,这里能给出以下两种求解方法:

1、用栈实现:

分析可知玩家每到达一个位置时都会向四个方向分别尝试,而按DFS算法是优先确定一条路线的可行性,然后通过回溯的方式回到上一个分叉点并尝试另一个方向,这里可以通过栈把每种不同的移动方式存入栈中,如果需要回溯到上一步就将栈顶元素出栈,最后栈内存放的操作即为游戏的解。

顺序栈的抽象数据类型定义如下:

cpp 复制代码
ADT Stack {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    约定an端为栈顶,a1端为栈底。
    基本操作:
        InitStack(&S)        //构造空的顺序栈Stack
        StackEmpty(S)        //判断栈Stack是否为空
        StackFull(S)         //判断栈Stack是否为满
        GetTop(S)            //获取Stack栈的栈顶元素
        Push(&S)             //将数据元素压入栈Stack
        Pop(&S)              //将数据元素弹出栈Stack
};
2、 用递归实现:

与栈类似,此题也可以选择构造DFS函数,以当前玩家的位置和箱子位置作为形式参数,每尝试一种方向的移动后都将更新后的玩家位置和箱子位置的数据作为DFS函数的新的参数,递归调用DFS函数,如果在某一条路径上能抵达终点就返回1,同时也回溯到上一步操作并将此次移动的方向存入路径内;否则返回0,同时也回溯到上一步操作,据此将游戏的解查找出来。

递归函数设计方案如下

cpp 复制代码
int DFS(int X_Player, int Y_Player, int X_Box, int Y_Box) {
    if(箱子到达终点) 
        return 1;
    else
        向四个方向循环遍历;
        if(移动合理)
            标记并递归DFS(X_Player, Y_Player, X_Box, Y_Box);
            if(DFS)
                将当前操作存入路径并回溯到上一步操作, return 1;
    return 0;
}

五、算法实现

此题与上一道BFS算法有所不同,所求解不保证是最优解,并且按照DFS算法查找可行的路径,判断检查所有操作的合理性的函数同上一题,如下所示;

cpp 复制代码
bool Judgestep(int x, int y) {   //判断移动的合理性
    if (x > 0 && x < X_map && y > 0 && y < Y_map && Map[x][y] != '#')
        return true;
    else
        return false;
}

实现DFS的方法按照递归函数设计方案设计,如下所示:

cpp 复制代码
int DFS(int X_Player, int Y_Player, int X_Box, int Y_Box) {
    if (X_Box == X_end && Y_Box == Y_end) {
        Way[Index] = '\0'; 
        return 1;
    } else {
        for (int i = 0; i < 4; i++) {   //四个方向
            int xp = Move[i][0] + X_Player;
            int yp = Move[i][1] + Y_Player;
            if(Judgestep(xp,yp)&&!Step_Flag[xp][yp][X_Box][Y_Box]) { //判断玩家是否可以移动
                if (xp == X_Box && yp == Y_Box) {   //判断玩家是否推到箱子(推到了)
                    int xb = Move[i][0] + X_Box;
                    int yb = Move[i][1] + Y_Box;
                    if(Judgestep(xb,yb)&&!Step_Flag[xp][yp][xb][yb]) { //判断箱子是否可以推动
                        Step_Flag[xp][yp][xb][yb] = 1;    //标记
                        Count = DFS(xp, yp, xb, yb);     //递归进入下个位置直到抵达终点或无解
                        if (Count == 1) {   //抵达终点
                            Way[Index++] = Direction[i];
                            Count = 0; 
                            return 1;
                        }
                    }
                } else {   //判断玩家是否推到箱子(没推到)
                    Step_Flag[xp][yp][X_Box][Y_Box] = 1;    //标记
                    Count = DFS(xp, yp, X_Box, Y_Box);      //递归进入下个位置直到抵达终点或无解
                    if (Count == 1) {   //抵达终点
                        Way[Index++] = Direction[i];
                        Count = 0; 
                        return 1;
                    }
                }
            }
        }
    }
    Way[Index] = '\0';
    return 0;
}

分析如下:

算法的时间复杂度为O(N×M)。

读入地图信息时将N×M个信息依次存入Map数组内,语句频度为N×M,进行DFS时语句频度与初值有关,最坏情况为搜索了整个地图并无解,语句频度为N×M,因此取语句频度最大的作为时间复杂度,即T(n)=O(N×M)。

算法的空间复杂度为O(N×M)。

建立二维地图需要开辟N×M的空间,故有S(n)=O(N×M)。

六、小结

本题与上一题相似,利用该数组标记对应不同玩家位置和箱子位置,把具体情况分析清楚并逐个求解,同时运用了递归的思想实现DFS,遍历完所有的路径最后确定游戏的解。通过完成此题巩固了递归函数和DFS算法。

问题 W: 带权路径长度

一、题目描述

给定n个权值作为n个叶子结点,构造哈夫曼树, 求其带权路径长度。

二、输入

输入由多组数据组成。

每组数据分成两行。第一行仅一个整数n(2<=n<=100000)。第二行有n个空格分开的权值,值范围在[1,1000000000]之间。

三、输出

对于每组测试数据,输出一行,即其对应哈夫曼树的带权路径长度对1000000007取模。

四、算法分析

此题最直接的方式就是构建Huffman树后直接求解WPL,但经过测试后这种方法存在时间超限的问题,因此需要选择效率更高的算法实现,通过分析构建Huffman树的过程,每次从数列中取出最小的两个数求和,并将和值返回到数列中,重复操作直到只剩下一个数时,Huffman树便构建完成,这里剩下的这个数经验证可知即为WPL的值。这种不断从数列中取出最小数并返回数的操作可以由小根堆来实现。

堆的抽象数据类型定义如下:

cpp 复制代码
ADT Heap {
    数据对象:D={ai|ai∈Elemset,i=1,2,...,n,n≥0}
    数据关系:R={<ai-1,ai>|ai-1,ai∈D,i=2,...,n}
    基本操作:
        CreateHeap(&H)        //构造空的堆Heap
        HeapAdjust(&H)        //调整堆Heap
        HeapDelete(&H)        //删除堆Heap的堆顶元素
        GetHeap(H)            //获取堆Heap的堆顶元素
        GetBack(&H)           //将数据返回堆Heap
};

五、算法实现

流程演示如下(以1,2,3,4为例):

与上文分析一致,根据流程图的演示操作实现算法,为了防止运算数据过大,将每次计算的结果都如题要求与1000000007取余,核心算法如下所示:

cpp 复制代码
void HeapAdjust() {
    for (ll i = n / 2; i; --i) 
        Delete(i);
}

void Delete(ll num) {
    ll temp = Heap[num], Root, Leaves;
    for (Root = num; 2 * Root <= n; Root = Leaves) {
        Leaves = Root << 1;
        if (Leaves != Heapsize && Heap[Leaves + 1] < Heap[Leaves])
            Leaves++;
        if (Heap[Leaves] < temp) 
            Heap[Root] = Heap[Leaves];
        else 
            break;
    }
    Heap[Root] = temp;
}

ll GetHeap() {
    ll min = Heap[1], temp = Heap[Heapsize--], Root, Leaves;
    for (Root = 1; 2 * Root <= Heapsize; Root = Leaves) {
        Leaves = Root << 1;
        if (Leaves != Heapsize && Heap[Leaves + 1] < Heap[Leaves])
            Leaves++;
        if (Heap[Leaves] < temp) 
            Heap[Root] = Heap[Leaves];
        else 
            break;
    }
    Heap[Root] = temp; return min;
}

void GetBack(ll sum) {
    ll i;
    for (i = ++Heapsize; i != 1 && sum < Heap[i / 2]; i /= 2)
        Heap[i] = Heap[i / 2];
    Heap[i] = sum;
}

分析如下:

算法的时间复杂度为O(nlog2n)。

设有n个记录的初始序列所对应的完全二叉树的深度为h,初建堆时,每个非终端节点都要自上而下进行"筛选"。由于第i层上的结点数小于等于2i-1,且第i层结点最大下移的深度为h-i,每下移一层要做两次比较,所以建初堆时关键字总的比较次数为:

调整建新堆时要做n-1次"筛选",每次"筛选"都要将根结点下移到合适的位置。n个结点的完全二叉树的深度为⌊log2n⌋+1,则重建堆时关键字总的比较次数不超过:

因此取语句频度最大的作为时间复杂度,即T(n)=O(nlog2n)。

算法的空间复杂度为O(n)。

建立堆需要开辟n个空间,故有S(n)=O(n)。

六、小结

本题考察了WPL的相关计算方法,一开始选择使用构造Huffman树的方式求解,但当构造的Huffman树极不平衡时(即读入序列有序),时间复杂度达到了O(n²),从而导致时间超限,因此改用了平均效率更高的小根堆。利用WPL+=min1+min2的方式(每次求和后,边权就包含在了和值内,这样就实现了对应层的数能匹配到对应的权)即可求得正确的WPL。

问题 X: 自来水管道

一、题目描述

你领到了一个铺设校园内自来水管道的任务。校园内有若干需要供水的点,每两个供水点可能存在多种铺设路径。对于每一种铺设路径,其成本是预知的。

任务要求最终铺设的管道保证任意两点可以直接或间接的联通,同时总成本最低。

二、输入

每个测试用例由多行组成,第一行是两个整数P和R,P代表供水点数(1<=P<=50),每个点都对应1到P中的一个唯一编号。R代表可能的铺设路径数,路径数可能有非常多。接下有R行,每行格式如下:

节点A编号 节点B编号 路径成本

路径成本不超过100。

测试用例之间有一空行分开。输入结束用P=0表示,注意没有R值。

三、输出

每个测试用例占用一行输出最低总成本。

四、算法分析

此题要求计算出所有预知成本的铺设路径中的最低总成本,可用最小生成树算法求解,这里可以选用Prim算法或Kruskal算法的二者之一,本题选用kruskal算法求解最小生成树。

图的抽象数据类型定义如下:

cpp 复制代码
ADT Graph {
    数据对象:V是具有相同特性的数据元素的集合,成为顶点集。
    数据关系:
        R={VR}
        VR={<v,w>|v,w∈V且P(v,w)<v,w>表示从v到w的弧,
        谓词P(v,w)定义了弧<v,w>的意义或信息}
    基本操作:
        CreateGraph(&G,V,VR)   //构造空的图Graph
        GetVex(G,v)            //获取图Graph的顶点v
        InsertVex(&G,v)        //在图Graph中添加顶点v
        InsertArc(&G,v,w)      //在图Graph中添加弧<v,w>,<w,v>
        Kruscal(G)             //对图Graph求解最小生成树
};

五、算法实现

流程演示如下(以第二组测试数据为例):

对于任意一个连通网的最小生成树来说,在要求总权值最小的情况下,最直接的方法是将连通网中的所有边按照权值大小进行升序排序,从小到大依次选择即可。而由于最小生成树本身是一棵生成树,所以需要注意以下两点:①生成树中任意顶点之间有且仅有一条通路,即生成树中不能存在回路;②对于具有n个顶点的连通网,其生成树中只能有n-1条边,这n-1条边连通着n个顶点。连接n个顶点在不产生回路的情况下,只需要n-1条边。

由上述分析可知具体实现方法为:将所有边按照权值的大小进行升序排序,然后从小到大逐个判断这个边能不能与之前选择的所有边组成回路,若可以则作为最小生成树的一部分;反之舍去该边,直到具有n个顶点的连通网筛选出来n-1条边为止。但筛选出来的边和所有的顶点构成此连通网的最小生成树在其中还需判断是否有回路,在初始状态下给每个顶点赋予不同的标记,对于遍历过程的每条边都有两个顶点,判断这两个顶点的标记是否一致,如果一致,说明它们本身就处在一棵树中,如果继续连接就会产生回路;如果不一致,说明它们之间还没有任何关系,便可以连接。

核心算法如下所示:

cpp 复制代码
void Kruskal(int P, int R, int& Cost) {
    for (int i = 1; i <= P; i++) 
        Vertex[i] = i; //给每个节点按序编号
    for (int i = 1; i <= R; i++) {
        if (Search(Data[i].V[0]) != Search(Data[i].V[1])) {    //若节点不连通就将其加入到最小生成树中,同时累加路径成本
            Cost += Data[i].V[2];
            Vertex[Search(Data[i].V[0])] = Search(Data[i].V[1]);
        }    //将一个点的尾节点指向另一个点的尾节点
    }
}

分析如下:

算法的时间复杂度为O(n)。

输入数据时循环读入了n条边,语句频度为n,排序算法的语句频度为log2n,初始化连通分量的语句频度为n,通过遍历计算总权值的语句频度也为n,因此取语句频度最大的作为时间复杂度,即T(n)=O(n)。

算法的空间复杂度为O(n)。

建立结构体数组和记录连通分量的数组分别需要开辟3n、n个空间,故有S(n)=O(n)。

六、小结

本题考察了图的应用,其核心是运用Prim算法或Kruskal算法求解最小生成树,由于此题读取的边数可以远多于顶点数,因此要通过设计筛选算法以保留最小权值边,从而顺利实现最小生成树的求解。

问题 Y: 最小时间

一、题目描述

有多个城市组成一个铁路交通网络。任意两个城市之间有直连铁路,或者通过其他城市间接到达。给定某个城市,要求在M时间内能从该城市到达任意指定的另一城市,求最小的M。

二、输入

输入由多组测试用例组成

每个测试用例由多行组成,第一行是整数n(1 <= n <= 100),表示城市的数目。

其余行表示邻接矩阵A。A(i,j)的值如果是一个整数t,表示城市i与城市j有铁路直连,需要t时间到达另一方。如果A(i,j)的值为x,表明城市i与城市j之间没有直连铁路。很明显有A(i,i) = 0。

由于对称关系和A(i,i) 为 0,输入只给出矩阵的下三角。第一行A(1,1)在输入中省略,第二行只有A(2,1),下一行则是A(3,1) 和A(3,2),依此类推。

三、输出

输出城市1所对应的最小M。

四、算法分析

此题要求计算出所有已知时间的连通路径中能到达任意城市的最小时间,可用最短路径算法求解,这里可以选用Dijkstra算法或Floyd算法的二者之一,本题选用Dijkstra算法求解最短路径。

图的抽象数据类型定义如下:

cpp 复制代码
ADT Graph {
    数据对象:V是具有相同特性的数据元素的集合,成为顶点集。
    数据关系:
        R={VR}
        VR={<v,w>|v,w∈V且P(v,w)<v,w>表示从v到w的弧,
        谓词P(v,w)定义了弧<v,w>的意义或信息}
    基本操作:
        CreateGraph(&G,V,VR)   //构造空的图Graph
        GetVex(G,v)            //获取图Graph的顶点v
        InsertVex(&G,v)        //在图Graph中添加顶点v
        InsertArc(&G,v,w)      //在图Graph中添加弧<v,w>,<w,v>
        Dijkstra(G)            //对图Graph求解最短路径
};

五、算法实现

流程演示如下(以测试数据为例):

首先给出邻接矩阵及相关变量名含义解释:

cpp 复制代码
typedef int ArcType;
typedef int VerTexType;
#define MaxInt 10000
#define MVNum 200
#define Max 10000
ArcType Matrix[MVNum][MVNum];    //邻接矩阵
bool S[MVNum];
//从源点V0到终点Vi是否已被确定最短路径长度
VerTexType Path[MVNum];
//从源点V0到终点Vi的当前最短路径上Vi的直接前驱顶点序号
VerTexType D[MVNum];
//从源点V0到终点Vi的当前最短路径长度
int MIN;

然后按题目要求构造函数,以n阶矩阵的下三角形式依次读入两城市之间的耗时,注意到读入的为字符型数据,需要注意将字符型的数字数据转换为整型数据后再存入邻接矩阵中,如果两城市之间无连接时便将相应位置赋值MaxInt(题目中两城市之间的耗时不会取到的正值),读入下三角数据后将上三角的对称位置赋相同的值,如下所示:

cpp 复制代码
void CreateMatrix(int n) {
    char ch[Max];
    for (int i = 1; i <= n; i++) {
        Matrix[i][i] = 0;
        for (int j = 1; j <= n; j++) {
            if (i > j) {
                cin >> ch;
                VerTexType num = 0;
                if (ch[0] == 'x')
                    Matrix[i][j] = Matrix[j][i] = MaxInt;
                else {
                    for (int k = 0; k < strlen(ch); k++)
                        num = num * 10 + ch[k] - '0';
                    Matrix[i][j] = Matrix[j][i] = num;
                }
            }
        }
    }
}

再对构造完成的邻接矩阵进行Dijkstra算法求最短路径,此处的方法与教材提供的算法代码基本相同,但注意到之前存储邻接矩阵时去掉了下标有0的左边界和上边界,因此在Dijkstra算法的具体操作中取值时应注意做出适当调整,最后用全局变量MIN存放每次从城市1到任意指定的另一城市的最短时间,循环更新MIN的值直到找到这些最短时间里最长的时间即为所求的最小时间,如下所示:

cpp 复制代码
void Dijkstra(int n) {
    int VerTexNum = n, min, v;
    for (int V = 1; V <= VerTexNum; V++) {
        S[V] = false; D[V] = Matrix[1][V];
        if (D[V] < MaxInt) Path[V] = 1;
        else Path[V] = -1;
    }
    S[1] = true; D[1] = 0;
    for (int i = 2; i <= VerTexNum; i++) {
        min = MaxInt;
        for (int w = 1; w <= VerTexNum; w++)
            if (!S[w] && D[w] < min) v = w, min = D[w];
        S[v] = true;
        for (int w = 1; w <= VerTexNum; w++) {
            if (!S[w] && (D[v] + Matrix[v][w] < D[w])) {
                D[w] = D[v] + Matrix[v][w]; 
                Path[w] = v;
            }
        }
    }
    for (int i = 2; i <= VerTexNum; i++)
        if (MIN <= D[i]) MIN = D[i];
}

分析如下:

算法的时间复杂度为O(n²)。

构造邻接矩阵的语句频度为n²/2,Dijkstra算法求解最短路径的主循环共进行n- 1次,每次执行的时间是O(n),所以算法的时间复杂度是O(n²)。如果用带权的邻接表作为有向图的存储结构,则虽然修改D的时间可以减少,但由于在D向量中选择最小分量的时间不变,所以其时间复杂度依然为0(n²)。因此取语句频度最大的作为时间复杂度,即T(n)=O(n²)。

算法的空间复杂度为O(n²)。

构造邻接矩阵需要开辟n²个空间,故有S(n)=O(n²)。

六、小结

本题考察了图的应用,其核心是运用Dijkstra算法或Floyd算法求解最短路径,题目未考查图的其他操作,核心算法可以套用教材模板完成,也巩固了最短路径算法。

相关推荐
爱吃生蚝的于勒2 小时前
C语言内存函数
c语言·开发语言·数据结构·c++·学习·算法
ChoSeitaku7 小时前
链表循环及差集相关算法题|判断循环双链表是否对称|两循环单链表合并成循环链表|使双向循环链表有序|单循环链表改双向循环链表|两链表的差集(C)
c语言·算法·链表
Fuxiao___7 小时前
不使用递归的决策树生成算法
算法
我爱工作&工作love我7 小时前
1435:【例题3】曲线 一本通 代替三分
c++·算法
白-胖-子8 小时前
【蓝桥等考C++真题】蓝桥杯等级考试C++组第13级L13真题原题(含答案)-统计数字
开发语言·c++·算法·蓝桥杯·等考·13级
workflower8 小时前
数据结构练习题和答案
数据结构·算法·链表·线性回归
好睡凯8 小时前
c++写一个死锁并且自己解锁
开发语言·c++·算法
Sunyanhui18 小时前
力扣 二叉树的直径-543
算法·leetcode·职场和发展
一个不喜欢and不会代码的码农8 小时前
力扣105:从先序和中序序列构造二叉树
数据结构·算法·leetcode
前端郭德纲8 小时前
浏览器是加载ES6模块的?
javascript·算法