다수결 투표는 각 투표자가 하나의 후보자를 고르고, 가장 많은 표를 얻은 후보자가 승리하는 아주 단순한 시스템이다. 하지만 최다 득표자가 여러 명인 경우, 한 명의 승자를 뽑을 수 없다는 문제점이 있다.
이에 대한 대안으로 순위선택투표 시스템이 있다. 동점자가 있는 경우, 가장 적은 득표를 가진 후보를 제거하고 그 후보를 뽑은 투표자가 차선으로 뽑은 후보자에게 표가 던져지는 방식으로 결선을 하여 승자를 가려내는 방식이다. 또한 다수결 투표의 다른 잠재적인 결함도 예방할 수 있다.
위와 같은 투표의 결과는 어떻게 될까? 다수결 투표 시스템에서는 4표를 얻은 Charlie가 승리하며, 즉각결선투표 시스템을 따른다고 해도 Charlie가 승리하게 된다. 하지만 Alice는 자신이 승자가 되어야 한다고 최종 변론할 여지가 있다. 9명 중 5명이 Charlie보다 Alice를 선호하기 때문이다.
이 투표에서 Alice는 이른바 ‘콩도르세 승자(Condorcet winner)’가 된다. 일대일 대결에서 항상 승리하는 후보자를 의미한다. Alice는 Bob과의 대결에서도, Charlie와의 대결에서도 항상 승리한다.
Tideman 투표 시스템은 콩도르세 승자가 존재할 경우 그 후보자가 승리할 수 있도록 보장하는 순위선택투표의 한 형태이다.
쉽게 설명하면 Tideman 투표 시스템은 화살표로 이루어진 후보자들의 ‘그래프’를 그리며 작동한다. 후보 A에서 B로 향하는 화살표는 A와 B의 일대일 대결에서 A가 승자임을 의미한다. 위 투표의 결과를 그래프로 나타낸 그림은 다음과 같다.
그래프의 ‘원천’이 되는 후보자가 승자가 된다. 어떤 화살표도 자신을 가리키지 않는, 즉 일대일 대결에서 항상 승리하는 후보자가 최종 승자이다. 따라서 이 투표의 승자는 Alice이다.
하지만 위와 같은 경우가 존재할 수 있다. 마치 가위바위보 게임처럼 모든 후보자를 이기는 승자가 없는 경우이다. Tideman 알고리즘은 이러한 사이클이 생기는 것을 방지해야 한다. 이를 위해서 가장 강력한 화살표부터 하나씩 고정시킨다. 사이클이 생기지 않으면 화살표를 추가하고, 사이클이 생긴다면 그 화살표는 무시한다.
Alice와 Bob의 일대일 결과는 7-2로, 가장 큰 격차를 가지므로 첫 번째로 고정되는 화살표가 된다. 그 다음으로는 6-3인 Charlie 대 Alice의 대결이 두 번째 화살표가 된다. 그 다음은 5-4인 Bob 대 Charlie의 대결인데, 이 화살표를 추가하면 그래프는 사이클이 생기게 된다. 그러므로 이 단계는 무시한다.
이 단계를 하나씩 그림으로 나타내면 위와 같다. Charlie를 가리키는 화살표가 없으므로, 즉 Charlie가 이 그래프의 ‘원천’이 되었기 때문에 승자는 Charlie가 된다.
정리하면 Tideman 투표 시스템은 세 가지 단계로 이루어진다.
그래프가 완성되면, 그래프의 원천이 되는 후보자가 승자가 된다.
아래 예시와 같이 Tideman 시스템에 따라 동작하는 투표 프로그램을 작성한다.
./tideman Alice Bob Charlie
Number of voters: 5
Rank 1: Alice
Rank 2: Charlie
Rank 3: Bob
Rank 1: Alice
Rank 2: Charlie
Rank 3: Bob
Rank 1: Bob
Rank 2: Charlie
Rank 3: Alice
Rank 1: Bob
Rank 2: Charlie
Rank 3: Alice
Rank 1: Charlie
Rank 2: Alice
Rank 3: Bob
Charlie
int
타입의 2차원 preference
배열 : preferences[i][j]
는 후보 i
를 후보 j
보다 선호하는 투표자의 수를 의미한다.boolean
타입의 2차원 locked
배열 : locked[i][j]
는 후보 i
가 후보 j
를 가리키는 화살표가 존재하는 것을 의미한다(인접 행렬).pair
구조체 : 후보자 페어를 나타낸다. winner
는 승자의 인덱스를, loser
은 패자의 인덱스를 저장한다.string
타입의 candidates
배열에 저장되며, 각 페어에 대한 결과는 pairs
배열에 저장된다.pair_count
및 candidate_count
: 각각 페어의 수와 후보자의 수를 저장한다.Main()
candidate_count
를 결정한 뒤, candidate_count
만큼 루프를 돌아 locked
의 각 원소를 false
로 초기화한다. 즉 아직 고정된 화살표가 없다는 것을 의미한다.ranks
에 저장한다(vote
호출). ranks[i]
는 한 투표자가 i
번째로 선호하는 후보자의 인덱스를 의미한다. record_preference
함수는 ranks
를 인수로 전달받아 전역 변수인 preferences
를 업데이트 한다.add_pairs
를 호출하여 모든 후보자 페어를 pairs
에 추가한 뒤 sort_pairs
를 호출하여 정렬하고, lock_pairs
를 통해 그래프를 완성한다. 마지막으로 print_winner
를 호출하여 최종 승자의 이름을 출력한다.vote
함수를 완성한다.rank
, name
, ranks
를 인수로 받는다.name
이 유효한 이름이면 ranks
를 업데이트한다.ranks[i]
는 투표자가 i
번째 선호한 후보자를 의미한다.true
를 반환하고, 그렇지 않다면 false
를 반환한다(예를 들어 인수로 받은 name
이 유효한 이름이 아닌 경우).record_preferences
함수를 완성한다.ranks
를 인수로 받는다.preferences
를 업데이트한다. preferences[i][j]
는 후보 i
를 후보 j
보다 선호하는 투표자의 수를 의미한다.add_pairs
함수를 완성한다.pairs
를 업데이트한다. 만약 페어가 동점인 경우에는 업데이트하지 않는다.pair_count
를 업데이트한다. 모든 페어는 pairs[0]
~ pairs[pair_count - 1]
범위에 저장된다.sort_pairs
함수를 완성한다.pairs
배열을 정렬한다. 승리의 강도란 그 후보자를 선호한 투표자의 수를 의미한다. 만약 같은 강도를 가진 페어가 여러 개 있다면, 그 페어들은 정렬하지 않아도 된다.lock_pairs
함수를 완성한다.print_winner
함수를 완성한다.제공된 소스코드에서 vote
, record_preferences
, add_pairs
, sort_pairs
, lock_pairs
, print_winner
함수 외에는 수정해서는 안된다. 단 헤더 파일이나 지시사항을 위반하지 않는 선에서의 다른 함수의 추가는 허용된다.
bool vote(int rank, string name, int ranks[])
{
for (int i = 0; i < candidate_count; i++)
{
if (strcmp(name, candidates[i]) == 0)
{
ranks[rank] = i;
return true;
}
}
return false;
}
void record_preferences(int ranks[])
{
for (int i = 0; i < candidate_count - 1; i++)
{
for (int j = i + 1; j < candidate_count; j++)
{
preferences[ranks[i]][ranks[j]]++;
}
}
return;
}
void add_pairs(void)
{
for (int i = 0; i < candidate_count - 1; i++)
{
for (int j = i + 1; j < candidate_count; j++)
{
if (preferences[i][j] > preferences[j][i])
{
pairs[pair_count].winner = i;
pairs[pair_count].loser = j;
pair_count++;
}
else if (preferences[i][j] < preferences[j][i])
{
pairs[pair_count].winner = j;
pairs[pair_count].loser = i;
pair_count++;
}
}
}
return;
}
{0, 1, 2, 3} 4명의 후보가 존재할 때 생성되는 페어 :
01 12 23
02 13
03
강의에서 배운 정렬 알고리즘은 버블 정렬, 선택 정렬, 합병 정렬이었고 그 중에서도 합병 정렬의 속도가 탁월하다고 했다. 하지만 합병 정렬을 쓰려면 sort_pairs()에 파라미터를 설정하는게 좋은데 이미 void로 선언되어 있어서 같은 분할 정복 정렬 중 하나인 qsort()를 선택했다.
void sort_pairs(void)
{
qsort(pairs, pair_count, sizeof(pair), compare_pair);
return;
}
int compare_pair(const void *a, const void *b)
{
const pair *pa = (const pair *)a;
const pair *pb = (const pair *)b;
int margin_a = preferences[pa->winner][pa->loser] - preferences[pb->winner][pb->loser];
int margin_b = preferences[pb->winner][pb->loser] - preferences[pa->winner][pa->loser];
if (margin_a > margin_b)
{
return -1;
}
else if (margin_a < margin_b)
{
return 1;
}
else
{
return 0;
}
}
return margin_b - margin_a;
void lock_pairs(void)
{
for (int i = 0; i < pair_count; i++)
{
if (!cycle(pairs[i].winner, pairs[i].loser))
{
locked[pairs[i].winner][pairs[i].loser] = true;
}
}
return;
}
bool cycle(int start, int edge)
{
if (start == edge)
{
return true;
}
for (int i = 0; i < candidate_count; i++)
{
if (locked[edge][i] == true)
{
if (cycle(start, i))
{
return true;
}
}
}
return false;
}
void print_winner(void)
{
for (int i = 0; i < candidate_count; i++)
{
int false_count = 0;
for (int j = 0; j < candidate_count; j++)
{
if (locked[j][i] == false)
{
false_count++;
}
if (false_count == candidate_count)
{
printf("%s\n", candidates[i]);
}
}
}
return;
}
강의에서는 Mario 과제(별찍기 문제와 유사)를 예를 들며 재귀함수를 이용하면 반복문을 쓰는 것보다 코드가 간결하고 보기 좋다고 했지만, 재귀함수가 익숙하지 않은 나에게는 정말 반복문보다 좋은 것이 맞나? 생각이 들었다.
비교를 위해 ChatGPT에게 cycle()함수를 재귀함수 대신 for 루프를 이용한 반복문으로 바꿔달라고 요청했다.
bool cycle(int start, int edge)
{
// Create a stack to keep track of nodes to visit
int stack[MAX];
int top = -1;
// Create a boolean array to track visited nodes
bool visited[candidate_count];
for (int i = 0; i < candidate_count; i++)
{
visited[i] = false;
}
// Push the edge node onto the stack
stack[++top] = edge;
// Loop through all nodes in the stack
while (top >= 0)
{
// Pop the top node from the stack
int node = stack[top--];
// Check if the node has been visited before
if (visited[node])
{
continue;
}
// Mark the node as visited
visited[node] = true;
// Check if the node is the start node
if (node == start)
{
return true;
}
// Push all nodes that are locked over the current node onto the stack
for (int i = 0; i < candidate_count; i++)
{
if (locked[node][i])
{
stack[++top] = i;
}
}
}
// If no cycles are found, return false
return false;
}
과제 체크 프로그램인 check50에 넣어보니 통과한다.
코드가 복잡해졌다. 재귀함수에서는 조건문이 true인 경우 그 안에서 다시 자신을 호출함으로써 재귀함수끼리 연결되어있기 때문에 진행 상태를 별도의 변수에 저장할 필요가 없었다. 하지만 반복문을 사용하니 stack, top, visited 변수가 추가되었고 while 루프 제어를 위한 조건문도 추가되었다.
반복문이 시간 및 공간 효율이 더 좋기 때문에 대부분의 경우에는 반복문을 사용하는 것이 좋고, 재귀함수는 피보나치 수열이나 팩토리얼처럼 작고 비슷한 문제들을 해결하는 방식에 의존하는 경우에 유용하다고 한다.
재귀함수는 트리나 그래프에 대한 작업에도 유용한데, cycle()도 트리 순회 알고리즘이라는 것 같다. 이번 과제에서는 여기까지만 알아보고 알고리즘을 공부하고 다시 보는게 좋을 것 같다.
lock_pairs()와 cycle()에서 막히는 바람에 계획했던 일정을 넘겨버렸다. 너무 안 풀리니까 집중이 안돼서 버린 시간도 많았다. 꼭 내 힘으로 풀어야지! 하는 마음이었는데, 별로 좋은 공부 전략은 아닌 것 같다. 너무 쉽게 포기해도 문제겠지만 현재 나의 실력으로는 어렵다는 것을 빨리 깨닫는 것도 중요한 것 같다.
다음부터는 한 섹션에 1주일만 할당하고 풀리지 않으면 적당한 선에서 마무리 하고 솔루션을 찾아봐야겠다.